diff --git a/cmd/qbtrade/main.go b/cmd/qbtrade/main.go new file mode 100644 index 0000000..c274c97 --- /dev/null +++ b/cmd/qbtrade/main.go @@ -0,0 +1,7 @@ +package main + +import "git.qtrade.icu/lychiyu/qbtrade/pkg/cmd" + +func main() { + cmd.Execute() +} diff --git a/config/atrpin.yaml b/config/atrpin.yaml new file mode 100644 index 0000000..18b6c67 --- /dev/null +++ b/config/atrpin.yaml @@ -0,0 +1,40 @@ +sessions: + max: + exchange: &exchange max + envVarPrefix: max + +persistence: + json: + directory: var/data + redis: + host: 127.0.0.1 + port: 6379 + db: 0 + +exchangeStrategies: +- on: *exchange + atrpin: + symbol: BTCUSDT + interval: 5m + window: 14 + multiplier: 100.0 + minPriceRange: 20% + amount: 100 + +backtest: + startTime: "2018-10-01" + endTime: "2018-11-01" + symbols: + - BTCUSDT + sessions: + - *exchange + # syncSecKLines: true + accounts: + max: + makerFeeRate: 0.0% + takerFeeRate: 0.075% + balances: + BTC: 1.0 + USDT: 10_000.0 + + diff --git a/config/audacitymaker.yaml b/config/audacitymaker.yaml new file mode 100644 index 0000000..36ecaa8 --- /dev/null +++ b/config/audacitymaker.yaml @@ -0,0 +1,21 @@ +persistence: + json: + directory: var/data + redis: + host: 127.0.0.1 + port: 6379 + db: 0 + +sessions: + binance: + exchange: binance + envVarPrefix: binance +# futures: true + +exchangeStrategies: +- on: binance + audacitymaker: + symbol: ETHBUSD + orderFlow: + interval: 1m + quantity: 0.01 diff --git a/config/autoborrow.yaml b/config/autoborrow.yaml new file mode 100644 index 0000000..87bb105 --- /dev/null +++ b/config/autoborrow.yaml @@ -0,0 +1,38 @@ +--- +exchangeStrategies: +- on: binance + autoborrow: + interval: 30m + autoRepayWhenDeposit: true + + # minMarginRatio for triggering auto borrow + # we trigger auto borrow only when the margin ratio is above the number + minMarginLevel: 3.0 + + # maxMarginRatio for stop auto-repay + # if the margin ratio is high enough, we don't have the urge to repay + maxMarginLevel: 20.0 + + marginRepayAlert: + slackMentions: + - '<@USER_ID>' + - '' + + marginLevelAlert: + interval: 5m + minMargin: 2.0 + slackMentions: + - '<@USER_ID>' + - '' + + assets: + - asset: ETH + low: 3.0 + maxQuantityPerBorrow: 1.0 + maxTotalBorrow: 10.0 + + - asset: USDT + low: 1000.0 + maxQuantityPerBorrow: 100.0 + maxTotalBorrow: 10.0 + diff --git a/config/autobuy.yaml b/config/autobuy.yaml new file mode 100644 index 0000000..4e55e08 --- /dev/null +++ b/config/autobuy.yaml @@ -0,0 +1,22 @@ +--- +exchangeStrategies: + - on: max + # automaticaly buy coins when the balance is lower than the threshold + autobuy: + symbol: MAXTWD + schedule: "@every 1s" + threshold: 200 + # price type: LAST, BUY, SELL, MID, TAKER, MAKER + priceType: BUY + + # order quantity or amount + # quantity: 100 + amount: 800 + + # skip if the price is higher than the upper band + bollinger: + interval: 1m + window: 21 + bandWidth: 2.0 + + dryRun: true diff --git a/config/backtest.yaml b/config/backtest.yaml new file mode 100644 index 0000000..d41388b --- /dev/null +++ b/config/backtest.yaml @@ -0,0 +1,23 @@ +--- +backtest: + startTime: "2022-01-01" + endTime: "2022-01-02" + symbols: + - BTCUSDT + sessions: + - binance + - ftx + - max + - kucoin + - okex + +exchangeStrategies: +- on: binance + grid: + symbol: BTCUSDT + quantity: 0.001 + gridNumber: 100 + profitSpread: 1000.0 # The profit price spread that you want to add to your sell order when your buy order is executed + upperPrice: 40_000.0 + lowerPrice: 20_000.0 + diff --git a/config/binance-margin.yaml b/config/binance-margin.yaml new file mode 100644 index 0000000..61cb4ae --- /dev/null +++ b/config/binance-margin.yaml @@ -0,0 +1,27 @@ +--- +sessions: + # cross margin + binance_margin: + exchange: binance + margin: true + + # isolated margin + binance_margin_linkusdt: + exchange: binance + margin: true + isolatedMargin: true + isolatedMarginSymbol: LINKUSDT + + binance_margin_dotusdt: + exchange: binance + margin: true + isolatedMargin: true + isolatedMarginSymbol: DOTUSDT + +exchangeStrategies: + +- on: binance_margin_linkusdt + dummy: + symbol: LINKUSDT + interval: 1m + diff --git a/config/bollgrid.yaml b/config/bollgrid.yaml new file mode 100644 index 0000000..c72fc02 --- /dev/null +++ b/config/bollgrid.yaml @@ -0,0 +1,60 @@ +--- +notifications: + slack: + defaultChannel: "dev-qbtrade" + errorChannel: "qbtrade-error" + switches: + trade: true + orderUpdate: true + submitOrder: true + +sessions: + # binance: + # exchange: binance + # envVarPrefix: binance + + max: + exchange: max + envVarPrefix: MAX + +riskControls: + # This is the session-based risk controller, which let you configure different risk controller by session. + sessionBased: + # "max" is the session name that you want to configure the risk control + max: + # orderExecutor is one of the risk control + orderExecutor: + # symbol-routed order executor + bySymbol: + BTCUSDT: + # basic risk control order executor + basic: + minQuoteBalance: 1000.0 + maxBaseAssetBalance: 0 + minBaseAssetBalance: 1.0 + maxOrderAmount: 3000.0 + +backtest: + # for testing max draw down (MDD) at 03-12 + # see here for more details + # https://www.investopedia.com/terms/m/maximum-drawdown-mdd.asp + startTime: "2020-09-04" + endTime: "2020-09-14" + symbols: + - BTCUSDT + account: + max: + makerFeeRate: 0.075% + takerFeeRate: 0.075% + balances: + BTC: 0.0 + USDT: 10000.0 + +exchangeStrategies: +- on: max + bollgrid: + symbol: BTCUSDT + interval: 5m + gridNumber: 2 + quantity: 0.001 + profitSpread: 100.0 diff --git a/config/bollmaker.yaml b/config/bollmaker.yaml new file mode 100644 index 0000000..06cb9dd --- /dev/null +++ b/config/bollmaker.yaml @@ -0,0 +1,232 @@ +--- +persistence: + redis: + host: 127.0.0.1 + port: 6379 + db: 0 + +sessions: + binance: + exchange: binance + envVarPrefix: BINANCE + +# example command: +# godotenv -f .env.local -- go run ./cmd/qbtrade backtest --sync-from 2020-11-01 --config config/grid.yaml --base-asset-baseline +backtest: + # for testing max draw down (MDD) at 03-12 + # see here for more details + # https://www.investopedia.com/terms/m/maximum-drawdown-mdd.asp + startTime: "2022-05-01" + endTime: "2023-11-01" + sessions: + - binance + symbols: + - ETHUSDT + accounts: + binance: + balances: + ETH: 0.0 + USDT: 10_000.0 + +exchangeStrategies: + +- on: binance + bollmaker: + symbol: ETHUSDT + + # interval is how long do you want to update your order price and quantity + interval: 1m + + # quantity is the base order quantity for your buy/sell order. + quantity: 0.05 + + # amount is used for fixed-amount order, for example, use fixed 20 USDT order for BTCUSDT market + # amount: 20 + + # useTickerPrice use the ticker api to get the mid price instead of the closed kline price. + # The back-test engine is kline-based, so the ticker price api is not supported. + # Turn this on if you want to do real trading. + useTickerPrice: true + + # spread is the price spread from the middle price. + # For ask orders, the ask price is ((bestAsk + bestBid) / 2 * (1.0 + spread)) + # For bid orders, the bid price is ((bestAsk + bestBid) / 2 * (1.0 - spread)) + # Spread can be set by percentage or floating number. e.g., 0.1% or 0.001 + spread: 0.1% + + # minProfitSpread is the minimal order price spread from the current average cost. + # For long position, you will only place sell order above the price (= average cost * (1 + minProfitSpread)) + # For short position, you will only place buy order below the price (= average cost * (1 - minProfitSpread)) + minProfitSpread: 0.1% + + # minProfitActivationRate activates MinProfitSpread when position RoI higher than the specified percentage + minProfitActivationRate: -10% + + # trendEMA detects the trend by a given EMA + # when EMA goes up (the last > the previous), allow buy and sell + # when EMA goes down (the last < the previous), disable buy, allow sell + # uncomment this to enable it: + trendEMA: + interval: 1d + window: 7 + maxGradient: 1.5 + minGradient: 1.01 + + # ================================================================== + # Dynamic spread is an experimental feature. it will override the fixed spread settings above. + # + # dynamicSpread enables the automatic adjustment to bid and ask spread. + # Choose one of the scaling strategy to enable dynamicSpread: + # - amplitude: scales by K-line amplitude + # - weightedBollWidth: scales by weighted Bollinger band width (explained below) + # ================================================================== + # + # ========================================= + # dynamicSpread with amplitude + # ========================================= + # dynamicSpread: + # amplitude: # delete other scaling strategy if this is defined + # # window is the window of the SMAs of spreads + # window: 1 + # askSpreadScale: + # byPercentage: + # # exp means we want to use exponential scale, you can replace "exp" with "linear" for linear scale + # exp: + # # from down to up + # domain: [ 0.0001, 0.005 ] + # # when in down band, holds 1.0 by maximum + # # when in up band, holds 0.05 by maximum + # range: [ 0.001, 0.002 ] + # bidSpreadScale: + # byPercentage: + # # exp means we want to use exponential scale, you can replace "exp" with "linear" for linear scale + # exp: + # # from down to up + # domain: [ 0.0001, 0.005 ] + # # when in down band, holds 1.0 by maximum + # # when in up band, holds 0.05 by maximum + # range: [ 0.001, 0.002 ] + # + # ========================================= + # dynamicSpread with weightedBollWidth + # ========================================= + # dynamicSpread: + # # weightedBollWidth scales spread base on weighted Bollinger bandwidth ratio between default and neutral bands. + # # + # # Given the default band: moving average bd_mid, band from bd_lower to bd_upper. + # # And the neutral band: from bn_lower to bn_upper + # # Set the sigmoid weighting function: + # # - to ask spread, the weighting density function d_weight(x) is sigmoid((x - bd_mid) / (bd_upper - bd_lower)) + # # - to bid spread, the weighting density function d_weight(x) is sigmoid((bd_mid - x) / (bd_upper - bd_lower)) + # # Then calculate the weighted band width ratio by taking integral of d_weight(x) from bx_lower to bx_upper: + # # - weighted_ratio = integral(d_weight from bn_lower to bn_upper) / integral(d_weight from bd_lower to bd_upper) + # # - The wider neutral band get greater ratio + # # - To ask spread, the higher neutral band get greater ratio + # # - To bid spread, the lower neutral band get greater ratio + # # The weighted ratio always positive, and may be greater than 1 if neutral band is wider than default band. + # + # weightedBollWidth: # delete other scaling strategy if this is defined + # # sensitivity is a factor of the weighting function: 1 / (1 + exp(-(x - bd_mid) * sensitivity / (bd_upper - bd_lower))) + # # A positive number. The greater factor, the sharper weighting function. Default set to 1.0 . + # sensitivity: 1.0 + # + # askSpreadScale: + # byPercentage: + # # exp means we want to use exponential scale, you can replace "exp" with "linear" for linear scale + # linear: + # # from down to up + # domain: [ 0.1, 0.5 ] + # range: [ 0.001, 0.002 ] + # bidSpreadScale: + # byPercentage: + # # exp means we want to use exponential scale, you can replace "exp" with "linear" for linear scale + # linear: + # # from down to up + # domain: [ 0.1, 0.5 ] + # range: [ 0.001, 0.002 ] + + # maxExposurePosition is the maximum position you can hold + # +10 means you can hold 10 ETH long position by maximum + # -10 means you can hold -10 ETH short position by maximum + # uncomment this if you want a fixed position exposure. + # maxExposurePosition: 3.0 + maxExposurePosition: 10 + + # dynamicExposurePositionScale overrides maxExposurePosition + # for domain, + # -1 means -100%, the price is on the lower band price. + # if the price breaks the lower band, a number less than -1 will be given. + # 1 means 100%, the price is on the upper band price. + # if the price breaks the upper band, a number greater than 1 will be given, for example, 1.2 for 120%, and 1.3 for 130%. + dynamicExposurePositionScale: + byPercentage: + # exp means we want to use exponential scale, you can replace "exp" with "linear" for linear scale + exp: + # from lower band -100% (-1) to upper band 100% (+1) + domain: [ -1, 1 ] + # when in down band, holds 1.0 by maximum + # when in up band, holds 0.05 by maximum + range: [ 10.0, 1.0 ] + + # DisableShort means you can don't want short position during the market making + # THe short here means you might sell some of your existing inventory. + disableShort: true + + # uptrendSkew, like the strongUptrendSkew, but the price is still in the default band. + uptrendSkew: 0.8 + + # downtrendSkew, like the strongDowntrendSkew, but the price is still in the default band. + downtrendSkew: 1.2 + + # defaultBollinger is a long-term time frame bollinger + # this bollinger band is used for controlling your position (how much you can hold) + # when price is near the upper band, it holds less. + # when price is near the lower band, it holds more. + defaultBollinger: + interval: "1h" + window: 21 + bandWidth: 2.0 + + # neutralBollinger is the smaller range of the bollinger band + # If price is in this band, it usually means the price is oscillating. + neutralBollinger: + interval: "5m" + window: 21 + bandWidth: 2.0 + + # tradeInBand: when tradeInBand is set, you will only place orders in the bollinger band. + tradeInBand: true + + # buyBelowNeutralSMA: when this set, it will only place buy order when the current price is below the SMA line. + buyBelowNeutralSMA: true + + # emaCross is used for turning buy on/off + # when short term EMA cross fast term EMA, turn on buy, + # otherwise, turn off buy + emaCross: + enabled: false + interval: 1h + fastWindow: 3 + slowWindow: 12 + + exits: + + # roiTakeProfit is used to force taking profit by percentage of the position ROI (currently the price change) + # force to take the profit ROI exceeded the percentage. + - roiTakeProfit: + percentage: 3% + + - protectiveStopLoss: + activationRatio: 1% + stopLossRatio: 0.2% + placeStopOrder: false + + - protectiveStopLoss: + activationRatio: 2% + stopLossRatio: 1% + placeStopOrder: false + + - protectiveStopLoss: + activationRatio: 5% + stopLossRatio: 3% + placeStopOrder: false diff --git a/config/bollmaker_optimizer.yaml b/config/bollmaker_optimizer.yaml new file mode 100644 index 0000000..ac8716d --- /dev/null +++ b/config/bollmaker_optimizer.yaml @@ -0,0 +1,29 @@ +# usage: +# +# go run ./cmd/qbtrade optimize --config bollmaker_ethusdt.yaml --optimizer-config optimizer.yaml --debug +# +--- +executor: + type: local + local: + maxNumberOfProcesses: 10 + +matrix: +- type: iterate + label: interval + path: '/exchangeStrategies/0/bollmaker/interval' + values: [ "1m", "5m", "15m", "30m" ] + +- type: range + path: '/exchangeStrategies/0/bollmaker/amount' + label: amount + min: 20.0 + max: 100.0 + step: 20.0 + +- type: range + label: spread + path: '/exchangeStrategies/0/bollmaker/spread' + min: 0.1% + max: 0.2% + step: 0.01% diff --git a/config/convert.yaml b/config/convert.yaml new file mode 100644 index 0000000..ae5f144 --- /dev/null +++ b/config/convert.yaml @@ -0,0 +1,26 @@ +--- +exchangeStrategies: +- on: binance + convert: + ## the initial asset you want to convert to + from: BNB + + ## the final asset you want to convert to + to: BTC + + ## interval is the period of trigger + interval: 1m + + ## minBalance is the minimal balance line you want to keep + ## in this example, it means 1 BNB + minBalance: 1.0 + + ## maxQuantity is the max quantity per order for converting asset + ## in this example, it means 2 BNB + maxQuantity: 2.0 + + ## useTakerOrder uses the taker price for the order, so the order will be a taker + ## which will be filled immediately + useTakerOrder: true + + diff --git a/config/dca.yaml b/config/dca.yaml new file mode 100644 index 0000000..b8d08e4 --- /dev/null +++ b/config/dca.yaml @@ -0,0 +1,23 @@ +--- +backtest: + startTime: "2022-04-01" + endTime: "2022-05-01" + sessions: + - binance + symbols: + - BTCUSDT + accounts: + binance: + balances: + USDT: 20_000.0 + +exchangeStrategies: + +- on: binance + dca: + symbol: BTCUSDT + budgetPeriod: day + investmentInterval: 4h + budget: 1000 + + diff --git a/config/dca2.yaml b/config/dca2.yaml new file mode 100644 index 0000000..e14c2c9 --- /dev/null +++ b/config/dca2.yaml @@ -0,0 +1,31 @@ +--- +backtest: + startTime: "2023-06-01" + endTime: "2023-07-01" + sessions: + - max + symbols: + - ETHUSDT + accounts: + binance: + balances: + USDT: 20_000.0 + +persistence: + redis: + host: 127.0.0.1 + port: 6379 + db: 0 + +exchangeStrategies: + +- on: max + dca2: + symbol: ETHUSDT + quoteInvestment: "200" + maxOrderCount: 5 + priceDeviation: "0.01" + takeProfitRatio: "0.002" + coolDownInterval: 180 + recoverWhenStart: true + keepOrdersWhenShutdown: true diff --git a/config/deposit2transfer.yaml b/config/deposit2transfer.yaml new file mode 100644 index 0000000..94cc742 --- /dev/null +++ b/config/deposit2transfer.yaml @@ -0,0 +1,12 @@ +--- +## deposit2transfer scans the deposit history and then transfer the deposited assets into your margin account +## currently only cross-margin is supported. +exchangeStrategies: +- on: binance + deposit2transfer: + ## interval is the deposit history scanning interval + interval: 1m + + ## assets are the assets you want to transfer into the margin account + assets: + - USDT diff --git a/config/driftBTC.yaml b/config/driftBTC.yaml new file mode 100644 index 0000000..0447825 --- /dev/null +++ b/config/driftBTC.yaml @@ -0,0 +1,141 @@ +--- +persistence: + redis: + host: 127.0.0.1 + port: 6379 + db: 0 + +sessions: + binance: + exchange: binance + #futures: true + #margin: true + #isolatedMargin: true + #isolatedMarginSymbol: BTCUSDT + envVarPrefix: binance + heikinAshi: false + + # Drift strategy intends to place buy/sell orders as much value mas it could be. To exchanges that requires to + # calculate fees before placing limit orders (e.g. FTX Pro), make sure the fee rate is configured correctly and + # enable modifyOrderAmountForFee to prevent order rejection. + makerFeeRate: 0.02% + takerFeeRate: 0.07% + modifyOrderAmountForFee: false + +exchangeStrategies: + +- on: binance + drift: + debug: false + minInterval: 1s + limitOrder: true + #quantity: 0.0012 + canvasPath: "./output.png" + symbol: BTCUSDT + # kline interval for indicators + interval: 1s + window: 2 + useAtr: true + useStopLoss: true + stoploss: 0.01% + source: hl2 + predictOffset: 2 + noTrailingStopLoss: true + # stddev on high/low-source + hlVarianceMultiplier: 0.7 + hlRangeWindow: 6 + smootherWindow: 10 + fisherTransformWindow: 45 + atrWindow: 24 + # orders not been traded will be canceled after `pendingMinutes` minutes + pendingMinInterval: 6 + noRebalance: true + trendWindow: 4 + rebalanceFilter: 2 + + # ActivationRatio should be increasing order + # when farest price from entry goes over that ratio, start using the callback ratio accordingly to do trailingstop + #trailingActivationRatio: [0.01, 0.016, 0.05] + #trailingActivationRatio: [0.001, 0.0081, 0.022] + trailingActivationRatio: [0.0008, 0.002, 0.01] + #trailingActivationRatio: [] + #trailingCallbackRate: [] + #trailingCallbackRate: [0.002, 0.01, 0.1] + #trailingCallbackRate: [0.0004, 0.0009, 0.018] + trailingCallbackRate: [0.00014, 0.0003, 0.0016] + + generateGraph: true + graphPNLDeductFee: false + graphPNLPath: "./pnl.png" + graphCumPNLPath: "./cumpnl.png" + graphElapsedPath: "./elapsed.png" + #exits: + # - roiStopLoss: + # percentage: 0.35% + #- roiTakeProfit: + # percentage: 0.7% + #- protectiveStopLoss: + # activationRatio: 0.5% + # stopLossRatio: 0.2% + # placeStopOrder: false + #- trailingStop: + # callbackRate: 0.3% + # activationRatio is relative to the average cost, + # when side is buy, 1% means lower 1% than the average cost. + # when side is sell, 1% means higher 1% than the average cost. + # activationRatio: 0.7% + # minProfit uses the position ROI to calculate the profit ratio + + # minProfit: 1.5% + # interval: 1m + # side: sell + # closePosition: 100% + + #- trailingStop: + # callbackRate: 0.3% + # activationRatio is relative to the average cost, + # when side is buy, 1% means lower 1% than the average cost. + # when side is sell, 1% means higher 1% than the average cost. + # activationRatio: 0.7% + # minProfit uses the position ROI to calculate the profit ratio + + # minProfit: 1.5% + # interval: 1m + # side: buy + # closePosition: 100% + #- protectiveStopLoss: + # activationRatio: 5% + # stopLossRatio: 1% + # placeStopOrder: false + #- cumulatedVolumeTakeProfit: + # interval: 5m + # window: 2 + # minQuoteVolume: 200_000_000 + #- protectiveStopLoss: + # activationRatio: 2% + # stopLossRatio: 1% + # placeStopOrder: false + +sync: + userDataStream: + trades: true + filledOrders: true + sessions: + - binance + symbols: + - BTCUSDT + +backtest: + startTime: "2022-10-19" + endTime: "2022-10-20" + symbols: + - BTCUSDT + sessions: [binance] + syncSecKLines: true + accounts: + binance: + makerFeeRate: 0.000 + takerFeeRate: 0.000 + balances: + BTC: 0 + USDT: 49 diff --git a/config/elliottwave.yaml b/config/elliottwave.yaml new file mode 100644 index 0000000..a2f37d1 --- /dev/null +++ b/config/elliottwave.yaml @@ -0,0 +1,126 @@ +--- +persistence: + redis: + host: 127.0.0.1 + port: 6379 + db: 0 + +sessions: + binance: + exchange: binance + futures: false + envVarPrefix: binance + heikinAshi: false + + # Drift strategy intends to place buy/sell orders as much value mas it could be. To exchanges that requires to + # calculate fees before placing limit orders (e.g. FTX Pro), make sure the fee rate is configured correctly and + # enable modifyOrderAmountForFee to prevent order rejection. + makerFeeRate: 0.0002 + takerFeeRate: 0.0007 + modifyOrderAmountForFee: false + +exchangeStrategies: + +- on: binance + elliottwave: + minInterval: 1s + symbol: BTCUSDT + limitOrder: true + #quantity: 0.16 + # kline interval for indicators + interval: 1s + stoploss: 0.01% + windowATR: 14 + windowQuick: 4 + windowSlow: 155 + source: hl2 + pendingMinInterval: 5 + useHeikinAshi: true + + drawGraph: true + graphIndicatorPath: "./indicator.png" + graphPNLPath: "./pnl.png" + graphCumPNLPath: "./cumpnl.png" + + + # ActivationRatio should be increasing order + # when farest price from entry goes over that ratio, start using the callback ratio accordingly to do trailingstop + #trailingActivationRatio: [0.01, 0.016, 0.05] + #trailingActivationRatio: [0.001, 0.0081, 0.022] + #trailingActivationRatio: [0.0017, 0.01, 0.015] + trailingActivationRatio: [] + trailingCallbackRate: [] + #trailingCallbackRate: [0.002, 0.01, 0.1] + #trailingCallbackRate: [0.0004, 0.0009, 0.018] + #trailingCallbackRate: [0.0006, 0.0019, 0.006] + + #exits: + # - roiStopLoss: + # percentage: 0.35% + #- roiTakeProfit: + # percentage: 0.7% + #- protectiveStopLoss: + # activationRatio: 0.5% + # stopLossRatio: 0.2% + # placeStopOrder: false + #- trailingStop: + # callbackRate: 0.3% + # activationRatio is relative to the average cost, + # when side is buy, 1% means lower 1% than the average cost. + # when side is sell, 1% means higher 1% than the average cost. + # activationRatio: 0.7% + # minProfit uses the position ROI to calculate the profit ratio + + # minProfit: 1.5% + # interval: 1m + # side: sell + # closePosition: 100% + + #- trailingStop: + # callbackRate: 0.3% + # activationRatio is relative to the average cost, + # when side is buy, 1% means lower 1% than the average cost. + # when side is sell, 1% means higher 1% than the average cost. + # activationRatio: 0.7% + # minProfit uses the position ROI to calculate the profit ratio + + # minProfit: 1.5% + # interval: 1m + # side: buy + # closePosition: 100% + #- protectiveStopLoss: + # activationRatio: 5% + # stopLossRatio: 1% + # placeStopOrder: false + #- cumulatedVolumeTakeProfit: + # interval: 5m + # window: 2 + # minQuoteVolume: 200_000_000 + #- protectiveStopLoss: + # activationRatio: 2% + # stopLossRatio: 1% + # placeStopOrder: false + +sync: + userDataStream: + trades: true + filledOrders: true + sessions: + - binance + symbols: + - BTCUSDT + +backtest: + startTime: "2022-10-15" + endTime: "2022-10-19" + symbols: + - BTCUSDT + sessions: [binance] + syncSecKLines: true + accounts: + binance: + makerFeeRate: 0.000 + takerFeeRate: 0.000 + balances: + BTC: 0 + USDT: 100 diff --git a/config/emacross.yaml b/config/emacross.yaml new file mode 100644 index 0000000..f5e8aa6 --- /dev/null +++ b/config/emacross.yaml @@ -0,0 +1,37 @@ +persistence: + json: + directory: var/data + redis: + host: 127.0.0.1 + port: 6379 + db: 0 + +sessions: + binance: + exchange: binance + envVarPrefix: binance + +exchangeStrategies: +- on: binance + emacross: + symbol: BTCUSDT + # interval: 5m + # fastWindow: 6 + # slowWindow: 18 + # quantity: 0.01 + leverage: 2 + +backtest: + startTime: "2022-01-01" + endTime: "2022-03-01" + symbols: + - BTCUSDT + sessions: [binance] + # syncSecKLines: true + accounts: + binance: + makerFeeRate: 0.0% + takerFeeRate: 0.075% + balances: + BTC: 0.0 + USDT: 10_000.0 diff --git a/config/emastop.yaml b/config/emastop.yaml new file mode 100644 index 0000000..bfc6783 --- /dev/null +++ b/config/emastop.yaml @@ -0,0 +1,31 @@ +--- +crossExchangeStrategies: +- emastop: + sourceExchange: "binance" + targetExchange: "max" + symbol: ETHUSDT + + # interval is the kline interval we want to update our stop limit order + interval: 1m + + # movingAverage* is used for calculating the stop price + movingAverageType: EWMA + movingAverageInterval: 1h + movingAverageWindow: 99 + + # stop price adjusts the stop price by a ratio. If defined, stop price = moving average price * ratio + stopPriceRatio: 0.975 + + # orderType is the stop order type, could be "market" or "limit" + orderType: market + # orderType: limit + + # priceRatio greater than 1 means we will place the sell order as a maker order + # priceRatio less than 1 means we will place the sell order as a taker order (trade with the existing bid orders) + # priceRatio: 1.1 + + # you can specify "quantity" or "balancePercentage" for the sell order quantity + quantity: 0.02 + + # balancePercentage 0.25 means 25% of the current base asset + # balancePercentage: 0.25 diff --git a/config/environment.yaml b/config/environment.yaml new file mode 100644 index 0000000..96994c9 --- /dev/null +++ b/config/environment.yaml @@ -0,0 +1,7 @@ +environment: + disableDefaultKLineSubscription: true + disableHistoryKLinePreload: true + disableStartupBalanceQuery: true + disableSessionTradeBuffer: true + disableMarketDataStore: true + maxSessionTradeBufferSize: true diff --git a/config/etf.yaml b/config/etf.yaml new file mode 100644 index 0000000..3897a58 --- /dev/null +++ b/config/etf.yaml @@ -0,0 +1,11 @@ +exchangeStrategies: +- on: max + etf: + duration: 24h + totalAmount: 200.0 + index: + BTCUSDT: 5% + LTCUSDT: 15% + ETHUSDT: 30% + LINKUSDT: 20% + DOTUSDT: 30% diff --git a/config/ewo_dgtrd.yaml b/config/ewo_dgtrd.yaml new file mode 100644 index 0000000..605c45e --- /dev/null +++ b/config/ewo_dgtrd.yaml @@ -0,0 +1,65 @@ +--- +sessions: + binance: + exchange: binance + futures: true + envVarPrefix: binance + heikinAshi: false + +exchangeStrategies: + +- on: binance + ewo_dgtrd: + symbol: MATICUSDT + # kline interval for indicators + interval: 15m + # use ema as MA + useEma: false + # use sma as MA, used when ema is false + # if both sma and ema are false, use EVMA + useSma: false + # ewo signal line window size + sigWin: 5 + # SL percentage from entry price + stoploss: 2% + # use HeikinAshi klines instead of normal OHLC + useHeikinAshi: true + # disable SL when short + disableShortStop: false + # disable SL when long + disableLongStop: false + # CCI Stochastic Indicator high filter + cciStochFilterHigh: 80 + # CCI Stochastic Indicator low filter + cciStochFilterLow: 20 + # ewo change rate histogram's upperbound filter + # set to 1 would intend to let all ewo pass + ewoChangeFilterHigh: 1. + # ewo change rate histogram's lowerbound filter + # set to 0 would intend to let all ewo pass + ewoChangeFilterLow: 0.0 + # print record exit point in log messages + record: false + +sync: + userDataStream: + trades: true + filledOrders: true + sessions: + - binance + symbols: + - MATICUSDT + +backtest: + startTime: "2022-05-01" + endTime: "2022-05-27" + symbols: + - MATICUSDT + sessions: [binance] + accounts: + binance: + #makerFeeRate: 0 + #takerFeeRate: 15 + balances: + MATIC: 000.0 + USDT: 15000.0 diff --git a/config/factorzoo.yaml b/config/factorzoo.yaml new file mode 100644 index 0000000..1de7a57 --- /dev/null +++ b/config/factorzoo.yaml @@ -0,0 +1,44 @@ +sessions: + binance: + exchange: binance + envVarPrefix: binance + +exchangeStrategies: +- on: binance + factorzoo: + symbol: BTCBUSD + linear: + enabled: true + interval: 1d + quantity: 1.0 + window: 5 + + exits: + - trailingStop: + callbackRate: 1% + activationRatio: 1% + closePosition: 100% + minProfit: 15% + interval: 1m + side: buy + - trailingStop: + callbackRate: 1% + activationRatio: 1% + closePosition: 100% + minProfit: 15% + interval: 1m + side: sell + + +backtest: + sessions: + - binance + startTime: "2021-01-01" + endTime: "2022-08-31" + symbols: + - BTCBUSD + accounts: + binance: + balances: + BTC: 1.0 + BUSD: 40_000.0 diff --git a/config/fixedmaker.yaml b/config/fixedmaker.yaml new file mode 100644 index 0000000..d66a426 --- /dev/null +++ b/config/fixedmaker.yaml @@ -0,0 +1,34 @@ +--- +backtest: + startTime: "2023-01-01" + endTime: "2023-05-31" + symbols: + - USDCUSDT + sessions: + - max + accounts: + max: + balances: + USDC: 500.0 + USDT: 500.0 + +exchangeStrategies: + - on: max + fixedmaker: + symbol: USDCUSDT + interval: 1m + halfSpread: 0.05% + quantity: 15 + orderType: LIMIT_MAKER + dryRun: true + + positionHardLimit: 1500 + maxPositionQuantity: 1500 + circuitBreakLossThreshold: -0.15 + circuitBreakEMA: + interval: 1m + window: 14 + + inventorySkew: + inventoryRangeMultiplier: 1.0 + targetBaseRatio: 0.5 diff --git a/config/flashcrash.yaml b/config/flashcrash.yaml new file mode 100644 index 0000000..367ebb5 --- /dev/null +++ b/config/flashcrash.yaml @@ -0,0 +1,14 @@ +--- +sessions: + max: + exchange: max + envVarPrefix: max + +exchangeStrategies: +- on: max + flashcrash: + symbol: BTCUSDT + interval: 1m + baseQuantity: 0.01 + percentage: 0.6 # 60% of the current price from the moving average + gridNumber: 3 diff --git a/config/fmaker.yaml b/config/fmaker.yaml new file mode 100644 index 0000000..e1993fa --- /dev/null +++ b/config/fmaker.yaml @@ -0,0 +1,26 @@ +sessions: + binance: + exchange: binance + envVarPrefix: binance + + +exchangeStrategies: +- on: binance + fmaker: + symbol: BTCUSDT + interval: 1m + spread: 0.15% + amount: 300 # 11 + +backtest: + sessions: + - binance + startTime: "2022-01-01" + endTime: "2022-05-31" + symbols: + - BTCUSDT + account: + binance: + balances: + BTC: 1 # 1 + USDT: 45_000 # 30_000 diff --git a/config/grid-usdttwd.yaml b/config/grid-usdttwd.yaml new file mode 100644 index 0000000..4b4e73e --- /dev/null +++ b/config/grid-usdttwd.yaml @@ -0,0 +1,53 @@ +--- +sessions: + max: + exchange: max + envVarPrefix: max + +riskControls: + # This is the session-based risk controller, which let you configure different risk controller by session. + sessionBased: + # "max" is the session name that you want to configure the risk control + max: + # orderExecutor is one of the risk control + orderExecutor: + # symbol-routed order executor + bySymbol: + USDTTWD: + # basic risk control order executor + basic: + minQuoteBalance: 100.0 # keep 100 twd + maxBaseAssetBalance: 100_000.0 + minBaseAssetBalance: 1_000.0 + maxOrderAmount: 2000.0 # 1000 twd + +backtest: + # for testing max draw down (MDD) at 03-12 + # see here for more details + # https://www.investopedia.com/terms/m/maximum-drawdown-mdd.asp + startTime: "2021-01-01" + endTime: "2021-01-30" + sessions: + - max + symbols: + - USDTTWD + feeMode: quote + accounts: + max: + makerFeeRate: 0.0125% + takerFeeRate: 0.075% + balances: + BTC: 0.0 + USDT: 10_000.0 + TWD: 100_000.0 + +exchangeStrategies: +- on: max + grid: + symbol: USDTTWD + quantity: 10.0 # 10 USDT per grid + gridNumber: 100 # 50 GRID, Total Amount will be 10 USDT * 50 GRID = 500 USDT + profitSpread: 0.1 # When buying USDT at 28.1, we will sell it at 28.1 + 0.1 = 28.2, When selling USDT at 28.1, we will buy it back at 28.1 - 0.1 = 28.0 + upperPrice: 28.90 + lowerPrice: 27.90 + long: true # long mode means we don't keep cash when we sell usdt, we will use the same amount of twd to buy more usdt back diff --git a/config/grid.yaml b/config/grid.yaml new file mode 100644 index 0000000..733459c --- /dev/null +++ b/config/grid.yaml @@ -0,0 +1,61 @@ +--- +sessions: + binance: + exchange: binance + envVarPrefix: binance + + #max: + # exchange: max + # envVarPrefix: max + +riskControls: + # This is the session-based risk controller, which let you configure different risk controller by session. + sessionBased: + # "max" is the session name that you want to configure the risk control + max: + # orderExecutor is one of the risk control + orderExecutor: + # symbol-routed order executor + bySymbol: + BTCUSDT: + # basic risk control order executor + basic: + minQuoteBalance: 100.0 + maxBaseAssetBalance: 3.0 + minBaseAssetBalance: 0.0 + maxOrderAmount: 1000.0 + +# example command: +# godotenv -f .env.local -- go run ./cmd/qbtrade backtest --sync-from 2020-11-01 --config config/grid.yaml --base-asset-baseline +backtest: + # for testing max draw down (MDD) at 03-12 + # see here for more details + # https://www.investopedia.com/terms/m/maximum-drawdown-mdd.asp + startTime: "2022-05-09" + endTime: "2022-05-20" + symbols: + - BTCUSDT + sessions: [binance] + accounts: + binance: + balances: + BTC: 0.0 + USDT: 10000.0 + +exchangeStrategies: + +- on: binance + grid: + symbol: BTCUSDT + quantity: 0.001 + # scaleQuantity: + # byPrice: + # exp: + # domain: [20_000, 30_000] + # range: [0.2, 0.001] + gridNumber: 20 + profitSpread: 1000.0 # The profit price spread that you want to add to your sell order when your buy order is executed + upperPrice: 30_000.0 + lowerPrice: 28_000.0 + # long: true # The sell order is submitted in the same order amount as the filled corresponding buy order, rather than the same quantity. + diff --git a/config/grid2-max.yaml b/config/grid2-max.yaml new file mode 100644 index 0000000..0d54a8e --- /dev/null +++ b/config/grid2-max.yaml @@ -0,0 +1,94 @@ +--- +notifications: + slack: + defaultChannel: "dev-qbtrade" + errorChannel: "qbtrade-error" + switches: + trade: false + orderUpdate: true + submitOrder: true + +sessions: + max: + exchange: max + envVarPrefix: MAX + + +# example command: +# godotenv -f .env.local -- go run ./cmd/qbtrade backtest --config config/grid2-max.yaml --base-asset-baseline +backtest: + startTime: "2022-01-01" + endTime: "2022-11-25" + symbols: + - BTCUSDT + sessions: [ max ] + accounts: + binance: + balances: + BTC: 0.0 + USDT: 10000.0 + +exchangeStrategies: + +## You can run the following command to cancel all grid orders if the orders are not successfully canceled: +## go run ./cmd/qbtrade --dotenv .env.local.max-staging cancel-order --all --symbol BTCUSDT --config config/grid2-max.yaml +- on: max + grid2: + symbol: BTCUSDT + upperPrice: 18_000.0 + lowerPrice: 16_000.0 + gridNumber: 20 + + ## compound is used for buying more inventory when the profit is made by the filled SELL order. + ## when compound is disabled, fixed quantity is used for each grid order. + ## default: false + compound: true + + ## earnBase is used to profit base quantity instead of quote quantity. + ## meaning that earn BTC instead of USDT when trading in the BTCUSDT pair. + # earnBase: true + + ## triggerPrice is used for opening your grid only when the last price touches your pre-set price. + ## this is useful when you don't want to create a grid from a higher price. + ## for example, when the last price hit 17_000.0 then open a grid with the price range 13_000 to 20_000 + # triggerPrice: 16_900.0 + + ## stopLossPrice is used for closing the grid and sell all the inventory to stop loss. + ## (optional) + stopLossPrice: 16_000.0 + + ## takeProfitPrice is used for closing the grid and sell all the inventory at higher price to take profit + ## (optional) + takeProfitPrice: 20_000.0 + + ## profitSpread is the profit spread of the arbitrage order (sell order) + ## greater the profitSpread, greater the profit you make when the sell order is filled. + ## you can set this instead of the default grid profit spread. + ## by default, profitSpread = (upperPrice - lowerPrice) / gridNumber + ## that is, greater the gridNumber, lesser the profit of each grid. + # profitSpread: 1000.0 + + ## There are 3 kinds of setup + ## NOTICE: you can only choose one, uncomment the config to enable it + ## + ## 1) fixed amount: amount is the quote unit (e.g. USDT in BTCUSDT) + # amount: 10.0 + + ## 2) fixed quantity: it will use your balance to place orders with the fixed quantity. e.g. 0.001 BTC + quantity: 0.001 + + ## 3) quoteInvestment and baseInvestment: when using quoteInvestment, the strategy will automatically calculate your best quantity for the whole grid. + ## quoteInvestment is required, and baseInvestment is optional (could be zero) + ## if you have existing BTC position and want to reuse it you can set the baseInvestment. + # quoteInvestment: 10_000 + # baseInvestment: 1.0 + + feeRate: 0.075% + closeWhenCancelOrder: true + resetPositionWhenStart: true + clearOpenOrdersWhenStart: false + keepOrdersWhenShutdown: false + recoverOrdersWhenStart: false + + ## skipSpreadCheck skips the minimal spread check for the grid profit + skipSpreadCheck: true diff --git a/config/grid2.yaml b/config/grid2.yaml new file mode 100644 index 0000000..cf28272 --- /dev/null +++ b/config/grid2.yaml @@ -0,0 +1,128 @@ +--- +notifications: + slack: + defaultChannel: "dev-qbtrade" + errorChannel: "qbtrade-error" + switches: + trade: false + orderUpdate: false + submitOrder: false + +sessions: + binance: + exchange: binance + envVarPrefix: binance + +sync: + # userDataStream is used to sync the trading data in real-time + # it uses the websocket connection to insert the trades + userDataStream: + trades: false + filledOrders: false + + # since is the start date of your trading data + since: 2019-01-01 + + # sessions is the list of session names you want to sync + # by default, qbtrade sync all your available sessions. + sessions: + - binance + + # symbols is the list of symbols you want to sync + # by default, qbtrade try to guess your symbols by your existing account balances. + symbols: + - BTCUSDT + +# example command: +# go run ./cmd/qbtrade backtest --config config/grid2.yaml --base-asset-baseline +backtest: + startTime: "2021-06-01" + endTime: "2021-06-30" + symbols: + - BTCUSDT + sessions: [binance] + feeMode: token + accounts: + binance: + makerFeeRate: 0.075% + takerFeeRate: 0.075% + balances: + BTC: 1.0 + USDT: 21_000.0 + +exchangeStrategies: + +- on: binance + grid2: + symbol: BTCUSDT + + ## autoRange can be used to detect a price range from a specific time frame + ## the pivot low / pivot high of the given range will be used for lowerPrice and upperPrice. + ## when autoRange is set, it will override the upperPrice/lowerPrice settings. + ## + ## the valid format is [1-9][hdw] + ## example: "14d" means it will find the highest/lowest price that is higher/lower than left 14d and right 14d. + # autoRange: 14d + + lowerPrice: 28_000.0 + upperPrice: 50_000.0 + + ## gridNumber is the total orders between the upper price and the lower price + ## gridSpread = (upperPrice - lowerPrice) / gridNumber + ## Make sure your gridNumber satisfy this: MIN(gridSpread/lowerPrice, gridSpread/upperPrice) > (makerFeeRate * 2) + gridNumber: 150 + + ## compound is used for buying more inventory when the profit is made by the filled SELL order. + ## when compound is disabled, fixed quantity is used for each grid order. + ## default: false + compound: true + + ## earnBase is used to profit base quantity instead of quote quantity. + ## meaning that earn BTC instead of USDT when trading in the BTCUSDT pair. + # earnBase: true + + ## triggerPrice (optional) is used for opening your grid only when the last price touches your trigger price. + ## this is useful when you don't want to create a grid from a higher price. + ## for example, when the last price hit 17_000.0 then open a grid with the price range 13_000 to 20_000 + # triggerPrice: 17_000.0 + + ## triggerPrice (optional) is used for closing your grid only when the last price touches your stop loss price. + ## for example, when the price drops to 17_000.0 then close the grid and sell all base inventory. + # stopLossPrice: 10_000.0 + + ## profitSpread is the profit spread of the arbitrage order (sell order) + ## greater the profitSpread, greater the profit you make when the sell order is filled. + ## you can set this instead of the default grid profit spread. + ## by default, profitSpread = (upperPrice - lowerPrice) / gridNumber + ## that is, greater the gridNumber, lesser the profit of each grid. + # profitSpread: 1000.0 + + ## There are 3 kinds of setup + ## NOTICE: you can only choose one, uncomment the config to enable it + ## + ## 1) fixed amount: amount is the quote unit (e.g. 10 USDT in BTCUSDT) + # amount: 10.0 + + ## 2) fixed quantity: it will use your balance to place orders with the fixed quantity. e.g. 0.001 BTC + # quantity: 0.001 + + ## 3) quoteInvestment and baseInvestment: when using quoteInvestment, the strategy will automatically calculate your best quantity for the whole grid. + ## quoteInvestment is required, and baseInvestment is optional (could be zero) + ## if you have existing BTC position and want to reuse it you can set the baseInvestment. + quoteInvestment: 20_000 + + ## baseInvestment (optional) can be useful when you have existing inventory, maybe bought at much lower price + baseInvestment: 1.0 + + ## closeWhenCancelOrder (optional) + ## default to false + closeWhenCancelOrder: true + + ## resetPositionWhenStart (optional) + ## default to false + resetPositionWhenStart: false + + ## clearOpenOrdersWhenStart (optional) + ## default to false + clearOpenOrdersWhenStart: false + keepOrdersWhenShutdown: false diff --git a/config/harmonic.yaml b/config/harmonic.yaml new file mode 100644 index 0000000..9f7fa11 --- /dev/null +++ b/config/harmonic.yaml @@ -0,0 +1,37 @@ +persistence: +json: + directory: var/data +redis: + host: 127.0.0.1 + port: 6379 + db: 0 + +sessions: + binance: + exchange: binance + envVarPrefix: binance + +exchangeStrategies: + - on: binance + harmonic: + symbol: BTCBUSD + interval: 1s + window: 60 + quantity: 0.005 + # Draw pnl + drawGraph: true + graphPNLPath: "./pnl.png" + graphCumPNLPath: "./cumpnl.png" + +backtest: + sessions: + - binance + startTime: "2022-10-01" + endTime: "2022-10-07" + symbols: + - BTCBUSD + accounts: + binance: + balances: + BTC: 1.0 + BUSD: 60_000.0 \ No newline at end of file diff --git a/config/irr.yaml b/config/irr.yaml new file mode 100644 index 0000000..ff91681 --- /dev/null +++ b/config/irr.yaml @@ -0,0 +1,40 @@ +persistence: + json: + directory: var/data + redis: + host: 127.0.0.1 + port: 6379 + db: 0 + +sessions: + binance: + exchange: binance + envVarPrefix: binance + +exchangeStrategies: +- on: binance + irr: + symbol: BTCUSDT + interval: 1m + window: 10 + amount: 5000 + # Draw pnl + drawGraph: true + graphPNLPath: "./pnl.png" + graphCumPNLPath: "./cumpnl.png" + + +backtest: + startTime: "2022-01-01" + endTime: "2022-11-01" + symbols: + - BTCUSDT + sessions: [binance] +# syncSecKLines: true + accounts: + binance: + makerFeeRate: 0.0000 + takerFeeRate: 0.0000 + balances: + BTC: 0.0 + USDT: 5000 \ No newline at end of file diff --git a/config/linregmaker.yaml b/config/linregmaker.yaml new file mode 100644 index 0000000..644e213 --- /dev/null +++ b/config/linregmaker.yaml @@ -0,0 +1,186 @@ +--- +persistence: + redis: + host: 127.0.0.1 + port: 6379 + db: 0 + +sessions: + binance: + exchange: binance + envVarPrefix: binance + margin: true + isolatedMargin: true + isolatedMarginSymbol: BTCUSDT + +backtest: + sessions: [binance] + # for testing max draw down (MDD) at 03-12 + # see here for more details + # https://www.investopedia.com/terms/m/maximum-drawdown-mdd.asp + startTime: "2022-05-01" + endTime: "2022-10-31" + symbols: + - BTCUSDT + accounts: + binance: + makerCommission: 10 # 0.15% + takerCommission: 15 # 0.15% + balances: + BTC: 2.0 + USDT: 10000.0 + +exchangeStrategies: +- on: binance + linregmaker: + symbol: BTCUSDT + + # interval is how long do you want to update your order price and quantity + interval: 1m + + # leverage uses the account net value to calculate the allowed margin + leverage: 1 + + # reverseEMA is used to determine the long-term trend. + # Above the ReverseEMA is the long trend and vise versa. + # All the opposite trend position will be closed upon the trend change + reverseEMA: + interval: 1d + window: 60 + + # reverseInterval is the interval to check trend reverse against ReverseEMA. Close price of this interval crossing + # the ReverseEMA triggers main trend change. + reverseInterval: 4h + + # fastLinReg is to determine the short-term trend. + # Buy/sell orders are placed if the FastLinReg and the ReverseEMA trend are in the same direction, and only orders + # that reduce position are placed if the FastLinReg and the ReverseEMA trend are in different directions. + fastLinReg: + interval: 1m + window: 30 + + # slowLinReg is to determine the midterm trend. + # When the SlowLinReg and the ReverseEMA trend are in different directions, creation of opposite position is + # allowed. + slowLinReg: + interval: 1m + window: 120 + + # allowOppositePosition if true, the creation of opposite position is allowed when both fast and slow LinReg are in + # the opposite direction to main trend + allowOppositePosition: true + + # fasterDecreaseRatio the quantity of decreasing position orders are multiplied by this ratio when both fast and + # slow LinReg are in the opposite direction to main trend + fasterDecreaseRatio: 2 + + # neutralBollinger is the smaller range of the bollinger band + # If price is in this band, it usually means the price is oscillating. + # If price goes out of this band, we tend to not place sell orders or buy orders + neutralBollinger: + interval: "15m" + window: 21 + bandWidth: 2.0 + + # tradeInBand: when tradeInBand is set, you will only place orders in the bollinger band. + tradeInBand: true + + # spread is the price spread from the middle price. + # For ask orders, the ask price is ((bestAsk + bestBid) / 2 * (1.0 + spread)) + # For bid orders, the bid price is ((bestAsk + bestBid) / 2 * (1.0 - spread)) + # Spread can be set by percentage or floating number. e.g., 0.1% or 0.001 + spread: 0.1% + # dynamicSpread enables the automatic adjustment to bid and ask spread. + # Overrides Spread, BidSpread, and AskSpread + dynamicSpread: + amplitude: # delete other scaling strategy if this is defined + # window is the window of the SMAs of spreads + window: 1 + interval: "1m" + askSpreadScale: + byPercentage: + # exp means we want to use exponential scale, you can replace "exp" with "linear" for linear scale + exp: + # from down to up + domain: [ 0.0001, 0.005 ] + # the spread range + range: [ 0.001, 0.002 ] + bidSpreadScale: + byPercentage: + # exp means we want to use exponential scale, you can replace "exp" with "linear" for linear scale + exp: + # from down to up + domain: [ 0.0001, 0.005 ] + # the spread range + range: [ 0.001, 0.002 ] + + # maxExposurePosition is the maximum position you can hold + # 10 means you can hold 10 ETH long/short position by maximum + #maxExposurePosition: 10 + # dynamicExposure is used to define the exposure position range with the given percentage. + # When DynamicExposure is set, your MaxExposurePosition will be calculated dynamically + dynamicExposure: + bollBandExposure: + interval: "1h" + window: 21 + bandWidth: 2.0 + dynamicExposurePositionScale: + byPercentage: + # exp means we want to use exponential scale, you can replace "exp" with "linear" for linear scale + exp: + # from lower band -100% (-1) to upper band 100% (+1) + domain: [ -1, 1 ] + # when in down band, holds 0.1 by maximum + # when in up band, holds 1 by maximum + range: [ 0.1, 1 ] + + # quantity is the base order quantity for your buy/sell order. + quantity: 0.001 + # amount: fixed amount instead of qty + #amount: 10 + # useDynamicQuantityAsAmount calculates amount instead of quantity + useDynamicQuantityAsAmount: false + # dynamicQuantityIncrease calculates the increase position order quantity dynamically + dynamicQuantityIncrease: + - linRegDynamicQuantity: + quantityLinReg: + interval: 1m + window: 20 + dynamicQuantityLinRegScale: + byPercentage: + linear: + domain: [ -0.0001, 0.00005 ] + range: [ 0, 0.02 ] + # dynamicQuantityDecrease calculates the decrease position order quantity dynamically + dynamicQuantityDecrease: + - linRegDynamicQuantity: + quantityLinReg: + interval: 1m + window: 20 + dynamicQuantityLinRegScale: + byPercentage: + linear: + domain: [ -0.00005, 0.0001 ] + range: [ 0.02, 0 ] + + # minProfitSpread is the minimal order price spread from the current average cost. + # For long position, you will only place sell order above the price (= average cost * (1 + minProfitSpread)) + # For short position, you will only place buy order below the price (= average cost * (1 - minProfitSpread)) + minProfitSpread: 0.1% + + # minProfitActivationRate activates MinProfitSpread when position RoI higher than the specified percentage + minProfitActivationRate: -10% + + exits: + # roiStopLoss is the stop loss percentage of the position ROI (currently the price change) + - roiStopLoss: + percentage: 30% + + profitStatsTracker: + interval: 1d + window: 30 + accumulatedProfitReport: + profitMAWindow: 60 + shortTermProfitWindow: 14 + tsvReportPath: res.tsv + trackParameters: false diff --git a/config/liquiditymaker.yaml b/config/liquiditymaker.yaml new file mode 100644 index 0000000..85288d5 --- /dev/null +++ b/config/liquiditymaker.yaml @@ -0,0 +1,54 @@ +sessions: + max: + exchange: max + envVarPrefix: max + makerFeeRate: 0% + takerFeeRate: 0.025% + +#services: +# googleSpreadSheet: +# jsonTokenFile: ".credentials/google-cloud/service-account-json-token.json" +# spreadSheetId: "YOUR_SPREADSHEET_ID" + +exchangeStrategies: +- on: max + liquiditymaker: + symbol: &symbol USDTTWD + + ## adjustmentUpdateInterval is the interval for adjusting position + adjustmentUpdateInterval: 1m + + ## liquidityUpdateInterval is the interval for updating liquidity orders + liquidityUpdateInterval: 1h + + numOfLiquidityLayers: 30 + askLiquidityAmount: 20_000.0 + bidLiquidityAmount: 20_000.0 + liquidityPriceRange: 2% + useLastTradePrice: true + spread: 1.1% + + liquidityScale: + exp: + domain: [1, 30] + range: [1, 4] + + ## maxExposure controls how much balance should be used for placing the maker orders + maxExposure: 200_000 + minProfit: 0.01% + + +backtest: + sessions: + - max + startTime: "2023-05-20" + endTime: "2023-06-01" + symbols: + - *symbol + account: + max: + makerFeeRate: 0.0% + takerFeeRate: 0.025% + balances: + USDT: 5000 + TWD: 150_000 diff --git a/config/marketcap.yaml b/config/marketcap.yaml new file mode 100644 index 0000000..8fc4d7e --- /dev/null +++ b/config/marketcap.yaml @@ -0,0 +1,27 @@ +--- +notifications: + slack: + defaultChannel: "qbtrade" + errorChannel: "qbtrade-error" + switches: + trade: true + orderUpdate: true + submitOrder: true + + +exchangeStrategies: + - on: max + marketcap: + interval: 1m + quoteCurrency: TWD + quoteCurrencyWeight: 0% + baseCurrencies: + - BTC + - ETH + - MATIC + threshold: 1% + # max amount to buy or sell per order + maxAmount: 1_000 + queryInterval: 1h + orderType: LIMIT_MAKER # LIMIT_MAKER, LIMIT, MARKET + dryRun: true diff --git a/config/max-margin.yaml b/config/max-margin.yaml new file mode 100644 index 0000000..6e6e010 --- /dev/null +++ b/config/max-margin.yaml @@ -0,0 +1,35 @@ +--- +sessions: + max_margin: + exchange: max + margin: true + +sync: + # userDataStream is used to sync the trading data in real-time + # it uses the websocket connection to insert the trades + userDataStream: + trades: false + filledOrders: false + + # since is the start date of your trading data + since: 2019-11-01 + + # sessions is the list of session names you want to sync + # by default, qbtrade sync all your available sessions. + sessions: + - max_margin + + # symbols is the list of symbols you want to sync + # by default, qbtrade try to guess your symbols by your existing account balances. + symbols: + - BTCUSDT + - ETHUSDT + + +exchangeStrategies: + +- on: max_margin + pricealert: + symbol: LINKUSDT + interval: 1m + diff --git a/config/minimal.yaml b/config/minimal.yaml new file mode 100644 index 0000000..21a2c89 --- /dev/null +++ b/config/minimal.yaml @@ -0,0 +1,10 @@ +--- +exchangeStrategies: +- on: max + xpuremaker: + symbol: MAXUSDT + numOrders: 2 + side: both + behindVolume: 1000.0 + priceTick: 0.01 + baseQuantity: 100.0 diff --git a/config/multi-session.yaml b/config/multi-session.yaml new file mode 100644 index 0000000..1ec7f47 --- /dev/null +++ b/config/multi-session.yaml @@ -0,0 +1,50 @@ +--- +sessions: + binance_margin_linkusdt: + exchange: binance + margin: true + isolatedMargin: true + isolatedMarginSymbol: LINKUSDT + + binance_cross_margin: + exchange: binance + margin: true + # + # The following API key/secret will be used: + # BINANCE_CROSS_MARGIN_API_KEY=____YOUR_MARGIN_API_KEY____ + # BINANCE_CROSS_MARGIN_API_SECRET=____YOUR_MARGIN_API_SECRET____ + envVarPrefix: BINANCE_CROSS_MARGIN + + binance: + exchange: binance + # + # The following API key/secret will be used: + # BINANCE_SPOT_API_KEY=____YOUR_SPOT_API_KEY____ + # BINANCE_SPOT_API_SECRET=____YOUR_SPOT_API_SECRET____ + envVarPrefix: BINANCE_SPOT + + + +exchangeStrategies: + +- on: binance_margin_linkusdt + support: + symbol: LINKUSDT + interval: 1m + minVolume: 2_000 + marginOrderSideEffect: borrow + + scaleQuantity: + byVolume: + exp: + domain: [ 1_000, 200_000 ] + range: [ 3.0, 5.0 ] + + maxBaseAssetBalance: 1000.0 + minQuoteAssetBalance: 2000.0 + + targets: + - profitPercentage: 0.02 + quantityPercentage: 0.5 + marginOrderSideEffect: repay + diff --git a/config/optimizer-hyperparam-search.yaml b/config/optimizer-hyperparam-search.yaml new file mode 100644 index 0000000..1665963 --- /dev/null +++ b/config/optimizer-hyperparam-search.yaml @@ -0,0 +1,52 @@ +# usage: +# +# go run ./cmd/qbtrade hoptimize --config bollmaker_ethusdt.yaml --optimizer-config optimizer-hyperparam-search.yaml +# +--- +# The search algorithm. Supports the following algorithms: +# - tpe: (default) Tree-structured Parzen Estimators +# - cmaes: Covariance Matrix Adaptation Evolution Strategy +# - sobol: Quasi-monte carlo sampling based on Sobol sequence +# - random: random search +# Reference: https://c-bata.medium.com/practical-bayesian-optimization-in-go-using-goptuna-edf97195fcb5 +algorithm: tpe + +# The objective function to be maximized. Possible options are: +# - profit: by trading profit +# - volume: by trading volume +# - equity: by equity difference +objectiveBy: equity + +# Maximum number of search evaluations. +maxEvaluation: 1000 + +executor: + type: local + local: + maxNumberOfProcesses: 10 + +matrix: +- type: string # alias: iterate + path: '/exchangeStrategies/0/bollmaker/interval' + values: ["1m", "5m"] + +- type: rangeInt + label: window + path: '/exchangeStrategies/0/bollmaker/defaultBollinger/window' + min: 12 + max: 240 + +- type: rangeFloat # alias: range + path: '/exchangeStrategies/0/bollmaker/spread' + min: 0.001 + max: 0.002 + +- type: rangeFloat + path: '/exchangeStrategies/0/bollmaker/quantity' + min: 0.001 + max: 0.070 + # Most markets defines the minimum order amount. "step" is useful in such case. + step: 0.001 + +- type: bool + path: '/exchangeStrategies/0/bollmaker/buyBelowNeutralSMA' \ No newline at end of file diff --git a/config/optimizer.yaml b/config/optimizer.yaml new file mode 100644 index 0000000..7089386 --- /dev/null +++ b/config/optimizer.yaml @@ -0,0 +1,26 @@ +# usage: +# +# go run ./cmd/qbtrade optimize --config bollmaker_ethusdt.yaml --optimizer-config optimizer.yaml --debug +# +--- +executor: + type: local + local: + maxNumberOfProcesses: 10 + +matrix: +- type: iterate + path: '/exchangeStrategies/0/bollmaker/interval' + values: ["1m", "5m"] + +- type: range + path: '/exchangeStrategies/0/bollmaker/amount' + min: 20.0 + max: 40.0 + step: 20.0 + +- type: range + path: '/exchangeStrategies/0/bollmaker/spread' + min: 0.1% + max: 0.2% + step: 0.02% diff --git a/config/pivotshort-GMTBUSD.yaml b/config/pivotshort-GMTBUSD.yaml new file mode 100644 index 0000000..d2d6202 --- /dev/null +++ b/config/pivotshort-GMTBUSD.yaml @@ -0,0 +1,63 @@ +--- +sessions: + binance: + exchange: binance + envVarPrefix: binance + margin: true + isolatedMargin: true + isolatedMarginSymbol: GMTBUSD + # futures: true + +exchangeStrategies: +- on: binance + pivotshort: + symbol: GMTBUSD + interval: 5m + window: 120 + + entry: + immediate: true + catBounceRatio: 1% + quantity: 20 + numLayers: 3 + marginOrderSideEffect: borrow + + exits: + # roiStopLoss is the stop loss percentage of the position ROI (currently the price change) + - roiStopLoss: + percentage: 2% + + # roiTakeProfit is used to force taking profit by percentage of the position ROI (currently the price change) + # force to take the profit ROI exceeded the percentage. + - roiTakeProfit: + percentage: 30% + + - protectiveStopLoss: + activationRatio: 1% + stopLossRatio: 0.2% + placeStopOrder: true + + # lowerShadowTakeProfit is used to taking profit when the (lower shadow height / low price) > lowerShadowRatio + # 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: + ratio: 3% + + # cumulatedVolumeTakeProfit is used to take profit when the cumulated quote volume from the klines exceeded a threshold + - cumulatedVolumeTakeProfit: + minQuoteVolume: 90_000_000 + window: 5 + + +backtest: + sessions: + - binance + startTime: "2022-05-25" + endTime: "2022-06-03" + symbols: + - GMTBUSD + accounts: + binance: + balances: + GMT: 3_000.0 + USDT: 3_000.0 diff --git a/config/pivotshort.yaml b/config/pivotshort.yaml new file mode 100644 index 0000000..414a0ac --- /dev/null +++ b/config/pivotshort.yaml @@ -0,0 +1,153 @@ +--- +sessions: + binance: + exchange: binance + envVarPrefix: binance + + # uncomment this to enable cross margin + margin: true + + # uncomment this to enable isolated margin + # isolatedMargin: true + # isolatedMarginSymbol: ETHUSDT + +exchangeStrategies: +- on: binance + pivotshort: + symbol: ETHUSDT + + # interval is the main pivot interval + interval: 5m + + # window is the main pivot window + window: 200 + + quantity: 10.0 + + # when quantity is not given, leverage will be used. + # leverage: 10.0 + + # breakLow settings are used for shorting when the current price break the previous low + breakLow: + # ratio is how much the price breaks the previous low to trigger the short. + ratio: 0% + + # quantity is used for submitting the sell order + # if quantity is not set, all base balance will be used for selling the short. + quantity: 10.0 + + # marketOrder submits the market sell order when the closed price is lower than the previous pivot low. + # by default we will use market order + marketOrder: true + + # limitOrder place limit order to open the short position instead of using market order + # this is useful when your quantity or leverage is quiet large. + limitOrder: false + + # limitOrderTakerRatio is the price ratio to adjust your limit order as a taker order. e.g., 0.1% + # for sell order, 0.1% ratio means your final price = price * (1 - 0.1%) + # for buy order, 0.1% ratio means your final price = price * (1 + 0.1%) + # this is only enabled when the limitOrder option set to true + limitOrderTakerRatio: 0 + + # bounceRatio is used for calculating the price of the limit sell order. + # it's ratio of pivot low bounce when a new pivot low is detected. + # Sometimes when the price breaks the previous low, the price might be pulled back to a higher price. + # The bounceRatio is useful for such case, however, you might also miss the chance to short at the price if there is no pull back. + # Notice: When marketOrder is set, bounceRatio will not be used. + # bounceRatio: 0.1% + + # stopEMA is the price range we allow short. + # Short-allowed price range = [current price] > [EMA] * (1 - [stopEMARange]) + # Higher the stopEMARange than higher the chance to open a short + stopEMA: + interval: 1h + window: 99 + range: 2% + + trendEMA: + interval: 1d + window: 7 + + resistanceShort: + enabled: true + interval: 5m + window: 80 + + quantity: 10.0 + + # 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, 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) + - roiStopLoss: + percentage: 0.8% + + # (1) roiTakeProfit is used to force taking profit by percentage of the position ROI (currently the price change) + # force to take the profit ROI exceeded the percentage. + - roiTakeProfit: + percentage: 35% + + # (2) protective stop loss -- short term + - protectiveStopLoss: + activationRatio: 0.6% + stopLossRatio: 0.1% + placeStopOrder: false + + # (3) protective stop loss -- long term + - protectiveStopLoss: + activationRatio: 5% + stopLossRatio: 1% + placeStopOrder: false + + # (4) lowerShadowTakeProfit is used to taking profit when the (lower shadow height / low price) > lowerShadowRatio + # 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: + interval: 5m + window: 2 + minQuoteVolume: 200_000_000 + + - trailingStop: + callbackRate: 3% + + # activationRatio is relative to the average cost, + # when side is buy, 1% means lower 1% than the average cost. + # when side is sell, 1% means higher 1% than the average cost. + activationRatio: 40% + + # minProfit uses the position ROI to calculate the profit ratio + # minProfit: 1% + + interval: 1m + side: buy + closePosition: 100% + +backtest: + sessions: + - binance + startTime: "2022-01-01" + endTime: "2022-06-18" + symbols: + - ETHUSDT + accounts: + binance: + balances: + ETH: 10.0 + USDT: 5000.0 diff --git a/config/pivotshort_optimizer.yaml b/config/pivotshort_optimizer.yaml new file mode 100644 index 0000000..d2bc25a --- /dev/null +++ b/config/pivotshort_optimizer.yaml @@ -0,0 +1,65 @@ +# usage: +# +# go run ./cmd/qbtrade optimize --config config/pivotshort.yaml --optimizer-config config/pivotshort_optimizer.yaml --debug +# +--- +executor: + type: local + local: + maxNumberOfProcesses: 10 + +matrix: + +- type: iterate + label: interval + path: '/exchangeStrategies/0/pivotshort/interval' + values: [ "1m", "5m", "30m" ] + +- type: range + path: '/exchangeStrategies/0/pivotshort/window' + label: window + min: 100.0 + max: 200.0 + step: 20.0 + +- type: range + path: '/exchangeStrategies/0/pivotshort/breakLow/stopEMARange' + label: stopEMARange + min: 0% + max: 10% + step: 1% + +- type: range + path: '/exchangeStrategies/0/pivotshort/exits/0/roiStopLoss/percentage' + label: roiStopLossPercentage + min: 0.5% + max: 2% + step: 0.1% + +- type: range + path: '/exchangeStrategies/0/pivotshort/exits/1/roiTakeProfit/percentage' + label: roiTakeProfit + min: 10% + max: 40% + step: 5% + +- type: range + path: '/exchangeStrategies/0/pivotshort/exits/2/protectiveStopLoss/activationRatio' + label: protectiveStopLoss_activationRatio + min: 0.5% + max: 3% + step: 0.1% + +- type: range + path: '/exchangeStrategies/0/pivotshort/exits/4/lowerShadowTakeProfit/ratio' + label: lowerShadowTakeProfit_ratio + min: 1% + max: 10% + step: 1% + +- type: range + path: '/exchangeStrategies/0/pivotshort/exits/5/cumulatedVolumeTakeProfit/minQuoteVolume' + label: cumulatedVolumeTakeProfit_minQuoteVolume + min: 3_000_000 + max: 20_000_000 + step: 100_000 diff --git a/config/pricealert-tg.yaml b/config/pricealert-tg.yaml new file mode 100644 index 0000000..2021160 --- /dev/null +++ b/config/pricealert-tg.yaml @@ -0,0 +1,12 @@ +--- +sessions: + binance: + exchange: binance + envVarPrefix: binance + +exchangeStrategies: +- on: binance + pricealert: + symbol: "BTCUSDT" + interval: "1m" + minChange: 300 \ No newline at end of file diff --git a/config/pricealert.yaml b/config/pricealert.yaml new file mode 100644 index 0000000..85c9511 --- /dev/null +++ b/config/pricealert.yaml @@ -0,0 +1,22 @@ +--- +notifications: + slack: + defaultChannel: "dev-qbtrade" + errorChannel: "qbtrade-error" + + switches: + trade: true + orderUpdate: true + submitOrder: true + +sessions: + binance: + exchange: binance + envVarPrefix: binance + +exchangeStrategies: +- on: binance + pricealert: + symbol: "BTCUSDT" + interval: "1m" + minChange: 0.01 diff --git a/config/pricedrop.yaml b/config/pricedrop.yaml new file mode 100644 index 0000000..d49c193 --- /dev/null +++ b/config/pricedrop.yaml @@ -0,0 +1,55 @@ +--- +notifications: + slack: + defaultChannel: "qbtrade" + errorChannel: "qbtrade-error" + switches: + trade: true + orderUpdate: true + submitOrder: true + +sessions: + binance: + exchange: binance + envVarPrefix: binance + +riskControls: + # This is the session-based risk controller, which let you configure different risk controller by session. + sessionBased: + # "max" is the session name that you want to configure the risk control + max: + # orderExecutors is one of the risk control + orderExecutor: + # symbol-routed order executor + bySymbol: + BTCUSDT: + # basic risk control order executor + basic: + minQuoteBalance: 1000.0 + maxBaseAssetBalance: 2.0 + minBaseAssetBalance: 0.1 + maxOrderAmount: 100.0 + +backtest: + # for testing max draw down (MDD) at 03-12 + # see here for more details + # https://www.investopedia.com/terms/m/maximum-drawdown-mdd.asp + startTime: "2022-01-01" + endTime: "2022-01-15" + symbols: + - BTCUSDT + account: + binance: + makerFeeRate: 0.075% + takerFeeRate: 0.075% + balances: + BTC: 0.1 + USDT: 10000.0 + +exchangeStrategies: +- on: binance + pricedrop: + symbol: "BTCUSDT" + interval: "1m" + baseQuantity: 0.001 + minDropPercentage: -0.01 diff --git a/config/random.yaml b/config/random.yaml new file mode 100644 index 0000000..290adf1 --- /dev/null +++ b/config/random.yaml @@ -0,0 +1,10 @@ +--- +exchangeStrategies: + - on: max + random: + symbol: USDCUSDT + # https://pkg.go.dev/github.com/robfig/cron#hdr-Predefined_schedules + schedule: "@every 8h" + quantity: 8 + onStart: true + dryRun: true diff --git a/config/rebalance.yaml b/config/rebalance.yaml new file mode 100644 index 0000000..763e2e3 --- /dev/null +++ b/config/rebalance.yaml @@ -0,0 +1,17 @@ +--- +exchangeStrategies: + - on: max + rebalance: + schedule: "@every 1s" + quoteCurrency: TWD + targetWeights: + BTC: 60% + ETH: 30% + TWD: 10% + threshold: 1% + maxAmount: 1_000 # max amount to buy or sell per order + orderType: LIMIT_MAKER # LIMIT, LIMIT_MAKER or MARKET + priceType: MAKER # LAST, MID, TAKER or MAKER + balanceType: TOTAL + dryRun: true + onStart: true diff --git a/config/rsicross.yaml b/config/rsicross.yaml new file mode 100644 index 0000000..0974438 --- /dev/null +++ b/config/rsicross.yaml @@ -0,0 +1,54 @@ +persistence: + json: + directory: var/data + redis: + host: 127.0.0.1 + port: 6379 + db: 0 + +sessions: + binance: + exchange: binance + envVarPrefix: binance + +exchangeStrategies: +- on: binance + rsicross: + symbol: BTCUSDT + interval: 5m + fastWindow: 6 + slowWindow: 18 + openBelow: 30.0 + closeAbove: 70.0 + quantity: 0.1 + + ### RISK CONTROLS + ## circuitBreakEMA is used for calculating the price for circuitBreak + # circuitBreakEMA: + # interval: 1m + # window: 14 + + ## circuitBreakLossThreshold is the maximum loss threshold for realized+unrealized PnL + # circuitBreakLossThreshold: -10.0 + + ## positionHardLimit is the maximum position limit + # positionHardLimit: 500.0 + + ## maxPositionQuantity is the maximum quantity per order that could be controlled in positionHardLimit, + ## this parameter is used with positionHardLimit togerther + # maxPositionQuantity: 10.0 + +backtest: + startTime: "2022-01-01" + endTime: "2022-03-01" + symbols: + - BTCUSDT + sessions: [binance] + # syncSecKLines: true + accounts: + binance: + makerFeeRate: 0.0% + takerFeeRate: 0.075% + balances: + BTC: 0.0 + USDT: 10_000.0 diff --git a/config/rsmaker.yaml b/config/rsmaker.yaml new file mode 100644 index 0000000..9ad6000 --- /dev/null +++ b/config/rsmaker.yaml @@ -0,0 +1,101 @@ +--- +persistence: + redis: + host: 127.0.0.1 + port: 6379 + db: 0 + +sync: + # userDataStream is used to sync the trading data in real-time + # it uses the websocket connection to insert the trades + userDataStream: + trades: true + filledOrders: true + + # since is the start date of your trading data + since: 2021-08-01 + + # sessions is the list of session names you want to sync + # by default, qbtrade sync all your available sessions. + sessions: + - binance + + # symbols is the list of symbols you want to sync + # by default, qbtrade try to guess your symbols by your existing account balances. + symbols: + - NEARBUSD + - BTCUSDT + - ETHUSDT + - LINKUSDT + - BNBUSDT + - DOTUSDT + - DOTBUSD + + +sessions: + binance: + exchange: binance + envVarPrefix: binance +# futures: true + + +exchangeStrategies: +- on: binance + rsmaker: + symbol: BTCBUSD + interval: 1m +# quantity: 40 + amount: 20 + minProfitSpread: 0.1% + +# uptrendSkew: 0.7 + + # downtrendSkew, like the strongDowntrendSkew, but the price is still in the default band. +# downtrendSkew: 1.3 + + # tradeInBand: when tradeInBand is set, you will only place orders in the bollinger band. +# tradeInBand: true + + # buyBelowNeutralSMA: when this set, it will only place buy order when the current price is below the SMA line. +# buyBelowNeutralSMA: true + + defaultBollinger: + interval: "1h" + window: 21 + bandWidth: 2.0 + + # neutralBollinger is the smaller range of the bollinger band + # If price is in this band, it usually means the price is oscillating. + neutralBollinger: + interval: "5m" + window: 21 + bandWidth: 2.0 + + dynamicExposurePositionScale: + byPercentage: + # exp means we want to use exponential scale, you can replace "exp" with "linear" for linear scale + exp: + # from lower band -100% (-1) to upper band 100% (+1) + domain: [ -2, 2 ] + # when in down band, holds 1.0 by maximum + # when in up band, holds 0.05 by maximum + range: [ 1, 0.01 ] + + + +backtest: + sessions: + - binance + # for testing max draw down (MDD) at 03-12 + # see here for more details + # https://www.investopedia.com/terms/m/maximum-drawdown-mdd.asp + startTime: "2022-03-26" + endTime: "2022-04-12" + symbols: + - BTCBUSD + account: + binance: + makerFeeRate: 0.0 + balances: + BTC: 1 + BUSD: 45_000.0 diff --git a/config/schedule-USDTTWD.yaml b/config/schedule-USDTTWD.yaml new file mode 100644 index 0000000..068557f --- /dev/null +++ b/config/schedule-USDTTWD.yaml @@ -0,0 +1,33 @@ +--- +backtest: + sessions: + - max + startTime: "2022-01-01" + endTime: "2022-06-18" + symbols: + - USDTTWD + accounts: + binance: + balances: + TWD: 280_000.0 + +exchangeStrategies: +- on: max + schedule: + interval: 1m + symbol: USDTTWD + side: buy + amount: 500 + + aboveMovingAverage: + type: EWMA + interval: 1h + window: 99 + side: sell + + belowMovingAverage: + type: EWMA + interval: 1h + window: 99 + side: buy + diff --git a/config/schedule-btcusdt.yaml b/config/schedule-btcusdt.yaml new file mode 100644 index 0000000..5ab8ce2 --- /dev/null +++ b/config/schedule-btcusdt.yaml @@ -0,0 +1,30 @@ +--- +riskControls: + # This is the session-based risk controller, which let you configure different risk controller by session. + sessionBased: + # "max" is the session name that you want to configure the risk control + max: + # orderExecutor is one of the risk control + orderExecutor: + # symbol-routed order executor + bySymbol: + BTCUSDT: + # basic risk control order executor + basic: + minQuoteBalance: 1000.0 + maxBaseAssetBalance: 500.0 + minBaseAssetBalance: 300.0 + maxOrderAmount: 1000.0 + +exchangeStrategies: + +- on: max + schedule: + interval: 1h + symbol: BTCUSDT + side: buy + quantity: 0.001 + belowMovingAverage: + type: EWMA + interval: 1h + window: 99 diff --git a/config/schedule-ethusdt.yaml b/config/schedule-ethusdt.yaml new file mode 100644 index 0000000..8713b1e --- /dev/null +++ b/config/schedule-ethusdt.yaml @@ -0,0 +1,83 @@ +--- + +sessions: + binance: + exchange: binance + envVarPrefix: binance + +# time godotenv -f .env.local -- go run ./cmd/qbtrade backtest --base-asset-baseline --config config/schedule-ethusdt.yaml -v +backtest: + startTime: "2021-08-01" + endTime: "2021-08-07" + symbols: + - ETHUSDT + account: + binance: + balances: + ETH: 1.0 + USDT: 20_000.0 + +riskControls: + # This is the session-based risk controller, which let you configure different risk controller by session. + sessionBased: + # "max" is the session name that you want to configure the risk control + max: + # orderExecutor is one of the risk control + orderExecutor: + # symbol-routed order executor + bySymbol: + ETHUSDT: + # basic risk control order executor + basic: + minQuoteBalance: 1000.0 + maxBaseAssetBalance: 500.0 + minBaseAssetBalance: 300.0 + maxOrderAmount: 1000.0 + +exchangeStrategies: + +- on: binance + schedule: + # trigger schedule per hour + # valid intervals are: 1m, 5m, 15m, 30m, 1h, 2h, 4h, 6h, 12h, 1d + interval: 1h + + symbol: ETHUSDT + side: buy + + # quantity is the quantity of the crypto (in base currency) you want to buy/sell + # quantity: 0.01 + + # amount is the quote quantity of the crypto (in quote currency), here is USDT. + # 11.0 means you want to buy ETH with 11 USDT. + # the quantity will be calculated automatically, according to the latest price + amount: 11.0 + + # belowMovingAverage is a special override (optional) + # execute order only when the closed price is below the moving average line. + # you can open the app to adjust your parameters here. + # the interval here could be different from the triggering interval. + belowMovingAverage: + type: EWMA + interval: 1h + window: 99 + + # you can override the default side + side: buy + + # you can choose one of quantity or amount + # quantity: 0.05 + # amount is how much quote balance you want to buy + # here 11.0 means you want to buy ETH with 11.0 USDT + # please note that crypto exchange requires you to submit an order above the min notional limit $10 usdt + amount: 11.0 + + # aboveMovingAverage is a special override (optional) + # aboveMovingAverage: + # type: EWMA + # interval: 1h + # window: 99 + # side: sell + # # quantity: 0.05 + # amount: 11.0 + diff --git a/config/schedule.yaml b/config/schedule.yaml new file mode 100644 index 0000000..28754cf --- /dev/null +++ b/config/schedule.yaml @@ -0,0 +1,33 @@ +--- +backtest: + sessions: + - binance + startTime: "2022-01-01" + endTime: "2022-06-18" + symbols: + - ETHUSDT + accounts: + binance: + balances: + USDT: 20_000.0 + +exchangeStrategies: +- on: binance + schedule: + interval: 1h + symbol: ETHUSDT + side: buy + amount: 20 + + aboveMovingAverage: + type: EWMA + interval: 1h + window: 99 + side: sell + + belowMovingAverage: + type: EWMA + interval: 1h + window: 99 + side: buy + diff --git a/config/scmaker.yaml b/config/scmaker.yaml new file mode 100644 index 0000000..0f18409 --- /dev/null +++ b/config/scmaker.yaml @@ -0,0 +1,80 @@ +sessions: + max: + exchange: max + envVarPrefix: max + makerFeeRate: 0% + takerFeeRate: 0.025% + +#services: +# googleSpreadSheet: +# jsonTokenFile: ".credentials/google-cloud/service-account-json-token.json" +# spreadSheetId: "YOUR_SPREADSHEET_ID" + +exchangeStrategies: +- on: max + scmaker: + symbol: &symbol USDCUSDT + + ## adjustmentUpdateInterval is the interval for adjusting position + adjustmentUpdateInterval: 1m + + ## liquidityUpdateInterval is the interval for updating liquidity orders + liquidityUpdateInterval: 1h + liquiditySkew: 1.5 + numOfLiquidityLayers: 10 + liquidityLayerTickSize: 0.0001 + liquidityScale: + exp: + domain: [0, 9] + range: [1, 4] + + + ## maxExposure controls how much balance should be used for placing the maker orders + maxExposure: 10_000 + + ## circuitBreakEMA is used for calculating the price for circuitBreak + circuitBreakEMA: + interval: 1m + window: 14 + + ## circuitBreakLossThreshold is the maximum loss threshold for realized+unrealized PnL + circuitBreakLossThreshold: -10.0 + + ## positionHardLimit is the maximum position limit + positionHardLimit: 500.0 + + ## maxPositionQuantity is the maximum quantity per order that could be controlled in positionHardLimit, + ## this parameter is used with positionHardLimit togerther + maxPositionQuantity: 10.0 + + + midPriceEMA: + interval: 1h + window: 99 + + ## priceRangeBollinger is used for the liquidity price range + priceRangeBollinger: + interval: 1h + window: 10 + k: 1.0 + + + strengthInterval: 1m + + minProfit: 0.01% + + +backtest: + sessions: + - max + startTime: "2023-05-20" + endTime: "2023-06-01" + symbols: + - *symbol + account: + max: + makerFeeRate: 0.0% + takerFeeRate: 0.025% + balances: + USDC: 5000 + USDT: 5000 diff --git a/config/skeleton.yaml b/config/skeleton.yaml new file mode 100644 index 0000000..d801df3 --- /dev/null +++ b/config/skeleton.yaml @@ -0,0 +1,24 @@ +--- +sessions: + binance: + exchange: binance + heikinAshi: true + envVarPrefix: binance + +exchangeStrategies: + +- on: binance + skeleton: + symbol: BNBBUSD + +backtest: + startTime: "2022-06-14" + endTime: "2022-06-15" + symbols: + - BNBBUSD + sessions: [binance] + account: + binance: + balances: + BNB: 0 + BUSD: 10000 diff --git a/config/supertrend.yaml b/config/supertrend.yaml new file mode 100644 index 0000000..e9b182a --- /dev/null +++ b/config/supertrend.yaml @@ -0,0 +1,124 @@ +--- +persistence: + redis: + host: 127.0.0.1 + port: 6379 + db: 0 + +sessions: + binance: + exchange: binance + envVarPrefix: binance + margin: true + isolatedMargin: true + isolatedMarginSymbol: BTCUSDT + +backtest: + sessions: [binance] + # for testing max draw down (MDD) at 03-12 + # see here for more details + # https://www.investopedia.com/terms/m/maximum-drawdown-mdd.asp + startTime: "2022-05-01" + endTime: "2022-10-31" + symbols: + - BTCUSDT + accounts: + binance: + makerCommission: 10 # 0.15% + takerCommission: 15 # 0.15% + balances: + BTC: 50.0 + USDT: 10000.0 + +exchangeStrategies: +- on: binance + supertrend: + symbol: BTCUSDT + + # interval is how long do you want to update your order price and quantity + interval: 1m + + # ATR window used by Supertrend + window: 220 + # ATR Multiplier for calculating super trend prices, the higher, the stronger the trends are + supertrendMultiplier: 10 + + # leverage uses the account net value to calculate the order qty + leverage: 1.0 + # quantity sets the fixed order qty, takes precedence over Leverage + #quantity: 0.5 + + # fastDEMAWindow and slowDEMAWindow are for filtering super trend noise + fastDEMAWindow: 28 + slowDEMAWindow: 170 + + # Use linear regression as trend confirmation + linearRegression: + interval: 1m + window: 18 + + # TP according to ATR multiple, 0 to disable this + TakeProfitAtrMultiplier: 0 + + # Set SL price to the low of the triggering Kline + stopLossByTriggeringK: false + + # TP/SL by reversed supertrend signal + stopByReversedSupertrend: false + + # TP/SL by reversed DEMA signal + stopByReversedDema: false + + # TP/SL by reversed linear regression signal + stopByReversedLinGre: false + + # Draw pnl + drawGraph: true + graphPNLPath: "./pnl.png" + graphCumPNLPath: "./cumpnl.png" + + exits: + # roiStopLoss is the stop loss percentage of the position ROI (currently the price change) + - roiStopLoss: + percentage: 2% + - trailingStop: + callbackRate: 2% + #activationRatio: 20% + minProfit: 10% + interval: 1m + side: both + closePosition: 100% + - higherHighLowerLowStopLoss: + # interval is the kline interval used by this exit + interval: 15m + # window is used as the range to determining higher highs and lower lows + window: 5 + # highLowWindow is the range to calculate the number of higher highs and lower lows + highLowWindow: 12 + # If the number of higher highs or lower lows with in HighLowWindow is less than MinHighLow, the exit is + # triggered. 0 disables this parameter. Either one of MaxHighLow and MinHighLow must be larger than 0 + minHighLow: 2 + # If the number of higher highs or lower lows with in HighLowWindow is more than MaxHighLow, the exit is + # triggered. 0 disables this parameter. Either one of MaxHighLow and MinHighLow must be larger than 0 + maxHighLow: 0 + # ActivationRatio is the trigger condition + # When the price goes higher (lower for short position) than this ratio, the stop will be activated. + # You can use this to combine several exits + activationRatio: 0.5% + # DeactivationRatio is the kill condition + # When the price goes higher (lower for short position) than this ratio, the stop will be deactivated. + # You can use this to combine several exits + deactivationRatio: 10% + # If true, looking for lower lows in long position and higher highs in short position. If false, looking for + # higher highs in long position and lower lows in short position + oppositeDirectionAsPosition: false + + profitStatsTracker: + interval: 1d + window: 30 + accumulatedProfitReport: + profitMAWindow: 60 + shortTermProfitWindow: 14 + accumulateTradeWindow: 30 + tsvReportPath: res.tsv + trackParameters: false diff --git a/config/support-margin.yaml b/config/support-margin.yaml new file mode 100644 index 0000000..0886bd3 --- /dev/null +++ b/config/support-margin.yaml @@ -0,0 +1,70 @@ +--- +notifications: + slack: + defaultChannel: "dev-qbtrade" + errorChannel: "qbtrade-error" + + switches: + trade: true + orderUpdate: true + submitOrder: true + +sessions: + binance_margin_linkusdt: + exchange: binance + margin: true + isolatedMargin: true + isolatedMarginSymbol: LINKUSDT + +riskControls: + # This is the session-based risk controller, which let you configure different risk controller by session. + sessionBased: + max: + orderExecutor: + bySymbol: + BTCUSDT: + basic: + minQuoteBalance: 100.0 + maxBaseAssetBalance: 3.0 + minBaseAssetBalance: 0.0 + maxOrderAmount: 1000.0 + +backtest: + # for testing max draw down (MDD) at 03-12 + # see here for more details + # https://www.investopedia.com/terms/m/maximum-drawdown-mdd.asp + startTime: "2020-09-04" + endTime: "2020-09-14" + symbols: + - LINKUSDT + account: + binance: + makerFeeRate: 0.075% + takerFeeRate: 0.075% + balances: + LINK: 0.0 + USDT: 10000.0 + +exchangeStrategies: + +- on: binance_margin_linkusdt + support: + symbol: LINKUSDT + interval: 1m + minVolume: 2_000 + marginOrderSideEffect: borrow + + scaleQuantity: + byVolume: + exp: + domain: [ 1_000, 200_000 ] + range: [ 3.0, 5.0 ] + + maxBaseAssetBalance: 1000.0 + minQuoteAssetBalance: 2000.0 + + targets: + - profitPercentage: 0.02 + quantityPercentage: 0.5 + marginOrderSideEffect: repay + diff --git a/config/support.yaml b/config/support.yaml new file mode 100644 index 0000000..d36057d --- /dev/null +++ b/config/support.yaml @@ -0,0 +1,56 @@ +--- +notifications: + slack: + defaultChannel: "dev-qbtrade" + errorChannel: "qbtrade-error" + switches: + trade: true + orderUpdate: true + submitOrder: true + +sessions: + binance: + exchange: binance + +backtest: + startTime: "2021-09-01" + endTime: "2021-11-01" + sessions: + - binance + symbols: + - LINKUSDT + account: + binance: + balances: + USDT: 10000.0 + +exchangeStrategies: + +- on: binance + support: + symbol: LINKUSDT + interval: 5m + minVolume: 80_000 + triggerMovingAverage: + interval: 5m + window: 99 + longTermMovingAverage: + interval: 1h + window: 99 + + scaleQuantity: + byVolume: + exp: + domain: [ 10_000, 200_000 ] + range: [ 0.5, 1.0 ] + + maxBaseAssetBalance: 1000.0 + minQuoteAssetBalance: 2000.0 + + trailingStopTarget: + callbackRatio: 1.5% + minimumProfitPercentage: 2% + + targets: + - profitPercentage: 0.02 + quantityPercentage: 0.5 diff --git a/config/swing.yaml b/config/swing.yaml new file mode 100644 index 0000000..7b52bf4 --- /dev/null +++ b/config/swing.yaml @@ -0,0 +1,43 @@ +--- +notifications: + slack: + defaultChannel: "dev-qbtrade" + errorChannel: "qbtrade-error" + + switches: + trade: true + orderUpdate: true + submitOrder: true + +sessions: + binance: + exchange: binance + envVarPrefix: binance + +riskControls: + # This is the session-based risk controller, which let you configure different risk controller by session. + sessionBased: + # "max" is the session name that you want to configure the risk control + binance: + # orderExecutor is one of the risk control + orderExecutor: + # symbol-routed order executor + bySymbol: + BNBUSDT: + # basic risk control order executor + basic: + minQuoteBalance: 1000.0 + maxBaseAssetBalance: 50.0 + minBaseAssetBalance: 10.0 + maxOrderAmount: 100.0 + +exchangeStrategies: +- on: binance + swing: + symbol: BNBUSDT + interval: 1m + minChange: 0.01 + baseQuantity: 1.0 + movingAverageType: EWMA + movingAverageInterval: 1m + movingAverageWindow: 99 diff --git a/config/sync.yaml b/config/sync.yaml new file mode 100644 index 0000000..7d79640 --- /dev/null +++ b/config/sync.yaml @@ -0,0 +1,64 @@ +--- +sessions: + binance: + exchange: binance + envVarPrefix: binance + + binance_margin_dotusdt: + exchange: binance + envVarPrefix: binance + margin: true + isolatedMargin: true + isolatedMarginSymbol: DOTUSDT + + max: + exchange: max + envVarPrefix: max + + kucoin: + exchange: kucoin + envVarPrefix: kucoin + + okex: + exchange: okex + envVarPrefix: okex + +sync: + # userDataStream is used to sync the trading data in real-time + # it uses the websocket connection to insert the trades + userDataStream: + trades: true + filledOrders: true + + # since is the start date of your trading data + since: 2019-01-01 + + # sessions is the list of session names you want to sync + # by default, qbtrade sync all your available sessions. + sessions: + - binance + - binance_margin_dotusdt + - max + - okex + - kucoin + + # symbols is the list of symbols you want to sync + # by default, qbtrade try to guess your symbols by your existing account balances. + symbols: + - BTCUSDT + - ETHUSDT + - DOTUSDT + - binance:BNBUSDT + - max:USDTTWD + + # marginHistory enables the margin history sync + marginHistory: true + + # marginAssets lists the assets that are used in the margin. + # including loan, repay, interest and liquidation + marginAssets: + - USDT + + depositHistory: true + rewardHistory: true + withdrawHistory: true diff --git a/config/trendtrader.yaml b/config/trendtrader.yaml new file mode 100644 index 0000000..30a166c --- /dev/null +++ b/config/trendtrader.yaml @@ -0,0 +1,50 @@ +persistence: + json: + directory: var/data + redis: + host: 127.0.0.1 + port: 6379 + db: 0 + +sessions: + binance: + exchange: binance + envVarPrefix: binance +# futures: true + +exchangeStrategies: +- on: binance + trendtrader: + symbol: BTCBUSD + trendLine: + interval: 30m + pivotRightWindow: 40 + quantity: 1 + exits: + - trailingStop: + callbackRate: 1% + activationRatio: 1% + closePosition: 100% + minProfit: 15% + interval: 1m + side: buy + - trailingStop: + callbackRate: 1% + activationRatio: 1% + closePosition: 100% + minProfit: 15% + interval: 1m + side: sell + +backtest: + sessions: + - binance + startTime: "2021-01-01" + endTime: "2022-08-31" + symbols: + - BTCBUSD + accounts: + binance: + balances: + BTC: 1 + BUSD: 50_000.0 \ No newline at end of file diff --git a/config/tri.yaml b/config/tri.yaml new file mode 100644 index 0000000..3176102 --- /dev/null +++ b/config/tri.yaml @@ -0,0 +1,35 @@ +--- +notifications: + slack: + defaultChannel: "dev-qbtrade" + errorChannel: "qbtrade-error" + +sessions: + binance: + exchange: binance + envVarPrefix: binance + +exchangeStrategies: + +## triangular arbitrage strategy +- on: binance + tri: + minSpreadRatio: 1.0011 + separateStream: true + # resetPosition: true + limits: + BTC: 0.001 + ETH: 0.01 + USDT: 20.0 + symbols: + - BNBUSDT + - BNBBTC + - BNBETH + - BTCUSDT + - ETHUSDT + - ETHBTC + paths: + - [ BTCUSDT, ETHBTC, ETHUSDT ] + - [ BNBBTC, BNBUSDT, BTCUSDT ] + - [ BNBETH, BNBUSDT, ETHUSDT ] + diff --git a/config/wall.yaml b/config/wall.yaml new file mode 100644 index 0000000..2f6104d --- /dev/null +++ b/config/wall.yaml @@ -0,0 +1,46 @@ +--- +persistence: + redis: + host: 127.0.0.1 + port: 6379 + db: 0 + +sessions: + max: + exchange: max + envVarPrefix: MAX + + +logging: + trade: true + order: true + # fields: + # env: prod + + +exchangeStrategies: + +- on: max + wall: + symbol: DOTUSDT + + # interval is how long do you want to update your order price and quantity + interval: 1m + + fixedPrice: 2.0 + + side: buy + + # quantity is the base order quantity for your buy/sell order. + # quantity: 0.05 + + numLayers: 3 + layerSpread: 0.1 + + quantityScale: + byLayer: + linear: + domain: [ 1, 3 ] + range: [ 10000.0, 30000.0 ] + + diff --git a/config/xalign.yaml b/config/xalign.yaml new file mode 100644 index 0000000..18e099d --- /dev/null +++ b/config/xalign.yaml @@ -0,0 +1,49 @@ +--- +notifications: + slack: + defaultChannel: "dev-qbtrade" + errorChannel: "qbtrade-error" + + switches: + trade: true + orderUpdate: true + submitOrder: true + +sessions: + max: + exchange: max + envVarPrefix: max + + binance: + exchange: binance + envVarPrefix: binance + +persistence: + json: + directory: var/data + redis: + host: 127.0.0.1 + port: 6379 + db: 0 + +crossExchangeStrategies: +- xalign: + interval: 1m + sessions: + - max + - binance + + ## quoteCurrencies config specifies which quote currency should be used for BUY order or SELL order. + ## when specifying [USDC,TWD] for "BUY", then it will consider BTCUSDC first then BTCTWD second. + quoteCurrencies: + buy: [USDC, TWD] + sell: [USDT] + expectedBalances: + BTC: 0.0440 + useTakerOrder: false + dryRun: true + balanceToleranceRange: 10% + maxAmounts: + USDT: 100 + USDC: 100 + TWD: 3000 diff --git a/config/xbalance.yaml b/config/xbalance.yaml new file mode 100644 index 0000000..54557d2 --- /dev/null +++ b/config/xbalance.yaml @@ -0,0 +1,39 @@ +--- +notifications: + slack: + defaultChannel: "dev-qbtrade" + errorChannel: "qbtrade-error" + + switches: + trade: true + orderUpdate: true + submitOrder: true + +sessions: + max: + exchange: max + envVarPrefix: max + + binance: + exchange: binance + envVarPrefix: binance + +#persistence: +# json: +# directory: var/data +# redis: +# host: 127.0.0.1 +# port: 6379 +# db: 0 + +crossExchangeStrategies: + +- xbalance: + interval: 1h + asset: USDT + addresses: + binance: your_whitelisted_address + max: your_whitelisted_address + low: 5000 + middle: 6000 + diff --git a/config/xdepthmaker.yaml b/config/xdepthmaker.yaml new file mode 100644 index 0000000..b9d1801 --- /dev/null +++ b/config/xdepthmaker.yaml @@ -0,0 +1,67 @@ +--- +notifications: + slack: + defaultChannel: "dev-qbtrade" + errorChannel: "qbtrade-error" + + switches: + trade: true + orderUpdate: false + submitOrder: false + +persistence: + json: + directory: var/data + redis: + host: 127.0.0.1 + port: 6379 + db: 0 + +logging: + trade: true + order: true + fields: + env: staging + +sessions: + max: + exchange: max + envVarPrefix: max + + binance: + exchange: binance + envVarPrefix: binance + +crossExchangeStrategies: + +- xdepthmaker: + symbol: "BTCUSDT" + makerExchange: max + hedgeExchange: binance + + # disableHedge disables the hedge orders on the source exchange + # disableHedge: true + + hedgeInterval: 10s + notifyTrade: true + + margin: 0.004 + askMargin: 0.4% + bidMargin: 0.4% + + depthScale: + byLayer: + linear: + domain: [1, 30] + range: [50, 20_000] + + # numLayers means how many order we want to place on each side. 3 means we want 3 bid orders and 3 ask orders + numLayers: 30 + + # pips is the fraction numbers between each order. for BTC, 1 pip is 0.1, + # 0.1 pip is 0.01, here we use 10, so we will get 18000.00, 18001.00 and + # 18002.00 + pips: 10 + persistence: + type: redis + diff --git a/config/xfixedmaker.yaml b/config/xfixedmaker.yaml new file mode 100644 index 0000000..b1ebaf0 --- /dev/null +++ b/config/xfixedmaker.yaml @@ -0,0 +1,51 @@ +--- +sessions: + max: + exchange: max + envVarPrefix: max + binance: + exchange: binance + envVarPrefix: binance + publicOnly: true + +backtest: + startTime: "2023-01-01" + endTime: "2023-01-02" + symbols: + - BTCUSDT + sessions: + - max + - binance + accounts: + max: + balances: + BTC: 0.5 + USDT: 15000.0 + +crossExchangeStrategies: + - xfixedmaker: + tradingExchange: max + symbol: BTCUSDT + interval: 1m + halfSpread: 0.01% + quantity: 0.005 + orderType: LIMIT_MAKER + dryRun: true + + referenceExchange: binance + referencePriceEMA: + interval: 1m + window: 14 + orderPriceLossThreshold: -10 + + positionHardLimit: 200 + maxPositionQuantity: 0.005 + + circuitBreakLossThreshold: -10 + circuitBreakEMA: + interval: 1m + window: 14 + + inventorySkew: + inventoryRangeMultiplier: 1.0 + targetBaseRatio: 0.5 diff --git a/config/xfunding.yaml b/config/xfunding.yaml new file mode 100644 index 0000000..b768fa3 --- /dev/null +++ b/config/xfunding.yaml @@ -0,0 +1,64 @@ +--- +notifications: + slack: + defaultChannel: "dev-qbtrade" + errorChannel: "qbtrade-error" + + switches: + trade: true + orderUpdate: true + submitOrder: true + +persistence: + redis: + host: 127.0.0.1 + port: 6379 + db: 1 + +sessions: + binance: + exchange: binance + envVarPrefix: BINANCE + + binance_futures: + exchange: binance + envVarPrefix: BINANCE + futures: true + +crossExchangeStrategies: + +- xfunding: + spotSession: binance + futuresSession: binance_futures + + ## symbol is the symbol name of the spot market and the futures market + ## todo: provide option to separate the futures market symbol + symbol: ETHUSDT + + ## interval is the interval for checking futures premium and the funding rate + interval: 1m + + ## leverage is the leverage of the reverse futures position size. + ## for example, you can buy 1 BTC and short 3 BTC in the futures account with 3x leverage. + leverage: 1.0 + + ## incrementalQuoteQuantity is the quote quantity per maker order when creating the positions + ## when in BTC-USDT 20 means 20 USDT, each buy order will hold 20 USDT quote amount. + incrementalQuoteQuantity: 10 + + ## quoteInvestment is how much you want to invest to create your position. + ## for example, when 10k USDT is given as the quote investment, and the average executed price of your position is around BTC 18k + ## you will be holding around 0.555555 BTC + quoteInvestment: 50 + + ## shortFundingRate is the funding rate range you want to create your position + shortFundingRate: + ## when funding rate is higher than this high value, the strategy will start buying spot and opening a short position + high: 0.0001% + ## when funding rate is lower than this low value, the strategy will start closing futures position and sell the spot + low: -0.01% + + ## reset will reset the spot/futures positions, the transfer stats and the position state. + # reset: true + + # closeFuturesPosition: true diff --git a/config/xgap.yaml b/config/xgap.yaml new file mode 100644 index 0000000..39c8f58 --- /dev/null +++ b/config/xgap.yaml @@ -0,0 +1,41 @@ +--- +notifications: + slack: + defaultChannel: "dev-qbtrade" + errorChannel: "qbtrade-error" + + switches: + trade: true + orderUpdate: true + submitOrder: true + +persistence: + json: + directory: var/data + redis: + host: 127.0.0.1 + port: 6379 + db: 0 + +sessions: + max: + exchange: max + envVarPrefix: max + + binance: + exchange: binance + envVarPrefix: binance + publicOnly: true + +crossExchangeStrategies: + +- xgap: + symbol: "ETHUSDT" + sourceExchange: binance + tradingExchange: max + updateInterval: 1m + dailyMaxVolume: 100 + dailyFeeBudgets: + MAX: 100 + persistence: + type: redis diff --git a/config/xmaker-btcusdt.yaml b/config/xmaker-btcusdt.yaml new file mode 100644 index 0000000..777411f --- /dev/null +++ b/config/xmaker-btcusdt.yaml @@ -0,0 +1,80 @@ +--- +notifications: + slack: + defaultChannel: "dev-qbtrade" + errorChannel: "qbtrade-error" + + switches: + trade: true + orderUpdate: true + submitOrder: true + +persistence: + json: + directory: var/data + redis: + host: 127.0.0.1 + port: 6379 + db: 0 + +sessions: + max: + exchange: max + envVarPrefix: MAX + + binance: + exchange: binance + envVarPrefix: BINANCE + +riskControls: + # This is the session-based risk controller, which let you configure different risk controller by session. + sessionBased: + # "max" is the session name that you want to configure the risk control + max: + # orderExecutor is one of the risk control + orderExecutor: + # symbol-routed order executor + bySymbol: + BTCUSDT: + # basic risk control order executor + basic: + # keep at least X USDT (keep cash) + minQuoteBalance: 100.0 + + # maximum BTC balance (don't buy too much) + maxBaseAssetBalance: 1.0 + + # minimum BTC balance (don't sell too much) + minBaseAssetBalance: 0.01 + + maxOrderAmount: 1000.0 + +crossExchangeStrategies: + +- xmaker: + symbol: BTCUSDT + sourceExchange: binance + makerExchange: max + updateInterval: 1s + + # disableHedge disables the hedge orders on the source exchange + # disableHedge: true + + hedgeInterval: 10s + + margin: 0.004 + askMargin: 0.004 + bidMargin: 0.004 + + quantity: 0.001 + quantityMultiplier: 2 + + # numLayers means how many order we want to place on each side. 3 means we want 3 bid orders and 3 ask orders + numLayers: 1 + # pips is the fraction numbers between each order. for BTC, 1 pip is 0.1, + # 0.1 pip is 0.01, here we use 10, so we will get 18000.00, 18001.00 and + # 18002.00 + pips: 10 + persistence: + type: redis + diff --git a/config/xmaker-ethusdt.yaml b/config/xmaker-ethusdt.yaml new file mode 100644 index 0000000..8f58972 --- /dev/null +++ b/config/xmaker-ethusdt.yaml @@ -0,0 +1,82 @@ +--- +notifications: + slack: + defaultChannel: "dev-qbtrade" + errorChannel: "qbtrade-error" + + switches: + trade: true + orderUpdate: false + submitOrder: false + +persistence: + json: + directory: var/data + redis: + host: 127.0.0.1 + port: 6379 + db: 0 + +sessions: + max: + exchange: max + envVarPrefix: max + + binance: + exchange: binance + envVarPrefix: binance + +riskControls: + # This is the session-based risk controller, which let you configure different risk controller by session. + sessionBased: + # "max" is the session name that you want to configure the risk control + max: + # orderExecutor is one of the risk control + orderExecutor: + # symbol-routed order executor + bySymbol: + ETHUSDT: + # basic risk control order executor + basic: + # keep at least X USDT (keep cash) + minQuoteBalance: 100.0 + + # maximum ETH balance (don't buy too much) + maxBaseAssetBalance: 10.0 + + # minimum ETH balance (don't sell too much) + minBaseAssetBalance: 0.0 + + maxOrderAmount: 1000.0 + +crossExchangeStrategies: + +- xmaker: + symbol: ETHUSDT + sourceExchange: binance + makerExchange: max + updateInterval: 2s + + # disableHedge disables the hedge orders on the source exchange + # disableHedge: true + + hedgeInterval: 10s + + margin: 0.004 + askMargin: 0.004 + bidMargin: 0.004 + + quantity: 0.01 + quantityMultiplier: 2 + + # numLayers means how many order we want to place on each side. 3 means we want 3 bid orders and 3 ask orders + numLayers: 2 + + # pips is the fraction numbers between each order. for BTC, 1 pip is 0.1, + # 0.1 pip is 0.01, here we use 10, so we will get 18000.00, 18001.00 and + # 18002.00 + pips: 10 + + persistence: + type: redis + diff --git a/config/xmaker.yaml b/config/xmaker.yaml new file mode 100644 index 0000000..eb30e5d --- /dev/null +++ b/config/xmaker.yaml @@ -0,0 +1,64 @@ +--- +notifications: + slack: + defaultChannel: "dev-qbtrade" + errorChannel: "qbtrade-error" + + switches: + trade: true + orderUpdate: false + submitOrder: false + +persistence: + json: + directory: var/data + redis: + host: 127.0.0.1 + port: 6379 + db: 0 + +logging: + trade: true + order: true + fields: + env: staging + +sessions: + max: + exchange: max + envVarPrefix: max + + binance: + exchange: binance + envVarPrefix: binance + +crossExchangeStrategies: + +- xmaker: + symbol: "BTCUSDT" + sourceExchange: binance + makerExchange: max + updateInterval: 1s + + # disableHedge disables the hedge orders on the source exchange + # disableHedge: true + + hedgeInterval: 10s + notifyTrade: true + + margin: 0.004 + askMargin: 0.4% + bidMargin: 0.4% + + quantity: 0.001 + quantityMultiplier: 2 + + # numLayers means how many order we want to place on each side. 3 means we want 3 bid orders and 3 ask orders + numLayers: 1 + # pips is the fraction numbers between each order. for BTC, 1 pip is 0.1, + # 0.1 pip is 0.01, here we use 10, so we will get 18000.00, 18001.00 and + # 18002.00 + pips: 10 + persistence: + type: redis + diff --git a/config/xnav.yaml b/config/xnav.yaml new file mode 100644 index 0000000..cdfd73b --- /dev/null +++ b/config/xnav.yaml @@ -0,0 +1,36 @@ +--- +notifications: + slack: + defaultChannel: "dev-qbtrade" + errorChannel: "qbtrade-error" + + switches: + trade: true + orderUpdate: true + submitOrder: true + +sessions: + max: + exchange: max + envVarPrefix: max + + binance: + exchange: binance + envVarPrefix: binance + +persistence: + json: + directory: var/data + redis: + host: 127.0.0.1 + port: 6379 + db: 0 + +crossExchangeStrategies: + +- xnav: + interval: 1h + # schedule: "0 * * * *" # every hour + reportOnStart: true + ignoreDusts: true + diff --git a/config/xpuremaker.yaml b/config/xpuremaker.yaml new file mode 100644 index 0000000..9b2443e --- /dev/null +++ b/config/xpuremaker.yaml @@ -0,0 +1,28 @@ +--- +notifications: + slack: + defaultChannel: "qbtrade" + errorChannel: "qbtrade-error" + + switches: + trade: true + orderUpdate: true + submitOrder: true + +sessions: + max: + exchange: max + envVarPrefix: max + binance: + exchange: binance + envVarPrefix: binance + +exchangeStrategies: +- on: max + xpuremaker: + symbol: MAXUSDT + numOrders: 2 + side: both + behindVolume: 1000.0 + priceTick: 0.001 + baseQuantity: 100.0 diff --git a/go.mod b/go.mod index 2fc651e..8c37639 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module git.qtrade.icu/lychiyu/qbtrade -go 1.22 +go 1.22.0 diff --git a/pkg/accounting/cost_distribution.go b/pkg/accounting/cost_distribution.go new file mode 100644 index 0000000..cc82258 --- /dev/null +++ b/pkg/accounting/cost_distribution.go @@ -0,0 +1,236 @@ +package accounting + +import ( + "math" + "sort" + "strconv" + "strings" + "sync" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type Stock types.Trade + +func (stock *Stock) String() string { + return stock.Price.String() + " (" + stock.Quantity.String() + ")" +} + +func (stock *Stock) Consume(quantity fixedpoint.Value) fixedpoint.Value { + q := fixedpoint.Min(stock.Quantity, quantity) + stock.Quantity = stock.Quantity.Sub(q) + return q +} + +type StockSlice []Stock + +func (slice StockSlice) QuantityBelowPrice(price fixedpoint.Value) (quantity fixedpoint.Value) { + for _, stock := range slice { + if stock.Price.Compare(price) < 0 { + quantity = quantity.Add(stock.Quantity) + } + } + + return quantity +} + +func (slice StockSlice) Quantity() (total fixedpoint.Value) { + for _, stock := range slice { + total = total.Add(stock.Quantity) + } + + return total +} + +type StockDistribution struct { + mu sync.Mutex + + Symbol string + TradingFeeCurrency string + Stocks StockSlice + PendingSells StockSlice +} + +type DistributionStats struct { + PriceLevels []string `json:"priceLevels"` + TotalQuantity fixedpoint.Value `json:"totalQuantity"` + Quantities map[string]fixedpoint.Value `json:"quantities"` + Stocks map[string]StockSlice `json:"stocks"` +} + +func (m *StockDistribution) DistributionStats(level int) *DistributionStats { + var d = DistributionStats{ + Quantities: map[string]fixedpoint.Value{}, + Stocks: map[string]StockSlice{}, + } + + for _, stock := range m.Stocks { + n := math.Ceil(math.Log10(stock.Price.Float64())) + digits := int(n - math.Max(float64(level), 1.0)) + key := stock.Price.Round(-digits, fixedpoint.Down).FormatString(2) + + d.TotalQuantity = d.TotalQuantity.Add(stock.Quantity) + d.Stocks[key] = append(d.Stocks[key], stock) + d.Quantities[key] = d.Quantities[key].Add(stock.Quantity) + } + + var priceLevels []float64 + for priceString := range d.Stocks { + price, _ := strconv.ParseFloat(priceString, 32) + priceLevels = append(priceLevels, price) + } + sort.Float64s(priceLevels) + + for _, price := range priceLevels { + d.PriceLevels = append(d.PriceLevels, strconv.FormatFloat(price, 'f', 2, 64)) + } + + return &d +} + +func (m *StockDistribution) stock(stock Stock) error { + m.mu.Lock() + m.Stocks = append(m.Stocks, stock) + m.mu.Unlock() + return m.flushPendingSells() +} + +func (m *StockDistribution) squash() { + m.mu.Lock() + defer m.mu.Unlock() + + var squashed StockSlice + for _, stock := range m.Stocks { + if !stock.Quantity.IsZero() { + squashed = append(squashed, stock) + } + } + m.Stocks = squashed +} + +func (m *StockDistribution) flushPendingSells() error { + if len(m.Stocks) == 0 || len(m.PendingSells) == 0 { + return nil + } + + pendingSells := m.PendingSells + m.PendingSells = nil + + for _, sell := range pendingSells { + if err := m.consume(sell); err != nil { + return err + } + } + + return nil +} + +func (m *StockDistribution) consume(sell Stock) error { + m.mu.Lock() + defer m.mu.Unlock() + + if len(m.Stocks) == 0 { + m.PendingSells = append(m.PendingSells, sell) + return nil + } + + idx := len(m.Stocks) - 1 + for ; idx >= 0; idx-- { + stock := m.Stocks[idx] + + // find any stock price is lower than the sell trade + if stock.Price.Compare(sell.Price) >= 0 { + continue + } + + if stock.Quantity.IsZero() { + continue + } + + delta := stock.Consume(sell.Quantity) + sell.Consume(delta) + m.Stocks[idx] = stock + + if sell.Quantity.IsZero() { + return nil + } + } + + idx = len(m.Stocks) - 1 + for ; idx >= 0; idx-- { + stock := m.Stocks[idx] + + if stock.Quantity.IsZero() { + continue + } + + delta := stock.Consume(sell.Quantity) + sell.Consume(delta) + m.Stocks[idx] = stock + + if sell.Quantity.IsZero() { + return nil + } + } + + if sell.Quantity.Sign() > 0 { + m.PendingSells = append(m.PendingSells, sell) + } + + return nil +} + +func (m *StockDistribution) AddTrades(trades []types.Trade) (checkpoints []int, err error) { + feeSymbol := strings.HasPrefix(m.Symbol, m.TradingFeeCurrency) + for idx, trade := range trades { + // for other market trades + // convert trading fee trades to sell trade + if trade.Symbol != m.Symbol { + if feeSymbol && trade.FeeCurrency == m.TradingFeeCurrency { + trade.Symbol = m.Symbol + trade.IsBuyer = false + trade.Quantity = trade.Fee + trade.Fee = fixedpoint.Zero + } + } + + if trade.Symbol != m.Symbol { + continue + } + + if trade.IsBuyer { + if idx > 0 && len(m.Stocks) == 0 { + checkpoints = append(checkpoints, idx) + } + + stock := toStock(trade) + if err := m.stock(stock); err != nil { + return checkpoints, err + } + } else { + stock := toStock(trade) + if err := m.consume(stock); err != nil { + return checkpoints, err + } + } + } + + err = m.flushPendingSells() + + m.squash() + + return checkpoints, err +} + +func toStock(trade types.Trade) Stock { + if strings.HasPrefix(trade.Symbol, trade.FeeCurrency) { + if trade.IsBuyer { + trade.Quantity = trade.Quantity.Sub(trade.Fee) + } else { + trade.Quantity = trade.Quantity.Add(trade.Fee) + } + trade.Fee = fixedpoint.Zero + } + return Stock(trade) +} diff --git a/pkg/accounting/cost_distribution_test.go b/pkg/accounting/cost_distribution_test.go new file mode 100644 index 0000000..a60802f --- /dev/null +++ b/pkg/accounting/cost_distribution_test.go @@ -0,0 +1,184 @@ +package accounting + +import ( + "encoding/json" + "io/ioutil" + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func TestStockManager(t *testing.T) { + + t.Run("testdata", func(t *testing.T) { + var trades []types.Trade + + out, err := ioutil.ReadFile("testdata/btcusdt-trades.json") + assert.NoError(t, err) + + err = json.Unmarshal(out, &trades) + assert.NoError(t, err) + + var stockManager = &StockDistribution{ + TradingFeeCurrency: "BNB", + Symbol: "BTCUSDT", + } + + _, err = stockManager.AddTrades(trades) + assert.NoError(t, err) + assert.Equal(t, "0.72970242", stockManager.Stocks.Quantity().String()) + assert.NotEmpty(t, stockManager.Stocks) + assert.Equal(t, 20, len(stockManager.Stocks)) + assert.Equal(t, 0, len(stockManager.PendingSells)) + + }) + + t.Run("stock", func(t *testing.T) { + var trades = []types.Trade{ + {Symbol: "BTCUSDT", Price: fixedpoint.MustNewFromString("9100.0"), Quantity: fixedpoint.MustNewFromString("0.05"), IsBuyer: true}, + {Symbol: "BTCUSDT", Price: fixedpoint.MustNewFromString("9100.0"), Quantity: fixedpoint.MustNewFromString("0.05"), IsBuyer: true}, + {Symbol: "BTCUSDT", Price: fixedpoint.MustNewFromString("9200.0"), Quantity: fixedpoint.MustNewFromString("0.01"), IsBuyer: false}, + } + + var stockManager = &StockDistribution{ + TradingFeeCurrency: "BNB", + Symbol: "BTCUSDT", + } + + _, err := stockManager.AddTrades(trades) + assert.NoError(t, err) + assert.Len(t, stockManager.Stocks, 2) + assert.Equal(t, StockSlice{ + { + Symbol: "BTCUSDT", + Price: fixedpoint.MustNewFromString("9100.0"), + Quantity: fixedpoint.MustNewFromString("0.05"), + IsBuyer: true, + }, + { + Symbol: "BTCUSDT", + Price: fixedpoint.MustNewFromString("9100.0"), + Quantity: fixedpoint.MustNewFromString("0.04"), + IsBuyer: true, + }, + }, stockManager.Stocks) + assert.Len(t, stockManager.PendingSells, 0) + }) + + t.Run("sold out", func(t *testing.T) { + var trades = []types.Trade{ + {Symbol: "BTCUSDT", Price: fixedpoint.MustNewFromString("9100.0"), Quantity: fixedpoint.MustNewFromString("0.05"), IsBuyer: true}, + {Symbol: "BTCUSDT", Price: fixedpoint.MustNewFromString("9200.0"), Quantity: fixedpoint.MustNewFromString("0.05"), IsBuyer: false}, + {Symbol: "BTCUSDT", Price: fixedpoint.MustNewFromString("9100.0"), Quantity: fixedpoint.MustNewFromString("0.05"), IsBuyer: true}, + {Symbol: "BTCUSDT", Price: fixedpoint.MustNewFromString("9200.0"), Quantity: fixedpoint.MustNewFromString("0.05"), IsBuyer: false}, + } + + var stockManager = &StockDistribution{ + TradingFeeCurrency: "BNB", + Symbol: "BTCUSDT", + } + + _, err := stockManager.AddTrades(trades) + assert.NoError(t, err) + assert.Len(t, stockManager.Stocks, 0) + assert.Len(t, stockManager.PendingSells, 0) + }) + + t.Run("oversell", func(t *testing.T) { + var trades = []types.Trade{ + {Symbol: "BTCUSDT", Price: fixedpoint.MustNewFromString("9100.0"), Quantity: fixedpoint.MustNewFromString("0.05"), IsBuyer: true}, + {Symbol: "BTCUSDT", Price: fixedpoint.MustNewFromString("9200.0"), Quantity: fixedpoint.MustNewFromString("0.05"), IsBuyer: false}, + {Symbol: "BTCUSDT", Price: fixedpoint.MustNewFromString("9200.0"), Quantity: fixedpoint.MustNewFromString("0.05"), IsBuyer: false}, + } + + var stockManager = &StockDistribution{ + TradingFeeCurrency: "BNB", + Symbol: "BTCUSDT", + } + + _, err := stockManager.AddTrades(trades) + assert.NoError(t, err) + assert.Len(t, stockManager.Stocks, 0) + assert.Len(t, stockManager.PendingSells, 1) + }) + + t.Run("loss sell", func(t *testing.T) { + var trades = []types.Trade{ + {Symbol: "BTCUSDT", Price: fixedpoint.MustNewFromString("9100.0"), Quantity: fixedpoint.MustNewFromString("0.05"), IsBuyer: true}, + {Symbol: "BTCUSDT", Price: fixedpoint.MustNewFromString("9200.0"), Quantity: fixedpoint.MustNewFromString("0.02"), IsBuyer: false}, + {Symbol: "BTCUSDT", Price: fixedpoint.MustNewFromString("8000.0"), Quantity: fixedpoint.MustNewFromString("0.01"), IsBuyer: false}, + } + + var stockManager = &StockDistribution{ + TradingFeeCurrency: "BNB", + Symbol: "BTCUSDT", + } + + _, err := stockManager.AddTrades(trades) + assert.NoError(t, err) + assert.Len(t, stockManager.Stocks, 1) + assert.Equal(t, StockSlice{ + { + Symbol: "BTCUSDT", + Price: fixedpoint.MustNewFromString("9100.0"), + Quantity: fixedpoint.MustNewFromString("0.02"), + IsBuyer: true, + }, + }, stockManager.Stocks) + assert.Len(t, stockManager.PendingSells, 0) + }) + + t.Run("pending sell 1", func(t *testing.T) { + var trades = []types.Trade{ + {Symbol: "BTCUSDT", Price: fixedpoint.MustNewFromString("9200.0"), Quantity: fixedpoint.MustNewFromString("0.02")}, + {Symbol: "BTCUSDT", Price: fixedpoint.MustNewFromString("9100.0"), Quantity: fixedpoint.MustNewFromString("0.05"), IsBuyer: true}, + } + + var stockManager = &StockDistribution{ + TradingFeeCurrency: "BNB", + Symbol: "BTCUSDT", + } + + _, err := stockManager.AddTrades(trades) + assert.NoError(t, err) + assert.Len(t, stockManager.Stocks, 1) + assert.Equal(t, StockSlice{ + { + Symbol: "BTCUSDT", + Price: fixedpoint.MustNewFromString("9100.0"), + Quantity: fixedpoint.MustNewFromString("0.03"), + IsBuyer: true, + }, + }, stockManager.Stocks) + assert.Len(t, stockManager.PendingSells, 0) + }) + + t.Run("pending sell 2", func(t *testing.T) { + var trades = []types.Trade{ + {Symbol: "BTCUSDT", Price: fixedpoint.MustNewFromString("9200.0"), Quantity: fixedpoint.MustNewFromString("0.1")}, + {Symbol: "BTCUSDT", Price: fixedpoint.MustNewFromString("9100.0"), Quantity: fixedpoint.MustNewFromString("0.05"), IsBuyer: true}, + } + + var stockManager = &StockDistribution{ + TradingFeeCurrency: "BNB", + Symbol: "BTCUSDT", + } + + _, err := stockManager.AddTrades(trades) + assert.NoError(t, err) + assert.Len(t, stockManager.Stocks, 0) + assert.Len(t, stockManager.PendingSells, 1) + assert.Equal(t, StockSlice{ + { + Symbol: "BTCUSDT", + Price: fixedpoint.MustNewFromString("9200.0"), + Quantity: fixedpoint.MustNewFromString("0.05"), + IsBuyer: false, + }, + }, stockManager.PendingSells) + }) + +} diff --git a/pkg/accounting/pnl/avg_cost.go b/pkg/accounting/pnl/avg_cost.go new file mode 100644 index 0000000..4d6cfad --- /dev/null +++ b/pkg/accounting/pnl/avg_cost.go @@ -0,0 +1,129 @@ +package pnl + +import ( + "time" + + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type AverageCostCalculator struct { + TradingFeeCurrency string + Market types.Market + ExchangeFee *types.ExchangeFee +} + +func (c *AverageCostCalculator) Calculate(symbol string, trades []types.Trade, currentPrice fixedpoint.Value) *AverageCostPnLReport { + // copy trades, so that we can truncate it. + var bidVolume = fixedpoint.Zero + var askVolume = fixedpoint.Zero + var feeUSD = fixedpoint.Zero + var grossProfit = fixedpoint.Zero + var grossLoss = fixedpoint.Zero + + var position = types.NewPositionFromMarket(c.Market) + + if c.ExchangeFee != nil { + position.SetFeeRate(*c.ExchangeFee) + } else { + makerFeeRate := 0.075 * 0.01 + + if c.Market.QuoteCurrency == "BUSD" { + makerFeeRate = 0 + } + + position.SetFeeRate(types.ExchangeFee{ + // binance vip 0 uses 0.075% + MakerFeeRate: fixedpoint.NewFromFloat(makerFeeRate), + TakerFeeRate: fixedpoint.NewFromFloat(0.075 * 0.01), + }) + } + + if len(trades) == 0 { + return &AverageCostPnLReport{ + Symbol: symbol, + Market: c.Market, + LastPrice: currentPrice, + NumTrades: 0, + Position: position, + BuyVolume: bidVolume, + SellVolume: askVolume, + FeeInUSD: feeUSD, + } + } + + var currencyFees = map[string]fixedpoint.Value{} + + // TODO: configure the exchange fee rate here later + // position.SetExchangeFeeRate() + var totalProfit fixedpoint.Value + var totalNetProfit fixedpoint.Value + + var tradeIDs = map[uint64]types.Trade{} + + for _, trade := range trades { + if _, exists := tradeIDs[trade.ID]; exists { + log.Warnf("duplicated trade: %+v", trade) + continue + } + + if trade.Symbol != symbol { + continue + } + + profit, netProfit, madeProfit := position.AddTrade(trade) + if madeProfit { + totalProfit = totalProfit.Add(profit) + totalNetProfit = totalNetProfit.Add(netProfit) + } + + if profit.Sign() > 0 { + grossProfit = grossProfit.Add(profit) + } else if profit.Sign() < 0 { + grossLoss = grossLoss.Add(profit) + } + + if trade.IsBuyer { + bidVolume = bidVolume.Add(trade.Quantity) + } else { + askVolume = askVolume.Add(trade.Quantity) + } + + if _, ok := currencyFees[trade.FeeCurrency]; !ok { + currencyFees[trade.FeeCurrency] = trade.Fee + } else { + currencyFees[trade.FeeCurrency] = currencyFees[trade.FeeCurrency].Add(trade.Fee) + } + + tradeIDs[trade.ID] = trade + } + + unrealizedProfit := currentPrice.Sub(position.AverageCost). + Mul(position.GetBase()) + + return &AverageCostPnLReport{ + Symbol: symbol, + Market: c.Market, + LastPrice: currentPrice, + NumTrades: len(trades), + StartTime: time.Time(trades[0].Time), + Position: position, + + BuyVolume: bidVolume, + SellVolume: askVolume, + + BaseAssetPosition: position.GetBase(), + Profit: totalProfit, + NetProfit: totalNetProfit, + UnrealizedProfit: unrealizedProfit, + + GrossProfit: grossProfit, + GrossLoss: grossLoss, + + AverageCost: position.AverageCost, + FeeInUSD: totalProfit.Sub(totalNetProfit), + CurrencyFees: currencyFees, + } +} diff --git a/pkg/accounting/pnl/report.go b/pkg/accounting/pnl/report.go new file mode 100644 index 0000000..5f5a911 --- /dev/null +++ b/pkg/accounting/pnl/report.go @@ -0,0 +1,100 @@ +package pnl + +import ( + "encoding/json" + "strconv" + "time" + + "github.com/fatih/color" + "github.com/slack-go/slack" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/slack/slackstyle" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type AverageCostPnLReport struct { + LastPrice fixedpoint.Value `json:"lastPrice"` + StartTime time.Time `json:"startTime"` + Symbol string `json:"symbol"` + Market types.Market `json:"market"` + + NumTrades int `json:"numTrades"` + Profit fixedpoint.Value `json:"profit"` + UnrealizedProfit fixedpoint.Value `json:"unrealizedProfit"` + + NetProfit fixedpoint.Value `json:"netProfit"` + GrossProfit fixedpoint.Value `json:"grossProfit"` + GrossLoss fixedpoint.Value `json:"grossLoss"` + Position *types.Position `json:"position,omitempty"` + + AverageCost fixedpoint.Value `json:"averageCost"` + BuyVolume fixedpoint.Value `json:"buyVolume,omitempty"` + SellVolume fixedpoint.Value `json:"sellVolume,omitempty"` + FeeInUSD fixedpoint.Value `json:"feeInUSD"` + BaseAssetPosition fixedpoint.Value `json:"baseAssetPosition"` + CurrencyFees map[string]fixedpoint.Value `json:"currencyFees"` +} + +func (report *AverageCostPnLReport) JSON() ([]byte, error) { + return json.MarshalIndent(report, "", " ") +} + +func (report AverageCostPnLReport) Print() { + color.Green("TRADES SINCE: %v", report.StartTime) + color.Green("NUMBER OF TRADES: %d", report.NumTrades) + color.Green(report.Position.String()) + color.Green("AVERAGE COST: %s", types.USD.FormatMoney(report.AverageCost)) + color.Green("BASE ASSET POSITION: %s", report.BaseAssetPosition.String()) + + color.Green("TOTAL BUY VOLUME: %v", report.BuyVolume) + color.Green("TOTAL SELL VOLUME: %v", report.SellVolume) + + color.Green("CURRENT PRICE: %s", types.USD.FormatMoney(report.LastPrice)) + color.Green("CURRENCY FEES:") + for currency, fee := range report.CurrencyFees { + color.Green(" - %s: %s", currency, fee.String()) + } + + if report.Profit.Sign() > 0 { + color.Green("PROFIT: %s", types.USD.FormatMoney(report.Profit)) + } else { + color.Red("PROFIT: %s", types.USD.FormatMoney(report.Profit)) + } + + if report.UnrealizedProfit.Sign() > 0 { + color.Green("UNREALIZED PROFIT: %s", types.USD.FormatMoney(report.UnrealizedProfit)) + } else { + color.Red("UNREALIZED PROFIT: %s", types.USD.FormatMoney(report.UnrealizedProfit)) + } +} + +func (report AverageCostPnLReport) SlackAttachment() slack.Attachment { + var color = slackstyle.Red + + if report.UnrealizedProfit.Sign() > 0 { + color = slackstyle.Green + } + + return slack.Attachment{ + Title: report.Symbol + " Profit and Loss report", + Text: "Profit " + types.USD.FormatMoney(report.Profit), + Color: color, + // Pretext: "", + // Text: "", + Fields: []slack.AttachmentField{ + {Title: "Profit", Value: types.USD.FormatMoney(report.Profit)}, + {Title: "Unrealized Profit", Value: types.USD.FormatMoney(report.UnrealizedProfit)}, + {Title: "Current Price", Value: report.Market.FormatPrice(report.LastPrice), Short: true}, + {Title: "Average Cost", Value: report.Market.FormatPrice(report.AverageCost), Short: true}, + + // FIXME: + // {Title: "Fee (USD)", Value: types.USD.FormatMoney(report.FeeInUSD), Short: true}, + {Title: "Base Asset Position", Value: report.BaseAssetPosition.String(), Short: true}, + {Title: "Number of Trades", Value: strconv.Itoa(report.NumTrades), Short: true}, + }, + Footer: report.StartTime.Format(time.RFC822), + FooterIcon: "", + } +} diff --git a/pkg/accounting/testdata/btcusdt-trades.json b/pkg/accounting/testdata/btcusdt-trades.json new file mode 100644 index 0000000..b678c1f --- /dev/null +++ b/pkg/accounting/testdata/btcusdt-trades.json @@ -0,0 +1 @@ +[{"gid":1,"id":199025959,"exchange":"binance","price":9200,"quantity":0.060187,"quoteQuantity":553.7204,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2019-10-31T15:48:51.803Z","fee":0.00006019,"feeCurrency":"BTC"},{"gid":2,"id":199025961,"exchange":"binance","price":9200,"quantity":0.001194,"quoteQuantity":10.9848,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2019-10-31T15:48:52.218Z","fee":0.00000119,"feeCurrency":"BTC"},{"gid":3,"id":199025962,"exchange":"binance","price":9200,"quantity":0.038619,"quoteQuantity":355.2948,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2019-10-31T15:48:52.265Z","fee":0.00003862,"feeCurrency":"BTC"},{"gid":4,"id":199046339,"exchange":"binance","price":9240,"quantity":0.0999,"quoteQuantity":923.076,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2019-10-31T16:54:01.311Z","fee":0.923076,"feeCurrency":"USDT"},{"gid":5,"id":199114140,"exchange":"binance","price":9100,"quantity":0.033727,"quoteQuantity":306.9157,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2019-10-31T21:01:03.587Z","fee":0.00003373,"feeCurrency":"BTC"},{"gid":6,"id":199114141,"exchange":"binance","price":9100,"quantity":0.066273,"quoteQuantity":603.0843,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2019-10-31T21:01:03.587Z","fee":0.00006627,"feeCurrency":"BTC"},{"gid":7,"id":199148278,"exchange":"binance","price":9120.4,"quantity":0.021925,"quoteQuantity":199.96477,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2019-10-31T22:09:23.079Z","fee":0.19996477,"feeCurrency":"USDT"},{"gid":8,"id":199148279,"exchange":"binance","price":9120.39,"quantity":0.077975,"quoteQuantity":711.16241025,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2019-10-31T22:09:23.079Z","fee":0.71116241,"feeCurrency":"USDT"},{"gid":9,"id":202364121,"exchange":"binance","price":8839.33,"quantity":0.04,"quoteQuantity":353.5732,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2019-11-08T13:34:35.317Z","fee":0.00004,"feeCurrency":"BTC"},{"gid":10,"id":202407857,"exchange":"binance","price":8800,"quantity":0.04,"quoteQuantity":352,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2019-11-08T14:34:41.803Z","fee":0.00004,"feeCurrency":"BTC"},{"gid":11,"id":202822019,"exchange":"binance","price":8820,"quantity":0.07992,"quoteQuantity":704.8944,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2019-11-09T14:43:45.764Z","fee":0.7048944,"feeCurrency":"USDT"},{"gid":12,"id":215350255,"exchange":"binance","price":7212.37,"quantity":0.1,"quoteQuantity":721.237,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2019-12-11T05:47:45.648Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":13,"id":215350930,"exchange":"binance","price":7200,"quantity":0.1,"quoteQuantity":720,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2019-12-11T05:52:21.929Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":14,"id":215407013,"exchange":"binance","price":7226.15,"quantity":0.01,"quoteQuantity":72.2615,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2019-12-11T11:16:46.675Z","fee":0.0722615,"feeCurrency":"USDT"},{"gid":15,"id":215407014,"exchange":"binance","price":7226.14,"quantity":0.151153,"quoteQuantity":1092.25273942,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2019-12-11T11:16:46.675Z","fee":1.09225274,"feeCurrency":"USDT"},{"gid":16,"id":215407015,"exchange":"binance","price":7226.13,"quantity":0.038647,"quoteQuantity":279.26824611,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2019-12-11T11:16:46.675Z","fee":0.27926825,"feeCurrency":"USDT"},{"gid":17,"id":224977656,"exchange":"binance","price":7924.85,"quantity":0.017633,"quoteQuantity":139.73888005,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-07T17:18:56.894Z","fee":0.00001763,"feeCurrency":"BTC"},{"gid":18,"id":224977657,"exchange":"binance","price":7924.86,"quantity":0.082367,"quoteQuantity":652.74694362,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-07T17:18:56.894Z","fee":0.00008237,"feeCurrency":"BTC"},{"gid":19,"id":225288185,"exchange":"binance","price":8288.95,"quantity":0.1,"quoteQuantity":828.895,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-08T00:40:19.811Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":20,"id":225337881,"exchange":"binance","price":8377.12,"quantity":0.1,"quoteQuantity":837.712,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-08T01:10:21.477Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":21,"id":225341851,"exchange":"binance","price":8366.17,"quantity":0.023901,"quoteQuantity":199.95982917,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-08T01:15:52.926Z","fee":0.19995983,"feeCurrency":"USDT"},{"gid":22,"id":225341852,"exchange":"binance","price":8366.16,"quantity":0.141656,"quoteQuantity":1185.11676096,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-08T01:15:52.926Z","fee":1.18511676,"feeCurrency":"USDT"},{"gid":23,"id":225341853,"exchange":"binance","price":8366.13,"quantity":0.134143,"quoteQuantity":1122.25777659,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-08T01:15:52.926Z","fee":1.12225778,"feeCurrency":"USDT"},{"gid":24,"id":225342863,"exchange":"binance","price":8378.05,"quantity":0.012462,"quoteQuantity":104.4072591,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-08T01:16:47.149Z","fee":0.00001246,"feeCurrency":"BTC"},{"gid":25,"id":225342864,"exchange":"binance","price":8378.11,"quantity":0.004001,"quoteQuantity":33.52081811,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-08T01:16:47.149Z","fee":0.000004,"feeCurrency":"BTC"},{"gid":26,"id":225342865,"exchange":"binance","price":8378.41,"quantity":0.283537,"quoteQuantity":2375.58923617,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-08T01:16:47.149Z","fee":0.00028354,"feeCurrency":"BTC"},{"gid":27,"id":225366063,"exchange":"binance","price":8365.8,"quantity":0.164894,"quoteQuantity":1379.4702252,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-08T01:47:36.771Z","fee":1.37947023,"feeCurrency":"USDT"},{"gid":28,"id":225366064,"exchange":"binance","price":8365.04,"quantity":0.06778,"quoteQuantity":566.9824112,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-08T01:47:36.771Z","fee":0.56698241,"feeCurrency":"USDT"},{"gid":29,"id":225366065,"exchange":"binance","price":8365,"quantity":0.067026,"quoteQuantity":560.67249,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-08T01:47:36.771Z","fee":0.56067249,"feeCurrency":"USDT"},{"gid":30,"id":225367076,"exchange":"binance","price":8374.82,"quantity":0.047744,"quoteQuantity":399.84740608,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-08T01:48:50.114Z","fee":0.00004774,"feeCurrency":"BTC"},{"gid":31,"id":225367077,"exchange":"binance","price":8374.83,"quantity":0.211243,"quoteQuantity":1769.12421369,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-08T01:48:50.114Z","fee":0.00021124,"feeCurrency":"BTC"},{"gid":32,"id":225367078,"exchange":"binance","price":8375.27,"quantity":0.041013,"quoteQuantity":343.49494851,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-08T01:48:50.114Z","fee":0.00004101,"feeCurrency":"BTC"},{"gid":33,"id":225369369,"exchange":"binance","price":8359.39,"quantity":0.067697,"quoteQuantity":565.90562483,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-08T01:52:45.056Z","fee":0.56590562,"feeCurrency":"USDT"},{"gid":34,"id":225369370,"exchange":"binance","price":8359.27,"quantity":0.001276,"quoteQuantity":10.66642852,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-08T01:52:45.056Z","fee":0.01066643,"feeCurrency":"USDT"},{"gid":35,"id":225369371,"exchange":"binance","price":8359.17,"quantity":0.230727,"quoteQuantity":1928.68621659,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-08T01:52:45.056Z","fee":1.92868622,"feeCurrency":"USDT"},{"gid":36,"id":225434043,"exchange":"binance","price":8290.6,"quantity":0.060055,"quoteQuantity":497.891983,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-08T03:35:31.163Z","fee":0.00006006,"feeCurrency":"BTC"},{"gid":37,"id":225434045,"exchange":"binance","price":8290.6,"quantity":0.017866,"quoteQuantity":148.1198596,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-08T03:35:31.423Z","fee":0.00001787,"feeCurrency":"BTC"},{"gid":38,"id":225434054,"exchange":"binance","price":8290.6,"quantity":0.022079,"quoteQuantity":183.0481574,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-08T03:35:31.69Z","fee":0.00002208,"feeCurrency":"BTC"},{"gid":39,"id":225442824,"exchange":"binance","price":8317.53,"quantity":0.2,"quoteQuantity":1663.506,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-08T03:49:28.74Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":40,"id":225442864,"exchange":"binance","price":8319.25,"quantity":0.1,"quoteQuantity":831.925,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-08T03:49:38.529Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":41,"id":225530751,"exchange":"binance","price":8293.43,"quantity":0.2,"quoteQuantity":1658.686,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-08T07:50:31.077Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":42,"id":225561417,"exchange":"binance","price":8292.93,"quantity":0.01185,"quoteQuantity":98.2712205,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-08T09:24:08.924Z","fee":0.00001185,"feeCurrency":"BTC"},{"gid":43,"id":225561418,"exchange":"binance","price":8292.94,"quantity":0.08815,"quoteQuantity":731.022661,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-08T09:24:08.924Z","fee":0.00008815,"feeCurrency":"BTC"},{"gid":44,"id":225568087,"exchange":"binance","price":8306.78,"quantity":0.1,"quoteQuantity":830.678,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-08T09:44:45.706Z","fee":0.830678,"feeCurrency":"USDT"},{"gid":45,"id":225576277,"exchange":"binance","price":8338.79,"quantity":0.026376,"quoteQuantity":219.94392504,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-08T10:07:28.018Z","fee":0.21994393,"feeCurrency":"USDT"},{"gid":46,"id":225576278,"exchange":"binance","price":8337.45,"quantity":0.19818,"quoteQuantity":1652.315841,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-08T10:07:28.018Z","fee":1.65231584,"feeCurrency":"USDT"},{"gid":47,"id":225576279,"exchange":"binance","price":8337.21,"quantity":0.039523,"quoteQuantity":329.51155083,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-08T10:07:28.018Z","fee":0.32951155,"feeCurrency":"USDT"},{"gid":48,"id":225576280,"exchange":"binance","price":8337.2,"quantity":0.3,"quoteQuantity":2501.16,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-08T10:07:28.018Z","fee":2.50116,"feeCurrency":"USDT"},{"gid":49,"id":225576281,"exchange":"binance","price":8337.19,"quantity":0.035221,"quoteQuantity":293.64416899,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-08T10:07:28.018Z","fee":0.29364417,"feeCurrency":"USDT"},{"gid":50,"id":225577532,"exchange":"binance","price":8348.72,"quantity":0.1,"quoteQuantity":834.872,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-08T10:08:16.429Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":51,"id":225577580,"exchange":"binance","price":8345.21,"quantity":0.0999,"quoteQuantity":833.686479,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-08T10:08:23.433Z","fee":0.83368648,"feeCurrency":"USDT"},{"gid":52,"id":225614109,"exchange":"binance","price":8325,"quantity":0.16538,"quoteQuantity":1376.7885,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-08T12:09:03.065Z","fee":0.00016538,"feeCurrency":"BTC"},{"gid":53,"id":225614110,"exchange":"binance","price":8325,"quantity":0.13462,"quoteQuantity":1120.7115,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-08T12:09:03.069Z","fee":0.00013462,"feeCurrency":"BTC"},{"gid":54,"id":225717952,"exchange":"binance","price":8293.43,"quantity":0.2,"quoteQuantity":1658.686,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-08T14:30:55.268Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":55,"id":225718197,"exchange":"binance","price":8303.4,"quantity":0.006021,"quoteQuantity":49.9947714,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-08T14:31:33.157Z","fee":0.00000602,"feeCurrency":"BTC"},{"gid":56,"id":225718198,"exchange":"binance","price":8304.22,"quantity":0.193979,"quoteQuantity":1610.84429138,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-08T14:31:33.157Z","fee":0.00019398,"feeCurrency":"BTC"},{"gid":57,"id":225739866,"exchange":"binance","price":8289.84,"quantity":0.301572,"quoteQuantity":2499.98362848,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-08T15:23:16.997Z","fee":2.49998363,"feeCurrency":"USDT"},{"gid":58,"id":225739867,"exchange":"binance","price":8289.83,"quantity":0.301572,"quoteQuantity":2499.98061276,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-08T15:23:16.997Z","fee":2.49998061,"feeCurrency":"USDT"},{"gid":59,"id":225739868,"exchange":"binance","price":8289.8,"quantity":0.096156,"quoteQuantity":797.1140088,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-08T15:23:16.997Z","fee":0.79711401,"feeCurrency":"USDT"},{"gid":60,"id":225753302,"exchange":"binance","price":8319.95,"quantity":0.2,"quoteQuantity":1663.99,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-08T15:44:50.6Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":61,"id":225781652,"exchange":"binance","price":8200,"quantity":0.040158,"quoteQuantity":329.2956,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-08T16:30:10.199Z","fee":0.00004016,"feeCurrency":"BTC"},{"gid":62,"id":225781653,"exchange":"binance","price":8200,"quantity":0.114042,"quoteQuantity":935.1444,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-08T16:30:10.199Z","fee":0.00011404,"feeCurrency":"BTC"},{"gid":63,"id":225781654,"exchange":"binance","price":8200,"quantity":0.008865,"quoteQuantity":72.693,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-08T16:30:10.2Z","fee":0.00000887,"feeCurrency":"BTC"},{"gid":64,"id":225781655,"exchange":"binance","price":8200,"quantity":0.236935,"quoteQuantity":1942.867,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-08T16:30:10.211Z","fee":0.00023694,"feeCurrency":"BTC"},{"gid":65,"id":225833770,"exchange":"binance","price":8084.07,"quantity":0.03463,"quoteQuantity":279.9513441,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-08T16:55:19.086Z","fee":0.27995134,"feeCurrency":"USDT"},{"gid":66,"id":225833771,"exchange":"binance","price":8084,"quantity":0.1,"quoteQuantity":808.4,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-08T16:55:19.086Z","fee":0.8084,"feeCurrency":"USDT"},{"gid":67,"id":225833772,"exchange":"binance","price":8083.78,"quantity":0.006185,"quoteQuantity":49.9981793,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-08T16:55:19.086Z","fee":0.04999818,"feeCurrency":"USDT"},{"gid":68,"id":225833773,"exchange":"binance","price":8083.44,"quantity":0.012,"quoteQuantity":97.00128,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-08T16:55:19.086Z","fee":0.09700128,"feeCurrency":"USDT"},{"gid":69,"id":225833774,"exchange":"binance","price":8082.72,"quantity":0.446584,"quoteQuantity":3609.61342848,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-08T16:55:19.086Z","fee":3.60961343,"feeCurrency":"USDT"},{"gid":70,"id":226348945,"exchange":"binance","price":7871,"quantity":0.2,"quoteQuantity":1574.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-09T16:07:49.628Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":71,"id":226575257,"exchange":"binance","price":7808.34,"quantity":0.1,"quoteQuantity":780.834,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-10T02:37:58.046Z","fee":0.780834,"feeCurrency":"USDT"},{"gid":72,"id":226575258,"exchange":"binance","price":7808.34,"quantity":0.0998,"quoteQuantity":779.272332,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-10T02:37:58.35Z","fee":0.77927233,"feeCurrency":"USDT"},{"gid":73,"id":228067081,"exchange":"binance","price":8076.06,"quantity":0.1,"quoteQuantity":807.606,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-13T08:10:36.549Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":74,"id":228118631,"exchange":"binance","price":8092,"quantity":0.1,"quoteQuantity":809.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-13T11:59:10.787Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":75,"id":228119814,"exchange":"binance","price":8071,"quantity":0.1,"quoteQuantity":807.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-13T12:00:04.73Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":76,"id":228316488,"exchange":"binance","price":8363.65,"quantity":0.2989,"quoteQuantity":2499.894985,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-14T01:11:32.694Z","fee":2.49989499,"feeCurrency":"USDT"},{"gid":77,"id":228316489,"exchange":"binance","price":8363.62,"quantity":0.0008,"quoteQuantity":6.690896,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-14T01:11:32.694Z","fee":0.0066909,"feeCurrency":"USDT"},{"gid":78,"id":228392264,"exchange":"binance","price":8411.66,"quantity":0.1,"quoteQuantity":841.166,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-14T02:53:31.781Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":79,"id":228404319,"exchange":"binance","price":8380,"quantity":0.1,"quoteQuantity":838,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-14T03:28:26.936Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":80,"id":228563984,"exchange":"binance","price":8546,"quantity":0.152238,"quoteQuantity":1301.025948,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-14T09:33:05.048Z","fee":1.30102595,"feeCurrency":"USDT"},{"gid":81,"id":228563985,"exchange":"binance","price":8545.74,"quantity":0.047562,"quoteQuantity":406.45248588,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-14T09:33:05.048Z","fee":0.40645249,"feeCurrency":"USDT"},{"gid":82,"id":228568255,"exchange":"binance","price":8521,"quantity":0.1,"quoteQuantity":852.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-14T09:46:19.765Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":83,"id":228568989,"exchange":"binance","price":8510,"quantity":0.1,"quoteQuantity":851,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-14T09:47:39.998Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":84,"id":228582883,"exchange":"binance","price":8482,"quantity":0.1,"quoteQuantity":848.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-14T10:19:38.485Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":85,"id":228583876,"exchange":"binance","price":8488.61,"quantity":0.1,"quoteQuantity":848.861,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-14T10:22:44.935Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":86,"id":229239420,"exchange":"binance","price":8833.1,"quantity":0.1,"quoteQuantity":883.31,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-15T00:55:26.521Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":87,"id":229242271,"exchange":"binance","price":8782,"quantity":0.1,"quoteQuantity":878.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-15T00:58:38.125Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":88,"id":229255985,"exchange":"binance","price":8718,"quantity":0.1,"quoteQuantity":871.8,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-15T01:10:01.74Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":89,"id":229259850,"exchange":"binance","price":8736.41,"quantity":0.05,"quoteQuantity":436.8205,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-15T01:14:12.055Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":90,"id":229341938,"exchange":"binance","price":8732.07,"quantity":0.05,"quoteQuantity":436.6035,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-15T03:59:07.559Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":91,"id":229569585,"exchange":"binance","price":8775.65,"quantity":0.024663,"quoteQuantity":216.43385595,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-15T11:39:33.771Z","fee":0.00002466,"feeCurrency":"BTC"},{"gid":92,"id":229641224,"exchange":"binance","price":8902,"quantity":0.223,"quoteQuantity":1985.146,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-15T13:58:27.305Z","fee":1.985146,"feeCurrency":"USDT"},{"gid":93,"id":229654415,"exchange":"binance","price":8849.51,"quantity":0.017459,"quoteQuantity":154.50359509,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-15T14:06:16.57Z","fee":0.00001746,"feeCurrency":"BTC"},{"gid":94,"id":229654416,"exchange":"binance","price":8849.61,"quantity":0.1,"quoteQuantity":884.961,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-15T14:06:16.57Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":95,"id":229654417,"exchange":"binance","price":8849.62,"quantity":0.082541,"quoteQuantity":730.45648442,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-15T14:06:16.57Z","fee":0.00008254,"feeCurrency":"BTC"},{"gid":96,"id":229667487,"exchange":"binance","price":8814.82,"quantity":0.002146,"quoteQuantity":18.91660372,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-15T14:15:38.638Z","fee":0.00000215,"feeCurrency":"BTC"},{"gid":97,"id":229667488,"exchange":"binance","price":8814.92,"quantity":0.022029,"quoteQuantity":194.18387268,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-15T14:15:38.638Z","fee":0.00002203,"feeCurrency":"BTC"},{"gid":98,"id":229753945,"exchange":"binance","price":8792.97,"quantity":0.025015,"quoteQuantity":219.95614455,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-15T16:15:45.787Z","fee":0.21995614,"feeCurrency":"USDT"},{"gid":99,"id":229753946,"exchange":"binance","price":8790.5,"quantity":0.023596,"quoteQuantity":207.420638,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-15T16:15:45.787Z","fee":0.20742064,"feeCurrency":"USDT"},{"gid":100,"id":229753947,"exchange":"binance","price":8790.49,"quantity":0.051389,"quoteQuantity":451.73449061,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-15T16:15:45.787Z","fee":0.45173449,"feeCurrency":"USDT"},{"gid":101,"id":229978570,"exchange":"binance","price":8680,"quantity":0.101195,"quoteQuantity":878.3726,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-16T01:21:24.438Z","fee":0.0001012,"feeCurrency":"BTC"},{"gid":102,"id":230026494,"exchange":"binance","price":8660,"quantity":0.2,"quoteQuantity":1732,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-16T02:28:42.085Z","fee":1.732,"feeCurrency":"USDT"},{"gid":103,"id":230026979,"exchange":"binance","price":8656.94,"quantity":0.1,"quoteQuantity":865.694,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-16T02:29:39.441Z","fee":0.865694,"feeCurrency":"USDT"},{"gid":104,"id":230028023,"exchange":"binance","price":8677.75,"quantity":0.164131,"quoteQuantity":1424.28778525,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-16T02:32:26.362Z","fee":0.00016413,"feeCurrency":"BTC"},{"gid":105,"id":230028024,"exchange":"binance","price":8677.83,"quantity":0.035869,"quoteQuantity":311.26508427,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-16T02:32:26.362Z","fee":0.00003587,"feeCurrency":"BTC"},{"gid":106,"id":230109221,"exchange":"binance","price":8676.96,"quantity":0.099041,"quoteQuantity":859.37479536,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-16T06:11:24.604Z","fee":0.00009904,"feeCurrency":"BTC"},{"gid":107,"id":230118120,"exchange":"binance","price":8720,"quantity":0.3,"quoteQuantity":2616,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-16T06:44:18.871Z","fee":2.616,"feeCurrency":"USDT"},{"gid":108,"id":230130834,"exchange":"binance","price":8681,"quantity":0.1,"quoteQuantity":868.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-16T07:22:32.821Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":109,"id":230133846,"exchange":"binance","price":8671,"quantity":0.1,"quoteQuantity":867.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-16T07:27:25.375Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":110,"id":230178063,"exchange":"binance","price":8633.01,"quantity":0.101732,"quoteQuantity":878.25337332,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-16T09:35:06.041Z","fee":0.00010173,"feeCurrency":"BTC"},{"gid":111,"id":230188489,"exchange":"binance","price":8625,"quantity":0.2,"quoteQuantity":1725,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-16T10:05:19.807Z","fee":1.725,"feeCurrency":"USDT"},{"gid":112,"id":230189018,"exchange":"binance","price":8620.2,"quantity":0.135483,"quoteQuantity":1167.8905566,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-16T10:06:53.131Z","fee":1.16789056,"feeCurrency":"USDT"},{"gid":113,"id":230189019,"exchange":"binance","price":8620.16,"quantity":0.121643,"quoteQuantity":1048.58212288,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-16T10:06:53.131Z","fee":1.04858212,"feeCurrency":"USDT"},{"gid":114,"id":230189020,"exchange":"binance","price":8620.15,"quantity":0.042874,"quoteQuantity":369.5803111,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-16T10:06:53.131Z","fee":0.36958031,"feeCurrency":"USDT"},{"gid":115,"id":230189274,"exchange":"binance","price":8621.74,"quantity":0.1,"quoteQuantity":862.174,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-16T10:07:31.456Z","fee":0.862174,"feeCurrency":"USDT"},{"gid":116,"id":230189377,"exchange":"binance","price":8624.29,"quantity":0.1,"quoteQuantity":862.429,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-16T10:07:40.873Z","fee":0.862429,"feeCurrency":"USDT"},{"gid":117,"id":230245974,"exchange":"binance","price":8752.15,"quantity":0.126056,"quoteQuantity":1103.2610204,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-16T12:15:43.667Z","fee":1.10326102,"feeCurrency":"USDT"},{"gid":118,"id":230536969,"exchange":"binance","price":8775.97,"quantity":0.1,"quoteQuantity":877.597,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-17T03:12:19.578Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":119,"id":230539655,"exchange":"binance","price":8797.94,"quantity":0.1,"quoteQuantity":879.794,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-17T03:14:20.116Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":120,"id":230541506,"exchange":"binance","price":8821.87,"quantity":0.014,"quoteQuantity":123.50618,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-17T03:15:12.625Z","fee":0.000014,"feeCurrency":"BTC"},{"gid":121,"id":230541507,"exchange":"binance","price":8821.88,"quantity":0.086,"quoteQuantity":758.68168,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-17T03:15:12.625Z","fee":0.000086,"feeCurrency":"BTC"},{"gid":122,"id":230542292,"exchange":"binance","price":8801.89,"quantity":0.013629,"quoteQuantity":119.96095881,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-17T03:16:03.121Z","fee":0.11996096,"feeCurrency":"USDT"},{"gid":123,"id":230542293,"exchange":"binance","price":8801.88,"quantity":0.084461,"quoteQuantity":743.41558668,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-17T03:16:03.121Z","fee":0.74341559,"feeCurrency":"USDT"},{"gid":124,"id":230542294,"exchange":"binance","price":8801.81,"quantity":0.067316,"quoteQuantity":592.50264196,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-17T03:16:03.121Z","fee":0.59250264,"feeCurrency":"USDT"},{"gid":125,"id":230542295,"exchange":"binance","price":8801.79,"quantity":0.134294,"quoteQuantity":1182.02758626,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-17T03:16:03.121Z","fee":1.18202759,"feeCurrency":"USDT"},{"gid":126,"id":230544168,"exchange":"binance","price":8801.93,"quantity":0.143284,"quoteQuantity":1261.17573812,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-17T03:19:59.824Z","fee":0.00014328,"feeCurrency":"BTC"},{"gid":127,"id":230544169,"exchange":"binance","price":8801.94,"quantity":0.056716,"quoteQuantity":499.21082904,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-17T03:19:59.824Z","fee":0.00005672,"feeCurrency":"BTC"},{"gid":128,"id":230549918,"exchange":"binance","price":8829.06,"quantity":0.1998,"quoteQuantity":1764.046188,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-17T03:30:24.901Z","fee":1.76404619,"feeCurrency":"USDT"},{"gid":129,"id":230552363,"exchange":"binance","price":8824,"quantity":0.002,"quoteQuantity":17.648,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-17T03:35:34.61Z","fee":0.000002,"feeCurrency":"BTC"},{"gid":130,"id":230552364,"exchange":"binance","price":8824.45,"quantity":0.085375,"quoteQuantity":753.38741875,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-17T03:35:34.61Z","fee":0.00008538,"feeCurrency":"BTC"},{"gid":131,"id":230552365,"exchange":"binance","price":8824.46,"quantity":0.009829,"quoteQuantity":86.73561734,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-17T03:35:34.61Z","fee":0.00000983,"feeCurrency":"BTC"},{"gid":132,"id":230552366,"exchange":"binance","price":8824.98,"quantity":0.102796,"quoteQuantity":907.17264408,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-17T03:35:34.61Z","fee":0.0001028,"feeCurrency":"BTC"},{"gid":133,"id":230553173,"exchange":"binance","price":8819.69,"quantity":0.011336,"quoteQuantity":99.98000584,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-17T03:37:10.794Z","fee":0.09998001,"feeCurrency":"USDT"},{"gid":134,"id":230553174,"exchange":"binance","price":8819,"quantity":0.016879,"quoteQuantity":148.855901,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-17T03:37:10.794Z","fee":0.1488559,"feeCurrency":"USDT"},{"gid":135,"id":230553175,"exchange":"binance","price":8818.04,"quantity":0.171585,"quoteQuantity":1513.0433934,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-17T03:37:10.794Z","fee":1.51304339,"feeCurrency":"USDT"},{"gid":136,"id":230698572,"exchange":"binance","price":8871.93,"quantity":0.014,"quoteQuantity":124.20702,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-17T07:08:49.052Z","fee":0.000014,"feeCurrency":"BTC"},{"gid":137,"id":230698573,"exchange":"binance","price":8871.93,"quantity":0.086,"quoteQuantity":762.98598,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-17T07:08:49.052Z","fee":0.000086,"feeCurrency":"BTC"},{"gid":138,"id":230779739,"exchange":"binance","price":8951.31,"quantity":0.01,"quoteQuantity":89.5131,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-17T09:41:35.907Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":139,"id":230779740,"exchange":"binance","price":8951.32,"quantity":0.020107,"quoteQuantity":179.98419124,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-17T09:41:35.907Z","fee":0.00002011,"feeCurrency":"BTC"},{"gid":140,"id":230779741,"exchange":"binance","price":8951.33,"quantity":0.01,"quoteQuantity":89.5133,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-17T09:41:35.907Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":141,"id":230779742,"exchange":"binance","price":8951.38,"quantity":0.059893,"quoteQuantity":536.12500234,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-17T09:41:35.907Z","fee":0.00005989,"feeCurrency":"BTC"},{"gid":142,"id":230815042,"exchange":"binance","price":8902,"quantity":0.1,"quoteQuantity":890.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-17T10:26:24.033Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":143,"id":230862818,"exchange":"binance","price":8862,"quantity":0.1,"quoteQuantity":886.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-17T12:07:13.881Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":144,"id":230867135,"exchange":"binance","price":8841,"quantity":0.1,"quoteQuantity":884.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-17T12:12:16.163Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":145,"id":230880613,"exchange":"binance","price":8834.13,"quantity":0.088683,"quoteQuantity":783.43715079,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-17T12:27:14.969Z","fee":0.00008868,"feeCurrency":"BTC"},{"gid":146,"id":230880614,"exchange":"binance","price":8834.13,"quantity":0.011317,"quoteQuantity":99.97584921,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-17T12:27:14.997Z","fee":0.00001132,"feeCurrency":"BTC"},{"gid":147,"id":230880636,"exchange":"binance","price":8834.27,"quantity":0.1,"quoteQuantity":883.427,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-17T12:27:18.531Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":148,"id":230891116,"exchange":"binance","price":8826,"quantity":0.060127,"quoteQuantity":530.680902,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-17T12:58:05.243Z","fee":0.00006013,"feeCurrency":"BTC"},{"gid":149,"id":230891119,"exchange":"binance","price":8826,"quantity":0.039873,"quoteQuantity":351.919098,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-17T12:58:05.318Z","fee":0.00003987,"feeCurrency":"BTC"},{"gid":150,"id":230903563,"exchange":"binance","price":8855.19,"quantity":0.1,"quoteQuantity":885.519,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-17T13:33:27.58Z","fee":0.885519,"feeCurrency":"USDT"},{"gid":151,"id":230916432,"exchange":"binance","price":8830.74,"quantity":0.1,"quoteQuantity":883.074,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-17T14:12:24.116Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":152,"id":230960930,"exchange":"binance","price":8868,"quantity":0.05,"quoteQuantity":443.4,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-17T16:02:37.663Z","fee":0.4434,"feeCurrency":"USDT"},{"gid":153,"id":230973101,"exchange":"binance","price":8900,"quantity":0.1,"quoteQuantity":890,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-17T16:25:00.506Z","fee":0.89,"feeCurrency":"USDT"},{"gid":154,"id":230980768,"exchange":"binance","price":8918,"quantity":0.015834,"quoteQuantity":141.207612,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-17T16:37:57.596Z","fee":0.14120761,"feeCurrency":"USDT"},{"gid":155,"id":230980769,"exchange":"binance","price":8918,"quantity":0.04016,"quoteQuantity":358.14688,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-17T16:37:57.614Z","fee":0.35814688,"feeCurrency":"USDT"},{"gid":156,"id":230980770,"exchange":"binance","price":8918,"quantity":0.044006,"quoteQuantity":392.445508,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-17T16:37:57.628Z","fee":0.39244551,"feeCurrency":"USDT"},{"gid":157,"id":230988683,"exchange":"binance","price":8898.99,"quantity":0.1,"quoteQuantity":889.899,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-17T16:51:54.085Z","fee":0.889899,"feeCurrency":"USDT"},{"gid":158,"id":230989556,"exchange":"binance","price":8890.2,"quantity":0.01832,"quoteQuantity":162.868464,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-17T16:53:50.214Z","fee":0.16286846,"feeCurrency":"USDT"},{"gid":159,"id":230989557,"exchange":"binance","price":8890.2,"quantity":0.022369,"quoteQuantity":198.8648838,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-17T16:53:50.228Z","fee":0.19886488,"feeCurrency":"USDT"},{"gid":160,"id":230989558,"exchange":"binance","price":8890.2,"quantity":0.059311,"quoteQuantity":527.2866522,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-17T16:53:50.364Z","fee":0.52728665,"feeCurrency":"USDT"},{"gid":161,"id":230989688,"exchange":"binance","price":8898.98,"quantity":0.1,"quoteQuantity":889.898,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-17T16:54:09.947Z","fee":0.889898,"feeCurrency":"USDT"},{"gid":162,"id":230991122,"exchange":"binance","price":8884.57,"quantity":0.00143,"quoteQuantity":12.7049351,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-17T16:56:14.572Z","fee":0.01270494,"feeCurrency":"USDT"},{"gid":163,"id":230996684,"exchange":"binance","price":8884.18,"quantity":0.086022,"quoteQuantity":764.23493196,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-17T17:06:46.494Z","fee":0.00008602,"feeCurrency":"BTC"},{"gid":164,"id":230996685,"exchange":"binance","price":8884.19,"quantity":0.013978,"quoteQuantity":124.18320782,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-17T17:06:46.494Z","fee":0.00001398,"feeCurrency":"BTC"},{"gid":165,"id":230996910,"exchange":"binance","price":8888,"quantity":0.06068,"quoteQuantity":539.32384,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-17T17:07:29.816Z","fee":0.00006068,"feeCurrency":"BTC"},{"gid":166,"id":230997205,"exchange":"binance","price":8889.23,"quantity":0.087977,"quoteQuantity":782.04778771,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-17T17:07:57.123Z","fee":0.78204779,"feeCurrency":"USDT"},{"gid":167,"id":230997206,"exchange":"binance","price":8889.21,"quantity":0.115068,"quoteQuantity":1022.86361628,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-17T17:07:57.123Z","fee":1.02286362,"feeCurrency":"USDT"},{"gid":168,"id":230997207,"exchange":"binance","price":8889.17,"quantity":0.089227,"quoteQuantity":793.15397159,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-17T17:07:57.123Z","fee":0.79315397,"feeCurrency":"USDT"},{"gid":169,"id":230997208,"exchange":"binance","price":8889.1,"quantity":0.115917,"quoteQuantity":1030.3978047,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-17T17:07:57.123Z","fee":1.0303978,"feeCurrency":"USDT"},{"gid":170,"id":230998963,"exchange":"binance","price":8895.09,"quantity":0.013488,"quoteQuantity":119.97697392,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-17T17:12:21.609Z","fee":0.00001349,"feeCurrency":"BTC"},{"gid":171,"id":230998964,"exchange":"binance","price":8895.47,"quantity":0.086512,"quoteQuantity":769.56490064,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-17T17:12:21.609Z","fee":0.00008651,"feeCurrency":"BTC"},{"gid":172,"id":231001055,"exchange":"binance","price":8905.34,"quantity":0.015717,"quoteQuantity":139.96522878,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-17T17:18:26.109Z","fee":0.13996523,"feeCurrency":"USDT"},{"gid":173,"id":231001056,"exchange":"binance","price":8905.33,"quantity":0.025078,"quoteQuantity":223.32786574,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-17T17:18:26.109Z","fee":0.22332787,"feeCurrency":"USDT"},{"gid":174,"id":231001057,"exchange":"binance","price":8905.33,"quantity":0.002037,"quoteQuantity":18.14015721,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-17T17:18:26.109Z","fee":0.01814016,"feeCurrency":"USDT"},{"gid":175,"id":231001058,"exchange":"binance","price":8905.18,"quantity":0.057068,"quoteQuantity":508.20081224,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-17T17:18:26.109Z","fee":0.50820081,"feeCurrency":"USDT"},{"gid":176,"id":231059167,"exchange":"binance","price":8887.61,"quantity":0.1,"quoteQuantity":888.761,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-17T19:41:18.207Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":177,"id":231229177,"exchange":"binance","price":8934.08,"quantity":0.0999,"quoteQuantity":892.514592,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-18T03:16:35.994Z","fee":0.89251459,"feeCurrency":"USDT"},{"gid":178,"id":231244178,"exchange":"binance","price":8906.95,"quantity":0.153298,"quoteQuantity":1365.4176211,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-18T04:18:04.065Z","fee":0.0001533,"feeCurrency":"BTC"},{"gid":179,"id":231244179,"exchange":"binance","price":8906.95,"quantity":0.346702,"quoteQuantity":3088.0573789,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-18T04:18:04.079Z","fee":0.0003467,"feeCurrency":"BTC"},{"gid":180,"id":231245037,"exchange":"binance","price":8898,"quantity":0.127783,"quoteQuantity":1137.013134,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-18T04:21:40.895Z","fee":1.13701313,"feeCurrency":"USDT"},{"gid":181,"id":231245038,"exchange":"binance","price":8896.16,"quantity":0.002,"quoteQuantity":17.79232,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-18T04:21:40.895Z","fee":0.01779232,"feeCurrency":"USDT"},{"gid":182,"id":231245039,"exchange":"binance","price":8895.64,"quantity":0.033724,"quoteQuantity":299.99656336,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-18T04:21:40.895Z","fee":0.29999656,"feeCurrency":"USDT"},{"gid":183,"id":231245040,"exchange":"binance","price":8895.61,"quantity":0.02532,"quoteQuantity":225.2368452,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-18T04:21:40.895Z","fee":0.22523685,"feeCurrency":"USDT"},{"gid":184,"id":231245041,"exchange":"binance","price":8895.41,"quantity":0.152013,"quoteQuantity":1352.21796033,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-18T04:21:40.895Z","fee":1.35221796,"feeCurrency":"USDT"},{"gid":185,"id":231245042,"exchange":"binance","price":8895.1,"quantity":0.128371,"quoteQuantity":1141.8728821,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-18T04:21:40.895Z","fee":1.14187288,"feeCurrency":"USDT"},{"gid":186,"id":231245043,"exchange":"binance","price":8895.06,"quantity":0.011242,"quoteQuantity":99.99826452,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-18T04:21:40.895Z","fee":0.09999826,"feeCurrency":"USDT"},{"gid":187,"id":231245044,"exchange":"binance","price":8894.96,"quantity":0.019047,"quoteQuantity":169.42230312,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-18T04:21:40.895Z","fee":0.1694223,"feeCurrency":"USDT"},{"gid":188,"id":231466318,"exchange":"binance","price":8882,"quantity":0.1,"quoteQuantity":888.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-18T15:39:32.492Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":189,"id":231468008,"exchange":"binance","price":8882,"quantity":0.027176,"quoteQuantity":241.377232,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-18T15:40:45.562Z","fee":0.00002718,"feeCurrency":"BTC"},{"gid":190,"id":231468009,"exchange":"binance","price":8882,"quantity":0.003263,"quoteQuantity":28.981966,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-18T15:40:45.619Z","fee":0.00000326,"feeCurrency":"BTC"},{"gid":191,"id":231468010,"exchange":"binance","price":8882,"quantity":0.003263,"quoteQuantity":28.981966,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-18T15:40:45.635Z","fee":0.00000326,"feeCurrency":"BTC"},{"gid":192,"id":231468054,"exchange":"binance","price":8882,"quantity":0.066298,"quoteQuantity":588.858836,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-18T15:40:47.159Z","fee":0.0000663,"feeCurrency":"BTC"},{"gid":193,"id":231469415,"exchange":"binance","price":8872,"quantity":0.005422,"quoteQuantity":48.103984,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-18T15:41:59.322Z","fee":0.00000542,"feeCurrency":"BTC"},{"gid":194,"id":231469417,"exchange":"binance","price":8872,"quantity":0.01,"quoteQuantity":88.72,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-18T15:41:59.332Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":195,"id":231469451,"exchange":"binance","price":8872,"quantity":0.084578,"quoteQuantity":750.376016,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-18T15:42:01.31Z","fee":0.00008458,"feeCurrency":"BTC"},{"gid":196,"id":231513327,"exchange":"binance","price":8890.58,"quantity":0.086704,"quoteQuantity":770.84884832,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-18T17:05:59.236Z","fee":0.77084885,"feeCurrency":"USDT"},{"gid":197,"id":231513328,"exchange":"binance","price":8890.57,"quantity":0.106212,"quoteQuantity":944.28522084,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-18T17:05:59.236Z","fee":0.94428522,"feeCurrency":"USDT"},{"gid":198,"id":231513329,"exchange":"binance","price":8890.54,"quantity":0.106784,"quoteQuantity":949.36742336,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-18T17:05:59.236Z","fee":0.94936742,"feeCurrency":"USDT"},{"gid":199,"id":231853483,"exchange":"binance","price":9065.01,"quantity":0.209985,"quoteQuantity":1903.51612485,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-19T10:26:21.486Z","fee":0.00020999,"feeCurrency":"BTC"},{"gid":200,"id":231853484,"exchange":"binance","price":9065.01,"quantity":0.016381,"quoteQuantity":148.49392881,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-19T10:26:21.486Z","fee":0.00001638,"feeCurrency":"BTC"},{"gid":201,"id":231853485,"exchange":"binance","price":9065.02,"quantity":0.073634,"quoteQuantity":667.49368268,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-19T10:26:21.486Z","fee":0.00007363,"feeCurrency":"BTC"},{"gid":202,"id":231853511,"exchange":"binance","price":9065.97,"quantity":0.1,"quoteQuantity":906.597,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-19T10:26:30.802Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":203,"id":231853600,"exchange":"binance","price":9065.07,"quantity":0.1,"quoteQuantity":906.507,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-19T10:27:07.673Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":204,"id":231863286,"exchange":"binance","price":9011,"quantity":0.1496,"quoteQuantity":1348.0456,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-19T11:00:39.212Z","fee":0.0001496,"feeCurrency":"BTC"},{"gid":205,"id":231863287,"exchange":"binance","price":9011,"quantity":0.135837,"quoteQuantity":1224.027207,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-19T11:00:39.214Z","fee":0.00013584,"feeCurrency":"BTC"},{"gid":206,"id":231887728,"exchange":"binance","price":8633.64,"quantity":0.153172,"quoteQuantity":1322.43190608,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-19T11:02:57.841Z","fee":1.32243191,"feeCurrency":"USDT"},{"gid":207,"id":231887729,"exchange":"binance","price":8633.25,"quantity":0.143799,"quoteQuantity":1241.45271675,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-19T11:02:57.841Z","fee":1.24145272,"feeCurrency":"USDT"},{"gid":208,"id":231887730,"exchange":"binance","price":8633.01,"quantity":0.01,"quoteQuantity":86.3301,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-19T11:02:57.841Z","fee":0.0863301,"feeCurrency":"USDT"},{"gid":209,"id":231887731,"exchange":"binance","price":8633,"quantity":0.477681,"quoteQuantity":4123.820073,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-19T11:02:57.841Z","fee":4.12382007,"feeCurrency":"USDT"},{"gid":210,"id":231891566,"exchange":"binance","price":8619.56,"quantity":0.360923,"quoteQuantity":3110.99745388,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-19T11:03:36.495Z","fee":0.00036092,"feeCurrency":"BTC"},{"gid":211,"id":231891567,"exchange":"binance","price":8631.37,"quantity":0.339077,"quoteQuantity":2926.69904549,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-19T11:03:36.495Z","fee":0.00033908,"feeCurrency":"BTC"},{"gid":212,"id":231893285,"exchange":"binance","price":8577.82,"quantity":0.6993,"quoteQuantity":5998.469526,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-19T11:03:45.928Z","fee":5.99846953,"feeCurrency":"USDT"},{"gid":213,"id":231901230,"exchange":"binance","price":8698.84,"quantity":0.085665,"quoteQuantity":745.1861286,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-19T11:04:58.784Z","fee":0.00008567,"feeCurrency":"BTC"},{"gid":214,"id":231912338,"exchange":"binance","price":8651.12,"quantity":0.660616,"quoteQuantity":5715.06828992,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-19T11:08:35.98Z","fee":0.00066062,"feeCurrency":"BTC"},{"gid":215,"id":231912339,"exchange":"binance","price":8651.13,"quantity":0.031565,"quoteQuantity":273.07291845,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-19T11:08:35.98Z","fee":0.00003157,"feeCurrency":"BTC"},{"gid":216,"id":231951293,"exchange":"binance","price":8627.92,"quantity":0.155532,"quoteQuantity":1341.91765344,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-19T11:34:40.461Z","fee":1.34191765,"feeCurrency":"USDT"},{"gid":217,"id":231951294,"exchange":"binance","price":8627.15,"quantity":0.057944,"quoteQuantity":499.8915796,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-19T11:34:40.461Z","fee":0.49989158,"feeCurrency":"USDT"},{"gid":218,"id":231951295,"exchange":"binance","price":8626.65,"quantity":0.4,"quoteQuantity":3450.66,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-19T11:34:40.461Z","fee":3.45066,"feeCurrency":"USDT"},{"gid":219,"id":231951296,"exchange":"binance","price":8626.28,"quantity":0.163592,"quoteQuantity":1411.19039776,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-19T11:34:40.461Z","fee":1.4111904,"feeCurrency":"USDT"},{"gid":220,"id":231957039,"exchange":"binance","price":8654.81,"quantity":0.774099,"quoteQuantity":6699.67976619,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-19T11:41:49.953Z","fee":0.0007741,"feeCurrency":"BTC"},{"gid":221,"id":232020334,"exchange":"binance","price":8606.91,"quantity":0.290452,"quoteQuantity":2499.89422332,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-19T13:38:12.301Z","fee":2.49989422,"feeCurrency":"USDT"},{"gid":222,"id":232020335,"exchange":"binance","price":8606.87,"quantity":0.482873,"quoteQuantity":4156.02513751,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-19T13:38:12.301Z","fee":4.15602514,"feeCurrency":"USDT"},{"gid":223,"id":232020982,"exchange":"binance","price":8619.94,"quantity":0.290041,"quoteQuantity":2500.13601754,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-19T13:40:12.054Z","fee":0.00029004,"feeCurrency":"BTC"},{"gid":224,"id":232020983,"exchange":"binance","price":8619.98,"quantity":0.071186,"quoteQuantity":613.62189628,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-19T13:40:12.054Z","fee":0.00007119,"feeCurrency":"BTC"},{"gid":225,"id":232020984,"exchange":"binance","price":8620,"quantity":0.004995,"quoteQuantity":43.0569,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-19T13:40:12.054Z","fee":0.000005,"feeCurrency":"BTC"},{"gid":226,"id":232020985,"exchange":"binance","price":8620,"quantity":0.01,"quoteQuantity":86.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-19T13:40:12.054Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":227,"id":232020986,"exchange":"binance","price":8620,"quantity":0.054716,"quoteQuantity":471.65192,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-19T13:40:12.054Z","fee":0.00005472,"feeCurrency":"BTC"},{"gid":228,"id":232020987,"exchange":"binance","price":8620,"quantity":0.018,"quoteQuantity":155.16,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-19T13:40:12.054Z","fee":0.000018,"feeCurrency":"BTC"},{"gid":229,"id":232020988,"exchange":"binance","price":8620,"quantity":0.12987,"quoteQuantity":1119.4794,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-19T13:40:12.054Z","fee":0.00012987,"feeCurrency":"BTC"},{"gid":230,"id":232021091,"exchange":"binance","price":8618.62,"quantity":0.144501,"quoteQuantity":1245.39920862,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-19T13:40:23.461Z","fee":0.0001445,"feeCurrency":"BTC"},{"gid":231,"id":232396480,"exchange":"binance","price":8611.14,"quantity":0.17521,"quoteQuantity":1508.7578394,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-20T11:57:23.768Z","fee":1.50875784,"feeCurrency":"USDT"},{"gid":232,"id":232396481,"exchange":"binance","price":8611.05,"quantity":0.547375,"quoteQuantity":4713.47349375,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-20T11:57:23.768Z","fee":4.71347349,"feeCurrency":"USDT"},{"gid":233,"id":232396779,"exchange":"binance","price":8625.77,"quantity":0.577412,"quoteQuantity":4980.62310724,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-20T11:57:36.457Z","fee":0.00057741,"feeCurrency":"BTC"},{"gid":234,"id":232396843,"exchange":"binance","price":8628.11,"quantity":0.143687,"quoteQuantity":1239.74724157,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-20T11:57:41.044Z","fee":0.00014369,"feeCurrency":"BTC"},{"gid":235,"id":232396891,"exchange":"binance","price":8628.3,"quantity":0.035727,"quoteQuantity":308.2632741,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-20T11:57:51.372Z","fee":0.00003573,"feeCurrency":"BTC"},{"gid":236,"id":232396954,"exchange":"binance","price":8622.78,"quantity":0.008883,"quoteQuantity":76.59615474,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-20T11:57:59.732Z","fee":0.00000888,"feeCurrency":"BTC"},{"gid":237,"id":232396977,"exchange":"binance","price":8622.29,"quantity":0.002213,"quoteQuantity":19.08112777,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-20T11:58:03.799Z","fee":0.00000221,"feeCurrency":"BTC"},{"gid":238,"id":232406183,"exchange":"binance","price":8589,"quantity":0.091806,"quoteQuantity":788.521734,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-20T12:21:57.416Z","fee":0.78852173,"feeCurrency":"USDT"},{"gid":239,"id":232406185,"exchange":"binance","price":8589,"quantity":0.508139,"quoteQuantity":4364.405871,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-20T12:21:57.44Z","fee":4.36440587,"feeCurrency":"USDT"},{"gid":240,"id":232406187,"exchange":"binance","price":8589,"quantity":0.047108,"quoteQuantity":404.610612,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-20T12:21:57.468Z","fee":0.40461061,"feeCurrency":"USDT"},{"gid":241,"id":232406189,"exchange":"binance","price":8589,"quantity":0.1,"quoteQuantity":858.9,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-20T12:21:57.485Z","fee":0.8589,"feeCurrency":"USDT"},{"gid":242,"id":232406191,"exchange":"binance","price":8589,"quantity":0.002329,"quoteQuantity":20.003781,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-20T12:21:57.536Z","fee":0.02000378,"feeCurrency":"USDT"},{"gid":243,"id":232406194,"exchange":"binance","price":8589,"quantity":0.017772,"quoteQuantity":152.643708,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-20T12:21:57.586Z","fee":0.15264371,"feeCurrency":"USDT"},{"gid":244,"id":232428715,"exchange":"binance","price":8548.02,"quantity":0.292519,"quoteQuantity":2500.45826238,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-20T13:13:10.383Z","fee":0.00029252,"feeCurrency":"BTC"},{"gid":245,"id":232428716,"exchange":"binance","price":8548.06,"quantity":0.126675,"quoteQuantity":1082.8255005,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-20T13:13:10.383Z","fee":0.00012668,"feeCurrency":"BTC"},{"gid":246,"id":232428717,"exchange":"binance","price":8548.15,"quantity":0.158795,"quoteQuantity":1357.40347925,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-20T13:13:10.383Z","fee":0.0001588,"feeCurrency":"BTC"},{"gid":247,"id":232428737,"exchange":"binance","price":8545.32,"quantity":0.106305,"quoteQuantity":908.4102426,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-20T13:13:14.505Z","fee":0.00010631,"feeCurrency":"BTC"},{"gid":248,"id":232428738,"exchange":"binance","price":8545.34,"quantity":0.032109,"quoteQuantity":274.38232206,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-20T13:13:14.505Z","fee":0.00003211,"feeCurrency":"BTC"},{"gid":249,"id":232428739,"exchange":"binance","price":8545.41,"quantity":0.006165,"quoteQuantity":52.68245265,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-20T13:13:14.505Z","fee":0.00000617,"feeCurrency":"BTC"},{"gid":250,"id":232428791,"exchange":"binance","price":8541.6,"quantity":0.048307,"quoteQuantity":412.6190712,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-20T13:13:24.558Z","fee":0.00004831,"feeCurrency":"BTC"},{"gid":251,"id":232429291,"exchange":"binance","price":8532.45,"quantity":0.770104,"quoteQuantity":6570.8738748,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-20T13:13:56.819Z","fee":6.57087387,"feeCurrency":"USDT"},{"gid":252,"id":232429507,"exchange":"binance","price":8540.15,"quantity":0.768621,"quoteQuantity":6564.13863315,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-20T13:14:16.933Z","fee":0.00076862,"feeCurrency":"BTC"},{"gid":253,"id":232795933,"exchange":"binance","price":8650.31,"quantity":0.009188,"quoteQuantity":79.47904828,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-21T14:36:02.84Z","fee":0.07947905,"feeCurrency":"USDT"},{"gid":254,"id":232795936,"exchange":"binance","price":8650.31,"quantity":0.1128,"quoteQuantity":975.754968,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-21T14:36:03.597Z","fee":0.97575497,"feeCurrency":"USDT"},{"gid":255,"id":232795965,"exchange":"binance","price":8650.31,"quantity":0.084858,"quoteQuantity":734.04800598,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-21T14:36:09.503Z","fee":0.73404801,"feeCurrency":"USDT"},{"gid":256,"id":232795966,"exchange":"binance","price":8650.31,"quantity":0.045866,"quoteQuantity":396.75511846,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-21T14:36:10.522Z","fee":0.39675512,"feeCurrency":"USDT"},{"gid":257,"id":232795967,"exchange":"binance","price":8650.31,"quantity":0.019545,"quoteQuantity":169.07030895,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-21T14:36:10.854Z","fee":0.16907031,"feeCurrency":"USDT"},{"gid":258,"id":232795974,"exchange":"binance","price":8650.31,"quantity":0.000208,"quoteQuantity":1.79926448,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-21T14:36:12.973Z","fee":0.00179926,"feeCurrency":"USDT"},{"gid":259,"id":232795975,"exchange":"binance","price":8650.31,"quantity":0.054864,"quoteQuantity":474.59060784,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-21T14:36:13.099Z","fee":0.47459061,"feeCurrency":"USDT"},{"gid":260,"id":232795976,"exchange":"binance","price":8650.31,"quantity":0.026283,"quoteQuantity":227.35609773,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-21T14:36:13.182Z","fee":0.2273561,"feeCurrency":"USDT"},{"gid":261,"id":232795977,"exchange":"binance","price":8650.31,"quantity":0.04689,"quoteQuantity":405.6130359,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-21T14:36:13.277Z","fee":0.40561304,"feeCurrency":"USDT"},{"gid":262,"id":232795978,"exchange":"binance","price":8650.31,"quantity":0.052038,"quoteQuantity":450.14483178,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-21T14:36:13.366Z","fee":0.45014483,"feeCurrency":"USDT"},{"gid":263,"id":232795980,"exchange":"binance","price":8650.31,"quantity":0.084197,"quoteQuantity":728.33015107,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-21T14:36:13.511Z","fee":0.72833015,"feeCurrency":"USDT"},{"gid":264,"id":232795983,"exchange":"binance","price":8650.31,"quantity":0.045238,"quoteQuantity":391.32272378,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-21T14:36:13.578Z","fee":0.39132272,"feeCurrency":"USDT"},{"gid":265,"id":232795984,"exchange":"binance","price":8650.31,"quantity":0.05276,"quoteQuantity":456.3903556,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-21T14:36:13.677Z","fee":0.45639036,"feeCurrency":"USDT"},{"gid":266,"id":232795985,"exchange":"binance","price":8650.31,"quantity":0.051315,"quoteQuantity":443.89065765,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-21T14:36:13.791Z","fee":0.44389066,"feeCurrency":"USDT"},{"gid":267,"id":232795986,"exchange":"binance","price":8650.31,"quantity":0.050567,"quoteQuantity":437.42022577,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-21T14:36:13.883Z","fee":0.43742023,"feeCurrency":"USDT"},{"gid":268,"id":232795987,"exchange":"binance","price":8650.31,"quantity":0.031225,"quoteQuantity":270.10592975,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-21T14:36:13.988Z","fee":0.27010593,"feeCurrency":"USDT"},{"gid":269,"id":232796027,"exchange":"binance","price":8650.31,"quantity":0.000011,"quoteQuantity":0.09515341,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-21T14:36:23.981Z","fee":0.00009515,"feeCurrency":"USDT"},{"gid":270,"id":233036682,"exchange":"binance","price":8722.73,"quantity":0.000009,"quoteQuantity":0.07850457,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-22T05:51:50.091Z","fee":1e-8,"feeCurrency":"BTC"},{"gid":271,"id":233036709,"exchange":"binance","price":8722.69,"quantity":0.286609,"quoteQuantity":2500.00145821,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-22T05:52:03.314Z","fee":0.00028661,"feeCurrency":"BTC"},{"gid":272,"id":233036710,"exchange":"binance","price":8722.69,"quantity":0.474126,"quoteQuantity":4135.65411894,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-22T05:52:03.314Z","fee":0.00047413,"feeCurrency":"BTC"},{"gid":273,"id":233208208,"exchange":"binance","price":8600,"quantity":0.759983,"quoteQuantity":6535.8538,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-22T16:36:45.67Z","fee":6.5358538,"feeCurrency":"USDT"},{"gid":274,"id":233317988,"exchange":"binance","price":8600,"quantity":0.759223,"quoteQuantity":6529.3178,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-23T01:42:22.22Z","fee":0.00075922,"feeCurrency":"BTC"},{"gid":275,"id":233515641,"exchange":"binance","price":8443.77,"quantity":0.1,"quoteQuantity":844.377,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-23T10:58:59.323Z","fee":0.844377,"feeCurrency":"USDT"},{"gid":276,"id":233516359,"exchange":"binance","price":8442.92,"quantity":0.1,"quoteQuantity":844.292,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-23T11:02:22.583Z","fee":0.844292,"feeCurrency":"USDT"},{"gid":277,"id":233524414,"exchange":"binance","price":8436.95,"quantity":0.064501,"quoteQuantity":544.19171195,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-23T11:24:02.004Z","fee":0.54419171,"feeCurrency":"USDT"},{"gid":278,"id":233524415,"exchange":"binance","price":8436.93,"quantity":0.035499,"quoteQuantity":299.50257807,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-23T11:24:02.004Z","fee":0.29950258,"feeCurrency":"USDT"},{"gid":279,"id":233524424,"exchange":"binance","price":8438,"quantity":0.1,"quoteQuantity":843.8,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-23T11:24:04.088Z","fee":0.8438,"feeCurrency":"USDT"},{"gid":280,"id":233524425,"exchange":"binance","price":8437.78,"quantity":0.1,"quoteQuantity":843.778,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-23T11:24:05.408Z","fee":0.843778,"feeCurrency":"USDT"},{"gid":281,"id":233528746,"exchange":"binance","price":8454.59,"quantity":0.258464,"quoteQuantity":2185.20714976,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-23T11:40:47.569Z","fee":2.18520715,"feeCurrency":"USDT"},{"gid":282,"id":234261570,"exchange":"binance","price":8349.44,"quantity":0.1,"quoteQuantity":834.944,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-25T06:05:43.397Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":283,"id":234262101,"exchange":"binance","price":8346.28,"quantity":0.011978,"quoteQuantity":99.97174184,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-25T06:06:22.341Z","fee":0.09997174,"feeCurrency":"USDT"},{"gid":284,"id":234262102,"exchange":"binance","price":8346.27,"quantity":0.057052,"quoteQuantity":476.17139604,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-25T06:06:22.341Z","fee":0.4761714,"feeCurrency":"USDT"},{"gid":285,"id":234262103,"exchange":"binance","price":8346.23,"quantity":0.03087,"quoteQuantity":257.6481201,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-25T06:06:22.341Z","fee":0.25764812,"feeCurrency":"USDT"},{"gid":286,"id":234339428,"exchange":"binance","price":8298.5,"quantity":0.1,"quoteQuantity":829.85,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-25T12:57:55.204Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":287,"id":234380185,"exchange":"binance","price":8352.05,"quantity":0.008774,"quoteQuantity":73.2808867,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-25T16:41:11.684Z","fee":0.07328089,"feeCurrency":"USDT"},{"gid":288,"id":234380186,"exchange":"binance","price":8352.05,"quantity":0.060005,"quoteQuantity":501.16476025,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-25T16:41:12.13Z","fee":0.50116476,"feeCurrency":"USDT"},{"gid":289,"id":234380187,"exchange":"binance","price":8352.05,"quantity":0.001393,"quoteQuantity":11.63440565,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-25T16:41:12.187Z","fee":0.01163441,"feeCurrency":"USDT"},{"gid":290,"id":234380190,"exchange":"binance","price":8352.05,"quantity":0.002,"quoteQuantity":16.7041,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-25T16:41:12.839Z","fee":0.0167041,"feeCurrency":"USDT"},{"gid":291,"id":234380195,"exchange":"binance","price":8352.05,"quantity":0.027728,"quoteQuantity":231.5856424,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-25T16:41:14.28Z","fee":0.23158564,"feeCurrency":"USDT"},{"gid":292,"id":234461193,"exchange":"binance","price":8304.17,"quantity":0.2,"quoteQuantity":1660.834,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-26T00:27:35.422Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":293,"id":234461414,"exchange":"binance","price":8306.89,"quantity":0.1,"quoteQuantity":830.689,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-26T00:28:03.479Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":294,"id":234530138,"exchange":"binance","price":8396.98,"quantity":0.2997,"quoteQuantity":2516.574906,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-26T08:07:39.703Z","fee":2.51657491,"feeCurrency":"USDT"},{"gid":295,"id":234531506,"exchange":"binance","price":8412.17,"quantity":0.1,"quoteQuantity":841.217,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-26T08:11:08.89Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":296,"id":234531809,"exchange":"binance","price":8415,"quantity":0.063862,"quoteQuantity":537.39873,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-26T08:11:34.326Z","fee":0.00006386,"feeCurrency":"BTC"},{"gid":297,"id":234531810,"exchange":"binance","price":8415.02,"quantity":0.00338,"quoteQuantity":28.4427676,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-26T08:11:34.326Z","fee":0.00000338,"feeCurrency":"BTC"},{"gid":298,"id":234531811,"exchange":"binance","price":8415.02,"quantity":0.0068,"quoteQuantity":57.222136,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-26T08:11:34.326Z","fee":0.0000068,"feeCurrency":"BTC"},{"gid":299,"id":234531812,"exchange":"binance","price":8415.2,"quantity":0.001952,"quoteQuantity":16.4264704,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-26T08:11:34.326Z","fee":0.00000195,"feeCurrency":"BTC"},{"gid":300,"id":234531813,"exchange":"binance","price":8415.32,"quantity":0.010797,"quoteQuantity":90.86021004,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-26T08:11:34.326Z","fee":0.0000108,"feeCurrency":"BTC"},{"gid":301,"id":234531814,"exchange":"binance","price":8415.36,"quantity":0.0025,"quoteQuantity":21.0384,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-26T08:11:34.326Z","fee":0.0000025,"feeCurrency":"BTC"},{"gid":302,"id":234531815,"exchange":"binance","price":8415.42,"quantity":0.0029,"quoteQuantity":24.404718,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-26T08:11:34.326Z","fee":0.0000029,"feeCurrency":"BTC"},{"gid":303,"id":234531816,"exchange":"binance","price":8415.55,"quantity":0.001559,"quoteQuantity":13.11984245,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-26T08:11:34.326Z","fee":0.00000156,"feeCurrency":"BTC"},{"gid":304,"id":234531817,"exchange":"binance","price":8415.59,"quantity":0.005738,"quoteQuantity":48.28865542,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-26T08:11:34.326Z","fee":0.00000574,"feeCurrency":"BTC"},{"gid":305,"id":234531818,"exchange":"binance","price":8415.59,"quantity":0.000512,"quoteQuantity":4.30878208,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-26T08:11:34.326Z","fee":5.1e-7,"feeCurrency":"BTC"},{"gid":306,"id":234903866,"exchange":"binance","price":8659,"quantity":0.2,"quoteQuantity":1731.8,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-27T07:44:28.107Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":307,"id":235401961,"exchange":"binance","price":9088.81,"quantity":0.022278,"quoteQuantity":202.48050918,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-28T03:23:04.547Z","fee":0.00002228,"feeCurrency":"BTC"},{"gid":308,"id":235401962,"exchange":"binance","price":9088.81,"quantity":0.308728,"quoteQuantity":2805.97013368,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-28T03:23:04.725Z","fee":0.00030873,"feeCurrency":"BTC"},{"gid":309,"id":235495577,"exchange":"binance","price":8995.41,"quantity":0.029163,"quoteQuantity":262.33314183,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-28T08:20:57.504Z","fee":0.26233314,"feeCurrency":"USDT"},{"gid":310,"id":235495578,"exchange":"binance","price":8995.3,"quantity":0.170837,"quoteQuantity":1536.7300661,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-28T08:20:57.504Z","fee":1.53673007,"feeCurrency":"USDT"},{"gid":311,"id":235501281,"exchange":"binance","price":8993.19,"quantity":0.02332,"quoteQuantity":209.7211908,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-28T08:32:31.031Z","fee":0.00002332,"feeCurrency":"BTC"},{"gid":312,"id":235501282,"exchange":"binance","price":8993.21,"quantity":0.176514,"quoteQuantity":1587.42746994,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-28T08:32:31.031Z","fee":0.00017651,"feeCurrency":"BTC"},{"gid":313,"id":235616575,"exchange":"binance","price":9100,"quantity":0.2,"quoteQuantity":1820,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-28T13:54:14.739Z","fee":1.82,"feeCurrency":"USDT"},{"gid":314,"id":235844351,"exchange":"binance","price":9085.72,"quantity":0.1,"quoteQuantity":908.572,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-28T22:21:23.257Z","fee":0.908572,"feeCurrency":"USDT"},{"gid":315,"id":235844446,"exchange":"binance","price":9085.85,"quantity":0.1,"quoteQuantity":908.585,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-28T22:21:48.007Z","fee":0.908585,"feeCurrency":"USDT"},{"gid":316,"id":235968661,"exchange":"binance","price":9331.7,"quantity":0.011022,"quoteQuantity":102.8539974,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-29T01:44:56.601Z","fee":0.00001102,"feeCurrency":"BTC"},{"gid":317,"id":235968662,"exchange":"binance","price":9334.1,"quantity":0.088978,"quoteQuantity":830.5295498,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-29T01:44:56.601Z","fee":0.00008898,"feeCurrency":"BTC"},{"gid":318,"id":235978391,"exchange":"binance","price":9300,"quantity":0.1,"quoteQuantity":930,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-29T02:23:12.962Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":319,"id":236119258,"exchange":"binance","price":9281,"quantity":0.0154,"quoteQuantity":142.9274,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-29T10:02:51.344Z","fee":0.0000154,"feeCurrency":"BTC"},{"gid":320,"id":236119259,"exchange":"binance","price":9281,"quantity":0.003948,"quoteQuantity":36.641388,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-29T10:02:51.344Z","fee":0.00000395,"feeCurrency":"BTC"},{"gid":321,"id":236119263,"exchange":"binance","price":9281,"quantity":0.060195,"quoteQuantity":558.669795,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-29T10:02:51.38Z","fee":0.0000602,"feeCurrency":"BTC"},{"gid":322,"id":236119264,"exchange":"binance","price":9281,"quantity":0.01,"quoteQuantity":92.81,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-29T10:02:51.536Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":323,"id":236119265,"exchange":"binance","price":9281,"quantity":0.010457,"quoteQuantity":97.051417,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-29T10:02:51.592Z","fee":0.00001046,"feeCurrency":"BTC"},{"gid":324,"id":236254830,"exchange":"binance","price":9288.99,"quantity":0.047819,"quoteQuantity":444.19021281,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-29T15:44:38.482Z","fee":0.00004782,"feeCurrency":"BTC"},{"gid":325,"id":236254831,"exchange":"binance","price":9288.99,"quantity":0.042842,"quoteQuantity":397.95890958,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-29T15:44:38.482Z","fee":0.00004284,"feeCurrency":"BTC"},{"gid":326,"id":236326169,"exchange":"binance","price":9400,"quantity":0.1,"quoteQuantity":940,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-29T19:37:09.025Z","fee":0.94,"feeCurrency":"USDT"},{"gid":327,"id":236490569,"exchange":"binance","price":9347.11,"quantity":0.1,"quoteQuantity":934.711,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-01-30T04:31:09.484Z","fee":0.934711,"feeCurrency":"USDT"},{"gid":328,"id":236496991,"exchange":"binance","price":9350,"quantity":0.1,"quoteQuantity":935,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-30T05:02:15.34Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":329,"id":236646795,"exchange":"binance","price":9400,"quantity":0.1,"quoteQuantity":940,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-01-30T13:50:12.384Z","fee":0.94,"feeCurrency":"USDT"},{"gid":330,"id":236728597,"exchange":"binance","price":9320,"quantity":0.1,"quoteQuantity":932,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-01-30T16:32:43.061Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":331,"id":236743309,"exchange":"binance","price":9293.14,"quantity":0.101637,"quoteQuantity":944.52687018,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-01-30T16:37:47.269Z","fee":0.00010164,"feeCurrency":"BTC"},{"gid":332,"id":237457222,"exchange":"binance","price":9430,"quantity":0.008944,"quoteQuantity":84.34192,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-01T07:18:40.874Z","fee":0.08434192,"feeCurrency":"USDT"},{"gid":333,"id":237457224,"exchange":"binance","price":9430,"quantity":0.019947,"quoteQuantity":188.10021,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-01T07:18:41.74Z","fee":0.18810021,"feeCurrency":"USDT"},{"gid":334,"id":237457225,"exchange":"binance","price":9430,"quantity":0.021109,"quoteQuantity":199.05787,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-01T07:18:42.099Z","fee":0.19905787,"feeCurrency":"USDT"},{"gid":335,"id":237496158,"exchange":"binance","price":9401.01,"quantity":0.05,"quoteQuantity":470.0505,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-01T10:05:28.864Z","fee":0.4700505,"feeCurrency":"USDT"},{"gid":336,"id":237496509,"exchange":"binance","price":9401.33,"quantity":0.1,"quoteQuantity":940.133,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-01T10:06:55.939Z","fee":0.940133,"feeCurrency":"USDT"},{"gid":337,"id":237738521,"exchange":"binance","price":9260,"quantity":0.1,"quoteQuantity":926,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-02T02:11:22.919Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":338,"id":237919221,"exchange":"binance","price":9416.99,"quantity":0.1,"quoteQuantity":941.699,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-02T10:19:30.684Z","fee":0.941699,"feeCurrency":"USDT"},{"gid":339,"id":237952776,"exchange":"binance","price":9447.23,"quantity":0.001164,"quoteQuantity":10.99657572,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-02T12:06:02.83Z","fee":0.01099658,"feeCurrency":"USDT"},{"gid":340,"id":237953054,"exchange":"binance","price":9447,"quantity":0.01198,"quoteQuantity":113.17506,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-02T12:06:41.21Z","fee":0.11317506,"feeCurrency":"USDT"},{"gid":341,"id":237953241,"exchange":"binance","price":9447,"quantity":0.0102,"quoteQuantity":96.3594,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-02T12:07:10.729Z","fee":0.0963594,"feeCurrency":"USDT"},{"gid":342,"id":237953243,"exchange":"binance","price":9447,"quantity":0.03657,"quoteQuantity":345.47679,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-02T12:07:10.948Z","fee":0.34547679,"feeCurrency":"USDT"},{"gid":343,"id":237953244,"exchange":"binance","price":9447,"quantity":0.040086,"quoteQuantity":378.692442,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-02T12:07:11.084Z","fee":0.37869244,"feeCurrency":"USDT"},{"gid":344,"id":237994520,"exchange":"binance","price":9390,"quantity":0.041665,"quoteQuantity":391.23435,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-02T14:09:50.422Z","fee":0.00004167,"feeCurrency":"BTC"},{"gid":345,"id":237994521,"exchange":"binance","price":9390,"quantity":0.058335,"quoteQuantity":547.76565,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-02T14:09:50.422Z","fee":0.00005834,"feeCurrency":"BTC"},{"gid":346,"id":237994901,"exchange":"binance","price":9380,"quantity":0.1,"quoteQuantity":938,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-02T14:09:53.615Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":347,"id":238008264,"exchange":"binance","price":9443.83,"quantity":0.012704,"quoteQuantity":119.97441632,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-02T14:40:14.802Z","fee":0.11997442,"feeCurrency":"USDT"},{"gid":348,"id":238008265,"exchange":"binance","price":9443.82,"quantity":0.087296,"quoteQuantity":824.40771072,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-02T14:40:14.802Z","fee":0.82440771,"feeCurrency":"USDT"},{"gid":349,"id":238016181,"exchange":"binance","price":9445,"quantity":0.026261,"quoteQuantity":248.035145,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-02T15:05:52.29Z","fee":0.24803515,"feeCurrency":"USDT"},{"gid":350,"id":238016337,"exchange":"binance","price":9438.49,"quantity":0.05,"quoteQuantity":471.9245,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-02T15:06:19.199Z","fee":0.4719245,"feeCurrency":"USDT"},{"gid":351,"id":238019124,"exchange":"binance","price":9456.85,"quantity":0.1,"quoteQuantity":945.685,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-02T15:18:16.767Z","fee":0.945685,"feeCurrency":"USDT"},{"gid":352,"id":238120859,"exchange":"binance","price":9380,"quantity":0.1,"quoteQuantity":938,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-02T22:42:35.884Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":353,"id":238158220,"exchange":"binance","price":9318.62,"quantity":0.04943,"quoteQuantity":460.6193866,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-03T00:10:17.569Z","fee":0.00004943,"feeCurrency":"BTC"},{"gid":354,"id":238158221,"exchange":"binance","price":9318.62,"quantity":0.006001,"quoteQuantity":55.92103862,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-03T00:10:17.645Z","fee":0.000006,"feeCurrency":"BTC"},{"gid":355,"id":238158222,"exchange":"binance","price":9318.62,"quantity":0.044569,"quoteQuantity":415.32157478,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-03T00:10:17.742Z","fee":0.00004457,"feeCurrency":"BTC"},{"gid":356,"id":238158762,"exchange":"binance","price":9327.54,"quantity":0.1,"quoteQuantity":932.754,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-03T00:10:54.602Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":357,"id":238203352,"exchange":"binance","price":9547.21,"quantity":0.042934,"quoteQuantity":409.89991414,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-03T01:33:58.332Z","fee":0.40989991,"feeCurrency":"USDT"},{"gid":358,"id":238203353,"exchange":"binance","price":9547.1,"quantity":0.01,"quoteQuantity":95.471,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-03T01:33:58.332Z","fee":0.095471,"feeCurrency":"USDT"},{"gid":359,"id":238203354,"exchange":"binance","price":9546.97,"quantity":0.047066,"quoteQuantity":449.33769002,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-03T01:33:58.332Z","fee":0.44933769,"feeCurrency":"USDT"},{"gid":360,"id":238204036,"exchange":"binance","price":9540.75,"quantity":0.1,"quoteQuantity":954.075,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-03T01:34:17.916Z","fee":0.954075,"feeCurrency":"USDT"},{"gid":361,"id":238211156,"exchange":"binance","price":9576.14,"quantity":0.048011,"quoteQuantity":459.76005754,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-03T01:36:01.448Z","fee":0.45976006,"feeCurrency":"USDT"},{"gid":362,"id":238211157,"exchange":"binance","price":9574.07,"quantity":0.040526,"quoteQuantity":387.99876082,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-03T01:36:01.448Z","fee":0.38799876,"feeCurrency":"USDT"},{"gid":363,"id":238211158,"exchange":"binance","price":9572.93,"quantity":0.010261,"quoteQuantity":98.22783473,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-03T01:36:01.448Z","fee":0.09822783,"feeCurrency":"USDT"},{"gid":364,"id":238211159,"exchange":"binance","price":9572.49,"quantity":0.001202,"quoteQuantity":11.50613298,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-03T01:36:01.448Z","fee":0.01150613,"feeCurrency":"USDT"},{"gid":365,"id":238212496,"exchange":"binance","price":9555.98,"quantity":0.1,"quoteQuantity":955.598,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-03T01:36:39.156Z","fee":0.955598,"feeCurrency":"USDT"},{"gid":366,"id":238276135,"exchange":"binance","price":9372.29,"quantity":0.1,"quoteQuantity":937.229,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-03T03:39:01.835Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":367,"id":238276349,"exchange":"binance","price":9370,"quantity":0.1,"quoteQuantity":937,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-03T03:40:09.764Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":368,"id":238328606,"exchange":"binance","price":9350,"quantity":0.1,"quoteQuantity":935,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-03T07:31:57.404Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":369,"id":238517165,"exchange":"binance","price":9271,"quantity":0.1,"quoteQuantity":927.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-03T15:59:41.924Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":370,"id":238794919,"exchange":"binance","price":9241.87,"quantity":0.021625,"quoteQuantity":199.85543875,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-04T09:00:44.757Z","fee":0.00002163,"feeCurrency":"BTC"},{"gid":371,"id":238794920,"exchange":"binance","price":9241.88,"quantity":0.070139,"quoteQuantity":648.21622132,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-04T09:00:44.757Z","fee":0.00007014,"feeCurrency":"BTC"},{"gid":372,"id":238813242,"exchange":"binance","price":9197.38,"quantity":0.1,"quoteQuantity":919.738,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-04T09:24:35.696Z","fee":0.919738,"feeCurrency":"USDT"},{"gid":373,"id":238878105,"exchange":"binance","price":9200,"quantity":0.1,"quoteQuantity":920,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-04T11:29:15.175Z","fee":0.92,"feeCurrency":"USDT"},{"gid":374,"id":238911719,"exchange":"binance","price":9121,"quantity":0.2,"quoteQuantity":1824.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-04T12:36:29.286Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":375,"id":238939989,"exchange":"binance","price":9130.07,"quantity":0.1,"quoteQuantity":913.007,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-04T13:42:20.712Z","fee":0.913007,"feeCurrency":"USDT"},{"gid":376,"id":238940016,"exchange":"binance","price":9130.04,"quantity":0.076578,"quoteQuantity":699.16020312,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-04T13:42:32.113Z","fee":0.6991602,"feeCurrency":"USDT"},{"gid":377,"id":238940017,"exchange":"binance","price":9130.04,"quantity":0.023376,"quoteQuantity":213.42381504,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-04T13:42:32.221Z","fee":0.21342382,"feeCurrency":"USDT"},{"gid":378,"id":238940019,"exchange":"binance","price":9130.04,"quantity":0.000046,"quoteQuantity":0.41998184,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-04T13:42:33.402Z","fee":0.00041998,"feeCurrency":"USDT"},{"gid":379,"id":238971516,"exchange":"binance","price":9198,"quantity":0.2,"quoteQuantity":1839.6,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-04T15:07:11.13Z","fee":1.8396,"feeCurrency":"USDT"},{"gid":380,"id":239012712,"exchange":"binance","price":9200.01,"quantity":0.03,"quoteQuantity":276.0003,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-04T16:00:58.024Z","fee":0.2760003,"feeCurrency":"USDT"},{"gid":381,"id":239013400,"exchange":"binance","price":9212,"quantity":0.1,"quoteQuantity":921.2,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-04T16:01:51.232Z","fee":0.9212,"feeCurrency":"USDT"},{"gid":382,"id":239017204,"exchange":"binance","price":9210,"quantity":0.018633,"quoteQuantity":171.60993,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-04T16:06:50.923Z","fee":0.00001863,"feeCurrency":"BTC"},{"gid":383,"id":239017213,"exchange":"binance","price":9210,"quantity":0.060351,"quoteQuantity":555.83271,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-04T16:06:51.22Z","fee":0.00006035,"feeCurrency":"BTC"},{"gid":384,"id":239017306,"exchange":"binance","price":9210,"quantity":0.021016,"quoteQuantity":193.55736,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-04T16:07:01.329Z","fee":0.00002102,"feeCurrency":"BTC"},{"gid":385,"id":239017566,"exchange":"binance","price":9208,"quantity":0.060236,"quoteQuantity":554.653088,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-04T16:07:24.504Z","fee":0.00006024,"feeCurrency":"BTC"},{"gid":386,"id":239019777,"exchange":"binance","price":9208,"quantity":0.039764,"quoteQuantity":366.146912,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-04T16:10:44.138Z","fee":0.00003976,"feeCurrency":"BTC"},{"gid":387,"id":239062787,"exchange":"binance","price":9210,"quantity":0.1,"quoteQuantity":921,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-04T17:47:40.415Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":388,"id":239105406,"exchange":"binance","price":9170,"quantity":0.032662,"quoteQuantity":299.51054,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-04T20:08:45.877Z","fee":0.00003266,"feeCurrency":"BTC"},{"gid":389,"id":239105407,"exchange":"binance","price":9170,"quantity":0.060288,"quoteQuantity":552.84096,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-04T20:08:46.17Z","fee":0.00006029,"feeCurrency":"BTC"},{"gid":390,"id":239105408,"exchange":"binance","price":9170,"quantity":0.00705,"quoteQuantity":64.6485,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-04T20:08:46.365Z","fee":0.00000705,"feeCurrency":"BTC"},{"gid":391,"id":239226090,"exchange":"binance","price":9220,"quantity":0.04039,"quoteQuantity":372.3958,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-05T02:58:02.292Z","fee":0.3723958,"feeCurrency":"USDT"},{"gid":392,"id":239226091,"exchange":"binance","price":9220,"quantity":0.05961,"quoteQuantity":549.6042,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-05T02:58:02.527Z","fee":0.5496042,"feeCurrency":"USDT"},{"gid":393,"id":239227503,"exchange":"binance","price":9230,"quantity":0.1,"quoteQuantity":923,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-05T03:00:23.89Z","fee":0.923,"feeCurrency":"USDT"},{"gid":394,"id":239232023,"exchange":"binance","price":9220.03,"quantity":0.058401,"quoteQuantity":538.45897203,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-05T03:15:24.562Z","fee":0.53845897,"feeCurrency":"USDT"},{"gid":395,"id":239232024,"exchange":"binance","price":9219.99,"quantity":0.041599,"quoteQuantity":383.54236401,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-05T03:15:24.562Z","fee":0.38354236,"feeCurrency":"USDT"},{"gid":396,"id":239236708,"exchange":"binance","price":9225,"quantity":0.073268,"quoteQuantity":675.8973,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-05T03:39:07.921Z","fee":0.00007327,"feeCurrency":"BTC"},{"gid":397,"id":239236709,"exchange":"binance","price":9225,"quantity":0.001165,"quoteQuantity":10.747125,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-05T03:39:07.921Z","fee":0.00000117,"feeCurrency":"BTC"},{"gid":398,"id":239236710,"exchange":"binance","price":9225,"quantity":0.036628,"quoteQuantity":337.8933,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-05T03:39:07.923Z","fee":0.00003663,"feeCurrency":"BTC"},{"gid":399,"id":239237109,"exchange":"binance","price":9216.81,"quantity":0.1,"quoteQuantity":921.681,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-05T03:40:40.125Z","fee":0.921681,"feeCurrency":"USDT"},{"gid":400,"id":239237660,"exchange":"binance","price":9218.01,"quantity":0.060416,"quoteQuantity":556.91529216,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-05T03:43:33.402Z","fee":0.00006042,"feeCurrency":"BTC"},{"gid":401,"id":239237661,"exchange":"binance","price":9218.02,"quantity":0.039584,"quoteQuantity":364.88610368,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-05T03:43:33.402Z","fee":0.00003958,"feeCurrency":"BTC"},{"gid":402,"id":239248088,"exchange":"binance","price":9222.14,"quantity":0.000192,"quoteQuantity":1.77065088,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-05T04:21:17.373Z","fee":0.00177065,"feeCurrency":"USDT"},{"gid":403,"id":239248089,"exchange":"binance","price":9222.14,"quantity":0.199808,"quoteQuantity":1842.65734912,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-05T04:21:17.373Z","fee":1.84265735,"feeCurrency":"USDT"},{"gid":404,"id":239257772,"exchange":"binance","price":9230,"quantity":0.1,"quoteQuantity":923,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-05T04:54:13.824Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":405,"id":239264332,"exchange":"binance","price":9253.79,"quantity":0.060182,"quoteQuantity":556.91158978,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-05T05:06:08.661Z","fee":0.55691159,"feeCurrency":"USDT"},{"gid":406,"id":239264333,"exchange":"binance","price":9253.75,"quantity":0.255894,"quoteQuantity":2367.9791025,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-05T05:06:08.661Z","fee":2.3679791,"feeCurrency":"USDT"},{"gid":407,"id":239265095,"exchange":"binance","price":9260.53,"quantity":0.1,"quoteQuantity":926.053,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-05T05:07:42.232Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":408,"id":239268556,"exchange":"binance","price":9240.01,"quantity":0.1,"quoteQuantity":924.001,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-05T05:18:09.128Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":409,"id":239268856,"exchange":"binance","price":9241,"quantity":0.099959,"quoteQuantity":923.721119,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-05T05:18:56.029Z","fee":0.00009996,"feeCurrency":"BTC"},{"gid":410,"id":239268865,"exchange":"binance","price":9241,"quantity":0.000041,"quoteQuantity":0.378881,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-05T05:18:57.608Z","fee":4e-8,"feeCurrency":"BTC"},{"gid":411,"id":239268922,"exchange":"binance","price":9240.01,"quantity":0.014956,"quoteQuantity":138.19358956,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-05T05:19:05.92Z","fee":0.00001496,"feeCurrency":"BTC"},{"gid":412,"id":239268923,"exchange":"binance","price":9240.01,"quantity":0.073364,"quoteQuantity":677.88409364,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-05T05:19:05.937Z","fee":0.00007336,"feeCurrency":"BTC"},{"gid":413,"id":239268924,"exchange":"binance","price":9240.01,"quantity":0.00123,"quoteQuantity":11.3652123,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-05T05:19:05.96Z","fee":0.00000123,"feeCurrency":"BTC"},{"gid":414,"id":239268925,"exchange":"binance","price":9240.01,"quantity":0.01045,"quoteQuantity":96.5581045,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-05T05:19:05.96Z","fee":0.00001045,"feeCurrency":"BTC"},{"gid":415,"id":239275722,"exchange":"binance","price":9260,"quantity":0.1,"quoteQuantity":926,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-05T05:40:04.209Z","fee":0.926,"feeCurrency":"USDT"},{"gid":416,"id":239279587,"exchange":"binance","price":9278.69,"quantity":0.1,"quoteQuantity":927.869,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-05T05:45:34.579Z","fee":0.927869,"feeCurrency":"USDT"},{"gid":417,"id":239280642,"exchange":"binance","price":9283.66,"quantity":0.1,"quoteQuantity":928.366,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-05T05:47:07.803Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":418,"id":239280862,"exchange":"binance","price":9280,"quantity":0.004396,"quoteQuantity":40.79488,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-05T05:47:35.243Z","fee":0.0000044,"feeCurrency":"BTC"},{"gid":419,"id":239280875,"exchange":"binance","price":9280,"quantity":0.095604,"quoteQuantity":887.20512,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-05T05:47:36.279Z","fee":0.0000956,"feeCurrency":"BTC"},{"gid":420,"id":239320031,"exchange":"binance","price":9272,"quantity":0.1,"quoteQuantity":927.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-05T07:36:44.064Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":421,"id":239330689,"exchange":"binance","price":9251,"quantity":0.027291,"quoteQuantity":252.469041,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-05T08:07:47.213Z","fee":0.00002729,"feeCurrency":"BTC"},{"gid":422,"id":239330690,"exchange":"binance","price":9251,"quantity":0.072709,"quoteQuantity":672.630959,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-05T08:07:47.22Z","fee":0.00007271,"feeCurrency":"BTC"},{"gid":423,"id":239336714,"exchange":"binance","price":9241,"quantity":0.1,"quoteQuantity":924.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-05T08:24:01.604Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":424,"id":239426549,"exchange":"binance","price":9373.51,"quantity":0.059411,"quoteQuantity":556.88960261,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-05T11:04:14.183Z","fee":0.5568896,"feeCurrency":"USDT"},{"gid":425,"id":239426550,"exchange":"binance","price":9373.47,"quantity":0.040589,"quoteQuantity":380.45977383,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-05T11:04:14.183Z","fee":0.38045977,"feeCurrency":"USDT"},{"gid":426,"id":239427836,"exchange":"binance","price":9381,"quantity":0.1,"quoteQuantity":938.1,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-05T11:06:19.372Z","fee":0.9381,"feeCurrency":"USDT"},{"gid":427,"id":239428161,"exchange":"binance","price":9391,"quantity":0.0029,"quoteQuantity":27.2339,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-05T11:07:02.452Z","fee":0.0272339,"feeCurrency":"USDT"},{"gid":428,"id":239428168,"exchange":"binance","price":9391,"quantity":0.001842,"quoteQuantity":17.298222,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-05T11:07:03.572Z","fee":0.01729822,"feeCurrency":"USDT"},{"gid":429,"id":239428171,"exchange":"binance","price":9391,"quantity":0.015605,"quoteQuantity":146.546555,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-05T11:07:03.921Z","fee":0.14654656,"feeCurrency":"USDT"},{"gid":430,"id":239428172,"exchange":"binance","price":9391,"quantity":0.014111,"quoteQuantity":132.516401,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-05T11:07:03.972Z","fee":0.1325164,"feeCurrency":"USDT"},{"gid":431,"id":239428174,"exchange":"binance","price":9391,"quantity":0.01019,"quoteQuantity":95.69429,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-05T11:07:05.172Z","fee":0.09569429,"feeCurrency":"USDT"},{"gid":432,"id":239428176,"exchange":"binance","price":9391,"quantity":0.0027,"quoteQuantity":25.3557,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-05T11:07:05.452Z","fee":0.0253557,"feeCurrency":"USDT"},{"gid":433,"id":239428178,"exchange":"binance","price":9391,"quantity":0.052652,"quoteQuantity":494.454932,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-05T11:07:06.404Z","fee":0.49445493,"feeCurrency":"USDT"},{"gid":434,"id":239429147,"exchange":"binance","price":9404.54,"quantity":0.1,"quoteQuantity":940.454,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-05T11:08:39.194Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":435,"id":239429231,"exchange":"binance","price":9404.9,"quantity":0.1,"quoteQuantity":940.49,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-05T11:08:50.408Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":436,"id":239441666,"exchange":"binance","price":9381.82,"quantity":0.006898,"quoteQuantity":64.71579436,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-05T11:29:11.252Z","fee":0.0000069,"feeCurrency":"BTC"},{"gid":437,"id":239441667,"exchange":"binance","price":9381.86,"quantity":0.093102,"quoteQuantity":873.46992972,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-05T11:29:11.252Z","fee":0.0000931,"feeCurrency":"BTC"},{"gid":438,"id":239521634,"exchange":"binance","price":9429.69,"quantity":0.030302,"quoteQuantity":285.73846638,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-05T13:43:22.795Z","fee":0.0000303,"feeCurrency":"BTC"},{"gid":439,"id":239521641,"exchange":"binance","price":9429.69,"quantity":0.000016,"quoteQuantity":0.15087504,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-05T13:43:24.281Z","fee":2e-8,"feeCurrency":"BTC"},{"gid":440,"id":239543300,"exchange":"binance","price":9423.89,"quantity":0.1,"quoteQuantity":942.389,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-05T14:27:01.639Z","fee":0.942389,"feeCurrency":"USDT"},{"gid":441,"id":239543824,"exchange":"binance","price":9420,"quantity":0.1,"quoteQuantity":942,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-05T14:28:30.143Z","fee":0.942,"feeCurrency":"USDT"},{"gid":442,"id":239544119,"exchange":"binance","price":9420,"quantity":0.010615,"quoteQuantity":99.9933,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-05T14:29:19.441Z","fee":0.0999933,"feeCurrency":"USDT"},{"gid":443,"id":239544120,"exchange":"binance","price":9420,"quantity":0.042461,"quoteQuantity":399.98262,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-05T14:29:19.464Z","fee":0.39998262,"feeCurrency":"USDT"},{"gid":444,"id":239544121,"exchange":"binance","price":9420,"quantity":0.046924,"quoteQuantity":442.02408,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-05T14:29:19.547Z","fee":0.44202408,"feeCurrency":"USDT"},{"gid":445,"id":239549152,"exchange":"binance","price":9435.2,"quantity":0.015127,"quoteQuantity":142.7262704,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-05T14:43:37.745Z","fee":0.14272627,"feeCurrency":"USDT"},{"gid":446,"id":239549153,"exchange":"binance","price":9435.2,"quantity":0.02,"quoteQuantity":188.704,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-05T14:43:37.977Z","fee":0.188704,"feeCurrency":"USDT"},{"gid":447,"id":239549154,"exchange":"binance","price":9435.2,"quantity":0.003592,"quoteQuantity":33.8912384,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-05T14:43:38.195Z","fee":0.03389124,"feeCurrency":"USDT"},{"gid":448,"id":239549155,"exchange":"binance","price":9435.2,"quantity":0.061281,"quoteQuantity":578.1984912,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-05T14:43:38.228Z","fee":0.57819849,"feeCurrency":"USDT"},{"gid":449,"id":239560411,"exchange":"binance","price":9450,"quantity":0.004062,"quoteQuantity":38.3859,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-05T15:15:45.372Z","fee":0.0383859,"feeCurrency":"USDT"},{"gid":450,"id":239560413,"exchange":"binance","price":9450,"quantity":0.125026,"quoteQuantity":1181.4957,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-05T15:15:45.502Z","fee":1.1814957,"feeCurrency":"USDT"},{"gid":451,"id":239560934,"exchange":"binance","price":9450.39,"quantity":0.1,"quoteQuantity":945.039,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-05T15:17:00.961Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":452,"id":239601424,"exchange":"binance","price":9554.61,"quantity":0.2999,"quoteQuantity":2865.427539,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-05T16:23:25.863Z","fee":2.86542754,"feeCurrency":"USDT"},{"gid":453,"id":239607533,"exchange":"binance","price":9531,"quantity":0.1,"quoteQuantity":953.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-05T16:33:10.573Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":454,"id":239607922,"exchange":"binance","price":9521,"quantity":0.1,"quoteQuantity":952.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-05T16:33:44.935Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":455,"id":239608222,"exchange":"binance","price":9511,"quantity":0.1,"quoteQuantity":951.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-05T16:34:01.717Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":456,"id":239608347,"exchange":"binance","price":9501,"quantity":0.1,"quoteQuantity":950.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-05T16:34:02.421Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":457,"id":239666795,"exchange":"binance","price":9650,"quantity":0.3996,"quoteQuantity":3856.14,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-05T18:18:31.153Z","fee":3.85614,"feeCurrency":"USDT"},{"gid":458,"id":239672606,"exchange":"binance","price":9686,"quantity":0.008132,"quoteQuantity":78.766552,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-05T18:22:03.952Z","fee":0.00000813,"feeCurrency":"BTC"},{"gid":459,"id":239672607,"exchange":"binance","price":9686,"quantity":0.050345,"quoteQuantity":487.64167,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-05T18:22:03.956Z","fee":0.00005035,"feeCurrency":"BTC"},{"gid":460,"id":239672610,"exchange":"binance","price":9686,"quantity":0.005567,"quoteQuantity":53.921962,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-05T18:22:04.028Z","fee":0.00000557,"feeCurrency":"BTC"},{"gid":461,"id":239672611,"exchange":"binance","price":9686,"quantity":0.035956,"quoteQuantity":348.269816,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-05T18:22:04.151Z","fee":0.00003596,"feeCurrency":"BTC"},{"gid":462,"id":239672939,"exchange":"binance","price":9691.54,"quantity":0.015791,"quoteQuantity":153.03910814,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-05T18:22:11.26Z","fee":0.00001579,"feeCurrency":"BTC"},{"gid":463,"id":239672940,"exchange":"binance","price":9691.58,"quantity":0.018418,"quoteQuantity":178.49952044,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-05T18:22:11.26Z","fee":0.00001842,"feeCurrency":"BTC"},{"gid":464,"id":239672941,"exchange":"binance","price":9691.59,"quantity":0.065791,"quoteQuantity":637.61939769,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-05T18:22:11.26Z","fee":0.00006579,"feeCurrency":"BTC"},{"gid":465,"id":239677776,"exchange":"binance","price":9721.94,"quantity":0.1062,"quoteQuantity":1032.470028,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-05T18:23:32.491Z","fee":0.0001062,"feeCurrency":"BTC"},{"gid":466,"id":239677777,"exchange":"binance","price":9721.99,"quantity":0.051262,"quoteQuantity":498.36865138,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-05T18:23:32.491Z","fee":0.00005126,"feeCurrency":"BTC"},{"gid":467,"id":239677778,"exchange":"binance","price":9724.71,"quantity":0.042538,"quoteQuantity":413.66971398,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-05T18:23:32.491Z","fee":0.00004254,"feeCurrency":"BTC"},{"gid":468,"id":239678169,"exchange":"binance","price":9725.22,"quantity":0.044952,"quoteQuantity":437.16808944,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-05T18:23:40.582Z","fee":0.00004495,"feeCurrency":"BTC"},{"gid":469,"id":239678170,"exchange":"binance","price":9725.22,"quantity":0.00826,"quoteQuantity":80.3303172,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-05T18:23:40.582Z","fee":0.00000826,"feeCurrency":"BTC"},{"gid":470,"id":239678171,"exchange":"binance","price":9726.88,"quantity":0.014,"quoteQuantity":136.17632,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-05T18:23:40.582Z","fee":0.000014,"feeCurrency":"BTC"},{"gid":471,"id":239678172,"exchange":"binance","price":9726.89,"quantity":0.008567,"quoteQuantity":83.33026663,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-05T18:23:40.582Z","fee":0.00000857,"feeCurrency":"BTC"},{"gid":472,"id":239678173,"exchange":"binance","price":9729.46,"quantity":0.024221,"quoteQuantity":235.65725066,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-05T18:23:40.582Z","fee":0.00002422,"feeCurrency":"BTC"},{"gid":473,"id":239682857,"exchange":"binance","price":9668.89,"quantity":0.3,"quoteQuantity":2900.667,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-05T18:25:21.291Z","fee":2.900667,"feeCurrency":"USDT"},{"gid":474,"id":239682858,"exchange":"binance","price":9668.88,"quantity":0.139322,"quoteQuantity":1347.08769936,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-05T18:25:21.291Z","fee":1.3470877,"feeCurrency":"USDT"},{"gid":475,"id":239682859,"exchange":"binance","price":9668.57,"quantity":0.060178,"quoteQuantity":581.83520546,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-05T18:25:21.291Z","fee":0.58183521,"feeCurrency":"USDT"},{"gid":476,"id":239688294,"exchange":"binance","price":9657.13,"quantity":0.1,"quoteQuantity":965.713,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-05T18:28:15.567Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":477,"id":239690767,"exchange":"binance","price":9600.01,"quantity":0.0999,"quoteQuantity":959.040999,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-05T18:29:32.334Z","fee":0.959041,"feeCurrency":"USDT"},{"gid":478,"id":239875234,"exchange":"binance","price":9606.11,"quantity":0.085589,"quoteQuantity":822.17734879,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-06T01:45:19.921Z","fee":0.00008559,"feeCurrency":"BTC"},{"gid":479,"id":239875235,"exchange":"binance","price":9606.12,"quantity":0.014411,"quoteQuantity":138.43379532,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-06T01:45:19.921Z","fee":0.00001441,"feeCurrency":"BTC"},{"gid":480,"id":239875379,"exchange":"binance","price":9611.22,"quantity":0.057249,"quoteQuantity":550.23273378,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-06T01:45:31.817Z","fee":0.00005725,"feeCurrency":"BTC"},{"gid":481,"id":239875380,"exchange":"binance","price":9611.23,"quantity":0.042751,"quoteQuantity":410.88969373,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-06T01:45:31.817Z","fee":0.00004275,"feeCurrency":"BTC"},{"gid":482,"id":239875715,"exchange":"binance","price":9614.35,"quantity":0.1,"quoteQuantity":961.435,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-06T01:45:54.457Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":483,"id":239877849,"exchange":"binance","price":9611,"quantity":0.010401,"quoteQuantity":99.964011,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-06T01:50:30.101Z","fee":0.0000104,"feeCurrency":"BTC"},{"gid":484,"id":239877850,"exchange":"binance","price":9611,"quantity":0.010406,"quoteQuantity":100.012066,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-06T01:50:30.106Z","fee":0.00001041,"feeCurrency":"BTC"},{"gid":485,"id":239877851,"exchange":"binance","price":9611,"quantity":0.079193,"quoteQuantity":761.123923,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-06T01:50:30.162Z","fee":0.00007919,"feeCurrency":"BTC"},{"gid":486,"id":239881879,"exchange":"binance","price":9605.54,"quantity":0.045819,"quoteQuantity":440.11623726,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-06T02:04:34.831Z","fee":0.00004582,"feeCurrency":"BTC"},{"gid":487,"id":239881880,"exchange":"binance","price":9605.54,"quantity":0.054181,"quoteQuantity":520.43776274,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-06T02:04:36.393Z","fee":0.00005418,"feeCurrency":"BTC"},{"gid":488,"id":239882068,"exchange":"binance","price":9608.78,"quantity":0.057263,"quoteQuantity":550.22756914,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-06T02:05:18.109Z","fee":0.00005726,"feeCurrency":"BTC"},{"gid":489,"id":239882069,"exchange":"binance","price":9608.82,"quantity":0.042737,"quoteQuantity":410.65214034,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-06T02:05:18.109Z","fee":0.00004274,"feeCurrency":"BTC"},{"gid":490,"id":239884822,"exchange":"binance","price":9600,"quantity":0.032784,"quoteQuantity":314.7264,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-06T02:17:10.314Z","fee":0.3147264,"feeCurrency":"USDT"},{"gid":491,"id":239884823,"exchange":"binance","price":9600,"quantity":0.011267,"quoteQuantity":108.1632,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-06T02:17:10.314Z","fee":0.1081632,"feeCurrency":"USDT"},{"gid":492,"id":239884824,"exchange":"binance","price":9600,"quantity":0.242115,"quoteQuantity":2324.304,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-06T02:17:10.314Z","fee":2.324304,"feeCurrency":"USDT"},{"gid":493,"id":239884825,"exchange":"binance","price":9600,"quantity":0.2,"quoteQuantity":1920,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-06T02:17:10.314Z","fee":1.92,"feeCurrency":"USDT"},{"gid":494,"id":239884826,"exchange":"binance","price":9600,"quantity":0.01,"quoteQuantity":96,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-06T02:17:10.314Z","fee":0.096,"feeCurrency":"USDT"},{"gid":495,"id":239884827,"exchange":"binance","price":9600,"quantity":0.01,"quoteQuantity":96,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-06T02:17:10.314Z","fee":0.096,"feeCurrency":"USDT"},{"gid":496,"id":239884828,"exchange":"binance","price":9600,"quantity":0.013021,"quoteQuantity":125.0016,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-06T02:17:10.314Z","fee":0.1250016,"feeCurrency":"USDT"},{"gid":497,"id":239884829,"exchange":"binance","price":9600,"quantity":0.0565,"quoteQuantity":542.4,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-06T02:17:10.314Z","fee":0.5424,"feeCurrency":"USDT"},{"gid":498,"id":239884830,"exchange":"binance","price":9600,"quantity":0.010073,"quoteQuantity":96.7008,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-06T02:17:10.314Z","fee":0.0967008,"feeCurrency":"USDT"},{"gid":499,"id":239884831,"exchange":"binance","price":9600,"quantity":0.01364,"quoteQuantity":130.944,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-06T02:17:10.314Z","fee":0.130944,"feeCurrency":"USDT"},{"gid":500,"id":239923595,"exchange":"binance","price":9641,"quantity":0.1,"quoteQuantity":964.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-06T04:25:06.103Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":501,"id":239928316,"exchange":"binance","price":9631,"quantity":0.042397,"quoteQuantity":408.325507,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-06T04:40:41.673Z","fee":0.0000424,"feeCurrency":"BTC"},{"gid":502,"id":239928317,"exchange":"binance","price":9631,"quantity":0.002109,"quoteQuantity":20.311779,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-06T04:40:42.109Z","fee":0.00000211,"feeCurrency":"BTC"},{"gid":503,"id":239928318,"exchange":"binance","price":9631,"quantity":0.042397,"quoteQuantity":408.325507,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-06T04:40:42.206Z","fee":0.0000424,"feeCurrency":"BTC"},{"gid":504,"id":239928319,"exchange":"binance","price":9631,"quantity":0.013097,"quoteQuantity":126.137207,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-06T04:40:42.621Z","fee":0.0000131,"feeCurrency":"BTC"},{"gid":505,"id":239928731,"exchange":"binance","price":9625,"quantity":0.079191,"quoteQuantity":762.213375,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-06T04:42:04.369Z","fee":0.00007919,"feeCurrency":"BTC"},{"gid":506,"id":239928736,"exchange":"binance","price":9625,"quantity":0.020765,"quoteQuantity":199.863125,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-06T04:42:06.157Z","fee":0.00002077,"feeCurrency":"BTC"},{"gid":507,"id":239928813,"exchange":"binance","price":9625,"quantity":0.000044,"quoteQuantity":0.4235,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-06T04:42:22.42Z","fee":4e-8,"feeCurrency":"BTC"},{"gid":508,"id":239954764,"exchange":"binance","price":9621.55,"quantity":0.057185,"quoteQuantity":550.20833675,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-06T06:06:04.751Z","fee":0.55020834,"feeCurrency":"USDT"},{"gid":509,"id":239954765,"exchange":"binance","price":9621.52,"quantity":0.057185,"quoteQuantity":550.2066212,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-06T06:06:04.751Z","fee":0.55020662,"feeCurrency":"USDT"},{"gid":510,"id":239954766,"exchange":"binance","price":9621.48,"quantity":0.18533,"quoteQuantity":1783.1488884,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-06T06:06:04.751Z","fee":1.78314889,"feeCurrency":"USDT"},{"gid":511,"id":240034722,"exchange":"binance","price":9640.69,"quantity":0.1,"quoteQuantity":964.069,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-06T09:05:40.649Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":512,"id":240034731,"exchange":"binance","price":9640.65,"quantity":0.1,"quoteQuantity":964.065,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-06T09:05:43.386Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":513,"id":240034872,"exchange":"binance","price":9639.09,"quantity":0.003896,"quoteQuantity":37.55389464,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-06T09:06:00.508Z","fee":0.0000039,"feeCurrency":"BTC"},{"gid":514,"id":240034873,"exchange":"binance","price":9639.12,"quantity":0.016854,"quoteQuantity":162.45772848,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-06T09:06:00.508Z","fee":0.00001685,"feeCurrency":"BTC"},{"gid":515,"id":240034874,"exchange":"binance","price":9639.87,"quantity":0.07925,"quoteQuantity":763.9596975,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-06T09:06:00.508Z","fee":0.00007925,"feeCurrency":"BTC"},{"gid":516,"id":240034900,"exchange":"binance","price":9639.07,"quantity":0.02075,"quoteQuantity":200.0107025,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-06T09:06:06.273Z","fee":0.00002075,"feeCurrency":"BTC"},{"gid":517,"id":240034901,"exchange":"binance","price":9639.07,"quantity":0.07925,"quoteQuantity":763.8962975,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-06T09:06:06.273Z","fee":0.00007925,"feeCurrency":"BTC"},{"gid":518,"id":240034905,"exchange":"binance","price":9639.07,"quantity":0.1,"quoteQuantity":963.907,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-06T09:06:08.031Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":519,"id":240034970,"exchange":"binance","price":9639.04,"quantity":0.1,"quoteQuantity":963.904,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-06T09:06:14.45Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":520,"id":240037066,"exchange":"binance","price":9642.42,"quantity":0.055598,"quoteQuantity":536.09926716,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-06T09:10:37.41Z","fee":0.53609927,"feeCurrency":"USDT"},{"gid":521,"id":240037067,"exchange":"binance","price":9642.38,"quantity":0.446771,"quoteQuantity":4307.93575498,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-06T09:10:37.41Z","fee":4.30793575,"feeCurrency":"USDT"},{"gid":522,"id":240037068,"exchange":"binance","price":9642.25,"quantity":0.035,"quoteQuantity":337.47875,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-06T09:10:37.41Z","fee":0.33747875,"feeCurrency":"USDT"},{"gid":523,"id":240037069,"exchange":"binance","price":9642,"quantity":0.062031,"quoteQuantity":598.102902,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-06T09:10:37.41Z","fee":0.5981029,"feeCurrency":"USDT"},{"gid":524,"id":241048665,"exchange":"binance","price":9731,"quantity":0.1,"quoteQuantity":973.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-07T21:01:49.133Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":525,"id":241149759,"exchange":"binance","price":9751,"quantity":0.005732,"quoteQuantity":55.892732,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-08T02:34:45.366Z","fee":0.00000573,"feeCurrency":"BTC"},{"gid":526,"id":241149760,"exchange":"binance","price":9751,"quantity":0.094268,"quoteQuantity":919.207268,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-08T02:34:45.366Z","fee":0.00009427,"feeCurrency":"BTC"},{"gid":527,"id":241159562,"exchange":"binance","price":9721,"quantity":0.1,"quoteQuantity":972.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-08T02:53:44.095Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":528,"id":241175899,"exchange":"binance","price":9681.48,"quantity":0.029975,"quoteQuantity":290.202363,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-08T03:10:55.053Z","fee":0.00002998,"feeCurrency":"BTC"},{"gid":529,"id":241175900,"exchange":"binance","price":9681.48,"quantity":0.070025,"quoteQuantity":677.945637,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-08T03:10:55.053Z","fee":0.00007003,"feeCurrency":"BTC"},{"gid":530,"id":241176318,"exchange":"binance","price":9675.17,"quantity":0.1,"quoteQuantity":967.517,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-08T03:11:37.298Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":531,"id":241178177,"exchange":"binance","price":9669.89,"quantity":0.097038,"quoteQuantity":938.34678582,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-08T03:13:32.075Z","fee":0.93834679,"feeCurrency":"USDT"},{"gid":532,"id":241178182,"exchange":"binance","price":9669.89,"quantity":0.002962,"quoteQuantity":28.64221418,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-08T03:13:32.309Z","fee":0.02864221,"feeCurrency":"USDT"},{"gid":533,"id":241186405,"exchange":"binance","price":9709.41,"quantity":0.001992,"quoteQuantity":19.34114472,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-08T03:28:25.976Z","fee":0.00000199,"feeCurrency":"BTC"},{"gid":534,"id":241186406,"exchange":"binance","price":9709.41,"quantity":0.001992,"quoteQuantity":19.34114472,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-08T03:28:26.075Z","fee":0.00000199,"feeCurrency":"BTC"},{"gid":535,"id":241186410,"exchange":"binance","price":9709.41,"quantity":0.096016,"quoteQuantity":932.25871056,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-08T03:28:26.406Z","fee":0.00009602,"feeCurrency":"BTC"},{"gid":536,"id":241206420,"exchange":"binance","price":9740.28,"quantity":0.1,"quoteQuantity":974.028,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-08T04:36:53.969Z","fee":0.974028,"feeCurrency":"USDT"},{"gid":537,"id":241206806,"exchange":"binance","price":9749,"quantity":0.1,"quoteQuantity":974.9,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-08T04:37:47.01Z","fee":0.9749,"feeCurrency":"USDT"},{"gid":538,"id":241207829,"exchange":"binance","price":9753.93,"quantity":0.1,"quoteQuantity":975.393,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-08T04:39:25.876Z","fee":0.975393,"feeCurrency":"USDT"},{"gid":539,"id":241242212,"exchange":"binance","price":9767.41,"quantity":0.001403,"quoteQuantity":13.70367623,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-08T06:36:21.463Z","fee":0.0000014,"feeCurrency":"BTC"},{"gid":540,"id":241242213,"exchange":"binance","price":9767.42,"quantity":0.098597,"quoteQuantity":963.03830974,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-08T06:36:21.463Z","fee":0.0000986,"feeCurrency":"BTC"},{"gid":541,"id":241273835,"exchange":"binance","price":9780.51,"quantity":0.015217,"quoteQuantity":148.83002067,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-08T08:20:47.537Z","fee":0.00001522,"feeCurrency":"BTC"},{"gid":542,"id":241273836,"exchange":"binance","price":9780.86,"quantity":0.000932,"quoteQuantity":9.11576152,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-08T08:20:47.537Z","fee":9.3e-7,"feeCurrency":"BTC"},{"gid":543,"id":241273837,"exchange":"binance","price":9780.86,"quantity":0.028469,"quoteQuantity":278.45130334,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-08T08:20:47.638Z","fee":0.00002847,"feeCurrency":"BTC"},{"gid":544,"id":241273838,"exchange":"binance","price":9780.86,"quantity":0.010048,"quoteQuantity":98.27808128,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-08T08:20:47.727Z","fee":0.00001005,"feeCurrency":"BTC"},{"gid":545,"id":241273839,"exchange":"binance","price":9780.86,"quantity":0.045334,"quoteQuantity":443.40550724,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-08T08:20:47.758Z","fee":0.00004533,"feeCurrency":"BTC"},{"gid":546,"id":241276768,"exchange":"binance","price":9802.07,"quantity":0.05969,"quoteQuantity":585.0855583,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-08T08:27:56.482Z","fee":0.58508556,"feeCurrency":"USDT"},{"gid":547,"id":241276769,"exchange":"binance","price":9802.06,"quantity":0.04031,"quoteQuantity":395.1210386,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-08T08:27:56.482Z","fee":0.39512104,"feeCurrency":"USDT"},{"gid":548,"id":241278530,"exchange":"binance","price":9795.78,"quantity":0.023357,"quoteQuantity":228.80003346,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-08T08:31:44.093Z","fee":0.22880003,"feeCurrency":"USDT"},{"gid":549,"id":241278532,"exchange":"binance","price":9795.78,"quantity":0.076643,"quoteQuantity":750.77796654,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-08T08:31:44.224Z","fee":0.75077797,"feeCurrency":"USDT"},{"gid":550,"id":241314911,"exchange":"binance","price":9840,"quantity":0.1,"quoteQuantity":984,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-08T10:25:07.372Z","fee":0.984,"feeCurrency":"USDT"},{"gid":551,"id":241317449,"exchange":"binance","price":9821,"quantity":0.1,"quoteQuantity":982.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-08T10:30:40.778Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":552,"id":241336491,"exchange":"binance","price":9812.39,"quantity":0.057409,"quoteQuantity":563.31949751,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-08T11:35:37.48Z","fee":0.00005741,"feeCurrency":"BTC"},{"gid":553,"id":241336492,"exchange":"binance","price":9812.45,"quantity":0.042591,"quoteQuantity":417.92205795,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-08T11:35:37.48Z","fee":0.00004259,"feeCurrency":"BTC"},{"gid":554,"id":241340648,"exchange":"binance","price":9806.29,"quantity":0.1,"quoteQuantity":980.629,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-08T11:52:28.596Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":555,"id":241380053,"exchange":"binance","price":9785,"quantity":0.1,"quoteQuantity":978.5,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-08T13:44:51.971Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":556,"id":241386377,"exchange":"binance","price":9799.65,"quantity":0.000041,"quoteQuantity":0.40178565,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-08T13:49:18.465Z","fee":4e-8,"feeCurrency":"BTC"},{"gid":557,"id":241386378,"exchange":"binance","price":9799.99,"quantity":0.07778,"quoteQuantity":762.2432222,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-08T13:49:18.465Z","fee":0.00007778,"feeCurrency":"BTC"},{"gid":558,"id":241386379,"exchange":"binance","price":9800,"quantity":0.0051,"quoteQuantity":49.98,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-08T13:49:18.465Z","fee":0.0000051,"feeCurrency":"BTC"},{"gid":559,"id":241386380,"exchange":"binance","price":9800,"quantity":0.01007,"quoteQuantity":98.686,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-08T13:49:18.465Z","fee":0.00001007,"feeCurrency":"BTC"},{"gid":560,"id":241386381,"exchange":"binance","price":9800,"quantity":0.007009,"quoteQuantity":68.6882,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-08T13:49:18.465Z","fee":0.00000701,"feeCurrency":"BTC"},{"gid":561,"id":241388680,"exchange":"binance","price":9783.1,"quantity":0.040557,"quoteQuantity":396.7731867,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-08T13:53:09.186Z","fee":0.00004056,"feeCurrency":"BTC"},{"gid":562,"id":241388681,"exchange":"binance","price":9783.11,"quantity":0.024937,"quoteQuantity":243.96141407,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-08T13:53:09.186Z","fee":0.00002494,"feeCurrency":"BTC"},{"gid":563,"id":241406790,"exchange":"binance","price":9820,"quantity":0.1,"quoteQuantity":982,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-08T14:43:41.368Z","fee":0.982,"feeCurrency":"USDT"},{"gid":564,"id":241739960,"exchange":"binance","price":10068.08,"quantity":0.058488,"quoteQuantity":588.86186304,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-09T05:15:38.402Z","fee":0.58886186,"feeCurrency":"USDT"},{"gid":565,"id":241739961,"exchange":"binance","price":10068.04,"quantity":0.041512,"quoteQuantity":417.94447648,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-09T05:15:38.402Z","fee":0.41794448,"feeCurrency":"USDT"},{"gid":566,"id":241740037,"exchange":"binance","price":10069.66,"quantity":0.0576,"quoteQuantity":580.012416,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-09T05:15:49.13Z","fee":0.58001242,"feeCurrency":"USDT"},{"gid":567,"id":241740038,"exchange":"binance","price":10069.63,"quantity":0.0424,"quoteQuantity":426.952312,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-09T05:15:49.13Z","fee":0.42695231,"feeCurrency":"USDT"},{"gid":568,"id":241740224,"exchange":"binance","price":10070,"quantity":0.1,"quoteQuantity":1007,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-09T05:16:15.105Z","fee":1.007,"feeCurrency":"USDT"},{"gid":569,"id":241740851,"exchange":"binance","price":10080.79,"quantity":0.019832,"quoteQuantity":199.92222728,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-09T05:17:49.932Z","fee":0.19992223,"feeCurrency":"USDT"},{"gid":570,"id":241740853,"exchange":"binance","price":10080.79,"quantity":0.040168,"quoteQuantity":404.92517272,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-09T05:17:50.018Z","fee":0.40492517,"feeCurrency":"USDT"},{"gid":571,"id":241748543,"exchange":"binance","price":10077.88,"quantity":0.08294,"quoteQuantity":835.8593672,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-09T05:36:27.457Z","fee":0.83585937,"feeCurrency":"USDT"},{"gid":572,"id":241748558,"exchange":"binance","price":10077.7,"quantity":0.01706,"quoteQuantity":171.925562,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-09T05:36:30.809Z","fee":0.17192556,"feeCurrency":"USDT"},{"gid":573,"id":241753076,"exchange":"binance","price":10100,"quantity":0.1,"quoteQuantity":1010,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-09T05:50:40.591Z","fee":1.01,"feeCurrency":"USDT"},{"gid":574,"id":241766702,"exchange":"binance","price":10121.88,"quantity":0.1,"quoteQuantity":1012.188,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-09T06:22:10.074Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":575,"id":241767444,"exchange":"binance","price":10117.08,"quantity":0.104028,"quoteQuantity":1052.45959824,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-09T06:23:30.933Z","fee":1.0524596,"feeCurrency":"USDT"},{"gid":576,"id":241768973,"exchange":"binance","price":10111.34,"quantity":0.1,"quoteQuantity":1011.134,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-09T06:26:53.074Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":577,"id":241769318,"exchange":"binance","price":10101,"quantity":0.1,"quoteQuantity":1010.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-09T06:27:44.322Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":578,"id":241849505,"exchange":"binance","price":10114,"quantity":0.1,"quoteQuantity":1011.4,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-09T09:14:36.892Z","fee":1.0114,"feeCurrency":"USDT"},{"gid":579,"id":241852602,"exchange":"binance","price":10131.1,"quantity":0.003947,"quoteQuantity":39.9874517,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-09T09:22:02.087Z","fee":0.03998745,"feeCurrency":"USDT"},{"gid":580,"id":241852605,"exchange":"binance","price":10131.1,"quantity":0.004442,"quoteQuantity":45.0023462,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-09T09:22:03.305Z","fee":0.04500235,"feeCurrency":"USDT"},{"gid":581,"id":241852607,"exchange":"binance","price":10131.1,"quantity":0.004546,"quoteQuantity":46.0559806,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-09T09:22:03.972Z","fee":0.04605598,"feeCurrency":"USDT"},{"gid":582,"id":241852608,"exchange":"binance","price":10131.1,"quantity":0.086865,"quoteQuantity":880.0380015,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-09T09:22:04.155Z","fee":0.880038,"feeCurrency":"USDT"},{"gid":583,"id":241971869,"exchange":"binance","price":10081,"quantity":0.1,"quoteQuantity":1008.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-09T14:53:52.405Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":584,"id":242022502,"exchange":"binance","price":10103.41,"quantity":0.0999,"quoteQuantity":1009.330659,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-09T16:25:55.285Z","fee":1.00933066,"feeCurrency":"USDT"},{"gid":585,"id":242677292,"exchange":"binance","price":9888.76,"quantity":0.001215,"quoteQuantity":12.0148434,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-10T15:50:23.801Z","fee":0.00000122,"feeCurrency":"BTC"},{"gid":586,"id":242677293,"exchange":"binance","price":9889.67,"quantity":0.008122,"quoteQuantity":80.32389974,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-10T15:50:23.872Z","fee":0.00000812,"feeCurrency":"BTC"},{"gid":587,"id":242677296,"exchange":"binance","price":9889.67,"quantity":0.040663,"quoteQuantity":402.14365121,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-10T15:50:24.112Z","fee":0.00004066,"feeCurrency":"BTC"},{"gid":588,"id":242885835,"exchange":"binance","price":9780,"quantity":0.002045,"quoteQuantity":20.0001,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-11T01:34:14.94Z","fee":0.0200001,"feeCurrency":"USDT"},{"gid":589,"id":242885836,"exchange":"binance","price":9780,"quantity":0.047905,"quoteQuantity":468.5109,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-11T01:34:14.94Z","fee":0.4685109,"feeCurrency":"USDT"},{"gid":590,"id":244520217,"exchange":"binance","price":10391.49,"quantity":0.1,"quoteQuantity":1039.149,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-13T03:46:30.659Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":591,"id":244587056,"exchange":"binance","price":10444.25,"quantity":0.059062,"quoteQuantity":616.8582935,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-13T06:13:59.666Z","fee":0.00005906,"feeCurrency":"BTC"},{"gid":592,"id":244587057,"exchange":"binance","price":10444.26,"quantity":0.040938,"quoteQuantity":427.56711588,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-13T06:13:59.666Z","fee":0.00004094,"feeCurrency":"BTC"},{"gid":593,"id":244628205,"exchange":"binance","price":10421,"quantity":0.1,"quoteQuantity":1042.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-13T07:16:33.135Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":594,"id":244642493,"exchange":"binance","price":10418.87,"quantity":0.042606,"quoteQuantity":443.90637522,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-13T07:33:38.254Z","fee":0.00004261,"feeCurrency":"BTC"},{"gid":595,"id":244642494,"exchange":"binance","price":10418.87,"quantity":0.057394,"quoteQuantity":597.98062478,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-13T07:33:38.438Z","fee":0.00005739,"feeCurrency":"BTC"},{"gid":596,"id":244671592,"exchange":"binance","price":10348.64,"quantity":0.059607,"quoteQuantity":616.85138448,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-13T08:06:32.008Z","fee":0.61685138,"feeCurrency":"USDT"},{"gid":597,"id":244671593,"exchange":"binance","price":10348.6,"quantity":0.013385,"quoteQuantity":138.516011,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-13T08:06:32.008Z","fee":0.13851601,"feeCurrency":"USDT"},{"gid":598,"id":244671594,"exchange":"binance","price":10348.53,"quantity":0.326608,"quoteQuantity":3379.91268624,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-13T08:06:32.008Z","fee":3.37991269,"feeCurrency":"USDT"},{"gid":599,"id":244772455,"exchange":"binance","price":10191,"quantity":0.1,"quoteQuantity":1019.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-13T09:20:50.351Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":600,"id":244777051,"exchange":"binance","price":10181,"quantity":0.1,"quoteQuantity":1018.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-13T09:25:14.597Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":601,"id":244796709,"exchange":"binance","price":10188.39,"quantity":0.005663,"quoteQuantity":57.69685257,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-13T09:41:03.489Z","fee":0.05769685,"feeCurrency":"USDT"},{"gid":602,"id":244796710,"exchange":"binance","price":10188.38,"quantity":0.000002,"quoteQuantity":0.02037676,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-13T09:41:03.489Z","fee":0.00002038,"feeCurrency":"USDT"},{"gid":603,"id":244796711,"exchange":"binance","price":10188.33,"quantity":0.194135,"quoteQuantity":1977.91144455,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-13T09:41:03.489Z","fee":1.97791144,"feeCurrency":"USDT"},{"gid":604,"id":248407139,"exchange":"binance","price":9702.97,"quantity":0.003486,"quoteQuantity":33.82455342,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-18T09:37:37.643Z","fee":0.00000349,"feeCurrency":"BTC"},{"gid":605,"id":248407140,"exchange":"binance","price":9702.97,"quantity":0.041222,"quoteQuantity":399.97582934,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-18T09:37:37.748Z","fee":0.00004122,"feeCurrency":"BTC"},{"gid":606,"id":248407142,"exchange":"binance","price":9702.97,"quantity":0.0015,"quoteQuantity":14.554455,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-18T09:37:37.946Z","fee":0.0000015,"feeCurrency":"BTC"},{"gid":607,"id":248407143,"exchange":"binance","price":9702.97,"quantity":0.053792,"quoteQuantity":521.94216224,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-18T09:37:37.957Z","fee":0.00005379,"feeCurrency":"BTC"},{"gid":608,"id":248417309,"exchange":"binance","price":9682,"quantity":0.1,"quoteQuantity":968.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-18T10:04:05.825Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":609,"id":248505012,"exchange":"binance","price":9581,"quantity":0.1,"quoteQuantity":958.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-18T13:25:34.805Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":610,"id":248506356,"exchange":"binance","price":9598.12,"quantity":0.085956,"quoteQuantity":825.01600272,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-18T13:26:50.565Z","fee":0.00008596,"feeCurrency":"BTC"},{"gid":611,"id":248506357,"exchange":"binance","price":9598.13,"quantity":0.014044,"quoteQuantity":134.79613772,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-18T13:26:50.565Z","fee":0.00001404,"feeCurrency":"BTC"},{"gid":612,"id":248586595,"exchange":"binance","price":9810.6,"quantity":0.091208,"quoteQuantity":894.8052048,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-18T15:43:06.536Z","fee":0.8948052,"feeCurrency":"USDT"},{"gid":613,"id":248586596,"exchange":"binance","price":9810.56,"quantity":0.008792,"quoteQuantity":86.25444352,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-18T15:43:06.536Z","fee":0.08625444,"feeCurrency":"USDT"},{"gid":614,"id":248610070,"exchange":"binance","price":9870,"quantity":0.1,"quoteQuantity":987,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-18T16:10:00.449Z","fee":0.987,"feeCurrency":"USDT"},{"gid":615,"id":248638228,"exchange":"binance","price":9912,"quantity":0.19,"quoteQuantity":1883.28,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-18T16:49:00.648Z","fee":1.88328,"feeCurrency":"USDT"},{"gid":616,"id":249247039,"exchange":"binance","price":10090,"quantity":0.008502,"quoteQuantity":85.78518,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-19T20:37:37.101Z","fee":0.0000085,"feeCurrency":"BTC"},{"gid":617,"id":249247040,"exchange":"binance","price":10090,"quantity":0.091498,"quoteQuantity":923.21482,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-19T20:37:37.101Z","fee":0.0000915,"feeCurrency":"BTC"},{"gid":618,"id":249284402,"exchange":"binance","price":10020,"quantity":0.1,"quoteQuantity":1002,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-19T21:33:09.154Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":619,"id":249536781,"exchange":"binance","price":9622.11,"quantity":0.020785,"quoteQuantity":199.99555635,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-20T00:44:46.557Z","fee":0.00002079,"feeCurrency":"BTC"},{"gid":620,"id":249536782,"exchange":"binance","price":9622.76,"quantity":0.066403,"quoteQuantity":638.98013228,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-20T00:44:46.613Z","fee":0.0000664,"feeCurrency":"BTC"},{"gid":621,"id":249536788,"exchange":"binance","price":9622.76,"quantity":0.026361,"quoteQuantity":253.66557636,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-20T00:44:47.094Z","fee":0.00002636,"feeCurrency":"BTC"},{"gid":622,"id":249536789,"exchange":"binance","price":9622.76,"quantity":0.067873,"quoteQuantity":653.12558948,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-20T00:44:47.171Z","fee":0.00006787,"feeCurrency":"BTC"},{"gid":623,"id":249536790,"exchange":"binance","price":9622.76,"quantity":0.018578,"quoteQuantity":178.77163528,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-20T00:44:47.193Z","fee":0.00001858,"feeCurrency":"BTC"},{"gid":624,"id":249554712,"exchange":"binance","price":9614.62,"quantity":0.095418,"quoteQuantity":917.40781116,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-20T01:13:19.467Z","fee":0.00009542,"feeCurrency":"BTC"},{"gid":625,"id":249554713,"exchange":"binance","price":9614.65,"quantity":0.004582,"quoteQuantity":44.0543263,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-20T01:13:19.467Z","fee":0.00000458,"feeCurrency":"BTC"},{"gid":626,"id":249575089,"exchange":"binance","price":9591,"quantity":0.018072,"quoteQuantity":173.328552,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-20T02:03:48.636Z","fee":0.00001807,"feeCurrency":"BTC"},{"gid":627,"id":249576519,"exchange":"binance","price":9581,"quantity":0.2,"quoteQuantity":1916.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-20T02:05:01.874Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":628,"id":249698463,"exchange":"binance","price":9600,"quantity":0.1,"quoteQuantity":960,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-20T06:51:35.299Z","fee":0.96,"feeCurrency":"USDT"},{"gid":629,"id":249746663,"exchange":"binance","price":9551,"quantity":0.1,"quoteQuantity":955.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-20T08:52:57.841Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":630,"id":249897354,"exchange":"binance","price":9600.87,"quantity":0.072012,"quoteQuantity":691.37785044,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-20T14:18:59.78Z","fee":0.69137785,"feeCurrency":"USDT"},{"gid":631,"id":249897355,"exchange":"binance","price":9600.87,"quantity":0.027981,"quoteQuantity":268.64194347,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-20T14:18:59.896Z","fee":0.26864194,"feeCurrency":"USDT"},{"gid":632,"id":249899166,"exchange":"binance","price":9600.87,"quantity":0.000007,"quoteQuantity":0.06720609,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-20T14:23:29.517Z","fee":0.00006721,"feeCurrency":"USDT"},{"gid":633,"id":249940178,"exchange":"binance","price":9571,"quantity":0.035958,"quoteQuantity":344.154018,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-20T16:00:27.342Z","fee":0.00003596,"feeCurrency":"BTC"},{"gid":634,"id":249940181,"exchange":"binance","price":9571,"quantity":0.062776,"quoteQuantity":600.829096,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-20T16:00:27.73Z","fee":0.00006278,"feeCurrency":"BTC"},{"gid":635,"id":249940183,"exchange":"binance","price":9571,"quantity":0.001266,"quoteQuantity":12.116886,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-20T16:00:28.095Z","fee":0.00000127,"feeCurrency":"BTC"},{"gid":636,"id":249998369,"exchange":"binance","price":9698,"quantity":0.1,"quoteQuantity":969.8,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-20T16:50:13.143Z","fee":0.9698,"feeCurrency":"USDT"},{"gid":637,"id":250450938,"exchange":"binance","price":9730.86,"quantity":0.1,"quoteQuantity":973.086,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-21T14:02:28.835Z","fee":0.973086,"feeCurrency":"USDT"},{"gid":638,"id":250467642,"exchange":"binance","price":9691,"quantity":0.1,"quoteQuantity":969.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-21T14:50:31.554Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":639,"id":250474797,"exchange":"binance","price":9671,"quantity":0.043468,"quoteQuantity":420.379028,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-21T15:05:39.981Z","fee":0.00004347,"feeCurrency":"BTC"},{"gid":640,"id":250474800,"exchange":"binance","price":9671,"quantity":0.048281,"quoteQuantity":466.925551,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-21T15:05:40.026Z","fee":0.00004828,"feeCurrency":"BTC"},{"gid":641,"id":250474801,"exchange":"binance","price":9671,"quantity":0.008251,"quoteQuantity":79.795421,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-21T15:05:40.052Z","fee":0.00000825,"feeCurrency":"BTC"},{"gid":642,"id":251017913,"exchange":"binance","price":9916.57,"quantity":0.1,"quoteQuantity":991.657,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-23T04:04:04.352Z","fee":0.991657,"feeCurrency":"USDT"},{"gid":643,"id":251035848,"exchange":"binance","price":9871.22,"quantity":0.1,"quoteQuantity":987.122,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-23T04:20:44.056Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":644,"id":251036412,"exchange":"binance","price":9866.2,"quantity":0.001445,"quoteQuantity":14.256659,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-23T04:21:41.598Z","fee":0.00000145,"feeCurrency":"BTC"},{"gid":645,"id":251130364,"exchange":"binance","price":9873.53,"quantity":0.227897,"quoteQuantity":2250.14786641,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-23T08:50:26.341Z","fee":2.25014787,"feeCurrency":"USDT"},{"gid":646,"id":251131350,"exchange":"binance","price":9869,"quantity":0.007875,"quoteQuantity":77.718375,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-23T08:54:34.604Z","fee":0.07771838,"feeCurrency":"USDT"},{"gid":647,"id":251131368,"exchange":"binance","price":9869,"quantity":0.005066,"quoteQuantity":49.996354,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-23T08:54:45.554Z","fee":0.04999635,"feeCurrency":"USDT"},{"gid":648,"id":251131376,"exchange":"binance","price":9869,"quantity":0.049245,"quoteQuantity":485.998905,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-23T08:54:49.699Z","fee":0.48599891,"feeCurrency":"USDT"},{"gid":649,"id":251131377,"exchange":"binance","price":9869,"quantity":0.019131,"quoteQuantity":188.803839,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-23T08:54:49.783Z","fee":0.18880384,"feeCurrency":"USDT"},{"gid":650,"id":251131378,"exchange":"binance","price":9869,"quantity":0.018678,"quoteQuantity":184.333182,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-23T08:54:49.89Z","fee":0.18433318,"feeCurrency":"USDT"},{"gid":651,"id":251131379,"exchange":"binance","price":9869,"quantity":0.000005,"quoteQuantity":0.049345,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-23T08:54:49.99Z","fee":0.00004935,"feeCurrency":"USDT"},{"gid":652,"id":251146695,"exchange":"binance","price":9901.62,"quantity":0.000002,"quoteQuantity":0.01980324,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-23T09:29:06.722Z","fee":0.0000198,"feeCurrency":"USDT"},{"gid":653,"id":251146696,"exchange":"binance","price":9901.34,"quantity":0.099998,"quoteQuantity":990.11419732,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-23T09:29:06.722Z","fee":0.9901142,"feeCurrency":"USDT"},{"gid":654,"id":251246303,"exchange":"binance","price":9845,"quantity":0.0381,"quoteQuantity":375.0945,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-23T13:38:01.879Z","fee":0.0000381,"feeCurrency":"BTC"},{"gid":655,"id":251246304,"exchange":"binance","price":9845,"quantity":0.008751,"quoteQuantity":86.153595,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-23T13:38:01.879Z","fee":0.00000875,"feeCurrency":"BTC"},{"gid":656,"id":251246307,"exchange":"binance","price":9845,"quantity":0.053149,"quoteQuantity":523.251905,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-23T13:38:02.236Z","fee":0.00005315,"feeCurrency":"BTC"},{"gid":657,"id":251435272,"exchange":"binance","price":9901.54,"quantity":0.000004,"quoteQuantity":0.03960616,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-24T01:18:43.892Z","fee":0.00000321,"feeCurrency":"BNB"},{"gid":658,"id":251435273,"exchange":"binance","price":9901.54,"quantity":0.00504,"quoteQuantity":49.9037616,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-24T01:18:44.01Z","fee":0.00000504,"feeCurrency":"BTC"},{"gid":659,"id":251435274,"exchange":"binance","price":9901.54,"quantity":0.010061,"quoteQuantity":99.61939394,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-24T01:18:44.117Z","fee":0.00001006,"feeCurrency":"BTC"},{"gid":660,"id":251435275,"exchange":"binance","price":9901.54,"quantity":0.006055,"quoteQuantity":59.9538247,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-24T01:18:44.213Z","fee":0.00000606,"feeCurrency":"BTC"},{"gid":661,"id":251435399,"exchange":"binance","price":9901.54,"quantity":0.02884,"quoteQuantity":285.5604136,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-24T01:19:14.62Z","fee":0.00002884,"feeCurrency":"BTC"},{"gid":662,"id":251435612,"exchange":"binance","price":9891,"quantity":0.01,"quoteQuantity":98.91,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-24T01:20:04.775Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":663,"id":251447795,"exchange":"binance","price":9907,"quantity":0.01,"quoteQuantity":99.07,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-24T02:15:55.366Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":664,"id":251447849,"exchange":"binance","price":9907.02,"quantity":0.01,"quoteQuantity":99.0702,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-24T02:16:11.913Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":665,"id":251447850,"exchange":"binance","price":9907.01,"quantity":0.01,"quoteQuantity":99.0701,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-24T02:16:11.913Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":666,"id":251454804,"exchange":"binance","price":9845,"quantity":0.05,"quoteQuantity":492.25,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-24T02:36:20.993Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":667,"id":251455090,"exchange":"binance","price":9835,"quantity":0.05,"quoteQuantity":491.75,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-24T02:36:24.298Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":668,"id":251492556,"exchange":"binance","price":9748.21,"quantity":0.089152,"quoteQuantity":869.07241792,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-24T02:56:49.603Z","fee":0.00008915,"feeCurrency":"BTC"},{"gid":669,"id":251492557,"exchange":"binance","price":9748.75,"quantity":0.003182,"quoteQuantity":31.0205225,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-24T02:56:49.655Z","fee":0.00000318,"feeCurrency":"BTC"},{"gid":670,"id":251492558,"exchange":"binance","price":9748.75,"quantity":0.047458,"quoteQuantity":462.6561775,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-24T02:56:49.713Z","fee":0.00004746,"feeCurrency":"BTC"},{"gid":671,"id":251656028,"exchange":"binance","price":9785,"quantity":0.03,"quoteQuantity":293.55,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-24T11:36:18.288Z","fee":0.29355,"feeCurrency":"USDT"},{"gid":672,"id":251791224,"exchange":"binance","price":9661,"quantity":0.030407,"quoteQuantity":293.762027,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-24T16:50:03.666Z","fee":0.00003041,"feeCurrency":"BTC"},{"gid":673,"id":252032193,"exchange":"binance","price":9601.35,"quantity":0.075528,"quoteQuantity":725.1707628,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-25T02:44:33.507Z","fee":0.72517076,"feeCurrency":"USDT"},{"gid":674,"id":252032194,"exchange":"binance","price":9601.35,"quantity":0.024446,"quoteQuantity":234.7146021,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-25T02:44:33.607Z","fee":0.2347146,"feeCurrency":"USDT"},{"gid":675,"id":252032237,"exchange":"binance","price":9601.35,"quantity":0.000026,"quoteQuantity":0.2496351,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-25T02:44:42.956Z","fee":0.00000859,"feeCurrency":"BNB"},{"gid":676,"id":252257162,"exchange":"binance","price":9570,"quantity":0.05,"quoteQuantity":478.5,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-25T13:34:49.444Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":677,"id":252266492,"exchange":"binance","price":9551,"quantity":0.05,"quoteQuantity":477.55,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-25T14:04:56.886Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":678,"id":252346996,"exchange":"binance","price":9408.67,"quantity":0.095196,"quoteQuantity":895.66774932,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-25T15:46:13.859Z","fee":0.89566775,"feeCurrency":"USDT"},{"gid":679,"id":252346997,"exchange":"binance","price":9408.65,"quantity":0.060384,"quoteQuantity":568.1319216,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-25T15:46:13.859Z","fee":0.56813192,"feeCurrency":"USDT"},{"gid":680,"id":252346998,"exchange":"binance","price":9408.64,"quantity":0.04442,"quoteQuantity":417.9317888,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-25T15:46:13.859Z","fee":0.41793179,"feeCurrency":"USDT"},{"gid":681,"id":252347502,"exchange":"binance","price":9414.96,"quantity":0.069528,"quoteQuantity":654.60333888,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-25T15:47:42.706Z","fee":0.00006953,"feeCurrency":"BTC"},{"gid":682,"id":252347503,"exchange":"binance","price":9415,"quantity":0.069229,"quoteQuantity":651.791035,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-25T15:47:42.706Z","fee":0.00006923,"feeCurrency":"BTC"},{"gid":683,"id":252347504,"exchange":"binance","price":9415,"quantity":0.0015,"quoteQuantity":14.1225,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-25T15:47:43.205Z","fee":0.0000015,"feeCurrency":"BTC"},{"gid":684,"id":252347518,"exchange":"binance","price":9415,"quantity":0.059677,"quoteQuantity":561.858955,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-25T15:47:50.65Z","fee":0.00005968,"feeCurrency":"BTC"},{"gid":685,"id":252349473,"exchange":"binance","price":9400.48,"quantity":0.001275,"quoteQuantity":11.985612,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-25T15:52:22.716Z","fee":0.01198561,"feeCurrency":"USDT"},{"gid":686,"id":252349474,"exchange":"binance","price":9400.47,"quantity":0.07276,"quoteQuantity":683.9781972,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-25T15:52:22.716Z","fee":0.6839782,"feeCurrency":"USDT"},{"gid":687,"id":252349475,"exchange":"binance","price":9400.45,"quantity":0.125965,"quoteQuantity":1184.12768425,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-25T15:52:22.716Z","fee":1.18412768,"feeCurrency":"USDT"},{"gid":688,"id":252582056,"exchange":"binance","price":9331,"quantity":0.1,"quoteQuantity":933.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-26T00:41:11.526Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":689,"id":252584506,"exchange":"binance","price":9311,"quantity":0.1,"quoteQuantity":931.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-26T00:50:02.366Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":690,"id":252605165,"exchange":"binance","price":9230.59,"quantity":0.023629,"quoteQuantity":218.10961111,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-26T01:25:31.04Z","fee":0.21810961,"feeCurrency":"USDT"},{"gid":691,"id":252605166,"exchange":"binance","price":9230.57,"quantity":0.026371,"quoteQuantity":243.41936147,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-26T01:25:31.04Z","fee":0.24341936,"feeCurrency":"USDT"},{"gid":692,"id":252605593,"exchange":"binance","price":9240,"quantity":0.05,"quoteQuantity":462,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-26T01:25:44.239Z","fee":0.462,"feeCurrency":"USDT"},{"gid":693,"id":252605916,"exchange":"binance","price":9230,"quantity":0.047,"quoteQuantity":433.81,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-26T01:25:58.084Z","fee":0.43381,"feeCurrency":"USDT"},{"gid":694,"id":252605917,"exchange":"binance","price":9230,"quantity":0.003,"quoteQuantity":27.69,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-26T01:25:58.106Z","fee":0.02769,"feeCurrency":"USDT"},{"gid":695,"id":252606639,"exchange":"binance","price":9234,"quantity":0.010818,"quoteQuantity":99.893412,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-26T01:26:35.139Z","fee":0.09989341,"feeCurrency":"USDT"},{"gid":696,"id":252606640,"exchange":"binance","price":9234,"quantity":0.01737,"quoteQuantity":160.39458,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-26T01:26:35.144Z","fee":0.16039458,"feeCurrency":"USDT"},{"gid":697,"id":252606641,"exchange":"binance","price":9234,"quantity":0.021812,"quoteQuantity":201.412008,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-26T01:26:35.29Z","fee":0.20141201,"feeCurrency":"USDT"},{"gid":698,"id":252608352,"exchange":"binance","price":9233.26,"quantity":0.05,"quoteQuantity":461.663,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-26T01:27:59.876Z","fee":0.461663,"feeCurrency":"USDT"},{"gid":699,"id":252610634,"exchange":"binance","price":9222.84,"quantity":0.1,"quoteQuantity":922.284,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-26T01:29:37.509Z","fee":0.922284,"feeCurrency":"USDT"},{"gid":700,"id":252612421,"exchange":"binance","price":9215,"quantity":0.1,"quoteQuantity":921.5,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-26T01:31:14.276Z","fee":0.9215,"feeCurrency":"USDT"},{"gid":701,"id":252612663,"exchange":"binance","price":9210,"quantity":0.1,"quoteQuantity":921,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-26T01:31:20.773Z","fee":0.921,"feeCurrency":"USDT"},{"gid":702,"id":252818950,"exchange":"binance","price":9102,"quantity":0.132352,"quoteQuantity":1204.667904,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-26T08:30:47.056Z","fee":0.00013235,"feeCurrency":"BTC"},{"gid":703,"id":252818951,"exchange":"binance","price":9102,"quantity":0.067648,"quoteQuantity":615.732096,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-26T08:30:47.056Z","fee":0.00006765,"feeCurrency":"BTC"},{"gid":704,"id":252938123,"exchange":"binance","price":9237.6,"quantity":0.009913,"quoteQuantity":91.5723288,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-26T12:15:45.573Z","fee":0.00000991,"feeCurrency":"BTC"},{"gid":705,"id":252938130,"exchange":"binance","price":9237.6,"quantity":0.160992,"quoteQuantity":1487.1796992,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-26T12:15:45.762Z","fee":0.00016099,"feeCurrency":"BTC"},{"gid":706,"id":252938143,"exchange":"binance","price":9237.6,"quantity":0.004358,"quoteQuantity":40.2574608,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-26T12:15:45.918Z","fee":0.00000436,"feeCurrency":"BTC"},{"gid":707,"id":252938144,"exchange":"binance","price":9237.6,"quantity":0.077414,"quoteQuantity":715.1195664,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-26T12:15:45.987Z","fee":0.00007741,"feeCurrency":"BTC"},{"gid":708,"id":252938147,"exchange":"binance","price":9237.6,"quantity":0.100227,"quoteQuantity":925.8569352,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-26T12:15:46.016Z","fee":0.00010023,"feeCurrency":"BTC"},{"gid":709,"id":252942595,"exchange":"binance","price":9220,"quantity":0.004129,"quoteQuantity":38.06938,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-26T12:20:15.69Z","fee":0.03806938,"feeCurrency":"USDT"},{"gid":710,"id":252942596,"exchange":"binance","price":9220,"quantity":0.095863,"quoteQuantity":883.85686,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-26T12:20:15.785Z","fee":0.88385686,"feeCurrency":"USDT"},{"gid":711,"id":252942608,"exchange":"binance","price":9220,"quantity":0.000008,"quoteQuantity":0.07376,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-26T12:20:16.32Z","fee":0.00000283,"feeCurrency":"BNB"},{"gid":712,"id":253048212,"exchange":"binance","price":9050.55,"quantity":0.152015,"quoteQuantity":1375.81935825,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-26T14:26:51.331Z","fee":1.37581936,"feeCurrency":"USDT"},{"gid":713,"id":253048213,"exchange":"binance","price":9050.49,"quantity":0.047985,"quoteQuantity":434.28776265,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-26T14:26:51.331Z","fee":0.43428776,"feeCurrency":"USDT"},{"gid":714,"id":253056555,"exchange":"binance","price":9022,"quantity":0.1,"quoteQuantity":902.2,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-26T14:35:03.471Z","fee":0.9022,"feeCurrency":"USDT"},{"gid":715,"id":253056875,"exchange":"binance","price":9025,"quantity":0.1,"quoteQuantity":902.5,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-26T14:35:21.3Z","fee":0.9025,"feeCurrency":"USDT"},{"gid":716,"id":253058247,"exchange":"binance","price":9047.71,"quantity":0.009374,"quoteQuantity":84.81323354,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-26T14:36:21.715Z","fee":0.08481323,"feeCurrency":"USDT"},{"gid":717,"id":253058248,"exchange":"binance","price":9046.2,"quantity":0.090626,"quoteQuantity":819.8209212,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-26T14:36:21.715Z","fee":0.81982092,"feeCurrency":"USDT"},{"gid":718,"id":253186241,"exchange":"binance","price":8731,"quantity":0.028082,"quoteQuantity":245.183942,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-26T16:45:09.946Z","fee":0.00002808,"feeCurrency":"BTC"},{"gid":719,"id":253186242,"exchange":"binance","price":8731,"quantity":0.010984,"quoteQuantity":95.901304,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-26T16:45:10.021Z","fee":0.00001098,"feeCurrency":"BTC"},{"gid":720,"id":253186245,"exchange":"binance","price":8731,"quantity":0.050153,"quoteQuantity":437.885843,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-26T16:45:10.051Z","fee":0.00005015,"feeCurrency":"BTC"},{"gid":721,"id":253186251,"exchange":"binance","price":8731,"quantity":0.010781,"quoteQuantity":94.128911,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-26T16:45:10.133Z","fee":0.00001078,"feeCurrency":"BTC"},{"gid":722,"id":253186880,"exchange":"binance","price":8721,"quantity":0.1,"quoteQuantity":872.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-26T16:45:12.46Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":723,"id":253202429,"exchange":"binance","price":8690,"quantity":0.199774,"quoteQuantity":1736.03606,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-26T16:48:20.043Z","fee":1.73603606,"feeCurrency":"USDT"},{"gid":724,"id":253202437,"exchange":"binance","price":8690,"quantity":0.000226,"quoteQuantity":1.96394,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-26T16:48:20.188Z","fee":0.00196394,"feeCurrency":"USDT"},{"gid":725,"id":253409569,"exchange":"binance","price":8775.47,"quantity":0.065377,"quoteQuantity":573.71390219,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-26T22:26:02.387Z","fee":0.00006538,"feeCurrency":"BTC"},{"gid":726,"id":253409570,"exchange":"binance","price":8775.47,"quantity":0.005387,"quoteQuantity":47.27345689,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-26T22:26:02.436Z","fee":0.00000539,"feeCurrency":"BTC"},{"gid":727,"id":253409571,"exchange":"binance","price":8775.47,"quantity":0.002488,"quoteQuantity":21.83336936,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-26T22:26:03.005Z","fee":0.00000249,"feeCurrency":"BTC"},{"gid":728,"id":253409572,"exchange":"binance","price":8775.47,"quantity":0.002488,"quoteQuantity":21.83336936,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-26T22:26:03.387Z","fee":0.00000249,"feeCurrency":"BTC"},{"gid":729,"id":253409577,"exchange":"binance","price":8775.47,"quantity":0.024256,"quoteQuantity":212.85780032,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-26T22:26:04.062Z","fee":0.00002426,"feeCurrency":"BTC"},{"gid":730,"id":253409583,"exchange":"binance","price":8775.47,"quantity":0.000004,"quoteQuantity":0.03510188,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-26T22:26:04.866Z","fee":0.00000357,"feeCurrency":"BNB"},{"gid":731,"id":253418282,"exchange":"binance","price":8805.5,"quantity":0.1,"quoteQuantity":880.55,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-26T22:49:14.811Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":732,"id":253420952,"exchange":"binance","price":8786.2,"quantity":0.1,"quoteQuantity":878.62,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-26T22:59:52.999Z","fee":0.87862,"feeCurrency":"USDT"},{"gid":733,"id":253443014,"exchange":"binance","price":8737.71,"quantity":0.1,"quoteQuantity":873.771,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-27T00:07:13.633Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":734,"id":253448418,"exchange":"binance","price":8711,"quantity":0.05,"quoteQuantity":435.55,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T00:16:19.934Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":735,"id":253452962,"exchange":"binance","price":8700,"quantity":0.01,"quoteQuantity":87,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T00:20:41.57Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":736,"id":253453343,"exchange":"binance","price":8690,"quantity":0.01,"quoteQuantity":86.9,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T00:21:18.159Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":737,"id":253461747,"exchange":"binance","price":8661,"quantity":0.05,"quoteQuantity":433.05,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T00:35:55.334Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":738,"id":253487728,"exchange":"binance","price":8587.09,"quantity":0.01,"quoteQuantity":85.8709,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-27T01:05:01.028Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":739,"id":253498503,"exchange":"binance","price":8600,"quantity":0.057345,"quoteQuantity":493.167,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-27T01:11:34.424Z","fee":0.493167,"feeCurrency":"USDT"},{"gid":740,"id":253498504,"exchange":"binance","price":8600,"quantity":0.008325,"quoteQuantity":71.595,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-27T01:11:34.424Z","fee":0.071595,"feeCurrency":"USDT"},{"gid":741,"id":253498505,"exchange":"binance","price":8600,"quantity":0.03433,"quoteQuantity":295.238,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-27T01:11:34.427Z","fee":0.295238,"feeCurrency":"USDT"},{"gid":742,"id":253508022,"exchange":"binance","price":8600,"quantity":0.001887,"quoteQuantity":16.2282,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-27T01:21:16.156Z","fee":0.0162282,"feeCurrency":"USDT"},{"gid":743,"id":253508023,"exchange":"binance","price":8600,"quantity":0.098113,"quoteQuantity":843.7718,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-27T01:21:16.158Z","fee":0.8437718,"feeCurrency":"USDT"},{"gid":744,"id":253510895,"exchange":"binance","price":8617.34,"quantity":0.011073,"quoteQuantity":95.41980582,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-27T01:23:59.067Z","fee":0.09541981,"feeCurrency":"USDT"},{"gid":745,"id":253510896,"exchange":"binance","price":8617.34,"quantity":0.068927,"quoteQuantity":593.96739418,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-27T01:23:59.14Z","fee":0.59396739,"feeCurrency":"USDT"},{"gid":746,"id":253514539,"exchange":"binance","price":8647.49,"quantity":0.01,"quoteQuantity":86.4749,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-27T01:27:52.079Z","fee":0.0864749,"feeCurrency":"USDT"},{"gid":747,"id":253519154,"exchange":"binance","price":8630.56,"quantity":0.1,"quoteQuantity":863.056,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-27T01:34:58.998Z","fee":0.863056,"feeCurrency":"USDT"},{"gid":748,"id":253538065,"exchange":"binance","price":8671.9,"quantity":0.002305,"quoteQuantity":19.9887295,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-27T02:02:25.949Z","fee":0.01998873,"feeCurrency":"USDT"},{"gid":749,"id":253538066,"exchange":"binance","price":8670.84,"quantity":0.007695,"quoteQuantity":66.7221138,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-27T02:02:25.949Z","fee":0.06672211,"feeCurrency":"USDT"},{"gid":750,"id":253561662,"exchange":"binance","price":8670.47,"quantity":0.060894,"quoteQuantity":527.97960018,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-27T02:50:48.195Z","fee":0.5279796,"feeCurrency":"USDT"},{"gid":751,"id":253588181,"exchange":"binance","price":8750.24,"quantity":0.048312,"quoteQuantity":422.74159488,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T04:02:47.371Z","fee":0.00004831,"feeCurrency":"BTC"},{"gid":752,"id":253588186,"exchange":"binance","price":8750.24,"quantity":0.051688,"quoteQuantity":452.28240512,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T04:02:48.442Z","fee":0.00005169,"feeCurrency":"BTC"},{"gid":753,"id":253588532,"exchange":"binance","price":8731.01,"quantity":0.0999,"quoteQuantity":872.227899,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-27T04:03:35.408Z","fee":0.8722279,"feeCurrency":"USDT"},{"gid":754,"id":253612099,"exchange":"binance","price":8740,"quantity":0.1,"quoteQuantity":874,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T05:20:04.992Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":755,"id":253625265,"exchange":"binance","price":8773.42,"quantity":0.09559,"quoteQuantity":838.6512178,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-27T05:53:57.165Z","fee":0.00009559,"feeCurrency":"BTC"},{"gid":756,"id":253625266,"exchange":"binance","price":8773.46,"quantity":0.00441,"quoteQuantity":38.6909586,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-27T05:53:57.165Z","fee":0.00000441,"feeCurrency":"BTC"},{"gid":757,"id":253626923,"exchange":"binance","price":8774,"quantity":0.1,"quoteQuantity":877.4,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T05:57:34.55Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":758,"id":253627724,"exchange":"binance","price":8766.26,"quantity":0.002281,"quoteQuantity":19.99583906,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-27T05:58:39.197Z","fee":0.00000228,"feeCurrency":"BTC"},{"gid":759,"id":253627725,"exchange":"binance","price":8766.8,"quantity":0.001198,"quoteQuantity":10.5026264,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-27T05:58:39.197Z","fee":0.0000012,"feeCurrency":"BTC"},{"gid":760,"id":253627726,"exchange":"binance","price":8766.8,"quantity":0.001198,"quoteQuantity":10.5026264,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-27T05:58:39.197Z","fee":0.0000012,"feeCurrency":"BTC"},{"gid":761,"id":253627727,"exchange":"binance","price":8766.99,"quantity":0.045323,"quoteQuantity":397.34628777,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-27T05:58:39.197Z","fee":0.00004532,"feeCurrency":"BTC"},{"gid":762,"id":253633857,"exchange":"binance","price":8800,"quantity":0.013036,"quoteQuantity":114.7168,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T06:09:47.711Z","fee":0.00001304,"feeCurrency":"BTC"},{"gid":763,"id":253633859,"exchange":"binance","price":8800,"quantity":0.0023,"quoteQuantity":20.24,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T06:09:47.746Z","fee":0.0000023,"feeCurrency":"BTC"},{"gid":764,"id":253633860,"exchange":"binance","price":8800,"quantity":0.00774,"quoteQuantity":68.112,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T06:09:47.753Z","fee":0.00000774,"feeCurrency":"BTC"},{"gid":765,"id":253633862,"exchange":"binance","price":8800,"quantity":0.06005,"quoteQuantity":528.44,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T06:09:47.838Z","fee":0.00006005,"feeCurrency":"BTC"},{"gid":766,"id":253633863,"exchange":"binance","price":8800,"quantity":0.012609,"quoteQuantity":110.9592,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T06:09:47.854Z","fee":0.00001261,"feeCurrency":"BTC"},{"gid":767,"id":253645119,"exchange":"binance","price":8792.09,"quantity":0.035244,"quoteQuantity":309.86841996,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-27T06:25:20.753Z","fee":0.30986842,"feeCurrency":"USDT"},{"gid":768,"id":253645120,"exchange":"binance","price":8792.06,"quantity":0.024544,"quoteQuantity":215.79232064,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-27T06:25:20.753Z","fee":0.21579232,"feeCurrency":"USDT"},{"gid":769,"id":253645121,"exchange":"binance","price":8792.06,"quantity":0.140212,"quoteQuantity":1232.75231672,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-27T06:25:20.753Z","fee":1.23275232,"feeCurrency":"USDT"},{"gid":770,"id":253646124,"exchange":"binance","price":8785,"quantity":0.018167,"quoteQuantity":159.597095,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-27T06:26:28.278Z","fee":0.1595971,"feeCurrency":"USDT"},{"gid":771,"id":253646174,"exchange":"binance","price":8785,"quantity":0.001153,"quoteQuantity":10.129105,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-27T06:26:32.337Z","fee":0.01012911,"feeCurrency":"USDT"},{"gid":772,"id":253646175,"exchange":"binance","price":8785,"quantity":0.08068,"quoteQuantity":708.7738,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-27T06:26:32.341Z","fee":0.7087738,"feeCurrency":"USDT"},{"gid":773,"id":253650131,"exchange":"binance","price":8803,"quantity":0.017338,"quoteQuantity":152.626414,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-27T06:33:34.644Z","fee":0.15262641,"feeCurrency":"USDT"},{"gid":774,"id":253652651,"exchange":"binance","price":8785.99,"quantity":0.095451,"quoteQuantity":838.63153149,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-27T06:38:25.517Z","fee":0.00009545,"feeCurrency":"BTC"},{"gid":775,"id":253652652,"exchange":"binance","price":8786.03,"quantity":0.107563,"quoteQuantity":945.05174489,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-27T06:38:25.517Z","fee":0.00010756,"feeCurrency":"BTC"},{"gid":776,"id":253652653,"exchange":"binance","price":8786.4,"quantity":0.114059,"quoteQuantity":1002.1679976,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-27T06:38:25.517Z","fee":0.00011406,"feeCurrency":"BTC"},{"gid":777,"id":253653281,"exchange":"binance","price":8785.64,"quantity":0.1,"quoteQuantity":878.564,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-27T06:39:46.856Z","fee":0.878564,"feeCurrency":"USDT"},{"gid":778,"id":253653345,"exchange":"binance","price":8785.65,"quantity":0.1,"quoteQuantity":878.565,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-27T06:39:57.524Z","fee":0.878565,"feeCurrency":"USDT"},{"gid":779,"id":253665372,"exchange":"binance","price":8815,"quantity":0.025024,"quoteQuantity":220.58656,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T07:06:16.421Z","fee":0.00002502,"feeCurrency":"BTC"},{"gid":780,"id":253665386,"exchange":"binance","price":8815,"quantity":0.020887,"quoteQuantity":184.118905,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T07:06:18.662Z","fee":0.00002089,"feeCurrency":"BTC"},{"gid":781,"id":253665391,"exchange":"binance","price":8815,"quantity":0.054089,"quoteQuantity":476.794535,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T07:06:18.812Z","fee":0.00005409,"feeCurrency":"BTC"},{"gid":782,"id":253667600,"exchange":"binance","price":8809,"quantity":0.09,"quoteQuantity":792.81,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T07:10:13.178Z","fee":0.00009,"feeCurrency":"BTC"},{"gid":783,"id":253667602,"exchange":"binance","price":8809,"quantity":0.01,"quoteQuantity":88.09,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T07:10:13.427Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":784,"id":253669450,"exchange":"binance","price":8800,"quantity":0.01,"quoteQuantity":88,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T07:14:04.142Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":785,"id":253676746,"exchange":"binance","price":8783,"quantity":0.01,"quoteQuantity":87.83,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T07:32:05.166Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":786,"id":253685053,"exchange":"binance","price":8815,"quantity":0.000001,"quoteQuantity":0.008815,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-27T07:55:52.354Z","fee":3.5e-7,"feeCurrency":"BNB"},{"gid":787,"id":253685054,"exchange":"binance","price":8815,"quantity":0.005439,"quoteQuantity":47.944785,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-27T07:55:53.457Z","fee":0.04794479,"feeCurrency":"USDT"},{"gid":788,"id":253685059,"exchange":"binance","price":8815,"quantity":0.09456,"quoteQuantity":833.5464,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-27T07:55:54.279Z","fee":0.8335464,"feeCurrency":"USDT"},{"gid":789,"id":253685271,"exchange":"binance","price":8819,"quantity":0.099994,"quoteQuantity":881.847086,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-27T07:56:20.38Z","fee":0.88184709,"feeCurrency":"USDT"},{"gid":790,"id":253685272,"exchange":"binance","price":8819,"quantity":0.000006,"quoteQuantity":0.052914,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-27T07:56:20.38Z","fee":0.00000212,"feeCurrency":"BNB"},{"gid":791,"id":253701755,"exchange":"binance","price":8847,"quantity":0.060148,"quoteQuantity":532.129356,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T08:29:27.289Z","fee":0.00006015,"feeCurrency":"BTC"},{"gid":792,"id":253701758,"exchange":"binance","price":8847,"quantity":0.039852,"quoteQuantity":352.570644,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T08:29:27.632Z","fee":0.00003985,"feeCurrency":"BTC"},{"gid":793,"id":253703515,"exchange":"binance","price":8829,"quantity":0.05,"quoteQuantity":441.45,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T08:32:15.519Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":794,"id":253710454,"exchange":"binance","price":8825,"quantity":0.05,"quoteQuantity":441.25,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T08:43:44.002Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":795,"id":253710921,"exchange":"binance","price":8821,"quantity":0.05,"quoteQuantity":441.05,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T08:44:18.823Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":796,"id":253713965,"exchange":"binance","price":8811,"quantity":0.05,"quoteQuantity":440.55,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T08:51:51.914Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":797,"id":253716252,"exchange":"binance","price":8797.44,"quantity":0.095324,"quoteQuantity":838.60717056,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-27T08:57:03.316Z","fee":0.83860717,"feeCurrency":"USDT"},{"gid":798,"id":253716253,"exchange":"binance","price":8797.4,"quantity":0.000002,"quoteQuantity":0.0175948,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-27T08:57:03.316Z","fee":0.00001759,"feeCurrency":"USDT"},{"gid":799,"id":253716254,"exchange":"binance","price":8797.16,"quantity":0.093892,"quoteQuantity":825.98294672,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-27T08:57:03.316Z","fee":0.82598295,"feeCurrency":"USDT"},{"gid":800,"id":253716255,"exchange":"binance","price":8797.15,"quantity":0.010782,"quoteQuantity":94.8508713,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-27T08:57:03.316Z","fee":0.09485087,"feeCurrency":"USDT"},{"gid":801,"id":253758027,"exchange":"binance","price":8790.46,"quantity":0.1,"quoteQuantity":879.046,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-27T10:36:39.141Z","fee":0.879046,"feeCurrency":"USDT"},{"gid":802,"id":253804369,"exchange":"binance","price":8896.6,"quantity":0.102259,"quoteQuantity":909.7574194,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-27T12:16:50.719Z","fee":0.00010226,"feeCurrency":"BTC"},{"gid":803,"id":253804370,"exchange":"binance","price":8896.65,"quantity":0.097741,"quoteQuantity":869.56746765,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-27T12:16:50.719Z","fee":0.00009774,"feeCurrency":"BTC"},{"gid":804,"id":253808324,"exchange":"binance","price":8910,"quantity":0.035105,"quoteQuantity":312.78555,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T12:20:36.77Z","fee":0.00003511,"feeCurrency":"BTC"},{"gid":805,"id":253808343,"exchange":"binance","price":8910,"quantity":0.064895,"quoteQuantity":578.21445,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T12:20:39.547Z","fee":0.0000649,"feeCurrency":"BTC"},{"gid":806,"id":253811521,"exchange":"binance","price":8920,"quantity":0.1,"quoteQuantity":892,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T12:24:15.149Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":807,"id":253818617,"exchange":"binance","price":8899,"quantity":0.068268,"quoteQuantity":607.516932,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T12:30:39.489Z","fee":0.00006827,"feeCurrency":"BTC"},{"gid":808,"id":253827658,"exchange":"binance","price":8800,"quantity":0.165137,"quoteQuantity":1453.2056,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-27T12:39:19.768Z","fee":1.4532056,"feeCurrency":"USDT"},{"gid":809,"id":253827673,"exchange":"binance","price":8800,"quantity":0.56685,"quoteQuantity":4988.28,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-27T12:39:20.277Z","fee":4.98828,"feeCurrency":"USDT"},{"gid":810,"id":253945344,"exchange":"binance","price":8930,"quantity":0.05,"quoteQuantity":446.5,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T16:40:08.658Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":811,"id":253946165,"exchange":"binance","price":8945,"quantity":0.060119,"quoteQuantity":537.764455,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T16:40:45.227Z","fee":0.00006012,"feeCurrency":"BTC"},{"gid":812,"id":253946183,"exchange":"binance","price":8945,"quantity":0.003276,"quoteQuantity":29.30382,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T16:40:46.61Z","fee":0.00000328,"feeCurrency":"BTC"},{"gid":813,"id":253946200,"exchange":"binance","price":8945,"quantity":0.036592,"quoteQuantity":327.31544,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T16:40:47.768Z","fee":0.00003659,"feeCurrency":"BTC"},{"gid":814,"id":253946201,"exchange":"binance","price":8945,"quantity":0.000013,"quoteQuantity":0.116285,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T16:40:47.78Z","fee":1e-8,"feeCurrency":"BTC"},{"gid":815,"id":253947453,"exchange":"binance","price":8951.97,"quantity":0.3,"quoteQuantity":2685.591,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-27T16:41:58.11Z","fee":0.0003,"feeCurrency":"BTC"},{"gid":816,"id":253947802,"exchange":"binance","price":8952.92,"quantity":0.1,"quoteQuantity":895.292,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-27T16:42:24.866Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":817,"id":253966683,"exchange":"binance","price":8901,"quantity":0.1,"quoteQuantity":890.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T17:13:01.994Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":818,"id":253967470,"exchange":"binance","price":8891,"quantity":0.070078,"quoteQuantity":623.063498,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-27T17:13:35.754Z","fee":0.00007008,"feeCurrency":"BTC"},{"gid":819,"id":254241990,"exchange":"binance","price":8785.92,"quantity":0.002275,"quoteQuantity":19.987968,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-28T06:37:15.098Z","fee":0.01998797,"feeCurrency":"USDT"},{"gid":820,"id":254241991,"exchange":"binance","price":8785.92,"quantity":0.047725,"quoteQuantity":419.308032,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-28T06:37:15.098Z","fee":0.41930803,"feeCurrency":"USDT"},{"gid":821,"id":254244478,"exchange":"binance","price":8800,"quantity":0.05,"quoteQuantity":440,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-28T06:43:50.148Z","fee":0.44,"feeCurrency":"USDT"},{"gid":822,"id":254245562,"exchange":"binance","price":8780,"quantity":0.028606,"quoteQuantity":251.16068,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-28T06:46:51.021Z","fee":0.25116068,"feeCurrency":"USDT"},{"gid":823,"id":254245563,"exchange":"binance","price":8780,"quantity":0.021394,"quoteQuantity":187.83932,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-28T06:46:51.058Z","fee":0.18783932,"feeCurrency":"USDT"},{"gid":824,"id":254246708,"exchange":"binance","price":8800,"quantity":0.057766,"quoteQuantity":508.3408,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-28T06:49:39.57Z","fee":0.5083408,"feeCurrency":"USDT"},{"gid":825,"id":254246710,"exchange":"binance","price":8800,"quantity":0.042234,"quoteQuantity":371.6592,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-28T06:49:39.819Z","fee":0.3716592,"feeCurrency":"USDT"},{"gid":826,"id":254253148,"exchange":"binance","price":8767.17,"quantity":0.01,"quoteQuantity":87.6717,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-02-28T07:08:25.859Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":827,"id":254253273,"exchange":"binance","price":8764.56,"quantity":0.0085,"quoteQuantity":74.49876,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-28T07:08:41.075Z","fee":0.0000085,"feeCurrency":"BTC"},{"gid":828,"id":254253274,"exchange":"binance","price":8764.56,"quantity":0.001438,"quoteQuantity":12.60343728,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-28T07:08:41.077Z","fee":0.00000144,"feeCurrency":"BTC"},{"gid":829,"id":254253343,"exchange":"binance","price":8764.56,"quantity":0.000062,"quoteQuantity":0.54340272,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-28T07:08:42.007Z","fee":6e-8,"feeCurrency":"BTC"},{"gid":830,"id":254258018,"exchange":"binance","price":8754.36,"quantity":0.005359,"quoteQuantity":46.91461524,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-28T07:18:09.3Z","fee":0.00000536,"feeCurrency":"BTC"},{"gid":831,"id":254258025,"exchange":"binance","price":8754.36,"quantity":0.002276,"quoteQuantity":19.92492336,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-28T07:18:10.117Z","fee":0.00000228,"feeCurrency":"BTC"},{"gid":832,"id":254258027,"exchange":"binance","price":8754.36,"quantity":0.012365,"quoteQuantity":108.2476614,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-28T07:18:10.337Z","fee":0.00001237,"feeCurrency":"BTC"},{"gid":833,"id":254272008,"exchange":"binance","price":8740,"quantity":0.01,"quoteQuantity":87.4,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-28T07:44:30.195Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":834,"id":254272691,"exchange":"binance","price":8721,"quantity":0.02,"quoteQuantity":174.42,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-28T07:46:46.338Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":835,"id":254275589,"exchange":"binance","price":8706.02,"quantity":0.01,"quoteQuantity":87.0602,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-28T07:50:02.057Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":836,"id":254277059,"exchange":"binance","price":8714.46,"quantity":0.002904,"quoteQuantity":25.30679184,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-28T07:53:48.281Z","fee":0.0000029,"feeCurrency":"BTC"},{"gid":837,"id":254277060,"exchange":"binance","price":8714.46,"quantity":0.007096,"quoteQuantity":61.83780816,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-28T07:53:48.283Z","fee":0.0000071,"feeCurrency":"BTC"},{"gid":838,"id":254293407,"exchange":"binance","price":8750,"quantity":0.080439,"quoteQuantity":703.84125,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-28T08:34:11.63Z","fee":0.70384125,"feeCurrency":"USDT"},{"gid":839,"id":254293410,"exchange":"binance","price":8750,"quantity":0.010096,"quoteQuantity":88.34,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-28T08:34:12.055Z","fee":0.08834,"feeCurrency":"USDT"},{"gid":840,"id":254293421,"exchange":"binance","price":8750,"quantity":0.009465,"quoteQuantity":82.81875,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-28T08:34:13.496Z","fee":0.08281875,"feeCurrency":"USDT"},{"gid":841,"id":254293521,"exchange":"binance","price":8748.98,"quantity":0.01,"quoteQuantity":87.4898,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-28T08:34:40.887Z","fee":0.0874898,"feeCurrency":"USDT"},{"gid":842,"id":254293527,"exchange":"binance","price":8748.95,"quantity":0.004922,"quoteQuantity":43.0623319,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-28T08:34:42.842Z","fee":0.04306233,"feeCurrency":"USDT"},{"gid":843,"id":254293528,"exchange":"binance","price":8748.94,"quantity":0.002267,"quoteQuantity":19.83384698,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-28T08:34:42.842Z","fee":0.01983385,"feeCurrency":"USDT"},{"gid":844,"id":254293529,"exchange":"binance","price":8748.69,"quantity":0.002811,"quoteQuantity":24.59256759,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-28T08:34:42.842Z","fee":0.02459257,"feeCurrency":"USDT"},{"gid":845,"id":254293535,"exchange":"binance","price":8748.69,"quantity":0.01,"quoteQuantity":87.4869,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-28T08:34:44.911Z","fee":0.0874869,"feeCurrency":"USDT"},{"gid":846,"id":254293568,"exchange":"binance","price":8748.98,"quantity":0.01,"quoteQuantity":87.4898,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-28T08:34:53.606Z","fee":0.0874898,"feeCurrency":"USDT"},{"gid":847,"id":254293745,"exchange":"binance","price":8748,"quantity":0.01,"quoteQuantity":87.48,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-28T08:35:22.888Z","fee":0.08748,"feeCurrency":"USDT"},{"gid":848,"id":254293793,"exchange":"binance","price":8747.45,"quantity":0.01,"quoteQuantity":87.4745,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-28T08:35:34.224Z","fee":0.0874745,"feeCurrency":"USDT"},{"gid":849,"id":254294492,"exchange":"binance","price":8749,"quantity":0.05,"quoteQuantity":437.45,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-28T08:39:01.529Z","fee":0.43745,"feeCurrency":"USDT"},{"gid":850,"id":254355114,"exchange":"binance","price":8623.39,"quantity":0.030591,"quoteQuantity":263.79812349,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-28T10:05:18.068Z","fee":0.26379812,"feeCurrency":"USDT"},{"gid":851,"id":254355115,"exchange":"binance","price":8623.39,"quantity":0.002488,"quoteQuantity":21.45499432,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-28T10:05:18.151Z","fee":0.02145499,"feeCurrency":"USDT"},{"gid":852,"id":254355116,"exchange":"binance","price":8623.39,"quantity":0.066908,"quoteQuantity":576.97377812,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-28T10:05:18.226Z","fee":0.57697378,"feeCurrency":"USDT"},{"gid":853,"id":254355216,"exchange":"binance","price":8623.39,"quantity":0.000013,"quoteQuantity":0.11210407,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-28T10:05:23.173Z","fee":0.0001121,"feeCurrency":"USDT"},{"gid":854,"id":254355332,"exchange":"binance","price":8615.85,"quantity":0.003801,"quoteQuantity":32.74884585,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-28T10:05:26.384Z","fee":0.03274885,"feeCurrency":"USDT"},{"gid":855,"id":254355333,"exchange":"binance","price":8615.74,"quantity":0.054231,"quoteQuantity":467.24019594,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-28T10:05:26.384Z","fee":0.4672402,"feeCurrency":"USDT"},{"gid":856,"id":254355334,"exchange":"binance","price":8615.73,"quantity":0.041968,"quoteQuantity":361.58495664,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-28T10:05:26.384Z","fee":0.36158496,"feeCurrency":"USDT"},{"gid":857,"id":254355461,"exchange":"binance","price":8617.28,"quantity":0.050299,"quoteQuantity":433.44056672,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-28T10:05:33.979Z","fee":0.43344057,"feeCurrency":"USDT"},{"gid":858,"id":254355462,"exchange":"binance","price":8617.17,"quantity":0.049701,"quoteQuantity":428.28196617,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-28T10:05:33.979Z","fee":0.42828197,"feeCurrency":"USDT"},{"gid":859,"id":254978463,"exchange":"binance","price":8601,"quantity":0.01,"quoteQuantity":86.01,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-29T11:07:57.298Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":860,"id":255037585,"exchange":"binance","price":8638.72,"quantity":0.002315,"quoteQuantity":19.9986368,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-02-29T13:52:10.285Z","fee":0.01999864,"feeCurrency":"USDT"},{"gid":861,"id":255037624,"exchange":"binance","price":8637.23,"quantity":0.01,"quoteQuantity":86.3723,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-02-29T13:52:26.63Z","fee":0.0863723,"feeCurrency":"USDT"},{"gid":862,"id":255043251,"exchange":"binance","price":8600,"quantity":0.01,"quoteQuantity":86,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-29T14:09:41.068Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":863,"id":255095830,"exchange":"binance","price":8631,"quantity":0.003106,"quoteQuantity":26.807886,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-29T16:25:14.407Z","fee":0.00000311,"feeCurrency":"BTC"},{"gid":864,"id":255095831,"exchange":"binance","price":8631,"quantity":0.016894,"quoteQuantity":145.812114,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-29T16:25:14.474Z","fee":0.00001689,"feeCurrency":"BTC"},{"gid":865,"id":255108231,"exchange":"binance","price":8592,"quantity":0.02,"quoteQuantity":171.84,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-29T17:03:49.915Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":866,"id":255192632,"exchange":"binance","price":8592,"quantity":0.01898,"quoteQuantity":163.07616,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-29T23:37:15.191Z","fee":0.00001898,"feeCurrency":"BTC"},{"gid":867,"id":255192633,"exchange":"binance","price":8592,"quantity":0.03102,"quoteQuantity":266.52384,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-02-29T23:37:15.191Z","fee":0.00003102,"feeCurrency":"BTC"},{"gid":868,"id":255278248,"exchange":"binance","price":8643.89,"quantity":0.03,"quoteQuantity":259.3167,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-01T03:40:15.219Z","fee":0.2593167,"feeCurrency":"USDT"},{"gid":869,"id":255287495,"exchange":"binance","price":8592.97,"quantity":0.009983,"quoteQuantity":85.78361951,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-01T04:15:40.33Z","fee":0.00000998,"feeCurrency":"BTC"},{"gid":870,"id":255287501,"exchange":"binance","price":8592.97,"quantity":0.000017,"quoteQuantity":0.14608049,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-01T04:15:41.311Z","fee":2e-8,"feeCurrency":"BTC"},{"gid":871,"id":255300711,"exchange":"binance","price":8561,"quantity":0.005,"quoteQuantity":42.805,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-01T05:11:19.422Z","fee":0.000005,"feeCurrency":"BTC"},{"gid":872,"id":255313425,"exchange":"binance","price":8570,"quantity":0.005,"quoteQuantity":42.85,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-01T06:03:36.548Z","fee":0.000005,"feeCurrency":"BTC"},{"gid":873,"id":255319118,"exchange":"binance","price":8541,"quantity":0.005,"quoteQuantity":42.705,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-01T06:32:19.57Z","fee":0.000005,"feeCurrency":"BTC"},{"gid":874,"id":255339239,"exchange":"binance","price":8541.87,"quantity":0.01,"quoteQuantity":85.4187,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-01T07:49:27.102Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":875,"id":255348122,"exchange":"binance","price":8521,"quantity":0.01,"quoteQuantity":85.21,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-01T08:23:01.407Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":876,"id":255376971,"exchange":"binance","price":8547.82,"quantity":0.05,"quoteQuantity":427.391,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-01T10:03:52.575Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":877,"id":255397607,"exchange":"binance","price":8598,"quantity":0.04,"quoteQuantity":343.92,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-01T11:42:53.145Z","fee":0.34392,"feeCurrency":"USDT"},{"gid":878,"id":255406244,"exchange":"binance","price":8649.69,"quantity":0.1,"quoteQuantity":864.969,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-01T11:58:55.441Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":879,"id":255407686,"exchange":"binance","price":8674.96,"quantity":0.2,"quoteQuantity":1734.992,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-01T12:00:37.918Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":880,"id":255409420,"exchange":"binance","price":8696.98,"quantity":0.050517,"quoteQuantity":439.34533866,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-01T12:01:12.375Z","fee":0.00005052,"feeCurrency":"BTC"},{"gid":881,"id":255409421,"exchange":"binance","price":8696.99,"quantity":0.049483,"quoteQuantity":430.35315617,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-01T12:01:12.375Z","fee":0.00004948,"feeCurrency":"BTC"},{"gid":882,"id":255418473,"exchange":"binance","price":8694.49,"quantity":0.032988,"quoteQuantity":286.81383612,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-01T12:08:10.226Z","fee":0.28681384,"feeCurrency":"USDT"},{"gid":883,"id":255418474,"exchange":"binance","price":8694.48,"quantity":0.090205,"quoteQuantity":784.2855684,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-01T12:08:10.226Z","fee":0.78428557,"feeCurrency":"USDT"},{"gid":884,"id":255418475,"exchange":"binance","price":8694.46,"quantity":0.076807,"quoteQuantity":667.79538922,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-01T12:08:10.226Z","fee":0.66779539,"feeCurrency":"USDT"},{"gid":885,"id":255418738,"exchange":"binance","price":8697.14,"quantity":0.090185,"quoteQuantity":784.3515709,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-01T12:08:27.747Z","fee":0.78435157,"feeCurrency":"USDT"},{"gid":886,"id":255418739,"exchange":"binance","price":8697.1,"quantity":0.009815,"quoteQuantity":85.3620365,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-01T12:08:27.747Z","fee":0.08536204,"feeCurrency":"USDT"},{"gid":887,"id":255445089,"exchange":"binance","price":8590.01,"quantity":0.051009,"quoteQuantity":438.16782009,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-01T12:56:36.8Z","fee":0.00005101,"feeCurrency":"BTC"},{"gid":888,"id":255445099,"exchange":"binance","price":8590.01,"quantity":0.028991,"quoteQuantity":249.03297991,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-01T12:56:40.207Z","fee":0.00002899,"feeCurrency":"BTC"},{"gid":889,"id":255447064,"exchange":"binance","price":8579.22,"quantity":0.01,"quoteQuantity":85.7922,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-01T13:00:12.994Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":890,"id":255447164,"exchange":"binance","price":8584.21,"quantity":0.02,"quoteQuantity":171.6842,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-01T13:00:25.449Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":891,"id":255450228,"exchange":"binance","price":8556,"quantity":0.01,"quoteQuantity":85.56,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-01T13:05:16.388Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":892,"id":255450796,"exchange":"binance","price":8551,"quantity":0.01,"quoteQuantity":85.51,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-01T13:06:39.324Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":893,"id":255451142,"exchange":"binance","price":8551,"quantity":0.01,"quoteQuantity":85.51,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-01T13:06:56.062Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":894,"id":255539067,"exchange":"binance","price":8470.12,"quantity":0.01,"quoteQuantity":84.7012,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-01T16:54:30.736Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":895,"id":255721164,"exchange":"binance","price":8620.71,"quantity":0.000027,"quoteQuantity":0.23275917,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-02T02:36:24.183Z","fee":0.00023276,"feeCurrency":"USDT"},{"gid":896,"id":255721246,"exchange":"binance","price":8620.97,"quantity":0.02,"quoteQuantity":172.4194,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-02T02:36:38.163Z","fee":0.1724194,"feeCurrency":"USDT"},{"gid":897,"id":255721318,"exchange":"binance","price":8620.15,"quantity":0.008638,"quoteQuantity":74.4608557,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-02T02:37:00.735Z","fee":0.07446086,"feeCurrency":"USDT"},{"gid":898,"id":255721319,"exchange":"binance","price":8620.14,"quantity":0.002319,"quoteQuantity":19.99010466,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-02T02:37:00.735Z","fee":0.0199901,"feeCurrency":"USDT"},{"gid":899,"id":255721320,"exchange":"binance","price":8620.03,"quantity":0.009043,"quoteQuantity":77.95093129,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-02T02:37:00.735Z","fee":0.07795093,"feeCurrency":"USDT"},{"gid":900,"id":255721530,"exchange":"binance","price":8614.02,"quantity":0.02,"quoteQuantity":172.2804,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-02T02:37:38.225Z","fee":0.1722804,"feeCurrency":"USDT"},{"gid":901,"id":255721621,"exchange":"binance","price":8613.32,"quantity":0.002321,"quoteQuantity":19.99151572,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-02T02:37:54.979Z","fee":0.01999152,"feeCurrency":"USDT"},{"gid":902,"id":255721622,"exchange":"binance","price":8612.79,"quantity":0.017679,"quoteQuantity":152.26551441,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-02T02:37:54.979Z","fee":0.15226551,"feeCurrency":"USDT"},{"gid":903,"id":255721782,"exchange":"binance","price":8610.97,"quantity":0.01,"quoteQuantity":86.1097,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-02T02:38:30.819Z","fee":0.0861097,"feeCurrency":"USDT"},{"gid":904,"id":255721970,"exchange":"binance","price":8603.52,"quantity":0.01,"quoteQuantity":86.0352,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-02T02:39:05.125Z","fee":0.0860352,"feeCurrency":"USDT"},{"gid":905,"id":255722106,"exchange":"binance","price":8603.99,"quantity":0.01,"quoteQuantity":86.0399,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-02T02:39:23.157Z","fee":0.0860399,"feeCurrency":"USDT"},{"gid":906,"id":255722155,"exchange":"binance","price":8603.67,"quantity":0.01,"quoteQuantity":86.0367,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-02T02:39:37.628Z","fee":0.0860367,"feeCurrency":"USDT"},{"gid":907,"id":255722381,"exchange":"binance","price":8611.05,"quantity":0.02,"quoteQuantity":172.221,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-02T02:40:29.928Z","fee":0.172221,"feeCurrency":"USDT"},{"gid":908,"id":255725708,"exchange":"binance","price":8624,"quantity":0.1,"quoteQuantity":862.4,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-02T02:52:04.767Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":909,"id":255725825,"exchange":"binance","price":8624,"quantity":0.1,"quoteQuantity":862.4,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-02T02:52:17.091Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":910,"id":255781698,"exchange":"binance","price":8647.9,"quantity":0.088828,"quoteQuantity":768.1756612,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-02T06:04:56.803Z","fee":0.00008883,"feeCurrency":"BTC"},{"gid":911,"id":255781699,"exchange":"binance","price":8647.93,"quantity":0.088827,"quoteQuantity":768.16967811,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-02T06:04:56.803Z","fee":0.00008883,"feeCurrency":"BTC"},{"gid":912,"id":255781725,"exchange":"binance","price":8648,"quantity":0.016321,"quoteQuantity":141.144008,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-02T06:05:04.5Z","fee":0.00001632,"feeCurrency":"BTC"},{"gid":913,"id":255839511,"exchange":"binance","price":8670,"quantity":0.01,"quoteQuantity":86.7,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-02T08:59:14.869Z","fee":0.0867,"feeCurrency":"USDT"},{"gid":914,"id":255888946,"exchange":"binance","price":8762.97,"quantity":0.01,"quoteQuantity":87.6297,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-02T10:38:28.508Z","fee":0.0876297,"feeCurrency":"USDT"},{"gid":915,"id":255967483,"exchange":"binance","price":8826.05,"quantity":0.001652,"quoteQuantity":14.5806346,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-02T13:26:49.505Z","fee":0.01458063,"feeCurrency":"USDT"},{"gid":916,"id":255967484,"exchange":"binance","price":8826.02,"quantity":0.008348,"quoteQuantity":73.67961496,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-02T13:26:49.505Z","fee":0.07367961,"feeCurrency":"USDT"},{"gid":917,"id":256249394,"exchange":"binance","price":8844,"quantity":0.060262,"quoteQuantity":532.957128,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-03T00:16:26.405Z","fee":0.53295713,"feeCurrency":"USDT"},{"gid":918,"id":256249429,"exchange":"binance","price":8844,"quantity":0.039738,"quoteQuantity":351.442872,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-03T00:16:33.218Z","fee":0.35144287,"feeCurrency":"USDT"},{"gid":919,"id":256249768,"exchange":"binance","price":8850.02,"quantity":0.032738,"quoteQuantity":289.73195476,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-03T00:17:49.813Z","fee":0.00003274,"feeCurrency":"BTC"},{"gid":920,"id":256249774,"exchange":"binance","price":8850.02,"quantity":0.067262,"quoteQuantity":595.27004524,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-03T00:17:51.351Z","fee":0.00006726,"feeCurrency":"BTC"},{"gid":921,"id":256330559,"exchange":"binance","price":8822,"quantity":0.171073,"quoteQuantity":1509.206006,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-03T05:14:42.391Z","fee":1.50920601,"feeCurrency":"USDT"},{"gid":922,"id":256330560,"exchange":"binance","price":8822,"quantity":0.028927,"quoteQuantity":255.193994,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-03T05:14:42.412Z","fee":0.25519399,"feeCurrency":"USDT"},{"gid":923,"id":256331485,"exchange":"binance","price":8826.3,"quantity":0.24,"quoteQuantity":2118.312,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-03T05:18:29.014Z","fee":2.118312,"feeCurrency":"USDT"},{"gid":924,"id":256356318,"exchange":"binance","price":8795,"quantity":0.05,"quoteQuantity":439.75,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-03T06:59:14.421Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":925,"id":256359305,"exchange":"binance","price":8781.59,"quantity":0.099999,"quoteQuantity":878.15021841,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-03T07:01:20.988Z","fee":0.87815022,"feeCurrency":"USDT"},{"gid":926,"id":256359306,"exchange":"binance","price":8781.59,"quantity":0.000001,"quoteQuantity":0.00878159,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-03T07:01:21Z","fee":0.00000878,"feeCurrency":"USDT"},{"gid":927,"id":256360816,"exchange":"binance","price":8780,"quantity":0.05,"quoteQuantity":439,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-03T07:04:28.296Z","fee":0.439,"feeCurrency":"USDT"},{"gid":928,"id":256395375,"exchange":"binance","price":8789,"quantity":0.1,"quoteQuantity":878.9,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-03T08:42:59.651Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":929,"id":256395382,"exchange":"binance","price":8788,"quantity":0.1,"quoteQuantity":878.8,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-03T08:43:00.025Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":930,"id":256395420,"exchange":"binance","price":8787,"quantity":0.1,"quoteQuantity":878.7,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-03T08:43:06.587Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":931,"id":256397189,"exchange":"binance","price":8777.64,"quantity":0.4,"quoteQuantity":3511.056,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-03T08:49:47.704Z","fee":3.511056,"feeCurrency":"USDT"},{"gid":932,"id":256431116,"exchange":"binance","price":8848.75,"quantity":0.1,"quoteQuantity":884.875,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-03T10:29:01.836Z","fee":0.03331112,"feeCurrency":"BNB"},{"gid":933,"id":256431588,"exchange":"binance","price":8850,"quantity":0.1,"quoteQuantity":885,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-03T10:29:36.308Z","fee":0.03331112,"feeCurrency":"BNB"},{"gid":934,"id":256431727,"exchange":"binance","price":8850,"quantity":0.1,"quoteQuantity":885,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-03T10:29:42.213Z","fee":0.03331112,"feeCurrency":"BNB"},{"gid":935,"id":256431731,"exchange":"binance","price":8850,"quantity":0.1,"quoteQuantity":885,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-03T10:29:42.557Z","fee":0.03331112,"feeCurrency":"BNB"},{"gid":936,"id":256431847,"exchange":"binance","price":8850,"quantity":0.088129,"quoteQuantity":779.94165,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-03T10:29:46.823Z","fee":0.02935709,"feeCurrency":"BNB"},{"gid":937,"id":256431849,"exchange":"binance","price":8850,"quantity":0.011871,"quoteQuantity":105.05835,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-03T10:29:46.86Z","fee":0.00395403,"feeCurrency":"BNB"},{"gid":938,"id":256482560,"exchange":"binance","price":8842.74,"quantity":0.02,"quoteQuantity":176.8548,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-03T12:40:53.899Z","fee":0.00658269,"feeCurrency":"BNB"},{"gid":939,"id":256488089,"exchange":"binance","price":8830.1,"quantity":0.01,"quoteQuantity":88.301,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-03T12:55:02.959Z","fee":0.00330527,"feeCurrency":"BNB"},{"gid":940,"id":256491520,"exchange":"binance","price":8781.99,"quantity":0.01,"quoteQuantity":87.8199,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-03T12:56:24.706Z","fee":0.00330192,"feeCurrency":"BNB"},{"gid":941,"id":256493118,"exchange":"binance","price":8786.32,"quantity":0.01,"quoteQuantity":87.8632,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-03T12:57:29.947Z","fee":0.00330556,"feeCurrency":"BNB"},{"gid":942,"id":256538138,"exchange":"binance","price":8735.8,"quantity":0.05,"quoteQuantity":436.79,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-03T14:43:14.329Z","fee":0.01671528,"feeCurrency":"BNB"},{"gid":943,"id":256538398,"exchange":"binance","price":8732.22,"quantity":0.1,"quoteQuantity":873.222,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-03T14:43:59.908Z","fee":0.03341686,"feeCurrency":"BNB"},{"gid":944,"id":256538677,"exchange":"binance","price":8734.46,"quantity":0.00003,"quoteQuantity":0.2620338,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-03T14:45:15.136Z","fee":0.00001,"feeCurrency":"BNB"},{"gid":945,"id":256538678,"exchange":"binance","price":8734.24,"quantity":0.009989,"quoteQuantity":87.24632336,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-03T14:45:15.136Z","fee":0.00333368,"feeCurrency":"BNB"},{"gid":946,"id":256538696,"exchange":"binance","price":8734,"quantity":0.012311,"quoteQuantity":107.524274,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-03T14:45:20.26Z","fee":0.0041085,"feeCurrency":"BNB"},{"gid":947,"id":256538699,"exchange":"binance","price":8734,"quantity":0.02767,"quoteQuantity":241.66978,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-03T14:45:20.363Z","fee":0.00923421,"feeCurrency":"BNB"},{"gid":948,"id":256538929,"exchange":"binance","price":8738.49,"quantity":0.05,"quoteQuantity":436.9245,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-03T14:45:55.916Z","fee":0.01669489,"feeCurrency":"BNB"},{"gid":949,"id":256538954,"exchange":"binance","price":8739,"quantity":0.017827,"quoteQuantity":155.790153,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-03T14:45:58.701Z","fee":0.00595274,"feeCurrency":"BNB"},{"gid":950,"id":256538955,"exchange":"binance","price":8739,"quantity":0.002289,"quoteQuantity":20.003571,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-03T14:45:58.714Z","fee":0.00076433,"feeCurrency":"BNB"},{"gid":951,"id":256538956,"exchange":"binance","price":8739,"quantity":0.079821,"quoteQuantity":697.555719,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-03T14:45:58.824Z","fee":0.02665362,"feeCurrency":"BNB"},{"gid":952,"id":256538957,"exchange":"binance","price":8739,"quantity":0.000063,"quoteQuantity":0.550557,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-03T14:45:58.843Z","fee":0.00002103,"feeCurrency":"BNB"},{"gid":953,"id":256560247,"exchange":"binance","price":8837.39,"quantity":0.1,"quoteQuantity":883.739,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-03T15:10:31.203Z","fee":0.03340022,"feeCurrency":"BNB"},{"gid":954,"id":256578035,"exchange":"binance","price":8692.01,"quantity":0.1,"quoteQuantity":869.201,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-03T15:21:22.535Z","fee":0.03344331,"feeCurrency":"BNB"},{"gid":955,"id":256582100,"exchange":"binance","price":8711.57,"quantity":0.1,"quoteQuantity":871.157,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-03T15:26:21.225Z","fee":0.03339285,"feeCurrency":"BNB"},{"gid":956,"id":256770192,"exchange":"binance","price":8782.66,"quantity":0.01,"quoteQuantity":87.8266,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-04T00:54:09.077Z","fee":0.00333205,"feeCurrency":"BNB"},{"gid":957,"id":256770354,"exchange":"binance","price":8783,"quantity":0.002717,"quoteQuantity":23.863411,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-04T00:55:09.177Z","fee":0.000905,"feeCurrency":"BNB"},{"gid":958,"id":256770355,"exchange":"binance","price":8783,"quantity":0.007283,"quoteQuantity":63.966589,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-04T00:55:09.295Z","fee":0.00242589,"feeCurrency":"BNB"},{"gid":959,"id":256866295,"exchange":"binance","price":8802.1,"quantity":0.05,"quoteQuantity":440.105,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-04T06:17:50.692Z","fee":0.01639487,"feeCurrency":"BNB"},{"gid":960,"id":256905588,"exchange":"binance","price":8803,"quantity":0.02,"quoteQuantity":176.06,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-04T08:09:54.968Z","fee":0.00658009,"feeCurrency":"BNB"},{"gid":961,"id":256985477,"exchange":"binance","price":8739.42,"quantity":0.006102,"quoteQuantity":53.32794084,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-04T13:08:39.783Z","fee":0.00200499,"feeCurrency":"BNB"},{"gid":962,"id":256985478,"exchange":"binance","price":8739.42,"quantity":0.003898,"quoteQuantity":34.06625916,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-04T13:08:39.834Z","fee":0.00128187,"feeCurrency":"BNB"},{"gid":963,"id":257030471,"exchange":"binance","price":8727.76,"quantity":0.000097,"quoteQuantity":0.84659272,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-04T14:51:37.283Z","fee":0.00003303,"feeCurrency":"BNB"},{"gid":964,"id":257030490,"exchange":"binance","price":8728.41,"quantity":0.009903,"quoteQuantity":86.43744423,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-04T14:51:38.95Z","fee":0.00327048,"feeCurrency":"BNB"},{"gid":965,"id":257044160,"exchange":"binance","price":8682.01,"quantity":0.01,"quoteQuantity":86.8201,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-04T15:07:30.362Z","fee":0.00331081,"feeCurrency":"BNB"},{"gid":966,"id":257054588,"exchange":"binance","price":8720.86,"quantity":0.01,"quoteQuantity":87.2086,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-04T15:19:00.88Z","fee":0.00329848,"feeCurrency":"BNB"},{"gid":967,"id":257054620,"exchange":"binance","price":8723.66,"quantity":0.002291,"quoteQuantity":19.98590506,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-04T15:19:05.568Z","fee":0.00075683,"feeCurrency":"BNB"},{"gid":968,"id":257054621,"exchange":"binance","price":8723.24,"quantity":0.007709,"quoteQuantity":67.24745716,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-04T15:19:05.568Z","fee":0.00254655,"feeCurrency":"BNB"},{"gid":969,"id":257058385,"exchange":"binance","price":8757.2,"quantity":0.01,"quoteQuantity":87.572,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-04T15:25:46.592Z","fee":0.0033043,"feeCurrency":"BNB"},{"gid":970,"id":257110739,"exchange":"binance","price":8691,"quantity":0.01,"quoteQuantity":86.91,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-04T17:37:30.57Z","fee":0.00331023,"feeCurrency":"BNB"},{"gid":971,"id":257121768,"exchange":"binance","price":8681,"quantity":0.02,"quoteQuantity":173.62,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-04T18:21:55.799Z","fee":0.00666636,"feeCurrency":"BNB"},{"gid":972,"id":257186827,"exchange":"binance","price":8742.47,"quantity":0.02,"quoteQuantity":174.8494,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-04T22:29:14.783Z","fee":0.00657411,"feeCurrency":"BNB"},{"gid":973,"id":257187653,"exchange":"binance","price":8741.25,"quantity":0.05,"quoteQuantity":437.0625,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-04T22:32:10.04Z","fee":0.01644726,"feeCurrency":"BNB"},{"gid":974,"id":257187728,"exchange":"binance","price":8740.42,"quantity":0.05,"quoteQuantity":437.021,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-04T22:32:22.974Z","fee":0.01644569,"feeCurrency":"BNB"},{"gid":975,"id":257258264,"exchange":"binance","price":8860.56,"quantity":0.064303,"quoteQuantity":569.76058968,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-05T01:09:53.959Z","fee":0.02113043,"feeCurrency":"BNB"},{"gid":976,"id":257267556,"exchange":"binance","price":8860.58,"quantity":0.010231,"quoteQuantity":90.65259398,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-05T01:24:22.072Z","fee":0.00336365,"feeCurrency":"BNB"},{"gid":977,"id":257267557,"exchange":"binance","price":8860.58,"quantity":0.089769,"quoteQuantity":795.40540602,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-05T01:24:22.104Z","fee":0.02951665,"feeCurrency":"BNB"},{"gid":978,"id":257268300,"exchange":"binance","price":8849.95,"quantity":0.1,"quoteQuantity":884.995,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-05T01:26:08.994Z","fee":0.03286802,"feeCurrency":"BNB"},{"gid":979,"id":257270440,"exchange":"binance","price":8859.21,"quantity":0.023158,"quoteQuantity":205.16158518,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-05T01:30:51.477Z","fee":0.00761808,"feeCurrency":"BNB"},{"gid":980,"id":257270441,"exchange":"binance","price":8859.21,"quantity":0.02684,"quoteQuantity":237.7811964,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-05T01:30:51.54Z","fee":0.00882855,"feeCurrency":"BNB"},{"gid":981,"id":257270442,"exchange":"binance","price":8859.21,"quantity":0.000002,"quoteQuantity":0.01771842,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-05T01:30:51.603Z","fee":0.00000328,"feeCurrency":"BNB"},{"gid":982,"id":257270587,"exchange":"binance","price":8856.37,"quantity":0.020549,"quoteQuantity":181.98954713,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-05T01:31:14.721Z","fee":0.00675957,"feeCurrency":"BNB"},{"gid":983,"id":257270588,"exchange":"binance","price":8856.37,"quantity":0.002258,"quoteQuantity":19.99768346,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-05T01:31:14.797Z","fee":0.00074338,"feeCurrency":"BNB"},{"gid":984,"id":257270599,"exchange":"binance","price":8856.37,"quantity":0.022554,"quoteQuantity":199.74656898,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-05T01:31:15.701Z","fee":0.00741743,"feeCurrency":"BNB"},{"gid":985,"id":257270600,"exchange":"binance","price":8856.37,"quantity":0.004639,"quoteQuantity":41.08470043,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-05T01:31:15.701Z","fee":0.00152624,"feeCurrency":"BNB"},{"gid":986,"id":257277675,"exchange":"binance","price":8844.57,"quantity":0.1,"quoteQuantity":884.457,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-05T01:48:47.391Z","fee":0.03292392,"feeCurrency":"BNB"},{"gid":987,"id":257297030,"exchange":"binance","price":8926.63,"quantity":0.2,"quoteQuantity":1785.326,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-05T02:36:35.631Z","fee":0.06552507,"feeCurrency":"BNB"},{"gid":988,"id":257297241,"exchange":"binance","price":8918.75,"quantity":0.096664,"quoteQuantity":862.12205,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-05T02:36:43.122Z","fee":0.03166826,"feeCurrency":"BNB"},{"gid":989,"id":257297242,"exchange":"binance","price":8918.76,"quantity":0.103336,"quoteQuantity":921.62898336,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-05T02:36:43.122Z","fee":0.00010334,"feeCurrency":"BTC"},{"gid":990,"id":257299884,"exchange":"binance","price":8915,"quantity":0.00901,"quoteQuantity":80.32415,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-05T02:41:39.139Z","fee":0.00294636,"feeCurrency":"BNB"},{"gid":991,"id":257299885,"exchange":"binance","price":8915,"quantity":0.00099,"quoteQuantity":8.82585,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-05T02:41:39.152Z","fee":0.00032373,"feeCurrency":"BNB"},{"gid":992,"id":257302220,"exchange":"binance","price":8936.03,"quantity":0.1,"quoteQuantity":893.603,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-05T02:45:27.938Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":993,"id":257309983,"exchange":"binance","price":8912,"quantity":0.01,"quoteQuantity":89.12,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-05T03:03:56.09Z","fee":0.0032637,"feeCurrency":"BNB"},{"gid":994,"id":257325614,"exchange":"binance","price":8896,"quantity":0.399897,"quoteQuantity":3557.483712,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-05T03:41:52.609Z","fee":3.55748371,"feeCurrency":"USDT"},{"gid":995,"id":257365940,"exchange":"binance","price":8913.4,"quantity":0.063144,"quoteQuantity":562.8277296,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-05T05:41:16.068Z","fee":0.56282773,"feeCurrency":"USDT"},{"gid":996,"id":257365941,"exchange":"binance","price":8913.4,"quantity":0.056756,"quoteQuantity":505.8889304,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-05T05:41:16.068Z","fee":0.50588893,"feeCurrency":"USDT"},{"gid":997,"id":257398707,"exchange":"binance","price":8919,"quantity":0.01,"quoteQuantity":89.19,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-05T07:13:01.677Z","fee":0.00322358,"feeCurrency":"BNB"},{"gid":998,"id":257402934,"exchange":"binance","price":8899.22,"quantity":0.01,"quoteQuantity":88.9922,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-05T07:29:36.379Z","fee":0.00322566,"feeCurrency":"BNB"},{"gid":999,"id":257402949,"exchange":"binance","price":8899.47,"quantity":0.01,"quoteQuantity":88.9947,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-05T07:29:41.011Z","fee":0.00322566,"feeCurrency":"BNB"},{"gid":1000,"id":257425941,"exchange":"binance","price":8895.01,"quantity":0.044022,"quoteQuantity":391.57613022,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-05T08:41:34.996Z","fee":0.00004402,"feeCurrency":"BTC"},{"gid":1001,"id":257425942,"exchange":"binance","price":8895.01,"quantity":0.04402,"quoteQuantity":391.5583402,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-05T08:41:35.732Z","fee":0.00004402,"feeCurrency":"BTC"},{"gid":1002,"id":257425943,"exchange":"binance","price":8895.01,"quantity":0.011946,"quoteQuantity":106.25978946,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-05T08:41:35.954Z","fee":0.00385383,"feeCurrency":"BNB"},{"gid":1003,"id":257425948,"exchange":"binance","price":8895.01,"quantity":0.000012,"quoteQuantity":0.10674012,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-05T08:41:38.169Z","fee":0.00000321,"feeCurrency":"BNB"},{"gid":1004,"id":257470849,"exchange":"binance","price":9059.28,"quantity":0.029844,"quoteQuantity":270.36515232,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-05T09:44:00.122Z","fee":0.00002984,"feeCurrency":"BTC"},{"gid":1005,"id":257470850,"exchange":"binance","price":9059.29,"quantity":0.087042,"quoteQuantity":788.53872018,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-05T09:44:00.122Z","fee":0.00008704,"feeCurrency":"BTC"},{"gid":1006,"id":257470851,"exchange":"binance","price":9059.33,"quantity":0.449828,"quoteQuantity":4075.14029524,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-05T09:44:00.122Z","fee":0.00044983,"feeCurrency":"BTC"},{"gid":1007,"id":257515437,"exchange":"binance","price":9080.59,"quantity":0.2,"quoteQuantity":1816.118,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-05T11:02:17.074Z","fee":1.816118,"feeCurrency":"USDT"},{"gid":1008,"id":257539676,"exchange":"binance","price":9068.79,"quantity":0.052658,"quoteQuantity":477.54434382,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-05T11:38:12.594Z","fee":0.47754434,"feeCurrency":"USDT"},{"gid":1009,"id":257539677,"exchange":"binance","price":9068.64,"quantity":0.28125,"quoteQuantity":2550.555,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-05T11:38:12.594Z","fee":2.550555,"feeCurrency":"USDT"},{"gid":1010,"id":257539678,"exchange":"binance","price":9068.59,"quantity":0.162151,"quoteQuantity":1470.48093709,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-05T11:38:12.594Z","fee":1.47048094,"feeCurrency":"USDT"},{"gid":1011,"id":257825282,"exchange":"binance","price":9032.68,"quantity":0.077751,"quoteQuantity":702.29990268,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-05T23:51:31.989Z","fee":0.00007775,"feeCurrency":"BTC"},{"gid":1012,"id":257825283,"exchange":"binance","price":9032.7,"quantity":0.022249,"quoteQuantity":200.9685423,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-05T23:51:31.989Z","fee":0.00002225,"feeCurrency":"BTC"},{"gid":1013,"id":257836446,"exchange":"binance","price":9047.55,"quantity":0.087168,"quoteQuantity":788.6568384,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-06T00:20:20.999Z","fee":0.00008717,"feeCurrency":"BTC"},{"gid":1014,"id":257836447,"exchange":"binance","price":9047.59,"quantity":0.012832,"quoteQuantity":116.09867488,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-06T00:20:20.999Z","fee":0.00001283,"feeCurrency":"BTC"},{"gid":1015,"id":257841374,"exchange":"binance","price":9046.56,"quantity":0.01,"quoteQuantity":90.4656,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-06T00:36:44.974Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1016,"id":257856625,"exchange":"binance","price":9019.43,"quantity":0.00125,"quoteQuantity":11.2742875,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-06T01:04:27.447Z","fee":0.00000125,"feeCurrency":"BTC"},{"gid":1017,"id":257856629,"exchange":"binance","price":9019.43,"quantity":0.002438,"quoteQuantity":21.98937034,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-06T01:04:28.64Z","fee":0.00000244,"feeCurrency":"BTC"},{"gid":1018,"id":257856630,"exchange":"binance","price":9019.43,"quantity":0.002217,"quoteQuantity":19.99607631,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-06T01:04:28.737Z","fee":0.00000222,"feeCurrency":"BTC"},{"gid":1019,"id":257856633,"exchange":"binance","price":9019.43,"quantity":0.016,"quoteQuantity":144.31088,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-06T01:04:28.918Z","fee":0.000016,"feeCurrency":"BTC"},{"gid":1020,"id":257856634,"exchange":"binance","price":9019.43,"quantity":0.002441,"quoteQuantity":22.01642863,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-06T01:04:28.983Z","fee":0.00000244,"feeCurrency":"BTC"},{"gid":1021,"id":257856635,"exchange":"binance","price":9019.43,"quantity":0.002442,"quoteQuantity":22.02544806,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-06T01:04:29.149Z","fee":0.00000244,"feeCurrency":"BTC"},{"gid":1022,"id":257856636,"exchange":"binance","price":9019.43,"quantity":0.001325,"quoteQuantity":11.95074475,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-06T01:04:29.274Z","fee":0.00000133,"feeCurrency":"BTC"},{"gid":1023,"id":257856637,"exchange":"binance","price":9019.43,"quantity":0.002217,"quoteQuantity":19.99607631,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-06T01:04:29.335Z","fee":0.00000222,"feeCurrency":"BTC"},{"gid":1024,"id":257856638,"exchange":"binance","price":9019.43,"quantity":0.00125,"quoteQuantity":11.2742875,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-06T01:04:29.436Z","fee":0.00000125,"feeCurrency":"BTC"},{"gid":1025,"id":257856639,"exchange":"binance","price":9019.43,"quantity":0.008369,"quoteQuantity":75.48360967,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-06T01:04:29.452Z","fee":0.00000837,"feeCurrency":"BTC"},{"gid":1026,"id":257856640,"exchange":"binance","price":9019.43,"quantity":0.060051,"quoteQuantity":541.62579093,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-06T01:04:29.493Z","fee":0.00006005,"feeCurrency":"BTC"},{"gid":1027,"id":257976621,"exchange":"binance","price":9131.23,"quantity":0.01,"quoteQuantity":91.3123,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-06T07:34:33.424Z","fee":0.0913123,"feeCurrency":"USDT"},{"gid":1028,"id":257990238,"exchange":"binance","price":9100,"quantity":0.01,"quoteQuantity":91,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-06T08:11:51.745Z","fee":0.091,"feeCurrency":"USDT"},{"gid":1029,"id":257993960,"exchange":"binance","price":9078.39,"quantity":0.000037,"quoteQuantity":0.33590043,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-06T08:18:07.092Z","fee":0.00001284,"feeCurrency":"BNB"},{"gid":1030,"id":257993961,"exchange":"binance","price":9079.41,"quantity":0.009963,"quoteQuantity":90.45816183,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-06T08:18:07.113Z","fee":0.00000996,"feeCurrency":"BTC"},{"gid":1031,"id":258048248,"exchange":"binance","price":9136.67,"quantity":0.01,"quoteQuantity":91.3667,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-06T10:53:03.096Z","fee":0.0913667,"feeCurrency":"USDT"},{"gid":1032,"id":258048265,"exchange":"binance","price":9136.66,"quantity":0.01,"quoteQuantity":91.3666,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-06T10:53:11.961Z","fee":0.0913666,"feeCurrency":"USDT"},{"gid":1033,"id":258083214,"exchange":"binance","price":9118.63,"quantity":0.01,"quoteQuantity":91.1863,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-06T12:06:21.393Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1034,"id":258083345,"exchange":"binance","price":9119.12,"quantity":0.01,"quoteQuantity":91.1912,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-06T12:06:32.735Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1035,"id":258131656,"exchange":"binance","price":9056.98,"quantity":0.02,"quoteQuantity":181.1396,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-06T13:58:39.462Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1036,"id":258132095,"exchange":"binance","price":9052.24,"quantity":0.02,"quoteQuantity":181.0448,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-06T13:59:16.484Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1037,"id":258167935,"exchange":"binance","price":9058.74,"quantity":0.01,"quoteQuantity":90.5874,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-06T15:35:53.885Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1038,"id":258168352,"exchange":"binance","price":9046.41,"quantity":0.01,"quoteQuantity":90.4641,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-06T15:36:47.648Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1039,"id":258177655,"exchange":"binance","price":9029.67,"quantity":0.01,"quoteQuantity":90.2967,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-06T15:52:07.978Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1040,"id":258219476,"exchange":"binance","price":9058.82,"quantity":0.01,"quoteQuantity":90.5882,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-06T17:13:46.267Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1041,"id":258360549,"exchange":"binance","price":9090.18,"quantity":0.05146,"quoteQuantity":467.7806628,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-07T02:36:11.441Z","fee":0.00005146,"feeCurrency":"BTC"},{"gid":1042,"id":258360550,"exchange":"binance","price":9090.2,"quantity":0.14854,"quoteQuantity":1350.258308,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-07T02:36:11.441Z","fee":0.00014854,"feeCurrency":"BTC"},{"gid":1043,"id":258418368,"exchange":"binance","price":9073.79,"quantity":0.099998,"quoteQuantity":907.36085242,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-07T07:06:11.132Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1044,"id":258418371,"exchange":"binance","price":9073.79,"quantity":0.000002,"quoteQuantity":0.01814758,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-07T07:06:11.251Z","fee":0.00000324,"feeCurrency":"BNB"},{"gid":1045,"id":258431546,"exchange":"binance","price":9087.6,"quantity":0.060101,"quoteQuantity":546.1738476,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-07T07:57:29.782Z","fee":0.0000601,"feeCurrency":"BTC"},{"gid":1046,"id":258431547,"exchange":"binance","price":9087.6,"quantity":0.005,"quoteQuantity":45.438,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-07T07:57:29.903Z","fee":0.000005,"feeCurrency":"BTC"},{"gid":1047,"id":258431549,"exchange":"binance","price":9087.6,"quantity":0.034899,"quoteQuantity":317.1481524,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-07T07:57:30.168Z","fee":0.0000349,"feeCurrency":"BTC"},{"gid":1048,"id":258448771,"exchange":"binance","price":9106.75,"quantity":0.001971,"quoteQuantity":17.94940425,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-07T08:53:18.453Z","fee":0.00000197,"feeCurrency":"BTC"},{"gid":1049,"id":258448775,"exchange":"binance","price":9106.75,"quantity":0.001971,"quoteQuantity":17.94940425,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-07T08:53:21.022Z","fee":0.00000197,"feeCurrency":"BTC"},{"gid":1050,"id":258448788,"exchange":"binance","price":9107.99,"quantity":0.1,"quoteQuantity":910.799,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-07T08:53:28.093Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1051,"id":258624775,"exchange":"binance","price":9062.48,"quantity":0.3,"quoteQuantity":2718.744,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-07T16:48:50.994Z","fee":2.718744,"feeCurrency":"USDT"},{"gid":1052,"id":258625548,"exchange":"binance","price":9070.07,"quantity":0.05,"quoteQuantity":453.5035,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-07T16:49:59.785Z","fee":0.4535035,"feeCurrency":"USDT"},{"gid":1053,"id":258625792,"exchange":"binance","price":9065.69,"quantity":0.05,"quoteQuantity":453.2845,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-07T16:50:22.235Z","fee":0.4532845,"feeCurrency":"USDT"},{"gid":1054,"id":258625915,"exchange":"binance","price":9063.93,"quantity":0.05,"quoteQuantity":453.1965,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-07T16:50:32.728Z","fee":0.4531965,"feeCurrency":"USDT"},{"gid":1055,"id":258626130,"exchange":"binance","price":9065.01,"quantity":0.044365,"quoteQuantity":402.16916865,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-07T16:50:51.719Z","fee":0.40216917,"feeCurrency":"USDT"},{"gid":1056,"id":258626131,"exchange":"binance","price":9065,"quantity":0.035635,"quoteQuantity":323.031275,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-07T16:50:51.719Z","fee":0.32303128,"feeCurrency":"USDT"},{"gid":1057,"id":258626357,"exchange":"binance","price":9065,"quantity":0.05,"quoteQuantity":453.25,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-07T16:51:13.233Z","fee":0.45325,"feeCurrency":"USDT"},{"gid":1058,"id":258634860,"exchange":"binance","price":9029.68,"quantity":0.05,"quoteQuantity":451.484,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-07T16:58:16.342Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1059,"id":258642194,"exchange":"binance","price":8952.16,"quantity":0.023351,"quoteQuantity":209.04188816,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-07T17:00:57.927Z","fee":0.20904189,"feeCurrency":"USDT"},{"gid":1060,"id":258642195,"exchange":"binance","price":8948.48,"quantity":0.131387,"quoteQuantity":1175.71394176,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-07T17:00:57.927Z","fee":1.17571394,"feeCurrency":"USDT"},{"gid":1061,"id":258642196,"exchange":"binance","price":8946.75,"quantity":0.023898,"quoteQuantity":213.8094315,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-07T17:00:57.927Z","fee":0.21380943,"feeCurrency":"USDT"},{"gid":1062,"id":258642197,"exchange":"binance","price":8945.65,"quantity":0.021364,"quoteQuantity":191.1148666,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-07T17:00:57.927Z","fee":0.19111487,"feeCurrency":"USDT"},{"gid":1063,"id":258652472,"exchange":"binance","price":8939.02,"quantity":0.083513,"quoteQuantity":746.52437726,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-07T17:03:53.739Z","fee":0.74652438,"feeCurrency":"USDT"},{"gid":1064,"id":258652473,"exchange":"binance","price":8935.13,"quantity":0.069455,"quoteQuantity":620.58945415,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-07T17:03:53.739Z","fee":0.62058945,"feeCurrency":"USDT"},{"gid":1065,"id":259597241,"exchange":"binance","price":8066.32,"quantity":0.01,"quoteQuantity":80.6632,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-09T01:12:05.057Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1066,"id":259612142,"exchange":"binance","price":8105,"quantity":0.01,"quoteQuantity":81.05,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-09T01:40:39.649Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1067,"id":259620387,"exchange":"binance","price":8097.51,"quantity":0.009048,"quoteQuantity":73.26627048,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-09T01:58:17.832Z","fee":0.07326627,"feeCurrency":"USDT"},{"gid":1068,"id":259620388,"exchange":"binance","price":8097.51,"quantity":0.010932,"quoteQuantity":88.52197932,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-09T01:58:17.934Z","fee":0.08852198,"feeCurrency":"USDT"},{"gid":1069,"id":259729058,"exchange":"binance","price":7853.6,"quantity":0.01,"quoteQuantity":78.536,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-09T04:42:48.246Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1070,"id":259729207,"exchange":"binance","price":7850,"quantity":0.01,"quoteQuantity":78.5,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-09T04:43:01.726Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1071,"id":259753887,"exchange":"binance","price":7792.22,"quantity":0.01998,"quoteQuantity":155.6885556,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-09T05:19:13.448Z","fee":0.15568856,"feeCurrency":"USDT"},{"gid":1072,"id":259826280,"exchange":"binance","price":7928.01,"quantity":0.1,"quoteQuantity":792.801,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-09T06:10:11.378Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1073,"id":259834738,"exchange":"binance","price":7952.22,"quantity":0.1,"quoteQuantity":795.222,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-09T06:17:48.569Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1074,"id":259836990,"exchange":"binance","price":7914.67,"quantity":0.09,"quoteQuantity":712.3203,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-09T06:19:41.639Z","fee":0.7123203,"feeCurrency":"USDT"},{"gid":1075,"id":259845706,"exchange":"binance","price":7900,"quantity":0.071556,"quoteQuantity":565.2924,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-09T06:34:26.649Z","fee":0.5652924,"feeCurrency":"USDT"},{"gid":1076,"id":259845707,"exchange":"binance","price":7900,"quantity":0.038244,"quoteQuantity":302.1276,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-09T06:34:26.649Z","fee":0.3021276,"feeCurrency":"USDT"},{"gid":1077,"id":259883435,"exchange":"binance","price":7910.34,"quantity":0.1,"quoteQuantity":791.034,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-09T07:49:01.824Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1078,"id":259905840,"exchange":"binance","price":7934.27,"quantity":0.002521,"quoteQuantity":20.00229467,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-09T08:26:28.491Z","fee":0.00000252,"feeCurrency":"BTC"},{"gid":1079,"id":259905841,"exchange":"binance","price":7934.27,"quantity":0.054272,"quoteQuantity":430.60870144,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-09T08:26:28.969Z","fee":0.00005427,"feeCurrency":"BTC"},{"gid":1080,"id":259905844,"exchange":"binance","price":7934.27,"quantity":0.015867,"quoteQuantity":125.89306209,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-09T08:26:30.468Z","fee":0.00001587,"feeCurrency":"BTC"},{"gid":1081,"id":259905845,"exchange":"binance","price":7934.27,"quantity":0.015868,"quoteQuantity":125.90099636,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-09T08:26:30.468Z","fee":0.00001587,"feeCurrency":"BTC"},{"gid":1082,"id":259905848,"exchange":"binance","price":7934.27,"quantity":0.006,"quoteQuantity":47.60562,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-09T08:26:30.727Z","fee":0.000006,"feeCurrency":"BTC"},{"gid":1083,"id":259905850,"exchange":"binance","price":7934.27,"quantity":0.005,"quoteQuantity":39.67135,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-09T08:26:31.159Z","fee":0.000005,"feeCurrency":"BTC"},{"gid":1084,"id":259905853,"exchange":"binance","price":7934.27,"quantity":0.000472,"quoteQuantity":3.74497544,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-09T08:26:32.603Z","fee":0.00016262,"feeCurrency":"BNB"},{"gid":1085,"id":259938430,"exchange":"binance","price":7932,"quantity":0.001519,"quoteQuantity":12.048708,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-09T09:27:30.042Z","fee":0.00000152,"feeCurrency":"BTC"},{"gid":1086,"id":259938431,"exchange":"binance","price":7932,"quantity":0.008481,"quoteQuantity":67.271292,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-09T09:27:30.042Z","fee":0.00000848,"feeCurrency":"BTC"},{"gid":1087,"id":260125402,"exchange":"binance","price":7821,"quantity":0.02,"quoteQuantity":156.42,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-09T14:01:46.112Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1088,"id":260164089,"exchange":"binance","price":7710,"quantity":0.01,"quoteQuantity":77.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-09T14:55:53.78Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1089,"id":260164090,"exchange":"binance","price":7710,"quantity":0.01,"quoteQuantity":77.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-09T14:55:53.78Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1090,"id":260497503,"exchange":"binance","price":7940.88,"quantity":0.006556,"quoteQuantity":52.06040928,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-10T00:50:35.258Z","fee":0.00000656,"feeCurrency":"BTC"},{"gid":1091,"id":260497506,"exchange":"binance","price":7940.88,"quantity":0.006556,"quoteQuantity":52.06040928,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-10T00:50:36.524Z","fee":0.00000656,"feeCurrency":"BTC"},{"gid":1092,"id":260497508,"exchange":"binance","price":7940.88,"quantity":0.006884,"quoteQuantity":54.66501792,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-10T00:50:37.664Z","fee":0.00000688,"feeCurrency":"BTC"},{"gid":1093,"id":260497633,"exchange":"binance","price":7940.88,"quantity":0.000004,"quoteQuantity":0.03176352,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-10T00:51:14.014Z","fee":0.00000353,"feeCurrency":"BNB"},{"gid":1094,"id":260522583,"exchange":"binance","price":7911,"quantity":0.01,"quoteQuantity":79.11,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-10T01:54:04.329Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1095,"id":260542952,"exchange":"binance","price":7882,"quantity":0.02,"quoteQuantity":157.64,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-10T02:58:50.216Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1096,"id":260585087,"exchange":"binance","price":7915.39,"quantity":0.01,"quoteQuantity":79.1539,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T05:09:38.353Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1097,"id":260585438,"exchange":"binance","price":7913,"quantity":0.01,"quoteQuantity":79.13,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-10T05:11:01.051Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1098,"id":260624827,"exchange":"binance","price":7906,"quantity":0.01,"quoteQuantity":79.06,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-10T07:05:14.254Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1099,"id":260627241,"exchange":"binance","price":7898,"quantity":0.01,"quoteQuantity":78.98,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-10T07:11:51.203Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1100,"id":260647812,"exchange":"binance","price":7901.31,"quantity":0.01,"quoteQuantity":79.0131,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T07:58:15.183Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1101,"id":260676963,"exchange":"binance","price":7880,"quantity":0.01,"quoteQuantity":78.8,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-10T09:35:32.79Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1102,"id":260709381,"exchange":"binance","price":7980.15,"quantity":0.090926,"quoteQuantity":725.6031189,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-10T10:37:33.881Z","fee":0.72560312,"feeCurrency":"USDT"},{"gid":1103,"id":260709382,"exchange":"binance","price":7980.12,"quantity":0.009074,"quoteQuantity":72.41160888,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-10T10:37:33.881Z","fee":0.07241161,"feeCurrency":"USDT"},{"gid":1104,"id":260758601,"exchange":"binance","price":8081.84,"quantity":0.2,"quoteQuantity":1616.368,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T11:25:00.741Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":1105,"id":260758763,"exchange":"binance","price":8083.01,"quantity":0.000025,"quoteQuantity":0.20207525,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T11:25:20.962Z","fee":0.00001056,"feeCurrency":"BNB"},{"gid":1106,"id":260758764,"exchange":"binance","price":8083.5,"quantity":0.09606,"quoteQuantity":776.50101,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T11:25:20.962Z","fee":0.00009606,"feeCurrency":"BTC"},{"gid":1107,"id":260758765,"exchange":"binance","price":8083.57,"quantity":0.003915,"quoteQuantity":31.64717655,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T11:25:20.962Z","fee":0.00000392,"feeCurrency":"BTC"},{"gid":1108,"id":260763094,"exchange":"binance","price":8104.45,"quantity":0.000737,"quoteQuantity":5.97297965,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T11:30:09.202Z","fee":0.00026088,"feeCurrency":"BNB"},{"gid":1109,"id":260763095,"exchange":"binance","price":8104.5,"quantity":0.099263,"quoteQuantity":804.4769835,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T11:30:09.202Z","fee":0.00009926,"feeCurrency":"BTC"},{"gid":1110,"id":260765276,"exchange":"binance","price":8086.61,"quantity":0.104127,"quoteQuantity":842.03443947,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-10T11:32:00.134Z","fee":0.84203444,"feeCurrency":"USDT"},{"gid":1111,"id":260765277,"exchange":"binance","price":8086.6,"quantity":0.5,"quoteQuantity":4043.3,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-10T11:32:00.134Z","fee":4.0433,"feeCurrency":"USDT"},{"gid":1112,"id":260765278,"exchange":"binance","price":8086.59,"quantity":0.055114,"quoteQuantity":445.68432126,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-10T11:32:00.134Z","fee":0.44568432,"feeCurrency":"USDT"},{"gid":1113,"id":260768556,"exchange":"binance","price":8061,"quantity":0.1,"quoteQuantity":806.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-10T11:35:50.648Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1114,"id":260772697,"exchange":"binance","price":8066.81,"quantity":0.02,"quoteQuantity":161.3362,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T11:41:42.63Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1115,"id":260772948,"exchange":"binance","price":8064.26,"quantity":0.02,"quoteQuantity":161.2852,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T11:42:06.367Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1116,"id":260773382,"exchange":"binance","price":8062.18,"quantity":0.02,"quoteQuantity":161.2436,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T11:42:55.613Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1117,"id":260777161,"exchange":"binance","price":8062.25,"quantity":0.02,"quoteQuantity":161.245,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T11:50:23.511Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1118,"id":260777884,"exchange":"binance","price":8051,"quantity":0.05,"quoteQuantity":402.55,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-10T11:51:28.584Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1119,"id":260782597,"exchange":"binance","price":8063.51,"quantity":0.01,"quoteQuantity":80.6351,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T11:57:54.131Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1120,"id":260783336,"exchange":"binance","price":8069.86,"quantity":0.05,"quoteQuantity":403.493,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T11:59:07.108Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1121,"id":260784433,"exchange":"binance","price":8075.53,"quantity":0.1,"quoteQuantity":807.553,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T12:00:48.231Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1122,"id":260785046,"exchange":"binance","price":8074.52,"quantity":0.05,"quoteQuantity":403.726,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-10T12:01:21.242Z","fee":0.403726,"feeCurrency":"USDT"},{"gid":1123,"id":260787251,"exchange":"binance","price":8082.86,"quantity":0.05,"quoteQuantity":404.143,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T12:04:55.887Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1124,"id":260787498,"exchange":"binance","price":8082.44,"quantity":0.05,"quoteQuantity":404.122,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T12:05:36.298Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1125,"id":260789141,"exchange":"binance","price":8090.29,"quantity":0.1,"quoteQuantity":809.029,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T12:08:55.362Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1126,"id":260789472,"exchange":"binance","price":8099.46,"quantity":0.089596,"quoteQuantity":725.67921816,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T12:09:24.33Z","fee":0.0000896,"feeCurrency":"BTC"},{"gid":1127,"id":260789473,"exchange":"binance","price":8099.49,"quantity":0.010404,"quoteQuantity":84.26709396,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T12:09:24.33Z","fee":0.0000104,"feeCurrency":"BTC"},{"gid":1128,"id":260814111,"exchange":"binance","price":8138.73,"quantity":0.05,"quoteQuantity":406.9365,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T12:51:07.69Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1129,"id":260822484,"exchange":"binance","price":8145.23,"quantity":0.02687,"quoteQuantity":218.8623301,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T13:03:27.697Z","fee":0.00002687,"feeCurrency":"BTC"},{"gid":1130,"id":260822485,"exchange":"binance","price":8145.23,"quantity":0.07313,"quoteQuantity":595.6606699,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T13:03:27.697Z","fee":0.00007313,"feeCurrency":"BTC"},{"gid":1131,"id":260823141,"exchange":"binance","price":8137,"quantity":0.005904,"quoteQuantity":48.040848,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-10T13:04:17.092Z","fee":0.0000059,"feeCurrency":"BTC"},{"gid":1132,"id":260823142,"exchange":"binance","price":8137,"quantity":0.094096,"quoteQuantity":765.659152,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-10T13:04:17.118Z","fee":0.0000941,"feeCurrency":"BTC"},{"gid":1133,"id":260827239,"exchange":"binance","price":8121,"quantity":0.1,"quoteQuantity":812.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-10T13:10:59.565Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1134,"id":260829452,"exchange":"binance","price":8109.79,"quantity":0.079717,"quoteQuantity":646.48812943,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-10T13:13:40.537Z","fee":0.64648813,"feeCurrency":"USDT"},{"gid":1135,"id":260829453,"exchange":"binance","price":8109.76,"quantity":0.079717,"quoteQuantity":646.48573792,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-10T13:13:40.537Z","fee":0.64648574,"feeCurrency":"USDT"},{"gid":1136,"id":260829454,"exchange":"binance","price":8109.72,"quantity":0.829526,"quoteQuantity":6727.22359272,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-10T13:13:40.537Z","fee":6.72722359,"feeCurrency":"USDT"},{"gid":1137,"id":260865451,"exchange":"binance","price":8075.3,"quantity":0.1,"quoteQuantity":807.53,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T14:01:07.746Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1138,"id":260865528,"exchange":"binance","price":8075,"quantity":0.098246,"quoteQuantity":793.33645,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-10T14:01:16.651Z","fee":0.00009825,"feeCurrency":"BTC"},{"gid":1139,"id":260865529,"exchange":"binance","price":8075,"quantity":0.001753,"quoteQuantity":14.155475,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-10T14:01:16.845Z","fee":0.00000175,"feeCurrency":"BTC"},{"gid":1140,"id":260865543,"exchange":"binance","price":8075,"quantity":0.000001,"quoteQuantity":0.008075,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-10T14:01:18.845Z","fee":0.00000351,"feeCurrency":"BNB"},{"gid":1141,"id":260877313,"exchange":"binance","price":8101,"quantity":0.1,"quoteQuantity":810.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-10T14:30:41.023Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1142,"id":260877337,"exchange":"binance","price":8100.62,"quantity":0.08958,"quoteQuantity":725.6535396,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T14:30:44.02Z","fee":0.00008958,"feeCurrency":"BTC"},{"gid":1143,"id":260877338,"exchange":"binance","price":8100.66,"quantity":0.01042,"quoteQuantity":84.4088772,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T14:30:44.02Z","fee":0.00001042,"feeCurrency":"BTC"},{"gid":1144,"id":260896417,"exchange":"binance","price":8021,"quantity":0.1,"quoteQuantity":802.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-10T15:03:26.829Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1145,"id":260923001,"exchange":"binance","price":7943.11,"quantity":0.007556,"quoteQuantity":60.01813916,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T15:34:30.677Z","fee":0.00000756,"feeCurrency":"BTC"},{"gid":1146,"id":260923002,"exchange":"binance","price":7943.12,"quantity":0.075568,"quoteQuantity":600.24569216,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T15:34:30.677Z","fee":0.00007557,"feeCurrency":"BTC"},{"gid":1147,"id":260923003,"exchange":"binance","price":7943.12,"quantity":0.016876,"quoteQuantity":134.04809312,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T15:34:30.677Z","fee":0.00001688,"feeCurrency":"BTC"},{"gid":1148,"id":260923555,"exchange":"binance","price":7921,"quantity":0.1,"quoteQuantity":792.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-10T15:34:51.72Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1149,"id":260927776,"exchange":"binance","price":7844.81,"quantity":0.1,"quoteQuantity":784.481,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-10T15:35:10.874Z","fee":0.784481,"feeCurrency":"USDT"},{"gid":1150,"id":260935202,"exchange":"binance","price":7800,"quantity":0.060382,"quoteQuantity":470.9796,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T15:36:57.627Z","fee":0.00006038,"feeCurrency":"BTC"},{"gid":1151,"id":260935203,"exchange":"binance","price":7800,"quantity":0.005348,"quoteQuantity":41.7144,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T15:36:57.627Z","fee":0.00000535,"feeCurrency":"BTC"},{"gid":1152,"id":260935204,"exchange":"binance","price":7803.58,"quantity":0.03427,"quoteQuantity":267.4286866,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T15:36:57.627Z","fee":0.00003427,"feeCurrency":"BTC"},{"gid":1153,"id":260942803,"exchange":"binance","price":7836.41,"quantity":0.007653,"quoteQuantity":59.97204573,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T15:41:08.095Z","fee":0.00000765,"feeCurrency":"BTC"},{"gid":1154,"id":260942804,"exchange":"binance","price":7836.43,"quantity":0.042347,"quoteQuantity":331.84930121,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T15:41:08.095Z","fee":0.00004235,"feeCurrency":"BTC"},{"gid":1155,"id":260959252,"exchange":"binance","price":7781,"quantity":0.1,"quoteQuantity":778.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-10T15:55:25.972Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1156,"id":260981717,"exchange":"binance","price":7815.43,"quantity":0.01,"quoteQuantity":78.1543,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-10T16:10:28.989Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1157,"id":261221159,"exchange":"binance","price":7913.83,"quantity":0.05,"quoteQuantity":395.6915,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-11T00:14:41.357Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1158,"id":261311152,"exchange":"binance","price":7903.81,"quantity":0.01,"quoteQuantity":79.0381,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-11T05:08:38.015Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1159,"id":261311750,"exchange":"binance","price":7906,"quantity":0.049995,"quoteQuantity":395.26047,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-11T05:10:17.661Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1160,"id":261311786,"exchange":"binance","price":7906,"quantity":0.000005,"quoteQuantity":0.03953,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-11T05:10:17.841Z","fee":0.00000352,"feeCurrency":"BNB"},{"gid":1161,"id":261332858,"exchange":"binance","price":7837.69,"quantity":0.01,"quoteQuantity":78.3769,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-11T05:55:17.816Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1162,"id":261332990,"exchange":"binance","price":7835,"quantity":0.01,"quoteQuantity":78.35,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-11T05:55:43.219Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1163,"id":261447527,"exchange":"binance","price":7780,"quantity":0.05,"quoteQuantity":389,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-11T10:10:08.182Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1164,"id":261637674,"exchange":"binance","price":7756,"quantity":0.004979,"quoteQuantity":38.617124,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-11T16:17:41.061Z","fee":0.00000498,"feeCurrency":"BTC"},{"gid":1165,"id":261637677,"exchange":"binance","price":7756,"quantity":0.000021,"quoteQuantity":0.162876,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-11T16:17:42.4Z","fee":0.00000718,"feeCurrency":"BNB"},{"gid":1166,"id":261659945,"exchange":"binance","price":7702,"quantity":0.01,"quoteQuantity":77.02,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-11T17:02:56.724Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1167,"id":261909097,"exchange":"binance","price":7928.27,"quantity":0.082113,"quoteQuantity":651.01403451,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-11T23:41:31.289Z","fee":0.65101403,"feeCurrency":"USDT"},{"gid":1168,"id":261909098,"exchange":"binance","price":7926.48,"quantity":0.017887,"quoteQuantity":141.78094776,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-11T23:41:31.289Z","fee":0.14178095,"feeCurrency":"USDT"},{"gid":1169,"id":261910010,"exchange":"binance","price":7920.84,"quantity":0.1,"quoteQuantity":792.084,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-11T23:43:58.03Z","fee":0.792084,"feeCurrency":"USDT"},{"gid":1170,"id":261910182,"exchange":"binance","price":7920.57,"quantity":0.088972,"quoteQuantity":704.70895404,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-11T23:44:37.329Z","fee":0.70470895,"feeCurrency":"USDT"},{"gid":1171,"id":261910183,"exchange":"binance","price":7920.54,"quantity":0.011028,"quoteQuantity":87.34771512,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-11T23:44:37.329Z","fee":0.08734772,"feeCurrency":"USDT"},{"gid":1172,"id":261911745,"exchange":"binance","price":7931.27,"quantity":0.1,"quoteQuantity":793.127,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-11T23:51:10.367Z","fee":0.793127,"feeCurrency":"USDT"},{"gid":1173,"id":261912752,"exchange":"binance","price":7935.37,"quantity":0.05,"quoteQuantity":396.7685,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-11T23:54:31.849Z","fee":0.3967685,"feeCurrency":"USDT"},{"gid":1174,"id":261930672,"exchange":"binance","price":7910.14,"quantity":0.1,"quoteQuantity":791.014,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-12T00:51:13.002Z","fee":0.791014,"feeCurrency":"USDT"},{"gid":1175,"id":261930696,"exchange":"binance","price":7910,"quantity":0.1,"quoteQuantity":791,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-12T00:51:22.458Z","fee":0.791,"feeCurrency":"USDT"},{"gid":1176,"id":261966135,"exchange":"binance","price":7788.97,"quantity":0.05,"quoteQuantity":389.4485,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-12T01:51:05.232Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1177,"id":261966322,"exchange":"binance","price":7776.58,"quantity":0.05,"quoteQuantity":388.829,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-12T01:51:20.139Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1178,"id":262028022,"exchange":"binance","price":7732.41,"quantity":0.05,"quoteQuantity":386.6205,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-12T02:43:30.354Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1179,"id":262062873,"exchange":"binance","price":7684,"quantity":0.099992,"quoteQuantity":768.338528,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-12T03:41:37.543Z","fee":0.00009999,"feeCurrency":"BTC"},{"gid":1180,"id":262062874,"exchange":"binance","price":7684,"quantity":0.000008,"quoteQuantity":0.061472,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-12T03:41:37.715Z","fee":0.00000367,"feeCurrency":"BNB"},{"gid":1181,"id":262081965,"exchange":"binance","price":7602,"quantity":0.1,"quoteQuantity":760.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-12T04:12:49.686Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1182,"id":262115167,"exchange":"binance","price":7631.4,"quantity":0.05,"quoteQuantity":381.57,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-12T04:52:38.997Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1183,"id":262159559,"exchange":"binance","price":7561,"quantity":0.05,"quoteQuantity":378.05,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-12T06:26:39.556Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1184,"id":262161505,"exchange":"binance","price":7523,"quantity":0.1,"quoteQuantity":752.3,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-12T06:26:51.237Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1185,"id":262444338,"exchange":"binance","price":6006.61,"quantity":0.186312,"quoteQuantity":1119.10352232,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-12T10:46:40.423Z","fee":1.11910352,"feeCurrency":"USDT"},{"gid":1186,"id":262444339,"exchange":"binance","price":6006.57,"quantity":0.313688,"quoteQuantity":1884.18893016,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-12T10:46:40.423Z","fee":1.88418893,"feeCurrency":"USDT"},{"gid":1187,"id":262504577,"exchange":"binance","price":6595,"quantity":0.2,"quoteQuantity":1319,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-12T10:56:04.82Z","fee":1.319,"feeCurrency":"USDT"},{"gid":1188,"id":262506020,"exchange":"binance","price":6598.11,"quantity":0.1,"quoteQuantity":659.811,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-12T10:56:19.824Z","fee":0.659811,"feeCurrency":"USDT"},{"gid":1189,"id":262546160,"exchange":"binance","price":6250.38,"quantity":0.5,"quoteQuantity":3125.19,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-12T11:04:58.553Z","fee":0.0005,"feeCurrency":"BTC"},{"gid":1190,"id":262546915,"exchange":"binance","price":6276.19,"quantity":0.2,"quoteQuantity":1255.238,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-12T11:05:12.539Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":1191,"id":262550896,"exchange":"binance","price":6340.22,"quantity":0.412385,"quoteQuantity":2614.6116247,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-12T11:06:29.13Z","fee":2.61461162,"feeCurrency":"USDT"},{"gid":1192,"id":262550897,"exchange":"binance","price":6340.22,"quantity":0.087615,"quoteQuantity":555.4983753,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-12T11:06:29.13Z","fee":0.55549838,"feeCurrency":"USDT"},{"gid":1193,"id":262561337,"exchange":"binance","price":6384.7,"quantity":0.001568,"quoteQuantity":10.0112096,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-12T11:10:22.377Z","fee":0.00000157,"feeCurrency":"BTC"},{"gid":1194,"id":262561338,"exchange":"binance","price":6388.14,"quantity":0.142868,"quoteQuantity":912.66078552,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-12T11:10:22.377Z","fee":0.00014287,"feeCurrency":"BTC"},{"gid":1195,"id":262561339,"exchange":"binance","price":6388.2,"quantity":0.025,"quoteQuantity":159.705,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-12T11:10:22.377Z","fee":0.000025,"feeCurrency":"BTC"},{"gid":1196,"id":262561340,"exchange":"binance","price":6388.21,"quantity":0.230564,"quoteQuantity":1472.89125044,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-12T11:10:22.377Z","fee":0.00023056,"feeCurrency":"BTC"},{"gid":1197,"id":262562658,"exchange":"binance","price":6436.49,"quantity":0.2,"quoteQuantity":1287.298,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-12T11:10:43.197Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":1198,"id":262563598,"exchange":"binance","price":6410.83,"quantity":0.2,"quoteQuantity":1282.166,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-12T11:10:59.506Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":1199,"id":262685160,"exchange":"binance","price":6080,"quantity":0.02,"quoteQuantity":121.6,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-12T12:02:26.087Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1200,"id":262760939,"exchange":"binance","price":6121.63,"quantity":0.01,"quoteQuantity":61.2163,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-12T12:35:18.953Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1201,"id":262761887,"exchange":"binance","price":6120,"quantity":0.01,"quoteQuantity":61.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-12T12:35:45.128Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1202,"id":263789435,"exchange":"binance","price":4600,"quantity":0.02,"quoteQuantity":92,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-13T00:47:05.147Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1203,"id":263852713,"exchange":"binance","price":4700,"quantity":0.02,"quoteQuantity":94,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-13T01:09:14.226Z","fee":0.094,"feeCurrency":"USDT"},{"gid":1204,"id":264368198,"exchange":"binance","price":5011.11,"quantity":0.01,"quoteQuantity":50.1111,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-13T04:32:38.926Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1205,"id":264376893,"exchange":"binance","price":4891.34,"quantity":0.01,"quoteQuantity":48.9134,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-13T04:38:57.633Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1206,"id":264459400,"exchange":"binance","price":4993.49,"quantity":0.02,"quoteQuantity":99.8698,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-13T05:34:03.857Z","fee":0.0998698,"feeCurrency":"USDT"},{"gid":1207,"id":264596330,"exchange":"binance","price":5119.51,"quantity":0.003901,"quoteQuantity":19.97120851,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-13T07:36:28.612Z","fee":0.01997121,"feeCurrency":"USDT"},{"gid":1208,"id":264596331,"exchange":"binance","price":5119.19,"quantity":0.006099,"quoteQuantity":31.22193981,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-13T07:36:28.612Z","fee":0.03122194,"feeCurrency":"USDT"},{"gid":1209,"id":264599635,"exchange":"binance","price":5150,"quantity":0.01,"quoteQuantity":51.5,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-13T07:41:19.335Z","fee":0.0515,"feeCurrency":"USDT"},{"gid":1210,"id":264605186,"exchange":"binance","price":5200,"quantity":0.01,"quoteQuantity":52,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-13T07:49:33.76Z","fee":0.052,"feeCurrency":"USDT"},{"gid":1211,"id":264656749,"exchange":"binance","price":5463.85,"quantity":0.02,"quoteQuantity":109.277,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-13T08:09:42.762Z","fee":0.109277,"feeCurrency":"USDT"},{"gid":1212,"id":264814242,"exchange":"binance","price":5652.85,"quantity":0.05,"quoteQuantity":282.6425,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-13T10:03:59.369Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1213,"id":264837360,"exchange":"binance","price":5563.14,"quantity":0.0082,"quoteQuantity":45.617748,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-13T10:20:29.136Z","fee":0.0000082,"feeCurrency":"BTC"},{"gid":1214,"id":264838066,"exchange":"binance","price":5590.97,"quantity":0.05,"quoteQuantity":279.5485,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-13T10:20:43.757Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1215,"id":264852106,"exchange":"binance","price":5600,"quantity":0.1,"quoteQuantity":560,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-13T10:31:29.537Z","fee":0.56,"feeCurrency":"USDT"},{"gid":1216,"id":266567207,"exchange":"binance","price":5360,"quantity":0.099996,"quoteQuantity":535.97856,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-14T10:46:03.632Z","fee":0.53597856,"feeCurrency":"USDT"},{"gid":1217,"id":266567208,"exchange":"binance","price":5360,"quantity":0.000004,"quoteQuantity":0.02144,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-14T10:46:03.656Z","fee":0.0000015,"feeCurrency":"BNB"},{"gid":1218,"id":266589319,"exchange":"binance","price":5383.58,"quantity":0.05,"quoteQuantity":269.179,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-14T11:17:41.622Z","fee":0.269179,"feeCurrency":"USDT"},{"gid":1219,"id":266590155,"exchange":"binance","price":5383.31,"quantity":0.037142,"quoteQuantity":199.94690002,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-14T11:19:14.425Z","fee":0.1999469,"feeCurrency":"USDT"},{"gid":1220,"id":266590156,"exchange":"binance","price":5383.26,"quantity":0.012858,"quoteQuantity":69.21795708,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-14T11:19:14.425Z","fee":0.06921796,"feeCurrency":"USDT"},{"gid":1221,"id":266607514,"exchange":"binance","price":5450.04,"quantity":0.01,"quoteQuantity":54.5004,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-14T11:29:57.915Z","fee":0.0545004,"feeCurrency":"USDT"},{"gid":1222,"id":266607515,"exchange":"binance","price":5450.04,"quantity":0.04,"quoteQuantity":218.0016,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-14T11:29:57.947Z","fee":0.2180016,"feeCurrency":"USDT"},{"gid":1223,"id":268237143,"exchange":"binance","price":5268.54,"quantity":0.1,"quoteQuantity":526.854,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-16T01:26:47.514Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1224,"id":268380007,"exchange":"binance","price":5100,"quantity":0.02,"quoteQuantity":102,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-16T06:00:24.914Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1225,"id":268606303,"exchange":"binance","price":4860.38,"quantity":0.01,"quoteQuantity":48.6038,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-16T08:08:08.076Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1226,"id":268911952,"exchange":"binance","price":4568.5,"quantity":0.01,"quoteQuantity":45.685,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-16T11:17:40.215Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1227,"id":269200384,"exchange":"binance","price":4855.44,"quantity":0.594989,"quoteQuantity":2888.93339016,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-16T13:58:05.513Z","fee":2.88893339,"feeCurrency":"USDT"},{"gid":1228,"id":269200385,"exchange":"binance","price":4854.79,"quantity":0.084727,"quoteQuantity":411.33179233,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-16T13:58:05.513Z","fee":0.41133179,"feeCurrency":"USDT"},{"gid":1229,"id":269200386,"exchange":"binance","price":4854.01,"quantity":0.005112,"quoteQuantity":24.81369912,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-16T13:58:05.513Z","fee":0.0248137,"feeCurrency":"USDT"},{"gid":1230,"id":269200387,"exchange":"binance","price":4850.37,"quantity":0.007623,"quoteQuantity":36.97437051,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-16T13:58:05.513Z","fee":0.03697437,"feeCurrency":"USDT"},{"gid":1231,"id":269200388,"exchange":"binance","price":4850.26,"quantity":0.107549,"quoteQuantity":521.64061274,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-16T13:58:05.513Z","fee":0.52164061,"feeCurrency":"USDT"},{"gid":1232,"id":269254587,"exchange":"binance","price":4900,"quantity":0.006274,"quoteQuantity":30.7426,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-16T14:21:52.86Z","fee":0.0307426,"feeCurrency":"USDT"},{"gid":1233,"id":269255105,"exchange":"binance","price":4900,"quantity":0.233393,"quoteQuantity":1143.6257,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-16T14:22:00.29Z","fee":1.1436257,"feeCurrency":"USDT"},{"gid":1234,"id":272579057,"exchange":"binance","price":5870,"quantity":0.2,"quoteQuantity":1174,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-19T15:52:46.583Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":1235,"id":272579150,"exchange":"binance","price":5868.76,"quantity":0.013738,"quoteQuantity":80.62502488,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-19T15:52:50.305Z","fee":0.00001374,"feeCurrency":"BTC"},{"gid":1236,"id":272579151,"exchange":"binance","price":5869.11,"quantity":0.1,"quoteQuantity":586.911,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-19T15:52:50.305Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1237,"id":272579152,"exchange":"binance","price":5869.17,"quantity":0.086262,"quoteQuantity":506.28634254,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-19T15:52:50.305Z","fee":0.00008626,"feeCurrency":"BTC"},{"gid":1238,"id":272615114,"exchange":"binance","price":5943.31,"quantity":0.019086,"quoteQuantity":113.43401466,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-19T16:11:12.567Z","fee":0.11343401,"feeCurrency":"USDT"},{"gid":1239,"id":272615115,"exchange":"binance","price":5942.44,"quantity":0.080914,"quoteQuantity":480.82659016,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-19T16:11:12.567Z","fee":0.48082659,"feeCurrency":"USDT"},{"gid":1240,"id":272615496,"exchange":"binance","price":5948.44,"quantity":0.1,"quoteQuantity":594.844,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-19T16:11:21.078Z","fee":0.594844,"feeCurrency":"USDT"},{"gid":1241,"id":272721093,"exchange":"binance","price":6158.57,"quantity":0.1,"quoteQuantity":615.857,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-19T16:38:56.641Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1242,"id":272768890,"exchange":"binance","price":6220,"quantity":0.1,"quoteQuantity":622,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-19T17:03:20.696Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1243,"id":272770573,"exchange":"binance","price":6210,"quantity":0.1,"quoteQuantity":621,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-19T17:04:03.132Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1244,"id":272796319,"exchange":"binance","price":6190,"quantity":0.1,"quoteQuantity":619,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-19T17:17:53.187Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1245,"id":273199522,"exchange":"binance","price":6260,"quantity":0.056899,"quoteQuantity":356.18774,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T00:19:52.354Z","fee":0.0000569,"feeCurrency":"BTC"},{"gid":1246,"id":273199523,"exchange":"binance","price":6260,"quantity":0.003194,"quoteQuantity":19.99444,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T00:19:52.429Z","fee":0.00000319,"feeCurrency":"BTC"},{"gid":1247,"id":273199524,"exchange":"binance","price":6260,"quantity":0.025606,"quoteQuantity":160.29356,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T00:19:52.55Z","fee":0.00002561,"feeCurrency":"BTC"},{"gid":1248,"id":273199525,"exchange":"binance","price":6260,"quantity":0.014301,"quoteQuantity":89.52426,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T00:19:52.62Z","fee":0.0000143,"feeCurrency":"BTC"},{"gid":1249,"id":273216086,"exchange":"binance","price":6182,"quantity":0.1,"quoteQuantity":618.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T00:45:59.196Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1250,"id":273392549,"exchange":"binance","price":6247.53,"quantity":0.1,"quoteQuantity":624.753,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-20T06:12:02.879Z","fee":0.624753,"feeCurrency":"USDT"},{"gid":1251,"id":273392737,"exchange":"binance","price":6246.83,"quantity":0.004643,"quoteQuantity":29.00403169,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-20T06:12:29.998Z","fee":0.02900403,"feeCurrency":"USDT"},{"gid":1252,"id":273392738,"exchange":"binance","price":6245.72,"quantity":0.095357,"quoteQuantity":595.57312204,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-20T06:12:29.998Z","fee":0.59557312,"feeCurrency":"USDT"},{"gid":1253,"id":273393034,"exchange":"binance","price":6246.16,"quantity":0.093013,"quoteQuantity":580.97408008,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-20T06:13:28.094Z","fee":0.58097408,"feeCurrency":"USDT"},{"gid":1254,"id":273393035,"exchange":"binance","price":6245.94,"quantity":0.006987,"quoteQuantity":43.64038278,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-20T06:13:28.094Z","fee":0.04364038,"feeCurrency":"USDT"},{"gid":1255,"id":273487037,"exchange":"binance","price":6449.48,"quantity":0.094297,"quoteQuantity":608.16661556,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-20T08:12:14.297Z","fee":0.60816662,"feeCurrency":"USDT"},{"gid":1256,"id":273487039,"exchange":"binance","price":6449.48,"quantity":0.005703,"quoteQuantity":36.78138444,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-20T08:12:14.503Z","fee":0.03678138,"feeCurrency":"USDT"},{"gid":1257,"id":273488435,"exchange":"binance","price":6444.24,"quantity":0.000003,"quoteQuantity":0.01933272,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-20T08:12:53.765Z","fee":0.00000114,"feeCurrency":"BNB"},{"gid":1258,"id":273488436,"exchange":"binance","price":6441.1,"quantity":0.003103,"quoteQuantity":19.9867333,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-20T08:12:53.765Z","fee":0.01998673,"feeCurrency":"USDT"},{"gid":1259,"id":273488437,"exchange":"binance","price":6441.09,"quantity":0.096894,"quoteQuantity":624.10297446,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-20T08:12:53.765Z","fee":0.62410297,"feeCurrency":"USDT"},{"gid":1260,"id":273514026,"exchange":"binance","price":6655.71,"quantity":0.220366,"quoteQuantity":1466.69218986,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-20T08:19:40.894Z","fee":0.00022037,"feeCurrency":"BTC"},{"gid":1261,"id":273514027,"exchange":"binance","price":6655.84,"quantity":0.179634,"quoteQuantity":1195.61516256,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-20T08:19:40.894Z","fee":0.00017963,"feeCurrency":"BTC"},{"gid":1262,"id":273517334,"exchange":"binance","price":6752.41,"quantity":0.2,"quoteQuantity":1350.482,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-20T08:20:04.624Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":1263,"id":273519682,"exchange":"binance","price":6651.7,"quantity":0.1,"quoteQuantity":665.17,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-20T08:20:23.266Z","fee":0.66517,"feeCurrency":"USDT"},{"gid":1264,"id":273520221,"exchange":"binance","price":6655.12,"quantity":0.1,"quoteQuantity":665.512,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-20T08:20:29.77Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1265,"id":273521007,"exchange":"binance","price":6680.89,"quantity":0.1,"quoteQuantity":668.089,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-20T08:20:40.189Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1266,"id":273527106,"exchange":"binance","price":6575.32,"quantity":0.091223,"quoteQuantity":599.82041636,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-20T08:21:51.563Z","fee":0.59982042,"feeCurrency":"USDT"},{"gid":1267,"id":273527107,"exchange":"binance","price":6571.27,"quantity":0.308777,"quoteQuantity":2029.05703679,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-20T08:21:51.563Z","fee":2.02905704,"feeCurrency":"USDT"},{"gid":1268,"id":273527978,"exchange":"binance","price":6587.14,"quantity":0.112,"quoteQuantity":737.75968,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-20T08:22:00.16Z","fee":0.73775968,"feeCurrency":"USDT"},{"gid":1269,"id":273527979,"exchange":"binance","price":6582.57,"quantity":0.288,"quoteQuantity":1895.78016,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-20T08:22:00.16Z","fee":1.89578016,"feeCurrency":"USDT"},{"gid":1270,"id":273537674,"exchange":"binance","price":6570,"quantity":0.1,"quoteQuantity":657,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T08:25:07.527Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1271,"id":273538074,"exchange":"binance","price":6560,"quantity":0.1,"quoteQuantity":656,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T08:25:19.233Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1272,"id":273629368,"exchange":"binance","price":6658.92,"quantity":0.1,"quoteQuantity":665.892,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-20T09:20:46.845Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1273,"id":273646001,"exchange":"binance","price":6614.46,"quantity":0.05,"quoteQuantity":330.723,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T09:32:14.329Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1274,"id":273646429,"exchange":"binance","price":6617.54,"quantity":0.1,"quoteQuantity":661.754,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T09:32:31.292Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1275,"id":273647008,"exchange":"binance","price":6616,"quantity":0.1,"quoteQuantity":661.6,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T09:33:03.85Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1276,"id":273797322,"exchange":"binance","price":6815,"quantity":0.2,"quoteQuantity":1363,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T11:03:30.272Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":1277,"id":273805844,"exchange":"binance","price":6795,"quantity":0.1,"quoteQuantity":679.5,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T11:07:33.319Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1278,"id":273808541,"exchange":"binance","price":6790,"quantity":0.1,"quoteQuantity":679,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T11:09:51.063Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1279,"id":273838030,"exchange":"binance","price":6713.7,"quantity":0.002977,"quoteQuantity":19.9866849,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-20T11:22:10.512Z","fee":0.00000298,"feeCurrency":"BTC"},{"gid":1280,"id":273838031,"exchange":"binance","price":6713.71,"quantity":0.026998,"quoteQuantity":181.25674258,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-20T11:22:10.512Z","fee":0.000027,"feeCurrency":"BTC"},{"gid":1281,"id":273838032,"exchange":"binance","price":6714.88,"quantity":0.020025,"quoteQuantity":134.465472,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-20T11:22:10.512Z","fee":0.00002003,"feeCurrency":"BTC"},{"gid":1282,"id":273972117,"exchange":"binance","price":6565,"quantity":0.1,"quoteQuantity":656.5,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T13:01:33.123Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1283,"id":273999904,"exchange":"binance","price":6474.14,"quantity":0.003088,"quoteQuantity":19.99214432,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-20T13:22:21.234Z","fee":0.01999214,"feeCurrency":"USDT"},{"gid":1284,"id":273999905,"exchange":"binance","price":6474,"quantity":0.197828,"quoteQuantity":1280.738472,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-20T13:22:21.234Z","fee":1.28073847,"feeCurrency":"USDT"},{"gid":1285,"id":273999906,"exchange":"binance","price":6473.15,"quantity":0.099084,"quoteQuantity":641.3855946,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-20T13:22:21.234Z","fee":0.64138559,"feeCurrency":"USDT"},{"gid":1286,"id":274000297,"exchange":"binance","price":6468.83,"quantity":0.1,"quoteQuantity":646.883,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-20T13:22:30.545Z","fee":0.646883,"feeCurrency":"USDT"},{"gid":1287,"id":274003033,"exchange":"binance","price":6428.61,"quantity":0.2,"quoteQuantity":1285.722,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-20T13:22:57.3Z","fee":1.285722,"feeCurrency":"USDT"},{"gid":1288,"id":274017566,"exchange":"binance","price":6511,"quantity":0.240625,"quoteQuantity":1566.709375,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T13:30:10.094Z","fee":0.00024063,"feeCurrency":"BTC"},{"gid":1289,"id":274017567,"exchange":"binance","price":6511,"quantity":0.25937,"quoteQuantity":1688.75807,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T13:30:10.121Z","fee":0.00025937,"feeCurrency":"BTC"},{"gid":1290,"id":274017616,"exchange":"binance","price":6511,"quantity":0.000005,"quoteQuantity":0.032555,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T13:30:11.442Z","fee":0.00000384,"feeCurrency":"BNB"},{"gid":1291,"id":274050196,"exchange":"binance","price":6530,"quantity":0.05,"quoteQuantity":326.5,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-20T13:55:06.316Z","fee":0.3265,"feeCurrency":"USDT"},{"gid":1292,"id":274050319,"exchange":"binance","price":6537.49,"quantity":0.05,"quoteQuantity":326.8745,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-20T13:55:13.333Z","fee":0.3268745,"feeCurrency":"USDT"},{"gid":1293,"id":274078238,"exchange":"binance","price":6644.79,"quantity":0.05,"quoteQuantity":332.2395,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-20T14:16:59.453Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1294,"id":274083548,"exchange":"binance","price":6660,"quantity":0.1,"quoteQuantity":666,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-20T14:22:38.62Z","fee":0.666,"feeCurrency":"USDT"},{"gid":1295,"id":274087380,"exchange":"binance","price":6700,"quantity":0.1,"quoteQuantity":670,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-20T14:25:42.295Z","fee":0.67,"feeCurrency":"USDT"},{"gid":1296,"id":274092270,"exchange":"binance","price":6700.06,"quantity":0.05,"quoteQuantity":335.003,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-20T14:28:48.323Z","fee":0.335003,"feeCurrency":"USDT"},{"gid":1297,"id":274093798,"exchange":"binance","price":6700.04,"quantity":0.020071,"quoteQuantity":134.47650284,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-20T14:30:09.07Z","fee":0.1344765,"feeCurrency":"USDT"},{"gid":1298,"id":274093799,"exchange":"binance","price":6700.01,"quantity":0.002984,"quoteQuantity":19.99282984,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-20T14:30:09.07Z","fee":0.01999283,"feeCurrency":"USDT"},{"gid":1299,"id":274093800,"exchange":"binance","price":6700,"quantity":0.076945,"quoteQuantity":515.5315,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-20T14:30:09.07Z","fee":0.5155315,"feeCurrency":"USDT"},{"gid":1300,"id":274099962,"exchange":"binance","price":6640,"quantity":0.02,"quoteQuantity":132.8,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T14:34:46.744Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1301,"id":274100608,"exchange":"binance","price":6641,"quantity":0.014797,"quoteQuantity":98.266877,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T14:35:42.033Z","fee":0.0000148,"feeCurrency":"BTC"},{"gid":1302,"id":274100610,"exchange":"binance","price":6641,"quantity":0.003012,"quoteQuantity":20.002692,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T14:35:42.632Z","fee":0.00000301,"feeCurrency":"BTC"},{"gid":1303,"id":274100611,"exchange":"binance","price":6641,"quantity":0.002191,"quoteQuantity":14.550431,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T14:35:42.633Z","fee":0.00000219,"feeCurrency":"BTC"},{"gid":1304,"id":274109692,"exchange":"binance","price":6595,"quantity":0.02,"quoteQuantity":131.9,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T14:41:19.551Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1305,"id":274110441,"exchange":"binance","price":6600,"quantity":0.010598,"quoteQuantity":69.9468,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-20T14:41:45.227Z","fee":0.0000106,"feeCurrency":"BTC"},{"gid":1306,"id":274110442,"exchange":"binance","price":6600,"quantity":0.005902,"quoteQuantity":38.9532,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-20T14:41:45.227Z","fee":0.0000059,"feeCurrency":"BTC"},{"gid":1307,"id":274110458,"exchange":"binance","price":6600,"quantity":0.0335,"quoteQuantity":221.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T14:41:45.871Z","fee":0.0000335,"feeCurrency":"BTC"},{"gid":1308,"id":274111596,"exchange":"binance","price":6582,"quantity":0.02,"quoteQuantity":131.64,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T14:42:37.658Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1309,"id":274113120,"exchange":"binance","price":6604.02,"quantity":0.011373,"quoteQuantity":75.10751946,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-20T14:43:55.877Z","fee":0.00001137,"feeCurrency":"BTC"},{"gid":1310,"id":274113121,"exchange":"binance","price":6604.02,"quantity":0.008627,"quoteQuantity":56.97288054,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-20T14:43:55.877Z","fee":0.00000863,"feeCurrency":"BTC"},{"gid":1311,"id":274145061,"exchange":"binance","price":6573.1,"quantity":0.02,"quoteQuantity":131.462,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-20T15:17:26.86Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1312,"id":274146940,"exchange":"binance","price":6581,"quantity":0.02,"quoteQuantity":131.62,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T15:19:18.653Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1313,"id":274147156,"exchange":"binance","price":6575,"quantity":0.0013,"quoteQuantity":8.5475,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T15:19:46.167Z","fee":0.0000013,"feeCurrency":"BTC"},{"gid":1314,"id":274147157,"exchange":"binance","price":6575,"quantity":0.0187,"quoteQuantity":122.9525,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T15:19:46.211Z","fee":0.0000187,"feeCurrency":"BTC"},{"gid":1315,"id":274147405,"exchange":"binance","price":6570,"quantity":0.02,"quoteQuantity":131.4,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T15:20:03.595Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1316,"id":274147691,"exchange":"binance","price":6560,"quantity":0.02,"quoteQuantity":131.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T15:20:26.026Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1317,"id":274149211,"exchange":"binance","price":6540,"quantity":0.05,"quoteQuantity":327,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T15:22:08.201Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1318,"id":274270803,"exchange":"binance","price":6439.29,"quantity":0.02,"quoteQuantity":128.7858,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T17:03:18.433Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1319,"id":274271162,"exchange":"binance","price":6430,"quantity":0.019997,"quoteQuantity":128.58071,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T17:03:48.532Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1320,"id":274271163,"exchange":"binance","price":6430,"quantity":0.000003,"quoteQuantity":0.01929,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T17:03:48.535Z","fee":0.0000039,"feeCurrency":"BNB"},{"gid":1321,"id":274327406,"exchange":"binance","price":6350,"quantity":0.02,"quoteQuantity":127,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-20T18:23:29.548Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1322,"id":274850760,"exchange":"binance","price":6318,"quantity":0.02,"quoteQuantity":126.36,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-21T02:04:16.543Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1323,"id":275113849,"exchange":"binance","price":6111.4,"quantity":0.00327,"quoteQuantity":19.984278,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-21T08:59:24.199Z","fee":0.00000327,"feeCurrency":"BTC"},{"gid":1324,"id":275113850,"exchange":"binance","price":6111.57,"quantity":0.00673,"quoteQuantity":41.1308661,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-21T08:59:24.199Z","fee":0.00000673,"feeCurrency":"BTC"},{"gid":1325,"id":275113961,"exchange":"binance","price":6113,"quantity":0.003271,"quoteQuantity":19.995623,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-21T08:59:38.964Z","fee":0.00000327,"feeCurrency":"BTC"},{"gid":1326,"id":275113962,"exchange":"binance","price":6113,"quantity":0.006729,"quoteQuantity":41.134377,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-21T08:59:38.977Z","fee":0.00000673,"feeCurrency":"BTC"},{"gid":1327,"id":275761704,"exchange":"binance","price":6122,"quantity":0.01,"quoteQuantity":61.22,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-22T00:13:57.965Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1328,"id":276254673,"exchange":"binance","price":6055.68,"quantity":0.02,"quoteQuantity":121.1136,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-22T14:07:18.351Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1329,"id":276431354,"exchange":"binance","price":5880,"quantity":0.02,"quoteQuantity":117.6,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-22T18:34:46.905Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1330,"id":276892885,"exchange":"binance","price":5884.75,"quantity":0.01,"quoteQuantity":58.8475,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-23T05:21:25.465Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1331,"id":276895477,"exchange":"binance","price":5900,"quantity":0.01,"quoteQuantity":59,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-23T05:27:12.284Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1332,"id":277165835,"exchange":"binance","price":6251.24,"quantity":0.01,"quoteQuantity":62.5124,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-23T12:28:34.247Z","fee":0.0625124,"feeCurrency":"USDT"},{"gid":1333,"id":277166548,"exchange":"binance","price":6236.83,"quantity":0.01,"quoteQuantity":62.3683,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-23T12:28:52.343Z","fee":0.0623683,"feeCurrency":"USDT"},{"gid":1334,"id":277167442,"exchange":"binance","price":6208.84,"quantity":0.05,"quoteQuantity":310.442,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-23T12:29:17.683Z","fee":0.310442,"feeCurrency":"USDT"},{"gid":1335,"id":277261055,"exchange":"binance","price":6511.93,"quantity":0.05,"quoteQuantity":325.5965,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-23T13:07:29.705Z","fee":0.3255965,"feeCurrency":"USDT"},{"gid":1336,"id":277397504,"exchange":"binance","price":6274.38,"quantity":0.05,"quoteQuantity":313.719,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-23T14:12:26.192Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1337,"id":277524851,"exchange":"binance","price":6300,"quantity":0.1,"quoteQuantity":630,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-23T16:48:38.866Z","fee":0.63,"feeCurrency":"USDT"},{"gid":1338,"id":277899057,"exchange":"binance","price":6537.66,"quantity":0.003057,"quoteQuantity":19.98562662,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-24T01:23:47.461Z","fee":0.01998563,"feeCurrency":"USDT"},{"gid":1339,"id":277899058,"exchange":"binance","price":6537.65,"quantity":0.096943,"quoteQuantity":633.77940395,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-24T01:23:47.461Z","fee":0.6337794,"feeCurrency":"USDT"},{"gid":1340,"id":277899879,"exchange":"binance","price":6550,"quantity":0.056,"quoteQuantity":366.8,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-24T01:25:14.704Z","fee":0.3668,"feeCurrency":"USDT"},{"gid":1341,"id":277899880,"exchange":"binance","price":6550,"quantity":0.003834,"quoteQuantity":25.1127,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-24T01:25:14.812Z","fee":0.0251127,"feeCurrency":"USDT"},{"gid":1342,"id":277899901,"exchange":"binance","price":6550,"quantity":0.040166,"quoteQuantity":263.0873,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-24T01:25:19.797Z","fee":0.2630873,"feeCurrency":"USDT"},{"gid":1343,"id":278029752,"exchange":"binance","price":6434.83,"quantity":0.01,"quoteQuantity":64.3483,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-24T05:06:47.684Z","fee":0.0643483,"feeCurrency":"USDT"},{"gid":1344,"id":278029819,"exchange":"binance","price":6440,"quantity":0.01,"quoteQuantity":64.4,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-24T05:07:04.043Z","fee":0.0644,"feeCurrency":"USDT"},{"gid":1345,"id":278031796,"exchange":"binance","price":6440,"quantity":0.02,"quoteQuantity":128.8,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-24T05:10:55.384Z","fee":0.1288,"feeCurrency":"USDT"},{"gid":1346,"id":278075544,"exchange":"binance","price":6577,"quantity":0.01,"quoteQuantity":65.77,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-24T06:53:31.053Z","fee":0.06577,"feeCurrency":"USDT"},{"gid":1347,"id":278075633,"exchange":"binance","price":6575.64,"quantity":0.01,"quoteQuantity":65.7564,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-24T06:53:48.731Z","fee":0.0657564,"feeCurrency":"USDT"},{"gid":1348,"id":278082791,"exchange":"binance","price":6600,"quantity":0.2,"quoteQuantity":1320,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-24T07:07:31.946Z","fee":1.32,"feeCurrency":"USDT"},{"gid":1349,"id":278117318,"exchange":"binance","price":6635.58,"quantity":0.02,"quoteQuantity":132.7116,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-24T08:09:49.966Z","fee":0.1327116,"feeCurrency":"USDT"},{"gid":1350,"id":278118918,"exchange":"binance","price":6640,"quantity":0.02,"quoteQuantity":132.8,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-24T08:11:35.616Z","fee":0.1328,"feeCurrency":"USDT"},{"gid":1351,"id":278127759,"exchange":"binance","price":6700,"quantity":0.1,"quoteQuantity":670,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-24T08:20:05.414Z","fee":0.67,"feeCurrency":"USDT"},{"gid":1352,"id":278173856,"exchange":"binance","price":6732.92,"quantity":0.02,"quoteQuantity":134.6584,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-24T08:41:16.714Z","fee":0.1346584,"feeCurrency":"USDT"},{"gid":1353,"id":278229176,"exchange":"binance","price":6800,"quantity":0.1,"quoteQuantity":680,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-24T09:47:19.844Z","fee":0.68,"feeCurrency":"USDT"},{"gid":1354,"id":278275281,"exchange":"binance","price":6700,"quantity":0.05,"quoteQuantity":335,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-24T10:36:52.135Z","fee":0.335,"feeCurrency":"USDT"},{"gid":1355,"id":278275282,"exchange":"binance","price":6700,"quantity":0.05,"quoteQuantity":335,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-24T10:36:52.162Z","fee":0.335,"feeCurrency":"USDT"},{"gid":1356,"id":278279025,"exchange":"binance","price":6705.81,"quantity":0.02,"quoteQuantity":134.1162,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-24T10:41:33.985Z","fee":0.1341162,"feeCurrency":"USDT"},{"gid":1357,"id":278279237,"exchange":"binance","price":6706.76,"quantity":0.034287,"quoteQuantity":229.95468012,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-24T10:41:47.061Z","fee":0.22995468,"feeCurrency":"USDT"},{"gid":1358,"id":278279238,"exchange":"binance","price":6706.75,"quantity":0.021743,"quoteQuantity":145.82486525,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-24T10:41:47.061Z","fee":0.14582487,"feeCurrency":"USDT"},{"gid":1359,"id":278541598,"exchange":"binance","price":6572.13,"quantity":0.1,"quoteQuantity":657.213,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-24T15:43:55.663Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1360,"id":278541661,"exchange":"binance","price":6573.41,"quantity":0.00599,"quoteQuantity":39.3747259,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-24T15:44:02.964Z","fee":0.00000599,"feeCurrency":"BTC"},{"gid":1361,"id":278541662,"exchange":"binance","price":6574.95,"quantity":0.003041,"quoteQuantity":19.99442295,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-24T15:44:02.964Z","fee":0.00000304,"feeCurrency":"BTC"},{"gid":1362,"id":278541663,"exchange":"binance","price":6574.96,"quantity":0.090969,"quoteQuantity":598.11753624,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-24T15:44:02.964Z","fee":0.00009097,"feeCurrency":"BTC"},{"gid":1363,"id":278545091,"exchange":"binance","price":6580,"quantity":0.029265,"quoteQuantity":192.5637,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-24T15:46:23.301Z","fee":0.1925637,"feeCurrency":"USDT"},{"gid":1364,"id":278545095,"exchange":"binance","price":6580,"quantity":0.070735,"quoteQuantity":465.4363,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-24T15:46:23.949Z","fee":0.4654363,"feeCurrency":"USDT"},{"gid":1365,"id":278614592,"exchange":"binance","price":6660,"quantity":0.04872,"quoteQuantity":324.4752,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-24T17:33:55.428Z","fee":0.3244752,"feeCurrency":"USDT"},{"gid":1366,"id":278614691,"exchange":"binance","price":6660,"quantity":0.05108,"quoteQuantity":340.1928,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-24T17:34:14.537Z","fee":0.3401928,"feeCurrency":"USDT"},{"gid":1367,"id":279080708,"exchange":"binance","price":6800,"quantity":0.1,"quoteQuantity":680,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-25T08:59:12.08Z","fee":0.68,"feeCurrency":"USDT"},{"gid":1368,"id":279118210,"exchange":"binance","price":6900,"quantity":0.1,"quoteQuantity":690,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-25T09:21:39.275Z","fee":0.69,"feeCurrency":"USDT"},{"gid":1369,"id":279374390,"exchange":"binance","price":6604.76,"quantity":0.1,"quoteQuantity":660.476,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-25T13:12:47.768Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1370,"id":279375511,"exchange":"binance","price":6611.04,"quantity":0.002977,"quoteQuantity":19.68106608,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-25T13:13:51.192Z","fee":0.01968107,"feeCurrency":"USDT"},{"gid":1371,"id":279375518,"exchange":"binance","price":6611.04,"quantity":0.09692,"quoteQuantity":640.7419968,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-25T13:13:53.066Z","fee":0.640742,"feeCurrency":"USDT"},{"gid":1372,"id":279375519,"exchange":"binance","price":6611.04,"quantity":0.000003,"quoteQuantity":0.01983312,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-25T13:13:53.086Z","fee":0.0000012,"feeCurrency":"BNB"},{"gid":1373,"id":280045023,"exchange":"binance","price":6595.76,"quantity":0.369131,"quoteQuantity":2434.69948456,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-26T12:02:29.669Z","fee":0.00036913,"feeCurrency":"BTC"},{"gid":1374,"id":280045024,"exchange":"binance","price":6596.19,"quantity":0.630869,"quoteQuantity":4161.33178911,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-26T12:02:29.669Z","fee":0.00063087,"feeCurrency":"BTC"},{"gid":1375,"id":280047825,"exchange":"binance","price":6606.01,"quantity":0.092503,"quoteQuantity":611.07574303,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-26T12:07:46.557Z","fee":0.61107574,"feeCurrency":"USDT"},{"gid":1376,"id":280047826,"exchange":"binance","price":6605.83,"quantity":0.109998,"quoteQuantity":726.62808834,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-26T12:07:46.557Z","fee":0.72662809,"feeCurrency":"USDT"},{"gid":1377,"id":280047827,"exchange":"binance","price":6605.5,"quantity":0.004193,"quoteQuantity":27.6968615,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-26T12:07:46.557Z","fee":0.02769686,"feeCurrency":"USDT"},{"gid":1378,"id":280047828,"exchange":"binance","price":6605.49,"quantity":0.093686,"quoteQuantity":618.84193614,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-26T12:07:46.557Z","fee":0.61884194,"feeCurrency":"USDT"},{"gid":1379,"id":280047829,"exchange":"binance","price":6605.48,"quantity":0.111611,"quoteQuantity":737.24422828,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-26T12:07:46.557Z","fee":0.73724423,"feeCurrency":"USDT"},{"gid":1380,"id":280047830,"exchange":"binance","price":6605.47,"quantity":0.4,"quoteQuantity":2642.188,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-26T12:07:46.557Z","fee":2.642188,"feeCurrency":"USDT"},{"gid":1381,"id":280047831,"exchange":"binance","price":6605.28,"quantity":0.187009,"quoteQuantity":1235.24680752,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-26T12:07:46.557Z","fee":1.23524681,"feeCurrency":"USDT"},{"gid":1382,"id":280100352,"exchange":"binance","price":6512,"quantity":0.2,"quoteQuantity":1302.4,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-26T13:33:30.943Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":1383,"id":280202793,"exchange":"binance","price":6622,"quantity":0.006089,"quoteQuantity":40.321358,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-26T16:44:27.912Z","fee":0.04032136,"feeCurrency":"USDT"},{"gid":1384,"id":280202798,"exchange":"binance","price":6622,"quantity":0.072667,"quoteQuantity":481.200874,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-26T16:44:29.822Z","fee":0.48120087,"feeCurrency":"USDT"},{"gid":1385,"id":280202801,"exchange":"binance","price":6622,"quantity":0.00302,"quoteQuantity":19.99844,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-26T16:44:31.288Z","fee":0.01999844,"feeCurrency":"USDT"},{"gid":1386,"id":280202802,"exchange":"binance","price":6622,"quantity":0.11802,"quoteQuantity":781.52844,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-26T16:44:31.355Z","fee":0.78152844,"feeCurrency":"USDT"},{"gid":1387,"id":280202803,"exchange":"binance","price":6622,"quantity":0.000004,"quoteQuantity":0.026488,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-26T16:44:31.411Z","fee":0.00000161,"feeCurrency":"BNB"},{"gid":1388,"id":280714633,"exchange":"binance","price":6649.64,"quantity":0.02,"quoteQuantity":132.9928,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-27T11:46:35.073Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1389,"id":280724199,"exchange":"binance","price":6600,"quantity":0.02,"quoteQuantity":132,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-27T12:02:03.919Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1390,"id":280790445,"exchange":"binance","price":6614,"quantity":0.011,"quoteQuantity":72.754,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-27T14:15:11.899Z","fee":0.072754,"feeCurrency":"USDT"},{"gid":1391,"id":280790446,"exchange":"binance","price":6614,"quantity":0.006048,"quoteQuantity":40.001472,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-27T14:15:12.016Z","fee":0.04000147,"feeCurrency":"USDT"},{"gid":1392,"id":280790447,"exchange":"binance","price":6614,"quantity":0.002952,"quoteQuantity":19.524528,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-27T14:15:12.083Z","fee":0.01952453,"feeCurrency":"USDT"},{"gid":1393,"id":280988731,"exchange":"binance","price":6532,"quantity":0.119759,"quoteQuantity":782.265788,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-27T23:43:15.503Z","fee":0.00011976,"feeCurrency":"BTC"},{"gid":1394,"id":280988732,"exchange":"binance","price":6532,"quantity":0.115497,"quoteQuantity":754.426404,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-27T23:43:15.503Z","fee":0.0001155,"feeCurrency":"BTC"},{"gid":1395,"id":280988733,"exchange":"binance","price":6532,"quantity":0.049154,"quoteQuantity":321.073928,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-27T23:43:15.503Z","fee":0.00004915,"feeCurrency":"BTC"},{"gid":1396,"id":280988734,"exchange":"binance","price":6532,"quantity":0.21559,"quoteQuantity":1408.23388,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-27T23:43:15.503Z","fee":0.00021559,"feeCurrency":"BTC"},{"gid":1397,"id":281059141,"exchange":"binance","price":6211,"quantity":0.1,"quoteQuantity":621.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-28T00:03:27.156Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1398,"id":281223134,"exchange":"binance","price":6098.95,"quantity":0.00328,"quoteQuantity":20.004556,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-28T03:40:57.162Z","fee":0.00000328,"feeCurrency":"BTC"},{"gid":1399,"id":281223135,"exchange":"binance","price":6098.96,"quantity":0.09672,"quoteQuantity":589.8914112,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-28T03:40:57.162Z","fee":0.00009672,"feeCurrency":"BTC"},{"gid":1400,"id":281253474,"exchange":"binance","price":6143.81,"quantity":0.003255,"quoteQuantity":19.99810155,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-28T04:22:28.201Z","fee":0.00000326,"feeCurrency":"BTC"},{"gid":1401,"id":281253475,"exchange":"binance","price":6143.82,"quantity":0.096745,"quoteQuantity":594.3838659,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-28T04:22:28.201Z","fee":0.00009675,"feeCurrency":"BTC"},{"gid":1402,"id":281319235,"exchange":"binance","price":6140,"quantity":0.337114,"quoteQuantity":2069.87996,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-28T06:34:07.213Z","fee":2.06987996,"feeCurrency":"USDT"},{"gid":1403,"id":281319236,"exchange":"binance","price":6140,"quantity":0.056258,"quoteQuantity":345.42412,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-28T06:34:07.213Z","fee":0.34542412,"feeCurrency":"USDT"},{"gid":1404,"id":281319237,"exchange":"binance","price":6140,"quantity":0.006628,"quoteQuantity":40.69592,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-28T06:34:07.357Z","fee":0.04069592,"feeCurrency":"USDT"},{"gid":1405,"id":281321298,"exchange":"binance","price":6151.24,"quantity":0.1,"quoteQuantity":615.124,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-28T06:40:47.404Z","fee":0.615124,"feeCurrency":"USDT"},{"gid":1406,"id":281330581,"exchange":"binance","price":6176.36,"quantity":0.000003,"quoteQuantity":0.01852908,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-28T07:03:57.82Z","fee":0.00000114,"feeCurrency":"BNB"},{"gid":1407,"id":281330582,"exchange":"binance","price":6176.26,"quantity":0.099997,"quoteQuantity":617.60747122,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-28T07:03:57.82Z","fee":0.61760747,"feeCurrency":"USDT"},{"gid":1408,"id":281374666,"exchange":"binance","price":6260,"quantity":0.003195,"quoteQuantity":20.0007,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-28T08:53:26.445Z","fee":0.0000032,"feeCurrency":"BTC"},{"gid":1409,"id":281374667,"exchange":"binance","price":6260,"quantity":0.2,"quoteQuantity":1252,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-28T08:53:26.527Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":1410,"id":281374670,"exchange":"binance","price":6260,"quantity":0.161149,"quoteQuantity":1008.79274,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-28T08:53:26.82Z","fee":0.00016115,"feeCurrency":"BTC"},{"gid":1411,"id":281374671,"exchange":"binance","price":6260,"quantity":0.635656,"quoteQuantity":3979.20656,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-28T08:53:26.834Z","fee":0.00063566,"feeCurrency":"BTC"},{"gid":1412,"id":281375523,"exchange":"binance","price":6231.35,"quantity":1,"quoteQuantity":6231.35,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-28T08:54:14.15Z","fee":6.23135,"feeCurrency":"USDT"},{"gid":1413,"id":282032467,"exchange":"binance","price":6095,"quantity":0.117289,"quoteQuantity":714.876455,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-29T16:29:40.196Z","fee":0.00011729,"feeCurrency":"BTC"},{"gid":1414,"id":282032468,"exchange":"binance","price":6095,"quantity":0.3,"quoteQuantity":1828.5,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-29T16:29:40.196Z","fee":0.0003,"feeCurrency":"BTC"},{"gid":1415,"id":282032469,"exchange":"binance","price":6095,"quantity":0.1,"quoteQuantity":609.5,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-29T16:29:40.206Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1416,"id":282032470,"exchange":"binance","price":6095,"quantity":0.019571,"quoteQuantity":119.285245,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-29T16:29:40.229Z","fee":0.00001957,"feeCurrency":"BTC"},{"gid":1417,"id":282032471,"exchange":"binance","price":6095,"quantity":0.009845,"quoteQuantity":60.005275,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-29T16:29:40.518Z","fee":0.00000985,"feeCurrency":"BTC"},{"gid":1418,"id":282032472,"exchange":"binance","price":6095,"quantity":0.003281,"quoteQuantity":19.997695,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-29T16:29:40.534Z","fee":0.00000328,"feeCurrency":"BTC"},{"gid":1419,"id":282032473,"exchange":"binance","price":6095,"quantity":0.1,"quoteQuantity":609.5,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-29T16:29:40.575Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1420,"id":282032474,"exchange":"binance","price":6095,"quantity":0.1,"quoteQuantity":609.5,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-29T16:29:40.575Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1421,"id":282032475,"exchange":"binance","price":6095,"quantity":0.1,"quoteQuantity":609.5,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-29T16:29:40.592Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1422,"id":282032476,"exchange":"binance","price":6095,"quantity":0.150014,"quoteQuantity":914.33533,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-29T16:29:40.615Z","fee":0.00015001,"feeCurrency":"BTC"},{"gid":1423,"id":282075951,"exchange":"binance","price":6099,"quantity":0.000851,"quoteQuantity":5.190249,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-29T18:11:23.223Z","fee":0.00519025,"feeCurrency":"USDT"},{"gid":1424,"id":282075952,"exchange":"binance","price":6099,"quantity":0.1,"quoteQuantity":609.9,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-29T18:11:23.233Z","fee":0.6099,"feeCurrency":"USDT"},{"gid":1425,"id":282075960,"exchange":"binance","price":6099,"quantity":0.0108,"quoteQuantity":65.8692,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-29T18:11:25.19Z","fee":0.0658692,"feeCurrency":"USDT"},{"gid":1426,"id":282075961,"exchange":"binance","price":6099,"quantity":0.11,"quoteQuantity":670.89,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-29T18:11:25.577Z","fee":0.67089,"feeCurrency":"USDT"},{"gid":1427,"id":282075972,"exchange":"binance","price":6099,"quantity":0.409903,"quoteQuantity":2499.998397,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-29T18:11:34.248Z","fee":2.4999984,"feeCurrency":"USDT"},{"gid":1428,"id":282075973,"exchange":"binance","price":6099,"quantity":0.368446,"quoteQuantity":2247.152154,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-29T18:11:34.802Z","fee":2.24715215,"feeCurrency":"USDT"},{"gid":1429,"id":282100875,"exchange":"binance","price":6021,"quantity":0.1,"quoteQuantity":602.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-29T19:31:06.07Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1430,"id":282467127,"exchange":"binance","price":6209.49,"quantity":0.050821,"quoteQuantity":315.57249129,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-30T06:45:17.555Z","fee":0.00005082,"feeCurrency":"BTC"},{"gid":1431,"id":282467128,"exchange":"binance","price":6209.5,"quantity":0.015,"quoteQuantity":93.1425,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-30T06:45:17.555Z","fee":0.000015,"feeCurrency":"BTC"},{"gid":1432,"id":282467129,"exchange":"binance","price":6209.99,"quantity":0.208585,"quoteQuantity":1295.31076415,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-30T06:45:17.555Z","fee":0.00020859,"feeCurrency":"BTC"},{"gid":1433,"id":282467130,"exchange":"binance","price":6210,"quantity":0.047434,"quoteQuantity":294.56514,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-30T06:45:17.555Z","fee":0.00004743,"feeCurrency":"BTC"},{"gid":1434,"id":282467131,"exchange":"binance","price":6210,"quantity":0.003217,"quoteQuantity":19.97757,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-30T06:45:17.68Z","fee":0.00000322,"feeCurrency":"BTC"},{"gid":1435,"id":282467132,"exchange":"binance","price":6210,"quantity":0.008468,"quoteQuantity":52.58628,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-30T06:45:17.761Z","fee":0.00000847,"feeCurrency":"BTC"},{"gid":1436,"id":282467133,"exchange":"binance","price":6210,"quantity":0.038658,"quoteQuantity":240.06618,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-30T06:45:17.841Z","fee":0.00003866,"feeCurrency":"BTC"},{"gid":1437,"id":282467134,"exchange":"binance","price":6210,"quantity":0.022356,"quoteQuantity":138.83076,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-30T06:45:17.874Z","fee":0.00002236,"feeCurrency":"BTC"},{"gid":1438,"id":282467135,"exchange":"binance","price":6210,"quantity":0.0261,"quoteQuantity":162.081,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-30T06:45:17.885Z","fee":0.0000261,"feeCurrency":"BTC"},{"gid":1439,"id":282467137,"exchange":"binance","price":6210,"quantity":0.099139,"quoteQuantity":615.65319,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-30T06:45:17.982Z","fee":0.00009914,"feeCurrency":"BTC"},{"gid":1440,"id":282467138,"exchange":"binance","price":6210,"quantity":0.003221,"quoteQuantity":20.00241,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-30T06:45:18.071Z","fee":0.00000322,"feeCurrency":"BTC"},{"gid":1441,"id":282467139,"exchange":"binance","price":6210,"quantity":0.083549,"quoteQuantity":518.83929,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-30T06:45:18.081Z","fee":0.00008355,"feeCurrency":"BTC"},{"gid":1442,"id":282467140,"exchange":"binance","price":6210,"quantity":0.39345,"quoteQuantity":2443.3245,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-30T06:45:18.1Z","fee":0.00039345,"feeCurrency":"BTC"},{"gid":1443,"id":282467142,"exchange":"binance","price":6210,"quantity":0.000002,"quoteQuantity":0.01242,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-30T06:45:18.171Z","fee":0.00000393,"feeCurrency":"BNB"},{"gid":1444,"id":282503510,"exchange":"binance","price":6275,"quantity":1,"quoteQuantity":6275,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-30T07:19:51.207Z","fee":6.275,"feeCurrency":"USDT"},{"gid":1445,"id":282520820,"exchange":"binance","price":6243,"quantity":1,"quoteQuantity":6243,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-30T07:47:50.065Z","fee":0.001,"feeCurrency":"BTC"},{"gid":1446,"id":282545754,"exchange":"binance","price":6250.67,"quantity":0.185834,"quoteQuantity":1161.58700878,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-30T08:38:35.949Z","fee":1.16158701,"feeCurrency":"USDT"},{"gid":1447,"id":282545755,"exchange":"binance","price":6250.65,"quantity":0.360128,"quoteQuantity":2251.0340832,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-30T08:38:35.949Z","fee":2.25103408,"feeCurrency":"USDT"},{"gid":1448,"id":282545756,"exchange":"binance","price":6250.62,"quantity":0.003198,"quoteQuantity":19.98948276,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-30T08:38:35.949Z","fee":0.01998948,"feeCurrency":"USDT"},{"gid":1449,"id":282545757,"exchange":"binance","price":6250.55,"quantity":0.45084,"quoteQuantity":2817.997962,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-30T08:38:35.949Z","fee":2.81799796,"feeCurrency":"USDT"},{"gid":1450,"id":282645243,"exchange":"binance","price":6357,"quantity":0.398582,"quoteQuantity":2533.785774,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-30T11:56:47.224Z","fee":0.00039858,"feeCurrency":"BTC"},{"gid":1451,"id":282645247,"exchange":"binance","price":6357,"quantity":0.057313,"quoteQuantity":364.338741,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-30T11:56:47.355Z","fee":0.00005731,"feeCurrency":"BTC"},{"gid":1452,"id":282645250,"exchange":"binance","price":6357,"quantity":0.205725,"quoteQuantity":1307.793825,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-30T11:56:47.595Z","fee":0.00020573,"feeCurrency":"BTC"},{"gid":1453,"id":282645251,"exchange":"binance","price":6357,"quantity":0.1,"quoteQuantity":635.7,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-30T11:56:47.632Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1454,"id":282645252,"exchange":"binance","price":6357,"quantity":0.23838,"quoteQuantity":1515.38166,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-30T11:56:47.66Z","fee":0.00023838,"feeCurrency":"BTC"},{"gid":1455,"id":282660778,"exchange":"binance","price":6330,"quantity":0.1,"quoteQuantity":633,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-30T12:19:12.602Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1456,"id":282675194,"exchange":"binance","price":6303.78,"quantity":1,"quoteQuantity":6303.78,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-30T12:48:21.803Z","fee":6.30378,"feeCurrency":"USDT"},{"gid":1457,"id":282728553,"exchange":"binance","price":6328.99,"quantity":0.2,"quoteQuantity":1265.798,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-30T14:37:54.617Z","fee":1.265798,"feeCurrency":"USDT"},{"gid":1458,"id":282780135,"exchange":"binance","price":6333.73,"quantity":0.045644,"quoteQuantity":289.09677212,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-30T15:50:00.805Z","fee":0.00004564,"feeCurrency":"BTC"},{"gid":1459,"id":282780136,"exchange":"binance","price":6334,"quantity":0.017038,"quoteQuantity":107.918692,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-30T15:50:01.116Z","fee":0.00001704,"feeCurrency":"BTC"},{"gid":1460,"id":282780137,"exchange":"binance","price":6334,"quantity":0.087705,"quoteQuantity":555.52347,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-30T15:50:01.491Z","fee":0.00008771,"feeCurrency":"BTC"},{"gid":1461,"id":282780138,"exchange":"binance","price":6334,"quantity":0.003157,"quoteQuantity":19.996438,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-30T15:50:01.556Z","fee":0.00000316,"feeCurrency":"BTC"},{"gid":1462,"id":282780139,"exchange":"binance","price":6334,"quantity":0.01,"quoteQuantity":63.34,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-30T15:50:01.667Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1463,"id":282780140,"exchange":"binance","price":6334,"quantity":0.02732,"quoteQuantity":173.04488,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-30T15:50:01.695Z","fee":0.00002732,"feeCurrency":"BTC"},{"gid":1464,"id":282780141,"exchange":"binance","price":6334,"quantity":0.265508,"quoteQuantity":1681.727672,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-30T15:50:01.704Z","fee":0.00026551,"feeCurrency":"BTC"},{"gid":1465,"id":282780142,"exchange":"binance","price":6334,"quantity":0.235755,"quoteQuantity":1493.27217,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-30T15:50:01.729Z","fee":0.00023576,"feeCurrency":"BTC"},{"gid":1466,"id":282780143,"exchange":"binance","price":6334,"quantity":0.280385,"quoteQuantity":1775.95859,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-30T15:50:01.764Z","fee":0.00028039,"feeCurrency":"BTC"},{"gid":1467,"id":282780144,"exchange":"binance","price":6334,"quantity":0.027488,"quoteQuantity":174.108992,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-30T15:50:01.778Z","fee":0.00002749,"feeCurrency":"BTC"},{"gid":1468,"id":283093021,"exchange":"binance","price":6514.73,"quantity":1,"quoteQuantity":6514.73,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-31T02:00:11.683Z","fee":6.51473,"feeCurrency":"USDT"},{"gid":1469,"id":283097984,"exchange":"binance","price":6472,"quantity":0.008911,"quoteQuantity":57.671992,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-31T02:11:29.503Z","fee":0.00000891,"feeCurrency":"BTC"},{"gid":1470,"id":283097985,"exchange":"binance","price":6472,"quantity":0.01504,"quoteQuantity":97.33888,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-31T02:11:29.503Z","fee":0.00001504,"feeCurrency":"BTC"},{"gid":1471,"id":283098014,"exchange":"binance","price":6472,"quantity":0.826503,"quoteQuantity":5349.127416,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-31T02:11:30.359Z","fee":0.0008265,"feeCurrency":"BTC"},{"gid":1472,"id":283098040,"exchange":"binance","price":6472,"quantity":0.03993,"quoteQuantity":258.42696,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-31T02:11:33.495Z","fee":0.00003993,"feeCurrency":"BTC"},{"gid":1473,"id":283098041,"exchange":"binance","price":6472,"quantity":0.012,"quoteQuantity":77.664,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-31T02:11:33.509Z","fee":0.000012,"feeCurrency":"BTC"},{"gid":1474,"id":283098042,"exchange":"binance","price":6472,"quantity":0.002962,"quoteQuantity":19.170064,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-31T02:11:33.649Z","fee":0.00000296,"feeCurrency":"BTC"},{"gid":1475,"id":283098044,"exchange":"binance","price":6472,"quantity":0.09465,"quoteQuantity":612.5748,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-31T02:11:33.995Z","fee":0.00009465,"feeCurrency":"BTC"},{"gid":1476,"id":283098045,"exchange":"binance","price":6472,"quantity":0.000004,"quoteQuantity":0.025888,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-31T02:11:34.034Z","fee":0.00000395,"feeCurrency":"BNB"},{"gid":1477,"id":283117574,"exchange":"binance","price":6436.3,"quantity":0.1,"quoteQuantity":643.63,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-31T03:06:27.746Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1478,"id":283117724,"exchange":"binance","price":6435.44,"quantity":0.005,"quoteQuantity":32.1772,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-31T03:06:48.987Z","fee":0.000005,"feeCurrency":"BTC"},{"gid":1479,"id":283117725,"exchange":"binance","price":6436.04,"quantity":0.095,"quoteQuantity":611.4238,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-31T03:06:48.987Z","fee":0.000095,"feeCurrency":"BTC"},{"gid":1480,"id":283195510,"exchange":"binance","price":6451.27,"quantity":0.134015,"quoteQuantity":864.56694905,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-31T06:54:38.963Z","fee":0.86456695,"feeCurrency":"USDT"},{"gid":1481,"id":283195515,"exchange":"binance","price":6451.21,"quantity":0.001717,"quoteQuantity":11.07672757,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-31T06:54:41.34Z","fee":0.01107673,"feeCurrency":"USDT"},{"gid":1482,"id":283195521,"exchange":"binance","price":6451.21,"quantity":0.064268,"quoteQuantity":414.60636428,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-31T06:54:43.071Z","fee":0.41460636,"feeCurrency":"USDT"},{"gid":1483,"id":283214460,"exchange":"binance","price":6449.65,"quantity":0.1,"quoteQuantity":644.965,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-31T08:02:50.444Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1484,"id":283214558,"exchange":"binance","price":6449.26,"quantity":0.1,"quoteQuantity":644.926,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-03-31T08:03:04.481Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1485,"id":283261310,"exchange":"binance","price":6430,"quantity":0.05,"quoteQuantity":321.5,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-31T10:00:56.176Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1486,"id":283261678,"exchange":"binance","price":6420,"quantity":0.05,"quoteQuantity":321,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-31T10:01:06.622Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1487,"id":283262598,"exchange":"binance","price":6420,"quantity":0.05,"quoteQuantity":321,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-31T10:02:16.889Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1488,"id":283277035,"exchange":"binance","price":6411.63,"quantity":0.338561,"quoteQuantity":2170.72786443,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-31T10:48:06.766Z","fee":2.17072786,"feeCurrency":"USDT"},{"gid":1489,"id":283277036,"exchange":"binance","price":6411.62,"quantity":0.861439,"quoteQuantity":5523.21952118,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-31T10:48:06.766Z","fee":5.52321952,"feeCurrency":"USDT"},{"gid":1490,"id":283277142,"exchange":"binance","price":6410.69,"quantity":0.02097,"quoteQuantity":134.4321693,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-31T10:48:30.341Z","fee":0.13443217,"feeCurrency":"USDT"},{"gid":1491,"id":283277143,"exchange":"binance","price":6410.68,"quantity":0.17903,"quoteQuantity":1147.7040404,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-31T10:48:30.341Z","fee":1.14770404,"feeCurrency":"USDT"},{"gid":1492,"id":283278157,"exchange":"binance","price":6415,"quantity":0.313745,"quoteQuantity":2012.674175,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-31T10:52:42.028Z","fee":0.00031375,"feeCurrency":"BTC"},{"gid":1493,"id":283278163,"exchange":"binance","price":6415,"quantity":0.156606,"quoteQuantity":1004.62749,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-31T10:52:46.93Z","fee":0.00015661,"feeCurrency":"BTC"},{"gid":1494,"id":283278164,"exchange":"binance","price":6415,"quantity":0.34952,"quoteQuantity":2242.1708,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-31T10:52:48.11Z","fee":0.00034952,"feeCurrency":"BTC"},{"gid":1495,"id":283278167,"exchange":"binance","price":6415,"quantity":0.032703,"quoteQuantity":209.789745,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-31T10:52:51.195Z","fee":0.0000327,"feeCurrency":"BTC"},{"gid":1496,"id":283278170,"exchange":"binance","price":6415,"quantity":0.147426,"quoteQuantity":945.73779,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-03-31T10:52:53.277Z","fee":0.00014743,"feeCurrency":"BTC"},{"gid":1497,"id":283287988,"exchange":"binance","price":6362.71,"quantity":1,"quoteQuantity":6362.71,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-31T11:10:19.515Z","fee":6.36271,"feeCurrency":"USDT"},{"gid":1498,"id":283296802,"exchange":"binance","price":6380,"quantity":0.156141,"quoteQuantity":996.17958,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-03-31T11:18:24.233Z","fee":0.99617958,"feeCurrency":"USDT"},{"gid":1499,"id":283296809,"exchange":"binance","price":6380,"quantity":0.004268,"quoteQuantity":27.22984,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-03-31T11:18:25.255Z","fee":0.02722984,"feeCurrency":"USDT"},{"gid":1500,"id":283874749,"exchange":"binance","price":6230.55,"quantity":0.026455,"quoteQuantity":164.82920025,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-01T12:07:59.876Z","fee":0.00002646,"feeCurrency":"BTC"},{"gid":1501,"id":283874750,"exchange":"binance","price":6230.57,"quantity":0.973545,"quoteQuantity":6065.74027065,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-01T12:07:59.876Z","fee":0.00097355,"feeCurrency":"BTC"},{"gid":1502,"id":283912824,"exchange":"binance","price":6220.01,"quantity":0.055079,"quoteQuantity":342.59193079,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-01T13:12:28.098Z","fee":0.34259193,"feeCurrency":"USDT"},{"gid":1503,"id":283912825,"exchange":"binance","price":6220,"quantity":0.943921,"quoteQuantity":5871.18862,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-01T13:12:28.098Z","fee":5.87118862,"feeCurrency":"USDT"},{"gid":1504,"id":285527050,"exchange":"binance","price":6784.26,"quantity":0.000304,"quoteQuantity":2.06241504,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-03T03:33:21.68Z","fee":3e-7,"feeCurrency":"BTC"},{"gid":1505,"id":285527051,"exchange":"binance","price":6784.26,"quantity":0.199695,"quoteQuantity":1354.7828007,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-03T03:33:21.816Z","fee":0.0001997,"feeCurrency":"BTC"},{"gid":1506,"id":285527052,"exchange":"binance","price":6784.26,"quantity":0.000001,"quoteQuantity":0.00678426,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-03T03:33:22.02Z","fee":1e-8,"feeCurrency":"BTC"},{"gid":1507,"id":285527315,"exchange":"binance","price":6780.32,"quantity":0.1998,"quoteQuantity":1354.707936,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-03T03:34:03.824Z","fee":1.35470794,"feeCurrency":"USDT"},{"gid":1508,"id":286568361,"exchange":"binance","price":6720.95,"quantity":0.106988,"quoteQuantity":719.0609986,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-04T11:51:48.718Z","fee":0.00010699,"feeCurrency":"BTC"},{"gid":1509,"id":286568366,"exchange":"binance","price":6720.95,"quantity":0.017025,"quoteQuantity":114.42417375,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-04T11:51:49.183Z","fee":0.00001703,"feeCurrency":"BTC"},{"gid":1510,"id":286568368,"exchange":"binance","price":6720.95,"quantity":0.002975,"quoteQuantity":19.99482625,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-04T11:51:49.329Z","fee":0.00000298,"feeCurrency":"BTC"},{"gid":1511,"id":286568389,"exchange":"binance","price":6720.95,"quantity":0.37301,"quoteQuantity":2506.9815595,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-04T11:51:50.744Z","fee":0.00037301,"feeCurrency":"BTC"},{"gid":1512,"id":286568391,"exchange":"binance","price":6720.95,"quantity":0.000002,"quoteQuantity":0.0134419,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-04T11:51:50.826Z","fee":1e-8,"feeCurrency":"BTC"},{"gid":1513,"id":286570784,"exchange":"binance","price":6713.25,"quantity":0.436853,"quoteQuantity":2932.70340225,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-04T11:54:30.257Z","fee":2.9327034,"feeCurrency":"USDT"},{"gid":1514,"id":286570785,"exchange":"binance","price":6712.97,"quantity":0.062647,"quoteQuantity":420.54743159,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-04T11:54:30.257Z","fee":0.42054743,"feeCurrency":"USDT"},{"gid":1515,"id":286575449,"exchange":"binance","price":6700,"quantity":1,"quoteQuantity":6700,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-04T11:59:50.638Z","fee":0.001,"feeCurrency":"BTC"},{"gid":1516,"id":286674421,"exchange":"binance","price":6747.07,"quantity":0.36346,"quoteQuantity":2452.2900622,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-04T16:11:44.801Z","fee":2.45229006,"feeCurrency":"USDT"},{"gid":1517,"id":286674422,"exchange":"binance","price":6747.04,"quantity":0.363469,"quoteQuantity":2452.33988176,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-04T16:11:44.801Z","fee":2.45233988,"feeCurrency":"USDT"},{"gid":1518,"id":286674423,"exchange":"binance","price":6747.01,"quantity":0.272071,"quoteQuantity":1835.66575771,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-04T16:11:44.801Z","fee":1.83566576,"feeCurrency":"USDT"},{"gid":1519,"id":287306448,"exchange":"binance","price":6719.42,"quantity":0.165359,"quoteQuantity":1111.11657178,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-05T17:33:52.083Z","fee":0.00016536,"feeCurrency":"BTC"},{"gid":1520,"id":287306449,"exchange":"binance","price":6719.48,"quantity":0.213086,"quoteQuantity":1431.82711528,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-05T17:33:52.083Z","fee":0.00021309,"feeCurrency":"BTC"},{"gid":1521,"id":287306450,"exchange":"binance","price":6719.49,"quantity":0.117411,"quoteQuantity":788.94204039,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-05T17:33:52.083Z","fee":0.00011741,"feeCurrency":"BTC"},{"gid":1522,"id":287306451,"exchange":"binance","price":6719.88,"quantity":0.005956,"quoteQuantity":40.02360528,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-05T17:33:52.083Z","fee":0.00000596,"feeCurrency":"BTC"},{"gid":1523,"id":287306452,"exchange":"binance","price":6720,"quantity":0.118584,"quoteQuantity":796.88448,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-05T17:33:52.083Z","fee":0.00011858,"feeCurrency":"BTC"},{"gid":1524,"id":287306453,"exchange":"binance","price":6720,"quantity":0.078243,"quoteQuantity":525.79296,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-05T17:33:52.083Z","fee":0.00007824,"feeCurrency":"BTC"},{"gid":1525,"id":287306454,"exchange":"binance","price":6720,"quantity":0.040779,"quoteQuantity":274.03488,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-05T17:33:52.083Z","fee":0.00004078,"feeCurrency":"BTC"},{"gid":1526,"id":287306455,"exchange":"binance","price":6720,"quantity":0.020609,"quoteQuantity":138.49248,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-05T17:33:52.083Z","fee":0.00002061,"feeCurrency":"BTC"},{"gid":1527,"id":287306456,"exchange":"binance","price":6720,"quantity":0.239973,"quoteQuantity":1612.61856,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-05T17:33:52.083Z","fee":0.00023997,"feeCurrency":"BTC"},{"gid":1528,"id":287314825,"exchange":"binance","price":6809,"quantity":0.999,"quoteQuantity":6802.191,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-05T17:46:14.02Z","fee":6.802191,"feeCurrency":"USDT"},{"gid":1529,"id":287339426,"exchange":"binance","price":6783.55,"quantity":0.1,"quoteQuantity":678.355,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-05T18:17:05.726Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1530,"id":287339747,"exchange":"binance","price":6780.01,"quantity":0.0999,"quoteQuantity":677.322999,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-05T18:18:23.041Z","fee":0.677323,"feeCurrency":"USDT"},{"gid":1531,"id":288849483,"exchange":"binance","price":7385.71,"quantity":0.165221,"quoteQuantity":1220.27439191,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-07T11:56:47.785Z","fee":0.00016522,"feeCurrency":"BTC"},{"gid":1532,"id":288849484,"exchange":"binance","price":7385.72,"quantity":0.034779,"quoteQuantity":256.86795588,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-07T11:56:47.785Z","fee":0.00003478,"feeCurrency":"BTC"},{"gid":1533,"id":288850147,"exchange":"binance","price":7382,"quantity":0.025525,"quoteQuantity":188.42555,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-07T11:57:26.771Z","fee":0.00002553,"feeCurrency":"BTC"},{"gid":1534,"id":288850148,"exchange":"binance","price":7382,"quantity":0.093281,"quoteQuantity":688.600342,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-07T11:57:26.846Z","fee":0.00009328,"feeCurrency":"BTC"},{"gid":1535,"id":288850149,"exchange":"binance","price":7382,"quantity":0.038476,"quoteQuantity":284.029832,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-07T11:57:26.953Z","fee":0.00003848,"feeCurrency":"BTC"},{"gid":1536,"id":288850152,"exchange":"binance","price":7382,"quantity":0.004367,"quoteQuantity":32.237194,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-07T11:57:27.142Z","fee":0.00000437,"feeCurrency":"BTC"},{"gid":1537,"id":288850153,"exchange":"binance","price":7382,"quantity":0.002706,"quoteQuantity":19.975692,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-07T11:57:27.263Z","fee":0.00000271,"feeCurrency":"BTC"},{"gid":1538,"id":288850166,"exchange":"binance","price":7382,"quantity":0.035645,"quoteQuantity":263.13139,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-07T11:57:27.902Z","fee":0.00003565,"feeCurrency":"BTC"},{"gid":1539,"id":288874864,"exchange":"binance","price":7411.63,"quantity":0.067073,"quoteQuantity":497.12025899,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T12:26:45.016Z","fee":0.49712026,"feeCurrency":"USDT"},{"gid":1540,"id":288874865,"exchange":"binance","price":7411.63,"quantity":0.294,"quoteQuantity":2179.01922,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T12:26:45.016Z","fee":2.17901922,"feeCurrency":"USDT"},{"gid":1541,"id":288874866,"exchange":"binance","price":7411.63,"quantity":0.038527,"quoteQuantity":285.54786901,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T12:26:45.016Z","fee":0.28554787,"feeCurrency":"USDT"},{"gid":1542,"id":288877397,"exchange":"binance","price":7405,"quantity":0.1,"quoteQuantity":740.5,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-07T12:27:53.101Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1543,"id":288877980,"exchange":"binance","price":7398.17,"quantity":0.002702,"quoteQuantity":19.98985534,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T12:28:16.358Z","fee":0.01998986,"feeCurrency":"USDT"},{"gid":1544,"id":288877981,"exchange":"binance","price":7398.16,"quantity":0.006759,"quoteQuantity":50.00416344,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T12:28:16.358Z","fee":0.05000416,"feeCurrency":"USDT"},{"gid":1545,"id":288877982,"exchange":"binance","price":7397.08,"quantity":0.090439,"quoteQuantity":668.98451812,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T12:28:16.358Z","fee":0.66898452,"feeCurrency":"USDT"},{"gid":1546,"id":288883833,"exchange":"binance","price":7377.33,"quantity":0.02,"quoteQuantity":147.5466,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-07T12:32:05.323Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1547,"id":288887544,"exchange":"binance","price":7342.64,"quantity":0.01998,"quoteQuantity":146.7059472,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T12:33:45.956Z","fee":0.14670595,"feeCurrency":"USDT"},{"gid":1548,"id":288897614,"exchange":"binance","price":7338.34,"quantity":0.003099,"quoteQuantity":22.74151566,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-07T12:37:36.885Z","fee":0.0000031,"feeCurrency":"BTC"},{"gid":1549,"id":288897615,"exchange":"binance","price":7338.73,"quantity":0.096901,"quoteQuantity":711.13027573,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-07T12:37:36.885Z","fee":0.0000969,"feeCurrency":"BTC"},{"gid":1550,"id":288897915,"exchange":"binance","price":7341.59,"quantity":0.001359,"quoteQuantity":9.97722081,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-07T12:37:50.285Z","fee":0.00000136,"feeCurrency":"BTC"},{"gid":1551,"id":288897916,"exchange":"binance","price":7341.63,"quantity":0.098641,"quoteQuantity":724.18572483,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-07T12:37:50.285Z","fee":0.00009864,"feeCurrency":"BTC"},{"gid":1552,"id":288898208,"exchange":"binance","price":7346.15,"quantity":0.025799,"quoteQuantity":189.52332385,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-07T12:38:14.093Z","fee":0.0000258,"feeCurrency":"BTC"},{"gid":1553,"id":288898209,"exchange":"binance","price":7346.67,"quantity":0.001492,"quoteQuantity":10.96123164,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-07T12:38:14.093Z","fee":0.00000149,"feeCurrency":"BTC"},{"gid":1554,"id":288898210,"exchange":"binance","price":7346.8,"quantity":0.003671,"quoteQuantity":26.9701028,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-07T12:38:14.093Z","fee":0.00000367,"feeCurrency":"BTC"},{"gid":1555,"id":288898211,"exchange":"binance","price":7347,"quantity":0.0015,"quoteQuantity":11.0205,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-07T12:38:14.093Z","fee":0.0000015,"feeCurrency":"BTC"},{"gid":1556,"id":288898212,"exchange":"binance","price":7347,"quantity":0.067538,"quoteQuantity":496.201686,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-07T12:38:14.093Z","fee":0.00006754,"feeCurrency":"BTC"},{"gid":1557,"id":288899933,"exchange":"binance","price":7349.14,"quantity":0.1,"quoteQuantity":734.914,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-07T12:39:51.282Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1558,"id":288902561,"exchange":"binance","price":7368,"quantity":0.0018,"quoteQuantity":13.2624,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-07T12:41:57.909Z","fee":0.0132624,"feeCurrency":"USDT"},{"gid":1559,"id":288902695,"exchange":"binance","price":7367.56,"quantity":0.108403,"quoteQuantity":798.66560668,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-07T12:42:10.116Z","fee":0.0001084,"feeCurrency":"BTC"},{"gid":1560,"id":288902696,"exchange":"binance","price":7367.85,"quantity":0.091597,"quoteQuantity":674.87295645,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-07T12:42:10.116Z","fee":0.0000916,"feeCurrency":"BTC"},{"gid":1561,"id":288957774,"exchange":"binance","price":7319.7,"quantity":0.166708,"quoteQuantity":1220.2525476,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T14:08:46.314Z","fee":1.22025255,"feeCurrency":"USDT"},{"gid":1562,"id":288957775,"exchange":"binance","price":7319.62,"quantity":0.033292,"quoteQuantity":243.68478904,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T14:08:46.314Z","fee":0.24368479,"feeCurrency":"USDT"},{"gid":1563,"id":288957959,"exchange":"binance","price":7315.22,"quantity":0.002733,"quoteQuantity":19.99249626,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T14:08:54.531Z","fee":0.0199925,"feeCurrency":"USDT"},{"gid":1564,"id":288957960,"exchange":"binance","price":7315.21,"quantity":0.394867,"quoteQuantity":2888.53502707,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T14:08:54.531Z","fee":2.88853503,"feeCurrency":"USDT"},{"gid":1565,"id":288961515,"exchange":"binance","price":7318,"quantity":0.273012,"quoteQuantity":1997.901816,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-07T14:11:16.65Z","fee":0.00027301,"feeCurrency":"BTC"},{"gid":1566,"id":288961516,"exchange":"binance","price":7318,"quantity":0.004267,"quoteQuantity":31.225906,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-07T14:11:16.668Z","fee":0.00000427,"feeCurrency":"BTC"},{"gid":1567,"id":288961517,"exchange":"binance","price":7318,"quantity":0.046805,"quoteQuantity":342.51899,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-07T14:11:16.775Z","fee":0.00004681,"feeCurrency":"BTC"},{"gid":1568,"id":288961657,"exchange":"binance","price":7320,"quantity":0.22108,"quoteQuantity":1618.3056,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-07T14:11:22.65Z","fee":0.00022108,"feeCurrency":"BTC"},{"gid":1569,"id":288961658,"exchange":"binance","price":7320,"quantity":0.0015,"quoteQuantity":10.98,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-07T14:11:22.65Z","fee":0.0000015,"feeCurrency":"BTC"},{"gid":1570,"id":288961659,"exchange":"binance","price":7320,"quantity":0.37742,"quoteQuantity":2762.7144,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-07T14:11:22.65Z","fee":0.00037742,"feeCurrency":"BTC"},{"gid":1571,"id":288962930,"exchange":"binance","price":7300.43,"quantity":0.002739,"quoteQuantity":19.99587777,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T14:12:43.473Z","fee":0.01999588,"feeCurrency":"USDT"},{"gid":1572,"id":288962931,"exchange":"binance","price":7300.38,"quantity":0.249995,"quoteQuantity":1825.0584981,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T14:12:43.473Z","fee":1.8250585,"feeCurrency":"USDT"},{"gid":1573,"id":288962932,"exchange":"binance","price":7299.7,"quantity":0.004584,"quoteQuantity":33.4618248,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T14:12:43.473Z","fee":0.03346182,"feeCurrency":"USDT"},{"gid":1574,"id":288962933,"exchange":"binance","price":7299.6,"quantity":0.665842,"quoteQuantity":4860.3802632,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T14:12:43.473Z","fee":4.86038026,"feeCurrency":"USDT"},{"gid":1575,"id":288978445,"exchange":"binance","price":7315,"quantity":1,"quoteQuantity":7315,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-07T14:26:56.28Z","fee":0.001,"feeCurrency":"BTC"},{"gid":1576,"id":288980244,"exchange":"binance","price":7298.35,"quantity":0.167196,"quoteQuantity":1220.2549266,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T14:29:33.343Z","fee":1.22025493,"feeCurrency":"USDT"},{"gid":1577,"id":288980245,"exchange":"binance","price":7298.32,"quantity":0.139224,"quoteQuantity":1016.10130368,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T14:29:33.343Z","fee":1.0161013,"feeCurrency":"USDT"},{"gid":1578,"id":288980246,"exchange":"binance","price":7298.32,"quantity":0.002739,"quoteQuantity":19.99009848,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T14:29:33.343Z","fee":0.0199901,"feeCurrency":"USDT"},{"gid":1579,"id":288980247,"exchange":"binance","price":7298.32,"quantity":0.689841,"quoteQuantity":5034.68036712,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T14:29:33.343Z","fee":5.03468037,"feeCurrency":"USDT"},{"gid":1580,"id":288981916,"exchange":"binance","price":7321.64,"quantity":1,"quoteQuantity":7321.64,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-07T14:32:13.184Z","fee":0.001,"feeCurrency":"BTC"},{"gid":1581,"id":288986987,"exchange":"binance","price":7297.26,"quantity":0.698977,"quoteQuantity":5100.61690302,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T14:41:55.235Z","fee":5.1006169,"feeCurrency":"USDT"},{"gid":1582,"id":288986988,"exchange":"binance","price":7297.07,"quantity":0.00274,"quoteQuantity":19.9939718,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T14:41:55.235Z","fee":0.01999397,"feeCurrency":"USDT"},{"gid":1583,"id":288986989,"exchange":"binance","price":7297.06,"quantity":0.12453,"quoteQuantity":908.7028818,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T14:41:55.235Z","fee":0.90870288,"feeCurrency":"USDT"},{"gid":1584,"id":288986990,"exchange":"binance","price":7297,"quantity":0.004197,"quoteQuantity":30.625509,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T14:41:55.235Z","fee":0.03062551,"feeCurrency":"USDT"},{"gid":1585,"id":288986991,"exchange":"binance","price":7296.9,"quantity":0.168556,"quoteQuantity":1229.9362764,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T14:41:55.235Z","fee":1.22993628,"feeCurrency":"USDT"},{"gid":1586,"id":289017462,"exchange":"binance","price":7321,"quantity":0.1,"quoteQuantity":732.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-07T15:29:34.023Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1587,"id":289023344,"exchange":"binance","price":7325,"quantity":0.4,"quoteQuantity":2930,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-07T15:38:52.783Z","fee":0.0004,"feeCurrency":"BTC"},{"gid":1588,"id":289026799,"exchange":"binance","price":7332,"quantity":0.060318,"quoteQuantity":442.251576,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-07T15:44:47.467Z","fee":0.00006032,"feeCurrency":"BTC"},{"gid":1589,"id":289026832,"exchange":"binance","price":7332,"quantity":0.06007,"quoteQuantity":440.43324,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-07T15:44:49.396Z","fee":0.00006007,"feeCurrency":"BTC"},{"gid":1590,"id":289026845,"exchange":"binance","price":7332,"quantity":0.05544,"quoteQuantity":406.48608,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-07T15:44:50.359Z","fee":0.00005544,"feeCurrency":"BTC"},{"gid":1591,"id":289026851,"exchange":"binance","price":7332,"quantity":0.024172,"quoteQuantity":177.229104,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-07T15:44:50.668Z","fee":0.00002417,"feeCurrency":"BTC"},{"gid":1592,"id":289036216,"exchange":"binance","price":7318.3,"quantity":0.044074,"quoteQuantity":322.5467542,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-07T16:00:03.547Z","fee":0.32254675,"feeCurrency":"USDT"},{"gid":1593,"id":289036217,"exchange":"binance","price":7318.3,"quantity":0.002733,"quoteQuantity":20.0009139,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-07T16:00:03.599Z","fee":0.02000091,"feeCurrency":"USDT"},{"gid":1594,"id":289036220,"exchange":"binance","price":7318.3,"quantity":0.006829,"quoteQuantity":49.9766707,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-07T16:00:04.086Z","fee":0.04997667,"feeCurrency":"USDT"},{"gid":1595,"id":289036221,"exchange":"binance","price":7318.3,"quantity":0.046364,"quoteQuantity":339.3056612,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-07T16:00:04.092Z","fee":0.33930566,"feeCurrency":"USDT"},{"gid":1596,"id":289036975,"exchange":"binance","price":7325.93,"quantity":0.1,"quoteQuantity":732.593,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T16:01:17.755Z","fee":0.732593,"feeCurrency":"USDT"},{"gid":1597,"id":289037289,"exchange":"binance","price":7327.34,"quantity":0.1,"quoteQuantity":732.734,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T16:02:03.201Z","fee":0.732734,"feeCurrency":"USDT"},{"gid":1598,"id":289037304,"exchange":"binance","price":7327.2,"quantity":0.1,"quoteQuantity":732.72,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T16:02:06.563Z","fee":0.73272,"feeCurrency":"USDT"},{"gid":1599,"id":289037367,"exchange":"binance","price":7328.6,"quantity":0.1,"quoteQuantity":732.86,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T16:02:14.575Z","fee":0.73286,"feeCurrency":"USDT"},{"gid":1600,"id":289037856,"exchange":"binance","price":7325.59,"quantity":0.1,"quoteQuantity":732.559,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T16:03:26.791Z","fee":0.732559,"feeCurrency":"USDT"},{"gid":1601,"id":289037878,"exchange":"binance","price":7325.58,"quantity":0.0993,"quoteQuantity":727.430094,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-07T16:03:31.219Z","fee":0.72743009,"feeCurrency":"USDT"},{"gid":1602,"id":289046965,"exchange":"binance","price":7366.95,"quantity":0.003695,"quoteQuantity":27.22088025,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-07T16:14:51.724Z","fee":0.0000037,"feeCurrency":"BTC"},{"gid":1603,"id":289046966,"exchange":"binance","price":7367.41,"quantity":0.033931,"quoteQuantity":249.98358871,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-07T16:14:51.724Z","fee":0.00003393,"feeCurrency":"BTC"},{"gid":1604,"id":289046967,"exchange":"binance","price":7367.55,"quantity":0.188271,"quoteQuantity":1387.09600605,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-07T16:14:51.724Z","fee":0.00018827,"feeCurrency":"BTC"},{"gid":1605,"id":289046968,"exchange":"binance","price":7367.88,"quantity":0.7,"quoteQuantity":5157.516,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-07T16:14:51.724Z","fee":0.0007,"feeCurrency":"BTC"},{"gid":1606,"id":289046969,"exchange":"binance","price":7368,"quantity":0.074103,"quoteQuantity":545.990904,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-07T16:14:51.724Z","fee":0.0000741,"feeCurrency":"BTC"},{"gid":1607,"id":289369454,"exchange":"binance","price":7183.33,"quantity":0.003194,"quoteQuantity":22.94355602,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-08T01:44:24.221Z","fee":0.02294356,"feeCurrency":"USDT"},{"gid":1608,"id":289369455,"exchange":"binance","price":7183.15,"quantity":0.002783,"quoteQuantity":19.99070645,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-08T01:44:24.221Z","fee":0.01999071,"feeCurrency":"USDT"},{"gid":1609,"id":289369456,"exchange":"binance","price":7183.13,"quantity":0.173429,"quoteQuantity":1245.76305277,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-08T01:44:24.221Z","fee":1.24576305,"feeCurrency":"USDT"},{"gid":1610,"id":289369457,"exchange":"binance","price":7183.07,"quantity":0.020594,"quoteQuantity":147.92814358,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-08T01:44:24.221Z","fee":0.14792814,"feeCurrency":"USDT"},{"gid":1611,"id":289371885,"exchange":"binance","price":7173,"quantity":0.000017,"quoteQuantity":0.121941,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-08T01:54:10.922Z","fee":0.00012194,"feeCurrency":"USDT"},{"gid":1612,"id":289371886,"exchange":"binance","price":7173,"quantity":0.021042,"quoteQuantity":150.934266,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-08T01:54:10.922Z","fee":0.15093427,"feeCurrency":"USDT"},{"gid":1613,"id":289371887,"exchange":"binance","price":7173,"quantity":0.062737,"quoteQuantity":450.012501,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-08T01:54:10.943Z","fee":0.4500125,"feeCurrency":"USDT"},{"gid":1614,"id":289372080,"exchange":"binance","price":7170.81,"quantity":0.002788,"quoteQuantity":19.99221828,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-08T01:54:52.135Z","fee":0.01999222,"feeCurrency":"USDT"},{"gid":1615,"id":289372081,"exchange":"binance","price":7170.81,"quantity":0.01972,"quoteQuantity":141.4083732,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-08T01:54:52.135Z","fee":0.14140837,"feeCurrency":"USDT"},{"gid":1616,"id":289372082,"exchange":"binance","price":7170.11,"quantity":0.068,"quoteQuantity":487.56748,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-08T01:54:52.135Z","fee":0.48756748,"feeCurrency":"USDT"},{"gid":1617,"id":289372083,"exchange":"binance","price":7170.1,"quantity":0.067943,"quoteQuantity":487.1581043,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-08T01:54:52.135Z","fee":0.4871581,"feeCurrency":"USDT"},{"gid":1618,"id":289372084,"exchange":"binance","price":7170,"quantity":0.041549,"quoteQuantity":297.90633,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-08T01:54:52.135Z","fee":0.29790633,"feeCurrency":"USDT"},{"gid":1619,"id":289372334,"exchange":"binance","price":7170,"quantity":0.021232,"quoteQuantity":152.23344,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-08T01:55:16.22Z","fee":0.15223344,"feeCurrency":"USDT"},{"gid":1620,"id":289372453,"exchange":"binance","price":7170,"quantity":0.178768,"quoteQuantity":1281.76656,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-08T01:55:59.175Z","fee":1.28176656,"feeCurrency":"USDT"},{"gid":1621,"id":289381891,"exchange":"binance","price":7203.54,"quantity":0.5,"quoteQuantity":3601.77,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-08T02:30:35.17Z","fee":0.0005,"feeCurrency":"BTC"},{"gid":1622,"id":289564127,"exchange":"binance","price":7304.21,"quantity":0.1,"quoteQuantity":730.421,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-08T06:53:09.878Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1623,"id":289564822,"exchange":"binance","price":7290,"quantity":0.1,"quoteQuantity":729,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-08T06:53:44.609Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1624,"id":289843991,"exchange":"binance","price":7288.25,"quantity":0.1,"quoteQuantity":728.825,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-08T15:24:51.488Z","fee":0.728825,"feeCurrency":"USDT"},{"gid":1625,"id":289849673,"exchange":"binance","price":7268,"quantity":0.022867,"quoteQuantity":166.197356,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-08T15:34:54.804Z","fee":0.00002287,"feeCurrency":"BTC"},{"gid":1626,"id":289849675,"exchange":"binance","price":7268,"quantity":0.077133,"quoteQuantity":560.602644,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-08T15:34:54.866Z","fee":0.00007713,"feeCurrency":"BTC"},{"gid":1627,"id":289896766,"exchange":"binance","price":7301,"quantity":0.039406,"quoteQuantity":287.703206,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-08T16:57:32.585Z","fee":0.28770321,"feeCurrency":"USDT"},{"gid":1628,"id":289896767,"exchange":"binance","price":7301,"quantity":0.148037,"quoteQuantity":1080.818137,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-08T16:57:32.585Z","fee":1.08081814,"feeCurrency":"USDT"},{"gid":1629,"id":289896791,"exchange":"binance","price":7301,"quantity":0.012557,"quoteQuantity":91.678657,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-08T16:57:37.304Z","fee":0.09167866,"feeCurrency":"USDT"},{"gid":1630,"id":289899674,"exchange":"binance","price":7326,"quantity":0.1,"quoteQuantity":732.6,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-08T17:01:48.926Z","fee":0.7326,"feeCurrency":"USDT"},{"gid":1631,"id":289900440,"exchange":"binance","price":7328.88,"quantity":0.014404,"quoteQuantity":105.56518752,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-08T17:03:35.728Z","fee":0.10556519,"feeCurrency":"USDT"},{"gid":1632,"id":289915405,"exchange":"binance","price":7312.54,"quantity":0.002734,"quoteQuantity":19.99248436,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-08T17:33:43.305Z","fee":0.01999248,"feeCurrency":"USDT"},{"gid":1633,"id":289915406,"exchange":"binance","price":7312.49,"quantity":0.017266,"quoteQuantity":126.25745234,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-08T17:33:43.305Z","fee":0.12625745,"feeCurrency":"USDT"},{"gid":1634,"id":289916074,"exchange":"binance","price":7312.49,"quantity":0.02,"quoteQuantity":146.2498,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-08T17:35:40.944Z","fee":0.1462498,"feeCurrency":"USDT"},{"gid":1635,"id":289923384,"exchange":"binance","price":7308.83,"quantity":0.00339,"quoteQuantity":24.7769337,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-08T17:54:14.828Z","fee":0.02477693,"feeCurrency":"USDT"},{"gid":1636,"id":289923385,"exchange":"binance","price":7308.67,"quantity":0.075714,"quoteQuantity":553.36864038,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-08T17:54:14.828Z","fee":0.55336864,"feeCurrency":"USDT"},{"gid":1637,"id":289923386,"exchange":"binance","price":7308.62,"quantity":0.080896,"quoteQuantity":591.23812352,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-08T17:54:14.828Z","fee":0.59123812,"feeCurrency":"USDT"},{"gid":1638,"id":290153221,"exchange":"binance","price":7310,"quantity":0.1,"quoteQuantity":731,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-09T03:05:25.156Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1639,"id":290163553,"exchange":"binance","price":7277,"quantity":0.05,"quoteQuantity":363.85,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-09T03:32:28.534Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1640,"id":290414332,"exchange":"binance","price":7175,"quantity":0.022609,"quoteQuantity":162.219575,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-09T12:08:10.796Z","fee":0.00002261,"feeCurrency":"BTC"},{"gid":1641,"id":290414333,"exchange":"binance","price":7175,"quantity":0.007048,"quoteQuantity":50.5694,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-09T12:08:10.798Z","fee":0.00000705,"feeCurrency":"BTC"},{"gid":1642,"id":290414334,"exchange":"binance","price":7175,"quantity":0.020343,"quoteQuantity":145.961025,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-09T12:08:10.923Z","fee":0.00002034,"feeCurrency":"BTC"},{"gid":1643,"id":290820799,"exchange":"binance","price":7254.59,"quantity":0.02,"quoteQuantity":145.0918,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-10T02:22:34.168Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1644,"id":290820981,"exchange":"binance","price":7254.12,"quantity":0.02,"quoteQuantity":145.0824,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-10T02:22:45.389Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1645,"id":290837532,"exchange":"binance","price":7231.84,"quantity":0.02,"quoteQuantity":144.6368,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-10T02:42:01.621Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1646,"id":290913166,"exchange":"binance","price":7105.9,"quantity":0.007187,"quoteQuantity":51.0701033,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-10T04:05:52.501Z","fee":0.00000719,"feeCurrency":"BTC"},{"gid":1647,"id":290913869,"exchange":"binance","price":7105.9,"quantity":0.012813,"quoteQuantity":91.0478967,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-10T04:06:03.762Z","fee":0.00001281,"feeCurrency":"BTC"},{"gid":1648,"id":290949636,"exchange":"binance","price":7158.75,"quantity":0.02,"quoteQuantity":143.175,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-10T04:43:34.675Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1649,"id":291060536,"exchange":"binance","price":6960,"quantity":0.01,"quoteQuantity":69.6,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-10T06:04:32.365Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1650,"id":291086639,"exchange":"binance","price":6995.99,"quantity":0.01,"quoteQuantity":69.9599,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-10T06:35:13.269Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1651,"id":291104887,"exchange":"binance","price":6949,"quantity":0.01,"quoteQuantity":69.49,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-10T07:00:40.676Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1652,"id":291175537,"exchange":"binance","price":6932.37,"quantity":0.01,"quoteQuantity":69.3237,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-10T08:06:34.499Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1653,"id":291179797,"exchange":"binance","price":6944.42,"quantity":0.01,"quoteQuantity":69.4442,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-10T08:09:03.468Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1654,"id":291323172,"exchange":"binance","price":6906.37,"quantity":0.01,"quoteQuantity":69.0637,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-10T12:34:08.744Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1655,"id":291323269,"exchange":"binance","price":6904.14,"quantity":0.003283,"quoteQuantity":22.66629162,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-10T12:34:25.55Z","fee":0.00000328,"feeCurrency":"BTC"},{"gid":1656,"id":291323270,"exchange":"binance","price":6904.29,"quantity":0.006717,"quoteQuantity":46.37611593,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-10T12:34:25.55Z","fee":0.00000672,"feeCurrency":"BTC"},{"gid":1657,"id":291814470,"exchange":"binance","price":6909.92,"quantity":0.1,"quoteQuantity":690.992,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-11T05:14:10.398Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1658,"id":292366930,"exchange":"binance","price":6830,"quantity":0.01,"quoteQuantity":68.3,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-12T10:41:03.948Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1659,"id":292519074,"exchange":"binance","price":6985,"quantity":0.002863,"quoteQuantity":19.998055,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-12T15:40:25.88Z","fee":0.00000286,"feeCurrency":"BTC"},{"gid":1660,"id":292519075,"exchange":"binance","price":6985,"quantity":0.005,"quoteQuantity":34.925,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-12T15:40:25.901Z","fee":0.000005,"feeCurrency":"BTC"},{"gid":1661,"id":292519078,"exchange":"binance","price":6985,"quantity":0.001931,"quoteQuantity":13.488035,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-12T15:40:26.208Z","fee":0.00000193,"feeCurrency":"BTC"},{"gid":1662,"id":292519091,"exchange":"binance","price":6985,"quantity":0.000206,"quoteQuantity":1.43891,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-12T15:40:29.854Z","fee":2.1e-7,"feeCurrency":"BTC"},{"gid":1663,"id":292520155,"exchange":"binance","price":6986.23,"quantity":0.1,"quoteQuantity":698.623,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-12T15:43:03.585Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1664,"id":292522908,"exchange":"binance","price":6990,"quantity":0.006783,"quoteQuantity":47.41317,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-12T15:49:13.966Z","fee":0.00000678,"feeCurrency":"BTC"},{"gid":1665,"id":292522910,"exchange":"binance","price":6990,"quantity":0.056425,"quoteQuantity":394.41075,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-12T15:49:14.127Z","fee":0.00005643,"feeCurrency":"BTC"},{"gid":1666,"id":292522911,"exchange":"binance","price":6990,"quantity":0.036792,"quoteQuantity":257.17608,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-12T15:49:14.152Z","fee":0.00003679,"feeCurrency":"BTC"},{"gid":1667,"id":292524968,"exchange":"binance","price":6992,"quantity":0.014287,"quoteQuantity":99.894704,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-12T15:54:10.989Z","fee":0.00001429,"feeCurrency":"BTC"},{"gid":1668,"id":292524969,"exchange":"binance","price":6992,"quantity":0.060067,"quoteQuantity":419.988464,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-12T15:54:11.009Z","fee":0.00006007,"feeCurrency":"BTC"},{"gid":1669,"id":292524970,"exchange":"binance","price":6992,"quantity":0.025646,"quoteQuantity":179.316832,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-12T15:54:11.031Z","fee":0.00002565,"feeCurrency":"BTC"},{"gid":1670,"id":292989803,"exchange":"binance","price":6703.87,"quantity":0.1,"quoteQuantity":670.387,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-13T01:29:06.058Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1671,"id":294544616,"exchange":"binance","price":6726.56,"quantity":0.01,"quoteQuantity":67.2656,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-15T12:11:47.343Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1672,"id":294554306,"exchange":"binance","price":6727.43,"quantity":0.01,"quoteQuantity":67.2743,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-15T12:21:59.915Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1673,"id":294609422,"exchange":"binance","price":6727,"quantity":0.1,"quoteQuantity":672.7,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-15T13:57:44.663Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1674,"id":295010555,"exchange":"binance","price":6634.37,"quantity":0.002732,"quoteQuantity":18.12509884,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-16T04:30:30.295Z","fee":0.00000273,"feeCurrency":"BTC"},{"gid":1675,"id":295010556,"exchange":"binance","price":6635.66,"quantity":0.017268,"quoteQuantity":114.58457688,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-16T04:30:30.295Z","fee":0.00001727,"feeCurrency":"BTC"},{"gid":1676,"id":295163575,"exchange":"binance","price":6910.02,"quantity":0.1,"quoteQuantity":691.002,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-16T07:40:14.912Z","fee":0.691002,"feeCurrency":"USDT"},{"gid":1677,"id":295183016,"exchange":"binance","price":6878,"quantity":0.1,"quoteQuantity":687.8,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-16T07:59:31.285Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1678,"id":295233543,"exchange":"binance","price":7100,"quantity":0.2,"quoteQuantity":1420,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-16T08:33:45.553Z","fee":1.42,"feeCurrency":"USDT"},{"gid":1679,"id":295260934,"exchange":"binance","price":7057.09,"quantity":0.2,"quoteQuantity":1411.418,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-16T08:47:19.428Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":1680,"id":295403842,"exchange":"binance","price":7024,"quantity":0.037733,"quoteQuantity":265.036592,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-16T11:54:35.524Z","fee":0.26503659,"feeCurrency":"USDT"},{"gid":1681,"id":295403843,"exchange":"binance","price":7024,"quantity":0.060432,"quoteQuantity":424.474368,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-16T11:54:35.878Z","fee":0.42447437,"feeCurrency":"USDT"},{"gid":1682,"id":295403846,"exchange":"binance","price":7024,"quantity":0.060066,"quoteQuantity":421.903584,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-16T11:54:36.228Z","fee":0.42190358,"feeCurrency":"USDT"},{"gid":1683,"id":295403847,"exchange":"binance","price":7024,"quantity":0.041769,"quoteQuantity":293.385456,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-16T11:54:36.582Z","fee":0.29338546,"feeCurrency":"USDT"},{"gid":1684,"id":295487049,"exchange":"binance","price":7015.2,"quantity":0.039522,"quoteQuantity":277.2547344,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-16T13:42:19.831Z","fee":0.00003952,"feeCurrency":"BTC"},{"gid":1685,"id":295487162,"exchange":"binance","price":7015.2,"quantity":0.060478,"quoteQuantity":424.2652656,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-16T13:42:33.727Z","fee":0.00006048,"feeCurrency":"BTC"},{"gid":1686,"id":295492337,"exchange":"binance","price":7007.63,"quantity":0.1,"quoteQuantity":700.763,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-16T13:51:03.028Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1687,"id":295553851,"exchange":"binance","price":7022.19,"quantity":0.003105,"quoteQuantity":21.80389995,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-16T15:52:19.277Z","fee":0.0218039,"feeCurrency":"USDT"},{"gid":1688,"id":295553852,"exchange":"binance","price":7022.05,"quantity":0.096895,"quoteQuantity":680.40153475,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-16T15:52:19.277Z","fee":0.68040153,"feeCurrency":"USDT"},{"gid":1689,"id":295554315,"exchange":"binance","price":7024.52,"quantity":0.1,"quoteQuantity":702.452,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-16T15:53:03.408Z","fee":0.702452,"feeCurrency":"USDT"},{"gid":1690,"id":295555421,"exchange":"binance","price":7045,"quantity":0.095978,"quoteQuantity":676.16501,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-16T15:54:55.679Z","fee":0.67616501,"feeCurrency":"USDT"},{"gid":1691,"id":295555422,"exchange":"binance","price":7045,"quantity":0.004022,"quoteQuantity":28.33499,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-16T15:54:55.736Z","fee":0.02833499,"feeCurrency":"USDT"},{"gid":1692,"id":295620368,"exchange":"binance","price":7001,"quantity":0.099991,"quoteQuantity":700.036991,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-16T18:13:53.957Z","fee":0.00009999,"feeCurrency":"BTC"},{"gid":1693,"id":295620369,"exchange":"binance","price":7001,"quantity":0.000009,"quoteQuantity":0.063009,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-16T18:13:54.253Z","fee":0.0000034,"feeCurrency":"BNB"},{"gid":1694,"id":296140417,"exchange":"binance","price":6985,"quantity":0.2,"quoteQuantity":1397,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-17T13:32:49.172Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":1695,"id":296419725,"exchange":"binance","price":7092.23,"quantity":0.2,"quoteQuantity":1418.446,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-18T04:02:42.198Z","fee":1.418446,"feeCurrency":"USDT"},{"gid":1696,"id":296488482,"exchange":"binance","price":7089,"quantity":0.001828,"quoteQuantity":12.958692,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-18T09:49:45.768Z","fee":0.00000183,"feeCurrency":"BTC"},{"gid":1697,"id":296488483,"exchange":"binance","price":7089,"quantity":0.014084,"quoteQuantity":99.841476,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-18T09:49:45.869Z","fee":0.00001408,"feeCurrency":"BTC"},{"gid":1698,"id":296488484,"exchange":"binance","price":7089,"quantity":0.004088,"quoteQuantity":28.979832,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-18T09:49:45.945Z","fee":0.00000409,"feeCurrency":"BTC"},{"gid":1699,"id":296631256,"exchange":"binance","price":7227.99,"quantity":0.1,"quoteQuantity":722.799,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-18T14:15:16.058Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1700,"id":296685969,"exchange":"binance","price":7207.96,"quantity":0.1,"quoteQuantity":720.796,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-18T16:05:40.624Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1701,"id":296816900,"exchange":"binance","price":7260,"quantity":0.2,"quoteQuantity":1452,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-18T22:47:24.94Z","fee":1.452,"feeCurrency":"USDT"},{"gid":1702,"id":296920836,"exchange":"binance","price":7230,"quantity":0.2,"quoteQuantity":1446,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-19T04:20:23.648Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":1703,"id":296942610,"exchange":"binance","price":7185,"quantity":0.1,"quoteQuantity":718.5,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-19T05:46:08.119Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1704,"id":297417126,"exchange":"binance","price":7200.49,"quantity":0.029976,"quoteQuantity":215.84188824,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-20T04:29:12.145Z","fee":0.21584189,"feeCurrency":"USDT"},{"gid":1705,"id":297417127,"exchange":"binance","price":7200.49,"quantity":0.070024,"quoteQuantity":504.20711176,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-20T04:29:12.145Z","fee":0.50420711,"feeCurrency":"USDT"},{"gid":1706,"id":297417190,"exchange":"binance","price":7200.23,"quantity":0.00365,"quoteQuantity":26.2808395,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-20T04:29:22.383Z","fee":0.02628084,"feeCurrency":"USDT"},{"gid":1707,"id":297417213,"exchange":"binance","price":7200,"quantity":0.09635,"quoteQuantity":693.72,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-20T04:29:29.036Z","fee":0.69372,"feeCurrency":"USDT"},{"gid":1708,"id":297490357,"exchange":"binance","price":7146.22,"quantity":0.2,"quoteQuantity":1429.244,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-20T08:14:50.893Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":1709,"id":297589541,"exchange":"binance","price":7066,"quantity":0.02,"quoteQuantity":141.32,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-20T11:35:19.133Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1710,"id":297589582,"exchange":"binance","price":7066,"quantity":0.00702,"quoteQuantity":49.60332,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-20T11:35:38.116Z","fee":0.00000702,"feeCurrency":"BTC"},{"gid":1711,"id":297589583,"exchange":"binance","price":7066,"quantity":0.000005,"quoteQuantity":0.03533,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-20T11:35:38.135Z","fee":1e-8,"feeCurrency":"BTC"},{"gid":1712,"id":299483856,"exchange":"binance","price":7132.19,"quantity":0.05,"quoteQuantity":356.6095,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-23T12:17:52.679Z","fee":0.3566095,"feeCurrency":"USDT"},{"gid":1713,"id":299483923,"exchange":"binance","price":7132.69,"quantity":0.05,"quoteQuantity":356.6345,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-23T12:18:15.645Z","fee":0.3566345,"feeCurrency":"USDT"},{"gid":1714,"id":299484177,"exchange":"binance","price":7130.99,"quantity":0.05,"quoteQuantity":356.5495,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-23T12:19:12.98Z","fee":0.3565495,"feeCurrency":"USDT"},{"gid":1715,"id":299527144,"exchange":"binance","price":7190,"quantity":0.05,"quoteQuantity":359.5,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-23T13:35:12.345Z","fee":0.3595,"feeCurrency":"USDT"},{"gid":1716,"id":299552743,"exchange":"binance","price":7220,"quantity":0.02,"quoteQuantity":144.4,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-23T13:50:20.785Z","fee":0.1444,"feeCurrency":"USDT"},{"gid":1717,"id":299573063,"exchange":"binance","price":7290,"quantity":0.2,"quoteQuantity":1458,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-23T14:05:02.084Z","fee":1.458,"feeCurrency":"USDT"},{"gid":1718,"id":299613193,"exchange":"binance","price":7390,"quantity":0.289469,"quoteQuantity":2139.17591,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-23T14:19:28.194Z","fee":2.13917591,"feeCurrency":"USDT"},{"gid":1719,"id":299613194,"exchange":"binance","price":7390,"quantity":0.110531,"quoteQuantity":816.82409,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-23T14:19:28.241Z","fee":0.81682409,"feeCurrency":"USDT"},{"gid":1720,"id":299625005,"exchange":"binance","price":7490,"quantity":0.4,"quoteQuantity":2996,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-23T14:20:49.088Z","fee":2.996,"feeCurrency":"USDT"},{"gid":1721,"id":299783953,"exchange":"binance","price":7521.87,"quantity":0.1,"quoteQuantity":752.187,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-23T15:23:44.943Z","fee":0.752187,"feeCurrency":"USDT"},{"gid":1722,"id":299786216,"exchange":"binance","price":7526.5,"quantity":0.1,"quoteQuantity":752.65,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-23T15:26:18.016Z","fee":0.75265,"feeCurrency":"USDT"},{"gid":1723,"id":299938181,"exchange":"binance","price":7511.84,"quantity":0.007208,"quoteQuantity":54.14534272,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-23T19:19:23.837Z","fee":0.05414534,"feeCurrency":"USDT"},{"gid":1724,"id":299938182,"exchange":"binance","price":7511.66,"quantity":0.226385,"quoteQuantity":1700.5271491,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-23T19:19:23.837Z","fee":1.70052715,"feeCurrency":"USDT"},{"gid":1725,"id":299938183,"exchange":"binance","price":7511.61,"quantity":0.020855,"quoteQuantity":156.65462655,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-23T19:19:23.837Z","fee":0.15665463,"feeCurrency":"USDT"},{"gid":1726,"id":300010156,"exchange":"binance","price":7558,"quantity":0.009997,"quoteQuantity":75.557326,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-23T22:25:25.015Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1727,"id":300010157,"exchange":"binance","price":7558.72,"quantity":0.090003,"quoteQuantity":680.30747616,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-23T22:25:25.015Z","fee":0.00009,"feeCurrency":"BTC"},{"gid":1728,"id":300032316,"exchange":"binance","price":7508.03,"quantity":0.0999,"quoteQuantity":750.052197,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-23T23:14:49.661Z","fee":0.7500522,"feeCurrency":"USDT"},{"gid":1729,"id":300143070,"exchange":"binance","price":7532,"quantity":0.1,"quoteQuantity":753.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-24T03:32:35.102Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1730,"id":300143470,"exchange":"binance","price":7531.4,"quantity":0.0999,"quoteQuantity":752.38686,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-24T03:33:42.417Z","fee":0.75238686,"feeCurrency":"USDT"},{"gid":1731,"id":300258676,"exchange":"binance","price":7543,"quantity":0.02,"quoteQuantity":150.86,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-24T08:41:44.533Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1732,"id":300337462,"exchange":"binance","price":7566.16,"quantity":0.01998,"quoteQuantity":151.1718768,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-24T11:06:38.864Z","fee":0.15117188,"feeCurrency":"USDT"},{"gid":1733,"id":300450429,"exchange":"binance","price":7424.13,"quantity":0.133215,"quoteQuantity":989.00547795,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-24T14:17:30.957Z","fee":0.00013322,"feeCurrency":"BTC"},{"gid":1734,"id":300450430,"exchange":"binance","price":7424.14,"quantity":0.066785,"quoteQuantity":495.8211899,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-24T14:17:30.957Z","fee":0.00006679,"feeCurrency":"BTC"},{"gid":1735,"id":300704233,"exchange":"binance","price":7520.19,"quantity":0.007304,"quoteQuantity":54.92746776,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-25T04:48:14.154Z","fee":0.05492747,"feeCurrency":"USDT"},{"gid":1736,"id":300704235,"exchange":"binance","price":7520,"quantity":0.192496,"quoteQuantity":1447.56992,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-25T04:48:14.694Z","fee":1.44756992,"feeCurrency":"USDT"},{"gid":1737,"id":301136834,"exchange":"binance","price":7538,"quantity":0.05,"quoteQuantity":376.9,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-25T21:06:41.602Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1738,"id":301299920,"exchange":"binance","price":7541,"quantity":0.02,"quoteQuantity":150.82,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-26T07:50:47.764Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1739,"id":301300719,"exchange":"binance","price":7545.5,"quantity":0.02,"quoteQuantity":150.91,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-26T07:54:48.477Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1740,"id":301313239,"exchange":"binance","price":7552.37,"quantity":0.006317,"quoteQuantity":47.70832129,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-26T08:51:13.083Z","fee":0.04770832,"feeCurrency":"USDT"},{"gid":1741,"id":301313240,"exchange":"binance","price":7552.34,"quantity":0.011447,"quoteQuantity":86.45163598,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-26T08:51:13.083Z","fee":0.08645164,"feeCurrency":"USDT"},{"gid":1742,"id":301313241,"exchange":"binance","price":7552.3,"quantity":0.072146,"quoteQuantity":544.8682358,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-26T08:51:13.083Z","fee":0.54486824,"feeCurrency":"USDT"},{"gid":1743,"id":301483931,"exchange":"binance","price":7650,"quantity":0.1,"quoteQuantity":765,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-26T14:03:22.063Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1744,"id":301532894,"exchange":"binance","price":7482,"quantity":0.1,"quoteQuantity":748.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-26T15:10:11.003Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1745,"id":301537175,"exchange":"binance","price":7530,"quantity":0.1,"quoteQuantity":753,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-26T15:10:39.737Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1746,"id":301539383,"exchange":"binance","price":7540,"quantity":0.1,"quoteQuantity":754,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-26T15:11:16.965Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1747,"id":301742238,"exchange":"binance","price":7700,"quantity":0.1,"quoteQuantity":770,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-27T00:00:16.865Z","fee":0.77,"feeCurrency":"USDT"},{"gid":1748,"id":301796965,"exchange":"binance","price":7743.69,"quantity":0.175375,"quoteQuantity":1358.04963375,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-27T01:03:50.724Z","fee":1.35804963,"feeCurrency":"USDT"},{"gid":1749,"id":301796966,"exchange":"binance","price":7743.66,"quantity":0.124225,"quoteQuantity":961.9561635,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-27T01:03:50.724Z","fee":0.96195616,"feeCurrency":"USDT"},{"gid":1750,"id":301810701,"exchange":"binance","price":7713,"quantity":0.083625,"quoteQuantity":644.999625,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-27T01:18:10.057Z","fee":0.00008363,"feeCurrency":"BTC"},{"gid":1751,"id":301810702,"exchange":"binance","price":7713,"quantity":0.00259,"quoteQuantity":19.97667,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-27T01:18:10.057Z","fee":0.00000259,"feeCurrency":"BTC"},{"gid":1752,"id":301810703,"exchange":"binance","price":7713,"quantity":0.113785,"quoteQuantity":877.623705,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-27T01:18:10.076Z","fee":0.00011379,"feeCurrency":"BTC"},{"gid":1753,"id":301849917,"exchange":"binance","price":7697.26,"quantity":0.05,"quoteQuantity":384.863,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-27T02:20:29.797Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1754,"id":301892041,"exchange":"binance","price":7701,"quantity":0.02,"quoteQuantity":154.02,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-27T04:27:29.74Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1755,"id":301988061,"exchange":"binance","price":7686.45,"quantity":0.0214,"quoteQuantity":164.49003,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-27T07:42:14.198Z","fee":0.0000214,"feeCurrency":"BTC"},{"gid":1756,"id":301988062,"exchange":"binance","price":7686.47,"quantity":0.0214,"quoteQuantity":164.490458,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-27T07:42:14.198Z","fee":0.0000214,"feeCurrency":"BTC"},{"gid":1757,"id":301988063,"exchange":"binance","price":7686.48,"quantity":0.0572,"quoteQuantity":439.666656,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-27T07:42:14.198Z","fee":0.0000572,"feeCurrency":"BTC"},{"gid":1758,"id":302118074,"exchange":"binance","price":7689.91,"quantity":0.000002,"quoteQuantity":0.01537982,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-27T12:50:06.39Z","fee":0.00000354,"feeCurrency":"BNB"},{"gid":1759,"id":302118092,"exchange":"binance","price":7690,"quantity":0.019998,"quoteQuantity":153.78462,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-27T12:50:08.813Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1760,"id":302125192,"exchange":"binance","price":7690.47,"quantity":0.02,"quoteQuantity":153.8094,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-27T13:09:40.006Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1761,"id":302125486,"exchange":"binance","price":7689,"quantity":0.008337,"quoteQuantity":64.103193,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-27T13:10:39.295Z","fee":0.00000834,"feeCurrency":"BTC"},{"gid":1762,"id":302125487,"exchange":"binance","price":7689,"quantity":0.011663,"quoteQuantity":89.676807,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-27T13:10:39.348Z","fee":0.00001166,"feeCurrency":"BTC"},{"gid":1763,"id":302127718,"exchange":"binance","price":7680.42,"quantity":0.015,"quoteQuantity":115.2063,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-27T13:15:22.582Z","fee":0.000015,"feeCurrency":"BTC"},{"gid":1764,"id":302127719,"exchange":"binance","price":7680.51,"quantity":0.000006,"quoteQuantity":0.04608306,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-27T13:15:22.582Z","fee":0.00000354,"feeCurrency":"BNB"},{"gid":1765,"id":302127721,"exchange":"binance","price":7681,"quantity":0.004994,"quoteQuantity":38.358914,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-27T13:15:22.617Z","fee":0.00000499,"feeCurrency":"BTC"},{"gid":1766,"id":302130660,"exchange":"binance","price":7675.01,"quantity":0.01,"quoteQuantity":76.7501,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-27T13:17:38.116Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1767,"id":302174765,"exchange":"binance","price":7665.55,"quantity":0.000007,"quoteQuantity":0.05365885,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-27T14:30:37.796Z","fee":0.00000356,"feeCurrency":"BNB"},{"gid":1768,"id":302174766,"exchange":"binance","price":7666.08,"quantity":0.002609,"quoteQuantity":20.00080272,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-27T14:30:37.796Z","fee":0.00000261,"feeCurrency":"BTC"},{"gid":1769,"id":302174767,"exchange":"binance","price":7666.92,"quantity":0.006142,"quoteQuantity":47.09022264,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-27T14:30:37.796Z","fee":0.00000614,"feeCurrency":"BTC"},{"gid":1770,"id":302174768,"exchange":"binance","price":7666.99,"quantity":0.007983,"quoteQuantity":61.20558117,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-27T14:30:37.796Z","fee":0.00000798,"feeCurrency":"BTC"},{"gid":1771,"id":302174769,"exchange":"binance","price":7667,"quantity":0.003259,"quoteQuantity":24.986753,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-27T14:30:37.796Z","fee":0.00000326,"feeCurrency":"BTC"},{"gid":1772,"id":302542840,"exchange":"binance","price":7685.12,"quantity":0.1,"quoteQuantity":768.512,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-28T08:08:13.98Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1773,"id":302581820,"exchange":"binance","price":7739,"quantity":0.1,"quoteQuantity":773.9,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-04-28T09:56:37.805Z","fee":0.7739,"feeCurrency":"USDT"},{"gid":1774,"id":302608827,"exchange":"binance","price":7743.68,"quantity":0.1,"quoteQuantity":774.368,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-28T10:58:22.822Z","fee":0.774368,"feeCurrency":"USDT"},{"gid":1775,"id":302609311,"exchange":"binance","price":7743.6,"quantity":0.1,"quoteQuantity":774.36,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-28T10:59:57.158Z","fee":0.77436,"feeCurrency":"USDT"},{"gid":1776,"id":302640720,"exchange":"binance","price":7739.11,"quantity":0.1,"quoteQuantity":773.911,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-28T12:25:56.371Z","fee":0.773911,"feeCurrency":"USDT"},{"gid":1777,"id":302708540,"exchange":"binance","price":7702,"quantity":0.012356,"quoteQuantity":95.165912,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-28T14:56:45.713Z","fee":0.00001236,"feeCurrency":"BTC"},{"gid":1778,"id":302708541,"exchange":"binance","price":7702,"quantity":0.01,"quoteQuantity":77.02,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-28T14:56:45.719Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1779,"id":302708542,"exchange":"binance","price":7702,"quantity":0.077644,"quoteQuantity":598.014088,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-28T14:56:45.723Z","fee":0.00007764,"feeCurrency":"BTC"},{"gid":1780,"id":302710411,"exchange":"binance","price":7702,"quantity":0.023171,"quoteQuantity":178.463042,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-28T14:57:34.947Z","fee":0.00002317,"feeCurrency":"BTC"},{"gid":1781,"id":302710412,"exchange":"binance","price":7702,"quantity":0.07682,"quoteQuantity":591.66764,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-28T14:57:35.021Z","fee":0.00007682,"feeCurrency":"BTC"},{"gid":1782,"id":302710413,"exchange":"binance","price":7702,"quantity":0.000009,"quoteQuantity":0.069318,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-28T14:57:35.06Z","fee":0.00000354,"feeCurrency":"BNB"},{"gid":1783,"id":302710968,"exchange":"binance","price":7702,"quantity":0.1,"quoteQuantity":770.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-28T14:58:14.257Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1784,"id":302990726,"exchange":"binance","price":7816.13,"quantity":0.179319,"quoteQuantity":1401.58061547,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-29T03:46:29.541Z","fee":1.40158062,"feeCurrency":"USDT"},{"gid":1785,"id":302990727,"exchange":"binance","price":7816.13,"quantity":0.020681,"quoteQuantity":161.64538453,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-29T03:46:29.541Z","fee":0.16164538,"feeCurrency":"USDT"},{"gid":1786,"id":302990931,"exchange":"binance","price":7815.56,"quantity":0.07,"quoteQuantity":547.0892,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-29T03:47:24.256Z","fee":0.5470892,"feeCurrency":"USDT"},{"gid":1787,"id":302993255,"exchange":"binance","price":7802,"quantity":0.2,"quoteQuantity":1560.4,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-29T03:55:42.058Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":1788,"id":303028414,"exchange":"binance","price":7858.41,"quantity":0.40892,"quoteQuantity":3213.4610172,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-29T05:29:35.516Z","fee":3.21346102,"feeCurrency":"USDT"},{"gid":1789,"id":303100743,"exchange":"binance","price":7944,"quantity":0.09999,"quoteQuantity":794.32056,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-29T06:53:07.44Z","fee":0.00009999,"feeCurrency":"BTC"},{"gid":1790,"id":303100744,"exchange":"binance","price":7944,"quantity":0.00001,"quoteQuantity":0.07944,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-29T06:53:07.646Z","fee":0.0000036,"feeCurrency":"BNB"},{"gid":1791,"id":303101588,"exchange":"binance","price":7951.92,"quantity":0.1,"quoteQuantity":795.192,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-29T06:54:12.302Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1792,"id":303101706,"exchange":"binance","price":7951.84,"quantity":0.1,"quoteQuantity":795.184,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-29T06:54:22.18Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1793,"id":303101937,"exchange":"binance","price":7951,"quantity":0.1,"quoteQuantity":795.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-29T06:54:42.623Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1794,"id":303109102,"exchange":"binance","price":7932.89,"quantity":0.047391,"quoteQuantity":375.94758999,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-29T07:06:52.764Z","fee":0.00004739,"feeCurrency":"BTC"},{"gid":1795,"id":303109103,"exchange":"binance","price":7932.92,"quantity":0.002609,"quoteQuantity":20.69698828,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-29T07:06:52.764Z","fee":0.00000261,"feeCurrency":"BTC"},{"gid":1796,"id":303123292,"exchange":"binance","price":7935,"quantity":0.05,"quoteQuantity":396.75,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-29T07:31:29.398Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1797,"id":303149932,"exchange":"binance","price":7937,"quantity":0.009107,"quoteQuantity":72.282259,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-29T08:25:40.304Z","fee":0.00000911,"feeCurrency":"BTC"},{"gid":1798,"id":303149935,"exchange":"binance","price":7937,"quantity":0.002421,"quoteQuantity":19.215477,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-29T08:25:40.946Z","fee":0.00000242,"feeCurrency":"BTC"},{"gid":1799,"id":303149941,"exchange":"binance","price":7937,"quantity":0.008472,"quoteQuantity":67.242264,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-29T08:25:41.568Z","fee":0.00000847,"feeCurrency":"BTC"},{"gid":1800,"id":303186293,"exchange":"binance","price":7943.9,"quantity":0.1,"quoteQuantity":794.39,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-29T09:44:56.115Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1801,"id":303293992,"exchange":"binance","price":8109,"quantity":0.02,"quoteQuantity":162.18,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-29T10:58:01.8Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1802,"id":303303154,"exchange":"binance","price":8135.91,"quantity":0.2,"quoteQuantity":1627.182,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-29T11:08:09.422Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":1803,"id":303303363,"exchange":"binance","price":8137.31,"quantity":0.2,"quoteQuantity":1627.462,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-29T11:08:21.423Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":1804,"id":303303863,"exchange":"binance","price":8140.2,"quantity":0.172182,"quoteQuantity":1401.5959164,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-29T11:08:41.533Z","fee":0.00017218,"feeCurrency":"BTC"},{"gid":1805,"id":303303864,"exchange":"binance","price":8140.63,"quantity":0.027818,"quoteQuantity":226.45604534,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-29T11:08:41.533Z","fee":0.00002782,"feeCurrency":"BTC"},{"gid":1806,"id":303330543,"exchange":"binance","price":8127.87,"quantity":0.05,"quoteQuantity":406.3935,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-29T11:33:50.501Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1807,"id":303364212,"exchange":"binance","price":8155,"quantity":0.057444,"quoteQuantity":468.45582,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-29T12:31:06.495Z","fee":0.46845582,"feeCurrency":"USDT"},{"gid":1808,"id":303364213,"exchange":"binance","price":8154.98,"quantity":0.142556,"quoteQuantity":1162.54132888,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-29T12:31:06.495Z","fee":1.16254133,"feeCurrency":"USDT"},{"gid":1809,"id":303468600,"exchange":"binance","price":8373.9,"quantity":0.007161,"quoteQuantity":59.9654979,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-29T13:29:10.956Z","fee":0.00000716,"feeCurrency":"BTC"},{"gid":1810,"id":303468601,"exchange":"binance","price":8374.3,"quantity":0.119489,"quoteQuantity":1000.6367327,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-29T13:29:10.956Z","fee":0.00011949,"feeCurrency":"BTC"},{"gid":1811,"id":303468602,"exchange":"binance","price":8374.35,"quantity":0.07335,"quoteQuantity":614.2585725,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-29T13:29:10.956Z","fee":0.00007335,"feeCurrency":"BTC"},{"gid":1812,"id":303481421,"exchange":"binance","price":8355,"quantity":0.045121,"quoteQuantity":376.985955,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-29T13:36:48.834Z","fee":0.00004512,"feeCurrency":"BTC"},{"gid":1813,"id":303481422,"exchange":"binance","price":8355,"quantity":0.054879,"quoteQuantity":458.514045,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-29T13:36:48.834Z","fee":0.00005488,"feeCurrency":"BTC"},{"gid":1814,"id":303494829,"exchange":"binance","price":8291.62,"quantity":0.1,"quoteQuantity":829.162,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-29T13:41:06.868Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1815,"id":303520490,"exchange":"binance","price":8272.34,"quantity":0.169415,"quoteQuantity":1401.4584811,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-29T14:03:05.22Z","fee":1.40145848,"feeCurrency":"USDT"},{"gid":1816,"id":303520491,"exchange":"binance","price":8272.33,"quantity":0.530585,"quoteQuantity":4389.17421305,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-29T14:03:05.22Z","fee":4.38917421,"feeCurrency":"USDT"},{"gid":1817,"id":303520760,"exchange":"binance","price":8273.2,"quantity":0.003022,"quoteQuantity":25.0016104,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-29T14:03:25.474Z","fee":0.00000302,"feeCurrency":"BTC"},{"gid":1818,"id":303520761,"exchange":"binance","price":8273.41,"quantity":0.169417,"quoteQuantity":1401.65630197,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-29T14:03:25.474Z","fee":0.00016942,"feeCurrency":"BTC"},{"gid":1819,"id":303520762,"exchange":"binance","price":8273.43,"quantity":0.08,"quoteQuantity":661.8744,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-29T14:03:25.474Z","fee":0.00008,"feeCurrency":"BTC"},{"gid":1820,"id":303520763,"exchange":"binance","price":8273.5,"quantity":0.047561,"quoteQuantity":393.4959335,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-29T14:03:25.474Z","fee":0.00004756,"feeCurrency":"BTC"},{"gid":1821,"id":303571962,"exchange":"binance","price":8318.33,"quantity":0.2,"quoteQuantity":1663.666,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-29T15:09:43.478Z","fee":1.663666,"feeCurrency":"USDT"},{"gid":1822,"id":303813392,"exchange":"binance","price":8741.92,"quantity":0.002287,"quoteQuantity":19.99277104,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-29T17:26:38.677Z","fee":0.00000229,"feeCurrency":"BTC"},{"gid":1823,"id":303813393,"exchange":"binance","price":8741.93,"quantity":0.198039,"quoteQuantity":1731.24307527,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-29T17:26:38.677Z","fee":0.00019804,"feeCurrency":"BTC"},{"gid":1824,"id":303813394,"exchange":"binance","price":8742.1,"quantity":0.210421,"quoteQuantity":1839.5214241,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-29T17:26:38.677Z","fee":0.00021042,"feeCurrency":"BTC"},{"gid":1825,"id":303818886,"exchange":"binance","price":8702,"quantity":0.1,"quoteQuantity":870.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-29T17:29:23.857Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1826,"id":303828111,"exchange":"binance","price":8726,"quantity":0.1,"quoteQuantity":872.6,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-29T17:34:43.387Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1827,"id":303907260,"exchange":"binance","price":8732.68,"quantity":0.052811,"quoteQuantity":461.18156348,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-29T17:52:56.373Z","fee":0.00005281,"feeCurrency":"BTC"},{"gid":1828,"id":303990457,"exchange":"binance","price":8666.25,"quantity":0.039882,"quoteQuantity":345.6273825,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-29T18:24:05.175Z","fee":0.00003988,"feeCurrency":"BTC"},{"gid":1829,"id":304339930,"exchange":"binance","price":8789.92,"quantity":0.1,"quoteQuantity":878.992,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-30T00:53:57.492Z","fee":0.878992,"feeCurrency":"USDT"},{"gid":1830,"id":304340114,"exchange":"binance","price":8791.15,"quantity":0.006102,"quoteQuantity":53.6435973,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-30T00:54:07.943Z","fee":0.0536436,"feeCurrency":"USDT"},{"gid":1831,"id":304340115,"exchange":"binance","price":8790.93,"quantity":0.093898,"quoteQuantity":825.45074514,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-30T00:54:07.943Z","fee":0.82545075,"feeCurrency":"USDT"},{"gid":1832,"id":304385966,"exchange":"binance","price":8820,"quantity":0.1,"quoteQuantity":882,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-30T02:08:19.722Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1833,"id":304774871,"exchange":"binance","price":9434.92,"quantity":0.002119,"quoteQuantity":19.99259548,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-30T06:21:31.547Z","fee":0.00000212,"feeCurrency":"BTC"},{"gid":1834,"id":304774872,"exchange":"binance","price":9435,"quantity":0.02,"quoteQuantity":188.7,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-30T06:21:31.633Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1835,"id":304774873,"exchange":"binance","price":9435,"quantity":0.051168,"quoteQuantity":482.77008,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-30T06:21:31.686Z","fee":0.00005117,"feeCurrency":"BTC"},{"gid":1836,"id":304774874,"exchange":"binance","price":9435,"quantity":0.026706,"quoteQuantity":251.97111,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-30T06:21:31.783Z","fee":0.00002671,"feeCurrency":"BTC"},{"gid":1837,"id":304774875,"exchange":"binance","price":9435,"quantity":0.000007,"quoteQuantity":0.066045,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-30T06:21:31.817Z","fee":0.00000393,"feeCurrency":"BNB"},{"gid":1838,"id":304874432,"exchange":"binance","price":9178,"quantity":0.1,"quoteQuantity":917.8,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-30T07:18:49.512Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1839,"id":304909377,"exchange":"binance","price":9128.68,"quantity":0.152924,"quoteQuantity":1395.99426032,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-30T07:29:38.099Z","fee":1.39599426,"feeCurrency":"USDT"},{"gid":1840,"id":304909378,"exchange":"binance","price":9128.56,"quantity":0.347076,"quoteQuantity":3168.30409056,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-30T07:29:38.099Z","fee":3.16830409,"feeCurrency":"USDT"},{"gid":1841,"id":304924826,"exchange":"binance","price":9177.31,"quantity":0.457653,"quoteQuantity":4200.02345343,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-30T07:36:41.042Z","fee":0.00045765,"feeCurrency":"BTC"},{"gid":1842,"id":304924827,"exchange":"binance","price":9177.32,"quantity":0.042347,"quoteQuantity":388.63197004,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-30T07:36:41.042Z","fee":0.00004235,"feeCurrency":"BTC"},{"gid":1843,"id":305009432,"exchange":"binance","price":9076.42,"quantity":0.00549,"quoteQuantity":49.8295458,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-30T09:00:27.937Z","fee":0.04982955,"feeCurrency":"USDT"},{"gid":1844,"id":305009433,"exchange":"binance","price":9076.36,"quantity":0.32587,"quoteQuantity":2957.7134332,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-30T09:00:27.937Z","fee":2.95771343,"feeCurrency":"USDT"},{"gid":1845,"id":305009434,"exchange":"binance","price":9076.34,"quantity":0.07032,"quoteQuantity":638.2482288,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-30T09:00:27.937Z","fee":0.63824823,"feeCurrency":"USDT"},{"gid":1846,"id":305009435,"exchange":"binance","price":9076.3,"quantity":0.33783,"quoteQuantity":3066.246429,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-30T09:00:27.937Z","fee":3.06624643,"feeCurrency":"USDT"},{"gid":1847,"id":305009436,"exchange":"binance","price":9076.19,"quantity":0.035323,"quoteQuantity":320.59825937,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-30T09:00:27.937Z","fee":0.32059826,"feeCurrency":"USDT"},{"gid":1848,"id":305009437,"exchange":"binance","price":9076,"quantity":0.116954,"quoteQuantity":1061.474504,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-30T09:00:27.937Z","fee":1.0614745,"feeCurrency":"USDT"},{"gid":1849,"id":305009438,"exchange":"binance","price":9074.04,"quantity":0.002019,"quoteQuantity":18.32048676,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-30T09:00:27.937Z","fee":0.01832049,"feeCurrency":"USDT"},{"gid":1850,"id":305009439,"exchange":"binance","price":9074.04,"quantity":0.79614,"quoteQuantity":7224.2062056,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-30T09:00:27.937Z","fee":7.22420621,"feeCurrency":"USDT"},{"gid":1851,"id":305041793,"exchange":"binance","price":8958.02,"quantity":0.002232,"quoteQuantity":19.99430064,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-30T09:08:40.55Z","fee":0.00000223,"feeCurrency":"BTC"},{"gid":1852,"id":305041794,"exchange":"binance","price":8958.03,"quantity":0.097768,"quoteQuantity":875.80867704,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-30T09:08:40.55Z","fee":0.00009777,"feeCurrency":"BTC"},{"gid":1853,"id":305052381,"exchange":"binance","price":8982.39,"quantity":0.139451,"quoteQuantity":1252.60326789,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-30T09:11:57.392Z","fee":0.00013945,"feeCurrency":"BTC"},{"gid":1854,"id":305052382,"exchange":"binance","price":8983.07,"quantity":0.060549,"quoteQuantity":543.91590543,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-30T09:11:57.392Z","fee":0.00006055,"feeCurrency":"BTC"},{"gid":1855,"id":305052716,"exchange":"binance","price":8977.97,"quantity":0.1,"quoteQuantity":897.797,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-30T09:12:07.968Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1856,"id":305053902,"exchange":"binance","price":8973.8,"quantity":0.1,"quoteQuantity":897.38,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-30T09:12:39.2Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1857,"id":305055851,"exchange":"binance","price":8947.25,"quantity":0.198721,"quoteQuantity":1778.00646725,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-30T09:13:40.47Z","fee":1.77800647,"feeCurrency":"USDT"},{"gid":1858,"id":305055852,"exchange":"binance","price":8947.21,"quantity":0.156023,"quoteQuantity":1395.97054583,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-30T09:13:40.47Z","fee":1.39597055,"feeCurrency":"USDT"},{"gid":1859,"id":305055853,"exchange":"binance","price":8947.08,"quantity":0.144756,"quoteQuantity":1295.14351248,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-30T09:13:40.47Z","fee":1.29514351,"feeCurrency":"USDT"},{"gid":1860,"id":305080040,"exchange":"binance","price":8895.08,"quantity":0.01,"quoteQuantity":88.9508,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-30T09:20:47.071Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1861,"id":305080835,"exchange":"binance","price":8894.99,"quantity":0.01,"quoteQuantity":88.9499,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-30T09:21:10.481Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":1862,"id":305091364,"exchange":"binance","price":8955.28,"quantity":0.01998,"quoteQuantity":178.9264944,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-30T09:25:28.445Z","fee":0.17892649,"feeCurrency":"USDT"},{"gid":1863,"id":305235285,"exchange":"binance","price":8864.75,"quantity":0.157499,"quoteQuantity":1396.18926025,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-30T10:20:10.988Z","fee":0.0001575,"feeCurrency":"BTC"},{"gid":1864,"id":305235286,"exchange":"binance","price":8864.79,"quantity":0.192932,"quoteQuantity":1710.30166428,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-30T10:20:10.988Z","fee":0.00019293,"feeCurrency":"BTC"},{"gid":1865,"id":305235287,"exchange":"binance","price":8864.8,"quantity":0.079389,"quoteQuantity":703.7676072,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-30T10:20:10.988Z","fee":0.00007939,"feeCurrency":"BTC"},{"gid":1866,"id":305235288,"exchange":"binance","price":8865.77,"quantity":0.07018,"quoteQuantity":622.1997386,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-30T10:20:10.988Z","fee":0.00007018,"feeCurrency":"BTC"},{"gid":1867,"id":305236212,"exchange":"binance","price":8849.64,"quantity":0.004518,"quoteQuantity":39.98267352,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-30T10:20:22.809Z","fee":0.00000452,"feeCurrency":"BTC"},{"gid":1868,"id":305236213,"exchange":"binance","price":8849.65,"quantity":0.045482,"quoteQuantity":402.4997813,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-30T10:20:22.809Z","fee":0.00004548,"feeCurrency":"BTC"},{"gid":1869,"id":305256021,"exchange":"binance","price":8771.32,"quantity":0.143504,"quoteQuantity":1258.71950528,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-30T10:31:49.146Z","fee":1.25871951,"feeCurrency":"USDT"},{"gid":1870,"id":305256022,"exchange":"binance","price":8771.31,"quantity":0.143428,"quoteQuantity":1258.05145068,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-30T10:31:49.146Z","fee":1.25805145,"feeCurrency":"USDT"},{"gid":1871,"id":305256023,"exchange":"binance","price":8770.74,"quantity":0.121737,"quoteQuantity":1067.72357538,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-30T10:31:49.146Z","fee":1.06772358,"feeCurrency":"USDT"},{"gid":1872,"id":305256024,"exchange":"binance","price":8766.1,"quantity":0.140781,"quoteQuantity":1234.1003241,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-04-30T10:31:49.146Z","fee":1.23410032,"feeCurrency":"USDT"},{"gid":1873,"id":305341568,"exchange":"binance","price":8902.34,"quantity":0.02,"quoteQuantity":178.0468,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-30T11:47:40.662Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1874,"id":305350337,"exchange":"binance","price":8865,"quantity":0.02,"quoteQuantity":177.3,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-30T11:58:34.466Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1875,"id":305353274,"exchange":"binance","price":8805,"quantity":0.02,"quoteQuantity":176.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-30T12:00:17.579Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1876,"id":305354888,"exchange":"binance","price":8829.89,"quantity":0.002264,"quoteQuantity":19.99087096,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-30T12:00:35.619Z","fee":0.00000226,"feeCurrency":"BTC"},{"gid":1877,"id":305354889,"exchange":"binance","price":8829.9,"quantity":0.017736,"quoteQuantity":156.6071064,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-30T12:00:35.619Z","fee":0.00001774,"feeCurrency":"BTC"},{"gid":1878,"id":305380860,"exchange":"binance","price":8860,"quantity":0.004373,"quoteQuantity":38.74478,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-30T12:41:13.447Z","fee":0.00000437,"feeCurrency":"BTC"},{"gid":1879,"id":305380863,"exchange":"binance","price":8860,"quantity":0.003219,"quoteQuantity":28.52034,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-30T12:41:14.448Z","fee":0.00000322,"feeCurrency":"BTC"},{"gid":1880,"id":305380866,"exchange":"binance","price":8860,"quantity":0.012408,"quoteQuantity":109.93488,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-30T12:41:14.728Z","fee":0.00001241,"feeCurrency":"BTC"},{"gid":1881,"id":305385073,"exchange":"binance","price":8836.39,"quantity":0.006787,"quoteQuantity":59.97257893,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-30T12:44:35.201Z","fee":0.00000679,"feeCurrency":"BTC"},{"gid":1882,"id":305385074,"exchange":"binance","price":8836.4,"quantity":0.013213,"quoteQuantity":116.7553532,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-04-30T12:44:35.201Z","fee":0.00001321,"feeCurrency":"BTC"},{"gid":1883,"id":305390901,"exchange":"binance","price":8811,"quantity":0.02,"quoteQuantity":176.22,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-30T12:48:36.696Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1884,"id":305440629,"exchange":"binance","price":8770,"quantity":0.02,"quoteQuantity":175.4,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-30T13:31:04.18Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1885,"id":305480845,"exchange":"binance","price":8822,"quantity":0.005922,"quoteQuantity":52.243884,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-30T14:28:48.18Z","fee":0.00000592,"feeCurrency":"BTC"},{"gid":1886,"id":305480849,"exchange":"binance","price":8822,"quantity":0.086592,"quoteQuantity":763.914624,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-30T14:28:48.73Z","fee":0.00008659,"feeCurrency":"BTC"},{"gid":1887,"id":305480850,"exchange":"binance","price":8822,"quantity":0.007486,"quoteQuantity":66.041492,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-30T14:28:49.028Z","fee":0.00000749,"feeCurrency":"BTC"},{"gid":1888,"id":305639590,"exchange":"binance","price":8506,"quantity":0.05,"quoteQuantity":425.3,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-30T17:37:12.672Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1889,"id":305968562,"exchange":"binance","price":8580,"quantity":0.1,"quoteQuantity":858,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-04-30T23:54:25.533Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1890,"id":306032643,"exchange":"binance","price":8683.93,"quantity":0.076737,"quoteQuantity":666.37873641,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-01T01:54:46.781Z","fee":0.66637874,"feeCurrency":"USDT"},{"gid":1891,"id":306032644,"exchange":"binance","price":8683.93,"quantity":0.008692,"quoteQuantity":75.48071956,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-01T01:54:46.781Z","fee":0.07548072,"feeCurrency":"USDT"},{"gid":1892,"id":306032645,"exchange":"binance","price":8683.72,"quantity":0.000156,"quoteQuantity":1.35466032,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-01T01:54:46.781Z","fee":0.00005949,"feeCurrency":"BNB"},{"gid":1893,"id":306032646,"exchange":"binance","price":8682.02,"quantity":0.324005,"quoteQuantity":2813.0178901,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-01T01:54:46.781Z","fee":2.81301789,"feeCurrency":"USDT"},{"gid":1894,"id":306121172,"exchange":"binance","price":8816.56,"quantity":0.006152,"quoteQuantity":54.23947712,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-01T05:29:27.588Z","fee":0.00000615,"feeCurrency":"BTC"},{"gid":1895,"id":306121173,"exchange":"binance","price":8816.99,"quantity":0.080879,"quoteQuantity":713.10933421,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-01T05:29:27.588Z","fee":0.00008088,"feeCurrency":"BTC"},{"gid":1896,"id":306121174,"exchange":"binance","price":8817,"quantity":0.012969,"quoteQuantity":114.347673,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-01T05:29:27.588Z","fee":0.00001297,"feeCurrency":"BTC"},{"gid":1897,"id":306174980,"exchange":"binance","price":8711.01,"quantity":0.002295,"quoteQuantity":19.99176795,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-01T07:23:18.052Z","fee":0.01999177,"feeCurrency":"USDT"},{"gid":1898,"id":306174981,"exchange":"binance","price":8711,"quantity":0.097605,"quoteQuantity":850.237155,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-01T07:23:18.052Z","fee":0.85023716,"feeCurrency":"USDT"},{"gid":1899,"id":306217482,"exchange":"binance","price":8785.86,"quantity":0.05,"quoteQuantity":439.293,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-01T08:56:24.385Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1900,"id":306326874,"exchange":"binance","price":8957.19,"quantity":0.000078,"quoteQuantity":0.69866082,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-01T11:37:34.809Z","fee":0.00003054,"feeCurrency":"BNB"},{"gid":1901,"id":306326875,"exchange":"binance","price":8957.28,"quantity":0.006261,"quoteQuantity":56.08153008,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-01T11:37:34.809Z","fee":0.00000626,"feeCurrency":"BTC"},{"gid":1902,"id":306326876,"exchange":"binance","price":8957.63,"quantity":0.010543,"quoteQuantity":94.44029309,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-01T11:37:34.809Z","fee":0.00001054,"feeCurrency":"BTC"},{"gid":1903,"id":306326877,"exchange":"binance","price":8959.03,"quantity":0.212132,"quoteQuantity":1900.49695196,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-01T11:37:34.809Z","fee":0.00021213,"feeCurrency":"BTC"},{"gid":1904,"id":306326878,"exchange":"binance","price":8959.12,"quantity":0.258614,"quoteQuantity":2316.95385968,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-01T11:37:34.809Z","fee":0.00025861,"feeCurrency":"BTC"},{"gid":1905,"id":306326879,"exchange":"binance","price":8959.26,"quantity":0.012372,"quoteQuantity":110.84396472,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-01T11:37:34.809Z","fee":0.00001237,"feeCurrency":"BTC"},{"gid":1906,"id":306370874,"exchange":"binance","price":8964.94,"quantity":0.1,"quoteQuantity":896.494,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-01T12:01:10.244Z","fee":0.896494,"feeCurrency":"USDT"},{"gid":1907,"id":306387704,"exchange":"binance","price":8973.36,"quantity":0.176194,"quoteQuantity":1581.05219184,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-01T12:11:55.155Z","fee":1.58105219,"feeCurrency":"USDT"},{"gid":1908,"id":306387705,"exchange":"binance","price":8973.32,"quantity":0.082741,"quoteQuantity":742.46147012,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-01T12:11:55.155Z","fee":0.74246147,"feeCurrency":"USDT"},{"gid":1909,"id":306387706,"exchange":"binance","price":8973.32,"quantity":0.002228,"quoteQuantity":19.99255696,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-01T12:11:55.155Z","fee":0.01999256,"feeCurrency":"USDT"},{"gid":1910,"id":306387707,"exchange":"binance","price":8973.31,"quantity":0.188287,"quoteQuantity":1689.55761997,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-01T12:11:55.155Z","fee":1.68955762,"feeCurrency":"USDT"},{"gid":1911,"id":306441482,"exchange":"binance","price":8880,"quantity":0.019598,"quoteQuantity":174.03024,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-01T12:59:47.235Z","fee":0.0000196,"feeCurrency":"BTC"},{"gid":1912,"id":306441491,"exchange":"binance","price":8880,"quantity":0.080402,"quoteQuantity":713.96976,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-01T12:59:48.147Z","fee":0.0000804,"feeCurrency":"BTC"},{"gid":1913,"id":306470116,"exchange":"binance","price":8794.43,"quantity":0.0999,"quoteQuantity":878.563557,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-01T13:31:45.842Z","fee":0.87856356,"feeCurrency":"USDT"},{"gid":1914,"id":306565999,"exchange":"binance","price":8760,"quantity":0.1,"quoteQuantity":876,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-01T15:40:29.673Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1915,"id":306599346,"exchange":"binance","price":8726,"quantity":0.097709,"quoteQuantity":852.608734,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-01T16:13:01.772Z","fee":0.00009771,"feeCurrency":"BTC"},{"gid":1916,"id":306599348,"exchange":"binance","price":8726,"quantity":0.002291,"quoteQuantity":19.991266,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-01T16:13:01.822Z","fee":0.00000229,"feeCurrency":"BTC"},{"gid":1917,"id":306602556,"exchange":"binance","price":8732,"quantity":0.1,"quoteQuantity":873.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-01T16:15:46.819Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1918,"id":306615589,"exchange":"binance","price":8685,"quantity":0.02,"quoteQuantity":173.7,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-01T16:32:18.477Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":1919,"id":306728498,"exchange":"binance","price":8685,"quantity":0.1,"quoteQuantity":868.5,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-01T20:33:04.109Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1920,"id":306731128,"exchange":"binance","price":8665,"quantity":0.1,"quoteQuantity":866.5,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-01T20:35:57.625Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1921,"id":306789206,"exchange":"binance","price":8819,"quantity":0.081352,"quoteQuantity":717.443288,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-01T22:07:32.383Z","fee":0.71744329,"feeCurrency":"USDT"},{"gid":1922,"id":306789207,"exchange":"binance","price":8819,"quantity":0.018648,"quoteQuantity":164.456712,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-01T22:07:33.39Z","fee":0.16445671,"feeCurrency":"USDT"},{"gid":1923,"id":306888400,"exchange":"binance","price":8778.79,"quantity":0.1,"quoteQuantity":877.879,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-02T02:08:12.223Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1924,"id":307056352,"exchange":"binance","price":8828,"quantity":0.1,"quoteQuantity":882.8,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-02T10:42:10.784Z","fee":0.8828,"feeCurrency":"USDT"},{"gid":1925,"id":307146946,"exchange":"binance","price":8874.61,"quantity":0.05,"quoteQuantity":443.7305,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-02T13:33:43.459Z","fee":0.4437305,"feeCurrency":"USDT"},{"gid":1926,"id":307149047,"exchange":"binance","price":8892.77,"quantity":0.05,"quoteQuantity":444.6385,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-02T13:37:04.974Z","fee":0.4446385,"feeCurrency":"USDT"},{"gid":1927,"id":307149105,"exchange":"binance","price":8893.79,"quantity":0.05,"quoteQuantity":444.6895,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-02T13:37:10.134Z","fee":0.4446895,"feeCurrency":"USDT"},{"gid":1928,"id":307149370,"exchange":"binance","price":8891.9,"quantity":0.063216,"quoteQuantity":562.1103504,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-02T13:37:34.111Z","fee":0.56211035,"feeCurrency":"USDT"},{"gid":1929,"id":307149371,"exchange":"binance","price":8891.87,"quantity":0.036784,"quoteQuantity":327.07854608,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-02T13:37:34.111Z","fee":0.32707855,"feeCurrency":"USDT"},{"gid":1930,"id":307157867,"exchange":"binance","price":8904.85,"quantity":0.013213,"quoteQuantity":117.65978305,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-02T13:57:49.914Z","fee":0.11765978,"feeCurrency":"USDT"},{"gid":1931,"id":307157871,"exchange":"binance","price":8904.85,"quantity":0.02,"quoteQuantity":178.097,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-02T13:57:51.787Z","fee":0.178097,"feeCurrency":"USDT"},{"gid":1932,"id":307157930,"exchange":"binance","price":8904.85,"quantity":0.002246,"quoteQuantity":20.0002931,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-02T13:58:00.066Z","fee":0.02000029,"feeCurrency":"USDT"},{"gid":1933,"id":307157931,"exchange":"binance","price":8904.85,"quantity":0.002246,"quoteQuantity":20.0002931,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-02T13:58:00.066Z","fee":0.02000029,"feeCurrency":"USDT"},{"gid":1934,"id":307157932,"exchange":"binance","price":8904.85,"quantity":0.019458,"quoteQuantity":173.2705713,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-02T13:58:00.097Z","fee":0.17327057,"feeCurrency":"USDT"},{"gid":1935,"id":307157933,"exchange":"binance","price":8904.85,"quantity":0.045413,"quoteQuantity":404.39595305,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-02T13:58:00.749Z","fee":0.40439595,"feeCurrency":"USDT"},{"gid":1936,"id":307157934,"exchange":"binance","price":8904.85,"quantity":0.060033,"quoteQuantity":534.58486005,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-02T13:58:00.86Z","fee":0.53458486,"feeCurrency":"USDT"},{"gid":1937,"id":307157935,"exchange":"binance","price":8904.85,"quantity":0.006771,"quoteQuantity":60.29473935,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-02T13:58:01.004Z","fee":0.06029474,"feeCurrency":"USDT"},{"gid":1938,"id":307197982,"exchange":"binance","price":8940,"quantity":0.051467,"quoteQuantity":460.11498,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-02T15:10:18.384Z","fee":0.00005147,"feeCurrency":"BTC"},{"gid":1939,"id":307197983,"exchange":"binance","price":8940,"quantity":0.048533,"quoteQuantity":433.88502,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-02T15:10:18.384Z","fee":0.00004853,"feeCurrency":"BTC"},{"gid":1940,"id":307203687,"exchange":"binance","price":8945,"quantity":0.1,"quoteQuantity":894.5,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-02T15:16:31.485Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1941,"id":307214672,"exchange":"binance","price":8912.23,"quantity":0.1998,"quoteQuantity":1780.663554,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-02T15:31:47.816Z","fee":1.78066355,"feeCurrency":"USDT"},{"gid":1942,"id":307314042,"exchange":"binance","price":8902,"quantity":0.2,"quoteQuantity":1780.4,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-02T18:04:59.145Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":1943,"id":307593021,"exchange":"binance","price":9091.15,"quantity":0.002199,"quoteQuantity":19.99143885,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-03T01:40:15.902Z","fee":0.01999144,"feeCurrency":"USDT"},{"gid":1944,"id":307593022,"exchange":"binance","price":9091.01,"quantity":0.007,"quoteQuantity":63.63707,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-03T01:40:15.902Z","fee":0.06363707,"feeCurrency":"USDT"},{"gid":1945,"id":307593023,"exchange":"binance","price":9090.48,"quantity":0.190601,"quoteQuantity":1732.65457848,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-03T01:40:15.902Z","fee":1.73265458,"feeCurrency":"USDT"},{"gid":1946,"id":307710449,"exchange":"binance","price":9078.63,"quantity":0.05,"quoteQuantity":453.9315,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-03T06:03:34.917Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1947,"id":307710631,"exchange":"binance","price":9070.25,"quantity":0.04995,"quoteQuantity":453.0589875,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-03T06:03:58.164Z","fee":0.45305899,"feeCurrency":"USDT"},{"gid":1948,"id":307725540,"exchange":"binance","price":8930,"quantity":0.1,"quoteQuantity":893,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-03T06:11:37.694Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1949,"id":307726324,"exchange":"binance","price":8964.68,"quantity":0.2,"quoteQuantity":1792.936,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-03T06:11:47.377Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":1950,"id":307729157,"exchange":"binance","price":8939.01,"quantity":0.2,"quoteQuantity":1787.802,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-03T06:12:42.091Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":1951,"id":307738616,"exchange":"binance","price":8969.43,"quantity":0.119486,"quoteQuantity":1071.72131298,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-03T06:19:12.078Z","fee":1.07172131,"feeCurrency":"USDT"},{"gid":1952,"id":307738617,"exchange":"binance","price":8969.43,"quantity":0.176868,"quoteQuantity":1586.40514524,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-03T06:19:12.078Z","fee":1.58640515,"feeCurrency":"USDT"},{"gid":1953,"id":307738618,"exchange":"binance","price":8969.27,"quantity":0.001127,"quoteQuantity":10.10836729,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-03T06:19:12.078Z","fee":0.01010837,"feeCurrency":"USDT"},{"gid":1954,"id":307738619,"exchange":"binance","price":8969,"quantity":0.178508,"quoteQuantity":1601.038252,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-03T06:19:12.088Z","fee":1.60103825,"feeCurrency":"USDT"},{"gid":1955,"id":307738620,"exchange":"binance","price":8969,"quantity":0.023511,"quoteQuantity":210.870159,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-03T06:19:12.155Z","fee":0.21087016,"feeCurrency":"USDT"},{"gid":1956,"id":307871154,"exchange":"binance","price":9018.54,"quantity":0.042087,"quoteQuantity":379.56329298,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-03T10:47:59.623Z","fee":0.00004209,"feeCurrency":"BTC"},{"gid":1957,"id":307871155,"exchange":"binance","price":9018.58,"quantity":0.057913,"quoteQuantity":522.29302354,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-03T10:47:59.623Z","fee":0.00005791,"feeCurrency":"BTC"},{"gid":1958,"id":307871351,"exchange":"binance","price":9012,"quantity":0.017782,"quoteQuantity":160.251384,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-03T10:48:07.556Z","fee":0.00001778,"feeCurrency":"BTC"},{"gid":1959,"id":307871364,"exchange":"binance","price":9012,"quantity":0.082218,"quoteQuantity":740.948616,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-03T10:48:08.826Z","fee":0.00008222,"feeCurrency":"BTC"},{"gid":1960,"id":307871435,"exchange":"binance","price":9013,"quantity":0.057921,"quoteQuantity":522.041973,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-03T10:48:12.568Z","fee":0.00005792,"feeCurrency":"BTC"},{"gid":1961,"id":307871446,"exchange":"binance","price":9013,"quantity":0.042079,"quoteQuantity":379.258027,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-03T10:48:13.518Z","fee":0.00004208,"feeCurrency":"BTC"},{"gid":1962,"id":307923079,"exchange":"binance","price":8978.92,"quantity":0.042196,"quoteQuantity":378.87450832,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-03T12:23:15.909Z","fee":0.0000422,"feeCurrency":"BTC"},{"gid":1963,"id":307923080,"exchange":"binance","price":8979.59,"quantity":0.001135,"quoteQuantity":10.19183465,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-03T12:23:15.909Z","fee":0.00000114,"feeCurrency":"BTC"},{"gid":1964,"id":307923081,"exchange":"binance","price":8980,"quantity":0.056669,"quoteQuantity":508.88762,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-03T12:23:15.909Z","fee":0.00005667,"feeCurrency":"BTC"},{"gid":1965,"id":307943551,"exchange":"binance","price":8905,"quantity":0.016571,"quoteQuantity":147.564755,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-03T12:33:52.692Z","fee":0.00001657,"feeCurrency":"BTC"},{"gid":1966,"id":307943552,"exchange":"binance","price":8905,"quantity":0.00254,"quoteQuantity":22.6187,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-03T12:33:52.71Z","fee":0.00000254,"feeCurrency":"BTC"},{"gid":1967,"id":307943553,"exchange":"binance","price":8905,"quantity":0.080889,"quoteQuantity":720.316545,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-03T12:33:52.713Z","fee":0.00008089,"feeCurrency":"BTC"},{"gid":1968,"id":308062058,"exchange":"binance","price":8782,"quantity":0.1,"quoteQuantity":878.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-03T14:50:44.863Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1969,"id":308398057,"exchange":"binance","price":8782,"quantity":0.060118,"quoteQuantity":527.956276,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-04T01:14:55.298Z","fee":0.00006012,"feeCurrency":"BTC"},{"gid":1970,"id":308398058,"exchange":"binance","price":8782,"quantity":0.039882,"quoteQuantity":350.243724,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-04T01:14:55.3Z","fee":0.00003988,"feeCurrency":"BTC"},{"gid":1971,"id":308414548,"exchange":"binance","price":8718.79,"quantity":0.002293,"quoteQuantity":19.99218547,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-04T01:19:51.864Z","fee":0.00000229,"feeCurrency":"BTC"},{"gid":1972,"id":308414549,"exchange":"binance","price":8718.8,"quantity":0.077707,"quoteQuantity":677.5117916,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-04T01:19:51.864Z","fee":0.00007771,"feeCurrency":"BTC"},{"gid":1973,"id":308415468,"exchange":"binance","price":8727.79,"quantity":0.1,"quoteQuantity":872.779,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-04T01:20:24.4Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1974,"id":308419417,"exchange":"binance","price":8682,"quantity":0.005888,"quoteQuantity":51.119616,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-04T01:23:24.437Z","fee":0.00000589,"feeCurrency":"BTC"},{"gid":1975,"id":308419434,"exchange":"binance","price":8682,"quantity":0.044112,"quoteQuantity":382.980384,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-04T01:23:24.828Z","fee":0.00004411,"feeCurrency":"BTC"},{"gid":1976,"id":308861939,"exchange":"binance","price":8800,"quantity":0.1,"quoteQuantity":880,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-04T13:50:29.019Z","fee":0.88,"feeCurrency":"USDT"},{"gid":1977,"id":309295424,"exchange":"binance","price":8871.63,"quantity":0.002254,"quoteQuantity":19.99665402,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-05T03:53:36.65Z","fee":0.01999665,"feeCurrency":"USDT"},{"gid":1978,"id":309295425,"exchange":"binance","price":8871.59,"quantity":0.047746,"quoteQuantity":423.58293614,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-05T03:53:36.65Z","fee":0.42358294,"feeCurrency":"USDT"},{"gid":1979,"id":309440229,"exchange":"binance","price":9015.99,"quantity":0.05,"quoteQuantity":450.7995,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-05T06:34:38.186Z","fee":0.4507995,"feeCurrency":"USDT"},{"gid":1980,"id":309440582,"exchange":"binance","price":9007.78,"quantity":0.066128,"quoteQuantity":595.66647584,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-05T06:35:23.773Z","fee":0.59566648,"feeCurrency":"USDT"},{"gid":1981,"id":309440583,"exchange":"binance","price":9007.78,"quantity":0.033872,"quoteQuantity":305.11152416,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-05T06:35:23.773Z","fee":0.30511152,"feeCurrency":"USDT"},{"gid":1982,"id":309440797,"exchange":"binance","price":9003.55,"quantity":0.1,"quoteQuantity":900.355,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-05T06:35:45.17Z","fee":0.900355,"feeCurrency":"USDT"},{"gid":1983,"id":309591939,"exchange":"binance","price":8822,"quantity":0.05,"quoteQuantity":441.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-05T10:10:35.52Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1984,"id":309594111,"exchange":"binance","price":8786,"quantity":0.05,"quoteQuantity":439.3,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-05T10:10:39.178Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":1985,"id":309598018,"exchange":"binance","price":8790,"quantity":0.1,"quoteQuantity":879,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-05T10:11:14.321Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1986,"id":309683470,"exchange":"binance","price":8860.63,"quantity":0.1,"quoteQuantity":886.063,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-05T12:13:14.473Z","fee":0.886063,"feeCurrency":"USDT"},{"gid":1987,"id":309688874,"exchange":"binance","price":8870.01,"quantity":0.009124,"quoteQuantity":80.92997124,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-05T12:25:29.5Z","fee":0.00000912,"feeCurrency":"BTC"},{"gid":1988,"id":309688875,"exchange":"binance","price":8870.01,"quantity":0.021412,"quoteQuantity":189.92465412,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-05T12:25:29.5Z","fee":0.00002141,"feeCurrency":"BTC"},{"gid":1989,"id":309688876,"exchange":"binance","price":8870.01,"quantity":0.011117,"quoteQuantity":98.60790117,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-05T12:25:29.5Z","fee":0.00001112,"feeCurrency":"BTC"},{"gid":1990,"id":309688877,"exchange":"binance","price":8870.01,"quantity":0.058347,"quoteQuantity":517.53847347,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-05T12:25:29.5Z","fee":0.00005835,"feeCurrency":"BTC"},{"gid":1991,"id":310101481,"exchange":"binance","price":9006.69,"quantity":0.1,"quoteQuantity":900.669,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-06T03:42:50.833Z","fee":0.900669,"feeCurrency":"USDT"},{"gid":1992,"id":310108245,"exchange":"binance","price":9055.02,"quantity":0.02877,"quoteQuantity":260.5129254,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-06T03:47:43.479Z","fee":0.26051293,"feeCurrency":"USDT"},{"gid":1993,"id":310118288,"exchange":"binance","price":8972,"quantity":0.1,"quoteQuantity":897.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-06T03:56:24.853Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":1994,"id":310348821,"exchange":"binance","price":9200,"quantity":0.1999,"quoteQuantity":1839.08,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-06T10:22:24.794Z","fee":1.83908,"feeCurrency":"USDT"},{"gid":1995,"id":310427391,"exchange":"binance","price":9221.37,"quantity":0.261336,"quoteQuantity":2409.87595032,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-06T10:39:33.626Z","fee":2.40987595,"feeCurrency":"USDT"},{"gid":1996,"id":310427392,"exchange":"binance","price":9221.36,"quantity":0.238664,"quoteQuantity":2200.80666304,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-06T10:39:33.626Z","fee":2.20080666,"feeCurrency":"USDT"},{"gid":1997,"id":310545862,"exchange":"binance","price":9377.91,"quantity":0.085569,"quoteQuantity":802.45838079,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-06T12:04:27.173Z","fee":0.00008557,"feeCurrency":"BTC"},{"gid":1998,"id":310545863,"exchange":"binance","price":9377.93,"quantity":0.029,"quoteQuantity":271.95997,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-06T12:04:27.173Z","fee":0.000029,"feeCurrency":"BTC"},{"gid":1999,"id":310545864,"exchange":"binance","price":9377.99,"quantity":0.023425,"quoteQuantity":219.67941575,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-06T12:04:27.173Z","fee":0.00002343,"feeCurrency":"BTC"},{"gid":2000,"id":310545865,"exchange":"binance","price":9377.99,"quantity":0.023485,"quoteQuantity":220.24209515,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-06T12:04:27.173Z","fee":0.00002349,"feeCurrency":"BTC"},{"gid":2001,"id":310545866,"exchange":"binance","price":9378.14,"quantity":0.000839,"quoteQuantity":7.86825946,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-06T12:04:27.173Z","fee":8.4e-7,"feeCurrency":"BTC"},{"gid":2002,"id":310545867,"exchange":"binance","price":9378.47,"quantity":0.264784,"quoteQuantity":2483.26880048,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-06T12:04:27.173Z","fee":0.00026478,"feeCurrency":"BTC"},{"gid":2003,"id":310545868,"exchange":"binance","price":9378.75,"quantity":0.072898,"quoteQuantity":683.6921175,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-06T12:04:27.173Z","fee":0.0000729,"feeCurrency":"BTC"},{"gid":2004,"id":310560021,"exchange":"binance","price":9292.08,"quantity":0.002151,"quoteQuantity":19.98726408,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-06T12:10:09.613Z","fee":0.01998726,"feeCurrency":"USDT"},{"gid":2005,"id":310560022,"exchange":"binance","price":9291.26,"quantity":0.096868,"quoteQuantity":900.02577368,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-06T12:10:09.613Z","fee":0.90002577,"feeCurrency":"USDT"},{"gid":2006,"id":310560023,"exchange":"binance","price":9291.21,"quantity":0.400481,"quoteQuantity":3720.95307201,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-06T12:10:09.613Z","fee":3.72095307,"feeCurrency":"USDT"},{"gid":2007,"id":311261163,"exchange":"binance","price":9271.38,"quantity":0.02,"quoteQuantity":185.4276,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-07T06:45:41.034Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2008,"id":311289777,"exchange":"binance","price":9293.97,"quantity":0.01998,"quoteQuantity":185.6935206,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-07T07:50:43.523Z","fee":0.18569352,"feeCurrency":"USDT"},{"gid":2009,"id":311368882,"exchange":"binance","price":9278,"quantity":0.02,"quoteQuantity":185.56,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-07T10:54:33.825Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2010,"id":311379443,"exchange":"binance","price":9270,"quantity":0.02,"quoteQuantity":185.4,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-07T11:17:33.447Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2011,"id":311379995,"exchange":"binance","price":9265,"quantity":0.02,"quoteQuantity":185.3,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-07T11:19:32.216Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2012,"id":311396321,"exchange":"binance","price":9290,"quantity":0.099992,"quoteQuantity":928.92568,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-07T12:08:36.814Z","fee":0.00009999,"feeCurrency":"BTC"},{"gid":2013,"id":311396322,"exchange":"binance","price":9290,"quantity":0.000008,"quoteQuantity":0.07432,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-07T12:08:36.939Z","fee":0.00000423,"feeCurrency":"BNB"},{"gid":2014,"id":311455532,"exchange":"binance","price":9423.95,"quantity":0.180395,"quoteQuantity":1700.03346025,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-07T13:16:20.425Z","fee":0.0001804,"feeCurrency":"BTC"},{"gid":2015,"id":311455533,"exchange":"binance","price":9424,"quantity":0.219605,"quoteQuantity":2069.55752,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-07T13:16:20.425Z","fee":0.00021961,"feeCurrency":"BTC"},{"gid":2016,"id":311507576,"exchange":"binance","price":9452.01,"quantity":0.00294,"quoteQuantity":27.7889094,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-07T13:25:52.345Z","fee":0.02778891,"feeCurrency":"USDT"},{"gid":2017,"id":311507577,"exchange":"binance","price":9451.78,"quantity":0.1,"quoteQuantity":945.178,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-07T13:25:52.345Z","fee":0.945178,"feeCurrency":"USDT"},{"gid":2018,"id":311507578,"exchange":"binance","price":9450.07,"quantity":0.033419,"quoteQuantity":315.81188933,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-07T13:25:52.345Z","fee":0.31581189,"feeCurrency":"USDT"},{"gid":2019,"id":311507579,"exchange":"binance","price":9450.04,"quantity":0.283221,"quoteQuantity":2676.44977884,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-07T13:25:52.345Z","fee":2.67644978,"feeCurrency":"USDT"},{"gid":2020,"id":311530353,"exchange":"binance","price":9488.67,"quantity":0.1,"quoteQuantity":948.867,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-07T13:35:25.523Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":2021,"id":311532514,"exchange":"binance","price":9482,"quantity":0.02,"quoteQuantity":189.64,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-07T13:37:05.226Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2022,"id":311533671,"exchange":"binance","price":9472,"quantity":0.02,"quoteQuantity":189.44,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-07T13:37:58.505Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2023,"id":311535124,"exchange":"binance","price":9462,"quantity":0.02,"quoteQuantity":189.24,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-07T13:38:53.748Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2024,"id":311582293,"exchange":"binance","price":9490,"quantity":0.02,"quoteQuantity":189.8,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-07T14:20:03.484Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2025,"id":311636502,"exchange":"binance","price":9462,"quantity":0.1,"quoteQuantity":946.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-07T15:31:45.841Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":2026,"id":312182787,"exchange":"binance","price":9855,"quantity":0.1,"quoteQuantity":985.5,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-07T22:52:39.479Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":2027,"id":312195002,"exchange":"binance","price":9863.43,"quantity":0.008529,"quoteQuantity":84.12519447,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-07T23:13:56.69Z","fee":0.00000853,"feeCurrency":"BTC"},{"gid":2028,"id":312195003,"exchange":"binance","price":9863.73,"quantity":0.091471,"quoteQuantity":902.24524683,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-07T23:13:56.69Z","fee":0.00009147,"feeCurrency":"BTC"},{"gid":2029,"id":312403691,"exchange":"binance","price":9902,"quantity":0.02,"quoteQuantity":198.04,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-08T02:32:55.537Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2030,"id":312420502,"exchange":"binance","price":9919.59,"quantity":0.02,"quoteQuantity":198.3918,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-08T02:55:42.552Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2031,"id":312427404,"exchange":"binance","price":9882,"quantity":0.02,"quoteQuantity":197.64,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-08T03:06:49.476Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2032,"id":312429001,"exchange":"binance","price":9854,"quantity":0.02,"quoteQuantity":197.08,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-08T03:09:20.101Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2033,"id":312438575,"exchange":"binance","price":9899.1,"quantity":0.001148,"quoteQuantity":11.3641668,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-08T03:22:11.887Z","fee":0.00000115,"feeCurrency":"BTC"},{"gid":2034,"id":312438576,"exchange":"binance","price":9899.13,"quantity":0.011367,"quoteQuantity":112.52341071,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-08T03:22:11.887Z","fee":0.00001137,"feeCurrency":"BTC"},{"gid":2035,"id":312438577,"exchange":"binance","price":9899.82,"quantity":0.006814,"quoteQuantity":67.45737348,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-08T03:22:11.887Z","fee":0.00000681,"feeCurrency":"BTC"},{"gid":2036,"id":312438578,"exchange":"binance","price":9900,"quantity":0.000671,"quoteQuantity":6.6429,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-08T03:22:11.887Z","fee":6.7e-7,"feeCurrency":"BTC"},{"gid":2037,"id":312491457,"exchange":"binance","price":9879.47,"quantity":0.02,"quoteQuantity":197.5894,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-08T05:14:55.308Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2038,"id":312494724,"exchange":"binance","price":9865,"quantity":0.02,"quoteQuantity":197.3,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-08T05:21:24.479Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2039,"id":312495125,"exchange":"binance","price":9852,"quantity":0.02,"quoteQuantity":197.04,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-08T05:21:29.595Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2040,"id":312496459,"exchange":"binance","price":9832,"quantity":0.02,"quoteQuantity":196.64,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-08T05:21:48.83Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2041,"id":312497107,"exchange":"binance","price":9822,"quantity":0.02,"quoteQuantity":196.44,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-08T05:21:55.222Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2042,"id":312535585,"exchange":"binance","price":9782,"quantity":0.05,"quoteQuantity":489.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-08T06:09:41.797Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":2043,"id":312574569,"exchange":"binance","price":9811,"quantity":0.002038,"quoteQuantity":19.994818,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-08T06:52:40.022Z","fee":0.00000204,"feeCurrency":"BTC"},{"gid":2044,"id":312574571,"exchange":"binance","price":9811,"quantity":0.017962,"quoteQuantity":176.225182,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-08T06:52:40.035Z","fee":0.00001796,"feeCurrency":"BTC"},{"gid":2045,"id":312577649,"exchange":"binance","price":9770,"quantity":0.02,"quoteQuantity":195.4,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-08T06:57:56.98Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2046,"id":312577949,"exchange":"binance","price":9760,"quantity":0.1,"quoteQuantity":976,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-08T06:58:09.222Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":2047,"id":312584483,"exchange":"binance","price":9735,"quantity":0.1,"quoteQuantity":973.5,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-08T07:02:37.298Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":2048,"id":312700092,"exchange":"binance","price":9840,"quantity":0.005046,"quoteQuantity":49.65264,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-08T09:54:27.077Z","fee":0.00000505,"feeCurrency":"BTC"},{"gid":2049,"id":312700093,"exchange":"binance","price":9840,"quantity":0.005046,"quoteQuantity":49.65264,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-08T09:54:27.188Z","fee":0.00000505,"feeCurrency":"BTC"},{"gid":2050,"id":312700094,"exchange":"binance","price":9840,"quantity":0.005046,"quoteQuantity":49.65264,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-08T09:54:27.285Z","fee":0.00000505,"feeCurrency":"BTC"},{"gid":2051,"id":312700095,"exchange":"binance","price":9840,"quantity":0.024677,"quoteQuantity":242.82168,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-08T09:54:27.314Z","fee":0.00002468,"feeCurrency":"BTC"},{"gid":2052,"id":312700097,"exchange":"binance","price":9840,"quantity":0.038141,"quoteQuantity":375.30744,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-08T09:54:27.39Z","fee":0.00003814,"feeCurrency":"BTC"},{"gid":2053,"id":312700098,"exchange":"binance","price":9840,"quantity":0.005047,"quoteQuantity":49.66248,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-08T09:54:27.394Z","fee":0.00000505,"feeCurrency":"BTC"},{"gid":2054,"id":312700103,"exchange":"binance","price":9840,"quantity":0.016997,"quoteQuantity":167.25048,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-08T09:54:28.112Z","fee":0.000017,"feeCurrency":"BTC"},{"gid":2055,"id":312927613,"exchange":"binance","price":10000,"quantity":0.1,"quoteQuantity":1000,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-08T15:17:20.79Z","fee":1,"feeCurrency":"USDT"},{"gid":2056,"id":312929679,"exchange":"binance","price":9977.61,"quantity":0.148038,"quoteQuantity":1477.06542918,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-08T15:18:22.053Z","fee":1.47706543,"feeCurrency":"USDT"},{"gid":2057,"id":312929680,"exchange":"binance","price":9976.58,"quantity":0.051962,"quoteQuantity":518.40304996,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-08T15:18:22.053Z","fee":0.51840305,"feeCurrency":"USDT"},{"gid":2058,"id":312930178,"exchange":"binance","price":9975.44,"quantity":0.2,"quoteQuantity":1995.088,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-08T15:18:36.977Z","fee":1.995088,"feeCurrency":"USDT"},{"gid":2059,"id":312955341,"exchange":"binance","price":9980,"quantity":0.1,"quoteQuantity":998,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-08T15:41:27.319Z","fee":0.998,"feeCurrency":"USDT"},{"gid":2060,"id":312963250,"exchange":"binance","price":9940,"quantity":0.3,"quoteQuantity":2982,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-08T15:53:47.842Z","fee":0.0003,"feeCurrency":"BTC"},{"gid":2061,"id":313235585,"exchange":"binance","price":9781,"quantity":0.02,"quoteQuantity":195.62,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-08T23:48:13.525Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2062,"id":313342700,"exchange":"binance","price":9870,"quantity":0.1,"quoteQuantity":987,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-09T03:03:38.177Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":2063,"id":313419761,"exchange":"binance","price":9820,"quantity":0.02,"quoteQuantity":196.4,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-09T05:40:09.784Z","fee":0.1964,"feeCurrency":"USDT"},{"gid":2064,"id":313419810,"exchange":"binance","price":9820,"quantity":0.004071,"quoteQuantity":39.97722,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-09T05:40:17.403Z","fee":0.00000407,"feeCurrency":"BTC"},{"gid":2065,"id":313419818,"exchange":"binance","price":9820,"quantity":0.015929,"quoteQuantity":156.42278,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-09T05:40:17.819Z","fee":0.00001593,"feeCurrency":"BTC"},{"gid":2066,"id":313419829,"exchange":"binance","price":9819.77,"quantity":0.02,"quoteQuantity":196.3954,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-09T05:40:19.189Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2067,"id":313460070,"exchange":"binance","price":9760,"quantity":0.02,"quoteQuantity":195.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-09T06:54:31.161Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2068,"id":313492349,"exchange":"binance","price":9720,"quantity":0.02,"quoteQuantity":194.4,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-09T07:06:24.619Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2069,"id":313658411,"exchange":"binance","price":9610,"quantity":0.02,"quoteQuantity":192.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-09T10:46:51.953Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2070,"id":313758253,"exchange":"binance","price":9640,"quantity":0.02,"quoteQuantity":192.8,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-09T13:13:52.949Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2071,"id":313809914,"exchange":"binance","price":9685.05,"quantity":0.1,"quoteQuantity":968.505,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-09T15:01:45.79Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":2072,"id":313810897,"exchange":"binance","price":9681,"quantity":0.002065,"quoteQuantity":19.991265,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-09T15:03:41.546Z","fee":0.00000207,"feeCurrency":"BTC"},{"gid":2073,"id":313810900,"exchange":"binance","price":9681,"quantity":0.017935,"quoteQuantity":173.628735,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-09T15:03:41.6Z","fee":0.00001794,"feeCurrency":"BTC"},{"gid":2074,"id":313820975,"exchange":"binance","price":9635,"quantity":0.02,"quoteQuantity":192.7,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-09T15:25:51.805Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2075,"id":313859627,"exchange":"binance","price":9750.19,"quantity":0.02,"quoteQuantity":195.0038,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-09T16:29:23.385Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2076,"id":313971479,"exchange":"binance","price":9630,"quantity":0.1,"quoteQuantity":963,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-09T20:51:37.07Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":2077,"id":314178627,"exchange":"binance","price":8612.91,"quantity":0.1,"quoteQuantity":861.291,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-10T00:20:01.936Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":2078,"id":314214478,"exchange":"binance","price":8603.83,"quantity":0.1,"quoteQuantity":860.383,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-10T00:22:51.94Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":2079,"id":314234989,"exchange":"binance","price":8400,"quantity":0.052143,"quoteQuantity":438.0012,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-10T00:25:02.223Z","fee":0.00005214,"feeCurrency":"BTC"},{"gid":2080,"id":314844601,"exchange":"binance","price":8745.04,"quantity":0.039881,"quoteQuantity":348.76094024,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-10T07:43:21.578Z","fee":0.34876094,"feeCurrency":"USDT"},{"gid":2081,"id":314966452,"exchange":"binance","price":8710,"quantity":0.040002,"quoteQuantity":348.41742,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-10T10:00:37.905Z","fee":0.00004,"feeCurrency":"BTC"},{"gid":2082,"id":315818292,"exchange":"binance","price":8704.87,"quantity":0.2,"quoteQuantity":1740.974,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-11T06:47:09.205Z","fee":1.740974,"feeCurrency":"USDT"},{"gid":2083,"id":315822644,"exchange":"binance","price":8686.69,"quantity":0.2,"quoteQuantity":1737.338,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-11T06:58:49.835Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":2084,"id":315827824,"exchange":"binance","price":8702,"quantity":0.2,"quoteQuantity":1740.4,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-11T07:12:42.478Z","fee":1.7404,"feeCurrency":"USDT"},{"gid":2085,"id":315832910,"exchange":"binance","price":8667.61,"quantity":0.002307,"quoteQuantity":19.99617627,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-11T07:27:43.83Z","fee":0.00000231,"feeCurrency":"BTC"},{"gid":2086,"id":315832911,"exchange":"binance","price":8667.62,"quantity":0.197693,"quoteQuantity":1713.52780066,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-11T07:27:43.83Z","fee":0.00019769,"feeCurrency":"BTC"},{"gid":2087,"id":317586820,"exchange":"binance","price":8906,"quantity":0.14438,"quoteQuantity":1285.84828,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-12T16:51:43.018Z","fee":1.28584828,"feeCurrency":"USDT"},{"gid":2088,"id":317586822,"exchange":"binance","price":8906,"quantity":0.05562,"quoteQuantity":495.35172,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-12T16:51:43.021Z","fee":0.49535172,"feeCurrency":"USDT"},{"gid":2089,"id":317613244,"exchange":"binance","price":8892,"quantity":0.1,"quoteQuantity":889.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-12T17:34:22.083Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":2090,"id":317799670,"exchange":"binance","price":8800.64,"quantity":0.009199,"quoteQuantity":80.95708736,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-12T23:48:56.472Z","fee":0.0000092,"feeCurrency":"BTC"},{"gid":2091,"id":317799671,"exchange":"binance","price":8800.64,"quantity":0.004329,"quoteQuantity":38.09797056,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-12T23:48:56.472Z","fee":0.00000433,"feeCurrency":"BTC"},{"gid":2092,"id":317799672,"exchange":"binance","price":8800.67,"quantity":0.086472,"quoteQuantity":761.01153624,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-12T23:48:56.472Z","fee":0.00008647,"feeCurrency":"BTC"},{"gid":2093,"id":317802947,"exchange":"binance","price":8813.02,"quantity":0.008447,"quoteQuantity":74.44357994,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-12T23:59:41.959Z","fee":0.07444358,"feeCurrency":"USDT"},{"gid":2094,"id":317802964,"exchange":"binance","price":8813.02,"quantity":0.060023,"quoteQuantity":528.98389946,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-12T23:59:44.179Z","fee":0.5289839,"feeCurrency":"USDT"},{"gid":2095,"id":317802966,"exchange":"binance","price":8813.02,"quantity":0.03153,"quoteQuantity":277.8745206,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-12T23:59:44.817Z","fee":0.27787452,"feeCurrency":"USDT"},{"gid":2096,"id":317803035,"exchange":"binance","price":8813,"quantity":0.1,"quoteQuantity":881.3,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-12T23:59:58.05Z","fee":0.8813,"feeCurrency":"USDT"},{"gid":2097,"id":319136974,"exchange":"binance","price":9920,"quantity":0.1,"quoteQuantity":992,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-14T10:06:48.314Z","fee":0.992,"feeCurrency":"USDT"},{"gid":2098,"id":319141768,"exchange":"binance","price":9900,"quantity":0.076274,"quoteQuantity":755.1126,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-14T10:07:43.85Z","fee":0.7551126,"feeCurrency":"USDT"},{"gid":2099,"id":319141769,"exchange":"binance","price":9900,"quantity":0.023726,"quoteQuantity":234.8874,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-14T10:07:43.85Z","fee":0.2348874,"feeCurrency":"USDT"},{"gid":2100,"id":319167832,"exchange":"binance","price":9764.55,"quantity":0.027271,"quoteQuantity":266.28904305,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-14T10:10:42.47Z","fee":0.00002727,"feeCurrency":"BTC"},{"gid":2101,"id":319167833,"exchange":"binance","price":9765.17,"quantity":0.002819,"quoteQuantity":27.52801423,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-14T10:10:42.47Z","fee":0.00000282,"feeCurrency":"BTC"},{"gid":2102,"id":319167834,"exchange":"binance","price":9768.68,"quantity":0.017624,"quoteQuantity":172.16321632,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-14T10:10:42.47Z","fee":0.00001762,"feeCurrency":"BTC"},{"gid":2103,"id":319167835,"exchange":"binance","price":9769.01,"quantity":0.007983,"quoteQuantity":77.98600683,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-14T10:10:42.47Z","fee":0.00000798,"feeCurrency":"BTC"},{"gid":2104,"id":319167836,"exchange":"binance","price":9769.11,"quantity":0.144303,"quoteQuantity":1409.71188033,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-14T10:10:42.47Z","fee":0.0001443,"feeCurrency":"BTC"},{"gid":2105,"id":319254068,"exchange":"binance","price":9656.12,"quantity":0.002727,"quoteQuantity":26.33223924,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-14T10:51:28.453Z","fee":0.00000273,"feeCurrency":"BTC"},{"gid":2106,"id":321711225,"exchange":"binance","price":9503.84,"quantity":0.2,"quoteQuantity":1900.768,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-17T03:06:53.75Z","fee":1.900768,"feeCurrency":"USDT"},{"gid":2107,"id":321712992,"exchange":"binance","price":9506.87,"quantity":0.002102,"quoteQuantity":19.98344074,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-17T03:13:15.416Z","fee":0.0000021,"feeCurrency":"BTC"},{"gid":2108,"id":321712993,"exchange":"binance","price":9507.11,"quantity":0.003261,"quoteQuantity":31.00268571,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-17T03:13:15.416Z","fee":0.00000326,"feeCurrency":"BTC"},{"gid":2109,"id":321712994,"exchange":"binance","price":9507.78,"quantity":0.142282,"quoteQuantity":1352.78595396,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-17T03:13:15.416Z","fee":0.00014228,"feeCurrency":"BTC"},{"gid":2110,"id":321712995,"exchange":"binance","price":9507.79,"quantity":0.052015,"quoteQuantity":494.54769685,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-17T03:13:15.416Z","fee":0.00005202,"feeCurrency":"BTC"},{"gid":2111,"id":321779061,"exchange":"binance","price":9524.57,"quantity":0.2,"quoteQuantity":1904.914,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-17T06:27:44.602Z","fee":1.904914,"feeCurrency":"USDT"},{"gid":2112,"id":321786846,"exchange":"binance","price":9545.01,"quantity":0.199424,"quoteQuantity":1903.50407424,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-17T06:48:05.207Z","fee":0.00019942,"feeCurrency":"BTC"},{"gid":2113,"id":321941202,"exchange":"binance","price":9701.47,"quantity":0.05,"quoteQuantity":485.0735,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-17T12:20:36.854Z","fee":0.4850735,"feeCurrency":"USDT"},{"gid":2114,"id":322055471,"exchange":"binance","price":9800,"quantity":0.05,"quoteQuantity":490,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-17T14:51:28.913Z","fee":0.49,"feeCurrency":"USDT"},{"gid":2115,"id":322173472,"exchange":"binance","price":9750.01,"quantity":0.015,"quoteQuantity":146.25015,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-17T17:11:46.047Z","fee":0.000015,"feeCurrency":"BTC"},{"gid":2116,"id":322173473,"exchange":"binance","price":9750.46,"quantity":0.035,"quoteQuantity":341.2661,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-17T17:11:46.047Z","fee":0.000035,"feeCurrency":"BTC"},{"gid":2117,"id":322379214,"exchange":"binance","price":9820,"quantity":0.1,"quoteQuantity":982,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-18T01:07:20.856Z","fee":0.982,"feeCurrency":"USDT"},{"gid":2118,"id":322401928,"exchange":"binance","price":9900,"quantity":0.2,"quoteQuantity":1980,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-18T01:20:33.52Z","fee":1.98,"feeCurrency":"USDT"},{"gid":2119,"id":322471117,"exchange":"binance","price":9877.99,"quantity":0.1,"quoteQuantity":987.799,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-18T02:19:34.502Z","fee":0.987799,"feeCurrency":"USDT"},{"gid":2120,"id":322500704,"exchange":"binance","price":9886,"quantity":0.0037,"quoteQuantity":36.5782,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-18T03:13:27.21Z","fee":0.0365782,"feeCurrency":"USDT"},{"gid":2121,"id":322500705,"exchange":"binance","price":9886,"quantity":0.0037,"quoteQuantity":36.5782,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-18T03:13:27.321Z","fee":0.0365782,"feeCurrency":"USDT"},{"gid":2122,"id":322500941,"exchange":"binance","price":9884.28,"quantity":0.05,"quoteQuantity":494.214,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-18T03:13:56.321Z","fee":0.494214,"feeCurrency":"USDT"},{"gid":2123,"id":322527581,"exchange":"binance","price":9900,"quantity":0.05,"quoteQuantity":495,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-18T04:07:45.971Z","fee":0.495,"feeCurrency":"USDT"},{"gid":2124,"id":322651777,"exchange":"binance","price":9787.53,"quantity":0.003893,"quoteQuantity":38.10285429,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-18T08:18:08.583Z","fee":0.00000389,"feeCurrency":"BTC"},{"gid":2125,"id":322651778,"exchange":"binance","price":9787.53,"quantity":0.003578,"quoteQuantity":35.01978234,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-18T08:18:08.731Z","fee":0.00000358,"feeCurrency":"BTC"},{"gid":2126,"id":322651779,"exchange":"binance","price":9787.53,"quantity":0.003578,"quoteQuantity":35.01978234,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-18T08:18:08.736Z","fee":0.00000358,"feeCurrency":"BTC"},{"gid":2127,"id":322651780,"exchange":"binance","price":9787.53,"quantity":0.008951,"quoteQuantity":87.60818103,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-18T08:18:08.869Z","fee":0.00000895,"feeCurrency":"BTC"},{"gid":2128,"id":322679289,"exchange":"binance","price":9632,"quantity":0.1,"quoteQuantity":963.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-18T08:48:12.361Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":2129,"id":322686716,"exchange":"binance","price":9532,"quantity":0.1,"quoteQuantity":953.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-18T08:48:35.783Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":2130,"id":322687847,"exchange":"binance","price":9512,"quantity":0.05116,"quoteQuantity":486.63392,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-18T08:48:37.718Z","fee":0.00005116,"feeCurrency":"BTC"},{"gid":2131,"id":322788565,"exchange":"binance","price":9654.21,"quantity":0.02,"quoteQuantity":193.0842,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-18T10:42:06.465Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2132,"id":322833614,"exchange":"binance","price":9599.71,"quantity":0.02,"quoteQuantity":191.9942,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-18T12:07:53.693Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2133,"id":322900996,"exchange":"binance","price":9712.32,"quantity":0.002058,"quoteQuantity":19.98795456,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-18T14:16:56.492Z","fee":0.01998795,"feeCurrency":"USDT"},{"gid":2134,"id":322900997,"exchange":"binance","price":9712.31,"quantity":0.017942,"quoteQuantity":174.25826602,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-18T14:16:56.492Z","fee":0.17425827,"feeCurrency":"USDT"},{"gid":2135,"id":322901245,"exchange":"binance","price":9718.27,"quantity":0.02,"quoteQuantity":194.3654,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-18T14:17:21.072Z","fee":0.1943654,"feeCurrency":"USDT"},{"gid":2136,"id":322967338,"exchange":"binance","price":9623,"quantity":0.04,"quoteQuantity":384.92,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-18T16:22:51.429Z","fee":0.00004,"feeCurrency":"BTC"},{"gid":2137,"id":323489291,"exchange":"binance","price":9799,"quantity":0.1,"quoteQuantity":979.9,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-19T10:30:46.439Z","fee":0.9799,"feeCurrency":"USDT"},{"gid":2138,"id":323629148,"exchange":"binance","price":9638.88,"quantity":0.1,"quoteQuantity":963.888,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-19T12:53:36.013Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":2139,"id":323646917,"exchange":"binance","price":9666.4,"quantity":0.002068,"quoteQuantity":19.9901152,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-19T13:24:29.21Z","fee":0.00000207,"feeCurrency":"BTC"},{"gid":2140,"id":323646918,"exchange":"binance","price":9666.55,"quantity":0.00411,"quoteQuantity":39.7295205,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-19T13:24:29.21Z","fee":0.00000411,"feeCurrency":"BTC"},{"gid":2141,"id":323646919,"exchange":"binance","price":9666.62,"quantity":0.008625,"quoteQuantity":83.3745975,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-19T13:24:29.21Z","fee":0.00000863,"feeCurrency":"BTC"},{"gid":2142,"id":323646920,"exchange":"binance","price":9666.92,"quantity":0.038639,"quoteQuantity":373.52012188,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-19T13:24:29.21Z","fee":0.00003864,"feeCurrency":"BTC"},{"gid":2143,"id":323646921,"exchange":"binance","price":9667.23,"quantity":0.156558,"quoteQuantity":1513.48219434,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-19T13:24:29.21Z","fee":0.00015656,"feeCurrency":"BTC"},{"gid":2144,"id":323680085,"exchange":"binance","price":9706.8,"quantity":0.00461,"quoteQuantity":44.748348,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-19T14:05:56.56Z","fee":0.04474835,"feeCurrency":"USDT"},{"gid":2145,"id":323680086,"exchange":"binance","price":9706.74,"quantity":0.007042,"quoteQuantity":68.35486308,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-19T14:05:56.56Z","fee":0.06835486,"feeCurrency":"USDT"},{"gid":2146,"id":323680087,"exchange":"binance","price":9706.5,"quantity":0.00206,"quoteQuantity":19.99539,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-19T14:05:56.56Z","fee":0.01999539,"feeCurrency":"USDT"},{"gid":2147,"id":323680088,"exchange":"binance","price":9706.49,"quantity":0.001267,"quoteQuantity":12.29812283,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-19T14:05:56.56Z","fee":0.01229812,"feeCurrency":"USDT"},{"gid":2148,"id":323680089,"exchange":"binance","price":9705.5,"quantity":0.02,"quoteQuantity":194.11,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-19T14:05:56.56Z","fee":0.19411,"feeCurrency":"USDT"},{"gid":2149,"id":323680090,"exchange":"binance","price":9705.11,"quantity":0.139501,"quoteQuantity":1353.87255011,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-19T14:05:56.56Z","fee":1.35387255,"feeCurrency":"USDT"},{"gid":2150,"id":323680091,"exchange":"binance","price":9705.1,"quantity":0.02552,"quoteQuantity":247.674152,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-19T14:05:56.56Z","fee":0.24767415,"feeCurrency":"USDT"},{"gid":2151,"id":323765369,"exchange":"binance","price":9632,"quantity":0.01,"quoteQuantity":96.32,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-19T17:31:17.855Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":2152,"id":323843781,"exchange":"binance","price":9672.54,"quantity":0.01,"quoteQuantity":96.7254,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-19T21:14:54.066Z","fee":0.0967254,"feeCurrency":"USDT"},{"gid":2153,"id":324412667,"exchange":"binance","price":9456.05,"quantity":0.013311,"quoteQuantity":125.86948155,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-20T16:07:20.737Z","fee":0.00001331,"feeCurrency":"BTC"},{"gid":2154,"id":324412676,"exchange":"binance","price":9456.42,"quantity":0.002114,"quoteQuantity":19.99087188,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-20T16:07:21.082Z","fee":0.00000211,"feeCurrency":"BTC"},{"gid":2155,"id":324412922,"exchange":"binance","price":9466.78,"quantity":0.1,"quoteQuantity":946.678,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-20T16:07:27.449Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":2156,"id":324415537,"exchange":"binance","price":9477.06,"quantity":0.003793,"quoteQuantity":35.94648858,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-20T16:08:55.636Z","fee":0.00000379,"feeCurrency":"BTC"},{"gid":2157,"id":324415538,"exchange":"binance","price":9477.49,"quantity":0.0069,"quoteQuantity":65.394681,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-20T16:08:55.636Z","fee":0.0000069,"feeCurrency":"BTC"},{"gid":2158,"id":324415539,"exchange":"binance","price":9478,"quantity":0.089307,"quoteQuantity":846.451746,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-20T16:08:55.659Z","fee":0.00008931,"feeCurrency":"BTC"},{"gid":2159,"id":324418611,"exchange":"binance","price":9469.36,"quantity":0.011829,"quoteQuantity":112.01305944,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-20T16:10:44.982Z","fee":0.00001183,"feeCurrency":"BTC"},{"gid":2160,"id":324418612,"exchange":"binance","price":9470,"quantity":0.023146,"quoteQuantity":219.19262,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-20T16:10:44.982Z","fee":0.00002315,"feeCurrency":"BTC"},{"gid":2161,"id":324418665,"exchange":"binance","price":9470,"quantity":0.006974,"quoteQuantity":66.04378,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-20T16:10:47.352Z","fee":0.00000697,"feeCurrency":"BTC"},{"gid":2162,"id":324758008,"exchange":"binance","price":9506.17,"quantity":0.02,"quoteQuantity":190.1234,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-21T05:07:47.305Z","fee":0.1901234,"feeCurrency":"USDT"},{"gid":2163,"id":324758031,"exchange":"binance","price":9503.65,"quantity":0.02,"quoteQuantity":190.073,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-21T05:07:53.955Z","fee":0.190073,"feeCurrency":"USDT"},{"gid":2164,"id":324758118,"exchange":"binance","price":9502.08,"quantity":0.02,"quoteQuantity":190.0416,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-21T05:08:16.216Z","fee":0.1900416,"feeCurrency":"USDT"},{"gid":2165,"id":324794053,"exchange":"binance","price":9485,"quantity":0.060061,"quoteQuantity":569.678585,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-21T07:00:50.353Z","fee":0.00006006,"feeCurrency":"BTC"},{"gid":2166,"id":328643694,"exchange":"binance","price":8901.76,"quantity":0.1,"quoteQuantity":890.176,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-26T12:11:07.801Z","fee":0.890176,"feeCurrency":"USDT"},{"gid":2167,"id":328645918,"exchange":"binance","price":8890.1,"quantity":0.100031,"quoteQuantity":889.2855931,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-26T12:15:43.198Z","fee":0.00010003,"feeCurrency":"BTC"},{"gid":2168,"id":330462405,"exchange":"binance","price":9500.06,"quantity":0.02,"quoteQuantity":190.0012,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-29T02:09:28.869Z","fee":0.1900012,"feeCurrency":"USDT"},{"gid":2169,"id":331110009,"exchange":"binance","price":9510.8,"quantity":0.02,"quoteQuantity":190.216,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-30T05:18:43.657Z","fee":0.190216,"feeCurrency":"USDT"},{"gid":2170,"id":331115749,"exchange":"binance","price":9526.5,"quantity":0.019978,"quoteQuantity":190.320417,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-30T05:35:04.145Z","fee":0.19032042,"feeCurrency":"USDT"},{"gid":2171,"id":331115750,"exchange":"binance","price":9526.5,"quantity":0.000022,"quoteQuantity":0.209583,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-30T05:35:04.236Z","fee":0.00000911,"feeCurrency":"BNB"},{"gid":2172,"id":331115970,"exchange":"binance","price":9529.07,"quantity":0.02,"quoteQuantity":190.5814,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-30T05:35:40.226Z","fee":0.1905814,"feeCurrency":"USDT"},{"gid":2173,"id":331142954,"exchange":"binance","price":9550,"quantity":0.02,"quoteQuantity":191,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-30T06:18:37.24Z","fee":0.191,"feeCurrency":"USDT"},{"gid":2174,"id":331209853,"exchange":"binance","price":9524.89,"quantity":0.006545,"quoteQuantity":62.34040505,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-30T09:07:47.213Z","fee":0.00000655,"feeCurrency":"BTC"},{"gid":2175,"id":331209854,"exchange":"binance","price":9524.95,"quantity":0.013455,"quoteQuantity":128.15820225,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-30T09:07:47.213Z","fee":0.00001346,"feeCurrency":"BTC"},{"gid":2176,"id":331213253,"exchange":"binance","price":9498,"quantity":0.000027,"quoteQuantity":0.256446,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-30T09:09:10.875Z","fee":0.00001236,"feeCurrency":"BNB"},{"gid":2177,"id":331213260,"exchange":"binance","price":9498,"quantity":0.079957,"quoteQuantity":759.431586,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-30T09:09:11.338Z","fee":0.00007996,"feeCurrency":"BTC"},{"gid":2178,"id":331217140,"exchange":"binance","price":9490.99,"quantity":0.112974,"quoteQuantity":1072.23510426,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-30T09:13:11.459Z","fee":1.0722351,"feeCurrency":"USDT"},{"gid":2179,"id":331217141,"exchange":"binance","price":9490.99,"quantity":0.087026,"quoteQuantity":825.96289574,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-30T09:13:11.459Z","fee":0.8259629,"feeCurrency":"USDT"},{"gid":2180,"id":331339960,"exchange":"binance","price":9545.52,"quantity":0.198782,"quoteQuantity":1897.47755664,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-30T14:27:10.702Z","fee":0.00019878,"feeCurrency":"BTC"},{"gid":2181,"id":331395166,"exchange":"binance","price":9529.49,"quantity":0.2,"quoteQuantity":1905.898,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-30T16:29:29.29Z","fee":1.905898,"feeCurrency":"USDT"},{"gid":2182,"id":331757875,"exchange":"binance","price":9561.12,"quantity":0.05,"quoteQuantity":478.056,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-31T04:44:49.429Z","fee":0.478056,"feeCurrency":"USDT"},{"gid":2183,"id":331776334,"exchange":"binance","price":9585.17,"quantity":0.02,"quoteQuantity":191.7034,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-31T05:33:17.379Z","fee":0.1917034,"feeCurrency":"USDT"},{"gid":2184,"id":331781250,"exchange":"binance","price":9572.77,"quantity":0.003468,"quoteQuantity":33.19836636,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-31T05:48:04.119Z","fee":0.03319837,"feeCurrency":"USDT"},{"gid":2185,"id":331781252,"exchange":"binance","price":9572.77,"quantity":0.016524,"quoteQuantity":158.18045148,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-31T05:48:04.345Z","fee":0.15818045,"feeCurrency":"USDT"},{"gid":2186,"id":331781261,"exchange":"binance","price":9572.77,"quantity":0.000008,"quoteQuantity":0.07658216,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-05-31T05:48:06.147Z","fee":0.00000325,"feeCurrency":"BNB"},{"gid":2187,"id":331788243,"exchange":"binance","price":9572.6,"quantity":0.05,"quoteQuantity":478.63,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-31T06:08:35.994Z","fee":0.47863,"feeCurrency":"USDT"},{"gid":2188,"id":331975692,"exchange":"binance","price":9532.11,"quantity":0.004467,"quoteQuantity":42.57993537,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-31T13:20:15.367Z","fee":0.00000447,"feeCurrency":"BTC"},{"gid":2189,"id":331975693,"exchange":"binance","price":9532.11,"quantity":0.005533,"quoteQuantity":52.74116463,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-31T13:20:15.455Z","fee":0.00000553,"feeCurrency":"BTC"},{"gid":2190,"id":331975904,"exchange":"binance","price":9534.14,"quantity":0.02,"quoteQuantity":190.6828,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-31T13:20:34.195Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2191,"id":331975994,"exchange":"binance","price":9532,"quantity":0.02,"quoteQuantity":190.64,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-31T13:20:42.5Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2192,"id":331986182,"exchange":"binance","price":9581.97,"quantity":0.1,"quoteQuantity":958.197,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-31T13:36:10.702Z","fee":0.958197,"feeCurrency":"USDT"},{"gid":2193,"id":332006461,"exchange":"binance","price":9535,"quantity":0.021288,"quoteQuantity":202.98108,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-31T14:19:09.237Z","fee":0.00002129,"feeCurrency":"BTC"},{"gid":2194,"id":332006469,"exchange":"binance","price":9535,"quantity":0.004972,"quoteQuantity":47.40802,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-31T14:19:11.681Z","fee":0.00000497,"feeCurrency":"BTC"},{"gid":2195,"id":332006470,"exchange":"binance","price":9535,"quantity":0.002097,"quoteQuantity":19.994895,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-31T14:19:11.756Z","fee":0.0000021,"feeCurrency":"BTC"},{"gid":2196,"id":332006471,"exchange":"binance","price":9535,"quantity":0.004972,"quoteQuantity":47.40802,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-31T14:19:11.793Z","fee":0.00000497,"feeCurrency":"BTC"},{"gid":2197,"id":332006492,"exchange":"binance","price":9535,"quantity":0.004194,"quoteQuantity":39.98979,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-31T14:19:12.346Z","fee":0.00000419,"feeCurrency":"BTC"},{"gid":2198,"id":332006494,"exchange":"binance","price":9535,"quantity":0.004972,"quoteQuantity":47.40802,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-31T14:19:12.448Z","fee":0.00000497,"feeCurrency":"BTC"},{"gid":2199,"id":332006507,"exchange":"binance","price":9535,"quantity":0.019395,"quoteQuantity":184.931325,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-31T14:19:12.647Z","fee":0.0000194,"feeCurrency":"BTC"},{"gid":2200,"id":332006508,"exchange":"binance","price":9535,"quantity":0.01,"quoteQuantity":95.35,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-31T14:19:12.793Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":2201,"id":332037682,"exchange":"binance","price":9491.2,"quantity":0.1,"quoteQuantity":949.12,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-05-31T15:01:45.086Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":2202,"id":332065909,"exchange":"binance","price":9463,"quantity":0.4,"quoteQuantity":3785.2,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-31T15:44:52.867Z","fee":3.7852,"feeCurrency":"USDT"},{"gid":2203,"id":332103513,"exchange":"binance","price":9527,"quantity":0.04,"quoteQuantity":381.08,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-31T16:58:41.832Z","fee":0.38108,"feeCurrency":"USDT"},{"gid":2204,"id":332190300,"exchange":"binance","price":9464,"quantity":0.04636,"quoteQuantity":438.75104,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-31T21:21:02.397Z","fee":0.00004636,"feeCurrency":"BTC"},{"gid":2205,"id":332190302,"exchange":"binance","price":9464,"quantity":0.004063,"quoteQuantity":38.452232,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-31T21:21:02.651Z","fee":0.00000406,"feeCurrency":"BTC"},{"gid":2206,"id":332190303,"exchange":"binance","price":9464,"quantity":0.042948,"quoteQuantity":406.459872,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-31T21:21:02.732Z","fee":0.00004295,"feeCurrency":"BTC"},{"gid":2207,"id":332190304,"exchange":"binance","price":9464,"quantity":0.064168,"quoteQuantity":607.285952,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-31T21:21:02.762Z","fee":0.00006417,"feeCurrency":"BTC"},{"gid":2208,"id":332190305,"exchange":"binance","price":9464,"quantity":0.001878,"quoteQuantity":17.773392,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-31T21:21:02.782Z","fee":0.00000188,"feeCurrency":"BTC"},{"gid":2209,"id":332190306,"exchange":"binance","price":9464,"quantity":0.033804,"quoteQuantity":319.921056,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-31T21:21:02.844Z","fee":0.0000338,"feeCurrency":"BTC"},{"gid":2210,"id":332190307,"exchange":"binance","price":9464,"quantity":0.064168,"quoteQuantity":607.285952,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-31T21:21:02.864Z","fee":0.00006417,"feeCurrency":"BTC"},{"gid":2211,"id":332190308,"exchange":"binance","price":9464,"quantity":0.006985,"quoteQuantity":66.10604,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-31T21:21:02.974Z","fee":0.00000699,"feeCurrency":"BTC"},{"gid":2212,"id":332190309,"exchange":"binance","price":9464,"quantity":0.008775,"quoteQuantity":83.0466,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-31T21:21:02.995Z","fee":0.00000878,"feeCurrency":"BTC"},{"gid":2213,"id":332190310,"exchange":"binance","price":9464,"quantity":0.064168,"quoteQuantity":607.285952,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-31T21:21:03.07Z","fee":0.00006417,"feeCurrency":"BTC"},{"gid":2214,"id":332190320,"exchange":"binance","price":9464,"quantity":0.062683,"quoteQuantity":593.231912,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-31T21:21:03.823Z","fee":0.00006268,"feeCurrency":"BTC"},{"gid":2215,"id":332191649,"exchange":"binance","price":9457.6,"quantity":0.298943,"quoteQuantity":2827.2833168,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-31T21:22:31.048Z","fee":2.82728332,"feeCurrency":"USDT"},{"gid":2216,"id":332191650,"exchange":"binance","price":9457.57,"quantity":0.101057,"quoteQuantity":955.75365149,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-05-31T21:22:31.048Z","fee":0.95575365,"feeCurrency":"USDT"},{"gid":2217,"id":332281144,"exchange":"binance","price":9480,"quantity":0.39,"quoteQuantity":3697.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-31T23:56:11.736Z","fee":0.00039,"feeCurrency":"BTC"},{"gid":2218,"id":332281146,"exchange":"binance","price":9480,"quantity":0.01,"quoteQuantity":94.8,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-05-31T23:56:11.815Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":2219,"id":332295780,"exchange":"binance","price":9482,"quantity":0.001097,"quoteQuantity":10.401754,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-01T00:20:03.715Z","fee":0.01040175,"feeCurrency":"USDT"},{"gid":2220,"id":332295781,"exchange":"binance","price":9482,"quantity":0.002109,"quoteQuantity":19.997538,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-01T00:20:03.716Z","fee":0.01999754,"feeCurrency":"USDT"},{"gid":2221,"id":332295782,"exchange":"binance","price":9482,"quantity":0.396794,"quoteQuantity":3762.400708,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-01T00:20:03.722Z","fee":3.76240071,"feeCurrency":"USDT"},{"gid":2222,"id":332539048,"exchange":"binance","price":9535.01,"quantity":0.05,"quoteQuantity":476.7505,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-01T11:06:33.904Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":2223,"id":332544229,"exchange":"binance","price":9520.96,"quantity":0.02,"quoteQuantity":190.4192,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-01T11:23:20.984Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2224,"id":332549711,"exchange":"binance","price":9521.28,"quantity":0.009782,"quoteQuantity":93.13716096,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-01T11:39:33.177Z","fee":0.00000978,"feeCurrency":"BTC"},{"gid":2225,"id":332549712,"exchange":"binance","price":9521.28,"quantity":0.015436,"quoteQuantity":146.97047808,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-01T11:39:33.177Z","fee":0.00001544,"feeCurrency":"BTC"},{"gid":2226,"id":332549714,"exchange":"binance","price":9521.32,"quantity":0.014388,"quoteQuantity":136.99275216,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-01T11:39:33.571Z","fee":0.00001439,"feeCurrency":"BTC"},{"gid":2227,"id":332549715,"exchange":"binance","price":9521.32,"quantity":0.010394,"quoteQuantity":98.96460008,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-01T11:39:34.338Z","fee":0.00001039,"feeCurrency":"BTC"},{"gid":2228,"id":332620272,"exchange":"binance","price":9573.81,"quantity":0.05,"quoteQuantity":478.6905,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-01T14:35:22.631Z","fee":0.4786905,"feeCurrency":"USDT"},{"gid":2229,"id":332629390,"exchange":"binance","price":9553.47,"quantity":0.05,"quoteQuantity":477.6735,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-01T14:56:58.169Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":2230,"id":332851150,"exchange":"binance","price":9800,"quantity":0.014072,"quoteQuantity":137.9056,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-01T23:01:19.518Z","fee":0.1379056,"feeCurrency":"USDT"},{"gid":2231,"id":332851151,"exchange":"binance","price":9800,"quantity":0.185928,"quoteQuantity":1822.0944,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-01T23:01:19.534Z","fee":1.8220944,"feeCurrency":"USDT"},{"gid":2232,"id":332867331,"exchange":"binance","price":9890,"quantity":0.015525,"quoteQuantity":153.54225,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-01T23:04:54.458Z","fee":0.15354225,"feeCurrency":"USDT"},{"gid":2233,"id":332867333,"exchange":"binance","price":9890,"quantity":0.1,"quoteQuantity":989,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-01T23:04:54.482Z","fee":0.989,"feeCurrency":"USDT"},{"gid":2234,"id":332867335,"exchange":"binance","price":9890,"quantity":0.05,"quoteQuantity":494.5,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-01T23:04:54.496Z","fee":0.4945,"feeCurrency":"USDT"},{"gid":2235,"id":332867336,"exchange":"binance","price":9890,"quantity":0.018881,"quoteQuantity":186.73309,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-01T23:04:54.508Z","fee":0.18673309,"feeCurrency":"USDT"},{"gid":2236,"id":332867337,"exchange":"binance","price":9890,"quantity":0.215594,"quoteQuantity":2132.22466,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-01T23:04:54.511Z","fee":2.13222466,"feeCurrency":"USDT"},{"gid":2237,"id":333248014,"exchange":"binance","price":10094.99,"quantity":0.319824,"quoteQuantity":3228.62008176,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-02T02:03:27.253Z","fee":3.22862008,"feeCurrency":"USDT"},{"gid":2238,"id":333256507,"exchange":"binance","price":10114.05,"quantity":0.2,"quoteQuantity":2022.81,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-02T02:17:40.896Z","fee":0.0002,"feeCurrency":"BTC"},{"gid":2239,"id":333280825,"exchange":"binance","price":10099.06,"quantity":0.0999,"quoteQuantity":1008.896094,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-02T03:04:38.613Z","fee":1.00889609,"feeCurrency":"USDT"},{"gid":2240,"id":333286881,"exchange":"binance","price":10062.44,"quantity":0.01,"quoteQuantity":100.6244,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-02T03:14:35.07Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":2241,"id":333287517,"exchange":"binance","price":10057.59,"quantity":0.01,"quoteQuantity":100.5759,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-02T03:15:56.611Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":2242,"id":333378674,"exchange":"binance","price":10117,"quantity":0.1,"quoteQuantity":1011.7,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-02T06:24:12.717Z","fee":1.0117,"feeCurrency":"USDT"},{"gid":2243,"id":333393512,"exchange":"binance","price":10100.5,"quantity":0.009999,"quoteQuantity":100.9948995,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-02T06:56:17.186Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":2244,"id":333393513,"exchange":"binance","price":10100.5,"quantity":0.000001,"quoteQuantity":0.0101005,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-02T06:56:17.285Z","fee":0.0000042,"feeCurrency":"BNB"},{"gid":2245,"id":333412858,"exchange":"binance","price":10092,"quantity":0.01,"quoteQuantity":100.92,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-02T07:43:07.57Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":2246,"id":333478563,"exchange":"binance","price":10090,"quantity":0.01,"quoteQuantity":100.9,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-02T09:56:24.108Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":2247,"id":333570044,"exchange":"binance","price":10134.29,"quantity":0.04985,"quoteQuantity":505.1943565,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-02T13:05:53.508Z","fee":0.50519436,"feeCurrency":"USDT"},{"gid":2248,"id":333580445,"exchange":"binance","price":10160.65,"quantity":0.01,"quoteQuantity":101.6065,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-02T13:24:47.834Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":2249,"id":333580446,"exchange":"binance","price":10160.65,"quantity":0.018598,"quoteQuantity":188.9677687,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-02T13:24:48.078Z","fee":0.0000186,"feeCurrency":"BTC"},{"gid":2250,"id":333581725,"exchange":"binance","price":10165.01,"quantity":0.01,"quoteQuantity":101.6501,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-02T13:26:09.012Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":2251,"id":333586652,"exchange":"binance","price":10162,"quantity":0.01,"quoteQuantity":101.62,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-02T13:33:36.921Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":2252,"id":333586709,"exchange":"binance","price":10161,"quantity":0.01,"quoteQuantity":101.61,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-02T13:33:48.63Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":2253,"id":333588784,"exchange":"binance","price":10185,"quantity":0.001432,"quoteQuantity":14.58492,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-02T13:36:17.073Z","fee":0.00000143,"feeCurrency":"BTC"},{"gid":2254,"id":333588785,"exchange":"binance","price":10185,"quantity":0.098568,"quoteQuantity":1003.91508,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-02T13:36:17.087Z","fee":0.00009857,"feeCurrency":"BTC"},{"gid":2255,"id":333589865,"exchange":"binance","price":10181,"quantity":0.001964,"quoteQuantity":19.995484,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-02T13:37:24.471Z","fee":0.00000196,"feeCurrency":"BTC"},{"gid":2256,"id":333589866,"exchange":"binance","price":10181,"quantity":0.005201,"quoteQuantity":52.951381,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-02T13:37:24.569Z","fee":0.0000052,"feeCurrency":"BTC"},{"gid":2257,"id":333589867,"exchange":"binance","price":10181,"quantity":0.005759,"quoteQuantity":58.632379,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-02T13:37:24.573Z","fee":0.00000576,"feeCurrency":"BTC"},{"gid":2258,"id":333589869,"exchange":"binance","price":10181,"quantity":0.002043,"quoteQuantity":20.799783,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-02T13:37:24.736Z","fee":0.00000204,"feeCurrency":"BTC"},{"gid":2259,"id":333589872,"exchange":"binance","price":10181,"quantity":0.001768,"quoteQuantity":18.000008,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-02T13:37:25.472Z","fee":0.00000177,"feeCurrency":"BTC"},{"gid":2260,"id":333589873,"exchange":"binance","price":10181,"quantity":0.001768,"quoteQuantity":18.000008,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-02T13:37:25.499Z","fee":0.00000177,"feeCurrency":"BTC"},{"gid":2261,"id":333589874,"exchange":"binance","price":10181,"quantity":0.001768,"quoteQuantity":18.000008,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-02T13:37:25.513Z","fee":0.00000177,"feeCurrency":"BTC"},{"gid":2262,"id":333589875,"exchange":"binance","price":10181,"quantity":0.029725,"quoteQuantity":302.630225,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-02T13:37:25.919Z","fee":0.00002973,"feeCurrency":"BTC"},{"gid":2263,"id":333589876,"exchange":"binance","price":10181,"quantity":0.000004,"quoteQuantity":0.040724,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-02T13:37:26.074Z","fee":0.0000042,"feeCurrency":"BNB"},{"gid":2264,"id":333590899,"exchange":"binance","price":10176,"quantity":0.007379,"quoteQuantity":75.088704,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-02T13:38:48.551Z","fee":0.00000738,"feeCurrency":"BTC"},{"gid":2265,"id":333590903,"exchange":"binance","price":10176,"quantity":0.002043,"quoteQuantity":20.789568,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-02T13:38:49.423Z","fee":0.00000204,"feeCurrency":"BTC"},{"gid":2266,"id":333590906,"exchange":"binance","price":10176,"quantity":0.000578,"quoteQuantity":5.881728,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-02T13:38:49.896Z","fee":5.8e-7,"feeCurrency":"BTC"},{"gid":2267,"id":333596190,"exchange":"binance","price":10172,"quantity":0.01,"quoteQuantity":101.72,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-02T13:48:57.238Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":2268,"id":333606524,"exchange":"binance","price":10167,"quantity":0.003197,"quoteQuantity":32.503899,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-02T14:14:18.877Z","fee":0.0000032,"feeCurrency":"BTC"},{"gid":2269,"id":333606547,"exchange":"binance","price":10167,"quantity":0.002461,"quoteQuantity":25.020987,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-02T14:14:21.061Z","fee":0.00000246,"feeCurrency":"BTC"},{"gid":2270,"id":333606549,"exchange":"binance","price":10167,"quantity":0.004342,"quoteQuantity":44.145114,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-02T14:14:21.865Z","fee":0.00000434,"feeCurrency":"BTC"},{"gid":2271,"id":333617253,"exchange":"binance","price":10154.95,"quantity":0.01,"quoteQuantity":101.5495,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-02T14:37:21.475Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":2272,"id":333627495,"exchange":"binance","price":10032,"quantity":0.02,"quoteQuantity":200.64,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-02T14:46:21.988Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2273,"id":333857143,"exchange":"binance","price":9470.01,"quantity":0.1,"quoteQuantity":947.001,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-02T15:43:30.489Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":2274,"id":333861328,"exchange":"binance","price":9449.34,"quantity":0.05,"quoteQuantity":472.467,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-02T15:46:39.187Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":2275,"id":333912127,"exchange":"binance","price":9465.01,"quantity":0.1,"quoteQuantity":946.501,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-02T16:39:11.035Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":2276,"id":333936085,"exchange":"binance","price":9489.43,"quantity":0.05,"quoteQuantity":474.4715,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-02T17:09:51.883Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":2277,"id":333936194,"exchange":"binance","price":9490.82,"quantity":0.05,"quoteQuantity":474.541,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-02T17:10:13.825Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":2278,"id":334152228,"exchange":"binance","price":9499.04,"quantity":0.1,"quoteQuantity":949.904,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-03T01:49:25.631Z","fee":0.949904,"feeCurrency":"USDT"},{"gid":2279,"id":334167026,"exchange":"binance","price":9486.51,"quantity":0.01,"quoteQuantity":94.8651,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-03T02:30:48.759Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":2280,"id":334181073,"exchange":"binance","price":9491,"quantity":0.009987,"quoteQuantity":94.786617,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-03T03:13:01.444Z","fee":0.00000999,"feeCurrency":"BTC"},{"gid":2281,"id":334181077,"exchange":"binance","price":9491,"quantity":0.000013,"quoteQuantity":0.123383,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-03T03:13:02.221Z","fee":0.00000414,"feeCurrency":"BNB"},{"gid":2282,"id":334181507,"exchange":"binance","price":9482,"quantity":0.01,"quoteQuantity":94.82,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-03T03:14:31.872Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":2283,"id":334213928,"exchange":"binance","price":9496.09,"quantity":0.002628,"quoteQuantity":24.95572452,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-03T05:13:45.729Z","fee":0.00000263,"feeCurrency":"BTC"},{"gid":2284,"id":334213930,"exchange":"binance","price":9496.09,"quantity":0.002765,"quoteQuantity":26.25668885,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-03T05:13:45.813Z","fee":0.00000277,"feeCurrency":"BTC"},{"gid":2285,"id":334213948,"exchange":"binance","price":9496.09,"quantity":0.014598,"quoteQuantity":138.62392182,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-03T05:13:51.145Z","fee":0.0000146,"feeCurrency":"BTC"},{"gid":2286,"id":334213958,"exchange":"binance","price":9496.09,"quantity":0.000009,"quoteQuantity":0.08546481,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-03T05:13:56.852Z","fee":0.00000414,"feeCurrency":"BNB"},{"gid":2287,"id":334215945,"exchange":"binance","price":9472,"quantity":0.01,"quoteQuantity":94.72,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-03T05:17:32.905Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":2288,"id":334220033,"exchange":"binance","price":9460.56,"quantity":0.05,"quoteQuantity":473.028,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-03T05:22:57.189Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":2289,"id":334223938,"exchange":"binance","price":9423,"quantity":0.09428,"quoteQuantity":888.40044,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-03T05:27:10.321Z","fee":0.00009428,"feeCurrency":"BTC"},{"gid":2290,"id":334223939,"exchange":"binance","price":9423,"quantity":0.00572,"quoteQuantity":53.89956,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-03T05:27:10.321Z","fee":0.00000572,"feeCurrency":"BTC"},{"gid":2291,"id":334230826,"exchange":"binance","price":9458.6,"quantity":0.00621,"quoteQuantity":58.737906,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-03T05:30:36.344Z","fee":0.00000621,"feeCurrency":"BTC"},{"gid":2292,"id":334230827,"exchange":"binance","price":9458.67,"quantity":0.013727,"quoteQuantity":129.83916309,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-03T05:30:36.344Z","fee":0.00001373,"feeCurrency":"BTC"},{"gid":2293,"id":334230828,"exchange":"binance","price":9458.72,"quantity":0.000063,"quoteQuantity":0.59589936,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-03T05:30:36.344Z","fee":0.00002491,"feeCurrency":"BNB"},{"gid":2294,"id":334261661,"exchange":"binance","price":9517.22,"quantity":0.05,"quoteQuantity":475.861,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-03T06:53:30.856Z","fee":0.475861,"feeCurrency":"USDT"},{"gid":2295,"id":334261701,"exchange":"binance","price":9516.46,"quantity":0.05,"quoteQuantity":475.823,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-03T06:53:44.173Z","fee":0.475823,"feeCurrency":"USDT"},{"gid":2296,"id":334263071,"exchange":"binance","price":9519.55,"quantity":0.04,"quoteQuantity":380.782,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-03T06:58:23.977Z","fee":0.380782,"feeCurrency":"USDT"},{"gid":2297,"id":334383234,"exchange":"binance","price":9619.86,"quantity":0.1,"quoteQuantity":961.986,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-03T11:57:40.339Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":2298,"id":334412338,"exchange":"binance","price":9580.08,"quantity":0.1,"quoteQuantity":958.008,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-03T12:46:20.727Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":2299,"id":334696366,"exchange":"binance","price":9635.61,"quantity":0.05,"quoteQuantity":481.7805,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-04T00:50:37.456Z","fee":0.4817805,"feeCurrency":"USDT"},{"gid":2300,"id":334717659,"exchange":"binance","price":9645.99,"quantity":0.05,"quoteQuantity":482.2995,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-04T01:49:51.169Z","fee":0.4822995,"feeCurrency":"USDT"},{"gid":2301,"id":334728733,"exchange":"binance","price":9660.82,"quantity":0.02,"quoteQuantity":193.2164,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-04T02:18:42.403Z","fee":0.1932164,"feeCurrency":"USDT"},{"gid":2302,"id":334871191,"exchange":"binance","price":9580,"quantity":0.05,"quoteQuantity":479,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-04T09:19:55.055Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":2303,"id":334872113,"exchange":"binance","price":9562,"quantity":0.05,"quoteQuantity":478.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-04T09:19:59.628Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":2304,"id":334929573,"exchange":"binance","price":9550,"quantity":0.05,"quoteQuantity":477.5,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-04T10:56:37.312Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":2305,"id":334929600,"exchange":"binance","price":9549.77,"quantity":0.05,"quoteQuantity":477.4885,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-04T10:56:45.509Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":2306,"id":334940178,"exchange":"binance","price":9525.68,"quantity":0.02,"quoteQuantity":190.5136,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-04T11:25:16.975Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2307,"id":335039587,"exchange":"binance","price":9737.69,"quantity":0.05,"quoteQuantity":486.8845,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-04T13:21:08.573Z","fee":0.4868845,"feeCurrency":"USDT"},{"gid":2308,"id":335040338,"exchange":"binance","price":9740.77,"quantity":0.02,"quoteQuantity":194.8154,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-04T13:21:27.016Z","fee":0.1948154,"feeCurrency":"USDT"},{"gid":2309,"id":335041101,"exchange":"binance","price":9740,"quantity":0.02,"quoteQuantity":194.8,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-04T13:21:57.818Z","fee":0.1948,"feeCurrency":"USDT"},{"gid":2310,"id":335047719,"exchange":"binance","price":9722.98,"quantity":0.021935,"quoteQuantity":213.2735663,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-04T13:27:54.452Z","fee":0.21327357,"feeCurrency":"USDT"},{"gid":2311,"id":335047720,"exchange":"binance","price":9722.97,"quantity":0.028065,"quoteQuantity":272.87515305,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-04T13:27:54.452Z","fee":0.27287515,"feeCurrency":"USDT"},{"gid":2312,"id":335077024,"exchange":"binance","price":9770,"quantity":0.027,"quoteQuantity":263.79,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-04T14:02:46.289Z","fee":0.26379,"feeCurrency":"USDT"},{"gid":2313,"id":335078083,"exchange":"binance","price":9757.79,"quantity":0.006733,"quoteQuantity":65.69920007,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-04T14:04:18.058Z","fee":0.0656992,"feeCurrency":"USDT"},{"gid":2314,"id":335078084,"exchange":"binance","price":9757.39,"quantity":0.013267,"quoteQuantity":129.45129313,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-04T14:04:18.058Z","fee":0.12945129,"feeCurrency":"USDT"},{"gid":2315,"id":335369849,"exchange":"binance","price":9810.97,"quantity":0.02,"quoteQuantity":196.2194,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-05T01:29:39.746Z","fee":0.1962194,"feeCurrency":"USDT"},{"gid":2316,"id":335505611,"exchange":"binance","price":9704.29,"quantity":0.05,"quoteQuantity":485.2145,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-05T08:18:16.506Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":2317,"id":335526481,"exchange":"binance","price":9816.76,"quantity":0.01,"quoteQuantity":98.1676,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-05T08:36:33.323Z","fee":0.0981676,"feeCurrency":"USDT"},{"gid":2318,"id":335580559,"exchange":"binance","price":9734.8,"quantity":0.000012,"quoteQuantity":0.1168176,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-05T10:22:03.006Z","fee":0.00000414,"feeCurrency":"BNB"},{"gid":2319,"id":335580560,"exchange":"binance","price":9735.35,"quantity":0.049988,"quoteQuantity":486.6506758,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-05T10:22:03.006Z","fee":0.00004999,"feeCurrency":"BTC"},{"gid":2320,"id":335586406,"exchange":"binance","price":9737.55,"quantity":0.02,"quoteQuantity":194.751,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-05T10:25:56.826Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2321,"id":335627467,"exchange":"binance","price":9687.1,"quantity":0.05,"quoteQuantity":484.355,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-05T11:43:38.907Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":2322,"id":335643627,"exchange":"binance","price":9677.39,"quantity":0.023788,"quoteQuantity":230.20575332,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-05T11:58:08.285Z","fee":0.00002379,"feeCurrency":"BTC"},{"gid":2323,"id":335643628,"exchange":"binance","price":9677.4,"quantity":0.008556,"quoteQuantity":82.7998344,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-05T11:58:08.285Z","fee":0.00000856,"feeCurrency":"BTC"},{"gid":2324,"id":335643629,"exchange":"binance","price":9677.4,"quantity":0.01514,"quoteQuantity":146.515836,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-05T11:58:08.285Z","fee":0.00001514,"feeCurrency":"BTC"},{"gid":2325,"id":335643630,"exchange":"binance","price":9677.62,"quantity":0.002516,"quoteQuantity":24.34889192,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-05T11:58:08.285Z","fee":0.00000252,"feeCurrency":"BTC"},{"gid":2326,"id":335929912,"exchange":"binance","price":9589.47,"quantity":0.02,"quoteQuantity":191.7894,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-06T00:25:13.573Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2327,"id":335938788,"exchange":"binance","price":9569.66,"quantity":0.02,"quoteQuantity":191.3932,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-06T00:40:35.717Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2328,"id":336175051,"exchange":"binance","price":9660.9,"quantity":0.01,"quoteQuantity":96.609,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-06T13:46:31.003Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":2329,"id":336205639,"exchange":"binance","price":9658.63,"quantity":0.003439,"quoteQuantity":33.21602857,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-06T15:38:38.395Z","fee":0.03321603,"feeCurrency":"USDT"},{"gid":2330,"id":336205640,"exchange":"binance","price":9658.38,"quantity":0.036561,"quoteQuantity":353.12003118,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-06T15:38:38.465Z","fee":0.35312003,"feeCurrency":"USDT"},{"gid":2331,"id":336229927,"exchange":"binance","price":9601.19,"quantity":0.001257,"quoteQuantity":12.06869583,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-06T16:52:14.668Z","fee":0.00000126,"feeCurrency":"BTC"},{"gid":2332,"id":336229928,"exchange":"binance","price":9601.21,"quantity":0.098743,"quoteQuantity":948.05227903,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-06T16:52:14.668Z","fee":0.00009874,"feeCurrency":"BTC"},{"gid":2333,"id":336253573,"exchange":"binance","price":9620,"quantity":0.00118,"quoteQuantity":11.3516,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-06T18:15:42.12Z","fee":0.0113516,"feeCurrency":"USDT"},{"gid":2334,"id":336267393,"exchange":"binance","price":9617.54,"quantity":0.05,"quoteQuantity":480.877,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-06T19:19:10.813Z","fee":0.480877,"feeCurrency":"USDT"},{"gid":2335,"id":336585310,"exchange":"binance","price":9504.47,"quantity":0.05,"quoteQuantity":475.2235,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-07T12:26:19.62Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":2336,"id":336647151,"exchange":"binance","price":9480,"quantity":0.02,"quoteQuantity":189.6,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-07T13:56:17.726Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2337,"id":336992422,"exchange":"binance","price":9740.35,"quantity":0.007186,"quoteQuantity":69.9941551,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-08T00:20:47.518Z","fee":0.06999416,"feeCurrency":"USDT"},{"gid":2338,"id":336992423,"exchange":"binance","price":9740,"quantity":0.0025,"quoteQuantity":24.35,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-08T00:20:47.604Z","fee":0.02435,"feeCurrency":"USDT"},{"gid":2339,"id":336992424,"exchange":"binance","price":9740,"quantity":0.004103,"quoteQuantity":39.96322,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-08T00:20:47.717Z","fee":0.03996322,"feeCurrency":"USDT"},{"gid":2340,"id":336992425,"exchange":"binance","price":9740,"quantity":0.086211,"quoteQuantity":839.69514,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-08T00:20:47.825Z","fee":0.83969514,"feeCurrency":"USDT"},{"gid":2341,"id":336998842,"exchange":"binance","price":9751.64,"quantity":0.05,"quoteQuantity":487.582,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-08T00:38:33.698Z","fee":0.487582,"feeCurrency":"USDT"},{"gid":2342,"id":336999187,"exchange":"binance","price":9757.06,"quantity":0.05,"quoteQuantity":487.853,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-08T00:40:10.044Z","fee":0.487853,"feeCurrency":"USDT"},{"gid":2343,"id":336999634,"exchange":"binance","price":9760,"quantity":0.03,"quoteQuantity":292.8,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-08T00:40:59.539Z","fee":0.2928,"feeCurrency":"USDT"},{"gid":2344,"id":337024740,"exchange":"binance","price":9744,"quantity":0.002,"quoteQuantity":19.488,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-08T02:10:24.628Z","fee":0.019488,"feeCurrency":"USDT"},{"gid":2345,"id":337055784,"exchange":"binance","price":9750.13,"quantity":0.008859,"quoteQuantity":86.37640167,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-08T04:05:09.42Z","fee":0.0863764,"feeCurrency":"USDT"},{"gid":2346,"id":337055785,"exchange":"binance","price":9750.13,"quantity":0.00516,"quoteQuantity":50.3106708,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-08T04:05:10.301Z","fee":0.05031067,"feeCurrency":"USDT"},{"gid":2347,"id":337055791,"exchange":"binance","price":9750.13,"quantity":0.009301,"quoteQuantity":90.68595913,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-08T04:05:13.356Z","fee":0.09068596,"feeCurrency":"USDT"},{"gid":2348,"id":337055795,"exchange":"binance","price":9750.13,"quantity":0.02668,"quoteQuantity":260.1334684,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-08T04:05:13.791Z","fee":0.26013347,"feeCurrency":"USDT"},{"gid":2349,"id":337084745,"exchange":"binance","price":9735.99,"quantity":0.001702,"quoteQuantity":16.57065498,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-08T06:07:58.5Z","fee":0.0000017,"feeCurrency":"BTC"},{"gid":2350,"id":337084746,"exchange":"binance","price":9736.6,"quantity":0.006925,"quoteQuantity":67.425955,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-08T06:07:58.5Z","fee":0.00000693,"feeCurrency":"BTC"},{"gid":2351,"id":337084747,"exchange":"binance","price":9737,"quantity":0.001373,"quoteQuantity":13.368901,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-08T06:07:58.5Z","fee":0.00000137,"feeCurrency":"BTC"},{"gid":2352,"id":337106847,"exchange":"binance","price":9723,"quantity":0.009965,"quoteQuantity":96.889695,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-08T07:34:23.567Z","fee":0.00000997,"feeCurrency":"BTC"},{"gid":2353,"id":337106867,"exchange":"binance","price":9723,"quantity":0.000035,"quoteQuantity":0.340305,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-08T07:34:25.425Z","fee":0.00001675,"feeCurrency":"BNB"},{"gid":2354,"id":337117870,"exchange":"binance","price":9723.45,"quantity":0.000054,"quoteQuantity":0.5250663,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-08T08:15:08.836Z","fee":0.00002091,"feeCurrency":"BNB"},{"gid":2355,"id":337117871,"exchange":"binance","price":9723.74,"quantity":0.009946,"quoteQuantity":96.71231804,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-08T08:15:08.836Z","fee":0.00000995,"feeCurrency":"BTC"},{"gid":2356,"id":337136203,"exchange":"binance","price":9686.08,"quantity":0.01,"quoteQuantity":96.8608,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-08T08:40:29.262Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":2357,"id":337136404,"exchange":"binance","price":9686.01,"quantity":0.05,"quoteQuantity":484.3005,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-08T08:41:07.982Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":2358,"id":337346442,"exchange":"binance","price":9728.79,"quantity":0.05,"quoteQuantity":486.4395,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-08T17:11:49.174Z","fee":0.4864395,"feeCurrency":"USDT"},{"gid":2359,"id":337347226,"exchange":"binance","price":9721.79,"quantity":0.009755,"quoteQuantity":94.83606145,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-08T17:13:42.921Z","fee":0.09483606,"feeCurrency":"USDT"},{"gid":2360,"id":337347227,"exchange":"binance","price":9721.78,"quantity":0.002056,"quoteQuantity":19.98797968,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-08T17:13:42.921Z","fee":0.01998798,"feeCurrency":"USDT"},{"gid":2361,"id":337347228,"exchange":"binance","price":9721.78,"quantity":0.038189,"quoteQuantity":371.26505642,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-08T17:13:42.921Z","fee":0.37126506,"feeCurrency":"USDT"},{"gid":2362,"id":337446335,"exchange":"binance","price":9728.09,"quantity":0.004922,"quoteQuantity":47.88165898,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-08T23:29:46.817Z","fee":0.04788166,"feeCurrency":"USDT"},{"gid":2363,"id":337446336,"exchange":"binance","price":9728.09,"quantity":0.044817,"quoteQuantity":435.98380953,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-08T23:29:46.821Z","fee":0.43598381,"feeCurrency":"USDT"},{"gid":2364,"id":337446337,"exchange":"binance","price":9728.09,"quantity":0.034521,"quoteQuantity":335.82339489,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-08T23:29:46.923Z","fee":0.33582339,"feeCurrency":"USDT"},{"gid":2365,"id":337446338,"exchange":"binance","price":9728.09,"quantity":0.01574,"quoteQuantity":153.1201366,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-08T23:29:47.023Z","fee":0.15312014,"feeCurrency":"USDT"},{"gid":2366,"id":337453510,"exchange":"binance","price":9780,"quantity":0.046619,"quoteQuantity":455.93382,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-08T23:38:26.028Z","fee":0.45593382,"feeCurrency":"USDT"},{"gid":2367,"id":337477261,"exchange":"binance","price":9835,"quantity":0.060059,"quoteQuantity":590.680265,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-09T00:02:12.194Z","fee":0.59068027,"feeCurrency":"USDT"},{"gid":2368,"id":337477262,"exchange":"binance","price":9835,"quantity":0.030464,"quoteQuantity":299.61344,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-09T00:02:12.199Z","fee":0.29961344,"feeCurrency":"USDT"},{"gid":2369,"id":337477297,"exchange":"binance","price":9835,"quantity":0.009477,"quoteQuantity":93.206295,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-09T00:02:13.669Z","fee":0.0932063,"feeCurrency":"USDT"},{"gid":2370,"id":337527923,"exchange":"binance","price":9654.5,"quantity":0.099999,"quoteQuantity":965.4403455,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-09T00:27:20.865Z","fee":0.96544035,"feeCurrency":"USDT"},{"gid":2371,"id":337527931,"exchange":"binance","price":9654.5,"quantity":0.000001,"quoteQuantity":0.0096545,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-09T00:27:21.934Z","fee":4.2e-7,"feeCurrency":"BNB"},{"gid":2372,"id":337579114,"exchange":"binance","price":9690,"quantity":0.05,"quoteQuantity":484.5,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-09T02:53:37.626Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":2373,"id":337617008,"exchange":"binance","price":9681.43,"quantity":0.01,"quoteQuantity":96.8143,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-09T04:51:33.996Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":2374,"id":337685154,"exchange":"binance","price":9672,"quantity":0.01,"quoteQuantity":96.72,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-09T09:13:21.131Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":2375,"id":337687912,"exchange":"binance","price":9662,"quantity":0.01,"quoteQuantity":96.62,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-09T09:22:04.977Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":2376,"id":337694995,"exchange":"binance","price":9658.97,"quantity":0.01,"quoteQuantity":96.5897,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-09T09:37:37.413Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":2377,"id":337744306,"exchange":"binance","price":9711.28,"quantity":0.05,"quoteQuantity":485.564,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-09T11:53:56.689Z","fee":0.485564,"feeCurrency":"USDT"},{"gid":2378,"id":337744774,"exchange":"binance","price":9707.82,"quantity":0.002059,"quoteQuantity":19.98840138,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-09T11:54:32.145Z","fee":0.0199884,"feeCurrency":"USDT"},{"gid":2379,"id":337744775,"exchange":"binance","price":9707.78,"quantity":0.007756,"quoteQuantity":75.29354168,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-09T11:54:32.145Z","fee":0.07529354,"feeCurrency":"USDT"},{"gid":2380,"id":337744776,"exchange":"binance","price":9705.92,"quantity":0.020185,"quoteQuantity":195.9139952,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-09T11:54:32.145Z","fee":0.195914,"feeCurrency":"USDT"},{"gid":2381,"id":337752308,"exchange":"binance","price":9731.54,"quantity":0.0099,"quoteQuantity":96.342246,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-09T12:02:18.858Z","fee":0.09634225,"feeCurrency":"USDT"},{"gid":2382,"id":337798125,"exchange":"binance","price":9693.97,"quantity":0.01,"quoteQuantity":96.9397,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-09T13:48:25.497Z","fee":0.00420238,"feeCurrency":"BNB"},{"gid":2383,"id":337798167,"exchange":"binance","price":9693.62,"quantity":0.01,"quoteQuantity":96.9362,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-09T13:48:40.624Z","fee":0.00420238,"feeCurrency":"BNB"},{"gid":2384,"id":337831342,"exchange":"binance","price":9713.29,"quantity":0.01,"quoteQuantity":97.1329,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-09T15:23:18.652Z","fee":0.0042045,"feeCurrency":"BNB"},{"gid":2385,"id":337908737,"exchange":"binance","price":9735,"quantity":0.03,"quoteQuantity":292.05,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-09T19:56:42.81Z","fee":0.01266391,"feeCurrency":"BNB"},{"gid":2386,"id":338042495,"exchange":"binance","price":9784.14,"quantity":0.1,"quoteQuantity":978.414,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-10T01:07:58.092Z","fee":0.04210317,"feeCurrency":"BNB"},{"gid":2387,"id":338078068,"exchange":"binance","price":9754.64,"quantity":0.021828,"quoteQuantity":212.92428192,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-10T03:10:25.654Z","fee":0.00918201,"feeCurrency":"BNB"},{"gid":2388,"id":338078069,"exchange":"binance","price":9754.63,"quantity":0.015638,"quoteQuantity":152.54290394,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-10T03:10:25.654Z","fee":0.00657816,"feeCurrency":"BNB"},{"gid":2389,"id":338078070,"exchange":"binance","price":9754.36,"quantity":0.0068,"quoteQuantity":66.329648,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-10T03:10:25.654Z","fee":0.00286035,"feeCurrency":"BNB"},{"gid":2390,"id":338078071,"exchange":"binance","price":9753.74,"quantity":0.055734,"quoteQuantity":543.61494516,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-10T03:10:25.654Z","fee":0.02344252,"feeCurrency":"BNB"},{"gid":2391,"id":338109403,"exchange":"binance","price":9763.54,"quantity":0.1,"quoteQuantity":976.354,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-10T05:09:41.306Z","fee":0.04217605,"feeCurrency":"BNB"},{"gid":2392,"id":338177894,"exchange":"binance","price":9743,"quantity":0.01,"quoteQuantity":97.43,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-10T08:30:39.698Z","fee":0.00422439,"feeCurrency":"BNB"},{"gid":2393,"id":338211414,"exchange":"binance","price":9721,"quantity":0.007373,"quoteQuantity":71.672933,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-10T10:02:49.631Z","fee":0.00310777,"feeCurrency":"BNB"},{"gid":2394,"id":338211491,"exchange":"binance","price":9721,"quantity":0.002627,"quoteQuantity":25.537067,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-10T10:02:57.653Z","fee":0.00110901,"feeCurrency":"BNB"},{"gid":2395,"id":338242772,"exchange":"binance","price":9727,"quantity":0.006442,"quoteQuantity":62.661334,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-10T11:59:35.622Z","fee":0.00271959,"feeCurrency":"BNB"},{"gid":2396,"id":338242778,"exchange":"binance","price":9727,"quantity":0.003553,"quoteQuantity":34.560031,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-10T11:59:36.906Z","fee":0.00149915,"feeCurrency":"BNB"},{"gid":2397,"id":338242783,"exchange":"binance","price":9727,"quantity":0.000005,"quoteQuantity":0.048635,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-10T11:59:38.114Z","fee":0.00000422,"feeCurrency":"BNB"},{"gid":2398,"id":338247792,"exchange":"binance","price":9751.14,"quantity":0.041365,"quoteQuantity":403.3559061,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-10T12:09:00.998Z","fee":0.01750473,"feeCurrency":"BNB"},{"gid":2399,"id":338247793,"exchange":"binance","price":9751.13,"quantity":0.058635,"quoteQuantity":571.75750755,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-10T12:09:00.998Z","fee":0.02481297,"feeCurrency":"BNB"},{"gid":2400,"id":338341865,"exchange":"binance","price":9769.23,"quantity":0.015,"quoteQuantity":146.53845,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-10T16:44:33.654Z","fee":0.00636348,"feeCurrency":"BNB"},{"gid":2401,"id":338341866,"exchange":"binance","price":9769.39,"quantity":0.015,"quoteQuantity":146.54085,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-10T16:44:33.654Z","fee":0.00636348,"feeCurrency":"BNB"},{"gid":2402,"id":338341868,"exchange":"binance","price":9769.39,"quantity":0.010711,"quoteQuantity":104.63993629,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-10T16:44:34.998Z","fee":0.00454352,"feeCurrency":"BNB"},{"gid":2403,"id":338341869,"exchange":"binance","price":9769.39,"quantity":0.009289,"quoteQuantity":90.74786371,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-10T16:44:35.001Z","fee":0.00394111,"feeCurrency":"BNB"},{"gid":2404,"id":338345968,"exchange":"binance","price":9765.71,"quantity":0.01,"quoteQuantity":97.6571,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-10T16:59:16.774Z","fee":0.00423752,"feeCurrency":"BNB"},{"gid":2405,"id":338346857,"exchange":"binance","price":9761.96,"quantity":0.041381,"quoteQuantity":403.95966676,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-10T17:02:38.118Z","fee":0.01753638,"feeCurrency":"BNB"},{"gid":2406,"id":338346858,"exchange":"binance","price":9761.95,"quantity":0.008619,"quoteQuantity":84.13824705,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-10T17:02:38.118Z","fee":0.00365253,"feeCurrency":"BNB"},{"gid":2407,"id":338401614,"exchange":"binance","price":9943.94,"quantity":0.04001,"quoteQuantity":397.8570394,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-10T18:37:58.798Z","fee":0.01706217,"feeCurrency":"BNB"},{"gid":2408,"id":338446882,"exchange":"binance","price":9775.48,"quantity":0.05,"quoteQuantity":488.774,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-10T18:54:31.658Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":2409,"id":338449565,"exchange":"binance","price":9807.23,"quantity":0.05,"quoteQuantity":490.3615,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-10T18:55:15.718Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":2410,"id":338466558,"exchange":"binance","price":9838.77,"quantity":0.008506,"quoteQuantity":83.68857762,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-10T19:06:14.401Z","fee":0.00360357,"feeCurrency":"BNB"},{"gid":2411,"id":338466559,"exchange":"binance","price":9838.76,"quantity":0.083799,"quoteQuantity":824.47824924,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-10T19:06:14.401Z","fee":0.82447825,"feeCurrency":"USDT"},{"gid":2412,"id":338466560,"exchange":"binance","price":9838.19,"quantity":0.007595,"quoteQuantity":74.72105305,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-10T19:06:14.401Z","fee":0.00321744,"feeCurrency":"BNB"},{"gid":2413,"id":338498894,"exchange":"binance","price":9843.23,"quantity":0.018211,"quoteQuantity":179.25506153,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-10T19:57:00.825Z","fee":0.00001821,"feeCurrency":"BTC"},{"gid":2414,"id":338607535,"exchange":"binance","price":9887.47,"quantity":0.000003,"quoteQuantity":0.02966241,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-11T01:23:25.29Z","fee":0.00000126,"feeCurrency":"BNB"},{"gid":2415,"id":338607536,"exchange":"binance","price":9887.08,"quantity":0.01819,"quoteQuantity":179.8459852,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-11T01:23:25.29Z","fee":0.17984599,"feeCurrency":"USDT"},{"gid":2416,"id":338634970,"exchange":"binance","price":9910.98,"quantity":0.016949,"quoteQuantity":167.98120002,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-11T02:05:02.039Z","fee":0.00001695,"feeCurrency":"BTC"},{"gid":2417,"id":338637950,"exchange":"binance","price":9923.58,"quantity":0.016932,"quoteQuantity":168.02605656,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-11T02:13:19.784Z","fee":0.16802606,"feeCurrency":"USDT"},{"gid":2418,"id":338729692,"exchange":"binance","price":9786.81,"quantity":0.018811,"quoteQuantity":184.09968291,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-11T06:18:00.758Z","fee":0.00001881,"feeCurrency":"BTC"},{"gid":2419,"id":338735298,"exchange":"binance","price":9766.28,"quantity":0.017022,"quoteQuantity":166.24161816,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-11T06:19:00.482Z","fee":0.00001702,"feeCurrency":"BTC"},{"gid":2420,"id":338738297,"exchange":"binance","price":9789.47,"quantity":0.020549,"quoteQuantity":201.16381903,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-11T06:20:00.707Z","fee":0.00002055,"feeCurrency":"BTC"},{"gid":2421,"id":338763840,"exchange":"binance","price":9823.2,"quantity":0.056326,"quoteQuantity":553.3015632,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-11T07:11:16.911Z","fee":0.55330156,"feeCurrency":"USDT"},{"gid":2422,"id":338824826,"exchange":"binance","price":9786.72,"quantity":0.02,"quoteQuantity":195.7344,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-11T09:54:35.697Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2423,"id":338826971,"exchange":"binance","price":9794.81,"quantity":0.01998,"quoteQuantity":195.7003038,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-11T10:01:23.136Z","fee":0.1957003,"feeCurrency":"USDT"},{"gid":2424,"id":338917111,"exchange":"binance","price":9713.2,"quantity":0.018033,"quoteQuantity":175.1581356,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-11T12:59:01.438Z","fee":0.00001803,"feeCurrency":"BTC"},{"gid":2425,"id":338926378,"exchange":"binance","price":9730.43,"quantity":0.00103,"quoteQuantity":10.0223429,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-11T13:01:27.036Z","fee":0.00043557,"feeCurrency":"BNB"},{"gid":2426,"id":338926379,"exchange":"binance","price":9729.03,"quantity":0.016984,"quoteQuantity":165.23784552,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-11T13:01:27.036Z","fee":0.16523785,"feeCurrency":"USDT"},{"gid":2427,"id":339004923,"exchange":"binance","price":9635.88,"quantity":0.00468,"quoteQuantity":45.0959184,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-11T13:59:17.92Z","fee":0.00000468,"feeCurrency":"BTC"},{"gid":2428,"id":339004924,"exchange":"binance","price":9635.99,"quantity":0.00532,"quoteQuantity":51.2634668,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-11T13:59:17.92Z","fee":0.00000532,"feeCurrency":"BTC"},{"gid":2429,"id":339005119,"exchange":"binance","price":9638.6,"quantity":0.006081,"quoteQuantity":58.6123266,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-11T13:59:42.625Z","fee":0.00000608,"feeCurrency":"BTC"},{"gid":2430,"id":339005120,"exchange":"binance","price":9638.67,"quantity":0.003919,"quoteQuantity":37.77394773,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-11T13:59:42.625Z","fee":0.00000392,"feeCurrency":"BTC"},{"gid":2431,"id":339018191,"exchange":"binance","price":9659.38,"quantity":0.01048,"quoteQuantity":101.2303024,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-11T14:13:05.718Z","fee":0.1012303,"feeCurrency":"USDT"},{"gid":2432,"id":339018192,"exchange":"binance","price":9659.38,"quantity":0.001517,"quoteQuantity":14.65327946,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-11T14:13:05.727Z","fee":0.01465328,"feeCurrency":"USDT"},{"gid":2433,"id":339018193,"exchange":"binance","price":9659.38,"quantity":0.003176,"quoteQuantity":30.67819088,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-11T14:13:06.242Z","fee":0.03067819,"feeCurrency":"USDT"},{"gid":2434,"id":339018194,"exchange":"binance","price":9659.38,"quantity":0.004807,"quoteQuantity":46.43263966,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-11T14:13:06.43Z","fee":0.04643264,"feeCurrency":"USDT"},{"gid":2435,"id":339034991,"exchange":"binance","price":9626.01,"quantity":0.002,"quoteQuantity":19.25202,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-11T14:41:08.874Z","fee":0.000002,"feeCurrency":"BTC"},{"gid":2436,"id":339047962,"exchange":"binance","price":9610.08,"quantity":0.01,"quoteQuantity":96.1008,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-11T15:00:53.158Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":2437,"id":339095391,"exchange":"binance","price":9560,"quantity":0.003552,"quoteQuantity":33.95712,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-11T15:35:40.793Z","fee":0.00000355,"feeCurrency":"BTC"},{"gid":2438,"id":339095392,"exchange":"binance","price":9560,"quantity":0.006448,"quoteQuantity":61.64288,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-11T15:35:41.04Z","fee":0.00000645,"feeCurrency":"BTC"},{"gid":2439,"id":339112519,"exchange":"binance","price":9505,"quantity":0.005,"quoteQuantity":47.525,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-11T16:00:16.117Z","fee":0.000005,"feeCurrency":"BTC"},{"gid":2440,"id":339118406,"exchange":"binance","price":9491.73,"quantity":0.017303,"quoteQuantity":164.23540419,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-11T16:01:05.493Z","fee":0.0000173,"feeCurrency":"BTC"},{"gid":2441,"id":339134310,"exchange":"binance","price":9546.23,"quantity":0.01,"quoteQuantity":95.4623,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-11T16:16:58.465Z","fee":0.0954623,"feeCurrency":"USDT"},{"gid":2442,"id":339135061,"exchange":"binance","price":9538.14,"quantity":0.005,"quoteQuantity":47.6907,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-11T16:17:54.839Z","fee":0.0476907,"feeCurrency":"USDT"},{"gid":2443,"id":339166859,"exchange":"binance","price":9402,"quantity":0.01,"quoteQuantity":94.02,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-11T16:45:39.77Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":2444,"id":339181113,"exchange":"binance","price":9382,"quantity":0.1,"quoteQuantity":938.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-11T16:51:03.814Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":2445,"id":339192776,"exchange":"binance","price":9347.48,"quantity":0.018741,"quoteQuantity":175.18112268,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-11T16:53:06.008Z","fee":0.00001874,"feeCurrency":"BTC"},{"gid":2446,"id":339198559,"exchange":"binance","price":9344.04,"quantity":0.00267,"quoteQuantity":24.9485868,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-11T16:55:01.354Z","fee":0.00000267,"feeCurrency":"BTC"},{"gid":2447,"id":339198560,"exchange":"binance","price":9344.05,"quantity":0.016952,"quoteQuantity":158.4003356,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-11T16:55:01.354Z","fee":0.00001695,"feeCurrency":"BTC"},{"gid":2448,"id":339208893,"exchange":"binance","price":9282,"quantity":0.055649,"quoteQuantity":516.534018,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-11T16:58:37.862Z","fee":0.00005565,"feeCurrency":"BTC"},{"gid":2449,"id":339208899,"exchange":"binance","price":9282,"quantity":0.044351,"quoteQuantity":411.665982,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-11T16:58:37.943Z","fee":0.00004435,"feeCurrency":"BTC"},{"gid":2450,"id":339212885,"exchange":"binance","price":9270.23,"quantity":0.018961,"quoteQuantity":175.77283103,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-11T16:59:05.725Z","fee":0.00001896,"feeCurrency":"BTC"},{"gid":2451,"id":339226903,"exchange":"binance","price":9126.41,"quantity":0.027509,"quoteQuantity":251.05841269,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-11T17:00:02.664Z","fee":0.00002751,"feeCurrency":"BTC"},{"gid":2452,"id":339227412,"exchange":"binance","price":9119.99,"quantity":0.037297,"quoteQuantity":340.14826703,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-11T17:00:06.387Z","fee":0.0000373,"feeCurrency":"BTC"},{"gid":2453,"id":339227645,"exchange":"binance","price":9120.02,"quantity":0.024182,"quoteQuantity":220.54032364,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-11T17:00:07.393Z","fee":0.00002418,"feeCurrency":"BTC"},{"gid":2454,"id":339235237,"exchange":"binance","price":9191.3,"quantity":0.019187,"quoteQuantity":176.3534731,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-11T17:01:05.776Z","fee":0.17635347,"feeCurrency":"USDT"},{"gid":2455,"id":339239625,"exchange":"binance","price":9229.61,"quantity":0.018735,"quoteQuantity":172.91674335,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-11T17:02:05.632Z","fee":0.17291674,"feeCurrency":"USDT"},{"gid":2456,"id":339246474,"exchange":"binance","price":9266.72,"quantity":0.018829,"quoteQuantity":174.48307088,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-11T17:04:05.63Z","fee":0.17448307,"feeCurrency":"USDT"},{"gid":2457,"id":339249401,"exchange":"binance","price":9260,"quantity":0.023327,"quoteQuantity":216.00802,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-11T17:05:00.698Z","fee":0.21600802,"feeCurrency":"USDT"},{"gid":2458,"id":339334308,"exchange":"binance","price":9379.77,"quantity":0.029572,"quoteQuantity":277.37855844,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-11T18:00:01.706Z","fee":0.27737856,"feeCurrency":"USDT"},{"gid":2459,"id":339547370,"exchange":"binance","price":9280.7,"quantity":0.049246,"quoteQuantity":457.0373522,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-12T00:00:01.407Z","fee":0.00004925,"feeCurrency":"BTC"},{"gid":2460,"id":339599668,"exchange":"binance","price":9337.21,"quantity":0.009935,"quoteQuantity":92.76518135,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-12T01:39:16.435Z","fee":0.00000994,"feeCurrency":"BTC"},{"gid":2461,"id":339599669,"exchange":"binance","price":9337.22,"quantity":0.015,"quoteQuantity":140.0583,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-12T01:39:16.435Z","fee":0.000015,"feeCurrency":"BTC"},{"gid":2462,"id":339599670,"exchange":"binance","price":9337.22,"quantity":0.063586,"quoteQuantity":593.71647092,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-12T01:39:16.435Z","fee":0.00006359,"feeCurrency":"BTC"},{"gid":2463,"id":339599671,"exchange":"binance","price":9337.44,"quantity":0.011479,"quoteQuantity":107.18447376,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-12T01:39:16.435Z","fee":0.00001148,"feeCurrency":"BTC"},{"gid":2464,"id":339605090,"exchange":"binance","price":9338.64,"quantity":0.063576,"quoteQuantity":593.71337664,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-12T01:47:57.577Z","fee":0.00006358,"feeCurrency":"BTC"},{"gid":2465,"id":339605091,"exchange":"binance","price":9338.64,"quantity":0.036424,"quoteQuantity":340.15062336,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-12T01:47:57.577Z","fee":0.00003642,"feeCurrency":"BTC"},{"gid":2466,"id":339608379,"exchange":"binance","price":9339.71,"quantity":0.05816,"quoteQuantity":543.1975336,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-12T01:55:19.088Z","fee":0.54319753,"feeCurrency":"USDT"},{"gid":2467,"id":339608380,"exchange":"binance","price":9339.7,"quantity":0.038506,"quoteQuantity":359.6344882,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-12T01:55:19.088Z","fee":0.35963449,"feeCurrency":"USDT"},{"gid":2468,"id":339608381,"exchange":"binance","price":9339,"quantity":0.003334,"quoteQuantity":31.136226,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-12T01:55:19.088Z","fee":0.03113623,"feeCurrency":"USDT"},{"gid":2469,"id":339608501,"exchange":"binance","price":9335.73,"quantity":0.079024,"quoteQuantity":737.74672752,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-12T01:55:35.327Z","fee":0.73774673,"feeCurrency":"USDT"},{"gid":2470,"id":339608502,"exchange":"binance","price":9335.4,"quantity":0.01,"quoteQuantity":93.354,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-12T01:55:35.327Z","fee":0.093354,"feeCurrency":"USDT"},{"gid":2471,"id":339608503,"exchange":"binance","price":9335,"quantity":0.010976,"quoteQuantity":102.46096,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-12T01:55:35.327Z","fee":0.10246096,"feeCurrency":"USDT"},{"gid":2472,"id":339615401,"exchange":"binance","price":9337.37,"quantity":0.031711,"quoteQuantity":296.09734007,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-12T02:16:08.397Z","fee":0.00003171,"feeCurrency":"BTC"},{"gid":2473,"id":339615402,"exchange":"binance","price":9337.38,"quantity":0.023235,"quoteQuantity":216.9540243,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-12T02:16:08.397Z","fee":0.00002324,"feeCurrency":"BTC"},{"gid":2474,"id":339615403,"exchange":"binance","price":9337.43,"quantity":0.00841,"quoteQuantity":78.5277863,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-12T02:16:08.397Z","fee":0.00000841,"feeCurrency":"BTC"},{"gid":2475,"id":339615404,"exchange":"binance","price":9337.43,"quantity":0.013301,"quoteQuantity":124.19715643,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-12T02:16:08.397Z","fee":0.0000133,"feeCurrency":"BTC"},{"gid":2476,"id":339615405,"exchange":"binance","price":9337.65,"quantity":0.023343,"quoteQuantity":217.96876395,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-12T02:16:08.397Z","fee":0.00002334,"feeCurrency":"BTC"},{"gid":2477,"id":339616181,"exchange":"binance","price":9337,"quantity":0.02,"quoteQuantity":186.74,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-12T02:18:10.61Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2478,"id":339618794,"exchange":"binance","price":9350.01,"quantity":0.005046,"quoteQuantity":47.18015046,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-12T02:27:12.973Z","fee":0.00000505,"feeCurrency":"BTC"},{"gid":2479,"id":339618795,"exchange":"binance","price":9350.01,"quantity":0.005424,"quoteQuantity":50.71445424,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-12T02:27:12.973Z","fee":0.00000542,"feeCurrency":"BTC"},{"gid":2480,"id":339618796,"exchange":"binance","price":9350.01,"quantity":0.00953,"quoteQuantity":89.1055953,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-12T02:27:13.537Z","fee":0.00000953,"feeCurrency":"BTC"},{"gid":2481,"id":339619702,"exchange":"binance","price":9339.69,"quantity":0.02,"quoteQuantity":186.7938,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-12T02:28:15.795Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":2482,"id":339624700,"exchange":"binance","price":9344.08,"quantity":0.060019,"quoteQuantity":560.82233752,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-12T02:45:32.948Z","fee":0.00006002,"feeCurrency":"BTC"},{"gid":2483,"id":339624702,"exchange":"binance","price":9344.08,"quantity":0.039981,"quoteQuantity":373.58566248,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-12T02:45:35.143Z","fee":0.00003998,"feeCurrency":"BTC"},{"gid":2484,"id":339741594,"exchange":"binance","price":9442,"quantity":0.05,"quoteQuantity":472.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-12T07:45:09.701Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":2485,"id":339803636,"exchange":"binance","price":9452.93,"quantity":0.001554,"quoteQuantity":14.68985322,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-12T09:47:40.742Z","fee":0.00000155,"feeCurrency":"BTC"},{"gid":2486,"id":339803638,"exchange":"binance","price":9452.93,"quantity":0.018446,"quoteQuantity":174.36874678,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-12T09:47:40.782Z","fee":0.00001845,"feeCurrency":"BTC"},{"gid":2487,"id":339822559,"exchange":"binance","price":9467.05,"quantity":0.01,"quoteQuantity":94.6705,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-12T10:37:32.396Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":2488,"id":339838406,"exchange":"binance","price":9492.35,"quantity":0.1,"quoteQuantity":949.235,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-12T11:19:02.623Z","fee":0.0425629,"feeCurrency":"BNB"},{"gid":2489,"id":339879916,"exchange":"binance","price":9453.86,"quantity":0.004771,"quoteQuantity":45.10436606,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-12T12:39:37.574Z","fee":0.00203116,"feeCurrency":"BNB"},{"gid":2490,"id":339879917,"exchange":"binance","price":9453.86,"quantity":0.015229,"quoteQuantity":143.97283394,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-12T12:39:37.68Z","fee":0.00648526,"feeCurrency":"BNB"},{"gid":2491,"id":339880060,"exchange":"binance","price":9453.34,"quantity":0.02,"quoteQuantity":189.0668,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-12T12:39:57.547Z","fee":0.00851643,"feeCurrency":"BNB"},{"gid":2492,"id":339893548,"exchange":"binance","price":9471.42,"quantity":0.2,"quoteQuantity":1894.284,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-12T13:24:10.496Z","fee":0.08520842,"feeCurrency":"BNB"},{"gid":2493,"id":339928865,"exchange":"binance","price":9445,"quantity":0.1,"quoteQuantity":944.5,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-12T14:50:34.196Z","fee":0.04256526,"feeCurrency":"BNB"},{"gid":2494,"id":339947390,"exchange":"binance","price":9391.01,"quantity":0.057185,"quoteQuantity":537.02490685,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-12T15:20:05.764Z","fee":0.02442624,"feeCurrency":"BNB"},{"gid":2495,"id":339959615,"exchange":"binance","price":9429.85,"quantity":0.015607,"quoteQuantity":147.17166895,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-12T15:50:08.241Z","fee":0.0066669,"feeCurrency":"BNB"},{"gid":2496,"id":339959616,"exchange":"binance","price":9429.84,"quantity":0.004393,"quoteQuantity":41.42528712,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-12T15:50:08.241Z","fee":0.00187656,"feeCurrency":"BNB"},{"gid":2497,"id":339968417,"exchange":"binance","price":9446.68,"quantity":0.02,"quoteQuantity":188.9336,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-12T16:24:19.93Z","fee":0.00853881,"feeCurrency":"BNB"},{"gid":2498,"id":339981321,"exchange":"binance","price":9414.01,"quantity":0.01,"quoteQuantity":94.1401,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-12T17:16:09.023Z","fee":0.00427203,"feeCurrency":"BNB"},{"gid":2499,"id":339981398,"exchange":"binance","price":9414,"quantity":0.01,"quoteQuantity":94.14,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-12T17:16:28.999Z","fee":0.00427203,"feeCurrency":"BNB"},{"gid":2500,"id":339981482,"exchange":"binance","price":9412.5,"quantity":0.01,"quoteQuantity":94.125,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-12T17:17:20.036Z","fee":0.00427228,"feeCurrency":"BNB"},{"gid":2501,"id":340179474,"exchange":"binance","price":9394,"quantity":0.02,"quoteQuantity":187.88,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-13T04:37:26.117Z","fee":0.00852708,"feeCurrency":"BNB"},{"gid":2502,"id":340208087,"exchange":"binance","price":9428.54,"quantity":0.018357,"quoteQuantity":173.07970878,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-13T06:38:47.639Z","fee":0.00781614,"feeCurrency":"BNB"},{"gid":2503,"id":340208088,"exchange":"binance","price":9428.25,"quantity":0.001643,"quoteQuantity":15.49061475,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-13T06:38:47.639Z","fee":0.00069954,"feeCurrency":"BNB"},{"gid":2504,"id":340244946,"exchange":"binance","price":9449.56,"quantity":0.01,"quoteQuantity":94.4956,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-13T09:09:09.111Z","fee":0.00427062,"feeCurrency":"BNB"},{"gid":2505,"id":340246308,"exchange":"binance","price":9443.62,"quantity":0.01,"quoteQuantity":94.4362,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-13T09:13:36.233Z","fee":0.00427021,"feeCurrency":"BNB"},{"gid":2506,"id":340246678,"exchange":"binance","price":9445.01,"quantity":0.01,"quoteQuantity":94.4501,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-13T09:14:32.504Z","fee":0.00427074,"feeCurrency":"BNB"},{"gid":2507,"id":340246743,"exchange":"binance","price":9445.47,"quantity":0.01,"quoteQuantity":94.4547,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-13T09:14:41.27Z","fee":0.00427095,"feeCurrency":"BNB"},{"gid":2508,"id":340246797,"exchange":"binance","price":9448.96,"quantity":0.01,"quoteQuantity":94.4896,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-13T09:14:44.938Z","fee":0.00427253,"feeCurrency":"BNB"},{"gid":2509,"id":340272532,"exchange":"binance","price":9380,"quantity":0.04,"quoteQuantity":375.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-13T10:34:27.765Z","fee":0.01705126,"feeCurrency":"BNB"},{"gid":2510,"id":340346260,"exchange":"binance","price":9435.74,"quantity":0.002229,"quoteQuantity":21.03226446,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-13T14:46:30.442Z","fee":0.00094952,"feeCurrency":"BNB"},{"gid":2511,"id":340346261,"exchange":"binance","price":9435.81,"quantity":0.019679,"quoteQuantity":185.68730499,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-13T14:46:30.442Z","fee":0.00837969,"feeCurrency":"BNB"},{"gid":2512,"id":340346281,"exchange":"binance","price":9436,"quantity":0.005131,"quoteQuantity":48.416116,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-13T14:46:36.135Z","fee":0.00218433,"feeCurrency":"BNB"},{"gid":2513,"id":340346283,"exchange":"binance","price":9436,"quantity":0.005982,"quoteQuantity":56.446152,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-13T14:46:37.008Z","fee":0.00254626,"feeCurrency":"BNB"},{"gid":2514,"id":340346288,"exchange":"binance","price":9436,"quantity":0.055636,"quoteQuantity":524.981296,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-13T14:46:37.644Z","fee":0.02369137,"feeCurrency":"BNB"},{"gid":2515,"id":340346289,"exchange":"binance","price":9436,"quantity":0.011329,"quoteQuantity":106.900444,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-13T14:46:37.748Z","fee":0.00482428,"feeCurrency":"BNB"},{"gid":2516,"id":340346291,"exchange":"binance","price":9436,"quantity":0.000014,"quoteQuantity":0.132104,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-13T14:46:40.556Z","fee":0.00000425,"feeCurrency":"BNB"},{"gid":2517,"id":340712721,"exchange":"binance","price":9342,"quantity":0.006739,"quoteQuantity":62.955738,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-14T14:44:06.337Z","fee":0.00285254,"feeCurrency":"BNB"},{"gid":2518,"id":340736838,"exchange":"binance","price":9386.81,"quantity":0.005,"quoteQuantity":46.93405,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-14T15:18:31.95Z","fee":0.00211548,"feeCurrency":"BNB"},{"gid":2519,"id":340851203,"exchange":"binance","price":9290,"quantity":0.007073,"quoteQuantity":65.70817,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-14T22:15:08.835Z","fee":0.00299069,"feeCurrency":"BNB"},{"gid":2520,"id":340872236,"exchange":"binance","price":9348.77,"quantity":0.034571,"quoteQuantity":323.19632767,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-14T22:29:08.027Z","fee":0.01463484,"feeCurrency":"BNB"},{"gid":2521,"id":340873091,"exchange":"binance","price":9357,"quantity":0.035547,"quoteQuantity":332.613279,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-14T22:30:00.582Z","fee":0.01506126,"feeCurrency":"BNB"},{"gid":2522,"id":340951155,"exchange":"binance","price":9244.6,"quantity":0.007145,"quoteQuantity":66.052667,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-15T01:50:06.731Z","fee":0.00303137,"feeCurrency":"BNB"},{"gid":2523,"id":341024952,"exchange":"binance","price":9170,"quantity":0.01,"quoteQuantity":91.7,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-15T03:46:30.976Z","fee":0.00425797,"feeCurrency":"BNB"},{"gid":2524,"id":341052437,"exchange":"binance","price":9200,"quantity":0.05,"quoteQuantity":460,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-15T04:25:12.123Z","fee":0.02126091,"feeCurrency":"BNB"},{"gid":2525,"id":341113927,"exchange":"binance","price":9040.81,"quantity":0.008076,"quoteQuantity":73.01358156,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-15T05:50:00.639Z","fee":0.00348796,"feeCurrency":"BNB"},{"gid":2526,"id":341130341,"exchange":"binance","price":9060,"quantity":0.001284,"quoteQuantity":11.63304,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-15T05:57:07.327Z","fee":0.00055085,"feeCurrency":"BNB"},{"gid":2527,"id":341130342,"exchange":"binance","price":9060,"quantity":0.006716,"quoteQuantity":60.84696,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-15T05:57:07.331Z","fee":0.00288127,"feeCurrency":"BNB"},{"gid":2528,"id":341133433,"exchange":"binance","price":9036.75,"quantity":0.008484,"quoteQuantity":76.667787,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-15T06:00:01.929Z","fee":0.00364198,"feeCurrency":"BNB"},{"gid":2529,"id":341136388,"exchange":"binance","price":9065,"quantity":0.000794,"quoteQuantity":7.19761,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-15T06:02:35.611Z","fee":0.00034113,"feeCurrency":"BNB"},{"gid":2530,"id":341136389,"exchange":"binance","price":9065,"quantity":0.007206,"quoteQuantity":65.32239,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-15T06:02:35.67Z","fee":0.003096,"feeCurrency":"BNB"},{"gid":2531,"id":341160799,"exchange":"binance","price":8990.04,"quantity":0.008195,"quoteQuantity":73.6733778,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-15T06:27:06.353Z","fee":0.00351387,"feeCurrency":"BNB"},{"gid":2532,"id":341217487,"exchange":"binance","price":9045,"quantity":0.004877,"quoteQuantity":44.112465,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-15T07:09:12.227Z","fee":0.00210622,"feeCurrency":"BNB"},{"gid":2533,"id":341220138,"exchange":"binance","price":9045,"quantity":0.015123,"quoteQuantity":136.787535,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-15T07:13:16.558Z","fee":0.00652173,"feeCurrency":"BNB"},{"gid":2534,"id":341223691,"exchange":"binance","price":9012,"quantity":0.02,"quoteQuantity":180.24,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-15T07:18:15.78Z","fee":0.00862465,"feeCurrency":"BNB"},{"gid":2535,"id":341290256,"exchange":"binance","price":9123.49,"quantity":0.04,"quoteQuantity":364.9396,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-15T09:07:12.585Z","fee":0.01720431,"feeCurrency":"BNB"},{"gid":2536,"id":341359315,"exchange":"binance","price":9129.54,"quantity":0.02,"quoteQuantity":182.5908,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-15T11:53:05.374Z","fee":0.00860634,"feeCurrency":"BNB"},{"gid":2537,"id":341387850,"exchange":"binance","price":9082,"quantity":0.039599,"quoteQuantity":359.638118,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-15T13:13:12.322Z","fee":0.01699764,"feeCurrency":"BNB"},{"gid":2538,"id":341387871,"exchange":"binance","price":9082,"quantity":0.010401,"quoteQuantity":94.461882,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-15T13:13:13.416Z","fee":0.00446403,"feeCurrency":"BNB"},{"gid":2539,"id":341393802,"exchange":"binance","price":9089.94,"quantity":0.01,"quoteQuantity":90.8994,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-15T13:27:47.394Z","fee":0.00430464,"feeCurrency":"BNB"},{"gid":2540,"id":341416679,"exchange":"binance","price":9166.5,"quantity":0.003781,"quoteQuantity":34.6585365,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-15T13:55:00.968Z","fee":0.00163548,"feeCurrency":"BNB"},{"gid":2541,"id":341416680,"exchange":"binance","price":9166.5,"quantity":0.00186,"quoteQuantity":17.04969,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-15T13:55:00.968Z","fee":0.00080454,"feeCurrency":"BNB"},{"gid":2542,"id":341416681,"exchange":"binance","price":9166.08,"quantity":0.028929,"quoteQuantity":265.16552832,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-15T13:55:00.968Z","fee":0.01251279,"feeCurrency":"BNB"},{"gid":2543,"id":341417007,"exchange":"binance","price":9175.51,"quantity":0.034266,"quoteQuantity":314.40802566,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-15T13:55:05.666Z","fee":0.0147824,"feeCurrency":"BNB"},{"gid":2544,"id":341458447,"exchange":"binance","price":9213.92,"quantity":0.005674,"quoteQuantity":52.27978208,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-15T14:46:50.236Z","fee":0.00244508,"feeCurrency":"BNB"},{"gid":2545,"id":341458448,"exchange":"binance","price":9213.93,"quantity":0.054326,"quoteQuantity":500.55596118,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-15T14:46:50.236Z","fee":0.02342887,"feeCurrency":"BNB"},{"gid":2546,"id":341484290,"exchange":"binance","price":9283.02,"quantity":0.005201,"quoteQuantity":48.28098702,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-15T15:26:34.527Z","fee":0.00225485,"feeCurrency":"BNB"},{"gid":2547,"id":341484291,"exchange":"binance","price":9283.02,"quantity":0.006498,"quoteQuantity":60.32106396,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-15T15:26:34.527Z","fee":0.00281856,"feeCurrency":"BNB"},{"gid":2548,"id":341484292,"exchange":"binance","price":9283.03,"quantity":0.020617,"quoteQuantity":191.38822951,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-15T15:26:34.527Z","fee":0.00894137,"feeCurrency":"BNB"},{"gid":2549,"id":341484293,"exchange":"binance","price":9283.2,"quantity":0.017684,"quoteQuantity":164.1641088,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-15T15:26:34.527Z","fee":0.0076665,"feeCurrency":"BNB"},{"gid":2550,"id":341491365,"exchange":"binance","price":9261.24,"quantity":0.009994,"quoteQuantity":92.55683256,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-15T15:36:10.813Z","fee":0.00432642,"feeCurrency":"BNB"},{"gid":2551,"id":341491830,"exchange":"binance","price":9261.24,"quantity":0.000006,"quoteQuantity":0.05556744,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-15T15:37:10.424Z","fee":0.00000432,"feeCurrency":"BNB"},{"gid":2552,"id":341508496,"exchange":"binance","price":9292.12,"quantity":0.01,"quoteQuantity":92.9212,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-15T15:57:41.686Z","fee":0.00432849,"feeCurrency":"BNB"},{"gid":2553,"id":341529684,"exchange":"binance","price":9360.2,"quantity":0.01,"quoteQuantity":93.602,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-15T16:13:26.753Z","fee":0.00432924,"feeCurrency":"BNB"},{"gid":2554,"id":341530198,"exchange":"binance","price":9370.74,"quantity":0.02,"quoteQuantity":187.4148,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-15T16:14:14.446Z","fee":0.00864552,"feeCurrency":"BNB"},{"gid":2555,"id":341540586,"exchange":"binance","price":9357.73,"quantity":0.1,"quoteQuantity":935.773,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-15T16:28:15.066Z","fee":0.04328688,"feeCurrency":"BNB"},{"gid":2556,"id":341544590,"exchange":"binance","price":9341,"quantity":0.02,"quoteQuantity":186.82,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-15T16:34:23.208Z","fee":0.00864403,"feeCurrency":"BNB"},{"gid":2557,"id":341545061,"exchange":"binance","price":9345.8,"quantity":0.02,"quoteQuantity":186.916,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-15T16:35:08.06Z","fee":0.00863856,"feeCurrency":"BNB"},{"gid":2558,"id":341545082,"exchange":"binance","price":9346,"quantity":0.02,"quoteQuantity":186.92,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-15T16:35:15.251Z","fee":0.00863856,"feeCurrency":"BNB"},{"gid":2559,"id":341547123,"exchange":"binance","price":9332.98,"quantity":0.006149,"quoteQuantity":57.38849402,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-15T16:38:45.308Z","fee":0.00265391,"feeCurrency":"BNB"},{"gid":2560,"id":341547124,"exchange":"binance","price":9333,"quantity":0.003851,"quoteQuantity":35.941383,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-15T16:38:45.308Z","fee":0.00166138,"feeCurrency":"BNB"},{"gid":2561,"id":341547611,"exchange":"binance","price":9327.26,"quantity":0.01,"quoteQuantity":93.2726,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-15T16:39:10.546Z","fee":0.00431654,"feeCurrency":"BNB"},{"gid":2562,"id":341562140,"exchange":"binance","price":9365,"quantity":0.02,"quoteQuantity":187.3,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-15T17:06:55.284Z","fee":0.00866451,"feeCurrency":"BNB"},{"gid":2563,"id":341562684,"exchange":"binance","price":9366,"quantity":0.01,"quoteQuantity":93.66,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-15T17:08:53.326Z","fee":0.004332,"feeCurrency":"BNB"},{"gid":2564,"id":341562700,"exchange":"binance","price":9365.96,"quantity":0.01,"quoteQuantity":93.6596,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-15T17:09:02.755Z","fee":0.004332,"feeCurrency":"BNB"},{"gid":2565,"id":341563163,"exchange":"binance","price":9356,"quantity":0.01,"quoteQuantity":93.56,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-15T17:09:45.416Z","fee":0.0043325,"feeCurrency":"BNB"},{"gid":2566,"id":341671970,"exchange":"binance","price":9465.1,"quantity":0.002187,"quoteQuantity":20.7001737,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-15T20:55:05.766Z","fee":0.00094218,"feeCurrency":"BNB"},{"gid":2567,"id":341745374,"exchange":"binance","price":9435.88,"quantity":0.002,"quoteQuantity":18.87176,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-15T23:46:45.791Z","fee":0.0008632,"feeCurrency":"BNB"},{"gid":2568,"id":341750090,"exchange":"binance","price":9428.1,"quantity":0.002,"quoteQuantity":18.8562,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-16T00:05:41.214Z","fee":0.00086316,"feeCurrency":"BNB"},{"gid":2569,"id":341814805,"exchange":"binance","price":9540.15,"quantity":0.002547,"quoteQuantity":24.29876205,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-16T03:09:09.346Z","fee":0.0010991,"feeCurrency":"BNB"},{"gid":2570,"id":341820340,"exchange":"binance","price":9570.01,"quantity":0.002862,"quoteQuantity":27.38936862,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-16T03:10:00.691Z","fee":0.00123889,"feeCurrency":"BNB"},{"gid":2571,"id":341820811,"exchange":"binance","price":9580.97,"quantity":0.002746,"quoteQuantity":26.30934362,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-16T03:10:05.456Z","fee":0.00118675,"feeCurrency":"BNB"},{"gid":2572,"id":341826236,"exchange":"binance","price":9562.82,"quantity":0.05,"quoteQuantity":478.141,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-16T03:14:13.115Z","fee":0.02163702,"feeCurrency":"BNB"},{"gid":2573,"id":341851666,"exchange":"binance","price":9539.56,"quantity":0.01,"quoteQuantity":95.3956,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-16T03:57:55.786Z","fee":0.00432924,"feeCurrency":"BNB"},{"gid":2574,"id":341855473,"exchange":"binance","price":9538,"quantity":0.01,"quoteQuantity":95.38,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-16T04:04:17.004Z","fee":0.00433676,"feeCurrency":"BNB"},{"gid":2575,"id":341855609,"exchange":"binance","price":9532,"quantity":0.01,"quoteQuantity":95.32,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-16T04:04:19.603Z","fee":0.00433676,"feeCurrency":"BNB"},{"gid":2576,"id":341903951,"exchange":"binance","price":9528.33,"quantity":0.009976,"quoteQuantity":95.05462008,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-16T06:31:07.795Z","fee":0.00433155,"feeCurrency":"BNB"},{"gid":2577,"id":341903952,"exchange":"binance","price":9528.33,"quantity":0.000024,"quoteQuantity":0.22867992,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-16T06:31:08.085Z","fee":0.00001041,"feeCurrency":"BNB"},{"gid":2578,"id":341904047,"exchange":"binance","price":9527.94,"quantity":0.01,"quoteQuantity":95.2794,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-16T06:31:32.088Z","fee":0.00434178,"feeCurrency":"BNB"},{"gid":2579,"id":341904050,"exchange":"binance","price":9527,"quantity":0.02,"quoteQuantity":190.54,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-16T06:31:32.645Z","fee":0.00868356,"feeCurrency":"BNB"},{"gid":2580,"id":341904403,"exchange":"binance","price":9530,"quantity":0.01,"quoteQuantity":95.3,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-16T06:32:21.127Z","fee":0.00434203,"feeCurrency":"BNB"},{"gid":2581,"id":341904724,"exchange":"binance","price":9521,"quantity":0.01,"quoteQuantity":95.21,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-16T06:33:06.733Z","fee":0.00434228,"feeCurrency":"BNB"},{"gid":2582,"id":341914239,"exchange":"binance","price":9495,"quantity":0.01,"quoteQuantity":94.95,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-16T06:44:16.312Z","fee":0.00434127,"feeCurrency":"BNB"},{"gid":2583,"id":341914303,"exchange":"binance","price":9492,"quantity":0.008987,"quoteQuantity":85.304604,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-16T06:44:23.389Z","fee":0.00390281,"feeCurrency":"BNB"},{"gid":2584,"id":341914304,"exchange":"binance","price":9492,"quantity":0.001013,"quoteQuantity":9.615396,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-16T06:44:23.414Z","fee":0.00043846,"feeCurrency":"BNB"},{"gid":2585,"id":341957665,"exchange":"binance","price":9481.9,"quantity":0.01,"quoteQuantity":94.819,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-16T08:29:46.345Z","fee":0.00435387,"feeCurrency":"BNB"},{"gid":2586,"id":341963606,"exchange":"binance","price":9487.42,"quantity":0.002,"quoteQuantity":18.97484,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-16T08:48:24.623Z","fee":0.00087123,"feeCurrency":"BNB"},{"gid":2587,"id":341970147,"exchange":"binance","price":9515.02,"quantity":0.002,"quoteQuantity":19.03004,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-16T09:07:25.063Z","fee":0.00087172,"feeCurrency":"BNB"},{"gid":2588,"id":342028826,"exchange":"binance","price":9503.06,"quantity":0.002,"quoteQuantity":19.00612,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-16T11:49:46.476Z","fee":0.00086966,"feeCurrency":"BNB"},{"gid":2589,"id":342039520,"exchange":"binance","price":9493.3,"quantity":0.01,"quoteQuantity":94.933,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-16T12:16:54.257Z","fee":0.00435312,"feeCurrency":"BNB"},{"gid":2590,"id":342052591,"exchange":"binance","price":9537.63,"quantity":0.01,"quoteQuantity":95.3763,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-16T12:44:30.079Z","fee":0.00436526,"feeCurrency":"BNB"},{"gid":2591,"id":342074734,"exchange":"binance","price":9522.8,"quantity":0.002415,"quoteQuantity":22.997562,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-16T13:14:06.526Z","fee":0.00105113,"feeCurrency":"BNB"},{"gid":2592,"id":342109226,"exchange":"binance","price":9507,"quantity":0.01,"quoteQuantity":95.07,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-16T14:33:26.223Z","fee":0.00435514,"feeCurrency":"BNB"},{"gid":2593,"id":342120358,"exchange":"binance","price":9474,"quantity":0.01,"quoteQuantity":94.74,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-16T14:46:22.821Z","fee":0.00435691,"feeCurrency":"BNB"},{"gid":2594,"id":342126907,"exchange":"binance","price":9450.38,"quantity":0.009345,"quoteQuantity":88.3138011,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-16T14:55:57.803Z","fee":0.00407679,"feeCurrency":"BNB"},{"gid":2595,"id":342126908,"exchange":"binance","price":9450.39,"quantity":0.000655,"quoteQuantity":6.19000545,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-16T14:55:57.803Z","fee":0.00028776,"feeCurrency":"BNB"},{"gid":2596,"id":342138107,"exchange":"binance","price":9435,"quantity":0.01,"quoteQuantity":94.35,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-16T15:02:53.141Z","fee":0.00436426,"feeCurrency":"BNB"},{"gid":2597,"id":342174087,"exchange":"binance","price":9480.39,"quantity":0.01,"quoteQuantity":94.8039,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-16T16:22:13.443Z","fee":0.00437292,"feeCurrency":"BNB"},{"gid":2598,"id":342182845,"exchange":"binance","price":9501.44,"quantity":0.02,"quoteQuantity":190.0288,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-16T16:41:49.057Z","fee":0.00873765,"feeCurrency":"BNB"},{"gid":2599,"id":342323953,"exchange":"binance","price":9469.99,"quantity":0.02,"quoteQuantity":189.3998,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-17T01:39:08.043Z","fee":0.00869867,"feeCurrency":"BNB"},{"gid":2600,"id":342324300,"exchange":"binance","price":9470.95,"quantity":0.02,"quoteQuantity":189.419,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-17T01:41:02.714Z","fee":0.00868708,"feeCurrency":"BNB"},{"gid":2601,"id":342505044,"exchange":"binance","price":9495.81,"quantity":0.02,"quoteQuantity":189.9162,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-17T12:10:11.925Z","fee":0.00863442,"feeCurrency":"BNB"},{"gid":2602,"id":342537628,"exchange":"binance","price":9481.66,"quantity":0.002442,"quoteQuantity":23.15421372,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-17T13:22:00.976Z","fee":0.00104925,"feeCurrency":"BNB"},{"gid":2603,"id":342551672,"exchange":"binance","price":9467.98,"quantity":0.01,"quoteQuantity":94.6798,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-17T13:41:29.66Z","fee":0.00431207,"feeCurrency":"BNB"},{"gid":2604,"id":342567246,"exchange":"binance","price":9435.01,"quantity":0.01,"quoteQuantity":94.3501,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-17T14:03:51.757Z","fee":0.00430836,"feeCurrency":"BNB"},{"gid":2605,"id":342604380,"exchange":"binance","price":9428.55,"quantity":0.01,"quoteQuantity":94.2855,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-17T14:56:35.16Z","fee":0.00430267,"feeCurrency":"BNB"},{"gid":2606,"id":342615241,"exchange":"binance","price":9419.05,"quantity":0.01,"quoteQuantity":94.1905,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-17T15:21:27.591Z","fee":0.00430366,"feeCurrency":"BNB"},{"gid":2607,"id":342615769,"exchange":"binance","price":9418.84,"quantity":0.01,"quoteQuantity":94.1884,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-17T15:22:58.055Z","fee":0.00430267,"feeCurrency":"BNB"},{"gid":2608,"id":342642008,"exchange":"binance","price":9379.1,"quantity":0.000875,"quoteQuantity":8.2067125,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-17T16:23:30.27Z","fee":0.00038016,"feeCurrency":"BNB"},{"gid":2609,"id":342642009,"exchange":"binance","price":9379.65,"quantity":0.004836,"quoteQuantity":45.3599874,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-17T16:23:30.27Z","fee":0.00209088,"feeCurrency":"BNB"},{"gid":2610,"id":342642010,"exchange":"binance","price":9379.72,"quantity":0.004289,"quoteQuantity":40.22961908,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-17T16:23:30.27Z","fee":0.00185328,"feeCurrency":"BNB"},{"gid":2611,"id":342650054,"exchange":"binance","price":9382.6,"quantity":0.000207,"quoteQuantity":1.9421982,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-17T16:40:01.75Z","fee":0.00009075,"feeCurrency":"BNB"},{"gid":2612,"id":342650055,"exchange":"binance","price":9382.79,"quantity":0.001793,"quoteQuantity":16.82334247,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-17T16:40:01.75Z","fee":0.00077355,"feeCurrency":"BNB"},{"gid":2613,"id":342898814,"exchange":"binance","price":9410.68,"quantity":0.01,"quoteQuantity":94.1068,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-18T04:59:57.404Z","fee":0.00429774,"feeCurrency":"BNB"},{"gid":2614,"id":342920121,"exchange":"binance","price":9436.99,"quantity":0.005341,"quoteQuantity":50.40296359,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-18T06:37:55.588Z","fee":0.00229788,"feeCurrency":"BNB"},{"gid":2615,"id":342920122,"exchange":"binance","price":9436.99,"quantity":0.005903,"quoteQuantity":55.70655197,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-18T06:37:55.588Z","fee":0.00253967,"feeCurrency":"BNB"},{"gid":2616,"id":342920123,"exchange":"binance","price":9436.82,"quantity":0.008756,"quoteQuantity":82.62879592,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-18T06:37:55.588Z","fee":0.00376707,"feeCurrency":"BNB"},{"gid":2617,"id":342978954,"exchange":"binance","price":9474.99,"quantity":0.01,"quoteQuantity":94.7499,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-18T09:51:31.246Z","fee":0.00431035,"feeCurrency":"BNB"},{"gid":2618,"id":342978955,"exchange":"binance","price":9474.99,"quantity":0.002,"quoteQuantity":18.94998,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-18T09:51:31.339Z","fee":0.00086206,"feeCurrency":"BNB"},{"gid":2619,"id":342978956,"exchange":"binance","price":9474.99,"quantity":0.007989,"quoteQuantity":75.69569511,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-18T09:51:31.463Z","fee":0.00344354,"feeCurrency":"BNB"},{"gid":2620,"id":342978958,"exchange":"binance","price":9474.99,"quantity":0.000011,"quoteQuantity":0.10422489,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-18T09:51:31.875Z","fee":0.00000474,"feeCurrency":"BNB"},{"gid":2621,"id":342988842,"exchange":"binance","price":9414.51,"quantity":0.02,"quoteQuantity":188.2902,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-18T10:01:30.156Z","fee":0.00859647,"feeCurrency":"BNB"},{"gid":2622,"id":343076289,"exchange":"binance","price":9393.47,"quantity":0.02,"quoteQuantity":187.8694,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-18T14:01:32.679Z","fee":0.00868608,"feeCurrency":"BNB"},{"gid":2623,"id":343134267,"exchange":"binance","price":9406.67,"quantity":0.01,"quoteQuantity":94.0667,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-18T16:29:46.115Z","fee":0.00434404,"feeCurrency":"BNB"},{"gid":2624,"id":343135000,"exchange":"binance","price":9399,"quantity":0.04,"quoteQuantity":375.96,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-18T16:31:15.512Z","fee":0.01738727,"feeCurrency":"BNB"},{"gid":2625,"id":343176793,"exchange":"binance","price":9382,"quantity":0.02,"quoteQuantity":187.64,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-18T19:03:42.135Z","fee":0.0086891,"feeCurrency":"BNB"},{"gid":2626,"id":343299239,"exchange":"binance","price":9405.75,"quantity":0.02,"quoteQuantity":188.115,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-18T23:43:56.91Z","fee":0.00871563,"feeCurrency":"BNB"},{"gid":2627,"id":343344261,"exchange":"binance","price":9328.48,"quantity":0.02,"quoteQuantity":186.5696,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-19T01:58:11.337Z","fee":0.00868557,"feeCurrency":"BNB"},{"gid":2628,"id":343344830,"exchange":"binance","price":9326.62,"quantity":0.008586,"quoteQuantity":80.07835932,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-19T01:58:33.603Z","fee":0.00373045,"feeCurrency":"BNB"},{"gid":2629,"id":343344832,"exchange":"binance","price":9326.62,"quantity":0.041414,"quoteQuantity":386.25264068,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-19T01:58:33.947Z","fee":0.01798349,"feeCurrency":"BNB"},{"gid":2630,"id":343389035,"exchange":"binance","price":9289.28,"quantity":0.01,"quoteQuantity":92.8928,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-19T03:34:07.307Z","fee":0.00435564,"feeCurrency":"BNB"},{"gid":2631,"id":343390261,"exchange":"binance","price":9276.01,"quantity":0.01,"quoteQuantity":92.7601,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-19T03:37:39.993Z","fee":0.00435363,"feeCurrency":"BNB"},{"gid":2632,"id":343470216,"exchange":"binance","price":9273.88,"quantity":0.01,"quoteQuantity":92.7388,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-19T07:50:28.453Z","fee":0.00435742,"feeCurrency":"BNB"},{"gid":2633,"id":343471041,"exchange":"binance","price":9262.9,"quantity":0.1,"quoteQuantity":926.29,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-19T07:52:03.147Z","fee":0.04357425,"feeCurrency":"BNB"},{"gid":2634,"id":343545850,"exchange":"binance","price":9366.1,"quantity":0.1,"quoteQuantity":936.61,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-19T10:28:48.356Z","fee":0.04353915,"feeCurrency":"BNB"},{"gid":2635,"id":343684512,"exchange":"binance","price":9348.3,"quantity":0.01,"quoteQuantity":93.483,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-19T17:01:40.638Z","fee":0.0043516,"feeCurrency":"BNB"},{"gid":2636,"id":343689950,"exchange":"binance","price":9330.16,"quantity":0.01,"quoteQuantity":93.3016,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-19T17:19:22.175Z","fee":0.00436071,"feeCurrency":"BNB"},{"gid":2637,"id":343690892,"exchange":"binance","price":9322,"quantity":0.01,"quoteQuantity":93.22,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-19T17:22:35.764Z","fee":0.00436554,"feeCurrency":"BNB"},{"gid":2638,"id":343691525,"exchange":"binance","price":9316.55,"quantity":0.01,"quoteQuantity":93.1655,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-19T17:24:12.505Z","fee":0.00436401,"feeCurrency":"BNB"},{"gid":2639,"id":343876719,"exchange":"binance","price":9309.92,"quantity":0.01,"quoteQuantity":93.0992,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-20T05:37:15.168Z","fee":0.00437317,"feeCurrency":"BNB"},{"gid":2640,"id":343953049,"exchange":"binance","price":9288.26,"quantity":0.01,"quoteQuantity":92.8826,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-20T11:03:48.266Z","fee":0.00437802,"feeCurrency":"BNB"},{"gid":2641,"id":344253006,"exchange":"binance","price":9371.21,"quantity":0.06,"quoteQuantity":562.2726,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-21T04:47:53.273Z","fee":0.02625213,"feeCurrency":"BNB"},{"gid":2642,"id":344297102,"exchange":"binance","price":9371.8,"quantity":0.02,"quoteQuantity":187.436,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-21T08:15:46.092Z","fee":0.00876014,"feeCurrency":"BNB"},{"gid":2643,"id":344353411,"exchange":"binance","price":9323,"quantity":0.01,"quoteQuantity":93.23,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-21T13:11:25.784Z","fee":0.00437802,"feeCurrency":"BNB"},{"gid":2644,"id":344379092,"exchange":"binance","price":9350.08,"quantity":0.015,"quoteQuantity":140.2512,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-21T15:06:33.012Z","fee":0.00656972,"feeCurrency":"BNB"},{"gid":2645,"id":344379093,"exchange":"binance","price":9350.36,"quantity":0.005,"quoteQuantity":46.7518,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-21T15:06:33.012Z","fee":0.0021899,"feeCurrency":"BNB"},{"gid":2646,"id":344397842,"exchange":"binance","price":9372.91,"quantity":0.03,"quoteQuantity":281.1873,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-21T16:08:38.468Z","fee":0.01309412,"feeCurrency":"BNB"},{"gid":2647,"id":344398288,"exchange":"binance","price":9372.72,"quantity":0.05,"quoteQuantity":468.636,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-21T16:09:50.329Z","fee":0.02183573,"feeCurrency":"BNB"},{"gid":2648,"id":344408025,"exchange":"binance","price":9353.93,"quantity":0.02,"quoteQuantity":187.0786,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-21T16:44:13.039Z","fee":0.00872802,"feeCurrency":"BNB"},{"gid":2649,"id":344428442,"exchange":"binance","price":9331,"quantity":0.01,"quoteQuantity":93.31,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-21T18:15:11.066Z","fee":0.00436909,"feeCurrency":"BNB"},{"gid":2650,"id":344432624,"exchange":"binance","price":9321,"quantity":0.01,"quoteQuantity":93.21,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-21T18:30:09.306Z","fee":0.00437113,"feeCurrency":"BNB"},{"gid":2651,"id":344491284,"exchange":"binance","price":9301,"quantity":0.005964,"quoteQuantity":55.471164,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-21T22:44:59.324Z","fee":0.0026058,"feeCurrency":"BNB"},{"gid":2652,"id":344491285,"exchange":"binance","price":9301,"quantity":0.004036,"quoteQuantity":37.538836,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-21T22:44:59.324Z","fee":0.00176634,"feeCurrency":"BNB"},{"gid":2653,"id":344493335,"exchange":"binance","price":9291,"quantity":0.01,"quoteQuantity":92.91,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-21T22:47:41.266Z","fee":0.00437011,"feeCurrency":"BNB"},{"gid":2654,"id":344511376,"exchange":"binance","price":9281,"quantity":0.01,"quoteQuantity":92.81,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-22T00:01:36.028Z","fee":0.00436224,"feeCurrency":"BNB"},{"gid":2655,"id":344522656,"exchange":"binance","price":9318.36,"quantity":0.0084,"quoteQuantity":78.274224,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-22T00:46:10.508Z","fee":0.00366043,"feeCurrency":"BNB"},{"gid":2656,"id":344522657,"exchange":"binance","price":9318.36,"quantity":0.001597,"quoteQuantity":14.88142092,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-22T00:46:12.247Z","fee":0.00069591,"feeCurrency":"BNB"},{"gid":2657,"id":344522658,"exchange":"binance","price":9318.36,"quantity":0.000003,"quoteQuantity":0.02795508,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-22T00:46:14.707Z","fee":0.0000013,"feeCurrency":"BNB"},{"gid":2658,"id":344541516,"exchange":"binance","price":9372,"quantity":0.01,"quoteQuantity":93.72,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-22T01:54:01.97Z","fee":0.00435855,"feeCurrency":"BNB"},{"gid":2659,"id":344549715,"exchange":"binance","price":9384.7,"quantity":0.01,"quoteQuantity":93.847,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-22T02:03:27.186Z","fee":0.0043497,"feeCurrency":"BNB"},{"gid":2660,"id":344555258,"exchange":"binance","price":9374.21,"quantity":0.1,"quoteQuantity":937.421,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-22T02:17:24.081Z","fee":0.04355103,"feeCurrency":"BNB"},{"gid":2661,"id":344773169,"exchange":"binance","price":9483.33,"quantity":0.01,"quoteQuantity":94.8333,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-22T12:53:33.78Z","fee":0.00435124,"feeCurrency":"BNB"},{"gid":2662,"id":344798358,"exchange":"binance","price":9462.35,"quantity":0.01,"quoteQuantity":94.6235,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-22T13:53:16.644Z","fee":0.00436579,"feeCurrency":"BNB"},{"gid":2663,"id":344885291,"exchange":"binance","price":9561.27,"quantity":0.01,"quoteQuantity":95.6127,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-22T16:44:12.26Z","fee":0.00438975,"feeCurrency":"BNB"},{"gid":2664,"id":344885838,"exchange":"binance","price":9563.1,"quantity":0.07,"quoteQuantity":669.417,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-22T16:44:41.671Z","fee":0.03073416,"feeCurrency":"BNB"},{"gid":2665,"id":344886566,"exchange":"binance","price":9568.85,"quantity":0.00968,"quoteQuantity":92.626468,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-22T16:46:05.078Z","fee":0.00424586,"feeCurrency":"BNB"},{"gid":2666,"id":345100515,"exchange":"binance","price":9700,"quantity":0.1,"quoteQuantity":970,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-23T00:01:19.62Z","fee":0.04410012,"feeCurrency":"BNB"},{"gid":2667,"id":345128734,"exchange":"binance","price":9657.13,"quantity":0.05602,"quoteQuantity":540.9924226,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-23T01:14:13.992Z","fee":0.02466297,"feeCurrency":"BNB"},{"gid":2668,"id":345128735,"exchange":"binance","price":9657.1,"quantity":0.04398,"quoteQuantity":424.719258,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-23T01:14:13.992Z","fee":0.01936226,"feeCurrency":"BNB"},{"gid":2669,"id":345129815,"exchange":"binance","price":9652.29,"quantity":0.057743,"quoteQuantity":557.35218147,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-23T01:15:35.082Z","fee":0.02541988,"feeCurrency":"BNB"},{"gid":2670,"id":345129816,"exchange":"binance","price":9652.29,"quantity":0.042257,"quoteQuantity":407.87681853,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-23T01:15:35.082Z","fee":0.01860256,"feeCurrency":"BNB"},{"gid":2671,"id":345137594,"exchange":"binance","price":9629.75,"quantity":0.01,"quoteQuantity":96.2975,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-23T01:29:26.513Z","fee":0.00441254,"feeCurrency":"BNB"},{"gid":2672,"id":345137948,"exchange":"binance","price":9625.01,"quantity":0.01,"quoteQuantity":96.2501,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-23T01:30:03.21Z","fee":0.00441254,"feeCurrency":"BNB"},{"gid":2673,"id":345141516,"exchange":"binance","price":9637.93,"quantity":0.02,"quoteQuantity":192.7586,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-23T01:36:41.787Z","fee":0.00882352,"feeCurrency":"BNB"},{"gid":2674,"id":345168954,"exchange":"binance","price":9648.4,"quantity":0.02,"quoteQuantity":192.968,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-23T02:46:56.085Z","fee":0.00883756,"feeCurrency":"BNB"},{"gid":2675,"id":345178285,"exchange":"binance","price":9640.91,"quantity":0.002047,"quoteQuantity":19.73494277,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-23T03:29:01.895Z","fee":0.00090382,"feeCurrency":"BNB"},{"gid":2676,"id":345178286,"exchange":"binance","price":9640.91,"quantity":0.001146,"quoteQuantity":11.04848286,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-23T03:29:01.895Z","fee":0.00050702,"feeCurrency":"BNB"},{"gid":2677,"id":345178287,"exchange":"binance","price":9640.91,"quantity":0.001522,"quoteQuantity":14.67346502,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-23T03:29:01.895Z","fee":0.00067014,"feeCurrency":"BNB"},{"gid":2678,"id":345178288,"exchange":"binance","price":9640.91,"quantity":0.015285,"quoteQuantity":147.36130935,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-23T03:29:01.895Z","fee":0.00674122,"feeCurrency":"BNB"},{"gid":2679,"id":345178729,"exchange":"binance","price":9631,"quantity":0.01,"quoteQuantity":96.31,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-23T03:33:12.127Z","fee":0.00440658,"feeCurrency":"BNB"},{"gid":2680,"id":345222797,"exchange":"binance","price":9627.03,"quantity":0.01,"quoteQuantity":96.2703,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-23T06:43:23.702Z","fee":0.00441384,"feeCurrency":"BNB"},{"gid":2681,"id":345233859,"exchange":"binance","price":9640,"quantity":0.1,"quoteQuantity":964,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-23T07:27:06.671Z","fee":0.04407171,"feeCurrency":"BNB"},{"gid":2682,"id":345271799,"exchange":"binance","price":9611.66,"quantity":0.025549,"quoteQuantity":245.56830134,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-23T08:52:03.104Z","fee":0.01127661,"feeCurrency":"BNB"},{"gid":2683,"id":345271800,"exchange":"binance","price":9610.67,"quantity":0.051389,"quoteQuantity":493.88272063,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-23T08:52:03.104Z","fee":0.02267934,"feeCurrency":"BNB"},{"gid":2684,"id":345271801,"exchange":"binance","price":9610.15,"quantity":0.023062,"quoteQuantity":221.6292793,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-23T08:52:03.104Z","fee":0.01017732,"feeCurrency":"BNB"},{"gid":2685,"id":345313248,"exchange":"binance","price":9596.19,"quantity":0.001303,"quoteQuantity":12.50383557,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-23T10:51:08.651Z","fee":0.0005742,"feeCurrency":"BNB"},{"gid":2686,"id":345313249,"exchange":"binance","price":9596.19,"quantity":0.001139,"quoteQuantity":10.93006041,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-23T10:51:08.651Z","fee":0.00050193,"feeCurrency":"BNB"},{"gid":2687,"id":345313250,"exchange":"binance","price":9596.19,"quantity":0.002101,"quoteQuantity":20.16159519,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-23T10:51:08.651Z","fee":0.00092587,"feeCurrency":"BNB"},{"gid":2688,"id":345313251,"exchange":"binance","price":9596.19,"quantity":0.001136,"quoteQuantity":10.90127184,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-23T10:51:08.651Z","fee":0.00050061,"feeCurrency":"BNB"},{"gid":2689,"id":345313252,"exchange":"binance","price":9596.19,"quantity":0.001104,"quoteQuantity":10.59419376,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-23T10:51:08.651Z","fee":0.00048651,"feeCurrency":"BNB"},{"gid":2690,"id":345313253,"exchange":"binance","price":9595.71,"quantity":0.013217,"quoteQuantity":126.82649907,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-23T10:51:08.651Z","fee":0.00582422,"feeCurrency":"BNB"},{"gid":2691,"id":345322867,"exchange":"binance","price":9609.72,"quantity":0.1,"quoteQuantity":960.972,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-23T11:25:50.067Z","fee":0.0441246,"feeCurrency":"BNB"},{"gid":2692,"id":345323748,"exchange":"binance","price":9614.99,"quantity":0.08,"quoteQuantity":769.1992,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-23T11:27:19.477Z","fee":0.03524827,"feeCurrency":"BNB"},{"gid":2693,"id":345722954,"exchange":"binance","price":9544.42,"quantity":0.002,"quoteQuantity":19.08884,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-24T08:13:15.195Z","fee":0.00087473,"feeCurrency":"BNB"},{"gid":2694,"id":345748899,"exchange":"binance","price":9518,"quantity":0.000308,"quoteQuantity":2.931544,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-24T09:28:32.895Z","fee":0.00013448,"feeCurrency":"BNB"},{"gid":2695,"id":345748900,"exchange":"binance","price":9518,"quantity":0.099692,"quoteQuantity":948.868456,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-24T09:28:32.917Z","fee":0.04352924,"feeCurrency":"BNB"},{"gid":2696,"id":345749068,"exchange":"binance","price":9515.78,"quantity":0.1,"quoteQuantity":951.578,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-24T09:28:54.04Z","fee":0.04365354,"feeCurrency":"BNB"},{"gid":2697,"id":345820096,"exchange":"binance","price":9403.76,"quantity":0.002127,"quoteQuantity":20.00179752,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-24T11:14:38.18Z","fee":0.0009291,"feeCurrency":"BNB"},{"gid":2698,"id":345820184,"exchange":"binance","price":9406.66,"quantity":0.02,"quoteQuantity":188.1332,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-24T11:14:45.896Z","fee":0.00872397,"feeCurrency":"BNB"},{"gid":2699,"id":345822192,"exchange":"binance","price":9402.56,"quantity":0.02,"quoteQuantity":188.0512,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-24T11:19:10.313Z","fee":0.00872193,"feeCurrency":"BNB"},{"gid":2700,"id":345863638,"exchange":"binance","price":9367.05,"quantity":0.049597,"quoteQuantity":464.57757885,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-24T12:19:36.285Z","fee":0.02162916,"feeCurrency":"BNB"},{"gid":2701,"id":345863639,"exchange":"binance","price":9367.89,"quantity":0.000403,"quoteQuantity":3.77525967,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-24T12:19:36.447Z","fee":0.00017442,"feeCurrency":"BNB"},{"gid":2702,"id":345870301,"exchange":"binance","price":9381.43,"quantity":0.05,"quoteQuantity":469.0715,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-24T12:37:44.844Z","fee":0.02174543,"feeCurrency":"BNB"},{"gid":2703,"id":345906222,"exchange":"binance","price":9372.61,"quantity":0.004559,"quoteQuantity":42.72972899,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-24T13:56:01.263Z","fee":0.00198294,"feeCurrency":"BNB"},{"gid":2704,"id":345906463,"exchange":"binance","price":9374.87,"quantity":0.004559,"quoteQuantity":42.74003233,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-24T13:57:01.368Z","fee":0.00198272,"feeCurrency":"BNB"},{"gid":2705,"id":345907201,"exchange":"binance","price":9362.47,"quantity":0.004612,"quoteQuantity":43.17971164,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-24T13:58:00.571Z","fee":0.00200469,"feeCurrency":"BNB"},{"gid":2706,"id":345908764,"exchange":"binance","price":9356.91,"quantity":0.004612,"quoteQuantity":43.15406892,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-24T13:59:00.793Z","fee":0.00200434,"feeCurrency":"BNB"},{"gid":2707,"id":345921686,"exchange":"binance","price":9343.32,"quantity":0.002,"quoteQuantity":18.68664,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-24T14:28:11.965Z","fee":0.0008688,"feeCurrency":"BNB"},{"gid":2708,"id":345958029,"exchange":"binance","price":9301.77,"quantity":0.02841,"quoteQuantity":264.2632857,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-24T15:19:01.512Z","fee":0.01234358,"feeCurrency":"BNB"},{"gid":2709,"id":345958031,"exchange":"binance","price":9301.77,"quantity":0.01367,"quoteQuantity":127.1551959,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-24T15:19:01.678Z","fee":0.00593934,"feeCurrency":"BNB"},{"gid":2710,"id":345958063,"exchange":"binance","price":9301.77,"quantity":0.00792,"quoteQuantity":73.6700184,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-24T15:19:12.299Z","fee":0.00344148,"feeCurrency":"BNB"},{"gid":2711,"id":345962121,"exchange":"binance","price":9313.54,"quantity":0.05,"quoteQuantity":465.677,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-24T15:27:00.226Z","fee":0.02168883,"feeCurrency":"BNB"},{"gid":2712,"id":345991026,"exchange":"binance","price":9226.07,"quantity":0.05,"quoteQuantity":461.3035,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-24T15:54:25.867Z","fee":0.02164377,"feeCurrency":"BNB"},{"gid":2713,"id":345996722,"exchange":"binance","price":9249.52,"quantity":0.00515,"quoteQuantity":47.635028,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-24T16:00:00.638Z","fee":0.00222763,"feeCurrency":"BNB"},{"gid":2714,"id":346031096,"exchange":"binance","price":9311.02,"quantity":0.05,"quoteQuantity":465.551,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-24T17:09:07.562Z","fee":0.02169385,"feeCurrency":"BNB"},{"gid":2715,"id":346114257,"exchange":"binance","price":9272.99,"quantity":0.02,"quoteQuantity":185.4598,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-24T21:58:32.646Z","fee":0.00865251,"feeCurrency":"BNB"},{"gid":2716,"id":346145797,"exchange":"binance","price":9296.59,"quantity":0.005866,"quoteQuantity":54.53379694,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-25T00:00:01.103Z","fee":0.0025581,"feeCurrency":"BNB"},{"gid":2717,"id":346274889,"exchange":"binance","price":9102.22,"quantity":0.006405,"quoteQuantity":58.2997191,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-25T04:00:01.309Z","fee":0.00277632,"feeCurrency":"BNB"},{"gid":2718,"id":346302259,"exchange":"binance","price":9121.97,"quantity":0.015,"quoteQuantity":136.82955,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-25T04:55:27.234Z","fee":0.00652249,"feeCurrency":"BNB"},{"gid":2719,"id":346302260,"exchange":"binance","price":9121.97,"quantity":0.055357,"quoteQuantity":504.96489329,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-25T04:55:27.234Z","fee":0.02407235,"feeCurrency":"BNB"},{"gid":2720,"id":346302261,"exchange":"binance","price":9122.25,"quantity":0.129643,"quoteQuantity":1182.63585675,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-25T04:55:27.234Z","fee":0.05637174,"feeCurrency":"BNB"},{"gid":2721,"id":346302385,"exchange":"binance","price":9122.53,"quantity":0.1,"quoteQuantity":912.253,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-25T04:56:22.962Z","fee":0.04350348,"feeCurrency":"BNB"},{"gid":2722,"id":346337060,"exchange":"binance","price":9165.55,"quantity":0.022637,"quoteQuantity":207.48055535,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-25T06:24:47.517Z","fee":0.00982044,"feeCurrency":"BNB"},{"gid":2723,"id":346337061,"exchange":"binance","price":9165.41,"quantity":0.0419,"quoteQuantity":384.030679,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-25T06:24:47.517Z","fee":0.01817689,"feeCurrency":"BNB"},{"gid":2724,"id":346337062,"exchange":"binance","price":9165,"quantity":0.005623,"quoteQuantity":51.534795,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-25T06:24:47.641Z","fee":0.00243923,"feeCurrency":"BNB"},{"gid":2725,"id":346337063,"exchange":"binance","price":9165,"quantity":0.0013,"quoteQuantity":11.9145,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-25T06:24:47.71Z","fee":0.00056393,"feeCurrency":"BNB"},{"gid":2726,"id":346337065,"exchange":"binance","price":9165,"quantity":0.028536,"quoteQuantity":261.53244,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-25T06:24:47.774Z","fee":0.01237881,"feeCurrency":"BNB"},{"gid":2727,"id":346337066,"exchange":"binance","price":9165,"quantity":0.000004,"quoteQuantity":0.03666,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-25T06:24:47.817Z","fee":0.00000173,"feeCurrency":"BNB"},{"gid":2728,"id":346337327,"exchange":"binance","price":9169.67,"quantity":0.1,"quoteQuantity":916.967,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-25T06:25:52.579Z","fee":0.04339524,"feeCurrency":"BNB"},{"gid":2729,"id":346337331,"exchange":"binance","price":9169.64,"quantity":0.1,"quoteQuantity":916.964,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-25T06:25:57.571Z","fee":0.04339524,"feeCurrency":"BNB"},{"gid":2730,"id":346352110,"exchange":"binance","price":9154.3,"quantity":0.05,"quoteQuantity":457.715,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-25T07:29:36.834Z","fee":0.02172024,"feeCurrency":"BNB"},{"gid":2731,"id":346353546,"exchange":"binance","price":9181.89,"quantity":0.005828,"quoteQuantity":53.51205492,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-25T07:36:56.581Z","fee":0.00253374,"feeCurrency":"BNB"},{"gid":2732,"id":346353547,"exchange":"binance","price":9181.87,"quantity":0.014172,"quoteQuantity":130.12546164,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-25T07:36:56.581Z","fee":0.00616131,"feeCurrency":"BNB"},{"gid":2733,"id":346353582,"exchange":"binance","price":9182,"quantity":0.02,"quoteQuantity":183.64,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-25T07:37:09.422Z","fee":0.00869463,"feeCurrency":"BNB"},{"gid":2734,"id":346362082,"exchange":"binance","price":9223.62,"quantity":0.002357,"quoteQuantity":21.74007234,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-25T08:01:00.796Z","fee":0.00102899,"feeCurrency":"BNB"},{"gid":2735,"id":346376130,"exchange":"binance","price":9281.37,"quantity":0.01,"quoteQuantity":92.8137,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-25T08:20:36.916Z","fee":0.00435728,"feeCurrency":"BNB"},{"gid":2736,"id":346397396,"exchange":"binance","price":9301.15,"quantity":0.002837,"quoteQuantity":26.38736255,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-25T09:00:00.668Z","fee":0.00123512,"feeCurrency":"BNB"},{"gid":2737,"id":346402114,"exchange":"binance","price":9284.21,"quantity":0.002,"quoteQuantity":18.56842,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-25T09:14:33.689Z","fee":0.00086971,"feeCurrency":"BNB"},{"gid":2738,"id":346414782,"exchange":"binance","price":9261,"quantity":0.005,"quoteQuantity":46.305,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-25T09:58:48.539Z","fee":0.00217126,"feeCurrency":"BNB"},{"gid":2739,"id":346431990,"exchange":"binance","price":9260.29,"quantity":0.005,"quoteQuantity":46.30145,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-25T10:51:33.396Z","fee":0.00216549,"feeCurrency":"BNB"},{"gid":2740,"id":346454406,"exchange":"binance","price":9224.74,"quantity":0.05,"quoteQuantity":461.237,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-25T12:07:30.641Z","fee":0.02164501,"feeCurrency":"BNB"},{"gid":2741,"id":346455180,"exchange":"binance","price":9218.37,"quantity":0.01,"quoteQuantity":92.1837,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-25T12:09:16.759Z","fee":0.00433175,"feeCurrency":"BNB"},{"gid":2742,"id":346455712,"exchange":"binance","price":9218.71,"quantity":0.002,"quoteQuantity":18.43742,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-25T12:10:10.703Z","fee":0.00086745,"feeCurrency":"BNB"},{"gid":2743,"id":346456064,"exchange":"binance","price":9223,"quantity":0.007905,"quoteQuantity":72.907815,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-25T12:10:35.225Z","fee":0.00343077,"feeCurrency":"BNB"},{"gid":2744,"id":346456066,"exchange":"binance","price":9223,"quantity":0.002095,"quoteQuantity":19.322185,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-25T12:10:35.62Z","fee":0.00091082,"feeCurrency":"BNB"},{"gid":2745,"id":346457268,"exchange":"binance","price":9226.24,"quantity":0.003333,"quoteQuantity":30.75105792,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-25T12:13:14.088Z","fee":0.00144531,"feeCurrency":"BNB"},{"gid":2746,"id":346457269,"exchange":"binance","price":9226.24,"quantity":0.003333,"quoteQuantity":30.75105792,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-25T12:13:14.089Z","fee":0.00144531,"feeCurrency":"BNB"},{"gid":2747,"id":346457270,"exchange":"binance","price":9226.24,"quantity":0.003333,"quoteQuantity":30.75105792,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-25T12:13:14.089Z","fee":0.00144531,"feeCurrency":"BNB"},{"gid":2748,"id":346457271,"exchange":"binance","price":9226.24,"quantity":0.003333,"quoteQuantity":30.75105792,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-25T12:13:14.089Z","fee":0.00144531,"feeCurrency":"BNB"},{"gid":2749,"id":346457272,"exchange":"binance","price":9226.24,"quantity":0.003333,"quoteQuantity":30.75105792,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-25T12:13:14.089Z","fee":0.00144531,"feeCurrency":"BNB"},{"gid":2750,"id":346457273,"exchange":"binance","price":9226.24,"quantity":0.003333,"quoteQuantity":30.75105792,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-25T12:13:14.091Z","fee":0.00144531,"feeCurrency":"BNB"},{"gid":2751,"id":346457274,"exchange":"binance","price":9226.24,"quantity":0.000002,"quoteQuantity":0.01845248,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-25T12:13:14.091Z","fee":0.00000433,"feeCurrency":"BNB"},{"gid":2752,"id":346501051,"exchange":"binance","price":9181.84,"quantity":0.01,"quoteQuantity":91.8184,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-25T13:57:32.958Z","fee":0.00434908,"feeCurrency":"BNB"},{"gid":2753,"id":346520794,"exchange":"binance","price":9258.43,"quantity":0.0024,"quoteQuantity":22.220232,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-25T14:50:05.197Z","fee":0.00104461,"feeCurrency":"BNB"},{"gid":2754,"id":346520805,"exchange":"binance","price":9258.43,"quantity":0.005047,"quoteQuantity":46.72729621,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-25T14:50:06.567Z","fee":0.00219673,"feeCurrency":"BNB"},{"gid":2755,"id":346520806,"exchange":"binance","price":9258.43,"quantity":0.002163,"quoteQuantity":20.02598409,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-25T14:50:06.908Z","fee":0.00094146,"feeCurrency":"BNB"},{"gid":2756,"id":346520875,"exchange":"binance","price":9257.53,"quantity":0.01,"quoteQuantity":92.5753,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-25T14:50:28.43Z","fee":0.00435033,"feeCurrency":"BNB"},{"gid":2757,"id":346524208,"exchange":"binance","price":9251.01,"quantity":0.02,"quoteQuantity":185.0202,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-25T15:00:01.965Z","fee":0.00870258,"feeCurrency":"BNB"},{"gid":2758,"id":346529654,"exchange":"binance","price":9232.99,"quantity":0.02,"quoteQuantity":184.6598,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-25T15:17:12.233Z","fee":0.00868658,"feeCurrency":"BNB"},{"gid":2759,"id":346599655,"exchange":"binance","price":9254.73,"quantity":0.005,"quoteQuantity":46.27365,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-25T19:05:55.751Z","fee":0.00217896,"feeCurrency":"BNB"},{"gid":2760,"id":346603863,"exchange":"binance","price":9285.84,"quantity":0.01,"quoteQuantity":92.8584,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-25T19:20:28.275Z","fee":0.00435888,"feeCurrency":"BNB"},{"gid":2761,"id":346603892,"exchange":"binance","price":9285.97,"quantity":0.01,"quoteQuantity":92.8597,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-25T19:20:36.847Z","fee":0.00435894,"feeCurrency":"BNB"},{"gid":2762,"id":346654203,"exchange":"binance","price":9245,"quantity":0.02,"quoteQuantity":184.9,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-25T22:19:50.781Z","fee":0.00872042,"feeCurrency":"BNB"},{"gid":2763,"id":346783023,"exchange":"binance","price":9210.17,"quantity":0.01,"quoteQuantity":92.1017,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-26T06:03:05.884Z","fee":0.00438468,"feeCurrency":"BNB"},{"gid":2764,"id":346789125,"exchange":"binance","price":9232,"quantity":0.01,"quoteQuantity":92.32,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-26T06:27:52.937Z","fee":0.00438493,"feeCurrency":"BNB"},{"gid":2765,"id":346833091,"exchange":"binance","price":9131,"quantity":0.02,"quoteQuantity":182.62,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-26T07:41:29.662Z","fee":0.00876219,"feeCurrency":"BNB"},{"gid":2766,"id":346833520,"exchange":"binance","price":9132.01,"quantity":0.1,"quoteQuantity":913.201,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-26T07:42:28.625Z","fee":0.0438084,"feeCurrency":"BNB"},{"gid":2767,"id":346866760,"exchange":"binance","price":9141.75,"quantity":0.04,"quoteQuantity":365.67,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-26T08:44:15.71Z","fee":0.01748556,"feeCurrency":"BNB"},{"gid":2768,"id":346898833,"exchange":"binance","price":9222.01,"quantity":0.01,"quoteQuantity":92.2201,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-26T09:40:16.735Z","fee":0.00438637,"feeCurrency":"BNB"},{"gid":2769,"id":346900740,"exchange":"binance","price":9220.04,"quantity":0.01,"quoteQuantity":92.2004,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-26T09:41:09.993Z","fee":0.00438113,"feeCurrency":"BNB"},{"gid":2770,"id":346909392,"exchange":"binance","price":9212.66,"quantity":0.002304,"quoteQuantity":21.22596864,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-26T10:00:01.667Z","fee":0.00100923,"feeCurrency":"BNB"},{"gid":2771,"id":346940244,"exchange":"binance","price":9186.57,"quantity":0.002,"quoteQuantity":18.37314,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-26T11:30:06.664Z","fee":0.00087606,"feeCurrency":"BNB"},{"gid":2772,"id":346940458,"exchange":"binance","price":9185.6,"quantity":0.002,"quoteQuantity":18.3712,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-26T11:30:40.224Z","fee":0.00087606,"feeCurrency":"BNB"},{"gid":2773,"id":346942526,"exchange":"binance","price":9186,"quantity":0.001982,"quoteQuantity":18.206652,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-26T11:36:53.498Z","fee":0.0008672,"feeCurrency":"BNB"},{"gid":2774,"id":346942530,"exchange":"binance","price":9186,"quantity":0.000018,"quoteQuantity":0.165348,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-26T11:36:58.296Z","fee":0.00000875,"feeCurrency":"BNB"},{"gid":2775,"id":346945503,"exchange":"binance","price":9165,"quantity":0.01,"quoteQuantity":91.65,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-26T11:44:06.415Z","fee":0.00437317,"feeCurrency":"BNB"},{"gid":2776,"id":346991441,"exchange":"binance","price":9177,"quantity":0.000021,"quoteQuantity":0.192717,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-26T13:45:14.964Z","fee":0.00000872,"feeCurrency":"BNB"},{"gid":2777,"id":346991516,"exchange":"binance","price":9177,"quantity":0.009979,"quoteQuantity":91.577283,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-26T13:45:26.295Z","fee":0.00435452,"feeCurrency":"BNB"},{"gid":2778,"id":346995769,"exchange":"binance","price":9155,"quantity":0.013023,"quoteQuantity":119.225565,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-26T13:58:51.23Z","fee":0.00568194,"feeCurrency":"BNB"},{"gid":2779,"id":346995770,"exchange":"binance","price":9155,"quantity":0.006977,"quoteQuantity":63.874435,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-26T13:58:51.23Z","fee":0.00304608,"feeCurrency":"BNB"},{"gid":2780,"id":346998019,"exchange":"binance","price":9135,"quantity":0.01,"quoteQuantity":91.35,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-26T14:00:22.533Z","fee":0.00436325,"feeCurrency":"BNB"},{"gid":2781,"id":347047059,"exchange":"binance","price":9093.41,"quantity":0.01,"quoteQuantity":90.9341,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-26T14:58:50.399Z","fee":0.00434908,"feeCurrency":"BNB"},{"gid":2782,"id":347051944,"exchange":"binance","price":9157.5,"quantity":0.002085,"quoteQuantity":19.0933875,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-26T15:02:00.963Z","fee":0.000914,"feeCurrency":"BNB"},{"gid":2783,"id":347082829,"exchange":"binance","price":9125.56,"quantity":0.002,"quoteQuantity":18.25112,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-26T16:09:57.637Z","fee":0.00087305,"feeCurrency":"BNB"},{"gid":2784,"id":347258408,"exchange":"binance","price":9140.16,"quantity":0.01,"quoteQuantity":91.4016,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-27T04:33:00.616Z","fee":0.00434505,"feeCurrency":"BNB"},{"gid":2785,"id":347320068,"exchange":"binance","price":9148,"quantity":0.01,"quoteQuantity":91.48,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-27T09:51:07.222Z","fee":0.00435186,"feeCurrency":"BNB"},{"gid":2786,"id":347516483,"exchange":"binance","price":9027.93,"quantity":0.006486,"quoteQuantity":58.55515398,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-27T19:45:03.868Z","fee":0.00288,"feeCurrency":"BNB"},{"gid":2787,"id":347521450,"exchange":"binance","price":8988,"quantity":0.00672,"quoteQuantity":60.39936,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-27T19:46:02.192Z","fee":0.00298206,"feeCurrency":"BNB"},{"gid":2788,"id":347569535,"exchange":"binance","price":8931.62,"quantity":0.00751,"quoteQuantity":67.0764662,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-27T20:00:00.789Z","fee":0.00336429,"feeCurrency":"BNB"},{"gid":2789,"id":347639126,"exchange":"binance","price":9078.16,"quantity":0.001709,"quoteQuantity":15.51457544,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-27T21:57:02.251Z","fee":0.00076872,"feeCurrency":"BNB"},{"gid":2790,"id":347645531,"exchange":"binance","price":9059.09,"quantity":0.001742,"quoteQuantity":15.78093478,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-27T22:00:00.652Z","fee":0.00077475,"feeCurrency":"BNB"},{"gid":2791,"id":347689876,"exchange":"binance","price":9023.21,"quantity":0.1,"quoteQuantity":902.321,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-27T23:45:45.01Z","fee":0.04451038,"feeCurrency":"BNB"},{"gid":2792,"id":347690923,"exchange":"binance","price":9011.24,"quantity":0.1,"quoteQuantity":901.124,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-27T23:50:26.91Z","fee":0.04463223,"feeCurrency":"BNB"},{"gid":2793,"id":347691046,"exchange":"binance","price":9013.48,"quantity":0.000012,"quoteQuantity":0.10816176,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-27T23:50:54.166Z","fee":0.00000446,"feeCurrency":"BNB"},{"gid":2794,"id":347691053,"exchange":"binance","price":9014.32,"quantity":0.005248,"quoteQuantity":47.30715136,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-06-27T23:50:55.538Z","fee":0.00234318,"feeCurrency":"BNB"},{"gid":2795,"id":347761199,"exchange":"binance","price":9000.56,"quantity":0.091787,"quoteQuantity":826.13440072,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-28T07:40:36.095Z","fee":0.04099131,"feeCurrency":"BNB"},{"gid":2796,"id":347761200,"exchange":"binance","price":9000.48,"quantity":0.008213,"quoteQuantity":73.92094224,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-28T07:40:36.095Z","fee":0.00366782,"feeCurrency":"BNB"},{"gid":2797,"id":347761306,"exchange":"binance","price":9001.4,"quantity":0.1,"quoteQuantity":900.14,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-28T07:41:14.725Z","fee":0.04465406,"feeCurrency":"BNB"},{"gid":2798,"id":347761622,"exchange":"binance","price":9004.02,"quantity":0.06,"quoteQuantity":540.2412,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-28T07:43:37.18Z","fee":0.02678699,"feeCurrency":"BNB"},{"gid":2799,"id":347761771,"exchange":"binance","price":9004.06,"quantity":0.01466,"quoteQuantity":131.9995196,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-28T07:44:50.999Z","fee":0.00654464,"feeCurrency":"BNB"},{"gid":2800,"id":347761772,"exchange":"binance","price":9004.55,"quantity":0.245265,"quoteQuantity":2208.50095575,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-28T07:44:50.999Z","fee":0.10949553,"feeCurrency":"BNB"},{"gid":2801,"id":347888951,"exchange":"binance","price":9133.44,"quantity":0.002,"quoteQuantity":18.26688,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-28T14:29:26.56Z","fee":0.00089142,"feeCurrency":"BNB"},{"gid":2802,"id":347905659,"exchange":"binance","price":9153.99,"quantity":0.002077,"quoteQuantity":19.01283723,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-28T15:00:01.215Z","fee":0.00092493,"feeCurrency":"BNB"},{"gid":2803,"id":347916790,"exchange":"binance","price":9135.73,"quantity":0.002,"quoteQuantity":18.27146,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-28T15:29:58.178Z","fee":0.00088972,"feeCurrency":"BNB"},{"gid":2804,"id":348388099,"exchange":"binance","price":9121.27,"quantity":0.01,"quoteQuantity":91.2127,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-29T17:14:58.402Z","fee":0.00443461,"feeCurrency":"BNB"},{"gid":2805,"id":348388551,"exchange":"binance","price":9125,"quantity":0.060446,"quoteQuantity":551.56975,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-29T17:17:20.996Z","fee":0.02689038,"feeCurrency":"BNB"},{"gid":2806,"id":348388554,"exchange":"binance","price":9125,"quantity":0.039554,"quoteQuantity":360.93025,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-29T17:17:21.299Z","fee":0.01759624,"feeCurrency":"BNB"},{"gid":2807,"id":348388583,"exchange":"binance","price":9126.1,"quantity":0.019068,"quoteQuantity":174.0164748,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-29T17:17:35.505Z","fee":0.00848373,"feeCurrency":"BNB"},{"gid":2808,"id":348388584,"exchange":"binance","price":9126,"quantity":0.00125,"quoteQuantity":11.4075,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-29T17:17:35.505Z","fee":0.00055614,"feeCurrency":"BNB"},{"gid":2809,"id":348388602,"exchange":"binance","price":9126,"quantity":0.021653,"quoteQuantity":197.605278,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-29T17:17:37.153Z","fee":0.00963374,"feeCurrency":"BNB"},{"gid":2810,"id":348388603,"exchange":"binance","price":9126,"quantity":0.058029,"quoteQuantity":529.572654,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-29T17:17:37.472Z","fee":0.02581797,"feeCurrency":"BNB"},{"gid":2811,"id":348389974,"exchange":"binance","price":9134.7,"quantity":0.02,"quoteQuantity":182.694,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-29T17:21:01.674Z","fee":0.00889972,"feeCurrency":"BNB"},{"gid":2812,"id":348390353,"exchange":"binance","price":9138.18,"quantity":0.00526,"quoteQuantity":48.0668268,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-29T17:22:13.428Z","fee":0.00234166,"feeCurrency":"BNB"},{"gid":2813,"id":348447762,"exchange":"binance","price":9200,"quantity":0.01,"quoteQuantity":92,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-06-29T21:02:07.773Z","fee":0.00445479,"feeCurrency":"BNB"},{"gid":2814,"id":348534188,"exchange":"binance","price":9175.27,"quantity":0.01,"quoteQuantity":91.7527,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-06-30T02:51:19.315Z","fee":0.00444014,"feeCurrency":"BNB"},{"gid":2815,"id":348615122,"exchange":"binance","price":9150,"quantity":0.01,"quoteQuantity":91.5,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-06-30T09:19:02.588Z","fee":0.00444892,"feeCurrency":"BNB"},{"gid":2816,"id":348841875,"exchange":"binance","price":9082,"quantity":0.1,"quoteQuantity":908.2,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-07-01T00:39:33.356Z","fee":0.04446024,"feeCurrency":"BNB"},{"gid":2817,"id":348850931,"exchange":"binance","price":9112.8,"quantity":0.1,"quoteQuantity":911.28,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-01T01:22:03.777Z","fee":0.04437076,"feeCurrency":"BNB"},{"gid":2818,"id":349026270,"exchange":"binance","price":9242.4,"quantity":0.000007,"quoteQuantity":0.0646968,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-01T13:45:13.803Z","fee":0.00000306,"feeCurrency":"BNB"},{"gid":2819,"id":349026271,"exchange":"binance","price":9242.24,"quantity":0.009993,"quoteQuantity":92.35770432,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-01T13:45:13.803Z","fee":0.00437952,"feeCurrency":"BNB"},{"gid":2820,"id":349054565,"exchange":"binance","price":9217.36,"quantity":0.1,"quoteQuantity":921.736,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-01T14:56:37.757Z","fee":0.04403121,"feeCurrency":"BNB"},{"gid":2821,"id":349446653,"exchange":"binance","price":9176.34,"quantity":0.01,"quoteQuantity":91.7634,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-02T15:02:34.586Z","fee":0.00438442,"feeCurrency":"BNB"},{"gid":2822,"id":349446906,"exchange":"binance","price":9177.86,"quantity":0.01,"quoteQuantity":91.7786,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-02T15:02:51.509Z","fee":0.00438442,"feeCurrency":"BNB"},{"gid":2823,"id":349454379,"exchange":"binance","price":9093.47,"quantity":0.006127,"quoteQuantity":55.71569069,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-02T15:05:01.419Z","fee":0.0026889,"feeCurrency":"BNB"},{"gid":2824,"id":349462996,"exchange":"binance","price":9075,"quantity":0.1,"quoteQuantity":907.5,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-07-02T15:06:03.99Z","fee":0.04383913,"feeCurrency":"BNB"},{"gid":2825,"id":349502824,"exchange":"binance","price":9048.79,"quantity":0.006711,"quoteQuantity":60.72642969,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-02T16:00:00.779Z","fee":0.00295977,"feeCurrency":"BNB"},{"gid":2826,"id":349509851,"exchange":"binance","price":9046.16,"quantity":0.1,"quoteQuantity":904.616,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-07-02T16:06:56.596Z","fee":0.04422169,"feeCurrency":"BNB"},{"gid":2827,"id":349529394,"exchange":"binance","price":8980.33,"quantity":0.004452,"quoteQuantity":39.98042916,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-02T16:39:00.534Z","fee":0.00196716,"feeCurrency":"BNB"},{"gid":2828,"id":349529395,"exchange":"binance","price":8980.34,"quantity":0.002394,"quoteQuantity":21.49893396,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-02T16:39:00.534Z","fee":0.00105651,"feeCurrency":"BNB"},{"gid":2829,"id":349538191,"exchange":"binance","price":8972.35,"quantity":0.1,"quoteQuantity":897.235,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-02T16:42:57.47Z","fee":0.0445183,"feeCurrency":"BNB"},{"gid":2830,"id":349542986,"exchange":"binance","price":8987.21,"quantity":0.013267,"quoteQuantity":119.23331507,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-02T16:48:43.183Z","fee":0.00588005,"feeCurrency":"BNB"},{"gid":2831,"id":349542987,"exchange":"binance","price":8987.21,"quantity":0.086733,"quoteQuantity":779.48768493,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-02T16:48:43.183Z","fee":0.03844084,"feeCurrency":"BNB"},{"gid":2832,"id":349723709,"exchange":"binance","price":9097.63,"quantity":0.1,"quoteQuantity":909.763,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-03T04:07:57.131Z","fee":0.04435367,"feeCurrency":"BNB"},{"gid":2833,"id":349723758,"exchange":"binance","price":9098.03,"quantity":0.02,"quoteQuantity":181.9606,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-03T04:08:34.052Z","fee":0.00887322,"feeCurrency":"BNB"},{"gid":2834,"id":349837294,"exchange":"binance","price":9077.45,"quantity":0.01,"quoteQuantity":90.7745,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-03T11:14:13.954Z","fee":0.00444496,"feeCurrency":"BNB"},{"gid":2835,"id":350164292,"exchange":"binance","price":9051.61,"quantity":0.01,"quoteQuantity":90.5161,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-07-04T12:04:23.739Z","fee":0.00441565,"feeCurrency":"BNB"},{"gid":2836,"id":350198154,"exchange":"binance","price":9074.34,"quantity":0.01,"quoteQuantity":90.7434,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-04T14:05:50.133Z","fee":0.00441696,"feeCurrency":"BNB"},{"gid":2837,"id":350248727,"exchange":"binance","price":9157.77,"quantity":0.002035,"quoteQuantity":18.63606195,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-04T17:35:00.902Z","fee":0.00090569,"feeCurrency":"BNB"},{"gid":2838,"id":350251240,"exchange":"binance","price":9165.04,"quantity":0.01,"quoteQuantity":91.6504,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-04T17:36:04.339Z","fee":0.00442229,"feeCurrency":"BNB"},{"gid":2839,"id":350252582,"exchange":"binance","price":9153.76,"quantity":0.003597,"quoteQuantity":32.92607472,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-04T17:36:57.846Z","fee":0.00158874,"feeCurrency":"BNB"},{"gid":2840,"id":350252583,"exchange":"binance","price":9153.75,"quantity":0.006403,"quoteQuantity":58.61146125,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-04T17:36:57.846Z","fee":0.0028281,"feeCurrency":"BNB"},{"gid":2841,"id":350268518,"exchange":"binance","price":9172.5,"quantity":0.002139,"quoteQuantity":19.6199775,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-04T18:00:01.346Z","fee":0.00094461,"feeCurrency":"BNB"},{"gid":2842,"id":350268622,"exchange":"binance","price":9180,"quantity":0.005143,"quoteQuantity":47.21274,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-07-04T18:00:03.308Z","fee":0.00227307,"feeCurrency":"BNB"},{"gid":2843,"id":350268623,"exchange":"binance","price":9180,"quantity":0.004857,"quoteQuantity":44.58726,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-07-04T18:00:03.309Z","fee":0.00214667,"feeCurrency":"BNB"},{"gid":2844,"id":350372159,"exchange":"binance","price":9125.25,"quantity":0.008951,"quoteQuantity":81.68011275,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-05T01:33:15.526Z","fee":0.00394403,"feeCurrency":"BNB"},{"gid":2845,"id":350372160,"exchange":"binance","price":9125.05,"quantity":0.011049,"quoteQuantity":100.82267745,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-05T01:33:15.526Z","fee":0.00486835,"feeCurrency":"BNB"},{"gid":2846,"id":350513242,"exchange":"binance","price":9028.1,"quantity":0.02,"quoteQuantity":180.562,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-05T10:16:11.606Z","fee":0.00883236,"feeCurrency":"BNB"},{"gid":2847,"id":350513275,"exchange":"binance","price":9028.48,"quantity":0.02,"quoteQuantity":180.5696,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-05T10:16:22.766Z","fee":0.00883236,"feeCurrency":"BNB"},{"gid":2848,"id":350618232,"exchange":"binance","price":9029.29,"quantity":0.01,"quoteQuantity":90.2929,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-05T17:05:18.751Z","fee":0.00440943,"feeCurrency":"BNB"},{"gid":2849,"id":350618611,"exchange":"binance","price":9027,"quantity":0.01,"quoteQuantity":90.27,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-07-05T17:07:25.883Z","fee":0.00440761,"feeCurrency":"BNB"},{"gid":2850,"id":350772953,"exchange":"binance","price":9099.59,"quantity":0.02,"quoteQuantity":181.9918,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-06T02:27:02.767Z","fee":0.00875504,"feeCurrency":"BNB"},{"gid":2851,"id":350773086,"exchange":"binance","price":9099.03,"quantity":0.005472,"quoteQuantity":49.78989216,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-07-06T02:27:59.773Z","fee":0.00239523,"feeCurrency":"BNB"},{"gid":2852,"id":350773087,"exchange":"binance","price":9099.03,"quantity":0.014528,"quoteQuantity":132.19070784,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-07-06T02:28:03.275Z","fee":0.00635817,"feeCurrency":"BNB"},{"gid":2853,"id":350838150,"exchange":"binance","price":9213.93,"quantity":0.065605,"quoteQuantity":604.47987765,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-06T06:22:05.468Z","fee":0.02881323,"feeCurrency":"BNB"},{"gid":2854,"id":350838151,"exchange":"binance","price":9213.83,"quantity":0.034395,"quoteQuantity":316.90968285,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-06T06:22:05.468Z","fee":0.01510586,"feeCurrency":"BNB"},{"gid":2855,"id":350839939,"exchange":"binance","price":9217.64,"quantity":0.1,"quoteQuantity":921.764,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-06T06:22:41.507Z","fee":0.04393694,"feeCurrency":"BNB"},{"gid":2856,"id":350841358,"exchange":"binance","price":9200.61,"quantity":0.1,"quoteQuantity":920.061,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-06T06:23:20.497Z","fee":0.04400337,"feeCurrency":"BNB"},{"gid":2857,"id":350868308,"exchange":"binance","price":9226.75,"quantity":0.002415,"quoteQuantity":22.28260125,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-06T07:00:01.565Z","fee":0.0010623,"feeCurrency":"BNB"},{"gid":2858,"id":350943370,"exchange":"binance","price":9186.99,"quantity":0.01,"quoteQuantity":91.8699,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-06T10:38:09.143Z","fee":0.00439673,"feeCurrency":"BNB"},{"gid":2859,"id":350985426,"exchange":"binance","price":9206.69,"quantity":0.1,"quoteQuantity":920.669,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-06T12:28:42.38Z","fee":0.0439358,"feeCurrency":"BNB"},{"gid":2860,"id":350985747,"exchange":"binance","price":9208.64,"quantity":0.007905,"quoteQuantity":72.7942992,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-06T12:29:42.204Z","fee":0.00347436,"feeCurrency":"BNB"},{"gid":2861,"id":351084764,"exchange":"binance","price":9306.28,"quantity":0.1,"quoteQuantity":930.628,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-06T15:50:41.778Z","fee":0.04431258,"feeCurrency":"BNB"},{"gid":2862,"id":351270752,"exchange":"binance","price":9339.31,"quantity":0.00339,"quoteQuantity":31.6602609,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-07T00:00:00.599Z","fee":0.00145636,"feeCurrency":"BNB"},{"gid":2863,"id":351340461,"exchange":"binance","price":9311.47,"quantity":0.01,"quoteQuantity":93.1147,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-07T03:24:00.195Z","fee":0.00431685,"feeCurrency":"BNB"},{"gid":2864,"id":351341586,"exchange":"binance","price":9304.34,"quantity":0.01,"quoteQuantity":93.0434,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-07T03:28:39.711Z","fee":0.00431315,"feeCurrency":"BNB"},{"gid":2865,"id":351351917,"exchange":"binance","price":9284.04,"quantity":0.07,"quoteQuantity":649.8828,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-07T04:16:04.737Z","fee":0.03011526,"feeCurrency":"BNB"},{"gid":2866,"id":351354035,"exchange":"binance","price":9300,"quantity":0.01,"quoteQuantity":93,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-07-07T04:22:57.796Z","fee":0.00430308,"feeCurrency":"BNB"},{"gid":2867,"id":351354295,"exchange":"binance","price":9301.6,"quantity":0.01,"quoteQuantity":93.016,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-07T04:23:49.523Z","fee":0.00430308,"feeCurrency":"BNB"},{"gid":2868,"id":351354559,"exchange":"binance","price":9300,"quantity":0.029999,"quoteQuantity":278.9907,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-07-07T04:25:04.1Z","fee":0.01290358,"feeCurrency":"BNB"},{"gid":2869,"id":351354560,"exchange":"binance","price":9300,"quantity":0.000001,"quoteQuantity":0.0093,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-07-07T04:25:04.23Z","fee":4.2e-7,"feeCurrency":"BNB"},{"gid":2870,"id":351371438,"exchange":"binance","price":9281.25,"quantity":0.000021,"quoteQuantity":0.19490625,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-07T05:37:24.366Z","fee":0.000009,"feeCurrency":"BNB"},{"gid":2871,"id":351371543,"exchange":"binance","price":9281.25,"quantity":0.049979,"quoteQuantity":463.86759375,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-07-07T05:37:34.43Z","fee":0.02144789,"feeCurrency":"BNB"},{"gid":2872,"id":351417228,"exchange":"binance","price":9253.6,"quantity":0.00876,"quoteQuantity":81.061536,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-07T07:49:17.108Z","fee":0.00377282,"feeCurrency":"BNB"},{"gid":2873,"id":351417229,"exchange":"binance","price":9253.6,"quantity":0.00124,"quoteQuantity":11.474464,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-07T07:49:17.108Z","fee":0.00053405,"feeCurrency":"BNB"},{"gid":2874,"id":351417343,"exchange":"binance","price":9253.97,"quantity":0.01,"quoteQuantity":92.5397,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-07T07:49:48.714Z","fee":0.00430687,"feeCurrency":"BNB"},{"gid":2875,"id":351475681,"exchange":"binance","price":9241.1,"quantity":0.002524,"quoteQuantity":23.3245364,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-07T11:28:02.925Z","fee":0.00106376,"feeCurrency":"BNB"},{"gid":2876,"id":351475682,"exchange":"binance","price":9241.1,"quantity":0.001254,"quoteQuantity":11.5883394,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-07-07T11:28:03.585Z","fee":0.00052766,"feeCurrency":"BNB"},{"gid":2877,"id":351475722,"exchange":"binance","price":9241.1,"quantity":0.006222,"quoteQuantity":57.4981242,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-07-07T11:28:20.918Z","fee":0.00262565,"feeCurrency":"BNB"},{"gid":2878,"id":351476901,"exchange":"binance","price":9246.95,"quantity":0.02,"quoteQuantity":184.939,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-07T11:32:41.78Z","fee":0.00846309,"feeCurrency":"BNB"},{"gid":2879,"id":351477195,"exchange":"binance","price":9248.49,"quantity":0.002167,"quoteQuantity":20.04147783,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-07T11:33:33.426Z","fee":0.0009172,"feeCurrency":"BNB"},{"gid":2880,"id":351477196,"exchange":"binance","price":9248.5,"quantity":0.047833,"quoteQuantity":442.3835005,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-07T11:33:33.426Z","fee":0.02021669,"feeCurrency":"BNB"},{"gid":2881,"id":351486654,"exchange":"binance","price":9274.64,"quantity":0.01,"quoteQuantity":92.7464,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-07T12:03:49.342Z","fee":0.00421158,"feeCurrency":"BNB"},{"gid":2882,"id":352091852,"exchange":"binance","price":9449.99,"quantity":0.05,"quoteQuantity":472.4995,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-08T13:51:43.009Z","fee":0.02039214,"feeCurrency":"BNB"},{"gid":2883,"id":352093814,"exchange":"binance","price":9436.58,"quantity":0.003549,"quoteQuantity":33.49042242,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-08T13:52:00.513Z","fee":0.00144537,"feeCurrency":"BNB"},{"gid":2884,"id":352100936,"exchange":"binance","price":9421.46,"quantity":0.007961,"quoteQuantity":75.00424306,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-08T13:55:26.746Z","fee":0.00323661,"feeCurrency":"BNB"},{"gid":2885,"id":352100937,"exchange":"binance","price":9421.46,"quantity":0.002039,"quoteQuantity":19.21035694,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-08T13:55:26.746Z","fee":0.00082897,"feeCurrency":"BNB"},{"gid":2886,"id":352107445,"exchange":"binance","price":9442.29,"quantity":0.00376,"quoteQuantity":35.5030104,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-08T14:00:00.703Z","fee":0.00153527,"feeCurrency":"BNB"},{"gid":2887,"id":352186893,"exchange":"binance","price":9391.85,"quantity":0.02,"quoteQuantity":187.837,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-07-08T16:09:50.054Z","fee":0.00813757,"feeCurrency":"BNB"},{"gid":2888,"id":352430212,"exchange":"binance","price":9374.09,"quantity":0.06,"quoteQuantity":562.4454,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-09T02:47:13.927Z","fee":0.02510809,"feeCurrency":"BNB"},{"gid":2889,"id":352439181,"exchange":"binance","price":9380.36,"quantity":0.001054,"quoteQuantity":9.88689944,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-09T03:07:49.512Z","fee":0.00043908,"feeCurrency":"BNB"},{"gid":2890,"id":352439182,"exchange":"binance","price":9380.29,"quantity":0.018946,"quoteQuantity":177.71897434,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-09T03:07:49.512Z","fee":0.00789266,"feeCurrency":"BNB"},{"gid":2891,"id":352457549,"exchange":"binance","price":9403.49,"quantity":0.02,"quoteQuantity":188.0698,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-09T04:17:25.222Z","fee":0.00831729,"feeCurrency":"BNB"},{"gid":2892,"id":352667717,"exchange":"binance","price":9348.31,"quantity":0.01,"quoteQuantity":93.4831,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-09T14:22:32.818Z","fee":0.00413496,"feeCurrency":"BNB"},{"gid":2893,"id":352693295,"exchange":"binance","price":9279.81,"quantity":0.005005,"quoteQuantity":46.44544905,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-09T14:56:01.389Z","fee":0.00207918,"feeCurrency":"BNB"},{"gid":2894,"id":352695129,"exchange":"binance","price":9297.29,"quantity":0.01,"quoteQuantity":92.9729,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-09T14:56:46.95Z","fee":0.00415696,"feeCurrency":"BNB"},{"gid":2895,"id":352698781,"exchange":"binance","price":9288.94,"quantity":0.005082,"quoteQuantity":47.20639308,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-09T15:00:01.807Z","fee":0.00211138,"feeCurrency":"BNB"},{"gid":2896,"id":352705212,"exchange":"binance","price":9243.95,"quantity":0.005214,"quoteQuantity":48.1979553,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-09T15:01:00.853Z","fee":0.00216481,"feeCurrency":"BNB"},{"gid":2897,"id":352708593,"exchange":"binance","price":9263.11,"quantity":0.01,"quoteQuantity":92.6311,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-09T15:02:41.987Z","fee":0.00418386,"feeCurrency":"BNB"},{"gid":2898,"id":352708926,"exchange":"binance","price":9261.65,"quantity":0.001511,"quoteQuantity":13.99435315,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-09T15:02:57.743Z","fee":0.00063176,"feeCurrency":"BNB"},{"gid":2899,"id":352708927,"exchange":"binance","price":9261.65,"quantity":0.008489,"quoteQuantity":78.62214685,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-09T15:02:57.743Z","fee":0.00355209,"feeCurrency":"BNB"},{"gid":2900,"id":352709792,"exchange":"binance","price":9257.81,"quantity":0.01,"quoteQuantity":92.5781,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-09T15:03:42.348Z","fee":0.00417827,"feeCurrency":"BNB"},{"gid":2901,"id":352736412,"exchange":"binance","price":9203.49,"quantity":0.01,"quoteQuantity":92.0349,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-09T15:21:41.475Z","fee":0.0041565,"feeCurrency":"BNB"},{"gid":2902,"id":352740844,"exchange":"binance","price":9174.67,"quantity":0.01,"quoteQuantity":91.7467,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-09T15:23:01.447Z","fee":0.00416342,"feeCurrency":"BNB"},{"gid":2903,"id":352744758,"exchange":"binance","price":9189.9,"quantity":0.01,"quoteQuantity":91.899,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-09T15:24:36.472Z","fee":0.00416781,"feeCurrency":"BNB"},{"gid":2904,"id":352770588,"exchange":"binance","price":9218.55,"quantity":0.01,"quoteQuantity":92.1855,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-09T15:51:55.428Z","fee":0.00417292,"feeCurrency":"BNB"},{"gid":2905,"id":352774986,"exchange":"binance","price":9208.51,"quantity":0.005505,"quoteQuantity":50.69284755,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-09T16:00:00.724Z","fee":0.00230505,"feeCurrency":"BNB"},{"gid":2906,"id":352794629,"exchange":"binance","price":9207.67,"quantity":0.005,"quoteQuantity":46.03835,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-09T16:39:29.125Z","fee":0.0020779,"feeCurrency":"BNB"},{"gid":2907,"id":353227532,"exchange":"binance","price":9173.52,"quantity":0.01,"quoteQuantity":91.7352,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-10T12:38:19.687Z","fee":0.00413587,"feeCurrency":"BNB"},{"gid":2908,"id":353227632,"exchange":"binance","price":9172,"quantity":0.01,"quoteQuantity":91.72,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-07-10T12:38:30.809Z","fee":0.00413587,"feeCurrency":"BNB"},{"gid":2909,"id":353247464,"exchange":"binance","price":9171.01,"quantity":0.01,"quoteQuantity":91.7101,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-10T13:35:50.342Z","fee":0.00413086,"feeCurrency":"BNB"},{"gid":2910,"id":353262686,"exchange":"binance","price":9179.57,"quantity":0.01,"quoteQuantity":91.7957,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-10T14:15:47.524Z","fee":0.00412995,"feeCurrency":"BNB"},{"gid":2911,"id":353301160,"exchange":"binance","price":9240.9,"quantity":0.03,"quoteQuantity":277.227,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-07-10T15:47:49.335Z","fee":0.01223946,"feeCurrency":"BNB"},{"gid":2912,"id":353301747,"exchange":"binance","price":9240.9,"quantity":0.01,"quoteQuantity":92.409,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-07-10T15:49:10.378Z","fee":0.00407982,"feeCurrency":"BNB"},{"gid":2913,"id":353336816,"exchange":"binance","price":9212.92,"quantity":0.01,"quoteQuantity":92.1292,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-10T17:12:12.917Z","fee":0.00405098,"feeCurrency":"BNB"},{"gid":2914,"id":353538050,"exchange":"binance","price":9270.95,"quantity":0.05,"quoteQuantity":463.5475,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-11T04:43:46.556Z","fee":0.01998932,"feeCurrency":"BNB"},{"gid":2915,"id":353545715,"exchange":"binance","price":9265.79,"quantity":0.005,"quoteQuantity":46.32895,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-11T05:14:25.734Z","fee":0.00200202,"feeCurrency":"BNB"},{"gid":2916,"id":353545716,"exchange":"binance","price":9265.96,"quantity":0.015,"quoteQuantity":138.9894,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-11T05:14:25.734Z","fee":0.00600608,"feeCurrency":"BNB"},{"gid":2917,"id":353590961,"exchange":"binance","price":9260.59,"quantity":0.01,"quoteQuantity":92.6059,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-11T08:12:03.015Z","fee":0.00401972,"feeCurrency":"BNB"},{"gid":2918,"id":353597328,"exchange":"binance","price":9263.85,"quantity":0.01,"quoteQuantity":92.6385,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-11T08:41:59.887Z","fee":0.00401241,"feeCurrency":"BNB"},{"gid":2919,"id":353642253,"exchange":"binance","price":9218.85,"quantity":0.01,"quoteQuantity":92.1885,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-11T10:20:12.593Z","fee":0.00401649,"feeCurrency":"BNB"},{"gid":2920,"id":354093605,"exchange":"binance","price":9335.9,"quantity":0.002141,"quoteQuantity":19.9881619,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-12T13:20:02.216Z","fee":0.00081018,"feeCurrency":"BNB"},{"gid":2921,"id":354093606,"exchange":"binance","price":9335.89,"quantity":0.011716,"quoteQuantity":109.37928724,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-12T13:20:02.216Z","fee":0.00443353,"feeCurrency":"BNB"},{"gid":2922,"id":354105587,"exchange":"binance","price":9231,"quantity":0.05363,"quoteQuantity":495.05853,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-12T13:27:01.654Z","fee":0.02029901,"feeCurrency":"BNB"},{"gid":2923,"id":354147909,"exchange":"binance","price":9210,"quantity":0.054984,"quoteQuantity":506.40264,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-12T14:00:01.434Z","fee":0.02092934,"feeCurrency":"BNB"},{"gid":2924,"id":354157376,"exchange":"binance","price":9217.3,"quantity":0.000025,"quoteQuantity":0.2304325,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-12T14:17:13.124Z","fee":0.00000953,"feeCurrency":"BNB"},{"gid":2925,"id":354157377,"exchange":"binance","price":9217.21,"quantity":0.005,"quoteQuantity":46.08605,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-12T14:17:13.124Z","fee":0.0019067,"feeCurrency":"BNB"},{"gid":2926,"id":354157384,"exchange":"binance","price":9217,"quantity":0.004975,"quoteQuantity":45.854575,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-07-12T14:17:15.599Z","fee":0.00189712,"feeCurrency":"BNB"},{"gid":2927,"id":354390415,"exchange":"binance","price":9284.15,"quantity":0.05,"quoteQuantity":464.2075,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-13T01:11:33.625Z","fee":0.01925358,"feeCurrency":"BNB"},{"gid":2928,"id":354399256,"exchange":"binance","price":9288.43,"quantity":0.01,"quoteQuantity":92.8843,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-13T01:33:25.327Z","fee":0.00388119,"feeCurrency":"BNB"},{"gid":2929,"id":354399346,"exchange":"binance","price":9289.78,"quantity":0.01,"quoteQuantity":92.8978,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-13T01:33:36.025Z","fee":0.00388176,"feeCurrency":"BNB"},{"gid":2930,"id":354602028,"exchange":"binance","price":9269.99,"quantity":0.1,"quoteQuantity":926.999,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-07-13T10:19:01.353Z","fee":0.926999,"feeCurrency":"USDT"},{"gid":2931,"id":354658141,"exchange":"binance","price":9295.04,"quantity":0.004425,"quoteQuantity":41.130552,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-13T12:02:01.591Z","fee":0.00165306,"feeCurrency":"BNB"},{"gid":2932,"id":354658142,"exchange":"binance","price":9295.71,"quantity":0.013649,"quoteQuantity":126.87714579,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-13T12:02:01.591Z","fee":0.00509353,"feeCurrency":"BNB"},{"gid":2933,"id":354728973,"exchange":"binance","price":9322.62,"quantity":0.015,"quoteQuantity":139.8393,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-13T14:40:53.213Z","fee":0.0055656,"feeCurrency":"BNB"},{"gid":2934,"id":354728974,"exchange":"binance","price":9322.62,"quantity":0.005,"quoteQuantity":46.6131,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-13T14:40:53.213Z","fee":0.0018552,"feeCurrency":"BNB"},{"gid":2935,"id":355116579,"exchange":"binance","price":9188.94,"quantity":0.01,"quoteQuantity":91.8894,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-14T04:55:16.478Z","fee":0.00377795,"feeCurrency":"BNB"},{"gid":2936,"id":355116608,"exchange":"binance","price":9188.89,"quantity":0.01,"quoteQuantity":91.8889,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-14T04:55:23.391Z","fee":0.00377795,"feeCurrency":"BNB"},{"gid":2937,"id":355267932,"exchange":"binance","price":9120.48,"quantity":0.002532,"quoteQuantity":23.09305536,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-14T11:23:00.556Z","fee":0.00096144,"feeCurrency":"BNB"},{"gid":2938,"id":355267933,"exchange":"binance","price":9121.34,"quantity":0.020748,"quoteQuantity":189.24956232,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-14T11:23:00.556Z","fee":0.00788533,"feeCurrency":"BNB"},{"gid":2939,"id":355275518,"exchange":"binance","price":9206.73,"quantity":0.004604,"quoteQuantity":42.38778492,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-14T11:26:00.644Z","fee":0.00176976,"feeCurrency":"BNB"},{"gid":2940,"id":355345375,"exchange":"binance","price":9209.54,"quantity":0.008898,"quoteQuantity":81.94648692,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-14T13:23:32.657Z","fee":0.00340836,"feeCurrency":"BNB"},{"gid":2941,"id":355345376,"exchange":"binance","price":9209.4,"quantity":0.001102,"quoteQuantity":10.1487588,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-14T13:23:32.657Z","fee":0.0004221,"feeCurrency":"BNB"},{"gid":2942,"id":356368622,"exchange":"binance","price":9130.31,"quantity":0.1,"quoteQuantity":913.031,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-16T07:31:30.592Z","fee":0.04081188,"feeCurrency":"BNB"},{"gid":2943,"id":356405581,"exchange":"binance","price":9120.39,"quantity":0.02,"quoteQuantity":182.4078,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-16T08:31:13.436Z","fee":0.00810766,"feeCurrency":"BNB"},{"gid":2944,"id":356432876,"exchange":"binance","price":9082,"quantity":0.01,"quoteQuantity":90.82,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-07-16T09:14:37.115Z","fee":0.00407896,"feeCurrency":"BNB"},{"gid":2945,"id":356437464,"exchange":"binance","price":9101.68,"quantity":0.003662,"quoteQuantity":33.33035216,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-16T09:16:00.53Z","fee":0.0015016,"feeCurrency":"BNB"},{"gid":2946,"id":356439081,"exchange":"binance","price":9105,"quantity":0.01,"quoteQuantity":91.05,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-16T09:17:26.013Z","fee":0.00407985,"feeCurrency":"BNB"},{"gid":2947,"id":356439214,"exchange":"binance","price":9106.29,"quantity":0.01,"quoteQuantity":91.0629,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-07-16T09:17:37.037Z","fee":0.00407985,"feeCurrency":"BNB"},{"gid":2948,"id":356439278,"exchange":"binance","price":9106.43,"quantity":0.01,"quoteQuantity":91.0643,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-16T09:17:45.755Z","fee":0.00407985,"feeCurrency":"BNB"},{"gid":2949,"id":356439329,"exchange":"binance","price":9107,"quantity":0.01,"quoteQuantity":91.07,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-16T09:17:53.955Z","fee":0.00407985,"feeCurrency":"BNB"},{"gid":2950,"id":356439565,"exchange":"binance","price":9105,"quantity":0.01,"quoteQuantity":91.05,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-16T09:18:13.064Z","fee":0.00407497,"feeCurrency":"BNB"},{"gid":2951,"id":356465692,"exchange":"binance","price":9079.1,"quantity":0.02,"quoteQuantity":181.582,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-16T09:52:18.707Z","fee":0.00815261,"feeCurrency":"BNB"},{"gid":2952,"id":356585631,"exchange":"binance","price":9082.86,"quantity":0.009621,"quoteQuantity":87.38619606,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-16T13:33:41.474Z","fee":0.00388153,"feeCurrency":"BNB"},{"gid":2953,"id":356585632,"exchange":"binance","price":9082.89,"quantity":0.000379,"quoteQuantity":3.44241531,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-16T13:33:41.474Z","fee":0.00015332,"feeCurrency":"BNB"},{"gid":2954,"id":357101817,"exchange":"binance","price":9169.54,"quantity":0.02,"quoteQuantity":183.3908,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-17T12:34:42.19Z","fee":0.00791372,"feeCurrency":"BNB"},{"gid":2955,"id":357174294,"exchange":"binance","price":9148.87,"quantity":0.02,"quoteQuantity":182.9774,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-17T15:45:22.731Z","fee":0.00790305,"feeCurrency":"BNB"},{"gid":2956,"id":357608783,"exchange":"binance","price":9169.29,"quantity":0.04,"quoteQuantity":366.7716,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-18T15:56:38.565Z","fee":0.01610913,"feeCurrency":"BNB"},{"gid":2957,"id":357616298,"exchange":"binance","price":9161.57,"quantity":0.01,"quoteQuantity":91.6157,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-18T16:17:58.138Z","fee":0.00401907,"feeCurrency":"BNB"},{"gid":2958,"id":357689246,"exchange":"binance","price":9152,"quantity":0.01,"quoteQuantity":91.52,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-07-18T19:50:51.479Z","fee":0.00400491,"feeCurrency":"BNB"},{"gid":2959,"id":357797019,"exchange":"binance","price":9142,"quantity":0.0033,"quoteQuantity":30.1686,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-07-19T03:17:36.821Z","fee":0.0013353,"feeCurrency":"BNB"},{"gid":2960,"id":357797020,"exchange":"binance","price":9142,"quantity":0.002555,"quoteQuantity":23.35781,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-07-19T03:17:36.827Z","fee":0.00103587,"feeCurrency":"BNB"},{"gid":2961,"id":357797669,"exchange":"binance","price":9142,"quantity":0.004145,"quoteQuantity":37.89359,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-07-19T03:18:41.928Z","fee":0.00167925,"feeCurrency":"BNB"},{"gid":2962,"id":357895737,"exchange":"binance","price":9122,"quantity":0.01,"quoteQuantity":91.22,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-07-19T10:15:32.977Z","fee":0.00379113,"feeCurrency":"BNB"},{"gid":2963,"id":357896546,"exchange":"binance","price":9112,"quantity":0.01,"quoteQuantity":91.12,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-07-19T10:15:36.516Z","fee":0.00379113,"feeCurrency":"BNB"},{"gid":2964,"id":357897568,"exchange":"binance","price":9119.22,"quantity":0.009648,"quoteQuantity":87.98223456,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-19T10:16:00.614Z","fee":0.00365844,"feeCurrency":"BNB"},{"gid":2965,"id":357897569,"exchange":"binance","price":9119.22,"quantity":0.012346,"quoteQuantity":112.58589012,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-19T10:16:00.614Z","fee":0.00468204,"feeCurrency":"BNB"},{"gid":2966,"id":357931118,"exchange":"binance","price":9112.12,"quantity":0.01,"quoteQuantity":91.1212,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-07-19T11:08:01.452Z","fee":0.00382945,"feeCurrency":"BNB"},{"gid":2967,"id":358154333,"exchange":"binance","price":9189.03,"quantity":0.02,"quoteQuantity":183.7806,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-19T22:50:57.555Z","fee":0.00770565,"feeCurrency":"BNB"},{"gid":2968,"id":358154448,"exchange":"binance","price":9189.03,"quantity":0.02,"quoteQuantity":183.7806,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-07-19T22:51:12.008Z","fee":0.00770803,"feeCurrency":"BNB"},{"gid":2969,"id":358156266,"exchange":"binance","price":9189.01,"quantity":0.01,"quoteQuantity":91.8901,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-07-19T22:56:15.135Z","fee":0.00385054,"feeCurrency":"BNB"},{"gid":2970,"id":358156372,"exchange":"binance","price":9186.74,"quantity":0.01,"quoteQuantity":91.8674,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-19T22:56:33.206Z","fee":0.00384959,"feeCurrency":"BNB"},{"gid":2971,"id":358164841,"exchange":"binance","price":9222.96,"quantity":0.004378,"quoteQuantity":40.37811888,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-19T23:11:00.591Z","fee":0.00168504,"feeCurrency":"BNB"},{"gid":2972,"id":358191967,"exchange":"binance","price":9215.45,"quantity":0.01,"quoteQuantity":92.1545,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-20T00:02:08.293Z","fee":0.00382951,"feeCurrency":"BNB"},{"gid":2973,"id":358236167,"exchange":"binance","price":9177.96,"quantity":0.000023,"quoteQuantity":0.21109308,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-20T02:10:35.001Z","fee":0.00000878,"feeCurrency":"BNB"},{"gid":2974,"id":358236178,"exchange":"binance","price":9177.89,"quantity":0.009977,"quoteQuantity":91.56780853,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-07-20T02:10:38.697Z","fee":0.00381001,"feeCurrency":"BNB"},{"gid":2975,"id":358398812,"exchange":"binance","price":9143.49,"quantity":0.021359,"quoteQuantity":195.29580291,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-20T10:47:01.295Z","fee":0.00817471,"feeCurrency":"BNB"},{"gid":2976,"id":358409529,"exchange":"binance","price":9155.32,"quantity":0.021063,"quoteQuantity":192.83850516,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-20T11:00:00.596Z","fee":0.00809792,"feeCurrency":"BNB"},{"gid":2977,"id":358910843,"exchange":"binance","price":9213.23,"quantity":0.004238,"quoteQuantity":39.04566874,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-21T07:28:00.643Z","fee":0.00166188,"feeCurrency":"BNB"},{"gid":2978,"id":358926440,"exchange":"binance","price":9254.98,"quantity":0.0045,"quoteQuantity":41.64741,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-21T07:37:01.348Z","fee":0.0017654,"feeCurrency":"BNB"},{"gid":2979,"id":358934035,"exchange":"binance","price":9278.02,"quantity":0.004667,"quoteQuantity":43.30051934,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-21T07:39:00.621Z","fee":0.00183639,"feeCurrency":"BNB"},{"gid":2980,"id":358939077,"exchange":"binance","price":9294.99,"quantity":0.1,"quoteQuantity":929.499,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-21T07:41:13.302Z","fee":0.03940211,"feeCurrency":"BNB"},{"gid":2981,"id":358939348,"exchange":"binance","price":9294.96,"quantity":0.0132,"quoteQuantity":122.693472,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-21T07:41:22.382Z","fee":0.00520106,"feeCurrency":"BNB"},{"gid":2982,"id":358939349,"exchange":"binance","price":9294.7,"quantity":0.0068,"quoteQuantity":63.20396,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-21T07:41:22.382Z","fee":0.00267925,"feeCurrency":"BNB"},{"gid":2983,"id":358959502,"exchange":"binance","price":9339.99,"quantity":0.005158,"quoteQuantity":48.17566842,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-21T07:46:01.44Z","fee":0.00204057,"feeCurrency":"BNB"},{"gid":2984,"id":358961786,"exchange":"binance","price":9319.69,"quantity":0.008778,"quoteQuantity":81.80823882,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-21T07:47:00.533Z","fee":0.0034764,"feeCurrency":"BNB"},{"gid":2985,"id":358961787,"exchange":"binance","price":9319.92,"quantity":0.008531,"quoteQuantity":79.50823752,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-21T07:47:00.533Z","fee":0.00337741,"feeCurrency":"BNB"},{"gid":2986,"id":358984838,"exchange":"binance","price":9319.64,"quantity":0.005435,"quoteQuantity":50.6522434,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-21T08:00:05.746Z","fee":0.0021352,"feeCurrency":"BNB"},{"gid":2987,"id":359080220,"exchange":"binance","price":9327.14,"quantity":0.01,"quoteQuantity":93.2714,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-21T10:14:04.375Z","fee":0.00391461,"feeCurrency":"BNB"},{"gid":2988,"id":359151334,"exchange":"binance","price":9378.12,"quantity":0.005427,"quoteQuantity":50.89505724,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-21T12:17:00.611Z","fee":0.0021306,"feeCurrency":"BNB"},{"gid":2989,"id":359155614,"exchange":"binance","price":9364.9,"quantity":0.016342,"quoteQuantity":153.0411958,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-21T12:18:00.505Z","fee":0.00640784,"feeCurrency":"BNB"},{"gid":2990,"id":359180989,"exchange":"binance","price":9359.31,"quantity":0.016474,"quoteQuantity":154.18527294,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-21T12:49:00.661Z","fee":0.00646761,"feeCurrency":"BNB"},{"gid":2991,"id":359188364,"exchange":"binance","price":9367.42,"quantity":0.005346,"quoteQuantity":50.07822732,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-21T13:00:00.604Z","fee":0.00209547,"feeCurrency":"BNB"},{"gid":2992,"id":359192657,"exchange":"binance","price":9394.51,"quantity":0.005509,"quoteQuantity":51.75435559,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-21T13:03:01.688Z","fee":0.00216528,"feeCurrency":"BNB"},{"gid":2993,"id":359225369,"exchange":"binance","price":9414.22,"quantity":0.015382,"quoteQuantity":144.80953204,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-21T13:31:01.355Z","fee":0.00606594,"feeCurrency":"BNB"},{"gid":2994,"id":359248010,"exchange":"binance","price":9402.28,"quantity":0.009992,"quoteQuantity":93.94758176,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-07-21T13:54:59.3Z","fee":0.00396019,"feeCurrency":"BNB"},{"gid":2995,"id":359248011,"exchange":"binance","price":9402.28,"quantity":0.000008,"quoteQuantity":0.07521824,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-07-21T13:54:59.413Z","fee":0.00000316,"feeCurrency":"BNB"},{"gid":2996,"id":359765127,"exchange":"binance","price":9326.93,"quantity":0.02,"quoteQuantity":186.5386,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-22T10:31:07.174Z","fee":0.00794236,"feeCurrency":"BNB"},{"gid":2997,"id":359766057,"exchange":"binance","price":9326.16,"quantity":0.1,"quoteQuantity":932.616,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-22T10:33:07.598Z","fee":0.0397123,"feeCurrency":"BNB"},{"gid":2998,"id":360072878,"exchange":"binance","price":9428.05,"quantity":0.005889,"quoteQuantity":55.52178645,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-22T22:30:00.631Z","fee":0.00234373,"feeCurrency":"BNB"},{"gid":2999,"id":360083724,"exchange":"binance","price":9465.88,"quantity":0.002112,"quoteQuantity":19.99193856,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-22T22:31:00.71Z","fee":0.00083932,"feeCurrency":"BNB"},{"gid":3000,"id":360083725,"exchange":"binance","price":9465.87,"quantity":0.004146,"quoteQuantity":39.24549702,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-22T22:31:00.71Z","fee":0.00164765,"feeCurrency":"BNB"},{"gid":3001,"id":360088405,"exchange":"binance","price":9489.99,"quantity":0.006382,"quoteQuantity":60.56511618,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-22T22:32:00.705Z","fee":0.00254867,"feeCurrency":"BNB"},{"gid":3002,"id":360109095,"exchange":"binance","price":9532.24,"quantity":0.006818,"quoteQuantity":64.99081232,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-22T22:37:00.536Z","fee":0.00273357,"feeCurrency":"BNB"},{"gid":3003,"id":360149454,"exchange":"binance","price":9499.99,"quantity":0.00714,"quoteQuantity":67.8299286,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-22T23:00:00.824Z","fee":0.00283514,"feeCurrency":"BNB"},{"gid":3004,"id":360172647,"exchange":"binance","price":9507.99,"quantity":0.07,"quoteQuantity":665.5593,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-22T23:22:55.918Z","fee":0.02793284,"feeCurrency":"BNB"},{"gid":3005,"id":360268104,"exchange":"binance","price":9498.37,"quantity":0.02,"quoteQuantity":189.9674,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-23T02:30:28.118Z","fee":0.00782349,"feeCurrency":"BNB"},{"gid":3006,"id":360573951,"exchange":"binance","price":9457.33,"quantity":0.014857,"quoteQuantity":140.50755181,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-23T14:21:00.573Z","fee":0.00574306,"feeCurrency":"BNB"},{"gid":3007,"id":360584243,"exchange":"binance","price":9549.84,"quantity":0.007178,"quoteQuantity":68.54875152,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-23T14:26:00.939Z","fee":0.00280213,"feeCurrency":"BNB"},{"gid":3008,"id":360603691,"exchange":"binance","price":9479.47,"quantity":0.014378,"quoteQuantity":136.29581966,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-23T14:30:02.978Z","fee":0.00558779,"feeCurrency":"BNB"},{"gid":3009,"id":360729638,"exchange":"binance","price":9619.99,"quantity":0.00774,"quoteQuantity":74.4587226,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-23T17:00:00.542Z","fee":0.00303275,"feeCurrency":"BNB"},{"gid":3010,"id":360740508,"exchange":"binance","price":9644.62,"quantity":0.002,"quoteQuantity":19.28924,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-23T17:03:01.463Z","fee":0.00078795,"feeCurrency":"BNB"},{"gid":3011,"id":360740509,"exchange":"binance","price":9644.62,"quantity":0.006086,"quoteQuantity":58.69715732,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-23T17:03:01.463Z","fee":0.00239773,"feeCurrency":"BNB"},{"gid":3012,"id":360750885,"exchange":"binance","price":9616.27,"quantity":0.012323,"quoteQuantity":118.50129521,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-23T17:07:01.318Z","fee":0.00484403,"feeCurrency":"BNB"},{"gid":3013,"id":360936089,"exchange":"binance","price":9591.04,"quantity":0.1,"quoteQuantity":959.104,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-24T00:17:27.316Z","fee":0.03829265,"feeCurrency":"BNB"},{"gid":3014,"id":361305320,"exchange":"binance","price":9510.21,"quantity":0.011671,"quoteQuantity":110.99366091,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-24T13:48:37.094Z","fee":0.00450483,"feeCurrency":"BNB"},{"gid":3015,"id":361305321,"exchange":"binance","price":9510.21,"quantity":0.088329,"quoteQuantity":840.02733909,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-24T13:48:37.094Z","fee":0.03409371,"feeCurrency":"BNB"},{"gid":3016,"id":361532950,"exchange":"binance","price":9594.5,"quantity":0.04,"quoteQuantity":383.78,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-07-24T21:22:46.677Z","fee":0.01493145,"feeCurrency":"BNB"},{"gid":3017,"id":361533025,"exchange":"binance","price":9594.92,"quantity":0.010058,"quoteQuantity":96.50570536,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-24T21:22:59.434Z","fee":0.00375467,"feeCurrency":"BNB"},{"gid":3018,"id":361533026,"exchange":"binance","price":9594.92,"quantity":0.004016,"quoteQuantity":38.53319872,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-24T21:22:59.434Z","fee":0.00149918,"feeCurrency":"BNB"},{"gid":3019,"id":361533027,"exchange":"binance","price":9594.92,"quantity":0.017731,"quoteQuantity":170.12752652,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-24T21:22:59.434Z","fee":0.00661902,"feeCurrency":"BNB"},{"gid":3020,"id":361533028,"exchange":"binance","price":9594.82,"quantity":0.043096,"quoteQuantity":413.49836272,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-24T21:22:59.434Z","fee":0.01608768,"feeCurrency":"BNB"},{"gid":3021,"id":361533029,"exchange":"binance","price":9594.81,"quantity":0.025099,"quoteQuantity":240.82013619,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-24T21:22:59.434Z","fee":0.00936941,"feeCurrency":"BNB"},{"gid":3022,"id":361555177,"exchange":"binance","price":9558.61,"quantity":0.013251,"quoteQuantity":126.66114111,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-24T22:07:00.891Z","fee":0.00487681,"feeCurrency":"BNB"},{"gid":3023,"id":361671675,"exchange":"binance","price":9579.54,"quantity":0.000008,"quoteQuantity":0.07663632,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-25T03:07:22.95Z","fee":0.00000367,"feeCurrency":"BNB"},{"gid":3024,"id":361671676,"exchange":"binance","price":9579.6,"quantity":0.00001,"quoteQuantity":0.095796,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-25T03:07:22.95Z","fee":0.00000367,"feeCurrency":"BNB"},{"gid":3025,"id":361671677,"exchange":"binance","price":9579.61,"quantity":0.003938,"quoteQuantity":37.72450418,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-25T03:07:22.95Z","fee":0.00145044,"feeCurrency":"BNB"},{"gid":3026,"id":361671678,"exchange":"binance","price":9579.61,"quantity":0.016044,"quoteQuantity":153.69526284,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-25T03:07:22.95Z","fee":0.00590487,"feeCurrency":"BNB"},{"gid":3027,"id":361671958,"exchange":"binance","price":9579.3,"quantity":0.02,"quoteQuantity":191.586,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-25T03:08:19.945Z","fee":0.00736738,"feeCurrency":"BNB"},{"gid":3028,"id":361705636,"exchange":"binance","price":9584.02,"quantity":0.01,"quoteQuantity":95.8402,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-25T04:06:53.024Z","fee":0.00364024,"feeCurrency":"BNB"},{"gid":3029,"id":361707749,"exchange":"binance","price":9573.59,"quantity":0.01,"quoteQuantity":95.7359,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-25T04:12:00.207Z","fee":0.00364874,"feeCurrency":"BNB"},{"gid":3030,"id":361720196,"exchange":"binance","price":9577.1,"quantity":0.01,"quoteQuantity":95.771,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-25T04:48:03.765Z","fee":0.00366156,"feeCurrency":"BNB"},{"gid":3031,"id":361890928,"exchange":"binance","price":9579.16,"quantity":0.000019,"quoteQuantity":0.18200404,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-25T12:09:56.383Z","fee":0.00000731,"feeCurrency":"BNB"},{"gid":3032,"id":361890929,"exchange":"binance","price":9579.21,"quantity":0.009981,"quoteQuantity":95.61009501,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-25T12:09:56.383Z","fee":0.00365085,"feeCurrency":"BNB"},{"gid":3033,"id":361929376,"exchange":"binance","price":9580.7,"quantity":0.012779,"quoteQuantity":122.4317653,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-25T13:37:00.549Z","fee":0.00466832,"feeCurrency":"BNB"},{"gid":3034,"id":361944347,"exchange":"binance","price":9575.79,"quantity":0.000028,"quoteQuantity":0.26812212,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-25T14:03:47.082Z","fee":0.00001099,"feeCurrency":"BNB"},{"gid":3035,"id":361944348,"exchange":"binance","price":9575.8,"quantity":0.009972,"quoteQuantity":95.4898776,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-25T14:03:47.082Z","fee":0.00365629,"feeCurrency":"BNB"},{"gid":3036,"id":362095420,"exchange":"binance","price":9670,"quantity":0.080515,"quoteQuantity":778.58005,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-07-25T18:05:20.099Z","fee":0.02973648,"feeCurrency":"BNB"},{"gid":3037,"id":362095743,"exchange":"binance","price":9670,"quantity":0.019485,"quoteQuantity":188.41995,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-07-25T18:05:44.278Z","fee":0.00719636,"feeCurrency":"BNB"},{"gid":3038,"id":362109531,"exchange":"binance","price":9654.77,"quantity":0.01,"quoteQuantity":96.5477,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-25T18:28:03.066Z","fee":0.00367665,"feeCurrency":"BNB"},{"gid":3039,"id":362485701,"exchange":"binance","price":9722.96,"quantity":0.009264,"quoteQuantity":90.07350144,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-26T08:34:19.959Z","fee":0.00336147,"feeCurrency":"BNB"},{"gid":3040,"id":362485702,"exchange":"binance","price":9722.96,"quantity":0.090736,"quoteQuantity":882.22249856,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-26T08:34:19.959Z","fee":0.03292388,"feeCurrency":"BNB"},{"gid":3041,"id":362550331,"exchange":"binance","price":9770.33,"quantity":0.000005,"quoteQuantity":0.04885165,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-26T09:38:09.293Z","fee":0.0000018,"feeCurrency":"BNB"},{"gid":3042,"id":362550337,"exchange":"binance","price":9770.33,"quantity":0.002559,"quoteQuantity":25.00227447,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-07-26T09:38:10.62Z","fee":0.00092712,"feeCurrency":"BNB"},{"gid":3043,"id":362550338,"exchange":"binance","price":9770.33,"quantity":0.007436,"quoteQuantity":72.65217388,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-07-26T09:38:11.587Z","fee":0.00269407,"feeCurrency":"BNB"},{"gid":3044,"id":362568072,"exchange":"binance","price":9816.5,"quantity":0.011587,"quoteQuantity":113.7437855,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-26T09:54:28.267Z","fee":0.0042225,"feeCurrency":"BNB"},{"gid":3045,"id":362568073,"exchange":"binance","price":9816,"quantity":0.088413,"quoteQuantity":867.862008,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-26T09:54:28.267Z","fee":0.03221755,"feeCurrency":"BNB"},{"gid":3046,"id":362568655,"exchange":"binance","price":9807.76,"quantity":0.1,"quoteQuantity":980.776,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-26T09:54:54.122Z","fee":0.03645554,"feeCurrency":"BNB"},{"gid":3047,"id":362569386,"exchange":"binance","price":9811.61,"quantity":0.059995,"quoteQuantity":588.64754195,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-26T09:55:23.434Z","fee":0.02189847,"feeCurrency":"BNB"},{"gid":3048,"id":362569387,"exchange":"binance","price":9811.61,"quantity":0.040005,"quoteQuantity":392.51345805,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-26T09:55:23.434Z","fee":0.01460202,"feeCurrency":"BNB"},{"gid":3049,"id":362591461,"exchange":"binance","price":9900,"quantity":0.1,"quoteQuantity":990,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-07-26T10:03:18.055Z","fee":0.0370133,"feeCurrency":"BNB"},{"gid":3050,"id":362601105,"exchange":"binance","price":9969.44,"quantity":0.013385,"quoteQuantity":133.4409544,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-26T10:04:01.249Z","fee":0.00498897,"feeCurrency":"BNB"},{"gid":3051,"id":362610361,"exchange":"binance","price":9991.66,"quantity":0.01328,"quoteQuantity":132.6892448,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-26T10:05:00.655Z","fee":0.00495145,"feeCurrency":"BNB"},{"gid":3052,"id":362612719,"exchange":"binance","price":10000,"quantity":0.1,"quoteQuantity":1000,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-07-26T10:05:14.069Z","fee":0.03746441,"feeCurrency":"BNB"},{"gid":3053,"id":362654258,"exchange":"binance","price":10000,"quantity":0.00826,"quoteQuantity":82.6,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-26T10:12:00.637Z","fee":0.00311431,"feeCurrency":"BNB"},{"gid":3054,"id":362679367,"exchange":"binance","price":9982.64,"quantity":0.01,"quoteQuantity":99.8264,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-26T10:19:12.909Z","fee":0.00378787,"feeCurrency":"BNB"},{"gid":3055,"id":362682828,"exchange":"binance","price":9962.95,"quantity":0.01,"quoteQuantity":99.6295,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-07-26T10:20:34.415Z","fee":0.00378729,"feeCurrency":"BNB"},{"gid":3056,"id":362683521,"exchange":"binance","price":9961.64,"quantity":0.01,"quoteQuantity":99.6164,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-26T10:20:50.644Z","fee":0.00378729,"feeCurrency":"BNB"},{"gid":3057,"id":362752245,"exchange":"binance","price":9989.01,"quantity":0.01,"quoteQuantity":99.8901,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-26T10:57:12.233Z","fee":0.00382809,"feeCurrency":"BNB"},{"gid":3058,"id":362768687,"exchange":"binance","price":9985.28,"quantity":0.01,"quoteQuantity":99.8528,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-07-26T11:09:51.971Z","fee":0.00382672,"feeCurrency":"BNB"},{"gid":3059,"id":362835998,"exchange":"binance","price":9928.94,"quantity":0.008062,"quoteQuantity":80.04711428,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-26T12:28:03.665Z","fee":0.00307868,"feeCurrency":"BNB"},{"gid":3060,"id":362879724,"exchange":"binance","price":9878.33,"quantity":0.00913,"quoteQuantity":90.1891529,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-26T13:00:00.658Z","fee":0.00344094,"feeCurrency":"BNB"},{"gid":3061,"id":362887185,"exchange":"binance","price":9887.68,"quantity":0.01,"quoteQuantity":98.8768,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-07-26T13:07:47.827Z","fee":0.00379036,"feeCurrency":"BNB"},{"gid":3062,"id":362910242,"exchange":"binance","price":9878.1,"quantity":0.002,"quoteQuantity":19.7562,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-26T13:43:04.655Z","fee":0.00075814,"feeCurrency":"BNB"},{"gid":3063,"id":362911582,"exchange":"binance","price":9872.3,"quantity":0.000006,"quoteQuantity":0.0592338,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-26T13:45:56.903Z","fee":0.00000378,"feeCurrency":"BNB"},{"gid":3064,"id":362911583,"exchange":"binance","price":9872.31,"quantity":0.001994,"quoteQuantity":19.68538614,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-26T13:45:56.903Z","fee":0.00075412,"feeCurrency":"BNB"},{"gid":3065,"id":362986996,"exchange":"binance","price":9963.54,"quantity":0.009852,"quoteQuantity":98.16079608,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-26T15:51:00.567Z","fee":0.00377628,"feeCurrency":"BNB"},{"gid":3066,"id":363012992,"exchange":"binance","price":9923.77,"quantity":0.008192,"quoteQuantity":81.29552384,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-26T16:15:00.527Z","fee":0.00314001,"feeCurrency":"BNB"},{"gid":3067,"id":363227222,"exchange":"binance","price":9931.54,"quantity":0.011611,"quoteQuantity":115.31511094,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T00:00:00.661Z","fee":0.00442485,"feeCurrency":"BNB"},{"gid":3068,"id":363281898,"exchange":"binance","price":9972.61,"quantity":0.010121,"quoteQuantity":100.93278581,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T01:00:00.618Z","fee":0.00390711,"feeCurrency":"BNB"},{"gid":3069,"id":363287663,"exchange":"binance","price":9997.73,"quantity":0.011913,"quoteQuantity":119.10295749,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T01:04:51.441Z","fee":0.00459049,"feeCurrency":"BNB"},{"gid":3070,"id":363287664,"exchange":"binance","price":9997.55,"quantity":0.008087,"quoteQuantity":80.85018685,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T01:04:51.441Z","fee":0.00311614,"feeCurrency":"BNB"},{"gid":3071,"id":363288395,"exchange":"binance","price":10005.79,"quantity":0.01184,"quoteQuantity":118.4685536,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T01:05:48.085Z","fee":0.00457171,"feeCurrency":"BNB"},{"gid":3072,"id":363288396,"exchange":"binance","price":10005.71,"quantity":0.00816,"quoteQuantity":81.6465936,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T01:05:48.085Z","fee":0.00315075,"feeCurrency":"BNB"},{"gid":3073,"id":363314996,"exchange":"binance","price":10057.01,"quantity":0.001,"quoteQuantity":10.05701,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T01:28:17.41Z","fee":0.00038842,"feeCurrency":"BNB"},{"gid":3074,"id":363317044,"exchange":"binance","price":10080.22,"quantity":0.011801,"quoteQuantity":118.95667622,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T01:29:00.519Z","fee":0.00459441,"feeCurrency":"BNB"},{"gid":3075,"id":363331267,"exchange":"binance","price":10075.51,"quantity":0.001,"quoteQuantity":10.07551,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T01:39:26.137Z","fee":0.00038872,"feeCurrency":"BNB"},{"gid":3076,"id":363339698,"exchange":"binance","price":10020.76,"quantity":0.007416,"quoteQuantity":74.31395616,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T01:46:02.517Z","fee":0.00287537,"feeCurrency":"BNB"},{"gid":3077,"id":363342277,"exchange":"binance","price":10038.34,"quantity":0.001,"quoteQuantity":10.03834,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T01:48:00.072Z","fee":0.00038727,"feeCurrency":"BNB"},{"gid":3078,"id":363342553,"exchange":"binance","price":10039.73,"quantity":0.001,"quoteQuantity":10.03973,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T01:48:30.658Z","fee":0.00038741,"feeCurrency":"BNB"},{"gid":3079,"id":363353630,"exchange":"binance","price":10049.08,"quantity":0.000011,"quoteQuantity":0.11053988,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T01:53:23.326Z","fee":0.00000426,"feeCurrency":"BNB"},{"gid":3080,"id":363353631,"exchange":"binance","price":10049.08,"quantity":0.000989,"quoteQuantity":9.93854012,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-07-27T01:53:23.327Z","fee":0.00038374,"feeCurrency":"BNB"},{"gid":3081,"id":363353795,"exchange":"binance","price":10048.54,"quantity":0.001,"quoteQuantity":10.04854,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-07-27T01:53:31.829Z","fee":0.00038799,"feeCurrency":"BNB"},{"gid":3082,"id":363361863,"exchange":"binance","price":10079.99,"quantity":0.012914,"quoteQuantity":130.17299086,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T02:00:00.619Z","fee":0.00500569,"feeCurrency":"BNB"},{"gid":3083,"id":363432202,"exchange":"binance","price":10146.86,"quantity":0.007179,"quoteQuantity":72.84430794,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T03:31:00.544Z","fee":0.00282732,"feeCurrency":"BNB"},{"gid":3084,"id":363453413,"exchange":"binance","price":10156.57,"quantity":0.006019,"quoteQuantity":61.13239483,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T03:37:00.558Z","fee":0.00239547,"feeCurrency":"BNB"},{"gid":3085,"id":363479539,"exchange":"binance","price":10249.99,"quantity":0.006135,"quoteQuantity":62.88368865,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T03:50:00.536Z","fee":0.00245603,"feeCurrency":"BNB"},{"gid":3086,"id":363515681,"exchange":"binance","price":10235.01,"quantity":0.000009,"quoteQuantity":0.09211509,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T03:59:00.599Z","fee":0.00000401,"feeCurrency":"BNB"},{"gid":3087,"id":363515682,"exchange":"binance","price":10235.02,"quantity":0.00532,"quoteQuantity":54.4503064,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T03:59:00.599Z","fee":0.00213597,"feeCurrency":"BNB"},{"gid":3088,"id":363517869,"exchange":"binance","price":10250.11,"quantity":0.005409,"quoteQuantity":55.44284499,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T04:00:00.57Z","fee":0.0021689,"feeCurrency":"BNB"},{"gid":3089,"id":363553214,"exchange":"binance","price":10251.65,"quantity":0.005118,"quoteQuantity":52.4679447,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T04:23:03.778Z","fee":0.00204799,"feeCurrency":"BNB"},{"gid":3090,"id":363571300,"exchange":"binance","price":10190.42,"quantity":0.005659,"quoteQuantity":57.66758678,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T04:31:00.707Z","fee":0.00225342,"feeCurrency":"BNB"},{"gid":3091,"id":363596488,"exchange":"binance","price":10206.39,"quantity":0.005646,"quoteQuantity":57.62527794,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T05:00:04.752Z","fee":0.00225374,"feeCurrency":"BNB"},{"gid":3092,"id":363660971,"exchange":"binance","price":10281.54,"quantity":0.005039,"quoteQuantity":51.80868006,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T05:55:00.992Z","fee":0.00202582,"feeCurrency":"BNB"},{"gid":3093,"id":363669058,"exchange":"binance","price":10287.81,"quantity":0.017564,"quoteQuantity":180.69509484,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T06:00:00.701Z","fee":0.00707371,"feeCurrency":"BNB"},{"gid":3094,"id":363702160,"exchange":"binance","price":10227.18,"quantity":0.00552,"quoteQuantity":56.4540336,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T06:29:00.733Z","fee":0.00219896,"feeCurrency":"BNB"},{"gid":3095,"id":363799260,"exchange":"binance","price":10156,"quantity":0.005897,"quoteQuantity":59.889932,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T08:00:00.635Z","fee":0.00234536,"feeCurrency":"BNB"},{"gid":3096,"id":363799411,"exchange":"binance","price":10157.59,"quantity":0.006571,"quoteQuantity":66.74552389,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T08:00:04.773Z","fee":0.0026117,"feeCurrency":"BNB"},{"gid":3097,"id":363806951,"exchange":"binance","price":10175.57,"quantity":0.013641,"quoteQuantity":138.80495037,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T08:04:00.583Z","fee":0.00543078,"feeCurrency":"BNB"},{"gid":3098,"id":363813766,"exchange":"binance","price":10122.42,"quantity":0.006311,"quoteQuantity":63.88259262,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T08:08:00.578Z","fee":0.00251047,"feeCurrency":"BNB"},{"gid":3099,"id":363822436,"exchange":"binance","price":10156.63,"quantity":0.0017,"quoteQuantity":17.266271,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T08:12:00.572Z","fee":0.00067829,"feeCurrency":"BNB"},{"gid":3100,"id":363822437,"exchange":"binance","price":10156.62,"quantity":0.011782,"quoteQuantity":119.66529684,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T08:12:00.572Z","fee":0.004701,"feeCurrency":"BNB"},{"gid":3101,"id":363925368,"exchange":"binance","price":10259.05,"quantity":0.000008,"quoteQuantity":0.0820724,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T09:53:01.458Z","fee":0.00000317,"feeCurrency":"BNB"},{"gid":3102,"id":363925369,"exchange":"binance","price":10258.95,"quantity":0.0015,"quoteQuantity":15.388425,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T09:53:01.458Z","fee":0.00059613,"feeCurrency":"BNB"},{"gid":3103,"id":363998684,"exchange":"binance","price":10282.21,"quantity":0.005064,"quoteQuantity":52.06911144,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T11:08:00.686Z","fee":0.00201743,"feeCurrency":"BNB"},{"gid":3104,"id":364001967,"exchange":"binance","price":10272.12,"quantity":0.004576,"quoteQuantity":47.00522112,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T11:10:00.644Z","fee":0.00182169,"feeCurrency":"BNB"},{"gid":3105,"id":364001968,"exchange":"binance","price":10272.34,"quantity":0.000498,"quoteQuantity":5.11562532,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T11:10:00.644Z","fee":0.00019887,"feeCurrency":"BNB"},{"gid":3106,"id":364006777,"exchange":"binance","price":10239.41,"quantity":0.005283,"quoteQuantity":54.09480303,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T11:12:00.585Z","fee":0.00209945,"feeCurrency":"BNB"},{"gid":3107,"id":364126916,"exchange":"binance","price":10356.91,"quantity":0.014029,"quoteQuantity":145.29709039,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T13:21:00.558Z","fee":0.00566875,"feeCurrency":"BNB"},{"gid":3108,"id":364136932,"exchange":"binance","price":10343.45,"quantity":0.003612,"quoteQuantity":37.3605414,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T13:24:00.616Z","fee":0.00146256,"feeCurrency":"BNB"},{"gid":3109,"id":364136933,"exchange":"binance","price":10343.54,"quantity":0.001006,"quoteQuantity":10.40560124,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T13:24:00.616Z","fee":0.00040919,"feeCurrency":"BNB"},{"gid":3110,"id":364149250,"exchange":"binance","price":10295.48,"quantity":0.001457,"quoteQuantity":15.00051436,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T13:29:00.571Z","fee":0.00059001,"feeCurrency":"BNB"},{"gid":3111,"id":364149251,"exchange":"binance","price":10295.49,"quantity":0.003546,"quoteQuantity":36.50780754,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T13:29:00.571Z","fee":0.0014346,"feeCurrency":"BNB"},{"gid":3112,"id":364190359,"exchange":"binance","price":10347.1,"quantity":0.010062,"quoteQuantity":104.1125202,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T14:00:00.615Z","fee":0.00408999,"feeCurrency":"BNB"},{"gid":3113,"id":364233091,"exchange":"binance","price":10319.02,"quantity":0.000892,"quoteQuantity":9.20456584,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T14:25:00.531Z","fee":0.00036951,"feeCurrency":"BNB"},{"gid":3114,"id":364233092,"exchange":"binance","price":10319.34,"quantity":0.003961,"quoteQuantity":40.87490574,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T14:25:00.531Z","fee":0.00164415,"feeCurrency":"BNB"},{"gid":3115,"id":364266179,"exchange":"binance","price":10360.33,"quantity":0.005374,"quoteQuantity":55.67641342,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T14:49:00.766Z","fee":0.0022286,"feeCurrency":"BNB"},{"gid":3116,"id":364303041,"exchange":"binance","price":10381.95,"quantity":0.004438,"quoteQuantity":46.0750941,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T15:01:00.501Z","fee":0.00186606,"feeCurrency":"BNB"},{"gid":3117,"id":364314329,"exchange":"binance","price":10291.56,"quantity":0.005207,"quoteQuantity":53.58815292,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T15:04:00.554Z","fee":0.00218307,"feeCurrency":"BNB"},{"gid":3118,"id":364318424,"exchange":"binance","price":10327.25,"quantity":0.000005,"quoteQuantity":0.05163625,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T15:05:00.547Z","fee":0.00000208,"feeCurrency":"BNB"},{"gid":3119,"id":364318425,"exchange":"binance","price":10326.49,"quantity":0.000004,"quoteQuantity":0.04130596,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T15:05:00.547Z","fee":0.00000166,"feeCurrency":"BNB"},{"gid":3120,"id":364318426,"exchange":"binance","price":10325.62,"quantity":0.001936,"quoteQuantity":19.99040032,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T15:05:00.547Z","fee":0.00080875,"feeCurrency":"BNB"},{"gid":3121,"id":364318427,"exchange":"binance","price":10325.61,"quantity":0.007273,"quoteQuantity":75.09816153,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T15:05:00.547Z","fee":0.00303827,"feeCurrency":"BNB"},{"gid":3122,"id":364392080,"exchange":"binance","price":10299.77,"quantity":0.006047,"quoteQuantity":62.28270919,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T16:00:00.569Z","fee":0.00249258,"feeCurrency":"BNB"},{"gid":3123,"id":364448037,"exchange":"binance","price":10489.33,"quantity":0.006364,"quoteQuantity":66.75409612,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T16:36:00.988Z","fee":0.00267042,"feeCurrency":"BNB"},{"gid":3124,"id":364518225,"exchange":"binance","price":10643.74,"quantity":0.000009,"quoteQuantity":0.09579366,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T16:55:58.751Z","fee":0.00000429,"feeCurrency":"BNB"},{"gid":3125,"id":364518226,"exchange":"binance","price":10643.83,"quantity":0.000991,"quoteQuantity":10.54803553,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T16:55:58.751Z","fee":0.0004252,"feeCurrency":"BNB"},{"gid":3126,"id":364524010,"exchange":"binance","price":10687.81,"quantity":0.001707,"quoteQuantity":18.24409167,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T16:56:54.44Z","fee":0.00073344,"feeCurrency":"BNB"},{"gid":3127,"id":364539146,"exchange":"binance","price":10690.42,"quantity":0.000018,"quoteQuantity":0.19242756,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T16:59:00.605Z","fee":0.00000862,"feeCurrency":"BNB"},{"gid":3128,"id":364539147,"exchange":"binance","price":10690.43,"quantity":0.003483,"quoteQuantity":37.23476769,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T16:59:00.605Z","fee":0.00150189,"feeCurrency":"BNB"},{"gid":3129,"id":364543194,"exchange":"binance","price":10721.29,"quantity":0.003151,"quoteQuantity":33.78278479,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T17:00:00.767Z","fee":0.00135321,"feeCurrency":"BNB"},{"gid":3130,"id":364569359,"exchange":"binance","price":10811.96,"quantity":0.003149,"quoteQuantity":34.04686204,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T17:03:00.734Z","fee":0.00135775,"feeCurrency":"BNB"},{"gid":3131,"id":364575596,"exchange":"binance","price":10848,"quantity":0.003149,"quoteQuantity":34.160352,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T17:04:00.533Z","fee":0.00136119,"feeCurrency":"BNB"},{"gid":3132,"id":364584336,"exchange":"binance","price":10816.01,"quantity":0.002966,"quoteQuantity":32.08028566,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T17:06:00.705Z","fee":0.00127401,"feeCurrency":"BNB"},{"gid":3133,"id":364593115,"exchange":"binance","price":10744.9,"quantity":0.003326,"quoteQuantity":35.7375374,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T17:07:00.545Z","fee":0.0014187,"feeCurrency":"BNB"},{"gid":3134,"id":364599893,"exchange":"binance","price":10684.53,"quantity":0.003466,"quoteQuantity":37.03258098,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T17:08:00.674Z","fee":0.00146256,"feeCurrency":"BNB"},{"gid":3135,"id":364610176,"exchange":"binance","price":10725,"quantity":0.009097,"quoteQuantity":97.565325,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T17:10:00.757Z","fee":0.0038607,"feeCurrency":"BNB"},{"gid":3136,"id":364635806,"exchange":"binance","price":10834.62,"quantity":0.001011,"quoteQuantity":10.95380082,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T17:17:38.004Z","fee":0.00042975,"feeCurrency":"BNB"},{"gid":3137,"id":364934378,"exchange":"binance","price":11071.01,"quantity":0.005397,"quoteQuantity":59.75024097,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T22:02:00.548Z","fee":0.0022937,"feeCurrency":"BNB"},{"gid":3138,"id":364940015,"exchange":"binance","price":11161.83,"quantity":0.004858,"quoteQuantity":54.22417014,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T22:05:00.94Z","fee":0.00207541,"feeCurrency":"BNB"},{"gid":3139,"id":364983696,"exchange":"binance","price":11321.45,"quantity":0.00426,"quoteQuantity":48.229377,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T22:20:00.614Z","fee":0.00183557,"feeCurrency":"BNB"},{"gid":3140,"id":364985906,"exchange":"binance","price":11357.64,"quantity":0.004319,"quoteQuantity":49.05364716,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T22:21:00.519Z","fee":0.00186127,"feeCurrency":"BNB"},{"gid":3141,"id":364991066,"exchange":"binance","price":11320.76,"quantity":0.001744,"quoteQuantity":19.74340544,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T22:23:02.114Z","fee":0.00074802,"feeCurrency":"BNB"},{"gid":3142,"id":364991067,"exchange":"binance","price":11323.12,"quantity":0.002547,"quoteQuantity":28.83998664,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T22:23:02.114Z","fee":0.00109623,"feeCurrency":"BNB"},{"gid":3143,"id":364994780,"exchange":"binance","price":11249.62,"quantity":0.004671,"quoteQuantity":52.54697502,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T22:24:00.803Z","fee":0.00200762,"feeCurrency":"BNB"},{"gid":3144,"id":365004885,"exchange":"binance","price":11195.84,"quantity":0.008498,"quoteQuantity":95.14224832,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T22:26:00.638Z","fee":0.00360824,"feeCurrency":"BNB"},{"gid":3145,"id":365014514,"exchange":"binance","price":11125.02,"quantity":0.005206,"quoteQuantity":57.91685412,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T22:31:00.637Z","fee":0.00220425,"feeCurrency":"BNB"},{"gid":3146,"id":365042297,"exchange":"binance","price":11209.68,"quantity":0.005535,"quoteQuantity":62.0455788,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T23:00:01.264Z","fee":0.00231366,"feeCurrency":"BNB"},{"gid":3147,"id":365058679,"exchange":"binance","price":11115.69,"quantity":0.00515,"quoteQuantity":57.2458035,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T23:18:00.647Z","fee":0.00216144,"feeCurrency":"BNB"},{"gid":3148,"id":365079243,"exchange":"binance","price":10836.03,"quantity":0.007565,"quoteQuantity":81.97456695,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T23:20:00.905Z","fee":0.00315258,"feeCurrency":"BNB"},{"gid":3149,"id":365084887,"exchange":"binance","price":10954.87,"quantity":0.011997,"quoteQuantity":131.42557539,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T23:21:00.919Z","fee":0.00502602,"feeCurrency":"BNB"},{"gid":3150,"id":365092288,"exchange":"binance","price":10930.43,"quantity":0.0012,"quoteQuantity":13.116516,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-27T23:23:02.153Z","fee":0.0005004,"feeCurrency":"BNB"},{"gid":3151,"id":365118895,"exchange":"binance","price":10989.18,"quantity":0.005809,"quoteQuantity":63.83614662,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-27T23:46:00.737Z","fee":0.00243014,"feeCurrency":"BNB"},{"gid":3152,"id":365128341,"exchange":"binance","price":11029.98,"quantity":0.006273,"quoteQuantity":69.19106454,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-28T00:00:00.969Z","fee":0.00262856,"feeCurrency":"BNB"},{"gid":3153,"id":365128349,"exchange":"binance","price":11028.31,"quantity":0.005348,"quoteQuantity":58.97940188,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-28T00:00:01.482Z","fee":0.00224145,"feeCurrency":"BNB"},{"gid":3154,"id":365130836,"exchange":"binance","price":11094.26,"quantity":0.006181,"quoteQuantity":68.57362106,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-28T00:01:00.809Z","fee":0.00260283,"feeCurrency":"BNB"},{"gid":3155,"id":365168380,"exchange":"binance","price":11136.26,"quantity":0.00514,"quoteQuantity":57.2403764,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-28T00:32:00.553Z","fee":0.00216548,"feeCurrency":"BNB"},{"gid":3156,"id":365187189,"exchange":"binance","price":11155.19,"quantity":0.000002,"quoteQuantity":0.02231038,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-28T01:00:00.752Z","fee":8.2e-7,"feeCurrency":"BNB"},{"gid":3157,"id":365187190,"exchange":"binance","price":11155.18,"quantity":0.005242,"quoteQuantity":58.47545356,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-28T01:00:00.752Z","fee":0.00217626,"feeCurrency":"BNB"},{"gid":3158,"id":365214090,"exchange":"binance","price":11138.74,"quantity":0.000008,"quoteQuantity":0.08910992,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-28T01:27:03.341Z","fee":0.00000408,"feeCurrency":"BNB"},{"gid":3159,"id":365214091,"exchange":"binance","price":11140.11,"quantity":0.000902,"quoteQuantity":10.04837922,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-28T01:27:03.341Z","fee":0.000368,"feeCurrency":"BNB"},{"gid":3160,"id":365214092,"exchange":"binance","price":11140.26,"quantity":0.004202,"quoteQuantity":46.81137252,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-28T01:27:03.341Z","fee":0.00171736,"feeCurrency":"BNB"},{"gid":3161,"id":365233609,"exchange":"binance","price":11165.1,"quantity":0.005694,"quoteQuantity":63.5740794,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-28T01:54:27.283Z","fee":0.00232671,"feeCurrency":"BNB"},{"gid":3162,"id":366674934,"exchange":"binance","price":10888.57,"quantity":0.005,"quoteQuantity":54.44285,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-29T02:01:29.047Z","fee":0.000005,"feeCurrency":"BTC"},{"gid":3163,"id":366698343,"exchange":"binance","price":10910.71,"quantity":0.006963,"quoteQuantity":75.97127373,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-07-29T02:48:39.474Z","fee":0.00000696,"feeCurrency":"BTC"},{"gid":3164,"id":366698535,"exchange":"binance","price":10916.51,"quantity":0.000026,"quoteQuantity":0.28382926,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-29T02:49:06.772Z","fee":0.00001215,"feeCurrency":"BNB"},{"gid":3165,"id":366698536,"exchange":"binance","price":10916.51,"quantity":0.009974,"quoteQuantity":108.88127074,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-07-29T02:49:06.841Z","fee":0.00000997,"feeCurrency":"BTC"},{"gid":3166,"id":366710879,"exchange":"binance","price":10959.57,"quantity":0.01,"quoteQuantity":109.5957,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-29T03:11:51.381Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":3167,"id":366892477,"exchange":"binance","price":11021.65,"quantity":0.031931,"quoteQuantity":351.93230615,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-29T07:50:55.846Z","fee":0.35193231,"feeCurrency":"USDT"},{"gid":3168,"id":367269437,"exchange":"binance","price":11202.09,"quantity":0.000408,"quoteQuantity":4.57045272,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-29T14:54:00.592Z","fee":0.00017251,"feeCurrency":"BNB"},{"gid":3169,"id":367269438,"exchange":"binance","price":11202.09,"quantity":0.007681,"quoteQuantity":86.04325329,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-29T14:54:00.592Z","fee":0.00323159,"feeCurrency":"BNB"},{"gid":3170,"id":367274731,"exchange":"binance","price":11167.15,"quantity":0.003582,"quoteQuantity":40.0007313,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-29T14:55:04.765Z","fee":0.00149882,"feeCurrency":"BNB"},{"gid":3171,"id":367274732,"exchange":"binance","price":11167.16,"quantity":0.004794,"quoteQuantity":53.53536504,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-29T14:55:04.765Z","fee":0.00200541,"feeCurrency":"BNB"},{"gid":3172,"id":367284702,"exchange":"binance","price":11067.94,"quantity":0.009425,"quoteQuantity":104.3153345,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-29T14:56:00.816Z","fee":0.00394802,"feeCurrency":"BNB"},{"gid":3173,"id":367288493,"exchange":"binance","price":11114.67,"quantity":0.00474,"quoteQuantity":52.6835358,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-29T14:57:00.64Z","fee":0.00198488,"feeCurrency":"BNB"},{"gid":3174,"id":367288494,"exchange":"binance","price":11114.43,"quantity":0.018561,"quoteQuantity":206.29493523,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-29T14:57:00.64Z","fee":0.00777228,"feeCurrency":"BNB"},{"gid":3175,"id":367334394,"exchange":"binance","price":11160.99,"quantity":0.001791,"quoteQuantity":19.98933309,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-29T15:37:52.74Z","fee":0.00075192,"feeCurrency":"BNB"},{"gid":3176,"id":367334395,"exchange":"binance","price":11160.98,"quantity":0.000798,"quoteQuantity":8.90646204,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-07-29T15:37:52.74Z","fee":0.00033502,"feeCurrency":"BNB"},{"gid":3177,"id":367996490,"exchange":"binance","price":10942.29,"quantity":0.009818,"quoteQuantity":107.43140322,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-07-30T07:31:00.546Z","fee":0.00407649,"feeCurrency":"BNB"},{"gid":3178,"id":367999366,"exchange":"binance","price":10936.98,"quantity":0.009818,"quoteQuantity":107.37926964,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-07-30T07:33:29.195Z","fee":0.00407518,"feeCurrency":"BNB"},{"gid":3179,"id":369803336,"exchange":"binance","price":11489.74,"quantity":0.01,"quoteQuantity":114.8974,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T05:36:38.404Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":3180,"id":369815098,"exchange":"binance","price":11529.82,"quantity":0.002,"quoteQuantity":23.05964,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T05:44:42.19Z","fee":0.000002,"feeCurrency":"BTC"},{"gid":3181,"id":369845838,"exchange":"binance","price":11619.18,"quantity":0.003438,"quoteQuantity":39.94674084,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T05:55:41.293Z","fee":0.00000344,"feeCurrency":"BTC"},{"gid":3182,"id":369845839,"exchange":"binance","price":11619.19,"quantity":0.006562,"quoteQuantity":76.24512478,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T05:55:41.293Z","fee":0.00000656,"feeCurrency":"BTC"},{"gid":3183,"id":369863796,"exchange":"binance","price":11569.06,"quantity":0.002,"quoteQuantity":23.13812,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-08-01T06:05:06.911Z","fee":0.000002,"feeCurrency":"BTC"},{"gid":3184,"id":369921136,"exchange":"binance","price":11616.93,"quantity":0.001,"quoteQuantity":11.61693,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T06:39:10.452Z","fee":0.000001,"feeCurrency":"BTC"},{"gid":3185,"id":369930217,"exchange":"binance","price":11657.82,"quantity":0.01,"quoteQuantity":116.5782,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T06:47:42.643Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":3186,"id":369930218,"exchange":"binance","price":11657.83,"quantity":0.015,"quoteQuantity":174.86745,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T06:47:42.643Z","fee":0.000015,"feeCurrency":"BTC"},{"gid":3187,"id":369930219,"exchange":"binance","price":11658.05,"quantity":0.008505,"quoteQuantity":99.15171525,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T06:47:42.643Z","fee":0.00000851,"feeCurrency":"BTC"},{"gid":3188,"id":369930220,"exchange":"binance","price":11658.43,"quantity":0.01,"quoteQuantity":116.5843,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T06:47:42.643Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":3189,"id":369930221,"exchange":"binance","price":11658.52,"quantity":0.00657,"quoteQuantity":76.5964764,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T06:47:42.643Z","fee":0.00000657,"feeCurrency":"BTC"},{"gid":3190,"id":369930222,"exchange":"binance","price":11658.76,"quantity":0.049925,"quoteQuantity":582.063593,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T06:47:42.643Z","fee":0.00004993,"feeCurrency":"BTC"},{"gid":3191,"id":370112836,"exchange":"binance","price":11606.69,"quantity":0.02,"quoteQuantity":232.1338,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-08-01T10:42:07.746Z","fee":0.00002,"feeCurrency":"BTC"},{"gid":3192,"id":370122743,"exchange":"binance","price":11610,"quantity":0.01,"quoteQuantity":116.1,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T10:57:25.719Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":3193,"id":370130463,"exchange":"binance","price":11591.49,"quantity":0.001433,"quoteQuantity":16.61060517,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-08-01T11:03:49.909Z","fee":0.00000143,"feeCurrency":"BTC"},{"gid":3194,"id":370130464,"exchange":"binance","price":11591.49,"quantity":0.000881,"quoteQuantity":10.21210269,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-08-01T11:03:50.072Z","fee":8.8e-7,"feeCurrency":"BTC"},{"gid":3195,"id":370130465,"exchange":"binance","price":11591.49,"quantity":0.007686,"quoteQuantity":89.09219214,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-08-01T11:03:50.078Z","fee":0.00000769,"feeCurrency":"BTC"},{"gid":3196,"id":370131393,"exchange":"binance","price":11597.14,"quantity":0.001286,"quoteQuantity":14.91392204,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-08-01T11:04:22.786Z","fee":0.00000129,"feeCurrency":"BTC"},{"gid":3197,"id":370131394,"exchange":"binance","price":11597.14,"quantity":0.008714,"quoteQuantity":101.05747796,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-08-01T11:04:22.795Z","fee":0.00000871,"feeCurrency":"BTC"},{"gid":3198,"id":370176887,"exchange":"binance","price":11676.5,"quantity":0.01,"quoteQuantity":116.765,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-01T12:15:57.131Z","fee":0.116765,"feeCurrency":"USDT"},{"gid":3199,"id":370223093,"exchange":"binance","price":11598.29,"quantity":0.01,"quoteQuantity":115.9829,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-08-01T13:17:56.511Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":3200,"id":370231520,"exchange":"binance","price":11586.81,"quantity":0.01,"quoteQuantity":115.8681,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T13:29:38.624Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":3201,"id":370235599,"exchange":"binance","price":11573.94,"quantity":0.01,"quoteQuantity":115.7394,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-08-01T13:33:59.234Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":3202,"id":370247288,"exchange":"binance","price":11548.2,"quantity":0.01,"quoteQuantity":115.482,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T13:40:43.068Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":3203,"id":370247658,"exchange":"binance","price":11550,"quantity":0.1,"quoteQuantity":1155,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T13:40:57.307Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":3204,"id":370383094,"exchange":"binance","price":11685.99,"quantity":0.01,"quoteQuantity":116.8599,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-01T16:46:34.855Z","fee":0.00415977,"feeCurrency":"BNB"},{"gid":3205,"id":370388165,"exchange":"binance","price":11635.05,"quantity":0.007344,"quoteQuantity":85.4478072,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T16:51:01.07Z","fee":0.00305561,"feeCurrency":"BNB"},{"gid":3206,"id":370388166,"exchange":"binance","price":11635.25,"quantity":0.000716,"quoteQuantity":8.330839,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T16:51:01.07Z","fee":0.00029973,"feeCurrency":"BNB"},{"gid":3207,"id":370414944,"exchange":"binance","price":11683.53,"quantity":0.00001,"quoteQuantity":0.1168353,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-01T17:24:48.177Z","fee":0.00000414,"feeCurrency":"BNB"},{"gid":3208,"id":370414945,"exchange":"binance","price":11683.52,"quantity":0.00999,"quoteQuantity":116.7183648,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-01T17:24:48.177Z","fee":0.00414612,"feeCurrency":"BNB"},{"gid":3209,"id":370415083,"exchange":"binance","price":11681.08,"quantity":0.01,"quoteQuantity":116.8108,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-01T17:24:55.101Z","fee":0.00414941,"feeCurrency":"BNB"},{"gid":3210,"id":370454885,"exchange":"binance","price":11659.42,"quantity":0.007951,"quoteQuantity":92.70404842,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T18:10:00.521Z","fee":0.00330624,"feeCurrency":"BNB"},{"gid":3211,"id":370464952,"exchange":"binance","price":11623.88,"quantity":0.008179,"quoteQuantity":95.07171452,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T18:19:00.591Z","fee":0.00339907,"feeCurrency":"BNB"},{"gid":3212,"id":370485521,"exchange":"binance","price":11571.04,"quantity":0.008375,"quoteQuantity":96.90746,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T18:38:00.712Z","fee":0.00347563,"feeCurrency":"BNB"},{"gid":3213,"id":370520962,"exchange":"binance","price":11750.08,"quantity":0.037529,"quoteQuantity":440.96875232,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-01T19:26:00.933Z","fee":0.01563357,"feeCurrency":"BNB"},{"gid":3214,"id":370530757,"exchange":"binance","price":11750,"quantity":0.007438,"quoteQuantity":87.3965,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T19:32:00.708Z","fee":0.00311001,"feeCurrency":"BNB"},{"gid":3215,"id":370532199,"exchange":"binance","price":11762.85,"quantity":0.007003,"quoteQuantity":82.37523855,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-01T19:33:00.686Z","fee":0.00292968,"feeCurrency":"BNB"},{"gid":3216,"id":370532200,"exchange":"binance","price":11762.82,"quantity":0.007019,"quoteQuantity":82.56323358,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-01T19:33:00.686Z","fee":0.00293636,"feeCurrency":"BNB"},{"gid":3217,"id":370532201,"exchange":"binance","price":11762.32,"quantity":0.0234,"quoteQuantity":275.238288,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-01T19:33:00.686Z","fee":0.00978886,"feeCurrency":"BNB"},{"gid":3218,"id":370533889,"exchange":"binance","price":11788.4,"quantity":0.035764,"quoteQuantity":421.6003376,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-01T19:34:00.539Z","fee":0.01494975,"feeCurrency":"BNB"},{"gid":3219,"id":370537069,"exchange":"binance","price":11822.97,"quantity":0.001259,"quoteQuantity":14.88511923,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-01T19:35:00.525Z","fee":0.00052706,"feeCurrency":"BNB"},{"gid":3220,"id":370537070,"exchange":"binance","price":11822.96,"quantity":0.002317,"quoteQuantity":27.39379832,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-01T19:35:00.525Z","fee":0.00096998,"feeCurrency":"BNB"},{"gid":3221,"id":370539477,"exchange":"binance","price":11801.13,"quantity":0.002456,"quoteQuantity":28.98357528,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T19:36:00.541Z","fee":0.00102963,"feeCurrency":"BNB"},{"gid":3222,"id":370539478,"exchange":"binance","price":11801.51,"quantity":0.004763,"quoteQuantity":56.21059213,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T19:36:00.541Z","fee":0.00199229,"feeCurrency":"BNB"},{"gid":3223,"id":370541385,"exchange":"binance","price":11822.32,"quantity":0.006813,"quoteQuantity":80.54546616,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-01T19:37:00.701Z","fee":0.00285355,"feeCurrency":"BNB"},{"gid":3224,"id":370541386,"exchange":"binance","price":11821.84,"quantity":0.000042,"quoteQuantity":0.49651728,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-01T19:37:00.701Z","fee":0.00001758,"feeCurrency":"BNB"},{"gid":3225,"id":370553561,"exchange":"binance","price":11783.38,"quantity":0.007367,"quoteQuantity":86.80816046,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T19:46:00.531Z","fee":0.00308144,"feeCurrency":"BNB"},{"gid":3226,"id":370557247,"exchange":"binance","price":11757.83,"quantity":0.007532,"quoteQuantity":88.55997556,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T19:48:00.637Z","fee":0.00314606,"feeCurrency":"BNB"},{"gid":3227,"id":370558498,"exchange":"binance","price":11782.54,"quantity":0.014094,"quoteQuantity":166.06311876,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-01T19:49:00.587Z","fee":0.00590118,"feeCurrency":"BNB"},{"gid":3228,"id":370566869,"exchange":"binance","price":11778.98,"quantity":0.000003,"quoteQuantity":0.03533694,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-01T19:59:00.57Z","fee":0.00000124,"feeCurrency":"BNB"},{"gid":3229,"id":370566870,"exchange":"binance","price":11778.77,"quantity":0.001407,"quoteQuantity":16.57272939,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-01T19:59:00.57Z","fee":0.00058416,"feeCurrency":"BNB"},{"gid":3230,"id":370584540,"exchange":"binance","price":11714.03,"quantity":0.007232,"quoteQuantity":84.71586496,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T20:17:00.716Z","fee":0.00296019,"feeCurrency":"BNB"},{"gid":3231,"id":370584541,"exchange":"binance","price":11714.2,"quantity":0.000702,"quoteQuantity":8.2233684,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T20:17:00.716Z","fee":0.00028659,"feeCurrency":"BNB"},{"gid":3232,"id":370590375,"exchange":"binance","price":11752.2,"quantity":0.007142,"quoteQuantity":83.9342124,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-01T20:19:00.92Z","fee":0.00293282,"feeCurrency":"BNB"},{"gid":3233,"id":370590376,"exchange":"binance","price":11752.12,"quantity":0.000139,"quoteQuantity":1.63354468,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-01T20:19:00.92Z","fee":0.00005707,"feeCurrency":"BNB"},{"gid":3234,"id":370641990,"exchange":"binance","price":11801,"quantity":0.007241,"quoteQuantity":85.451041,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T21:20:00.567Z","fee":0.00296721,"feeCurrency":"BNB"},{"gid":3235,"id":370662751,"exchange":"binance","price":11755.74,"quantity":0.007465,"quoteQuantity":87.7565991,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-01T21:45:00.921Z","fee":0.00305779,"feeCurrency":"BNB"},{"gid":3236,"id":370663673,"exchange":"binance","price":11775.35,"quantity":0.007304,"quoteQuantity":86.0071564,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-01T21:46:00.506Z","fee":0.00298482,"feeCurrency":"BNB"},{"gid":3237,"id":370663674,"exchange":"binance","price":11774.59,"quantity":0.00666,"quoteQuantity":78.4187694,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-01T21:46:00.506Z","fee":0.00272148,"feeCurrency":"BNB"},{"gid":3238,"id":370683123,"exchange":"binance","price":11739.24,"quantity":0.001396,"quoteQuantity":16.38797904,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-01T22:08:00.609Z","fee":0.00057637,"feeCurrency":"BNB"},{"gid":3239,"id":370774180,"exchange":"binance","price":11810.62,"quantity":0.007164,"quoteQuantity":84.61128168,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T00:18:00.792Z","fee":0.0029285,"feeCurrency":"BNB"},{"gid":3240,"id":370779854,"exchange":"binance","price":11778.21,"quantity":0.007309,"quoteQuantity":86.08693689,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T00:23:00.632Z","fee":0.00299606,"feeCurrency":"BNB"},{"gid":3241,"id":370841722,"exchange":"binance","price":11909.8,"quantity":0.013166,"quoteQuantity":156.8044268,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T01:43:00.536Z","fee":0.00539528,"feeCurrency":"BNB"},{"gid":3242,"id":370842370,"exchange":"binance","price":11910.56,"quantity":0.001462,"quoteQuantity":17.41323872,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T01:43:34.03Z","fee":0.00059667,"feeCurrency":"BNB"},{"gid":3243,"id":370861060,"exchange":"binance","price":11926.62,"quantity":0.000017,"quoteQuantity":0.20275254,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T02:01:00.543Z","fee":0.00000815,"feeCurrency":"BNB"},{"gid":3244,"id":370861061,"exchange":"binance","price":11927.25,"quantity":0.000207,"quoteQuantity":2.46894075,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T02:01:00.543Z","fee":0.00008566,"feeCurrency":"BNB"},{"gid":3245,"id":370861062,"exchange":"binance","price":11927.4,"quantity":0.006355,"quoteQuantity":75.798627,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T02:01:00.543Z","fee":0.00259464,"feeCurrency":"BNB"},{"gid":3246,"id":370864134,"exchange":"binance","price":11929.82,"quantity":0.005921,"quoteQuantity":70.63646422,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T02:04:00.726Z","fee":0.00241943,"feeCurrency":"BNB"},{"gid":3247,"id":370879449,"exchange":"binance","price":11940.69,"quantity":0.001,"quoteQuantity":11.94069,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T02:19:57.846Z","fee":0.00040873,"feeCurrency":"BNB"},{"gid":3248,"id":370883085,"exchange":"binance","price":11908.52,"quantity":0.121111,"quoteQuantity":1442.25276572,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T02:23:09.069Z","fee":0.04939131,"feeCurrency":"BNB"},{"gid":3249,"id":370883086,"exchange":"binance","price":11908.43,"quantity":0.080547,"quoteQuantity":959.18831121,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T02:23:09.069Z","fee":0.03284831,"feeCurrency":"BNB"},{"gid":3250,"id":370892001,"exchange":"binance","price":11905.29,"quantity":0.01,"quoteQuantity":119.0529,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T02:34:54.261Z","fee":0.00409656,"feeCurrency":"BNB"},{"gid":3251,"id":370917682,"exchange":"binance","price":11894.55,"quantity":0.01,"quoteQuantity":118.9455,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T03:06:36.598Z","fee":0.00409634,"feeCurrency":"BNB"},{"gid":3252,"id":370971782,"exchange":"binance","price":11965.39,"quantity":0.006453,"quoteQuantity":77.21266167,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T04:01:00.565Z","fee":0.00262721,"feeCurrency":"BNB"},{"gid":3253,"id":370975719,"exchange":"binance","price":11968.61,"quantity":0.003011,"quoteQuantity":36.03748471,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-08-02T04:03:33.836Z","fee":0.0012279,"feeCurrency":"BNB"},{"gid":3254,"id":370975720,"exchange":"binance","price":11968.61,"quantity":0.02043,"quoteQuantity":244.5187023,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-08-02T04:03:33.856Z","fee":0.00833152,"feeCurrency":"BNB"},{"gid":3255,"id":370975721,"exchange":"binance","price":11968.61,"quantity":0.003012,"quoteQuantity":36.04945332,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-08-02T04:03:33.887Z","fee":0.00122831,"feeCurrency":"BNB"},{"gid":3256,"id":371010572,"exchange":"binance","price":12012.26,"quantity":0.008625,"quoteQuantity":103.6057425,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T04:21:01.053Z","fee":0.00353842,"feeCurrency":"BNB"},{"gid":3257,"id":371019631,"exchange":"binance","price":11928.31,"quantity":0.008987,"quoteQuantity":107.19972197,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T04:23:00.615Z","fee":0.00367178,"feeCurrency":"BNB"},{"gid":3258,"id":371021565,"exchange":"binance","price":11971.42,"quantity":0.007112,"quoteQuantity":85.14073904,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T04:24:00.686Z","fee":0.00291986,"feeCurrency":"BNB"},{"gid":3259,"id":371021566,"exchange":"binance","price":11970.72,"quantity":0.008739,"quoteQuantity":104.61212208,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T04:24:00.686Z","fee":0.00358762,"feeCurrency":"BNB"},{"gid":3260,"id":371022779,"exchange":"binance","price":11957.23,"quantity":0.001761,"quoteQuantity":21.05668203,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T04:24:45.502Z","fee":0.00072131,"feeCurrency":"BNB"},{"gid":3261,"id":371023214,"exchange":"binance","price":11941.71,"quantity":0.008814,"quoteQuantity":105.25423194,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T04:25:00.725Z","fee":0.00361262,"feeCurrency":"BNB"},{"gid":3262,"id":371023215,"exchange":"binance","price":11941.71,"quantity":0.000155,"quoteQuantity":1.85096505,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T04:25:00.725Z","fee":0.0000656,"feeCurrency":"BNB"},{"gid":3263,"id":371027803,"exchange":"binance","price":11923.54,"quantity":0.009129,"quoteQuantity":108.84999666,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T04:29:00.554Z","fee":0.00373853,"feeCurrency":"BNB"},{"gid":3264,"id":371041843,"exchange":"binance","price":11829.03,"quantity":0.018098,"quoteQuantity":214.08178494,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T04:38:09.08Z","fee":0.00741951,"feeCurrency":"BNB"},{"gid":3265,"id":371072915,"exchange":"binance","price":11277.65,"quantity":0.003,"quoteQuantity":33.83295,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T04:40:05.015Z","fee":0.00121621,"feeCurrency":"BNB"},{"gid":3266,"id":371072916,"exchange":"binance","price":11280.29,"quantity":0.007,"quoteQuantity":78.96203,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T04:40:05.015Z","fee":0.00283783,"feeCurrency":"BNB"},{"gid":3267,"id":371077726,"exchange":"binance","price":11158.9,"quantity":0.01,"quoteQuantity":111.589,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T04:40:38.146Z","fee":0.00403521,"feeCurrency":"BNB"},{"gid":3268,"id":371114171,"exchange":"binance","price":10881.22,"quantity":0.01,"quoteQuantity":108.8122,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T04:43:26.297Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":3269,"id":371126285,"exchange":"binance","price":11011.38,"quantity":0.002262,"quoteQuantity":24.90774156,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T04:44:53.64Z","fee":0.00094171,"feeCurrency":"BNB"},{"gid":3270,"id":371126286,"exchange":"binance","price":11011.38,"quantity":0.007738,"quoteQuantity":85.20605844,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T04:44:53.64Z","fee":0.00000774,"feeCurrency":"BTC"},{"gid":3271,"id":371153075,"exchange":"binance","price":11220.74,"quantity":0.1,"quoteQuantity":1122.074,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T04:49:07.462Z","fee":0.0001,"feeCurrency":"BTC"},{"gid":3272,"id":371178068,"exchange":"binance","price":11208.46,"quantity":0.01,"quoteQuantity":112.0846,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T04:54:42.558Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":3273,"id":371199409,"exchange":"binance","price":11143.19,"quantity":0.01,"quoteQuantity":111.4319,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-08-02T05:00:44.23Z","fee":0.00001,"feeCurrency":"BTC"},{"gid":3274,"id":371222279,"exchange":"binance","price":11342.01,"quantity":0.05,"quoteQuantity":567.1005,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T05:08:51.654Z","fee":0.00005,"feeCurrency":"BTC"},{"gid":3275,"id":371237510,"exchange":"binance","price":11331.36,"quantity":0.01,"quoteQuantity":113.3136,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-08-02T05:13:11.463Z","fee":0.00416296,"feeCurrency":"BNB"},{"gid":3276,"id":371383790,"exchange":"binance","price":11297.3,"quantity":0.05,"quoteQuantity":564.865,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-08-02T06:40:25.836Z","fee":0.02051983,"feeCurrency":"BNB"},{"gid":3277,"id":371411642,"exchange":"binance","price":11286.02,"quantity":0.006962,"quoteQuantity":78.57327124,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T07:08:18.72Z","fee":0.00286513,"feeCurrency":"BNB"},{"gid":3278,"id":371411643,"exchange":"binance","price":11286.25,"quantity":0.007034,"quoteQuantity":79.3874825,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T07:08:18.72Z","fee":0.00289395,"feeCurrency":"BNB"},{"gid":3279,"id":371411644,"exchange":"binance","price":11286.4,"quantity":0.006763,"quoteQuantity":76.3299232,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T07:08:18.72Z","fee":0.0027828,"feeCurrency":"BNB"},{"gid":3280,"id":371411645,"exchange":"binance","price":11286.65,"quantity":0.014384,"quoteQuantity":162.3471736,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T07:08:18.72Z","fee":0.00591963,"feeCurrency":"BNB"},{"gid":3281,"id":371411646,"exchange":"binance","price":11286.85,"quantity":0.007081,"quoteQuantity":79.92218485,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T07:08:18.72Z","fee":0.00291453,"feeCurrency":"BNB"},{"gid":3282,"id":371411647,"exchange":"binance","price":11286.95,"quantity":0.00628,"quoteQuantity":70.882046,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T07:08:18.72Z","fee":0.00258521,"feeCurrency":"BNB"},{"gid":3283,"id":371411648,"exchange":"binance","price":11287.33,"quantity":0.001496,"quoteQuantity":16.88584568,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-08-02T07:08:18.73Z","fee":0.00061748,"feeCurrency":"BNB"},{"gid":3284,"id":371461782,"exchange":"binance","price":11340.16,"quantity":0.005925,"quoteQuantity":67.190448,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T07:59:57.253Z","fee":0.00241331,"feeCurrency":"BNB"},{"gid":3285,"id":371461783,"exchange":"binance","price":11340.74,"quantity":0.004075,"quoteQuantity":46.2135155,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T07:59:57.253Z","fee":0.00166042,"feeCurrency":"BNB"},{"gid":3286,"id":371463214,"exchange":"binance","price":11317,"quantity":0.01,"quoteQuantity":113.17,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-08-02T08:00:53.557Z","fee":0.00407099,"feeCurrency":"BNB"},{"gid":3287,"id":371491601,"exchange":"binance","price":11305.1,"quantity":0.013088,"quoteQuantity":147.9611488,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T08:28:02.161Z","fee":0.00532431,"feeCurrency":"BNB"},{"gid":3288,"id":371539359,"exchange":"binance","price":11292.74,"quantity":0.021517,"quoteQuantity":242.98588658,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T09:29:00.912Z","fee":0.00879765,"feeCurrency":"BNB"},{"gid":3289,"id":371559618,"exchange":"binance","price":11287.62,"quantity":0.013298,"quoteQuantity":150.10277076,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T10:04:00.534Z","fee":0.00542031,"feeCurrency":"BNB"},{"gid":3290,"id":371595261,"exchange":"binance","price":11171.85,"quantity":0.014411,"quoteQuantity":160.99753035,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T10:46:00.549Z","fee":0.00589703,"feeCurrency":"BNB"},{"gid":3291,"id":371597108,"exchange":"binance","price":11198.94,"quantity":0.007442,"quoteQuantity":83.34251148,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T10:47:00.534Z","fee":0.00304818,"feeCurrency":"BNB"},{"gid":3292,"id":371597109,"exchange":"binance","price":11198.07,"quantity":0.012594,"quoteQuantity":141.02849358,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T10:47:00.534Z","fee":0.00515799,"feeCurrency":"BNB"},{"gid":3293,"id":371599762,"exchange":"binance","price":11248.84,"quantity":0.007083,"quoteQuantity":79.67553372,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T10:48:00.566Z","fee":0.002908,"feeCurrency":"BNB"},{"gid":3294,"id":371599763,"exchange":"binance","price":11247.04,"quantity":0.013973,"quoteQuantity":157.15488992,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T10:48:00.566Z","fee":0.00573586,"feeCurrency":"BNB"},{"gid":3295,"id":371612162,"exchange":"binance","price":11267.95,"quantity":0.05,"quoteQuantity":563.3975,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T11:03:32.492Z","fee":0.0204393,"feeCurrency":"BNB"},{"gid":3296,"id":371614060,"exchange":"binance","price":11259.29,"quantity":0.010267,"quoteQuantity":115.59913043,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T11:07:28.872Z","fee":0.00420212,"feeCurrency":"BNB"},{"gid":3297,"id":371614061,"exchange":"binance","price":11259.29,"quantity":0.0014,"quoteQuantity":15.763006,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T11:07:28.872Z","fee":0.00057282,"feeCurrency":"BNB"},{"gid":3298,"id":371614062,"exchange":"binance","price":11259.3,"quantity":0.008333,"quoteQuantity":93.8237469,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T11:07:28.872Z","fee":0.00340834,"feeCurrency":"BNB"},{"gid":3299,"id":371624213,"exchange":"binance","price":11198.35,"quantity":0.014049,"quoteQuantity":157.32561915,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T11:17:00.578Z","fee":0.00576575,"feeCurrency":"BNB"},{"gid":3300,"id":371636902,"exchange":"binance","price":11106.24,"quantity":0.006635,"quoteQuantity":73.6899024,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T11:22:00.599Z","fee":0.00272193,"feeCurrency":"BNB"},{"gid":3301,"id":371636903,"exchange":"binance","price":11103.73,"quantity":0.011941,"quoteQuantity":132.58963993,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T11:22:00.599Z","fee":0.00489756,"feeCurrency":"BNB"},{"gid":3302,"id":371639192,"exchange":"binance","price":11138.27,"quantity":0.01914,"quoteQuantity":213.1864878,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T11:23:00.557Z","fee":0.00785976,"feeCurrency":"BNB"},{"gid":3303,"id":371640210,"exchange":"binance","price":11132.57,"quantity":0.01,"quoteQuantity":111.3257,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T11:23:47.689Z","fee":0.00409589,"feeCurrency":"BNB"},{"gid":3304,"id":371640375,"exchange":"binance","price":11124.56,"quantity":0.00346,"quoteQuantity":38.4909776,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-08-02T11:23:53.836Z","fee":0.00141717,"feeCurrency":"BNB"},{"gid":3305,"id":371640379,"exchange":"binance","price":11124.56,"quantity":0.00654,"quoteQuantity":72.7546224,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-08-02T11:23:53.927Z","fee":0.00267871,"feeCurrency":"BNB"},{"gid":3306,"id":371649011,"exchange":"binance","price":11160.86,"quantity":0.006664,"quoteQuantity":74.37597104,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T11:31:00.584Z","fee":0.0027407,"feeCurrency":"BNB"},{"gid":3307,"id":371649012,"exchange":"binance","price":11160.67,"quantity":0.003533,"quoteQuantity":39.43064711,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T11:31:00.584Z","fee":0.00145299,"feeCurrency":"BNB"},{"gid":3308,"id":371649013,"exchange":"binance","price":11160.61,"quantity":0.00921,"quoteQuantity":102.7892181,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T11:31:00.584Z","fee":0.00378771,"feeCurrency":"BNB"},{"gid":3309,"id":371654174,"exchange":"binance","price":11120.33,"quantity":0.01,"quoteQuantity":111.2033,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T11:36:44.27Z","fee":0.00412087,"feeCurrency":"BNB"},{"gid":3310,"id":371654290,"exchange":"binance","price":11113.76,"quantity":0.01,"quoteQuantity":111.1376,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-08-02T11:36:51.057Z","fee":0.00412087,"feeCurrency":"BNB"},{"gid":3311,"id":371658545,"exchange":"binance","price":11072.81,"quantity":0.015242,"quoteQuantity":168.77177002,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T11:39:00.534Z","fee":0.00627849,"feeCurrency":"BNB"},{"gid":3312,"id":371665576,"exchange":"binance","price":11002.5,"quantity":0.007367,"quoteQuantity":81.0554175,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T11:40:00.513Z","fee":0.00303708,"feeCurrency":"BNB"},{"gid":3313,"id":371665577,"exchange":"binance","price":11003.82,"quantity":0.009367,"quoteQuantity":103.07278194,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T11:40:00.513Z","fee":0.00386126,"feeCurrency":"BNB"},{"gid":3314,"id":371672507,"exchange":"binance","price":11005.35,"quantity":0.015801,"quoteQuantity":173.89553535,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T11:43:00.573Z","fee":0.0064992,"feeCurrency":"BNB"},{"gid":3315,"id":371674019,"exchange":"binance","price":11035.77,"quantity":0.000017,"quoteQuantity":0.18760809,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T11:44:00.595Z","fee":0.00000701,"feeCurrency":"BNB"},{"gid":3316,"id":371674020,"exchange":"binance","price":11035.76,"quantity":0.004906,"quoteQuantity":54.14143856,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T11:44:00.595Z","fee":0.00202506,"feeCurrency":"BNB"},{"gid":3317,"id":371674021,"exchange":"binance","price":11034.95,"quantity":0.001812,"quoteQuantity":19.9953294,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T11:44:00.595Z","fee":0.00074788,"feeCurrency":"BNB"},{"gid":3318,"id":371674022,"exchange":"binance","price":11034.94,"quantity":0.000029,"quoteQuantity":0.32001326,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T11:44:00.595Z","fee":0.00001196,"feeCurrency":"BNB"},{"gid":3319,"id":371674023,"exchange":"binance","price":11034.86,"quantity":0.000007,"quoteQuantity":0.07724402,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T11:44:00.595Z","fee":0.00000288,"feeCurrency":"BNB"},{"gid":3320,"id":371674024,"exchange":"binance","price":11034.84,"quantity":0.006923,"quoteQuantity":76.39419732,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T11:44:00.595Z","fee":0.00285739,"feeCurrency":"BNB"},{"gid":3321,"id":371674025,"exchange":"binance","price":11034.03,"quantity":0.003977,"quoteQuantity":43.88233731,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T11:44:00.595Z","fee":0.00164134,"feeCurrency":"BNB"},{"gid":3322,"id":371676000,"exchange":"binance","price":11060,"quantity":0.017968,"quoteQuantity":198.72608,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T11:45:00.546Z","fee":0.00742301,"feeCurrency":"BNB"},{"gid":3323,"id":371682861,"exchange":"binance","price":10958.96,"quantity":0.016637,"quoteQuantity":182.32421752,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T11:49:00.568Z","fee":0.00686317,"feeCurrency":"BNB"},{"gid":3324,"id":371720101,"exchange":"binance","price":11156,"quantity":0.1,"quoteQuantity":1115.6,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T12:15:05.724Z","fee":0.04112743,"feeCurrency":"BNB"},{"gid":3325,"id":371734969,"exchange":"binance","price":11086.4,"quantity":0.02,"quoteQuantity":221.728,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-08-02T12:28:22.792Z","fee":0.0082318,"feeCurrency":"BNB"},{"gid":3326,"id":371823614,"exchange":"binance","price":11075.56,"quantity":0.007244,"quoteQuantity":80.23135664,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T14:07:23.406Z","fee":0.00296997,"feeCurrency":"BNB"},{"gid":3327,"id":371823615,"exchange":"binance","price":11075.75,"quantity":0.002756,"quoteQuantity":30.524767,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T14:07:23.406Z","fee":0.00113219,"feeCurrency":"BNB"},{"gid":3328,"id":371860757,"exchange":"binance","price":11135.37,"quantity":0.02,"quoteQuantity":222.7074,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T14:59:20.852Z","fee":0.00816548,"feeCurrency":"BNB"},{"gid":3329,"id":371884922,"exchange":"binance","price":11103.33,"quantity":0.01,"quoteQuantity":111.0333,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T15:42:21.201Z","fee":0.00407652,"feeCurrency":"BNB"},{"gid":3330,"id":371899488,"exchange":"binance","price":11113.43,"quantity":0.018707,"quoteQuantity":207.89893501,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T16:01:00.538Z","fee":0.00765297,"feeCurrency":"BNB"},{"gid":3331,"id":371901514,"exchange":"binance","price":11075.39,"quantity":0.015307,"quoteQuantity":169.53099473,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T16:03:00.659Z","fee":0.00624558,"feeCurrency":"BNB"},{"gid":3332,"id":371914855,"exchange":"binance","price":11122.83,"quantity":0.018845,"quoteQuantity":209.60973135,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T16:23:00.909Z","fee":0.00767919,"feeCurrency":"BNB"},{"gid":3333,"id":371952894,"exchange":"binance","price":11175.24,"quantity":0.014277,"quoteQuantity":159.54890148,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T17:07:00.588Z","fee":0.0058033,"feeCurrency":"BNB"},{"gid":3334,"id":371960830,"exchange":"binance","price":11189.51,"quantity":0.014203,"quoteQuantity":158.92461053,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T17:16:00.806Z","fee":0.00575675,"feeCurrency":"BNB"},{"gid":3335,"id":371962505,"exchange":"binance","price":11228.85,"quantity":0.007097,"quoteQuantity":79.69114845,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T17:17:00.613Z","fee":0.00288673,"feeCurrency":"BNB"},{"gid":3336,"id":371962506,"exchange":"binance","price":11228.72,"quantity":0.003645,"quoteQuantity":40.9286844,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T17:17:00.613Z","fee":0.0014826,"feeCurrency":"BNB"},{"gid":3337,"id":371962507,"exchange":"binance","price":11228.44,"quantity":0.004368,"quoteQuantity":49.04582592,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T17:17:00.613Z","fee":0.00177663,"feeCurrency":"BNB"},{"gid":3338,"id":371962508,"exchange":"binance","price":11227.87,"quantity":0.000034,"quoteQuantity":0.38174758,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T17:17:00.613Z","fee":0.00001382,"feeCurrency":"BNB"},{"gid":3339,"id":371962509,"exchange":"binance","price":11227.07,"quantity":0.005472,"quoteQuantity":61.43452704,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T17:17:00.613Z","fee":0.0022254,"feeCurrency":"BNB"},{"gid":3340,"id":371973807,"exchange":"binance","price":11231.12,"quantity":0.05,"quoteQuantity":561.556,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T17:30:05.624Z","fee":0.0202922,"feeCurrency":"BNB"},{"gid":3341,"id":372017059,"exchange":"binance","price":11221.29,"quantity":0.013924,"quoteQuantity":156.24524196,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T18:36:00.545Z","fee":0.0056129,"feeCurrency":"BNB"},{"gid":3342,"id":372020279,"exchange":"binance","price":11203.65,"quantity":0.013964,"quoteQuantity":156.4477686,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T18:41:01.142Z","fee":0.00562902,"feeCurrency":"BNB"},{"gid":3343,"id":372049065,"exchange":"binance","price":11161.25,"quantity":0.000016,"quoteQuantity":0.17858,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T19:40:00.598Z","fee":0.00000804,"feeCurrency":"BNB"},{"gid":3344,"id":372049066,"exchange":"binance","price":11161.93,"quantity":0.002018,"quoteQuantity":22.52477474,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T19:40:00.598Z","fee":0.00081354,"feeCurrency":"BNB"},{"gid":3345,"id":372049067,"exchange":"binance","price":11162.73,"quantity":0.012657,"quoteQuantity":141.28667361,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T19:40:00.598Z","fee":0.0050988,"feeCurrency":"BNB"},{"gid":3346,"id":372065914,"exchange":"binance","price":11196.5,"quantity":0.020039,"quoteQuantity":224.3666635,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T19:58:00.702Z","fee":0.00811283,"feeCurrency":"BNB"},{"gid":3347,"id":372082068,"exchange":"binance","price":11132.2,"quantity":0.014667,"quoteQuantity":163.2759774,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T20:26:00.895Z","fee":0.00587771,"feeCurrency":"BNB"},{"gid":3348,"id":372084727,"exchange":"binance","price":11133.38,"quantity":0.0146,"quoteQuantity":162.547348,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T20:29:00.64Z","fee":0.0058481,"feeCurrency":"BNB"},{"gid":3349,"id":372138251,"exchange":"binance","price":11076.63,"quantity":0.001805,"quoteQuantity":19.99331715,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T21:40:00.714Z","fee":0.00072536,"feeCurrency":"BNB"},{"gid":3350,"id":372138252,"exchange":"binance","price":11076.62,"quantity":0.006717,"quoteQuantity":74.40165654,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T21:40:00.714Z","fee":0.00269931,"feeCurrency":"BNB"},{"gid":3351,"id":372138253,"exchange":"binance","price":11076.52,"quantity":0.007446,"quoteQuantity":82.47576792,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T21:40:00.714Z","fee":0.00299225,"feeCurrency":"BNB"},{"gid":3352,"id":372138254,"exchange":"binance","price":11076.31,"quantity":0.002311,"quoteQuantity":25.59735241,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T21:40:00.714Z","fee":0.00092868,"feeCurrency":"BNB"},{"gid":3353,"id":372140038,"exchange":"binance","price":11099.69,"quantity":0.018513,"quoteQuantity":205.48856097,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-02T21:41:00.936Z","fee":0.00740797,"feeCurrency":"BNB"},{"gid":3354,"id":372211508,"exchange":"binance","price":11041.55,"quantity":0.015636,"quoteQuantity":172.6456758,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-02T23:52:00.539Z","fee":0.00621094,"feeCurrency":"BNB"},{"gid":3355,"id":372218038,"exchange":"binance","price":11057.19,"quantity":0.003779,"quoteQuantity":41.78512101,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-03T00:01:00.675Z","fee":0.00149588,"feeCurrency":"BNB"},{"gid":3356,"id":372218039,"exchange":"binance","price":11057.85,"quantity":0.011455,"quoteQuantity":126.66767175,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-03T00:01:00.675Z","fee":0.00453513,"feeCurrency":"BNB"},{"gid":3357,"id":372231162,"exchange":"binance","price":11036.59,"quantity":0.017766,"quoteQuantity":196.07605794,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T00:08:00.85Z","fee":0.00707749,"feeCurrency":"BNB"},{"gid":3358,"id":372233346,"exchange":"binance","price":11055.32,"quantity":0.029174,"quoteQuantity":322.52790568,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-08-03T00:10:00.993Z","fee":0.01159625,"feeCurrency":"BNB"},{"gid":3359,"id":372233369,"exchange":"binance","price":11055.32,"quantity":0.0028,"quoteQuantity":30.954896,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-08-03T00:10:02.73Z","fee":0.00111311,"feeCurrency":"BNB"},{"gid":3360,"id":372233370,"exchange":"binance","price":11055.32,"quantity":0.004522,"quoteQuantity":49.99215704,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-08-03T00:10:02.753Z","fee":0.00179688,"feeCurrency":"BNB"},{"gid":3361,"id":372233379,"exchange":"binance","price":11055.32,"quantity":0.001328,"quoteQuantity":14.68146496,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-08-03T00:10:03.465Z","fee":0.00052872,"feeCurrency":"BNB"},{"gid":3362,"id":372233559,"exchange":"binance","price":11055.32,"quantity":0.024795,"quoteQuantity":274.1166594,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-08-03T00:10:15.093Z","fee":0.00984908,"feeCurrency":"BNB"},{"gid":3363,"id":372233561,"exchange":"binance","price":11055.32,"quantity":0.024795,"quoteQuantity":274.1166594,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-08-03T00:10:15.198Z","fee":0.00984908,"feeCurrency":"BNB"},{"gid":3364,"id":372233562,"exchange":"binance","price":11055.32,"quantity":0.012586,"quoteQuantity":139.14225752,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":true,"tradedAt":"2020-08-03T00:10:15.299Z","fee":0.00499999,"feeCurrency":"BNB"},{"gid":3365,"id":372247659,"exchange":"binance","price":11098.33,"quantity":0.018494,"quoteQuantity":205.25251502,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T00:31:00.85Z","fee":0.00728406,"feeCurrency":"BNB"},{"gid":3366,"id":372253929,"exchange":"binance","price":11129.1,"quantity":0.019046,"quoteQuantity":211.9648386,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T00:41:00.624Z","fee":0.00752721,"feeCurrency":"BNB"},{"gid":3367,"id":372280012,"exchange":"binance","price":11155,"quantity":0.006947,"quoteQuantity":77.493785,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T01:21:00.575Z","fee":0.0027611,"feeCurrency":"BNB"},{"gid":3368,"id":372280013,"exchange":"binance","price":11154.91,"quantity":0.012378,"quoteQuantity":138.07547598,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T01:21:00.575Z","fee":0.00491963,"feeCurrency":"BNB"},{"gid":3369,"id":372309157,"exchange":"binance","price":11177.97,"quantity":0.019798,"quoteQuantity":221.30145006,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T02:20:00.568Z","fee":0.00785298,"feeCurrency":"BNB"},{"gid":3370,"id":372349739,"exchange":"binance","price":11145.54,"quantity":0.014488,"quoteQuantity":161.47658352,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-03T03:40:00.708Z","fee":0.0057892,"feeCurrency":"BNB"},{"gid":3371,"id":372356150,"exchange":"binance","price":11118.61,"quantity":0.014829,"quoteQuantity":164.87786769,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-03T03:55:00.554Z","fee":0.00590491,"feeCurrency":"BNB"},{"gid":3372,"id":372527019,"exchange":"binance","price":11153.04,"quantity":0.568262,"quoteQuantity":6337.84881648,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-03T09:32:00.664Z","fee":0.21984679,"feeCurrency":"BNB"},{"gid":3373,"id":372527584,"exchange":"binance","price":11163.03,"quantity":0.109639,"quoteQuantity":1223.90344617,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T09:33:00.524Z","fee":0.04243235,"feeCurrency":"BNB"},{"gid":3374,"id":372527585,"exchange":"binance","price":11163.03,"quantity":0.192403,"quoteQuantity":2147.80046109,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T09:33:00.524Z","fee":0.07446358,"feeCurrency":"BNB"},{"gid":3375,"id":372527586,"exchange":"binance","price":11163.03,"quantity":0.003844,"quoteQuantity":42.91068732,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T09:33:00.524Z","fee":0.0014877,"feeCurrency":"BNB"},{"gid":3376,"id":372527587,"exchange":"binance","price":11163.03,"quantity":0.05,"quoteQuantity":558.1515,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T09:33:00.524Z","fee":0.01935093,"feeCurrency":"BNB"},{"gid":3377,"id":372527588,"exchange":"binance","price":11163.03,"quantity":0.008374,"quoteQuantity":93.47921322,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T09:33:00.524Z","fee":0.00324089,"feeCurrency":"BNB"},{"gid":3378,"id":372527589,"exchange":"binance","price":11162.84,"quantity":0.00178,"quoteQuantity":19.8698552,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T09:33:00.524Z","fee":0.00068887,"feeCurrency":"BNB"},{"gid":3379,"id":372527590,"exchange":"binance","price":11162.8,"quantity":0.008433,"quoteQuantity":94.1358924,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T09:33:00.524Z","fee":0.00326366,"feeCurrency":"BNB"},{"gid":3380,"id":372527591,"exchange":"binance","price":11162.29,"quantity":0.072879,"quoteQuantity":813.49653291,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T09:33:00.524Z","fee":0.02820367,"feeCurrency":"BNB"},{"gid":3381,"id":372527592,"exchange":"binance","price":11161.83,"quantity":0.007191,"quoteQuantity":80.26471953,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T09:33:00.524Z","fee":0.00278274,"feeCurrency":"BNB"},{"gid":3382,"id":372527593,"exchange":"binance","price":11161.78,"quantity":0.017696,"quoteQuantity":197.51885888,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T09:33:00.524Z","fee":0.00684791,"feeCurrency":"BNB"},{"gid":3383,"id":372527594,"exchange":"binance","price":11161.21,"quantity":0.001434,"quoteQuantity":16.00517514,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T09:33:00.524Z","fee":0.00055488,"feeCurrency":"BNB"},{"gid":3384,"id":372527595,"exchange":"binance","price":11160.6,"quantity":0.015,"quoteQuantity":167.409,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T09:33:00.524Z","fee":0.00580401,"feeCurrency":"BNB"},{"gid":3385,"id":372527596,"exchange":"binance","price":11160.48,"quantity":0.007087,"quoteQuantity":79.09432176,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T09:33:00.524Z","fee":0.00274217,"feeCurrency":"BNB"},{"gid":3386,"id":372527597,"exchange":"binance","price":11160.38,"quantity":0.219972,"quoteQuantity":2454.97110936,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T09:33:00.524Z","fee":0.08511309,"feeCurrency":"BNB"},{"gid":3387,"id":372527598,"exchange":"binance","price":11159.93,"quantity":0.2,"quoteQuantity":2231.986,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T09:33:00.524Z","fee":0.07738227,"feeCurrency":"BNB"},{"gid":3388,"id":372527599,"exchange":"binance","price":11159.92,"quantity":0.047748,"quoteQuantity":532.86386016,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T09:33:00.524Z","fee":0.01847422,"feeCurrency":"BNB"},{"gid":3389,"id":372528042,"exchange":"binance","price":11166.43,"quantity":0.5,"quoteQuantity":5583.215,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-03T09:33:55.806Z","fee":0.0005,"feeCurrency":"BTC"},{"gid":3390,"id":372574616,"exchange":"binance","price":11192.55,"quantity":0.01,"quoteQuantity":111.9255,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T11:04:05.548Z","fee":0.00381389,"feeCurrency":"BNB"},{"gid":3391,"id":372792282,"exchange":"binance","price":11359.27,"quantity":0.01,"quoteQuantity":113.5927,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T15:43:53.948Z","fee":0.00386007,"feeCurrency":"BNB"},{"gid":3392,"id":372845284,"exchange":"binance","price":11448.18,"quantity":0.01,"quoteQuantity":114.4818,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T16:32:37.157Z","fee":0.00386564,"feeCurrency":"BNB"},{"gid":3393,"id":372849777,"exchange":"binance","price":11410.77,"quantity":0.002,"quoteQuantity":22.82154,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-03T16:35:45.807Z","fee":0.00077427,"feeCurrency":"BNB"},{"gid":3394,"id":372892754,"exchange":"binance","price":11387.72,"quantity":0.000004,"quoteQuantity":0.04555088,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-03T17:26:31.957Z","fee":0.00000384,"feeCurrency":"BNB"},{"gid":3395,"id":372892755,"exchange":"binance","price":11387.72,"quantity":0.001,"quoteQuantity":11.38772,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-03T17:26:31.957Z","fee":0.00038419,"feeCurrency":"BNB"},{"gid":3396,"id":372892756,"exchange":"binance","price":11388.15,"quantity":0.000996,"quoteQuantity":11.3425974,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-03T17:26:31.957Z","fee":0.00038419,"feeCurrency":"BNB"},{"gid":3397,"id":372893047,"exchange":"binance","price":11395.88,"quantity":0.002,"quoteQuantity":22.79176,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-03T17:26:43.236Z","fee":0.00076839,"feeCurrency":"BNB"},{"gid":3398,"id":372900965,"exchange":"binance","price":11417.95,"quantity":0.00686,"quoteQuantity":78.327137,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T17:39:01.087Z","fee":0.00264253,"feeCurrency":"BNB"},{"gid":3399,"id":372900966,"exchange":"binance","price":11417.88,"quantity":0.000119,"quoteQuantity":1.35872772,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T17:39:01.087Z","fee":0.00004583,"feeCurrency":"BNB"},{"gid":3400,"id":372996262,"exchange":"binance","price":11401.36,"quantity":0.006756,"quoteQuantity":77.02758816,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T21:06:00.634Z","fee":0.00261065,"feeCurrency":"BNB"},{"gid":3401,"id":373033650,"exchange":"binance","price":11290.67,"quantity":0.000017,"quoteQuantity":0.19194139,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T21:46:00.544Z","fee":0.00000654,"feeCurrency":"BNB"},{"gid":3402,"id":373033651,"exchange":"binance","price":11289.1,"quantity":0.005715,"quoteQuantity":64.5172065,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T21:46:00.544Z","fee":0.00220214,"feeCurrency":"BNB"},{"gid":3403,"id":373050472,"exchange":"binance","price":11189.69,"quantity":0.003571,"quoteQuantity":39.95838299,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-03T22:00:00.801Z","fee":0.00138529,"feeCurrency":"BNB"},{"gid":3404,"id":373050473,"exchange":"binance","price":11189.71,"quantity":0.000929,"quoteQuantity":10.39524059,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-03T22:00:00.801Z","fee":0.00036087,"feeCurrency":"BNB"},{"gid":3405,"id":373053008,"exchange":"binance","price":11207.53,"quantity":0.005108,"quoteQuantity":57.24806324,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T22:01:00.545Z","fee":0.00198698,"feeCurrency":"BNB"},{"gid":3406,"id":373054858,"exchange":"binance","price":11241.92,"quantity":0.005437,"quoteQuantity":61.12231904,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T22:02:00.519Z","fee":0.00210841,"feeCurrency":"BNB"},{"gid":3407,"id":373071403,"exchange":"binance","price":11281.65,"quantity":0.005762,"quoteQuantity":65.0048673,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T22:21:00.618Z","fee":0.00223111,"feeCurrency":"BNB"},{"gid":3408,"id":373085175,"exchange":"binance","price":11240.01,"quantity":0.01,"quoteQuantity":112.4001,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-03T22:44:47.874Z","fee":0.0038616,"feeCurrency":"BNB"},{"gid":3409,"id":373085230,"exchange":"binance","price":11240.06,"quantity":0.01,"quoteQuantity":112.4006,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-03T22:44:54.951Z","fee":0.0038616,"feeCurrency":"BNB"},{"gid":3410,"id":373087347,"exchange":"binance","price":11218.11,"quantity":0.000013,"quoteQuantity":0.14583543,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-03T22:46:00.67Z","fee":0.00000385,"feeCurrency":"BNB"},{"gid":3411,"id":373087348,"exchange":"binance","price":11218.42,"quantity":0.003709,"quoteQuantity":41.60911978,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-03T22:46:00.67Z","fee":0.00143184,"feeCurrency":"BNB"},{"gid":3412,"id":373095340,"exchange":"binance","price":11194.45,"quantity":0.003783,"quoteQuantity":42.34860435,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-03T22:56:00.913Z","fee":0.00146329,"feeCurrency":"BNB"},{"gid":3413,"id":373097665,"exchange":"binance","price":11216.1,"quantity":0.000031,"quoteQuantity":0.3476991,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-03T22:59:42.598Z","fee":0.00001158,"feeCurrency":"BNB"},{"gid":3414,"id":373097666,"exchange":"binance","price":11216.31,"quantity":0.004011,"quoteQuantity":44.98861941,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-03T22:59:42.598Z","fee":0.00154897,"feeCurrency":"BNB"},{"gid":3415,"id":373097667,"exchange":"binance","price":11217.22,"quantity":0.001782,"quoteQuantity":19.98908604,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-03T22:59:42.598Z","fee":0.00068757,"feeCurrency":"BNB"},{"gid":3416,"id":373097668,"exchange":"binance","price":11217.24,"quantity":0.004176,"quoteQuantity":46.84319424,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-03T22:59:42.598Z","fee":0.00161464,"feeCurrency":"BNB"},{"gid":3417,"id":373097958,"exchange":"binance","price":11210.35,"quantity":0.000002,"quoteQuantity":0.0224207,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-03T23:00:09.4Z","fee":0.00000386,"feeCurrency":"BNB"},{"gid":3418,"id":373097959,"exchange":"binance","price":11210.54,"quantity":0.000029,"quoteQuantity":0.32510566,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-03T23:00:09.4Z","fee":0.00001158,"feeCurrency":"BNB"},{"gid":3419,"id":373097960,"exchange":"binance","price":11210.79,"quantity":0.000119,"quoteQuantity":1.33408401,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-03T23:00:09.4Z","fee":0.00004635,"feeCurrency":"BNB"},{"gid":3420,"id":373097961,"exchange":"binance","price":11210.92,"quantity":0.00985,"quoteQuantity":110.427562,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-03T23:00:09.4Z","fee":0.00380484,"feeCurrency":"BNB"},{"gid":3421,"id":373100705,"exchange":"binance","price":11230.66,"quantity":0.00534,"quoteQuantity":59.9717244,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T23:03:00.806Z","fee":0.00207467,"feeCurrency":"BNB"},{"gid":3422,"id":373100706,"exchange":"binance","price":11230.65,"quantity":0.000077,"quoteQuantity":0.86476005,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T23:03:00.806Z","fee":0.00002991,"feeCurrency":"BNB"},{"gid":3423,"id":373107422,"exchange":"binance","price":11143.21,"quantity":0.004065,"quoteQuantity":45.29714865,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-03T23:14:00.612Z","fee":0.00157304,"feeCurrency":"BNB"},{"gid":3424,"id":373109440,"exchange":"binance","price":11174.45,"quantity":0.004903,"quoteQuantity":54.78832835,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-03T23:15:00.729Z","fee":0.00190156,"feeCurrency":"BNB"},{"gid":3425,"id":373149139,"exchange":"binance","price":11301,"quantity":0.01,"quoteQuantity":113.01,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":true,"tradedAt":"2020-08-04T00:17:25.253Z","fee":0.00381314,"feeCurrency":"BNB"},{"gid":3426,"id":373149330,"exchange":"binance","price":11307.66,"quantity":0.01,"quoteQuantity":113.0766,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-04T00:17:39.919Z","fee":0.00381538,"feeCurrency":"BNB"},{"gid":3427,"id":373149830,"exchange":"binance","price":11314.97,"quantity":0.002,"quoteQuantity":22.62994,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-04T00:17:52.235Z","fee":0.00076356,"feeCurrency":"BNB"},{"gid":3428,"id":373197385,"exchange":"binance","price":11366.92,"quantity":0.01,"quoteQuantity":113.6692,"symbol":"BTCUSDT","side":"SELL","isBuyer":false,"isMaker":false,"tradedAt":"2020-08-04T01:19:10.472Z","fee":0.0038233,"feeCurrency":"BNB"},{"gid":3429,"id":373236646,"exchange":"binance","price":11303.49,"quantity":0.003329,"quoteQuantity":37.62931821,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-04T02:38:00.566Z","fee":0.00127905,"feeCurrency":"BNB"},{"gid":3430,"id":373267019,"exchange":"binance","price":11242.01,"quantity":0.000013,"quoteQuantity":0.14614613,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-04T03:32:00.556Z","fee":0.00000385,"feeCurrency":"BNB"},{"gid":3431,"id":373267020,"exchange":"binance","price":11244.46,"quantity":0.003573,"quoteQuantity":40.17645558,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-04T03:32:00.556Z","fee":0.00137703,"feeCurrency":"BNB"},{"gid":3432,"id":373329913,"exchange":"binance","price":11271.8,"quantity":0.002,"quoteQuantity":22.5436,"symbol":"BTCUSDT","side":"BUY","isBuyer":true,"isMaker":false,"tradedAt":"2020-08-04T05:40:26.013Z","fee":0.00076911,"feeCurrency":"BNB"}] \ No newline at end of file diff --git a/pkg/backtest/assets_dummy.go b/pkg/backtest/assets_dummy.go new file mode 100644 index 0000000..a6f0fcb --- /dev/null +++ b/pkg/backtest/assets_dummy.go @@ -0,0 +1,65 @@ +//go:build !web +// +build !web + +package backtest + +import ( + "bytes" + "errors" + "net/http" + "os" + "time" +) + +var assets = map[string][]byte{} + +var FS = &fs{} + +type fs struct{} + +func (fs *fs) Open(name string) (http.File, error) { + if name == "/" { + return fs, nil + } + b, ok := assets[name] + if !ok { + return nil, os.ErrNotExist + } + return &file{name: name, size: len(b), Reader: bytes.NewReader(b)}, nil +} + +func (fs *fs) Close() error { return nil } +func (fs *fs) Read(p []byte) (int, error) { return 0, nil } +func (fs *fs) Seek(offset int64, whence int) (int64, error) { return 0, nil } +func (fs *fs) Stat() (os.FileInfo, error) { return fs, nil } +func (fs *fs) Name() string { return "/" } +func (fs *fs) Size() int64 { return 0 } +func (fs *fs) Mode() os.FileMode { return 0755 } +func (fs *fs) ModTime() time.Time { return time.Time{} } +func (fs *fs) IsDir() bool { return true } +func (fs *fs) Sys() interface{} { return nil } +func (fs *fs) Readdir(count int) ([]os.FileInfo, error) { + files := []os.FileInfo{} + for name, data := range assets { + files = append(files, &file{name: name, size: len(data), Reader: bytes.NewReader(data)}) + } + return files, nil +} + +type file struct { + name string + size int + *bytes.Reader +} + +func (f *file) Close() error { return nil } +func (f *file) Readdir(count int) ([]os.FileInfo, error) { + return nil, errors.New("readdir is not supported") +} +func (f *file) Stat() (os.FileInfo, error) { return f, nil } +func (f *file) Name() string { return f.name } +func (f *file) Size() int64 { return int64(f.size) } +func (f *file) Mode() os.FileMode { return 0644 } +func (f *file) ModTime() time.Time { return time.Time{} } +func (f *file) IsDir() bool { return false } +func (f *file) Sys() interface{} { return nil } diff --git a/pkg/backtest/dumper.go b/pkg/backtest/dumper.go new file mode 100644 index 0000000..9642c85 --- /dev/null +++ b/pkg/backtest/dumper.go @@ -0,0 +1,97 @@ +package backtest + +import ( + "fmt" + "path/filepath" + "strconv" + "time" + + "go.uber.org/multierr" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/data/tsv" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const DateFormat = "2006-01-02T15:04" + +type symbolInterval struct { + Symbol string + Interval types.Interval +} + +// KLineDumper dumps the received kline data into a folder for the backtest report to load the charts. +type KLineDumper struct { + OutputDirectory string + writers map[symbolInterval]*tsv.Writer + filenames map[symbolInterval]string +} + +func NewKLineDumper(outputDirectory string) *KLineDumper { + return &KLineDumper{ + OutputDirectory: outputDirectory, + writers: make(map[symbolInterval]*tsv.Writer), + filenames: make(map[symbolInterval]string), + } +} + +func (d *KLineDumper) Filenames() map[symbolInterval]string { + return d.filenames +} + +func (d *KLineDumper) formatFileName(symbol string, interval types.Interval) string { + return filepath.Join(d.OutputDirectory, fmt.Sprintf("%s-%s.tsv", + symbol, + interval)) +} + +var csvHeader = []string{"date", "startTime", "endTime", "interval", "open", "high", "low", "close", "volume"} + +func (d *KLineDumper) encode(k types.KLine) []string { + return []string{ + time.Time(k.StartTime).Format(time.ANSIC), // ANSIC date - for javascript to parse (this works with Date.parse(date_str) + strconv.FormatInt(k.StartTime.Unix(), 10), + strconv.FormatInt(k.EndTime.Unix(), 10), + k.Interval.String(), + k.Open.String(), + k.High.String(), + k.Low.String(), + k.Close.String(), + k.Volume.String(), + } +} + +func (d *KLineDumper) Record(k types.KLine) error { + si := symbolInterval{Symbol: k.Symbol, Interval: k.Interval} + + w, ok := d.writers[si] + if !ok { + filename := d.formatFileName(k.Symbol, k.Interval) + w2, err := tsv.NewWriterFile(filename) + if err != nil { + return err + } + w = w2 + + d.writers[si] = w2 + d.filenames[si] = filename + + if err2 := w2.Write(csvHeader); err2 != nil { + return err2 + } + } + + return w.Write(d.encode(k)) +} + +func (d *KLineDumper) Close() error { + var err error = nil + for _, w := range d.writers { + w.Flush() + err2 := w.Close() + if err2 != nil { + err = multierr.Append(err, err2) + } + } + + return err +} diff --git a/pkg/backtest/dumper_test.go b/pkg/backtest/dumper_test.go new file mode 100644 index 0000000..8668d1f --- /dev/null +++ b/pkg/backtest/dumper_test.go @@ -0,0 +1,54 @@ +package backtest + +import ( + "encoding/csv" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func TestKLineDumper(t *testing.T) { + tempDir := os.TempDir() + _ = os.Mkdir(tempDir, 0755) + dumper := NewKLineDumper(tempDir) + + t1 := time.Now() + err := dumper.Record(types.KLine{ + Exchange: types.ExchangeBinance, + Symbol: "BTCUSDT", + StartTime: types.Time(t1), + EndTime: types.Time(t1.Add(time.Minute)), + Interval: types.Interval1m, + Open: fixedpoint.NewFromFloat(1000.0), + High: fixedpoint.NewFromFloat(2000.0), + Low: fixedpoint.NewFromFloat(3000.0), + Close: fixedpoint.NewFromFloat(4000.0), + Volume: fixedpoint.NewFromFloat(5000.0), + QuoteVolume: fixedpoint.NewFromFloat(6000.0), + NumberOfTrades: 10, + Closed: true, + }) + assert.NoError(t, err) + + err = dumper.Close() + assert.NoError(t, err) + + filenames := dumper.Filenames() + assert.NotEmpty(t, filenames) + for _, filename := range filenames { + f, err := os.Open(filename) + if assert.NoError(t, err) { + reader := csv.NewReader(f) + records, err2 := reader.Read() + if assert.NoError(t, err2) { + assert.NotEmptyf(t, records, "%v", records) + } + + } + } +} diff --git a/pkg/backtest/exchange.go b/pkg/backtest/exchange.go new file mode 100644 index 0000000..2c7af42 --- /dev/null +++ b/pkg/backtest/exchange.go @@ -0,0 +1,451 @@ +/* +The backtest process + +The backtest engine loads the klines from the database into a kline-channel, +there are multiple matching engine that matches the order sent from the strategy. + +for each kline, the backtest engine: + +1) load the kline, run matching logics to send out order update and trades to the user data stream. +2) once the matching process for the kline is done, the kline will be pushed to the market data stream. +3) go to 1 and load the next kline. + +There are 2 ways that a strategy could work with backtest engine: + + 1. the strategy receives kline from the market data stream, and then it submits the order by the given market data to the backtest engine. + backtest engine receives the order and then pushes the trade and order updates to the user data stream. + + the strategy receives the trade and update its position. + + 2. the strategy places the orders when it starts. (like grid) the strategy then receives the order updates and then submit a new order + by its order update message. + +We need to ensure that: + + 1. if the strategy submits the order from the market data stream, since it's a separate goroutine, the strategy should block the backtest engine + to process the trades before the next kline is published. +*/ +package backtest + +import ( + "context" + "fmt" + "strconv" + "sync" + "time" + + "github.com/sirupsen/logrus" + + "github.com/pkg/errors" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/cache" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/service" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +var log = logrus.WithField("cmd", "backtest") + +var ErrUnimplemented = errors.New("unimplemented method") +var ErrNegativeQuantity = errors.New("order quantity can not be negative") +var ErrZeroQuantity = errors.New("order quantity can not be zero") +var ErrEmptyOrderType = errors.New("order type can not be empty string") + +type Exchange struct { + sourceName types.ExchangeName + publicExchange types.Exchange + srv *service.BacktestService + currentTime time.Time + + account *types.Account + config *qbtrade.Backtest + + MarketDataStream types.StandardStreamEmitter + + trades map[string][]types.Trade + tradesMutex sync.Mutex + + closedOrders map[string][]types.Order + closedOrdersMutex sync.Mutex + + matchingBooks map[string]*SimplePriceMatching + matchingBooksMutex sync.Mutex + + markets types.MarketMap + + Src *ExchangeDataSource +} + +func NewExchange( + sourceName types.ExchangeName, sourceExchange types.Exchange, srv *service.BacktestService, config *qbtrade.Backtest, +) (*Exchange, error) { + ex := sourceExchange + + markets, err := cache.LoadExchangeMarketsWithCache(context.Background(), ex) + if err != nil { + return nil, err + } + + startTime := config.StartTime.Time() + configAccount := config.GetAccount(sourceName.String()) + + account := &types.Account{ + MakerFeeRate: configAccount.MakerFeeRate, + TakerFeeRate: configAccount.TakerFeeRate, + AccountType: types.AccountTypeSpot, + } + + balances := configAccount.Balances.BalanceMap() + account.UpdateBalances(balances) + + e := &Exchange{ + sourceName: sourceName, + publicExchange: ex, + markets: markets, + srv: srv, + config: config, + account: account, + currentTime: startTime, + closedOrders: make(map[string][]types.Order), + trades: make(map[string][]types.Trade), + } + + e.resetMatchingBooks() + return e, nil +} + +func (e *Exchange) addTrade(trade types.Trade) { + e.tradesMutex.Lock() + e.trades[trade.Symbol] = append(e.trades[trade.Symbol], trade) + e.tradesMutex.Unlock() +} + +func (e *Exchange) addClosedOrder(order types.Order) { + e.closedOrdersMutex.Lock() + e.closedOrders[order.Symbol] = append(e.closedOrders[order.Symbol], order) + e.closedOrdersMutex.Unlock() +} + +func (e *Exchange) resetMatchingBooks() { + e.matchingBooksMutex.Lock() + e.matchingBooks = make(map[string]*SimplePriceMatching) + for symbol, market := range e.markets { + e._addMatchingBook(symbol, market) + } + e.matchingBooksMutex.Unlock() +} + +func (e *Exchange) addMatchingBook(symbol string, market types.Market) { + e.matchingBooksMutex.Lock() + e._addMatchingBook(symbol, market) + e.matchingBooksMutex.Unlock() +} + +func (e *Exchange) _addMatchingBook(symbol string, market types.Market) { + matching := &SimplePriceMatching{ + currentTime: e.currentTime, + account: e.account, + Market: market, + closedOrders: make(map[uint64]types.Order), + feeModeFunction: getFeeModeFunction(e.config.FeeMode), + } + + e.matchingBooks[symbol] = matching +} + +func (e *Exchange) NewStream() types.Stream { + return &types.BacktestStream{ + StandardStreamEmitter: &types.StandardStream{}, + } +} + +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) SubmitOrder(ctx context.Context, order types.SubmitOrder) (createdOrder *types.Order, err error) { + symbol := order.Symbol + matching, ok := e.matchingBook(symbol) + if !ok { + return nil, fmt.Errorf("matching engine is not initialized for symbol %s", symbol) + } + + if order.Price.Sign() < 0 { + return nil, fmt.Errorf("order price can not be negative, %s given", order.Price.String()) + } + + if order.Quantity.Sign() < 0 { + return nil, ErrNegativeQuantity + } + + if order.Quantity.IsZero() { + return nil, ErrZeroQuantity + } + + if order.Type == "" { + return nil, ErrEmptyOrderType + } + + createdOrder, _, err = matching.PlaceOrder(order) + if createdOrder != nil { + // market order can be closed immediately. + switch createdOrder.Status { + case types.OrderStatusFilled, types.OrderStatusCanceled, types.OrderStatusRejected: + e.addClosedOrder(*createdOrder) + } + } + + return createdOrder, err +} + +func (e *Exchange) QueryOpenOrders(ctx context.Context, symbol string) (orders []types.Order, err error) { + matching, ok := e.matchingBook(symbol) + if !ok { + return nil, fmt.Errorf("matching engine is not initialized for symbol %s", symbol) + } + + return append(matching.bidOrders, matching.askOrders...), nil +} + +func (e *Exchange) QueryClosedOrders( + ctx context.Context, symbol string, since, until time.Time, lastOrderID uint64, +) (orders []types.Order, err error) { + orders, ok := e.closedOrders[symbol] + if !ok { + return orders, fmt.Errorf("matching engine is not initialized for symbol %s", symbol) + } + + return orders, nil +} + +func (e *Exchange) CancelOrders(ctx context.Context, orders ...types.Order) error { + 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) + } + _, err := matching.CancelOrder(order) + if err != nil { + return err + } + } + + return nil +} + +func (e *Exchange) QueryAccount(ctx context.Context) (*types.Account, error) { + return e.account, nil +} + +func (e *Exchange) QueryAccountBalances(ctx context.Context) (types.BalanceMap, error) { + return e.account.Balances(), nil +} + +func (e *Exchange) QueryKLines( + ctx context.Context, symbol string, interval types.Interval, options types.KLineQueryOptions, +) ([]types.KLine, error) { + if options.EndTime != nil { + return e.srv.QueryKLinesBackward(e, symbol, interval, *options.EndTime, 1000) + } + + if options.StartTime != nil { + return e.srv.QueryKLinesForward(e, symbol, interval, *options.StartTime, 1000) + } + + return nil, errors.New("endTime or startTime can not be nil") +} + +func (e *Exchange) QueryTrades( + ctx context.Context, symbol string, options *types.TradeQueryOptions, +) ([]types.Trade, error) { + // we don't need query trades for backtest + return nil, nil +} + +func (e *Exchange) QueryTicker(ctx context.Context, symbol string) (*types.Ticker, error) { + matching, ok := e.matchingBook(symbol) + if !ok { + return nil, fmt.Errorf("matching engine is not initialized for symbol %s", symbol) + } + + kline := matching.lastKLine + return &types.Ticker{ + Time: kline.EndTime.Time(), + Volume: kline.Volume, + Last: kline.Close, + Open: kline.Open, + High: kline.High, + Low: kline.Low, + Buy: kline.Close.Sub(matching.Market.TickSize), + Sell: kline.Close.Add(matching.Market.TickSize), + }, nil +} + +func (e *Exchange) QueryTickers(ctx context.Context, symbol ...string) (map[string]types.Ticker, error) { + // Not using Tickers in back test (yet) + return nil, ErrUnimplemented +} + +func (e *Exchange) Name() types.ExchangeName { + return e.publicExchange.Name() +} + +func (e *Exchange) PlatformFeeCurrency() string { + return e.publicExchange.PlatformFeeCurrency() +} + +func (e *Exchange) QueryMarkets(ctx context.Context) (types.MarketMap, error) { + return e.markets, nil +} + +func (e *Exchange) QueryDepositHistory( + ctx context.Context, asset string, since, until time.Time, +) (allDeposits []types.Deposit, err error) { + return nil, nil +} + +func (e *Exchange) QueryWithdrawHistory( + ctx context.Context, asset string, since, until time.Time, +) (allWithdraws []types.Withdraw, err error) { + return nil, nil +} + +func (e *Exchange) matchingBook(symbol string) (*SimplePriceMatching, bool) { + e.matchingBooksMutex.Lock() + m, ok := e.matchingBooks[symbol] + e.matchingBooksMutex.Unlock() + return m, ok +} + +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(userDataStream.EmitTradeUpdate) + matching.OnOrderUpdate(userDataStream.EmitOrderUpdate) + matching.OnBalanceUpdate(userDataStream.EmitBalanceUpdate) + } + e.matchingBooksMutex.Unlock() +} + +func (e *Exchange) SubscribeMarketData( + startTime, endTime time.Time, requiredInterval types.Interval, extraIntervals ...types.Interval, +) (chan types.KLine, error) { + log.Infof("collecting backtest configurations...") + + loadedSymbols := map[string]struct{}{} + loadedIntervals := map[types.Interval]struct{}{ + // 1m interval is required for the backtest matching engine + requiredInterval: {}, + } + + for _, it := range extraIntervals { + loadedIntervals[it] = struct{}{} + } + + // collect subscriptions + for _, sub := range e.MarketDataStream.GetSubscriptions() { + loadedSymbols[sub.Symbol] = struct{}{} + + switch sub.Channel { + case types.KLineChannel: + loadedIntervals[sub.Options.Interval] = struct{}{} + + default: + // Since Environment is not yet been injected at this point, no hard error + log.Errorf("stream channel %s is not supported in backtest", sub.Channel) + } + } + + var symbols []string + for symbol := range loadedSymbols { + symbols = append(symbols, symbol) + } + + var intervals []types.Interval + for interval := range loadedIntervals { + intervals = append(intervals, interval) + } + + var isFutures bool + if futuresExchange, ok := e.publicExchange.(types.FuturesExchange); ok { + isFutures = futuresExchange.GetFuturesSettings().IsFutures + } else { + isFutures = false + } + + if isFutures { + log.Infof("querying futures klines from database with exchange: %v symbols: %v and intervals: %v for back-testing", e.Name(), symbols, intervals) + } else { + log.Infof("querying klines from database with exchange: %v symbols: %v and intervals: %v for back-testing", e.Name(), symbols, intervals) + } + + if len(symbols) == 0 { + log.Warnf("empty symbols, will not query kline data from the database") + + c := make(chan types.KLine) + close(c) + return c, nil + } + + klineC, errC := e.srv.QueryKLinesCh(startTime, endTime, e.publicExchange, symbols, intervals) + go func() { + if err := <-errC; err != nil { + log.WithError(err).Error("backtest data feed error") + } + }() + return klineC, nil +} + +func (e *Exchange) ConsumeKLine(k types.KLine, requiredInterval types.Interval) { + matching, ok := e.matchingBook(k.Symbol) + if !ok { + log.Errorf("matching book of %s is not initialized", k.Symbol) + return + } + if matching.klineCache == nil { + matching.klineCache = make(map[types.Interval]types.KLine) + } + + requiredKline, ok := matching.klineCache[k.Interval] + if ok { // pop out all the old + if requiredKline.Interval != requiredInterval { + panic(fmt.Sprintf("expect required kline interval %s, got interval %s", requiredInterval.String(), requiredKline.Interval.String())) + } + e.currentTime = requiredKline.EndTime.Time() + // here we generate trades and order updates + matching.processKLine(requiredKline) + matching.nextKLine = &k + for _, kline := range matching.klineCache { + e.MarketDataStream.EmitKLineClosed(kline) + for _, h := range e.Src.Callbacks { + h(kline, e.Src) + } + } + // reset the paramcache + matching.klineCache = make(map[types.Interval]types.KLine) + } + matching.klineCache[k.Interval] = k +} + +func (e *Exchange) CloseMarketData() error { + if err := e.MarketDataStream.Close(); err != nil { + log.WithError(err).Error("stream close error") + return err + } + return nil +} diff --git a/pkg/backtest/exchange_klinec.go b/pkg/backtest/exchange_klinec.go new file mode 100644 index 0000000..84a81e6 --- /dev/null +++ b/pkg/backtest/exchange_klinec.go @@ -0,0 +1,13 @@ +package backtest + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type ExchangeDataSource struct { + C chan types.KLine + Exchange *Exchange + Session *qbtrade.ExchangeSession + Callbacks []func(types.KLine, *ExchangeDataSource) +} diff --git a/pkg/backtest/fee.go b/pkg/backtest/fee.go new file mode 100644 index 0000000..03abcbd --- /dev/null +++ b/pkg/backtest/fee.go @@ -0,0 +1,57 @@ +package backtest + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type FeeModeFunction func(order *types.Order, market *types.Market, feeRate fixedpoint.Value) (fee fixedpoint.Value, feeCurrency string) + +func feeModeFunctionToken(order *types.Order, _ *types.Market, feeRate fixedpoint.Value) (fee fixedpoint.Value, feeCurrency string) { + quoteQuantity := order.Quantity.Mul(order.Price) + feeCurrency = FeeToken + fee = quoteQuantity.Mul(feeRate) + return fee, feeCurrency +} + +func feeModeFunctionNative(order *types.Order, market *types.Market, feeRate fixedpoint.Value) (fee fixedpoint.Value, feeCurrency string) { + switch order.Side { + + case types.SideTypeBuy: + fee = order.Quantity.Mul(feeRate) + feeCurrency = market.BaseCurrency + + case types.SideTypeSell: + quoteQuantity := order.Quantity.Mul(order.Price) + fee = quoteQuantity.Mul(feeRate) + feeCurrency = market.QuoteCurrency + + } + + return fee, feeCurrency +} + +func feeModeFunctionQuote(order *types.Order, market *types.Market, feeRate fixedpoint.Value) (fee fixedpoint.Value, feeCurrency string) { + feeCurrency = market.QuoteCurrency + quoteQuantity := order.Quantity.Mul(order.Price) + fee = quoteQuantity.Mul(feeRate) + return fee, feeCurrency +} + +func getFeeModeFunction(feeMode qbtrade.BacktestFeeMode) FeeModeFunction { + switch feeMode { + + case qbtrade.BacktestFeeModeNative: + return feeModeFunctionNative + + case qbtrade.BacktestFeeModeQuote: + return feeModeFunctionQuote + + case qbtrade.BacktestFeeModeToken: + return feeModeFunctionToken + + default: + return feeModeFunctionQuote + } +} diff --git a/pkg/backtest/fee_test.go b/pkg/backtest/fee_test.go new file mode 100644 index 0000000..980959f --- /dev/null +++ b/pkg/backtest/fee_test.go @@ -0,0 +1,124 @@ +package backtest + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func Test_feeModeFunctionToken(t *testing.T) { + market := getTestMarket() + t.Run("sellOrder", func(t *testing.T) { + order := types.Order{ + SubmitOrder: types.SubmitOrder{ + Symbol: market.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeLimit, + Quantity: fixedpoint.NewFromFloat(0.1), + Price: fixedpoint.NewFromFloat(20000.0), + TimeInForce: types.TimeInForceGTC, + }, + } + feeRate := fixedpoint.MustNewFromString("0.075%") + fee, feeCurrency := feeModeFunctionToken(&order, &market, feeRate) + assert.Equal(t, "1.5", fee.String()) + assert.Equal(t, "FEE", feeCurrency) + }) + + t.Run("buyOrder", func(t *testing.T) { + order := types.Order{ + SubmitOrder: types.SubmitOrder{ + Symbol: market.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: fixedpoint.NewFromFloat(0.1), + Price: fixedpoint.NewFromFloat(20000.0), + TimeInForce: types.TimeInForceGTC, + }, + } + + feeRate := fixedpoint.MustNewFromString("0.075%") + fee, feeCurrency := feeModeFunctionToken(&order, &market, feeRate) + assert.Equal(t, "1.5", fee.String()) + assert.Equal(t, "FEE", feeCurrency) + }) +} + +func Test_feeModeFunctionQuote(t *testing.T) { + market := getTestMarket() + t.Run("sellOrder", func(t *testing.T) { + order := types.Order{ + SubmitOrder: types.SubmitOrder{ + Symbol: market.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeLimit, + Quantity: fixedpoint.NewFromFloat(0.1), + Price: fixedpoint.NewFromFloat(20000.0), + TimeInForce: types.TimeInForceGTC, + }, + } + feeRate := fixedpoint.MustNewFromString("0.075%") + fee, feeCurrency := feeModeFunctionQuote(&order, &market, feeRate) + assert.Equal(t, "1.5", fee.String()) + assert.Equal(t, "USDT", feeCurrency) + }) + + t.Run("buyOrder", func(t *testing.T) { + order := types.Order{ + SubmitOrder: types.SubmitOrder{ + Symbol: market.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: fixedpoint.NewFromFloat(0.1), + Price: fixedpoint.NewFromFloat(20000.0), + TimeInForce: types.TimeInForceGTC, + }, + } + + feeRate := fixedpoint.MustNewFromString("0.075%") + fee, feeCurrency := feeModeFunctionQuote(&order, &market, feeRate) + assert.Equal(t, "1.5", fee.String()) + assert.Equal(t, "USDT", feeCurrency) + }) +} + +func Test_feeModeFunctionNative(t *testing.T) { + market := getTestMarket() + t.Run("sellOrder", func(t *testing.T) { + order := types.Order{ + SubmitOrder: types.SubmitOrder{ + Symbol: market.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeLimit, + Quantity: fixedpoint.NewFromFloat(0.1), + Price: fixedpoint.NewFromFloat(20000.0), + TimeInForce: types.TimeInForceGTC, + }, + } + feeRate := fixedpoint.MustNewFromString("0.075%") + fee, feeCurrency := feeModeFunctionNative(&order, &market, feeRate) + assert.Equal(t, "1.5", fee.String()) + assert.Equal(t, "USDT", feeCurrency) + }) + + t.Run("buyOrder", func(t *testing.T) { + order := types.Order{ + SubmitOrder: types.SubmitOrder{ + Symbol: market.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: fixedpoint.NewFromFloat(0.1), + Price: fixedpoint.NewFromFloat(20000.0), + TimeInForce: types.TimeInForceGTC, + }, + } + + feeRate := fixedpoint.MustNewFromString("0.075%") + fee, feeCurrency := feeModeFunctionNative(&order, &market, feeRate) + assert.Equal(t, "0.000075", fee.String()) + assert.Equal(t, "BTC", feeCurrency) + }) +} diff --git a/pkg/backtest/fixture_test.go b/pkg/backtest/fixture_test.go new file mode 100644 index 0000000..a136b17 --- /dev/null +++ b/pkg/backtest/fixture_test.go @@ -0,0 +1,93 @@ +package backtest + +import ( + "context" + "errors" + "math/rand" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type KLineFixtureGenerator struct { + Symbol string + Interval types.Interval + StartTime, EndTime time.Time + StartPrice fixedpoint.Value +} + +func (g *KLineFixtureGenerator) Generate(ctx context.Context, c chan types.KLine) error { + defer close(c) + + startTime := g.StartTime + price := g.StartPrice + + if price.IsZero() { + return errors.New("startPrice can not be zero") + } + + for startTime.Before(g.EndTime) { + open := price + high := price.Mul(fixedpoint.NewFromFloat(1.01)) + low := price.Mul(fixedpoint.NewFromFloat(0.99)) + amp := high.Sub(low) + cls := low.Add(amp.Mul(fixedpoint.NewFromFloat(rand.Float64()))) + + vol := fixedpoint.NewFromFloat(rand.Float64() * 1000.0) + quoteVol := fixedpoint.NewFromFloat(rand.Float64() * 1000.0).Mul(price) + + nextStartTime := startTime.Add(g.Interval.Duration()) + k := types.KLine{ + Exchange: types.ExchangeBinance, + Symbol: g.Symbol, + StartTime: types.Time(startTime), + EndTime: types.Time(nextStartTime.Add(-time.Millisecond)), + Interval: g.Interval, + Open: open, + Close: cls, + High: high, + Low: low, + Volume: vol, + QuoteVolume: quoteVol, + Closed: true, + } + select { + case <-ctx.Done(): + return ctx.Err() + case c <- k: + } + price = cls + startTime = nextStartTime + } + + return nil +} + +func TestKLineFixtureGenerator(t *testing.T) { + startTime := time.Date(2022, time.January, 1, 0, 0, 0, 0, time.Local) + endTime := time.Date(2022, time.January, 31, 0, 0, 0, 0, time.Local) + ctx := context.Background() + g := &KLineFixtureGenerator{ + Symbol: "BTCUSDT", + Interval: types.Interval1m, + StartTime: startTime, + EndTime: endTime, + StartPrice: fixedpoint.NewFromFloat(18000.0), + } + + c := make(chan types.KLine, 20) + go func() { + err := g.Generate(ctx, c) + assert.NoError(t, err) + }() + for k := range c { + // high must higher than low + assert.True(t, k.High.Compare(k.Low) > 0) + assert.True(t, k.StartTime.After(startTime) || k.StartTime.Equal(startTime)) + assert.True(t, k.StartTime.Before(endTime)) + } +} diff --git a/pkg/backtest/manifests.go b/pkg/backtest/manifests.go new file mode 100644 index 0000000..c457e91 --- /dev/null +++ b/pkg/backtest/manifests.go @@ -0,0 +1,47 @@ +package backtest + +import "encoding/json" + +type ManifestEntry struct { + Type string `json:"type"` + Filename string `json:"filename"` + StrategyID string `json:"strategyID"` + StrategyInstance string `json:"strategyInstance"` + StrategyProperty string `json:"strategyProperty"` +} + +type Manifests map[InstancePropertyIndex]string + +func (m *Manifests) UnmarshalJSON(j []byte) error { + var entries []ManifestEntry + if err := json.Unmarshal(j, &entries); err != nil { + return err + } + + mm := make(Manifests) + for _, entry := range entries { + index := InstancePropertyIndex{ + ID: entry.StrategyID, + InstanceID: entry.StrategyInstance, + Property: entry.StrategyProperty, + } + mm[index] = entry.Filename + } + *m = mm + return nil +} + +func (m Manifests) MarshalJSON() ([]byte, error) { + var arr []ManifestEntry + for k, v := range m { + arr = append(arr, ManifestEntry{ + Type: "strategyProperty", + Filename: v, + StrategyID: k.ID, + StrategyInstance: k.InstanceID, + StrategyProperty: k.Property, + }) + + } + return json.MarshalIndent(arr, "", " ") +} diff --git a/pkg/backtest/matching.go b/pkg/backtest/matching.go new file mode 100644 index 0000000..c09c816 --- /dev/null +++ b/pkg/backtest/matching.go @@ -0,0 +1,725 @@ +package backtest + +import ( + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +var orderID uint64 = 1 +var tradeID uint64 = 1 + +func incOrderID() uint64 { + return atomic.AddUint64(&orderID, 1) +} + +func incTradeID() uint64 { + return atomic.AddUint64(&tradeID, 1) +} + +var klineMatchingLogger *logrus.Entry = nil + +// FeeToken is used to simulate the exchange platform fee token +// This is to ease the back-testing environment for closing positions. +const FeeToken = "FEE" + +var useFeeToken = true + +func init() { + logger := logrus.New() + if v, ok := util.GetEnvVarBool("DEBUG_MATCHING"); ok && v { + logger.SetLevel(logrus.DebugLevel) + } else { + logger.SetLevel(logrus.ErrorLevel) + } + klineMatchingLogger = logger.WithField("backtest", "klineEngine") + + if v, ok := util.GetEnvVarBool("BACKTEST_USE_FEE_TOKEN"); ok { + useFeeToken = v + } +} + +// SimplePriceMatching implements a simple kline data driven matching engine for backtest +// +//go:generate callbackgen -type SimplePriceMatching +type SimplePriceMatching struct { + Symbol string + Market types.Market + + mu sync.Mutex + bidOrders []types.Order + askOrders []types.Order + closedOrders map[uint64]types.Order + + klineCache map[types.Interval]types.KLine + lastPrice fixedpoint.Value + lastKLine types.KLine + nextKLine *types.KLine + currentTime time.Time + + feeModeFunction FeeModeFunction + + account *types.Account + + tradeUpdateCallbacks []func(trade types.Trade) + orderUpdateCallbacks []func(order types.Order) + balanceUpdateCallbacks []func(balances types.BalanceMap) +} + +func (m *SimplePriceMatching) CancelOrder(o types.Order) (types.Order, error) { + found := false + + switch o.Side { + + case types.SideTypeBuy: + m.mu.Lock() + var orders []types.Order + for _, order := range m.bidOrders { + if o.OrderID == order.OrderID { + found = true + continue + } + orders = append(orders, order) + } + m.bidOrders = orders + m.mu.Unlock() + + case types.SideTypeSell: + m.mu.Lock() + var orders []types.Order + for _, order := range m.askOrders { + if o.OrderID == order.OrderID { + found = true + continue + } + orders = append(orders, order) + } + m.askOrders = orders + m.mu.Unlock() + } + + if !found { + return o, fmt.Errorf("cancel order failed, order %d not found: %+v", o.OrderID, o) + } + + switch o.Side { + case types.SideTypeBuy: + if err := m.account.UnlockBalance(m.Market.QuoteCurrency, o.Price.Mul(o.Quantity)); err != nil { + return o, err + } + + case types.SideTypeSell: + if err := m.account.UnlockBalance(m.Market.BaseCurrency, o.Quantity); err != nil { + return o, err + } + } + + o.Status = types.OrderStatusCanceled + m.EmitOrderUpdate(o) + m.EmitBalanceUpdate(m.account.Balances()) + return o, nil +} + +// PlaceOrder returns the created order object, executed trade (if any) and error +func (m *SimplePriceMatching) PlaceOrder(o types.SubmitOrder) (*types.Order, *types.Trade, error) { + if o.Type == types.OrderTypeMarket { + if m.lastPrice.IsZero() { + panic("unexpected error: for market order, the last price can not be zero") + } + } + + isTaker := o.Type == types.OrderTypeMarket || isLimitTakerOrder(o, m.lastPrice) + + // price for checking account balance, default price + price := o.Price + + switch o.Type { + case types.OrderTypeMarket: + price = m.Market.TruncatePrice(m.lastPrice) + + case types.OrderTypeStopMarket: + // the actual price might be different. + o.StopPrice = m.Market.TruncatePrice(o.StopPrice) + price = o.StopPrice + + case types.OrderTypeLimit, types.OrderTypeStopLimit, types.OrderTypeLimitMaker: + o.Price = m.Market.TruncatePrice(o.Price) + price = o.Price + } + + o.Quantity = m.Market.TruncateQuantity(o.Quantity) + + if o.Quantity.Compare(m.Market.MinQuantity) < 0 { + return nil, nil, fmt.Errorf("order quantity %s is less than minQuantity %s, order: %+v", o.Quantity.String(), m.Market.MinQuantity.String(), o) + } + + quoteQuantity := o.Quantity.Mul(price) + if quoteQuantity.Compare(m.Market.MinNotional) < 0 { + return nil, nil, fmt.Errorf("order amount %s is less than minNotional %s, order: %+v", quoteQuantity.String(), m.Market.MinNotional.String(), o) + } + + switch o.Side { + case types.SideTypeBuy: + if err := m.account.LockBalance(m.Market.QuoteCurrency, quoteQuantity); err != nil { + return nil, nil, err + } + + case types.SideTypeSell: + if err := m.account.LockBalance(m.Market.BaseCurrency, o.Quantity); err != nil { + return nil, nil, err + } + } + + m.EmitBalanceUpdate(m.account.Balances()) + + // start from one + orderID := incOrderID() + order := m.newOrder(o, orderID) + + if isTaker { + var price fixedpoint.Value + if order.Type == types.OrderTypeMarket { + order.Price = m.Market.TruncatePrice(m.lastPrice) + price = order.Price + } else if order.Type == types.OrderTypeLimit { + // if limit order's price is with the range of next kline + // we assume it will be traded as a maker trade, and is traded at its original price + // TODO: if it is treated as a maker trade, fee should be specially handled + // otherwise, set NextKLine.Close(i.e., m.LastPrice) to be the taker traded price + if m.nextKLine != nil && m.nextKLine.High.Compare(order.Price) > 0 && order.Side == types.SideTypeBuy { + order.AveragePrice = order.Price + } else if m.nextKLine != nil && m.nextKLine.Low.Compare(order.Price) < 0 && order.Side == types.SideTypeSell { + order.AveragePrice = order.Price + } else { + order.AveragePrice = m.Market.TruncatePrice(m.lastPrice) + } + price = order.AveragePrice + } + + // emit the order update for Status:New + m.EmitOrderUpdate(order) + + // copy the order object to avoid side effect (for different callbacks) + var order2 = order + + // emit trade before we publish order + trade := m.newTradeFromOrder(&order2, false, price) + m.executeTrade(trade) + + // unlock the rest balances for limit taker + if order.Type == types.OrderTypeLimit { + if order.AveragePrice.IsZero() { + return nil, nil, fmt.Errorf("the average price of the given limit taker order can not be zero") + } + + switch o.Side { + case types.SideTypeBuy: + // limit buy taker, the order price is higher than the current best ask price + // the executed price is lower than the given price, so we will use less quote currency to buy the base asset. + amount := order.Price.Sub(order.AveragePrice).Mul(order.Quantity) + if amount.Sign() > 0 { + if err := m.account.UnlockBalance(m.Market.QuoteCurrency, amount); err != nil { + return nil, nil, err + } + m.EmitBalanceUpdate(m.account.Balances()) + } + + case types.SideTypeSell: + // limit sell taker, the order price is lower than the current best bid price + // the executed price is higher than the given price, so we will get more quote currency back + amount := order.AveragePrice.Sub(order.Price).Mul(order.Quantity) + if amount.Sign() > 0 { + m.account.AddBalance(m.Market.QuoteCurrency, amount) + m.EmitBalanceUpdate(m.account.Balances()) + } + } + } + + // update the order status + order2.Status = types.OrderStatusFilled + 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 + } + + // For limit maker orders (open status) + switch o.Side { + + case types.SideTypeBuy: + m.mu.Lock() + m.bidOrders = append(m.bidOrders, order) + m.mu.Unlock() + + case types.SideTypeSell: + m.mu.Lock() + m.askOrders = append(m.askOrders, order) + m.mu.Unlock() + } + + m.EmitOrderUpdate(order) // emit order New status + return &order, nil, nil +} + +func (m *SimplePriceMatching) executeTrade(trade types.Trade) { + var err error + // execute trade, update account balances + if trade.IsBuyer { + err = m.account.UseLockedBalance(m.Market.QuoteCurrency, trade.QuoteQuantity) + + // all-in buy trade, we can only deduct the fee from the quote quantity and re-calculate the base quantity + switch trade.FeeCurrency { + case m.Market.QuoteCurrency: + m.account.AddBalance(m.Market.QuoteCurrency, trade.Fee.Neg()) + m.account.AddBalance(m.Market.BaseCurrency, trade.Quantity) + case m.Market.BaseCurrency: + m.account.AddBalance(m.Market.BaseCurrency, trade.Quantity.Sub(trade.Fee)) + default: + m.account.AddBalance(m.Market.BaseCurrency, trade.Quantity) + } + + } else { // sell trade + err = m.account.UseLockedBalance(m.Market.BaseCurrency, trade.Quantity) + + switch trade.FeeCurrency { + case m.Market.QuoteCurrency: + m.account.AddBalance(m.Market.QuoteCurrency, trade.QuoteQuantity.Sub(trade.Fee)) + case m.Market.BaseCurrency: + m.account.AddBalance(m.Market.BaseCurrency, trade.Fee.Neg()) + m.account.AddBalance(m.Market.QuoteCurrency, trade.QuoteQuantity) + default: + m.account.AddBalance(m.Market.QuoteCurrency, trade.QuoteQuantity) + } + } + + if err != nil { + panic(errors.Wrapf(err, "executeTrade exception, wanted to use more than the locked balance")) + } + + m.EmitTradeUpdate(trade) + m.EmitBalanceUpdate(m.account.Balances()) +} + +func (m *SimplePriceMatching) getFeeRate(isMaker bool) (feeRate fixedpoint.Value) { + // BINANCE uses 0.1% for both maker and taker + // MAX uses 0.050% for maker and 0.15% for taker + if isMaker { + feeRate = m.account.MakerFeeRate + } else { + feeRate = m.account.TakerFeeRate + } + return feeRate +} + +func (m *SimplePriceMatching) newTradeFromOrder(order *types.Order, isMaker bool, price fixedpoint.Value) types.Trade { + // BINANCE uses 0.1% for both maker and taker + // MAX uses 0.050% for maker and 0.15% for taker + var feeRate = m.getFeeRate(isMaker) + var quoteQuantity = order.Quantity.Mul(price) + var fee fixedpoint.Value + var feeCurrency string + + if m.feeModeFunction != nil { + fee, feeCurrency = m.feeModeFunction(order, &m.Market, feeRate) + } else { + fee, feeCurrency = feeModeFunctionQuote(order, &m.Market, feeRate) + } + + // update order time + order.UpdateTime = types.Time(m.currentTime) + + var id = incTradeID() + return types.Trade{ + ID: id, + OrderID: order.OrderID, + Exchange: types.ExchangeBacktest, + Price: price, + Quantity: order.Quantity, + QuoteQuantity: quoteQuantity, + Symbol: order.Symbol, + Side: order.Side, + IsBuyer: order.Side == types.SideTypeBuy, + IsMaker: isMaker, + Time: types.Time(m.currentTime), + Fee: fee, + FeeCurrency: feeCurrency, + } +} + +// buyToPrice means price go up and the limit sell should be triggered +func (m *SimplePriceMatching) buyToPrice(price fixedpoint.Value) (closedOrders []types.Order, trades []types.Trade) { + klineMatchingLogger.Debugf("kline buy to price %s", price.String()) + + var bidOrders []types.Order + for _, o := range m.bidOrders { + switch o.Type { + + case types.OrderTypeStopMarket: + // the price is still lower than the stop price, we will put the order back to the list + if price.Compare(o.StopPrice) < 0 { + // not triggering it, put it back + bidOrders = append(bidOrders, o) + break + } + + o.Type = types.OrderTypeMarket + o.ExecutedQuantity = o.Quantity + o.Price = price + o.Status = types.OrderStatusFilled + closedOrders = append(closedOrders, o) + + case types.OrderTypeStopLimit: + // the price is still lower than the stop price, we will put the order back to the list + if price.Compare(o.StopPrice) < 0 { + bidOrders = append(bidOrders, o) + break + } + + // convert this order to limit order + // we use value object here, so it's a copy + o.Type = types.OrderTypeLimit + + // is it a taker order? + // higher than the current price, then it's a taker order + if o.Price.Compare(price) >= 0 { + // limit buy taker order, move it to the closed order + // we assume that we have no price slippage here, so the latest price will be the executed price + o.AveragePrice = price + o.ExecutedQuantity = o.Quantity + o.Status = types.OrderStatusFilled + closedOrders = append(closedOrders, o) + } else { + // keep it as a maker order + bidOrders = append(bidOrders, o) + } + default: + bidOrders = append(bidOrders, o) + } + } + m.bidOrders = bidOrders + + var askOrders []types.Order + for _, o := range m.askOrders { + switch o.Type { + + case types.OrderTypeStopMarket: + // should we trigger the order + if price.Compare(o.StopPrice) < 0 { + // not triggering it, put it back + askOrders = append(askOrders, o) + break + } + + o.Type = types.OrderTypeMarket + o.ExecutedQuantity = o.Quantity + o.Price = price + o.Status = types.OrderStatusFilled + closedOrders = append(closedOrders, o) + + case types.OrderTypeStopLimit: + // should we trigger the order? + if price.Compare(o.StopPrice) < 0 { + askOrders = append(askOrders, o) + break + } + + o.Type = types.OrderTypeLimit + + // is it a taker order? + // higher than the current price, then it's a taker order + if o.Price.Compare(price) <= 0 { + // limit sell order as taker, move it to the closed order + // we assume that we have no price slippage here, so the latest price will be the executed price + // TODO: simulate slippage here + o.AveragePrice = price + o.ExecutedQuantity = o.Quantity + o.Status = types.OrderStatusFilled + closedOrders = append(closedOrders, o) + } else { + // maker order + askOrders = append(askOrders, o) + } + + case types.OrderTypeLimit, types.OrderTypeLimitMaker: + if price.Compare(o.Price) >= 0 { + o.ExecutedQuantity = o.Quantity + o.Status = types.OrderStatusFilled + closedOrders = append(closedOrders, o) + } else { + askOrders = append(askOrders, o) + } + + default: + askOrders = append(askOrders, o) + } + + } + + m.askOrders = askOrders + m.lastPrice = price + + for i := range closedOrders { + o := closedOrders[i] + executedPrice := o.Price + if !o.AveragePrice.IsZero() { + executedPrice = o.AveragePrice + } + + trade := m.newTradeFromOrder(&o, !isTakerOrder(o), executedPrice) + m.executeTrade(trade) + closedOrders[i] = o + + trades = append(trades, trade) + + m.EmitOrderUpdate(o) + + m.closedOrders[o.OrderID] = o + } + + return closedOrders, trades +} + +// sellToPrice simulates the price trend in down direction. +// When price goes down, buy orders should be executed, and the stop orders should be triggered. +func (m *SimplePriceMatching) sellToPrice(price fixedpoint.Value) (closedOrders []types.Order, trades []types.Trade) { + klineMatchingLogger.Debugf("kline sell to price %s", price.String()) + + // in this section we handle --- the price goes lower, and we trigger the stop sell + var askOrders []types.Order + for _, o := range m.askOrders { + switch o.Type { + + case types.OrderTypeStopMarket: + // should we trigger the order + if price.Compare(o.StopPrice) > 0 { + askOrders = append(askOrders, o) + break + } + + o.Type = types.OrderTypeMarket + o.ExecutedQuantity = o.Quantity + o.Price = price + o.Status = types.OrderStatusFilled + closedOrders = append(closedOrders, o) + + case types.OrderTypeStopLimit: + // if the price is lower than the stop price + // we should trigger the stop sell order + if price.Compare(o.StopPrice) > 0 { + askOrders = append(askOrders, o) + break + } + + o.Type = types.OrderTypeLimit + + // handle TAKER SELL + // if the order price is lower than the current price + // it's a taker order + if o.Price.Compare(price) <= 0 { + o.AveragePrice = price + o.ExecutedQuantity = o.Quantity + o.Status = types.OrderStatusFilled + closedOrders = append(closedOrders, o) + } else { + askOrders = append(askOrders, o) + } + + default: + askOrders = append(askOrders, o) + } + } + m.askOrders = askOrders + + var bidOrders []types.Order + for _, o := range m.bidOrders { + switch o.Type { + + case types.OrderTypeStopMarket: + // price goes down and if the stop price is still lower than the current price + // or the stop price is not touched + // then we should skip this order + if price.Compare(o.StopPrice) > 0 { + bidOrders = append(bidOrders, o) + break + } + + o.Type = types.OrderTypeMarket + o.ExecutedQuantity = o.Quantity + o.Price = price + o.Status = types.OrderStatusFilled + closedOrders = append(closedOrders, o) + + case types.OrderTypeStopLimit: + // price goes down and if the stop price is still lower than the current price + // or the stop price is not touched + // then we should skip this order + if price.Compare(o.StopPrice) > 0 { + bidOrders = append(bidOrders, o) + break + } + + o.Type = types.OrderTypeLimit + + // handle TAKER order + if o.Price.Compare(price) >= 0 { + o.AveragePrice = price + o.ExecutedQuantity = o.Quantity + o.Status = types.OrderStatusFilled + closedOrders = append(closedOrders, o) + } else { + bidOrders = append(bidOrders, o) + } + + case types.OrderTypeLimit, types.OrderTypeLimitMaker: + if price.Compare(o.Price) <= 0 { + o.ExecutedQuantity = o.Quantity + o.Status = types.OrderStatusFilled + closedOrders = append(closedOrders, o) + } else { + bidOrders = append(bidOrders, o) + } + + default: + bidOrders = append(bidOrders, o) + } + } + + m.bidOrders = bidOrders + m.lastPrice = price + + for i := range closedOrders { + o := closedOrders[i] + executedPrice := o.Price + if !o.AveragePrice.IsZero() { + executedPrice = o.AveragePrice + } + + trade := m.newTradeFromOrder(&o, !isTakerOrder(o), executedPrice) + m.executeTrade(trade) + closedOrders[i] = o + + trades = append(trades, trade) + + m.EmitOrderUpdate(o) + + m.closedOrders[o.OrderID] = o + } + + return closedOrders, trades +} + +func (m *SimplePriceMatching) getOrder(orderID uint64) (types.Order, bool) { + if o, ok := m.closedOrders[orderID]; ok { + return o, true + } + + for _, o := range m.bidOrders { + if o.OrderID == orderID { + return o, true + } + } + + for _, o := range m.askOrders { + if o.OrderID == orderID { + return o, true + } + } + + return types.Order{}, false +} + +func (m *SimplePriceMatching) processKLine(kline types.KLine) { + m.currentTime = kline.EndTime.Time() + + if m.lastPrice.IsZero() { + m.lastPrice = kline.Open + } else { + if m.lastPrice.Compare(kline.Open) > 0 { + m.sellToPrice(kline.Open) + } else { + m.buyToPrice(kline.Open) + } + } + + switch kline.Direction() { + case types.DirectionDown: + if kline.High.Compare(kline.Open) >= 0 { + m.buyToPrice(kline.High) + } + + // if low is lower than close, sell to low first, and then buy up to close + if kline.Low.Compare(kline.Close) < 0 { + m.sellToPrice(kline.Low) + m.buyToPrice(kline.Close) + } else { + m.sellToPrice(kline.Close) + } + + case types.DirectionUp: + if kline.Low.Compare(kline.Open) <= 0 { + m.sellToPrice(kline.Low) + } + + if kline.High.Compare(kline.Close) > 0 { + m.buyToPrice(kline.High) + m.sellToPrice(kline.Close) + } else { + m.buyToPrice(kline.Close) + } + default: // no trade up or down + if m.lastPrice.IsZero() { + m.buyToPrice(kline.Close) + } + } + + m.lastKLine = kline +} + +func (m *SimplePriceMatching) newOrder(o types.SubmitOrder, orderID uint64) types.Order { + return types.Order{ + OrderID: orderID, + SubmitOrder: o, + Exchange: types.ExchangeBacktest, + Status: types.OrderStatusNew, + ExecutedQuantity: fixedpoint.Zero, + IsWorking: true, + CreationTime: types.Time(m.currentTime), + UpdateTime: types.Time(m.currentTime), + } +} + +func isTakerOrder(o types.Order) bool { + if o.AveragePrice.IsZero() { + return false + } + + switch o.Side { + case types.SideTypeBuy: + return o.AveragePrice.Compare(o.Price) < 0 + + case types.SideTypeSell: + return o.AveragePrice.Compare(o.Price) > 0 + + } + return false +} + +func isLimitTakerOrder(o types.SubmitOrder, currentPrice fixedpoint.Value) bool { + if currentPrice.IsZero() { + return false + } + + return o.Type == types.OrderTypeLimit && ((o.Side == types.SideTypeBuy && o.Price.Compare(currentPrice) >= 0) || + (o.Side == types.SideTypeSell && o.Price.Compare(currentPrice) <= 0)) +} diff --git a/pkg/backtest/matching_test.go b/pkg/backtest/matching_test.go new file mode 100644 index 0000000..75f1a29 --- /dev/null +++ b/pkg/backtest/matching_test.go @@ -0,0 +1,529 @@ +package backtest + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func newLimitOrder(symbol string, side types.SideType, price, quantity float64) types.SubmitOrder { + return types.SubmitOrder{ + Symbol: symbol, + Side: side, + Type: types.OrderTypeLimit, + Quantity: fixedpoint.NewFromFloat(quantity), + Price: fixedpoint.NewFromFloat(price), + TimeInForce: types.TimeInForceGTC, + } +} + +func TestSimplePriceMatching_orderUpdate(t *testing.T) { + account := &types.Account{ + MakerFeeRate: fixedpoint.NewFromFloat(0.075 * 0.01), + TakerFeeRate: fixedpoint.NewFromFloat(0.075 * 0.01), + } + account.UpdateBalances(types.BalanceMap{ + "USDT": {Currency: "USDT", Available: fixedpoint.NewFromFloat(10000.0)}, + }) + 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"), + StepSize: fixedpoint.MustNewFromString("0.00001"), + TickSize: fixedpoint.MustNewFromString("0.01"), + } + + t1 := time.Date(2021, 7, 1, 0, 0, 0, 0, time.UTC) + engine := &SimplePriceMatching{ + account: account, + Market: market, + currentTime: t1, + closedOrders: make(map[uint64]types.Order), + lastPrice: fixedpoint.NewFromFloat(25000), + } + + orderUpdateCnt := 0 + orderUpdateNewStatusCnt := 0 + orderUpdateFilledStatusCnt := 0 + var lastOrder types.Order + engine.OnOrderUpdate(func(order types.Order) { + lastOrder = order + + orderUpdateCnt++ + switch order.Status { + case types.OrderStatusNew: + orderUpdateNewStatusCnt++ + + case types.OrderStatusFilled: + orderUpdateFilledStatusCnt++ + + } + }) + + // maker order + _, _, err := engine.PlaceOrder(newLimitOrder("BTCUSDT", types.SideTypeBuy, 24000.0, 0.1)) + assert.NoError(t, err) + assert.Equal(t, 1, orderUpdateCnt) // should get new status + assert.Equal(t, 1, orderUpdateNewStatusCnt) // should get new status + assert.Equal(t, 0, orderUpdateFilledStatusCnt) // should get new status + assert.Equal(t, types.OrderStatusNew, lastOrder.Status) + assert.Equal(t, fixedpoint.NewFromFloat(0.0), lastOrder.ExecutedQuantity) + + t2 := t1.Add(time.Minute) + + // should match 25000, 24000 + k := newKLine("BTCUSDT", types.Interval1m, t2, 26000, 27000, 23000, 25000) + engine.processKLine(k) + + assert.Equal(t, 2, orderUpdateCnt) // should got new and filled + assert.Equal(t, 1, orderUpdateNewStatusCnt) // should got new status + assert.Equal(t, 1, orderUpdateFilledStatusCnt) // should got new status + assert.Equal(t, types.OrderStatusFilled, lastOrder.Status) + assert.Equal(t, "0.1", lastOrder.ExecutedQuantity.String()) + assert.Equal(t, lastOrder.Quantity.String(), lastOrder.ExecutedQuantity.String()) +} + +func TestSimplePriceMatching_CancelOrder(t *testing.T) { + account := getTestAccount() + market := getTestMarket() + t1 := time.Date(2021, 7, 1, 0, 0, 0, 0, time.UTC) + engine := &SimplePriceMatching{ + account: account, + Market: market, + currentTime: t1, + closedOrders: make(map[uint64]types.Order), + lastPrice: fixedpoint.NewFromFloat(30000.0), + } + + createdOrder1, trade1, err := engine.PlaceOrder(newLimitOrder("BTCUSDT", types.SideTypeBuy, 20000.0, 0.1)) + assert.NoError(t, err) + assert.Nil(t, trade1) + assert.Len(t, engine.bidOrders, 1) + assert.Len(t, engine.askOrders, 0) + + createdOrder2, trade2, err := engine.PlaceOrder(newLimitOrder("BTCUSDT", types.SideTypeSell, 40000.0, 0.1)) + assert.NoError(t, err) + assert.Nil(t, trade2) + assert.Len(t, engine.bidOrders, 1) + assert.Len(t, engine.askOrders, 1) + + if assert.NotNil(t, createdOrder1) { + retOrder, err := engine.CancelOrder(*createdOrder1) + assert.NoError(t, err) + assert.NotNil(t, retOrder) + assert.Len(t, engine.bidOrders, 0) + assert.Len(t, engine.askOrders, 1) + } + + if assert.NotNil(t, createdOrder2) { + retOrder, err := engine.CancelOrder(*createdOrder2) + assert.NoError(t, err) + assert.NotNil(t, retOrder) + assert.Len(t, engine.bidOrders, 0) + assert.Len(t, engine.askOrders, 0) + } +} + +func TestSimplePriceMatching_processKLine(t *testing.T) { + account := getTestAccount() + market := getTestMarket() + + t1 := time.Date(2021, 7, 1, 0, 0, 0, 0, time.UTC) + engine := &SimplePriceMatching{ + account: account, + Market: market, + currentTime: t1, + closedOrders: make(map[uint64]types.Order), + lastPrice: fixedpoint.NewFromFloat(30000.0), + } + + for i := 0; i <= 5; i++ { + var p = 20000.0 + float64(i)*1000.0 + _, _, err := engine.PlaceOrder(newLimitOrder("BTCUSDT", types.SideTypeBuy, p, 0.001)) + assert.NoError(t, err) + } + + t2 := t1.Add(time.Minute) + + // should match 25000, 24000 + k := newKLine("BTCUSDT", types.Interval1m, t2, 30000, 27000, 23000, 25000) + assert.Equal(t, t2.Add(time.Minute-time.Millisecond), k.EndTime.Time()) + + engine.processKLine(k) + assert.Equal(t, 3, len(engine.bidOrders)) + assert.Len(t, engine.bidOrders, 3) + assert.Equal(t, 3, len(engine.closedOrders)) + + for _, o := range engine.closedOrders { + assert.Equal(t, k.EndTime.Time(), o.UpdateTime.Time()) + } +} + +func newKLine(symbol string, interval types.Interval, startTime time.Time, o, h, l, c float64) types.KLine { + return types.KLine{ + Symbol: symbol, + StartTime: types.Time(startTime), + EndTime: types.Time(startTime.Add(interval.Duration() - time.Millisecond)), + Interval: interval, + Open: fixedpoint.NewFromFloat(o), + High: fixedpoint.NewFromFloat(h), + Low: fixedpoint.NewFromFloat(l), + Close: fixedpoint.NewFromFloat(c), + Closed: true, + } +} + +// 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"), + StepSize: fixedpoint.MustNewFromString("0.00001"), + TickSize: fixedpoint.MustNewFromString("0.01"), + } + return market +} + +func getTestAccount() *types.Account { + account := &types.Account{ + MakerFeeRate: fixedpoint.NewFromFloat(0.075 * 0.01), + TakerFeeRate: fixedpoint.NewFromFloat(0.075 * 0.01), + } + account.UpdateBalances(types.BalanceMap{ + "USDT": {Currency: "USDT", Available: fixedpoint.NewFromFloat(1000000.0)}, + "BTC": {Currency: "BTC", Available: fixedpoint.NewFromFloat(100.0)}, + }) + return account +} + +func TestSimplePriceMatching_LimitBuyTakerOrder(t *testing.T) { + account := getTestAccount() + market := getTestMarket() + engine := &SimplePriceMatching{ + account: account, + Market: market, + closedOrders: make(map[uint64]types.Order), + lastPrice: fixedpoint.NewFromFloat(19000.0), + } + + takerOrder := types.SubmitOrder{ + Symbol: market.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: fixedpoint.NewFromFloat(0.1), + Price: fixedpoint.NewFromFloat(20000.0), + TimeInForce: types.TimeInForceGTC, + } + createdOrder, trade, err := engine.PlaceOrder(takerOrder) + assert.NoError(t, err) + t.Logf("created order: %+v", createdOrder) + t.Logf("executed trade: %+v", trade) + + assert.Equal(t, "19000", trade.Price.String()) + assert.Equal(t, "19000", createdOrder.AveragePrice.String()) + assert.Equal(t, "20000", createdOrder.Price.String()) + + usdt, ok := account.Balance("USDT") + assert.True(t, ok) + assert.True(t, usdt.Locked.IsZero()) + + btc, ok := account.Balance("BTC") + assert.True(t, ok) + assert.True(t, btc.Locked.IsZero()) + assert.Equal(t, fixedpoint.NewFromFloat(100.0).Add(createdOrder.Quantity).String(), btc.Available.String()) + + usedQuoteAmount := createdOrder.AveragePrice.Mul(createdOrder.Quantity) + assert.Equal(t, "USDT", trade.FeeCurrency) + assert.Equal(t, usdt.Available.String(), fixedpoint.NewFromFloat(1000000.0).Sub(usedQuoteAmount).Sub(trade.Fee).String()) +} + +func TestSimplePriceMatching_StopLimitOrderBuy(t *testing.T) { + account := getTestAccount() + market := getTestMarket() + engine := &SimplePriceMatching{ + account: account, + Market: market, + closedOrders: make(map[uint64]types.Order), + lastPrice: fixedpoint.NewFromFloat(19000.0), + } + + stopBuyOrder := types.SubmitOrder{ + Symbol: market.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeStopLimit, + Quantity: fixedpoint.NewFromFloat(0.1), + Price: fixedpoint.NewFromFloat(22000.0), + StopPrice: fixedpoint.NewFromFloat(21000.0), + TimeInForce: types.TimeInForceGTC, + } + createdOrder, trade, err := engine.PlaceOrder(stopBuyOrder) + assert.NoError(t, err) + assert.Nil(t, trade, "place stop order should not trigger the stop buy") + assert.NotNil(t, createdOrder, "place stop order should not trigger the stop buy") + + // place some limit orders, so we ensure that the remaining orders are not removed. + _, _, err = engine.PlaceOrder(newLimitOrder(market.Symbol, types.SideTypeBuy, 18000, 0.01)) + assert.NoError(t, err) + _, _, err = engine.PlaceOrder(newLimitOrder(market.Symbol, types.SideTypeSell, 32000, 0.01)) + assert.NoError(t, err) + assert.Equal(t, 2, len(engine.bidOrders)) + assert.Equal(t, 1, len(engine.askOrders)) + + closedOrders, trades := engine.buyToPrice(fixedpoint.NewFromFloat(20000.0)) + assert.Len(t, closedOrders, 0, "price change far from the price should not trigger the stop buy") + assert.Len(t, trades, 0, "price change far from the price should not trigger the stop buy") + assert.Equal(t, 2, len(engine.bidOrders), "bid orders should be the same") + assert.Equal(t, 1, len(engine.askOrders), "ask orders should be the same") + + closedOrders, trades = engine.buyToPrice(fixedpoint.NewFromFloat(21001.0)) + assert.Len(t, closedOrders, 1, "should trigger the stop buy order") + assert.Len(t, trades, 1, "should have stop order trade executed") + + assert.Equal(t, types.OrderStatusFilled, closedOrders[0].Status) + assert.Equal(t, types.OrderTypeLimit, closedOrders[0].Type) + assert.Equal(t, "21001", trades[0].Price.String()) + assert.Equal(t, "22000", closedOrders[0].Price.String(), "order.Price should not be adjusted") + + assert.Equal(t, fixedpoint.NewFromFloat(21001.0).String(), engine.lastPrice.String()) + + stopOrder2 := types.SubmitOrder{ + Symbol: market.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeStopLimit, + Quantity: fixedpoint.NewFromFloat(0.1), + Price: fixedpoint.NewFromFloat(22000.0), + StopPrice: fixedpoint.NewFromFloat(21000.0), + TimeInForce: types.TimeInForceGTC, + } + createdOrder, trade, err = engine.PlaceOrder(stopOrder2) + assert.NoError(t, err) + assert.Nil(t, trade, "place stop order should not trigger the stop buy") + assert.NotNil(t, createdOrder, "place stop order should not trigger the stop buy") + assert.Len(t, engine.bidOrders, 2) + + closedOrders, trades = engine.sellToPrice(fixedpoint.NewFromFloat(20500.0)) + assert.Len(t, closedOrders, 1, "should trigger the stop buy order") + assert.Len(t, trades, 1, "should have stop order trade executed") + assert.Len(t, engine.bidOrders, 1, "should left one bid order") +} + +func TestSimplePriceMatching_StopLimitOrderSell(t *testing.T) { + account := getTestAccount() + market := getTestMarket() + engine := &SimplePriceMatching{ + account: account, + Market: market, + closedOrders: make(map[uint64]types.Order), + lastPrice: fixedpoint.NewFromFloat(22000.0), + } + + stopSellOrder := types.SubmitOrder{ + Symbol: market.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeStopLimit, + Quantity: fixedpoint.NewFromFloat(0.1), + Price: fixedpoint.NewFromFloat(20000.0), + StopPrice: fixedpoint.NewFromFloat(21000.0), + TimeInForce: types.TimeInForceGTC, + } + createdOrder, trade, err := engine.PlaceOrder(stopSellOrder) + assert.NoError(t, err) + assert.Nil(t, trade, "place stop order should not trigger the stop sell") + assert.NotNil(t, createdOrder, "place stop order should not trigger the stop sell") + + // place some limit orders, so we ensure that the remaining orders are not removed. + _, _, err = engine.PlaceOrder(newLimitOrder(market.Symbol, types.SideTypeBuy, 18000, 0.01)) + assert.NoError(t, err) + _, _, err = engine.PlaceOrder(newLimitOrder(market.Symbol, types.SideTypeSell, 32000, 0.01)) + assert.NoError(t, err) + assert.Equal(t, 1, len(engine.bidOrders)) + assert.Equal(t, 2, len(engine.askOrders)) + + closedOrders, trades := engine.sellToPrice(fixedpoint.NewFromFloat(21500.0)) + assert.Len(t, closedOrders, 0, "price change far from the price should not trigger the stop buy") + assert.Len(t, trades, 0, "price change far from the price should not trigger the stop buy") + assert.Equal(t, 1, len(engine.bidOrders)) + assert.Equal(t, 2, len(engine.askOrders)) + + closedOrders, trades = engine.sellToPrice(fixedpoint.NewFromFloat(20990.0)) + assert.Len(t, closedOrders, 1, "should trigger the stop sell order") + assert.Len(t, trades, 1, "should have stop order trade executed") + assert.Equal(t, 1, len(engine.bidOrders)) + assert.Equal(t, 1, len(engine.askOrders)) + + assert.Equal(t, types.OrderStatusFilled, closedOrders[0].Status) + assert.Equal(t, types.OrderTypeLimit, closedOrders[0].Type) + assert.Equal(t, "20000", closedOrders[0].Price.String(), "limit order price should not be changed") + assert.Equal(t, "20990", trades[0].Price.String()) + assert.Equal(t, "20990", engine.lastPrice.String()) + + // place a stop limit sell order with a higher price than the current price + stopOrder2 := types.SubmitOrder{ + Symbol: market.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeStopLimit, + Quantity: fixedpoint.NewFromFloat(0.1), + Price: fixedpoint.NewFromFloat(20000.0), + StopPrice: fixedpoint.NewFromFloat(21000.0), + TimeInForce: types.TimeInForceGTC, + } + + createdOrder, trade, err = engine.PlaceOrder(stopOrder2) + assert.NoError(t, err) + assert.Nil(t, trade, "place stop order should not trigger the stop sell") + assert.NotNil(t, createdOrder, "place stop order should not trigger the stop sell") + + closedOrders, trades = engine.buyToPrice(fixedpoint.NewFromFloat(21000.0)) + if assert.Len(t, closedOrders, 1, "should trigger the stop sell order") { + assert.Len(t, trades, 1, "should have stop order trade executed") + assert.Equal(t, types.SideTypeSell, closedOrders[0].Side) + assert.Equal(t, types.OrderStatusFilled, closedOrders[0].Status) + assert.Equal(t, types.OrderTypeLimit, closedOrders[0].Type) + assert.Equal(t, "21000", trades[0].Price.String(), "trade price should be the kline price not the order price") + assert.Equal(t, "21000", engine.lastPrice.String(), "engine last price should be updated correctly") + } +} + +func TestSimplePriceMatching_StopMarketOrderSell(t *testing.T) { + account := getTestAccount() + market := getTestMarket() + engine := &SimplePriceMatching{ + account: account, + Market: market, + closedOrders: make(map[uint64]types.Order), + lastPrice: fixedpoint.NewFromFloat(22000.0), + } + + stopOrder := types.SubmitOrder{ + Symbol: market.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeStopMarket, + Quantity: fixedpoint.NewFromFloat(0.1), + Price: fixedpoint.NewFromFloat(20000.0), + StopPrice: fixedpoint.NewFromFloat(21000.0), + TimeInForce: types.TimeInForceGTC, + } + createdOrder, trade, err := engine.PlaceOrder(stopOrder) + assert.NoError(t, err) + assert.Nil(t, trade, "place stop order should not trigger the stop sell") + assert.NotNil(t, createdOrder, "place stop order should not trigger the stop sell") + + closedOrders, trades := engine.sellToPrice(fixedpoint.NewFromFloat(21500.0)) + assert.Len(t, closedOrders, 0, "price change far from the price should not trigger the stop buy") + assert.Len(t, trades, 0, "price change far from the price should not trigger the stop buy") + + closedOrders, trades = engine.sellToPrice(fixedpoint.NewFromFloat(20990.0)) + assert.Len(t, closedOrders, 1, "should trigger the stop sell order") + assert.Len(t, trades, 1, "should have stop order trade executed") + + assert.Equal(t, types.OrderStatusFilled, closedOrders[0].Status) + assert.Equal(t, types.OrderTypeMarket, closedOrders[0].Type) + assert.Equal(t, fixedpoint.NewFromFloat(20990.0), trades[0].Price, "trade price should be adjusted to the last price") +} + +func TestSimplePriceMatching_PlaceLimitOrder(t *testing.T) { + account := getTestAccount() + market := getTestMarket() + engine := &SimplePriceMatching{ + account: account, + Market: market, + closedOrders: make(map[uint64]types.Order), + } + + for i := 0; i < 5; i++ { + _, _, err := engine.PlaceOrder(newLimitOrder("BTCUSDT", types.SideTypeBuy, 8000.0-float64(i), 1.0)) + assert.NoError(t, err) + } + assert.Len(t, engine.bidOrders, 5) + assert.Len(t, engine.askOrders, 0) + + for i := 0; i < 5; i++ { + _, _, err := engine.PlaceOrder(newLimitOrder("BTCUSDT", types.SideTypeSell, 9000.0+float64(i), 1.0)) + assert.NoError(t, err) + } + assert.Len(t, engine.bidOrders, 5) + assert.Len(t, engine.askOrders, 5) + + closedOrders, trades := engine.sellToPrice(fixedpoint.NewFromFloat(8100.0)) + assert.Len(t, closedOrders, 0) + assert.Len(t, trades, 0) + + closedOrders, trades = engine.sellToPrice(fixedpoint.NewFromFloat(8000.0)) + assert.Len(t, closedOrders, 1) + assert.Len(t, trades, 1) + for _, trade := range trades { + assert.True(t, trade.IsBuyer) + } + + for _, o := range closedOrders { + assert.Equal(t, types.SideTypeBuy, o.Side) + } + + closedOrders, trades = engine.sellToPrice(fixedpoint.NewFromFloat(7000.0)) + assert.Len(t, closedOrders, 4) + assert.Len(t, trades, 4) + + closedOrders, trades = engine.buyToPrice(fixedpoint.NewFromFloat(8900.0)) + assert.Len(t, closedOrders, 0) + assert.Len(t, trades, 0) + + closedOrders, trades = engine.buyToPrice(fixedpoint.NewFromFloat(9000.0)) + assert.Len(t, closedOrders, 1) + assert.Len(t, trades, 1) + for _, o := range closedOrders { + assert.Equal(t, types.SideTypeSell, o.Side) + } + for _, trade := range trades { + assert.Equal(t, types.SideTypeSell, trade.Side) + } + + closedOrders, trades = engine.buyToPrice(fixedpoint.NewFromFloat(9500.0)) + assert.Len(t, closedOrders, 4) + assert.Len(t, trades, 4) +} + +func TestSimplePriceMatching_LimitTakerOrder(t *testing.T) { + account := getTestAccount() + market := getTestMarket() + engine := &SimplePriceMatching{ + account: account, + Market: market, + closedOrders: make(map[uint64]types.Order), + lastPrice: fixedpoint.NewFromFloat(20000.0), + } + + closedOrder, trade, err := engine.PlaceOrder(newLimitOrder("BTCUSDT", types.SideTypeBuy, 21000.0, 1.0)) + assert.NoError(t, err) + if assert.NotNil(t, closedOrder) { + if assert.NotNil(t, trade) { + assert.Equal(t, "20000", trade.Price.String()) + assert.False(t, trade.IsMaker, "should be taker") + } + } + + closedOrder, trade, err = engine.PlaceOrder(newLimitOrder("BTCUSDT", types.SideTypeSell, 19000.0, 1.0)) + assert.NoError(t, err) + if assert.NotNil(t, closedOrder) { + assert.Equal(t, "19000", closedOrder.Price.String()) + if assert.NotNil(t, trade) { + assert.Equal(t, "20000", trade.Price.String()) + assert.False(t, trade.IsMaker, "should be taker") + } + } +} diff --git a/pkg/backtest/priceorder.go b/pkg/backtest/priceorder.go new file mode 100644 index 0000000..d0b0e5f --- /dev/null +++ b/pkg/backtest/priceorder.go @@ -0,0 +1,77 @@ +package backtest + +import ( + "sort" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type PriceOrder struct { + Price fixedpoint.Value + Order types.Order +} + +type PriceOrderSlice []PriceOrder + +func (slice PriceOrderSlice) Len() int { return len(slice) } +func (slice PriceOrderSlice) Less(i, j int) bool { return slice[i].Price.Compare(slice[j].Price) < 0 } +func (slice PriceOrderSlice) Swap(i, j int) { slice[i], slice[j] = slice[j], slice[i] } + +func (slice PriceOrderSlice) InsertAt(idx int, po PriceOrder) PriceOrderSlice { + rear := append([]PriceOrder{}, slice[idx:]...) + newSlice := append(slice[:idx], po) + return append(newSlice, rear...) +} + +func (slice PriceOrderSlice) Remove(price fixedpoint.Value, descending bool) PriceOrderSlice { + matched, idx := slice.Find(price, descending) + if matched.Price != price { + return slice + } + + return append(slice[:idx], slice[idx+1:]...) +} + +func (slice PriceOrderSlice) First() (PriceOrder, bool) { + if len(slice) > 0 { + return slice[0], true + } + return PriceOrder{}, false +} + +// FindPriceVolumePair finds the pair by the given price, this function is a read-only +// operation, so we use the value receiver to avoid copy value from the pointer +// If the price is not found, it will return the index where the price can be inserted at. +// true for descending (bid orders), false for ascending (ask orders) +func (slice PriceOrderSlice) Find(price fixedpoint.Value, descending bool) (pv PriceOrder, idx int) { + idx = sort.Search(len(slice), func(i int) bool { + if descending { + return slice[i].Price.Compare(price) <= 0 + } + return slice[i].Price.Compare(price) >= 0 + }) + + if idx >= len(slice) || slice[idx].Price != price { + return pv, idx + } + + pv = slice[idx] + + return pv, idx +} + +func (slice PriceOrderSlice) Upsert(po PriceOrder, descending bool) PriceOrderSlice { + if len(slice) == 0 { + return append(slice, po) + } + + price := po.Price + _, idx := slice.Find(price, descending) + if idx >= len(slice) || slice[idx].Price != price { + return slice.InsertAt(idx, po) + } + + slice[idx].Order = po.Order + return slice +} diff --git a/pkg/backtest/recorder.go b/pkg/backtest/recorder.go new file mode 100644 index 0000000..11c4ff5 --- /dev/null +++ b/pkg/backtest/recorder.go @@ -0,0 +1,156 @@ +package backtest + +import ( + "fmt" + "path/filepath" + "reflect" + "strings" + + "go.uber.org/multierr" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/data/tsv" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type Instance interface { + ID() string + InstanceID() string +} + +type InstancePropertyIndex struct { + ID string + InstanceID string + Property string +} + +type StateRecorder struct { + outputDirectory string + strategies []Instance + writers map[types.CsvFormatter]*tsv.Writer + lastLines map[types.CsvFormatter][]string + manifests Manifests +} + +func NewStateRecorder(outputDir string) *StateRecorder { + return &StateRecorder{ + outputDirectory: outputDir, + writers: make(map[types.CsvFormatter]*tsv.Writer), + lastLines: make(map[types.CsvFormatter][]string), + manifests: make(Manifests), + } +} + +func (r *StateRecorder) Snapshot() (int, error) { + var c int + for obj, writer := range r.writers { + records := obj.CsvRecords() + lastLine, hasLastLine := r.lastLines[obj] + + for _, record := range records { + if hasLastLine && equalStringSlice(lastLine, record) { + continue + } + + if err := writer.Write(record); err != nil { + return c, err + } + c++ + r.lastLines[obj] = record + } + + writer.Flush() + } + return c, nil +} + +func (r *StateRecorder) Scan(instance Instance) error { + r.strategies = append(r.strategies, instance) + + rt := reflect.TypeOf(instance) + rv := reflect.ValueOf(instance) + if rt.Kind() == reflect.Ptr { + rt = rt.Elem() + rv = rv.Elem() + } + + if rt.Kind() != reflect.Struct { + return fmt.Errorf("given object is not a struct: %+v", rt) + } + + for i := 0; i < rt.NumField(); i++ { + structField := rt.Field(i) + if !structField.IsExported() { + continue + } + + obj := rv.Field(i).Interface() + switch o := obj.(type) { + + case types.CsvFormatter: // interface type + typeName := strings.ToLower(structField.Type.Elem().Name()) + if typeName == "" { + return fmt.Errorf("%v is a non-defined type", structField.Type) + } + + if err := r.newCsvWriter(o, instance, typeName); err != nil { + return err + } + } + } + + return nil +} + +func (r *StateRecorder) formatCsvFilename(instance Instance, objType string) string { + return filepath.Join(r.outputDirectory, fmt.Sprintf("%s-%s.tsv", instance.InstanceID(), objType)) +} + +func (r *StateRecorder) Manifests() Manifests { + return r.manifests +} + +func (r *StateRecorder) newCsvWriter(o types.CsvFormatter, instance Instance, typeName string) error { + fn := r.formatCsvFilename(instance, typeName) + w, err := tsv.NewWriterFile(fn) + if err != nil { + return err + } + + r.manifests[InstancePropertyIndex{ + ID: instance.ID(), + InstanceID: instance.InstanceID(), + Property: typeName, + }] = fn + + r.writers[o] = w + return w.Write(o.CsvHeader()) +} + +func (r *StateRecorder) Close() error { + var err error + + for _, w := range r.writers { + err2 := w.Close() + if err2 != nil { + err = multierr.Append(err, err2) + } + } + + return err +} + +func equalStringSlice(a, b []string) bool { + if len(a) != len(b) { + return false + } + + for i := 0; i < len(a); i++ { + ad := a[i] + bd := b[i] + if ad != bd { + return false + } + } + + return true +} diff --git a/pkg/backtest/recorder_test.go b/pkg/backtest/recorder_test.go new file mode 100644 index 0000000..acb0eb6 --- /dev/null +++ b/pkg/backtest/recorder_test.go @@ -0,0 +1,61 @@ +package backtest + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type testStrategy struct { + Symbol string + + Position *types.Position +} + +func (s *testStrategy) ID() string { return "my-test" } +func (s *testStrategy) InstanceID() string { return "my-test:" + s.Symbol } + +func TestStateRecorder(t *testing.T) { + tmpDir, _ := os.MkdirTemp(os.TempDir(), "qbtrade") + t.Logf("tmpDir: %s", tmpDir) + + st := &testStrategy{ + Symbol: "BTCUSDT", + Position: types.NewPosition("BTCUSDT", "BTC", "USDT"), + } + + recorder := NewStateRecorder(tmpDir) + err := recorder.Scan(st) + assert.NoError(t, err) + assert.Len(t, recorder.writers, 1) + + st.Position.AddTrade(types.Trade{ + OrderID: 1, + Exchange: types.ExchangeBinance, + Price: fixedpoint.NewFromFloat(18000.0), + Quantity: fixedpoint.NewFromFloat(1.0), + QuoteQuantity: fixedpoint.NewFromFloat(18000.0), + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + IsBuyer: true, + IsMaker: false, + Time: types.Time(time.Now()), + Fee: fixedpoint.NewFromFloat(0.00001), + FeeCurrency: "BNB", + IsMargin: false, + IsFutures: false, + IsIsolated: false, + }) + + n, err := recorder.Snapshot() + assert.NoError(t, err) + assert.Equal(t, 1, n) + + err = recorder.Close() + assert.NoError(t, err) +} diff --git a/pkg/backtest/report.go b/pkg/backtest/report.go new file mode 100644 index 0000000..2e78965 --- /dev/null +++ b/pkg/backtest/report.go @@ -0,0 +1,261 @@ +package backtest + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + "time" + + "github.com/fatih/color" + "github.com/gofrs/flock" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/accounting/pnl" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +type Run struct { + ID string `json:"id"` + Config *qbtrade.Config `json:"config"` + Time time.Time `json:"time"` +} + +type ReportIndex struct { + Runs []Run `json:"runs,omitempty"` +} + +// SummaryReport is the summary of the back-test session +type SummaryReport struct { + StartTime time.Time `json:"startTime"` + EndTime time.Time `json:"endTime"` + Sessions []string `json:"sessions"` + Symbols []string `json:"symbols"` + Intervals []types.Interval `json:"intervals"` + InitialTotalBalances types.BalanceMap `json:"initialTotalBalances"` + FinalTotalBalances types.BalanceMap `json:"finalTotalBalances"` + + InitialEquityValue fixedpoint.Value `json:"initialEquityValue"` + FinalEquityValue fixedpoint.Value `json:"finalEquityValue"` + + // TotalProfit is the profit aggregated from the symbol reports + TotalProfit fixedpoint.Value `json:"totalProfit,omitempty"` + TotalUnrealizedProfit fixedpoint.Value `json:"totalUnrealizedProfit,omitempty"` + + TotalGrossProfit fixedpoint.Value `json:"totalGrossProfit,omitempty"` + TotalGrossLoss fixedpoint.Value `json:"totalGrossLoss,omitempty"` + + SymbolReports []SessionSymbolReport `json:"symbolReports,omitempty"` + + Manifests Manifests `json:"manifests,omitempty"` +} + +func ReadSummaryReport(filename string) (*SummaryReport, error) { + o, err := ioutil.ReadFile(filename) + if err != nil { + return nil, err + } + + var report SummaryReport + err = json.Unmarshal(o, &report) + return &report, err +} + +// SessionSymbolReport is the report per exchange session +// trades are merged, collected and re-calculated +type SessionSymbolReport struct { + Exchange types.ExchangeName `json:"exchange"` + Symbol string `json:"symbol,omitempty"` + Intervals []types.Interval `json:"intervals,omitempty"` + Subscriptions []types.Subscription `json:"subscriptions"` + Market types.Market `json:"market"` + LastPrice fixedpoint.Value `json:"lastPrice,omitempty"` + StartPrice fixedpoint.Value `json:"startPrice,omitempty"` + PnL *pnl.AverageCostPnLReport `json:"pnl,omitempty"` + InitialBalances types.BalanceMap `json:"initialBalances,omitempty"` + FinalBalances types.BalanceMap `json:"finalBalances,omitempty"` + Manifests Manifests `json:"manifests,omitempty"` + Sharpe fixedpoint.Value `json:"sharpeRatio"` + Sortino fixedpoint.Value `json:"sortinoRatio"` + ProfitFactor fixedpoint.Value `json:"profitFactor"` + WinningRatio fixedpoint.Value `json:"winningRatio"` +} + +func (r *SessionSymbolReport) InitialEquityValue() fixedpoint.Value { + return InQuoteAsset(r.InitialBalances, r.Market, r.StartPrice) +} + +func (r *SessionSymbolReport) FinalEquityValue() fixedpoint.Value { + return InQuoteAsset(r.FinalBalances, r.Market, r.LastPrice) +} + +func (r *SessionSymbolReport) Print(wantBaseAssetBaseline bool) { + color.Green("%s %s PROFIT AND LOSS REPORT", r.Exchange, r.Symbol) + color.Green("===============================================") + r.PnL.Print() + + initQuoteAsset := r.InitialEquityValue() + finalQuoteAsset := r.FinalEquityValue() + color.Green("INITIAL ASSET IN %s ~= %s %s (1 %s = %v)", r.Market.QuoteCurrency, r.Market.FormatQuantity(initQuoteAsset), r.Market.QuoteCurrency, r.Market.BaseCurrency, r.StartPrice) + color.Green("FINAL ASSET IN %s ~= %s %s (1 %s = %v)", r.Market.QuoteCurrency, r.Market.FormatQuantity(finalQuoteAsset), r.Market.QuoteCurrency, r.Market.BaseCurrency, r.LastPrice) + + if r.PnL.Profit.Sign() > 0 { + color.Green("REALIZED PROFIT: +%v %s", r.PnL.Profit, r.Market.QuoteCurrency) + } else { + color.Red("REALIZED PROFIT: %v %s", r.PnL.Profit, r.Market.QuoteCurrency) + } + + if r.PnL.UnrealizedProfit.Sign() > 0 { + color.Green("UNREALIZED PROFIT: +%v %s", r.PnL.UnrealizedProfit, r.Market.QuoteCurrency) + } else { + color.Red("UNREALIZED PROFIT: %v %s", r.PnL.UnrealizedProfit, r.Market.QuoteCurrency) + } + + if finalQuoteAsset.Compare(initQuoteAsset) > 0 { + color.Green("ASSET INCREASED: +%v %s (+%s)", finalQuoteAsset.Sub(initQuoteAsset), r.Market.QuoteCurrency, finalQuoteAsset.Sub(initQuoteAsset).Div(initQuoteAsset).FormatPercentage(2)) + } else { + color.Red("ASSET DECREASED: %v %s (%s)", finalQuoteAsset.Sub(initQuoteAsset), r.Market.QuoteCurrency, finalQuoteAsset.Sub(initQuoteAsset).Div(initQuoteAsset).FormatPercentage(2)) + } + + if r.Sharpe.Sign() > 0 { + color.Green("REALIZED SHARPE RATIO: %s", r.Sharpe.FormatString(4)) + } else { + color.Red("REALIZED SHARPE RATIO: %s", r.Sharpe.FormatString(4)) + } + + if r.Sortino.Sign() > 0 { + color.Green("REALIZED SORTINO RATIO: %s", r.Sortino.FormatString(4)) + } else { + color.Red("REALIZED SORTINO RATIO: %s", r.Sortino.FormatString(4)) + } + + if wantBaseAssetBaseline { + if r.LastPrice.Compare(r.StartPrice) > 0 { + color.Green("%s BASE ASSET PERFORMANCE: +%s (= (%s - %s) / %s)", + r.Market.BaseCurrency, + r.LastPrice.Sub(r.StartPrice).Div(r.StartPrice).FormatPercentage(2), + r.LastPrice.FormatString(2), + r.StartPrice.FormatString(2), + r.StartPrice.FormatString(2)) + } else { + color.Red("%s BASE ASSET PERFORMANCE: %s (= (%s - %s) / %s)", + r.Market.BaseCurrency, + r.LastPrice.Sub(r.StartPrice).Div(r.StartPrice).FormatPercentage(2), + r.LastPrice.FormatString(2), + r.StartPrice.FormatString(2), + r.StartPrice.FormatString(2)) + } + } +} + +const SessionTimeFormat = "2006-01-02T15_04" + +// FormatSessionName returns the back-test session name +func FormatSessionName(sessions []string, symbols []string, startTime, endTime time.Time) string { + return fmt.Sprintf("%s_%s_%s-%s", + strings.Join(sessions, "-"), + strings.Join(symbols, "-"), + startTime.Format(SessionTimeFormat), + endTime.Format(SessionTimeFormat), + ) +} + +func WriteReportIndex(outputDirectory string, reportIndex *ReportIndex) error { + indexFile := getReportIndexPath(outputDirectory) + indexLock := flock.New(indexFile) + + if err := indexLock.Lock(); err != nil { + log.WithError(err).Errorf("report index file lock error while write report: %s", err) + return err + } + defer func() { + if err := indexLock.Unlock(); err != nil { + log.WithError(err).Errorf("report index file unlock error while write report: %s", err) + } + }() + + return writeReportIndexLocked(outputDirectory, reportIndex) +} + +func LoadReportIndex(outputDirectory string) (*ReportIndex, error) { + indexFile := getReportIndexPath(outputDirectory) + indexLock := flock.New(indexFile) + + if err := indexLock.Lock(); err != nil { + log.WithError(err).Errorf("report index file lock error while load report: %s", err) + return nil, err + } + defer func() { + if err := indexLock.Unlock(); err != nil { + log.WithError(err).Errorf("report index file unlock error while load report: %s", err) + } + }() + + return loadReportIndexLocked(indexFile) +} + +func AddReportIndexRun(outputDirectory string, run Run) error { + // append report index + indexFile := getReportIndexPath(outputDirectory) + indexLock := flock.New(indexFile) + + if err := indexLock.Lock(); err != nil { + log.WithError(err).Errorf("report index file lock error: %s", err) + return err + } + defer func() { + if err := indexLock.Unlock(); err != nil { + log.WithError(err).Errorf("report index file unlock error: %s", err) + } + }() + + reportIndex, err := loadReportIndexLocked(indexFile) + if err != nil { + return err + } + + reportIndex.Runs = append(reportIndex.Runs, run) + return writeReportIndexLocked(indexFile, reportIndex) +} + +// InQuoteAsset converts all balances in quote asset +func InQuoteAsset(balances types.BalanceMap, market types.Market, price fixedpoint.Value) fixedpoint.Value { + quote := balances[market.QuoteCurrency] + base := balances[market.BaseCurrency] + return base.Total().Mul(price).Add(quote.Total()) +} + +func getReportIndexPath(outputDirectory string) string { + return filepath.Join(outputDirectory, "index.json") +} + +// writeReportIndexLocked must be protected by file lock +func writeReportIndexLocked(indexFilePath string, reportIndex *ReportIndex) error { + if err := util.WriteJsonFile(indexFilePath, reportIndex); err != nil { + return err + } + return nil +} + +// loadReportIndexLocked must be protected by file lock +func loadReportIndexLocked(indexFilePath string) (*ReportIndex, error) { + var reportIndex ReportIndex + if fileInfo, err := os.Stat(indexFilePath); err != nil { + return nil, err + } else if fileInfo.Size() != 0 { + o, err := ioutil.ReadFile(indexFilePath) + if err != nil { + return nil, err + } + + if err := json.Unmarshal(o, &reportIndex); err != nil { + return nil, err + } + } + + return &reportIndex, nil +} diff --git a/pkg/backtest/simplepricematching_callbacks.go b/pkg/backtest/simplepricematching_callbacks.go new file mode 100644 index 0000000..215f469 --- /dev/null +++ b/pkg/backtest/simplepricematching_callbacks.go @@ -0,0 +1,37 @@ +// Code generated by "callbackgen -type SimplePriceMatching"; DO NOT EDIT. + +package backtest + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func (m *SimplePriceMatching) OnTradeUpdate(cb func(trade types.Trade)) { + m.tradeUpdateCallbacks = append(m.tradeUpdateCallbacks, cb) +} + +func (m *SimplePriceMatching) EmitTradeUpdate(trade types.Trade) { + for _, cb := range m.tradeUpdateCallbacks { + cb(trade) + } +} + +func (m *SimplePriceMatching) OnOrderUpdate(cb func(order types.Order)) { + m.orderUpdateCallbacks = append(m.orderUpdateCallbacks, cb) +} + +func (m *SimplePriceMatching) EmitOrderUpdate(order types.Order) { + for _, cb := range m.orderUpdateCallbacks { + cb(order) + } +} + +func (m *SimplePriceMatching) OnBalanceUpdate(cb func(balances types.BalanceMap)) { + m.balanceUpdateCallbacks = append(m.balanceUpdateCallbacks, cb) +} + +func (m *SimplePriceMatching) EmitBalanceUpdate(balances types.BalanceMap) { + for _, cb := range m.balanceUpdateCallbacks { + cb(balances) + } +} diff --git a/pkg/backtest/utils.go b/pkg/backtest/utils.go new file mode 100644 index 0000000..ef0e669 --- /dev/null +++ b/pkg/backtest/utils.go @@ -0,0 +1,57 @@ +package backtest + +import ( + "time" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func CollectSubscriptionIntervals(environ *qbtrade.Environment) (allKLineIntervals map[types.Interval]struct{}, requiredInterval types.Interval, backTestIntervals []types.Interval) { + // default extra back-test intervals + backTestIntervals = []types.Interval{types.Interval1h, types.Interval1d} + // all subscribed intervals + allKLineIntervals = make(map[types.Interval]struct{}) + + for _, interval := range backTestIntervals { + allKLineIntervals[interval] = struct{}{} + } + // default interval is 1m for all exchanges + requiredInterval = types.Interval1m + for _, session := range environ.Sessions() { + for _, sub := range session.Subscriptions { + if sub.Channel == types.KLineChannel { + if sub.Options.Interval.Seconds()%60 > 0 { + // if any subscription interval is less than 60s, then we will use 1s for back-testing + requiredInterval = types.Interval1s + logrus.Warnf("found kline subscription interval less than 60s, modify default backtest interval to 1s") + } + allKLineIntervals[sub.Options.Interval] = struct{}{} + } + } + } + return allKLineIntervals, requiredInterval, backTestIntervals +} + +func InitializeExchangeSources(sessions map[string]*qbtrade.ExchangeSession, startTime, endTime time.Time, requiredInterval types.Interval, extraIntervals ...types.Interval) (exchangeSources []*ExchangeDataSource, err error) { + for _, session := range sessions { + backtestEx := session.Exchange.(*Exchange) + + c, err := backtestEx.SubscribeMarketData(startTime, endTime, requiredInterval, extraIntervals...) + if err != nil { + return exchangeSources, err + } + + sessionCopy := session + src := &ExchangeDataSource{ + C: c, + Exchange: backtestEx, + Session: sessionCopy, + } + backtestEx.Src = src + exchangeSources = append(exchangeSources, src) + } + return exchangeSources, nil +} diff --git a/pkg/cache/cache.go b/pkg/cache/cache.go new file mode 100644 index 0000000..eb2971a --- /dev/null +++ b/pkg/cache/cache.go @@ -0,0 +1,178 @@ +package cache + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "os" + "path" + "reflect" + "sync" + "time" + + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util/backoff" +) + +const memCacheExpiry = 5 * time.Minute +const fileCacheExpiry = 24 * time.Hour + +var globalMarketMemCache *marketMemCache = newMarketMemCache() + +type marketMemCache struct { + sync.Mutex + markets map[string]marketMapWithTime +} + +type marketMapWithTime struct { + updatedAt time.Time + markets types.MarketMap +} + +func newMarketMemCache() *marketMemCache { + cache := &marketMemCache{ + markets: make(map[string]marketMapWithTime), + } + return cache +} + +func (c *marketMemCache) IsOutdated(exName string) bool { + c.Lock() + defer c.Unlock() + + data, ok := c.markets[exName] + return !ok || time.Since(data.updatedAt) > memCacheExpiry +} + +func (c *marketMemCache) Set(exName string, markets types.MarketMap) { + c.Lock() + defer c.Unlock() + + c.markets[exName] = marketMapWithTime{ + updatedAt: time.Now(), + markets: markets, + } +} + +func (c *marketMemCache) Get(exName string) (types.MarketMap, bool) { + c.Lock() + defer c.Unlock() + + markets, ok := c.markets[exName] + if !ok { + return nil, false + } + + copied := types.MarketMap{} + for key, val := range markets.markets { + copied[key] = val + } + return copied, true +} + +type DataFetcher func() (interface{}, error) + +// WithCache let you use the cache with the given cache key, variable reference and your data fetcher, +// The key must be an unique ID. +// obj is the pointer of your local variable +// fetcher is the closure that will fetch your remote data or some slow operation. +func WithCache(key string, obj interface{}, fetcher DataFetcher) error { + cacheDir := CacheDir() + cacheFile := path.Join(cacheDir, key+".json") + + stat, err := os.Stat(cacheFile) + if os.IsNotExist(err) || (stat != nil && time.Since(stat.ModTime()) > fileCacheExpiry) { + log.Debugf("cache %s not found or cache expired, executing fetcher callback to get the data", cacheFile) + + data, err := fetcher() + if err != nil { + return err + } + + out, err := json.Marshal(data) + if err != nil { + return err + } + + if err := ioutil.WriteFile(cacheFile, out, 0666); err != nil { + return err + } + + rv := reflect.ValueOf(obj).Elem() + if !rv.CanSet() { + return errors.New("can not set cache object value") + } + + rv.Set(reflect.ValueOf(data)) + + } else { + log.Debugf("cache %s found", cacheFile) + + data, err := ioutil.ReadFile(cacheFile) + if err != nil { + return err + } + + if err := json.Unmarshal(data, obj); err != nil { + return err + } + } + + return nil +} + +func LoadExchangeMarketsWithCache(ctx context.Context, ex types.ExchangePublic) (markets types.MarketMap, err error) { + inMem, ok := util.GetEnvVarBool("USE_MARKETS_CACHE_IN_MEMORY") + if ok && inMem { + return loadMarketsFromMem(ctx, ex) + } + + // fallback to use files as cache + return loadMarketsFromFile(ctx, ex) +} + +// loadMarketsFromMem is useful for one process to run multiple qbtrades in different go routines. +func loadMarketsFromMem(ctx context.Context, ex types.ExchangePublic) (markets types.MarketMap, _ error) { + exName := ex.Name().String() + if globalMarketMemCache.IsOutdated(exName) { + op := func() error { + rst, err2 := ex.QueryMarkets(ctx) + if err2 != nil { + return err2 + } + + markets = rst + globalMarketMemCache.Set(exName, rst) + return nil + } + + if err := backoff.RetryGeneral(ctx, op); err != nil { + return nil, err + } + + return markets, nil + } + + rst, _ := globalMarketMemCache.Get(exName) + return rst, nil +} + +func loadMarketsFromFile(ctx context.Context, ex types.ExchangePublic) (markets types.MarketMap, err error) { + key := fmt.Sprintf("%s-markets", ex.Name()) + if futureExchange, implemented := ex.(types.FuturesExchange); implemented { + settings := futureExchange.GetFuturesSettings() + if settings.IsFutures { + key = fmt.Sprintf("%s-futures-markets", ex.Name()) + } + } + + err = WithCache(key, &markets, func() (interface{}, error) { + return ex.QueryMarkets(ctx) + }) + return markets, err +} diff --git a/pkg/cache/cache_test.go b/pkg/cache/cache_test.go new file mode 100644 index 0000000..b13fae3 --- /dev/null +++ b/pkg/cache/cache_test.go @@ -0,0 +1,103 @@ +package cache + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types/mocks" +) + +func Test_newMarketMemCache(t *testing.T) { + cache := newMarketMemCache() + assert.NotNil(t, cache) + assert.NotNil(t, cache.markets) +} + +func Test_marketMemCache_GetSet(t *testing.T) { + cache := newMarketMemCache() + cache.Set("max", types.MarketMap{ + "btctwd": types.Market{ + Symbol: "btctwd", + LocalSymbol: "btctwd", + }, + "ethtwd": types.Market{ + Symbol: "ethtwd", + LocalSymbol: "ethtwd", + }, + }) + markets, ok := cache.Get("max") + assert.True(t, ok) + + btctwd, ok := markets["btctwd"] + assert.True(t, ok) + ethtwd, ok := markets["ethtwd"] + assert.True(t, ok) + assert.Equal(t, types.Market{ + Symbol: "btctwd", + LocalSymbol: "btctwd", + }, btctwd) + assert.Equal(t, types.Market{ + Symbol: "ethtwd", + LocalSymbol: "ethtwd", + }, ethtwd) + + _, ok = cache.Get("binance") + assert.False(t, ok) + + expired := cache.IsOutdated("max") + assert.False(t, expired) + + detailed := cache.markets["max"] + detailed.updatedAt = time.Now().Add(-2 * memCacheExpiry) + cache.markets["max"] = detailed + expired = cache.IsOutdated("max") + assert.True(t, expired) + + expired = cache.IsOutdated("binance") + assert.True(t, expired) +} + +func Test_loadMarketsFromMem(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockEx := mocks.NewMockExchangePublic(mockCtrl) + mockEx.EXPECT().Name().Return(types.ExchangeName("max")).AnyTimes() + mockEx.EXPECT().QueryMarkets(gomock.Any()).Return(nil, errors.New("faked")).Times(1) + mockEx.EXPECT().QueryMarkets(gomock.Any()).Return(types.MarketMap{ + "btctwd": types.Market{ + Symbol: "btctwd", + LocalSymbol: "btctwd", + }, + "ethtwd": types.Market{ + Symbol: "ethtwd", + LocalSymbol: "ethtwd", + }, + }, nil).Times(1) + + for i := 0; i < 10; i++ { + markets, err := loadMarketsFromMem(context.Background(), mockEx) + assert.NoError(t, err) + + btctwd, ok := markets["btctwd"] + assert.True(t, ok) + ethtwd, ok := markets["ethtwd"] + assert.True(t, ok) + assert.Equal(t, types.Market{ + Symbol: "btctwd", + LocalSymbol: "btctwd", + }, btctwd) + assert.Equal(t, types.Market{ + Symbol: "ethtwd", + LocalSymbol: "ethtwd", + }, ethtwd) + } + + globalMarketMemCache = newMarketMemCache() // reset the global cache +} diff --git a/pkg/cache/home.go b/pkg/cache/home.go new file mode 100644 index 0000000..29040ea --- /dev/null +++ b/pkg/cache/home.go @@ -0,0 +1,31 @@ +package cache + +import ( + "os" + "path" +) + +func prepareDir(p string) string { + _, err := os.Stat(p) + if err != nil { + _ = os.Mkdir(p, 0777) + } + + return p +} + +func CacheDir() string { + home := HomeDir() + dir := path.Join(home, "cache") + return prepareDir(dir) +} + +func HomeDir() string { + homeDir, err := os.UserHomeDir() + if err != nil { + panic(err) + } + + dir := path.Join(homeDir, ".qbtrade") + return prepareDir(dir) +} diff --git a/pkg/cmd/account.go b/pkg/cmd/account.go new file mode 100644 index 0000000..5b74573 --- /dev/null +++ b/pkg/cmd/account.go @@ -0,0 +1,96 @@ +package cmd + +import ( + "context" + "fmt" + qbtrade "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func init() { + accountCmd.Flags().String("session", "", "the exchange session name for querying information") + accountCmd.Flags().Bool("total", false, "report total asset") + RootCmd.AddCommand(accountCmd) +} + +// go run ./cmd/qbtrade account --session=binance --config=config/qbtrade.yaml +var accountCmd = &cobra.Command{ + Use: "account [--session SESSION]", + Short: "show user account details (ex: balance)", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + showTotal, err := cmd.Flags().GetBool("total") + if err != nil { + return err + } + + sessionName, err := cmd.Flags().GetString("session") + if err != nil { + return err + } + + environ := qbtrade.NewEnvironment() + if err := environ.ConfigureDatabase(ctx, userConfig); err != nil { + return err + } + + if err := environ.ConfigureExchangeSessions(userConfig); err != nil { + return err + } + + if len(sessionName) > 0 { + session, ok := environ.Session(sessionName) + if !ok { + return fmt.Errorf("session %s not found", sessionName) + } + + a, err := session.Exchange.QueryAccount(ctx) + if err != nil { + return errors.Wrapf(err, "account query failed") + } + + a.Print() + } else { + var total = types.BalanceMap{} + for _, session := range environ.Sessions() { + a, err := session.Exchange.QueryAccount(ctx) + if err != nil { + return errors.Wrapf(err, "account query failed") + } + + log.Infof("--------------------------------------------") + log.Infof("SESSION %s", session.Name) + log.Infof("--------------------------------------------") + a.Print() + + for c, b := range a.Balances() { + tb, ok := total[c] + if !ok { + total[c] = b + } else { + tb.Available = tb.Available.Add(b.Available) + tb.Locked = tb.Locked.Add(b.Locked) + total[c] = tb + } + } + + if showTotal { + log.Infof("===============================================") + log.Infof("TOTAL ASSETS") + log.Infof("===============================================") + total.Print() + } + } + + } + + return nil + }, +} diff --git a/pkg/cmd/backtest.go b/pkg/cmd/backtest.go new file mode 100644 index 0000000..ef7de01 --- /dev/null +++ b/pkg/cmd/backtest.go @@ -0,0 +1,773 @@ +package cmd + +import ( + "bufio" + "context" + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/fatih/color" + "github.com/google/uuid" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/cmd/cmdutil" + "git.qtrade.icu/lychiyu/qbtrade/pkg/core" + "git.qtrade.icu/lychiyu/qbtrade/pkg/data/tsv" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" + + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/accounting/pnl" + "git.qtrade.icu/lychiyu/qbtrade/pkg/backtest" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/service" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func init() { + BacktestCmd.Flags().Bool("sync", false, "sync backtest data") + BacktestCmd.Flags().Bool("sync-only", false, "sync backtest data only, do not run backtest") + BacktestCmd.Flags().String("sync-from", "", "sync backtest data from the given time, which will override the time range in the backtest config") + BacktestCmd.Flags().String("sync-exchange", "", "specify only one exchange to sync backtest data") + BacktestCmd.Flags().String("session", "", "specify only one exchange session to run backtest") + + BacktestCmd.Flags().Bool("verify", false, "verify the kline back-test data") + + BacktestCmd.Flags().Bool("base-asset-baseline", false, "use base asset performance as the competitive baseline performance") + BacktestCmd.Flags().CountP("verbose", "v", "verbose level") + BacktestCmd.Flags().Bool("force", false, "force execution without confirm") + BacktestCmd.Flags().String("output", "", "the report output directory") + BacktestCmd.Flags().Bool("subdir", false, "generate report in the sub-directory of the output directory") + RootCmd.AddCommand(BacktestCmd) +} + +var BacktestCmd = &cobra.Command{ + Use: "backtest", + Short: "run backtest with strategies", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + verboseCnt, err := cmd.Flags().GetCount("verbose") + if err != nil { + return err + } + + if viper.GetBool("debug") { + verboseCnt = 2 + } + + configFile, err := cmd.Flags().GetString("config") + if err != nil { + return err + } + + if len(configFile) == 0 { + return errors.New("--config option is required") + } + + wantBaseAssetBaseline, err := cmd.Flags().GetBool("base-asset-baseline") + if err != nil { + return err + } + + wantSync, err := cmd.Flags().GetBool("sync") + if err != nil { + return err + } + + syncExchangeName, err := cmd.Flags().GetString("sync-exchange") + if err != nil { + return err + } + + sessionName, err := cmd.Flags().GetString("session") + if err != nil { + return err + } + + force, err := cmd.Flags().GetBool("force") + if err != nil { + return err + } + + outputDirectory, err := cmd.Flags().GetString("output") + if err != nil { + return err + } + + generatingReport := len(outputDirectory) > 0 + + reportFileInSubDir, err := cmd.Flags().GetBool("subdir") + if err != nil { + return err + } + + syncOnly, err := cmd.Flags().GetBool("sync-only") + if err != nil { + return err + } + + syncFromDateStr, err := cmd.Flags().GetString("sync-from") + if err != nil { + return err + } + + shouldVerify, err := cmd.Flags().GetBool("verify") + if err != nil { + return err + } + + userConfig, err := qbtrade.Load(configFile, true) + if err != nil { + return err + } + + if userConfig.Backtest == nil { + return errors.New("backtest config is not defined") + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var now = time.Now().Local() + var startTime, endTime time.Time + + startTime = userConfig.Backtest.StartTime.Time().Local() + + // set default start time to the past 6 months + // userConfig.Backtest.StartTime = now.AddDate(0, -6, 0).Format("2006-01-02") + if userConfig.Backtest.EndTime != nil { + endTime = userConfig.Backtest.EndTime.Time().Local() + } else { + endTime = now + } + + // ensure that we're using local time + startTime = startTime.Local() + endTime = endTime.Local() + + log.Infof("starting backtest with startTime %s", startTime.Format(time.RFC3339)) + + environ := qbtrade.NewEnvironment() + if err := qbtrade.BootstrapBacktestEnvironment(ctx, environ); err != nil { + return err + } + + if environ.DatabaseService == nil { + return errors.New("database service is not enabled, please check your environment variables DB_DRIVER and DB_DSN") + } + + backtestService := &service.BacktestService{DB: environ.DatabaseService.DB} + environ.BacktestService = backtestService + qbtrade.SetBackTesting(backtestService) + + if len(sessionName) > 0 { + userConfig.Backtest.Sessions = []string{sessionName} + } else if len(syncExchangeName) > 0 { + userConfig.Backtest.Sessions = []string{syncExchangeName} + } else if len(userConfig.Backtest.Sessions) == 0 { + log.Infof("backtest.sessions is not defined, using all supported exchanges: %v", types.SupportedExchanges) + for _, exName := range types.SupportedExchanges { + userConfig.Backtest.Sessions = append(userConfig.Backtest.Sessions, exName.String()) + } + } + + var sourceExchanges = make(map[types.ExchangeName]types.Exchange) + for _, name := range userConfig.Backtest.Sessions { + exName, err := types.ValidExchangeName(name) + if err != nil { + return err + } + + publicExchange, err := exchange.NewPublic(exName) + if err != nil { + return err + } + sourceExchanges[exName] = publicExchange + + // Set exchange to use futures + if userConfig.Sessions[exName.String()].Futures { + futuresExchange, ok := publicExchange.(types.FuturesExchange) + if !ok { + return fmt.Errorf("exchange %s does not support futures", publicExchange.Name()) + } + + futuresExchange.UseFutures() + } + } + + var syncFromTime time.Time + + // user can override the sync from time if the option is given + if len(syncFromDateStr) > 0 { + syncFromTime, err = time.Parse(types.DateFormat, syncFromDateStr) + if err != nil { + return err + } + + if syncFromTime.After(startTime) { + return fmt.Errorf("sync-from time %s can not be latter than the backtest start time %s", syncFromTime, startTime) + } + + syncFromTime = syncFromTime.Local() + } else { + // we need at least 1 month backward data for EMA and last prices + syncFromTime = startTime.AddDate(0, -1, 0) + log.Infof("adjusted sync start time %s to %s for backward market data", startTime, syncFromTime) + } + + if wantSync { + log.Infof("starting synchronization: %v", userConfig.Backtest.Symbols) + if err := sync(ctx, userConfig, backtestService, sourceExchanges, syncFromTime, endTime); err != nil { + return err + } + log.Info("synchronization done") + + if shouldVerify { + err := verify(userConfig, backtestService, sourceExchanges, syncFromTime, endTime) + if err != nil { + return err + } + } + + if syncOnly { + return nil + } + } + + if userConfig.Backtest.RecordTrades { + log.Warn("!!! Trade recording is enabled for back-testing !!!") + log.Warn("!!! To run back-testing, you should use an isolated database for storing back-testing trades !!!") + log.Warn("!!! The trade record in the current database WILL ALL BE DELETED BEFORE THIS BACK-TESTING !!!") + if !force { + if !confirmation("Are you sure to continue?") { + return nil + } + } + + if err := environ.TradeService.DeleteAll(); err != nil { + return err + } + } + + if verboseCnt == 2 { + log.SetLevel(log.DebugLevel) + } else if verboseCnt > 0 { + log.SetLevel(log.InfoLevel) + } else { + // default mode, disable strategy logging and order executor logging + log.SetLevel(log.ErrorLevel) + } + + environ.SetStartTime(startTime) + + // exchangeNameStr is the session name. + for name, sourceExchange := range sourceExchanges { + backtestExchange, err := backtest.NewExchange(sourceExchange.Name(), sourceExchange, backtestService, userConfig.Backtest) + if err != nil { + return errors.Wrap(err, "failed to create backtest exchange") + } + session := environ.AddExchange(name.String(), backtestExchange) + exchangeFromConfig := userConfig.Sessions[name.String()] + if exchangeFromConfig != nil { + session.UseHeikinAshi = exchangeFromConfig.UseHeikinAshi + session.Futures = exchangeFromConfig.Futures + } + } + + if err := environ.Init(ctx); err != nil { + 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 := qbtrade.NewTrader(environ) + if verboseCnt == 0 { + trader.DisableLogging() + } + + if err := trader.Configure(userConfig); err != nil { + return err + } + + if err := trader.Initialize(ctx); err != nil { + return err + } + + if err := trader.Run(ctx); err != nil { + return err + } + + allKLineIntervals, requiredInterval, backTestIntervals := backtest.CollectSubscriptionIntervals(environ) + exchangeSources, err := backtest.InitializeExchangeSources(environ.Sessions(), startTime, endTime, requiredInterval, backTestIntervals...) + if err != nil { + return err + } + + var kLineHandlers []func(k types.KLine, exSource *backtest.ExchangeDataSource) + var manifests backtest.Manifests + var runID = userConfig.GetSignature() + "_" + uuid.NewString() + var reportDir = outputDirectory + var sessionTradeStats = make(map[string]map[string]*types.TradeStats) + + // for each exchange session, iterate the positions and + // allocate trade collector to calculate the tradeStats + var tradeCollectorList []*core.TradeCollector + for _, exSource := range exchangeSources { + sessionName := exSource.Session.Name + tradeStatsMap := make(map[string]*types.TradeStats) + for usedSymbol := range exSource.Session.Positions() { + market, _ := exSource.Session.Market(usedSymbol) + position := types.NewPositionFromMarket(market) + orderStore := core.NewOrderStore(usedSymbol) + orderStore.AddOrderUpdate = true + tradeCollector := core.NewTradeCollector(usedSymbol, position, orderStore) + + tradeStats := types.NewTradeStats(usedSymbol) + tradeStats.SetIntervalProfitCollector(types.NewIntervalProfitCollector(types.Interval1d, startTime)) + tradeCollector.OnProfit(func(trade types.Trade, profit *types.Profit) { + if profit == nil { + return + } + tradeStats.Add(profit) + }) + tradeStatsMap[usedSymbol] = tradeStats + + orderStore.BindStream(exSource.Session.UserDataStream) + tradeCollector.BindStream(exSource.Session.UserDataStream) + tradeCollectorList = append(tradeCollectorList, tradeCollector) + } + sessionTradeStats[sessionName] = tradeStatsMap + } + + kLineHandlers = append(kLineHandlers, func(k types.KLine, _ *backtest.ExchangeDataSource) { + if k.Interval == types.Interval1d && k.Closed { + for _, collector := range tradeCollectorList { + collector.Process() + } + } + }) + + if generatingReport { + if reportFileInSubDir { + // reportDir = filepath.Join(reportDir, backtestSessionName) + reportDir = filepath.Join(reportDir, runID) + } + if err := util.SafeMkdirAll(reportDir); err != nil { + return err + } + + startTimeStr := startTime.Format("20060102") + endTimeStr := endTime.Format("20060102") + kLineSubDir := strings.Join([]string{"klines", "_", startTimeStr, "-", endTimeStr}, "") + kLineDataDir := filepath.Join(outputDirectory, "shared", kLineSubDir) + if err := util.SafeMkdirAll(kLineDataDir); err != nil { + return err + } + + stateRecorder := backtest.NewStateRecorder(reportDir) + err = trader.IterateStrategies(func(st qbtrade.StrategyID) error { + return stateRecorder.Scan(st.(backtest.Instance)) + }) + if err != nil { + return err + } + + manifests = stateRecorder.Manifests() + manifests, err = rewriteManifestPaths(manifests, reportDir) + if err != nil { + return err + } + + // state snapshot + kLineHandlers = append(kLineHandlers, func(k types.KLine, _ *backtest.ExchangeDataSource) { + // snapshot per 1m + if k.Interval == types.Interval1m && k.Closed { + if _, err := stateRecorder.Snapshot(); err != nil { + log.WithError(err).Errorf("state record failed to snapshot the strategy state") + } + } + }) + + dumper := backtest.NewKLineDumper(kLineDataDir) + defer func() { + if err := dumper.Close(); err != nil { + log.WithError(err).Errorf("kline dumper can not close files") + } + }() + + kLineHandlers = append(kLineHandlers, func(k types.KLine, _ *backtest.ExchangeDataSource) { + if err := dumper.Record(k); err != nil { + log.WithError(err).Errorf("can not write kline to file") + } + }) + + // equity curve recording -- record per 1h kline + equityCurveTsv, err := tsv.NewWriterFile(filepath.Join(reportDir, "equity_curve.tsv")) + if err != nil { + return err + } + defer func() { _ = equityCurveTsv.Close() }() + + _ = equityCurveTsv.Write([]string{ + "time", + "in_usd", + }) + defer equityCurveTsv.Flush() + + kLineHandlers = append(kLineHandlers, func(k types.KLine, exSource *backtest.ExchangeDataSource) { + if k.Interval != types.Interval1h { + return + } + + balances, err := exSource.Exchange.QueryAccountBalances(ctx) + if err != nil { + log.WithError(err).Errorf("query back-test account balance error") + } else { + assets := balances.Assets(exSource.Session.AllLastPrices(), k.EndTime.Time()) + _ = equityCurveTsv.Write([]string{ + k.EndTime.Time().Format(time.RFC1123), + assets.InUSD().String(), + }) + } + }) + + ordersTsv, err := tsv.NewWriterFile(filepath.Join(reportDir, "orders.tsv")) + if err != nil { + return err + } + defer func() { _ = ordersTsv.Close() }() + _ = ordersTsv.Write(types.Order{}.CsvHeader()) + + for _, exSource := range exchangeSources { + exSource.Session.UserDataStream.OnOrderUpdate(func(order types.Order) { + if order.Status == types.OrderStatusFilled { + for _, record := range order.CsvRecords() { + _ = ordersTsv.Write(record) + } + } + }) + } + } + + runCtx, cancelRun := context.WithCancel(ctx) + for _, exK := range exchangeSources { + exK.Callbacks = kLineHandlers + } + go func() { + defer cancelRun() + + // Optimize back-test speed for single exchange source + var numOfExchangeSources = len(exchangeSources) + if numOfExchangeSources == 1 { + exSource := exchangeSources[0] + for k := range exSource.C { + exSource.Exchange.ConsumeKLine(k, requiredInterval) + } + + if err := exSource.Exchange.CloseMarketData(); err != nil { + log.WithError(err).Errorf("close market data error") + } + return + } + + RunMultiExchangeData: + for { + for _, exK := range exchangeSources { + k, more := <-exK.C + if !more { + if err := exK.Exchange.CloseMarketData(); err != nil { + log.WithError(err).Errorf("close market data error") + return + } + break RunMultiExchangeData + } + + exK.Exchange.ConsumeKLine(k, requiredInterval) + } + } + }() + + cmdutil.WaitForSignal(runCtx, syscall.SIGINT, syscall.SIGTERM) + + log.Infof("shutting down trader...") + + gracefulShutdownPeriod := 30 * time.Second + shtCtx, cancelShutdown := context.WithTimeout(qbtrade.NewTodoContextWithExistingIsolation(ctx), gracefulShutdownPeriod) + qbtrade.Shutdown(shtCtx) + cancelShutdown() + + // put the logger back to print the pnl + log.SetLevel(log.InfoLevel) + + // aggregate total balances + initTotalBalances := types.BalanceMap{} + finalTotalBalances := types.BalanceMap{} + var sessionNames []string + for _, session := range environ.Sessions() { + sessionNames = append(sessionNames, session.Name) + accountConfig := userConfig.Backtest.GetAccount(session.Name) + initBalances := accountConfig.Balances.BalanceMap() + initTotalBalances = initTotalBalances.Add(initBalances) + + finalBalances := session.GetAccount().Balances() + finalTotalBalances = finalTotalBalances.Add(finalBalances) + } + + summaryReport := &backtest.SummaryReport{ + StartTime: startTime, + EndTime: endTime, + Sessions: sessionNames, + InitialTotalBalances: initTotalBalances, + FinalTotalBalances: finalTotalBalances, + Manifests: manifests, + Symbols: nil, + } + + for interval := range allKLineIntervals { + summaryReport.Intervals = append(summaryReport.Intervals, interval) + } + + for _, session := range environ.Sessions() { + for symbol, trades := range session.Trades { + if len(trades.Trades) == 0 { + log.Warnf("session has no %s trades", symbol) + continue + } + + tradeState := sessionTradeStats[session.Name][symbol] + profitFactor := tradeState.ProfitFactor + winningRatio := tradeState.WinningRatio + intervalProfits := tradeState.IntervalProfits[types.Interval1d] + + symbolReport, err := createSymbolReport(userConfig, session, symbol, trades.Copy(), intervalProfits, profitFactor, winningRatio) + if err != nil { + return err + } + + summaryReport.Symbols = append(summaryReport.Symbols, symbol) + summaryReport.SymbolReports = append(summaryReport.SymbolReports, *symbolReport) + summaryReport.TotalProfit = symbolReport.PnL.Profit + summaryReport.TotalUnrealizedProfit = symbolReport.PnL.UnrealizedProfit + summaryReport.InitialEquityValue = summaryReport.InitialEquityValue.Add(symbolReport.InitialEquityValue()) + summaryReport.FinalEquityValue = summaryReport.FinalEquityValue.Add(symbolReport.FinalEquityValue()) + summaryReport.TotalGrossProfit.Add(symbolReport.PnL.GrossProfit) + summaryReport.TotalGrossLoss.Add(symbolReport.PnL.GrossLoss) + + // write report to a file + if generatingReport { + reportFileName := fmt.Sprintf("symbol_report_%s_%s.json", session.Name, symbol) + if err := util.WriteJsonFile(filepath.Join(reportDir, reportFileName), &symbolReport); err != nil { + return err + } + } + } + } + + if generatingReport { + summaryReportFile := filepath.Join(reportDir, "summary.json") + + // output summary report filepath to stdout, so that our optimizer can read from it + fmt.Println(summaryReportFile) + + if err := util.WriteJsonFile(summaryReportFile, summaryReport); err != nil { + return errors.Wrapf(err, "can not write summary report json file: %s", summaryReportFile) + } + + configJsonFile := filepath.Join(reportDir, "config.json") + if err := util.WriteJsonFile(configJsonFile, userConfig); err != nil { + return errors.Wrapf(err, "can not write config json file: %s", configJsonFile) + } + + // append report index + if reportFileInSubDir { + if err := backtest.AddReportIndexRun(outputDirectory, backtest.Run{ + ID: runID, + Config: userConfig, + Time: time.Now(), + }); err != nil { + return err + } + } + } else { + color.Green("BACK-TEST REPORT") + color.Green("===============================================\n") + color.Green("START TIME: %s\n", startTime.Format(time.RFC1123)) + color.Green("END TIME: %s\n", endTime.Format(time.RFC1123)) + color.Green("INITIAL TOTAL BALANCE: %v\n", initTotalBalances) + color.Green("FINAL TOTAL BALANCE: %v\n", finalTotalBalances) + for _, symbolReport := range summaryReport.SymbolReports { + symbolReport.Print(wantBaseAssetBaseline) + } + } + + return nil + }, +} + +func createSymbolReport( + userConfig *qbtrade.Config, session *qbtrade.ExchangeSession, symbol string, trades []types.Trade, + intervalProfit *types.IntervalProfitCollector, + profitFactor, winningRatio fixedpoint.Value, +) ( + *backtest.SessionSymbolReport, + error, +) { + backtestExchange, ok := session.Exchange.(*backtest.Exchange) + if !ok { + return nil, fmt.Errorf("unexpected error, exchange instance is not a backtest exchange") + } + + market, ok := session.Market(symbol) + if !ok { + return nil, fmt.Errorf("market not found: %s, %s", symbol, session.Exchange.Name()) + } + + startPrice, ok := session.StartPrice(symbol) + if !ok { + return nil, fmt.Errorf("start price not found: %s, %s. run --sync first", symbol, session.Exchange.Name()) + } + + lastPrice, ok := session.LastPrice(symbol) + if !ok { + return nil, fmt.Errorf("last price not found: %s, %s", symbol, session.Exchange.Name()) + } + + calculator := &pnl.AverageCostCalculator{ + TradingFeeCurrency: backtestExchange.PlatformFeeCurrency(), + Market: market, + } + + sharpeRatio := fixedpoint.NewFromFloat(intervalProfit.GetSharpe()) + sortinoRatio := fixedpoint.NewFromFloat(intervalProfit.GetSortino()) + + report := calculator.Calculate(symbol, trades, lastPrice) + accountConfig := userConfig.Backtest.GetAccount(session.Exchange.Name().String()) + initBalances := accountConfig.Balances.BalanceMap() + finalBalances := session.GetAccount().Balances() + symbolReport := backtest.SessionSymbolReport{ + Exchange: session.Exchange.Name(), + Symbol: symbol, + Market: market, + LastPrice: lastPrice, + StartPrice: startPrice, + PnL: report, + InitialBalances: initBalances, + FinalBalances: finalBalances, + // Manifests: manifests, + Sharpe: sharpeRatio, + Sortino: sortinoRatio, + ProfitFactor: profitFactor, + WinningRatio: winningRatio, + } + + for _, s := range session.Subscriptions { + symbolReport.Subscriptions = append(symbolReport.Subscriptions, s) + } + + sessionKLineIntervals := map[types.Interval]struct{}{} + for _, sub := range session.Subscriptions { + if sub.Channel == types.KLineChannel { + sessionKLineIntervals[sub.Options.Interval] = struct{}{} + } + } + + for interval := range sessionKLineIntervals { + symbolReport.Intervals = append(symbolReport.Intervals, interval) + } + + return &symbolReport, nil +} + +func verify( + userConfig *qbtrade.Config, backtestService *service.BacktestService, + sourceExchanges map[types.ExchangeName]types.Exchange, startTime, endTime time.Time, +) error { + for _, sourceExchange := range sourceExchanges { + err := backtestService.Verify(sourceExchange, userConfig.Backtest.Symbols, startTime, endTime) + if err != nil { + return err + } + } + return nil +} + +func confirmation(s string) bool { + reader := bufio.NewReader(os.Stdin) + for { + fmt.Printf("%s [y/N]: ", s) + + response, err := reader.ReadString('\n') + if err != nil { + log.Fatal(err) + } + + response = strings.ToLower(strings.TrimSpace(response)) + + if response == "y" || response == "yes" { + return true + } else if response == "n" || response == "no" { + return false + } else { + return false + } + } +} + +func getExchangeIntervals(ex types.Exchange) types.IntervalMap { + exCustom, ok := ex.(types.CustomIntervalProvider) + if ok { + return exCustom.SupportedInterval() + } + return types.SupportedIntervals +} + +func sync( + ctx context.Context, userConfig *qbtrade.Config, backtestService *service.BacktestService, + sourceExchanges map[types.ExchangeName]types.Exchange, syncFrom, syncTo time.Time, +) error { + for _, symbol := range userConfig.Backtest.Symbols { + for _, sourceExchange := range sourceExchanges { + var supportIntervals = getExchangeIntervals(sourceExchange) + + if !userConfig.Backtest.SyncSecKLines { + delete(supportIntervals, types.Interval1s) + } + + // sort intervals + var intervals = supportIntervals.Slice() + intervals.Sort() + + for _, interval := range intervals { + if err := backtestService.Sync(ctx, sourceExchange, symbol, interval, syncFrom, syncTo); err != nil { + return err + } + } + } + } + return nil +} + +func rewriteManifestPaths(manifests backtest.Manifests, basePath string) (backtest.Manifests, error) { + var filterManifests = backtest.Manifests{} + for k, m := range manifests { + p, err := filepath.Rel(basePath, m) + if err != nil { + return nil, err + } + filterManifests[k] = p + } + return filterManifests, nil +} diff --git a/pkg/cmd/balances.go b/pkg/cmd/balances.go new file mode 100644 index 0000000..95076bf --- /dev/null +++ b/pkg/cmd/balances.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "context" + "fmt" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" +) + +func init() { + balancesCmd.Flags().String("session", "", "the exchange session name for querying balances") + RootCmd.AddCommand(balancesCmd) +} + +// go run ./cmd/qbtrade balances --session=binance +var balancesCmd = &cobra.Command{ + Use: "balances [--session SESSION]", + Short: "Show user account balances", + SilenceUsage: true, + PreRunE: cobraInitRequired([]string{ + "session", + }), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + sessionName, err := cmd.Flags().GetString("session") + if err != nil { + return err + } + + environ := qbtrade.NewEnvironment() + + if err := environ.ConfigureExchangeSessions(userConfig); err != nil { + return err + } + + if len(sessionName) > 0 { + session, ok := environ.Session(sessionName) + if !ok { + return fmt.Errorf("session %s not found", sessionName) + } + + b, err := session.Exchange.QueryAccountBalances(ctx) + if err != nil { + return err + } + + b.Print() + } else { + for _, session := range environ.Sessions() { + + b, err := session.Exchange.QueryAccountBalances(ctx) + if err != nil { + return err + } + + log.Infof("SESSION %s", session.Name) + b.Print() + } + } + + return nil + }, +} diff --git a/pkg/cmd/build.go b/pkg/cmd/build.go new file mode 100644 index 0000000..f70e773 --- /dev/null +++ b/pkg/cmd/build.go @@ -0,0 +1,59 @@ +package cmd + +import ( + "context" + + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" +) + +func init() { + RootCmd.AddCommand(BuildCmd) +} + +var BuildCmd = &cobra.Command{ + Use: "build", + Short: "build cross-platform binary", + + // SilenceUsage is an option to silence usage when an error occurs. + SilenceUsage: true, + + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + configFile, err := cmd.Flags().GetString("config") + if err != nil { + return err + } + + if len(configFile) == 0 { + return errors.New("--config option is required") + } + + userConfig, err := qbtrade.LoadBuildConfig(configFile) + if err != nil { + return err + } + + if userConfig.Build == nil { + return errors.New("build config is not defined") + } + + for _, target := range userConfig.Build.Targets { + log.Infof("building %s ...", target.Name) + + binary, err := qbtrade.BuildTarget(ctx, userConfig, target) + if err != nil { + return err + } + + log.Infof("build succeeded: %s", binary) + } + + return nil + }, +} diff --git a/pkg/cmd/cancel.go b/pkg/cmd/cancel.go new file mode 100644 index 0000000..0cd0ccc --- /dev/null +++ b/pkg/cmd/cancel.go @@ -0,0 +1,166 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type advancedOrderCancelApi interface { + CancelAllOrders(ctx context.Context) ([]types.Order, error) + CancelOrdersBySymbol(ctx context.Context, symbol string) ([]types.Order, error) + CancelOrdersByGroupID(ctx context.Context, groupID int64) ([]types.Order, error) +} + +func init() { + cancelOrderCmd.Flags().String("session", "", "session to execute cancel orders") + cancelOrderCmd.Flags().String("symbol", "", "symbol to cancel orders") + cancelOrderCmd.Flags().Int64("group-id", 0, "group ID to cancel orders") + cancelOrderCmd.Flags().Uint64("order-id", 0, "order ID to cancel orders") + cancelOrderCmd.Flags().String("order-uuid", "", "order UUID to cancel orders") + cancelOrderCmd.Flags().Bool("all", false, "cancel all orders") + RootCmd.AddCommand(cancelOrderCmd) +} + +var cancelOrderCmd = &cobra.Command{ + Use: "cancel-order", + Short: "cancel orders", + Long: "this command can cancel orders from exchange", + + // SilenceUsage is an option to silence usage when an error occurs. + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + symbol, err := cmd.Flags().GetString("symbol") + if err != nil { + return err + } + + groupID, err := cmd.Flags().GetInt64("group-id") + if err != nil { + return err + } + + orderID, err := cmd.Flags().GetUint64("order-id") + if err != nil { + return err + } + + orderUUID, err := cmd.Flags().GetString("order-uuid") + if err != nil { + return err + } + + all, err := cmd.Flags().GetBool("all") + if err != nil { + return err + } + + sessionName, err := cmd.Flags().GetString("session") + if err != nil { + return err + } + + environ := qbtrade.NewEnvironment() + if err := environ.ConfigureExchangeSessions(userConfig); err != nil { + return err + } + + if err := environ.Init(ctx); err != nil { + return err + } + + var sessions = environ.Sessions() + + if len(sessionName) > 0 { + ses, ok := environ.Session(sessionName) + if !ok { + return fmt.Errorf("session %s not found", sessionName) + } + + if orderID > 0 || orderUUID != "" { + if orderID > 0 { + logrus.Infof("canceling order by the given order id %d", orderID) + } else if orderUUID != "" { + logrus.Infof("canceling order by the given order uuid %s", orderUUID) + } + + err := ses.Exchange.CancelOrders(ctx, types.Order{ + SubmitOrder: types.SubmitOrder{ + Symbol: symbol, + }, + OrderID: orderID, + UUID: orderUUID, + }) + if err != nil { + return err + } + return nil + } + + sessions = map[string]*qbtrade.ExchangeSession{sessionName: ses} + } + + for sessionID, session := range sessions { + var log = logrus.WithField("session", sessionID) + + e, ok := session.Exchange.(advancedOrderCancelApi) + if ok { + if all { + log.Infof("canceling all orders") + + orders, err := e.CancelAllOrders(ctx) + if err != nil { + return err + } + + for _, o := range orders { + log.Info("CANCELED ", o.String()) + } + } else if groupID > 0 { + log.Infof("canceling orders by group id: %d", groupID) + + orders, err := e.CancelOrdersByGroupID(ctx, groupID) + if err != nil { + return err + } + + for _, o := range orders { + log.Info("CANCELED ", o.String()) + } + } else if len(symbol) > 0 { + log.Infof("canceling orders by symbol: %s", symbol) + + orders, err := e.CancelOrdersBySymbol(ctx, symbol) + if err != nil { + return err + } + + for _, o := range orders { + log.Info("CANCELED ", o.String()) + } + } + } else if len(symbol) > 0 { + openOrders, err := session.Exchange.QueryOpenOrders(ctx, symbol) + if err != nil { + return err + } + + if err := session.Exchange.CancelOrders(ctx, openOrders...); err != nil { + return err + } + } else { + log.Error("unsupported operation") + } + } + + return nil + }, +} diff --git a/pkg/cmd/cmdutil/exchange.go b/pkg/cmd/cmdutil/exchange.go new file mode 100644 index 0000000..b6eaaab --- /dev/null +++ b/pkg/cmd/cmdutil/exchange.go @@ -0,0 +1 @@ +package cmdutil diff --git a/pkg/cmd/cmdutil/flags.go b/pkg/cmd/cmdutil/flags.go new file mode 100644 index 0000000..b8a7cbd --- /dev/null +++ b/pkg/cmd/cmdutil/flags.go @@ -0,0 +1,11 @@ +package cmdutil + +import "github.com/spf13/pflag" + +// PersistentFlags defines the flags for environments +func PersistentFlags(flags *pflag.FlagSet) { + flags.String("binance-api-key", "", "binance api key") + flags.String("binance-api-secret", "", "binance api secret") + flags.String("max-api-key", "", "max api key") + flags.String("max-api-secret", "", "max api secret") +} diff --git a/pkg/cmd/cmdutil/signal.go b/pkg/cmd/cmdutil/signal.go new file mode 100644 index 0000000..1c7cd64 --- /dev/null +++ b/pkg/cmd/cmdutil/signal.go @@ -0,0 +1,27 @@ +package cmdutil + +import ( + "context" + "os" + "os/signal" + + "github.com/sirupsen/logrus" +) + +func WaitForSignal(ctx context.Context, signals ...os.Signal) os.Signal { + var sigC = make(chan os.Signal, 1) + signal.Notify(sigC, signals...) + defer signal.Stop(sigC) + + select { + case sig := <-sigC: + logrus.Warnf("%v", sig) + return sig + + case <-ctx.Done(): + return nil + + } + + return nil +} diff --git a/pkg/cmd/deposit.go b/pkg/cmd/deposit.go new file mode 100644 index 0000000..dd0bbac --- /dev/null +++ b/pkg/cmd/deposit.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "context" + "fmt" + "time" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func init() { + depositsCmd.Flags().String("session", "", "the exchange session name for querying balances") + depositsCmd.Flags().String("asset", "", "the trading pair, like btcusdt") + RootCmd.AddCommand(depositsCmd) +} + +// go run ./cmd/qbtrade deposits --session=ftx --asset="BTC" +// This is a testing util and will query deposits in last 7 days. +var depositsCmd = &cobra.Command{ + Use: "deposits", + Short: "A testing utility that will query deposition history in last 7 days", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + environ := qbtrade.NewEnvironment() + if err := environ.ConfigureExchangeSessions(userConfig); err != nil { + return err + } + + sessionName, err := cmd.Flags().GetString("session") + if err != nil { + return err + } + + session, ok := environ.Session(sessionName) + if !ok { + return fmt.Errorf("session %s not found", sessionName) + } + + asset, err := cmd.Flags().GetString("asset") + if err != nil { + return fmt.Errorf("can't get the asset from flags: %w", err) + } + if asset == "" { + return fmt.Errorf("asset is not found") + } + + until := time.Now() + since := until.Add(-7 * 24 * time.Hour) + exchange, ok := session.Exchange.(types.ExchangeTransferService) + if !ok { + return fmt.Errorf("exchange session %s does not implement transfer service", sessionName) + } + histories, err := exchange.QueryDepositHistory(ctx, asset, since, until) + if err != nil { + return err + } + + log.Infof("%d histories", len(histories)) + for _, h := range histories { + log.Infof("deposit history: %+v", h) + } + return nil + }, +} diff --git a/pkg/cmd/exchangetest.go b/pkg/cmd/exchangetest.go new file mode 100644 index 0000000..9a0b9b3 --- /dev/null +++ b/pkg/cmd/exchangetest.go @@ -0,0 +1,63 @@ +//go:build exchangetest +// +build exchangetest + +package cmd + +import ( + "context" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// go run ./cmd/qbtrade kline --exchange=binance --symbol=BTCUSDT +var exchangeTestCmd = &cobra.Command{ + Use: "exchange-test", + Short: "test the exchange", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + exchangeNameStr, err := cmd.Flags().GetString("exchange") + if err != nil { + return err + } + + exchangeName, err := types.ValidExchangeName(exchangeNameStr) + if err != nil { + return err + } + + exMinimal, err := exchange.NewWithEnvVarPrefix(exchangeName, "") + if err != nil { + return err + } + + log.Infof("types.ExchangeMinimal: ✅") + + if service, ok := exMinimal.(types.ExchangeAccountService); ok { + log.Infof("types.ExchangeAccountService: ✅ (%T)", service) + } + + if service, ok := exMinimal.(types.ExchangeMarketDataService); ok { + log.Infof("types.ExchangeMarketDataService: ✅ (%T)", service) + } + + if ex, ok := exMinimal.(types.Exchange); ok { + log.Infof("types.Exchange: ✅ (%T)", ex) + } + + _ = ctx + // cmdutil.WaitForSignal(ctx, syscall.SIGINT, syscall.SIGTERM) + return nil + }, +} + +func init() { + exchangeTestCmd.Flags().String("exchange", "", "session name") + exchangeTestCmd.MarkFlagRequired("exchange") + + RootCmd.AddCommand(exchangeTestCmd) +} diff --git a/pkg/cmd/hoptimize.go b/pkg/cmd/hoptimize.go new file mode 100644 index 0000000..36f5f59 --- /dev/null +++ b/pkg/cmd/hoptimize.go @@ -0,0 +1,171 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/optimizer" + "github.com/fatih/color" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + "io/ioutil" + "os" + "os/signal" + "syscall" + "time" +) + +func init() { + hoptimizeCmd.Flags().String("optimizer-config", "optimizer.yaml", "config file") + hoptimizeCmd.Flags().String("name", "", "assign an optimization session name") + hoptimizeCmd.Flags().Bool("json-keep-all", false, "keep all results of trials") + hoptimizeCmd.Flags().String("output", "output", "backtest report output directory") + hoptimizeCmd.Flags().Bool("json", false, "print optimizer metrics in json format") + hoptimizeCmd.Flags().Bool("tsv", false, "print optimizer metrics in csv format") + RootCmd.AddCommand(hoptimizeCmd) +} + +var hoptimizeCmd = &cobra.Command{ + Use: "hoptimize", + Short: "run hyperparameter optimizer (experimental)", + + // SilenceUsage is an option to silence usage when an error occurs. + SilenceUsage: true, + + RunE: func(cmd *cobra.Command, args []string) error { + optimizerConfigFilename, err := cmd.Flags().GetString("optimizer-config") + if err != nil { + return err + } + + configFile, err := cmd.Flags().GetString("config") + if err != nil { + return err + } + + optSessionName, err := cmd.Flags().GetString("name") + if err != nil { + return err + } + + jsonKeepAll, err := cmd.Flags().GetBool("json-keep-all") + if err != nil { + return err + } + + outputDirectory, err := cmd.Flags().GetString("output") + if err != nil { + return err + } + + printJsonFormat, err := cmd.Flags().GetBool("json") + if err != nil { + return err + } + + printTsvFormat, err := cmd.Flags().GetBool("tsv") + if err != nil { + return err + } + + yamlBody, err := ioutil.ReadFile(configFile) + if err != nil { + return err + } + var obj map[string]interface{} + if err := yaml.Unmarshal(yamlBody, &obj); err != nil { + return err + } + delete(obj, "notifications") + delete(obj, "sync") + + optConfig, err := optimizer.LoadConfig(optimizerConfigFilename) + if err != nil { + return err + } + + // the config json template used for patch + configJson, err := json.MarshalIndent(obj, "", " ") + if err != nil { + return err + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + c := make(chan os.Signal) + signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) + <-c + log.Info("Early stop by manual cancelation.") + cancel() + }() + + if len(optSessionName) == 0 { + optSessionName = fmt.Sprintf("qbtrade-hpopt-%v", time.Now().UnixMilli()) + } + tempDirNameFormat := fmt.Sprintf("%s-config-*", optSessionName) + configDir, err := os.MkdirTemp("", tempDirNameFormat) + if err != nil { + return err + } + + executor := &optimizer.LocalProcessExecutor{ + Config: optConfig.Executor.LocalExecutorConfig, + Bin: os.Args[0], + WorkDir: ".", + ConfigDir: configDir, + OutputDir: outputDirectory, + } + + optz := &optimizer.HyperparameterOptimizer{ + SessionName: optSessionName, + Config: optConfig, + } + + if err := executor.Prepare(configJson); err != nil { + return err + } + + report, err := optz.Run(ctx, executor, configJson) + log.Info("All test trial finished.") + if err != nil { + return err + } + + if printJsonFormat { + if !jsonKeepAll { + report.Trials = nil + } + out, err := json.MarshalIndent(report, "", " ") + if err != nil { + return err + } + + // print report JSON to stdout + fmt.Println(string(out)) + } else if printTsvFormat { + if err := optimizer.FormatResultsTsv(os.Stdout, report.Parameters, report.Trials); err != nil { + return err + } + } else { + color.Green("OPTIMIZER REPORT") + color.Green("===============================================\n") + color.Green("SESSION NAME: %s\n", report.Name) + color.Green("OPTIMIZE OBJECTIVE: %s\n", report.Objective) + color.Green("BEST OBJECTIVE VALUE: %s\n", report.Best.Value) + color.Green("OPTIMAL PARAMETERS:") + for _, selectorConfig := range optConfig.Matrix { + label := selectorConfig.Label + if val, exist := report.Best.Parameters[label]; exist { + color.Green(" - %s: %v", label, val) + } else { + color.Red(" - %s: (invalid parameter definition)", label) + } + } + } + + return nil + }, +} diff --git a/pkg/cmd/import.go b/pkg/cmd/import.go new file mode 100644 index 0000000..6e23f87 --- /dev/null +++ b/pkg/cmd/import.go @@ -0,0 +1,6 @@ +package cmd + +// import built-in strategies +import ( + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/cmd/strategy" +) diff --git a/pkg/cmd/kline.go b/pkg/cmd/kline.go new file mode 100644 index 0000000..4a7ac9e --- /dev/null +++ b/pkg/cmd/kline.go @@ -0,0 +1,107 @@ +package cmd + +import ( + "context" + "fmt" + "syscall" + "time" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/cmd/cmdutil" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// go run ./cmd/qbtrade kline --exchange=binance --symbol=BTCUSDT +var klineCmd = &cobra.Command{ + Use: "kline", + Short: "connect to the kline market data streaming service of an exchange", + PreRunE: cobraInitRequired([]string{ + "session", + "symbol", + "interval", + }), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + environ := qbtrade.NewEnvironment() + if err := environ.ConfigureExchangeSessions(userConfig); err != nil { + return err + } + + sessionName, err := cmd.Flags().GetString("session") + if err != nil { + return err + } + + session, ok := environ.Session(sessionName) + if !ok { + return fmt.Errorf("session %s not found", sessionName) + } + + symbol, err := cmd.Flags().GetString("symbol") + if err != nil { + return fmt.Errorf("can not get the symbol from flags: %w", err) + } + + if symbol == "" { + return fmt.Errorf("--symbol option is required") + } + + interval, err := cmd.Flags().GetString("interval") + if err != nil { + return err + } + + now := time.Now() + kLines, err := session.Exchange.QueryKLines(ctx, symbol, types.Interval(interval), types.KLineQueryOptions{ + Limit: 50, + EndTime: &now, + }) + if err != nil { + return err + } + log.Infof("kLines from RESTful API") + for _, k := range kLines { + log.Info(k.String()) + } + + s := session.Exchange.NewStream() + s.SetPublicOnly() + s.Subscribe(types.KLineChannel, symbol, types.SubscribeOptions{Interval: types.Interval(interval)}) + + s.OnKLineClosed(func(kline types.KLine) { + log.Infof("kline closed: %s", kline.String()) + }) + + s.OnKLine(func(kline types.KLine) { + log.Infof("kline: %s", kline.String()) + }) + + log.Infof("connecting...") + if err := s.Connect(ctx); err != nil { + return err + } + + log.Infof("connected") + defer func() { + log.Infof("closing connection...") + if err := s.Close(); err != nil { + log.WithError(err).Errorf("connection close error") + } + }() + + cmdutil.WaitForSignal(ctx, syscall.SIGINT, syscall.SIGTERM) + return nil + }, +} + +func init() { + // since the public data does not require trading authentication, we use --exchange option here. + klineCmd.Flags().String("session", "", "session name") + klineCmd.Flags().String("symbol", "", "the trading pair. e.g, BTCUSDT, LTCUSDT...") + klineCmd.Flags().String("interval", "1m", "interval of the kline (candle), .e.g, 1m, 3m, 15m") + RootCmd.AddCommand(klineCmd) +} diff --git a/pkg/cmd/margin.go b/pkg/cmd/margin.go new file mode 100644 index 0000000..2bffbae --- /dev/null +++ b/pkg/cmd/margin.go @@ -0,0 +1,189 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "time" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +var selectedSession *qbtrade.ExchangeSession + +func init() { + marginLoansCmd.Flags().String("asset", "", "asset") + marginLoansCmd.Flags().String("session", "", "exchange session name") + marginCmd.AddCommand(marginLoansCmd) + + marginRepaysCmd.Flags().String("asset", "", "asset") + marginRepaysCmd.Flags().String("session", "", "exchange session name") + marginCmd.AddCommand(marginRepaysCmd) + + marginInterestsCmd.Flags().String("asset", "", "asset") + marginInterestsCmd.Flags().String("session", "", "exchange session name") + marginCmd.AddCommand(marginInterestsCmd) + + RootCmd.AddCommand(marginCmd) +} + +// go run ./cmd/qbtrade margin --session=binance +var marginCmd = &cobra.Command{ + Use: "margin", + Short: "margin related history", + SilenceUsage: true, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if err := cobraLoadDotenv(cmd, args); err != nil { + return err + } + + if err := cobraLoadConfig(cmd, args); err != nil { + return err + } + + // ctx := context.Background() + environ := qbtrade.NewEnvironment() + + if userConfig == nil { + return errors.New("user config is not loaded") + } + + if err := environ.ConfigureExchangeSessions(userConfig); err != nil { + return err + } + + sessionName, err := cmd.Flags().GetString("session") + if err != nil { + return err + } + + session, ok := environ.Session(sessionName) + if !ok { + return fmt.Errorf("session %s not found", sessionName) + } + + selectedSession = session + return nil + }, +} + +// go run ./cmd/qbtrade margin loans --session=binance +var marginLoansCmd = &cobra.Command{ + Use: "loans --session=SESSION_NAME --asset=ASSET", + Short: "query loans history", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + asset, err := cmd.Flags().GetString("asset") + if err != nil { + return err + } + + if selectedSession == nil { + return errors.New("session is not set") + } + + marginHistoryService, ok := selectedSession.Exchange.(types.MarginHistoryService) + if !ok { + return fmt.Errorf("exchange %s does not support MarginHistoryService service", selectedSession.ExchangeName) + } + + now := time.Now() + startTime := now.AddDate(0, -5, 0) + endTime := now + loans, err := marginHistoryService.QueryLoanHistory(ctx, asset, &startTime, &endTime) + if err != nil { + return err + } + + log.Infof("%d loans", len(loans)) + for _, loan := range loans { + log.Infof("LOAN %+v", loan) + } + + return nil + }, +} + +// go run ./cmd/qbtrade margin loans --session=binance +var marginRepaysCmd = &cobra.Command{ + Use: "repays --session=SESSION_NAME --asset=ASSET", + Short: "query repay history", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + asset, err := cmd.Flags().GetString("asset") + if err != nil { + return err + } + + if selectedSession == nil { + return errors.New("session is not set") + } + + marginHistoryService, ok := selectedSession.Exchange.(types.MarginHistoryService) + if !ok { + return fmt.Errorf("exchange %s does not support MarginHistoryService service", selectedSession.ExchangeName) + } + + now := time.Now() + startTime := now.AddDate(0, -5, 0) + endTime := now + repays, err := marginHistoryService.QueryLoanHistory(ctx, asset, &startTime, &endTime) + if err != nil { + return err + } + + log.Infof("%d repays", len(repays)) + for _, repay := range repays { + log.Infof("REPAY %+v", repay) + } + + return nil + }, +} + +// go run ./cmd/qbtrade margin interests --session=binance +var marginInterestsCmd = &cobra.Command{ + Use: "interests --session=SESSION_NAME --asset=ASSET", + Short: "query interests history", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + asset, err := cmd.Flags().GetString("asset") + if err != nil { + return fmt.Errorf("can't get the symbol from flags: %w", err) + } + + if selectedSession == nil { + return errors.New("session is not set") + } + + marginHistoryService, ok := selectedSession.Exchange.(types.MarginHistoryService) + if !ok { + return fmt.Errorf("exchange %s does not support MarginHistoryService service", selectedSession.ExchangeName) + } + + now := time.Now() + startTime := now.AddDate(0, -5, 0) + endTime := now + interests, err := marginHistoryService.QueryInterestHistory(ctx, asset, &startTime, &endTime) + if err != nil { + return err + } + + log.Infof("%d interests", len(interests)) + for _, interest := range interests { + log.Infof("INTEREST %+v", interest) + } + + return nil + }, +} diff --git a/pkg/cmd/market.go b/pkg/cmd/market.go new file mode 100644 index 0000000..0ee27a3 --- /dev/null +++ b/pkg/cmd/market.go @@ -0,0 +1,75 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" +) + +func init() { + marketCmd.Flags().String("session", "", "the exchange session name for querying information") + RootCmd.AddCommand(marketCmd) +} + +// go run ./cmd/qbtrade market --session=binance --config=config/qbtrade.yaml +var marketCmd = &cobra.Command{ + Use: "market", + Short: "List the symbols that the are available to be traded in the exchange", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + configFile, err := cmd.Flags().GetString("config") + if err != nil { + return err + } + + if len(configFile) == 0 { + return errors.New("--config option is required") + } + + if _, err := os.Stat(configFile); os.IsNotExist(err) { + return err + } + + userConfig, err := qbtrade.Load(configFile, false) + if err != nil { + return err + } + + environ := qbtrade.NewEnvironment() + if err := environ.ConfigureDatabase(ctx, userConfig); err != nil { + return err + } + + if err := environ.ConfigureExchangeSessions(userConfig); err != nil { + return err + } + + sessionName, err := cmd.Flags().GetString("session") + if err != nil { + return err + } + + session, ok := environ.Session(sessionName) + if !ok { + return fmt.Errorf("session %s not found", sessionName) + } + + markets, err := session.Exchange.QueryMarkets(ctx) + if err != nil { + return err + } + + for _, m := range markets { + log.Infof("market: %+v", m) + } + return nil + }, +} diff --git a/pkg/cmd/optimize.go b/pkg/cmd/optimize.go new file mode 100644 index 0000000..aa37e84 --- /dev/null +++ b/pkg/cmd/optimize.go @@ -0,0 +1,146 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "os" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/optimizer" +) + +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") + optimizeCmd.Flags().Int("limit", 50, "limit how many results to print pr metric") + RootCmd.AddCommand(optimizeCmd) +} + +var optimizeCmd = &cobra.Command{ + Use: "optimize", + Short: "run optimizer", + + // SilenceUsage is an option to silence usage when an error occurs. + SilenceUsage: true, + + RunE: func(cmd *cobra.Command, args []string) error { + optimizerConfigFilename, err := cmd.Flags().GetString("optimizer-config") + if err != nil { + return err + } + + configFile, err := cmd.Flags().GetString("config") + if err != nil { + return err + } + + printJsonFormat, err := cmd.Flags().GetBool("json") + if err != nil { + return err + } + + printTsvFormat, err := cmd.Flags().GetBool("tsv") + if err != nil { + return err + } + + outputDirectory, err := cmd.Flags().GetString("output") + if err != nil { + return err + } + + resultLimit, err := cmd.Flags().GetInt("limit") + if err != nil { + return err + } + + yamlBody, err := ioutil.ReadFile(configFile) + if err != nil { + return err + } + var obj map[string]interface{} + if err := yaml.Unmarshal(yamlBody, &obj); err != nil { + return err + } + delete(obj, "notifications") + delete(obj, "sync") + + optConfig, err := optimizer.LoadConfig(optimizerConfigFilename) + if err != nil { + return err + } + + // the config json template used for patch + configJson, err := json.MarshalIndent(obj, "", " ") + if err != nil { + return err + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + _ = ctx + + configDir, err := os.MkdirTemp("", "qbtrade-config-*") + if err != nil { + return err + } + + executor := &optimizer.LocalProcessExecutor{ + Config: optConfig.Executor.LocalExecutorConfig, + Bin: os.Args[0], + WorkDir: ".", + ConfigDir: configDir, + OutputDir: outputDirectory, + } + + optz := &optimizer.GridOptimizer{ + Config: optConfig, + } + + if err := executor.Prepare(configJson); err != nil { + return err + } + + metrics, err := optz.Run(executor, configJson) + if err != nil { + return err + } + + if printJsonFormat { + out, err := json.MarshalIndent(metrics, "", " ") + if err != nil { + return err + } + + // print metrics JSON to stdout + fmt.Println(string(out)) + } else if printTsvFormat { + if err := optimizer.FormatMetricsTsv(os.Stdout, metrics); err != nil { + return err + } + } else { + for n, values := range metrics { + if len(values) == 0 { + continue + } + + if len(values) > resultLimit && resultLimit != 0 { + values = values[:resultLimit] + } + + fmt.Printf("%v => %s\n", values[0].Labels, n) + for _, m := range values { + fmt.Printf("%v => %s %v\n", m.Params, n, m.Value) + } + } + } + + return nil + }, +} diff --git a/pkg/cmd/orderbook.go b/pkg/cmd/orderbook.go new file mode 100644 index 0000000..83774c9 --- /dev/null +++ b/pkg/cmd/orderbook.go @@ -0,0 +1,172 @@ +package cmd + +import ( + "context" + "fmt" + "syscall" + "time" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/cmd/cmdutil" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// go run ./cmd/qbtrade orderbook --session=binance --symbol=BTCUSDT +var orderbookCmd = &cobra.Command{ + Use: "orderbook --session=[exchange_name] --symbol=[pair_name]", + Short: "connect to the order book market data streaming service of an exchange", + PreRunE: cobraInitRequired([]string{ + "session", + "symbol", + }), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + sessionName, err := cmd.Flags().GetString("session") + if err != nil { + return err + } + + symbol, err := cmd.Flags().GetString("symbol") + if err != nil { + return fmt.Errorf("can not get the symbol from flags: %w", err) + } + + if symbol == "" { + return fmt.Errorf("--symbol option is required") + } + + dumpDepthUpdate, err := cmd.Flags().GetBool("dump-update") + if err != nil { + return err + } + + environ := qbtrade.NewEnvironment() + if err := environ.ConfigureExchangeSessions(userConfig); err != nil { + return err + } + + session, ok := environ.Session(sessionName) + if !ok { + return fmt.Errorf("session %s not found", sessionName) + } + + orderBook := types.NewMutexOrderBook(symbol) + + s := session.Exchange.NewStream() + s.SetPublicOnly() + s.Subscribe(types.BookChannel, symbol, types.SubscribeOptions{}) + s.OnBookSnapshot(func(book types.SliceOrderBook) { + if dumpDepthUpdate { + log.Infof("orderbook snapshot: %s", book.String()) + } + + orderBook.Load(book) + + if ok, err := orderBook.IsValid(); !ok { + log.WithError(err).Panicf("invalid error book snapshot") + } + + if bid, ask, ok := orderBook.BestBidAndAsk(); ok { + log.Infof("ASK | %f x %f / %f x %f | BID | %s", + ask.Volume.Float64(), ask.Price.Float64(), + bid.Price.Float64(), bid.Volume.Float64(), + book.Time.String()) + } + }) + + s.OnBookUpdate(func(book types.SliceOrderBook) { + if dumpDepthUpdate { + log.Infof("orderbook update: %s", book.String()) + } + orderBook.Update(book) + + if bid, ask, ok := orderBook.BestBidAndAsk(); ok { + log.Infof("ASK | %f x %f / %f x %f | BID | %s", + ask.Volume.Float64(), ask.Price.Float64(), + bid.Price.Float64(), bid.Volume.Float64(), + book.Time.String()) + } + }) + + log.Infof("connecting...") + if err := s.Connect(ctx); err != nil { + return fmt.Errorf("failed to connect to %s", sessionName) + } + + log.Infof("connected") + defer func() { + log.Infof("closing connection...") + if err := s.Close(); err != nil { + log.WithError(err).Errorf("connection close error") + } + time.Sleep(1 * time.Second) + }() + + cmdutil.WaitForSignal(ctx, syscall.SIGINT, syscall.SIGTERM) + return nil + }, +} + +// go run ./cmd/qbtrade orderupdate --session=ftx +var orderUpdateCmd = &cobra.Command{ + Use: "orderupdate", + Short: "Listen to order update events", + PreRunE: cobraInitRequired([]string{ + "config", + "session", + }), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + environ := qbtrade.NewEnvironment() + if err := environ.ConfigureExchangeSessions(userConfig); err != nil { + return err + } + + sessionName, err := cmd.Flags().GetString("session") + if err != nil { + return err + } + + session, ok := environ.Session(sessionName) + if !ok { + return fmt.Errorf("session %s not found", sessionName) + } + + s := session.Exchange.NewStream() + s.OnOrderUpdate(func(order types.Order) { + log.Infof("order update: %+v", order) + }) + + log.Infof("connecting...") + if err := s.Connect(ctx); err != nil { + return fmt.Errorf("failed to connect to %s", sessionName) + } + + log.Infof("connected") + defer func() { + log.Infof("closing connection...") + if err := s.Close(); err != nil { + log.WithError(err).Errorf("connection close error") + } + time.Sleep(1 * time.Second) + }() + + cmdutil.WaitForSignal(ctx, syscall.SIGINT, syscall.SIGTERM) + return nil + }, +} + +func init() { + orderbookCmd.Flags().String("session", "", "session name") + orderbookCmd.Flags().String("symbol", "", "the trading pair. e.g, BTCUSDT, LTCUSDT...") + orderbookCmd.Flags().Bool("dump-update", false, "dump the depth update") + + orderUpdateCmd.Flags().String("session", "", "session name") + RootCmd.AddCommand(orderbookCmd) + RootCmd.AddCommand(orderUpdateCmd) +} diff --git a/pkg/cmd/orders.go b/pkg/cmd/orders.go new file mode 100644 index 0000000..e9d7665 --- /dev/null +++ b/pkg/cmd/orders.go @@ -0,0 +1,430 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +var getOrderCmd = &cobra.Command{ + Use: "get-order --session SESSION --order-id ORDER_ID", + Short: "Get order status", + SilenceUsage: true, + PreRunE: cobraInitRequired([]string{ + "order-id", + "symbol", + }), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + environ := qbtrade.NewEnvironment() + if err := environ.ConfigureExchangeSessions(userConfig); err != nil { + return err + } + + sessionName, err := cmd.Flags().GetString("session") + if err != nil { + return err + } + + session, ok := environ.Session(sessionName) + if !ok { + return fmt.Errorf("session %s not found", sessionName) + } + + orderID, err := cmd.Flags().GetString("order-id") + if err != nil { + return fmt.Errorf("can't get the order-id from flags: %w", err) + } + + symbol, err := cmd.Flags().GetString("symbol") + if err != nil { + return fmt.Errorf("can't get the symbol from flags: %w", err) + } + + service, ok := session.Exchange.(types.ExchangeOrderQueryService) + if !ok { + return fmt.Errorf("query order status is not supported for exchange %T, interface types.ExchangeOrderQueryService is not implemented", session.Exchange) + } + + order, err := service.QueryOrder(ctx, types.OrderQuery{ + OrderID: orderID, + Symbol: symbol, + }) + if err != nil { + return err + } + + log.Infof("%+v", order) + + return nil + }, +} + +// go run ./cmd/qbtrade list-orders [open|closed] --session=ftx --symbol=BTCUSDT +var listOrdersCmd = &cobra.Command{ + Use: "list-orders open|closed --session SESSION --symbol SYMBOL", + Short: "list user's open orders in exchange of a specific trading pair", + Args: cobra.OnlyValidArgs, + // default is open which means we query open orders if you haven't provided args. + ValidArgs: []string{"", "open", "closed"}, + SilenceUsage: true, + PreRunE: cobraInitRequired([]string{ + "session", + "symbol", + }), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + environ := qbtrade.NewEnvironment() + if err := environ.ConfigureExchangeSessions(userConfig); err != nil { + return err + } + + sessionName, err := cmd.Flags().GetString("session") + if err != nil { + return err + } + + session, ok := environ.Session(sessionName) + if !ok { + return fmt.Errorf("session %s not found", sessionName) + } + symbol, err := cmd.Flags().GetString("symbol") + if err != nil { + return fmt.Errorf("can't get the symbol from flags: %w", err) + } + + status := "open" + if len(args) != 0 { + status = args[0] + } + + var os []types.Order + switch status { + case "open": + os, err = session.Exchange.QueryOpenOrders(ctx, symbol) + if err != nil { + return err + } + case "closed": + tradeHistoryService, ok := session.Exchange.(types.ExchangeTradeHistoryService) + if !ok { + // skip exchanges that does not support trading history services + log.Warnf("exchange %s does not implement ExchangeTradeHistoryService, skip syncing closed orders (listOrdersCmd)", session.Exchange.Name()) + return nil + } + + os, err = tradeHistoryService.QueryClosedOrders(ctx, symbol, time.Now().Add(-3*24*time.Hour), time.Now(), 0) + if err != nil { + return err + } + default: + return fmt.Errorf("invalid status %s", status) + } + + log.Infof("%s ORDERS FROM %s SESSION", strings.ToUpper(status), session.Name) + for _, o := range os { + log.Infof("%+v", o) + } + + return nil + }, +} + +var executeOrderCmd = &cobra.Command{ + Use: "execute-order --session SESSION --symbol SYMBOL --side SIDE --target-quantity TOTAL_QUANTITY --slice-quantity SLICE_QUANTITY", + Short: "execute buy/sell on the balance/position you have on specific symbol", + SilenceUsage: true, + PreRunE: cobraInitRequired([]string{ + "symbol", + "side", + "target-quantity", + "slice-quantity", + }), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + sessionName, err := cmd.Flags().GetString("session") + if err != nil { + return err + } + + symbol, err := cmd.Flags().GetString("symbol") + if err != nil { + return fmt.Errorf("can not get the symbol from flags: %w", err) + } + + if symbol == "" { + return fmt.Errorf("symbol not found") + } + + sideS, err := cmd.Flags().GetString("side") + if err != nil { + return fmt.Errorf("can't get side: %w", err) + } + + side, err := types.StrToSideType(sideS) + if err != nil { + return err + } + + targetQuantityS, err := cmd.Flags().GetString("target-quantity") + if err != nil { + return err + } + if len(targetQuantityS) == 0 { + return errors.New("--target-quantity can not be empty") + } + + targetQuantity, err := fixedpoint.NewFromString(targetQuantityS) + if err != nil { + return err + } + + sliceQuantityS, err := cmd.Flags().GetString("slice-quantity") + if err != nil { + return err + } + if len(sliceQuantityS) == 0 { + return errors.New("--slice-quantity can not be empty") + } + + sliceQuantity, err := fixedpoint.NewFromString(sliceQuantityS) + if err != nil { + return err + } + + numOfPriceTicks, err := cmd.Flags().GetInt("price-ticks") + if err != nil { + return err + } + + stopPriceS, err := cmd.Flags().GetString("stop-price") + if err != nil { + return err + } + + stopPrice, err := fixedpoint.NewFromString(stopPriceS) + if err != nil { + return err + } + + updateInterval, err := cmd.Flags().GetDuration("update-interval") + if err != nil { + return err + } + + deadlineDuration, err := cmd.Flags().GetDuration("deadline") + if err != nil { + return err + } + + var deadlineTime time.Time + if deadlineDuration > 0 { + deadlineTime = time.Now().Add(deadlineDuration) + } + + environ := qbtrade.NewEnvironment() + if err := environ.ConfigureExchangeSessions(userConfig); err != nil { + return err + } + + if err := environ.Init(ctx); err != nil { + return err + } + + session, ok := environ.Session(sessionName) + if !ok { + return fmt.Errorf("session %s not found", sessionName) + } + + executionCtx, cancelExecution := context.WithCancel(ctx) + defer cancelExecution() + + execution := &qbtrade.TwapExecution{ + Session: session, + Symbol: symbol, + Side: side, + TargetQuantity: targetQuantity, + SliceQuantity: sliceQuantity, + StopPrice: stopPrice, + NumOfTicks: numOfPriceTicks, + UpdateInterval: updateInterval, + DeadlineTime: deadlineTime, + } + + if err := execution.Run(executionCtx); err != nil { + return err + } + + var sigC = make(chan os.Signal, 1) + signal.Notify(sigC, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(sigC) + + select { + case sig := <-sigC: + log.Warnf("signal %v", sig) + log.Infof("shutting down order executor...") + shutdownCtx, cancelShutdown := context.WithDeadline(ctx, time.Now().Add(10*time.Second)) + execution.Shutdown(shutdownCtx) + cancelShutdown() + + case <-execution.Done(): + log.Infof("the order execution is completed") + + case <-ctx.Done(): + + } + + return nil + }, +} + +// go run ./cmd/qbtrade submit-order --session=ftx --symbol=BTCUSDT --side=buy --price=18000 --quantity=0.001 +var submitOrderCmd = &cobra.Command{ + Use: "submit-order --session SESSION --symbol SYMBOL --side SIDE --quantity QUANTITY [--price PRICE]", + Short: "place order to the exchange", + SilenceUsage: true, + PreRunE: cobraInitRequired([]string{ + "session", + "symbol", + "side", + "quantity", + }), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + sessionName, err := cmd.Flags().GetString("session") + if err != nil { + return err + } + + symbol, err := cmd.Flags().GetString("symbol") + if err != nil { + return fmt.Errorf("can't get the symbol from flags: %w", err) + } + if symbol == "" { + return fmt.Errorf("symbol is not found") + } + + side, err := cmd.Flags().GetString("side") + if err != nil { + return fmt.Errorf("can not get side: %w", err) + } + + price, err := cmd.Flags().GetString("price") + if err != nil { + return fmt.Errorf("can not get price: %w", err) + } + + asMarketOrder, err := cmd.Flags().GetBool("market") + if err != nil { + return err + } + + quantity, err := cmd.Flags().GetString("quantity") + if err != nil { + return fmt.Errorf("can not get quantity: %w", err) + } + + marginOrderSideEffect, err := cmd.Flags().GetString("margin-side-effect") + if err != nil { + return fmt.Errorf("can not get quantity: %w", err) + } + + environ := qbtrade.NewEnvironment() + if err := environ.ConfigureExchangeSessions(userConfig); err != nil { + return err + } + + if err := environ.Init(ctx); err != nil { + return err + } + + session, ok := environ.Session(sessionName) + if !ok { + return fmt.Errorf("session %s not found", sessionName) + } + + market, ok := session.Market(symbol) + if !ok { + return fmt.Errorf("market definition %s not found", symbol) + } + + so := types.SubmitOrder{ + Symbol: symbol, + Side: types.SideType(strings.ToUpper(side)), + Type: types.OrderTypeLimit, + Quantity: fixedpoint.MustNewFromString(quantity), + Market: market, + MarginSideEffect: types.MarginOrderSideEffectType(marginOrderSideEffect), + } + + if asMarketOrder { + so.Type = types.OrderTypeMarket + so.Price = fixedpoint.Zero + } else { + if len(price) == 0 { + return fmt.Errorf("price is required for limit order submission") + } + + so.Type = types.OrderTypeLimit + so.Price = fixedpoint.MustNewFromString(price) + so.TimeInForce = types.TimeInForceGTC + } + + co, err := session.Exchange.SubmitOrder(ctx, so) + if err != nil { + return err + } + + log.Infof("submitted order: %+v\ncreated order: %+v", so, co) + return nil + }, +} + +func init() { + listOrdersCmd.Flags().String("session", "", "the exchange session name for sync") + listOrdersCmd.Flags().String("symbol", "", "the trading pair, like btcusdt") + + getOrderCmd.Flags().String("session", "", "the exchange session name for sync") + getOrderCmd.Flags().String("symbol", "", "the trading pair, like btcusdt") + getOrderCmd.Flags().String("order-id", "", "order id") + + submitOrderCmd.Flags().String("session", "", "the exchange session name for sync") + submitOrderCmd.Flags().String("symbol", "", "the trading pair, like btcusdt") + submitOrderCmd.Flags().String("side", "", "the trading side: buy or sell") + submitOrderCmd.Flags().String("price", "", "the trading price") + submitOrderCmd.Flags().String("quantity", "", "the trading quantity") + submitOrderCmd.Flags().Bool("market", false, "submit order as a market order") + submitOrderCmd.Flags().String("margin-side-effect", "", "margin order side effect") + + executeOrderCmd.Flags().String("session", "", "the exchange session name for sync") + executeOrderCmd.Flags().String("symbol", "", "the trading pair, like btcusdt") + executeOrderCmd.Flags().String("side", "", "the trading side: buy or sell") + executeOrderCmd.Flags().String("target-quantity", "", "target quantity") + executeOrderCmd.Flags().String("slice-quantity", "", "slice quantity") + executeOrderCmd.Flags().String("stop-price", "0", "stop price") + executeOrderCmd.Flags().Duration("update-interval", time.Second*10, "order update time") + executeOrderCmd.Flags().Duration("deadline", 0, "deadline of the order execution") + executeOrderCmd.Flags().Int("price-ticks", 0, "the number of price tick for the jump spread, default to 0") + + RootCmd.AddCommand(listOrdersCmd) + RootCmd.AddCommand(getOrderCmd) + RootCmd.AddCommand(submitOrderCmd) + RootCmd.AddCommand(executeOrderCmd) +} diff --git a/pkg/cmd/pnl.go b/pkg/cmd/pnl.go new file mode 100644 index 0000000..984d7a4 --- /dev/null +++ b/pkg/cmd/pnl.go @@ -0,0 +1,205 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "time" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/accounting/pnl" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/service" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func init() { + PnLCmd.Flags().StringArray("session", []string{}, "target exchange sessions") + PnLCmd.Flags().String("symbol", "", "trading symbol") + PnLCmd.Flags().Bool("include-transfer", false, "convert transfer records into trades") + PnLCmd.Flags().Bool("sync", false, "sync before loading trades") + PnLCmd.Flags().String("since", "", "query trades from a time point") + PnLCmd.Flags().Uint64("limit", 0, "number of trades") + RootCmd.AddCommand(PnLCmd) +} + +var PnLCmd = &cobra.Command{ + Use: "pnl", + Short: "Average Cost Based PnL Calculator", + Long: "This command calculates the average cost-based profit from your total trades", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + sessionNames, err := cmd.Flags().GetStringArray("session") + if err != nil { + return err + } + + if len(sessionNames) == 0 { + return errors.New("--session [SESSION] is required") + } + + wantSync, err := cmd.Flags().GetBool("sync") + if err != nil { + return err + } + + symbol, err := cmd.Flags().GetString("symbol") + if err != nil { + return err + } + + if len(symbol) == 0 { + return errors.New("--symbol [SYMBOL] is required") + } + + // this is the default since + since := time.Now().AddDate(-1, 0, 0) + + sinceOpt, err := cmd.Flags().GetString("since") + if err != nil { + return err + } + + if sinceOpt != "" { + lt, err := types.ParseLooseFormatTime(sinceOpt) + if err != nil { + return err + } + since = lt.Time() + } + + until := time.Now() + + includeTransfer, err := cmd.Flags().GetBool("include-transfer") + if err != nil { + return err + } + + limit, err := cmd.Flags().GetUint64("limit") + if err != nil { + return err + } + + environ := qbtrade.NewEnvironment() + + if err := environ.ConfigureDatabase(ctx, userConfig); err != nil { + return err + } + + if err := environ.ConfigureExchangeSessions(userConfig); err != nil { + return err + } + + for _, sessionName := range sessionNames { + session, ok := environ.Session(sessionName) + if !ok { + return fmt.Errorf("session %s not found", sessionName) + } + + if wantSync { + if err := environ.SyncSession(ctx, session, symbol); err != nil { + return err + } + } + + if includeTransfer { + exchange := session.Exchange + market, _ := session.Market(symbol) + transferService, ok := exchange.(types.ExchangeTransferService) + if !ok { + return fmt.Errorf("session exchange %s does not implement transfer service", sessionName) + } + + deposits, err := transferService.QueryDepositHistory(ctx, market.BaseCurrency, since, until) + if err != nil { + return err + } + _ = deposits + + withdrawals, err := transferService.QueryWithdrawHistory(ctx, market.BaseCurrency, since, until) + if err != nil { + return err + } + + sort.Slice(withdrawals, func(i, j int) bool { + a := withdrawals[i].ApplyTime.Time() + b := withdrawals[j].ApplyTime.Time() + return a.Before(b) + }) + + // we need the backtest klines for the daily prices + backtestService := &service.BacktestService{DB: environ.DatabaseService.DB} + if err := backtestService.Sync(ctx, exchange, symbol, types.Interval1d, since, until); err != nil { + return err + } + } + } + + if err = environ.Init(ctx); err != nil { + return err + } + + session, _ := environ.Session(sessionNames[0]) + exchange := session.Exchange + + var trades []types.Trade + tradingFeeCurrency := exchange.PlatformFeeCurrency() + if strings.HasPrefix(symbol, tradingFeeCurrency) { + log.Infof("loading all trading fee currency related trades: %s", symbol) + trades, err = environ.TradeService.QueryForTradingFeeCurrency(exchange.Name(), symbol, tradingFeeCurrency) + } else { + trades, err = environ.TradeService.Query(service.QueryTradesOptions{ + Symbol: symbol, + Limit: limit, + Sessions: sessionNames, + Since: &since, + }) + } + + if err != nil { + return err + } + + if len(trades) == 0 { + return errors.New("empty trades, you need to run sync command to sync the trades from the exchange first") + } + + trades = types.SortTradesAscending(trades) + + log.Infof("%d trades loaded", len(trades)) + + tickers, err := exchange.QueryTickers(ctx, symbol) + if err != nil { + return err + } + + currentTick, ok := tickers[symbol] + if !ok { + 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) + report.Print() + + log.Warnf("note that if you're using cross-exchange arbitrage, the PnL won't be accurate") + log.Warnf("withdrawal and deposits are not considered in the PnL") + return nil + }, +} diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go new file mode 100644 index 0000000..75933b7 --- /dev/null +++ b/pkg/cmd/root.go @@ -0,0 +1,262 @@ +package cmd + +import ( + "net/http" + "os" + "path" + "runtime/pprof" + "strings" + "time" + + "github.com/heroku/rollrus" + "github.com/joho/godotenv" + rotatelogs "github.com/lestrrat-go/file-rotatelogs" + "github.com/pkg/errors" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/rifflock/lfshook" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" + + _ "time/tzdata" + + _ "github.com/go-sql-driver/mysql" +) + +var cpuProfileFile *os.File + +var userConfig *qbtrade.Config + +var RootCmd = &cobra.Command{ + Use: "qbtrade", + Short: "qbtrade is a crypto trading bot", + + // SilenceUsage is an option to silence usage when an error occurs. + SilenceUsage: true, + + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if err := cobraLoadDotenv(cmd, args); err != nil { + return err + } + + if viper.GetBool("debug") { + log.Infof("debug mode is enabled") + log.SetLevel(log.DebugLevel) + } + + env := qbtrade.GetCurrentEnv() + + logFormatter, err := cmd.Flags().GetString("log-formatter") + if err != nil { + return err + } + + if len(logFormatter) == 0 { + formatter := qbtrade.NewLogFormatterWithEnv(env) + log.SetFormatter(formatter) + } else { + formatter := qbtrade.NewLogFormatter(qbtrade.LogFormatterType(logFormatter)) + log.SetFormatter(formatter) + } + + if token := viper.GetString("rollbar-token"); token != "" { + log.Infof("found rollbar token %q, setting up rollbar hook...", util.MaskKey(token)) + + log.AddHook(rollrus.NewHook( + token, + env, + )) + } + + if viper.GetBool("metrics") { + http.Handle("/metrics", promhttp.Handler()) + go func() { + port := viper.GetString("metrics-port") + log.Infof("starting metrics server at :%s", port) + err := http.ListenAndServe(":"+port, nil) + if err != nil { + log.WithError(err).Errorf("metrics server error") + } + }() + } + + cpuProfile, err := cmd.Flags().GetString("cpu-profile") + if err != nil { + return err + } + + if cpuProfile != "" { + log.Infof("starting cpu profiler, recording at %s", cpuProfile) + + cpuProfileFile, err = os.Create(cpuProfile) + if err != nil { + return errors.Wrap(err, "can not create file for CPU profile") + } + + if err := pprof.StartCPUProfile(cpuProfileFile); err != nil { + return errors.Wrap(err, "can not start CPU profile") + } + } + + return cobraLoadConfig(cmd, args) + }, + PersistentPostRunE: func(cmd *cobra.Command, args []string) error { + pprof.StopCPUProfile() + if cpuProfileFile != nil { + return cpuProfileFile.Close() // error handling omitted for example + } + + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} + +func cobraLoadDotenv(cmd *cobra.Command, args []string) error { + disableDotEnv, err := cmd.Flags().GetBool("no-dotenv") + if err != nil { + return err + } + + if !disableDotEnv { + dotenvFile, err := cmd.Flags().GetString("dotenv") + if err != nil { + return err + } + + if _, err := os.Stat(dotenvFile); err == nil { + if err := godotenv.Load(dotenvFile); err != nil { + return errors.Wrap(err, "error loading dotenv file") + } + } + } + return nil +} + +func cobraLoadConfig(cmd *cobra.Command, args []string) error { + configFile, err := cmd.Flags().GetString("config") + if err != nil { + return errors.Wrapf(err, "failed to get the config flag") + } + + // load config file nicely + if len(configFile) > 0 { + // if config file exists, use the config loaded from the config file. + // otherwise, use an empty config object + if _, err := os.Stat(configFile); err == nil { + // load successfully + userConfig, err = qbtrade.Load(configFile, false) + if err != nil { + return errors.Wrapf(err, "can not load config file: %s", configFile) + } + + } else if os.IsNotExist(err) { + // config file doesn't exist, we should use the empty config + userConfig = &qbtrade.Config{} + } else { + // other error + return errors.Wrapf(err, "config file load error: %s", configFile) + } + } + + return nil +} + +func init() { + RootCmd.PersistentFlags().Bool("debug", false, "debug mode") + RootCmd.PersistentFlags().Bool("metrics", false, "enable prometheus metrics") + RootCmd.PersistentFlags().String("metrics-port", "9090", "prometheus http server port") + + RootCmd.PersistentFlags().Bool("no-dotenv", false, "disable built-in dotenv") + RootCmd.PersistentFlags().String("dotenv", ".env.local", "the dotenv file you want to load") + + RootCmd.PersistentFlags().String("config", "qbtrade.yaml", "config file") + + RootCmd.PersistentFlags().String("log-formatter", "", "configure log formatter") + + RootCmd.PersistentFlags().String("rollbar-token", "", "rollbar token") + + // A flag can be 'persistent' meaning that this flag will be available to + // the command it's assigned to as well as every command under that command. + // For global flags, assign a flag as a persistent flag on the root. + RootCmd.PersistentFlags().String("slack-token", "", "slack token") + RootCmd.PersistentFlags().String("slack-channel", "dev-qbtrade", "slack trading channel") + RootCmd.PersistentFlags().String("slack-error-channel", "qbtrade-error", "slack error channel") + + RootCmd.PersistentFlags().String("telegram-bot-token", "", "telegram bot token from bot father") + RootCmd.PersistentFlags().String("telegram-bot-auth-token", "", "telegram auth token") + + RootCmd.PersistentFlags().String("binance-api-key", "", "binance api key") + RootCmd.PersistentFlags().String("binance-api-secret", "", "binance api secret") + + RootCmd.PersistentFlags().String("max-api-key", "", "max api key") + RootCmd.PersistentFlags().String("max-api-secret", "", "max api secret") + + RootCmd.PersistentFlags().String("cpu-profile", "", "cpu profile") + + viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + + // Enable environment variable binding, the env vars are not overloaded yet. + viper.AutomaticEnv() + + // setup the config paths for looking up the config file + /* + viper.AddConfigPath("config") + viper.AddConfigPath("$HOME/.qbtrade") + viper.AddConfigPath("/etc/qbtrade") + + // set the config file name and format for loading the config file. + viper.SetConfigName("qbtrade") + viper.SetConfigType("yaml") + + err := viper.ReadInConfig() + if err != nil { + log.WithError(err).Fatal("failed to load config file") + } + */ + // Once the flags are defined, we can bind config keys with flags. + if err := viper.BindPFlags(RootCmd.PersistentFlags()); err != nil { + log.WithError(err).Errorf("failed to bind persistent flags. please check the flag settings.") + return + } + + environment := os.Getenv("qbtrade_ENV") + logDir := "log" + switch environment { + case "production", "prod": + if err := os.MkdirAll(logDir, 0777); err != nil { + log.Panic(err) + } + writer, err := rotatelogs.New( + path.Join(logDir, "access_log.%Y%m%d"), + rotatelogs.WithLinkName("access_log"), + // rotatelogs.WithMaxAge(24 * time.Hour), + rotatelogs.WithRotationTime(time.Duration(24)*time.Hour), + ) + if err != nil { + log.Panic(err) + } + + log.AddHook( + lfshook.NewHook( + lfshook.WriterMap{ + log.DebugLevel: writer, + log.InfoLevel: writer, + log.WarnLevel: writer, + log.ErrorLevel: writer, + log.FatalLevel: writer, + }, &log.JSONFormatter{}), + ) + + } +} + +func Execute() { + if err := RootCmd.Execute(); err != nil { + log.WithError(err).Fatalf("cannot execute command") + } +} diff --git a/pkg/cmd/run.go b/pkg/cmd/run.go new file mode 100644 index 0000000..969e8b9 --- /dev/null +++ b/pkg/cmd/run.go @@ -0,0 +1,334 @@ +package cmd + +import ( + "context" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "syscall" + "time" + + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + flag "github.com/spf13/pflag" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/cmd/cmdutil" + "git.qtrade.icu/lychiyu/qbtrade/pkg/grpc" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/server" +) + +func init() { + RunCmd.Flags().Bool("no-compile", false, "do not compile wrapper binary") + RunCmd.Flags().Bool("no-sync", false, "do not sync on startup") + RunCmd.Flags().String("totp-key-url", "", "time-based one-time password key URL, if defined, it will be used for restoring the otp key") + RunCmd.Flags().String("totp-issuer", "", "") + RunCmd.Flags().String("totp-account-name", "", "") + RunCmd.Flags().Bool("enable-webserver", false, "enable webserver") + RunCmd.Flags().Bool("enable-web-server", false, "legacy option, this is renamed to --enable-webserver") + RunCmd.Flags().String("webserver-bind", ":8080", "webserver binding") + RunCmd.Flags().Bool("lightweight", false, "lightweight mode") + + RunCmd.Flags().Bool("enable-grpc", false, "enable grpc server") + RunCmd.Flags().String("grpc-bind", ":50051", "grpc server binding") + + RunCmd.Flags().Bool("setup", false, "use setup mode") + RootCmd.AddCommand(RunCmd) +} + +var RunCmd = &cobra.Command{ + Use: "run", + Short: "run strategies from config file", + + // SilenceUsage is an option to silence usage when an error occurs. + SilenceUsage: true, + RunE: run, +} + +func runSetup(baseCtx context.Context, userConfig *qbtrade.Config, enableApiServer bool) error { + ctx, cancelTrading := context.WithCancel(baseCtx) + defer cancelTrading() + + environ := qbtrade.NewEnvironment() + + trader := qbtrade.NewTrader(environ) + + if enableApiServer { + go func() { + s := &server.Server{ + Config: userConfig, + Environ: environ, + Trader: trader, + OpenInBrowser: true, + Setup: &server.Setup{ + Context: ctx, + Cancel: cancelTrading, + Token: "", + }, + } + + if err := s.Run(ctx); err != nil { + log.WithError(err).Errorf("server error") + } + }() + } + + cmdutil.WaitForSignal(ctx, syscall.SIGINT, syscall.SIGTERM) + cancelTrading() + + gracefulShutdownPeriod := 30 * time.Second + shtCtx, cancelShutdown := context.WithTimeout(qbtrade.NewTodoContextWithExistingIsolation(ctx), gracefulShutdownPeriod) + qbtrade.Shutdown(shtCtx) + cancelShutdown() + + return nil +} + +func runConfig(basectx context.Context, cmd *cobra.Command, userConfig *qbtrade.Config) error { + noSync, err := cmd.Flags().GetBool("no-sync") + if err != nil { + return err + } + + enableWebServer, err := cmd.Flags().GetBool("enable-webserver") + if err != nil { + return err + } + + webServerBind, err := cmd.Flags().GetString("webserver-bind") + if err != nil { + return err + } + + enableWebServerLegacy, err := cmd.Flags().GetBool("enable-web-server") + if err != nil { + return err + } + if enableWebServerLegacy { + log.Warn("command option --enable-web-server is renamed to --enable-webserver") + enableWebServer = true + } + + enableGrpc, err := cmd.Flags().GetBool("enable-grpc") + if err != nil { + return err + } + + grpcBind, err := cmd.Flags().GetString("grpc-bind") + if err != nil { + return err + } + + _ = grpcBind + _ = enableGrpc + + tradingCtx, cancelTrading := context.WithCancel(basectx) + defer cancelTrading() + + environ := qbtrade.NewEnvironment() + + lightweight, err := cmd.Flags().GetBool("lightweight") + if err != nil { + return err + } + + if lightweight { + if err := qbtrade.BootstrapEnvironmentLightweight(tradingCtx, environ, userConfig); err != nil { + return err + } + } else { + if err := qbtrade.BootstrapEnvironment(tradingCtx, environ, userConfig); err != nil { + return err + } + } + + if err := environ.Init(tradingCtx); err != nil { + return err + } + + if !noSync { + if err := environ.Sync(tradingCtx, userConfig); err != nil { + return err + } + + if userConfig.Sync != nil { + environ.BindSync(userConfig.Sync) + } + } + + trader := qbtrade.NewTrader(environ) + if err := trader.Configure(userConfig); err != nil { + return err + } + + if err := trader.Initialize(tradingCtx); err != nil { + return err + } + + if err := trader.LoadState(tradingCtx); err != nil { + return err + } + + if err := trader.Run(tradingCtx); err != nil { + return err + } + + if enableWebServer { + go func() { + s := &server.Server{ + Config: userConfig, + Environ: environ, + Trader: trader, + } + + if err := s.Run(tradingCtx, webServerBind); err != nil { + log.WithError(err).Errorf("http server bind error") + } + }() + } + + if enableGrpc { + go func() { + s := &grpc.Server{ + Config: userConfig, + Environ: environ, + Trader: trader, + } + if err := s.ListenAndServe(grpcBind); err != nil { + log.WithError(err).Errorf("grpc server bind error") + } + }() + } + + cmdutil.WaitForSignal(tradingCtx, syscall.SIGINT, syscall.SIGTERM) + cancelTrading() + + gracefulShutdownPeriod := 30 * time.Second + shtCtx, cancelShutdown := context.WithTimeout(qbtrade.NewTodoContextWithExistingIsolation(tradingCtx), gracefulShutdownPeriod) + qbtrade.Shutdown(shtCtx) + + if err := trader.SaveState(shtCtx); err != nil { + log.WithError(err).Errorf("can not save strategy persistence states") + } + + cancelShutdown() + + for _, session := range environ.Sessions() { + if err := session.MarketDataStream.Close(); err != nil { + log.WithError(err).Errorf("[%s] market data stream close error", session.Name) + } + if err := session.UserDataStream.Close(); err != nil { + log.WithError(err).Errorf("[%s] user data stream close error", session.Name) + } + } + + return nil +} + +func run(cmd *cobra.Command, args []string) error { + setup, err := cmd.Flags().GetBool("setup") + if err != nil { + return err + } + + noCompile, err := cmd.Flags().GetBool("no-compile") + if err != nil { + return err + } + + configFile, err := cmd.Flags().GetString("config") + if err != nil { + return err + } + + if !setup { + // if it's not setup, then the config file option is required. + if len(configFile) == 0 { + return errors.New("--config option is required") + } + + if _, err := os.Stat(configFile); err != nil { + return err + } + + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // for wrapper binary, we can just run the strategies + if qbtrade.IsWrapperBinary || (userConfig.Build != nil && len(userConfig.Build.Imports) == 0) || noCompile { + if qbtrade.IsWrapperBinary { + log.Infof("running wrapper binary...") + } + + if setup { + return runSetup(ctx, userConfig, true) + } + + // default setting is false, here load as true + userConfig, err = qbtrade.Load(configFile, true) + if err != nil { + return err + } + + return runConfig(ctx, cmd, userConfig) + } + + return runWrapperBinary(ctx, cmd, userConfig, args) +} + +func runWrapperBinary(ctx context.Context, cmd *cobra.Command, userConfig *qbtrade.Config, args []string) error { + var runArgs = []string{"run"} + cmd.Flags().Visit(func(flag *flag.Flag) { + runArgs = append(runArgs, "--"+flag.Name, flag.Value.String()) + }) + runArgs = append(runArgs, args...) + + runCmd, err := buildAndRun(ctx, userConfig, runArgs...) + if err != nil { + return err + } + + if sig := cmdutil.WaitForSignal(ctx, syscall.SIGTERM, syscall.SIGINT); sig != nil { + log.Infof("sending signal to the child process...") + if err := runCmd.Process.Signal(sig); err != nil { + return err + } + + if err := runCmd.Wait(); err != nil { + return err + } + } + + return nil +} + +// buildAndRun builds the package natively and run the binary with the given args +func buildAndRun(ctx context.Context, userConfig *qbtrade.Config, args ...string) (*exec.Cmd, error) { + packageDir, err := ioutil.TempDir("build", "qbtradew") + if err != nil { + return nil, err + } + + defer os.RemoveAll(packageDir) + + targetConfig := qbtrade.GetNativeBuildTargetConfig() + binary, err := qbtrade.Build(ctx, userConfig, targetConfig) + if err != nil { + return nil, err + } + + cwd, err := os.Getwd() + if err != nil { + return nil, err + } + + executePath := filepath.Join(cwd, binary) + runCmd := exec.Command(executePath, args...) + runCmd.Stdout = os.Stdout + runCmd.Stderr = os.Stderr + return runCmd, runCmd.Start() +} diff --git a/pkg/cmd/strategy/builtin.go b/pkg/cmd/strategy/builtin.go new file mode 100644 index 0000000..194790e --- /dev/null +++ b/pkg/cmd/strategy/builtin.go @@ -0,0 +1,56 @@ +package strategy + +// import built-in strategies +import ( + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/atrpin" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/audacitymaker" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/autoborrow" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/autobuy" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/bollgrid" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/bollmaker" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/convert" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/dca" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/deposit2transfer" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/drift" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/elliottwave" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/emacross" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/emastop" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/etf" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/ewoDgtrd" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/factorzoo" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/fixedmaker" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/flashcrash" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/fmaker" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/grid" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/grid2" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/harmonic" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/irr" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/kline" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/linregmaker" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/liquiditymaker" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/marketcap" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/pivotshort" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/pricealert" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/pricedrop" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/random" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/rebalance" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/rsicross" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/rsmaker" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/schedule" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/scmaker" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/skeleton" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/supertrend" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/support" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/swing" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/techsignal" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/trendtrader" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/wall" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/xalign" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/xbalance" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/xdepthmaker" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/xfixedmaker" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/xfunding" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/xgap" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/xmaker" + _ "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/xnav" +) diff --git a/pkg/cmd/sync.go b/pkg/cmd/sync.go new file mode 100644 index 0000000..c3b801c --- /dev/null +++ b/pkg/cmd/sync.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "context" + "time" + + "github.com/spf13/cobra" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" +) + +func init() { + SyncCmd.Flags().StringArray("session", []string{}, "the exchange session name for sync") + SyncCmd.Flags().String("symbol", "", "symbol of market for syncing") + SyncCmd.Flags().String("since", "", "sync from time") + RootCmd.AddCommand(SyncCmd) +} + +var SyncCmd = &cobra.Command{ + Use: "sync [--session=[exchange_name]] [--symbol=[pair_name]] [[--since=yyyy/mm/dd]]", + Short: "sync trades and orders history", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + since, err := cmd.Flags().GetString("since") + if err != nil { + return err + } + + environ := qbtrade.NewEnvironment() + if err := environ.ConfigureDatabase(ctx, userConfig); err != nil { + return err + } + + if err := environ.ConfigureExchangeSessions(userConfig); err != nil { + return err + } + + sessionNames, err := cmd.Flags().GetStringArray("session") + if err != nil { + return err + } + + symbol, err := cmd.Flags().GetString("symbol") + if err != nil { + return err + } + + var ( + // default sync start time + defaultSyncStartTime = time.Now().AddDate(-1, 0, 0) + ) + + var syncStartTime = defaultSyncStartTime + + if userConfig.Sync != nil && userConfig.Sync.Since != nil { + syncStartTime = userConfig.Sync.Since.Time() + } + + if len(since) > 0 { + syncStartTime, err = time.ParseInLocation("2006-01-02", since, time.Local) + if err != nil { + return err + } + } + + environ.SetSyncStartTime(syncStartTime) + + if len(symbol) > 0 { + if userConfig.Sync != nil && len(userConfig.Sync.Symbols) > 0 { + userConfig.Sync.Symbols = []qbtrade.SyncSymbol{ + {Symbol: symbol}, + } + } + } + + if len(sessionNames) > 0 { + if userConfig.Sync != nil && len(userConfig.Sync.Sessions) > 0 { + userConfig.Sync.Sessions = sessionNames + } + } + + return environ.Sync(ctx, userConfig) + }, +} diff --git a/pkg/cmd/trades.go b/pkg/cmd/trades.go new file mode 100644 index 0000000..eae56a8 --- /dev/null +++ b/pkg/cmd/trades.go @@ -0,0 +1,146 @@ +package cmd + +import ( + "context" + "fmt" + "syscall" + "time" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/cmd/cmdutil" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// go run ./cmd/qbtrade trades --session=binance --symbol="BTC/USD" +var tradesCmd = &cobra.Command{ + Use: "trades --session=[exchange_name] --symbol=[pair_name]", + Short: "Query trading history", + SilenceUsage: true, + PreRunE: cobraInitRequired([]string{ + "session", + "symbol", + }), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + environ := qbtrade.NewEnvironment() + + if err := environ.ConfigureExchangeSessions(userConfig); err != nil { + return err + } + + sessionName, err := cmd.Flags().GetString("session") + if err != nil { + return err + } + + session, ok := environ.Session(sessionName) + if !ok { + return fmt.Errorf("session %s not found", sessionName) + } + + symbol, err := cmd.Flags().GetString("symbol") + if err != nil { + return fmt.Errorf("can't get the symbol from flags: %w", err) + } + if symbol == "" { + return fmt.Errorf("symbol is not found") + } + + limit, err := cmd.Flags().GetInt64("limit") + if err != nil { + return err + } + + now := time.Now() + since := now.Add(-24 * time.Hour) + + tradeHistoryService, ok := session.Exchange.(types.ExchangeTradeHistoryService) + if !ok { + // skip exchanges that does not support trading history services + log.Warnf("exchange %s does not implement ExchangeTradeHistoryService, skip syncing closed orders (tradesCmd)", session.Exchange.Name()) + return nil + } + + trades, err := tradeHistoryService.QueryTrades(ctx, symbol, &types.TradeQueryOptions{ + StartTime: &since, + Limit: limit, + LastTradeID: 0, + }) + if err != nil { + return err + } + + log.Infof("%d trades", len(trades)) + for _, trade := range trades { + log.Infof("TRADE %s %s %4s %s @ %s orderID %d %s amount %v , fee %v %s ", + trade.Exchange.String(), + trade.Symbol, + trade.Side, + trade.Quantity.FormatString(4), + trade.Price.FormatString(3), + trade.OrderID, + trade.Time.Time().Format(time.StampMilli), + trade.QuoteQuantity, + trade.Fee, + trade.FeeCurrency) + } + return nil + }, +} + +// go run ./cmd/qbtrade tradeupdate --session=ftx +var tradeUpdateCmd = &cobra.Command{ + Use: "tradeupdate --session=[exchange_name]", + Short: "Listen to trade update events", + PreRunE: cobraInitRequired([]string{ + "session", + }), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + environ := qbtrade.NewEnvironment() + + if err := environ.ConfigureExchangeSessions(userConfig); err != nil { + return err + } + + sessionName, err := cmd.Flags().GetString("session") + if err != nil { + return err + } + + session, ok := environ.Session(sessionName) + if !ok { + return fmt.Errorf("session %s not found", sessionName) + } + + s := session.Exchange.NewStream() + s.OnTradeUpdate(func(trade types.Trade) { + log.Infof("trade update: %+v", trade) + }) + + log.Infof("connecting...") + if err := s.Connect(ctx); err != nil { + return fmt.Errorf("failed to connect to %s", sessionName) + } + log.Infof("connected") + + cmdutil.WaitForSignal(ctx, syscall.SIGINT, syscall.SIGTERM) + return nil + }, +} + +func init() { + tradesCmd.Flags().String("session", "", "the exchange session name for querying balances") + tradesCmd.Flags().String("symbol", "", "the trading pair, like btcusdt") + tradesCmd.Flags().Int64("limit", 100, "limit") + + tradeUpdateCmd.Flags().String("session", "", "the exchange session name for querying balances") + + RootCmd.AddCommand(tradesCmd) + RootCmd.AddCommand(tradeUpdateCmd) +} diff --git a/pkg/cmd/transfer.go b/pkg/cmd/transfer.go new file mode 100644 index 0000000..21ad61d --- /dev/null +++ b/pkg/cmd/transfer.go @@ -0,0 +1,209 @@ +package cmd + +import ( + "context" + "fmt" + "sort" + "time" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func init() { + TransferHistoryCmd.Flags().String("session", "", "target exchange session") + TransferHistoryCmd.Flags().String("asset", "", "trading symbol") + TransferHistoryCmd.Flags().String("since", "", "since time") + RootCmd.AddCommand(TransferHistoryCmd) +} + +type timeRecord struct { + Record interface{} + Time time.Time +} + +type timeSlice []timeRecord + +func (p timeSlice) Len() int { + return len(p) +} + +func (p timeSlice) Less(i, j int) bool { + return p[i].Time.Before(p[j].Time) +} + +func (p timeSlice) Swap(i, j int) { + p[i], p[j] = p[j], p[i] +} + +var TransferHistoryCmd = &cobra.Command{ + Use: "transfer-history", + Short: "show transfer history", + + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + configFile, err := cmd.Flags().GetString("config") + if err != nil { + return err + } + + userConfig, err := qbtrade.Load(configFile, false) + if err != nil { + return err + } + + environ := qbtrade.NewEnvironment() + if err := qbtrade.BootstrapEnvironment(ctx, environ, userConfig); err != nil { + return err + } + + sessionName, err := cmd.Flags().GetString("session") + if err != nil { + return err + } + + asset, err := cmd.Flags().GetString("asset") + if err != nil { + return err + } + + session, ok := environ.Session(sessionName) + if !ok { + return fmt.Errorf("session %s not found", sessionName) + } + + // default + var now = time.Now() + var since = now.AddDate(-1, 0, 0) + var until = now + + sinceStr, err := cmd.Flags().GetString("since") + if err != nil { + return err + } + + if len(sinceStr) > 0 { + loc, err := time.LoadLocation("Asia/Taipei") + if err != nil { + return err + } + + since, err = time.ParseInLocation("2006-01-02", sinceStr, loc) + if err != nil { + return err + } + } + + var records timeSlice + + exchange, ok := session.Exchange.(types.ExchangeTransferService) + if !ok { + return fmt.Errorf("exchange session %s does not implement transfer service", sessionName) + } + + deposits, err := exchange.QueryDepositHistory(ctx, asset, since, until) + if err != nil { + return err + } + for _, d := range deposits { + records = append(records, timeRecord{ + Record: d, + Time: d.EffectiveTime(), + }) + } + + withdraws, err := exchange.QueryWithdrawHistory(ctx, asset, since, until) + if err != nil { + return err + } + for _, w := range withdraws { + records = append(records, timeRecord{ + Record: w, + Time: w.EffectiveTime(), + }) + } + + sort.Sort(records) + + for _, record := range records { + switch record := record.Record.(type) { + + case types.Deposit: + logrus.Infof("%s: <--- DEPOSIT %v %s [%s]", record.Time, record.Amount, record.Asset, record.Status) + + case types.Withdraw: + logrus.Infof("%s: ---> WITHDRAW %v %s [%s]", record.ApplyTime, record.Amount, record.Asset, record.Status) + + default: + logrus.Infof("unknown record: %+v", record) + + } + } + + stats := calBaselineStats(asset, deposits, withdraws) + for asset, quantity := range stats.TotalDeposit { + logrus.Infof("total %s deposit: %v", asset, quantity) + } + + for asset, quantity := range stats.TotalWithdraw { + logrus.Infof("total %s withdraw: %v", asset, quantity) + } + + for asset, quantity := range stats.BaselineBalance { + logrus.Infof("baseline %s balance: %v", asset, quantity) + } + + return nil + }, +} + +type BaselineStats struct { + Asset string + TotalDeposit map[string]fixedpoint.Value + TotalWithdraw map[string]fixedpoint.Value + BaselineBalance map[string]fixedpoint.Value +} + +func calBaselineStats(asset string, deposits []types.Deposit, withdraws []types.Withdraw) (stats BaselineStats) { + stats.Asset = asset + stats.TotalDeposit = make(map[string]fixedpoint.Value) + stats.TotalWithdraw = make(map[string]fixedpoint.Value) + stats.BaselineBalance = make(map[string]fixedpoint.Value) + + for _, deposit := range deposits { + if deposit.Status == types.DepositSuccess { + if _, ok := stats.TotalDeposit[deposit.Asset]; !ok { + stats.TotalDeposit[deposit.Asset] = fixedpoint.Zero + } + + stats.TotalDeposit[deposit.Asset] = stats.TotalDeposit[deposit.Asset].Add(deposit.Amount) + } + } + + for _, withdraw := range withdraws { + if withdraw.Status == "completed" { + if _, ok := stats.TotalWithdraw[withdraw.Asset]; !ok { + stats.TotalWithdraw[withdraw.Asset] = fixedpoint.Zero + } + + stats.TotalWithdraw[withdraw.Asset] = stats.TotalWithdraw[withdraw.Asset].Add(withdraw.Amount) + } + } + + for asset, deposit := range stats.TotalDeposit { + withdraw, ok := stats.TotalWithdraw[asset] + if !ok { + withdraw = fixedpoint.Zero + } + + stats.BaselineBalance[asset] = deposit.Sub(withdraw) + } + + return stats +} diff --git a/pkg/cmd/userdatastream.go b/pkg/cmd/userdatastream.go new file mode 100644 index 0000000..17574eb --- /dev/null +++ b/pkg/cmd/userdatastream.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "context" + "fmt" + "syscall" + "time" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/cmd/cmdutil" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// go run ./cmd/qbtrade userdatastream --session=binance +var userDataStreamCmd = &cobra.Command{ + Use: "userdatastream", + Short: "Listen to session events (orderUpdate, tradeUpdate, balanceUpdate, balanceSnapshot)", + PreRunE: cobraInitRequired([]string{ + "session", + }), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + sessionName, err := cmd.Flags().GetString("session") + if err != nil { + return err + } + + environ := qbtrade.NewEnvironment() + if err := environ.ConfigureExchangeSessions(userConfig); err != nil { + return err + } + + session, ok := environ.Session(sessionName) + if !ok { + return fmt.Errorf("session %s not found", sessionName) + } + + s := session.Exchange.NewStream() + s.OnOrderUpdate(func(order types.Order) { + log.Infof("[orderUpdate] %+v", order) + }) + s.OnTradeUpdate(func(trade types.Trade) { + log.Infof("[tradeUpdate] %+v", trade) + }) + s.OnBalanceUpdate(func(trade types.BalanceMap) { + log.Infof("[balanceUpdate] %+v", trade) + }) + s.OnBalanceSnapshot(func(trade types.BalanceMap) { + log.Infof("[balanceSnapshot] %+v", trade) + }) + + log.Infof("connecting...") + if err := s.Connect(ctx); err != nil { + return fmt.Errorf("failed to connect to %s", sessionName) + } + + log.Infof("connected") + defer func() { + log.Infof("closing connection...") + if err := s.Close(); err != nil { + log.WithError(err).Errorf("connection close error") + } + time.Sleep(1 * time.Second) + }() + + cmdutil.WaitForSignal(ctx, syscall.SIGINT, syscall.SIGTERM) + return nil + }, +} + +func init() { + userDataStreamCmd.Flags().String("session", "", "session name") + RootCmd.AddCommand(userDataStreamCmd) +} diff --git a/pkg/cmd/utils.go b/pkg/cmd/utils.go new file mode 100644 index 0000000..ab32ad7 --- /dev/null +++ b/pkg/cmd/utils.go @@ -0,0 +1,33 @@ +package cmd + +import ( + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func cobraInitRequired(required []string) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + for _, key := range required { + if err := cmd.MarkFlagRequired(key); err != nil { + log.WithError(err).Errorf("cannot mark --%s option required", key) + } + } + return nil + } +} + +// inQuoteAsset converts all balances in quote asset +func inQuoteAsset(balances types.BalanceMap, market types.Market, price fixedpoint.Value) fixedpoint.Value { + quote := balances[market.QuoteCurrency] + base := balances[market.BaseCurrency] + return base.Total().Mul(price).Add(quote.Total()) +} + +func inBaseAsset(balances types.BalanceMap, market types.Market, price fixedpoint.Value) fixedpoint.Value { + quote := balances[market.QuoteCurrency] + base := balances[market.BaseCurrency] + return quote.Total().Div(price).Add(base.Total()) +} diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go new file mode 100644 index 0000000..601550e --- /dev/null +++ b/pkg/cmd/version.go @@ -0,0 +1,23 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/version" +) + +func init() { + // VersionCmd.Flags().String("session", "", "the exchange session name for sync") + RootCmd.AddCommand(VersionCmd) +} + +var VersionCmd = &cobra.Command{ + Use: "version", + Short: "show version name", + SilenceUsage: true, + Run: func(cmd *cobra.Command, args []string) { + fmt.Println(version.Version) + }, +} diff --git a/pkg/core/orderstore.go b/pkg/core/orderstore.go new file mode 100644 index 0000000..642a8b7 --- /dev/null +++ b/pkg/core/orderstore.go @@ -0,0 +1,171 @@ +package core + +import ( + "sync" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type OrderStore struct { + // any created orders for tracking trades + mu sync.Mutex + orders map[uint64]types.Order + + Symbol string + + // RemoveCancelled removes the canceled order when receiving a cancel order update event + // It also removes the order even if it's partially filled + // by default, only 0 filled canceled order will be removed. + RemoveCancelled bool + + // RemoveFilled removes the fully filled order when receiving a filled order update event + RemoveFilled bool + + // AddOrderUpdate adds the order into the store when receiving an order update when the order does not exist in the current store. + AddOrderUpdate bool + C chan types.Order +} + +func NewOrderStore(symbol string) *OrderStore { + return &OrderStore{ + Symbol: symbol, + orders: make(map[uint64]types.Order), + C: make(chan types.Order), + } +} + +func (s *OrderStore) AllFilled() bool { + s.mu.Lock() + defer s.mu.Unlock() + + // If any order is new or partially filled, we return false + for _, o := range s.orders { + switch o.Status { + + case types.OrderStatusCanceled, types.OrderStatusRejected: + continue + + case types.OrderStatusNew, types.OrderStatusPartiallyFilled: + return false + + case types.OrderStatusFilled: + // do nothing for the filled order + + } + } + + // If we pass through the for loop, then all the orders filled + return true +} + +func (s *OrderStore) NumOfOrders() (num int) { + s.mu.Lock() + num = len(s.orders) + s.mu.Unlock() + return num +} + +func (s *OrderStore) Orders() (orders []types.Order) { + s.mu.Lock() + defer s.mu.Unlock() + + for _, o := range s.orders { + orders = append(orders, o) + } + + return orders +} + +func (s *OrderStore) Exists(oID uint64) (ok bool) { + s.mu.Lock() + defer s.mu.Unlock() + + _, ok = s.orders[oID] + return ok +} + +// Get a single order from the order store by order ID +// Should check ok to make sure the order is returned successfully +func (s *OrderStore) Get(oID uint64) (order types.Order, ok bool) { + s.mu.Lock() + defer s.mu.Unlock() + + order, ok = s.orders[oID] + return order, ok +} + +func (s *OrderStore) Add(orders ...types.Order) { + s.mu.Lock() + defer s.mu.Unlock() + + for _, o := range orders { + old, ok := s.orders[o.OrderID] + if ok && o.Tag == "" && old.Tag != "" { + o.Tag = old.Tag + } + s.orders[o.OrderID] = o + } +} + +func (s *OrderStore) Remove(o types.Order) { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.orders, o.OrderID) +} + +func (s *OrderStore) Update(o types.Order) bool { + s.mu.Lock() + defer s.mu.Unlock() + + old, ok := s.orders[o.OrderID] + if ok { + o.Tag = old.Tag + s.orders[o.OrderID] = o + } + return ok +} + +func (s *OrderStore) BindStream(stream types.Stream) { + hasSymbol := s.Symbol != "" + stream.OnOrderUpdate(func(order types.Order) { + // if we have symbol defined, we should filter out the orders that we are not interested in + if hasSymbol && order.Symbol != s.Symbol { + return + } + + s.HandleOrderUpdate(order) + }) +} + +func (s *OrderStore) HandleOrderUpdate(order types.Order) { + + switch order.Status { + + case types.OrderStatusNew, types.OrderStatusPartiallyFilled, types.OrderStatusFilled: + if s.AddOrderUpdate { + s.Add(order) + } else { + s.Update(order) + } + + if s.RemoveFilled && order.Status == types.OrderStatusFilled { + s.Remove(order) + } + + case types.OrderStatusCanceled: + if s.RemoveCancelled { + s.Remove(order) + } else if order.ExecutedQuantity.IsZero() { + s.Remove(order) + } + + case types.OrderStatusRejected: + s.Remove(order) + } + + select { + case s.C <- order: + default: + } +} diff --git a/pkg/core/tradecollector.go b/pkg/core/tradecollector.go new file mode 100644 index 0000000..71541e1 --- /dev/null +++ b/pkg/core/tradecollector.go @@ -0,0 +1,255 @@ +package core + +import ( + "context" + "sync" + "time" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/sigchan" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +//go:generate callbackgen -type TradeCollector +type TradeCollector struct { + Symbol string + orderSig sigchan.Chan + + tradeStore *TradeStore + tradeC chan types.Trade + position *types.Position + orderStore *OrderStore + doneTrades map[types.TradeKey]struct{} + + mu sync.Mutex + + recoverCallbacks []func(trade types.Trade) + + tradeCallbacks []func(trade types.Trade, profit, netProfit fixedpoint.Value) + + positionUpdateCallbacks []func(position *types.Position) + profitCallbacks []func(trade types.Trade, profit *types.Profit) +} + +func NewTradeCollector(symbol string, position *types.Position, orderStore *OrderStore) *TradeCollector { + tradeStore := NewTradeStore() + tradeStore.EnablePrune = true + + return &TradeCollector{ + Symbol: symbol, + orderSig: sigchan.New(1), + + tradeC: make(chan types.Trade, 100), + tradeStore: tradeStore, + doneTrades: make(map[types.TradeKey]struct{}), + position: position, + orderStore: orderStore, + } +} + +// OrderStore returns the order store used by the trade collector +func (c *TradeCollector) OrderStore() *OrderStore { + return c.orderStore +} + +// Position returns the position used by the trade collector +func (c *TradeCollector) Position() *types.Position { + return c.position +} + +func (c *TradeCollector) TradeStore() *TradeStore { + return c.tradeStore +} + +func (c *TradeCollector) SetPosition(position *types.Position) { + c.position = position +} + +// QueueTrade sends the trade object to the trade channel, +// so that the goroutine can receive the trade and process in the background. +func (c *TradeCollector) QueueTrade(trade types.Trade) { + c.tradeC <- trade +} + +// BindStreamForBackground bind the stream callback for background processing +func (c *TradeCollector) BindStreamForBackground(stream types.Stream) { + stream.OnTradeUpdate(c.QueueTrade) +} + +func (c *TradeCollector) BindStream(stream types.Stream) { + stream.OnTradeUpdate(func(trade types.Trade) { + c.processTrade(trade) + }) +} + +// Emit triggers the trade processing (position update) +// If you sent order, and the order store is updated, you can call this method +// so that trades will be processed in the next round of the goroutine loop +func (c *TradeCollector) Emit() { + c.orderSig.Emit() +} + +func (c *TradeCollector) Recover( + ctx context.Context, ex types.ExchangeTradeHistoryService, symbol string, from time.Time, +) error { + logrus.Debugf("recovering %s trades...", symbol) + + trades, err := ex.QueryTrades(ctx, symbol, &types.TradeQueryOptions{ + StartTime: &from, + }) + + if err != nil { + return err + } + + cnt := 0 + for _, td := range trades { + if c.RecoverTrade(td) { + cnt++ + } + } + + logrus.Infof("%d %s trades were recovered", cnt, symbol) + return nil +} + +func (c *TradeCollector) RecoverTrade(td types.Trade) bool { + logrus.Debugf("checking trade: %s", td.String()) + if c.processTrade(td) { + logrus.Infof("recovered trade: %s", td.String()) + c.EmitRecover(td) + return true + } + + // add to the trade store, and then we can recover it when an order is matched + c.tradeStore.Add(td) + return false +} + +// Process filters the received trades and see if there are orders matching the trades +// if we have the order in the order store, then the trade will be considered for the position. +// profit will also be calculated. +func (c *TradeCollector) Process() bool { + positionChanged := false + + var trades []types.Trade + + // if it's already done, remove the trade from the trade store + c.mu.Lock() + c.tradeStore.Filter(func(trade types.Trade) bool { + key := trade.Key() + + // remove done trades + if _, done := c.doneTrades[key]; done { + return true + } + + // if it's the trade we're looking for, add it to the list and mark it as done + if c.orderStore.Exists(trade.OrderID) { + trades = append(trades, trade) + c.doneTrades[key] = struct{}{} + return true + } + + return false + }) + c.mu.Unlock() + + for _, trade := range trades { + var p types.Profit + if c.position != nil { + profit, netProfit, madeProfit := c.position.AddTrade(trade) + if madeProfit { + p = c.position.NewProfit(trade, profit, netProfit) + } + positionChanged = true + + c.EmitTrade(trade, profit, netProfit) + } else { + c.EmitTrade(trade, fixedpoint.Zero, fixedpoint.Zero) + } + + if !p.Profit.IsZero() { + c.EmitProfit(trade, &p) + } + } + + if positionChanged && c.position != nil { + c.EmitPositionUpdate(c.position) + } + + return positionChanged +} + +// processTrade takes a trade and see if there is a matched order +// if the order is found, then we add the trade to the position +// return true when the given trade is added +// return false when the given trade is not added +func (c *TradeCollector) processTrade(trade types.Trade) bool { + key := trade.Key() + + c.mu.Lock() + + // if it's already done, remove the trade from the trade store + if _, done := c.doneTrades[key]; done { + c.mu.Unlock() + return false + } + + if !c.orderStore.Exists(trade.OrderID) { + // not done yet + // add this trade to the trade store for the later processing + c.tradeStore.Add(trade) + c.mu.Unlock() + return false + } + + c.doneTrades[key] = struct{}{} + c.mu.Unlock() + + if c.position != nil { + profit, netProfit, madeProfit := c.position.AddTrade(trade) + if madeProfit { + p := c.position.NewProfit(trade, profit, netProfit) + c.EmitTrade(trade, profit, netProfit) + c.EmitProfit(trade, &p) + } else { + c.EmitTrade(trade, fixedpoint.Zero, fixedpoint.Zero) + c.EmitProfit(trade, nil) + } + c.EmitPositionUpdate(c.position) + } else { + c.EmitTrade(trade, fixedpoint.Zero, fixedpoint.Zero) + } + + return true +} + +// return true when the given trade is added +// return false when the given trade is not added +func (c *TradeCollector) ProcessTrade(trade types.Trade) bool { + return c.processTrade(trade) +} + +// Run is a goroutine executed in the background +// Do not use this function if you need back-testing +func (c *TradeCollector) Run(ctx context.Context) { + var ticker = time.NewTicker(3 * time.Second) + for { + select { + case <-ctx.Done(): + return + + case <-ticker.C: + c.Process() + + case <-c.orderSig: + c.Process() + + case trade := <-c.tradeC: + c.processTrade(trade) + } + } +} diff --git a/pkg/core/tradecollector_callbacks.go b/pkg/core/tradecollector_callbacks.go new file mode 100644 index 0000000..26ebf9e --- /dev/null +++ b/pkg/core/tradecollector_callbacks.go @@ -0,0 +1,48 @@ +// Code generated by "callbackgen -type TradeCollector"; DO NOT EDIT. + +package core + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func (c *TradeCollector) OnRecover(cb func(trade types.Trade)) { + c.recoverCallbacks = append(c.recoverCallbacks, cb) +} + +func (c *TradeCollector) EmitRecover(trade types.Trade) { + for _, cb := range c.recoverCallbacks { + cb(trade) + } +} + +func (c *TradeCollector) OnTrade(cb func(trade types.Trade, profit fixedpoint.Value, netProfit fixedpoint.Value)) { + c.tradeCallbacks = append(c.tradeCallbacks, cb) +} + +func (c *TradeCollector) EmitTrade(trade types.Trade, profit fixedpoint.Value, netProfit fixedpoint.Value) { + for _, cb := range c.tradeCallbacks { + cb(trade, profit, netProfit) + } +} + +func (c *TradeCollector) OnPositionUpdate(cb func(position *types.Position)) { + c.positionUpdateCallbacks = append(c.positionUpdateCallbacks, cb) +} + +func (c *TradeCollector) EmitPositionUpdate(position *types.Position) { + for _, cb := range c.positionUpdateCallbacks { + cb(position) + } +} + +func (c *TradeCollector) OnProfit(cb func(trade types.Trade, profit *types.Profit)) { + c.profitCallbacks = append(c.profitCallbacks, cb) +} + +func (c *TradeCollector) EmitProfit(trade types.Trade, profit *types.Profit) { + for _, cb := range c.profitCallbacks { + cb(trade, profit) + } +} diff --git a/pkg/core/tradecollector_test.go b/pkg/core/tradecollector_test.go new file mode 100644 index 0000000..8bef1ec --- /dev/null +++ b/pkg/core/tradecollector_test.go @@ -0,0 +1,65 @@ +package core + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func TestTradeCollector_ShouldNotCountDuplicatedTrade(t *testing.T) { + symbol := "BTCUSDT" + position := types.NewPosition(symbol, "BTC", "USDT") + orderStore := NewOrderStore(symbol) + collector := NewTradeCollector(symbol, position, orderStore) + assert.NotNil(t, collector) + + matched := collector.RecoverTrade(types.Trade{ + ID: 1, + OrderID: 399, + Exchange: types.ExchangeBinance, + Price: fixedpoint.NewFromInt(40000), + Quantity: fixedpoint.One, + QuoteQuantity: fixedpoint.NewFromInt(40000), + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + IsBuyer: true, + }) + assert.False(t, matched, "should be added to the trade store") + assert.Equal(t, 1, len(collector.tradeStore.Trades()), "should have 1 trade in the trade store") + + orderStore.Add(types.Order{ + SubmitOrder: types.SubmitOrder{ + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: fixedpoint.One, + Price: fixedpoint.NewFromInt(40000), + }, + Exchange: types.ExchangeBinance, + OrderID: 399, + Status: types.OrderStatusFilled, + ExecutedQuantity: fixedpoint.One, + IsWorking: false, + }) + + matched = collector.Process() + assert.True(t, matched) + assert.Equal(t, 0, len(collector.tradeStore.Trades()), "the found trade should be removed from the trade store") + + matched = collector.ProcessTrade(types.Trade{ + ID: 1, + OrderID: 399, + Exchange: types.ExchangeBinance, + Price: fixedpoint.NewFromInt(40000), + Quantity: fixedpoint.One, + QuoteQuantity: fixedpoint.NewFromInt(40000), + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + IsBuyer: true, + }) + assert.False(t, matched, "the same trade should not match") + assert.Equal(t, 0, len(collector.tradeStore.Trades()), "the same trade should not be added to the trade store") +} diff --git a/pkg/core/tradestore.go b/pkg/core/tradestore.go new file mode 100644 index 0000000..5b7dbe5 --- /dev/null +++ b/pkg/core/tradestore.go @@ -0,0 +1,162 @@ +package core + +import ( + "sync" + "time" + + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const TradeExpiryTime = 3 * time.Hour +const CoolTradePeriod = 1 * time.Hour +const MaximumTradeStoreSize = 1_000 + +type TradeStore struct { + // any created trades for tracking trades + sync.Mutex + + EnablePrune bool + + trades map[uint64]types.Trade + lastTradeTime time.Time +} + +func NewTradeStore() *TradeStore { + return &TradeStore{ + trades: make(map[uint64]types.Trade), + } +} + +func (s *TradeStore) Num() (num int) { + s.Lock() + num = len(s.trades) + s.Unlock() + return num +} + +func (s *TradeStore) Trades() (trades []types.Trade) { + s.Lock() + defer s.Unlock() + + for _, o := range s.trades { + trades = append(trades, o) + } + + return trades +} + +func (s *TradeStore) Exists(oID uint64) (ok bool) { + s.Lock() + defer s.Unlock() + + _, ok = s.trades[oID] + return ok +} + +func (s *TradeStore) Clear() { + s.Lock() + s.trades = make(map[uint64]types.Trade) + s.Unlock() +} + +type TradeFilter func(trade types.Trade) bool + +// Filter filters the trades by a given TradeFilter function +func (s *TradeStore) Filter(filter TradeFilter) { + s.Lock() + var trades = make(map[uint64]types.Trade) + for _, trade := range s.trades { + if !filter(trade) { + trades[trade.ID] = trade + } + } + s.trades = trades + s.Unlock() +} + +// GetOrderTrades finds the trades match order id matches to the given order +func (s *TradeStore) GetOrderTrades(o types.Order) (trades []types.Trade) { + s.Lock() + for _, t := range s.trades { + if t.OrderID == o.OrderID { + trades = append(trades, t) + } + } + s.Unlock() + return trades +} + +func (s *TradeStore) GetAndClear() (trades []types.Trade) { + s.Lock() + for _, t := range s.trades { + trades = append(trades, t) + } + s.trades = make(map[uint64]types.Trade) + s.Unlock() + + return trades +} + +func (s *TradeStore) Add(trades ...types.Trade) { + s.Lock() + defer s.Unlock() + + for _, trade := range trades { + s.trades[trade.ID] = trade + s.touchLastTradeTime(trade) + } +} + +func (s *TradeStore) touchLastTradeTime(trade types.Trade) { + if trade.Time.Time().After(s.lastTradeTime) { + s.lastTradeTime = trade.Time.Time() + } +} + +// Prune prunes trades that are older than the expiry time +// see TradeExpiryTime (3 hours) +func (s *TradeStore) Prune(curTime time.Time) { + s.Lock() + defer s.Unlock() + + var trades = make(map[uint64]types.Trade) + var cutOffTime = curTime.Add(-TradeExpiryTime) + + log.Infof("pruning expired trades, cutoff time = %s", cutOffTime.String()) + for _, trade := range s.trades { + if trade.Time.Before(cutOffTime) { + continue + } + + trades[trade.ID] = trade + } + + s.trades = trades + + log.Infof("trade pruning done, size: %d", len(trades)) +} + +func (s *TradeStore) isCoolTrade(trade types.Trade) bool { + // if the duration between the current trade and the last trade is over 1 hour, we call it "cool trade" + return !s.lastTradeTime.IsZero() && time.Time(trade.Time).Sub(s.lastTradeTime) > CoolTradePeriod +} + +func (s *TradeStore) exceededMaximumTradeStoreSize() bool { + return len(s.trades) > MaximumTradeStoreSize +} + +func (s *TradeStore) BindStream(stream types.Stream) { + stream.OnTradeUpdate(func(trade types.Trade) { + s.Add(trade) + }) + + if s.EnablePrune { + stream.OnTradeUpdate(func(trade types.Trade) { + if s.isCoolTrade(trade) || s.exceededMaximumTradeStoreSize() { + s.Prune(time.Time(trade.Time)) + } + }) + } +} diff --git a/pkg/core/tradestore_test.go b/pkg/core/tradestore_test.go new file mode 100644 index 0000000..f92661b --- /dev/null +++ b/pkg/core/tradestore_test.go @@ -0,0 +1,39 @@ +package core + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func TestTradeStore_isCoolTrade(t *testing.T) { + now := time.Now() + store := NewTradeStore() + store.lastTradeTime = now.Add(-2 * time.Hour) + ok := store.isCoolTrade(types.Trade{ + Time: types.Time(now), + }) + assert.True(t, ok) + + store.lastTradeTime = now.Add(-2 * time.Minute) + ok = store.isCoolTrade(types.Trade{ + Time: types.Time(now), + }) + assert.False(t, ok) +} + +func TestTradeStore_Prune(t *testing.T) { + now := time.Now() + store := NewTradeStore() + store.Add( + types.Trade{ID: 1, Time: types.Time(now.Add(-25 * time.Hour))}, + types.Trade{ID: 2, Time: types.Time(now.Add(-2 * time.Hour))}, + types.Trade{ID: 3, Time: types.Time(now.Add(-2 * time.Minute))}, + types.Trade{ID: 4, Time: types.Time(now.Add(-1 * time.Minute))}, + ) + store.Prune(now) + assert.Equal(t, 3, len(store.trades)) +} diff --git a/pkg/data/tsv/writer.go b/pkg/data/tsv/writer.go new file mode 100644 index 0000000..f91df9a --- /dev/null +++ b/pkg/data/tsv/writer.go @@ -0,0 +1,45 @@ +package tsv + +import ( + "encoding/csv" + "io" + "os" +) + +type Writer struct { + file io.WriteCloser + + *csv.Writer +} + +func NewWriterFile(filename string) (*Writer, error) { + f, err := os.Create(filename) + if err != nil { + return nil, err + } + + return NewWriter(f), nil +} + +func AppendWriterFile(filename string) (*Writer, error) { + f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + return nil, err + } + + return NewWriter(f), nil +} + +func NewWriter(file io.WriteCloser) *Writer { + tsv := csv.NewWriter(file) + tsv.Comma = '\t' + return &Writer{ + Writer: tsv, + file: file, + } +} + +func (w *Writer) Close() error { + w.Writer.Flush() + return w.file.Close() +} diff --git a/pkg/datasource/coinmarketcap/datasource.go b/pkg/datasource/coinmarketcap/datasource.go new file mode 100644 index 0000000..905d58d --- /dev/null +++ b/pkg/datasource/coinmarketcap/datasource.go @@ -0,0 +1,36 @@ +package coinmarketcap + +import ( + "context" + + v1 "git.qtrade.icu/lychiyu/qbtrade/pkg/datasource/coinmarketcap/v1" +) + +type DataSource struct { + client *v1.RestClient +} + +func New(apiKey string) *DataSource { + client := v1.New() + client.Auth(apiKey) + return &DataSource{client: client} +} + +func (d *DataSource) QueryMarketCapInUSD(ctx context.Context, limit int) (map[string]float64, error) { + req := v1.ListingsLatestRequest{ + Client: d.client, + Limit: &limit, + } + + resp, err := req.Do(ctx) + if err != nil { + return nil, err + } + + marketcaps := make(map[string]float64) + for _, data := range resp { + marketcaps[data.Symbol] = data.Quote["USD"].MarketCap + } + + return marketcaps, nil +} diff --git a/pkg/datasource/coinmarketcap/v1/client.go b/pkg/datasource/coinmarketcap/v1/client.go new file mode 100644 index 0000000..be4f3d1 --- /dev/null +++ b/pkg/datasource/coinmarketcap/v1/client.go @@ -0,0 +1,55 @@ +package v1 + +import ( + "context" + "net/http" + "net/url" + "time" + + "github.com/c9s/requestgen" +) + +const baseURL = "https://pro-api.coinmarketcap.com" +const defaultHTTPTimeout = time.Second * 15 + +type RestClient struct { + requestgen.BaseAPIClient + + apiKey string +} + +func New() *RestClient { + u, err := url.Parse(baseURL) + if err != nil { + panic(err) + } + + return &RestClient{ + BaseAPIClient: requestgen.BaseAPIClient{ + BaseURL: u, + HttpClient: &http.Client{ + Timeout: defaultHTTPTimeout, + }, + }, + } +} + +func (c *RestClient) Auth(apiKey string) { + // pragma: allowlist nextline secret + c.apiKey = apiKey +} + +func (c *RestClient) NewAuthenticatedRequest(ctx context.Context, method, refURL string, params url.Values, payload interface{}) (*http.Request, error) { + req, err := c.NewRequest(ctx, method, refURL, params, payload) + if err != nil { + return nil, err + } + + req.Header.Add("Accept", "application/json") + req.Header.Add("Content-Type", "application/json") + + // Attach API Key to header. https://coinmarketcap.com/api/documentation/v1/#section/Authentication + req.Header.Add("X-CMC_PRO_API_KEY", c.apiKey) + + return req, nil +} diff --git a/pkg/datasource/coinmarketcap/v1/listings.go b/pkg/datasource/coinmarketcap/v1/listings.go new file mode 100644 index 0000000..35c8532 --- /dev/null +++ b/pkg/datasource/coinmarketcap/v1/listings.go @@ -0,0 +1,56 @@ +package v1 + +import ( + "github.com/c9s/requestgen" +) + +//go:generate requestgen -method GET -url "/v1/cryptocurrency/listings/historical" -type ListingsHistoricalRequest -responseType Response -responseDataField Data -responseDataType []Data +type ListingsHistoricalRequest struct { + Client requestgen.AuthenticatedAPIClient + + Date string `param:"date,query,required"` + Start *int `param:"start,query" default:"1"` + Limit *int `param:"limit,query" default:"100"` + Convert *string `param:"convert,query"` + ConvertID *string `param:"convert_id,query"` + Sort *string `param:"sort,query" default:"cmc_rank" validValues:"cmc_rank,name,symbol,market_cap,price,circulating_supply,total_supply,max_supply,num_market_pairs,volume_24h,percent_change_1h,percent_change_24h,percent_change_7d"` + SortDir *string `param:"sort_dir,query" validValues:"asc,desc"` + CryptocurrencyType *string `param:"cryptocurrency_type,query" default:"all" validValues:"all,coins,tokens"` + Aux *string `param:"aux,query" default:"platform,tags,date_added,circulating_supply,total_supply,max_supply,cmc_rank,num_market_pairs"` +} + +//go:generate requestgen -method GET -url "/v1/cryptocurrency/listings/latest" -type ListingsLatestRequest -responseType Response -responseDataField Data -responseDataType []Data +type ListingsLatestRequest struct { + Client requestgen.AuthenticatedAPIClient + + Start *int `param:"start,query" default:"1"` + Limit *int `param:"limit,query" default:"100"` + PriceMin *float64 `param:"price_min,query"` + PriceMax *float64 `param:"price_max,query"` + MarketCapMin *float64 `param:"market_cap_min,query"` + MarketCapMax *float64 `param:"market_cap_max,query"` + Volume24HMin *float64 `param:"volume_24h_min,query"` + Volume24HMax *float64 `param:"volume_24h_max,query"` + CirculatingSupplyMin *float64 `param:"circulating_supply_min,query"` + CirculatingSupplyMax *float64 `param:"circulating_supply_max,query"` + PercentChange24HMin *float64 `param:"percent_change_24h_min,query"` + PercentChange24HMax *float64 `param:"percent_change_24h_max,query"` + Convert *string `param:"convert,query"` + ConvertID *string `param:"convert_id,query"` + Sort *string `param:"sort,query" default:"market_cap" validValues:"name,symbol,date_added,market_cap,market_cap_strict,price,circulating_supply,total_supply,max_supply,num_market_pairs,volume_24h,percent_change_1h,percent_change_24h,percent_change_7d,market_cap_by_total_supply_strict,volume_7d,volume_30d"` + SortDir *string `param:"sort_dir,query" validValues:"asc,desc"` + CryptocurrencyType *string `param:"cryptocurrency_type,query" default:"all" validValues:"all,coins,tokens"` + Tag *string `param:"tag,query" default:"all" validValues:"all,defi,filesharing"` + Aux *string `param:"aux,query" default:"num_market_pairs,cmc_rank,date_added,tags,platform,max_supply,circulating_supply,total_supply"` +} + +//go:generate requestgen -method GET -url "/v1/cryptocurrency/listings/new" -type ListingsNewRequest -responseType Response -responseDataField Data -responseDataType []Data +type ListingsNewRequest struct { + Client requestgen.AuthenticatedAPIClient + + Start *int `param:"start,query" default:"1"` + Limit *int `param:"limit,query" default:"100"` + Convert *string `param:"convert,query"` + ConvertID *string `param:"convert_id,query"` + SortDir *string `param:"sort_dir,query" validValues:"asc,desc"` +} diff --git a/pkg/datasource/coinmarketcap/v1/listings_historical_request_requestgen.go b/pkg/datasource/coinmarketcap/v1/listings_historical_request_requestgen.go new file mode 100644 index 0000000..c71651c --- /dev/null +++ b/pkg/datasource/coinmarketcap/v1/listings_historical_request_requestgen.go @@ -0,0 +1,315 @@ +// Code generated by "requestgen -method GET -url /v1/cryptocurrency/listings/historical -type ListingsHistoricalRequest -responseType Response -responseDataField Data -responseDataType []Data"; DO NOT EDIT. + +package v1 + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (l *ListingsHistoricalRequest) SetDate(Date string) *ListingsHistoricalRequest { + l.Date = Date + return l +} + +func (l *ListingsHistoricalRequest) SetStart(Start int) *ListingsHistoricalRequest { + l.Start = &Start + return l +} + +func (l *ListingsHistoricalRequest) SetLimit(Limit int) *ListingsHistoricalRequest { + l.Limit = &Limit + return l +} + +func (l *ListingsHistoricalRequest) SetConvert(Convert string) *ListingsHistoricalRequest { + l.Convert = &Convert + return l +} + +func (l *ListingsHistoricalRequest) SetConvertID(ConvertID string) *ListingsHistoricalRequest { + l.ConvertID = &ConvertID + return l +} + +func (l *ListingsHistoricalRequest) SetSort(Sort string) *ListingsHistoricalRequest { + l.Sort = &Sort + return l +} + +func (l *ListingsHistoricalRequest) SetSortDir(SortDir string) *ListingsHistoricalRequest { + l.SortDir = &SortDir + return l +} + +func (l *ListingsHistoricalRequest) SetCryptocurrencyType(CryptocurrencyType string) *ListingsHistoricalRequest { + l.CryptocurrencyType = &CryptocurrencyType + return l +} + +func (l *ListingsHistoricalRequest) SetAux(Aux string) *ListingsHistoricalRequest { + l.Aux = &Aux + return l +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (l *ListingsHistoricalRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check Date field -> json key date + Date := l.Date + + // TEMPLATE check-required + if len(Date) == 0 { + return nil, fmt.Errorf("date is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of Date + params["date"] = Date + // check Start field -> json key start + if l.Start != nil { + Start := *l.Start + + // assign parameter of Start + params["start"] = Start + } else { + Start := 1 + + // assign parameter of Start + params["start"] = Start + } + // check Limit field -> json key limit + if l.Limit != nil { + Limit := *l.Limit + + // assign parameter of Limit + params["limit"] = Limit + } else { + Limit := 100 + + // assign parameter of Limit + params["limit"] = Limit + } + // check Convert field -> json key convert + if l.Convert != nil { + Convert := *l.Convert + + // assign parameter of Convert + params["convert"] = Convert + } else { + } + // check ConvertID field -> json key convert_id + if l.ConvertID != nil { + ConvertID := *l.ConvertID + + // assign parameter of ConvertID + params["convert_id"] = ConvertID + } else { + } + // check Sort field -> json key sort + if l.Sort != nil { + Sort := *l.Sort + + // TEMPLATE check-valid-values + switch Sort { + case "cmc_rank", "name", "symbol", "market_cap", "price", "circulating_supply", "total_supply", "max_supply", "num_market_pairs", "volume_24h", "percent_change_1h", "percent_change_24h", "percent_change_7d": + params["sort"] = Sort + + default: + return nil, fmt.Errorf("sort value %v is invalid", Sort) + + } + // END TEMPLATE check-valid-values + + // assign parameter of Sort + params["sort"] = Sort + } else { + Sort := "cmc_rank" + + // assign parameter of Sort + params["sort"] = Sort + } + // check SortDir field -> json key sort_dir + if l.SortDir != nil { + SortDir := *l.SortDir + + // TEMPLATE check-valid-values + switch SortDir { + case "asc", "desc": + params["sort_dir"] = SortDir + + default: + return nil, fmt.Errorf("sort_dir value %v is invalid", SortDir) + + } + // END TEMPLATE check-valid-values + + // assign parameter of SortDir + params["sort_dir"] = SortDir + } else { + } + // check CryptocurrencyType field -> json key cryptocurrency_type + if l.CryptocurrencyType != nil { + CryptocurrencyType := *l.CryptocurrencyType + + // TEMPLATE check-valid-values + switch CryptocurrencyType { + case "all", "coins", "tokens": + params["cryptocurrency_type"] = CryptocurrencyType + + default: + return nil, fmt.Errorf("cryptocurrency_type value %v is invalid", CryptocurrencyType) + + } + // END TEMPLATE check-valid-values + + // assign parameter of CryptocurrencyType + params["cryptocurrency_type"] = CryptocurrencyType + } else { + CryptocurrencyType := "all" + + // assign parameter of CryptocurrencyType + params["cryptocurrency_type"] = CryptocurrencyType + } + // check Aux field -> json key aux + if l.Aux != nil { + Aux := *l.Aux + + // assign parameter of Aux + params["aux"] = Aux + } else { + Aux := "platform,tags,date_added,circulating_supply,total_supply,max_supply,cmc_rank,num_market_pairs" + + // assign parameter of Aux + params["aux"] = Aux + } + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (l *ListingsHistoricalRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (l *ListingsHistoricalRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := l.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if l.isVarSlice(_v) { + l.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (l *ListingsHistoricalRequest) GetParametersJSON() ([]byte, error) { + params, err := l.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (l *ListingsHistoricalRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (l *ListingsHistoricalRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (l *ListingsHistoricalRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (l *ListingsHistoricalRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (l *ListingsHistoricalRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := l.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (l *ListingsHistoricalRequest) Do(ctx context.Context) ([]Data, error) { + + // no body params + var params interface{} + query, err := l.GetQueryParameters() + if err != nil { + return nil, err + } + + apiURL := "/v1/cryptocurrency/listings/historical" + + req, err := l.Client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := l.Client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse Response + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data []Data + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/datasource/coinmarketcap/v1/listings_latest_request_requestgen.go b/pkg/datasource/coinmarketcap/v1/listings_latest_request_requestgen.go new file mode 100644 index 0000000..fe453c8 --- /dev/null +++ b/pkg/datasource/coinmarketcap/v1/listings_latest_request_requestgen.go @@ -0,0 +1,457 @@ +// Code generated by "requestgen -method GET -url /v1/cryptocurrency/listings/latest -type ListingsLatestRequest -responseType Response -responseDataField Data -responseDataType []Data"; DO NOT EDIT. + +package v1 + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (l *ListingsLatestRequest) SetStart(Start int) *ListingsLatestRequest { + l.Start = &Start + return l +} + +func (l *ListingsLatestRequest) SetLimit(Limit int) *ListingsLatestRequest { + l.Limit = &Limit + return l +} + +func (l *ListingsLatestRequest) SetPriceMin(PriceMin float64) *ListingsLatestRequest { + l.PriceMin = &PriceMin + return l +} + +func (l *ListingsLatestRequest) SetPriceMax(PriceMax float64) *ListingsLatestRequest { + l.PriceMax = &PriceMax + return l +} + +func (l *ListingsLatestRequest) SetMarketCapMin(MarketCapMin float64) *ListingsLatestRequest { + l.MarketCapMin = &MarketCapMin + return l +} + +func (l *ListingsLatestRequest) SetMarketCapMax(MarketCapMax float64) *ListingsLatestRequest { + l.MarketCapMax = &MarketCapMax + return l +} + +func (l *ListingsLatestRequest) SetVolume24HMin(Volume24HMin float64) *ListingsLatestRequest { + l.Volume24HMin = &Volume24HMin + return l +} + +func (l *ListingsLatestRequest) SetVolume24HMax(Volume24HMax float64) *ListingsLatestRequest { + l.Volume24HMax = &Volume24HMax + return l +} + +func (l *ListingsLatestRequest) SetCirculatingSupplyMin(CirculatingSupplyMin float64) *ListingsLatestRequest { + l.CirculatingSupplyMin = &CirculatingSupplyMin + return l +} + +func (l *ListingsLatestRequest) SetCirculatingSupplyMax(CirculatingSupplyMax float64) *ListingsLatestRequest { + l.CirculatingSupplyMax = &CirculatingSupplyMax + return l +} + +func (l *ListingsLatestRequest) SetPercentChange24HMin(PercentChange24HMin float64) *ListingsLatestRequest { + l.PercentChange24HMin = &PercentChange24HMin + return l +} + +func (l *ListingsLatestRequest) SetPercentChange24HMax(PercentChange24HMax float64) *ListingsLatestRequest { + l.PercentChange24HMax = &PercentChange24HMax + return l +} + +func (l *ListingsLatestRequest) SetConvert(Convert string) *ListingsLatestRequest { + l.Convert = &Convert + return l +} + +func (l *ListingsLatestRequest) SetConvertID(ConvertID string) *ListingsLatestRequest { + l.ConvertID = &ConvertID + return l +} + +func (l *ListingsLatestRequest) SetSort(Sort string) *ListingsLatestRequest { + l.Sort = &Sort + return l +} + +func (l *ListingsLatestRequest) SetSortDir(SortDir string) *ListingsLatestRequest { + l.SortDir = &SortDir + return l +} + +func (l *ListingsLatestRequest) SetCryptocurrencyType(CryptocurrencyType string) *ListingsLatestRequest { + l.CryptocurrencyType = &CryptocurrencyType + return l +} + +func (l *ListingsLatestRequest) SetTag(Tag string) *ListingsLatestRequest { + l.Tag = &Tag + return l +} + +func (l *ListingsLatestRequest) SetAux(Aux string) *ListingsLatestRequest { + l.Aux = &Aux + return l +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (l *ListingsLatestRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check Start field -> json key start + if l.Start != nil { + Start := *l.Start + + // assign parameter of Start + params["start"] = Start + } else { + Start := 1 + + // assign parameter of Start + params["start"] = Start + } + // check Limit field -> json key limit + if l.Limit != nil { + Limit := *l.Limit + + // assign parameter of Limit + params["limit"] = Limit + } else { + Limit := 100 + + // assign parameter of Limit + params["limit"] = Limit + } + // check PriceMin field -> json key price_min + if l.PriceMin != nil { + PriceMin := *l.PriceMin + + // assign parameter of PriceMin + params["price_min"] = PriceMin + } else { + } + // check PriceMax field -> json key price_max + if l.PriceMax != nil { + PriceMax := *l.PriceMax + + // assign parameter of PriceMax + params["price_max"] = PriceMax + } else { + } + // check MarketCapMin field -> json key market_cap_min + if l.MarketCapMin != nil { + MarketCapMin := *l.MarketCapMin + + // assign parameter of MarketCapMin + params["market_cap_min"] = MarketCapMin + } else { + } + // check MarketCapMax field -> json key market_cap_max + if l.MarketCapMax != nil { + MarketCapMax := *l.MarketCapMax + + // assign parameter of MarketCapMax + params["market_cap_max"] = MarketCapMax + } else { + } + // check Volume24HMin field -> json key volume_24h_min + if l.Volume24HMin != nil { + Volume24HMin := *l.Volume24HMin + + // assign parameter of Volume24HMin + params["volume_24h_min"] = Volume24HMin + } else { + } + // check Volume24HMax field -> json key volume_24h_max + if l.Volume24HMax != nil { + Volume24HMax := *l.Volume24HMax + + // assign parameter of Volume24HMax + params["volume_24h_max"] = Volume24HMax + } else { + } + // check CirculatingSupplyMin field -> json key circulating_supply_min + if l.CirculatingSupplyMin != nil { + CirculatingSupplyMin := *l.CirculatingSupplyMin + + // assign parameter of CirculatingSupplyMin + params["circulating_supply_min"] = CirculatingSupplyMin + } else { + } + // check CirculatingSupplyMax field -> json key circulating_supply_max + if l.CirculatingSupplyMax != nil { + CirculatingSupplyMax := *l.CirculatingSupplyMax + + // assign parameter of CirculatingSupplyMax + params["circulating_supply_max"] = CirculatingSupplyMax + } else { + } + // check PercentChange24HMin field -> json key percent_change_24h_min + if l.PercentChange24HMin != nil { + PercentChange24HMin := *l.PercentChange24HMin + + // assign parameter of PercentChange24HMin + params["percent_change_24h_min"] = PercentChange24HMin + } else { + } + // check PercentChange24HMax field -> json key percent_change_24h_max + if l.PercentChange24HMax != nil { + PercentChange24HMax := *l.PercentChange24HMax + + // assign parameter of PercentChange24HMax + params["percent_change_24h_max"] = PercentChange24HMax + } else { + } + // check Convert field -> json key convert + if l.Convert != nil { + Convert := *l.Convert + + // assign parameter of Convert + params["convert"] = Convert + } else { + } + // check ConvertID field -> json key convert_id + if l.ConvertID != nil { + ConvertID := *l.ConvertID + + // assign parameter of ConvertID + params["convert_id"] = ConvertID + } else { + } + // check Sort field -> json key sort + if l.Sort != nil { + Sort := *l.Sort + + // TEMPLATE check-valid-values + switch Sort { + case "name", "symbol", "date_added", "market_cap", "market_cap_strict", "price", "circulating_supply", "total_supply", "max_supply", "num_market_pairs", "volume_24h", "percent_change_1h", "percent_change_24h", "percent_change_7d", "market_cap_by_total_supply_strict", "volume_7d", "volume_30d": + params["sort"] = Sort + + default: + return nil, fmt.Errorf("sort value %v is invalid", Sort) + + } + // END TEMPLATE check-valid-values + + // assign parameter of Sort + params["sort"] = Sort + } else { + Sort := "market_cap" + + // assign parameter of Sort + params["sort"] = Sort + } + // check SortDir field -> json key sort_dir + if l.SortDir != nil { + SortDir := *l.SortDir + + // TEMPLATE check-valid-values + switch SortDir { + case "asc", "desc": + params["sort_dir"] = SortDir + + default: + return nil, fmt.Errorf("sort_dir value %v is invalid", SortDir) + + } + // END TEMPLATE check-valid-values + + // assign parameter of SortDir + params["sort_dir"] = SortDir + } else { + } + // check CryptocurrencyType field -> json key cryptocurrency_type + if l.CryptocurrencyType != nil { + CryptocurrencyType := *l.CryptocurrencyType + + // TEMPLATE check-valid-values + switch CryptocurrencyType { + case "all", "coins", "tokens": + params["cryptocurrency_type"] = CryptocurrencyType + + default: + return nil, fmt.Errorf("cryptocurrency_type value %v is invalid", CryptocurrencyType) + + } + // END TEMPLATE check-valid-values + + // assign parameter of CryptocurrencyType + params["cryptocurrency_type"] = CryptocurrencyType + } else { + CryptocurrencyType := "all" + + // assign parameter of CryptocurrencyType + params["cryptocurrency_type"] = CryptocurrencyType + } + // check Tag field -> json key tag + if l.Tag != nil { + Tag := *l.Tag + + // TEMPLATE check-valid-values + switch Tag { + case "all", "defi", "filesharing": + params["tag"] = Tag + + default: + return nil, fmt.Errorf("tag value %v is invalid", Tag) + + } + // END TEMPLATE check-valid-values + + // assign parameter of Tag + params["tag"] = Tag + } else { + Tag := "all" + + // assign parameter of Tag + params["tag"] = Tag + } + // check Aux field -> json key aux + if l.Aux != nil { + Aux := *l.Aux + + // assign parameter of Aux + params["aux"] = Aux + } else { + Aux := "num_market_pairs,cmc_rank,date_added,tags,platform,max_supply,circulating_supply,total_supply" + + // assign parameter of Aux + params["aux"] = Aux + } + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (l *ListingsLatestRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (l *ListingsLatestRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := l.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if l.isVarSlice(_v) { + l.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (l *ListingsLatestRequest) GetParametersJSON() ([]byte, error) { + params, err := l.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (l *ListingsLatestRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (l *ListingsLatestRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (l *ListingsLatestRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (l *ListingsLatestRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (l *ListingsLatestRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := l.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (l *ListingsLatestRequest) Do(ctx context.Context) ([]Data, error) { + + // no body params + var params interface{} + query, err := l.GetQueryParameters() + if err != nil { + return nil, err + } + + apiURL := "/v1/cryptocurrency/listings/latest" + + req, err := l.Client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := l.Client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse Response + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data []Data + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/datasource/coinmarketcap/v1/listings_new_request_requestgen.go b/pkg/datasource/coinmarketcap/v1/listings_new_request_requestgen.go new file mode 100644 index 0000000..a23e47d --- /dev/null +++ b/pkg/datasource/coinmarketcap/v1/listings_new_request_requestgen.go @@ -0,0 +1,226 @@ +// Code generated by "requestgen -method GET -url /v1/cryptocurrency/listings/new -type ListingsNewRequest -responseType Response -responseDataField Data -responseDataType []Data"; DO NOT EDIT. + +package v1 + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (l *ListingsNewRequest) SetStart(Start int) *ListingsNewRequest { + l.Start = &Start + return l +} + +func (l *ListingsNewRequest) SetLimit(Limit int) *ListingsNewRequest { + l.Limit = &Limit + return l +} + +func (l *ListingsNewRequest) SetConvert(Convert string) *ListingsNewRequest { + l.Convert = &Convert + return l +} + +func (l *ListingsNewRequest) SetConvertID(ConvertID string) *ListingsNewRequest { + l.ConvertID = &ConvertID + return l +} + +func (l *ListingsNewRequest) SetSortDir(SortDir string) *ListingsNewRequest { + l.SortDir = &SortDir + return l +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (l *ListingsNewRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check Start field -> json key start + if l.Start != nil { + Start := *l.Start + + // assign parameter of Start + params["start"] = Start + } else { + Start := 1 + + // assign parameter of Start + params["start"] = Start + } + // check Limit field -> json key limit + if l.Limit != nil { + Limit := *l.Limit + + // assign parameter of Limit + params["limit"] = Limit + } else { + Limit := 100 + + // assign parameter of Limit + params["limit"] = Limit + } + // check Convert field -> json key convert + if l.Convert != nil { + Convert := *l.Convert + + // assign parameter of Convert + params["convert"] = Convert + } else { + } + // check ConvertID field -> json key convert_id + if l.ConvertID != nil { + ConvertID := *l.ConvertID + + // assign parameter of ConvertID + params["convert_id"] = ConvertID + } else { + } + // check SortDir field -> json key sort_dir + if l.SortDir != nil { + SortDir := *l.SortDir + + // TEMPLATE check-valid-values + switch SortDir { + case "asc", "desc": + params["sort_dir"] = SortDir + + default: + return nil, fmt.Errorf("sort_dir value %v is invalid", SortDir) + + } + // END TEMPLATE check-valid-values + + // assign parameter of SortDir + params["sort_dir"] = SortDir + } else { + } + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (l *ListingsNewRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (l *ListingsNewRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := l.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if l.isVarSlice(_v) { + l.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (l *ListingsNewRequest) GetParametersJSON() ([]byte, error) { + params, err := l.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (l *ListingsNewRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (l *ListingsNewRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (l *ListingsNewRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (l *ListingsNewRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (l *ListingsNewRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := l.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (l *ListingsNewRequest) Do(ctx context.Context) ([]json.RawMessage, error) { + + // no body params + var params interface{} + query, err := l.GetQueryParameters() + if err != nil { + return nil, err + } + + apiURL := "/v1/cryptocurrency/listings/new" + + req, err := l.Client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := l.Client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse Response + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data []json.RawMessage + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/datasource/coinmarketcap/v1/types.go b/pkg/datasource/coinmarketcap/v1/types.go new file mode 100644 index 0000000..a505625 --- /dev/null +++ b/pkg/datasource/coinmarketcap/v1/types.go @@ -0,0 +1,61 @@ +package v1 + +import ( + "encoding/json" + "time" +) + +type Response struct { + Data json.RawMessage `json:"data"` + Status Status `json:"status"` +} + +type Data struct { + ID int64 `json:"id"` + Name string `json:"name"` + Symbol string `json:"symbol"` + Slug string `json:"slug"` + CmcRank int64 `json:"cmc_rank,omitempty"` + IsActive bool `json:"is_active,omitempty"` + IsFiat int64 `json:"is_fiat,omitempty"` + NumMarketPairs int64 `json:"num_market_pairs"` + CirculatingSupply float64 `json:"circulating_supply"` + TotalSupply float64 `json:"total_supply"` + MaxSupply float64 `json:"max_supply"` + LastUpdated time.Time `json:"last_updated"` + DateAdded time.Time `json:"date_added"` + Tags []string `json:"tags"` + SelfReportedCirculatingSupply float64 `json:"self_reported_circulating_supply,omitempty"` + SelfReportedMarketCap float64 `json:"self_reported_market_cap,omitempty"` + Platform Platform `json:"platform"` + Quote map[string]Quote `json:"quote"` +} + +type Quote struct { + Price float64 `json:"price"` + Volume24H float64 `json:"volume_24h"` + VolumeChange24H float64 `json:"volume_change_24h"` + PercentChange1H float64 `json:"percent_change_1h"` + PercentChange24H float64 `json:"percent_change_24h"` + PercentChange7D float64 `json:"percent_change_7d"` + MarketCap float64 `json:"market_cap"` + MarketCapDominance float64 `json:"market_cap_dominance"` + FullyDilutedMarketCap float64 `json:"fully_diluted_market_cap"` + LastUpdated time.Time `json:"last_updated"` +} + +type Status struct { + Timestamp time.Time `json:"timestamp"` + ErrorCode int `json:"error_code"` + ErrorMessage string `json:"error_message"` + Elapsed int `json:"elapsed"` + CreditCount int `json:"credit_count"` +} + +type Platform struct { + ID int `json:"id"` + Name string `json:"name"` + Symbol string `json:"symbol"` + Slug string `json:"slug"` + TokenAddress string `json:"token_address"` +} diff --git a/pkg/datasource/glassnode/datasource.go b/pkg/datasource/glassnode/datasource.go new file mode 100644 index 0000000..65bf494 --- /dev/null +++ b/pkg/datasource/glassnode/datasource.go @@ -0,0 +1,78 @@ +package glassnode + +import ( + "context" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datasource/glassnode/glassnodeapi" +) + +type DataSource struct { + client *glassnodeapi.RestClient +} + +func New(apiKey string) *DataSource { + client := glassnodeapi.NewRestClient() + client.Auth(apiKey) + + return &DataSource{client: client} +} + +func (d *DataSource) Query(ctx context.Context, category, metric, asset string, options QueryOptions) (glassnodeapi.DataSlice, error) { + req := glassnodeapi.Request{ + Client: d.client, + Asset: asset, + Since: options.Since, + Until: options.Until, + Interval: options.Interval, + + Category: category, + Metric: metric, + } + + resp, err := req.Do(ctx) + if err != nil { + return nil, err + } + + return glassnodeapi.DataSlice(resp), nil +} + +// query last futures open interest +// https://docs.glassnode.com/api/derivatives#futures-open-interest +func (d *DataSource) QueryFuturesOpenInterest(ctx context.Context, currency string) (float64, error) { + until := time.Now() + since := until.Add(-24 * time.Hour) + + options := QueryOptions{ + Since: &since, + Until: &until, + } + resp, err := d.Query(ctx, "derivatives", "futures_open_interest_sum", currency, options) + + if err != nil { + return 0, err + } + + return resp.Last().Value, nil +} + +// query last market cap in usd +// https://docs.glassnode.com/api/market#market-cap +func (d *DataSource) QueryMarketCapInUSD(ctx context.Context, currency string) (float64, error) { + until := time.Now() + since := until.Add(-24 * time.Hour) + + options := QueryOptions{ + Since: &since, + Until: &until, + } + + resp, err := d.Query(ctx, "market", "marketcap_usd", currency, options) + + if err != nil { + return 0, err + } + + return resp.Last().Value, nil +} diff --git a/pkg/datasource/glassnode/glassnodeapi/client.go b/pkg/datasource/glassnode/glassnodeapi/client.go new file mode 100644 index 0000000..fcc71d0 --- /dev/null +++ b/pkg/datasource/glassnode/glassnodeapi/client.go @@ -0,0 +1,55 @@ +package glassnodeapi + +import ( + "context" + "net/http" + "net/url" + "time" + + "github.com/c9s/requestgen" +) + +const defaultHTTPTimeout = time.Second * 15 +const glassnodeBaseURL = "https://api.glassnode.com" + +type RestClient struct { + requestgen.BaseAPIClient + + apiKey string +} + +func NewRestClient() *RestClient { + u, err := url.Parse(glassnodeBaseURL) + if err != nil { + panic(err) + } + + return &RestClient{ + BaseAPIClient: requestgen.BaseAPIClient{ + BaseURL: u, + HttpClient: &http.Client{ + Timeout: defaultHTTPTimeout, + }, + }, + } +} + +func (c *RestClient) Auth(apiKey string) { + // pragma: allowlist nextline secret + c.apiKey = apiKey +} + +func (c *RestClient) NewAuthenticatedRequest(ctx context.Context, method, refURL string, params url.Values, payload interface{}) (*http.Request, error) { + req, err := c.NewRequest(ctx, method, refURL, params, payload) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Accept", "application/json") + + // Attch API Key to header. https://docs.glassnode.com/basic-api/api-key#usage + req.Header.Add("X-Api-Key", c.apiKey) + + return req, nil +} diff --git a/pkg/datasource/glassnode/glassnodeapi/request.go b/pkg/datasource/glassnode/glassnodeapi/request.go new file mode 100644 index 0000000..f8addc5 --- /dev/null +++ b/pkg/datasource/glassnode/glassnodeapi/request.go @@ -0,0 +1,23 @@ +package glassnodeapi + +import ( + "time" + + "github.com/c9s/requestgen" +) + +//go:generate requestgen -method GET -type Request -url "/v1/metrics/:category/:metric" -responseType DataSlice +type Request struct { + Client requestgen.AuthenticatedAPIClient + + Asset string `param:"a,required,query"` + Since *time.Time `param:"s,query,seconds"` + Until *time.Time `param:"u,query,seconds"` + Interval *Interval `param:"i,query"` + Format *Format `param:"f,query" default:"JSON"` + Currency *string `param:"c,query"` + TimestampFormat *string `param:"timestamp_format,query"` + + Category string `param:"category,slug"` + Metric string `param:"metric,slug"` +} diff --git a/pkg/datasource/glassnode/glassnodeapi/request_requestgen.go b/pkg/datasource/glassnode/glassnodeapi/request_requestgen.go new file mode 100644 index 0000000..0949ad1 --- /dev/null +++ b/pkg/datasource/glassnode/glassnodeapi/request_requestgen.go @@ -0,0 +1,288 @@ +// Code generated by "requestgen -method GET -type Request -url /v1/metrics/:category/:metric -responseType DataSlice"; DO NOT EDIT. + +package glassnodeapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (r *Request) SetAsset(Asset string) *Request { + r.Asset = Asset + return r +} + +func (r *Request) SetSince(Since time.Time) *Request { + r.Since = &Since + return r +} + +func (r *Request) SetUntil(Until time.Time) *Request { + r.Until = &Until + return r +} + +func (r *Request) SetInterval(Interval Interval) *Request { + r.Interval = &Interval + return r +} + +func (r *Request) SetFormat(Format Format) *Request { + r.Format = &Format + return r +} + +func (r *Request) SetCurrency(Currency string) *Request { + r.Currency = &Currency + return r +} + +func (r *Request) SetTimestampFormat(TimestampFormat string) *Request { + r.TimestampFormat = &TimestampFormat + return r +} + +func (r *Request) SetCategory(Category string) *Request { + r.Category = Category + return r +} + +func (r *Request) SetMetric(Metric string) *Request { + r.Metric = Metric + return r +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (r *Request) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check Asset field -> json key a + Asset := r.Asset + + // TEMPLATE check-required + if len(Asset) == 0 { + return nil, fmt.Errorf("a is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of Asset + params["a"] = Asset + // check Since field -> json key s + if r.Since != nil { + Since := *r.Since + + // assign parameter of Since + // convert time.Time to seconds time stamp + params["s"] = strconv.FormatInt(Since.Unix(), 10) + } else { + } + // check Until field -> json key u + if r.Until != nil { + Until := *r.Until + + // assign parameter of Until + // convert time.Time to seconds time stamp + params["u"] = strconv.FormatInt(Until.Unix(), 10) + } else { + } + // check Interval field -> json key i + if r.Interval != nil { + Interval := *r.Interval + + // TEMPLATE check-valid-values + switch Interval { + case Interval1h, Interval24h, Interval10m, Interval1w, Interval1m: + params["i"] = Interval + + default: + return nil, fmt.Errorf("i value %v is invalid", Interval) + + } + // END TEMPLATE check-valid-values + + // assign parameter of Interval + params["i"] = Interval + } else { + } + // check Format field -> json key f + if r.Format != nil { + Format := *r.Format + + // TEMPLATE check-valid-values + switch Format { + case FormatJSON, FormatCSV: + params["f"] = Format + + default: + return nil, fmt.Errorf("f value %v is invalid", Format) + + } + // END TEMPLATE check-valid-values + + // assign parameter of Format + params["f"] = Format + } else { + Format := "JSON" + + // assign parameter of Format + params["f"] = Format + } + // check Currency field -> json key c + if r.Currency != nil { + Currency := *r.Currency + + // assign parameter of Currency + params["c"] = Currency + } else { + } + // check TimestampFormat field -> json key timestamp_format + if r.TimestampFormat != nil { + TimestampFormat := *r.TimestampFormat + + // assign parameter of TimestampFormat + params["timestamp_format"] = TimestampFormat + } else { + } + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (r *Request) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (r *Request) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := r.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if r.isVarSlice(_v) { + r.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (r *Request) GetParametersJSON() ([]byte, error) { + params, err := r.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (r *Request) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check Category field -> json key category + Category := r.Category + + // assign parameter of Category + params["category"] = Category + // check Metric field -> json key metric + Metric := r.Metric + + // assign parameter of Metric + params["metric"] = Metric + + return params, nil +} + +func (r *Request) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (r *Request) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (r *Request) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (r *Request) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := r.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (r *Request) Do(ctx context.Context) (DataSlice, error) { + + // no body params + var params interface{} + query, err := r.GetQueryParameters() + if err != nil { + return nil, err + } + + apiURL := "/v1/metrics/:category/:metric" + slugs, err := r.GetSlugsMap() + if err != nil { + return nil, err + } + + apiURL = r.applySlugsToUrl(apiURL, slugs) + + req, err := r.Client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := r.Client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse DataSlice + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/datasource/glassnode/glassnodeapi/types.go b/pkg/datasource/glassnode/glassnodeapi/types.go new file mode 100644 index 0000000..0e7183f --- /dev/null +++ b/pkg/datasource/glassnode/glassnodeapi/types.go @@ -0,0 +1,121 @@ +package glassnodeapi + +import ( + "encoding/json" + "time" +) + +type Interval string + +const ( + Interval1h Interval = "1h" + Interval24h Interval = "24h" + Interval10m Interval = "10m" + Interval1w Interval = "1w" + Interval1m Interval = "1month" +) + +type Format string + +const ( + FormatJSON Format = "JSON" + FormatCSV Format = "CSV" +) + +type Timestamp time.Time + +func (t Timestamp) Unix() float64 { + return float64(time.Time(t).Unix()) +} + +func (t Timestamp) String() string { + return time.Time(t).String() +} + +func (t *Timestamp) UnmarshalJSON(o []byte) error { + var timestamp int64 + if err := json.Unmarshal(o, ×tamp); err != nil { + return err + } + + *t = Timestamp(time.Unix(timestamp, 0)) + return nil +} + +/* +In Glassnode API, there are two types of response, for example: + + /v1/metrics/market/marketcap_usd + + [ + { + "t": 1614556800, + "v": 927789865185.0476 + }, + ... + ] + +and + + /v1/metrics/market/price_usd_ohlc + + [ + { + "t": 1614556800, + "o": { + "c": 49768.16035012147, + "h": 49773.18922304233, + "l": 45159.50305252744, + "o": 45159.50305252744 + } + }, + ... + ] + +both can be stored into the DataSlice structure. + +Note: use `HasOptions` to verify the type of response. +*/ +type DataSlice []Data +type Data struct { + Timestamp Timestamp `json:"t"` + Value float64 `json:"v"` + Options map[string]float64 `json:"o"` +} + +func (s DataSlice) IsEmpty() bool { + return len(s) == 0 +} + +func (s DataSlice) First() Data { + if s.IsEmpty() { + return Data{} + } + return s[0] +} +func (s DataSlice) FirstValue() float64 { + return s.First().Value +} + +func (s DataSlice) FirstOptions() map[string]float64 { + return s.First().Options +} + +func (s DataSlice) Last() Data { + if s.IsEmpty() { + return Data{} + } + return s[len(s)-1] +} + +func (s DataSlice) LastValue() float64 { + return s.Last().Value +} + +func (s DataSlice) LastOptions() map[string]float64 { + return s.Last().Options +} + +func (s DataSlice) HasOptions() bool { + return len(s.First().Options) != 0 +} diff --git a/pkg/datasource/glassnode/types.go b/pkg/datasource/glassnode/types.go new file mode 100644 index 0000000..d8a6e08 --- /dev/null +++ b/pkg/datasource/glassnode/types.go @@ -0,0 +1,14 @@ +package glassnode + +import ( + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datasource/glassnode/glassnodeapi" +) + +type QueryOptions struct { + Since *time.Time + Until *time.Time + Interval *glassnodeapi.Interval + Currency *string +} diff --git a/pkg/datasource/wise/README.md b/pkg/datasource/wise/README.md new file mode 100644 index 0000000..1b6a04f --- /dev/null +++ b/pkg/datasource/wise/README.md @@ -0,0 +1,25 @@ +# Wise + +[Wise API Docs](https://docs.wise.com/api-docs) + +```go +c := wise.NewClient() +c.Auth(os.Getenv("WISE_TOKEN")) + +ctx := context.Background() +rates, err := c.QueryRate(ctx, "USD", "TWD") +if err != nil { + panic(err) +} +fmt.Printf("%+v\n", rates) + +// or +now := time.Now() +rates, err = c.QueryRateHistory(ctx, "USD", "TWD", now.Add(-time.Hour*24*7), now, types.Interval1h) +if err != nil { + panic(err) +} +for _, rate := range rates { + fmt.Printf("%+v\n", rate) +} +``` diff --git a/pkg/datasource/wise/client.go b/pkg/datasource/wise/client.go new file mode 100644 index 0000000..64a9f01 --- /dev/null +++ b/pkg/datasource/wise/client.go @@ -0,0 +1,80 @@ +package wise + +import ( + "context" + "fmt" + "net/http" + "net/url" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/c9s/requestgen" +) + +const ( + defaultHTTPTimeout = time.Second * 15 + defaultBaseURL = "https://api.transferwise.com" + sandboxBaseURL = "https://api.sandbox.transferwise.tech" +) + +type Client struct { + requestgen.BaseAPIClient + + token string +} + +func NewClient() *Client { + u, err := url.Parse(defaultBaseURL) + if err != nil { + panic(err) + } + + return &Client{ + BaseAPIClient: requestgen.BaseAPIClient{ + BaseURL: u, + HttpClient: &http.Client{ + Timeout: defaultHTTPTimeout, + }, + }, + } +} + +func (c *Client) Auth(token string) { + c.token = token +} + +func (c *Client) NewAuthenticatedRequest(ctx context.Context, method, refURL string, params url.Values, payload interface{}) (*http.Request, error) { + req, err := c.NewRequest(ctx, method, refURL, params, payload) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", "application/json") + + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + return req, nil +} + +func (c *Client) QueryRate(ctx context.Context, source string, target string) ([]Rate, error) { + req := c.NewRateRequest().Source(source).Target(target) + return req.Do(ctx) +} + +func (c *Client) QueryRateHistory(ctx context.Context, source string, target string, from time.Time, to time.Time, interval types.Interval) ([]Rate, error) { + req := c.NewRateRequest().Source(source).Target(target).From(from).To(to) + + switch interval { + case types.Interval1h: + req.Group(GroupHour) + case types.Interval1d: + req.Group(GroupDay) + case types.Interval1m: + req.Group(GroupMinute) + default: + return nil, fmt.Errorf("unsupported interval: %s", interval) + } + + return req.Do(ctx) +} diff --git a/pkg/datasource/wise/group.go b/pkg/datasource/wise/group.go new file mode 100644 index 0000000..2770237 --- /dev/null +++ b/pkg/datasource/wise/group.go @@ -0,0 +1,9 @@ +package wise + +type Group string + +const ( + GroupMinute = Group("minute") + GroupHour = Group("hour") + GroupDay = Group("day") +) diff --git a/pkg/datasource/wise/rate.go b/pkg/datasource/wise/rate.go new file mode 100644 index 0000000..7ffc25d --- /dev/null +++ b/pkg/datasource/wise/rate.go @@ -0,0 +1,10 @@ +package wise + +import "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + +type Rate struct { + Value fixedpoint.Value `json:"rate"` + Target string `json:"target"` + Source string `json:"source"` + Time Time `json:"time"` +} diff --git a/pkg/datasource/wise/rate_request.go b/pkg/datasource/wise/rate_request.go new file mode 100644 index 0000000..f8d8561 --- /dev/null +++ b/pkg/datasource/wise/rate_request.go @@ -0,0 +1,27 @@ +package wise + +import ( + "time" + + "github.com/c9s/requestgen" +) + +// https://docs.wise.com/api-docs/api-reference/rate + +//go:generate requestgen -method GET -url "/v1/rates" -type RateRequest -responseType []Rate +type RateRequest struct { + client requestgen.AuthenticatedAPIClient + + source string `param:"source"` + target string `param:"target"` + time *time.Time `param:"time" timeFormat:"2006-01-02T15:04:05-0700"` + from *time.Time `param:"from" timeFormat:"2006-01-02T15:04:05-0700"` + to *time.Time `param:"to" timeFormat:"2006-01-02T15:04:05-0700"` + group *Group `param:"group"` +} + +func (c *Client) NewRateRequest() *RateRequest { + return &RateRequest{ + client: c, + } +} diff --git a/pkg/datasource/wise/rate_request_requestgen.go b/pkg/datasource/wise/rate_request_requestgen.go new file mode 100644 index 0000000..7869491 --- /dev/null +++ b/pkg/datasource/wise/rate_request_requestgen.go @@ -0,0 +1,229 @@ +// Code generated by "requestgen -method GET -url /v1/rates -type RateRequest -responseType []Rate"; DO NOT EDIT. + +package wise + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" + "time" +) + +func (r *RateRequest) Source(source string) *RateRequest { + r.source = source + return r +} + +func (r *RateRequest) Target(target string) *RateRequest { + r.target = target + return r +} + +func (r *RateRequest) Time(time time.Time) *RateRequest { + r.time = &time + return r +} + +func (r *RateRequest) From(from time.Time) *RateRequest { + r.from = &from + return r +} + +func (r *RateRequest) To(to time.Time) *RateRequest { + r.to = &to + return r +} + +func (r *RateRequest) Group(group Group) *RateRequest { + r.group = &group + return r +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (r *RateRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (r *RateRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check source field -> json key source + source := r.source + + // assign parameter of source + params["source"] = source + // check target field -> json key target + target := r.target + + // assign parameter of target + params["target"] = target + // check time field -> json key time + if r.time != nil { + time := *r.time + + // assign parameter of time + params["time"] = time.Format("2006-01-02T15:04:05-0700") + } else { + } + // check from field -> json key from + if r.from != nil { + from := *r.from + + // assign parameter of from + params["from"] = from.Format("2006-01-02T15:04:05-0700") + } else { + } + // check to field -> json key to + if r.to != nil { + to := *r.to + + // assign parameter of to + params["to"] = to.Format("2006-01-02T15:04:05-0700") + } else { + } + // check group field -> json key group + if r.group != nil { + group := *r.group + + // assign parameter of group + params["group"] = group + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (r *RateRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := r.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if r.isVarSlice(_v) { + r.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (r *RateRequest) GetParametersJSON() ([]byte, error) { + params, err := r.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (r *RateRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (r *RateRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (r *RateRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (r *RateRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (r *RateRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := r.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (r *RateRequest) GetPath() string { + return "/v1/rates" +} + +// Do generates the request object and send the request object to the API endpoint +func (r *RateRequest) Do(ctx context.Context) ([]Rate, error) { + + // empty params for GET operation + var params interface{} + query, err := r.GetParametersQuery() + if err != nil { + return nil, err + } + + var apiURL string + + apiURL = r.GetPath() + + req, err := r.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := r.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []Rate + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + return apiResponse, nil +} diff --git a/pkg/datasource/wise/time.go b/pkg/datasource/wise/time.go new file mode 100644 index 0000000..ba12abd --- /dev/null +++ b/pkg/datasource/wise/time.go @@ -0,0 +1,34 @@ +package wise + +import ( + "encoding/json" + "time" +) + +const layout = "2006-01-02T15:04:05-0700" + +type Time time.Time + +func (t Time) Time() time.Time { + return time.Time(t) +} + +func (t Time) String() string { + return time.Time(t).Format(layout) +} + +func (t *Time) UnmarshalJSON(data []byte) error { + var s string + err := json.Unmarshal(data, &s) + if err != nil { + return err + } + + parsed, err := time.Parse(layout, s) + if err != nil { + return err + } + + *t = Time(parsed) + return nil +} diff --git a/pkg/datatype/bools/slice.go b/pkg/datatype/bools/slice.go new file mode 100644 index 0000000..d84388f --- /dev/null +++ b/pkg/datatype/bools/slice.go @@ -0,0 +1,54 @@ +package bools + +type BoolSlice []bool + +func New(a ...bool) BoolSlice { + return BoolSlice(a) +} + +func (s *BoolSlice) Push(v bool) { + *s = append(*s, v) +} + +func (s *BoolSlice) Update(v bool) { + *s = append(*s, v) +} + +func (s *BoolSlice) Pop(i int64) (v bool) { + v = (*s)[i] + *s = append((*s)[:i], (*s)[i+1:]...) + return v +} + +func (s BoolSlice) Tail(size int) BoolSlice { + length := len(s) + if length <= size { + win := make(BoolSlice, length) + copy(win, s) + return win + } + + win := make(BoolSlice, size) + copy(win, s[length-size:]) + return win +} + +func (s *BoolSlice) Length() int { + return len(*s) +} + +func (s *BoolSlice) Index(i int) bool { + length := len(*s) + if length-i < 0 || i < 0 { + return false + } + return (*s)[length-i-1] +} + +func (s *BoolSlice) Last() bool { + length := len(*s) + if length > 0 { + return (*s)[length-1] + } + return false +} diff --git a/pkg/datatype/floats/funcs.go b/pkg/datatype/floats/funcs.go new file mode 100644 index 0000000..7942781 --- /dev/null +++ b/pkg/datatype/floats/funcs.go @@ -0,0 +1,167 @@ +package floats + +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)) +} + +// Multiply multiplies two float series +func Multiply(inReal0 []float64, inReal1 []float64) []float64 { + outReal := make([]float64, len(inReal0)) + for i := 0; i < len(inReal0); i++ { + outReal[i] = inReal0[i] * inReal1[i] + } + return outReal +} + +// CrossOver returns true if series1 is crossing over series2. +// +// NOTE: Usually this is used with Media Average Series to check if it crosses for buy signals. +// It assumes first values are the most recent. +// The crossover function does not use most recent value, since usually it's not a complete candle. +// The second recent values and the previous are used, instead. +// +// ported from https://github.com/markcheno/go-talib/blob/master/talib.go +func CrossOver(series1 []float64, series2 []float64) bool { + if len(series1) < 3 || len(series2) < 3 { + return false + } + + N := len(series1) + + return series1[N-2] <= series2[N-2] && series1[N-1] > series2[N-1] +} + +// CrossUnder returns true if series1 is crossing under series2. +// +// NOTE: Usually this is used with Media Average Series to check if it crosses for sell signals. +// +// ported from https://github.com/markcheno/go-talib/blob/master/talib.go +func CrossUnder(series1 []float64, series2 []float64) bool { + if len(series1) < 3 || len(series2) < 3 { + return false + } + + N := len(series1) + + return series1[N-1] <= series2[N-1] && series1[N-2] > series2[N-2] +} + +// MinMax - Lowest and highest values over a specified period +// ported from https://github.com/markcheno/go-talib/blob/master/talib.go +func MinMax(inReal []float64, inTimePeriod int) (outMin []float64, outMax []float64) { + outMin = make([]float64, len(inReal)) + outMax = make([]float64, len(inReal)) + nbInitialElementNeeded := inTimePeriod - 1 + startIdx := nbInitialElementNeeded + outIdx := startIdx + today := startIdx + trailingIdx := startIdx - nbInitialElementNeeded + highestIdx := -1 + highest := 0.0 + lowestIdx := -1 + lowest := 0.0 + for today < len(inReal) { + tmpLow, tmpHigh := inReal[today], inReal[today] + if highestIdx < trailingIdx { + highestIdx = trailingIdx + highest = inReal[highestIdx] + i := highestIdx + i++ + for i <= today { + tmpHigh = inReal[i] + if tmpHigh > highest { + highestIdx = i + highest = tmpHigh + } + i++ + } + } else if tmpHigh >= highest { + highestIdx = today + highest = tmpHigh + } + if lowestIdx < trailingIdx { + lowestIdx = trailingIdx + lowest = inReal[lowestIdx] + i := lowestIdx + i++ + for i <= today { + tmpLow = inReal[i] + if tmpLow < lowest { + lowestIdx = i + lowest = tmpLow + } + i++ + } + } else if tmpLow <= lowest { + lowestIdx = today + lowest = tmpLow + } + outMax[outIdx] = highest + outMin[outIdx] = lowest + outIdx++ + trailingIdx++ + today++ + } + return outMin, outMax +} diff --git a/pkg/datatype/floats/funcs_test.go b/pkg/datatype/floats/funcs_test.go new file mode 100644 index 0000000..52401fb --- /dev/null +++ b/pkg/datatype/floats/funcs_test.go @@ -0,0 +1,23 @@ +package floats + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLower(t *testing.T) { + out := Lower([]float64{10.0, 11.0, 12.0, 13.0, 15.0}, 12.0) + assert.Equal(t, []float64{10.0, 11.0}, out) +} + +func TestHigher(t *testing.T) { + out := Higher([]float64{10.0, 11.0, 12.0, 13.0, 15.0}, 12.0) + assert.Equal(t, []float64{13.0, 15.0}, out) +} + +func TestLSM(t *testing.T) { + slice := Slice{1., 2., 3., 4.} + slope := LSM(slice) + assert.Equal(t, 1.0, slope) +} diff --git a/pkg/datatype/floats/map.go b/pkg/datatype/floats/map.go new file mode 100644 index 0000000..efdcf82 --- /dev/null +++ b/pkg/datatype/floats/map.go @@ -0,0 +1,42 @@ +package floats + +type Map map[string]float64 + +func (m Map) Sum() float64 { + sum := 0.0 + for _, v := range m { + sum += v + } + return sum +} + +func (m Map) MulScalar(x float64) Map { + o := Map{} + for k, v := range m { + o[k] = v * x + } + + return o +} +func (m Map) DivScalar(x float64) Map { + o := Map{} + for k, v := range m { + o[k] = v / x + } + + return o +} + +func (m Map) Normalize() Map { + sum := m.Sum() + if sum == 0 { + panic("zero sum") + } + + o := Map{} + for k, v := range m { + o[k] = v / sum + } + + return o +} diff --git a/pkg/datatype/floats/pivot.go b/pkg/datatype/floats/pivot.go new file mode 100644 index 0000000..00bad94 --- /dev/null +++ b/pkg/datatype/floats/pivot.go @@ -0,0 +1,30 @@ +package floats + +func (s Slice) Pivot(left, right int, f func(a, pivot float64) bool) (float64, bool) { + return FindPivot(s, left, right, f) +} + +func FindPivot(values Slice, left, right int, f func(a, pivot float64) bool) (float64, bool) { + length := len(values) + + if length == 0 || length < left+right+1 { + return 0.0, false + } + + end := length - 1 + index := end - right + val := values[index] + + for i := index - left; i <= index+right; i++ { + if i == index { + continue + } + + // return if we found lower value + if !f(values[i], val) { + return 0.0, false + } + } + + return val, true +} diff --git a/pkg/datatype/floats/pivot_test.go b/pkg/datatype/floats/pivot_test.go new file mode 100644 index 0000000..5228882 --- /dev/null +++ b/pkg/datatype/floats/pivot_test.go @@ -0,0 +1,29 @@ +package floats + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFindPivot(t *testing.T) { + + t.Run("middle", func(t *testing.T) { + pv, ok := FindPivot(Slice{10, 20, 30, 40, 30, 20}, 2, 2, func(a, pivot float64) bool { + return a < pivot + }) + if assert.True(t, ok) { + assert.Equal(t, 40., pv) + } + }) + + t.Run("last", func(t *testing.T) { + pv, ok := FindPivot(Slice{10, 20, 30, 40, 30, 45}, 2, 0, func(a, pivot float64) bool { + return a < pivot + }) + if assert.True(t, ok) { + assert.Equal(t, 45., pv) + } + }) + +} diff --git a/pkg/datatype/floats/slice.go b/pkg/datatype/floats/slice.go new file mode 100644 index 0000000..1d610a4 --- /dev/null +++ b/pkg/datatype/floats/slice.go @@ -0,0 +1,238 @@ +package floats + +import ( + "math" + + "gonum.org/v1/gonum/floats" +) + +type Slice []float64 + +func New(a ...float64) Slice { + return Slice(a) +} + +func (s *Slice) Push(v float64) { + *s = append(*s, v) +} + +func (s *Slice) Append(vs ...float64) { + *s = append(*s, vs...) +} + +// Update equals to Push() +// which push an element into the slice +func (s *Slice) Update(v float64) { + *s = append(*s, v) +} + +func (s *Slice) Pop(i int64) (v float64) { + v = (*s)[i] + *s = append((*s)[:i], (*s)[i+1:]...) + return v +} + +func (s Slice) Max() float64 { + return floats.Max(s) +} + +func (s Slice) Min() float64 { + return floats.Min(s) +} + +func (s Slice) Sub(b Slice) (c Slice) { + if len(s) != len(b) { + return c + } + + c = make(Slice, len(s)) + for i := 0; i < len(s); i++ { + ai := s[i] + bi := b[i] + ci := ai - bi + c[i] = ci + } + + return c +} + +func (s Slice) Add(b Slice) (c Slice) { + if len(s) != len(b) { + return c + } + + c = make(Slice, len(s)) + for i := 0; i < len(s); i++ { + ai := s[i] + bi := b[i] + ci := ai + bi + c[i] = ci + } + + return c +} + +func (s Slice) Sum() (sum float64) { + for _, v := range s { + sum += v + } + return sum +} + +func (s Slice) Mean() (mean float64) { + length := len(s) + if length == 0 { + panic("zero length slice") + } + return s.Sum() / float64(length) +} + +func (s Slice) Tail(size int) Slice { + length := len(s) + if length <= size { + win := make(Slice, length) + copy(win, s) + return win + } + + win := make(Slice, size) + copy(win, s[length-size:]) + return win +} + +func (s Slice) Average() float64 { + if len(s) == 0 { + return 0.0 + } + + total := 0.0 + for _, value := range s { + total += value + } + return total / float64(len(s)) +} + +func (s Slice) Diff() (values Slice) { + for i, v := range s { + if i == 0 { + values.Push(0) + continue + } + values.Push(v - s[i-1]) + } + return values +} + +func (s Slice) PositiveValuesOrZero() (values Slice) { + for _, v := range s { + values.Push(math.Max(v, 0)) + } + return values +} + +func (s Slice) NegativeValuesOrZero() (values Slice) { + for _, v := range s { + values.Push(math.Min(v, 0)) + } + return values +} + +func (s Slice) Abs() (values Slice) { + for _, v := range s { + values.Push(math.Abs(v)) + } + return values +} + +func (s Slice) MulScalar(x float64) (values Slice) { + for _, v := range s { + values.Push(v * x) + } + return values +} + +func (s Slice) DivScalar(x float64) (values Slice) { + for _, v := range s { + values.Push(v / x) + } + return values +} + +func (s Slice) Mul(other Slice) (values Slice) { + if len(s) != len(other) { + panic("slice lengths do not match") + } + + for i, v := range s { + values.Push(v * other[i]) + } + + return values +} + +func (s Slice) Dot(other Slice) float64 { + return floats.Dot(s, other) +} + +func (s Slice) Normalize() Slice { + return s.DivScalar(s.Sum()) +} + +func (s Slice) Addr() *Slice { + return &s +} + +// Last, Index, Length implements the types.Series interface +func (s Slice) Last(i int) float64 { + length := len(s) + if i < 0 || length-1-i < 0 { + return 0.0 + } + return s[length-1-i] +} + +func (s Slice) Truncate(size int) Slice { + if size < 0 || len(s) <= size { + return s + } + + return s[len(s)-size:] +} + +// Index fetches the element from the end of the slice +// WARNING: it does not start from 0!!! +func (s Slice) Index(i int) float64 { + return s.Last(i) +} + +func (s Slice) Length() int { + return len(s) +} + +func (s Slice) LSM() float64 { + return LSM(s) +} + +// LSM is the least squares method for linear regression +func LSM(values Slice) float64 { + var sumX, sumY, sumXSqr, sumXY = .0, .0, .0, .0 + + end := len(values) - 1 + for i := end; i >= 0; i-- { + val := values[i] + per := float64(end - i + 1) + sumX += per + sumY += val + sumXSqr += per * per + sumXY += val * per + } + + length := float64(len(values)) + slope := (length*sumXY - sumX*sumY) / (length*sumXSqr - sumX*sumX) + + average := sumY / length + tail := average - slope*sumX/length + slope + head := tail + slope*(length-1) + slope2 := (tail - head) / (length - 1) + return slope2 +} diff --git a/pkg/datatype/floats/slice_test.go b/pkg/datatype/floats/slice_test.go new file mode 100644 index 0000000..d72caeb --- /dev/null +++ b/pkg/datatype/floats/slice_test.go @@ -0,0 +1,33 @@ +package floats + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSub(t *testing.T) { + a := New(1, 2, 3, 4, 5) + b := New(1, 2, 3, 4, 5) + c := a.Sub(b) + assert.Equal(t, Slice{.0, .0, .0, .0, .0}, c) + assert.Equal(t, 5, len(c)) + assert.Equal(t, 5, c.Length()) +} + +func TestTruncate(t *testing.T) { + a := New(1, 2, 3, 4, 5) + for i := 5; i > 0; i-- { + a = a.Truncate(i) + assert.Equal(t, i, a.Length()) + } +} + +func TestAdd(t *testing.T) { + a := New(1, 2, 3, 4, 5) + b := New(1, 2, 3, 4, 5) + c := a.Add(b) + assert.Equal(t, Slice{2.0, 4.0, 6.0, 8.0, 10.0}, c) + assert.Equal(t, 5, len(c)) + assert.Equal(t, 5, c.Length()) +} diff --git a/pkg/datatype/string_slice.go b/pkg/datatype/string_slice.go new file mode 100644 index 0000000..d33d2c4 --- /dev/null +++ b/pkg/datatype/string_slice.go @@ -0,0 +1,57 @@ +package datatype + +import ( + "encoding/json" + "fmt" +) + +type StringSlice []string + +func (s *StringSlice) decode(a interface{}) error { + switch d := a.(type) { + case string: + *s = append(*s, d) + + case []string: + *s = append(*s, d...) + + case []interface{}: + for _, de := range d { + if err := s.decode(de); err != nil { + return err + } + } + + default: + return fmt.Errorf("unexpected type %T for StringSlice: %+v", d, d) + } + + return nil +} + +func (s *StringSlice) UnmarshalYAML(unmarshal func(interface{}) error) (err error) { + var ss []string + err = unmarshal(&ss) + if err == nil { + *s = ss + return + } + + var as string + err = unmarshal(&as) + if err == nil { + *s = append(*s, as) + } + + return err +} + +func (s *StringSlice) UnmarshalJSON(b []byte) error { + var a interface{} + var err = json.Unmarshal(b, &a) + if err != nil { + return err + } + + return s.decode(a) +} diff --git a/pkg/depth/buffer.go b/pkg/depth/buffer.go new file mode 100644 index 0000000..05e715c --- /dev/null +++ b/pkg/depth/buffer.go @@ -0,0 +1,205 @@ +package depth + +import ( + "fmt" + "sync" + "sync/atomic" + "time" + + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +type SnapshotFetcher func() (snapshot types.SliceOrderBook, finalUpdateID int64, err error) + +type Update struct { + FirstUpdateID, FinalUpdateID int64 + + // Object is the update object + Object types.SliceOrderBook +} + +//go:generate callbackgen -type Buffer +type Buffer struct { + buffer []Update + + finalUpdateID int64 + fetcher SnapshotFetcher + snapshot *types.SliceOrderBook + + resetCallbacks []func() + readyCallbacks []func(snapshot types.SliceOrderBook, updates []Update) + pushCallbacks []func(update Update) + + resetC chan struct{} + mu sync.Mutex + once util.Reonce + + // updateTimeout the timeout duration when not receiving update messages + updateTimeout time.Duration + + // bufferingPeriod is used to buffer the update message before we get the full depth + bufferingPeriod atomic.Value +} + +func NewBuffer(fetcher SnapshotFetcher) *Buffer { + return &Buffer{ + fetcher: fetcher, + resetC: make(chan struct{}, 1), + } +} + +func (b *Buffer) SetUpdateTimeout(d time.Duration) { + b.updateTimeout = d +} + +func (b *Buffer) SetBufferingPeriod(d time.Duration) { + b.bufferingPeriod.Store(d) +} + +func (b *Buffer) resetSnapshot() { + b.snapshot = nil + b.finalUpdateID = 0 + b.EmitReset() +} + +func (b *Buffer) emitReset() { + select { + case b.resetC <- struct{}{}: + default: + } +} + +func (b *Buffer) Reset() { + b.mu.Lock() + b.resetSnapshot() + b.emitReset() + b.mu.Unlock() +} + +// AddUpdate adds the update to the buffer or push the update to the subscriber +func (b *Buffer) AddUpdate(o types.SliceOrderBook, firstUpdateID int64, finalArgs ...int64) error { + finalUpdateID := firstUpdateID + if len(finalArgs) > 0 { + finalUpdateID = finalArgs[0] + } + + u := Update{ + FirstUpdateID: firstUpdateID, + FinalUpdateID: finalUpdateID, + Object: o, + } + + select { + case <-b.resetC: + log.Warnf("received depth reset signal, resetting...") + + // if the once goroutine is still running, overwriting this once might cause "unlock of unlocked mutex" panic. + b.once.Reset() + default: + } + + // if the snapshot is set to nil, we need to buffer the message + b.mu.Lock() + if b.snapshot == nil { + b.buffer = append(b.buffer, u) + b.once.Do(func() { + go b.tryFetch() + }) + b.mu.Unlock() + return nil + } + + // if there is a missing update, we should reset the snapshot and re-fetch the snapshot + if u.FirstUpdateID > b.finalUpdateID+1 { + // emitReset will reset the once outside the mutex lock section + b.buffer = []Update{u} + finalUpdateID = b.finalUpdateID + b.resetSnapshot() + b.emitReset() + b.mu.Unlock() + return fmt.Errorf("found missing update between finalUpdateID %d and firstUpdateID %d, diff: %d", + finalUpdateID+1, + u.FirstUpdateID, + u.FirstUpdateID-finalUpdateID) + } + + log.Debugf("depth update id %d -> %d", b.finalUpdateID, u.FinalUpdateID) + b.finalUpdateID = u.FinalUpdateID + b.mu.Unlock() + + b.EmitPush(u) + return nil +} + +func (b *Buffer) fetchAndPush() error { + book, finalUpdateID, err := b.fetcher() + if err != nil { + return err + } + + b.mu.Lock() + log.Debugf("fetched depth snapshot, final update id %d", finalUpdateID) + + if len(b.buffer) > 0 { + // the snapshot is too early + if finalUpdateID < b.buffer[0].FirstUpdateID { + b.resetSnapshot() + b.emitReset() + b.mu.Unlock() + return fmt.Errorf("depth snapshot is too early, final update %d is < the first update id %d", finalUpdateID, b.buffer[0].FirstUpdateID) + } + } + + var pushUpdates []Update + for _, u := range b.buffer { + // skip old events + if u.FirstUpdateID < finalUpdateID+1 { + continue + } + + if u.FirstUpdateID > finalUpdateID+1 { + b.resetSnapshot() + b.emitReset() + b.mu.Unlock() + return fmt.Errorf("there is a missing depth update, the update id %d > final update id %d + 1", u.FirstUpdateID, finalUpdateID) + } + + pushUpdates = append(pushUpdates, u) + + // update the final update id to the correct final update id + finalUpdateID = u.FinalUpdateID + } + + // clean the buffer since we have filtered out the buffer we want + b.buffer = nil + + // set the final update ID so that we will know if there is an update missing + b.finalUpdateID = finalUpdateID + + // set the snapshot + b.snapshot = &book + + b.mu.Unlock() + + // should unlock first then call ready + b.EmitReady(book, pushUpdates) + return nil +} + +func (b *Buffer) tryFetch() { + for { + if period := b.bufferingPeriod.Load(); period != nil { + <-time.After(period.(time.Duration)) + } + + err := b.fetchAndPush() + if err != nil { + log.WithError(err).Errorf("snapshot fetch failed") + continue + } + break + } +} diff --git a/pkg/depth/buffer_callbacks.go b/pkg/depth/buffer_callbacks.go new file mode 100644 index 0000000..6c98588 --- /dev/null +++ b/pkg/depth/buffer_callbacks.go @@ -0,0 +1,37 @@ +// Code generated by "callbackgen -type Buffer"; DO NOT EDIT. + +package depth + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func (b *Buffer) OnReset(cb func()) { + b.resetCallbacks = append(b.resetCallbacks, cb) +} + +func (b *Buffer) EmitReset() { + for _, cb := range b.resetCallbacks { + cb() + } +} + +func (b *Buffer) OnReady(cb func(snapshot types.SliceOrderBook, updates []Update)) { + b.readyCallbacks = append(b.readyCallbacks, cb) +} + +func (b *Buffer) EmitReady(snapshot types.SliceOrderBook, updates []Update) { + for _, cb := range b.readyCallbacks { + cb(snapshot, updates) + } +} + +func (b *Buffer) OnPush(cb func(update Update)) { + b.pushCallbacks = append(b.pushCallbacks, cb) +} + +func (b *Buffer) EmitPush(update Update) { + for _, cb := range b.pushCallbacks { + cb(update) + } +} diff --git a/pkg/depth/buffer_test.go b/pkg/depth/buffer_test.go new file mode 100644 index 0000000..61994fa --- /dev/null +++ b/pkg/depth/buffer_test.go @@ -0,0 +1,157 @@ +//go:build !race +// +build !race + +package depth + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +var itov = fixedpoint.NewFromInt + +func TestDepthBuffer_ReadyState(t *testing.T) { + buf := NewBuffer(func() (book types.SliceOrderBook, finalID int64, err error) { + return types.SliceOrderBook{ + Bids: types.PriceVolumeSlice{ + {Price: itov(100), Volume: itov(1)}, + }, + Asks: types.PriceVolumeSlice{ + {Price: itov(99), Volume: itov(1)}, + }, + }, 33, nil + }) + buf.SetBufferingPeriod(time.Millisecond * 5) + + readyC := make(chan struct{}) + buf.OnReady(func(snapshot types.SliceOrderBook, updates []Update) { + assert.Greater(t, len(updates), 33) + close(readyC) + }) + + var updateID int64 = 1 + for ; updateID < 100; updateID++ { + buf.AddUpdate( + types.SliceOrderBook{ + Bids: types.PriceVolumeSlice{ + {Price: itov(100), Volume: itov(updateID)}, + }, + Asks: types.PriceVolumeSlice{ + {Price: itov(99), Volume: itov(updateID)}, + }, + }, updateID) + } + + <-readyC +} + +func TestDepthBuffer_CorruptedUpdateAtTheBeginning(t *testing.T) { + // snapshot starts from 30, + // the first ready event should have a snapshot(30) and updates (31~50) + var snapshotFinalID int64 = 0 + buf := NewBuffer(func() (types.SliceOrderBook, int64, error) { + snapshotFinalID += 30 + return types.SliceOrderBook{ + Bids: types.PriceVolumeSlice{ + {Price: itov(100), Volume: itov(1)}, + }, + Asks: types.PriceVolumeSlice{ + {Price: itov(99), Volume: itov(1)}, + }, + }, snapshotFinalID, nil + }) + + resetC := make(chan struct{}, 1) + + buf.OnReset(func() { + resetC <- struct{}{} + }) + + var updateID int64 = 10 + for ; updateID < 100; updateID++ { + if updateID == 50 { + updateID += 5 + } + + buf.AddUpdate(types.SliceOrderBook{ + Bids: types.PriceVolumeSlice{ + {Price: itov(100), Volume: itov(updateID)}, + }, + Asks: types.PriceVolumeSlice{ + {Price: itov(99), Volume: itov(updateID)}, + }, + }, updateID) + } + + <-resetC +} + +func TestDepthBuffer_ConcurrentRun(t *testing.T) { + var snapshotFinalID int64 = 0 + buf := NewBuffer(func() (types.SliceOrderBook, int64, error) { + snapshotFinalID += 30 + time.Sleep(10 * time.Millisecond) + return types.SliceOrderBook{ + Bids: types.PriceVolumeSlice{ + {Price: itov(100), Volume: itov(1)}, + }, + Asks: types.PriceVolumeSlice{ + {Price: itov(99), Volume: itov(1)}, + }, + }, snapshotFinalID, nil + }) + + readyCnt := 0 + resetCnt := 0 + pushCnt := 0 + + buf.OnPush(func(update Update) { + pushCnt++ + }) + buf.OnReady(func(snapshot types.SliceOrderBook, updates []Update) { + readyCnt++ + }) + buf.OnReset(func() { + resetCnt++ + }) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + + var updateID int64 = 10 + + for { + select { + case <-ctx.Done(): + assert.Greater(t, readyCnt, 1) + assert.Greater(t, resetCnt, 1) + assert.Greater(t, pushCnt, 1) + return + + case <-ticker.C: + updateID++ + if updateID%100 == 0 { + updateID++ + } + + buf.AddUpdate(types.SliceOrderBook{ + Bids: types.PriceVolumeSlice{ + {Price: itov(100), Volume: itov(updateID)}, + }, + Asks: types.PriceVolumeSlice{ + {Price: itov(99), Volume: itov(updateID)}, + }, + }, updateID) + + } + } +} diff --git a/pkg/dynamic/call.go b/pkg/dynamic/call.go new file mode 100644 index 0000000..4ba5948 --- /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 0000000..b65029d --- /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/can.go b/pkg/dynamic/can.go new file mode 100644 index 0000000..532a69d --- /dev/null +++ b/pkg/dynamic/can.go @@ -0,0 +1,14 @@ +package dynamic + +import "reflect" + +// For backward compatibility of reflect.Value.CanInt in go1.17 +func CanInt(v reflect.Value) bool { + k := v.Type().Kind() + switch k { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return true + default: + return false + } +} diff --git a/pkg/dynamic/field.go b/pkg/dynamic/field.go new file mode 100644 index 0000000..4061d40 --- /dev/null +++ b/pkg/dynamic/field.go @@ -0,0 +1,113 @@ +package dynamic + +import ( + "errors" + "reflect" + "strings" +) + +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 +} + +// Used by qbtrade/interact_modify.go +func GetModifiableFields(val reflect.Value, callback func(tagName, name string)) { + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + if !val.IsValid() { + return + } + num := val.Type().NumField() + for i := 0; i < num; i++ { + t := val.Type().Field(i) + if !t.IsExported() { + continue + } + if t.Anonymous { + GetModifiableFields(val.Field(i), callback) + } + modifiable := t.Tag.Get("modifiable") + if modifiable != "true" { + continue + } + jsonTag := t.Tag.Get("json") + if jsonTag == "" || jsonTag == "-" { + continue + } + name := strings.Split(jsonTag, ",")[0] + callback(name, t.Name) + } +} + +var zeroValue reflect.Value = reflect.Zero(reflect.TypeOf(0)) + +func GetModifiableField(val reflect.Value, name string) (reflect.Value, bool) { + if val.Kind() == reflect.Ptr { + if val.IsNil() { + return zeroValue, false + } + } + if val.Kind() != reflect.Struct { + return zeroValue, false + } + if !val.IsValid() { + return zeroValue, false + } + field, ok := val.Type().FieldByName(name) + if !ok { + return zeroValue, ok + } + if field.Tag.Get("modifiable") != "true" { + return zeroValue, false + } + jsonTag := field.Tag.Get("json") + if jsonTag == "" || jsonTag == "-" { + return zeroValue, false + } + value, err := FieldByIndexErr(val, field.Index) + if err != nil { + return zeroValue, false + } + return value, true +} + +// Modified from golang 1.19.1 reflect to eliminate all possible panic +func FieldByIndexErr(v reflect.Value, index []int) (reflect.Value, error) { + if len(index) == 1 { + return v.Field(index[0]), nil + } + if v.Kind() != reflect.Struct { + return zeroValue, errors.New("should receive a Struct") + } + for i, x := range index { + if i > 0 { + if v.Kind() == reflect.Ptr && v.Type().Elem().Kind() == reflect.Struct { + if v.IsNil() { + return zeroValue, errors.New("reflect: indirection through nil pointer to embedded struct field ") + } + v = v.Elem() + } + } + v = v.Field(x) + } + return v, nil +} diff --git a/pkg/dynamic/field_test.go b/pkg/dynamic/field_test.go new file mode 100644 index 0000000..cd9485a --- /dev/null +++ b/pkg/dynamic/field_test.go @@ -0,0 +1,73 @@ +package dynamic + +import ( + "encoding/json" + "reflect" + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "github.com/stretchr/testify/assert" +) + +type Inner struct { + Field5 float64 `json:"field5,omitempty" modifiable:"true"` +} + +type InnerPointer struct { + Field6 float64 `json:"field6" modifiable:"true"` +} + +type Strategy struct { + Inner + *InnerPointer + Field1 fixedpoint.Value `json:"field1" modifiable:"true"` + Field2 float64 `json:"field2"` + field3 float64 `json:"field3" modifiable:"true"` + Field4 *fixedpoint.Value `json:"field4" modifiable:"true"` +} + +func TestGetModifiableFields(t *testing.T) { + s := Strategy{} + val := reflect.ValueOf(s) + GetModifiableFields(val, func(tagName, name string) { + assert.NotEqual(t, tagName, "field2") + assert.NotEqual(t, name, "Field2") + assert.NotEqual(t, tagName, "field3") + assert.NotEqual(t, name, "Field3") + }) +} + +func TestGetModifiableField(t *testing.T) { + // val must be get from pointer.Elem(), otherwise the fields will be unaddressable + s := &Strategy{Field1: fixedpoint.NewFromInt(1)} + val := reflect.ValueOf(s).Elem() + _, ok := GetModifiableField(val, "Field1") + assert.True(t, ok) + _, ok = GetModifiableField(val, "Field5") + assert.True(t, ok) + _, ok = GetModifiableField(val, "Field6") + assert.False(t, ok) + s.InnerPointer = &InnerPointer{} + _, ok = GetModifiableField(val, "Field6") + assert.True(t, ok) + _, ok = GetModifiableField(val, "Field2") + assert.False(t, ok) + _, ok = GetModifiableField(val, "Field3") + assert.False(t, ok) + _, ok = GetModifiableField(val, "Random") + assert.False(t, ok) + field, ok := GetModifiableField(val, "Field1") + assert.True(t, ok) + x := reflect.New(field.Type()) + xi := x.Interface() + assert.NoError(t, json.Unmarshal([]byte("\"3.1415%\""), &xi)) + assert.True(t, field.CanAddr()) + field.Set(x.Elem()) + assert.Equal(t, s.Field1.String(), "0.031415") + field, _ = GetModifiableField(val, "Field4") + x = reflect.New(field.Type()) + xi = x.Interface() + assert.NoError(t, json.Unmarshal([]byte("311"), &xi)) + field.Set(x.Elem()) + assert.Equal(t, s.Field4.String(), "311") +} diff --git a/pkg/dynamic/id.go b/pkg/dynamic/id.go new file mode 100644 index 0000000..b7b4217 --- /dev/null +++ b/pkg/dynamic/id.go @@ -0,0 +1,30 @@ +package dynamic + +import ( + "reflect" +) + +type InstanceIDProvider interface { + InstanceID() string +} + +func CallID(obj interface{}) string { + sv := reflect.ValueOf(obj) + st := reflect.TypeOf(obj) + if st.Implements(reflect.TypeOf((*InstanceIDProvider)(nil)).Elem()) { + m := sv.MethodByName("InstanceID") + ret := m.Call(nil) + return ret[0].String() + } + + if symbol, ok := LookupSymbolField(sv); ok { + m := sv.MethodByName("ID") + ret := m.Call(nil) + return ret[0].String() + ":" + symbol + } + + // fallback to just ID + m := sv.MethodByName("ID") + ret := m.Call(nil) + return ret[0].String() + ":" +} diff --git a/pkg/dynamic/inject.go b/pkg/dynamic/inject.go new file mode 100644 index 0000000..ed39ab6 --- /dev/null +++ b/pkg/dynamic/inject.go @@ -0,0 +1,130 @@ +package dynamic + +import ( + "fmt" + "reflect" + "time" + + "github.com/sirupsen/logrus" +) + +type testEnvironment struct { + startTime time.Time +} + +func InjectField(target interface{}, fieldName string, obj interface{}, pointerOnly bool) error { + rs := reflect.ValueOf(target) + field := rs.FieldByName(fieldName) + + if !field.IsValid() { + return nil + } + + if !field.CanSet() { + return fmt.Errorf("field %s of %s can not be set", fieldName, rs.Type()) + } + + rv := reflect.ValueOf(obj) + if field.Kind() == reflect.Ptr { + if field.Type() != rv.Type() { + return fmt.Errorf("field type mismatches: %s != %s", field.Type(), rv.Type()) + } + + field.Set(rv) + } else if field.Kind() == reflect.Interface { + field.Set(rv) + } else { + // set as value + if pointerOnly { + return fmt.Errorf("field %s %s does not allow value assignment (pointer type only)", field.Type(), rv.Type()) + } + + field.Set(rv.Elem()) + } + + return nil +} + +// 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 { + sv := reflect.ValueOf(f) + st := reflect.TypeOf(f) + + if st.Kind() != reflect.Ptr { + return fmt.Errorf("f needs to be a pointer of a struct, %s given", st) + } + + // solve the reference + st = st.Elem() + sv = sv.Elem() + + if st.Kind() != reflect.Struct { + return fmt.Errorf("f needs to be a struct, %s given", st) + } + + for i := 0; i < sv.NumField(); i++ { + fv := sv.Field(i) + ft := fv.Type() + + // skip unexported fields + if !st.Field(i).IsExported() { + continue + } + + fieldName := st.Field(i).Name + + switch k := fv.Kind(); k { + + case reflect.Ptr, reflect.Struct: + for oi := 0; oi < len(objects); oi++ { + obj := objects[oi] + if obj == nil { + continue + } + + ot := reflect.TypeOf(obj) + if ft.AssignableTo(ot) { + if !fv.CanSet() { + return fmt.Errorf("field %v of %s can not be set to %s, make sure it is an exported field", fv, sv.Type(), ot) + } + + if k == reflect.Ptr && !fv.IsNil() { + logrus.Debugf("[injection] field %s is not nil, not injecting", fieldName) + continue + } + + if k == reflect.Ptr && ot.Kind() == reflect.Struct { + logrus.Debugf("[injection] found ptr + struct, injecting field %s to %T", fieldName, obj) + fv.Set(reflect.ValueOf(obj).Addr()) + } else { + logrus.Debugf("[injection] injecting field %s to %T", fieldName, obj) + fv.Set(reflect.ValueOf(obj)) + } + } + } + + case reflect.Interface: + for oi := 0; oi < len(objects); oi++ { + obj := objects[oi] + if obj == nil { + continue + } + + objT := reflect.TypeOf(obj) + logrus.Debugln( + ft.PkgPath(), + ft.Name(), + objT, "implements", ft, "=", objT.Implements(ft), + ) + + if objT.Implements(ft) { + fv.Set(reflect.ValueOf(obj)) + } + } + } + } + + return nil +} diff --git a/pkg/dynamic/inject_test.go b/pkg/dynamic/inject_test.go new file mode 100644 index 0000000..4e565a1 --- /dev/null +++ b/pkg/dynamic/inject_test.go @@ -0,0 +1,105 @@ +package dynamic + +import ( + "reflect" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/service" + "git.qtrade.icu/lychiyu/qbtrade/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 *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 0000000..9c932d6 --- /dev/null +++ b/pkg/dynamic/iterate.go @@ -0,0 +1,108 @@ +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 isStructPtr(tpe reflect.Type) bool { + return tpe.Kind() == reflect.Ptr && tpe.Elem().Kind() == reflect.Struct +} + +func IterateFieldsByTag(obj interface{}, tagName string, children bool, cb StructFieldIterator) error { + sv := reflect.ValueOf(obj) + st := reflect.TypeOf(obj) + + if st.Kind() != reflect.Ptr { + return fmt.Errorf("obj 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 children && isStructPtr(ft.Type) && !fv.IsNil() { + // recursive iterate the struct field + if err := IterateFieldsByTag(fv.Interface(), tagName, false, cb); err != nil { + return fmt.Errorf("unable to iterate struct fields over the type %v: %v", ft, err) + } + } + + tag, ok := ft.Tag.Lookup(tagName) + if !ok { + continue + } + + // call the iterator + 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 0000000..4cbf02e --- /dev/null +++ b/pkg/dynamic/iterate_test.go @@ -0,0 +1,131 @@ +package dynamic + +import ( + "os" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" +) + +type TestEmbedded struct { + Foo int `persistence:"foo"` + Bar int `persistence:"bar"` +} + +type TestA struct { + *TestEmbedded + Outer int `persistence:"outer"` +} + +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) + }) + +} + +func TestIterateFieldsByTag(t *testing.T) { + t.Run("nested", func(t *testing.T) { + var a = struct { + A int `persistence:"a"` + B int `persistence:"b"` + C *struct { + D int `persistence:"d"` + E int `persistence:"e"` + } + }{ + A: 1, + B: 2, + C: &struct { + D int `persistence:"d"` + E int `persistence:"e"` + }{ + D: 3, + E: 4, + }, + } + + collectedTags := []string{} + cnt := 0 + err := IterateFieldsByTag(&a, "persistence", true, func(tag string, ft reflect.StructField, fv reflect.Value) error { + cnt++ + collectedTags = append(collectedTags, tag) + return nil + }) + assert.NoError(t, err) + assert.Equal(t, 4, cnt) + assert.Equal(t, []string{"a", "b", "d", "e"}, collectedTags) + }) + + t.Run("nested nil", func(t *testing.T) { + var a = struct { + A int `persistence:"a"` + B int `persistence:"b"` + C *struct { + D int `persistence:"d"` + E int `persistence:"e"` + } + }{ + A: 1, + B: 2, + C: nil, + } + + collectedTags := []string{} + cnt := 0 + err := IterateFieldsByTag(&a, "persistence", true, func(tag string, ft reflect.StructField, fv reflect.Value) error { + cnt++ + collectedTags = append(collectedTags, tag) + return nil + }) + assert.NoError(t, err) + assert.Equal(t, 2, cnt) + assert.Equal(t, []string{"a", "b"}, collectedTags) + }) + + t.Run("embedded", func(t *testing.T) { + a := &TestA{ + TestEmbedded: &TestEmbedded{Foo: 1, Bar: 2}, + Outer: 3, + } + + collectedTags := []string{} + cnt := 0 + err := IterateFieldsByTag(a, "persistence", true, func(tag string, ft reflect.StructField, fv reflect.Value) error { + cnt++ + collectedTags = append(collectedTags, tag) + return nil + }) + assert.NoError(t, err) + assert.Equal(t, 3, cnt) + assert.Equal(t, []string{"foo", "bar", "outer"}, collectedTags) + }) +} diff --git a/pkg/dynamic/merge.go b/pkg/dynamic/merge.go new file mode 100644 index 0000000..8e44bd3 --- /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 0000000..3fa3dc5 --- /dev/null +++ b/pkg/dynamic/merge_test.go @@ -0,0 +1,82 @@ +package dynamic + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/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/print_config.go b/pkg/dynamic/print_config.go new file mode 100644 index 0000000..be122f2 --- /dev/null +++ b/pkg/dynamic/print_config.go @@ -0,0 +1,132 @@ +package dynamic + +import ( + "encoding/json" + "fmt" + "io" + "reflect" + "sort" + "strings" + + "github.com/fatih/color" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/jedib0t/go-pretty/v6/text" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +func DefaultWhiteList() []string { + return []string{"Window", "RightWindow", "Interval", "Symbol", "Source"} +} + +// @param s: strategy object +// @param f: io.Writer used for writing the config dump +// @param style: pretty print table style. Use NewDefaultTableStyle() to get default one. +// @param withColor: whether to print with color +// @param whiteLists: fields to be printed out from embedded struct (1st layer only) +func PrintConfig(s interface{}, f io.Writer, style *table.Style, withColor bool, whiteLists ...string) { + t := table.NewWriter() + var write func(io.Writer, string, ...interface{}) + + if withColor { + write = color.New(color.FgHiYellow).FprintfFunc() + } else { + write = func(a io.Writer, format string, args ...interface{}) { + fmt.Fprintf(a, format, args...) + } + } + if style != nil { + t.SetOutputMirror(f) + t.SetStyle(*style) + t.SetColumnConfigs([]table.ColumnConfig{ + {Number: 4, WidthMax: 50, WidthMaxEnforcer: text.WrapText}, + }) + t.AppendHeader(table.Row{"json", "struct field name", "type", "value"}) + } + write(f, "---- %s Settings ---\n", CallID(s)) + + embeddedWhiteSet := map[string]struct{}{} + for _, whiteList := range whiteLists { + embeddedWhiteSet[whiteList] = struct{}{} + } + + redundantSet := map[string]struct{}{} + + var rows []table.Row + + val := reflect.ValueOf(s) + + if val.Type().Kind() == util.Pointer { + val = val.Elem() + } + var values types.JsonArr + for i := 0; i < val.Type().NumField(); i++ { + t := val.Type().Field(i) + if !t.IsExported() { + continue + } + fieldName := t.Name + switch jsonTag := t.Tag.Get("json"); jsonTag { + case "-": + case "": + // we only fetch fields from the first layer of the embedded struct + if t.Anonymous { + var target reflect.Type + var field reflect.Value + if t.Type.Kind() == util.Pointer { + target = t.Type.Elem() + field = val.Field(i).Elem() + } else { + target = t.Type + field = val.Field(i) + } + for j := 0; j < target.NumField(); j++ { + tt := target.Field(j) + if !tt.IsExported() { + continue + } + fieldName := tt.Name + if _, ok := embeddedWhiteSet[fieldName]; !ok { + continue + } + if jtag := tt.Tag.Get("json"); jtag != "" && jtag != "-" { + name := strings.Split(jtag, ",")[0] + if _, ok := redundantSet[name]; ok { + continue + } + redundantSet[name] = struct{}{} + value := field.Field(j).Interface() + if e, err := json.Marshal(value); err == nil { + value = string(e) + } + values = append(values, types.JsonStruct{Key: fieldName, Json: name, Type: tt.Type.String(), Value: value}) + } + } + } + default: + name := strings.Split(jsonTag, ",")[0] + if _, ok := redundantSet[name]; ok { + continue + } + redundantSet[name] = struct{}{} + value := val.Field(i).Interface() + if e, err := json.Marshal(value); err == nil { + value = string(e) + } + values = append(values, types.JsonStruct{Key: fieldName, Json: name, Type: t.Type.String(), Value: value}) + } + } + sort.Sort(values) + for _, value := range values { + if style != nil { + rows = append(rows, table.Row{value.Json, value.Key, value.Type, value.Value}) + } else { + write(f, "%s: %v\n", value.Json, value.Value) + } + } + if style != nil { + t.AppendRows(rows) + t.Render() + } +} diff --git a/pkg/dynamic/print_strategy.go b/pkg/dynamic/print_strategy.go new file mode 100644 index 0000000..2cff0e4 --- /dev/null +++ b/pkg/dynamic/print_strategy.go @@ -0,0 +1,97 @@ +package dynamic + +import ( + "fmt" + "io" + "reflect" + "unsafe" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +// @param s: strategy object +// @param f: io.Writer used for writing the strategy dump +// @param seriesLength: if exist, the first value will be chosen to be the length of data from series to be printed out +// +// default to 1 when not exist or the value is invalid +func ParamDump(s interface{}, f io.Writer, seriesLength ...int) { + length := 1 + if len(seriesLength) > 0 && seriesLength[0] > 0 { + length = seriesLength[0] + } + val := reflect.ValueOf(s) + if val.Type().Kind() == util.Pointer { + val = val.Elem() + } + for i := 0; i < val.Type().NumField(); i++ { + t := val.Type().Field(i) + if ig := t.Tag.Get("ignore"); ig == "true" { + continue + } + field := val.Field(i) + if t.IsExported() || t.Anonymous || t.Type.Kind() == reflect.Func || t.Type.Kind() == reflect.Chan { + continue + } + fieldName := t.Name + typeName := field.Type().String() + value := reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem() + isSeries := true + lastFunc := value.MethodByName("Last") + isSeries = isSeries && lastFunc.IsValid() + lengthFunc := value.MethodByName("Length") + isSeries = isSeries && lengthFunc.IsValid() + indexFunc := value.MethodByName("Index") + isSeries = isSeries && indexFunc.IsValid() + + stringFunc := value.MethodByName("String") + canString := stringFunc.IsValid() + + if isSeries { + l := int(lengthFunc.Call(nil)[0].Int()) + if l >= length { + fmt.Fprintf(f, "%s: Series[..., %.4f", fieldName, indexFunc.Call([]reflect.Value{reflect.ValueOf(length - 1)})[0].Float()) + for j := length - 2; j >= 0; j-- { + fmt.Fprintf(f, ", %.4f", indexFunc.Call([]reflect.Value{reflect.ValueOf(j)})[0].Float()) + } + fmt.Fprintf(f, "]\n") + } else if l > 0 { + fmt.Fprintf(f, "%s: Series[%.4f", fieldName, indexFunc.Call([]reflect.Value{reflect.ValueOf(l - 1)})[0].Float()) + for j := l - 2; j >= 0; j-- { + fmt.Fprintf(f, ", %.4f", indexFunc.Call([]reflect.Value{reflect.ValueOf(j)})[0].Float()) + } + fmt.Fprintf(f, "]\n") + } else { + fmt.Fprintf(f, "%s: Series[]\n", fieldName) + } + } else if canString { + fmt.Fprintf(f, "%s: %s\n", fieldName, stringFunc.Call(nil)[0].String()) + } else if CanInt(field) { + fmt.Fprintf(f, "%s: %d\n", fieldName, field.Int()) + } else if field.CanConvert(reflect.TypeOf(float64(0))) { + fmt.Fprintf(f, "%s: %.4f\n", fieldName, field.Float()) + } else if field.CanInterface() { + fmt.Fprintf(f, "%s: %v", fieldName, field.Interface()) + } else if field.Type().Kind() == reflect.Map { + fmt.Fprintf(f, "%s: {", fieldName) + iter := value.MapRange() + for iter.Next() { + k := iter.Key().Interface() + v := iter.Value().Interface() + fmt.Fprintf(f, "%v: %v, ", k, v) + } + fmt.Fprintf(f, "}\n") + } else if field.Type().Kind() == reflect.Slice { + fmt.Fprintf(f, "%s: [", fieldName) + l := field.Len() + if l > 0 { + fmt.Fprintf(f, "%v", field.Index(0)) + } + for j := 1; j < field.Len(); j++ { + fmt.Fprintf(f, ", %v", field.Index(j)) + } + fmt.Fprintf(f, "]\n") + } else { + fmt.Fprintf(f, "%s(%s): %s\n", fieldName, typeName, field.String()) + } + } +} diff --git a/pkg/dynamic/typevalue.go b/pkg/dynamic/typevalue.go new file mode 100644 index 0000000..3ca3f1c --- /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/exchange/batch/batch_test.go b/pkg/exchange/batch/batch_test.go new file mode 100644 index 0000000..389c4ab --- /dev/null +++ b/pkg/exchange/batch/batch_test.go @@ -0,0 +1 @@ +package batch diff --git a/pkg/exchange/batch/closedorders.go b/pkg/exchange/batch/closedorders.go new file mode 100644 index 0000000..791d8e5 --- /dev/null +++ b/pkg/exchange/batch/closedorders.go @@ -0,0 +1,42 @@ +package batch + +import ( + "context" + "strconv" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type ClosedOrderBatchQuery struct { + types.ExchangeTradeHistoryService +} + +func (q *ClosedOrderBatchQuery) Query(ctx context.Context, symbol string, startTime, endTime time.Time, lastOrderID uint64, opts ...Option) (c chan types.Order, errC chan error) { + query := &AsyncTimeRangedBatchQuery{ + Type: types.Order{}, + Q: func(startTime, endTime time.Time) (interface{}, error) { + orders, err := q.ExchangeTradeHistoryService.QueryClosedOrders(ctx, symbol, startTime, endTime, lastOrderID) + return orders, err + }, + T: func(obj interface{}) time.Time { + return time.Time(obj.(types.Order).CreationTime) + }, + ID: func(obj interface{}) string { + order := obj.(types.Order) + if order.OrderID > lastOrderID { + lastOrderID = order.OrderID + } + return strconv.FormatUint(order.OrderID, 10) + }, + JumpIfEmpty: 30 * 24 * time.Hour, + } + + for _, opt := range opts { + opt(query) + } + + c = make(chan types.Order, 100) + errC = query.Query(ctx, c, startTime, endTime) + return c, errC +} diff --git a/pkg/exchange/batch/deposit.go b/pkg/exchange/batch/deposit.go new file mode 100644 index 0000000..33a9ff3 --- /dev/null +++ b/pkg/exchange/batch/deposit.go @@ -0,0 +1,36 @@ +package batch + +import ( + "context" + "time" + + "golang.org/x/time/rate" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type DepositBatchQuery struct { + types.ExchangeTransferService +} + +func (e *DepositBatchQuery) Query(ctx context.Context, asset string, startTime, endTime time.Time) (c chan types.Deposit, errC chan error) { + query := &AsyncTimeRangedBatchQuery{ + Type: types.Deposit{}, + Limiter: rate.NewLimiter(rate.Every(5*time.Second), 2), + JumpIfEmpty: time.Hour * 24 * 80, + Q: func(startTime, endTime time.Time) (interface{}, error) { + return e.ExchangeTransferService.QueryDepositHistory(ctx, asset, startTime, endTime) + }, + T: func(obj interface{}) time.Time { + return time.Time(obj.(types.Deposit).Time) + }, + ID: func(obj interface{}) string { + deposit := obj.(types.Deposit) + return deposit.TransactionID + }, + } + + c = make(chan types.Deposit, 100) + errC = query.Query(ctx, c, startTime, endTime) + return c, errC +} diff --git a/pkg/exchange/batch/funding_fee.go b/pkg/exchange/batch/funding_fee.go new file mode 100644 index 0000000..07b3e76 --- /dev/null +++ b/pkg/exchange/batch/funding_fee.go @@ -0,0 +1,41 @@ +package batch + +import ( + "context" + "time" + + "golang.org/x/time/rate" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/binance/binanceapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type BinanceFuturesIncomeHistoryService interface { + QueryFuturesIncomeHistory(ctx context.Context, symbol string, incomeType binanceapi.FuturesIncomeType, startTime, endTime *time.Time) ([]binanceapi.FuturesIncome, error) +} + +type BinanceFuturesIncomeBatchQuery struct { + BinanceFuturesIncomeHistoryService +} + +func (e *BinanceFuturesIncomeBatchQuery) Query(ctx context.Context, symbol string, incomeType binanceapi.FuturesIncomeType, startTime, endTime time.Time) (c chan binanceapi.FuturesIncome, errC chan error) { + query := &AsyncTimeRangedBatchQuery{ + Type: types.MarginInterest{}, + Limiter: rate.NewLimiter(rate.Every(3*time.Second), 1), + JumpIfEmpty: time.Hour * 24 * 30, + Q: func(startTime, endTime time.Time) (interface{}, error) { + return e.QueryFuturesIncomeHistory(ctx, symbol, incomeType, &startTime, &endTime) + }, + T: func(obj interface{}) time.Time { + return time.Time(obj.(binanceapi.FuturesIncome).Time) + }, + ID: func(obj interface{}) string { + interest := obj.(binanceapi.FuturesIncome) + return interest.Time.String() + }, + } + + c = make(chan binanceapi.FuturesIncome, 100) + errC = query.Query(ctx, c, startTime, endTime) + return c, errC +} diff --git a/pkg/exchange/batch/kline.go b/pkg/exchange/batch/kline.go new file mode 100644 index 0000000..7a093ce --- /dev/null +++ b/pkg/exchange/batch/kline.go @@ -0,0 +1,37 @@ +package batch + +import ( + "context" + "strconv" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type KLineBatchQuery struct { + types.Exchange +} + +func (e *KLineBatchQuery) Query(ctx context.Context, symbol string, interval types.Interval, startTime, endTime time.Time) (c chan types.KLine, errC chan error) { + query := &AsyncTimeRangedBatchQuery{ + Type: types.KLine{}, + Limiter: nil, // the rate limiter is handled in the exchange query method + Q: func(startTime, endTime time.Time) (interface{}, error) { + return e.Exchange.QueryKLines(ctx, symbol, interval, types.KLineQueryOptions{ + StartTime: &startTime, + EndTime: &endTime, + }) + }, + T: func(obj interface{}) time.Time { + return time.Time(obj.(types.KLine).StartTime) + }, + ID: func(obj interface{}) string { + kline := obj.(types.KLine) + return strconv.FormatInt(kline.StartTime.UnixMilli(), 10) + }, + } + + c = make(chan types.KLine, 3000) + errC = query.Query(ctx, c, startTime, endTime) + return c, errC +} diff --git a/pkg/exchange/batch/margin_interest.go b/pkg/exchange/batch/margin_interest.go new file mode 100644 index 0000000..667c30e --- /dev/null +++ b/pkg/exchange/batch/margin_interest.go @@ -0,0 +1,36 @@ +package batch + +import ( + "context" + "time" + + "golang.org/x/time/rate" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type MarginInterestBatchQuery struct { + types.MarginHistoryService +} + +func (e *MarginInterestBatchQuery) Query(ctx context.Context, asset string, startTime, endTime time.Time) (c chan types.MarginInterest, errC chan error) { + query := &AsyncTimeRangedBatchQuery{ + Type: types.MarginInterest{}, + Limiter: rate.NewLimiter(rate.Every(5*time.Second), 2), + JumpIfEmpty: time.Hour * 24 * 30, + Q: func(startTime, endTime time.Time) (interface{}, error) { + return e.QueryInterestHistory(ctx, asset, &startTime, &endTime) + }, + T: func(obj interface{}) time.Time { + return time.Time(obj.(types.MarginInterest).Time) + }, + ID: func(obj interface{}) string { + interest := obj.(types.MarginInterest) + return interest.Time.String() + }, + } + + c = make(chan types.MarginInterest, 100) + errC = query.Query(ctx, c, startTime, endTime) + return c, errC +} diff --git a/pkg/exchange/batch/margin_liquidation.go b/pkg/exchange/batch/margin_liquidation.go new file mode 100644 index 0000000..1508ade --- /dev/null +++ b/pkg/exchange/batch/margin_liquidation.go @@ -0,0 +1,37 @@ +package batch + +import ( + "context" + "strconv" + "time" + + "golang.org/x/time/rate" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type MarginLiquidationBatchQuery struct { + types.MarginHistoryService +} + +func (e *MarginLiquidationBatchQuery) Query(ctx context.Context, startTime, endTime time.Time) (c chan types.MarginLiquidation, errC chan error) { + query := &AsyncTimeRangedBatchQuery{ + Type: types.MarginLiquidation{}, + Limiter: rate.NewLimiter(rate.Every(5*time.Second), 2), + JumpIfEmpty: time.Hour * 24 * 30, + Q: func(startTime, endTime time.Time) (interface{}, error) { + return e.QueryLiquidationHistory(ctx, &startTime, &endTime) + }, + T: func(obj interface{}) time.Time { + return time.Time(obj.(types.MarginLiquidation).UpdatedTime) + }, + ID: func(obj interface{}) string { + liquidation := obj.(types.MarginLiquidation) + return strconv.FormatUint(liquidation.OrderID, 10) + }, + } + + c = make(chan types.MarginLiquidation, 100) + errC = query.Query(ctx, c, startTime, endTime) + return c, errC +} diff --git a/pkg/exchange/batch/margin_loan.go b/pkg/exchange/batch/margin_loan.go new file mode 100644 index 0000000..521d5a6 --- /dev/null +++ b/pkg/exchange/batch/margin_loan.go @@ -0,0 +1,37 @@ +package batch + +import ( + "context" + "strconv" + "time" + + "golang.org/x/time/rate" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type MarginLoanBatchQuery struct { + types.MarginHistoryService +} + +func (e *MarginLoanBatchQuery) Query(ctx context.Context, asset string, startTime, endTime time.Time) (c chan types.MarginLoan, errC chan error) { + query := &AsyncTimeRangedBatchQuery{ + Type: types.MarginLoan{}, + Limiter: rate.NewLimiter(rate.Every(5*time.Second), 2), + JumpIfEmpty: time.Hour * 24 * 30, + Q: func(startTime, endTime time.Time) (interface{}, error) { + return e.QueryLoanHistory(ctx, asset, &startTime, &endTime) + }, + T: func(obj interface{}) time.Time { + return time.Time(obj.(types.MarginLoan).Time) + }, + ID: func(obj interface{}) string { + loan := obj.(types.MarginLoan) + return strconv.FormatUint(loan.TransactionID, 10) + }, + } + + c = make(chan types.MarginLoan, 100) + errC = query.Query(ctx, c, startTime, endTime) + return c, errC +} diff --git a/pkg/exchange/batch/margin_repay.go b/pkg/exchange/batch/margin_repay.go new file mode 100644 index 0000000..8fd69fd --- /dev/null +++ b/pkg/exchange/batch/margin_repay.go @@ -0,0 +1,37 @@ +package batch + +import ( + "context" + "strconv" + "time" + + "golang.org/x/time/rate" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type MarginRepayBatchQuery struct { + types.MarginHistoryService +} + +func (e *MarginRepayBatchQuery) Query(ctx context.Context, asset string, startTime, endTime time.Time) (c chan types.MarginRepay, errC chan error) { + query := &AsyncTimeRangedBatchQuery{ + Type: types.MarginRepay{}, + Limiter: rate.NewLimiter(rate.Every(5*time.Second), 2), + JumpIfEmpty: time.Hour * 24 * 30, + Q: func(startTime, endTime time.Time) (interface{}, error) { + return e.QueryRepayHistory(ctx, asset, &startTime, &endTime) + }, + T: func(obj interface{}) time.Time { + return time.Time(obj.(types.MarginRepay).Time) + }, + ID: func(obj interface{}) string { + loan := obj.(types.MarginRepay) + return strconv.FormatUint(loan.TransactionID, 10) + }, + } + + c = make(chan types.MarginRepay, 100) + errC = query.Query(ctx, c, startTime, endTime) + return c, errC +} diff --git a/pkg/exchange/batch/option.go b/pkg/exchange/batch/option.go new file mode 100644 index 0000000..67f18e6 --- /dev/null +++ b/pkg/exchange/batch/option.go @@ -0,0 +1,12 @@ +package batch + +import "time" + +type Option func(query *AsyncTimeRangedBatchQuery) + +// JumpIfEmpty jump the startTime + duration when the result is empty +func JumpIfEmpty(duration time.Duration) Option { + return func(query *AsyncTimeRangedBatchQuery) { + query.JumpIfEmpty = duration + } +} diff --git a/pkg/exchange/batch/reward.go b/pkg/exchange/batch/reward.go new file mode 100644 index 0000000..e3c68dd --- /dev/null +++ b/pkg/exchange/batch/reward.go @@ -0,0 +1,34 @@ +package batch + +import ( + "context" + "time" + + "golang.org/x/time/rate" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type RewardBatchQuery struct { + Service types.ExchangeRewardService +} + +func (q *RewardBatchQuery) Query(ctx context.Context, startTime, endTime time.Time) (c chan types.Reward, errC chan error) { + query := &AsyncTimeRangedBatchQuery{ + Type: types.Reward{}, + Limiter: rate.NewLimiter(rate.Every(5*time.Second), 2), + Q: func(startTime, endTime time.Time) (interface{}, error) { + return q.Service.QueryRewards(ctx, startTime) + }, + T: func(obj interface{}) time.Time { + return time.Time(obj.(types.Reward).CreatedAt) + }, + ID: func(obj interface{}) string { + return obj.(types.Reward).UUID + }, + } + + c = make(chan types.Reward, 500) + errC = query.Query(ctx, c, startTime, endTime) + return c, errC +} diff --git a/pkg/exchange/batch/time_range_query.go b/pkg/exchange/batch/time_range_query.go new file mode 100644 index 0000000..0364307 --- /dev/null +++ b/pkg/exchange/batch/time_range_query.go @@ -0,0 +1,125 @@ +package batch + +import ( + "context" + "reflect" + "sort" + "time" + + "github.com/sirupsen/logrus" + "golang.org/x/time/rate" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +var log = logrus.WithField("component", "batch") + +type AsyncTimeRangedBatchQuery struct { + // Type is the object type of the result + Type interface{} + + // Limiter is the rate limiter for each query + Limiter *rate.Limiter + + // Q is the remote query function + Q func(startTime, endTime time.Time) (interface{}, error) + + // T function returns time of an object + T func(obj interface{}) time.Time + + // ID returns the ID of the object + ID func(obj interface{}) string + + // JumpIfEmpty jump the startTime + duration when the result is empty + JumpIfEmpty time.Duration +} + +func (q *AsyncTimeRangedBatchQuery) Query(ctx context.Context, ch interface{}, since, until time.Time) chan error { + errC := make(chan error, 1) + cRef := reflect.ValueOf(ch) + // cRef := reflect.MakeChan(reflect.TypeOf(q.Type), 100) + startTime := since + endTime := until + + go func() { + defer cRef.Close() + defer close(errC) + + idMap := make(map[string]struct{}, 100) + for startTime.Before(endTime) { + if q.Limiter != nil { + if err := q.Limiter.Wait(ctx); err != nil { + errC <- err + return + } + } + + log.Debugf("batch querying %T: %v <=> %v", q.Type, startTime, endTime) + + queryProfiler := util.StartTimeProfile("remoteQuery") + + sliceInf, err := q.Q(startTime, endTime) + if err != nil { + errC <- err + return + } + + listRef := reflect.ValueOf(sliceInf) + listLen := listRef.Len() + log.Debugf("batch querying %T: %d remote records", q.Type, listLen) + + queryProfiler.StopAndLog(log.Debugf) + + if listLen == 0 { + if q.JumpIfEmpty > 0 { + startTime = startTime.Add(q.JumpIfEmpty) + if startTime.Before(endTime) { + log.Debugf("batch querying %T: empty records jump to %s", q.Type, startTime) + continue + } + } + + log.Debugf("batch querying %T: empty records, query is completed", q.Type) + return + } + + // sort by time + sort.Slice(listRef.Interface(), func(i, j int) bool { + a := listRef.Index(i) + b := listRef.Index(j) + tA := q.T(a.Interface()) + tB := q.T(b.Interface()) + return tA.Before(tB) + }) + + sentAny := false + for i := 0; i < listLen; i++ { + item := listRef.Index(i) + entryTime := q.T(item.Interface()) + if entryTime.Before(since) || entryTime.After(until) { + continue + } + + obj := item.Interface() + id := q.ID(obj) + if _, exists := idMap[id]; exists { + log.Debugf("batch querying %T: ignore duplicated record, id = %s", q.Type, id) + continue + } + + idMap[id] = struct{}{} + + cRef.Send(item) + sentAny = true + startTime = entryTime + } + + if !sentAny { + log.Debugf("batch querying %T: %d/%d records are not sent", q.Type, listLen, listLen) + return + } + } + }() + + return errC +} diff --git a/pkg/exchange/batch/time_range_query_test.go b/pkg/exchange/batch/time_range_query_test.go new file mode 100644 index 0000000..e3d6634 --- /dev/null +++ b/pkg/exchange/batch/time_range_query_test.go @@ -0,0 +1,45 @@ +package batch + +import ( + "context" + "strconv" + "testing" + "time" +) + +func Test_TimeRangedQuery(t *testing.T) { + startTime := time.Date(2021, time.January, 1, 0, 0, 0, 0, time.UTC) + endTime := time.Date(2021, time.January, 2, 0, 0, 0, 0, time.UTC) + q := &AsyncTimeRangedBatchQuery{ + Type: time.Time{}, + T: func(obj interface{}) time.Time { + return obj.(time.Time) + }, + ID: func(obj interface{}) string { + return strconv.FormatInt(obj.(time.Time).UnixMilli(), 10) + }, + Q: func(startTime, endTime time.Time) (interface{}, error) { + var cnt = 0 + var data []time.Time + for startTime.Before(endTime) && cnt < 5 { + d := startTime + data = append(data, d) + cnt++ + startTime = startTime.Add(time.Minute) + } + t.Logf("data: %v", data) + return data, nil + }, + } + + ch := make(chan time.Time, 100) + + // consumer + go func() { + for d := range ch { + _ = d + } + }() + errC := q.Query(context.Background(), ch, startTime, endTime) + <-errC +} diff --git a/pkg/exchange/batch/trade.go b/pkg/exchange/batch/trade.go new file mode 100644 index 0000000..b0e75b7 --- /dev/null +++ b/pkg/exchange/batch/trade.go @@ -0,0 +1,55 @@ +package batch + +import ( + "context" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +var closedErrChan = make(chan error) + +func init() { + close(closedErrChan) +} + +type TradeBatchQuery struct { + types.ExchangeTradeHistoryService +} + +func (e TradeBatchQuery) Query(ctx context.Context, symbol string, options *types.TradeQueryOptions, opts ...Option) (c chan types.Trade, errC chan error) { + if options.EndTime == nil { + now := time.Now() + options.EndTime = &now + } + + startTime := *options.StartTime + endTime := *options.EndTime + query := &AsyncTimeRangedBatchQuery{ + Type: types.Trade{}, + Q: func(startTime, endTime time.Time) (interface{}, error) { + options.StartTime = &startTime + options.EndTime = &endTime + return e.ExchangeTradeHistoryService.QueryTrades(ctx, symbol, options) + }, + T: func(obj interface{}) time.Time { + return time.Time(obj.(types.Trade).Time) + }, + ID: func(obj interface{}) string { + trade := obj.(types.Trade) + if trade.ID > options.LastTradeID { + options.LastTradeID = trade.ID + } + return trade.Key().String() + }, + JumpIfEmpty: 24 * time.Hour, + } + + for _, opt := range opts { + opt(query) + } + + c = make(chan types.Trade, 100) + errC = query.Query(ctx, c, startTime, endTime) + return c, errC +} diff --git a/pkg/exchange/batch/trade_test.go b/pkg/exchange/batch/trade_test.go new file mode 100644 index 0000000..32ae750 --- /dev/null +++ b/pkg/exchange/batch/trade_test.go @@ -0,0 +1,140 @@ +package batch + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types/mocks" +) + +func Test_TradeBatchQuery(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + var ( + ctx = context.Background() + timeNow = time.Now() + startTime = timeNow.Add(-24 * time.Hour) + endTime = timeNow + expSymbol = "BTCUSDT" + queryTrades1 = []types.Trade{ + { + ID: 1, + Time: types.Time(startTime.Add(time.Hour)), + }, + } + queryTrades2 = []types.Trade{ + { + ID: 2, + Time: types.Time(startTime.Add(time.Hour)), + }, + { + ID: 3, + Time: types.Time(startTime.Add(2 * time.Hour)), + }, + } + emptyTrades = []types.Trade{} + allRes = append(queryTrades1, queryTrades2...) + ) + + t.Run("succeeds", func(t *testing.T) { + var ( + expOptions = &types.TradeQueryOptions{ + StartTime: &startTime, + EndTime: &endTime, + LastTradeID: 0, + Limit: 50, + } + mockExchange = mocks.NewMockExchangeTradeHistoryService(ctrl) + ) + + mockExchange.EXPECT().QueryTrades(ctx, expSymbol, expOptions).DoAndReturn( + func(ctx context.Context, symbol string, options *types.TradeQueryOptions) ([]types.Trade, error) { + assert.Equal(t, startTime, *options.StartTime) + assert.Equal(t, endTime, *options.EndTime) + assert.Equal(t, uint64(0), options.LastTradeID) + assert.Equal(t, expOptions.Limit, options.Limit) + return queryTrades1, nil + }).Times(1) + mockExchange.EXPECT().QueryTrades(ctx, expSymbol, expOptions).DoAndReturn( + func(ctx context.Context, symbol string, options *types.TradeQueryOptions) ([]types.Trade, error) { + assert.Equal(t, queryTrades1[0].Time.Time(), *options.StartTime) + assert.Equal(t, endTime, *options.EndTime) + assert.Equal(t, queryTrades1[0].ID, options.LastTradeID) + assert.Equal(t, expOptions.Limit, options.Limit) + return queryTrades2, nil + }).Times(1) + mockExchange.EXPECT().QueryTrades(ctx, expSymbol, expOptions).DoAndReturn( + func(ctx context.Context, symbol string, options *types.TradeQueryOptions) ([]types.Trade, error) { + assert.Equal(t, queryTrades2[1].Time.Time(), *options.StartTime) + assert.Equal(t, endTime, *options.EndTime) + assert.Equal(t, queryTrades2[1].ID, options.LastTradeID) + assert.Equal(t, expOptions.Limit, options.Limit) + return emptyTrades, nil + }).Times(1) + + tradeBatchQuery := &TradeBatchQuery{ExchangeTradeHistoryService: mockExchange} + + resCh, errC := tradeBatchQuery.Query(ctx, expSymbol, expOptions) + wg := sync.WaitGroup{} + wg.Add(1) + rcvCount := 0 + go func() { + defer wg.Done() + for ch := range resCh { + assert.Equal(t, allRes[rcvCount], ch) + rcvCount++ + } + assert.NoError(t, <-errC) + }() + wg.Wait() + assert.Equal(t, rcvCount, len(allRes)) + }) + + t.Run("failed to call query trades", func(t *testing.T) { + var ( + expOptions = &types.TradeQueryOptions{ + StartTime: &startTime, + EndTime: &endTime, + LastTradeID: 0, + Limit: 50, + } + mockExchange = mocks.NewMockExchangeTradeHistoryService(ctrl) + unknownErr = errors.New("unknown err") + ) + + mockExchange.EXPECT().QueryTrades(ctx, expSymbol, expOptions).DoAndReturn( + func(ctx context.Context, symbol string, options *types.TradeQueryOptions) ([]types.Trade, error) { + assert.Equal(t, startTime, *options.StartTime) + assert.Equal(t, endTime, *options.EndTime) + assert.Equal(t, uint64(0), options.LastTradeID) + assert.Equal(t, expOptions.Limit, options.Limit) + return nil, unknownErr + }).Times(1) + + tradeBatchQuery := &TradeBatchQuery{ExchangeTradeHistoryService: mockExchange} + + resCh, errC := tradeBatchQuery.Query(ctx, expSymbol, expOptions) + wg := sync.WaitGroup{} + wg.Add(1) + rcvCount := 0 + go func() { + defer wg.Done() + for ch := range resCh { + assert.Equal(t, allRes[rcvCount], ch) + rcvCount++ + } + assert.Equal(t, 0, rcvCount) + assert.Equal(t, unknownErr, <-errC) + }() + wg.Wait() + assert.Equal(t, rcvCount, 0) + }) +} diff --git a/pkg/exchange/batch/withdraw.go b/pkg/exchange/batch/withdraw.go new file mode 100644 index 0000000..93b7655 --- /dev/null +++ b/pkg/exchange/batch/withdraw.go @@ -0,0 +1,36 @@ +package batch + +import ( + "context" + "time" + + "golang.org/x/time/rate" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type WithdrawBatchQuery struct { + types.ExchangeTransferService +} + +func (e *WithdrawBatchQuery) Query(ctx context.Context, asset string, startTime, endTime time.Time) (c chan types.Withdraw, errC chan error) { + query := &AsyncTimeRangedBatchQuery{ + Type: types.Withdraw{}, + Limiter: rate.NewLimiter(rate.Every(5*time.Second), 2), + JumpIfEmpty: time.Hour * 24 * 80, + Q: func(startTime, endTime time.Time) (interface{}, error) { + return e.ExchangeTransferService.QueryWithdrawHistory(ctx, asset, startTime, endTime) + }, + T: func(obj interface{}) time.Time { + return time.Time(obj.(types.Withdraw).ApplyTime) + }, + ID: func(obj interface{}) string { + withdraw := obj.(types.Withdraw) + return withdraw.TransactionID + }, + } + + c = make(chan types.Withdraw, 100) + errC = query.Query(ctx, c, startTime, endTime) + return c, errC +} diff --git a/pkg/exchange/batch/withdraw_test.go b/pkg/exchange/batch/withdraw_test.go new file mode 100644 index 0000000..4c8dca9 --- /dev/null +++ b/pkg/exchange/batch/withdraw_test.go @@ -0,0 +1,38 @@ +package batch + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/binance" + "git.qtrade.icu/lychiyu/qbtrade/pkg/testutil" +) + +func TestWithdrawBatchQuery(t *testing.T) { + key, secret, ok := testutil.IntegrationTestConfigured(t, "BINANCE") + if !ok { + t.Skip("binance api is not set") + } + + ex := binance.New(key, secret) + q := WithdrawBatchQuery{ + ExchangeTransferService: ex, + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + now := time.Now() + startTime := now.AddDate(0, -6, 0) + endTime := now + dataC, errC := q.Query(ctx, "", startTime, endTime) + + for withdraw := range dataC { + t.Logf("%+v", withdraw) + } + + err := <-errC + assert.NoError(t, err) +} diff --git a/pkg/exchange/binance/binanceapi/alias.go b/pkg/exchange/binance/binanceapi/alias.go new file mode 100644 index 0000000..0f6aa9b --- /dev/null +++ b/pkg/exchange/binance/binanceapi/alias.go @@ -0,0 +1,49 @@ +package binanceapi + +import ( + "github.com/adshao/go-binance/v2" +) + +type SideType = binance.SideType + +const SideTypeBuy = binance.SideTypeBuy +const SideTypeSell = binance.SideTypeSell + +type OrderType = binance.OrderType + +const ( + OrderTypeLimit OrderType = binance.OrderTypeLimit + OrderTypeMarket OrderType = binance.OrderTypeMarket + OrderTypeLimitMaker OrderType = binance.OrderTypeLimitMaker + OrderTypeStopLoss OrderType = binance.OrderTypeStopLoss + OrderTypeStopLossLimit OrderType = binance.OrderTypeStopLossLimit + OrderTypeTakeProfit OrderType = binance.OrderTypeTakeProfit + OrderTypeTakeProfitLimit OrderType = binance.OrderTypeTakeProfitLimit +) + +type OrderStatusType = binance.OrderStatusType + +const ( + OrderStatusTypeNew OrderStatusType = binance.OrderStatusTypeNew + OrderStatusTypePartiallyFilled OrderStatusType = binance.OrderStatusTypePartiallyFilled + OrderStatusTypeFilled OrderStatusType = binance.OrderStatusTypeFilled + OrderStatusTypeCanceled OrderStatusType = binance.OrderStatusTypeCanceled + OrderStatusTypePendingCancel OrderStatusType = binance.OrderStatusTypePendingCancel + OrderStatusTypeRejected OrderStatusType = binance.OrderStatusTypeRejected + OrderStatusTypeExpired OrderStatusType = binance.OrderStatusTypeExpired +) + +type CancelReplaceModeType string + +const ( + StopOnFailure CancelReplaceModeType = "STOP_ON_FAILURE" + AllowFailure CancelReplaceModeType = "ALLOW_FAILURE" +) + +type OrderRespType string + +const ( + Ack OrderRespType = "ACK" + Result OrderRespType = "RESULT" + Full OrderRespType = "FULL" +) diff --git a/pkg/exchange/binance/binanceapi/cancel_replace_request.go b/pkg/exchange/binance/binanceapi/cancel_replace_request.go new file mode 100644 index 0000000..19a5b59 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/cancel_replace_request.go @@ -0,0 +1,48 @@ +package binanceapi + +import ( + "github.com/adshao/go-binance/v2" + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type CancelReplaceSpotOrderData struct { + CancelResult string `json:"cancelResult"` + NewOrderResult string `json:"newOrderResult"` + NewOrderResponse *binance.Order `json:"newOrderResponse"` +} + +type CancelReplaceSpotOrderResponse struct { + Code int `json:"code,omitempty"` + Msg string `json:"msg,omitempty"` + Data *CancelReplaceSpotOrderData `json:"data"` +} + +//go:generate requestgen -method POST -url "/api/v3/order/cancelReplace" -type CancelReplaceSpotOrderRequest -responseType .CancelReplaceSpotOrderResponse +type CancelReplaceSpotOrderRequest struct { + client requestgen.AuthenticatedAPIClient + symbol string `param:"symbol"` + side SideType `param:"side"` + cancelReplaceMode CancelReplaceModeType `param:"cancelReplaceMode"` + timeInForce string `param:"timeInForce"` + quantity string `param:"quantity"` + quoteOrderQty string `param:"quoteOrderQty"` + price string `param:"price"` + cancelNewClientOrderId string `param:"cancelNewClientOrderId"` + cancelOrigClientOrderId string `param:"cancelOrigClientOrderId"` + cancelOrderId int `param:"cancelOrderId"` + newClientOrderId string `param:"newClientOrderId"` + strategyId int `param:"strategyId"` + strategyType int `param:"strategyType"` + stopPrice string `param:"stopPrice"` + trailingDelta int `param:"trailingDelta"` + icebergQty string `param:"icebergQty"` + newOrderRespType OrderRespType `param:"newOrderRespType"` + recvWindow int `param:"recvWindow"` + timestamp types.MillisecondTimestamp `param:"timestamp"` +} + +func (c *RestClient) NewCancelReplaceSpotOrderRequest() *CancelReplaceSpotOrderRequest { + return &CancelReplaceSpotOrderRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/cancel_replace_spot_order_request_requestgen.go b/pkg/exchange/binance/binanceapi/cancel_replace_spot_order_request_requestgen.go new file mode 100644 index 0000000..da627a1 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/cancel_replace_spot_order_request_requestgen.go @@ -0,0 +1,351 @@ +// Code generated by "requestgen -method POST -url /api/v3/order/cancelReplace -type CancelReplaceSpotOrderRequest -responseType .CancelReplaceSpotOrderResponse"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "github.com/adshao/go-binance/v2" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "net/url" + "reflect" + "regexp" +) + +func (c *CancelReplaceSpotOrderRequest) Symbol(symbol string) *CancelReplaceSpotOrderRequest { + c.symbol = symbol + return c +} + +func (c *CancelReplaceSpotOrderRequest) Side(side binance.SideType) *CancelReplaceSpotOrderRequest { + c.side = side + return c +} + +func (c *CancelReplaceSpotOrderRequest) CancelReplaceMode(cancelReplaceMode CancelReplaceModeType) *CancelReplaceSpotOrderRequest { + c.cancelReplaceMode = cancelReplaceMode + return c +} + +func (c *CancelReplaceSpotOrderRequest) TimeInForce(timeInForce string) *CancelReplaceSpotOrderRequest { + c.timeInForce = timeInForce + return c +} + +func (c *CancelReplaceSpotOrderRequest) Quantity(quantity string) *CancelReplaceSpotOrderRequest { + c.quantity = quantity + return c +} + +func (c *CancelReplaceSpotOrderRequest) QuoteOrderQty(quoteOrderQty string) *CancelReplaceSpotOrderRequest { + c.quoteOrderQty = quoteOrderQty + return c +} + +func (c *CancelReplaceSpotOrderRequest) Price(price string) *CancelReplaceSpotOrderRequest { + c.price = price + return c +} + +func (c *CancelReplaceSpotOrderRequest) CancelNewClientOrderId(cancelNewClientOrderId string) *CancelReplaceSpotOrderRequest { + c.cancelNewClientOrderId = cancelNewClientOrderId + return c +} + +func (c *CancelReplaceSpotOrderRequest) CancelOrigClientOrderId(cancelOrigClientOrderId string) *CancelReplaceSpotOrderRequest { + c.cancelOrigClientOrderId = cancelOrigClientOrderId + return c +} + +func (c *CancelReplaceSpotOrderRequest) CancelOrderId(cancelOrderId int) *CancelReplaceSpotOrderRequest { + c.cancelOrderId = cancelOrderId + return c +} + +func (c *CancelReplaceSpotOrderRequest) NewClientOrderId(newClientOrderId string) *CancelReplaceSpotOrderRequest { + c.newClientOrderId = newClientOrderId + return c +} + +func (c *CancelReplaceSpotOrderRequest) StrategyId(strategyId int) *CancelReplaceSpotOrderRequest { + c.strategyId = strategyId + return c +} + +func (c *CancelReplaceSpotOrderRequest) StrategyType(strategyType int) *CancelReplaceSpotOrderRequest { + c.strategyType = strategyType + return c +} + +func (c *CancelReplaceSpotOrderRequest) StopPrice(stopPrice string) *CancelReplaceSpotOrderRequest { + c.stopPrice = stopPrice + return c +} + +func (c *CancelReplaceSpotOrderRequest) TrailingDelta(trailingDelta int) *CancelReplaceSpotOrderRequest { + c.trailingDelta = trailingDelta + return c +} + +func (c *CancelReplaceSpotOrderRequest) IcebergQty(icebergQty string) *CancelReplaceSpotOrderRequest { + c.icebergQty = icebergQty + return c +} + +func (c *CancelReplaceSpotOrderRequest) NewOrderRespType(newOrderRespType OrderRespType) *CancelReplaceSpotOrderRequest { + c.newOrderRespType = newOrderRespType + return c +} + +func (c *CancelReplaceSpotOrderRequest) RecvWindow(recvWindow int) *CancelReplaceSpotOrderRequest { + c.recvWindow = recvWindow + return c +} + +func (c *CancelReplaceSpotOrderRequest) Timestamp(timestamp types.MillisecondTimestamp) *CancelReplaceSpotOrderRequest { + c.timestamp = timestamp + return c +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (c *CancelReplaceSpotOrderRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (c *CancelReplaceSpotOrderRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := c.symbol + + // assign parameter of symbol + params["symbol"] = symbol + // check side field -> json key side + side := c.side + + // assign parameter of side + params["side"] = side + // check cancelReplaceMode field -> json key cancelReplaceMode + cancelReplaceMode := c.cancelReplaceMode + + // TEMPLATE check-valid-values + switch cancelReplaceMode { + case StopOnFailure, AllowFailure: + params["cancelReplaceMode"] = cancelReplaceMode + + default: + return nil, fmt.Errorf("cancelReplaceMode value %v is invalid", cancelReplaceMode) + + } + // END TEMPLATE check-valid-values + + // assign parameter of cancelReplaceMode + params["cancelReplaceMode"] = cancelReplaceMode + // check timeInForce field -> json key timeInForce + timeInForce := c.timeInForce + + // assign parameter of timeInForce + params["timeInForce"] = timeInForce + // check quantity field -> json key quantity + quantity := c.quantity + + // assign parameter of quantity + params["quantity"] = quantity + // check quoteOrderQty field -> json key quoteOrderQty + quoteOrderQty := c.quoteOrderQty + + // assign parameter of quoteOrderQty + params["quoteOrderQty"] = quoteOrderQty + // check price field -> json key price + price := c.price + + // assign parameter of price + params["price"] = price + // check cancelNewClientOrderId field -> json key cancelNewClientOrderId + cancelNewClientOrderId := c.cancelNewClientOrderId + + // assign parameter of cancelNewClientOrderId + params["cancelNewClientOrderId"] = cancelNewClientOrderId + // check cancelOrigClientOrderId field -> json key cancelOrigClientOrderId + cancelOrigClientOrderId := c.cancelOrigClientOrderId + + // assign parameter of cancelOrigClientOrderId + params["cancelOrigClientOrderId"] = cancelOrigClientOrderId + // check cancelOrderId field -> json key cancelOrderId + cancelOrderId := c.cancelOrderId + + // assign parameter of cancelOrderId + params["cancelOrderId"] = cancelOrderId + // check newClientOrderId field -> json key newClientOrderId + newClientOrderId := c.newClientOrderId + + // assign parameter of newClientOrderId + params["newClientOrderId"] = newClientOrderId + // check strategyId field -> json key strategyId + strategyId := c.strategyId + + // assign parameter of strategyId + params["strategyId"] = strategyId + // check strategyType field -> json key strategyType + strategyType := c.strategyType + + // assign parameter of strategyType + params["strategyType"] = strategyType + // check stopPrice field -> json key stopPrice + stopPrice := c.stopPrice + + // assign parameter of stopPrice + params["stopPrice"] = stopPrice + // check trailingDelta field -> json key trailingDelta + trailingDelta := c.trailingDelta + + // assign parameter of trailingDelta + params["trailingDelta"] = trailingDelta + // check icebergQty field -> json key icebergQty + icebergQty := c.icebergQty + + // assign parameter of icebergQty + params["icebergQty"] = icebergQty + // check newOrderRespType field -> json key newOrderRespType + newOrderRespType := c.newOrderRespType + + // TEMPLATE check-valid-values + switch newOrderRespType { + case Ack, Result, Full: + params["newOrderRespType"] = newOrderRespType + + default: + return nil, fmt.Errorf("newOrderRespType value %v is invalid", newOrderRespType) + + } + // END TEMPLATE check-valid-values + + // assign parameter of newOrderRespType + params["newOrderRespType"] = newOrderRespType + // check recvWindow field -> json key recvWindow + recvWindow := c.recvWindow + + // assign parameter of recvWindow + params["recvWindow"] = recvWindow + // check timestamp field -> json key timestamp + timestamp := c.timestamp + + // assign parameter of timestamp + params["timestamp"] = timestamp + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (c *CancelReplaceSpotOrderRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := c.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if c.isVarSlice(_v) { + c.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (c *CancelReplaceSpotOrderRequest) GetParametersJSON() ([]byte, error) { + params, err := c.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (c *CancelReplaceSpotOrderRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (c *CancelReplaceSpotOrderRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (c *CancelReplaceSpotOrderRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (c *CancelReplaceSpotOrderRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (c *CancelReplaceSpotOrderRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := c.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (c *CancelReplaceSpotOrderRequest) Do(ctx context.Context) (*CancelReplaceSpotOrderResponse, error) { + + params, err := c.GetParameters() + if err != nil { + return nil, err + } + query := url.Values{} + + apiURL := "/api/v3/order/cancelReplace" + + req, err := c.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := c.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse CancelReplaceSpotOrderResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return &apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/client.go b/pkg/exchange/binance/binanceapi/client.go new file mode 100644 index 0000000..63ba773 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/client.go @@ -0,0 +1,257 @@ +package binanceapi + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "crypto/tls" + "encoding/json" + "fmt" + "net" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/c9s/requestgen" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const defaultHTTPTimeout = time.Second * 10 +const RestBaseURL = "https://api.binance.com" +const SandboxRestBaseURL = "https://testnet.binance.vision" +const DebugRequestResponse = false + +var dialer = &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, +} + +var defaultTransport = &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: dialer.DialContext, + MaxIdleConns: 100, + MaxConnsPerHost: 100, + MaxIdleConnsPerHost: 100, + // TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper), + ExpectContinueTimeout: 0, + ForceAttemptHTTP2: true, + TLSClientConfig: &tls.Config{}, +} + +var DefaultHttpClient = &http.Client{ + Timeout: defaultHTTPTimeout, + Transport: defaultTransport, +} + +type RestClient struct { + requestgen.BaseAPIClient + + Key, Secret string + + recvWindow int + timeOffset int64 +} + +func NewClient(baseURL string) *RestClient { + if len(baseURL) == 0 { + baseURL = RestBaseURL + } + + u, err := url.Parse(baseURL) + if err != nil { + panic(err) + } + + client := &RestClient{ + BaseAPIClient: requestgen.BaseAPIClient{ + BaseURL: u, + HttpClient: DefaultHttpClient, + }, + } + + // client.AccountService = &AccountService{client: client} + return client +} + +func (c *RestClient) Auth(key, secret string) { + c.Key = key + // pragma: allowlist nextline secret + c.Secret = secret +} + +// NewRequest create new API request. Relative url can be provided in refURL. +func (c *RestClient) NewRequest(ctx context.Context, method, refURL string, params url.Values, payload interface{}) (*http.Request, error) { + rel, err := url.Parse(refURL) + if err != nil { + return nil, err + } + + if params != nil { + rel.RawQuery = params.Encode() + } + + body, err := castPayload(payload) + if err != nil { + return nil, err + } + + pathURL := c.BaseURL.ResolveReference(rel) + return http.NewRequestWithContext(ctx, method, pathURL.String(), bytes.NewReader(body)) +} + +func (c *RestClient) SetTimeOffsetFromServer(ctx context.Context) error { + req, err := c.NewRequest(ctx, "GET", "/api/v3/time", nil, nil) + if err != nil { + return err + } + + resp, err := c.SendRequest(req) + if err != nil { + return err + } + + var a struct { + ServerTime types.MillisecondTimestamp `json:"serverTime"` + } + + err = resp.DecodeJSON(&a) + if err != nil { + return err + } + + c.timeOffset = currentTimestamp() - a.ServerTime.Time().UnixMilli() + return nil +} + +func (c *RestClient) SendRequest(req *http.Request) (*requestgen.Response, error) { + if DebugRequestResponse { + logrus.Debugf("-> request: %+v", req) + response, err := c.BaseAPIClient.SendRequest(req) + logrus.Debugf("<- response: %s", string(response.Body)) + return response, err + } + + return c.BaseAPIClient.SendRequest(req) +} + +// newAuthenticatedRequest creates new http request for authenticated routes. +func (c *RestClient) NewAuthenticatedRequest(ctx context.Context, method, refURL string, params url.Values, payload interface{}) (*http.Request, error) { + if len(c.Key) == 0 { + return nil, errors.New("empty api key") + } + + if len(c.Secret) == 0 { + return nil, errors.New("empty api secret") + } + + rel, err := url.Parse(refURL) + if err != nil { + return nil, err + } + + if params == nil { + params = url.Values{} + } + + if c.recvWindow > 0 { + params.Set("recvWindow", strconv.Itoa(c.recvWindow)) + } + + params.Set("timestamp", strconv.FormatInt(currentTimestamp()-c.timeOffset, 10)) + rawQuery := params.Encode() + + pathURL := c.BaseURL.ResolveReference(rel) + body, err := castPayload(payload) + if err != nil { + return nil, err + } + + toSign := rawQuery + string(body) + signature := sign(c.Secret, toSign) + + // sv is the extra url parameters that we need to attach to the request + sv := url.Values{} + sv.Set("signature", signature) + if rawQuery == "" { + rawQuery = sv.Encode() + } else { + rawQuery = rawQuery + "&" + sv.Encode() + } + + if rawQuery != "" { + pathURL.RawQuery = rawQuery + } + + req, err := http.NewRequestWithContext(ctx, method, pathURL.String(), bytes.NewReader(body)) + if err != nil { + return nil, err + } + + // if our payload body is not an empty string + if len(body) > 0 { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + + req.Header.Add("Accept", "application/json") + + // Build authentication headers + req.Header.Add("X-MBX-APIKEY", c.Key) + return req, nil +} + +// sign uses sha256 to sign the payload with the given secret +func sign(secret, payload string) string { + var sig = hmac.New(sha256.New, []byte(secret)) + _, err := sig.Write([]byte(payload)) + if err != nil { + return "" + } + + return fmt.Sprintf("%x", sig.Sum(nil)) +} + +func currentTimestamp() int64 { + return FormatTimestamp(time.Now()) +} + +// FormatTimestamp formats a time into Unix timestamp in milliseconds, as requested by Binance. +func FormatTimestamp(t time.Time) int64 { + return t.UnixNano() / int64(time.Millisecond) +} + +func castPayload(payload interface{}) ([]byte, error) { + if payload != nil { + switch v := payload.(type) { + case string: + return []byte(v), nil + + case []byte: + return v, nil + + case map[string]interface{}: + var params = url.Values{} + for a, b := range v { + params.Add(a, fmt.Sprintf("%v", b)) + } + + return []byte(params.Encode()), nil + + default: + body, err := json.Marshal(v) + return body, err + } + } + + return nil, nil +} + +type APIResponse struct { + Code string `json:"code"` + Message string `json:"msg"` + Data json.RawMessage `json:"data"` +} diff --git a/pkg/exchange/binance/binanceapi/client_test.go b/pkg/exchange/binance/binanceapi/client_test.go new file mode 100644 index 0000000..868909e --- /dev/null +++ b/pkg/exchange/binance/binanceapi/client_test.go @@ -0,0 +1,262 @@ +package binanceapi + +import ( + "context" + "log" + "net/http/httputil" + "os" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/testutil" +) + +func getTestClientOrSkip(t *testing.T) *RestClient { + if b, _ := strconv.ParseBool(os.Getenv("CI")); b { + t.Skip("skip test for CI") + } + + key, secret, ok := testutil.IntegrationTestConfigured(t, "BINANCE") + if !ok { + t.SkipNow() + return nil + } + + client := NewClient("") + client.Auth(key, secret) + return client +} + +func TestClient_GetTradeFeeRequest(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + + err := client.SetTimeOffsetFromServer(ctx) + assert.NoError(t, err) + + req := client.NewGetTradeFeeRequest() + tradeFees, err := req.Do(ctx) + assert.NoError(t, err) + assert.NotEmpty(t, tradeFees) + t.Logf("tradeFees: %+v", tradeFees) +} + +func TestClient_GetDepositAddressRequest(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + + err := client.SetTimeOffsetFromServer(ctx) + assert.NoError(t, err) + + req := client.NewGetDepositAddressRequest() + req.Coin("BTC") + address, err := req.Do(ctx) + assert.NoError(t, err) + assert.NotNil(t, address) + assert.NotEmpty(t, address.Url) + assert.NotEmpty(t, address.Address) + t.Logf("deposit address: %+v", address) +} + +func TestClient_GetDepositHistoryRequest(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + + err := client.SetTimeOffsetFromServer(ctx) + assert.NoError(t, err) + + req := client.NewGetDepositHistoryRequest() + history, err := req.Do(ctx) + assert.NoError(t, err) + assert.NotNil(t, history) + assert.NotEmpty(t, history) + t.Logf("deposit history: %+v", history) +} + +func TestClient_NewSpotRebateHistoryRequest(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + + err := client.SetTimeOffsetFromServer(ctx) + assert.NoError(t, err) + + req := client.NewGetSpotRebateHistoryRequest() + history, err := req.Do(ctx) + assert.NoError(t, err) + assert.NotNil(t, history) + assert.NotEmpty(t, history) + t.Logf("spot rebate history: %+v", history) +} + +func TestClient_NewGetMarginInterestRateHistoryRequest(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + + err := client.SetTimeOffsetFromServer(ctx) + assert.NoError(t, err) + + req := client.NewGetMarginInterestRateHistoryRequest() + req.Asset("BTC") + history, err := req.Do(ctx) + assert.NoError(t, err) + assert.NotNil(t, history) + assert.NotEmpty(t, history) + t.Logf("interest rate history: %+v", history) +} + +func TestClient_privateCall(t *testing.T) { + if b, _ := strconv.ParseBool(os.Getenv("CI")); b { + t.Skip("skip test for CI") + } + + key, secret, ok := testutil.IntegrationTestConfigured(t, "BINANCE") + if !ok { + t.SkipNow() + } + + client := NewClient("") + client.Auth(key, secret) + + ctx := context.Background() + + err := client.SetTimeOffsetFromServer(ctx) + assert.NoError(t, err) + + req, err := client.NewAuthenticatedRequest(ctx, "GET", "/sapi/v1/asset/tradeFee", nil, nil) + assert.NoError(t, err) + assert.NotNil(t, req) + + resp, err := client.SendRequest(req) + if assert.NoError(t, err) { + var feeStructs []struct { + Symbol string `json:"symbol"` + MakerCommission string `json:"makerCommission"` + TakerCommission string `json:"takerCommission"` + } + err = resp.DecodeJSON(&feeStructs) + if assert.NoError(t, err) { + assert.NotEmpty(t, feeStructs) + } + } else { + dump, _ := httputil.DumpRequest(req, true) + log.Printf("request: %s", dump) + } +} + +func TestClient_setTimeOffsetFromServer(t *testing.T) { + if b, _ := strconv.ParseBool(os.Getenv("CI")); b { + t.Skip("skip test for CI") + } + + client := NewClient("") + err := client.SetTimeOffsetFromServer(context.Background()) + assert.NoError(t, err) +} + +func TestClient_NewTransferAssetRequest(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + + err := client.SetTimeOffsetFromServer(ctx) + assert.NoError(t, err) + + req := client.NewTransferAssetRequest() + req.Asset("BTC") + req.FromSymbol("BTCUSDT") + req.ToSymbol("BTCUSDT") + req.Amount("0.01") + req.TransferType(TransferAssetTypeIsolatedMarginToMain) + res, err := req.Do(ctx) + assert.NoError(t, err) + assert.NotNil(t, res) + assert.NotEmpty(t, res) + t.Logf("result: %+v", res) +} + +func TestClient_GetMarginBorrowRepayHistoryRequest(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + + err := client.SetTimeOffsetFromServer(ctx) + assert.NoError(t, err) + + req := client.NewGetMarginBorrowRepayHistoryRequest() + end := time.Now() + start := end.Add(-24 * time.Hour * 30) + req.StartTime(start) + req.EndTime(end) + req.Asset("BTC") + req.SetBorrowRepayType(BorrowRepayTypeBorrow) + res, err := req.Do(ctx) + assert.NoError(t, err) + assert.NotNil(t, res) + assert.NotEmpty(t, res) + t.Logf("result: %+v", res) +} + +func TestClient_NewPlaceMarginOrderRequest(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + + err := client.SetTimeOffsetFromServer(ctx) + assert.NoError(t, err) + + res, err := client.NewPlaceMarginOrderRequest(). + Asset("USDT"). + Amount(fixedpoint.NewFromFloat(5)). + IsIsolated(true). + Symbol("BNBUSDT"). + SetBorrowRepayType(BorrowRepayTypeBorrow). + Do(ctx) + assert.NoError(t, err) + assert.NotNil(t, res) + assert.NotEmpty(t, res) + t.Logf("result: %+v", res) + + <-time.After(time.Second) + end := time.Now() + start := end.Add(-24 * time.Hour * 30) + histories, err := client.NewGetMarginBorrowRepayHistoryRequest(). + StartTime(start). + EndTime(end). + Asset("BNB"). + IsolatedSymbol("BNBUSDT"). + SetBorrowRepayType(BorrowRepayTypeBorrow). + Do(ctx) + assert.NoError(t, err) + assert.NotNil(t, histories) + assert.NotEmpty(t, histories) + t.Logf("result: %+v", histories) + + res, err = client.NewPlaceMarginOrderRequest(). + Asset("USDT"). + Amount(fixedpoint.NewFromFloat(5)). + IsIsolated(true). + Symbol("BNBUSDT"). + SetBorrowRepayType(BorrowRepayTypeRepay). + Do(ctx) + assert.NoError(t, err) + assert.NotNil(t, res) + assert.NotEmpty(t, res) + t.Logf("result: %+v", res) +} + +func TestClient_GetDepth(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + + err := client.SetTimeOffsetFromServer(ctx) + assert.NoError(t, err) + + req := client.NewGetDepthRequest().Symbol("BTCUSDT").Limit(1000) + resp, err := req.Do(ctx) + if assert.NoError(t, err) { + assert.NotNil(t, resp) + assert.NotEmpty(t, resp) + t.Logf("response: %+v", resp) + } +} diff --git a/pkg/exchange/binance/binanceapi/futures_change_initial_leverage_request.go b/pkg/exchange/binance/binanceapi/futures_change_initial_leverage_request.go new file mode 100644 index 0000000..aeb17f4 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_change_initial_leverage_request.go @@ -0,0 +1,25 @@ +package binanceapi + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +type FuturesChangeInitialLeverageResponse struct { + Leverage int `json:"leverage"` + MaxNotionalValue fixedpoint.Value `json:"maxNotionalValue"` + Symbol string `json:"symbol"` +} + +//go:generate requestgen -method POST -url "/fapi/v1/leverage" -type FuturesChangeInitialLeverageRequest -responseType FuturesChangeInitialLeverageResponse +type FuturesChangeInitialLeverageRequest struct { + client requestgen.AuthenticatedAPIClient + + symbol string `param:"symbol"` + leverage int `param:"leverage"` +} + +func (c *FuturesRestClient) NewFuturesChangeInitialLeverageRequest() *FuturesChangeInitialLeverageRequest { + return &FuturesChangeInitialLeverageRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/futures_change_initial_leverage_request_requestgen.go b/pkg/exchange/binance/binanceapi/futures_change_initial_leverage_request_requestgen.go new file mode 100644 index 0000000..c6a1eb6 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_change_initial_leverage_request_requestgen.go @@ -0,0 +1,157 @@ +// Code generated by "requestgen -method POST -url /fapi/v1/leverage -type FuturesChangeInitialLeverageRequest -responseType FuturesChangeInitialLeverageResponse"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (f *FuturesChangeInitialLeverageRequest) Symbol(symbol string) *FuturesChangeInitialLeverageRequest { + f.symbol = symbol + return f +} + +func (f *FuturesChangeInitialLeverageRequest) Leverage(leverage int) *FuturesChangeInitialLeverageRequest { + f.leverage = leverage + return f +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (f *FuturesChangeInitialLeverageRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (f *FuturesChangeInitialLeverageRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := f.symbol + + // assign parameter of symbol + params["symbol"] = symbol + // check leverage field -> json key leverage + leverage := f.leverage + + // assign parameter of leverage + params["leverage"] = leverage + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (f *FuturesChangeInitialLeverageRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := f.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if f.isVarSlice(_v) { + f.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (f *FuturesChangeInitialLeverageRequest) GetParametersJSON() ([]byte, error) { + params, err := f.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (f *FuturesChangeInitialLeverageRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (f *FuturesChangeInitialLeverageRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (f *FuturesChangeInitialLeverageRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (f *FuturesChangeInitialLeverageRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (f *FuturesChangeInitialLeverageRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := f.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (f *FuturesChangeInitialLeverageRequest) Do(ctx context.Context) (*FuturesChangeInitialLeverageResponse, error) { + + params, err := f.GetParameters() + if err != nil { + return nil, err + } + query := url.Values{} + + apiURL := "/fapi/v1/leverage" + + req, err := f.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := f.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse FuturesChangeInitialLeverageResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return &apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/futures_change_multi_assets_mode_request.go b/pkg/exchange/binance/binanceapi/futures_change_multi_assets_mode_request.go new file mode 100644 index 0000000..eea4a98 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_change_multi_assets_mode_request.go @@ -0,0 +1,29 @@ +package binanceapi + +import ( + "github.com/c9s/requestgen" +) + +type MultiAssetsMarginMode string + +const ( + MultiAssetsMarginModeOn MultiAssetsMarginMode = "true" + MultiAssetsMarginModeOff MultiAssetsMarginMode = "false" +) + +// Code 200 == success +type FuturesChangeMultiAssetsModeResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` +} + +//go:generate requestgen -method POST -url "/fapi/v1/multiAssetsMargin" -type FuturesChangeMultiAssetsModeRequest -responseType FuturesChangeMultiAssetsModeResponse +type FuturesChangeMultiAssetsModeRequest struct { + client requestgen.AuthenticatedAPIClient + + multiAssetsMargin MultiAssetsMarginMode `param:"multiAssetsMargin"` +} + +func (c *FuturesRestClient) NewFuturesChangeMultiAssetsModeRequest() *FuturesChangeMultiAssetsModeRequest { + return &FuturesChangeMultiAssetsModeRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/futures_change_multi_assets_mode_request_requestgen.go b/pkg/exchange/binance/binanceapi/futures_change_multi_assets_mode_request_requestgen.go new file mode 100644 index 0000000..b145487 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_change_multi_assets_mode_request_requestgen.go @@ -0,0 +1,158 @@ +// Code generated by "requestgen -method POST -url /fapi/v1/multiAssetsMargin -type FuturesChangeMultiAssetsModeRequest -responseType FuturesChangeMultiAssetsModeResponse"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (f *FuturesChangeMultiAssetsModeRequest) MultiAssetsMargin(multiAssetsMargin MultiAssetsMarginMode) *FuturesChangeMultiAssetsModeRequest { + f.multiAssetsMargin = multiAssetsMargin + return f +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (f *FuturesChangeMultiAssetsModeRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (f *FuturesChangeMultiAssetsModeRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check multiAssetsMargin field -> json key multiAssetsMargin + multiAssetsMargin := f.multiAssetsMargin + + // TEMPLATE check-valid-values + switch multiAssetsMargin { + case MultiAssetsMarginModeOn, MultiAssetsMarginModeOff: + params["multiAssetsMargin"] = multiAssetsMargin + + default: + return nil, fmt.Errorf("multiAssetsMargin value %v is invalid", multiAssetsMargin) + + } + // END TEMPLATE check-valid-values + + // assign parameter of multiAssetsMargin + params["multiAssetsMargin"] = multiAssetsMargin + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (f *FuturesChangeMultiAssetsModeRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := f.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if f.isVarSlice(_v) { + f.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (f *FuturesChangeMultiAssetsModeRequest) GetParametersJSON() ([]byte, error) { + params, err := f.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (f *FuturesChangeMultiAssetsModeRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (f *FuturesChangeMultiAssetsModeRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (f *FuturesChangeMultiAssetsModeRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (f *FuturesChangeMultiAssetsModeRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (f *FuturesChangeMultiAssetsModeRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := f.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (f *FuturesChangeMultiAssetsModeRequest) Do(ctx context.Context) (*FuturesChangeMultiAssetsModeResponse, error) { + + params, err := f.GetParameters() + if err != nil { + return nil, err + } + query := url.Values{} + + apiURL := "/fapi/v1/multiAssetsMargin" + + req, err := f.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := f.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse FuturesChangeMultiAssetsModeResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return &apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/futures_client.go b/pkg/exchange/binance/binanceapi/futures_client.go new file mode 100644 index 0000000..87155ad --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_client.go @@ -0,0 +1,33 @@ +package binanceapi + +import ( + "net/url" + + "github.com/c9s/requestgen" +) + +type FuturesRestClient struct { + RestClient +} + +const FuturesRestBaseURL = "https://fapi.binance.com" + +func NewFuturesRestClient(baseURL string) *FuturesRestClient { + if len(baseURL) == 0 { + baseURL = FuturesRestBaseURL + } + + u, err := url.Parse(baseURL) + if err != nil { + panic(err) + } + + return &FuturesRestClient{ + RestClient: RestClient{ + BaseAPIClient: requestgen.BaseAPIClient{ + BaseURL: u, + HttpClient: DefaultHttpClient, + }, + }, + } +} diff --git a/pkg/exchange/binance/binanceapi/futures_get_account_balance_request.go b/pkg/exchange/binance/binanceapi/futures_get_account_balance_request.go new file mode 100644 index 0000000..44506e1 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_get_account_balance_request.go @@ -0,0 +1,39 @@ +package binanceapi + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type FuturesBalance struct { + AccountAlias string `json:"accountAlias"` + Asset string `json:"asset"` + + // Balance - wallet balance + Balance fixedpoint.Value `json:"balance"` + + CrossWalletBalance fixedpoint.Value `json:"crossWalletBalance"` + + // CrossUnPnL unrealized profit of crossed positions + CrossUnPnl fixedpoint.Value `json:"crossUnPnl"` + + AvailableBalance fixedpoint.Value `json:"availableBalance"` + + // MaxWithdrawAmount - maximum amount for transfer out + MaxWithdrawAmount fixedpoint.Value `json:"maxWithdrawAmount"` + + // MarginAvailable - whether the asset can be used as margin in Multi-Assets mode + MarginAvailable bool `json:"marginAvailable"` + UpdateTime types.MillisecondTimestamp `json:"updateTime"` +} + +//go:generate requestgen -method GET -url "/fapi/v2/balance" -type FuturesGetAccountBalanceRequest -responseType []FuturesBalance +type FuturesGetAccountBalanceRequest struct { + client requestgen.AuthenticatedAPIClient +} + +func (c *FuturesRestClient) NewFuturesGetAccountBalanceRequest() *FuturesGetAccountBalanceRequest { + return &FuturesGetAccountBalanceRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/futures_get_account_balance_request_requestgen.go b/pkg/exchange/binance/binanceapi/futures_get_account_balance_request_requestgen.go new file mode 100644 index 0000000..8aca6d6 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_get_account_balance_request_requestgen.go @@ -0,0 +1,135 @@ +// Code generated by "requestgen -method GET -url /fapi/v2/balance -type FuturesGetAccountBalanceRequest -responseType []FuturesBalance"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (f *FuturesGetAccountBalanceRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (f *FuturesGetAccountBalanceRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (f *FuturesGetAccountBalanceRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := f.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if f.isVarSlice(_v) { + f.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (f *FuturesGetAccountBalanceRequest) GetParametersJSON() ([]byte, error) { + params, err := f.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (f *FuturesGetAccountBalanceRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (f *FuturesGetAccountBalanceRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (f *FuturesGetAccountBalanceRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (f *FuturesGetAccountBalanceRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (f *FuturesGetAccountBalanceRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := f.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (f *FuturesGetAccountBalanceRequest) Do(ctx context.Context) ([]FuturesBalance, error) { + + // no body params + var params interface{} + query := url.Values{} + + apiURL := "/fapi/v2/balance" + + req, err := f.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := f.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []FuturesBalance + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/futures_get_account_request.go b/pkg/exchange/binance/binanceapi/futures_get_account_request.go new file mode 100644 index 0000000..131793f --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_get_account_request.go @@ -0,0 +1,67 @@ +package binanceapi + +import ( + "github.com/c9s/requestgen" +) + +// FuturesAccountAsset define account asset +type FuturesAccountAsset struct { + Asset string `json:"asset"` + InitialMargin string `json:"initialMargin"` + MaintMargin string `json:"maintMargin"` + MarginBalance string `json:"marginBalance"` + MaxWithdrawAmount string `json:"maxWithdrawAmount"` + OpenOrderInitialMargin string `json:"openOrderInitialMargin"` + PositionInitialMargin string `json:"positionInitialMargin"` + UnrealizedProfit string `json:"unrealizedProfit"` + WalletBalance string `json:"walletBalance"` +} + +// FuturesAccountPosition define account position +type FuturesAccountPosition struct { + Isolated bool `json:"isolated"` + Leverage string `json:"leverage"` + InitialMargin string `json:"initialMargin"` + MaintMargin string `json:"maintMargin"` + OpenOrderInitialMargin string `json:"openOrderInitialMargin"` + PositionInitialMargin string `json:"positionInitialMargin"` + Symbol string `json:"symbol"` + UnrealizedProfit string `json:"unrealizedProfit"` + EntryPrice string `json:"entryPrice"` + MaxNotional string `json:"maxNotional"` + PositionSide string `json:"positionSide"` + PositionAmt string `json:"positionAmt"` + Notional string `json:"notional"` + IsolatedWallet string `json:"isolatedWallet"` + UpdateTime int64 `json:"updateTime"` +} + +type FuturesAccount struct { + Assets []*FuturesAccountAsset `json:"assets"` + FeeTier int `json:"feeTier"` + CanTrade bool `json:"canTrade"` + CanDeposit bool `json:"canDeposit"` + CanWithdraw bool `json:"canWithdraw"` + UpdateTime int64 `json:"updateTime"` + TotalInitialMargin string `json:"totalInitialMargin"` + TotalMaintMargin string `json:"totalMaintMargin"` + TotalWalletBalance string `json:"totalWalletBalance"` + TotalUnrealizedProfit string `json:"totalUnrealizedProfit"` + TotalMarginBalance string `json:"totalMarginBalance"` + TotalPositionInitialMargin string `json:"totalPositionInitialMargin"` + TotalOpenOrderInitialMargin string `json:"totalOpenOrderInitialMargin"` + TotalCrossWalletBalance string `json:"totalCrossWalletBalance"` + TotalCrossUnPnl string `json:"totalCrossUnPnl"` + AvailableBalance string `json:"availableBalance"` + MaxWithdrawAmount string `json:"maxWithdrawAmount"` + Positions []*FuturesAccountPosition `json:"positions"` +} + +//go:generate requestgen -method GET -url "/fapi/v2/account" -type FuturesGetAccountRequest -responseType FuturesAccount +type FuturesGetAccountRequest struct { + client requestgen.AuthenticatedAPIClient +} + +func (c *FuturesRestClient) NewFuturesGetAccountRequest() *FuturesGetAccountRequest { + return &FuturesGetAccountRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/futures_get_account_request_requestgen.go b/pkg/exchange/binance/binanceapi/futures_get_account_request_requestgen.go new file mode 100644 index 0000000..a9d3f04 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_get_account_request_requestgen.go @@ -0,0 +1,135 @@ +// Code generated by "requestgen -method GET -url /fapi/v2/account -type FuturesGetAccountRequest -responseType FuturesAccount"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (f *FuturesGetAccountRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (f *FuturesGetAccountRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (f *FuturesGetAccountRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := f.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if f.isVarSlice(_v) { + f.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (f *FuturesGetAccountRequest) GetParametersJSON() ([]byte, error) { + params, err := f.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (f *FuturesGetAccountRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (f *FuturesGetAccountRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (f *FuturesGetAccountRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (f *FuturesGetAccountRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (f *FuturesGetAccountRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := f.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (f *FuturesGetAccountRequest) Do(ctx context.Context) (*FuturesAccount, error) { + + // no body params + var params interface{} + query := url.Values{} + + apiURL := "/fapi/v2/account" + + req, err := f.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := f.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse FuturesAccount + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return &apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/futures_get_global_long_short_account_ratio.go b/pkg/exchange/binance/binanceapi/futures_get_global_long_short_account_ratio.go new file mode 100644 index 0000000..ad845bc --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_get_global_long_short_account_ratio.go @@ -0,0 +1,33 @@ +package binanceapi + +import ( + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/c9s/requestgen" +) + +type FuturesGlobalLongShortAccountRatio struct { + Symbol string `json:"symbol"` + LongShortRatio fixedpoint.Value `json:"longShortRatio"` + LongAccount fixedpoint.Value `json:"longAccount"` + ShortAccount fixedpoint.Value `json:"shortAccount"` + Timestamp types.MillisecondTimestamp `json:"timestamp"` +} + +//go:generate requestgen -method GET -url "/futures/data/globalLongShortAccountRatio" -type FuturesGlobalLongShortAccountRatioRequest -responseType []FuturesGlobalLongShortAccountRatio +type FuturesGlobalLongShortAccountRatioRequest struct { + client requestgen.AuthenticatedAPIClient + + symbol string `param:"symbol"` + period types.Interval `param:"period"` + + limit *uint64 `param:"limit"` + startTime *time.Time `param:"startTime,milliseconds"` + endTime *time.Time `param:"endTime,milliseconds"` +} + +func (c *FuturesRestClient) NewFuturesGlobalLongShortAccountRatioRequest() *FuturesGlobalLongShortAccountRatioRequest { + return &FuturesGlobalLongShortAccountRatioRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/futures_get_income_history_request.go b/pkg/exchange/binance/binanceapi/futures_get_income_history_request.go new file mode 100644 index 0000000..cb6a34a --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_get_income_history_request.go @@ -0,0 +1,58 @@ +package binanceapi + +import ( + "time" + + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// FuturesIncomeType can be one of the following value: +// TRANSFER, WELCOME_BONUS, REALIZED_PNL, FUNDING_FEE, COMMISSION, INSURANCE_CLEAR, REFERRAL_KICKBACK, COMMISSION_REBATE, +// API_REBATE, CONTEST_REWARD, CROSS_COLLATERAL_TRANSFER, OPTIONS_PREMIUM_FEE, +// OPTIONS_SETTLE_PROFIT, INTERNAL_TRANSFER, AUTO_EXCHANGE, +// DELIVERED_SETTELMENT, COIN_SWAP_DEPOSIT, COIN_SWAP_WITHDRAW, POSITION_LIMIT_INCREASE_FEE +type FuturesIncomeType string + +const ( + FuturesIncomeTransfer FuturesIncomeType = "TRANSFER" + FuturesIncomeWelcomeBonus FuturesIncomeType = "WELCOME_BONUS" + FuturesIncomeFundingFee FuturesIncomeType = "FUNDING_FEE" + FuturesIncomeRealizedPnL FuturesIncomeType = "REALIZED_PNL" + FuturesIncomeCommission FuturesIncomeType = "COMMISSION" + FuturesIncomeReferralKickback FuturesIncomeType = "REFERRAL_KICKBACK" + FuturesIncomeCommissionRebate FuturesIncomeType = "COMMISSION_REBATE" + FuturesIncomeApiRebate FuturesIncomeType = "API_REBATE" + FuturesIncomeContestReward FuturesIncomeType = "CONTEST_REWARD" +) + +type FuturesIncome struct { + Symbol string `json:"symbol"` + IncomeType FuturesIncomeType `json:"incomeType"` + Income fixedpoint.Value `json:"income"` + Asset string `json:"asset"` + Info string `json:"info"` + Time types.MillisecondTimestamp `json:"time"` + TranId int64 `json:"tranId"` + TradeId string `json:"tradeId"` +} + +//go:generate requestgen -method GET -url "/fapi/v1/income" -type FuturesGetIncomeHistoryRequest -responseType []FuturesIncome +type FuturesGetIncomeHistoryRequest struct { + client requestgen.AuthenticatedAPIClient + + symbol string `param:"symbol"` + + incomeType FuturesIncomeType `param:"incomeType"` + + startTime *time.Time `param:"startTime,milliseconds"` + endTime *time.Time `param:"endTime,milliseconds"` + + limit *uint64 `param:"limit"` +} + +func (c *FuturesRestClient) NewFuturesGetIncomeHistoryRequest() *FuturesGetIncomeHistoryRequest { + return &FuturesGetIncomeHistoryRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/futures_get_income_history_request_requestgen.go b/pkg/exchange/binance/binanceapi/futures_get_income_history_request_requestgen.go new file mode 100644 index 0000000..1771427 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_get_income_history_request_requestgen.go @@ -0,0 +1,212 @@ +// Code generated by "requestgen -method GET -url /fapi/v1/income -type FuturesGetIncomeHistoryRequest -responseType []FuturesIncome"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (f *FuturesGetIncomeHistoryRequest) Symbol(symbol string) *FuturesGetIncomeHistoryRequest { + f.symbol = symbol + return f +} + +func (f *FuturesGetIncomeHistoryRequest) IncomeType(incomeType FuturesIncomeType) *FuturesGetIncomeHistoryRequest { + f.incomeType = incomeType + return f +} + +func (f *FuturesGetIncomeHistoryRequest) StartTime(startTime time.Time) *FuturesGetIncomeHistoryRequest { + f.startTime = &startTime + return f +} + +func (f *FuturesGetIncomeHistoryRequest) EndTime(endTime time.Time) *FuturesGetIncomeHistoryRequest { + f.endTime = &endTime + return f +} + +func (f *FuturesGetIncomeHistoryRequest) Limit(limit uint64) *FuturesGetIncomeHistoryRequest { + f.limit = &limit + return f +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (f *FuturesGetIncomeHistoryRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (f *FuturesGetIncomeHistoryRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := f.symbol + + // assign parameter of symbol + params["symbol"] = symbol + // check incomeType field -> json key incomeType + incomeType := f.incomeType + + // TEMPLATE check-valid-values + switch incomeType { + case FuturesIncomeTransfer, FuturesIncomeWelcomeBonus, FuturesIncomeFundingFee, FuturesIncomeRealizedPnL, FuturesIncomeCommission, FuturesIncomeReferralKickback, FuturesIncomeCommissionRebate, FuturesIncomeApiRebate, FuturesIncomeContestReward: + params["incomeType"] = incomeType + + default: + return nil, fmt.Errorf("incomeType value %v is invalid", incomeType) + + } + // END TEMPLATE check-valid-values + + // assign parameter of incomeType + params["incomeType"] = incomeType + // check startTime field -> json key startTime + if f.startTime != nil { + startTime := *f.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["startTime"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key endTime + if f.endTime != nil { + endTime := *f.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["endTime"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check limit field -> json key limit + if f.limit != nil { + limit := *f.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (f *FuturesGetIncomeHistoryRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := f.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if f.isVarSlice(_v) { + f.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (f *FuturesGetIncomeHistoryRequest) GetParametersJSON() ([]byte, error) { + params, err := f.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (f *FuturesGetIncomeHistoryRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (f *FuturesGetIncomeHistoryRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (f *FuturesGetIncomeHistoryRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (f *FuturesGetIncomeHistoryRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (f *FuturesGetIncomeHistoryRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := f.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (f *FuturesGetIncomeHistoryRequest) Do(ctx context.Context) ([]FuturesIncome, error) { + + // empty params for GET operation + var params interface{} + query, err := f.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/fapi/v1/income" + + req, err := f.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := f.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []FuturesIncome + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/futures_get_multi_assets_mode_request.go b/pkg/exchange/binance/binanceapi/futures_get_multi_assets_mode_request.go new file mode 100644 index 0000000..1ea85fb --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_get_multi_assets_mode_request.go @@ -0,0 +1,18 @@ +package binanceapi + +import ( + "github.com/c9s/requestgen" +) + +type FuturesMultiAssetsModeResponse struct { + MultiAssetsMargin bool `json:"multiAssetsMargin"` +} + +//go:generate requestgen -method GET -url "/fapi/v1/multiAssetsMargin" -type FuturesGetMultiAssetsModeRequest -responseType FuturesMultiAssetsModeResponse +type FuturesGetMultiAssetsModeRequest struct { + client requestgen.AuthenticatedAPIClient +} + +func (c *FuturesRestClient) NewFuturesGetMultiAssetsModeRequest() *FuturesGetMultiAssetsModeRequest { + return &FuturesGetMultiAssetsModeRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/futures_get_multi_assets_mode_request_requestgen.go b/pkg/exchange/binance/binanceapi/futures_get_multi_assets_mode_request_requestgen.go new file mode 100644 index 0000000..83b1f89 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_get_multi_assets_mode_request_requestgen.go @@ -0,0 +1,135 @@ +// Code generated by "requestgen -method GET -url /fapi/v1/multiAssetsMargin -type FuturesGetMultiAssetsModeRequest -responseType FuturesMultiAssetsModeResponse"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (f *FuturesGetMultiAssetsModeRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (f *FuturesGetMultiAssetsModeRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (f *FuturesGetMultiAssetsModeRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := f.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if f.isVarSlice(_v) { + f.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (f *FuturesGetMultiAssetsModeRequest) GetParametersJSON() ([]byte, error) { + params, err := f.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (f *FuturesGetMultiAssetsModeRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (f *FuturesGetMultiAssetsModeRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (f *FuturesGetMultiAssetsModeRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (f *FuturesGetMultiAssetsModeRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (f *FuturesGetMultiAssetsModeRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := f.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (f *FuturesGetMultiAssetsModeRequest) Do(ctx context.Context) (*FuturesMultiAssetsModeResponse, error) { + + // no body params + var params interface{} + query := url.Values{} + + apiURL := "/fapi/v1/multiAssetsMargin" + + req, err := f.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := f.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse FuturesMultiAssetsModeResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return &apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/futures_get_open_interest.go b/pkg/exchange/binance/binanceapi/futures_get_open_interest.go new file mode 100644 index 0000000..6a8ec9f --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_get_open_interest.go @@ -0,0 +1,24 @@ +package binanceapi + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/c9s/requestgen" +) + +type FuturesOpenInterest struct { + OpenInterest fixedpoint.Value `json:"openInterest"` + Symbol string `json:"symbol"` + Time types.MillisecondTimestamp `json:"time"` +} + +//go:generate requestgen -method GET -url "/fapi/v1/openInterest" -type FuturesGetOpenInterestRequest -responseType FuturesOpenInterest +type FuturesGetOpenInterestRequest struct { + client requestgen.AuthenticatedAPIClient + + symbol string `param:"symbol"` +} + +func (c *FuturesRestClient) NewFuturesGetOpenInterestRequest() *FuturesGetOpenInterestRequest { + return &FuturesGetOpenInterestRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/futures_get_open_interest_request_requestgen.go b/pkg/exchange/binance/binanceapi/futures_get_open_interest_request_requestgen.go new file mode 100644 index 0000000..0af266f --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_get_open_interest_request_requestgen.go @@ -0,0 +1,148 @@ +// Code generated by "requestgen -method GET -url /fapi/v1/openInterest -type FuturesGetOpenInterestRequest -responseType FuturesOpenInterest"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (f *FuturesGetOpenInterestRequest) Symbol(symbol string) *FuturesGetOpenInterestRequest { + f.symbol = symbol + return f +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (f *FuturesGetOpenInterestRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (f *FuturesGetOpenInterestRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := f.symbol + + // assign parameter of symbol + params["symbol"] = symbol + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (f *FuturesGetOpenInterestRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := f.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if f.isVarSlice(_v) { + f.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (f *FuturesGetOpenInterestRequest) GetParametersJSON() ([]byte, error) { + params, err := f.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (f *FuturesGetOpenInterestRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (f *FuturesGetOpenInterestRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (f *FuturesGetOpenInterestRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (f *FuturesGetOpenInterestRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (f *FuturesGetOpenInterestRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := f.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (f *FuturesGetOpenInterestRequest) Do(ctx context.Context) (*FuturesOpenInterest, error) { + + // empty params for GET operation + var params interface{} + query, err := f.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/fapi/v1/openInterest" + + req, err := f.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := f.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse FuturesOpenInterest + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return &apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/futures_get_open_interest_statistics.go b/pkg/exchange/binance/binanceapi/futures_get_open_interest_statistics.go new file mode 100644 index 0000000..b7cdd9e --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_get_open_interest_statistics.go @@ -0,0 +1,32 @@ +package binanceapi + +import ( + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/c9s/requestgen" +) + +type FuturesOpenInterestStatistics struct { + Symbol string `json:"symbol"` + SumOpenInterest fixedpoint.Value `json:"sumOpenInterest"` + SumOpenInterestValue fixedpoint.Value `json:"sumOpenInterestValue"` + Timestamp types.MillisecondTimestamp `json:"timestamp"` +} + +//go:generate requestgen -method GET -url "/futures/data/openInterestHist" -type FuturesGetOpenInterestStatisticsRequest -responseType []FuturesOpenInterestStatistics +type FuturesGetOpenInterestStatisticsRequest struct { + client requestgen.AuthenticatedAPIClient + + symbol string `param:"symbol,required"` + period types.Interval `param:"period,required"` + + limit *uint64 `param:"limit"` + startTime *time.Time `param:"startTime,milliseconds"` + endTime *time.Time `param:"endTime,milliseconds"` +} + +func (c *FuturesRestClient) NewFuturesGetOpenInterestStatisticsRequest() *FuturesGetOpenInterestStatisticsRequest { + return &FuturesGetOpenInterestStatisticsRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/futures_get_open_interest_statistics_request_requestgen.go b/pkg/exchange/binance/binanceapi/futures_get_open_interest_statistics_request_requestgen.go new file mode 100644 index 0000000..2716295 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_get_open_interest_statistics_request_requestgen.go @@ -0,0 +1,214 @@ +// Code generated by "requestgen -method GET -url /futures/data/openInterestHist -type FuturesGetOpenInterestStatisticsRequest -responseType []FuturesOpenInterestStatistics"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (f *FuturesGetOpenInterestStatisticsRequest) Symbol(symbol string) *FuturesGetOpenInterestStatisticsRequest { + f.symbol = symbol + return f +} + +func (f *FuturesGetOpenInterestStatisticsRequest) Period(period types.Interval) *FuturesGetOpenInterestStatisticsRequest { + f.period = period + return f +} + +func (f *FuturesGetOpenInterestStatisticsRequest) Limit(limit uint64) *FuturesGetOpenInterestStatisticsRequest { + f.limit = &limit + return f +} + +func (f *FuturesGetOpenInterestStatisticsRequest) StartTime(startTime time.Time) *FuturesGetOpenInterestStatisticsRequest { + f.startTime = &startTime + return f +} + +func (f *FuturesGetOpenInterestStatisticsRequest) EndTime(endTime time.Time) *FuturesGetOpenInterestStatisticsRequest { + f.endTime = &endTime + return f +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (f *FuturesGetOpenInterestStatisticsRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (f *FuturesGetOpenInterestStatisticsRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := f.symbol + + // TEMPLATE check-required + if len(symbol) == 0 { + return nil, fmt.Errorf("symbol is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of symbol + params["symbol"] = symbol + // check period field -> json key period + period := f.period + + // TEMPLATE check-required + if len(period) == 0 { + return nil, fmt.Errorf("period is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of period + params["period"] = period + // check limit field -> json key limit + if f.limit != nil { + limit := *f.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + // check startTime field -> json key startTime + if f.startTime != nil { + startTime := *f.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["startTime"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key endTime + if f.endTime != nil { + endTime := *f.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["endTime"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (f *FuturesGetOpenInterestStatisticsRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := f.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if f.isVarSlice(_v) { + f.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (f *FuturesGetOpenInterestStatisticsRequest) GetParametersJSON() ([]byte, error) { + params, err := f.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (f *FuturesGetOpenInterestStatisticsRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (f *FuturesGetOpenInterestStatisticsRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (f *FuturesGetOpenInterestStatisticsRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (f *FuturesGetOpenInterestStatisticsRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (f *FuturesGetOpenInterestStatisticsRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := f.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (f *FuturesGetOpenInterestStatisticsRequest) Do(ctx context.Context) ([]FuturesOpenInterestStatistics, error) { + + // empty params for GET operation + var params interface{} + query, err := f.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/futures/data/openInterestHist" + + req, err := f.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := f.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []FuturesOpenInterestStatistics + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/futures_get_position_risks_request.go b/pkg/exchange/binance/binanceapi/futures_get_position_risks_request.go new file mode 100644 index 0000000..0921803 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_get_position_risks_request.go @@ -0,0 +1,37 @@ +package binanceapi + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type FuturesPositionRisk struct { + EntryPrice fixedpoint.Value `json:"entryPrice"` + MarginType string `json:"marginType"` + IsAutoAddMargin string `json:"isAutoAddMargin"` + IsolatedMargin string `json:"isolatedMargin"` + Leverage fixedpoint.Value `json:"leverage"` + LiquidationPrice fixedpoint.Value `json:"liquidationPrice"` + MarkPrice fixedpoint.Value `json:"markPrice"` + MaxNotionalValue fixedpoint.Value `json:"maxNotionalValue"` + PositionAmount fixedpoint.Value `json:"positionAmt"` + Notional fixedpoint.Value `json:"notional"` + IsolatedWallet string `json:"isolatedWallet"` + Symbol string `json:"symbol"` + UnRealizedProfit fixedpoint.Value `json:"unRealizedProfit"` + PositionSide string `json:"positionSide"` + UpdateTime types.MillisecondTimestamp `json:"updateTime"` +} + +//go:generate requestgen -method GET -url "/fapi/v2/positionRisk" -type FuturesGetPositionRisksRequest -responseType []FuturesPositionRisk +type FuturesGetPositionRisksRequest struct { + client requestgen.AuthenticatedAPIClient + + symbol string `param:"symbol"` +} + +func (c *FuturesRestClient) NewFuturesGetPositionRisksRequest() *FuturesGetPositionRisksRequest { + return &FuturesGetPositionRisksRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/futures_get_position_risks_request_requestgen.go b/pkg/exchange/binance/binanceapi/futures_get_position_risks_request_requestgen.go new file mode 100644 index 0000000..f17d53d --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_get_position_risks_request_requestgen.go @@ -0,0 +1,148 @@ +// Code generated by "requestgen -method GET -url /fapi/v2/positionRisk -type FuturesGetPositionRisksRequest -responseType []FuturesPositionRisk"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (f *FuturesGetPositionRisksRequest) Symbol(symbol string) *FuturesGetPositionRisksRequest { + f.symbol = symbol + return f +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (f *FuturesGetPositionRisksRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (f *FuturesGetPositionRisksRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := f.symbol + + // assign parameter of symbol + params["symbol"] = symbol + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (f *FuturesGetPositionRisksRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := f.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if f.isVarSlice(_v) { + f.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (f *FuturesGetPositionRisksRequest) GetParametersJSON() ([]byte, error) { + params, err := f.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (f *FuturesGetPositionRisksRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (f *FuturesGetPositionRisksRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (f *FuturesGetPositionRisksRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (f *FuturesGetPositionRisksRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (f *FuturesGetPositionRisksRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := f.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (f *FuturesGetPositionRisksRequest) Do(ctx context.Context) ([]FuturesPositionRisk, error) { + + // empty params for GET operation + var params interface{} + query, err := f.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/fapi/v2/positionRisk" + + req, err := f.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := f.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []FuturesPositionRisk + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/futures_get_taker_buy_sell_volume.go b/pkg/exchange/binance/binanceapi/futures_get_taker_buy_sell_volume.go new file mode 100644 index 0000000..55d9e64 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_get_taker_buy_sell_volume.go @@ -0,0 +1,32 @@ +package binanceapi + +import ( + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/c9s/requestgen" +) + +type FuturesTakerBuySellVolume struct { + BuySellRatio fixedpoint.Value `json:"buySellRatio"` + BuyVol fixedpoint.Value `json:"buyVol"` + SellVol fixedpoint.Value `json:"sellVol"` + Timestamp types.MillisecondTimestamp `json:"timestamp"` +} + +//go:generate requestgen -method GET -url "/futures/data/takerlongshortRatio" -type FuturesTakerBuySellVolumeRequest -responseType []FuturesTakerBuySellVolume +type FuturesTakerBuySellVolumeRequest struct { + client requestgen.AuthenticatedAPIClient + + symbol string `param:"symbol"` + period types.Interval `param:"period"` + + limit *uint64 `param:"limit"` + startTime *time.Time `param:"startTime,milliseconds"` + endTime *time.Time `param:"endTime,milliseconds"` +} + +func (c *FuturesRestClient) NewFuturesTakerBuySellVolumeRequest() *FuturesTakerBuySellVolumeRequest { + return &FuturesTakerBuySellVolumeRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/futures_get_top_trader_long_short_account_ratio.go b/pkg/exchange/binance/binanceapi/futures_get_top_trader_long_short_account_ratio.go new file mode 100644 index 0000000..1c7a011 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_get_top_trader_long_short_account_ratio.go @@ -0,0 +1,33 @@ +package binanceapi + +import ( + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/c9s/requestgen" +) + +type FuturesTopTraderLongShortAccountRatio struct { + Symbol string `json:"symbol"` + LongShortRatio fixedpoint.Value `json:"longShortRatio"` + LongAccount fixedpoint.Value `json:"longAccount"` + ShortAccount fixedpoint.Value `json:"shortAccount"` + Timestamp types.MillisecondTimestamp `json:"timestamp"` +} + +//go:generate requestgen -method GET -url "/futures/data/topLongShortAccountRatio" -type FuturesTopTraderLongShortAccountRatioRequest -responseType []FuturesTopTraderLongShortAccountRatio +type FuturesTopTraderLongShortAccountRatioRequest struct { + client requestgen.AuthenticatedAPIClient + + symbol string `param:"symbol"` + period types.Interval `param:"period"` + + limit *uint64 `param:"limit"` + startTime *time.Time `param:"startTime,milliseconds"` + endTime *time.Time `param:"endTime,milliseconds"` +} + +func (c *FuturesRestClient) NewFuturesTopTraderLongShortAccountRatioRequest() *FuturesTopTraderLongShortAccountRatioRequest { + return &FuturesTopTraderLongShortAccountRatioRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/futures_get_top_trader_long_short_position_ratio.go b/pkg/exchange/binance/binanceapi/futures_get_top_trader_long_short_position_ratio.go new file mode 100644 index 0000000..8633c72 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_get_top_trader_long_short_position_ratio.go @@ -0,0 +1,33 @@ +package binanceapi + +import ( + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/c9s/requestgen" +) + +type FuturesTopTraderLongShortPositionRatio struct { + Symbol string `json:"symbol"` + LongShortRatio fixedpoint.Value `json:"longShortRatio"` + LongAccount fixedpoint.Value `json:"longAccount"` + ShortAccount fixedpoint.Value `json:"shortAccount"` + Timestamp types.MillisecondTimestamp `json:"timestamp"` +} + +//go:generate requestgen -method GET -url "/futures/data/topLongShortPositionRatio" -type FuturesTopTraderLongShortPositionRatioRequest -responseType []FuturesTopTraderLongShortPositionRatio +type FuturesTopTraderLongShortPositionRatioRequest struct { + client requestgen.AuthenticatedAPIClient + + symbol string `param:"symbol"` + period types.Interval `param:"period"` + + limit *uint64 `param:"limit"` + startTime *time.Time `param:"startTime,milliseconds"` + endTime *time.Time `param:"endTime,milliseconds"` +} + +func (c *FuturesRestClient) NewFuturesTopTraderLongShortPositionRatioRequest() *FuturesTopTraderLongShortPositionRatioRequest { + return &FuturesTopTraderLongShortPositionRatioRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/futures_global_long_short_account_ratio_request_requestgen.go b/pkg/exchange/binance/binanceapi/futures_global_long_short_account_ratio_request_requestgen.go new file mode 100644 index 0000000..b41e45f --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_global_long_short_account_ratio_request_requestgen.go @@ -0,0 +1,202 @@ +// Code generated by "requestgen -method GET -url /futures/data/globalLongShortAccountRatio -type FuturesGlobalLongShortAccountRatioRequest -responseType []FuturesGlobalLongShortAccountRatio"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (f *FuturesGlobalLongShortAccountRatioRequest) Symbol(symbol string) *FuturesGlobalLongShortAccountRatioRequest { + f.symbol = symbol + return f +} + +func (f *FuturesGlobalLongShortAccountRatioRequest) Period(period types.Interval) *FuturesGlobalLongShortAccountRatioRequest { + f.period = period + return f +} + +func (f *FuturesGlobalLongShortAccountRatioRequest) Limit(limit uint64) *FuturesGlobalLongShortAccountRatioRequest { + f.limit = &limit + return f +} + +func (f *FuturesGlobalLongShortAccountRatioRequest) StartTime(startTime time.Time) *FuturesGlobalLongShortAccountRatioRequest { + f.startTime = &startTime + return f +} + +func (f *FuturesGlobalLongShortAccountRatioRequest) EndTime(endTime time.Time) *FuturesGlobalLongShortAccountRatioRequest { + f.endTime = &endTime + return f +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (f *FuturesGlobalLongShortAccountRatioRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (f *FuturesGlobalLongShortAccountRatioRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := f.symbol + + // assign parameter of symbol + params["symbol"] = symbol + // check period field -> json key period + period := f.period + + // assign parameter of period + params["period"] = period + // check limit field -> json key limit + if f.limit != nil { + limit := *f.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + // check startTime field -> json key startTime + if f.startTime != nil { + startTime := *f.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["startTime"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key endTime + if f.endTime != nil { + endTime := *f.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["endTime"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (f *FuturesGlobalLongShortAccountRatioRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := f.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if f.isVarSlice(_v) { + f.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (f *FuturesGlobalLongShortAccountRatioRequest) GetParametersJSON() ([]byte, error) { + params, err := f.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (f *FuturesGlobalLongShortAccountRatioRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (f *FuturesGlobalLongShortAccountRatioRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (f *FuturesGlobalLongShortAccountRatioRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (f *FuturesGlobalLongShortAccountRatioRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (f *FuturesGlobalLongShortAccountRatioRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := f.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (f *FuturesGlobalLongShortAccountRatioRequest) Do(ctx context.Context) ([]FuturesGlobalLongShortAccountRatio, error) { + + // empty params for GET operation + var params interface{} + query, err := f.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/futures/data/globalLongShortAccountRatio" + + req, err := f.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := f.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []FuturesGlobalLongShortAccountRatio + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/futures_taker_buy_sell_volume_request_requestgen.go b/pkg/exchange/binance/binanceapi/futures_taker_buy_sell_volume_request_requestgen.go new file mode 100644 index 0000000..4472ff2 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_taker_buy_sell_volume_request_requestgen.go @@ -0,0 +1,202 @@ +// Code generated by "requestgen -method GET -url /futures/data/takerlongshortRatio -type FuturesTakerBuySellVolumeRequest -responseType []FuturesTakerBuySellVolume"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (f *FuturesTakerBuySellVolumeRequest) Symbol(symbol string) *FuturesTakerBuySellVolumeRequest { + f.symbol = symbol + return f +} + +func (f *FuturesTakerBuySellVolumeRequest) Period(period types.Interval) *FuturesTakerBuySellVolumeRequest { + f.period = period + return f +} + +func (f *FuturesTakerBuySellVolumeRequest) Limit(limit uint64) *FuturesTakerBuySellVolumeRequest { + f.limit = &limit + return f +} + +func (f *FuturesTakerBuySellVolumeRequest) StartTime(startTime time.Time) *FuturesTakerBuySellVolumeRequest { + f.startTime = &startTime + return f +} + +func (f *FuturesTakerBuySellVolumeRequest) EndTime(endTime time.Time) *FuturesTakerBuySellVolumeRequest { + f.endTime = &endTime + return f +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (f *FuturesTakerBuySellVolumeRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (f *FuturesTakerBuySellVolumeRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := f.symbol + + // assign parameter of symbol + params["symbol"] = symbol + // check period field -> json key period + period := f.period + + // assign parameter of period + params["period"] = period + // check limit field -> json key limit + if f.limit != nil { + limit := *f.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + // check startTime field -> json key startTime + if f.startTime != nil { + startTime := *f.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["startTime"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key endTime + if f.endTime != nil { + endTime := *f.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["endTime"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (f *FuturesTakerBuySellVolumeRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := f.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if f.isVarSlice(_v) { + f.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (f *FuturesTakerBuySellVolumeRequest) GetParametersJSON() ([]byte, error) { + params, err := f.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (f *FuturesTakerBuySellVolumeRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (f *FuturesTakerBuySellVolumeRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (f *FuturesTakerBuySellVolumeRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (f *FuturesTakerBuySellVolumeRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (f *FuturesTakerBuySellVolumeRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := f.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (f *FuturesTakerBuySellVolumeRequest) Do(ctx context.Context) ([]FuturesTakerBuySellVolume, error) { + + // empty params for GET operation + var params interface{} + query, err := f.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/futures/data/takerlongshortRatio" + + req, err := f.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := f.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []FuturesTakerBuySellVolume + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/futures_top_trader_long_short_account_ratio_request_requestgen.go b/pkg/exchange/binance/binanceapi/futures_top_trader_long_short_account_ratio_request_requestgen.go new file mode 100644 index 0000000..6e9b070 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_top_trader_long_short_account_ratio_request_requestgen.go @@ -0,0 +1,202 @@ +// Code generated by "requestgen -method GET -url /futures/data/topLongShortAccountRatio -type FuturesTopTraderLongShortAccountRatioRequest -responseType []FuturesTopTraderLongShortAccountRatio"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (f *FuturesTopTraderLongShortAccountRatioRequest) Symbol(symbol string) *FuturesTopTraderLongShortAccountRatioRequest { + f.symbol = symbol + return f +} + +func (f *FuturesTopTraderLongShortAccountRatioRequest) Period(period types.Interval) *FuturesTopTraderLongShortAccountRatioRequest { + f.period = period + return f +} + +func (f *FuturesTopTraderLongShortAccountRatioRequest) Limit(limit uint64) *FuturesTopTraderLongShortAccountRatioRequest { + f.limit = &limit + return f +} + +func (f *FuturesTopTraderLongShortAccountRatioRequest) StartTime(startTime time.Time) *FuturesTopTraderLongShortAccountRatioRequest { + f.startTime = &startTime + return f +} + +func (f *FuturesTopTraderLongShortAccountRatioRequest) EndTime(endTime time.Time) *FuturesTopTraderLongShortAccountRatioRequest { + f.endTime = &endTime + return f +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (f *FuturesTopTraderLongShortAccountRatioRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (f *FuturesTopTraderLongShortAccountRatioRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := f.symbol + + // assign parameter of symbol + params["symbol"] = symbol + // check period field -> json key period + period := f.period + + // assign parameter of period + params["period"] = period + // check limit field -> json key limit + if f.limit != nil { + limit := *f.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + // check startTime field -> json key startTime + if f.startTime != nil { + startTime := *f.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["startTime"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key endTime + if f.endTime != nil { + endTime := *f.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["endTime"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (f *FuturesTopTraderLongShortAccountRatioRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := f.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if f.isVarSlice(_v) { + f.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (f *FuturesTopTraderLongShortAccountRatioRequest) GetParametersJSON() ([]byte, error) { + params, err := f.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (f *FuturesTopTraderLongShortAccountRatioRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (f *FuturesTopTraderLongShortAccountRatioRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (f *FuturesTopTraderLongShortAccountRatioRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (f *FuturesTopTraderLongShortAccountRatioRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (f *FuturesTopTraderLongShortAccountRatioRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := f.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (f *FuturesTopTraderLongShortAccountRatioRequest) Do(ctx context.Context) ([]FuturesTopTraderLongShortAccountRatio, error) { + + // empty params for GET operation + var params interface{} + query, err := f.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/futures/data/topLongShortAccountRatio" + + req, err := f.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := f.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []FuturesTopTraderLongShortAccountRatio + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/futures_top_trader_long_short_position_ratio_request_requestgen.go b/pkg/exchange/binance/binanceapi/futures_top_trader_long_short_position_ratio_request_requestgen.go new file mode 100644 index 0000000..d7c5552 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_top_trader_long_short_position_ratio_request_requestgen.go @@ -0,0 +1,202 @@ +// Code generated by "requestgen -method GET -url /futures/data/topLongShortPositionRatio -type FuturesTopTraderLongShortPositionRatioRequest -responseType []FuturesTopTraderLongShortPositionRatio"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (f *FuturesTopTraderLongShortPositionRatioRequest) Symbol(symbol string) *FuturesTopTraderLongShortPositionRatioRequest { + f.symbol = symbol + return f +} + +func (f *FuturesTopTraderLongShortPositionRatioRequest) Period(period types.Interval) *FuturesTopTraderLongShortPositionRatioRequest { + f.period = period + return f +} + +func (f *FuturesTopTraderLongShortPositionRatioRequest) Limit(limit uint64) *FuturesTopTraderLongShortPositionRatioRequest { + f.limit = &limit + return f +} + +func (f *FuturesTopTraderLongShortPositionRatioRequest) StartTime(startTime time.Time) *FuturesTopTraderLongShortPositionRatioRequest { + f.startTime = &startTime + return f +} + +func (f *FuturesTopTraderLongShortPositionRatioRequest) EndTime(endTime time.Time) *FuturesTopTraderLongShortPositionRatioRequest { + f.endTime = &endTime + return f +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (f *FuturesTopTraderLongShortPositionRatioRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (f *FuturesTopTraderLongShortPositionRatioRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := f.symbol + + // assign parameter of symbol + params["symbol"] = symbol + // check period field -> json key period + period := f.period + + // assign parameter of period + params["period"] = period + // check limit field -> json key limit + if f.limit != nil { + limit := *f.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + // check startTime field -> json key startTime + if f.startTime != nil { + startTime := *f.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["startTime"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key endTime + if f.endTime != nil { + endTime := *f.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["endTime"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (f *FuturesTopTraderLongShortPositionRatioRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := f.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if f.isVarSlice(_v) { + f.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (f *FuturesTopTraderLongShortPositionRatioRequest) GetParametersJSON() ([]byte, error) { + params, err := f.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (f *FuturesTopTraderLongShortPositionRatioRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (f *FuturesTopTraderLongShortPositionRatioRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (f *FuturesTopTraderLongShortPositionRatioRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (f *FuturesTopTraderLongShortPositionRatioRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (f *FuturesTopTraderLongShortPositionRatioRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := f.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (f *FuturesTopTraderLongShortPositionRatioRequest) Do(ctx context.Context) ([]FuturesTopTraderLongShortPositionRatio, error) { + + // empty params for GET operation + var params interface{} + query, err := f.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/futures/data/topLongShortPositionRatio" + + req, err := f.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := f.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []FuturesTopTraderLongShortPositionRatio + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/futures_transfer_request.go b/pkg/exchange/binance/binanceapi/futures_transfer_request.go new file mode 100644 index 0000000..ee726f7 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_transfer_request.go @@ -0,0 +1,33 @@ +package binanceapi + +import "github.com/c9s/requestgen" + +type FuturesTransferType int + +const ( + FuturesTransferSpotToUsdtFutures FuturesTransferType = 1 + FuturesTransferUsdtFuturesToSpot FuturesTransferType = 2 + + FuturesTransferSpotToCoinFutures FuturesTransferType = 3 + FuturesTransferCoinFuturesToSpot FuturesTransferType = 4 +) + +type FuturesTransferResponse struct { + TranId int64 `json:"tranId"` +} + +//go:generate requestgen -method POST -url "/sapi/v1/futures/transfer" -type FuturesTransferRequest -responseType .FuturesTransferResponse +type FuturesTransferRequest struct { + client requestgen.AuthenticatedAPIClient + + asset string `param:"asset"` + + // amount is a decimal in string format + amount string `param:"amount"` + + transferType FuturesTransferType `param:"type"` +} + +func (c *RestClient) NewFuturesTransferRequest() *FuturesTransferRequest { + return &FuturesTransferRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/futures_transfer_request_requestgen.go b/pkg/exchange/binance/binanceapi/futures_transfer_request_requestgen.go new file mode 100644 index 0000000..50f4b46 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/futures_transfer_request_requestgen.go @@ -0,0 +1,178 @@ +// Code generated by "requestgen -method POST -url /sapi/v1/futures/transfer -type FuturesTransferRequest -responseType .FuturesTransferResponse"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (f *FuturesTransferRequest) Asset(asset string) *FuturesTransferRequest { + f.asset = asset + return f +} + +func (f *FuturesTransferRequest) Amount(amount string) *FuturesTransferRequest { + f.amount = amount + return f +} + +func (f *FuturesTransferRequest) TransferType(transferType FuturesTransferType) *FuturesTransferRequest { + f.transferType = transferType + return f +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (f *FuturesTransferRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (f *FuturesTransferRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check asset field -> json key asset + asset := f.asset + + // assign parameter of asset + params["asset"] = asset + // check amount field -> json key amount + amount := f.amount + + // assign parameter of amount + params["amount"] = amount + // check transferType field -> json key type + transferType := f.transferType + + // TEMPLATE check-valid-values + switch transferType { + case FuturesTransferSpotToUsdtFutures, FuturesTransferUsdtFuturesToSpot, FuturesTransferSpotToCoinFutures, FuturesTransferCoinFuturesToSpot: + params["type"] = transferType + + default: + return nil, fmt.Errorf("type value %v is invalid", transferType) + + } + // END TEMPLATE check-valid-values + + // assign parameter of transferType + params["type"] = transferType + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (f *FuturesTransferRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := f.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if f.isVarSlice(_v) { + f.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (f *FuturesTransferRequest) GetParametersJSON() ([]byte, error) { + params, err := f.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (f *FuturesTransferRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (f *FuturesTransferRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (f *FuturesTransferRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (f *FuturesTransferRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (f *FuturesTransferRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := f.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (f *FuturesTransferRequest) Do(ctx context.Context) (*FuturesTransferResponse, error) { + + params, err := f.GetParameters() + if err != nil { + return nil, err + } + query := url.Values{} + + apiURL := "/sapi/v1/futures/transfer" + + req, err := f.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := f.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse FuturesTransferResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return &apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/get_api_referral_if_new_user_request.go b/pkg/exchange/binance/binanceapi/get_api_referral_if_new_user_request.go new file mode 100644 index 0000000..07d8857 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_api_referral_if_new_user_request.go @@ -0,0 +1,19 @@ +package binanceapi + +import "github.com/c9s/requestgen" + +type ApiReferralIfNewUserResponse struct { + ApiAgentCode string `json:"apiAgentCode"` + RebateWorking bool `json:"rebateWorking"` + IfNewUser bool `json:"ifNewUser"` + ReferrerId int `json:"referrerId"` +} + +//go:generate requestgen -method GET -url "/sapi/v1/apiReferral/ifNewUser" -type GetApiReferralIfNewUserRequest -responseType .ApiReferralIfNewUserResponse +type GetApiReferralIfNewUserRequest struct { + client requestgen.AuthenticatedAPIClient +} + +func (c *RestClient) NewGetApiReferralIfNewUserRequest() *GetApiReferralIfNewUserRequest { + return &GetApiReferralIfNewUserRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/get_api_referral_if_new_user_request_requestgen.go b/pkg/exchange/binance/binanceapi/get_api_referral_if_new_user_request_requestgen.go new file mode 100644 index 0000000..feb42d9 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_api_referral_if_new_user_request_requestgen.go @@ -0,0 +1,135 @@ +// Code generated by "requestgen -method GET -url /sapi/v1/apiReferral/ifNewUser -type GetApiReferralIfNewUserRequest -responseType .ApiReferralIfNewUserResponse"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetApiReferralIfNewUserRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetApiReferralIfNewUserRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetApiReferralIfNewUserRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetApiReferralIfNewUserRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetApiReferralIfNewUserRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetApiReferralIfNewUserRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetApiReferralIfNewUserRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetApiReferralIfNewUserRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetApiReferralIfNewUserRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetApiReferralIfNewUserRequest) Do(ctx context.Context) (*ApiReferralIfNewUserResponse, error) { + + // no body params + var params interface{} + query := url.Values{} + + apiURL := "/sapi/v1/apiReferral/ifNewUser" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse ApiReferralIfNewUserResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return &apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/get_deposit_address_request.go b/pkg/exchange/binance/binanceapi/get_deposit_address_request.go new file mode 100644 index 0000000..17b0005 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_deposit_address_request.go @@ -0,0 +1,25 @@ +package binanceapi + +import ( + "github.com/c9s/requestgen" +) + +type DepositAddress struct { + Address string `json:"address"` + Coin string `json:"coin"` + Tag string `json:"tag"` + Url string `json:"url"` +} + +//go:generate requestgen -method GET -url "/sapi/v1/capital/deposit/address" -type GetDepositAddressRequest -responseType .DepositAddress +type GetDepositAddressRequest struct { + client requestgen.AuthenticatedAPIClient + + coin string `param:"coin"` + + network *string `param:"network"` +} + +func (c *RestClient) NewGetDepositAddressRequest() *GetDepositAddressRequest { + return &GetDepositAddressRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/get_deposit_address_request_requestgen.go b/pkg/exchange/binance/binanceapi/get_deposit_address_request_requestgen.go new file mode 100644 index 0000000..6406dcf --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_deposit_address_request_requestgen.go @@ -0,0 +1,161 @@ +// Code generated by "requestgen -method GET -url /sapi/v1/capital/deposit/address -type GetDepositAddressRequest -responseType .DepositAddress"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (g *GetDepositAddressRequest) Coin(coin string) *GetDepositAddressRequest { + g.coin = coin + return g +} + +func (g *GetDepositAddressRequest) Network(network string) *GetDepositAddressRequest { + g.network = &network + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetDepositAddressRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetDepositAddressRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check coin field -> json key coin + coin := g.coin + + // assign parameter of coin + params["coin"] = coin + // check network field -> json key network + if g.network != nil { + network := *g.network + + // assign parameter of network + params["network"] = network + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetDepositAddressRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetDepositAddressRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetDepositAddressRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetDepositAddressRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetDepositAddressRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetDepositAddressRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetDepositAddressRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetDepositAddressRequest) Do(ctx context.Context) (*DepositAddress, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/sapi/v1/capital/deposit/address" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse DepositAddress + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return &apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/get_deposit_history_request.go b/pkg/exchange/binance/binanceapi/get_deposit_history_request.go new file mode 100644 index 0000000..93d6641 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_deposit_history_request.go @@ -0,0 +1,51 @@ +package binanceapi + +import ( + "time" + + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type DepositStatus int + +const ( + DepositStatusPending DepositStatus = 0 + DepositStatusSuccess DepositStatus = 1 + DepositStatusCredited DepositStatus = 6 + DepositStatusWrong DepositStatus = 7 + DepositStatusWaitingUserConfirm DepositStatus = 8 +) + +type DepositHistory struct { + Amount fixedpoint.Value `json:"amount"` + Coin string `json:"coin"` + Network string `json:"network"` + Status DepositStatus `json:"status"` + Address string `json:"address"` + AddressTag string `json:"addressTag"` + TxId string `json:"txId"` + InsertTime types.MillisecondTimestamp `json:"insertTime"` + TransferType int `json:"transferType"` + UnlockConfirm int `json:"unlockConfirm"` + + // ConfirmTimes format = "current/required", for example: "7/16" + ConfirmTimes string `json:"confirmTimes"` + WalletType int `json:"walletType"` +} + +//go:generate requestgen -method GET -url "/sapi/v1/capital/deposit/hisrec" -type GetDepositHistoryRequest -responseType []DepositHistory +type GetDepositHistoryRequest struct { + client requestgen.AuthenticatedAPIClient + + coin *string `param:"coin"` + + startTime *time.Time `param:"startTime,milliseconds"` + endTime *time.Time `param:"endTime,milliseconds"` +} + +func (c *RestClient) NewGetDepositHistoryRequest() *GetDepositHistoryRequest { + return &GetDepositHistoryRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/get_deposit_history_request_requestgen.go b/pkg/exchange/binance/binanceapi/get_deposit_history_request_requestgen.go new file mode 100644 index 0000000..dce6cb8 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_deposit_history_request_requestgen.go @@ -0,0 +1,181 @@ +// Code generated by "requestgen -method GET -url /sapi/v1/capital/deposit/hisrec -type GetDepositHistoryRequest -responseType []DepositHistory"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetDepositHistoryRequest) Coin(coin string) *GetDepositHistoryRequest { + g.coin = &coin + return g +} + +func (g *GetDepositHistoryRequest) StartTime(startTime time.Time) *GetDepositHistoryRequest { + g.startTime = &startTime + return g +} + +func (g *GetDepositHistoryRequest) EndTime(endTime time.Time) *GetDepositHistoryRequest { + g.endTime = &endTime + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetDepositHistoryRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetDepositHistoryRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check coin field -> json key coin + if g.coin != nil { + coin := *g.coin + + // assign parameter of coin + params["coin"] = coin + } else { + } + // check startTime field -> json key startTime + if g.startTime != nil { + startTime := *g.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["startTime"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key endTime + if g.endTime != nil { + endTime := *g.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["endTime"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetDepositHistoryRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetDepositHistoryRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetDepositHistoryRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetDepositHistoryRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetDepositHistoryRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetDepositHistoryRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetDepositHistoryRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetDepositHistoryRequest) Do(ctx context.Context) ([]DepositHistory, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/sapi/v1/capital/deposit/hisrec" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []DepositHistory + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/get_depth_request.go b/pkg/exchange/binance/binanceapi/get_depth_request.go new file mode 100644 index 0000000..07c9ca3 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_depth_request.go @@ -0,0 +1,25 @@ +package binanceapi + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +type Depth struct { + LastUpdateId int64 `json:"lastUpdateId"` + Bids [][]fixedpoint.Value `json:"bids"` + Asks [][]fixedpoint.Value `json:"asks"` +} + +//go:generate requestgen -method GET -url "/api/v3/depth" -type GetDepthRequest -responseType .Depth +type GetDepthRequest struct { + client requestgen.APIClient + + symbol string `param:"symbol"` + limit int `param:"limit" defaultValue:"1000"` +} + +func (c *RestClient) NewGetDepthRequest() *GetDepthRequest { + return &GetDepthRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/get_depth_request_requestgen.go b/pkg/exchange/binance/binanceapi/get_depth_request_requestgen.go new file mode 100644 index 0000000..57b8705 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_depth_request_requestgen.go @@ -0,0 +1,190 @@ +// Code generated by "requestgen -method GET -url /api/v3/depth -type GetDepthRequest -responseType .Depth"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (g *GetDepthRequest) Symbol(symbol string) *GetDepthRequest { + g.symbol = symbol + return g +} + +func (g *GetDepthRequest) Limit(limit int) *GetDepthRequest { + g.limit = limit + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetDepthRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetDepthRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := g.symbol + + // assign parameter of symbol + params["symbol"] = symbol + // check limit field -> json key limit + limit := g.limit + + // assign parameter of limit + params["limit"] = limit + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetDepthRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetDepthRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetDepthRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetDepthRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetDepthRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetDepthRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetDepthRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (g *GetDepthRequest) GetPath() string { + return "/api/v3/depth" +} + +// Do generates the request object and send the request object to the API endpoint +func (g *GetDepthRequest) Do(ctx context.Context) (*Depth, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + var apiURL string + + apiURL = g.GetPath() + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse Depth + + type responseUnmarshaler interface { + Unmarshal(data []byte) error + } + + if unmarshaler, ok := interface{}(&apiResponse).(responseUnmarshaler); ok { + if err := unmarshaler.Unmarshal(response.Body); err != nil { + return nil, err + } + } else { + // The line below checks the content type, however, some API server might not send the correct content type header, + // Hence, this is commented for backward compatibility + // response.IsJSON() + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + } + + type responseValidator interface { + Validate() error + } + + if validator, ok := interface{}(&apiResponse).(responseValidator); ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + return &apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/get_historical_trades_request.go b/pkg/exchange/binance/binanceapi/get_historical_trades_request.go new file mode 100644 index 0000000..11b0518 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_historical_trades_request.go @@ -0,0 +1,20 @@ +package binanceapi + +import ( + "github.com/c9s/requestgen" +) + +//type Trade = binance.TradeV3 + +//go:generate requestgen -method GET -url "/api/v3/historicalTrades" -type GetHistoricalTradesRequest -responseType []Trade +type GetHistoricalTradesRequest struct { + client requestgen.AuthenticatedAPIClient + + symbol string `param:"symbol"` + limit *uint64 `param:"limit"` + fromID *uint64 `param:"fromId"` +} + +func (c *RestClient) NewGetHistoricalTradesRequest() *GetHistoricalTradesRequest { + return &GetHistoricalTradesRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/get_historical_trades_request_requestgen.go b/pkg/exchange/binance/binanceapi/get_historical_trades_request_requestgen.go new file mode 100644 index 0000000..a786cfc --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_historical_trades_request_requestgen.go @@ -0,0 +1,175 @@ +// Code generated by "requestgen -method GET -url /api/v3/historicalTrades -type GetHistoricalTradesRequest -responseType []Trade"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "github.com/adshao/go-binance/v2" + "net/url" + "reflect" + "regexp" +) + +func (g *GetHistoricalTradesRequest) Symbol(symbol string) *GetHistoricalTradesRequest { + g.symbol = symbol + return g +} + +func (g *GetHistoricalTradesRequest) Limit(limit uint64) *GetHistoricalTradesRequest { + g.limit = &limit + return g +} + +func (g *GetHistoricalTradesRequest) FromID(fromID uint64) *GetHistoricalTradesRequest { + g.fromID = &fromID + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetHistoricalTradesRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetHistoricalTradesRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := g.symbol + + // assign parameter of symbol + params["symbol"] = symbol + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + // check fromID field -> json key fromId + if g.fromID != nil { + fromID := *g.fromID + + // assign parameter of fromID + params["fromId"] = fromID + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetHistoricalTradesRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetHistoricalTradesRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetHistoricalTradesRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetHistoricalTradesRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetHistoricalTradesRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetHistoricalTradesRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetHistoricalTradesRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetHistoricalTradesRequest) Do(ctx context.Context) ([]binance.TradeV3, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/api/v3/historicalTrades" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []binance.TradeV3 + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/get_margin_borrow_repay_history_request.go b/pkg/exchange/binance/binanceapi/get_margin_borrow_repay_history_request.go new file mode 100644 index 0000000..580b649 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_margin_borrow_repay_history_request.go @@ -0,0 +1,64 @@ +package binanceapi + +import ( + "time" + + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// one of PENDING (pending execution), CONFIRMED (successfully loaned), FAILED (execution failed, nothing happened to your account); +type MarginBorrowStatus string + +const ( + BorrowRepayStatusPending MarginBorrowStatus = "PENDING" + BorrowRepayStatusConfirmed MarginBorrowStatus = "CONFIRMED" + BorrowRepayStatusFailed MarginBorrowStatus = "FAILED" +) + +type BorrowRepayType string + +const ( + BorrowRepayTypeBorrow BorrowRepayType = "BORROW" + BorrowRepayTypeRepay BorrowRepayType = "REPAY" +) + +type MarginBorrowRepayRecord struct { + IsolatedSymbol string `json:"isolatedSymbol"` + Amount fixedpoint.Value `json:"amount"` + Asset string `json:"asset"` + Interest fixedpoint.Value `json:"interest"` + Principal fixedpoint.Value `json:"principal"` + Status MarginBorrowStatus `json:"status"` + Timestamp types.MillisecondTimestamp `json:"timestamp"` + TxId uint64 `json:"txId"` +} + +// GetMarginBorrowRepayHistoryRequest +// +// txId or startTime must be sent. txId takes precedence. +// Response in descending order +// If isolatedSymbol is not sent, crossed margin data will be returned +// The max interval between startTime and endTime is 30 days. +// If startTime and endTime not sent, return records of the last 7 days by default +// Set archived to true to query data from 6 months ago +// +//go:generate requestgen -method GET -url "/sapi/v1/margin/borrow-repay" -type GetMarginBorrowRepayHistoryRequest -responseType .RowsResponse -responseDataField Rows -responseDataType []MarginBorrowRepayRecord +type GetMarginBorrowRepayHistoryRequest struct { + client requestgen.AuthenticatedAPIClient + + asset string `param:"asset"` + startTime *time.Time `param:"startTime,milliseconds"` + endTime *time.Time `param:"endTime,milliseconds"` + isolatedSymbol *string `param:"isolatedSymbol"` + archived *bool `param:"archived"` + size *int `param:"size"` + current *int `param:"current"` + BorrowRepayType BorrowRepayType `param:"type"` +} + +func (c *RestClient) NewGetMarginBorrowRepayHistoryRequest() *GetMarginBorrowRepayHistoryRequest { + return &GetMarginBorrowRepayHistoryRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/get_margin_borrow_repay_history_request_requestgen.go b/pkg/exchange/binance/binanceapi/get_margin_borrow_repay_history_request_requestgen.go new file mode 100644 index 0000000..a85fc3a --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_margin_borrow_repay_history_request_requestgen.go @@ -0,0 +1,273 @@ +// Code generated by "requestgen -method GET -url /sapi/v1/margin/borrow-repay -type GetMarginBorrowRepayHistoryRequest -responseType .RowsResponse -responseDataField Rows -responseDataType []MarginBorrowRepayRecord"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetMarginBorrowRepayHistoryRequest) Asset(asset string) *GetMarginBorrowRepayHistoryRequest { + g.asset = asset + return g +} + +func (g *GetMarginBorrowRepayHistoryRequest) StartTime(startTime time.Time) *GetMarginBorrowRepayHistoryRequest { + g.startTime = &startTime + return g +} + +func (g *GetMarginBorrowRepayHistoryRequest) EndTime(endTime time.Time) *GetMarginBorrowRepayHistoryRequest { + g.endTime = &endTime + return g +} + +func (g *GetMarginBorrowRepayHistoryRequest) IsolatedSymbol(isolatedSymbol string) *GetMarginBorrowRepayHistoryRequest { + g.isolatedSymbol = &isolatedSymbol + return g +} + +func (g *GetMarginBorrowRepayHistoryRequest) Archived(archived bool) *GetMarginBorrowRepayHistoryRequest { + g.archived = &archived + return g +} + +func (g *GetMarginBorrowRepayHistoryRequest) Size(size int) *GetMarginBorrowRepayHistoryRequest { + g.size = &size + return g +} + +func (g *GetMarginBorrowRepayHistoryRequest) Current(current int) *GetMarginBorrowRepayHistoryRequest { + g.current = ¤t + return g +} + +func (g *GetMarginBorrowRepayHistoryRequest) SetBorrowRepayType(BorrowRepayType BorrowRepayType) *GetMarginBorrowRepayHistoryRequest { + g.BorrowRepayType = BorrowRepayType + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetMarginBorrowRepayHistoryRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetMarginBorrowRepayHistoryRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check asset field -> json key asset + asset := g.asset + + // assign parameter of asset + params["asset"] = asset + // check startTime field -> json key startTime + if g.startTime != nil { + startTime := *g.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["startTime"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key endTime + if g.endTime != nil { + endTime := *g.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["endTime"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check isolatedSymbol field -> json key isolatedSymbol + if g.isolatedSymbol != nil { + isolatedSymbol := *g.isolatedSymbol + + // assign parameter of isolatedSymbol + params["isolatedSymbol"] = isolatedSymbol + } else { + } + // check archived field -> json key archived + if g.archived != nil { + archived := *g.archived + + // assign parameter of archived + params["archived"] = archived + } else { + } + // check size field -> json key size + if g.size != nil { + size := *g.size + + // assign parameter of size + params["size"] = size + } else { + } + // check current field -> json key current + if g.current != nil { + current := *g.current + + // assign parameter of current + params["current"] = current + } else { + } + // check BorrowRepayType field -> json key type + BorrowRepayType := g.BorrowRepayType + + // TEMPLATE check-valid-values + switch BorrowRepayType { + case BorrowRepayTypeBorrow, BorrowRepayTypeRepay: + params["type"] = BorrowRepayType + + default: + return nil, fmt.Errorf("type value %v is invalid", BorrowRepayType) + + } + // END TEMPLATE check-valid-values + + // assign parameter of BorrowRepayType + params["type"] = BorrowRepayType + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetMarginBorrowRepayHistoryRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetMarginBorrowRepayHistoryRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetMarginBorrowRepayHistoryRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetMarginBorrowRepayHistoryRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetMarginBorrowRepayHistoryRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetMarginBorrowRepayHistoryRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetMarginBorrowRepayHistoryRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (g *GetMarginBorrowRepayHistoryRequest) GetPath() string { + return "/sapi/v1/margin/borrow-repay" +} + +// Do generates the request object and send the request object to the API endpoint +func (g *GetMarginBorrowRepayHistoryRequest) Do(ctx context.Context) ([]MarginBorrowRepayRecord, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + var apiURL string + + apiURL = g.GetPath() + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse RowsResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data []MarginBorrowRepayRecord + if err := json.Unmarshal(apiResponse.Rows, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/binance/binanceapi/get_margin_borrow_repay_history_request_test.go b/pkg/exchange/binance/binanceapi/get_margin_borrow_repay_history_request_test.go new file mode 100644 index 0000000..4d58c3f --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_margin_borrow_repay_history_request_test.go @@ -0,0 +1,29 @@ +package binanceapi + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func Test_GetMarginLoanHistoryRequest(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + + err := client.SetTimeOffsetFromServer(ctx) + assert.NoError(t, err) + + req := client.NewGetMarginBorrowRepayHistoryRequest() + req.Asset("USDT") + req.IsolatedSymbol("DOTUSDT") + req.StartTime(time.Date(2022, time.February, 1, 0, 0, 0, 0, time.UTC)) + req.EndTime(time.Date(2022, time.March, 1, 0, 0, 0, 0, time.UTC)) + req.Size(100) + + records, err := req.Do(ctx) + assert.NoError(t, err) + assert.NotEmpty(t, records) + t.Logf("loans: %+v", records) +} diff --git a/pkg/exchange/binance/binanceapi/get_margin_interest_history_request.go b/pkg/exchange/binance/binanceapi/get_margin_interest_history_request.go new file mode 100644 index 0000000..1e94a6c --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_margin_interest_history_request.go @@ -0,0 +1,52 @@ +package binanceapi + +import ( + "time" + + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// interest type in response has 4 enums: +// PERIODIC interest charged per hour +// ON_BORROW first interest charged on borrow +// PERIODIC_CONVERTED interest charged per hour converted into BNB +// ON_BORROW_CONVERTED first interest charged on borrow converted into BNB +type InterestType string + +const ( + InterestTypePeriodic InterestType = "PERIODIC" + InterestTypeOnBorrow InterestType = "ON_BORROW" + InterestTypePeriodicConverted InterestType = "PERIODIC_CONVERTED" + InterestTypeOnBorrowConverted InterestType = "ON_BORROW_CONVERTED" +) + +// MarginInterest is the user margin interest record +type MarginInterest struct { + IsolatedSymbol string `json:"isolatedSymbol"` + Asset string `json:"asset"` + Interest fixedpoint.Value `json:"interest"` + InterestAccuredTime types.MillisecondTimestamp `json:"interestAccuredTime"` + InterestRate fixedpoint.Value `json:"interestRate"` + Principal fixedpoint.Value `json:"principal"` + Type InterestType `json:"type"` +} + +//go:generate requestgen -method GET -url "/sapi/v1/margin/interestHistory" -type GetMarginInterestHistoryRequest -responseType .RowsResponse -responseDataField Rows -responseDataType []MarginInterest +type GetMarginInterestHistoryRequest struct { + client requestgen.AuthenticatedAPIClient + + asset string `param:"asset"` + startTime *time.Time `param:"startTime,milliseconds"` + endTime *time.Time `param:"endTime,milliseconds"` + isolatedSymbol *string `param:"isolatedSymbol"` + archived *bool `param:"archived"` + size *int `param:"size"` + current *int `param:"current"` +} + +func (c *RestClient) NewGetMarginInterestHistoryRequest() *GetMarginInterestHistoryRequest { + return &GetMarginInterestHistoryRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/get_margin_interest_history_request_requestgen.go b/pkg/exchange/binance/binanceapi/get_margin_interest_history_request_requestgen.go new file mode 100644 index 0000000..b73d167 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_margin_interest_history_request_requestgen.go @@ -0,0 +1,234 @@ +// Code generated by "requestgen -method GET -url /sapi/v1/margin/interestHistory -type GetMarginInterestHistoryRequest -responseType .RowsResponse -responseDataField Rows -responseDataType []MarginInterest"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetMarginInterestHistoryRequest) Asset(asset string) *GetMarginInterestHistoryRequest { + g.asset = asset + return g +} + +func (g *GetMarginInterestHistoryRequest) StartTime(startTime time.Time) *GetMarginInterestHistoryRequest { + g.startTime = &startTime + return g +} + +func (g *GetMarginInterestHistoryRequest) EndTime(endTime time.Time) *GetMarginInterestHistoryRequest { + g.endTime = &endTime + return g +} + +func (g *GetMarginInterestHistoryRequest) IsolatedSymbol(isolatedSymbol string) *GetMarginInterestHistoryRequest { + g.isolatedSymbol = &isolatedSymbol + return g +} + +func (g *GetMarginInterestHistoryRequest) Archived(archived bool) *GetMarginInterestHistoryRequest { + g.archived = &archived + return g +} + +func (g *GetMarginInterestHistoryRequest) Size(size int) *GetMarginInterestHistoryRequest { + g.size = &size + return g +} + +func (g *GetMarginInterestHistoryRequest) Current(current int) *GetMarginInterestHistoryRequest { + g.current = ¤t + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetMarginInterestHistoryRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetMarginInterestHistoryRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check asset field -> json key asset + asset := g.asset + + // assign parameter of asset + params["asset"] = asset + // check startTime field -> json key startTime + if g.startTime != nil { + startTime := *g.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["startTime"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key endTime + if g.endTime != nil { + endTime := *g.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["endTime"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check isolatedSymbol field -> json key isolatedSymbol + if g.isolatedSymbol != nil { + isolatedSymbol := *g.isolatedSymbol + + // assign parameter of isolatedSymbol + params["isolatedSymbol"] = isolatedSymbol + } else { + } + // check archived field -> json key archived + if g.archived != nil { + archived := *g.archived + + // assign parameter of archived + params["archived"] = archived + } else { + } + // check size field -> json key size + if g.size != nil { + size := *g.size + + // assign parameter of size + params["size"] = size + } else { + } + // check current field -> json key current + if g.current != nil { + current := *g.current + + // assign parameter of current + params["current"] = current + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetMarginInterestHistoryRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetMarginInterestHistoryRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetMarginInterestHistoryRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetMarginInterestHistoryRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetMarginInterestHistoryRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetMarginInterestHistoryRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetMarginInterestHistoryRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetMarginInterestHistoryRequest) Do(ctx context.Context) ([]MarginInterest, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/sapi/v1/margin/interestHistory" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse RowsResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data []MarginInterest + if err := json.Unmarshal(apiResponse.Rows, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/binance/binanceapi/get_margin_interest_history_request_test.go b/pkg/exchange/binance/binanceapi/get_margin_interest_history_request_test.go new file mode 100644 index 0000000..60540c3 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_margin_interest_history_request_test.go @@ -0,0 +1,29 @@ +package binanceapi + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func Test_GetMarginInterestHistoryRequest(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + + err := client.SetTimeOffsetFromServer(ctx) + assert.NoError(t, err) + + req := client.NewGetMarginInterestHistoryRequest() + req.Asset("USDT") + req.IsolatedSymbol("DOTUSDT") + req.StartTime(time.Date(2022, time.February, 1, 0, 0, 0, 0, time.UTC)) + req.EndTime(time.Date(2022, time.March, 1, 0, 0, 0, 0, time.UTC)) + req.Size(100) + + records, err := req.Do(ctx) + assert.NoError(t, err) + assert.NotEmpty(t, records) + t.Logf("interest: %+v", records) +} diff --git a/pkg/exchange/binance/binanceapi/get_margin_interest_rate_history_request.go b/pkg/exchange/binance/binanceapi/get_margin_interest_rate_history_request.go new file mode 100644 index 0000000..676fbd3 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_margin_interest_rate_history_request.go @@ -0,0 +1,30 @@ +package binanceapi + +import ( + "time" + + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type MarginInterestRate struct { + Asset string `json:"asset"` + DailyInterestRate fixedpoint.Value `json:"dailyInterestRate"` + Timestamp types.MillisecondTimestamp `json:"timestamp"` + VipLevel int `json:"vipLevel"` +} + +//go:generate requestgen -method GET -url "/sapi/v1/margin/interestRateHistory" -type GetMarginInterestRateHistoryRequest -responseType []MarginInterestRate +type GetMarginInterestRateHistoryRequest struct { + client requestgen.APIClient + + asset string `param:"asset"` + startTime *time.Time `param:"startTime,milliseconds"` + endTime *time.Time `param:"endTime,milliseconds"` +} + +func (c *RestClient) NewGetMarginInterestRateHistoryRequest() *GetMarginInterestRateHistoryRequest { + return &GetMarginInterestRateHistoryRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/get_margin_interest_rate_history_request_requestgen.go b/pkg/exchange/binance/binanceapi/get_margin_interest_rate_history_request_requestgen.go new file mode 100644 index 0000000..1f80665 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_margin_interest_rate_history_request_requestgen.go @@ -0,0 +1,178 @@ +// Code generated by "requestgen -method GET -url /sapi/v1/margin/interestRateHistory -type GetMarginInterestRateHistoryRequest -responseType []MarginInterestRate"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetMarginInterestRateHistoryRequest) Asset(asset string) *GetMarginInterestRateHistoryRequest { + g.asset = asset + return g +} + +func (g *GetMarginInterestRateHistoryRequest) StartTime(startTime time.Time) *GetMarginInterestRateHistoryRequest { + g.startTime = &startTime + return g +} + +func (g *GetMarginInterestRateHistoryRequest) EndTime(endTime time.Time) *GetMarginInterestRateHistoryRequest { + g.endTime = &endTime + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetMarginInterestRateHistoryRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetMarginInterestRateHistoryRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check asset field -> json key asset + asset := g.asset + + // assign parameter of asset + params["asset"] = asset + // check startTime field -> json key startTime + if g.startTime != nil { + startTime := *g.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["startTime"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key endTime + if g.endTime != nil { + endTime := *g.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["endTime"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetMarginInterestRateHistoryRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetMarginInterestRateHistoryRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetMarginInterestRateHistoryRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetMarginInterestRateHistoryRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetMarginInterestRateHistoryRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetMarginInterestRateHistoryRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetMarginInterestRateHistoryRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetMarginInterestRateHistoryRequest) Do(ctx context.Context) ([]MarginInterestRate, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/sapi/v1/margin/interestRateHistory" + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []MarginInterestRate + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/get_margin_liquidation_history_request.go b/pkg/exchange/binance/binanceapi/get_margin_liquidation_history_request.go new file mode 100644 index 0000000..d5fe7f6 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_margin_liquidation_history_request.go @@ -0,0 +1,38 @@ +package binanceapi + +import ( + "time" + + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type MarginLiquidationRecord struct { + AveragePrice fixedpoint.Value `json:"avgPrice"` + ExecutedQuantity fixedpoint.Value `json:"executedQty"` + OrderId uint64 `json:"orderId"` + Price fixedpoint.Value `json:"price"` + Quantity fixedpoint.Value `json:"qty"` + Side SideType `json:"side"` + Symbol string `json:"symbol"` + TimeInForce string `json:"timeInForce"` + IsIsolated bool `json:"isIsolated"` + UpdatedTime types.MillisecondTimestamp `json:"updatedTime"` +} + +//go:generate requestgen -method GET -url "/sapi/v1/margin/forceLiquidationRec" -type GetMarginLiquidationHistoryRequest -responseType .RowsResponse -responseDataField Rows -responseDataType []MarginLiquidationRecord +type GetMarginLiquidationHistoryRequest struct { + client requestgen.AuthenticatedAPIClient + + isolatedSymbol *string `param:"isolatedSymbol"` + startTime *time.Time `param:"startTime,milliseconds"` + endTime *time.Time `param:"endTime,milliseconds"` + size *int `param:"size"` + current *int `param:"current"` +} + +func (c *RestClient) NewGetMarginLiquidationHistoryRequest() *GetMarginLiquidationHistoryRequest { + return &GetMarginLiquidationHistoryRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/get_margin_liquidation_history_request_requestgen.go b/pkg/exchange/binance/binanceapi/get_margin_liquidation_history_request_requestgen.go new file mode 100644 index 0000000..9424919 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_margin_liquidation_history_request_requestgen.go @@ -0,0 +1,211 @@ +// Code generated by "requestgen -method GET -url /sapi/v1/margin/forceLiquidationRec -type GetMarginLiquidationHistoryRequest -responseType .RowsResponse -responseDataField Rows -responseDataType []MarginLiquidationRecord"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetMarginLiquidationHistoryRequest) IsolatedSymbol(isolatedSymbol string) *GetMarginLiquidationHistoryRequest { + g.isolatedSymbol = &isolatedSymbol + return g +} + +func (g *GetMarginLiquidationHistoryRequest) StartTime(startTime time.Time) *GetMarginLiquidationHistoryRequest { + g.startTime = &startTime + return g +} + +func (g *GetMarginLiquidationHistoryRequest) EndTime(endTime time.Time) *GetMarginLiquidationHistoryRequest { + g.endTime = &endTime + return g +} + +func (g *GetMarginLiquidationHistoryRequest) Size(size int) *GetMarginLiquidationHistoryRequest { + g.size = &size + return g +} + +func (g *GetMarginLiquidationHistoryRequest) Current(current int) *GetMarginLiquidationHistoryRequest { + g.current = ¤t + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetMarginLiquidationHistoryRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetMarginLiquidationHistoryRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check isolatedSymbol field -> json key isolatedSymbol + if g.isolatedSymbol != nil { + isolatedSymbol := *g.isolatedSymbol + + // assign parameter of isolatedSymbol + params["isolatedSymbol"] = isolatedSymbol + } else { + } + // check startTime field -> json key startTime + if g.startTime != nil { + startTime := *g.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["startTime"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key endTime + if g.endTime != nil { + endTime := *g.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["endTime"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check size field -> json key size + if g.size != nil { + size := *g.size + + // assign parameter of size + params["size"] = size + } else { + } + // check current field -> json key current + if g.current != nil { + current := *g.current + + // assign parameter of current + params["current"] = current + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetMarginLiquidationHistoryRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetMarginLiquidationHistoryRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetMarginLiquidationHistoryRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetMarginLiquidationHistoryRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetMarginLiquidationHistoryRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetMarginLiquidationHistoryRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetMarginLiquidationHistoryRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetMarginLiquidationHistoryRequest) Do(ctx context.Context) ([]MarginLiquidationRecord, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/sapi/v1/margin/forceLiquidationRec" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse RowsResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data []MarginLiquidationRecord + if err := json.Unmarshal(apiResponse.Rows, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/binance/binanceapi/get_margin_max_borrowable_request.go b/pkg/exchange/binance/binanceapi/get_margin_max_borrowable_request.go new file mode 100644 index 0000000..26ae890 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_margin_max_borrowable_request.go @@ -0,0 +1,25 @@ +package binanceapi + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +// MarginMaxBorrowable is the user margin interest record +type MarginMaxBorrowable struct { + Amount fixedpoint.Value `json:"amount"` + BorrowLimit fixedpoint.Value `json:"borrowLimit"` +} + +//go:generate requestgen -method GET -url "/sapi/v1/margin/maxBorrowable" -type GetMarginMaxBorrowableRequest -responseType .MarginMaxBorrowable +type GetMarginMaxBorrowableRequest struct { + client requestgen.AuthenticatedAPIClient + + asset string `param:"asset"` + isolatedSymbol *string `param:"isolatedSymbol"` +} + +func (c *RestClient) NewGetMarginMaxBorrowableRequest() *GetMarginMaxBorrowableRequest { + return &GetMarginMaxBorrowableRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/get_margin_max_borrowable_request_requestgen.go b/pkg/exchange/binance/binanceapi/get_margin_max_borrowable_request_requestgen.go new file mode 100644 index 0000000..a4b3298 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_margin_max_borrowable_request_requestgen.go @@ -0,0 +1,161 @@ +// Code generated by "requestgen -method GET -url /sapi/v1/margin/maxBorrowable -type GetMarginMaxBorrowableRequest -responseType .MarginMaxBorrowable"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (g *GetMarginMaxBorrowableRequest) Asset(asset string) *GetMarginMaxBorrowableRequest { + g.asset = asset + return g +} + +func (g *GetMarginMaxBorrowableRequest) IsolatedSymbol(isolatedSymbol string) *GetMarginMaxBorrowableRequest { + g.isolatedSymbol = &isolatedSymbol + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetMarginMaxBorrowableRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetMarginMaxBorrowableRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check asset field -> json key asset + asset := g.asset + + // assign parameter of asset + params["asset"] = asset + // check isolatedSymbol field -> json key isolatedSymbol + if g.isolatedSymbol != nil { + isolatedSymbol := *g.isolatedSymbol + + // assign parameter of isolatedSymbol + params["isolatedSymbol"] = isolatedSymbol + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetMarginMaxBorrowableRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetMarginMaxBorrowableRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetMarginMaxBorrowableRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetMarginMaxBorrowableRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetMarginMaxBorrowableRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetMarginMaxBorrowableRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetMarginMaxBorrowableRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetMarginMaxBorrowableRequest) Do(ctx context.Context) (*MarginMaxBorrowable, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/sapi/v1/margin/maxBorrowable" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse MarginMaxBorrowable + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return &apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/get_margin_trades_request.go b/pkg/exchange/binance/binanceapi/get_margin_trades_request.go new file mode 100644 index 0000000..4d5aab2 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_margin_trades_request.go @@ -0,0 +1,24 @@ +package binanceapi + +import ( + "time" + + "github.com/c9s/requestgen" +) + +//go:generate requestgen -method GET -url "/sapi/v1/margin/myTrades" -type GetMarginTradesRequest -responseType []Trade +type GetMarginTradesRequest struct { + client requestgen.AuthenticatedAPIClient + + isIsolated bool `param:"isIsolated"` + symbol string `param:"symbol"` + orderID *uint64 `param:"orderId"` + startTime *time.Time `param:"startTime,milliseconds"` + endTime *time.Time `param:"endTime,milliseconds"` + fromID *uint64 `param:"fromId"` + limit *uint64 `param:"limit"` +} + +func (c *RestClient) NewGetMarginTradesRequest() *GetMarginTradesRequest { + return &GetMarginTradesRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/get_margin_trades_request_requestgen.go b/pkg/exchange/binance/binanceapi/get_margin_trades_request_requestgen.go new file mode 100644 index 0000000..6f4f7af --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_margin_trades_request_requestgen.go @@ -0,0 +1,260 @@ +// Code generated by "requestgen -method GET -url /sapi/v1/margin/myTrades -type GetMarginTradesRequest -responseType []Trade"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "github.com/adshao/go-binance/v2" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetMarginTradesRequest) IsIsolated(isIsolated bool) *GetMarginTradesRequest { + g.isIsolated = isIsolated + return g +} + +func (g *GetMarginTradesRequest) Symbol(symbol string) *GetMarginTradesRequest { + g.symbol = symbol + return g +} + +func (g *GetMarginTradesRequest) OrderID(orderID uint64) *GetMarginTradesRequest { + g.orderID = &orderID + return g +} + +func (g *GetMarginTradesRequest) StartTime(startTime time.Time) *GetMarginTradesRequest { + g.startTime = &startTime + return g +} + +func (g *GetMarginTradesRequest) EndTime(endTime time.Time) *GetMarginTradesRequest { + g.endTime = &endTime + return g +} + +func (g *GetMarginTradesRequest) FromID(fromID uint64) *GetMarginTradesRequest { + g.fromID = &fromID + return g +} + +func (g *GetMarginTradesRequest) Limit(limit uint64) *GetMarginTradesRequest { + g.limit = &limit + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetMarginTradesRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetMarginTradesRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check isIsolated field -> json key isIsolated + isIsolated := g.isIsolated + + // assign parameter of isIsolated + params["isIsolated"] = isIsolated + // check symbol field -> json key symbol + symbol := g.symbol + + // assign parameter of symbol + params["symbol"] = symbol + // check orderID field -> json key orderId + if g.orderID != nil { + orderID := *g.orderID + + // assign parameter of orderID + params["orderId"] = orderID + } else { + } + // check startTime field -> json key startTime + if g.startTime != nil { + startTime := *g.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["startTime"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key endTime + if g.endTime != nil { + endTime := *g.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["endTime"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check fromID field -> json key fromId + if g.fromID != nil { + fromID := *g.fromID + + // assign parameter of fromID + params["fromId"] = fromID + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetMarginTradesRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetMarginTradesRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetMarginTradesRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetMarginTradesRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetMarginTradesRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetMarginTradesRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetMarginTradesRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (g *GetMarginTradesRequest) GetPath() string { + return "/sapi/v1/margin/myTrades" +} + +// Do generates the request object and send the request object to the API endpoint +func (g *GetMarginTradesRequest) Do(ctx context.Context) ([]binance.TradeV3, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + var apiURL string + + apiURL = g.GetPath() + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []binance.TradeV3 + + type responseUnmarshaler interface { + Unmarshal(data []byte) error + } + + if unmarshaler, ok := interface{}(&apiResponse).(responseUnmarshaler); ok { + if err := unmarshaler.Unmarshal(response.Body); err != nil { + return nil, err + } + } else { + // The line below checks the content type, however, some API server might not send the correct content type header, + // Hence, this is commented for backward compatibility + // response.IsJSON() + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + } + + type responseValidator interface { + Validate() error + } + + if validator, ok := interface{}(&apiResponse).(responseValidator); ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + return apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/get_my_trades_request.go b/pkg/exchange/binance/binanceapi/get_my_trades_request.go new file mode 100644 index 0000000..8986ca7 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_my_trades_request.go @@ -0,0 +1,27 @@ +package binanceapi + +import ( + "time" + + "github.com/c9s/requestgen" + + "github.com/adshao/go-binance/v2" +) + +type Trade = binance.TradeV3 + +//go:generate requestgen -method GET -url "/api/v3/myTrades" -type GetMyTradesRequest -responseType []Trade +type GetMyTradesRequest struct { + client requestgen.AuthenticatedAPIClient + + symbol string `param:"symbol"` + orderID *uint64 `param:"orderId"` + startTime *time.Time `param:"startTime,milliseconds"` + endTime *time.Time `param:"endTime,milliseconds"` + fromID *uint64 `param:"fromId"` + limit *uint64 `param:"limit"` +} + +func (c *RestClient) NewGetMyTradesRequest() *GetMyTradesRequest { + return &GetMyTradesRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/get_my_trades_request_requestgen.go b/pkg/exchange/binance/binanceapi/get_my_trades_request_requestgen.go new file mode 100644 index 0000000..316b640 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_my_trades_request_requestgen.go @@ -0,0 +1,218 @@ +// Code generated by "requestgen -method GET -url /api/v3/myTrades -type GetMyTradesRequest -responseType []Trade"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "github.com/adshao/go-binance/v2" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetMyTradesRequest) Symbol(symbol string) *GetMyTradesRequest { + g.symbol = symbol + return g +} + +func (g *GetMyTradesRequest) OrderID(orderID uint64) *GetMyTradesRequest { + g.orderID = &orderID + return g +} + +func (g *GetMyTradesRequest) StartTime(startTime time.Time) *GetMyTradesRequest { + g.startTime = &startTime + return g +} + +func (g *GetMyTradesRequest) EndTime(endTime time.Time) *GetMyTradesRequest { + g.endTime = &endTime + return g +} + +func (g *GetMyTradesRequest) FromID(fromID uint64) *GetMyTradesRequest { + g.fromID = &fromID + return g +} + +func (g *GetMyTradesRequest) Limit(limit uint64) *GetMyTradesRequest { + g.limit = &limit + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetMyTradesRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetMyTradesRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := g.symbol + + // assign parameter of symbol + params["symbol"] = symbol + // check orderID field -> json key orderId + if g.orderID != nil { + orderID := *g.orderID + + // assign parameter of orderID + params["orderId"] = orderID + } else { + } + // check startTime field -> json key startTime + if g.startTime != nil { + startTime := *g.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["startTime"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key endTime + if g.endTime != nil { + endTime := *g.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["endTime"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check fromID field -> json key fromId + if g.fromID != nil { + fromID := *g.fromID + + // assign parameter of fromID + params["fromId"] = fromID + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetMyTradesRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetMyTradesRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetMyTradesRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetMyTradesRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetMyTradesRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetMyTradesRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetMyTradesRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetMyTradesRequest) Do(ctx context.Context) ([]binance.TradeV3, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/api/v3/myTrades" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []binance.TradeV3 + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/get_spot_rebate_history_request.go b/pkg/exchange/binance/binanceapi/get_spot_rebate_history_request.go new file mode 100644 index 0000000..7c314fe --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_spot_rebate_history_request.go @@ -0,0 +1,42 @@ +package binanceapi + +import ( + "time" + + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// rebate type:1 is commission rebate,2 is referral kickback +type RebateType int + +const ( + RebateTypeCommission = 1 + RebateTypeReferralKickback = 2 +) + +type SpotRebate struct { + Asset string `json:"asset"` + Type RebateType `json:"type"` + Amount fixedpoint.Value `json:"amount"` + UpdateTime types.MillisecondTimestamp `json:"updateTime"` +} + +// GetSpotRebateHistoryRequest +// The max interval between startTime and endTime is 30 days. +// If startTime and endTime are not sent, the recent 7 days' data will be returned. +// The earliest startTime is supported on June 10, 2020 +// +//go:generate requestgen -method GET -url "/sapi/v1/rebate/taxQuery" -type GetSpotRebateHistoryRequest -responseType PagedDataResponse -responseDataField Data.Data -responseDataType []SpotRebate +type GetSpotRebateHistoryRequest struct { + client requestgen.AuthenticatedAPIClient + + startTime *time.Time `param:"startTime,milliseconds"` + endTime *time.Time `param:"endTime,milliseconds"` +} + +func (c *RestClient) NewGetSpotRebateHistoryRequest() *GetSpotRebateHistoryRequest { + return &GetSpotRebateHistoryRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/get_spot_rebate_history_request_requestgen.go b/pkg/exchange/binance/binanceapi/get_spot_rebate_history_request_requestgen.go new file mode 100644 index 0000000..05cc5b6 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_spot_rebate_history_request_requestgen.go @@ -0,0 +1,172 @@ +// Code generated by "requestgen -method GET -url /sapi/v1/rebate/taxQuery -type GetSpotRebateHistoryRequest -responseType PagedDataResponse -responseDataField Data.Data -responseDataType []SpotRebate"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetSpotRebateHistoryRequest) StartTime(startTime time.Time) *GetSpotRebateHistoryRequest { + g.startTime = &startTime + return g +} + +func (g *GetSpotRebateHistoryRequest) EndTime(endTime time.Time) *GetSpotRebateHistoryRequest { + g.endTime = &endTime + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetSpotRebateHistoryRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetSpotRebateHistoryRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check startTime field -> json key startTime + if g.startTime != nil { + startTime := *g.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["startTime"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key endTime + if g.endTime != nil { + endTime := *g.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["endTime"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetSpotRebateHistoryRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetSpotRebateHistoryRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetSpotRebateHistoryRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetSpotRebateHistoryRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetSpotRebateHistoryRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetSpotRebateHistoryRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetSpotRebateHistoryRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetSpotRebateHistoryRequest) Do(ctx context.Context) ([]SpotRebate, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/sapi/v1/rebate/taxQuery" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse PagedDataResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data []SpotRebate + if err := json.Unmarshal(apiResponse.Data.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/binance/binanceapi/get_trade_fee_request.go b/pkg/exchange/binance/binanceapi/get_trade_fee_request.go new file mode 100644 index 0000000..3b67444 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_trade_fee_request.go @@ -0,0 +1,22 @@ +package binanceapi + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +type TradeFee struct { + Symbol string `json:"symbol"` + MakerCommission fixedpoint.Value `json:"makerCommission"` + TakerCommission fixedpoint.Value `json:"takerCommission"` +} + +//go:generate requestgen -method GET -url "/sapi/v1/asset/tradeFee" -type GetTradeFeeRequest -responseType []TradeFee +type GetTradeFeeRequest struct { + client requestgen.AuthenticatedAPIClient +} + +func (c *RestClient) NewGetTradeFeeRequest() *GetTradeFeeRequest { + return &GetTradeFeeRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/get_trade_fee_request_requestgen.go b/pkg/exchange/binance/binanceapi/get_trade_fee_request_requestgen.go new file mode 100644 index 0000000..77aac0c --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_trade_fee_request_requestgen.go @@ -0,0 +1,135 @@ +// Code generated by "requestgen -method GET -url /sapi/v1/asset/tradeFee -type GetTradeFeeRequest -responseType []TradeFee"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetTradeFeeRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetTradeFeeRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetTradeFeeRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetTradeFeeRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetTradeFeeRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetTradeFeeRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetTradeFeeRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetTradeFeeRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetTradeFeeRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetTradeFeeRequest) Do(ctx context.Context) ([]TradeFee, error) { + + // no body params + var params interface{} + query := url.Values{} + + apiURL := "/sapi/v1/asset/tradeFee" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []TradeFee + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/get_withdraw_history_request.go b/pkg/exchange/binance/binanceapi/get_withdraw_history_request.go new file mode 100644 index 0000000..b90b2d5 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_withdraw_history_request.go @@ -0,0 +1,68 @@ +package binanceapi + +import ( + "time" + + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +// 1 for internal transfer, 0 for external transfer +// +//go:generate stringer -type=TransferType +type TransferType int + +const ( + TransferTypeInternal TransferType = 0 + TransferTypeExternal TransferType = 0 +) + +type WithdrawRecord struct { + Id string `json:"id"` + Address string `json:"address"` + Amount fixedpoint.Value `json:"amount"` + ApplyTime string `json:"applyTime"` + Coin string `json:"coin"` + WithdrawOrderID string `json:"withdrawOrderId"` + Network string `json:"network"` + TransferType TransferType `json:"transferType"` + Status WithdrawStatus `json:"status"` + TransactionFee fixedpoint.Value `json:"transactionFee"` + ConfirmNo int `json:"confirmNo"` + Info string `json:"info"` + TxID string `json:"txId"` +} + +//go:generate stringer -type=WithdrawStatus +type WithdrawStatus int + +// WithdrawStatus: 0(0:Email Sent,1:Cancelled 2:Awaiting Approval 3:Rejected 4:Processing 5:Failure 6:Completed) +const ( + WithdrawStatusEmailSent WithdrawStatus = iota + WithdrawStatusCancelled + WithdrawStatusAwaitingApproval + WithdrawStatusRejected + WithdrawStatusProcessing + WithdrawStatusFailure + WithdrawStatusCompleted +) + +//go:generate requestgen -method GET -url "/sapi/v1/capital/withdraw/history" -type GetWithdrawHistoryRequest -responseType []WithdrawRecord +type GetWithdrawHistoryRequest struct { + client requestgen.AuthenticatedAPIClient + coin string `param:"coin"` + + withdrawOrderId *string `param:"withdrawOrderId"` + + status *WithdrawStatus `param:"status"` + + startTime *time.Time `param:"startTime,milliseconds"` + endTime *time.Time `param:"endTime,milliseconds"` + limit *uint64 `param:"limit"` + offset *uint64 `param:"offset"` +} + +func (c *RestClient) NewGetWithdrawHistoryRequest() *GetWithdrawHistoryRequest { + return &GetWithdrawHistoryRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/get_withdraw_history_request_requestgen.go b/pkg/exchange/binance/binanceapi/get_withdraw_history_request_requestgen.go new file mode 100644 index 0000000..74717d3 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/get_withdraw_history_request_requestgen.go @@ -0,0 +1,241 @@ +// Code generated by "requestgen -method GET -url /sapi/v1/capital/withdraw/history -type GetWithdrawHistoryRequest -responseType []WithdrawRecord"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetWithdrawHistoryRequest) Coin(coin string) *GetWithdrawHistoryRequest { + g.coin = coin + return g +} + +func (g *GetWithdrawHistoryRequest) WithdrawOrderId(withdrawOrderId string) *GetWithdrawHistoryRequest { + g.withdrawOrderId = &withdrawOrderId + return g +} + +func (g *GetWithdrawHistoryRequest) Status(status WithdrawStatus) *GetWithdrawHistoryRequest { + g.status = &status + return g +} + +func (g *GetWithdrawHistoryRequest) StartTime(startTime time.Time) *GetWithdrawHistoryRequest { + g.startTime = &startTime + return g +} + +func (g *GetWithdrawHistoryRequest) EndTime(endTime time.Time) *GetWithdrawHistoryRequest { + g.endTime = &endTime + return g +} + +func (g *GetWithdrawHistoryRequest) Limit(limit uint64) *GetWithdrawHistoryRequest { + g.limit = &limit + return g +} + +func (g *GetWithdrawHistoryRequest) Offset(offset uint64) *GetWithdrawHistoryRequest { + g.offset = &offset + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetWithdrawHistoryRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetWithdrawHistoryRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check coin field -> json key coin + coin := g.coin + + // assign parameter of coin + params["coin"] = coin + // check withdrawOrderId field -> json key withdrawOrderId + if g.withdrawOrderId != nil { + withdrawOrderId := *g.withdrawOrderId + + // assign parameter of withdrawOrderId + params["withdrawOrderId"] = withdrawOrderId + } else { + } + // check status field -> json key status + if g.status != nil { + status := *g.status + + // TEMPLATE check-valid-values + switch status { + case WithdrawStatusEmailSent: + params["status"] = status + + default: + return nil, fmt.Errorf("status value %v is invalid", status) + + } + // END TEMPLATE check-valid-values + + // assign parameter of status + params["status"] = status + } else { + } + // check startTime field -> json key startTime + if g.startTime != nil { + startTime := *g.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["startTime"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key endTime + if g.endTime != nil { + endTime := *g.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["endTime"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + // check offset field -> json key offset + if g.offset != nil { + offset := *g.offset + + // assign parameter of offset + params["offset"] = offset + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetWithdrawHistoryRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetWithdrawHistoryRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetWithdrawHistoryRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetWithdrawHistoryRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetWithdrawHistoryRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetWithdrawHistoryRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetWithdrawHistoryRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetWithdrawHistoryRequest) Do(ctx context.Context) ([]WithdrawRecord, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/sapi/v1/capital/withdraw/history" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []WithdrawRecord + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/page.go b/pkg/exchange/binance/binanceapi/page.go new file mode 100644 index 0000000..1daec64 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/page.go @@ -0,0 +1,15 @@ +package binanceapi + +import "encoding/json" + +type PagedDataResponse struct { + Status string `json:"status"` + Type string `json:"type"` + Code string `json:"code"` + Data struct { + Page int `json:"page"` + TotalRecords int `json:"totalRecords"` + TotalPageNum int `json:"totalPageNum"` + Data json.RawMessage `json:"data"` + } `json:"data"` +} diff --git a/pkg/exchange/binance/binanceapi/place_margin_order_request.go b/pkg/exchange/binance/binanceapi/place_margin_order_request.go new file mode 100644 index 0000000..47b9202 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/place_margin_order_request.go @@ -0,0 +1,28 @@ +package binanceapi + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +//go:generate requestgen -method POST -url "/sapi/v1/margin/borrow-repay" -type PlaceMarginOrderRequest -responseType .TransferResponse +type PlaceMarginOrderRequest struct { + client requestgen.AuthenticatedAPIClient + + asset string `param:"asset"` + + // TRUE for Isolated Margin, FALSE for Cross Margin, Default FALSE + isIsolated bool `param:"isIsolated"` + + // Only for Isolated margin + symbol *string `param:"symbol"` + + amount fixedpoint.Value `param:"amount"` + + BorrowRepayType BorrowRepayType `param:"type"` +} + +func (c *RestClient) NewPlaceMarginOrderRequest() *PlaceMarginOrderRequest { + return &PlaceMarginOrderRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/place_margin_order_request_requestgen.go b/pkg/exchange/binance/binanceapi/place_margin_order_request_requestgen.go new file mode 100644 index 0000000..62afa5d --- /dev/null +++ b/pkg/exchange/binance/binanceapi/place_margin_order_request_requestgen.go @@ -0,0 +1,220 @@ +// Code generated by "requestgen -method POST -url /sapi/v1/margin/borrow-repay -type PlaceMarginOrderRequest -responseType .TransferResponse"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "net/url" + "reflect" + "regexp" +) + +func (p *PlaceMarginOrderRequest) Asset(asset string) *PlaceMarginOrderRequest { + p.asset = asset + return p +} + +func (p *PlaceMarginOrderRequest) IsIsolated(isIsolated bool) *PlaceMarginOrderRequest { + p.isIsolated = isIsolated + return p +} + +func (p *PlaceMarginOrderRequest) Symbol(symbol string) *PlaceMarginOrderRequest { + p.symbol = &symbol + return p +} + +func (p *PlaceMarginOrderRequest) Amount(amount fixedpoint.Value) *PlaceMarginOrderRequest { + p.amount = amount + return p +} + +func (p *PlaceMarginOrderRequest) SetBorrowRepayType(BorrowRepayType BorrowRepayType) *PlaceMarginOrderRequest { + p.BorrowRepayType = BorrowRepayType + return p +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (p *PlaceMarginOrderRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (p *PlaceMarginOrderRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check asset field -> json key asset + asset := p.asset + + // assign parameter of asset + params["asset"] = asset + // check isIsolated field -> json key isIsolated + isIsolated := p.isIsolated + + // assign parameter of isIsolated + params["isIsolated"] = isIsolated + // check symbol field -> json key symbol + if p.symbol != nil { + symbol := *p.symbol + + // assign parameter of symbol + params["symbol"] = symbol + } else { + } + // check amount field -> json key amount + amount := p.amount + + // assign parameter of amount + params["amount"] = amount + // check BorrowRepayType field -> json key type + BorrowRepayType := p.BorrowRepayType + + // TEMPLATE check-valid-values + switch BorrowRepayType { + case BorrowRepayTypeBorrow, BorrowRepayTypeRepay: + params["type"] = BorrowRepayType + + default: + return nil, fmt.Errorf("type value %v is invalid", BorrowRepayType) + + } + // END TEMPLATE check-valid-values + + // assign parameter of BorrowRepayType + params["type"] = BorrowRepayType + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (p *PlaceMarginOrderRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := p.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if p.isVarSlice(_v) { + p.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (p *PlaceMarginOrderRequest) GetParametersJSON() ([]byte, error) { + params, err := p.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (p *PlaceMarginOrderRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (p *PlaceMarginOrderRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (p *PlaceMarginOrderRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (p *PlaceMarginOrderRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (p *PlaceMarginOrderRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := p.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (p *PlaceMarginOrderRequest) GetPath() string { + return "/sapi/v1/margin/borrow-repay" +} + +// Do generates the request object and send the request object to the API endpoint +func (p *PlaceMarginOrderRequest) Do(ctx context.Context) (*TransferResponse, error) { + + params, err := p.GetParameters() + if err != nil { + return nil, err + } + query := url.Values{} + + var apiURL string + + apiURL = p.GetPath() + + req, err := p.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := p.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse TransferResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + return &apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/rows.go b/pkg/exchange/binance/binanceapi/rows.go new file mode 100644 index 0000000..6039841 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/rows.go @@ -0,0 +1,8 @@ +package binanceapi + +import "encoding/json" + +type RowsResponse struct { + Rows json.RawMessage `json:"rows"` + Total int `json:"total"` +} diff --git a/pkg/exchange/binance/binanceapi/transfer_asset_request.go b/pkg/exchange/binance/binanceapi/transfer_asset_request.go new file mode 100644 index 0000000..5c3f740 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/transfer_asset_request.go @@ -0,0 +1,36 @@ +package binanceapi + +import ( + "github.com/c9s/requestgen" +) + +type TransferResponse struct { + TranId int `json:"tranId"` +} + +type TransferAssetType string + +const ( + TransferAssetTypeMainToMargin TransferAssetType = "MAIN_MARGIN" + TransferAssetTypeMarginToMain TransferAssetType = "MARGIN_MAIN" + TransferAssetTypeMainToIsolatedMargin TransferAssetType = "MAIN_ISOLATED_MARGIN" + TransferAssetTypeIsolatedMarginToMain TransferAssetType = "ISOLATED_MARGIN_MAIN" +) + +//go:generate requestgen -method POST -url "/sapi/v1/asset/transfer" -type TransferAssetRequest -responseType .TransferResponse +type TransferAssetRequest struct { + client requestgen.AuthenticatedAPIClient + + asset string `param:"asset"` + + transferType TransferAssetType `param:"type"` + + amount string `param:"amount"` + + fromSymbol *string `param:"fromSymbol"` + toSymbol *string `param:"toSymbol"` +} + +func (c *RestClient) NewTransferAssetRequest() *TransferAssetRequest { + return &TransferAssetRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/transfer_asset_request_requestgen.go b/pkg/exchange/binance/binanceapi/transfer_asset_request_requestgen.go new file mode 100644 index 0000000..12e98f0 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/transfer_asset_request_requestgen.go @@ -0,0 +1,222 @@ +// Code generated by "requestgen -method POST -url /sapi/v1/asset/transfer -type TransferAssetRequest -responseType .TransferResponse"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (t *TransferAssetRequest) Asset(asset string) *TransferAssetRequest { + t.asset = asset + return t +} + +func (t *TransferAssetRequest) TransferType(transferType TransferAssetType) *TransferAssetRequest { + t.transferType = transferType + return t +} + +func (t *TransferAssetRequest) Amount(amount string) *TransferAssetRequest { + t.amount = amount + return t +} + +func (t *TransferAssetRequest) FromSymbol(fromSymbol string) *TransferAssetRequest { + t.fromSymbol = &fromSymbol + return t +} + +func (t *TransferAssetRequest) ToSymbol(toSymbol string) *TransferAssetRequest { + t.toSymbol = &toSymbol + return t +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (t *TransferAssetRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (t *TransferAssetRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check asset field -> json key asset + asset := t.asset + + // assign parameter of asset + params["asset"] = asset + // check transferType field -> json key type + transferType := t.transferType + + // TEMPLATE check-valid-values + switch transferType { + case TransferAssetTypeMainToMargin, TransferAssetTypeMarginToMain, TransferAssetTypeMainToIsolatedMargin, TransferAssetTypeIsolatedMarginToMain: + params["type"] = transferType + + default: + return nil, fmt.Errorf("type value %v is invalid", transferType) + + } + // END TEMPLATE check-valid-values + + // assign parameter of transferType + params["type"] = transferType + // check amount field -> json key amount + amount := t.amount + + // assign parameter of amount + params["amount"] = amount + // check fromSymbol field -> json key fromSymbol + if t.fromSymbol != nil { + fromSymbol := *t.fromSymbol + + // assign parameter of fromSymbol + params["fromSymbol"] = fromSymbol + } else { + } + // check toSymbol field -> json key toSymbol + if t.toSymbol != nil { + toSymbol := *t.toSymbol + + // assign parameter of toSymbol + params["toSymbol"] = toSymbol + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (t *TransferAssetRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := t.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if t.isVarSlice(_v) { + t.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (t *TransferAssetRequest) GetParametersJSON() ([]byte, error) { + params, err := t.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (t *TransferAssetRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (t *TransferAssetRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (t *TransferAssetRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (t *TransferAssetRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (t *TransferAssetRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := t.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (t *TransferAssetRequest) GetPath() string { + return "/sapi/v1/asset/transfer" +} + +// Do generates the request object and send the request object to the API endpoint +func (t *TransferAssetRequest) Do(ctx context.Context) (*TransferResponse, error) { + + params, err := t.GetParameters() + if err != nil { + return nil, err + } + query := url.Values{} + + var apiURL string + + apiURL = t.GetPath() + + req, err := t.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := t.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse TransferResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + return &apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/transfertype_string.go b/pkg/exchange/binance/binanceapi/transfertype_string.go new file mode 100644 index 0000000..8fad40b --- /dev/null +++ b/pkg/exchange/binance/binanceapi/transfertype_string.go @@ -0,0 +1,24 @@ +// Code generated by "stringer -type=TransferType"; DO NOT EDIT. + +package binanceapi + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[TransferTypeInternal-0] + _ = x[TransferTypeExternal-0] +} + +const _TransferType_name = "TransferTypeInternal" + +var _TransferType_index = [...]uint8{0, 20} + +func (i TransferType) String() string { + if i < 0 || i >= TransferType(len(_TransferType_index)-1) { + return "TransferType(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _TransferType_name[_TransferType_index[i]:_TransferType_index[i+1]] +} diff --git a/pkg/exchange/binance/binanceapi/withdraw_request.go b/pkg/exchange/binance/binanceapi/withdraw_request.go new file mode 100644 index 0000000..5038829 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/withdraw_request.go @@ -0,0 +1,41 @@ +package binanceapi + +import "github.com/c9s/requestgen" + +type WalletType int + +const ( + WalletTypeSpot WalletType = 0 + WalletTypeFunding WalletType = 1 +) + +type WithdrawResponse struct { + ID string `json:"id"` +} + +//go:generate requestgen -method POST -url "/sapi/v1/capital/withdraw/apply" -type WithdrawRequest -responseType .WithdrawResponse +type WithdrawRequest struct { + client requestgen.AuthenticatedAPIClient + coin string `param:"coin"` + network *string `param:"network"` + + address string `param:"address"` + addressTag *string `param:"addressTag"` + + // amount is a decimal in string format + amount string `param:"amount"` + + withdrawOrderId *string `param:"withdrawOrderId"` + + transactionFeeFlag *bool `param:"transactionFeeFlag"` + + // name is the address name + name *string `param:"name"` + + // The wallet type for withdraw: 0-spot wallet ,1-funding wallet.Default spot wallet + walletType *WalletType `param:"walletType"` +} + +func (c *RestClient) NewWithdrawRequest() *WithdrawRequest { + return &WithdrawRequest{client: c} +} diff --git a/pkg/exchange/binance/binanceapi/withdraw_request_requestgen.go b/pkg/exchange/binance/binanceapi/withdraw_request_requestgen.go new file mode 100644 index 0000000..557041c --- /dev/null +++ b/pkg/exchange/binance/binanceapi/withdraw_request_requestgen.go @@ -0,0 +1,256 @@ +// Code generated by "requestgen -method POST -url /sapi/v1/capital/withdraw/apply -type WithdrawRequest -responseType .WithdrawResponse"; DO NOT EDIT. + +package binanceapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (w *WithdrawRequest) Coin(coin string) *WithdrawRequest { + w.coin = coin + return w +} + +func (w *WithdrawRequest) Network(network string) *WithdrawRequest { + w.network = &network + return w +} + +func (w *WithdrawRequest) Address(address string) *WithdrawRequest { + w.address = address + return w +} + +func (w *WithdrawRequest) AddressTag(addressTag string) *WithdrawRequest { + w.addressTag = &addressTag + return w +} + +func (w *WithdrawRequest) Amount(amount string) *WithdrawRequest { + w.amount = amount + return w +} + +func (w *WithdrawRequest) WithdrawOrderId(withdrawOrderId string) *WithdrawRequest { + w.withdrawOrderId = &withdrawOrderId + return w +} + +func (w *WithdrawRequest) TransactionFeeFlag(transactionFeeFlag bool) *WithdrawRequest { + w.transactionFeeFlag = &transactionFeeFlag + return w +} + +func (w *WithdrawRequest) Name(name string) *WithdrawRequest { + w.name = &name + return w +} + +func (w *WithdrawRequest) WalletType(walletType WalletType) *WithdrawRequest { + w.walletType = &walletType + return w +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (w *WithdrawRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (w *WithdrawRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check coin field -> json key coin + coin := w.coin + + // assign parameter of coin + params["coin"] = coin + // check network field -> json key network + if w.network != nil { + network := *w.network + + // assign parameter of network + params["network"] = network + } else { + } + // check address field -> json key address + address := w.address + + // assign parameter of address + params["address"] = address + // check addressTag field -> json key addressTag + if w.addressTag != nil { + addressTag := *w.addressTag + + // assign parameter of addressTag + params["addressTag"] = addressTag + } else { + } + // check amount field -> json key amount + amount := w.amount + + // assign parameter of amount + params["amount"] = amount + // check withdrawOrderId field -> json key withdrawOrderId + if w.withdrawOrderId != nil { + withdrawOrderId := *w.withdrawOrderId + + // assign parameter of withdrawOrderId + params["withdrawOrderId"] = withdrawOrderId + } else { + } + // check transactionFeeFlag field -> json key transactionFeeFlag + if w.transactionFeeFlag != nil { + transactionFeeFlag := *w.transactionFeeFlag + + // assign parameter of transactionFeeFlag + params["transactionFeeFlag"] = transactionFeeFlag + } else { + } + // check name field -> json key name + if w.name != nil { + name := *w.name + + // assign parameter of name + params["name"] = name + } else { + } + // check walletType field -> json key walletType + if w.walletType != nil { + walletType := *w.walletType + + // TEMPLATE check-valid-values + switch walletType { + case WalletTypeSpot, WalletTypeFunding: + params["walletType"] = walletType + + default: + return nil, fmt.Errorf("walletType value %v is invalid", walletType) + + } + // END TEMPLATE check-valid-values + + // assign parameter of walletType + params["walletType"] = walletType + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (w *WithdrawRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := w.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if w.isVarSlice(_v) { + w.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (w *WithdrawRequest) GetParametersJSON() ([]byte, error) { + params, err := w.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (w *WithdrawRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (w *WithdrawRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (w *WithdrawRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (w *WithdrawRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (w *WithdrawRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := w.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (w *WithdrawRequest) Do(ctx context.Context) (*WithdrawResponse, error) { + + params, err := w.GetParameters() + if err != nil { + return nil, err + } + query := url.Values{} + + apiURL := "/sapi/v1/capital/withdraw/apply" + + req, err := w.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := w.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse WithdrawResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return &apiResponse, nil +} diff --git a/pkg/exchange/binance/binanceapi/withdrawstatus_string.go b/pkg/exchange/binance/binanceapi/withdrawstatus_string.go new file mode 100644 index 0000000..7c972b7 --- /dev/null +++ b/pkg/exchange/binance/binanceapi/withdrawstatus_string.go @@ -0,0 +1,29 @@ +// Code generated by "stringer -type=WithdrawStatus"; DO NOT EDIT. + +package binanceapi + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[WithdrawStatusEmailSent-0] + _ = x[WithdrawStatusCancelled-1] + _ = x[WithdrawStatusAwaitingApproval-2] + _ = x[WithdrawStatusRejected-3] + _ = x[WithdrawStatusProcessing-4] + _ = x[WithdrawStatusFailure-5] + _ = x[WithdrawStatusCompleted-6] +} + +const _WithdrawStatus_name = "WithdrawStatusEmailSentWithdrawStatusCancelledWithdrawStatusAwaitingApprovalWithdrawStatusRejectedWithdrawStatusProcessingWithdrawStatusFailureWithdrawStatusCompleted" + +var _WithdrawStatus_index = [...]uint8{0, 23, 46, 76, 98, 122, 143, 166} + +func (i WithdrawStatus) String() string { + if i < 0 || i >= WithdrawStatus(len(_WithdrawStatus_index)-1) { + return "WithdrawStatus(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _WithdrawStatus_name[_WithdrawStatus_index[i]:_WithdrawStatus_index[i+1]] +} diff --git a/pkg/exchange/binance/cancel_replace.go b/pkg/exchange/binance/cancel_replace.go new file mode 100644 index 0000000..f3e1a22 --- /dev/null +++ b/pkg/exchange/binance/cancel_replace.go @@ -0,0 +1,70 @@ +package binance + +import ( + "context" + "fmt" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/binance/binanceapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func (e *Exchange) CancelReplace(ctx context.Context, cancelReplaceMode types.CancelReplaceModeType, o types.Order) (*types.Order, error) { + if err := orderLimiter.Wait(ctx); err != nil { + log.WithError(err).Errorf("order rate limiter wait error") + return nil, err + } + + if e.IsFutures || e.IsMargin { + // Not supported at the moment + return nil, nil + } + var req = e.client2.NewCancelReplaceSpotOrderRequest() + req.Symbol(o.Symbol) + req.Side(binance.SideType(o.Side)) + if o.OrderID > 0 { + req.CancelOrderId(int(o.OrderID)) + } else { + return nil, types.NewOrderError(fmt.Errorf("cannot cancel %s order", o.Symbol), o) + } + req.CancelReplaceMode(binanceapi.CancelReplaceModeType(cancelReplaceMode)) + if len(o.TimeInForce) > 0 { + // TODO: check the TimeInForce value + req.TimeInForce(string(binance.TimeInForceType(o.TimeInForce))) + } else { + switch o.Type { + case types.OrderTypeLimit, types.OrderTypeStopLimit: + req.TimeInForce(string(binance.TimeInForceTypeGTC)) + } + } + if o.Market.Symbol != "" { + req.Quantity(o.Market.FormatQuantity(o.Quantity)) + } else { + req.Quantity(o.Quantity.FormatString(8)) + } + + switch o.Type { + case types.OrderTypeStopLimit, types.OrderTypeLimit, types.OrderTypeLimitMaker: + if o.Market.Symbol != "" { + req.Price(o.Market.FormatPrice(o.Price)) + } else { + // TODO: report error + req.Price(o.Price.FormatString(8)) + } + } + switch o.Type { + case types.OrderTypeStopLimit, types.OrderTypeStopMarket: + if o.Market.Symbol != "" { + req.StopPrice(o.Market.FormatPrice(o.StopPrice)) + } else { + // TODO report error + req.StopPrice(o.StopPrice.FormatString(8)) + } + } + req.NewOrderRespType(binanceapi.Full) + + resp, err := req.Do(ctx) + if resp != nil && resp.Data != nil && resp.Data.NewOrderResponse != nil { + return toGlobalOrder(resp.Data.NewOrderResponse, e.IsMargin) + } + return nil, err +} diff --git a/pkg/exchange/binance/convert.go b/pkg/exchange/binance/convert.go new file mode 100644 index 0000000..a4cba69 --- /dev/null +++ b/pkg/exchange/binance/convert.go @@ -0,0 +1,355 @@ +package binance + +import ( + "fmt" + "strings" + "time" + + "github.com/adshao/go-binance/v2" + "github.com/adshao/go-binance/v2/futures" + "github.com/pkg/errors" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func toGlobalMarket(symbol binance.Symbol) types.Market { + market := types.Market{ + Exchange: types.ExchangeBinance, + Symbol: symbol.Symbol, + LocalSymbol: symbol.Symbol, + PricePrecision: symbol.QuotePrecision, + VolumePrecision: symbol.BaseAssetPrecision, + QuoteCurrency: symbol.QuoteAsset, + BaseCurrency: symbol.BaseAsset, + } + + if f := symbol.NotionalFilter(); f != nil { + market.MinNotional = fixedpoint.MustNewFromString(f.MinNotional) + market.MinAmount = fixedpoint.MustNewFromString(f.MinNotional) + } + + // The LOT_SIZE filter defines the quantity (aka "lots" in auction terms) rules for a symbol. + // There are 3 parts: + // minQty defines the minimum quantity/icebergQty allowed. + // maxQty defines the maximum quantity/icebergQty allowed. + // stepSize defines the intervals that a quantity/icebergQty can be increased/decreased by. + if f := symbol.LotSizeFilter(); f != nil { + market.MinQuantity = fixedpoint.MustNewFromString(f.MinQuantity) + market.MaxQuantity = fixedpoint.MustNewFromString(f.MaxQuantity) + market.StepSize = fixedpoint.MustNewFromString(f.StepSize) + } + + if f := symbol.PriceFilter(); f != nil { + market.MaxPrice = fixedpoint.MustNewFromString(f.MaxPrice) + market.MinPrice = fixedpoint.MustNewFromString(f.MinPrice) + market.TickSize = fixedpoint.MustNewFromString(f.TickSize) + } + + if market.MinNotional.IsZero() { + log.Warnf("binance market %s minNotional is zero", market.Symbol) + } + + if market.MinQuantity.IsZero() { + log.Warnf("binance market %s minQuantity is zero", market.Symbol) + } + + return market +} + +// TODO: Cuz it returns types.Market as well, merge following to the above function +func toGlobalFuturesMarket(symbol futures.Symbol) types.Market { + market := types.Market{ + Exchange: types.ExchangeBinance, + Symbol: symbol.Symbol, + LocalSymbol: symbol.Symbol, + PricePrecision: symbol.QuotePrecision, + VolumePrecision: symbol.BaseAssetPrecision, + QuoteCurrency: symbol.QuoteAsset, + BaseCurrency: symbol.BaseAsset, + } + + if f := symbol.MinNotionalFilter(); f != nil { + market.MinNotional = fixedpoint.MustNewFromString(f.Notional) + market.MinAmount = fixedpoint.MustNewFromString(f.Notional) + } + + // The LOT_SIZE filter defines the quantity (aka "lots" in auction terms) rules for a symbol. + // There are 3 parts: + // minQty defines the minimum quantity/icebergQty allowed. + // maxQty defines the maximum quantity/icebergQty allowed. + // stepSize defines the intervals that a quantity/icebergQty can be increased/decreased by. + if f := symbol.LotSizeFilter(); f != nil { + market.MinQuantity = fixedpoint.MustNewFromString(f.MinQuantity) + market.MaxQuantity = fixedpoint.MustNewFromString(f.MaxQuantity) + market.StepSize = fixedpoint.MustNewFromString(f.StepSize) + } + + if f := symbol.PriceFilter(); f != nil { + market.MaxPrice = fixedpoint.MustNewFromString(f.MaxPrice) + market.MinPrice = fixedpoint.MustNewFromString(f.MinPrice) + market.TickSize = fixedpoint.MustNewFromString(f.TickSize) + } + + return market +} + +// func toGlobalIsolatedMarginAccount(account *binance.IsolatedMarginAccount) *types.IsolatedMarginAccount { +// return &types.IsolatedMarginAccount{ +// TotalAssetOfBTC: fixedpoint.MustNewFromString(account.TotalNetAssetOfBTC), +// TotalLiabilityOfBTC: fixedpoint.MustNewFromString(account.TotalLiabilityOfBTC), +// TotalNetAssetOfBTC: fixedpoint.MustNewFromString(account.TotalNetAssetOfBTC), +// Assets: toGlobalIsolatedMarginAssets(account.Assets), +// } +// } + +func toGlobalTicker(stats *binance.PriceChangeStats) (*types.Ticker, error) { + return &types.Ticker{ + Volume: fixedpoint.MustNewFromString(stats.Volume), + Last: fixedpoint.MustNewFromString(stats.LastPrice), + Open: fixedpoint.MustNewFromString(stats.OpenPrice), + High: fixedpoint.MustNewFromString(stats.HighPrice), + Low: fixedpoint.MustNewFromString(stats.LowPrice), + Buy: fixedpoint.MustNewFromString(stats.BidPrice), + Sell: fixedpoint.MustNewFromString(stats.AskPrice), + Time: time.Unix(0, stats.CloseTime*int64(time.Millisecond)), + }, nil +} + +func toGlobalFuturesTicker(stats *futures.PriceChangeStats) (*types.Ticker, error) { + return &types.Ticker{ + Volume: fixedpoint.MustNewFromString(stats.Volume), + Last: fixedpoint.MustNewFromString(stats.LastPrice), + Open: fixedpoint.MustNewFromString(stats.OpenPrice), + High: fixedpoint.MustNewFromString(stats.HighPrice), + Low: fixedpoint.MustNewFromString(stats.LowPrice), + Buy: fixedpoint.MustNewFromString(stats.LastPrice), + Sell: fixedpoint.MustNewFromString(stats.LastPrice), + Time: time.Unix(0, stats.CloseTime*int64(time.Millisecond)), + }, nil +} + +func toLocalOrderType(orderType types.OrderType) (binance.OrderType, error) { + switch orderType { + + case types.OrderTypeLimitMaker: + return binance.OrderTypeLimitMaker, nil + + case types.OrderTypeLimit: + return binance.OrderTypeLimit, nil + + case types.OrderTypeStopLimit: + return binance.OrderTypeStopLossLimit, nil + + case types.OrderTypeStopMarket: + return binance.OrderTypeStopLoss, nil + + case types.OrderTypeMarket: + return binance.OrderTypeMarket, nil + } + + return "", fmt.Errorf("can not convert to local order, order type %s not supported", orderType) +} + +func toGlobalOrders(binanceOrders []*binance.Order, isMargin bool) (orders []types.Order, err error) { + for _, binanceOrder := range binanceOrders { + order, err := toGlobalOrder(binanceOrder, isMargin) + if err != nil { + return orders, err + } + + orders = append(orders, *order) + } + + return orders, err +} + +func toGlobalOrder(binanceOrder *binance.Order, isMargin bool) (*types.Order, error) { + return &types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: binanceOrder.ClientOrderID, + Symbol: binanceOrder.Symbol, + Side: toGlobalSideType(binanceOrder.Side), + Type: toGlobalOrderType(binanceOrder.Type), + Quantity: fixedpoint.MustNewFromString(binanceOrder.OrigQuantity), + Price: fixedpoint.MustNewFromString(binanceOrder.Price), + TimeInForce: types.TimeInForce(binanceOrder.TimeInForce), + }, + Exchange: types.ExchangeBinance, + IsWorking: binanceOrder.IsWorking, + OrderID: uint64(binanceOrder.OrderID), + Status: toGlobalOrderStatus(binanceOrder.Status), + OriginalStatus: string(binanceOrder.Status), + ExecutedQuantity: fixedpoint.MustNewFromString(binanceOrder.ExecutedQuantity), + CreationTime: types.Time(millisecondTime(binanceOrder.Time)), + UpdateTime: types.Time(millisecondTime(binanceOrder.UpdateTime)), + IsMargin: isMargin, + IsIsolated: binanceOrder.IsIsolated, + }, nil +} + +func millisecondTime(t int64) time.Time { + return time.Unix(0, t*int64(time.Millisecond)) +} + +func toGlobalTrade(t binance.TradeV3, isMargin bool) (*types.Trade, error) { + // skip trade ID that is the same. however this should not happen + var side types.SideType + if t.IsBuyer { + side = types.SideTypeBuy + } else { + side = types.SideTypeSell + } + + price, err := fixedpoint.NewFromString(t.Price) + if err != nil { + return nil, errors.Wrapf(err, "price parse error, price: %+v", t.Price) + } + + quantity, err := fixedpoint.NewFromString(t.Quantity) + if err != nil { + return nil, errors.Wrapf(err, "quantity parse error, quantity: %+v", t.Quantity) + } + + var quoteQuantity fixedpoint.Value + if len(t.QuoteQuantity) > 0 { + quoteQuantity, err = fixedpoint.NewFromString(t.QuoteQuantity) + if err != nil { + return nil, errors.Wrapf(err, "quote quantity parse error, quoteQuantity: %+v", t.QuoteQuantity) + } + } else { + quoteQuantity = price.Mul(quantity) + } + + fee, err := fixedpoint.NewFromString(t.Commission) + if err != nil { + return nil, errors.Wrapf(err, "commission parse error, commission: %+v", t.Commission) + } + + return &types.Trade{ + ID: uint64(t.ID), + OrderID: uint64(t.OrderID), + Price: price, + Symbol: t.Symbol, + Exchange: types.ExchangeBinance, + Quantity: quantity, + QuoteQuantity: quoteQuantity, + Side: side, + IsBuyer: t.IsBuyer, + IsMaker: t.IsMaker, + Fee: fee, + FeeCurrency: t.CommissionAsset, + Time: types.Time(millisecondTime(t.Time)), + IsMargin: isMargin, + IsIsolated: t.IsIsolated, + }, nil +} + +func toGlobalSideType(side binance.SideType) types.SideType { + switch side { + case binance.SideTypeBuy: + return types.SideTypeBuy + + case binance.SideTypeSell: + return types.SideTypeSell + + default: + log.Errorf("can not convert binance side type, unknown side type: %q", side) + return "" + } +} + +func toGlobalOrderType(orderType binance.OrderType) types.OrderType { + switch orderType { + + case binance.OrderTypeLimit, + binance.OrderTypeLimitMaker, binance.OrderTypeTakeProfitLimit: + return types.OrderTypeLimit + + case binance.OrderTypeMarket: + return types.OrderTypeMarket + + case binance.OrderTypeStopLossLimit: + return types.OrderTypeStopLimit + + case binance.OrderTypeStopLoss: + return types.OrderTypeStopMarket + + default: + log.Errorf("unsupported order type: %v", orderType) + return "" + } +} + +func toGlobalOrderStatus(orderStatus binance.OrderStatusType) types.OrderStatus { + switch orderStatus { + case binance.OrderStatusTypeNew: + return types.OrderStatusNew + + case binance.OrderStatusTypeRejected: + return types.OrderStatusRejected + + case binance.OrderStatusTypeCanceled, binance.OrderStatusTypeExpired, binance.OrderStatusTypePendingCancel: + return types.OrderStatusCanceled + + case binance.OrderStatusTypePartiallyFilled: + return types.OrderStatusPartiallyFilled + + case binance.OrderStatusTypeFilled: + return types.OrderStatusFilled + } + + return types.OrderStatus(orderStatus) +} + +func convertSubscription(s types.Subscription) string { + // binance uses lower case symbol name, + // for kline, it's "@kline_" + // for depth, it's "@depth OR @depth@100ms" + // for trade, it's "@trade" + // for aggregated trade, it's "@aggTrade" + switch s.Channel { + case types.KLineChannel: + return fmt.Sprintf("%s@%s_%s", strings.ToLower(s.Symbol), s.Channel, s.Options.String()) + case types.BookChannel: + // depth values: 5, 10, 20 + // Stream Names: @depth OR @depth@100ms. + // Update speed: 1000ms or 100ms + n := strings.ToLower(s.Symbol) + "@depth" + switch s.Options.Depth { + case types.DepthLevel5: + n += "5" + + case types.DepthLevel10: + n += "10" + + case types.DepthLevel20, types.DepthLevelMedium: + n += "20" + + // default to full + case types.DepthLevelFull: + default: + + } + + switch s.Options.Speed { + case types.SpeedHigh: + n += "@100ms" + + case types.SpeedLow: + n += "@1000ms" + + } + return n + case types.BookTickerChannel: + return fmt.Sprintf("%s@bookTicker", strings.ToLower(s.Symbol)) + case types.MarketTradeChannel: + return fmt.Sprintf("%s@trade", strings.ToLower(s.Symbol)) + case types.AggTradeChannel: + return fmt.Sprintf("%s@aggTrade", strings.ToLower(s.Symbol)) + case types.ForceOrderChannel: + return fmt.Sprintf("%s@forceOrder", strings.ToLower(s.Symbol)) + } + + return fmt.Sprintf("%s@%s", strings.ToLower(s.Symbol), s.Channel) +} diff --git a/pkg/exchange/binance/convert_futures.go b/pkg/exchange/binance/convert_futures.go new file mode 100644 index 0000000..5bee6a3 --- /dev/null +++ b/pkg/exchange/binance/convert_futures.go @@ -0,0 +1,290 @@ +package binance + +import ( + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/binance/binanceapi" + "time" + + "github.com/adshao/go-binance/v2/futures" + "github.com/pkg/errors" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func toGlobalFuturesAccountInfo(account *binanceapi.FuturesAccount) *types.FuturesAccountInfo { + return &types.FuturesAccountInfo{ + Assets: toGlobalFuturesUserAssets(account.Assets), + Positions: toGlobalFuturesPositions(account.Positions), + TotalInitialMargin: fixedpoint.MustNewFromString(account.TotalInitialMargin), + TotalMaintMargin: fixedpoint.MustNewFromString(account.TotalMaintMargin), + TotalMarginBalance: fixedpoint.MustNewFromString(account.TotalMarginBalance), + TotalOpenOrderInitialMargin: fixedpoint.MustNewFromString(account.TotalOpenOrderInitialMargin), + TotalPositionInitialMargin: fixedpoint.MustNewFromString(account.TotalPositionInitialMargin), + TotalUnrealizedProfit: fixedpoint.MustNewFromString(account.TotalUnrealizedProfit), + TotalWalletBalance: fixedpoint.MustNewFromString(account.TotalWalletBalance), + UpdateTime: account.UpdateTime, + } +} + +func toGlobalFuturesBalance(balances []*futures.Balance) types.BalanceMap { + retBalances := make(types.BalanceMap) + for _, balance := range balances { + retBalances[balance.Asset] = types.Balance{ + Currency: balance.Asset, + Available: fixedpoint.MustNewFromString(balance.AvailableBalance), + } + } + return retBalances +} + +func toGlobalFuturesPositions(futuresPositions []*binanceapi.FuturesAccountPosition) types.FuturesPositionMap { + retFuturesPositions := make(types.FuturesPositionMap) + for _, futuresPosition := range futuresPositions { + retFuturesPositions[futuresPosition.Symbol] = types.FuturesPosition{ // TODO: types.FuturesPosition + Isolated: futuresPosition.Isolated, + AverageCost: fixedpoint.MustNewFromString(futuresPosition.EntryPrice), + ApproximateAverageCost: fixedpoint.MustNewFromString(futuresPosition.EntryPrice), + Base: fixedpoint.MustNewFromString(futuresPosition.PositionAmt), + Quote: fixedpoint.MustNewFromString(futuresPosition.Notional), + + PositionRisk: &types.PositionRisk{ + Leverage: fixedpoint.MustNewFromString(futuresPosition.Leverage), + }, + Symbol: futuresPosition.Symbol, + UpdateTime: futuresPosition.UpdateTime, + } + } + + return retFuturesPositions +} + +func toGlobalFuturesUserAssets(assets []*binanceapi.FuturesAccountAsset) (retAssets types.FuturesAssetMap) { + retFuturesAssets := make(types.FuturesAssetMap) + for _, futuresAsset := range assets { + retFuturesAssets[futuresAsset.Asset] = types.FuturesUserAsset{ + Asset: futuresAsset.Asset, + InitialMargin: fixedpoint.MustNewFromString(futuresAsset.InitialMargin), + MaintMargin: fixedpoint.MustNewFromString(futuresAsset.MaintMargin), + MarginBalance: fixedpoint.MustNewFromString(futuresAsset.MarginBalance), + MaxWithdrawAmount: fixedpoint.MustNewFromString(futuresAsset.MaxWithdrawAmount), + OpenOrderInitialMargin: fixedpoint.MustNewFromString(futuresAsset.OpenOrderInitialMargin), + PositionInitialMargin: fixedpoint.MustNewFromString(futuresAsset.PositionInitialMargin), + UnrealizedProfit: fixedpoint.MustNewFromString(futuresAsset.UnrealizedProfit), + WalletBalance: fixedpoint.MustNewFromString(futuresAsset.WalletBalance), + } + } + + return retFuturesAssets +} + +func toLocalFuturesOrderType(orderType types.OrderType) (futures.OrderType, error) { + switch orderType { + + // case types.OrderTypeLimitMaker: + // return futures.OrderTypeLimitMaker, nil //TODO + + case types.OrderTypeLimit, types.OrderTypeLimitMaker: + return futures.OrderTypeLimit, nil + + // case types.OrderTypeStopLimit: + // return futures.OrderTypeStopLossLimit, nil //TODO + + // case types.OrderTypeStopMarket: + // return futures.OrderTypeStopLoss, nil //TODO + + case types.OrderTypeMarket: + return futures.OrderTypeMarket, nil + } + + return "", fmt.Errorf("can not convert to local order, order type %s not supported", orderType) +} + +func toGlobalFuturesOrders(futuresOrders []*futures.Order, isIsolated bool) (orders []types.Order, err error) { + for _, futuresOrder := range futuresOrders { + order, err := toGlobalFuturesOrder(futuresOrder, isIsolated) + if err != nil { + return orders, err + } + + orders = append(orders, *order) + } + + return orders, err +} + +func toGlobalFuturesOrder(futuresOrder *futures.Order, isIsolated bool) (*types.Order, error) { + return &types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: futuresOrder.ClientOrderID, + Symbol: futuresOrder.Symbol, + Side: toGlobalFuturesSideType(futuresOrder.Side), + Type: toGlobalFuturesOrderType(futuresOrder.Type), + ReduceOnly: futuresOrder.ReduceOnly, + ClosePosition: futuresOrder.ClosePosition, + Quantity: fixedpoint.MustNewFromString(futuresOrder.OrigQuantity), + Price: fixedpoint.MustNewFromString(futuresOrder.Price), + TimeInForce: types.TimeInForce(futuresOrder.TimeInForce), + }, + Exchange: types.ExchangeBinance, + OrderID: uint64(futuresOrder.OrderID), + Status: toGlobalFuturesOrderStatus(futuresOrder.Status), + ExecutedQuantity: fixedpoint.MustNewFromString(futuresOrder.ExecutedQuantity), + CreationTime: types.Time(millisecondTime(futuresOrder.Time)), + UpdateTime: types.Time(millisecondTime(futuresOrder.UpdateTime)), + IsFutures: true, + }, nil +} + +func toGlobalFuturesTrade(t futures.AccountTrade) (*types.Trade, error) { + // skip trade ID that is the same. however this should not happen + var side types.SideType + if t.Buyer { + side = types.SideTypeBuy + } else { + side = types.SideTypeSell + } + + price, err := fixedpoint.NewFromString(t.Price) + if err != nil { + return nil, errors.Wrapf(err, "price parse error, price: %+v", t.Price) + } + + quantity, err := fixedpoint.NewFromString(t.Quantity) + if err != nil { + return nil, errors.Wrapf(err, "quantity parse error, quantity: %+v", t.Quantity) + } + + var quoteQuantity fixedpoint.Value + if len(t.QuoteQuantity) > 0 { + quoteQuantity, err = fixedpoint.NewFromString(t.QuoteQuantity) + if err != nil { + return nil, errors.Wrapf(err, "quote quantity parse error, quoteQuantity: %+v", t.QuoteQuantity) + } + } else { + quoteQuantity = price.Mul(quantity) + } + + fee, err := fixedpoint.NewFromString(t.Commission) + if err != nil { + return nil, errors.Wrapf(err, "commission parse error, commission: %+v", t.Commission) + } + + return &types.Trade{ + ID: uint64(t.ID), + OrderID: uint64(t.OrderID), + Price: price, + Symbol: t.Symbol, + Exchange: "binance", + Quantity: quantity, + QuoteQuantity: quoteQuantity, + Side: side, + IsBuyer: t.Buyer, + IsMaker: t.Maker, + Fee: fee, + FeeCurrency: t.CommissionAsset, + Time: types.Time(millisecondTime(t.Time)), + IsFutures: true, + }, nil +} + +func toGlobalFuturesSideType(side futures.SideType) types.SideType { + switch side { + case futures.SideTypeBuy: + return types.SideTypeBuy + + case futures.SideTypeSell: + return types.SideTypeSell + + default: + log.Errorf("can not convert futures side type, unknown side type: %q", side) + return "" + } +} + +func toGlobalFuturesOrderType(orderType futures.OrderType) types.OrderType { + switch orderType { + // FIXME: handle this order type + // case futures.OrderTypeTrailingStopMarket: + + case futures.OrderTypeTakeProfit: + return types.OrderTypeStopLimit + + case futures.OrderTypeTakeProfitMarket: + return types.OrderTypeStopMarket + + case futures.OrderTypeStopMarket: + return types.OrderTypeStopMarket + + case futures.OrderTypeLimit: + return types.OrderTypeLimit + + case futures.OrderTypeMarket: + return types.OrderTypeMarket + + default: + log.Errorf("unsupported binance futures order type: %s", orderType) + return "" + } +} + +func toGlobalFuturesOrderStatus(orderStatus futures.OrderStatusType) types.OrderStatus { + switch orderStatus { + case futures.OrderStatusTypeNew: + return types.OrderStatusNew + + case futures.OrderStatusTypeRejected: + return types.OrderStatusRejected + + case futures.OrderStatusTypeCanceled: + return types.OrderStatusCanceled + + case futures.OrderStatusTypePartiallyFilled: + return types.OrderStatusPartiallyFilled + + case futures.OrderStatusTypeFilled: + return types.OrderStatusFilled + } + + return types.OrderStatus(orderStatus) +} + +func convertPremiumIndex(index *futures.PremiumIndex) (*types.PremiumIndex, error) { + markPrice, err := fixedpoint.NewFromString(index.MarkPrice) + if err != nil { + return nil, err + } + + lastFundingRate, err := fixedpoint.NewFromString(index.LastFundingRate) + if err != nil { + return nil, err + } + + nextFundingTime := time.Unix(0, index.NextFundingTime*int64(time.Millisecond)) + t := time.Unix(0, index.Time*int64(time.Millisecond)) + + return &types.PremiumIndex{ + Symbol: index.Symbol, + MarkPrice: markPrice, + NextFundingTime: nextFundingTime, + LastFundingRate: lastFundingRate, + Time: t, + }, nil +} + +func convertPositionRisk(risk *futures.PositionRisk) (*types.PositionRisk, error) { + leverage, err := fixedpoint.NewFromString(risk.Leverage) + if err != nil { + return nil, err + } + + liquidationPrice, err := fixedpoint.NewFromString(risk.LiquidationPrice) + if err != nil { + return nil, err + } + + return &types.PositionRisk{ + Leverage: leverage, + LiquidationPrice: liquidationPrice, + }, nil +} diff --git a/pkg/exchange/binance/convert_margin.go b/pkg/exchange/binance/convert_margin.go new file mode 100644 index 0000000..5963f1f --- /dev/null +++ b/pkg/exchange/binance/convert_margin.go @@ -0,0 +1,115 @@ +package binance + +import ( + "github.com/adshao/go-binance/v2" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/binance/binanceapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func toGlobalInterest(record binanceapi.MarginInterest) types.MarginInterest { + return types.MarginInterest{ + Exchange: types.ExchangeBinance, + Asset: record.Asset, + Principle: record.Principal, + Interest: record.Interest, + InterestRate: record.InterestRate, + IsolatedSymbol: record.IsolatedSymbol, + Time: types.Time(record.InterestAccuredTime), + } +} + +func toGlobalLiquidation(record binanceapi.MarginLiquidationRecord) types.MarginLiquidation { + return types.MarginLiquidation{ + Exchange: types.ExchangeBinance, + AveragePrice: record.AveragePrice, + ExecutedQuantity: record.ExecutedQuantity, + OrderID: record.OrderId, + Price: record.Price, + Quantity: record.Quantity, + Side: toGlobalSideType(record.Side), + Symbol: record.Symbol, + TimeInForce: types.TimeInForce(record.TimeInForce), + IsIsolated: record.IsIsolated, + UpdatedTime: types.Time(record.UpdatedTime), + } +} + +func toGlobalIsolatedUserAsset(userAsset binance.IsolatedUserAsset) types.IsolatedUserAsset { + return types.IsolatedUserAsset{ + Asset: userAsset.Asset, + Borrowed: fixedpoint.MustNewFromString(userAsset.Borrowed), + Free: fixedpoint.MustNewFromString(userAsset.Free), + Interest: fixedpoint.MustNewFromString(userAsset.Interest), + Locked: fixedpoint.MustNewFromString(userAsset.Locked), + NetAsset: fixedpoint.MustNewFromString(userAsset.NetAsset), + NetAssetOfBtc: fixedpoint.MustNewFromString(userAsset.NetAssetOfBtc), + BorrowEnabled: userAsset.BorrowEnabled, + RepayEnabled: userAsset.RepayEnabled, + TotalAsset: fixedpoint.MustNewFromString(userAsset.TotalAsset), + } +} + +func toGlobalIsolatedMarginAsset(asset binance.IsolatedMarginAsset) types.IsolatedMarginAsset { + return types.IsolatedMarginAsset{ + Symbol: asset.Symbol, + QuoteAsset: toGlobalIsolatedUserAsset(asset.QuoteAsset), + BaseAsset: toGlobalIsolatedUserAsset(asset.BaseAsset), + IsolatedCreated: asset.IsolatedCreated, + MarginLevel: fixedpoint.MustNewFromString(asset.MarginLevel), + MarginLevelStatus: asset.MarginLevelStatus, + MarginRatio: fixedpoint.MustNewFromString(asset.MarginRatio), + IndexPrice: fixedpoint.MustNewFromString(asset.IndexPrice), + LiquidatePrice: fixedpoint.MustNewFromString(asset.LiquidatePrice), + LiquidateRate: fixedpoint.MustNewFromString(asset.LiquidateRate), + TradeEnabled: false, + } +} + +func toGlobalIsolatedMarginAssets(assets []binance.IsolatedMarginAsset) (retAssets types.IsolatedMarginAssetMap) { + retMarginAssets := make(types.IsolatedMarginAssetMap) + for _, marginAsset := range assets { + retMarginAssets[marginAsset.Symbol] = toGlobalIsolatedMarginAsset(marginAsset) + } + + return retMarginAssets +} + +func toGlobalMarginUserAssets(assets []binance.UserAsset) types.MarginAssetMap { + retMarginAssets := make(types.MarginAssetMap) + for _, marginAsset := range assets { + retMarginAssets[marginAsset.Asset] = types.MarginUserAsset{ + Asset: marginAsset.Asset, + Borrowed: fixedpoint.MustNewFromString(marginAsset.Borrowed), + Free: fixedpoint.MustNewFromString(marginAsset.Free), + Interest: fixedpoint.MustNewFromString(marginAsset.Interest), + Locked: fixedpoint.MustNewFromString(marginAsset.Locked), + NetAsset: fixedpoint.MustNewFromString(marginAsset.NetAsset), + } + } + + return retMarginAssets +} + +func toGlobalMarginAccountInfo(account *binance.MarginAccount) *types.MarginAccountInfo { + return &types.MarginAccountInfo{ + BorrowEnabled: account.BorrowEnabled, + MarginLevel: fixedpoint.MustNewFromString(account.MarginLevel), + TotalAssetOfBTC: fixedpoint.MustNewFromString(account.TotalAssetOfBTC), + TotalLiabilityOfBTC: fixedpoint.MustNewFromString(account.TotalLiabilityOfBTC), + TotalNetAssetOfBTC: fixedpoint.MustNewFromString(account.TotalNetAssetOfBTC), + TradeEnabled: account.TradeEnabled, + TransferEnabled: account.TransferEnabled, + Assets: toGlobalMarginUserAssets(account.UserAssets), + } +} + +func toGlobalIsolatedMarginAccountInfo(account *binance.IsolatedMarginAccount) *types.IsolatedMarginAccountInfo { + return &types.IsolatedMarginAccountInfo{ + TotalAssetOfBTC: fixedpoint.MustNewFromString(account.TotalAssetOfBTC), + TotalLiabilityOfBTC: fixedpoint.MustNewFromString(account.TotalLiabilityOfBTC), + TotalNetAssetOfBTC: fixedpoint.MustNewFromString(account.TotalNetAssetOfBTC), + Assets: toGlobalIsolatedMarginAssets(account.Assets), + } +} diff --git a/pkg/exchange/binance/exchange.go b/pkg/exchange/binance/exchange.go new file mode 100644 index 0000000..2c48a42 --- /dev/null +++ b/pkg/exchange/binance/exchange.go @@ -0,0 +1,1531 @@ +package binance + +import ( + "context" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "github.com/adshao/go-binance/v2" + + "github.com/adshao/go-binance/v2/futures" + "github.com/spf13/viper" + + "go.uber.org/multierr" + + "golang.org/x/time/rate" + + "github.com/google/uuid" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/binance/binanceapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +const BNB = "BNB" + +const DefaultDepthLimit = 5000 + +const BinanceUSBaseURL = "https://api.binance.us" +const BinanceTestBaseURL = "https://testnet.binance.vision" +const BinanceUSWebSocketURL = "wss://stream.binance.us:9443" +const WebSocketURL = "wss://stream.binance.com:9443" +const WebSocketTestURL = "wss://testnet.binance.vision" +const FutureTestBaseURL = "https://testnet.binancefuture.com" +const FuturesWebSocketURL = "wss://fstream.binance.com" +const FuturesWebSocketTestURL = "wss://stream.binancefuture.com" + +// orderLimiter - the default order limiter apply 5 requests per second and a 2 initial bucket +// this includes SubmitOrder, CancelOrder and QueryClosedOrders +// +// Limit defines the maximum frequency of some events. Limit is represented as number of events per second. A zero Limit allows no events. +var orderLimiter = rate.NewLimiter(5, 2) +var queryTradeLimiter = rate.NewLimiter(1, 2) + +var log = logrus.WithFields(logrus.Fields{ + "exchange": "binance", +}) + +func init() { + _ = types.Exchange(&Exchange{}) + _ = types.MarginExchange(&Exchange{}) + _ = types.FuturesExchange(&Exchange{}) + + if n, ok := util.GetEnvVarInt("BINANCE_ORDER_RATE_LIMITER"); ok { + orderLimiter = rate.NewLimiter(rate.Every(time.Duration(n)*time.Minute), 2) + } + + if n, ok := util.GetEnvVarInt("BINANCE_QUERY_TRADES_RATE_LIMITER"); ok { + queryTradeLimiter = rate.NewLimiter(rate.Every(time.Duration(n)*time.Minute), 2) + } +} + +func isBinanceUs() bool { + v, ok := util.GetEnvVarBool("BINANCE_US") + return ok && v +} + +type Exchange struct { + types.MarginSettings + types.FuturesSettings + + key, secret string + // client is used for spot & margin + client *binance.Client + + // futuresClient is used for usdt-m futures + futuresClient *futures.Client // USDT-M Futures + // deliveryClient *delivery.Client // Coin-M Futures + + // client2 is a newer version of the binance api client implemented by ourselves. + client2 *binanceapi.RestClient + + futuresClient2 *binanceapi.FuturesRestClient +} + +var timeSetterOnce sync.Once + +func New(key, secret string) *Exchange { + if util.IsPaperTrade() { + binance.UseTestnet = true + } + var client = binance.NewClient(key, secret) + client.HTTPClient = binanceapi.DefaultHttpClient + client.Debug = viper.GetBool("debug-binance-client") + + var futuresClient = binance.NewFuturesClient(key, secret) + futuresClient.HTTPClient = binanceapi.DefaultHttpClient + futuresClient.Debug = viper.GetBool("debug-binance-futures-client") + + if isBinanceUs() { + client.BaseURL = BinanceUSBaseURL + } + + client2 := binanceapi.NewClient(client.BaseURL) + futuresClient2 := binanceapi.NewFuturesRestClient(futuresClient.BaseURL) + + ex := &Exchange{ + key: key, + secret: secret, + client: client, + futuresClient: futuresClient, + client2: client2, + futuresClient2: futuresClient2, + } + + if len(key) > 0 && len(secret) > 0 { + client2.Auth(key, secret) + futuresClient2.Auth(key, secret) + } + + ctx := context.Background() + go timeSetterOnce.Do(func() { + ex.setServerTimeOffset(ctx) + + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + + case <-ticker.C: + ex.setServerTimeOffset(ctx) + } + } + }) + + return ex +} + +func (e *Exchange) setServerTimeOffset(ctx context.Context) { + _, err := e.client.NewSetServerTimeService().Do(ctx) + if err != nil { + log.WithError(err).Error("can not set server time") + } + + _, err = e.futuresClient.NewSetServerTimeService().Do(ctx) + if err != nil { + log.WithError(err).Error("can not set server time") + } + + if err = e.client2.SetTimeOffsetFromServer(ctx); err != nil { + log.WithError(err).Error("can not set server time") + } +} + +func (e *Exchange) Name() types.ExchangeName { + return types.ExchangeBinance +} + +func (e *Exchange) QueryTicker(ctx context.Context, symbol string) (*types.Ticker, error) { + if e.IsFutures { + req := e.futuresClient.NewListPriceChangeStatsService() + req.Symbol(strings.ToUpper(symbol)) + stats, err := req.Do(ctx) + if err != nil { + return nil, err + } + + return toGlobalFuturesTicker(stats[0]) + } + req := e.client.NewListPriceChangeStatsService() + req.Symbol(strings.ToUpper(symbol)) + stats, err := req.Do(ctx) + if err != nil { + return nil, err + } + + return toGlobalTicker(stats[0]) +} + +func (e *Exchange) QueryTickers(ctx context.Context, symbol ...string) (map[string]types.Ticker, error) { + var tickers = make(map[string]types.Ticker) + + if len(symbol) == 1 { + ticker, err := e.QueryTicker(ctx, symbol[0]) + if err != nil { + return nil, err + } + + tickers[strings.ToUpper(symbol[0])] = *ticker + return tickers, nil + } + + m := make(map[string]struct{}) + exists := struct{}{} + + for _, s := range symbol { + m[s] = exists + } + + if e.IsFutures { + var req = e.futuresClient.NewListPriceChangeStatsService() + changeStats, err := req.Do(ctx) + if err != nil { + return nil, err + } + for _, stats := range changeStats { + if _, ok := m[stats.Symbol]; len(symbol) != 0 && !ok { + continue + } + + tick := types.Ticker{ + Volume: fixedpoint.MustNewFromString(stats.Volume), + Last: fixedpoint.MustNewFromString(stats.LastPrice), + Open: fixedpoint.MustNewFromString(stats.OpenPrice), + High: fixedpoint.MustNewFromString(stats.HighPrice), + Low: fixedpoint.MustNewFromString(stats.LowPrice), + Buy: fixedpoint.MustNewFromString(stats.LastPrice), + Sell: fixedpoint.MustNewFromString(stats.LastPrice), + Time: time.Unix(0, stats.CloseTime*int64(time.Millisecond)), + } + + tickers[stats.Symbol] = tick + } + + return tickers, nil + } + + var req = e.client.NewListPriceChangeStatsService() + changeStats, err := req.Do(ctx) + if err != nil { + return nil, err + } + + for _, stats := range changeStats { + if _, ok := m[stats.Symbol]; len(symbol) != 0 && !ok { + continue + } + + tick := types.Ticker{ + Volume: fixedpoint.MustNewFromString(stats.Volume), + Last: fixedpoint.MustNewFromString(stats.LastPrice), + Open: fixedpoint.MustNewFromString(stats.OpenPrice), + High: fixedpoint.MustNewFromString(stats.HighPrice), + Low: fixedpoint.MustNewFromString(stats.LowPrice), + Buy: fixedpoint.MustNewFromString(stats.BidPrice), + Sell: fixedpoint.MustNewFromString(stats.AskPrice), + Time: time.Unix(0, stats.CloseTime*int64(time.Millisecond)), + } + + tickers[stats.Symbol] = tick + } + + return tickers, nil +} + +func (e *Exchange) QueryMarkets(ctx context.Context) (types.MarketMap, error) { + + if e.IsFutures { + exchangeInfo, err := e.futuresClient.NewExchangeInfoService().Do(ctx) + if err != nil { + return nil, err + } + + markets := types.MarketMap{} + for _, symbol := range exchangeInfo.Symbols { + markets[symbol.Symbol] = toGlobalFuturesMarket(symbol) + } + + return markets, nil + } + + exchangeInfo, err := e.client.NewExchangeInfoService().Do(ctx) + if err != nil { + return nil, err + } + + markets := types.MarketMap{} + for _, symbol := range exchangeInfo.Symbols { + markets[symbol.Symbol] = toGlobalMarket(symbol) + } + + return markets, nil +} + +func (e *Exchange) QueryAveragePrice(ctx context.Context, symbol string) (fixedpoint.Value, error) { + resp, err := e.client.NewAveragePriceService().Symbol(symbol).Do(ctx) + if err != nil { + return fixedpoint.Zero, err + } + + return fixedpoint.MustNewFromString(resp.Price), nil +} + +func (e *Exchange) NewStream() types.Stream { + stream := NewStream(e, e.client, e.futuresClient) + stream.MarginSettings = e.MarginSettings + stream.FuturesSettings = e.FuturesSettings + return stream +} + +func (e *Exchange) QueryMarginAssetMaxBorrowable(ctx context.Context, asset string) (amount fixedpoint.Value, err error) { + req := e.client2.NewGetMarginMaxBorrowableRequest() + req.Asset(asset) + if e.IsIsolatedMargin { + req.IsolatedSymbol(e.IsolatedMarginSymbol) + } + + resp, err := req.Do(ctx) + if err != nil { + return fixedpoint.Zero, err + } + + return resp.Amount, nil +} + +func (e *Exchange) borrowRepayAsset( + ctx context.Context, asset string, amount fixedpoint.Value, marginType binanceapi.BorrowRepayType, +) error { + req := e.client2.NewPlaceMarginOrderRequest() + req.Asset(asset) + req.Amount(amount) + req.SetBorrowRepayType(marginType) + if e.IsIsolatedMargin { + req.IsIsolated(e.IsIsolatedMargin) + req.Symbol(e.IsolatedMarginSymbol) + } + + log.Infof("%s margin asset %s amount %f", marginType, asset, amount.Float64()) + resp, err := req.Do(ctx) + if err != nil { + return err + } + + log.Debugf("margin %s %f %s, transaction id = %d", marginType, amount.Float64(), asset, resp.TranId) + return err +} + +func (e *Exchange) RepayMarginAsset(ctx context.Context, asset string, amount fixedpoint.Value) error { + return e.borrowRepayAsset(ctx, asset, amount, binanceapi.BorrowRepayTypeRepay) +} + +func (e *Exchange) BorrowMarginAsset(ctx context.Context, asset string, amount fixedpoint.Value) error { + return e.borrowRepayAsset(ctx, asset, amount, binanceapi.BorrowRepayTypeBorrow) +} + +func (e *Exchange) QueryMarginBorrowHistory(ctx context.Context, asset string) error { + req := e.client2.NewGetMarginBorrowRepayHistoryRequest() + req.SetBorrowRepayType(binanceapi.BorrowRepayTypeBorrow) + req.Asset(asset) + history, err := req.Do(ctx) + if err != nil { + return err + } + _ = history + return nil +} + +// TransferMarginAccountAsset transfers the asset into/out from the margin account +// +// types.TransferIn => Spot to Margin +// types.TransferOut => Margin to Spot +// +// to call this method, you must set the IsMargin = true +func (e *Exchange) TransferMarginAccountAsset( + ctx context.Context, asset string, amount fixedpoint.Value, io types.TransferDirection, +) error { + if e.IsIsolatedMargin { + return e.transferIsolatedMarginAccountAsset(ctx, asset, amount, io) + } + + return e.transferCrossMarginAccountAsset(ctx, asset, amount, io) +} + +func (e *Exchange) transferIsolatedMarginAccountAsset( + ctx context.Context, asset string, amount fixedpoint.Value, io types.TransferDirection, +) error { + req := e.client2.NewTransferAssetRequest() + req.Asset(asset) + req.FromSymbol(e.IsolatedMarginSymbol) + req.ToSymbol(e.IsolatedMarginSymbol) + + switch io { + case types.TransferIn: + req.TransferType(binanceapi.TransferAssetTypeMainToIsolatedMargin) + + case types.TransferOut: + req.TransferType(binanceapi.TransferAssetTypeIsolatedMarginToMain) + } + + req.Asset(asset) + req.Amount(amount.String()) + resp, err := req.Do(ctx) + return logResponse(resp, err, req) +} + +// transferCrossMarginAccountAsset transfer asset to the cross margin account or to the main account +func (e *Exchange) transferCrossMarginAccountAsset( + ctx context.Context, asset string, amount fixedpoint.Value, io types.TransferDirection, +) error { + req := e.client2.NewTransferAssetRequest() + req.Asset(asset) + req.Amount(amount.String()) + + if io == types.TransferIn { + req.TransferType(binanceapi.TransferAssetTypeMainToMargin) + } else if io == types.TransferOut { + req.TransferType(binanceapi.TransferAssetTypeMarginToMain) + } else { + return fmt.Errorf("unexpected transfer direction: %d given", io) + } + + resp, err := req.Do(ctx) + return logResponse(resp, err, req) +} + +func (e *Exchange) QueryCrossMarginAccount(ctx context.Context) (*types.Account, error) { + marginAccount, err := e.client.NewGetMarginAccountService().Do(ctx) + if err != nil { + return nil, err + } + + marginLevel := fixedpoint.MustNewFromString(marginAccount.MarginLevel) + a := &types.Account{ + AccountType: types.AccountTypeMargin, + MarginInfo: toGlobalMarginAccountInfo(marginAccount), // In binance GO api, Account define marginAccount info which mantain []*AccountAsset and []*AccountPosition. + MarginLevel: marginLevel, + MarginTolerance: calculateMarginTolerance(marginLevel), + BorrowEnabled: marginAccount.BorrowEnabled, + TransferEnabled: marginAccount.TransferEnabled, + } + + // convert cross margin user assets into balances + balances := types.BalanceMap{} + for _, userAsset := range marginAccount.UserAssets { + balances[userAsset.Asset] = types.Balance{ + Currency: userAsset.Asset, + Available: fixedpoint.MustNewFromString(userAsset.Free), + Locked: fixedpoint.MustNewFromString(userAsset.Locked), + Interest: fixedpoint.MustNewFromString(userAsset.Interest), + Borrowed: fixedpoint.MustNewFromString(userAsset.Borrowed), + NetAsset: fixedpoint.MustNewFromString(userAsset.NetAsset), + } + } + a.UpdateBalances(balances) + return a, nil +} + +func (e *Exchange) QueryIsolatedMarginAccount(ctx context.Context) (*types.Account, error) { + req := e.client.NewGetIsolatedMarginAccountService() + req.Symbols(e.IsolatedMarginSymbol) + + marginAccount, err := req.Do(ctx) + if err != nil { + return nil, err + } + + a := &types.Account{ + AccountType: types.AccountTypeIsolatedMargin, + IsolatedMarginInfo: toGlobalIsolatedMarginAccountInfo(marginAccount), // In binance GO api, Account define marginAccount info which mantain []*AccountAsset and []*AccountPosition. + } + + if len(marginAccount.Assets) == 0 { + return nil, fmt.Errorf("empty margin account assets, please check your isolatedMarginSymbol is correctly set: %+v", marginAccount) + } + + // for isolated margin account, we will only have one asset in the Assets array. + if len(marginAccount.Assets) > 1 { + return nil, fmt.Errorf("unexpected number of user assets returned, got %d user assets", len(marginAccount.Assets)) + } + + userAsset := marginAccount.Assets[0] + marginLevel := fixedpoint.MustNewFromString(userAsset.MarginLevel) + a.MarginLevel = marginLevel + a.MarginTolerance = calculateMarginTolerance(marginLevel) + a.MarginRatio = fixedpoint.MustNewFromString(userAsset.MarginRatio) + a.BorrowEnabled = userAsset.BaseAsset.BorrowEnabled || userAsset.QuoteAsset.BorrowEnabled + a.LiquidationPrice = fixedpoint.MustNewFromString(userAsset.LiquidatePrice) + a.LiquidationRate = fixedpoint.MustNewFromString(userAsset.LiquidateRate) + + // Convert user assets into balances + balances := types.BalanceMap{} + balances[userAsset.BaseAsset.Asset] = types.Balance{ + Currency: userAsset.BaseAsset.Asset, + Available: fixedpoint.MustNewFromString(userAsset.BaseAsset.Free), + Locked: fixedpoint.MustNewFromString(userAsset.BaseAsset.Locked), + Interest: fixedpoint.MustNewFromString(userAsset.BaseAsset.Interest), + Borrowed: fixedpoint.MustNewFromString(userAsset.BaseAsset.Borrowed), + NetAsset: fixedpoint.MustNewFromString(userAsset.BaseAsset.NetAsset), + } + + balances[userAsset.QuoteAsset.Asset] = types.Balance{ + Currency: userAsset.QuoteAsset.Asset, + Available: fixedpoint.MustNewFromString(userAsset.QuoteAsset.Free), + Locked: fixedpoint.MustNewFromString(userAsset.QuoteAsset.Locked), + Interest: fixedpoint.MustNewFromString(userAsset.QuoteAsset.Interest), + Borrowed: fixedpoint.MustNewFromString(userAsset.QuoteAsset.Borrowed), + NetAsset: fixedpoint.MustNewFromString(userAsset.QuoteAsset.NetAsset), + } + + a.UpdateBalances(balances) + return a, nil +} + +func (e *Exchange) Withdraw( + ctx context.Context, asset string, amount fixedpoint.Value, address string, options *types.WithdrawalOptions, +) error { + req := e.client2.NewWithdrawRequest() + req.Coin(asset) + req.Address(address) + req.Amount(fmt.Sprintf("%f", amount.Float64())) + + if options != nil { + if options.Network != "" { + req.Network(options.Network) + } + if options.AddressTag != "" { + req.Network(options.AddressTag) + } + } + + response, err := req.Do(ctx) + if err != nil { + return err + } + + log.Infof("withdrawal request sent, response: %+v", response) + return nil +} + +func (e *Exchange) QueryWithdrawHistory(ctx context.Context, asset string, since, until time.Time) (withdraws []types.Withdraw, err error) { + var emptyTime = time.Time{} + if since == emptyTime { + since, err = getLaunchDate() + if err != nil { + return withdraws, err + } + } + + // startTime ~ endTime must be in 90 days + historyDayRangeLimit := time.Hour * 24 * 89 + if until.Sub(since) >= historyDayRangeLimit { + until = since.Add(historyDayRangeLimit) + } + + req := e.client2.NewGetWithdrawHistoryRequest() + if len(asset) > 0 { + req.Coin(asset) + } + + records, err := req. + StartTime(since). + EndTime(until). + Limit(1000). + Do(ctx) + + if err != nil { + return withdraws, err + } + + for _, d := range records { + // time format: 2006-01-02 15:04:05 + applyTime, err := time.Parse("2006-01-02 15:04:05", d.ApplyTime) + if err != nil { + return nil, err + } + + withdraws = append(withdraws, types.Withdraw{ + Exchange: types.ExchangeBinance, + ApplyTime: types.Time(applyTime), + Asset: d.Coin, + Amount: d.Amount, + Address: d.Address, + TransactionID: d.TxID, + TransactionFee: d.TransactionFee, + WithdrawOrderID: d.WithdrawOrderID, + Network: d.Network, + Status: d.Status.String(), + }) + } + + return withdraws, nil +} + +func (e *Exchange) QueryDepositHistory(ctx context.Context, asset string, since, until time.Time) (allDeposits []types.Deposit, err error) { + if since.IsZero() { + since, err = getLaunchDate() + if err != nil { + return nil, err + } + } + + // startTime ~ endTime must be in 90 days + historyDayRangeLimit := time.Hour * 24 * 89 + if until.Sub(since) >= historyDayRangeLimit { + until = since.Add(historyDayRangeLimit) + } + + req := e.client2.NewGetDepositHistoryRequest() + if len(asset) > 0 { + req.Coin(asset) + } + + req.StartTime(since). + EndTime(until) + + records, err := req.Do(ctx) + if err != nil { + return nil, err + } + + for _, d := range records { + // 0(0:pending,6: credited but cannot withdraw, 1:success) + // set the default status + status := types.DepositStatus(fmt.Sprintf("code: %d", d.Status)) + + // https://www.binance.com/en/support/faq/115003736451 + switch d.Status { + case binanceapi.DepositStatusPending: + status = types.DepositPending + + case binanceapi.DepositStatusCredited: + status = types.DepositCredited + + case binanceapi.DepositStatusSuccess: + status = types.DepositSuccess + } + + allDeposits = append(allDeposits, types.Deposit{ + Exchange: types.ExchangeBinance, + Time: types.Time(d.InsertTime.Time()), + Asset: d.Coin, + Amount: d.Amount, + Address: d.Address, + AddressTag: d.AddressTag, + TransactionID: d.TxId, + Status: status, + UnlockConfirm: d.UnlockConfirm, + Confirmation: d.ConfirmTimes, + }) + } + + return allDeposits, nil +} + +func (e *Exchange) QueryAccountBalances(ctx context.Context) (types.BalanceMap, error) { + account, err := e.QueryAccount(ctx) + if err != nil { + return nil, err + } + + return account.Balances(), nil +} + +func (e *Exchange) PlatformFeeCurrency() string { + return BNB +} + +func (e *Exchange) QuerySpotAccount(ctx context.Context) (*types.Account, error) { + account, err := e.client.NewGetAccountService().Do(ctx) + if err != nil { + return nil, err + } + + var balances = map[string]types.Balance{} + for _, b := range account.Balances { + balances[b.Asset] = types.Balance{ + Currency: b.Asset, + Available: fixedpoint.MustNewFromString(b.Free), + Locked: fixedpoint.MustNewFromString(b.Locked), + } + } + + a := &types.Account{ + AccountType: types.AccountTypeSpot, + CanDeposit: account.CanDeposit, // if can transfer in asset + CanTrade: account.CanTrade, // if can trade + CanWithdraw: account.CanWithdraw, // if can transfer out asset + } + a.UpdateBalances(balances) + return a, nil +} + +func (e *Exchange) QueryAccount(ctx context.Context) (*types.Account, error) { + var account *types.Account + var err error + if e.IsFutures { + account, err = e.QueryFuturesAccount(ctx) + } else if e.IsIsolatedMargin { + account, err = e.QueryIsolatedMarginAccount(ctx) + } else if e.IsMargin { + account, err = e.QueryCrossMarginAccount(ctx) + } else { + account, err = e.QuerySpotAccount(ctx) + } + + return account, err +} + +func (e *Exchange) QueryOpenOrders(ctx context.Context, symbol string) (orders []types.Order, err error) { + if e.IsMargin { + req := e.client.NewListMarginOpenOrdersService().Symbol(symbol) + req.IsIsolated(e.IsIsolatedMargin) + + binanceOrders, err := req.Do(ctx) + if err != nil { + return orders, err + } + + return toGlobalOrders(binanceOrders, false) + } + + if e.IsFutures { + req := e.futuresClient.NewListOpenOrdersService().Symbol(symbol) + + binanceOrders, err := req.Do(ctx) + if err != nil { + return orders, err + } + + return toGlobalFuturesOrders(binanceOrders, false) + } + + binanceOrders, err := e.client.NewListOpenOrdersService().Symbol(symbol).Do(ctx) + if err != nil { + return orders, err + } + + return toGlobalOrders(binanceOrders, false) +} + +func (e *Exchange) QueryOrderTrades(ctx context.Context, q types.OrderQuery) ([]types.Trade, error) { + orderID, err := strconv.ParseInt(q.OrderID, 10, 64) + if err != nil { + return nil, err + } + + if len(q.Symbol) == 0 { + return nil, errors.New("binance: symbol parameter is a mandatory parameter for querying order trades") + } + + var remoteTrades []binance.TradeV3 + var trades []types.Trade + + if e.IsMargin { + req := e.client2.NewGetMarginTradesRequest() + req.Symbol(q.Symbol).OrderID(uint64(orderID)) + + if e.IsIsolatedMargin { + req.IsIsolated(true) + } + + remoteTrades, err = req.Do(ctx) + if err != nil { + return nil, err + } + } else { + req := e.client2.NewGetMyTradesRequest() + req.Symbol(q.Symbol). + OrderID(uint64(orderID)) + + remoteTrades, err = req.Do(ctx) + if err != nil { + return nil, err + } + } + + for _, t := range remoteTrades { + localTrade, err := toGlobalTrade(t, e.IsMargin) + if err != nil { + log.WithError(err).Errorf("binance: unable to convert margin trade: %+v", t) + continue + } + + trades = append(trades, *localTrade) + } + + return types.SortTradesAscending(trades), nil +} + +func (e *Exchange) QueryOrder(ctx context.Context, q types.OrderQuery) (*types.Order, error) { + orderID, err := strconv.ParseInt(q.OrderID, 10, 64) + if err != nil { + return nil, err + } + + var order *binance.Order + if e.IsMargin { + order, err = e.client.NewGetMarginOrderService().Symbol(q.Symbol).OrderID(orderID).Do(ctx) + } else { + order, err = e.client.NewGetOrderService().Symbol(q.Symbol).OrderID(orderID).Do(ctx) + } + + if err != nil { + return nil, err + } + + return toGlobalOrder(order, e.IsMargin) +} + +func (e *Exchange) QueryClosedOrders( + ctx context.Context, symbol string, since, until time.Time, lastOrderID uint64, +) (orders []types.Order, err error) { + // we can only query orders within 24 hours + // if the until-since is more than 24 hours, we should reset the until to: + // new until = since + 24 hours - 1 millisecond + /* + if until.Sub(since) >= 24*time.Hour { + until = since.Add(24*time.Hour - time.Millisecond) + } + */ + + if err = orderLimiter.Wait(ctx); err != nil { + log.WithError(err).Errorf("order rate limiter wait error") + return nil, err + } + + log.Infof("querying closed orders %s from %s <=> %s ...", symbol, since, until) + + if e.IsMargin { + req := e.client.NewListMarginOrdersService().Symbol(symbol) + req.IsIsolated(e.IsIsolatedMargin) + + if lastOrderID > 0 { + req.OrderID(int64(lastOrderID)) + } else { + req.StartTime(since.UnixNano() / int64(time.Millisecond)) + if until.Sub(since) < 24*time.Hour { + req.EndTime(until.UnixNano() / int64(time.Millisecond)) + } + } + + binanceOrders, err := req.Do(ctx) + if err != nil { + return orders, err + } + + return toGlobalOrders(binanceOrders, e.IsMargin) + } + + if e.IsFutures { + return e.queryFuturesClosedOrders(ctx, symbol, since, until, lastOrderID) + } + + // If orderId is set, it will get orders >= that orderId. Otherwise most recent orders are returned. + // For some historical orders cummulativeQuoteQty will be < 0, meaning the data is not available at this time. + // If startTime and/or endTime provided, orderId is not required. + req := e.client.NewListOrdersService(). + Symbol(symbol) + + if lastOrderID > 0 { + req.OrderID(int64(lastOrderID)) + } else { + req.StartTime(since.UnixNano() / int64(time.Millisecond)) + if until.Sub(since) < 24*time.Hour { + req.EndTime(until.UnixNano() / int64(time.Millisecond)) + } + } + + // default 500, max 1000 + req.Limit(1000) + + binanceOrders, err := req.Do(ctx) + if err != nil { + return orders, err + } + + return toGlobalOrders(binanceOrders, e.IsMargin) +} + +func (e *Exchange) CancelOrders(ctx context.Context, orders ...types.Order) (err error) { + if err = orderLimiter.Wait(ctx); err != nil { + log.WithError(err).Errorf("order rate limiter wait error") + return err + } + + if e.IsFutures { + return e.cancelFuturesOrders(ctx, orders...) + } + + for _, o := range orders { + if e.IsMargin { + var req = e.client.NewCancelMarginOrderService() + req.IsIsolated(e.IsIsolatedMargin) + req.Symbol(o.Symbol) + + if o.OrderID > 0 { + req.OrderID(int64(o.OrderID)) + } else if len(o.ClientOrderID) > 0 { + req.OrigClientOrderID(o.ClientOrderID) + } else { + err = multierr.Append(err, types.NewOrderError( + fmt.Errorf("can not cancel %s order, order does not contain orderID or clientOrderID", o.Symbol), + o)) + continue + } + + _, err2 := req.Do(ctx) + if err2 != nil { + err = multierr.Append(err, types.NewOrderError(err2, o)) + } + } else { + // SPOT + var req = e.client.NewCancelOrderService() + req.Symbol(o.Symbol) + + if o.OrderID > 0 { + req.OrderID(int64(o.OrderID)) + } else if len(o.ClientOrderID) > 0 { + req.OrigClientOrderID(o.ClientOrderID) + } else { + err = multierr.Append(err, types.NewOrderError( + fmt.Errorf("can not cancel %s order, order does not contain orderID or clientOrderID", o.Symbol), + o)) + continue + } + + _, err2 := req.Do(ctx) + if err2 != nil { + err = multierr.Append(err, types.NewOrderError(err2, o)) + } + } + } + + return err +} + +func (e *Exchange) submitMarginOrder(ctx context.Context, order types.SubmitOrder) (*types.Order, error) { + orderType, err := toLocalOrderType(order.Type) + if err != nil { + return nil, err + } + + req := e.client.NewCreateMarginOrderService(). + Symbol(order.Symbol). + Type(orderType). + Side(binance.SideType(order.Side)) + + clientOrderID := newSpotClientOrderID(order.ClientOrderID) + if len(clientOrderID) > 0 { + req.NewClientOrderID(clientOrderID) + } + + // use response result format + req.NewOrderRespType(binance.NewOrderRespTypeRESULT) + + if e.IsIsolatedMargin { + req.IsIsolated(e.IsIsolatedMargin) + } + + if len(order.MarginSideEffect) > 0 { + req.SideEffectType(binance.SideEffectType(order.MarginSideEffect)) + } + + if order.Market.Symbol != "" { + req.Quantity(order.Market.FormatQuantity(order.Quantity)) + } else { + // TODO report error + req.Quantity(order.Quantity.FormatString(8)) + } + + // set price field for limit orders + switch order.Type { + case types.OrderTypeStopLimit, types.OrderTypeLimit, types.OrderTypeLimitMaker: + if order.Market.Symbol != "" { + req.Price(order.Market.FormatPrice(order.Price)) + } else { + // TODO report error + req.Price(order.Price.FormatString(8)) + } + } + + // set stop price + switch order.Type { + + case types.OrderTypeStopLimit, types.OrderTypeStopMarket: + if order.Market.Symbol != "" { + req.StopPrice(order.Market.FormatPrice(order.StopPrice)) + } else { + // TODO report error + req.StopPrice(order.StopPrice.FormatString(8)) + } + } + + // could be IOC or FOK + if len(order.TimeInForce) > 0 { + // TODO: check the TimeInForce value + req.TimeInForce(binance.TimeInForceType(order.TimeInForce)) + } else { + switch order.Type { + case types.OrderTypeLimit, types.OrderTypeStopLimit: + req.TimeInForce(binance.TimeInForceTypeGTC) + } + } + + response, err := req.Do(ctx) + if err != nil { + return nil, err + } + + log.Infof("margin order creation response: %+v", response) + + createdOrder, err := toGlobalOrder(&binance.Order{ + Symbol: response.Symbol, + OrderID: response.OrderID, + ClientOrderID: response.ClientOrderID, + Price: response.Price, + OrigQuantity: response.OrigQuantity, + ExecutedQuantity: response.ExecutedQuantity, + CummulativeQuoteQuantity: response.CummulativeQuoteQuantity, + Status: response.Status, + TimeInForce: response.TimeInForce, + Type: response.Type, + Side: response.Side, + UpdateTime: response.TransactTime, + Time: response.TransactTime, + IsIsolated: response.IsIsolated, + }, true) + + return createdOrder, err +} + +// qbtrade is a broker on Binance +const spotBrokerID = "NSUYEBKM" + +func newSpotClientOrderID(originalID string) (clientOrderID string) { + if originalID == types.NoClientOrderID { + return "" + } + + prefix := "x-" + spotBrokerID + prefixLen := len(prefix) + + if originalID != "" { + // try to keep the whole original client order ID if user specifies it. + if prefixLen+len(originalID) > 32 { + return originalID + } + + clientOrderID = prefix + originalID + return clientOrderID + } + + clientOrderID = uuid.New().String() + clientOrderID = prefix + clientOrderID + if len(clientOrderID) > 32 { + return clientOrderID[0:32] + } + + return clientOrderID +} + +func (e *Exchange) submitSpotOrder(ctx context.Context, order types.SubmitOrder) (*types.Order, error) { + orderType, err := toLocalOrderType(order.Type) + if err != nil { + return nil, err + } + + req := e.client.NewCreateOrderService(). + Symbol(order.Symbol). + Side(binance.SideType(order.Side)). + Type(orderType) + + clientOrderID := newSpotClientOrderID(order.ClientOrderID) + if len(clientOrderID) > 0 { + req.NewClientOrderID(clientOrderID) + } + + if order.Market.Symbol != "" { + req.Quantity(order.Market.FormatQuantity(order.Quantity)) + } else { + // TODO: report error + req.Quantity(order.Quantity.FormatString(8)) + } + + // set price field for limit orders + switch order.Type { + case types.OrderTypeStopLimit, types.OrderTypeLimit, types.OrderTypeLimitMaker: + if order.Market.Symbol != "" { + req.Price(order.Market.FormatPrice(order.Price)) + } else { + // TODO: report error + req.Price(order.Price.FormatString(8)) + } + } + + switch order.Type { + case types.OrderTypeStopLimit, types.OrderTypeStopMarket: + if order.Market.Symbol != "" { + req.StopPrice(order.Market.FormatPrice(order.StopPrice)) + } else { + // TODO: report error + req.StopPrice(order.StopPrice.FormatString(8)) + } + } + + if len(order.TimeInForce) > 0 { + // TODO: check the TimeInForce value + req.TimeInForce(binance.TimeInForceType(order.TimeInForce)) + } else { + switch order.Type { + case types.OrderTypeLimit, types.OrderTypeStopLimit: + req.TimeInForce(binance.TimeInForceTypeGTC) + } + } + + req.NewOrderRespType(binance.NewOrderRespTypeRESULT) + + response, err := req.Do(ctx) + if err != nil { + return nil, err + } + + log.Infof("spot order creation response: %+v", response) + + createdOrder, err := toGlobalOrder(&binance.Order{ + Symbol: response.Symbol, + OrderID: response.OrderID, + ClientOrderID: response.ClientOrderID, + Price: response.Price, + OrigQuantity: response.OrigQuantity, + ExecutedQuantity: response.ExecutedQuantity, + CummulativeQuoteQuantity: response.CummulativeQuoteQuantity, + Status: response.Status, + TimeInForce: response.TimeInForce, + Type: response.Type, + Side: response.Side, + UpdateTime: response.TransactTime, + Time: response.TransactTime, + IsIsolated: response.IsIsolated, + }, false) + + return createdOrder, err +} + +func (e *Exchange) SubmitOrder(ctx context.Context, order types.SubmitOrder) (createdOrder *types.Order, err error) { + if err = orderLimiter.Wait(ctx); err != nil { + log.WithError(err).Errorf("order rate limiter wait error") + return nil, err + } + + if e.IsMargin { + createdOrder, err = e.submitMarginOrder(ctx, order) + } else if e.IsFutures { + createdOrder, err = e.submitFuturesOrder(ctx, order) + } else { + createdOrder, err = e.submitSpotOrder(ctx, order) + } + + return createdOrder, err +} + +// QueryKLines queries the Kline/candlestick bars for a symbol. Klines are uniquely identified by their open time. +// Binance uses inclusive start time query range, eg: +// https://api.binance.com/api/v3/klines?symbol=BTCUSDT&interval=1m&startTime=1620172860000 +// the above query will return a kline with startTime = 1620172860000 +// and, +// https://api.binance.com/api/v3/klines?symbol=BTCUSDT&interval=1m&startTime=1620172860000&endTime=1620172920000 +// the above query will return a kline with startTime = 1620172860000, and a kline with endTime = 1620172860000 +// +// the endTime of a binance kline, is the (startTime + interval time - 1 millisecond), e.g., +// millisecond unix timestamp: 1620172860000 and 1620172919999 +func (e *Exchange) QueryKLines( + ctx context.Context, symbol string, interval types.Interval, options types.KLineQueryOptions, +) ([]types.KLine, error) { + if e.IsFutures { + return e.QueryFuturesKLines(ctx, symbol, interval, options) + } + + var limit = 1000 + if options.Limit > 0 { + // default limit == 1000 + limit = options.Limit + } + + log.Infof("querying kline %s %s %v", symbol, interval, options) + + req := e.client.NewKlinesService(). + Symbol(symbol). + Interval(string(interval)). + Limit(limit) + + if options.StartTime != nil { + req.StartTime(options.StartTime.UnixMilli()) + } + + if options.EndTime != nil { + req.EndTime(options.EndTime.UnixMilli()) + } + + resp, err := req.Do(ctx) + if err != nil { + return nil, err + } + + var kLines []types.KLine + for _, k := range resp { + kLines = append(kLines, types.KLine{ + Exchange: types.ExchangeBinance, + Symbol: symbol, + Interval: interval, + StartTime: types.NewTimeFromUnix(0, k.OpenTime*int64(time.Millisecond)), + EndTime: types.NewTimeFromUnix(0, k.CloseTime*int64(time.Millisecond)), + Open: fixedpoint.MustNewFromString(k.Open), + Close: fixedpoint.MustNewFromString(k.Close), + High: fixedpoint.MustNewFromString(k.High), + Low: fixedpoint.MustNewFromString(k.Low), + Volume: fixedpoint.MustNewFromString(k.Volume), + QuoteVolume: fixedpoint.MustNewFromString(k.QuoteAssetVolume), + TakerBuyBaseAssetVolume: fixedpoint.MustNewFromString(k.TakerBuyBaseAssetVolume), + TakerBuyQuoteAssetVolume: fixedpoint.MustNewFromString(k.TakerBuyQuoteAssetVolume), + LastTradeID: 0, + NumberOfTrades: uint64(k.TradeNum), + Closed: true, + }) + } + + kLines = types.SortKLinesAscending(kLines) + return kLines, nil +} + +func (e *Exchange) queryMarginTrades( + ctx context.Context, symbol string, options *types.TradeQueryOptions, +) (trades []types.Trade, err error) { + var remoteTrades []*binance.TradeV3 + req := e.client.NewListMarginTradesService(). + IsIsolated(e.IsIsolatedMargin). + Symbol(symbol) + + if options.Limit > 0 { + req.Limit(int(options.Limit)) + } else { + req.Limit(1000) + } + + // BINANCE seems to have an API bug, we can't use both fromId and the start time/end time + // BINANCE uses inclusive last trade ID + if options.LastTradeID > 0 { + req.FromID(int64(options.LastTradeID)) + } else { + if options.StartTime != nil && options.EndTime != nil { + if options.EndTime.Sub(*options.StartTime) < 24*time.Hour { + req.StartTime(options.StartTime.UnixMilli()) + req.EndTime(options.EndTime.UnixMilli()) + } else { + req.StartTime(options.StartTime.UnixMilli()) + } + } else if options.StartTime != nil { + req.StartTime(options.StartTime.UnixMilli()) + } else if options.EndTime != nil { + req.EndTime(options.EndTime.UnixMilli()) + } + } + + remoteTrades, err = req.Do(ctx) + if err != nil { + return nil, err + } + for _, t := range remoteTrades { + localTrade, err := toGlobalTrade(*t, e.IsMargin) + if err != nil { + log.WithError(err).Errorf("can not convert binance trade: %+v", t) + continue + } + + trades = append(trades, *localTrade) + } + + trades = types.SortTradesAscending(trades) + return trades, nil +} + +func (e *Exchange) querySpotTrades(ctx context.Context, symbol string, options *types.TradeQueryOptions) (trades []types.Trade, err error) { + req := e.client2.NewGetMyTradesRequest() + req.Symbol(symbol) + + // BINANCE uses inclusive last trade ID + if options.LastTradeID > 0 { + req.FromID(options.LastTradeID) + } else { + if options.StartTime != nil && options.EndTime != nil { + if options.EndTime.Sub(*options.StartTime) < 24*time.Hour { + req.StartTime(*options.StartTime) + req.EndTime(*options.EndTime) + } else { + req.StartTime(*options.StartTime) + } + } else if options.StartTime != nil { + req.StartTime(*options.StartTime) + } else if options.EndTime != nil { + req.EndTime(*options.EndTime) + } + } + + if options.Limit > 0 { + req.Limit(uint64(options.Limit)) + } else { + req.Limit(1000) + } + + remoteTrades, err := req.Do(ctx) + if err != nil { + return nil, err + } + + for _, t := range remoteTrades { + localTrade, err := toGlobalTrade(t, e.IsMargin) + if err != nil { + log.WithError(err).Errorf("can not convert binance trade: %+v", t) + continue + } + + trades = append(trades, *localTrade) + } + + trades = types.SortTradesAscending(trades) + return trades, nil +} + +func (e *Exchange) QueryTrades(ctx context.Context, symbol string, options *types.TradeQueryOptions) ([]types.Trade, error) { + if err := queryTradeLimiter.Wait(ctx); err != nil { + return nil, err + } + + if e.IsMargin { + return e.queryMarginTrades(ctx, symbol, options) + } else if e.IsFutures { + return e.queryFuturesTrades(ctx, symbol, options) + } + return e.querySpotTrades(ctx, symbol, options) +} + +// DefaultFeeRates returns the Binance VIP 0 fee schedule +// See also https://www.binance.com/en/fee/schedule +// See futures fee at: https://www.binance.com/en/fee/futureFee +func (e *Exchange) DefaultFeeRates() types.ExchangeFee { + if e.IsFutures { + return types.ExchangeFee{ + MakerFeeRate: fixedpoint.NewFromFloat(0.01 * 0.0180), // 0.0180% -USDT with BNB + TakerFeeRate: fixedpoint.NewFromFloat(0.01 * 0.0360), // 0.0360% -USDT with BNB + } + } + + return types.ExchangeFee{ + MakerFeeRate: fixedpoint.NewFromFloat(0.01 * 0.075), // 0.075% with BNB + TakerFeeRate: fixedpoint.NewFromFloat(0.01 * 0.075), // 0.075% with BNB + } +} + +// QueryDepth query the order book depth of a symbol +func (e *Exchange) QueryDepth(ctx context.Context, symbol string) (snapshot types.SliceOrderBook, finalUpdateID int64, err error) { + if e.IsFutures { + return e.queryFuturesDepth(ctx, symbol) + } + + response, err := e.client2.NewGetDepthRequest().Symbol(symbol).Limit(DefaultDepthLimit).Do(ctx) + if err != nil { + return snapshot, finalUpdateID, err + } + + return convertDepth(symbol, response) +} + +func convertDepth(symbol string, response *binanceapi.Depth) (snapshot types.SliceOrderBook, finalUpdateID int64, err error) { + snapshot.Symbol = symbol + snapshot.Time = time.Now() + snapshot.LastUpdateId = response.LastUpdateId + + finalUpdateID = response.LastUpdateId + for _, entry := range response.Bids { + snapshot.Bids = append(snapshot.Bids, types.PriceVolume{Price: entry[0], Volume: entry[1]}) + } + + for _, entry := range response.Asks { + snapshot.Asks = append(snapshot.Asks, types.PriceVolume{Price: entry[0], Volume: entry[1]}) + } + + return snapshot, finalUpdateID, err +} + +func convertDepthLegacy( + snapshot types.SliceOrderBook, symbol string, finalUpdateID int64, response *binance.DepthResponse, +) (types.SliceOrderBook, int64, error) { + snapshot.Symbol = symbol + // empty time since the API does not provide time information. + snapshot.Time = time.Time{} + finalUpdateID = response.LastUpdateID + for _, entry := range response.Bids { + // entry.Price, Quantity: entry.Quantity + price, err := fixedpoint.NewFromString(entry.Price) + if err != nil { + return snapshot, finalUpdateID, err + } + + quantity, err := fixedpoint.NewFromString(entry.Quantity) + if err != nil { + return snapshot, finalUpdateID, err + } + + snapshot.Bids = append(snapshot.Bids, types.PriceVolume{Price: price, Volume: quantity}) + } + + for _, entry := range response.Asks { + price, err := fixedpoint.NewFromString(entry.Price) + if err != nil { + return snapshot, finalUpdateID, err + } + + quantity, err := fixedpoint.NewFromString(entry.Quantity) + if err != nil { + return snapshot, finalUpdateID, err + } + + snapshot.Asks = append(snapshot.Asks, types.PriceVolume{Price: price, Volume: quantity}) + } + + return snapshot, finalUpdateID, nil +} + +// QueryPremiumIndex is only for futures +func (e *Exchange) QueryPremiumIndex(ctx context.Context, symbol string) (*types.PremiumIndex, error) { + // when symbol is set, only one index will be returned. + indexes, err := e.futuresClient.NewPremiumIndexService().Symbol(symbol).Do(ctx) + if err != nil { + return nil, err + } + + return convertPremiumIndex(indexes[0]) +} + +func (e *Exchange) QueryFundingRateHistory(ctx context.Context, symbol string) (*types.FundingRate, error) { + rates, err := e.futuresClient.NewFundingRateService(). + Symbol(symbol). + Limit(1). + Do(ctx) + if err != nil { + return nil, err + } + + if len(rates) == 0 { + return nil, errors.New("empty funding rate data") + } + + rate := rates[0] + fundingRate, err := fixedpoint.NewFromString(rate.FundingRate) + if err != nil { + return nil, err + } + + return &types.FundingRate{ + FundingRate: fundingRate, + FundingTime: time.Unix(0, rate.FundingTime*int64(time.Millisecond)), + Time: time.Unix(0, rate.FundingTime*int64(time.Millisecond)), + }, nil +} + +func (e *Exchange) QueryPositionRisk(ctx context.Context, symbol string) (*types.PositionRisk, error) { + // when symbol is set, only one position risk will be returned. + risks, err := e.futuresClient.NewGetPositionRiskService().Symbol(symbol).Do(ctx) + if err != nil { + return nil, err + } + + return convertPositionRisk(risks[0]) +} + +// in seconds +var SupportedIntervals = map[types.Interval]int{ + types.Interval1s: 1, + types.Interval1m: 1 * 60, + types.Interval5m: 5 * 60, + types.Interval15m: 15 * 60, + types.Interval30m: 30 * 60, + types.Interval1h: 60 * 60, + types.Interval2h: 60 * 60 * 2, + types.Interval4h: 60 * 60 * 4, + types.Interval6h: 60 * 60 * 6, + types.Interval12h: 60 * 60 * 12, + types.Interval1d: 60 * 60 * 24, + types.Interval3d: 60 * 60 * 24 * 3, + types.Interval1w: 60 * 60 * 24 * 7, +} + +func (e *Exchange) SupportedInterval() map[types.Interval]int { + return SupportedIntervals +} + +func (e *Exchange) IsSupportedInterval(interval types.Interval) bool { + _, ok := SupportedIntervals[interval] + return ok +} + +func getLaunchDate() (time.Time, error) { + // binance launch date 12:00 July 14th, 2017 + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + return time.Time{}, err + } + + return time.Date(2017, time.July, 14, 0, 0, 0, 0, loc), nil +} + +// Margin tolerance ranges from 0.0 (liquidation) to 1.0 (safest level of margin). +func calculateMarginTolerance(marginLevel fixedpoint.Value) fixedpoint.Value { + if marginLevel.IsZero() { + // Although margin level shouldn't be zero, that would indicate a significant problem. + // In that case, margin tolerance should return 0.0 to also reflect that problem. + return fixedpoint.Zero + } + + // Formula created by operations team for our binance code. Liquidation occurs at 1.1, + // so when marginLevel equals 1.1, the formula becomes 1.0 - 1.0, or zero. + // = 1.0 - (1.1 / marginLevel) + return fixedpoint.One.Sub(fixedpoint.NewFromFloat(1.1).Div(marginLevel)) +} + +func logResponse(resp interface{}, err error, req interface{}) error { + if err != nil { + log.WithError(err).Errorf("%T: error %+v", req, resp) + return err + } + + log.Infof("%T: response: %+v", req, resp) + return nil +} diff --git a/pkg/exchange/binance/exchange_test.go b/pkg/exchange/binance/exchange_test.go new file mode 100644 index 0000000..79ffe7b --- /dev/null +++ b/pkg/exchange/binance/exchange_test.go @@ -0,0 +1,34 @@ +package binance + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_newClientOrderID(t *testing.T) { + cID := newSpotClientOrderID("") + assert.Len(t, cID, 32) + strings.HasPrefix(cID, "x-"+spotBrokerID) + + cID = newSpotClientOrderID("myid1") + assert.Equal(t, cID, "x-"+spotBrokerID+"myid1") +} + +func Test_new(t *testing.T) { + ex := New("", "") + assert.NotEmpty(t, ex) + ctx := context.Background() + ticker, err := ex.QueryTicker(ctx, "btcusdt") + if len(os.Getenv("GITHUB_CI")) > 0 { + // Github action runs in the US, and therefore binance api is not accessible + assert.Empty(t, ticker) + assert.Error(t, err) + } else { + assert.NotEmpty(t, ticker) + assert.NoError(t, err) + } +} diff --git a/pkg/exchange/binance/futures.go b/pkg/exchange/binance/futures.go new file mode 100644 index 0000000..de61bd9 --- /dev/null +++ b/pkg/exchange/binance/futures.go @@ -0,0 +1,413 @@ +package binance + +import ( + "context" + "fmt" + "time" + + "github.com/adshao/go-binance/v2" + "github.com/adshao/go-binance/v2/futures" + "github.com/google/uuid" + "go.uber.org/multierr" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/binance/binanceapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func (e *Exchange) queryFuturesClosedOrders( + ctx context.Context, symbol string, since, until time.Time, lastOrderID uint64, +) (orders []types.Order, err error) { + req := e.futuresClient.NewListOrdersService().Symbol(symbol) + + if lastOrderID > 0 { + req.OrderID(int64(lastOrderID)) + } else { + req.StartTime(since.UnixNano() / int64(time.Millisecond)) + if until.Sub(since) < 24*time.Hour { + req.EndTime(until.UnixNano() / int64(time.Millisecond)) + } + } + + binanceOrders, err := req.Do(ctx) + if err != nil { + return orders, err + } + return toGlobalFuturesOrders(binanceOrders, false) +} + +func (e *Exchange) TransferFuturesAccountAsset( + ctx context.Context, asset string, amount fixedpoint.Value, io types.TransferDirection, +) error { + req := e.client2.NewFuturesTransferRequest() + req.Asset(asset) + req.Amount(amount.String()) + + if io == types.TransferIn { + req.TransferType(binanceapi.FuturesTransferSpotToUsdtFutures) + } else if io == types.TransferOut { + req.TransferType(binanceapi.FuturesTransferUsdtFuturesToSpot) + } else { + return fmt.Errorf("unexpected transfer direction: %d given", io) + } + + resp, err := req.Do(ctx) + + switch io { + case types.TransferIn: + log.Infof("internal transfer (spot) => (futures) %s %s, transaction = %+v, err = %+v", amount.String(), asset, resp, err) + case types.TransferOut: + log.Infof("internal transfer (futures) => (spot) %s %s, transaction = %+v, err = %+v", amount.String(), asset, resp, err) + } + + return err +} + +// QueryFuturesAccount gets the futures account balances from Binance +// Balance.Available = Wallet Balance(in Binance UI) - Used Margin +// Balance.Locked = Used Margin +func (e *Exchange) QueryFuturesAccount(ctx context.Context) (*types.Account, error) { + // account, err := e.futuresClient.NewGetAccountService().Do(ctx) + reqAccount := e.futuresClient2.NewFuturesGetAccountRequest() + account, err := reqAccount.Do(ctx) + if err != nil { + return nil, err + } + + req := e.futuresClient2.NewFuturesGetAccountBalanceRequest() + accountBalances, err := req.Do(ctx) + if err != nil { + return nil, err + } + + var balances = map[string]types.Balance{} + for _, b := range accountBalances { + // The futures account balance is much different from the spot balance: + // - Balance is the actual balance of the asset + // - AvailableBalance is the available margin balance (can be used as notional) + // - CrossWalletBalance (this will be meaningful when using isolated margin) + balances[b.Asset] = types.Balance{ + Currency: b.Asset, + Available: b.AvailableBalance, // AvailableBalance here is the available margin, like how much quantity/notional you can SHORT/LONG, not what you can withdraw + Locked: b.Balance.Sub(b.AvailableBalance.Sub(b.CrossUnPnl)), // FIXME: AvailableBalance is the available margin balance, it could be re-calculated by the current formula. + MaxWithdrawAmount: b.MaxWithdrawAmount, + } + } + + a := &types.Account{ + AccountType: types.AccountTypeFutures, + FuturesInfo: toGlobalFuturesAccountInfo(account), // In binance GO api, Account define account info which mantain []*AccountAsset and []*AccountPosition. + CanDeposit: account.CanDeposit, // if can transfer in asset + CanTrade: account.CanTrade, // if can trade + CanWithdraw: account.CanWithdraw, // if can transfer out asset + } + a.UpdateBalances(balances) + return a, nil +} + +func (e *Exchange) cancelFuturesOrders(ctx context.Context, orders ...types.Order) (err error) { + for _, o := range orders { + var req = e.futuresClient.NewCancelOrderService() + + // Mandatory + req.Symbol(o.Symbol) + + if o.OrderID > 0 { + req.OrderID(int64(o.OrderID)) + } else { + err = multierr.Append(err, types.NewOrderError( + fmt.Errorf("can not cancel %s order, order does not contain orderID or clientOrderID", o.Symbol), + o)) + continue + } + + _, err2 := req.Do(ctx) + if err2 != nil { + err = multierr.Append(err, types.NewOrderError(err2, o)) + } + } + + return err +} + +func (e *Exchange) submitFuturesOrder(ctx context.Context, order types.SubmitOrder) (*types.Order, error) { + orderType, err := toLocalFuturesOrderType(order.Type) + if err != nil { + return nil, err + } + + req := e.futuresClient.NewCreateOrderService(). + Symbol(order.Symbol). + Type(orderType). + Side(futures.SideType(order.Side)) + + if order.ReduceOnly { + req.ReduceOnly(order.ReduceOnly) + } else if order.ClosePosition { + req.ClosePosition(order.ClosePosition) + } + + clientOrderID := newFuturesClientOrderID(order.ClientOrderID) + if len(clientOrderID) > 0 { + req.NewClientOrderID(clientOrderID) + } + + // use response result format + req.NewOrderResponseType(futures.NewOrderRespTypeRESULT) + + if !order.ClosePosition { + if order.Market.Symbol != "" { + req.Quantity(order.Market.FormatQuantity(order.Quantity)) + } else { + // TODO report error + req.Quantity(order.Quantity.FormatString(8)) + } + } + + // set price field for limit orders + switch order.Type { + case types.OrderTypeStopLimit, types.OrderTypeLimit, types.OrderTypeLimitMaker: + if order.Market.Symbol != "" { + req.Price(order.Market.FormatPrice(order.Price)) + } else { + // TODO report error + req.Price(order.Price.FormatString(8)) + } + } + + // set stop price + switch order.Type { + + case types.OrderTypeStopLimit, types.OrderTypeStopMarket: + if order.Market.Symbol != "" { + req.StopPrice(order.Market.FormatPrice(order.StopPrice)) + } else { + // TODO report error + req.StopPrice(order.StopPrice.FormatString(8)) + } + } + + // could be IOC or FOK + if len(order.TimeInForce) > 0 { + // TODO: check the TimeInForce value + req.TimeInForce(futures.TimeInForceType(order.TimeInForce)) + } else { + switch order.Type { + case types.OrderTypeLimit, types.OrderTypeLimitMaker, types.OrderTypeStopLimit: + req.TimeInForce(futures.TimeInForceTypeGTC) + } + } + + response, err := req.Do(ctx) + if err != nil { + return nil, err + } + + log.Infof("futures order creation response: %+v", response) + + createdOrder, err := toGlobalFuturesOrder(&futures.Order{ + Symbol: response.Symbol, + OrderID: response.OrderID, + ClientOrderID: response.ClientOrderID, + Price: response.Price, + OrigQuantity: response.OrigQuantity, + ExecutedQuantity: response.ExecutedQuantity, + Status: response.Status, + TimeInForce: response.TimeInForce, + Type: response.Type, + Side: response.Side, + ReduceOnly: response.ReduceOnly, + }, false) + + return createdOrder, err +} + +func (e *Exchange) QueryFuturesKLines( + ctx context.Context, symbol string, interval types.Interval, options types.KLineQueryOptions, +) ([]types.KLine, error) { + + var limit = 1000 + if options.Limit > 0 { + // default limit == 1000 + limit = options.Limit + } + + log.Infof("querying kline %s %s %v", symbol, interval, options) + + req := e.futuresClient.NewKlinesService(). + Symbol(symbol). + Interval(string(interval)). + Limit(limit) + + if options.StartTime != nil { + req.StartTime(options.StartTime.UnixMilli()) + } + + if options.EndTime != nil { + req.EndTime(options.EndTime.UnixMilli()) + } + + resp, err := req.Do(ctx) + if err != nil { + return nil, err + } + + var kLines []types.KLine + for _, k := range resp { + kLines = append(kLines, types.KLine{ + Exchange: types.ExchangeBinance, + Symbol: symbol, + Interval: interval, + StartTime: types.NewTimeFromUnix(0, k.OpenTime*int64(time.Millisecond)), + EndTime: types.NewTimeFromUnix(0, k.CloseTime*int64(time.Millisecond)), + Open: fixedpoint.MustNewFromString(k.Open), + Close: fixedpoint.MustNewFromString(k.Close), + High: fixedpoint.MustNewFromString(k.High), + Low: fixedpoint.MustNewFromString(k.Low), + Volume: fixedpoint.MustNewFromString(k.Volume), + QuoteVolume: fixedpoint.MustNewFromString(k.QuoteAssetVolume), + TakerBuyBaseAssetVolume: fixedpoint.MustNewFromString(k.TakerBuyBaseAssetVolume), + TakerBuyQuoteAssetVolume: fixedpoint.MustNewFromString(k.TakerBuyQuoteAssetVolume), + LastTradeID: 0, + NumberOfTrades: uint64(k.TradeNum), + Closed: true, + }) + } + + kLines = types.SortKLinesAscending(kLines) + return kLines, nil +} + +func (e *Exchange) queryFuturesTrades( + ctx context.Context, symbol string, options *types.TradeQueryOptions, +) (trades []types.Trade, err error) { + + var remoteTrades []*futures.AccountTrade + req := e.futuresClient.NewListAccountTradeService(). + Symbol(symbol) + if options.Limit > 0 { + req.Limit(int(options.Limit)) + } else { + req.Limit(1000) + } + + // BINANCE uses inclusive last trade ID + if options.LastTradeID > 0 { + req.FromID(int64(options.LastTradeID)) + } + + // The parameter fromId cannot be sent with startTime or endTime. + // Mentioned in binance futures docs + if options.LastTradeID <= 0 { + if options.StartTime != nil && options.EndTime != nil { + if options.EndTime.Sub(*options.StartTime) < 24*time.Hour { + req.StartTime(options.StartTime.UnixMilli()) + req.EndTime(options.EndTime.UnixMilli()) + } else { + req.StartTime(options.StartTime.UnixMilli()) + } + } else if options.EndTime != nil { + req.EndTime(options.EndTime.UnixMilli()) + } + } + + remoteTrades, err = req.Do(ctx) + if err != nil { + return nil, err + } + for _, t := range remoteTrades { + localTrade, err := toGlobalFuturesTrade(*t) + if err != nil { + log.WithError(err).Errorf("can not convert binance futures trade: %+v", t) + continue + } + + trades = append(trades, *localTrade) + } + + trades = types.SortTradesAscending(trades) + return trades, nil +} + +func (e *Exchange) QueryFuturesPositionRisks(ctx context.Context, symbol string) error { + req := e.futuresClient.NewGetPositionRiskService() + req.Symbol(symbol) + res, err := req.Do(ctx) + if err != nil { + return err + } + + _ = res + + return nil +} + +// qbtrade is a futures broker on Binance +const futuresBrokerID = "gBhMvywy" + +func newFuturesClientOrderID(originalID string) (clientOrderID string) { + if originalID == types.NoClientOrderID { + return "" + } + + prefix := "x-" + futuresBrokerID + prefixLen := len(prefix) + + if originalID != "" { + // try to keep the whole original client order ID if user specifies it. + if prefixLen+len(originalID) > 32 { + return originalID + } + + clientOrderID = prefix + originalID + return clientOrderID + } + + clientOrderID = uuid.New().String() + clientOrderID = prefix + clientOrderID + if len(clientOrderID) > 32 { + return clientOrderID[0:32] + } + + return clientOrderID +} + +func (e *Exchange) queryFuturesDepth(ctx context.Context, symbol string) (snapshot types.SliceOrderBook, finalUpdateID int64, err error) { + res, err := e.futuresClient.NewDepthService().Symbol(symbol).Do(ctx) + if err != nil { + return snapshot, finalUpdateID, err + } + + response := &binance.DepthResponse{ + LastUpdateID: res.LastUpdateID, + Bids: res.Bids, + Asks: res.Asks, + } + + return convertDepthLegacy(snapshot, symbol, finalUpdateID, response) +} + +func (e *Exchange) GetFuturesClient() *binanceapi.FuturesRestClient { + return e.futuresClient2 +} + +// QueryFuturesIncomeHistory queries the income history on the binance futures account +// This is more binance futures specific API, the convert function is not designed yet. +// TODO: consider other futures platforms and design the common data structure for this +func (e *Exchange) QueryFuturesIncomeHistory( + ctx context.Context, symbol string, incomeType binanceapi.FuturesIncomeType, startTime, endTime *time.Time, +) ([]binanceapi.FuturesIncome, error) { + req := e.futuresClient2.NewFuturesGetIncomeHistoryRequest() + req.Symbol(symbol) + req.IncomeType(incomeType) + if startTime != nil { + req.StartTime(*startTime) + } + + if endTime != nil { + req.EndTime(*endTime) + } + + resp, err := req.Do(ctx) + return resp, err +} diff --git a/pkg/exchange/binance/historical_trades.go b/pkg/exchange/binance/historical_trades.go new file mode 100644 index 0000000..d931f97 --- /dev/null +++ b/pkg/exchange/binance/historical_trades.go @@ -0,0 +1,29 @@ +package binance + +import ( + "context" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func (e *Exchange) QueryHistoricalTrades(ctx context.Context, symbol string, limit uint64) ([]types.Trade, error) { + req := e.client2.NewGetHistoricalTradesRequest() + req.Symbol(symbol) + req.Limit(limit) + trades, err := req.Do(ctx) + if err != nil { + return nil, err + } + + var result []types.Trade + for _, t := range trades { + localTrade, err := toGlobalTrade(t, e.IsMargin) + if err != nil { + log.WithError(err).Errorf("cannot convert binance trade: %+v", t) + continue + } + result = append(result, *localTrade) + } + result = types.SortTradesAscending(result) + return result, nil +} diff --git a/pkg/exchange/binance/margin_history.go b/pkg/exchange/binance/margin_history.go new file mode 100644 index 0000000..e3664c8 --- /dev/null +++ b/pkg/exchange/binance/margin_history.go @@ -0,0 +1,157 @@ +package binance + +import ( + "context" + "fmt" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/binance/binanceapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type BorrowRepayType interface { + types.MarginLoan | types.MarginRepay +} + +func queryBorrowRepayHistory[T BorrowRepayType](e *Exchange, ctx context.Context, asset string, startTime, endTime *time.Time) ([]T, error) { + req := e.client2.NewGetMarginBorrowRepayHistoryRequest() + req.Asset(asset) + req.Size(100) + + switch v := any(T{}); v.(type) { + case types.MarginLoan: + req.SetBorrowRepayType(binanceapi.BorrowRepayTypeBorrow) + case types.MarginRepay: + req.SetBorrowRepayType(binanceapi.BorrowRepayTypeRepay) + default: + return nil, fmt.Errorf("T is other type") + } + + if startTime != nil { + req.StartTime(*startTime) + + // 6 months + if time.Since(*startTime) > time.Hour*24*30*6 { + req.Archived(true) + } + } + + if startTime != nil && endTime != nil { + duration := endTime.Sub(*startTime) + if duration > time.Hour*24*30 { + t := startTime.Add(time.Hour * 24 * 30) + endTime = &t + } + } + + if endTime != nil { + req.EndTime(*endTime) + } + + if e.MarginSettings.IsIsolatedMargin { + req.IsolatedSymbol(e.MarginSettings.IsolatedMarginSymbol) + } + + records, err := req.Do(ctx) + if err != nil { + return nil, err + } + + var borrowRepay []T + for _, record := range records { + borrowRepay = append(borrowRepay, T{ + Exchange: types.ExchangeBinance, + TransactionID: record.TxId, + Asset: record.Asset, + Principle: record.Principal, + Time: types.Time(record.Timestamp), + IsolatedSymbol: record.IsolatedSymbol, + }) + } + + return borrowRepay, nil +} + +func (e *Exchange) QueryLoanHistory(ctx context.Context, asset string, startTime, endTime *time.Time) ([]types.MarginLoan, error) { + return queryBorrowRepayHistory[types.MarginLoan](e, ctx, asset, startTime, endTime) +} + +func (e *Exchange) QueryRepayHistory(ctx context.Context, asset string, startTime, endTime *time.Time) ([]types.MarginRepay, error) { + return queryBorrowRepayHistory[types.MarginRepay](e, ctx, asset, startTime, endTime) +} + +func (e *Exchange) QueryLiquidationHistory(ctx context.Context, startTime, endTime *time.Time) ([]types.MarginLiquidation, error) { + req := e.client2.NewGetMarginLiquidationHistoryRequest() + req.Size(100) + + if startTime != nil { + req.StartTime(*startTime) + } + + if startTime != nil && endTime != nil { + duration := endTime.Sub(*startTime) + if duration > time.Hour*24*30 { + t := startTime.Add(time.Hour * 24 * 30) + endTime = &t + } + } + + if endTime != nil { + req.EndTime(*endTime) + } + + if e.MarginSettings.IsIsolatedMargin { + req.IsolatedSymbol(e.MarginSettings.IsolatedMarginSymbol) + } + + records, err := req.Do(ctx) + var liquidations []types.MarginLiquidation + for _, record := range records { + liquidations = append(liquidations, toGlobalLiquidation(record)) + } + + return liquidations, err +} + +func (e *Exchange) QueryInterestHistory(ctx context.Context, asset string, startTime, endTime *time.Time) ([]types.MarginInterest, error) { + req := e.client2.NewGetMarginInterestHistoryRequest() + req.Asset(asset) + req.Size(100) + + if startTime != nil { + req.StartTime(*startTime) + + // 6 months + if time.Since(*startTime) > time.Hour*24*30*6 { + req.Archived(true) + } + } + + if startTime != nil && endTime != nil { + duration := endTime.Sub(*startTime) + if duration > time.Hour*24*30 { + t := startTime.Add(time.Hour * 24 * 30) + endTime = &t + } + } + + if endTime != nil { + req.EndTime(*endTime) + } + + if e.MarginSettings.IsIsolatedMargin { + req.IsolatedSymbol(e.MarginSettings.IsolatedMarginSymbol) + } + + records, err := req.Do(ctx) + if err != nil { + return nil, err + } + + var interests []types.MarginInterest + for _, record := range records { + interests = append(interests, toGlobalInterest(record)) + } + + return interests, err +} diff --git a/pkg/exchange/binance/parse.go b/pkg/exchange/binance/parse.go new file mode 100644 index 0000000..a2ac5f9 --- /dev/null +++ b/pkg/exchange/binance/parse.go @@ -0,0 +1,1181 @@ +package binance + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/adshao/go-binance/v2/futures" + "github.com/slack-go/slack" + + "github.com/adshao/go-binance/v2" + "github.com/valyala/fastjson" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/binance/binanceapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type EventType = string + +const ( + EventTypeKLine EventType = "kline" + EventTypeOutboundAccountPosition EventType = "outboundAccountPosition" + EventTypeOutboundAccountInfo EventType = "outboundAccountInfo" + EventTypeBalanceUpdate EventType = "balanceUpdate" + EventTypeExecutionReport EventType = "executionReport" + EventTypeDepthUpdate EventType = "depthUpdate" + EventTypeListenKeyExpired EventType = "listenKeyExpired" + EventTypeTrade EventType = "trade" + EventTypeAggTrade EventType = "aggTrade" + EventTypeForceOrder EventType = "forceOrder" + + // Our side defines the following event types since binance doesn't + // define the event name from the server messages. + // + EventTypeBookTicker EventType = "bookTicker" + EventTypePartialDepth EventType = "partialDepth" +) + +type EventBase struct { + Event string `json:"e"` // event name + Time types.MillisecondTimestamp `json:"E"` // event time +} + +/* +executionReport + + { + "e": "executionReport", // Event type + "E": 1499405658658, // Event time + "s": "ETHBTC", // Symbol + "c": "mUvoqJxFIILMdfAW5iGSOW", // Client order ID + "S": "BUY", // Side + "o": "LIMIT", // Order type + "f": "GTC", // Time in force + "q": "1.00000000", // Order quantity + "p": "0.10264410", // Order price + "P": "0.00000000", // Stop price + "F": "0.00000000", // Iceberg quantity + "g": -1, // OrderListId + "C": null, // Original client order ID; This is the ID of the order being canceled + "x": "NEW", // Current execution type + "X": "NEW", // Current order status + "r": "NONE", // Order reject reason; will be an error code. + "i": 4293153, // Order ID + "l": "0.00000000", // Last executed quantity + "z": "0.00000000", // Cumulative filled quantity + "L": "0.00000000", // Last executed price + "n": "0", // Commission amount + "N": null, // Commission asset + "T": 1499405658657, // Transaction time + "t": -1, // Trade ID + "I": 8641984, // Ignore + "w": true, // Is the order on the book? + "m": false, // Is this trade the maker side? + "M": false, // Ignore + "O": 1499405658657, // Order creation time + "Z": "0.00000000", // Cumulative quote asset transacted quantity + "Y": "0.00000000", // Last quote asset transacted quantity (i.e. lastPrice * lastQty) + "Q": "0.00000000" // Quote Order Quantity + } +*/ +type ExecutionReportEvent struct { + EventBase + + Symbol string `json:"s"` + Side string `json:"S"` + + ClientOrderID string `json:"c"` + OriginalClientOrderID string `json:"C"` + + OrderType string `json:"o"` + OrderCreationTime int64 `json:"O"` + + TimeInForce string `json:"f"` + IcebergQuantity fixedpoint.Value `json:"F"` + + OrderQuantity fixedpoint.Value `json:"q"` + QuoteOrderQuantity fixedpoint.Value `json:"Q"` + + OrderPrice fixedpoint.Value `json:"p"` + StopPrice fixedpoint.Value `json:"P"` + + IsOnBook bool `json:"w"` + WorkingTime types.MillisecondTimestamp `json:"W"` + TrailingTime types.MillisecondTimestamp `json:"D"` + + IsMaker bool `json:"m"` + Ignore bool `json:"M"` + + CommissionAmount fixedpoint.Value `json:"n"` + CommissionAsset string `json:"N"` + + CurrentExecutionType string `json:"x"` + CurrentOrderStatus string `json:"X"` + + OrderID int64 `json:"i"` + Ignored int64 `json:"I"` + + TradeID int64 `json:"t"` + TransactionTime int64 `json:"T"` + + LastExecutedQuantity fixedpoint.Value `json:"l"` + LastExecutedPrice fixedpoint.Value `json:"L"` + + CumulativeFilledQuantity fixedpoint.Value `json:"z"` + CumulativeQuoteAssetTransactedQuantity fixedpoint.Value `json:"Z"` + + LastQuoteAssetTransactedQuantity fixedpoint.Value `json:"Y"` +} + +func (e *ExecutionReportEvent) Order() (*types.Order, error) { + switch e.CurrentExecutionType { + case "NEW", "CANCELED", "REJECTED", "EXPIRED": + case "REPLACED": + case "TRADE": // For Order FILLED status. And the order has been completed. + default: + return nil, errors.New("execution report type is not for order") + } + + orderCreationTime := time.Unix(0, e.OrderCreationTime*int64(time.Millisecond)) + return &types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: e.ClientOrderID, + Symbol: e.Symbol, + Side: toGlobalSideType(binance.SideType(e.Side)), + Type: toGlobalOrderType(binance.OrderType(e.OrderType)), + Quantity: e.OrderQuantity, + Price: e.OrderPrice, + StopPrice: e.StopPrice, + TimeInForce: types.TimeInForce(e.TimeInForce), + ReduceOnly: false, + ClosePosition: false, + }, + Exchange: types.ExchangeBinance, + IsWorking: e.IsOnBook, + OrderID: uint64(e.OrderID), + Status: toGlobalOrderStatus(binance.OrderStatusType(e.CurrentOrderStatus)), + ExecutedQuantity: e.CumulativeFilledQuantity, + CreationTime: types.Time(orderCreationTime), + UpdateTime: types.Time(orderCreationTime), + }, nil +} + +func (e *ExecutionReportEvent) Trade() (*types.Trade, error) { + if e.CurrentExecutionType != "TRADE" { + return nil, errors.New("execution report is not a trade") + } + + tt := time.Unix(0, e.TransactionTime*int64(time.Millisecond)) + return &types.Trade{ + ID: uint64(e.TradeID), + Exchange: types.ExchangeBinance, + Symbol: e.Symbol, + OrderID: uint64(e.OrderID), + Side: toGlobalSideType(binance.SideType(e.Side)), + Price: e.LastExecutedPrice, + Quantity: e.LastExecutedQuantity, + QuoteQuantity: e.LastQuoteAssetTransactedQuantity, + IsBuyer: e.Side == "BUY", + IsMaker: e.IsMaker, + Time: types.Time(tt), + Fee: e.CommissionAmount, + FeeCurrency: e.CommissionAsset, + }, nil +} + +/* +event: balanceUpdate + +Balance Update occurs during the following: + +Deposits or withdrawals from the account +Transfer of funds between accounts (e.g. Spot to Margin) + + { + "e": "balanceUpdate", //KLineEvent Type + "E": 1573200697110, //KLineEvent Time + "a": "BTC", //Asset + "d": "100.00000000", //Balance Delta + "T": 1573200697068 //Clear Time + } + +This event is only for Spot +*/ +type BalanceUpdateEvent struct { + EventBase + + Asset string `json:"a"` + Delta fixedpoint.Value `json:"d"` + ClearTime types.MillisecondTimestamp `json:"T"` +} + +func (e *BalanceUpdateEvent) SlackAttachment() slack.Attachment { + return slack.Attachment{ + Title: "Binance Balance Update Event", + Color: "warning", + Fields: []slack.AttachmentField{ + { + Title: "Asset", + Value: e.Asset, + Short: true, + }, + { + Title: "Delta", + Value: e.Delta.String(), + Short: true, + }, + { + Title: "Time", + Value: e.ClearTime.String(), + Short: true, + }, + }, + } +} + +/* +outboundAccountInfo + + { + "e": "outboundAccountInfo", // KLineEvent type + "E": 1499405658849, // KLineEvent time + "m": 0, // Maker commission rate (bips) + "t": 0, // Taker commission rate (bips) + "b": 0, // Buyer commission rate (bips) + "s": 0, // Seller commission rate (bips) + "T": true, // Can trade? + "W": true, // Can withdraw? + "D": true, // Can deposit? + "u": 1499405658848, // Time of last account update + "B": [ // AccountBalances array + { + "a": "LTC", // Asset + "f": "17366.18538083", // Free amount + "l": "0.00000000" // Locked amount + }, + { + "a": "BTC", + "f": "10537.85314051", + "l": "2.19464093" + }, + { + "a": "ETH", + "f": "17902.35190619", + "l": "0.00000000" + }, + { + "a": "BNC", + "f": "1114503.29769312", + "l": "0.00000000" + }, + { + "a": "NEO", + "f": "0.00000000", + "l": "0.00000000" + } + ], + "P": [ // Account Permissions + "SPOT" + ] + } +*/ +type Balance struct { + Asset string `json:"a"` + Free fixedpoint.Value `json:"f"` + Locked fixedpoint.Value `json:"l"` +} + +type OutboundAccountPositionEvent struct { + EventBase + + LastAccountUpdateTime int `json:"u"` + Balances []Balance `json:"B,omitempty"` +} + +type OutboundAccountInfoEvent struct { + EventBase + + MakerCommissionRate int `json:"m"` + TakerCommissionRate int `json:"t"` + BuyerCommissionRate int `json:"b"` + SellerCommissionRate int `json:"s"` + + CanTrade bool `json:"T"` + CanWithdraw bool `json:"W"` + CanDeposit bool `json:"D"` + + LastAccountUpdateTime int `json:"u"` + + Balances []Balance `json:"B,omitempty"` + Permissions []string `json:"P,omitempty"` +} + +type ResultEvent struct { + Result interface{} `json:"result,omitempty"` + ID int `json:"id"` +} + +var parserPool fastjson.ParserPool + +func parseWebSocketEvent(message []byte) (interface{}, error) { + parser := parserPool.Get() + val, err := parser.ParseBytes(message) + if err != nil { + return nil, err + } + + eventType := string(val.GetStringBytes("e")) + if eventType == "" { + if isBookTicker(val) { + eventType = EventTypeBookTicker + } else if isPartialDepth(val) { + eventType = EventTypePartialDepth + } + } + + switch eventType { + + case EventTypeOutboundAccountPosition: + var event OutboundAccountPositionEvent + err = json.Unmarshal(message, &event) + return &event, err + + case EventTypeOutboundAccountInfo: + var event OutboundAccountInfoEvent + err = json.Unmarshal(message, &event) + return &event, err + + case EventTypeBalanceUpdate: + var event BalanceUpdateEvent + err = json.Unmarshal(message, &event) + return &event, err + + case EventTypeExecutionReport: + var event ExecutionReportEvent + err = json.Unmarshal(message, &event) + return &event, err + + case EventTypeDepthUpdate: + return parseDepthEvent(val) + + case EventTypeTrade: + var event MarketTradeEvent + err = json.Unmarshal(message, &event) + return &event, err + + case EventTypeBookTicker: + var event BookTickerEvent + err := json.Unmarshal(message, &event) + event.Event = eventType + return &event, err + + case EventTypePartialDepth: + var depth binanceapi.Depth + err := json.Unmarshal(message, &depth) + return &PartialDepthEvent{ + EventBase: EventBase{ + Event: EventTypePartialDepth, + Time: types.MillisecondTimestamp(time.Now()), + }, + Depth: depth, + }, err + + case EventTypeKLine: + var event KLineEvent + err := json.Unmarshal(message, &event) + return &event, err + + case EventTypeListenKeyExpired: + var event ListenKeyExpired + err = json.Unmarshal(message, &event) + return &event, err + + case EventTypeAggTrade: + var event AggTradeEvent + err = json.Unmarshal(message, &event) + return &event, err + + case EventTypeForceOrder: + var event ForceOrderEvent + err = json.Unmarshal(message, &event) + return &event, err + } + + // events for futures + switch eventType { + + // futures market data stream + // ======================================================== + case "continuousKline": + var event ContinuousKLineEvent + err = json.Unmarshal([]byte(message), &event) + return &event, err + + case "markPriceUpdate": + var event MarkPriceUpdateEvent + err = json.Unmarshal([]byte(message), &event) + return &event, err + + // futures user data stream + // ======================================================== + case "ORDER_TRADE_UPDATE": + var event OrderTradeUpdateEvent + err = json.Unmarshal([]byte(message), &event) + return &event, err + + // Event: Balance and Position Update + case "ACCOUNT_UPDATE": + var event AccountUpdateEvent + err = json.Unmarshal([]byte(message), &event) + return &event, err + + // Event: Order Update + case "ACCOUNT_CONFIG_UPDATE": + var event AccountConfigUpdateEvent + err = json.Unmarshal([]byte(message), &event) + return &event, err + + case "MARGIN_CALL": + var event MarginCallEvent + err = json.Unmarshal([]byte(message), &event) + return &event, err + + default: + id := val.GetInt("id") + if id > 0 { + return &ResultEvent{ID: id}, nil + } + } + + return nil, fmt.Errorf("unsupported binance websocket message: %s", message) +} + +// isBookTicker document ref :https://binance-docs.github.io/apidocs/spot/en/#individual-symbol-book-ticker-streams +// use key recognition because there's no identification in the content. +func isBookTicker(val *fastjson.Value) bool { + return val.Exists("u") && val.Exists("s") && + val.Exists("b") && val.Exists("B") && + val.Exists("a") && val.Exists("A") +} + +func isPartialDepth(val *fastjson.Value) bool { + return val.Exists("lastUpdateId") && + val.Exists("bids") && val.Exists("bids") +} + +type DepthEntry struct { + PriceLevel fixedpoint.Value + Quantity fixedpoint.Value +} + +type DepthEvent struct { + EventBase + + Symbol string `json:"s"` + FirstUpdateID int64 `json:"U"` + FinalUpdateID int64 `json:"u"` + + Bids types.PriceVolumeSlice `json:"b"` + Asks types.PriceVolumeSlice `json:"a"` +} + +func (e *DepthEvent) String() (o string) { + o += fmt.Sprintf("Depth %s bid/ask = ", e.Symbol) + + if len(e.Bids) == 0 { + o += "empty" + } else { + o += e.Bids[0].Price.String() + } + + o += "/" + + if len(e.Asks) == 0 { + o += "empty" + } else { + o += e.Asks[0].Price.String() + } + + o += fmt.Sprintf(" %d ~ %d", e.FirstUpdateID, e.FinalUpdateID) + return o +} + +func (e *DepthEvent) OrderBook() (book types.SliceOrderBook) { + book.Symbol = e.Symbol + book.Time = e.EventBase.Time.Time() + + // already in descending order + book.Bids = e.Bids + book.Asks = e.Asks + return book +} + +func parseDepthEntry(val *fastjson.Value) (pv types.PriceVolume, err error) { + arr, err := val.Array() + if err != nil { + return pv, err + } + + if len(arr) < 2 { + err = errors.New("incorrect depth entry element length") + return pv, err + } + + pv.Price, err = fixedpoint.NewFromString(string(arr[0].GetStringBytes())) + if err != nil { + return pv, err + } + + pv.Volume, err = fixedpoint.NewFromString(string(arr[1].GetStringBytes())) + if err != nil { + return pv, err + } + + return pv, err +} + +func parseDepthEvent(val *fastjson.Value) (depth *DepthEvent, err error) { + depth = &DepthEvent{ + EventBase: EventBase{ + Event: string(val.GetStringBytes("e")), + Time: types.NewMillisecondTimestampFromInt(val.GetInt64("E")), + }, + Symbol: string(val.GetStringBytes("s")), + FirstUpdateID: val.GetInt64("U"), + FinalUpdateID: val.GetInt64("u"), + Bids: make(types.PriceVolumeSlice, 0, 50), + Asks: make(types.PriceVolumeSlice, 0, 50), + } + + for _, ev := range val.GetArray("b") { + entry, err2 := parseDepthEntry(ev) + if err2 != nil { + err = err2 + continue + } + + depth.Bids = append(depth.Bids, entry) + } + + for _, ev := range val.GetArray("a") { + entry, err2 := parseDepthEntry(ev) + if err2 != nil { + err = err2 + continue + } + + depth.Asks = append(depth.Asks, entry) + } + + return depth, err +} + +type ForceOrderEventInner struct { + Symbol string `json:"s"` + TradeTime types.MillisecondTimestamp `json:"T"` + Side string `json:"S"` + OrderType string `json:"o"` + TimeInForce string `json:"f"` + Quantity fixedpoint.Value `json:"q"` + Price fixedpoint.Value `json:"p"` + AveragePrice fixedpoint.Value `json:"ap"` + OrderStatus string `json:"X"` + LastFilledQuantity fixedpoint.Value `json:"l"` + LastFilledAccQuantity fixedpoint.Value `json:"z"` +} + +type ForceOrderEvent struct { + EventBase + Order ForceOrderEventInner `json:"o"` +} + +func (e *ForceOrderEvent) LiquidationInfo() types.LiquidationInfo { + o := e.Order + return types.LiquidationInfo{ + Symbol: o.Symbol, + Side: types.SideType(o.Side), + OrderType: types.OrderType(o.OrderType), + TimeInForce: types.TimeInForce(o.TimeInForce), + Quantity: o.Quantity, + Price: o.Price, + AveragePrice: o.AveragePrice, + OrderStatus: types.OrderStatus(o.OrderStatus), + TradeTime: types.Time(o.TradeTime), + } +} + +/* +ForceOrderEvent + +{ + "E" : 1689303434028, + "e" : "forceOrder", + "o" : { + "S" : "BUY", // Side + "T" : 1689303434025, // Order Trade Time + "X" : "FILLED", // Order Status + "ap" : "2011.09", // Average Price + "f" : "IOC", // TimeInForce + "l" : "0.003", // Last filled Quantity + "o" : "LIMIT", // Order Type + "p" : "2021.37", // Price + "q" : "0.003", // Original Quantity + "s" : "ETHUSDT", // Symbol + "z" : "0.003" // Order Filed Accumulated Quantity + } +} +*/ + +type MarketTradeEvent struct { + EventBase + Symbol string `json:"s"` + Quantity fixedpoint.Value `json:"q"` + Price fixedpoint.Value `json:"p"` + + BuyerOrderId int64 `json:"b"` + SellerOrderId int64 `json:"a"` + + OrderTradeTime int64 `json:"T"` + TradeId int64 `json:"t"` + + IsMaker bool `json:"m"` + Dummy bool `json:"M"` +} + +/* + +market trade + +{ + "e": "trade", // Event type + "E": 123456789, // Event time + "s": "BNBBTC", // Symbol + "t": 12345, // Trade ID + "p": "0.001", // Price + "q": "100", // Quantity + "b": 88, // Buyer order ID + "a": 50, // Seller order ID + "T": 123456785, // Trade time + "m": true, // Is the buyer the market maker? + "M": true // Ignore +} + +*/ + +func (e *MarketTradeEvent) Trade() types.Trade { + tt := time.Unix(0, e.OrderTradeTime*int64(time.Millisecond)) + var orderId int64 + var side types.SideType + var isBuyer bool + if e.IsMaker { + orderId = e.SellerOrderId // seller is taker + side = types.SideTypeSell + isBuyer = false + } else { + orderId = e.BuyerOrderId // buyer is taker + side = types.SideTypeBuy + isBuyer = true + } + return types.Trade{ + ID: uint64(e.TradeId), + Exchange: types.ExchangeBinance, + Symbol: e.Symbol, + OrderID: uint64(orderId), + Side: side, + Price: e.Price, + Quantity: e.Quantity, + QuoteQuantity: e.Quantity.Mul(e.Price), + IsBuyer: isBuyer, + IsMaker: e.IsMaker, + Time: types.Time(tt), + Fee: fixedpoint.Zero, + FeeCurrency: "", + } +} + +type AggTradeEvent struct { + EventBase + Symbol string `json:"s"` + Quantity fixedpoint.Value `json:"q"` + Price fixedpoint.Value `json:"p"` + FirstTradeId int64 `json:"f"` + LastTradeId int64 `json:"l"` + OrderTradeTime int64 `json:"T"` + IsMaker bool `json:"m"` + Dummy bool `json:"M"` +} + +/* +aggregate trade +{ + "e": "aggTrade", // Event type + "E": 123456789, // Event time + "s": "BNBBTC", // Symbol + "a": 12345, // Aggregate trade ID + "p": "0.001", // Price + "q": "100", // Quantity + "f": 100, // First trade ID + "l": 105, // Last trade ID + "T": 123456785, // Trade time + "m": true, // Is the buyer the market maker? + "M": true // Ignore +} +*/ + +func (e *AggTradeEvent) Trade() types.Trade { + tt := time.Unix(0, e.OrderTradeTime*int64(time.Millisecond)) + var side types.SideType + var isBuyer bool + if e.IsMaker { + side = types.SideTypeSell + isBuyer = false + } else { + side = types.SideTypeBuy + isBuyer = true + } + return types.Trade{ + ID: uint64(e.LastTradeId), + Exchange: types.ExchangeBinance, + Symbol: e.Symbol, + OrderID: 0, + Side: side, + Price: e.Price, + Quantity: e.Quantity, + QuoteQuantity: e.Quantity.Mul(e.Price), + IsBuyer: isBuyer, + IsMaker: e.IsMaker, + Time: types.Time(tt), + Fee: fixedpoint.Zero, + FeeCurrency: "", + } +} + +type KLine struct { + StartTime int64 `json:"t"` + EndTime int64 `json:"T"` + + Symbol string `json:"s"` + Interval string `json:"i"` + + Open fixedpoint.Value `json:"o"` + Close fixedpoint.Value `json:"c"` + High fixedpoint.Value `json:"h"` + Low fixedpoint.Value `json:"l"` + + Volume fixedpoint.Value `json:"v"` // base asset volume (like 10 BTC) + QuoteVolume fixedpoint.Value `json:"q"` // quote asset volume + + TakerBuyBaseAssetVolume fixedpoint.Value `json:"V"` // taker buy base asset volume (like 10 BTC) + TakerBuyQuoteAssetVolume fixedpoint.Value `json:"Q"` // taker buy quote asset volume (like 1000USDT) + + LastTradeID int `json:"L"` + NumberOfTrades int64 `json:"n"` + Closed bool `json:"x"` +} + +/* + +kline + +{ + "e": "kline", // KLineEvent type + "E": 123456789, // KLineEvent time + "s": "BNBBTC", // Symbol + "k": { + "t": 123400000, // Kline start time + "T": 123460000, // Kline close time + "s": "BNBBTC", // Symbol + "i": "1m", // Interval + "f": 100, // First trade ID + "L": 200, // Last trade ID + "o": "0.0010", // Open price + "c": "0.0020", // Close price + "h": "0.0025", // High price + "l": "0.0015", // Low price + "v": "1000", // Base asset volume + "n": 100, // Number of trades + "x": false, // Is this kline closed? + "q": "1.0000", // Quote asset volume + "V": "500", // Taker buy base asset volume + "Q": "0.500", // Taker buy quote asset volume + "B": "123456" // Ignore + } +} + +*/ + +type KLineEvent struct { + EventBase + Symbol string `json:"s"` + KLine KLine `json:"k,omitempty"` +} + +func (k *KLine) KLine() types.KLine { + return types.KLine{ + Exchange: types.ExchangeBinance, + Symbol: k.Symbol, + Interval: types.Interval(k.Interval), + StartTime: types.NewTimeFromUnix(0, k.StartTime*int64(time.Millisecond)), + EndTime: types.NewTimeFromUnix(0, k.EndTime*int64(time.Millisecond)), + Open: k.Open, + Close: k.Close, + High: k.High, + Low: k.Low, + Volume: k.Volume, + QuoteVolume: k.QuoteVolume, + TakerBuyBaseAssetVolume: k.TakerBuyBaseAssetVolume, + TakerBuyQuoteAssetVolume: k.TakerBuyQuoteAssetVolume, + LastTradeID: uint64(k.LastTradeID), + NumberOfTrades: uint64(k.NumberOfTrades), + Closed: k.Closed, + } +} + +type ListenKeyExpired struct { + EventBase +} + +type MarkPriceUpdateEvent struct { + EventBase + + Symbol string `json:"s"` + + MarkPrice fixedpoint.Value `json:"p"` + IndexPrice fixedpoint.Value `json:"i"` + EstimatedPrice fixedpoint.Value `json:"P"` + + FundingRate fixedpoint.Value `json:"r"` + NextFundingTime int64 `json:"T"` +} + +/* +{ + "e": "markPriceUpdate", // Event type + "E": 1562305380000, // Event time + "s": "BTCUSDT", // Symbol + "p": "11794.15000000", // Mark price + "i": "11784.62659091", // Index price + "P": "11784.25641265", // Estimated Settle Price, only useful in the last hour before the settlement starts + "r": "0.00038167", // Funding rate + "T": 1562306400000 // Next funding time +} +*/ + +type ContinuousKLineEvent struct { + EventBase + Symbol string `json:"ps"` + ct string `json:"ct"` + KLine KLine `json:"k,omitempty"` +} + +/* +{ + "e":"continuous_kline", // Event type + "E":1607443058651, // Event time + "ps":"BTCUSDT", // Pair + "ct":"PERPETUAL" // Contract type + "k":{ + "t":1607443020000, // Kline start time + "T":1607443079999, // Kline close time + "i":"1m", // Interval + "f":116467658886, // First trade ID + "L":116468012423, // Last trade ID + "o":"18787.00", // Open price + "c":"18804.04", // Close price + "h":"18804.04", // High price + "l":"18786.54", // Low price + "v":"197.664", // volume + "n": 543, // Number of trades + "x":false, // Is this kline closed? + "q":"3715253.19494", // Quote asset volume + "V":"184.769", // Taker buy volume + "Q":"3472925.84746", //Taker buy quote asset volume + "B":"0" // Ignore + } +} +*/ + +// Similar to the ExecutionReportEvent's fields. But with totally different json key. +// e.g., Stop price. So that, we can not merge them. +type OrderTrade struct { + Symbol string `json:"s"` + ClientOrderID string `json:"c"` + Side string `json:"S"` + OrderType string `json:"o"` + TimeInForce string `json:"f"` + OriginalQuantity fixedpoint.Value `json:"q"` + OriginalPrice fixedpoint.Value `json:"p"` + + AveragePrice fixedpoint.Value `json:"ap"` + StopPrice fixedpoint.Value `json:"sp"` + CurrentExecutionType string `json:"x"` + CurrentOrderStatus string `json:"X"` + + OrderId int64 `json:"i"` + OrderLastFilledQuantity fixedpoint.Value `json:"l"` + OrderFilledAccumulatedQuantity fixedpoint.Value `json:"z"` + LastFilledPrice fixedpoint.Value `json:"L"` + + CommissionAmount fixedpoint.Value `json:"n"` + CommissionAsset string `json:"N"` + + OrderTradeTime types.MillisecondTimestamp `json:"T"` + TradeId int64 `json:"t"` + + BidsNotional string `json:"b"` + AskNotional string `json:"a"` + + IsMaker bool `json:"m"` + IsReduceOnly bool ` json:"r"` + + StopPriceWorkingType string `json:"wt"` + OriginalOrderType string `json:"ot"` + PositionSide string `json:"ps"` + RealizedProfit string `json:"rp"` +} + +type OrderTradeUpdateEvent struct { + EventBase + Transaction int64 `json:"T"` + OrderTrade OrderTrade `json:"o"` +} + +// { + +// "e":"ORDER_TRADE_UPDATE", // Event Type +// "E":1568879465651, // Event Time +// "T":1568879465650, // Transaction Time +// "o":{ +// "s":"BTCUSDT", // Symbol +// "c":"TEST", // Client Order Id +// // special client order id: +// // starts with "autoclose-": liquidation order +// // "adl_autoclose": ADL auto close order +// "S":"SELL", // Side +// "o":"TRAILING_STOP_MARKET", // Order Type +// "f":"GTC", // Time in Force +// "q":"0.001", // Original Quantity +// "p":"0", // Original Price +// "ap":"0", // Average Price +// "sp":"7103.04", // Stop Price. Please ignore with TRAILING_STOP_MARKET order +// "x":"NEW", // Execution Type +// "X":"NEW", // Order Status +// "i":8886774, // Order Id +// "l":"0", // Order Last Filled Quantity +// "z":"0", // Order Filled Accumulated Quantity +// "L":"0", // Last Filled Price +// "N":"USDT", // Commission Asset, will not push if no commission +// "n":"0", // Commission, will not push if no commission +// "T":1568879465651, // Order Trade Time +// "t":0, // Trade Id +// "b":"0", // Bids Notional +// "a":"9.91", // Ask Notional +// "m":false, // Is this trade the maker side? +// "R":false, // Is this reduce only +// "wt":"CONTRACT_PRICE", // Stop Price Working Type +// "ot":"TRAILING_STOP_MARKET", // Original Order Type +// "ps":"LONG", // Position Side +// "cp":false, // If Close-All, pushed with conditional order +// "AP":"7476.89", // Activation Price, only puhed with TRAILING_STOP_MARKET order +// "cr":"5.0", // Callback Rate, only puhed with TRAILING_STOP_MARKET order +// "rp":"0" // Realized Profit of the trade +// } + +// } + +func (e *OrderTradeUpdateEvent) OrderFutures() (*types.Order, error) { + + switch e.OrderTrade.CurrentExecutionType { + case "NEW", "CANCELED", "EXPIRED": + case "CALCULATED - Liquidation Execution": + case "TRADE": // For Order FILLED status. And the order has been completed. + default: + return nil, errors.New("execution report type is not for futures order") + } + + return &types.Order{ + Exchange: types.ExchangeBinance, + SubmitOrder: types.SubmitOrder{ + Symbol: e.OrderTrade.Symbol, + ClientOrderID: e.OrderTrade.ClientOrderID, + Side: toGlobalFuturesSideType(futures.SideType(e.OrderTrade.Side)), + Type: toGlobalFuturesOrderType(futures.OrderType(e.OrderTrade.OrderType)), + Quantity: e.OrderTrade.OriginalQuantity, + Price: e.OrderTrade.OriginalPrice, + TimeInForce: types.TimeInForce(e.OrderTrade.TimeInForce), + }, + OrderID: uint64(e.OrderTrade.OrderId), + Status: toGlobalFuturesOrderStatus(futures.OrderStatusType(e.OrderTrade.CurrentOrderStatus)), + ExecutedQuantity: e.OrderTrade.OrderFilledAccumulatedQuantity, + CreationTime: types.Time(e.OrderTrade.OrderTradeTime.Time()), // FIXME: find the correct field for creation time + UpdateTime: types.Time(e.OrderTrade.OrderTradeTime.Time()), + }, nil +} + +func (e *OrderTradeUpdateEvent) TradeFutures() (*types.Trade, error) { + if e.OrderTrade.CurrentExecutionType != "TRADE" { + return nil, errors.New("execution report is not a futures trade") + } + + return &types.Trade{ + ID: uint64(e.OrderTrade.TradeId), + Exchange: types.ExchangeBinance, + Symbol: e.OrderTrade.Symbol, + OrderID: uint64(e.OrderTrade.OrderId), + Side: toGlobalSideType(binance.SideType(e.OrderTrade.Side)), + Price: e.OrderTrade.LastFilledPrice, + Quantity: e.OrderTrade.OrderLastFilledQuantity, + QuoteQuantity: e.OrderTrade.LastFilledPrice.Mul(e.OrderTrade.OrderLastFilledQuantity), + IsBuyer: e.OrderTrade.Side == "BUY", + IsMaker: e.OrderTrade.IsMaker, + Time: types.Time(e.OrderTrade.OrderTradeTime.Time()), + Fee: e.OrderTrade.CommissionAmount, + FeeCurrency: e.OrderTrade.CommissionAsset, + }, nil +} + +type FuturesStreamBalance struct { + Asset string `json:"a"` + WalletBalance fixedpoint.Value `json:"wb"` + CrossWalletBalance fixedpoint.Value `json:"cw"` + BalanceChange fixedpoint.Value `json:"bc"` +} + +type FuturesStreamPosition struct { + Symbol string `json:"s"` + PositionAmount fixedpoint.Value `json:"pa"` + EntryPrice fixedpoint.Value `json:"ep"` + AccumulatedRealizedPnL fixedpoint.Value `json:"cr"` // (Pre-fee) Accumulated Realized PnL + UnrealizedPnL fixedpoint.Value `json:"up"` + MarginType string `json:"mt"` + IsolatedWallet fixedpoint.Value `json:"iw"` + PositionSide string `json:"ps"` +} + +type AccountUpdateEventReasonType string + +const ( + AccountUpdateEventReasonDeposit AccountUpdateEventReasonType = "DEPOSIT" + AccountUpdateEventReasonWithdraw AccountUpdateEventReasonType = "WITHDRAW" + AccountUpdateEventReasonOrder AccountUpdateEventReasonType = "ORDER" + AccountUpdateEventReasonFundingFee AccountUpdateEventReasonType = "FUNDING_FEE" + AccountUpdateEventReasonMarginTransfer AccountUpdateEventReasonType = "MARGIN_TRANSFER" + AccountUpdateEventReasonMarginTypeChange AccountUpdateEventReasonType = "MARGIN_TYPE_CHANGE" + AccountUpdateEventReasonAssetTransfer AccountUpdateEventReasonType = "ASSET_TRANSFER" + AccountUpdateEventReasonAdminDeposit AccountUpdateEventReasonType = "ADMIN_DEPOSIT" + AccountUpdateEventReasonAdminWithdraw AccountUpdateEventReasonType = "ADMIN_WITHDRAW" +) + +type AccountUpdate struct { + // m: DEPOSIT WITHDRAW + // ORDER FUNDING_FEE + // WITHDRAW_REJECT ADJUSTMENT + // INSURANCE_CLEAR + // ADMIN_DEPOSIT ADMIN_WITHDRAW + // MARGIN_TRANSFER MARGIN_TYPE_CHANGE + // ASSET_TRANSFER + // OPTIONS_PREMIUM_FEE OPTIONS_SETTLE_PROFIT + // AUTO_EXCHANGE + // COIN_SWAP_DEPOSIT COIN_SWAP_WITHDRAW + EventReasonType AccountUpdateEventReasonType `json:"m"` + Balances []FuturesStreamBalance `json:"B,omitempty"` + Positions []FuturesStreamPosition `json:"P,omitempty"` +} + +type MarginCallEvent struct { + EventBase + + CrossWalletBalance fixedpoint.Value `json:"cw"` + P []struct { + Symbol string `json:"s"` + PositionSide string `json:"ps"` + PositionAmount fixedpoint.Value `json:"pa"` + MarginType string `json:"mt"` + IsolatedWallet fixedpoint.Value `json:"iw"` + MarkPrice fixedpoint.Value `json:"mp"` + UnrealizedPnL fixedpoint.Value `json:"up"` + MaintenanceMarginRequired fixedpoint.Value `json:"mm"` + } `json:"p"` // Position(s) of Margin Call +} + +// AccountUpdateEvent is only used in the futures user data stream +type AccountUpdateEvent struct { + EventBase + Transaction int64 `json:"T"` + AccountUpdate AccountUpdate `json:"a"` +} + +type AccountConfigUpdateEvent struct { + EventBase + Transaction int64 `json:"T"` + + // When the leverage of a trade pair changes, + // the payload will contain the object ac to represent the account configuration of the trade pair, + // where s represents the specific trade pair and l represents the leverage + AccountConfig struct { + Symbol string `json:"s"` + Leverage fixedpoint.Value `json:"l"` + } `json:"ac"` + + // When the user Multi-Assets margin mode changes the payload will contain the object ai representing the user account configuration, + // where j represents the user Multi-Assets margin mode + MarginModeConfig struct { + MultiAssetsMode bool `json:"j"` + } `json:"ai"` +} + +/* + { + "lastUpdateId": 160, // Last update ID + "bids": [ // Bids to be updated + [ + "0.0024", // Price level to be updated + "10" // Quantity + ] + ], + "asks": [ // Asks to be updated + [ + "0.0026", // Price level to be updated + "100" // Quantity + ] + ] + } +*/ +type PartialDepthEvent struct { + EventBase + + binanceapi.Depth +} + +/* + { + "u":400900217, // order book updateId + "s":"BNBUSDT", // symbol + "b":"25.35190000", // best bid price + "B":"31.21000000", // best bid qty + "a":"25.36520000", // best ask price + "A":"40.66000000" // best ask qty + } +*/ +type BookTickerEvent struct { + EventBase + UpdateID int64 `json:"u"` + Symbol string `json:"s"` + Buy fixedpoint.Value `json:"b"` + BuySize fixedpoint.Value `json:"B"` + Sell fixedpoint.Value `json:"a"` + SellSize fixedpoint.Value `json:"A"` +} + +func (k *BookTickerEvent) BookTicker() types.BookTicker { + return types.BookTicker{ + Symbol: k.Symbol, + Buy: k.Buy, + BuySize: k.BuySize, + Sell: k.Sell, + SellSize: k.SellSize, + } +} diff --git a/pkg/exchange/binance/parse_test.go b/pkg/exchange/binance/parse_test.go new file mode 100644 index 0000000..fe2e735 --- /dev/null +++ b/pkg/exchange/binance/parse_test.go @@ -0,0 +1,414 @@ +package binance + +import ( + "regexp" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +var jsCommentTrimmer = regexp.MustCompile("(?m)//.*$") + +func TestMarginResponseParsing(t *testing.T) { + type testcase struct { + input string + } + + var testcases = []testcase{ + { + input: `{ + "e": "executionReport", + "E": 1608545107403, + "s": "BNBUSDT", + "c": "ios_0de017ca7ceb4102b3baa664feb46e65", + "S": "BUY", + "o": "MARKET", + "f": "GTC", + "q": "0.50000000", + "p": "0.00000000", + "P": "0.00000000", + "F": "0.00000000", + "g": -1, + "C": "", + "x": "NEW", + "X": "NEW", + "r": "NONE", + "i": 1147335332, + "l": "0.00000000", + "z": "0.00000000", + "L": "0.00000000", + "n": "0", + "N": null, + "T": 1608545107401, + "t": -1, + "I": 2387303818, + "w": true, + "m": false, + "M": false, + "O": 1608545107401, + "Z": "0.00000000", + "Y": "0.00000000", + "Q": "0.00000000" + }`, + }, + { + input: `{ + "e": "executionReport", + "E": 1608545107403, + "s": "BNBUSDT", + "c": "ios_0de017ca7ceb4102b3baa664feb46e65", + "S": "BUY", + "o": "MARKET", + "f": "GTC", + "q": "0.50000000", + "p": "0.00000000", + "P": "0.00000000", + "F": "0.00000000", + "g": -1, + "C": "", + "x": "TRADE", + "X": "FILLED", + "r": "NONE", + "i": 1147335332, + "l": "0.50000000", + "z": "0.50000000", + "L": "33.85710000", + "n": "0.00037500", + "N": "BNB", + "T": 1608545107401, + "t": 98414801, + "I": 2387303819, + "w": false, + "m": false, + "M": true, + "O": 1608545107401, + "Z": "16.92855000", + "Y": "16.92855000", + "Q": "0.00000000" + }`, + }, + { + input: `{ + "e": "outboundAccountInfo", + "E": 1608545107403, + "m": 9, + "t": 10, + "b": 0, + "s": 0, + "T": true, + "W": true, + "D": true, + "u": 1608545107401, + "B": [ + { + "a": "BNB", + "f": "89.99345616", + "l": "12.00000000" + }, + { + "a": "USDT", + "f": "0.16410063", + "l": "0.00000000" + } + ], + "P": [] + }`, + }, + { + input: `{ + "e":"outboundAccountPosition", + "E":1608545107403, + "u":1608545107401, + "B":[{"a":"BNB","f":"89.99345616","l":"12.00000000"},{"a":"USDT","f":"0.16410063","l":"0.00000000"}] + }`, + }, + } + + for _, testcase := range testcases { + payload := testcase.input + payload = jsCommentTrimmer.ReplaceAllLiteralString(payload, "") + event, err := parseWebSocketEvent([]byte(payload)) + assert.NoError(t, err) + assert.NotNil(t, event) + } +} + +func TestParseOrderUpdate(t *testing.T) { + payload := `{ + "e": "executionReport", // Event type + "E": 1499405658658, // Event time + "s": "ETHBTC", // Symbol + "c": "mUvoqJxFIILMdfAW5iGSOW", // Client order ID + "S": "BUY", // Side + "o": "LIMIT", // Order type + "f": "GTC", // Time in force + "q": "1.00000000", // Order quantity + "p": "0.10264410", // Order price + "P": "0.222", // Stop price + "F": "0.00000000", // Iceberg quantity + "g": -1, // OrderListId + "C": null, // Original client order ID; This is the ID of the order being canceled + "x": "NEW", // Current execution type + "X": "NEW", // Current order status + "r": "NONE", // Order reject reason; will be an error code. + "i": 4293153, // Order ID + "l": "0.00000000", // Last executed quantity + "z": "0.00000000", // Cumulative filled quantity + "L": "0.00000001", // Last executed price + "n": "0", // Commission amount + "N": null, // Commission asset + "T": 1499405658657, // Transaction time + "t": -1, // Trade ID + "I": 8641984, // Ignore + "w": true, // Is the order on the book? + "m": false, // Is this trade the maker side? + "M": true, // Ignore + "O": 1499405658657, // Order creation time + "Z": "0.1", // Cumulative quote asset transacted quantity + "Y": "0.00000000", // Last quote asset transacted quantity (i.e. lastPrice * lastQty) + "Q": "2.0" // Quote Order Quantity +}` + + payload = jsCommentTrimmer.ReplaceAllLiteralString(payload, "") + + event, err := parseWebSocketEvent([]byte(payload)) + assert.NoError(t, err) + assert.NotNil(t, event) + + executionReport, ok := event.(*ExecutionReportEvent) + assert.True(t, ok) + assert.NotNil(t, executionReport) + + assert.Equal(t, executionReport.Symbol, "ETHBTC") + assert.Equal(t, executionReport.Side, "BUY") + assert.Equal(t, executionReport.ClientOrderID, "mUvoqJxFIILMdfAW5iGSOW") + assert.Equal(t, executionReport.OriginalClientOrderID, "") + assert.Equal(t, executionReport.OrderType, "LIMIT") + assert.Equal(t, executionReport.OrderCreationTime, int64(1499405658657)) + assert.Equal(t, executionReport.TimeInForce, "GTC") + assert.Equal(t, executionReport.IcebergQuantity, fixedpoint.MustNewFromString("0.00000000")) + assert.Equal(t, executionReport.OrderQuantity, fixedpoint.MustNewFromString("1.00000000")) + assert.Equal(t, executionReport.QuoteOrderQuantity, fixedpoint.MustNewFromString("2.0")) + assert.Equal(t, executionReport.OrderPrice, fixedpoint.MustNewFromString("0.10264410")) + assert.Equal(t, executionReport.StopPrice, fixedpoint.MustNewFromString("0.222")) + assert.Equal(t, executionReport.IsOnBook, true) + assert.Equal(t, executionReport.IsMaker, false) + assert.Equal(t, executionReport.Ignore, true) + assert.Equal(t, executionReport.CommissionAmount, fixedpoint.MustNewFromString("0")) + assert.Equal(t, executionReport.CommissionAsset, "") + assert.Equal(t, executionReport.CurrentExecutionType, "NEW") + assert.Equal(t, executionReport.CurrentOrderStatus, "NEW") + assert.Equal(t, executionReport.OrderID, int64(4293153)) + assert.Equal(t, executionReport.Ignored, int64(8641984)) + assert.Equal(t, executionReport.TradeID, int64(-1)) + assert.Equal(t, executionReport.TransactionTime, int64(1499405658657)) + assert.Equal(t, executionReport.LastExecutedQuantity, fixedpoint.MustNewFromString("0.00000000")) + assert.Equal(t, executionReport.LastExecutedPrice, fixedpoint.MustNewFromString("0.00000001")) + assert.Equal(t, executionReport.CumulativeFilledQuantity, fixedpoint.MustNewFromString("0.00000000")) + assert.Equal(t, executionReport.CumulativeQuoteAssetTransactedQuantity, fixedpoint.MustNewFromString("0.1")) + assert.Equal(t, executionReport.LastQuoteAssetTransactedQuantity, fixedpoint.MustNewFromString("0.00000000")) + + orderUpdate, err := executionReport.Order() + assert.NoError(t, err) + assert.NotNil(t, orderUpdate) +} + +func TestFuturesResponseParsing(t *testing.T) { + type testcase struct { + input string + } + + var testcases = []testcase{ + { + input: `{ + "e": "ORDER_TRADE_UPDATE", + "T": 1639933384755, + "E": 1639933384763, + "o": { + "s": "BTCUSDT", + "c": "x-NSUYEBKMe60cf610-f5c7-49a4-9c1", + "S": "SELL", + "o": "MARKET", + "f": "GTC", + "q": "0.001", + "p": "0", + "ap": "0", + "sp": "0", + "x": "NEW", + "X": "NEW", + "i": 38541728873, + "l": "0", + "z": "0", + "L": "0", + "T": 1639933384755, + "t": 0, + "b": "0", + "a": "0", + "m": false, + "R": false, + "wt": "CONTRACT_PRICE", + "ot": "MARKET", + "ps": "BOTH", + "cp": false, + "rp": "0", + "pP": false, + "si": 0, + "ss": 0 + } + }`, + }, + { + input: `{ + "e": "ACCOUNT_UPDATE", + "T": 1639933384755, + "E": 1639933384763, + "a": { + "B": [ + { + "a": "USDT", + "wb": "86.94966888", + "cw": "86.94966888", + "bc": "0" + } + ], + "P": [ + { + "s": "BTCUSDT", + "pa": "-0.001", + "ep": "47202.40000", + "cr": "7.78107001", + "up": "-0.00233523", + "mt": "cross", + "iw": "0", + "ps": "BOTH", + "ma": "USDT" + } + ], + "m": "ORDER" + } + }`, + }, + { + input: `{ + "e": "ORDER_TRADE_UPDATE", + "T": 1639933384755, + "E": 1639933384763, + "o": { + "s": "BTCUSDT", + "c": "x-NSUYEBKMe60cf610-f5c7-49a4-9c1", + "S": "SELL", + "o": "MARKET", + "f": "GTC", + "q": "0.001", + "p": "0", + "ap": "47202.40000", + "sp": "0", + "x": "TRADE", + "X": "FILLED", + "i": 38541728873, + "l": "0.001", + "z": "0.001", + "L": "47202.40", + "n": "0.01888095", + "N": "USDT", + "T": 1639933384755, + "t": 1741505949, + "b": "0", + "a": "0", + "m": false, + "R": false, + "wt": "CONTRACT_PRICE", + "ot": "MARKET", + "ps": "BOTH", + "cp": false, + "rp": "0", + "pP": false, + "si": 0, + "ss": 0 + } + }`, + }, + } + + for _, testcase := range testcases { + payload := testcase.input + payload = jsCommentTrimmer.ReplaceAllLiteralString(payload, "") + event, err := parseWebSocketEvent([]byte(payload)) + assert.NoError(t, err) + assert.NotNil(t, event) + } +} + +func TestParseOrderFuturesUpdate(t *testing.T) { + payload := `{ + "e": "ORDER_TRADE_UPDATE", + "T": 1639933384755, + "E": 1639933384763, + "o": { + "s": "BTCUSDT", + "c": "x-NSUYEBKMe60cf610-f5c7-49a4-9c1", + "S": "SELL", + "o": "MARKET", + "f": "GTC", + "q": "0.001", + "p": "0", + "ap": "47202.40000", + "sp": "0", + "x": "TRADE", + "X": "FILLED", + "i": 38541728873, + "l": "0.001", + "z": "0.001", + "L": "47202.40", + "n": "0.01888095", + "N": "USDT", + "T": 1639933384755, + "t": 1741505949, + "b": "0", + "a": "0", + "m": false, + "R": false, + "wt": "CONTRACT_PRICE", + "ot": "MARKET", + "ps": "BOTH", + "cp": false, + "rp": "0", + "pP": false, + "si": 0, + "ss": 0 + } + }` + + payload = jsCommentTrimmer.ReplaceAllLiteralString(payload, "") + + event, err := parseWebSocketEvent([]byte(payload)) + assert.NoError(t, err) + assert.NotNil(t, event) + + orderTradeEvent, ok := event.(*OrderTradeUpdateEvent) + assert.True(t, ok) + assert.NotNil(t, orderTradeEvent) + + assert.Equal(t, "BTCUSDT", orderTradeEvent.OrderTrade.Symbol) + assert.Equal(t, "SELL", orderTradeEvent.OrderTrade.Side) + assert.Equal(t, "x-NSUYEBKMe60cf610-f5c7-49a4-9c1", orderTradeEvent.OrderTrade.ClientOrderID) + assert.Equal(t, "MARKET", orderTradeEvent.OrderTrade.OrderType) + assert.Equal(t, types.NewMillisecondTimestampFromInt(1639933384763), orderTradeEvent.Time) + assert.Equal(t, types.MillisecondTimestamp(time.UnixMilli(1639933384755)), orderTradeEvent.OrderTrade.OrderTradeTime) + assert.Equal(t, fixedpoint.MustNewFromString("0.001"), orderTradeEvent.OrderTrade.OriginalQuantity) + assert.Equal(t, fixedpoint.MustNewFromString("0.001"), orderTradeEvent.OrderTrade.OrderLastFilledQuantity) + assert.Equal(t, fixedpoint.MustNewFromString("0.001"), orderTradeEvent.OrderTrade.OrderFilledAccumulatedQuantity) + assert.Equal(t, "TRADE", orderTradeEvent.OrderTrade.CurrentExecutionType) + assert.Equal(t, "FILLED", orderTradeEvent.OrderTrade.CurrentOrderStatus) + assert.Equal(t, fixedpoint.MustNewFromString("47202.40"), orderTradeEvent.OrderTrade.LastFilledPrice) + assert.Equal(t, int64(38541728873), orderTradeEvent.OrderTrade.OrderId) + assert.Equal(t, int64(1741505949), orderTradeEvent.OrderTrade.TradeId) + + orderUpdate, err := orderTradeEvent.OrderFutures() + assert.NoError(t, err) + assert.NotNil(t, orderUpdate) +} diff --git a/pkg/exchange/binance/reward.go b/pkg/exchange/binance/reward.go new file mode 100644 index 0000000..b978c55 --- /dev/null +++ b/pkg/exchange/binance/reward.go @@ -0,0 +1,45 @@ +package binance + +import ( + "context" + "strconv" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/binance/binanceapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func (e *Exchange) QueryRewards(ctx context.Context, startTime time.Time) ([]types.Reward, error) { + req := e.client2.NewGetSpotRebateHistoryRequest() + req.StartTime(startTime) + history, err := req.Do(ctx) + if err != nil { + return nil, err + } + + var rewards []types.Reward + + for _, entry := range history { + t := types.RewardCommission + switch entry.Type { + case binanceapi.RebateTypeReferralKickback: + t = types.RewardReferralKickback + case binanceapi.RebateTypeCommission: + // use the default type + } + + rewards = append(rewards, types.Reward{ + UUID: strconv.FormatInt(entry.UpdateTime.Time().UnixMilli(), 10), + Exchange: types.ExchangeBinance, + Type: t, + Currency: entry.Asset, + Quantity: entry.Amount, + State: "done", + Note: "", + Spent: false, + CreatedAt: types.Time(entry.UpdateTime), + }) + } + + return rewards, nil +} diff --git a/pkg/exchange/binance/stream.go b/pkg/exchange/binance/stream.go new file mode 100644 index 0000000..b3d70ed --- /dev/null +++ b/pkg/exchange/binance/stream.go @@ -0,0 +1,504 @@ +package binance + +import ( + "context" + "net" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/depth" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" + + "github.com/adshao/go-binance/v2" + "github.com/adshao/go-binance/v2/futures" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// from Binance document: +// The websocket server will send a ping frame every 3 minutes. +// If the websocket server does not receive a pong frame back from the connection within a 10 minute period, the connection will be disconnected. +// Unsolicited pong frames are allowed. + +// WebSocket connections have a limit of 5 incoming messages per second. A message is considered: +// A PING frame +// A PONG frame +// A JSON controlled message (e.g. subscribe, unsubscribe) +const listenKeyKeepAliveInterval = 15 * time.Minute + +type WebSocketCommand struct { + // request ID is required + ID int `json:"id"` + Method string `json:"method"` + Params []string `json:"params"` +} + +//go:generate callbackgen -type Stream -interface +type Stream struct { + types.MarginSettings + types.FuturesSettings + types.StandardStream + + client *binance.Client + futuresClient *futures.Client + + // custom callbacks + depthEventCallbacks []func(e *DepthEvent) + kLineEventCallbacks []func(e *KLineEvent) + kLineClosedEventCallbacks []func(e *KLineEvent) + + marketTradeEventCallbacks []func(e *MarketTradeEvent) + aggTradeEventCallbacks []func(e *AggTradeEvent) + forceOrderEventCallbacks []func(e *ForceOrderEvent) + + balanceUpdateEventCallbacks []func(event *BalanceUpdateEvent) + outboundAccountInfoEventCallbacks []func(event *OutboundAccountInfoEvent) + outboundAccountPositionEventCallbacks []func(event *OutboundAccountPositionEvent) + executionReportEventCallbacks []func(event *ExecutionReportEvent) + bookTickerEventCallbacks []func(event *BookTickerEvent) + + // futures market data stream + markPriceUpdateEventCallbacks []func(e *MarkPriceUpdateEvent) + continuousKLineEventCallbacks []func(e *ContinuousKLineEvent) + continuousKLineClosedEventCallbacks []func(e *ContinuousKLineEvent) + + // futures user data stream event callbacks + orderTradeUpdateEventCallbacks []func(e *OrderTradeUpdateEvent) + accountUpdateEventCallbacks []func(e *AccountUpdateEvent) + accountConfigUpdateEventCallbacks []func(e *AccountConfigUpdateEvent) + marginCallEventCallbacks []func(e *MarginCallEvent) + listenKeyExpiredCallbacks []func(e *ListenKeyExpired) + + // depthBuffers is used for storing the depth info + depthBuffers map[string]*depth.Buffer +} + +func NewStream(ex *Exchange, client *binance.Client, futuresClient *futures.Client) *Stream { + stream := &Stream{ + StandardStream: types.NewStandardStream(), + client: client, + futuresClient: futuresClient, + depthBuffers: make(map[string]*depth.Buffer), + } + + stream.SetParser(parseWebSocketEvent) + stream.SetDispatcher(stream.dispatchEvent) + stream.SetEndpointCreator(stream.createEndpoint) + + stream.OnDepthEvent(func(e *DepthEvent) { + f, ok := stream.depthBuffers[e.Symbol] + if ok { + err := f.AddUpdate(types.SliceOrderBook{ + Symbol: e.Symbol, + Time: e.EventBase.Time.Time(), + Bids: e.Bids, + Asks: e.Asks, + }, e.FirstUpdateID, e.FinalUpdateID) + if err != nil { + log.WithError(err).Errorf("found missing %s update event", e.Symbol) + } + } else { + f = depth.NewBuffer(func() (types.SliceOrderBook, int64, error) { + log.Infof("fetching %s depth...", e.Symbol) + return ex.QueryDepth(context.Background(), e.Symbol) + }) + f.SetBufferingPeriod(time.Second) + f.OnReady(func(snapshot types.SliceOrderBook, updates []depth.Update) { + stream.EmitBookSnapshot(snapshot) + for _, u := range updates { + stream.EmitBookUpdate(u.Object) + } + }) + f.OnPush(func(update depth.Update) { + stream.EmitBookUpdate(update.Object) + }) + stream.depthBuffers[e.Symbol] = f + } + }) + + stream.OnOutboundAccountPositionEvent(stream.handleOutboundAccountPositionEvent) + stream.OnKLineEvent(stream.handleKLineEvent) + stream.OnBookTickerEvent(stream.handleBookTickerEvent) + stream.OnExecutionReportEvent(stream.handleExecutionReportEvent) + stream.OnContinuousKLineEvent(stream.handleContinuousKLineEvent) + stream.OnMarketTradeEvent(stream.handleMarketTradeEvent) + stream.OnAggTradeEvent(stream.handleAggTradeEvent) + stream.OnForceOrderEvent(stream.handleForceOrderEvent) + + // Futures User Data Stream + // =================================== + // Event type ACCOUNT_UPDATE from user data stream updates Balance and FuturesPosition. + stream.OnOrderTradeUpdateEvent(stream.handleOrderTradeUpdateEvent) + // =================================== + + stream.OnDisconnect(stream.handleDisconnect) + stream.OnConnect(stream.handleConnect) + stream.OnListenKeyExpired(func(e *ListenKeyExpired) { + stream.Reconnect() + }) + return stream +} + +func (s *Stream) handleDisconnect() { + log.Debugf("resetting depth snapshots...") + for _, f := range s.depthBuffers { + f.Reset() + } +} + +func (s *Stream) handleConnect() { + if !s.PublicOnly { + // Emit Auth before establishing the connection to prevent the caller from missing the Update data after + // creating the order. + s.EmitAuth() + return + } + + var params []string + for _, subscription := range s.Subscriptions { + params = append(params, convertSubscription(subscription)) + } + + if len(params) == 0 { + return + } + + log.Infof("subscribing channels: %+v", params) + err := s.Conn.WriteJSON(WebSocketCommand{ + Method: "SUBSCRIBE", + Params: params, + ID: 1, + }) + + if err != nil { + log.WithError(err).Error("subscribe error") + } +} + +func (s *Stream) handleContinuousKLineEvent(e *ContinuousKLineEvent) { + kline := e.KLine.KLine() + if e.KLine.Closed { + s.EmitContinuousKLineClosedEvent(e) + s.EmitKLineClosed(kline) + } else { + s.EmitKLine(kline) + } +} + +func (s *Stream) handleExecutionReportEvent(e *ExecutionReportEvent) { + switch e.CurrentExecutionType { + + case "NEW", "CANCELED", "REJECTED", "EXPIRED", "REPLACED": + order, err := e.Order() + if err != nil { + log.WithError(err).Error("order convert error") + return + } + + s.EmitOrderUpdate(*order) + + case "TRADE": + trade, err := e.Trade() + if err != nil { + log.WithError(err).Error("trade convert error") + return + } + + s.EmitTradeUpdate(*trade) + + order, err := e.Order() + if err != nil { + log.WithError(err).Error("order convert error") + return + } + + // Update Order with FILLED event + if order.Status == types.OrderStatusFilled { + s.EmitOrderUpdate(*order) + } + } +} + +func (s *Stream) handleBookTickerEvent(e *BookTickerEvent) { + s.EmitBookTickerUpdate(e.BookTicker()) +} + +func (s *Stream) handleMarketTradeEvent(e *MarketTradeEvent) { + s.EmitMarketTrade(e.Trade()) +} + +func (s *Stream) handleAggTradeEvent(e *AggTradeEvent) { + s.EmitAggTrade(e.Trade()) +} + +func (s *Stream) handleForceOrderEvent(e *ForceOrderEvent) { + s.EmitForceOrder(e.LiquidationInfo()) +} + +func (s *Stream) handleKLineEvent(e *KLineEvent) { + kline := e.KLine.KLine() + if e.KLine.Closed { + s.EmitKLineClosedEvent(e) + s.EmitKLineClosed(kline) + } else { + s.EmitKLine(kline) + } +} + +func (s *Stream) handleOutboundAccountPositionEvent(e *OutboundAccountPositionEvent) { + snapshot := types.BalanceMap{} + for _, balance := range e.Balances { + snapshot[balance.Asset] = types.Balance{ + Currency: balance.Asset, + Available: balance.Free, + Locked: balance.Locked, + } + } + s.EmitBalanceSnapshot(snapshot) +} + +func (s *Stream) handleOrderTradeUpdateEvent(e *OrderTradeUpdateEvent) { + switch e.OrderTrade.CurrentExecutionType { + + case "NEW", "CANCELED", "EXPIRED": + order, err := e.OrderFutures() + if err != nil { + log.WithError(err).Error("futures order convert error") + return + } + + s.EmitOrderUpdate(*order) + + case "TRADE": + trade, err := e.TradeFutures() + if err != nil { + log.WithError(err).Error("futures trade convert error") + return + } + + s.EmitTradeUpdate(*trade) + + order, err := e.OrderFutures() + if err != nil { + log.WithError(err).Error("futures order convert error") + return + } + + // Update Order with FILLED event + if order.Status == types.OrderStatusFilled { + s.EmitOrderUpdate(*order) + } + + case "CALCULATED - Liquidation Execution": + log.Infof("CALCULATED - Liquidation Execution not support yet.") + } + +} + +func (s *Stream) getEndpointUrl(listenKey string) string { + var url string + + if s.IsFutures { + url = FuturesWebSocketURL + "/ws" + } else if isBinanceUs() { + url = BinanceUSWebSocketURL + "/ws" + } else { + url = WebSocketURL + "/ws" + } + + if !s.PublicOnly { + url += "/" + listenKey + } + + return url +} + +func (s *Stream) createEndpoint(ctx context.Context) (string, error) { + var err error + var listenKey string + if s.PublicOnly { + log.Debugf("stream is set to public only mode") + } else { + listenKey, err = s.fetchListenKey(ctx) + if err != nil { + return "", err + } + + log.Debugf("listen key is created: %s", util.MaskKey(listenKey)) + go s.listenKeyKeepAlive(ctx, listenKey) + } + + url := s.getEndpointUrl(listenKey) + return url, nil +} + +func (s *Stream) dispatchEvent(e interface{}) { + switch e := e.(type) { + + case *OutboundAccountPositionEvent: + s.EmitOutboundAccountPositionEvent(e) + + case *OutboundAccountInfoEvent: + s.EmitOutboundAccountInfoEvent(e) + + case *BalanceUpdateEvent: + s.EmitBalanceUpdateEvent(e) + + case *MarketTradeEvent: + s.EmitMarketTradeEvent(e) + + case *AggTradeEvent: + s.EmitAggTradeEvent(e) + + case *KLineEvent: + s.EmitKLineEvent(e) + + case *BookTickerEvent: + s.EmitBookTickerEvent(e) + + case *DepthEvent: + s.EmitDepthEvent(e) + + case *ExecutionReportEvent: + s.EmitExecutionReportEvent(e) + + case *MarkPriceUpdateEvent: + s.EmitMarkPriceUpdateEvent(e) + + case *ContinuousKLineEvent: + s.EmitContinuousKLineEvent(e) + + case *OrderTradeUpdateEvent: + s.EmitOrderTradeUpdateEvent(e) + + case *AccountUpdateEvent: + s.EmitAccountUpdateEvent(e) + + case *AccountConfigUpdateEvent: + s.EmitAccountConfigUpdateEvent(e) + + case *ListenKeyExpired: + s.EmitListenKeyExpired(e) + + case *ForceOrderEvent: + s.EmitForceOrderEvent(e) + + case *MarginCallEvent: + + } +} + +func (s *Stream) fetchListenKey(ctx context.Context) (string, error) { + if s.IsMargin { + if s.IsIsolatedMargin { + log.Debugf("isolated margin %s is enabled, requesting margin user stream listen key...", s.IsolatedMarginSymbol) + req := s.client.NewStartIsolatedMarginUserStreamService() + req.Symbol(s.IsolatedMarginSymbol) + return req.Do(ctx) + } + + log.Debugf("margin mode is enabled, requesting margin user stream listen key...") + req := s.client.NewStartMarginUserStreamService() + return req.Do(ctx) + } else if s.IsFutures { + log.Debugf("futures mode is enabled, requesting futures user stream listen key...") + req := s.futuresClient.NewStartUserStreamService() + return req.Do(ctx) + } + + log.Debugf("spot mode is enabled, requesting user stream listen key...") + return s.client.NewStartUserStreamService().Do(ctx) +} + +func (s *Stream) keepaliveListenKey(ctx context.Context, listenKey string) error { + log.Debugf("keepalive listen key: %s", util.MaskKey(listenKey)) + if s.IsMargin { + if s.IsIsolatedMargin { + req := s.client.NewKeepaliveIsolatedMarginUserStreamService().ListenKey(listenKey) + req.Symbol(s.IsolatedMarginSymbol) + return req.Do(ctx) + } + req := s.client.NewKeepaliveMarginUserStreamService().ListenKey(listenKey) + return req.Do(ctx) + } else if s.IsFutures { + req := s.futuresClient.NewKeepaliveUserStreamService().ListenKey(listenKey) + return req.Do(ctx) + } + + return s.client.NewKeepaliveUserStreamService().ListenKey(listenKey).Do(ctx) +} + +func (s *Stream) closeListenKey(ctx context.Context, listenKey string) (err error) { + // should use background context to invalidate the user stream + log.Debugf("closing listen key: %s", util.MaskKey(listenKey)) + + if s.IsMargin { + if s.IsIsolatedMargin { + req := s.client.NewCloseIsolatedMarginUserStreamService().ListenKey(listenKey) + req.Symbol(s.IsolatedMarginSymbol) + err = req.Do(ctx) + } else { + req := s.client.NewCloseMarginUserStreamService().ListenKey(listenKey) + err = req.Do(ctx) + } + + } else if s.IsFutures { + req := s.futuresClient.NewCloseUserStreamService().ListenKey(listenKey) + err = req.Do(ctx) + } else { + err = s.client.NewCloseUserStreamService().ListenKey(listenKey).Do(ctx) + } + + return err +} + +// listenKeyKeepAlive +// From Binance +// Keepalive a user data stream to prevent a time out. User data streams will close after 60 minutes. +// It's recommended to send a ping about every 30 minutes. +func (s *Stream) listenKeyKeepAlive(ctx context.Context, listenKey string) { + keepAliveTicker := time.NewTicker(listenKeyKeepAliveInterval) + defer keepAliveTicker.Stop() + + // if we exit, we should invalidate the existing listen key + defer func() { + log.Debugf("keepalive worker stopped") + if err := s.closeListenKey(context.Background(), listenKey); err != nil { + log.WithError(err).Errorf("close listen key error: %v key: %s", err, util.MaskKey(listenKey)) + } + }() + + log.Debugf("starting listen key keep alive worker with interval %s, listen key = %s", listenKeyKeepAliveInterval, util.MaskKey(listenKey)) + + for { + select { + + case <-s.CloseC: + return + + case <-ctx.Done(): + return + + case <-keepAliveTicker.C: + for i := 0; i < 5; i++ { + err := s.keepaliveListenKey(ctx, listenKey) + if err != nil { + time.Sleep(5 * time.Second) + switch err.(type) { + case net.Error: + log.WithError(err).Errorf("listen key keep-alive network error: %v key: %s", err, util.MaskKey(listenKey)) + continue + + default: + log.WithError(err).Errorf("listen key keep-alive unexpected error: %v key: %s", err, util.MaskKey(listenKey)) + s.Reconnect() + return + + } + } else { + break + } + } + + } + } +} diff --git a/pkg/exchange/binance/stream_callbacks.go b/pkg/exchange/binance/stream_callbacks.go new file mode 100644 index 0000000..ecf8071 --- /dev/null +++ b/pkg/exchange/binance/stream_callbacks.go @@ -0,0 +1,235 @@ +// Code generated by "callbackgen -type Stream -interface"; DO NOT EDIT. + +package binance + +import () + +func (s *Stream) OnDepthEvent(cb func(e *DepthEvent)) { + s.depthEventCallbacks = append(s.depthEventCallbacks, cb) +} + +func (s *Stream) EmitDepthEvent(e *DepthEvent) { + for _, cb := range s.depthEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnKLineEvent(cb func(e *KLineEvent)) { + s.kLineEventCallbacks = append(s.kLineEventCallbacks, cb) +} + +func (s *Stream) EmitKLineEvent(e *KLineEvent) { + for _, cb := range s.kLineEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnKLineClosedEvent(cb func(e *KLineEvent)) { + s.kLineClosedEventCallbacks = append(s.kLineClosedEventCallbacks, cb) +} + +func (s *Stream) EmitKLineClosedEvent(e *KLineEvent) { + for _, cb := range s.kLineClosedEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnMarketTradeEvent(cb func(e *MarketTradeEvent)) { + s.marketTradeEventCallbacks = append(s.marketTradeEventCallbacks, cb) +} + +func (s *Stream) EmitMarketTradeEvent(e *MarketTradeEvent) { + for _, cb := range s.marketTradeEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnAggTradeEvent(cb func(e *AggTradeEvent)) { + s.aggTradeEventCallbacks = append(s.aggTradeEventCallbacks, cb) +} + +func (s *Stream) EmitAggTradeEvent(e *AggTradeEvent) { + for _, cb := range s.aggTradeEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnForceOrderEvent(cb func(e *ForceOrderEvent)) { + s.forceOrderEventCallbacks = append(s.forceOrderEventCallbacks, cb) +} + +func (s *Stream) EmitForceOrderEvent(e *ForceOrderEvent) { + for _, cb := range s.forceOrderEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnBalanceUpdateEvent(cb func(event *BalanceUpdateEvent)) { + s.balanceUpdateEventCallbacks = append(s.balanceUpdateEventCallbacks, cb) +} + +func (s *Stream) EmitBalanceUpdateEvent(event *BalanceUpdateEvent) { + for _, cb := range s.balanceUpdateEventCallbacks { + cb(event) + } +} + +func (s *Stream) OnOutboundAccountInfoEvent(cb func(event *OutboundAccountInfoEvent)) { + s.outboundAccountInfoEventCallbacks = append(s.outboundAccountInfoEventCallbacks, cb) +} + +func (s *Stream) EmitOutboundAccountInfoEvent(event *OutboundAccountInfoEvent) { + for _, cb := range s.outboundAccountInfoEventCallbacks { + cb(event) + } +} + +func (s *Stream) OnOutboundAccountPositionEvent(cb func(event *OutboundAccountPositionEvent)) { + s.outboundAccountPositionEventCallbacks = append(s.outboundAccountPositionEventCallbacks, cb) +} + +func (s *Stream) EmitOutboundAccountPositionEvent(event *OutboundAccountPositionEvent) { + for _, cb := range s.outboundAccountPositionEventCallbacks { + cb(event) + } +} + +func (s *Stream) OnExecutionReportEvent(cb func(event *ExecutionReportEvent)) { + s.executionReportEventCallbacks = append(s.executionReportEventCallbacks, cb) +} + +func (s *Stream) EmitExecutionReportEvent(event *ExecutionReportEvent) { + for _, cb := range s.executionReportEventCallbacks { + cb(event) + } +} + +func (s *Stream) OnBookTickerEvent(cb func(event *BookTickerEvent)) { + s.bookTickerEventCallbacks = append(s.bookTickerEventCallbacks, cb) +} + +func (s *Stream) EmitBookTickerEvent(event *BookTickerEvent) { + for _, cb := range s.bookTickerEventCallbacks { + cb(event) + } +} + +func (s *Stream) OnMarkPriceUpdateEvent(cb func(e *MarkPriceUpdateEvent)) { + s.markPriceUpdateEventCallbacks = append(s.markPriceUpdateEventCallbacks, cb) +} + +func (s *Stream) EmitMarkPriceUpdateEvent(e *MarkPriceUpdateEvent) { + for _, cb := range s.markPriceUpdateEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnContinuousKLineEvent(cb func(e *ContinuousKLineEvent)) { + s.continuousKLineEventCallbacks = append(s.continuousKLineEventCallbacks, cb) +} + +func (s *Stream) EmitContinuousKLineEvent(e *ContinuousKLineEvent) { + for _, cb := range s.continuousKLineEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnContinuousKLineClosedEvent(cb func(e *ContinuousKLineEvent)) { + s.continuousKLineClosedEventCallbacks = append(s.continuousKLineClosedEventCallbacks, cb) +} + +func (s *Stream) EmitContinuousKLineClosedEvent(e *ContinuousKLineEvent) { + for _, cb := range s.continuousKLineClosedEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnOrderTradeUpdateEvent(cb func(e *OrderTradeUpdateEvent)) { + s.orderTradeUpdateEventCallbacks = append(s.orderTradeUpdateEventCallbacks, cb) +} + +func (s *Stream) EmitOrderTradeUpdateEvent(e *OrderTradeUpdateEvent) { + for _, cb := range s.orderTradeUpdateEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnAccountUpdateEvent(cb func(e *AccountUpdateEvent)) { + s.accountUpdateEventCallbacks = append(s.accountUpdateEventCallbacks, cb) +} + +func (s *Stream) EmitAccountUpdateEvent(e *AccountUpdateEvent) { + for _, cb := range s.accountUpdateEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnAccountConfigUpdateEvent(cb func(e *AccountConfigUpdateEvent)) { + s.accountConfigUpdateEventCallbacks = append(s.accountConfigUpdateEventCallbacks, cb) +} + +func (s *Stream) EmitAccountConfigUpdateEvent(e *AccountConfigUpdateEvent) { + for _, cb := range s.accountConfigUpdateEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnMarginCallEvent(cb func(e *MarginCallEvent)) { + s.marginCallEventCallbacks = append(s.marginCallEventCallbacks, cb) +} + +func (s *Stream) EmitMarginCallEvent(e *MarginCallEvent) { + for _, cb := range s.marginCallEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnListenKeyExpired(cb func(e *ListenKeyExpired)) { + s.listenKeyExpiredCallbacks = append(s.listenKeyExpiredCallbacks, cb) +} + +func (s *Stream) EmitListenKeyExpired(e *ListenKeyExpired) { + for _, cb := range s.listenKeyExpiredCallbacks { + cb(e) + } +} + +type StreamEventHub interface { + OnDepthEvent(cb func(e *DepthEvent)) + + OnKLineEvent(cb func(e *KLineEvent)) + + OnKLineClosedEvent(cb func(e *KLineEvent)) + + OnMarketTradeEvent(cb func(e *MarketTradeEvent)) + + OnAggTradeEvent(cb func(e *AggTradeEvent)) + + OnForceOrderEvent(cb func(e *ForceOrderEvent)) + + OnBalanceUpdateEvent(cb func(event *BalanceUpdateEvent)) + + OnOutboundAccountInfoEvent(cb func(event *OutboundAccountInfoEvent)) + + OnOutboundAccountPositionEvent(cb func(event *OutboundAccountPositionEvent)) + + OnExecutionReportEvent(cb func(event *ExecutionReportEvent)) + + OnBookTickerEvent(cb func(event *BookTickerEvent)) + + OnMarkPriceUpdateEvent(cb func(e *MarkPriceUpdateEvent)) + + OnContinuousKLineEvent(cb func(e *ContinuousKLineEvent)) + + OnContinuousKLineClosedEvent(cb func(e *ContinuousKLineEvent)) + + OnOrderTradeUpdateEvent(cb func(e *OrderTradeUpdateEvent)) + + OnAccountUpdateEvent(cb func(e *AccountUpdateEvent)) + + OnAccountConfigUpdateEvent(cb func(e *AccountConfigUpdateEvent)) + + OnMarginCallEvent(cb func(e *MarginCallEvent)) + + OnListenKeyExpired(cb func(e *ListenKeyExpired)) +} diff --git a/pkg/exchange/binance/ticker_test.go b/pkg/exchange/binance/ticker_test.go new file mode 100644 index 0000000..3bdee46 --- /dev/null +++ b/pkg/exchange/binance/ticker_test.go @@ -0,0 +1,54 @@ +package binance + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExchange_QueryTickers_AllSymbols(t *testing.T) { + key := os.Getenv("BINANCE_API_KEY") + secret := os.Getenv("BINANCE_API_SECRET") + if len(key) == 0 && len(secret) == 0 { + t.Skip("api key/secret are not configured") + return + } + + e := New(key, secret) + got, err := e.QueryTickers(context.Background()) + if assert.NoError(t, err) { + assert.True(t, len(got) > 1, "binance: attempting to get all symbol tickers, but get 1 or less") + } +} + +func TestExchange_QueryTickers_SomeSymbols(t *testing.T) { + key := os.Getenv("BINANCE_API_KEY") + secret := os.Getenv("BINANCE_API_SECRET") + if len(key) == 0 && len(secret) == 0 { + t.Skip("api key/secret are not configured") + return + } + + e := New(key, secret) + got, err := e.QueryTickers(context.Background(), "BTCUSDT", "ETHUSDT") + if assert.NoError(t, err) { + assert.Len(t, got, 2, "binance: attempting to get two symbols, but number of tickers do not match") + } +} + +func TestExchange_QueryTickers_SingleSymbol(t *testing.T) { + key := os.Getenv("BINANCE_API_KEY") + secret := os.Getenv("BINANCE_API_SECRET") + if len(key) == 0 && len(secret) == 0 { + t.Skip("api key/secret are not configured") + return + } + + e := New(key, secret) + got, err := e.QueryTickers(context.Background(), "BTCUSDT") + if assert.NoError(t, err) { + assert.Len(t, got, 1, "binance: attempting to get one symbol, but number of tickers do not match") + } +} diff --git a/pkg/exchange/bitget/bitgetapi/cancel_order_by_symbol_request.go b/pkg/exchange/bitget/bitgetapi/cancel_order_by_symbol_request.go new file mode 100644 index 0000000..89ff6db --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/cancel_order_by_symbol_request.go @@ -0,0 +1,20 @@ +package bitgetapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +import ( + "github.com/c9s/requestgen" +) + +type CancelOrderBySymbolResponse string + +//go:generate GetRequest -url "/api/spot/v1/trade/cancel-symbol-order" -type CancelOrderBySymbolRequest -responseDataType .CancelOrderBySymbolResponse +type CancelOrderBySymbolRequest struct { + client requestgen.AuthenticatedAPIClient + symbol string `param:"symbol"` +} + +func (c *RestClient) NewCancelOrderBySymbolRequest() *CancelOrderBySymbolRequest { + return &CancelOrderBySymbolRequest{client: c} +} diff --git a/pkg/exchange/bitget/bitgetapi/cancel_order_by_symbol_request_requestgen.go b/pkg/exchange/bitget/bitgetapi/cancel_order_by_symbol_request_requestgen.go new file mode 100644 index 0000000..043f3c3 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/cancel_order_by_symbol_request_requestgen.go @@ -0,0 +1,152 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/spot/v1/trade/cancel-symbol-order -type CancelOrderBySymbolRequest -responseDataType .CancelOrderBySymbolResponse"; DO NOT EDIT. + +package bitgetapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (c *CancelOrderBySymbolRequest) Symbol(symbol string) *CancelOrderBySymbolRequest { + c.symbol = symbol + return c +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (c *CancelOrderBySymbolRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (c *CancelOrderBySymbolRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := c.symbol + + // assign parameter of symbol + params["symbol"] = symbol + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (c *CancelOrderBySymbolRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := c.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if c.isVarSlice(_v) { + c.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (c *CancelOrderBySymbolRequest) GetParametersJSON() ([]byte, error) { + params, err := c.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (c *CancelOrderBySymbolRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (c *CancelOrderBySymbolRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (c *CancelOrderBySymbolRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (c *CancelOrderBySymbolRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (c *CancelOrderBySymbolRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := c.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (c *CancelOrderBySymbolRequest) Do(ctx context.Context) (*CancelOrderBySymbolResponse, error) { + + // empty params for GET operation + var params interface{} + query, err := c.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/api/spot/v1/trade/cancel-symbol-order" + + req, err := c.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := c.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data CancelOrderBySymbolResponse + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/bitget/bitgetapi/cancel_order_request.go b/pkg/exchange/bitget/bitgetapi/cancel_order_request.go new file mode 100644 index 0000000..b0a7542 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/cancel_order_request.go @@ -0,0 +1,25 @@ +package bitgetapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +import ( + "github.com/c9s/requestgen" +) + +type CancelOrderResponse struct { + OrderId string `json:"orderId"` + ClientOrderId string `json:"clientOrderId"` +} + +//go:generate PostRequest -url "/api/spot/v1/trade/cancel-order-v2" -type CancelOrderRequest -responseDataType .CancelOrderResponse +type CancelOrderRequest struct { + client requestgen.AuthenticatedAPIClient + symbol string `param:"symbol"` + orderId *string `param:"orderId"` + clientOrderId *string `param:"clientOid"` +} + +func (c *RestClient) NewCancelOrderRequest() *CancelOrderRequest { + return &CancelOrderRequest{client: c} +} diff --git a/pkg/exchange/bitget/bitgetapi/cancel_order_request_requestgen.go b/pkg/exchange/bitget/bitgetapi/cancel_order_request_requestgen.go new file mode 100644 index 0000000..986db69 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/cancel_order_request_requestgen.go @@ -0,0 +1,177 @@ +// Code generated by "requestgen -method POST -responseType .APIResponse -responseDataField Data -url /api/spot/v1/trade/cancel-order-v2 -type CancelOrderRequest -responseDataType .CancelOrderResponse"; DO NOT EDIT. + +package bitgetapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (c *CancelOrderRequest) Symbol(symbol string) *CancelOrderRequest { + c.symbol = symbol + return c +} + +func (c *CancelOrderRequest) OrderId(orderId string) *CancelOrderRequest { + c.orderId = &orderId + return c +} + +func (c *CancelOrderRequest) ClientOrderId(clientOrderId string) *CancelOrderRequest { + c.clientOrderId = &clientOrderId + return c +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (c *CancelOrderRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (c *CancelOrderRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := c.symbol + + // assign parameter of symbol + params["symbol"] = symbol + // check orderId field -> json key orderId + if c.orderId != nil { + orderId := *c.orderId + + // assign parameter of orderId + params["orderId"] = orderId + } else { + } + // check clientOrderId field -> json key clientOid + if c.clientOrderId != nil { + clientOrderId := *c.clientOrderId + + // assign parameter of clientOrderId + params["clientOid"] = clientOrderId + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (c *CancelOrderRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := c.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if c.isVarSlice(_v) { + c.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (c *CancelOrderRequest) GetParametersJSON() ([]byte, error) { + params, err := c.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (c *CancelOrderRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (c *CancelOrderRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (c *CancelOrderRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (c *CancelOrderRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (c *CancelOrderRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := c.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (c *CancelOrderRequest) Do(ctx context.Context) (*CancelOrderResponse, error) { + + params, err := c.GetParameters() + if err != nil { + return nil, err + } + query := url.Values{} + + apiURL := "/api/spot/v1/trade/cancel-order-v2" + + req, err := c.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := c.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data CancelOrderResponse + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/bitget/bitgetapi/client.go b/pkg/exchange/bitget/bitgetapi/client.go new file mode 100644 index 0000000..a85b07a --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/client.go @@ -0,0 +1,181 @@ +package bitgetapi + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/c9s/requestgen" + "github.com/pkg/errors" +) + +const defaultHTTPTimeout = time.Second * 15 +const RestBaseURL = "https://api.bitget.com" +const PublicWebSocketURL = "wss://ws.bitget.com/spot/v1/stream" +const PrivateWebSocketURL = "wss://ws.bitget.com/spot/v1/stream" + +type RestClient struct { + requestgen.BaseAPIClient + + key, secret, passphrase string +} + +func NewClient() *RestClient { + u, err := url.Parse(RestBaseURL) + if err != nil { + panic(err) + } + + return &RestClient{ + BaseAPIClient: requestgen.BaseAPIClient{ + BaseURL: u, + HttpClient: &http.Client{ + Timeout: defaultHTTPTimeout, + }, + }, + } +} + +func (c *RestClient) Auth(key, secret, passphrase string) { + c.key = key + c.secret = secret + c.passphrase = passphrase +} + +// newAuthenticatedRequest creates new http request for authenticated routes. +func (c *RestClient) NewAuthenticatedRequest( + ctx context.Context, method, refURL string, params url.Values, payload interface{}, +) (*http.Request, error) { + if len(c.key) == 0 { + return nil, errors.New("empty api key") + } + + if len(c.secret) == 0 { + return nil, errors.New("empty api secret") + } + + rel, err := url.Parse(refURL) + if err != nil { + return nil, err + } + + if params != nil { + rel.RawQuery = params.Encode() + } + + pathURL := c.BaseURL.ResolveReference(rel) + path := pathURL.Path + if rel.RawQuery != "" { + path += "?" + rel.RawQuery + } + + // See https://bitgetlimited.github.io/apidoc/en/spot/#signature + // Sign( + // timestamp + + // method.toUpperCase() + + // requestPath + "?" + queryString + + // body **string + // ) + // (+ means string concat) encrypt by **HMAC SHA256 **algorithm, and encode the encrypted result through **BASE64. + + // set location to UTC so that it outputs "2020-12-08T09:08:57.715Z" + t := time.Now().In(time.UTC) + timestamp := strconv.FormatInt(t.UnixMilli(), 10) + + body, err := castPayload(payload) + if err != nil { + return nil, err + } + + signKey := timestamp + strings.ToUpper(method) + path + string(body) + signature := Sign(signKey, c.secret) + + req, err := http.NewRequestWithContext(ctx, method, pathURL.String(), bytes.NewReader(body)) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Accept", "application/json") + req.Header.Add("ACCESS-KEY", c.key) + req.Header.Add("ACCESS-SIGN", signature) + req.Header.Add("ACCESS-TIMESTAMP", timestamp) + req.Header.Add("ACCESS-PASSPHRASE", c.passphrase) + req.Header.Add("X-CHANNEL-API-CODE", "7575765263") + return req, nil +} + +func Sign(payload string, secret string) string { + var sig = hmac.New(sha256.New, []byte(secret)) + _, err := sig.Write([]byte(payload)) + if err != nil { + return "" + } + + return base64.StdEncoding.EncodeToString(sig.Sum(nil)) +} + +func castPayload(payload interface{}) ([]byte, error) { + if payload == nil { + return nil, nil + } + + switch v := payload.(type) { + case string: + return []byte(v), nil + + case []byte: + return v, nil + + } + return json.Marshal(payload) +} + +/* +sample: + + { + "code": "00000", + "msg": "success", + "data": { + "user_id": "714229403", + "inviter_id": "682221498", + "ips": "172.23.88.91", + "authorities": [ + "trade", + "readonly" + ], + "parentId":"566624801", + "trader":false + } + } +*/ + +type APIResponse struct { + Code string `json:"code"` + Message string `json:"msg"` + Data json.RawMessage `json:"data"` +} + +func (a APIResponse) Validate() error { + // v1, v2 use the same success code. + // https://www.bitget.com/api-doc/spot/error-code/restapi + // https://bitgetlimited.github.io/apidoc/en/mix/#restapi-error-codes + if a.Code != "00000" { + return a.Error() + } + return nil +} + +func (a APIResponse) Error() error { + return fmt.Errorf("code: %s, msg: %s, data: %q", a.Code, a.Message, a.Data) +} diff --git a/pkg/exchange/bitget/bitgetapi/client_test.go b/pkg/exchange/bitget/bitgetapi/client_test.go new file mode 100644 index 0000000..1f6151f --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/client_test.go @@ -0,0 +1,87 @@ +package bitgetapi + +import ( + "context" + "os" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/testutil" +) + +func getTestClientOrSkip(t *testing.T) *RestClient { + if b, _ := strconv.ParseBool(os.Getenv("CI")); b { + t.Skip("skip test for CI") + } + + key, secret, ok := testutil.IntegrationTestConfigured(t, "BITGET") + if !ok { + t.Skip("BITGET_* env vars are not configured") + return nil + } + + client := NewClient() + client.Auth(key, secret, os.Getenv("BITGET_API_PASSPHRASE")) + return client +} + +func TestClient(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + + t.Run("GetAllTickersRequest", func(t *testing.T) { + req := client.NewGetAllTickersRequest() + tickers, err := req.Do(ctx) + assert.NoError(t, err) + t.Logf("tickers: %+v", tickers) + }) + + t.Run("GetSymbolsRequest", func(t *testing.T) { + req := client.NewGetSymbolsRequest() + symbols, err := req.Do(ctx) + assert.NoError(t, err) + t.Logf("symbols: %+v", symbols) + }) + + t.Run("GetTickerRequest", func(t *testing.T) { + req := client.NewGetTickerRequest() + req.Symbol("BTCUSDT_SPBL") + ticker, err := req.Do(ctx) + assert.NoError(t, err) + t.Logf("ticker: %+v", ticker) + }) + + t.Run("GetDepthRequest", func(t *testing.T) { + req := client.NewGetDepthRequest() + req.Symbol("BTCUSDT_SPBL") + depth, err := req.Do(ctx) + assert.NoError(t, err) + t.Logf("depth: %+v", depth) + }) + + t.Run("GetServerTimeRequest", func(t *testing.T) { + req := client.NewGetServerTimeRequest() + serverTime, err := req.Do(ctx) + assert.NoError(t, err) + t.Logf("time: %+v", serverTime) + }) + + t.Run("GetAccountAssetsRequest", func(t *testing.T) { + req := client.NewGetAccountAssetsRequest() + assets, err := req.Do(ctx) + assert.NoError(t, err) + t.Logf("assets: %+v", assets) + }) + + t.Run("GetAccountTransfersRequest", func(t *testing.T) { + req := client.NewGetAccountTransfersRequest() + req.CoinId(1) + req.FromType(AccountExchange) + transfers, err := req.Do(ctx) + + assert.NoError(t, err) + t.Logf("transfers: %+v", transfers) + }) +} diff --git a/pkg/exchange/bitget/bitgetapi/get_account_assets_request.go b/pkg/exchange/bitget/bitgetapi/get_account_assets_request.go new file mode 100644 index 0000000..31f8749 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/get_account_assets_request.go @@ -0,0 +1,29 @@ +package bitgetapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type AccountAsset struct { + CoinId int64 `json:"coinId"` + CoinName string `json:"coinName"` + Available fixedpoint.Value `json:"available"` + Frozen fixedpoint.Value `json:"frozen"` + Lock fixedpoint.Value `json:"lock"` + UTime types.MillisecondTimestamp `json:"uTime"` +} + +//go:generate GetRequest -url "/api/spot/v1/account/assets" -type GetAccountAssetsRequest -responseDataType []AccountAsset +type GetAccountAssetsRequest struct { + client requestgen.AuthenticatedAPIClient +} + +func (c *RestClient) NewGetAccountAssetsRequest() *GetAccountAssetsRequest { + return &GetAccountAssetsRequest{client: c} +} diff --git a/pkg/exchange/bitget/bitgetapi/get_account_assets_request_requestgen.go b/pkg/exchange/bitget/bitgetapi/get_account_assets_request_requestgen.go new file mode 100644 index 0000000..d6c989b --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/get_account_assets_request_requestgen.go @@ -0,0 +1,139 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/spot/v1/account/assets -type GetAccountAssetsRequest -responseDataType []AccountAsset"; DO NOT EDIT. + +package bitgetapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetAccountAssetsRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetAccountAssetsRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetAccountAssetsRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetAccountAssetsRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetAccountAssetsRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetAccountAssetsRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetAccountAssetsRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetAccountAssetsRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetAccountAssetsRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetAccountAssetsRequest) Do(ctx context.Context) ([]AccountAsset, error) { + + // no body params + var params interface{} + query := url.Values{} + + apiURL := "/api/spot/v1/account/assets" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data []AccountAsset + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/bitget/bitgetapi/get_account_request.go b/pkg/exchange/bitget/bitgetapi/get_account_request.go new file mode 100644 index 0000000..d418371 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/get_account_request.go @@ -0,0 +1,28 @@ +package bitgetapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type Account struct { + UserId types.StrInt64 `json:"user_id"` + InviterId types.StrInt64 `json:"inviter_id"` + Ips string `json:"ips"` + Authorities []string `json:"authorities"` + ParentId types.StrInt64 `json:"parentId"` + Trader bool `json:"trader"` +} + +//go:generate GetRequest -url "/api/spot/v1/account/getInfo" -type GetAccountRequest -responseDataType .Account +type GetAccountRequest struct { + client requestgen.AuthenticatedAPIClient +} + +func (c *RestClient) NewGetAccountRequest() *GetAccountRequest { + return &GetAccountRequest{client: c} +} diff --git a/pkg/exchange/bitget/bitgetapi/get_account_request_requestgen.go b/pkg/exchange/bitget/bitgetapi/get_account_request_requestgen.go new file mode 100644 index 0000000..a94390d --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/get_account_request_requestgen.go @@ -0,0 +1,139 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/spot/v1/account/getInfo -type GetAccountRequest -responseDataType .Account"; DO NOT EDIT. + +package bitgetapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetAccountRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetAccountRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetAccountRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetAccountRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetAccountRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetAccountRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetAccountRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetAccountRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetAccountRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetAccountRequest) Do(ctx context.Context) (*Account, error) { + + // no body params + var params interface{} + query := url.Values{} + + apiURL := "/api/spot/v1/account/getInfo" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data Account + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/bitget/bitgetapi/get_account_transfers_request.go b/pkg/exchange/bitget/bitgetapi/get_account_transfers_request.go new file mode 100644 index 0000000..dcd0889 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/get_account_transfers_request.go @@ -0,0 +1,44 @@ +package bitgetapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type AccountType string + +const ( + AccountExchange AccountType = "EXCHANGE" + AccountContract AccountType = "CONTRACT" +) + +type Transfer struct { + CTime types.MillisecondTimestamp `json:"cTime"` + CoinId string `json:"coinId"` + CoinName string `json:"coinName"` + GroupType string `json:"groupType"` + BizType string `json:"bizType"` + Quantity fixedpoint.Value `json:"quantity"` + Balance fixedpoint.Value `json:"balance"` + Fees fixedpoint.Value `json:"fees"` + BillId string `json:"billId"` +} + +//go:generate GetRequest -url "/api/spot/v1/account/transferRecords" -type GetAccountTransfersRequest -responseDataType []Transfer +type GetAccountTransfersRequest struct { + client requestgen.AuthenticatedAPIClient + + coinId int `param:"coinId"` + fromType AccountType `param:"fromType"` + after string `param:"after"` + before string `param:"before"` +} + +func (c *RestClient) NewGetAccountTransfersRequest() *GetAccountTransfersRequest { + return &GetAccountTransfersRequest{client: c} +} diff --git a/pkg/exchange/bitget/bitgetapi/get_account_transfers_request_requestgen.go b/pkg/exchange/bitget/bitgetapi/get_account_transfers_request_requestgen.go new file mode 100644 index 0000000..6757b59 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/get_account_transfers_request_requestgen.go @@ -0,0 +1,193 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/spot/v1/account/transferRecords -type GetAccountTransfersRequest -responseDataType []Transfer"; DO NOT EDIT. + +package bitgetapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (g *GetAccountTransfersRequest) CoinId(coinId int) *GetAccountTransfersRequest { + g.coinId = coinId + return g +} + +func (g *GetAccountTransfersRequest) FromType(fromType AccountType) *GetAccountTransfersRequest { + g.fromType = fromType + return g +} + +func (g *GetAccountTransfersRequest) After(after string) *GetAccountTransfersRequest { + g.after = after + return g +} + +func (g *GetAccountTransfersRequest) Before(before string) *GetAccountTransfersRequest { + g.before = before + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetAccountTransfersRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetAccountTransfersRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check coinId field -> json key coinId + coinId := g.coinId + + // assign parameter of coinId + params["coinId"] = coinId + // check fromType field -> json key fromType + fromType := g.fromType + + // TEMPLATE check-valid-values + switch fromType { + case AccountExchange, AccountContract: + params["fromType"] = fromType + + default: + return nil, fmt.Errorf("fromType value %v is invalid", fromType) + + } + // END TEMPLATE check-valid-values + + // assign parameter of fromType + params["fromType"] = fromType + // check after field -> json key after + after := g.after + + // assign parameter of after + params["after"] = after + // check before field -> json key before + before := g.before + + // assign parameter of before + params["before"] = before + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetAccountTransfersRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetAccountTransfersRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetAccountTransfersRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetAccountTransfersRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetAccountTransfersRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetAccountTransfersRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetAccountTransfersRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetAccountTransfersRequest) Do(ctx context.Context) ([]Transfer, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/api/spot/v1/account/transferRecords" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data []Transfer + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/bitget/bitgetapi/get_all_tickers_request.go b/pkg/exchange/bitget/bitgetapi/get_all_tickers_request.go new file mode 100644 index 0000000..c75d2b2 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/get_all_tickers_request.go @@ -0,0 +1,17 @@ +package bitgetapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +import ( + "github.com/c9s/requestgen" +) + +//go:generate GetRequest -url "/api/spot/v1/market/tickers" -type GetAllTickersRequest -responseDataType []Ticker +type GetAllTickersRequest struct { + client requestgen.APIClient +} + +func (c *RestClient) NewGetAllTickersRequest() *GetAllTickersRequest { + return &GetAllTickersRequest{client: c} +} diff --git a/pkg/exchange/bitget/bitgetapi/get_all_tickers_request_requestgen.go b/pkg/exchange/bitget/bitgetapi/get_all_tickers_request_requestgen.go new file mode 100644 index 0000000..a1c94e0 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/get_all_tickers_request_requestgen.go @@ -0,0 +1,139 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/spot/v1/market/tickers -type GetAllTickersRequest -responseDataType []Ticker"; DO NOT EDIT. + +package bitgetapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetAllTickersRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetAllTickersRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetAllTickersRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetAllTickersRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetAllTickersRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetAllTickersRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetAllTickersRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetAllTickersRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetAllTickersRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetAllTickersRequest) Do(ctx context.Context) ([]Ticker, error) { + + // no body params + var params interface{} + query := url.Values{} + + apiURL := "/api/spot/v1/market/tickers" + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data []Ticker + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/bitget/bitgetapi/get_candles_request.go b/pkg/exchange/bitget/bitgetapi/get_candles_request.go new file mode 100644 index 0000000..2b718ba --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/get_candles_request.go @@ -0,0 +1,31 @@ +package bitgetapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type Candle struct { + Open fixedpoint.Value `json:"open"` + High fixedpoint.Value `json:"high"` + Low fixedpoint.Value `json:"low"` + Close fixedpoint.Value `json:"close"` + QuoteVol fixedpoint.Value `json:"quoteVol"` + BaseVol fixedpoint.Value `json:"baseVol"` + UsdtVol fixedpoint.Value `json:"usdtVol"` + Ts types.MillisecondTimestamp `json:"ts"` +} + +//go:generate GetRequest -url "/api/spot/v1/market/candles" -type GetCandlesRequest -responseDataType []Candle +type GetCandlesRequest struct { + client requestgen.APIClient +} + +func (c *RestClient) NewGetCandlesRequest() *GetCandlesRequest { + return &GetCandlesRequest{client: c} +} diff --git a/pkg/exchange/bitget/bitgetapi/get_candles_request_requestgen.go b/pkg/exchange/bitget/bitgetapi/get_candles_request_requestgen.go new file mode 100644 index 0000000..cacc444 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/get_candles_request_requestgen.go @@ -0,0 +1,139 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/spot/v1/market/candles -type GetCandlesRequest -responseDataType []Candle"; DO NOT EDIT. + +package bitgetapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetCandlesRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetCandlesRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetCandlesRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetCandlesRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetCandlesRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetCandlesRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetCandlesRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetCandlesRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetCandlesRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetCandlesRequest) Do(ctx context.Context) ([]Candle, error) { + + // no body params + var params interface{} + query := url.Values{} + + apiURL := "/api/spot/v1/market/candles" + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data []Candle + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/bitget/bitgetapi/get_depth_request.go b/pkg/exchange/bitget/bitgetapi/get_depth_request.go new file mode 100644 index 0000000..3997f9f --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/get_depth_request.go @@ -0,0 +1,30 @@ +package bitgetapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type Depth struct { + Asks [][]fixedpoint.Value `json:"asks"` + Bids [][]fixedpoint.Value `json:"bids"` + Timestamp types.MillisecondTimestamp `json:"timestamp"` +} + +//go:generate GetRequest -url "/api/spot/v1/market/depth" -type GetDepthRequest -responseDataType .Depth +type GetDepthRequest struct { + client requestgen.APIClient + + symbol string `param:"symbol"` + stepType string `param:"type" default:"step0"` + limit *int `param:"limit"` +} + +func (c *RestClient) NewGetDepthRequest() *GetDepthRequest { + return &GetDepthRequest{client: c} +} diff --git a/pkg/exchange/bitget/bitgetapi/get_depth_request_requestgen.go b/pkg/exchange/bitget/bitgetapi/get_depth_request_requestgen.go new file mode 100644 index 0000000..8f54091 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/get_depth_request_requestgen.go @@ -0,0 +1,175 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/spot/v1/market/depth -type GetDepthRequest -responseDataType .Depth"; DO NOT EDIT. + +package bitgetapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (g *GetDepthRequest) Symbol(symbol string) *GetDepthRequest { + g.symbol = symbol + return g +} + +func (g *GetDepthRequest) StepType(stepType string) *GetDepthRequest { + g.stepType = stepType + return g +} + +func (g *GetDepthRequest) Limit(limit int) *GetDepthRequest { + g.limit = &limit + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetDepthRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetDepthRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := g.symbol + + // assign parameter of symbol + params["symbol"] = symbol + // check stepType field -> json key type + stepType := g.stepType + + // assign parameter of stepType + params["type"] = stepType + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetDepthRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetDepthRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetDepthRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetDepthRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetDepthRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetDepthRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetDepthRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetDepthRequest) Do(ctx context.Context) (*Depth, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/api/spot/v1/market/depth" + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data Depth + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/bitget/bitgetapi/get_fills_request.go b/pkg/exchange/bitget/bitgetapi/get_fills_request.go new file mode 100644 index 0000000..240c010 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/get_fills_request.go @@ -0,0 +1,43 @@ +package bitgetapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type Fill struct { + AccountId types.StrInt64 `json:"accountId"` + Symbol string `json:"symbol"` + OrderId types.StrInt64 `json:"orderId"` + FillId types.StrInt64 `json:"fillId"` + OrderType OrderType `json:"orderType"` + Side OrderSide `json:"side"` + FillPrice fixedpoint.Value `json:"fillPrice"` + FillQuantity fixedpoint.Value `json:"fillQuantity"` + FillTotalAmount fixedpoint.Value `json:"fillTotalAmount"` + CreationTime types.MillisecondTimestamp `json:"cTime"` + FeeCurrency string `json:"feeCcy"` + Fees fixedpoint.Value `json:"fees"` +} + +//go:generate GetRequest -url "/api/spot/v1/trade/fills" -type GetFillsRequest -responseDataType .ServerTime +type GetFillsRequest struct { + client requestgen.AuthenticatedAPIClient + + // after - order id + after *string `param:"after"` + + // before - order id + before *string `param:"before"` + + limit *string `param:"limit"` +} + +func (c *RestClient) NewGetFillsRequest() *GetFillsRequest { + return &GetFillsRequest{client: c} +} diff --git a/pkg/exchange/bitget/bitgetapi/get_fills_request_requestgen.go b/pkg/exchange/bitget/bitgetapi/get_fills_request_requestgen.go new file mode 100644 index 0000000..76a4f1c --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/get_fills_request_requestgen.go @@ -0,0 +1,182 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/spot/v1/trade/fills -type GetFillsRequest -responseDataType .ServerTime"; DO NOT EDIT. + +package bitgetapi + +import ( + "context" + "encoding/json" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "net/url" + "reflect" + "regexp" +) + +func (g *GetFillsRequest) After(after string) *GetFillsRequest { + g.after = &after + return g +} + +func (g *GetFillsRequest) Before(before string) *GetFillsRequest { + g.before = &before + return g +} + +func (g *GetFillsRequest) Limit(limit string) *GetFillsRequest { + g.limit = &limit + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetFillsRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetFillsRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check after field -> json key after + if g.after != nil { + after := *g.after + + // assign parameter of after + params["after"] = after + } else { + } + // check before field -> json key before + if g.before != nil { + before := *g.before + + // assign parameter of before + params["before"] = before + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetFillsRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetFillsRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetFillsRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetFillsRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetFillsRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetFillsRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetFillsRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetFillsRequest) Do(ctx context.Context) (*types.MillisecondTimestamp, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/api/spot/v1/trade/fills" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data types.MillisecondTimestamp + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/bitget/bitgetapi/get_open_orders_request.go b/pkg/exchange/bitget/bitgetapi/get_open_orders_request.go new file mode 100644 index 0000000..24074f7 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/get_open_orders_request.go @@ -0,0 +1,19 @@ +package bitgetapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +import ( + "github.com/c9s/requestgen" +) + +//go:generate GetRequest -url "/api/spot/v1/trade/open-orders" -type GetOpenOrdersRequest -responseDataType []OrderDetail +type GetOpenOrdersRequest struct { + client requestgen.AuthenticatedAPIClient + + symbol string `param:"symbol"` +} + +func (c *RestClient) NewGetOpenOrdersRequest() *GetOpenOrdersRequest { + return &GetOpenOrdersRequest{client: c} +} diff --git a/pkg/exchange/bitget/bitgetapi/get_open_orders_request_requestgen.go b/pkg/exchange/bitget/bitgetapi/get_open_orders_request_requestgen.go new file mode 100644 index 0000000..e5617a4 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/get_open_orders_request_requestgen.go @@ -0,0 +1,152 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/spot/v1/trade/open-orders -type GetOpenOrdersRequest -responseDataType []OrderDetail"; DO NOT EDIT. + +package bitgetapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (g *GetOpenOrdersRequest) Symbol(symbol string) *GetOpenOrdersRequest { + g.symbol = symbol + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetOpenOrdersRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetOpenOrdersRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := g.symbol + + // assign parameter of symbol + params["symbol"] = symbol + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetOpenOrdersRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetOpenOrdersRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetOpenOrdersRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetOpenOrdersRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetOpenOrdersRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetOpenOrdersRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetOpenOrdersRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetOpenOrdersRequest) Do(ctx context.Context) ([]OrderDetail, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/api/spot/v1/trade/open-orders" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data []OrderDetail + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/bitget/bitgetapi/get_order_detail_request.go b/pkg/exchange/bitget/bitgetapi/get_order_detail_request.go new file mode 100644 index 0000000..4af581f --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/get_order_detail_request.go @@ -0,0 +1,41 @@ +package bitgetapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type OrderDetail struct { + AccountId types.StrInt64 `json:"accountId"` + Symbol string `json:"symbol"` + OrderId types.StrInt64 `json:"orderId"` + ClientOrderId string `json:"clientOrderId"` + Price fixedpoint.Value `json:"price"` + Quantity fixedpoint.Value `json:"quantity"` + OrderType OrderType `json:"orderType"` + Side OrderSide `json:"side"` + Status OrderStatus `json:"status"` + FillPrice fixedpoint.Value `json:"fillPrice"` + FillQuantity fixedpoint.Value `json:"fillQuantity"` + FillTotalAmount fixedpoint.Value `json:"fillTotalAmount"` + EnterPointSource string `json:"enterPointSource"` + CTime types.MillisecondTimestamp `json:"cTime"` +} + +//go:generate PostRequest -url "/api/spot/v1/trade/orderInfo" -type GetOrderDetailRequest -responseDataType []OrderDetail +type GetOrderDetailRequest struct { + client requestgen.AuthenticatedAPIClient + + symbol string `param:"symbol"` + orderId *string `param:"orderId"` + clientOrderId *string `param:"clientOid"` +} + +func (c *RestClient) NewGetOrderDetailRequest() *GetOrderDetailRequest { + return &GetOrderDetailRequest{client: c} +} diff --git a/pkg/exchange/bitget/bitgetapi/get_order_detail_request_requestgen.go b/pkg/exchange/bitget/bitgetapi/get_order_detail_request_requestgen.go new file mode 100644 index 0000000..d048ae5 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/get_order_detail_request_requestgen.go @@ -0,0 +1,177 @@ +// Code generated by "requestgen -method POST -responseType .APIResponse -responseDataField Data -url /api/spot/v1/trade/orderInfo -type GetOrderDetailRequest -responseDataType []OrderDetail"; DO NOT EDIT. + +package bitgetapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (g *GetOrderDetailRequest) Symbol(symbol string) *GetOrderDetailRequest { + g.symbol = symbol + return g +} + +func (g *GetOrderDetailRequest) OrderId(orderId string) *GetOrderDetailRequest { + g.orderId = &orderId + return g +} + +func (g *GetOrderDetailRequest) ClientOrderId(clientOrderId string) *GetOrderDetailRequest { + g.clientOrderId = &clientOrderId + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetOrderDetailRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetOrderDetailRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := g.symbol + + // assign parameter of symbol + params["symbol"] = symbol + // check orderId field -> json key orderId + if g.orderId != nil { + orderId := *g.orderId + + // assign parameter of orderId + params["orderId"] = orderId + } else { + } + // check clientOrderId field -> json key clientOid + if g.clientOrderId != nil { + clientOrderId := *g.clientOrderId + + // assign parameter of clientOrderId + params["clientOid"] = clientOrderId + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetOrderDetailRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetOrderDetailRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetOrderDetailRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetOrderDetailRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetOrderDetailRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetOrderDetailRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetOrderDetailRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetOrderDetailRequest) Do(ctx context.Context) ([]OrderDetail, error) { + + params, err := g.GetParameters() + if err != nil { + return nil, err + } + query := url.Values{} + + apiURL := "/api/spot/v1/trade/orderInfo" + + req, err := g.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data []OrderDetail + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/bitget/bitgetapi/get_order_history_request.go b/pkg/exchange/bitget/bitgetapi/get_order_history_request.go new file mode 100644 index 0000000..6de083d --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/get_order_history_request.go @@ -0,0 +1,27 @@ +package bitgetapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +import ( + "github.com/c9s/requestgen" +) + +//go:generate GetRequest -url "/api/spot/v1/trade/history" -type GetOrderHistoryRequest -responseDataType []OrderDetail +type GetOrderHistoryRequest struct { + client requestgen.AuthenticatedAPIClient + + symbol string `param:"symbol"` + + // after - order id + after *string `param:"after"` + + // before - order id + before *string `param:"before"` + + limit *string `param:"limit"` +} + +func (c *RestClient) NewGetOrderHistoryRequest() *GetOrderHistoryRequest { + return &GetOrderHistoryRequest{client: c} +} diff --git a/pkg/exchange/bitget/bitgetapi/get_order_history_request_requestgen.go b/pkg/exchange/bitget/bitgetapi/get_order_history_request_requestgen.go new file mode 100644 index 0000000..257aef2 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/get_order_history_request_requestgen.go @@ -0,0 +1,191 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/spot/v1/trade/history -type GetOrderHistoryRequest -responseDataType []OrderDetail"; DO NOT EDIT. + +package bitgetapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (g *GetOrderHistoryRequest) Symbol(symbol string) *GetOrderHistoryRequest { + g.symbol = symbol + return g +} + +func (g *GetOrderHistoryRequest) After(after string) *GetOrderHistoryRequest { + g.after = &after + return g +} + +func (g *GetOrderHistoryRequest) Before(before string) *GetOrderHistoryRequest { + g.before = &before + return g +} + +func (g *GetOrderHistoryRequest) Limit(limit string) *GetOrderHistoryRequest { + g.limit = &limit + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetOrderHistoryRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetOrderHistoryRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := g.symbol + + // assign parameter of symbol + params["symbol"] = symbol + // check after field -> json key after + if g.after != nil { + after := *g.after + + // assign parameter of after + params["after"] = after + } else { + } + // check before field -> json key before + if g.before != nil { + before := *g.before + + // assign parameter of before + params["before"] = before + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetOrderHistoryRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetOrderHistoryRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetOrderHistoryRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetOrderHistoryRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetOrderHistoryRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetOrderHistoryRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetOrderHistoryRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetOrderHistoryRequest) Do(ctx context.Context) ([]OrderDetail, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/api/spot/v1/trade/history" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data []OrderDetail + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/bitget/bitgetapi/get_server_time_request.go b/pkg/exchange/bitget/bitgetapi/get_server_time_request.go new file mode 100644 index 0000000..e9566ae --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/get_server_time_request.go @@ -0,0 +1,21 @@ +package bitgetapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type ServerTime = types.MillisecondTimestamp + +//go:generate GetRequest -url "/api/spot/v1/public/time" -type GetServerTimeRequest -responseDataType .ServerTime +type GetServerTimeRequest struct { + client requestgen.APIClient +} + +func (c *RestClient) NewGetServerTimeRequest() *GetServerTimeRequest { + return &GetServerTimeRequest{client: c} +} diff --git a/pkg/exchange/bitget/bitgetapi/get_server_time_request_requestgen.go b/pkg/exchange/bitget/bitgetapi/get_server_time_request_requestgen.go new file mode 100644 index 0000000..b01d5b4 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/get_server_time_request_requestgen.go @@ -0,0 +1,140 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/spot/v1/public/time -type GetServerTimeRequest -responseDataType .ServerTime"; DO NOT EDIT. + +package bitgetapi + +import ( + "context" + "encoding/json" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "net/url" + "reflect" + "regexp" +) + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetServerTimeRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetServerTimeRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetServerTimeRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetServerTimeRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetServerTimeRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetServerTimeRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetServerTimeRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetServerTimeRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetServerTimeRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetServerTimeRequest) Do(ctx context.Context) (*types.MillisecondTimestamp, error) { + + // no body params + var params interface{} + query := url.Values{} + + apiURL := "/api/spot/v1/public/time" + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data types.MillisecondTimestamp + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/bitget/bitgetapi/get_symbols_request.go b/pkg/exchange/bitget/bitgetapi/get_symbols_request.go new file mode 100644 index 0000000..9675ffb --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/get_symbols_request.go @@ -0,0 +1,47 @@ +package bitgetapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +type SymbolStatus string + +const ( + // SymbolOffline represent market is suspended, users cannot trade. + SymbolOffline SymbolStatus = "offline" + // SymbolGray represents market is online, but user trading is not available. + SymbolGray SymbolStatus = "gray" + // SymbolOnline trading begins, users can trade. + SymbolOnline SymbolStatus = "online" +) + +type Symbol struct { + Symbol string `json:"symbol"` + SymbolName string `json:"symbolName"` + BaseCoin string `json:"baseCoin"` + QuoteCoin string `json:"quoteCoin"` + MinTradeAmount fixedpoint.Value `json:"minTradeAmount"` + MaxTradeAmount fixedpoint.Value `json:"maxTradeAmount"` + TakerFeeRate fixedpoint.Value `json:"takerFeeRate"` + MakerFeeRate fixedpoint.Value `json:"makerFeeRate"` + PriceScale fixedpoint.Value `json:"priceScale"` + QuantityScale fixedpoint.Value `json:"quantityScale"` + MinTradeUSDT fixedpoint.Value `json:"minTradeUSDT"` + Status SymbolStatus `json:"status"` + BuyLimitPriceRatio fixedpoint.Value `json:"buyLimitPriceRatio"` + SellLimitPriceRatio fixedpoint.Value `json:"sellLimitPriceRatio"` +} + +//go:generate GetRequest -url "/api/spot/v1/public/products" -type GetSymbolsRequest -responseDataType []Symbol +type GetSymbolsRequest struct { + client requestgen.APIClient +} + +func (c *RestClient) NewGetSymbolsRequest() *GetSymbolsRequest { + return &GetSymbolsRequest{client: c} +} diff --git a/pkg/exchange/bitget/bitgetapi/get_symbols_request_requestgen.go b/pkg/exchange/bitget/bitgetapi/get_symbols_request_requestgen.go new file mode 100644 index 0000000..7539542 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/get_symbols_request_requestgen.go @@ -0,0 +1,139 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/spot/v1/public/products -type GetSymbolsRequest -responseDataType []Symbol"; DO NOT EDIT. + +package bitgetapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetSymbolsRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetSymbolsRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetSymbolsRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetSymbolsRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetSymbolsRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetSymbolsRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetSymbolsRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetSymbolsRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetSymbolsRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetSymbolsRequest) Do(ctx context.Context) ([]Symbol, error) { + + // no body params + var params interface{} + query := url.Values{} + + apiURL := "/api/spot/v1/public/products" + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data []Symbol + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/bitget/bitgetapi/get_ticker_request.go b/pkg/exchange/bitget/bitgetapi/get_ticker_request.go new file mode 100644 index 0000000..0e34d29 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/get_ticker_request.go @@ -0,0 +1,40 @@ +package bitgetapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type Ticker struct { + Symbol string `json:"symbol"` + High24H fixedpoint.Value `json:"high24h"` + Low24H fixedpoint.Value `json:"low24h"` + Close fixedpoint.Value `json:"close"` + QuoteVol fixedpoint.Value `json:"quoteVol"` + BaseVol fixedpoint.Value `json:"baseVol"` + UsdtVol fixedpoint.Value `json:"usdtVol"` + Ts types.MillisecondTimestamp `json:"ts"` + BuyOne fixedpoint.Value `json:"buyOne"` + SellOne fixedpoint.Value `json:"sellOne"` + BidSz fixedpoint.Value `json:"bidSz"` + AskSz fixedpoint.Value `json:"askSz"` + OpenUtc0 fixedpoint.Value `json:"openUtc0"` + ChangeUtc fixedpoint.Value `json:"changeUtc"` + Change fixedpoint.Value `json:"change"` +} + +//go:generate GetRequest -url "/api/spot/v1/market/ticker" -type GetTickerRequest -responseDataType .Ticker +type GetTickerRequest struct { + client requestgen.APIClient + + symbol string `param:"symbol"` +} + +func (c *RestClient) NewGetTickerRequest() *GetTickerRequest { + return &GetTickerRequest{client: c} +} diff --git a/pkg/exchange/bitget/bitgetapi/get_ticker_request_requestgen.go b/pkg/exchange/bitget/bitgetapi/get_ticker_request_requestgen.go new file mode 100644 index 0000000..bfb10dd --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/get_ticker_request_requestgen.go @@ -0,0 +1,152 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/spot/v1/market/ticker -type GetTickerRequest -responseDataType .Ticker"; DO NOT EDIT. + +package bitgetapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (g *GetTickerRequest) Symbol(symbol string) *GetTickerRequest { + g.symbol = symbol + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetTickerRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetTickerRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := g.symbol + + // assign parameter of symbol + params["symbol"] = symbol + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetTickerRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetTickerRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetTickerRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetTickerRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetTickerRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetTickerRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetTickerRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetTickerRequest) Do(ctx context.Context) (*Ticker, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/api/spot/v1/market/ticker" + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data Ticker + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/bitget/bitgetapi/place_order_request.go b/pkg/exchange/bitget/bitgetapi/place_order_request.go new file mode 100644 index 0000000..10266ce --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/place_order_request.go @@ -0,0 +1,29 @@ +package bitgetapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +import ( + "github.com/c9s/requestgen" +) + +type OrderResponse struct { + OrderId string `json:"orderId"` + ClientOrderId string `json:"clientOrderId"` +} + +//go:generate PostRequest -url "/api/spot/v1/trade/orders" -type PlaceOrderRequest -responseDataType .OrderResponse +type PlaceOrderRequest struct { + client requestgen.AuthenticatedAPIClient + symbol string `param:"symbol"` + orderType OrderType `param:"orderType"` + side OrderSide `param:"side"` + force OrderForce `param:"force"` + price string `param:"price"` + quantity string `param:"quantity"` + clientOrderId *string `param:"clientOrderId"` +} + +func (c *RestClient) NewPlaceOrderRequest() *PlaceOrderRequest { + return &PlaceOrderRequest{client: c} +} diff --git a/pkg/exchange/bitget/bitgetapi/place_order_request_requestgen.go b/pkg/exchange/bitget/bitgetapi/place_order_request_requestgen.go new file mode 100644 index 0000000..9ce8799 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/place_order_request_requestgen.go @@ -0,0 +1,247 @@ +// Code generated by "requestgen -method POST -responseType .APIResponse -responseDataField Data -url /api/spot/v1/trade/orders -type PlaceOrderRequest -responseDataType .OrderResponse"; DO NOT EDIT. + +package bitgetapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (p *PlaceOrderRequest) Symbol(symbol string) *PlaceOrderRequest { + p.symbol = symbol + return p +} + +func (p *PlaceOrderRequest) OrderType(orderType OrderType) *PlaceOrderRequest { + p.orderType = orderType + return p +} + +func (p *PlaceOrderRequest) Side(side OrderSide) *PlaceOrderRequest { + p.side = side + return p +} + +func (p *PlaceOrderRequest) Force(force OrderForce) *PlaceOrderRequest { + p.force = force + return p +} + +func (p *PlaceOrderRequest) Price(price string) *PlaceOrderRequest { + p.price = price + return p +} + +func (p *PlaceOrderRequest) Quantity(quantity string) *PlaceOrderRequest { + p.quantity = quantity + return p +} + +func (p *PlaceOrderRequest) ClientOrderId(clientOrderId string) *PlaceOrderRequest { + p.clientOrderId = &clientOrderId + return p +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (p *PlaceOrderRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (p *PlaceOrderRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := p.symbol + + // assign parameter of symbol + params["symbol"] = symbol + // check orderType field -> json key orderType + orderType := p.orderType + + // TEMPLATE check-valid-values + switch orderType { + case OrderTypeLimit, OrderTypeMarket: + params["orderType"] = orderType + + default: + return nil, fmt.Errorf("orderType value %v is invalid", orderType) + + } + // END TEMPLATE check-valid-values + + // assign parameter of orderType + params["orderType"] = orderType + // check side field -> json key side + side := p.side + + // TEMPLATE check-valid-values + switch side { + case OrderSideBuy, OrderSideSell: + params["side"] = side + + default: + return nil, fmt.Errorf("side value %v is invalid", side) + + } + // END TEMPLATE check-valid-values + + // assign parameter of side + params["side"] = side + // check force field -> json key force + force := p.force + + // TEMPLATE check-valid-values + switch force { + case OrderForceGTC, OrderForcePostOnly, OrderForceFOK, OrderForceIOC: + params["force"] = force + + default: + return nil, fmt.Errorf("force value %v is invalid", force) + + } + // END TEMPLATE check-valid-values + + // assign parameter of force + params["force"] = force + // check price field -> json key price + price := p.price + + // assign parameter of price + params["price"] = price + // check quantity field -> json key quantity + quantity := p.quantity + + // assign parameter of quantity + params["quantity"] = quantity + // check clientOrderId field -> json key clientOrderId + if p.clientOrderId != nil { + clientOrderId := *p.clientOrderId + + // assign parameter of clientOrderId + params["clientOrderId"] = clientOrderId + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (p *PlaceOrderRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := p.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if p.isVarSlice(_v) { + p.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (p *PlaceOrderRequest) GetParametersJSON() ([]byte, error) { + params, err := p.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (p *PlaceOrderRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (p *PlaceOrderRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (p *PlaceOrderRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (p *PlaceOrderRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (p *PlaceOrderRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := p.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (p *PlaceOrderRequest) Do(ctx context.Context) (*OrderResponse, error) { + + params, err := p.GetParameters() + if err != nil { + return nil, err + } + query := url.Values{} + + apiURL := "/api/spot/v1/trade/orders" + + req, err := p.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := p.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data OrderResponse + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/bitget/bitgetapi/types.go b/pkg/exchange/bitget/bitgetapi/types.go new file mode 100644 index 0000000..1aa6280 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/types.go @@ -0,0 +1,41 @@ +package bitgetapi + +type SideType string + +const ( + SideTypeBuy SideType = "buy" + SideTypeSell SideType = "sell" +) + +type OrderType string + +const ( + OrderTypeLimit OrderType = "limit" + OrderTypeMarket OrderType = "market" +) + +type OrderSide string + +const ( + OrderSideBuy OrderSide = "buy" + OrderSideSell OrderSide = "sell" +) + +type OrderForce string + +const ( + OrderForceGTC OrderForce = "normal" + OrderForcePostOnly OrderForce = "post_only" + OrderForceFOK OrderForce = "fok" + OrderForceIOC OrderForce = "ioc" +) + +type OrderStatus string + +const ( + OrderStatusInit OrderStatus = "init" + OrderStatusNew OrderStatus = "new" + OrderStatusPartialFill OrderStatus = "partial_fill" + OrderStatusFullFill OrderStatus = "full_fill" + OrderStatusCancelled OrderStatus = "cancelled" +) diff --git a/pkg/exchange/bitget/bitgetapi/v2/cancel_order_request.go b/pkg/exchange/bitget/bitgetapi/v2/cancel_order_request.go new file mode 100644 index 0000000..85d7c06 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/cancel_order_request.go @@ -0,0 +1,29 @@ +package bitgetapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type CancelOrder struct { + // OrderId are always numeric. It's confirmed with official customer service. https://t.me/bitgetOpenapi/24172 + OrderId types.StrInt64 `json:"orderId"` + ClientOrderId string `json:"clientOid"` +} + +//go:generate PostRequest -url "/api/v2/spot/trade/cancel-order" -type CancelOrderRequest -responseDataType .CancelOrder +type CancelOrderRequest struct { + client requestgen.AuthenticatedAPIClient + + symbol string `param:"symbol"` + orderId *string `param:"orderId"` + clientOrderId *string `param:"clientOid"` +} + +func (c *Client) NewCancelOrderRequest() *CancelOrderRequest { + return &CancelOrderRequest{client: c.Client} +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/cancel_order_request_requestgen.go b/pkg/exchange/bitget/bitgetapi/v2/cancel_order_request_requestgen.go new file mode 100644 index 0000000..4571cbc --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/cancel_order_request_requestgen.go @@ -0,0 +1,196 @@ +// Code generated by "requestgen -method POST -responseType .APIResponse -responseDataField Data -url /api/v2/spot/trade/cancel-order -type CancelOrderRequest -responseDataType .CancelOrder"; DO NOT EDIT. + +package bitgetapi + +import ( + "context" + "encoding/json" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bitget/bitgetapi" + "net/url" + "reflect" + "regexp" +) + +func (c *CancelOrderRequest) Symbol(symbol string) *CancelOrderRequest { + c.symbol = symbol + return c +} + +func (c *CancelOrderRequest) OrderId(orderId string) *CancelOrderRequest { + c.orderId = &orderId + return c +} + +func (c *CancelOrderRequest) ClientOrderId(clientOrderId string) *CancelOrderRequest { + c.clientOrderId = &clientOrderId + return c +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (c *CancelOrderRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (c *CancelOrderRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := c.symbol + + // assign parameter of symbol + params["symbol"] = symbol + // check orderId field -> json key orderId + if c.orderId != nil { + orderId := *c.orderId + + // assign parameter of orderId + params["orderId"] = orderId + } else { + } + // check clientOrderId field -> json key clientOid + if c.clientOrderId != nil { + clientOrderId := *c.clientOrderId + + // assign parameter of clientOrderId + params["clientOid"] = clientOrderId + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (c *CancelOrderRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := c.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if c.isVarSlice(_v) { + c.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (c *CancelOrderRequest) GetParametersJSON() ([]byte, error) { + params, err := c.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (c *CancelOrderRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (c *CancelOrderRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (c *CancelOrderRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (c *CancelOrderRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (c *CancelOrderRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := c.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (c *CancelOrderRequest) GetPath() string { + return "/api/v2/spot/trade/cancel-order" +} + +// Do generates the request object and send the request object to the API endpoint +func (c *CancelOrderRequest) Do(ctx context.Context) (*CancelOrder, error) { + + params, err := c.GetParameters() + if err != nil { + return nil, err + } + query := url.Values{} + + var apiURL string + + apiURL = c.GetPath() + + req, err := c.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := c.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse bitgetapi.APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data CancelOrder + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/client.go b/pkg/exchange/bitget/bitgetapi/v2/client.go new file mode 100644 index 0000000..52bf42f --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/client.go @@ -0,0 +1,21 @@ +package bitgetapi + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bitget/bitgetapi" +) + +const ( + PrivateWebSocketURL = "wss://ws.bitget.com/v2/ws/private" +) + +type APIResponse = bitgetapi.APIResponse + +type Client struct { + Client requestgen.AuthenticatedAPIClient +} + +func NewClient(client *bitgetapi.RestClient) *Client { + return &Client{Client: client} +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/client_test.go b/pkg/exchange/bitget/bitgetapi/v2/client_test.go new file mode 100644 index 0000000..cbcc02f --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/client_test.go @@ -0,0 +1,110 @@ +package bitgetapi + +import ( + "context" + "os" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bitget/bitgetapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/testutil" +) + +func getTestClientOrSkip(t *testing.T) *Client { + if b, _ := strconv.ParseBool(os.Getenv("CI")); b { + t.Skip("skip test for CI") + } + + key, secret, ok := testutil.IntegrationTestConfigured(t, "BITGET") + if !ok { + t.Skip("BITGET_* env vars are not configured") + return nil + } + + client := bitgetapi.NewClient() + client.Auth(key, secret, os.Getenv("BITGET_API_PASSPHRASE")) + return NewClient(client) +} + +func TestClient(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + + t.Run("GetUnfilledOrdersRequest", func(t *testing.T) { + startTime := time.Now().Add(-30 * 24 * time.Hour) + req := client.NewGetUnfilledOrdersRequest().StartTime(startTime) + resp, err := req.Do(ctx) + assert.NoError(t, err) + t.Logf("resp: %+v", resp) + }) + + t.Run("GetHistoryOrdersRequest", func(t *testing.T) { + startTime := time.Now().Add(-30 * 24 * time.Hour) + req, err := client.NewGetHistoryOrdersRequest().Symbol("APEUSDT").StartTime(startTime).Do(ctx) + assert.NoError(t, err) + + t.Logf("place order resp: %+v", req) + }) + + t.Run("PlaceOrderRequest", func(t *testing.T) { + req, err := client.NewPlaceOrderRequest().Symbol("APEUSDT").OrderType(OrderTypeLimit). + Side(SideTypeSell). + Price("2"). + Size("5"). + Force(OrderForceGTC). + Do(context.Background()) + assert.NoError(t, err) + + t.Logf("place order resp: %+v", req) + }) + + t.Run("GetTradeFillsRequest", func(t *testing.T) { + startTime := time.Now().Add(-30 * 24 * time.Hour) + req, err := client.NewGetTradeFillsRequest().Symbol("APEUSDT").StartTime(startTime).Do(ctx) + assert.NoError(t, err) + + t.Logf("get trade fills resp: %+v", req) + }) + + t.Run("CancelOrderRequest", func(t *testing.T) { + req, err := client.NewPlaceOrderRequest().Symbol("APEUSDT").OrderType(OrderTypeLimit). + Side(SideTypeSell). + Price("2"). + Size("5"). + Force(OrderForceGTC). + Do(context.Background()) + assert.NoError(t, err) + + resp, err := client.NewCancelOrderRequest().Symbol("APEUSDT").OrderId(req.OrderId).Do(ctx) + t.Logf("cancel order resp: %+v", resp) + }) + + t.Run("GetKLineRequest", func(t *testing.T) { + startTime := time.Date(2023, 8, 12, 0, 0, 0, 0, time.UTC) + endTime := time.Date(2023, 10, 14, 0, 0, 0, 0, time.UTC) + resp, err := client.NewGetKLineRequest().Symbol("APEUSDT").Granularity("30min").StartTime(startTime).EndTime(endTime).Limit("1000").Do(ctx) + assert.NoError(t, err) + t.Logf("resp: %+v", resp) + }) + + t.Run("GetSymbolsRequest", func(t *testing.T) { + resp, err := client.NewGetSymbolsRequest().Do(ctx) + assert.NoError(t, err) + t.Logf("resp: %+v", resp) + }) + + t.Run("GetTickersRequest", func(t *testing.T) { + resp, err := client.NewGetTickersRequest().Do(ctx) + assert.NoError(t, err) + t.Logf("resp: %+v", resp) + }) + + t.Run("GetAccountAssetsRequest", func(t *testing.T) { + resp, err := client.NewGetAccountAssetsRequest().AssetType(AssetTypeHoldOnly).Do(ctx) + assert.NoError(t, err) + t.Logf("resp: %+v", resp) + }) +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/get_account_assets_request.go b/pkg/exchange/bitget/bitgetapi/v2/get_account_assets_request.go new file mode 100644 index 0000000..78b5383 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/get_account_assets_request.go @@ -0,0 +1,39 @@ +package bitgetapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type AccountAsset struct { + Coin string `json:"coin"` + Available fixedpoint.Value `json:"available"` + Frozen fixedpoint.Value `json:"frozen"` + Locked fixedpoint.Value `json:"locked"` + LimitAvailable fixedpoint.Value `json:"limitAvailable"` + UpdatedTime types.MillisecondTimestamp `json:"uTime"` +} + +type AssetType string + +const ( + AssetTypeHoldOnly AssetType = "hold_only" + AssetTypeHAll AssetType = "all" +) + +//go:generate GetRequest -url "/api/v2/spot/account/assets" -type GetAccountAssetsRequest -responseDataType []AccountAsset +type GetAccountAssetsRequest struct { + client requestgen.AuthenticatedAPIClient + + coin *string `param:"symbol,query"` + assetType AssetType `param:"assetType,query"` +} + +func (c *Client) NewGetAccountAssetsRequest() *GetAccountAssetsRequest { + return &GetAccountAssetsRequest{client: c.Client} +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/get_account_assets_request_requestgen.go b/pkg/exchange/bitget/bitgetapi/v2/get_account_assets_request_requestgen.go new file mode 100644 index 0000000..d08b92e --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/get_account_assets_request_requestgen.go @@ -0,0 +1,195 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/v2/spot/account/assets -type GetAccountAssetsRequest -responseDataType []AccountAsset"; DO NOT EDIT. + +package bitgetapi + +import ( + "context" + "encoding/json" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bitget/bitgetapi" + "net/url" + "reflect" + "regexp" +) + +func (c *GetAccountAssetsRequest) Coin(coin string) *GetAccountAssetsRequest { + c.coin = &coin + return c +} + +func (c *GetAccountAssetsRequest) AssetType(assetType AssetType) *GetAccountAssetsRequest { + c.assetType = assetType + return c +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (c *GetAccountAssetsRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check coin field -> json key symbol + if c.coin != nil { + coin := *c.coin + + // assign parameter of coin + params["symbol"] = coin + } else { + } + // check assetType field -> json key limit + assetType := c.assetType + + // TEMPLATE check-valid-values + switch assetType { + case AssetTypeHoldOnly, AssetTypeHAll: + params["limit"] = assetType + + default: + return nil, fmt.Errorf("limit value %v is invalid", assetType) + + } + // END TEMPLATE check-valid-values + + // assign parameter of assetType + params["limit"] = assetType + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (c *GetAccountAssetsRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (c *GetAccountAssetsRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := c.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if c.isVarSlice(_v) { + c.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (c *GetAccountAssetsRequest) GetParametersJSON() ([]byte, error) { + params, err := c.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (c *GetAccountAssetsRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (c *GetAccountAssetsRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (c *GetAccountAssetsRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (c *GetAccountAssetsRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (c *GetAccountAssetsRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := c.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (c *GetAccountAssetsRequest) GetPath() string { + return "/api/v2/spot/account/assets" +} + +// Do generates the request object and send the request object to the API endpoint +func (c *GetAccountAssetsRequest) Do(ctx context.Context) ([]AccountAsset, error) { + + // no body params + var params interface{} + query, err := c.GetQueryParameters() + if err != nil { + return nil, err + } + + var apiURL string + + apiURL = c.GetPath() + + req, err := c.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := c.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse bitgetapi.APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data []AccountAsset + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/get_history_orders_request.go b/pkg/exchange/bitget/bitgetapi/v2/get_history_orders_request.go new file mode 100644 index 0000000..a24f37d --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/get_history_orders_request.go @@ -0,0 +1,103 @@ +package bitgetapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +import ( + "encoding/json" + "fmt" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/c9s/requestgen" +) + +type FeeDetail struct { + // NewFees should have a value because when I was integrating, it already prompted, + // "If there is no 'newFees' field, this data represents earlier historical data." + NewFees struct { + // Amount deducted by coupons, unit:currency obtained from the transaction. + DeductedByCoupon fixedpoint.Value `json:"c"` + // Amount deducted in BGB (Bitget Coin), unit:BGB + DeductedInBGB fixedpoint.Value `json:"d"` + // If the BGB balance is insufficient to cover the fees, the remaining amount is deducted from the + //currency obtained from the transaction. + DeductedFromCurrency fixedpoint.Value `json:"r"` + // The total fee amount to be paid, unit :currency obtained from the transaction. + ToBePaid fixedpoint.Value `json:"t"` + // ignored + Deduction bool `json:"deduction"` + // ignored + TotalDeductionFee fixedpoint.Value `json:"totalDeductionFee"` + } `json:"newFees"` +} + +type OrderDetail struct { + UserId types.StrInt64 `json:"userId"` + Symbol string `json:"symbol"` + // OrderId are always numeric. It's confirmed with official customer service. https://t.me/bitgetOpenapi/24172 + OrderId types.StrInt64 `json:"orderId"` + ClientOrderId string `json:"clientOid"` + Price fixedpoint.Value `json:"price"` + // Size is base coin when orderType=limit; quote coin when orderType=market + Size fixedpoint.Value `json:"size"` + OrderType OrderType `json:"orderType"` + Side SideType `json:"side"` + Status OrderStatus `json:"status"` + PriceAvg fixedpoint.Value `json:"priceAvg"` + BaseVolume fixedpoint.Value `json:"baseVolume"` + QuoteVolume fixedpoint.Value `json:"quoteVolume"` + EnterPointSource string `json:"enterPointSource"` + // The value is json string, so we unmarshal it after unmarshal OrderDetail + FeeDetailRaw string `json:"feeDetail"` + OrderSource string `json:"orderSource"` + CreatedTime types.MillisecondTimestamp `json:"cTime"` + UpdatedTime types.MillisecondTimestamp `json:"uTime"` + + FeeDetail FeeDetail +} + +func (o *OrderDetail) UnmarshalJSON(data []byte) error { + if o == nil { + return fmt.Errorf("failed to unmarshal json from nil pointer order detail") + } + // define new type to avoid loop reference + type AuxOrderDetail OrderDetail + + var aux AuxOrderDetail + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + *o = OrderDetail(aux) + + if len(aux.FeeDetailRaw) == 0 { + return nil + } + + var feeDetail FeeDetail + if err := json.Unmarshal([]byte(aux.FeeDetailRaw), &feeDetail); err != nil { + return fmt.Errorf("unexpected fee detail raw: %s, err: %w", aux.FeeDetailRaw, err) + } + o.FeeDetail = feeDetail + + return nil +} + +//go:generate GetRequest -url "/api/v2/spot/trade/history-orders" -type GetHistoryOrdersRequest -responseDataType []OrderDetail +type GetHistoryOrdersRequest struct { + client requestgen.AuthenticatedAPIClient + + symbol *string `param:"symbol,query"` + // Limit number default 100 max 100 + limit *string `param:"limit,query"` + // idLessThan requests the content on the page before this ID (older data), the value input should be the orderId of the corresponding interface. + idLessThan *string `param:"idLessThan,query"` + startTime *time.Time `param:"startTime,milliseconds,query"` + endTime *time.Time `param:"endTime,milliseconds,query"` + orderId *string `param:"orderId,query"` +} + +func (c *Client) NewGetHistoryOrdersRequest() *GetHistoryOrdersRequest { + return &GetHistoryOrdersRequest{client: c.Client} +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/get_history_orders_request_requestgen.go b/pkg/exchange/bitget/bitgetapi/v2/get_history_orders_request_requestgen.go new file mode 100644 index 0000000..a477b29 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/get_history_orders_request_requestgen.go @@ -0,0 +1,243 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/v2/spot/trade/history-orders -type GetHistoryOrdersRequest -responseDataType []OrderDetail"; DO NOT EDIT. + +package bitgetapi + +import ( + "context" + "encoding/json" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bitget/bitgetapi" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetHistoryOrdersRequest) Symbol(symbol string) *GetHistoryOrdersRequest { + g.symbol = &symbol + return g +} + +func (g *GetHistoryOrdersRequest) Limit(limit string) *GetHistoryOrdersRequest { + g.limit = &limit + return g +} + +func (g *GetHistoryOrdersRequest) IdLessThan(idLessThan string) *GetHistoryOrdersRequest { + g.idLessThan = &idLessThan + return g +} + +func (g *GetHistoryOrdersRequest) StartTime(startTime time.Time) *GetHistoryOrdersRequest { + g.startTime = &startTime + return g +} + +func (g *GetHistoryOrdersRequest) EndTime(endTime time.Time) *GetHistoryOrdersRequest { + g.endTime = &endTime + return g +} + +func (g *GetHistoryOrdersRequest) OrderId(orderId string) *GetHistoryOrdersRequest { + g.orderId = &orderId + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetHistoryOrdersRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + if g.symbol != nil { + symbol := *g.symbol + + // assign parameter of symbol + params["symbol"] = symbol + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + // check idLessThan field -> json key idLessThan + if g.idLessThan != nil { + idLessThan := *g.idLessThan + + // assign parameter of idLessThan + params["idLessThan"] = idLessThan + } else { + } + // check startTime field -> json key startTime + if g.startTime != nil { + startTime := *g.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["startTime"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key endTime + if g.endTime != nil { + endTime := *g.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["endTime"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check orderId field -> json key orderId + if g.orderId != nil { + orderId := *g.orderId + + // assign parameter of orderId + params["orderId"] = orderId + } else { + } + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetHistoryOrdersRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetHistoryOrdersRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetHistoryOrdersRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetHistoryOrdersRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetHistoryOrdersRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetHistoryOrdersRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetHistoryOrdersRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetHistoryOrdersRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (g *GetHistoryOrdersRequest) GetPath() string { + return "/api/v2/spot/trade/history-orders" +} + +// Do generates the request object and send the request object to the API endpoint +func (g *GetHistoryOrdersRequest) Do(ctx context.Context) ([]OrderDetail, error) { + + // no body params + var params interface{} + query, err := g.GetQueryParameters() + if err != nil { + return nil, err + } + + var apiURL string + + apiURL = g.GetPath() + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse bitgetapi.APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data []OrderDetail + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/get_history_orders_request_test.go b/pkg/exchange/bitget/bitgetapi/v2/get_history_orders_request_test.go new file mode 100644 index 0000000..4a4959c --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/get_history_orders_request_test.go @@ -0,0 +1,121 @@ +package bitgetapi + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func TestOrderDetail_UnmarshalJSON(t *testing.T) { + var ( + assert = assert.New(t) + ) + t.Run("empty fee", func(t *testing.T) { + input := `{ + "userId":"8672173294", + "symbol":"APEUSDT", + "orderId":"1104342023170068480", + "clientOid":"f3d6a1ee-4e94-48b5-a6e0-25f3e93d92e1", + "price":"1.2000000000000000", + "size":"5.0000000000000000", + "orderType":"limit", + "side":"buy", + "status":"cancelled", + "priceAvg":"0", + "baseVolume":"0.0000000000000000", + "quoteVolume":"0.0000000000000000", + "enterPointSource":"API", + "feeDetail":"", + "orderSource":"normal", + "cTime":"1699021576683", + "uTime":"1699021649099" + }` + var od OrderDetail + err := json.Unmarshal([]byte(input), &od) + assert.NoError(err) + assert.Equal(OrderDetail{ + UserId: types.StrInt64(8672173294), + Symbol: "APEUSDT", + OrderId: types.StrInt64(1104342023170068480), + ClientOrderId: "f3d6a1ee-4e94-48b5-a6e0-25f3e93d92e1", + Price: fixedpoint.NewFromFloat(1.2), + Size: fixedpoint.NewFromFloat(5), + OrderType: OrderTypeLimit, + Side: SideTypeBuy, + Status: OrderStatusCancelled, + PriceAvg: fixedpoint.Zero, + BaseVolume: fixedpoint.Zero, + QuoteVolume: fixedpoint.Zero, + EnterPointSource: "API", + FeeDetailRaw: "", + OrderSource: "normal", + CreatedTime: types.NewMillisecondTimestampFromInt(1699021576683), + UpdatedTime: types.NewMillisecondTimestampFromInt(1699021649099), + FeeDetail: FeeDetail{}, + }, od) + }) + + t.Run("fee", func(t *testing.T) { + input := `{ + "userId":"8672173294", + "symbol":"APEUSDT", + "orderId":"1104337778433757184", + "clientOid":"8afea7bd-d873-44fe-aff8-6a1fae3cc765", + "price":"1.4000000000000000", + "size":"5.0000000000000000", + "orderType":"limit", + "side":"sell", + "status":"filled", + "priceAvg":"1.4001000000000000", + "baseVolume":"5.0000000000000000", + "quoteVolume":"7.0005000000000000", + "enterPointSource":"API", + "feeDetail":"{\"newFees\":{\"c\":0,\"d\":0,\"deduction\":false,\"r\":-0.0070005,\"t\":-0.0070005,\"totalDeductionFee\":0},\"USDT\":{\"deduction\":false,\"feeCoinCode\":\"USDT\",\"totalDeductionFee\":0,\"totalFee\":-0.007000500000}}", + "orderSource":"normal", + "cTime":"1699020564659", + "uTime":"1699020564688" + }` + var od OrderDetail + err := json.Unmarshal([]byte(input), &od) + assert.NoError(err) + assert.Equal(OrderDetail{ + UserId: types.StrInt64(8672173294), + Symbol: "APEUSDT", + OrderId: types.StrInt64(1104337778433757184), + ClientOrderId: "8afea7bd-d873-44fe-aff8-6a1fae3cc765", + Price: fixedpoint.NewFromFloat(1.4), + Size: fixedpoint.NewFromFloat(5), + OrderType: OrderTypeLimit, + Side: SideTypeSell, + Status: OrderStatusFilled, + PriceAvg: fixedpoint.NewFromFloat(1.4001), + BaseVolume: fixedpoint.NewFromFloat(5), + QuoteVolume: fixedpoint.NewFromFloat(7.0005), + EnterPointSource: "API", + FeeDetailRaw: `{"newFees":{"c":0,"d":0,"deduction":false,"r":-0.0070005,"t":-0.0070005,"totalDeductionFee":0},"USDT":{"deduction":false,"feeCoinCode":"USDT","totalDeductionFee":0,"totalFee":-0.007000500000}}`, + OrderSource: "normal", + CreatedTime: types.NewMillisecondTimestampFromInt(1699020564659), + UpdatedTime: types.NewMillisecondTimestampFromInt(1699020564688), + FeeDetail: FeeDetail{ + NewFees: struct { + DeductedByCoupon fixedpoint.Value `json:"c"` + DeductedInBGB fixedpoint.Value `json:"d"` + DeductedFromCurrency fixedpoint.Value `json:"r"` + ToBePaid fixedpoint.Value `json:"t"` + Deduction bool `json:"deduction"` + TotalDeductionFee fixedpoint.Value `json:"totalDeductionFee"` + }{DeductedByCoupon: fixedpoint.NewFromFloat(0), + DeductedInBGB: fixedpoint.NewFromFloat(0), + DeductedFromCurrency: fixedpoint.NewFromFloat(-0.0070005), + ToBePaid: fixedpoint.NewFromFloat(-0.0070005), + Deduction: false, + TotalDeductionFee: fixedpoint.Zero, + }, + }, + }, od) + }) +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/get_k_line.go b/pkg/exchange/bitget/bitgetapi/v2/get_k_line.go new file mode 100644 index 0000000..b0de5eb --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/get_k_line.go @@ -0,0 +1,83 @@ +package bitgetapi + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +type KLine struct { + // System timestamp, Unix millisecond timestamp, e.g. 1690196141868 + Ts types.MillisecondTimestamp + Open fixedpoint.Value + High fixedpoint.Value + Low fixedpoint.Value + Close fixedpoint.Value + // Trading volume in base currency, e.g. "BTC" in the "BTCUSD" pair. + Volume fixedpoint.Value + // Trading volume in quote currency, e.g. "USD" in the "BTCUSD" pair. + QuoteVolume fixedpoint.Value + // Trading volume in USDT + UsdtVolume fixedpoint.Value +} + +type KLineResponse []KLine + +const KLinesArrayLen = 8 + +func (k *KLine) UnmarshalJSON(data []byte) error { + var jsonArr []json.RawMessage + err := json.Unmarshal(data, &jsonArr) + if err != nil { + return fmt.Errorf("failed to unmarshal jsonRawMessage: %v, err: %w", string(data), err) + } + if len(jsonArr) != KLinesArrayLen { + return fmt.Errorf("unexpected K Lines array length: %d, exp: %d", len(jsonArr), KLinesArrayLen) + } + + err = json.Unmarshal(jsonArr[0], &k.Ts) + if err != nil { + return fmt.Errorf("failed to unmarshal resp index 0: %v, err: %w", string(jsonArr[0]), err) + } + + values := make([]fixedpoint.Value, len(jsonArr)-1) + for i, jsonRaw := range jsonArr[1:] { + err = json.Unmarshal(jsonRaw, &values[i]) + if err != nil { + return fmt.Errorf("failed to unmarshal resp index %d: %v, err: %w", i+1, string(jsonRaw), err) + } + } + k.Open = values[0] + k.High = values[1] + k.Low = values[2] + k.Close = values[3] + k.Volume = values[4] + k.QuoteVolume = values[5] + k.UsdtVolume = values[6] + + return nil +} + +//go:generate GetRequest -url "/api/v2/spot/market/candles" -type GetKLineRequest -responseDataType .KLineResponse +type GetKLineRequest struct { + client requestgen.APIClient + + symbol string `param:"symbol,query"` + granularity string `param:"granularity,query"` + startTime *time.Time `param:"startTime,milliseconds,query"` + endTime *time.Time `param:"endTime,milliseconds,query"` + // Limit number default 100 max 1000 + limit *string `param:"limit,query"` +} + +func (s *Client) NewGetKLineRequest() *GetKLineRequest { + return &GetKLineRequest{client: s.Client} +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/get_k_line_request_requestgen.go b/pkg/exchange/bitget/bitgetapi/v2/get_k_line_request_requestgen.go new file mode 100644 index 0000000..47b4bec --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/get_k_line_request_requestgen.go @@ -0,0 +1,224 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/v2/spot/market/candles -type GetKLineRequest -responseDataType .KLineResponse"; DO NOT EDIT. + +package bitgetapi + +import ( + "context" + "encoding/json" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bitget/bitgetapi" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetKLineRequest) Symbol(symbol string) *GetKLineRequest { + g.symbol = symbol + return g +} + +func (g *GetKLineRequest) Granularity(granularity string) *GetKLineRequest { + g.granularity = granularity + return g +} + +func (g *GetKLineRequest) StartTime(startTime time.Time) *GetKLineRequest { + g.startTime = &startTime + return g +} + +func (g *GetKLineRequest) EndTime(endTime time.Time) *GetKLineRequest { + g.endTime = &endTime + return g +} + +func (g *GetKLineRequest) Limit(limit string) *GetKLineRequest { + g.limit = &limit + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetKLineRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := g.symbol + + // assign parameter of symbol + params["symbol"] = symbol + // check granularity field -> json key granularity + granularity := g.granularity + + // assign parameter of granularity + params["granularity"] = granularity + // check startTime field -> json key startTime + if g.startTime != nil { + startTime := *g.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["startTime"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key endTime + if g.endTime != nil { + endTime := *g.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["endTime"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetKLineRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetKLineRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetKLineRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetKLineRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetKLineRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetKLineRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetKLineRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetKLineRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (g *GetKLineRequest) GetPath() string { + return "/api/v2/spot/market/candles" +} + +// Do generates the request object and send the request object to the API endpoint +func (g *GetKLineRequest) Do(ctx context.Context) (KLineResponse, error) { + + // no body params + var params interface{} + query, err := g.GetQueryParameters() + if err != nil { + return nil, err + } + + var apiURL string + + apiURL = g.GetPath() + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse bitgetapi.APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data KLineResponse + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/get_symbols_request.go b/pkg/exchange/bitget/bitgetapi/v2/get_symbols_request.go new file mode 100644 index 0000000..c8c5a1f --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/get_symbols_request.go @@ -0,0 +1,49 @@ +package bitgetapi + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +type SymbolStatus string + +const ( + // SymbolStatusOffline represent market is suspended, users cannot trade. + SymbolStatusOffline SymbolStatus = "offline" + + // SymbolStatusGray represents market is online, but user trading is not available. + SymbolStatusGray SymbolStatus = "gray" + + // SymbolStatusOnline trading begins, users can trade. + SymbolStatusOnline SymbolStatus = "online" +) + +type Symbol struct { + Symbol string `json:"symbol"` + BaseCoin string `json:"baseCoin"` + QuoteCoin string `json:"quoteCoin"` + MinTradeAmount fixedpoint.Value `json:"minTradeAmount"` + MaxTradeAmount fixedpoint.Value `json:"maxTradeAmount"` + TakerFeeRate fixedpoint.Value `json:"takerFeeRate"` + MakerFeeRate fixedpoint.Value `json:"makerFeeRate"` + PricePrecision fixedpoint.Value `json:"pricePrecision"` + QuantityPrecision fixedpoint.Value `json:"quantityPrecision"` + QuotePrecision fixedpoint.Value `json:"quotePrecision"` + MinTradeUSDT fixedpoint.Value `json:"minTradeUSDT"` + Status SymbolStatus `json:"status"` + BuyLimitPriceRatio fixedpoint.Value `json:"buyLimitPriceRatio"` + SellLimitPriceRatio fixedpoint.Value `json:"sellLimitPriceRatio"` +} + +//go:generate GetRequest -url "/api/v2/spot/public/symbols" -type GetSymbolsRequest -responseDataType []Symbol +type GetSymbolsRequest struct { + client requestgen.APIClient +} + +func (c *Client) NewGetSymbolsRequest() *GetSymbolsRequest { + return &GetSymbolsRequest{client: c.Client} +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/get_symbols_request_requestgen.go b/pkg/exchange/bitget/bitgetapi/v2/get_symbols_request_requestgen.go new file mode 100644 index 0000000..8e6afdb --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/get_symbols_request_requestgen.go @@ -0,0 +1,172 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/v2/spot/public/symbols -type GetSymbolsRequest -responseDataType []Symbol"; DO NOT EDIT. + +package bitgetapi + +import ( + "context" + "encoding/json" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bitget/bitgetapi" + "net/url" + "reflect" + "regexp" +) + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetSymbolsRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetSymbolsRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetSymbolsRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetSymbolsRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetSymbolsRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetSymbolsRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetSymbolsRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetSymbolsRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetSymbolsRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (g *GetSymbolsRequest) GetPath() string { + return "/api/v2/spot/public/symbols" +} + +// Do generates the request object and send the request object to the API endpoint +func (g *GetSymbolsRequest) Do(ctx context.Context) ([]Symbol, error) { + + // no body params + var params interface{} + query := url.Values{} + + var apiURL string + + apiURL = g.GetPath() + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse bitgetapi.APIResponse + + type responseUnmarshaler interface { + Unmarshal(data []byte) error + } + + if unmarshaler, ok := interface{}(&apiResponse).(responseUnmarshaler); ok { + if err := unmarshaler.Unmarshal(response.Body); err != nil { + return nil, err + } + } else { + // The line below checks the content type, however, some API server might not send the correct content type header, + // Hence, this is commented for backward compatibility + // response.IsJSON() + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + } + + type responseValidator interface { + Validate() error + } + + if validator, ok := interface{}(&apiResponse).(responseValidator); ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data []Symbol + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/get_tickers_request.go b/pkg/exchange/bitget/bitgetapi/v2/get_tickers_request.go new file mode 100644 index 0000000..e1c65d7 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/get_tickers_request.go @@ -0,0 +1,40 @@ +package bitgetapi + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/c9s/requestgen" +) + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +type Ticker struct { + Symbol string `json:"symbol"` + High24H fixedpoint.Value `json:"high24h"` + Open fixedpoint.Value `json:"open"` + Low24H fixedpoint.Value `json:"low24h"` + LastPr fixedpoint.Value `json:"lastPr"` + QuoteVolume fixedpoint.Value `json:"quoteVolume"` + BaseVolume fixedpoint.Value `json:"baseVolume"` + UsdtVolume fixedpoint.Value `json:"usdtVolume"` + BidPr fixedpoint.Value `json:"bidPr"` + AskPr fixedpoint.Value `json:"askPr"` + BidSz fixedpoint.Value `json:"bidSz"` + AskSz fixedpoint.Value `json:"askSz"` + OpenUtc fixedpoint.Value `json:"openUtc"` + Ts types.MillisecondTimestamp `json:"ts"` + ChangeUtc24H fixedpoint.Value `json:"changeUtc24h"` + Change24H fixedpoint.Value `json:"change24h"` +} + +//go:generate GetRequest -url "/api/v2/spot/market/tickers" -type GetTickersRequest -responseDataType []Ticker +type GetTickersRequest struct { + client requestgen.APIClient + + symbol *string `param:"symbol,query"` +} + +func (s *Client) NewGetTickersRequest() *GetTickersRequest { + return &GetTickersRequest{client: s.Client} +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/get_tickers_request_requestgen.go b/pkg/exchange/bitget/bitgetapi/v2/get_tickers_request_requestgen.go new file mode 100644 index 0000000..ed55b7b --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/get_tickers_request_requestgen.go @@ -0,0 +1,174 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/v2/spot/market/tickers -type GetTickersRequest -responseDataType []Ticker"; DO NOT EDIT. + +package bitgetapi + +import ( + "context" + "encoding/json" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bitget/bitgetapi" + "net/url" + "reflect" + "regexp" +) + +func (g *GetTickersRequest) Symbol(symbol string) *GetTickersRequest { + g.symbol = &symbol + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetTickersRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + if g.symbol != nil { + symbol := *g.symbol + + // assign parameter of symbol + params["symbol"] = symbol + } else { + } + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetTickersRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetTickersRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetTickersRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetTickersRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetTickersRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetTickersRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetTickersRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetTickersRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (g *GetTickersRequest) GetPath() string { + return "/api/v2/spot/market/tickers" +} + +// Do generates the request object and send the request object to the API endpoint +func (g *GetTickersRequest) Do(ctx context.Context) ([]Ticker, error) { + + // no body params + var params interface{} + query, err := g.GetQueryParameters() + if err != nil { + return nil, err + } + + var apiURL string + + apiURL = g.GetPath() + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse bitgetapi.APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data []Ticker + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/get_trade_fills.go b/pkg/exchange/bitget/bitgetapi/v2/get_trade_fills.go new file mode 100644 index 0000000..869b783 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/get_trade_fills.go @@ -0,0 +1,72 @@ +package bitgetapi + +import ( + "time" + + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +type TradeScope string + +const ( + TradeMaker TradeScope = "maker" + TradeTaker TradeScope = "taker" +) + +type DiscountStatus string + +const ( + DiscountYes DiscountStatus = "yes" + DiscountNo DiscountStatus = "no" +) + +type TradeFee struct { + // Discount or not + Deduction DiscountStatus `json:"deduction"` + // Transaction fee coin + FeeCoin string `json:"feeCoin"` + // Total transaction fee discount + TotalDeductionFee fixedpoint.Value `json:"totalDeductionFee"` + // Total transaction fee + TotalFee fixedpoint.Value `json:"totalFee"` +} + +type Trade struct { + UserId types.StrInt64 `json:"userId"` + Symbol string `json:"symbol"` + OrderId types.StrInt64 `json:"orderId"` + TradeId types.StrInt64 `json:"tradeId"` + OrderType OrderType `json:"orderType"` + Side SideType `json:"side"` + PriceAvg fixedpoint.Value `json:"priceAvg"` + Size fixedpoint.Value `json:"size"` + Amount fixedpoint.Value `json:"amount"` + FeeDetail TradeFee `json:"feeDetail"` + TradeScope TradeScope `json:"tradeScope"` + CreatedTime types.MillisecondTimestamp `json:"cTime"` + UpdatedTime types.MillisecondTimestamp `json:"uTime"` +} + +//go:generate GetRequest -url "/api/v2/spot/trade/fills" -type GetTradeFillsRequest -responseDataType []Trade +type GetTradeFillsRequest struct { + client requestgen.AuthenticatedAPIClient + + symbol string `param:"symbol,query"` + // Limit number default 100 max 100 + limit *string `param:"limit,query"` + // idLessThan requests the content on the page before this ID (older data), the value input should be the orderId of the corresponding interface. + idLessThan *string `param:"idLessThan,query"` + startTime *time.Time `param:"startTime,milliseconds,query"` + endTime *time.Time `param:"endTime,milliseconds,query"` + orderId *string `param:"orderId,query"` +} + +func (s *Client) NewGetTradeFillsRequest() *GetTradeFillsRequest { + return &GetTradeFillsRequest{client: s.Client} +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/get_trade_fills_request_requestgen.go b/pkg/exchange/bitget/bitgetapi/v2/get_trade_fills_request_requestgen.go new file mode 100644 index 0000000..386f939 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/get_trade_fills_request_requestgen.go @@ -0,0 +1,240 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/v2/spot/trade/fills -type GetTradeFillsRequest -responseDataType []Trade"; DO NOT EDIT. + +package bitgetapi + +import ( + "context" + "encoding/json" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bitget/bitgetapi" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (s *GetTradeFillsRequest) Symbol(symbol string) *GetTradeFillsRequest { + s.symbol = symbol + return s +} + +func (s *GetTradeFillsRequest) Limit(limit string) *GetTradeFillsRequest { + s.limit = &limit + return s +} + +func (s *GetTradeFillsRequest) IdLessThan(idLessThan string) *GetTradeFillsRequest { + s.idLessThan = &idLessThan + return s +} + +func (s *GetTradeFillsRequest) StartTime(startTime time.Time) *GetTradeFillsRequest { + s.startTime = &startTime + return s +} + +func (s *GetTradeFillsRequest) EndTime(endTime time.Time) *GetTradeFillsRequest { + s.endTime = &endTime + return s +} + +func (s *GetTradeFillsRequest) OrderId(orderId string) *GetTradeFillsRequest { + s.orderId = &orderId + return s +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (s *GetTradeFillsRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := s.symbol + + // assign parameter of symbol + params["symbol"] = symbol + // check limit field -> json key limit + if s.limit != nil { + limit := *s.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + // check idLessThan field -> json key idLessThan + if s.idLessThan != nil { + idLessThan := *s.idLessThan + + // assign parameter of idLessThan + params["idLessThan"] = idLessThan + } else { + } + // check startTime field -> json key startTime + if s.startTime != nil { + startTime := *s.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["startTime"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key endTime + if s.endTime != nil { + endTime := *s.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["endTime"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check orderId field -> json key orderId + if s.orderId != nil { + orderId := *s.orderId + + // assign parameter of orderId + params["orderId"] = orderId + } else { + } + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (s *GetTradeFillsRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (s *GetTradeFillsRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := s.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if s.isVarSlice(_v) { + s.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (s *GetTradeFillsRequest) GetParametersJSON() ([]byte, error) { + params, err := s.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (s *GetTradeFillsRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (s *GetTradeFillsRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (s *GetTradeFillsRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (s *GetTradeFillsRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (s *GetTradeFillsRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := s.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (s *GetTradeFillsRequest) GetPath() string { + return "/api/v2/spot/trade/fills" +} + +// Do generates the request object and send the request object to the API endpoint +func (s *GetTradeFillsRequest) Do(ctx context.Context) ([]Trade, error) { + + // no body params + var params interface{} + query, err := s.GetQueryParameters() + if err != nil { + return nil, err + } + + var apiURL string + + apiURL = s.GetPath() + + req, err := s.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := s.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse bitgetapi.APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data []Trade + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/get_unfilled_orders_request.go b/pkg/exchange/bitget/bitgetapi/v2/get_unfilled_orders_request.go new file mode 100644 index 0000000..278e3e4 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/get_unfilled_orders_request.go @@ -0,0 +1,56 @@ +package bitgetapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +import ( + "time" + + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type UnfilledOrder struct { + UserId types.StrInt64 `json:"userId"` + Symbol string `json:"symbol"` + // OrderId are always numeric. It's confirmed with official customer service. https://t.me/bitgetOpenapi/24172 + OrderId types.StrInt64 `json:"orderId"` + ClientOrderId string `json:"clientOid"` + PriceAvg fixedpoint.Value `json:"priceAvg"` + // Size is base coin when orderType=limit; quote coin when orderType=market + Size fixedpoint.Value `json:"size"` + OrderType OrderType `json:"orderType"` + Side SideType `json:"side"` + Status OrderStatus `json:"status"` + BasePrice fixedpoint.Value `json:"basePrice"` + BaseVolume fixedpoint.Value `json:"baseVolume"` + QuoteVolume fixedpoint.Value `json:"quoteVolume"` + EnterPointSource string `json:"enterPointSource"` + OrderSource string `json:"orderSource"` + CreatedTime types.MillisecondTimestamp `json:"cTime"` + UpdatedTime types.MillisecondTimestamp `json:"uTime"` +} + +//go:generate GetRequest -url "/api/v2/spot/trade/unfilled-orders" -type GetUnfilledOrdersRequest -responseDataType []UnfilledOrder +type GetUnfilledOrdersRequest struct { + client requestgen.AuthenticatedAPIClient + + symbol *string `param:"symbol,query"` + + // limit number default 100 max 100 + limit *string `param:"limit,query"` + + // idLessThan requests the content on the page before this ID (older data), the value input should be the orderId of the corresponding interface. + idLessThan *string `param:"idLessThan,query"` + + startTime *time.Time `param:"startTime,milliseconds,query"` + endTime *time.Time `param:"endTime,milliseconds,query"` + + orderId *string `param:"orderId,query"` +} + +func (c *Client) NewGetUnfilledOrdersRequest() *GetUnfilledOrdersRequest { + return &GetUnfilledOrdersRequest{client: c.Client} +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/get_unfilled_orders_request_requestgen.go b/pkg/exchange/bitget/bitgetapi/v2/get_unfilled_orders_request_requestgen.go new file mode 100644 index 0000000..c392c3e --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/get_unfilled_orders_request_requestgen.go @@ -0,0 +1,243 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/v2/spot/trade/unfilled-orders -type GetUnfilledOrdersRequest -responseDataType []UnfilledOrder"; DO NOT EDIT. + +package bitgetapi + +import ( + "context" + "encoding/json" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bitget/bitgetapi" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetUnfilledOrdersRequest) Symbol(symbol string) *GetUnfilledOrdersRequest { + g.symbol = &symbol + return g +} + +func (g *GetUnfilledOrdersRequest) Limit(limit string) *GetUnfilledOrdersRequest { + g.limit = &limit + return g +} + +func (g *GetUnfilledOrdersRequest) IdLessThan(idLessThan string) *GetUnfilledOrdersRequest { + g.idLessThan = &idLessThan + return g +} + +func (g *GetUnfilledOrdersRequest) StartTime(startTime time.Time) *GetUnfilledOrdersRequest { + g.startTime = &startTime + return g +} + +func (g *GetUnfilledOrdersRequest) EndTime(endTime time.Time) *GetUnfilledOrdersRequest { + g.endTime = &endTime + return g +} + +func (g *GetUnfilledOrdersRequest) OrderId(orderId string) *GetUnfilledOrdersRequest { + g.orderId = &orderId + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetUnfilledOrdersRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + if g.symbol != nil { + symbol := *g.symbol + + // assign parameter of symbol + params["symbol"] = symbol + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + // check idLessThan field -> json key idLessThan + if g.idLessThan != nil { + idLessThan := *g.idLessThan + + // assign parameter of idLessThan + params["idLessThan"] = idLessThan + } else { + } + // check startTime field -> json key startTime + if g.startTime != nil { + startTime := *g.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["startTime"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key endTime + if g.endTime != nil { + endTime := *g.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["endTime"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check orderId field -> json key orderId + if g.orderId != nil { + orderId := *g.orderId + + // assign parameter of orderId + params["orderId"] = orderId + } else { + } + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetUnfilledOrdersRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetUnfilledOrdersRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetUnfilledOrdersRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetUnfilledOrdersRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetUnfilledOrdersRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetUnfilledOrdersRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetUnfilledOrdersRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetUnfilledOrdersRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (g *GetUnfilledOrdersRequest) GetPath() string { + return "/api/v2/spot/trade/unfilled-orders" +} + +// Do generates the request object and send the request object to the API endpoint +func (g *GetUnfilledOrdersRequest) Do(ctx context.Context) ([]UnfilledOrder, error) { + + // no body params + var params interface{} + query, err := g.GetQueryParameters() + if err != nil { + return nil, err + } + + var apiURL string + + apiURL = g.GetPath() + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse bitgetapi.APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data []UnfilledOrder + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/place_order_request.go b/pkg/exchange/bitget/bitgetapi/v2/place_order_request.go new file mode 100644 index 0000000..b55071e --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/place_order_request.go @@ -0,0 +1,29 @@ +package bitgetapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +import ( + "github.com/c9s/requestgen" +) + +type PlaceOrderResponse struct { + OrderId string `json:"orderId"` + ClientOrderId string `json:"clientOid"` +} + +//go:generate PostRequest -url "/api/v2/spot/trade/place-order" -type PlaceOrderRequest -responseDataType .PlaceOrderResponse +type PlaceOrderRequest struct { + client requestgen.AuthenticatedAPIClient + symbol string `param:"symbol"` + orderType OrderType `param:"orderType"` + side SideType `param:"side"` + force OrderForce `param:"force"` + price *string `param:"price"` + size string `param:"size"` + clientOrderId *string `param:"clientOid"` +} + +func (c *Client) NewPlaceOrderRequest() *PlaceOrderRequest { + return &PlaceOrderRequest{client: c.Client} +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/place_order_request_requestgen.go b/pkg/exchange/bitget/bitgetapi/v2/place_order_request_requestgen.go new file mode 100644 index 0000000..d73d8b3 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/place_order_request_requestgen.go @@ -0,0 +1,251 @@ +// Code generated by "requestgen -method POST -responseType .APIResponse -responseDataField Data -url /api/v2/spot/trade/place-order -type PlaceOrderRequest -responseDataType .PlaceOrderResponse"; DO NOT EDIT. + +package bitgetapi + +import ( + "context" + "encoding/json" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bitget/bitgetapi" + "net/url" + "reflect" + "regexp" +) + +func (p *PlaceOrderRequest) Symbol(symbol string) *PlaceOrderRequest { + p.symbol = symbol + return p +} + +func (p *PlaceOrderRequest) OrderType(orderType OrderType) *PlaceOrderRequest { + p.orderType = orderType + return p +} + +func (p *PlaceOrderRequest) Side(side SideType) *PlaceOrderRequest { + p.side = side + return p +} + +func (p *PlaceOrderRequest) Force(force OrderForce) *PlaceOrderRequest { + p.force = force + return p +} + +func (p *PlaceOrderRequest) Price(price string) *PlaceOrderRequest { + p.price = &price + return p +} + +func (p *PlaceOrderRequest) Size(size string) *PlaceOrderRequest { + p.size = size + return p +} + +func (p *PlaceOrderRequest) ClientOrderId(clientOrderId string) *PlaceOrderRequest { + p.clientOrderId = &clientOrderId + return p +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (p *PlaceOrderRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (p *PlaceOrderRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := p.symbol + + // assign parameter of symbol + params["symbol"] = symbol + // check orderType field -> json key orderType + orderType := p.orderType + + // TEMPLATE check-valid-values + switch orderType { + case OrderTypeLimit, OrderTypeMarket: + params["orderType"] = orderType + + default: + return nil, fmt.Errorf("orderType value %v is invalid", orderType) + + } + // END TEMPLATE check-valid-values + + // assign parameter of orderType + params["orderType"] = orderType + // check side field -> json key side + side := p.side + + // TEMPLATE check-valid-values + switch side { + case SideTypeBuy, SideTypeSell: + params["side"] = side + + default: + return nil, fmt.Errorf("side value %v is invalid", side) + + } + // END TEMPLATE check-valid-values + + // assign parameter of side + params["side"] = side + // check force field -> json key force + force := p.force + + // TEMPLATE check-valid-values + switch force { + case OrderForceGTC, OrderForcePostOnly, OrderForceFOK, OrderForceIOC: + params["force"] = force + + default: + return nil, fmt.Errorf("force value %v is invalid", force) + + } + // END TEMPLATE check-valid-values + + // assign parameter of force + params["force"] = force + // check price field -> json key price + if p.price != nil { + price := *p.price + + // assign parameter of price + params["price"] = price + } else { + } + // check size field -> json key size + size := p.size + + // assign parameter of size + params["size"] = size + // check clientOrderId field -> json key clientOid + if p.clientOrderId != nil { + clientOrderId := *p.clientOrderId + + // assign parameter of clientOrderId + params["clientOid"] = clientOrderId + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (p *PlaceOrderRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := p.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if p.isVarSlice(_v) { + p.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (p *PlaceOrderRequest) GetParametersJSON() ([]byte, error) { + params, err := p.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (p *PlaceOrderRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (p *PlaceOrderRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (p *PlaceOrderRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (p *PlaceOrderRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (p *PlaceOrderRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := p.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (p *PlaceOrderRequest) Do(ctx context.Context) (*PlaceOrderResponse, error) { + + params, err := p.GetParameters() + if err != nil { + return nil, err + } + query := url.Values{} + + apiURL := "/api/v2/spot/trade/place-order" + + req, err := p.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := p.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse bitgetapi.APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data PlaceOrderResponse + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/testdata/cancel_order_request.json b/pkg/exchange/bitget/bitgetapi/v2/testdata/cancel_order_request.json new file mode 100644 index 0000000..1cf8750 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/testdata/cancel_order_request.json @@ -0,0 +1,9 @@ +{ + "code":"00000", + "msg":"success", + "requestTime":1709883438563, + "data":{ + "orderId":"1149899973610643488", + "clientOid":"9471cf38-33c2-4aee-a2fb-fcf71629ffb7" + } +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/testdata/get_account_assets_request.json b/pkg/exchange/bitget/bitgetapi/v2/testdata/get_account_assets_request.json new file mode 100644 index 0000000..9b85d83 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/testdata/get_account_assets_request.json @@ -0,0 +1,23 @@ +{ + "code":"00000", + "msg":"success", + "requestTime":1709632445201, + "data":[ + { + "coin":"BTC", + "available":"0.00000690", + "limitAvailable":"0", + "frozen":"0.00000000", + "locked":"0.00000000", + "uTime":"1708658921000" + }, + { + "coin":"USDT", + "available":"0.68360342", + "limitAvailable":"0", + "frozen":"9.08096000", + "locked":"0.00000000", + "uTime":"1708667916000" + } + ] +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/testdata/get_history_orders_request.json b/pkg/exchange/bitget/bitgetapi/v2/testdata/get_history_orders_request.json new file mode 100644 index 0000000..3aaecaa --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/testdata/get_history_orders_request.json @@ -0,0 +1,45 @@ +{ + "code":"00000", + "msg":"success", + "requestTime":1709649001209, + "data":[ + { + "userId":"8672173294", + "symbol":"BTCUSDT", + "orderId":"1148914989018062853", + "clientOid":"bf3ba805-66bc-4ef6-bf34-d63d79dc2e4c", + "price":"0", + "size":"6.0000000000000000", + "orderType":"market", + "side":"buy", + "status":"filled", + "priceAvg":"67360.8700000000000000", + "baseVolume":"0.0000890000000000", + "quoteVolume":"5.9951174300000000", + "enterPointSource":"API", + "feeDetail":"{\"BTC\":{\"deduction\":false,\"feeCoinCode\":\"BTC\",\"totalDeductionFee\":0,\"totalFee\":-8.90000000E-8},\"newFees\":{\"c\":0,\"d\":0,\"deduction\":false,\"r\":-8.9E-8,\"t\":-8.9E-8,\"totalDeductionFee\":0}}", + "orderSource":"market", + "cTime":"1709648599867", + "uTime":"1709648600016" + }, + { + "userId":"8672173294", + "symbol":"BTCUSDT", + "orderId":"1148903850645331968", + "clientOid":"684a79df-f931-474f-a9a5-f1deab1cd770", + "price":"66000.0000000000000000", + "size":"0.0000900000000000", + "orderType":"limit", + "side":"buy", + "status":"cancelled", + "priceAvg":"0", + "baseVolume":"0.0000000000000000", + "quoteVolume":"0.0000000000000000", + "enterPointSource":"API", + "feeDetail":"", + "orderSource":"normal", + "cTime":"1709645944272", + "uTime":"1709648518713" + } + ] +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/testdata/get_history_orders_request_market_buy.json b/pkg/exchange/bitget/bitgetapi/v2/testdata/get_history_orders_request_market_buy.json new file mode 100644 index 0000000..e0236ac --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/testdata/get_history_orders_request_market_buy.json @@ -0,0 +1,26 @@ +{ + "code":"00000", + "msg":"success", + "requestTime":1709649001209, + "data":[ + { + "userId":"8672173294", + "symbol":"BTCUSDT", + "orderId":"1148903850645331968", + "clientOid":"684a79df-f931-474f-a9a5-f1deab1cd770", + "price":"0", + "size":"6.0000000000000000", + "orderType":"market", + "side":"buy", + "status":"filled", + "priceAvg":"67360.8700000000000000", + "baseVolume":"0.0000890000000000", + "quoteVolume":"5.9951174300000000", + "enterPointSource":"API", + "feeDetail":"{\"BTC\":{\"deduction\":false,\"feeCoinCode\":\"BTC\",\"totalDeductionFee\":0,\"totalFee\":-8.90000000E-8},\"newFees\":{\"c\":0,\"d\":0,\"deduction\":false,\"r\":-8.9E-8,\"t\":-8.9E-8,\"totalDeductionFee\":0}}", + "orderSource":"market", + "cTime":"1709645944272", + "uTime":"1709645944272" + } + ] +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/testdata/get_k_line_request.json b/pkg/exchange/bitget/bitgetapi/v2/testdata/get_k_line_request.json new file mode 100644 index 0000000..4f1dd32 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/testdata/get_k_line_request.json @@ -0,0 +1,27 @@ +{ + "code":"00000", + "msg":"success", + "requestTime":1709629803685, + "data":[ + [ + "1709352000000", + "62308.42", + "62308.43", + "61760", + "62014.17", + "987.377637", + "61283110.57046518", + "61283110.57046518" + ], + [ + "1709366400000", + "62014.17", + "62122.8", + "61648.26", + "61825.64", + "1271.183413", + "78680550.55539777", + "78680550.55539777" + ] + ] +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/testdata/get_symbols_request.json b/pkg/exchange/bitget/bitgetapi/v2/testdata/get_symbols_request.json new file mode 100644 index 0000000..6c781f9 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/testdata/get_symbols_request.json @@ -0,0 +1,58 @@ +{ + "code":"00000", + "msg":"success", + "requestTime":1709620645267, + "data":[ + { + "symbol":"ETHUSDT", + "baseCoin":"ETH", + "quoteCoin":"USDT", + "minTradeAmount":"0", + "maxTradeAmount":"10000000000", + "takerFeeRate":"0.002", + "makerFeeRate":"0.002", + "pricePrecision":"2", + "quantityPrecision":"4", + "quotePrecision":"6", + "status":"online", + "minTradeUSDT":"5", + "buyLimitPriceRatio":"0.05", + "sellLimitPriceRatio":"0.05", + "areaSymbol":"no" + }, + { + "symbol":"BTCUSDT", + "baseCoin":"BTC", + "quoteCoin":"USDT", + "minTradeAmount":"0", + "maxTradeAmount":"10000000000", + "takerFeeRate":"0.002", + "makerFeeRate":"0.002", + "pricePrecision":"2", + "quantityPrecision":"6", + "quotePrecision":"6", + "status":"gray", + "minTradeUSDT":"5", + "buyLimitPriceRatio":"0.05", + "sellLimitPriceRatio":"0.05", + "areaSymbol":"no" + }, + { + "symbol":"SPONGEUSDT", + "baseCoin":"SPONGE", + "quoteCoin":"USDT", + "minTradeAmount":"0", + "maxTradeAmount":"10000000000", + "takerFeeRate":"0.001", + "makerFeeRate":"0.001", + "pricePrecision":"8", + "quantityPrecision":"0", + "quotePrecision":"8", + "status":"offline", + "minTradeUSDT":"5", + "buyLimitPriceRatio":"0.1", + "sellLimitPriceRatio":"0.1", + "areaSymbol":"no" + } + ] +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/testdata/get_ticker_request.json b/pkg/exchange/bitget/bitgetapi/v2/testdata/get_ticker_request.json new file mode 100644 index 0000000..99aa581 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/testdata/get_ticker_request.json @@ -0,0 +1,25 @@ +{ + "code":"00000", + "msg":"success", + "requestTime":1709626632870, + "data":[ + { + "open":"64654.54", + "symbol":"BTCUSDT", + "high24h":"68686.93", + "low24h":"64583.42", + "lastPr":"66554.03", + "quoteVolume":"1963568540.741927", + "baseVolume":"29439.351448", + "usdtVolume":"1963568540.74192678", + "ts":"1709626631127", + "bidPr":"66554", + "askPr":"66554.07", + "bidSz":"0.000237", + "askSz":"0.08228", + "openUtc":"68245.32", + "changeUtc24h":"-0.02478", + "change24h":"0.00153" + } + ] +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/testdata/get_tickers_request.json b/pkg/exchange/bitget/bitgetapi/v2/testdata/get_tickers_request.json new file mode 100644 index 0000000..3fd1dc7 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/testdata/get_tickers_request.json @@ -0,0 +1,43 @@ +{ + "code":"00000", + "msg":"success", + "requestTime":1709626632870, + "data":[ + { + "open":"64654.54", + "symbol":"BTCUSDT", + "high24h":"68686.93", + "low24h":"64583.42", + "lastPr":"66554.03", + "quoteVolume":"1963568540.741927", + "baseVolume":"29439.351448", + "usdtVolume":"1963568540.74192678", + "ts":"1709626631127", + "bidPr":"66554", + "askPr":"66554.07", + "bidSz":"0.000237", + "askSz":"0.08228", + "openUtc":"68245.32", + "changeUtc24h":"-0.02478", + "change24h":"0.00153" + }, + { + "open":"3506.6", + "symbol":"ETHUSDT", + "high24h":"3740", + "low24h":"3461.17", + "lastPr":"3686.95", + "quoteVolume":"873897317.8273", + "baseVolume":"243220.866", + "usdtVolume":"873897317.827294", + "ts":"1709626631726", + "bidPr":"3686.94", + "askPr":"3686.98", + "bidSz":"1.0046", + "askSz":"1.0015", + "openUtc":"3627.67", + "changeUtc24h":"0.01634", + "change24h":"0.03572" + } + ] +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/testdata/get_trade_fills_request.json b/pkg/exchange/bitget/bitgetapi/v2/testdata/get_trade_fills_request.json new file mode 100644 index 0000000..97890f1 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/testdata/get_trade_fills_request.json @@ -0,0 +1,87 @@ +{ + "code":"00000", + "msg":"success", + "requestTime":1709886267383, + "data":[ + { + "userId":"8672173294", + "symbol":"APEUSDT", + "orderId":"1149103067745689603", + "tradeId":"1149103068190019665", + "orderType":"market", + "side":"sell", + "priceAvg":"1.9959", + "size":"2.98", + "amount":"5.947782", + "feeDetail":{ + "deduction":"no", + "feeCoin":"USDT", + "totalDeductionFee":"", + "totalFee":"-0.005947782" + }, + "tradeScope":"taker", + "cTime":"1709693441436", + "uTime":"1709693441473" + }, + { + "userId":"8672173294", + "symbol":"APEUSDT", + "orderId":"1149101366691176462", + "tradeId":"1149101368775479371", + "orderType":"limit", + "side":"buy", + "priceAvg":"2.01", + "size":"0.0013", + "amount":"0.002613", + "feeDetail":{ + "deduction":"no", + "feeCoin":"APE", + "totalDeductionFee":"", + "totalFee":"-0.0000013" + }, + "tradeScope":"maker", + "cTime":"1709693036264", + "uTime":"1709693036294" + }, + { + "userId":"8672173294", + "symbol":"APEUSDT", + "orderId":"1149098107519836161", + "tradeId":"1149098107964166145", + "orderType":"market", + "side":"buy", + "priceAvg":"2.0087", + "size":"2.987", + "amount":"5.9999869", + "feeDetail":{ + "deduction":"no", + "feeCoin":"APE", + "totalDeductionFee":"", + "totalFee":"-0.002987" + }, + "tradeScope":"taker", + "cTime":"1709692258826", + "uTime":"1709692258892" + }, + { + "userId":"8672173294", + "symbol":"APEUSDT", + "orderId":"1149096768878354435", + "tradeId":"1149096769322684417", + "orderType":"market", + "side":"sell", + "priceAvg":"2.0068", + "size":"2.9603", + "amount":"5.94073004", + "feeDetail":{ + "deduction":"no", + "feeCoin":"USDT", + "totalDeductionFee":"", + "totalFee":"-0.00594073004" + }, + "tradeScope":"taker", + "cTime":"1709691939669", + "uTime":"1709691939734" + } + ] +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/testdata/get_unfilled_orders_request_limit_order.json b/pkg/exchange/bitget/bitgetapi/v2/testdata/get_unfilled_orders_request_limit_order.json new file mode 100644 index 0000000..7d9f7ee --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/testdata/get_unfilled_orders_request_limit_order.json @@ -0,0 +1,25 @@ +{ + "code":"00000", + "msg":"success", + "requestTime":1709645944336, + "data":[ + { + "userId":"8672173294", + "symbol":"BTCUSDT", + "orderId":"1148903850645331968", + "clientOid":"684a79df-f931-474f-a9a5-f1deab1cd770", + "priceAvg":"66000", + "size":"0.00009", + "orderType":"limit", + "side":"buy", + "status":"live", + "basePrice":"0", + "baseVolume":"0", + "quoteVolume":"0", + "enterPointSource":"API", + "orderSource":"normal", + "cTime":"1709645944272", + "uTime":"1709645944272" + } + ] +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/testdata/get_unfilled_orders_request_market_buy_order.json b/pkg/exchange/bitget/bitgetapi/v2/testdata/get_unfilled_orders_request_market_buy_order.json new file mode 100644 index 0000000..99872d8 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/testdata/get_unfilled_orders_request_market_buy_order.json @@ -0,0 +1,25 @@ +{ + "code":"00000", + "msg":"success", + "requestTime":1709692258790, + "data":[ + { + "userId":"8672173294", + "symbol":"BTCUSDT", + "orderId":"1148903850645331968", + "clientOid":"684a79df-f931-474f-a9a5-f1deab1cd770", + "priceAvg":"0", + "size":"6", + "orderType":"market", + "side":"buy", + "status":"live", + "basePrice":"0", + "baseVolume":"0", + "quoteVolume":"0", + "enterPointSource":"API", + "orderSource":"market", + "cTime":"1709645944272", + "uTime":"1709645944272" + } + ] +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/testdata/get_unfilled_orders_request_market_sell_order.json b/pkg/exchange/bitget/bitgetapi/v2/testdata/get_unfilled_orders_request_market_sell_order.json new file mode 100644 index 0000000..d09393d --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/testdata/get_unfilled_orders_request_market_sell_order.json @@ -0,0 +1,25 @@ +{ + "code":"00000", + "msg":"success", + "requestTime":1709692258790, + "data":[ + { + "userId":"8672173294", + "symbol":"BTCUSDT", + "orderId":"1148903850645331968", + "clientOid":"684a79df-f931-474f-a9a5-f1deab1cd770", + "priceAvg":"0", + "size":"0.00009", + "orderType":"market", + "side":"sell", + "status":"live", + "basePrice":"0", + "baseVolume":"0", + "quoteVolume":"0", + "enterPointSource":"API", + "orderSource":"market", + "cTime":"1709645944272", + "uTime":"1709645944272" + } + ] +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/testdata/place_order_request.json b/pkg/exchange/bitget/bitgetapi/v2/testdata/place_order_request.json new file mode 100644 index 0000000..9170034 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/testdata/place_order_request.json @@ -0,0 +1,9 @@ +{ + "code":"00000", + "msg":"success", + "requestTime":1709645944239, + "data":{ + "orderId":"1148903850645331968", + "clientOid":"684a79df-f931-474f-a9a5-f1deab1cd770" + } +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/testdata/request_error.json b/pkg/exchange/bitget/bitgetapi/v2/testdata/request_error.json new file mode 100644 index 0000000..6bb3bd1 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/testdata/request_error.json @@ -0,0 +1,6 @@ +{ + "code":"40018", + "msg":"Invalid IP", + "requestTime":1709620645267, + "data":[] +} diff --git a/pkg/exchange/bitget/bitgetapi/v2/types.go b/pkg/exchange/bitget/bitgetapi/v2/types.go new file mode 100644 index 0000000..82a9185 --- /dev/null +++ b/pkg/exchange/bitget/bitgetapi/v2/types.go @@ -0,0 +1,42 @@ +package bitgetapi + +type SideType string + +const ( + SideTypeBuy SideType = "buy" + SideTypeSell SideType = "sell" +) + +type OrderType string + +const ( + OrderTypeLimit OrderType = "limit" + OrderTypeMarket OrderType = "market" +) + +type OrderForce string + +const ( + OrderForceGTC OrderForce = "gtc" + OrderForcePostOnly OrderForce = "post_only" + OrderForceFOK OrderForce = "fok" + OrderForceIOC OrderForce = "ioc" +) + +type OrderStatus string + +const ( + OrderStatusInit OrderStatus = "init" + OrderStatusNew OrderStatus = "new" + OrderStatusLive OrderStatus = "live" + OrderStatusPartialFilled OrderStatus = "partially_filled" + OrderStatusFilled OrderStatus = "filled" + OrderStatusCancelled OrderStatus = "cancelled" +) + +func (o OrderStatus) IsWorking() bool { + return o == OrderStatusInit || + o == OrderStatusNew || + o == OrderStatusLive || + o == OrderStatusPartialFilled +} diff --git a/pkg/exchange/bitget/convert.go b/pkg/exchange/bitget/convert.go new file mode 100644 index 0000000..7f26df2 --- /dev/null +++ b/pkg/exchange/bitget/convert.go @@ -0,0 +1,497 @@ +package bitget + +import ( + "errors" + "fmt" + "math" + "strconv" + "time" + + v2 "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bitget/bitgetapi/v2" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func toGlobalBalance(asset v2.AccountAsset) types.Balance { + return types.Balance{ + Currency: asset.Coin, + Available: asset.Available, + Locked: asset.Locked.Add(asset.Frozen), + Borrowed: fixedpoint.Zero, + Interest: fixedpoint.Zero, + NetAsset: fixedpoint.Zero, + MaxWithdrawAmount: fixedpoint.Zero, + } +} + +func toGlobalMarket(s v2.Symbol) types.Market { + if s.Status != v2.SymbolStatusOnline { + log.Warnf("The market symbol status %s is not online: %s", s.Symbol, s.Status) + } + + return types.Market{ + Exchange: types.ExchangeBitget, + Symbol: s.Symbol, + LocalSymbol: s.Symbol, + PricePrecision: s.PricePrecision.Int(), + VolumePrecision: s.QuantityPrecision.Int(), + QuoteCurrency: s.QuoteCoin, + BaseCurrency: s.BaseCoin, + MinNotional: s.MinTradeUSDT, + MinAmount: s.MinTradeUSDT, + MinQuantity: s.MinTradeAmount, + MaxQuantity: s.MaxTradeAmount, + StepSize: fixedpoint.NewFromFloat(1.0 / math.Pow10(s.QuantityPrecision.Int())), + TickSize: fixedpoint.NewFromFloat(1.0 / math.Pow10(s.PricePrecision.Int())), + MinPrice: fixedpoint.Zero, + MaxPrice: fixedpoint.Zero, + } +} + +func toGlobalTicker(ticker v2.Ticker) types.Ticker { + return types.Ticker{ + Time: ticker.Ts.Time(), + Volume: ticker.BaseVolume, + Last: ticker.LastPr, + Open: ticker.Open, + High: ticker.High24H, + Low: ticker.Low24H, + Buy: ticker.BidPr, + Sell: ticker.AskPr, + } +} + +func toGlobalSideType(side v2.SideType) (types.SideType, error) { + switch side { + case v2.SideTypeBuy: + return types.SideTypeBuy, nil + + case v2.SideTypeSell: + return types.SideTypeSell, nil + + default: + return types.SideType(side), fmt.Errorf("unexpected side: %s", side) + } +} + +func toGlobalOrderType(s v2.OrderType) (types.OrderType, error) { + switch s { + case v2.OrderTypeMarket: + return types.OrderTypeMarket, nil + + case v2.OrderTypeLimit: + return types.OrderTypeLimit, nil + + default: + return types.OrderType(s), fmt.Errorf("unexpected order type: %s", s) + } +} + +func toGlobalOrderStatus(status v2.OrderStatus) (types.OrderStatus, error) { + switch status { + case v2.OrderStatusInit, v2.OrderStatusNew, v2.OrderStatusLive: + return types.OrderStatusNew, nil + + case v2.OrderStatusPartialFilled: + return types.OrderStatusPartiallyFilled, nil + + case v2.OrderStatusFilled: + return types.OrderStatusFilled, nil + + case v2.OrderStatusCancelled: + return types.OrderStatusCanceled, nil + + default: + return types.OrderStatus(status), fmt.Errorf("unexpected order status: %s", status) + } +} + +func isMaker(s v2.TradeScope) (bool, error) { + switch s { + case v2.TradeMaker: + return true, nil + + case v2.TradeTaker: + return false, nil + + default: + return false, fmt.Errorf("unexpected trade scope: %s", s) + } +} + +func isFeeDiscount(s v2.DiscountStatus) (bool, error) { + switch s { + case v2.DiscountYes: + return true, nil + + case v2.DiscountNo: + return false, nil + + default: + return false, fmt.Errorf("unexpected discount status: %s", s) + } +} + +func toGlobalTrade(trade v2.Trade) (*types.Trade, error) { + side, err := toGlobalSideType(trade.Side) + if err != nil { + return nil, err + } + + isMaker, err := isMaker(trade.TradeScope) + if err != nil { + return nil, err + } + + isDiscount, err := isFeeDiscount(trade.FeeDetail.Deduction) + if err != nil { + return nil, err + } + + return &types.Trade{ + ID: uint64(trade.TradeId), + OrderID: uint64(trade.OrderId), + Exchange: types.ExchangeBitget, + Price: trade.PriceAvg, + Quantity: trade.Size, + QuoteQuantity: trade.Amount, + Symbol: trade.Symbol, + Side: side, + IsBuyer: side == types.SideTypeBuy, + IsMaker: isMaker, + Time: types.Time(trade.CreatedTime), + Fee: trade.FeeDetail.TotalFee.Abs(), + FeeCurrency: trade.FeeDetail.FeeCoin, + FeeDiscounted: isDiscount, + }, nil +} + +// unfilledOrderToGlobalOrder convert the local order to global. +// +// Note that the quantity unit, according official document: Base coin when orderType=limit; Quote coin when orderType=market +// https://bitgetlimited.github.io/apidoc/zh/spot/#19671a1099 +func unfilledOrderToGlobalOrder(order v2.UnfilledOrder) (*types.Order, error) { + side, err := toGlobalSideType(order.Side) + if err != nil { + return nil, err + } + + orderType, err := toGlobalOrderType(order.OrderType) + if err != nil { + return nil, err + } + + status, err := toGlobalOrderStatus(order.Status) + if err != nil { + return nil, err + } + + qty := order.Size + price := order.PriceAvg + + // 2023/11/05 The market order will be executed immediately, so this check is used to handle corner cases. + // 2024/03/06 After placing a Market Order, we can retrieve it through the unfilledOrder API, so we still need to + // handle the Market Order status. + if orderType == types.OrderTypeMarket { + price = order.PriceAvg + if side == types.SideTypeBuy { + qty, err = processMarketBuyQuantity(order.BaseVolume, order.QuoteVolume, order.PriceAvg, order.Size, order.Status) + if err != nil { + return nil, err + } + } + } + + return &types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: order.ClientOrderId, + Symbol: order.Symbol, + Side: side, + Type: orderType, + Quantity: qty, + Price: price, + + // Bitget does not include the "time-in-force" field in its API response for spot trading, so we set GTC. + TimeInForce: types.TimeInForceGTC, + }, + Exchange: types.ExchangeBitget, + OrderID: uint64(order.OrderId), + UUID: strconv.FormatInt(int64(order.OrderId), 10), + Status: status, + ExecutedQuantity: order.BaseVolume, + IsWorking: order.Status.IsWorking(), + CreationTime: types.Time(order.CreatedTime.Time()), + UpdateTime: types.Time(order.UpdatedTime.Time()), + }, nil +} + +func toGlobalOrder(order v2.OrderDetail) (*types.Order, error) { + side, err := toGlobalSideType(order.Side) + if err != nil { + return nil, err + } + + orderType, err := toGlobalOrderType(order.OrderType) + if err != nil { + return nil, err + } + + status, err := toGlobalOrderStatus(order.Status) + if err != nil { + return nil, err + } + + qty := order.Size + price := order.Price + + if orderType == types.OrderTypeMarket { + price = order.PriceAvg + if side == types.SideTypeBuy { + qty, err = processMarketBuyQuantity(order.BaseVolume, order.QuoteVolume, order.PriceAvg, order.Size, order.Status) + if err != nil { + return nil, err + } + } + } + + return &types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: order.ClientOrderId, + Symbol: order.Symbol, + Side: side, + Type: orderType, + Quantity: qty, + Price: price, + // Bitget does not include the "time-in-force" field in its API response for spot trading, so we set GTC. + TimeInForce: types.TimeInForceGTC, + }, + Exchange: types.ExchangeBitget, + OrderID: uint64(order.OrderId), + UUID: strconv.FormatInt(int64(order.OrderId), 10), + Status: status, + ExecutedQuantity: order.BaseVolume, + IsWorking: order.Status.IsWorking(), + CreationTime: types.Time(order.CreatedTime.Time()), + UpdateTime: types.Time(order.UpdatedTime.Time()), + }, nil +} + +// processMarketBuyQuantity returns the estimated base quantity or real. The order size will be 'quote quantity' when side is buy and +// type is market, so we need to convert that. This is because the unit of types.Order.Quantity is base coin. +// +// If the order status is PartialFilled, return estimated base coin quantity. +// If the order status is Filled, return the filled base quantity instead of the buy quantity, because a market order on the buy side +// cannot execute all. +// Otherwise, return zero. +func processMarketBuyQuantity( + filledQty, filledPrice, priceAvg, buyQty fixedpoint.Value, orderStatus v2.OrderStatus, +) (fixedpoint.Value, error) { + switch orderStatus { + case v2.OrderStatusInit, v2.OrderStatusNew, v2.OrderStatusLive, v2.OrderStatusCancelled: + return fixedpoint.Zero, nil + + case v2.OrderStatusPartialFilled: + // sanity check for avoid divide 0 + if priceAvg.IsZero() { + return fixedpoint.Zero, errors.New("priceAvg for a partialFilled should not be zero") + } + // calculate the remaining quote coin quantity. + remainPrice := buyQty.Sub(filledPrice) + // calculate the remaining base coin quantity. + remainBaseCoinQty := remainPrice.Div(priceAvg) + // Estimated quantity that may be purchased. + return filledQty.Add(remainBaseCoinQty), nil + + case v2.OrderStatusFilled: + // Market buy orders may not purchase the entire quantity, hence the use of filledQty here. + return filledQty, nil + + default: + return fixedpoint.Zero, fmt.Errorf("failed to execute market buy quantity due to unexpected order status %s ", orderStatus) + } +} + +func toLocalOrderType(orderType types.OrderType) (v2.OrderType, error) { + switch orderType { + case types.OrderTypeLimit, types.OrderTypeLimitMaker: + return v2.OrderTypeLimit, nil + + case types.OrderTypeMarket: + return v2.OrderTypeMarket, nil + + default: + return "", fmt.Errorf("order type %s not supported", orderType) + } +} + +func toLocalSide(side types.SideType) (v2.SideType, error) { + switch side { + case types.SideTypeSell: + return v2.SideTypeSell, nil + + case types.SideTypeBuy: + return v2.SideTypeBuy, nil + + default: + return "", fmt.Errorf("side type %s not supported", side) + } +} + +func toGlobalBalanceMap(balances []Balance) types.BalanceMap { + bm := types.BalanceMap{} + for _, obj := range balances { + bm[obj.Coin] = types.Balance{ + Currency: obj.Coin, + Available: obj.Available, + Locked: obj.Frozen.Add(obj.Locked), + } + } + return bm +} + +func toGlobalKLines(symbol string, interval types.Interval, kLines v2.KLineResponse) []types.KLine { + gKLines := make([]types.KLine, len(kLines)) + for i, kline := range kLines { + // follow the binance rule, to avoid endTime overlapping with the next startTime. So we subtract -1 time.Millisecond + // on endTime. + endTime := types.Time(kline.Ts.Time().Add(interval.Duration() - time.Millisecond)) + gKLines[i] = types.KLine{ + Exchange: types.ExchangeBitget, + Symbol: symbol, + StartTime: types.Time(kline.Ts), + EndTime: endTime, + Interval: interval, + Open: kline.Open, + Close: kline.Close, + High: kline.High, + Low: kline.Low, + Volume: kline.Volume, + QuoteVolume: kline.QuoteVolume, + // Bitget doesn't support close flag in REST API + Closed: false, + } + } + return gKLines +} + +func toGlobalTimeInForce(force v2.OrderForce) (types.TimeInForce, error) { + switch force { + case v2.OrderForceFOK: + return types.TimeInForceFOK, nil + + case v2.OrderForceGTC, v2.OrderForcePostOnly: + return types.TimeInForceGTC, nil + + case v2.OrderForceIOC: + return types.TimeInForceIOC, nil + + default: + return "", fmt.Errorf("unexpected time-in-force: %s", force) + } +} + +func (o *Order) processMarketBuyQuantity() (fixedpoint.Value, error) { + switch o.Status { + case v2.OrderStatusLive, v2.OrderStatusNew, v2.OrderStatusInit, v2.OrderStatusCancelled: + return fixedpoint.Zero, nil + + case v2.OrderStatusPartialFilled: + if o.FillPrice.IsZero() { + return fixedpoint.Zero, fmt.Errorf("fillPrice for a partialFilled should not be zero") + } + return o.NewSize.Div(o.FillPrice), nil + + case v2.OrderStatusFilled: + return o.AccBaseVolume, nil + + default: + return fixedpoint.Zero, fmt.Errorf("unexpected status: %s", o.Status) + } +} + +func (o *Order) toGlobalOrder() (types.Order, error) { + side, err := toGlobalSideType(o.Side) + if err != nil { + return types.Order{}, err + } + + orderType, err := toGlobalOrderType(o.OrderType) + if err != nil { + return types.Order{}, err + } + + timeInForce, err := toGlobalTimeInForce(o.Force) + if err != nil { + return types.Order{}, err + } + + status, err := toGlobalOrderStatus(o.Status) + if err != nil { + return types.Order{}, err + } + + qty := o.NewSize + if orderType == types.OrderTypeMarket && side == types.SideTypeBuy { + qty, err = o.processMarketBuyQuantity() + if err != nil { + return types.Order{}, err + } + } + + price := o.Price + if orderType == types.OrderTypeMarket { + price = o.PriceAvg + } + + return types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: o.ClientOrderId, + Symbol: o.InstId, + Side: side, + Type: orderType, + Quantity: qty, + Price: price, + TimeInForce: timeInForce, + }, + Exchange: types.ExchangeBitget, + OrderID: uint64(o.OrderId), + UUID: strconv.FormatInt(int64(o.OrderId), 10), + Status: status, + ExecutedQuantity: o.AccBaseVolume, + IsWorking: o.Status.IsWorking(), + CreationTime: types.Time(o.CreatedTime.Time()), + UpdateTime: types.Time(o.UpdatedTime.Time()), + }, nil +} + +func (o *Order) toGlobalTrade() (types.Trade, error) { + if o.Status != v2.OrderStatusPartialFilled { + return types.Trade{}, fmt.Errorf("failed to convert to global trade, unexpected status: %s", o.Status) + } + + side, err := toGlobalSideType(o.Side) + if err != nil { + return types.Trade{}, err + } + + isMaker, err := o.isMaker() + if err != nil { + return types.Trade{}, err + } + + return types.Trade{ + ID: uint64(o.TradeId), + OrderID: uint64(o.OrderId), + Exchange: types.ExchangeBitget, + Price: o.FillPrice, + Quantity: o.BaseVolume, + QuoteQuantity: o.FillPrice.Mul(o.BaseVolume), + Symbol: o.InstId, + Side: side, + IsBuyer: side == types.SideTypeBuy, + IsMaker: isMaker, + Time: types.Time(o.FillTime), + Fee: o.FillFee.Abs(), + FeeCurrency: o.FillFeeCoin, + }, nil +} diff --git a/pkg/exchange/bitget/convert_test.go b/pkg/exchange/bitget/convert_test.go new file mode 100644 index 0000000..25f3af7 --- /dev/null +++ b/pkg/exchange/bitget/convert_test.go @@ -0,0 +1,1200 @@ +package bitget + +import ( + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + v2 "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bitget/bitgetapi/v2" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func Test_toGlobalBalance(t *testing.T) { + // sample: + // { + // "coinId":"10012", + // "coinName":"usdt", + // "available":"0", + // "frozen":"0", + // "lock":"0", + // "uTime":"1622697148" + // } + asset := v2.AccountAsset{ + Coin: "USDT", + Available: fixedpoint.NewFromFloat(1.2), + Frozen: fixedpoint.NewFromFloat(0.5), + Locked: fixedpoint.NewFromFloat(0.5), + LimitAvailable: fixedpoint.Zero, + UpdatedTime: types.NewMillisecondTimestampFromInt(1622697148), + } + + assert.Equal(t, types.Balance{ + Currency: "USDT", + Available: fixedpoint.NewFromFloat(1.2), + Locked: fixedpoint.NewFromFloat(1), // frozen + lock + Borrowed: fixedpoint.Zero, + Interest: fixedpoint.Zero, + NetAsset: fixedpoint.Zero, + MaxWithdrawAmount: fixedpoint.Zero, + }, toGlobalBalance(asset)) +} + +func Test_toGlobalMarket(t *testing.T) { + // sample: + // { + // "symbol":"BTCUSDT", + // "baseCoin":"BTC", + // "quoteCoin":"USDT", + // "minTradeAmount":"0", + // "maxTradeAmount":"10000000000", + // "takerFeeRate":"0.002", + // "makerFeeRate":"0.002", + // "pricePrecision":"2", + // "quantityPrecision":"4", + // "quotePrecision":"6", + // "status":"online", + // "minTradeUSDT":"5", + // "buyLimitPriceRatio":"0.05", + // "sellLimitPriceRatio":"0.05" + // } + inst := v2.Symbol{ + Symbol: "BTCUSDT", + BaseCoin: "BTC", + QuoteCoin: "USDT", + MinTradeAmount: fixedpoint.NewFromFloat(0), + MaxTradeAmount: fixedpoint.NewFromFloat(10000000000), + TakerFeeRate: fixedpoint.NewFromFloat(0.002), + MakerFeeRate: fixedpoint.NewFromFloat(0.002), + PricePrecision: fixedpoint.NewFromFloat(2), + QuantityPrecision: fixedpoint.NewFromFloat(4), + QuotePrecision: fixedpoint.NewFromFloat(6), + MinTradeUSDT: fixedpoint.NewFromFloat(5), + Status: v2.SymbolStatusOnline, + BuyLimitPriceRatio: fixedpoint.NewFromFloat(0.05), + SellLimitPriceRatio: fixedpoint.NewFromFloat(0.05), + } + + exp := types.Market{ + Exchange: types.ExchangeBitget, + Symbol: inst.Symbol, + LocalSymbol: inst.Symbol, + PricePrecision: 2, + VolumePrecision: 4, + QuoteCurrency: inst.QuoteCoin, + BaseCurrency: inst.BaseCoin, + MinNotional: inst.MinTradeUSDT, + MinAmount: inst.MinTradeUSDT, + MinQuantity: inst.MinTradeAmount, + MaxQuantity: inst.MaxTradeAmount, + StepSize: fixedpoint.NewFromFloat(0.0001), + MinPrice: fixedpoint.Zero, + MaxPrice: fixedpoint.Zero, + TickSize: fixedpoint.NewFromFloat(0.01), + } + + assert.Equal(t, toGlobalMarket(inst), exp) +} + +func Test_toGlobalTicker(t *testing.T) { + // sample: + // { + // "open":"36465.96", + // "symbol":"BTCUSDT", + // "high24h":"37040.25", + // "low24h":"36202.65", + // "lastPr":"36684.42", + // "quoteVolume":"311893591.2805", + // "baseVolume":"8507.3684", + // "usdtVolume":"311893591.280427", + // "ts":"1699947106122", + // "bidPr":"36684.49", + // "askPr":"36684.51", + // "bidSz":"0.3812", + // "askSz":"0.0133", + // "openUtc":"36465.96", + // "changeUtc24h":"0.00599", + // "change24h":"-0.00426" + // } + ticker := v2.Ticker{ + Symbol: "BTCUSDT", + High24H: fixedpoint.NewFromFloat(24175.65), + Low24H: fixedpoint.NewFromFloat(23677.75), + LastPr: fixedpoint.NewFromFloat(24014.11), + QuoteVolume: fixedpoint.NewFromFloat(177689342.3025), + BaseVolume: fixedpoint.NewFromFloat(7421.5009), + UsdtVolume: fixedpoint.NewFromFloat(177689342.302407), + Ts: types.NewMillisecondTimestampFromInt(1660704288118), + BidPr: fixedpoint.NewFromFloat(24013.94), + AskPr: fixedpoint.NewFromFloat(24014.06), + BidSz: fixedpoint.NewFromFloat(0.0663), + AskSz: fixedpoint.NewFromFloat(0.0119), + OpenUtc: fixedpoint.NewFromFloat(23856.72), + ChangeUtc24H: fixedpoint.NewFromFloat(0.00301), + Change24H: fixedpoint.NewFromFloat(0.00069), + Open: fixedpoint.NewFromFloat(23856.72), + } + + assert.Equal(t, types.Ticker{ + Time: types.NewMillisecondTimestampFromInt(1660704288118).Time(), + Volume: fixedpoint.NewFromFloat(7421.5009), + Last: fixedpoint.NewFromFloat(24014.11), + Open: fixedpoint.NewFromFloat(23856.72), + High: fixedpoint.NewFromFloat(24175.65), + Low: fixedpoint.NewFromFloat(23677.75), + Buy: fixedpoint.NewFromFloat(24013.94), + Sell: fixedpoint.NewFromFloat(24014.06), + }, toGlobalTicker(ticker)) +} + +func Test_toGlobalSideType(t *testing.T) { + side, err := toGlobalSideType(v2.SideTypeBuy) + assert.NoError(t, err) + assert.Equal(t, types.SideTypeBuy, side) + + side, err = toGlobalSideType(v2.SideTypeSell) + assert.NoError(t, err) + assert.Equal(t, types.SideTypeSell, side) + + _, err = toGlobalSideType("xxx") + assert.ErrorContains(t, err, "xxx") +} + +func Test_toGlobalOrderType(t *testing.T) { + orderType, err := toGlobalOrderType(v2.OrderTypeMarket) + assert.NoError(t, err) + assert.Equal(t, types.OrderTypeMarket, orderType) + + orderType, err = toGlobalOrderType(v2.OrderTypeLimit) + assert.NoError(t, err) + assert.Equal(t, types.OrderTypeLimit, orderType) + + _, err = toGlobalOrderType("xxx") + assert.ErrorContains(t, err, "xxx") +} + +func Test_toGlobalOrderStatus(t *testing.T) { + status, err := toGlobalOrderStatus(v2.OrderStatusInit) + assert.NoError(t, err) + assert.Equal(t, types.OrderStatusNew, status) + + status, err = toGlobalOrderStatus(v2.OrderStatusNew) + assert.NoError(t, err) + assert.Equal(t, types.OrderStatusNew, status) + + status, err = toGlobalOrderStatus(v2.OrderStatusLive) + assert.NoError(t, err) + assert.Equal(t, types.OrderStatusNew, status) + + status, err = toGlobalOrderStatus(v2.OrderStatusFilled) + assert.NoError(t, err) + assert.Equal(t, types.OrderStatusFilled, status) + + status, err = toGlobalOrderStatus(v2.OrderStatusPartialFilled) + assert.NoError(t, err) + assert.Equal(t, types.OrderStatusPartiallyFilled, status) + + status, err = toGlobalOrderStatus(v2.OrderStatusCancelled) + assert.NoError(t, err) + assert.Equal(t, types.OrderStatusCanceled, status) + + _, err = toGlobalOrderStatus("xxx") + assert.ErrorContains(t, err, "xxx") +} + +func Test_unfilledOrderToGlobalOrder(t *testing.T) { + var ( + assert = assert.New(t) + orderId = 1105087175647989764 + unfilledOrder = v2.UnfilledOrder{ + Symbol: "BTCUSDT", + OrderId: types.StrInt64(orderId), + ClientOrderId: "74b86af3-6098-479c-acac-bfb074c067f3", + PriceAvg: fixedpoint.NewFromFloat(1.2), + Size: fixedpoint.NewFromFloat(5), + OrderType: v2.OrderTypeLimit, + Side: v2.SideTypeBuy, + Status: v2.OrderStatusLive, + BasePrice: fixedpoint.NewFromFloat(0), + BaseVolume: fixedpoint.NewFromFloat(0), + QuoteVolume: fixedpoint.NewFromFloat(0), + EnterPointSource: "API", + OrderSource: "normal", + CreatedTime: types.NewMillisecondTimestampFromInt(1660704288118), + UpdatedTime: types.NewMillisecondTimestampFromInt(1660704288118), + } + ) + + t.Run("succeeds with limit order", func(t *testing.T) { + order, err := unfilledOrderToGlobalOrder(unfilledOrder) + assert.NoError(err) + assert.Equal(&types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: "74b86af3-6098-479c-acac-bfb074c067f3", + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: fixedpoint.NewFromFloat(5), + Price: fixedpoint.NewFromFloat(1.2), + TimeInForce: types.TimeInForceGTC, + }, + Exchange: types.ExchangeBitget, + OrderID: uint64(orderId), + UUID: strconv.FormatInt(int64(orderId), 10), + Status: types.OrderStatusNew, + ExecutedQuantity: fixedpoint.NewFromFloat(0), + IsWorking: true, + CreationTime: types.Time(types.NewMillisecondTimestampFromInt(1660704288118).Time()), + UpdateTime: types.Time(types.NewMillisecondTimestampFromInt(1660704288118).Time()), + }, order) + }) + + t.Run("succeeds with market buy order", func(t *testing.T) { + unfilledOrder2 := unfilledOrder + unfilledOrder2.OrderType = v2.OrderTypeMarket + unfilledOrder2.Side = v2.SideTypeBuy + unfilledOrder2.Size = unfilledOrder2.PriceAvg.Mul(unfilledOrder2.Size) + unfilledOrder2.PriceAvg = fixedpoint.Zero + unfilledOrder2.Status = v2.OrderStatusNew + + order, err := unfilledOrderToGlobalOrder(unfilledOrder2) + assert.NoError(err) + assert.Equal(&types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: "74b86af3-6098-479c-acac-bfb074c067f3", + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + Type: types.OrderTypeMarket, + Quantity: fixedpoint.Zero, + Price: fixedpoint.Zero, + TimeInForce: types.TimeInForceGTC, + }, + Exchange: types.ExchangeBitget, + OrderID: uint64(orderId), + UUID: strconv.FormatInt(int64(orderId), 10), + Status: types.OrderStatusNew, + ExecutedQuantity: fixedpoint.Zero, + IsWorking: true, + CreationTime: types.Time(types.NewMillisecondTimestampFromInt(1660704288118).Time()), + UpdateTime: types.Time(types.NewMillisecondTimestampFromInt(1660704288118).Time()), + }, order) + }) + + t.Run("succeeds with market sell order", func(t *testing.T) { + unfilledOrder2 := unfilledOrder + unfilledOrder2.OrderType = v2.OrderTypeMarket + unfilledOrder2.Side = v2.SideTypeSell + unfilledOrder2.PriceAvg = fixedpoint.Zero + unfilledOrder2.Status = v2.OrderStatusNew + + order, err := unfilledOrderToGlobalOrder(unfilledOrder2) + assert.NoError(err) + assert.Equal(&types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: "74b86af3-6098-479c-acac-bfb074c067f3", + Symbol: "BTCUSDT", + Side: types.SideTypeSell, + Type: types.OrderTypeMarket, + Quantity: fixedpoint.NewFromInt(5), + Price: fixedpoint.Zero, + TimeInForce: types.TimeInForceGTC, + }, + Exchange: types.ExchangeBitget, + OrderID: uint64(orderId), + UUID: strconv.FormatInt(int64(orderId), 10), + Status: types.OrderStatusNew, + ExecutedQuantity: fixedpoint.Zero, + IsWorking: true, + CreationTime: types.Time(types.NewMillisecondTimestampFromInt(1660704288118).Time()), + UpdateTime: types.Time(types.NewMillisecondTimestampFromInt(1660704288118).Time()), + }, order) + }) + + t.Run("failed to convert side", func(t *testing.T) { + newOrder := unfilledOrder + newOrder.Side = "xxx" + + _, err := unfilledOrderToGlobalOrder(newOrder) + assert.ErrorContains(err, "xxx") + }) + + t.Run("failed to convert oder type", func(t *testing.T) { + newOrder := unfilledOrder + newOrder.OrderType = "xxx" + + _, err := unfilledOrderToGlobalOrder(newOrder) + assert.ErrorContains(err, "xxx") + }) + + t.Run("failed to convert oder status", func(t *testing.T) { + newOrder := unfilledOrder + newOrder.Status = "xxx" + + _, err := unfilledOrderToGlobalOrder(newOrder) + assert.ErrorContains(err, "xxx") + }) +} + +func Test_toGlobalOrder(t *testing.T) { + var ( + assert = assert.New(t) + orderId = 1105087175647989764 + unfilledOrder = v2.OrderDetail{ + UserId: 123456, + Symbol: "BTCUSDT", + OrderId: types.StrInt64(orderId), + ClientOrderId: "74b86af3-6098-479c-acac-bfb074c067f3", + Price: fixedpoint.NewFromFloat(1.2), + Size: fixedpoint.NewFromFloat(5), + OrderType: v2.OrderTypeLimit, + Side: v2.SideTypeBuy, + Status: v2.OrderStatusFilled, + PriceAvg: fixedpoint.NewFromFloat(1.4), + BaseVolume: fixedpoint.NewFromFloat(5), + QuoteVolume: fixedpoint.NewFromFloat(7.0005), + EnterPointSource: "API", + FeeDetailRaw: `{\"newFees\":{\"c\":0,\"d\":0,\"deduction\":false,\"r\":-0.0070005,\"t\":-0.0070005,\"totalDeductionFee\":0},\"USDT\":{\"deduction\":false,\"feeCoinCode\":\"USDT\",\"totalDeductionFee\":0,\"totalFee\":-0.007000500000}}`, + OrderSource: "normal", + CreatedTime: types.NewMillisecondTimestampFromInt(1660704288118), + UpdatedTime: types.NewMillisecondTimestampFromInt(1660704288118), + } + + expOrder = &types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: "74b86af3-6098-479c-acac-bfb074c067f3", + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: fixedpoint.NewFromFloat(5), + Price: fixedpoint.NewFromFloat(1.2), + TimeInForce: types.TimeInForceGTC, + }, + Exchange: types.ExchangeBitget, + OrderID: uint64(orderId), + UUID: strconv.FormatInt(int64(orderId), 10), + Status: types.OrderStatusFilled, + ExecutedQuantity: fixedpoint.NewFromFloat(5), + IsWorking: false, + CreationTime: types.Time(types.NewMillisecondTimestampFromInt(1660704288118).Time()), + UpdateTime: types.Time(types.NewMillisecondTimestampFromInt(1660704288118).Time()), + } + ) + + t.Run("succeeds with limit buy", func(t *testing.T) { + order, err := toGlobalOrder(unfilledOrder) + assert.NoError(err) + assert.Equal(expOrder, order) + }) + + t.Run("succeeds with limit sell", func(t *testing.T) { + newUnfilledOrder := unfilledOrder + newUnfilledOrder.Side = v2.SideTypeSell + + newExpOrder := *expOrder + newExpOrder.Side = types.SideTypeSell + + order, err := toGlobalOrder(newUnfilledOrder) + assert.NoError(err) + assert.Equal(&newExpOrder, order) + }) + + t.Run("succeeds with market sell", func(t *testing.T) { + newUnfilledOrder := unfilledOrder + newUnfilledOrder.Side = v2.SideTypeSell + newUnfilledOrder.OrderType = v2.OrderTypeMarket + + newExpOrder := *expOrder + newExpOrder.Side = types.SideTypeSell + newExpOrder.Type = types.OrderTypeMarket + newExpOrder.Price = newUnfilledOrder.PriceAvg + + order, err := toGlobalOrder(newUnfilledOrder) + assert.NoError(err) + assert.Equal(&newExpOrder, order) + }) + + t.Run("succeeds with market buy", func(t *testing.T) { + newUnfilledOrder := unfilledOrder + newUnfilledOrder.Side = v2.SideTypeBuy + newUnfilledOrder.OrderType = v2.OrderTypeMarket + + newExpOrder := *expOrder + newExpOrder.Side = types.SideTypeBuy + newExpOrder.Type = types.OrderTypeMarket + newExpOrder.Price = newUnfilledOrder.PriceAvg + newExpOrder.Quantity = newUnfilledOrder.BaseVolume + + order, err := toGlobalOrder(newUnfilledOrder) + assert.NoError(err) + assert.Equal(&newExpOrder, order) + }) + + t.Run("succeeds with limit buy", func(t *testing.T) { + order, err := toGlobalOrder(unfilledOrder) + assert.NoError(err) + assert.Equal(&types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: "74b86af3-6098-479c-acac-bfb074c067f3", + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: fixedpoint.NewFromFloat(5), + Price: fixedpoint.NewFromFloat(1.2), + TimeInForce: types.TimeInForceGTC, + }, + Exchange: types.ExchangeBitget, + OrderID: uint64(orderId), + UUID: strconv.FormatInt(int64(orderId), 10), + Status: types.OrderStatusFilled, + ExecutedQuantity: fixedpoint.NewFromFloat(5), + IsWorking: false, + CreationTime: types.Time(types.NewMillisecondTimestampFromInt(1660704288118).Time()), + UpdateTime: types.Time(types.NewMillisecondTimestampFromInt(1660704288118).Time()), + }, order) + }) + + t.Run("failed to convert side", func(t *testing.T) { + newOrder := unfilledOrder + newOrder.Side = "xxx" + + _, err := toGlobalOrder(newOrder) + assert.ErrorContains(err, "xxx") + }) + + t.Run("failed to convert oder type", func(t *testing.T) { + newOrder := unfilledOrder + newOrder.OrderType = "xxx" + + _, err := toGlobalOrder(newOrder) + assert.ErrorContains(err, "xxx") + }) + + t.Run("failed to convert oder status", func(t *testing.T) { + newOrder := unfilledOrder + newOrder.Status = "xxx" + + _, err := toGlobalOrder(newOrder) + assert.ErrorContains(err, "xxx") + }) +} + +func Test_processMarketBuyQuantity(t *testing.T) { + var ( + assert = assert.New(t) + filledBaseCoinQty = fixedpoint.NewFromFloat(3.5648) + filledPrice = fixedpoint.NewFromFloat(4.99998848) + priceAvg = fixedpoint.NewFromFloat(1.4026) + buyQty = fixedpoint.NewFromFloat(5) + ) + + t.Run("zero quantity on Init/New/Live/Cancelled", func(t *testing.T) { + qty, err := processMarketBuyQuantity(filledBaseCoinQty, filledPrice, priceAvg, buyQty, v2.OrderStatusInit) + assert.NoError(err) + assert.Equal(fixedpoint.Zero, qty) + + qty, err = processMarketBuyQuantity(filledBaseCoinQty, filledPrice, priceAvg, buyQty, v2.OrderStatusNew) + assert.NoError(err) + assert.Equal(fixedpoint.Zero, qty) + + qty, err = processMarketBuyQuantity(filledBaseCoinQty, filledPrice, priceAvg, buyQty, v2.OrderStatusLive) + assert.NoError(err) + assert.Equal(fixedpoint.Zero, qty) + + qty, err = processMarketBuyQuantity(filledBaseCoinQty, filledPrice, priceAvg, buyQty, v2.OrderStatusCancelled) + assert.NoError(err) + assert.Equal(fixedpoint.Zero, qty) + }) + + t.Run("5 on PartialFilled", func(t *testing.T) { + priceAvg := fixedpoint.NewFromFloat(2) + buyQty := fixedpoint.NewFromFloat(10) + filledPrice := fixedpoint.NewFromFloat(4) + filledBaseCoinQty := fixedpoint.NewFromFloat(2) + + qty, err := processMarketBuyQuantity(filledBaseCoinQty, filledPrice, priceAvg, buyQty, v2.OrderStatusPartialFilled) + assert.NoError(err) + assert.Equal(fixedpoint.NewFromFloat(5), qty) + }) + + t.Run("3.5648 on Filled", func(t *testing.T) { + qty, err := processMarketBuyQuantity(filledBaseCoinQty, filledPrice, priceAvg, buyQty, v2.OrderStatusFilled) + assert.NoError(err) + assert.Equal(fixedpoint.NewFromFloat(3.5648), qty) + }) + + t.Run("unexpected order status", func(t *testing.T) { + _, err := processMarketBuyQuantity(filledBaseCoinQty, filledPrice, priceAvg, buyQty, "xxx") + assert.ErrorContains(err, "xxx") + }) +} + +func Test_toLocalOrderType(t *testing.T) { + orderType, err := toLocalOrderType(types.OrderTypeLimit) + assert.NoError(t, err) + assert.Equal(t, v2.OrderTypeLimit, orderType) + + orderType, err = toLocalOrderType(types.OrderTypeMarket) + assert.NoError(t, err) + assert.Equal(t, v2.OrderTypeMarket, orderType) + + _, err = toLocalOrderType("xxx") + assert.ErrorContains(t, err, "xxx") +} + +func Test_toLocalSide(t *testing.T) { + orderType, err := toLocalSide(types.SideTypeSell) + assert.NoError(t, err) + assert.Equal(t, v2.SideTypeSell, orderType) + + orderType, err = toLocalSide(types.SideTypeBuy) + assert.NoError(t, err) + assert.Equal(t, v2.SideTypeBuy, orderType) + + _, err = toLocalOrderType("xxx") + assert.ErrorContains(t, err, "xxx") +} + +func Test_isMaker(t *testing.T) { + isM, err := isMaker(v2.TradeTaker) + assert.NoError(t, err) + assert.False(t, isM) + + isM, err = isMaker(v2.TradeMaker) + assert.NoError(t, err) + assert.True(t, isM) + + _, err = isMaker("xxx") + assert.ErrorContains(t, err, "xxx") +} + +func Test_isFeeDiscount(t *testing.T) { + isDiscount, err := isFeeDiscount(v2.DiscountNo) + assert.NoError(t, err) + assert.False(t, isDiscount) + + isDiscount, err = isFeeDiscount(v2.DiscountYes) + assert.NoError(t, err) + assert.True(t, isDiscount) + + _, err = isFeeDiscount("xxx") + assert.ErrorContains(t, err, "xxx") +} + +func Test_toGlobalTrade(t *testing.T) { + // { + // "userId":"8672173294", + // "symbol":"APEUSDT", + // "orderId":"1104337778433757184", + // "tradeId":"1104337778504044545", + // "orderType":"limit", + // "side":"sell", + // "priceAvg":"1.4001", + // "newSize":"5", + // "amount":"7.0005", + // "feeDetail":{ + // "deduction":"no", + // "feeCoin":"USDT", + // "totalDeductionFee":"", + // "totalFee":"-0.0070005" + // }, + // "tradeScope":"taker", + // "cTime":"1699020564676", + // "uTime":"1699020564687" + // } + trade := v2.Trade{ + UserId: types.StrInt64(8672173294), + Symbol: "APEUSDT", + OrderId: types.StrInt64(1104337778433757184), + TradeId: types.StrInt64(1104337778504044545), + OrderType: v2.OrderTypeLimit, + Side: v2.SideTypeSell, + PriceAvg: fixedpoint.NewFromFloat(1.4001), + Size: fixedpoint.NewFromFloat(5), + Amount: fixedpoint.NewFromFloat(7.0005), + FeeDetail: v2.TradeFee{ + Deduction: "no", + FeeCoin: "USDT", + TotalDeductionFee: fixedpoint.Zero, + TotalFee: fixedpoint.NewFromFloat(-0.0070005), + }, + TradeScope: v2.TradeTaker, + CreatedTime: types.NewMillisecondTimestampFromInt(1699020564676), + UpdatedTime: types.NewMillisecondTimestampFromInt(1699020564687), + } + + res, err := toGlobalTrade(trade) + assert.NoError(t, err) + assert.Equal(t, &types.Trade{ + ID: uint64(1104337778504044545), + OrderID: uint64(1104337778433757184), + Exchange: types.ExchangeBitget, + Price: fixedpoint.NewFromFloat(1.4001), + Quantity: fixedpoint.NewFromFloat(5), + QuoteQuantity: fixedpoint.NewFromFloat(7.0005), + Symbol: "APEUSDT", + Side: types.SideTypeSell, + IsBuyer: false, + IsMaker: false, + Time: types.Time(types.NewMillisecondTimestampFromInt(1699020564676)), + Fee: fixedpoint.NewFromFloat(0.0070005), + FeeCurrency: "USDT", + FeeDiscounted: false, + }, res) +} + +func Test_toGlobalBalanceMap(t *testing.T) { + assert.Equal(t, types.BalanceMap{ + "BTC": { + Currency: "BTC", + Available: fixedpoint.NewFromFloat(0.5), + Locked: fixedpoint.NewFromFloat(0.6 + 0.7), + }, + }, toGlobalBalanceMap([]Balance{ + { + Coin: "BTC", + Available: fixedpoint.NewFromFloat(0.5), + Frozen: fixedpoint.NewFromFloat(0.6), + Locked: fixedpoint.NewFromFloat(0.7), + LimitAvailable: fixedpoint.Zero, + UpdatedTime: types.NewMillisecondTimestampFromInt(1699020564676), + }, + })) +} + +func Test_toGlobalKLines(t *testing.T) { + symbol := "BTCUSDT" + interval := types.Interval15m + + resp := v2.KLineResponse{ + /* + [ + { + "Ts": "1699816800000", + "OpenPrice": 29045.3, + "HighPrice": 29228.56, + "LowPrice": 29045.3, + "ClosePrice": 29228.56, + "Volume": 9.265593, + "QuoteVolume": 270447.43520753, + "UsdtVolume": 270447.43520753 + }, + { + "Ts": "1699816800000", + "OpenPrice": 29167.33, + "HighPrice": 29229.08, + "LowPrice": 29000, + "ClosePrice": 29045.3, + "Volume": 9.295508, + "QuoteVolume": 270816.87513775, + "UsdtVolume": 270816.87513775 + } + ] + */ + { + Ts: types.NewMillisecondTimestampFromInt(1691486100000), + Open: fixedpoint.NewFromFloat(29045.3), + High: fixedpoint.NewFromFloat(29228.56), + Low: fixedpoint.NewFromFloat(29045.3), + Close: fixedpoint.NewFromFloat(29228.56), + Volume: fixedpoint.NewFromFloat(9.265593), + QuoteVolume: fixedpoint.NewFromFloat(270447.43520753), + UsdtVolume: fixedpoint.NewFromFloat(270447.43520753), + }, + { + Ts: types.NewMillisecondTimestampFromInt(1691487000000), + Open: fixedpoint.NewFromFloat(29167.33), + High: fixedpoint.NewFromFloat(29229.08), + Low: fixedpoint.NewFromFloat(29000), + Close: fixedpoint.NewFromFloat(29045.3), + Volume: fixedpoint.NewFromFloat(9.295508), + QuoteVolume: fixedpoint.NewFromFloat(270816.87513775), + UsdtVolume: fixedpoint.NewFromFloat(270447.43520753), + }, + } + + expKlines := []types.KLine{ + { + Exchange: types.ExchangeBitget, + Symbol: symbol, + StartTime: types.Time(resp[0].Ts.Time()), + EndTime: types.Time(resp[0].Ts.Time().Add(interval.Duration() - time.Millisecond)), + Interval: interval, + Open: fixedpoint.NewFromFloat(29045.3), + Close: fixedpoint.NewFromFloat(29228.56), + High: fixedpoint.NewFromFloat(29228.56), + Low: fixedpoint.NewFromFloat(29045.3), + Volume: fixedpoint.NewFromFloat(9.265593), + QuoteVolume: fixedpoint.NewFromFloat(270447.43520753), + Closed: false, + }, + { + Exchange: types.ExchangeBitget, + Symbol: symbol, + StartTime: types.Time(resp[1].Ts.Time()), + EndTime: types.Time(resp[1].Ts.Time().Add(interval.Duration() - time.Millisecond)), + Interval: interval, + Open: fixedpoint.NewFromFloat(29167.33), + Close: fixedpoint.NewFromFloat(29045.3), + High: fixedpoint.NewFromFloat(29229.08), + Low: fixedpoint.NewFromFloat(29000), + Volume: fixedpoint.NewFromFloat(9.295508), + QuoteVolume: fixedpoint.NewFromFloat(270816.87513775), + Closed: false, + }, + } + + assert.Equal(t, toGlobalKLines(symbol, interval, resp), expKlines) +} + +func Test_toGlobalTimeInForce(t *testing.T) { + force, err := toGlobalTimeInForce(v2.OrderForceFOK) + assert.NoError(t, err) + assert.Equal(t, types.TimeInForceFOK, force) + + force, err = toGlobalTimeInForce(v2.OrderForceGTC) + assert.NoError(t, err) + assert.Equal(t, types.TimeInForceGTC, force) + + force, err = toGlobalTimeInForce(v2.OrderForcePostOnly) + assert.NoError(t, err) + assert.Equal(t, types.TimeInForceGTC, force) + + force, err = toGlobalTimeInForce(v2.OrderForceIOC) + assert.NoError(t, err) + assert.Equal(t, types.TimeInForceIOC, force) + + _, err = toGlobalTimeInForce("xxx") + assert.ErrorContains(t, err, "xxx") +} + +func TestOrder_processMarketBuyQuantity(t *testing.T) { + t.Run("zero qty", func(t *testing.T) { + o := Order{} + for _, s := range []v2.OrderStatus{v2.OrderStatusLive, v2.OrderStatusNew, v2.OrderStatusInit, v2.OrderStatusCancelled} { + o.Status = s + qty, err := o.processMarketBuyQuantity() + assert.NoError(t, err) + assert.Equal(t, fixedpoint.Zero, qty) + } + }) + + t.Run("calculate qty", func(t *testing.T) { + o := Order{ + NewSize: fixedpoint.NewFromFloat(2), + Trade: Trade{ + FillPrice: fixedpoint.NewFromFloat(1), + }, + Status: v2.OrderStatusPartialFilled, + } + qty, err := o.processMarketBuyQuantity() + assert.NoError(t, err) + assert.Equal(t, fixedpoint.NewFromFloat(2), qty) + }) + + t.Run("return accumulated balance", func(t *testing.T) { + o := Order{ + AccBaseVolume: fixedpoint.NewFromFloat(5), + Status: v2.OrderStatusFilled, + } + qty, err := o.processMarketBuyQuantity() + assert.NoError(t, err) + assert.Equal(t, fixedpoint.NewFromFloat(5), qty) + }) + + t.Run("unexpected status", func(t *testing.T) { + o := Order{ + Status: "xxx", + } + _, err := o.processMarketBuyQuantity() + assert.ErrorContains(t, err, "xxx") + }) +} + +func TestOrder_toGlobalOrder(t *testing.T) { + o := Order{ + Trade: Trade{ + FillPrice: fixedpoint.NewFromFloat(0.49016), + TradeId: types.StrInt64(1107950490073112582), + BaseVolume: fixedpoint.NewFromFloat(33.6558), + FillTime: types.NewMillisecondTimestampFromInt(1699881902235), + FillFee: fixedpoint.NewFromFloat(-0.0336558), + FillFeeCoin: "BGB", + TradeScope: "T", + }, + InstId: "BGBUSDT", + OrderId: types.StrInt64(1107950489998626816), + ClientOrderId: "cc73aab9-1e44-4022-8458-60d8c6a08753", + NewSize: fixedpoint.NewFromFloat(39.0), + Notional: fixedpoint.NewFromFloat(39.0), + OrderType: v2.OrderTypeMarket, + Force: v2.OrderForceGTC, + Side: v2.SideTypeBuy, + AccBaseVolume: fixedpoint.NewFromFloat(33.6558), + PriceAvg: fixedpoint.NewFromFloat(0.49016), + Status: v2.OrderStatusPartialFilled, + CreatedTime: types.NewMillisecondTimestampFromInt(1699881902217), + UpdatedTime: types.NewMillisecondTimestampFromInt(1699881902248), + FeeDetail: nil, + EnterPointSource: "API", + } + + // market buy example: + // { + // "instId":"BGBUSDT", + // "orderId":"1107950489998626816", + // "clientOid":"cc73aab9-1e44-4022-8458-60d8c6a08753", + // "newSize":"39.0000", + // "notional":"39.000000", + // "orderType":"market", + // "force":"gtc", + // "side":"buy", + // "fillPrice":"0.49016", + // "tradeId":"1107950490073112582", + // "baseVolume":"33.6558", + // "fillTime":"1699881902235", + // "fillFee":"-0.0336558", + // "fillFeeCoin":"BGB", + // "tradeScope":"T", + // "accBaseVolume":"33.6558", + // "priceAvg":"0.49016", + // "status":"partially_filled", + // "cTime":"1699881902217", + // "uTime":"1699881902248", + // "feeDetail":[ + // { + // "feeCoin":"BGB", + // "fee":"-0.0336558" + // } + // ], + // "enterPointSource":"API" + // } + t.Run("market buy", func(t *testing.T) { + newO := o + res, err := newO.toGlobalOrder() + assert.NoError(t, err) + assert.Equal(t, types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: "cc73aab9-1e44-4022-8458-60d8c6a08753", + Symbol: "BGBUSDT", + Side: types.SideTypeBuy, + Type: types.OrderTypeMarket, + Quantity: newO.NewSize.Div(newO.FillPrice), + Price: newO.PriceAvg, + TimeInForce: types.TimeInForceGTC, + }, + Exchange: types.ExchangeBitget, + OrderID: uint64(newO.OrderId), + UUID: strconv.FormatInt(int64(newO.OrderId), 10), + Status: types.OrderStatusPartiallyFilled, + ExecutedQuantity: newO.AccBaseVolume, + IsWorking: newO.Status.IsWorking(), + CreationTime: types.Time(newO.CreatedTime), + UpdateTime: types.Time(newO.UpdatedTime), + }, res) + }) + + // market sell example: + // { + // "instId":"BGBUSDT", + // "orderId":"1107940456212631553", + // "clientOid":"088bb971-858e-48e2-b503-85c3274edd89", + // "newSize":"285.0000", + // "orderType":"market", + // "force":"gtc", + // "side":"sell", + // "fillPrice":"0.48706", + // "tradeId":"1107940456278728706", + // "baseVolume":"22.5840", + // "fillTime":"1699879509992", + // "fillFee":"-0.01099976304", + // "fillFeeCoin":"USDT", + // "tradeScope":"T", + // "accBaseVolume":"45.1675", + // "priceAvg":"0.48706", + // "status":"partially_filled", + // "cTime":"1699879509976", + // "uTime":"1699879510007", + // "feeDetail":[ + // { + // "feeCoin":"USDT", + // "fee":"-0.02199928255" + // } + // ], + // "enterPointSource":"API" + // } + t.Run("market sell", func(t *testing.T) { + newO := o + newO.OrderType = v2.OrderTypeMarket + newO.Side = v2.SideTypeSell + + res, err := newO.toGlobalOrder() + assert.NoError(t, err) + assert.Equal(t, types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: "cc73aab9-1e44-4022-8458-60d8c6a08753", + Symbol: "BGBUSDT", + Side: types.SideTypeSell, + Type: types.OrderTypeMarket, + Quantity: newO.NewSize, + Price: newO.PriceAvg, + TimeInForce: types.TimeInForceGTC, + }, + Exchange: types.ExchangeBitget, + OrderID: uint64(newO.OrderId), + UUID: strconv.FormatInt(int64(newO.OrderId), 10), + Status: types.OrderStatusPartiallyFilled, + ExecutedQuantity: newO.AccBaseVolume, + IsWorking: newO.Status.IsWorking(), + CreationTime: types.Time(newO.CreatedTime), + UpdateTime: types.Time(newO.UpdatedTime), + }, res) + }) + + // limit buy example: + // { + // "instId":"BGBUSDT", + // "orderId":"1107955329902481408", + // "clientOid":"c578164a-bf34-44ba-8bb7-a1538f33b1b8", + // "price":"0.49998", + // "newSize":"24.9990", + // "notional":"24.999000", + // "orderType":"limit", + // "force":"gtc", + // "side":"buy", + // "fillPrice":"0.49998", + // "tradeId":"1107955401758285828", + // "baseVolume":"15.9404", + // "fillTime":"1699883073272", + // "fillFee":"-0.0159404", + // "fillFeeCoin":"BGB", + // "tradeScope":"M", + // "accBaseVolume":"15.9404", + // "priceAvg":"0.49998", + // "status":"partially_filled", + // "cTime":"1699883056140", + // "uTime":"1699883073285", + // "feeDetail":[ + // { + // "feeCoin":"BGB", + // "fee":"-0.0159404" + // } + // ], + // "enterPointSource":"API" + // } + t.Run("limit buy", func(t *testing.T) { + newO := o + newO.Price = fixedpoint.NewFromFloat(0.49998) + newO.OrderType = v2.OrderTypeLimit + + res, err := newO.toGlobalOrder() + assert.NoError(t, err) + assert.Equal(t, types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: "cc73aab9-1e44-4022-8458-60d8c6a08753", + Symbol: "BGBUSDT", + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: newO.NewSize, + Price: newO.Price, + TimeInForce: types.TimeInForceGTC, + }, + Exchange: types.ExchangeBitget, + OrderID: uint64(newO.OrderId), + UUID: strconv.FormatInt(int64(newO.OrderId), 10), + Status: types.OrderStatusPartiallyFilled, + ExecutedQuantity: newO.AccBaseVolume, + IsWorking: newO.Status.IsWorking(), + CreationTime: types.Time(newO.CreatedTime), + UpdateTime: types.Time(newO.UpdatedTime), + }, res) + }) + + // limit sell example: + // { + // "instId":"BGBUSDT", + // "orderId":"1107936497259417600", + // "clientOid":"02d4592e-091c-4b5a-aef3-6a7cf57b5e82", + // "price":"0.48710", + // "newSize":"280.0000", + // "orderType":"limit", + // "force":"gtc", + // "side":"sell", + // "fillPrice":"0.48710", + // "tradeId":"1107937053540556809", + // "baseVolume":"41.0593", + // "fillTime":"1699878698716", + // "fillFee":"-0.01999998503", + // "fillFeeCoin":"USDT", + // "tradeScope":"M", + // "accBaseVolume":"146.3209", + // "priceAvg":"0.48710", + // "status":"partially_filled", + // "cTime":"1699878566088", + // "uTime":"1699878698746", + // "feeDetail":[ + // { + // "feeCoin":"USDT", + // "fee":"-0.07127291039" + // } + // ], + // "enterPointSource":"API" + // } + t.Run("limit sell", func(t *testing.T) { + newO := o + newO.OrderType = v2.OrderTypeLimit + newO.Side = v2.SideTypeSell + newO.Price = fixedpoint.NewFromFloat(0.48710) + + res, err := newO.toGlobalOrder() + assert.NoError(t, err) + assert.Equal(t, types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: "cc73aab9-1e44-4022-8458-60d8c6a08753", + Symbol: "BGBUSDT", + Side: types.SideTypeSell, + Type: types.OrderTypeLimit, + Quantity: newO.NewSize, + Price: newO.Price, + TimeInForce: types.TimeInForceGTC, + }, + Exchange: types.ExchangeBitget, + OrderID: uint64(newO.OrderId), + UUID: strconv.FormatInt(int64(newO.OrderId), 10), + Status: types.OrderStatusPartiallyFilled, + ExecutedQuantity: newO.AccBaseVolume, + IsWorking: newO.Status.IsWorking(), + CreationTime: types.Time(newO.CreatedTime), + UpdateTime: types.Time(newO.UpdatedTime), + }, res) + }) + + t.Run("unexpected status", func(t *testing.T) { + newO := o + newO.Status = "xxx" + _, err := newO.toGlobalOrder() + assert.ErrorContains(t, err, "xxx") + }) + + t.Run("unexpected time-in-force", func(t *testing.T) { + newO := o + newO.Force = "xxx" + _, err := newO.toGlobalOrder() + assert.ErrorContains(t, err, "xxx") + }) + + t.Run("unexpected order type", func(t *testing.T) { + newO := o + newO.OrderType = "xxx" + _, err := newO.toGlobalOrder() + assert.ErrorContains(t, err, "xxx") + }) + + t.Run("unexpected side", func(t *testing.T) { + newO := o + newO.Side = "xxx" + _, err := newO.toGlobalOrder() + assert.ErrorContains(t, err, "xxx") + }) +} + +func TestOrder_toGlobalTrade(t *testing.T) { + // market buy example: + // { + // "instId":"BGBUSDT", + // "orderId":"1107950489998626816", + // "clientOid":"cc73aab9-1e44-4022-8458-60d8c6a08753", + // "newSize":"39.0000", + // "notional":"39.000000", + // "orderType":"market", + // "force":"gtc", + // "side":"buy", + // "fillPrice":"0.49016", + // "tradeId":"1107950490073112582", + // "baseVolume":"33.6558", + // "fillTime":"1699881902235", + // "fillFee":"-0.0336558", + // "fillFeeCoin":"BGB", + // "tradeScope":"T", + // "accBaseVolume":"33.6558", + // "priceAvg":"0.49016", + // "status":"partially_filled", + // "cTime":"1699881902217", + // "uTime":"1699881902248", + // "feeDetail":[ + // { + // "feeCoin":"BGB", + // "fee":"-0.0336558" + // } + // ], + // "enterPointSource":"API" + // } + o := Order{ + Trade: Trade{ + FillPrice: fixedpoint.NewFromFloat(0.49016), + TradeId: types.StrInt64(1107950490073112582), + BaseVolume: fixedpoint.NewFromFloat(33.6558), + FillTime: types.NewMillisecondTimestampFromInt(1699881902235), + FillFee: fixedpoint.NewFromFloat(-0.0336558), + FillFeeCoin: "BGB", + TradeScope: "T", + }, + InstId: "BGBUSDT", + OrderId: types.StrInt64(1107950489998626816), + ClientOrderId: "cc73aab9-1e44-4022-8458-60d8c6a08753", + NewSize: fixedpoint.NewFromFloat(39.0), + Notional: fixedpoint.NewFromFloat(39.0), + OrderType: v2.OrderTypeMarket, + Force: v2.OrderForceGTC, + Side: v2.SideTypeBuy, + AccBaseVolume: fixedpoint.NewFromFloat(33.6558), + PriceAvg: fixedpoint.NewFromFloat(0.49016), + Status: v2.OrderStatusPartialFilled, + CreatedTime: types.NewMillisecondTimestampFromInt(1699881902217), + UpdatedTime: types.NewMillisecondTimestampFromInt(1699881902248), + FeeDetail: nil, + EnterPointSource: "API", + } + + t.Run("succeeds", func(t *testing.T) { + res, err := o.toGlobalTrade() + assert.NoError(t, err) + assert.Equal(t, types.Trade{ + ID: uint64(o.TradeId), + OrderID: uint64(o.OrderId), + Exchange: types.ExchangeBitget, + Price: o.FillPrice, + Quantity: o.BaseVolume, + QuoteQuantity: o.FillPrice.Mul(o.BaseVolume), + Symbol: "BGBUSDT", + Side: types.SideTypeBuy, + IsBuyer: true, + IsMaker: false, + Time: types.Time(o.FillTime), + Fee: o.FillFee.Abs(), + FeeCurrency: "BGB", + }, res) + }) + + t.Run("unexpected trade scope", func(t *testing.T) { + newO := o + newO.TradeScope = "xxx" + _, err := newO.toGlobalTrade() + assert.ErrorContains(t, err, "xxx") + }) + + t.Run("unexpected side type", func(t *testing.T) { + newO := o + newO.Side = "xxx" + _, err := newO.toGlobalTrade() + assert.ErrorContains(t, err, "xxx") + }) + + t.Run("unexpected side type", func(t *testing.T) { + newO := o + newO.Status = "xxx" + _, err := newO.toGlobalTrade() + assert.ErrorContains(t, err, "xxx") + }) +} diff --git a/pkg/exchange/bitget/debug.go b/pkg/exchange/bitget/debug.go new file mode 100644 index 0000000..84b853d --- /dev/null +++ b/pkg/exchange/bitget/debug.go @@ -0,0 +1,19 @@ +package bitget + +import "git.qtrade.icu/lychiyu/qbtrade/pkg/util" + +type LogFunction func(msg string, args ...interface{}) + +var debugf LogFunction + +func getDebugFunction() LogFunction { + if v, ok := util.GetEnvVarBool("DEBUG_BITGET"); ok && v { + return log.Infof + } + + return func(msg string, args ...interface{}) {} +} + +func init() { + debugf = getDebugFunction() +} diff --git a/pkg/exchange/bitget/exchange.go b/pkg/exchange/bitget/exchange.go new file mode 100644 index 0000000..f05409e --- /dev/null +++ b/pkg/exchange/bitget/exchange.go @@ -0,0 +1,599 @@ +package bitget + +import ( + "context" + "errors" + "fmt" + "strconv" + "time" + + "github.com/sirupsen/logrus" + "go.uber.org/multierr" + "golang.org/x/time/rate" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bitget/bitgetapi" + v2 "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bitget/bitgetapi/v2" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ( + ID = "bitget" + + PlatformToken = "BGB" + + queryLimit = 100 + defaultKLineLimit = 100 + maxOrderIdLen = 36 + maxHistoricalDataQueryPeriod = 90 * 24 * time.Hour +) + +var log = logrus.WithFields(logrus.Fields{ + "exchange": ID, +}) + +var ( + // queryMarketRateLimiter has its own rate limit. https://bitgetlimited.github.io/apidoc/en/spot/#get-symbols + queryMarketRateLimiter = rate.NewLimiter(rate.Every(time.Second/10), 5) + + // queryAccountRateLimiter has its own rate limit. https://bitgetlimited.github.io/apidoc/en/spot/#get-account-assets + queryAccountRateLimiter = rate.NewLimiter(rate.Every(time.Second/5), 5) + + // queryTickerRateLimiter has its own rate limit. https://bitgetlimited.github.io/apidoc/en/spot/#get-single-ticker + queryTickerRateLimiter = rate.NewLimiter(rate.Every(time.Second/10), 5) + + // queryTickersRateLimiter has its own rate limit. https://bitgetlimited.github.io/apidoc/en/spot/#get-all-tickers + queryTickersRateLimiter = rate.NewLimiter(rate.Every(time.Second/10), 5) + + // queryOpenOrdersRateLimiter has its own rate limit. https://www.bitget.com/zh-CN/api-doc/spot/trade/Get-Unfilled-Orders + queryOpenOrdersRateLimiter = rate.NewLimiter(rate.Every(time.Second/10), 5) + + // closedQueryOrdersRateLimiter has its own rate limit. https://www.bitget.com/api-doc/spot/trade/Get-History-Orders + closedQueryOrdersRateLimiter = rate.NewLimiter(rate.Every(time.Second/15), 5) + + // submitOrderRateLimiter has its own rate limit. https://www.bitget.com/zh-CN/api-doc/spot/trade/Place-Order + submitOrderRateLimiter = rate.NewLimiter(rate.Every(time.Second/5), 5) + + // queryTradeRateLimiter has its own rate limit. https://www.bitget.com/zh-CN/api-doc/spot/trade/Get-Fills + queryTradeRateLimiter = rate.NewLimiter(rate.Every(time.Second/5), 5) + + // cancelOrderRateLimiter has its own rate limit. https://www.bitget.com/api-doc/spot/trade/Cancel-Order + cancelOrderRateLimiter = rate.NewLimiter(rate.Every(time.Second/5), 5) + + // kLineRateLimiter has its own rate limit. https://www.bitget.com/api-doc/spot/market/Get-Candle-Data + kLineRateLimiter = rate.NewLimiter(rate.Every(time.Second/10), 5) +) + +type Exchange struct { + key, secret, passphrase string + + client *bitgetapi.RestClient + v2client *v2.Client + timeNowFn func() time.Time +} + +func New(key, secret, passphrase string) *Exchange { + client := bitgetapi.NewClient() + + if len(key) > 0 && len(secret) > 0 { + client.Auth(key, secret, passphrase) + } + + return &Exchange{ + key: key, + secret: secret, + passphrase: passphrase, + client: client, + v2client: v2.NewClient(client), + timeNowFn: func() time.Time { + return time.Now() + }, + } +} + +func (e *Exchange) Name() types.ExchangeName { + return types.ExchangeBitget +} + +func (e *Exchange) PlatformFeeCurrency() string { + return PlatformToken +} + +func (e *Exchange) NewStream() types.Stream { + return NewStream(e.key, e.secret, e.passphrase) +} + +func (e *Exchange) QueryMarkets(ctx context.Context) (types.MarketMap, error) { + if err := queryMarketRateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("markets rate limiter wait error: %w", err) + } + + req := e.v2client.NewGetSymbolsRequest() + symbols, err := req.Do(ctx) + if err != nil { + return nil, err + } + + markets := types.MarketMap{} + for _, s := range symbols { + if s.Status == v2.SymbolStatusOffline { + // ignore offline symbols + continue + } + + markets[s.Symbol] = toGlobalMarket(s) + } + + return markets, nil +} + +func (e *Exchange) QueryTicker(ctx context.Context, symbol string) (*types.Ticker, error) { + if err := queryTickerRateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("ticker rate limiter wait error: %w", err) + } + + req := e.v2client.NewGetTickersRequest() + req.Symbol(symbol) + resp, err := req.Do(ctx) + if err != nil { + return nil, fmt.Errorf("failed to query ticker, symbol: %s, err: %w", symbol, err) + } + if len(resp) != 1 { + return nil, fmt.Errorf("unexpected length of query single symbol: %s, resp: %+v", symbol, resp) + } + + ticker := toGlobalTicker(resp[0]) + return &ticker, nil +} + +func (e *Exchange) QueryTickers(ctx context.Context, symbols ...string) (map[string]types.Ticker, error) { + tickers := map[string]types.Ticker{} + if len(symbols) > 0 { + for _, s := range symbols { + t, err := e.QueryTicker(ctx, s) + if err != nil { + return nil, err + } + + tickers[s] = *t + } + + return tickers, nil + } + + if err := queryTickersRateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("tickers rate limiter wait error: %w", err) + } + + resp, err := e.v2client.NewGetTickersRequest().Do(ctx) + if err != nil { + return nil, fmt.Errorf("failed to query tickers: %w", err) + } + + for _, s := range resp { + tickers[s.Symbol] = toGlobalTicker(s) + } + + return tickers, nil +} + +// QueryKLines queries the k line data by interval and time range...etc. +// +// If you provide only the start time, the system will return the latest data. +// If you provide both the start and end times, the system will return data within the specified range. +// If you provide only the end time, the system will return data that occurred before the end time. +// +// The end time has different limits. 1m, 5m can query for one month,15m can query for 52 days,30m can query for 62 days, +// 1H can query for 83 days,4H can query for 240 days,6H can query for 360 days. +func (e *Exchange) QueryKLines( + ctx context.Context, symbol string, interval types.Interval, options types.KLineQueryOptions, +) ([]types.KLine, error) { + req := e.v2client.NewGetKLineRequest().Symbol(symbol) + intervalStr, found := toLocalGranularity[interval] + if !found { + return nil, fmt.Errorf("%s not supported, supported granlarity: %+v", intervalStr, toLocalGranularity) + } + req.Granularity(intervalStr) + + limit := uint64(options.Limit) + if limit > defaultKLineLimit || limit <= 0 { + log.Debugf("limtit is exceeded or zero, update to %d, got: %d", defaultKLineLimit, options.Limit) + limit = defaultKLineLimit + } + req.Limit(strconv.FormatUint(limit, 10)) + + if options.StartTime != nil { + req.StartTime(*options.StartTime) + } + + if options.EndTime != nil { + if options.StartTime != nil && options.EndTime.Before(*options.StartTime) { + return nil, fmt.Errorf("end time %s before start time %s", *options.EndTime, *options.StartTime) + } + + ok, duration := hasMaxDuration(interval) + if ok && time.Since(*options.EndTime) > duration { + return nil, fmt.Errorf("end time %s are greater than max duration %s", *options.EndTime, duration) + } + req.EndTime(*options.EndTime) + } + + if err := kLineRateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("query klines rate limiter wait error: %w", err) + } + + resp, err := req.Do(ctx) + if err != nil { + return nil, fmt.Errorf("failed to call k line, err: %w", err) + } + + kLines := toGlobalKLines(symbol, interval, resp) + return types.SortKLinesAscending(kLines), nil +} + +func (e *Exchange) QueryAccount(ctx context.Context) (*types.Account, error) { + bals, err := e.QueryAccountBalances(ctx) + if err != nil { + return nil, err + } + + account := types.NewAccount() + account.UpdateBalances(bals) + return account, nil +} + +func (e *Exchange) QueryAccountBalances(ctx context.Context) (types.BalanceMap, error) { + if err := queryAccountRateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("account rate limiter wait error: %w", err) + } + + req := e.v2client.NewGetAccountAssetsRequest().AssetType(v2.AssetTypeHoldOnly) + resp, err := req.Do(ctx) + if err != nil { + return nil, fmt.Errorf("failed to query account assets: %w", err) + } + + bals := types.BalanceMap{} + for _, asset := range resp { + b := toGlobalBalance(asset) + bals[asset.Coin] = b + } + + return bals, nil +} + +// SubmitOrder submits an order. +// +// Remark: +// 1. We support only GTC for time-in-force, because the response from queryOrder does not include time-in-force information. +// 2. For market buy orders, the size unit is quote currency, whereas the unit for order.Quantity is in base currency. +// Therefore, we need to calculate the equivalent quote currency amount based on the ticker data. +// +// Note that there is a bug in Bitget where you can place a market order with the 'post_only' option successfully, +// which should not be possible. The issue has been reported. +func (e *Exchange) SubmitOrder(ctx context.Context, order types.SubmitOrder) (createdOrder *types.Order, err error) { + if len(order.Market.Symbol) == 0 { + return nil, fmt.Errorf("order.Market.Symbol is required: %+v", order) + } + + req := e.v2client.NewPlaceOrderRequest() + req.Symbol(order.Market.Symbol) + + // set order type + orderType, err := toLocalOrderType(order.Type) + if err != nil { + return nil, err + } + + req.OrderType(orderType) + + // set side + side, err := toLocalSide(order.Side) + if err != nil { + return nil, err + } + + req.Side(side) + + // set quantity + qty := order.Quantity + // if the order is market buy, the quantity is quote coin, instead of base coin. so we need to convert it. + if order.Type == types.OrderTypeMarket && order.Side == types.SideTypeBuy { + ticker, err := e.QueryTicker(ctx, order.Market.Symbol) + if err != nil { + return nil, err + } + qty = order.Quantity.Mul(ticker.Buy) + } + + req.Size(order.Market.FormatQuantity(qty)) + + // set TimeInForce + // we only support GTC/PostOnly, because: + // 1. we only support SPOT trading. + // 2. The query open/closed order does not include the `force` in SPOT. + // If we support FOK/IOC, but you can't query them, that would be unreasonable. + // The other case to consider is 'PostOnly', which is a trade-off because we want to support 'xmaker'. + if len(order.TimeInForce) != 0 && order.TimeInForce != types.TimeInForceGTC { + return nil, fmt.Errorf("time-in-force %s not supported", order.TimeInForce) + } + + switch order.Type { + case types.OrderTypeLimitMaker: + req.Force(v2.OrderForcePostOnly) + default: + req.Force(v2.OrderForceGTC) + } + + // set price + switch order.Type { + case types.OrderTypeLimit, types.OrderTypeLimitMaker: + req.Price(order.Market.FormatPrice(order.Price)) + + } + + // set client order id + if len(order.ClientOrderID) > maxOrderIdLen { + return nil, fmt.Errorf("unexpected length of client order id, got: %d", len(order.ClientOrderID)) + } + + if len(order.ClientOrderID) > 0 { + req.ClientOrderId(order.ClientOrderID) + } + + if err := submitOrderRateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("place order rate limiter wait error: %w", err) + } + + timeNow := time.Now() + res, err := req.Do(ctx) + if err != nil { + return nil, fmt.Errorf("failed to place order, order: %#v, err: %w", order, err) + } + + debugf("order created: %+v", res) + + if len(res.OrderId) == 0 || (len(order.ClientOrderID) != 0 && res.ClientOrderId != order.ClientOrderID) { + return nil, fmt.Errorf("unexpected order id, resp: %#v, order: %#v", res, order) + } + + intOrderId, err := strconv.ParseUint(res.OrderId, 10, 64) + if err != nil { + return nil, err + } + + return &types.Order{ + SubmitOrder: order, + Exchange: types.ExchangeBitget, + OrderID: intOrderId, + UUID: res.OrderId, + Status: types.OrderStatusNew, + ExecutedQuantity: fixedpoint.Zero, + IsWorking: true, + CreationTime: types.Time(timeNow), + UpdateTime: types.Time(timeNow), + }, nil +} + +func (e *Exchange) QueryOpenOrders(ctx context.Context, symbol string) (orders []types.Order, err error) { + var nextCursor types.StrInt64 + for { + if err := queryOpenOrdersRateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("open order rate limiter wait error: %w", err) + } + + req := e.v2client.NewGetUnfilledOrdersRequest(). + Symbol(symbol). + Limit(strconv.FormatInt(queryLimit, 10)) + if nextCursor != 0 { + req.IdLessThan(strconv.FormatInt(int64(nextCursor), 10)) + } + + openOrders, err := req.Do(ctx) + if err != nil { + return nil, fmt.Errorf("failed to query open orders: %w", err) + } + + for _, o := range openOrders { + order, err := unfilledOrderToGlobalOrder(o) + if err != nil { + return nil, fmt.Errorf("failed to convert order, err: %v", err) + } + + orders = append(orders, *order) + } + + orderLen := len(openOrders) + // a defensive programming to ensure the length of order response is expected. + if orderLen > queryLimit { + return nil, fmt.Errorf("unexpected open orders length %d", orderLen) + } + + if orderLen < queryLimit { + break + } + nextCursor = openOrders[orderLen-1].OrderId + } + + return orders, nil +} + +// QueryClosedOrders queries closed order by time range(`CreatedTime`) and id. The order of the response is in descending order. +// If you need to retrieve all data, please utilize the function pkg/exchange/batch.ClosedOrderBatchQuery. +// +// REMARK: If your start time is 90 days earlier, we will update it to now - 90 days. +// ** Since is inclusive, Until is exclusive. If you use a time range to query, you must provide both a start time and an end time. ** +// ** Since and Until cannot exceed 90 days. ** +// ** Since from the last 90 days can be queried ** +func (e *Exchange) QueryClosedOrders( + ctx context.Context, symbol string, since, until time.Time, lastOrderID uint64, +) (orders []types.Order, err error) { + newSince := since + now := e.timeNowFn() + + if time.Since(newSince) > maxHistoricalDataQueryPeriod { + newSince = now.Add(-maxHistoricalDataQueryPeriod) + log.Warnf("!!!BITGET EXCHANGE API NOTICE!!! The closed order API cannot query data beyond 90 days from the current date, update %s -> %s", since, newSince) + } + if until.Before(newSince) || until.Equal(newSince) { + log.Warnf("!!!BITGET EXCHANGE API NOTICE!!! The 'until' comes before 'since', update until to now(%s -> %s).", until, now) + until = now + } + if until.Sub(newSince) > maxHistoricalDataQueryPeriod { + return nil, fmt.Errorf("the start time %s and end time %s cannot exceed 90 days", newSince, until) + } + if lastOrderID != 0 { + log.Warn("!!!BITGET EXCHANGE API NOTICE!!! The order of response is in descending order, so the last order id not supported.") + } + + if err := closedQueryOrdersRateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("query closed order rate limiter wait error: %w", err) + } + res, err := e.v2client.NewGetHistoryOrdersRequest(). + Symbol(symbol). + Limit(strconv.Itoa(queryLimit)). + StartTime(newSince). + EndTime(until). + Do(ctx) + if err != nil { + return nil, fmt.Errorf("failed to call get order histories error: %w", err) + } + + for _, order := range res { + o, err2 := toGlobalOrder(order) + if err2 != nil { + err = multierr.Append(err, err2) + continue + } + + if o.Status.Closed() { + orders = append(orders, *o) + } + } + if err != nil { + return nil, err + } + + return types.SortOrdersAscending(orders), nil +} + +func (e *Exchange) CancelOrders(ctx context.Context, orders ...types.Order) (errs error) { + if len(orders) == 0 { + return nil + } + + for _, order := range orders { + req := e.v2client.NewCancelOrderRequest() + + reqId := "" + switch { + // use the OrderID first, then the ClientOrderID + case order.OrderID > 0: + req.OrderId(strconv.FormatUint(order.OrderID, 10)) + reqId = strconv.FormatUint(order.OrderID, 10) + + case len(order.ClientOrderID) != 0: + req.ClientOrderId(order.ClientOrderID) + reqId = order.ClientOrderID + + default: + errs = multierr.Append( + errs, + fmt.Errorf("the order uuid and client order id are empty, order: %#v", order), + ) + continue + } + + req.Symbol(order.Symbol) + + if err := cancelOrderRateLimiter.Wait(ctx); err != nil { + errs = multierr.Append(errs, fmt.Errorf("cancel order rate limiter wait, orderId: %d, clientOrderId: %s, error: %w", order.OrderID, order.ClientOrderID, err)) + continue + } + res, err := req.Do(ctx) + if err != nil { + errs = multierr.Append(errs, fmt.Errorf("failed to cancel orderId: %d, clientOrderId: %s, err: %w", order.OrderID, order.ClientOrderID, err)) + continue + } + + // sanity check + if res.OrderId.String() != reqId && res.ClientOrderId != reqId { + errs = multierr.Append(errs, fmt.Errorf("order id mismatch, exp: %s, respOrderId: %d, respClientOrderId: %s", reqId, res.OrderId, res.ClientOrderId)) + continue + } + } + + return errs +} + +// QueryTrades queries fill trades. The trade of the response is in descending order. The time-based query are typically +// using (`CreatedTime`) as the search criteria. +// If you need to retrieve all data, please utilize the function pkg/exchange/batch.TradeBatchQuery. +// +// REMARK: If your start time is 90 days earlier, we will update it to now - 90 days. +// ** StartTime is inclusive, EndTime is exclusive. If you use the EndTime, the StartTime is required. ** +// ** StartTime and EndTime cannot exceed 90 days. ** +func (e *Exchange) QueryTrades( + ctx context.Context, symbol string, options *types.TradeQueryOptions, +) (trades []types.Trade, err error) { + if options.LastTradeID != 0 { + log.Warn("!!!BITGET EXCHANGE API NOTICE!!! The trade of response is in descending order, so the last trade id not supported.") + } + + req := e.v2client.NewGetTradeFillsRequest() + req.Symbol(symbol) + + var newStartTime time.Time + if options.StartTime != nil { + newStartTime = *options.StartTime + if time.Since(newStartTime) > maxHistoricalDataQueryPeriod { + newStartTime = e.timeNowFn().Add(-maxHistoricalDataQueryPeriod) + log.Warnf("!!!BITGET EXCHANGE API NOTICE!!! The trade API cannot query data beyond 90 days from the current date, update %s -> %s", *options.StartTime, newStartTime) + } + req.StartTime(newStartTime) + } + + if options.EndTime != nil { + if newStartTime.IsZero() { + return nil, errors.New("start time is required for query trades if you take end time") + } + if options.EndTime.Before(newStartTime) { + return nil, fmt.Errorf("end time %s before start %s", *options.EndTime, newStartTime) + } + if options.EndTime.Sub(newStartTime) > maxHistoricalDataQueryPeriod { + return nil, fmt.Errorf("start time %s and end time %s cannot greater than 90 days", newStartTime, options.EndTime) + } + req.EndTime(*options.EndTime) + } + + limit := options.Limit + if limit > queryLimit || limit <= 0 { + log.Debugf("limtit is exceeded or zero, update to %d, got: %d", queryLimit, options.Limit) + limit = queryLimit + } + req.Limit(strconv.FormatInt(limit, 10)) + + if err := queryTradeRateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("trade rate limiter wait error: %w", err) + } + response, err := req.Do(ctx) + if err != nil { + return nil, fmt.Errorf("failed to query trades, err: %w", err) + } + + var errs error + for _, trade := range response { + res, err := toGlobalTrade(trade) + if err != nil { + errs = multierr.Append(errs, err) + continue + } + trades = append(trades, *res) + } + + if errs != nil { + return nil, errs + } + + return trades, nil +} diff --git a/pkg/exchange/bitget/exchange_test.go b/pkg/exchange/bitget/exchange_test.go new file mode 100644 index 0000000..6ac44ff --- /dev/null +++ b/pkg/exchange/bitget/exchange_test.go @@ -0,0 +1,1637 @@ +package bitget + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + v2 "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bitget/bitgetapi/v2" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/testing/httptesting" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func TestExchange_QueryMarkets(t *testing.T) { + ex := New("key", "secret", "passphrase") + + t.Run("succeeds", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + f, err := os.ReadFile("bitgetapi/v2/testdata/get_symbols_request.json") + assert.NoError(t, err) + + transport.GET("/api/v2/spot/public/symbols", func(req *http.Request) (*http.Response, error) { + return httptesting.BuildResponseString(http.StatusOK, string(f)), nil + }) + + mkts, err := ex.QueryMarkets(context.Background()) + assert.NoError(t, err) + + expMkts := types.MarketMap{ + "ETHUSDT": types.Market{ + Exchange: types.ExchangeBitget, + Symbol: "ETHUSDT", + LocalSymbol: "ETHUSDT", + PricePrecision: 2, + VolumePrecision: 4, + QuoteCurrency: "USDT", + BaseCurrency: "ETH", + MinNotional: fixedpoint.NewFromInt(5), + MinAmount: fixedpoint.NewFromInt(5), + MinQuantity: fixedpoint.NewFromInt(0), + MaxQuantity: fixedpoint.NewFromInt(10000000000), + StepSize: fixedpoint.NewFromFloat(1.0 / math.Pow10(4)), + TickSize: fixedpoint.NewFromFloat(1.0 / math.Pow10(2)), + MinPrice: fixedpoint.Zero, + MaxPrice: fixedpoint.Zero, + }, + "BTCUSDT": types.Market{ + Exchange: types.ExchangeBitget, + Symbol: "BTCUSDT", + LocalSymbol: "BTCUSDT", + PricePrecision: 2, + VolumePrecision: 6, + QuoteCurrency: "USDT", + BaseCurrency: "BTC", + MinNotional: fixedpoint.NewFromInt(5), + MinAmount: fixedpoint.NewFromInt(5), + MinQuantity: fixedpoint.NewFromInt(0), + MaxQuantity: fixedpoint.NewFromInt(10000000000), + StepSize: fixedpoint.NewFromFloat(1.0 / math.Pow10(6)), + TickSize: fixedpoint.NewFromFloat(1.0 / math.Pow10(2)), + MinPrice: fixedpoint.Zero, + MaxPrice: fixedpoint.Zero, + }, + } + assert.Equal(t, expMkts, mkts) + }) + + t.Run("error", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + f, err := os.ReadFile("bitgetapi/v2/testdata/request_error.json") + assert.NoError(t, err) + + transport.GET("/api/v2/spot/public/symbols", func(req *http.Request) (*http.Response, error) { + return httptesting.BuildResponseString(http.StatusBadRequest, string(f)), nil + }) + + _, err = ex.QueryMarkets(context.Background()) + assert.ErrorContains(t, err, "Invalid IP") + }) +} + +func TestExchange_QueryTicker(t *testing.T) { + var ( + assert = assert.New(t) + ex = New("key", "secret", "passphrase") + url = "/api/v2/spot/market/tickers" + ) + + t.Run("succeeds", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + f, err := os.ReadFile("bitgetapi/v2/testdata/get_ticker_request.json") + assert.NoError(err) + + transport.GET(url, func(req *http.Request) (*http.Response, error) { + return httptesting.BuildResponseString(http.StatusOK, string(f)), nil + }) + + tickers, err := ex.QueryTicker(context.Background(), "BTCUSDT") + assert.NoError(err) + expTicker := &types.Ticker{ + Time: types.NewMillisecondTimestampFromInt(1709626631127).Time(), + Volume: fixedpoint.MustNewFromString("29439.351448"), + Last: fixedpoint.MustNewFromString("66554.03"), + Open: fixedpoint.MustNewFromString("64654.54"), + High: fixedpoint.MustNewFromString("68686.93"), + Low: fixedpoint.MustNewFromString("64583.42"), + Buy: fixedpoint.MustNewFromString("66554"), + Sell: fixedpoint.MustNewFromString("66554.07"), + } + assert.Equal(expTicker, tickers) + }) + + t.Run("unexpected length", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + f, err := os.ReadFile("bitgetapi/v2/testdata/get_tickers_request.json") + assert.NoError(err) + + transport.GET(url, func(req *http.Request) (*http.Response, error) { + return httptesting.BuildResponseString(http.StatusOK, string(f)), nil + }) + + _, err = ex.QueryTicker(context.Background(), "BTCUSDT") + assert.ErrorContains(err, "unexpected length of query") + }) + + t.Run("error", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + f, err := os.ReadFile("bitgetapi/v2/testdata/request_error.json") + assert.NoError(err) + + transport.GET(url, func(req *http.Request) (*http.Response, error) { + return httptesting.BuildResponseString(http.StatusBadRequest, string(f)), nil + }) + + _, err = ex.QueryTicker(context.Background(), "BTCUSDT") + assert.ErrorContains(err, "Invalid IP") + }) +} + +func TestExchange_QueryTickers(t *testing.T) { + var ( + assert = assert.New(t) + ex = New("key", "secret", "passphrase") + url = "/api/v2/spot/market/tickers" + expBtcSymbol = "BTCUSDT" + expBtcTicker = types.Ticker{ + Time: types.NewMillisecondTimestampFromInt(1709626631127).Time(), + Volume: fixedpoint.MustNewFromString("29439.351448"), + Last: fixedpoint.MustNewFromString("66554.03"), + Open: fixedpoint.MustNewFromString("64654.54"), + High: fixedpoint.MustNewFromString("68686.93"), + Low: fixedpoint.MustNewFromString("64583.42"), + Buy: fixedpoint.MustNewFromString("66554"), + Sell: fixedpoint.MustNewFromString("66554.07"), + } + ) + + t.Run("succeeds", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + f, err := os.ReadFile("bitgetapi/v2/testdata/get_tickers_request.json") + assert.NoError(err) + + transport.GET(url, func(req *http.Request) (*http.Response, error) { + return httptesting.BuildResponseString(http.StatusOK, string(f)), nil + }) + + tickers, err := ex.QueryTickers(context.Background()) + assert.NoError(err) + expTickers := map[string]types.Ticker{ + expBtcSymbol: expBtcTicker, + "ETHUSDT": { + Time: types.NewMillisecondTimestampFromInt(1709626631726).Time(), + Volume: fixedpoint.MustNewFromString("243220.866"), + Last: fixedpoint.MustNewFromString("3686.95"), + Open: fixedpoint.MustNewFromString("3506.6"), + High: fixedpoint.MustNewFromString("3740"), + Low: fixedpoint.MustNewFromString("3461.17"), + Buy: fixedpoint.MustNewFromString("3686.94"), + Sell: fixedpoint.MustNewFromString("3686.98"), + }, + } + assert.Equal(expTickers, tickers) + }) + + t.Run("succeeds for query one markets", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + f, err := os.ReadFile("bitgetapi/v2/testdata/get_ticker_request.json") + assert.NoError(err) + + transport.GET(url, func(req *http.Request) (*http.Response, error) { + assert.Contains(req.URL.Query(), "symbol") + assert.Equal(req.URL.Query()["symbol"], []string{expBtcSymbol}) + return httptesting.BuildResponseString(http.StatusOK, string(f)), nil + }) + + tickers, err := ex.QueryTickers(context.Background(), expBtcSymbol) + assert.NoError(err) + expTickers := map[string]types.Ticker{ + expBtcSymbol: expBtcTicker, + } + assert.Equal(expTickers, tickers) + }) + + t.Run("error", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + f, err := os.ReadFile("bitgetapi/v2/testdata/request_error.json") + assert.NoError(err) + + transport.GET(url, func(req *http.Request) (*http.Response, error) { + return httptesting.BuildResponseString(http.StatusBadRequest, string(f)), nil + }) + + _, err = ex.QueryTicker(context.Background(), expBtcSymbol) + assert.ErrorContains(err, "Invalid IP") + }) +} + +func TestExchange_QueryKLines(t *testing.T) { + var ( + assert = assert.New(t) + ex = New("key", "secret", "passphrase") + url = "/api/v2/spot/market/candles" + expBtcSymbol = "BTCUSDT" + interval = types.Interval4h + expBtcKlines = []types.KLine{ + { + Exchange: types.ExchangeBitget, + Symbol: expBtcSymbol, + StartTime: types.Time(types.NewMillisecondTimestampFromInt(1709352000000).Time()), + EndTime: types.Time(types.NewMillisecondTimestampFromInt(1709352000000).Time().Add(interval.Duration() - time.Millisecond)), + Interval: interval, + Open: fixedpoint.MustNewFromString("62308.42"), + Close: fixedpoint.MustNewFromString("62014.17"), + High: fixedpoint.MustNewFromString("62308.43"), + Low: fixedpoint.MustNewFromString("61760"), + Volume: fixedpoint.MustNewFromString("987.377637"), + QuoteVolume: fixedpoint.MustNewFromString("61283110.57046518"), + Closed: false, + }, + { + Exchange: types.ExchangeBitget, + Symbol: expBtcSymbol, + StartTime: types.Time(types.NewMillisecondTimestampFromInt(1709366400000).Time()), + EndTime: types.Time(types.NewMillisecondTimestampFromInt(1709366400000).Time().Add(interval.Duration() - time.Millisecond)), + Interval: interval, + Open: fixedpoint.MustNewFromString("62014.17"), + Close: fixedpoint.MustNewFromString("61825.64"), + High: fixedpoint.MustNewFromString("62122.8"), + Low: fixedpoint.MustNewFromString("61648.26"), + Volume: fixedpoint.MustNewFromString("1271.183413"), + QuoteVolume: fixedpoint.MustNewFromString("78680550.55539777"), + Closed: false, + }, + } + ) + + t.Run("succeeds without time range", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + f, err := os.ReadFile("bitgetapi/v2/testdata/get_k_line_request.json") + assert.NoError(err) + + transport.GET(url, func(req *http.Request) (*http.Response, error) { + query := req.URL.Query() + assert.Len(query, 3) + assert.Contains(query, "symbol") + assert.Contains(query, "granularity") + assert.Contains(query, "limit") + assert.Equal(query["symbol"], []string{expBtcSymbol}) + assert.Equal(query["granularity"], []string{interval.String()}) + assert.Equal(query["limit"], []string{strconv.Itoa(defaultKLineLimit)}) + return httptesting.BuildResponseString(http.StatusOK, string(f)), nil + }) + + klines, err := ex.QueryKLines(context.Background(), expBtcSymbol, interval, types.KLineQueryOptions{}) + assert.NoError(err) + assert.Equal(expBtcKlines, klines) + }) + + t.Run("succeeds with time range", func(t *testing.T) { + var ( + transport = &httptesting.MockTransport{} + limit = 50 + startTime = time.Now() + endTime = startTime.Add(8 * time.Hour) + startTimeMs = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + endTimeMs = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + ) + + ex.client.HttpClient.Transport = transport + + f, err := os.ReadFile("bitgetapi/v2/testdata/get_k_line_request.json") + assert.NoError(err) + + transport.GET(url, func(req *http.Request) (*http.Response, error) { + query := req.URL.Query() + assert.Len(query, 5) + assert.Contains(query, "symbol") + assert.Contains(query, "granularity") + assert.Contains(query, "limit") + assert.Contains(query, "startTime") + assert.Contains(query, "endTime") + assert.Equal(query["symbol"], []string{expBtcSymbol}) + assert.Equal(query["granularity"], []string{interval.String()}) + assert.Equal(query["limit"], []string{strconv.Itoa(limit)}) + assert.Equal(query["startTime"], []string{startTimeMs}) + assert.Equal(query["endTime"], []string{endTimeMs}) + + return httptesting.BuildResponseString(http.StatusOK, string(f)), nil + }) + + klines, err := ex.QueryKLines(context.Background(), expBtcSymbol, interval, types.KLineQueryOptions{ + Limit: limit, + StartTime: &startTime, + EndTime: &endTime, + }) + assert.NoError(err) + assert.Equal(expBtcKlines, klines) + }) + + t.Run("error", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + f, err := os.ReadFile("bitgetapi/v2/testdata/request_error.json") + assert.NoError(err) + + transport.GET(url, func(req *http.Request) (*http.Response, error) { + return httptesting.BuildResponseString(http.StatusBadRequest, string(f)), nil + }) + + _, err = ex.QueryKLines(context.Background(), expBtcSymbol, interval, types.KLineQueryOptions{}) + assert.ErrorContains(err, "Invalid IP") + }) + + t.Run("reach max duration", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + transport.GET(url, func(req *http.Request) (*http.Response, error) { + return httptesting.BuildResponse(http.StatusBadRequest, nil), nil + }) + + before31Days := time.Now().Add(-31 * 24 * time.Hour) + _, err := ex.QueryKLines(context.Background(), expBtcSymbol, types.Interval1m, types.KLineQueryOptions{ + EndTime: &before31Days, + }) + assert.ErrorContains(err, "are greater than max duration") + }) + + t.Run("end time before start time", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + transport.GET(url, func(req *http.Request) (*http.Response, error) { + return httptesting.BuildResponse(http.StatusBadRequest, nil), nil + }) + + startTime := time.Now() + endTime := startTime.Add(-time.Hour) + _, err := ex.QueryKLines(context.Background(), expBtcSymbol, types.Interval1m, types.KLineQueryOptions{ + StartTime: &startTime, + EndTime: &endTime, + }) + assert.ErrorContains(err, "before start time") + }) + + t.Run("unexpected duraiton", func(t *testing.T) { + _, err := ex.QueryKLines(context.Background(), expBtcSymbol, "87h", types.KLineQueryOptions{}) + assert.ErrorContains(err, "not supported") + }) +} + +func TestExchange_QueryAccount(t *testing.T) { + var ( + assert = assert.New(t) + ex = New("key", "secret", "passphrase") + url = "/api/v2/spot/account/assets" + expAccount = &types.Account{ + AccountType: "spot", + FuturesInfo: nil, + MarginInfo: nil, + IsolatedMarginInfo: nil, + MarginLevel: fixedpoint.Zero, + MarginTolerance: fixedpoint.Zero, + BorrowEnabled: false, + TransferEnabled: false, + MarginRatio: fixedpoint.Zero, + LiquidationPrice: fixedpoint.Zero, + LiquidationRate: fixedpoint.Zero, + MakerFeeRate: fixedpoint.Zero, + TakerFeeRate: fixedpoint.Zero, + TotalAccountValue: fixedpoint.Zero, + CanDeposit: false, + CanTrade: false, + CanWithdraw: false, + } + balances = types.BalanceMap{ + "BTC": { + Currency: "BTC", + Available: fixedpoint.MustNewFromString("0.00000690"), + Locked: fixedpoint.Zero, + Borrowed: fixedpoint.Zero, + Interest: fixedpoint.Zero, + NetAsset: fixedpoint.Zero, + MaxWithdrawAmount: fixedpoint.Zero, + }, + "USDT": { + Currency: "USDT", + Available: fixedpoint.MustNewFromString("0.68360342"), + Locked: fixedpoint.MustNewFromString("9.08096000"), + Borrowed: fixedpoint.Zero, + Interest: fixedpoint.Zero, + NetAsset: fixedpoint.Zero, + MaxWithdrawAmount: fixedpoint.Zero, + }, + } + ) + expAccount.UpdateBalances(balances) + + t.Run("succeeds", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + f, err := os.ReadFile("bitgetapi/v2/testdata/get_account_assets_request.json") + assert.NoError(err) + + transport.GET(url, func(req *http.Request) (*http.Response, error) { + query := req.URL.Query() + assert.Len(query, 1) + assert.Contains(query, "limit") + assert.Equal(query["limit"], []string{string(v2.AssetTypeHoldOnly)}) + return httptesting.BuildResponseString(http.StatusOK, string(f)), nil + }) + + acct, err := ex.QueryAccount(context.Background()) + assert.NoError(err) + assert.Equal(expAccount, acct) + }) + + t.Run("error", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + f, err := os.ReadFile("bitgetapi/v2/testdata/request_error.json") + assert.NoError(err) + + transport.GET(url, func(req *http.Request) (*http.Response, error) { + return httptesting.BuildResponseString(http.StatusBadRequest, string(f)), nil + }) + + _, err = ex.QueryAccount(context.Background()) + assert.ErrorContains(err, "Invalid IP") + }) +} + +func TestExchange_QueryAccountBalances(t *testing.T) { + var ( + assert = assert.New(t) + ex = New("key", "secret", "passphrase") + url = "/api/v2/spot/account/assets" + expBalancesMap = types.BalanceMap{ + "BTC": { + Currency: "BTC", + Available: fixedpoint.MustNewFromString("0.00000690"), + Locked: fixedpoint.Zero, + Borrowed: fixedpoint.Zero, + Interest: fixedpoint.Zero, + NetAsset: fixedpoint.Zero, + MaxWithdrawAmount: fixedpoint.Zero, + }, + "USDT": { + Currency: "USDT", + Available: fixedpoint.MustNewFromString("0.68360342"), + Locked: fixedpoint.MustNewFromString("9.08096000"), + Borrowed: fixedpoint.Zero, + Interest: fixedpoint.Zero, + NetAsset: fixedpoint.Zero, + MaxWithdrawAmount: fixedpoint.Zero, + }, + } + ) + + t.Run("succeeds", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + f, err := os.ReadFile("bitgetapi/v2/testdata/get_account_assets_request.json") + assert.NoError(err) + + transport.GET(url, func(req *http.Request) (*http.Response, error) { + query := req.URL.Query() + assert.Len(query, 1) + assert.Contains(query, "limit") + assert.Equal(query["limit"], []string{string(v2.AssetTypeHoldOnly)}) + return httptesting.BuildResponseString(http.StatusOK, string(f)), nil + }) + + acct, err := ex.QueryAccountBalances(context.Background()) + assert.NoError(err) + assert.Equal(expBalancesMap, acct) + }) + + t.Run("error", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + f, err := os.ReadFile("bitgetapi/v2/testdata/request_error.json") + assert.NoError(err) + + transport.GET(url, func(req *http.Request) (*http.Response, error) { + return httptesting.BuildResponseString(http.StatusBadRequest, string(f)), nil + }) + + _, err = ex.QueryAccountBalances(context.Background()) + assert.ErrorContains(err, "Invalid IP") + }) +} + +func TestExchange_SubmitOrder(t *testing.T) { + var ( + assert = assert.New(t) + ex = New("key", "secret", "passphrase") + placeOrderUrl = "/api/v2/spot/trade/place-order" + tickerUrl = "/api/v2/spot/market/tickers" + clientOrderId = "684a79df-f931-474f-a9a5-f1deab1cd770" + expBtcSymbol = "BTCUSDT" + mkt = types.Market{ + Symbol: expBtcSymbol, + LocalSymbol: expBtcSymbol, + PricePrecision: fixedpoint.MustNewFromString("2").Int(), + VolumePrecision: fixedpoint.MustNewFromString("6").Int(), + StepSize: fixedpoint.NewFromFloat(1.0 / math.Pow10(6)), + TickSize: fixedpoint.NewFromFloat(1.0 / math.Pow10(2)), + } + expOrder = &types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: clientOrderId, + Symbol: expBtcSymbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: fixedpoint.MustNewFromString("0.00009"), + Price: fixedpoint.MustNewFromString("66000"), + TimeInForce: types.TimeInForceGTC, + Market: mkt, + }, + Exchange: types.ExchangeBitget, + OrderID: 1148903850645331968, + UUID: "1148903850645331968", + Status: types.OrderStatusNew, + ExecutedQuantity: fixedpoint.Zero, + IsWorking: true, + CreationTime: types.Time(types.NewMillisecondTimestampFromInt(1709645944272).Time()), + UpdateTime: types.Time(types.NewMillisecondTimestampFromInt(1709645944272).Time()), + } + reqLimitOrder = types.SubmitOrder{ + ClientOrderID: clientOrderId, + Symbol: expBtcSymbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: fixedpoint.MustNewFromString("0.00009"), + Price: fixedpoint.MustNewFromString("66000"), + Market: mkt, + TimeInForce: types.TimeInForceGTC, + } + ) + + type NewOrder struct { + ClientOid string `json:"clientOid"` + Force string `json:"force"` + OrderType string `json:"orderType"` + Price string `json:"price"` + Side string `json:"side"` + Size string `json:"size"` + Symbol string `json:"symbol"` + } + + t.Run("Limit order", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + placeOrderFile, err := os.ReadFile("bitgetapi/v2/testdata/place_order_request.json") + assert.NoError(err) + + transport.POST(placeOrderUrl, func(req *http.Request) (*http.Response, error) { + raw, err := io.ReadAll(req.Body) + assert.NoError(err) + + reqq := &NewOrder{} + err = json.Unmarshal(raw, &reqq) + assert.NoError(err) + assert.Equal(&NewOrder{ + ClientOid: expOrder.ClientOrderID, + Force: string(v2.OrderForceGTC), + OrderType: string(v2.OrderTypeLimit), + Price: "66000.00", + Side: string(v2.SideTypeBuy), + Size: "0.000090", + Symbol: expBtcSymbol, + }, reqq) + + return httptesting.BuildResponseString(http.StatusOK, string(placeOrderFile)), nil + }) + + acct, err := ex.SubmitOrder(context.Background(), reqLimitOrder) + assert.NoError(err) + expOrder.CreationTime = acct.CreationTime + expOrder.UpdateTime = acct.UpdateTime + assert.Equal(expOrder, acct) + }) + + t.Run("Limit Maker order", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + placeOrderFile, err := os.ReadFile("bitgetapi/v2/testdata/place_order_request.json") + assert.NoError(err) + + transport.POST(placeOrderUrl, func(req *http.Request) (*http.Response, error) { + raw, err := io.ReadAll(req.Body) + assert.NoError(err) + + reqq := &NewOrder{} + err = json.Unmarshal(raw, &reqq) + assert.NoError(err) + assert.Equal(&NewOrder{ + ClientOid: expOrder.ClientOrderID, + Force: string(v2.OrderForcePostOnly), + OrderType: string(v2.OrderTypeLimit), + Price: "66000.00", + Side: string(v2.SideTypeBuy), + Size: "0.000090", + Symbol: expBtcSymbol, + }, reqq) + + return httptesting.BuildResponseString(http.StatusOK, string(placeOrderFile)), nil + }) + + reqLimitOrder2 := reqLimitOrder + reqLimitOrder2.Type = types.OrderTypeLimitMaker + acct, err := ex.SubmitOrder(context.Background(), reqLimitOrder2) + assert.NoError(err) + + expOrder2 := *expOrder + expOrder2.Status = types.OrderStatusNew + expOrder2.IsWorking = true + expOrder2.Type = types.OrderTypeLimitMaker + expOrder2.SubmitOrder = reqLimitOrder2 + acct.CreationTime = expOrder2.CreationTime + acct.UpdateTime = expOrder2.UpdateTime + assert.Equal(&expOrder2, acct) + }) + + t.Run("Market order", func(t *testing.T) { + t.Run("Buy", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + // get ticker to calculate btc amount + tickerFile, err := os.ReadFile("bitgetapi/v2/testdata/get_ticker_request.json") + assert.NoError(err) + + transport.GET(tickerUrl, func(req *http.Request) (*http.Response, error) { + assert.Contains(req.URL.Query(), "symbol") + assert.Equal(req.URL.Query()["symbol"], []string{expBtcSymbol}) + return httptesting.BuildResponseString(http.StatusOK, string(tickerFile)), nil + }) + + // place order + placeOrderFile, err := os.ReadFile("bitgetapi/v2/testdata/place_order_request.json") + assert.NoError(err) + + transport.POST(placeOrderUrl, func(req *http.Request) (*http.Response, error) { + raw, err := io.ReadAll(req.Body) + assert.NoError(err) + + reqq := &NewOrder{} + err = json.Unmarshal(raw, &reqq) + assert.NoError(err) + assert.Equal(&NewOrder{ + ClientOid: expOrder.ClientOrderID, + Force: string(v2.OrderForceGTC), + OrderType: string(v2.OrderTypeMarket), + Price: "", + Side: string(v2.SideTypeBuy), + Size: reqLimitOrder.Market.FormatQuantity(fixedpoint.MustNewFromString("66554").Mul(fixedpoint.MustNewFromString("0.00009"))), // ticker: 66554, size: 0.00009 + Symbol: expBtcSymbol, + }, reqq) + + return httptesting.BuildResponseString(http.StatusOK, string(placeOrderFile)), nil + }) + + reqMarketOrder := reqLimitOrder + reqMarketOrder.Side = types.SideTypeBuy + reqMarketOrder.Type = types.OrderTypeMarket + acct, err := ex.SubmitOrder(context.Background(), reqMarketOrder) + assert.NoError(err) + expOrder2 := *expOrder + expOrder2.Side = types.SideTypeBuy + expOrder2.Type = types.OrderTypeMarket + expOrder2.CreationTime = acct.CreationTime + expOrder2.UpdateTime = acct.UpdateTime + assert.Equal(&expOrder2, acct) + }) + + t.Run("Sell", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + // get ticker to calculate btc amount + tickerFile, err := os.ReadFile("bitgetapi/v2/testdata/get_ticker_request.json") + assert.NoError(err) + + transport.GET(tickerUrl, func(req *http.Request) (*http.Response, error) { + assert.Contains(req.URL.Query(), "symbol") + assert.Equal(req.URL.Query()["symbol"], []string{expBtcSymbol}) + return httptesting.BuildResponseString(http.StatusOK, string(tickerFile)), nil + }) + + // place order + placeOrderFile, err := os.ReadFile("bitgetapi/v2/testdata/place_order_request.json") + assert.NoError(err) + + transport.POST(placeOrderUrl, func(req *http.Request) (*http.Response, error) { + raw, err := io.ReadAll(req.Body) + assert.NoError(err) + + reqq := &NewOrder{} + err = json.Unmarshal(raw, &reqq) + assert.NoError(err) + assert.Equal(&NewOrder{ + ClientOid: expOrder.ClientOrderID, + Force: string(v2.OrderForceGTC), + OrderType: string(v2.OrderTypeMarket), + Price: "", + Side: string(v2.SideTypeSell), + Size: "0.000090", // size: 0.00009 + Symbol: expBtcSymbol, + }, reqq) + + return httptesting.BuildResponseString(http.StatusOK, string(placeOrderFile)), nil + }) + + reqMarketOrder := reqLimitOrder + reqMarketOrder.Side = types.SideTypeSell + reqMarketOrder.Type = types.OrderTypeMarket + acct, err := ex.SubmitOrder(context.Background(), reqMarketOrder) + assert.NoError(err) + expOrder2 := *expOrder + expOrder2.Side = types.SideTypeSell + expOrder2.Type = types.OrderTypeMarket + expOrder2.CreationTime = acct.CreationTime + expOrder2.UpdateTime = acct.UpdateTime + assert.Equal(&expOrder2, acct) + }) + + t.Run("failed to get ticker on buy", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + // get ticker to calculate btc amount + requestErrFile, err := os.ReadFile("bitgetapi/v2/testdata/request_error.json") + assert.NoError(err) + + transport.GET(tickerUrl, func(req *http.Request) (*http.Response, error) { + assert.Contains(req.URL.Query(), "symbol") + assert.Equal(req.URL.Query()["symbol"], []string{expBtcSymbol}) + return httptesting.BuildResponseString(http.StatusBadRequest, string(requestErrFile)), nil + }) + + reqMarketOrder := reqLimitOrder + reqMarketOrder.Side = types.SideTypeBuy + reqMarketOrder.Type = types.OrderTypeMarket + _, err = ex.SubmitOrder(context.Background(), reqMarketOrder) + assert.ErrorContains(err, "Invalid IP") + }) + }) + + t.Run("unexpected client order id", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + placeOrderFile, err := os.ReadFile("bitgetapi/v2/testdata/place_order_request.json") + assert.NoError(err) + + transport.POST(placeOrderUrl, func(req *http.Request) (*http.Response, error) { + raw, err := io.ReadAll(req.Body) + assert.NoError(err) + + reqq := &NewOrder{} + err = json.Unmarshal(raw, &reqq) + assert.NoError(err) + assert.Equal(&NewOrder{ + ClientOid: expOrder.ClientOrderID, + Force: string(v2.OrderForceGTC), + OrderType: string(v2.OrderTypeLimit), + Price: "66000.00", + Side: string(v2.SideTypeBuy), + Size: "0.000090", + Symbol: expBtcSymbol, + }, reqq) + + apiResp := &v2.APIResponse{} + err = json.Unmarshal(placeOrderFile, &apiResp) + assert.NoError(err) + placeOrderResp := &v2.PlaceOrderResponse{} + err = json.Unmarshal(apiResp.Data, &placeOrderResp) + assert.NoError(err) + // remove the client order id to test + placeOrderResp.ClientOrderId = "unexpected client order id" + + raw, err = json.Marshal(placeOrderResp) + assert.NoError(err) + apiResp.Data = raw + raw, err = json.Marshal(apiResp) + assert.NoError(err) + + return httptesting.BuildResponseString(http.StatusOK, string(raw)), nil + }) + + _, err = ex.SubmitOrder(context.Background(), reqLimitOrder) + assert.ErrorContains(err, "unexpected order id") + }) + + t.Run("failed to place order", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + placeOrderFile, err := os.ReadFile("bitgetapi/v2/testdata/place_order_request.json") + assert.NoError(err) + + transport.POST(placeOrderUrl, func(req *http.Request) (*http.Response, error) { + raw, err := io.ReadAll(req.Body) + assert.NoError(err) + + reqq := &NewOrder{} + err = json.Unmarshal(raw, &reqq) + assert.NoError(err) + assert.Equal(&NewOrder{ + ClientOid: expOrder.ClientOrderID, + Force: string(v2.OrderForceGTC), + OrderType: string(v2.OrderTypeLimit), + Price: "66000.00", + Side: string(v2.SideTypeBuy), + Size: "0.000090", + Symbol: expBtcSymbol, + }, reqq) + + return httptesting.BuildResponseString(http.StatusBadRequest, string(placeOrderFile)), nil + }) + + _, err = ex.SubmitOrder(context.Background(), reqLimitOrder) + assert.ErrorContains(err, "failed to place order") + }) + + t.Run("unexpected client order id", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + reqOrder2 := reqLimitOrder + reqOrder2.ClientOrderID = strings.Repeat("s", maxOrderIdLen+1) + _, err := ex.SubmitOrder(context.Background(), reqOrder2) + assert.ErrorContains(err, "unexpected length of client order id") + }) + + t.Run("time-in-force unsupported", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + reqOrder2 := reqLimitOrder + reqOrder2.TimeInForce = types.TimeInForceIOC + _, err := ex.SubmitOrder(context.Background(), reqOrder2) + assert.ErrorContains(err, "not supported") + + reqOrder2.TimeInForce = types.TimeInForceFOK + _, err = ex.SubmitOrder(context.Background(), reqOrder2) + assert.ErrorContains(err, "not supported") + }) + + t.Run("unexpected side", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + reqOrder2 := reqLimitOrder + reqOrder2.Side = "GG" + _, err := ex.SubmitOrder(context.Background(), reqOrder2) + assert.ErrorContains(err, "not supported") + }) + + t.Run("unexpected side", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + reqOrder2 := reqLimitOrder + reqOrder2.Type = "GG" + _, err := ex.SubmitOrder(context.Background(), reqOrder2) + assert.ErrorContains(err, "not supported") + }) +} + +func TestExchange_QueryOpenOrders(t *testing.T) { + var ( + assert = assert.New(t) + ex = New("key", "secret", "passphrase") + expBtcSymbol = "BTCUSDT" + url = "/api/v2/spot/trade/unfilled-orders" + ) + + t.Run("succeeds", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + f, err := os.ReadFile("bitgetapi/v2/testdata/get_unfilled_orders_request_limit_order.json") + assert.NoError(err) + + transport.GET(url, func(req *http.Request) (*http.Response, error) { + query := req.URL.Query() + assert.Len(query, 2) + assert.Contains(query, "symbol") + assert.Equal(query["symbol"], []string{expBtcSymbol}) + assert.Equal(query["limit"], []string{strconv.FormatInt(queryLimit, 10)}) + return httptesting.BuildResponseString(http.StatusOK, string(f)), nil + }) + + orders, err := ex.QueryOpenOrders(context.Background(), expBtcSymbol) + assert.NoError(err) + expOrder := []types.Order{ + { + SubmitOrder: types.SubmitOrder{ + ClientOrderID: "684a79df-f931-474f-a9a5-f1deab1cd770", + Symbol: expBtcSymbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: fixedpoint.MustNewFromString("0.00009"), + Price: fixedpoint.MustNewFromString("66000"), + TimeInForce: types.TimeInForceGTC, + }, + Exchange: types.ExchangeBitget, + OrderID: 1148903850645331968, + UUID: "1148903850645331968", + Status: types.OrderStatusNew, + ExecutedQuantity: fixedpoint.Zero, + IsWorking: true, + CreationTime: types.Time(types.NewMillisecondTimestampFromInt(1709645944272).Time()), + UpdateTime: types.Time(types.NewMillisecondTimestampFromInt(1709645944272).Time()), + }, + } + assert.Equal(expOrder, orders) + }) + + t.Run("succeeds on pagination with mock limit + 1", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + dataTemplate := `{ + "userId":"8672173294", + "symbol":"BTCUSDT", + "orderId":"%d", + "clientOid":"684a79df-f931-474f-a9a5-f1deab1cd770", + "priceAvg":"0", + "size":"0.00009", + "orderType":"market", + "side":"sell", + "status":"live", + "basePrice":"0", + "baseVolume":"0", + "quoteVolume":"0", + "enterPointSource":"API", + "orderSource":"market", + "cTime":"1709645944272", + "uTime":"1709645944272" + }` + + openOrdersStr := make([]string, 0, queryLimit+1) + expOrders := make([]types.Order, 0, queryLimit+1) + for i := 0; i < queryLimit+1; i++ { + dataStr := fmt.Sprintf(dataTemplate, i) + openOrdersStr = append(openOrdersStr, dataStr) + + unfilledOdr := &v2.UnfilledOrder{} + err := json.Unmarshal([]byte(dataStr), &unfilledOdr) + assert.NoError(err) + gOdr, err := unfilledOrderToGlobalOrder(*unfilledOdr) + assert.NoError(err) + expOrders = append(expOrders, *gOdr) + } + + transport.GET(url, func(req *http.Request) (*http.Response, error) { + query := req.URL.Query() + assert.Contains(query, "symbol") + assert.Equal(query["symbol"], []string{expBtcSymbol}) + assert.Equal(query["limit"], []string{strconv.FormatInt(queryLimit, 10)}) + if len(query) == 2 { + // first time query + resp := &v2.APIResponse{ + Code: "00000", + Data: []byte("[" + strings.Join(openOrdersStr[0:queryLimit], ",") + "]"), + } + respRaw, err := json.Marshal(resp) + assert.NoError(err) + + return httptesting.BuildResponseString(http.StatusOK, string(respRaw)), nil + } + // second time query + // last order id, so need to -1 + assert.Equal(query["idLessThan"], []string{strconv.FormatInt(queryLimit-1, 10)}) + + resp := &v2.APIResponse{ + Code: "00000", + Data: []byte("[" + strings.Join(openOrdersStr[queryLimit:queryLimit+1], ",") + "]"), + } + respRaw, err := json.Marshal(resp) + assert.NoError(err) + return httptesting.BuildResponseString(http.StatusOK, string(respRaw)), nil + }) + + orders, err := ex.QueryOpenOrders(context.Background(), expBtcSymbol) + assert.NoError(err) + assert.Equal(expOrders, orders) + }) + + t.Run("succeeds on pagination with mock limit + 1", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + dataTemplate := `{ + "userId":"8672173294", + "symbol":"BTCUSDT", + "orderId":"%d", + "clientOid":"684a79df-f931-474f-a9a5-f1deab1cd770", + "priceAvg":"0", + "size":"0.00009", + "orderType":"market", + "side":"sell", + "status":"live", + "basePrice":"0", + "baseVolume":"0", + "quoteVolume":"0", + "enterPointSource":"API", + "orderSource":"market", + "cTime":"1709645944272", + "uTime":"1709645944272" + }` + + openOrdersStr := make([]string, 0, queryLimit+1) + for i := 0; i < queryLimit+1; i++ { + dataStr := fmt.Sprintf(dataTemplate, i) + openOrdersStr = append(openOrdersStr, dataStr) + } + + transport.GET(url, func(req *http.Request) (*http.Response, error) { + query := req.URL.Query() + assert.Contains(query, "symbol") + assert.Equal(query["symbol"], []string{expBtcSymbol}) + assert.Equal(query["limit"], []string{strconv.FormatInt(queryLimit, 10)}) + // first time query + resp := &v2.APIResponse{ + Code: "00000", + Data: []byte("[" + strings.Join(openOrdersStr, ",") + "]"), + } + respRaw, err := json.Marshal(resp) + assert.NoError(err) + + return httptesting.BuildResponseString(http.StatusOK, string(respRaw)), nil + }) + + _, err := ex.QueryOpenOrders(context.Background(), expBtcSymbol) + assert.ErrorContains(err, "unexpected open orders length") + }) + + t.Run("error", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + f, err := os.ReadFile("bitgetapi/v2/testdata/request_error.json") + assert.NoError(err) + + transport.GET(url, func(req *http.Request) (*http.Response, error) { + return httptesting.BuildResponseString(http.StatusBadRequest, string(f)), nil + }) + + _, err = ex.QueryOpenOrders(context.Background(), "BTCUSDT") + assert.ErrorContains(err, "Invalid IP") + }) +} + +func TestExchange_QueryClosedOrders(t *testing.T) { + var ( + assert = assert.New(t) + ex = New("key", "secret", "passphrase") + expBtcSymbol = "BTCUSDT" + since = time.Now().Add(-24 * time.Hour) + until = since.Add(time.Hour) + lastOrderId = uint64(0) + url = "/api/v2/spot/trade/history-orders" + ) + + t.Run("succeeds on market buy", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + // order history + historyOrderFile, err := os.ReadFile("bitgetapi/v2/testdata/get_history_orders_request.json") + assert.NoError(err) + + transport.GET(url, func(req *http.Request) (*http.Response, error) { + query := req.URL.Query() + assert.Len(query, 4) + assert.Contains(query, "startTime") + assert.Contains(query, "endTime") + assert.Contains(query, "limit") + assert.Contains(query, "symbol") + assert.Equal(query["startTime"], []string{strconv.FormatInt(since.UnixNano()/int64(time.Millisecond), 10)}) + assert.Equal(query["endTime"], []string{strconv.FormatInt(until.UnixNano()/int64(time.Millisecond), 10)}) + assert.Equal(query["limit"], []string{strconv.FormatInt(queryLimit, 10)}) + assert.Equal(query["symbol"], []string{expBtcSymbol}) + return httptesting.BuildResponseString(http.StatusOK, string(historyOrderFile)), nil + }) + + orders, err := ex.QueryClosedOrders(context.Background(), expBtcSymbol, since, until, lastOrderId) + assert.NoError(err) + expOrder := []types.Order{ + { + SubmitOrder: types.SubmitOrder{ + ClientOrderID: "684a79df-f931-474f-a9a5-f1deab1cd770", + Symbol: expBtcSymbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: fixedpoint.MustNewFromString("0.00009"), + Price: fixedpoint.MustNewFromString("66000"), + TimeInForce: types.TimeInForceGTC, + }, + Exchange: types.ExchangeBitget, + OrderID: 1148903850645331968, + UUID: "1148903850645331968", + Status: types.OrderStatusCanceled, + ExecutedQuantity: fixedpoint.MustNewFromString("0"), + IsWorking: false, + CreationTime: types.Time(types.NewMillisecondTimestampFromInt(1709645944272).Time()), + UpdateTime: types.Time(types.NewMillisecondTimestampFromInt(1709648518713).Time()), + }, + { + SubmitOrder: types.SubmitOrder{ + ClientOrderID: "bf3ba805-66bc-4ef6-bf34-d63d79dc2e4c", + Symbol: expBtcSymbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeMarket, + Quantity: fixedpoint.MustNewFromString("0.000089"), + Price: fixedpoint.MustNewFromString("67360.87"), + TimeInForce: types.TimeInForceGTC, + }, + Exchange: types.ExchangeBitget, + OrderID: 1148914989018062853, + UUID: "1148914989018062853", + Status: types.OrderStatusFilled, + ExecutedQuantity: fixedpoint.MustNewFromString("0.000089"), + IsWorking: false, + CreationTime: types.Time(types.NewMillisecondTimestampFromInt(1709648599867).Time()), + UpdateTime: types.Time(types.NewMillisecondTimestampFromInt(1709648600016).Time()), + }, + } + assert.Equal(expOrder, orders) + }) + + t.Run("adjust time range since unexpected since and until", func(t *testing.T) { + timeNow := time.Now().Truncate(time.Second) + ex.timeNowFn = func() time.Time { + return timeNow + } + defer func() { ex.timeNowFn = func() time.Time { return time.Now() } }() + newSince := timeNow.Add(-maxHistoricalDataQueryPeriod * 2) + expNewSince := timeNow.Add(-maxHistoricalDataQueryPeriod) + newUntil := timeNow.Add(-maxHistoricalDataQueryPeriod) + expNewUntil := timeNow + + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + // order history + historyOrderFile, err := os.ReadFile("bitgetapi/v2/testdata/get_history_orders_request.json") + assert.NoError(err) + + transport.GET(url, func(req *http.Request) (*http.Response, error) { + query := req.URL.Query() + assert.Len(query, 4) + assert.Contains(query, "startTime") + assert.Contains(query, "endTime") + assert.Contains(query, "limit") + assert.Contains(query, "symbol") + assert.Equal(query["startTime"], []string{strconv.FormatInt(expNewSince.UnixNano()/int64(time.Millisecond), 10)}) + assert.Equal(query["endTime"], []string{strconv.FormatInt(expNewUntil.UnixNano()/int64(time.Millisecond), 10)}) + assert.Equal(query["limit"], []string{strconv.FormatInt(queryLimit, 10)}) + assert.Equal(query["symbol"], []string{expBtcSymbol}) + return httptesting.BuildResponseString(http.StatusOK, string(historyOrderFile)), nil + }) + + orders, err := ex.QueryClosedOrders(context.Background(), expBtcSymbol, newSince, newUntil, lastOrderId) + assert.NoError(err) + expOrder := []types.Order{ + { + SubmitOrder: types.SubmitOrder{ + ClientOrderID: "684a79df-f931-474f-a9a5-f1deab1cd770", + Symbol: expBtcSymbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: fixedpoint.MustNewFromString("0.00009"), + Price: fixedpoint.MustNewFromString("66000"), + TimeInForce: types.TimeInForceGTC, + }, + Exchange: types.ExchangeBitget, + OrderID: 1148903850645331968, + UUID: "1148903850645331968", + Status: types.OrderStatusCanceled, + ExecutedQuantity: fixedpoint.MustNewFromString("0"), + IsWorking: false, + CreationTime: types.Time(types.NewMillisecondTimestampFromInt(1709645944272).Time()), + UpdateTime: types.Time(types.NewMillisecondTimestampFromInt(1709648518713).Time()), + }, + { + SubmitOrder: types.SubmitOrder{ + ClientOrderID: "bf3ba805-66bc-4ef6-bf34-d63d79dc2e4c", + Symbol: expBtcSymbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeMarket, + Quantity: fixedpoint.MustNewFromString("0.000089"), + Price: fixedpoint.MustNewFromString("67360.87"), + TimeInForce: types.TimeInForceGTC, + }, + Exchange: types.ExchangeBitget, + OrderID: 1148914989018062853, + UUID: "1148914989018062853", + Status: types.OrderStatusFilled, + ExecutedQuantity: fixedpoint.MustNewFromString("0.000089"), + IsWorking: false, + CreationTime: types.Time(types.NewMillisecondTimestampFromInt(1709648599867).Time()), + UpdateTime: types.Time(types.NewMillisecondTimestampFromInt(1709648600016).Time()), + }, + } + assert.Equal(expOrder, orders) + }) + + t.Run("error", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + f, err := os.ReadFile("bitgetapi/v2/testdata/request_error.json") + assert.NoError(err) + + transport.GET(url, func(req *http.Request) (*http.Response, error) { + return httptesting.BuildResponseString(http.StatusBadRequest, string(f)), nil + }) + + _, err = ex.QueryClosedOrders(context.Background(), expBtcSymbol, since, until, lastOrderId) + assert.ErrorContains(err, "Invalid IP") + }) +} + +func TestExchange_CancelOrders(t *testing.T) { + var ( + assert = assert.New(t) + ex = New("key", "secret", "passphrase") + cancelOrderUrl = "/api/v2/spot/trade/cancel-order" + order = types.Order{ + OrderID: 1149899973610643488, + SubmitOrder: types.SubmitOrder{ + ClientOrderID: "9471cf38-33c2-4aee-a2fb-fcf71629ffb7", + }, + } + ) + + t.Run("order id first, either orderId or clientOid is required", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + // order history + historyOrderFile, err := os.ReadFile("bitgetapi/v2/testdata/cancel_order_request.json") + assert.NoError(err) + + transport.POST(cancelOrderUrl, func(req *http.Request) (*http.Response, error) { + raw, err := io.ReadAll(req.Body) + assert.NoError(err) + + type cancelOrder struct { + OrderId string `json:"orderId"` + ClientOrderId string `json:"clientOrderId"` + } + reqq := &cancelOrder{} + err = json.Unmarshal(raw, &reqq) + assert.NoError(err) + + assert.Equal(strconv.FormatUint(order.OrderID, 10), reqq.OrderId) + assert.Equal("", reqq.ClientOrderId) + return httptesting.BuildResponseString(http.StatusOK, string(historyOrderFile)), nil + }) + + err = ex.CancelOrders(context.Background(), order) + assert.NoError(err) + }) + + t.Run("unexpected order id and client order id", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + transport.POST(cancelOrderUrl, func(req *http.Request) (*http.Response, error) { + raw, err := io.ReadAll(req.Body) + assert.NoError(err) + + type cancelOrder struct { + OrderId string `json:"orderId"` + ClientOrderId string `json:"clientOrderId"` + } + reqq := &cancelOrder{} + err = json.Unmarshal(raw, &reqq) + assert.NoError(err) + + assert.Equal(strconv.FormatUint(order.OrderID, 10), reqq.OrderId) + assert.Equal("", reqq.ClientOrderId) + + reqq.OrderId = "123456789" + reqq.ClientOrderId = "test wrong order client id" + raw, err = json.Marshal(reqq) + assert.NoError(err) + apiResp := v2.APIResponse{Code: "00000", Data: raw} + raw, err = json.Marshal(apiResp) + assert.NoError(err) + return httptesting.BuildResponseString(http.StatusOK, string(raw)), nil + }) + + err := ex.CancelOrders(context.Background(), order) + assert.ErrorContains(err, "order id mismatch") + }) + + t.Run("client order id", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + // order history + historyOrderFile, err := os.ReadFile("bitgetapi/v2/testdata/cancel_order_request.json") + assert.NoError(err) + + transport.POST(cancelOrderUrl, func(req *http.Request) (*http.Response, error) { + raw, err := io.ReadAll(req.Body) + assert.NoError(err) + + type cancelOrder struct { + OrderId string `json:"orderId"` + ClientOrderId string `json:"clientOid"` + } + reqq := &cancelOrder{} + err = json.Unmarshal(raw, &reqq) + assert.NoError(err) + + assert.Equal("", reqq.OrderId) + assert.Equal(order.ClientOrderID, reqq.ClientOrderId) + return httptesting.BuildResponseString(http.StatusOK, string(historyOrderFile)), nil + }) + + newOrder := order + newOrder.OrderID = 0 + err = ex.CancelOrders(context.Background(), newOrder) + assert.NoError(err) + }) + + t.Run("empty order id and client order id", func(t *testing.T) { + newOrder := order + newOrder.OrderID = 0 + newOrder.ClientOrderID = "" + err := ex.CancelOrders(context.Background(), newOrder) + assert.ErrorContains(err, "the order uuid and client order id are empty") + }) + + t.Run("error", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + f, err := os.ReadFile("bitgetapi/v2/testdata/request_error.json") + assert.NoError(err) + + transport.POST(cancelOrderUrl, func(req *http.Request) (*http.Response, error) { + return httptesting.BuildResponseString(http.StatusBadRequest, string(f)), nil + }) + + err = ex.CancelOrders(context.Background(), order) + assert.ErrorContains(err, "Invalid IP") + }) +} + +func TestExchange_QueryTrades(t *testing.T) { + var ( + assert = assert.New(t) + ex = New("key", "secret", "passphrase") + expApeSymbol = "APEUSDT" + since = time.Now().Add(-24 * time.Hour) + until = since.Add(time.Hour) + options = &types.TradeQueryOptions{ + StartTime: &since, + EndTime: &until, + Limit: queryLimit, + LastTradeID: 0, + } + url = "/api/v2/spot/trade/fills" + expOrder = []types.Trade{ + { + ID: 1149103068190019665, + OrderID: 1149103067745689603, + Exchange: types.ExchangeBitget, + Price: fixedpoint.MustNewFromString("1.9959"), + Quantity: fixedpoint.MustNewFromString("2.98"), + QuoteQuantity: fixedpoint.MustNewFromString("5.947782"), + Symbol: expApeSymbol, + Side: types.SideTypeSell, + IsBuyer: false, + IsMaker: false, + Time: types.Time(types.NewMillisecondTimestampFromInt(1709693441436).Time()), + Fee: fixedpoint.MustNewFromString("0.005947782"), + FeeCurrency: "USDT", + FeeDiscounted: false, + }, + { + ID: 1149101368775479371, + OrderID: 1149101366691176462, + Exchange: types.ExchangeBitget, + Price: fixedpoint.MustNewFromString("2.01"), + Quantity: fixedpoint.MustNewFromString("0.0013"), + QuoteQuantity: fixedpoint.MustNewFromString("0.002613"), + Symbol: expApeSymbol, + Side: types.SideTypeBuy, + IsBuyer: true, + IsMaker: true, + Time: types.Time(types.NewMillisecondTimestampFromInt(1709693036264).Time()), + Fee: fixedpoint.MustNewFromString("0.0000013"), + FeeCurrency: "APE", + FeeDiscounted: false, + }, + { + ID: 1149098107964166145, + OrderID: 1149098107519836161, + Exchange: types.ExchangeBitget, + Price: fixedpoint.MustNewFromString("2.0087"), + Quantity: fixedpoint.MustNewFromString("2.987"), + QuoteQuantity: fixedpoint.MustNewFromString("5.9999869"), + Symbol: expApeSymbol, + Side: types.SideTypeBuy, + IsBuyer: true, + IsMaker: false, + Time: types.Time(types.NewMillisecondTimestampFromInt(1709692258826).Time()), + Fee: fixedpoint.MustNewFromString("0.002987"), + FeeCurrency: "APE", + FeeDiscounted: false, + }, + { + ID: 1149096769322684417, + OrderID: 1149096768878354435, + Exchange: types.ExchangeBitget, + Price: fixedpoint.MustNewFromString("2.0068"), + Quantity: fixedpoint.MustNewFromString("2.9603"), + QuoteQuantity: fixedpoint.MustNewFromString("5.94073004"), + Symbol: expApeSymbol, + Side: types.SideTypeSell, + IsBuyer: false, + IsMaker: false, + Time: types.Time(types.NewMillisecondTimestampFromInt(1709691939669).Time()), + Fee: fixedpoint.MustNewFromString("0.00594073004"), + FeeCurrency: "USDT", + FeeDiscounted: false, + }, + } + ) + + t.Run("succeeds", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + // order history + historyOrderFile, err := os.ReadFile("bitgetapi/v2/testdata/get_trade_fills_request.json") + assert.NoError(err) + + transport.GET(url, func(req *http.Request) (*http.Response, error) { + query := req.URL.Query() + assert.Len(query, 4) + assert.Contains(query, "startTime") + assert.Contains(query, "endTime") + assert.Contains(query, "limit") + assert.Contains(query, "symbol") + assert.Equal(query["startTime"], []string{strconv.FormatInt(since.UnixNano()/int64(time.Millisecond), 10)}) + assert.Equal(query["endTime"], []string{strconv.FormatInt(until.UnixNano()/int64(time.Millisecond), 10)}) + assert.Equal(query["limit"], []string{strconv.FormatInt(queryLimit, 10)}) + assert.Equal(query["symbol"], []string{expApeSymbol}) + return httptesting.BuildResponseString(http.StatusOK, string(historyOrderFile)), nil + }) + + orders, err := ex.QueryTrades(context.Background(), expApeSymbol, options) + assert.NoError(err) + assert.Equal(expOrder, orders) + }) + + t.Run("succeeds with lastTradeId (not supported) and limit 0", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + // order history + historyOrderFile, err := os.ReadFile("bitgetapi/v2/testdata/get_trade_fills_request.json") + assert.NoError(err) + + transport.GET(url, func(req *http.Request) (*http.Response, error) { + query := req.URL.Query() + assert.Len(query, 4) + assert.Contains(query, "startTime") + assert.Contains(query, "endTime") + assert.Contains(query, "limit") + assert.Contains(query, "symbol") + assert.Equal(query["startTime"], []string{strconv.FormatInt(since.UnixNano()/int64(time.Millisecond), 10)}) + assert.Equal(query["endTime"], []string{strconv.FormatInt(until.UnixNano()/int64(time.Millisecond), 10)}) + assert.Equal(query["limit"], []string{strconv.FormatInt(queryLimit, 10)}) + assert.Equal(query["symbol"], []string{expApeSymbol}) + return httptesting.BuildResponseString(http.StatusOK, string(historyOrderFile)), nil + }) + + newOpts := *options + newOpts.LastTradeID = 50 + newOpts.Limit = 0 + orders, err := ex.QueryTrades(context.Background(), expApeSymbol, &newOpts) + assert.NoError(err) + assert.Equal(expOrder, orders) + }) + + t.Run("adjust time range since unexpected since and until", func(t *testing.T) { + timeNow := time.Now().Truncate(time.Second) + ex.timeNowFn = func() time.Time { + return timeNow + } + defer func() { ex.timeNowFn = func() time.Time { return time.Now() } }() + newSince := timeNow.Add(-maxHistoricalDataQueryPeriod * 2) + expNewSince := timeNow.Add(-maxHistoricalDataQueryPeriod) + newUntil := timeNow.Add(-maxHistoricalDataQueryPeriod + 24*time.Hour) + newOpts := *options + newOpts.StartTime = &newSince + newOpts.EndTime = &newUntil + + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + // order history + historyOrderFile, err := os.ReadFile("bitgetapi/v2/testdata/get_trade_fills_request.json") + assert.NoError(err) + + transport.GET(url, func(req *http.Request) (*http.Response, error) { + query := req.URL.Query() + assert.Len(query, 4) + assert.Contains(query, "startTime") + assert.Contains(query, "endTime") + assert.Contains(query, "limit") + assert.Contains(query, "symbol") + assert.Equal(query["startTime"], []string{strconv.FormatInt(expNewSince.UnixNano()/int64(time.Millisecond), 10)}) + assert.Equal(query["endTime"], []string{strconv.FormatInt(newUntil.UnixNano()/int64(time.Millisecond), 10)}) + assert.Equal(query["limit"], []string{strconv.FormatInt(queryLimit, 10)}) + assert.Equal(query["symbol"], []string{expApeSymbol}) + return httptesting.BuildResponseString(http.StatusOK, string(historyOrderFile)), nil + }) + + orders, err := ex.QueryTrades(context.Background(), expApeSymbol, &newOpts) + assert.NoError(err) + assert.Equal(expOrder, orders) + }) + + t.Run("failed due to empty since", func(t *testing.T) { + timeNow := time.Now().Truncate(time.Second) + ex.timeNowFn = func() time.Time { + return timeNow + } + defer func() { ex.timeNowFn = func() time.Time { return time.Now() } }() + newSince := timeNow.Add(-maxHistoricalDataQueryPeriod * 2) + newUntil := timeNow.Add(-maxHistoricalDataQueryPeriod - time.Second) + newOpts := *options + newOpts.StartTime = &newSince + newOpts.EndTime = &newUntil + + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + _, err := ex.QueryTrades(context.Background(), expApeSymbol, &newOpts) + assert.ErrorContains(err, "before start") + }) + + t.Run("failed due to empty since", func(t *testing.T) { + newOpts := *options + newOpts.StartTime = nil + + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + _, err := ex.QueryTrades(context.Background(), expApeSymbol, &newOpts) + assert.ErrorContains(err, "start time is required") + }) + + t.Run("error", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + f, err := os.ReadFile("bitgetapi/v2/testdata/request_error.json") + assert.NoError(err) + + transport.GET(url, func(req *http.Request) (*http.Response, error) { + return httptesting.BuildResponseString(http.StatusBadRequest, string(f)), nil + }) + + _, err = ex.QueryTrades(context.Background(), expApeSymbol, options) + assert.ErrorContains(err, "Invalid IP") + }) +} diff --git a/pkg/exchange/bitget/stream.go b/pkg/exchange/bitget/stream.go new file mode 100644 index 0000000..9516c7f --- /dev/null +++ b/pkg/exchange/bitget/stream.go @@ -0,0 +1,477 @@ +package bitget + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/gorilla/websocket" + "golang.org/x/time/rate" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bitget/bitgetapi" + v2 "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bitget/bitgetapi/v2" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +var ( + pingBytes = []byte("ping") + pongBytes = []byte("pong") + + marketTradeLogLimiter = rate.NewLimiter(rate.Every(time.Minute), 1) + tradeLogLimiter = rate.NewLimiter(rate.Every(time.Minute), 1) + orderLogLimiter = rate.NewLimiter(rate.Every(time.Minute), 1) + kLineLogLimiter = rate.NewLimiter(rate.Every(time.Minute), 1) +) + +//go:generate callbackgen -type Stream +type Stream struct { + types.StandardStream + + privateChannelSymbols []string + + key, secret, passphrase string + bookEventCallbacks []func(o BookEvent) + marketTradeEventCallbacks []func(o MarketTradeEvent) + KLineEventCallbacks []func(o KLineEvent) + + accountEventCallbacks []func(e AccountEvent) + orderTradeEventCallbacks []func(e OrderTradeEvent) + + lastCandle map[string]types.KLine +} + +func NewStream(key, secret, passphrase string) *Stream { + stream := &Stream{ + StandardStream: types.NewStandardStream(), + lastCandle: map[string]types.KLine{}, + key: key, + secret: secret, + passphrase: passphrase, + } + + stream.SetEndpointCreator(stream.createEndpoint) + stream.SetParser(parseWebSocketEvent) + stream.SetDispatcher(stream.dispatchEvent) + stream.SetHeartBeat(stream.ping) + stream.OnConnect(stream.handlerConnect) + + stream.OnBookEvent(stream.handleBookEvent) + stream.OnMarketTradeEvent(stream.handleMaretTradeEvent) + stream.OnKLineEvent(stream.handleKLineEvent) + + stream.OnAuth(stream.handleAuth) + stream.OnAccountEvent(stream.handleAccountEvent) + stream.OnOrderTradeEvent(stream.handleOrderTradeEvent) + return stream +} + +func (s *Stream) syncSubscriptions(opType WsEventType) error { + if opType != WsEventUnsubscribe && opType != WsEventSubscribe { + return fmt.Errorf("unexpected subscription type: %v", opType) + } + + logger := log.WithField("opType", opType) + var args []WsArg + for _, subscription := range s.Subscriptions { + arg, err := convertSubscription(subscription) + if err != nil { + logger.WithError(err).Errorf("convert error, subscription: %+v", subscription) + return err + } + + args = append(args, arg) + } + + logger.Infof("%s channels: %+v", opType, args) + + batchSize := 10 + lenArgs := len(args) + for begin := 0; begin < lenArgs; begin += batchSize { + end := begin + batchSize + if end > lenArgs { + end = lenArgs + } + + if err := s.Conn.WriteJSON(WsOp{ + Op: opType, + Args: args[begin:end], + }); err != nil { + logger.WithError(err).Error("failed to send request") + return err + } + } + + return nil +} + +func (s *Stream) Unsubscribe() { + // errors are handled in the syncSubscriptions, so they are skipped here. + _ = s.syncSubscriptions(WsEventUnsubscribe) + s.Resubscribe(func(old []types.Subscription) (new []types.Subscription, err error) { + // clear the subscriptions + return []types.Subscription{}, nil + }) +} + +func (s *Stream) createEndpoint(_ context.Context) (string, error) { + var url string + if s.PublicOnly { + url = bitgetapi.PublicWebSocketURL + } else { + url = v2.PrivateWebSocketURL + } + return url, nil +} + +func (s *Stream) dispatchEvent(event interface{}) { + switch e := event.(type) { + case *WsEvent: + if err := e.IsValid(); err != nil { + log.Errorf("invalid event: %v", err) + return + } + if e.IsAuthenticated() { + s.EmitAuth() + } + + case *BookEvent: + s.EmitBookEvent(*e) + + case *MarketTradeEvent: + s.EmitMarketTradeEvent(*e) + + case *KLineEvent: + s.EmitKLineEvent(*e) + + case *AccountEvent: + s.EmitAccountEvent(*e) + + case *OrderTradeEvent: + s.EmitOrderTradeEvent(*e) + + case []byte: + // We only handle the 'pong' case. Others are unexpected. + if !bytes.Equal(e, pongBytes) { + log.Errorf("invalid event: %q", e) + } + } +} + +// handleAuth subscribe private stream channels. Because Bitget doesn't allow authentication and subscription to be used +// consecutively, we subscribe after authentication confirmation. +func (s *Stream) handleAuth() { + op := WsOp{ + Op: WsEventSubscribe, + Args: []WsArg{ + { + InstType: instSpV2, + Channel: ChannelAccount, + Coin: "default", // all coins + }, + }, + } + if len(s.privateChannelSymbols) > 0 { + for _, symbol := range s.privateChannelSymbols { + op.Args = append(op.Args, WsArg{ + InstType: instSpV2, + Channel: ChannelOrders, + InstId: symbol, + }) + } + } else { + log.Warnf("you have not subscribed to any order channels") + } + + if err := s.Conn.WriteJSON(op); err != nil { + log.WithError(err).Error("failed to send subscription request") + return + } +} + +func (s *Stream) SetPrivateChannelSymbols(symbols []string) { + s.privateChannelSymbols = symbols +} + +func (s *Stream) handlerConnect() { + if s.PublicOnly { + // errors are handled in the syncSubscriptions, so they are skipped here. + _ = s.syncSubscriptions(WsEventSubscribe) + } else { + timestamp := strconv.FormatInt(time.Now().Unix(), 10) + + if err := s.Conn.WriteJSON(WsOp{ + Op: WsEventLogin, + Args: []WsArg{ + { + ApiKey: s.key, + Passphrase: s.passphrase, + Timestamp: timestamp, + Sign: bitgetapi.Sign(fmt.Sprintf("%sGET/user/verify", timestamp), s.secret), + }, + }, + }); err != nil { + log.WithError(err).Error("failed to auth request") + return + } + } +} + +func (s *Stream) handleBookEvent(o BookEvent) { + for _, book := range o.ToGlobalOrderBooks() { + switch o.actionType { + case ActionTypeSnapshot: + s.EmitBookSnapshot(book) + + case ActionTypeUpdate: + s.EmitBookUpdate(book) + } + } +} + +// ping implements the bitget text message of WebSocket PingPong. +func (s *Stream) ping(conn *websocket.Conn) error { + err := conn.WriteMessage(websocket.TextMessage, pingBytes) + if err != nil { + log.WithError(err).Error("ping error", err) + return nil + } + return nil +} + +func convertSubscription(sub types.Subscription) (WsArg, error) { + arg := WsArg{ + // support spot only + InstType: instSp, + Channel: "", + InstId: sub.Symbol, + } + + switch sub.Channel { + case types.BookChannel: + arg.Channel = ChannelOrderBook5 + + switch sub.Options.Depth { + case types.DepthLevel5: + arg.Channel = ChannelOrderBook5 + case types.DepthLevel15, types.DepthLevelMedium: + arg.Channel = ChannelOrderBook15 + case types.DepthLevel200, types.DepthLevelFull: + log.Warn("*** The subscription events for the order book may return fewer than 200 bids/asks at a depth of 200. ***") + arg.Channel = ChannelOrderBook + } + return arg, nil + + case types.MarketTradeChannel: + arg.Channel = ChannelTrade + return arg, nil + + case types.KLineChannel: + interval, found := toLocalInterval[sub.Options.Interval] + if !found { + return WsArg{}, fmt.Errorf("interval %s not supported on KLine subscription", sub.Options.Interval) + } + + arg.Channel = ChannelType(interval) + return arg, nil + } + + return arg, fmt.Errorf("unsupported stream channel: %s", sub.Channel) +} + +func parseWebSocketEvent(in []byte) (interface{}, error) { + switch { + case bytes.Equal(in, pongBytes): + // return global pong event to avoid emit raw message + return types.WebsocketPongEvent{}, nil + + default: + return parseEvent(in) + } +} + +func parseEvent(in []byte) (interface{}, error) { + var event WsEvent + + err := json.Unmarshal(in, &event) + if err != nil { + return nil, err + } + + if event.IsOp() { + return &event, nil + } + + ch := event.Arg.Channel + switch ch { + case ChannelAccount: + var acct AccountEvent + err = json.Unmarshal(event.Data, &acct.Balances) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal data into AccountEvent, Arg: %+v Data: %s, err: %w", event.Arg, string(event.Data), err) + } + + acct.actionType = event.Action + acct.instId = event.Arg.InstId + return &acct, nil + + case ChannelOrderBook, ChannelOrderBook5, ChannelOrderBook15: + var book BookEvent + err = json.Unmarshal(event.Data, &book.Events) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal data into BookEvent, Arg: %+v Data: %s, err: %w", event.Arg, string(event.Data), err) + } + + book.actionType = event.Action + book.instId = event.Arg.InstId + return &book, nil + + case ChannelOrders: + var order OrderTradeEvent + err = json.Unmarshal(event.Data, &order.Orders) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal data into OrderTradeEvent, Arg: %+v Data: %s, err: %w", event.Arg, string(event.Data), err) + } + + order.actionType = event.Action + order.instId = event.Arg.InstId + return &order, nil + + case ChannelTrade: + var trade MarketTradeEvent + err = json.Unmarshal(event.Data, &trade.Events) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal data into MarketTradeEvent, Arg: %+v Data: %s, err: %w", event.Arg, string(event.Data), err) + } + + trade.actionType = event.Action + trade.instId = event.Arg.InstId + return &trade, nil + + default: + + // handle the `KLine` case here to avoid complicating the code structure. + if strings.HasPrefix(string(ch), "candle") { + var kline KLineEvent + err = json.Unmarshal(event.Data, &kline.Events) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal data into KLineEvent, Arg: %+v Data: %s, err: %w", event.Arg, string(event.Data), err) + } + + kline.actionType = event.Action + kline.channel = ch + kline.instId = event.Arg.InstId + return &kline, nil + } + // return an error for any other case + + return nil, fmt.Errorf("unhandled websocket event: %+v", string(in)) + } +} + +func (s *Stream) handleMaretTradeEvent(m MarketTradeEvent) { + if m.actionType == ActionTypeSnapshot { + // we don't support snapshot event + return + } + for _, trade := range m.Events { + globalTrade, err := trade.ToGlobal(m.instId) + if err != nil { + if marketTradeLogLimiter.Allow() { + log.WithError(err).Error("failed to convert to market trade") + } + return + } + + s.EmitMarketTrade(globalTrade) + } +} + +func (s *Stream) handleKLineEvent(k KLineEvent) { + if k.actionType == ActionTypeSnapshot { + // we don't support snapshot event + return + } + + interval, found := toGlobalInterval[string(k.channel)] + if !found { + if kLineLogLimiter.Allow() { + log.Errorf("unexpected interval %s on KLine subscription", k.channel) + } + return + } + + for _, kline := range k.Events { + last, ok := s.lastCandle[k.CacheKey()] + if ok && kline.StartTime.Time().After(last.StartTime.Time()) { + last.Closed = true + s.EmitKLineClosed(last) + } + + kLine := kline.ToGlobal(interval, k.instId) + s.EmitKLine(kLine) + s.lastCandle[k.CacheKey()] = kLine + } +} + +func (s *Stream) handleAccountEvent(m AccountEvent) { + balanceMap := toGlobalBalanceMap(m.Balances) + if len(balanceMap) == 0 { + return + } + + if m.actionType == ActionTypeUpdate { + s.StandardStream.EmitBalanceUpdate(balanceMap) + return + } + s.StandardStream.EmitBalanceSnapshot(balanceMap) +} + +func (s *Stream) handleOrderTradeEvent(m OrderTradeEvent) { + if len(m.Orders) == 0 { + return + } + + debugf("received OrderTradeEvent: %+v", m) + + for _, order := range m.Orders { + debugf("received Order: %+v", order) + + globalOrder, err := order.toGlobalOrder() + if err != nil { + if orderLogLimiter.Allow() { + log.Errorf("failed to convert order to global: %s", err) + } + continue + } + + // The bitget support only snapshot on orders channel, so we use snapshot as update to emit data. + if m.actionType != ActionTypeSnapshot { + continue + } + + s.StandardStream.EmitOrderUpdate(globalOrder) + + if order.TradeId == 0 { + continue + } + + debugf("received Trade: %+v", order.Trade) + + switch globalOrder.Status { + case types.OrderStatusPartiallyFilled, types.OrderStatusFilled: + trade, err := order.toGlobalTrade() + if err != nil { + if tradeLogLimiter.Allow() { + log.Errorf("failed to convert trade to global: %s", err) + } + continue + } + + s.StandardStream.EmitTradeUpdate(trade) + } + } +} diff --git a/pkg/exchange/bitget/stream_callbacks.go b/pkg/exchange/bitget/stream_callbacks.go new file mode 100644 index 0000000..01dea0f --- /dev/null +++ b/pkg/exchange/bitget/stream_callbacks.go @@ -0,0 +1,55 @@ +// Code generated by "callbackgen -type Stream"; DO NOT EDIT. + +package bitget + +import () + +func (s *Stream) OnBookEvent(cb func(o BookEvent)) { + s.bookEventCallbacks = append(s.bookEventCallbacks, cb) +} + +func (s *Stream) EmitBookEvent(o BookEvent) { + for _, cb := range s.bookEventCallbacks { + cb(o) + } +} + +func (s *Stream) OnMarketTradeEvent(cb func(o MarketTradeEvent)) { + s.marketTradeEventCallbacks = append(s.marketTradeEventCallbacks, cb) +} + +func (s *Stream) EmitMarketTradeEvent(o MarketTradeEvent) { + for _, cb := range s.marketTradeEventCallbacks { + cb(o) + } +} + +func (s *Stream) OnKLineEvent(cb func(o KLineEvent)) { + s.KLineEventCallbacks = append(s.KLineEventCallbacks, cb) +} + +func (s *Stream) EmitKLineEvent(o KLineEvent) { + for _, cb := range s.KLineEventCallbacks { + cb(o) + } +} + +func (s *Stream) OnAccountEvent(cb func(e AccountEvent)) { + s.accountEventCallbacks = append(s.accountEventCallbacks, cb) +} + +func (s *Stream) EmitAccountEvent(e AccountEvent) { + for _, cb := range s.accountEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnOrderTradeEvent(cb func(e OrderTradeEvent)) { + s.orderTradeEventCallbacks = append(s.orderTradeEventCallbacks, cb) +} + +func (s *Stream) EmitOrderTradeEvent(e OrderTradeEvent) { + for _, cb := range s.orderTradeEventCallbacks { + cb(e) + } +} diff --git a/pkg/exchange/bitget/stream_test.go b/pkg/exchange/bitget/stream_test.go new file mode 100644 index 0000000..c071fb1 --- /dev/null +++ b/pkg/exchange/bitget/stream_test.go @@ -0,0 +1,766 @@ +package bitget + +import ( + "context" + "fmt" + "os" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func getTestClientOrSkip(t *testing.T) *Stream { + if b, _ := strconv.ParseBool(os.Getenv("CI")); b { + t.Skip("skip test for CI") + } + + return NewStream(os.Getenv("BITGET_API_KEY"), + os.Getenv("BITGET_API_SECRET"), + os.Getenv("BITGET_API_PASSPHRASE")) +} + +func TestStream(t *testing.T) { + t.Skip() + s := getTestClientOrSkip(t) + + symbols := []string{ + "AAVEUSDT", + "ADAUSDT", + "ALICEUSDT", + "AXSUSDT", + "BTCUSDT", + "COMPUSDT", + "DAIUSDT", + "DOGEUSDT", + "DOTUSDT", + "ETHUSDT", + "FILUSDT", + "GALAUSDT", + "GRTUSDT", + "LINKUSDT", + "LTCUSDT", + "MANAUSDT", + "MATICUSDT", + "PAXGUSDT", + "SANDUSDT", + "SLPUSDT", + "SOLUSDT", + "UNIUSDT", + "XLMUSDT", + "YFIUSDT", + "APEUSDT", + "ARUSDT", + "BNBUSDT", + "CHZUSDT", + "ENSUSDT", + "ETCUSDT", + "FTMUSDT", + "GMTUSDT", + "LOOKSUSDT", + "XTZUSDT", + "ARBUSDT", + "LDOUSDT", + "TRXUSDT", + } + + t.Run("book test", func(t *testing.T) { + s.Subscribe(types.BookChannel, "BTCUSDT", types.SubscribeOptions{ + Depth: types.DepthLevel5, + }) + s.SetPublicOnly() + err := s.Connect(context.Background()) + assert.NoError(t, err) + + s.OnBookSnapshot(func(book types.SliceOrderBook) { + t.Log("got snapshot", len(book.Bids), len(book.Asks), book.Symbol, book.Time, book) + }) + s.OnBookUpdate(func(book types.SliceOrderBook) { + t.Log("got update", len(book.Bids), len(book.Asks), book.Symbol, book.Time, book) + }) + c := make(chan struct{}) + <-c + }) + + t.Run("book test on unsubscribe and reconnect", func(t *testing.T) { + for _, symbol := range symbols { + s.Subscribe(types.BookChannel, symbol, types.SubscribeOptions{ + Depth: types.DepthLevel200, + }) + } + + s.SetPublicOnly() + err := s.Connect(context.Background()) + assert.NoError(t, err) + + s.OnBookSnapshot(func(book types.SliceOrderBook) { + t.Log("got snapshot", book) + }) + s.OnBookUpdate(func(book types.SliceOrderBook) { + t.Log("got update", book) + }) + + <-time.After(2 * time.Second) + + s.Unsubscribe() + for _, symbol := range symbols { + s.Subscribe(types.BookChannel, symbol, types.SubscribeOptions{ + Depth: types.DepthLevel200, + }) + } + + <-time.After(2 * time.Second) + + s.Reconnect() + + c := make(chan struct{}) + <-c + }) + + t.Run("trade test", func(t *testing.T) { + s.Subscribe(types.MarketTradeChannel, "BTCUSDT", types.SubscribeOptions{}) + s.SetPublicOnly() + err := s.Connect(context.Background()) + assert.NoError(t, err) + + s.OnMarketTrade(func(trade types.Trade) { + t.Log("got update", trade) + }) + c := make(chan struct{}) + <-c + }) + + t.Run("kline test", func(t *testing.T) { + s.Subscribe(types.KLineChannel, "BTCUSDT", types.SubscribeOptions{Interval: types.Interval1w}) + s.SetPublicOnly() + err := s.Connect(context.Background()) + assert.NoError(t, err) + + s.OnKLine(func(kline types.KLine) { + t.Log("got update", kline) + }) + s.OnKLineClosed(func(kline types.KLine) { + t.Log("got closed update", kline) + }) + c := make(chan struct{}) + <-c + }) + + t.Run("private test", func(t *testing.T) { + s.SetPrivateChannelSymbols([]string{"BTCUSDT"}) + err := s.Connect(context.Background()) + assert.NoError(t, err) + + s.OnBalanceSnapshot(func(balances types.BalanceMap) { + t.Log("get balances", balances) + }) + s.OnBalanceUpdate(func(balances types.BalanceMap) { + t.Log("get update", balances) + }) + s.OnOrderUpdate(func(order types.Order) { + t.Log("order update", order) + }) + s.OnTradeUpdate(func(trade types.Trade) { + t.Log("trade update", trade) + }) + + c := make(chan struct{}) + <-c + }) + +} + +func TestStream_parseWebSocketEvent(t *testing.T) { + t.Run("op subscribe event", func(t *testing.T) { + input := `{ + "event":"subscribe", + "arg":{ + "instType":"sp", + "channel":"books5", + "instId":"BTCUSDT" + } + }` + res, err := parseWebSocketEvent([]byte(input)) + assert.NoError(t, err) + opEvent, ok := res.(*WsEvent) + assert.True(t, ok) + assert.Equal(t, WsEvent{ + Event: WsEventSubscribe, + Arg: WsArg{ + InstType: instSp, + Channel: ChannelOrderBook5, + InstId: "BTCUSDT", + }, + }, *opEvent) + + assert.NoError(t, opEvent.IsValid()) + }) + + t.Run("op unsubscribe event", func(t *testing.T) { + input := `{ + "event":"unsubscribe", + "arg":{ + "instType":"sp", + "channel":"books5", + "instId":"BTCUSDT" + } + }` + res, err := parseWebSocketEvent([]byte(input)) + assert.NoError(t, err) + opEvent, ok := res.(*WsEvent) + assert.True(t, ok) + assert.Equal(t, WsEvent{ + Event: WsEventUnsubscribe, + Arg: WsArg{ + InstType: instSp, + Channel: ChannelOrderBook5, + InstId: "BTCUSDT", + }, + }, *opEvent) + }) + + t.Run("op error event", func(t *testing.T) { + input := `{ + "event":"error", + "arg":{ + "instType":"sp", + "channel":"books5", + "instId":"BTCUSDT-" + }, + "code":30001, + "msg":"instType:sp,channel:books5,instId:BTCUSDT- doesn't exist", + "op":"subscribe" + }` + res, err := parseWebSocketEvent([]byte(input)) + assert.NoError(t, err) + opEvent, ok := res.(*WsEvent) + assert.True(t, ok) + assert.Equal(t, WsEvent{ + Event: WsEventError, + Code: 30001, + Msg: "instType:sp,channel:books5,instId:BTCUSDT- doesn't exist", + Op: "subscribe", + Arg: WsArg{ + InstType: instSp, + Channel: ChannelOrderBook5, + InstId: "BTCUSDT-", + }, + }, *opEvent) + }) + + t.Run("Orderbook event", func(t *testing.T) { + input := `{ + "action":"%s", + "arg":{ + "instType":"sp", + "channel":"books5", + "instId":"BTCUSDT" + }, + "data":[ + { + "asks":[ + [ + "28350.78", + "0.2082" + ], + [ + "28350.80", + "0.2081" + ] + ], + "bids":[ + [ + "28350.70", + "0.5585" + ], + [ + "28350.67", + "6.8175" + ] + ], + "checksum":0, + "ts":"1697593934630" + } + ], + "ts":1697593934630 + }` + + eventFn := func(in string, actionType ActionType) { + res, err := parseWebSocketEvent([]byte(in)) + assert.NoError(t, err) + book, ok := res.(*BookEvent) + assert.True(t, ok) + assert.Equal(t, BookEvent{ + Events: []struct { + Asks types.PriceVolumeSlice `json:"asks"` + // Order book on buy side, descending order + Bids types.PriceVolumeSlice `json:"bids"` + Ts types.MillisecondTimestamp `json:"ts"` + Checksum int `json:"checksum"` + }{ + { + Asks: []types.PriceVolume{ + { + Price: fixedpoint.NewFromFloat(28350.78), + Volume: fixedpoint.NewFromFloat(0.2082), + }, + { + Price: fixedpoint.NewFromFloat(28350.80), + Volume: fixedpoint.NewFromFloat(0.2081), + }, + }, + Bids: []types.PriceVolume{ + { + Price: fixedpoint.NewFromFloat(28350.70), + Volume: fixedpoint.NewFromFloat(0.5585), + }, + { + Price: fixedpoint.NewFromFloat(28350.67), + Volume: fixedpoint.NewFromFloat(6.8175), + }, + }, + Ts: types.NewMillisecondTimestampFromInt(1697593934630), + Checksum: 0, + }, + }, + actionType: actionType, + instId: "BTCUSDT", + }, *book) + } + + t.Run("snapshot type", func(t *testing.T) { + snapshotInput := fmt.Sprintf(input, ActionTypeSnapshot) + eventFn(snapshotInput, ActionTypeSnapshot) + }) + + t.Run("update type", func(t *testing.T) { + snapshotInput := fmt.Sprintf(input, ActionTypeUpdate) + eventFn(snapshotInput, ActionTypeUpdate) + }) + }) +} + +func Test_parseWebSocketEvent_MarketTrade(t *testing.T) { + t.Run("MarketTrade event", func(t *testing.T) { + input := `{ + "action":"%s", + "arg":{ + "instType":"sp", + "channel":"trade", + "instId":"BTCUSDT" + }, + "data":[ + [ + "1697697791663", + "28303.43", + "0.0452", + "sell" + ], + [ + "1697697794663", + "28345.67", + "0.1234", + "sell" + ] + ], + "ts":1697697791670 + }` + + eventFn := func(in string, actionType ActionType) { + res, err := parseWebSocketEvent([]byte(in)) + assert.NoError(t, err) + book, ok := res.(*MarketTradeEvent) + assert.True(t, ok) + assert.Equal(t, MarketTradeEvent{ + Events: MarketTradeSlice{ + { + Ts: types.NewMillisecondTimestampFromInt(1697697791663), + Price: fixedpoint.NewFromFloat(28303.43), + Size: fixedpoint.NewFromFloat(0.0452), + Side: "sell", + }, + + { + Ts: types.NewMillisecondTimestampFromInt(1697697794663), + Price: fixedpoint.NewFromFloat(28345.67), + Size: fixedpoint.NewFromFloat(0.1234), + Side: "sell", + }, + }, + actionType: actionType, + instId: "BTCUSDT", + }, *book) + } + + t.Run("snapshot type", func(t *testing.T) { + snapshotInput := fmt.Sprintf(input, ActionTypeSnapshot) + eventFn(snapshotInput, ActionTypeSnapshot) + }) + + t.Run("update type", func(t *testing.T) { + snapshotInput := fmt.Sprintf(input, ActionTypeUpdate) + eventFn(snapshotInput, ActionTypeUpdate) + }) + }) + + t.Run("Unexpected length of market trade", func(t *testing.T) { + input := `{ + "action":"%s", + "arg":{ + "instType":"sp", + "channel":"trade", + "instId":"BTCUSDT" + }, + "data":[ + [ + "1697697791663", + "28303.43", + "28303.43", + "0.0452", + "sell" + ] + ], + "ts":1697697791670 + }` + _, err := parseWebSocketEvent([]byte(input)) + assert.ErrorContains(t, err, "unexpected trades length") + }) + + t.Run("Unexpected timestamp", func(t *testing.T) { + input := `{ + "action":"%s", + "arg":{ + "instType":"sp", + "channel":"trade", + "instId":"BTCUSDT" + }, + "data":[ + [ + "TIMESTAMP", + "28303.43", + "0.0452", + "sell" + ] + ], + "ts":1697697791670 + }` + _, err := parseWebSocketEvent([]byte(input)) + assert.ErrorContains(t, err, "timestamp") + }) + + t.Run("Unexpected price", func(t *testing.T) { + input := `{ + "action":"%s", + "arg":{ + "instType":"sp", + "channel":"trade", + "instId":"BTCUSDT" + }, + "data":[ + [ + "1697697791663", + "1p", + "0.0452", + "sell" + ] + ], + "ts":1697697791670 + }` + _, err := parseWebSocketEvent([]byte(input)) + assert.ErrorContains(t, err, "price") + }) + + t.Run("Unexpected size", func(t *testing.T) { + input := `{ + "action":"%s", + "arg":{ + "instType":"sp", + "channel":"trade", + "instId":"BTCUSDT" + }, + "data":[ + [ + "1697697791663", + "28303.43", + "2v", + "sell" + ] + ], + "ts":1697697791670 + }` + _, err := parseWebSocketEvent([]byte(input)) + assert.ErrorContains(t, err, "size") + }) + + t.Run("Unexpected side", func(t *testing.T) { + input := `{ + "action":"%s", + "arg":{ + "instType":"sp", + "channel":"trade", + "instId":"BTCUSDT" + }, + "data":[ + [ + "1697697791663", + "28303.43", + "0.0452", + 12345 + ] + ], + "ts":1697697791670 + }` + _, err := parseWebSocketEvent([]byte(input)) + assert.ErrorContains(t, err, "side") + }) +} + +func Test_parseWebSocketEvent_KLine(t *testing.T) { + t.Run("KLine event", func(t *testing.T) { + input := `{ + "action":"%s", + "arg":{ + "instType":"sp", + "channel":"candle5m", + "instId":"BTCUSDT" + }, + "data":[ + ["1698744600000","34361.49","34458.98","34355.53","34416.41","99.6631"] + ], + "ts":1697697791670 + }` + + eventFn := func(in string, actionType ActionType) { + res, err := parseWebSocketEvent([]byte(in)) + assert.NoError(t, err) + kline, ok := res.(*KLineEvent) + assert.True(t, ok) + assert.Equal(t, KLineEvent{ + channel: "candle5m", + Events: KLineSlice{ + { + StartTime: types.NewMillisecondTimestampFromInt(1698744600000), + OpenPrice: fixedpoint.NewFromFloat(34361.49), + HighestPrice: fixedpoint.NewFromFloat(34458.98), + LowestPrice: fixedpoint.NewFromFloat(34355.53), + ClosePrice: fixedpoint.NewFromFloat(34416.41), + Volume: fixedpoint.NewFromFloat(99.6631), + }, + }, + actionType: actionType, + instId: "BTCUSDT", + }, *kline) + } + + t.Run("snapshot type", func(t *testing.T) { + snapshotInput := fmt.Sprintf(input, ActionTypeSnapshot) + eventFn(snapshotInput, ActionTypeSnapshot) + }) + + t.Run("update type", func(t *testing.T) { + snapshotInput := fmt.Sprintf(input, ActionTypeUpdate) + eventFn(snapshotInput, ActionTypeUpdate) + }) + }) + + t.Run("Unexpected length of kline", func(t *testing.T) { + input := `{ + "action":"%s", + "arg":{ + "instType":"sp", + "channel":"candle5m", + "instId":"BTCUSDT" + }, + "data":[ + ["1698744600000","34361.45","34458.98","34355.53","34416.41","99.6631", "123456"] + ], + "ts":1697697791670 + }` + _, err := parseWebSocketEvent([]byte(input)) + assert.ErrorContains(t, err, "unexpected kline length") + }) + + t.Run("Unexpected timestamp", func(t *testing.T) { + input := `{ + "action":"%s", + "arg":{ + "instType":"sp", + "channel":"candle5m", + "instId":"BTCUSDT" + }, + "data":[ + ["timestamp","34361.49","34458.98","34355.53","34416.41","99.6631"] + ], + "ts":1697697791670 + }` + _, err := parseWebSocketEvent([]byte(input)) + assert.ErrorContains(t, err, "timestamp") + }) + + t.Run("Unexpected open price", func(t *testing.T) { + input := `{ + "action":"%s", + "arg":{ + "instType":"sp", + "channel":"candle5m", + "instId":"BTCUSDT" + }, + "data":[ + ["1698744600000","1p","34458.98","34355.53","34416.41","99.6631"] + ], + "ts":1697697791670 + }` + _, err := parseWebSocketEvent([]byte(input)) + assert.ErrorContains(t, err, "open price") + }) + + t.Run("Unexpected highest price", func(t *testing.T) { + input := `{ + "action":"%s", + "arg":{ + "instType":"sp", + "channel":"candle5m", + "instId":"BTCUSDT" + }, + "data":[ + ["1698744600000","34361.45","3p","34355.53","34416.41","99.6631"] + ], + "ts":1697697791670 + }` + _, err := parseWebSocketEvent([]byte(input)) + assert.ErrorContains(t, err, "highest price") + }) + + t.Run("Unexpected lowest price", func(t *testing.T) { + input := `{ + "action":"%s", + "arg":{ + "instType":"sp", + "channel":"candle5m", + "instId":"BTCUSDT" + }, + "data":[ + ["1698744600000","34361.45","34458.98","1p","34416.41","99.6631"] + ], + "ts":1697697791670 + }` + _, err := parseWebSocketEvent([]byte(input)) + assert.ErrorContains(t, err, "lowest price") + }) + + t.Run("Unexpected close price", func(t *testing.T) { + input := `{ + "action":"%s", + "arg":{ + "instType":"sp", + "channel":"candle5m", + "instId":"BTCUSDT" + }, + "data":[ + ["1698744600000","34361.45","34458.98","34355.53","1c","99.6631"] + ], + "ts":1697697791670 + }` + _, err := parseWebSocketEvent([]byte(input)) + assert.ErrorContains(t, err, "close price") + }) + + t.Run("Unexpected volume", func(t *testing.T) { + input := `{ + "action":"%s", + "arg":{ + "instType":"sp", + "channel":"candle5m", + "instId":"BTCUSDT" + }, + "data":[ + ["1698744600000","34361.45","34458.98","34355.53","34416.41", "1v"] + ], + "ts":1697697791670 + }` + _, err := parseWebSocketEvent([]byte(input)) + assert.ErrorContains(t, err, "volume") + }) +} + +func Test_convertSubscription(t *testing.T) { + t.Run("BookChannel.ChannelOrderBook5", func(t *testing.T) { + res, err := convertSubscription(types.Subscription{ + Symbol: "BTCUSDT", + Channel: types.BookChannel, + Options: types.SubscribeOptions{ + Depth: types.DepthLevel5, + }, + }) + assert.NoError(t, err) + assert.Equal(t, WsArg{ + InstType: instSp, + Channel: ChannelOrderBook5, + InstId: "BTCUSDT", + }, res) + }) + t.Run("BookChannel.DepthLevel15", func(t *testing.T) { + res, err := convertSubscription(types.Subscription{ + Symbol: "BTCUSDT", + Channel: types.BookChannel, + Options: types.SubscribeOptions{ + Depth: types.DepthLevel15, + }, + }) + assert.NoError(t, err) + assert.Equal(t, WsArg{ + InstType: instSp, + Channel: ChannelOrderBook15, + InstId: "BTCUSDT", + }, res) + }) + t.Run("BookChannel.DepthLevel200", func(t *testing.T) { + res, err := convertSubscription(types.Subscription{ + Symbol: "BTCUSDT", + Channel: types.BookChannel, + Options: types.SubscribeOptions{ + Depth: types.DepthLevel200, + }, + }) + assert.NoError(t, err) + assert.Equal(t, WsArg{ + InstType: instSp, + Channel: ChannelOrderBook, + InstId: "BTCUSDT", + }, res) + }) + t.Run("TradeChannel", func(t *testing.T) { + res, err := convertSubscription(types.Subscription{ + Symbol: "BTCUSDT", + Channel: types.MarketTradeChannel, + Options: types.SubscribeOptions{}, + }) + assert.NoError(t, err) + assert.Equal(t, WsArg{ + InstType: instSp, + Channel: ChannelTrade, + InstId: "BTCUSDT", + }, res) + }) + t.Run("CandleChannel", func(t *testing.T) { + for gInterval, localInterval := range toLocalInterval { + res, err := convertSubscription(types.Subscription{ + Symbol: "BTCUSDT", + Channel: types.KLineChannel, + Options: types.SubscribeOptions{ + Interval: gInterval, + }, + }) + assert.NoError(t, err) + assert.Equal(t, WsArg{ + InstType: instSp, + Channel: ChannelType(localInterval), + InstId: "BTCUSDT", + }, res) + } + }) +} diff --git a/pkg/exchange/bitget/types.go b/pkg/exchange/bitget/types.go new file mode 100644 index 0000000..24e6d5c --- /dev/null +++ b/pkg/exchange/bitget/types.go @@ -0,0 +1,529 @@ +package bitget + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + v2 "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bitget/bitgetapi/v2" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type InstType string + +const ( + instSp InstType = "sp" + instSpV2 InstType = "SPOT" +) + +type ChannelType string + +const ( + ChannelAccount ChannelType = "account" + // ChannelOrderBook snapshot and update might return less than 200 bids/asks as per symbol's orderbook various from + // each other; The number of bids/asks is not a fixed value and may vary in the future + ChannelOrderBook ChannelType = "books" + // ChannelOrderBook5 top 5 order book of "books" that begins from bid1/ask1 + ChannelOrderBook5 ChannelType = "books5" + // ChannelOrderBook15 top 15 order book of "books" that begins from bid1/ask1 + ChannelOrderBook15 ChannelType = "books15" + ChannelTrade ChannelType = "trade" + ChannelOrders ChannelType = "orders" +) + +type WsArg struct { + InstType InstType `json:"instType"` + Channel ChannelType `json:"channel"` + // InstId Instrument ID. e.q. BTCUSDT, ETHUSDT + InstId string `json:"instId"` + Coin string `json:"coin"` + + ApiKey string `json:"apiKey"` + Passphrase string `json:"passphrase"` + Timestamp string `json:"timestamp"` + Sign string `json:"sign"` +} + +type WsEventType string + +const ( + WsEventSubscribe WsEventType = "subscribe" + WsEventUnsubscribe WsEventType = "unsubscribe" + WsEventLogin WsEventType = "login" + WsEventError WsEventType = "error" +) + +type WsOp struct { + Op WsEventType `json:"op"` + Args []WsArg `json:"args"` +} + +// WsEvent is the lowest level of event type. We use this struct to convert the received data, so that we will know +// whether the event belongs to `op` or `data`. +type WsEvent struct { + // for comment event + Arg WsArg `json:"arg"` + + // for op event + Event WsEventType `json:"event"` + Code int `json:"code"` + Msg string `json:"msg"` + Op string `json:"op"` + + // for data event + Action ActionType `json:"action"` + Data json.RawMessage `json:"data"` +} + +// IsOp represents the data event will be empty +func (w *WsEvent) IsOp() bool { + return w.Action == "" && len(w.Data) == 0 +} + +func (w *WsEvent) IsValid() error { + switch w.Event { + case WsEventError: + return fmt.Errorf("websocket request error, op: %s, code: %d, msg: %s", w.Op, w.Code, w.Msg) + + case WsEventSubscribe, WsEventUnsubscribe, WsEventLogin: + // Actually, this code is unnecessary because the events are either `Subscribe` or `Unsubscribe`, But to avoid bugs + // in the exchange, we still check. + if w.Code != 0 || len(w.Msg) != 0 { + return fmt.Errorf("unexpected websocket %s event, code: %d, msg: %s", w.Event, w.Code, w.Msg) + } + return nil + + default: + return fmt.Errorf("unexpected event type: %+v", w) + } +} + +func (w *WsEvent) IsAuthenticated() bool { + return w.Event == WsEventLogin && w.Code == 0 +} + +type ActionType string + +const ( + ActionTypeSnapshot ActionType = "snapshot" + ActionTypeUpdate ActionType = "update" +) + +// { +// "asks":[ +// [ +// "28350.78", +// "0.2082" +// ], +// ], +// "bids":[ +// [ +// "28350.70", +// "0.5585" +// ], +// ], +// "checksum":0, +// "ts":"1697593934630" +// } +type BookEvent struct { + Events []struct { + // Order book on sell side, ascending order + Asks types.PriceVolumeSlice `json:"asks"` + // Order book on buy side, descending order + Bids types.PriceVolumeSlice `json:"bids"` + Ts types.MillisecondTimestamp `json:"ts"` + Checksum int `json:"checksum"` + } + + // internal use + actionType ActionType + instId string +} + +func (e *BookEvent) ToGlobalOrderBooks() []types.SliceOrderBook { + books := make([]types.SliceOrderBook, len(e.Events)) + for i, event := range e.Events { + books[i] = types.SliceOrderBook{ + Symbol: e.instId, + Bids: event.Bids, + Asks: event.Asks, + Time: event.Ts.Time(), + } + } + + return books +} + +type SideType string + +const ( + SideBuy SideType = "buy" + SideSell SideType = "sell" +) + +func (s SideType) ToGlobal() (types.SideType, error) { + switch s { + case SideBuy: + return types.SideTypeBuy, nil + case SideSell: + return types.SideTypeSell, nil + default: + return "", fmt.Errorf("unexpceted side type: %s", s) + } +} + +type MarketTrade struct { + Ts types.MillisecondTimestamp + Price fixedpoint.Value + Size fixedpoint.Value + Side SideType +} + +type MarketTradeSlice []MarketTrade + +func (m *MarketTradeSlice) UnmarshalJSON(b []byte) error { + if m == nil { + return errors.New("nil pointer of market trade slice") + } + s, err := parseMarketTradeSliceJSON(b) + if err != nil { + return err + } + + *m = s + return nil +} + +// ParseMarketTradeSliceJSON tries to parse a 2 dimensional string array into a MarketTradeSlice +// +// [ +// +// [ +// "1697694819663", +// "28312.97", +// "0.1653", +// "sell" +// ], +// [ +// "1697694818663", +// "28313", +// "0.1598", +// "buy" +// ] +// +// ] +func parseMarketTradeSliceJSON(in []byte) (slice MarketTradeSlice, err error) { + var rawTrades [][]json.RawMessage + + err = json.Unmarshal(in, &rawTrades) + if err != nil { + return slice, err + } + + for _, raw := range rawTrades { + if len(raw) != 4 { + return nil, fmt.Errorf("unexpected trades length: %d, data: %q", len(raw), raw) + } + var trade MarketTrade + if err = json.Unmarshal(raw[0], &trade.Ts); err != nil { + return nil, fmt.Errorf("failed to unmarshal into timestamp: %q", raw[0]) + } + if err = json.Unmarshal(raw[1], &trade.Price); err != nil { + return nil, fmt.Errorf("failed to unmarshal into price: %q", raw[1]) + } + if err = json.Unmarshal(raw[2], &trade.Size); err != nil { + return nil, fmt.Errorf("failed to unmarshal into size: %q", raw[2]) + } + if err = json.Unmarshal(raw[3], &trade.Side); err != nil { + return nil, fmt.Errorf("failed to unmarshal into side: %q", raw[3]) + } + + slice = append(slice, trade) + } + + return slice, nil +} + +func (m MarketTrade) ToGlobal(symbol string) (types.Trade, error) { + side, err := m.Side.ToGlobal() + if err != nil { + return types.Trade{}, err + } + + return types.Trade{ + ID: 0, // not supported + OrderID: 0, // not supported + Exchange: types.ExchangeBitget, + Price: m.Price, + Quantity: m.Size, + QuoteQuantity: m.Price.Mul(m.Size), + Symbol: symbol, + Side: side, + IsBuyer: side == types.SideTypeBuy, + IsMaker: false, // not supported + Time: types.Time(m.Ts.Time()), + Fee: fixedpoint.Zero, // not supported + FeeCurrency: "", // not supported + }, nil +} + +type MarketTradeEvent struct { + Events MarketTradeSlice + + // internal use + actionType ActionType + instId string +} + +var ( + toLocalInterval = map[types.Interval]string{ + types.Interval1m: "candle1m", + types.Interval5m: "candle5m", + types.Interval15m: "candle15m", + types.Interval30m: "candle30m", + types.Interval1h: "candle1H", + types.Interval4h: "candle4H", + types.Interval12h: "candle12H", + types.Interval1d: "candle1D", + types.Interval1w: "candle1W", + } + + toGlobalInterval = map[string]types.Interval{ + "candle1m": types.Interval1m, + "candle5m": types.Interval5m, + "candle15m": types.Interval15m, + "candle30m": types.Interval30m, + "candle1H": types.Interval1h, + "candle4H": types.Interval4h, + "candle12H": types.Interval12h, + "candle1D": types.Interval1d, + "candle1W": types.Interval1w, + } + + // we align utc time zone + toLocalGranularity = map[types.Interval]string{ + types.Interval1m: "1min", + types.Interval5m: "5min", + types.Interval15m: "15min", + types.Interval30m: "30min", + types.Interval1h: "1h", + types.Interval4h: "4h", + types.Interval6h: "6Hutc", + types.Interval12h: "12Hutc", + types.Interval1d: "1Dutc", + types.Interval3d: "3Dutc", + types.Interval1w: "1Wutc", + types.Interval1mo: "1Mutc", + } +) + +func hasMaxDuration(interval types.Interval) (bool, time.Duration) { + switch interval { + case types.Interval1m, types.Interval5m: + return true, 30 * 24 * time.Hour + case types.Interval15m: + return true, 52 * 24 * time.Hour + case types.Interval30m: + return true, 62 * 24 * time.Hour + case types.Interval1h: + return true, 83 * 24 * time.Hour + case types.Interval4h: + return true, 240 * 24 * time.Hour + case types.Interval6h: + return true, 360 * 24 * time.Hour + default: + return false, 0 * time.Duration(0) + } +} + +type KLine struct { + StartTime types.MillisecondTimestamp + OpenPrice fixedpoint.Value + HighestPrice fixedpoint.Value + LowestPrice fixedpoint.Value + ClosePrice fixedpoint.Value + Volume fixedpoint.Value +} + +func (k KLine) ToGlobal(interval types.Interval, symbol string) types.KLine { + startTime := k.StartTime.Time() + + return types.KLine{ + Exchange: types.ExchangeBitget, + Symbol: symbol, + StartTime: types.Time(startTime), + EndTime: types.Time(startTime.Add(interval.Duration() - time.Millisecond)), + Interval: interval, + Open: k.OpenPrice, + Close: k.ClosePrice, + High: k.HighestPrice, + Low: k.LowestPrice, + Volume: k.Volume, + QuoteVolume: fixedpoint.Zero, // not supported + TakerBuyBaseAssetVolume: fixedpoint.Zero, // not supported + TakerBuyQuoteAssetVolume: fixedpoint.Zero, // not supported + LastTradeID: 0, // not supported + NumberOfTrades: 0, // not supported + Closed: false, + } +} + +type KLineSlice []KLine + +func (m *KLineSlice) UnmarshalJSON(b []byte) error { + if m == nil { + return errors.New("nil pointer of kline slice") + } + s, err := parseKLineSliceJSON(b) + if err != nil { + return err + } + + *m = s + return nil +} + +// parseKLineSliceJSON tries to parse a 2 dimensional string array into a KLineSlice +// +// [ +// +// ["1597026383085", "8533.02", "8553.74", "8527.17", "8548.26", "45247"] +// ] +func parseKLineSliceJSON(in []byte) (slice KLineSlice, err error) { + var rawKLines [][]json.RawMessage + + err = json.Unmarshal(in, &rawKLines) + if err != nil { + return slice, err + } + + for _, raw := range rawKLines { + if len(raw) != 6 { + return nil, fmt.Errorf("unexpected kline length: %d, data: %q", len(raw), raw) + } + var kline KLine + if err = json.Unmarshal(raw[0], &kline.StartTime); err != nil { + return nil, fmt.Errorf("failed to unmarshal into timestamp: %q", raw[0]) + } + if err = json.Unmarshal(raw[1], &kline.OpenPrice); err != nil { + return nil, fmt.Errorf("failed to unmarshal into open price: %q", raw[1]) + } + if err = json.Unmarshal(raw[2], &kline.HighestPrice); err != nil { + return nil, fmt.Errorf("failed to unmarshal into highest price: %q", raw[2]) + } + if err = json.Unmarshal(raw[3], &kline.LowestPrice); err != nil { + return nil, fmt.Errorf("failed to unmarshal into lowest price: %q", raw[3]) + } + if err = json.Unmarshal(raw[4], &kline.ClosePrice); err != nil { + return nil, fmt.Errorf("failed to unmarshal into close price: %q", raw[4]) + } + if err = json.Unmarshal(raw[5], &kline.Volume); err != nil { + return nil, fmt.Errorf("failed to unmarshal into volume: %q", raw[5]) + } + + slice = append(slice, kline) + } + + return slice, nil +} + +type KLineEvent struct { + Events KLineSlice + + // internal use + actionType ActionType + channel ChannelType + instId string +} + +func (k KLineEvent) CacheKey() string { + // e.q: candle5m.BTCUSDT + return fmt.Sprintf("%s.%s", k.channel, k.instId) +} + +type Balance struct { + Coin string `json:"coin"` + Available fixedpoint.Value `json:"available"` + // Amount of frozen assets Usually frozen when the order is placed + Frozen fixedpoint.Value `json:"frozen"` + // Amount of locked assets Locked assests required to become a fiat merchants, etc. + Locked fixedpoint.Value `json:"locked"` + // Restricted availability For spot copy trading + LimitAvailable fixedpoint.Value `json:"limitAvailable"` + UpdatedTime types.MillisecondTimestamp `json:"uTime"` +} + +type AccountEvent struct { + Balances []Balance + + // internal use + actionType ActionType + instId string +} + +type Trade struct { + // Latest filled price + FillPrice fixedpoint.Value `json:"fillPrice"` + TradeId types.StrInt64 `json:"tradeId"` + // Number of latest filled orders + BaseVolume fixedpoint.Value `json:"baseVolume"` + FillTime types.MillisecondTimestamp `json:"fillTime"` + // Transaction fee of the latest transaction, negative value + FillFee fixedpoint.Value `json:"fillFee"` + // Currency of transaction fee of the latest transaction + FillFeeCoin string `json:"fillFeeCoin"` + // Direction of liquidity of the latest transaction + TradeScope string `json:"tradeScope"` +} + +type Order struct { + Trade + + InstId string `json:"instId"` + // OrderId are always numeric. It's confirmed with official customer service. https://t.me/bitgetOpenapi/24172 + OrderId types.StrInt64 `json:"orderId"` + ClientOrderId string `json:"clientOid"` + // NewSize represents the order quantity, following the specified rules: + // when orderType=limit, newSize represents the quantity of base coin, + // when orderType=marketandside=buy, newSize represents the quantity of quote coin, + // when orderType=marketandside=sell, newSize represents the quantity of base coin. + NewSize fixedpoint.Value `json:"newSize"` + // Buy amount, returned when buying at market price + Notional fixedpoint.Value `json:"notional"` + OrderType v2.OrderType `json:"orderType"` + Force v2.OrderForce `json:"force"` + Side v2.SideType `json:"side"` + AccBaseVolume fixedpoint.Value `json:"accBaseVolume"` + PriceAvg fixedpoint.Value `json:"priceAvg"` + // The Price field is only applicable to limit orders. + Price fixedpoint.Value `json:"price"` + Status v2.OrderStatus `json:"status"` + CreatedTime types.MillisecondTimestamp `json:"cTime"` + UpdatedTime types.MillisecondTimestamp `json:"uTime"` + FeeDetail []struct { + FeeCoin string `json:"feeCoin"` + Fee string `json:"fee"` + } `json:"feeDetail"` + EnterPointSource string `json:"enterPointSource"` +} + +func (o *Order) isMaker() (bool, error) { + switch o.TradeScope { + case "T": + return false, nil + case "M": + return true, nil + default: + return false, fmt.Errorf("unexpected trade scope: %s", o.TradeScope) + } +} + +type OrderTradeEvent struct { + Orders []Order + + // internal use + actionType ActionType + instId string +} diff --git a/pkg/exchange/bitget/types_test.go b/pkg/exchange/bitget/types_test.go new file mode 100644 index 0000000..d21f5c5 --- /dev/null +++ b/pkg/exchange/bitget/types_test.go @@ -0,0 +1,40 @@ +package bitget + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func Test_hasMaxDuration(t *testing.T) { + ok, duration := hasMaxDuration(types.Interval1m) + assert.True(t, ok) + assert.Equal(t, 30*24*time.Hour, duration) + + ok, duration = hasMaxDuration(types.Interval5m) + assert.True(t, ok) + assert.Equal(t, 30*24*time.Hour, duration) + + ok, duration = hasMaxDuration(types.Interval15m) + assert.True(t, ok) + assert.Equal(t, 52*24*time.Hour, duration) + + ok, duration = hasMaxDuration(types.Interval30m) + assert.True(t, ok) + assert.Equal(t, 62*24*time.Hour, duration) + + ok, duration = hasMaxDuration(types.Interval1h) + assert.True(t, ok) + assert.Equal(t, 83*24*time.Hour, duration) + + ok, duration = hasMaxDuration(types.Interval4h) + assert.True(t, ok) + assert.Equal(t, 240*24*time.Hour, duration) + + ok, duration = hasMaxDuration(types.Interval6h) + assert.True(t, ok) + assert.Equal(t, 360*24*time.Hour, duration) +} diff --git a/pkg/exchange/bybit/bybitapi/cancel_order_request.go b/pkg/exchange/bybit/bybitapi/cancel_order_request.go new file mode 100644 index 0000000..4ff7cb0 --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/cancel_order_request.go @@ -0,0 +1,35 @@ +package bybitapi + +import ( + "github.com/c9s/requestgen" +) + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Result +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Result + +type CancelOrderResponse struct { + OrderId string `json:"orderId"` + OrderLinkId string `json:"orderLinkId"` +} + +//go:generate PostRequest -url "/v5/order/cancel" -type CancelOrderRequest -responseDataType .CancelOrderResponse +type CancelOrderRequest struct { + client requestgen.AuthenticatedAPIClient + + category Category `param:"category" validValues:"spot"` + symbol string `param:"symbol"` + // User customised order ID. Either orderId or orderLinkId is required + orderLinkId string `param:"orderLinkId"` + + orderId *string `param:"orderId"` + // orderFilter default type is Order + // tpsl order type are not currently supported + orderFilter *string `param:"timeInForce" validValues:"Order"` +} + +func (c *RestClient) NewCancelOrderRequest() *CancelOrderRequest { + return &CancelOrderRequest{ + client: c, + category: CategorySpot, + } +} diff --git a/pkg/exchange/bybit/bybitapi/cancel_order_request_requestgen.go b/pkg/exchange/bybit/bybitapi/cancel_order_request_requestgen.go new file mode 100644 index 0000000..715bf42 --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/cancel_order_request_requestgen.go @@ -0,0 +1,237 @@ +// Code generated by "requestgen -method POST -responseType .APIResponse -responseDataField Result -url /v5/order/cancel -type CancelOrderRequest -responseDataType .CancelOrderResponse"; DO NOT EDIT. + +package bybitapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (p *CancelOrderRequest) Category(category Category) *CancelOrderRequest { + p.category = category + return p +} + +func (p *CancelOrderRequest) Symbol(symbol string) *CancelOrderRequest { + p.symbol = symbol + return p +} + +func (p *CancelOrderRequest) OrderLinkId(orderLinkId string) *CancelOrderRequest { + p.orderLinkId = orderLinkId + return p +} + +func (p *CancelOrderRequest) OrderId(orderId string) *CancelOrderRequest { + p.orderId = &orderId + return p +} + +func (p *CancelOrderRequest) OrderFilter(orderFilter string) *CancelOrderRequest { + p.orderFilter = &orderFilter + return p +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (p *CancelOrderRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (p *CancelOrderRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check category field -> json key category + category := p.category + + // TEMPLATE check-valid-values + switch category { + case "spot": + params["category"] = category + + default: + return nil, fmt.Errorf("category value %v is invalid", category) + + } + // END TEMPLATE check-valid-values + + // assign parameter of category + params["category"] = category + // check symbol field -> json key symbol + symbol := p.symbol + + // assign parameter of symbol + params["symbol"] = symbol + // check orderLinkId field -> json key orderLinkId + orderLinkId := p.orderLinkId + + // assign parameter of orderLinkId + params["orderLinkId"] = orderLinkId + // check orderId field -> json key orderId + if p.orderId != nil { + orderId := *p.orderId + + // assign parameter of orderId + params["orderId"] = orderId + } else { + } + // check orderFilter field -> json key timeInForce + if p.orderFilter != nil { + orderFilter := *p.orderFilter + + // TEMPLATE check-valid-values + switch orderFilter { + case "Order": + params["timeInForce"] = orderFilter + + default: + return nil, fmt.Errorf("timeInForce value %v is invalid", orderFilter) + + } + // END TEMPLATE check-valid-values + + // assign parameter of orderFilter + params["timeInForce"] = orderFilter + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (p *CancelOrderRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := p.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if p.isVarSlice(_v) { + p.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (p *CancelOrderRequest) GetParametersJSON() ([]byte, error) { + params, err := p.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (p *CancelOrderRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (p *CancelOrderRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (p *CancelOrderRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (p *CancelOrderRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (p *CancelOrderRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := p.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (p *CancelOrderRequest) GetPath() string { + return "/v5/order/cancel" +} + +// Do generates the request object and send the request object to the API endpoint +func (p *CancelOrderRequest) Do(ctx context.Context) (*CancelOrderResponse, error) { + + params, err := p.GetParameters() + if err != nil { + return nil, err + } + query := url.Values{} + + var apiURL string + + apiURL = p.GetPath() + + req, err := p.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := p.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data CancelOrderResponse + if err := json.Unmarshal(apiResponse.Result, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/bybit/bybitapi/client.go b/pkg/exchange/bybit/bybitapi/client.go new file mode 100644 index 0000000..4e6d556 --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/client.go @@ -0,0 +1,186 @@ +package bybitapi + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/c9s/requestgen" + "github.com/pkg/errors" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ( + defaultHTTPTimeout = time.Second * 15 + + RestBaseURL = "https://api.bybit.com" + WsSpotPublicSpotUrl = "wss://stream.bybit.com/v5/public/spot" + WsSpotPrivateUrl = "wss://stream.bybit.com/v5/private" +) + +// defaultRequestWindowMilliseconds specify how long an HTTP request is valid. It is also used to prevent replay attacks. +var defaultRequestWindowMilliseconds = fmt.Sprintf("%d", 5*time.Second.Milliseconds()) + +type RestClient struct { + requestgen.BaseAPIClient + + key, secret string +} + +func NewClient() (*RestClient, error) { + u, err := url.Parse(RestBaseURL) + if err != nil { + return nil, err + } + + return &RestClient{ + BaseAPIClient: requestgen.BaseAPIClient{ + BaseURL: u, + HttpClient: &http.Client{ + Timeout: defaultHTTPTimeout, + }, + }, + }, nil +} + +func (c *RestClient) Auth(key, secret string) { + c.key = key + // pragma: allowlist secret + c.secret = secret +} + +// newAuthenticatedRequest creates new http request for authenticated routes. +func (c *RestClient) NewAuthenticatedRequest(ctx context.Context, method, refURL string, params url.Values, payload interface{}) (*http.Request, error) { + if len(c.key) == 0 { + return nil, errors.New("empty api key") + } + + if len(c.secret) == 0 { + return nil, errors.New("empty api secret") + } + + rel, err := url.Parse(refURL) + if err != nil { + return nil, err + } + + if params != nil { + rel.RawQuery = params.Encode() + } + + pathURL := c.BaseURL.ResolveReference(rel) + path := pathURL.Path + if rel.RawQuery != "" { + path += "?" + rel.RawQuery + } + + t := time.Now().In(time.UTC) + timestamp := strconv.FormatInt(t.UnixMilli(), 10) + + body, err := castPayload(payload) + if err != nil { + return nil, err + } + + var signKey string + switch method { + case http.MethodPost: + signKey = timestamp + c.key + defaultRequestWindowMilliseconds + string(body) + case http.MethodGet: + signKey = timestamp + c.key + defaultRequestWindowMilliseconds + rel.RawQuery + default: + return nil, fmt.Errorf("unexpected method: %s", method) + } + + // See https://bybit-exchange.github.io/docs/v5/guide#create-a-request + // + // 1. timestamp + API key + (recv_window) + (queryString | jsonBodyString) + // 2. Use the HMAC_SHA256 or RSA_SHA256 algorithm to sign the string in step 1, and convert it to a hex + // string (HMAC_SHA256) / base64 (RSA_SHA256) to obtain the sign parameter. + // 3. Append the sign parameter to request header, and send the HTTP request. + signature := Sign(signKey, c.secret) + + req, err := http.NewRequestWithContext(ctx, method, pathURL.String(), bytes.NewReader(body)) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", "application/json") + req.Header.Add("X-BAPI-API-KEY", c.key) + req.Header.Add("X-BAPI-TIMESTAMP", timestamp) + req.Header.Add("X-BAPI-SIGN", signature) + req.Header.Add("X-BAPI-RECV-WINDOW", defaultRequestWindowMilliseconds) + return req, nil +} + +func Sign(payload string, secret string) string { + var sig = hmac.New(sha256.New, []byte(secret)) + _, err := sig.Write([]byte(payload)) + if err != nil { + return "" + } + + return hex.EncodeToString(sig.Sum(nil)) +} + +func castPayload(payload interface{}) ([]byte, error) { + if payload == nil { + return nil, nil + } + + switch v := payload.(type) { + case string: + return []byte(v), nil + + case []byte: + return v, nil + + } + return json.Marshal(payload) +} + +/* +sample: + +{ + "retCode": 0, + "retMsg": "OK", + "result": { + }, + "retExtInfo": {}, + "time": 1671017382656 +} +*/ + +type APIResponse struct { + // Success/Error code + RetCode uint `json:"retCode"` + // Success/Error msg. OK, success, SUCCESS indicate a successful response + RetMsg string `json:"retMsg"` + // Business data result + Result json.RawMessage `json:"result"` + // Extend info. Most of the time, it is {} + RetExtInfo json.RawMessage `json:"retExtInfo"` + // Time is current timestamp (ms) + Time types.MillisecondTimestamp `json:"time"` +} + +func (a APIResponse) Validate() error { + if a.RetCode != 0 { + return a.Error() + } + return nil +} + +func (a APIResponse) Error() error { + return fmt.Errorf("retCode: %d, retMsg: %s, retExtInfo: %q, time: %s", a.RetCode, a.RetMsg, a.RetExtInfo, a.Time) +} diff --git a/pkg/exchange/bybit/bybitapi/client_test.go b/pkg/exchange/bybit/bybitapi/client_test.go new file mode 100644 index 0000000..0902c89 --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/client_test.go @@ -0,0 +1,183 @@ +package bybitapi + +import ( + "context" + "os" + "strconv" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/testutil" +) + +func getTestClientOrSkip(t *testing.T) *RestClient { + if b, _ := strconv.ParseBool(os.Getenv("CI")); b { + t.Skip("skip test for CI") + } + + key, secret, ok := testutil.IntegrationTestConfigured(t, "BYBIT") + if !ok { + t.Skip("BYBIT_* env vars are not configured") + return nil + } + + client, err := NewClient() + assert.NoError(t, err) + client.Auth(key, secret) + return client +} + +func TestClient(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + + t.Run("GetAccountInfoRequest", func(t *testing.T) { + req := client.NewGetAccountRequest() + accountInfo, err := req.Do(ctx) + assert.NoError(t, err) + t.Logf("accountInfo: %+v", accountInfo) + }) + + t.Run("GetInstrumentsInfoRequest", func(t *testing.T) { + req := client.NewGetInstrumentsInfoRequest() + instrumentsInfo, err := req.Do(ctx) + assert.NoError(t, err) + t.Logf("instrumentsInfo: %+v", instrumentsInfo) + }) + + t.Run("GetTicker", func(t *testing.T) { + req := client.NewGetTickersRequest() + apiResp, err := req.Symbol("BTCUSDT").Do(ctx) + assert.NoError(t, err) + t.Logf("apiResp: %+v", apiResp) + + req = client.NewGetTickersRequest() + tickers, err := req.Symbol("BTCUSDT").DoWithResponseTime(ctx) + assert.NoError(t, err) + t.Logf("tickers: %+v", tickers) + }) + + t.Run("GetOpenOrderRequest", func(t *testing.T) { + cursor := "" + for { + req := client.NewGetOpenOrderRequest().Limit(1) + if len(cursor) != 0 { + req = req.Cursor(cursor) + } + openOrders, err := req.Do(ctx) + assert.NoError(t, err) + + for _, o := range openOrders.List { + t.Logf("openOrders: %+v", o) + } + if len(openOrders.NextPageCursor) == 0 { + break + } + cursor = openOrders.NextPageCursor + } + }) + + t.Run("PlaceOrderRequest", func(t *testing.T) { + req := client.NewPlaceOrderRequest(). + Symbol("DOTUSDT"). + Side(SideBuy). + OrderType(OrderTypeLimit). + Qty("1"). + Price("4.6"). + OrderLinkId(uuid.NewString()). + TimeInForce(TimeInForceGTC) + apiResp, err := req.Do(ctx) + assert.NoError(t, err) + t.Logf("apiResp: %+v", apiResp) + + _, err = strconv.ParseUint(apiResp.OrderId, 10, 64) + assert.NoError(t, err) + + ordersResp, err := client.NewGetOpenOrderRequest().OrderLinkId(apiResp.OrderLinkId).Do(ctx) + assert.NoError(t, err) + assert.Equal(t, len(ordersResp.List), 1) + t.Logf("apiResp: %+v", ordersResp.List[0]) + }) + + t.Run("CancelOrderRequest", func(t *testing.T) { + req := client.NewPlaceOrderRequest(). + Symbol("DOTUSDT"). + Side(SideBuy). + OrderType(OrderTypeLimit). + Qty("1"). + Price("4.6"). + OrderLinkId(uuid.NewString()). + TimeInForce(TimeInForceGTC) + apiResp, err := req.Do(ctx) + assert.NoError(t, err) + t.Logf("apiResp: %+v", apiResp) + + ordersResp, err := client.NewGetOpenOrderRequest().OrderLinkId(apiResp.OrderLinkId).Do(ctx) + assert.NoError(t, err) + assert.Equal(t, len(ordersResp.List), 1) + t.Logf("apiResp: %+v", ordersResp.List[0]) + + cancelReq := client.NewCancelOrderRequest(). + Symbol("DOTUSDT"). + OrderLinkId(apiResp.OrderLinkId) + cancelResp, err := cancelReq.Do(ctx) + assert.NoError(t, err) + t.Logf("apiResp: %+v", cancelResp) + + ordersResp, err = client.NewGetOpenOrderRequest().OrderLinkId(apiResp.OrderLinkId).Do(ctx) + assert.NoError(t, err) + assert.Equal(t, len(ordersResp.List), 1) + assert.Equal(t, ordersResp.List[0].OrderStatus, OrderStatusCancelled) + t.Logf("apiResp: %+v", ordersResp.List[0]) + }) + + t.Run("GetOrderHistoriesRequest", func(t *testing.T) { + req := client.NewPlaceOrderRequest(). + Symbol("DOTUSDT"). + Side(SideBuy). + OrderType(OrderTypeLimit). + Qty("1"). + Price("4.6"). + OrderLinkId(uuid.NewString()). + TimeInForce(TimeInForceGTC) + apiResp, err := req.Do(ctx) + assert.NoError(t, err) + t.Logf("apiResp: %+v", apiResp) + + ordersResp, err := client.NewGetOpenOrderRequest().OrderLinkId(apiResp.OrderLinkId).Do(ctx) + assert.NoError(t, err) + assert.Equal(t, len(ordersResp.List), 1) + t.Logf("apiResp: %+v", ordersResp.List[0]) + + orderResp, err := client.NewGetOrderHistoriesRequest().Symbol("DOTUSDT").Cursor("0").Do(ctx) + assert.NoError(t, err) + t.Logf("apiResp: %#v", orderResp) + }) + + t.Run("GetWalletBalancesRequest", func(t *testing.T) { + req := client.NewGetWalletBalancesRequest().Coin("BTC") + apiResp, err := req.Do(ctx) + assert.NoError(t, err) + t.Logf("apiResp: %+v", apiResp) + }) + + t.Run("GetKLinesRequest", func(t *testing.T) { + startTime := time.Date(2023, 8, 8, 9, 28, 0, 0, time.UTC) + endTime := time.Date(2023, 8, 8, 9, 45, 0, 0, time.UTC) + req := client.NewGetKLinesRequest(). + Symbol("BTCUSDT").Interval("15").StartTime(startTime).EndTime(endTime) + apiResp, err := req.Do(ctx) + assert.NoError(t, err) + t.Logf("apiResp: %+v", apiResp.List) + }) + + t.Run("GetFeeRatesRequest", func(t *testing.T) { + req := client.NewGetFeeRatesRequest() + apiResp, err := req.Do(ctx) + assert.NoError(t, err) + t.Logf("apiResp: %+v", apiResp) + }) +} diff --git a/pkg/exchange/bybit/bybitapi/get_account_info_request.go b/pkg/exchange/bybit/bybitapi/get_account_info_request.go new file mode 100644 index 0000000..38fa983 --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/get_account_info_request.go @@ -0,0 +1,24 @@ +package bybitapi + +import "github.com/c9s/requestgen" + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Result +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Result + +type AccountInfo struct { + MarginMode string `json:"marginMode"` + UpdatedTime string `json:"updatedTime"` + UnifiedMarginStatus int `json:"unifiedMarginStatus"` + DcpStatus string `json:"dcpStatus"` + TimeWindow int `json:"timeWindow"` + SmpGroup int `json:"smpGroup"` +} + +//go:generate GetRequest -url "/v5/account/info" -type GetAccountInfoRequest -responseDataType .AccountInfo +type GetAccountInfoRequest struct { + client requestgen.AuthenticatedAPIClient +} + +func (c *RestClient) NewGetAccountRequest() *GetAccountInfoRequest { + return &GetAccountInfoRequest{client: c} +} diff --git a/pkg/exchange/bybit/bybitapi/get_account_info_request_requestgen.go b/pkg/exchange/bybit/bybitapi/get_account_info_request_requestgen.go new file mode 100644 index 0000000..c7907f4 --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/get_account_info_request_requestgen.go @@ -0,0 +1,157 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Result -url /v5/account/info -type GetAccountInfoRequest -responseDataType .AccountInfo"; DO NOT EDIT. + +package bybitapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetAccountInfoRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetAccountInfoRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetAccountInfoRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetAccountInfoRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetAccountInfoRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetAccountInfoRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetAccountInfoRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetAccountInfoRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetAccountInfoRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (g *GetAccountInfoRequest) GetPath() string { + return "/v5/account/info" +} + +// Do generates the request object and send the request object to the API endpoint +func (g *GetAccountInfoRequest) Do(ctx context.Context) (*AccountInfo, error) { + + // no body params + var params interface{} + query := url.Values{} + + var apiURL string + + apiURL = g.GetPath() + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data AccountInfo + if err := json.Unmarshal(apiResponse.Result, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/bybit/bybitapi/get_fee_rates_request.go b/pkg/exchange/bybit/bybitapi/get_fee_rates_request.go new file mode 100644 index 0000000..3790589 --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/get_fee_rates_request.go @@ -0,0 +1,38 @@ +package bybitapi + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Result +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Result + +type FeeRates struct { + List []FeeRate `json:"list"` +} + +type FeeRate struct { + Symbol string `json:"symbol"` + TakerFeeRate fixedpoint.Value `json:"takerFeeRate"` + MakerFeeRate fixedpoint.Value `json:"makerFeeRate"` +} + +//go:generate GetRequest -url "/v5/account/fee-rate" -type GetFeeRatesRequest -responseDataType .FeeRates +type GetFeeRatesRequest struct { + client requestgen.AuthenticatedAPIClient + + category Category `param:"category,query" validValues:"spot"` + // Symbol name. Valid for linear, inverse, spot + symbol *string `param:"symbol,query"` + // Base coin. SOL, BTC, ETH. Valid for option + baseCoin *string `param:"baseCoin,query"` +} + +func (c *RestClient) NewGetFeeRatesRequest() *GetFeeRatesRequest { + return &GetFeeRatesRequest{ + client: c, + category: CategorySpot, + } +} diff --git a/pkg/exchange/bybit/bybitapi/get_fee_rates_request_requestgen.go b/pkg/exchange/bybit/bybitapi/get_fee_rates_request_requestgen.go new file mode 100644 index 0000000..e9db736 --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/get_fee_rates_request_requestgen.go @@ -0,0 +1,207 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Result -url /v5/account/fee-rate -type GetFeeRatesRequest -responseDataType .FeeRates"; DO NOT EDIT. + +package bybitapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (g *GetFeeRatesRequest) Category(category Category) *GetFeeRatesRequest { + g.category = category + return g +} + +func (g *GetFeeRatesRequest) Symbol(symbol string) *GetFeeRatesRequest { + g.symbol = &symbol + return g +} + +func (g *GetFeeRatesRequest) BaseCoin(baseCoin string) *GetFeeRatesRequest { + g.baseCoin = &baseCoin + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetFeeRatesRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check category field -> json key category + category := g.category + + // TEMPLATE check-valid-values + switch category { + case "spot": + params["category"] = category + + default: + return nil, fmt.Errorf("category value %v is invalid", category) + + } + // END TEMPLATE check-valid-values + + // assign parameter of category + params["category"] = category + // check symbol field -> json key symbol + if g.symbol != nil { + symbol := *g.symbol + + // assign parameter of symbol + params["symbol"] = symbol + } else { + } + // check baseCoin field -> json key baseCoin + if g.baseCoin != nil { + baseCoin := *g.baseCoin + + // assign parameter of baseCoin + params["baseCoin"] = baseCoin + } else { + } + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetFeeRatesRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetFeeRatesRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetFeeRatesRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetFeeRatesRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetFeeRatesRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetFeeRatesRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetFeeRatesRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetFeeRatesRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (g *GetFeeRatesRequest) GetPath() string { + return "/v5/account/fee-rate" +} + +// Do generates the request object and send the request object to the API endpoint +func (g *GetFeeRatesRequest) Do(ctx context.Context) (*FeeRates, error) { + + // no body params + var params interface{} + query, err := g.GetQueryParameters() + if err != nil { + return nil, err + } + + var apiURL string + + apiURL = g.GetPath() + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data FeeRates + if err := json.Unmarshal(apiResponse.Result, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/bybit/bybitapi/get_instruments_info_request.go b/pkg/exchange/bybit/bybitapi/get_instruments_info_request.go new file mode 100644 index 0000000..2124204 --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/get_instruments_info_request.go @@ -0,0 +1,56 @@ +package bybitapi + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Result +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Result + +type InstrumentsInfo struct { + Category Category `json:"category"` + List []Instrument `json:"list"` +} + +type Instrument struct { + Symbol string `json:"symbol"` + BaseCoin string `json:"baseCoin"` + QuoteCoin string `json:"quoteCoin"` + Innovation string `json:"innovation"` + Status Status `json:"status"` + MarginTrading string `json:"marginTrading"` + LotSizeFilter struct { + BasePrecision fixedpoint.Value `json:"basePrecision"` + QuotePrecision fixedpoint.Value `json:"quotePrecision"` + MinOrderQty fixedpoint.Value `json:"minOrderQty"` + MaxOrderQty fixedpoint.Value `json:"maxOrderQty"` + MinOrderAmt fixedpoint.Value `json:"minOrderAmt"` + MaxOrderAmt fixedpoint.Value `json:"maxOrderAmt"` + } `json:"lotSizeFilter"` + + PriceFilter struct { + TickSize fixedpoint.Value `json:"tickSize"` + } `json:"priceFilter"` +} + +//go:generate GetRequest -url "/v5/market/instruments-info" -type GetInstrumentsInfoRequest -responseDataType .InstrumentsInfo +type GetInstrumentsInfoRequest struct { + client requestgen.APIClient + + category Category `param:"category,query" validValues:"spot"` + symbol *string `param:"symbol,query"` + + // limit is invalid if category spot. + limit *uint64 `param:"limit,query"` + // cursor is invalid if category spot. + cursor *string `param:"cursor,query"` +} + +func (c *RestClient) NewGetInstrumentsInfoRequest() *GetInstrumentsInfoRequest { + return &GetInstrumentsInfoRequest{ + client: c, + category: CategorySpot, + } +} diff --git a/pkg/exchange/bybit/bybitapi/get_instruments_info_request_requestgen.go b/pkg/exchange/bybit/bybitapi/get_instruments_info_request_requestgen.go new file mode 100644 index 0000000..ed2fc88 --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/get_instruments_info_request_requestgen.go @@ -0,0 +1,220 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Result -url /v5/market/instruments-info -type GetInstrumentsInfoRequest -responseDataType .InstrumentsInfo"; DO NOT EDIT. + +package bybitapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (g *GetInstrumentsInfoRequest) Category(category Category) *GetInstrumentsInfoRequest { + g.category = category + return g +} + +func (g *GetInstrumentsInfoRequest) Symbol(symbol string) *GetInstrumentsInfoRequest { + g.symbol = &symbol + return g +} + +func (g *GetInstrumentsInfoRequest) Limit(limit uint64) *GetInstrumentsInfoRequest { + g.limit = &limit + return g +} + +func (g *GetInstrumentsInfoRequest) Cursor(cursor string) *GetInstrumentsInfoRequest { + g.cursor = &cursor + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetInstrumentsInfoRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check category field -> json key category + category := g.category + + // TEMPLATE check-valid-values + switch category { + case "spot": + params["category"] = category + + default: + return nil, fmt.Errorf("category value %v is invalid", category) + + } + // END TEMPLATE check-valid-values + + // assign parameter of category + params["category"] = category + // check symbol field -> json key symbol + if g.symbol != nil { + symbol := *g.symbol + + // assign parameter of symbol + params["symbol"] = symbol + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + // check cursor field -> json key cursor + if g.cursor != nil { + cursor := *g.cursor + + // assign parameter of cursor + params["cursor"] = cursor + } else { + } + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetInstrumentsInfoRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetInstrumentsInfoRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetInstrumentsInfoRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetInstrumentsInfoRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetInstrumentsInfoRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetInstrumentsInfoRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetInstrumentsInfoRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetInstrumentsInfoRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (g *GetInstrumentsInfoRequest) GetPath() string { + return "/v5/market/instruments-info" +} + +// Do generates the request object and send the request object to the API endpoint +func (g *GetInstrumentsInfoRequest) Do(ctx context.Context) (*InstrumentsInfo, error) { + + // no body params + var params interface{} + query, err := g.GetQueryParameters() + if err != nil { + return nil, err + } + + var apiURL string + + apiURL = g.GetPath() + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data InstrumentsInfo + if err := json.Unmarshal(apiResponse.Result, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/bybit/bybitapi/get_k_lines_request.go b/pkg/exchange/bybit/bybitapi/get_k_lines_request.go new file mode 100644 index 0000000..101e799 --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/get_k_lines_request.go @@ -0,0 +1,107 @@ +package bybitapi + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Result +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Result + +type IntervalSign string + +const ( + IntervalSignDay IntervalSign = "D" + IntervalSignWeek IntervalSign = "W" + IntervalSignMonth IntervalSign = "M" +) + +type KLinesResponse struct { + Symbol string `json:"symbol"` + // An string array of individual candle + // Sort in reverse by startTime + List []KLine `json:"list"` + Category Category `json:"category"` +} + +type KLine struct { + // list[0]: startTime, Start time of the candle (ms) + StartTime types.MillisecondTimestamp + // list[1]: openPrice + Open fixedpoint.Value + // list[2]: highPrice + High fixedpoint.Value + // list[3]: lowPrice + Low fixedpoint.Value + // list[4]: closePrice + Close fixedpoint.Value + // list[5]: volume, Trade volume. Unit of contract: pieces of contract. Unit of spot: quantity of coins + Volume fixedpoint.Value + // list[6]: turnover, Turnover. Unit of figure: quantity of quota coin + TurnOver fixedpoint.Value +} + +const KLinesArrayLen = 7 + +func (k *KLine) UnmarshalJSON(data []byte) error { + var jsonArr []json.RawMessage + err := json.Unmarshal(data, &jsonArr) + if err != nil { + return fmt.Errorf("failed to unmarshal jsonRawMessage: %v, err: %w", string(data), err) + } + if len(jsonArr) != KLinesArrayLen { + return fmt.Errorf("unexpected K Lines array length: %d, exp: %d", len(jsonArr), KLinesArrayLen) + } + + err = json.Unmarshal(jsonArr[0], &k.StartTime) + if err != nil { + return fmt.Errorf("failed to unmarshal resp index 0: %v, err: %w", string(jsonArr[0]), err) + } + + values := make([]fixedpoint.Value, len(jsonArr)-1) + for i, jsonRaw := range jsonArr[1:] { + err = json.Unmarshal(jsonRaw, &values[i]) + if err != nil { + return fmt.Errorf("failed to unmarshal resp index %d: %v, err: %w", i+1, string(jsonRaw), err) + } + } + k.Open = values[0] + k.High = values[1] + k.Low = values[2] + k.Close = values[3] + k.Volume = values[4] + k.TurnOver = values[5] + + return nil +} + +//go:generate GetRequest -url "/v5/market/kline" -type GetKLinesRequest -responseDataType .KLinesResponse +type GetKLinesRequest struct { + client requestgen.APIClient + + category Category `param:"category,query" validValues:"spot"` + symbol string `param:"symbol,query"` + // Kline interval. + // - 1,3,5,15,30,60,120,240,360,720: minute + // - D: day + // - M: month + // - W: week + interval string `param:"interval,query" validValues:"1,3,5,15,30,60,120,240,360,720,D,W,M"` + startTime *time.Time `param:"start,query,milliseconds"` + endTime *time.Time `param:"end,query,milliseconds"` + // Limit for data size per page. [1, 1000]. Default: 200 + limit *uint64 `param:"limit,query"` +} + +func (c *RestClient) NewGetKLinesRequest() *GetKLinesRequest { + return &GetKLinesRequest{ + client: c, + category: CategorySpot, + } +} diff --git a/pkg/exchange/bybit/bybitapi/get_k_lines_request_requestgen.go b/pkg/exchange/bybit/bybitapi/get_k_lines_request_requestgen.go new file mode 100644 index 0000000..e08072a --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/get_k_lines_request_requestgen.go @@ -0,0 +1,255 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Result -url /v5/market/kline -type GetKLinesRequest -responseDataType .KLinesResponse"; DO NOT EDIT. + +package bybitapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetKLinesRequest) Category(category Category) *GetKLinesRequest { + g.category = category + return g +} + +func (g *GetKLinesRequest) Symbol(symbol string) *GetKLinesRequest { + g.symbol = symbol + return g +} + +func (g *GetKLinesRequest) Interval(interval string) *GetKLinesRequest { + g.interval = interval + return g +} + +func (g *GetKLinesRequest) StartTime(startTime time.Time) *GetKLinesRequest { + g.startTime = &startTime + return g +} + +func (g *GetKLinesRequest) EndTime(endTime time.Time) *GetKLinesRequest { + g.endTime = &endTime + return g +} + +func (g *GetKLinesRequest) Limit(limit uint64) *GetKLinesRequest { + g.limit = &limit + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetKLinesRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check category field -> json key category + category := g.category + + // TEMPLATE check-valid-values + switch category { + case "spot": + params["category"] = category + + default: + return nil, fmt.Errorf("category value %v is invalid", category) + + } + // END TEMPLATE check-valid-values + + // assign parameter of category + params["category"] = category + // check symbol field -> json key symbol + symbol := g.symbol + + // assign parameter of symbol + params["symbol"] = symbol + // check interval field -> json key interval + interval := g.interval + + // TEMPLATE check-valid-values + switch interval { + case "1", "3", "5", "15", "30", "60", "120", "240", "360", "720", "D", "W", "M": + params["interval"] = interval + + default: + return nil, fmt.Errorf("interval value %v is invalid", interval) + + } + // END TEMPLATE check-valid-values + + // assign parameter of interval + params["interval"] = interval + // check startTime field -> json key start + if g.startTime != nil { + startTime := *g.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["start"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key end + if g.endTime != nil { + endTime := *g.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["end"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetKLinesRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetKLinesRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetKLinesRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetKLinesRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetKLinesRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetKLinesRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetKLinesRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetKLinesRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (g *GetKLinesRequest) GetPath() string { + return "/v5/market/kline" +} + +// Do generates the request object and send the request object to the API endpoint +func (g *GetKLinesRequest) Do(ctx context.Context) (*KLinesResponse, error) { + + // no body params + var params interface{} + query, err := g.GetQueryParameters() + if err != nil { + return nil, err + } + + var apiURL string + + apiURL = g.GetPath() + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data KLinesResponse + if err := json.Unmarshal(apiResponse.Result, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/bybit/bybitapi/get_k_lines_request_test.go b/pkg/exchange/bybit/bybitapi/get_k_lines_request_test.go new file mode 100644 index 0000000..715c7eb --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/get_k_lines_request_test.go @@ -0,0 +1,175 @@ +package bybitapi + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func TestKLinesResponse_UnmarshalJSON(t *testing.T) { + t.Run("succeeds", func(t *testing.T) { + data := `{ + "symbol": "BTCUSDT", + "category": "spot", + "list": [ + [ + "1670608800000", + "17071", + "17073", + "17027", + "17055.5", + "268611", + "15.74462667" + ], + [ + "1670605200000", + "17071.5", + "17071.5", + "17061", + "17071", + "4177", + "0.24469757" + ] + ] + }` + + expRes := &KLinesResponse{ + Symbol: "BTCUSDT", + List: []KLine{ + { + StartTime: types.NewMillisecondTimestampFromInt(1670608800000), + Open: fixedpoint.NewFromFloat(17071), + High: fixedpoint.NewFromFloat(17073), + Low: fixedpoint.NewFromFloat(17027), + Close: fixedpoint.NewFromFloat(17055.5), + Volume: fixedpoint.NewFromFloat(268611), + TurnOver: fixedpoint.NewFromFloat(15.74462667), + }, + { + StartTime: types.NewMillisecondTimestampFromInt(1670605200000), + Open: fixedpoint.NewFromFloat(17071.5), + High: fixedpoint.NewFromFloat(17071.5), + Low: fixedpoint.NewFromFloat(17061), + Close: fixedpoint.NewFromFloat(17071), + Volume: fixedpoint.NewFromFloat(4177), + TurnOver: fixedpoint.NewFromFloat(0.24469757), + }, + }, + Category: CategorySpot, + } + + kline := &KLinesResponse{} + err := json.Unmarshal([]byte(data), kline) + assert.NoError(t, err) + assert.Equal(t, expRes, kline) + }) + + t.Run("unexpected length", func(t *testing.T) { + data := `{ + "symbol": "BTCUSDT", + "category": "spot", + "list": [ + [ + "1670608800000", + "17071", + "17073", + "17027", + "17055.5", + "268611" + ] + ] + }` + kline := &KLinesResponse{} + err := json.Unmarshal([]byte(data), kline) + assert.Equal(t, fmt.Errorf("unexpected K Lines array length: 6, exp: %d", KLinesArrayLen), err) + }) + + t.Run("unexpected json array", func(t *testing.T) { + klineJson := `{}` + + data := fmt.Sprintf(`{ + "symbol": "BTCUSDT", + "category": "spot", + "list": [%s] + }`, klineJson) + + var jsonArr []json.RawMessage + expErr := json.Unmarshal([]byte(klineJson), &jsonArr) + assert.Error(t, expErr) + + kline := &KLinesResponse{} + err := json.Unmarshal([]byte(data), kline) + assert.Equal(t, fmt.Errorf("failed to unmarshal jsonRawMessage: %v, err: %w", klineJson, expErr), err) + }) + + t.Run("unexpected json 0", func(t *testing.T) { + klineJson := ` + [ + "a", + "17071.5", + "17071.5", + "17061", + "17071", + "4177", + "0.24469757" + ] + ` + + data := fmt.Sprintf(`{ + "symbol": "BTCUSDT", + "category": "spot", + "list": [%s] + }`, klineJson) + + var jsonArr []json.RawMessage + err := json.Unmarshal([]byte(klineJson), &jsonArr) + assert.NoError(t, err) + + timestamp := types.MillisecondTimestamp{} + expErr := json.Unmarshal(jsonArr[0], ×tamp) + assert.NoError(t, err) + + kline := &KLinesResponse{} + err = json.Unmarshal([]byte(data), kline) + assert.Equal(t, fmt.Errorf("failed to unmarshal resp index 0: %v, err: %w", string(jsonArr[0]), expErr), err) + }) + + t.Run("unexpected json 1", func(t *testing.T) { + // TODO: fix panic + t.Skip("test will result in a panic, skip it") + klineJson := ` + [ + "1670608800000", + "a", + "17071.5", + "17061", + "17071", + "4177", + "0.24469757" + ] + ` + + data := fmt.Sprintf(`{ + "symbol": "BTCUSDT", + "category": "spot", + "list": [%s] + }`, klineJson) + + var jsonArr []json.RawMessage + err := json.Unmarshal([]byte(klineJson), &jsonArr) + assert.NoError(t, err) + + var value fixedpoint.Value + expErr := json.Unmarshal(jsonArr[1], &value) + assert.NoError(t, err) + + kline := &KLinesResponse{} + err = json.Unmarshal([]byte(data), kline) + assert.Equal(t, fmt.Errorf("failed to unmarshal resp index 1: %v, err: %w", string(jsonArr[1]), expErr), err) + }) +} diff --git a/pkg/exchange/bybit/bybitapi/get_open_orders_request.go b/pkg/exchange/bybit/bybitapi/get_open_orders_request.go new file mode 100644 index 0000000..5313ac6 --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/get_open_orders_request.go @@ -0,0 +1,104 @@ +package bybitapi + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/c9s/requestgen" +) + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Result +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Result + +type OrdersResponse struct { + List []Order `json:"list"` + NextPageCursor string `json:"nextPageCursor"` + Category string `json:"category"` +} + +type Order struct { + OrderId string `json:"orderId"` + OrderLinkId string `json:"orderLinkId"` + Symbol string `json:"symbol"` + Side Side `json:"side"` + OrderStatus OrderStatus `json:"orderStatus"` + OrderType OrderType `json:"orderType"` + TimeInForce TimeInForce `json:"timeInForce"` + Price fixedpoint.Value `json:"price"` + CreatedTime types.MillisecondTimestamp `json:"createdTime"` + UpdatedTime types.MillisecondTimestamp `json:"updatedTime"` + + // Qty represents **quote coin** if order is market buy + Qty fixedpoint.Value `json:"qty"` + + // AvgPrice is supported in both RESTful API and WebSocket. + // + // For websocket must notice that: + // - Normal account is not supported. + // - For normal account USDT Perp and Inverse derivatives trades, if a partially filled order, and the + // final orderStatus is Cancelled, then avgPrice is "0" + AvgPrice fixedpoint.Value `json:"avgPrice"` + + // CumExecQty is supported in both RESTful API and WebSocket. + CumExecQty fixedpoint.Value `json:"cumExecQty"` + + // CumExecValue is supported in both RESTful API and WebSocket. + // However, it's **not** supported for **normal accounts** in RESTful API. + CumExecValue fixedpoint.Value `json:"cumExecValue"` + + // CumExecFee is supported in both RESTful API and WebSocket. + // However, it's **not** supported for **normal accounts** in RESTful API. + // For websocket normal spot, it is the execution fee per single fill. + CumExecFee fixedpoint.Value `json:"cumExecFee"` + + BlockTradeId string `json:"blockTradeId"` + IsLeverage string `json:"isLeverage"` + PositionIdx int `json:"positionIdx"` + CancelType string `json:"cancelType"` + RejectReason string `json:"rejectReason"` + LeavesQty fixedpoint.Value `json:"leavesQty"` + LeavesValue fixedpoint.Value `json:"leavesValue"` + StopOrderType string `json:"stopOrderType"` + OrderIv string `json:"orderIv"` + TriggerPrice fixedpoint.Value `json:"triggerPrice"` + TakeProfit fixedpoint.Value `json:"takeProfit"` + StopLoss fixedpoint.Value `json:"stopLoss"` + TpTriggerBy string `json:"tpTriggerBy"` + SlTriggerBy string `json:"slTriggerBy"` + TriggerDirection int `json:"triggerDirection"` + TriggerBy string `json:"triggerBy"` + LastPriceOnCreated string `json:"lastPriceOnCreated"` + ReduceOnly bool `json:"reduceOnly"` + CloseOnTrigger bool `json:"closeOnTrigger"` + SmpType string `json:"smpType"` + SmpGroup int `json:"smpGroup"` + SmpOrderId string `json:"smpOrderId"` + TpslMode string `json:"tpslMode"` + TpLimitPrice string `json:"tpLimitPrice"` + SlLimitPrice string `json:"slLimitPrice"` + PlaceType string `json:"placeType"` +} + +//go:generate GetRequest -url "/v5/order/realtime" -type GetOpenOrdersRequest -responseDataType .OrdersResponse +type GetOpenOrdersRequest struct { + client requestgen.AuthenticatedAPIClient + + category Category `param:"category,query" validValues:"spot"` + symbol *string `param:"symbol,query"` + baseCoin *string `param:"baseCoin,query"` + settleCoin *string `param:"settleCoin,query"` + orderId *string `param:"orderId,query"` + orderLinkId *string `param:"orderLinkId,query"` + openOnly *OpenOnly `param:"openOnly,query" validValues:"0"` + orderFilter *string `param:"orderFilter,query"` + limit *uint64 `param:"limit,query"` + cursor *string `param:"cursor,query"` +} + +// NewGetOpenOrderRequest queries unfilled or partially filled orders in real-time. To query older order records, +// please use the order history interface. +func (c *RestClient) NewGetOpenOrderRequest() *GetOpenOrdersRequest { + return &GetOpenOrdersRequest{ + client: c, + category: CategorySpot, + } +} diff --git a/pkg/exchange/bybit/bybitapi/get_open_orders_request_requestgen.go b/pkg/exchange/bybit/bybitapi/get_open_orders_request_requestgen.go new file mode 100644 index 0000000..3b7c29b --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/get_open_orders_request_requestgen.go @@ -0,0 +1,309 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Result -url /v5/order/realtime -type GetOpenOrdersRequest -responseDataType .OrdersResponse"; DO NOT EDIT. + +package bybitapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (g *GetOpenOrdersRequest) Category(category Category) *GetOpenOrdersRequest { + g.category = category + return g +} + +func (g *GetOpenOrdersRequest) Symbol(symbol string) *GetOpenOrdersRequest { + g.symbol = &symbol + return g +} + +func (g *GetOpenOrdersRequest) BaseCoin(baseCoin string) *GetOpenOrdersRequest { + g.baseCoin = &baseCoin + return g +} + +func (g *GetOpenOrdersRequest) SettleCoin(settleCoin string) *GetOpenOrdersRequest { + g.settleCoin = &settleCoin + return g +} + +func (g *GetOpenOrdersRequest) OrderId(orderId string) *GetOpenOrdersRequest { + g.orderId = &orderId + return g +} + +func (g *GetOpenOrdersRequest) OrderLinkId(orderLinkId string) *GetOpenOrdersRequest { + g.orderLinkId = &orderLinkId + return g +} + +func (g *GetOpenOrdersRequest) OpenOnly(openOnly OpenOnly) *GetOpenOrdersRequest { + g.openOnly = &openOnly + return g +} + +func (g *GetOpenOrdersRequest) OrderFilter(orderFilter string) *GetOpenOrdersRequest { + g.orderFilter = &orderFilter + return g +} + +func (g *GetOpenOrdersRequest) Limit(limit uint64) *GetOpenOrdersRequest { + g.limit = &limit + return g +} + +func (g *GetOpenOrdersRequest) Cursor(cursor string) *GetOpenOrdersRequest { + g.cursor = &cursor + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetOpenOrdersRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check category field -> json key category + category := g.category + + // TEMPLATE check-valid-values + switch category { + case "spot": + params["category"] = category + + default: + return nil, fmt.Errorf("category value %v is invalid", category) + + } + // END TEMPLATE check-valid-values + + // assign parameter of category + params["category"] = category + // check symbol field -> json key symbol + if g.symbol != nil { + symbol := *g.symbol + + // assign parameter of symbol + params["symbol"] = symbol + } else { + } + // check baseCoin field -> json key baseCoin + if g.baseCoin != nil { + baseCoin := *g.baseCoin + + // assign parameter of baseCoin + params["baseCoin"] = baseCoin + } else { + } + // check settleCoin field -> json key settleCoin + if g.settleCoin != nil { + settleCoin := *g.settleCoin + + // assign parameter of settleCoin + params["settleCoin"] = settleCoin + } else { + } + // check orderId field -> json key orderId + if g.orderId != nil { + orderId := *g.orderId + + // assign parameter of orderId + params["orderId"] = orderId + } else { + } + // check orderLinkId field -> json key orderLinkId + if g.orderLinkId != nil { + orderLinkId := *g.orderLinkId + + // assign parameter of orderLinkId + params["orderLinkId"] = orderLinkId + } else { + } + // check openOnly field -> json key openOnly + if g.openOnly != nil { + openOnly := *g.openOnly + + // TEMPLATE check-valid-values + switch openOnly { + case OpenOnlyOrder: + params["openOnly"] = openOnly + + default: + return nil, fmt.Errorf("openOnly value %v is invalid", openOnly) + + } + // END TEMPLATE check-valid-values + + // assign parameter of openOnly + params["openOnly"] = openOnly + } else { + } + // check orderFilter field -> json key orderFilter + if g.orderFilter != nil { + orderFilter := *g.orderFilter + + // assign parameter of orderFilter + params["orderFilter"] = orderFilter + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + // check cursor field -> json key cursor + if g.cursor != nil { + cursor := *g.cursor + + // assign parameter of cursor + params["cursor"] = cursor + } else { + } + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetOpenOrdersRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetOpenOrdersRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetOpenOrdersRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetOpenOrdersRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetOpenOrdersRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetOpenOrdersRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetOpenOrdersRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetOpenOrdersRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (g *GetOpenOrdersRequest) GetPath() string { + return "/v5/order/realtime" +} + +// Do generates the request object and send the request object to the API endpoint +func (g *GetOpenOrdersRequest) Do(ctx context.Context) (*OrdersResponse, error) { + + // no body params + var params interface{} + query, err := g.GetQueryParameters() + if err != nil { + return nil, err + } + + var apiURL string + + apiURL = g.GetPath() + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data OrdersResponse + if err := json.Unmarshal(apiResponse.Result, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/bybit/bybitapi/get_order_histories_request.go b/pkg/exchange/bybit/bybitapi/get_order_histories_request.go new file mode 100644 index 0000000..7731bc6 --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/get_order_histories_request.go @@ -0,0 +1,52 @@ +package bybitapi + +import ( + "time" + + "github.com/c9s/requestgen" +) + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Result +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Result + +//go:generate GetRequest -url "/v5/order/history" -type GetOrderHistoriesRequest -responseDataType .OrdersResponse +type GetOrderHistoriesRequest struct { + client requestgen.AuthenticatedAPIClient + + category Category `param:"category,query" validValues:"spot"` + + symbol *string `param:"symbol,query"` + orderId *string `param:"orderId,query"` + orderLinkId *string `param:"orderLinkId,query"` + // orderFilter supports 3 types of Order: + // 1. active order, 2. StopOrder: conditional order, 3. tpslOrder: spot TP/SL order + // Normal spot: return Order active order by default + // Others: all kinds of orders by default + orderFilter *string `param:"orderFilter,query"` + // orderStatus if the account belongs to Normal spot, orderStatus is not supported. + //// For other accounts, return all status orders if not explicitly passed. + orderStatus *OrderStatus `param:"orderStatus,query"` + + // startTime must + // Normal spot is not supported temporarily + // startTime and endTime must be passed together + // If not passed, query the past 7 days data by default + // For each request, startTime and endTime interval should be less then 7 days + startTime *time.Time `param:"startTime,query,milliseconds"` + + // endTime for each request, startTime and endTime interval should be less then 7 days + endTime *time.Time `param:"endTime,query,milliseconds"` + + // limit for data size per page. [1, 50]. Default: 20 + limit *uint64 `param:"limit,query"` + // cursor uses the nextPageCursor token from the response to retrieve the next page of the result set + cursor *string `param:"cursor,query"` +} + +// NewGetOrderHistoriesRequest is descending order by createdTime +func (c *RestClient) NewGetOrderHistoriesRequest() *GetOrderHistoriesRequest { + return &GetOrderHistoriesRequest{ + client: c, + category: CategorySpot, + } +} diff --git a/pkg/exchange/bybit/bybitapi/get_order_histories_request_requestgen.go b/pkg/exchange/bybit/bybitapi/get_order_histories_request_requestgen.go new file mode 100644 index 0000000..62b3624 --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/get_order_histories_request_requestgen.go @@ -0,0 +1,313 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Result -url /v5/order/history -type GetOrderHistoriesRequest -responseDataType .OrdersResponse"; DO NOT EDIT. + +package bybitapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetOrderHistoriesRequest) Category(category Category) *GetOrderHistoriesRequest { + g.category = category + return g +} + +func (g *GetOrderHistoriesRequest) Symbol(symbol string) *GetOrderHistoriesRequest { + g.symbol = &symbol + return g +} + +func (g *GetOrderHistoriesRequest) OrderId(orderId string) *GetOrderHistoriesRequest { + g.orderId = &orderId + return g +} + +func (g *GetOrderHistoriesRequest) OrderLinkId(orderLinkId string) *GetOrderHistoriesRequest { + g.orderLinkId = &orderLinkId + return g +} + +func (g *GetOrderHistoriesRequest) OrderFilter(orderFilter string) *GetOrderHistoriesRequest { + g.orderFilter = &orderFilter + return g +} + +func (g *GetOrderHistoriesRequest) OrderStatus(orderStatus OrderStatus) *GetOrderHistoriesRequest { + g.orderStatus = &orderStatus + return g +} + +func (g *GetOrderHistoriesRequest) StartTime(startTime time.Time) *GetOrderHistoriesRequest { + g.startTime = &startTime + return g +} + +func (g *GetOrderHistoriesRequest) EndTime(endTime time.Time) *GetOrderHistoriesRequest { + g.endTime = &endTime + return g +} + +func (g *GetOrderHistoriesRequest) Limit(limit uint64) *GetOrderHistoriesRequest { + g.limit = &limit + return g +} + +func (g *GetOrderHistoriesRequest) Cursor(cursor string) *GetOrderHistoriesRequest { + g.cursor = &cursor + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetOrderHistoriesRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check category field -> json key category + category := g.category + + // TEMPLATE check-valid-values + switch category { + case "spot": + params["category"] = category + + default: + return nil, fmt.Errorf("category value %v is invalid", category) + + } + // END TEMPLATE check-valid-values + + // assign parameter of category + params["category"] = category + // check symbol field -> json key symbol + if g.symbol != nil { + symbol := *g.symbol + + // assign parameter of symbol + params["symbol"] = symbol + } else { + } + // check orderId field -> json key orderId + if g.orderId != nil { + orderId := *g.orderId + + // assign parameter of orderId + params["orderId"] = orderId + } else { + } + // check orderLinkId field -> json key orderLinkId + if g.orderLinkId != nil { + orderLinkId := *g.orderLinkId + + // assign parameter of orderLinkId + params["orderLinkId"] = orderLinkId + } else { + } + // check orderFilter field -> json key orderFilter + if g.orderFilter != nil { + orderFilter := *g.orderFilter + + // assign parameter of orderFilter + params["orderFilter"] = orderFilter + } else { + } + // check orderStatus field -> json key orderStatus + if g.orderStatus != nil { + orderStatus := *g.orderStatus + + // TEMPLATE check-valid-values + switch orderStatus { + case OrderStatusCreated, OrderStatusNew, OrderStatusRejected, OrderStatusPartiallyFilled, OrderStatusPartiallyFilledCanceled, OrderStatusFilled, OrderStatusCancelled, OrderStatusDeactivated, OrderStatusActive: + params["orderStatus"] = orderStatus + + default: + return nil, fmt.Errorf("orderStatus value %v is invalid", orderStatus) + + } + // END TEMPLATE check-valid-values + + // assign parameter of orderStatus + params["orderStatus"] = orderStatus + } else { + } + // check startTime field -> json key startTime + if g.startTime != nil { + startTime := *g.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["startTime"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key endTime + if g.endTime != nil { + endTime := *g.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["endTime"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + // check cursor field -> json key cursor + if g.cursor != nil { + cursor := *g.cursor + + // assign parameter of cursor + params["cursor"] = cursor + } else { + } + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetOrderHistoriesRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetOrderHistoriesRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetOrderHistoriesRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetOrderHistoriesRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetOrderHistoriesRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetOrderHistoriesRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetOrderHistoriesRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetOrderHistoriesRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (g *GetOrderHistoriesRequest) GetPath() string { + return "/v5/order/history" +} + +// Do generates the request object and send the request object to the API endpoint +func (g *GetOrderHistoriesRequest) Do(ctx context.Context) (*OrdersResponse, error) { + + // no body params + var params interface{} + query, err := g.GetQueryParameters() + if err != nil { + return nil, err + } + + var apiURL string + + apiURL = g.GetPath() + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data OrdersResponse + if err := json.Unmarshal(apiResponse.Result, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/bybit/bybitapi/get_tickers_request.go b/pkg/exchange/bybit/bybitapi/get_tickers_request.go new file mode 100644 index 0000000..93b8466 --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/get_tickers_request.go @@ -0,0 +1,72 @@ +package bybitapi + +import ( + "context" + "encoding/json" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/c9s/requestgen" +) + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Result +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Result + +type Tickers struct { + Category Category `json:"category"` + List []Ticker `json:"list"` + + // ClosedTime is current timestamp (ms). This value is obtained from outside APIResponse. + ClosedTime types.MillisecondTimestamp +} + +type Ticker struct { + Symbol string `json:"symbol"` + Bid1Price fixedpoint.Value `json:"bid1Price"` + Bid1Size fixedpoint.Value `json:"bid1Size"` + Ask1Price fixedpoint.Value `json:"ask1Price"` + Ask1Size fixedpoint.Value `json:"ask1Size"` + LastPrice fixedpoint.Value `json:"lastPrice"` + PrevPrice24H fixedpoint.Value `json:"prevPrice24h"` + Price24HPcnt fixedpoint.Value `json:"price24hPcnt"` + HighPrice24H fixedpoint.Value `json:"highPrice24h"` + LowPrice24H fixedpoint.Value `json:"lowPrice24h"` + Turnover24H fixedpoint.Value `json:"turnover24h"` + Volume24H fixedpoint.Value `json:"volume24h"` + UsdIndexPrice fixedpoint.Value `json:"usdIndexPrice"` +} + +// GetTickersRequest without **-responseDataType .InstrumentsInfo** in generation command, because the caller +// needs the APIResponse.Time. We implemented the DoWithResponseTime to handle this. +// +//go:generate GetRequest -url "/v5/market/tickers" -type GetTickersRequest +type GetTickersRequest struct { + client requestgen.APIClient + + category Category `param:"category,query" validValues:"spot"` + symbol *string `param:"symbol,query"` +} + +func (c *RestClient) NewGetTickersRequest() *GetTickersRequest { + return &GetTickersRequest{ + client: c, + category: CategorySpot, + } +} + +func (g *GetTickersRequest) DoWithResponseTime(ctx context.Context) (*Tickers, error) { + resp, err := g.Do(ctx) + if err != nil { + return nil, err + } + + var data Tickers + if err := json.Unmarshal(resp.Result, &data); err != nil { + return nil, err + } + + // Our types.Ticker requires the closed time, but this API does not provide it. This API returns the Tickers of the + // past 24 hours, so in terms of closed time, it is the current time, so fill it in Tickers.ClosedTime. + data.ClosedTime = resp.Time + return &data, nil +} diff --git a/pkg/exchange/bybit/bybitapi/get_tickers_request_requestgen.go b/pkg/exchange/bybit/bybitapi/get_tickers_request_requestgen.go new file mode 100644 index 0000000..59a81a4 --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/get_tickers_request_requestgen.go @@ -0,0 +1,190 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Result -url /v5/market/tickers -type GetTickersRequest"; DO NOT EDIT. + +package bybitapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (g *GetTickersRequest) Category(category Category) *GetTickersRequest { + g.category = category + return g +} + +func (g *GetTickersRequest) Symbol(symbol string) *GetTickersRequest { + g.symbol = &symbol + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetTickersRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check category field -> json key category + category := g.category + + // TEMPLATE check-valid-values + switch category { + case "spot": + params["category"] = category + + default: + return nil, fmt.Errorf("category value %v is invalid", category) + + } + // END TEMPLATE check-valid-values + + // assign parameter of category + params["category"] = category + // check symbol field -> json key symbol + if g.symbol != nil { + symbol := *g.symbol + + // assign parameter of symbol + params["symbol"] = symbol + } else { + } + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetTickersRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetTickersRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetTickersRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetTickersRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetTickersRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetTickersRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetTickersRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetTickersRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (g *GetTickersRequest) GetPath() string { + return "/v5/market/tickers" +} + +// Do generates the request object and send the request object to the API endpoint +func (g *GetTickersRequest) Do(ctx context.Context) (*APIResponse, error) { + + // no body params + var params interface{} + query, err := g.GetQueryParameters() + if err != nil { + return nil, err + } + + var apiURL string + + apiURL = g.GetPath() + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + return &apiResponse, nil +} diff --git a/pkg/exchange/bybit/bybitapi/get_wallet_balances_request.go b/pkg/exchange/bybit/bybitapi/get_wallet_balances_request.go new file mode 100644 index 0000000..13b5cb9 --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/get_wallet_balances_request.go @@ -0,0 +1,92 @@ +package bybitapi + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Result +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Result + +type WalletBalancesResponse struct { + List []WalletBalances `json:"list"` +} + +type WalletBalances struct { + AccountType AccountType `json:"accountType"` + AccountIMRate fixedpoint.Value `json:"accountIMRate"` + AccountMMRate fixedpoint.Value `json:"accountMMRate"` + TotalEquity fixedpoint.Value `json:"totalEquity"` + TotalWalletBalance fixedpoint.Value `json:"totalWalletBalance"` + TotalMarginBalance fixedpoint.Value `json:"totalMarginBalance"` + TotalAvailableBalance fixedpoint.Value `json:"totalAvailableBalance"` + TotalPerpUPL fixedpoint.Value `json:"totalPerpUPL"` + TotalInitialMargin fixedpoint.Value `json:"totalInitialMargin"` + TotalMaintenanceMargin fixedpoint.Value `json:"totalMaintenanceMargin"` + // Account LTV: account total borrowed size / (account total equity + account total borrowed size). + // In non-unified mode & unified (inverse) & unified (isolated_margin), the field will be returned as an empty string. + AccountLTV fixedpoint.Value `json:"accountLTV"` + Coins []struct { + Coin string `json:"coin"` + // Equity of current coin + Equity fixedpoint.Value `json:"equity"` + // UsdValue of current coin. If this coin cannot be collateral, then it is 0 + UsdValue fixedpoint.Value `json:"usdValue"` + // WalletBalance of current coin + WalletBalance fixedpoint.Value `json:"walletBalance"` + // Free available balance for Spot wallet. This is a unique field for Normal SPOT + Free fixedpoint.Value + // Locked balance for Spot wallet. This is a unique field for Normal SPOT + Locked fixedpoint.Value + // Available amount to withdraw of current coin + AvailableToWithdraw fixedpoint.Value `json:"availableToWithdraw"` + // Available amount to borrow of current coin + AvailableToBorrow fixedpoint.Value `json:"availableToBorrow"` + // Borrow amount of current coin + BorrowAmount fixedpoint.Value `json:"borrowAmount"` + // Accrued interest + AccruedInterest fixedpoint.Value `json:"accruedInterest"` + // Pre-occupied margin for order. For portfolio margin mode, it returns "" + TotalOrderIM fixedpoint.Value `json:"totalOrderIM"` + // Sum of initial margin of all positions + Pre-occupied liquidation fee. For portfolio margin mode, it returns "" + TotalPositionIM fixedpoint.Value `json:"totalPositionIM"` + // Sum of maintenance margin for all positions. For portfolio margin mode, it returns "" + TotalPositionMM fixedpoint.Value `json:"totalPositionMM"` + // Unrealised P&L + UnrealisedPnl fixedpoint.Value `json:"unrealisedPnl"` + // Cumulative Realised P&L + CumRealisedPnl fixedpoint.Value `json:"cumRealisedPnl"` + // Bonus. This is a unique field for UNIFIED account + Bonus fixedpoint.Value `json:"bonus"` + // Whether it can be used as a margin collateral currency (platform) + // - When marginCollateral=false, then collateralSwitch is meaningless + // - This is a unique field for UNIFIED account + CollateralSwitch bool `json:"collateralSwitch"` + // Whether the collateral is turned on by user (user) + // - When marginCollateral=true, then collateralSwitch is meaningful + // - This is a unique field for UNIFIED account + MarginCollateral bool `json:"marginCollateral"` + } `json:"coin"` +} + +//go:generate GetRequest -url "/v5/account/wallet-balance" -type GetWalletBalancesRequest -responseDataType .WalletBalancesResponse +type GetWalletBalancesRequest struct { + client requestgen.AuthenticatedAPIClient + + // Account type + // - Unified account: UNIFIED (trade spot/linear/options), CONTRACT(trade inverse) + // - Normal account: CONTRACT, SPOT + accountType AccountType `param:"accountType,query" validValues:"SPOT"` + // Coin name + // - If not passed, it returns non-zero asset info + // - You can pass multiple coins to query, separated by comma. USDT,USDC + coin *string `param:"coin,query"` +} + +func (c *RestClient) NewGetWalletBalancesRequest() *GetWalletBalancesRequest { + return &GetWalletBalancesRequest{ + client: c, + accountType: AccountTypeSpot, + } +} diff --git a/pkg/exchange/bybit/bybitapi/get_wallet_balances_request_requestgen.go b/pkg/exchange/bybit/bybitapi/get_wallet_balances_request_requestgen.go new file mode 100644 index 0000000..601d26c --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/get_wallet_balances_request_requestgen.go @@ -0,0 +1,194 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Result -url /v5/account/wallet-balance -type GetWalletBalancesRequest -responseDataType .WalletBalancesResponse"; DO NOT EDIT. + +package bybitapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (g *GetWalletBalancesRequest) AccountType(accountType AccountType) *GetWalletBalancesRequest { + g.accountType = accountType + return g +} + +func (g *GetWalletBalancesRequest) Coin(coin string) *GetWalletBalancesRequest { + g.coin = &coin + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetWalletBalancesRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check accountType field -> json key accountType + accountType := g.accountType + + // TEMPLATE check-valid-values + switch accountType { + case "SPOT": + params["accountType"] = accountType + + default: + return nil, fmt.Errorf("accountType value %v is invalid", accountType) + + } + // END TEMPLATE check-valid-values + + // assign parameter of accountType + params["accountType"] = accountType + // check coin field -> json key coin + if g.coin != nil { + coin := *g.coin + + // assign parameter of coin + params["coin"] = coin + } else { + } + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetWalletBalancesRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetWalletBalancesRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetWalletBalancesRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetWalletBalancesRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetWalletBalancesRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetWalletBalancesRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetWalletBalancesRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetWalletBalancesRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (g *GetWalletBalancesRequest) GetPath() string { + return "/v5/account/wallet-balance" +} + +// Do generates the request object and send the request object to the API endpoint +func (g *GetWalletBalancesRequest) Do(ctx context.Context) (*WalletBalancesResponse, error) { + + // no body params + var params interface{} + query, err := g.GetQueryParameters() + if err != nil { + return nil, err + } + + var apiURL string + + apiURL = g.GetPath() + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data WalletBalancesResponse + if err := json.Unmarshal(apiResponse.Result, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/bybit/bybitapi/place_order_request.go b/pkg/exchange/bybit/bybitapi/place_order_request.go new file mode 100644 index 0000000..9afbebf --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/place_order_request.go @@ -0,0 +1,57 @@ +package bybitapi + +import ( + "github.com/c9s/requestgen" +) + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Result +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Result + +type PlaceOrderResponse struct { + OrderId string `json:"orderId"` + OrderLinkId string `json:"orderLinkId"` +} + +//go:generate PostRequest -url "/v5/order/create" -type PlaceOrderRequest -responseDataType .PlaceOrderResponse +type PlaceOrderRequest struct { + client requestgen.AuthenticatedAPIClient + + category Category `param:"category" validValues:"spot"` + symbol string `param:"symbol"` + side Side `param:"side" validValues:"Buy,Sell"` + orderType OrderType `param:"orderType" validValues:"Market,Limit"` + qty string `param:"qty"` + orderLinkId string `param:"orderLinkId"` + timeInForce TimeInForce `param:"timeInForce"` + + isLeverage *bool `param:"isLeverage"` + price *string `param:"price"` + triggerDirection *int `param:"triggerDirection"` + // orderFilter default spot + orderFilter *string `param:"orderFilter"` + // triggerPrice when submitting an order, if triggerPrice is set, the order will be automatically converted into a conditional order. + triggerPrice *string `param:"triggerPrice"` + triggerBy *string `param:"triggerBy"` + orderIv *string `param:"orderIv"` + positionIdx *string `param:"positionIdx"` + takeProfit *string `param:"takeProfit"` + stopLoss *string `param:"stopLoss"` + tpTriggerBy *string `param:"tpTriggerBy"` + slTriggerBy *string `param:"slTriggerBy"` + reduceOnly *bool `param:"reduceOnly"` + closeOnTrigger *bool `param:"closeOnTrigger"` + smpType *string `param:"smpType"` + mmp *bool `param:"mmp"` // option only + tpslMode *string `param:"tpslMode"` + tpLimitPrice *string `param:"tpLimitPrice"` + slLimitPrice *string `param:"slLimitPrice"` + tpOrderType *string `param:"tpOrderType"` + slOrderType *string `param:"slOrderType"` +} + +func (c *RestClient) NewPlaceOrderRequest() *PlaceOrderRequest { + return &PlaceOrderRequest{ + client: c, + category: CategorySpot, + } +} diff --git a/pkg/exchange/bybit/bybitapi/place_order_request_requestgen.go b/pkg/exchange/bybit/bybitapi/place_order_request_requestgen.go new file mode 100644 index 0000000..95e3aaa --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/place_order_request_requestgen.go @@ -0,0 +1,546 @@ +// Code generated by "requestgen -method POST -responseType .APIResponse -responseDataField Result -url /v5/order/create -type PlaceOrderRequest -responseDataType .PlaceOrderResponse"; DO NOT EDIT. + +package bybitapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (p *PlaceOrderRequest) Category(category Category) *PlaceOrderRequest { + p.category = category + return p +} + +func (p *PlaceOrderRequest) Symbol(symbol string) *PlaceOrderRequest { + p.symbol = symbol + return p +} + +func (p *PlaceOrderRequest) Side(side Side) *PlaceOrderRequest { + p.side = side + return p +} + +func (p *PlaceOrderRequest) OrderType(orderType OrderType) *PlaceOrderRequest { + p.orderType = orderType + return p +} + +func (p *PlaceOrderRequest) Qty(qty string) *PlaceOrderRequest { + p.qty = qty + return p +} + +func (p *PlaceOrderRequest) OrderLinkId(orderLinkId string) *PlaceOrderRequest { + p.orderLinkId = orderLinkId + return p +} + +func (p *PlaceOrderRequest) TimeInForce(timeInForce TimeInForce) *PlaceOrderRequest { + p.timeInForce = timeInForce + return p +} + +func (p *PlaceOrderRequest) IsLeverage(isLeverage bool) *PlaceOrderRequest { + p.isLeverage = &isLeverage + return p +} + +func (p *PlaceOrderRequest) Price(price string) *PlaceOrderRequest { + p.price = &price + return p +} + +func (p *PlaceOrderRequest) TriggerDirection(triggerDirection int) *PlaceOrderRequest { + p.triggerDirection = &triggerDirection + return p +} + +func (p *PlaceOrderRequest) OrderFilter(orderFilter string) *PlaceOrderRequest { + p.orderFilter = &orderFilter + return p +} + +func (p *PlaceOrderRequest) TriggerPrice(triggerPrice string) *PlaceOrderRequest { + p.triggerPrice = &triggerPrice + return p +} + +func (p *PlaceOrderRequest) TriggerBy(triggerBy string) *PlaceOrderRequest { + p.triggerBy = &triggerBy + return p +} + +func (p *PlaceOrderRequest) OrderIv(orderIv string) *PlaceOrderRequest { + p.orderIv = &orderIv + return p +} + +func (p *PlaceOrderRequest) PositionIdx(positionIdx string) *PlaceOrderRequest { + p.positionIdx = &positionIdx + return p +} + +func (p *PlaceOrderRequest) TakeProfit(takeProfit string) *PlaceOrderRequest { + p.takeProfit = &takeProfit + return p +} + +func (p *PlaceOrderRequest) StopLoss(stopLoss string) *PlaceOrderRequest { + p.stopLoss = &stopLoss + return p +} + +func (p *PlaceOrderRequest) TpTriggerBy(tpTriggerBy string) *PlaceOrderRequest { + p.tpTriggerBy = &tpTriggerBy + return p +} + +func (p *PlaceOrderRequest) SlTriggerBy(slTriggerBy string) *PlaceOrderRequest { + p.slTriggerBy = &slTriggerBy + return p +} + +func (p *PlaceOrderRequest) ReduceOnly(reduceOnly bool) *PlaceOrderRequest { + p.reduceOnly = &reduceOnly + return p +} + +func (p *PlaceOrderRequest) CloseOnTrigger(closeOnTrigger bool) *PlaceOrderRequest { + p.closeOnTrigger = &closeOnTrigger + return p +} + +func (p *PlaceOrderRequest) SmpType(smpType string) *PlaceOrderRequest { + p.smpType = &smpType + return p +} + +func (p *PlaceOrderRequest) Mmp(mmp bool) *PlaceOrderRequest { + p.mmp = &mmp + return p +} + +func (p *PlaceOrderRequest) TpslMode(tpslMode string) *PlaceOrderRequest { + p.tpslMode = &tpslMode + return p +} + +func (p *PlaceOrderRequest) TpLimitPrice(tpLimitPrice string) *PlaceOrderRequest { + p.tpLimitPrice = &tpLimitPrice + return p +} + +func (p *PlaceOrderRequest) SlLimitPrice(slLimitPrice string) *PlaceOrderRequest { + p.slLimitPrice = &slLimitPrice + return p +} + +func (p *PlaceOrderRequest) TpOrderType(tpOrderType string) *PlaceOrderRequest { + p.tpOrderType = &tpOrderType + return p +} + +func (p *PlaceOrderRequest) SlOrderType(slOrderType string) *PlaceOrderRequest { + p.slOrderType = &slOrderType + return p +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (p *PlaceOrderRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (p *PlaceOrderRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check category field -> json key category + category := p.category + + // TEMPLATE check-valid-values + switch category { + case "spot": + params["category"] = category + + default: + return nil, fmt.Errorf("category value %v is invalid", category) + + } + // END TEMPLATE check-valid-values + + // assign parameter of category + params["category"] = category + // check symbol field -> json key symbol + symbol := p.symbol + + // assign parameter of symbol + params["symbol"] = symbol + // check side field -> json key side + side := p.side + + // TEMPLATE check-valid-values + switch side { + case "Buy", "Sell": + params["side"] = side + + default: + return nil, fmt.Errorf("side value %v is invalid", side) + + } + // END TEMPLATE check-valid-values + + // assign parameter of side + params["side"] = side + // check orderType field -> json key orderType + orderType := p.orderType + + // TEMPLATE check-valid-values + switch orderType { + case "Market", "Limit": + params["orderType"] = orderType + + default: + return nil, fmt.Errorf("orderType value %v is invalid", orderType) + + } + // END TEMPLATE check-valid-values + + // assign parameter of orderType + params["orderType"] = orderType + // check qty field -> json key qty + qty := p.qty + + // assign parameter of qty + params["qty"] = qty + // check orderLinkId field -> json key orderLinkId + orderLinkId := p.orderLinkId + + // assign parameter of orderLinkId + params["orderLinkId"] = orderLinkId + // check timeInForce field -> json key timeInForce + timeInForce := p.timeInForce + + // TEMPLATE check-valid-values + switch timeInForce { + case TimeInForceGTC, TimeInForceIOC, TimeInForceFOK: + params["timeInForce"] = timeInForce + + default: + return nil, fmt.Errorf("timeInForce value %v is invalid", timeInForce) + + } + // END TEMPLATE check-valid-values + + // assign parameter of timeInForce + params["timeInForce"] = timeInForce + // check isLeverage field -> json key isLeverage + if p.isLeverage != nil { + isLeverage := *p.isLeverage + + // assign parameter of isLeverage + params["isLeverage"] = isLeverage + } else { + } + // check price field -> json key price + if p.price != nil { + price := *p.price + + // assign parameter of price + params["price"] = price + } else { + } + // check triggerDirection field -> json key triggerDirection + if p.triggerDirection != nil { + triggerDirection := *p.triggerDirection + + // assign parameter of triggerDirection + params["triggerDirection"] = triggerDirection + } else { + } + // check orderFilter field -> json key orderFilter + if p.orderFilter != nil { + orderFilter := *p.orderFilter + + // assign parameter of orderFilter + params["orderFilter"] = orderFilter + } else { + } + // check triggerPrice field -> json key triggerPrice + if p.triggerPrice != nil { + triggerPrice := *p.triggerPrice + + // assign parameter of triggerPrice + params["triggerPrice"] = triggerPrice + } else { + } + // check triggerBy field -> json key triggerBy + if p.triggerBy != nil { + triggerBy := *p.triggerBy + + // assign parameter of triggerBy + params["triggerBy"] = triggerBy + } else { + } + // check orderIv field -> json key orderIv + if p.orderIv != nil { + orderIv := *p.orderIv + + // assign parameter of orderIv + params["orderIv"] = orderIv + } else { + } + // check positionIdx field -> json key positionIdx + if p.positionIdx != nil { + positionIdx := *p.positionIdx + + // assign parameter of positionIdx + params["positionIdx"] = positionIdx + } else { + } + // check takeProfit field -> json key takeProfit + if p.takeProfit != nil { + takeProfit := *p.takeProfit + + // assign parameter of takeProfit + params["takeProfit"] = takeProfit + } else { + } + // check stopLoss field -> json key stopLoss + if p.stopLoss != nil { + stopLoss := *p.stopLoss + + // assign parameter of stopLoss + params["stopLoss"] = stopLoss + } else { + } + // check tpTriggerBy field -> json key tpTriggerBy + if p.tpTriggerBy != nil { + tpTriggerBy := *p.tpTriggerBy + + // assign parameter of tpTriggerBy + params["tpTriggerBy"] = tpTriggerBy + } else { + } + // check slTriggerBy field -> json key slTriggerBy + if p.slTriggerBy != nil { + slTriggerBy := *p.slTriggerBy + + // assign parameter of slTriggerBy + params["slTriggerBy"] = slTriggerBy + } else { + } + // check reduceOnly field -> json key reduceOnly + if p.reduceOnly != nil { + reduceOnly := *p.reduceOnly + + // assign parameter of reduceOnly + params["reduceOnly"] = reduceOnly + } else { + } + // check closeOnTrigger field -> json key closeOnTrigger + if p.closeOnTrigger != nil { + closeOnTrigger := *p.closeOnTrigger + + // assign parameter of closeOnTrigger + params["closeOnTrigger"] = closeOnTrigger + } else { + } + // check smpType field -> json key smpType + if p.smpType != nil { + smpType := *p.smpType + + // assign parameter of smpType + params["smpType"] = smpType + } else { + } + // check mmp field -> json key mmp + if p.mmp != nil { + mmp := *p.mmp + + // assign parameter of mmp + params["mmp"] = mmp + } else { + } + // check tpslMode field -> json key tpslMode + if p.tpslMode != nil { + tpslMode := *p.tpslMode + + // assign parameter of tpslMode + params["tpslMode"] = tpslMode + } else { + } + // check tpLimitPrice field -> json key tpLimitPrice + if p.tpLimitPrice != nil { + tpLimitPrice := *p.tpLimitPrice + + // assign parameter of tpLimitPrice + params["tpLimitPrice"] = tpLimitPrice + } else { + } + // check slLimitPrice field -> json key slLimitPrice + if p.slLimitPrice != nil { + slLimitPrice := *p.slLimitPrice + + // assign parameter of slLimitPrice + params["slLimitPrice"] = slLimitPrice + } else { + } + // check tpOrderType field -> json key tpOrderType + if p.tpOrderType != nil { + tpOrderType := *p.tpOrderType + + // assign parameter of tpOrderType + params["tpOrderType"] = tpOrderType + } else { + } + // check slOrderType field -> json key slOrderType + if p.slOrderType != nil { + slOrderType := *p.slOrderType + + // assign parameter of slOrderType + params["slOrderType"] = slOrderType + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (p *PlaceOrderRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := p.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if p.isVarSlice(_v) { + p.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (p *PlaceOrderRequest) GetParametersJSON() ([]byte, error) { + params, err := p.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (p *PlaceOrderRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (p *PlaceOrderRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (p *PlaceOrderRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (p *PlaceOrderRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (p *PlaceOrderRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := p.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (p *PlaceOrderRequest) GetPath() string { + return "/v5/order/create" +} + +// Do generates the request object and send the request object to the API endpoint +func (p *PlaceOrderRequest) Do(ctx context.Context) (*PlaceOrderResponse, error) { + + params, err := p.GetParameters() + if err != nil { + return nil, err + } + query := url.Values{} + + var apiURL string + + apiURL = p.GetPath() + + req, err := p.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := p.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data PlaceOrderResponse + if err := json.Unmarshal(apiResponse.Result, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/bybit/bybitapi/types.go b/pkg/exchange/bybit/bybitapi/types.go new file mode 100644 index 0000000..67905dd --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/types.go @@ -0,0 +1,130 @@ +package bybitapi + +import "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + +var ( + SupportedIntervals = map[types.Interval]int{ + types.Interval1m: 1 * 60, + types.Interval3m: 3 * 60, + types.Interval5m: 5 * 60, + types.Interval15m: 15 * 60, + types.Interval30m: 30 * 60, + types.Interval1h: 60 * 60, + types.Interval2h: 60 * 60 * 2, + types.Interval4h: 60 * 60 * 4, + types.Interval6h: 60 * 60 * 6, + types.Interval12h: 60 * 60 * 12, + types.Interval1d: 60 * 60 * 24, + types.Interval1w: 60 * 60 * 24 * 7, + types.Interval1mo: 60 * 60 * 24 * 30, + } + + ToGlobalInterval = map[string]types.Interval{ + "1": types.Interval1m, + "3": types.Interval3m, + "5": types.Interval5m, + "15": types.Interval15m, + "30": types.Interval30m, + "60": types.Interval1h, + "120": types.Interval2h, + "240": types.Interval4h, + "360": types.Interval6h, + "720": types.Interval12h, + "D": types.Interval1d, + "W": types.Interval1w, + "M": types.Interval1mo, + } +) + +type Category string + +const ( + CategorySpot Category = "spot" +) + +type Status string + +const ( + // StatusTrading is only include the "Trading" status for `spot` category. + StatusTrading Status = "Trading" +) + +type OpenOnly int + +const ( + OpenOnlyOrder OpenOnly = 0 +) + +type Side string + +const ( + SideBuy Side = "Buy" + SideSell Side = "Sell" +) + +type OrderStatus string + +const ( + // OrderStatusCreated order has been accepted by the system but not yet put through the matching engine + OrderStatusCreated OrderStatus = "Created" + // OrderStatusNew is order has been placed successfully. + OrderStatusNew OrderStatus = "New" + OrderStatusRejected OrderStatus = "Rejected" + OrderStatusPartiallyFilled OrderStatus = "PartiallyFilled" + // OrderStatusPartiallyFilledCanceled means that the order has been partially filled but not all then cancel. + OrderStatusPartiallyFilledCanceled OrderStatus = "PartiallyFilledCanceled" + OrderStatusFilled OrderStatus = "Filled" + OrderStatusCancelled OrderStatus = "Cancelled" + + // Following statuses is conditional orders. Once you place conditional orders, it will be in untriggered status. + // Untriggered -> Triggered -> New + // Once the trigger price reached, order status will be moved to triggered + // Singe BBGO not support Untriggered/Triggered, so comment it. + // + // OrderStatusUntriggered means that the order not triggered + //OrderStatusUntriggered OrderStatus = "Untriggered" + //// OrderStatusTriggered means that the order has been triggered + //OrderStatusTriggered OrderStatus = "Triggered" + + // Following statuses is stop orders + // OrderStatusDeactivated is an order status for stopOrders. + //e.g. when you place a conditional order, then you cancel it, this order status is "Deactivated" + OrderStatusDeactivated OrderStatus = "Deactivated" + + // OrderStatusActive order has been triggered and the new active order has been successfully placed. Is the final + // state of a successful conditional order + OrderStatusActive OrderStatus = "Active" +) + +var ( + AllOrderStatuses = []OrderStatus{ + OrderStatusCreated, + OrderStatusNew, + OrderStatusRejected, + OrderStatusPartiallyFilled, + OrderStatusPartiallyFilledCanceled, + OrderStatusFilled, + OrderStatusCancelled, + OrderStatusDeactivated, + OrderStatusActive, + } +) + +type OrderType string + +const ( + OrderTypeMarket OrderType = "Market" + OrderTypeLimit OrderType = "Limit" +) + +type TimeInForce string + +const ( + TimeInForceGTC TimeInForce = "GTC" + TimeInForceIOC TimeInForce = "IOC" + TimeInForceFOK TimeInForce = "FOK" +) + +type AccountType string + +const AccountTypeSpot AccountType = "SPOT" diff --git a/pkg/exchange/bybit/bybitapi/types_test.go b/pkg/exchange/bybit/bybitapi/types_test.go new file mode 100644 index 0000000..862b646 --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/types_test.go @@ -0,0 +1,41 @@ +package bybitapi + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func Test_SupportedIntervals(t *testing.T) { + assert.Equal(t, SupportedIntervals[types.Interval1m], 60) + assert.Equal(t, SupportedIntervals[types.Interval3m], 180) + assert.Equal(t, SupportedIntervals[types.Interval5m], 300) + assert.Equal(t, SupportedIntervals[types.Interval15m], 15*60) + assert.Equal(t, SupportedIntervals[types.Interval30m], 30*60) + assert.Equal(t, SupportedIntervals[types.Interval1h], 60*60) + assert.Equal(t, SupportedIntervals[types.Interval2h], 60*60*2) + assert.Equal(t, SupportedIntervals[types.Interval4h], 60*60*4) + assert.Equal(t, SupportedIntervals[types.Interval6h], 60*60*6) + assert.Equal(t, SupportedIntervals[types.Interval12h], 60*60*12) + assert.Equal(t, SupportedIntervals[types.Interval1d], 60*60*24) + assert.Equal(t, SupportedIntervals[types.Interval1w], 60*60*24*7) + assert.Equal(t, SupportedIntervals[types.Interval1mo], 60*60*24*30) +} + +func Test_ToGlobalInterval(t *testing.T) { + assert.Equal(t, ToGlobalInterval["1"], types.Interval1m) + assert.Equal(t, ToGlobalInterval["3"], types.Interval3m) + assert.Equal(t, ToGlobalInterval["5"], types.Interval5m) + assert.Equal(t, ToGlobalInterval["15"], types.Interval15m) + assert.Equal(t, ToGlobalInterval["30"], types.Interval30m) + assert.Equal(t, ToGlobalInterval["60"], types.Interval1h) + assert.Equal(t, ToGlobalInterval["120"], types.Interval2h) + assert.Equal(t, ToGlobalInterval["240"], types.Interval4h) + assert.Equal(t, ToGlobalInterval["360"], types.Interval6h) + assert.Equal(t, ToGlobalInterval["720"], types.Interval12h) + assert.Equal(t, ToGlobalInterval["D"], types.Interval1d) + assert.Equal(t, ToGlobalInterval["W"], types.Interval1w) + assert.Equal(t, ToGlobalInterval["M"], types.Interval1mo) +} diff --git a/pkg/exchange/bybit/bybitapi/v3/client.go b/pkg/exchange/bybit/bybitapi/v3/client.go new file mode 100644 index 0000000..9148e37 --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/v3/client.go @@ -0,0 +1,17 @@ +package v3 + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bybit/bybitapi" +) + +type APIResponse = bybitapi.APIResponse + +type Client struct { + Client requestgen.AuthenticatedAPIClient +} + +func NewClient(client *bybitapi.RestClient) *Client { + return &Client{Client: client} +} diff --git a/pkg/exchange/bybit/bybitapi/v3/client_test.go b/pkg/exchange/bybit/bybitapi/v3/client_test.go new file mode 100644 index 0000000..ed0da55 --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/v3/client_test.go @@ -0,0 +1,44 @@ +package v3 + +import ( + "context" + "os" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bybit/bybitapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/testutil" +) + +func getTestClientOrSkip(t *testing.T) *bybitapi.RestClient { + if b, _ := strconv.ParseBool(os.Getenv("CI")); b { + t.Skip("skip test for CI") + } + + key, secret, ok := testutil.IntegrationTestConfigured(t, "BYBIT") + if !ok { + t.Skip("BYBIT_* env vars are not configured") + return nil + } + + client, err := bybitapi.NewClient() + assert.NoError(t, err) + client.Auth(key, secret) + return client +} + +func TestClient(t *testing.T) { + client := getTestClientOrSkip(t) + v3Client := Client{Client: client} + ctx := context.Background() + + t.Run("GetTradeRequest", func(t *testing.T) { + startTime := time.Date(2023, 7, 27, 16, 13, 9, 0, time.UTC) + apiResp, err := v3Client.NewGetTradesRequest().Symbol("BTCUSDT").StartTime(startTime).Do(ctx) + assert.NoError(t, err) + t.Logf("apiResp: %+v", apiResp) + }) +} diff --git a/pkg/exchange/bybit/bybitapi/v3/get_trades_request.go b/pkg/exchange/bybit/bybitapi/v3/get_trades_request.go new file mode 100644 index 0000000..8b7c504 --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/v3/get_trades_request.go @@ -0,0 +1,55 @@ +package v3 + +import ( + "time" + + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Result +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Result + +type TradesResponse struct { + List []Trade `json:"list"` +} + +type Trade struct { + Symbol string `json:"symbol"` + Id string `json:"id"` + OrderId string `json:"orderId"` + TradeId string `json:"tradeId"` + OrderPrice fixedpoint.Value `json:"orderPrice"` + OrderQty fixedpoint.Value `json:"orderQty"` + ExecFee fixedpoint.Value `json:"execFee"` + FeeTokenId string `json:"feeTokenId"` + CreatTime types.MillisecondTimestamp `json:"creatTime"` + IsBuyer Side `json:"isBuyer"` + IsMaker OrderType `json:"isMaker"` + MatchOrderId string `json:"matchOrderId"` + MakerRebate fixedpoint.Value `json:"makerRebate"` + ExecutionTime types.MillisecondTimestamp `json:"executionTime"` + BlockTradeId string `json:"blockTradeId"` +} + +//go:generate GetRequest -url "/spot/v3/private/my-trades" -type GetTradesRequest -responseDataType .TradesResponse +type GetTradesRequest struct { + client requestgen.AuthenticatedAPIClient + + symbol *string `param:"symbol,query"` + orderId *string `param:"orderId,query"` + // Limit default value is 50, max 50 + limit *uint64 `param:"limit,query"` + startTime *time.Time `param:"startTime,query,milliseconds"` + endTime *time.Time `param:"endTime,query,milliseconds"` + fromTradeId *string `param:"fromTradeId,query"` + toTradeId *string `param:"toTradeId,query"` +} + +func (c *Client) NewGetTradesRequest() *GetTradesRequest { + return &GetTradesRequest{ + client: c.Client, + } +} diff --git a/pkg/exchange/bybit/bybitapi/v3/get_trades_request_requestgen.go b/pkg/exchange/bybit/bybitapi/v3/get_trades_request_requestgen.go new file mode 100644 index 0000000..63a5725 --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/v3/get_trades_request_requestgen.go @@ -0,0 +1,256 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Result -url /spot/v3/private/my-trades -type GetTradesRequest -responseDataType .TradesResponse"; DO NOT EDIT. + +package v3 + +import ( + "context" + "encoding/json" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bybit/bybitapi" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetTradesRequest) Symbol(symbol string) *GetTradesRequest { + g.symbol = &symbol + return g +} + +func (g *GetTradesRequest) OrderId(orderId string) *GetTradesRequest { + g.orderId = &orderId + return g +} + +func (g *GetTradesRequest) Limit(limit uint64) *GetTradesRequest { + g.limit = &limit + return g +} + +func (g *GetTradesRequest) StartTime(startTime time.Time) *GetTradesRequest { + g.startTime = &startTime + return g +} + +func (g *GetTradesRequest) EndTime(endTime time.Time) *GetTradesRequest { + g.endTime = &endTime + return g +} + +func (g *GetTradesRequest) FromTradeId(fromTradeId string) *GetTradesRequest { + g.fromTradeId = &fromTradeId + return g +} + +func (g *GetTradesRequest) ToTradeId(toTradeId string) *GetTradesRequest { + g.toTradeId = &toTradeId + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetTradesRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + if g.symbol != nil { + symbol := *g.symbol + + // assign parameter of symbol + params["symbol"] = symbol + } else { + } + // check orderId field -> json key orderId + if g.orderId != nil { + orderId := *g.orderId + + // assign parameter of orderId + params["orderId"] = orderId + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + // check startTime field -> json key startTime + if g.startTime != nil { + startTime := *g.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["startTime"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key endTime + if g.endTime != nil { + endTime := *g.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["endTime"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check fromTradeId field -> json key fromTradeId + if g.fromTradeId != nil { + fromTradeId := *g.fromTradeId + + // assign parameter of fromTradeId + params["fromTradeId"] = fromTradeId + } else { + } + // check toTradeId field -> json key toTradeId + if g.toTradeId != nil { + toTradeId := *g.toTradeId + + // assign parameter of toTradeId + params["toTradeId"] = toTradeId + } else { + } + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetTradesRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetTradesRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetTradesRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetTradesRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetTradesRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetTradesRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetTradesRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetTradesRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (g *GetTradesRequest) GetPath() string { + return "/spot/v3/private/my-trades" +} + +// Do generates the request object and send the request object to the API endpoint +func (g *GetTradesRequest) Do(ctx context.Context) (*TradesResponse, error) { + + // no body params + var params interface{} + query, err := g.GetQueryParameters() + if err != nil { + return nil, err + } + + var apiURL string + + apiURL = g.GetPath() + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse bybitapi.APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data TradesResponse + if err := json.Unmarshal(apiResponse.Result, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/bybit/bybitapi/v3/types.go b/pkg/exchange/bybit/bybitapi/v3/types.go new file mode 100644 index 0000000..c3f7a1b --- /dev/null +++ b/pkg/exchange/bybit/bybitapi/v3/types.go @@ -0,0 +1,15 @@ +package v3 + +type Side string + +const ( + SideBuy Side = "0" + SideSell Side = "1" +) + +type OrderType string + +const ( + OrderTypeMaker OrderType = "0" + OrderTypeTaker OrderType = "1" +) diff --git a/pkg/exchange/bybit/convert.go b/pkg/exchange/bybit/convert.go new file mode 100644 index 0000000..7bad673 --- /dev/null +++ b/pkg/exchange/bybit/convert.go @@ -0,0 +1,389 @@ +package bybit + +import ( + "fmt" + "strconv" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bybit/bybitapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bybit/bybitapi/v3" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func toGlobalMarket(m bybitapi.Instrument) types.Market { + return types.Market{ + Symbol: m.Symbol, + LocalSymbol: m.Symbol, + PricePrecision: m.LotSizeFilter.QuotePrecision.NumFractionalDigits(), + VolumePrecision: m.LotSizeFilter.BasePrecision.NumFractionalDigits(), + QuoteCurrency: m.QuoteCoin, + BaseCurrency: m.BaseCoin, + MinNotional: m.LotSizeFilter.MinOrderAmt, + MinAmount: m.LotSizeFilter.MinOrderAmt, + + // quantity + MinQuantity: m.LotSizeFilter.MinOrderQty, + MaxQuantity: m.LotSizeFilter.MaxOrderQty, + StepSize: m.LotSizeFilter.BasePrecision, + + // price + MinPrice: m.LotSizeFilter.MinOrderAmt, + MaxPrice: m.LotSizeFilter.MaxOrderAmt, + TickSize: m.PriceFilter.TickSize, + } +} + +func toGlobalTicker(stats bybitapi.Ticker, time time.Time) types.Ticker { + return types.Ticker{ + Volume: stats.Volume24H, + Last: stats.LastPrice, + Open: stats.PrevPrice24H, // Market price 24 hours ago + High: stats.HighPrice24H, + Low: stats.LowPrice24H, + Buy: stats.Bid1Price, + Sell: stats.Ask1Price, + Time: time, + } +} + +func toGlobalOrder(order bybitapi.Order) (*types.Order, error) { + side, err := toGlobalSideType(order.Side) + if err != nil { + return nil, err + } + + orderType, err := toGlobalOrderType(order.OrderType) + if err != nil { + return nil, err + } + + timeInForce, err := toGlobalTimeInForce(order.TimeInForce) + if err != nil { + return nil, err + } + + status, err := toGlobalOrderStatus(order.OrderStatus, order.Side, order.OrderType) + if err != nil { + return nil, err + } + + // linear and inverse : 42f4f364-82e1-49d3-ad1d-cd8cf9aa308d (UUID format) + // spot : 1468264727470772736 (only numbers) + // Now we only use spot trading. + orderIdNum, err := strconv.ParseUint(order.OrderId, 10, 64) + if err != nil { + return nil, fmt.Errorf("unexpected order id: %s, err: %w", order.OrderId, err) + } + + qty, err := processMarketBuyQuantity(order) + if err != nil { + return nil, err + } + + return &types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: order.OrderLinkId, + Symbol: order.Symbol, + Side: side, + Type: orderType, + Quantity: qty, + Price: order.Price, + TimeInForce: timeInForce, + }, + Exchange: types.ExchangeBybit, + OrderID: orderIdNum, + UUID: order.OrderId, + Status: status, + ExecutedQuantity: order.CumExecQty, + IsWorking: status == types.OrderStatusNew || status == types.OrderStatusPartiallyFilled, + CreationTime: types.Time(order.CreatedTime.Time()), + UpdateTime: types.Time(order.UpdatedTime.Time()), + }, nil +} + +func toGlobalSideType(side bybitapi.Side) (types.SideType, error) { + switch side { + case bybitapi.SideBuy: + return types.SideTypeBuy, nil + + case bybitapi.SideSell: + return types.SideTypeSell, nil + + default: + return types.SideType(side), fmt.Errorf("unexpected side: %s", side) + } +} + +func toGlobalOrderType(s bybitapi.OrderType) (types.OrderType, error) { + switch s { + case bybitapi.OrderTypeMarket: + return types.OrderTypeMarket, nil + + case bybitapi.OrderTypeLimit: + return types.OrderTypeLimit, nil + + default: + return types.OrderType(s), fmt.Errorf("unexpected order type: %s", s) + } +} + +func toGlobalTimeInForce(force bybitapi.TimeInForce) (types.TimeInForce, error) { + switch force { + case bybitapi.TimeInForceGTC: + return types.TimeInForceGTC, nil + + case bybitapi.TimeInForceIOC: + return types.TimeInForceIOC, nil + + case bybitapi.TimeInForceFOK: + return types.TimeInForceFOK, nil + + default: + return types.TimeInForce(force), fmt.Errorf("unexpected timeInForce type: %s", force) + } +} + +func toGlobalOrderStatus(status bybitapi.OrderStatus, side bybitapi.Side, orderType bybitapi.OrderType) (types.OrderStatus, error) { + switch status { + + case bybitapi.OrderStatusPartiallyFilledCanceled: + // market buy order -> PartiallyFilled -> PartiallyFilledCanceled + if orderType == bybitapi.OrderTypeMarket && side == bybitapi.SideBuy { + return types.OrderStatusFilled, nil + } + // limit buy/sell order -> PartiallyFilled -> PartiallyFilledCanceled(Canceled) + return types.OrderStatusCanceled, nil + + default: + return processOtherOrderStatus(status) + } +} + +func processOtherOrderStatus(status bybitapi.OrderStatus) (types.OrderStatus, error) { + switch status { + case bybitapi.OrderStatusCreated, + bybitapi.OrderStatusNew, + bybitapi.OrderStatusActive: + return types.OrderStatusNew, nil + + case bybitapi.OrderStatusFilled: + return types.OrderStatusFilled, nil + + case bybitapi.OrderStatusPartiallyFilled: + return types.OrderStatusPartiallyFilled, nil + + case bybitapi.OrderStatusCancelled, + bybitapi.OrderStatusDeactivated: + return types.OrderStatusCanceled, nil + + case bybitapi.OrderStatusRejected: + return types.OrderStatusRejected, nil + + default: + // following not supported + // bybitapi.OrderStatusUntriggered + // bybitapi.OrderStatusTriggered + return types.OrderStatus(status), fmt.Errorf("unexpected order status: %s", status) + } +} + +// processMarketBuyQuantity converts the quantity unit from quote coin to base coin if the order is a **MARKET BUY**. +// +// If the status is OrderStatusPartiallyFilled, it returns the estimated quantity based on the base coin. +// +// If the order status is OrderStatusPartiallyFilledCanceled, it indicates that the order is not fully filled, +// and the system has automatically canceled it. In this scenario, CumExecQty is considered equal to Qty. +func processMarketBuyQuantity(o bybitapi.Order) (fixedpoint.Value, error) { + if o.Side != bybitapi.SideBuy || o.OrderType != bybitapi.OrderTypeMarket { + return o.Qty, nil + } + + var qty fixedpoint.Value + switch o.OrderStatus { + case bybitapi.OrderStatusPartiallyFilled: + // if CumExecValue is zero, it indicates the caller is from the RESTFUL API. + // we can use AvgPrice to estimate quantity. + if o.CumExecValue.IsZero() { + if o.AvgPrice.IsZero() { + return fixedpoint.Zero, fmt.Errorf("AvgPrice shouldn't be zero") + } + + qty = o.Qty.Div(o.AvgPrice) + } else { + if o.CumExecQty.IsZero() { + return fixedpoint.Zero, fmt.Errorf("CumExecQty shouldn't be zero") + } + + // from web socket event + qty = o.Qty.Div(o.CumExecValue.Div(o.CumExecQty)) + } + + case bybitapi.OrderStatusPartiallyFilledCanceled, + // Considering extreme scenarios, there's a possibility that 'OrderStatusFilled' could occur. + bybitapi.OrderStatusFilled: + qty = o.CumExecQty + + case bybitapi.OrderStatusCreated, + bybitapi.OrderStatusNew, + bybitapi.OrderStatusRejected: + qty = fixedpoint.Zero + + case bybitapi.OrderStatusCancelled: + qty = o.Qty + + default: + return fixedpoint.Zero, fmt.Errorf("unexpected order status: %s", o.OrderStatus) + } + + return qty, nil +} + +func toLocalOrderType(orderType types.OrderType) (bybitapi.OrderType, error) { + switch orderType { + case types.OrderTypeLimit: + return bybitapi.OrderTypeLimit, nil + + case types.OrderTypeMarket: + return bybitapi.OrderTypeMarket, nil + + default: + return "", fmt.Errorf("order type %s not supported", orderType) + } +} + +func toLocalSide(side types.SideType) (bybitapi.Side, error) { + switch side { + case types.SideTypeSell: + return bybitapi.SideSell, nil + + case types.SideTypeBuy: + return bybitapi.SideBuy, nil + + default: + return "", fmt.Errorf("side type %s not supported", side) + } +} + +func toV3Buyer(isBuyer v3.Side) (types.SideType, error) { + switch isBuyer { + case v3.SideBuy: + return types.SideTypeBuy, nil + case v3.SideSell: + return types.SideTypeSell, nil + default: + return "", fmt.Errorf("unexpected side type: %s", isBuyer) + } +} +func toV3Maker(isMaker v3.OrderType) (bool, error) { + switch isMaker { + case v3.OrderTypeMaker: + return true, nil + case v3.OrderTypeTaker: + return false, nil + default: + return false, fmt.Errorf("unexpected order type: %s", isMaker) + } +} + +func v3ToGlobalTrade(trade v3.Trade) (*types.Trade, error) { + side, err := toV3Buyer(trade.IsBuyer) + if err != nil { + return nil, err + } + isMaker, err := toV3Maker(trade.IsMaker) + if err != nil { + return nil, err + } + + orderIdNum, err := strconv.ParseUint(trade.OrderId, 10, 64) + if err != nil { + return nil, fmt.Errorf("unexpected order id: %s, err: %w", trade.OrderId, err) + } + tradeIdNum, err := strconv.ParseUint(trade.TradeId, 10, 64) + if err != nil { + return nil, fmt.Errorf("unexpected trade id: %s, err: %w", trade.TradeId, err) + } + + return &types.Trade{ + ID: tradeIdNum, + OrderID: orderIdNum, + Exchange: types.ExchangeBybit, + Price: trade.OrderPrice, + Quantity: trade.OrderQty, + QuoteQuantity: trade.OrderPrice.Mul(trade.OrderQty), + Symbol: trade.Symbol, + Side: side, + IsBuyer: side == types.SideTypeBuy, + IsMaker: isMaker, + Time: types.Time(trade.ExecutionTime), + Fee: trade.ExecFee, + FeeCurrency: trade.FeeTokenId, + IsMargin: false, + IsFutures: false, + IsIsolated: false, + }, nil +} + +func toGlobalBalanceMap(events []bybitapi.WalletBalances) types.BalanceMap { + bm := types.BalanceMap{} + for _, event := range events { + if event.AccountType != bybitapi.AccountTypeSpot { + continue + } + + for _, obj := range event.Coins { + bm[obj.Coin] = types.Balance{ + Currency: obj.Coin, + Available: obj.Free, + Locked: obj.Locked, + } + } + } + return bm +} + +func toLocalInterval(interval types.Interval) (string, error) { + if _, found := bybitapi.SupportedIntervals[interval]; !found { + return "", fmt.Errorf("interval not supported: %s", interval) + } + + switch interval { + + case types.Interval1d: + return string(bybitapi.IntervalSignDay), nil + + case types.Interval1w: + return string(bybitapi.IntervalSignWeek), nil + + case types.Interval1mo: + return string(bybitapi.IntervalSignMonth), nil + + default: + return fmt.Sprintf("%d", interval.Minutes()), nil + + } +} + +func toGlobalKLines(symbol string, interval types.Interval, klines []bybitapi.KLine) []types.KLine { + gKLines := make([]types.KLine, len(klines)) + for i, kline := range klines { + endTime := types.Time(kline.StartTime.Time().Add(interval.Duration() - time.Millisecond)) + gKLines[i] = types.KLine{ + Exchange: types.ExchangeBybit, + Symbol: symbol, + StartTime: types.Time(kline.StartTime), + EndTime: endTime, + Interval: interval, + Open: kline.Open, + Close: kline.Close, + High: kline.High, + Low: kline.Low, + Volume: kline.Volume, + QuoteVolume: kline.TurnOver, + // Bybit doesn't support close flag in REST API + Closed: false, + } + } + return gKLines +} diff --git a/pkg/exchange/bybit/convert_test.go b/pkg/exchange/bybit/convert_test.go new file mode 100644 index 0000000..edb0d84 --- /dev/null +++ b/pkg/exchange/bybit/convert_test.go @@ -0,0 +1,866 @@ +package bybit + +import ( + "fmt" + "strconv" + "testing" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bybit/bybitapi" + v3 "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bybit/bybitapi/v3" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +func TestToGlobalMarket(t *testing.T) { + // sample: + //{ + // "Symbol": "BTCUSDT", + // "BaseCoin": "BTC", + // "QuoteCoin": "USDT", + // "Innovation": 0, + // "Status": "Trading", + // "MarginTrading": "both", + // "LotSizeFilter": { + // "BasePrecision": 0.000001, + // "QuotePrecision": 0.00000001, + // "MinOrderQty": 0.000048, + // "MaxOrderQty": 71.73956243, + // "MinOrderAmt": 1, + // "MaxOrderAmt": 2000000 + // }, + // "PriceFilter": { + // "TickSize": 0.01 + // } + //} + inst := bybitapi.Instrument{ + Symbol: "BTCUSDT", + BaseCoin: "BTC", + QuoteCoin: "USDT", + Innovation: "0", + Status: bybitapi.StatusTrading, + MarginTrading: "both", + LotSizeFilter: struct { + BasePrecision fixedpoint.Value `json:"basePrecision"` + QuotePrecision fixedpoint.Value `json:"quotePrecision"` + MinOrderQty fixedpoint.Value `json:"minOrderQty"` + MaxOrderQty fixedpoint.Value `json:"maxOrderQty"` + MinOrderAmt fixedpoint.Value `json:"minOrderAmt"` + MaxOrderAmt fixedpoint.Value `json:"maxOrderAmt"` + }{ + BasePrecision: fixedpoint.NewFromFloat(0.000001), + QuotePrecision: fixedpoint.NewFromFloat(0.00000001), + MinOrderQty: fixedpoint.NewFromFloat(0.000048), + MaxOrderQty: fixedpoint.NewFromFloat(71.73956243), + MinOrderAmt: fixedpoint.NewFromInt(1), + MaxOrderAmt: fixedpoint.NewFromInt(2000000), + }, + PriceFilter: struct { + TickSize fixedpoint.Value `json:"tickSize"` + }{ + TickSize: fixedpoint.NewFromFloat(0.01), + }, + } + + exp := types.Market{ + Symbol: inst.Symbol, + LocalSymbol: inst.Symbol, + PricePrecision: 8, + VolumePrecision: 6, + QuoteCurrency: inst.QuoteCoin, + BaseCurrency: inst.BaseCoin, + MinNotional: inst.LotSizeFilter.MinOrderAmt, + MinAmount: inst.LotSizeFilter.MinOrderAmt, + MinQuantity: inst.LotSizeFilter.MinOrderQty, + MaxQuantity: inst.LotSizeFilter.MaxOrderQty, + StepSize: inst.LotSizeFilter.BasePrecision, + MinPrice: inst.LotSizeFilter.MinOrderAmt, + MaxPrice: inst.LotSizeFilter.MaxOrderAmt, + TickSize: inst.PriceFilter.TickSize, + } + + assert.Equal(t, toGlobalMarket(inst), exp) +} + +func TestToGlobalTicker(t *testing.T) { + // sample + //{ + // "symbol": "BTCUSDT", + // "bid1Price": "28995.98", + // "bid1Size": "4.741552", + // "ask1Price": "28995.99", + // "ask1Size": "0.16075", + // "lastPrice": "28994", + // "prevPrice24h": "29900", + // "price24hPcnt": "-0.0303", + // "highPrice24h": "30344.78", + // "lowPrice24h": "28948.87", + // "turnover24h": "184705500.13172874", + // "volume24h": "6240.807096", + // "usdIndexPrice": "28977.82001643" + //} + ticker := bybitapi.Ticker{ + Symbol: "BTCUSDT", + Bid1Price: fixedpoint.NewFromFloat(28995.98), + Bid1Size: fixedpoint.NewFromFloat(4.741552), + Ask1Price: fixedpoint.NewFromFloat(28995.99), + Ask1Size: fixedpoint.NewFromFloat(0.16075), + LastPrice: fixedpoint.NewFromFloat(28994), + PrevPrice24H: fixedpoint.NewFromFloat(29900), + Price24HPcnt: fixedpoint.NewFromFloat(-0.0303), + HighPrice24H: fixedpoint.NewFromFloat(30344.78), + LowPrice24H: fixedpoint.NewFromFloat(28948.87), + Turnover24H: fixedpoint.NewFromFloat(184705500.13172874), + Volume24H: fixedpoint.NewFromFloat(6240.807096), + UsdIndexPrice: fixedpoint.NewFromFloat(28977.82001643), + } + + timeNow := time.Now() + + exp := types.Ticker{ + Time: timeNow, + Volume: ticker.Volume24H, + Last: ticker.LastPrice, + Open: ticker.PrevPrice24H, + High: ticker.HighPrice24H, + Low: ticker.LowPrice24H, + Buy: ticker.Bid1Price, + Sell: ticker.Ask1Price, + } + + assert.Equal(t, toGlobalTicker(ticker, timeNow), exp) +} + +func Test_processMarketBuyQuantity(t *testing.T) { + t.Run("websocket event", func(t *testing.T) { + t.Run("Market/Buy/OrderStatusPartiallyFilled", func(t *testing.T) { + o := bybitapi.Order{ + Qty: fixedpoint.NewFromFloat(5), + OrderType: bybitapi.OrderTypeMarket, + Side: bybitapi.SideBuy, + CumExecValue: fixedpoint.NewFromFloat(200), + CumExecQty: fixedpoint.NewFromFloat(2), + OrderStatus: bybitapi.OrderStatusPartiallyFilled, + } + res, err := processMarketBuyQuantity(o) + assert.NoError(t, err) + assert.Equal(t, o.Qty.Div(o.CumExecValue.Div(o.CumExecQty)), res) + }) + + t.Run("Market/Buy/OrderStatusPartiallyFilledCanceled", func(t *testing.T) { + o := bybitapi.Order{ + Qty: fixedpoint.NewFromFloat(5), + OrderType: bybitapi.OrderTypeMarket, + Side: bybitapi.SideBuy, + CumExecValue: fixedpoint.NewFromFloat(200), + CumExecQty: fixedpoint.NewFromFloat(2), + OrderStatus: bybitapi.OrderStatusPartiallyFilled, + } + res, err := processMarketBuyQuantity(o) + assert.NoError(t, err) + assert.Equal(t, o.Qty.Div(o.CumExecValue.Div(o.CumExecQty)), res) + }) + + t.Run("Market/Buy/OrderStatusFilled", func(t *testing.T) { + o := bybitapi.Order{ + Qty: fixedpoint.NewFromFloat(5), + OrderType: bybitapi.OrderTypeMarket, + Side: bybitapi.SideBuy, + CumExecValue: fixedpoint.NewFromFloat(200), + CumExecQty: fixedpoint.NewFromFloat(2), + OrderStatus: bybitapi.OrderStatusFilled, + } + res, err := processMarketBuyQuantity(o) + assert.NoError(t, err) + assert.Equal(t, o.CumExecQty, res) + }) + + t.Run("Market/Buy/OrderStatusCreated", func(t *testing.T) { + o := bybitapi.Order{ + Qty: fixedpoint.NewFromFloat(5), + OrderType: bybitapi.OrderTypeMarket, + Side: bybitapi.SideBuy, + CumExecValue: fixedpoint.NewFromFloat(200), + CumExecQty: fixedpoint.NewFromFloat(2), + OrderStatus: bybitapi.OrderStatusCreated, + } + res, err := processMarketBuyQuantity(o) + assert.NoError(t, err) + assert.Equal(t, fixedpoint.Zero, res) + }) + + t.Run("Market/Buy/OrderStatusNew", func(t *testing.T) { + o := bybitapi.Order{ + Qty: fixedpoint.NewFromFloat(5), + OrderType: bybitapi.OrderTypeMarket, + Side: bybitapi.SideBuy, + CumExecValue: fixedpoint.NewFromFloat(200), + CumExecQty: fixedpoint.NewFromFloat(2), + OrderStatus: bybitapi.OrderStatusNew, + } + res, err := processMarketBuyQuantity(o) + assert.NoError(t, err) + assert.Equal(t, fixedpoint.Zero, res) + }) + + t.Run("Market/Buy/OrderStatusRejected", func(t *testing.T) { + o := bybitapi.Order{ + Qty: fixedpoint.NewFromFloat(5), + OrderType: bybitapi.OrderTypeMarket, + Side: bybitapi.SideBuy, + CumExecValue: fixedpoint.NewFromFloat(200), + CumExecQty: fixedpoint.NewFromFloat(2), + OrderStatus: bybitapi.OrderStatusRejected, + } + res, err := processMarketBuyQuantity(o) + assert.NoError(t, err) + assert.Equal(t, fixedpoint.Zero, res) + }) + + t.Run("Market/Buy/OrderStatusCanceled", func(t *testing.T) { + o := bybitapi.Order{ + Qty: fixedpoint.NewFromFloat(5), + OrderType: bybitapi.OrderTypeMarket, + Side: bybitapi.SideBuy, + CumExecValue: fixedpoint.NewFromFloat(200), + CumExecQty: fixedpoint.NewFromFloat(2), + OrderStatus: bybitapi.OrderStatusCancelled, + } + res, err := processMarketBuyQuantity(o) + assert.NoError(t, err) + assert.Equal(t, o.Qty, res) + }) + + t.Run("Market/Buy/Unexpected status", func(t *testing.T) { + o := bybitapi.Order{ + Qty: fixedpoint.NewFromFloat(5), + OrderType: bybitapi.OrderTypeMarket, + Side: bybitapi.SideBuy, + CumExecValue: fixedpoint.NewFromFloat(200), + CumExecQty: fixedpoint.NewFromFloat(2), + OrderStatus: bybitapi.OrderStatus("unexpected"), + } + res, err := processMarketBuyQuantity(o) + assert.Error(t, err) + assert.Equal(t, fmt.Errorf("unexpected order status: %s", o.OrderStatus), err) + assert.Equal(t, fixedpoint.Zero, res) + }) + + t.Run("Market/Buy/CumExecQty zero", func(t *testing.T) { + o := bybitapi.Order{ + Qty: fixedpoint.NewFromFloat(5), + OrderType: bybitapi.OrderTypeMarket, + Side: bybitapi.SideBuy, + CumExecValue: fixedpoint.NewFromFloat(200), + CumExecQty: fixedpoint.Zero, + OrderStatus: bybitapi.OrderStatusPartiallyFilled, + } + res, err := processMarketBuyQuantity(o) + assert.Error(t, err) + assert.Equal(t, fmt.Errorf("CumExecQty shouldn't be zero"), err) + assert.Equal(t, fixedpoint.Zero, res) + }) + + t.Run("Market/Sell", func(t *testing.T) { + o := bybitapi.Order{ + Qty: fixedpoint.NewFromFloat(5.55), + OrderType: bybitapi.OrderTypeMarket, + Side: bybitapi.SideSell, + } + res, err := processMarketBuyQuantity(o) + assert.NoError(t, err) + assert.Equal(t, o.Qty, res) + }) + + t.Run("Limit/Buy", func(t *testing.T) { + o := bybitapi.Order{ + Qty: fixedpoint.NewFromFloat(5.55), + OrderType: bybitapi.OrderTypeLimit, + Side: bybitapi.SideBuy, + } + res, err := processMarketBuyQuantity(o) + assert.NoError(t, err) + assert.Equal(t, o.Qty, res) + }) + + t.Run("Limit/Sell", func(t *testing.T) { + o := bybitapi.Order{ + Qty: fixedpoint.NewFromFloat(5.55), + OrderType: bybitapi.OrderTypeLimit, + Side: bybitapi.SideSell, + } + res, err := processMarketBuyQuantity(o) + assert.NoError(t, err) + assert.Equal(t, o.Qty, res) + }) + }) + + t.Run("Restful API", func(t *testing.T) { + t.Run("Market/Buy/OrderStatusPartiallyFilled", func(t *testing.T) { + o := bybitapi.Order{ + Qty: fixedpoint.NewFromFloat(200), + OrderType: bybitapi.OrderTypeMarket, + Side: bybitapi.SideBuy, + AvgPrice: fixedpoint.NewFromFloat(25000), + OrderStatus: bybitapi.OrderStatusPartiallyFilled, + } + res, err := processMarketBuyQuantity(o) + assert.NoError(t, err) + assert.Equal(t, o.Qty.Div(o.AvgPrice), res) + }) + + t.Run("Market/Buy/OrderStatusPartiallyFilledCanceled", func(t *testing.T) { + o := bybitapi.Order{ + Qty: fixedpoint.NewFromFloat(200), + OrderType: bybitapi.OrderTypeMarket, + Side: bybitapi.SideBuy, + AvgPrice: fixedpoint.NewFromFloat(25000), + OrderStatus: bybitapi.OrderStatusPartiallyFilledCanceled, + CumExecQty: fixedpoint.NewFromFloat(0.002), + } + res, err := processMarketBuyQuantity(o) + assert.NoError(t, err) + assert.Equal(t, o.CumExecQty, res) + }) + + t.Run("Market/Buy/OrderStatusFilled", func(t *testing.T) { + o := bybitapi.Order{ + Qty: fixedpoint.NewFromFloat(200), + OrderType: bybitapi.OrderTypeMarket, + Side: bybitapi.SideBuy, + AvgPrice: fixedpoint.NewFromFloat(25000), + OrderStatus: bybitapi.OrderStatusFilled, + CumExecQty: fixedpoint.NewFromFloat(0.002), + } + res, err := processMarketBuyQuantity(o) + assert.NoError(t, err) + assert.Equal(t, o.CumExecQty, res) + }) + + t.Run("Market/Buy/OrderStatusCreated", func(t *testing.T) { + o := bybitapi.Order{ + Qty: fixedpoint.NewFromFloat(200), + OrderType: bybitapi.OrderTypeMarket, + Side: bybitapi.SideBuy, + AvgPrice: fixedpoint.NewFromFloat(25000), + OrderStatus: bybitapi.OrderStatusCreated, + CumExecQty: fixedpoint.NewFromFloat(0.002), + } + res, err := processMarketBuyQuantity(o) + assert.NoError(t, err) + assert.Equal(t, fixedpoint.Zero, res) + }) + + t.Run("Market/Buy/OrderStatusNew", func(t *testing.T) { + o := bybitapi.Order{ + Qty: fixedpoint.NewFromFloat(200), + OrderType: bybitapi.OrderTypeMarket, + Side: bybitapi.SideBuy, + AvgPrice: fixedpoint.NewFromFloat(25000), + OrderStatus: bybitapi.OrderStatusNew, + CumExecQty: fixedpoint.NewFromFloat(0.002), + } + res, err := processMarketBuyQuantity(o) + assert.NoError(t, err) + assert.Equal(t, fixedpoint.Zero, res) + }) + + t.Run("Market/Buy/OrderStatusRejected", func(t *testing.T) { + o := bybitapi.Order{ + Qty: fixedpoint.NewFromFloat(200), + OrderType: bybitapi.OrderTypeMarket, + Side: bybitapi.SideBuy, + AvgPrice: fixedpoint.NewFromFloat(25000), + OrderStatus: bybitapi.OrderStatusRejected, + CumExecQty: fixedpoint.NewFromFloat(0.002), + } + res, err := processMarketBuyQuantity(o) + assert.NoError(t, err) + assert.Equal(t, fixedpoint.Zero, res) + }) + + t.Run("Market/Buy/OrderStatusCanceled", func(t *testing.T) { + o := bybitapi.Order{ + Qty: fixedpoint.NewFromFloat(200), + OrderType: bybitapi.OrderTypeMarket, + Side: bybitapi.SideBuy, + AvgPrice: fixedpoint.NewFromFloat(25000), + OrderStatus: bybitapi.OrderStatusCancelled, + CumExecQty: fixedpoint.NewFromFloat(0.002), + } + res, err := processMarketBuyQuantity(o) + assert.NoError(t, err) + assert.Equal(t, o.Qty, res) + }) + + t.Run("Market/Buy/AvgPrice zero", func(t *testing.T) { + o := bybitapi.Order{ + Qty: fixedpoint.NewFromFloat(200), + OrderType: bybitapi.OrderTypeMarket, + Side: bybitapi.SideBuy, + AvgPrice: fixedpoint.Zero, + OrderStatus: bybitapi.OrderStatusPartiallyFilled, + CumExecQty: fixedpoint.NewFromFloat(0.002), + } + res, err := processMarketBuyQuantity(o) + assert.Error(t, err) + assert.Equal(t, fmt.Errorf("AvgPrice shouldn't be zero"), err) + assert.Equal(t, fixedpoint.Zero, res) + }) + + t.Run("Market/Sell", func(t *testing.T) { + o := bybitapi.Order{ + Qty: fixedpoint.NewFromFloat(5.55), + OrderType: bybitapi.OrderTypeMarket, + Side: bybitapi.SideSell, + } + res, err := processMarketBuyQuantity(o) + assert.NoError(t, err) + assert.Equal(t, o.Qty, res) + }) + + t.Run("Limit/Buy", func(t *testing.T) { + o := bybitapi.Order{ + Qty: fixedpoint.NewFromFloat(5.55), + OrderType: bybitapi.OrderTypeLimit, + Side: bybitapi.SideBuy, + } + res, err := processMarketBuyQuantity(o) + assert.NoError(t, err) + assert.Equal(t, o.Qty, res) + }) + + t.Run("Limit/Sell", func(t *testing.T) { + o := bybitapi.Order{ + Qty: fixedpoint.NewFromFloat(5.55), + OrderType: bybitapi.OrderTypeLimit, + Side: bybitapi.SideSell, + } + res, err := processMarketBuyQuantity(o) + assert.NoError(t, err) + assert.Equal(t, o.Qty, res) + }) + }) +} + +func TestToGlobalOrder(t *testing.T) { + // sample: partialFilled + //{ + // "OrderId": 1472539279335923200, + // "OrderLinkId": 1690276361150, + // "BlockTradeId": null, + // "Symbol": "DOTUSDT", + // "Price": 7.278, + // "Qty": 0.8, + // "Side": "Sell", + // "IsLeverage": 0, + // "PositionIdx": 0, + // "OrderStatus": "PartiallyFilled", + // "CancelType": "UNKNOWN", + // "RejectReason": null, + // "AvgPrice": 7.278, + // "LeavesQty": 0, + // "LeavesValue": 0, + // "CumExecQty": 0.5, + // "CumExecValue": 0, + // "CumExecFee": 0, + // "TimeInForce": "GTC", + // "OrderType": "Limit", + // "StopOrderType": null, + // "OrderIv": null, + // "TriggerPrice": 0, + // "TakeProfit": 0, + // "StopLoss": 0, + // "TpTriggerBy": null, + // "SlTriggerBy": null, + // "TriggerDirection": 0, + // "TriggerBy": null, + // "LastPriceOnCreated": null, + // "ReduceOnly": false, + // "CloseOnTrigger": false, + // "SmpType": "None", + // "SmpGroup": 0, + // "SmpOrderId": null, + // "TpslMode": null, + // "TpLimitPrice": null, + // "SlLimitPrice": null, + // "PlaceType": null, + // "CreatedTime": "2023-07-25 17:12:41.325 +0800 CST", + // "UpdatedTime": "2023-07-25 17:12:57.868 +0800 CST" + //} + timeNow := time.Now() + openOrder := bybitapi.Order{ + OrderId: "1472539279335923200", + OrderLinkId: "1690276361150", + BlockTradeId: "", + Symbol: "DOTUSDT", + Price: fixedpoint.NewFromFloat(7.278), + Qty: fixedpoint.NewFromFloat(0.8), + Side: bybitapi.SideSell, + IsLeverage: "0", + PositionIdx: 0, + OrderStatus: bybitapi.OrderStatusPartiallyFilled, + CancelType: "UNKNOWN", + RejectReason: "", + AvgPrice: fixedpoint.NewFromFloat(7.728), + LeavesQty: fixedpoint.NewFromFloat(0), + LeavesValue: fixedpoint.NewFromFloat(0), + CumExecQty: fixedpoint.NewFromFloat(0.5), + CumExecValue: fixedpoint.NewFromFloat(0), + CumExecFee: fixedpoint.NewFromFloat(0), + TimeInForce: "GTC", + OrderType: bybitapi.OrderTypeLimit, + StopOrderType: "", + OrderIv: "", + TriggerPrice: fixedpoint.NewFromFloat(0), + TakeProfit: fixedpoint.NewFromFloat(0), + StopLoss: fixedpoint.NewFromFloat(0), + TpTriggerBy: "", + SlTriggerBy: "", + TriggerDirection: 0, + TriggerBy: "", + LastPriceOnCreated: "", + ReduceOnly: false, + CloseOnTrigger: false, + SmpType: "None", + SmpGroup: 0, + SmpOrderId: "", + TpslMode: "", + TpLimitPrice: "", + SlLimitPrice: "", + PlaceType: "", + CreatedTime: types.MillisecondTimestamp(timeNow), + UpdatedTime: types.MillisecondTimestamp(timeNow), + } + side, err := toGlobalSideType(openOrder.Side) + assert.NoError(t, err) + orderType, err := toGlobalOrderType(openOrder.OrderType) + assert.NoError(t, err) + tif, err := toGlobalTimeInForce(openOrder.TimeInForce) + assert.NoError(t, err) + status, err := toGlobalOrderStatus(openOrder.OrderStatus, openOrder.Side, openOrder.OrderType) + assert.NoError(t, err) + orderIdNum, err := strconv.ParseUint(openOrder.OrderId, 10, 64) + assert.NoError(t, err) + + exp := types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: openOrder.OrderLinkId, + Symbol: openOrder.Symbol, + Side: side, + Type: orderType, + Quantity: openOrder.Qty, + Price: openOrder.Price, + TimeInForce: tif, + }, + Exchange: types.ExchangeBybit, + OrderID: orderIdNum, + UUID: openOrder.OrderId, + Status: status, + ExecutedQuantity: openOrder.CumExecQty, + IsWorking: status == types.OrderStatusNew || status == types.OrderStatusPartiallyFilled, + CreationTime: types.Time(openOrder.CreatedTime), + UpdateTime: types.Time(openOrder.UpdatedTime), + IsFutures: false, + IsMargin: false, + IsIsolated: false, + } + res, err := toGlobalOrder(openOrder) + assert.NoError(t, err) + assert.Equal(t, res, &exp) +} + +func TestToGlobalSideType(t *testing.T) { + res, err := toGlobalSideType(bybitapi.SideBuy) + assert.NoError(t, err) + assert.Equal(t, types.SideTypeBuy, res) + + res, err = toGlobalSideType(bybitapi.SideSell) + assert.NoError(t, err) + assert.Equal(t, types.SideTypeSell, res) + + res, err = toGlobalSideType("GG") + assert.Error(t, err) +} + +func TestToGlobalOrderType(t *testing.T) { + res, err := toGlobalOrderType(bybitapi.OrderTypeMarket) + assert.NoError(t, err) + assert.Equal(t, types.OrderTypeMarket, res) + + res, err = toGlobalOrderType(bybitapi.OrderTypeLimit) + assert.NoError(t, err) + assert.Equal(t, types.OrderTypeLimit, res) + + res, err = toGlobalOrderType("GG") + assert.Error(t, err) +} + +func TestToGlobalTimeInForce(t *testing.T) { + res, err := toGlobalTimeInForce(bybitapi.TimeInForceGTC) + assert.NoError(t, err) + assert.Equal(t, types.TimeInForceGTC, res) + + res, err = toGlobalTimeInForce(bybitapi.TimeInForceIOC) + assert.NoError(t, err) + assert.Equal(t, types.TimeInForceIOC, res) + + res, err = toGlobalTimeInForce(bybitapi.TimeInForceFOK) + assert.NoError(t, err) + assert.Equal(t, types.TimeInForceFOK, res) + + res, err = toGlobalTimeInForce("GG") + assert.Error(t, err) +} + +func Test_toGlobalOrderStatus(t *testing.T) { + t.Run("market/buy", func(t *testing.T) { + res, err := toGlobalOrderStatus(bybitapi.OrderStatusPartiallyFilledCanceled, bybitapi.SideBuy, bybitapi.OrderTypeMarket) + assert.NoError(t, err) + assert.Equal(t, types.OrderStatusFilled, res) + }) + + t.Run("limit/buy", func(t *testing.T) { + res, err := toGlobalOrderStatus(bybitapi.OrderStatusPartiallyFilledCanceled, bybitapi.SideBuy, bybitapi.OrderTypeLimit) + assert.NoError(t, err) + assert.Equal(t, types.OrderStatusCanceled, res) + }) + + t.Run("limit/sell", func(t *testing.T) { + res, err := toGlobalOrderStatus(bybitapi.OrderStatusPartiallyFilledCanceled, bybitapi.SideSell, bybitapi.OrderTypeLimit) + assert.NoError(t, err) + assert.Equal(t, types.OrderStatusCanceled, res) + }) +} + +func Test_processOtherOrderStatus(t *testing.T) { + t.Run("New", func(t *testing.T) { + res, err := processOtherOrderStatus(bybitapi.OrderStatusNew) + assert.NoError(t, err) + assert.Equal(t, types.OrderStatusNew, res) + + res, err = processOtherOrderStatus(bybitapi.OrderStatusActive) + assert.NoError(t, err) + assert.Equal(t, types.OrderStatusNew, res) + }) + + t.Run("Filled", func(t *testing.T) { + res, err := processOtherOrderStatus(bybitapi.OrderStatusFilled) + assert.NoError(t, err) + assert.Equal(t, types.OrderStatusFilled, res) + }) + + t.Run("PartiallyFilled", func(t *testing.T) { + res, err := processOtherOrderStatus(bybitapi.OrderStatusPartiallyFilled) + assert.NoError(t, err) + assert.Equal(t, types.OrderStatusPartiallyFilled, res) + }) + + t.Run("OrderStatusCanceled", func(t *testing.T) { + res, err := processOtherOrderStatus(bybitapi.OrderStatusCancelled) + assert.NoError(t, err) + assert.Equal(t, types.OrderStatusCanceled, res) + + res, err = processOtherOrderStatus(bybitapi.OrderStatusDeactivated) + assert.NoError(t, err) + assert.Equal(t, types.OrderStatusCanceled, res) + }) + + t.Run("OrderStatusRejected", func(t *testing.T) { + res, err := processOtherOrderStatus(bybitapi.OrderStatusRejected) + assert.NoError(t, err) + assert.Equal(t, types.OrderStatusRejected, res) + }) + + t.Run("OrderStatusPartiallyFilledCanceled", func(t *testing.T) { + res, err := processOtherOrderStatus(bybitapi.OrderStatusPartiallyFilledCanceled) + assert.Equal(t, types.OrderStatus(bybitapi.OrderStatusPartiallyFilledCanceled), res) + assert.Error(t, err) + assert.Equal(t, fmt.Errorf("unexpected order status: %s", bybitapi.OrderStatusPartiallyFilledCanceled), err) + }) +} + +func Test_toLocalOrderType(t *testing.T) { + orderType, err := toLocalOrderType(types.OrderTypeLimit) + assert.NoError(t, err) + assert.Equal(t, bybitapi.OrderTypeLimit, orderType) + + orderType, err = toLocalOrderType(types.OrderTypeMarket) + assert.NoError(t, err) + assert.Equal(t, bybitapi.OrderTypeMarket, orderType) + + orderType, err = toLocalOrderType("wrong type") + assert.Equal(t, fmt.Errorf("order type wrong type not supported"), err) + assert.Equal(t, bybitapi.OrderType(""), orderType) +} + +func Test_toLocalSide(t *testing.T) { + side, err := toLocalSide(types.SideTypeSell) + assert.NoError(t, err) + assert.Equal(t, bybitapi.SideSell, side) + + side, err = toLocalSide(types.SideTypeBuy) + assert.NoError(t, err) + assert.Equal(t, bybitapi.SideBuy, side) + + side, err = toLocalSide("wrong side") + assert.Equal(t, fmt.Errorf("side type %s not supported", "wrong side"), err) + assert.Equal(t, bybitapi.Side(""), side) +} + +func Test_toGlobalTrade(t *testing.T) { + /* sample: trade + { + "Symbol":"BTCUSDT", + "Id":"1474200510090276864", + "OrderId":"1474200270671015936", + "TradeId":"2100000000031181772", + "OrderPrice":"27628", + "OrderQty":"0.007959", + "ExecFee":"0.21989125", + "FeeTokenId":"USDT", + "CreatTime":"2023-07-28 00:13:15.457 +0800 CST", + "IsBuyer":"1", + "IsMaker":"0", + "MatchOrderId":"5760912963729109504", + "MakerRebate":"0", + "ExecutionTime":"2023-07-28 00:13:15.463 +0800 CST", + "BlockTradeId": "", + } + */ + timeNow := time.Now() + trade := v3.Trade{ + Symbol: "DOTUSDT", + Id: "1474200510090276864", + OrderId: "1474200270671015936", + TradeId: "2100000000031181772", + OrderPrice: fixedpoint.NewFromFloat(27628), + OrderQty: fixedpoint.NewFromFloat(0.007959), + ExecFee: fixedpoint.NewFromFloat(0.21989125), + FeeTokenId: "USDT", + CreatTime: types.MillisecondTimestamp(timeNow), + IsBuyer: "0", + IsMaker: "0", + MatchOrderId: "5760912963729109504", + MakerRebate: fixedpoint.NewFromFloat(0), + ExecutionTime: types.MillisecondTimestamp(timeNow), + BlockTradeId: "", + } + + s, err := toV3Buyer(trade.IsBuyer) + assert.NoError(t, err) + m, err := toV3Maker(trade.IsMaker) + assert.NoError(t, err) + orderIdNum, err := strconv.ParseUint(trade.OrderId, 10, 64) + assert.NoError(t, err) + tradeId, err := strconv.ParseUint(trade.TradeId, 10, 64) + assert.NoError(t, err) + + exp := types.Trade{ + ID: tradeId, + OrderID: orderIdNum, + Exchange: types.ExchangeBybit, + Price: trade.OrderPrice, + Quantity: trade.OrderQty, + QuoteQuantity: trade.OrderPrice.Mul(trade.OrderQty), + Symbol: trade.Symbol, + Side: s, + IsBuyer: s == types.SideTypeBuy, + IsMaker: m, + Time: types.Time(timeNow), + Fee: trade.ExecFee, + FeeCurrency: trade.FeeTokenId, + IsMargin: false, + IsFutures: false, + IsIsolated: false, + } + res, err := v3ToGlobalTrade(trade) + assert.NoError(t, err) + assert.Equal(t, res, &exp) +} + +func Test_toGlobalKLines(t *testing.T) { + symbol := "BTCUSDT" + interval := types.Interval15m + + resp := bybitapi.KLinesResponse{ + Symbol: symbol, + List: []bybitapi.KLine{ + /* + [ + { + "StartTime": "2023-08-08 17:30:00 +0800 CST", + "OpenPrice": 29045.3, + "HighPrice": 29228.56, + "LowPrice": 29045.3, + "ClosePrice": 29228.56, + "Volume": 9.265593, + "TurnOver": 270447.43520753 + }, + { + "StartTime": "2023-08-08 17:15:00 +0800 CST", + "OpenPrice": 29167.33, + "HighPrice": 29229.08, + "LowPrice": 29000, + "ClosePrice": 29045.3, + "Volume": 9.295508, + "TurnOver": 270816.87513775 + } + ] + */ + { + StartTime: types.NewMillisecondTimestampFromInt(1691486100000), + Open: fixedpoint.NewFromFloat(29045.3), + High: fixedpoint.NewFromFloat(29228.56), + Low: fixedpoint.NewFromFloat(29045.3), + Close: fixedpoint.NewFromFloat(29228.56), + Volume: fixedpoint.NewFromFloat(9.265593), + TurnOver: fixedpoint.NewFromFloat(270447.43520753), + }, + { + StartTime: types.NewMillisecondTimestampFromInt(1691487000000), + Open: fixedpoint.NewFromFloat(29167.33), + High: fixedpoint.NewFromFloat(29229.08), + Low: fixedpoint.NewFromFloat(29000), + Close: fixedpoint.NewFromFloat(29045.3), + Volume: fixedpoint.NewFromFloat(9.295508), + TurnOver: fixedpoint.NewFromFloat(270816.87513775), + }, + }, + Category: bybitapi.CategorySpot, + } + + expKlines := []types.KLine{ + { + Exchange: types.ExchangeBybit, + Symbol: resp.Symbol, + StartTime: types.Time(resp.List[0].StartTime.Time()), + EndTime: types.Time(resp.List[0].StartTime.Time().Add(interval.Duration() - time.Millisecond)), + Interval: interval, + Open: fixedpoint.NewFromFloat(29045.3), + Close: fixedpoint.NewFromFloat(29228.56), + High: fixedpoint.NewFromFloat(29228.56), + Low: fixedpoint.NewFromFloat(29045.3), + Volume: fixedpoint.NewFromFloat(9.265593), + QuoteVolume: fixedpoint.NewFromFloat(270447.43520753), + Closed: false, + }, + { + Exchange: types.ExchangeBybit, + Symbol: resp.Symbol, + StartTime: types.Time(resp.List[1].StartTime.Time()), + EndTime: types.Time(resp.List[1].StartTime.Time().Add(interval.Duration() - time.Millisecond)), + Interval: interval, + Open: fixedpoint.NewFromFloat(29167.33), + Close: fixedpoint.NewFromFloat(29045.3), + High: fixedpoint.NewFromFloat(29229.08), + Low: fixedpoint.NewFromFloat(29000), + Volume: fixedpoint.NewFromFloat(9.295508), + QuoteVolume: fixedpoint.NewFromFloat(270816.87513775), + Closed: false, + }, + } + + assert.Equal(t, toGlobalKLines(symbol, interval, resp.List), expKlines) +} diff --git a/pkg/exchange/bybit/exchange.go b/pkg/exchange/bybit/exchange.go new file mode 100644 index 0000000..465bddc --- /dev/null +++ b/pkg/exchange/bybit/exchange.go @@ -0,0 +1,604 @@ +package bybit + +import ( + "context" + "errors" + "fmt" + "strconv" + "time" + + "github.com/sirupsen/logrus" + "go.uber.org/multierr" + "golang.org/x/time/rate" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bybit/bybitapi" + v3 "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bybit/bybitapi/v3" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ( + maxOrderIdLen = 36 + defaultQueryLimit = 50 + defaultKLineLimit = 1000 + + halfYearDuration = 6 * 30 * 24 * time.Hour +) + +// https://bybit-exchange.github.io/docs/zh-TW/v5/rate-limit +// GET/POST method (shared): 120 requests per second for 5 consecutive seconds +var ( + // sharedRateLimiter indicates that the API belongs to the public API. + // The default order limiter apply 5 requests per second and a 5 initial bucket + // this includes QueryMarkets, QueryTicker, QueryAccountBalances, GetFeeRates + sharedRateLimiter = rate.NewLimiter(rate.Every(time.Second/5), 5) + queryOrderTradeRateLimiter = rate.NewLimiter(rate.Every(time.Second/5), 5) + orderRateLimiter = rate.NewLimiter(rate.Every(time.Second/10), 10) + closedOrderQueryLimiter = rate.NewLimiter(rate.Every(time.Second), 1) + + log = logrus.WithFields(logrus.Fields{ + "exchange": "bybit", + }) + + _ types.ExchangeAccountService = &Exchange{} + _ types.ExchangeMarketDataService = &Exchange{} + _ types.CustomIntervalProvider = &Exchange{} + _ types.ExchangeMinimal = &Exchange{} + _ types.ExchangeTradeService = &Exchange{} + _ types.Exchange = &Exchange{} + _ types.ExchangeOrderQueryService = &Exchange{} +) + +type Exchange struct { + key, secret string + client *bybitapi.RestClient + v3client *v3.Client +} + +func New(key, secret string) (*Exchange, error) { + client, err := bybitapi.NewClient() + if err != nil { + return nil, err + } + + if len(key) > 0 && len(secret) > 0 { + client.Auth(key, secret) + } + + return &Exchange{ + key: key, + // pragma: allowlist nextline secret + secret: secret, + client: client, + v3client: v3.NewClient(client), + }, nil +} + +func (e *Exchange) Name() types.ExchangeName { + return types.ExchangeBybit +} + +// PlatformFeeCurrency returns empty string. The platform does not support "PlatformFeeCurrency" but instead charges +// fees using the native token. +func (e *Exchange) PlatformFeeCurrency() string { + return "" +} + +func (e *Exchange) QueryMarkets(ctx context.Context) (types.MarketMap, error) { + if err := sharedRateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("markets rate limiter wait error: %w", err) + } + + instruments, err := e.client.NewGetInstrumentsInfoRequest().Do(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get instruments, err: %v", err) + } + + marketMap := types.MarketMap{} + for _, s := range instruments.List { + marketMap.Add(toGlobalMarket(s)) + } + + return marketMap, nil +} + +func (e *Exchange) QueryTicker(ctx context.Context, symbol string) (*types.Ticker, error) { + if err := sharedRateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("ticker order rate limiter wait error: %w", err) + } + + s, err := e.client.NewGetTickersRequest().Symbol(symbol).DoWithResponseTime(ctx) + if err != nil { + return nil, fmt.Errorf("failed to call ticker, symbol: %s, err: %w", symbol, err) + } + + if len(s.List) != 1 { + return nil, fmt.Errorf("unexpected ticker length, exp:1, got:%d", len(s.List)) + } + + ticker := toGlobalTicker(s.List[0], s.ClosedTime.Time()) + return &ticker, nil +} + +func (e *Exchange) QueryTickers(ctx context.Context, symbols ...string) (map[string]types.Ticker, error) { + tickers := map[string]types.Ticker{} + if len(symbols) > 0 { + for _, s := range symbols { + t, err := e.QueryTicker(ctx, s) + if err != nil { + return nil, err + } + + tickers[s] = *t + } + + return tickers, nil + } + + if err := sharedRateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("tickers rate limiter wait error: %w", err) + } + allTickers, err := e.client.NewGetTickersRequest().DoWithResponseTime(ctx) + if err != nil { + return nil, fmt.Errorf("failed to call ticker, err: %w", err) + } + + for _, s := range allTickers.List { + tickers[s.Symbol] = toGlobalTicker(s, allTickers.ClosedTime.Time()) + } + + return tickers, nil +} + +func (e *Exchange) QueryOpenOrders(ctx context.Context, symbol string) (orders []types.Order, err error) { + cursor := "" + for { + req := e.client.NewGetOpenOrderRequest().Symbol(symbol) + if len(cursor) != 0 { + // the default limit is 20. + req = req.Cursor(cursor) + } + + if err = queryOrderTradeRateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("place order rate limiter wait error: %w", err) + } + res, err := req.Do(ctx) + if err != nil { + return nil, fmt.Errorf("failed to query open orders, err: %w", err) + } + + for _, order := range res.List { + order, err := toGlobalOrder(order) + if err != nil { + return nil, fmt.Errorf("failed to convert order, err: %v", err) + } + + orders = append(orders, *order) + } + + if len(res.NextPageCursor) == 0 { + break + } + cursor = res.NextPageCursor + } + + return orders, nil +} + +func (e *Exchange) QueryOrder(ctx context.Context, q types.OrderQuery) (*types.Order, error) { + if len(q.OrderID) == 0 && len(q.ClientOrderID) == 0 { + return nil, errors.New("one of OrderID/ClientOrderID is required parameter") + } + + if len(q.OrderID) != 0 && len(q.ClientOrderID) != 0 { + return nil, errors.New("only accept one parameter of OrderID/ClientOrderID") + } + + req := e.client.NewGetOrderHistoriesRequest() + if len(q.Symbol) != 0 { + req.Symbol(q.Symbol) + } + + if len(q.OrderID) != 0 { + req.OrderId(q.OrderID) + } + + if len(q.ClientOrderID) != 0 { + req.OrderLinkId(q.ClientOrderID) + } + + res, err := req.Do(ctx) + if err != nil { + return nil, fmt.Errorf("failed to query order, queryConfig: %+v, err: %w", q, err) + } + if len(res.List) != 1 { + return nil, fmt.Errorf("unexpected order length, queryConfig: %+v", q) + } + + return toGlobalOrder(res.List[0]) +} + +func (e *Exchange) QueryOrderTrades(ctx context.Context, q types.OrderQuery) (trades []types.Trade, err error) { + if len(q.ClientOrderID) != 0 { + log.Warn("!!!BYBIT EXCHANGE API NOTICE!!! Bybit does not support searching for trades using OrderClientId.") + } + + if len(q.OrderID) == 0 { + return nil, errors.New("orderID is required parameter") + } + req := e.v3client.NewGetTradesRequest().OrderId(q.OrderID) + + if len(q.Symbol) != 0 { + req.Symbol(q.Symbol) + } + + if err := queryOrderTradeRateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("trade rate limiter wait error: %w", err) + } + response, err := req.Do(ctx) + if err != nil { + return nil, fmt.Errorf("failed to query order trades, err: %w", err) + } + + var errs error + for _, trade := range response.List { + res, err := v3ToGlobalTrade(trade) + if err != nil { + errs = multierr.Append(errs, err) + continue + } + trades = append(trades, *res) + } + + if errs != nil { + return nil, errs + } + + return trades, nil +} + +func (e *Exchange) SubmitOrder(ctx context.Context, order types.SubmitOrder) (*types.Order, error) { + if len(order.Market.Symbol) == 0 { + return nil, fmt.Errorf("order.Market.Symbol is required: %+v", order) + } + + req := e.client.NewPlaceOrderRequest() + req.Symbol(order.Market.Symbol) + + // set order type + orderType, err := toLocalOrderType(order.Type) + if err != nil { + return nil, err + } + req.OrderType(orderType) + + // set side + side, err := toLocalSide(order.Side) + if err != nil { + return nil, err + } + req.Side(side) + + // set quantity + orderQty := order.Quantity + // if the order is market buy, the quantity is quote coin, instead of base coin. so we need to convert it. + if order.Type == types.OrderTypeMarket && order.Side == types.SideTypeBuy { + ticker, err := e.QueryTicker(ctx, order.Market.Symbol) + if err != nil { + return nil, err + } + orderQty = order.Quantity.Mul(ticker.Buy) + } + req.Qty(order.Market.FormatQuantity(orderQty)) + + // set price + switch order.Type { + case types.OrderTypeLimit: + req.Price(order.Market.FormatPrice(order.Price)) + } + + // set timeInForce + switch order.TimeInForce { + case types.TimeInForceFOK: + req.TimeInForce(bybitapi.TimeInForceFOK) + case types.TimeInForceIOC: + req.TimeInForce(bybitapi.TimeInForceIOC) + default: + req.TimeInForce(bybitapi.TimeInForceGTC) + } + + // set client order id + if len(order.ClientOrderID) > maxOrderIdLen { + return nil, fmt.Errorf("unexpected length of order id, got: %d", len(order.ClientOrderID)) + } + if len(order.ClientOrderID) > 0 { + req.OrderLinkId(order.ClientOrderID) + } + + if err := orderRateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("place order rate limiter wait error: %w", err) + } + timeNow := time.Now() + res, err := req.Do(ctx) + if err != nil { + return nil, fmt.Errorf("failed to place order, order: %#v, err: %w", order, err) + } + + if len(res.OrderId) == 0 || (len(order.ClientOrderID) != 0 && res.OrderLinkId != order.ClientOrderID) { + return nil, fmt.Errorf("unexpected order id, resp: %#v, order: %#v", res, order) + } + + intOrderId, err := strconv.ParseUint(res.OrderId, 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse orderId: %s", res.OrderId) + } + + return &types.Order{ + SubmitOrder: order, + Exchange: types.ExchangeBybit, + OrderID: intOrderId, + UUID: res.OrderId, + Status: types.OrderStatusNew, + ExecutedQuantity: fixedpoint.Zero, + IsWorking: true, + CreationTime: types.Time(timeNow), + UpdateTime: types.Time(timeNow), + }, nil +} + +func (e *Exchange) CancelOrders(ctx context.Context, orders ...types.Order) (errs error) { + if len(orders) == 0 { + return nil + } + + for _, order := range orders { + req := e.client.NewCancelOrderRequest() + + reqId := "" + switch { + // use the OrderID first, then the ClientOrderID + case order.OrderID > 0: + req.OrderId(order.UUID) + reqId = order.UUID + + case len(order.ClientOrderID) != 0: + req.OrderLinkId(order.ClientOrderID) + reqId = order.ClientOrderID + + default: + errs = multierr.Append( + errs, + fmt.Errorf("the order uuid and client order id are empty, order: %#v", order), + ) + continue + } + + req.Symbol(order.Market.Symbol) + + if err := orderRateLimiter.Wait(ctx); err != nil { + errs = multierr.Append(errs, fmt.Errorf("cancel order rate limiter wait, order id: %s, error: %w", order.ClientOrderID, err)) + continue + } + res, err := req.Do(ctx) + if err != nil { + errs = multierr.Append(errs, fmt.Errorf("failed to cancel order id: %s, err: %w", order.ClientOrderID, err)) + continue + } + + // sanity check + if res.OrderId != reqId && res.OrderLinkId != reqId { + errs = multierr.Append(errs, fmt.Errorf("order id mismatch, exp: %s, respOrderId: %s, respOrderLinkId: %s", reqId, res.OrderId, res.OrderLinkId)) + continue + } + } + + return errs +} + +func (e *Exchange) QueryClosedOrders(ctx context.Context, symbol string, since, util time.Time, lastOrderID uint64) (orders []types.Order, err error) { + if !since.IsZero() || !util.IsZero() { + log.Warn("!!!BYBIT EXCHANGE API NOTICE!!! the since/until conditions will not be effected on SPOT account, bybit exchange does not support time-range-based query currently") + } + + if err := closedOrderQueryLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("query closed order rate limiter wait error: %w", err) + } + res, err := e.client.NewGetOrderHistoriesRequest(). + Symbol(symbol). + Cursor(strconv.FormatUint(lastOrderID, 10)). + Limit(defaultQueryLimit). + Do(ctx) + if err != nil { + return nil, fmt.Errorf("failed to call get order histories error: %w", err) + } + + for _, order := range res.List { + o, err2 := toGlobalOrder(order) + if err2 != nil { + err = multierr.Append(err, err2) + continue + } + + if o.Status.Closed() { + orders = append(orders, *o) + } + } + if err != nil { + return nil, err + } + + return types.SortOrdersAscending(orders), nil +} + +/* +QueryTrades queries trades by time range or trade id range. +If options.StartTime is not specified, you can only query for records in the last 7 days. +If you want to query for records older than 7 days, options.StartTime is required. +It supports to query records up to 180 days. + +** Here includes MakerRebate. If needed, let's discuss how to modify it to return in trade. ** +** StartTime and EndTime are inclusive. ** +** StartTime and EndTime cannot exceed 180 days. ** +** StartTime, EndTime, FromTradeId can be used together. ** +** If the `FromTradeId` is passed, and `ToTradeId` is null, then the result is sorted by tradeId in `ascend`. +Otherwise, the result is sorted by tradeId in `descend`. ** +*/ +func (e *Exchange) QueryTrades(ctx context.Context, symbol string, options *types.TradeQueryOptions) (trades []types.Trade, err error) { + // using v3 client, since the v5 API does not support feeCurrency. + req := e.v3client.NewGetTradesRequest() + req.Symbol(symbol) + + // If `lastTradeId` is given and greater than 0, the query will use it as a condition and the retrieved result will be + // in `ascending` order. We can use `lastTradeId` to retrieve all the data. So we hack it to '1' if `lastTradeID` is '0'. + // If 0 is given, it will not be used as a condition and the result will be in `descending` order. The FromTradeId + // option cannot be used to retrieve more data. + req.FromTradeId(strconv.FormatUint(options.LastTradeID, 10)) + if options.LastTradeID == 0 { + req.FromTradeId("1") + } + if options.StartTime != nil { + req.StartTime(options.StartTime.UTC()) + } + if options.EndTime != nil { + req.EndTime(options.EndTime.UTC()) + } + + limit := uint64(options.Limit) + if limit > defaultQueryLimit || limit <= 0 { + log.Debugf("limtit is exceeded or zero, update to %d, got: %d", defaultQueryLimit, options.Limit) + limit = defaultQueryLimit + } + req.Limit(limit) + + if err := queryOrderTradeRateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("trade rate limiter wait error: %w", err) + } + response, err := req.Do(ctx) + if err != nil { + return nil, fmt.Errorf("failed to query trades, err: %w", err) + } + + var errs error + for _, trade := range response.List { + res, err := v3ToGlobalTrade(trade) + if err != nil { + errs = multierr.Append(errs, err) + continue + } + trades = append(trades, *res) + } + + if errs != nil { + return nil, errs + } + + return trades, nil +} + +func (e *Exchange) QueryAccount(ctx context.Context) (*types.Account, error) { + balanceMap, err := e.QueryAccountBalances(ctx) + if err != nil { + return nil, err + } + acct := &types.Account{ + AccountType: types.AccountTypeSpot, + // MakerFeeRate bybit doesn't support global maker fee rate. + MakerFeeRate: fixedpoint.Zero, + // TakerFeeRate bybit doesn't support global taker fee rate. + TakerFeeRate: fixedpoint.Zero, + } + acct.UpdateBalances(balanceMap) + + return acct, nil +} + +func (e *Exchange) QueryAccountBalances(ctx context.Context) (types.BalanceMap, error) { + if err := sharedRateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("query account balances rate limiter wait error: %w", err) + } + + req := e.client.NewGetWalletBalancesRequest() + accounts, err := req.Do(ctx) + if err != nil { + return nil, err + } + + return toGlobalBalanceMap(accounts.List), nil +} + +/* +QueryKLines queries for historical klines (also known as candles/candlesticks). Charts are returned in groups based +on the requested interval. + +A k-line's start time is inclusive, but end time is not(startTime + interval - 1 millisecond). +e.q. 15m interval k line can be represented as 00:00:00.000 ~ 00:14:59.999 +*/ +func (e *Exchange) QueryKLines(ctx context.Context, symbol string, interval types.Interval, options types.KLineQueryOptions) ([]types.KLine, error) { + req := e.client.NewGetKLinesRequest().Symbol(symbol) + intervalStr, err := toLocalInterval(interval) + if err != nil { + return nil, err + } + req.Interval(intervalStr) + + limit := uint64(options.Limit) + if limit > defaultKLineLimit || limit <= 0 { + log.Debugf("limtit is exceeded or zero, update to %d, got: %d", defaultKLineLimit, options.Limit) + limit = defaultKLineLimit + } + req.Limit(limit) + + if options.StartTime != nil { + req.StartTime(*options.StartTime) + } + + if options.EndTime != nil { + req.EndTime(*options.EndTime) + } + + if err := sharedRateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("query klines rate limiter wait error: %w", err) + } + + resp, err := req.Do(ctx) + if err != nil { + return nil, fmt.Errorf("failed to call k line, err: %w", err) + } + + if resp.Category != bybitapi.CategorySpot { + return nil, fmt.Errorf("unexpected category: %s", resp.Category) + } + + if resp.Symbol != symbol { + return nil, fmt.Errorf("unexpected symbol: %s, exp: %s", resp.Category, symbol) + } + + kLines := toGlobalKLines(symbol, interval, resp.List) + return types.SortKLinesAscending(kLines), nil + +} + +func (e *Exchange) SupportedInterval() map[types.Interval]int { + return bybitapi.SupportedIntervals +} + +func (e *Exchange) IsSupportedInterval(interval types.Interval) bool { + _, ok := bybitapi.SupportedIntervals[interval] + return ok +} + +func (e *Exchange) GetAllFeeRates(ctx context.Context) (bybitapi.FeeRates, error) { + if err := sharedRateLimiter.Wait(ctx); err != nil { + return bybitapi.FeeRates{}, fmt.Errorf("query fee rate limiter wait error: %w", err) + } + feeRates, err := e.client.NewGetFeeRatesRequest().Do(ctx) + if err != nil { + return bybitapi.FeeRates{}, fmt.Errorf("failed to get fee rates, err: %w", err) + } + + return *feeRates, nil +} + +func (e *Exchange) NewStream() types.Stream { + return NewStream(e.key, e.secret, e) +} diff --git a/pkg/exchange/bybit/market_info_poller.go b/pkg/exchange/bybit/market_info_poller.go new file mode 100644 index 0000000..eeb47b0 --- /dev/null +++ b/pkg/exchange/bybit/market_info_poller.go @@ -0,0 +1,142 @@ +package bybit + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "golang.org/x/time/rate" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bybit/bybitapi" +) + +const ( + // To maintain aligned fee rates, it's important to update fees frequently. + feeRatePollingPeriod = time.Minute +) + +var ( + pollFeeRateRateLimiter = rate.NewLimiter(rate.Every(10*time.Minute), 1) +) + +type symbolFeeDetail struct { + bybitapi.FeeRate + + BaseCoin string + QuoteCoin string +} + +// feeRatePoller pulls the specified market data from bbgo QueryMarkets. +type feeRatePoller struct { + mu sync.Mutex + once sync.Once + client MarketInfoProvider + + symbolFeeDetail map[string]symbolFeeDetail +} + +func newFeeRatePoller(marketInfoProvider MarketInfoProvider) *feeRatePoller { + return &feeRatePoller{ + client: marketInfoProvider, + symbolFeeDetail: map[string]symbolFeeDetail{}, + } +} + +func (p *feeRatePoller) Start(ctx context.Context) { + p.once.Do(func() { + p.startLoop(ctx) + }) +} + +func (p *feeRatePoller) startLoop(ctx context.Context) { + err := p.poll(ctx) + if err != nil { + log.WithError(err).Warn("failed to initialize the fee rate, the ticker is scheduled to update it subsequently") + } + + ticker := time.NewTicker(feeRatePollingPeriod) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + if err := ctx.Err(); !errors.Is(err, context.Canceled) { + log.WithError(err).Error("context done with error") + } + + return + case <-ticker.C: + if err := p.poll(ctx); err != nil { + log.WithError(err).Warn("failed to update fee rate") + } + } + } +} + +func (p *feeRatePoller) poll(ctx context.Context) error { + symbolFeeRate, err := p.getAllFeeRates(ctx) + if err != nil { + return err + } + + p.mu.Lock() + p.symbolFeeDetail = symbolFeeRate + p.mu.Unlock() + + if pollFeeRateRateLimiter.Allow() { + log.Infof("updated fee rate: %+v", p.symbolFeeDetail) + } + + return nil +} + +func (p *feeRatePoller) Get(symbol string) (symbolFeeDetail, bool) { + p.mu.Lock() + defer p.mu.Unlock() + + fee, found := p.symbolFeeDetail[symbol] + return fee, found +} + +func (e *feeRatePoller) getAllFeeRates(ctx context.Context) (map[string]symbolFeeDetail, error) { + feeRates, err := e.client.GetAllFeeRates(ctx) + if err != nil { + return nil, fmt.Errorf("failed to call get fee rates: %w", err) + } + + symbolMap := map[string]symbolFeeDetail{} + for _, f := range feeRates.List { + if _, found := symbolMap[f.Symbol]; !found { + symbolMap[f.Symbol] = symbolFeeDetail{FeeRate: f} + } + } + + mkts, err := e.client.QueryMarkets(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get markets: %w", err) + } + + // update base coin, quote coin into symbolFeeDetail + for _, mkt := range mkts { + feeRate, found := symbolMap[mkt.Symbol] + if !found { + continue + } + + feeRate.BaseCoin = mkt.BaseCurrency + feeRate.QuoteCoin = mkt.QuoteCurrency + + symbolMap[mkt.Symbol] = feeRate + } + + // remove trading pairs that are not present in spot market. + for k, v := range symbolMap { + if len(v.BaseCoin) == 0 || len(v.QuoteCoin) == 0 { + log.Debugf("related market not found: %s, skipping the associated trade", k) + delete(symbolMap, k) + } + } + + return symbolMap, nil +} diff --git a/pkg/exchange/bybit/market_info_poller_test.go b/pkg/exchange/bybit/market_info_poller_test.go new file mode 100644 index 0000000..c7215ef --- /dev/null +++ b/pkg/exchange/bybit/market_info_poller_test.go @@ -0,0 +1,173 @@ +package bybit + +import ( + "context" + "fmt" + "testing" + + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bybit/bybitapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bybit/mocks" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func TestFeeRatePoller_getAllFeeRates(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + unknownErr := errors.New("unknown err") + + t.Run("succeeds", func(t *testing.T) { + mockMarketProvider := mocks.NewMockStreamDataProvider(mockCtrl) + s := &feeRatePoller{ + client: mockMarketProvider, + } + + ctx := context.Background() + feeRates := bybitapi.FeeRates{ + List: []bybitapi.FeeRate{ + { + Symbol: "BTCUSDT", + TakerFeeRate: fixedpoint.NewFromFloat(0.001), + MakerFeeRate: fixedpoint.NewFromFloat(0.001), + }, + { + Symbol: "ETHUSDT", + TakerFeeRate: fixedpoint.NewFromFloat(0.001), + MakerFeeRate: fixedpoint.NewFromFloat(0.001), + }, + { + Symbol: "OPTIONCOIN", + TakerFeeRate: fixedpoint.NewFromFloat(0.001), + MakerFeeRate: fixedpoint.NewFromFloat(0.001), + }, + }, + } + + mkts := types.MarketMap{ + "BTCUSDT": types.Market{ + Symbol: "BTCUSDT", + QuoteCurrency: "USDT", + BaseCurrency: "BTC", + }, + "ETHUSDT": types.Market{ + Symbol: "ETHUSDT", + QuoteCurrency: "USDT", + BaseCurrency: "ETH", + }, + } + + mockMarketProvider.EXPECT().GetAllFeeRates(ctx).Return(feeRates, nil).Times(1) + mockMarketProvider.EXPECT().QueryMarkets(ctx).Return(mkts, nil).Times(1) + + expFeeRates := map[string]symbolFeeDetail{ + "BTCUSDT": { + FeeRate: feeRates.List[0], + BaseCoin: "BTC", + QuoteCoin: "USDT", + }, + "ETHUSDT": { + FeeRate: feeRates.List[1], + BaseCoin: "ETH", + QuoteCoin: "USDT", + }, + } + symbolFeeDetails, err := s.getAllFeeRates(ctx) + assert.NoError(t, err) + assert.Equal(t, expFeeRates, symbolFeeDetails) + }) + + t.Run("failed to query markets", func(t *testing.T) { + mockMarketProvider := mocks.NewMockStreamDataProvider(mockCtrl) + s := &feeRatePoller{ + client: mockMarketProvider, + } + + ctx := context.Background() + feeRates := bybitapi.FeeRates{ + List: []bybitapi.FeeRate{ + { + Symbol: "BTCUSDT", + TakerFeeRate: fixedpoint.NewFromFloat(0.001), + MakerFeeRate: fixedpoint.NewFromFloat(0.001), + }, + { + Symbol: "ETHUSDT", + TakerFeeRate: fixedpoint.NewFromFloat(0.001), + MakerFeeRate: fixedpoint.NewFromFloat(0.001), + }, + { + Symbol: "OPTIONCOIN", + TakerFeeRate: fixedpoint.NewFromFloat(0.001), + MakerFeeRate: fixedpoint.NewFromFloat(0.001), + }, + }, + } + + mockMarketProvider.EXPECT().GetAllFeeRates(ctx).Return(feeRates, nil).Times(1) + mockMarketProvider.EXPECT().QueryMarkets(ctx).Return(nil, unknownErr).Times(1) + + symbolFeeDetails, err := s.getAllFeeRates(ctx) + assert.Equal(t, fmt.Errorf("failed to get markets: %w", unknownErr), err) + assert.Equal(t, map[string]symbolFeeDetail(nil), symbolFeeDetails) + }) + + t.Run("failed to get fee rates", func(t *testing.T) { + mockMarketProvider := mocks.NewMockStreamDataProvider(mockCtrl) + s := &feeRatePoller{ + client: mockMarketProvider, + } + + ctx := context.Background() + + mockMarketProvider.EXPECT().GetAllFeeRates(ctx).Return(bybitapi.FeeRates{}, unknownErr).Times(1) + + symbolFeeDetails, err := s.getAllFeeRates(ctx) + assert.Equal(t, fmt.Errorf("failed to call get fee rates: %w", unknownErr), err) + assert.Equal(t, map[string]symbolFeeDetail(nil), symbolFeeDetails) + }) +} + +func Test_feeRatePoller_Get(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockMarketProvider := mocks.NewMockStreamDataProvider(mockCtrl) + t.Run("found", func(t *testing.T) { + symbol := "BTCUSDT" + expFeeDetail := symbolFeeDetail{ + FeeRate: bybitapi.FeeRate{ + Symbol: symbol, + TakerFeeRate: fixedpoint.NewFromFloat(0.1), + MakerFeeRate: fixedpoint.NewFromFloat(0.2), + }, + BaseCoin: "BTC", + QuoteCoin: "USDT", + } + + s := &feeRatePoller{ + client: mockMarketProvider, + symbolFeeDetail: map[string]symbolFeeDetail{ + symbol: expFeeDetail, + }, + } + + res, found := s.Get(symbol) + assert.True(t, found) + assert.Equal(t, expFeeDetail, res) + }) + t.Run("not found", func(t *testing.T) { + symbol := "BTCUSDT" + s := &feeRatePoller{ + client: mockMarketProvider, + symbolFeeDetail: map[string]symbolFeeDetail{}, + } + + _, found := s.Get(symbol) + assert.False(t, found) + }) +} diff --git a/pkg/exchange/bybit/mocks/stream.go b/pkg/exchange/bybit/mocks/stream.go new file mode 100644 index 0000000..f264b8d --- /dev/null +++ b/pkg/exchange/bybit/mocks/stream.go @@ -0,0 +1,82 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bybit (interfaces: StreamDataProvider) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + bybitapi "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bybit/bybitapi" + types "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + gomock "go.uber.org/mock/gomock" +) + +// MockStreamDataProvider is a mock of StreamDataProvider interface. +type MockStreamDataProvider struct { + ctrl *gomock.Controller + recorder *MockStreamDataProviderMockRecorder +} + +// MockStreamDataProviderMockRecorder is the mock recorder for MockStreamDataProvider. +type MockStreamDataProviderMockRecorder struct { + mock *MockStreamDataProvider +} + +// NewMockStreamDataProvider creates a new mock instance. +func NewMockStreamDataProvider(ctrl *gomock.Controller) *MockStreamDataProvider { + mock := &MockStreamDataProvider{ctrl: ctrl} + mock.recorder = &MockStreamDataProviderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStreamDataProvider) EXPECT() *MockStreamDataProviderMockRecorder { + return m.recorder +} + +// GetAllFeeRates mocks base method. +func (m *MockStreamDataProvider) GetAllFeeRates(arg0 context.Context) (bybitapi.FeeRates, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllFeeRates", arg0) + ret0, _ := ret[0].(bybitapi.FeeRates) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllFeeRates indicates an expected call of GetAllFeeRates. +func (mr *MockStreamDataProviderMockRecorder) GetAllFeeRates(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllFeeRates", reflect.TypeOf((*MockStreamDataProvider)(nil).GetAllFeeRates), arg0) +} + +// QueryAccountBalances mocks base method. +func (m *MockStreamDataProvider) 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 *MockStreamDataProviderMockRecorder) QueryAccountBalances(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryAccountBalances", reflect.TypeOf((*MockStreamDataProvider)(nil).QueryAccountBalances), arg0) +} + +// QueryMarkets mocks base method. +func (m *MockStreamDataProvider) 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 *MockStreamDataProviderMockRecorder) QueryMarkets(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryMarkets", reflect.TypeOf((*MockStreamDataProvider)(nil).QueryMarkets), arg0) +} diff --git a/pkg/exchange/bybit/stream.go b/pkg/exchange/bybit/stream.go new file mode 100644 index 0000000..650776e --- /dev/null +++ b/pkg/exchange/bybit/stream.go @@ -0,0 +1,482 @@ +package bybit + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "time" + + "github.com/gorilla/websocket" + "golang.org/x/time/rate" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bybit/bybitapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/retry" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ( + // spotArgsLimit can input up to 10 args for each subscription request sent to one connection. + spotArgsLimit = 10 +) + +var ( + // wsAuthRequest specifies the duration for which a websocket request's authentication is valid. + wsAuthRequest = 10 * time.Second + // The default taker/maker fees can help us in estimating trading fees in the SPOT market, because trade fees are not + // provided for traditional accounts on Bybit. + // https://www.bybit.com/en-US/help-center/article/Trading-Fee-Structure + defaultTakerFee = fixedpoint.NewFromFloat(0.001) + defaultMakerFee = fixedpoint.NewFromFloat(0.001) + + marketTradeLogLimiter = rate.NewLimiter(rate.Every(time.Minute), 1) + tradeLogLimiter = rate.NewLimiter(rate.Every(time.Minute), 1) + orderLogLimiter = rate.NewLimiter(rate.Every(time.Minute), 1) + kLineLogLimiter = rate.NewLimiter(rate.Every(time.Minute), 1) +) + +// MarketInfoProvider calculates trade fees since trading fees are not supported by streaming. +type MarketInfoProvider interface { + GetAllFeeRates(ctx context.Context) (bybitapi.FeeRates, error) + QueryMarkets(ctx context.Context) (types.MarketMap, error) +} + +// AccountBalanceProvider provides a function to query all balances at streaming connected and emit balance snapshot. +type AccountBalanceProvider interface { + QueryAccountBalances(ctx context.Context) (types.BalanceMap, error) +} + +//go:generate mockgen -destination=mocks/stream.go -package=mocks . StreamDataProvider +type StreamDataProvider interface { + MarketInfoProvider + AccountBalanceProvider +} + +//go:generate callbackgen -type Stream +type Stream struct { + types.StandardStream + + key, secret string + streamDataProvider StreamDataProvider + feeRateProvider *feeRatePoller + marketsInfo types.MarketMap + + bookEventCallbacks []func(e BookEvent) + marketTradeEventCallbacks []func(e []MarketTradeEvent) + walletEventCallbacks []func(e []bybitapi.WalletBalances) + kLineEventCallbacks []func(e KLineEvent) + orderEventCallbacks []func(e []OrderEvent) + tradeEventCallbacks []func(e []TradeEvent) +} + +func NewStream(key, secret string, userDataProvider StreamDataProvider) *Stream { + stream := &Stream{ + StandardStream: types.NewStandardStream(), + // pragma: allowlist nextline secret + key: key, + secret: secret, + streamDataProvider: userDataProvider, + feeRateProvider: newFeeRatePoller(userDataProvider), + } + + stream.SetEndpointCreator(stream.createEndpoint) + stream.SetParser(stream.parseWebSocketEvent) + stream.SetDispatcher(stream.dispatchEvent) + stream.SetHeartBeat(stream.ping) + stream.SetBeforeConnect(func(ctx context.Context) (err error) { + if stream.PublicOnly { + // we don't need the fee rate in the public stream. + return + } + + // get account fee rate + go stream.feeRateProvider.Start(ctx) + + stream.marketsInfo, err = stream.streamDataProvider.QueryMarkets(ctx) + if err != nil { + log.WithError(err).Error("failed to query market info before to connect stream") + return err + } + return nil + }) + stream.OnConnect(stream.handlerConnect) + stream.OnAuth(stream.handleAuthEvent) + + stream.OnBookEvent(stream.handleBookEvent) + stream.OnMarketTradeEvent(stream.handleMarketTradeEvent) + stream.OnKLineEvent(stream.handleKLineEvent) + stream.OnWalletEvent(stream.handleWalletEvent) + stream.OnOrderEvent(stream.handleOrderEvent) + stream.OnTradeEvent(stream.handleTradeEvent) + return stream +} + +func (s *Stream) syncSubscriptions(opType WsOpType) error { + if opType != WsOpTypeUnsubscribe && opType != WsOpTypeSubscribe { + return fmt.Errorf("unexpected subscription type: %v", opType) + } + + logger := log.WithField("opType", opType) + lens := len(s.Subscriptions) + for begin := 0; begin < lens; begin += spotArgsLimit { + end := begin + spotArgsLimit + if end > lens { + end = lens + } + + topics := []string{} + for _, subscription := range s.Subscriptions[begin:end] { + topic, err := s.convertSubscription(subscription) + if err != nil { + logger.WithError(err).Errorf("convert error, subscription: %+v", subscription) + return err + } + + topics = append(topics, topic) + } + + logger.Infof("%s channels: %+v", opType, topics) + if err := s.Conn.WriteJSON(WebsocketOp{ + Op: opType, + Args: topics, + }); err != nil { + logger.WithError(err).Error("failed to send request") + return err + } + } + + return nil +} + +func (s *Stream) Unsubscribe() { + // errors are handled in the syncSubscriptions, so they are skipped here. + _ = s.syncSubscriptions(WsOpTypeUnsubscribe) + s.Resubscribe(func(old []types.Subscription) (new []types.Subscription, err error) { + // clear the subscriptions + return []types.Subscription{}, nil + }) +} + +func (s *Stream) createEndpoint(_ context.Context) (string, error) { + var url string + if s.PublicOnly { + url = bybitapi.WsSpotPublicSpotUrl + } else { + url = bybitapi.WsSpotPrivateUrl + } + return url, nil +} + +func (s *Stream) dispatchEvent(event interface{}) { + switch e := event.(type) { + case *WebSocketOpEvent: + if e.IsAuthenticated() { + s.EmitAuth() + } + + case *BookEvent: + s.EmitBookEvent(*e) + + case []MarketTradeEvent: + s.EmitMarketTradeEvent(e) + + case []bybitapi.WalletBalances: + s.EmitWalletEvent(e) + + case *KLineEvent: + s.EmitKLineEvent(*e) + + case []OrderEvent: + s.EmitOrderEvent(e) + + case []TradeEvent: + s.EmitTradeEvent(e) + + } +} + +func (s *Stream) parseWebSocketEvent(in []byte) (interface{}, error) { + var e WsEvent + + err := json.Unmarshal(in, &e) + if err != nil { + return nil, err + } + + switch { + case e.IsOp(): + if err = e.IsValid(); err != nil { + log.Errorf("invalid event: %+v, err: %s", e, err) + return nil, err + } + + // return global pong event to avoid emit raw message + if ok, pongEvent := e.toGlobalPongEventIfValid(); ok { + return pongEvent, nil + } + return e.WebSocketOpEvent, nil + + case e.IsTopic(): + switch getTopicType(e.Topic) { + + case TopicTypeOrderBook: + var book BookEvent + err = json.Unmarshal(e.WebSocketTopicEvent.Data, &book) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal data into BookEvent: %+v, err: %w", string(e.WebSocketTopicEvent.Data), err) + } + + book.Type = e.WebSocketTopicEvent.Type + book.ServerTime = e.WebSocketTopicEvent.Ts.Time() + return &book, nil + + case TopicTypeMarketTrade: + // snapshot only + var trade []MarketTradeEvent + err = json.Unmarshal(e.WebSocketTopicEvent.Data, &trade) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal data into MarketTradeEvent: %+v, err: %w", string(e.WebSocketTopicEvent.Data), err) + } + + return trade, nil + + case TopicTypeKLine: + var kLines []KLine + err = json.Unmarshal(e.WebSocketTopicEvent.Data, &kLines) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal data into KLine: %+v, err: %w", string(e.WebSocketTopicEvent.Data), err) + } + + symbol, err := getSymbolFromTopic(e.Topic) + if err != nil { + return nil, err + } + + return &KLineEvent{KLines: kLines, Symbol: symbol, Type: e.WebSocketTopicEvent.Type}, nil + + case TopicTypeWallet: + var wallets []bybitapi.WalletBalances + return wallets, json.Unmarshal(e.WebSocketTopicEvent.Data, &wallets) + + case TopicTypeOrder: + var orders []OrderEvent + return orders, json.Unmarshal(e.WebSocketTopicEvent.Data, &orders) + + case TopicTypeTrade: + var trades []TradeEvent + return trades, json.Unmarshal(e.WebSocketTopicEvent.Data, &trades) + + } + } + + return nil, fmt.Errorf("unhandled websocket event: %+v", string(in)) +} + +// ping implements the Bybit text message of WebSocket PingPong. +func (s *Stream) ping(conn *websocket.Conn) error { + err := conn.WriteJSON(struct { + Op WsOpType `json:"op"` + }{ + Op: WsOpTypePing, + }) + if err != nil { + log.WithError(err).Error("ping error") + return err + } + + return nil +} + +func (s *Stream) handlerConnect() { + if s.PublicOnly { + // errors are handled in the syncSubscriptions, so they are skipped here. + _ = s.syncSubscriptions(WsOpTypeSubscribe) + } else { + expires := strconv.FormatInt(time.Now().Add(wsAuthRequest).In(time.UTC).UnixMilli(), 10) + + if err := s.Conn.WriteJSON(WebsocketOp{ + Op: WsOpTypeAuth, + Args: []string{ + s.key, + expires, + bybitapi.Sign(fmt.Sprintf("GET/realtime%s", expires), s.secret), + }, + }); err != nil { + log.WithError(err).Error("failed to auth request") + return + } + + if err := s.Conn.WriteJSON(WebsocketOp{ + Op: WsOpTypeSubscribe, + Args: []string{ + string(TopicTypeWallet), + string(TopicTypeOrder), + string(TopicTypeTrade), + }, + }); err != nil { + log.WithError(err).Error("failed to send subscription request") + return + } + } +} + +func (s *Stream) convertSubscription(sub types.Subscription) (string, error) { + switch sub.Channel { + + case types.BookChannel: + depth := types.DepthLevel1 + + switch sub.Options.Depth { + case types.DepthLevel50: + depth = sub.Options.Depth + case types.DepthLevel200: + depth = sub.Options.Depth + } + return genTopic(TopicTypeOrderBook, depth, sub.Symbol), nil + + case types.MarketTradeChannel: + return genTopic(TopicTypeMarketTrade, sub.Symbol), nil + + case types.KLineChannel: + interval, err := toLocalInterval(sub.Options.Interval) + if err != nil { + return "", err + } + + return genTopic(TopicTypeKLine, interval, sub.Symbol), nil + + } + + return "", fmt.Errorf("unsupported stream channel: %s", sub.Channel) +} + +func (s *Stream) handleAuthEvent() { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var balnacesMap types.BalanceMap + var err error + err = retry.GeneralBackoff(ctx, func() error { + balnacesMap, err = s.streamDataProvider.QueryAccountBalances(ctx) + return err + }) + if err != nil { + log.WithError(err).Error("no more attempts to retrieve balances") + return + } + + s.EmitBalanceSnapshot(balnacesMap) +} + +func (s *Stream) handleBookEvent(e BookEvent) { + orderBook := e.OrderBook() + switch { + // Occasionally, you'll receive "UpdateId"=1, which is a snapshot data due to the restart of + // the service. So please overwrite your local orderbook + case e.Type == DataTypeSnapshot || e.UpdateId.Int() == 1: + s.EmitBookSnapshot(orderBook) + + case e.Type == DataTypeDelta: + s.EmitBookUpdate(orderBook) + } +} + +func (s *Stream) handleMarketTradeEvent(events []MarketTradeEvent) { + for _, event := range events { + trade, err := event.toGlobalTrade() + if err != nil { + if marketTradeLogLimiter.Allow() { + log.WithError(err).Error("failed to convert to market trade") + } + continue + } + + s.StandardStream.EmitMarketTrade(trade) + } +} + +func (s *Stream) handleWalletEvent(events []bybitapi.WalletBalances) { + s.StandardStream.EmitBalanceUpdate(toGlobalBalanceMap(events)) +} + +func (s *Stream) handleOrderEvent(events []OrderEvent) { + for _, event := range events { + if event.Category != bybitapi.CategorySpot { + return + } + + gOrder, err := toGlobalOrder(event.Order) + if err != nil { + if orderLogLimiter.Allow() { + log.WithError(err).Error("failed to convert to global order") + } + continue + } + s.StandardStream.EmitOrderUpdate(*gOrder) + } +} + +func (s *Stream) handleKLineEvent(klineEvent KLineEvent) { + if klineEvent.Type != DataTypeSnapshot { + return + } + + for _, event := range klineEvent.KLines { + kline, err := event.toGlobalKLine(klineEvent.Symbol) + if err != nil { + if kLineLogLimiter.Allow() { + log.WithError(err).Error("failed to convert to global k line") + } + continue + } + + if kline.Closed { + s.EmitKLineClosed(kline) + } else { + s.EmitKLine(kline) + } + } +} + +func (s *Stream) handleTradeEvent(events []TradeEvent) { + for _, event := range events { + feeRate, found := s.feeRateProvider.Get(event.Symbol) + if !found { + feeRate = symbolFeeDetail{ + FeeRate: bybitapi.FeeRate{ + Symbol: event.Symbol, + TakerFeeRate: defaultTakerFee, + MakerFeeRate: defaultMakerFee, + }, + BaseCoin: "", + QuoteCoin: "", + } + + if market, ok := s.marketsInfo[event.Symbol]; ok { + feeRate.BaseCoin = market.BaseCurrency + feeRate.QuoteCoin = market.QuoteCurrency + } + + if tradeLogLimiter.Allow() { + // The error log level was utilized due to a detected discrepancy in the fee calculations. + log.Errorf("failed to get %s fee rate, use default taker fee %f, maker fee %f, base coin: %s, quote coin: %s", + event.Symbol, + feeRate.TakerFeeRate.Float64(), + feeRate.MakerFeeRate.Float64(), + feeRate.BaseCoin, + feeRate.QuoteCoin, + ) + } + } + + gTrade, err := event.toGlobalTrade(feeRate) + if err != nil { + if tradeLogLimiter.Allow() { + log.WithError(err).Errorf("unable to convert: %+v", event) + } + continue + } + s.StandardStream.EmitTradeUpdate(*gTrade) + } +} diff --git a/pkg/exchange/bybit/stream_callbacks.go b/pkg/exchange/bybit/stream_callbacks.go new file mode 100644 index 0000000..2b9292a --- /dev/null +++ b/pkg/exchange/bybit/stream_callbacks.go @@ -0,0 +1,67 @@ +// Code generated by "callbackgen -type Stream"; DO NOT EDIT. + +package bybit + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bybit/bybitapi" +) + +func (s *Stream) OnBookEvent(cb func(e BookEvent)) { + s.bookEventCallbacks = append(s.bookEventCallbacks, cb) +} + +func (s *Stream) EmitBookEvent(e BookEvent) { + for _, cb := range s.bookEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnMarketTradeEvent(cb func(e []MarketTradeEvent)) { + s.marketTradeEventCallbacks = append(s.marketTradeEventCallbacks, cb) +} + +func (s *Stream) EmitMarketTradeEvent(e []MarketTradeEvent) { + for _, cb := range s.marketTradeEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnWalletEvent(cb func(e []bybitapi.WalletBalances)) { + s.walletEventCallbacks = append(s.walletEventCallbacks, cb) +} + +func (s *Stream) EmitWalletEvent(e []bybitapi.WalletBalances) { + for _, cb := range s.walletEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnKLineEvent(cb func(e KLineEvent)) { + s.kLineEventCallbacks = append(s.kLineEventCallbacks, cb) +} + +func (s *Stream) EmitKLineEvent(e KLineEvent) { + for _, cb := range s.kLineEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnOrderEvent(cb func(e []OrderEvent)) { + s.orderEventCallbacks = append(s.orderEventCallbacks, cb) +} + +func (s *Stream) EmitOrderEvent(e []OrderEvent) { + for _, cb := range s.orderEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnTradeEvent(cb func(e []TradeEvent)) { + s.tradeEventCallbacks = append(s.tradeEventCallbacks, cb) +} + +func (s *Stream) EmitTradeEvent(e []TradeEvent) { + for _, cb := range s.tradeEventCallbacks { + cb(e) + } +} diff --git a/pkg/exchange/bybit/stream_test.go b/pkg/exchange/bybit/stream_test.go new file mode 100644 index 0000000..a52293e --- /dev/null +++ b/pkg/exchange/bybit/stream_test.go @@ -0,0 +1,467 @@ +package bybit + +import ( + "context" + "errors" + "fmt" + "os" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bybit/bybitapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/testutil" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func getTestClientOrSkip(t *testing.T) *Stream { + if b, _ := strconv.ParseBool(os.Getenv("CI")); b { + t.Skip("skip test for CI") + } + + key, secret, ok := testutil.IntegrationTestConfigured(t, "BYBIT") + if !ok { + t.Skip("BYBIT_* env vars are not configured") + return nil + } + + exchange, err := New(key, secret) + assert.NoError(t, err) + return NewStream(key, secret, exchange) +} + +func TestStream(t *testing.T) { + //t.Skip() + s := getTestClientOrSkip(t) + + symbols := []string{ + "BTCUSDT", + "ETHUSDT", + "DOTUSDT", + "ADAUSDT", + "AAVEUSDT", + "APTUSDT", + "ATOMUSDT", + "AXSUSDT", + "BNBUSDT", + "SOLUSDT", + "DOGEUSDT", + } + + t.Run("Auth test", func(t *testing.T) { + s.OnBalanceSnapshot(func(balances types.BalanceMap) { + t.Log("got balance snapshot", balances) + }) + s.Connect(context.Background()) + c := make(chan struct{}) + <-c + }) + + t.Run("book test", func(t *testing.T) { + s.Subscribe(types.BookChannel, "BTCUSDT", types.SubscribeOptions{ + Depth: types.DepthLevel50, + }) + s.SetPublicOnly() + err := s.Connect(context.Background()) + assert.NoError(t, err) + + //s.OnBookSnapshot(func(book types.SliceOrderBook) { + // t.Log("got snapshot", book) + //}) + //s.OnBookUpdate(func(book types.SliceOrderBook) { + // t.Log("got update", book) + //}) + c := make(chan struct{}) + <-c + }) + + t.Run("book test on unsubscribe and reconnect", func(t *testing.T) { + for _, symbol := range symbols { + s.Subscribe(types.BookChannel, symbol, types.SubscribeOptions{ + Depth: types.DepthLevel50, + }) + } + + s.SetPublicOnly() + err := s.Connect(context.Background()) + assert.NoError(t, err) + + s.OnBookSnapshot(func(book types.SliceOrderBook) { + t.Log("got snapshot", book) + }) + s.OnBookUpdate(func(book types.SliceOrderBook) { + t.Log("got update", book) + }) + + <-time.After(2 * time.Second) + + s.Unsubscribe() + for _, symbol := range symbols { + s.Subscribe(types.BookChannel, symbol, types.SubscribeOptions{ + Depth: types.DepthLevel50, + }) + } + + <-time.After(2 * time.Second) + + s.Reconnect() + + c := make(chan struct{}) + <-c + }) + + t.Run("market trade test", func(t *testing.T) { + s.Subscribe(types.MarketTradeChannel, "BTCUSDT", types.SubscribeOptions{}) + s.SetPublicOnly() + err := s.Connect(context.Background()) + assert.NoError(t, err) + + s.OnMarketTrade(func(trade types.Trade) { + t.Log("got update", trade) + }) + c := make(chan struct{}) + <-c + }) + + t.Run("wallet test", func(t *testing.T) { + s.OnAuth(func() { + t.Log("authenticated") + }) + err := s.Connect(context.Background()) + assert.NoError(t, err) + + s.OnBalanceUpdate(func(balances types.BalanceMap) { + t.Log("got update", balances) + }) + c := make(chan struct{}) + <-c + }) + + t.Run("order test", func(t *testing.T) { + err := s.Connect(context.Background()) + assert.NoError(t, err) + + s.OnOrderUpdate(func(order types.Order) { + t.Log("got update", order) + }) + c := make(chan struct{}) + <-c + }) + + t.Run("kline test", func(t *testing.T) { + s.Subscribe(types.KLineChannel, "BTCUSDT", types.SubscribeOptions{ + Interval: types.Interval30m, + Depth: "", + Speed: "", + }) + s.SetPublicOnly() + err := s.Connect(context.Background()) + assert.NoError(t, err) + + s.OnKLine(func(kline types.KLine) { + t.Log(kline) + }) + c := make(chan struct{}) + <-c + }) + + t.Run("trade test", func(t *testing.T) { + err := s.Connect(context.Background()) + assert.NoError(t, err) + + s.OnTradeUpdate(func(trade types.Trade) { + t.Log("got update", trade.Fee, trade.FeeCurrency, trade) + }) + c := make(chan struct{}) + <-c + }) +} + +func TestStream_parseWebSocketEvent(t *testing.T) { + s := Stream{} + + t.Run("op", func(t *testing.T) { + input := `{ + "success":true, + "ret_msg":"subscribe", + "conn_id":"a403c8e5-e2b6-4edd-a8f0-1a64fa7227a5", + "op":"subscribe" + }` + res, err := s.parseWebSocketEvent([]byte(input)) + assert.NoError(t, err) + opEvent, ok := res.(*WebSocketOpEvent) + assert.True(t, ok) + expSucceeds := true + expRetMsg := "subscribe" + assert.Equal(t, WebSocketOpEvent{ + Success: expSucceeds, + RetMsg: expRetMsg, + ReqId: "", + ConnId: "a403c8e5-e2b6-4edd-a8f0-1a64fa7227a5", + Op: WsOpTypeSubscribe, + Args: nil, + }, *opEvent) + }) + t.Run("TopicTypeOrderBook with delta", func(t *testing.T) { + input := `{ + "topic":"orderbook.50.BTCUSDT", + "ts":1691130685111, + "type":"delta", + "data":{ + "s":"BTCUSDT", + "b":[ + + ], + "a":[ + [ + "29239.37", + "0.082356" + ], + [ + "29236.1", + "0" + ] + ], + "u":1854104, + "seq":10559247733 + } + }` + + res, err := s.parseWebSocketEvent([]byte(input)) + assert.NoError(t, err) + book, ok := res.(*BookEvent) + assert.True(t, ok) + assert.Equal(t, BookEvent{ + Symbol: "BTCUSDT", + Bids: nil, + Asks: types.PriceVolumeSlice{ + { + fixedpoint.NewFromFloat(29239.37), + fixedpoint.NewFromFloat(0.082356), + }, + { + fixedpoint.NewFromFloat(29236.1), + fixedpoint.NewFromFloat(0), + }, + }, + UpdateId: fixedpoint.NewFromFloat(1854104), + SequenceId: fixedpoint.NewFromFloat(10559247733), + Type: DataTypeDelta, + ServerTime: types.NewMillisecondTimestampFromInt(1691130685111).Time(), + }, *book) + }) + + t.Run("TopicTypeMarketTrade with snapshot", func(t *testing.T) { + input := `{ + "topic":"publicTrade.BTCUSDT", + "ts":1694348711526, + "type":"snapshot", + "data":[ + { + "i":"2290000000068683805", + "T":1694348711524, + "p":"25816.27", + "v":"0.000083", + "S":"Sell", + "s":"BTCUSDT", + "BT":false + } + ] +}` + + res, err := s.parseWebSocketEvent([]byte(input)) + assert.NoError(t, err) + book, ok := res.([]MarketTradeEvent) + assert.True(t, ok) + assert.Equal(t, []MarketTradeEvent{ + { + Timestamp: types.NewMillisecondTimestampFromInt(1694348711524), + Symbol: "BTCUSDT", + Side: bybitapi.SideSell, + Quantity: fixedpoint.NewFromFloat(0.000083), + Price: fixedpoint.NewFromFloat(25816.27), + Direction: "", + TradeId: "2290000000068683805", + BlockTrade: false, + }, + }, book) + }) + + t.Run("TopicTypeKLine with snapshot", func(t *testing.T) { + input := `{ + "topic": "kline.5.BTCUSDT", + "data": [ + { + "start": 1672324800000, + "end": 1672325099999, + "interval": "5", + "open": "16649.5", + "close": "16677", + "high": "16677", + "low": "16608", + "volume": "2.081", + "turnover": "34666.4005", + "confirm": false, + "timestamp": 1672324988882 + } + ], + "ts": 1672324988882, + "type": "snapshot" +}` + + res, err := s.parseWebSocketEvent([]byte(input)) + assert.NoError(t, err) + book, ok := res.(*KLineEvent) + assert.True(t, ok) + assert.Equal(t, KLineEvent{ + Symbol: "BTCUSDT", + Type: DataTypeSnapshot, + KLines: []KLine{ + { + StartTime: types.NewMillisecondTimestampFromInt(1672324800000), + EndTime: types.NewMillisecondTimestampFromInt(1672325099999), + Interval: "5", + OpenPrice: fixedpoint.NewFromFloat(16649.5), + ClosePrice: fixedpoint.NewFromFloat(16677), + HighPrice: fixedpoint.NewFromFloat(16677), + LowPrice: fixedpoint.NewFromFloat(16608), + Volume: fixedpoint.NewFromFloat(2.081), + Turnover: fixedpoint.NewFromFloat(34666.4005), + Confirm: false, + Timestamp: types.NewMillisecondTimestampFromInt(1672324988882), + }, + }, + }, *book) + }) + + t.Run("TopicTypeKLine with invalid topic", func(t *testing.T) { + input := `{ + "topic": "kline.5", + "data": [ + { + "start": 1672324800000, + "end": 1672325099999, + "interval": "5", + "open": "16649.5", + "close": "16677", + "high": "16677", + "low": "16608", + "volume": "2.081", + "turnover": "34666.4005", + "confirm": false, + "timestamp": 1672324988882 + } + ], + "ts": 1672324988882, + "type": "snapshot" +}` + + res, err := s.parseWebSocketEvent([]byte(input)) + assert.Equal(t, errors.New("unexpected topic: kline.5"), err) + assert.Nil(t, res) + }) + + t.Run("Parse fails", func(t *testing.T) { + input := `{ + "topic":"orderbook.50.BTCUSDT", + "ts":1691130685111, + "type":"delta", + "data":{ + "GG": "test", + } + }` + + res, err := s.parseWebSocketEvent([]byte(input)) + assert.Error(t, err) + assert.Equal(t, nil, res) + }) +} + +func Test_convertSubscription(t *testing.T) { + s := Stream{} + t.Run("BookChannel.DepthLevel1", func(t *testing.T) { + res, err := s.convertSubscription(types.Subscription{ + Symbol: "BTCUSDT", + Channel: types.BookChannel, + Options: types.SubscribeOptions{ + Depth: types.DepthLevel1, + }, + }) + assert.NoError(t, err) + assert.Equal(t, genTopic(TopicTypeOrderBook, types.DepthLevel1, "BTCUSDT"), res) + }) + t.Run("BookChannel.DepthLevel50", func(t *testing.T) { + res, err := s.convertSubscription(types.Subscription{ + Symbol: "BTCUSDT", + Channel: types.BookChannel, + Options: types.SubscribeOptions{ + Depth: types.DepthLevel50, + }, + }) + assert.NoError(t, err) + assert.Equal(t, genTopic(TopicTypeOrderBook, types.DepthLevel50, "BTCUSDT"), res) + }) + t.Run("BookChannel.DepthLevel200", func(t *testing.T) { + res, err := s.convertSubscription(types.Subscription{ + Symbol: "BTCUSDT", + Channel: types.BookChannel, + Options: types.SubscribeOptions{ + Depth: types.DepthLevel200, + }, + }) + assert.NoError(t, err) + assert.Equal(t, genTopic(TopicTypeOrderBook, types.DepthLevel200, "BTCUSDT"), res) + }) + t.Run("BookChannel. with default depth", func(t *testing.T) { + res, err := s.convertSubscription(types.Subscription{ + Symbol: "BTCUSDT", + Channel: types.BookChannel, + }) + assert.NoError(t, err) + assert.Equal(t, genTopic(TopicTypeOrderBook, types.DepthLevel1, "BTCUSDT"), res) + }) + t.Run("BookChannel.DepthLevel50", func(t *testing.T) { + res, err := s.convertSubscription(types.Subscription{ + Symbol: "BTCUSDT", + Channel: types.BookChannel, + Options: types.SubscribeOptions{ + Depth: types.DepthLevel50, + }, + }) + assert.NoError(t, err) + assert.Equal(t, genTopic(TopicTypeOrderBook, types.DepthLevel50, "BTCUSDT"), res) + }) + t.Run("BookChannel. not support depth, use default level 1", func(t *testing.T) { + res, err := s.convertSubscription(types.Subscription{ + Symbol: "BTCUSDT", + Channel: types.BookChannel, + Options: types.SubscribeOptions{ + Depth: "20", + }, + }) + assert.NoError(t, err) + assert.Equal(t, genTopic(TopicTypeOrderBook, types.DepthLevel1, "BTCUSDT"), res) + }) + + t.Run("unsupported channel", func(t *testing.T) { + res, err := s.convertSubscription(types.Subscription{ + Symbol: "BTCUSDT", + Channel: "unsupported", + }) + assert.Equal(t, fmt.Errorf("unsupported stream channel: %s", "unsupported"), err) + assert.Equal(t, "", res) + }) + + t.Run("MarketTradeChannel", func(t *testing.T) { + res, err := s.convertSubscription(types.Subscription{ + Symbol: "BTCUSDT", + Channel: types.MarketTradeChannel, + Options: types.SubscribeOptions{}, + }) + assert.NoError(t, err) + assert.Equal(t, genTopic(TopicTypeMarketTrade, "BTCUSDT"), res) + }) +} diff --git a/pkg/exchange/bybit/types.go b/pkg/exchange/bybit/types.go new file mode 100644 index 0000000..263e2d4 --- /dev/null +++ b/pkg/exchange/bybit/types.go @@ -0,0 +1,430 @@ +package bybit + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bybit/bybitapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type WsEvent struct { + // "op" and "topic" are exclusive. + *WebSocketOpEvent + *WebSocketTopicEvent +} + +func (w *WsEvent) IsOp() bool { + return w.WebSocketOpEvent != nil && w.WebSocketTopicEvent == nil +} + +func (w *WsEvent) IsTopic() bool { + return w.WebSocketOpEvent == nil && w.WebSocketTopicEvent != nil +} + +type WsOpType string + +const ( + WsOpTypePing WsOpType = "ping" + WsOpTypePong WsOpType = "pong" + WsOpTypeAuth WsOpType = "auth" + WsOpTypeSubscribe WsOpType = "subscribe" + WsOpTypeUnsubscribe WsOpType = "unsubscribe" +) + +type WebsocketOp struct { + Op WsOpType `json:"op"` + Args []string `json:"args"` +} + +type WebSocketOpEvent struct { + Success bool `json:"success"` + RetMsg string `json:"ret_msg"` + ReqId string `json:"req_id,omitempty"` + + ConnId string `json:"conn_id"` + Op WsOpType `json:"op"` + Args []string `json:"args"` +} + +func (w *WebSocketOpEvent) IsValid() error { + switch w.Op { + case WsOpTypePing: + // public event + if !w.Success || WsOpType(w.RetMsg) != WsOpTypePong { + return fmt.Errorf("unexpected response result: %+v", w) + } + return nil + case WsOpTypePong: + // private event, no success and ret_msg fields in response + return nil + case WsOpTypeAuth: + if !w.Success || w.RetMsg != "" { + return fmt.Errorf("unexpected response result: %+v", w) + } + return nil + case WsOpTypeSubscribe: + // in the public channel, you can get RetMsg = 'subscribe', but in the private channel, you cannot. + // so, we only verify that success is true. + if !w.Success { + return fmt.Errorf("unexpected response result: %+v", w) + } + return nil + + case WsOpTypeUnsubscribe: + // in the public channel, you can get RetMsg = 'subscribe', but in the private channel, you cannot. + // so, we only verify that success is true. + if !w.Success { + return fmt.Errorf("unexpected response result: %+v", w) + } + return nil + + default: + return fmt.Errorf("unexpected op type: %+v", w) + } +} + +func (w *WebSocketOpEvent) toGlobalPongEventIfValid() (bool, *types.WebsocketPongEvent) { + if w.Op == WsOpTypePing || w.Op == WsOpTypePong { + return true, &types.WebsocketPongEvent{} + } + return false, nil +} + +func (w *WebSocketOpEvent) IsAuthenticated() bool { + return w.Op == WsOpTypeAuth && w.Success +} + +type TopicType string + +const ( + TopicTypeOrderBook TopicType = "orderbook" + TopicTypeMarketTrade TopicType = "publicTrade" + TopicTypeWallet TopicType = "wallet" + TopicTypeOrder TopicType = "order" + TopicTypeKLine TopicType = "kline" + TopicTypeTrade TopicType = "execution" +) + +type DataType string + +const ( + DataTypeSnapshot DataType = "snapshot" + DataTypeDelta DataType = "delta" +) + +type WebSocketTopicEvent struct { + Topic string `json:"topic"` + Type DataType `json:"type"` + // The timestamp (ms) that the system generates the data + Ts types.MillisecondTimestamp `json:"ts"` + Data json.RawMessage `json:"data"` +} + +type BookEvent struct { + // Symbol name + Symbol string `json:"s"` + // Bids. For snapshot stream, the element is sorted by price in descending order + Bids types.PriceVolumeSlice `json:"b"` + // Asks. For snapshot stream, the element is sorted by price in ascending order + Asks types.PriceVolumeSlice `json:"a"` + // Update ID. Is a sequence. Occasionally, you'll receive "u"=1, which is a snapshot data due to the restart of + // the service. So please overwrite your local orderbook + UpdateId fixedpoint.Value `json:"u"` + // Cross sequence. You can use this field to compare different levels orderbook data, and for the smaller seq, + // then it means the data is generated earlier. + SequenceId fixedpoint.Value `json:"seq"` + + // internal use + // Copied from WebSocketTopicEvent.Type, WebSocketTopicEvent.Ts + // Type can be one of snapshot or delta. + Type DataType + // ServerTime using the websocket timestamp as server time. Since the event not provide server time information. + ServerTime time.Time +} + +func (e *BookEvent) OrderBook() (snapshot types.SliceOrderBook) { + snapshot.Symbol = e.Symbol + snapshot.Bids = e.Bids + snapshot.Asks = e.Asks + snapshot.Time = e.ServerTime + return snapshot +} + +type MarketTradeEvent struct { + // Timestamp is the timestamp (ms) that the order is filled + Timestamp types.MillisecondTimestamp `json:"T"` + Symbol string `json:"s"` + // Side of taker. Buy,Sell + Side bybitapi.Side `json:"S"` + // Quantity is the trade size + Quantity fixedpoint.Value `json:"v"` + // Price is the trade price + Price fixedpoint.Value `json:"p"` + // L is the direction of price change. Unique field for future + Direction string `json:"L"` + // trade ID + TradeId string `json:"i"` + // Whether it is a block trade order or not + BlockTrade bool `json:"BT"` +} + +func (m *MarketTradeEvent) toGlobalTrade() (types.Trade, error) { + tradeId, err := strconv.ParseUint(m.TradeId, 10, 64) + if err != nil { + return types.Trade{}, fmt.Errorf("unexpected trade id: %s, err: %w", m.TradeId, err) + } + + side, err := toGlobalSideType(m.Side) + if err != nil { + return types.Trade{}, err + } + + return types.Trade{ + ID: tradeId, + OrderID: 0, // not supported + Exchange: types.ExchangeBybit, + Price: m.Price, + Quantity: m.Quantity, + QuoteQuantity: m.Price.Mul(m.Quantity), + Symbol: m.Symbol, + Side: side, + IsBuyer: side == types.SideTypeBuy, + IsMaker: false, // not supported + Time: types.Time(m.Timestamp.Time()), + Fee: fixedpoint.Zero, // not supported + FeeCurrency: "", // not supported + }, nil +} + +const topicSeparator = "." + +func genTopic(in ...interface{}) string { + out := make([]string, len(in)) + for k, v := range in { + out[k] = fmt.Sprintf("%v", v) + } + return strings.Join(out, topicSeparator) +} + +func getTopicType(topic string) TopicType { + slice := strings.Split(topic, topicSeparator) + if len(slice) == 0 { + return "" + } + return TopicType(slice[0]) +} + +func getSymbolFromTopic(topic string) (string, error) { + slice := strings.Split(topic, topicSeparator) + if len(slice) != 3 { + return "", fmt.Errorf("unexpected topic: %s", topic) + } + return slice[2], nil +} + +type OrderEvent struct { + bybitapi.Order + + Category bybitapi.Category `json:"category"` +} + +type KLineEvent struct { + KLines []KLine + + // internal use + // Type can be one of snapshot or delta. Copied from WebSocketTopicEvent.Type + Type DataType + // Symbol. Copied from WebSocketTopicEvent.Topic + Symbol string +} + +type KLine struct { + // The start timestamp (ms) + StartTime types.MillisecondTimestamp `json:"start"` + // The end timestamp (ms) + EndTime types.MillisecondTimestamp `json:"end"` + // Kline interval + Interval string `json:"interval"` + OpenPrice fixedpoint.Value `json:"open"` + ClosePrice fixedpoint.Value `json:"close"` + HighPrice fixedpoint.Value `json:"high"` + LowPrice fixedpoint.Value `json:"low"` + // Trade volume + Volume fixedpoint.Value `json:"volume"` + // Turnover. Unit of figure: quantity of quota coin + Turnover fixedpoint.Value `json:"turnover"` + // Weather the tick is ended or not + Confirm bool `json:"confirm"` + // The timestamp (ms) of the last matched order in the candle + Timestamp types.MillisecondTimestamp `json:"timestamp"` +} + +func (k *KLine) toGlobalKLine(symbol string) (types.KLine, error) { + interval, found := bybitapi.ToGlobalInterval[k.Interval] + if !found { + return types.KLine{}, fmt.Errorf("unexpected k line interval: %+v", k) + } + + return types.KLine{ + Exchange: types.ExchangeBybit, + Symbol: symbol, + StartTime: types.Time(k.StartTime.Time()), + EndTime: types.Time(k.EndTime.Time()), + Interval: interval, + Open: k.OpenPrice, + Close: k.ClosePrice, + High: k.HighPrice, + Low: k.LowPrice, + Volume: k.Volume, + QuoteVolume: k.Turnover, + Closed: k.Confirm, + }, nil +} + +type TradeEvent struct { + // linear and inverse order id format: 42f4f364-82e1-49d3-ad1d-cd8cf9aa308d (UUID format) + // spot: 1468264727470772736 (only numbers) + // we only use spot trading. + OrderId string `json:"orderId"` + OrderLinkId string `json:"orderLinkId"` + Category bybitapi.Category `json:"category"` + Symbol string `json:"symbol"` + ExecId string `json:"execId"` + ExecPrice fixedpoint.Value `json:"execPrice"` + ExecQty fixedpoint.Value `json:"execQty"` + + // Is maker order. true: maker, false: taker + IsMaker bool `json:"isMaker"` + // Paradigm block trade ID + BlockTradeId string `json:"blockTradeId"` + // Order type. Market,Limit + OrderType bybitapi.OrderType `json:"orderType"` + // Side. Buy,Sell + Side bybitapi.Side `json:"side"` + // Executed timestamp(ms) + ExecTime types.MillisecondTimestamp `json:"execTime"` + // Closed position size + ClosedSize fixedpoint.Value `json:"closedSize"` + + /* The following parameters do not support SPOT trading. */ + // Executed trading fee. You can get spot fee currency instruction here. Normal spot is not supported + ExecFee fixedpoint.Value `json:"execFee"` + // Executed type. Normal spot is not supported + ExecType string `json:"execType"` + // Executed order value. Normal spot is not supported + ExecValue fixedpoint.Value `json:"execValue"` + // Trading fee rate. Normal spot is not supported + FeeRate fixedpoint.Value `json:"feeRate"` + // The remaining qty not executed. Normal spot is not supported + LeavesQty fixedpoint.Value `json:"leavesQty"` + // Order price. Normal spot is not supported + OrderPrice fixedpoint.Value `json:"orderPrice"` + // Order qty. Normal spot is not supported + OrderQty fixedpoint.Value `json:"orderQty"` + // Stop order type. If the order is not stop order, any type is not returned. Normal spot is not supported + StopOrderType string `json:"stopOrderType"` + // Whether to borrow. Unified spot only. 0: false, 1: true. . Normal spot is not supported, always 0 + IsLeverage string `json:"isLeverage"` + // Implied volatility of mark price. Valid for option + MarkIv string `json:"markIv"` + // The mark price of the symbol when executing. Valid for option + MarkPrice fixedpoint.Value `json:"markPrice"` + // The index price of the symbol when executing. Valid for option + IndexPrice fixedpoint.Value `json:"indexPrice"` + // The underlying price of the symbol when executing. Valid for option + UnderlyingPrice fixedpoint.Value `json:"underlyingPrice"` + // Implied volatility. Valid for option + TradeIv string `json:"tradeIv"` +} + +func (t *TradeEvent) toGlobalTrade(symbolFee symbolFeeDetail) (*types.Trade, error) { + if t.Category != bybitapi.CategorySpot { + return nil, fmt.Errorf("unexected category: %s", t.Category) + } + + side, err := toGlobalSideType(t.Side) + if err != nil { + return nil, err + } + + orderIdNum, err := strconv.ParseUint(t.OrderId, 10, 64) + if err != nil { + return nil, fmt.Errorf("unexpected order id: %s, err: %w", t.OrderId, err) + } + + execIdNum, err := strconv.ParseUint(t.ExecId, 10, 64) + if err != nil { + return nil, fmt.Errorf("unexpected exec id: %s, err: %w", t.ExecId, err) + } + + trade := &types.Trade{ + ID: execIdNum, + OrderID: orderIdNum, + Exchange: types.ExchangeBybit, + Price: t.ExecPrice, + Quantity: t.ExecQty, + QuoteQuantity: t.ExecPrice.Mul(t.ExecQty), + Symbol: t.Symbol, + Side: side, + IsBuyer: side == types.SideTypeBuy, + IsMaker: t.IsMaker, + Time: types.Time(t.ExecTime), + Fee: fixedpoint.Zero, + FeeCurrency: "", + } + trade.FeeCurrency, trade.Fee = calculateFee(*t, symbolFee) + return trade, nil +} + +// CalculateFee given isMaker to get the fee currency and fee. +// https://bybit-exchange.github.io/docs/v5/enum#spot-fee-currency-instruction +// +// with the example of BTCUSDT: +// +// Is makerFeeRate positive? +// +// - TRUE +// Side = Buy -> base currency (BTC) +// Side = Sell -> quote currency (USDT) +// +// - FALSE +// IsMakerOrder = TRUE +// -> Side = Buy -> quote currency (USDT) +// -> Side = Sell -> base currency (BTC) +// +// IsMakerOrder = FALSE +// -> Side = Buy -> base currency (BTC) +// -> Side = Sell -> quote currency (USDT) +func calculateFee(t TradeEvent, feeDetail symbolFeeDetail) (string, fixedpoint.Value) { + if feeDetail.MakerFeeRate.Sign() > 0 || !t.IsMaker { + if t.Side == bybitapi.SideBuy { + return feeDetail.BaseCoin, baseCoinAsFee(t, feeDetail) + } + return feeDetail.QuoteCoin, quoteCoinAsFee(t, feeDetail) + } + + if t.Side == bybitapi.SideBuy { + return feeDetail.QuoteCoin, quoteCoinAsFee(t, feeDetail) + } + return feeDetail.BaseCoin, baseCoinAsFee(t, feeDetail) +} + +func baseCoinAsFee(t TradeEvent, feeDetail symbolFeeDetail) fixedpoint.Value { + if t.IsMaker { + return feeDetail.MakerFeeRate.Mul(t.ExecQty) + } + return feeDetail.TakerFeeRate.Mul(t.ExecQty) +} + +func quoteCoinAsFee(t TradeEvent, feeDetail symbolFeeDetail) fixedpoint.Value { + baseFee := t.ExecPrice.Mul(t.ExecQty) + if t.IsMaker { + return feeDetail.MakerFeeRate.Mul(baseFee) + } + return feeDetail.TakerFeeRate.Mul(baseFee) +} diff --git a/pkg/exchange/bybit/types_test.go b/pkg/exchange/bybit/types_test.go new file mode 100644 index 0000000..8fe6d15 --- /dev/null +++ b/pkg/exchange/bybit/types_test.go @@ -0,0 +1,889 @@ +package bybit + +import ( + "fmt" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bybit/bybitapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func Test_parseWebSocketEvent(t *testing.T) { + t.Run("[public] PingEvent without req id", func(t *testing.T) { + s := NewStream("", "", nil) + msg := `{"success":true,"ret_msg":"pong","conn_id":"a806f6c4-3608-4b6d-a225-9f5da975bc44","op":"ping"}` + raw, err := s.parseWebSocketEvent([]byte(msg)) + assert.NoError(t, err) + + e, ok := raw.(*types.WebsocketPongEvent) + assert.True(t, ok) + assert.Equal(t, &types.WebsocketPongEvent{}, e) + }) + + t.Run("[public] PingEvent with req id", func(t *testing.T) { + s := NewStream("", "", nil) + msg := `{"success":true,"ret_msg":"pong","conn_id":"a806f6c4-3608-4b6d-a225-9f5da975bc44","req_id":"b26704da-f5af-44c2-bdf7-935d6739e1a0","op":"ping"}` + raw, err := s.parseWebSocketEvent([]byte(msg)) + assert.NoError(t, err) + + e, ok := raw.(*types.WebsocketPongEvent) + assert.True(t, ok) + assert.Equal(t, &types.WebsocketPongEvent{}, e) + }) + + t.Run("[private] PingEvent without req id", func(t *testing.T) { + s := NewStream("", "", nil) + msg := `{"op":"pong","args":["1690884539181"],"conn_id":"civn4p1dcjmtvb69ome0-yrt1"}` + raw, err := s.parseWebSocketEvent([]byte(msg)) + assert.NoError(t, err) + + e, ok := raw.(*types.WebsocketPongEvent) + assert.True(t, ok) + assert.Equal(t, &types.WebsocketPongEvent{}, e) + }) + + t.Run("[private] PingEvent with req id", func(t *testing.T) { + s := NewStream("", "", nil) + msg := `{"req_id":"78d36b57-a142-47b7-9143-5843df77d44d","op":"pong","args":["1690884539181"],"conn_id":"civn4p1dcjmtvb69ome0-yrt1"}` + raw, err := s.parseWebSocketEvent([]byte(msg)) + assert.NoError(t, err) + + e, ok := raw.(*types.WebsocketPongEvent) + assert.True(t, ok) + assert.Equal(t, &types.WebsocketPongEvent{}, e) + }) +} + +func Test_WebSocketEventIsValid(t *testing.T) { + t.Run("[public] valid op ping", func(t *testing.T) { + expRetMsg := string(WsOpTypePong) + expReqId := "b26704da-f5af-44c2-bdf7-935d6739e1a0" + + w := &WebSocketOpEvent{ + Success: true, + RetMsg: expRetMsg, + ReqId: expReqId, + ConnId: "test-conndid", + Op: WsOpTypePing, + Args: nil, + } + assert.NoError(t, w.IsValid()) + }) + + t.Run("[private] valid op ping", func(t *testing.T) { + w := &WebSocketOpEvent{ + Success: false, + RetMsg: "", + ReqId: "", + ConnId: "test-conndid", + Op: WsOpTypePong, + Args: nil, + } + assert.NoError(t, w.IsValid()) + }) + + t.Run("[public] un-Success", func(t *testing.T) { + expRetMsg := string(WsOpTypePong) + expReqId := "b26704da-f5af-44c2-bdf7-935d6739e1a0" + + w := &WebSocketOpEvent{ + Success: false, + RetMsg: expRetMsg, + ReqId: expReqId, + ConnId: "test-conndid", + Op: WsOpTypePing, + Args: nil, + } + assert.Equal(t, fmt.Errorf("unexpected response result: %+v", w), w.IsValid()) + }) + + t.Run("[public] invalid ret msg", func(t *testing.T) { + expRetMsg := "PINGPONGPINGPONG" + expReqId := "b26704da-f5af-44c2-bdf7-935d6739e1a0" + + w := &WebSocketOpEvent{ + Success: false, + RetMsg: expRetMsg, + ReqId: expReqId, + ConnId: "test-conndid", + Op: WsOpTypePing, + Args: nil, + } + assert.Equal(t, fmt.Errorf("unexpected response result: %+v", w), w.IsValid()) + }) + + t.Run("[public] missing RetMsg field", func(t *testing.T) { + expReqId := "b26704da-f5af-44c2-bdf7-935d6739e1a0" + + w := &WebSocketOpEvent{ + ReqId: expReqId, + ConnId: "test-conndid", + Op: WsOpTypePing, + Args: nil, + } + assert.Equal(t, fmt.Errorf("unexpected response result: %+v", w), w.IsValid()) + }) + + t.Run("unexpected op type", func(t *testing.T) { + w := &WebSocketOpEvent{ + Op: WsOpType("unexpected"), + } + assert.Equal(t, fmt.Errorf("unexpected op type: %+v", w), w.IsValid()) + }) + + t.Run("[subscribe] valid with public channel", func(t *testing.T) { + expRetMsg := "subscribe" + w := &WebSocketOpEvent{ + Success: true, + RetMsg: expRetMsg, + ReqId: "", + ConnId: "test-conndid", + Op: WsOpTypeSubscribe, + Args: nil, + } + assert.NoError(t, w.IsValid()) + }) + + t.Run("[unsubscribe] valid with public channel", func(t *testing.T) { + expRetMsg := "subscribe" + w := &WebSocketOpEvent{ + Success: true, + RetMsg: expRetMsg, + ReqId: "", + ConnId: "test-conndid", + Op: WsOpTypeUnsubscribe, + Args: nil, + } + assert.NoError(t, w.IsValid()) + }) + + t.Run("[subscribe] valid with private channel", func(t *testing.T) { + w := &WebSocketOpEvent{ + Success: true, + RetMsg: "", + ReqId: "", + ConnId: "test-conndid", + Op: WsOpTypeSubscribe, + Args: nil, + } + assert.NoError(t, w.IsValid()) + }) + + t.Run("[subscribe] un-succeeds", func(t *testing.T) { + expRetMsg := "" + w := &WebSocketOpEvent{ + Success: false, + RetMsg: expRetMsg, + ReqId: "", + ConnId: "test-conndid", + Op: WsOpTypeSubscribe, + Args: nil, + } + assert.Equal(t, fmt.Errorf("unexpected response result: %+v", w), w.IsValid()) + }) + + t.Run("[unsubscribe] un-succeeds", func(t *testing.T) { + expRetMsg := "" + w := &WebSocketOpEvent{ + Success: false, + RetMsg: expRetMsg, + ReqId: "", + ConnId: "test-conndid", + Op: WsOpTypeUnsubscribe, + Args: nil, + } + assert.Equal(t, fmt.Errorf("unexpected response result: %+v", w), w.IsValid()) + }) + + t.Run("[auth] valid", func(t *testing.T) { + w := &WebSocketOpEvent{ + Success: true, + RetMsg: "", + ReqId: "", + ConnId: "test-conndid", + Op: WsOpTypeAuth, + Args: nil, + } + assert.NoError(t, w.IsValid()) + }) + + t.Run("[subscribe] un-succeeds", func(t *testing.T) { + expRetMsg := "invalid signature" + w := &WebSocketOpEvent{ + Success: false, + RetMsg: expRetMsg, + ReqId: "", + ConnId: "test-conndid", + Op: WsOpTypeAuth, + Args: nil, + } + assert.Equal(t, fmt.Errorf("unexpected response result: %+v", w), w.IsValid()) + }) +} + +func TestBookEvent_OrderBook(t *testing.T) { + t.Run("snapshot", func(t *testing.T) { + /* + { + "topic":"orderbook.50.BTCUSDT", + "ts":1691129753071, + "type":"snapshot", + "data":{ + "s":"BTCUSDT", + "b":[ + [ + "29230.81", + "4.713817" + ], + [ + "29230", + "0.1646" + ], + [ + "29229.92", + "0.036" + ], + ], + "a":[ + [ + "29230.82", + "2.745421" + ], + [ + "29231.41", + "1.6" + ], + [ + "29231.42", + "0.513654" + ], + ], + "u":1841364, + "seq":10558648910 + } + } + */ + event := &BookEvent{ + Symbol: "BTCUSDT", + Bids: types.PriceVolumeSlice{ + { + fixedpoint.NewFromFloat(29230.81), + fixedpoint.NewFromFloat(4.713817), + }, + { + fixedpoint.NewFromFloat(29230), + fixedpoint.NewFromFloat(0.1646), + }, + { + fixedpoint.NewFromFloat(29229.92), + fixedpoint.NewFromFloat(0.036), + }, + }, + Asks: types.PriceVolumeSlice{ + { + fixedpoint.NewFromFloat(29230.82), + fixedpoint.NewFromFloat(2.745421), + }, + { + fixedpoint.NewFromFloat(29231.41), + fixedpoint.NewFromFloat(1.6), + }, + { + fixedpoint.NewFromFloat(29231.42), + fixedpoint.NewFromFloat(0.513654), + }, + }, + UpdateId: fixedpoint.NewFromFloat(1841364), + SequenceId: fixedpoint.NewFromFloat(10558648910), + Type: DataTypeSnapshot, + } + + expSliceOrderBook := types.SliceOrderBook{ + Symbol: event.Symbol, + Bids: event.Bids, + Asks: event.Asks, + } + + assert.Equal(t, expSliceOrderBook, event.OrderBook()) + }) + t.Run("delta", func(t *testing.T) { + /* + { + "topic":"orderbook.50.BTCUSDT", + "ts":1691130685111, + "type":"delta", + "data":{ + "s":"BTCUSDT", + "b":[ + + ], + "a":[ + [ + "29239.37", + "0.082356" + ], + [ + "29236.1", + "0" + ] + ], + "u":1854104, + "seq":10559247733 + } + } + */ + event := &BookEvent{ + Symbol: "BTCUSDT", + Bids: types.PriceVolumeSlice{}, + Asks: types.PriceVolumeSlice{ + { + fixedpoint.NewFromFloat(29239.37), + fixedpoint.NewFromFloat(0.082356), + }, + { + fixedpoint.NewFromFloat(29236.1), + fixedpoint.NewFromFloat(0), + }, + }, + UpdateId: fixedpoint.NewFromFloat(1854104), + SequenceId: fixedpoint.NewFromFloat(10559247733), + Type: DataTypeDelta, + } + + expSliceOrderBook := types.SliceOrderBook{ + Symbol: event.Symbol, + Bids: types.PriceVolumeSlice{}, + Asks: event.Asks, + } + + assert.Equal(t, expSliceOrderBook, event.OrderBook()) + }) + +} + +func TestMarketTradeEvent_Trade(t *testing.T) { + qty := fixedpoint.NewFromFloat(0.002289) + price := fixedpoint.NewFromFloat(28829.7600) + tradeId := uint64(2290000000068683542) + tradeTime := types.NewMillisecondTimestampFromInt(1691486100000) + event := MarketTradeEvent{ + Timestamp: tradeTime, + Symbol: "BTCUSDT", + Side: bybitapi.SideSell, + Quantity: qty, + Price: price, + Direction: "", + TradeId: strconv.FormatUint(tradeId, 10), + BlockTrade: false, + } + t.Run("succeeds", func(t *testing.T) { + expEvent := types.Trade{ + ID: tradeId, + Exchange: types.ExchangeBybit, + Price: price, + Quantity: qty, + QuoteQuantity: price.Mul(qty), + Symbol: event.Symbol, + Side: types.SideTypeSell, + IsBuyer: false, + IsMaker: false, + Time: types.Time(tradeTime.Time()), + } + + trade, err := event.toGlobalTrade() + assert.NoError(t, err) + assert.Equal(t, expEvent, trade) + }) + + t.Run("invalid side", func(t *testing.T) { + newEvent := event + newEvent.Side = "invalid" + _, err := newEvent.toGlobalTrade() + assert.ErrorContains(t, err, "unexpected side") + }) + + t.Run("invalid trade id", func(t *testing.T) { + newEvent := event + newEvent.TradeId = "invalid" + _, err := newEvent.toGlobalTrade() + assert.ErrorContains(t, err, "unexpected trade id") + }) +} + +func Test_genTopicName(t *testing.T) { + exp := "orderbook.50.BTCUSDT" + assert.Equal(t, exp, genTopic(TopicTypeOrderBook, types.DepthLevel50, "BTCUSDT")) +} + +func Test_getTopicName(t *testing.T) { + exp := TopicTypeOrderBook + assert.Equal(t, exp, getTopicType("orderbook.50.BTCUSDT")) +} + +func Test_getSymbolFromTopic(t *testing.T) { + t.Run("succeeds", func(t *testing.T) { + exp := "BTCUSDT" + res, err := getSymbolFromTopic("kline.1.BTCUSDT") + assert.NoError(t, err) + assert.Equal(t, exp, res) + }) + + t.Run("unexpected topic", func(t *testing.T) { + res, err := getSymbolFromTopic("kline.1") + assert.Empty(t, res) + assert.Equal(t, err, fmt.Errorf("unexpected topic: kline.1")) + }) +} + +func TestKLine_toGlobalKLine(t *testing.T) { + t.Run("succeeds", func(t *testing.T) { + k := KLine{ + StartTime: types.NewMillisecondTimestampFromInt(1691486100000), + EndTime: types.NewMillisecondTimestampFromInt(1691487000000), + Interval: "1", + OpenPrice: fixedpoint.NewFromFloat(29045.3), + ClosePrice: fixedpoint.NewFromFloat(29228.56), + HighPrice: fixedpoint.NewFromFloat(29228.56), + LowPrice: fixedpoint.NewFromFloat(29045.3), + Volume: fixedpoint.NewFromFloat(9.265593), + Turnover: fixedpoint.NewFromFloat(270447.43520753), + Confirm: false, + Timestamp: types.NewMillisecondTimestampFromInt(1691486100000), + } + + gKline, err := k.toGlobalKLine("BTCUSDT") + assert.NoError(t, err) + + assert.Equal(t, types.KLine{ + Exchange: types.ExchangeBybit, + Symbol: "BTCUSDT", + StartTime: types.Time(k.StartTime.Time()), + EndTime: types.Time(k.EndTime.Time()), + Interval: types.Interval1m, + Open: fixedpoint.NewFromFloat(29045.3), + Close: fixedpoint.NewFromFloat(29228.56), + High: fixedpoint.NewFromFloat(29228.56), + Low: fixedpoint.NewFromFloat(29045.3), + Volume: fixedpoint.NewFromFloat(9.265593), + QuoteVolume: fixedpoint.NewFromFloat(270447.43520753), + Closed: false, + }, gKline) + }) + + t.Run("interval not supported", func(t *testing.T) { + k := KLine{ + StartTime: types.NewMillisecondTimestampFromInt(1691486100000), + EndTime: types.NewMillisecondTimestampFromInt(1691487000000), + Interval: "112", + OpenPrice: fixedpoint.NewFromFloat(29045.3), + ClosePrice: fixedpoint.NewFromFloat(29228.56), + HighPrice: fixedpoint.NewFromFloat(29228.56), + LowPrice: fixedpoint.NewFromFloat(29045.3), + Volume: fixedpoint.NewFromFloat(9.265593), + Turnover: fixedpoint.NewFromFloat(270447.43520753), + Confirm: false, + Timestamp: types.NewMillisecondTimestampFromInt(1691486100000), + } + + gKline, err := k.toGlobalKLine("BTCUSDT") + assert.Equal(t, fmt.Errorf("unexpected k line interval: %+v", &k), err) + assert.Equal(t, gKline, types.KLine{}) + }) +} + +func TestTradeEvent_toGlobalTrade(t *testing.T) { + /* + { + "category":"spot", + "symbol":"BTCUSDT", + "execFee":"", + "execId":"2100000000032905730", + "execPrice":"28829.7600", + "execQty":"0.002289", + "execType":"", + "execValue":"", + "isMaker":false, + "feeRate":"", + "tradeIv":"", + "markIv":"", + "blockTradeId":"", + "markPrice":"", + "indexPrice":"", + "underlyingPrice":"", + "leavesQty":"", + "orderId":"1482125285219500288", + "orderLinkId":"1691419101980", + "orderPrice":"", + "orderQty":"", + "orderType":"", + "stopOrderType":"", + "side":"Buy", + "execTime":"1691419102282", + "isLeverage":"0" + } + */ + t.Run("succeeds", func(t *testing.T) { + symbolFee := symbolFeeDetail{ + FeeRate: bybitapi.FeeRate{ + Symbol: "BTCUSDT", + TakerFeeRate: fixedpoint.NewFromFloat(0.001), + MakerFeeRate: fixedpoint.NewFromFloat(0.002), + }, + BaseCoin: "BTC", + QuoteCoin: "USDT", + } + qty := fixedpoint.NewFromFloat(0.002289) + price := fixedpoint.NewFromFloat(28829.7600) + timeNow := time.Now().Truncate(time.Second) + expTrade := &types.Trade{ + ID: 2100000000032905730, + OrderID: 1482125285219500288, + Exchange: types.ExchangeBybit, + Price: price, + Quantity: qty, + QuoteQuantity: qty.Mul(price), + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + IsBuyer: true, + IsMaker: false, + Time: types.Time(timeNow), + Fee: symbolFee.FeeRate.TakerFeeRate.Mul(qty), + FeeCurrency: "BTC", + } + tradeEvent := TradeEvent{ + OrderId: fmt.Sprintf("%d", expTrade.OrderID), + OrderLinkId: "1691419101980", + Category: "spot", + Symbol: expTrade.Symbol, + ExecId: fmt.Sprintf("%d", expTrade.ID), + ExecPrice: expTrade.Price, + ExecQty: expTrade.Quantity, + IsMaker: false, + BlockTradeId: "", + OrderType: "", + Side: bybitapi.SideBuy, + ExecTime: types.MillisecondTimestamp(timeNow), + ClosedSize: fixedpoint.NewFromInt(0), + ExecFee: fixedpoint.NewFromInt(0), + ExecType: "", + ExecValue: fixedpoint.NewFromInt(0), + FeeRate: fixedpoint.NewFromInt(0), + LeavesQty: fixedpoint.NewFromInt(0), + OrderPrice: fixedpoint.NewFromInt(0), + OrderQty: fixedpoint.NewFromInt(0), + StopOrderType: "", + IsLeverage: "0", + MarkIv: "", + MarkPrice: fixedpoint.NewFromInt(0), + IndexPrice: fixedpoint.NewFromInt(0), + UnderlyingPrice: fixedpoint.NewFromInt(0), + TradeIv: "", + } + + actualTrade, err := tradeEvent.toGlobalTrade(symbolFee) + assert.NoError(t, err) + assert.Equal(t, expTrade, actualTrade) + }) + + t.Run("unexpected category", func(t *testing.T) { + tradeEvent := TradeEvent{ + Category: "test-spot", + } + + actualTrade, err := tradeEvent.toGlobalTrade(symbolFeeDetail{}) + assert.Equal(t, fmt.Errorf("unexected category: %s", tradeEvent.Category), err) + assert.Nil(t, actualTrade) + }) + + t.Run("unexpected side", func(t *testing.T) { + tradeEvent := TradeEvent{ + Category: "spot", + Side: bybitapi.Side("BOTH"), + } + + actualTrade, err := tradeEvent.toGlobalTrade(symbolFeeDetail{}) + assert.Equal(t, fmt.Errorf("unexpected side: BOTH"), err) + assert.Nil(t, actualTrade) + }) + + t.Run("unexpected order id", func(t *testing.T) { + tradeEvent := TradeEvent{ + Category: "spot", + Side: bybitapi.SideBuy, + OrderId: "ABCD3123", + } + + _, nerr := strconv.ParseUint(tradeEvent.OrderId, 10, 64) + actualTrade, err := tradeEvent.toGlobalTrade(symbolFeeDetail{}) + assert.Equal(t, fmt.Errorf("unexpected order id: %s, err: %w", tradeEvent.OrderId, nerr), err) + assert.Nil(t, actualTrade) + }) + + t.Run("unexpected exec id", func(t *testing.T) { + tradeEvent := TradeEvent{ + Category: "spot", + Side: bybitapi.SideBuy, + OrderId: "3123", + ExecId: "ABC3123", + } + + _, nerr := strconv.ParseUint(tradeEvent.ExecId, 10, 64) + actualTrade, err := tradeEvent.toGlobalTrade(symbolFeeDetail{}) + assert.Equal(t, fmt.Errorf("unexpected exec id: %s, err: %w", tradeEvent.ExecId, nerr), err) + assert.Nil(t, actualTrade) + }) +} + +func TestTradeEvent_CalculateFee(t *testing.T) { + t.Run("maker fee positive, maker, buyer", func(t *testing.T) { + symbolFee := symbolFeeDetail{ + FeeRate: bybitapi.FeeRate{ + Symbol: "BTCUSDT", + TakerFeeRate: fixedpoint.NewFromFloat(0.001), + MakerFeeRate: fixedpoint.NewFromFloat(0.002), + }, + BaseCoin: "BTC", + QuoteCoin: "USDT", + } + + qty := fixedpoint.NewFromFloat(0.010000) + price := fixedpoint.NewFromFloat(28830.8100) + trade := &TradeEvent{ + ExecPrice: price, + ExecQty: qty, + IsMaker: true, + Side: bybitapi.SideBuy, + } + + feeCurrency, fee := calculateFee(*trade, symbolFee) + assert.Equal(t, feeCurrency, "BTC") + assert.Equal(t, fee, qty.Mul(symbolFee.FeeRate.MakerFeeRate)) + }) + + t.Run("maker fee positive, maker, seller", func(t *testing.T) { + symbolFee := symbolFeeDetail{ + FeeRate: bybitapi.FeeRate{ + Symbol: "BTCUSDT", + TakerFeeRate: fixedpoint.NewFromFloat(0.001), + MakerFeeRate: fixedpoint.NewFromFloat(0.002), + }, + BaseCoin: "BTC", + QuoteCoin: "USDT", + } + + qty := fixedpoint.NewFromFloat(0.010000) + price := fixedpoint.NewFromFloat(28830.8099) + trade := &TradeEvent{ + ExecPrice: price, + ExecQty: qty, + IsMaker: true, + Side: bybitapi.SideSell, + } + + feeCurrency, fee := calculateFee(*trade, symbolFee) + assert.Equal(t, feeCurrency, "USDT") + assert.Equal(t, fee, qty.Mul(price).Mul(symbolFee.FeeRate.MakerFeeRate)) + }) + + t.Run("maker fee positive, taker, buyer", func(t *testing.T) { + symbolFee := symbolFeeDetail{ + FeeRate: bybitapi.FeeRate{ + Symbol: "BTCUSDT", + TakerFeeRate: fixedpoint.NewFromFloat(0.001), + MakerFeeRate: fixedpoint.NewFromFloat(0.002), + }, + BaseCoin: "BTC", + QuoteCoin: "USDT", + } + + qty := fixedpoint.NewFromFloat(0.010000) + price := fixedpoint.NewFromFloat(28830.8100) + trade := &TradeEvent{ + ExecPrice: price, + ExecQty: qty, + IsMaker: false, + Side: bybitapi.SideBuy, + } + + feeCurrency, fee := calculateFee(*trade, symbolFee) + assert.Equal(t, feeCurrency, "BTC") + assert.Equal(t, fee, qty.Mul(symbolFee.FeeRate.TakerFeeRate)) + }) + + t.Run("maker fee positive, taker, seller", func(t *testing.T) { + symbolFee := symbolFeeDetail{ + FeeRate: bybitapi.FeeRate{ + Symbol: "BTCUSDT", + TakerFeeRate: fixedpoint.NewFromFloat(0.001), + MakerFeeRate: fixedpoint.NewFromFloat(0.002), + }, + BaseCoin: "BTC", + QuoteCoin: "USDT", + } + + qty := fixedpoint.NewFromFloat(0.010000) + price := fixedpoint.NewFromFloat(28830.8099) + trade := &TradeEvent{ + ExecPrice: price, + ExecQty: qty, + IsMaker: false, + Side: bybitapi.SideSell, + } + + feeCurrency, fee := calculateFee(*trade, symbolFee) + assert.Equal(t, feeCurrency, "USDT") + assert.Equal(t, fee, qty.Mul(price).Mul(symbolFee.FeeRate.TakerFeeRate)) + }) + + t.Run("maker fee negative, maker, buyer", func(t *testing.T) { + symbolFee := symbolFeeDetail{ + FeeRate: bybitapi.FeeRate{ + Symbol: "BTCUSDT", + TakerFeeRate: fixedpoint.NewFromFloat(-0.001), + MakerFeeRate: fixedpoint.NewFromFloat(-0.002), + }, + BaseCoin: "BTC", + QuoteCoin: "USDT", + } + + qty := fixedpoint.NewFromFloat(0.002289) + price := fixedpoint.NewFromFloat(28829.7600) + trade := &TradeEvent{ + ExecPrice: price, + ExecQty: qty, + IsMaker: true, + Side: bybitapi.SideBuy, + } + + feeCurrency, fee := calculateFee(*trade, symbolFee) + assert.Equal(t, feeCurrency, "USDT") + assert.Equal(t, fee, qty.Mul(price).Mul(symbolFee.FeeRate.MakerFeeRate)) + }) + + t.Run("maker fee negative, maker, seller", func(t *testing.T) { + symbolFee := symbolFeeDetail{ + FeeRate: bybitapi.FeeRate{ + Symbol: "BTCUSDT", + TakerFeeRate: fixedpoint.NewFromFloat(-0.001), + MakerFeeRate: fixedpoint.NewFromFloat(-0.002), + }, + BaseCoin: "BTC", + QuoteCoin: "USDT", + } + + qty := fixedpoint.NewFromFloat(0.002289) + price := fixedpoint.NewFromFloat(28829.7600) + trade := &TradeEvent{ + ExecPrice: price, + ExecQty: qty, + IsMaker: true, + Side: bybitapi.SideSell, + } + + feeCurrency, fee := calculateFee(*trade, symbolFee) + assert.Equal(t, feeCurrency, "BTC") + assert.Equal(t, fee, qty.Mul(symbolFee.FeeRate.MakerFeeRate)) + }) + + t.Run("maker fee negative, taker, buyer", func(t *testing.T) { + symbolFee := symbolFeeDetail{ + FeeRate: bybitapi.FeeRate{ + Symbol: "BTCUSDT", + TakerFeeRate: fixedpoint.NewFromFloat(-0.001), + MakerFeeRate: fixedpoint.NewFromFloat(-0.002), + }, + BaseCoin: "BTC", + QuoteCoin: "USDT", + } + + qty := fixedpoint.NewFromFloat(0.002289) + price := fixedpoint.NewFromFloat(28829.7600) + trade := &TradeEvent{ + ExecPrice: price, + ExecQty: qty, + IsMaker: false, + Side: bybitapi.SideBuy, + } + + feeCurrency, fee := calculateFee(*trade, symbolFee) + assert.Equal(t, feeCurrency, "BTC") + assert.Equal(t, fee, qty.Mul(symbolFee.FeeRate.TakerFeeRate)) + }) + + t.Run("maker fee negative, taker, seller", func(t *testing.T) { + symbolFee := symbolFeeDetail{ + FeeRate: bybitapi.FeeRate{ + Symbol: "BTCUSDT", + TakerFeeRate: fixedpoint.NewFromFloat(-0.001), + MakerFeeRate: fixedpoint.NewFromFloat(-0.002), + }, + BaseCoin: "BTC", + QuoteCoin: "USDT", + } + + qty := fixedpoint.NewFromFloat(0.002289) + price := fixedpoint.NewFromFloat(28829.7600) + trade := &TradeEvent{ + ExecPrice: price, + ExecQty: qty, + IsMaker: false, + Side: bybitapi.SideSell, + } + + feeCurrency, fee := calculateFee(*trade, symbolFee) + assert.Equal(t, feeCurrency, "USDT") + assert.Equal(t, fee, qty.Mul(price).Mul(symbolFee.FeeRate.TakerFeeRate)) + }) + +} + +func TestTradeEvent_baseCoinAsFee(t *testing.T) { + symbolFee := symbolFeeDetail{ + FeeRate: bybitapi.FeeRate{ + Symbol: "BTCUSDT", + TakerFeeRate: fixedpoint.NewFromFloat(0.001), + MakerFeeRate: fixedpoint.NewFromFloat(0.002), + }, + BaseCoin: "BTC", + QuoteCoin: "USDT", + } + qty := fixedpoint.NewFromFloat(0.002289) + price := fixedpoint.NewFromFloat(28829.7600) + trade := &TradeEvent{ + ExecPrice: price, + ExecQty: qty, + IsMaker: false, + } + assert.Equal(t, symbolFee.FeeRate.TakerFeeRate.Mul(qty), baseCoinAsFee(*trade, symbolFee)) + + trade.IsMaker = true + assert.Equal(t, symbolFee.FeeRate.MakerFeeRate.Mul(qty), baseCoinAsFee(*trade, symbolFee)) +} + +func TestTradeEvent_quoteCoinAsFee(t *testing.T) { + symbolFee := symbolFeeDetail{ + FeeRate: bybitapi.FeeRate{ + Symbol: "BTCUSDT", + TakerFeeRate: fixedpoint.NewFromFloat(0.001), + MakerFeeRate: fixedpoint.NewFromFloat(0.002), + }, + BaseCoin: "BTC", + QuoteCoin: "USDT", + } + qty := fixedpoint.NewFromFloat(0.002289) + price := fixedpoint.NewFromFloat(28829.7600) + trade := &TradeEvent{ + ExecPrice: price, + ExecQty: qty, + IsMaker: false, + } + assert.Equal(t, symbolFee.FeeRate.TakerFeeRate.Mul(qty.Mul(price)), quoteCoinAsFee(*trade, symbolFee)) + + trade.IsMaker = true + assert.Equal(t, symbolFee.FeeRate.MakerFeeRate.Mul(qty.Mul(price)), quoteCoinAsFee(*trade, symbolFee)) +} diff --git a/pkg/exchange/factory.go b/pkg/exchange/factory.go new file mode 100644 index 0000000..020779f --- /dev/null +++ b/pkg/exchange/factory.go @@ -0,0 +1,74 @@ +package exchange + +import ( + "fmt" + "os" + "strings" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/binance" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bitget" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bybit" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/kucoin" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/max" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/okex" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func NewPublic(exchangeName types.ExchangeName) (types.Exchange, error) { + exMinimal, err := New(exchangeName, "", "", "") + if err != nil { + return nil, err + } + + if ex, ok := exMinimal.(types.Exchange); ok { + return ex, nil + } + + return nil, fmt.Errorf("exchange %T does not implement types.Exchange", exMinimal) +} + +func New(n types.ExchangeName, key, secret, passphrase string) (types.ExchangeMinimal, error) { + switch n { + + case types.ExchangeBinance: + return binance.New(key, secret), nil + + case types.ExchangeMax: + return max.New(key, secret), nil + + case types.ExchangeOKEx: + return okex.New(key, secret, passphrase), nil + + case types.ExchangeKucoin: + return kucoin.New(key, secret, passphrase), nil + + case types.ExchangeBitget: + return bitget.New(key, secret, passphrase), nil + + case types.ExchangeBybit: + return bybit.New(key, secret) + + default: + return nil, fmt.Errorf("unsupported exchange: %v", n) + + } +} + +// NewWithEnvVarPrefix allocate and initialize the exchange instance with the given environment variable prefix +// When the varPrefix is a empty string, the default exchange name will be used as the prefix +func NewWithEnvVarPrefix(n types.ExchangeName, varPrefix string) (types.ExchangeMinimal, error) { + if len(varPrefix) == 0 { + varPrefix = n.String() + } + + varPrefix = strings.ToUpper(varPrefix) + + key := os.Getenv(varPrefix + "_API_KEY") + secret := os.Getenv(varPrefix + "_API_SECRET") + if len(key) == 0 || len(secret) == 0 { + return nil, fmt.Errorf("can not initialize exchange %s: empty key or secret, env var prefix: %s", n, varPrefix) + } + + passphrase := os.Getenv(varPrefix + "_API_PASSPHRASE") + return New(n, key, secret, passphrase) +} diff --git a/pkg/exchange/kucoin/convert.go b/pkg/exchange/kucoin/convert.go new file mode 100644 index 0000000..80b2188 --- /dev/null +++ b/pkg/exchange/kucoin/convert.go @@ -0,0 +1,246 @@ +package kucoin + +import ( + "fmt" + "hash/fnv" + "strings" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/kucoin/kucoinapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func toGlobalBalanceMap(accounts []kucoinapi.Account) types.BalanceMap { + balances := types.BalanceMap{} + + // for now, we only return the trading account + for _, account := range accounts { + switch account.Type { + case kucoinapi.AccountTypeTrade: + balances[account.Currency] = types.Balance{ + Currency: account.Currency, + Available: account.Available, + Locked: account.Holds, + } + } + } + + return balances +} + +func toGlobalSymbol(symbol string) string { + return strings.ReplaceAll(symbol, "-", "") +} + +func toGlobalMarket(m kucoinapi.Symbol) types.Market { + symbol := toGlobalSymbol(m.Symbol) + return types.Market{ + Symbol: symbol, + LocalSymbol: m.Symbol, + PricePrecision: m.PriceIncrement.NumFractionalDigits(), // convert 0.0001 to 4 + VolumePrecision: m.BaseIncrement.NumFractionalDigits(), + QuoteCurrency: m.QuoteCurrency, + BaseCurrency: m.BaseCurrency, + MinNotional: m.QuoteMinSize, + MinAmount: m.QuoteMinSize, + MinQuantity: m.BaseMinSize, + MaxQuantity: fixedpoint.Zero, // not used + StepSize: m.BaseIncrement, + + MinPrice: fixedpoint.Zero, // not used + MaxPrice: fixedpoint.Zero, // not used + TickSize: m.PriceIncrement, + } +} + +func toGlobalTicker(s kucoinapi.Ticker24H) types.Ticker { + return types.Ticker{ + Time: s.Time.Time(), + Volume: s.Volume, + Last: s.Last, + Open: s.Last.Sub(s.ChangePrice), + High: s.High, + Low: s.Low, + Buy: s.Buy, + Sell: s.Sell, + } +} + +func toLocalInterval(i types.Interval) string { + switch i { + case types.Interval1m: + return "1min" + + case types.Interval5m: + return "5min" + + case types.Interval15m: + return "15min" + + case types.Interval30m: + return "30min" + + case types.Interval1h: + return "1hour" + + case types.Interval2h: + return "2hour" + + case types.Interval4h: + return "4hour" + + case types.Interval6h: + return "6hour" + + case types.Interval12h: + return "12hour" + + case types.Interval1d: + return "1day" + + } + + return "1hour" +} + +// convertSubscriptions global subscription to local websocket command +func convertSubscriptions(ss []types.Subscription) ([]WebSocketCommand, error) { + var id = time.Now().UnixNano() / int64(time.Millisecond) + var cmds []WebSocketCommand + for _, s := range ss { + id++ + + var subscribeTopic string + switch s.Channel { + case types.BookChannel: + // see https://docs.kucoin.com/#level-2-market-data + subscribeTopic = "/market/level2" + ":" + toLocalSymbol(s.Symbol) + + case types.KLineChannel: + subscribeTopic = "/market/candles" + ":" + toLocalSymbol(s.Symbol) + "_" + toLocalInterval(types.Interval(s.Options.Interval)) + + default: + return nil, fmt.Errorf("websocket channel %s is not supported by kucoin", s.Channel) + } + + cmds = append(cmds, WebSocketCommand{ + Id: id, + Type: WebSocketMessageTypeSubscribe, + Topic: subscribeTopic, + PrivateChannel: false, + Response: true, + }) + } + + return cmds, nil +} + +func hashStringID(s string) uint64 { + h := fnv.New64a() + h.Write([]byte(s)) + return h.Sum64() +} + +func toGlobalOrderStatus(o kucoinapi.Order) types.OrderStatus { + var status types.OrderStatus + if o.IsActive { + status = types.OrderStatusNew + if o.DealSize.Sign() > 0 { + status = types.OrderStatusPartiallyFilled + } + } else if o.CancelExist { + status = types.OrderStatusCanceled + } else { + status = types.OrderStatusFilled + } + + return status +} + +func toGlobalSide(s string) types.SideType { + switch s { + case "buy": + return types.SideTypeBuy + case "sell": + return types.SideTypeSell + } + + return types.SideTypeSelf +} + +func toGlobalOrderType(s string) types.OrderType { + switch s { + case "limit": + return types.OrderTypeLimit + + case "stop_limit": + return types.OrderTypeStopLimit + + case "market": + return types.OrderTypeMarket + + case "stop_market": + return types.OrderTypeStopMarket + + } + + return "" +} + +func toLocalSide(side types.SideType) kucoinapi.SideType { + switch side { + case types.SideTypeBuy: + return kucoinapi.SideTypeBuy + + case types.SideTypeSell: + return kucoinapi.SideTypeSell + + } + + return "" +} + +func toGlobalOrder(o kucoinapi.Order) types.Order { + var status = toGlobalOrderStatus(o) + var order = types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: o.ClientOrderID, + Symbol: toGlobalSymbol(o.Symbol), + Side: toGlobalSide(o.Side), + Type: toGlobalOrderType(o.Type), + Quantity: o.Size, + Price: o.Price, + StopPrice: o.StopPrice, + TimeInForce: types.TimeInForce(o.TimeInForce), + }, + Exchange: types.ExchangeKucoin, + OrderID: hashStringID(o.ID), + UUID: o.ID, + Status: status, + ExecutedQuantity: o.DealSize, + IsWorking: o.IsActive, + CreationTime: types.Time(o.CreatedAt.Time()), + UpdateTime: types.Time(o.CreatedAt.Time()), // kucoin does not response updated time + } + return order +} + +func toGlobalTrade(fill kucoinapi.Fill) types.Trade { + var trade = types.Trade{ + ID: hashStringID(fill.TradeId), + OrderID: hashStringID(fill.OrderId), + Exchange: types.ExchangeKucoin, + Price: fill.Price, + Quantity: fill.Size, + QuoteQuantity: fill.Funds, + Symbol: toGlobalSymbol(fill.Symbol), + Side: toGlobalSide(string(fill.Side)), + IsBuyer: fill.Side == kucoinapi.SideTypeBuy, + IsMaker: fill.Liquidity == kucoinapi.LiquidityTypeMaker, + Time: types.Time(fill.CreatedAt.Time()), + Fee: fill.Fee, + FeeCurrency: toGlobalSymbol(fill.FeeCurrency), + } + return trade +} diff --git a/pkg/exchange/kucoin/exchange.go b/pkg/exchange/kucoin/exchange.go new file mode 100644 index 0000000..eed5ab6 --- /dev/null +++ b/pkg/exchange/kucoin/exchange.go @@ -0,0 +1,445 @@ +package kucoin + +import ( + "context" + "fmt" + "sort" + "strconv" + "time" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "go.uber.org/multierr" + "golang.org/x/time/rate" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/kucoin/kucoinapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +var marketDataLimiter = rate.NewLimiter(rate.Every(6*time.Second), 1) +var queryTradeLimiter = rate.NewLimiter(rate.Every(6*time.Second), 1) +var queryOrderLimiter = rate.NewLimiter(rate.Every(6*time.Second), 1) + +var ErrMissingSequence = errors.New("sequence is missing") + +// KCS is the platform currency of Kucoin, pre-allocate static string here +const KCS = "KCS" + +var log = logrus.WithFields(logrus.Fields{ + "exchange": "kucoin", +}) + +type Exchange struct { + key, secret, passphrase string + client *kucoinapi.RestClient +} + +func New(key, secret, passphrase string) *Exchange { + client := kucoinapi.NewClient() + + if len(key) > 0 && len(secret) > 0 && len(passphrase) > 0 { + client.Auth(key, secret, passphrase) + } + + return &Exchange{ + key: key, + // pragma: allowlist nextline secret + secret: secret, + passphrase: passphrase, + client: client, + } +} + +func (e *Exchange) Name() types.ExchangeName { + return types.ExchangeKucoin +} + +func (e *Exchange) PlatformFeeCurrency() string { + return KCS +} + +func (e *Exchange) QueryAccount(ctx context.Context) (*types.Account, error) { + req := e.client.AccountService.NewListAccountsRequest() + accounts, err := req.Do(ctx) + if err != nil { + return nil, err + } + + // for now, we only return the trading account + a := types.NewAccount() + balances := toGlobalBalanceMap(accounts) + a.UpdateBalances(balances) + return a, nil +} + +func (e *Exchange) QueryAccountBalances(ctx context.Context) (types.BalanceMap, error) { + req := e.client.AccountService.NewListAccountsRequest() + accounts, err := req.Do(ctx) + if err != nil { + return nil, err + } + + return toGlobalBalanceMap(accounts), nil +} + +func (e *Exchange) QueryMarkets(ctx context.Context) (types.MarketMap, error) { + markets, err := e.client.MarketDataService.ListSymbols() + if err != nil { + return nil, err + } + + marketMap := types.MarketMap{} + for _, s := range markets { + market := toGlobalMarket(s) + marketMap.Add(market) + } + + return marketMap, nil +} + +func (e *Exchange) QueryTicker(ctx context.Context, symbol string) (*types.Ticker, error) { + s, err := e.client.MarketDataService.GetTicker24HStat(symbol) + if err != nil { + return nil, err + } + + ticker := toGlobalTicker(*s) + return &ticker, nil +} + +func (e *Exchange) QueryTickers(ctx context.Context, symbols ...string) (map[string]types.Ticker, error) { + tickers := map[string]types.Ticker{} + if len(symbols) > 0 { + for _, s := range symbols { + t, err := e.QueryTicker(ctx, s) + if err != nil { + return nil, err + } + + tickers[s] = *t + } + + return tickers, nil + } + + allTickers, err := e.client.MarketDataService.ListTickers() + if err != nil { + return nil, err + } + + for _, s := range allTickers.Ticker { + tickers[s.Symbol] = toGlobalTicker(s) + } + + return tickers, nil +} + +// From the doc +// Type of candlestick patterns: 1min, 3min, 5min, 15min, 30min, 1hour, 2hour, 4hour, 6hour, 8hour, 12hour, 1day, 1week +var supportedIntervals = map[types.Interval]int{ + types.Interval1m: 1 * 60, + types.Interval5m: 5 * 60, + types.Interval15m: 15 * 60, + types.Interval30m: 30 * 60, + types.Interval1h: 60 * 60, + types.Interval2h: 60 * 60 * 2, + types.Interval4h: 60 * 60 * 4, + types.Interval6h: 60 * 60 * 6, + // types.Interval8h: 60 * 60 * 8, + types.Interval12h: 60 * 60 * 12, +} + +func (e *Exchange) SupportedInterval() map[types.Interval]int { + return supportedIntervals +} + +func (e *Exchange) IsSupportedInterval(interval types.Interval) bool { + _, ok := supportedIntervals[interval] + return ok +} + +func (e *Exchange) QueryKLines(ctx context.Context, symbol string, interval types.Interval, options types.KLineQueryOptions) ([]types.KLine, error) { + if err := marketDataLimiter.Wait(ctx); err != nil { + return nil, err + } + + req := e.client.MarketDataService.NewGetKLinesRequest() + req.Symbol(toLocalSymbol(symbol)) + req.Interval(toLocalInterval(interval)) + if options.StartTime != nil { + req.StartAt(*options.StartTime) + // For each query, the system would return at most **1500** pieces of data. To obtain more data, please page the data by time. + req.EndAt(options.StartTime.Add(1500 * interval.Duration())) + } else if options.EndTime != nil { + req.EndAt(*options.EndTime) + } + + ks, err := req.Do(ctx) + if err != nil { + return nil, err + } + + var klines []types.KLine + for _, k := range ks { + gi := toGlobalInterval(k.Interval) + klines = append(klines, types.KLine{ + Exchange: types.ExchangeKucoin, + Symbol: toGlobalSymbol(k.Symbol), + StartTime: types.Time(k.StartTime), + EndTime: types.Time(k.StartTime.Add(gi.Duration() - time.Millisecond)), + Interval: gi, + Open: k.Open, + Close: k.Close, + High: k.High, + Low: k.Low, + Volume: k.Volume, + QuoteVolume: k.QuoteVolume, + Closed: true, + }) + } + + sort.Slice(klines, func(i, j int) bool { + return klines[i].StartTime.Before(klines[j].StartTime.Time()) + }) + + return klines, nil +} + +func (e *Exchange) SubmitOrder(ctx context.Context, order types.SubmitOrder) (createdOrder *types.Order, err error) { + req := e.client.TradeService.NewPlaceOrderRequest() + req.Symbol(toLocalSymbol(order.Symbol)) + req.Side(toLocalSide(order.Side)) + + if order.ClientOrderID != "" { + req.ClientOrderID(order.ClientOrderID) + } + + if order.Market.Symbol != "" { + req.Size(order.Market.FormatQuantity(order.Quantity)) + } else { + // TODO: report error? + req.Size(order.Quantity.FormatString(8)) + } + + // set price field for limit orders + switch order.Type { + case types.OrderTypeStopLimit, types.OrderTypeLimit, types.OrderTypeLimitMaker: + if order.Market.Symbol != "" { + req.Price(order.Market.FormatPrice(order.Price)) + } else { + // TODO: report error? + req.Price(order.Price.FormatString(8)) + } + } + + if order.Type == types.OrderTypeLimitMaker { + req.PostOnly(true) + } + + switch order.TimeInForce { + case "FOK": + req.TimeInForce(kucoinapi.TimeInForceFOK) + case "IOC": + req.TimeInForce(kucoinapi.TimeInForceIOC) + default: + // default to GTC + req.TimeInForce(kucoinapi.TimeInForceGTC) + } + + switch order.Type { + case types.OrderTypeStopLimit: + req.OrderType(kucoinapi.OrderTypeStopLimit) + + case types.OrderTypeLimit, types.OrderTypeLimitMaker: + req.OrderType(kucoinapi.OrderTypeLimit) + + case types.OrderTypeMarket: + req.OrderType(kucoinapi.OrderTypeMarket) + } + + orderResponse, err := req.Do(ctx) + if err != nil { + return createdOrder, err + } + + return &types.Order{ + SubmitOrder: order, + Exchange: types.ExchangeKucoin, + OrderID: hashStringID(orderResponse.OrderID), + UUID: orderResponse.OrderID, + Status: types.OrderStatusNew, + ExecutedQuantity: fixedpoint.Zero, + IsWorking: true, + CreationTime: types.Time(time.Now()), + UpdateTime: types.Time(time.Now()), + }, nil +} + +// QueryOpenOrders +/* +Documentation from the Kucoin API page + +Any order on the exchange order book is in active status. +Orders removed from the order book will be marked with done status. +After an order becomes done, there may be a few milliseconds latency before it’s fully settled. + +You can check the orders in any status. +If the status parameter is not specified, orders of done status will be returned by default. + +When you query orders in active status, there is no time limit. +However, when you query orders in done status, the start and end time range cannot exceed 7* 24 hours. +An error will occur if the specified time window exceeds the range. + +If you specify the end time only, the system will automatically calculate the start time as end time minus 7*24 hours, and vice versa. + +The history for cancelled orders is only kept for one month. +You will not be able to query for cancelled orders that have happened more than a month ago. +*/ +func (e *Exchange) QueryOpenOrders(ctx context.Context, symbol string) (orders []types.Order, err error) { + req := e.client.TradeService.NewListOrdersRequest() + req.Symbol(toLocalSymbol(symbol)) + req.Status("active") + orderList, err := req.Do(ctx) + if err != nil { + return nil, err + } + + // TODO: support pagination (right now we can only get 50 items from the first page) + for _, o := range orderList.Items { + order := toGlobalOrder(o) + orders = append(orders, order) + } + + return orders, err +} + +func (e *Exchange) QueryClosedOrders(ctx context.Context, symbol string, since, until time.Time, lastOrderID uint64) (orders []types.Order, err error) { + req := e.client.TradeService.NewListOrdersRequest() + req.Symbol(toLocalSymbol(symbol)) + req.Status("done") + req.StartAt(since) + + // kucoin: + // When you query orders in active status, there is no time limit. + // However, when you query orders in done status, the start and end time range cannot exceed 7* 24 hours. + // An error will occur if the specified time window exceeds the range. + // If you specify the end time only, the system will automatically calculate the start time as end time minus 7*24 hours, and vice versa. + if until.Sub(since) < 7*24*time.Hour { + req.EndAt(until) + } + + if err := queryOrderLimiter.Wait(ctx); err != nil { + return nil, err + } + + orderList, err := req.Do(ctx) + if err != nil { + return orders, err + } + + for _, o := range orderList.Items { + order := toGlobalOrder(o) + orders = append(orders, order) + } + + return orders, err +} + +var launchDate = time.Date(2017, 9, 0, 0, 0, 0, 0, time.UTC) + +func (e *Exchange) QueryTrades(ctx context.Context, symbol string, options *types.TradeQueryOptions) (trades []types.Trade, err error) { + req := e.client.TradeService.NewGetFillsRequest() + req.Symbol(toLocalSymbol(symbol)) + + // we always sync trades in the ascending order, and kucoin does not support last trade ID query + // hence we need to set the start time here + if options.StartTime != nil && options.StartTime.Before(launchDate) { + // copy the time data object + t := launchDate + options.StartTime = &t + } + + if options.StartTime != nil && options.EndTime != nil { + req.StartAt(*options.StartTime) + + if options.EndTime.Sub(*options.StartTime) < 7*24*time.Hour { + req.EndAt(*options.EndTime) + } + } else if options.StartTime != nil { + req.StartAt(*options.StartTime) + } else if options.EndTime != nil { + req.EndAt(*options.EndTime) + } + + if err := queryTradeLimiter.Wait(ctx); err != nil { + return trades, err + } + + response, err := req.Do(ctx) + if err != nil { + return trades, err + } + + for _, fill := range response.Items { + trade := toGlobalTrade(fill) + trades = append(trades, trade) + } + + return trades, nil +} + +func (e *Exchange) CancelOrders(ctx context.Context, orders ...types.Order) (errs error) { + for _, o := range orders { + req := e.client.TradeService.NewCancelOrderRequest() + + if o.UUID != "" { + req.OrderID(o.UUID) + } else if o.ClientOrderID != "" { + req.ClientOrderID(o.ClientOrderID) + } else { + errs = multierr.Append( + errs, + fmt.Errorf("the order uuid or client order id is empty, order: %#v", o), + ) + continue + } + + response, err := req.Do(ctx) + if err != nil { + errs = multierr.Append(errs, err) + continue + } + + log.Infof("cancelled orders: %v", response.CancelledOrderIDs) + } + + return errors.Wrap(errs, "order cancel error") +} + +func (e *Exchange) NewStream() types.Stream { + return NewStream(e.client, e) +} + +func (e *Exchange) QueryDepth(ctx context.Context, symbol string) (types.SliceOrderBook, int64, error) { + orderBook, err := e.client.MarketDataService.GetOrderBook(toLocalSymbol(symbol), 100) + if err != nil { + return types.SliceOrderBook{}, 0, err + } + + if len(orderBook.Sequence) == 0 { + return types.SliceOrderBook{}, 0, ErrMissingSequence + } + + sequence, err := strconv.ParseInt(orderBook.Sequence, 10, 64) + if err != nil { + return types.SliceOrderBook{}, 0, err + } + + return types.SliceOrderBook{ + Symbol: toGlobalSymbol(symbol), + Time: orderBook.Time.Time(), + Bids: orderBook.Bids, + Asks: orderBook.Asks, + }, sequence, nil +} diff --git a/pkg/exchange/kucoin/generate_symbol_map.go b/pkg/exchange/kucoin/generate_symbol_map.go new file mode 100644 index 0000000..bcba8d7 --- /dev/null +++ b/pkg/exchange/kucoin/generate_symbol_map.go @@ -0,0 +1,77 @@ +//go:build ignore +// +build ignore + +package main + +import ( + "encoding/json" + "log" + "net/http" + "os" + "strings" + "text/template" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/kucoin/kucoinapi" +) + +var packageTemplate = template.Must(template.New("").Parse(`// Code generated by go generate; DO NOT EDIT. +package kucoin + +var symbolMap = map[string]string{ +{{- range $k, $v := . }} + {{ printf "%q" $k }}: {{ printf "%q" $v }}, +{{- end }} +} + +func toLocalSymbol(symbol string) string { + s, ok := symbolMap[symbol] + if ok { + return s + } + + return symbol +} +`)) + +type Market struct { + Symbol string `json:"symbol"` +} + +type ApiResponse struct { + Data []Market `json:"data"` +} + +func main() { + + const apiUrl = kucoinapi.RestBaseURL + "/v1/symbols" + + resp, err := http.Get(apiUrl) + if err != nil { + log.Fatal(err) + } + + defer resp.Body.Close() + + r := &ApiResponse{} + if err := json.NewDecoder(resp.Body).Decode(r); err != nil { + log.Fatal(err) + } + + var data = map[string]string{} + for _, m := range r.Data { + key := strings.ReplaceAll(strings.ToUpper(strings.TrimSpace(m.Symbol)), "-", "") + data[key] = m.Symbol + } + + f, err := os.Create("symbols.go") + if err != nil { + log.Fatal(err) + } + + defer f.Close() + + err = packageTemplate.Execute(f, data) + if err != nil { + log.Fatal(err) + } +} diff --git a/pkg/exchange/kucoin/kucoinapi/account.go b/pkg/exchange/kucoin/kucoinapi/account.go new file mode 100644 index 0000000..7571ff3 --- /dev/null +++ b/pkg/exchange/kucoin/kucoinapi/account.go @@ -0,0 +1,39 @@ +package kucoinapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +type AccountService struct { + client *RestClient +} + +func (s *AccountService) NewListSubAccountsRequest() *ListSubAccountsRequest { + return &ListSubAccountsRequest{client: s.client} +} + +type SubAccount struct { + UserID string `json:"userId"` + Name string `json:"subName"` + Type string `json:"type"` + Remark string `json:"remarks"` +} + +//go:generate GetRequest -url "/api/v1/sub/user" -type ListSubAccountsRequest -responseDataType []SubAccount +type ListSubAccountsRequest struct { + client requestgen.AuthenticatedAPIClient +} + +type Account struct { + ID string `json:"id"` + Currency string `json:"currency"` + Type AccountType `json:"type"` + Balance fixedpoint.Value `json:"balance"` + Available fixedpoint.Value `json:"available"` + Holds fixedpoint.Value `json:"holds"` +} diff --git a/pkg/exchange/kucoin/kucoinapi/bullet.go b/pkg/exchange/kucoin/kucoinapi/bullet.go new file mode 100644 index 0000000..0226fad --- /dev/null +++ b/pkg/exchange/kucoin/kucoinapi/bullet.go @@ -0,0 +1,67 @@ +package kucoinapi + +import ( + "net/url" + "time" + + "github.com/c9s/requestgen" + "github.com/pkg/errors" +) + +type BulletService struct { + client *RestClient +} + +func (s *BulletService) NewGetPublicBulletRequest() *GetPublicBulletRequest { + return &GetPublicBulletRequest{client: s.client} +} + +func (s *BulletService) NewGetPrivateBulletRequest() *GetPrivateBulletRequest { + return &GetPrivateBulletRequest{client: s.client} +} + +type Bullet struct { + InstanceServers []struct { + Endpoint string `json:"endpoint"` + Protocol string `json:"protocol"` + Encrypt bool `json:"encrypt"` + PingInterval int `json:"pingInterval"` + PingTimeout int `json:"pingTimeout"` + } `json:"instanceServers"` + Token string `json:"token"` +} + +func (b *Bullet) PingInterval() time.Duration { + return time.Duration(b.InstanceServers[0].PingInterval) * time.Millisecond +} + +func (b *Bullet) PingTimeout() time.Duration { + return time.Duration(b.InstanceServers[0].PingTimeout) * time.Millisecond +} + +func (b *Bullet) URL() (*url.URL, error) { + if len(b.InstanceServers) == 0 { + return nil, errors.New("InstanceServers is empty") + } + + u, err := url.Parse(b.InstanceServers[0].Endpoint) + if err != nil { + return nil, err + } + + params := url.Values{} + params.Add("token", b.Token) + + u.RawQuery = params.Encode() + return u, nil +} + +//go:generate requestgen -type GetPublicBulletRequest -method "POST" -url "/api/v1/bullet-public" -responseType .APIResponse -responseDataField Data -responseDataType .Bullet +type GetPublicBulletRequest struct { + client requestgen.APIClient +} + +//go:generate requestgen -type GetPrivateBulletRequest -method "POST" -url "/api/v1/bullet-private" -responseType .APIResponse -responseDataField Data -responseDataType .Bullet +type GetPrivateBulletRequest struct { + client requestgen.AuthenticatedAPIClient +} diff --git a/pkg/exchange/kucoin/kucoinapi/cancel_all_order_request_requestgen.go b/pkg/exchange/kucoin/kucoinapi/cancel_all_order_request_requestgen.go new file mode 100644 index 0000000..d6ad722 --- /dev/null +++ b/pkg/exchange/kucoin/kucoinapi/cancel_all_order_request_requestgen.go @@ -0,0 +1,143 @@ +// Code generated by "requestgen -method DELETE -responseType .APIResponse -responseDataField Data -url /api/v1/orders -type CancelAllOrderRequest -responseDataType .CancelOrderResponse"; DO NOT EDIT. + +package kucoinapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "regexp" +) + +func (r *CancelAllOrderRequest) Symbol(symbol string) *CancelAllOrderRequest { + r.symbol = &symbol + return r +} + +func (r *CancelAllOrderRequest) TradeType(tradeType string) *CancelAllOrderRequest { + r.tradeType = &tradeType + return r +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (r *CancelAllOrderRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (r *CancelAllOrderRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + if r.symbol != nil { + symbol := *r.symbol + + // assign parameter of symbol + params["symbol"] = symbol + } else { + } + // check tradeType field -> json key tradeType + if r.tradeType != nil { + tradeType := *r.tradeType + + // assign parameter of tradeType + params["tradeType"] = tradeType + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (r *CancelAllOrderRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := r.GetParameters() + if err != nil { + return query, err + } + + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (r *CancelAllOrderRequest) GetParametersJSON() ([]byte, error) { + params, err := r.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (r *CancelAllOrderRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (r *CancelAllOrderRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for k, v := range slugs { + needleRE := regexp.MustCompile(":" + k + "\\b") + url = needleRE.ReplaceAllString(url, v) + } + + return url +} + +func (r *CancelAllOrderRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := r.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for k, v := range params { + slugs[k] = fmt.Sprintf("%v", v) + } + + return slugs, nil +} + +func (r *CancelAllOrderRequest) Do(ctx context.Context) (*CancelOrderResponse, error) { + + params, err := r.GetParameters() + if err != nil { + return nil, err + } + query := url.Values{} + + apiURL := "/api/v1/orders" + + req, err := r.client.NewAuthenticatedRequest(ctx, "DELETE", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := r.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data CancelOrderResponse + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/kucoin/kucoinapi/cancel_order_request_requestgen.go b/pkg/exchange/kucoin/kucoinapi/cancel_order_request_requestgen.go new file mode 100644 index 0000000..e53a6cb --- /dev/null +++ b/pkg/exchange/kucoin/kucoinapi/cancel_order_request_requestgen.go @@ -0,0 +1,111 @@ +// Code generated by "requestgen -type CancelOrderRequest"; DO NOT EDIT. + +package kucoinapi + +import ( + "encoding/json" + "fmt" + "net/url" + "regexp" +) + +func (r *CancelOrderRequest) OrderID(orderID string) *CancelOrderRequest { + r.orderID = &orderID + return r +} + +func (r *CancelOrderRequest) ClientOrderID(clientOrderID string) *CancelOrderRequest { + r.clientOrderID = &clientOrderID + return r +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (r *CancelOrderRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (r *CancelOrderRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check orderID field -> json key orderID + if r.orderID != nil { + orderID := *r.orderID + + // assign parameter of orderID + params["orderID"] = orderID + } else { + } + // check clientOrderID field -> json key clientOrderID + if r.clientOrderID != nil { + clientOrderID := *r.clientOrderID + + // assign parameter of clientOrderID + params["clientOrderID"] = clientOrderID + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (r *CancelOrderRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := r.GetParameters() + if err != nil { + return query, err + } + + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (r *CancelOrderRequest) GetParametersJSON() ([]byte, error) { + params, err := r.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (r *CancelOrderRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (r *CancelOrderRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for k, v := range slugs { + needleRE := regexp.MustCompile(":" + k + "\\b") + url = needleRE.ReplaceAllString(url, v) + } + + return url +} + +func (r *CancelOrderRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := r.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for k, v := range params { + slugs[k] = fmt.Sprintf("%v", v) + } + + return slugs, nil +} diff --git a/pkg/exchange/kucoin/kucoinapi/client.go b/pkg/exchange/kucoin/kucoinapi/client.go new file mode 100644 index 0000000..6df4719 --- /dev/null +++ b/pkg/exchange/kucoin/kucoinapi/client.go @@ -0,0 +1,155 @@ +package kucoinapi + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/c9s/requestgen" + "github.com/pkg/errors" +) + +const defaultHTTPTimeout = time.Second * 15 +const RestBaseURL = "https://api.kucoin.com/api" +const SandboxRestBaseURL = "https://openapi-sandbox.kucoin.com/api" + +type RestClient struct { + requestgen.BaseAPIClient + + Key, Secret, Passphrase string + KeyVersion string + + AccountService *AccountService + MarketDataService *MarketDataService + TradeService *TradeService + BulletService *BulletService +} + +func NewClient() *RestClient { + u, err := url.Parse(RestBaseURL) + if err != nil { + panic(err) + } + + client := &RestClient{ + BaseAPIClient: requestgen.BaseAPIClient{ + BaseURL: u, + HttpClient: &http.Client{ + Timeout: defaultHTTPTimeout, + }, + }, + KeyVersion: "2", + } + + client.AccountService = &AccountService{client: client} + client.MarketDataService = &MarketDataService{client: client} + client.TradeService = &TradeService{client: client} + client.BulletService = &BulletService{client: client} + return client +} + +func (c *RestClient) Auth(key, secret, passphrase string) { + c.Key = key + // pragma: allowlist nextline secret + c.Secret = secret + c.Passphrase = passphrase +} + +// newAuthenticatedRequest creates new http request for authenticated routes. +func (c *RestClient) NewAuthenticatedRequest(ctx context.Context, method, refURL string, params url.Values, payload interface{}) (*http.Request, error) { + if len(c.Key) == 0 { + return nil, errors.New("empty api key") + } + + if len(c.Secret) == 0 { + return nil, errors.New("empty api secret") + } + + rel, err := url.Parse(refURL) + if err != nil { + return nil, err + } + + if params != nil { + rel.RawQuery = params.Encode() + } + + pathURL := c.BaseURL.ResolveReference(rel) + path := pathURL.Path + if rel.RawQuery != "" { + path += "?" + rel.RawQuery + } + + body, err := castPayload(payload) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, method, pathURL.String(), bytes.NewReader(body)) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Accept", "application/json") + + // Build authentication headers + c.attachAuthHeaders(req, method, path, body) + return req, nil +} + +func (c *RestClient) attachAuthHeaders(req *http.Request, method string, path string, body []byte) { + // Set location to UTC so that it outputs "2020-12-08T09:08:57.715Z" + t := time.Now().In(time.UTC) + // timestamp := t.Format("2006-01-02T15:04:05.999Z07:00") + timestamp := strconv.FormatInt(t.UnixNano()/int64(time.Millisecond), 10) + signKey := timestamp + strings.ToUpper(method) + path + string(body) + signature := sign(c.Secret, signKey) + + req.Header.Add("KC-API-KEY", c.Key) + req.Header.Add("KC-API-SIGN", signature) + req.Header.Add("KC-API-TIMESTAMP", timestamp) + req.Header.Add("KC-API-PASSPHRASE", sign(c.Secret, c.Passphrase)) + req.Header.Add("KC-API-KEY-VERSION", c.KeyVersion) +} + +// sign uses sha256 to sign the payload with the given secret +func sign(secret, payload string) string { + var sig = hmac.New(sha256.New, []byte(secret)) + _, err := sig.Write([]byte(payload)) + if err != nil { + return "" + } + + return base64.StdEncoding.EncodeToString(sig.Sum(nil)) +} + +func castPayload(payload interface{}) ([]byte, error) { + if payload == nil { + return nil, nil + } + + switch v := payload.(type) { + case string: + return []byte(v), nil + + case []byte: + return v, nil + + } + return json.Marshal(payload) +} + +type APIResponse struct { + Code string `json:"code"` + Message string `json:"msg"` + Data json.RawMessage `json:"data"` +} diff --git a/pkg/exchange/kucoin/kucoinapi/get_account_request.go b/pkg/exchange/kucoin/kucoinapi/get_account_request.go new file mode 100644 index 0000000..842dba7 --- /dev/null +++ b/pkg/exchange/kucoin/kucoinapi/get_account_request.go @@ -0,0 +1,16 @@ +package kucoinapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +import "github.com/c9s/requestgen" + +//go:generate GetRequest -url "/api/v1/accounts/:accountID" -type GetAccountRequest -responseDataType .Account +type GetAccountRequest struct { + client requestgen.AuthenticatedAPIClient + accountID string `param:"accountID,slug"` +} + +func (s *AccountService) NewGetAccountRequest(accountID string) *GetAccountRequest { + return &GetAccountRequest{client: s.client, accountID: accountID} +} diff --git a/pkg/exchange/kucoin/kucoinapi/get_account_request_requestgen.go b/pkg/exchange/kucoin/kucoinapi/get_account_request_requestgen.go new file mode 100644 index 0000000..5abda24 --- /dev/null +++ b/pkg/exchange/kucoin/kucoinapi/get_account_request_requestgen.go @@ -0,0 +1,131 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/v1/accounts/:accountID -type GetAccountRequest -responseDataType .Account"; DO NOT EDIT. + +package kucoinapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "regexp" +) + +func (g *GetAccountRequest) AccountID(accountID string) *GetAccountRequest { + g.accountID = accountID + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetAccountRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetAccountRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetAccountRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetAccountRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetAccountRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check accountID field -> json key accountID + accountID := g.accountID + + // assign parameter of accountID + params["accountID"] = accountID + + return params, nil +} + +func (g *GetAccountRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for k, v := range slugs { + needleRE := regexp.MustCompile(":" + k + "\\b") + url = needleRE.ReplaceAllString(url, v) + } + + return url +} + +func (g *GetAccountRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for k, v := range params { + slugs[k] = fmt.Sprintf("%v", v) + } + + return slugs, nil +} + +func (g *GetAccountRequest) Do(ctx context.Context) (*Account, error) { + + // no body params + var params interface{} + query := url.Values{} + + apiURL := "/api/v1/accounts/:accountID" + slugs, err := g.GetSlugsMap() + if err != nil { + return nil, err + } + + apiURL = g.applySlugsToUrl(apiURL, slugs) + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data Account + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/kucoin/kucoinapi/get_all_tickers_request_requestgen.go b/pkg/exchange/kucoin/kucoinapi/get_all_tickers_request_requestgen.go new file mode 100644 index 0000000..f633ac0 --- /dev/null +++ b/pkg/exchange/kucoin/kucoinapi/get_all_tickers_request_requestgen.go @@ -0,0 +1,115 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -type GetAllTickersRequest -url /api/v1/market/allTickers -responseDataType AllTickers"; DO NOT EDIT. + +package kucoinapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "regexp" +) + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetAllTickersRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetAllTickersRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetAllTickersRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetAllTickersRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetAllTickersRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetAllTickersRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for k, v := range slugs { + needleRE := regexp.MustCompile(":" + k + "\\b") + url = needleRE.ReplaceAllString(url, v) + } + + return url +} + +func (g *GetAllTickersRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for k, v := range params { + slugs[k] = fmt.Sprintf("%v", v) + } + + return slugs, nil +} + +func (g *GetAllTickersRequest) Do(ctx context.Context) (*AllTickers, error) { + + // no body params + var params interface{} + query := url.Values{} + + apiURL := "/api/v1/market/allTickers" + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data AllTickers + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/kucoin/kucoinapi/get_fills_request_requestgen.go b/pkg/exchange/kucoin/kucoinapi/get_fills_request_requestgen.go new file mode 100644 index 0000000..307d1dc --- /dev/null +++ b/pkg/exchange/kucoin/kucoinapi/get_fills_request_requestgen.go @@ -0,0 +1,239 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/v1/fills -type GetFillsRequest -responseDataType .FillListPage"; DO NOT EDIT. + +package kucoinapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "regexp" + "strconv" + "time" +) + +func (r *GetFillsRequest) OrderID(orderID string) *GetFillsRequest { + r.orderID = &orderID + return r +} + +func (r *GetFillsRequest) TradeType(tradeType string) *GetFillsRequest { + r.tradeType = &tradeType + return r +} + +func (r *GetFillsRequest) Symbol(symbol string) *GetFillsRequest { + r.symbol = &symbol + return r +} + +func (r *GetFillsRequest) Side(side string) *GetFillsRequest { + r.side = &side + return r +} + +func (r *GetFillsRequest) OrderType(orderType string) *GetFillsRequest { + r.orderType = &orderType + return r +} + +func (r *GetFillsRequest) StartAt(startAt time.Time) *GetFillsRequest { + r.startAt = &startAt + return r +} + +func (r *GetFillsRequest) EndAt(endAt time.Time) *GetFillsRequest { + r.endAt = &endAt + return r +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (r *GetFillsRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check orderID field -> json key orderId + if r.orderID != nil { + orderID := *r.orderID + + // assign parameter of orderID + params["orderId"] = orderID + } else { + } + // check tradeType field -> json key tradeType + if r.tradeType != nil { + tradeType := *r.tradeType + + // assign parameter of tradeType + params["tradeType"] = tradeType + } else { + tradeType := "TRADE" + + // assign parameter of tradeType + params["tradeType"] = tradeType + } + // check symbol field -> json key symbol + if r.symbol != nil { + symbol := *r.symbol + + // assign parameter of symbol + params["symbol"] = symbol + } else { + } + // check side field -> json key side + if r.side != nil { + side := *r.side + + // TEMPLATE check-valid-values + switch side { + case "buy", "sell": + params["side"] = side + + default: + return nil, fmt.Errorf("side value %v is invalid", side) + + } + // END TEMPLATE check-valid-values + + // assign parameter of side + params["side"] = side + } else { + } + // check orderType field -> json key type + if r.orderType != nil { + orderType := *r.orderType + + // TEMPLATE check-valid-values + switch orderType { + case "limit", "market", "limit_stop", "market_stop": + params["type"] = orderType + + default: + return nil, fmt.Errorf("type value %v is invalid", orderType) + + } + // END TEMPLATE check-valid-values + + // assign parameter of orderType + params["type"] = orderType + } else { + } + // check startAt field -> json key startAt + if r.startAt != nil { + startAt := *r.startAt + + // assign parameter of startAt + // convert time.Time to milliseconds time stamp + params["startAt"] = strconv.FormatInt(startAt.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endAt field -> json key endAt + if r.endAt != nil { + endAt := *r.endAt + + // assign parameter of endAt + // convert time.Time to milliseconds time stamp + params["endAt"] = strconv.FormatInt(endAt.UnixNano()/int64(time.Millisecond), 10) + } else { + } + + query := url.Values{} + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (r *GetFillsRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (r *GetFillsRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := r.GetParameters() + if err != nil { + return query, err + } + + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (r *GetFillsRequest) GetParametersJSON() ([]byte, error) { + params, err := r.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (r *GetFillsRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (r *GetFillsRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for k, v := range slugs { + needleRE := regexp.MustCompile(":" + k + "\\b") + url = needleRE.ReplaceAllString(url, v) + } + + return url +} + +func (r *GetFillsRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := r.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for k, v := range params { + slugs[k] = fmt.Sprintf("%v", v) + } + + return slugs, nil +} + +func (r *GetFillsRequest) Do(ctx context.Context) (*FillListPage, error) { + + // no body params + var params interface{} + query, err := r.GetQueryParameters() + if err != nil { + return nil, err + } + + apiURL := "/api/v1/fills" + + req, err := r.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := r.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data FillListPage + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/kucoin/kucoinapi/get_k_lines_request_requestgen.go b/pkg/exchange/kucoin/kucoinapi/get_k_lines_request_requestgen.go new file mode 100644 index 0000000..839f2fa --- /dev/null +++ b/pkg/exchange/kucoin/kucoinapi/get_k_lines_request_requestgen.go @@ -0,0 +1,142 @@ +// Code generated by "requestgen -type GetKLinesRequest"; DO NOT EDIT. + +package kucoinapi + +import ( + "encoding/json" + "fmt" + "net/url" + "regexp" + "strconv" + "time" +) + +func (r *GetKLinesRequest) Symbol(symbol string) *GetKLinesRequest { + r.symbol = symbol + return r +} + +func (r *GetKLinesRequest) Interval(interval string) *GetKLinesRequest { + r.interval = interval + return r +} + +func (r *GetKLinesRequest) StartAt(startAt time.Time) *GetKLinesRequest { + r.startAt = &startAt + return r +} + +func (r *GetKLinesRequest) EndAt(endAt time.Time) *GetKLinesRequest { + r.endAt = &endAt + return r +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (r *GetKLinesRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (r *GetKLinesRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := r.symbol + + // assign parameter of symbol + params["symbol"] = symbol + // check interval field -> json key type + interval := r.interval + + switch interval { + case "1min", "3min", "5min", "15min", "30min", "1hour", "2hour", "4hour", "6hour", "8hour", "12hour", "1day", "1week": + params["type"] = interval + + default: + return params, fmt.Errorf("type value %v is invalid", interval) + + } + + // assign parameter of interval + params["type"] = interval + // check startAt field -> json key startAt + if r.startAt != nil { + startAt := *r.startAt + + // assign parameter of startAt + // convert time.Time to seconds time stamp + params["startAt"] = strconv.FormatInt(startAt.Unix(), 10) + } + // check endAt field -> json key endAt + if r.endAt != nil { + endAt := *r.endAt + + // assign parameter of endAt + // convert time.Time to seconds time stamp + params["endAt"] = strconv.FormatInt(endAt.Unix(), 10) + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (r *GetKLinesRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := r.GetParameters() + if err != nil { + return query, err + } + + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (r *GetKLinesRequest) GetParametersJSON() ([]byte, error) { + params, err := r.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (r *GetKLinesRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (r *GetKLinesRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for k, v := range slugs { + needleRE := regexp.MustCompile(":" + k + "\\b") + url = needleRE.ReplaceAllString(url, v) + } + + return url +} + +func (r *GetKLinesRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := r.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for k, v := range params { + slugs[k] = fmt.Sprintf("%v", v) + } + + return slugs, nil +} diff --git a/pkg/exchange/kucoin/kucoinapi/get_order_book_level_2_depth_100_request_requestgen.go b/pkg/exchange/kucoin/kucoinapi/get_order_book_level_2_depth_100_request_requestgen.go new file mode 100644 index 0000000..7a77035 --- /dev/null +++ b/pkg/exchange/kucoin/kucoinapi/get_order_book_level_2_depth_100_request_requestgen.go @@ -0,0 +1,128 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -type GetOrderBookLevel2Depth100Request -url /api/v1/market/orderbook/level2_100 -responseDataType .OrderBook"; DO NOT EDIT. + +package kucoinapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "regexp" +) + +func (g *GetOrderBookLevel2Depth100Request) Symbol(symbol string) *GetOrderBookLevel2Depth100Request { + g.symbol = symbol + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetOrderBookLevel2Depth100Request) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := g.symbol + + // assign parameter of symbol + params["symbol"] = symbol + + query := url.Values{} + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetOrderBookLevel2Depth100Request) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetOrderBookLevel2Depth100Request) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetOrderBookLevel2Depth100Request) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetOrderBookLevel2Depth100Request) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetOrderBookLevel2Depth100Request) applySlugsToUrl(url string, slugs map[string]string) string { + for k, v := range slugs { + needleRE := regexp.MustCompile(":" + k + "\\b") + url = needleRE.ReplaceAllString(url, v) + } + + return url +} + +func (g *GetOrderBookLevel2Depth100Request) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for k, v := range params { + slugs[k] = fmt.Sprintf("%v", v) + } + + return slugs, nil +} + +func (g *GetOrderBookLevel2Depth100Request) Do(ctx context.Context) (*OrderBook, error) { + + // no body params + var params interface{} + query, err := g.GetQueryParameters() + if err != nil { + return nil, err + } + + apiURL := "/api/v1/market/orderbook/level2_100" + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data OrderBook + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/kucoin/kucoinapi/get_order_book_level_2_depth_20_request_requestgen.go b/pkg/exchange/kucoin/kucoinapi/get_order_book_level_2_depth_20_request_requestgen.go new file mode 100644 index 0000000..871a893 --- /dev/null +++ b/pkg/exchange/kucoin/kucoinapi/get_order_book_level_2_depth_20_request_requestgen.go @@ -0,0 +1,128 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -type GetOrderBookLevel2Depth20Request -url /api/v1/market/orderbook/level2_20 -responseDataType .OrderBook"; DO NOT EDIT. + +package kucoinapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "regexp" +) + +func (g *GetOrderBookLevel2Depth20Request) Symbol(symbol string) *GetOrderBookLevel2Depth20Request { + g.symbol = symbol + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetOrderBookLevel2Depth20Request) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := g.symbol + + // assign parameter of symbol + params["symbol"] = symbol + + query := url.Values{} + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetOrderBookLevel2Depth20Request) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetOrderBookLevel2Depth20Request) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetOrderBookLevel2Depth20Request) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetOrderBookLevel2Depth20Request) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetOrderBookLevel2Depth20Request) applySlugsToUrl(url string, slugs map[string]string) string { + for k, v := range slugs { + needleRE := regexp.MustCompile(":" + k + "\\b") + url = needleRE.ReplaceAllString(url, v) + } + + return url +} + +func (g *GetOrderBookLevel2Depth20Request) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for k, v := range params { + slugs[k] = fmt.Sprintf("%v", v) + } + + return slugs, nil +} + +func (g *GetOrderBookLevel2Depth20Request) Do(ctx context.Context) (*OrderBook, error) { + + // no body params + var params interface{} + query, err := g.GetQueryParameters() + if err != nil { + return nil, err + } + + apiURL := "/api/v1/market/orderbook/level2_20" + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data OrderBook + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/kucoin/kucoinapi/get_order_book_level_2_depth_all_request_requestgen.go b/pkg/exchange/kucoin/kucoinapi/get_order_book_level_2_depth_all_request_requestgen.go new file mode 100644 index 0000000..b71792b --- /dev/null +++ b/pkg/exchange/kucoin/kucoinapi/get_order_book_level_2_depth_all_request_requestgen.go @@ -0,0 +1,128 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -type GetOrderBookLevel2DepthAllRequest -url /api/v3/market/orderbook/level2 -responseDataType .OrderBook"; DO NOT EDIT. + +package kucoinapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "regexp" +) + +func (g *GetOrderBookLevel2DepthAllRequest) Symbol(symbol string) *GetOrderBookLevel2DepthAllRequest { + g.symbol = symbol + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetOrderBookLevel2DepthAllRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := g.symbol + + // assign parameter of symbol + params["symbol"] = symbol + + query := url.Values{} + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetOrderBookLevel2DepthAllRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetOrderBookLevel2DepthAllRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetOrderBookLevel2DepthAllRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetOrderBookLevel2DepthAllRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetOrderBookLevel2DepthAllRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for k, v := range slugs { + needleRE := regexp.MustCompile(":" + k + "\\b") + url = needleRE.ReplaceAllString(url, v) + } + + return url +} + +func (g *GetOrderBookLevel2DepthAllRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for k, v := range params { + slugs[k] = fmt.Sprintf("%v", v) + } + + return slugs, nil +} + +func (g *GetOrderBookLevel2DepthAllRequest) Do(ctx context.Context) (*OrderBook, error) { + + // no body params + var params interface{} + query, err := g.GetQueryParameters() + if err != nil { + return nil, err + } + + apiURL := "/api/v3/market/orderbook/level2" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data OrderBook + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/kucoin/kucoinapi/get_private_bullet_request_requestgen.go b/pkg/exchange/kucoin/kucoinapi/get_private_bullet_request_requestgen.go new file mode 100644 index 0000000..535d46e --- /dev/null +++ b/pkg/exchange/kucoin/kucoinapi/get_private_bullet_request_requestgen.go @@ -0,0 +1,115 @@ +// Code generated by "requestgen -type GetPrivateBulletRequest -method POST -url /api/v1/bullet-private -responseType .APIResponse -responseDataField Data -responseDataType .Bullet"; DO NOT EDIT. + +package kucoinapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "regexp" +) + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetPrivateBulletRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetPrivateBulletRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetPrivateBulletRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetPrivateBulletRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetPrivateBulletRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetPrivateBulletRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for k, v := range slugs { + needleRE := regexp.MustCompile(":" + k + "\\b") + url = needleRE.ReplaceAllString(url, v) + } + + return url +} + +func (g *GetPrivateBulletRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for k, v := range params { + slugs[k] = fmt.Sprintf("%v", v) + } + + return slugs, nil +} + +func (g *GetPrivateBulletRequest) Do(ctx context.Context) (*Bullet, error) { + + // no body params + var params interface{} + query := url.Values{} + + apiURL := "/api/v1/bullet-private" + + req, err := g.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data Bullet + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/kucoin/kucoinapi/get_public_bullet_request_requestgen.go b/pkg/exchange/kucoin/kucoinapi/get_public_bullet_request_requestgen.go new file mode 100644 index 0000000..c7b8aba --- /dev/null +++ b/pkg/exchange/kucoin/kucoinapi/get_public_bullet_request_requestgen.go @@ -0,0 +1,115 @@ +// Code generated by "requestgen -type GetPublicBulletRequest -method POST -url /api/v1/bullet-public -responseType .APIResponse -responseDataField Data -responseDataType .Bullet"; DO NOT EDIT. + +package kucoinapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "regexp" +) + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetPublicBulletRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetPublicBulletRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetPublicBulletRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetPublicBulletRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetPublicBulletRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetPublicBulletRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for k, v := range slugs { + needleRE := regexp.MustCompile(":" + k + "\\b") + url = needleRE.ReplaceAllString(url, v) + } + + return url +} + +func (g *GetPublicBulletRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for k, v := range params { + slugs[k] = fmt.Sprintf("%v", v) + } + + return slugs, nil +} + +func (g *GetPublicBulletRequest) Do(ctx context.Context) (*Bullet, error) { + + // no body params + var params interface{} + query := url.Values{} + + apiURL := "/api/v1/bullet-public" + + req, err := g.client.NewRequest(ctx, "POST", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data Bullet + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/kucoin/kucoinapi/get_ticker_request_requestgen.go b/pkg/exchange/kucoin/kucoinapi/get_ticker_request_requestgen.go new file mode 100644 index 0000000..761f725 --- /dev/null +++ b/pkg/exchange/kucoin/kucoinapi/get_ticker_request_requestgen.go @@ -0,0 +1,128 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -type GetTickerRequest -url /api/v1/market/orderbook/level1 -responseDataType Ticker"; DO NOT EDIT. + +package kucoinapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "regexp" +) + +func (g *GetTickerRequest) Symbol(symbol string) *GetTickerRequest { + g.symbol = symbol + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetTickerRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + symbol := g.symbol + + // assign parameter of symbol + params["symbol"] = symbol + + query := url.Values{} + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetTickerRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetTickerRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetTickerRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetTickerRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetTickerRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for k, v := range slugs { + needleRE := regexp.MustCompile(":" + k + "\\b") + url = needleRE.ReplaceAllString(url, v) + } + + return url +} + +func (g *GetTickerRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for k, v := range params { + slugs[k] = fmt.Sprintf("%v", v) + } + + return slugs, nil +} + +func (g *GetTickerRequest) Do(ctx context.Context) (*Ticker, error) { + + // no body params + var params interface{} + query, err := g.GetQueryParameters() + if err != nil { + return nil, err + } + + apiURL := "/api/v1/market/orderbook/level1" + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data Ticker + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/kucoin/kucoinapi/list_accounts_request.go b/pkg/exchange/kucoin/kucoinapi/list_accounts_request.go new file mode 100644 index 0000000..a07f493 --- /dev/null +++ b/pkg/exchange/kucoin/kucoinapi/list_accounts_request.go @@ -0,0 +1,15 @@ +package kucoinapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +import "github.com/c9s/requestgen" + +//go:generate GetRequest -url "/api/v1/accounts" -type ListAccountsRequest -responseDataType []Account +type ListAccountsRequest struct { + client requestgen.AuthenticatedAPIClient +} + +func (s *AccountService) NewListAccountsRequest() *ListAccountsRequest { + return &ListAccountsRequest{client: s.client} +} diff --git a/pkg/exchange/kucoin/kucoinapi/list_accounts_request_requestgen.go b/pkg/exchange/kucoin/kucoinapi/list_accounts_request_requestgen.go new file mode 100644 index 0000000..253f186 --- /dev/null +++ b/pkg/exchange/kucoin/kucoinapi/list_accounts_request_requestgen.go @@ -0,0 +1,115 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/v1/accounts -type ListAccountsRequest -responseDataType []Account"; DO NOT EDIT. + +package kucoinapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "regexp" +) + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (l *ListAccountsRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (l *ListAccountsRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (l *ListAccountsRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := l.GetParameters() + if err != nil { + return query, err + } + + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (l *ListAccountsRequest) GetParametersJSON() ([]byte, error) { + params, err := l.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (l *ListAccountsRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (l *ListAccountsRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for k, v := range slugs { + needleRE := regexp.MustCompile(":" + k + "\\b") + url = needleRE.ReplaceAllString(url, v) + } + + return url +} + +func (l *ListAccountsRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := l.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for k, v := range params { + slugs[k] = fmt.Sprintf("%v", v) + } + + return slugs, nil +} + +func (l *ListAccountsRequest) Do(ctx context.Context) ([]Account, error) { + + // no body params + var params interface{} + query := url.Values{} + + apiURL := "/api/v1/accounts" + + req, err := l.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := l.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data []Account + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/kucoin/kucoinapi/list_history_orders_request_requestgen.go b/pkg/exchange/kucoin/kucoinapi/list_history_orders_request_requestgen.go new file mode 100644 index 0000000..3653765 --- /dev/null +++ b/pkg/exchange/kucoin/kucoinapi/list_history_orders_request_requestgen.go @@ -0,0 +1,161 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/v1/hist-orders -type ListHistoryOrdersRequest -responseDataType .HistoryOrderListPage"; DO NOT EDIT. + +package kucoinapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "regexp" + "strconv" + "time" +) + +func (l *ListHistoryOrdersRequest) Symbol(symbol string) *ListHistoryOrdersRequest { + l.symbol = &symbol + return l +} + +func (l *ListHistoryOrdersRequest) StartAt(startAt time.Time) *ListHistoryOrdersRequest { + l.startAt = &startAt + return l +} + +func (l *ListHistoryOrdersRequest) EndAt(endAt time.Time) *ListHistoryOrdersRequest { + l.endAt = &endAt + return l +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (l *ListHistoryOrdersRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (l *ListHistoryOrdersRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check symbol field -> json key symbol + if l.symbol != nil { + symbol := *l.symbol + + // assign parameter of symbol + params["symbol"] = symbol + } else { + } + // check startAt field -> json key startAt + if l.startAt != nil { + startAt := *l.startAt + + // assign parameter of startAt + // convert time.Time to milliseconds time stamp + params["startAt"] = strconv.FormatInt(startAt.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endAt field -> json key endAt + if l.endAt != nil { + endAt := *l.endAt + + // assign parameter of endAt + // convert time.Time to milliseconds time stamp + params["endAt"] = strconv.FormatInt(endAt.UnixNano()/int64(time.Millisecond), 10) + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (l *ListHistoryOrdersRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := l.GetParameters() + if err != nil { + return query, err + } + + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (l *ListHistoryOrdersRequest) GetParametersJSON() ([]byte, error) { + params, err := l.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (l *ListHistoryOrdersRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (l *ListHistoryOrdersRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for k, v := range slugs { + needleRE := regexp.MustCompile(":" + k + "\\b") + url = needleRE.ReplaceAllString(url, v) + } + + return url +} + +func (l *ListHistoryOrdersRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := l.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for k, v := range params { + slugs[k] = fmt.Sprintf("%v", v) + } + + return slugs, nil +} + +func (l *ListHistoryOrdersRequest) Do(ctx context.Context) (*HistoryOrderListPage, error) { + + // empty params for GET operation + var params interface{} + query, err := l.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/api/v1/hist-orders" + + req, err := l.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := l.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data HistoryOrderListPage + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/kucoin/kucoinapi/list_orders_request_requestgen.go b/pkg/exchange/kucoin/kucoinapi/list_orders_request_requestgen.go new file mode 100644 index 0000000..900e220 --- /dev/null +++ b/pkg/exchange/kucoin/kucoinapi/list_orders_request_requestgen.go @@ -0,0 +1,239 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/v1/orders -type ListOrdersRequest -responseDataType .OrderListPage"; DO NOT EDIT. + +package kucoinapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "regexp" + "strconv" + "time" +) + +func (r *ListOrdersRequest) Status(status string) *ListOrdersRequest { + r.status = &status + return r +} + +func (r *ListOrdersRequest) Symbol(symbol string) *ListOrdersRequest { + r.symbol = &symbol + return r +} + +func (r *ListOrdersRequest) Side(side SideType) *ListOrdersRequest { + r.side = &side + return r +} + +func (r *ListOrdersRequest) OrderType(orderType OrderType) *ListOrdersRequest { + r.orderType = &orderType + return r +} + +func (r *ListOrdersRequest) TradeType(tradeType TradeType) *ListOrdersRequest { + r.tradeType = &tradeType + return r +} + +func (r *ListOrdersRequest) StartAt(startAt time.Time) *ListOrdersRequest { + r.startAt = &startAt + return r +} + +func (r *ListOrdersRequest) EndAt(endAt time.Time) *ListOrdersRequest { + r.endAt = &endAt + return r +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (r *ListOrdersRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (r *ListOrdersRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check status field -> json key status + if r.status != nil { + status := *r.status + + // TEMPLATE check-valid-values + switch status { + case "active", "done": + params["status"] = status + + default: + return nil, fmt.Errorf("status value %v is invalid", status) + + } + // END TEMPLATE check-valid-values + + // assign parameter of status + params["status"] = status + } else { + } + // check symbol field -> json key symbol + if r.symbol != nil { + symbol := *r.symbol + + // assign parameter of symbol + params["symbol"] = symbol + } else { + } + // check side field -> json key side + if r.side != nil { + side := *r.side + + // TEMPLATE check-valid-values + switch side { + case "buy", "sell": + params["side"] = side + + default: + return nil, fmt.Errorf("side value %v is invalid", side) + + } + // END TEMPLATE check-valid-values + + // assign parameter of side + params["side"] = side + } else { + } + // check orderType field -> json key type + if r.orderType != nil { + orderType := *r.orderType + + // assign parameter of orderType + params["type"] = orderType + } else { + } + // check tradeType field -> json key tradeType + if r.tradeType != nil { + tradeType := *r.tradeType + + // assign parameter of tradeType + params["tradeType"] = tradeType + } else { + tradeType := "TRADE" + + // assign parameter of tradeType + params["tradeType"] = tradeType + } + // check startAt field -> json key startAt + if r.startAt != nil { + startAt := *r.startAt + + // assign parameter of startAt + // convert time.Time to milliseconds time stamp + params["startAt"] = strconv.FormatInt(startAt.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endAt field -> json key endAt + if r.endAt != nil { + endAt := *r.endAt + + // assign parameter of endAt + // convert time.Time to milliseconds time stamp + params["endAt"] = strconv.FormatInt(endAt.UnixNano()/int64(time.Millisecond), 10) + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (r *ListOrdersRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := r.GetParameters() + if err != nil { + return query, err + } + + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (r *ListOrdersRequest) GetParametersJSON() ([]byte, error) { + params, err := r.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (r *ListOrdersRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (r *ListOrdersRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for k, v := range slugs { + needleRE := regexp.MustCompile(":" + k + "\\b") + url = needleRE.ReplaceAllString(url, v) + } + + return url +} + +func (r *ListOrdersRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := r.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for k, v := range params { + slugs[k] = fmt.Sprintf("%v", v) + } + + return slugs, nil +} + +func (r *ListOrdersRequest) Do(ctx context.Context) (*OrderListPage, error) { + + // empty params for GET operation + var params interface{} + query, err := r.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/api/v1/orders" + + req, err := r.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := r.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data OrderListPage + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/kucoin/kucoinapi/list_sub_accounts_request_requestgen.go b/pkg/exchange/kucoin/kucoinapi/list_sub_accounts_request_requestgen.go new file mode 100644 index 0000000..60494f6 --- /dev/null +++ b/pkg/exchange/kucoin/kucoinapi/list_sub_accounts_request_requestgen.go @@ -0,0 +1,115 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/v1/sub/user -type ListSubAccountsRequest -responseDataType []SubAccount"; DO NOT EDIT. + +package kucoinapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "regexp" +) + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (l *ListSubAccountsRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (l *ListSubAccountsRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (l *ListSubAccountsRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := l.GetParameters() + if err != nil { + return query, err + } + + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (l *ListSubAccountsRequest) GetParametersJSON() ([]byte, error) { + params, err := l.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (l *ListSubAccountsRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (l *ListSubAccountsRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for k, v := range slugs { + needleRE := regexp.MustCompile(":" + k + "\\b") + url = needleRE.ReplaceAllString(url, v) + } + + return url +} + +func (l *ListSubAccountsRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := l.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for k, v := range params { + slugs[k] = fmt.Sprintf("%v", v) + } + + return slugs, nil +} + +func (l *ListSubAccountsRequest) Do(ctx context.Context) ([]SubAccount, error) { + + // no body params + var params interface{} + query := url.Values{} + + apiURL := "/api/v1/sub/user" + + req, err := l.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := l.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data []SubAccount + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/kucoin/kucoinapi/list_symbols_request_requestgen.go b/pkg/exchange/kucoin/kucoinapi/list_symbols_request_requestgen.go new file mode 100644 index 0000000..fa4675c --- /dev/null +++ b/pkg/exchange/kucoin/kucoinapi/list_symbols_request_requestgen.go @@ -0,0 +1,127 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -type ListSymbolsRequest -url /api/v1/symbols -responseDataType []Symbol"; DO NOT EDIT. + +package kucoinapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "regexp" +) + +func (l *ListSymbolsRequest) Market(market string) *ListSymbolsRequest { + l.market = &market + return l +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (l *ListSymbolsRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (l *ListSymbolsRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check market field -> json key market + if l.market != nil { + market := *l.market + + // assign parameter of market + params["market"] = market + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (l *ListSymbolsRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := l.GetParameters() + if err != nil { + return query, err + } + + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (l *ListSymbolsRequest) GetParametersJSON() ([]byte, error) { + params, err := l.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (l *ListSymbolsRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (l *ListSymbolsRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for k, v := range slugs { + needleRE := regexp.MustCompile(":" + k + "\\b") + url = needleRE.ReplaceAllString(url, v) + } + + return url +} + +func (l *ListSymbolsRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := l.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for k, v := range params { + slugs[k] = fmt.Sprintf("%v", v) + } + + return slugs, nil +} + +func (l *ListSymbolsRequest) Do(ctx context.Context) ([]Symbol, error) { + + // empty params for GET operation + var params interface{} + query := url.Values{} + + apiURL := "/api/v1/symbols" + + req, err := l.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := l.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data []Symbol + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/kucoin/kucoinapi/marketdata.go b/pkg/exchange/kucoin/kucoinapi/marketdata.go new file mode 100644 index 0000000..db76754 --- /dev/null +++ b/pkg/exchange/kucoin/kucoinapi/marketdata.go @@ -0,0 +1,362 @@ +package kucoinapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +import ( + "context" + "encoding/json" + "net/url" + "strconv" + "time" + + "github.com/c9s/requestgen" + "github.com/pkg/errors" + "github.com/valyala/fastjson" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type MarketDataService struct { + client *RestClient +} + +func (s *MarketDataService) NewGetKLinesRequest() *GetKLinesRequest { + return &GetKLinesRequest{client: s.client} +} + +type Symbol struct { + Symbol string `json:"symbol"` + Name string `json:"name"` + BaseCurrency string `json:"baseCurrency"` + QuoteCurrency string `json:"quoteCurrency"` + FeeCurrency string `json:"feeCurrency"` + Market string `json:"market"` + BaseMinSize fixedpoint.Value `json:"baseMinSize"` + QuoteMinSize fixedpoint.Value `json:"quoteMinSize"` + BaseIncrement fixedpoint.Value `json:"baseIncrement"` + QuoteIncrement fixedpoint.Value `json:"quoteIncrement"` + PriceIncrement fixedpoint.Value `json:"priceIncrement"` + PriceLimitRate fixedpoint.Value `json:"priceLimitRate"` + IsMarginEnabled bool `json:"isMarginEnabled"` + EnableTrading bool `json:"enableTrading"` +} + +//go:generate GetRequest -type ListSymbolsRequest -url "/api/v1/symbols" -responseDataType []Symbol +type ListSymbolsRequest struct { + client requestgen.APIClient + market *string `param:"market"` +} + +func (s *MarketDataService) NewListSymbolsRequest() *ListSymbolsRequest { + return &ListSymbolsRequest{client: s.client} +} + +func (s *MarketDataService) ListSymbols(market ...string) ([]Symbol, error) { + req := s.NewListSymbolsRequest() + if len(market) == 1 { + req.Market(market[0]) + } else if len(market) > 1 { + return nil, errors.New("symbols api only supports one market parameter") + } + + return req.Do(context.Background()) +} + +/* +//Get Ticker + + { + "sequence": "1550467636704", + "bestAsk": "0.03715004", + "size": "0.17", + "price": "0.03715005", + "bestBidSize": "3.803", + "bestBid": "0.03710768", + "bestAskSize": "1.788", + "time": 1550653727731 + } +*/ +type Ticker struct { + Sequence string `json:"sequence"` + Size fixedpoint.Value `json:"size"` + Price fixedpoint.Value `json:"price"` + BestAsk fixedpoint.Value `json:"bestAsk"` + BestBid fixedpoint.Value `json:"bestBid"` + BestBidSize fixedpoint.Value `json:"bestBidSize"` + Time types.MillisecondTimestamp `json:"time"` +} + +//go:generate GetRequest -type GetTickerRequest -url "/api/v1/market/orderbook/level1" -responseDataType Ticker +type GetTickerRequest struct { + client requestgen.APIClient + symbol string `param:"symbol,query"` +} + +func (s *MarketDataService) NewGetTickerRequest(symbol string) *GetTickerRequest { + return &GetTickerRequest{client: s.client, symbol: symbol} +} + +/* +{ + "time":1602832092060, + "ticker":[ + { + "symbol": "BTC-USDT", // symbol + "symbolName":"BTC-USDT", // SymbolName of trading pairs, it would change after renaming + "buy": "11328.9", // bestAsk + "sell": "11329", // bestBid + "changeRate": "-0.0055", // 24h change rate + "changePrice": "-63.6", // 24h change price + "high": "11610", // 24h highest price + "low": "11200", // 24h lowest price + "vol": "2282.70993217", // 24h volume,the aggregated trading volume in BTC + "volValue": "25984946.157790431", // 24h total, the trading volume in quote currency of last 24 hours + "last": "11328.9", // last price + "averagePrice": "11360.66065903", // 24h average transaction price yesterday + "takerFeeRate": "0.001", // Basic Taker Fee + "makerFeeRate": "0.001", // Basic Maker Fee + "takerCoefficient": "1", // Taker Fee Coefficient + "makerCoefficient": "1" // Maker Fee Coefficient + } + ] +} +*/ + +type Ticker24H struct { + Symbol string `json:"symbol"` + SymbolName string `json:"symbolName"` + Buy fixedpoint.Value `json:"buy"` + Sell fixedpoint.Value `json:"sell"` + ChangeRate fixedpoint.Value `json:"changeRate"` + ChangePrice fixedpoint.Value `json:"changePrice"` + High fixedpoint.Value `json:"high"` + Low fixedpoint.Value `json:"low"` + Last fixedpoint.Value `json:"last"` + AveragePrice fixedpoint.Value `json:"averagePrice"` + Volume fixedpoint.Value `json:"vol"` // base volume + VolumeValue fixedpoint.Value `json:"volValue"` // quote volume + + TakerFeeRate fixedpoint.Value `json:"takerFeeRate"` + MakerFeeRate fixedpoint.Value `json:"makerFeeRate"` + + TakerCoefficient fixedpoint.Value `json:"takerCoefficient"` + MakerCoefficient fixedpoint.Value `json:"makerCoefficient"` + + Time types.MillisecondTimestamp `json:"time"` +} + +type AllTickers struct { + Time types.MillisecondTimestamp `json:"time"` + Ticker []Ticker24H `json:"ticker"` +} + +//go:generate GetRequest -type GetAllTickersRequest -url "/api/v1/market/allTickers" -responseDataType AllTickers +type GetAllTickersRequest struct { + client requestgen.APIClient +} + +func (s *MarketDataService) ListTickers() (*AllTickers, error) { + req := &GetAllTickersRequest{client: s.client} + return req.Do(context.Background()) +} + +func (s *MarketDataService) GetTicker24HStat(symbol string) (*Ticker24H, error) { + var params = url.Values{} + params.Add("symbol", symbol) + + req, err := s.client.NewRequest(context.Background(), "GET", "/api/v1/market/stats", params, nil) + if err != nil { + return nil, err + } + + response, err := s.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse struct { + Code string `json:"code"` + Message string `json:"msg"` + Data *Ticker24H `json:"data"` + } + + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + return apiResponse.Data, nil +} + +/* + { + "sequence": "3262786978", + "time": 1550653727731, + "bids": [["6500.12", "0.45054140"], + ["6500.11", "0.45054140"]], //[price,size] + "asks": [["6500.16", "0.57753524"], + ["6500.15", "0.57753524"]] + } +*/ +type OrderBook struct { + Sequence string `json:"sequence,omitempty"` + Time types.MillisecondTimestamp `json:"time"` + Bids types.PriceVolumeSlice `json:"bids,omitempty"` + Asks types.PriceVolumeSlice `json:"asks,omitempty"` +} + +//go:generate GetRequest -type GetOrderBookLevel2Depth20Request -url "/api/v1/market/orderbook/level2_20" -responseDataType .OrderBook +type GetOrderBookLevel2Depth20Request struct { + client requestgen.APIClient + symbol string `param:"symbol,query"` +} + +//go:generate GetRequest -type GetOrderBookLevel2Depth100Request -url "/api/v1/market/orderbook/level2_100" -responseDataType .OrderBook +type GetOrderBookLevel2Depth100Request struct { + client requestgen.APIClient + symbol string `param:"symbol,query"` +} + +//go:generate GetRequest -type GetOrderBookLevel2DepthAllRequest -url "/api/v3/market/orderbook/level2" -responseDataType .OrderBook +type GetOrderBookLevel2DepthAllRequest struct { + client requestgen.AuthenticatedAPIClient + symbol string `param:"symbol,query"` +} + +type OrderBookRequest interface { + Do(ctx context.Context) (*OrderBook, error) +} + +func (s *MarketDataService) NewGetOrderBookRequest(symbol string, depth int) OrderBookRequest { + switch depth { + case 20: + return &GetOrderBookLevel2Depth20Request{client: s.client, symbol: symbol} + + case 100: + return &GetOrderBookLevel2Depth100Request{client: s.client, symbol: symbol} + } + + return &GetOrderBookLevel2DepthAllRequest{client: s.client, symbol: symbol} +} + +func (s *MarketDataService) GetOrderBook(symbol string, depth int) (*OrderBook, error) { + req := s.NewGetOrderBookRequest(symbol, depth) + return req.Do(context.Background()) +} + +//go:generate requestgen -type GetKLinesRequest +type GetKLinesRequest struct { + client *RestClient + + symbol string `param:"symbol"` + + interval string `param:"type" validValues:"1min,3min,5min,15min,30min,1hour,2hour,4hour,6hour,8hour,12hour,1day,1week"` + + startAt *time.Time `param:"startAt,seconds"` + + endAt *time.Time `param:"endAt,seconds"` +} + +type KLine struct { + Symbol string + Interval string + StartTime time.Time + Open fixedpoint.Value + High fixedpoint.Value + Low fixedpoint.Value + Close fixedpoint.Value + Volume, QuoteVolume fixedpoint.Value +} + +func (r *GetKLinesRequest) Do(ctx context.Context) ([]KLine, error) { + params, err := r.GetParametersQuery() + if err != nil { + return nil, err + } + + req, err := r.client.NewRequest(ctx, "GET", "/api/v1/market/candles", params, nil) + if err != nil { + return nil, err + } + + response, err := r.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse struct { + Code string `json:"code"` + Message string `json:"msg"` + Data json.RawMessage `json:"data"` + } + + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + if apiResponse.Data == nil { + return nil, errors.New("api error: [" + apiResponse.Code + "] " + apiResponse.Message) + } + + return parseKLines(apiResponse.Data, r.symbol, r.interval) +} + +func parseKLines(b []byte, symbol, interval string) (klines []KLine, err error) { + s, err := fastjson.ParseBytes(b) + if err != nil { + return klines, err + } + + for _, v := range s.GetArray() { + arr := v.GetArray() + ts, err := strconv.ParseInt(string(arr[0].GetStringBytes()), 10, 64) + if err != nil { + return klines, err + } + + o, err := fixedpoint.NewFromString(string(arr[1].GetStringBytes())) + if err != nil { + return klines, err + } + + c, err := fixedpoint.NewFromString(string(arr[2].GetStringBytes())) + if err != nil { + return klines, err + } + + h, err := fixedpoint.NewFromString(string(arr[3].GetStringBytes())) + if err != nil { + return klines, err + } + + l, err := fixedpoint.NewFromString(string(arr[4].GetStringBytes())) + if err != nil { + return klines, err + } + + vv, err := fixedpoint.NewFromString(string(arr[5].GetStringBytes())) + if err != nil { + return klines, err + } + + qv, err := fixedpoint.NewFromString(string(arr[6].GetStringBytes())) + if err != nil { + return klines, err + } + + klines = append(klines, KLine{ + Symbol: symbol, + Interval: interval, + StartTime: time.Unix(ts, 0), + Open: o, + High: h, + Low: l, + Close: c, + Volume: vv, + QuoteVolume: qv, + }) + } + + return klines, err +} diff --git a/pkg/exchange/kucoin/kucoinapi/place_order_request_requestgen.go b/pkg/exchange/kucoin/kucoinapi/place_order_request_requestgen.go new file mode 100644 index 0000000..50b7557 --- /dev/null +++ b/pkg/exchange/kucoin/kucoinapi/place_order_request_requestgen.go @@ -0,0 +1,251 @@ +// Code generated by "requestgen -method POST -responseType .APIResponse -responseDataField Data -url /api/v1/orders -type PlaceOrderRequest -responseDataType .OrderResponse"; DO NOT EDIT. + +package kucoinapi + +import ( + "context" + "encoding/json" + "fmt" + "github.com/google/uuid" + "net/url" + "regexp" +) + +func (r *PlaceOrderRequest) ClientOrderID(clientOrderID string) *PlaceOrderRequest { + r.clientOrderID = &clientOrderID + return r +} + +func (r *PlaceOrderRequest) Symbol(symbol string) *PlaceOrderRequest { + r.symbol = symbol + return r +} + +func (r *PlaceOrderRequest) Tag(tag string) *PlaceOrderRequest { + r.tag = &tag + return r +} + +func (r *PlaceOrderRequest) Side(side SideType) *PlaceOrderRequest { + r.side = side + return r +} + +func (r *PlaceOrderRequest) OrderType(orderType OrderType) *PlaceOrderRequest { + r.orderType = orderType + return r +} + +func (r *PlaceOrderRequest) Size(size string) *PlaceOrderRequest { + r.size = size + return r +} + +func (r *PlaceOrderRequest) Price(price string) *PlaceOrderRequest { + r.price = &price + return r +} + +func (r *PlaceOrderRequest) TimeInForce(timeInForce TimeInForceType) *PlaceOrderRequest { + r.timeInForce = &timeInForce + return r +} + +func (r *PlaceOrderRequest) PostOnly(postOnly bool) *PlaceOrderRequest { + r.postOnly = &postOnly + return r +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (r *PlaceOrderRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (r *PlaceOrderRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check clientOrderID field -> json key clientOid + if r.clientOrderID != nil { + clientOrderID := *r.clientOrderID + + // TEMPLATE check-required + if len(clientOrderID) == 0 { + return nil, fmt.Errorf("clientOid is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of clientOrderID + params["clientOid"] = clientOrderID + } else { + // assign default of clientOrderID + clientOrderID := uuid.New().String() + // assign parameter of clientOrderID + params["clientOid"] = clientOrderID + } + // check symbol field -> json key symbol + symbol := r.symbol + + // TEMPLATE check-required + if len(symbol) == 0 { + return nil, fmt.Errorf("symbol is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of symbol + params["symbol"] = symbol + // check tag field -> json key tag + if r.tag != nil { + tag := *r.tag + + // assign parameter of tag + params["tag"] = tag + } else { + } + // check side field -> json key side + side := r.side + + // assign parameter of side + params["side"] = side + // check orderType field -> json key ordType + orderType := r.orderType + + // assign parameter of orderType + params["ordType"] = orderType + // check size field -> json key size + size := r.size + + // TEMPLATE check-required + if len(size) == 0 { + return nil, fmt.Errorf("size is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of size + params["size"] = size + // check price field -> json key price + if r.price != nil { + price := *r.price + + // assign parameter of price + params["price"] = price + } else { + } + // check timeInForce field -> json key timeInForce + if r.timeInForce != nil { + timeInForce := *r.timeInForce + + // TEMPLATE check-required + if len(timeInForce) == 0 { + return nil, fmt.Errorf("timeInForce is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of timeInForce + params["timeInForce"] = timeInForce + } else { + } + // check postOnly field -> json key postOnly + if r.postOnly != nil { + postOnly := *r.postOnly + + // assign parameter of postOnly + params["postOnly"] = postOnly + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (r *PlaceOrderRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := r.GetParameters() + if err != nil { + return query, err + } + + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (r *PlaceOrderRequest) GetParametersJSON() ([]byte, error) { + params, err := r.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (r *PlaceOrderRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (r *PlaceOrderRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for k, v := range slugs { + needleRE := regexp.MustCompile(":" + k + "\\b") + url = needleRE.ReplaceAllString(url, v) + } + + return url +} + +func (r *PlaceOrderRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := r.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for k, v := range params { + slugs[k] = fmt.Sprintf("%v", v) + } + + return slugs, nil +} + +func (r *PlaceOrderRequest) Do(ctx context.Context) (*OrderResponse, error) { + + params, err := r.GetParameters() + if err != nil { + return nil, err + } + query := url.Values{} + + apiURL := "/api/v1/orders" + + req, err := r.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := r.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data OrderResponse + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/pkg/exchange/kucoin/kucoinapi/trade.go b/pkg/exchange/kucoin/kucoinapi/trade.go new file mode 100644 index 0000000..d54e83c --- /dev/null +++ b/pkg/exchange/kucoin/kucoinapi/trade.go @@ -0,0 +1,337 @@ +package kucoinapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data +//go:generate -command DeleteRequest requestgen -method DELETE -responseType .APIResponse -responseDataField Data + +import ( + "context" + "time" + + "github.com/c9s/requestgen" + "github.com/pkg/errors" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type TradeService struct { + client *RestClient +} + +type OrderResponse struct { + OrderID string `json:"orderId"` +} + +func (c *TradeService) NewListHistoryOrdersRequest() *ListHistoryOrdersRequest { + return &ListHistoryOrdersRequest{client: c.client} + +} + +func (c *TradeService) NewPlaceOrderRequest() *PlaceOrderRequest { + return &PlaceOrderRequest{client: c.client} +} + +func (c *TradeService) NewBatchPlaceOrderRequest() *BatchPlaceOrderRequest { + return &BatchPlaceOrderRequest{client: c.client} +} + +func (c *TradeService) NewCancelOrderRequest() *CancelOrderRequest { + return &CancelOrderRequest{client: c.client} +} + +func (c *TradeService) NewCancelAllOrderRequest() *CancelAllOrderRequest { + return &CancelAllOrderRequest{client: c.client} +} + +func (c *TradeService) NewGetFillsRequest() *GetFillsRequest { + return &GetFillsRequest{client: c.client} +} + +//go:generate GetRequest -url /api/v1/fills -type GetFillsRequest -responseDataType .FillListPage +type GetFillsRequest struct { + client requestgen.AuthenticatedAPIClient + + orderID *string `param:"orderId"` + + tradeType *string `param:"tradeType" default:"TRADE"` + + symbol *string `param:"symbol"` + + side *string `param:"side" validValues:"buy,sell"` + + orderType *string `param:"type" validValues:"limit,market,limit_stop,market_stop"` + + startAt *time.Time `param:"startAt,milliseconds"` + + endAt *time.Time `param:"endAt,milliseconds"` +} + +type FillListPage struct { + CurrentPage int `json:"currentPage"` + PageSize int `json:"pageSize"` + TotalNumber int `json:"totalNum"` + TotalPage int `json:"totalPage"` + Items []Fill `json:"items"` +} + +type Fill struct { + Symbol string `json:"symbol"` + TradeId string `json:"tradeId"` + OrderId string `json:"orderId"` + CounterOrderId string `json:"counterOrderId"` + Side SideType `json:"side"` + Liquidity LiquidityType `json:"liquidity"` + ForceTaker bool `json:"forceTaker"` + Price fixedpoint.Value `json:"price"` + Size fixedpoint.Value `json:"size"` + Funds fixedpoint.Value `json:"funds"` + Fee fixedpoint.Value `json:"fee"` + FeeRate fixedpoint.Value `json:"feeRate"` + FeeCurrency string `json:"feeCurrency"` + Stop string `json:"stop"` + Type OrderType `json:"type"` + CreatedAt types.MillisecondTimestamp `json:"createdAt"` + TradeType TradeType `json:"tradeType"` +} + +//go:generate GetRequest -url /api/v1/hist-orders -type ListHistoryOrdersRequest -responseDataType .HistoryOrderListPage +type ListHistoryOrdersRequest struct { + client requestgen.AuthenticatedAPIClient + + symbol *string `param:"symbol"` + + startAt *time.Time `param:"startAt,milliseconds"` + + endAt *time.Time `param:"endAt,milliseconds"` +} + +type HistoryOrder struct { + Symbol string `json:"symbol"` + DealPrice string `json:"dealPrice"` + DealValue string `json:"dealValue"` + Amount string `json:"amount"` + Fee string `json:"fee"` + Side string `json:"side"` + CreatedAt int `json:"createdAt"` +} + +type HistoryOrderListPage struct { + CurrentPage int `json:"currentPage"` + PageSize int `json:"pageSize"` + TotalNum int `json:"totalNum"` + TotalPage int `json:"totalPage"` + Items []HistoryOrder `json:"items"` +} + +//go:generate GetRequest -url /api/v1/orders -type ListOrdersRequest -responseDataType .OrderListPage +type ListOrdersRequest struct { + client requestgen.AuthenticatedAPIClient + + status *string `param:"status" validValues:"active,done"` + + symbol *string `param:"symbol"` + + side *SideType `param:"side" validValues:"buy,sell"` + + orderType *OrderType `param:"type"` + + tradeType *TradeType `param:"tradeType" default:"TRADE"` + + startAt *time.Time `param:"startAt,milliseconds"` + + endAt *time.Time `param:"endAt,milliseconds"` +} + +type Order struct { + ID string `json:"id"` + Symbol string `json:"symbol"` + OperationType string `json:"opType"` + Type string `json:"type"` + Side string `json:"side"` + Price fixedpoint.Value `json:"price"` + Size fixedpoint.Value `json:"size"` + Funds fixedpoint.Value `json:"funds"` + DealFunds fixedpoint.Value `json:"dealFunds"` + DealSize fixedpoint.Value `json:"dealSize"` + Fee fixedpoint.Value `json:"fee"` + FeeCurrency string `json:"feeCurrency"` + StopType string `json:"stop"` + StopTriggerred bool `json:"stopTriggered"` + StopPrice fixedpoint.Value `json:"stopPrice"` + TimeInForce TimeInForceType `json:"timeInForce"` + PostOnly bool `json:"postOnly"` + Hidden bool `json:"hidden"` + Iceberg bool `json:"iceberg"` + Channel string `json:"channel"` + ClientOrderID string `json:"clientOid"` + Remark string `json:"remark"` + IsActive bool `json:"isActive"` + CancelExist bool `json:"cancelExist"` + CreatedAt types.MillisecondTimestamp `json:"createdAt"` +} + +type OrderListPage struct { + CurrentPage int `json:"currentPage"` + PageSize int `json:"pageSize"` + TotalNumber int `json:"totalNum"` + TotalPage int `json:"totalPage"` + Items []Order `json:"items"` +} + +func (c *TradeService) NewListOrdersRequest() *ListOrdersRequest { + return &ListOrdersRequest{client: c.client} +} + +//go:generate PostRequest -url /api/v1/orders -type PlaceOrderRequest -responseDataType .OrderResponse +type PlaceOrderRequest struct { + client requestgen.AuthenticatedAPIClient + + // A combination of case-sensitive alphanumerics, all numbers, or all letters of up to 32 characters. + clientOrderID *string `param:"clientOid,required" defaultValuer:"uuid()"` + + symbol string `param:"symbol,required"` + + // A combination of case-sensitive alphanumerics, all numbers, or all letters of up to 8 characters. + tag *string `param:"tag"` + + // "buy" or "sell" + side SideType `param:"side"` + + orderType OrderType `param:"ordType"` + + // limit order parameters + size string `param:"size,required"` + + price *string `param:"price"` + + timeInForce *TimeInForceType `param:"timeInForce,required"` + + postOnly *bool `param:"postOnly"` +} + +type CancelOrderResponse struct { + CancelledOrderIDs []string `json:"cancelledOrderIds,omitempty"` + + // used when using client order id for canceling order + CancelledOrderId string `json:"cancelledOrderId,omitempty"` + ClientOrderID string `json:"clientOid,omitempty"` +} + +//go:generate requestgen -type CancelOrderRequest +type CancelOrderRequest struct { + client requestgen.AuthenticatedAPIClient + + orderID *string `param:"orderID"` + clientOrderID *string `param:"clientOrderID"` +} + +func (r *CancelOrderRequest) Do(ctx context.Context) (*CancelOrderResponse, error) { + if r.orderID == nil && r.clientOrderID == nil { + return nil, errors.New("either orderID or clientOrderID is required for canceling order") + } + + var refURL string + + if r.orderID != nil { + refURL = "/api/v1/orders/" + *r.orderID + } else if r.clientOrderID != nil { + refURL = "/api/v1/order/client-order/" + *r.clientOrderID + } + + req, err := r.client.NewAuthenticatedRequest(ctx, "DELETE", refURL, nil, nil) + if err != nil { + return nil, err + } + + response, err := r.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse struct { + Code string `json:"code"` + Message string `json:"msg"` + Data *CancelOrderResponse `json:"data"` + } + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + if apiResponse.Data == nil { + return nil, errors.New("api error: [" + apiResponse.Code + "] " + apiResponse.Message) + } + + return apiResponse.Data, nil +} + +//go:generate DeleteRequest -url /api/v1/orders -type CancelAllOrderRequest -responseDataType .CancelOrderResponse +type CancelAllOrderRequest struct { + client requestgen.AuthenticatedAPIClient + + symbol *string `param:"symbol"` + tradeType *string `param:"tradeType"` +} + +// Request via this endpoint to place 5 orders at the same time. +// The order type must be a limit order of the same symbol. +// The interface currently only supports spot trading +type BatchPlaceOrderRequest struct { + client *RestClient + + symbol string + reqs []*PlaceOrderRequest +} + +func (r *BatchPlaceOrderRequest) Symbol(symbol string) *BatchPlaceOrderRequest { + r.symbol = symbol + return r +} + +func (r *BatchPlaceOrderRequest) Add(reqs ...*PlaceOrderRequest) *BatchPlaceOrderRequest { + r.reqs = append(r.reqs, reqs...) + return r +} + +func (r *BatchPlaceOrderRequest) Do(ctx context.Context) ([]OrderResponse, error) { + var orderList []map[string]interface{} + for _, req := range r.reqs { + params, err := req.GetParameters() + if err != nil { + return nil, err + } + + orderList = append(orderList, params) + } + + var payload = map[string]interface{}{ + "symbol": r.symbol, + "orderList": orderList, + } + + req, err := r.client.NewAuthenticatedRequest(ctx, "POST", "/api/v1/orders/multi", nil, payload) + if err != nil { + return nil, err + } + + response, err := r.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse struct { + Code string `json:"code"` + Message string `json:"msg"` + Data []OrderResponse `json:"data"` + } + + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + if apiResponse.Data == nil { + return nil, errors.New("api error: [" + apiResponse.Code + "] " + apiResponse.Message) + } + + return apiResponse.Data, nil +} diff --git a/pkg/exchange/kucoin/kucoinapi/types.go b/pkg/exchange/kucoin/kucoinapi/types.go new file mode 100644 index 0000000..90294be --- /dev/null +++ b/pkg/exchange/kucoin/kucoinapi/types.go @@ -0,0 +1,64 @@ +package kucoinapi + +type AccountType string + +const ( + AccountTypeMain AccountType = "main" + AccountTypeTrade AccountType = "trade" + AccountTypeMargin AccountType = "margin" + AccountTypePool AccountType = "pool" +) + +type TradeType string + +const ( + TradeTypeSpot TradeType = "TRADE" + TradeTypeMargin TradeType = "MARGIN" +) + +type SideType string + +const ( + SideTypeBuy SideType = "buy" + SideTypeSell SideType = "sell" +) + +type TimeInForceType string + +const ( + // GTC Good Till Canceled orders remain open on the book until canceled. This is the default behavior if no policy is specified. + TimeInForceGTC TimeInForceType = "GTC" + + // GTT Good Till Time orders remain open on the book until canceled or the allotted cancelAfter is depleted on the matching engine. GTT orders are guaranteed to cancel before any other order is processed after the cancelAfter seconds placed in order book. + TimeInForceGTT TimeInForceType = "GTT" + + // FOK Fill Or Kill orders are rejected if the entire size cannot be matched. + TimeInForceFOK TimeInForceType = "FOK" + + // IOC Immediate Or Cancel orders instantly cancel the remaining size of the limit order instead of opening it on the book. + TimeInForceIOC TimeInForceType = "IOC" +) + +type LiquidityType string + +const ( + LiquidityTypeMaker LiquidityType = "maker" + LiquidityTypeTaker LiquidityType = "taker" +) + +type OrderType string + +const ( + OrderTypeMarket OrderType = "market" + OrderTypeLimit OrderType = "limit" + OrderTypeStopLimit OrderType = "stop_limit" +) + +type OrderState string + +const ( + OrderStateCanceled OrderState = "canceled" + OrderStateLive OrderState = "live" + OrderStatePartiallyFilled OrderState = "partially_filled" + OrderStateFilled OrderState = "filled" +) diff --git a/pkg/exchange/kucoin/parse.go b/pkg/exchange/kucoin/parse.go new file mode 100644 index 0000000..51126ee --- /dev/null +++ b/pkg/exchange/kucoin/parse.go @@ -0,0 +1,104 @@ +package kucoin + +import ( + "encoding/json" + "strings" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func parseWebSocketEvent(in []byte) (interface{}, error) { + var resp WebSocketEvent + var err = json.Unmarshal(in, &resp) + if err != nil { + return nil, err + } + + switch resp.Type { + case WebSocketMessageTypeAck: + return &resp, nil + + case WebSocketMessageTypeError: + resp.Object = string(resp.Data) + return &resp, nil + + case WebSocketMessageTypeMessage: + switch resp.Subject { + case WebSocketSubjectOrderChange: + var o WebSocketPrivateOrderEvent + if err := json.Unmarshal(resp.Data, &o); err != nil { + return &resp, err + } + resp.Object = &o + + case WebSocketSubjectAccountBalance: + var o WebSocketAccountBalanceEvent + if err := json.Unmarshal(resp.Data, &o); err != nil { + return &resp, err + } + resp.Object = &o + + case WebSocketSubjectTradeCandlesUpdate, WebSocketSubjectTradeCandlesAdd: + var o WebSocketCandleEvent + if err := json.Unmarshal(resp.Data, &o); err != nil { + return &resp, err + } + + o.Interval = extractIntervalFromTopic(resp.Topic) + o.Add = resp.Subject == WebSocketSubjectTradeCandlesAdd + resp.Object = &o + + case WebSocketSubjectTradeL2Update: + var o WebSocketOrderBookL2Event + if err := json.Unmarshal(resp.Data, &o); err != nil { + return &resp, err + } + resp.Object = &o + + case WebSocketSubjectTradeTicker: + var o WebSocketTickerEvent + if err := json.Unmarshal(resp.Data, &o); err != nil { + return &resp, err + } + resp.Object = &o + + default: + // return nil, fmt.Errorf("kucoin: unsupported subject: %s", resp.Subject) + + } + } + + return &resp, nil +} + +func extractIntervalFromTopic(topic string) types.Interval { + ta := strings.Split(topic, ":") + tb := strings.Split(ta[1], "_") + interval := tb[1] + return toGlobalInterval(interval) +} + +func toGlobalInterval(a string) types.Interval { + switch a { + case "1min": + return types.Interval1m + case "5min": + return types.Interval5m + case "15min": + return types.Interval15m + case "30min": + return types.Interval30m + case "1hour": + return types.Interval1h + case "2hour": + return types.Interval2h + case "4hour": + return types.Interval4h + case "6hour": + return types.Interval6h + case "12hour": + return types.Interval12h + + } + return "" +} diff --git a/pkg/exchange/kucoin/stream.go b/pkg/exchange/kucoin/stream.go new file mode 100644 index 0000000..38e6375 --- /dev/null +++ b/pkg/exchange/kucoin/stream.go @@ -0,0 +1,317 @@ +package kucoin + +import ( + "context" + "time" + + "github.com/gorilla/websocket" + "github.com/pkg/errors" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/depth" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/kucoin/kucoinapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +const readTimeout = 30 * time.Second + +//go:generate callbackgen -type Stream -interface +type Stream struct { + types.StandardStream + + client *kucoinapi.RestClient + exchange *Exchange + + bullet *kucoinapi.Bullet + candleEventCallbacks []func(candle *WebSocketCandleEvent, e *WebSocketEvent) + orderBookL2EventCallbacks []func(e *WebSocketOrderBookL2Event) + tickerEventCallbacks []func(e *WebSocketTickerEvent) + accountBalanceEventCallbacks []func(e *WebSocketAccountBalanceEvent) + privateOrderEventCallbacks []func(e *WebSocketPrivateOrderEvent) + + lastCandle map[string]types.KLine + depthBuffers map[string]*depth.Buffer +} + +func NewStream(client *kucoinapi.RestClient, ex *Exchange) *Stream { + stream := &Stream{ + StandardStream: types.NewStandardStream(), + client: client, + exchange: ex, + lastCandle: make(map[string]types.KLine), + depthBuffers: make(map[string]*depth.Buffer), + } + + stream.SetParser(parseWebSocketEvent) + stream.SetDispatcher(stream.dispatchEvent) + stream.SetEndpointCreator(stream.getEndpoint) + + stream.OnConnect(stream.handleConnect) + stream.OnCandleEvent(stream.handleCandleEvent) + stream.OnOrderBookL2Event(stream.handleOrderBookL2Event) + stream.OnTickerEvent(stream.handleTickerEvent) + stream.OnPrivateOrderEvent(stream.handlePrivateOrderEvent) + stream.OnAccountBalanceEvent(stream.handleAccountBalanceEvent) + return stream +} + +func (s *Stream) handleCandleEvent(candle *WebSocketCandleEvent, e *WebSocketEvent) { + kline := candle.KLine() + last, ok := s.lastCandle[e.Topic] + if ok && kline.StartTime.After(last.StartTime.Time()) || e.Subject == WebSocketSubjectTradeCandlesAdd { + last.Closed = true + s.EmitKLineClosed(last) + } + + s.EmitKLine(kline) + s.lastCandle[e.Topic] = kline +} + +func (s *Stream) handleOrderBookL2Event(e *WebSocketOrderBookL2Event) { + f, ok := s.depthBuffers[e.Symbol] + if ok { + f.AddUpdate(types.SliceOrderBook{ + Symbol: toGlobalSymbol(e.Symbol), + Time: e.Time.Time(), + Bids: e.Changes.Bids, + Asks: e.Changes.Asks, + }, e.SequenceStart, e.SequenceEnd) + } else { + f = depth.NewBuffer(func() (types.SliceOrderBook, int64, error) { + return s.exchange.QueryDepth(context.Background(), e.Symbol) + }) + s.depthBuffers[e.Symbol] = f + f.SetBufferingPeriod(time.Second) + f.OnReady(func(snapshot types.SliceOrderBook, updates []depth.Update) { + if valid, err := snapshot.IsValid(); !valid { + log.Errorf("depth snapshot is invalid, error: %v", err) + return + } + + s.EmitBookSnapshot(snapshot) + for _, u := range updates { + s.EmitBookUpdate(u.Object) + } + }) + f.OnPush(func(update depth.Update) { + s.EmitBookUpdate(update.Object) + }) + } +} + +func (s *Stream) handleTickerEvent(e *WebSocketTickerEvent) {} + +func (s *Stream) handleAccountBalanceEvent(e *WebSocketAccountBalanceEvent) { + bm := types.BalanceMap{} + bm[e.Currency] = types.Balance{ + Currency: e.Currency, + Available: e.Available, + Locked: e.Hold, + } + s.StandardStream.EmitBalanceUpdate(bm) +} + +func (s *Stream) handlePrivateOrderEvent(e *WebSocketPrivateOrderEvent) { + if e.Type == "match" { + s.StandardStream.EmitTradeUpdate(types.Trade{ + OrderID: hashStringID(e.OrderId), + ID: hashStringID(e.TradeId), + Exchange: types.ExchangeKucoin, + Price: e.MatchPrice, + Quantity: e.MatchSize, + QuoteQuantity: e.MatchPrice.Mul(e.MatchSize), + Symbol: toGlobalSymbol(e.Symbol), + Side: toGlobalSide(e.Side), + IsBuyer: e.Side == "buy", + IsMaker: e.Liquidity == "maker", + Time: types.Time(e.Ts.Time()), + Fee: fixedpoint.Zero, // not supported + FeeCurrency: "", // not supported + }) + } + + switch e.Type { + case "open", "match", "filled", "canceled": + status := types.OrderStatusNew + if e.Status == "done" { + if e.FilledSize == e.Size { + status = types.OrderStatusFilled + } else { + status = types.OrderStatusCanceled + } + } else if e.Status == "open" { + if e.FilledSize.Sign() > 0 { + status = types.OrderStatusPartiallyFilled + } + } + + s.StandardStream.EmitOrderUpdate(types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: e.ClientOid, + Symbol: toGlobalSymbol(e.Symbol), + Side: toGlobalSide(e.Side), + Type: toGlobalOrderType(e.OrderType), + Quantity: e.Size, + Price: e.Price, + }, + Exchange: types.ExchangeKucoin, + OrderID: hashStringID(e.OrderId), + UUID: e.OrderId, + Status: status, + ExecutedQuantity: e.FilledSize, + IsWorking: e.Status == "open", + CreationTime: types.Time(e.OrderTime.Time()), + UpdateTime: types.Time(e.Ts.Time()), + }) + + default: + log.Warnf("unhandled private order type: %s, payload: %+v", e.Type, e) + + } +} + +func (s *Stream) handleConnect() { + if s.PublicOnly { + if err := s.sendSubscriptions(); err != nil { + log.WithError(err).Errorf("subscription error") + return + } + } else { + // Emit Auth before establishing the connection to prevent the caller from missing the Update data after + // creating the order. + s.EmitAuth() + id := time.Now().UnixNano() / int64(time.Millisecond) + cmds := []WebSocketCommand{ + { + Id: id, + Type: WebSocketMessageTypeSubscribe, + Topic: "/spotMarket/tradeOrders", + PrivateChannel: true, + Response: true, + }, + { + Id: id + 1, + Type: WebSocketMessageTypeSubscribe, + Topic: "/account/balance", + PrivateChannel: true, + Response: true, + }, + } + for _, cmd := range cmds { + if err := s.Conn.WriteJSON(cmd); err != nil { + log.WithError(err).Errorf("private subscribe write error, cmd: %+v", cmd) + } + } + } +} + +func (s *Stream) sendSubscriptions() error { + cmds, err := convertSubscriptions(s.Subscriptions) + if err != nil { + return errors.Wrapf(err, "subscription convert error, subscriptions: %+v", s.Subscriptions) + } + + for _, cmd := range cmds { + if err := s.Conn.WriteJSON(cmd); err != nil { + return errors.Wrapf(err, "subscribe write error, cmd: %+v", cmd) + } + } + + return nil +} + +// getEndpoint use the PublicOnly flag to check whether we should allocate a public bullet or private bullet +func (s *Stream) getEndpoint(ctx context.Context) (string, error) { + var bullet *kucoinapi.Bullet + var err error + if s.PublicOnly { + bullet, err = s.client.BulletService.NewGetPublicBulletRequest().Do(ctx) + } else { + bullet, err = s.client.BulletService.NewGetPrivateBulletRequest().Do(ctx) + } + + if err != nil { + return "", err + } + + url, err := bullet.URL() + if err != nil { + return "", err + } + + s.bullet = bullet + + log.Debugf("bullet: %+v", bullet) + return url.String(), nil +} + +func (s *Stream) dispatchEvent(event interface{}) { + e, ok := event.(*WebSocketEvent) + if !ok { + return + } + + if e.Object == nil { + return + } + + switch et := e.Object.(type) { + + case *WebSocketTickerEvent: + s.EmitTickerEvent(et) + + case *WebSocketOrderBookL2Event: + s.EmitOrderBookL2Event(et) + + case *WebSocketCandleEvent: + s.EmitCandleEvent(et, e) + + case *WebSocketAccountBalanceEvent: + s.EmitAccountBalanceEvent(et) + + case *WebSocketPrivateOrderEvent: + s.EmitPrivateOrderEvent(et) + + default: + log.Warnf("unhandled event: %+v", et) + + } +} + +type WebSocketConnector interface { + Conn() *websocket.Conn + Reconnect() +} + +func ping(ctx context.Context, w WebSocketConnector, interval time.Duration) { + log.Infof("starting websocket ping worker with interval %s", interval) + + pingTicker := time.NewTicker(interval) + defer pingTicker.Stop() + + for { + select { + + case <-ctx.Done(): + log.Debug("ping worker stopped") + return + + case <-pingTicker.C: + conn := w.Conn() + + if err := conn.WriteJSON(WebSocketCommand{ + Id: util.UnixMilli(), + Type: "ping", + }); err != nil { + log.WithError(err).Error("websocket ping error", err) + w.Reconnect() + } + + if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(3*time.Second)); err != nil { + log.WithError(err).Error("ping error", err) + w.Reconnect() + } + } + } +} diff --git a/pkg/exchange/kucoin/stream_callbacks.go b/pkg/exchange/kucoin/stream_callbacks.go new file mode 100644 index 0000000..944d159 --- /dev/null +++ b/pkg/exchange/kucoin/stream_callbacks.go @@ -0,0 +1,65 @@ +// Code generated by "callbackgen -type Stream -interface"; DO NOT EDIT. + +package kucoin + +func (s *Stream) OnCandleEvent(cb func(candle *WebSocketCandleEvent, e *WebSocketEvent)) { + s.candleEventCallbacks = append(s.candleEventCallbacks, cb) +} + +func (s *Stream) EmitCandleEvent(candle *WebSocketCandleEvent, e *WebSocketEvent) { + for _, cb := range s.candleEventCallbacks { + cb(candle, e) + } +} + +func (s *Stream) OnOrderBookL2Event(cb func(e *WebSocketOrderBookL2Event)) { + s.orderBookL2EventCallbacks = append(s.orderBookL2EventCallbacks, cb) +} + +func (s *Stream) EmitOrderBookL2Event(e *WebSocketOrderBookL2Event) { + for _, cb := range s.orderBookL2EventCallbacks { + cb(e) + } +} + +func (s *Stream) OnTickerEvent(cb func(e *WebSocketTickerEvent)) { + s.tickerEventCallbacks = append(s.tickerEventCallbacks, cb) +} + +func (s *Stream) EmitTickerEvent(e *WebSocketTickerEvent) { + for _, cb := range s.tickerEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnAccountBalanceEvent(cb func(e *WebSocketAccountBalanceEvent)) { + s.accountBalanceEventCallbacks = append(s.accountBalanceEventCallbacks, cb) +} + +func (s *Stream) EmitAccountBalanceEvent(e *WebSocketAccountBalanceEvent) { + for _, cb := range s.accountBalanceEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnPrivateOrderEvent(cb func(e *WebSocketPrivateOrderEvent)) { + s.privateOrderEventCallbacks = append(s.privateOrderEventCallbacks, cb) +} + +func (s *Stream) EmitPrivateOrderEvent(e *WebSocketPrivateOrderEvent) { + for _, cb := range s.privateOrderEventCallbacks { + cb(e) + } +} + +type StreamEventHub interface { + OnCandleEvent(cb func(candle *WebSocketCandleEvent, e *WebSocketEvent)) + + OnOrderBookL2Event(cb func(e *WebSocketOrderBookL2Event)) + + OnTickerEvent(cb func(e *WebSocketTickerEvent)) + + OnAccountBalanceEvent(cb func(e *WebSocketAccountBalanceEvent)) + + OnPrivateOrderEvent(cb func(e *WebSocketPrivateOrderEvent)) +} diff --git a/pkg/exchange/kucoin/symbols.go b/pkg/exchange/kucoin/symbols.go new file mode 100644 index 0000000..ddd85b7 --- /dev/null +++ b/pkg/exchange/kucoin/symbols.go @@ -0,0 +1,1117 @@ +// Code generated by go generate; DO NOT EDIT. +package kucoin + +var symbolMap = map[string]string{ + "1EARTHUSDT": "1EARTH-USDT", + "1INCHUSDT": "1INCH-USDT", + "2CRZBTC": "2CRZ-BTC", + "2CRZUSDT": "2CRZ-USDT", + "AAVE3LUSDT": "AAVE3L-USDT", + "AAVE3SUSDT": "AAVE3S-USDT", + "AAVEBTC": "AAVE-BTC", + "AAVEKCS": "AAVE-KCS", + "AAVEUSDT": "AAVE-USDT", + "AAVEUST": "AAVE-UST", + "ABBCBTC": "ABBC-BTC", + "ABBCUSDT": "ABBC-USDT", + "ACEUSDT": "ACE-USDT", + "ACOINUSDT": "ACOIN-USDT", + "ACTBTC": "ACT-BTC", + "ACTETH": "ACT-ETH", + "ADA3LUSDT": "ADA3L-USDT", + "ADA3SUSDT": "ADA3S-USDT", + "ADABTC": "ADA-BTC", + "ADAKCS": "ADA-KCS", + "ADAUSDC": "ADA-USDC", + "ADAUSDT": "ADA-USDT", + "ADBBTC": "ADB-BTC", + "ADBETH": "ADB-ETH", + "ADXUSDT": "ADX-USDT", + "AERGOBTC": "AERGO-BTC", + "AERGOUSDT": "AERGO-USDT", + "AGIXBTC": "AGIX-BTC", + "AGIXETH": "AGIX-ETH", + "AGIXUSDT": "AGIX-USDT", + "AGLDUSDT": "AGLD-USDT", + "AIONBTC": "AION-BTC", + "AIONETH": "AION-ETH", + "AIOZUSDT": "AIOZ-USDT", + "AIUSDT": "AI-USDT", + "AKROBTC": "AKRO-BTC", + "AKROUSDT": "AKRO-USDT", + "ALBTETH": "ALBT-ETH", + "ALBTUSDT": "ALBT-USDT", + "ALEPHUSDT": "ALEPH-USDT", + "ALGOBTC": "ALGO-BTC", + "ALGOETH": "ALGO-ETH", + "ALGOKCS": "ALGO-KCS", + "ALGOUSDT": "ALGO-USDT", + "ALICEBTC": "ALICE-BTC", + "ALICEETH": "ALICE-ETH", + "ALICEUSDT": "ALICE-USDT", + "ALPACAUSDT": "ALPACA-USDT", + "ALPHABTC": "ALPHA-BTC", + "ALPHAUSDT": "ALPHA-USDT", + "AMBBTC": "AMB-BTC", + "AMBETH": "AMB-ETH", + "AMPLBTC": "AMPL-BTC", + "AMPLETH": "AMPL-ETH", + "AMPLUSDT": "AMPL-USDT", + "ANCUSDT": "ANC-USDT", + "ANCUST": "ANC-UST", + "ANKRBTC": "ANKR-BTC", + "ANKRUSDT": "ANKR-USDT", + "ANTBTC": "ANT-BTC", + "ANTUSDT": "ANT-USDT", + "AOABTC": "AOA-BTC", + "AOAUSDT": "AOA-USDT", + "API3USDT": "API3-USDT", + "APLBTC": "APL-BTC", + "APLUSDT": "APL-USDT", + "ARBTC": "AR-BTC", + "ARKERUSDT": "ARKER-USDT", + "ARPAUSDT": "ARPA-USDT", + "ARRRBTC": "ARRR-BTC", + "ARRRUSDT": "ARRR-USDT", + "ARUSDT": "AR-USDT", + "ARXUSDT": "ARX-USDT", + "ASDUSDT": "ASD-USDT", + "ATABTC": "ATA-BTC", + "ATAUSDT": "ATA-USDT", + "ATOM3LUSDT": "ATOM3L-USDT", + "ATOM3SUSDT": "ATOM3S-USDT", + "ATOMBTC": "ATOM-BTC", + "ATOMETH": "ATOM-ETH", + "ATOMKCS": "ATOM-KCS", + "ATOMUSDT": "ATOM-USDT", + "ATOMUST": "ATOM-UST", + "AUDIOBTC": "AUDIO-BTC", + "AUDIOUSDT": "AUDIO-USDT", + "AURYUSDT": "AURY-USDT", + "AVABTC": "AVA-BTC", + "AVAETH": "AVA-ETH", + "AVAUSDT": "AVA-USDT", + "AVAX3LUSDT": "AVAX3L-USDT", + "AVAX3SUSDT": "AVAX3S-USDT", + "AVAXBTC": "AVAX-BTC", + "AVAXUSDT": "AVAX-USDT", + "AXCUSDT": "AXC-USDT", + "AXPRBTC": "AXPR-BTC", + "AXPRETH": "AXPR-ETH", + "AXS3LUSDT": "AXS3L-USDT", + "AXS3SUSDT": "AXS3S-USDT", + "AXSUSDT": "AXS-USDT", + "BADGERBTC": "BADGER-BTC", + "BADGERUSDT": "BADGER-USDT", + "BAKEBTC": "BAKE-BTC", + "BAKEETH": "BAKE-ETH", + "BAKEUSDT": "BAKE-USDT", + "BALBTC": "BAL-BTC", + "BALETH": "BAL-ETH", + "BALUSDT": "BAL-USDT", + "BANDBTC": "BAND-BTC", + "BANDUSDT": "BAND-USDT", + "BASICUSDT": "BASIC-USDT", + "BATUSDT": "BAT-USDT", + "BAXBTC": "BAX-BTC", + "BAXETH": "BAX-ETH", + "BAXUSDT": "BAX-USDT", + "BCDBTC": "BCD-BTC", + "BCDETH": "BCD-ETH", + "BCH3LUSDT": "BCH3L-USDT", + "BCH3SUSDT": "BCH3S-USDT", + "BCHBTC": "BCH-BTC", + "BCHKCS": "BCH-KCS", + "BCHSVBTC": "BCHSV-BTC", + "BCHSVETH": "BCHSV-ETH", + "BCHSVKCS": "BCHSV-KCS", + "BCHSVUSDC": "BCHSV-USDC", + "BCHSVUSDT": "BCHSV-USDT", + "BCHUSDC": "BCH-USDC", + "BCHUSDT": "BCH-USDT", + "BEPROBTC": "BEPRO-BTC", + "BEPROUSDT": "BEPRO-USDT", + "BLOKUSDT": "BLOK-USDT", + "BMONUSDT": "BMON-USDT", + "BNB3LUSDT": "BNB3L-USDT", + "BNB3SUSDT": "BNB3S-USDT", + "BNBBTC": "BNB-BTC", + "BNBKCS": "BNB-KCS", + "BNBUSDT": "BNB-USDT", + "BNSBTC": "BNS-BTC", + "BNSUSDT": "BNS-USDT", + "BNTBTC": "BNT-BTC", + "BNTETH": "BNT-ETH", + "BNTUSDT": "BNT-USDT", + "BOAUSDT": "BOA-USDT", + "BOLTBTC": "BOLT-BTC", + "BOLTUSDT": "BOLT-USDT", + "BONDLYETH": "BONDLY-ETH", + "BONDLYUSDT": "BONDLY-USDT", + "BONDUSDT": "BOND-USDT", + "BOSONETH": "BOSON-ETH", + "BOSONUSDT": "BOSON-USDT", + "BTC3LUSDT": "BTC3L-USDT", + "BTC3SUSDT": "BTC3S-USDT", + "BTCDAI": "BTC-DAI", + "BTCPAX": "BTC-PAX", + "BTCTUSD": "BTC-TUSD", + "BTCUSDC": "BTC-USDC", + "BTCUSDT": "BTC-USDT", + "BTCUST": "BTC-UST", + "BTTBTC": "BTT-BTC", + "BTTETH": "BTT-ETH", + "BTTTRX": "BTT-TRX", + "BTTUSDT": "BTT-USDT", + "BURGERBTC": "BURGER-BTC", + "BURGERUSDT": "BURGER-USDT", + "BURPUSDT": "BURP-USDT", + "BUXBTC": "BUX-BTC", + "BUXUSDT": "BUX-USDT", + "BUYBTC": "BUY-BTC", + "BUYUSDT": "BUY-USDT", + "C98USDT": "C98-USDT", + "CAKEUSDT": "CAKE-USDT", + "CAPPBTC": "CAPP-BTC", + "CAPPETH": "CAPP-ETH", + "CARDUSDT": "CARD-USDT", + "CARRBTC": "CARR-BTC", + "CARRUSDT": "CARR-USDT", + "CASBTC": "CAS-BTC", + "CASUSDT": "CAS-USDT", + "CBCBTC": "CBC-BTC", + "CBCUSDT": "CBC-USDT", + "CELOBTC": "CELO-BTC", + "CELOUSDT": "CELO-USDT", + "CEREUSDT": "CERE-USDT", + "CEURBTC": "CEUR-BTC", + "CEURUSDT": "CEUR-USDT", + "CFGBTC": "CFG-BTC", + "CFGUSDT": "CFG-USDT", + "CGGUSDT": "CGG-USDT", + "CHMBUSDT": "CHMB-USDT", + "CHRBTC": "CHR-BTC", + "CHRUSDT": "CHR-USDT", + "CHSBBTC": "CHSB-BTC", + "CHSBETH": "CHSB-ETH", + "CHZBTC": "CHZ-BTC", + "CHZUSDT": "CHZ-USDT", + "CIRUSETH": "CIRUS-ETH", + "CIRUSUSDT": "CIRUS-USDT", + "CIX100USDT": "CIX100-USDT", + "CKBBTC": "CKB-BTC", + "CKBUSDT": "CKB-USDT", + "CLVUSDT": "CLV-USDT", + "COMBUSDT": "COMB-USDT", + "COMPUSDT": "COMP-USDT", + "COTIBTC": "COTI-BTC", + "COTIUSDT": "COTI-USDT", + "COVBTC": "COV-BTC", + "COVETH": "COV-ETH", + "COVUSDT": "COV-USDT", + "CPCBTC": "CPC-BTC", + "CPCETH": "CPC-ETH", + "CPOOLUSDT": "CPOOL-USDT", + "CQTUSDT": "CQT-USDT", + "CREAMBTC": "CREAM-BTC", + "CREAMUSDT": "CREAM-USDT", + "CREDIUSDT": "CREDI-USDT", + "CROBTC": "CRO-BTC", + "CROUSDT": "CRO-USDT", + "CRPTBTC": "CRPT-BTC", + "CRPTETH": "CRPT-ETH", + "CRPTUSDT": "CRPT-USDT", + "CRVUSDT": "CRV-USDT", + "CSBTC": "CS-BTC", + "CSETH": "CS-ETH", + "CSPBTC": "CSP-BTC", + "CSPETH": "CSP-ETH", + "CTIETH": "CTI-ETH", + "CTIUSDT": "CTI-USDT", + "CTSIBTC": "CTSI-BTC", + "CTSIUSDT": "CTSI-USDT", + "CUDOSBTC": "CUDOS-BTC", + "CUDOSUSDT": "CUDOS-USDT", + "CUSDBTC": "CUSD-BTC", + "CUSDUSDT": "CUSD-USDT", + "CVBTC": "CV-BTC", + "CVCBTC": "CVC-BTC", + "CVETH": "CV-ETH", + "CWARBTC": "CWAR-BTC", + "CWARUSDT": "CWAR-USDT", + "CWSUSDT": "CWS-USDT", + "CXOBTC": "CXO-BTC", + "CXOETH": "CXO-ETH", + "DACCBTC": "DACC-BTC", + "DACCETH": "DACC-ETH", + "DAGBTC": "DAG-BTC", + "DAGETH": "DAG-ETH", + "DAGUSDT": "DAG-USDT", + "DAOUSDT": "DAO-USDT", + "DAPPTBTC": "DAPPT-BTC", + "DAPPTUSDT": "DAPPT-USDT", + "DAPPXUSDT": "DAPPX-USDT", + "DASHBTC": "DASH-BTC", + "DASHETH": "DASH-ETH", + "DASHKCS": "DASH-KCS", + "DASHUSDT": "DASH-USDT", + "DATABTC": "DATA-BTC", + "DATAUSDT": "DATA-USDT", + "DATXBTC": "DATX-BTC", + "DATXETH": "DATX-ETH", + "DCRBTC": "DCR-BTC", + "DCRETH": "DCR-ETH", + "DEGOETH": "DEGO-ETH", + "DEGOUSDT": "DEGO-USDT", + "DENTBTC": "DENT-BTC", + "DENTETH": "DENT-ETH", + "DEROBTC": "DERO-BTC", + "DEROUSDT": "DERO-USDT", + "DEXEBTC": "DEXE-BTC", + "DEXEETH": "DEXE-ETH", + "DEXEUSDT": "DEXE-USDT", + "DFIBTC": "DFI-BTC", + "DFIUSDT": "DFI-USDT", + "DFYNUSDT": "DFYN-USDT", + "DGBBTC": "DGB-BTC", + "DGBETH": "DGB-ETH", + "DGBUSDT": "DGB-USDT", + "DGTXBTC": "DGTX-BTC", + "DGTXETH": "DGTX-ETH", + "DIABTC": "DIA-BTC", + "DIAUSDT": "DIA-USDT", + "DINOUSDT": "DINO-USDT", + "DIVIUSDT": "DIVI-USDT", + "DMGUSDT": "DMG-USDT", + "DMTRUSDT": "DMTR-USDT", + "DOCKBTC": "DOCK-BTC", + "DOCKETH": "DOCK-ETH", + "DODOUSDT": "DODO-USDT", + "DOGE3LUSDT": "DOGE3L-USDT", + "DOGE3SUSDT": "DOGE3S-USDT", + "DOGEBTC": "DOGE-BTC", + "DOGEKCS": "DOGE-KCS", + "DOGEUSDC": "DOGE-USDC", + "DOGEUSDT": "DOGE-USDT", + "DORABTC": "DORA-BTC", + "DORAUSDT": "DORA-USDT", + "DOT3LUSDT": "DOT3L-USDT", + "DOT3SUSDT": "DOT3S-USDT", + "DOTBTC": "DOT-BTC", + "DOTKCS": "DOT-KCS", + "DOTUSDT": "DOT-USDT", + "DOTUST": "DOT-UST", + "DPETUSDT": "DPET-USDT", + "DPIUSDT": "DPI-USDT", + "DPRUSDT": "DPR-USDT", + "DREAMSUSDT": "DREAMS-USDT", + "DRGNBTC": "DRGN-BTC", + "DRGNETH": "DRGN-ETH", + "DSLABTC": "DSLA-BTC", + "DSLAUSDT": "DSLA-USDT", + "DVPNUSDT": "DVPN-USDT", + "DYDXUSDT": "DYDX-USDT", + "DYPETH": "DYP-ETH", + "DYPUSDT": "DYP-USDT", + "EDGBTC": "EDG-BTC", + "EDGUSDT": "EDG-USDT", + "EFXBTC": "EFX-BTC", + "EFXUSDT": "EFX-USDT", + "EGLDBTC": "EGLD-BTC", + "EGLDUSDT": "EGLD-USDT", + "ELABTC": "ELA-BTC", + "ELAETH": "ELA-ETH", + "ELAUSDT": "ELA-USDT", + "ELFBTC": "ELF-BTC", + "ELFETH": "ELF-ETH", + "ELONUSDT": "ELON-USDT", + "ENJBTC": "ENJ-BTC", + "ENJETH": "ENJ-ETH", + "ENJUSDT": "ENJ-USDT", + "ENQBTC": "ENQ-BTC", + "ENQUSDT": "ENQ-USDT", + "ENSUSDT": "ENS-USDT", + "EOS3LUSDT": "EOS3L-USDT", + "EOS3SUSDT": "EOS3S-USDT", + "EOSBTC": "EOS-BTC", + "EOSCUSDT": "EOSC-USDT", + "EOSETH": "EOS-ETH", + "EOSKCS": "EOS-KCS", + "EOSUSDC": "EOS-USDC", + "EOSUSDT": "EOS-USDT", + "EPIKUSDT": "EPIK-USDT", + "EPSBTC": "EPS-BTC", + "EPSUSDT": "EPS-USDT", + "EQXBTC": "EQX-BTC", + "EQXUSDT": "EQX-USDT", + "EQZBTC": "EQZ-BTC", + "EQZUSDT": "EQZ-USDT", + "ERGBTC": "ERG-BTC", + "ERGUSDT": "ERG-USDT", + "ERNBTC": "ERN-BTC", + "ERNUSDT": "ERN-USDT", + "ERSDLUSDT": "ERSDL-USDT", + "ETCBTC": "ETC-BTC", + "ETCETH": "ETC-ETH", + "ETCUSDT": "ETC-USDT", + "ETH2ETH": "ETH2-ETH", + "ETH3LUSDT": "ETH3L-USDT", + "ETH3SUSDT": "ETH3S-USDT", + "ETHBTC": "ETH-BTC", + "ETHDAI": "ETH-DAI", + "ETHOBTC": "ETHO-BTC", + "ETHOUSDT": "ETHO-USDT", + "ETHPAX": "ETH-PAX", + "ETHTUSD": "ETH-TUSD", + "ETHUSDC": "ETH-USDC", + "ETHUSDT": "ETH-USDT", + "ETHUST": "ETH-UST", + "ETNBTC": "ETN-BTC", + "ETNETH": "ETN-ETH", + "ETNUSDT": "ETN-USDT", + "EWTBTC": "EWT-BTC", + "EWTKCS": "EWT-KCS", + "EWTUSDT": "EWT-USDT", + "EXRDUSDT": "EXRD-USDT", + "FALCONSUSDT": "FALCONS-USDT", + "FCLETH": "FCL-ETH", + "FCLUSDT": "FCL-USDT", + "FEARUSDT": "FEAR-USDT", + "FETBTC": "FET-BTC", + "FETETH": "FET-ETH", + "FILUSDT": "FIL-USDT", + "FKXBTC": "FKX-BTC", + "FKXETH": "FKX-ETH", + "FKXUSDT": "FKX-USDT", + "FLAMEUSDT": "FLAME-USDT", + "FLOWBTC": "FLOW-BTC", + "FLOWUSDT": "FLOW-USDT", + "FLUXBTC": "FLUX-BTC", + "FLUXUSDT": "FLUX-USDT", + "FLYUSDT": "FLY-USDT", + "FORESTPLUSBTC": "FORESTPLUS-BTC", + "FORESTPLUSUSDT": "FORESTPLUS-USDT", + "FORMETH": "FORM-ETH", + "FORMUSDT": "FORM-USDT", + "FORTHUSDT": "FORTH-USDT", + "FRMUSDT": "FRM-USDT", + "FRONTBTC": "FRONT-BTC", + "FRONTUSDT": "FRONT-USDT", + "FTGUSDT": "FTG-USDT", + "FTM3LUSDT": "FTM3L-USDT", + "FTM3SUSDT": "FTM3S-USDT", + "FTMBTC": "FTM-BTC", + "FTMETH": "FTM-ETH", + "FTMUSDT": "FTM-USDT", + "FTTBTC": "FTT-BTC", + "FTTUSDT": "FTT-USDT", + "FXBTC": "FX-BTC", + "FXETH": "FX-ETH", + "FXSBTC": "FXS-BTC", + "FXSUSDT": "FXS-USDT", + "GAFIUSDT": "GAFI-USDT", + "GALAX3LUSDT": "GALAX3L-USDT", + "GALAX3SUSDT": "GALAX3S-USDT", + "GALAXUSDT": "GALAX-USDT", + "GASBTC": "GAS-BTC", + "GASUSDT": "GAS-USDT", + "GEEQUSDT": "GEEQ-USDT", + "GENSUSDT": "GENS-USDT", + "GHSTBTC": "GHST-BTC", + "GHSTUSDT": "GHST-USDT", + "GHXUSDT": "GHX-USDT", + "GLCHUSDT": "GLCH-USDT", + "GLMBTC": "GLM-BTC", + "GLMUSDT": "GLM-USDT", + "GLQBTC": "GLQ-BTC", + "GLQUSDT": "GLQ-USDT", + "GMBBTC": "GMB-BTC", + "GMBETH": "GMB-ETH", + "GMBUSDT": "GMB-USDT", + "GMEEUSDT": "GMEE-USDT", + "GOBTC": "GO-BTC", + "GODSUSDT": "GODS-USDT", + "GOETH": "GO-ETH", + "GOM2BTC": "GOM2-BTC", + "GOM2USDT": "GOM2-USDT", + "GOUSDT": "GO-USDT", + "GOVIBTC": "GOVI-BTC", + "GOVIUSDT": "GOVI-USDT", + "GRINBTC": "GRIN-BTC", + "GRINETH": "GRIN-ETH", + "GRINUSDT": "GRIN-USDT", + "GRTKCS": "GRT-KCS", + "GRTUSDT": "GRT-USDT", + "GSPIUSDT": "GSPI-USDT", + "GTCBTC": "GTC-BTC", + "GTCUSDT": "GTC-USDT", + "H3RO3SUSDT": "H3RO3S-USDT", + "HAIBTC": "HAI-BTC", + "HAIUSDT": "HAI-USDT", + "HAKAUSDT": "HAKA-USDT", + "HAPIUSDT": "HAPI-USDT", + "HARDUSDT": "HARD-USDT", + "HBARBTC": "HBAR-BTC", + "HBARUSDT": "HBAR-USDT", + "HEARTBTC": "HEART-BTC", + "HEARTUSDT": "HEART-USDT", + "HEGICBTC": "HEGIC-BTC", + "HEGICUSDT": "HEGIC-USDT", + "HEROUSDT": "HERO-USDT", + "HORDUSDT": "HORD-USDT", + "HOTCROSSUSDT": "HOTCROSS-USDT", + "HPBBTC": "HPB-BTC", + "HPBETH": "HPB-ETH", + "HTRBTC": "HTR-BTC", + "HTRUSDT": "HTR-USDT", + "HTUSDT": "HT-USDT", + "HYDRAUSDT": "HYDRA-USDT", + "HYVEBTC": "HYVE-BTC", + "HYVEUSDT": "HYVE-USDT", + "ICPBTC": "ICP-BTC", + "ICPUSDT": "ICP-USDT", + "IDEAUSDT": "IDEA-USDT", + "ILAUSDT": "ILA-USDT", + "ILVUSDT": "ILV-USDT", + "IMXUSDT": "IMX-USDT", + "INJBTC": "INJ-BTC", + "INJUSDT": "INJ-USDT", + "IOIUSDT": "IOI-USDT", + "IOSTBTC": "IOST-BTC", + "IOSTETH": "IOST-ETH", + "IOSTUSDT": "IOST-USDT", + "IOTXBTC": "IOTX-BTC", + "IOTXETH": "IOTX-ETH", + "IOTXUSDT": "IOTX-USDT", + "ISPUSDT": "ISP-USDT", + "IXSUSDT": "IXS-USDT", + "JARBTC": "JAR-BTC", + "JARUSDT": "JAR-USDT", + "JASMYUSDT": "JASMY-USDT", + "JSTUSDT": "JST-USDT", + "JUPETH": "JUP-ETH", + "JUPUSDT": "JUP-USDT", + "KAIBTC": "KAI-BTC", + "KAIETH": "KAI-ETH", + "KAIUSDT": "KAI-USDT", + "KARUSDT": "KAR-USDT", + "KATBTC": "KAT-BTC", + "KATUSDT": "KAT-USDT", + "KAVAUSDT": "KAVA-USDT", + "KCSBTC": "KCS-BTC", + "KCSETH": "KCS-ETH", + "KCSUSDT": "KCS-USDT", + "KDABTC": "KDA-BTC", + "KDAUSDT": "KDA-USDT", + "KDONUSDT": "KDON-USDT", + "KEEPBTC": "KEEP-BTC", + "KEEPUSDT": "KEEP-USDT", + "KEYBTC": "KEY-BTC", + "KEYETH": "KEY-ETH", + "KINUSDT": "KIN-USDT", + "KLAYBTC": "KLAY-BTC", + "KLAYUSDT": "KLAY-USDT", + "KLVBTC": "KLV-BTC", + "KLVTRX": "KLV-TRX", + "KLVUSDT": "KLV-USDT", + "KMAUSDT": "KMA-USDT", + "KMDBTC": "KMD-BTC", + "KMDUSDT": "KMD-USDT", + "KNCBTC": "KNC-BTC", + "KNCETH": "KNC-ETH", + "KOKUSDT": "KOK-USDT", + "KOLETH": "KOL-ETH", + "KOLUSDT": "KOL-USDT", + "KONOUSDT": "KONO-USDT", + "KRLBTC": "KRL-BTC", + "KRLUSDT": "KRL-USDT", + "KSMBTC": "KSM-BTC", + "KSMUSDT": "KSM-USDT", + "LABSETH": "LABS-ETH", + "LABSUSDT": "LABS-USDT", + "LACEETH": "LACE-ETH", + "LACEUSDT": "LACE-USDT", + "LAYERBTC": "LAYER-BTC", + "LAYERUSDT": "LAYER-USDT", + "LIKEUSDT": "LIKE-USDT", + "LINABTC": "LINA-BTC", + "LINAUSDT": "LINA-USDT", + "LINK3LUSDT": "LINK3L-USDT", + "LINK3SUSDT": "LINK3S-USDT", + "LINKBTC": "LINK-BTC", + "LINKKCS": "LINK-KCS", + "LINKUSDC": "LINK-USDC", + "LINKUSDT": "LINK-USDT", + "LITBTC": "LIT-BTC", + "LITHETH": "LITH-ETH", + "LITHUSDT": "LITH-USDT", + "LITUSDT": "LIT-USDT", + "LNCHXUSDT": "LNCHX-USDT", + "LOCGUSDT": "LOCG-USDT", + "LOCUSDT": "LOC-USDT", + "LOKIBTC": "LOKI-BTC", + "LOKIETH": "LOKI-ETH", + "LOKIUSDT": "LOKI-USDT", + "LONUSDT": "LON-USDT", + "LOOMBTC": "LOOM-BTC", + "LOOMETH": "LOOM-ETH", + "LPOOLBTC": "LPOOL-BTC", + "LPOOLUSDT": "LPOOL-USDT", + "LPTUSDT": "LPT-USDT", + "LRCBTC": "LRC-BTC", + "LRCETH": "LRC-ETH", + "LRCUSDT": "LRC-USDT", + "LSKBTC": "LSK-BTC", + "LSKETH": "LSK-ETH", + "LSSUSDT": "LSS-USDT", + "LTC3LUSDT": "LTC3L-USDT", + "LTC3SUSDT": "LTC3S-USDT", + "LTCBTC": "LTC-BTC", + "LTCETH": "LTC-ETH", + "LTCKCS": "LTC-KCS", + "LTCUSDC": "LTC-USDC", + "LTCUSDT": "LTC-USDT", + "LTOBTC": "LTO-BTC", + "LTOUSDT": "LTO-USDT", + "LTXBTC": "LTX-BTC", + "LTXUSDT": "LTX-USDT", + "LUNA3LUSDT": "LUNA3L-USDT", + "LUNA3SUSDT": "LUNA3S-USDT", + "LUNABTC": "LUNA-BTC", + "LUNAETH": "LUNA-ETH", + "LUNAKCS": "LUNA-KCS", + "LUNAUSDT": "LUNA-USDT", + "LUNAUST": "LUNA-UST", + "LYMBTC": "LYM-BTC", + "LYMETH": "LYM-ETH", + "LYMUSDT": "LYM-USDT", + "LYXEETH": "LYXE-ETH", + "LYXEUSDT": "LYXE-USDT", + "MAHABTC": "MAHA-BTC", + "MAHAUSDT": "MAHA-USDT", + "MAKIBTC": "MAKI-BTC", + "MAKIUSDT": "MAKI-USDT", + "MANA3LUSDT": "MANA3L-USDT", + "MANA3SUSDT": "MANA3S-USDT", + "MANABTC": "MANA-BTC", + "MANAETH": "MANA-ETH", + "MANAUSDT": "MANA-USDT", + "MANBTC": "MAN-BTC", + "MANUSDT": "MAN-USDT", + "MAPBTC": "MAP-BTC", + "MAPUSDT": "MAP-USDT", + "MARSHUSDT": "MARSH-USDT", + "MASKUSDT": "MASK-USDT", + "MATIC3LUSDT": "MATIC3L-USDT", + "MATIC3SUSDT": "MATIC3S-USDT", + "MATICBTC": "MATIC-BTC", + "MATICUSDT": "MATIC-USDT", + "MATICUST": "MATIC-UST", + "MATTERUSDT": "MATTER-USDT", + "MEMUSDT": "MEM-USDT", + "MFTBTC": "MFT-BTC", + "MFTUSDT": "MFT-USDT", + "MHCBTC": "MHC-BTC", + "MHCETH": "MHC-ETH", + "MHCUSDT": "MHC-USDT", + "MIRKCS": "MIR-KCS", + "MIRUSDT": "MIR-USDT", + "MIRUST": "MIR-UST", + "MITXBTC": "MITX-BTC", + "MITXUSDT": "MITX-USDT", + "MKRBTC": "MKR-BTC", + "MKRDAI": "MKR-DAI", + "MKRETH": "MKR-ETH", + "MKRUSDT": "MKR-USDT", + "MLKBTC": "MLK-BTC", + "MLKUSDT": "MLK-USDT", + "MLNBTC": "MLN-BTC", + "MLNUSDT": "MLN-USDT", + "MNETUSDT": "MNET-USDT", + "MNSTUSDT": "MNST-USDT", + "MNWUSDT": "MNW-USDT", + "MODEFIBTC": "MODEFI-BTC", + "MODEFIUSDT": "MODEFI-USDT", + "MONIUSDT": "MONI-USDT", + "MOVRETH": "MOVR-ETH", + "MOVRUSDT": "MOVR-USDT", + "MSWAPBTC": "MSWAP-BTC", + "MSWAPUSDT": "MSWAP-USDT", + "MTLBTC": "MTL-BTC", + "MTLUSDT": "MTL-USDT", + "MTRGUSDT": "MTRG-USDT", + "MTVBTC": "MTV-BTC", + "MTVETH": "MTV-ETH", + "MTVUSDT": "MTV-USDT", + "MVPBTC": "MVP-BTC", + "MVPETH": "MVP-ETH", + "MXCUSDT": "MXC-USDT", + "MXWUSDT": "MXW-USDT", + "NAKAUSDT": "NAKA-USDT", + "NANOBTC": "NANO-BTC", + "NANOETH": "NANO-ETH", + "NANOKCS": "NANO-KCS", + "NANOUSDT": "NANO-USDT", + "NDAUUSDT": "NDAU-USDT", + "NEAR3LUSDT": "NEAR3L-USDT", + "NEAR3SUSDT": "NEAR3S-USDT", + "NEARBTC": "NEAR-BTC", + "NEARUSDT": "NEAR-USDT", + "NEOBTC": "NEO-BTC", + "NEOETH": "NEO-ETH", + "NEOKCS": "NEO-KCS", + "NEOUSDT": "NEO-USDT", + "NFTBUSDT": "NFTB-USDT", + "NFTTRX": "NFT-TRX", + "NFTUSDT": "NFT-USDT", + "NGCUSDT": "NGC-USDT", + "NGLBTC": "NGL-BTC", + "NGLUSDT": "NGL-USDT", + "NGMUSDT": "NGM-USDT", + "NIFUSDT": "NIF-USDT", + "NIMBTC": "NIM-BTC", + "NIMETH": "NIM-ETH", + "NIMUSDT": "NIM-USDT", + "NKNBTC": "NKN-BTC", + "NKNUSDT": "NKN-USDT", + "NMRBTC": "NMR-BTC", + "NMRUSDT": "NMR-USDT", + "NOIABTC": "NOIA-BTC", + "NOIAUSDT": "NOIA-USDT", + "NORDBTC": "NORD-BTC", + "NORDUSDT": "NORD-USDT", + "NRGBTC": "NRG-BTC", + "NRGETH": "NRG-ETH", + "NTVRKUSDC": "NTVRK-USDC", + "NTVRKUSDT": "NTVRK-USDT", + "NUBTC": "NU-BTC", + "NULSBTC": "NULS-BTC", + "NULSETH": "NULS-ETH", + "NUMUSDT": "NUM-USDT", + "NUUSDT": "NU-USDT", + "NWCBTC": "NWC-BTC", + "NWCUSDT": "NWC-USDT", + "OCEANBTC": "OCEAN-BTC", + "OCEANETH": "OCEAN-ETH", + "ODDZUSDT": "ODDZ-USDT", + "OGNBTC": "OGN-BTC", + "OGNUSDT": "OGN-USDT", + "OLTBTC": "OLT-BTC", + "OLTETH": "OLT-ETH", + "OMBTC": "OM-BTC", + "OMGBTC": "OMG-BTC", + "OMGETH": "OMG-ETH", + "OMGUSDT": "OMG-USDT", + "OMUSDT": "OM-USDT", + "ONEBTC": "ONE-BTC", + "ONEUSDT": "ONE-USDT", + "ONTBTC": "ONT-BTC", + "ONTETH": "ONT-ETH", + "ONTUSDT": "ONT-USDT", + "OOEUSDT": "OOE-USDT", + "OPCTBTC": "OPCT-BTC", + "OPCTETH": "OPCT-ETH", + "OPCTUSDT": "OPCT-USDT", + "OPULUSDT": "OPUL-USDT", + "ORAIUSDT": "ORAI-USDT", + "ORBSBTC": "ORBS-BTC", + "ORBSUSDT": "ORBS-USDT", + "ORNUSDT": "ORN-USDT", + "OUSDBTC": "OUSD-BTC", + "OUSDUSDT": "OUSD-USDT", + "OXTBTC": "OXT-BTC", + "OXTETH": "OXT-ETH", + "OXTUSDT": "OXT-USDT", + "PAXGBTC": "PAXG-BTC", + "PAXGUSDT": "PAXG-USDT", + "PBRUSDT": "PBR-USDT", + "PBXUSDT": "PBX-USDT", + "PCXBTC": "PCX-BTC", + "PCXUSDT": "PCX-USDT", + "PDEXBTC": "PDEX-BTC", + "PDEXUSDT": "PDEX-USDT", + "PELUSDT": "PEL-USDT", + "PERPBTC": "PERP-BTC", + "PERPUSDT": "PERP-USDT", + "PHAETH": "PHA-ETH", + "PHAUSDT": "PHA-USDT", + "PHNXBTC": "PHNX-BTC", + "PHNXUSDT": "PHNX-USDT", + "PIVXBTC": "PIVX-BTC", + "PIVXETH": "PIVX-ETH", + "PIVXUSDT": "PIVX-USDT", + "PLAYBTC": "PLAY-BTC", + "PLAYETH": "PLAY-ETH", + "PLUUSDT": "PLU-USDT", + "PMONUSDT": "PMON-USDT", + "PNTBTC": "PNT-BTC", + "PNTUSDT": "PNT-USDT", + "POLCUSDT": "POLC-USDT", + "POLKBTC": "POLK-BTC", + "POLKUSDT": "POLK-USDT", + "POLSBTC": "POLS-BTC", + "POLSUSDT": "POLS-USDT", + "POLUSDT": "POL-USDT", + "POLXUSDT": "POLX-USDT", + "PONDBTC": "POND-BTC", + "PONDUSDT": "POND-USDT", + "POWRBTC": "POWR-BTC", + "POWRETH": "POWR-ETH", + "PPTBTC": "PPT-BTC", + "PPTETH": "PPT-ETH", + "PREBTC": "PRE-BTC", + "PREUSDT": "PRE-USDT", + "PROMBTC": "PROM-BTC", + "PROMUSDT": "PROM-USDT", + "PRQUSDT": "PRQ-USDT", + "PUNDIXBTC": "PUNDIX-BTC", + "PUNDIXUSDT": "PUNDIX-USDT", + "PUSHBTC": "PUSH-BTC", + "PUSHUSDT": "PUSH-USDT", + "PYRBTC": "PYR-BTC", + "PYRUSDT": "PYR-USDT", + "QIBTC": "QI-BTC", + "QIUSDT": "QI-USDT", + "QKCBTC": "QKC-BTC", + "QKCETH": "QKC-ETH", + "QNTUSDT": "QNT-USDT", + "QRDOETH": "QRDO-ETH", + "QRDOUSDT": "QRDO-USDT", + "QTUMBTC": "QTUM-BTC", + "QUICKBTC": "QUICK-BTC", + "QUICKUSDT": "QUICK-USDT", + "RBTCBTC": "RBTC-BTC", + "REAPUSDT": "REAP-USDT", + "REEFBTC": "REEF-BTC", + "REEFUSDT": "REEF-USDT", + "RENUSDT": "REN-USDT", + "REPBTC": "REP-BTC", + "REPETH": "REP-ETH", + "REPUSDT": "REP-USDT", + "REQBTC": "REQ-BTC", + "REQETH": "REQ-ETH", + "REQUSDT": "REQ-USDT", + "REVVBTC": "REVV-BTC", + "REVVUSDT": "REVV-USDT", + "RFOXUSDT": "RFOX-USDT", + "RFUELUSDT": "RFUEL-USDT", + "RIFBTC": "RIF-BTC", + "RLCBTC": "RLC-BTC", + "RLCUSDT": "RLC-USDT", + "RLYUSDT": "RLY-USDT", + "RMRKUSDT": "RMRK-USDT", + "RNDRBTC": "RNDR-BTC", + "RNDRUSDT": "RNDR-USDT", + "ROOBEEBTC": "ROOBEE-BTC", + "ROSEUSDT": "ROSE-USDT", + "ROSNUSDT": "ROSN-USDT", + "ROUTEUSDT": "ROUTE-USDT", + "RSRBTC": "RSR-BTC", + "RSRUSDT": "RSR-USDT", + "RUNEBTC": "RUNE-BTC", + "RUNEUSDT": "RUNE-USDT", + "RUSDT": "R-USDT", + "SAND3LUSDT": "SAND3L-USDT", + "SAND3SUSDT": "SAND3S-USDT", + "SANDUSDT": "SAND-USDT", + "SCLPBTC": "SCLP-BTC", + "SCLPUSDT": "SCLP-USDT", + "SDAOETH": "SDAO-ETH", + "SDAOUSDT": "SDAO-USDT", + "SDNETH": "SDN-ETH", + "SDNUSDT": "SDN-USDT", + "SENSOBTC": "SENSO-BTC", + "SENSOUSDT": "SENSO-USDT", + "SFPBTC": "SFP-BTC", + "SFPUSDT": "SFP-USDT", + "SFUNDUSDT": "SFUND-USDT", + "SHABTC": "SHA-BTC", + "SHAUSDT": "SHA-USDT", + "SHFTBTC": "SHFT-BTC", + "SHFTUSDT": "SHFT-USDT", + "SHIBDOGE": "SHIB-DOGE", + "SHIBUSDT": "SHIB-USDT", + "SHILLUSDT": "SHILL-USDT", + "SHRBTC": "SHR-BTC", + "SHRUSDT": "SHR-USDT", + "SKEYUSDT": "SKEY-USDT", + "SKLBTC": "SKL-BTC", + "SKLUSDT": "SKL-USDT", + "SKUBTC": "SKU-BTC", + "SKUUSDT": "SKU-USDT", + "SLIMUSDT": "SLIM-USDT", + "SLPUSDT": "SLP-USDT", + "SNTBTC": "SNT-BTC", + "SNTETH": "SNT-ETH", + "SNTVTBTC": "SNTVT-BTC", + "SNTVTETH": "SNTVT-ETH", + "SNXBTC": "SNX-BTC", + "SNXETH": "SNX-ETH", + "SNXUSDT": "SNX-USDT", + "SNXUST": "SNX-UST", + "SOL3LUSDT": "SOL3L-USDT", + "SOL3SUSDT": "SOL3S-USDT", + "SOLRUSDT": "SOLR-USDT", + "SOLUSDT": "SOL-USDT", + "SOLUST": "SOL-UST", + "SOLVEBTC": "SOLVE-BTC", + "SOLVEUSDT": "SOLVE-USDT", + "SOULBTC": "SOUL-BTC", + "SOULETH": "SOUL-ETH", + "SOULUSDT": "SOUL-USDT", + "SOVUSDT": "SOV-USDT", + "SPIUSDT": "SPI-USDT", + "SRKBTC": "SRK-BTC", + "SRKUSDT": "SRK-USDT", + "SRMBTC": "SRM-BTC", + "SRMUSDT": "SRM-USDT", + "STCBTC": "STC-BTC", + "STCUSDT": "STC-USDT", + "STMXUSDT": "STMX-USDT", + "STNDETH": "STND-ETH", + "STNDUSDT": "STND-USDT", + "STORJBTC": "STORJ-BTC", + "STORJETH": "STORJ-ETH", + "STORJUSDT": "STORJ-USDT", + "STRKBTC": "STRK-BTC", + "STRKETH": "STRK-ETH", + "STRONGUSDT": "STRONG-USDT", + "STXBTC": "STX-BTC", + "STXUSDT": "STX-USDT", + "SUKUBTC": "SUKU-BTC", + "SUKUUSDT": "SUKU-USDT", + "SUNUSDT": "SUN-USDT", + "SUPERBTC": "SUPER-BTC", + "SUPERUSDT": "SUPER-USDT", + "SUSDBTC": "SUSD-BTC", + "SUSDETH": "SUSD-ETH", + "SUSDUSDT": "SUSD-USDT", + "SUSHI3LUSDT": "SUSHI3L-USDT", + "SUSHI3SUSDT": "SUSHI3S-USDT", + "SUSHIUSDT": "SUSHI-USDT", + "SUTERBTC": "SUTER-BTC", + "SUTERUSDT": "SUTER-USDT", + "SWASHUSDT": "SWASH-USDT", + "SWINGBYBTC": "SWINGBY-BTC", + "SWINGBYUSDT": "SWINGBY-USDT", + "SWPUSDT": "SWP-USDT", + "SXPBTC": "SXP-BTC", + "SXPUSDT": "SXP-USDT", + "SYLOUSDT": "SYLO-USDT", + "TARAETH": "TARA-ETH", + "TARAUSDT": "TARA-USDT", + "TCPUSDT": "TCP-USDT", + "TELBTC": "TEL-BTC", + "TELETH": "TEL-ETH", + "TELUSDT": "TEL-USDT", + "THETAUSDT": "THETA-USDT", + "TIDALUSDT": "TIDAL-USDT", + "TIMEBTC": "TIME-BTC", + "TIMEETH": "TIME-ETH", + "TKOBTC": "TKO-BTC", + "TKOUSDT": "TKO-USDT", + "TKYBTC": "TKY-BTC", + "TKYETH": "TKY-ETH", + "TKYUSDT": "TKY-USDT", + "TLMBTC": "TLM-BTC", + "TLMETH": "TLM-ETH", + "TLMUSDT": "TLM-USDT", + "TLOSBTC": "TLOS-BTC", + "TLOSUSDT": "TLOS-USDT", + "TOKOBTC": "TOKO-BTC", + "TOKOKCS": "TOKO-KCS", + "TOKOUSDT": "TOKO-USDT", + "TOMOBTC": "TOMO-BTC", + "TOMOETH": "TOMO-ETH", + "TOMOUSDT": "TOMO-USDT", + "TONEBTC": "TONE-BTC", + "TONEETH": "TONE-ETH", + "TONEUSDT": "TONE-USDT", + "TOWERBTC": "TOWER-BTC", + "TOWERUSDT": "TOWER-USDT", + "TRACBTC": "TRAC-BTC", + "TRACETH": "TRAC-ETH", + "TRADEBTC": "TRADE-BTC", + "TRADEUSDT": "TRADE-USDT", + "TRBBTC": "TRB-BTC", + "TRBUSDT": "TRB-USDT", + "TRIASBTC": "TRIAS-BTC", + "TRIASUSDT": "TRIAS-USDT", + "TRIBEUSDT": "TRIBE-USDT", + "TRUBTC": "TRU-BTC", + "TRUUSDT": "TRU-USDT", + "TRVLUSDT": "TRVL-USDT", + "TRXBTC": "TRX-BTC", + "TRXETH": "TRX-ETH", + "TRXKCS": "TRX-KCS", + "TRXUSDT": "TRX-USDT", + "TVKBTC": "TVK-BTC", + "TVKUSDT": "TVK-USDT", + "TWTBTC": "TWT-BTC", + "TWTUSDT": "TWT-USDT", + "TXAUSDC": "TXA-USDC", + "TXAUSDT": "TXA-USDT", + "UBXETH": "UBX-ETH", + "UBXTUSDT": "UBXT-USDT", + "UBXUSDT": "UBX-USDT", + "UDOOETH": "UDOO-ETH", + "UFOUSDT": "UFO-USDT", + "UMAUSDT": "UMA-USDT", + "UMBUSDT": "UMB-USDT", + "UNBUSDT": "UNB-USDT", + "UNFIUSDT": "UNFI-USDT", + "UNI3LUSDT": "UNI3L-USDT", + "UNI3SUSDT": "UNI3S-USDT", + "UNICUSDT": "UNIC-USDT", + "UNIKCS": "UNI-KCS", + "UNIUSDT": "UNI-USDT", + "UNOBTC": "UNO-BTC", + "UNOUSDT": "UNO-USDT", + "UOSBTC": "UOS-BTC", + "UOSUSDT": "UOS-USDT", + "UQCBTC": "UQC-BTC", + "UQCETH": "UQC-ETH", + "USDCUSDT": "USDC-USDT", + "USDCUST": "USDC-UST", + "USDJUSDT": "USDJ-USDT", + "USDNUSDT": "USDN-USDT", + "USDTDAI": "USDT-DAI", + "USDTPAX": "USDT-PAX", + "USDTTUSD": "USDT-TUSD", + "USDTUSDC": "USDT-USDC", + "USDTUST": "USDT-UST", + "UTKBTC": "UTK-BTC", + "UTKETH": "UTK-ETH", + "VAIUSDT": "VAI-USDT", + "VEEDBTC": "VEED-BTC", + "VEEDUSDT": "VEED-USDT", + "VEGAETH": "VEGA-ETH", + "VEGAUSDT": "VEGA-USDT", + "VELOUSDT": "VELO-USDT", + "VET3LUSDT": "VET3L-USDT", + "VET3SUSDT": "VET3S-USDT", + "VETBTC": "VET-BTC", + "VETETH": "VET-ETH", + "VETKCS": "VET-KCS", + "VETUSDT": "VET-USDT", + "VIDBTC": "VID-BTC", + "VIDTBTC": "VIDT-BTC", + "VIDTUSDT": "VIDT-USDT", + "VIDUSDT": "VID-USDT", + "VLXBTC": "VLX-BTC", + "VLXUSDT": "VLX-USDT", + "VRABTC": "VRA-BTC", + "VRAUSDT": "VRA-USDT", + "VRUSDT": "VR-USDT", + "VSYSBTC": "VSYS-BTC", + "VSYSUSDT": "VSYS-USDT", + "VXVUSDT": "VXV-USDT", + "WANBTC": "WAN-BTC", + "WANETH": "WAN-ETH", + "WAVESBTC": "WAVES-BTC", + "WAVESUSDT": "WAVES-USDT", + "WAXBTC": "WAX-BTC", + "WAXETH": "WAX-ETH", + "WAXUSDT": "WAX-USDT", + "WBTCBTC": "WBTC-BTC", + "WBTCETH": "WBTC-ETH", + "WESTBTC": "WEST-BTC", + "WESTUSDT": "WEST-USDT", + "WILDUSDT": "WILD-USDT", + "WINBTC": "WIN-BTC", + "WINTRX": "WIN-TRX", + "WINUSDT": "WIN-USDT", + "WNCGBTC": "WNCG-BTC", + "WNCGUSDT": "WNCG-USDT", + "WNXMBTC": "WNXM-BTC", + "WNXMUSDT": "WNXM-USDT", + "WOMUSDT": "WOM-USDT", + "WOOUSDT": "WOO-USDT", + "WRXBTC": "WRX-BTC", + "WRXUSDT": "WRX-USDT", + "WSIENNAUSDT": "WSIENNA-USDT", + "WTCBTC": "WTC-BTC", + "WXTBTC": "WXT-BTC", + "WXTUSDT": "WXT-USDT", + "XAVAUSDT": "XAVA-USDT", + "XCADUSDT": "XCAD-USDT", + "XCHUSDT": "XCH-USDT", + "XCURBTC": "XCUR-BTC", + "XCURUSDT": "XCUR-USDT", + "XDBBTC": "XDB-BTC", + "XDBUSDT": "XDB-USDT", + "XDCBTC": "XDC-BTC", + "XDCETH": "XDC-ETH", + "XDCUSDT": "XDC-USDT", + "XECUSDT": "XEC-USDT", + "XEDBTC": "XED-BTC", + "XEDUSDT": "XED-USDT", + "XEMBTC": "XEM-BTC", + "XEMUSDT": "XEM-USDT", + "XHVBTC": "XHV-BTC", + "XHVUSDT": "XHV-USDT", + "XLMBTC": "XLM-BTC", + "XLMETH": "XLM-ETH", + "XLMKCS": "XLM-KCS", + "XLMUSDT": "XLM-USDT", + "XMRBTC": "XMR-BTC", + "XMRETH": "XMR-ETH", + "XMRUSDT": "XMR-USDT", + "XNLUSDT": "XNL-USDT", + "XPRBTC": "XPR-BTC", + "XPRTUSDT": "XPRT-USDT", + "XPRUSDT": "XPR-USDT", + "XRP3LUSDT": "XRP3L-USDT", + "XRP3SUSDT": "XRP3S-USDT", + "XRPBTC": "XRP-BTC", + "XRPETH": "XRP-ETH", + "XRPKCS": "XRP-KCS", + "XRPPAX": "XRP-PAX", + "XRPTUSD": "XRP-TUSD", + "XRPUSDC": "XRP-USDC", + "XRPUSDT": "XRP-USDT", + "XSRUSDT": "XSR-USDT", + "XTAGUSDT": "XTAG-USDT", + "XTMUSDT": "XTM-USDT", + "XTZBTC": "XTZ-BTC", + "XTZKCS": "XTZ-KCS", + "XTZUSDT": "XTZ-USDT", + "XVSBTC": "XVS-BTC", + "XVSUSDT": "XVS-USDT", + "XYMBTC": "XYM-BTC", + "XYMUSDT": "XYM-USDT", + "XYOBTC": "XYO-BTC", + "XYOETH": "XYO-ETH", + "XYOUSDT": "XYO-USDT", + "YFDAIBTC": "YFDAI-BTC", + "YFDAIUSDT": "YFDAI-USDT", + "YFIUSDT": "YFI-USDT", + "YFIUST": "YFI-UST", + "YGGUSDT": "YGG-USDT", + "YLDUSDT": "YLD-USDT", + "YOPETH": "YOP-ETH", + "YOPUSDT": "YOP-USDT", + "ZCXBTC": "ZCX-BTC", + "ZCXUSDT": "ZCX-USDT", + "ZECBTC": "ZEC-BTC", + "ZECKCS": "ZEC-KCS", + "ZECUSDT": "ZEC-USDT", + "ZEEUSDT": "ZEE-USDT", + "ZENUSDT": "ZEN-USDT", + "ZILBTC": "ZIL-BTC", + "ZILETH": "ZIL-ETH", + "ZILUSDT": "ZIL-USDT", + "ZKTUSDT": "ZKT-USDT", + "ZORTUSDT": "ZORT-USDT", + "ZRXBTC": "ZRX-BTC", + "ZRXETH": "ZRX-ETH", +} + +func toLocalSymbol(symbol string) string { + s, ok := symbolMap[symbol] + if ok { + return s + } + + return symbol +} diff --git a/pkg/exchange/kucoin/testdata/ack.json b/pkg/exchange/kucoin/testdata/ack.json new file mode 100644 index 0000000..c1a49e5 --- /dev/null +++ b/pkg/exchange/kucoin/testdata/ack.json @@ -0,0 +1,4 @@ +{ + "id": "1640198781304", + "type": "ack" +} \ No newline at end of file diff --git a/pkg/exchange/kucoin/testdata/btc-01-account-balance.json b/pkg/exchange/kucoin/testdata/btc-01-account-balance.json new file mode 100644 index 0000000..ea006c0 --- /dev/null +++ b/pkg/exchange/kucoin/testdata/btc-01-account-balance.json @@ -0,0 +1,24 @@ +{ + "id": "61c3728cfd0c3c0001a16a64", + "type": "message", + "topic": "/account/balance", + "userId": "61af6413efeab1000113f08b", + "channelType": "private", + "subject": "account.balance", + "data": { + "accountId": "61b48b6d94ab8d000103ea77", + "available": "42.240598061678", + "availableChange": "-14.099267307125", + "currency": "USDT", + "hold": "14.099267307125", + "holdChange": "14.099267307125", + "relationContext": { + "symbol": "BTC-USDT", + "orderId": "61c3728cfd0c3c0001a16a62" + }, + "relationEvent": "trade.hold", + "relationEventId": "61c3728cfd0c3c0001a16a64", + "time": "1640198796182", + "total": "56.339865368803" + } +} \ No newline at end of file diff --git a/pkg/exchange/kucoin/testdata/btc-02-trade-orders.json b/pkg/exchange/kucoin/testdata/btc-02-trade-orders.json new file mode 100644 index 0000000..8c8f6f5 --- /dev/null +++ b/pkg/exchange/kucoin/testdata/btc-02-trade-orders.json @@ -0,0 +1,25 @@ +{ + "type": "message", + "topic": "/spotMarket/tradeOrders", + "userId": "61af6413efeab1000113f08b", + "channelType": "private", + "subject": "orderChange", + "data": { + "symbol": "BTC-USDT", + "orderType": "limit", + "side": "buy", + "orderId": "61c3728cfd0c3c0001a16a62", + "liquidity": "taker", + "type": "match", + "orderTime": 1640198796191168550, + "size": "0.00028975", + "filledSize": "0.00028975", + "price": "48611.5", + "matchPrice": "48604.5", + "matchSize": "0.00028975", + "tradeId": "61c3728c2e113d2923db40a3", + "remainSize": "0", + "status": "match", + "ts": 1640198796191168550 + } +} \ No newline at end of file diff --git a/pkg/exchange/kucoin/testdata/btc-03-trade-orders.json b/pkg/exchange/kucoin/testdata/btc-03-trade-orders.json new file mode 100644 index 0000000..9e03249 --- /dev/null +++ b/pkg/exchange/kucoin/testdata/btc-03-trade-orders.json @@ -0,0 +1,21 @@ +{ + "type": "message", + "topic": "/spotMarket/tradeOrders", + "userId": "61af6413efeab1000113f08b", + "channelType": "private", + "subject": "orderChange", + "data": { + "symbol": "BTC-USDT", + "orderType": "limit", + "side": "buy", + "orderId": "61c3728cfd0c3c0001a16a62", + "type": "filled", + "orderTime": 1640198796191168550, + "size": "0.00028975", + "filledSize": "0.00028975", + "price": "48611.5", + "remainSize": "0", + "status": "done", + "ts": 1640198796191168550 + } +} \ No newline at end of file diff --git a/pkg/exchange/kucoin/testdata/btc-04-account-balance.json b/pkg/exchange/kucoin/testdata/btc-04-account-balance.json new file mode 100644 index 0000000..7161044 --- /dev/null +++ b/pkg/exchange/kucoin/testdata/btc-04-account-balance.json @@ -0,0 +1,25 @@ +{ + "id": "61c3728c47d4ea0001c2238a", + "type": "message", + "topic": "/account/balance", + "userId": "61af6413efeab1000113f08b", + "channelType": "private", + "subject": "account.balance", + "data": { + "accountId": "61c1fc287de2940001bd2aac", + "available": "0.00028975", + "availableChange": "0.00028975", + "currency": "BTC", + "hold": "0", + "holdChange": "0", + "relationContext": { + "symbol": "BTC-USDT", + "orderId": "61c3728cfd0c3c0001a16a62", + "tradeId": "61c3728c2e113d2923db40a3" + }, + "relationEvent": "trade.setted", + "relationEventId": "61c3728c47d4ea0001c2238a", + "time": "1640198796230", + "total": "0.00028975" + } +} \ No newline at end of file diff --git a/pkg/exchange/kucoin/testdata/cro-01-account-balance.json b/pkg/exchange/kucoin/testdata/cro-01-account-balance.json new file mode 100644 index 0000000..6928a11 --- /dev/null +++ b/pkg/exchange/kucoin/testdata/cro-01-account-balance.json @@ -0,0 +1,24 @@ +{ + "id": "61c3f702e5edc90001b0b581", + "type": "message", + "topic": "/account/balance", + "userId": "61af6413efeab1000113f08b", + "channelType": "private", + "subject": "account.balance", + "data": { + "accountId": "61b48b6d94ab8d000103ea77", + "available": "0.000036536828", + "availableChange": "-56.3116335782", + "currency": "USDT", + "hold": "56.3116335782", + "holdChange": "56.3116335782", + "relationContext": { + "symbol": "CRO-USDT", + "orderId": "61c3f702e5edc90001b0b575" + }, + "relationEvent": "trade.hold", + "relationEventId": "61c3f702e5edc90001b0b581", + "time": "1640232706413", + "total": "56.311670115028" + } +} \ No newline at end of file diff --git a/pkg/exchange/kucoin/testdata/cro-02-trade-orders.json b/pkg/exchange/kucoin/testdata/cro-02-trade-orders.json new file mode 100644 index 0000000..16d918a --- /dev/null +++ b/pkg/exchange/kucoin/testdata/cro-02-trade-orders.json @@ -0,0 +1,21 @@ +{ + "type": "message", + "topic": "/spotMarket/tradeOrders", + "userId": "61af6413efeab1000113f08b", + "channelType": "private", + "subject": "orderChange", + "data": { + "symbol": "CRO-USDT", + "orderType": "limit", + "side": "buy", + "orderId": "61c3f702e5edc90001b0b575", + "type": "open", + "orderTime": 1640232706419104233, + "size": "104.5639", + "filledSize": "0", + "price": "0.538", + "remainSize": "104.5639", + "status": "open", + "ts": 1640232706419104233 + } +} \ No newline at end of file diff --git a/pkg/exchange/kucoin/testdata/cro-03-trade-orders.json b/pkg/exchange/kucoin/testdata/cro-03-trade-orders.json new file mode 100644 index 0000000..778ba89 --- /dev/null +++ b/pkg/exchange/kucoin/testdata/cro-03-trade-orders.json @@ -0,0 +1,25 @@ +{ + "type": "message", + "topic": "/spotMarket/tradeOrders", + "userId": "61af6413efeab1000113f08b", + "channelType": "private", + "subject": "orderChange", + "data": { + "symbol": "CRO-USDT", + "orderType": "limit", + "side": "buy", + "orderId": "61c3f702e5edc90001b0b575", + "liquidity": "maker", + "type": "match", + "orderTime": 1640232706419104233, + "size": "104.5639", + "filledSize": "104.5639", + "price": "0.538", + "matchPrice": "0.538", + "matchSize": "104.5639", + "tradeId": "61c3f7107857782458a39b06", + "remainSize": "0", + "status": "open", + "ts": 1640232720266477485 + } +} \ No newline at end of file diff --git a/pkg/exchange/kucoin/testdata/cro-04-trade-orders.json b/pkg/exchange/kucoin/testdata/cro-04-trade-orders.json new file mode 100644 index 0000000..200311c --- /dev/null +++ b/pkg/exchange/kucoin/testdata/cro-04-trade-orders.json @@ -0,0 +1,21 @@ +{ + "type": "message", + "topic": "/spotMarket/tradeOrders", + "userId": "61af6413efeab1000113f08b", + "channelType": "private", + "subject": "orderChange", + "data": { + "symbol": "CRO-USDT", + "orderType": "limit", + "side": "buy", + "orderId": "61c3f702e5edc90001b0b575", + "type": "filled", + "orderTime": 1640232706419104233, + "size": "104.5639", + "filledSize": "104.5639", + "price": "0.538", + "remainSize": "0", + "status": "done", + "ts": 1640232720266477485 + } +} \ No newline at end of file diff --git a/pkg/exchange/kucoin/testdata/cro-05-account-balance.json b/pkg/exchange/kucoin/testdata/cro-05-account-balance.json new file mode 100644 index 0000000..c05bed4 --- /dev/null +++ b/pkg/exchange/kucoin/testdata/cro-05-account-balance.json @@ -0,0 +1,25 @@ +{ + "id": "61c3f710506791000143eeef", + "type": "message", + "topic": "/account/balance", + "userId": "61af6413efeab1000113f08b", + "channelType": "private", + "subject": "account.balance", + "data": { + "accountId": "61b48b6d94ab8d000103ea77", + "available": "0.000036536828", + "availableChange": "0", + "currency": "USDT", + "hold": "0", + "holdChange": "-56.3116335782", + "relationContext": { + "symbol": "CRO-USDT", + "orderId": "61c3f702e5edc90001b0b575", + "tradeId": "61c3f7107857782458a39b06" + }, + "relationEvent": "trade.setted", + "relationEventId": "61c3f710506791000143eeef", + "time": "1640232720319", + "total": "0.000036536828" + } +} \ No newline at end of file diff --git a/pkg/exchange/kucoin/testdata/cro-06-account-balance.json b/pkg/exchange/kucoin/testdata/cro-06-account-balance.json new file mode 100644 index 0000000..446dafc --- /dev/null +++ b/pkg/exchange/kucoin/testdata/cro-06-account-balance.json @@ -0,0 +1,25 @@ +{ + "id": "61c3f710506791000143eeee", + "type": "message", + "topic": "/account/balance", + "userId": "61af6413efeab1000113f08b", + "channelType": "private", + "subject": "account.balance", + "data": { + "accountId": "61c3f710e5756100011faf58", + "available": "104.5639", + "availableChange": "104.5639", + "currency": "CRO", + "hold": "0", + "holdChange": "0", + "relationContext": { + "symbol": "CRO-USDT", + "orderId": "61c3f702e5edc90001b0b575", + "tradeId": "61c3f7107857782458a39b06" + }, + "relationEvent": "trade.setted", + "relationEventId": "61c3f710506791000143eeee", + "time": "1640232720329", + "total": "104.5639" + } +} \ No newline at end of file diff --git a/pkg/exchange/kucoin/testdata/cro-07-account-balance.json b/pkg/exchange/kucoin/testdata/cro-07-account-balance.json new file mode 100644 index 0000000..153cd3b --- /dev/null +++ b/pkg/exchange/kucoin/testdata/cro-07-account-balance.json @@ -0,0 +1,24 @@ +{ + "id": "61c3f71ce5edc90001b10686", + "type": "message", + "topic": "/account/balance", + "userId": "61af6413efeab1000113f08b", + "channelType": "private", + "subject": "account.balance", + "data": { + "accountId": "61c3f710e5756100011faf58", + "available": "0", + "availableChange": "-104.5639", + "currency": "CRO", + "hold": "104.5639", + "holdChange": "104.5639", + "relationContext": { + "symbol": "CRO-USDT", + "orderId": "61c3f71ce5edc90001b10685" + }, + "relationEvent": "trade.hold", + "relationEventId": "61c3f71ce5edc90001b10686", + "time": "1640232732749", + "total": "104.5639" + } +} \ No newline at end of file diff --git a/pkg/exchange/kucoin/testdata/cro-08-trade-orders.json b/pkg/exchange/kucoin/testdata/cro-08-trade-orders.json new file mode 100644 index 0000000..15a815e --- /dev/null +++ b/pkg/exchange/kucoin/testdata/cro-08-trade-orders.json @@ -0,0 +1,21 @@ +{ + "type": "message", + "topic": "/spotMarket/tradeOrders", + "userId": "61af6413efeab1000113f08b", + "channelType": "private", + "subject": "orderChange", + "data": { + "symbol": "CRO-USDT", + "orderType": "limit", + "side": "sell", + "orderId": "61c3f71ce5edc90001b10685", + "type": "open", + "orderTime": 1640232732749540684, + "size": "104.5639", + "filledSize": "0", + "price": "0.5382", + "remainSize": "104.5639", + "status": "open", + "ts": 1640232732749540684 + } +} \ No newline at end of file diff --git a/pkg/exchange/kucoin/testdata/cro-09-trade-orders.json b/pkg/exchange/kucoin/testdata/cro-09-trade-orders.json new file mode 100644 index 0000000..ab59319 --- /dev/null +++ b/pkg/exchange/kucoin/testdata/cro-09-trade-orders.json @@ -0,0 +1,25 @@ +{ + "type": "message", + "topic": "/spotMarket/tradeOrders", + "userId": "61af6413efeab1000113f08b", + "channelType": "private", + "subject": "orderChange", + "data": { + "symbol": "CRO-USDT", + "orderType": "limit", + "side": "sell", + "orderId": "61c3f71ce5edc90001b10685", + "liquidity": "maker", + "type": "match", + "orderTime": 1640232732749540684, + "size": "104.5639", + "filledSize": "104.5639", + "price": "0.5382", + "matchPrice": "0.5382", + "matchSize": "104.5639", + "tradeId": "61c3f71f7857782458a39b54", + "remainSize": "0", + "status": "open", + "ts": 1640232735930840841 + } +} \ No newline at end of file diff --git a/pkg/exchange/kucoin/testdata/cro-10-trade-orders.json b/pkg/exchange/kucoin/testdata/cro-10-trade-orders.json new file mode 100644 index 0000000..a04d052 --- /dev/null +++ b/pkg/exchange/kucoin/testdata/cro-10-trade-orders.json @@ -0,0 +1,21 @@ +{ + "type": "message", + "topic": "/spotMarket/tradeOrders", + "userId": "61af6413efeab1000113f08b", + "channelType": "private", + "subject": "orderChange", + "data": { + "symbol": "CRO-USDT", + "orderType": "limit", + "side": "sell", + "orderId": "61c3f71ce5edc90001b10685", + "type": "filled", + "orderTime": 1640232732749540684, + "size": "104.5639", + "filledSize": "104.5639", + "price": "0.5382", + "remainSize": "0", + "status": "done", + "ts": 1640232735930840841 + } +} \ No newline at end of file diff --git a/pkg/exchange/kucoin/testdata/cro-11-account-balance.json b/pkg/exchange/kucoin/testdata/cro-11-account-balance.json new file mode 100644 index 0000000..b4c2549 --- /dev/null +++ b/pkg/exchange/kucoin/testdata/cro-11-account-balance.json @@ -0,0 +1,25 @@ +{ + "id": "61c3f71fd5ad710001b5c2a1", + "type": "message", + "topic": "/account/balance", + "userId": "61af6413efeab1000113f08b", + "channelType": "private", + "subject": "account.balance", + "data": { + "accountId": "61b48b6d94ab8d000103ea77", + "available": "56.220051225848", + "availableChange": "56.22001468902", + "currency": "USDT", + "hold": "0", + "holdChange": "0", + "relationContext": { + "symbol": "CRO-USDT", + "orderId": "61c3f71ce5edc90001b10685", + "tradeId": "61c3f71f7857782458a39b54" + }, + "relationEvent": "trade.setted", + "relationEventId": "61c3f71fd5ad710001b5c2a1", + "time": "1640232735979", + "total": "56.220051225848" + } +} \ No newline at end of file diff --git a/pkg/exchange/kucoin/testdata/cro-12-account-balance.json b/pkg/exchange/kucoin/testdata/cro-12-account-balance.json new file mode 100644 index 0000000..5386f14 --- /dev/null +++ b/pkg/exchange/kucoin/testdata/cro-12-account-balance.json @@ -0,0 +1,25 @@ +{ + "id": "61c3f71fd5ad710001b5c2a0", + "type": "message", + "topic": "/account/balance", + "userId": "61af6413efeab1000113f08b", + "channelType": "private", + "subject": "account.balance", + "data": { + "accountId": "61c3f710e5756100011faf58", + "available": "0", + "availableChange": "0", + "currency": "CRO", + "hold": "0", + "holdChange": "-104.5639", + "relationContext": { + "symbol": "CRO-USDT", + "orderId": "61c3f71ce5edc90001b10685", + "tradeId": "61c3f71f7857782458a39b54" + }, + "relationEvent": "trade.setted", + "relationEventId": "61c3f71fd5ad710001b5c2a0", + "time": "1640232735982", + "total": "0" + } +} \ No newline at end of file diff --git a/pkg/exchange/kucoin/testdata/welcome.json b/pkg/exchange/kucoin/testdata/welcome.json new file mode 100644 index 0000000..0665f1a --- /dev/null +++ b/pkg/exchange/kucoin/testdata/welcome.json @@ -0,0 +1,4 @@ +{ + "id": "TuhpZyeoee", + "type": "welcome" +} \ No newline at end of file diff --git a/pkg/exchange/kucoin/websocket.go b/pkg/exchange/kucoin/websocket.go new file mode 100644 index 0000000..43cc06c --- /dev/null +++ b/pkg/exchange/kucoin/websocket.go @@ -0,0 +1,159 @@ +package kucoin + +import ( + "encoding/json" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type WebSocketMessageType string + +const ( + WebSocketMessageTypePing WebSocketMessageType = "ping" + WebSocketMessageTypeSubscribe WebSocketMessageType = "subscribe" + WebSocketMessageTypeUnsubscribe WebSocketMessageType = "unsubscribe" + WebSocketMessageTypeAck WebSocketMessageType = "ack" + WebSocketMessageTypeError WebSocketMessageType = "error" + WebSocketMessageTypePong WebSocketMessageType = "pong" + WebSocketMessageTypeWelcome WebSocketMessageType = "welcome" + WebSocketMessageTypeMessage WebSocketMessageType = "message" +) + +type WebSocketSubject string + +const ( + WebSocketSubjectTradeTicker WebSocketSubject = "trade.ticker" + WebSocketSubjectTradeSnapshot WebSocketSubject = "trade.snapshot" // ticker snapshot + WebSocketSubjectTradeL2Update WebSocketSubject = "trade.l2update" // order book L2 + WebSocketSubjectLevel2 WebSocketSubject = "level2" // level2 + WebSocketSubjectTradeCandlesUpdate WebSocketSubject = "trade.candles.update" + WebSocketSubjectTradeCandlesAdd WebSocketSubject = "trade.candles.add" + + // private subjects + WebSocketSubjectOrderChange WebSocketSubject = "orderChange" + WebSocketSubjectAccountBalance WebSocketSubject = "account.balance" + WebSocketSubjectStopOrder WebSocketSubject = "stopOrder" +) + +type WebSocketCommand struct { + Id int64 `json:"id"` + Type WebSocketMessageType `json:"type"` + Topic string `json:"topic"` + PrivateChannel bool `json:"privateChannel"` + Response bool `json:"response"` +} + +func (c *WebSocketCommand) JSON() ([]byte, error) { + type tt WebSocketCommand + var a = (*tt)(c) + return json.Marshal(a) +} + +type WebSocketEvent struct { + Type WebSocketMessageType `json:"type"` + Topic string `json:"topic"` + Subject WebSocketSubject `json:"subject"` + Data json.RawMessage `json:"data"` + Code int `json:"code"` // used in type error + + // Object is used for storing the parsed Data + Object interface{} `json:"-"` +} + +type WebSocketTickerEvent struct { + Sequence string `json:"sequence"` + Price fixedpoint.Value `json:"price"` + Size fixedpoint.Value `json:"size"` + BestAsk fixedpoint.Value `json:"bestAsk"` + BestAskSize fixedpoint.Value `json:"bestAskSize"` + BestBid fixedpoint.Value `json:"bestBid"` + BestBidSize fixedpoint.Value `json:"bestBidSize"` +} + +type WebSocketOrderBookL2Event struct { + SequenceStart int64 `json:"sequenceStart"` + SequenceEnd int64 `json:"sequenceEnd"` + Symbol string `json:"symbol"` + Changes struct { + Asks types.PriceVolumeSlice `json:"asks"` + Bids types.PriceVolumeSlice `json:"bids"` + } `json:"changes"` + Time types.MillisecondTimestamp `json:"time"` +} + +type WebSocketCandleEvent struct { + Symbol string `json:"symbol"` + Candles []string `json:"candles"` + Time types.MillisecondTimestamp `json:"time"` + + // Interval is an injected field (not from the payload) + Interval types.Interval + + // Is a new candle or not + Add bool +} + +func (e *WebSocketCandleEvent) KLine() types.KLine { + startTime := types.MustParseUnixTimestamp(e.Candles[0]) + openPrice := fixedpoint.MustNewFromString(e.Candles[1]) + closePrice := fixedpoint.MustNewFromString(e.Candles[2]) + highPrice := fixedpoint.MustNewFromString(e.Candles[3]) + lowPrice := fixedpoint.MustNewFromString(e.Candles[4]) + volume := fixedpoint.MustNewFromString(e.Candles[5]) + quoteVolume := fixedpoint.MustNewFromString(e.Candles[6]) + kline := types.KLine{ + Exchange: types.ExchangeKucoin, + Symbol: toGlobalSymbol(e.Symbol), + StartTime: types.Time(startTime), + EndTime: types.Time(startTime.Add(e.Interval.Duration() - time.Millisecond)), + Interval: e.Interval, + Open: openPrice, + Close: closePrice, + High: highPrice, + Low: lowPrice, + Volume: volume, + QuoteVolume: quoteVolume, + Closed: false, + } + return kline +} + +type WebSocketPrivateOrderEvent struct { + OrderId string `json:"orderId"` + TradeId string `json:"tradeId"` + Symbol string `json:"symbol"` + OrderType string `json:"orderType"` + Side string `json:"side"` + Type string `json:"type"` + OrderTime types.NanosecondTimestamp `json:"orderTime"` + Price fixedpoint.Value `json:"price"` + Size fixedpoint.Value `json:"size"` + FilledSize fixedpoint.Value `json:"filledSize"` + RemainSize fixedpoint.Value `json:"remainSize"` + + Liquidity string `json:"liquidity"` + MatchPrice fixedpoint.Value `json:"matchPrice"` + MatchSize fixedpoint.Value `json:"matchSize"` + ClientOid string `json:"clientOid"` + Status string `json:"status"` + Ts types.MillisecondTimestamp `json:"ts"` +} + +type WebSocketAccountBalanceEvent struct { + Total fixedpoint.Value `json:"total"` + Available fixedpoint.Value `json:"available"` + AvailableChange fixedpoint.Value `json:"availableChange"` + Currency string `json:"currency"` + Hold fixedpoint.Value `json:"hold"` + HoldChange fixedpoint.Value `json:"holdChange"` + RelationEvent string `json:"relationEvent"` + RelationEventId string `json:"relationEventId"` + RelationContext struct { + Symbol string `json:"symbol"` + TradeId string `json:"tradeId"` + OrderId string `json:"orderId"` + } `json:"relationContext"` + Time string `json:"time"` +} diff --git a/pkg/exchange/max/client_order_id.go b/pkg/exchange/max/client_order_id.go new file mode 100644 index 0000000..b3f7c15 --- /dev/null +++ b/pkg/exchange/max/client_order_id.go @@ -0,0 +1,42 @@ +package max + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/google/uuid" +) + +// BBGO is a broker on MAX +const spotBrokerID = "bbgo" + +func NewClientOrderID(originalID string, tags ...string) (clientOrderID string) { + // skip blank client order ID + if originalID == types.NoClientOrderID { + return "" + } + + prefix := "x-" + spotBrokerID + "-" + + for _, tag := range tags { + prefix += tag + "-" + } + + prefixLen := len(prefix) + + if originalID != "" { + // try to keep the whole original client order ID if user specifies it. + if prefixLen+len(originalID) > 32 { + return originalID + } + + clientOrderID = prefix + originalID + return clientOrderID + } + + clientOrderID = uuid.New().String() + clientOrderID = prefix + clientOrderID + if len(clientOrderID) > 32 { + return clientOrderID[0:32] + } + + return clientOrderID +} diff --git a/pkg/exchange/max/convert.go b/pkg/exchange/max/convert.go new file mode 100644 index 0000000..61d5b0c --- /dev/null +++ b/pkg/exchange/max/convert.go @@ -0,0 +1,342 @@ +package max + +import ( + "fmt" + "strings" + "time" + + max "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/max/maxapi" + v3 "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/max/maxapi/v3" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func toGlobalCurrency(currency string) string { + return strings.ToUpper(currency) +} + +func toLocalCurrency(currency string) string { + return strings.ToLower(currency) +} + +func toLocalSymbol(symbol string) string { + return strings.ToLower(symbol) +} + +func toGlobalSymbol(symbol string) string { + return strings.ToUpper(symbol) +} + +func toLocalSideType(side types.SideType) string { + return strings.ToLower(string(side)) +} + +func toGlobalSideType(v string) types.SideType { + switch strings.ToLower(v) { + case "bid", "buy": + return types.SideTypeBuy + + case "ask", "sell": + return types.SideTypeSell + + case "self-trade": + return types.SideTypeSelf + + } + + return types.SideType(v) +} + +func toGlobalRewards(maxRewards []max.Reward) ([]types.Reward, error) { + // convert to global reward + var rewards []types.Reward + for _, r := range maxRewards { + // ignore "accepted" + if r.State != "done" { + continue + } + + reward, err := r.Reward() + if err != nil { + return nil, err + } + + rewards = append(rewards, *reward) + } + + return rewards, nil +} + +func toGlobalOrderStatus( + orderState max.OrderState, executedVolume, remainingVolume fixedpoint.Value, +) types.OrderStatus { + switch orderState { + + case max.OrderStateCancel: + return types.OrderStatusCanceled + + case max.OrderStateFinalizing, max.OrderStateDone: + if executedVolume.IsZero() { + return types.OrderStatusCanceled + } else if remainingVolume.IsZero() { + return types.OrderStatusFilled + } + + return types.OrderStatusFilled + + case max.OrderStateWait: + if executedVolume.Sign() > 0 && remainingVolume.Sign() > 0 { + return types.OrderStatusPartiallyFilled + } + + return types.OrderStatusNew + + case max.OrderStateConvert: + if executedVolume.Sign() > 0 && remainingVolume.Sign() > 0 { + return types.OrderStatusPartiallyFilled + } + + return types.OrderStatusNew + + case max.OrderStateFailed: + return types.OrderStatusRejected + + } + + log.Errorf("can not convert MAX exchange order status, unknown order state: %q", orderState) + return types.OrderStatus(orderState) +} + +func toGlobalOrderType(orderType max.OrderType) types.OrderType { + switch orderType { + case max.OrderTypeLimit: + return types.OrderTypeLimit + + case max.OrderTypeMarket: + return types.OrderTypeMarket + + case max.OrderTypeStopLimit: + return types.OrderTypeStopLimit + + case max.OrderTypeStopMarket: + return types.OrderTypeStopMarket + + case max.OrderTypeIOCLimit: + return types.OrderTypeLimit + + case max.OrderTypePostOnly: + return types.OrderTypeLimitMaker + + } + + log.Errorf("order convert error, unknown order type: %v", orderType) + return types.OrderType(orderType) +} + +func toLocalOrderType(orderType types.OrderType) (max.OrderType, error) { + switch orderType { + + case types.OrderTypeStopLimit: + return max.OrderTypeStopLimit, nil + + case types.OrderTypeStopMarket: + return max.OrderTypeStopMarket, nil + + case types.OrderTypeLimitMaker: + return max.OrderTypePostOnly, nil + + case types.OrderTypeLimit: + return max.OrderTypeLimit, nil + + case types.OrderTypeMarket: + return max.OrderTypeMarket, nil + } + + return "", fmt.Errorf("order type %s not supported", orderType) +} + +func toGlobalOrders(maxOrders []max.Order) (orders []types.Order, err error) { + for _, localOrder := range maxOrders { + o, err := toGlobalOrder(localOrder) + if err != nil { + log.WithError(err).Error("order convert error") + } else { + orders = append(orders, *o) + } + } + + return orders, err +} + +func toGlobalOrder(maxOrder max.Order) (*types.Order, error) { + executedVolume := maxOrder.ExecutedVolume + remainingVolume := maxOrder.RemainingVolume + isMargin := maxOrder.WalletType == max.WalletTypeMargin + return &types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: maxOrder.ClientOID, + Symbol: toGlobalSymbol(maxOrder.Market), + Side: toGlobalSideType(maxOrder.Side), + Type: toGlobalOrderType(maxOrder.OrderType), + Quantity: maxOrder.Volume, + Price: maxOrder.Price, + TimeInForce: types.TimeInForceGTC, // MAX only supports GTC + GroupID: maxOrder.GroupID, + }, + Exchange: types.ExchangeMax, + IsWorking: maxOrder.State == max.OrderStateWait, + OrderID: maxOrder.ID, + Status: toGlobalOrderStatus(maxOrder.State, executedVolume, remainingVolume), + OriginalStatus: string(maxOrder.State), + ExecutedQuantity: executedVolume, + CreationTime: types.Time(maxOrder.CreatedAt.Time()), + UpdateTime: types.Time(maxOrder.UpdatedAt.Time()), + IsMargin: isMargin, + IsIsolated: false, // isolated margin is not supported + }, nil +} + +func toGlobalTradeV3(t v3.Trade) ([]types.Trade, error) { + var trades []types.Trade + isMargin := t.WalletType == max.WalletTypeMargin + side := toGlobalSideType(t.Side) + + fee := fixedpoint.Zero + if t.Fee != nil { + fee = *t.Fee + } + + trade := types.Trade{ + ID: t.ID, + OrderID: t.OrderID, + Price: t.Price, + Symbol: toGlobalSymbol(t.Market), + Exchange: types.ExchangeMax, + Quantity: t.Volume, + Side: side, + IsBuyer: t.IsBuyer(), + IsMaker: t.IsMaker(), + Fee: fee, + FeeProcessing: t.Fee == nil, + FeeCurrency: toGlobalCurrency(t.FeeCurrency), + FeeDiscounted: t.FeeDiscounted, + QuoteQuantity: t.Funds, + Time: types.Time(t.CreatedAt), + IsMargin: isMargin, + IsIsolated: false, + IsFutures: false, + } + + if t.Side == "self-trade" { + trade.Side = types.SideTypeSell + + // create trade for bid + bidTrade := trade + bidTrade.Side = types.SideTypeBuy + bidTrade.OrderID = t.SelfTradeBidOrderID + bidTrade.Fee = t.SelfTradeBidFee + bidTrade.FeeCurrency = toGlobalCurrency(t.SelfTradeBidFeeCurrency) + bidTrade.FeeDiscounted = t.SelfTradeBidFeeDiscounted + bidTrade.IsBuyer = !trade.IsBuyer + bidTrade.IsMaker = !trade.IsMaker + trades = append(trades, bidTrade) + } + + trades = append(trades, trade) + + return trades, nil +} + +func toGlobalTradeV2(t max.Trade) (*types.Trade, error) { + isMargin := t.WalletType == max.WalletTypeMargin + side := toGlobalSideType(t.Side) + return &types.Trade{ + ID: t.ID, + OrderID: t.OrderID, + Price: t.Price, + Symbol: toGlobalSymbol(t.Market), + Exchange: types.ExchangeMax, + Quantity: t.Volume, + Side: side, + IsBuyer: t.IsBuyer(), + IsMaker: t.IsMaker(), + Fee: t.Fee, + FeeCurrency: toGlobalCurrency(t.FeeCurrency), + QuoteQuantity: t.Funds, + Time: types.Time(t.CreatedAt), + IsMargin: isMargin, + IsIsolated: false, + IsFutures: false, + }, nil +} + +func toGlobalDepositStatus(a max.DepositState) types.DepositStatus { + switch a { + + case max.DepositStateSubmitting, max.DepositStateSubmitted, max.DepositStatePending, max.DepositStateChecking: + return types.DepositPending + + case max.DepositStateRejected: + return types.DepositRejected + + case max.DepositStateCancelled: + return types.DepositCancelled + + case max.DepositStateAccepted: + return types.DepositSuccess + } + + // other states goes to this + // max.DepositStateSuspect, max.DepositStateSuspended + log.Warnf("unsupported deposit state %q from max exchange", a) + return types.DepositStatus(a) +} + +func convertWebSocketTrade(t max.TradeUpdate) (*types.Trade, error) { + // skip trade ID that is the same. however this should not happen + var side = toGlobalSideType(t.Side) + + return &types.Trade{ + ID: t.ID, + OrderID: t.OrderID, + Symbol: toGlobalSymbol(t.Market), + Exchange: types.ExchangeMax, + Price: t.Price, + Quantity: t.Volume, + Side: side, + IsBuyer: side == types.SideTypeBuy, + IsMaker: t.Maker, + Fee: t.Fee, + FeeCurrency: toGlobalCurrency(t.FeeCurrency), + FeeDiscounted: t.FeeDiscounted, + QuoteQuantity: t.Funds, + Time: types.Time(t.Timestamp.Time()), + }, nil +} + +func convertWebSocketOrderUpdate(u max.OrderUpdate) (*types.Order, error) { + timeInForce := types.TimeInForceGTC + if u.OrderType == max.OrderTypeIOCLimit { + timeInForce = types.TimeInForceIOC + } + + return &types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: u.ClientOID, + Symbol: toGlobalSymbol(u.Market), + Side: toGlobalSideType(u.Side), + Type: toGlobalOrderType(u.OrderType), + Quantity: u.Volume, + Price: u.Price, + StopPrice: u.StopPrice, + TimeInForce: timeInForce, // MAX only supports GTC + GroupID: u.GroupID, + }, + Exchange: types.ExchangeMax, + OrderID: u.ID, + Status: toGlobalOrderStatus(u.State, u.ExecutedVolume, u.RemainingVolume), + ExecutedQuantity: u.ExecutedVolume, + CreationTime: types.Time(time.Unix(0, u.CreatedAtMs*int64(time.Millisecond))), + UpdateTime: types.Time(time.Unix(0, u.UpdateTime*int64(time.Millisecond))), + }, nil +} diff --git a/pkg/exchange/max/convert_test.go b/pkg/exchange/max/convert_test.go new file mode 100644 index 0000000..0aeebf3 --- /dev/null +++ b/pkg/exchange/max/convert_test.go @@ -0,0 +1,116 @@ +package max + +import ( + "encoding/json" + "testing" + + v3 "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/max/maxapi/v3" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +func Test_toGlobalTradeV3(t *testing.T) { + assert := assert.New(t) + + t.Run("ask trade", func(t *testing.T) { + str := ` + { + "id": 68444, + "order_id": 87, + "wallet_type": "spot", + "price": "21499.0", + "volume": "0.2658", + "funds": "5714.4", + "market": "ethtwd", + "market_name": "ETH/TWD", + "side": "bid", + "fee": "0.00001", + "fee_currency": "usdt", + "self_trade_bid_fee": "0.00001", + "self_trade_bid_fee_currency": "eth", + "self_trade_bid_order_id": 86, + "liquidity": "maker", + "created_at": 1521726960357 + } + ` + + var trade v3.Trade + assert.NoError(json.Unmarshal([]byte(str), &trade)) + + trades, err := toGlobalTradeV3(trade) + assert.NoError(err) + assert.Len(trades, 1) + + assert.Equal(uint64(87), trades[0].OrderID) + assert.Equal(types.SideTypeBuy, trades[0].Side) + }) + + t.Run("bid trade", func(t *testing.T) { + str := ` + { + "id": 68444, + "order_id": 87, + "wallet_type": "spot", + "price": "21499.0", + "volume": "0.2658", + "funds": "5714.4", + "market": "ethtwd", + "market_name": "ETH/TWD", + "side": "ask", + "fee": "0.00001", + "fee_currency": "usdt", + "self_trade_bid_fee": "0.00001", + "self_trade_bid_fee_currency": "eth", + "self_trade_bid_order_id": 86, + "liquidity": "maker", + "created_at": 1521726960357 + } + ` + + var trade v3.Trade + assert.NoError(json.Unmarshal([]byte(str), &trade)) + + trades, err := toGlobalTradeV3(trade) + assert.NoError(err) + assert.Len(trades, 1) + + assert.Equal(uint64(87), trades[0].OrderID) + assert.Equal(types.SideTypeSell, trades[0].Side) + }) + + t.Run("self trade", func(t *testing.T) { + str := ` + { + "id": 68444, + "order_id": 87, + "wallet_type": "spot", + "price": "21499.0", + "volume": "0.2658", + "funds": "5714.4", + "market": "ethtwd", + "market_name": "ETH/TWD", + "side": "self-trade", + "fee": "0.00001", + "fee_currency": "usdt", + "self_trade_bid_fee": "0.00001", + "self_trade_bid_fee_currency": "eth", + "self_trade_bid_order_id": 86, + "liquidity": "maker", + "created_at": 1521726960357 + } + ` + + var trade v3.Trade + assert.NoError(json.Unmarshal([]byte(str), &trade)) + + trades, err := toGlobalTradeV3(trade) + assert.NoError(err) + assert.Len(trades, 2) + + assert.Equal(uint64(86), trades[0].OrderID) + assert.Equal(types.SideTypeBuy, trades[0].Side) + + assert.Equal(uint64(87), trades[1].OrderID) + assert.Equal(types.SideTypeSell, trades[1].Side) + }) +} diff --git a/pkg/exchange/max/exchange.go b/pkg/exchange/max/exchange.go new file mode 100644 index 0000000..57ed9de --- /dev/null +++ b/pkg/exchange/max/exchange.go @@ -0,0 +1,1254 @@ +package max + +import ( + "context" + "fmt" + "math" + "os" + "sort" + "strconv" + "time" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "go.uber.org/multierr" + "golang.org/x/time/rate" + + maxapi "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/max/maxapi" + v3 "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/max/maxapi/v3" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +var log = logrus.WithField("exchange", "max") + +func init() { + _ = types.ExchangeTradeHistoryService(&Exchange{}) + +} + +type Exchange struct { + types.MarginSettings + + key, secret string + client *maxapi.RestClient + + v3client *v3.Client + v3margin *v3.MarginService + + submitOrderLimiter, queryTradeLimiter, accountQueryLimiter, closedOrderQueryLimiter, marketDataLimiter *rate.Limiter +} + +func New(key, secret string) *Exchange { + baseURL := maxapi.ProductionAPIURL + if override := os.Getenv("MAX_API_BASE_URL"); len(override) > 0 { + baseURL = override + } + + client := maxapi.NewRestClient(baseURL) + client.Auth(key, secret) + return &Exchange{ + client: client, + key: key, + // pragma: allowlist nextline secret + secret: secret, + v3client: &v3.Client{Client: client}, + v3margin: &v3.MarginService{Client: client}, + + queryTradeLimiter: rate.NewLimiter(rate.Every(250*time.Millisecond), 2), + + // 1200 cpm (1200 requests per minute = 20 requests per second) + submitOrderLimiter: rate.NewLimiter(rate.Every(50*time.Millisecond), 20), + + // closedOrderQueryLimiter is used for the closed orders query rate limit, 1 request per second + closedOrderQueryLimiter: rate.NewLimiter(rate.Every(1*time.Second), 1), + accountQueryLimiter: rate.NewLimiter(rate.Every(250*time.Millisecond), 1), + marketDataLimiter: rate.NewLimiter(rate.Every(2*time.Second), 10), + } +} + +func (e *Exchange) Name() types.ExchangeName { + return types.ExchangeMax +} + +func (e *Exchange) QueryTicker(ctx context.Context, symbol string) (*types.Ticker, error) { + req := e.client.NewGetTickerRequest() + req.Market(toLocalSymbol(symbol)) + ticker, err := req.Do(ctx) + + if err != nil { + return nil, err + } + + return &types.Ticker{ + Time: ticker.Time, + Volume: ticker.Volume, + Last: ticker.Last, + Open: ticker.Open, + High: ticker.High, + Low: ticker.Low, + Buy: ticker.Buy, + Sell: ticker.Sell, + }, nil +} + +func (e *Exchange) QueryTickers(ctx context.Context, symbol ...string) (map[string]types.Ticker, error) { + if err := e.marketDataLimiter.Wait(ctx); err != nil { + return nil, err + } + + var tickers = make(map[string]types.Ticker) + if len(symbol) == 1 { + ticker, err := e.QueryTicker(ctx, symbol[0]) + if err != nil { + return nil, err + } + + tickers[toGlobalSymbol(symbol[0])] = *ticker + } else { + req := e.client.NewGetTickersRequest() + maxTickers, err := req.Do(ctx) + if err != nil { + return nil, err + } + + m := make(map[string]struct{}) + exists := struct{}{} + for _, s := range symbol { + m[toGlobalSymbol(s)] = exists + } + + for k, v := range maxTickers { + if _, ok := m[toGlobalSymbol(k)]; len(symbol) != 0 && !ok { + continue + } + + tickers[toGlobalSymbol(k)] = types.Ticker{ + Time: v.Time, + Volume: v.Volume, + Last: v.Last, + Open: v.Open, + High: v.High, + Low: v.Low, + Buy: v.Buy, + Sell: v.Sell, + } + } + } + + return tickers, nil +} + +func (e *Exchange) QueryMarkets(ctx context.Context) (types.MarketMap, error) { + req := e.client.NewGetMarketsRequest() + remoteMarkets, err := req.Do(ctx) + if err != nil { + return nil, err + } + + markets := types.MarketMap{} + for _, m := range remoteMarkets { + symbol := toGlobalSymbol(m.ID) + + market := types.Market{ + Exchange: types.ExchangeMax, + Symbol: symbol, + LocalSymbol: m.ID, + PricePrecision: m.QuoteUnitPrecision, + VolumePrecision: m.BaseUnitPrecision, + QuoteCurrency: toGlobalCurrency(m.QuoteUnit), + BaseCurrency: toGlobalCurrency(m.BaseUnit), + MinNotional: m.MinQuoteAmount, + MinAmount: m.MinQuoteAmount, + + MinQuantity: m.MinBaseAmount, + MaxQuantity: fixedpoint.NewFromInt(10000), + // make it like 0.0001 + StepSize: fixedpoint.NewFromFloat(1.0 / math.Pow10(m.BaseUnitPrecision)), + // used in the price formatter + MinPrice: fixedpoint.NewFromFloat(1.0 / math.Pow10(m.QuoteUnitPrecision)), + MaxPrice: fixedpoint.NewFromInt(10000), + TickSize: fixedpoint.NewFromFloat(1.0 / math.Pow10(m.QuoteUnitPrecision)), + } + + markets[symbol] = market + } + + return markets, nil +} + +func (e *Exchange) NewStream() types.Stream { + stream := NewStream(e.key, e.secret) + stream.MarginSettings = e.MarginSettings + return stream +} + +func (e *Exchange) QueryOrderTrades(ctx context.Context, q types.OrderQuery) ([]types.Trade, error) { + if q.OrderID == "" { + return nil, errors.New("max.QueryOrder: OrderID is required parameter") + } + + orderID, err := strconv.ParseInt(q.OrderID, 10, 64) + if err != nil { + return nil, err + } + + maxTrades, err := e.v3client.NewGetOrderTradesRequest().OrderID(uint64(orderID)).Do(ctx) + if err != nil { + return nil, err + } + + var trades []types.Trade + for _, t := range maxTrades { + localTrades, err := toGlobalTradeV3(t) + if err != nil { + log.WithError(err).Errorf("can not convert trade: %+v", t) + continue + } + + // because self-trades will contains ask and bid orders in its struct + // we need to make sure the trade's order is what we want + for _, localTrade := range localTrades { + if localTrade.OrderID == uint64(orderID) { + trades = append(trades, localTrade) + } + } + } + + // ensure everything is sorted ascending + trades = types.SortTradesAscending(trades) + return trades, nil +} + +func (e *Exchange) QueryOrder(ctx context.Context, q types.OrderQuery) (*types.Order, error) { + if len(q.OrderID) == 0 && len(q.ClientOrderID) == 0 { + return nil, errors.New("max.QueryOrder: one of OrderID/ClientOrderID is required parameter") + } + + if len(q.OrderID) != 0 && len(q.ClientOrderID) != 0 { + return nil, errors.New("max.QueryOrder: only accept one parameter of OrderID/ClientOrderID") + } + + request := e.v3client.NewGetOrderRequest() + + if len(q.OrderID) != 0 { + orderID, err := strconv.ParseInt(q.OrderID, 10, 64) + if err != nil { + return nil, err + } + + request.Id(uint64(orderID)) + } + + if len(q.ClientOrderID) != 0 { + request.ClientOrderID(q.ClientOrderID) + } + + maxOrder, err := request.Do(ctx) + if err != nil { + return nil, err + } + + return toGlobalOrder(*maxOrder) +} + +func (e *Exchange) QueryOpenOrders(ctx context.Context, symbol string) ([]types.Order, error) { + market := toLocalSymbol(symbol) + walletType := maxapi.WalletTypeSpot + if e.MarginSettings.IsMargin { + walletType = maxapi.WalletTypeMargin + } + + // timestamp can't be negative, so we need to use time which epochtime is > 0 + since, err := e.getLaunchDate() + if err != nil { + return nil, err + } + + // use types.OrderMap because the timestamp params is inclusive. We will get the duplicated order if we use the last order as new since. + // If we use since = since + 1ms, we may miss some orders with the same created_at. + // As a result, we use OrderMap to avoid duplicated or missing order. + var orderMap types.OrderMap = make(types.OrderMap) + var limit uint = 1000 + for { + req := e.v3client.NewGetWalletOpenOrdersRequest(walletType).Market(market).Timestamp(since).OrderBy(maxapi.OrderByAsc).Limit(limit) + maxOrders, err := req.Do(ctx) + if err != nil { + return nil, err + } + + numUniqueOrders := 0 + for _, maxOrder := range maxOrders { + createdAt := maxOrder.CreatedAt.Time() + if createdAt.After(since) { + since = createdAt + } + + order, err := toGlobalOrder(maxOrder) + if err != nil { + return nil, err + } + + if _, exist := orderMap[order.OrderID]; !exist { + orderMap[order.OrderID] = *order + numUniqueOrders++ + } + } + + if len(maxOrders) < int(limit) { + break + } + + if numUniqueOrders == 0 { + break + } + } + + return orderMap.Orders(), err +} + +func (e *Exchange) QueryClosedOrders( + ctx context.Context, symbol string, since, until time.Time, lastOrderID uint64, +) ([]types.Order, error) { + if !since.IsZero() || !until.IsZero() { + return e.queryClosedOrdersByTime(ctx, symbol, since, until, maxapi.OrderByAsc) + } + + return e.queryClosedOrdersByLastOrderID(ctx, symbol, lastOrderID) +} + +func (e *Exchange) QueryClosedOrdersDesc( + ctx context.Context, symbol string, since, until time.Time, lastOrderID uint64, +) ([]types.Order, error) { + closedOrders, err := e.queryClosedOrdersByTime(ctx, symbol, since, until, maxapi.OrderByDesc) + if lastOrderID == 0 { + return closedOrders, err + } + + var filterClosedOrders []types.Order + for _, closedOrder := range closedOrders { + if closedOrder.OrderID > lastOrderID { + filterClosedOrders = append(filterClosedOrders, closedOrder) + } + } + + return filterClosedOrders, err +} + +func (e *Exchange) queryClosedOrdersByLastOrderID( + ctx context.Context, symbol string, lastOrderID uint64, +) (orders []types.Order, err error) { + if err := e.closedOrderQueryLimiter.Wait(ctx); err != nil { + return orders, err + } + + market := toLocalSymbol(symbol) + walletType := maxapi.WalletTypeSpot + if e.MarginSettings.IsMargin { + walletType = maxapi.WalletTypeMargin + } + + if lastOrderID == 0 { + lastOrderID = 1 + } + + req := e.v3client.NewGetWalletOrderHistoryRequest(walletType). + Market(market). + FromID(lastOrderID). + Limit(1000) + + maxOrders, err := req.Do(ctx) + if err != nil { + return orders, err + } + + for _, maxOrder := range maxOrders { + order, err2 := toGlobalOrder(maxOrder) + if err2 != nil { + err = multierr.Append(err, err2) + continue + } + + orders = append(orders, *order) + } + + if err != nil { + return nil, err + } + return types.SortOrdersAscending(orders), nil +} + +func (e *Exchange) queryClosedOrdersByTime( + ctx context.Context, symbol string, since, until time.Time, orderByType maxapi.OrderByType, +) (orders []types.Order, err error) { + if err := e.closedOrderQueryLimiter.Wait(ctx); err != nil { + return orders, err + } + + // there is since limit for closed orders API. If the since is before launch date, it will respond error + sinceLimit, err := e.getLaunchDate() + if err != nil { + return orders, err + } + if since.Before(sinceLimit) { + since = sinceLimit + } + + if until.IsZero() { + until = time.Now() + } + + market := toLocalSymbol(symbol) + walletType := maxapi.WalletTypeSpot + if e.MarginSettings.IsMargin { + walletType = maxapi.WalletTypeMargin + } + + req := e.v3client.NewGetWalletClosedOrdersRequest(walletType). + Market(market). + Limit(1000). + OrderBy(orderByType) + + switch orderByType { + case maxapi.OrderByAsc: + req.Timestamp(since) + case maxapi.OrderByDesc: + req.Timestamp(until) + case maxapi.OrderByAscUpdatedAt: + // not implement yet + return nil, fmt.Errorf("unsupported order by type: %s", orderByType) + case maxapi.OrderByDescUpdatedAt: + // not implement yet + return nil, fmt.Errorf("unsupported order by type: %s", orderByType) + default: + return nil, fmt.Errorf("unsupported order by type: %s", orderByType) + } + + maxOrders, err := req.Do(ctx) + if err != nil { + return orders, err + } + + for _, maxOrder := range maxOrders { + createdAt := maxOrder.CreatedAt.Time() + if createdAt.Before(since) || createdAt.After(until) { + continue + } + + order, err2 := toGlobalOrder(maxOrder) + if err2 != nil { + err = multierr.Append(err, err2) + continue + } + + orders = append(orders, *order) + } + + if err != nil { + return nil, err + } + return orders, nil +} + +func (e *Exchange) CancelAllOrders(ctx context.Context) ([]types.Order, error) { + walletType := maxapi.WalletTypeSpot + if e.MarginSettings.IsMargin { + walletType = maxapi.WalletTypeMargin + } + + req := e.v3client.NewCancelWalletOrderAllRequest(walletType) + var orderResponses, err = req.Do(ctx) + if err != nil { + return nil, err + } + + var maxOrders []maxapi.Order + for _, resp := range orderResponses { + if resp.Error == nil { + maxOrders = append(maxOrders, resp.Order) + } + } + + return toGlobalOrders(maxOrders) +} + +func (e *Exchange) CancelOrdersBySymbol(ctx context.Context, symbol string) ([]types.Order, error) { + market := toLocalSymbol(symbol) + walletType := maxapi.WalletTypeSpot + if e.MarginSettings.IsMargin { + walletType = maxapi.WalletTypeMargin + } + + req := e.v3client.NewCancelWalletOrderAllRequest(walletType) + req.Market(market) + + var orderResponses, err = req.Do(ctx) + if err != nil { + return nil, err + } + + var maxOrders []maxapi.Order + for _, resp := range orderResponses { + if resp.Error == nil { + maxOrders = append(maxOrders, resp.Order) + } + } + + return toGlobalOrders(maxOrders) +} + +func (e *Exchange) CancelOrdersByGroupID(ctx context.Context, groupID uint32) ([]types.Order, error) { + walletType := maxapi.WalletTypeSpot + if e.MarginSettings.IsMargin { + walletType = maxapi.WalletTypeMargin + } + + req := e.v3client.NewCancelWalletOrderAllRequest(walletType) + req.GroupID(groupID) + + var orderResponses, err = req.Do(ctx) + if err != nil { + return nil, err + } + + var maxOrders []maxapi.Order + for _, resp := range orderResponses { + if resp.Error == nil { + maxOrders = append(maxOrders, resp.Order) + } + } + + return toGlobalOrders(maxOrders) +} + +func (e *Exchange) CancelOrders(ctx context.Context, orders ...types.Order) (err2 error) { + walletType := maxapi.WalletTypeSpot + if e.MarginSettings.IsMargin { + walletType = maxapi.WalletTypeMargin + } + + var groupIDs = make(map[uint32]struct{}) + var orphanOrders []types.Order + for _, o := range orders { + if o.GroupID > 0 { + groupIDs[o.GroupID] = struct{}{} + } else { + orphanOrders = append(orphanOrders, o) + } + } + + if len(groupIDs) > 0 { + for groupID := range groupIDs { + req := e.v3client.NewCancelWalletOrderAllRequest(walletType) + req.GroupID(groupID) + + if _, err := req.Do(ctx); err != nil { + log.WithError(err).Errorf("group id order cancel error") + err2 = err + } + } + } + + for _, o := range orphanOrders { + req := e.v3client.NewCancelOrderRequest() + if o.OrderID > 0 { + req.Id(o.OrderID) + } else if len(o.ClientOrderID) > 0 && o.ClientOrderID != types.NoClientOrderID { + req.ClientOrderID(o.ClientOrderID) + } else { + return fmt.Errorf("order id or client order id is not defined, order=%+v", o) + } + + if _, err := req.Do(ctx); err != nil { + log.WithError(err).Errorf("order cancel error") + err2 = err + } + } + + return err2 +} + +func (e *Exchange) Withdraw( + ctx context.Context, asset string, amount fixedpoint.Value, address string, options *types.WithdrawalOptions, +) error { + asset = toLocalCurrency(asset) + + addresses, err := e.client.WithdrawalService.NewGetWithdrawalAddressesRequest(). + Currency(asset). + Do(ctx) + + if err != nil { + return err + } + + var whitelistAddress maxapi.WithdrawalAddress + for _, a := range addresses { + if a.Address == address { + whitelistAddress = a + break + } + } + + if whitelistAddress.Address != address { + return fmt.Errorf("address %s is not in the whitelist", address) + } + + if whitelistAddress.UUID == "" { + return errors.New("address UUID can not be empty") + } + + response, err := e.client.WithdrawalService.NewWithdrawalRequest(). + Currency(asset). + Amount(amount.Float64()). + AddressUUID(whitelistAddress.UUID). + Do(ctx) + + if err != nil { + return err + } + + log.Infof("withdrawal request response: %+v", response) + return nil +} + +func (e *Exchange) SubmitOrder(ctx context.Context, order types.SubmitOrder) (createdOrder *types.Order, err error) { + if err := e.submitOrderLimiter.Wait(ctx); err != nil { + return nil, err + } + + walletType := maxapi.WalletTypeSpot + if e.MarginSettings.IsMargin { + walletType = maxapi.WalletTypeMargin + } + + o := order + orderType, err := toLocalOrderType(o.Type) + if err != nil { + return createdOrder, err + } + + // case IOC type + if orderType == maxapi.OrderTypeLimit && o.TimeInForce == types.TimeInForceIOC { + orderType = maxapi.OrderTypeIOCLimit + } + + var quantityString string + if o.Market.Symbol != "" { + quantityString = o.Market.FormatQuantity(o.Quantity) + } else { + quantityString = o.Quantity.String() + } + + clientOrderID := NewClientOrderID(o.ClientOrderID) + + req := e.v3client.NewCreateWalletOrderRequest(walletType) + req.Market(toLocalSymbol(o.Symbol)). + Side(toLocalSideType(o.Side)). + Volume(quantityString). + OrderType(orderType) + + if clientOrderID != "" { + req.ClientOrderID(clientOrderID) + } + + if o.GroupID > 0 { + req.GroupID(strconv.FormatUint(uint64(o.GroupID%math.MaxInt32), 10)) + } + + switch o.Type { + case types.OrderTypeStopLimit, types.OrderTypeLimit, types.OrderTypeLimitMaker: + var priceInString string + if o.Market.Symbol != "" { + priceInString = o.Market.FormatPrice(o.Price) + } else { + priceInString = o.Price.String() + } + req.Price(priceInString) + } + + // set stop price field for limit orders + switch o.Type { + case types.OrderTypeStopLimit, types.OrderTypeStopMarket: + var priceInString string + if o.Market.Symbol != "" { + priceInString = o.Market.FormatPrice(o.StopPrice) + } else { + priceInString = o.StopPrice.String() + } + req.StopPrice(priceInString) + } + + retOrder, err := req.Do(ctx) + if err != nil { + return createdOrder, err + } + + if retOrder == nil { + return createdOrder, errors.New("returned nil order") + } + + createdOrder, err = toGlobalOrder(*retOrder) + return createdOrder, err +} + +// PlatformFeeCurrency +func (e *Exchange) PlatformFeeCurrency() string { + return toGlobalCurrency("max") +} + +func (e *Exchange) getLaunchDate() (time.Time, error) { + // MAX launch date June 21th, 2018 + loc, err := time.LoadLocation("Asia/Taipei") + if err != nil { + return time.Time{}, err + } + + return time.Date(2018, time.June, 21, 0, 0, 0, 0, loc), nil +} + +func (e *Exchange) QuerySpotAccount(ctx context.Context) (*types.Account, error) { + if err := e.accountQueryLimiter.Wait(ctx); err != nil { + return nil, err + } + + vipLevel, err := e.client.NewGetVipLevelRequest().Do(ctx) + if err != nil { + return nil, err + } + + // MAX returns the fee rate in the following format: + // "maker_fee": 0.0005 -> 0.05% + // "taker_fee": 0.0015 -> 0.15% + a := &types.Account{ + AccountType: types.AccountTypeSpot, + MarginLevel: fixedpoint.Zero, + MakerFeeRate: fixedpoint.NewFromFloat(vipLevel.Current.MakerFee), // 0.15% = 0.0015 + TakerFeeRate: fixedpoint.NewFromFloat(vipLevel.Current.TakerFee), // 0.15% = 0.0015 + } + + balances, err := e.queryBalances(ctx, maxapi.WalletTypeSpot) + if err != nil { + return nil, err + } + a.UpdateBalances(balances) + return a, nil +} + +func (e *Exchange) QueryAccount(ctx context.Context) (*types.Account, error) { + if err := e.accountQueryLimiter.Wait(ctx); err != nil { + return nil, err + } + + vipLevel, err := e.client.NewGetVipLevelRequest().Do(ctx) + if err != nil { + return nil, err + } + + // MAX returns the fee rate in the following format: + // "maker_fee": 0.0005 -> 0.05% + // "taker_fee": 0.0015 -> 0.15% + + a := &types.Account{ + MarginLevel: fixedpoint.Zero, + MakerFeeRate: fixedpoint.NewFromFloat(vipLevel.Current.MakerFee), // 0.15% = 0.0015 + TakerFeeRate: fixedpoint.NewFromFloat(vipLevel.Current.TakerFee), // 0.15% = 0.0015 + } + + if e.MarginSettings.IsMargin { + a.AccountType = types.AccountTypeMargin + } else { + a.AccountType = types.AccountTypeSpot + } + + balances, err := e.QueryAccountBalances(ctx) + if err != nil { + return nil, err + } + a.UpdateBalances(balances) + + if e.MarginSettings.IsMargin { + req := e.v3client.NewGetMarginADRatioRequest() + adRatio, err := req.Do(ctx) + if err != nil { + return a, err + } + + a.MarginLevel = adRatio.AdRatio + a.TotalAccountValue = adRatio.AssetInUsdt + } + + return a, nil +} + +func (e *Exchange) QueryAccountBalances(ctx context.Context) (types.BalanceMap, error) { + walletType := maxapi.WalletTypeSpot + if e.MarginSettings.IsMargin { + walletType = maxapi.WalletTypeMargin + } + + return e.queryBalances(ctx, walletType) +} + +func (e *Exchange) queryBalances(ctx context.Context, walletType maxapi.WalletType) (types.BalanceMap, error) { + if err := e.accountQueryLimiter.Wait(ctx); err != nil { + return nil, err + } + + req := e.v3client.NewGetWalletAccountsRequest(walletType) + + accounts, err := req.Do(ctx) + if err != nil { + return nil, err + } + + var balances = make(types.BalanceMap) + for _, b := range accounts { + cur := toGlobalCurrency(b.Currency) + balances[cur] = types.Balance{ + Currency: cur, + Available: b.Balance, + Locked: b.Locked, + NetAsset: b.Balance.Add(b.Locked).Sub(b.Principal).Sub(b.Interest), + Borrowed: b.Principal, + Interest: b.Interest, + } + } + + return balances, nil +} + +func (e *Exchange) QueryWithdrawHistory( + ctx context.Context, asset string, since, until time.Time, +) (allWithdraws []types.Withdraw, err error) { + startTime := since + limit := 1000 + txIDs := map[string]struct{}{} + + emptyTime := time.Time{} + if startTime == emptyTime { + startTime, err = e.getLaunchDate() + if err != nil { + return nil, err + } + } + + for startTime.Before(until) { + // startTime ~ endTime must be in 60 days + endTime := startTime.AddDate(0, 0, 60) + if endTime.After(until) { + endTime = until + } + + log.Infof("querying withdraw %s: %s <=> %s", asset, startTime, endTime) + req := e.client.NewGetWithdrawalHistoryRequest() + if len(asset) > 0 { + req.Currency(toLocalCurrency(asset)) + } + + withdraws, err := req. + From(startTime). + To(endTime). + Limit(limit). + Do(ctx) + + if err != nil { + return allWithdraws, err + } + + if len(withdraws) == 0 { + startTime = endTime + continue + } + + for i := len(withdraws) - 1; i >= 0; i-- { + d := withdraws[i] + if _, ok := txIDs[d.TxID]; ok { + continue + } + + // we can convert this later + status := d.State + switch d.State { + + case "confirmed": + status = "completed" // make it compatible with binance + + case "submitting", "submitted", "accepted", + "rejected", "suspect", "approved", "delisted_processing", + "processing", "retryable", "sent", "canceled", + "failed", "pending", + "kgi_manually_processing", "kgi_manually_confirmed", "kgi_possible_failed", + "sygna_verifying": + + default: + status = d.State + + } + + txIDs[d.TxID] = struct{}{} + withdraw := types.Withdraw{ + Exchange: types.ExchangeMax, + ApplyTime: types.Time(d.CreatedAt), + Asset: toGlobalCurrency(d.Currency), + Amount: d.Amount, + Address: "", + AddressTag: "", + TransactionID: d.TxID, + TransactionFee: d.Fee, + TransactionFeeCurrency: d.FeeCurrency, + // WithdrawOrderID: d.WithdrawOrderID, + // Network: d.Network, + Status: status, + } + allWithdraws = append(allWithdraws, withdraw) + } + + // go next time frame + if len(withdraws) < limit { + startTime = endTime + } else { + // its in descending order, so we get the first record + startTime = withdraws[0].CreatedAt.Time() + } + } + + return allWithdraws, nil +} + +func (e *Exchange) QueryDepositHistory( + ctx context.Context, asset string, since, until time.Time, +) (allDeposits []types.Deposit, err error) { + startTime := since + limit := 1000 + txIDs := map[string]struct{}{} + + emptyTime := time.Time{} + if startTime == emptyTime { + startTime, err = e.getLaunchDate() + if err != nil { + return nil, err + } + } + + for startTime.Before(until) { + // startTime ~ endTime must be in 90 days + endTime := startTime.AddDate(0, 0, 60) + if endTime.After(until) { + endTime = until + } + + log.Infof("querying deposit history %s: %s <=> %s", asset, startTime, endTime) + + req := e.client.NewGetDepositHistoryRequest() + if len(asset) > 0 { + req.Currency(toLocalCurrency(asset)) + } + + deposits, err := req. + From(startTime). + To(endTime). + Limit(limit). + Do(ctx) + + if err != nil { + return nil, err + } + + for i := len(deposits) - 1; i >= 0; i-- { + d := deposits[i] + if _, ok := txIDs[d.TxID]; ok { + continue + } + + allDeposits = append(allDeposits, types.Deposit{ + Exchange: types.ExchangeMax, + Time: types.Time(d.CreatedAt), + Amount: d.Amount, + Asset: toGlobalCurrency(d.Currency), + Address: d.Address, // not supported + AddressTag: "", // not supported + TransactionID: d.TxID, + Status: toGlobalDepositStatus(d.State), + Confirmation: "", + }) + } + + if len(deposits) < limit { + startTime = endTime + } else { + startTime = time.Time(deposits[0].CreatedAt) + } + } + + return allDeposits, err +} + +// QueryTrades +// For MAX API spec +// start_time and end_time need to be within 3 days +// without any parameters -> return trades within 24 hours +// give start_time or end_time -> ignore parameter from_id +// give start_time or from_id -> order by time asc +// give end_time -> order by time desc +// limit should b1 1~1000 +// For this QueryTrades spec (to be compatible with batch.TradeBatchQuery) +// give LastTradeID -> ignore start_time (but still can filter the end_time) +// without any parameters -> return trades within 24 hours +func (e *Exchange) QueryTrades( + ctx context.Context, symbol string, options *types.TradeQueryOptions, +) (trades []types.Trade, err error) { + if err := e.queryTradeLimiter.Wait(ctx); err != nil { + return nil, err + } + + market := toLocalSymbol(symbol) + walletType := maxapi.WalletTypeSpot + if e.MarginSettings.IsMargin { + walletType = maxapi.WalletTypeMargin + } + + req := e.v3client.NewGetWalletTradesRequest(walletType) + req.Market(market) + + if options.Limit > 0 { + req.Limit(uint64(options.Limit)) + } else { + req.Limit(1000) + } + + // If we use start_time as parameter, MAX will ignore from_id. + // However, we want to use from_id as main parameter for batch.TradeBatchQuery + if options.LastTradeID > 0 { + // MAX uses inclusive last trade ID + req.From(options.LastTradeID) + } else { + // option's start_time and end_time need to be within 3 days + // so if the start_time and end_time is over 3 days, we make end_time down to start_time + 3 days + if options.StartTime != nil && options.EndTime != nil { + endTime := *options.EndTime + startTime := *options.StartTime + if endTime.Sub(startTime) > 72*time.Hour { + startTime := *options.StartTime + endTime = startTime.Add(72 * time.Hour) + } + req.StartTime(startTime) + req.EndTime(endTime) + } else if options.StartTime != nil { + req.StartTime(*options.StartTime) + } else if options.EndTime != nil { + req.EndTime(*options.EndTime) + } + } + + maxTrades, err := req.Do(ctx) + if err != nil { + return nil, err + } + + for _, t := range maxTrades { + localTrades, err := toGlobalTradeV3(t) + if err != nil { + log.WithError(err).Errorf("can not convert trade: %+v", t) + continue + } + + trades = append(trades, localTrades...) + } + + // ensure everything is sorted ascending + trades = types.SortTradesAscending(trades) + + return trades, nil +} + +func (e *Exchange) QueryRewards(ctx context.Context, startTime time.Time) ([]types.Reward, error) { + var from = startTime + var emptyTime = time.Time{} + + if from == emptyTime { + from = time.Unix(maxapi.TimestampSince, 0) + } + + var now = time.Now() + for { + if from.After(now) { + return nil, nil + } + + // scan by 30 days + // an user might get most 14 commission records by currency per day + // limit 1000 / 14 = 71 days + to := from.Add(time.Hour * 24 * 30) + req := e.client.RewardService.NewGetRewardsRequest() + req.From(from.Unix()) + req.To(to.Unix()) + req.Limit(1000) + + maxRewards, err := req.Do(ctx) + if err != nil { + return nil, err + } + + if len(maxRewards) == 0 { + // next page + from = to + continue + } + + rewards, err := toGlobalRewards(maxRewards) + if err != nil { + return nil, err + } + + // sort them in the ascending order + sort.Sort(types.RewardSliceByCreationTime(rewards)) + return rewards, nil + } + + return nil, errors.New("unknown error") +} + +// QueryKLines returns the klines from the MAX exchange API. +// The KLine API of the MAX exchange uses inclusive time range +// +// https://max-api.maicoin.com/api/v2/k?market=btctwd&limit=10&period=1×tamp=1620202440 +// The above query will return a kline that starts with 1620202440 (unix timestamp) without endTime. +// We need to calculate the endTime by ourself. +func (e *Exchange) QueryKLines( + ctx context.Context, symbol string, interval types.Interval, options types.KLineQueryOptions, +) ([]types.KLine, error) { + if err := e.marketDataLimiter.Wait(ctx); err != nil { + return nil, err + } + + var limit = 5000 + if options.Limit > 0 { + // default limit == 500 + limit = options.Limit + } + + // workaround for the kline query, because MAX does not support query by end time + // so we need to use the given end time and the limit number to calculate the start time + if options.EndTime != nil && options.StartTime == nil { + startTime := options.EndTime.Add(-time.Duration(limit) * interval.Duration()) + options.StartTime = &startTime + } + + if options.StartTime == nil { + return nil, errors.New("start time can not be empty") + } + + log.Infof("querying kline %s %s %+v", symbol, interval, options) + localKLines, err := e.client.PublicService.KLines(toLocalSymbol(symbol), string(interval), *options.StartTime, limit) + if err != nil { + return nil, err + } + + var kLines []types.KLine + for _, k := range localKLines { + if options.EndTime != nil && k.StartTime.After(*options.EndTime) { + break + } + + kLines = append(kLines, k.KLine()) + } + + return kLines, nil +} + +var Two = fixedpoint.NewFromInt(2) + +func (e *Exchange) QueryAveragePrice(ctx context.Context, symbol string) (fixedpoint.Value, error) { + ticker, err := e.client.PublicService.Ticker(toLocalSymbol(symbol)) + if err != nil { + return fixedpoint.Zero, err + } + + return ticker.Sell.Add(ticker.Buy).Div(Two), nil +} + +func (e *Exchange) RepayMarginAsset(ctx context.Context, asset string, amount fixedpoint.Value) error { + req := e.v3client.NewMarginRepayRequest() + req.Currency(toLocalCurrency(asset)) + req.Amount(amount.String()) + resp, err := req.Do(ctx) + if err != nil { + return err + } + + log.Infof("margin repay: %v", resp) + return nil +} + +func (e *Exchange) BorrowMarginAsset(ctx context.Context, asset string, amount fixedpoint.Value) error { + req := e.v3client.NewMarginLoanRequest() + req.Currency(toLocalCurrency(asset)) + req.Amount(amount.String()) + resp, err := req.Do(ctx) + if err != nil { + return err + } + + log.Infof("margin borrow: %v", resp) + return nil +} + +func (e *Exchange) QueryMarginAssetMaxBorrowable( + ctx context.Context, asset string, +) (amount fixedpoint.Value, err error) { + req := e.v3client.NewGetMarginBorrowingLimitsRequest() + resp, err := req.Do(ctx) + if err != nil { + return fixedpoint.Zero, err + } + + limits := *resp + if limit, ok := limits[toLocalCurrency(asset)]; ok { + return limit, nil + } + + err = fmt.Errorf("borrowing limit of %s not found", asset) + return amount, err +} + +// DefaultFeeRates returns the MAX VIP 0 fee schedule +// See also https://max-vip-zh.maicoin.com/ +func (e *Exchange) DefaultFeeRates() types.ExchangeFee { + return types.ExchangeFee{ + MakerFeeRate: fixedpoint.NewFromFloat(0.01 * 0.045), // 0.045% + TakerFeeRate: fixedpoint.NewFromFloat(0.01 * 0.150), // 0.15% + } +} + +var SupportedIntervals = map[types.Interval]int{ + types.Interval1m: 1 * 60, + types.Interval5m: 5 * 60, + types.Interval15m: 15 * 60, + types.Interval30m: 30 * 60, + types.Interval1h: 60 * 60, + types.Interval2h: 60 * 60 * 2, + types.Interval4h: 60 * 60 * 4, + types.Interval6h: 60 * 60 * 6, + types.Interval12h: 60 * 60 * 12, + types.Interval1d: 60 * 60 * 24, + types.Interval3d: 60 * 60 * 24 * 3, +} + +func (e *Exchange) SupportedInterval() map[types.Interval]int { + return SupportedIntervals +} + +func (e *Exchange) IsSupportedInterval(interval types.Interval) bool { + _, ok := SupportedIntervals[interval] + return ok +} + +func logResponse(resp interface{}, err error, req interface{}) error { + if err != nil { + log.WithError(err).Errorf("%T: error %+v", req, resp) + return err + } + + log.Infof("%T: response: %+v", req, resp) + return nil +} diff --git a/pkg/exchange/max/margin.go b/pkg/exchange/max/margin.go new file mode 100644 index 0000000..b84296a --- /dev/null +++ b/pkg/exchange/max/margin.go @@ -0,0 +1,43 @@ +package max + +import ( + "context" + "errors" + "fmt" + + v3 "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/max/maxapi/v3" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// TransferMarginAccountAsset transfers the asset into/out from the margin account +// +// types.TransferIn => Spot to Margin +// types.TransferOut => Margin to Spot +// +// to call this method, you must set the IsMargin = true +func (e *Exchange) TransferMarginAccountAsset(ctx context.Context, asset string, amount fixedpoint.Value, io types.TransferDirection) error { + if e.IsIsolatedMargin { + return errors.New("isolated margin is not supported") + } + + return e.transferCrossMarginAccountAsset(ctx, asset, amount, io) +} + +// transferCrossMarginAccountAsset transfer asset to the cross margin account or to the main account +func (e *Exchange) transferCrossMarginAccountAsset(ctx context.Context, asset string, amount fixedpoint.Value, io types.TransferDirection) error { + req := e.v3margin.NewMarginTransferRequest() + req.Currency(toLocalCurrency(asset)) + req.Amount(amount.String()) + + if io == types.TransferIn { + req.Side(v3.MarginTransferSideIn) + } else if io == types.TransferOut { + req.Side(v3.MarginTransferSideOut) + } else { + return fmt.Errorf("unexpected transfer direction: %d given", io) + } + + resp, err := req.Do(ctx) + return logResponse(resp, err, req) +} diff --git a/pkg/exchange/max/maxapi/account.go b/pkg/exchange/max/maxapi/account.go new file mode 100644 index 0000000..d90441f --- /dev/null +++ b/pkg/exchange/max/maxapi/account.go @@ -0,0 +1,198 @@ +package max + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST +//go:generate -command DeleteRequest requestgen -method DELETE + +import ( + "time" + + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type AccountService struct { + client requestgen.AuthenticatedAPIClient +} + +// Account is for max rest api v2, Balance and Type will be conflict with types.PrivateBalanceUpdate +type Account struct { + Type string `json:"type"` + Currency string `json:"currency"` + Balance fixedpoint.Value `json:"balance"` + Locked fixedpoint.Value `json:"locked"` + + // v3 fields for M wallet + Principal fixedpoint.Value `json:"principal"` + Interest fixedpoint.Value `json:"interest"` + + // v2 fields + FiatCurrency string `json:"fiat_currency"` + FiatBalance fixedpoint.Value `json:"fiat_balance"` +} + +type UserBank struct { + Branch string `json:"branch"` + Name string `json:"name"` + Account string `json:"account"` + State string `json:"state"` +} + +type UserInfo struct { + Sn string `json:"sn"` + Name string `json:"name"` + Type string `json:"member_type"` + Level int `json:"level"` + VipLevel int `json:"vip_level"` + Email string `json:"email"` + Accounts []Account `json:"accounts"` + Bank *UserBank `json:"bank,omitempty"` + IsFrozen bool `json:"is_frozen"` + IsActivated bool `json:"is_activated"` + KycApproved bool `json:"kyc_approved"` + KycState string `json:"kyc_state"` + PhoneSet bool `json:"phone_set"` + PhoneNumber string `json:"phone_number"` + ProfileVerified bool `json:"profile_verified"` + CountryCode string `json:"country_code"` + IdentityNumber string `json:"identity_number"` + WithDrawable bool `json:"withdrawable"` + ReferralCode string `json:"referral_code"` +} + +type VipLevelSettings struct { + Level int `json:"level"` + MinimumTradingVolume float64 `json:"minimum_trading_volume"` + MinimumStakingVolume float64 `json:"minimum_staking_volume"` + MakerFee float64 `json:"maker_fee"` + TakerFee float64 `json:"taker_fee"` +} + +type VipLevel struct { + Current VipLevelSettings `json:"current_vip_level"` + Next VipLevelSettings `json:"next_vip_level"` +} + +//go:generate GetRequest -url "v2/members/vip_level" -type GetVipLevelRequest -responseType .VipLevel +type GetVipLevelRequest struct { + client requestgen.AuthenticatedAPIClient +} + +func (c *RestClient) NewGetVipLevelRequest() *GetVipLevelRequest { + return &GetVipLevelRequest{client: c} +} + +//go:generate GetRequest -url "v2/members/accounts/:currency" -type GetAccountRequest -responseType .Account +type GetAccountRequest struct { + client requestgen.AuthenticatedAPIClient + + currency string `param:"currency,slug"` +} + +func (c *RestClient) NewGetAccountRequest() *GetAccountRequest { + return &GetAccountRequest{client: c} +} + +//go:generate GetRequest -url "v2/members/accounts" -type GetAccountsRequest -responseType []Account +type GetAccountsRequest struct { + client requestgen.AuthenticatedAPIClient +} + +func (c *RestClient) NewGetAccountsRequest() *GetAccountsRequest { + return &GetAccountsRequest{client: c} +} + +type DepositState string + +const ( + DepositStateSubmitting DepositState = "submitting" + DepositStateCancelled DepositState = "cancelled" + DepositStateSubmitted DepositState = "submitted" + DepositStatePending DepositState = "pending" + DepositStateSuspect DepositState = "suspect" + DepositStateRejected DepositState = "rejected" + DepositStateSuspended DepositState = "suspended" + DepositStateAccepted DepositState = "accepted" + DepositStateChecking DepositState = "checking" +) + +type Deposit struct { + Currency string `json:"currency"` // "eth" + CurrencyVersion string `json:"currency_version"` // "eth" + NetworkProtocol string `json:"network_protocol"` // "ethereum-erc20" + Amount fixedpoint.Value `json:"amount"` + Fee fixedpoint.Value `json:"fee"` + TxID string `json:"txid"` + State DepositState `json:"state"` + Status string `json:"status"` + Confirmations int64 `json:"confirmations"` + Address string `json:"to_address"` // 0x5c7d23d516f120d322fc7b116386b7e491739138 + CreatedAt types.MillisecondTimestamp `json:"created_at"` + UpdatedAt types.MillisecondTimestamp `json:"updated_at"` +} + +//go:generate GetRequest -url "v2/deposits" -type GetDepositHistoryRequest -responseType []Deposit +type GetDepositHistoryRequest struct { + client requestgen.AuthenticatedAPIClient + + currency *string `param:"currency"` + from *time.Time `param:"from,seconds"` // seconds + to *time.Time `param:"to,seconds"` // seconds + state *string `param:"state"` // submitting, submitted, rejected, accepted, checking, refunded, canceled, suspect + limit *int `param:"limit"` +} + +func (c *RestClient) NewGetDepositHistoryRequest() *GetDepositHistoryRequest { + return &GetDepositHistoryRequest{ + client: c, + } +} + +// submitted -> accepted -> processing -> sent -> confirmed +type WithdrawState string + +const ( + WithdrawStateSubmitting WithdrawState = "submitting" + WithdrawStateConfirmed WithdrawState = "confirmed" +) + +type Withdraw struct { + UUID string `json:"uuid"` + Currency string `json:"currency"` + CurrencyVersion string `json:"currency_version"` // "eth" + Amount fixedpoint.Value `json:"amount"` + Fee fixedpoint.Value `json:"fee"` + FeeCurrency string `json:"fee_currency"` + TxID string `json:"txid"` + + // State can be "submitting", "submitted", + // "rejected", "accepted", "suspect", "approved", "delisted_processing", + // "processing", "retryable", "sent", "canceled", + // "failed", "pending", "confirmed", + // "kgi_manually_processing", "kgi_manually_confirmed", "kgi_possible_failed", + // "sygna_verifying" + State string `json:"state"` + Confirmations int `json:"confirmations"` + CreatedAt types.MillisecondTimestamp `json:"created_at"` + UpdatedAt types.MillisecondTimestamp `json:"updated_at"` + Notes string `json:"notes"` +} + +//go:generate GetRequest -url "v2/withdrawals" -type GetWithdrawHistoryRequest -responseType []Withdraw +type GetWithdrawHistoryRequest struct { + client requestgen.AuthenticatedAPIClient + + currency string `param:"currency"` + from *time.Time `param:"from,seconds"` // seconds + to *time.Time `param:"to,seconds"` // seconds + state *string `param:"state"` // submitting, submitted, rejected, accepted, checking, refunded, canceled, suspect + limit *int `param:"limit"` +} + +func (c *RestClient) NewGetWithdrawalHistoryRequest() *GetWithdrawHistoryRequest { + return &GetWithdrawHistoryRequest{ + client: c, + } +} diff --git a/pkg/exchange/max/maxapi/account_test.go b/pkg/exchange/max/maxapi/account_test.go new file mode 100644 index 0000000..b074f3d --- /dev/null +++ b/pkg/exchange/max/maxapi/account_test.go @@ -0,0 +1,112 @@ +package max + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAccountService_GetAccountsRequest(t *testing.T) { + key, secret, ok := integrationTestConfigured(t, "MAX") + if !ok { + t.SkipNow() + } + + ctx := context.Background() + + client := NewRestClient(ProductionAPIURL) + client.Auth(key, secret) + + req := client.NewGetAccountsRequest() + accounts, err := req.Do(ctx) + assert.NoError(t, err) + assert.NotNil(t, accounts) + assert.NotEmpty(t, accounts) + + t.Logf("accounts: %+v", accounts) +} + +func TestAccountService_GetAccountRequest(t *testing.T) { + key, secret, ok := integrationTestConfigured(t, "MAX") + if !ok { + t.SkipNow() + } + + ctx := context.Background() + + client := NewRestClient(ProductionAPIURL) + client.Auth(key, secret) + + req := client.NewGetAccountRequest() + req.Currency("twd") + account, err := req.Do(ctx) + assert.NoError(t, err) + assert.NotNil(t, account) + t.Logf("account: %+v", account) + + req2 := client.NewGetAccountRequest() + req2.Currency("usdt") + account, err = req.Do(ctx) + assert.NoError(t, err) + assert.NotNil(t, account) + t.Logf("account: %+v", account) +} + +func TestAccountService_GetVipLevelRequest(t *testing.T) { + key, secret, ok := integrationTestConfigured(t, "MAX") + if !ok { + t.SkipNow() + } + + ctx := context.Background() + + client := NewRestClient(ProductionAPIURL) + client.Auth(key, secret) + + req := client.NewGetVipLevelRequest() + vipLevel, err := req.Do(ctx) + assert.NoError(t, err) + assert.NotNil(t, vipLevel) + t.Logf("vipLevel: %+v", vipLevel) +} + +func TestAccountService_GetWithdrawHistoryRequest(t *testing.T) { + key, secret, ok := integrationTestConfigured(t, "MAX") + if !ok { + t.SkipNow() + } + + ctx := context.Background() + + client := NewRestClient(ProductionAPIURL) + client.Auth(key, secret) + + req := client.NewGetWithdrawalHistoryRequest() + req.Currency("usdt") + withdraws, err := req.Do(ctx) + assert.NoError(t, err) + assert.NotNil(t, withdraws) + assert.NotEmpty(t, withdraws) + t.Logf("withdraws: %+v", withdraws) +} + +func TestAccountService_NewGetDepositHistoryRequest(t *testing.T) { + key, secret, ok := integrationTestConfigured(t, "MAX") + if !ok { + t.SkipNow() + } + + ctx := context.Background() + + client := NewRestClient(ProductionAPIURL) + client.Auth(key, secret) + + req := client.NewGetDepositHistoryRequest() + req.Currency("usdt") + deposits, err := req.Do(ctx) + assert.NoError(t, err) + assert.NotNil(t, deposits) + assert.NotEmpty(t, deposits) + t.Logf("deposits: %+v", deposits) +} diff --git a/pkg/exchange/max/maxapi/auth.go b/pkg/exchange/max/maxapi/auth.go new file mode 100644 index 0000000..15629dc --- /dev/null +++ b/pkg/exchange/max/maxapi/auth.go @@ -0,0 +1,16 @@ +package max + +type AuthMessage struct { + Action string `json:"action,omitempty"` + APIKey string `json:"apiKey,omitempty"` + Nonce int64 `json:"nonce,omitempty"` + Signature string `json:"signature,omitempty"` + ID string `json:"id,omitempty"` + Filters []string `json:"filters,omitempty"` +} + +type AuthEvent struct { + Event string + ID string + Timestamp int64 +} diff --git a/pkg/exchange/max/maxapi/cancel_order_request.go b/pkg/exchange/max/maxapi/cancel_order_request.go new file mode 100644 index 0000000..3c77188 --- /dev/null +++ b/pkg/exchange/max/maxapi/cancel_order_request.go @@ -0,0 +1,19 @@ +package max + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST +//go:generate -command DeleteRequest requestgen -method DELETE + +import "github.com/c9s/requestgen" + +func (s *OrderService) NewCancelOrderRequest() *CancelOrderRequest { + return &CancelOrderRequest{client: s.client} +} + +//go:generate PostRequest -url "/api/v2/order/delete" -type CancelOrderRequest -responseType .Order +type CancelOrderRequest struct { + client requestgen.AuthenticatedAPIClient + + id *uint64 `param:"id,omitempty"` + clientOrderID *string `param:"client_oid,omitempty"` +} diff --git a/pkg/exchange/max/maxapi/cancel_order_request_requestgen.go b/pkg/exchange/max/maxapi/cancel_order_request_requestgen.go new file mode 100644 index 0000000..402204c --- /dev/null +++ b/pkg/exchange/max/maxapi/cancel_order_request_requestgen.go @@ -0,0 +1,163 @@ +// Code generated by "requestgen -method POST -url /api/v2/order/delete -type CancelOrderRequest -responseType .Order"; DO NOT EDIT. + +package max + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (c *CancelOrderRequest) Id(id uint64) *CancelOrderRequest { + c.id = &id + return c +} + +func (c *CancelOrderRequest) ClientOrderID(clientOrderID string) *CancelOrderRequest { + c.clientOrderID = &clientOrderID + return c +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (c *CancelOrderRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (c *CancelOrderRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check id field -> json key id + if c.id != nil { + id := *c.id + + // assign parameter of id + params["id"] = id + } else { + } + // check clientOrderID field -> json key client_oid + if c.clientOrderID != nil { + clientOrderID := *c.clientOrderID + + // assign parameter of clientOrderID + params["client_oid"] = clientOrderID + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (c *CancelOrderRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := c.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if c.isVarSlice(_v) { + c.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (c *CancelOrderRequest) GetParametersJSON() ([]byte, error) { + params, err := c.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (c *CancelOrderRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (c *CancelOrderRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (c *CancelOrderRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (c *CancelOrderRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (c *CancelOrderRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := c.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (c *CancelOrderRequest) Do(ctx context.Context) (*Order, error) { + + params, err := c.GetParameters() + if err != nil { + return nil, err + } + query := url.Values{} + + apiURL := "/api/v2/order/delete" + + req, err := c.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := c.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse Order + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return &apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/create_order_request.go b/pkg/exchange/max/maxapi/create_order_request.go new file mode 100644 index 0000000..6646e3a --- /dev/null +++ b/pkg/exchange/max/maxapi/create_order_request.go @@ -0,0 +1,26 @@ +package max + +import "github.com/c9s/requestgen" + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST +//go:generate -command DeleteRequest requestgen -method DELETE + +//go:generate PostRequest -url "/api/v2/orders" -type CreateOrderRequest -responseType .Order +type CreateOrderRequest struct { + client requestgen.AuthenticatedAPIClient + + market string `param:"market,required"` + side string `param:"side,required"` + volume string `param:"volume,required"` + orderType OrderType `param:"ord_type"` + + price *string `param:"price"` + stopPrice *string `param:"stop_price"` + clientOrderID *string `param:"client_oid"` + groupID *string `param:"group_id"` +} + +func (s *OrderService) NewCreateOrderRequest() *CreateOrderRequest { + return &CreateOrderRequest{client: s.client} +} diff --git a/pkg/exchange/max/maxapi/create_order_request_requestgen.go b/pkg/exchange/max/maxapi/create_order_request_requestgen.go new file mode 100644 index 0000000..2edd002 --- /dev/null +++ b/pkg/exchange/max/maxapi/create_order_request_requestgen.go @@ -0,0 +1,247 @@ +// Code generated by "requestgen -method POST -url /api/v2/orders -type CreateOrderRequest -responseType .Order"; DO NOT EDIT. + +package max + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (c *CreateOrderRequest) Market(market string) *CreateOrderRequest { + c.market = market + return c +} + +func (c *CreateOrderRequest) Side(side string) *CreateOrderRequest { + c.side = side + return c +} + +func (c *CreateOrderRequest) Volume(volume string) *CreateOrderRequest { + c.volume = volume + return c +} + +func (c *CreateOrderRequest) OrderType(orderType OrderType) *CreateOrderRequest { + c.orderType = orderType + return c +} + +func (c *CreateOrderRequest) Price(price string) *CreateOrderRequest { + c.price = &price + return c +} + +func (c *CreateOrderRequest) StopPrice(stopPrice string) *CreateOrderRequest { + c.stopPrice = &stopPrice + return c +} + +func (c *CreateOrderRequest) ClientOrderID(clientOrderID string) *CreateOrderRequest { + c.clientOrderID = &clientOrderID + return c +} + +func (c *CreateOrderRequest) GroupID(groupID string) *CreateOrderRequest { + c.groupID = &groupID + return c +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (c *CreateOrderRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (c *CreateOrderRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check market field -> json key market + market := c.market + + // TEMPLATE check-required + if len(market) == 0 { + return nil, fmt.Errorf("market is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of market + params["market"] = market + // check side field -> json key side + side := c.side + + // TEMPLATE check-required + if len(side) == 0 { + return nil, fmt.Errorf("side is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of side + params["side"] = side + // check volume field -> json key volume + volume := c.volume + + // TEMPLATE check-required + if len(volume) == 0 { + return nil, fmt.Errorf("volume is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of volume + params["volume"] = volume + // check orderType field -> json key ord_type + orderType := c.orderType + + // assign parameter of orderType + params["ord_type"] = orderType + // check price field -> json key price + if c.price != nil { + price := *c.price + + // assign parameter of price + params["price"] = price + } else { + } + // check stopPrice field -> json key stop_price + if c.stopPrice != nil { + stopPrice := *c.stopPrice + + // assign parameter of stopPrice + params["stop_price"] = stopPrice + } else { + } + // check clientOrderID field -> json key client_oid + if c.clientOrderID != nil { + clientOrderID := *c.clientOrderID + + // assign parameter of clientOrderID + params["client_oid"] = clientOrderID + } else { + } + // check groupID field -> json key group_id + if c.groupID != nil { + groupID := *c.groupID + + // assign parameter of groupID + params["group_id"] = groupID + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (c *CreateOrderRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := c.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if c.isVarSlice(_v) { + c.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (c *CreateOrderRequest) GetParametersJSON() ([]byte, error) { + params, err := c.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (c *CreateOrderRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (c *CreateOrderRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (c *CreateOrderRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (c *CreateOrderRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (c *CreateOrderRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := c.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (c *CreateOrderRequest) Do(ctx context.Context) (*Order, error) { + + params, err := c.GetParameters() + if err != nil { + return nil, err + } + query := url.Values{} + + apiURL := "/api/v2/orders" + + req, err := c.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := c.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse Order + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return &apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/get_account_request_requestgen.go b/pkg/exchange/max/maxapi/get_account_request_requestgen.go new file mode 100644 index 0000000..c69b720 --- /dev/null +++ b/pkg/exchange/max/maxapi/get_account_request_requestgen.go @@ -0,0 +1,151 @@ +// Code generated by "requestgen -method GET -url v2/members/accounts/:currency -type GetAccountRequest -responseType .Account"; DO NOT EDIT. + +package max + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (g *GetAccountRequest) Currency(currency string) *GetAccountRequest { + g.currency = currency + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetAccountRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetAccountRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetAccountRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetAccountRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetAccountRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check currency field -> json key currency + currency := g.currency + + // assign parameter of currency + params["currency"] = currency + + return params, nil +} + +func (g *GetAccountRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetAccountRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetAccountRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetAccountRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetAccountRequest) Do(ctx context.Context) (*Account, error) { + + // no body params + var params interface{} + query := url.Values{} + + apiURL := "v2/members/accounts/:currency" + slugs, err := g.GetSlugsMap() + if err != nil { + return nil, err + } + + apiURL = g.applySlugsToUrl(apiURL, slugs) + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse Account + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return &apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/get_accounts_request_requestgen.go b/pkg/exchange/max/maxapi/get_accounts_request_requestgen.go new file mode 100644 index 0000000..7e497c9 --- /dev/null +++ b/pkg/exchange/max/maxapi/get_accounts_request_requestgen.go @@ -0,0 +1,135 @@ +// Code generated by "requestgen -method GET -url v2/members/accounts -type GetAccountsRequest -responseType []Account"; DO NOT EDIT. + +package max + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetAccountsRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetAccountsRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetAccountsRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetAccountsRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetAccountsRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetAccountsRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetAccountsRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetAccountsRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetAccountsRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetAccountsRequest) Do(ctx context.Context) ([]Account, error) { + + // no body params + var params interface{} + query := url.Values{} + + apiURL := "v2/members/accounts" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []Account + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/get_deposit_history_request_requestgen.go b/pkg/exchange/max/maxapi/get_deposit_history_request_requestgen.go new file mode 100644 index 0000000..a60f24f --- /dev/null +++ b/pkg/exchange/max/maxapi/get_deposit_history_request_requestgen.go @@ -0,0 +1,207 @@ +// Code generated by "requestgen -method GET -url v2/deposits -type GetDepositHistoryRequest -responseType []Deposit"; DO NOT EDIT. + +package max + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetDepositHistoryRequest) Currency(currency string) *GetDepositHistoryRequest { + g.currency = ¤cy + return g +} + +func (g *GetDepositHistoryRequest) From(from time.Time) *GetDepositHistoryRequest { + g.from = &from + return g +} + +func (g *GetDepositHistoryRequest) To(to time.Time) *GetDepositHistoryRequest { + g.to = &to + return g +} + +func (g *GetDepositHistoryRequest) State(state string) *GetDepositHistoryRequest { + g.state = &state + return g +} + +func (g *GetDepositHistoryRequest) Limit(limit int) *GetDepositHistoryRequest { + g.limit = &limit + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetDepositHistoryRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetDepositHistoryRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check currency field -> json key currency + if g.currency != nil { + currency := *g.currency + + // assign parameter of currency + params["currency"] = currency + } else { + } + // check from field -> json key from + if g.from != nil { + from := *g.from + + // assign parameter of from + // convert time.Time to seconds time stamp + params["from"] = strconv.FormatInt(from.Unix(), 10) + } else { + } + // check to field -> json key to + if g.to != nil { + to := *g.to + + // assign parameter of to + // convert time.Time to seconds time stamp + params["to"] = strconv.FormatInt(to.Unix(), 10) + } else { + } + // check state field -> json key state + if g.state != nil { + state := *g.state + + // assign parameter of state + params["state"] = state + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetDepositHistoryRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetDepositHistoryRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetDepositHistoryRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetDepositHistoryRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetDepositHistoryRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetDepositHistoryRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetDepositHistoryRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetDepositHistoryRequest) Do(ctx context.Context) ([]Deposit, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "v2/deposits" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []Deposit + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/get_k_lines_request.go b/pkg/exchange/max/maxapi/get_k_lines_request.go new file mode 100644 index 0000000..fe28d10 --- /dev/null +++ b/pkg/exchange/max/maxapi/get_k_lines_request.go @@ -0,0 +1,27 @@ +package max + +import ( + "time" + + "github.com/c9s/requestgen" +) + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST +//go:generate -command DeleteRequest requestgen -method DELETE + +type KLineData []float64 + +//go:generate GetRequest -url "/api/v2/k" -type GetKLinesRequest -responseType []KLineData +type GetKLinesRequest struct { + client requestgen.APIClient + + market string `param:"market,required"` + limit *int `param:"limit"` + period *int `param:"period"` + timestamp time.Time `param:"timestamp,seconds"` +} + +func (c *RestClient) NewGetKLinesRequest() *GetKLinesRequest { + return &GetKLinesRequest{client: c} +} diff --git a/pkg/exchange/max/maxapi/get_k_lines_request_requestgen.go b/pkg/exchange/max/maxapi/get_k_lines_request_requestgen.go new file mode 100644 index 0000000..595184c --- /dev/null +++ b/pkg/exchange/max/maxapi/get_k_lines_request_requestgen.go @@ -0,0 +1,193 @@ +// Code generated by "requestgen -method GET -url /api/v2/k -type GetKLinesRequest -responseType []KLineData"; DO NOT EDIT. + +package max + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetKLinesRequest) Market(market string) *GetKLinesRequest { + g.market = market + return g +} + +func (g *GetKLinesRequest) Limit(limit int) *GetKLinesRequest { + g.limit = &limit + return g +} + +func (g *GetKLinesRequest) Period(period int) *GetKLinesRequest { + g.period = &period + return g +} + +func (g *GetKLinesRequest) Timestamp(timestamp time.Time) *GetKLinesRequest { + g.timestamp = timestamp + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetKLinesRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetKLinesRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check market field -> json key market + market := g.market + + // TEMPLATE check-required + if len(market) == 0 { + return nil, fmt.Errorf("market is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of market + params["market"] = market + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + // check period field -> json key period + if g.period != nil { + period := *g.period + + // assign parameter of period + params["period"] = period + } else { + } + // check timestamp field -> json key timestamp + timestamp := g.timestamp + + // assign parameter of timestamp + // convert time.Time to seconds time stamp + params["timestamp"] = strconv.FormatInt(timestamp.Unix(), 10) + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetKLinesRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetKLinesRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetKLinesRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetKLinesRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetKLinesRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetKLinesRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetKLinesRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetKLinesRequest) Do(ctx context.Context) ([]KLineData, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/api/v2/k" + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []KLineData + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/get_markets_request.go b/pkg/exchange/max/maxapi/get_markets_request.go new file mode 100644 index 0000000..8cbbf70 --- /dev/null +++ b/pkg/exchange/max/maxapi/get_markets_request.go @@ -0,0 +1,18 @@ +package max + +import ( + "github.com/c9s/requestgen" +) + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST +//go:generate -command DeleteRequest requestgen -method DELETE + +//go:generate GetRequest -url "/api/v2/markets" -type GetMarketsRequest -responseType []Market +type GetMarketsRequest struct { + client requestgen.APIClient +} + +func (c *RestClient) NewGetMarketsRequest() *GetMarketsRequest { + return &GetMarketsRequest{client: c} +} diff --git a/pkg/exchange/max/maxapi/get_markets_request_requestgen.go b/pkg/exchange/max/maxapi/get_markets_request_requestgen.go new file mode 100644 index 0000000..0084bfa --- /dev/null +++ b/pkg/exchange/max/maxapi/get_markets_request_requestgen.go @@ -0,0 +1,135 @@ +// Code generated by "requestgen -method GET -url /api/v2/markets -type GetMarketsRequest -responseType []Market"; DO NOT EDIT. + +package max + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetMarketsRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetMarketsRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetMarketsRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetMarketsRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetMarketsRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetMarketsRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetMarketsRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetMarketsRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetMarketsRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetMarketsRequest) Do(ctx context.Context) ([]Market, error) { + + // no body params + var params interface{} + query := url.Values{} + + apiURL := "/api/v2/markets" + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []Market + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/get_rewards_of_type_request_requestgen.go b/pkg/exchange/max/maxapi/get_rewards_of_type_request_requestgen.go new file mode 100644 index 0000000..225d48a --- /dev/null +++ b/pkg/exchange/max/maxapi/get_rewards_of_type_request_requestgen.go @@ -0,0 +1,222 @@ +// Code generated by "requestgen -method GET -url v2/rewards/:path_type -type GetRewardsOfTypeRequest -responseType []Reward"; DO NOT EDIT. + +package max + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (g *GetRewardsOfTypeRequest) From(from int64) *GetRewardsOfTypeRequest { + g.from = &from + return g +} + +func (g *GetRewardsOfTypeRequest) To(to int64) *GetRewardsOfTypeRequest { + g.to = &to + return g +} + +func (g *GetRewardsOfTypeRequest) Page(page int64) *GetRewardsOfTypeRequest { + g.page = &page + return g +} + +func (g *GetRewardsOfTypeRequest) Limit(limit int64) *GetRewardsOfTypeRequest { + g.limit = &limit + return g +} + +func (g *GetRewardsOfTypeRequest) Offset(offset int64) *GetRewardsOfTypeRequest { + g.offset = &offset + return g +} + +func (g *GetRewardsOfTypeRequest) PathType(pathType RewardType) *GetRewardsOfTypeRequest { + g.pathType = &pathType + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetRewardsOfTypeRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetRewardsOfTypeRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check from field -> json key from + if g.from != nil { + from := *g.from + + // assign parameter of from + params["from"] = from + } else { + } + // check to field -> json key to + if g.to != nil { + to := *g.to + + // assign parameter of to + params["to"] = to + } else { + } + // check page field -> json key page + if g.page != nil { + page := *g.page + + // assign parameter of page + params["page"] = page + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + // check offset field -> json key offset + if g.offset != nil { + offset := *g.offset + + // assign parameter of offset + params["offset"] = offset + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetRewardsOfTypeRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for k, v := range params { + if g.isVarSlice(v) { + g.iterateSlice(v, func(it interface{}) { + query.Add(k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(k, fmt.Sprintf("%v", v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetRewardsOfTypeRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetRewardsOfTypeRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check pathType field -> json key path_type + if g.pathType != nil { + pathType := *g.pathType + + // assign parameter of pathType + params["path_type"] = pathType + + } + + return params, nil +} + +func (g *GetRewardsOfTypeRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for k, v := range slugs { + needleRE := regexp.MustCompile(":" + k + "\\b") + url = needleRE.ReplaceAllString(url, v) + } + + return url +} + +func (g *GetRewardsOfTypeRequest) iterateSlice(slice interface{}, f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for i := 0; i < sliceValue.Len(); i++ { + it := sliceValue.Index(i).Interface() + f(it) + } +} + +func (g *GetRewardsOfTypeRequest) isVarSlice(v interface{}) bool { + rt := reflect.TypeOf(v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetRewardsOfTypeRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for k, v := range params { + slugs[k] = fmt.Sprintf("%v", v) + } + + return slugs, nil +} + +func (g *GetRewardsOfTypeRequest) Do(ctx context.Context) ([]Reward, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "v2/rewards/:path_type" + slugs, err := g.GetSlugsMap() + if err != nil { + return nil, err + } + + apiURL = g.applySlugsToUrl(apiURL, slugs) + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []Reward + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/get_rewards_request_requestgen.go b/pkg/exchange/max/maxapi/get_rewards_request_requestgen.go new file mode 100644 index 0000000..e21f1b5 --- /dev/null +++ b/pkg/exchange/max/maxapi/get_rewards_request_requestgen.go @@ -0,0 +1,216 @@ +// Code generated by "requestgen -method GET -url v2/rewards -type GetRewardsRequest -responseType []Reward"; DO NOT EDIT. + +package max + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (g *GetRewardsRequest) Currency(currency string) *GetRewardsRequest { + g.currency = ¤cy + return g +} + +func (g *GetRewardsRequest) From(from int64) *GetRewardsRequest { + g.from = &from + return g +} + +func (g *GetRewardsRequest) To(to int64) *GetRewardsRequest { + g.to = &to + return g +} + +func (g *GetRewardsRequest) Page(page int64) *GetRewardsRequest { + g.page = &page + return g +} + +func (g *GetRewardsRequest) Limit(limit int64) *GetRewardsRequest { + g.limit = &limit + return g +} + +func (g *GetRewardsRequest) Offset(offset int64) *GetRewardsRequest { + g.offset = &offset + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetRewardsRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for k, v := range params { + query.Add(k, fmt.Sprintf("%v", v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetRewardsRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check currency field -> json key currency + if g.currency != nil { + currency := *g.currency + + // assign parameter of currency + params["currency"] = currency + } else { + } + // check from field -> json key from + if g.from != nil { + from := *g.from + + // assign parameter of from + params["from"] = from + } else { + } + // check to field -> json key to + if g.to != nil { + to := *g.to + + // assign parameter of to + params["to"] = to + } else { + } + // check page field -> json key page + if g.page != nil { + page := *g.page + + // assign parameter of page + params["page"] = page + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + // check offset field -> json key offset + if g.offset != nil { + offset := *g.offset + + // assign parameter of offset + params["offset"] = offset + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetRewardsRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for k, v := range params { + if g.isVarSlice(v) { + g.iterateSlice(v, func(it interface{}) { + query.Add(k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(k, fmt.Sprintf("%v", v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetRewardsRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetRewardsRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetRewardsRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for k, v := range slugs { + needleRE := regexp.MustCompile(":" + k + "\\b") + url = needleRE.ReplaceAllString(url, v) + } + + return url +} + +func (g *GetRewardsRequest) iterateSlice(slice interface{}, f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for i := 0; i < sliceValue.Len(); i++ { + it := sliceValue.Index(i).Interface() + f(it) + } +} + +func (g *GetRewardsRequest) isVarSlice(v interface{}) bool { + rt := reflect.TypeOf(v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetRewardsRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for k, v := range params { + slugs[k] = fmt.Sprintf("%v", v) + } + + return slugs, nil +} + +func (g *GetRewardsRequest) Do(ctx context.Context) ([]Reward, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "v2/rewards" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []Reward + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/get_ticker_request.go b/pkg/exchange/max/maxapi/get_ticker_request.go new file mode 100644 index 0000000..7650e20 --- /dev/null +++ b/pkg/exchange/max/maxapi/get_ticker_request.go @@ -0,0 +1,20 @@ +package max + +import ( + "github.com/c9s/requestgen" +) + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST +//go:generate -command DeleteRequest requestgen -method DELETE + +//go:generate GetRequest -url "/api/v2/tickers/:market" -type GetTickerRequest -responseType .Ticker +type GetTickerRequest struct { + client requestgen.APIClient + + market *string `param:"market,slug"` +} + +func (c *RestClient) NewGetTickerRequest() *GetTickerRequest { + return &GetTickerRequest{client: c} +} diff --git a/pkg/exchange/max/maxapi/get_ticker_request_requestgen.go b/pkg/exchange/max/maxapi/get_ticker_request_requestgen.go new file mode 100644 index 0000000..0f8329d --- /dev/null +++ b/pkg/exchange/max/maxapi/get_ticker_request_requestgen.go @@ -0,0 +1,154 @@ +// Code generated by "requestgen -method GET -url /api/v2/tickers/:market -type GetTickerRequest -responseType .Ticker"; DO NOT EDIT. + +package max + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (g *GetTickerRequest) Market(market string) *GetTickerRequest { + g.market = &market + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetTickerRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetTickerRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetTickerRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetTickerRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetTickerRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check market field -> json key market + if g.market != nil { + market := *g.market + + // assign parameter of market + params["market"] = market + + } + + return params, nil +} + +func (g *GetTickerRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetTickerRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetTickerRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetTickerRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetTickerRequest) Do(ctx context.Context) (*Ticker, error) { + + // no body params + var params interface{} + query := url.Values{} + + apiURL := "/api/v2/tickers/:market" + slugs, err := g.GetSlugsMap() + if err != nil { + return nil, err + } + + apiURL = g.applySlugsToUrl(apiURL, slugs) + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse Ticker + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return &apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/get_tickers_request.go b/pkg/exchange/max/maxapi/get_tickers_request.go new file mode 100644 index 0000000..a8c82c4 --- /dev/null +++ b/pkg/exchange/max/maxapi/get_tickers_request.go @@ -0,0 +1,20 @@ +package max + +import ( + "github.com/c9s/requestgen" +) + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST +//go:generate -command DeleteRequest requestgen -method DELETE + +type TickerMap map[string]Ticker + +//go:generate GetRequest -url "/api/v2/tickers" -type GetTickersRequest -responseType .TickerMap +type GetTickersRequest struct { + client requestgen.APIClient +} + +func (c *RestClient) NewGetTickersRequest() *GetTickersRequest { + return &GetTickersRequest{client: c} +} diff --git a/pkg/exchange/max/maxapi/get_tickers_request_requestgen.go b/pkg/exchange/max/maxapi/get_tickers_request_requestgen.go new file mode 100644 index 0000000..8c072fb --- /dev/null +++ b/pkg/exchange/max/maxapi/get_tickers_request_requestgen.go @@ -0,0 +1,135 @@ +// Code generated by "requestgen -method GET -url /api/v2/tickers -type GetTickersRequest -responseType .TickerMap"; DO NOT EDIT. + +package max + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetTickersRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetTickersRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetTickersRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetTickersRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetTickersRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetTickersRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetTickersRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetTickersRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetTickersRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetTickersRequest) Do(ctx context.Context) (TickerMap, error) { + + // no body params + var params interface{} + query := url.Values{} + + apiURL := "/api/v2/tickers" + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse TickerMap + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/get_timestamp_request.go b/pkg/exchange/max/maxapi/get_timestamp_request.go new file mode 100644 index 0000000..9feef84 --- /dev/null +++ b/pkg/exchange/max/maxapi/get_timestamp_request.go @@ -0,0 +1,20 @@ +package max + +import ( + "github.com/c9s/requestgen" +) + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST +//go:generate -command DeleteRequest requestgen -method DELETE + +type Timestamp int64 + +//go:generate GetRequest -url "/api/v2/timestamp" -type GetTimestampRequest -responseType .Timestamp +type GetTimestampRequest struct { + client requestgen.APIClient +} + +func (c *RestClient) NewGetTimestampRequest() *GetTimestampRequest { + return &GetTimestampRequest{client: c} +} diff --git a/pkg/exchange/max/maxapi/get_timestamp_request_requestgen.go b/pkg/exchange/max/maxapi/get_timestamp_request_requestgen.go new file mode 100644 index 0000000..20777a9 --- /dev/null +++ b/pkg/exchange/max/maxapi/get_timestamp_request_requestgen.go @@ -0,0 +1,135 @@ +// Code generated by "requestgen -method GET -url /api/v2/timestamp -type GetTimestampRequest -responseType .Timestamp"; DO NOT EDIT. + +package max + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetTimestampRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetTimestampRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetTimestampRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetTimestampRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetTimestampRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetTimestampRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetTimestampRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetTimestampRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetTimestampRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetTimestampRequest) Do(ctx context.Context) (*Timestamp, error) { + + // no body params + var params interface{} + query := url.Values{} + + apiURL := "/api/v2/timestamp" + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse Timestamp + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return &apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/get_vip_level_request_requestgen.go b/pkg/exchange/max/maxapi/get_vip_level_request_requestgen.go new file mode 100644 index 0000000..790a9cd --- /dev/null +++ b/pkg/exchange/max/maxapi/get_vip_level_request_requestgen.go @@ -0,0 +1,135 @@ +// Code generated by "requestgen -method GET -url v2/members/vip_level -type GetVipLevelRequest -responseType .VipLevel"; DO NOT EDIT. + +package max + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetVipLevelRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetVipLevelRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetVipLevelRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetVipLevelRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetVipLevelRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetVipLevelRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetVipLevelRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetVipLevelRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetVipLevelRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetVipLevelRequest) Do(ctx context.Context) (*VipLevel, error) { + + // no body params + var params interface{} + query := url.Values{} + + apiURL := "v2/members/vip_level" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse VipLevel + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return &apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/get_withdraw_history_request_requestgen.go b/pkg/exchange/max/maxapi/get_withdraw_history_request_requestgen.go new file mode 100644 index 0000000..200e7ee --- /dev/null +++ b/pkg/exchange/max/maxapi/get_withdraw_history_request_requestgen.go @@ -0,0 +1,204 @@ +// Code generated by "requestgen -method GET -url v2/withdrawals -type GetWithdrawHistoryRequest -responseType []Withdraw"; DO NOT EDIT. + +package max + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetWithdrawHistoryRequest) Currency(currency string) *GetWithdrawHistoryRequest { + g.currency = currency + return g +} + +func (g *GetWithdrawHistoryRequest) From(from time.Time) *GetWithdrawHistoryRequest { + g.from = &from + return g +} + +func (g *GetWithdrawHistoryRequest) To(to time.Time) *GetWithdrawHistoryRequest { + g.to = &to + return g +} + +func (g *GetWithdrawHistoryRequest) State(state string) *GetWithdrawHistoryRequest { + g.state = &state + return g +} + +func (g *GetWithdrawHistoryRequest) Limit(limit int) *GetWithdrawHistoryRequest { + g.limit = &limit + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetWithdrawHistoryRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetWithdrawHistoryRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check currency field -> json key currency + currency := g.currency + + // assign parameter of currency + params["currency"] = currency + // check from field -> json key from + if g.from != nil { + from := *g.from + + // assign parameter of from + // convert time.Time to seconds time stamp + params["from"] = strconv.FormatInt(from.Unix(), 10) + } else { + } + // check to field -> json key to + if g.to != nil { + to := *g.to + + // assign parameter of to + // convert time.Time to seconds time stamp + params["to"] = strconv.FormatInt(to.Unix(), 10) + } else { + } + // check state field -> json key state + if g.state != nil { + state := *g.state + + // assign parameter of state + params["state"] = state + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetWithdrawHistoryRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetWithdrawHistoryRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetWithdrawHistoryRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetWithdrawHistoryRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetWithdrawHistoryRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetWithdrawHistoryRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetWithdrawHistoryRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetWithdrawHistoryRequest) Do(ctx context.Context) ([]Withdraw, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "v2/withdrawals" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []Withdraw + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/get_withdrawal_addresses_request_requestgen.go b/pkg/exchange/max/maxapi/get_withdrawal_addresses_request_requestgen.go new file mode 100644 index 0000000..71f4a0c --- /dev/null +++ b/pkg/exchange/max/maxapi/get_withdrawal_addresses_request_requestgen.go @@ -0,0 +1,154 @@ +// Code generated by "requestgen -method GET -url v2/withdraw_addresses -type GetWithdrawalAddressesRequest -responseType []WithdrawalAddress"; DO NOT EDIT. + +package max + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (g *GetWithdrawalAddressesRequest) Currency(currency string) *GetWithdrawalAddressesRequest { + g.currency = currency + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetWithdrawalAddressesRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetWithdrawalAddressesRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check currency field -> json key currency + currency := g.currency + + // TEMPLATE check-required + if len(currency) == 0 { + return nil, fmt.Errorf("currency is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of currency + params["currency"] = currency + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetWithdrawalAddressesRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetWithdrawalAddressesRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetWithdrawalAddressesRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetWithdrawalAddressesRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetWithdrawalAddressesRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetWithdrawalAddressesRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetWithdrawalAddressesRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetWithdrawalAddressesRequest) Do(ctx context.Context) ([]WithdrawalAddress, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "v2/withdraw_addresses" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []WithdrawalAddress + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/order.go b/pkg/exchange/max/maxapi/order.go new file mode 100644 index 0000000..f33f24a --- /dev/null +++ b/pkg/exchange/max/maxapi/order.go @@ -0,0 +1,108 @@ +package max + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type WalletType string + +const ( + WalletTypeSpot WalletType = "spot" + WalletTypeMargin WalletType = "m" +) + +type OrderByType string + +const ( + OrderByAsc OrderByType = "asc" + OrderByDesc OrderByType = "desc" + OrderByAscUpdatedAt OrderByType = "asc_updated_at" + OrderByDescUpdatedAt OrderByType = "desc_updated_at" +) + +type OrderStateToQuery int + +const ( + All = iota + Active + Closed +) + +type OrderState string + +const ( + OrderStateDone = OrderState("done") + + OrderStateCancel = OrderState("cancel") + OrderStateWait = OrderState("wait") + OrderStateConvert = OrderState("convert") + OrderStateFinalizing = OrderState("finalizing") + OrderStateFailed = OrderState("failed") +) + +func IsFilledOrderState(state OrderState) bool { + return state == OrderStateDone || state == OrderStateFinalizing +} + +type OrderType string + +// Order types that the API can return. +const ( + OrderTypeMarket = OrderType("market") + OrderTypeLimit = OrderType("limit") + OrderTypePostOnly = OrderType("post_only") + OrderTypeStopLimit = OrderType("stop_limit") + OrderTypeStopMarket = OrderType("stop_market") + OrderTypeIOCLimit = OrderType("ioc_limit") +) + +type QueryOrderOptions struct { + GroupID int + Offset int + Limit int + Page int + OrderBy string +} + +// OrderService manages the Order endpoint. +type OrderService struct { + client requestgen.AuthenticatedAPIClient +} + +type SubmitOrder struct { + Side string `json:"side"` + Market string `json:"market"` + Price string `json:"price"` + StopPrice string `json:"stop_price,omitempty"` + OrderType OrderType `json:"ord_type"` + Volume string `json:"volume"` + GroupID uint32 `json:"group_id,omitempty"` + ClientOID string `json:"client_oid,omitempty"` +} + +// Order represents one returned order (POST order/GET order/GET orders) on the max platform. +type Order struct { + ID uint64 `json:"id,omitempty"` + WalletType WalletType `json:"wallet_type,omitempty"` + Side string `json:"side"` + OrderType OrderType `json:"ord_type"` + Price fixedpoint.Value `json:"price,omitempty"` + StopPrice fixedpoint.Value `json:"stop_price,omitempty"` + AveragePrice fixedpoint.Value `json:"avg_price,omitempty"` + State OrderState `json:"state,omitempty"` + Market string `json:"market,omitempty"` + Volume fixedpoint.Value `json:"volume"` + RemainingVolume fixedpoint.Value `json:"remaining_volume,omitempty"` + ExecutedVolume fixedpoint.Value `json:"executed_volume,omitempty"` + TradesCount int64 `json:"trades_count,omitempty"` + GroupID uint32 `json:"group_id,omitempty"` + ClientOID string `json:"client_oid,omitempty"` + CreatedAt types.MillisecondTimestamp `json:"created_at"` + UpdatedAt types.MillisecondTimestamp `json:"updated_at"` +} diff --git a/pkg/exchange/max/maxapi/order_test.go b/pkg/exchange/max/maxapi/order_test.go new file mode 100644 index 0000000..0bfb887 --- /dev/null +++ b/pkg/exchange/max/maxapi/order_test.go @@ -0,0 +1,24 @@ +package max + +import ( + "os" + "regexp" + "testing" +) + +func maskSecret(s string) string { + re := regexp.MustCompile(`\b(\w{4})\w+\b`) + s = re.ReplaceAllString(s, "$1******") + return s +} + +func integrationTestConfigured(t *testing.T, prefix string) (key, secret string, ok bool) { + var hasKey, hasSecret bool + key, hasKey = os.LookupEnv(prefix + "_API_KEY") + secret, hasSecret = os.LookupEnv(prefix + "_API_SECRET") + ok = hasKey && hasSecret && os.Getenv("TEST_"+prefix) == "1" + if ok { + t.Logf(prefix+" api integration test enabled, key = %s, secret = %s", maskSecret(key), maskSecret(secret)) + } + return key, secret, ok +} diff --git a/pkg/exchange/max/maxapi/public.go b/pkg/exchange/max/maxapi/public.go new file mode 100644 index 0000000..1903789 --- /dev/null +++ b/pkg/exchange/max/maxapi/public.go @@ -0,0 +1,184 @@ +package max + +import ( + "context" + "fmt" + "strings" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type PublicService struct { + client *RestClient +} + +type Market struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"market_status"` // active + BaseUnit string `json:"base_unit"` + BaseUnitPrecision int `json:"base_unit_precision"` + QuoteUnit string `json:"quote_unit"` + QuoteUnitPrecision int `json:"quote_unit_precision"` + MinBaseAmount fixedpoint.Value `json:"min_base_amount"` + MinQuoteAmount fixedpoint.Value `json:"min_quote_amount"` + SupportMargin bool `json:"m_wallet_supported"` +} + +type Ticker struct { + Time time.Time + + At int64 `json:"at"` + Buy fixedpoint.Value `json:"buy"` + Sell fixedpoint.Value `json:"sell"` + Open fixedpoint.Value `json:"open"` + High fixedpoint.Value `json:"high"` + Low fixedpoint.Value `json:"low"` + Last fixedpoint.Value `json:"last"` + Volume fixedpoint.Value `json:"vol"` + VolumeInBTC fixedpoint.Value `json:"vol_in_btc"` +} + +func (s *PublicService) Timestamp(ctx context.Context) (int64, error) { + req := s.client.NewGetTimestampRequest() + ts, err := req.Do(ctx) + if err != nil || ts == nil { + return 0, nil + } + + return int64(*ts), nil +} + +func (s *PublicService) Markets(ctx context.Context) ([]Market, error) { + req := s.client.NewGetMarketsRequest() + return req.Do(ctx) +} + +func (s *PublicService) Tickers(ctx context.Context) (TickerMap, error) { + req := s.client.NewGetTickersRequest() + return req.Do(ctx) +} + +func (s *PublicService) Ticker(market string) (*Ticker, error) { + req := s.client.NewGetTickerRequest() + req.Market(market) + return req.Do(context.Background()) +} + +type Interval int64 + +func ParseInterval(a string) (Interval, error) { + switch strings.ToLower(a) { + + case "1m": + return 1, nil + + case "5m": + return 5, nil + + case "15m": + return 15, nil + + case "30m": + return 30, nil + + case "1h": + return 60, nil + + case "2h": + return 60 * 2, nil + + case "3h": + return 60 * 3, nil + + case "4h": + return 60 * 4, nil + + case "6h": + return 60 * 6, nil + + case "8h": + return 60 * 8, nil + + case "12h": + return 60 * 12, nil + + case "1d": + return 60 * 24, nil + + case "3d": + return 60 * 24 * 3, nil + + case "1w": + return 60 * 24 * 7, nil + + } + + return 0, fmt.Errorf("incorrect resolution: %q", a) +} + +type KLine struct { + Symbol string + Interval string + StartTime, EndTime time.Time + Open, High, Low, Close fixedpoint.Value + Volume fixedpoint.Value + Closed bool +} + +func (k KLine) KLine() types.KLine { + return types.KLine{ + Exchange: types.ExchangeMax, + Symbol: strings.ToUpper(k.Symbol), // global symbol + Interval: types.Interval(k.Interval), + StartTime: types.Time(k.StartTime), + EndTime: types.Time(k.EndTime), + Open: k.Open, + Close: k.Close, + High: k.High, + Low: k.Low, + Volume: k.Volume, + // QuoteVolume: util.MustParseFloat(k.QuoteAssetVolume), + // LastTradeID: 0, + // NumberOfTrades: k.TradeNum, + Closed: k.Closed, + } +} + +func (s *PublicService) KLines(symbol string, resolution string, start time.Time, limit int) ([]KLine, error) { + interval, err := ParseInterval(resolution) + if err != nil { + return nil, err + } + + req := s.client.NewGetKLinesRequest() + req.Market(symbol).Period(int(interval)).Timestamp(start).Limit(limit) + data, err := req.Do(context.Background()) + if err != nil { + return nil, err + } + + var kLines []KLine + for _, slice := range data { + ts := int64(slice[0]) + startTime := time.Unix(ts, 0) + endTime := startTime.Add(time.Duration(interval)*time.Minute - time.Millisecond) + isClosed := time.Now().Before(endTime) + kLines = append(kLines, KLine{ + Symbol: symbol, + Interval: resolution, + StartTime: startTime, + EndTime: endTime, + Open: fixedpoint.NewFromFloat(slice[1]), + High: fixedpoint.NewFromFloat(slice[2]), + Low: fixedpoint.NewFromFloat(slice[3]), + Close: fixedpoint.NewFromFloat(slice[4]), + Volume: fixedpoint.NewFromFloat(slice[5]), + Closed: isClosed, + }) + } + return kLines, nil + // return parseKLines(resp.Body, symbol, resolution, interval) +} diff --git a/pkg/exchange/max/maxapi/public_parser.go b/pkg/exchange/max/maxapi/public_parser.go new file mode 100644 index 0000000..04c4d68 --- /dev/null +++ b/pkg/exchange/max/maxapi/public_parser.go @@ -0,0 +1,314 @@ +package max + +import ( + "strings" + "time" + + "github.com/pkg/errors" + "github.com/valyala/fastjson" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +var ErrIncorrectBookEntryElementLength = errors.New("incorrect book entry element length") + +const Buy = 1 +const Sell = -1 + +var parserPool fastjson.ParserPool + +// ParseMessage accepts the raw messages from max public websocket channels and parses them into market data +// Return types: *BookEvent, *PublicTradeEvent, *SubscriptionEvent, *ErrorEvent +func ParseMessage(payload []byte) (interface{}, error) { + parser := parserPool.Get() + + val, err := parser.ParseBytes(payload) + if err != nil { + return nil, errors.Wrap(err, "failed to parse payload: "+string(payload)) + } + + if channel := string(val.GetStringBytes("c")); len(channel) > 0 { + switch channel { + case "kline": + return parseKLineEvent(val) + case "book": + return parseBookEvent(val) + case "trade": + return parsePublicTradeEvent(val) + case "user": + return ParseUserEvent(val) + } + } + + eventType := string(val.GetStringBytes("e")) + switch eventType { + case "authenticated": + return parseAuthEvent(val) + case "error": + return parseErrorEvent(val) + case "subscribed", "unsubscribed": + return parseSubscriptionEvent(val) + } + + return nil, errors.Wrapf(ErrMessageTypeNotSupported, "payload %s", payload) +} + +type TradeEntry struct { + Trend string `json:"tr"` + Price string `json:"p"` + Volume string `json:"v"` + Timestamp int64 `json:"T"` +} + +func (e TradeEntry) Time() time.Time { + return time.Unix(0, e.Timestamp*int64(time.Millisecond)) +} + +// parseTradeEntry parse the trade content payload +func parseTradeEntry(val *fastjson.Value) TradeEntry { + return TradeEntry{ + Trend: strings.ToLower(string(val.GetStringBytes("tr"))), + Timestamp: val.GetInt64("T"), + Price: string(val.GetStringBytes("p")), + Volume: string(val.GetStringBytes("v")), + } +} + +type KLineEvent struct { + Event string `json:"e"` + Market string `json:"M"` + Channel string `json:"c"` + KLine KLine `json:"k"` + Timestamp int64 `json:"T"` +} + +/* + { + "c": "kline", + "M": "btcusdt", + "e": "update", + "T": 1602999650179, + "k": { + "ST": 1602999900000, + "ET": 1602999900000, + "M": "btcusdt", + "R": "5m", + "O": "11417.21", + "H": "11417.21", + "L": "11417.21", + "C": "11417.21", + "v": "0", + "ti": 0, + "x": false + } + } +*/ +type KLinePayload struct { + StartTime int64 `json:"ST"` + EndTime int64 `json:"ET"` + Market string `json:"M"` + Resolution string `json:"R"` + Open string `json:"O"` + High string `json:"H"` + Low string `json:"L"` + Close string `json:"C"` + Volume string `json:"v"` + LastTradeID int `json:"ti"` + Closed bool `json:"x"` +} + +func (k KLinePayload) KLine() types.KLine { + return types.KLine{ + StartTime: types.Time(time.Unix(0, k.StartTime*int64(time.Millisecond))), + EndTime: types.Time(time.Unix(0, k.EndTime*int64(time.Millisecond))), + Symbol: k.Market, + Interval: types.Interval(k.Resolution), + Open: fixedpoint.MustNewFromString(k.Open), + Close: fixedpoint.MustNewFromString(k.Close), + High: fixedpoint.MustNewFromString(k.High), + Low: fixedpoint.MustNewFromString(k.Low), + Volume: fixedpoint.MustNewFromString(k.Volume), + QuoteVolume: fixedpoint.Zero, // TODO: add this from kingfisher + LastTradeID: uint64(k.LastTradeID), + NumberOfTrades: 0, // TODO: add this from kingfisher + Closed: k.Closed, + } +} + +type PublicTradeEvent struct { + Event string `json:"e"` + Market string `json:"M"` + Channel string `json:"c"` + Trades []TradeEntry `json:"t"` + Timestamp int64 `json:"T"` +} + +func (e *PublicTradeEvent) Time() time.Time { + return time.Unix(0, e.Timestamp*int64(time.Millisecond)) +} + +func parsePublicTradeEvent(val *fastjson.Value) (*PublicTradeEvent, error) { + event := PublicTradeEvent{ + Event: string(val.GetStringBytes("e")), + Market: string(val.GetStringBytes("M")), + Channel: string(val.GetStringBytes("c")), + Timestamp: val.GetInt64("T"), + } + + for _, tradeValue := range val.GetArray("t") { + event.Trades = append(event.Trades, parseTradeEntry(tradeValue)) + } + + return &event, nil +} + +type BookEvent struct { + Event string `json:"e"` + Market string `json:"M"` + Channel string `json:"c"` + Timestamp int64 `json:"t"` // Millisecond timestamp + Bids types.PriceVolumeSlice + Asks types.PriceVolumeSlice +} + +func (e *BookEvent) Time() time.Time { + return time.Unix(0, e.Timestamp*int64(time.Millisecond)) +} + +func (e *BookEvent) OrderBook() (snapshot types.SliceOrderBook, err error) { + snapshot.Symbol = strings.ToUpper(e.Market) + snapshot.Time = e.Time() + snapshot.Bids = e.Bids + snapshot.Asks = e.Asks + return snapshot, nil +} + +func parseKLineEvent(val *fastjson.Value) (*KLineEvent, error) { + event := KLineEvent{ + Event: string(val.GetStringBytes("e")), + Market: string(val.GetStringBytes("M")), + Channel: string(val.GetStringBytes("c")), + Timestamp: val.GetInt64("T"), + } + + k := val.Get("k") + + event.KLine = KLine{ + Symbol: string(k.GetStringBytes("M")), + Interval: string(k.GetStringBytes("R")), + StartTime: time.Unix(0, k.GetInt64("ST")*int64(time.Millisecond)), + EndTime: time.Unix(0, k.GetInt64("ET")*int64(time.Millisecond)), + Open: fixedpoint.MustNewFromBytes(k.GetStringBytes("O")), + High: fixedpoint.MustNewFromBytes(k.GetStringBytes("H")), + Low: fixedpoint.MustNewFromBytes(k.GetStringBytes("L")), + Close: fixedpoint.MustNewFromBytes(k.GetStringBytes("C")), + Volume: fixedpoint.MustNewFromBytes(k.GetStringBytes("v")), + Closed: k.GetBool("x"), + } + + return &event, nil +} + +func parseBookEvent(val *fastjson.Value) (event *BookEvent, err error) { + event = &BookEvent{ + Event: string(val.GetStringBytes("e")), + Market: string(val.GetStringBytes("M")), + Channel: string(val.GetStringBytes("c")), + Timestamp: val.GetInt64("T"), + } + + // t := time.Unix(0, event.Timestamp*int64(time.Millisecond)) + event.Asks, err = parseBookEntries2(val.GetArray("a")) + if err != nil { + return event, err + } + + event.Bids, err = parseBookEntries2(val.GetArray("b")) + return event, err +} + +// parseBookEntries2 parses JSON struct like `[["233330", "0.33"], ....]` +func parseBookEntries2(vals []*fastjson.Value) (entries types.PriceVolumeSlice, err error) { + entries = make(types.PriceVolumeSlice, 0, 50) + + var arr []*fastjson.Value + for _, entry := range vals { + arr, err = entry.Array() + if err != nil { + return entries, err + } + + if len(arr) < 2 { + return entries, ErrIncorrectBookEntryElementLength + } + + var pv types.PriceVolume + pv.Price, err = fixedpoint.NewFromString(string(arr[0].GetStringBytes())) + if err != nil { + return entries, err + } + + pv.Volume, err = fixedpoint.NewFromString(string(arr[1].GetStringBytes())) + if err != nil { + return entries, err + } + + entries = append(entries, pv) + } + + return entries, err +} + +type ErrorEvent struct { + Timestamp int64 + Errors []string + CommandID string +} + +func (e ErrorEvent) Time() time.Time { + return time.Unix(0, e.Timestamp*int64(time.Millisecond)) +} + +func parseErrorEvent(val *fastjson.Value) (*ErrorEvent, error) { + event := ErrorEvent{ + Timestamp: val.GetInt64("T"), + CommandID: string(val.GetStringBytes("i")), + } + + for _, entry := range val.GetArray("E") { + event.Errors = append(event.Errors, string(entry.GetStringBytes())) + } + + return &event, nil +} + +type SubscriptionEvent struct { + Event string `json:"e"` + Timestamp int64 `json:"T"` + CommandID string `json:"i"` + Subscriptions []Subscription `json:"s"` +} + +func (e SubscriptionEvent) Time() time.Time { + return time.Unix(0, e.Timestamp*int64(time.Millisecond)) +} + +func parseSubscriptionEvent(val *fastjson.Value) (*SubscriptionEvent, error) { + event := SubscriptionEvent{ + Event: string(val.GetStringBytes("e")), + Timestamp: val.GetInt64("T"), + CommandID: string(val.GetStringBytes("i")), + } + + for _, entry := range val.GetArray("s") { + market := string(entry.GetStringBytes("market")) + channel := string(entry.GetStringBytes("channel")) + event.Subscriptions = append(event.Subscriptions, Subscription{ + Market: market, + Channel: channel, + }) + } + + return &event, nil +} diff --git a/pkg/exchange/max/maxapi/public_test.go b/pkg/exchange/max/maxapi/public_test.go new file mode 100644 index 0000000..cf2c208 --- /dev/null +++ b/pkg/exchange/max/maxapi/public_test.go @@ -0,0 +1,70 @@ +package max + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPublicService(t *testing.T) { + key, secret, ok := integrationTestConfigured(t, "MAX") + if !ok { + t.SkipNow() + } + + ctx := context.Background() + client := NewRestClient(ProductionAPIURL) + _ = key + _ = secret + + t.Run("v2/timestamp", func(t *testing.T) { + req := client.NewGetTimestampRequest() + serverTimestamp, err := req.Do(ctx) + assert.NoError(t, err) + assert.NotZero(t, serverTimestamp) + }) + + t.Run("v2/markets", func(t *testing.T) { + req := client.NewGetMarketsRequest() + markets, err := req.Do(context.Background()) + assert.NoError(t, err) + if assert.NotEmpty(t, markets) { + assert.NotZero(t, markets[0].MinBaseAmount) + assert.NotZero(t, markets[0].MinQuoteAmount) + assert.NotEmpty(t, markets[0].Name) + assert.NotEmpty(t, markets[0].ID) + assert.NotEmpty(t, markets[0].BaseUnit) + assert.NotEmpty(t, markets[0].QuoteUnit) + t.Logf("%+v", markets[0]) + } + }) + + t.Run("v2/k", func(t *testing.T) { + req := client.NewGetKLinesRequest() + data, err := req.Market("btcusdt").Period(int(60)).Limit(100).Do(ctx) + assert.NoError(t, err) + if assert.NotEmpty(t, data) { + assert.NotEmpty(t, data[0]) + assert.Len(t, data[0], 6) + } + }) + + t.Run("v2/tickers", func(t *testing.T) { + req := client.NewGetTickersRequest() + tickers, err := req.Do(ctx) + assert.NoError(t, err) + assert.NotNil(t, tickers) + assert.NotEmpty(t, tickers) + assert.NotEmpty(t, tickers["btcusdt"]) + }) + + t.Run("v2/ticker/:market", func(t *testing.T) { + req := client.NewGetTickerRequest() + req.Market("btcusdt") + ticker, err := req.Do(ctx) + assert.NoError(t, err) + assert.NotNil(t, ticker) + assert.NotEmpty(t, ticker.Sell) + }) +} diff --git a/pkg/exchange/max/maxapi/restapi.go b/pkg/exchange/max/maxapi/restapi.go new file mode 100644 index 0000000..41e727c --- /dev/null +++ b/pkg/exchange/max/maxapi/restapi.go @@ -0,0 +1,356 @@ +package max + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "math" + "net" + "net/http" + "net/http/httputil" + "net/url" + "regexp" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/c9s/requestgen" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util/backoff" + "git.qtrade.icu/lychiyu/qbtrade/pkg/version" +) + +const ( + // ProductionAPIURL is the official MAX API v2 Endpoint + ProductionAPIURL = "https://max-api.maicoin.com/api/v2" + + UserAgent = "bbgo/" + version.Version + + defaultHTTPTimeout = time.Second * 60 + + // 2018-09-01 08:00:00 +0800 CST + TimestampSince = 1535760000 + + maxAllowedNegativeTimeOffset = -20 +) + +var httpTransportMaxIdleConnsPerHost = http.DefaultMaxIdleConnsPerHost +var httpTransportMaxIdleConns = 100 +var httpTransportIdleConnTimeout = 85 * time.Second +var disableUserAgentHeader = false + +func init() { + + if val, ok := util.GetEnvVarInt("HTTP_TRANSPORT_MAX_IDLE_CONNS_PER_HOST"); ok { + httpTransportMaxIdleConnsPerHost = val + } + + if val, ok := util.GetEnvVarInt("HTTP_TRANSPORT_MAX_IDLE_CONNS"); ok { + httpTransportMaxIdleConns = val + } + + if val, ok := util.GetEnvVarDuration("HTTP_TRANSPORT_IDLE_CONN_TIMEOUT"); ok { + httpTransportIdleConnTimeout = val + } + + if val, ok := util.GetEnvVarBool("DISABLE_MAX_USER_AGENT_HEADER"); ok { + disableUserAgentHeader = val + } +} + +var logger = log.WithField("exchange", "max") + +var htmlTagPattern = regexp.MustCompile("<[/]?[a-zA-Z-]+.*?>") + +// The following variables are used for nonce. + +// globalTimeOffset is used for nonce +var globalTimeOffset int64 = 0 + +// globalServerTimestamp is used for storing the server timestamp, default to Now +var globalServerTimestamp = time.Now().Unix() + +// reqCount is used for nonce, this variable counts the API request count. +var reqCount uint64 = 1 + +var nonceOnce sync.Once + +// create an isolated http httpTransport rather than the default one +var httpTransport = &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + + // ForceAttemptHTTP2: true, + // DisableCompression: false, + + MaxIdleConns: httpTransportMaxIdleConns, + MaxIdleConnsPerHost: httpTransportMaxIdleConnsPerHost, + IdleConnTimeout: httpTransportIdleConnTimeout, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, +} + +var defaultHttpClient = &http.Client{ + Timeout: defaultHTTPTimeout, + Transport: httpTransport, +} + +type RestClient struct { + requestgen.BaseAPIClient + + APIKey, APISecret string + + AccountService *AccountService + PublicService *PublicService + TradeService *TradeService + OrderService *OrderService + RewardService *RewardService + WithdrawalService *WithdrawalService +} + +func NewRestClient(baseURL string) *RestClient { + u, err := url.Parse(baseURL) + if err != nil { + panic(err) + } + + var client = &RestClient{ + BaseAPIClient: requestgen.BaseAPIClient{ + HttpClient: defaultHttpClient, + BaseURL: u, + }, + } + + client.AccountService = &AccountService{client} + client.TradeService = &TradeService{client} + client.PublicService = &PublicService{client} + client.OrderService = &OrderService{client} + client.RewardService = &RewardService{client} + client.WithdrawalService = &WithdrawalService{client} + + // defaultHttpClient.MaxTokenService = &MaxTokenService{defaultHttpClient} + client.initNonce() + return client +} + +// Auth sets api key and secret for usage is requests that requires authentication. +func (c *RestClient) Auth(key string, secret string) *RestClient { + // pragma: allowlist nextline secret + c.APIKey = key + // pragma: allowlist nextline secret + c.APISecret = secret + return c +} + +func (c *RestClient) queryAndUpdateServerTimestamp(ctx context.Context) { + op := func() error { + serverTs, err := c.PublicService.Timestamp(ctx) + if err != nil { + return err + } + + if serverTs == 0 { + return errors.New("unexpected zero server timestamp") + } + + clientTime := time.Now() + offset := serverTs - clientTime.Unix() + + if offset < 0 { + // avoid updating a negative offset: server time is before the local time + if offset > maxAllowedNegativeTimeOffset { + return nil + } + + // if the offset is greater than 15 seconds, we should restart + logger.Panicf("max exchange server timestamp offset %d is less than the negative offset %d", offset, maxAllowedNegativeTimeOffset) + } + + atomic.StoreInt64(&globalServerTimestamp, serverTs) + atomic.StoreInt64(&globalTimeOffset, offset) + + logger.Debugf("loaded max server timestamp: %d offset=%d", globalServerTimestamp, offset) + return nil + } + + if err := backoff.RetryGeneral(ctx, op); err != nil { + logger.WithError(err).Error("unable to sync timestamp with max") + } +} + +func (c *RestClient) initNonce() { + nonceOnce.Do(func() { + go c.queryAndUpdateServerTimestamp(context.Background()) + }) +} + +func (c *RestClient) getNonce() int64 { + // nonce 是以正整數表示的時間戳記,代表了從 Unix epoch 到當前時間所經過的毫秒數(ms)。 + // nonce 與伺服器的時間差不得超過正負30秒,每個 nonce 只能使用一次。 + var seconds = time.Now().Unix() + var rc = atomic.AddUint64(&reqCount, 1) + var offset = atomic.LoadInt64(&globalTimeOffset) + return (seconds+offset)*1000 - 1 + int64(math.Mod(float64(rc), 1000.0)) +} + +func (c *RestClient) NewAuthenticatedRequest( + ctx context.Context, m string, refURL string, params url.Values, payload interface{}, +) (*http.Request, error) { + return c.newAuthenticatedRequest(ctx, m, refURL, params, payload, nil) +} + +// newAuthenticatedRequest creates new http request for authenticated routes. +func (c *RestClient) newAuthenticatedRequest( + ctx context.Context, m string, refURL string, params url.Values, data interface{}, rel *url.URL, +) (*http.Request, error) { + if len(c.APIKey) == 0 { + return nil, errors.New("empty api key") + } + + if len(c.APISecret) == 0 { + return nil, errors.New("empty api secret") + } + + var err error + if rel == nil { + rel, err = url.Parse(refURL) + if err != nil { + return nil, err + } + } + + var p []byte + var payload = map[string]interface{}{ + "nonce": c.getNonce(), + "path": c.BaseURL.ResolveReference(rel).Path, + } + + switch d := data.(type) { + case map[string]interface{}: + for k, v := range d { + payload[k] = v + } + } + + for k, vs := range params { + k = strings.TrimSuffix(k, "[]") + if len(vs) == 1 { + payload[k] = vs[0] + } else { + payload[k] = vs + } + } + + p, err = castPayload(payload) + if err != nil { + return nil, err + } + + req, err := c.NewRequest(ctx, m, refURL, params, p) + if err != nil { + return nil, err + } + + encoded := base64.StdEncoding.EncodeToString(p) + + req.Header.Add("Content-Type", "application/json") + req.Header.Add("X-MAX-ACCESSKEY", c.APIKey) + req.Header.Add("X-MAX-PAYLOAD", encoded) + req.Header.Add("X-MAX-SIGNATURE", signPayload(encoded, c.APISecret)) + + if disableUserAgentHeader { + req.Header.Set("USER-AGENT", "") + } else { + req.Header.Set("USER-AGENT", UserAgent) + } + + if false { + out, _ := httputil.DumpRequestOut(req, true) + fmt.Println(string(out)) + } + + return req, nil +} + +type ErrorField struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// ErrorResponse is the custom error type that is returned if the API returns an +// error. +type ErrorResponse struct { + *requestgen.Response + Err ErrorField `json:"error"` +} + +func (r *ErrorResponse) Error() string { + return fmt.Sprintf("%s %s: %d %d %s", + r.Response.Response.Request.Method, + r.Response.Response.Request.URL.String(), + r.Response.Response.StatusCode, + r.Err.Code, + r.Err.Message, + ) +} + +// ToErrorResponse tries to convert/parse the server response to the standard Error interface object +func ToErrorResponse(response *requestgen.Response) (errorResponse *ErrorResponse, err error) { + errorResponse = &ErrorResponse{Response: response} + + contentType := response.Header.Get("content-type") + switch contentType { + case "text/json", "application/json", "application/json; charset=utf-8": + var err = response.DecodeJSON(errorResponse) + if err != nil { + return errorResponse, errors.Wrapf(err, "failed to decode json for response: %d %s", response.StatusCode, string(response.Body)) + } + return errorResponse, nil + case "text/html": + // convert 5xx error from the HTML page to the ErrorResponse + errorResponse.Err.Message = htmlTagPattern.ReplaceAllLiteralString(string(response.Body), "") + return errorResponse, nil + case "text/plain": + errorResponse.Err.Message = string(response.Body) + return errorResponse, nil + } + + return errorResponse, fmt.Errorf("unexpected response content type %s", contentType) +} + +func castPayload(payload interface{}) ([]byte, error) { + if payload == nil { + return nil, nil + } + + switch v := payload.(type) { + case string: + return []byte(v), nil + + case []byte: + return v, nil + } + + body, err := json.Marshal(payload) + return body, err +} + +func signPayload(payload string, secret string) string { + var sig = hmac.New(sha256.New, []byte(secret)) + _, err := sig.Write([]byte(payload)) + if err != nil { + return "" + } + return hex.EncodeToString(sig.Sum(nil)) +} diff --git a/pkg/exchange/max/maxapi/reward.go b/pkg/exchange/max/maxapi/reward.go new file mode 100644 index 0000000..737c0b3 --- /dev/null +++ b/pkg/exchange/max/maxapi/reward.go @@ -0,0 +1,169 @@ +package max + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type RewardType string + +const ( + RewardAirdrop = RewardType("airdrop_reward") + RewardCommission = RewardType("commission") + RewardHolding = RewardType("holding_reward") + RewardMining = RewardType("mining_reward") + RewardTrading = RewardType("trading_reward") + RewardRedemption = RewardType("redemption_reward") + RewardVipRebate = RewardType("vip_rebate") +) + +func ParseRewardType(s string) (RewardType, error) { + switch s { + case "airdrop_reward": + return RewardAirdrop, nil + case "commission": + return RewardCommission, nil + case "holding_reward": + return RewardHolding, nil + case "mining_reward": + return RewardMining, nil + case "trading_reward": + return RewardTrading, nil + case "vip_rebate": + return RewardVipRebate, nil + case "redemption_reward": + return RewardRedemption, nil + + } + + return RewardType(""), fmt.Errorf("unknown reward type: %s", s) +} + +func (t *RewardType) UnmarshalJSON(o []byte) error { + var s string + var err = json.Unmarshal(o, &s) + if err != nil { + return err + } + + rt, err := ParseRewardType(s) + if err != nil { + return err + } + + *t = rt + return nil +} + +func (t RewardType) RewardType() (types.RewardType, error) { + switch t { + + case RewardAirdrop: + return types.RewardAirdrop, nil + + case RewardCommission: + return types.RewardCommission, nil + + case RewardHolding: + return types.RewardHolding, nil + + case RewardMining: + return types.RewardMining, nil + + case RewardTrading: + return types.RewardTrading, nil + + case RewardVipRebate: + return types.RewardVipRebate, nil + + } + + return types.RewardType(""), fmt.Errorf("unknown reward type: %s", t) +} + +type Reward struct { + // UUID here is more like SN, not the real UUID + UUID string `json:"uuid"` + Type RewardType `json:"type"` + Currency string `json:"currency"` + Amount fixedpoint.Value `json:"amount"` + State string `json:"state"` + Note string `json:"note"` + + // Unix timestamp in seconds + CreatedAt types.Timestamp `json:"created_at"` +} + +func (reward Reward) Reward() (*types.Reward, error) { + rt, err := reward.Type.RewardType() + if err != nil { + return nil, err + } + + return &types.Reward{ + UUID: reward.UUID, + Exchange: types.ExchangeMax, + Type: rt, + Currency: strings.ToUpper(reward.Currency), + Quantity: reward.Amount, + State: reward.State, + Note: reward.Note, + Spent: false, + CreatedAt: types.Time(reward.CreatedAt), + }, nil +} + +type RewardService struct { + client requestgen.AuthenticatedAPIClient +} + +func (s *RewardService) NewGetRewardsRequest() *GetRewardsRequest { + return &GetRewardsRequest{client: s.client} +} + +func (s *RewardService) NewGetRewardsOfTypeRequest(pathType RewardType) *GetRewardsOfTypeRequest { + return &GetRewardsOfTypeRequest{client: s.client, pathType: &pathType} +} + +//go:generate GetRequest -url "v2/rewards/:path_type" -type GetRewardsOfTypeRequest -responseType []Reward +type GetRewardsOfTypeRequest struct { + client requestgen.AuthenticatedAPIClient + + pathType *RewardType `param:"path_type,slug"` + + // From Unix-timestamp + from *int64 `param:"from"` + + // To Unix-timestamp + to *int64 `param:"to"` + + page *int64 `param:"page"` + limit *int64 `param:"limit"` + offset *int64 `param:"offset"` +} + +//go:generate GetRequest -url "v2/rewards" -type GetRewardsRequest -responseType []Reward +type GetRewardsRequest struct { + client requestgen.AuthenticatedAPIClient + + currency *string `param:"currency"` + + // From Unix-timestamp + from *int64 `param:"from"` + + // To Unix-timestamp + to *int64 `param:"to"` + + page *int64 `param:"page"` + limit *int64 `param:"limit"` + offset *int64 `param:"offset"` +} diff --git a/pkg/exchange/max/maxapi/reward_test.go b/pkg/exchange/max/maxapi/reward_test.go new file mode 100644 index 0000000..a60eecf --- /dev/null +++ b/pkg/exchange/max/maxapi/reward_test.go @@ -0,0 +1,43 @@ +package max + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRewardService_GetRewardsRequest(t *testing.T) { + key, secret, ok := integrationTestConfigured(t, "MAX") + if !ok { + t.SkipNow() + } + + ctx := context.Background() + + client := NewRestClient(ProductionAPIURL) + client.Auth(key, secret) + + t.Run("v2/rewards", func(t *testing.T) { + req := client.RewardService.NewGetRewardsRequest() + rewards, err := req.Do(ctx) + assert.NoError(t, err) + assert.NotNil(t, rewards) + assert.NotEmpty(t, rewards) + t.Logf("rewards: %+v", rewards) + }) + + t.Run("v2/rewards with type", func(t *testing.T) { + req := client.RewardService.NewGetRewardsOfTypeRequest(RewardCommission) + rewards, err := req.Do(ctx) + assert.NoError(t, err) + assert.NotNil(t, rewards) + assert.NotEmpty(t, rewards) + + t.Logf("rewards: %+v", rewards) + + for _, reward := range rewards { + assert.Equal(t, RewardCommission, reward.Type) + } + }) +} diff --git a/pkg/exchange/max/maxapi/trade.go b/pkg/exchange/max/maxapi/trade.go new file mode 100644 index 0000000..11ebe55 --- /dev/null +++ b/pkg/exchange/max/maxapi/trade.go @@ -0,0 +1,154 @@ +package max + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST + +import ( + "net/url" + "strconv" + "time" + + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type MarkerInfo struct { + Fee string `json:"fee"` + FeeCurrency string `json:"fee_currency"` + OrderID int `json:"order_id"` +} + +type TradeInfo struct { + // Maker tells you the maker trade side + Maker string `json:"maker,omitempty"` + Bid *MarkerInfo `json:"bid,omitempty"` + Ask *MarkerInfo `json:"ask,omitempty"` +} + +type Liquidity string + +// Trade represents one returned trade on the max platform. +type Trade struct { + ID uint64 `json:"id" db:"exchange_id"` + WalletType WalletType `json:"wallet_type,omitempty"` + Price fixedpoint.Value `json:"price"` + Volume fixedpoint.Value `json:"volume"` + Funds fixedpoint.Value `json:"funds"` + Market string `json:"market"` + MarketName string `json:"market_name"` + CreatedAt types.MillisecondTimestamp `json:"created_at"` + Side string `json:"side"` + OrderID uint64 `json:"order_id"` + Fee fixedpoint.Value `json:"fee"` // float number as string + FeeCurrency string `json:"fee_currency"` + Liquidity Liquidity `json:"liquidity"` + Info TradeInfo `json:"info,omitempty"` +} + +func (t Trade) IsBuyer() bool { + return t.Side == "bid" || t.Side == "buy" +} + +func (t Trade) IsMaker() bool { + return t.Info.Maker == t.Side +} + +type QueryTradeOptions struct { + Market string `json:"market"` + Timestamp int64 `json:"timestamp,omitempty"` + From int64 `json:"from,omitempty"` + To int64 `json:"to,omitempty"` + OrderBy string `json:"order_by,omitempty"` + Page int `json:"page,omitempty"` + Offset int `json:"offset,omitempty"` + Limit int64 `json:"limit,omitempty"` +} + +type TradeService struct { + client requestgen.AuthenticatedAPIClient +} + +func (options *QueryTradeOptions) Map() map[string]interface{} { + var data = map[string]interface{}{} + data["market"] = options.Market + + if options.Limit > 0 { + data["limit"] = options.Limit + } + + if options.Timestamp > 0 { + data["timestamp"] = options.Timestamp + } + + if options.From >= 0 { + data["from"] = options.From + } + + if options.To > options.From { + data["to"] = options.To + } + if len(options.OrderBy) > 0 { + // could be "asc" or "desc" + data["order_by"] = options.OrderBy + } + + return data +} + +func (options *QueryTradeOptions) Params() url.Values { + var params = url.Values{} + params.Add("market", options.Market) + + if options.Limit > 0 { + params.Add("limit", strconv.FormatInt(options.Limit, 10)) + } + if options.Timestamp > 0 { + params.Add("timestamp", strconv.FormatInt(options.Timestamp, 10)) + } + if options.From >= 0 { + params.Add("from", strconv.FormatInt(options.From, 10)) + } + if options.To > options.From { + params.Add("to", strconv.FormatInt(options.To, 10)) + } + if len(options.OrderBy) > 0 { + // could be "asc" or "desc" + params.Add("order_by", options.OrderBy) + } + return params +} + +func (s *TradeService) NewGetPrivateTradeRequest() *GetPrivateTradesRequest { + return &GetPrivateTradesRequest{client: s.client} +} + +type PrivateRequestParams struct { + Nonce int64 `json:"nonce"` + Path string `json:"path"` +} + +//go:generate GetRequest -url "v2/trades/my" -type GetPrivateTradesRequest -responseType []Trade +type GetPrivateTradesRequest struct { + client requestgen.AuthenticatedAPIClient + + market string `param:"market"` // nolint:golint,structcheck + + // timestamp is the seconds elapsed since Unix epoch, set to return trades executed before the time only + timestamp *time.Time `param:"timestamp,seconds"` // nolint:golint,structcheck + + // From field is a trade id, set ot return trades created after the trade + from *int64 `param:"from"` // nolint:golint,structcheck + + // To field trade id, set to return trades created before the trade + to *int64 `param:"to"` // nolint:golint,structcheck + + orderBy *string `param:"order_by"` + + pagination *bool `param:"pagination"` + + limit *int64 `param:"limit"` + + offset *int64 `param:"offset"` +} diff --git a/pkg/exchange/max/maxapi/userdata.go b/pkg/exchange/max/maxapi/userdata.go new file mode 100644 index 0000000..ad41573 --- /dev/null +++ b/pkg/exchange/max/maxapi/userdata.go @@ -0,0 +1,250 @@ +package max + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "github.com/valyala/fastjson" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type BaseEvent struct { + Event string `json:"e"` + Timestamp int64 `json:"T"` +} + +type OrderUpdate struct { + Event string `json:"e"` + ID uint64 `json:"i"` + Side string `json:"sd"` + OrderType OrderType `json:"ot"` + + Price fixedpoint.Value `json:"p"` + StopPrice fixedpoint.Value `json:"sp"` + + Volume fixedpoint.Value `json:"v"` + AveragePrice fixedpoint.Value `json:"ap"` + State OrderState `json:"S"` + Market string `json:"M"` + + RemainingVolume fixedpoint.Value `json:"rv"` + ExecutedVolume fixedpoint.Value `json:"ev"` + + TradesCount int64 `json:"tc"` + + GroupID uint32 `json:"gi"` + ClientOID string `json:"ci"` + CreatedAtMs int64 `json:"T"` + UpdateTime int64 `json:"TU"` +} + +type OrderUpdateEvent struct { + BaseEvent + + Orders []OrderUpdate `json:"o"` +} + +func parseOrderUpdateEvent(v *fastjson.Value) *OrderUpdateEvent { + var e OrderUpdateEvent + e.Event = string(v.GetStringBytes("e")) + e.Timestamp = v.GetInt64("T") + + for _, ov := range v.GetArray("o") { + var o = ov.String() + var u OrderUpdate + if err := json.Unmarshal([]byte(o), &u); err != nil { + log.WithError(err).Error("parse error") + continue + } + + e.Orders = append(e.Orders, u) + } + + return &e +} + +type OrderSnapshotEvent struct { + BaseEvent + + Orders []OrderUpdate `json:"o"` +} + +func parserOrderSnapshotEvent(v *fastjson.Value) *OrderSnapshotEvent { + var e OrderSnapshotEvent + e.Event = string(v.GetStringBytes("e")) + e.Timestamp = v.GetInt64("T") + + for _, ov := range v.GetArray("o") { + var o = ov.String() + var u OrderUpdate + if err := json.Unmarshal([]byte(o), &u); err != nil { + log.WithError(err).Error("parse error") + continue + } + + e.Orders = append(e.Orders, u) + } + + return &e +} + +type TradeUpdate struct { + ID uint64 `json:"i"` + Side string `json:"sd"` + Price fixedpoint.Value `json:"p"` + Volume fixedpoint.Value `json:"v"` + Funds fixedpoint.Value `json:"fn"` + Market string `json:"M"` + + Fee fixedpoint.Value `json:"f"` + FeeCurrency string `json:"fc"` + FeeDiscounted bool `json:"fd"` + + Timestamp types.MillisecondTimestamp `json:"T"` + UpdateTime types.MillisecondTimestamp `json:"TU"` + + OrderID uint64 `json:"oi"` + + Maker bool `json:"m"` +} + +type TradeUpdateEvent struct { + BaseEvent + Trades []TradeUpdate `json:"t"` +} + +type TradeSnapshotEvent struct { + BaseEvent + Trades []TradeUpdate `json:"t"` +} + +func parseTradeUpdateEvent(v *fastjson.Value) (*TradeUpdateEvent, error) { + jsonBytes := v.String() + var e TradeUpdateEvent + err := json.Unmarshal([]byte(jsonBytes), &e) + return &e, err +} + +func parseTradeSnapshotEvent(v *fastjson.Value) (*TradeSnapshotEvent, error) { + jsonBytes := v.String() + var e TradeSnapshotEvent + err := json.Unmarshal([]byte(jsonBytes), &e) + return &e, err +} + +type BalanceMessage struct { + Currency string `json:"cu"` + Available fixedpoint.Value `json:"av"` + Locked fixedpoint.Value `json:"l"` +} + +func (m *BalanceMessage) Balance() (*types.Balance, error) { + return &types.Balance{ + Currency: strings.ToUpper(m.Currency), + Locked: m.Locked, + Available: m.Available, + }, nil +} + +type AccountUpdateEvent struct { + BaseEvent + Balances []BalanceMessage `json:"B"` +} + +type AccountSnapshotEvent struct { + BaseEvent + Balances []BalanceMessage `json:"B"` +} + +func parseAuthEvent(v *fastjson.Value) (*AuthEvent, error) { + var e AuthEvent + var err = json.Unmarshal([]byte(v.String()), &e) + return &e, err +} + +type ADRatio struct { + ADRatio fixedpoint.Value `json:"ad"` + AssetInUSDT fixedpoint.Value `json:"as"` + DebtInUSDT fixedpoint.Value `json:"db"` + IndexPrices []struct { + Market string `json:"M"` + Price fixedpoint.Value `json:"p"` + } `json:"idxp"` + TU types.MillisecondTimestamp `json:"TU"` +} + +func (r *ADRatio) String() string { + return fmt.Sprintf("ADRatio: %v Asset: %v USDT, Debt: %v USDT (Mark Prices: %+v)", r.ADRatio, r.AssetInUSDT, r.DebtInUSDT, r.IndexPrices) +} + +type ADRatioEvent struct { + ADRatio ADRatio `json:"ad"` +} + +func parseADRatioEvent(v *fastjson.Value) (*ADRatioEvent, error) { + o := v.String() + e := ADRatioEvent{} + err := json.Unmarshal([]byte(o), &e) + return &e, err +} + +type Debt struct { + Currency string `json:"cu"` + DebtPrincipal fixedpoint.Value `json:"dbp"` + DebtInterest fixedpoint.Value `json:"dbi"` + TU types.MillisecondTimestamp `json:"TU"` +} + +func (d *Debt) String() string { + return fmt.Sprintf("Debt %s %v (Interest %v)", d.Currency, d.DebtPrincipal, d.DebtInterest) +} + +type DebtEvent struct { + Debts []Debt `json:"db"` +} + +func parseDebts(v *fastjson.Value) (*DebtEvent, error) { + o := v.String() + e := DebtEvent{} + err := json.Unmarshal([]byte(o), &e) + return &e, err +} + +func ParseUserEvent(v *fastjson.Value) (interface{}, error) { + eventType := string(v.GetStringBytes("e")) + switch eventType { + case "order_snapshot", "mwallet_order_snapshot": + return parserOrderSnapshotEvent(v), nil + + case "order_update", "mwallet_order_update": + return parseOrderUpdateEvent(v), nil + + case "trade_snapshot", "mwallet_trade_snapshot": + return parseTradeSnapshotEvent(v) + + case "trade_update", "mwallet_trade_update": + return parseTradeUpdateEvent(v) + + case "ad_ratio_snapshot", "ad_ratio_update": + return parseADRatioEvent(v) + + case "borrowing_snapshot", "borrowing_update": + return parseDebts(v) + + case "account_snapshot", "account_update", "mwallet_account_snapshot", "mwallet_account_update": + var e AccountUpdateEvent + o := v.String() + err := json.Unmarshal([]byte(o), &e) + return &e, err + + case "error": + logger.Errorf("error %s", v.MarshalTo(nil)) + } + + return nil, errors.Wrapf(ErrMessageTypeNotSupported, "private message %s", v.MarshalTo(nil)) +} diff --git a/pkg/exchange/max/maxapi/userdata_test.go b/pkg/exchange/max/maxapi/userdata_test.go new file mode 100644 index 0000000..5c4880c --- /dev/null +++ b/pkg/exchange/max/maxapi/userdata_test.go @@ -0,0 +1,45 @@ +package max + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/valyala/fastjson" +) + +func Test_parseTradeSnapshotEvent(t *testing.T) { + fv, err := fastjson.Parse(`{ + "c": "user", + "e": "trade_snapshot", + "t": [{ + "i": 68444, + "p": "21499.0", + "v": "0.2658", + "M": "ethtwd", + "T": 1521726960357, + "sd": "bid", + "f": "3.2", + "fc": "twd", + "fd": false, + "m": true, + "oi": 7423, + "ci": "client-oid-1", + "gi": 123 + }], + "T": 1591786735192 + }`) + assert.NoError(t, err) + assert.NotNil(t, fv) + + evt, err := parseTradeSnapshotEvent(fv) + assert.NoError(t, err) + assert.NotNil(t, evt) + assert.Equal(t, "trade_snapshot", evt.Event) + assert.Equal(t, int64(1591786735192), evt.Timestamp) + assert.Equal(t, 1, len(evt.Trades)) + assert.Equal(t, "bid", evt.Trades[0].Side) + assert.Equal(t, "ethtwd", evt.Trades[0].Market) + assert.Equal(t, int64(1521726960357), evt.Trades[0].Timestamp.Time().UnixMilli()) + assert.Equal(t, "3.2", evt.Trades[0].Fee.String()) + assert.Equal(t, "twd", evt.Trades[0].FeeCurrency) +} diff --git a/pkg/exchange/max/maxapi/v3/cancel_order_request.go b/pkg/exchange/max/maxapi/v3/cancel_order_request.go new file mode 100644 index 0000000..875759f --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/cancel_order_request.go @@ -0,0 +1,19 @@ +package v3 + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST +//go:generate -command DeleteRequest requestgen -method DELETE + +import "github.com/c9s/requestgen" + +//go:generate DeleteRequest -url "/api/v3/order" -type CancelOrderRequest -responseType .Order +type CancelOrderRequest struct { + client requestgen.AuthenticatedAPIClient + + id *uint64 `param:"id,omitempty"` + clientOrderID *string `param:"client_oid,omitempty"` +} + +func (s *Client) NewCancelOrderRequest() *CancelOrderRequest { + return &CancelOrderRequest{client: s.Client} +} diff --git a/pkg/exchange/max/maxapi/v3/cancel_order_request_requestgen.go b/pkg/exchange/max/maxapi/v3/cancel_order_request_requestgen.go new file mode 100644 index 0000000..4b1ee1b --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/cancel_order_request_requestgen.go @@ -0,0 +1,164 @@ +// Code generated by "requestgen -method DELETE -url /api/v3/order -type CancelOrderRequest -responseType .Order"; DO NOT EDIT. + +package v3 + +import ( + "context" + "encoding/json" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/max/maxapi" + "net/url" + "reflect" + "regexp" +) + +func (c *CancelOrderRequest) Id(id uint64) *CancelOrderRequest { + c.id = &id + return c +} + +func (c *CancelOrderRequest) ClientOrderID(clientOrderID string) *CancelOrderRequest { + c.clientOrderID = &clientOrderID + return c +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (c *CancelOrderRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (c *CancelOrderRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check id field -> json key id + if c.id != nil { + id := *c.id + + // assign parameter of id + params["id"] = id + } else { + } + // check clientOrderID field -> json key client_oid + if c.clientOrderID != nil { + clientOrderID := *c.clientOrderID + + // assign parameter of clientOrderID + params["client_oid"] = clientOrderID + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (c *CancelOrderRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := c.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if c.isVarSlice(_v) { + c.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (c *CancelOrderRequest) GetParametersJSON() ([]byte, error) { + params, err := c.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (c *CancelOrderRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (c *CancelOrderRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (c *CancelOrderRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (c *CancelOrderRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (c *CancelOrderRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := c.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (c *CancelOrderRequest) Do(ctx context.Context) (*max.Order, error) { + + params, err := c.GetParameters() + if err != nil { + return nil, err + } + query := url.Values{} + + apiURL := "/api/v3/order" + + req, err := c.client.NewAuthenticatedRequest(ctx, "DELETE", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := c.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse max.Order + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return &apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/v3/cancel_wallet_order_all_request.go b/pkg/exchange/max/maxapi/v3/cancel_wallet_order_all_request.go new file mode 100644 index 0000000..773d100 --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/cancel_wallet_order_all_request.go @@ -0,0 +1,26 @@ +package v3 + +import "github.com/c9s/requestgen" + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST +//go:generate -command DeleteRequest requestgen -method DELETE + +type OrderCancelResponse struct { + Order Order + Error *string +} + +func (s *Client) NewCancelWalletOrderAllRequest(walletType WalletType) *CancelWalletOrderAllRequest { + return &CancelWalletOrderAllRequest{client: s.Client, walletType: walletType} +} + +//go:generate DeleteRequest -url "/api/v3/wallet/:walletType/orders" -type CancelWalletOrderAllRequest -responseType []OrderCancelResponse +type CancelWalletOrderAllRequest struct { + client requestgen.AuthenticatedAPIClient + + walletType WalletType `param:"walletType,slug,required"` + side *string `param:"side"` + market *string `param:"market"` + groupID *uint32 `param:"group_id"` +} diff --git a/pkg/exchange/max/maxapi/v3/cancel_wallet_order_all_request_requestgen.go b/pkg/exchange/max/maxapi/v3/cancel_wallet_order_all_request_requestgen.go new file mode 100644 index 0000000..d64ab84 --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/cancel_wallet_order_all_request_requestgen.go @@ -0,0 +1,200 @@ +// Code generated by "requestgen -method DELETE -url /api/v3/wallet/:walletType/orders -type CancelWalletOrderAllRequest -responseType []OrderCancelResponse"; DO NOT EDIT. + +package v3 + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" + + max "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/max/maxapi" +) + +func (c *CancelWalletOrderAllRequest) Side(side string) *CancelWalletOrderAllRequest { + c.side = &side + return c +} + +func (c *CancelWalletOrderAllRequest) Market(market string) *CancelWalletOrderAllRequest { + c.market = &market + return c +} + +func (c *CancelWalletOrderAllRequest) GroupID(groupID uint32) *CancelWalletOrderAllRequest { + c.groupID = &groupID + return c +} + +func (c *CancelWalletOrderAllRequest) WalletType(walletType max.WalletType) *CancelWalletOrderAllRequest { + c.walletType = walletType + return c +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (c *CancelWalletOrderAllRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (c *CancelWalletOrderAllRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check side field -> json key side + if c.side != nil { + side := *c.side + + // assign parameter of side + params["side"] = side + } else { + } + // check market field -> json key market + if c.market != nil { + market := *c.market + + // assign parameter of market + params["market"] = market + } else { + } + // check groupID field -> json key group_id + if c.groupID != nil { + groupID := *c.groupID + + // assign parameter of groupID + params["group_id"] = groupID + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (c *CancelWalletOrderAllRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := c.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if c.isVarSlice(_v) { + c.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (c *CancelWalletOrderAllRequest) GetParametersJSON() ([]byte, error) { + params, err := c.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (c *CancelWalletOrderAllRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check walletType field -> json key walletType + walletType := c.walletType + + // TEMPLATE check-required + if len(walletType) == 0 { + return nil, fmt.Errorf("walletType is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of walletType + params["walletType"] = walletType + + return params, nil +} + +func (c *CancelWalletOrderAllRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (c *CancelWalletOrderAllRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (c *CancelWalletOrderAllRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (c *CancelWalletOrderAllRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := c.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (c *CancelWalletOrderAllRequest) Do(ctx context.Context) ([]OrderCancelResponse, error) { + + params, err := c.GetParameters() + if err != nil { + return nil, err + } + query := url.Values{} + + apiURL := "/api/v3/wallet/:walletType/orders" + slugs, err := c.GetSlugsMap() + if err != nil { + return nil, err + } + + apiURL = c.applySlugsToUrl(apiURL, slugs) + + req, err := c.client.NewAuthenticatedRequest(ctx, "DELETE", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := c.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []OrderCancelResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/v3/create_wallet_order_request.go b/pkg/exchange/max/maxapi/v3/create_wallet_order_request.go new file mode 100644 index 0000000..762a007 --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/create_wallet_order_request.go @@ -0,0 +1,27 @@ +package v3 + +import "github.com/c9s/requestgen" + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST +//go:generate -command DeleteRequest requestgen -method DELETE + +//go:generate PostRequest -url "/api/v3/wallet/:walletType/order" -type CreateWalletOrderRequest -responseType .Order +type CreateWalletOrderRequest struct { + client requestgen.AuthenticatedAPIClient + + walletType WalletType `param:"walletType,slug,required"` + market string `param:"market,required"` + side string `param:"side,required"` + volume string `param:"volume,required"` + orderType OrderType `param:"ord_type"` + + price *string `param:"price"` + stopPrice *string `param:"stop_price"` + clientOrderID *string `param:"client_oid"` + groupID *string `param:"group_id"` +} + +func (s *Client) NewCreateWalletOrderRequest(walletType WalletType) *CreateWalletOrderRequest { + return &CreateWalletOrderRequest{client: s.Client, walletType: walletType} +} diff --git a/pkg/exchange/max/maxapi/v3/create_wallet_order_request_requestgen.go b/pkg/exchange/max/maxapi/v3/create_wallet_order_request_requestgen.go new file mode 100644 index 0000000..a6c2560 --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/create_wallet_order_request_requestgen.go @@ -0,0 +1,270 @@ +// Code generated by "requestgen -method POST -url /api/v3/wallet/:walletType/order -type CreateWalletOrderRequest -responseType .Order"; DO NOT EDIT. + +package v3 + +import ( + "context" + "encoding/json" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/max/maxapi" + "net/url" + "reflect" + "regexp" +) + +func (c *CreateWalletOrderRequest) Market(market string) *CreateWalletOrderRequest { + c.market = market + return c +} + +func (c *CreateWalletOrderRequest) Side(side string) *CreateWalletOrderRequest { + c.side = side + return c +} + +func (c *CreateWalletOrderRequest) Volume(volume string) *CreateWalletOrderRequest { + c.volume = volume + return c +} + +func (c *CreateWalletOrderRequest) OrderType(orderType max.OrderType) *CreateWalletOrderRequest { + c.orderType = orderType + return c +} + +func (c *CreateWalletOrderRequest) Price(price string) *CreateWalletOrderRequest { + c.price = &price + return c +} + +func (c *CreateWalletOrderRequest) StopPrice(stopPrice string) *CreateWalletOrderRequest { + c.stopPrice = &stopPrice + return c +} + +func (c *CreateWalletOrderRequest) ClientOrderID(clientOrderID string) *CreateWalletOrderRequest { + c.clientOrderID = &clientOrderID + return c +} + +func (c *CreateWalletOrderRequest) GroupID(groupID string) *CreateWalletOrderRequest { + c.groupID = &groupID + return c +} + +func (c *CreateWalletOrderRequest) WalletType(walletType max.WalletType) *CreateWalletOrderRequest { + c.walletType = walletType + return c +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (c *CreateWalletOrderRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (c *CreateWalletOrderRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check market field -> json key market + market := c.market + + // TEMPLATE check-required + if len(market) == 0 { + return nil, fmt.Errorf("market is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of market + params["market"] = market + // check side field -> json key side + side := c.side + + // TEMPLATE check-required + if len(side) == 0 { + return nil, fmt.Errorf("side is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of side + params["side"] = side + // check volume field -> json key volume + volume := c.volume + + // TEMPLATE check-required + if len(volume) == 0 { + return nil, fmt.Errorf("volume is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of volume + params["volume"] = volume + // check orderType field -> json key ord_type + orderType := c.orderType + + // assign parameter of orderType + params["ord_type"] = orderType + // check price field -> json key price + if c.price != nil { + price := *c.price + + // assign parameter of price + params["price"] = price + } else { + } + // check stopPrice field -> json key stop_price + if c.stopPrice != nil { + stopPrice := *c.stopPrice + + // assign parameter of stopPrice + params["stop_price"] = stopPrice + } else { + } + // check clientOrderID field -> json key client_oid + if c.clientOrderID != nil { + clientOrderID := *c.clientOrderID + + // assign parameter of clientOrderID + params["client_oid"] = clientOrderID + } else { + } + // check groupID field -> json key group_id + if c.groupID != nil { + groupID := *c.groupID + + // assign parameter of groupID + params["group_id"] = groupID + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (c *CreateWalletOrderRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := c.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if c.isVarSlice(_v) { + c.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (c *CreateWalletOrderRequest) GetParametersJSON() ([]byte, error) { + params, err := c.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (c *CreateWalletOrderRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check walletType field -> json key walletType + walletType := c.walletType + + // TEMPLATE check-required + if len(walletType) == 0 { + return nil, fmt.Errorf("walletType is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of walletType + params["walletType"] = walletType + + return params, nil +} + +func (c *CreateWalletOrderRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (c *CreateWalletOrderRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (c *CreateWalletOrderRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (c *CreateWalletOrderRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := c.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (c *CreateWalletOrderRequest) Do(ctx context.Context) (*max.Order, error) { + + params, err := c.GetParameters() + if err != nil { + return nil, err + } + query := url.Values{} + + apiURL := "/api/v3/wallet/:walletType/order" + slugs, err := c.GetSlugsMap() + if err != nil { + return nil, err + } + + apiURL = c.applySlugsToUrl(apiURL, slugs) + + req, err := c.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := c.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse max.Order + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return &apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/v3/get_margin_ad_ratio_request.go b/pkg/exchange/max/maxapi/v3/get_margin_ad_ratio_request.go new file mode 100644 index 0000000..38f885b --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/get_margin_ad_ratio_request.go @@ -0,0 +1,26 @@ +package v3 + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST +//go:generate -command DeleteRequest requestgen -method DELETE + +type ADRatio struct { + AdRatio fixedpoint.Value `json:"ad_ratio"` + AssetInUsdt fixedpoint.Value `json:"asset_in_usdt"` + DebtInUsdt fixedpoint.Value `json:"debt_in_usdt"` +} + +//go:generate GetRequest -url "/api/v3/wallet/m/ad_ratio" -type GetMarginADRatioRequest -responseType .ADRatio +type GetMarginADRatioRequest struct { + client requestgen.AuthenticatedAPIClient +} + +func (s *Client) NewGetMarginADRatioRequest() *GetMarginADRatioRequest { + return &GetMarginADRatioRequest{client: s.Client} +} diff --git a/pkg/exchange/max/maxapi/v3/get_margin_ad_ratio_request_requestgen.go b/pkg/exchange/max/maxapi/v3/get_margin_ad_ratio_request_requestgen.go new file mode 100644 index 0000000..cf54325 --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/get_margin_ad_ratio_request_requestgen.go @@ -0,0 +1,135 @@ +// Code generated by "requestgen -method GET -url /api/v3/wallet/m/ad_ratio -type GetMarginADRatioRequest -responseType .ADRatio"; DO NOT EDIT. + +package v3 + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetMarginADRatioRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetMarginADRatioRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetMarginADRatioRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetMarginADRatioRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetMarginADRatioRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetMarginADRatioRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetMarginADRatioRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetMarginADRatioRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetMarginADRatioRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetMarginADRatioRequest) Do(ctx context.Context) (*ADRatio, error) { + + // no body params + var params interface{} + query := url.Values{} + + apiURL := "/api/v3/wallet/m/ad_ratio" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse ADRatio + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return &apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/v3/get_margin_borrowing_limits_request_requestgen.go b/pkg/exchange/max/maxapi/v3/get_margin_borrowing_limits_request_requestgen.go new file mode 100644 index 0000000..4c631ea --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/get_margin_borrowing_limits_request_requestgen.go @@ -0,0 +1,135 @@ +// Code generated by "requestgen -method GET -url /api/v3/wallet/m/limits -type GetMarginBorrowingLimitsRequest -responseType .MarginBorrowingLimitMap"; DO NOT EDIT. + +package v3 + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetMarginBorrowingLimitsRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetMarginBorrowingLimitsRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetMarginBorrowingLimitsRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetMarginBorrowingLimitsRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetMarginBorrowingLimitsRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetMarginBorrowingLimitsRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetMarginBorrowingLimitsRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetMarginBorrowingLimitsRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetMarginBorrowingLimitsRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetMarginBorrowingLimitsRequest) Do(ctx context.Context) (*MarginBorrowingLimitMap, error) { + + // no body params + var params interface{} + query := url.Values{} + + apiURL := "/api/v3/wallet/m/limits" + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse MarginBorrowingLimitMap + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return &apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/v3/get_margin_interest_history_request.go b/pkg/exchange/max/maxapi/v3/get_margin_interest_history_request.go new file mode 100644 index 0000000..ede56f7 --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/get_margin_interest_history_request.go @@ -0,0 +1,35 @@ +package v3 + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST +//go:generate -command DeleteRequest requestgen -method DELETE + +import ( + "time" + + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type MarginInterestRecord struct { + Currency string `json:"currency"` + Amount fixedpoint.Value `json:"amount"` + InterestRate fixedpoint.Value `json:"interest_rate"` + CreatedAt types.MillisecondTimestamp `json:"created_at"` +} + +//go:generate GetRequest -url "/api/v3/wallet/m/interests" -type GetMarginInterestHistoryRequest -responseType []MarginInterestRecord +type GetMarginInterestHistoryRequest struct { + client requestgen.AuthenticatedAPIClient + + currency string `param:"currency,required"` + startTime *time.Time `param:"startTime,milliseconds"` + endTime *time.Time `param:"endTime,milliseconds"` + limit *int `param:"limit"` +} + +func (s *Client) NewGetMarginInterestHistoryRequest(currency string) *GetMarginInterestHistoryRequest { + return &GetMarginInterestHistoryRequest{client: s.Client, currency: currency} +} diff --git a/pkg/exchange/max/maxapi/v3/get_margin_interest_history_request_requestgen.go b/pkg/exchange/max/maxapi/v3/get_margin_interest_history_request_requestgen.go new file mode 100644 index 0000000..8f9ad40 --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/get_margin_interest_history_request_requestgen.go @@ -0,0 +1,197 @@ +// Code generated by "requestgen -method GET -url /api/v3/wallet/m/interests -type GetMarginInterestHistoryRequest -responseType []MarginInterestRecord"; DO NOT EDIT. + +package v3 + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetMarginInterestHistoryRequest) Currency(currency string) *GetMarginInterestHistoryRequest { + g.currency = currency + return g +} + +func (g *GetMarginInterestHistoryRequest) StartTime(startTime time.Time) *GetMarginInterestHistoryRequest { + g.startTime = &startTime + return g +} + +func (g *GetMarginInterestHistoryRequest) EndTime(endTime time.Time) *GetMarginInterestHistoryRequest { + g.endTime = &endTime + return g +} + +func (g *GetMarginInterestHistoryRequest) Limit(limit int) *GetMarginInterestHistoryRequest { + g.limit = &limit + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetMarginInterestHistoryRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetMarginInterestHistoryRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check currency field -> json key currency + currency := g.currency + + // TEMPLATE check-required + if len(currency) == 0 { + return nil, fmt.Errorf("currency is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of currency + params["currency"] = currency + // check startTime field -> json key startTime + if g.startTime != nil { + startTime := *g.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["startTime"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key endTime + if g.endTime != nil { + endTime := *g.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["endTime"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetMarginInterestHistoryRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetMarginInterestHistoryRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetMarginInterestHistoryRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetMarginInterestHistoryRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetMarginInterestHistoryRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetMarginInterestHistoryRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetMarginInterestHistoryRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetMarginInterestHistoryRequest) Do(ctx context.Context) ([]MarginInterestRecord, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/api/v3/wallet/m/interests" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []MarginInterestRecord + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/v3/get_margin_interest_rates_request.go b/pkg/exchange/max/maxapi/v3/get_margin_interest_rates_request.go new file mode 100644 index 0000000..0a03092 --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/get_margin_interest_rates_request.go @@ -0,0 +1,27 @@ +package v3 + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST +//go:generate -command DeleteRequest requestgen -method DELETE + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +type MarginInterestRate struct { + HourlyInterestRate fixedpoint.Value `json:"hourly_interest_rate"` + NextHourlyInterestRate fixedpoint.Value `json:"next_hourly_interest_rate"` +} + +type MarginInterestRateMap map[string]MarginInterestRate + +//go:generate GetRequest -url "/api/v3/wallet/m/interest_rates" -type GetMarginInterestRatesRequest -responseType .MarginInterestRateMap +type GetMarginInterestRatesRequest struct { + client requestgen.APIClient +} + +func (s *Client) NewGetMarginInterestRatesRequest() *GetMarginInterestRatesRequest { + return &GetMarginInterestRatesRequest{client: s.Client} +} diff --git a/pkg/exchange/max/maxapi/v3/get_margin_interest_rates_request_requestgen.go b/pkg/exchange/max/maxapi/v3/get_margin_interest_rates_request_requestgen.go new file mode 100644 index 0000000..f6c3bf8 --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/get_margin_interest_rates_request_requestgen.go @@ -0,0 +1,135 @@ +// Code generated by "requestgen -method GET -url /api/v3/wallet/m/interest_rates -type GetMarginInterestRatesRequest -responseType .MarginInterestRateMap"; DO NOT EDIT. + +package v3 + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetMarginInterestRatesRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetMarginInterestRatesRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetMarginInterestRatesRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetMarginInterestRatesRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetMarginInterestRatesRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetMarginInterestRatesRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetMarginInterestRatesRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetMarginInterestRatesRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetMarginInterestRatesRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetMarginInterestRatesRequest) Do(ctx context.Context) (MarginInterestRateMap, error) { + + // no body params + var params interface{} + query := url.Values{} + + apiURL := "/api/v3/wallet/m/interest_rates" + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse MarginInterestRateMap + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/v3/get_margin_liquidation_history_request.go b/pkg/exchange/max/maxapi/v3/get_margin_liquidation_history_request.go new file mode 100644 index 0000000..2e800b6 --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/get_margin_liquidation_history_request.go @@ -0,0 +1,38 @@ +package v3 + +import ( + "time" + + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func (s *Client) NewGetMarginLiquidationHistoryRequest() *GetMarginLiquidationHistoryRequest { + return &GetMarginLiquidationHistoryRequest{client: s.Client} +} + +type LiquidationRecord struct { + SN string `json:"sn"` + AdRatio fixedpoint.Value `json:"ad_ratio"` + ExpectedAdRatio fixedpoint.Value `json:"expected_ad_ratio"` + CreatedAt types.MillisecondTimestamp `json:"created_at"` + State LiquidationState `json:"state"` +} + +type LiquidationState string + +const ( + LiquidationStateProcessing LiquidationState = "processing" + LiquidationStateDebt LiquidationState = "debt" + LiquidationStateLiquidated LiquidationState = "liquidated" +) + +//go:generate GetRequest -url "/api/v3/wallet/m/liquidations" -type GetMarginLiquidationHistoryRequest -responseType []LiquidationRecord +type GetMarginLiquidationHistoryRequest struct { + client requestgen.AuthenticatedAPIClient + startTime *time.Time `param:"startTime,milliseconds"` + endTime *time.Time `param:"endTime,milliseconds"` + limit *int `param:"limit"` +} diff --git a/pkg/exchange/max/maxapi/v3/get_margin_liquidation_history_request_requestgen.go b/pkg/exchange/max/maxapi/v3/get_margin_liquidation_history_request_requestgen.go new file mode 100644 index 0000000..257b8c8 --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/get_margin_liquidation_history_request_requestgen.go @@ -0,0 +1,181 @@ +// Code generated by "requestgen -method GET -url /api/v3/wallet/m/liquidations -type GetMarginLiquidationHistoryRequest -responseType []LiquidationRecord"; DO NOT EDIT. + +package v3 + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetMarginLiquidationHistoryRequest) StartTime(startTime time.Time) *GetMarginLiquidationHistoryRequest { + g.startTime = &startTime + return g +} + +func (g *GetMarginLiquidationHistoryRequest) EndTime(endTime time.Time) *GetMarginLiquidationHistoryRequest { + g.endTime = &endTime + return g +} + +func (g *GetMarginLiquidationHistoryRequest) Limit(limit int) *GetMarginLiquidationHistoryRequest { + g.limit = &limit + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetMarginLiquidationHistoryRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetMarginLiquidationHistoryRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check startTime field -> json key startTime + if g.startTime != nil { + startTime := *g.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["startTime"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key endTime + if g.endTime != nil { + endTime := *g.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["endTime"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetMarginLiquidationHistoryRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetMarginLiquidationHistoryRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetMarginLiquidationHistoryRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetMarginLiquidationHistoryRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetMarginLiquidationHistoryRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetMarginLiquidationHistoryRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetMarginLiquidationHistoryRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetMarginLiquidationHistoryRequest) Do(ctx context.Context) ([]LiquidationRecord, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/api/v3/wallet/m/liquidations" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []LiquidationRecord + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/v3/get_margin_loan_history_request.go b/pkg/exchange/max/maxapi/v3/get_margin_loan_history_request.go new file mode 100644 index 0000000..56ace3c --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/get_margin_loan_history_request.go @@ -0,0 +1,38 @@ +package v3 + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST +//go:generate -command DeleteRequest requestgen -method DELETE + +import ( + "time" + + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type LoanRecord struct { + SN string `json:"sn"` + Currency string `json:"currency"` + Amount fixedpoint.Value `json:"amount"` + State string `json:"state"` + CreatedAt types.MillisecondTimestamp `json:"created_at"` + InterestRate fixedpoint.Value `json:"interest_rate"` +} + +//go:generate GetRequest -url "/api/v3/wallet/m/loans" -type GetMarginLoanHistoryRequest -responseType []LoanRecord +type GetMarginLoanHistoryRequest struct { + client requestgen.AuthenticatedAPIClient + + currency string `param:"currency,required"` + + startTime *time.Time `param:"startTime,milliseconds"` + endTime *time.Time `param:"endTime,milliseconds"` + limit *int `param:"limit"` +} + +func (s *Client) NewGetMarginLoanHistoryRequest() *GetMarginLoanHistoryRequest { + return &GetMarginLoanHistoryRequest{client: s.Client} +} diff --git a/pkg/exchange/max/maxapi/v3/get_margin_loan_history_request_requestgen.go b/pkg/exchange/max/maxapi/v3/get_margin_loan_history_request_requestgen.go new file mode 100644 index 0000000..b4efb53 --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/get_margin_loan_history_request_requestgen.go @@ -0,0 +1,197 @@ +// Code generated by "requestgen -method GET -url /api/v3/wallet/m/loans -type GetMarginLoanHistoryRequest -responseType []LoanRecord"; DO NOT EDIT. + +package v3 + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetMarginLoanHistoryRequest) Currency(currency string) *GetMarginLoanHistoryRequest { + g.currency = currency + return g +} + +func (g *GetMarginLoanHistoryRequest) StartTime(startTime time.Time) *GetMarginLoanHistoryRequest { + g.startTime = &startTime + return g +} + +func (g *GetMarginLoanHistoryRequest) EndTime(endTime time.Time) *GetMarginLoanHistoryRequest { + g.endTime = &endTime + return g +} + +func (g *GetMarginLoanHistoryRequest) Limit(limit int) *GetMarginLoanHistoryRequest { + g.limit = &limit + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetMarginLoanHistoryRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetMarginLoanHistoryRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check currency field -> json key currency + currency := g.currency + + // TEMPLATE check-required + if len(currency) == 0 { + return nil, fmt.Errorf("currency is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of currency + params["currency"] = currency + // check startTime field -> json key startTime + if g.startTime != nil { + startTime := *g.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["startTime"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key endTime + if g.endTime != nil { + endTime := *g.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["endTime"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetMarginLoanHistoryRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetMarginLoanHistoryRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetMarginLoanHistoryRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetMarginLoanHistoryRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetMarginLoanHistoryRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetMarginLoanHistoryRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetMarginLoanHistoryRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetMarginLoanHistoryRequest) Do(ctx context.Context) ([]LoanRecord, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/api/v3/wallet/m/loans" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []LoanRecord + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/v3/get_margin_repayment_history_request.go b/pkg/exchange/max/maxapi/v3/get_margin_repayment_history_request.go new file mode 100644 index 0000000..20133be --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/get_margin_repayment_history_request.go @@ -0,0 +1,34 @@ +package v3 + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST +//go:generate -command DeleteRequest requestgen -method DELETE + +import ( + "time" + + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type RepaymentRecord struct { + SN string `json:"sn"` + Currency string `json:"currency"` + Amount fixedpoint.Value `json:"amount"` + Principal fixedpoint.Value `json:"principal"` + Interest fixedpoint.Value `json:"interest"` + CreatedAt types.MillisecondTimestamp `json:"created_at"` + State string `json:"state"` +} + +//go:generate GetRequest -url "/api/v3/wallet/m/repayments" -type GetMarginRepaymentHistoryRequest -responseType []RepaymentRecord +type GetMarginRepaymentHistoryRequest struct { + client requestgen.AuthenticatedAPIClient + + currency string `param:"currency,required"` + startTime *time.Time `param:"startTime,milliseconds"` + endTime *time.Time `param:"endTime,milliseconds"` + limit *int `param:"limit"` +} diff --git a/pkg/exchange/max/maxapi/v3/get_margin_repayment_history_request_requestgen.go b/pkg/exchange/max/maxapi/v3/get_margin_repayment_history_request_requestgen.go new file mode 100644 index 0000000..ce441cf --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/get_margin_repayment_history_request_requestgen.go @@ -0,0 +1,197 @@ +// Code generated by "requestgen -method GET -url /api/v3/wallet/m/repayments -type GetMarginRepaymentHistoryRequest -responseType []RepaymentRecord"; DO NOT EDIT. + +package v3 + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetMarginRepaymentHistoryRequest) Currency(currency string) *GetMarginRepaymentHistoryRequest { + g.currency = currency + return g +} + +func (g *GetMarginRepaymentHistoryRequest) StartTime(startTime time.Time) *GetMarginRepaymentHistoryRequest { + g.startTime = &startTime + return g +} + +func (g *GetMarginRepaymentHistoryRequest) EndTime(endTime time.Time) *GetMarginRepaymentHistoryRequest { + g.endTime = &endTime + return g +} + +func (g *GetMarginRepaymentHistoryRequest) Limit(limit int) *GetMarginRepaymentHistoryRequest { + g.limit = &limit + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetMarginRepaymentHistoryRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetMarginRepaymentHistoryRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check currency field -> json key currency + currency := g.currency + + // TEMPLATE check-required + if len(currency) == 0 { + return nil, fmt.Errorf("currency is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of currency + params["currency"] = currency + // check startTime field -> json key startTime + if g.startTime != nil { + startTime := *g.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["startTime"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key endTime + if g.endTime != nil { + endTime := *g.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["endTime"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetMarginRepaymentHistoryRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetMarginRepaymentHistoryRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetMarginRepaymentHistoryRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetMarginRepaymentHistoryRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetMarginRepaymentHistoryRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetMarginRepaymentHistoryRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetMarginRepaymentHistoryRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetMarginRepaymentHistoryRequest) Do(ctx context.Context) ([]RepaymentRecord, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/api/v3/wallet/m/repayments" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []RepaymentRecord + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/v3/get_order_request.go b/pkg/exchange/max/maxapi/v3/get_order_request.go new file mode 100644 index 0000000..ca90c34 --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/get_order_request.go @@ -0,0 +1,19 @@ +package v3 + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST +//go:generate -command DeleteRequest requestgen -method DELETE + +import "github.com/c9s/requestgen" + +func (s *Client) NewGetOrderRequest() *GetOrderRequest { + return &GetOrderRequest{client: s.Client} +} + +//go:generate GetRequest -url "/api/v3/order" -type GetOrderRequest -responseType .Order +type GetOrderRequest struct { + client requestgen.AuthenticatedAPIClient + + id *uint64 `param:"id,omitempty"` + clientOrderID *string `param:"client_oid,omitempty"` +} diff --git a/pkg/exchange/max/maxapi/v3/get_order_request_requestgen.go b/pkg/exchange/max/maxapi/v3/get_order_request_requestgen.go new file mode 100644 index 0000000..ff6eb6a --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/get_order_request_requestgen.go @@ -0,0 +1,165 @@ +// Code generated by "requestgen -method GET -url /api/v3/order -type GetOrderRequest -responseType .Order"; DO NOT EDIT. + +package v3 + +import ( + "context" + "encoding/json" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/max/maxapi" + "net/url" + "reflect" + "regexp" +) + +func (g *GetOrderRequest) Id(id uint64) *GetOrderRequest { + g.id = &id + return g +} + +func (g *GetOrderRequest) ClientOrderID(clientOrderID string) *GetOrderRequest { + g.clientOrderID = &clientOrderID + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetOrderRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetOrderRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check id field -> json key id + if g.id != nil { + id := *g.id + + // assign parameter of id + params["id"] = id + } else { + } + // check clientOrderID field -> json key client_oid + if g.clientOrderID != nil { + clientOrderID := *g.clientOrderID + + // assign parameter of clientOrderID + params["client_oid"] = clientOrderID + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetOrderRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetOrderRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetOrderRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetOrderRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetOrderRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetOrderRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetOrderRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetOrderRequest) Do(ctx context.Context) (*max.Order, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/api/v3/order" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse max.Order + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return &apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/v3/get_order_trades_request.go b/pkg/exchange/max/maxapi/v3/get_order_trades_request.go new file mode 100644 index 0000000..a734dae --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/get_order_trades_request.go @@ -0,0 +1,19 @@ +package v3 + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST +//go:generate -command DeleteRequest requestgen -method DELETE + +import "github.com/c9s/requestgen" + +func (s *Client) NewGetOrderTradesRequest() *GetOrderTradesRequest { + return &GetOrderTradesRequest{client: s.Client} +} + +//go:generate GetRequest -url "/api/v3/order/trades" -type GetOrderTradesRequest -responseType []Trade +type GetOrderTradesRequest struct { + client requestgen.AuthenticatedAPIClient + + orderID *uint64 `param:"order_id,omitempty"` + clientOrderID *string `param:"client_oid,omitempty"` +} diff --git a/pkg/exchange/max/maxapi/v3/get_order_trades_request_requestgen.go b/pkg/exchange/max/maxapi/v3/get_order_trades_request_requestgen.go new file mode 100644 index 0000000..e739f33 --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/get_order_trades_request_requestgen.go @@ -0,0 +1,164 @@ +// Code generated by "requestgen -method GET -url /api/v3/order/trades -type GetOrderTradesRequest -responseType []Trade"; DO NOT EDIT. + +package v3 + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (g *GetOrderTradesRequest) OrderID(orderID uint64) *GetOrderTradesRequest { + g.orderID = &orderID + return g +} + +func (g *GetOrderTradesRequest) ClientOrderID(clientOrderID string) *GetOrderTradesRequest { + g.clientOrderID = &clientOrderID + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetOrderTradesRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetOrderTradesRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check orderID field -> json key order_id + if g.orderID != nil { + orderID := *g.orderID + + // assign parameter of orderID + params["order_id"] = orderID + } else { + } + // check clientOrderID field -> json key client_oid + if g.clientOrderID != nil { + clientOrderID := *g.clientOrderID + + // assign parameter of clientOrderID + params["client_oid"] = clientOrderID + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetOrderTradesRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetOrderTradesRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetOrderTradesRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetOrderTradesRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetOrderTradesRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetOrderTradesRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetOrderTradesRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetOrderTradesRequest) Do(ctx context.Context) ([]Trade, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/api/v3/order/trades" + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []Trade + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/v3/get_wallet_accounts_request.go b/pkg/exchange/max/maxapi/v3/get_wallet_accounts_request.go new file mode 100644 index 0000000..0b5b951 --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/get_wallet_accounts_request.go @@ -0,0 +1,19 @@ +package v3 + +import "github.com/c9s/requestgen" + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST +//go:generate -command DeleteRequest requestgen -method DELETE + +//go:generate GetRequest -url "/api/v3/wallet/:walletType/accounts" -type GetWalletAccountsRequest -responseType []Account +type GetWalletAccountsRequest struct { + client requestgen.AuthenticatedAPIClient + + walletType WalletType `param:"walletType,slug,required"` + currency *string `param:"currency,query"` +} + +func (s *Client) NewGetWalletAccountsRequest(walletType WalletType) *GetWalletAccountsRequest { + return &GetWalletAccountsRequest{client: s.Client, walletType: walletType} +} diff --git a/pkg/exchange/max/maxapi/v3/get_wallet_accounts_request_requestgen.go b/pkg/exchange/max/maxapi/v3/get_wallet_accounts_request_requestgen.go new file mode 100644 index 0000000..a67fcf8 --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/get_wallet_accounts_request_requestgen.go @@ -0,0 +1,174 @@ +// Code generated by "requestgen -method GET -url /api/v3/wallet/:walletType/accounts -type GetWalletAccountsRequest -responseType []Account"; DO NOT EDIT. + +package v3 + +import ( + "context" + "encoding/json" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/max/maxapi" + "net/url" + "reflect" + "regexp" +) + +func (g *GetWalletAccountsRequest) Currency(currency string) *GetWalletAccountsRequest { + g.currency = ¤cy + return g +} + +func (g *GetWalletAccountsRequest) WalletType(walletType max.WalletType) *GetWalletAccountsRequest { + g.walletType = walletType + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetWalletAccountsRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check currency field -> json key currency + if g.currency != nil { + currency := *g.currency + + // assign parameter of currency + params["currency"] = currency + } else { + } + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetWalletAccountsRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetWalletAccountsRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetWalletAccountsRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetWalletAccountsRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check walletType field -> json key walletType + walletType := g.walletType + + // TEMPLATE check-required + if len(walletType) == 0 { + return nil, fmt.Errorf("walletType is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of walletType + params["walletType"] = walletType + + return params, nil +} + +func (g *GetWalletAccountsRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetWalletAccountsRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetWalletAccountsRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetWalletAccountsRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetWalletAccountsRequest) Do(ctx context.Context) ([]max.Account, error) { + + // no body params + var params interface{} + query, err := g.GetQueryParameters() + if err != nil { + return nil, err + } + + apiURL := "/api/v3/wallet/:walletType/accounts" + slugs, err := g.GetSlugsMap() + if err != nil { + return nil, err + } + + apiURL = g.applySlugsToUrl(apiURL, slugs) + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []max.Account + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/v3/get_wallet_closed_orders_request.go b/pkg/exchange/max/maxapi/v3/get_wallet_closed_orders_request.go new file mode 100644 index 0000000..9ac43ea --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/get_wallet_closed_orders_request.go @@ -0,0 +1,27 @@ +package v3 + +import ( + "time" + + "github.com/c9s/requestgen" +) + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST +//go:generate -command DeleteRequest requestgen -method DELETE + +func (s *Client) NewGetWalletClosedOrdersRequest(walletType WalletType) *GetWalletClosedOrdersRequest { + return &GetWalletClosedOrdersRequest{client: s.Client, walletType: walletType} +} + +//go:generate GetRequest -url "/api/v3/wallet/:walletType/orders/closed" -type GetWalletClosedOrdersRequest -responseType []Order +type GetWalletClosedOrdersRequest struct { + client requestgen.AuthenticatedAPIClient + + walletType WalletType `param:"walletType,slug,required"` + + market string `param:"market,required"` + timestamp *time.Time `param:"timestamp,milliseconds,omitempty"` + orderBy *OrderByType `param:"order_by,omitempty"` + limit *uint `param:"limit,omitempty"` +} diff --git a/pkg/exchange/max/maxapi/v3/get_wallet_closed_orders_request_requestgen.go b/pkg/exchange/max/maxapi/v3/get_wallet_closed_orders_request_requestgen.go new file mode 100644 index 0000000..83cb8ac --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/get_wallet_closed_orders_request_requestgen.go @@ -0,0 +1,219 @@ +// Code generated by "requestgen --method GET -url /api/v3/wallet/:walletType/orders/closed -type GetWalletClosedOrdersRequest -responseType []Order"; DO NOT EDIT. + +package v3 + +import ( + "context" + "encoding/json" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/max/maxapi" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetWalletClosedOrdersRequest) Market(market string) *GetWalletClosedOrdersRequest { + g.market = market + return g +} + +func (g *GetWalletClosedOrdersRequest) Timestamp(timestamp time.Time) *GetWalletClosedOrdersRequest { + g.timestamp = ×tamp + return g +} + +func (g *GetWalletClosedOrdersRequest) OrderBy(orderBy max.OrderByType) *GetWalletClosedOrdersRequest { + g.orderBy = &orderBy + return g +} + +func (g *GetWalletClosedOrdersRequest) Limit(limit uint) *GetWalletClosedOrdersRequest { + g.limit = &limit + return g +} + +func (g *GetWalletClosedOrdersRequest) WalletType(walletType max.WalletType) *GetWalletClosedOrdersRequest { + g.walletType = walletType + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetWalletClosedOrdersRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetWalletClosedOrdersRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check market field -> json key market + market := g.market + + // TEMPLATE check-required + if len(market) == 0 { + return nil, fmt.Errorf("market is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of market + params["market"] = market + // check timestamp field -> json key timestamp + if g.timestamp != nil { + timestamp := *g.timestamp + + // assign parameter of timestamp + // convert time.Time to milliseconds time stamp + params["timestamp"] = strconv.FormatInt(timestamp.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check orderBy field -> json key order_by + if g.orderBy != nil { + orderBy := *g.orderBy + + // assign parameter of orderBy + params["order_by"] = orderBy + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetWalletClosedOrdersRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetWalletClosedOrdersRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetWalletClosedOrdersRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check walletType field -> json key walletType + walletType := g.walletType + + // TEMPLATE check-required + if len(walletType) == 0 { + return nil, fmt.Errorf("walletType is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of walletType + params["walletType"] = walletType + + return params, nil +} + +func (g *GetWalletClosedOrdersRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetWalletClosedOrdersRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetWalletClosedOrdersRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetWalletClosedOrdersRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetWalletClosedOrdersRequest) Do(ctx context.Context) ([]max.Order, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/api/v3/wallet/:walletType/orders/closed" + slugs, err := g.GetSlugsMap() + if err != nil { + return nil, err + } + + apiURL = g.applySlugsToUrl(apiURL, slugs) + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []max.Order + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/v3/get_wallet_open_orders_request.go b/pkg/exchange/max/maxapi/v3/get_wallet_open_orders_request.go new file mode 100644 index 0000000..15afdb0 --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/get_wallet_open_orders_request.go @@ -0,0 +1,26 @@ +package v3 + +import ( + "time" + + "github.com/c9s/requestgen" +) + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST +//go:generate -command DeleteRequest requestgen -method DELETE + +func (s *Client) NewGetWalletOpenOrdersRequest(walletType WalletType) *GetWalletOpenOrdersRequest { + return &GetWalletOpenOrdersRequest{client: s.Client, walletType: walletType} +} + +//go:generate GetRequest -url "/api/v3/wallet/:walletType/orders/new/open" -type GetWalletOpenOrdersRequest -responseType []Order +type GetWalletOpenOrdersRequest struct { + client requestgen.AuthenticatedAPIClient + + walletType WalletType `param:"walletType,slug,required"` + market string `param:"market,required"` + timestamp *time.Time `param:"timestamp,milliseconds,omitempty"` + orderBy *OrderByType `param:"order_by,omitempty"` + limit *uint `param:"limit,omitempty"` +} diff --git a/pkg/exchange/max/maxapi/v3/get_wallet_open_orders_request_requestgen.go b/pkg/exchange/max/maxapi/v3/get_wallet_open_orders_request_requestgen.go new file mode 100644 index 0000000..c9430ef --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/get_wallet_open_orders_request_requestgen.go @@ -0,0 +1,219 @@ +// Code generated by "requestgen -method GET -url /api/v3/wallet/:walletType/orders/new/open -type GetWalletOpenOrdersRequest -responseType []Order"; DO NOT EDIT. + +package v3 + +import ( + "context" + "encoding/json" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/max/maxapi" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetWalletOpenOrdersRequest) Market(market string) *GetWalletOpenOrdersRequest { + g.market = market + return g +} + +func (g *GetWalletOpenOrdersRequest) Timestamp(timestamp time.Time) *GetWalletOpenOrdersRequest { + g.timestamp = ×tamp + return g +} + +func (g *GetWalletOpenOrdersRequest) OrderBy(orderBy max.OrderByType) *GetWalletOpenOrdersRequest { + g.orderBy = &orderBy + return g +} + +func (g *GetWalletOpenOrdersRequest) Limit(limit uint) *GetWalletOpenOrdersRequest { + g.limit = &limit + return g +} + +func (g *GetWalletOpenOrdersRequest) WalletType(walletType max.WalletType) *GetWalletOpenOrdersRequest { + g.walletType = walletType + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetWalletOpenOrdersRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetWalletOpenOrdersRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check market field -> json key market + market := g.market + + // TEMPLATE check-required + if len(market) == 0 { + return nil, fmt.Errorf("market is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of market + params["market"] = market + // check timestamp field -> json key timestamp + if g.timestamp != nil { + timestamp := *g.timestamp + + // assign parameter of timestamp + // convert time.Time to milliseconds time stamp + params["timestamp"] = strconv.FormatInt(timestamp.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check orderBy field -> json key order_by + if g.orderBy != nil { + orderBy := *g.orderBy + + // assign parameter of orderBy + params["order_by"] = orderBy + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetWalletOpenOrdersRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetWalletOpenOrdersRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetWalletOpenOrdersRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check walletType field -> json key walletType + walletType := g.walletType + + // TEMPLATE check-required + if len(walletType) == 0 { + return nil, fmt.Errorf("walletType is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of walletType + params["walletType"] = walletType + + return params, nil +} + +func (g *GetWalletOpenOrdersRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetWalletOpenOrdersRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetWalletOpenOrdersRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetWalletOpenOrdersRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetWalletOpenOrdersRequest) Do(ctx context.Context) ([]max.Order, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/api/v3/wallet/:walletType/orders/new/open" + slugs, err := g.GetSlugsMap() + if err != nil { + return nil, err + } + + apiURL = g.applySlugsToUrl(apiURL, slugs) + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []max.Order + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/v3/get_wallet_order_history_request.go b/pkg/exchange/max/maxapi/v3/get_wallet_order_history_request.go new file mode 100644 index 0000000..fe8b538 --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/get_wallet_order_history_request.go @@ -0,0 +1,22 @@ +package v3 + +import "github.com/c9s/requestgen" + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST +//go:generate -command DeleteRequest requestgen -method DELETE + +func (s *Client) NewGetWalletOrderHistoryRequest(walletType WalletType) *GetWalletOrderHistoryRequest { + return &GetWalletOrderHistoryRequest{client: s.Client, walletType: walletType} +} + +//go:generate GetRequest -url "/api/v3/wallet/:walletType/orders/history" -type GetWalletOrderHistoryRequest -responseType []Order +type GetWalletOrderHistoryRequest struct { + client requestgen.AuthenticatedAPIClient + + walletType WalletType `param:"walletType,slug,required"` + + market string `param:"market,required"` + fromID *uint64 `param:"from_id"` + limit *uint `param:"limit"` +} diff --git a/pkg/exchange/max/maxapi/v3/get_wallet_order_history_request_requestgen.go b/pkg/exchange/max/maxapi/v3/get_wallet_order_history_request_requestgen.go new file mode 100644 index 0000000..c0b1bb7 --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/get_wallet_order_history_request_requestgen.go @@ -0,0 +1,203 @@ +// Code generated by "requestgen -method GET -url /api/v3/wallet/:walletType/orders/history -type GetWalletOrderHistoryRequest -responseType []Order"; DO NOT EDIT. + +package v3 + +import ( + "context" + "encoding/json" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/max/maxapi" + "net/url" + "reflect" + "regexp" +) + +func (g *GetWalletOrderHistoryRequest) Market(market string) *GetWalletOrderHistoryRequest { + g.market = market + return g +} + +func (g *GetWalletOrderHistoryRequest) FromID(fromID uint64) *GetWalletOrderHistoryRequest { + g.fromID = &fromID + return g +} + +func (g *GetWalletOrderHistoryRequest) Limit(limit uint) *GetWalletOrderHistoryRequest { + g.limit = &limit + return g +} + +func (g *GetWalletOrderHistoryRequest) WalletType(walletType max.WalletType) *GetWalletOrderHistoryRequest { + g.walletType = walletType + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetWalletOrderHistoryRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetWalletOrderHistoryRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check market field -> json key market + market := g.market + + // TEMPLATE check-required + if len(market) == 0 { + return nil, fmt.Errorf("market is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of market + params["market"] = market + // check fromID field -> json key from_id + if g.fromID != nil { + fromID := *g.fromID + + // assign parameter of fromID + params["from_id"] = fromID + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetWalletOrderHistoryRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetWalletOrderHistoryRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetWalletOrderHistoryRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check walletType field -> json key walletType + walletType := g.walletType + + // TEMPLATE check-required + if len(walletType) == 0 { + return nil, fmt.Errorf("walletType is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of walletType + params["walletType"] = walletType + + return params, nil +} + +func (g *GetWalletOrderHistoryRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetWalletOrderHistoryRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetWalletOrderHistoryRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetWalletOrderHistoryRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetWalletOrderHistoryRequest) Do(ctx context.Context) ([]max.Order, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/api/v3/wallet/:walletType/orders/history" + slugs, err := g.GetSlugsMap() + if err != nil { + return nil, err + } + + apiURL = g.applySlugsToUrl(apiURL, slugs) + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []max.Order + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/v3/get_wallet_trades_request.go b/pkg/exchange/max/maxapi/v3/get_wallet_trades_request.go new file mode 100644 index 0000000..46acf4d --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/get_wallet_trades_request.go @@ -0,0 +1,28 @@ +package v3 + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST +//go:generate -command DeleteRequest requestgen -method DELETE + +import ( + "time" + + "github.com/c9s/requestgen" +) + +func (s *Client) NewGetWalletTradesRequest(walletType WalletType) *GetWalletTradesRequest { + return &GetWalletTradesRequest{client: s.Client, walletType: walletType} +} + +//go:generate GetRequest -url "/api/v3/wallet/:walletType/trades" -type GetWalletTradesRequest -responseType []Trade +type GetWalletTradesRequest struct { + client requestgen.AuthenticatedAPIClient + + walletType WalletType `param:"walletType,slug,required"` + + market string `param:"market,required"` + from *uint64 `param:"from_id"` + startTime *time.Time `param:"start_time,milliseconds"` + endTime *time.Time `param:"end_time,milliseconds"` + limit *uint64 `param:"limit"` +} diff --git a/pkg/exchange/max/maxapi/v3/get_wallet_trades_request_requestgen.go b/pkg/exchange/max/maxapi/v3/get_wallet_trades_request_requestgen.go new file mode 100644 index 0000000..ec7614c --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/get_wallet_trades_request_requestgen.go @@ -0,0 +1,232 @@ +// Code generated by "requestgen -method GET -url /api/v3/wallet/:walletType/trades -type GetWalletTradesRequest -responseType []Trade"; DO NOT EDIT. + +package v3 + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetWalletTradesRequest) Market(market string) *GetWalletTradesRequest { + g.market = market + return g +} + +func (g *GetWalletTradesRequest) From(from uint64) *GetWalletTradesRequest { + g.from = &from + return g +} + +func (g *GetWalletTradesRequest) StartTime(startTime time.Time) *GetWalletTradesRequest { + g.startTime = &startTime + return g +} + +func (g *GetWalletTradesRequest) EndTime(endTime time.Time) *GetWalletTradesRequest { + g.endTime = &endTime + return g +} + +func (g *GetWalletTradesRequest) Limit(limit uint64) *GetWalletTradesRequest { + g.limit = &limit + return g +} + +func (g *GetWalletTradesRequest) WalletType(walletType WalletType) *GetWalletTradesRequest { + g.walletType = walletType + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetWalletTradesRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetWalletTradesRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check market field -> json key market + market := g.market + + // TEMPLATE check-required + if len(market) == 0 { + return nil, fmt.Errorf("market is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of market + params["market"] = market + // check from field -> json key from_id + if g.from != nil { + from := *g.from + + // assign parameter of from + params["from_id"] = from + } else { + } + // check startTime field -> json key start_time + if g.startTime != nil { + startTime := *g.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["start_time"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key end_time + if g.endTime != nil { + endTime := *g.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["end_time"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetWalletTradesRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetWalletTradesRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetWalletTradesRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check walletType field -> json key walletType + walletType := g.walletType + + // TEMPLATE check-required + if len(walletType) == 0 { + return nil, fmt.Errorf("walletType is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of walletType + params["walletType"] = walletType + + return params, nil +} + +func (g *GetWalletTradesRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetWalletTradesRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetWalletTradesRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetWalletTradesRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (g *GetWalletTradesRequest) Do(ctx context.Context) ([]Trade, error) { + + // empty params for GET operation + var params interface{} + query, err := g.GetParametersQuery() + if err != nil { + return nil, err + } + + apiURL := "/api/v3/wallet/:walletType/trades" + slugs, err := g.GetSlugsMap() + if err != nil { + return nil, err + } + + apiURL = g.applySlugsToUrl(apiURL, slugs) + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse []Trade + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/v3/margin.go b/pkg/exchange/max/maxapi/v3/margin.go new file mode 100644 index 0000000..4611835 --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/margin.go @@ -0,0 +1,27 @@ +package v3 + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST +//go:generate -command DeleteRequest requestgen -method DELETE + +import ( + "github.com/c9s/requestgen" + + maxapi "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/max/maxapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +type MarginService struct { + Client *maxapi.RestClient +} + +func (s *Client) NewGetMarginBorrowingLimitsRequest() *GetMarginBorrowingLimitsRequest { + return &GetMarginBorrowingLimitsRequest{client: s.Client} +} + +type MarginBorrowingLimitMap map[string]fixedpoint.Value + +//go:generate GetRequest -url "/api/v3/wallet/m/limits" -type GetMarginBorrowingLimitsRequest -responseType .MarginBorrowingLimitMap +type GetMarginBorrowingLimitsRequest struct { + client requestgen.APIClient +} diff --git a/pkg/exchange/max/maxapi/v3/margin_loan_request.go b/pkg/exchange/max/maxapi/v3/margin_loan_request.go new file mode 100644 index 0000000..6551309 --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/margin_loan_request.go @@ -0,0 +1,19 @@ +package v3 + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST +//go:generate -command DeleteRequest requestgen -method DELETE + +import "github.com/c9s/requestgen" + +func (s *Client) NewMarginLoanRequest() *MarginLoanRequest { + return &MarginLoanRequest{client: s.Client} +} + +//go:generate PostRequest -url "/api/v3/wallet/m/loan" -type MarginLoanRequest -responseType .LoanRecord +type MarginLoanRequest struct { + client requestgen.AuthenticatedAPIClient + + currency string `param:"currency,required"` + amount string `param:"amount"` +} diff --git a/pkg/exchange/max/maxapi/v3/margin_loan_request_requestgen.go b/pkg/exchange/max/maxapi/v3/margin_loan_request_requestgen.go new file mode 100644 index 0000000..a92a73c --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/margin_loan_request_requestgen.go @@ -0,0 +1,163 @@ +// Code generated by "requestgen -method POST -url /api/v3/wallet/m/loan -type MarginLoanRequest -responseType .LoanRecord"; DO NOT EDIT. + +package v3 + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (m *MarginLoanRequest) Currency(currency string) *MarginLoanRequest { + m.currency = currency + return m +} + +func (m *MarginLoanRequest) Amount(amount string) *MarginLoanRequest { + m.amount = amount + return m +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (m *MarginLoanRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (m *MarginLoanRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check currency field -> json key currency + currency := m.currency + + // TEMPLATE check-required + if len(currency) == 0 { + return nil, fmt.Errorf("currency is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of currency + params["currency"] = currency + // check amount field -> json key amount + amount := m.amount + + // assign parameter of amount + params["amount"] = amount + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (m *MarginLoanRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := m.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if m.isVarSlice(_v) { + m.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (m *MarginLoanRequest) GetParametersJSON() ([]byte, error) { + params, err := m.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (m *MarginLoanRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (m *MarginLoanRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (m *MarginLoanRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (m *MarginLoanRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (m *MarginLoanRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := m.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (m *MarginLoanRequest) Do(ctx context.Context) (*LoanRecord, error) { + + params, err := m.GetParameters() + if err != nil { + return nil, err + } + query := url.Values{} + + apiURL := "/api/v3/wallet/m/loan" + + req, err := m.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := m.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse LoanRecord + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return &apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/v3/margin_repay_request.go b/pkg/exchange/max/maxapi/v3/margin_repay_request.go new file mode 100644 index 0000000..8d6150f --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/margin_repay_request.go @@ -0,0 +1,19 @@ +package v3 + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST +//go:generate -command DeleteRequest requestgen -method DELETE + +import "github.com/c9s/requestgen" + +//go:generate PostRequest -url "/api/v3/wallet/m/repayment" -type MarginRepayRequest -responseType .RepaymentRecord +type MarginRepayRequest struct { + client requestgen.AuthenticatedAPIClient + + currency string `param:"currency,required"` + amount string `param:"amount"` +} + +func (s *Client) NewMarginRepayRequest() *MarginRepayRequest { + return &MarginRepayRequest{client: s.Client} +} diff --git a/pkg/exchange/max/maxapi/v3/margin_repay_request_requestgen.go b/pkg/exchange/max/maxapi/v3/margin_repay_request_requestgen.go new file mode 100644 index 0000000..0070e52 --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/margin_repay_request_requestgen.go @@ -0,0 +1,163 @@ +// Code generated by "requestgen -method POST -url /api/v3/wallet/m/repayment -type MarginRepayRequest -responseType .RepaymentRecord"; DO NOT EDIT. + +package v3 + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (m *MarginRepayRequest) Currency(currency string) *MarginRepayRequest { + m.currency = currency + return m +} + +func (m *MarginRepayRequest) Amount(amount string) *MarginRepayRequest { + m.amount = amount + return m +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (m *MarginRepayRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (m *MarginRepayRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check currency field -> json key currency + currency := m.currency + + // TEMPLATE check-required + if len(currency) == 0 { + return nil, fmt.Errorf("currency is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of currency + params["currency"] = currency + // check amount field -> json key amount + amount := m.amount + + // assign parameter of amount + params["amount"] = amount + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (m *MarginRepayRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := m.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if m.isVarSlice(_v) { + m.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (m *MarginRepayRequest) GetParametersJSON() ([]byte, error) { + params, err := m.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (m *MarginRepayRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (m *MarginRepayRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (m *MarginRepayRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (m *MarginRepayRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (m *MarginRepayRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := m.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (m *MarginRepayRequest) Do(ctx context.Context) (*RepaymentRecord, error) { + + params, err := m.GetParameters() + if err != nil { + return nil, err + } + query := url.Values{} + + apiURL := "/api/v3/wallet/m/repayment" + + req, err := m.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := m.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse RepaymentRecord + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return &apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/v3/margin_transfer_request.go b/pkg/exchange/max/maxapi/v3/margin_transfer_request.go new file mode 100644 index 0000000..f40fa37 --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/margin_transfer_request.go @@ -0,0 +1,41 @@ +package v3 + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST +//go:generate -command DeleteRequest requestgen -method DELETE + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func (s *MarginService) NewMarginTransferRequest() *MarginTransferRequest { + return &MarginTransferRequest{client: s.Client} +} + +type MarginTransferSide string + +const ( + MarginTransferSideIn MarginTransferSide = "in" + MarginTransferSideOut MarginTransferSide = "out" +) + +type MarginTransferResponse struct { + Sn string `json:"sn"` + Side MarginTransferSide `json:"side"` + Currency string `json:"currency"` + Amount fixedpoint.Value `json:"amount"` + CreatedAt types.MillisecondTimestamp `json:"created_at"` + State string `json:"state"` +} + +//go:generate PostRequest -url "/api/v3/wallet/m/transfer" -type MarginTransferRequest -responseType .MarginTransferResponse +type MarginTransferRequest struct { + client requestgen.AuthenticatedAPIClient + + currency string `param:"currency,required"` + amount string `param:"amount"` + side MarginTransferSide `param:"side"` +} diff --git a/pkg/exchange/max/maxapi/v3/margin_transfer_request_requestgen.go b/pkg/exchange/max/maxapi/v3/margin_transfer_request_requestgen.go new file mode 100644 index 0000000..da668af --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/margin_transfer_request_requestgen.go @@ -0,0 +1,184 @@ +// Code generated by "requestgen -method POST -url /api/v3/wallet/m/transfer -type MarginTransferRequest -responseType .MarginTransferResponse"; DO NOT EDIT. + +package v3 + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (m *MarginTransferRequest) Currency(currency string) *MarginTransferRequest { + m.currency = currency + return m +} + +func (m *MarginTransferRequest) Amount(amount string) *MarginTransferRequest { + m.amount = amount + return m +} + +func (m *MarginTransferRequest) Side(side MarginTransferSide) *MarginTransferRequest { + m.side = side + return m +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (m *MarginTransferRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (m *MarginTransferRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check currency field -> json key currency + currency := m.currency + + // TEMPLATE check-required + if len(currency) == 0 { + return nil, fmt.Errorf("currency is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of currency + params["currency"] = currency + // check amount field -> json key amount + amount := m.amount + + // assign parameter of amount + params["amount"] = amount + // check side field -> json key side + side := m.side + + // TEMPLATE check-valid-values + switch side { + case MarginTransferSideIn, MarginTransferSideOut: + params["side"] = side + + default: + return nil, fmt.Errorf("side value %v is invalid", side) + + } + // END TEMPLATE check-valid-values + + // assign parameter of side + params["side"] = side + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (m *MarginTransferRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := m.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if m.isVarSlice(_v) { + m.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (m *MarginTransferRequest) GetParametersJSON() ([]byte, error) { + params, err := m.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (m *MarginTransferRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (m *MarginTransferRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (m *MarginTransferRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (m *MarginTransferRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (m *MarginTransferRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := m.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (m *MarginTransferRequest) Do(ctx context.Context) (*MarginTransferResponse, error) { + + params, err := m.GetParameters() + if err != nil { + return nil, err + } + query := url.Values{} + + apiURL := "/api/v3/wallet/m/transfer" + + req, err := m.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := m.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse MarginTransferResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return &apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/v3/order.go b/pkg/exchange/max/maxapi/v3/order.go new file mode 100644 index 0000000..e660348 --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/order.go @@ -0,0 +1,19 @@ +package v3 + +import ( + "github.com/c9s/requestgen" + + maxapi "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/max/maxapi" +) + +// create type alias +type WalletType = maxapi.WalletType +type OrderByType = maxapi.OrderByType +type OrderType = maxapi.OrderType + +type Order = maxapi.Order +type Account = maxapi.Account + +type Client struct { + Client requestgen.AuthenticatedAPIClient +} diff --git a/pkg/exchange/max/maxapi/v3/trade.go b/pkg/exchange/max/maxapi/v3/trade.go new file mode 100644 index 0000000..01caa8a --- /dev/null +++ b/pkg/exchange/max/maxapi/v3/trade.go @@ -0,0 +1,42 @@ +package v3 + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type Liquidity string + +const ( + LiquidityMaker = "maker" + LiquidityTaker = "taker" +) + +type Trade struct { + ID uint64 `json:"id" db:"exchange_id"` + WalletType WalletType `json:"wallet_type,omitempty"` + Price fixedpoint.Value `json:"price"` + Volume fixedpoint.Value `json:"volume"` + Funds fixedpoint.Value `json:"funds"` + Market string `json:"market"` + MarketName string `json:"market_name"` + CreatedAt types.MillisecondTimestamp `json:"created_at"` + Side string `json:"side"` + OrderID uint64 `json:"order_id"` + Fee *fixedpoint.Value `json:"fee"` // float number in string, could be optional + FeeCurrency string `json:"fee_currency"` + FeeDiscounted bool `json:"fee_discounted"` + Liquidity Liquidity `json:"liquidity"` + SelfTradeBidFee fixedpoint.Value `json:"self_trade_bid_fee"` + SelfTradeBidFeeCurrency string `json:"self_trade_bid_fee_currency"` + SelfTradeBidFeeDiscounted bool `json:"self_trade_bid_fee_discounted"` + SelfTradeBidOrderID uint64 `json:"self_trade_bid_order_id"` +} + +func (t Trade) IsBuyer() bool { + return t.Side == "bid" +} + +func (t Trade) IsMaker() bool { + return t.Liquidity == "maker" +} diff --git a/pkg/exchange/max/maxapi/websocket.go b/pkg/exchange/max/maxapi/websocket.go new file mode 100644 index 0000000..241adda --- /dev/null +++ b/pkg/exchange/max/maxapi/websocket.go @@ -0,0 +1,30 @@ +package max + +import ( + "github.com/pkg/errors" +) + +var WebSocketURL = "wss://max-stream.maicoin.com/ws" + +var ErrMessageTypeNotSupported = errors.New("message type currently not supported") + +type SubscribeOptions struct { + Depth int `json:"depth,omitempty"` + Resolution string `json:"resolution,omitempty"` +} + +// Subscription is used for presenting the subscription metadata. +// This is used for sending subscribe and unsubscribe requests +type Subscription struct { + Channel string `json:"channel"` + Market string `json:"market"` + Depth int `json:"depth,omitempty"` + Resolution string `json:"resolution,omitempty"` +} + +type WebsocketCommand struct { + // Action is used for specify the action of the websocket session. + // Valid values are "subscribe", "unsubscribe" and "auth" + Action string `json:"action"` + Subscriptions []Subscription `json:"subscriptions,omitempty"` +} diff --git a/pkg/exchange/max/maxapi/withdrawal.go b/pkg/exchange/max/maxapi/withdrawal.go new file mode 100644 index 0000000..91e1017 --- /dev/null +++ b/pkg/exchange/max/maxapi/withdrawal.go @@ -0,0 +1,81 @@ +package max + +//go:generate -command GetRequest requestgen -method GET +//go:generate -command PostRequest requestgen -method POST + +import ( + "github.com/c9s/requestgen" +) + +/* + example response + + { + "uuid": "18022603540001", + "currency": "eth", + "currency_version": "eth", + "amount": "0.019", + "fee": "0.0", + "fee_currency": "eth", + "created_at": 1521726960, + "updated_at": 1521726960, + "state": "confirmed", + "type": "external", + "transaction_type": "external send", + "notes": "notes", + "sender": { + "email": "max****@maicoin.com" + }, + "recipient": { + "address": "0x5c7d23d516f120d322fc7b116386b7e491739138" + } + } +*/ + +//go:generate PostRequest -url "v2/withdrawal" -type WithdrawalRequest -responseType .Withdraw +type WithdrawalRequest struct { + client requestgen.AuthenticatedAPIClient + + addressUUID string `param:"withdraw_address_uuid,required"` + currency string `param:"currency,required"` + amount float64 `param:"amount"` +} + +type WithdrawalAddress struct { + UUID string `json:"uuid"` + Currency string `json:"currency"` + CurrencyVersion string `json:"currency_version"` + Address string `json:"address"` + ExtraLabel string `json:"extra_label"` + State string `json:"state"` + SygnaVaspCode string `json:"sygna_vasp_code"` + SygnaUserType string `json:"sygna_user_type"` + SygnaUserCode string `json:"sygna_user_code"` + IsInternal bool `json:"is_internal"` +} + +//go:generate GetRequest -url "v2/withdraw_addresses" -type GetWithdrawalAddressesRequest -responseType []WithdrawalAddress +type GetWithdrawalAddressesRequest struct { + client requestgen.AuthenticatedAPIClient + currency string `param:"currency,required"` +} + +type WithdrawalService struct { + client requestgen.AuthenticatedAPIClient +} + +func (s *WithdrawalService) NewGetWithdrawalAddressesRequest() *GetWithdrawalAddressesRequest { + return &GetWithdrawalAddressesRequest{ + client: s.client, + } +} + +func (s *WithdrawalService) NewWithdrawalRequest() *WithdrawalRequest { + return &WithdrawalRequest{client: s.client} +} + +func (s *WithdrawalService) NewGetWithdrawalHistoryRequest() *GetWithdrawHistoryRequest { + return &GetWithdrawHistoryRequest{ + client: s.client, + } +} diff --git a/pkg/exchange/max/maxapi/withdrawal_request_requestgen.go b/pkg/exchange/max/maxapi/withdrawal_request_requestgen.go new file mode 100644 index 0000000..b555008 --- /dev/null +++ b/pkg/exchange/max/maxapi/withdrawal_request_requestgen.go @@ -0,0 +1,179 @@ +// Code generated by "requestgen -method POST -url v2/withdrawal -type WithdrawalRequest -responseType .Withdraw"; DO NOT EDIT. + +package max + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (w *WithdrawalRequest) AddressUUID(addressUUID string) *WithdrawalRequest { + w.addressUUID = addressUUID + return w +} + +func (w *WithdrawalRequest) Currency(currency string) *WithdrawalRequest { + w.currency = currency + return w +} + +func (w *WithdrawalRequest) Amount(amount float64) *WithdrawalRequest { + w.amount = amount + return w +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (w *WithdrawalRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (w *WithdrawalRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check addressUUID field -> json key withdraw_address_uuid + addressUUID := w.addressUUID + + // TEMPLATE check-required + if len(addressUUID) == 0 { + return nil, fmt.Errorf("withdraw_address_uuid is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of addressUUID + params["withdraw_address_uuid"] = addressUUID + // check currency field -> json key currency + currency := w.currency + + // TEMPLATE check-required + if len(currency) == 0 { + return nil, fmt.Errorf("currency is required, empty string given") + } + // END TEMPLATE check-required + + // assign parameter of currency + params["currency"] = currency + // check amount field -> json key amount + amount := w.amount + + // assign parameter of amount + params["amount"] = amount + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (w *WithdrawalRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := w.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if w.isVarSlice(_v) { + w.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (w *WithdrawalRequest) GetParametersJSON() ([]byte, error) { + params, err := w.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (w *WithdrawalRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (w *WithdrawalRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (w *WithdrawalRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (w *WithdrawalRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (w *WithdrawalRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := w.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +func (w *WithdrawalRequest) Do(ctx context.Context) (*Withdraw, error) { + + params, err := w.GetParameters() + if err != nil { + return nil, err + } + query := url.Values{} + + apiURL := "v2/withdrawal" + + req, err := w.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := w.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse Withdraw + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + return &apiResponse, nil +} diff --git a/pkg/exchange/max/maxapi/withdrawal_test.go b/pkg/exchange/max/maxapi/withdrawal_test.go new file mode 100644 index 0000000..7eb3601 --- /dev/null +++ b/pkg/exchange/max/maxapi/withdrawal_test.go @@ -0,0 +1,27 @@ +package max + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWithdrawal(t *testing.T) { + key, secret, ok := integrationTestConfigured(t, "MAX") + if !ok { + t.SkipNow() + } + + ctx := context.Background() + client := NewRestClient(ProductionAPIURL) + client.Auth(key, secret) + + t.Run("v2/withdrawals", func(t *testing.T) { + req := client.NewGetWithdrawalHistoryRequest() + req.Currency("usdt") + withdrawals, err := req.Do(ctx) + assert.NoError(t, err) + assert.NotEmpty(t, withdrawals) + }) +} diff --git a/pkg/exchange/max/stream.go b/pkg/exchange/max/stream.go new file mode 100644 index 0000000..e837cb3 --- /dev/null +++ b/pkg/exchange/max/stream.go @@ -0,0 +1,306 @@ +package max + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "os" + "strconv" + "time" + + "github.com/google/uuid" + + max "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/max/maxapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +//go:generate callbackgen -type Stream +type Stream struct { + types.StandardStream + types.MarginSettings + + key, secret string + + privateChannels []string + + authEventCallbacks []func(e max.AuthEvent) + bookEventCallbacks []func(e max.BookEvent) + tradeEventCallbacks []func(e max.PublicTradeEvent) + kLineEventCallbacks []func(e max.KLineEvent) + errorEventCallbacks []func(e max.ErrorEvent) + subscriptionEventCallbacks []func(e max.SubscriptionEvent) + + tradeUpdateEventCallbacks []func(e max.TradeUpdateEvent) + tradeSnapshotEventCallbacks []func(e max.TradeSnapshotEvent) + orderUpdateEventCallbacks []func(e max.OrderUpdateEvent) + orderSnapshotEventCallbacks []func(e max.OrderSnapshotEvent) + adRatioEventCallbacks []func(e max.ADRatioEvent) + debtEventCallbacks []func(e max.DebtEvent) + + accountSnapshotEventCallbacks []func(e max.AccountSnapshotEvent) + accountUpdateEventCallbacks []func(e max.AccountUpdateEvent) +} + +func NewStream(key, secret string) *Stream { + stream := &Stream{ + StandardStream: types.NewStandardStream(), + key: key, + // pragma: allowlist nextline secret + secret: secret, + } + stream.SetEndpointCreator(stream.getEndpoint) + stream.SetParser(max.ParseMessage) + stream.SetDispatcher(stream.dispatchEvent) + stream.OnConnect(stream.handleConnect) + stream.OnAuthEvent(func(e max.AuthEvent) { + log.Infof("max websocket connection authenticated: %+v", e) + stream.EmitAuth() + }) + + stream.OnKLineEvent(stream.handleKLineEvent) + stream.OnOrderSnapshotEvent(stream.handleOrderSnapshotEvent) + stream.OnOrderUpdateEvent(stream.handleOrderUpdateEvent) + stream.OnTradeUpdateEvent(stream.handleTradeEvent) + stream.OnBookEvent(stream.handleBookEvent) + stream.OnAccountSnapshotEvent(stream.handleAccountSnapshotEvent) + stream.OnAccountUpdateEvent(stream.handleAccountUpdateEvent) + return stream +} + +func (s *Stream) getEndpoint(ctx context.Context) (string, error) { + url := os.Getenv("MAX_API_WS_URL") + if url == "" { + url = max.WebSocketURL + } + return url, nil +} + +func (s *Stream) SetPrivateChannels(channels []string) { + s.privateChannels = channels +} + +func (s *Stream) handleConnect() { + if s.PublicOnly { + cmd := &max.WebsocketCommand{ + Action: "subscribe", + } + for _, sub := range s.Subscriptions { + var depth int + + if len(sub.Options.Depth) > 0 { + switch sub.Options.Depth { + case types.DepthLevelFull: + depth = 50 + + case types.DepthLevelMedium: + depth = 20 + + case types.DepthLevel1: + depth = 1 + + case types.DepthLevel5: + depth = 5 + + default: + depth = 20 + + } + } + + cmd.Subscriptions = append(cmd.Subscriptions, max.Subscription{ + Channel: string(sub.Channel), + Market: toLocalSymbol(sub.Symbol), + Depth: depth, + Resolution: sub.Options.Interval.String(), + }) + } + + if err := s.Conn.WriteJSON(cmd); err != nil { + log.WithError(err).Error("failed to send subscription request") + } + + } else { + var filters []string + + if len(s.privateChannels) > 0 { + // TODO: maybe check the valid private channels + filters = s.privateChannels + } else if s.MarginSettings.IsMargin { + filters = []string{ + "mwallet_order", + "mwallet_trade", + "mwallet_account", + "ad_ratio", + "borrowing", + } + } + + log.Debugf("user data websocket filters: %v", filters) + + nonce := time.Now().UnixNano() / int64(time.Millisecond) + auth := &max.AuthMessage{ + // pragma: allowlist nextline secret + Action: "auth", + // pragma: allowlist nextline secret + APIKey: s.key, + Nonce: nonce, + Signature: signPayload(strconv.FormatInt(nonce, 10), s.secret), + ID: uuid.New().String(), + Filters: filters, + } + + if err := s.Conn.WriteJSON(auth); err != nil { + log.WithError(err).Error("failed to send auth request") + } + } +} + +func (s *Stream) handleKLineEvent(e max.KLineEvent) { + kline := e.KLine.KLine() + s.EmitKLine(kline) + if kline.Closed { + s.EmitKLineClosed(kline) + } +} + +func (s *Stream) handleOrderSnapshotEvent(e max.OrderSnapshotEvent) { + for _, o := range e.Orders { + globalOrder, err := convertWebSocketOrderUpdate(o) + if err != nil { + log.WithError(err).Error("websocket order snapshot convert error") + continue + } + + s.EmitOrderUpdate(*globalOrder) + } +} + +func (s *Stream) handleOrderUpdateEvent(e max.OrderUpdateEvent) { + for _, o := range e.Orders { + globalOrder, err := convertWebSocketOrderUpdate(o) + if err != nil { + log.WithError(err).Error("websocket order update convert error") + continue + } + + s.EmitOrderUpdate(*globalOrder) + } +} + +func (s *Stream) handleTradeEvent(e max.TradeUpdateEvent) { + for _, tradeUpdate := range e.Trades { + trade, err := convertWebSocketTrade(tradeUpdate) + if err != nil { + log.WithError(err).Error("websocket trade update convert error") + return + } + + s.EmitTradeUpdate(*trade) + } +} + +func (s *Stream) handleBookEvent(e max.BookEvent) { + newBook, err := e.OrderBook() + if err != nil { + log.WithError(err).Error("book convert error") + return + } + + newBook.Symbol = toGlobalSymbol(e.Market) + newBook.Time = e.Time() + + switch e.Event { + case "snapshot": + s.EmitBookSnapshot(newBook) + case "update": + s.EmitBookUpdate(newBook) + } +} + +func (s *Stream) handleAccountSnapshotEvent(e max.AccountSnapshotEvent) { + snapshot := map[string]types.Balance{} + for _, bm := range e.Balances { + balance, err := bm.Balance() + if err != nil { + continue + } + + snapshot[balance.Currency] = *balance + } + + s.EmitBalanceSnapshot(snapshot) +} + +func (s *Stream) handleAccountUpdateEvent(e max.AccountUpdateEvent) { + snapshot := map[string]types.Balance{} + for _, bm := range e.Balances { + balance, err := bm.Balance() + if err != nil { + continue + } + + snapshot[toGlobalCurrency(balance.Currency)] = *balance + } + + s.EmitBalanceUpdate(snapshot) +} + +func (s *Stream) dispatchEvent(e interface{}) { + switch e := e.(type) { + + case *max.AuthEvent: + s.EmitAuthEvent(*e) + + case *max.BookEvent: + s.EmitBookEvent(*e) + + case *max.PublicTradeEvent: + s.EmitTradeEvent(*e) + + case *max.KLineEvent: + s.EmitKLineEvent(*e) + + case *max.ErrorEvent: + s.EmitErrorEvent(*e) + + case *max.SubscriptionEvent: + s.EmitSubscriptionEvent(*e) + + case *max.TradeSnapshotEvent: + s.EmitTradeSnapshotEvent(*e) + + case *max.TradeUpdateEvent: + s.EmitTradeUpdateEvent(*e) + + case *max.AccountSnapshotEvent: + s.EmitAccountSnapshotEvent(*e) + + case *max.AccountUpdateEvent: + s.EmitAccountUpdateEvent(*e) + + case *max.OrderSnapshotEvent: + s.EmitOrderSnapshotEvent(*e) + + case *max.OrderUpdateEvent: + s.EmitOrderUpdateEvent(*e) + + case *max.ADRatioEvent: + s.EmitAdRatioEvent(*e) + + case *max.DebtEvent: + s.EmitDebtEvent(*e) + + default: + log.Warnf("unhandled %T event: %+v", e, e) + } +} + +func signPayload(payload string, secret string) string { + var sig = hmac.New(sha256.New, []byte(secret)) + _, err := sig.Write([]byte(payload)) + if err != nil { + return "" + } + return hex.EncodeToString(sig.Sum(nil)) +} diff --git a/pkg/exchange/max/stream_callbacks.go b/pkg/exchange/max/stream_callbacks.go new file mode 100644 index 0000000..1340908 --- /dev/null +++ b/pkg/exchange/max/stream_callbacks.go @@ -0,0 +1,147 @@ +// Code generated by "callbackgen -type Stream"; DO NOT EDIT. + +package max + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/max/maxapi" +) + +func (s *Stream) OnAuthEvent(cb func(e max.AuthEvent)) { + s.authEventCallbacks = append(s.authEventCallbacks, cb) +} + +func (s *Stream) EmitAuthEvent(e max.AuthEvent) { + for _, cb := range s.authEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnBookEvent(cb func(e max.BookEvent)) { + s.bookEventCallbacks = append(s.bookEventCallbacks, cb) +} + +func (s *Stream) EmitBookEvent(e max.BookEvent) { + for _, cb := range s.bookEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnTradeEvent(cb func(e max.PublicTradeEvent)) { + s.tradeEventCallbacks = append(s.tradeEventCallbacks, cb) +} + +func (s *Stream) EmitTradeEvent(e max.PublicTradeEvent) { + for _, cb := range s.tradeEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnKLineEvent(cb func(e max.KLineEvent)) { + s.kLineEventCallbacks = append(s.kLineEventCallbacks, cb) +} + +func (s *Stream) EmitKLineEvent(e max.KLineEvent) { + for _, cb := range s.kLineEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnErrorEvent(cb func(e max.ErrorEvent)) { + s.errorEventCallbacks = append(s.errorEventCallbacks, cb) +} + +func (s *Stream) EmitErrorEvent(e max.ErrorEvent) { + for _, cb := range s.errorEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnSubscriptionEvent(cb func(e max.SubscriptionEvent)) { + s.subscriptionEventCallbacks = append(s.subscriptionEventCallbacks, cb) +} + +func (s *Stream) EmitSubscriptionEvent(e max.SubscriptionEvent) { + for _, cb := range s.subscriptionEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnTradeUpdateEvent(cb func(e max.TradeUpdateEvent)) { + s.tradeUpdateEventCallbacks = append(s.tradeUpdateEventCallbacks, cb) +} + +func (s *Stream) EmitTradeUpdateEvent(e max.TradeUpdateEvent) { + for _, cb := range s.tradeUpdateEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnTradeSnapshotEvent(cb func(e max.TradeSnapshotEvent)) { + s.tradeSnapshotEventCallbacks = append(s.tradeSnapshotEventCallbacks, cb) +} + +func (s *Stream) EmitTradeSnapshotEvent(e max.TradeSnapshotEvent) { + for _, cb := range s.tradeSnapshotEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnOrderUpdateEvent(cb func(e max.OrderUpdateEvent)) { + s.orderUpdateEventCallbacks = append(s.orderUpdateEventCallbacks, cb) +} + +func (s *Stream) EmitOrderUpdateEvent(e max.OrderUpdateEvent) { + for _, cb := range s.orderUpdateEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnOrderSnapshotEvent(cb func(e max.OrderSnapshotEvent)) { + s.orderSnapshotEventCallbacks = append(s.orderSnapshotEventCallbacks, cb) +} + +func (s *Stream) EmitOrderSnapshotEvent(e max.OrderSnapshotEvent) { + for _, cb := range s.orderSnapshotEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnAdRatioEvent(cb func(e max.ADRatioEvent)) { + s.adRatioEventCallbacks = append(s.adRatioEventCallbacks, cb) +} + +func (s *Stream) EmitAdRatioEvent(e max.ADRatioEvent) { + for _, cb := range s.adRatioEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnDebtEvent(cb func(e max.DebtEvent)) { + s.debtEventCallbacks = append(s.debtEventCallbacks, cb) +} + +func (s *Stream) EmitDebtEvent(e max.DebtEvent) { + for _, cb := range s.debtEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnAccountSnapshotEvent(cb func(e max.AccountSnapshotEvent)) { + s.accountSnapshotEventCallbacks = append(s.accountSnapshotEventCallbacks, cb) +} + +func (s *Stream) EmitAccountSnapshotEvent(e max.AccountSnapshotEvent) { + for _, cb := range s.accountSnapshotEventCallbacks { + cb(e) + } +} + +func (s *Stream) OnAccountUpdateEvent(cb func(e max.AccountUpdateEvent)) { + s.accountUpdateEventCallbacks = append(s.accountUpdateEventCallbacks, cb) +} + +func (s *Stream) EmitAccountUpdateEvent(e max.AccountUpdateEvent) { + for _, cb := range s.accountUpdateEventCallbacks { + cb(e) + } +} diff --git a/pkg/exchange/max/ticker_test.go b/pkg/exchange/max/ticker_test.go new file mode 100644 index 0000000..6ef459e --- /dev/null +++ b/pkg/exchange/max/ticker_test.go @@ -0,0 +1,54 @@ +package max + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExchange_QueryTickers_AllSymbols(t *testing.T) { + key := os.Getenv("MAX_API_KEY") + secret := os.Getenv("MAX_API_SECRET") + if len(key) == 0 && len(secret) == 0 { + t.Skip("api key/secret are not configured") + return + } + + e := New(key, secret) + got, err := e.QueryTickers(context.Background()) + if assert.NoError(t, err) { + assert.True(t, len(got) > 1, "max: attempting to get all symbol tickers, but get 1 or less") + } +} + +func TestExchange_QueryTickers_SomeSymbols(t *testing.T) { + key := os.Getenv("MAX_API_KEY") + secret := os.Getenv("MAX_API_SECRET") + if len(key) == 0 && len(secret) == 0 { + t.Skip("api key/secret are not configured") + return + } + + e := New(key, secret) + got, err := e.QueryTickers(context.Background(), "BTCUSDT", "ETHUSDT") + if assert.NoError(t, err) { + assert.Len(t, got, 2, "max: attempting to get two symbols, but number of tickers do not match") + } +} + +func TestExchange_QueryTickers_SingleSymbol(t *testing.T) { + key := os.Getenv("MAX_API_KEY") + secret := os.Getenv("MAX_API_SECRET") + if len(key) == 0 && len(secret) == 0 { + t.Skip("api key/secret are not configured") + return + } + + e := New(key, secret) + got, err := e.QueryTickers(context.Background(), "BTCUSDT") + if assert.NoError(t, err) { + assert.Len(t, got, 1, "max: attempting to get 1 symbols, but number of tickers do not match") + } +} diff --git a/pkg/exchange/okex/convert.go b/pkg/exchange/okex/convert.go new file mode 100644 index 0000000..0f7b3a4 --- /dev/null +++ b/pkg/exchange/okex/convert.go @@ -0,0 +1,364 @@ +package okex + +import ( + "fmt" + "strconv" + "strings" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/okex/okexapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func toGlobalSymbol(symbol string) string { + return strings.ReplaceAll(symbol, "-", "") +} + +//go:generate sh -c "echo \"package okex\nvar spotSymbolMap = map[string]string{\n\" $(curl -s -L 'https://www.okx.com/api/v5/public/instruments?instType=SPOT' | jq -r '.data[] | \"\\(.instId | sub(\"-\" ; \"\") | tojson ): \\( .instId | tojson),\n\"') \"\n}\" > symbols.go" +//go:generate go run gensymbols.go +func toLocalSymbol(symbol string) string { + if s, ok := spotSymbolMap[symbol]; ok { + return s + } + + log.Errorf("failed to look up local symbol from %s", symbol) + return symbol +} + +func toGlobalTicker(marketTicker okexapi.MarketTicker) *types.Ticker { + return &types.Ticker{ + Time: marketTicker.Timestamp.Time(), + Volume: marketTicker.Volume24H, + Last: marketTicker.Last, + Open: marketTicker.Open24H, + High: marketTicker.High24H, + Low: marketTicker.Low24H, + Buy: marketTicker.BidPrice, + Sell: marketTicker.AskPrice, + } +} + +func toGlobalBalance(account *okexapi.Account) types.BalanceMap { + var balanceMap = types.BalanceMap{} + for _, balanceDetail := range account.Details { + balanceMap[balanceDetail.Currency] = types.Balance{ + Currency: balanceDetail.Currency, + Available: balanceDetail.CashBalance, + Locked: balanceDetail.Frozen, + } + } + return balanceMap +} + +type WebsocketSubscription struct { + Channel Channel `json:"channel"` + InstrumentID string `json:"instId,omitempty"` + InstrumentType string `json:"instType,omitempty"` +} + +var CandleChannels = []string{ + "candle1Y", + "candle6M", "candle3M", "candle1M", + "candle1W", + "candle1D", "candle2D", "candle3D", "candle5D", + "candle12H", "candle6H", "candle4H", "candle2H", "candle1H", + "candle30m", "candle15m", "candle5m", "candle3m", "candle1m", +} + +func convertIntervalToCandle(interval types.Interval) string { + s := interval.String() + switch s { + + case "1h", "2h", "4h", "6h", "12h", "1d", "3d": + return "candle" + strings.ToUpper(s) + + case "1m", "5m", "15m", "30m": + return "candle" + s + + } + + return "candle" + s +} + +func convertSubscription(s types.Subscription) (WebsocketSubscription, error) { + switch s.Channel { + case types.KLineChannel: + // Channel names are: + return WebsocketSubscription{ + Channel: Channel(convertIntervalToCandle(s.Options.Interval)), + InstrumentID: toLocalSymbol(s.Symbol), + }, nil + + case types.BookChannel: + ch := ChannelBooks + + switch s.Options.Depth { + case types.DepthLevelFull: + ch = ChannelBooks + + case types.DepthLevelMedium: + ch = ChannelBooks50 + + case types.DepthLevel50: + ch = ChannelBooks50 + + case types.DepthLevel5: + ch = ChannelBooks5 + + case types.DepthLevel1: + ch = ChannelBooks1 + } + + return WebsocketSubscription{ + Channel: ch, + InstrumentID: toLocalSymbol(s.Symbol), + }, nil + case types.BookTickerChannel: + return WebsocketSubscription{ + Channel: ChannelBooks5, + InstrumentID: toLocalSymbol(s.Symbol), + }, nil + case types.MarketTradeChannel: + return WebsocketSubscription{ + Channel: ChannelMarketTrades, + InstrumentID: toLocalSymbol(s.Symbol), + }, nil + } + + return WebsocketSubscription{}, fmt.Errorf("unsupported public stream channel %s", s.Channel) +} + +func toLocalSideType(side types.SideType) okexapi.SideType { + return okexapi.SideType(strings.ToLower(string(side))) +} + +func tradeToGlobal(trade okexapi.Trade) types.Trade { + side := toGlobalSide(trade.Side) + return types.Trade{ + ID: uint64(trade.TradeId), + OrderID: uint64(trade.OrderId), + Exchange: types.ExchangeOKEx, + Price: trade.FillPrice, + Quantity: trade.FillSize, + QuoteQuantity: trade.FillPrice.Mul(trade.FillSize), + Symbol: toGlobalSymbol(trade.InstrumentId), + Side: side, + IsBuyer: side == types.SideTypeBuy, + IsMaker: trade.ExecutionType == okexapi.LiquidityTypeMaker, + Time: types.Time(trade.Timestamp), + // The fees obtained from the exchange are negative, hence they are forcibly converted to positive. + Fee: trade.Fee.Abs(), + FeeCurrency: trade.FeeCurrency, + IsMargin: false, + IsFutures: false, + IsIsolated: false, + } +} + +func processMarketBuySize(o *okexapi.OrderDetail) (fixedpoint.Value, error) { + switch o.State { + case okexapi.OrderStateLive, okexapi.OrderStateCanceled: + return fixedpoint.Zero, nil + + case okexapi.OrderStatePartiallyFilled: + if o.FillPrice.IsZero() { + return fixedpoint.Zero, fmt.Errorf("fillPrice for a partialFilled should not be zero") + } + return o.Size.Div(o.FillPrice), nil + + case okexapi.OrderStateFilled: + return o.AccumulatedFillSize, nil + + default: + return fixedpoint.Zero, fmt.Errorf("unexpected status: %s", o.State) + } +} + +func orderDetailToGlobal(order *okexapi.OrderDetail) (*types.Order, error) { + side := toGlobalSide(order.Side) + + orderType, err := toGlobalOrderType(order.OrderType) + if err != nil { + return nil, err + } + + timeInForce := types.TimeInForceGTC + switch order.OrderType { + case okexapi.OrderTypeFOK: + timeInForce = types.TimeInForceFOK + case okexapi.OrderTypeIOC: + timeInForce = types.TimeInForceIOC + } + + orderStatus, err := toGlobalOrderStatus(order.State) + if err != nil { + return nil, err + } + + size := order.Size + if order.Side == okexapi.SideTypeBuy && + order.OrderType == okexapi.OrderTypeMarket && + order.TargetCurrency == okexapi.TargetCurrencyQuote { + + size, err = processMarketBuySize(order) + if err != nil { + return nil, err + } + } + + return &types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: order.ClientOrderId, + Symbol: toGlobalSymbol(order.InstrumentID), + Side: side, + Type: orderType, + Price: order.Price, + Quantity: size, + AveragePrice: order.AvgPrice, + TimeInForce: timeInForce, + }, + Exchange: types.ExchangeOKEx, + OrderID: uint64(order.OrderId), + UUID: strconv.FormatInt(int64(order.OrderId), 10), + Status: orderStatus, + OriginalStatus: string(order.State), + ExecutedQuantity: order.AccumulatedFillSize, + IsWorking: order.State.IsWorking(), + CreationTime: types.Time(order.CreatedTime), + UpdateTime: types.Time(order.UpdatedTime), + }, nil +} + +func toGlobalOrderStatus(state okexapi.OrderState) (types.OrderStatus, error) { + switch state { + case okexapi.OrderStateCanceled: + return types.OrderStatusCanceled, nil + case okexapi.OrderStateLive: + return types.OrderStatusNew, nil + case okexapi.OrderStatePartiallyFilled: + return types.OrderStatusPartiallyFilled, nil + case okexapi.OrderStateFilled: + return types.OrderStatusFilled, nil + + } + + return "", fmt.Errorf("unknown or unsupported okex order state: %s", state) +} + +func toLocalOrderType(orderType types.OrderType) (okexapi.OrderType, error) { + switch orderType { + case types.OrderTypeMarket: + return okexapi.OrderTypeMarket, nil + + case types.OrderTypeLimit: + return okexapi.OrderTypeLimit, nil + + case types.OrderTypeLimitMaker: + return okexapi.OrderTypePostOnly, nil + + } + + return "", fmt.Errorf("unknown or unsupported okex order type: %s", orderType) +} + +func toGlobalOrderType(orderType okexapi.OrderType) (types.OrderType, error) { + // IOC, FOK are only allowed with limit order type, so we assume the order type is always limit order for FOK, IOC orders + switch orderType { + case okexapi.OrderTypeMarket: + return types.OrderTypeMarket, nil + + case okexapi.OrderTypeLimit, okexapi.OrderTypeFOK, okexapi.OrderTypeIOC: + return types.OrderTypeLimit, nil + + case okexapi.OrderTypePostOnly: + return types.OrderTypeLimitMaker, nil + + } + + return "", fmt.Errorf("unknown or unsupported okex order type: %s", orderType) +} + +func toLocalInterval(interval types.Interval) (string, error) { + if _, ok := SupportedIntervals[interval]; !ok { + return "", fmt.Errorf("interval %s is not supported", interval) + } + + in, ok := ToLocalInterval[interval] + if !ok { + return "", fmt.Errorf("interval %s is not supported, got local interval %s", interval, in) + } + + return in, nil +} + +func toGlobalSide(side okexapi.SideType) (s types.SideType) { + switch string(side) { + case "sell": + s = types.SideTypeSell + case "buy": + s = types.SideTypeBuy + } + return s +} + +func toGlobalOrder(okexOrder *okexapi.OrderDetails) (*types.Order, error) { + + orderID, err := strconv.ParseInt(okexOrder.OrderID, 10, 64) + if err != nil { + return nil, err + } + + side := toGlobalSide(okexOrder.Side) + + orderType, err := toGlobalOrderType(okexOrder.OrderType) + if err != nil { + return nil, err + } + + timeInForce := types.TimeInForceGTC + switch okexOrder.OrderType { + case okexapi.OrderTypeFOK: + timeInForce = types.TimeInForceFOK + case okexapi.OrderTypeIOC: + timeInForce = types.TimeInForceIOC + } + + orderStatus, err := toGlobalOrderStatus(okexOrder.State) + if err != nil { + return nil, err + } + + isWorking := false + switch orderStatus { + case types.OrderStatusNew, types.OrderStatusPartiallyFilled: + isWorking = true + + } + + isMargin := false + if okexOrder.InstrumentType == okexapi.InstrumentTypeMARGIN { + isMargin = true + } + + return &types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: okexOrder.ClientOrderID, + Symbol: toGlobalSymbol(okexOrder.InstrumentID), + Side: side, + Type: orderType, + Price: okexOrder.Price, + Quantity: okexOrder.Quantity, + StopPrice: fixedpoint.Zero, // not supported yet + TimeInForce: timeInForce, + }, + Exchange: types.ExchangeOKEx, + OrderID: uint64(orderID), + Status: orderStatus, + ExecutedQuantity: okexOrder.FilledQuantity, + IsWorking: isWorking, + CreationTime: types.Time(okexOrder.CreationTime), + UpdateTime: types.Time(okexOrder.UpdateTime), + IsMargin: isMargin, + IsIsolated: false, + }, nil +} diff --git a/pkg/exchange/okex/convert_test.go b/pkg/exchange/okex/convert_test.go new file mode 100644 index 0000000..88ddd32 --- /dev/null +++ b/pkg/exchange/okex/convert_test.go @@ -0,0 +1,245 @@ +package okex + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/okex/okexapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func Test_orderDetailToGlobal(t *testing.T) { + var ( + assert = assert.New(t) + + orderId = 665576973905014786 + // {"accFillSz":"0","algoClOrdId":"","algoId":"","attachAlgoClOrdId":"","attachAlgoOrds":[],"avgPx":"","cTime":"1704957916401","cancelSource":"","cancelSourceReason":"","category":"normal","ccy":"","clOrdId":"","fee":"0","feeCcy":"USDT","fillPx":"","fillSz":"0","fillTime":"","instId":"BTC-USDT","instType":"SPOT","lever":"","ordId":"665576973905014786","ordType":"limit","pnl":"0","posSide":"net","px":"48174.5","pxType":"","pxUsd":"","pxVol":"","quickMgnType":"","rebate":"0","rebateCcy":"BTC","reduceOnly":"false","side":"sell","slOrdPx":"","slTriggerPx":"","slTriggerPxType":"","source":"","state":"live","stpId":"","stpMode":"","sz":"0.00001","tag":"","tdMode":"cash","tgtCcy":"","tpOrdPx":"","tpTriggerPx":"","tpTriggerPxType":"","tradeId":"","uTime":"1704957916401"} + openOrder = &okexapi.OrderDetail{ + AccumulatedFillSize: fixedpoint.NewFromFloat(0), + AvgPrice: fixedpoint.NewFromFloat(0), + CreatedTime: types.NewMillisecondTimestampFromInt(1704957916401), + Category: "normal", + Currency: "BTC", + ClientOrderId: "", + Fee: fixedpoint.Zero, + FeeCurrency: "USDT", + FillTime: types.NewMillisecondTimestampFromInt(0), + InstrumentID: "BTC-USDT", + InstrumentType: okexapi.InstrumentTypeSpot, + OrderId: types.StrInt64(orderId), + OrderType: okexapi.OrderTypeLimit, + Price: fixedpoint.NewFromFloat(48174.5), + Side: okexapi.SideTypeBuy, + State: okexapi.OrderStateLive, + Size: fixedpoint.NewFromFloat(0.00001), + UpdatedTime: types.NewMillisecondTimestampFromInt(1704957916401), + } + expOrder = &types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: openOrder.ClientOrderId, + Symbol: toGlobalSymbol(openOrder.InstrumentID), + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: fixedpoint.NewFromFloat(0.00001), + Price: fixedpoint.NewFromFloat(48174.5), + AveragePrice: fixedpoint.Zero, + StopPrice: fixedpoint.Zero, + TimeInForce: types.TimeInForceGTC, + }, + Exchange: types.ExchangeOKEx, + OrderID: uint64(orderId), + UUID: fmt.Sprintf("%d", orderId), + Status: types.OrderStatusNew, + OriginalStatus: string(okexapi.OrderStateLive), + ExecutedQuantity: fixedpoint.Zero, + IsWorking: true, + CreationTime: types.Time(types.NewMillisecondTimestampFromInt(1704957916401).Time()), + UpdateTime: types.Time(types.NewMillisecondTimestampFromInt(1704957916401).Time()), + } + ) + + t.Run("succeeds", func(t *testing.T) { + order, err := orderDetailToGlobal(openOrder) + assert.NoError(err) + assert.Equal(expOrder, order) + }) + + t.Run("succeeds with market/buy/targetQuoteCurrency", func(t *testing.T) { + newOrder := *openOrder + newOrder.OrderType = okexapi.OrderTypeMarket + newOrder.Side = okexapi.SideTypeBuy + newOrder.TargetCurrency = okexapi.TargetCurrencyQuote + newOrder.FillPrice = fixedpoint.NewFromFloat(100) + newOrder.Size = fixedpoint.NewFromFloat(10000) + newOrder.State = okexapi.OrderStatePartiallyFilled + + newExpOrder := *expOrder + newExpOrder.Side = types.SideTypeBuy + newExpOrder.Type = types.OrderTypeMarket + newExpOrder.Quantity = fixedpoint.NewFromFloat(100) + newExpOrder.Status = types.OrderStatusPartiallyFilled + newExpOrder.OriginalStatus = string(okexapi.OrderStatePartiallyFilled) + order, err := orderDetailToGlobal(&newOrder) + assert.NoError(err) + assert.Equal(&newExpOrder, order) + }) + + t.Run("unexpected order status", func(t *testing.T) { + newOrder := *openOrder + newOrder.State = "xxx" + _, err := orderDetailToGlobal(&newOrder) + assert.ErrorContains(err, "xxx") + }) + + t.Run("unexpected order type", func(t *testing.T) { + newOrder := *openOrder + newOrder.OrderType = "xxx" + _, err := orderDetailToGlobal(&newOrder) + assert.ErrorContains(err, "xxx") + }) + +} + +func Test_tradeToGlobal(t *testing.T) { + var ( + assert = assert.New(t) + raw = `{"side":"sell","fillSz":"1","fillPx":"46446.4","fillPxVol":"","fillFwdPx":"","fee":"-46","fillPnl":"0","ordId":"665951654130348158","feeRate":"-0.001","instType":"SPOT","fillPxUsd":"","instId":"BTC-USDT","clOrdId":"","posSide":"net","billId":"665951654138736652","fillMarkVol":"","tag":"","fillTime":"1705047247128","execType":"T","fillIdxPx":"","tradeId":"724072849","fillMarkPx":"","feeCcy":"USDT","ts":"1705047247130"}` + ) + var res okexapi.Trade + err := json.Unmarshal([]byte(raw), &res) + assert.NoError(err) + + t.Run("succeeds with sell/taker", func(t *testing.T) { + assert.Equal(tradeToGlobal(res), types.Trade{ + ID: uint64(724072849), + OrderID: uint64(665951654130348158), + Exchange: types.ExchangeOKEx, + Price: fixedpoint.NewFromFloat(46446.4), + Quantity: fixedpoint.One, + QuoteQuantity: fixedpoint.NewFromFloat(46446.4), + Symbol: "BTCUSDT", + Side: types.SideTypeSell, + IsBuyer: false, + IsMaker: false, + Time: types.Time(types.NewMillisecondTimestampFromInt(1705047247130).Time()), + Fee: fixedpoint.NewFromFloat(46), + FeeCurrency: "USDT", + }) + }) + + t.Run("succeeds with buy/taker", func(t *testing.T) { + newRes := res + newRes.Side = okexapi.SideTypeBuy + assert.Equal(tradeToGlobal(newRes), types.Trade{ + ID: uint64(724072849), + OrderID: uint64(665951654130348158), + Exchange: types.ExchangeOKEx, + Price: fixedpoint.NewFromFloat(46446.4), + Quantity: fixedpoint.One, + QuoteQuantity: fixedpoint.NewFromFloat(46446.4), + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + IsBuyer: true, + IsMaker: false, + Time: types.Time(types.NewMillisecondTimestampFromInt(1705047247130).Time()), + Fee: fixedpoint.NewFromFloat(46), + FeeCurrency: "USDT", + }) + }) + + t.Run("succeeds with sell/maker", func(t *testing.T) { + newRes := res + newRes.ExecutionType = okexapi.LiquidityTypeMaker + assert.Equal(tradeToGlobal(newRes), types.Trade{ + ID: uint64(724072849), + OrderID: uint64(665951654130348158), + Exchange: types.ExchangeOKEx, + Price: fixedpoint.NewFromFloat(46446.4), + Quantity: fixedpoint.One, + QuoteQuantity: fixedpoint.NewFromFloat(46446.4), + Symbol: "BTCUSDT", + Side: types.SideTypeSell, + IsBuyer: false, + IsMaker: true, + Time: types.Time(types.NewMillisecondTimestampFromInt(1705047247130).Time()), + Fee: fixedpoint.NewFromFloat(46), + FeeCurrency: "USDT", + }) + }) + + t.Run("succeeds with buy/maker", func(t *testing.T) { + newRes := res + newRes.Side = okexapi.SideTypeBuy + newRes.ExecutionType = okexapi.LiquidityTypeMaker + assert.Equal(tradeToGlobal(newRes), types.Trade{ + ID: uint64(724072849), + OrderID: uint64(665951654130348158), + Exchange: types.ExchangeOKEx, + Price: fixedpoint.NewFromFloat(46446.4), + Quantity: fixedpoint.One, + QuoteQuantity: fixedpoint.NewFromFloat(46446.4), + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + IsBuyer: true, + IsMaker: true, + Time: types.Time(types.NewMillisecondTimestampFromInt(1705047247130).Time()), + Fee: fixedpoint.NewFromFloat(46), + FeeCurrency: "USDT", + }) + }) +} + +func Test_processMarketBuyQuantity(t *testing.T) { + var ( + assert = assert.New(t) + ) + + t.Run("zero", func(t *testing.T) { + size, err := processMarketBuySize(&okexapi.OrderDetail{State: okexapi.OrderStateLive}) + assert.NoError(err) + assert.Equal(fixedpoint.Zero, size) + + size, err = processMarketBuySize(&okexapi.OrderDetail{State: okexapi.OrderStateCanceled}) + assert.NoError(err) + assert.Equal(fixedpoint.Zero, size) + }) + + t.Run("estimated size", func(t *testing.T) { + size, err := processMarketBuySize(&okexapi.OrderDetail{ + FillPrice: fixedpoint.NewFromFloat(2), + Size: fixedpoint.NewFromFloat(4), + State: okexapi.OrderStatePartiallyFilled, + }) + assert.NoError(err) + assert.Equal(fixedpoint.NewFromFloat(2), size) + }) + + t.Run("unexpected fill price", func(t *testing.T) { + _, err := processMarketBuySize(&okexapi.OrderDetail{ + FillPrice: fixedpoint.Zero, + Size: fixedpoint.NewFromFloat(4), + State: okexapi.OrderStatePartiallyFilled, + }) + assert.ErrorContains(err, "fillPrice") + }) + + t.Run("accumulatedFillsize", func(t *testing.T) { + size, err := processMarketBuySize(&okexapi.OrderDetail{ + AccumulatedFillSize: fixedpoint.NewFromFloat(1000), + State: okexapi.OrderStateFilled, + }) + assert.NoError(err) + assert.Equal(fixedpoint.NewFromFloat(1000), size) + }) + + t.Run("unexpected status", func(t *testing.T) { + _, err := processMarketBuySize(&okexapi.OrderDetail{ + State: "XXXXXXX", + }) + assert.ErrorContains(err, "unexpected") + }) +} diff --git a/pkg/exchange/okex/exchange.go b/pkg/exchange/okex/exchange.go new file mode 100644 index 0000000..5ab7f4f --- /dev/null +++ b/pkg/exchange/okex/exchange.go @@ -0,0 +1,660 @@ +package okex + +import ( + "context" + "fmt" + "regexp" + "strconv" + "time" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "go.uber.org/multierr" + "golang.org/x/time/rate" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/okex/okexapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +var ( + // clientOrderIdRegex combine of case-sensitive alphanumerics, all numbers, or all letters of up to 32 characters. + clientOrderIdRegex = regexp.MustCompile("^[a-zA-Z0-9]{0,32}$") + + // Rate Limit: 20 requests per 2 seconds, Rate limit rule: IP + instrumentType. + // Currently, calls are not made very frequently, so only IP is considered. + queryMarketLimiter = rate.NewLimiter(rate.Every(100*time.Millisecond), 1) + // Rate Limit: 20 requests per 2 seconds, Rate limit rule: IP + queryTickerLimiter = rate.NewLimiter(rate.Every(100*time.Millisecond), 1) + // Rate Limit: 20 requests per 2 seconds, Rate limit rule: IP + queryTickersLimiter = rate.NewLimiter(rate.Every(100*time.Millisecond), 1) + // Rate Limit: 10 requests per 2 seconds, Rate limit rule: UserID + queryAccountLimiter = rate.NewLimiter(rate.Every(200*time.Millisecond), 1) + // Rate Limit: 60 requests per 2 seconds, Rate limit rule (except Options): UserID + Instrument ID. + // TODO: support UserID + Instrument ID + placeOrderLimiter = rate.NewLimiter(rate.Every(33*time.Millisecond), 1) + // Rate Limit: 60 requests per 2 seconds, Rate limit rule (except Options): UserID + Instrument ID + // TODO: support UserID + Instrument ID + batchCancelOrderLimiter = rate.NewLimiter(rate.Every(33*time.Millisecond), 1) + // Rate Limit: 60 requests per 2 seconds, Rate limit rule: UserID + queryOpenOrderLimiter = rate.NewLimiter(rate.Every(33*time.Millisecond), 1) + // Rate Limit: 20 requests per 2 seconds, Rate limit rule: UserID + queryClosedOrderRateLimiter = rate.NewLimiter(rate.Every(100*time.Millisecond), 1) + // Rate Limit: 10 requests per 2 seconds, Rate limit rule: UserID + queryTradeLimiter = rate.NewLimiter(rate.Every(200*time.Millisecond), 1) + // Rate Limit: 40 requests per 2 seconds, Rate limit rule: IP + queryKLineLimiter = rate.NewLimiter(rate.Every(50*time.Millisecond), 1) +) + +const ( + ID = "okex" + + // PlatformToken is the platform currency of OKEx, pre-allocate static string here + PlatformToken = "OKB" + + defaultQueryLimit = 100 + + maxHistoricalDataQueryPeriod = 90 * 24 * time.Hour + threeDaysHistoricalPeriod = 3 * 24 * time.Hour +) + +var log = logrus.WithFields(logrus.Fields{ + "exchange": ID, +}) + +var ErrSymbolRequired = errors.New("symbol is a required parameter") + +type Exchange struct { + key, secret, passphrase string + + client *okexapi.RestClient + timeNowFunc func() time.Time +} + +func New(key, secret, passphrase string) *Exchange { + client := okexapi.NewClient() + + if len(key) > 0 && len(secret) > 0 { + client.Auth(key, secret, passphrase) + } + + return &Exchange{ + key: key, + secret: secret, + passphrase: passphrase, + client: client, + timeNowFunc: time.Now, + } +} + +func (e *Exchange) Name() types.ExchangeName { + return types.ExchangeOKEx +} + +func (e *Exchange) QueryMarkets(ctx context.Context) (types.MarketMap, error) { + if err := queryMarketLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("markets rate limiter wait error: %w", err) + } + + instruments, err := e.client.NewGetInstrumentsInfoRequest().Do(ctx) + if err != nil { + return nil, err + } + + markets := types.MarketMap{} + for _, instrument := range instruments { + symbol := toGlobalSymbol(instrument.InstrumentID) + market := types.Market{ + Exchange: types.ExchangeOKEx, + Symbol: symbol, + LocalSymbol: instrument.InstrumentID, + + QuoteCurrency: instrument.QuoteCurrency, + BaseCurrency: instrument.BaseCurrency, + + // convert tick size OKEx to precision + PricePrecision: instrument.TickSize.NumFractionalDigits(), + VolumePrecision: instrument.LotSize.NumFractionalDigits(), + + // TickSize: OKEx's price tick, for BTC-USDT it's "0.1" + TickSize: instrument.TickSize, + + // Quantity step size, for BTC-USDT, it's "0.00000001" + StepSize: instrument.LotSize, + + // for BTC-USDT, it's "0.00001" + MinQuantity: instrument.MinSize, + + // OKEx does not offer minimal notional, use 1 USD here. + MinNotional: fixedpoint.One, + MinAmount: fixedpoint.One, + } + markets[symbol] = market + } + + return markets, nil +} + +func (e *Exchange) QueryTicker(ctx context.Context, symbol string) (*types.Ticker, error) { + if err := queryTickerLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("ticker rate limiter wait error: %w", err) + } + + symbol = toLocalSymbol(symbol) + marketTicker, err := e.client.NewGetTickerRequest().InstId(symbol).Do(ctx) + if err != nil { + return nil, err + } + + if len(marketTicker) != 1 { + return nil, fmt.Errorf("unexpected length of %s market ticker, got: %v", symbol, marketTicker) + } + + return toGlobalTicker(marketTicker[0]), nil +} + +func (e *Exchange) QueryTickers(ctx context.Context, symbols ...string) (map[string]types.Ticker, error) { + if err := queryTickersLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("tickers rate limiter wait error: %w", err) + } + + marketTickers, err := e.client.NewGetTickersRequest().Do(ctx) + if err != nil { + return nil, err + } + + tickers := make(map[string]types.Ticker) + for _, marketTicker := range marketTickers { + symbol := toGlobalSymbol(marketTicker.InstrumentID) + ticker := toGlobalTicker(marketTicker) + tickers[symbol] = *ticker + } + + if len(symbols) == 0 { + return tickers, nil + } + + selectedTickers := make(map[string]types.Ticker, len(symbols)) + for _, symbol := range symbols { + if ticker, ok := tickers[symbol]; ok { + selectedTickers[symbol] = ticker + } + } + + return selectedTickers, nil +} + +func (e *Exchange) PlatformFeeCurrency() string { + return PlatformToken +} + +func (e *Exchange) QueryAccount(ctx context.Context) (*types.Account, error) { + bals, err := e.QueryAccountBalances(ctx) + if err != nil { + return nil, err + } + + account := types.NewAccount() + account.UpdateBalances(bals) + return account, nil +} + +func (e *Exchange) QueryAccountBalances(ctx context.Context) (types.BalanceMap, error) { + if err := queryAccountLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("account rate limiter wait error: %w", err) + } + + accountBalances, err := e.client.NewGetAccountInfoRequest().Do(ctx) + if err != nil { + return nil, err + } + + if len(accountBalances) != 1 { + return nil, fmt.Errorf("unexpected length of balances: %v", accountBalances) + } + + return toGlobalBalance(&accountBalances[0]), nil +} + +func (e *Exchange) SubmitOrder(ctx context.Context, order types.SubmitOrder) (*types.Order, error) { + orderReq := e.client.NewPlaceOrderRequest() + + orderReq.InstrumentID(toLocalSymbol(order.Symbol)) + orderReq.Side(toLocalSideType(order.Side)) + orderReq.Size(order.Market.FormatQuantity(order.Quantity)) + + // set price field for limit orders + switch order.Type { + case types.OrderTypeStopLimit, types.OrderTypeLimit, types.OrderTypeLimitMaker: + orderReq.Price(order.Market.FormatPrice(order.Price)) + case types.OrderTypeMarket: + // Because our order.Quantity unit is base coin, so we indicate the target currency to Base. + if order.Side == types.SideTypeBuy { + orderReq.Size(order.Market.FormatQuantity(order.Quantity)) + orderReq.TargetCurrency(okexapi.TargetCurrencyBase) + } else { + orderReq.Size(order.Market.FormatQuantity(order.Quantity)) + orderReq.TargetCurrency(okexapi.TargetCurrencyQuote) + } + } + + orderType, err := toLocalOrderType(order.Type) + if err != nil { + return nil, err + } + + switch order.TimeInForce { + case types.TimeInForceFOK: + orderReq.OrderType(okexapi.OrderTypeFOK) + case types.TimeInForceIOC: + orderReq.OrderType(okexapi.OrderTypeIOC) + default: + orderReq.OrderType(orderType) + } + + if err := placeOrderLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("place order rate limiter wait error: %w", err) + } + + if len(order.ClientOrderID) > 0 { + if ok := clientOrderIdRegex.MatchString(order.ClientOrderID); !ok { + return nil, fmt.Errorf("client order id should be case-sensitive alphanumerics, all numbers, or all letters of up to 32 characters: %s", order.ClientOrderID) + } + orderReq.ClientOrderID(order.ClientOrderID) + } + + timeNow := time.Now() + orders, err := orderReq.Do(ctx) + if err != nil { + return nil, err + } + + if len(orders) != 1 { + return nil, fmt.Errorf("unexpected length of order response: %v", orders) + } + + orderID, err := strconv.ParseUint(orders[0].OrderID, 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse response order id: %w", err) + } + + return &types.Order{ + SubmitOrder: order, + Exchange: types.ExchangeOKEx, + OrderID: orderID, + Status: types.OrderStatusNew, + ExecutedQuantity: fixedpoint.Zero, + IsWorking: true, + CreationTime: types.Time(timeNow), + UpdateTime: types.Time(timeNow), + }, nil + + // TODO: move this to batch place orders interface + /* + batchReq := e.client.TradeService.NewBatchPlaceOrderRequest() + batchReq.Add(reqs...) + orderHeads, err := batchReq.Do(ctx) + if err != nil { + return nil, err + } + + for idx, orderHead := range orderHeads { + orderID, err := strconv.ParseInt(orderHead.OrderID, 10, 64) + if err != nil { + return createdOrder, err + } + + submitOrder := order[idx] + createdOrder = append(createdOrder, types.Order{ + SubmitOrder: submitOrder, + Exchange: types.ExchangeOKEx, + OrderID: uint64(orderID), + Status: types.OrderStatusNew, + ExecutedQuantity: fixedpoint.Zero, + IsWorking: true, + CreationTime: types.Time(time.Now()), + UpdateTime: types.Time(time.Now()), + IsMargin: false, + IsIsolated: false, + }) + } + */ +} + +// QueryOpenOrders retrieves the pending orders. The data returned is ordered by createdTime, and we utilized the +// `After` parameter to acquire all orders. +func (e *Exchange) QueryOpenOrders(ctx context.Context, symbol string) (orders []types.Order, err error) { + instrumentID := toLocalSymbol(symbol) + + nextCursor := int64(0) + for { + if err := queryOpenOrderLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("query open orders rate limiter wait error: %w", err) + } + + req := e.client.NewGetOpenOrdersRequest(). + InstrumentID(instrumentID). + After(strconv.FormatInt(nextCursor, 10)) + openOrders, err := req.Do(ctx) + if err != nil { + return nil, fmt.Errorf("failed to query open orders: %w", err) + } + + for _, o := range openOrders { + o, err := orderDetailToGlobal(&o.OrderDetail) + if err != nil { + return nil, fmt.Errorf("failed to convert order, err: %v", err) + } + + orders = append(orders, *o) + } + + orderLen := len(openOrders) + // a defensive programming to ensure the length of order response is expected. + if orderLen > defaultQueryLimit { + return nil, fmt.Errorf("unexpected open orders length %d", orderLen) + } + + if orderLen < defaultQueryLimit { + break + } + nextCursor = int64(openOrders[orderLen-1].OrderId) + } + + return orders, err +} + +func (e *Exchange) CancelOrders(ctx context.Context, orders ...types.Order) error { + if len(orders) == 0 { + return nil + } + + var reqs []*okexapi.CancelOrderRequest + for _, order := range orders { + if len(order.Symbol) == 0 { + return ErrSymbolRequired + } + + req := e.client.NewCancelOrderRequest() + req.InstrumentID(toLocalSymbol(order.Symbol)) + req.OrderID(strconv.FormatUint(order.OrderID, 10)) + if len(order.ClientOrderID) > 0 { + if ok := clientOrderIdRegex.MatchString(order.ClientOrderID); !ok { + return fmt.Errorf("client order id should be case-sensitive alphanumerics, all numbers, or all letters of up to 32 characters: %s", order.ClientOrderID) + } + req.ClientOrderID(order.ClientOrderID) + } + reqs = append(reqs, req) + } + + if err := batchCancelOrderLimiter.Wait(ctx); err != nil { + return fmt.Errorf("batch cancel order rate limiter wait error: %w", err) + } + batchReq := e.client.NewBatchCancelOrderRequest() + batchReq.Add(reqs...) + _, err := batchReq.Do(ctx) + return err +} + +func (e *Exchange) NewStream() types.Stream { + return NewStream(e.client, e) +} + +func (e *Exchange) QueryKLines( + ctx context.Context, symbol string, interval types.Interval, options types.KLineQueryOptions, +) ([]types.KLine, error) { + if err := queryKLineLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("query k line rate limiter wait error: %w", err) + } + + intervalParam, err := toLocalInterval(interval) + if err != nil { + return nil, fmt.Errorf("failed to get interval: %w", err) + } + + req := e.client.NewGetCandlesRequest().InstrumentID(toLocalSymbol(symbol)) + req.Bar(intervalParam) + + if options.StartTime != nil { + req.After(*options.StartTime) + } + + if options.EndTime != nil { + req.Before(*options.EndTime) + } + + candles, err := req.Do(ctx) + if err != nil { + return nil, err + } + + var klines []types.KLine + for _, candle := range candles { + klines = append(klines, kLineToGlobal(candle, interval, symbol)) + } + + return klines, nil + +} + +func (e *Exchange) QueryOrder(ctx context.Context, q types.OrderQuery) (*types.Order, error) { + if len(q.Symbol) == 0 { + return nil, ErrSymbolRequired + } + if len(q.OrderID) == 0 && len(q.ClientOrderID) == 0 { + return nil, errors.New("okex.QueryOrder: OrderId or ClientOrderId is required parameter") + } + req := e.client.NewGetOrderDetailsRequest() + req.InstrumentID(toLocalSymbol(q.Symbol)). + OrderID(q.OrderID). + ClientOrderID(q.ClientOrderID) + + var order *okexapi.OrderDetails + order, err := req.Do(ctx) + + if err != nil { + return nil, err + } + + return toGlobalOrder(order) +} + +// QueryOrderTrades quires order trades can query trades in last 3 months. +func (e *Exchange) QueryOrderTrades(ctx context.Context, q types.OrderQuery) (trades []types.Trade, err error) { + if len(q.ClientOrderID) != 0 { + log.Warn("!!!OKEX EXCHANGE API NOTICE!!! Okex does not support searching for trades using OrderClientId.") + } + + req := e.client.NewGetTransactionHistoryRequest() + if len(q.Symbol) != 0 { + req.InstrumentID(toLocalSymbol(q.Symbol)) + } + + if len(q.OrderID) != 0 { + req.OrderID(q.OrderID) + } + + if err := queryTradeLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("order trade rate limiter wait error: %w", err) + } + response, err := req.Do(ctx) + if err != nil { + return nil, fmt.Errorf("failed to query order trades, err: %w", err) + } + + for _, trade := range response { + trades = append(trades, tradeToGlobal(trade)) + } + + return trades, nil +} + +/* +QueryClosedOrders can query closed orders in last 3 months, there are no time interval limitations, as long as until >= since. +Please Use lastOrderID as cursor, only return orders later than that order, that order is not included. +If you want to query all orders within a large time range (e.g. total orders > 100), we recommend using batch.ClosedOrderBatchQuery. + +** since and until are inclusive, you can include the lastTradeId as well. ** +*/ +func (e *Exchange) QueryClosedOrders( + ctx context.Context, symbol string, since, until time.Time, lastOrderID uint64, +) (orders []types.Order, err error) { + if symbol == "" { + return nil, ErrSymbolRequired + } + + newSince := since + now := time.Now() + + if time.Since(newSince) > maxHistoricalDataQueryPeriod { + newSince = now.Add(-maxHistoricalDataQueryPeriod) + log.Warnf("!!!OKX EXCHANGE API NOTICE!!! The closed order API cannot query data beyond 90 days from the current date, update %s -> %s", since, newSince) + } + if until.Before(newSince) { + log.Warnf("!!!OKX EXCHANGE API NOTICE!!! The 'until' comes before 'since', update until to now(%s -> %s).", until, now) + until = now + } + if until.Sub(newSince) > maxHistoricalDataQueryPeriod { + return nil, fmt.Errorf("the start time %s and end time %s cannot exceed 90 days", newSince, until) + } + + if err := queryClosedOrderRateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("query closed order rate limiter wait error: %w", err) + } + + res, err := e.client.NewGetOrderHistoryRequest(). + InstrumentID(toLocalSymbol(symbol)). + StartTime(since). + EndTime(until). + Limit(defaultQueryLimit). + Before(strconv.FormatUint(lastOrderID, 10)). + Do(ctx) + + if err != nil { + return nil, fmt.Errorf("failed to call get order histories error: %w", err) + } + + for _, order := range res { + o, err2 := orderDetailToGlobal(&order) + if err2 != nil { + err = multierr.Append(err, err2) + continue + } + + orders = append(orders, *o) + } + if err != nil { + return nil, err + } + + return types.SortOrdersAscending(orders), nil +} + +/* +QueryTrades can query trades in last 3 months, there are no time interval limitations, as long as end_time >= start_time. +okx does not provide an API to query by trade ID, so we use the bill ID to do it. The trades result is ordered by timestamp. + +REMARK: If your start time is 90 days earlier, we will update it to now - 90 days. +** StartTime and EndTime are inclusive. ** +** StartTime and EndTime cannot exceed 90 days. ** +** StartTime, EndTime, FromTradeId can be used together. ** + +If you want to query all trades within a large time range (e.g. total orders > 100), we recommend using batch.TradeBatchQuery. +We don't support the last trade id as a filter because okx supports bill ID only. +*/ +func (e *Exchange) QueryTrades(ctx context.Context, symbol string, options *types.TradeQueryOptions) (trades []types.Trade, err error) { + if symbol == "" { + return nil, ErrSymbolRequired + } + + limit := options.Limit + if limit > defaultQueryLimit || limit <= 0 { + log.Infof("limit is exceeded default limit %d or zero, got: %d, use default limit", defaultQueryLimit, limit) + limit = defaultQueryLimit + } + + timeNow := e.timeNowFunc() + newStartTime := timeNow.Add(-threeDaysHistoricalPeriod) + if options.StartTime != nil { + newStartTime = *options.StartTime + if timeNow.Sub(newStartTime) > maxHistoricalDataQueryPeriod { + newStartTime = timeNow.Add(-maxHistoricalDataQueryPeriod) + log.Warnf("!!!OKX EXCHANGE API NOTICE!!! The trade API cannot query data beyond 90 days from the current date, update %s -> %s", *options.StartTime, newStartTime) + } + } + + endTime := timeNow + if options.EndTime != nil { + if options.EndTime.Before(newStartTime) { + return nil, fmt.Errorf("end time %s before start %s", *options.EndTime, newStartTime) + } + if options.EndTime.Sub(newStartTime) > maxHistoricalDataQueryPeriod { + return nil, fmt.Errorf("start time %s and end time %s cannot greater than 90 days", newStartTime, options.EndTime) + } + endTime = *options.EndTime + } + + if options.LastTradeID != 0 { + // we don't support the last trade id as a filter because okx supports bill ID only. + // we don't have any more fields (types.Trade) to store it. + log.Infof("Last trade id not supported on QueryTrades") + } + + if timeNow.Sub(newStartTime) <= threeDaysHistoricalPeriod { + c := e.client.NewGetThreeDaysTransactionHistoryRequest(). + InstrumentID(toLocalSymbol(symbol)). + StartTime(newStartTime). + EndTime(endTime). + Limit(uint64(limit)) + return getTrades(ctx, limit, func(ctx context.Context, billId string) ([]okexapi.Trade, error) { + c.Before(billId) + return c.Do(ctx) + }) + } + + c := e.client.NewGetTransactionHistoryRequest(). + InstrumentID(toLocalSymbol(symbol)). + StartTime(newStartTime). + EndTime(endTime). + Limit(uint64(limit)) + return getTrades(ctx, limit, func(ctx context.Context, billId string) ([]okexapi.Trade, error) { + c.Before(billId) + return c.Do(ctx) + }) +} + +func getTrades(ctx context.Context, limit int64, doFunc func(ctx context.Context, billId string) ([]okexapi.Trade, error)) (trades []types.Trade, err error) { + billId := "0" + for { + response, err := doFunc(ctx, billId) + if err != nil { + return nil, fmt.Errorf("failed to query trades, err: %w", err) + } + + for _, trade := range response { + trades = append(trades, tradeToGlobal(trade)) + } + + tradeLen := int64(len(response)) + // a defensive programming to ensure the length of order response is expected. + if tradeLen > limit { + return nil, fmt.Errorf("unexpected trade length %d", tradeLen) + } + + if tradeLen < limit { + break + } + // use Before filter to get all data. + billId = response[tradeLen-1].BillId.String() + } + return trades, nil +} + +func (e *Exchange) SupportedInterval() map[types.Interval]int { + return SupportedIntervals +} + +func (e *Exchange) IsSupportedInterval(interval types.Interval) bool { + _, ok := SupportedIntervals[interval] + return ok +} diff --git a/pkg/exchange/okex/exchange_test.go b/pkg/exchange/okex/exchange_test.go new file mode 100644 index 0000000..6813f64 --- /dev/null +++ b/pkg/exchange/okex/exchange_test.go @@ -0,0 +1,386 @@ +package okex + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/okex/okexapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/testing/httptesting" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func Test_clientOrderIdRegex(t *testing.T) { + t.Run("empty client order id", func(t *testing.T) { + assert.True(t, clientOrderIdRegex.MatchString("")) + }) + + t.Run("mixed of digit and char", func(t *testing.T) { + assert.True(t, clientOrderIdRegex.MatchString("1s2f3g4h5j")) + }) + + t.Run("mixed of 16 chars and 16 digit", func(t *testing.T) { + assert.True(t, clientOrderIdRegex.MatchString(strings.Repeat("s", 16)+strings.Repeat("1", 16))) + }) + + t.Run("out of maximum length", func(t *testing.T) { + assert.False(t, clientOrderIdRegex.MatchString(strings.Repeat("s", 33))) + }) + + t.Run("invalid char: `-`", func(t *testing.T) { + assert.False(t, clientOrderIdRegex.MatchString(uuid.NewString())) + }) +} + +func TestExchange_QueryTrades(t *testing.T) { + var ( + assert = assert.New(t) + ex = New("key", "secret", "passphrase") + expBtcSymbol = "BTCUSDT" + expLocalBtcSymbol = "BTC-USDT" + until = time.Now() + since = until.Add(-threeDaysHistoricalPeriod) + + options = &types.TradeQueryOptions{ + StartTime: &since, + EndTime: &until, + Limit: defaultQueryLimit, + LastTradeID: 0, + } + threeDayUrl = "/api/v5/trade/fills" + historyUrl = "/api/v5/trade/fills-history" + expOrder = []types.Trade{ + { + ID: 749554213, + OrderID: 688362711456706560, + Exchange: types.ExchangeOKEx, + Price: fixedpoint.MustNewFromString("73397.8"), + Quantity: fixedpoint.MustNewFromString("0.001"), + QuoteQuantity: fixedpoint.MustNewFromString("73.3978"), + Symbol: expBtcSymbol, + Side: types.SideTypeBuy, + IsBuyer: true, + IsMaker: false, + Time: types.Time(types.NewMillisecondTimestampFromInt(1710390459574).Time()), + Fee: fixedpoint.MustNewFromString("0.000001"), + FeeCurrency: "BTC", + }, + } + ) + ex.timeNowFunc = func() time.Time { return until } + + t.Run("3 days", func(t *testing.T) { + t.Run("succeeds with one record", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + // order history + historyOrderFile, err := os.ReadFile("okexapi/testdata/get_three_days_transaction_history_request.json") + assert.NoError(err) + + transport.GET(threeDayUrl, func(req *http.Request) (*http.Response, error) { + query := req.URL.Query() + assert.Len(query, 6) + assert.Contains(query, "begin") + assert.Contains(query, "end") + assert.Contains(query, "limit") + assert.Contains(query, "instId") + assert.Contains(query, "instType") + assert.Contains(query, "before") + assert.Equal(query["begin"], []string{strconv.FormatInt(since.UnixNano()/int64(time.Millisecond), 10)}) + assert.Equal(query["end"], []string{strconv.FormatInt(until.UnixNano()/int64(time.Millisecond), 10)}) + assert.Equal(query["limit"], []string{strconv.FormatInt(defaultQueryLimit, 10)}) + assert.Equal(query["instId"], []string{expLocalBtcSymbol}) + assert.Equal(query["instType"], []string{string(okexapi.InstrumentTypeSpot)}) + assert.Equal(query["before"], []string{"0"}) + return httptesting.BuildResponseString(http.StatusOK, string(historyOrderFile)), nil + }) + + orders, err := ex.QueryTrades(context.Background(), expBtcSymbol, options) + assert.NoError(err) + assert.Equal(expOrder, orders) + }) + + t.Run("succeeds with exceeded max records", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + + tradeId := 749554213 + billId := 688362711465447466 + dataTemplace := ` + { + "side":"buy", + "fillSz":"0.001", + "fillPx":"73397.8", + "fillPxVol":"", + "fillFwdPx":"", + "fee":"-0.000001", + "fillPnl":"0", + "ordId":"688362711456706560", + "feeRate":"-0.001", + "instType":"SPOT", + "fillPxUsd":"", + "instId":"BTC-USDT", + "clOrdId":"1229606897", + "posSide":"net", + "billId":"%d", + "fillMarkVol":"", + "tag":"", + "fillTime":"1710390459571", + "execType":"T", + "fillIdxPx":"", + "tradeId":"%d", + "fillMarkPx":"", + "feeCcy":"BTC", + "ts":"1710390459574" + }` + + tradesStr := make([]string, 0, defaultQueryLimit+1) + expTrades := make([]types.Trade, 0, defaultQueryLimit+1) + for i := 0; i < defaultQueryLimit+1; i++ { + dataStr := fmt.Sprintf(dataTemplace, billId+i, tradeId+i) + tradesStr = append(tradesStr, dataStr) + + trade := &okexapi.Trade{} + err := json.Unmarshal([]byte(dataStr), &trade) + assert.NoError(err) + expTrades = append(expTrades, tradeToGlobal(*trade)) + } + + transport.GET(threeDayUrl, func(req *http.Request) (*http.Response, error) { + query := req.URL.Query() + assert.Contains(query, "begin") + assert.Contains(query, "end") + assert.Contains(query, "limit") + assert.Contains(query, "instId") + assert.Contains(query, "instType") + assert.Equal(query["begin"], []string{strconv.FormatInt(since.UnixNano()/int64(time.Millisecond), 10)}) + assert.Equal(query["end"], []string{strconv.FormatInt(until.UnixNano()/int64(time.Millisecond), 10)}) + assert.Equal(query["limit"], []string{strconv.FormatInt(defaultQueryLimit, 10)}) + assert.Equal(query["instId"], []string{expLocalBtcSymbol}) + assert.Equal(query["instType"], []string{string(okexapi.InstrumentTypeSpot)}) + assert.Len(query, 6) + + if query["before"][0] == "0" { + resp := &okexapi.APIResponse{ + Code: "0", + Data: []byte("[" + strings.Join(tradesStr[0:defaultQueryLimit], ",") + "]"), + } + respRaw, err := json.Marshal(resp) + assert.NoError(err) + + return httptesting.BuildResponseString(http.StatusOK, string(respRaw)), nil + } + + // second time query + // last order id, so need to -1 + assert.Equal(query["before"], []string{strconv.FormatInt(int64(billId+defaultQueryLimit-1), 10)}) + + resp := okexapi.APIResponse{ + Code: "0", + Data: []byte("[" + strings.Join(tradesStr[defaultQueryLimit:defaultQueryLimit+1], ",") + "]"), + } + respRaw, err := json.Marshal(resp) + assert.NoError(err) + return httptesting.BuildResponseString(http.StatusOK, string(respRaw)), nil + }) + + trades, err := ex.QueryTrades(context.Background(), expBtcSymbol, options) + assert.NoError(err) + assert.Equal(expTrades, trades) + }) + }) + t.Run("3 days < x < Max days", func(t *testing.T) { + t.Run("succeeds with one record", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + newSince := until.Add(-maxHistoricalDataQueryPeriod) + options.StartTime = &newSince + + // order history + historyOrderFile, err := os.ReadFile("okexapi/testdata/get_transaction_history_request.json") + assert.NoError(err) + + transport.GET(historyUrl, func(req *http.Request) (*http.Response, error) { + query := req.URL.Query() + assert.Len(query, 6) + assert.Contains(query, "begin") + assert.Contains(query, "end") + assert.Contains(query, "limit") + assert.Contains(query, "instId") + assert.Contains(query, "instType") + assert.Contains(query, "before") + assert.Equal(query["begin"], []string{strconv.FormatInt(newSince.UnixNano()/int64(time.Millisecond), 10)}) + assert.Equal(query["end"], []string{strconv.FormatInt(until.UnixNano()/int64(time.Millisecond), 10)}) + assert.Equal(query["limit"], []string{strconv.FormatInt(defaultQueryLimit, 10)}) + assert.Equal(query["instId"], []string{expLocalBtcSymbol}) + assert.Equal(query["instType"], []string{string(okexapi.InstrumentTypeSpot)}) + assert.Equal(query["before"], []string{"0"}) + return httptesting.BuildResponseString(http.StatusOK, string(historyOrderFile)), nil + }) + + orders, err := ex.QueryTrades(context.Background(), expBtcSymbol, options) + assert.NoError(err) + assert.Equal(expOrder, orders) + }) + + t.Run("succeeds with exceeded max records", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + newSince := until.Add(-maxHistoricalDataQueryPeriod) + options.StartTime = &newSince + + tradeId := 749554213 + billId := 688362711465447466 + dataTemplace := ` + { + "side":"buy", + "fillSz":"0.001", + "fillPx":"73397.8", + "fillPxVol":"", + "fillFwdPx":"", + "fee":"-0.000001", + "fillPnl":"0", + "ordId":"688362711456706560", + "feeRate":"-0.001", + "instType":"SPOT", + "fillPxUsd":"", + "instId":"BTC-USDT", + "clOrdId":"1229606897", + "posSide":"net", + "billId":"%d", + "fillMarkVol":"", + "tag":"", + "fillTime":"1710390459571", + "execType":"T", + "fillIdxPx":"", + "tradeId":"%d", + "fillMarkPx":"", + "feeCcy":"BTC", + "ts":"1710390459574" + }` + + tradesStr := make([]string, 0, defaultQueryLimit+1) + expTrades := make([]types.Trade, 0, defaultQueryLimit+1) + for i := 0; i < defaultQueryLimit+1; i++ { + dataStr := fmt.Sprintf(dataTemplace, billId+i, tradeId+i) + tradesStr = append(tradesStr, dataStr) + + trade := &okexapi.Trade{} + err := json.Unmarshal([]byte(dataStr), &trade) + assert.NoError(err) + expTrades = append(expTrades, tradeToGlobal(*trade)) + } + + transport.GET(historyUrl, func(req *http.Request) (*http.Response, error) { + query := req.URL.Query() + assert.Contains(query, "begin") + assert.Contains(query, "end") + assert.Contains(query, "limit") + assert.Contains(query, "instId") + assert.Contains(query, "instType") + assert.Equal(query["begin"], []string{strconv.FormatInt(newSince.UnixNano()/int64(time.Millisecond), 10)}) + assert.Equal(query["end"], []string{strconv.FormatInt(until.UnixNano()/int64(time.Millisecond), 10)}) + assert.Equal(query["limit"], []string{strconv.FormatInt(defaultQueryLimit, 10)}) + assert.Equal(query["instId"], []string{expLocalBtcSymbol}) + assert.Equal(query["instType"], []string{string(okexapi.InstrumentTypeSpot)}) + assert.Len(query, 6) + + if query["before"][0] == "0" { + resp := &okexapi.APIResponse{ + Code: "0", + Data: []byte("[" + strings.Join(tradesStr[0:defaultQueryLimit], ",") + "]"), + } + respRaw, err := json.Marshal(resp) + assert.NoError(err) + + return httptesting.BuildResponseString(http.StatusOK, string(respRaw)), nil + } + + // second time query + // last order id, so need to -1 + assert.Equal(query["before"], []string{strconv.FormatInt(int64(billId+defaultQueryLimit-1), 10)}) + + resp := okexapi.APIResponse{ + Code: "0", + Data: []byte("[" + strings.Join(tradesStr[defaultQueryLimit:defaultQueryLimit+1], ",") + "]"), + } + respRaw, err := json.Marshal(resp) + assert.NoError(err) + return httptesting.BuildResponseString(http.StatusOK, string(respRaw)), nil + }) + + trades, err := ex.QueryTrades(context.Background(), expBtcSymbol, options) + assert.NoError(err) + assert.Equal(expTrades, trades) + }) + }) + + t.Run("start time exceeded 3 months", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + newSince := options.StartTime.Add(-365 * 24 * time.Hour) + newOpts := *options + newOpts.StartTime = &newSince + + expSinceTime := until.Add(-maxHistoricalDataQueryPeriod) + + // order history + historyOrderFile, err := os.ReadFile("okexapi/testdata/get_three_days_transaction_history_request.json") + assert.NoError(err) + + transport.GET(historyUrl, func(req *http.Request) (*http.Response, error) { + query := req.URL.Query() + assert.Len(query, 6) + assert.Contains(query, "begin") + assert.Contains(query, "end") + assert.Contains(query, "limit") + assert.Contains(query, "instId") + assert.Contains(query, "instType") + assert.Contains(query, "before") + assert.Equal(query["begin"], []string{strconv.FormatInt(expSinceTime.UnixNano()/int64(time.Millisecond), 10)}) + assert.Equal(query["end"], []string{strconv.FormatInt(until.UnixNano()/int64(time.Millisecond), 10)}) + assert.Equal(query["limit"], []string{strconv.FormatInt(defaultQueryLimit, 10)}) + assert.Equal(query["instId"], []string{expLocalBtcSymbol}) + assert.Equal(query["instType"], []string{string(okexapi.InstrumentTypeSpot)}) + assert.Equal(query["before"], []string{"0"}) + return httptesting.BuildResponseString(http.StatusOK, string(historyOrderFile)), nil + }) + + orders, err := ex.QueryTrades(context.Background(), expBtcSymbol, &newOpts) + assert.NoError(err) + assert.Equal(expOrder, orders) + }) + + t.Run("start time after end day", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + newSince := options.StartTime.Add(365 * 24 * time.Hour) + newOpts := *options + newOpts.StartTime = &newSince + + _, err := ex.QueryTrades(context.Background(), expBtcSymbol, &newOpts) + assert.ErrorContains(err, "before start") + }) + + t.Run("empty symbol", func(t *testing.T) { + transport := &httptesting.MockTransport{} + ex.client.HttpClient.Transport = transport + newSince := options.StartTime.Add(365 * 24 * time.Hour) + newOpts := *options + newOpts.StartTime = &newSince + + _, err := ex.QueryTrades(context.Background(), "", &newOpts) + assert.ErrorContains(err, ErrSymbolRequired.Error()) + }) +} diff --git a/pkg/exchange/okex/gensymbols.go b/pkg/exchange/okex/gensymbols.go new file mode 100644 index 0000000..1aaa4b1 --- /dev/null +++ b/pkg/exchange/okex/gensymbols.go @@ -0,0 +1,52 @@ +//go:build ignore +// +build ignore + +package main + +import ( + "context" + "log" + "os" + "strings" + "text/template" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/okex/okexapi" +) + +var packageTemplate = template.Must(template.New("").Parse(`// Code generated by go generate; DO NOT EDIT. +package okex + +var spotSymbolMap = map[string]string{ +{{- range $k, $v := . }} + {{ printf "%q" $k }}: {{ printf "%q" $v }}, +{{- end }} +} + +`)) + +func main() { + ctx := context.Background() + client := okexapi.NewClient() + instruments, err := client.NewGetInstrumentsInfoRequest().InstType(okexapi.InstrumentTypeSpot).Do(ctx) + if err != nil { + log.Fatal(err) + } + + var data = map[string]string{} + for _, instrument := range instruments { + symbol := strings.ReplaceAll(instrument.InstrumentID, "-", "") + data[symbol] = instrument.InstrumentID + } + + f, err := os.Create("symbols.go") + if err != nil { + log.Fatal(err) + } + + defer f.Close() + + err = packageTemplate.Execute(f, data) + if err != nil { + log.Fatal(err) + } +} diff --git a/pkg/exchange/okex/kline_stream.go b/pkg/exchange/okex/kline_stream.go new file mode 100644 index 0000000..cd3939d --- /dev/null +++ b/pkg/exchange/okex/kline_stream.go @@ -0,0 +1,94 @@ +package okex + +import ( + "context" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/okex/okexapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +//go:generate callbackgen -type KLineStream -interface +type KLineStream struct { + types.StandardStream + + kLineEventCallbacks []func(candle KLineEvent) +} + +func NewKLineStream() *KLineStream { + k := &KLineStream{ + StandardStream: types.NewStandardStream(), + } + + k.SetParser(parseWebSocketEvent) + k.SetDispatcher(k.dispatchEvent) + k.SetEndpointCreator(func(_ context.Context) (string, error) { return okexapi.PublicBusinessWebSocketURL, nil }) + k.SetPingInterval(pingInterval) + + // K line channel is public only + k.SetPublicOnly() + k.OnConnect(k.handleConnect) + k.OnKLineEvent(k.handleKLineEvent) + + return k +} + +func (s *KLineStream) handleConnect() { + var subs []WebsocketSubscription + for _, subscription := range s.Subscriptions { + if subscription.Channel != types.KLineChannel { + continue + } + + sub, err := convertSubscription(subscription) + if err != nil { + log.WithError(err).Errorf("subscription convert error") + continue + } + + subs = append(subs, sub) + } + subscribe(s.Conn, subs) +} + +func (s *KLineStream) Connect(ctx context.Context) error { + if len(s.StandardStream.Subscriptions) == 0 { + log.Info("no subscriptions in kline") + return nil + } + return s.StandardStream.Connect(ctx) +} + +func (s *KLineStream) handleKLineEvent(k KLineEvent) { + for _, event := range k.Events { + kline := kLineToGlobal(event, types.Interval(k.Interval), k.Symbol) + if kline.Closed { + s.EmitKLineClosed(kline) + } else { + s.EmitKLine(kline) + } + } +} + +func (s *KLineStream) dispatchEvent(e interface{}) { + switch et := e.(type) { + case *WebSocketEvent: + if err := et.IsValid(); err != nil { + log.Errorf("invalid event: %v", err) + return + } + + case *KLineEvent: + s.EmitKLineEvent(*et) + } +} + +func (s *KLineStream) Unsubscribe() { + // errors are handled in the syncSubscriptions, so they are skipped here. + if len(s.StandardStream.Subscriptions) != 0 { + _ = syncSubscriptions(s.StandardStream.Conn, s.StandardStream.Subscriptions, WsEventTypeUnsubscribe) + } + s.Resubscribe(func(old []types.Subscription) (new []types.Subscription, err error) { + // clear the subscriptions + return []types.Subscription{}, nil + }) +} diff --git a/pkg/exchange/okex/klinestream_callbacks.go b/pkg/exchange/okex/klinestream_callbacks.go new file mode 100644 index 0000000..fc7e91e --- /dev/null +++ b/pkg/exchange/okex/klinestream_callbacks.go @@ -0,0 +1,19 @@ +// Code generated by "callbackgen -type KLineStream -interface"; DO NOT EDIT. + +package okex + +import () + +func (K *KLineStream) OnKLineEvent(cb func(candle KLineEvent)) { + K.kLineEventCallbacks = append(K.kLineEventCallbacks, cb) +} + +func (K *KLineStream) EmitKLineEvent(candle KLineEvent) { + for _, cb := range K.kLineEventCallbacks { + cb(candle) + } +} + +type KLineStreamEventHub interface { + OnKLineEvent(cb func(candle KLineEvent)) +} diff --git a/pkg/exchange/okex/okexapi/cancel_order_request.go b/pkg/exchange/okex/okexapi/cancel_order_request.go new file mode 100644 index 0000000..3b5cd40 --- /dev/null +++ b/pkg/exchange/okex/okexapi/cancel_order_request.go @@ -0,0 +1,21 @@ +package okexapi + +import "github.com/c9s/requestgen" + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +//go:generate PostRequest -url "/api/v5/trade/cancel-order" -type CancelOrderRequest -responseDataType []OrderResponse +type CancelOrderRequest struct { + client requestgen.AuthenticatedAPIClient + + instrumentID string `param:"instId"` + orderID *string `param:"ordId"` + clientOrderID *string `param:"clOrdId"` +} + +func (c *RestClient) NewCancelOrderRequest() *CancelOrderRequest { + return &CancelOrderRequest{ + client: c, + } +} diff --git a/pkg/exchange/okex/okexapi/cancel_order_request_requestgen.go b/pkg/exchange/okex/okexapi/cancel_order_request_requestgen.go new file mode 100644 index 0000000..ad550a6 --- /dev/null +++ b/pkg/exchange/okex/okexapi/cancel_order_request_requestgen.go @@ -0,0 +1,195 @@ +// Code generated by "requestgen -method POST -responseType .APIResponse -responseDataField Data -url /api/v5/trade/cancel-order -type CancelOrderRequest -responseDataType []OrderResponse"; DO NOT EDIT. + +package okexapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (c *CancelOrderRequest) InstrumentID(instrumentID string) *CancelOrderRequest { + c.instrumentID = instrumentID + return c +} + +func (c *CancelOrderRequest) OrderID(orderID string) *CancelOrderRequest { + c.orderID = &orderID + return c +} + +func (c *CancelOrderRequest) ClientOrderID(clientOrderID string) *CancelOrderRequest { + c.clientOrderID = &clientOrderID + return c +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (c *CancelOrderRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (c *CancelOrderRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check instrumentID field -> json key instId + instrumentID := c.instrumentID + + // assign parameter of instrumentID + params["instId"] = instrumentID + // check orderID field -> json key ordId + if c.orderID != nil { + orderID := *c.orderID + + // assign parameter of orderID + params["ordId"] = orderID + } else { + } + // check clientOrderID field -> json key clOrdId + if c.clientOrderID != nil { + clientOrderID := *c.clientOrderID + + // assign parameter of clientOrderID + params["clOrdId"] = clientOrderID + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (c *CancelOrderRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := c.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if c.isVarSlice(_v) { + c.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (c *CancelOrderRequest) GetParametersJSON() ([]byte, error) { + params, err := c.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (c *CancelOrderRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (c *CancelOrderRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (c *CancelOrderRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (c *CancelOrderRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (c *CancelOrderRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := c.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (c *CancelOrderRequest) GetPath() string { + return "/api/v5/trade/cancel-order" +} + +// Do generates the request object and send the request object to the API endpoint +func (c *CancelOrderRequest) Do(ctx context.Context) ([]OrderResponse, error) { + + params, err := c.GetParameters() + if err != nil { + return nil, err + } + query := url.Values{} + + var apiURL string + + apiURL = c.GetPath() + + req, err := c.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := c.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data []OrderResponse + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/okex/okexapi/client.go b/pkg/exchange/okex/okexapi/client.go new file mode 100644 index 0000000..76c6d94 --- /dev/null +++ b/pkg/exchange/okex/okexapi/client.go @@ -0,0 +1,260 @@ +package okexapi + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "github.com/c9s/requestgen" + "github.com/pkg/errors" +) + +const defaultHTTPTimeout = time.Second * 15 +const RestBaseURL = "https://aws.okx.com/" +const PublicWebSocketURL = "wss://wsaws.okx.com:8443/ws/v5/public" +const PrivateWebSocketURL = "wss://wsaws.okx.com:8443/ws/v5/private" +const PublicBusinessWebSocketURL = "wss://wsaws.okx.com:8443/ws/v5/business" + +type SideType string + +const ( + SideTypeBuy SideType = "buy" + SideTypeSell SideType = "sell" +) + +type OrderType string + +const ( + OrderTypeMarket OrderType = "market" + OrderTypeLimit OrderType = "limit" + OrderTypePostOnly OrderType = "post_only" + OrderTypeFOK OrderType = "fok" + OrderTypeIOC OrderType = "ioc" +) + +type InstrumentType string + +const ( + InstrumentTypeSpot InstrumentType = "SPOT" + InstrumentTypeSwap InstrumentType = "SWAP" + InstrumentTypeFutures InstrumentType = "FUTURES" + InstrumentTypeOption InstrumentType = "OPTION" + InstrumentTypeMARGIN InstrumentType = "MARGIN" +) + +type OrderState string + +const ( + OrderStateCanceled OrderState = "canceled" + OrderStateLive OrderState = "live" + OrderStatePartiallyFilled OrderState = "partially_filled" + OrderStateFilled OrderState = "filled" +) + +func (o OrderState) IsWorking() bool { + return o == OrderStateLive || o == OrderStatePartiallyFilled +} + +type RestClient struct { + requestgen.BaseAPIClient + + Key, Secret, Passphrase string +} + +var parsedBaseURL *url.URL + +func init() { + url, err := url.Parse(RestBaseURL) + if err != nil { + panic(err) + } + parsedBaseURL = url +} + +func NewClient() *RestClient { + client := &RestClient{ + BaseAPIClient: requestgen.BaseAPIClient{ + BaseURL: parsedBaseURL, + HttpClient: &http.Client{ + Timeout: defaultHTTPTimeout, + }, + }, + } + return client +} + +func (c *RestClient) Auth(key, secret, passphrase string) { + c.Key = key + // pragma: allowlist nextline secret + c.Secret = secret + c.Passphrase = passphrase +} + +// NewAuthenticatedRequest creates new http request for authenticated routes. +func (c *RestClient) NewAuthenticatedRequest(ctx context.Context, method, refURL string, params url.Values, payload interface{}) (*http.Request, error) { + if len(c.Key) == 0 { + return nil, errors.New("empty api key") + } + + if len(c.Secret) == 0 { + return nil, errors.New("empty api secret") + } + + rel, err := url.Parse(refURL) + if err != nil { + return nil, err + } + + if params != nil { + rel.RawQuery = params.Encode() + } + + pathURL := c.BaseURL.ResolveReference(rel) + path := pathURL.Path + if rel.RawQuery != "" { + path += "?" + rel.RawQuery + } + + // set location to UTC so that it outputs "2020-12-08T09:08:57.715Z" + t := time.Now().In(time.UTC) + timestamp := t.Format("2006-01-02T15:04:05.999Z07:00") + + var body []byte + + if payload != nil { + switch v := payload.(type) { + case string: + body = []byte(v) + + case []byte: + body = v + + default: + body, err = json.Marshal(v) + if err != nil { + return nil, err + } + } + } + + signKey := timestamp + strings.ToUpper(method) + path + string(body) + signature := Sign(signKey, c.Secret) + + req, err := http.NewRequest(method, pathURL.String(), bytes.NewReader(body)) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Accept", "application/json") + req.Header.Add("OK-ACCESS-KEY", c.Key) + req.Header.Add("OK-ACCESS-SIGN", signature) + req.Header.Add("OK-ACCESS-TIMESTAMP", timestamp) + req.Header.Add("OK-ACCESS-PASSPHRASE", c.Passphrase) + return req, nil +} + +type AssetBalance struct { + Currency string `json:"ccy"` + Balance fixedpoint.Value `json:"bal"` + Frozen fixedpoint.Value `json:"frozenBal,omitempty"` + Available fixedpoint.Value `json:"availBal,omitempty"` +} + +type AssetBalanceList []AssetBalance + +func (c *RestClient) AssetBalances(ctx context.Context) (AssetBalanceList, error) { + req, err := c.NewAuthenticatedRequest(ctx, "GET", "/api/v5/asset/balances", nil, nil) + if err != nil { + return nil, err + } + + response, err := c.SendRequest(req) + if err != nil { + return nil, err + } + + var balanceResponse struct { + Code string `json:"code"` + Message string `json:"msg"` + Data AssetBalanceList `json:"data"` + } + if err := response.DecodeJSON(&balanceResponse); err != nil { + return nil, err + } + + return balanceResponse.Data, nil +} + +type AssetCurrency struct { + Currency string `json:"ccy"` + Name string `json:"name"` + Chain string `json:"chain"` + CanDeposit bool `json:"canDep"` + CanWithdraw bool `json:"canWd"` + CanInternal bool `json:"canInternal"` + MinWithdrawalFee fixedpoint.Value `json:"minFee"` + MaxWithdrawalFee fixedpoint.Value `json:"maxFee"` + MinWithdrawalThreshold fixedpoint.Value `json:"minWd"` +} + +func (c *RestClient) AssetCurrencies(ctx context.Context) ([]AssetCurrency, error) { + req, err := c.NewAuthenticatedRequest(ctx, "GET", "/api/v5/asset/currencies", nil, nil) + if err != nil { + return nil, err + } + + response, err := c.SendRequest(req) + if err != nil { + return nil, err + } + + var currencyResponse struct { + Code string `json:"code"` + Message string `json:"msg"` + Data []AssetCurrency `json:"data"` + } + + if err := response.DecodeJSON(¤cyResponse); err != nil { + return nil, err + } + + return currencyResponse.Data, nil +} + +func Sign(payload string, secret string) string { + var sig = hmac.New(sha256.New, []byte(secret)) + _, err := sig.Write([]byte(payload)) + if err != nil { + return "" + } + + return base64.StdEncoding.EncodeToString(sig.Sum(nil)) + // return hex.EncodeToString(sig.Sum(nil)) +} + +type APIResponse struct { + Code string `json:"code"` + Message string `json:"msg"` + Data json.RawMessage `json:"data"` +} + +func (a APIResponse) Validate() error { + if a.Code != "0" { + return a.Error() + } + return nil +} + +func (a APIResponse) Error() error { + return fmt.Errorf("retCode: %s, retMsg: %s, data: %s", a.Code, a.Message, string(a.Data)) +} diff --git a/pkg/exchange/okex/okexapi/client_test.go b/pkg/exchange/okex/okexapi/client_test.go new file mode 100644 index 0000000..c6d155d --- /dev/null +++ b/pkg/exchange/okex/okexapi/client_test.go @@ -0,0 +1,348 @@ +package okexapi + +import ( + "context" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "os" + "strconv" + "testing" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/testutil" +) + +func getTestClientOrSkip(t *testing.T) *RestClient { + if b, _ := strconv.ParseBool(os.Getenv("CI")); b { + t.Skip("skip test for CI") + } + + key, secret, passphrase, ok := testutil.IntegrationTestWithPassphraseConfigured(t, "OKEX") + if !ok { + t.Skip("Please configure all credentials about OKEX") + return nil + } + + client := NewClient() + client.Auth(key, secret, passphrase) + return client +} + +func TestClient_GetInstrumentsRequest(t *testing.T) { + client := NewClient() + ctx := context.Background() + req := client.NewGetInstrumentsInfoRequest() + + instruments, err := req.Do(ctx) + assert.NoError(t, err) + assert.NotEmpty(t, instruments) + t.Logf("instruments: %+v", instruments) +} + +func TestClient_GetMarketTickers(t *testing.T) { + client := NewClient() + ctx := context.Background() + req := client.NewGetTickersRequest() + + tickers, err := req.Do(ctx) + assert.NoError(t, err) + assert.NotEmpty(t, tickers) + t.Logf("tickers: %+v", tickers) +} + +func TestClient_GetMarketTicker(t *testing.T) { + client := NewClient() + ctx := context.Background() + req := client.NewGetTickerRequest().InstId("BTC-USDT") + + tickers, err := req.Do(ctx) + assert.NoError(t, err) + assert.NotEmpty(t, tickers) + t.Logf("tickers: %+v", tickers) +} + +func TestClient_GetAcountInfo(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + req := client.NewGetAccountInfoRequest() + + acct, err := req.Do(ctx) + assert.NoError(t, err) + assert.NotEmpty(t, acct) + t.Logf("acct: %+v", acct) +} + +func TestClient_GetFundingRateRequest(t *testing.T) { + client := NewClient() + ctx := context.Background() + req := client.NewGetFundingRate() + + instrument, err := req. + InstrumentID("BTC-USDT-SWAP"). + Do(ctx) + assert.NoError(t, err) + assert.NotEmpty(t, instrument) + t.Logf("instrument: %+v", instrument) +} + +func TestClient_PlaceOrderRequest(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + req := client.NewPlaceOrderRequest() + + order, err := req. + InstrumentID("BTC-USDT"). + TradeMode(TradeModeCash). + Side(SideTypeSell). + OrderType(OrderTypeLimit). + TargetCurrency(TargetCurrencyBase). + Price("48000"). + Size("0.001"). + Do(ctx) + assert.NoError(t, err) + assert.NotEmpty(t, order) + t.Logf("place order: %+v", order) + + c := client.NewGetOrderDetailsRequest().OrderID(order[0].OrderID).InstrumentID("BTC-USDT") + res, err := c.Do(ctx) + assert.NoError(t, err) + t.Log(res) +} + +func TestClient_CancelOrderRequest(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + req := client.NewPlaceOrderRequest() + clientId := fmt.Sprintf("%d", uuid.New().ID()) + + order, err := req. + InstrumentID("BTC-USDT"). + TradeMode(TradeModeCash). + Side(SideTypeSell). + OrderType(OrderTypeLimit). + TargetCurrency(TargetCurrencyBase). + ClientOrderID(clientId). + Price("48000"). + Size("0.001"). + Do(ctx) + assert.NoError(t, err) + assert.NotEmpty(t, order) + t.Logf("place order: %+v", order) + + c := client.NewGetOrderDetailsRequest().ClientOrderID(clientId).InstrumentID("BTC-USDT") + res, err := c.Do(ctx) + assert.NoError(t, err) + t.Log(res) + + cancelResp, err := client.NewCancelOrderRequest().ClientOrderID(clientId).InstrumentID("BTC-USDT").Do(ctx) + assert.NoError(t, err) + t.Log(cancelResp) +} + +func TestClient_OpenOrdersRequest(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + + orders := []OpenOrder{} + beforeId := int64(0) + for { + c := client.NewGetOpenOrdersRequest().InstrumentID("BTC-USDT").Limit("1").After(fmt.Sprintf("%d", beforeId)) + res, err := c.Do(ctx) + assert.NoError(t, err) + if len(res) != 1 { + break + } + orders = append(orders, res...) + beforeId = int64(res[0].OrderId) + } + + t.Log(orders) +} + +func TestClient_OrderHistoryWithBeforeId(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + + orders := []OrderDetail{} + beforeId := int64(0) + for { + //>> [{"accFillSz":"0.00001","algoClOrdId":"","algoId":"","attachAlgoClOrdId":"","attachAlgoOrds":[],"avgPx":"48174.5","cTime":"1704957916401","cancelSource":"","cancelSourceReason":"","category":"normal","ccy":"","clOrdId":"","fee":"-0.000385396","feeCcy":"USDT","fillPx":"48174.5","fillSz":"0.00001","fillTime":"1704983881118","instId":"BTC-USDT","instType":"SPOT","lever":"","ordId":"665576973905014786","ordType":"limit","pnl":"0","posSide":"","px":"48174.5","pxType":"","pxUsd":"","pxVol":"","quickMgnType":"","rebate":"0","rebateCcy":"BTC","reduceOnly":"false","side":"sell","slOrdPx":"","slTriggerPx":"","slTriggerPxType":"","source":"","state":"filled","stpId":"","stpMode":"","sz":"0.00001","tag":"","tdMode":"cash","tgtCcy":"","tpOrdPx":"","tpTriggerPx":"","tpTriggerPxType":"","tradeId":"472610696","uTime":"1704983881135"}] + //>> [{"accFillSz":"0.00001","algoClOrdId":"","algoId":"","attachAlgoClOrdId":"","attachAlgoOrds":[],"avgPx":"48074.5","cTime":"1704957905283","cancelSource":"","cancelSourceReason":"","category":"normal","ccy":"","clOrdId":"","fee":"-0.000384596","feeCcy":"USDT","fillPx":"48074.5","fillSz":"0.00001","fillTime":"1704983824237","instId":"BTC-USDT","instType":"SPOT","lever":"","ordId":"665576927272742919","ordType":"limit","pnl":"0","posSide":"","px":"48074.5","pxType":"","pxUsd":"","pxVol":"","quickMgnType":"","rebate":"0","rebateCcy":"BTC","reduceOnly":"false","side":"sell","slOrdPx":"","slTriggerPx":"","slTriggerPxType":"","source":"","state":"filled","stpId":"","stpMode":"","sz":"0.00001","tag":"","tdMode":"cash","tgtCcy":"","tpOrdPx":"","tpTriggerPx":"","tpTriggerPxType":"","tradeId":"472601591","uTime":"1704983824240"}] + //>> [{"accFillSz":"0.00001","algoClOrdId":"","algoId":"","attachAlgoClOrdId":"","attachAlgoOrds":[],"avgPx":"48073.5","cTime":"1704957892896","cancelSource":"","cancelSourceReason":"","category":"normal","ccy":"","clOrdId":"","fee":"-0.000384588","feeCcy":"USDT","fillPx":"48073.5","fillSz":"0.00001","fillTime":"1704983824227","instId":"BTC-USDT","instType":"SPOT","lever":"","ordId":"665576875317899302","ordType":"limit","pnl":"0","posSide":"","px":"48073.5","pxType":"","pxUsd":"","pxVol":"","quickMgnType":"","rebate":"0","rebateCcy":"BTC","reduceOnly":"false","side":"sell","slOrdPx":"","slTriggerPx":"","slTriggerPxType":"","source":"","state":"filled","stpId":"","stpMode":"","sz":"0.00001","tag":"","tdMode":"cash","tgtCcy":"","tpOrdPx":"","tpTriggerPx":"","tpTriggerPxType":"","tradeId":"472601583","uTime":"1704983824230"}] + //>> [{"accFillSz":"0.00016266","algoClOrdId":"","algoId":"","attachAlgoClOrdId":"","attachAlgoOrds":[],"avgPx":"45919.8","cTime":"1704852215160","cancelSource":"","cancelSourceReason":"","category":"normal","ccy":"","clOrdId":"","fee":"-0.00000016266","feeCcy":"BTC","fillPx":"45919.8","fillSz":"0.00016266","fillTime":"1704852215162","instId":"BTC-USDT","instType":"SPOT","lever":"","ordId":"665133630767091729","ordType":"market","pnl":"0","posSide":"","px":"","pxType":"","pxUsd":"","pxVol":"","quickMgnType":"","rebate":"0","rebateCcy":"USDT","reduceOnly":"false","side":"buy","slOrdPx":"","slTriggerPx":"","slTriggerPxType":"","source":"","state":"filled","stpId":"","stpMode":"","sz":"0.00016266","tag":"","tdMode":"cash","tgtCcy":"base_ccy","tpOrdPx":"","tpTriggerPx":"","tpTriggerPxType":"","tradeId":"471113058","uTime":"1704852215163"}] + //>> [{"accFillSz":"0.00087627","algoClOrdId":"","algoId":"","attachAlgoClOrdId":"","attachAlgoOrds":[],"avgPx":"45647.6","cTime":"1704850530651","cancelSource":"","cancelSourceReason":"","category":"normal","ccy":"","clOrdId":"","fee":"-0.00000087627","feeCcy":"BTC","fillPx":"45647.6","fillSz":"0.00087627","fillTime":"1704850530652","instId":"BTC-USDT","instType":"SPOT","lever":"","ordId":"665126565424254976","ordType":"market","pnl":"0","posSide":"","px":"","pxType":"","pxUsd":"","pxVol":"","quickMgnType":"","rebate":"0","rebateCcy":"USDT","reduceOnly":"false","side":"buy","slOrdPx":"","slTriggerPx":"","slTriggerPxType":"","source":"","state":"filled","stpId":"","stpMode":"","sz":"40","tag":"","tdMode":"cash","tgtCcy":"quote_ccy","tpOrdPx":"","tpTriggerPx":"","tpTriggerPxType":"","tradeId":"471105716","uTime":"1704850530654"}] + //>> [{"accFillSz":"0.001","algoClOrdId":"","algoId":"","attachAlgoClOrdId":"","attachAlgoOrds":[],"avgPx":"45661.3","cTime":"1704850506060","cancelSource":"","cancelSourceReason":"","category":"normal","ccy":"","clOrdId":"","fee":"-0.0456613","feeCcy":"USDT","fillPx":"45661.3","fillSz":"0.001","fillTime":"1704850506061","instId":"BTC-USDT","instType":"SPOT","lever":"","ordId":"665126462282125313","ordType":"market","pnl":"0","posSide":"","px":"","pxType":"","pxUsd":"","pxVol":"","quickMgnType":"","rebate":"0","rebateCcy":"BTC","reduceOnly":"false","side":"sell","slOrdPx":"","slTriggerPx":"","slTriggerPxType":"","source":"","state":"filled","stpId":"","stpMode":"","sz":"0.001","tag":"","tdMode":"cash","tgtCcy":"base_ccy","tpOrdPx":"","tpTriggerPx":"","tpTriggerPxType":"","tradeId":"471105593","uTime":"1704850506062"}] + //>> [{"accFillSz":"0.00097361","algoClOrdId":"","algoId":"","attachAlgoClOrdId":"","attachAlgoOrds":[],"avgPx":"45743","cTime":"1704849690516","cancelSource":"","cancelSourceReason":"","category":"normal","ccy":"","clOrdId":"","fee":"-0.00000097361","feeCcy":"BTC","fillPx":"45743","fillSz":"0.00097361","fillTime":"1704849690517","instId":"BTC-USDT","instType":"SPOT","lever":"","ordId":"665123041642663944","ordType":"market","pnl":"0","posSide":"","px":"","pxType":"","pxUsd":"","pxVol":"","quickMgnType":"","rebate":"0","rebateCcy":"USDT","reduceOnly":"false","side":"buy","slOrdPx":"","slTriggerPx":"","slTriggerPxType":"","source":"","state":"filled","stpId":"","stpMode":"","sz":"0.00097361","tag":"","tdMode":"cash","tgtCcy":"base_ccy","tpOrdPx":"","tpTriggerPx":"","tpTriggerPxType":"","tradeId":"471100149","uTime":"1704849690519"}] + //>> [{"accFillSz":"0.00080894","algoClOrdId":"","algoId":"","attachAlgoClOrdId":"","attachAlgoOrds":[],"avgPx":"46728.2","cTime":"1704789666800","cancelSource":"","cancelSourceReason":"","category":"normal","ccy":"","clOrdId":"","fee":"-0.037800310108","feeCcy":"USDT","fillPx":"46728.2","fillSz":"0.00080894","fillTime":"1704789666801","instId":"BTC-USDT","instType":"SPOT","lever":"","ordId":"664871283930550273","ordType":"market","pnl":"0","posSide":"","px":"","pxType":"","pxUsd":"","pxVol":"","quickMgnType":"","rebate":"0","rebateCcy":"BTC","reduceOnly":"false","side":"sell","slOrdPx":"","slTriggerPx":"","slTriggerPxType":"","source":"","state":"filled","stpId":"","stpMode":"","sz":"37.8","tag":"","tdMode":"cash","tgtCcy":"quote_ccy","tpOrdPx":"","tpTriggerPx":"","tpTriggerPxType":"","tradeId":"470288552","uTime":"1704789666803"}] + //>> [{"accFillSz":"0.00085423","algoClOrdId":"","algoId":"","attachAlgoClOrdId":"","attachAlgoOrds":[],"avgPx":"46825.3","cTime":"1704789220044","cancelSource":"","cancelSourceReason":"","category":"normal","ccy":"","clOrdId":"","fee":"-0.00000085423","feeCcy":"BTC","fillPx":"46825.3","fillSz":"0.00085423","fillTime":"1704789220045","instId":"BTC-USDT","instType":"SPOT","lever":"","ordId":"664869410100072448","ordType":"market","pnl":"0","posSide":"","px":"","pxType":"","pxUsd":"","pxVol":"","quickMgnType":"","rebate":"0","rebateCcy":"USDT","reduceOnly":"false","side":"buy","slOrdPx":"","slTriggerPx":"","slTriggerPxType":"","source":"","state":"filled","stpId":"","stpMode":"","sz":"40","tag":"","tdMode":"cash","tgtCcy":"quote_ccy","tpOrdPx":"","tpTriggerPx":"","tpTriggerPxType":"","tradeId":"470287675","uTime":"1704789220046"}] + c := client.NewGetOrderHistoryRequest().InstrumentID("BTC-USDT").Limit(1).Before(fmt.Sprintf("%d", beforeId)) + res, err := c.Do(ctx) + assert.NoError(t, err) + if len(res) != 1 { + break + } + orders = append(orders, res...) + beforeId = int64(res[0].OrderId) + } + + t.Log(orders) +} + +func TestClient_OrderHistoryByTimeRange(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + + startTime := time.Date(2023, 7, 1, 0, 0, 0, 0, time.UTC) + t.Log(time.Since(startTime)) + //>> [{"accFillSz":"0.00001","algoClOrdId":"","algoId":"","attachAlgoClOrdId":"","attachAlgoOrds":[],"avgPx":"48174.5","cTime":"1704957916401","cancelSource":"","cancelSourceReason":"","category":"normal","ccy":"","clOrdId":"","fee":"-0.000385396","feeCcy":"USDT","fillPx":"48174.5","fillSz":"0.00001","fillTime":"1704983881118","instId":"BTC-USDT","instType":"SPOT","lever":"","ordId":"665576973905014786","ordType":"limit","pnl":"0","posSide":"","px":"48174.5","pxType":"","pxUsd":"","pxVol":"","quickMgnType":"","rebate":"0","rebateCcy":"BTC","reduceOnly":"false","side":"sell","slOrdPx":"","slTriggerPx":"","slTriggerPxType":"","source":"","state":"filled","stpId":"","stpMode":"","sz":"0.00001","tag":"","tdMode":"cash","tgtCcy":"","tpOrdPx":"","tpTriggerPx":"","tpTriggerPxType":"","tradeId":"472610696","uTime":"1704983881135"}] + //>> [{"accFillSz":"0.00001","algoClOrdId":"","algoId":"","attachAlgoClOrdId":"","attachAlgoOrds":[],"avgPx":"48074.5","cTime":"1704957905283","cancelSource":"","cancelSourceReason":"","category":"normal","ccy":"","clOrdId":"","fee":"-0.000384596","feeCcy":"USDT","fillPx":"48074.5","fillSz":"0.00001","fillTime":"1704983824237","instId":"BTC-USDT","instType":"SPOT","lever":"","ordId":"665576927272742919","ordType":"limit","pnl":"0","posSide":"","px":"48074.5","pxType":"","pxUsd":"","pxVol":"","quickMgnType":"","rebate":"0","rebateCcy":"BTC","reduceOnly":"false","side":"sell","slOrdPx":"","slTriggerPx":"","slTriggerPxType":"","source":"","state":"filled","stpId":"","stpMode":"","sz":"0.00001","tag":"","tdMode":"cash","tgtCcy":"","tpOrdPx":"","tpTriggerPx":"","tpTriggerPxType":"","tradeId":"472601591","uTime":"1704983824240"}] + //>> [{"accFillSz":"0.00001","algoClOrdId":"","algoId":"","attachAlgoClOrdId":"","attachAlgoOrds":[],"avgPx":"48073.5","cTime":"1704957892896","cancelSource":"","cancelSourceReason":"","category":"normal","ccy":"","clOrdId":"","fee":"-0.000384588","feeCcy":"USDT","fillPx":"48073.5","fillSz":"0.00001","fillTime":"1704983824227","instId":"BTC-USDT","instType":"SPOT","lever":"","ordId":"665576875317899302","ordType":"limit","pnl":"0","posSide":"","px":"48073.5","pxType":"","pxUsd":"","pxVol":"","quickMgnType":"","rebate":"0","rebateCcy":"BTC","reduceOnly":"false","side":"sell","slOrdPx":"","slTriggerPx":"","slTriggerPxType":"","source":"","state":"filled","stpId":"","stpMode":"","sz":"0.00001","tag":"","tdMode":"cash","tgtCcy":"","tpOrdPx":"","tpTriggerPx":"","tpTriggerPxType":"","tradeId":"472601583","uTime":"1704983824230"}] + //>> [{"accFillSz":"0.00016266","algoClOrdId":"","algoId":"","attachAlgoClOrdId":"","attachAlgoOrds":[],"avgPx":"45919.8","cTime":"1704852215160","cancelSource":"","cancelSourceReason":"","category":"normal","ccy":"","clOrdId":"","fee":"-0.00000016266","feeCcy":"BTC","fillPx":"45919.8","fillSz":"0.00016266","fillTime":"1704852215162","instId":"BTC-USDT","instType":"SPOT","lever":"","ordId":"665133630767091729","ordType":"market","pnl":"0","posSide":"","px":"","pxType":"","pxUsd":"","pxVol":"","quickMgnType":"","rebate":"0","rebateCcy":"USDT","reduceOnly":"false","side":"buy","slOrdPx":"","slTriggerPx":"","slTriggerPxType":"","source":"","state":"filled","stpId":"","stpMode":"","sz":"0.00016266","tag":"","tdMode":"cash","tgtCcy":"base_ccy","tpOrdPx":"","tpTriggerPx":"","tpTriggerPxType":"","tradeId":"471113058","uTime":"1704852215163"}] + //>> [{"accFillSz":"0.00087627","algoClOrdId":"","algoId":"","attachAlgoClOrdId":"","attachAlgoOrds":[],"avgPx":"45647.6","cTime":"1704850530651","cancelSource":"","cancelSourceReason":"","category":"normal","ccy":"","clOrdId":"","fee":"-0.00000087627","feeCcy":"BTC","fillPx":"45647.6","fillSz":"0.00087627","fillTime":"1704850530652","instId":"BTC-USDT","instType":"SPOT","lever":"","ordId":"665126565424254976","ordType":"market","pnl":"0","posSide":"","px":"","pxType":"","pxUsd":"","pxVol":"","quickMgnType":"","rebate":"0","rebateCcy":"USDT","reduceOnly":"false","side":"buy","slOrdPx":"","slTriggerPx":"","slTriggerPxType":"","source":"","state":"filled","stpId":"","stpMode":"","sz":"40","tag":"","tdMode":"cash","tgtCcy":"quote_ccy","tpOrdPx":"","tpTriggerPx":"","tpTriggerPxType":"","tradeId":"471105716","uTime":"1704850530654"}] + //>> [{"accFillSz":"0.001","algoClOrdId":"","algoId":"","attachAlgoClOrdId":"","attachAlgoOrds":[],"avgPx":"45661.3","cTime":"1704850506060","cancelSource":"","cancelSourceReason":"","category":"normal","ccy":"","clOrdId":"","fee":"-0.0456613","feeCcy":"USDT","fillPx":"45661.3","fillSz":"0.001","fillTime":"1704850506061","instId":"BTC-USDT","instType":"SPOT","lever":"","ordId":"665126462282125313","ordType":"market","pnl":"0","posSide":"","px":"","pxType":"","pxUsd":"","pxVol":"","quickMgnType":"","rebate":"0","rebateCcy":"BTC","reduceOnly":"false","side":"sell","slOrdPx":"","slTriggerPx":"","slTriggerPxType":"","source":"","state":"filled","stpId":"","stpMode":"","sz":"0.001","tag":"","tdMode":"cash","tgtCcy":"base_ccy","tpOrdPx":"","tpTriggerPx":"","tpTriggerPxType":"","tradeId":"471105593","uTime":"1704850506062"}] + //>> [{"accFillSz":"0.00097361","algoClOrdId":"","algoId":"","attachAlgoClOrdId":"","attachAlgoOrds":[],"avgPx":"45743","cTime":"1704849690516","cancelSource":"","cancelSourceReason":"","category":"normal","ccy":"","clOrdId":"","fee":"-0.00000097361","feeCcy":"BTC","fillPx":"45743","fillSz":"0.00097361","fillTime":"1704849690517","instId":"BTC-USDT","instType":"SPOT","lever":"","ordId":"665123041642663944","ordType":"market","pnl":"0","posSide":"","px":"","pxType":"","pxUsd":"","pxVol":"","quickMgnType":"","rebate":"0","rebateCcy":"USDT","reduceOnly":"false","side":"buy","slOrdPx":"","slTriggerPx":"","slTriggerPxType":"","source":"","state":"filled","stpId":"","stpMode":"","sz":"0.00097361","tag":"","tdMode":"cash","tgtCcy":"base_ccy","tpOrdPx":"","tpTriggerPx":"","tpTriggerPxType":"","tradeId":"471100149","uTime":"1704849690519"}] + //>> [{"accFillSz":"0.00080894","algoClOrdId":"","algoId":"","attachAlgoClOrdId":"","attachAlgoOrds":[],"avgPx":"46728.2","cTime":"1704789666800","cancelSource":"","cancelSourceReason":"","category":"normal","ccy":"","clOrdId":"","fee":"-0.037800310108","feeCcy":"USDT","fillPx":"46728.2","fillSz":"0.00080894","fillTime":"1704789666801","instId":"BTC-USDT","instType":"SPOT","lever":"","ordId":"664871283930550273","ordType":"market","pnl":"0","posSide":"","px":"","pxType":"","pxUsd":"","pxVol":"","quickMgnType":"","rebate":"0","rebateCcy":"BTC","reduceOnly":"false","side":"sell","slOrdPx":"","slTriggerPx":"","slTriggerPxType":"","source":"","state":"filled","stpId":"","stpMode":"","sz":"37.8","tag":"","tdMode":"cash","tgtCcy":"quote_ccy","tpOrdPx":"","tpTriggerPx":"","tpTriggerPxType":"","tradeId":"470288552","uTime":"1704789666803"}] + //>> [{"accFillSz":"0.00085423","algoClOrdId":"","algoId":"","attachAlgoClOrdId":"","attachAlgoOrds":[],"avgPx":"46825.3","cTime":"1704789220044","cancelSource":"","cancelSourceReason":"","category":"normal","ccy":"","clOrdId":"","fee":"-0.00000085423","feeCcy":"BTC","fillPx":"46825.3","fillSz":"0.00085423","fillTime":"1704789220045","instId":"BTC-USDT","instType":"SPOT","lever":"","ordId":"664869410100072448","ordType":"market","pnl":"0","posSide":"","px":"","pxType":"","pxUsd":"","pxVol":"","quickMgnType":"","rebate":"0","rebateCcy":"USDT","reduceOnly":"false","side":"buy","slOrdPx":"","slTriggerPx":"","slTriggerPxType":"","source":"","state":"filled","stpId":"","stpMode":"","sz":"40","tag":"","tdMode":"cash","tgtCcy":"quote_ccy","tpOrdPx":"","tpTriggerPx":"","tpTriggerPxType":"","tradeId":"470287675","uTime":"1704789220046"}] + c := client.NewGetOrderHistoryRequest().InstrumentID("BTC-USDT").Limit(100).After("665576927272742919").StartTime(types.NewMillisecondTimestampFromInt(1704789220044).Time()) + res, err := c.Do(ctx) + assert.NoError(t, err) + t.Log(res) +} + +func TestClient_TransactionHistoryByOrderId(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + + c := client.NewGetTransactionHistoryRequest().OrderID("665951812901531754") + res, err := c.Do(ctx) + assert.NoError(t, err) + t.Log(res) +} + +func TestClient_TransactionHistoryAll(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + + beforeId := int64(0) + for { + c := client.NewGetTransactionHistoryRequest().Before(strconv.FormatInt(beforeId, 10)).Limit(1) + res, err := c.Do(ctx) + assert.NoError(t, err) + t.Log(res) + + if len(res) != 1 { + break + } + //orders = append(orders, res...) + beforeId = int64(res[0].BillId) + t.Log(res[0]) + } +} + +func TestClient_TransactionHistoryWithTime(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + + beforeId := int64(0) + for { + // [{"side":"sell","fillSz":"1","fillPx":"46446.4","fillPxVol":"","fillFwdPx":"","fee":"-46.4464","fillPnl":"0","ordId":"665951654130348158","feeRate":"-0.001","instType":"SPOT","fillPxUsd":"","instId":"BTC-USDT","clOrdId":"","posSide":"net","billId":"665951654138736652","fillMarkVol":"","tag":"","fillTime":"1705047247128","execType":"T","fillIdxPx":"","tradeId":"724072849","fillMarkPx":"","feeCcy":"USDT","ts":"1705047247130"}] + // [{"side":"sell","fillSz":"11.053006","fillPx":"54.17","fillPxVol":"","fillFwdPx":"","fee":"-0.59874133502","fillPnl":"0","ordId":"665951812901531754","feeRate":"-0.001","instType":"SPOT","fillPxUsd":"","instId":"OKB-USDT","clOrdId":"","posSide":"net","billId":"665951812905726068","fillMarkVol":"","tag":"","fillTime":"1705047284982","execType":"T","fillIdxPx":"","tradeId":"589438381","fillMarkPx":"","feeCcy":"USDT","ts":"1705047284983"}] + // [{"side":"sell","fillSz":"88.946994","fillPx":"54.16","fillPxVol":"","fillFwdPx":"","fee":"-4.81736919504","fillPnl":"0","ordId":"665951812901531754","feeRate":"-0.001","instType":"SPOT","fillPxUsd":"","instId":"OKB-USDT","clOrdId":"","posSide":"net","billId":"665951812905726084","fillMarkVol":"","tag":"","fillTime":"1705047284982","execType":"T","fillIdxPx":"","tradeId":"589438382","fillMarkPx":"","feeCcy":"USDT","ts":"1705047284983"}] + c := client.NewGetTransactionHistoryRequest().Limit(1).Before(fmt.Sprintf("%d", beforeId)). + StartTime(types.NewMillisecondTimestampFromInt(1705047247130).Time()). + EndTime(types.NewMillisecondTimestampFromInt(1705047284983).Time()) + res, err := c.Do(ctx) + assert.NoError(t, err) + t.Log(res) + + if len(res) != 1 { + break + } + beforeId = int64(res[0].BillId) + } +} + +func TestClient_ThreeDaysTransactionHistoryWithTime(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + + beforeId := int64(0) + startTime := time.Now().Add(-3 * 24 * time.Hour) + end := time.Now() + + for { + // [{"side":"sell","fillSz":"1","fillPx":"46446.4","fillPxVol":"","fillFwdPx":"","fee":"-46.4464","fillPnl":"0","ordId":"665951654130348158","feeRate":"-0.001","instType":"SPOT","fillPxUsd":"","instId":"BTC-USDT","clOrdId":"","posSide":"net","billId":"665951654138736652","fillMarkVol":"","tag":"","fillTime":"1705047247128","execType":"T","fillIdxPx":"","tradeId":"724072849","fillMarkPx":"","feeCcy":"USDT","ts":"1705047247130"}] + // [{"side":"sell","fillSz":"11.053006","fillPx":"54.17","fillPxVol":"","fillFwdPx":"","fee":"-0.59874133502","fillPnl":"0","ordId":"665951812901531754","feeRate":"-0.001","instType":"SPOT","fillPxUsd":"","instId":"OKB-USDT","clOrdId":"","posSide":"net","billId":"665951812905726068","fillMarkVol":"","tag":"","fillTime":"1705047284982","execType":"T","fillIdxPx":"","tradeId":"589438381","fillMarkPx":"","feeCcy":"USDT","ts":"1705047284983"}] + // [{"side":"sell","fillSz":"88.946994","filollPx":"54.16","fillPxVol":"","fillFwdPx":"","fee":"-4.81736919504","fillPnl":"0","ordId":"665951812901531754","feeRate":"-0.001","instType":"SPOT","fillPxUsd":"","instId":"OKB-USDT","clOrdId":"","posSide":"net","billId":"665951812905726084","fillMarkVol":"","tag":"","fillTime":"1705047284982","execType":"T","fillIdxPx":"","tradeId":"589438382","fillMarkPx":"","feeCcy":"USDT","ts":"1705047284983"}] + c := client.NewGetThreeDaysTransactionHistoryRequest(). + StartTime(types.NewMillisecondTimestampFromInt(startTime.UnixMilli()).Time()). + EndTime(types.NewMillisecondTimestampFromInt(end.UnixMilli()).Time()). + Limit(1) + if beforeId != 0 { + c.Before(strconv.FormatInt(beforeId, 10)) + } + res, err := c.Do(ctx) + assert.NoError(t, err) + + if len(res) != 1 { + break + } + t.Log(res[0].FillTime, res[0].Timestamp, res[0].BillId, res) + beforeId = int64(res[0].BillId) + } +} + +func TestClient_BatchCancelOrderRequest(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + req := client.NewPlaceOrderRequest() + clientId := fmt.Sprintf("%d", uuid.New().ID()) + + order, err := req. + InstrumentID("BTC-USDT"). + TradeMode(TradeModeCash). + Side(SideTypeSell). + OrderType(OrderTypeLimit). + TargetCurrency(TargetCurrencyBase). + ClientOrderID(clientId). + Price("48000"). + Size("0.001"). + Do(ctx) + assert.NoError(t, err) + assert.NotEmpty(t, order) + t.Logf("place order: %+v", order) + + c := client.NewGetOrderDetailsRequest().ClientOrderID(clientId).InstrumentID("BTC-USDT") + res, err := c.Do(ctx) + assert.NoError(t, err) + t.Log(res) + + cancelResp, err := client.NewBatchCancelOrderRequest().Add(&CancelOrderRequest{instrumentID: "BTC-USDT", clientOrderID: &clientId}).Do(ctx) + assert.NoError(t, err) + t.Log(cancelResp) +} + +func TestClient_GetOrderDetailsRequest(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + req := client.NewGetOrderDetailsRequest() + + orderDetail, err := req. + InstrumentID("BTC-USDT"). + OrderID("609869603774656544"). + Do(ctx) + assert.NoError(t, err) + assert.NotEmpty(t, orderDetail) + t.Logf("order detail: %+v", orderDetail) +} + +func TestClient_CandlesTicksRequest(t *testing.T) { + client := getTestClientOrSkip(t) + ctx := context.Background() + req := client.NewGetCandlesRequest().InstrumentID("BTC-USDT") + res, err := req.Do(ctx) + assert.NoError(t, err) + t.Log(res) +} diff --git a/pkg/exchange/okex/okexapi/get_account_info_request.go b/pkg/exchange/okex/okexapi/get_account_info_request.go new file mode 100644 index 0000000..9920c31 --- /dev/null +++ b/pkg/exchange/okex/okexapi/get_account_info_request.go @@ -0,0 +1,40 @@ +package okexapi + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +type BalanceDetail struct { + Currency string `json:"ccy"` + Available fixedpoint.Value `json:"availEq"` + CashBalance fixedpoint.Value `json:"cashBal"` + OrderFrozen fixedpoint.Value `json:"ordFrozen"` + Frozen fixedpoint.Value `json:"frozenBal"` + Equity fixedpoint.Value `json:"eq"` + EquityInUSD fixedpoint.Value `json:"eqUsd"` + UpdateTime types.MillisecondTimestamp `json:"uTime"` + UnrealizedProfitAndLoss fixedpoint.Value `json:"upl"` +} + +type Account struct { + TotalEquityInUSD fixedpoint.Value `json:"totalEq"` + UpdateTime types.MillisecondTimestamp `json:"uTime"` + Details []BalanceDetail `json:"details"` +} + +//go:generate GetRequest -url "/api/v5/account/balance" -type GetAccountInfoRequest -responseDataType []Account +type GetAccountInfoRequest struct { + client requestgen.AuthenticatedAPIClient +} + +func (c *RestClient) NewGetAccountInfoRequest() *GetAccountInfoRequest { + return &GetAccountInfoRequest{ + client: c, + } +} diff --git a/pkg/exchange/okex/okexapi/get_account_info_request_requestgen.go b/pkg/exchange/okex/okexapi/get_account_info_request_requestgen.go new file mode 100644 index 0000000..b90836d --- /dev/null +++ b/pkg/exchange/okex/okexapi/get_account_info_request_requestgen.go @@ -0,0 +1,157 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/v5/account/balance -type GetAccountInfoRequest -responseDataType []Account"; DO NOT EDIT. + +package okexapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetAccountInfoRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetAccountInfoRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetAccountInfoRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetAccountInfoRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetAccountInfoRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetAccountInfoRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetAccountInfoRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetAccountInfoRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetAccountInfoRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (g *GetAccountInfoRequest) GetPath() string { + return "/api/v5/account/balance" +} + +// Do generates the request object and send the request object to the API endpoint +func (g *GetAccountInfoRequest) Do(ctx context.Context) ([]Account, error) { + + // no body params + var params interface{} + query := url.Values{} + + var apiURL string + + apiURL = g.GetPath() + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data []Account + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/okex/okexapi/get_candles_request.go b/pkg/exchange/okex/okexapi/get_candles_request.go new file mode 100644 index 0000000..db9ce75 --- /dev/null +++ b/pkg/exchange/okex/okexapi/get_candles_request.go @@ -0,0 +1,137 @@ +package okexapi + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type KLine struct { + StartTime types.MillisecondTimestamp + OpenPrice fixedpoint.Value + HighestPrice fixedpoint.Value + LowestPrice fixedpoint.Value + ClosePrice fixedpoint.Value + // Volume trading volume, with a unit of contract. + + // If it is a derivatives contract, the value is the number of contracts. + // If it is SPOT/MARGIN, the value is the quantity in base currency. + Volume fixedpoint.Value + // VolumeInCurrency trading volume, with a unit of currency. + // If it is a derivatives contract, the value is the number of base currency. + // If it is SPOT/MARGIN, the value is the quantity in quote currency. + VolumeInCurrency fixedpoint.Value + // VolumeInCurrencyQuote Trading volume, the value is the quantity in quote currency + // e.g. The unit is USDT for BTC-USDT and BTC-USDT-SWAP; + // The unit is USD for BTC-USD-SWAP + // ** REMARK: To prevent overflow, we need to avoid unmarshaling it. ** + //VolumeInCurrencyQuote fixedpoint.Value + // The state of candlesticks. + // 0 represents that it is uncompleted, 1 represents that it is completed. + Confirm fixedpoint.Value +} + +type KLineSlice []KLine + +func (m *KLineSlice) UnmarshalJSON(b []byte) error { + if m == nil { + return errors.New("nil pointer of kline slice") + } + s, err := parseKLineSliceJSON(b) + if err != nil { + return err + } + + *m = s + return nil +} + +// parseKLineSliceJSON tries to parse a 2 dimensional string array into a KLineSlice +// +// [ +// [ +// "1597026383085", +// "8533.02", +// "8553.74", +// "8527.17", +// "8548.26", +// "45247", +// "529.5858061", +// "5529.5858061", +// "0" +// ] +// ] +func parseKLineSliceJSON(in []byte) (slice KLineSlice, err error) { + var rawKLines [][]json.RawMessage + + err = json.Unmarshal(in, &rawKLines) + if err != nil { + return slice, err + } + + for _, raw := range rawKLines { + if len(raw) != 9 { + return nil, fmt.Errorf("unexpected kline length: %d, data: %q", len(raw), raw) + } + var kline KLine + if err = json.Unmarshal(raw[0], &kline.StartTime); err != nil { + return nil, fmt.Errorf("failed to unmarshal into timestamp: %q", raw[0]) + } + if err = json.Unmarshal(raw[1], &kline.OpenPrice); err != nil { + return nil, fmt.Errorf("failed to unmarshal into open price: %q", raw[1]) + } + if err = json.Unmarshal(raw[2], &kline.HighestPrice); err != nil { + return nil, fmt.Errorf("failed to unmarshal into highest price: %q", raw[2]) + } + if err = json.Unmarshal(raw[3], &kline.LowestPrice); err != nil { + return nil, fmt.Errorf("failed to unmarshal into lowest price: %q", raw[3]) + } + if err = json.Unmarshal(raw[4], &kline.ClosePrice); err != nil { + return nil, fmt.Errorf("failed to unmarshal into close price: %q", raw[4]) + } + if err = json.Unmarshal(raw[5], &kline.Volume); err != nil { + return nil, fmt.Errorf("failed to unmarshal into volume: %q", raw[5]) + } + if err = json.Unmarshal(raw[6], &kline.VolumeInCurrency); err != nil { + return nil, fmt.Errorf("failed to unmarshal into volume currency: %q", raw[6]) + } + //if err = json.Unmarshal(raw[7], &kline.VolumeInCurrencyQuote); err != nil { + // return nil, fmt.Errorf("failed to unmarshal into trading currency quote: %q", raw[7]) + //} + if err = json.Unmarshal(raw[8], &kline.Confirm); err != nil { + return nil, fmt.Errorf("failed to unmarshal into confirm: %q", raw[8]) + } + + slice = append(slice, kline) + } + + return slice, nil +} + +//go:generate GetRequest -url "/api/v5/market/candles" -type GetCandlesRequest -responseDataType KLineSlice +type GetCandlesRequest struct { + client requestgen.APIClient + + instrumentID string `param:"instId,query"` + + limit *int `param:"limit,query"` + + bar *string `param:"bar,query"` + + after *time.Time `param:"after,query,milliseconds"` + + before *time.Time `param:"before,query,milliseconds"` +} + +func (c *RestClient) NewGetCandlesRequest() *GetCandlesRequest { + return &GetCandlesRequest{client: c} +} diff --git a/pkg/exchange/okex/okexapi/get_candles_request_requestgen.go b/pkg/exchange/okex/okexapi/get_candles_request_requestgen.go new file mode 100644 index 0000000..58351d4 --- /dev/null +++ b/pkg/exchange/okex/okexapi/get_candles_request_requestgen.go @@ -0,0 +1,226 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/v5/market/candles -type GetCandlesRequest -responseDataType KLineSlice"; DO NOT EDIT. + +package okexapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetCandlesRequest) InstrumentID(instrumentID string) *GetCandlesRequest { + g.instrumentID = instrumentID + return g +} + +func (g *GetCandlesRequest) Limit(limit int) *GetCandlesRequest { + g.limit = &limit + return g +} + +func (g *GetCandlesRequest) Bar(bar string) *GetCandlesRequest { + g.bar = &bar + return g +} + +func (g *GetCandlesRequest) After(after time.Time) *GetCandlesRequest { + g.after = &after + return g +} + +func (g *GetCandlesRequest) Before(before time.Time) *GetCandlesRequest { + g.before = &before + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetCandlesRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check instrumentID field -> json key instId + instrumentID := g.instrumentID + + // assign parameter of instrumentID + params["instId"] = instrumentID + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + // check bar field -> json key bar + if g.bar != nil { + bar := *g.bar + + // assign parameter of bar + params["bar"] = bar + } else { + } + // check after field -> json key after + if g.after != nil { + after := *g.after + + // assign parameter of after + // convert time.Time to milliseconds time stamp + params["after"] = strconv.FormatInt(after.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check before field -> json key before + if g.before != nil { + before := *g.before + + // assign parameter of before + // convert time.Time to milliseconds time stamp + params["before"] = strconv.FormatInt(before.UnixNano()/int64(time.Millisecond), 10) + } else { + } + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetCandlesRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetCandlesRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetCandlesRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetCandlesRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetCandlesRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetCandlesRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetCandlesRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetCandlesRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (g *GetCandlesRequest) GetPath() string { + return "/api/v5/market/candles" +} + +// Do generates the request object and send the request object to the API endpoint +func (g *GetCandlesRequest) Do(ctx context.Context) (KLineSlice, error) { + + // no body params + var params interface{} + query, err := g.GetQueryParameters() + if err != nil { + return nil, err + } + + var apiURL string + + apiURL = g.GetPath() + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data KLineSlice + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/okex/okexapi/get_instruments_info_request.go b/pkg/exchange/okex/okexapi/get_instruments_info_request.go new file mode 100644 index 0000000..11d6081 --- /dev/null +++ b/pkg/exchange/okex/okexapi/get_instruments_info_request.go @@ -0,0 +1,47 @@ +package okexapi + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/c9s/requestgen" +) + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +type InstrumentInfo struct { + InstrumentType string `json:"instType"` + InstrumentID string `json:"instId"` + BaseCurrency string `json:"baseCcy"` + QuoteCurrency string `json:"quoteCcy"` + SettleCurrency string `json:"settleCcy"` + ContractValue string `json:"ctVal"` + ContractMultiplier string `json:"ctMult"` + ContractValueCurrency string `json:"ctValCcy"` + ListTime types.MillisecondTimestamp `json:"listTime"` + ExpiryTime types.MillisecondTimestamp `json:"expTime"` + TickSize fixedpoint.Value `json:"tickSz"` + LotSize fixedpoint.Value `json:"lotSz"` + + // MinSize = min order size + MinSize fixedpoint.Value `json:"minSz"` + + // instrument status + State string `json:"state"` +} + +//go:generate GetRequest -url "/api/v5/public/instruments" -type GetInstrumentsInfoRequest -responseDataType []InstrumentInfo +type GetInstrumentsInfoRequest struct { + client requestgen.APIClient + + instType InstrumentType `param:"instType,query" validValues:"SPOT"` + + instId *string `param:"instId,query"` +} + +func (c *RestClient) NewGetInstrumentsInfoRequest() *GetInstrumentsInfoRequest { + return &GetInstrumentsInfoRequest{ + client: c, + instType: InstrumentTypeSpot, + } +} diff --git a/pkg/exchange/okex/okexapi/get_instruments_info_request_requestgen.go b/pkg/exchange/okex/okexapi/get_instruments_info_request_requestgen.go new file mode 100644 index 0000000..7302d93 --- /dev/null +++ b/pkg/exchange/okex/okexapi/get_instruments_info_request_requestgen.go @@ -0,0 +1,194 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/v5/public/instruments -type GetInstrumentsInfoRequest -responseDataType []InstrumentInfo"; DO NOT EDIT. + +package okexapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (g *GetInstrumentsInfoRequest) InstType(instType InstrumentType) *GetInstrumentsInfoRequest { + g.instType = instType + return g +} + +func (g *GetInstrumentsInfoRequest) InstId(instId string) *GetInstrumentsInfoRequest { + g.instId = &instId + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetInstrumentsInfoRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check instType field -> json key instType + instType := g.instType + + // TEMPLATE check-valid-values + switch instType { + case "SPOT": + params["instType"] = instType + + default: + return nil, fmt.Errorf("instType value %v is invalid", instType) + + } + // END TEMPLATE check-valid-values + + // assign parameter of instType + params["instType"] = instType + // check instId field -> json key instId + if g.instId != nil { + instId := *g.instId + + // assign parameter of instId + params["instId"] = instId + } else { + } + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetInstrumentsInfoRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetInstrumentsInfoRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetInstrumentsInfoRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetInstrumentsInfoRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetInstrumentsInfoRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetInstrumentsInfoRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetInstrumentsInfoRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetInstrumentsInfoRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (g *GetInstrumentsInfoRequest) GetPath() string { + return "/api/v5/public/instruments" +} + +// Do generates the request object and send the request object to the API endpoint +func (g *GetInstrumentsInfoRequest) Do(ctx context.Context) ([]InstrumentInfo, error) { + + // no body params + var params interface{} + query, err := g.GetQueryParameters() + if err != nil { + return nil, err + } + + var apiURL string + + apiURL = g.GetPath() + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data []InstrumentInfo + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/okex/okexapi/get_open_orders_request.go b/pkg/exchange/okex/okexapi/get_open_orders_request.go new file mode 100644 index 0000000..448745c --- /dev/null +++ b/pkg/exchange/okex/okexapi/get_open_orders_request.go @@ -0,0 +1,43 @@ +package okexapi + +import ( + "time" + + "github.com/c9s/requestgen" +) + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +type OpenOrder struct { + OrderDetail + QuickMgnType string `json:"quickMgnType"` +} + +//go:generate GetRequest -url "/api/v5/trade/orders-pending" -type GetOpenOrdersRequest -responseDataType []OpenOrder +type GetOpenOrdersRequest struct { + client requestgen.AuthenticatedAPIClient + + instrumentType InstrumentType `param:"instType,query"` + instrumentID *string `param:"instId,query"` + orderType *OrderType `param:"ordType,query"` + state *OrderState `param:"state,query"` + category *string `param:"category,query"` + // Pagination of data to return records earlier than the requested ordId + after *string `param:"after,query"` + // Pagination of data to return records newer than the requested ordId + before *string `param:"before,query"` + // Filter with a begin timestamp. Unix timestamp format in milliseconds, e.g. 1597026383085 + begin *time.Time `param:"begin,query,timestamp"` + + // Filter with an end timestamp. Unix timestamp format in milliseconds, e.g. 1597026383085 + end *time.Time `param:"end,query,timestamp"` + limit *string `param:"limit,query"` +} + +func (c *RestClient) NewGetOpenOrdersRequest() *GetOpenOrdersRequest { + return &GetOpenOrdersRequest{ + client: c, + instrumentType: InstrumentTypeSpot, + } +} diff --git a/pkg/exchange/okex/okexapi/get_open_orders_request_requestgen.go b/pkg/exchange/okex/okexapi/get_open_orders_request_requestgen.go new file mode 100644 index 0000000..1dc9685 --- /dev/null +++ b/pkg/exchange/okex/okexapi/get_open_orders_request_requestgen.go @@ -0,0 +1,321 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/v5/trade/orders-pending -type GetOpenOrdersRequest -responseDataType []OpenOrder"; DO NOT EDIT. + +package okexapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" + "time" +) + +func (g *GetOpenOrdersRequest) InstrumentType(instrumentType InstrumentType) *GetOpenOrdersRequest { + g.instrumentType = instrumentType + return g +} + +func (g *GetOpenOrdersRequest) InstrumentID(instrumentID string) *GetOpenOrdersRequest { + g.instrumentID = &instrumentID + return g +} + +func (g *GetOpenOrdersRequest) OrderType(orderType OrderType) *GetOpenOrdersRequest { + g.orderType = &orderType + return g +} + +func (g *GetOpenOrdersRequest) State(state OrderState) *GetOpenOrdersRequest { + g.state = &state + return g +} + +func (g *GetOpenOrdersRequest) Category(category string) *GetOpenOrdersRequest { + g.category = &category + return g +} + +func (g *GetOpenOrdersRequest) After(after string) *GetOpenOrdersRequest { + g.after = &after + return g +} + +func (g *GetOpenOrdersRequest) Before(before string) *GetOpenOrdersRequest { + g.before = &before + return g +} + +func (g *GetOpenOrdersRequest) Begin(begin time.Time) *GetOpenOrdersRequest { + g.begin = &begin + return g +} + +func (g *GetOpenOrdersRequest) End(end time.Time) *GetOpenOrdersRequest { + g.end = &end + return g +} + +func (g *GetOpenOrdersRequest) Limit(limit string) *GetOpenOrdersRequest { + g.limit = &limit + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetOpenOrdersRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check instrumentType field -> json key instType + instrumentType := g.instrumentType + + // TEMPLATE check-valid-values + switch instrumentType { + case InstrumentTypeSpot, InstrumentTypeSwap, InstrumentTypeFutures, InstrumentTypeOption, InstrumentTypeMARGIN: + params["instType"] = instrumentType + + default: + return nil, fmt.Errorf("instType value %v is invalid", instrumentType) + + } + // END TEMPLATE check-valid-values + + // assign parameter of instrumentType + params["instType"] = instrumentType + // check instrumentID field -> json key instId + if g.instrumentID != nil { + instrumentID := *g.instrumentID + + // assign parameter of instrumentID + params["instId"] = instrumentID + } else { + } + // check orderType field -> json key ordType + if g.orderType != nil { + orderType := *g.orderType + + // TEMPLATE check-valid-values + switch orderType { + case OrderTypeMarket, OrderTypeLimit, OrderTypePostOnly, OrderTypeFOK, OrderTypeIOC: + params["ordType"] = orderType + + default: + return nil, fmt.Errorf("ordType value %v is invalid", orderType) + + } + // END TEMPLATE check-valid-values + + // assign parameter of orderType + params["ordType"] = orderType + } else { + } + // check state field -> json key state + if g.state != nil { + state := *g.state + + // TEMPLATE check-valid-values + switch state { + case OrderStateCanceled, OrderStateLive, OrderStatePartiallyFilled, OrderStateFilled: + params["state"] = state + + default: + return nil, fmt.Errorf("state value %v is invalid", state) + + } + // END TEMPLATE check-valid-values + + // assign parameter of state + params["state"] = state + } else { + } + // check category field -> json key category + if g.category != nil { + category := *g.category + + // assign parameter of category + params["category"] = category + } else { + } + // check after field -> json key after + if g.after != nil { + after := *g.after + + // assign parameter of after + params["after"] = after + } else { + } + // check before field -> json key before + if g.before != nil { + before := *g.before + + // assign parameter of before + params["before"] = before + } else { + } + // check begin field -> json key begin + if g.begin != nil { + begin := *g.begin + + // assign parameter of begin + params["begin"] = begin + } else { + } + // check end field -> json key end + if g.end != nil { + end := *g.end + + // assign parameter of end + params["end"] = end + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetOpenOrdersRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetOpenOrdersRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetOpenOrdersRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetOpenOrdersRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetOpenOrdersRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetOpenOrdersRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetOpenOrdersRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetOpenOrdersRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (g *GetOpenOrdersRequest) GetPath() string { + return "/api/v5/trade/orders-pending" +} + +// Do generates the request object and send the request object to the API endpoint +func (g *GetOpenOrdersRequest) Do(ctx context.Context) ([]OpenOrder, error) { + + // no body params + var params interface{} + query, err := g.GetQueryParameters() + if err != nil { + return nil, err + } + + var apiURL string + + apiURL = g.GetPath() + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data []OpenOrder + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/okex/okexapi/get_order_history_request.go b/pkg/exchange/okex/okexapi/get_order_history_request.go new file mode 100644 index 0000000..8f3469b --- /dev/null +++ b/pkg/exchange/okex/okexapi/get_order_history_request.go @@ -0,0 +1,110 @@ +package okexapi + +import ( + "time" + + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data +type OrderDetail struct { + AccumulatedFillSize fixedpoint.Value `json:"accFillSz"` + // If none is filled, it will return "". + AvgPrice fixedpoint.Value `json:"avgPx"` + CreatedTime types.MillisecondTimestamp `json:"cTime"` + Category string `json:"category"` + ClientOrderId string `json:"clOrdId"` + Fee fixedpoint.Value `json:"fee"` + FeeCurrency string `json:"feeCcy"` + // Last filled time + FillTime types.MillisecondTimestamp `json:"fillTime"` + InstrumentID string `json:"instId"` + InstrumentType InstrumentType `json:"instType"` + OrderId types.StrInt64 `json:"ordId"` + OrderType OrderType `json:"ordType"` + Price fixedpoint.Value `json:"px"` + Side SideType `json:"side"` + State OrderState `json:"state"` + Size fixedpoint.Value `json:"sz"` + TargetCurrency TargetCurrency `json:"tgtCcy"` + UpdatedTime types.MillisecondTimestamp `json:"uTime"` + + // Margin currency + // Only applicable to cross MARGIN orders in Single-currency margin. + Currency string `json:"ccy"` + TradeId string `json:"tradeId"` + // Last filled price + FillPrice fixedpoint.Value `json:"fillPx"` + // Last filled quantity + FillSize fixedpoint.Value `json:"fillSz"` + // Leverage, from 0.01 to 125. + // Only applicable to MARGIN/FUTURES/SWAP + Lever string `json:"lever"` + // Profit and loss, Applicable to orders which have a trade and aim to close position. It always is 0 in other conditions + Pnl fixedpoint.Value `json:"pnl"` + PositionSide string `json:"posSide"` + // Options price in USDOnly applicable to options; return "" for other instrument types + PriceUsd fixedpoint.Value `json:"pxUsd"` + // Implied volatility of the options orderOnly applicable to options; return "" for other instrument types + PriceVol fixedpoint.Value `json:"pxVol"` + // Price type of options + PriceType string `json:"pxType"` + // Rebate amount, only applicable to spot and margin, the reward of placing orders from the platform (rebate) + // given to user who has reached the specified trading level. If there is no rebate, this field is "". + Rebate fixedpoint.Value `json:"rebate"` + RebateCcy string `json:"rebateCcy"` + // Client-supplied Algo ID when placing order attaching TP/SL. + AttachAlgoClOrdId string `json:"attachAlgoClOrdId"` + SlOrdPx fixedpoint.Value `json:"slOrdPx"` + SlTriggerPx fixedpoint.Value `json:"slTriggerPx"` + SlTriggerPxType string `json:"slTriggerPxType"` + AttachAlgoOrds []interface{} `json:"attachAlgoOrds"` + Source string `json:"source"` + // Self trade prevention ID. Return "" if self trade prevention is not applicable + StpId string `json:"stpId"` + // Self trade prevention mode. Return "" if self trade prevention is not applicable + StpMode string `json:"stpMode"` + Tag string `json:"tag"` + TradeMode TradeMode `json:"tdMode"` + TpOrdPx fixedpoint.Value `json:"tpOrdPx"` + TpTriggerPx fixedpoint.Value `json:"tpTriggerPx"` + TpTriggerPxType string `json:"tpTriggerPxType"` + ReduceOnly string `json:"reduceOnly"` + AlgoClOrdId string `json:"algoClOrdId"` + AlgoId string `json:"algoId"` +} + +//go:generate GetRequest -url "/api/v5/trade/orders-history-archive" -type GetOrderHistoryRequest -responseDataType []OrderDetail +type GetOrderHistoryRequest struct { + client requestgen.AuthenticatedAPIClient + + instrumentType InstrumentType `param:"instType,query"` + instrumentID *string `param:"instId,query"` + orderType *OrderType `param:"ordType,query"` + // underlying and instrumentFamil Applicable to FUTURES/SWAP/OPTION + underlying *string `param:"uly,query"` + instrumentFamily *string `param:"instFamily,query"` + + state *OrderState `param:"state,query"` + after *string `param:"after,query"` + before *string `param:"before,query"` + startTime *time.Time `param:"begin,query,milliseconds"` + + // endTime for each request, startTime and endTime can be any interval, but should be in last 3 months + endTime *time.Time `param:"end,query,milliseconds"` + + // limit for data size per page. Default: 100 + limit *uint64 `param:"limit,query"` +} + +// NewGetOrderHistoryRequest is descending order by createdTime +func (c *RestClient) NewGetOrderHistoryRequest() *GetOrderHistoryRequest { + return &GetOrderHistoryRequest{ + client: c, + instrumentType: InstrumentTypeSpot, + } +} diff --git a/pkg/exchange/okex/okexapi/get_order_history_request_requestgen.go b/pkg/exchange/okex/okexapi/get_order_history_request_requestgen.go new file mode 100644 index 0000000..b36facb --- /dev/null +++ b/pkg/exchange/okex/okexapi/get_order_history_request_requestgen.go @@ -0,0 +1,339 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/v5/trade/orders-history-archive -type GetOrderHistoryRequest -responseDataType []OrderDetail"; DO NOT EDIT. + +package okexapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +func (g *GetOrderHistoryRequest) InstrumentType(instrumentType InstrumentType) *GetOrderHistoryRequest { + g.instrumentType = instrumentType + return g +} + +func (g *GetOrderHistoryRequest) InstrumentID(instrumentID string) *GetOrderHistoryRequest { + g.instrumentID = &instrumentID + return g +} + +func (g *GetOrderHistoryRequest) OrderType(orderType OrderType) *GetOrderHistoryRequest { + g.orderType = &orderType + return g +} + +func (g *GetOrderHistoryRequest) Underlying(underlying string) *GetOrderHistoryRequest { + g.underlying = &underlying + return g +} + +func (g *GetOrderHistoryRequest) InstrumentFamily(instrumentFamily string) *GetOrderHistoryRequest { + g.instrumentFamily = &instrumentFamily + return g +} + +func (g *GetOrderHistoryRequest) State(state OrderState) *GetOrderHistoryRequest { + g.state = &state + return g +} + +func (g *GetOrderHistoryRequest) After(after string) *GetOrderHistoryRequest { + g.after = &after + return g +} + +func (g *GetOrderHistoryRequest) Before(before string) *GetOrderHistoryRequest { + g.before = &before + return g +} + +func (g *GetOrderHistoryRequest) StartTime(startTime time.Time) *GetOrderHistoryRequest { + g.startTime = &startTime + return g +} + +func (g *GetOrderHistoryRequest) EndTime(endTime time.Time) *GetOrderHistoryRequest { + g.endTime = &endTime + return g +} + +func (g *GetOrderHistoryRequest) Limit(limit uint64) *GetOrderHistoryRequest { + g.limit = &limit + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetOrderHistoryRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check instrumentType field -> json key instType + instrumentType := g.instrumentType + + // TEMPLATE check-valid-values + switch instrumentType { + case InstrumentTypeSpot, InstrumentTypeSwap, InstrumentTypeFutures, InstrumentTypeOption, InstrumentTypeMARGIN: + params["instType"] = instrumentType + + default: + return nil, fmt.Errorf("instType value %v is invalid", instrumentType) + + } + // END TEMPLATE check-valid-values + + // assign parameter of instrumentType + params["instType"] = instrumentType + // check instrumentID field -> json key instId + if g.instrumentID != nil { + instrumentID := *g.instrumentID + + // assign parameter of instrumentID + params["instId"] = instrumentID + } else { + } + // check orderType field -> json key ordType + if g.orderType != nil { + orderType := *g.orderType + + // TEMPLATE check-valid-values + switch orderType { + case OrderTypeMarket, OrderTypeLimit, OrderTypePostOnly, OrderTypeFOK, OrderTypeIOC: + params["ordType"] = orderType + + default: + return nil, fmt.Errorf("ordType value %v is invalid", orderType) + + } + // END TEMPLATE check-valid-values + + // assign parameter of orderType + params["ordType"] = orderType + } else { + } + // check underlying field -> json key uly + if g.underlying != nil { + underlying := *g.underlying + + // assign parameter of underlying + params["uly"] = underlying + } else { + } + // check instrumentFamily field -> json key instFamily + if g.instrumentFamily != nil { + instrumentFamily := *g.instrumentFamily + + // assign parameter of instrumentFamily + params["instFamily"] = instrumentFamily + } else { + } + // check state field -> json key state + if g.state != nil { + state := *g.state + + // TEMPLATE check-valid-values + switch state { + case OrderStateCanceled, OrderStateLive, OrderStatePartiallyFilled, OrderStateFilled: + params["state"] = state + + default: + return nil, fmt.Errorf("state value %v is invalid", state) + + } + // END TEMPLATE check-valid-values + + // assign parameter of state + params["state"] = state + } else { + } + // check after field -> json key after + if g.after != nil { + after := *g.after + + // assign parameter of after + params["after"] = after + } else { + } + // check before field -> json key before + if g.before != nil { + before := *g.before + + // assign parameter of before + params["before"] = before + } else { + } + // check startTime field -> json key begin + if g.startTime != nil { + startTime := *g.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["begin"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key end + if g.endTime != nil { + endTime := *g.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["end"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetOrderHistoryRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetOrderHistoryRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetOrderHistoryRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetOrderHistoryRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetOrderHistoryRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetOrderHistoryRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetOrderHistoryRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetOrderHistoryRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (g *GetOrderHistoryRequest) GetPath() string { + return "/api/v5/trade/orders-history-archive" +} + +// Do generates the request object and send the request object to the API endpoint +func (g *GetOrderHistoryRequest) Do(ctx context.Context) ([]OrderDetail, error) { + + // no body params + var params interface{} + query, err := g.GetQueryParameters() + if err != nil { + return nil, err + } + + var apiURL string + + apiURL = g.GetPath() + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + + var data []OrderDetail + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/okex/okexapi/get_three_days_transaction_history_request.go b/pkg/exchange/okex/okexapi/get_three_days_transaction_history_request.go new file mode 100644 index 0000000..3a5168c --- /dev/null +++ b/pkg/exchange/okex/okexapi/get_three_days_transaction_history_request.go @@ -0,0 +1,39 @@ +package okexapi + +import ( + "time" + + "github.com/c9s/requestgen" +) + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +//go:generate GetRequest -url "/api/v5/trade/fills" -type GetThreeDaysTransactionHistoryRequest -responseDataType []Trade -rateLimiter 1+60/2s +type GetThreeDaysTransactionHistoryRequest struct { + client requestgen.AuthenticatedAPIClient + + instrumentType InstrumentType `param:"instType,query"` + instrumentID *string `param:"instId,query"` + orderID *string `param:"ordId,query"` + + underlying *string `param:"uly,query"` + instrumentFamily *string `param:"instFamily,query"` + + after *string `param:"after,query"` + before *string `param:"before,query"` + startTime *time.Time `param:"begin,query,milliseconds"` + + // endTime for each request, startTime and endTime can be any interval, but should be in last 3 months + endTime *time.Time `param:"end,query,milliseconds"` + + // limit for data size per page. Default: 100 + limit *uint64 `param:"limit,query"` +} + +func (c *RestClient) NewGetThreeDaysTransactionHistoryRequest() *GetThreeDaysTransactionHistoryRequest { + return &GetThreeDaysTransactionHistoryRequest{ + client: c, + instrumentType: InstrumentTypeSpot, + } +} diff --git a/pkg/exchange/okex/okexapi/get_three_days_transaction_history_request_requestgen.go b/pkg/exchange/okex/okexapi/get_three_days_transaction_history_request_requestgen.go new file mode 100644 index 0000000..594b7ba --- /dev/null +++ b/pkg/exchange/okex/okexapi/get_three_days_transaction_history_request_requestgen.go @@ -0,0 +1,322 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/v5/trade/fills -type GetThreeDaysTransactionHistoryRequest -responseDataType []Trade -rateLimiter 1+60/2s"; DO NOT EDIT. + +package okexapi + +import ( + "context" + "encoding/json" + "fmt" + "golang.org/x/time/rate" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +var GetThreeDaysTransactionHistoryRequestLimiter = rate.NewLimiter(30.000000300000004, 1) + +func (g *GetThreeDaysTransactionHistoryRequest) InstrumentType(instrumentType InstrumentType) *GetThreeDaysTransactionHistoryRequest { + g.instrumentType = instrumentType + return g +} + +func (g *GetThreeDaysTransactionHistoryRequest) InstrumentID(instrumentID string) *GetThreeDaysTransactionHistoryRequest { + g.instrumentID = &instrumentID + return g +} + +func (g *GetThreeDaysTransactionHistoryRequest) OrderID(orderID string) *GetThreeDaysTransactionHistoryRequest { + g.orderID = &orderID + return g +} + +func (g *GetThreeDaysTransactionHistoryRequest) Underlying(underlying string) *GetThreeDaysTransactionHistoryRequest { + g.underlying = &underlying + return g +} + +func (g *GetThreeDaysTransactionHistoryRequest) InstrumentFamily(instrumentFamily string) *GetThreeDaysTransactionHistoryRequest { + g.instrumentFamily = &instrumentFamily + return g +} + +func (g *GetThreeDaysTransactionHistoryRequest) After(after string) *GetThreeDaysTransactionHistoryRequest { + g.after = &after + return g +} + +func (g *GetThreeDaysTransactionHistoryRequest) Before(before string) *GetThreeDaysTransactionHistoryRequest { + g.before = &before + return g +} + +func (g *GetThreeDaysTransactionHistoryRequest) StartTime(startTime time.Time) *GetThreeDaysTransactionHistoryRequest { + g.startTime = &startTime + return g +} + +func (g *GetThreeDaysTransactionHistoryRequest) EndTime(endTime time.Time) *GetThreeDaysTransactionHistoryRequest { + g.endTime = &endTime + return g +} + +func (g *GetThreeDaysTransactionHistoryRequest) Limit(limit uint64) *GetThreeDaysTransactionHistoryRequest { + g.limit = &limit + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetThreeDaysTransactionHistoryRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check instrumentType field -> json key instType + instrumentType := g.instrumentType + + // TEMPLATE check-valid-values + switch instrumentType { + case InstrumentTypeSpot, InstrumentTypeSwap, InstrumentTypeFutures, InstrumentTypeOption, InstrumentTypeMARGIN: + params["instType"] = instrumentType + + default: + return nil, fmt.Errorf("instType value %v is invalid", instrumentType) + + } + // END TEMPLATE check-valid-values + + // assign parameter of instrumentType + params["instType"] = instrumentType + // check instrumentID field -> json key instId + if g.instrumentID != nil { + instrumentID := *g.instrumentID + + // assign parameter of instrumentID + params["instId"] = instrumentID + } else { + } + // check orderID field -> json key ordId + if g.orderID != nil { + orderID := *g.orderID + + // assign parameter of orderID + params["ordId"] = orderID + } else { + } + // check underlying field -> json key uly + if g.underlying != nil { + underlying := *g.underlying + + // assign parameter of underlying + params["uly"] = underlying + } else { + } + // check instrumentFamily field -> json key instFamily + if g.instrumentFamily != nil { + instrumentFamily := *g.instrumentFamily + + // assign parameter of instrumentFamily + params["instFamily"] = instrumentFamily + } else { + } + // check after field -> json key after + if g.after != nil { + after := *g.after + + // assign parameter of after + params["after"] = after + } else { + } + // check before field -> json key before + if g.before != nil { + before := *g.before + + // assign parameter of before + params["before"] = before + } else { + } + // check startTime field -> json key begin + if g.startTime != nil { + startTime := *g.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["begin"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key end + if g.endTime != nil { + endTime := *g.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["end"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetThreeDaysTransactionHistoryRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetThreeDaysTransactionHistoryRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetThreeDaysTransactionHistoryRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetThreeDaysTransactionHistoryRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetThreeDaysTransactionHistoryRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetThreeDaysTransactionHistoryRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetThreeDaysTransactionHistoryRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetThreeDaysTransactionHistoryRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (g *GetThreeDaysTransactionHistoryRequest) GetPath() string { + return "/api/v5/trade/fills" +} + +// Do generates the request object and send the request object to the API endpoint +func (g *GetThreeDaysTransactionHistoryRequest) Do(ctx context.Context) ([]Trade, error) { + if err := GetThreeDaysTransactionHistoryRequestLimiter.Wait(ctx); err != nil { + return nil, err + } + + // no body params + var params interface{} + query, err := g.GetQueryParameters() + if err != nil { + return nil, err + } + + var apiURL string + + apiURL = g.GetPath() + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + + type responseUnmarshaler interface { + Unmarshal(data []byte) error + } + + if unmarshaler, ok := interface{}(&apiResponse).(responseUnmarshaler); ok { + if err := unmarshaler.Unmarshal(response.Body); err != nil { + return nil, err + } + } else { + // The line below checks the content type, however, some API server might not send the correct content type header, + // Hence, this is commented for backward compatibility + // response.IsJSON() + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + } + + type responseValidator interface { + Validate() error + } + + if validator, ok := interface{}(&apiResponse).(responseValidator); ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data []Trade + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/okex/okexapi/get_ticker_request.go b/pkg/exchange/okex/okexapi/get_ticker_request.go new file mode 100644 index 0000000..a1c8c61 --- /dev/null +++ b/pkg/exchange/okex/okexapi/get_ticker_request.go @@ -0,0 +1,21 @@ +package okexapi + +import ( + "github.com/c9s/requestgen" +) + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +//go:generate GetRequest -url "/api/v5/market/ticker" -type GetTickerRequest -responseDataType []MarketTicker +type GetTickerRequest struct { + client requestgen.APIClient + + instId string `param:"instId,query"` +} + +func (c *RestClient) NewGetTickerRequest() *GetTickerRequest { + return &GetTickerRequest{ + client: c, + } +} diff --git a/pkg/exchange/okex/okexapi/get_ticker_request_requestgen.go b/pkg/exchange/okex/okexapi/get_ticker_request_requestgen.go new file mode 100644 index 0000000..e5ab521 --- /dev/null +++ b/pkg/exchange/okex/okexapi/get_ticker_request_requestgen.go @@ -0,0 +1,170 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/v5/market/ticker -type GetTickerRequest -responseDataType []MarketTicker"; DO NOT EDIT. + +package okexapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (g *GetTickerRequest) InstId(instId string) *GetTickerRequest { + g.instId = instId + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetTickerRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check instId field -> json key instId + instId := g.instId + + // assign parameter of instId + params["instId"] = instId + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetTickerRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetTickerRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetTickerRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetTickerRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetTickerRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetTickerRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetTickerRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetTickerRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (g *GetTickerRequest) GetPath() string { + return "/api/v5/market/ticker" +} + +// Do generates the request object and send the request object to the API endpoint +func (g *GetTickerRequest) Do(ctx context.Context) ([]MarketTicker, error) { + + // no body params + var params interface{} + query, err := g.GetQueryParameters() + if err != nil { + return nil, err + } + + var apiURL string + + apiURL = g.GetPath() + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data []MarketTicker + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/okex/okexapi/get_tickers_request.go b/pkg/exchange/okex/okexapi/get_tickers_request.go new file mode 100644 index 0000000..d2e23f9 --- /dev/null +++ b/pkg/exchange/okex/okexapi/get_tickers_request.go @@ -0,0 +1,51 @@ +package okexapi + +import ( + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +type MarketTicker struct { + InstrumentType string `json:"instType"` + InstrumentID string `json:"instId"` + + // last traded price + Last fixedpoint.Value `json:"last"` + + // last traded size + LastSize fixedpoint.Value `json:"lastSz"` + + AskPrice fixedpoint.Value `json:"askPx"` + AskSize fixedpoint.Value `json:"askSz"` + + BidPrice fixedpoint.Value `json:"bidPx"` + BidSize fixedpoint.Value `json:"bidSz"` + + Open24H fixedpoint.Value `json:"open24h"` + High24H fixedpoint.Value `json:"high24H"` + Low24H fixedpoint.Value `json:"low24H"` + Volume24H fixedpoint.Value `json:"vol24h"` + VolumeCurrency24H fixedpoint.Value `json:"volCcy24h"` + + // Millisecond timestamp + Timestamp types.MillisecondTimestamp `json:"ts"` +} + +//go:generate GetRequest -url "/api/v5/market/tickers" -type GetTickersRequest -responseDataType []MarketTicker +type GetTickersRequest struct { + client requestgen.APIClient + + instType InstrumentType `param:"instType,query" validValues:"SPOT"` +} + +func (c *RestClient) NewGetTickersRequest() *GetTickersRequest { + return &GetTickersRequest{ + client: c, + instType: InstrumentTypeSpot, + } +} diff --git a/pkg/exchange/okex/okexapi/get_tickers_request_requestgen.go b/pkg/exchange/okex/okexapi/get_tickers_request_requestgen.go new file mode 100644 index 0000000..a847106 --- /dev/null +++ b/pkg/exchange/okex/okexapi/get_tickers_request_requestgen.go @@ -0,0 +1,181 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/v5/market/tickers -type GetTickersRequest -responseDataType []MarketTicker"; DO NOT EDIT. + +package okexapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (g *GetTickersRequest) InstType(instType InstrumentType) *GetTickersRequest { + g.instType = instType + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetTickersRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check instType field -> json key instType + instType := g.instType + + // TEMPLATE check-valid-values + switch instType { + case "SPOT": + params["instType"] = instType + + default: + return nil, fmt.Errorf("instType value %v is invalid", instType) + + } + // END TEMPLATE check-valid-values + + // assign parameter of instType + params["instType"] = instType + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetTickersRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetTickersRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetTickersRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetTickersRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetTickersRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetTickersRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetTickersRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetTickersRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (g *GetTickersRequest) GetPath() string { + return "/api/v5/market/tickers" +} + +// Do generates the request object and send the request object to the API endpoint +func (g *GetTickersRequest) Do(ctx context.Context) ([]MarketTicker, error) { + + // no body params + var params interface{} + query, err := g.GetQueryParameters() + if err != nil { + return nil, err + } + + var apiURL string + + apiURL = g.GetPath() + + req, err := g.client.NewRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data []MarketTicker + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/okex/okexapi/get_transaction_history_request.go b/pkg/exchange/okex/okexapi/get_transaction_history_request.go new file mode 100644 index 0000000..9a214f0 --- /dev/null +++ b/pkg/exchange/okex/okexapi/get_transaction_history_request.go @@ -0,0 +1,86 @@ +package okexapi + +import ( + "time" + + "github.com/c9s/requestgen" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +type LiquidityType string + +const ( + LiquidityTypeMaker = "M" + LiquidityTypeTaker = "T" +) + +type Trade struct { + InstrumentType InstrumentType `json:"instType"` + InstrumentId string `json:"instId"` + TradeId types.StrInt64 `json:"tradeId"` + OrderId types.StrInt64 `json:"ordId"` + ClientOrderId string `json:"clOrdId"` + // Data generation time, Unix timestamp format in milliseconds, e.g. 1597026383085. + Timestamp types.MillisecondTimestamp `json:"ts"` + FillTime types.MillisecondTimestamp `json:"fillTime"` + FeeCurrency string `json:"feeCcy"` + Fee fixedpoint.Value `json:"fee"` + BillId types.StrInt64 `json:"billId"` + Side SideType `json:"side"` + ExecutionType LiquidityType `json:"execType"` + + Tag string `json:"tag"` + // Last filled price + FillPrice fixedpoint.Value `json:"fillPx"` + // Last filled quantity + FillSize fixedpoint.Value `json:"fillSz"` + // Index price at the moment of trade execution + //For cross currency spot pairs, it returns baseCcy-USDT index price. For example, for LTC-ETH, this field returns the index price of LTC-USDT. + FillIndexPrice fixedpoint.Value `json:"fillIdxPx"` + FillPnl string `json:"fillPnl"` + + // Only applicable to options; return "" for other instrument types + FillPriceVolume fixedpoint.Value `json:"fillPxVol"` + FillPriceUsd fixedpoint.Value `json:"fillPxUsd"` + FillMarkVolume fixedpoint.Value `json:"fillMarkVol"` + FillForwardPrice fixedpoint.Value `json:"fillFwdPx"` + FillMarkPrice fixedpoint.Value `json:"fillMarkPx"` + PosSide string `json:"posSide"` +} + +//go:generate GetRequest -url "/api/v5/trade/fills-history" -type GetTransactionHistoryRequest -responseDataType []Trade -rateLimiter 1+10/2s +type GetTransactionHistoryRequest struct { + client requestgen.AuthenticatedAPIClient + + instrumentType InstrumentType `param:"instType,query"` + instrumentID *string `param:"instId,query"` + orderID *string `param:"ordId,query"` + + // Underlying and InstrumentFamily Applicable to FUTURES/SWAP/OPTION + underlying *string `param:"uly,query"` + instrumentFamily *string `param:"instFamily,query"` + + after *string `param:"after,query"` + before *string `param:"before,query"` + startTime *time.Time `param:"begin,query,milliseconds"` + + // endTime for each request, startTime and endTime can be any interval, but should be in last 3 months + endTime *time.Time `param:"end,query,milliseconds"` + + // limit for data size per page. Default: 100 + limit *uint64 `param:"limit,query"` +} + +type OrderList []OrderDetails + +func (c *RestClient) NewGetTransactionHistoryRequest() *GetTransactionHistoryRequest { + return &GetTransactionHistoryRequest{ + client: c, + instrumentType: InstrumentTypeSpot, + } +} diff --git a/pkg/exchange/okex/okexapi/get_transaction_history_request_requestgen.go b/pkg/exchange/okex/okexapi/get_transaction_history_request_requestgen.go new file mode 100644 index 0000000..ad45f52 --- /dev/null +++ b/pkg/exchange/okex/okexapi/get_transaction_history_request_requestgen.go @@ -0,0 +1,322 @@ +// Code generated by "requestgen -method GET -responseType .APIResponse -responseDataField Data -url /api/v5/trade/fills-history -type GetTransactionHistoryRequest -responseDataType []Trade -rateLimiter 1+10/2s"; DO NOT EDIT. + +package okexapi + +import ( + "context" + "encoding/json" + "fmt" + "golang.org/x/time/rate" + "net/url" + "reflect" + "regexp" + "strconv" + "time" +) + +var GetTransactionHistoryRequestLimiter = rate.NewLimiter(5, 1) + +func (g *GetTransactionHistoryRequest) InstrumentType(instrumentType InstrumentType) *GetTransactionHistoryRequest { + g.instrumentType = instrumentType + return g +} + +func (g *GetTransactionHistoryRequest) InstrumentID(instrumentID string) *GetTransactionHistoryRequest { + g.instrumentID = &instrumentID + return g +} + +func (g *GetTransactionHistoryRequest) OrderID(orderID string) *GetTransactionHistoryRequest { + g.orderID = &orderID + return g +} + +func (g *GetTransactionHistoryRequest) Underlying(underlying string) *GetTransactionHistoryRequest { + g.underlying = &underlying + return g +} + +func (g *GetTransactionHistoryRequest) InstrumentFamily(instrumentFamily string) *GetTransactionHistoryRequest { + g.instrumentFamily = &instrumentFamily + return g +} + +func (g *GetTransactionHistoryRequest) After(after string) *GetTransactionHistoryRequest { + g.after = &after + return g +} + +func (g *GetTransactionHistoryRequest) Before(before string) *GetTransactionHistoryRequest { + g.before = &before + return g +} + +func (g *GetTransactionHistoryRequest) StartTime(startTime time.Time) *GetTransactionHistoryRequest { + g.startTime = &startTime + return g +} + +func (g *GetTransactionHistoryRequest) EndTime(endTime time.Time) *GetTransactionHistoryRequest { + g.endTime = &endTime + return g +} + +func (g *GetTransactionHistoryRequest) Limit(limit uint64) *GetTransactionHistoryRequest { + g.limit = &limit + return g +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (g *GetTransactionHistoryRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + // check instrumentType field -> json key instType + instrumentType := g.instrumentType + + // TEMPLATE check-valid-values + switch instrumentType { + case InstrumentTypeSpot, InstrumentTypeSwap, InstrumentTypeFutures, InstrumentTypeOption, InstrumentTypeMARGIN: + params["instType"] = instrumentType + + default: + return nil, fmt.Errorf("instType value %v is invalid", instrumentType) + + } + // END TEMPLATE check-valid-values + + // assign parameter of instrumentType + params["instType"] = instrumentType + // check instrumentID field -> json key instId + if g.instrumentID != nil { + instrumentID := *g.instrumentID + + // assign parameter of instrumentID + params["instId"] = instrumentID + } else { + } + // check orderID field -> json key ordId + if g.orderID != nil { + orderID := *g.orderID + + // assign parameter of orderID + params["ordId"] = orderID + } else { + } + // check underlying field -> json key uly + if g.underlying != nil { + underlying := *g.underlying + + // assign parameter of underlying + params["uly"] = underlying + } else { + } + // check instrumentFamily field -> json key instFamily + if g.instrumentFamily != nil { + instrumentFamily := *g.instrumentFamily + + // assign parameter of instrumentFamily + params["instFamily"] = instrumentFamily + } else { + } + // check after field -> json key after + if g.after != nil { + after := *g.after + + // assign parameter of after + params["after"] = after + } else { + } + // check before field -> json key before + if g.before != nil { + before := *g.before + + // assign parameter of before + params["before"] = before + } else { + } + // check startTime field -> json key begin + if g.startTime != nil { + startTime := *g.startTime + + // assign parameter of startTime + // convert time.Time to milliseconds time stamp + params["begin"] = strconv.FormatInt(startTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check endTime field -> json key end + if g.endTime != nil { + endTime := *g.endTime + + // assign parameter of endTime + // convert time.Time to milliseconds time stamp + params["end"] = strconv.FormatInt(endTime.UnixNano()/int64(time.Millisecond), 10) + } else { + } + // check limit field -> json key limit + if g.limit != nil { + limit := *g.limit + + // assign parameter of limit + params["limit"] = limit + } else { + } + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (g *GetTransactionHistoryRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (g *GetTransactionHistoryRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := g.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if g.isVarSlice(_v) { + g.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (g *GetTransactionHistoryRequest) GetParametersJSON() ([]byte, error) { + params, err := g.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (g *GetTransactionHistoryRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (g *GetTransactionHistoryRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (g *GetTransactionHistoryRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (g *GetTransactionHistoryRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (g *GetTransactionHistoryRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := g.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (g *GetTransactionHistoryRequest) GetPath() string { + return "/api/v5/trade/fills-history" +} + +// Do generates the request object and send the request object to the API endpoint +func (g *GetTransactionHistoryRequest) Do(ctx context.Context) ([]Trade, error) { + if err := GetTransactionHistoryRequestLimiter.Wait(ctx); err != nil { + return nil, err + } + + // no body params + var params interface{} + query, err := g.GetQueryParameters() + if err != nil { + return nil, err + } + + var apiURL string + + apiURL = g.GetPath() + + req, err := g.client.NewAuthenticatedRequest(ctx, "GET", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := g.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + + type responseUnmarshaler interface { + Unmarshal(data []byte) error + } + + if unmarshaler, ok := interface{}(&apiResponse).(responseUnmarshaler); ok { + if err := unmarshaler.Unmarshal(response.Body); err != nil { + return nil, err + } + } else { + // The line below checks the content type, however, some API server might not send the correct content type header, + // Hence, this is commented for backward compatibility + // response.IsJSON() + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + } + + type responseValidator interface { + Validate() error + } + + if validator, ok := interface{}(&apiResponse).(responseValidator); ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data []Trade + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/okex/okexapi/market.go b/pkg/exchange/okex/okexapi/market.go new file mode 100644 index 0000000..7f23e2c --- /dev/null +++ b/pkg/exchange/okex/okexapi/market.go @@ -0,0 +1,102 @@ +package okexapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" +) + +type MarketTickersRequest struct { + client *RestClient + + instType string +} + +func (r *MarketTickersRequest) InstrumentType(instType string) *MarketTickersRequest { + r.instType = instType + return r +} + +func (r *MarketTickersRequest) Do(ctx context.Context) ([]MarketTicker, error) { + // SPOT, SWAP, FUTURES, OPTION + var params = url.Values{} + params.Add("instType", string(r.instType)) + + req, err := r.client.NewRequest(ctx, "GET", "/api/v5/market/tickers", params, nil) + if err != nil { + return nil, err + } + + response, err := r.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data []MarketTicker + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + + return data, nil +} + +type MarketTickerRequest struct { + client *RestClient + + instId string +} + +func (r *MarketTickerRequest) InstrumentID(instId string) *MarketTickerRequest { + r.instId = instId + return r +} + +func (r *MarketTickerRequest) Do(ctx context.Context) (*MarketTicker, error) { + // SPOT, SWAP, FUTURES, OPTION + var params = url.Values{} + params.Add("instId", r.instId) + + req, err := r.client.NewRequest(ctx, "GET", "/api/v5/market/ticker", params, nil) + if err != nil { + return nil, err + } + + response, err := r.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data []MarketTicker + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + + if len(data) == 0 { + return nil, fmt.Errorf("ticker of %s not found", r.instId) + } + + return &data[0], nil +} + +func (c *RestClient) NewMarketTickerRequest(instId string) *MarketTickerRequest { + return &MarketTickerRequest{ + client: c, + instId: instId, + } +} + +func (c *RestClient) NewMarketTickersRequest(instType string) *MarketTickersRequest { + return &MarketTickersRequest{ + client: c, + instType: instType, + } +} diff --git a/pkg/exchange/okex/okexapi/place_order_request.go b/pkg/exchange/okex/okexapi/place_order_request.go new file mode 100644 index 0000000..3f87992 --- /dev/null +++ b/pkg/exchange/okex/okexapi/place_order_request.go @@ -0,0 +1,70 @@ +package okexapi + +import "github.com/c9s/requestgen" + +type TradeMode string + +const ( + TradeModeCash TradeMode = "cash" + TradeModeIsolated TradeMode = "isolated" + TradeModeCross TradeMode = "cross" +) + +type TargetCurrency string + +const ( + TargetCurrencyBase TargetCurrency = "base_ccy" + TargetCurrencyQuote TargetCurrency = "quote_ccy" +) + +//go:generate -command GetRequest requestgen -method GET -responseType .APIResponse -responseDataField Data +//go:generate -command PostRequest requestgen -method POST -responseType .APIResponse -responseDataField Data + +type OrderResponse struct { + OrderID string `json:"ordId"` + ClientOrderID string `json:"clOrdId"` + Tag string `json:"tag"` + Code string `json:"sCode"` + Message string `json:"sMsg"` +} + +//go:generate PostRequest -url "/api/v5/trade/order" -type PlaceOrderRequest -responseDataType []OrderResponse +type PlaceOrderRequest struct { + client requestgen.AuthenticatedAPIClient + + instrumentID string `param:"instId"` + + // tdMode + // margin mode: "cross", "isolated" + // non-margin mode cash + tradeMode TradeMode `param:"tdMode" validValues:"cross,isolated,cash"` + + // A combination of case-sensitive alphanumerics, all numbers, or all letters of up to 32 characters. + clientOrderID *string `param:"clOrdId"` + + // A combination of case-sensitive alphanumerics, all numbers, or all letters of up to 8 characters. + tag *string `param:"tag"` + + // "buy" or "sell" + side SideType `param:"side" validValues:"buy,sell"` + + orderType OrderType `param:"ordType"` + + size string `param:"sz"` + + // price + price *string `param:"px"` + + // Whether the target currency uses the quote or base currency. + // base_ccy: Base currency ,quote_ccy: Quote currency + // Only applicable to SPOT Market Orders + // Default is quote_ccy for buy, base_ccy for sell + targetCurrency *TargetCurrency `param:"tgtCcy" validValues:"quote_ccy,base_ccy"` +} + +func (c *RestClient) NewPlaceOrderRequest() *PlaceOrderRequest { + return &PlaceOrderRequest{ + client: c, + tradeMode: TradeModeCash, + } +} diff --git a/pkg/exchange/okex/okexapi/place_order_request_requestgen.go b/pkg/exchange/okex/okexapi/place_order_request_requestgen.go new file mode 100644 index 0000000..3303d59 --- /dev/null +++ b/pkg/exchange/okex/okexapi/place_order_request_requestgen.go @@ -0,0 +1,305 @@ +// Code generated by "requestgen -method POST -responseType .APIResponse -responseDataField Data -url /api/v5/trade/order -type PlaceOrderRequest -responseDataType []OrderResponse"; DO NOT EDIT. + +package okexapi + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" +) + +func (r *PlaceOrderRequest) InstrumentID(instrumentID string) *PlaceOrderRequest { + r.instrumentID = instrumentID + return r +} + +func (r *PlaceOrderRequest) TradeMode(tradeMode TradeMode) *PlaceOrderRequest { + r.tradeMode = tradeMode + return r +} + +func (r *PlaceOrderRequest) ClientOrderID(clientOrderID string) *PlaceOrderRequest { + r.clientOrderID = &clientOrderID + return r +} + +func (r *PlaceOrderRequest) Tag(tag string) *PlaceOrderRequest { + r.tag = &tag + return r +} + +func (r *PlaceOrderRequest) Side(side SideType) *PlaceOrderRequest { + r.side = side + return r +} + +func (r *PlaceOrderRequest) OrderType(orderType OrderType) *PlaceOrderRequest { + r.orderType = orderType + return r +} + +func (r *PlaceOrderRequest) Size(size string) *PlaceOrderRequest { + r.size = size + return r +} + +func (r *PlaceOrderRequest) Price(price string) *PlaceOrderRequest { + r.price = &price + return r +} + +func (r *PlaceOrderRequest) TargetCurrency(targetCurrency TargetCurrency) *PlaceOrderRequest { + r.targetCurrency = &targetCurrency + return r +} + +// GetQueryParameters builds and checks the query parameters and returns url.Values +func (r *PlaceOrderRequest) GetQueryParameters() (url.Values, error) { + var params = map[string]interface{}{} + + query := url.Values{} + for _k, _v := range params { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + + return query, nil +} + +// GetParameters builds and checks the parameters and return the result in a map object +func (r *PlaceOrderRequest) GetParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + // check instrumentID field -> json key instId + instrumentID := r.instrumentID + + // assign parameter of instrumentID + params["instId"] = instrumentID + // check tradeMode field -> json key tdMode + tradeMode := r.tradeMode + + // TEMPLATE check-valid-values + switch tradeMode { + case "cross", "isolated", "cash": + params["tdMode"] = tradeMode + + default: + return nil, fmt.Errorf("tdMode value %v is invalid", tradeMode) + + } + // END TEMPLATE check-valid-values + + // assign parameter of tradeMode + params["tdMode"] = tradeMode + // check clientOrderID field -> json key clOrdId + if r.clientOrderID != nil { + clientOrderID := *r.clientOrderID + + // assign parameter of clientOrderID + params["clOrdId"] = clientOrderID + } else { + } + // check tag field -> json key tag + if r.tag != nil { + tag := *r.tag + + // assign parameter of tag + params["tag"] = tag + } else { + } + // check side field -> json key side + side := r.side + + // TEMPLATE check-valid-values + switch side { + case "buy", "sell": + params["side"] = side + + default: + return nil, fmt.Errorf("side value %v is invalid", side) + + } + // END TEMPLATE check-valid-values + + // assign parameter of side + params["side"] = side + // check orderType field -> json key ordType + orderType := r.orderType + + // TEMPLATE check-valid-values + switch orderType { + case OrderTypeMarket, OrderTypeLimit, OrderTypePostOnly, OrderTypeFOK, OrderTypeIOC: + params["ordType"] = orderType + + default: + return nil, fmt.Errorf("ordType value %v is invalid", orderType) + + } + // END TEMPLATE check-valid-values + + // assign parameter of orderType + params["ordType"] = orderType + // check size field -> json key sz + size := r.size + + // assign parameter of size + params["sz"] = size + // check price field -> json key px + if r.price != nil { + price := *r.price + + // assign parameter of price + params["px"] = price + } else { + } + // check targetCurrency field -> json key tgtCcy + if r.targetCurrency != nil { + targetCurrency := *r.targetCurrency + + // TEMPLATE check-valid-values + switch targetCurrency { + case "quote_ccy", "base_ccy": + params["tgtCcy"] = targetCurrency + + default: + return nil, fmt.Errorf("tgtCcy value %v is invalid", targetCurrency) + + } + // END TEMPLATE check-valid-values + + // assign parameter of targetCurrency + params["tgtCcy"] = targetCurrency + } else { + } + + return params, nil +} + +// GetParametersQuery converts the parameters from GetParameters into the url.Values format +func (r *PlaceOrderRequest) GetParametersQuery() (url.Values, error) { + query := url.Values{} + + params, err := r.GetParameters() + if err != nil { + return query, err + } + + for _k, _v := range params { + if r.isVarSlice(_v) { + r.iterateSlice(_v, func(it interface{}) { + query.Add(_k+"[]", fmt.Sprintf("%v", it)) + }) + } else { + query.Add(_k, fmt.Sprintf("%v", _v)) + } + } + + return query, nil +} + +// GetParametersJSON converts the parameters from GetParameters into the JSON format +func (r *PlaceOrderRequest) GetParametersJSON() ([]byte, error) { + params, err := r.GetParameters() + if err != nil { + return nil, err + } + + return json.Marshal(params) +} + +// GetSlugParameters builds and checks the slug parameters and return the result in a map object +func (r *PlaceOrderRequest) GetSlugParameters() (map[string]interface{}, error) { + var params = map[string]interface{}{} + + return params, nil +} + +func (r *PlaceOrderRequest) applySlugsToUrl(url string, slugs map[string]string) string { + for _k, _v := range slugs { + needleRE := regexp.MustCompile(":" + _k + "\\b") + url = needleRE.ReplaceAllString(url, _v) + } + + return url +} + +func (r *PlaceOrderRequest) iterateSlice(slice interface{}, _f func(it interface{})) { + sliceValue := reflect.ValueOf(slice) + for _i := 0; _i < sliceValue.Len(); _i++ { + it := sliceValue.Index(_i).Interface() + _f(it) + } +} + +func (r *PlaceOrderRequest) isVarSlice(_v interface{}) bool { + rt := reflect.TypeOf(_v) + switch rt.Kind() { + case reflect.Slice: + return true + } + return false +} + +func (r *PlaceOrderRequest) GetSlugsMap() (map[string]string, error) { + slugs := map[string]string{} + params, err := r.GetSlugParameters() + if err != nil { + return slugs, nil + } + + for _k, _v := range params { + slugs[_k] = fmt.Sprintf("%v", _v) + } + + return slugs, nil +} + +// GetPath returns the request path of the API +func (r *PlaceOrderRequest) GetPath() string { + return "/api/v5/trade/order" +} + +// Do generates the request object and send the request object to the API endpoint +func (r *PlaceOrderRequest) Do(ctx context.Context) ([]OrderResponse, error) { + + params, err := r.GetParameters() + if err != nil { + return nil, err + } + query := url.Values{} + + var apiURL string + + apiURL = r.GetPath() + + req, err := r.client.NewAuthenticatedRequest(ctx, "POST", apiURL, query, params) + if err != nil { + return nil, err + } + + response, err := r.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + + type responseValidator interface { + Validate() error + } + validator, ok := interface{}(apiResponse).(responseValidator) + if ok { + if err := validator.Validate(); err != nil { + return nil, err + } + } + var data []OrderResponse + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/exchange/okex/okexapi/public.go b/pkg/exchange/okex/okexapi/public.go new file mode 100644 index 0000000..5cedded --- /dev/null +++ b/pkg/exchange/okex/okexapi/public.go @@ -0,0 +1,67 @@ +package okexapi + +import ( + "context" + "encoding/json" + "net/url" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/pkg/errors" +) + +func (s *RestClient) NewGetFundingRate() *GetFundingRateRequest { + return &GetFundingRateRequest{ + client: s, + } +} + +type FundingRate struct { + InstrumentType string `json:"instType"` + InstrumentID string `json:"instId"` + FundingRate fixedpoint.Value `json:"fundingRate"` + NextFundingRate fixedpoint.Value `json:"nextFundingRate"` + FundingTime types.MillisecondTimestamp `json:"fundingTime"` +} + +type GetFundingRateRequest struct { + client *RestClient + + instId string +} + +func (r *GetFundingRateRequest) InstrumentID(instId string) *GetFundingRateRequest { + r.instId = instId + return r +} + +func (r *GetFundingRateRequest) Do(ctx context.Context) (*FundingRate, error) { + // SPOT, SWAP, FUTURES, OPTION + var params = url.Values{} + params.Add("instId", string(r.instId)) + + req, err := r.client.NewRequest(ctx, "GET", "/api/v5/public/funding-rate", params, nil) + if err != nil { + return nil, err + } + + response, err := r.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data []FundingRate + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + + if len(data) == 0 { + return nil, errors.New("empty funding rate data") + } + + return &data[0], nil +} diff --git a/pkg/exchange/okex/okexapi/testdata/get_three_days_transaction_history_request.json b/pkg/exchange/okex/okexapi/testdata/get_three_days_transaction_history_request.json new file mode 100644 index 0000000..d3cbf4b --- /dev/null +++ b/pkg/exchange/okex/okexapi/testdata/get_three_days_transaction_history_request.json @@ -0,0 +1,32 @@ +{ + "code":"0", + "data":[ + { + "side":"buy", + "fillSz":"0.001", + "fillPx":"73397.8", + "fillPxVol":"", + "fillFwdPx":"", + "fee":"-0.000001", + "fillPnl":"0", + "ordId":"688362711456706560", + "feeRate":"-0.001", + "instType":"SPOT", + "fillPxUsd":"", + "instId":"BTC-USDT", + "clOrdId":"1229606897", + "posSide":"net", + "billId":"688362711465447466", + "fillMarkVol":"", + "tag":"", + "fillTime":"1710390459571", + "execType":"T", + "fillIdxPx":"", + "tradeId":"749554213", + "fillMarkPx":"", + "feeCcy":"BTC", + "ts":"1710390459574" + } + ], + "msg":"" +} diff --git a/pkg/exchange/okex/okexapi/testdata/get_transaction_history_request.json b/pkg/exchange/okex/okexapi/testdata/get_transaction_history_request.json new file mode 100644 index 0000000..d3cbf4b --- /dev/null +++ b/pkg/exchange/okex/okexapi/testdata/get_transaction_history_request.json @@ -0,0 +1,32 @@ +{ + "code":"0", + "data":[ + { + "side":"buy", + "fillSz":"0.001", + "fillPx":"73397.8", + "fillPxVol":"", + "fillFwdPx":"", + "fee":"-0.000001", + "fillPnl":"0", + "ordId":"688362711456706560", + "feeRate":"-0.001", + "instType":"SPOT", + "fillPxUsd":"", + "instId":"BTC-USDT", + "clOrdId":"1229606897", + "posSide":"net", + "billId":"688362711465447466", + "fillMarkVol":"", + "tag":"", + "fillTime":"1710390459571", + "execType":"T", + "fillIdxPx":"", + "tradeId":"749554213", + "fillMarkPx":"", + "feeCcy":"BTC", + "ts":"1710390459574" + } + ], + "msg":"" +} diff --git a/pkg/exchange/okex/okexapi/trade.go b/pkg/exchange/okex/okexapi/trade.go new file mode 100644 index 0000000..f245b8f --- /dev/null +++ b/pkg/exchange/okex/okexapi/trade.go @@ -0,0 +1,311 @@ +package okexapi + +import ( + "context" + "encoding/json" + "net/url" + + "github.com/pkg/errors" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func (c *RestClient) NewBatchPlaceOrderRequest() *BatchPlaceOrderRequest { + return &BatchPlaceOrderRequest{ + client: c, + } +} + +func (c *RestClient) NewBatchCancelOrderRequest() *BatchCancelOrderRequest { + return &BatchCancelOrderRequest{ + client: c, + } +} + +func (c *RestClient) NewGetOrderDetailsRequest() *GetOrderDetailsRequest { + return &GetOrderDetailsRequest{ + client: c, + } +} + +func (c *RestClient) NewGetTransactionDetailsRequest() *GetTransactionDetailsRequest { + return &GetTransactionDetailsRequest{ + client: c, + } +} + +func (r *PlaceOrderRequest) Parameters() map[string]interface{} { + params, _ := r.GetParameters() + return params +} + +type BatchCancelOrderRequest struct { + client *RestClient + + reqs []*CancelOrderRequest +} + +func (r *BatchCancelOrderRequest) Add(reqs ...*CancelOrderRequest) *BatchCancelOrderRequest { + r.reqs = append(r.reqs, reqs...) + return r +} + +func (r *BatchCancelOrderRequest) Do(ctx context.Context) ([]OrderResponse, error) { + var parameterList []map[string]interface{} + + for _, req := range r.reqs { + params, err := req.GetParameters() + if err != nil { + return nil, err + } + parameterList = append(parameterList, params) + } + + req, err := r.client.NewAuthenticatedRequest(ctx, "POST", "/api/v5/trade/cancel-batch-orders", nil, parameterList) + if err != nil { + return nil, err + } + + response, err := r.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data []OrderResponse + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + + return data, nil +} + +type BatchPlaceOrderRequest struct { + client *RestClient + + reqs []*PlaceOrderRequest +} + +func (r *BatchPlaceOrderRequest) Add(reqs ...*PlaceOrderRequest) *BatchPlaceOrderRequest { + r.reqs = append(r.reqs, reqs...) + return r +} + +func (r *BatchPlaceOrderRequest) Do(ctx context.Context) ([]OrderResponse, error) { + var parameterList []map[string]interface{} + + for _, req := range r.reqs { + params := req.Parameters() + parameterList = append(parameterList, params) + } + + req, err := r.client.NewAuthenticatedRequest(ctx, "POST", "/api/v5/trade/batch-orders", nil, parameterList) + if err != nil { + return nil, err + } + + response, err := r.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data []OrderResponse + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + + return data, nil +} + +type OrderDetails struct { + InstrumentType InstrumentType `json:"instType"` + InstrumentID string `json:"instId"` + Tag string `json:"tag"` + Price fixedpoint.Value `json:"px"` + Quantity fixedpoint.Value `json:"sz"` + + OrderID string `json:"ordId"` + ClientOrderID string `json:"clOrdId"` + OrderType OrderType `json:"ordType"` + Side SideType `json:"side"` + + // Accumulated fill quantity + FilledQuantity fixedpoint.Value `json:"accFillSz"` + + FeeCurrency string `json:"feeCcy"` + Fee fixedpoint.Value `json:"fee"` + + // trade related fields + LastTradeID string `json:"tradeId,omitempty"` + LastFilledPrice fixedpoint.Value `json:"fillPx"` + LastFilledQuantity fixedpoint.Value `json:"fillSz"` + LastFilledTime types.MillisecondTimestamp `json:"fillTime"` + LastFilledFee fixedpoint.Value `json:"fillFee"` + LastFilledFeeCurrency string `json:"fillFeeCcy"` + LastFilledPnl fixedpoint.Value `json:"fillPnl"` + BillID types.StrInt64 `json:"billId"` + + // ExecutionType = liquidity (M = maker or T = taker) + ExecutionType string `json:"execType"` + + // Average filled price. If none is filled, it will return 0. + AveragePrice fixedpoint.Value `json:"avgPx"` + + // Currency = Margin currency + // Only applicable to cross MARGIN orders in Single-currency margin. + Currency string `json:"ccy"` + + // Leverage = from 0.01 to 125. + // Only applicable to MARGIN/FUTURES/SWAP + Leverage fixedpoint.Value `json:"lever"` + + RebateCurrency string `json:"rebateCcy"` + Rebate fixedpoint.Value `json:"rebate"` + + PnL fixedpoint.Value `json:"pnl"` + + UpdateTime types.MillisecondTimestamp `json:"uTime"` + CreationTime types.MillisecondTimestamp `json:"cTime"` + + State OrderState `json:"state"` +} + +type GetOrderDetailsRequest struct { + client *RestClient + + instId string + ordId *string + clOrdId *string +} + +func (r *GetOrderDetailsRequest) InstrumentID(instId string) *GetOrderDetailsRequest { + r.instId = instId + return r +} + +func (r *GetOrderDetailsRequest) OrderID(orderID string) *GetOrderDetailsRequest { + r.ordId = &orderID + return r +} + +func (r *GetOrderDetailsRequest) ClientOrderID(clientOrderID string) *GetOrderDetailsRequest { + r.clOrdId = &clientOrderID + return r +} + +func (r *GetOrderDetailsRequest) QueryParameters() url.Values { + var values = url.Values{} + + values.Add("instId", r.instId) + + if r.ordId != nil { + values.Add("ordId", *r.ordId) + } else if r.clOrdId != nil { + values.Add("clOrdId", *r.clOrdId) + } + + return values +} + +func (r *GetOrderDetailsRequest) Do(ctx context.Context) (*OrderDetails, error) { + params := r.QueryParameters() + req, err := r.client.NewAuthenticatedRequest(ctx, "GET", "/api/v5/trade/order", params, nil) + if err != nil { + return nil, err + } + + response, err := r.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data []OrderDetails + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + + if len(data) == 0 { + return nil, errors.New("get order details error") + } + + return &data[0], nil +} + +type GetTransactionDetailsRequest struct { + client *RestClient + + instType *InstrumentType + + instId *string + + ordId *string +} + +func (r *GetTransactionDetailsRequest) InstrumentType(instType InstrumentType) *GetTransactionDetailsRequest { + r.instType = &instType + return r +} + +func (r *GetTransactionDetailsRequest) InstrumentID(instId string) *GetTransactionDetailsRequest { + r.instId = &instId + return r +} + +func (r *GetTransactionDetailsRequest) OrderID(orderID string) *GetTransactionDetailsRequest { + r.ordId = &orderID + return r +} + +func (r *GetTransactionDetailsRequest) Parameters() map[string]interface{} { + var payload = map[string]interface{}{} + + if r.instType != nil { + payload["instType"] = r.instType + } + + if r.instId != nil { + payload["instId"] = r.instId + } + + if r.ordId != nil { + payload["ordId"] = r.ordId + } + + return payload +} + +func (r *GetTransactionDetailsRequest) Do(ctx context.Context) ([]OrderDetails, error) { + payload := r.Parameters() + req, err := r.client.NewAuthenticatedRequest(ctx, "GET", "/api/v5/trade/fills", nil, payload) + if err != nil { + return nil, err + } + + response, err := r.client.SendRequest(req) + if err != nil { + return nil, err + } + + var apiResponse APIResponse + if err := response.DecodeJSON(&apiResponse); err != nil { + return nil, err + } + var data []OrderDetails + if err := json.Unmarshal(apiResponse.Data, &data); err != nil { + return nil, err + } + + return data, nil +} diff --git a/pkg/exchange/okex/parse.go b/pkg/exchange/okex/parse.go new file mode 100644 index 0000000..cc0df44 --- /dev/null +++ b/pkg/exchange/okex/parse.go @@ -0,0 +1,437 @@ +package okex + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/okex/okexapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type Channel string + +const ( + // books: 400 depth levels will be pushed in the initial full snapshot. + // Incremental data will be pushed every 100 ms for the changes in the order book during that period of time. + ChannelBooks Channel = "books" + + // ChannelBooks5 is books5 + // 5 depth levels snapshot will be pushed every time. + // Snapshot data will be pushed every 100 ms when there are changes in the 5 depth levels snapshot. + ChannelBooks5 Channel = "books5" + + // ChannelBooks50 is books50-l2-tbt: + // 50 depth levels will be pushed in the initial full snapshot. + // Incremental data will be pushed every 10 ms for the changes in the order book during that period of time. + ChannelBooks50 Channel = "books50-l2-tbt" + + // ChannelBooks1 is bbo-tbt + // 1 depth level snapshot will be pushed every time. + // Snapshot data will be pushed every 10 ms when there are changes in the 1 depth level snapshot. + ChannelBooks1 Channel = "bbo-tbt" + + ChannelCandlePrefix Channel = "candle" + ChannelAccount Channel = "account" + ChannelMarketTrades Channel = "trades" + ChannelOrderTrades Channel = "orders" +) + +type ActionType string + +const ( + ActionTypeSnapshot ActionType = "snapshot" + ActionTypeUpdate ActionType = "update" +) + +func parseWebSocketEvent(in []byte) (interface{}, error) { + var event WebSocketEvent + err := json.Unmarshal(in, &event) + if err != nil { + return nil, err + } + if event.Event != "" { + return &event, nil + } + + switch event.Arg.Channel { + case ChannelAccount: + return parseAccount(event.Data) + + case ChannelBooks, ChannelBooks5: + var bookEvent BookEvent + err = json.Unmarshal(event.Data, &bookEvent.Data) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal data into BookEvent, Arg: %+v Data: %s, err: %w", event.Arg, string(event.Data), err) + } + + instId := event.Arg.InstId + bookEvent.InstrumentID = instId + bookEvent.Symbol = toGlobalSymbol(instId) + bookEvent.channel = event.Arg.Channel + bookEvent.Action = event.ActionType + return &bookEvent, nil + + case ChannelMarketTrades: + var trade []MarketTradeEvent + err = json.Unmarshal(event.Data, &trade) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal data into MarketTradeEvent: %+v, err: %w", string(event.Data), err) + } + return trade, nil + + case ChannelOrderTrades: + var orderTrade []OrderTradeEvent + err := json.Unmarshal(event.Data, &orderTrade) + if err != nil { + return nil, err + } + + return orderTrade, nil + + default: + if strings.HasPrefix(string(event.Arg.Channel), string(ChannelCandlePrefix)) { + // TODO: Support kline subscription. The kline requires another URL to subscribe, which is why we cannot + // support it at this time. + var kLineEvt KLineEvent + err = json.Unmarshal(event.Data, &kLineEvt.Events) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal data into KLineEvent, Arg: %+v Data: %s, err: %w", event.Arg, string(event.Data), err) + } + + kLineEvt.Channel = event.Arg.Channel + kLineEvt.InstrumentID = event.Arg.InstId + kLineEvt.Symbol = toGlobalSymbol(event.Arg.InstId) + kLineEvt.Interval = strings.ToLower(strings.TrimPrefix(string(event.Arg.Channel), string(ChannelCandlePrefix))) + return &kLineEvt, nil + } + } + + return nil, nil +} + +type WsEventType string + +const ( + WsEventTypeLogin WsEventType = "login" + WsEventTypeError WsEventType = "error" + WsEventTypeSubscribe WsEventType = "subscribe" + WsEventTypeUnsubscribe WsEventType = "unsubscribe" + WsEventTypeConnectionInfo WsEventType = "channel-conn-count" + WsEventTypeConnectionError WsEventType = "channel-conn-count-error" +) + +type WebSocketEvent struct { + Event WsEventType `json:"event"` + Code string `json:"code,omitempty"` + Message string `json:"msg,omitempty"` + Arg struct { + Channel Channel `json:"channel"` + InstId string `json:"instId"` + } `json:"arg,omitempty"` + Data json.RawMessage `json:"data"` + ActionType ActionType `json:"action"` + Channel Channel `json:"channel"` + ConnCount string `json:"connCount"` +} + +func (w *WebSocketEvent) IsValid() error { + switch w.Event { + case WsEventTypeError: + return fmt.Errorf("websocket request error, code: %s, msg: %s", w.Code, w.Message) + + case WsEventTypeSubscribe, WsEventTypeUnsubscribe: + return nil + + case WsEventTypeLogin: + // Actually, this code is unnecessary because the events are either `Subscribe` or `Unsubscribe`, But to avoid bugs + // in the exchange, we still check. + if w.Code != "0" || len(w.Message) != 0 { + return fmt.Errorf("websocket request error, code: %s, msg: %s", w.Code, w.Message) + } + return nil + + case WsEventTypeConnectionInfo: + return nil + + case WsEventTypeConnectionError: + return fmt.Errorf("connection rate limit exceeded, channel: %s, connCount: %s", w.Channel, w.ConnCount) + + default: + return fmt.Errorf("unexpected event type: %+v", w) + } +} + +func (w *WebSocketEvent) IsAuthenticated() bool { + return w.Event == WsEventTypeLogin && w.Code == "0" +} + +type BookEvent struct { + InstrumentID string + Symbol string + Action ActionType + channel Channel + + Data []struct { + Bids PriceVolumeOrderSlice `json:"bids"` + Asks PriceVolumeOrderSlice `json:"asks"` + MillisecondTimestamp types.MillisecondTimestamp `json:"ts"` + Checksum int `json:"checksum"` + } +} + +func (event *BookEvent) BookTicker() types.BookTicker { + ticker := types.BookTicker{ + Symbol: event.Symbol, + } + + if len(event.Data) > 0 { + if len(event.Data[0].Bids) > 0 { + ticker.Buy = event.Data[0].Bids[0].Price + ticker.BuySize = event.Data[0].Bids[0].Volume + } + + if len(event.Data[0].Asks) > 0 { + ticker.Sell = event.Data[0].Asks[0].Price + ticker.SellSize = event.Data[0].Asks[0].Volume + } + } + + return ticker +} + +func (event *BookEvent) Book() types.SliceOrderBook { + book := types.SliceOrderBook{ + Symbol: event.Symbol, + } + + if len(event.Data) > 0 { + book.Time = event.Data[0].MillisecondTimestamp.Time() + } + + for _, data := range event.Data { + for _, bid := range data.Bids { + book.Bids = append(book.Bids, types.PriceVolume{Price: bid.Price, Volume: bid.Volume}) + } + + for _, ask := range data.Asks { + book.Asks = append(book.Asks, types.PriceVolume{Price: ask.Price, Volume: ask.Volume}) + } + } + + return book +} + +type PriceVolumeOrder struct { + types.PriceVolume + // NumLiquidated is part of a deprecated feature and it is always "0" + NumLiquidated int + // NumOrders is the number of orders at the price. + NumOrders int +} + +type PriceVolumeOrderSlice []PriceVolumeOrder + +func (slice *PriceVolumeOrderSlice) UnmarshalJSON(b []byte) error { + s, err := ParsePriceVolumeOrderSliceJSON(b) + if err != nil { + return err + } + + *slice = s + return nil +} + +// ParsePriceVolumeOrderSliceJSON tries to parse a 2 dimensional string array into a PriceVolumeOrderSlice +// +// [["8476.98", "415", "0", "13"], ["8477", "7", "0", "2"], ... ] +func ParsePriceVolumeOrderSliceJSON(b []byte) (slice PriceVolumeOrderSlice, err error) { + var as [][]fixedpoint.Value + + err = json.Unmarshal(b, &as) + if err != nil { + return slice, fmt.Errorf("failed to unmarshal price volume order slice: %w", err) + } + + for _, a := range as { + var pv PriceVolumeOrder + pv.Price = a[0] + pv.Volume = a[1] + pv.NumLiquidated = a[2].Int() + pv.NumOrders = a[3].Int() + + slice = append(slice, pv) + } + + return slice, nil +} + +func kLineToGlobal(k okexapi.KLine, interval types.Interval, symbol string) types.KLine { + startTime := k.StartTime.Time() + + return types.KLine{ + Exchange: types.ExchangeOKEx, + Symbol: symbol, + StartTime: types.Time(startTime), + EndTime: types.Time(startTime.Add(interval.Duration() - time.Millisecond)), + Interval: interval, + Open: k.OpenPrice, + Close: k.ClosePrice, + High: k.HighestPrice, + Low: k.LowestPrice, + Volume: k.Volume, + QuoteVolume: k.VolumeInCurrency, // not supported + TakerBuyBaseAssetVolume: fixedpoint.Zero, // not supported + TakerBuyQuoteAssetVolume: fixedpoint.Zero, // not supported + LastTradeID: 0, // not supported + NumberOfTrades: 0, // not supported + Closed: !k.Confirm.IsZero(), + } +} + +type KLineEvent struct { + Events okexapi.KLineSlice + + InstrumentID string + Symbol string + Interval string + Channel Channel +} + +func parseAccount(v []byte) (*okexapi.Account, error) { + var accounts []okexapi.Account + err := json.Unmarshal(v, &accounts) + if err != nil { + return nil, err + } + + if len(accounts) == 0 { + return &okexapi.Account{}, nil + } + + return &accounts[0], nil +} + +type OrderTradeEvent struct { + okexapi.OrderDetail + + Code types.StrInt64 `json:"code"` + Msg string `json:"msg"` + AmendResult string `json:"amendResult"` + ExecutionType okexapi.LiquidityType `json:"execType"` + // FillFee last filled fee amount or rebate amount: + // Negative number represents the user transaction fee charged by the platform; + // Positive number represents rebate + FillFee fixedpoint.Value `json:"fillFee"` + // FillFeeCurrency last filled fee currency or rebate currency. + // It is fee currency when fillFee is less than 0; It is rebate currency when fillFee>=0. + FillFeeCurrency string `json:"fillFeeCcy"` + // FillNotionalUsd Filled notional value in USD of order + FillNotionalUsd fixedpoint.Value `json:"fillNotionalUsd"` + FillPnl fixedpoint.Value `json:"fillPnl"` + // NotionalUsd Estimated national value in USD of order + NotionalUsd fixedpoint.Value `json:"notionalUsd"` + // ReqId Client Request ID as assigned by the client for order amendment. "" will be returned if there is no order amendment. + ReqId string `json:"reqId"` + LastPrice fixedpoint.Value `json:"lastPx"` + // QuickMgnType Quick Margin type, Only applicable to Quick Margin Mode of isolated margin + // manual, auto_borrow, auto_repay + QuickMgnType string `json:"quickMgnType"` + // AmendSource Source of the order amendation. + AmendSource string `json:"amendSource"` + // CancelSource Source of the order cancellation. + CancelSource string `json:"cancelSource"` + + // Only applicable to options; return "" for other instrument types + FillPriceVolume string `json:"fillPxVol"` + FillPriceUsd string `json:"fillPxUsd"` + FillMarkVolume string `json:"fillMarkVol"` + FillFwdPrice string `json:"fillFwdPx"` + FillMarkPrice string `json:"fillMarkPx"` +} + +func (o *OrderTradeEvent) toGlobalTrade() (types.Trade, error) { + side := toGlobalSide(o.Side) + tradeId, err := strconv.ParseUint(o.TradeId, 10, 64) + if err != nil { + return types.Trade{}, fmt.Errorf("unexpected trade id [%s] format: %w", o.TradeId, err) + } + return types.Trade{ + ID: tradeId, + OrderID: uint64(o.OrderId), + Exchange: types.ExchangeOKEx, + Price: o.FillPrice, + Quantity: o.FillSize, + QuoteQuantity: o.FillPrice.Mul(o.FillSize), + Symbol: toGlobalSymbol(o.InstrumentID), + Side: side, + IsBuyer: side == types.SideTypeBuy, + IsMaker: o.ExecutionType == okexapi.LiquidityTypeMaker, + Time: types.Time(o.FillTime.Time()), + // charged by the platform is positive in our design, so added the `Neg()`. + Fee: o.FillFee.Neg(), + FeeCurrency: o.FeeCurrency, + FeeDiscounted: false, + }, nil +} + +func toGlobalSideType(side okexapi.SideType) (types.SideType, error) { + switch side { + case okexapi.SideTypeBuy: + return types.SideTypeBuy, nil + + case okexapi.SideTypeSell: + return types.SideTypeSell, nil + + default: + return types.SideType(side), fmt.Errorf("unexpected side: %s", side) + } +} + +type MarketTradeEvent struct { + InstId string `json:"instId"` + TradeId types.StrInt64 `json:"tradeId"` + Px fixedpoint.Value `json:"px"` + Sz fixedpoint.Value `json:"sz"` + Side okexapi.SideType `json:"side"` + Timestamp types.MillisecondTimestamp `json:"ts"` + Count types.StrInt64 `json:"count"` +} + +func (m *MarketTradeEvent) toGlobalTrade() (types.Trade, error) { + symbol := toGlobalSymbol(m.InstId) + if symbol == "" { + return types.Trade{}, fmt.Errorf("unexpected inst id: %s", m.InstId) + } + + side, err := toGlobalSideType(m.Side) + if err != nil { + return types.Trade{}, err + } + + return types.Trade{ + ID: uint64(m.TradeId), + OrderID: 0, // not supported + Exchange: types.ExchangeOKEx, + Price: m.Px, + Quantity: m.Sz, + QuoteQuantity: m.Px.Mul(m.Sz), + Symbol: symbol, + Side: side, + IsBuyer: side == types.SideTypeBuy, + IsMaker: false, // not supported + Time: types.Time(m.Timestamp.Time()), + Fee: fixedpoint.Zero, // not supported + FeeCurrency: "", // not supported + }, nil +} + +type ConnectionInfoEvent struct { + Event string `json:"event"` + Channel Channel `json:"channel"` + ConnCount string `json:"connCount"` + ConnId string `json:"connId"` +} diff --git a/pkg/exchange/okex/parse_test.go b/pkg/exchange/okex/parse_test.go new file mode 100644 index 0000000..e52620f --- /dev/null +++ b/pkg/exchange/okex/parse_test.go @@ -0,0 +1,1181 @@ +package okex + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/okex/okexapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func Test_parseWebSocketEvent_accountEvent(t *testing.T) { + t.Run("succeeds", func(t *testing.T) { + in := ` +{ + "arg": { + "channel": "account", + "uid": "77982378738415879" + }, + "data": [ + { + "uTime": "1614846244194", + "totalEq": "91884", + "adjEq": "91884.8502560037982063", + "isoEq": "0", + "ordFroz": "0", + "imr": "0", + "mmr": "0", + "borrowFroz": "", + "notionalUsd": "", + "mgnRatio": "100000", + "details": [{ + "availBal": "", + "availEq": "1", + "ccy": "BTC", + "cashBal": "1", + "uTime": "1617279471503", + "disEq": "50559.01", + "eq": "1", + "eqUsd": "45078", + "fixedBal": "0", + "frozenBal": "0", + "interest": "0", + "isoEq": "0", + "liab": "0", + "maxLoan": "", + "mgnRatio": "", + "notionalLever": "0", + "ordFrozen": "0", + "upl": "0", + "uplLiab": "0", + "crossLiab": "0", + "isoLiab": "0", + "coinUsdPrice": "60000", + "stgyEq":"0", + "spotInUseAmt":"", + "isoUpl":"", + "borrowFroz": "" + }, + { + "availBal": "", + "availEq": "41307", + "ccy": "USDT", + "cashBal": "41307", + "uTime": "1617279471503", + "disEq": "41325", + "eq": "41307", + "eqUsd": "45078", + "fixedBal": "0", + "frozenBal": "0", + "interest": "0", + "isoEq": "0", + "liab": "0", + "maxLoan": "", + "mgnRatio": "", + "notionalLever": "0", + "ordFrozen": "0", + "upl": "0", + "uplLiab": "0", + "crossLiab": "0", + "isoLiab": "0", + "coinUsdPrice": "1.00007", + "stgyEq":"0", + "spotInUseAmt":"", + "isoUpl":"", + "borrowFroz": "" + } + ] + } + ] +} +` + + exp := &okexapi.Account{ + TotalEquityInUSD: fixedpoint.NewFromFloat(91884), + UpdateTime: types.NewMillisecondTimestampFromInt(1614846244194), + Details: []okexapi.BalanceDetail{ + { + Currency: "BTC", + Available: fixedpoint.NewFromFloat(1), + CashBalance: fixedpoint.NewFromFloat(1), + OrderFrozen: fixedpoint.Zero, + Frozen: fixedpoint.Zero, + Equity: fixedpoint.One, + EquityInUSD: fixedpoint.NewFromFloat(45078), + UpdateTime: types.NewMillisecondTimestampFromInt(1617279471503), + UnrealizedProfitAndLoss: fixedpoint.Zero, + }, + { + Currency: "USDT", + Available: fixedpoint.NewFromFloat(41307), + CashBalance: fixedpoint.NewFromFloat(41307), + OrderFrozen: fixedpoint.Zero, + Frozen: fixedpoint.Zero, + Equity: fixedpoint.NewFromFloat(41307), + EquityInUSD: fixedpoint.NewFromFloat(45078), + UpdateTime: types.NewMillisecondTimestampFromInt(1617279471503), + UnrealizedProfitAndLoss: fixedpoint.Zero, + }, + }, + } + + res, err := parseWebSocketEvent([]byte(in)) + assert.NoError(t, err) + event, ok := res.(*okexapi.Account) + assert.True(t, ok) + assert.Equal(t, exp, event) + }) + +} + +func TestParsePriceVolumeOrderSliceJSON(t *testing.T) { + t.Run("snapshot", func(t *testing.T) { + in := ` +{ + "arg": { + "channel": "books", + "instId": "BTC-USDT" + }, + "action": "snapshot", + "data": [ + { + "asks": [ + ["8476.98", "415", "0", "13"], + ["8477", "7", "0", "2"] + ], + "bids": [ + ["8476", "256", "0", "12"] + ], + "ts": "1597026383085", + "checksum": -855196043, + "prevSeqId": -1, + "seqId": 123456 + } + ] +} +` + + asks := PriceVolumeOrderSlice{ + { + PriceVolume: types.PriceVolume{ + Price: fixedpoint.NewFromFloat(8476.98), + Volume: fixedpoint.NewFromFloat(415), + }, + NumLiquidated: fixedpoint.Zero.Int(), + NumOrders: fixedpoint.NewFromFloat(13).Int(), + }, + { + PriceVolume: types.PriceVolume{ + Price: fixedpoint.NewFromFloat(8477), + Volume: fixedpoint.NewFromFloat(7), + }, + NumLiquidated: fixedpoint.Zero.Int(), + NumOrders: fixedpoint.NewFromFloat(2).Int(), + }, + } + bids := PriceVolumeOrderSlice{ + { + PriceVolume: types.PriceVolume{ + Price: fixedpoint.NewFromFloat(8476), + Volume: fixedpoint.NewFromFloat(256), + }, + NumLiquidated: fixedpoint.Zero.Int(), + NumOrders: fixedpoint.NewFromFloat(12).Int(), + }, + } + + res, err := parseWebSocketEvent([]byte(in)) + assert.NoError(t, err) + event, ok := res.(*BookEvent) + assert.True(t, ok) + assert.Equal(t, "BTCUSDT", event.Symbol) + assert.Equal(t, ChannelBooks, event.channel) + assert.Equal(t, ActionTypeSnapshot, event.Action) + assert.Len(t, event.Data, 1) + assert.Len(t, event.Data[0].Asks, 2) + assert.Equal(t, asks, event.Data[0].Asks) + assert.Len(t, event.Data[0].Bids, 1) + assert.Equal(t, bids, event.Data[0].Bids) + }) + + t.Run("unexpected asks", func(t *testing.T) { + t.Skip("this will cause panic, so i skip it") + in := ` +{ + "arg": { + "channel": "books", + "instId": "BTC-USDT" + }, + "action": "snapshot", + "data": [ + { + "asks": [ + ["XYZ", "415", "0", "13"] + ], + "bids": [ + ["8476", "256", "0", "12"] + ], + "ts": "1597026383085", + "checksum": -855196043, + "prevSeqId": -1, + "seqId": 123456 + } + ] +} +` + _, err := parseWebSocketEvent([]byte(in)) + assert.ErrorContains(t, err, "price volume order") + }) +} + +func TestBookEvent_BookTicker(t *testing.T) { + in := ` +{ + "arg": { + "channel": "books", + "instId": "BTC-USDT" + }, + "action": "snapshot", + "data": [ + { + "asks": [ + ["8476.98", "415", "0", "13"], + ["8477", "7", "0", "2"] + ], + "bids": [ + ["8476", "256", "0", "12"] + ], + "ts": "1597026383085", + "checksum": -855196043, + "prevSeqId": -1, + "seqId": 123456 + } + ] +} +` + + res, err := parseWebSocketEvent([]byte(in)) + assert.NoError(t, err) + event, ok := res.(*BookEvent) + assert.True(t, ok) + + ticker := event.BookTicker() + assert.Equal(t, types.BookTicker{ + Symbol: "BTCUSDT", + Buy: fixedpoint.NewFromFloat(8476), + BuySize: fixedpoint.NewFromFloat(256), + Sell: fixedpoint.NewFromFloat(8476.98), + SellSize: fixedpoint.NewFromFloat(415), + }, ticker) +} + +func TestBookEvent_Book(t *testing.T) { + in := ` +{ + "arg": { + "channel": "books", + "instId": "BTC-USDT" + }, + "action": "snapshot", + "data": [ + { + "asks": [ + ["8476.98", "415", "0", "13"], + ["8477", "7", "0", "2"] + ], + "bids": [ + ["8476", "256", "0", "12"] + ], + "ts": "1597026383085", + "checksum": -855196043, + "prevSeqId": -1, + "seqId": 123456 + } + ] +} +` + bids := types.PriceVolumeSlice{ + { + Price: fixedpoint.NewFromFloat(8476), + Volume: fixedpoint.NewFromFloat(256), + }, + } + asks := types.PriceVolumeSlice{ + { + Price: fixedpoint.NewFromFloat(8476.98), + Volume: fixedpoint.NewFromFloat(415), + }, + { + Price: fixedpoint.NewFromFloat(8477), + Volume: fixedpoint.NewFromFloat(7), + }, + } + + res, err := parseWebSocketEvent([]byte(in)) + assert.NoError(t, err) + event, ok := res.(*BookEvent) + assert.True(t, ok) + + book := event.Book() + assert.Equal(t, types.SliceOrderBook{ + Symbol: "BTCUSDT", + Time: types.NewMillisecondTimestampFromInt(1597026383085).Time(), + Bids: bids, + Asks: asks, + }, book) +} + +func Test_parseKLineSliceJSON(t *testing.T) { + t.Run("snapshot", func(t *testing.T) { + in := ` +{ + "arg": { + "channel": "candle1D", + "instId": "BTC-USDT" + }, + "data": [ + [ + "1597026383085", + "8533", + "8553.74", + "8527.17", + "8548.26", + "45247", + "529.5858061", + "529.5858061", + "0" + ] + ] +} +` + exp := &KLineEvent{ + Events: okexapi.KLineSlice{ + { + StartTime: types.NewMillisecondTimestampFromInt(1597026383085), + OpenPrice: fixedpoint.NewFromFloat(8533), + HighestPrice: fixedpoint.NewFromFloat(8553.74), + LowestPrice: fixedpoint.NewFromFloat(8527.17), + ClosePrice: fixedpoint.NewFromFloat(8548.26), + Volume: fixedpoint.NewFromFloat(45247), + VolumeInCurrency: fixedpoint.NewFromFloat(529.5858061), + //VolumeInCurrencyQuote: fixedpoint.NewFromFloat(529.5858061), + Confirm: fixedpoint.Zero, + }, + }, + InstrumentID: "BTC-USDT", + Symbol: "BTCUSDT", + Interval: "1d", + Channel: "candle1D", + } + + res, err := parseWebSocketEvent([]byte(in)) + assert.NoError(t, err) + event, ok := res.(*KLineEvent) + assert.True(t, ok) + assert.Len(t, event.Events, 1) + assert.Equal(t, exp, event) + }) + + t.Run("failed to convert timestamp", func(t *testing.T) { + t.Skip("this will cause panic, so i skip it") + in := ` +{ + "arg": { + "channel": "candle1D", + "instId": "BTC-USDT" + }, + "data": [ + [ + "x", + "8533", + "8553.74", + "8527.17", + "8548.26", + "45247", + "529.5858061", + "529.5858061", + "0" + ] + ] +} +` + + _, err := parseWebSocketEvent([]byte(in)) + assert.ErrorContains(t, err, "timestamp") + }) + + t.Run("failed to convert open price", func(t *testing.T) { + t.Skip("this will cause panic, so i skip it") + in := ` +{ + "arg": { + "channel": "candle1D", + "instId": "BTC-USDT" + }, + "data": [ + [ + "1597026383085", + "x", + "8553.74", + "8527.17", + "8548.26", + "45247", + "529.5858061", + "529.5858061", + "0" + ] + ] +} +` + + _, err := parseWebSocketEvent([]byte(in)) + assert.ErrorContains(t, err, "open price") + }) + + t.Run("failed to convert highest price", func(t *testing.T) { + t.Skip("this will cause panic, so i skip it") + in := ` +{ + "arg": { + "channel": "candle1D", + "instId": "BTC-USDT" + }, + "data": [ + [ + "1597026383085", + "8533", + "x", + "8527.17", + "8548.26", + "45247", + "529.5858061", + "529.5858061", + "0" + ] + ] +} +` + + _, err := parseWebSocketEvent([]byte(in)) + assert.ErrorContains(t, err, "highest price") + }) + t.Run("failed to convert lowest price", func(t *testing.T) { + t.Skip("this will cause panic, so i skip it") + in := ` +{ + "arg": { + "channel": "candle1D", + "instId": "BTC-USDT" + }, + "data": [ + [ + "1597026383085", + "8533", + "8553.74", + "x", + "8548.26", + "45247", + "529.5858061", + "529.5858061", + "0" + ] + ] +} +` + + _, err := parseWebSocketEvent([]byte(in)) + assert.ErrorContains(t, err, "lowest price") + }) + t.Run("failed to convert close price", func(t *testing.T) { + t.Skip("this will cause panic, so i skip it") + in := ` +{ + "arg": { + "channel": "candle1D", + "instId": "BTC-USDT" + }, + "data": [ + [ + "1597026383085", + "8533", + "8553.74", + "8527.17", + "x", + "45247", + "529.5858061", + "529.5858061", + "0" + ] + ] +} +` + + _, err := parseWebSocketEvent([]byte(in)) + assert.ErrorContains(t, err, "close price") + }) + t.Run("failed to convert volume", func(t *testing.T) { + t.Skip("this will cause panic, so i skip it") + in := ` +{ + "arg": { + "channel": "candle1D", + "instId": "BTC-USDT" + }, + "data": [ + [ + "1597026383085", + "8533", + "8553.74", + "8527.17", + "8548.26", + "x", + "529.5858061", + "529.5858061", + "0" + ] + ] +} +` + + _, err := parseWebSocketEvent([]byte(in)) + assert.ErrorContains(t, err, "volume") + }) + t.Run("failed to convert volume currency", func(t *testing.T) { + t.Skip("this will cause panic, so i skip it") + in := ` +{ + "arg": { + "channel": "candle1D", + "instId": "BTC-USDT" + }, + "data": [ + [ + "1597026383085", + "8533", + "8553.74", + "8527.17", + "8548.26", + "45247", + "x", + "529.5858061", + "0" + ] + ] +} +` + + _, err := parseWebSocketEvent([]byte(in)) + assert.ErrorContains(t, err, "volume currency") + }) + t.Run("failed to convert trading currency quote ", func(t *testing.T) { + t.Skip("this will cause panic, so i skip it") + in := ` +{ + "arg": { + "channel": "candle1D", + "instId": "BTC-USDT" + }, + "data": [ + [ + "1597026383085", + "8533", + "8553.74", + "8527.17", + "8548.26", + "45247", + "529.5858061", + "x", + "0" + ] + ] +} +` + + _, err := parseWebSocketEvent([]byte(in)) + assert.ErrorContains(t, err, "trading currency") + }) + t.Run("failed to convert confirm", func(t *testing.T) { + t.Skip("this will cause panic, so i skip it") + in := ` +{ + "arg": { + "channel": "candle1D", + "instId": "BTC-USDT" + }, + "data": [ + [ + "1597026383085", + "8533", + "8553.74", + "8527.17", + "8548.26", + "45247", + "529.5858061", + "529.5858061", + "g" + ] + ] +} +` + + _, err := parseWebSocketEvent([]byte(in)) + assert.ErrorContains(t, err, "confirm") + }) + +} + +func TestKLine_ToGlobal(t *testing.T) { + t.Run("snapshot", func(t *testing.T) { + in := ` +{ + "arg": { + "channel": "candle1D", + "instId": "BTC-USDT" + }, + "data": [ + [ + "1597026383085", + "8533", + "8553.74", + "8527.17", + "8548.26", + "45247", + "529.5858061", + "529.5858061", + "0" + ] + ] +} +` + exp := &KLineEvent{ + Events: okexapi.KLineSlice{ + { + StartTime: types.NewMillisecondTimestampFromInt(1597026383085), + OpenPrice: fixedpoint.NewFromFloat(8533), + HighestPrice: fixedpoint.NewFromFloat(8553.74), + LowestPrice: fixedpoint.NewFromFloat(8527.17), + ClosePrice: fixedpoint.NewFromFloat(8548.26), + Volume: fixedpoint.NewFromFloat(45247), + VolumeInCurrency: fixedpoint.NewFromFloat(529.5858061), + //VolumeInCurrencyQuote: fixedpoint.NewFromFloat(529.5858061), + Confirm: fixedpoint.Zero, + }, + }, + InstrumentID: "BTC-USDT", + Symbol: "BTCUSDT", + Interval: "1d", + Channel: "candle1D", + } + + res, err := parseWebSocketEvent([]byte(in)) + assert.NoError(t, err) + event, ok := res.(*KLineEvent) + assert.True(t, ok) + + assert.Equal(t, types.KLine{ + Exchange: types.ExchangeOKEx, + Symbol: "BTCUSDT", + StartTime: types.Time(types.NewMillisecondTimestampFromInt(1597026383085)), + EndTime: types.Time(types.NewMillisecondTimestampFromInt(1597026383085).Time().Add(types.Interval(exp.Interval).Duration() - time.Millisecond)), + Interval: types.Interval(exp.Interval), + Open: exp.Events[0].OpenPrice, + Close: exp.Events[0].ClosePrice, + High: exp.Events[0].HighestPrice, + Low: exp.Events[0].LowestPrice, + Volume: exp.Events[0].Volume, + QuoteVolume: exp.Events[0].VolumeInCurrency, + TakerBuyBaseAssetVolume: fixedpoint.Zero, + TakerBuyQuoteAssetVolume: fixedpoint.Zero, + LastTradeID: 0, + NumberOfTrades: 0, + Closed: false, + }, kLineToGlobal(event.Events[0], types.Interval(event.Interval), event.Symbol)) + }) + +} + +func Test_parseWebSocketEvent(t *testing.T) { + in := ` +{ + "arg": { + "channel": "trades", + "instId": "BTC-USDT" + }, + "data": [ + { + "instId": "BTC-USDT", + "tradeId": "130639474", + "px": "42219.9", + "sz": "0.12060306", + "side": "buy", + "ts": "1630048897897", + "count": "3" + } + ] +} +` + exp := []MarketTradeEvent{{ + InstId: "BTC-USDT", + TradeId: 130639474, + Px: fixedpoint.NewFromFloat(42219.9), + Sz: fixedpoint.NewFromFloat(0.12060306), + Side: okexapi.SideTypeBuy, + Timestamp: types.NewMillisecondTimestampFromInt(1630048897897), + Count: 3, + }} + + res, err := parseWebSocketEvent([]byte(in)) + assert.NoError(t, err) + event, ok := res.([]MarketTradeEvent) + assert.True(t, ok) + assert.Len(t, event, 1) + assert.Equal(t, exp, event) + +} + +func Test_toGlobalTrade(t *testing.T) { + // { + // "instId": "BTC-USDT", + // "tradeId": "130639474", + // "px": "42219.9", + // "sz": "0.12060306", + // "side": "buy", + // "ts": "1630048897897", + // "count": "3" + // } + marketTrade := MarketTradeEvent{ + InstId: "BTC-USDT", + TradeId: 130639474, + Px: fixedpoint.NewFromFloat(42219.9), + Sz: fixedpoint.NewFromFloat(0.12060306), + Side: okexapi.SideTypeBuy, + Timestamp: types.NewMillisecondTimestampFromInt(1630048897897), + Count: 3, + } + t.Run("succeeds", func(t *testing.T) { + trade, err := marketTrade.toGlobalTrade() + assert.NoError(t, err) + assert.Equal(t, types.Trade{ + ID: uint64(130639474), + OrderID: uint64(0), + Exchange: types.ExchangeOKEx, + Price: fixedpoint.NewFromFloat(42219.9), + Quantity: fixedpoint.NewFromFloat(0.12060306), + QuoteQuantity: marketTrade.Px.Mul(marketTrade.Sz), + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + IsBuyer: true, + IsMaker: false, + Time: types.Time(types.NewMillisecondTimestampFromInt(1630048897897)), + Fee: fixedpoint.Zero, + FeeCurrency: "", + FeeDiscounted: false, + }, trade) + }) + t.Run("unexpected side", func(t *testing.T) { + newTrade := marketTrade + newTrade.Side = "both" + _, err := newTrade.toGlobalTrade() + assert.ErrorContains(t, err, "both") + }) + t.Run("unexpected symbol", func(t *testing.T) { + newTrade := marketTrade + newTrade.InstId = "" + _, err := newTrade.toGlobalTrade() + assert.ErrorContains(t, err, "unexpected inst id") + }) +} + +func TestWebSocketEvent_IsValid(t *testing.T) { + t.Run("op login event", func(t *testing.T) { + input := `{ + "event": "login", + "code": "0", + "msg": "", + "connId": "a4d3ae55" +}` + res, err := parseWebSocketEvent([]byte(input)) + assert.NoError(t, err) + opEvent, ok := res.(*WebSocketEvent) + assert.True(t, ok) + assert.Equal(t, WebSocketEvent{ + Event: WsEventTypeLogin, + Code: "0", + Message: "", + }, *opEvent) + + assert.NoError(t, opEvent.IsValid()) + }) + + t.Run("op error event", func(t *testing.T) { + input := `{ + "event": "error", + "code": "60009", + "msg": "Login failed.", + "connId": "a4d3ae55" +}` + res, err := parseWebSocketEvent([]byte(input)) + assert.NoError(t, err) + opEvent, ok := res.(*WebSocketEvent) + assert.True(t, ok) + assert.Equal(t, WebSocketEvent{ + Event: WsEventTypeError, + Code: "60009", + Message: "Login failed.", + }, *opEvent) + + assert.ErrorContains(t, opEvent.IsValid(), "request error") + }) + + t.Run("unexpected event", func(t *testing.T) { + input := `{ + "event": "test gg", + "code": "60009", + "msg": "unexpected", + "connId": "a4d3ae55" +}` + res, err := parseWebSocketEvent([]byte(input)) + assert.NoError(t, err) + opEvent, ok := res.(*WebSocketEvent) + assert.True(t, ok) + assert.Equal(t, WebSocketEvent{ + Event: "test gg", + Code: "60009", + Message: "unexpected", + }, *opEvent) + + assert.ErrorContains(t, opEvent.IsValid(), "unexpected event type") + }) + + t.Run("conn count info", func(t *testing.T) { + input := `{ + "event":"channel-conn-count", + "channel":"orders", + "connCount": "2", + "connId":"abcd1234" +}` + res, err := parseWebSocketEvent([]byte(input)) + assert.NoError(t, err) + opEvent, ok := res.(*WebSocketEvent) + assert.True(t, ok) + assert.Equal(t, WebSocketEvent{ + Event: "channel-conn-count", + Channel: "orders", + ConnCount: "2", + }, *opEvent) + + assert.NoError(t, opEvent.IsValid()) + }) + + t.Run("conn count error", func(t *testing.T) { + input := `{ + "event": "channel-conn-count-error", + "channel": "orders", + "connCount": "20", + "connId":"a4d3ae55" +}` + res, err := parseWebSocketEvent([]byte(input)) + assert.NoError(t, err) + opEvent, ok := res.(*WebSocketEvent) + assert.True(t, ok) + assert.Equal(t, WebSocketEvent{ + Event: "channel-conn-count-error", + Channel: "orders", + ConnCount: "20", + }, *opEvent) + + assert.ErrorContains(t, opEvent.IsValid(), "rate limit") + }) +} + +func TestOrderTradeEvent(t *testing.T) { + //{"arg":{"channel":"orders","instType":"SPOT","uid":"530315546680502420"},"data":[{"accFillSz":"0","algoClOrdId":"","algoId":"","amendResult":"","amendSource":"","attachAlgoClOrdId":"","attachAlgoOrds":[],"avgPx":"0","cTime":"1705384184502","cancelSource":"","category":"normal","ccy":"","clOrdId":"","code":"0","execType":"","fee":"0","feeCcy":"OKB","fillFee":"0","fillFeeCcy":"","fillFwdPx":"","fillMarkPx":"","fillMarkVol":"","fillNotionalUsd":"","fillPnl":"0","fillPx":"","fillPxUsd":"","fillPxVol":"","fillSz":"0","fillTime":"","instId":"OKB-USDT","instType":"SPOT","lastPx":"54","lever":"0","msg":"","notionalUsd":"99.929","ordId":"667364871905857536","ordType":"market","pnl":"0","posSide":"","px":"","pxType":"","pxUsd":"","pxVol":"","quickMgnType":"","rebate":"0","rebateCcy":"USDT","reduceOnly":"false","reqId":"","side":"buy","slOrdPx":"","slTriggerPx":"","slTriggerPxType":"","source":"","state":"live","stpId":"","stpMode":"","sz":"100","tag":"","tdMode":"cash","tgtCcy":"quote_ccy","tpOrdPx":"","tpTriggerPx":"","tpTriggerPxType":"","tradeId":"","uTime":"1705384184502"}]} + //{"arg":{"channel":"orders","instType":"SPOT","uid":"530315546680502420"},"data":[{"accFillSz":"1.84","algoClOrdId":"","algoId":"","amendResult":"","amendSource":"","attachAlgoClOrdId":"","attachAlgoOrds":[],"avgPx":"54","cTime":"1705384184502","cancelSource":"","category":"normal","ccy":"","clOrdId":"","code":"0","execType":"T","fee":"-0.001844337","feeCcy":"OKB","fillFee":"-0.001844337","fillFeeCcy":"OKB","fillFwdPx":"","fillMarkPx":"","fillMarkVol":"","fillNotionalUsd":"99.9","fillPnl":"0","fillPx":"54","fillPxUsd":"","fillPxVol":"","fillSz":"1.844337","fillTime":"1705384184503","instId":"OKB-USDT","instType":"SPOT","lastPx":"54","lever":"0","msg":"","notionalUsd":"99.929","ordId":"667364871905857536","ordType":"market","pnl":"0","posSide":"","px":"","pxType":"","pxUsd":"","pxVol":"","quickMgnType":"","rebate":"0","rebateCcy":"USDT","reduceOnly":"false","reqId":"","side":"buy","slOrdPx":"","slTriggerPx":"","slTriggerPxType":"","source":"","state":"partially_filled","stpId":"","stpMode":"","sz":"100","tag":"","tdMode":"cash","tgtCcy":"quote_ccy","tpOrdPx":"","tpTriggerPx":"","tpTriggerPxType":"","tradeId":"590957341","uTime":"1705384184503"}]} + //{"arg":{"channel":"orders","instType":"SPOT","uid":"530315546680502420"},"data":[{"accFillSz":"1.84","algoClOrdId":"","algoId":"","amendResult":"","amendSource":"","attachAlgoClOrdId":"","attachAlgoOrds":[],"avgPx":"54","cTime":"1705384184502","cancelSource":"","category":"normal","ccy":"","clOrdId":"","code":"0","execType":"","fee":"-0.001844337","feeCcy":"OKB","fillFee":"0","fillFeeCcy":"","fillFwdPx":"","fillMarkPx":"","fillMarkVol":"","fillNotionalUsd":"99.9","fillPnl":"0","fillPx":"","fillPxUsd":"","fillPxVol":"","fillSz":"0","fillTime":"","instId":"OKB-USDT","instType":"SPOT","lastPx":"54","lever":"0","msg":"","notionalUsd":"99.929","ordId":"667364871905857536","ordType":"market","pnl":"0","posSide":"","px":"","pxType":"","pxUsd":"","pxVol":"","quickMgnType":"","rebate":"0","rebateCcy":"USDT","reduceOnly":"false","reqId":"","side":"buy","slOrdPx":"","slTriggerPx":"","slTriggerPxType":"","source":"","state":"filled","stpId":"","stpMode":"","sz":"100","tag":"","tdMode":"cash","tgtCcy":"quote_ccy","tpOrdPx":"","tpTriggerPx":"","tpTriggerPxType":"","tradeId":"","uTime":"1705384184504"}]} + t.Run("succeeds", func(t *testing.T) { + in := ` +{ + "arg":{ + "channel":"orders", + "instType":"SPOT", + "uid":"530315546680502420" + }, + "data":[ + { + "accFillSz":"1.84", + "algoClOrdId":"", + "algoId":"", + "amendResult":"", + "amendSource":"", + "attachAlgoClOrdId":"", + "attachAlgoOrds":[ + + ], + "avgPx":"54", + "cTime":"1705384184502", + "cancelSource":"", + "category":"normal", + "ccy":"", + "clOrdId":"", + "code":"0", + "execType":"T", + "fee":"-0.001844337", + "feeCcy":"OKB", + "fillFee":"-0.001844337", + "fillFeeCcy":"OKB", + "fillFwdPx":"", + "fillMarkPx":"", + "fillMarkVol":"", + "fillNotionalUsd":"99.9", + "fillPnl":"0", + "fillPx":"54", + "fillPxUsd":"", + "fillPxVol":"", + "fillSz":"1.84", + "fillTime":"1705384184503", + "instId":"OKB-USDT", + "instType":"SPOT", + "lastPx":"54", + "lever":"0", + "msg":"", + "notionalUsd":"99.929", + "ordId":"667364871905857536", + "ordType":"market", + "pnl":"0", + "posSide":"", + "px":"", + "pxType":"", + "pxUsd":"", + "pxVol":"", + "quickMgnType":"", + "rebate":"0", + "rebateCcy":"USDT", + "reduceOnly":"false", + "reqId":"", + "side":"buy", + "slOrdPx":"", + "slTriggerPx":"", + "slTriggerPxType":"", + "source":"", + "state":"partially_filled", + "stpId":"", + "stpMode":"", + "sz":"100", + "tag":"", + "tdMode":"cash", + "tgtCcy":"quote_ccy", + "tpOrdPx":"", + "tpTriggerPx":"", + "tpTriggerPxType":"", + "tradeId":"590957341", + "uTime":"1705384184503" + } + ] +} +` + + exp := []OrderTradeEvent{ + { + OrderDetail: okexapi.OrderDetail{ + AccumulatedFillSize: fixedpoint.NewFromFloat(1.84), + AvgPrice: fixedpoint.NewFromFloat(54), + CreatedTime: types.NewMillisecondTimestampFromInt(1705384184502), + Category: "normal", + ClientOrderId: "", + Fee: fixedpoint.NewFromFloat(-0.001844337), + FeeCurrency: "OKB", + FillTime: types.NewMillisecondTimestampFromInt(1705384184503), + InstrumentID: "OKB-USDT", + InstrumentType: okexapi.InstrumentTypeSpot, + OrderId: types.StrInt64(667364871905857536), + OrderType: okexapi.OrderTypeMarket, + Price: fixedpoint.Zero, + Side: okexapi.SideTypeBuy, + State: okexapi.OrderStatePartiallyFilled, + Size: fixedpoint.NewFromFloat(100), + TargetCurrency: okexapi.TargetCurrencyQuote, + UpdatedTime: types.NewMillisecondTimestampFromInt(1705384184503), + Currency: "", + TradeId: "590957341", + FillPrice: fixedpoint.NewFromFloat(54), + FillSize: fixedpoint.NewFromFloat(1.84), + Lever: "0", + Pnl: fixedpoint.Zero, + PositionSide: "", + PriceUsd: fixedpoint.Zero, + PriceVol: fixedpoint.Zero, + PriceType: "", + Rebate: fixedpoint.Zero, + RebateCcy: "USDT", + AttachAlgoClOrdId: "", + SlOrdPx: fixedpoint.Zero, + SlTriggerPx: fixedpoint.Zero, + SlTriggerPxType: "", + AttachAlgoOrds: []interface{}{}, + Source: "", + StpId: "", + StpMode: "", + Tag: "", + TradeMode: okexapi.TradeModeCash, + TpOrdPx: fixedpoint.Zero, + TpTriggerPx: fixedpoint.Zero, + TpTriggerPxType: "", + ReduceOnly: "false", + AlgoClOrdId: "", + AlgoId: "", + }, + Code: types.StrInt64(0), + Msg: "", + AmendResult: "", + ExecutionType: okexapi.LiquidityTypeTaker, + FillFee: fixedpoint.NewFromFloat(-0.001844337), + FillFeeCurrency: "OKB", + FillNotionalUsd: fixedpoint.NewFromFloat(99.9), + FillPnl: fixedpoint.Zero, + NotionalUsd: fixedpoint.NewFromFloat(99.929), + ReqId: "", + LastPrice: fixedpoint.NewFromFloat(54), + QuickMgnType: "", + AmendSource: "", + CancelSource: "", + FillPriceVolume: "", + FillPriceUsd: "", + FillMarkVolume: "", + FillFwdPrice: "", + FillMarkPrice: "", + }, + } + + res, err := parseWebSocketEvent([]byte(in)) + assert.NoError(t, err) + event, ok := res.([]OrderTradeEvent) + assert.True(t, ok) + assert.Equal(t, exp, event) + }) +} + +func TestOrderTradeEvent_toGlobalTrade1(t *testing.T) { + var ( + in = ` +{ + "arg":{ + "channel":"orders", + "instType":"SPOT", + "uid":"530315546680502420" + }, + "data":[ + { + "accFillSz":"1.84", + "algoClOrdId":"", + "algoId":"", + "amendResult":"", + "amendSource":"", + "attachAlgoClOrdId":"", + "attachAlgoOrds":[ + + ], + "avgPx":"54", + "cTime":"1705384184502", + "cancelSource":"", + "category":"normal", + "ccy":"", + "clOrdId":"", + "code":"0", + "execType":"T", + "fee":"-0.001844337", + "feeCcy":"OKB", + "fillFee":"-0.001844337", + "fillFeeCcy":"OKB", + "fillFwdPx":"", + "fillMarkPx":"", + "fillMarkVol":"", + "fillNotionalUsd":"99.9", + "fillPnl":"0", + "fillPx":"54", + "fillPxUsd":"", + "fillPxVol":"", + "fillSz":"1.84", + "fillTime":"1705384184503", + "instId":"OKB-USDT", + "instType":"SPOT", + "lastPx":"54", + "lever":"0", + "msg":"", + "notionalUsd":"99.929", + "ordId":"667364871905857536", + "ordType":"market", + "pnl":"0", + "posSide":"", + "px":"", + "pxType":"", + "pxUsd":"", + "pxVol":"", + "quickMgnType":"", + "rebate":"0", + "rebateCcy":"USDT", + "reduceOnly":"false", + "reqId":"", + "side":"buy", + "slOrdPx":"", + "slTriggerPx":"", + "slTriggerPxType":"", + "source":"", + "state":"partially_filled", + "stpId":"", + "stpMode":"", + "sz":"100", + "tag":"", + "tdMode":"cash", + "tgtCcy":"quote_ccy", + "tpOrdPx":"", + "tpTriggerPx":"", + "tpTriggerPxType":"", + "tradeId":"590957341", + "uTime":"1705384184503" + } + ] +} +` + expTrade = types.Trade{ + ID: uint64(590957341), + OrderID: uint64(667364871905857536), + Exchange: types.ExchangeOKEx, + Price: fixedpoint.NewFromFloat(54), + Quantity: fixedpoint.NewFromFloat(1.84), + QuoteQuantity: fixedpoint.NewFromFloat(54).Mul(fixedpoint.NewFromFloat(1.84)), + Symbol: "OKBUSDT", + Side: types.SideTypeBuy, + IsBuyer: true, + IsMaker: false, + Time: types.Time(types.NewMillisecondTimestampFromInt(1705384184503).Time()), + Fee: fixedpoint.NewFromFloat(0.001844337), + FeeCurrency: "OKB", + } + ) + + t.Run("succeeds", func(t *testing.T) { + res, err := parseWebSocketEvent([]byte(in)) + assert.NoError(t, err) + event, ok := res.([]OrderTradeEvent) + assert.True(t, ok) + + trade, err := event[0].toGlobalTrade() + assert.NoError(t, err) + assert.Equal(t, expTrade, trade) + }) + + t.Run("unexpected trade id", func(t *testing.T) { + res, err := parseWebSocketEvent([]byte(in)) + assert.NoError(t, err) + event, ok := res.([]OrderTradeEvent) + assert.True(t, ok) + + event[0].TradeId = "XXXX" + _, err = event[0].toGlobalTrade() + assert.ErrorContains(t, err, "trade id") + }) + +} diff --git a/pkg/exchange/okex/query_closed_orders_test.go b/pkg/exchange/okex/query_closed_orders_test.go new file mode 100644 index 0000000..67b827f --- /dev/null +++ b/pkg/exchange/okex/query_closed_orders_test.go @@ -0,0 +1,66 @@ +package okex + +import ( + "context" + "testing" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/testutil" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +func Test_QueryClosedOrders(t *testing.T) { + + key, secret, passphrase, ok := testutil.IntegrationTestWithPassphraseConfigured(t, types.ExchangeOKEx.String()) + if !ok { + t.Skip("Please configure all credentials about OKEX") + } + + e := New(key, secret, passphrase) + + queryOrder := types.OrderQuery{ + Symbol: "BTCUSDT", + } + + // test by order id as a cursor + /* + closedOrder, err := e.QueryClosedOrders(context.Background(), string(queryOrder.Symbol), time.Time{}, time.Time{}, 609869603774656544) + if assert.NoError(t, err) { + assert.NotEmpty(t, closedOrder) + } + t.Logf("closed order detail: %+v", closedOrder) + */ + // test by time interval + closedOrder, err := e.QueryClosedOrders(context.Background(), string(queryOrder.Symbol), time.Now().Add(-90*24*time.Hour), time.Now(), 0) + if assert.NoError(t, err) { + assert.NotEmpty(t, closedOrder) + } + t.Logf("closed order detail: %+v", closedOrder) + // test by no parameter + closedOrder, err = e.QueryClosedOrders(context.Background(), string(queryOrder.Symbol), time.Time{}, time.Time{}, 0) + if assert.NoError(t, err) { + assert.NotEmpty(t, closedOrder) + } + t.Logf("closed order detail: %+v", closedOrder) + // test by time interval (boundary test) + closedOrder, err = e.QueryClosedOrders(context.Background(), string(queryOrder.Symbol), time.Unix(1694155903, 999), time.Now(), 0) + if assert.NoError(t, err) { + assert.NotEmpty(t, closedOrder) + } + t.Logf("closed order detail: %+v", closedOrder) + // test by time interval (boundary test) + closedOrder, err = e.QueryClosedOrders(context.Background(), string(queryOrder.Symbol), time.Unix(1694154903, 999), time.Unix(1694155904, 0), 0) + if assert.NoError(t, err) { + assert.NotEmpty(t, closedOrder) + } + t.Logf("closed order detail: %+v", closedOrder) + // test by time interval and order id together + /* + closedOrder, err = e.QueryClosedOrders(context.Background(), string(queryOrder.Symbol), time.Unix(1694154903, 999), time.Now(), 609869603774656544) + if assert.NoError(t, err) { + assert.NotEmpty(t, closedOrder) + } + t.Logf("closed order detail: %+v", closedOrder) + */ +} diff --git a/pkg/exchange/okex/query_kline_test.go b/pkg/exchange/okex/query_kline_test.go new file mode 100644 index 0000000..d0ce45e --- /dev/null +++ b/pkg/exchange/okex/query_kline_test.go @@ -0,0 +1,85 @@ +package okex + +import ( + "context" + "testing" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/testutil" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +func Test_QueryKlines(t *testing.T) { + key, secret, passphrase, ok := testutil.IntegrationTestWithPassphraseConfigured(t, "OKEX") + if !ok { + t.Skip("Please configure all credentials about OKEX") + } + + e := New(key, secret, passphrase) + + queryOrder := types.OrderQuery{ + Symbol: "BTC-USDT", + } + + now := time.Now() + // test supported interval - minute + klineDetail, err := e.QueryKLines(context.Background(), queryOrder.Symbol, types.Interval1m, types.KLineQueryOptions{ + Limit: 50, + EndTime: &now}) + if assert.NoError(t, err) { + assert.NotEmpty(t, klineDetail) + } + // test supported interval - hour - 1 hour + klineDetail, err = e.QueryKLines(context.Background(), queryOrder.Symbol, types.Interval1h, types.KLineQueryOptions{ + Limit: 50, + EndTime: &now}) + if assert.NoError(t, err) { + assert.NotEmpty(t, klineDetail) + } + // test supported interval - hour - 6 hour to test UTC time + klineDetail, err = e.QueryKLines(context.Background(), queryOrder.Symbol, types.Interval6h, types.KLineQueryOptions{ + Limit: 50, + EndTime: &now}) + if assert.NoError(t, err) { + assert.NotEmpty(t, klineDetail) + } + // test supported interval - day + klineDetail, err = e.QueryKLines(context.Background(), queryOrder.Symbol, types.Interval1d, types.KLineQueryOptions{ + Limit: 50, + EndTime: &now}) + if assert.NoError(t, err) { + assert.NotEmpty(t, klineDetail) + assert.NotEmpty(t, klineDetail[0].Exchange) + assert.NotEmpty(t, klineDetail[0].Symbol) + assert.NotEmpty(t, klineDetail[0].StartTime) + assert.NotEmpty(t, klineDetail[0].EndTime) + assert.NotEmpty(t, klineDetail[0].Interval) + assert.NotEmpty(t, klineDetail[0].Open) + assert.NotEmpty(t, klineDetail[0].Close) + assert.NotEmpty(t, klineDetail[0].High) + assert.NotEmpty(t, klineDetail[0].Low) + assert.NotEmpty(t, klineDetail[0].Volume) + } + // test supported interval - week + klineDetail, err = e.QueryKLines(context.Background(), queryOrder.Symbol, types.Interval1w, types.KLineQueryOptions{ + Limit: 50, + EndTime: &now}) + if assert.NoError(t, err) { + assert.NotEmpty(t, klineDetail) + } + // test supported interval - month + klineDetail, err = e.QueryKLines(context.Background(), queryOrder.Symbol, types.Interval1mo, types.KLineQueryOptions{ + Limit: 50, + EndTime: &now}) + if assert.NoError(t, err) { + assert.NotEmpty(t, klineDetail) + } + // test not supported interval + klineDetail, err = e.QueryKLines(context.Background(), queryOrder.Symbol, types.Interval("2m"), types.KLineQueryOptions{ + Limit: 50, + EndTime: &now}) + if assert.Error(t, err) { + assert.Empty(t, klineDetail) + } +} diff --git a/pkg/exchange/okex/query_order_test.go b/pkg/exchange/okex/query_order_test.go new file mode 100644 index 0000000..7f6a2f9 --- /dev/null +++ b/pkg/exchange/okex/query_order_test.go @@ -0,0 +1,36 @@ +package okex + +import ( + "context" + "os" + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +func Test_QueryOrder(t *testing.T) { + key := os.Getenv("OKEX_API_KEY") + secret := os.Getenv("OKEX_API_SECRET") + passphrase := os.Getenv("OKEX_API_PASSPHRASE") + if len(key) == 0 && len(secret) == 0 { + t.Skip("api key/secret are not configured") + return + } + if len(passphrase) == 0 { + t.Skip("passphrase are not configured") + return + } + + e := New(key, secret, passphrase) + + queryOrder := types.OrderQuery{ + Symbol: "BTCUSDT", + OrderID: "609869603774656544", + } + orderDetail, err := e.QueryOrder(context.Background(), queryOrder) + if assert.NoError(t, err) { + assert.NotEmpty(t, orderDetail) + } + t.Logf("order detail: %+v", orderDetail) +} diff --git a/pkg/exchange/okex/query_order_trades_test.go b/pkg/exchange/okex/query_order_trades_test.go new file mode 100644 index 0000000..5b9ea21 --- /dev/null +++ b/pkg/exchange/okex/query_order_trades_test.go @@ -0,0 +1,40 @@ +package okex + +import ( + "context" + "testing" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/testutil" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +func Test_QueryOrderTrades(t *testing.T) { + + key, secret, passphrase, ok := testutil.IntegrationTestWithPassphraseConfigured(t, "OKEX") + if !ok { + t.Skip("Please configure all credentials about OKEX") + } + + e := New(key, secret, passphrase) + + queryOrder := types.OrderQuery{ + OrderID: "609869603774656544", + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + transactionDetail, err := e.QueryOrderTrades(ctx, queryOrder) + if assert.NoError(t, err) { + assert.NotEmpty(t, transactionDetail) + } + t.Logf("transaction detail: %+v", transactionDetail) + queryOrder = types.OrderQuery{ + Symbol: "BTC-USDT", + } + transactionDetail, err = e.QueryOrderTrades(ctx, queryOrder) + if assert.NoError(t, err) { + assert.NotEmpty(t, transactionDetail) + } + t.Logf("transaction detail: %+v", transactionDetail) +} diff --git a/pkg/exchange/okex/query_trades_test.go b/pkg/exchange/okex/query_trades_test.go new file mode 100644 index 0000000..3ce4d09 --- /dev/null +++ b/pkg/exchange/okex/query_trades_test.go @@ -0,0 +1,82 @@ +package okex + +import ( + "context" + "testing" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/testutil" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +func Test_QueryTrades(t *testing.T) { + key, secret, passphrase, ok := testutil.IntegrationTestWithPassphraseConfigured(t, "OKEX") + if !ok { + t.Skip("Please configure all credentials about OKEX") + } + + e := New(key, secret, passphrase) + + queryOrder := types.OrderQuery{ + Symbol: "BTCUSDT", + } + + since := time.Now().AddDate(0, -3, 0) + until := time.Now() + + queryOption := types.TradeQueryOptions{ + StartTime: &since, + EndTime: &until, + Limit: 100, + } + // query by time interval + transactionDetail, err := e.QueryTrades(context.Background(), queryOrder.Symbol, &queryOption) + if assert.NoError(t, err) { + assert.NotEmpty(t, transactionDetail) + } + // query by trade id + transactionDetail, err = e.QueryTrades(context.Background(), queryOrder.Symbol, &types.TradeQueryOptions{LastTradeID: 432044402}) + if assert.Error(t, err) { + assert.Empty(t, transactionDetail) + } + // query by no time interval and no trade id + transactionDetail, err = e.QueryTrades(context.Background(), queryOrder.Symbol, &types.TradeQueryOptions{}) + if assert.Error(t, err) { + assert.Empty(t, transactionDetail) + } + // query by limit exceed default value + transactionDetail, err = e.QueryTrades(context.Background(), queryOrder.Symbol, &types.TradeQueryOptions{Limit: 150}) + if assert.Error(t, err) { + assert.Empty(t, transactionDetail) + } + // pagenation test and test time interval : only end time + transactionDetail, err = e.QueryTrades(context.Background(), queryOrder.Symbol, &types.TradeQueryOptions{EndTime: &until, Limit: 1}) + if assert.NoError(t, err) { + assert.NotEmpty(t, transactionDetail) + assert.Less(t, 1, len(transactionDetail)) + } + // query by time interval: only start time + transactionDetail, err = e.QueryTrades(context.Background(), queryOrder.Symbol, &types.TradeQueryOptions{StartTime: &since, Limit: 100}) + if assert.NoError(t, err) { + assert.NotEmpty(t, transactionDetail) + } + // query by combination: start time, end time and after + transactionDetail, err = e.QueryTrades(context.Background(), queryOrder.Symbol, &types.TradeQueryOptions{StartTime: &since, EndTime: &until, Limit: 1}) + if assert.NoError(t, err) { + assert.NotEmpty(t, transactionDetail) + } + // query by time interval: 3 months earlier with start time and end time + since = time.Now().AddDate(0, -6, 0) + until = time.Now().AddDate(0, -3, 0) + transactionDetail, err = e.QueryTrades(context.Background(), queryOrder.Symbol, &types.TradeQueryOptions{StartTime: &since, EndTime: &until, Limit: 100}) + if assert.NoError(t, err) { + assert.Empty(t, transactionDetail) + } + // query by time interval: 3 months earlier with start time + since = time.Now().AddDate(0, -6, 0) + transactionDetail, err = e.QueryTrades(context.Background(), queryOrder.Symbol, &types.TradeQueryOptions{StartTime: &since, Limit: 100}) + if assert.NoError(t, err) { + assert.NotEmpty(t, transactionDetail) + } +} diff --git a/pkg/exchange/okex/stream.go b/pkg/exchange/okex/stream.go new file mode 100644 index 0000000..b4b456f --- /dev/null +++ b/pkg/exchange/okex/stream.go @@ -0,0 +1,324 @@ +package okex + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/gorilla/websocket" + "golang.org/x/time/rate" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/okex/okexapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/retry" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +var ( + marketTradeLogLimiter = rate.NewLimiter(rate.Every(time.Minute), 1) + tradeLogLimiter = rate.NewLimiter(rate.Every(time.Minute), 1) + // pingInterval the connection will break automatically if the subscription is not established or data has not been + // pushed for more than 30 seconds. Therefore, we set it to 20 seconds. + pingInterval = 20 * time.Second +) + +type WebsocketOp struct { + Op WsEventType `json:"op"` + Args interface{} `json:"args"` +} + +type WebsocketLogin struct { + Key string `json:"apiKey"` + Passphrase string `json:"passphrase"` + Timestamp string `json:"timestamp"` + Sign string `json:"sign"` +} + +//go:generate callbackgen -type Stream -interface +type Stream struct { + types.StandardStream + kLineStream *KLineStream + + client *okexapi.RestClient + balanceProvider types.ExchangeAccountService + + // public callbacks + kLineEventCallbacks []func(candle KLineEvent) + bookEventCallbacks []func(book BookEvent) + accountEventCallbacks []func(account okexapi.Account) + orderTradesEventCallbacks []func(orderTrades []OrderTradeEvent) + marketTradeEventCallbacks []func(tradeDetail []MarketTradeEvent) +} + +func NewStream(client *okexapi.RestClient, balanceProvider types.ExchangeAccountService) *Stream { + stream := &Stream{ + client: client, + balanceProvider: balanceProvider, + StandardStream: types.NewStandardStream(), + kLineStream: NewKLineStream(), + } + + stream.SetParser(parseWebSocketEvent) + stream.SetDispatcher(stream.dispatchEvent) + stream.SetEndpointCreator(stream.createEndpoint) + stream.SetPingInterval(pingInterval) + + stream.OnBookEvent(stream.handleBookEvent) + stream.OnAccountEvent(stream.handleAccountEvent) + stream.OnMarketTradeEvent(stream.handleMarketTradeEvent) + stream.OnOrderTradesEvent(stream.handleOrderDetailsEvent) + stream.OnConnect(stream.handleConnect) + stream.OnAuth(stream.subscribePrivateChannels(stream.emitBalanceSnapshot)) + stream.kLineStream.OnKLineClosed(stream.EmitKLineClosed) + stream.kLineStream.OnKLine(stream.EmitKLine) + + return stream +} + +func syncSubscriptions(conn *websocket.Conn, subscriptions []types.Subscription, opType WsEventType) error { + if opType != WsEventTypeUnsubscribe && opType != WsEventTypeSubscribe { + return fmt.Errorf("unexpected subscription type: %v", opType) + } + + logger := log.WithField("opType", opType) + var topics []WebsocketSubscription + for _, subscription := range subscriptions { + topic, err := convertSubscription(subscription) + if err != nil { + logger.WithError(err).Errorf("convert error, subscription: %+v", subscription) + return err + } + + topics = append(topics, topic) + } + + logger.Infof("%s channels: %+v", opType, topics) + if err := conn.WriteJSON(WebsocketOp{ + Op: opType, + Args: topics, + }); err != nil { + logger.WithError(err).Error("failed to send request") + return err + } + + return nil +} + +func (s *Stream) Unsubscribe() { + // errors are handled in the syncSubscriptions, so they are skipped here. + _ = syncSubscriptions(s.StandardStream.Conn, s.StandardStream.Subscriptions, WsEventTypeUnsubscribe) + s.Resubscribe(func(old []types.Subscription) (new []types.Subscription, err error) { + // clear the subscriptions + return []types.Subscription{}, nil + }) + + s.kLineStream.Unsubscribe() +} + +func (s *Stream) Connect(ctx context.Context) error { + if err := s.StandardStream.Connect(ctx); err != nil { + return err + } + if err := s.kLineStream.Connect(ctx); err != nil { + return err + } + return nil +} + +func (s *Stream) Subscribe(channel types.Channel, symbol string, options types.SubscribeOptions) { + if channel == types.KLineChannel { + s.kLineStream.Subscribe(channel, symbol, options) + } else { + s.StandardStream.Subscribe(channel, symbol, options) + } +} + +func subscribe(conn *websocket.Conn, subs []WebsocketSubscription) { + if len(subs) == 0 { + return + } + + log.Infof("subscribing channels: %+v", subs) + err := conn.WriteJSON(WebsocketOp{ + Op: "subscribe", + Args: subs, + }) + + if err != nil { + log.WithError(err).Error("subscribe error") + } +} + +func (s *Stream) handleConnect() { + if s.PublicOnly { + var subs []WebsocketSubscription + for _, subscription := range s.Subscriptions { + sub, err := convertSubscription(subscription) + if err != nil { + log.WithError(err).Errorf("subscription convert error") + continue + } + + subs = append(subs, sub) + } + subscribe(s.StandardStream.Conn, subs) + } else { + // login as private channel + // sign example: + // sign=CryptoJS.enc.Base64.Stringify(CryptoJS.HmacSHA256(timestamp +'GET'+'/users/self/verify', secretKey)) + msTimestamp := strconv.FormatFloat(float64(time.Now().UnixNano())/float64(time.Second), 'f', -1, 64) + payload := msTimestamp + "GET" + "/users/self/verify" + sign := okexapi.Sign(payload, s.client.Secret) + op := WebsocketOp{ + Op: "login", + Args: []WebsocketLogin{ + { + Key: s.client.Key, + Passphrase: s.client.Passphrase, + Timestamp: msTimestamp, + Sign: sign, + }, + }, + } + + log.Infof("sending okex login request") + err := s.Conn.WriteJSON(op) + if err != nil { + log.WithError(err).Errorf("can not send login message") + } + } +} + +func (s *Stream) subscribePrivateChannels(next func()) func() { + return func() { + var subs = []WebsocketSubscription{ + {Channel: ChannelAccount}, + {Channel: "orders", InstrumentType: string(okexapi.InstrumentTypeSpot)}, + } + + // https://www.okx.com/docs-v5/zh/#overview-websocket-connect + // **NOTICE** 2024/06/03 Since the number of channels we are currently subscribed to is far less + // than the rate limit of 20, rate limiting is not supported for now. + log.Infof("subscribing private channels: %+v", subs) + err := s.Conn.WriteJSON(WebsocketOp{ + Op: "subscribe", + Args: subs, + }) + if err != nil { + log.WithError(err).Error("private channel subscribe error") + return + } + next() + } +} + +func (s *Stream) emitBalanceSnapshot() { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var balancesMap types.BalanceMap + var err error + err = retry.GeneralBackoff(ctx, func() error { + balancesMap, err = s.balanceProvider.QueryAccountBalances(ctx) + return err + }) + if err != nil { + log.WithError(err).Error("no more attempts to retrieve balances") + return + } + + s.EmitBalanceSnapshot(balancesMap) +} + +func (s *Stream) handleOrderDetailsEvent(orderTrades []OrderTradeEvent) { + for _, evt := range orderTrades { + if evt.TradeId != "" { + trade, err := evt.toGlobalTrade() + if err != nil { + if tradeLogLimiter.Allow() { + log.WithError(err).Errorf("failed to convert global trade") + } + } else { + s.EmitTradeUpdate(trade) + } + } + + order, err := orderDetailToGlobal(&evt.OrderDetail) + if err != nil { + if tradeLogLimiter.Allow() { + log.WithError(err).Errorf("failed to convert global order") + } + } else { + s.EmitOrderUpdate(*order) + } + } +} + +func (s *Stream) handleAccountEvent(account okexapi.Account) { + balances := toGlobalBalance(&account) + s.EmitBalanceUpdate(balances) +} + +func (s *Stream) handleBookEvent(data BookEvent) { + book := data.Book() + switch data.Action { + case ActionTypeSnapshot: + s.EmitBookSnapshot(book) + case ActionTypeUpdate: + s.EmitBookUpdate(book) + } +} + +func (s *Stream) handleMarketTradeEvent(data []MarketTradeEvent) { + for _, event := range data { + trade, err := event.toGlobalTrade() + if err != nil { + if marketTradeLogLimiter.Allow() { + log.WithError(err).Error("failed to convert to market trade") + } + continue + } + + s.EmitMarketTrade(trade) + } +} + +func (s *Stream) createEndpoint(ctx context.Context) (string, error) { + var url string + if s.PublicOnly { + url = okexapi.PublicWebSocketURL + } else { + url = okexapi.PrivateWebSocketURL + } + return url, nil +} + +func (s *Stream) dispatchEvent(e interface{}) { + switch et := e.(type) { + case *WebSocketEvent: + if err := et.IsValid(); err != nil { + log.Errorf("invalid event: %v", err) + return + } + if et.IsAuthenticated() { + s.EmitAuth() + } + + case *BookEvent: + // there's "books" for 400 depth and books5 for 5 depth + if et.channel != ChannelBooks5 { + s.EmitBookEvent(*et) + } + s.EmitBookTickerUpdate(et.BookTicker()) + + case *okexapi.Account: + s.EmitAccountEvent(*et) + + case []OrderTradeEvent: + s.EmitOrderTradesEvent(et) + + case []MarketTradeEvent: + s.EmitMarketTradeEvent(et) + + } +} diff --git a/pkg/exchange/okex/stream_callbacks.go b/pkg/exchange/okex/stream_callbacks.go new file mode 100644 index 0000000..4f85469 --- /dev/null +++ b/pkg/exchange/okex/stream_callbacks.go @@ -0,0 +1,69 @@ +// Code generated by "callbackgen -type Stream -interface"; DO NOT EDIT. + +package okex + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/okex/okexapi" +) + +func (s *Stream) OnKLineEvent(cb func(candle KLineEvent)) { + s.kLineEventCallbacks = append(s.kLineEventCallbacks, cb) +} + +func (s *Stream) EmitKLineEvent(candle KLineEvent) { + for _, cb := range s.kLineEventCallbacks { + cb(candle) + } +} + +func (s *Stream) OnBookEvent(cb func(book BookEvent)) { + s.bookEventCallbacks = append(s.bookEventCallbacks, cb) +} + +func (s *Stream) EmitBookEvent(book BookEvent) { + for _, cb := range s.bookEventCallbacks { + cb(book) + } +} + +func (s *Stream) OnAccountEvent(cb func(account okexapi.Account)) { + s.accountEventCallbacks = append(s.accountEventCallbacks, cb) +} + +func (s *Stream) EmitAccountEvent(account okexapi.Account) { + for _, cb := range s.accountEventCallbacks { + cb(account) + } +} + +func (s *Stream) OnOrderTradesEvent(cb func(orderTrades []OrderTradeEvent)) { + s.orderTradesEventCallbacks = append(s.orderTradesEventCallbacks, cb) +} + +func (s *Stream) EmitOrderTradesEvent(orderTrades []OrderTradeEvent) { + for _, cb := range s.orderTradesEventCallbacks { + cb(orderTrades) + } +} + +func (s *Stream) OnMarketTradeEvent(cb func(tradeDetail []MarketTradeEvent)) { + s.marketTradeEventCallbacks = append(s.marketTradeEventCallbacks, cb) +} + +func (s *Stream) EmitMarketTradeEvent(tradeDetail []MarketTradeEvent) { + for _, cb := range s.marketTradeEventCallbacks { + cb(tradeDetail) + } +} + +type StreamEventHub interface { + OnKLineEvent(cb func(candle KLineEvent)) + + OnBookEvent(cb func(book BookEvent)) + + OnAccountEvent(cb func(account okexapi.Account)) + + OnOrderTradesEvent(cb func(orderTrades []OrderTradeEvent)) + + OnMarketTradeEvent(cb func(tradeDetail []MarketTradeEvent)) +} diff --git a/pkg/exchange/okex/stream_test.go b/pkg/exchange/okex/stream_test.go new file mode 100644 index 0000000..07bcd99 --- /dev/null +++ b/pkg/exchange/okex/stream_test.go @@ -0,0 +1,188 @@ +package okex + +import ( + "context" + "os" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/testutil" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func getTestClientOrSkip(t *testing.T) *Stream { + if b, _ := strconv.ParseBool(os.Getenv("CI")); b { + t.Skip("skip test for CI") + } + + key, secret, passphrase, ok := testutil.IntegrationTestWithPassphraseConfigured(t, "OKEX") + if !ok { + t.Skip("OKEX_* env vars are not configured") + return nil + } + + exchange := New(key, secret, passphrase) + return NewStream(exchange.client, exchange) +} + +func TestStream(t *testing.T) { + t.Skip() + s := getTestClientOrSkip(t) + + t.Run("account test", func(t *testing.T) { + err := s.Connect(context.Background()) + assert.NoError(t, err) + + s.OnBalanceUpdate(func(balances types.BalanceMap) { + t.Log("got snapshot", balances) + }) + s.OnBalanceSnapshot(func(balances types.BalanceMap) { + t.Log("got snapshot", balances) + }) + s.OnBookUpdate(func(book types.SliceOrderBook) { + t.Log("got update", book) + }) + c := make(chan struct{}) + <-c + }) + + t.Run("book test", func(t *testing.T) { + s.Subscribe(types.BookChannel, "BTCUSDT", types.SubscribeOptions{ + Depth: types.DepthLevel400, + }) + s.SetPublicOnly() + err := s.Connect(context.Background()) + assert.NoError(t, err) + + s.OnBookSnapshot(func(book types.SliceOrderBook) { + t.Log("got snapshot", book) + }) + s.OnBookUpdate(func(book types.SliceOrderBook) { + t.Log("got update", book) + }) + c := make(chan struct{}) + <-c + }) + + t.Run("book && kline test", func(t *testing.T) { + s.Subscribe(types.BookChannel, "BTCUSDT", types.SubscribeOptions{ + Depth: types.DepthLevel400, + }) + s.Subscribe(types.KLineChannel, "BTCUSDT", types.SubscribeOptions{ + Interval: types.Interval1m, + }) + s.SetPublicOnly() + err := s.Connect(context.Background()) + assert.NoError(t, err) + + s.OnBookSnapshot(func(book types.SliceOrderBook) { + t.Log("got snapshot", book) + }) + s.OnBookUpdate(func(book types.SliceOrderBook) { + t.Log("got update", book) + }) + s.OnKLine(func(kline types.KLine) { + t.Log("kline", kline) + }) + s.OnKLineClosed(func(kline types.KLine) { + t.Log("kline closed", kline) + }) + + c := make(chan struct{}) + <-c + }) + + t.Run("market trade test", func(t *testing.T) { + s.Subscribe(types.MarketTradeChannel, "BTCUSDT", types.SubscribeOptions{}) + s.SetPublicOnly() + err := s.Connect(context.Background()) + assert.NoError(t, err) + + s.OnMarketTrade(func(trade types.Trade) { + t.Log("got trade upgrade", trade) + }) + c := make(chan struct{}) + <-c + }) + + t.Run("kline test", func(t *testing.T) { + s.Subscribe(types.KLineChannel, "LTC-USD-200327", types.SubscribeOptions{ + Interval: types.Interval1m, + }) + s.SetPublicOnly() + err := s.Connect(context.Background()) + assert.NoError(t, err) + + s.OnKLine(func(kline types.KLine) { + t.Log("got update", kline) + }) + s.OnKLineClosed(func(kline types.KLine) { + t.Log("got closed", kline) + }) + c := make(chan struct{}) + <-c + }) + + t.Run("Subscribe/Unsubscribe test", func(t *testing.T) { + s.Subscribe(types.BookChannel, "BTCUSDT", types.SubscribeOptions{ + Depth: types.DepthLevel400, + }) + s.SetPublicOnly() + err := s.Connect(context.Background()) + assert.NoError(t, err) + + s.OnBookSnapshot(func(book types.SliceOrderBook) { + t.Log("got snapshot", book) + }) + s.OnBookUpdate(func(book types.SliceOrderBook) { + t.Log("got update", book) + }) + + <-time.After(5 * time.Second) + + s.Unsubscribe() + c := make(chan struct{}) + <-c + }) + + t.Run("Resubscribe test", func(t *testing.T) { + s.Subscribe(types.BookChannel, "BTCUSDT", types.SubscribeOptions{ + Depth: types.DepthLevel400, + }) + s.SetPublicOnly() + err := s.Connect(context.Background()) + assert.NoError(t, err) + + s.OnBookSnapshot(func(book types.SliceOrderBook) { + t.Log("got snapshot", book) + }) + s.OnBookUpdate(func(book types.SliceOrderBook) { + t.Log("got update", book) + }) + + <-time.After(5 * time.Second) + + s.Resubscribe(func(old []types.Subscription) (new []types.Subscription, err error) { + return old, nil + }) + c := make(chan struct{}) + <-c + }) + + t.Run("order trade test", func(t *testing.T) { + err := s.Connect(context.Background()) + assert.NoError(t, err) + + s.OnOrderUpdate(func(order types.Order) { + t.Log("order update", order) + }) + s.OnTradeUpdate(func(trade types.Trade) { + t.Log("trade update", trade) + }) + c := make(chan struct{}) + <-c + }) +} diff --git a/pkg/exchange/okex/symbols.go b/pkg/exchange/okex/symbols.go new file mode 100644 index 0000000..7832072 --- /dev/null +++ b/pkg/exchange/okex/symbols.go @@ -0,0 +1,560 @@ +// Code generated by go generate; DO NOT EDIT. +package okex + +var spotSymbolMap = map[string]string{ + "1INCHEUR": "1INCH-EUR", + "1INCHUSDC": "1INCH-USDC", + "1INCHUSDT": "1INCH-USDT", + "AAVEBTC": "AAVE-BTC", + "AAVEEUR": "AAVE-EUR", + "AAVEUSDC": "AAVE-USDC", + "AAVEUSDT": "AAVE-USDT", + "ACAUSDT": "ACA-USDT", + "ACEUSDC": "ACE-USDC", + "ACEUSDT": "ACE-USDT", + "ACHUSDT": "ACH-USDT", + "ADABTC": "ADA-BTC", + "ADAETH": "ADA-ETH", + "ADAEUR": "ADA-EUR", + "ADAUSDC": "ADA-USDC", + "ADAUSDT": "ADA-USDT", + "AERGOUSDT": "AERGO-USDT", + "AEVOUSDT": "AEVO-USDT", + "AGIXUSDC": "AGIX-USDC", + "AGIXUSDT": "AGIX-USDT", + "AGLDUSDC": "AGLD-USDC", + "AGLDUSDT": "AGLD-USDT", + "AIDOGEUSDT": "AIDOGE-USDT", + "AKITAUSDT": "AKITA-USDT", + "ALCXUSDT": "ALCX-USDT", + "ALGOEUR": "ALGO-EUR", + "ALGOTRY": "ALGO-TRY", + "ALGOUSDC": "ALGO-USDC", + "ALGOUSDT": "ALGO-USDT", + "ALPHAUSDT": "ALPHA-USDT", + "ANTUSDT": "ANT-USDT", + "APEEUR": "APE-EUR", + "APEUSDC": "APE-USDC", + "APEUSDT": "APE-USDT", + "API3USDT": "API3-USDT", + "APMUSDT": "APM-USDT", + "APTEUR": "APT-EUR", + "APTUSDC": "APT-USDC", + "APTUSDT": "APT-USDT", + "ARBEUR": "ARB-EUR", + "ARBUSDT": "ARB-USDT", + "ARGUSDT": "ARG-USDT", + "ARTYUSDT": "ARTY-USDT", + "ARUSDC": "AR-USDC", + "ARUSDT": "AR-USDT", + "ASTREUR": "ASTR-EUR", + "ASTRUSDC": "ASTR-USDC", + "ASTRUSDT": "ASTR-USDT", + "ASTUSDT": "AST-USDT", + "ATOMBTC": "ATOM-BTC", + "ATOMETH": "ATOM-ETH", + "ATOMEUR": "ATOM-EUR", + "ATOMUSDC": "ATOM-USDC", + "ATOMUSDT": "ATOM-USDT", + "AUCTIONUSDC": "AUCTION-USDC", + "AUCTIONUSDT": "AUCTION-USDT", + "AVAXBTC": "AVAX-BTC", + "AVAXEUR": "AVAX-EUR", + "AVAXUSDC": "AVAX-USDC", + "AVAXUSDT": "AVAX-USDT", + "AVIVEUSDT": "AVIVE-USDT", + "AXSEUR": "AXS-EUR", + "AXSUSDT": "AXS-USDT", + "AZYUSDT": "AZY-USDT", + "BABYDOGEUSDT": "BABYDOGE-USDT", + "BADGERUSDT": "BADGER-USDT", + "BALEUR": "BAL-EUR", + "BALUSDT": "BAL-USDT", + "BANDUSDT": "BAND-USDT", + "BATEUR": "BAT-EUR", + "BATUSDT": "BAT-USDT", + "BCHBTC": "BCH-BTC", + "BCHUSDC": "BCH-USDC", + "BCHUSDT": "BCH-USDT", + "BETHETH": "BETH-ETH", + "BETHUSDT": "BETH-USDT", + "BICOUSDT": "BICO-USDT", + "BIGTIMEUSDT": "BIGTIME-USDT", + "BLOCKUSDT": "BLOCK-USDT", + "BLOKUSDT": "BLOK-USDT", + "BLURUSDC": "BLUR-USDC", + "BLURUSDT": "BLUR-USDT", + "BNBUSDC": "BNB-USDC", + "BNBUSDT": "BNB-USDT", + "BNTUSDT": "BNT-USDT", + "BONEUSDT": "BONE-USDT", + "BONKUSDT": "BONK-USDT", + "BORAUSDT": "BORA-USDT", + "BORINGUSDT": "BORING-USDT", + "BRWLUSDT": "BRWL-USDT", + "BSVBTC": "BSV-BTC", + "BSVUSDC": "BSV-USDC", + "BSVUSDT": "BSV-USDT", + "BTCBRL": "BTC-BRL", + "BTCDAI": "BTC-DAI", + "BTCEUR": "BTC-EUR", + "BTCEURT": "BTC-EURT", + "BTCTRY": "BTC-TRY", + "BTCUSDC": "BTC-USDC", + "BTCUSDT": "BTC-USDT", + "BTTUSDT": "BTT-USDT", + "BZZUSDT": "BZZ-USDT", + "CEEKUSDT": "CEEK-USDT", + "CELOUSDT": "CELO-USDT", + "CELRUSDT": "CELR-USDT", + "CELUSDC": "CEL-USDC", + "CELUSDT": "CEL-USDT", + "CETUSUSDC": "CETUS-USDC", + "CETUSUSDT": "CETUS-USDT", + "CFGUSDT": "CFG-USDT", + "CFXUSDC": "CFX-USDC", + "CFXUSDT": "CFX-USDT", + "CGLUSDT": "CGL-USDT", + "CHZBTC": "CHZ-BTC", + "CHZEUR": "CHZ-EUR", + "CHZUSDC": "CHZ-USDC", + "CHZUSDT": "CHZ-USDT", + "CITYUSDT": "CITY-USDT", + "CLVUSDT": "CLV-USDT", + "COMPEUR": "COMP-EUR", + "COMPUSDC": "COMP-USDC", + "COMPUSDT": "COMP-USDT", + "CONVUSDT": "CONV-USDT", + "CORETRY": "CORE-TRY", + "COREUSDT": "CORE-USDT", + "CQTUSDT": "CQT-USDT", + "CROBTC": "CRO-BTC", + "CROEUR": "CRO-EUR", + "CROUSDC": "CRO-USDC", + "CROUSDT": "CRO-USDT", + "CRVBTC": "CRV-BTC", + "CRVEUR": "CRV-EUR", + "CRVUSDC": "CRV-USDC", + "CRVUSDT": "CRV-USDT", + "CSPRUSDC": "CSPR-USDC", + "CSPRUSDT": "CSPR-USDT", + "CTCUSDT": "CTC-USDT", + "CTXCUSDC": "CTXC-USDC", + "CTXCUSDT": "CTXC-USDT", + "CVCUSDT": "CVC-USDT", + "CVXUSDT": "CVX-USDT", + "DAIUSDT": "DAI-USDT", + "DAOUSDT": "DAO-USDT", + "DCRUSDT": "DCR-USDT", + "DEPUSDT": "DEP-USDT", + "DGBUSDT": "DGB-USDT", + "DIAUSDT": "DIA-USDT", + "DMAILUSDT": "DMAIL-USDT", + "DOGEBTC": "DOGE-BTC", + "DOGEETH": "DOGE-ETH", + "DOGEEUR": "DOGE-EUR", + "DOGEUSDC": "DOGE-USDC", + "DOGEUSDT": "DOGE-USDT", + "DORAUSDT": "DORA-USDT", + "DOSEUSDT": "DOSE-USDT", + "DOTBTC": "DOT-BTC", + "DOTEUR": "DOT-EUR", + "DOTUSDC": "DOT-USDC", + "DOTUSDT": "DOT-USDT", + "DYDXEUR": "DYDX-EUR", + "DYDXUSDC": "DYDX-USDC", + "DYDXUSDT": "DYDX-USDT", + "EGLDEUR": "EGLD-EUR", + "EGLDUSDC": "EGLD-USDC", + "EGLDUSDT": "EGLD-USDT", + "ELFUSDT": "ELF-USDT", + "ELONUSDT": "ELON-USDT", + "EMUSDT": "EM-USDT", + "ENJUSDT": "ENJ-USDT", + "ENSUSDT": "ENS-USDT", + "EOSBTC": "EOS-BTC", + "EOSETH": "EOS-ETH", + "EOSEUR": "EOS-EUR", + "EOSUSDC": "EOS-USDC", + "EOSUSDT": "EOS-USDT", + "ERNUSDT": "ERN-USDT", + "ETCBTC": "ETC-BTC", + "ETCUSDC": "ETC-USDC", + "ETCUSDT": "ETC-USDT", + "ETHBRL": "ETH-BRL", + "ETHBTC": "ETH-BTC", + "ETHDAI": "ETH-DAI", + "ETHEUR": "ETH-EUR", + "ETHEURT": "ETH-EURT", + "ETHFIUSDT": "ETHFI-USDT", + "ETHTRY": "ETH-TRY", + "ETHUSDC": "ETH-USDC", + "ETHUSDT": "ETH-USDT", + "ETHWUSDC": "ETHW-USDC", + "ETHWUSDT": "ETHW-USDT", + "EURTUSDT": "EURT-USDT", + "FETEUR": "FET-EUR", + "FETUSDT": "FET-USDT", + "FILBTC": "FIL-BTC", + "FILETH": "FIL-ETH", + "FILUSDC": "FIL-USDC", + "FILUSDT": "FIL-USDT", + "FITFIUSDT": "FITFI-USDT", + "FLMUSDT": "FLM-USDT", + "FLOKIUSDC": "FLOKI-USDC", + "FLOKIUSDT": "FLOKI-USDT", + "FLOWEUR": "FLOW-EUR", + "FLOWUSDT": "FLOW-USDT", + "FLREUR": "FLR-EUR", + "FLRUSDC": "FLR-USDC", + "FLRUSDT": "FLR-USDT", + "FORTHUSDT": "FORTH-USDT", + "FOXYUSDT": "FOXY-USDT", + "FRONTUSDT": "FRONT-USDT", + "FTMEUR": "FTM-EUR", + "FTMUSDC": "FTM-USDC", + "FTMUSDT": "FTM-USDT", + "FXSEUR": "FXS-EUR", + "FXSUSDT": "FXS-USDT", + "GALAUSDC": "GALA-USDC", + "GALAUSDT": "GALA-USDT", + "GALEUR": "GAL-EUR", + "GALFTUSDT": "GALFT-USDT", + "GALUSDT": "GAL-USDT", + "GARIUSDT": "GARI-USDT", + "GASUSDT": "GAS-USDT", + "GEARUSDT": "GEAR-USDT", + "GFTUSDT": "GFT-USDT", + "GHSTUSDT": "GHST-USDT", + "GLMRUSDT": "GLMR-USDT", + "GLMUSDC": "GLM-USDC", + "GLMUSDT": "GLM-USDT", + "GMTUSDT": "GMT-USDT", + "GMXUSDT": "GMX-USDT", + "GOALUSDT": "GOAL-USDT", + "GODSUSDT": "GODS-USDT", + "GOGUSDT": "GOG-USDT", + "GPTUSDT": "GPT-USDT", + "GRTBTC": "GRT-BTC", + "GRTEUR": "GRT-EUR", + "GRTUSDC": "GRT-USDC", + "GRTUSDT": "GRT-USDT", + "HBARBTC": "HBAR-BTC", + "HBAREUR": "HBAR-EUR", + "HBARUSDC": "HBAR-USDC", + "HBARUSDT": "HBAR-USDT", + "ICEUSDT": "ICE-USDT", + "ICPEUR": "ICP-EUR", + "ICPUSDC": "ICP-USDC", + "ICPUSDT": "ICP-USDT", + "ICXUSDT": "ICX-USDT", + "IDUSDT": "ID-USDT", + "IGUUSDT": "IGU-USDT", + "ILVUSDT": "ILV-USDT", + "IMXEUR": "IMX-EUR", + "IMXUSDT": "IMX-USDT", + "INJEUR": "INJ-EUR", + "INJUSDT": "INJ-USDT", + "IOSTUSDT": "IOST-USDT", + "IOTAUSDC": "IOTA-USDC", + "IOTAUSDT": "IOTA-USDT", + "IQUSDT": "IQ-USDT", + "JOEUSDT": "JOE-USDT", + "JPGUSDT": "JPG-USDT", + "JSTUSDT": "JST-USDT", + "JTOEUR": "JTO-EUR", + "JTOUSDC": "JTO-USDC", + "JTOUSDT": "JTO-USDT", + "JUPUSDT": "JUP-USDT", + "KANUSDT": "KAN-USDT", + "KCALUSDT": "KCAL-USDT", + "KDAUSDC": "KDA-USDC", + "KDAUSDT": "KDA-USDT", + "KINEUSDT": "KINE-USDT", + "KISHUUSDT": "KISHU-USDT", + "KLAYUSDC": "KLAY-USDC", + "KLAYUSDT": "KLAY-USDT", + "KNCUSDT": "KNC-USDT", + "KP3RUSDT": "KP3R-USDT", + "KSMUSDT": "KSM-USDT", + "LAMBUSDT": "LAMB-USDT", + "LATUSDT": "LAT-USDT", + "LBRUSDT": "LBR-USDT", + "LDOEUR": "LDO-EUR", + "LDOUSDT": "LDO-USDT", + "LEASHUSDT": "LEASH-USDT", + "LEOUSDT": "LEO-USDT", + "LETUSDT": "LET-USDT", + "LHINUUSDT": "LHINU-USDT", + "LINGUSDT": "LING-USDT", + "LINKBTC": "LINK-BTC", + "LINKETH": "LINK-ETH", + "LINKEUR": "LINK-EUR", + "LINKUSDC": "LINK-USDC", + "LINKUSDT": "LINK-USDT", + "LITHUSDT": "LITH-USDT", + "LONUSDT": "LON-USDT", + "LOOKSUSDC": "LOOKS-USDC", + "LOOKSUSDT": "LOOKS-USDT", + "LPTUSDT": "LPT-USDT", + "LQTYUSDT": "LQTY-USDT", + "LRCUSDC": "LRC-USDC", + "LRCUSDT": "LRC-USDT", + "LSKUSDT": "LSK-USDT", + "LTCBTC": "LTC-BTC", + "LTCETH": "LTC-ETH", + "LTCEUR": "LTC-EUR", + "LTCTRY": "LTC-TRY", + "LTCUSDC": "LTC-USDC", + "LTCUSDT": "LTC-USDT", + "LUNAUSDC": "LUNA-USDC", + "LUNAUSDT": "LUNA-USDT", + "LUNCEUR": "LUNC-EUR", + "LUNCUSDC": "LUNC-USDC", + "LUNCUSDT": "LUNC-USDT", + "MAGICUSDC": "MAGIC-USDC", + "MAGICUSDT": "MAGIC-USDT", + "MANABTC": "MANA-BTC", + "MANAEUR": "MANA-EUR", + "MANAUSDC": "MANA-USDC", + "MANAUSDT": "MANA-USDT", + "MASKUSDC": "MASK-USDC", + "MASKUSDT": "MASK-USDT", + "MATICBTC": "MATIC-BTC", + "MATICEUR": "MATIC-EUR", + "MATICUSDC": "MATIC-USDC", + "MATICUSDT": "MATIC-USDT", + "MDTUSDT": "MDT-USDT", + "MEMEUSDT": "MEME-USDT", + "MENGOUSDT": "MENGO-USDT", + "MERLUSDT": "MERL-USDT", + "METISUSDC": "METIS-USDC", + "METISUSDT": "METIS-USDT", + "MEWUSDT": "MEW-USDT", + "MILOUSDT": "MILO-USDT", + "MINAEUR": "MINA-EUR", + "MINAUSDC": "MINA-USDC", + "MINAUSDT": "MINA-USDT", + "MKRBTC": "MKR-BTC", + "MKREUR": "MKR-EUR", + "MKRUSDC": "MKR-USDC", + "MKRUSDT": "MKR-USDT", + "MLNUSDT": "MLN-USDT", + "MOVEZUSDT": "MOVEZ-USDT", + "MOVRUSDT": "MOVR-USDT", + "MRSTUSDT": "MRST-USDT", + "MSNUSDT": "MSN-USDT", + "MXCUSDT": "MXC-USDT", + "MYRIAUSDT": "MYRIA-USDT", + "NEARBTC": "NEAR-BTC", + "NEARUSDC": "NEAR-USDC", + "NEARUSDT": "NEAR-USDT", + "NEOBTC": "NEO-BTC", + "NEOUSDC": "NEO-USDC", + "NEOUSDT": "NEO-USDT", + "NFTUSDT": "NFT-USDT", + "NMRUSDT": "NMR-USDT", + "NULSUSDT": "NULS-USDT", + "NYMUSDT": "NYM-USDT", + "OASUSDT": "OAS-USDT", + "OKBBTC": "OKB-BTC", + "OKBETH": "OKB-ETH", + "OKBUSDC": "OKB-USDC", + "OKBUSDT": "OKB-USDT", + "OKTBTC": "OKT-BTC", + "OKTETH": "OKT-ETH", + "OKTUSDC": "OKT-USDC", + "OKTUSDT": "OKT-USDT", + "OMGUSDT": "OMG-USDT", + "OMIUSDT": "OMI-USDT", + "OMNUSDT": "OMN-USDT", + "OMUSDC": "OM-USDC", + "OMUSDT": "OM-USDT", + "ONEUSDT": "ONE-USDT", + "ONTUSDT": "ONT-USDT", + "OPEUR": "OP-EUR", + "OPUSDC": "OP-USDC", + "OPUSDT": "OP-USDT", + "ORBSUSDT": "ORBS-USDT", + "ORBUSDT": "ORB-USDT", + "ORDIUSDC": "ORDI-USDC", + "ORDIUSDT": "ORDI-USDT", + "OXTUSDT": "OXT-USDT", + "PCIUSDT": "PCI-USDT", + "PEOPLEUSDT": "PEOPLE-USDT", + "PEPEBRL": "PEPE-BRL", + "PEPEUSDC": "PEPE-USDC", + "PEPEUSDT": "PEPE-USDT", + "PERPUSDT": "PERP-USDT", + "PHAUSDT": "PHA-USDT", + "PITUSDT": "PIT-USDT", + "POLSUSDT": "POLS-USDT", + "POLYDOGEUSDT": "POLYDOGE-USDT", + "PORUSDT": "POR-USDT", + "PRCLUSDT": "PRCL-USDT", + "PRQUSDT": "PRQ-USDT", + "PSTAKEUSDT": "PSTAKE-USDT", + "PYTHUSDT": "PYTH-USDT", + "QTUMBTC": "QTUM-BTC", + "QTUMUSDT": "QTUM-USDT", + "RACAUSDT": "RACA-USDT", + "RADARUSDT": "RADAR-USDT", + "RAYUSDT": "RAY-USDT", + "RDNTUSDC": "RDNT-USDC", + "RDNTUSDT": "RDNT-USDT", + "RENUSDT": "REN-USDT", + "REPUSDT": "REP-USDT", + "REVVUSDT": "REVV-USDT", + "RIOUSDT": "RIO-USDT", + "RNDRUSDT": "RNDR-USDT", + "RONUSDC": "RON-USDC", + "RONUSDT": "RON-USDT", + "RPLUSDC": "RPL-USDC", + "RPLUSDT": "RPL-USDT", + "RSRUSDT": "RSR-USDT", + "RSS3USDT": "RSS3-USDT", + "RUNECOINUSDT": "RUNECOIN-USDT", + "RVNUSDC": "RVN-USDC", + "RVNUSDT": "RVN-USDT", + "SAFEUSDT": "SAFE-USDT", + "SAMOUSDT": "SAMO-USDT", + "SANDEUR": "SAND-EUR", + "SANDUSDC": "SAND-USDC", + "SANDUSDT": "SAND-USDT", + "SATSUSDT": "SATS-USDT", + "SCUSDT": "SC-USDT", + "SDUSDT": "SD-USDT", + "SHIBBTC": "SHIB-BTC", + "SHIBEUR": "SHIB-EUR", + "SHIBUSDC": "SHIB-USDC", + "SHIBUSDT": "SHIB-USDT", + "SISUSDT": "SIS-USDT", + "SKEBUSDT": "SKEB-USDT", + "SKLUSDT": "SKL-USDT", + "SLNUSDT": "SLN-USDT", + "SLPUSDT": "SLP-USDT", + "SNTUSDT": "SNT-USDT", + "SNXEUR": "SNX-EUR", + "SNXUSDC": "SNX-USDC", + "SNXUSDT": "SNX-USDT", + "SOLBTC": "SOL-BTC", + "SOLETH": "SOL-ETH", + "SOLEUR": "SOL-EUR", + "SOLUSDC": "SOL-USDC", + "SOLUSDT": "SOL-USDT", + "SPELLUSDT": "SPELL-USDT", + "SPURSUSDT": "SPURS-USDT", + "SSVUSDT": "SSV-USDT", + "SSWPUSDT": "SSWP-USDT", + "STARLUSDT": "STARL-USDT", + "STCUSDT": "STC-USDT", + "STETHETH": "STETH-ETH", + "STETHUSDT": "STETH-USDT", + "STORJUSDC": "STORJ-USDC", + "STORJUSDT": "STORJ-USDT", + "STRKUSDC": "STRK-USDC", + "STRKUSDT": "STRK-USDT", + "STXBTC": "STX-BTC", + "STXEUR": "STX-EUR", + "STXUSDC": "STX-USDC", + "STXUSDT": "STX-USDT", + "SUIEUR": "SUI-EUR", + "SUIUSDC": "SUI-USDC", + "SUIUSDT": "SUI-USDT", + "SUNUSDT": "SUN-USDT", + "SUSHIEUR": "SUSHI-EUR", + "SUSHIUSDC": "SUSHI-USDC", + "SUSHIUSDT": "SUSHI-USDT", + "SWEATUSDC": "SWEAT-USDC", + "SWEATUSDT": "SWEAT-USDT", + "SWFTCUSDT": "SWFTC-USDT", + "TAKIUSDT": "TAKI-USDT", + "TAMAUSDT": "TAMA-USDT", + "THETAUSDT": "THETA-USDT", + "THGUSDT": "THG-USDT", + "TIAUSDT": "TIA-USDT", + "TNSRUSDT": "TNSR-USDT", + "TONEUR": "TON-EUR", + "TONUSDC": "TON-USDC", + "TONUSDT": "TON-USDT", + "TRAUSDT": "TRA-USDT", + "TRBUSDT": "TRB-USDT", + "TRXBTC": "TRX-BTC", + "TRXETH": "TRX-ETH", + "TRXEUR": "TRX-EUR", + "TRXUSDC": "TRX-USDC", + "TRXUSDT": "TRX-USDT", + "TUPUSDT": "TUP-USDT", + "TURBOUSDT": "TURBO-USDT", + "TUSDT": "T-USDT", + "UMAUSDT": "UMA-USDT", + "UNIBTC": "UNI-BTC", + "UNIEUR": "UNI-EUR", + "UNIUSDC": "UNI-USDC", + "UNIUSDT": "UNI-USDT", + "USDCBRL": "USDC-BRL", + "USDCEUR": "USDC-EUR", + "USDCUSDT": "USDC-USDT", + "USDTBRL": "USDT-BRL", + "USDTEUR": "USDT-EUR", + "USDTTRY": "USDT-TRY", + "USDTUSDC": "USDT-USDC", + "USTCUSDT": "USTC-USDT", + "UTKUSDT": "UTK-USDT", + "VELAUSDT": "VELA-USDT", + "VELODROMEUSDT": "VELODROME-USDT", + "VELOUSDC": "VELO-USDC", + "VELOUSDT": "VELO-USDT", + "VENOMUSDC": "VENOM-USDC", + "VENOMUSDT": "VENOM-USDT", + "VRAUSDT": "VRA-USDT", + "WAXPUSDC": "WAXP-USDC", + "WAXPUSDT": "WAXP-USDT", + "WBTCBTC": "WBTC-BTC", + "WBTCUSDT": "WBTC-USDT", + "WIFEUR": "WIF-EUR", + "WIFIUSDT": "WIFI-USDT", + "WIFUSDC": "WIF-USDC", + "WIFUSDT": "WIF-USDT", + "WINUSDT": "WIN-USDT", + "WLDUSDT": "WLD-USDT", + "WNCGUSDT": "WNCG-USDT", + "WOOEUR": "WOO-EUR", + "WOOUSDT": "WOO-USDT", + "WSMUSDT": "WSM-USDT", + "WUSDT": "W-USDT", + "WXTUSDT": "WXT-USDT", + "XAUTUSDT": "XAUT-USDT", + "XCHBTC": "XCH-BTC", + "XCHUSDT": "XCH-USDT", + "XECUSDT": "XEC-USDT", + "XEMUSDT": "XEM-USDT", + "XLMBTC": "XLM-BTC", + "XLMEUR": "XLM-EUR", + "XLMUSDC": "XLM-USDC", + "XLMUSDT": "XLM-USDT", + "XNOUSDT": "XNO-USDT", + "XPRUSDT": "XPR-USDT", + "XRPBTC": "XRP-BTC", + "XRPETH": "XRP-ETH", + "XRPEUR": "XRP-EUR", + "XRPTRY": "XRP-TRY", + "XRPUSDC": "XRP-USDC", + "XRPUSDT": "XRP-USDT", + "XTZEUR": "XTZ-EUR", + "XTZUSDT": "XTZ-USDT", + "YFIUSDT": "YFI-USDT", + "YGGEUR": "YGG-EUR", + "YGGUSDC": "YGG-USDC", + "YGGUSDT": "YGG-USDT", + "ZBCNUSDT": "ZBCN-USDT", + "ZENTUSDT": "ZENT-USDT", + "ZEROUSDT": "ZERO-USDT", + "ZETAUSDT": "ZETA-USDT", + "ZEUSUSDT": "ZEUS-USDT", + "ZILUSDC": "ZIL-USDC", + "ZILUSDT": "ZIL-USDT", + "ZKUSDT": "ZK-USDT", + "ZRXUSDT": "ZRX-USDT", +} + diff --git a/pkg/exchange/okex/types.go b/pkg/exchange/okex/types.go new file mode 100644 index 0000000..1b9a20a --- /dev/null +++ b/pkg/exchange/okex/types.go @@ -0,0 +1,40 @@ +package okex + +import "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + +var ( + // below are supported UTC timezone interval for okex + SupportedIntervals = map[types.Interval]int{ + types.Interval1m: 1 * 60, + types.Interval3m: 3 * 60, + types.Interval5m: 5 * 60, + types.Interval15m: 15 * 60, + types.Interval30m: 30 * 60, + types.Interval1h: 60 * 60, + types.Interval2h: 60 * 60 * 2, + types.Interval4h: 60 * 60 * 4, + types.Interval6h: 60 * 60 * 6, + types.Interval12h: 60 * 60 * 12, + types.Interval1d: 60 * 60 * 24, + types.Interval3d: 60 * 60 * 24 * 3, + types.Interval1w: 60 * 60 * 24 * 7, + types.Interval1mo: 60 * 60 * 24 * 30, + } + + ToLocalInterval = map[types.Interval]string{ + types.Interval1m: "1m", + types.Interval3m: "3m", + types.Interval5m: "5m", + types.Interval15m: "15m", + types.Interval30m: "30m", + types.Interval1h: "1H", + types.Interval2h: "2H", + types.Interval4h: "4H", + types.Interval6h: "6Hutc", + types.Interval12h: "12Hutc", + types.Interval1d: "1Dutc", + types.Interval3d: "3Dutc", + types.Interval1w: "1Wutc", + types.Interval1mo: "1Mutc", + } +) diff --git a/pkg/exchange/retry/account.go b/pkg/exchange/retry/account.go new file mode 100644 index 0000000..830aafa --- /dev/null +++ b/pkg/exchange/retry/account.go @@ -0,0 +1,43 @@ +package retry + +import ( + "context" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func QueryAccountUntilSuccessful( + ctx context.Context, ex types.ExchangeAccountService, +) (account *types.Account, err error) { + var op = func() (err2 error) { + account, err2 = ex.QueryAccount(ctx) + return err2 + } + + err = GeneralBackoff(ctx, op) + return account, err +} + +func QueryAccountBalancesUntilSuccessful( + ctx context.Context, ex types.ExchangeAccountService, +) (bals types.BalanceMap, err error) { + var op = func() (err2 error) { + bals, err2 = ex.QueryAccountBalances(ctx) + return err2 + } + + err = GeneralBackoff(ctx, op) + return bals, err +} + +func QueryAccountBalancesUntilSuccessfulLite( + ctx context.Context, ex types.ExchangeAccountService, +) (bals types.BalanceMap, err error) { + var op = func() (err2 error) { + bals, err2 = ex.QueryAccountBalances(ctx) + return err2 + } + + err = GeneralLiteBackoff(ctx, op) + return bals, err +} diff --git a/pkg/exchange/retry/order.go b/pkg/exchange/retry/order.go new file mode 100644 index 0000000..02689c3 --- /dev/null +++ b/pkg/exchange/retry/order.go @@ -0,0 +1,227 @@ +package retry + +import ( + "context" + "errors" + "fmt" + "github.com/cenkalti/backoff/v4" + "strconv" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +var ErrOrderIsNil = errors.New("order object is nil") + +type advancedOrderCancelService interface { + CancelAllOrders(ctx context.Context) ([]types.Order, error) + CancelOrdersBySymbol(ctx context.Context, symbol string) ([]types.Order, error) + CancelOrdersByGroupID(ctx context.Context, groupID uint32) ([]types.Order, error) +} + +func QueryOrderUntilCanceled( + ctx context.Context, queryOrderService types.ExchangeOrderQueryService, symbol string, orderId uint64, +) (o *types.Order, err error) { + var op = func() (err2 error) { + o, err2 = queryOrderService.QueryOrder(ctx, types.OrderQuery{ + Symbol: symbol, + OrderID: strconv.FormatUint(orderId, 10), + }) + + if err2 != nil { + return err2 + } + + if o == nil { + return fmt.Errorf("order #%d response is nil", orderId) + } + + if o.Status == types.OrderStatusCanceled || o.Status == types.OrderStatusFilled { + return nil + } + + return fmt.Errorf("order #%d is not canceled yet: %s", o.OrderID, o.Status) + } + + err = GeneralBackoff(ctx, op) + return o, err +} + +func QueryOrderUntilFilled( + ctx context.Context, queryOrderService types.ExchangeOrderQueryService, symbol string, orderId uint64, +) (o *types.Order, err error) { + var op = func() (err2 error) { + o, err2 = queryOrderService.QueryOrder(ctx, types.OrderQuery{ + Symbol: symbol, + OrderID: strconv.FormatUint(orderId, 10), + }) + + if err2 != nil { + return err2 + } + + if o == nil { + return ErrOrderIsNil + } + + // for final status return nil error to stop the retry + switch o.Status { + case types.OrderStatusFilled, types.OrderStatusCanceled: + return nil + } + + return fmt.Errorf("order is not filled yet: status=%s E/Q=%s/%s", o.Status, o.ExecutedQuantity.String(), o.Quantity.String()) + } + + err = GeneralBackoff(ctx, op) + return o, err +} + +func GeneralBackoff(ctx context.Context, op backoff.Operation) (err error) { + err = backoff.Retry(op, backoff.WithContext( + backoff.WithMaxRetries( + backoff.NewExponentialBackOff(), + 101), + ctx)) + return err +} + +func GeneralLiteBackoff(ctx context.Context, op backoff.Operation) (err error) { + err = backoff.Retry(op, backoff.WithContext( + backoff.WithMaxRetries( + backoff.NewExponentialBackOff(), + 5), + ctx)) + return err +} + +func QueryOpenOrdersUntilSuccessful( + ctx context.Context, ex types.Exchange, symbol string, +) (openOrders []types.Order, err error) { + var op = func() (err2 error) { + openOrders, err2 = ex.QueryOpenOrders(ctx, symbol) + return err2 + } + + err = GeneralBackoff(ctx, op) + return openOrders, err +} + +func QueryOpenOrdersUntilSuccessfulLite( + ctx context.Context, ex types.Exchange, symbol string, +) (openOrders []types.Order, err error) { + var op = func() (err2 error) { + openOrders, err2 = ex.QueryOpenOrders(ctx, symbol) + return err2 + } + + err = GeneralLiteBackoff(ctx, op) + return openOrders, err +} + +func QueryClosedOrdersUntilSuccessful( + ctx context.Context, ex types.ExchangeTradeHistoryService, symbol string, since, until time.Time, lastOrderID uint64, +) (closedOrders []types.Order, err error) { + var op = func() (err2 error) { + closedOrders, err2 = ex.QueryClosedOrders(ctx, symbol, since, until, lastOrderID) + return err2 + } + + err = GeneralBackoff(ctx, op) + return closedOrders, err +} + +func QueryClosedOrdersUntilSuccessfulLite( + ctx context.Context, ex types.ExchangeTradeHistoryService, symbol string, since, until time.Time, lastOrderID uint64, +) (closedOrders []types.Order, err error) { + var op = func() (err2 error) { + closedOrders, err2 = ex.QueryClosedOrders(ctx, symbol, since, until, lastOrderID) + return err2 + } + + err = GeneralLiteBackoff(ctx, op) + return closedOrders, err +} + +var ErrTradeFeeIsProcessing = errors.New("trading fee is still processing") +var ErrTradeNotExecutedYet = errors.New("trades not executed yet") + +// QueryOrderTradesUntilSuccessful query order's trades until success (include the trading fee is not processing) +func QueryOrderTradesUntilSuccessful( + ctx context.Context, ex types.ExchangeOrderQueryService, q types.OrderQuery, +) (trades []types.Trade, err error) { + // sometimes the api might return empty trades without an error when we query the order too soon, + // so in the initial attempts, we should check the len(trades) and retry the query + var initialAttempts = 3 + var op = func() (err2 error) { + trades, err2 = ex.QueryOrderTrades(ctx, q) + if err2 != nil { + return err2 + } + + initialAttempts-- + + if initialAttempts > 0 && len(trades) == 0 { + return ErrTradeNotExecutedYet + } + + for _, trade := range trades { + if trade.FeeProcessing { + return ErrTradeFeeIsProcessing + } + } + + return err2 + } + + err = GeneralBackoff(ctx, op) + return trades, err +} + +// QueryOrderTradesUntilSuccessfulLite query order's trades until success (include the trading fee is not processing) +func QueryOrderTradesUntilSuccessfulLite( + ctx context.Context, ex types.ExchangeOrderQueryService, q types.OrderQuery, +) (trades []types.Trade, err error) { + var op = func() (err2 error) { + trades, err2 = ex.QueryOrderTrades(ctx, q) + for _, trade := range trades { + if trade.FeeProcessing { + return fmt.Errorf("there are some trades which trading fee is not ready") + } + } + return err2 + } + + err = GeneralLiteBackoff(ctx, op) + return trades, err +} + +func QueryOrderUntilSuccessful( + ctx context.Context, query types.ExchangeOrderQueryService, opts types.OrderQuery, +) (order *types.Order, err error) { + var op = func() (err2 error) { + order, err2 = query.QueryOrder(ctx, opts) + return err2 + } + + err = GeneralBackoff(ctx, op) + return order, err +} + +func CancelAllOrdersUntilSuccessful(ctx context.Context, service advancedOrderCancelService) error { + var op = func() (err2 error) { + _, err2 = service.CancelAllOrders(ctx) + return err2 + } + + return GeneralBackoff(ctx, op) +} + +func CancelOrdersUntilSuccessful(ctx context.Context, ex types.Exchange, orders ...types.Order) error { + var op = func() (err2 error) { + err2 = ex.CancelOrders(ctx, orders...) + return err2 + } + + return GeneralBackoff(ctx, op) +} diff --git a/pkg/exchange/retry/ticker.go b/pkg/exchange/retry/ticker.go new file mode 100644 index 0000000..8f81ec7 --- /dev/null +++ b/pkg/exchange/retry/ticker.go @@ -0,0 +1,17 @@ +package retry + +import ( + "context" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func QueryTickerUntilSuccessful(ctx context.Context, ex types.Exchange, symbol string) (ticker *types.Ticker, err error) { + var op = func() (err2 error) { + ticker, err2 = ex.QueryTicker(ctx, symbol) + return err2 + } + + err = GeneralBackoff(ctx, op) + return ticker, err +} diff --git a/pkg/exchange/retry/trade.go b/pkg/exchange/retry/trade.go new file mode 100644 index 0000000..8366c05 --- /dev/null +++ b/pkg/exchange/retry/trade.go @@ -0,0 +1,42 @@ +package retry + +import ( + "context" + "fmt" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func QueryTradesUntilSuccessful( + ctx context.Context, ex types.ExchangeTradeHistoryService, symbol string, q *types.TradeQueryOptions, +) (trades []types.Trade, err error) { + var op = func() (err2 error) { + trades, err2 = ex.QueryTrades(ctx, symbol, q) + for _, trade := range trades { + if trade.FeeProcessing { + return fmt.Errorf("trade fee of #%d (order #%d) is still processing", trade.ID, trade.OrderID) + } + } + return err2 + } + + err = GeneralBackoff(ctx, op) + return trades, err +} + +func QueryTradesUntilSuccessfulLite( + ctx context.Context, ex types.ExchangeTradeHistoryService, symbol string, q *types.TradeQueryOptions, +) (trades []types.Trade, err error) { + var op = func() (err2 error) { + trades, err2 = ex.QueryTrades(ctx, symbol, q) + for _, trade := range trades { + if trade.FeeProcessing { + return fmt.Errorf("trade fee of #%d (order #%d) is still processing", trade.ID, trade.OrderID) + } + } + return err2 + } + + err = GeneralLiteBackoff(ctx, op) + return trades, err +} diff --git a/pkg/exchange/util.go b/pkg/exchange/util.go new file mode 100644 index 0000000..3d007d9 --- /dev/null +++ b/pkg/exchange/util.go @@ -0,0 +1,37 @@ +package exchange + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/max" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func GetSessionAttributes(exchange types.Exchange) (isMargin, isFutures, isIsolated bool, isolatedSymbol string) { + if marginExchange, ok := exchange.(types.MarginExchange); ok { + marginSettings := marginExchange.GetMarginSettings() + isMargin = marginSettings.IsMargin + if isMargin { + isIsolated = marginSettings.IsIsolatedMargin + if marginSettings.IsIsolatedMargin { + isolatedSymbol = marginSettings.IsolatedMarginSymbol + } + } + } + + if futuresExchange, ok := exchange.(types.FuturesExchange); ok { + futuresSettings := futuresExchange.GetFuturesSettings() + isFutures = futuresSettings.IsFutures + if isFutures { + isIsolated = futuresSettings.IsIsolatedFutures + if futuresSettings.IsIsolatedFutures { + isolatedSymbol = futuresSettings.IsolatedFuturesSymbol + } + } + } + + return isMargin, isFutures, isIsolated, isolatedSymbol +} + +func IsMaxExchange(exchange interface{}) bool { + _, res := exchange.(*max.Exchange) + return res +} diff --git a/pkg/fixedpoint/const.go b/pkg/fixedpoint/const.go new file mode 100644 index 0000000..86e63cd --- /dev/null +++ b/pkg/fixedpoint/const.go @@ -0,0 +1,7 @@ +package fixedpoint + +var ( + Two Value = NewFromInt(2) + Three Value = NewFromInt(3) + Four Value = NewFromInt(4) +) diff --git a/pkg/fixedpoint/convert.go b/pkg/fixedpoint/convert.go new file mode 100644 index 0000000..d000603 --- /dev/null +++ b/pkg/fixedpoint/convert.go @@ -0,0 +1,612 @@ +//go:build !dnum + +package fixedpoint + +import ( + "bytes" + "database/sql/driver" + "errors" + "fmt" + "math" + "strconv" + "strings" + "sync/atomic" +) + +const MaxPrecision = 12 +const DefaultPrecision = 8 + +const DefaultPow = 1e8 + +type Value int64 + +const Zero = Value(0) +const One = Value(1e8) +const NegOne = Value(-1e8) +const PosInf = Value(math.MaxInt64) +const NegInf = Value(math.MinInt64) + +type RoundingMode int + +const ( + Up RoundingMode = iota + Down + HalfUp +) + +// Trunc returns the integer portion (truncating any fractional part) +func (v Value) Trunc() Value { + return NewFromFloat(math.Floor(v.Float64())) +} + +func (v Value) Round(r int, mode RoundingMode) Value { + pow := math.Pow10(r) + f := v.Float64() * pow + switch mode { + case Up: + f = math.Ceil(f) / pow + case HalfUp: + f = math.Floor(f+0.5) / pow + case Down: + f = math.Floor(f) / pow + } + + s := strconv.FormatFloat(f, 'f', r, 64) + return MustNewFromString(s) +} + +func (v Value) Value() (driver.Value, error) { + return v.Float64(), nil +} + +func (v *Value) Scan(src interface{}) error { + switch d := src.(type) { + case int64: + *v = NewFromInt(d) + return nil + + case float64: + *v = NewFromFloat(d) + return nil + + case []byte: + vv, err := NewFromString(string(d)) + if err != nil { + return err + } + *v = vv + return nil + + default: + + } + + return fmt.Errorf("fixedpoint.Value scan error, type: %T is not supported, value; %+v", src, src) +} + +func (v Value) Float64() float64 { + if v == PosInf { + return math.Inf(1) + } else if v == NegInf { + return math.Inf(-1) + } + return float64(v) / DefaultPow +} + +func (v Value) Abs() Value { + if v < 0 { + return -v + } + return v +} + +func (v Value) String() string { + if v == PosInf { + return "inf" + } else if v == NegInf { + return "-inf" + } + return strconv.FormatFloat(float64(v)/DefaultPow, 'f', -1, 64) +} + +func (v Value) FormatString(prec int) string { + if v == PosInf { + return "inf" + } else if v == NegInf { + return "-inf" + } + + u := int64(v) + + // trunc precision + precDiff := DefaultPrecision - prec + if precDiff > 0 { + powDiff := int64(math.Round(math.Pow10(precDiff))) + u = int64(v) / powDiff * powDiff + } + + // check sign + sign := Value(u).Sign() + + basePow := int64(DefaultPow) + a := u / basePow + b := u % basePow + + if a < 0 { + a = -a + } + + if b < 0 { + b = -b + } + + str := strconv.FormatInt(a, 10) + if prec > 0 { + bStr := fmt.Sprintf(".%08d", b) + if prec <= DefaultPrecision { + bStr = bStr[0 : prec+1] + } else { + for i := prec - DefaultPrecision; i > 0; i-- { + bStr += "0" + } + } + + str += bStr + } + + if sign < 0 { + str = "-" + str + } + + return str +} + +func (v Value) Percentage() string { + if v == 0 { + return "0" + } + if v == PosInf { + return "inf%" + } else if v == NegInf { + return "-inf%" + } + return strconv.FormatFloat(float64(v)/DefaultPow*100., 'f', -1, 64) + "%" +} + +func (v Value) FormatPercentage(prec int) string { + if v == 0 { + return "0" + } + if v == PosInf { + return "inf%" + } else if v == NegInf { + return "-inf%" + } + pow := math.Pow10(prec) + result := strconv.FormatFloat( + math.Trunc(float64(v)/DefaultPow*pow*100.)/pow, 'f', prec, 64) + return result + "%" +} + +func (v Value) SignedPercentage() string { + if v > 0 { + return "+" + v.Percentage() + } + return v.Percentage() +} + +func (v Value) Int64() int64 { + return int64(v.Float64()) +} + +func (v Value) Int() int { + n := v.Int64() + if int64(int(n)) != n { + panic("unable to convert Value to int32") + } + return int(n) +} + +func (v Value) Neg() Value { + return -v +} + +// TODO inf +func (v Value) Sign() int { + if v > 0 { + return 1 + } else if v == 0 { + return 0 + } else { + return -1 + } +} + +func (v Value) IsZero() bool { + return v == 0 +} + +func Mul(x, y Value) Value { + return NewFromFloat(x.Float64() * y.Float64()) +} + +func (v Value) Mul(v2 Value) Value { + return NewFromFloat(v.Float64() * v2.Float64()) +} + +func Div(x, y Value) Value { + return NewFromFloat(x.Float64() / y.Float64()) +} + +func (v Value) Div(v2 Value) Value { + return NewFromFloat(v.Float64() / v2.Float64()) +} + +func (v Value) Floor() Value { + return NewFromFloat(math.Floor(v.Float64())) +} + +func (v Value) Ceil() Value { + return NewFromFloat(math.Ceil(v.Float64())) +} + +func (v Value) Sub(v2 Value) Value { + return Value(int64(v) - int64(v2)) +} + +func (v Value) Add(v2 Value) Value { + return Value(int64(v) + int64(v2)) +} + +func (v *Value) AtomicAdd(v2 Value) { + atomic.AddInt64((*int64)(v), int64(v2)) +} + +func (v *Value) AtomicLoad() Value { + i := atomic.LoadInt64((*int64)(v)) + return Value(i) +} + +func (v *Value) UnmarshalYAML(unmarshal func(a interface{}) error) (err error) { + var s string + if err = unmarshal(&s); err == nil { + nv, err2 := NewFromString(s) + if err2 == nil { + *v = nv + return + } + } + + return err +} + +func (v Value) MarshalYAML() (interface{}, error) { + return v.FormatString(DefaultPrecision), nil +} + +func (v Value) MarshalJSON() ([]byte, error) { + if v.IsInf() { + return []byte("\"" + v.String() + "\""), nil + } + return []byte(v.FormatString(DefaultPrecision)), nil +} + +func (v *Value) UnmarshalJSON(data []byte) error { + if bytes.Equal(data, []byte{'n', 'u', 'l', 'l'}) { + *v = Zero + return nil + } + if len(data) == 0 || bytes.Equal(data, []byte{'"', '"'}) { + *v = Zero + return nil + } + var err error + if data[0] == '"' { + data = data[1 : len(data)-1] + } + if *v, err = NewFromString(string(data)); err != nil { + return err + } + return nil +} + +var ErrPrecisionLoss = errors.New("precision loss") + +func Parse(input string) (num int64, numDecimalPoints int, err error) { + length := len(input) + isPercentage := input[length-1] == '%' + if isPercentage { + length -= 1 + input = input[0:length] + } + + var neg int64 = 1 + var digit int64 + for i := 0; i < length; i++ { + c := input[i] + if c == '-' { + neg = -1 + } else if c >= '0' && c <= '9' { + digit, err = strconv.ParseInt(string(c), 10, 64) + if err != nil { + return + } + + num = num*10 + digit + } else if c == '.' { + i++ + if i > len(input)-1 { + err = fmt.Errorf("expect fraction numbers after dot") + return + } + + for j := i; j < len(input); j++ { + fc := input[j] + if fc >= '0' && fc <= '9' { + digit, err = strconv.ParseInt(string(fc), 10, 64) + if err != nil { + return + } + + numDecimalPoints++ + num = num*10 + digit + + if numDecimalPoints >= MaxPrecision { + return num, numDecimalPoints, ErrPrecisionLoss + } + } else { + err = fmt.Errorf("expect digit, got %c", fc) + return + } + } + break + } else { + err = fmt.Errorf("unexpected char %c", c) + return + } + } + + num = num * neg + if isPercentage { + numDecimalPoints += 2 + } + + return num, numDecimalPoints, nil +} + +func NewFromString(input string) (Value, error) { + length := len(input) + + if length == 0 { + return 0, nil + } + + isPercentage := input[length-1] == '%' + if isPercentage { + input = input[0 : length-1] + } + dotIndex := -1 + hasDecimal := false + decimalCount := 0 + // if is decimal, we don't need this + hasScientificNotion := false + hasIChar := false + scIndex := -1 + for i, c := range input { + if hasDecimal { + if c <= '9' && c >= '0' { + decimalCount++ + } else { + break + } + + } else if c == '.' { + dotIndex = i + hasDecimal = true + } + if c == 'e' || c == 'E' { + hasScientificNotion = true + scIndex = i + break + } + if c == 'i' || c == 'I' { + hasIChar = true + break + } + } + if hasDecimal { + after := input[dotIndex+1:] + if decimalCount >= 8 { + after = after[0:8] + "." + after[8:] + } else { + after = after[0:decimalCount] + strings.Repeat("0", 8-decimalCount) + after[decimalCount:] + } + input = input[0:dotIndex] + after + v, err := strconv.ParseFloat(input, 64) + if err != nil { + return 0, err + } + + if isPercentage { + v = v * 0.01 + } + + return Value(int64(math.Trunc(v))), nil + + } else if hasScientificNotion { + exp, err := strconv.ParseInt(input[scIndex+1:], 10, 32) + if err != nil { + return 0, err + } + v, err := strconv.ParseFloat(input[0:scIndex+1]+strconv.FormatInt(exp+8, 10), 64) + if err != nil { + return 0, err + } + return Value(int64(math.Trunc(v))), nil + } else if hasIChar { + if floatV, err := strconv.ParseFloat(input, 64); nil != err { + return 0, err + } else if math.IsInf(floatV, 1) { + return PosInf, nil + } else if math.IsInf(floatV, -1) { + return NegInf, nil + } else { + return 0, fmt.Errorf("fixedpoint.Value parse error, invalid input string %s", input) + } + } else { + v, err := strconv.ParseInt(input, 10, 64) + if err != nil { + return 0, err + } + if isPercentage { + v = v * DefaultPow / 100 + } else { + v = v * DefaultPow + } + return Value(v), nil + } +} + +func MustNewFromString(input string) Value { + v, err := NewFromString(input) + if err != nil { + panic(fmt.Errorf("can not parse %s into fixedpoint, error: %s", input, err.Error())) + } + return v +} + +func NewFromBytes(input []byte) (Value, error) { + return NewFromString(string(input)) +} + +func MustNewFromBytes(input []byte) (v Value) { + var err error + if v, err = NewFromString(string(input)); err != nil { + return Zero + } + return v +} + +func Must(v Value, err error) Value { + if err != nil { + panic(err) + } + return v +} + +func NewFromFloat(val float64) Value { + if math.IsInf(val, 1) { + return PosInf + } else if math.IsInf(val, -1) { + return NegInf + } + return Value(int64(math.Trunc(val * DefaultPow))) +} + +func NewFromInt(val int64) Value { + return Value(val * DefaultPow) +} + +func (a Value) IsInf() bool { + return a == PosInf || a == NegInf +} + +func (a Value) MulExp(exp int) Value { + return Value(int64(float64(a) * math.Pow(10, float64(exp)))) +} + +func (a Value) NumIntDigits() int { + digits := 0 + target := int64(a) + for pow := int64(DefaultPow); pow <= target; pow *= 10 { + digits++ + } + return digits +} + +// TODO: speedup +func (a Value) NumFractionalDigits() int { + if a == 0 { + return 0 + } + numPow := 0 + for pow := int64(DefaultPow); pow%10 != 1; pow /= 10 { + numPow++ + } + numZeros := 0 + for v := int64(a); v%10 == 0; v /= 10 { + numZeros++ + } + return numPow - numZeros +} + +func Compare(x, y Value) int { + if x > y { + return 1 + } else if x == y { + return 0 + } else { + return -1 + } +} + +func (x Value) Compare(y Value) int { + if x > y { + return 1 + } else if x == y { + return 0 + } else { + return -1 + } +} + +func Min(a, b Value) Value { + if a.Compare(b) < 0 { + return a + } + + return b +} + +func Max(a, b Value) Value { + if a.Compare(b) > 0 { + return a + } + + return b +} + +func Equal(x, y Value) bool { + return x == y +} + +func (x Value) Eq(y Value) bool { + return x == y +} + +func Abs(a Value) Value { + if a < 0 { + return -a + } + return a +} + +func Clamp(x, min, max Value) Value { + if x < min { + return min + } + if x > max { + return max + } + return x +} + +func (x Value) Clamp(min, max Value) Value { + if x < min { + return min + } + if x > max { + return max + } + return x +} diff --git a/pkg/fixedpoint/convert_test.go b/pkg/fixedpoint/convert_test.go new file mode 100644 index 0000000..dc008ea --- /dev/null +++ b/pkg/fixedpoint/convert_test.go @@ -0,0 +1,124 @@ +package fixedpoint + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_FormatString(t *testing.T) { + cases := []struct { + input string + prec int + expected string + }{ + {input: "0.57", prec: 5, expected: "0.57000"}, + {input: "-0.57", prec: 5, expected: "-0.57000"}, + {input: "0.57123456", prec: 8, expected: "0.57123456"}, + {input: "-0.57123456", prec: 8, expected: "-0.57123456"}, + {input: "0.57123456", prec: 5, expected: "0.57123"}, + {input: "-0.57123456", prec: 5, expected: "-0.57123"}, + {input: "0.57123456", prec: 0, expected: "0"}, + {input: "-0.57123456", prec: 0, expected: "0"}, + {input: "0.57123456", prec: -1, expected: "0"}, + {input: "-0.57123456", prec: -1, expected: "0"}, + {input: "0.57123456", prec: -5, expected: "0"}, + {input: "-0.57123456", prec: -5, expected: "0"}, + {input: "0.57123456", prec: -9, expected: "0"}, + {input: "-0.57123456", prec: -9, expected: "0"}, + + {input: "1.23456789", prec: 9, expected: "1.234567890"}, + {input: "-1.23456789", prec: 9, expected: "-1.234567890"}, + {input: "1.02345678", prec: 9, expected: "1.023456780"}, + {input: "-1.02345678", prec: 9, expected: "-1.023456780"}, + {input: "1.02345678", prec: 2, expected: "1.02"}, + {input: "-1.02345678", prec: 2, expected: "-1.02"}, + {input: "1.02345678", prec: 0, expected: "1"}, + {input: "-1.02345678", prec: 0, expected: "-1"}, + {input: "1.02345678", prec: -1, expected: "0"}, + {input: "-1.02345678", prec: -1, expected: "0"}, + {input: "1.02345678", prec: -10, expected: "0"}, + {input: "-1.02345678", prec: -10, expected: "0"}, + + {input: "0.0001234", prec: 9, expected: "0.000123400"}, + {input: "-0.0001234", prec: 9, expected: "-0.000123400"}, + {input: "0.0001234", prec: 7, expected: "0.0001234"}, + {input: "-0.0001234", prec: 7, expected: "-0.0001234"}, + {input: "0.0001234", prec: 5, expected: "0.00012"}, + {input: "-0.0001234", prec: 5, expected: "-0.00012"}, + {input: "0.0001234", prec: 3, expected: "0.000"}, + {input: "-0.0001234", prec: 3, expected: "0.000"}, + {input: "0.0001234", prec: 2, expected: "0.00"}, + {input: "-0.0001234", prec: 2, expected: "0.00"}, + {input: "0.0001234", prec: 0, expected: "0"}, + {input: "-0.0001234", prec: 0, expected: "0"}, + {input: "0.00001234", prec: -1, expected: "0"}, + {input: "-0.00001234", prec: -1, expected: "0"}, + {input: "0.00001234", prec: -5, expected: "0"}, + {input: "-0.00001234", prec: -5, expected: "0"}, + {input: "0.00001234", prec: -9, expected: "0"}, + {input: "-0.00001234", prec: -9, expected: "0"}, + + {input: "12.3456789", prec: 10, expected: "12.3456789000"}, + {input: "-12.3456789", prec: 10, expected: "-12.3456789000"}, + {input: "12.3456789", prec: 9, expected: "12.345678900"}, + {input: "-12.3456789", prec: 9, expected: "-12.345678900"}, + {input: "12.3456789", prec: 7, expected: "12.3456789"}, + {input: "-12.3456789", prec: 7, expected: "-12.3456789"}, + {input: "12.3456789", prec: 5, expected: "12.34567"}, + {input: "-12.3456789", prec: 5, expected: "-12.34567"}, + {input: "12.3456789", prec: 1, expected: "12.3"}, + {input: "-12.3456789", prec: 1, expected: "-12.3"}, + {input: "12.3456789", prec: 0, expected: "12"}, + {input: "-12.3456789", prec: 0, expected: "-12"}, + {input: "12.3456789", prec: -1, expected: "10"}, + {input: "-12.3456789", prec: -1, expected: "-10"}, + {input: "12.3456789", prec: -2, expected: "0"}, + {input: "-12.3456789", prec: -2, expected: "0"}, + {input: "12.3456789", prec: -3, expected: "0"}, + {input: "-12.3456789", prec: -3, expected: "0"}, + + {input: "12345678.9", prec: 10, expected: "12345678.9000000000"}, + {input: "-12345678.9", prec: 10, expected: "-12345678.9000000000"}, + {input: "12345678.9", prec: 3, expected: "12345678.900"}, + {input: "-12345678.9", prec: 3, expected: "-12345678.900"}, + {input: "12345678.9", prec: 1, expected: "12345678.9"}, + {input: "-12345678.9", prec: 1, expected: "-12345678.9"}, + {input: "12345678.9", prec: 0, expected: "12345678"}, + {input: "-12345678.9", prec: 0, expected: "-12345678"}, + {input: "12345678.9", prec: -2, expected: "12345600"}, + {input: "-12345678.9", prec: -2, expected: "-12345600"}, + {input: "12345678.9", prec: -5, expected: "12300000"}, + {input: "-12345678.9", prec: -5, expected: "-12300000"}, + {input: "12345678.9", prec: -7, expected: "10000000"}, + {input: "-12345678.9", prec: -7, expected: "-10000000"}, + {input: "12345678.9", prec: -8, expected: "0"}, + {input: "-12345678.9", prec: -8, expected: "0"}, + {input: "12345678.9", prec: -10, expected: "0"}, + {input: "-12345678.9", prec: -10, expected: "0"}, + + {input: "123000", prec: 7, expected: "123000.0000000"}, + {input: "-123000", prec: 7, expected: "-123000.0000000"}, + {input: "123000", prec: 2, expected: "123000.00"}, + {input: "-123000", prec: 2, expected: "-123000.00"}, + {input: "123000", prec: 0, expected: "123000"}, + {input: "-123000", prec: 0, expected: "-123000"}, + {input: "123000", prec: -1, expected: "123000"}, + {input: "-123000", prec: -1, expected: "-123000"}, + {input: "123000", prec: -5, expected: "100000"}, + {input: "-123000", prec: -5, expected: "-100000"}, + {input: "123000", prec: -6, expected: "0"}, + {input: "-123000", prec: -6, expected: "0"}, + {input: "123000", prec: -8, expected: "0"}, + {input: "-123000", prec: -8, expected: "0"}, + } + + for _, c := range cases { + t.Run(fmt.Sprintf("%s with prec = %d, expected %s", c.input, c.prec, c.expected), func(t *testing.T) { + v := MustNewFromString(c.input) + s := v.FormatString(c.prec) + assert.Equal(t, c.expected, s) + }) + } +} diff --git a/pkg/fixedpoint/count.go b/pkg/fixedpoint/count.go new file mode 100644 index 0000000..84a3d1c --- /dev/null +++ b/pkg/fixedpoint/count.go @@ -0,0 +1,13 @@ +package fixedpoint + +type Counter func(a Value) bool + +func Count(values []Value, counter Counter) int { + var c = 0 + for _, value := range values { + if counter(value) { + c++ + } + } + return c +} diff --git a/pkg/fixedpoint/dec.go b/pkg/fixedpoint/dec.go new file mode 100644 index 0000000..17c66ca --- /dev/null +++ b/pkg/fixedpoint/dec.go @@ -0,0 +1,1367 @@ +//go:build dnum + +package fixedpoint + +import ( + "bytes" + "database/sql/driver" + "errors" + "fmt" + "math" + "math/bits" + "strconv" + "strings" +) + +type Value struct { + coef uint64 + sign int8 + exp int +} + +const ( + signPosInf = +2 + signPos = +1 + signZero = 0 + signNeg = -1 + signNegInf = -2 + coefMin = 1000_0000_0000_0000 + coefMax = 9999_9999_9999_9999 + digitsMax = 16 + shiftMax = digitsMax - 1 + // to switch between scientific notion and normal presentation format + maxLeadingZeros = 19 +) + +// common values +var ( + Zero = Value{} + One = Value{1000_0000_0000_0000, signPos, 1} + NegOne = Value{1000_0000_0000_0000, signNeg, 1} + PosInf = Value{1, signPosInf, 0} + NegInf = Value{1, signNegInf, 0} +) + +var pow10f = [...]float64{ + 1, + 10, + 100, + 1000, + 10000, + 100000, + 1000000, + 10000000, + 100000000, + 1000000000, + 10000000000, + 100000000000, + 1000000000000, + 10000000000000, + 100000000000000, + 1000000000000000, + 10000000000000000, + 100000000000000000, + 1000000000000000000, + 10000000000000000000, + 100000000000000000000} + +var pow10 = [...]uint64{ + 1, + 10, + 100, + 1000, + 10000, + 100000, + 1000000, + 10000000, + 100000000, + 1000000000, + 10000000000, + 100000000000, + 1000000000000, + 10000000000000, + 100000000000000, + 1000000000000000, + 10000000000000000, + 100000000000000000, + 1000000000000000000} + +var halfpow10 = [...]uint64{ + 0, + 5, + 50, + 500, + 5000, + 50000, + 500000, + 5000000, + 50000000, + 500000000, + 5000000000, + 50000000000, + 500000000000, + 5000000000000, + 50000000000000, + 500000000000000, + 5000000000000000, + 50000000000000000, + 500000000000000000, + 5000000000000000000} + +func min(a int, b int) int { + if a < b { + return a + } + return b +} + +func max(a int, b int) int { + if a > b { + return a + } + return b +} + +func (v Value) Value() (driver.Value, error) { + return v.Float64(), nil +} + +// NewFromInt returns a Value for an int +func NewFromInt(n int64) Value { + if n == 0 { + return Zero + } + // n0 := n + sign := int8(signPos) + if n < 0 { + n = -n + sign = signNeg + } + return newNoSignCheck(sign, uint64(n), digitsMax) +} + +const log2of10 = 3.32192809488736234 + +// NewFromFloat converts a float64 to a Value +func NewFromFloat(f float64) Value { + switch { + case math.IsInf(f, +1): + return PosInf + case math.IsInf(f, -1): + return NegInf + case math.IsNaN(f): + panic("value.NewFromFloat can't convert NaN") + } + + if f == 0 { + return Zero + } + + sign := int8(signPos) + if f < 0 { + f = -f + sign = signNeg + } + n := uint64(f) + if float64(n) == f { + return newNoSignCheck(sign, n, digitsMax) + } + _, e := math.Frexp(f) + e = int(float32(e) / log2of10) + c := uint64(f/math.Pow10(e-16) + 0.5) + return newNoSignCheck(sign, c, e) +} + +// Raw constructs a Value without normalizing - arguments must be valid. +// Used by SuValue Unpack +func Raw(sign int8, coef uint64, exp int) Value { + return Value{coef, sign, int(exp)} +} + +func newNoSignCheck(sign int8, coef uint64, exp int) Value { + atmax := false + for coef > coefMax { + coef = (coef + 5) / 10 + exp++ + atmax = true + } + + if !atmax { + p := maxShift(coef) + coef *= pow10[p] + exp -= p + } + return Value{coef, sign, exp} +} + +// New constructs a Value, maximizing coef and handling exp out of range +// Used to normalize results of operations +func New(sign int8, coef uint64, exp int) Value { + if sign == 0 || coef == 0 { + return Zero + } else if sign == signPosInf { + return PosInf + } else if sign == signNegInf { + return NegInf + } else { + atmax := false + for coef > coefMax { + coef = (coef + 5) / 10 + exp++ + atmax = true + } + + if !atmax { + p := maxShift(coef) + coef *= pow10[p] + exp -= p + } + return Value{coef, sign, exp} + } +} + +func maxShift(x uint64) int { + i := ilog10(x) + if i > shiftMax { + return 0 + } + return shiftMax - i +} + +func ilog10(x uint64) int { + // based on Hacker's Delight + if x == 0 { + return 0 + } + y := (19 * (63 - bits.LeadingZeros64(x))) >> 6 + if y < 18 && x >= pow10[y+1] { + y++ + } + return y +} + +func Inf(sign int8) Value { + switch { + case sign < 0: + return NegInf + case sign > 0: + return PosInf + default: + return Zero + } +} + +func (dn Value) FormatString(prec int) string { + if dn.sign == 0 { + if prec <= 0 { + return "0" + } else { + return "0." + strings.Repeat("0", prec) + } + } + sign := "" + if dn.sign < 0 { + sign = "-" + } + if dn.IsInf() { + return sign + "inf" + } + digits := getDigits(dn.coef) + nd := len(digits) + e := int(dn.exp) - nd + if -maxLeadingZeros <= dn.exp && dn.exp <= 0 { + if prec < 0 { + return "0" + } + // decimal to the left + if prec+e+nd > 0 { + return sign + "0." + strings.Repeat("0", -e-nd) + digits[:min(prec+e+nd, nd)] + strings.Repeat("0", max(0, prec-nd+e+nd)) + } else if -e-nd > 0 && prec != 0 { + return "0." + strings.Repeat("0", min(prec, -e-nd)) + } else { + return "0" + } + } else if -nd < e && e <= -1 { + // decimal within + dec := nd + e + if prec > 0 { + decimals := digits[dec:min(dec+prec, nd)] + return sign + digits[:dec] + "." + decimals + strings.Repeat("0", max(0, prec-len(decimals))) + } else if prec == 0 { + return sign + digits[:dec] + } + + sigFigures := digits[0:max(dec+prec, 0)] + if len(sigFigures) == 0 { + return "0" + } + + return sign + sigFigures + strings.Repeat("0", max(-prec, 0)) + + } else if 0 < dn.exp && dn.exp <= digitsMax { + // decimal to the right + if prec > 0 { + return sign + digits + strings.Repeat("0", e) + "." + strings.Repeat("0", prec) + } else if prec+e >= 0 { + return sign + digits + strings.Repeat("0", e) + } else { + if len(digits) <= -prec-e { + return "0" + } + + return sign + digits[0:len(digits)+prec+e] + strings.Repeat("0", -prec) + } + } else { + // scientific notation + after := "" + if nd > 1 { + after = "." + digits[1:min(1+prec, nd)] + strings.Repeat("0", max(0, min(1+prec, nd)-1-prec)) + } + return sign + digits[:1] + after + "e" + strconv.Itoa(int(dn.exp-1)) + } +} + +// String returns a string representation of the Value +func (dn Value) String() string { + if dn.sign == 0 { + return "0" + } + sign := "" + if dn.sign < 0 { + sign = "-" + } + if dn.IsInf() { + return sign + "inf" + } + digits := getDigits(dn.coef) + nd := len(digits) + e := int(dn.exp) - nd + if -maxLeadingZeros <= dn.exp && dn.exp <= 0 { + // decimal to the left + return sign + "0." + strings.Repeat("0", -e-nd) + digits + } else if -nd < e && e <= -1 { + // decimal within + dec := nd + e + return sign + digits[:dec] + "." + digits[dec:] + } else if 0 < dn.exp && dn.exp <= digitsMax { + // decimal to the right + return sign + digits + strings.Repeat("0", e) + } else { + // scientific notation + after := "" + if nd > 1 { + after = "." + digits[1:] + } + return sign + digits[:1] + after + "e" + strconv.Itoa(int(dn.exp-1)) + } +} + +func (dn Value) Percentage() string { + if dn.sign == 0 { + return "0%" + } + sign := "" + if dn.sign < 0 { + sign = "-" + } + if dn.IsInf() { + return sign + "inf%" + } + digits := getDigits(dn.coef) + nd := len(digits) + e := int(dn.exp) - nd + 2 + + if -maxLeadingZeros <= dn.exp && dn.exp <= -2 { + // decimal to the left + return sign + "0." + strings.Repeat("0", -e-nd) + digits + "%" + } else if -nd < e && e <= -1 { + // decimal within + dec := nd + e + return sign + digits[:dec] + "." + digits[dec:] + "%" + } else if -2 < dn.exp && dn.exp <= digitsMax { + // decimal to the right + return sign + digits + strings.Repeat("0", e) + "%" + } else { + // scientific notation + after := "" + if nd > 1 { + after = "." + digits[1:] + } + return sign + digits[:1] + after + "e" + strconv.Itoa(int(dn.exp-1)) + "%" + } +} + +func (dn Value) FormatPercentage(prec int) string { + if dn.sign == 0 { + if prec <= 0 { + return "0" + } else { + return "0." + strings.Repeat("0", prec) + } + } + sign := "" + if dn.sign < 0 { + sign = "-" + } + if dn.IsInf() { + return sign + "inf" + } + digits := getDigits(dn.coef) + nd := len(digits) + exp := dn.exp + 2 + e := int(exp) - nd + + if -maxLeadingZeros <= exp && exp <= 0 { + // decimal to the left + if prec+e+nd > 0 { + return sign + "0." + strings.Repeat("0", -e-nd) + digits[:min(prec+e+nd, nd)] + strings.Repeat("0", max(0, prec-nd+e+nd)) + "%" + } else if -e-nd > 0 { + return "0." + strings.Repeat("0", -e-nd) + "%" + } else { + return "0" + } + } else if -nd < e && e <= -1 { + // decimal within + dec := nd + e + decimals := digits[dec:min(dec+prec, nd)] + return sign + digits[:dec] + "." + decimals + strings.Repeat("0", max(0, prec-len(decimals))) + "%" + } else if 0 < exp && exp <= digitsMax { + // decimal to the right + if prec > 0 { + return sign + digits + strings.Repeat("0", e) + "." + strings.Repeat("0", prec) + "%" + } else { + return sign + digits + strings.Repeat("0", e) + "%" + } + } else { + // scientific notation + after := "" + if nd > 1 { + after = "." + digits[1:min(1+prec, nd)] + strings.Repeat("0", max(0, min(1+prec, nd)-1-prec)) + } + return sign + digits[:1] + after + "e" + strconv.Itoa(int(exp-1)) + "%" + } +} + +func (dn Value) SignedPercentage() string { + if dn.Sign() >= 0 { + return "+" + dn.Percentage() + } + return dn.Percentage() +} + +// get digit length +func (a Value) NumDigits() int { + i := shiftMax + coef := a.coef + nd := 0 + for coef != 0 && coef < pow10[i] { + i-- + } + for coef != 0 { + coef %= pow10[i] + i-- + nd++ + } + return nd +} + +// alias of Exp +func (a Value) NumIntDigits() int { + return a.exp +} + +// get fractional digits +func (a Value) NumFractionalDigits() int { + nd := a.NumDigits() + return nd - a.exp +} + +func getDigits(coef uint64) string { + var digits [digitsMax]byte + i := shiftMax + nd := 0 + for coef != 0 { + digits[nd] = byte('0' + (coef / pow10[i])) + coef %= pow10[i] + nd++ + i-- + } + return string(digits[:nd]) +} + +func (v *Value) Scan(src interface{}) error { + var err error + switch d := src.(type) { + case int64: + *v = NewFromInt(d) + return nil + case float64: + *v = NewFromFloat(d) + return nil + case []byte: + *v, err = NewFromString(string(d)) + if err != nil { + return err + } + return nil + default: + } + return fmt.Errorf("fixedpoint.Value scan error, type %T is not supported, value: %+v", src, src) +} + +// NewFromString parses a numeric string and returns a Value representation. +func NewFromString(s string) (Value, error) { + length := len(s) + if length == 0 { + return Zero, nil + } + isPercentage := s[length-1] == '%' + if isPercentage { + s = s[:length-1] + } + r := &reader{s, 0} + sign := r.getSign() + if r.matchStrIgnoreCase("inf") { + return Inf(sign), nil + } + coef, exp := r.getCoef() + exp += r.getExp() + if r.len() != 0 { // didn't consume entire string + return Zero, errors.New("invalid number") + } else if coef == 0 || exp < math.MinInt8 { + return Zero, nil + } else if exp > math.MaxInt8 { + return Inf(sign), nil + } + if isPercentage { + exp -= 2 + } + atmax := false + for coef > coefMax { + coef = (coef + 5) / 10 + exp++ + atmax = true + } + + if !atmax { + p := maxShift(coef) + coef *= pow10[p] + exp -= p + } + // check(coefMin <= coef && coef <= coefMax) + return Value{coef, sign, exp}, nil +} + +func MustNewFromString(input string) Value { + v, err := NewFromString(input) + if err != nil { + panic(fmt.Errorf("cannot parse %s into fixedpoint, error: %s", input, err.Error())) + } + return v +} + +func NewFromBytes(s []byte) (Value, error) { + length := len(s) + if length == 0 { + return Zero, nil + } + isPercentage := s[length-1] == '%' + if isPercentage { + s = s[:length-1] + } + r := &readerBytes{s, 0} + sign := r.getSign() + if r.matchStrIgnoreCase("inf") { + return Inf(sign), nil + } + coef, exp := r.getCoef() + exp += r.getExp() + if r.len() != 0 { // didn't consume entire string + return Zero, errors.New("invalid number") + } else if coef == 0 || exp < math.MinInt8 { + return Zero, nil + } else if exp > math.MaxInt8 { + return Inf(sign), nil + } + if isPercentage { + exp -= 2 + } + atmax := false + for coef > coefMax { + coef = (coef + 5) / 10 + exp++ + atmax = true + } + + if !atmax { + p := maxShift(coef) + coef *= pow10[p] + exp -= p + } + // check(coefMin <= coef && coef <= coefMax) + return Value{coef, sign, exp}, nil +} + +func MustNewFromBytes(input []byte) Value { + v, err := NewFromBytes(input) + if err != nil { + panic(fmt.Errorf("cannot parse %s into fixedpoint, error: %s", input, err.Error())) + } + return v +} + +// TODO: refactor by interface + +type readerBytes struct { + s []byte + i int +} + +func (r *readerBytes) cur() byte { + if r.i >= len(r.s) { + return 0 + } + return byte(r.s[r.i]) +} + +func (r *readerBytes) prev() byte { + if r.i == 0 { + return 0 + } + return byte(r.s[r.i-1]) +} + +func (r *readerBytes) len() int { + return len(r.s) - r.i +} + +func (r *readerBytes) match(c byte) bool { + if r.cur() == c { + r.i++ + return true + } + return false +} + +func (r *readerBytes) matchDigit() bool { + c := r.cur() + if '0' <= c && c <= '9' { + r.i++ + return true + } + return false +} + +func (r *readerBytes) matchStrIgnoreCase(pre string) bool { + pre = strings.ToLower(pre) + boundary := r.i + len(pre) + if boundary > len(r.s) { + return false + } + for i, c := range bytes.ToLower(r.s[r.i:boundary]) { + if pre[i] != c { + return false + } + } + r.i = boundary + return true +} + +func (r *readerBytes) getSign() int8 { + if r.match('-') { + return int8(signNeg) + } + r.match('+') + return int8(signPos) +} + +func (r *readerBytes) getCoef() (uint64, int) { + digits := false + beforeDecimal := true + for r.match('0') { + digits = true + } + if r.cur() == '.' && r.len() > 1 { + digits = false + } + n := uint64(0) + exp := 0 + p := shiftMax + for { + c := r.cur() + if r.matchDigit() { + digits = true + // ignore extra decimal places + if c != '0' && p >= 0 { + n += uint64(c-'0') * pow10[p] + } + p-- + } else if beforeDecimal { + // decimal point or end + exp = shiftMax - p + if !r.match('.') { + break + } + beforeDecimal = false + if !digits { + for r.match('0') { + digits = true + exp-- + } + } + } else { + break + } + } + if !digits { + panic("numbers require at least one digit") + } + return n, exp +} + +func (r *readerBytes) getExp() int { + e := 0 + if r.match('e') || r.match('E') { + esign := r.getSign() + for r.matchDigit() { + e = e*10 + int(r.prev()-'0') + } + e *= int(esign) + } + return e +} + +type reader struct { + s string + i int +} + +func (r *reader) cur() byte { + if r.i >= len(r.s) { + return 0 + } + return byte(r.s[r.i]) +} + +func (r *reader) prev() byte { + if r.i == 0 { + return 0 + } + return byte(r.s[r.i-1]) +} + +func (r *reader) len() int { + return len(r.s) - r.i +} + +func (r *reader) match(c byte) bool { + if r.cur() == c { + r.i++ + return true + } + return false +} + +func (r *reader) matchDigit() bool { + c := r.cur() + if '0' <= c && c <= '9' { + r.i++ + return true + } + return false +} + +func (r *reader) matchStrIgnoreCase(pre string) bool { + boundary := r.i + len(pre) + if boundary > len(r.s) { + return false + } + data := strings.ToLower(r.s[r.i:boundary]) + pre = strings.ToLower(pre) + if data == pre { + r.i = boundary + return true + } + return false +} + +func (r *reader) getSign() int8 { + if r.match('-') { + return int8(signNeg) + } + r.match('+') + return int8(signPos) +} + +func (r *reader) getCoef() (uint64, int) { + digits := false + beforeDecimal := true + for r.match('0') { + digits = true + } + if r.cur() == '.' && r.len() > 1 { + digits = false + } + n := uint64(0) + exp := 0 + p := shiftMax + for { + c := r.cur() + if r.matchDigit() { + digits = true + // ignore extra decimal places + if c != '0' && p >= 0 { + n += uint64(c-'0') * pow10[p] + } + p-- + } else if beforeDecimal { + // decimal point or end + exp = shiftMax - p + if !r.match('.') { + break + } + beforeDecimal = false + if !digits { + for r.match('0') { + digits = true + exp-- + } + } + } else { + break + } + } + if !digits { + panic("numbers require at least one digit") + } + return n, exp +} + +func (r *reader) getExp() int { + e := 0 + if r.match('e') || r.match('E') { + esign := r.getSign() + for r.matchDigit() { + e = e*10 + int(r.prev()-'0') + } + e *= int(esign) + } + return e +} + +// end of FromStr --------------------------------------------------- + +// IsInf returns true if a Value is positive or negative infinite +func (dn Value) IsInf() bool { + return dn.sign == signPosInf || dn.sign == signNegInf +} + +// IsZero returns true if a Value is zero +func (dn Value) IsZero() bool { + return dn.sign == signZero +} + +// Float64 converts a Value to float64 +func (dn Value) Float64() float64 { + if dn.IsInf() { + return math.Inf(int(dn.sign)) + } + g := float64(dn.coef) + if dn.sign == signNeg { + g = -g + } + i := int(dn.exp) - digitsMax + return g * math.Pow(10, float64(i)) +} + +// Int64 converts a Value to an int64, returning whether it was convertible +func (dn Value) Int64() int64 { + if dn.sign == 0 { + return 0 + } + if dn.sign != signNegInf && dn.sign != signPosInf { + if 0 < dn.exp && dn.exp < digitsMax { + return int64(dn.sign) * int64(dn.coef/pow10[digitsMax-dn.exp]) + } else if dn.exp <= 0 && dn.coef != 0 { + result := math.Log10(float64(dn.coef)) - float64(digitsMax) + float64(dn.exp) + return int64(dn.sign) * int64(math.Pow(10, result)) + } + if dn.exp == digitsMax { + return int64(dn.sign) * int64(dn.coef) + } + if dn.exp == digitsMax+1 { + return int64(dn.sign) * (int64(dn.coef) * 10) + } + if dn.exp == digitsMax+2 { + return int64(dn.sign) * (int64(dn.coef) * 100) + } + if dn.exp == digitsMax+3 && dn.coef < math.MaxInt64/1000 { + return int64(dn.sign) * (int64(dn.coef) * 1000) + } + } + panic("unable to convert Value to int64") +} + +func (dn Value) Int() int { + // if int is int64, this is a nop + n := dn.Int64() + if int64(int(n)) != n { + panic("unable to convert Value to int32") + } + return int(n) +} + +// Sign returns -1 for negative, 0 for zero, and +1 for positive +func (dn Value) Sign() int { + return int(dn.sign) +} + +// Coef returns the coefficient +func (dn Value) Coef() uint64 { + return dn.coef +} + +// Exp returns the exponent +func (dn Value) Exp() int { + return int(dn.exp) +} + +// Frac returns the fractional portion, i.e. x - x.Int() +func (dn Value) Frac() Value { + if dn.sign == 0 || dn.sign == signNegInf || dn.sign == signPosInf || + dn.exp >= digitsMax { + return Zero + } + if dn.exp <= 0 { + return dn + } + frac := dn.coef % pow10[digitsMax-dn.exp] + if frac == dn.coef { + return dn + } + return New(dn.sign, frac, int(dn.exp)) +} + +type RoundingMode int + +const ( + Up RoundingMode = iota + Down + HalfUp +) + +// Trunc returns the integer portion (truncating any fractional part) +func (dn Value) Trunc() Value { + return dn.integer(Down) +} + +func (dn Value) integer(mode RoundingMode) Value { + if dn.sign == 0 || dn.sign == signNegInf || dn.sign == signPosInf || + dn.exp >= digitsMax { + return dn + } + if dn.exp <= 0 { + if mode == Up || + (mode == HalfUp && dn.exp == 0 && dn.coef >= One.coef*5) { + return New(dn.sign, One.coef, int(dn.exp)+1) + } + return Zero + } + e := digitsMax - dn.exp + frac := dn.coef % pow10[e] + if frac == 0 { + return dn + } + i := dn.coef - frac + if (mode == Up && frac > 0) || (mode == HalfUp && frac >= halfpow10[e]) { + return New(dn.sign, i+pow10[e], int(dn.exp)) // normalize + } + return Value{i, dn.sign, dn.exp} +} + +func (dn Value) Floor() Value { + return dn.Round(0, Down) +} + +func (dn Value) Round(r int, mode RoundingMode) Value { + if dn.sign == 0 || dn.sign == signNegInf || dn.sign == signPosInf || + r >= digitsMax { + return dn + } + if r <= -digitsMax { + return Zero + } + n := New(dn.sign, dn.coef, int(dn.exp)+r) // multiply by 10^r + n = n.integer(mode) + if n.sign == signPos || n.sign == signNeg { // i.e. not zero or inf + return New(n.sign, n.coef, int(n.exp)-r) + } + return n +} + +// arithmetic operations ------------------------------------------------------- + +// Neg returns the Value negated i.e. sign reversed +func (dn Value) Neg() Value { + return Value{dn.coef, -dn.sign, dn.exp} +} + +// Abs returns the Value with a positive sign +func (dn Value) Abs() Value { + if dn.sign < 0 { + return Value{dn.coef, -dn.sign, dn.exp} + } + return dn +} + +// Equal returns true if two Value's are equal +func Equal(x, y Value) bool { + return x.sign == y.sign && x.exp == y.exp && x.coef == y.coef +} + +func (x Value) Eq(y Value) bool { + return Equal(x, y) +} + +func Max(x, y Value) Value { + if Compare(x, y) > 0 { + return x + } + return y +} + +func Min(x, y Value) Value { + if Compare(x, y) < 0 { + return x + } + return y +} + +// Compare compares two Value's returning -1 for <, 0 for ==, +1 for > +func Compare(x, y Value) int { + switch { + case x.sign < y.sign: + return -1 + case x.sign > y.sign: + return 1 + case x == y: + return 0 + } + sign := int(x.sign) + switch { + case sign == 0 || sign == signNegInf || sign == signPosInf: + return 0 + case x.exp < y.exp: + return -sign + case x.exp > y.exp: + return +sign + case x.coef < y.coef: + return -sign + case x.coef > y.coef: + return +sign + default: + return 0 + } +} + +func (x Value) Compare(y Value) int { + return Compare(x, y) +} + +func (v Value) MarshalYAML() (interface{}, error) { + return v.FormatString(8), nil +} + +func (v *Value) UnmarshalYAML(unmarshal func(a interface{}) error) (err error) { + var f float64 + if err = unmarshal(&f); err == nil { + *v = NewFromFloat(f) + return + } + var i int64 + if err = unmarshal(&i); err == nil { + *v = NewFromInt(i) + return + } + + var s string + if err = unmarshal(&s); err == nil { + nv, err2 := NewFromString(s) + if err2 == nil { + *v = nv + return + } + } + return err +} + +// FIXME: should we limit to 8 prec? +func (v Value) MarshalJSON() ([]byte, error) { + if v.IsInf() { + return []byte("\"" + v.String() + "\""), nil + } + return []byte(v.FormatString(8)), nil +} + +func (v *Value) UnmarshalJSON(data []byte) error { + // FIXME: do we need to compare {}, [], "", or "null"? + if bytes.Compare(data, []byte{'n', 'u', 'l', 'l'}) == 0 { + *v = Zero + return nil + } + if len(data) == 0 { + *v = Zero + return nil + } + var err error + if data[0] == '"' { + data = data[1 : len(data)-1] + } + if *v, err = NewFromBytes(data); err != nil { + return err + } + return nil +} + +func Must(v Value, err error) Value { + if err != nil { + panic(err) + } + return v +} + +// v * 10^(exp) +func (v Value) MulExp(exp int) Value { + return Value{v.coef, v.sign, v.exp + exp} +} + +// Sub returns the difference of two Value's +func Sub(x, y Value) Value { + return Add(x, y.Neg()) +} + +func (x Value) Sub(y Value) Value { + return Sub(x, y) +} + +// Add returns the sum of two Value's +func Add(x, y Value) Value { + switch { + case x.sign == signZero: + return y + case y.sign == signZero: + return x + case x.IsInf(): + if y.sign == -x.sign { + return Zero + } + return x + case y.IsInf(): + return y + } + if !align(&x, &y) { + return x + } + if x.sign != y.sign { + return usub(x, y) + } + return uadd(x, y) +} + +func (x Value) Add(y Value) Value { + return Add(x, y) +} + +func uadd(x, y Value) Value { + return New(x.sign, x.coef+y.coef, int(x.exp)) +} + +func usub(x, y Value) Value { + if x.coef < y.coef { + return New(-x.sign, y.coef-x.coef, int(x.exp)) + } + return New(x.sign, x.coef-y.coef, int(x.exp)) +} + +func align(x, y *Value) bool { + if x.exp == y.exp { + return true + } + if x.exp < y.exp { + *x, *y = *y, *x // swap + } + yshift := ilog10(y.coef) + e := int(x.exp - y.exp) + if e > yshift { + return false + } + yshift = e + // check(0 <= yshift && yshift <= 20) + // y.coef = (y.coef + halfpow10[yshift]) / pow10[yshift] + y.coef = (y.coef) / pow10[yshift] + // check(int(y.exp)+yshift == int(x.exp)) + return true +} + +const e7 = 10000000 + +// Mul returns the product of two Value's +func Mul(x, y Value) Value { + sign := x.sign * y.sign + switch { + case sign == signZero: + return Zero + case x.IsInf() || y.IsInf(): + return Inf(sign) + } + e := int(x.exp) + int(y.exp) + + // split unevenly to use full 64 bit range to get more precision + // and avoid needing xlo * ylo + xhi := x.coef / e7 // 9 digits + xlo := x.coef % e7 // 7 digits + yhi := y.coef / e7 // 9 digits + ylo := y.coef % e7 // 7 digits + + c := xhi * yhi + if (xlo | ylo) != 0 { + c += (xlo*yhi + ylo*xhi) / e7 + } + return New(sign, c, e-2) +} + +func (x Value) Mul(y Value) Value { + return Mul(x, y) +} + +// Div returns the quotient of two Value's +func Div(x, y Value) Value { + sign := x.sign * y.sign + switch { + case x.sign == signZero: + return x + case y.sign == signZero: + return Inf(x.sign) + case x.IsInf(): + if y.IsInf() { + if sign < 0 { + return NegOne + } + return One + } + return Inf(sign) + case y.IsInf(): + return Zero + } + coef := div128(x.coef, y.coef) + return New(sign, coef, int(x.exp)-int(y.exp)) +} + +func (x Value) Div(y Value) Value { + return Div(x, y) +} + +// Hash returns a hash value for a Value +func (dn Value) Hash() uint32 { + return uint32(dn.coef>>32) ^ uint32(dn.coef) ^ + uint32(dn.sign)<<16 ^ uint32(dn.exp)<<8 +} + +// Format converts a number to a string with a specified format +func (dn Value) Format(mask string) string { + if dn.IsInf() { + return "#" + } + n := dn + before := 0 + after := 0 + intpart := true + for _, mc := range mask { + switch mc { + case '.': + intpart = false + case '#': + if intpart { + before++ + } else { + after++ + } + } + } + if before+after == 0 || n.Exp() > before { + return "#" // too big to fit in mask + } + n = n.Round(after, HalfUp) + e := n.Exp() + var digits []byte + if n.IsZero() && after == 0 { + digits = []byte("0") + e = 1 + } else { + digits = strconv.AppendUint(make([]byte, 0, digitsMax), n.Coef(), 10) + digits = bytes.TrimRight(digits, "0") + } + nd := len(digits) + + di := e - before + // check(di <= 0) + var buf strings.Builder + sign := n.Sign() + signok := (sign >= 0) + frac := false + for _, mc := range []byte(mask) { + switch mc { + case '#': + if 0 <= di && di < nd { + buf.WriteByte(digits[di]) + } else if frac || di >= 0 { + buf.WriteByte('0') + } + di++ + case ',': + if di > 0 { + buf.WriteByte(',') + } + case '-', '(': + signok = true + if sign < 0 { + buf.WriteByte(mc) + } + case ')': + if sign < 0 { + buf.WriteByte(mc) + } else { + buf.WriteByte(' ') + } + case '.': + frac = true + fallthrough + default: + buf.WriteByte(mc) + } + } + if !signok { + return "-" // negative not handled by mask + } + return buf.String() +} + +func Clamp(x, min, max Value) Value { + if x.Compare(min) < 0 { + return min + } + if x.Compare(max) > 0 { + return max + } + return x +} + +func (x Value) Clamp(min, max Value) Value { + if x.Compare(min) < 0 { + return min + } + if x.Compare(max) > 0 { + return max + } + return x +} diff --git a/pkg/fixedpoint/dec_dnum_test.go b/pkg/fixedpoint/dec_dnum_test.go new file mode 100644 index 0000000..051a8f0 --- /dev/null +++ b/pkg/fixedpoint/dec_dnum_test.go @@ -0,0 +1,44 @@ +//go:build dnum + +package fixedpoint + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDelta(t *testing.T) { + f1 := MustNewFromString("0.0009763593380614657") + f2 := NewFromInt(42300) + assert.InDelta(t, f1.Mul(f2).Float64(), 41.3, 1e-14) +} + +func TestFloor(t *testing.T) { + f1 := MustNewFromString("10.333333") + f2 := f1.Floor() + assert.Equal(t, "10", f2.String()) +} + +func TestInternal(t *testing.T) { + r := &reader{"1.1e-15", 0} + c, e := r.getCoef() + assert.Equal(t, uint64(1100000000000000), c) + assert.Equal(t, 1, e) + f := MustNewFromString("1.1e-15") + digits := getDigits(f.coef) + assert.Equal(t, "11", digits) + f = MustNewFromString("1.00000000000000111") + assert.Equal(t, "1.000000000000001", f.String()) + f = MustNewFromString("1.1e-15") + assert.Equal(t, "0.0000000000000011", f.String()) + assert.Equal(t, 16, f.NumFractionalDigits()) + f = MustNewFromString("1.00000000000000111") + assert.Equal(t, "1.000000000000001", f.String()) + f = MustNewFromString("0.00000000000000000001000111") + assert.Equal(t, "0.00000000000000000001000111", f.String()) + f = MustNewFromString("0.000000000000000000001000111") + assert.Equal(t, "1.000111e-21", f.String()) + f = MustNewFromString("1e-100") + assert.Equal(t, 100, f.NumFractionalDigits()) +} diff --git a/pkg/fixedpoint/dec_legacy_test.go b/pkg/fixedpoint/dec_legacy_test.go new file mode 100644 index 0000000..848b77e --- /dev/null +++ b/pkg/fixedpoint/dec_legacy_test.go @@ -0,0 +1,33 @@ +//go:build !dnum + +package fixedpoint + +import ( + "testing" +) + +func TestNumFractionalDigitsLegacy(t *testing.T) { + tests := []struct { + name string + v Value + want int + }{ + { + name: "over the default precision", + v: MustNewFromString("0.123456789"), + want: 8, + }, + { + name: "zero underflow", + v: MustNewFromString("1e-100"), + want: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.v.NumFractionalDigits(); got != tt.want { + t.Errorf("NumFractionalDigitsLegacy() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/fixedpoint/dec_test.go b/pkg/fixedpoint/dec_test.go new file mode 100644 index 0000000..e86e911 --- /dev/null +++ b/pkg/fixedpoint/dec_test.go @@ -0,0 +1,304 @@ +package fixedpoint + +import ( + "encoding/json" + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" +) + +const Delta = 1e-9 + +func BenchmarkMul(b *testing.B) { + b.ResetTimer() + + b.Run("mul-float64", func(b *testing.B) { + for i := 0; i < b.N; i++ { + x := NewFromFloat(20.0) + y := NewFromFloat(20.0) + x = x.Mul(y) // nolint + } + }) + + b.Run("mul-float64-large-numbers", func(b *testing.B) { + for i := 0; i < b.N; i++ { + x := NewFromFloat(88.12345678) + y := NewFromFloat(88.12345678) + x = x.Mul(y) // nolint + } + }) + + b.Run("mul-big-small-numbers", func(b *testing.B) { + for i := 0; i < b.N; i++ { + x := big.NewFloat(20.0) + y := big.NewFloat(20.0) + x = new(big.Float).Mul(x, y) // nolint + } + }) + + b.Run("mul-big-large-numbers", func(b *testing.B) { + for i := 0; i < b.N; i++ { + x := big.NewFloat(88.12345678) + y := big.NewFloat(88.12345678) + x = new(big.Float).Mul(x, y) // nolint + } + }) +} + +func TestMulString(t *testing.T) { + x := NewFromFloat(10.55) + assert.Equal(t, "10.55", x.String()) + y := NewFromFloat(10.55) + x = x.Mul(y) + assert.Equal(t, "111.3025", x.String()) + assert.Equal(t, "111.30", x.FormatString(2)) + assert.InDelta(t, 111.3025, x.Float64(), Delta) +} + +func TestMulExp(t *testing.T) { + x, _ := NewFromString("166") + digits := x.NumIntDigits() + assert.Equal(t, digits, 3) + step := x.MulExp(-digits + 1) + assert.Equal(t, "1.66", step.String()) +} + +func TestNew(t *testing.T) { + f := NewFromFloat(0.001) + assert.Equal(t, "0.001", f.String()) + assert.Equal(t, "0.0010", f.FormatString(4)) + assert.Equal(t, "0.1%", f.Percentage()) + assert.Equal(t, "0.10%", f.FormatPercentage(2)) + f = NewFromFloat(0.1) + assert.Equal(t, "10%", f.Percentage()) + assert.Equal(t, "10%", f.FormatPercentage(0)) + f = NewFromFloat(0.01) + assert.Equal(t, "1%", f.Percentage()) + assert.Equal(t, "1%", f.FormatPercentage(0)) + f = NewFromFloat(0.111) + assert.Equal(t, "11.1%", f.Percentage()) + assert.Equal(t, "11.1%", f.FormatPercentage(1)) +} + +func TestFormatString(t *testing.T) { + testCases := []struct { + value Value + prec int + out string + }{ + { + value: NewFromFloat(0.001), + prec: 8, + out: "0.00100000", + }, + { + value: NewFromFloat(0.123456789), + prec: 4, + out: "0.1234", + }, + { + value: NewFromFloat(0.123456789), + prec: 5, + out: "0.12345", + }, + { + value: NewFromFloat(20.0), + prec: 0, + out: "20", + }, + } + for _, testCase := range testCases { + assert.Equal(t, testCase.out, testCase.value.FormatString(testCase.prec)) + } +} + +func TestRound(t *testing.T) { + f := NewFromFloat(1.2345) + f = f.Round(0, Down) + assert.Equal(t, "1", f.String()) + w := NewFromFloat(1.2345) + w = w.Trunc() + assert.Equal(t, "1", w.String()) + s := NewFromFloat(1.2345) + assert.Equal(t, "1.23", s.Round(2, Down).String()) +} + +func TestNewFromString(t *testing.T) { + f, err := NewFromString("0.00000003") + assert.NoError(t, err) + assert.Equal(t, "0.00000003", f.String()) +} + +func TestFromString(t *testing.T) { + f := MustNewFromString("0.004075") + assert.Equal(t, "0.004075", f.String()) + f = MustNewFromString("0.03") + assert.Equal(t, "0.03", f.String()) + + f = MustNewFromString("0.75%") + assert.Equal(t, "0.0075", f.String()) + f = MustNewFromString("1.1e-7") + assert.Equal(t, "0.00000011", f.String()) + f = MustNewFromString(".0%") + assert.Equal(t, Zero, f) + f = MustNewFromString("") + assert.Equal(t, Zero, f) + + for _, s := range []string{"inf", "Inf", "INF", "iNF"} { + f = MustNewFromString(s) + assert.Equal(t, PosInf, f) + f = MustNewFromString("+" + s) + assert.Equal(t, PosInf, f) + f = MustNewFromString("-" + s) + assert.Equal(t, NegInf, f) + } +} + +func TestJson(t *testing.T) { + p := MustNewFromString("0") + e, err := json.Marshal(p) + assert.NoError(t, err) + assert.Equal(t, "0.00000000", string(e)) + p = MustNewFromString("1.00000003") + e, err = json.Marshal(p) + assert.NoError(t, err) + assert.Equal(t, "1.00000003", string(e)) + p = MustNewFromString("1.000000003") + e, err = json.Marshal(p) + assert.NoError(t, err) + assert.Equal(t, "1.00000000", string(e)) + p = MustNewFromString("1.000000008") + e, err = json.Marshal(p) + assert.NoError(t, err) + assert.Equal(t, "1.00000000", string(e)) + p = MustNewFromString("0.999999999") + e, err = json.Marshal(p) + assert.NoError(t, err) + assert.Equal(t, "0.99999999", string(e)) + + p = MustNewFromString("1.2e-9") + e, err = json.Marshal(p) + assert.NoError(t, err) + assert.Equal(t, "0.00000000", p.FormatString(8)) + assert.Equal(t, "0.00000000", string(e)) + + _ = json.Unmarshal([]byte("0.00153917575"), &p) + assert.Equal(t, "0.00153917", p.FormatString(8)) + + q := NewFromFloat(0.00153917575) + assert.Equal(t, p, q) + _ = json.Unmarshal([]byte("6e-8"), &p) + _ = json.Unmarshal([]byte("0.000062"), &q) + assert.Equal(t, "0.00006194", q.Sub(p).String()) + + assert.NoError(t, json.Unmarshal([]byte(`"inf"`), &p)) + assert.NoError(t, json.Unmarshal([]byte(`"+Inf"`), &q)) + assert.Equal(t, PosInf, p) + assert.Equal(t, p, q) +} + +func TestYaml(t *testing.T) { + p := MustNewFromString("0") + e, err := yaml.Marshal(p) + assert.NoError(t, err) + assert.Equal(t, "\"0.00000000\"\n", string(e)) + p = MustNewFromString("1.00000003") + e, err = yaml.Marshal(p) + assert.NoError(t, err) + assert.Equal(t, "\"1.00000003\"\n", string(e)) + p = MustNewFromString("1.000000003") + e, err = yaml.Marshal(p) + assert.NoError(t, err) + assert.Equal(t, "\"1.00000000\"\n", string(e)) + p = MustNewFromString("1.000000008") + e, err = yaml.Marshal(p) + assert.NoError(t, err) + assert.Equal(t, "\"1.00000000\"\n", string(e)) + p = MustNewFromString("0.999999999") + e, err = yaml.Marshal(p) + assert.NoError(t, err) + assert.Equal(t, "\"0.99999999\"\n", string(e)) + + p = MustNewFromString("1.2e-9") + e, err = yaml.Marshal(p) + assert.NoError(t, err) + assert.Equal(t, "0.00000000", p.FormatString(8)) + assert.Equal(t, "\"0.00000000\"\n", string(e)) + + _ = yaml.Unmarshal([]byte("0.00153917575"), &p) + assert.Equal(t, "0.00153917", p.FormatString(8)) + + q := NewFromFloat(0.00153917575) + assert.Equal(t, p, q) + _ = yaml.Unmarshal([]byte("6e-8"), &p) + _ = yaml.Unmarshal([]byte("0.000062"), &q) + assert.Equal(t, "0.00006194", q.Sub(p).String()) + + assert.NoError(t, json.Unmarshal([]byte(`"inf"`), &p)) + assert.NoError(t, json.Unmarshal([]byte(`"+Inf"`), &q)) + assert.Equal(t, PosInf, p) + assert.Equal(t, p, q) +} + +func TestNumFractionalDigits(t *testing.T) { + tests := []struct { + name string + v Value + want int + }{ + { + name: "ignore the integer part", + v: MustNewFromString("123.4567"), + want: 4, + }, + { + name: "ignore the sign", + v: MustNewFromString("-123.4567"), + want: 4, + }, + { + name: "ignore the trailing zero", + v: MustNewFromString("-123.45000000"), + want: 2, + }, + { + name: "no fractional parts", + v: MustNewFromString("-1"), + want: 0, + }, + { + name: "no fractional parts", + v: MustNewFromString("-1.0"), + want: 0, + }, + { + name: "only fractional part", + v: MustNewFromString(".123456"), + want: 6, + }, + { + name: "percentage", + v: MustNewFromString("0.075%"), // 0.075 * 0.01 + want: 5, + }, + { + name: "scientific notation", + v: MustNewFromString("1.1e-7"), + want: 8, + }, + { + name: "zero", + v: MustNewFromString("0"), + want: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.v.NumFractionalDigits(); got != tt.want { + t.Errorf("NumFractionalDigits() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/fixedpoint/div128.go b/pkg/fixedpoint/div128.go new file mode 100644 index 0000000..21d84c5 --- /dev/null +++ b/pkg/fixedpoint/div128.go @@ -0,0 +1,134 @@ +//go:build dnum +// +build dnum + +// Copyright Suneido Software Corp. All rights reserved. +// Governed by the MIT license found in the LICENSE file. + +package fixedpoint + +import ( + "math/bits" +) + +const ( + e16 = 1_0000_0000_0000_0000 + longMask = 0xffffffff + divNumBase = 1 << 32 + e16Hi = e16 >> 32 + e16Lo = e16 & longMask +) + +// returns (1e16 * dividend) / divisor +// Used by dnum divide +// Based on cSuneido code +// which is based on jSuneido code +// which is based on Java BigDecimal code +// which is based on Hacker's Delight and Knuth TAoCP Vol 2 +// A bit simpler with unsigned types +func div128(dividend, divisor uint64) uint64 { + //check(dividend != 0) + //check(divisor != 0) + // multiply dividend * e16 + d1Hi := dividend >> 32 + d1Lo := dividend & longMask + product := uint64(e16Lo) * d1Lo + d0 := product & longMask + d1 := product >> 32 + product = uint64(e16Hi)*d1Lo + d1 + d1 = product & longMask + d2 := product >> 32 + product = uint64(e16Lo)*d1Hi + d1 + d1 = product & longMask + d2 += product >> 32 + d3 := d2 >> 32 + d2 &= longMask + product = e16Hi*d1Hi + d2 + d2 = product & longMask + d3 = ((product >> 32) + d3) & longMask + dividendHi := make64(uint32(d3), uint32(d2)) + dividendLo := make64(uint32(d1), uint32(d0)) + // divide + return divide128(dividendHi, dividendLo, divisor) +} + +func divide128(dividendHi, dividendLo, divisor uint64) uint64 { + // so we can shift dividend as much as divisor + // don't allow equals to avoid quotient overflow (by 1) + //check(dividendHi < divisor) + + // maximize divisor (bit wise), since we're mostly using the top half + shift := uint(bits.LeadingZeros64(divisor)) + divisor = divisor << shift + + // split divisor + v1 := divisor >> 32 + v0 := divisor & longMask + + // matching shift + dls := dividendLo << shift + // split dividendLo + u1 := uint32(dls >> 32) + u0 := uint32(dls & longMask) + + // tmp1 = top 64 of dividend << shift + tmp1 := (dividendHi << shift) | (dividendLo >> (64 - shift)) + var q1, rtmp1 uint64 + if v1 == 1 { + q1 = tmp1 + rtmp1 = 0 + } else { + //check(tmp1 >= 0) + q1 = tmp1 / v1 // DIVIDE top 64 / top 32 + rtmp1 = tmp1 % v1 // remainder + } + + // adjust if quotient estimate too large + //check(q1 < divNumBase) + for q1*v0 > make64(uint32(rtmp1), u1) { + // done about 5.5 per 10,000 divides + q1-- + rtmp1 += v1 + if rtmp1 >= divNumBase { + break + } + } + //check(q1 >= 0) + u2 := tmp1 & longMask // low half + + // u2,u1 is the MIDDLE 64 bits of the dividend + tmp2 := mulsub(uint32(u2), uint32(u1), uint32(v1), uint32(v0), q1) + var q0, rtmp2 uint64 + if v1 == 1 { + q0 = tmp2 + rtmp2 = 0 + } else { + q0 = tmp2 / v1 // DIVIDE dividend remainder 64 / divisor high 32 + rtmp2 = tmp2 % v1 + } + + // adjust if quotient estimate too large + //check(q0 < divNumBase) + for q0*v0 > make64(uint32(rtmp2), u0) { + // done about .33 times per divide + q0-- + rtmp2 += v1 + if rtmp2 >= divNumBase { + break + } + //check(q0 < divNumBase) + } + + //check(q1 <= math.MaxUint32) + //check(q0 <= math.MaxUint32) + return make64(uint32(q1), uint32(q0)) +} + +// mulsub returns u1,u0 - v1,v0 * q0 +func mulsub(u1, u0, v1, v0 uint32, q0 uint64) uint64 { + tmp := uint64(u0) - q0*uint64(v0) + return make64(u1+uint32(tmp>>32)-uint32(q0*uint64(v1)), uint32(tmp&longMask)) +} + +func make64(hi, lo uint32) uint64 { + return uint64(hi)<<32 | uint64(lo) +} diff --git a/pkg/fixedpoint/filter.go b/pkg/fixedpoint/filter.go new file mode 100644 index 0000000..bbcfc8c --- /dev/null +++ b/pkg/fixedpoint/filter.go @@ -0,0 +1,20 @@ +package fixedpoint + +type Tester func(value Value) bool + +func PositiveTester(value Value) bool { + return value.Sign() > 0 +} + +func NegativeTester(value Value) bool { + return value.Sign() < 0 +} + +func Filter(values []Value, f Tester) (slice []Value) { + for _, v := range values { + if f(v) { + slice = append(slice, v) + } + } + return slice +} diff --git a/pkg/fixedpoint/helpers.go b/pkg/fixedpoint/helpers.go new file mode 100644 index 0000000..cb585e5 --- /dev/null +++ b/pkg/fixedpoint/helpers.go @@ -0,0 +1,15 @@ +package fixedpoint + +func Sum(values []Value) (s Value) { + s = Zero + for _, value := range values { + s = s.Add(value) + } + return s +} + +func Avg(values []Value) (avg Value) { + s := Sum(values) + avg = s.Div(NewFromInt(int64(len(values)))) + return avg +} diff --git a/pkg/fixedpoint/reduce.go b/pkg/fixedpoint/reduce.go new file mode 100644 index 0000000..0b8edbf --- /dev/null +++ b/pkg/fixedpoint/reduce.go @@ -0,0 +1,25 @@ +package fixedpoint + +type Reducer func(prev, curr Value) Value + +func SumReducer(prev, curr Value) Value { + return prev.Add(curr) +} + +func Reduce(values []Value, reducer Reducer, a ...Value) Value { + init := Zero + if len(a) > 0 { + init = a[0] + } + + if len(values) == 0 { + return init + } + + r := reducer(init, values[0]) + for i := 1; i < len(values); i++ { + r = reducer(r, values[i]) + } + + return r +} diff --git a/pkg/fixedpoint/reduce_test.go b/pkg/fixedpoint/reduce_test.go new file mode 100644 index 0000000..5800062 --- /dev/null +++ b/pkg/fixedpoint/reduce_test.go @@ -0,0 +1,37 @@ +package fixedpoint + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestReduce(t *testing.T) { + type args struct { + values []Value + init Value + reducer Reducer + } + tests := []struct { + name string + args args + want Value + }{ + { + name: "simple", + args: args{ + values: []Value{NewFromFloat(1), NewFromFloat(2), NewFromFloat(3)}, + init: NewFromFloat(0.0), + reducer: func(prev, curr Value) Value { + return prev.Add(curr) + }, + }, + want: NewFromFloat(6), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, Reduce(tt.args.values, tt.args.reducer, tt.args.init), "Reduce(%v, %v, %v)", tt.args.values, tt.args.init, tt.args.reducer) + }) + } +} diff --git a/pkg/fixedpoint/slice.go b/pkg/fixedpoint/slice.go new file mode 100644 index 0000000..c5a7db1 --- /dev/null +++ b/pkg/fixedpoint/slice.go @@ -0,0 +1,24 @@ +package fixedpoint + +type Slice []Value + +func (s Slice) Reduce(reducer Reducer, a ...Value) Value { + return Reduce(s, reducer, a...) +} + +// Defaults to ascending sort +func (s Slice) Len() int { return len(s) } +func (s Slice) Swap(i, j int) { s[i], s[j] = s[j], s[i] } +func (s Slice) Less(i, j int) bool { return s[i].Compare(s[j]) < 0 } + +type Ascending []Value + +func (s Ascending) Len() int { return len(s) } +func (s Ascending) Swap(i, j int) { s[i], s[j] = s[j], s[i] } +func (s Ascending) Less(i, j int) bool { return s[i].Compare(s[j]) < 0 } + +type Descending []Value + +func (s Descending) Len() int { return len(s) } +func (s Descending) Swap(i, j int) { s[i], s[j] = s[j], s[i] } +func (s Descending) Less(i, j int) bool { return s[i].Compare(s[j]) > 0 } diff --git a/pkg/fixedpoint/slice_test.go b/pkg/fixedpoint/slice_test.go new file mode 100644 index 0000000..082db90 --- /dev/null +++ b/pkg/fixedpoint/slice_test.go @@ -0,0 +1,35 @@ +package fixedpoint + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSortInterface(t *testing.T) { + slice := Slice{ + NewFromInt(7), + NewFromInt(3), + NewFromInt(1), + NewFromInt(2), + NewFromInt(5), + } + sort.Sort(slice) + assert.Equal(t, "1", slice[0].String()) + assert.Equal(t, "2", slice[1].String()) + assert.Equal(t, "3", slice[2].String()) + assert.Equal(t, "5", slice[3].String()) + + sort.Sort(Descending(slice)) + assert.Equal(t, "7", slice[0].String()) + assert.Equal(t, "5", slice[1].String()) + assert.Equal(t, "3", slice[2].String()) + assert.Equal(t, "2", slice[3].String()) + + sort.Sort(Ascending(slice)) + assert.Equal(t, "1", slice[0].String()) + assert.Equal(t, "2", slice[1].String()) + assert.Equal(t, "3", slice[2].String()) + assert.Equal(t, "5", slice[3].String()) +} diff --git a/pkg/grpc/convert.go b/pkg/grpc/convert.go new file mode 100644 index 0000000..249b43a --- /dev/null +++ b/pkg/grpc/convert.go @@ -0,0 +1,237 @@ +package grpc + +import ( + "fmt" + "strconv" + + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/pb" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func toSubscriptions(sub *pb.Subscription) (types.Subscription, error) { + switch sub.Channel { + case pb.Channel_TRADE: + return types.Subscription{ + Symbol: sub.Symbol, + Channel: types.MarketTradeChannel, + }, nil + + case pb.Channel_BOOK: + return types.Subscription{ + Symbol: sub.Symbol, + Channel: types.BookChannel, + Options: types.SubscribeOptions{ + Depth: types.Depth(sub.Depth), + }, + }, nil + + case pb.Channel_KLINE: + return types.Subscription{ + Symbol: sub.Symbol, + Channel: types.KLineChannel, + Options: types.SubscribeOptions{ + Interval: types.Interval(sub.Interval), + }, + }, nil + } + + return types.Subscription{}, fmt.Errorf("unsupported subscription channel: %s", sub.Channel) +} + +func transPriceVolume(srcPvs types.PriceVolumeSlice) (pvs []*pb.PriceVolume) { + for _, srcPv := range srcPvs { + pvs = append(pvs, &pb.PriceVolume{ + Price: srcPv.Price.String(), + Volume: srcPv.Volume.String(), + }) + } + return pvs +} + +func transBook(session *qbtrade.ExchangeSession, book types.SliceOrderBook, event pb.Event) *pb.MarketData { + return &pb.MarketData{ + Session: session.Name, + Exchange: session.ExchangeName.String(), + Symbol: book.Symbol, + Channel: pb.Channel_BOOK, + Event: event, + Depth: &pb.Depth{ + Exchange: session.ExchangeName.String(), + Symbol: book.Symbol, + Asks: transPriceVolume(book.Asks), + Bids: transPriceVolume(book.Bids), + }, + } +} + +func toOrderType(orderType pb.OrderType) types.OrderType { + switch orderType { + case pb.OrderType_MARKET: + return types.OrderTypeMarket + case pb.OrderType_LIMIT: + return types.OrderTypeLimit + + } + + log.Warnf("unexpected order type: %v", orderType) + return types.OrderTypeLimit +} + +func toSide(side pb.Side) types.SideType { + switch side { + case pb.Side_BUY: + return types.SideTypeBuy + case pb.Side_SELL: + return types.SideTypeSell + + } + + log.Warnf("unexpected side type: %v", side) + return types.SideTypeBuy +} + +func toSubmitOrders(pbOrders []*pb.SubmitOrder) (submitOrders []types.SubmitOrder) { + for _, pbOrder := range pbOrders { + submitOrders = append(submitOrders, types.SubmitOrder{ + ClientOrderID: pbOrder.ClientOrderId, + Symbol: pbOrder.Symbol, + Side: toSide(pbOrder.Side), + Type: toOrderType(pbOrder.OrderType), + Price: fixedpoint.MustNewFromString(pbOrder.Price), + Quantity: fixedpoint.MustNewFromString(pbOrder.Quantity), + StopPrice: fixedpoint.MustNewFromString(pbOrder.StopPrice), + TimeInForce: "", + }) + } + + return submitOrders +} + +func transBalances(session *qbtrade.ExchangeSession, balances types.BalanceMap) (pbBalances []*pb.Balance) { + for _, b := range balances { + pbBalances = append(pbBalances, &pb.Balance{ + Exchange: session.ExchangeName.String(), + Currency: b.Currency, + Available: b.Available.String(), + Locked: b.Locked.String(), + }) + } + return pbBalances +} + +func transTrade(session *qbtrade.ExchangeSession, trade types.Trade) *pb.Trade { + return &pb.Trade{ + Session: session.Name, + Exchange: trade.Exchange.String(), + Symbol: trade.Symbol, + Id: strconv.FormatUint(trade.ID, 10), + Price: trade.Price.String(), + Quantity: trade.Quantity.String(), + CreatedAt: trade.Time.UnixMilli(), + Side: transSide(trade.Side), + FeeCurrency: trade.FeeCurrency, + Fee: trade.Fee.String(), + Maker: trade.IsMaker, + } +} + +func transMarketTrade(session *qbtrade.ExchangeSession, marketTrade types.Trade) *pb.MarketData { + return &pb.MarketData{ + Session: session.Name, + Exchange: session.ExchangeName.String(), + Symbol: marketTrade.Symbol, + Channel: pb.Channel_TRADE, + Event: pb.Event_UPDATE, + Trades: []*pb.Trade{ + { + Exchange: marketTrade.Exchange.String(), + Symbol: marketTrade.Symbol, + Id: strconv.FormatUint(marketTrade.ID, 10), + Price: marketTrade.Price.String(), + Quantity: marketTrade.Quantity.String(), + CreatedAt: marketTrade.Time.UnixMilli(), + Side: transSide(marketTrade.Side), + FeeCurrency: marketTrade.FeeCurrency, + Fee: marketTrade.Fee.String(), + Maker: marketTrade.IsMaker, + }, + }, + } +} + +func transSide(side types.SideType) pb.Side { + switch side { + case types.SideTypeBuy: + return pb.Side_BUY + case types.SideTypeSell: + return pb.Side_SELL + } + + return pb.Side_SELL +} + +func transOrderType(orderType types.OrderType) pb.OrderType { + switch orderType { + case types.OrderTypeLimit: + return pb.OrderType_LIMIT + case types.OrderTypeMarket: + return pb.OrderType_MARKET + case types.OrderTypeStopLimit: + return pb.OrderType_STOP_LIMIT + case types.OrderTypeStopMarket: + return pb.OrderType_STOP_MARKET + } + + return pb.OrderType_LIMIT +} + +func transOrder(session *qbtrade.ExchangeSession, order types.Order) *pb.Order { + return &pb.Order{ + Exchange: order.Exchange.String(), + Symbol: order.Symbol, + Id: strconv.FormatUint(order.OrderID, 10), + Side: transSide(order.Side), + OrderType: transOrderType(order.Type), + Price: order.Price.String(), + StopPrice: order.StopPrice.String(), + Status: string(order.Status), + CreatedAt: order.CreationTime.UnixMilli(), + Quantity: order.Quantity.String(), + ExecutedQuantity: order.ExecutedQuantity.String(), + ClientOrderId: order.ClientOrderID, + GroupId: int64(order.GroupID), + } +} + +func transKLine(session *qbtrade.ExchangeSession, kline types.KLine) *pb.KLine { + return &pb.KLine{ + Session: session.Name, + Exchange: kline.Exchange.String(), + Symbol: kline.Symbol, + Open: kline.Open.String(), + High: kline.High.String(), + Low: kline.Low.String(), + Close: kline.Close.String(), + Volume: kline.Volume.String(), + QuoteVolume: kline.QuoteVolume.String(), + StartTime: kline.StartTime.UnixMilli(), + EndTime: kline.StartTime.UnixMilli(), + Closed: kline.Closed, + } +} + +func transKLineResponse(session *qbtrade.ExchangeSession, kline types.KLine) *pb.MarketData { + return &pb.MarketData{ + Session: session.Name, + Exchange: kline.Exchange.String(), + Symbol: kline.Symbol, + Channel: pb.Channel_KLINE, + Event: pb.Event_UPDATE, + Kline: transKLine(session, kline), + SubscribedAt: 0, + } +} diff --git a/pkg/grpc/server.go b/pkg/grpc/server.go new file mode 100644 index 0000000..a0e53af --- /dev/null +++ b/pkg/grpc/server.go @@ -0,0 +1,355 @@ +package grpc + +import ( + "context" + "fmt" + "net" + "strconv" + "time" + + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "google.golang.org/grpc" + "google.golang.org/grpc/reflection" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/pb" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type TradingService struct { + Config *qbtrade.Config + Environ *qbtrade.Environment + Trader *qbtrade.Trader + + pb.UnimplementedTradingServiceServer +} + +func (s *TradingService) SubmitOrder(ctx context.Context, request *pb.SubmitOrderRequest) (*pb.SubmitOrderResponse, error) { + sessionName := request.Session + + if len(sessionName) == 0 { + return nil, fmt.Errorf("session name can not be empty") + } + + session, ok := s.Environ.Session(sessionName) + if !ok { + return nil, fmt.Errorf("session %s not found", sessionName) + } + + submitOrders := toSubmitOrders(request.SubmitOrders) + for i := range submitOrders { + if market, ok := session.Market(submitOrders[i].Symbol); ok { + submitOrders[i].Market = market + } else { + log.Warnf("session %s market %s not found", sessionName, submitOrders[i].Symbol) + } + } + + // we will return this error later because some orders could be succeeded + createdOrders, _, err := qbtrade.BatchRetryPlaceOrder(ctx, session.Exchange, nil, nil, log.StandardLogger(), submitOrders...) + + // convert response + resp := &pb.SubmitOrderResponse{ + Session: sessionName, + Orders: nil, + } + + for _, createdOrder := range createdOrders { + resp.Orders = append(resp.Orders, transOrder(session, createdOrder)) + } + + return resp, err +} + +func (s *TradingService) CancelOrder(ctx context.Context, request *pb.CancelOrderRequest) (*pb.CancelOrderResponse, error) { + sessionName := request.Session + + if len(sessionName) == 0 { + return nil, fmt.Errorf("session name can not be empty") + } + + session, ok := s.Environ.Session(sessionName) + if !ok { + return nil, fmt.Errorf("session %s not found", sessionName) + } + + uuidOrderID := "" + orderID, err := strconv.ParseUint(request.OrderId, 10, 64) + if err != nil { + // TODO: validate uuid + uuidOrderID = request.OrderId + } + + session.Exchange.CancelOrders(ctx, types.Order{ + SubmitOrder: types.SubmitOrder{ + ClientOrderID: request.ClientOrderId, + }, + OrderID: orderID, + UUID: uuidOrderID, + }) + + resp := &pb.CancelOrderResponse{} + return resp, nil +} + +func (s *TradingService) QueryOrder(ctx context.Context, request *pb.QueryOrderRequest) (*pb.QueryOrderResponse, error) { + panic("implement me") +} + +func (s *TradingService) QueryOrders(ctx context.Context, request *pb.QueryOrdersRequest) (*pb.QueryOrdersResponse, error) { + panic("implement me") +} + +func (s *TradingService) QueryTrades(ctx context.Context, request *pb.QueryTradesRequest) (*pb.QueryTradesResponse, error) { + panic("implement me") +} + +type UserDataService struct { + Config *qbtrade.Config + Environ *qbtrade.Environment + Trader *qbtrade.Trader + + pb.UnimplementedUserDataServiceServer +} + +func (s *UserDataService) Subscribe(request *pb.UserDataRequest, server pb.UserDataService_SubscribeServer) error { + sessionName := request.Session + + if len(sessionName) == 0 { + return fmt.Errorf("session name can not be empty") + } + + session, ok := s.Environ.Session(sessionName) + if !ok { + return fmt.Errorf("session %s not found", sessionName) + } + + userDataStream := session.Exchange.NewStream() + userDataStream.OnOrderUpdate(func(order types.Order) { + err := server.Send(&pb.UserData{ + Channel: pb.Channel_ORDER, + Event: pb.Event_UPDATE, + Orders: []*pb.Order{transOrder(session, order)}, + }) + if err != nil { + log.WithError(err).Errorf("grpc: can not send user data") + } + }) + userDataStream.OnTradeUpdate(func(trade types.Trade) { + err := server.Send(&pb.UserData{ + Channel: pb.Channel_TRADE, + Event: pb.Event_UPDATE, + Trades: []*pb.Trade{transTrade(session, trade)}, + }) + if err != nil { + log.WithError(err).Errorf("grpc: can not send user data") + } + }) + + balanceHandler := func(balances types.BalanceMap) { + err := server.Send(&pb.UserData{ + Channel: pb.Channel_BALANCE, + Event: pb.Event_UPDATE, + Balances: transBalances(session, balances), + }) + if err != nil { + log.WithError(err).Errorf("grpc: can not send user data") + } + } + userDataStream.OnBalanceUpdate(balanceHandler) + userDataStream.OnBalanceSnapshot(balanceHandler) + + ctx := server.Context() + + balances, err := session.Exchange.QueryAccountBalances(ctx) + if err != nil { + return err + } + + err = server.Send(&pb.UserData{ + Channel: pb.Channel_BALANCE, + Event: pb.Event_SNAPSHOT, + Balances: transBalances(session, balances), + }) + if err != nil { + log.WithError(err).Errorf("grpc: can not send user data") + } + + go userDataStream.Connect(ctx) + + defer func() { + if err := userDataStream.Close(); err != nil { + log.WithError(err).Errorf("user data stream close error") + } + }() + + <-ctx.Done() + return nil +} + +type MarketDataService struct { + Config *qbtrade.Config + Environ *qbtrade.Environment + Trader *qbtrade.Trader + + pb.UnimplementedMarketDataServiceServer +} + +func (s *MarketDataService) Subscribe(request *pb.SubscribeRequest, server pb.MarketDataService_SubscribeServer) error { + exchangeSubscriptions := map[string][]types.Subscription{} + for _, sub := range request.Subscriptions { + session, ok := s.Environ.Session(sub.Exchange) + if !ok { + return fmt.Errorf("exchange %s not found", sub.Exchange) + } + + ss, err := toSubscriptions(sub) + if err != nil { + return err + } + + exchangeSubscriptions[session.Name] = append(exchangeSubscriptions[session.Name], ss) + } + + streamPool := map[string]types.Stream{} + for sessionName, subs := range exchangeSubscriptions { + session, ok := s.Environ.Session(sessionName) + if !ok { + log.Errorf("session %s not found", sessionName) + continue + } + + stream := session.Exchange.NewStream() + stream.SetPublicOnly() + for _, sub := range subs { + log.Infof("%s subscribe %s %s %+v", sessionName, sub.Channel, sub.Symbol, sub.Options) + stream.Subscribe(sub.Channel, sub.Symbol, sub.Options) + } + + stream.OnMarketTrade(func(trade types.Trade) { + if err := server.Send(transMarketTrade(session, trade)); err != nil { + log.WithError(err).Error("grpc stream send error") + } + }) + + stream.OnBookSnapshot(func(book types.SliceOrderBook) { + if err := server.Send(transBook(session, book, pb.Event_SNAPSHOT)); err != nil { + log.WithError(err).Error("grpc stream send error") + } + }) + + stream.OnBookUpdate(func(book types.SliceOrderBook) { + if err := server.Send(transBook(session, book, pb.Event_UPDATE)); err != nil { + log.WithError(err).Error("grpc stream send error") + } + }) + stream.OnKLineClosed(func(kline types.KLine) { + err := server.Send(transKLineResponse(session, kline)) + if err != nil { + log.WithError(err).Error("grpc stream send error") + } + }) + streamPool[sessionName] = stream + } + + for _, stream := range streamPool { + go stream.Connect(server.Context()) + } + + defer func() { + for _, stream := range streamPool { + if err := stream.Close(); err != nil { + log.WithError(err).Errorf("market data stream close error") + } + } + }() + + ctx := server.Context() + <-ctx.Done() + return ctx.Err() +} + +func (s *MarketDataService) QueryKLines(ctx context.Context, request *pb.QueryKLinesRequest) (*pb.QueryKLinesResponse, error) { + exchangeName, err := types.ValidExchangeName(request.Exchange) + if err != nil { + return nil, err + } + + for _, session := range s.Environ.Sessions() { + if session.ExchangeName == exchangeName { + response := &pb.QueryKLinesResponse{ + Klines: nil, + Error: nil, + } + + options := types.KLineQueryOptions{ + Limit: int(request.Limit), + } + + endTime := time.Now() + if request.EndTime != 0 { + endTime = time.Unix(request.EndTime, 0) + } + options.EndTime = &endTime + + if request.StartTime != 0 { + startTime := time.Unix(request.StartTime, 0) + options.StartTime = &startTime + } + + klines, err := session.Exchange.QueryKLines(ctx, request.Symbol, types.Interval(request.Interval), options) + if err != nil { + return nil, err + } + + for _, kline := range klines { + response.Klines = append(response.Klines, transKLine(session, kline)) + } + + return response, nil + } + } + + return nil, nil +} + +type Server struct { + Config *qbtrade.Config + Environ *qbtrade.Environment + Trader *qbtrade.Trader +} + +func (s *Server) ListenAndServe(bind string) error { + conn, err := net.Listen("tcp", bind) + if err != nil { + return errors.Wrapf(err, "failed to bind network at %s", bind) + } + + var grpcServer = grpc.NewServer() + pb.RegisterMarketDataServiceServer(grpcServer, &MarketDataService{ + Config: s.Config, + Environ: s.Environ, + Trader: s.Trader, + }) + + pb.RegisterTradingServiceServer(grpcServer, &TradingService{ + Config: s.Config, + Environ: s.Environ, + Trader: s.Trader, + }) + + pb.RegisterUserDataServiceServer(grpcServer, &UserDataService{ + Config: s.Config, + Environ: s.Environ, + Trader: s.Trader, + }) + + reflection.Register(grpcServer) + + if err := grpcServer.Serve(conn); err != nil { + return errors.Wrap(err, "failed to serve grpc connections") + } + + return nil +} diff --git a/pkg/indicator/ad.go b/pkg/indicator/ad.go new file mode 100644 index 0000000..345f616 --- /dev/null +++ b/pkg/indicator/ad.go @@ -0,0 +1,66 @@ +package indicator + +import ( + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +/* +ad implements accumulation/distribution indicator + +Accumulation/Distribution Indicator (A/D) +- https://www.investopedia.com/terms/a/accumulationdistribution.asp +*/ +//go:generate callbackgen -type AD +type AD struct { + types.SeriesBase + types.IntervalWindow + Values floats.Slice + PrePrice float64 + + EndTime time.Time + UpdateCallbacks []func(value float64) +} + +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 + } else { + moneyFlowVolume = ((2*cloze - high - low) / (high - low)) * volume + } + + ad := inc.Last(0) + moneyFlowVolume + inc.Values.Push(ad) +} + +func (inc *AD) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *AD) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *AD) Length() int { + return len(inc.Values) +} + +var _ types.SeriesExtend = &AD{} + +func (inc *AD) CalculateAndUpdate(kLines []types.KLine) { + for _, k := range kLines { + if inc.EndTime != zeroTime && !k.EndTime.After(inc.EndTime) { + continue + } + inc.Update(k.High.Float64(), k.Low.Float64(), k.Close.Float64(), k.Volume.Float64()) + } + + inc.EmitUpdate(inc.Last(0)) + inc.EndTime = kLines[len(kLines)-1].EndTime.Time() +} diff --git a/pkg/indicator/ad_callbacks.go b/pkg/indicator/ad_callbacks.go new file mode 100644 index 0000000..dd1e9e5 --- /dev/null +++ b/pkg/indicator/ad_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type AD"; DO NOT EDIT. + +package indicator + +import () + +func (inc *AD) OnUpdate(cb func(value float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *AD) EmitUpdate(value float64) { + for _, cb := range inc.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/alma.go b/pkg/indicator/alma.go new file mode 100644 index 0000000..a447dbf --- /dev/null +++ b/pkg/indicator/alma.go @@ -0,0 +1,92 @@ +package indicator + +import ( + "math" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// Refer: Arnaud Legoux Moving Average +// Refer: https://capital.com/arnaud-legoux-moving-average +// Also check https://github.com/DaveSkender/Stock.Indicators/blob/main/src/a-d/Alma/Alma.cs +// +// The Arnaud Legoux Moving Average (ALMA) is a technical analysis indicator that is used to smooth price data and reduce the lag associated +// with traditional moving averages. It was developed by Arnaud Legoux and is based on the weighted moving average, with the weighting factors +// determined using a Gaussian function. The ALMA is calculated by taking the weighted moving average of the input data using weighting factors +// that are based on the standard deviation of the data and the specified length of the moving average. This resulting average is then plotted +// on the price chart as a line, which can be used to make predictions about future price movements. The ALMA is typically more responsive to +// changes in the underlying data than a simple moving average, but may be less reliable in trending markets. +// +// @param offset: Gaussian applied to the combo line. 1->ema, 0->sma +// @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 0.5 + Sigma int // required: recommend to be 5 + weight []float64 + sum float64 + input []float64 + Values floats.Slice + UpdateCallbacks []func(value float64) +} + +const MaxNumOfALMA = 5_000 +const MaxNumOfALMATruncateSize = 100 + +func (inc *ALMA) Update(value float64) { + 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. + 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.input = append(inc.input, value) + if len(inc.input) >= inc.Window { + 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] + } + inc.Values.Push(weightedSum / inc.sum) + if len(inc.Values) > MaxNumOfALMA { + inc.Values = inc.Values[MaxNumOfALMATruncateSize-1:] + } + } +} + +func (inc *ALMA) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *ALMA) Index(i int) float64 { + return inc.Last(i) +} + +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 { + inc.Update(k.Close.Float64()) + inc.EmitUpdate(inc.Last(0)) + } + return + } + inc.Update(allKLines[len(allKLines)-1].Close.Float64()) + inc.EmitUpdate(inc.Last(0)) +} diff --git a/pkg/indicator/alma_callbacks.go b/pkg/indicator/alma_callbacks.go new file mode 100644 index 0000000..52d2b2f --- /dev/null +++ b/pkg/indicator/alma_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type ALMA"; DO NOT EDIT. + +package indicator + +import () + +func (inc *ALMA) OnUpdate(cb func(value float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *ALMA) EmitUpdate(value float64) { + for _, cb := range inc.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/alma_test.go b/pkg/indicator/alma_test.go new file mode 100644 index 0000000..8fc2945 --- /dev/null +++ b/pkg/indicator/alma_test.go @@ -0,0 +1,61 @@ +package indicator + +import ( + "encoding/json" + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +/* +python: + +import pandas as pd +import pandas_ta as ta + +data = pd.Series([0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9]) +sigma = 6 +offset = 0.9 +size = 5 + +result = ta.alma(data, size, sigma, offset) +print(result) +*/ +func Test_ALMA(t *testing.T) { + var Delta = 0.01 + var randomPrices = []byte(`[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]`) + var input []fixedpoint.Value + if err := json.Unmarshal(randomPrices, &input); err != nil { + panic(err) + } + tests := []struct { + name string + kLines []types.KLine + want float64 + next float64 + all int + }{ + { + name: "random_case", + kLines: buildKLines(input), + want: 5.60785, + next: 4.60785, + all: 26, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + alma := ALMA{ + IntervalWindow: types.IntervalWindow{Window: 5}, + Offset: 0.9, + Sigma: 6, + } + alma.CalculateAndUpdate(tt.kLines) + assert.InDelta(t, tt.want, alma.Last(0), Delta) + assert.InDelta(t, tt.next, alma.Index(1), Delta) + assert.Equal(t, tt.all, alma.Length()) + }) + } +} diff --git a/pkg/indicator/atr.go b/pkg/indicator/atr.go new file mode 100644 index 0000000..d339d5b --- /dev/null +++ b/pkg/indicator/atr.go @@ -0,0 +1,111 @@ +package indicator + +import ( + "math" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const MaxNumOfATR = 1000 +const MaxNumOfATRTruncateSize = 500 + +//go:generate callbackgen -type ATR +type ATR struct { + types.SeriesBase + types.IntervalWindow + PercentageVolatility floats.Slice + + PreviousClose float64 + RMA *RMA + + EndTime time.Time + UpdateCallbacks []func(value float64) +} + +var _ types.SeriesExtend = &ATR{} + +func (inc *ATR) Clone() *ATR { + out := &ATR{ + IntervalWindow: inc.IntervalWindow, + PercentageVolatility: inc.PercentageVolatility[:], + PreviousClose: inc.PreviousClose, + RMA: inc.RMA.Clone().(*RMA), + EndTime: inc.EndTime, + } + out.SeriesBase.Series = out + return out +} + +func (inc *ATR) TestUpdate(high, low, cloze float64) *ATR { + c := inc.Clone() + c.Update(high, low, cloze) + return c +} + +func (inc *ATR) Update(high, low, cloze float64) { + if inc.Window <= 0 { + panic("window must be greater than 0") + } + + if inc.RMA == nil { + inc.SeriesBase.Series = inc + inc.RMA = &RMA{ + IntervalWindow: types.IntervalWindow{Window: inc.Window}, + Adjust: true, + } + inc.PreviousClose = cloze + return + } + + // calculate true range + trueRange := high - low + hc := math.Abs(high - inc.PreviousClose) + lc := math.Abs(low - inc.PreviousClose) + if trueRange < hc { + trueRange = hc + } + if trueRange < lc { + trueRange = lc + } + + inc.PreviousClose = cloze + + // apply rolling moving average + inc.RMA.Update(trueRange) + atr := inc.RMA.Last(0) + inc.PercentageVolatility.Push(atr / cloze) + if len(inc.PercentageVolatility) > MaxNumOfATR { + inc.PercentageVolatility = inc.PercentageVolatility[MaxNumOfATRTruncateSize-1:] + } +} + +func (inc *ATR) Last(i int) float64 { + if inc.RMA == nil { + return 0 + } + return inc.RMA.Last(i) +} + +func (inc *ATR) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *ATR) Length() int { + if inc.RMA == nil { + return 0 + } + + return inc.RMA.Length() +} + +func (inc *ATR) PushK(k types.KLine) { + if inc.EndTime != zeroTime && !k.EndTime.After(inc.EndTime) { + return + } + + inc.Update(k.High.Float64(), k.Low.Float64(), k.Close.Float64()) + inc.EndTime = k.EndTime.Time() + inc.EmitUpdate(inc.Last(0)) +} diff --git a/pkg/indicator/atr_callbacks.go b/pkg/indicator/atr_callbacks.go new file mode 100644 index 0000000..addc133 --- /dev/null +++ b/pkg/indicator/atr_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type ATR"; DO NOT EDIT. + +package indicator + +import () + +func (inc *ATR) OnUpdate(cb func(value float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *ATR) EmitUpdate(value float64) { + for _, cb := range inc.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/atr_test.go b/pkg/indicator/atr_test.go new file mode 100644 index 0000000..9c50aa8 --- /dev/null +++ b/pkg/indicator/atr_test.go @@ -0,0 +1,76 @@ +package indicator + +import ( + "encoding/json" + "math" + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +/* +python + +import pandas as pd +import pandas_ta as ta + + data = { + "high": [40145.0, 40186.36, 40196.39, 40344.6, 40245.48, 40273.24, 40464.0, 40699.0, 40627.48, 40436.31, 40370.0, 40376.8, 40227.03, 40056.52, 39721.7, 39597.94, 39750.15, 39927.0, 40289.02, 40189.0], + "low": [39870.71, 39834.98, 39866.31, 40108.31, 40016.09, 40094.66, 40105.0, 40196.48, 40154.99, 39800.0, 39959.21, 39922.98, 39940.02, 39632.0, 39261.39, 39254.63, 39473.91, 39555.51, 39819.0, 40006.84], + "close": [40105.78, 39935.23, 40183.97, 40182.03, 40212.26, 40149.99, 40378.0, 40618.37, 40401.03, 39990.39, 40179.13, 40097.23, 40014.72, 39667.85, 39303.1, 39519.99, + +39693.79, 39827.96, 40074.94, 40059.84] +} + +high = pd.Series(data['high']) +low = pd.Series(data['low']) +close = pd.Series(data['close']) +result = ta.atr(high, low, close, length=14) +print(result) +*/ +func Test_calculateATR(t *testing.T) { + var bytes = []byte(`{ + "high": [40145.0, 40186.36, 40196.39, 40344.6, 40245.48, 40273.24, 40464.0, 40699.0, 40627.48, 40436.31, 40370.0, 40376.8, 40227.03, 40056.52, 39721.7, 39597.94, 39750.15, 39927.0, 40289.02, 40189.0], + "low": [39870.71, 39834.98, 39866.31, 40108.31, 40016.09, 40094.66, 40105.0, 40196.48, 40154.99, 39800.0, 39959.21, 39922.98, 39940.02, 39632.0, 39261.39, 39254.63, 39473.91, 39555.51, 39819.0, 40006.84], + "close": [40105.78, 39935.23, 40183.97, 40182.03, 40212.26, 40149.99, 40378.0, 40618.37, 40401.03, 39990.39, 40179.13, 40097.23, 40014.72, 39667.85, 39303.1, 39519.99, 39693.79, 39827.96, 40074.94, 40059.84] + }`) + buildKLines := func(bytes []byte) (kLines []types.KLine) { + var prices map[string][]fixedpoint.Value + _ = json.Unmarshal(bytes, &prices) + for i, h := range prices["high"] { + kLine := types.KLine{High: h, Low: prices["low"][i], Close: prices["close"][i]} + kLines = append(kLines, kLine) + } + return kLines + } + + tests := []struct { + name string + kLines []types.KLine + window int + want float64 + }{ + { + name: "test_binance_btcusdt_1h", + kLines: buildKLines(bytes), + window: 14, + want: 367.913903, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + atr := &ATR{IntervalWindow: types.IntervalWindow{Window: tt.window}} + for _, k := range tt.kLines { + atr.PushK(k) + } + + got := atr.Last(0) + diff := math.Trunc((got-tt.want)*100) / 100 + if diff != 0 { + t.Errorf("calculateATR() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/indicator/atrp.go b/pkg/indicator/atrp.go new file mode 100644 index 0000000..c6175e4 --- /dev/null +++ b/pkg/indicator/atrp.go @@ -0,0 +1,123 @@ +package indicator + +import ( + "math" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// ATRP is the average true range percentage +// See also https://www.fidelity.com/learning-center/trading-investing/technical-analysis/technical-indicator-guide/atrp +// +// The Average True Range Percentage (ATRP) is a technical analysis indicator that measures the volatility of a security's price. It is +// calculated by dividing the Average True Range (ATR) of the security by its closing price, and then multiplying the result by 100 to convert +// it to a percentage. The ATR is a measure of the range of a security's price, taking into account gaps between trading periods and any limit +// moves (sharp price movements that are allowed under certain exchange rules). The ATR is typically smoothed using a moving average to make it +// more responsive to changes in the underlying price data. The ATRP is a useful indicator for traders because it provides a way to compare the +// volatility of different securities, regardless of their individual prices. It can also be used to identify potential entry and exit points +// for trades based on changes in the security's volatility. +// +// Calculation: +// +// ATRP = (Average True Range / Close) * 100 +// +//go:generate callbackgen -type ATRP +type ATRP struct { + types.SeriesBase + types.IntervalWindow + PercentageVolatility floats.Slice + + PreviousClose float64 + RMA *RMA + + EndTime time.Time + UpdateCallbacks []func(value float64) +} + +func (inc *ATRP) Update(high, low, cloze float64) { + if inc.Window <= 0 { + panic("window must be greater than 0") + } + + if inc.RMA == nil { + inc.SeriesBase.Series = inc + inc.RMA = &RMA{ + IntervalWindow: types.IntervalWindow{Window: inc.Window}, + Adjust: true, + } + inc.PreviousClose = cloze + return + } + + // calculate true range + trueRange := high - low + hc := math.Abs(high - inc.PreviousClose) + lc := math.Abs(low - inc.PreviousClose) + if trueRange < hc { + trueRange = hc + } + if trueRange < lc { + trueRange = lc + } + + // Note: this is the difference from ATR + trueRange = trueRange / inc.PreviousClose * 100.0 + + inc.PreviousClose = cloze + + // apply rolling moving average + inc.RMA.Update(trueRange) + atr := inc.RMA.Last(0) + inc.PercentageVolatility.Push(atr / cloze) +} + +func (inc *ATRP) Last(i int) float64 { + if inc.RMA == nil { + return 0 + } + return inc.RMA.Last(i) +} + +func (inc *ATRP) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *ATRP) Length() int { + if inc.RMA == nil { + return 0 + } + return inc.RMA.Length() +} + +var _ types.SeriesExtend = &ATRP{} + +func (inc *ATRP) PushK(k types.KLine) { + inc.Update(k.High.Float64(), k.Low.Float64(), k.Close.Float64()) +} + +func (inc *ATRP) CalculateAndUpdate(kLines []types.KLine) { + for _, k := range kLines { + if inc.EndTime != zeroTime && !k.EndTime.After(inc.EndTime) { + continue + } + + inc.PushK(k) + } + + inc.EmitUpdate(inc.Last(0)) + inc.EndTime = kLines[len(kLines)-1].EndTime.Time() +} + +func (inc *ATRP) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *ATRP) Bind(updater KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} diff --git a/pkg/indicator/atrp_callbacks.go b/pkg/indicator/atrp_callbacks.go new file mode 100644 index 0000000..daaba83 --- /dev/null +++ b/pkg/indicator/atrp_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type ATRP"; DO NOT EDIT. + +package indicator + +import () + +func (inc *ATRP) OnUpdate(cb func(value float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *ATRP) EmitUpdate(value float64) { + for _, cb := range inc.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/boll.go b/pkg/indicator/boll.go new file mode 100644 index 0000000..67bfb0c --- /dev/null +++ b/pkg/indicator/boll.go @@ -0,0 +1,143 @@ +package indicator + +import ( + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +/* +boll implements the bollinger indicator: + +The Basics of Bollinger Bands +- https://www.investopedia.com/articles/technical/102201.asp + +Bollinger Bands +- https://www.investopedia.com/terms/b/bollingerbands.asp + +Bollinger Bands Technical indicator guide: +- https://www.fidelity.com/learning-center/trading-investing/technical-analysis/technical-indicator-guide/bollinger-bands +*/ + +//go:generate callbackgen -type BOLL +type BOLL struct { + types.IntervalWindow + + // K is the multiplier of Std, generally it's 2 + K float64 + + SMA *SMA + StdDev *StdDev + + UpBand floats.Slice + DownBand floats.Slice + + EndTime time.Time + + updateCallbacks []func(sma, upBand, downBand float64) +} + +type BandType int + +func (inc *BOLL) GetUpBand() types.SeriesExtend { + return types.NewSeries(&inc.UpBand) +} + +func (inc *BOLL) GetDownBand() types.SeriesExtend { + return types.NewSeries(&inc.DownBand) +} + +func (inc *BOLL) GetSMA() types.SeriesExtend { + return types.NewSeries(inc.SMA) +} + +func (inc *BOLL) GetStdDev() types.SeriesExtend { + return inc.StdDev +} + +func (inc *BOLL) LastUpBand() float64 { + if len(inc.UpBand) == 0 { + return 0.0 + } + + return inc.UpBand[len(inc.UpBand)-1] +} + +func (inc *BOLL) LastDownBand() float64 { + if len(inc.DownBand) == 0 { + return 0.0 + } + + return inc.DownBand[len(inc.DownBand)-1] +} + +func (inc *BOLL) Update(value float64) { + if inc.SMA == nil { + inc.SMA = &SMA{IntervalWindow: inc.IntervalWindow} + } + + if inc.StdDev == nil { + inc.StdDev = &StdDev{IntervalWindow: inc.IntervalWindow} + } + + inc.SMA.Update(value) + inc.StdDev.Update(value) + + var sma = inc.SMA.Last(0) + var stdDev = inc.StdDev.Last(0) + var band = inc.K * stdDev + + var upBand = sma + band + var downBand = sma - band + + inc.UpBand.Push(upBand) + inc.DownBand.Push(downBand) +} + +func (inc *BOLL) BindK(target KLineClosedEmitter, symbol string, interval types.Interval) { + target.OnKLineClosed(types.KLineWith(symbol, interval, inc.PushK)) +} + +func (inc *BOLL) PushK(k types.KLine) { + if inc.EndTime != zeroTime && k.EndTime.Before(inc.EndTime) { + return + } + inc.Update(k.Close.Float64()) + inc.EndTime = k.EndTime.Time() + inc.EmitUpdate(inc.SMA.Last(0), inc.UpBand.Last(0), inc.DownBand.Last(0)) +} + +func (inc *BOLL) LoadK(allKLines []types.KLine) { + for _, k := range allKLines { + inc.PushK(k) + } + + inc.EmitUpdate(inc.SMA.Last(0), inc.UpBand.Last(0), inc.DownBand.Last(0)) +} + +func (inc *BOLL) CalculateAndUpdate(allKLines []types.KLine) { + if inc.SMA == nil { + inc.LoadK(allKLines) + return + } + + var last = allKLines[len(allKLines)-1] + inc.PushK(last) +} + +func (inc *BOLL) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + if inc.EndTime != zeroTime && inc.EndTime.Before(inc.EndTime) { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *BOLL) Bind(updater KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} diff --git a/pkg/indicator/boll_callbacks.go b/pkg/indicator/boll_callbacks.go new file mode 100644 index 0000000..cafe0bc --- /dev/null +++ b/pkg/indicator/boll_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type BOLL"; DO NOT EDIT. + +package indicator + +import () + +func (inc *BOLL) OnUpdate(cb func(sma float64, upBand float64, downBand float64)) { + inc.updateCallbacks = append(inc.updateCallbacks, cb) +} + +func (inc *BOLL) EmitUpdate(sma float64, upBand float64, downBand float64) { + for _, cb := range inc.updateCallbacks { + cb(sma, upBand, downBand) + } +} diff --git a/pkg/indicator/boll_test.go b/pkg/indicator/boll_test.go new file mode 100644 index 0000000..b09ddf8 --- /dev/null +++ b/pkg/indicator/boll_test.go @@ -0,0 +1,69 @@ +package indicator + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +/* +python: + +import numpy as np +import pandas as pd + +np.random.seed(1) + +window = 14 +n = 100 + +s = pd.Series(10 + np.sin(2 * np.pi * np.arange(n) / n) + np.random.rand(n)) +print(s.tolist()) + +std = s.rolling(window).std() +ma = s.rolling(window).mean() + +boll_up = ma + std +boll_down = ma - std +print(boll_up) +print(boll_down) +*/ +func TestBOLL(t *testing.T) { + var Delta = 4e-2 + var randomPrices = []byte(`[10.417022004702574, 10.783115012971471, 10.12544760838165, 10.489713887217565, 10.395445777981967, 10.401355589143744, 10.55438476406235, 10.77134001860812, 10.878521148332386, 11.074643528982353, 11.006979766695768, 11.322643490145449, 10.888999355660205, 11.607086063812357, 10.797900835973715, 11.47948450455335, 11.261632727869141, 11.434996508489617, 11.045213991061253, 11.12787797497313, 11.75180108497069, 11.936844736848029, 11.295711428887932, 11.684437316983791, 11.87441588072431, 11.894606663503847, 11.08307093979805, 11.03116948454736, 11.152117670293258, 11.846725664558043, 11.049403350128204, 11.350884110893302, 11.862716582616521, 11.40947196501688, 11.536205039452488, 11.12453262538101, 11.457014170457374, 11.563594299318783, 10.70283538327288, 11.387568304693657, 11.576646341198968, 11.283992449358836, 10.76219766616612, 11.215058620016562, 10.471350559262321, 10.756910520550854, 11.157285390257952, 10.480995462959404, 10.413108572150653, 10.192819091647591, 10.019366957870297, 10.616045013410577, 10.086294882435753, 10.078165344786502, 10.242883272115485, 9.744345550742134, 10.205993052807335, 9.720949283340737, 10.107551862801568, 10.163931565041935, 9.514549176535352, 9.776631998070878, 10.009853051799057, 9.685210642105492, 9.279440216170297, 9.726879411540565, 9.819466719717774, 9.638582432014445, 10.039767703524793, 9.656778554613743, 9.95234539899273, 9.168891543017606, 9.15698909652207, 9.815276587395047, 9.399650108557262, 9.165354197116933, 9.929481851967761, 9.355651158431028, 9.768524852407467, 9.75741482422182, 9.932249574910657, 9.693895721167358, 9.846115381561317, 9.47259166193398, 9.425599966263011, 10.086869223821118, 9.657577947095504, 10.235871419726973, 9.97889439188976, 9.984271730460431, 9.526960720660902, 10.413662463728075, 9.968158459378225, 10.152610322822058, 10.040012250076602, 9.92800998586808, 10.654689633397398, 10.386298172086562, 9.877537093466854, 10.55435439409141]`) + var input []fixedpoint.Value + if err := json.Unmarshal(randomPrices, &input); err != nil { + panic(err) + } + tests := []struct { + name string + kLines []types.KLine + window int + k float64 + up float64 + down float64 + }{ + { + name: "random_case", + kLines: buildKLines(input), + window: 14, + k: 1, + up: 10.421434, + down: 9.772696, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + boll := BOLL{IntervalWindow: types.IntervalWindow{Window: tt.window}, K: tt.k} + boll.CalculateAndUpdate(tt.kLines) + assert.InDelta(t, tt.up, boll.UpBand.Last(0), Delta) + assert.InDelta(t, tt.down, boll.DownBand.Last(0), Delta) + }) + } + +} diff --git a/pkg/indicator/ca_callbacks.go b/pkg/indicator/ca_callbacks.go new file mode 100644 index 0000000..d4b9999 --- /dev/null +++ b/pkg/indicator/ca_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type CA"; DO NOT EDIT. + +package indicator + +import () + +func (inc *CA) OnUpdate(cb func(value float64)) { + inc.updateCallbacks = append(inc.updateCallbacks, cb) +} + +func (inc *CA) EmitUpdate(value float64) { + for _, cb := range inc.updateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/cci.go b/pkg/indicator/cci.go new file mode 100644 index 0000000..77ed7a4 --- /dev/null +++ b/pkg/indicator/cci.go @@ -0,0 +1,108 @@ +package indicator + +import ( + "math" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// Refer: Commodity Channel Index +// Refer URL: http://www.andrewshamlet.net/2017/07/08/python-tutorial-cci +// with modification of ddof=0 to let standard deviation to be divided by N instead of N-1 +// +// CCI = (Typical Price - n-period SMA of TP) / (Constant x Mean Deviation) +// +// Typical Price (TP) = (High + Low + Close)/3 +// +// Constant = .015 +// +// The Commodity Channel Index (CCI) is a technical analysis indicator that is used to identify potential overbought or oversold conditions +// in a security's price. It was originally developed for use in commodity markets, but can be applied to any security that has a sufficient +// amount of price data. The CCI is calculated by taking the difference between the security's typical price (the average of its high, low, and +// closing prices) and its moving average, and then dividing the result by the mean absolute deviation of the typical price. This resulting value +// is then plotted as a line on the price chart, with values above +100 indicating overbought conditions and values below -100 indicating +// oversold conditions. The CCI can be used by traders to identify potential entry and exit points for trades, or to confirm other technical +// analysis signals. + +//go:generate callbackgen -type CCI +type CCI struct { + types.SeriesBase + types.IntervalWindow + Input floats.Slice + TypicalPrice floats.Slice + MA floats.Slice + Values floats.Slice + + UpdateCallbacks []func(value float64) +} + +func (inc *CCI) Update(value float64) { + if len(inc.TypicalPrice) == 0 { + inc.SeriesBase.Series = inc + inc.TypicalPrice.Push(value) + inc.Input.Push(value) + return + } else if len(inc.TypicalPrice) > MaxNumOfEWMA { + inc.TypicalPrice = inc.TypicalPrice[MaxNumOfEWMATruncateSize-1:] + inc.Input = inc.Input[MaxNumOfEWMATruncateSize-1:] + } + + inc.Input.Push(value) + tp := inc.TypicalPrice.Last(0) - inc.Input.Last(inc.Window) + value + inc.TypicalPrice.Push(tp) + if len(inc.Input) < inc.Window { + return + } + + ma := tp / float64(inc.Window) + inc.MA.Push(ma) + if len(inc.MA) > MaxNumOfEWMA { + inc.MA = inc.MA[MaxNumOfEWMATruncateSize-1:] + } + + md := 0. + for i := 0; i < inc.Window; i++ { + diff := inc.Input.Last(i) - ma + md += diff * diff + } + md = math.Sqrt(md / float64(inc.Window)) + + cci := (value - ma) / (0.015 * md) + + inc.Values.Push(cci) + if len(inc.Values) > MaxNumOfEWMA { + inc.Values = inc.Values[MaxNumOfEWMATruncateSize-1:] + } +} + +func (inc *CCI) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *CCI) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *CCI) Length() int { + return len(inc.Values) +} + +var _ types.SeriesExtend = &CCI{} + +func (inc *CCI) PushK(k types.KLine) { + inc.Update(k.High.Add(k.Low).Add(k.Close).Div(three).Float64()) +} + +func (inc *CCI) CalculateAndUpdate(allKLines []types.KLine) { + if inc.TypicalPrice.Length() == 0 { + for _, k := range allKLines { + inc.PushK(k) + inc.EmitUpdate(inc.Last(0)) + } + } else { + k := allKLines[len(allKLines)-1] + inc.PushK(k) + inc.EmitUpdate(inc.Last(0)) + } +} diff --git a/pkg/indicator/cci_callbacks.go b/pkg/indicator/cci_callbacks.go new file mode 100644 index 0000000..52251a1 --- /dev/null +++ b/pkg/indicator/cci_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type CCI"; DO NOT EDIT. + +package indicator + +import () + +func (inc *CCI) OnUpdate(cb func(value float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *CCI) EmitUpdate(value float64) { + for _, cb := range inc.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/cci_test.go b/pkg/indicator/cci_test.go new file mode 100644 index 0000000..476f142 --- /dev/null +++ b/pkg/indicator/cci_test.go @@ -0,0 +1,38 @@ +package indicator + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +/* +python: + +import pandas as pd +s = pd.Series([0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9]) +cci = pd.Series((s - s.rolling(16).mean()) / (0.015 * s.rolling(16).std(ddof=0)), name="CCI") +print(cci) +*/ +func Test_CCI(t *testing.T) { + var randomPrices = []byte(`[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]`) + var input []float64 + var delta = 4.3e-2 + if err := json.Unmarshal(randomPrices, &input); err != nil { + panic(err) + } + t.Run("random_case", func(t *testing.T) { + cci := CCI{IntervalWindow: types.IntervalWindow{Window: 16}} + for _, value := range input { + cci.Update(value) + } + + last := cci.Last(0) + assert.InDelta(t, 93.250481, last, delta) + assert.InDelta(t, 81.813449, cci.Index(1), delta) + assert.Equal(t, 50-16+1, cci.Length()) + }) +} diff --git a/pkg/indicator/cma.go b/pkg/indicator/cma.go new file mode 100644 index 0000000..ad86dae --- /dev/null +++ b/pkg/indicator/cma.go @@ -0,0 +1,57 @@ +package indicator + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// Refer: Cumulative Moving Average, Cumulative Average +// Refer: https://en.wikipedia.org/wiki/Moving_average +// +//go:generate callbackgen -type CA +type CA struct { + types.SeriesBase + Interval types.Interval + Values floats.Slice + length float64 + updateCallbacks []func(value float64) +} + +func (inc *CA) Update(x float64) { + if len(inc.Values) == 0 { + inc.SeriesBase.Series = inc + } + + newVal := (inc.Values.Last(0)*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)) + } +} + +func (inc *CA) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *CA) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *CA) Length() int { + return len(inc.Values) +} + +var _ types.SeriesExtend = &CA{} + +func (inc *CA) PushK(k types.KLine) { + inc.Update(k.Close.Float64()) +} + +func (inc *CA) CalculateAndUpdate(allKLines []types.KLine) { + for _, k := range allKLines { + inc.PushK(k) + inc.EmitUpdate(inc.Last(0)) + } +} diff --git a/pkg/indicator/const.go b/pkg/indicator/const.go new file mode 100644 index 0000000..22e50c7 --- /dev/null +++ b/pkg/indicator/const.go @@ -0,0 +1,11 @@ +package indicator + +import ( + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +var three = fixedpoint.NewFromInt(3) + +var zeroTime = time.Time{} diff --git a/pkg/indicator/dema.go b/pkg/indicator/dema.go new file mode 100644 index 0000000..c038781 --- /dev/null +++ b/pkg/indicator/dema.go @@ -0,0 +1,103 @@ +package indicator + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// Refer: Double Exponential Moving Average +// Refer URL: https://investopedia.com/terms/d/double-exponential-moving-average.asp +// +// The Double Exponential Moving Average (DEMA) is a technical analysis indicator that is used to smooth price data and reduce the lag +// associated with traditional moving averages. It is calculated by taking the exponentially weighted moving average of the input data, +// and then taking the exponentially weighted moving average of that result. This double-smoothing process helps to eliminate much of the noise +// in the original data and provides a more accurate representation of the underlying trend. The DEMA line is then plotted on the price chart, +// which can be used to make predictions about future price movements. The DEMA is typically more responsive to changes in the underlying data +// than a simple moving average, but may be less reliable in trending markets. + +//go:generate callbackgen -type DEMA +type DEMA struct { + types.IntervalWindow + types.SeriesBase + Values floats.Slice + a1 *EWMA + a2 *EWMA + + UpdateCallbacks []func(value float64) +} + +func (inc *DEMA) Clone() *DEMA { + out := &DEMA{ + IntervalWindow: inc.IntervalWindow, + Values: inc.Values[:], + a1: inc.a1.Clone(), + a2: inc.a2.Clone(), + } + out.SeriesBase.Series = out + return out +} + +func (inc *DEMA) TestUpdate(value float64) *DEMA { + out := inc.Clone() + out.Update(value) + return out +} + +func (inc *DEMA) Update(value float64) { + if len(inc.Values) == 0 { + inc.SeriesBase.Series = inc + inc.a1 = &EWMA{IntervalWindow: inc.IntervalWindow} + inc.a2 = &EWMA{IntervalWindow: inc.IntervalWindow} + } + + inc.a1.Update(value) + inc.a2.Update(inc.a1.Last(0)) + inc.Values.Push(2*inc.a1.Last(0) - inc.a2.Last(0)) + if len(inc.Values) > MaxNumOfEWMA { + inc.Values = inc.Values[MaxNumOfEWMATruncateSize-1:] + } +} + +func (inc *DEMA) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *DEMA) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *DEMA) Length() int { + return len(inc.Values) +} + +var _ types.SeriesExtend = &DEMA{} + +func (inc *DEMA) PushK(k types.KLine) { + inc.Update(k.Close.Float64()) +} + +func (inc *DEMA) CalculateAndUpdate(allKLines []types.KLine) { + if inc.a1 == nil { + for _, k := range allKLines { + inc.PushK(k) + inc.EmitUpdate(inc.Last(0)) + } + } else { + // last k + k := allKLines[len(allKLines)-1] + inc.PushK(k) + inc.EmitUpdate(inc.Last(0)) + } +} + +func (inc *DEMA) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *DEMA) Bind(updater KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} diff --git a/pkg/indicator/dema_callbacks.go b/pkg/indicator/dema_callbacks.go new file mode 100644 index 0000000..e7c4f66 --- /dev/null +++ b/pkg/indicator/dema_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type DEMA"; DO NOT EDIT. + +package indicator + +import () + +func (inc *DEMA) OnUpdate(cb func(value float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *DEMA) EmitUpdate(value float64) { + for _, cb := range inc.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/dema_test.go b/pkg/indicator/dema_test.go new file mode 100644 index 0000000..842ff93 --- /dev/null +++ b/pkg/indicator/dema_test.go @@ -0,0 +1,55 @@ +package indicator + +import ( + "encoding/json" + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +/* +python: + +import pandas as pd +s = pd.Series([0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9]) +ma1 = s.ewm(span=16).mean() +ma2 = ma1.ewm(span=16).mean() +result = (2 * ma1 - ma2) +print(result) +*/ +func Test_DEMA(t *testing.T) { + var Delta = 4e-2 + var randomPrices = []byte(`[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]`) + var input []fixedpoint.Value + if err := json.Unmarshal(randomPrices, &input); err != nil { + panic(err) + } + tests := []struct { + name string + kLines []types.KLine + want float64 + next float64 + all int + }{ + { + name: "random_case", + kLines: buildKLines(input), + want: 6.420838, + next: 5.609367, + all: 50, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dema := DEMA{IntervalWindow: types.IntervalWindow{Window: 16}} + dema.CalculateAndUpdate(tt.kLines) + last := dema.Last(0) + assert.InDelta(t, tt.want, last, Delta) + assert.InDelta(t, tt.next, dema.Index(1), Delta) + assert.Equal(t, tt.all, dema.Length()) + }) + } +} diff --git a/pkg/indicator/dmi.go b/pkg/indicator/dmi.go new file mode 100644 index 0000000..05dab9e --- /dev/null +++ b/pkg/indicator/dmi.go @@ -0,0 +1,117 @@ +package indicator + +import ( + "math" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// Refer: https://www.investopedia.com/terms/d/dmi.asp +// Refer: https://github.com/twopirllc/pandas-ta/blob/main/pandas_ta/trend/adx.py +// +// Directional Movement Index +// +// The Directional Movement Index (DMI) is a technical analysis indicator that is used to identify the direction and strength of a trend +// in a security's price. It was developed by J. Welles Wilder and is based on the concept of the +DI and -DI lines, which measure the strength +// of upward and downward price movements, respectively. The DMI is calculated by taking the difference between the +DI and -DI lines, and then +// smoothing the result using a moving average. This resulting line is called the Average Directional Index (ADX), and is used to identify whether +// a security is trending or not. If the ADX is above a certain threshold, typically 20, it indicates that the security is in a strong trend, +// and if it is below that threshold it indicates that the security is in a sideways or choppy market. The DMI can be used by traders to confirm +// the direction and strength of a trend, or to identify potential entry and exit points for trades. + +//go:generate callbackgen -type DMI +type DMI struct { + types.IntervalWindow + + ADXSmoothing int + atr *ATR + DMP types.UpdatableSeriesExtend + DMN types.UpdatableSeriesExtend + DIPlus *types.Queue + DIMinus *types.Queue + ADX types.UpdatableSeriesExtend + PrevHigh, PrevLow float64 + + updateCallbacks []func(diplus, diminus, adx float64) +} + +func (inc *DMI) Update(high, low, cloze float64) { + if inc.DMP == nil || inc.DMN == nil { + inc.DMP = &RMA{IntervalWindow: inc.IntervalWindow, Adjust: true} + inc.DMN = &RMA{IntervalWindow: inc.IntervalWindow, Adjust: true} + inc.ADX = &RMA{IntervalWindow: types.IntervalWindow{Window: inc.ADXSmoothing}, Adjust: true} + } + + if inc.atr == nil { + inc.atr = &ATR{IntervalWindow: inc.IntervalWindow} + inc.atr.Update(high, low, cloze) + inc.PrevHigh = high + inc.PrevLow = low + inc.DIPlus = types.NewQueue(500) + inc.DIMinus = types.NewQueue(500) + return + } + + inc.atr.Update(high, low, cloze) + up := high - inc.PrevHigh + dn := inc.PrevLow - low + inc.PrevHigh = high + inc.PrevLow = low + pos := 0.0 + if up > dn && up > 0. { + pos = up + } + + neg := 0.0 + if dn > up && dn > 0. { + neg = dn + } + + inc.DMP.Update(pos) + inc.DMN.Update(neg) + if inc.atr.Length() < inc.Window { + return + } + k := 100. / inc.atr.Last(0) + dmp := inc.DMP.Last(0) + dmn := inc.DMN.Last(0) + inc.DIPlus.Update(k * dmp) + inc.DIMinus.Update(k * dmn) + dx := 100. * math.Abs(dmp-dmn) / (dmp + dmn) + inc.ADX.Update(dx) + +} + +func (inc *DMI) GetDIPlus() types.SeriesExtend { + return inc.DIPlus +} + +func (inc *DMI) GetDIMinus() types.SeriesExtend { + return inc.DIMinus +} + +func (inc *DMI) GetADX() types.SeriesExtend { + return inc.ADX +} + +func (inc *DMI) Length() int { + return inc.ADX.Length() +} + +func (inc *DMI) PushK(k types.KLine) { + inc.Update(k.High.Float64(), k.Low.Float64(), k.Close.Float64()) +} + +func (inc *DMI) CalculateAndUpdate(allKLines []types.KLine) { + last := allKLines[len(allKLines)-1] + + if inc.ADX == nil { + for _, k := range allKLines { + inc.PushK(k) + inc.EmitUpdate(inc.DIPlus.Last(0), inc.DIMinus.Last(0), inc.ADX.Last(0)) + } + } else { + inc.PushK(last) + inc.EmitUpdate(inc.DIPlus.Last(0), inc.DIMinus.Last(0), inc.ADX.Last(0)) + } +} diff --git a/pkg/indicator/dmi_callbacks.go b/pkg/indicator/dmi_callbacks.go new file mode 100644 index 0000000..ed84539 --- /dev/null +++ b/pkg/indicator/dmi_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type DMI"; DO NOT EDIT. + +package indicator + +import () + +func (inc *DMI) OnUpdate(cb func(diplus float64, diminus float64, adx float64)) { + inc.updateCallbacks = append(inc.updateCallbacks, cb) +} + +func (inc *DMI) EmitUpdate(diplus float64, diminus float64, adx float64) { + for _, cb := range inc.updateCallbacks { + cb(diplus, diminus, adx) + } +} diff --git a/pkg/indicator/dmi_test.go b/pkg/indicator/dmi_test.go new file mode 100644 index 0000000..7f049f2 --- /dev/null +++ b/pkg/indicator/dmi_test.go @@ -0,0 +1,87 @@ +package indicator + +import ( + "encoding/json" + "fmt" + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +/* +python: + +import pandas as pd +import pandas_ta as ta + +data = pd.Series([0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9]) + +high = pd.Series([100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109]) + +low = pd.Series([80,81,82,83,84,85,86,87,88,89,80,81,82,83,84,85,86,87,88,89,80,81,82,83,84,85,86,87,88,89]) + +close = pd.Series([90,91,92,93,94,95,96,97,98,99,90,91,92,93,94,95,96,97,98,99,90,91,92,93,94,95,96,97,98,99]) + +result = ta.adx(high, low, close, 5, 14) +print(result['ADX_14']) + +print(result['DMP_5']) +print(result['DMN_5']) +*/ +func Test_DMI(t *testing.T) { + var Delta = 0.001 + var highb = []byte(`[100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109]`) + var lowb = []byte(`[80,81,82,83,84,85,86,87,88,89,80,81,82,83,84,85,86,87,88,89,80,81,82,83,84,85,86,87,88,89]`) + var clozeb = []byte(`[90,91,92,93,94,95,96,97,98,99,90,91,92,93,94,95,96,97,98,99,90,91,92,93,94,95,96,97,98,99]`) + + buildKLines := func(h, l, c []byte) (klines []types.KLine) { + var hv, cv, lv []fixedpoint.Value + _ = json.Unmarshal(h, &hv) + _ = json.Unmarshal(l, &lv) + _ = json.Unmarshal(c, &cv) + if len(hv) != len(lv) || len(lv) != len(cv) { + panic(fmt.Sprintf("length not equal %v %v %v", len(hv), len(lv), len(cv))) + } + for i, hh := range hv { + kline := types.KLine{High: hh, Low: lv[i], Close: cv[i]} + klines = append(klines, kline) + } + return klines + } + + type output struct { + dip float64 + dim float64 + adx float64 + } + + tests := []struct { + name string + klines []types.KLine + want output + next output + total int + }{ + { + name: "test_dmi", + klines: buildKLines(highb, lowb, clozeb), + want: output{dip: 4.85114, dim: 1.339736, adx: 37.857156}, + next: output{dip: 4.813853, dim: 1.67532, adx: 36.111434}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dmi := &DMI{ + IntervalWindow: types.IntervalWindow{Window: 5}, + ADXSmoothing: 14, + } + dmi.CalculateAndUpdate(tt.klines) + assert.InDelta(t, dmi.GetDIPlus().Last(0), tt.want.dip, Delta) + assert.InDelta(t, dmi.GetDIMinus().Last(0), tt.want.dim, Delta) + assert.InDelta(t, dmi.GetADX().Last(0), tt.want.adx, Delta) + }) + } + +} diff --git a/pkg/indicator/drift.go b/pkg/indicator/drift.go new file mode 100644 index 0000000..3659c3c --- /dev/null +++ b/pkg/indicator/drift.go @@ -0,0 +1,142 @@ +package indicator + +import ( + "math" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// Refer: https://tradingview.com/script/aDymGrFx-Drift-Study-Inspired-by-Monte-Carlo-Simulations-with-BM-KL/ +// Brownian Motion's drift factor +// could be used in Monte Carlo Simulations +// +// In the context of Brownian motion, drift can be measured by calculating the simple moving average (SMA) of the logarithm +// of the price changes of a security over a specified period of time. This SMA can be used to identify the long-term trend +// or bias in the random movement of the security's price. A security with a positive drift is said to be trending upwards, +// while a security with a negative drift is said to be trending downwards. Drift can be used by traders to identify potential +// entry and exit points for trades, or to confirm other technical analysis signals. +// It is typically used in conjunction with other indicators to provide a more comprehensive view of the security's price. + +//go:generate callbackgen -type Drift +type Drift struct { + types.SeriesBase + types.IntervalWindow + chng *types.Queue + Values floats.Slice + MA types.UpdatableSeriesExtend + LastValue float64 + + UpdateCallbacks []func(value float64) +} + +func (inc *Drift) Update(value float64) { + if inc.chng == nil { + inc.SeriesBase.Series = inc + if inc.MA == nil { + inc.MA = &SMA{IntervalWindow: types.IntervalWindow{Interval: inc.Interval, Window: inc.Window}} + } + inc.chng = types.NewQueue(inc.Window) + inc.LastValue = value + return + } + var chng float64 + if value == 0 { + chng = 0 + } else { + chng = math.Log(value / inc.LastValue) + inc.LastValue = value + } + inc.MA.Update(chng) + inc.chng.Update(chng) + if inc.chng.Length() >= inc.Window { + stdev := types.Stdev(inc.chng, inc.Window) + drift := inc.MA.Last(0) - stdev*stdev*0.5 + inc.Values.Push(drift) + } +} + +// Assume that MA is SMA +func (inc *Drift) ZeroPoint() float64 { + window := float64(inc.Window) + stdev := types.Stdev(inc.chng, inc.Window) + chng := inc.chng.Index(inc.Window - 1) + /*b := -2 * inc.MA.Last() - 2 + c := window * stdev * stdev - chng * chng + 2 * chng * (inc.MA.Last() + 1) - 2 * inc.MA.Last() * window + + root := math.Sqrt(b*b - 4*c) + K1 := (-b + root)/2 + K2 := (-b - root)/2 + N1 := math.Exp(K1) * inc.LastValue + N2 := math.Exp(K2) * inc.LastValue + if math.Abs(inc.LastValue-N1) < math.Abs(inc.LastValue-N2) { + return N1 + } else { + return N2 + }*/ + return inc.LastValue * math.Exp(window*(0.5*stdev*stdev)+chng-inc.MA.Last(0)*window) +} + +func (inc *Drift) Clone() (out *Drift) { + out = &Drift{ + IntervalWindow: inc.IntervalWindow, + chng: inc.chng.Clone(), + Values: inc.Values[:], + MA: types.Clone(inc.MA), + LastValue: inc.LastValue, + } + out.SeriesBase.Series = out + return out +} + +func (inc *Drift) TestUpdate(value float64) *Drift { + out := inc.Clone() + out.Update(value) + return out +} + +func (inc *Drift) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *Drift) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *Drift) Length() int { + if inc.Values == nil { + return 0 + } + return inc.Values.Length() +} + +var _ types.SeriesExtend = &Drift{} + +func (inc *Drift) PushK(k types.KLine) { + inc.Update(k.Close.Float64()) +} + +func (inc *Drift) CalculateAndUpdate(allKLines []types.KLine) { + if inc.chng == nil { + for _, k := range allKLines { + inc.PushK(k) + inc.EmitUpdate(inc.Last(0)) + } + } else { + k := allKLines[len(allKLines)-1] + inc.PushK(k) + inc.EmitUpdate(inc.Last(0)) + } +} + +func (inc *Drift) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *Drift) Bind(updater KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} diff --git a/pkg/indicator/drift_callbacks.go b/pkg/indicator/drift_callbacks.go new file mode 100644 index 0000000..224ef74 --- /dev/null +++ b/pkg/indicator/drift_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type Drift"; DO NOT EDIT. + +package indicator + +import () + +func (inc *Drift) OnUpdate(cb func(value float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *Drift) EmitUpdate(value float64) { + for _, cb := range inc.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/drift_test.go b/pkg/indicator/drift_test.go new file mode 100644 index 0000000..2065770 --- /dev/null +++ b/pkg/indicator/drift_test.go @@ -0,0 +1,40 @@ +package indicator + +import ( + "encoding/json" + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +func Test_Drift(t *testing.T) { + var randomPrices = []byte(`[1, 1, 2, 3, 4, 5, 6, 7, 8, 9, 4, 1, 2, 3, 4, 5, 6, 7, 8, 9, 4, 1, 2, 3, 4, 5, 6, 7, 8, 9, 1, 1, 2, 3, 4, 5, 6, 7, 8, 9, 4, 1, 2, 3, 4, 5, 6, 7, 8, 9]`) + var input []fixedpoint.Value + if err := json.Unmarshal(randomPrices, &input); err != nil { + panic(err) + } + tests := []struct { + name string + kLines []types.KLine + all int + }{ + { + name: "random_case", + kLines: buildKLines(input), + all: 47, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + drift := Drift{IntervalWindow: types.IntervalWindow{Window: 3}} + drift.CalculateAndUpdate(tt.kLines) + assert.Equal(t, drift.Length(), tt.all) + for _, v := range drift.Values { + assert.LessOrEqual(t, v, 1.0) + } + }) + } +} diff --git a/pkg/indicator/emv.go b/pkg/indicator/emv.go new file mode 100644 index 0000000..51546eb --- /dev/null +++ b/pkg/indicator/emv.go @@ -0,0 +1,74 @@ +package indicator + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// Refer: Ease of Movement +// Refer URL: https://www.investopedia.com/terms/e/easeofmovement.asp +// The Ease of Movement (EOM) is a technical analysis indicator that is used to measure the relationship between the volume of a security +// and its price movement. It is calculated by dividing the difference between the high and low prices of the security by the total +// volume of the security over a specified period of time, and then multiplying the result by a constant factor. This resulting value is +// then plotted on the price chart as a line, which can be used to make predictions about future price movements. The EOM is typically +// used to identify periods of high or low trading activity, and can be used to confirm other technical analysis signals. + +//go:generate callbackgen -type EMV +type EMV struct { + types.SeriesBase + types.IntervalWindow + + prevH float64 + prevL float64 + Values *SMA + EMVScale float64 + + UpdateCallbacks []func(value float64) +} + +const DefaultEMVScale float64 = 100000000. + +func (inc *EMV) Update(high, low, vol float64) { + if inc.EMVScale == 0 { + 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} + return + } + + distanceMoved := (high+low)/2. - (inc.prevH+inc.prevL)/2. + boxRatio := vol / inc.EMVScale / (high - low) + result := distanceMoved / boxRatio + inc.prevH = high + inc.prevL = low + inc.Values.Update(result) +} + +func (inc *EMV) Last(i int) float64 { + if inc.Values == nil { + return 0 + } + + return inc.Values.Last(i) +} + +func (inc *EMV) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *EMV) Length() int { + if inc.Values == nil { + return 0 + } + return inc.Values.Length() +} + +var _ types.SeriesExtend = &EMV{} + +func (inc *EMV) PushK(k types.KLine) { + inc.Update(k.High.Float64(), k.Low.Float64(), k.Volume.Float64()) +} diff --git a/pkg/indicator/emv_callbacks.go b/pkg/indicator/emv_callbacks.go new file mode 100644 index 0000000..89afd8a --- /dev/null +++ b/pkg/indicator/emv_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type EMV"; DO NOT EDIT. + +package indicator + +import () + +func (inc *EMV) OnUpdate(cb func(value float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *EMV) EmitUpdate(value float64) { + for _, cb := range inc.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/emv_test.go b/pkg/indicator/emv_test.go new file mode 100644 index 0000000..15fa126 --- /dev/null +++ b/pkg/indicator/emv_test.go @@ -0,0 +1,34 @@ +package indicator + +import ( + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +// data from https://school.stockcharts.com/doku.php?id=technical_indicators:ease_of_movement_emv +func Test_EMV(t *testing.T) { + var Delta = 0.01 + emv := &EMV{ + EMVScale: 100000000, + IntervalWindow: types.IntervalWindow{Window: 14}, + } + emv.Update(63.74, 62.63, 32178836) + emv.Update(64.51, 63.85, 36461672) + assert.InDelta(t, 1.8, emv.Values.rawValues.Last(0), Delta) + emv.Update(64.57, 63.81, 51372680) + emv.Update(64.31, 62.62, 42476356) + emv.Update(63.43, 62.73, 29504176) + emv.Update(62.85, 61.95, 33098600) + emv.Update(62.70, 62.06, 30577960) + emv.Update(63.18, 62.69, 35693928) + emv.Update(62.47, 61.54, 49768136) + emv.Update(64.16, 63.21, 44759968) + emv.Update(64.38, 63.87, 33425504) + emv.Update(64.89, 64.29, 15895085) + emv.Update(65.25, 64.48, 37015388) + emv.Update(64.69, 63.65, 40672116) + emv.Update(64.26, 63.68, 35627200) + assert.InDelta(t, -0.03, emv.Last(0), Delta) +} diff --git a/pkg/indicator/ewma.go b/pkg/indicator/ewma.go new file mode 100644 index 0000000..c0afa1a --- /dev/null +++ b/pkg/indicator/ewma.go @@ -0,0 +1,92 @@ +package indicator + +import ( + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// These numbers should be aligned with qbtrade MaxNumOfKLines and MaxNumOfKLinesTruncate +const MaxNumOfEWMA = 1_000 +const MaxNumOfEWMATruncateSize = 500 + +//go:generate callbackgen -type EWMA +type EWMA struct { + types.IntervalWindow + types.SeriesBase + + Values floats.Slice + EndTime time.Time + + updateCallbacks []func(value float64) +} + +var _ types.SeriesExtend = &EWMA{} + +func (inc *EWMA) Clone() *EWMA { + out := &EWMA{ + IntervalWindow: inc.IntervalWindow, + Values: inc.Values[:], + } + out.SeriesBase.Series = out + return out +} + +func (inc *EWMA) TestUpdate(value float64) *EWMA { + out := inc.Clone() + out.Update(value) + return out +} + +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 { + inc.Values = inc.Values[MaxNumOfEWMATruncateSize-1:] + } + + ema := (1-multiplier)*inc.Last(0) + multiplier*value + inc.Values.Push(ema) +} + +func (inc *EWMA) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *EWMA) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *EWMA) Length() int { + return len(inc.Values) +} + +func (inc *EWMA) PushK(k types.KLine) { + if inc.EndTime != zeroTime && k.EndTime.Before(inc.EndTime) { + return + } + + inc.Update(k.Close.Float64()) + inc.EndTime = k.EndTime.Time() + inc.EmitUpdate(inc.Last(0)) +} + +func CalculateKLinesEMA(allKLines []types.KLine, priceF types.KLineValueMapper, window int) float64 { + var multiplier = 2.0 / (float64(window) + 1) + return ewma(types.MapKLinePrice(allKLines, priceF), multiplier) +} + +// see https://www.investopedia.com/ask/answers/122314/what-exponential-moving-average-ema-formula-and-how-ema-calculated.asp +func ewma(prices []float64, multiplier float64) float64 { + var end = len(prices) - 1 + if end == 0 { + return prices[0] + } + + return prices[end]*multiplier + (1-multiplier)*ewma(prices[:end], multiplier) +} diff --git a/pkg/indicator/ewma_callbacks.go b/pkg/indicator/ewma_callbacks.go new file mode 100644 index 0000000..38fbacb --- /dev/null +++ b/pkg/indicator/ewma_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type EWMA"; DO NOT EDIT. + +package indicator + +import () + +func (inc *EWMA) OnUpdate(cb func(value float64)) { + inc.updateCallbacks = append(inc.updateCallbacks, cb) +} + +func (inc *EWMA) EmitUpdate(value float64) { + for _, cb := range inc.updateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/ewma_test.go b/pkg/indicator/ewma_test.go new file mode 100644 index 0000000..93192bf --- /dev/null +++ b/pkg/indicator/ewma_test.go @@ -0,0 +1,1079 @@ +package indicator + +import ( + "encoding/json" + "math" + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// generated from: +// 2020/12/05 10:25 +// curl -s 'https://www.binance.com/api/v3/klines?symbol=ETHUSDT&interval=5m&endTime=1607135400000&limit=1000' | jq '. | map({ closePrice: (.[4] | tonumber), openTime: .[0] })' +// curl -s 'https://www.binance.com/api/v3/klines?symbol=ETHUSDT&interval=5m&endTime=1607135400000&limit=1000' | jq '. | map(.[4] | tonumber)' +var ethusdt5m = []byte(`[ + 614.36, + 613.62, + 611.68, + 614.7, + 607.69, + 606.82, + 602.91, + 606.35, + 606.04, + 603.94, + 607.37, + 599.72, + 597.77, + 594.36, + 595.18, + 592.65, + 595.44, + 595.08, + 595.82, + 596.31, + 592.47, + 594.19, + 590.71, + 586.77, + 589.74, + 592.33, + 593.44, + 594.34, + 593.89, + 594, + 590.81, + 595.51, + 594.08, + 596.58, + 592.97, + 593.3, + 590.89, + 591.9, + 589.67, + 590.25, + 592.53, + 594.01, + 591.46, + 588.5, + 591.34, + 589.24, + 592, + 594.57, + 593.98, + 593.93, + 593.46, + 594.22, + 595.57, + 595.81, + 595.44, + 599.65, + 600.3, + 599.5, + 601, + 598.24, + 596.56, + 596.55, + 595.61, + 596.91, + 593.49, + 592.03, + 592.56, + 592.95, + 593.56, + 593.78, + 594.69, + 594.17, + 595.62, + 596.06, + 595.83, + 598.51, + 598.29, + 596.94, + 597.87, + 598.02, + 595.13, + 594.87, + 597, + 599.89, + 599.49, + 599.32, + 598.82, + 597.26, + 597.46, + 593.32, + 592.69, + 589.42, + 590.39, + 589.91, + 586.83, + 586.53, + 588.85, + 589.44, + 584.95, + 584.06, + 588.99, + 588.75, + 588.62, + 589.61, + 589.27, + 585.26, + 582.06, + 577.06, + 583.13, + 588.01, + 589.25, + 589.42, + 591.03, + 590.99, + 592.33, + 593.86, + 592.12, + 593.21, + 592.4, + 590.86, + 590.1, + 592.32, + 590.79, + 592.01, + 592.07, + 596.66, + 595.63, + 595.76, + 594.8, + 594.21, + 592.58, + 592.19, + 592.6, + 591.29, + 591.89, + 590.03, + 590.33, + 588.53, + 589.26, + 587.18, + 589.03, + 587.49, + 590.83, + 590.63, + 587.13, + 584.86, + 584.86, + 584, + 587.95, + 589.48, + 589.31, + 586.96, + 584.29, + 585.68, + 587.57, + 582.19, + 580.7, + 580.01, + 580.83, + 578.84, + 578.97, + 582.23, + 581.45, + 582, + 582.69, + 582.68, + 581.54, + 581.32, + 584.95, + 582.57, + 580.75, + 579.58, + 580.13, + 582.42, + 582.85, + 585.5, + 584.87, + 586.1, + 585.39, + 587.32, + 587.83, + 590.11, + 589.84, + 589.78, + 592.13, + 590.5, + 589.54, + 589.01, + 590.7, + 592.73, + 593.54, + 591.56, + 591.32, + 590.49, + 590.64, + 591.43, + 591.94, + 593.12, + 593.97, + 595.47, + 596.23, + 597.63, + 595.06, + 595, + 594.88, + 597.61, + 596.67, + 600.23, + 599.22, + 596.36, + 597.75, + 596.34, + 596.05, + 595.9, + 595.8, + 596.33, + 597.31, + 598.82, + 603.56, + 602.33, + 599.58, + 598.7, + 598.41, + 596.14, + 596.21, + 595.13, + 596.47, + 595.81, + 594.36, + 595.78, + 594.66, + 596, + 597.95, + 597.77, + 597.57, + 598.43, + 597.99, + 598.4, + 600.05, + 597.81, + 596.52, + 597.04, + 598.86, + 600.16, + 601.05, + 600.3, + 599.6, + 597.42, + 598.49, + 598.62, + 589.69, + 591.62, + 592.73, + 593.15, + 593.62, + 593.77, + 592.34, + 592.54, + 588.71, + 591.31, + 593.5, + 593.36, + 593.49, + 593.63, + 593.62, + 593.37, + 596.52, + 597.04, + 597.59, + 596.94, + 596.09, + 595.51, + 595.13, + 593.1, + 594.48, + 594.61, + 596.8, + 595.36, + 593.32, + 593.58, + 593, + 591.84, + 590.61, + 592.27, + 591.81, + 590.27, + 587.59, + 587.96, + 591.07, + 591.86, + 589.61, + 588.16, + 588.08, + 585.46, + 586.69, + 588.21, + 588.7, + 589.51, + 585.28, + 586.27, + 589.88, + 590.97, + 590.92, + 590.7, + 590.09, + 589.38, + 588.51, + 586.69, + 585.33, + 584.32, + 585.42, + 586.3, + 586.08, + 587.32, + 587.55, + 587.31, + 587.07, + 587.57, + 587.44, + 588.56, + 588.91, + 589.13, + 591.17, + 594.2, + 593.36, + 595.75, + 596.29, + 596.78, + 596.82, + 596.39, + 596.31, + 595.77, + 595.95, + 594.09, + 596.86, + 595.64, + 595.41, + 592.67, + 594, + 594.42, + 594.92, + 595, + 596.46, + 596.5, + 596.98, + 596.57, + 599.27, + 596.93, + 596.47, + 596.59, + 595.8, + 596.01, + 596.38, + 598.19, + 598.72, + 598.13, + 596.83, + 596.36, + 597.12, + 597.95, + 596.02, + 596, + 596.33, + 594.97, + 595.82, + 596.58, + 596.37, + 597.88, + 598.5, + 598.06, + 599.71, + 599.97, + 600.7, + 601.44, + 599.72, + 597.62, + 597.24, + 597.7, + 597.67, + 599.85, + 599.43, + 596.28, + 596.21, + 597.68, + 597.75, + 597.35, + 598.18, + 598.39, + 598.54, + 599.73, + 600.1, + 598.7, + 597.96, + 596.89, + 598.8, + 599.57, + 598.4, + 598.12, + 597.39, + 597.64, + 594.69, + 595.61, + 595.82, + 595.97, + 596.71, + 596, + 592.87, + 592.94, + 591.5, + 591.81, + 592.02, + 593.01, + 591.34, + 590.34, + 590.27, + 591.48, + 591.92, + 591.87, + 592.07, + 592.43, + 592.79, + 592.25, + 591.28, + 591.18, + 592, + 592.73, + 593.61, + 592.93, + 592.61, + 592.68, + 593.84, + 594.28, + 595.41, + 595.17, + 594.8, + 594.67, + 595.99, + 596.16, + 597.58, + 596.63, + 596.06, + 595.24, + 595.17, + 595.69, + 596.51, + 596.5, + 595.56, + 594.47, + 593.49, + 593.47, + 593.81, + 593.93, + 594, + 592.85, + 593.04, + 593.88, + 593.78, + 593.16, + 594.9, + 594.99, + 593.91, + 591.28, + 588.23, + 589.01, + 589.12, + 588.28, + 589.36, + 589.94, + 589.6, + 589.77, + 590.36, + 590.85, + 590.18, + 590.01, + 590.77, + 589.97, + 590.78, + 590.99, + 588.95, + 588.9, + 588.02, + 590.51, + 592.01, + 591.8, + 596.14, + 595.68, + 595.72, + 598.47, + 598.1, + 598.47, + 597.27, + 595.94, + 596.43, + 602.8, + 607.08, + 606.03, + 605.98, + 603.91, + 604.5, + 604.19, + 604.98, + 605.25, + 606.41, + 599.67, + 601.87, + 603.85, + 603.18, + 602.31, + 602.5, + 603.02, + 603.84, + 605.19, + 605.29, + 606.56, + 605.54, + 605.36, + 605.11, + 609.64, + 608.82, + 610.79, + 609.74, + 612.51, + 614.67, + 614.8, + 613.42, + 612.79, + 614.4, + 610.96, + 610.57, + 612.19, + 613.22, + 614.24, + 612.67, + 612.4, + 611.6, + 610.69, + 611.5, + 613.6, + 610.99, + 611.22, + 610.54, + 607.53, + 608.7, + 608.98, + 608.4, + 608.72, + 609.35, + 609.84, + 608.5, + 609.27, + 611.71, + 610.67, + 611.18, + 610.87, + 612.38, + 611.53, + 610.7, + 610.08, + 609, + 609.51, + 608.16, + 609.17, + 611.02, + 612.63, + 612.25, + 612.71, + 613.27, + 612.47, + 611.12, + 611.34, + 611.14, + 610.58, + 610.01, + 610.64, + 610.07, + 609.73, + 610.85, + 611.75, + 612.76, + 612.51, + 613.12, + 613.03, + 614.49, + 615.24, + 617.37, + 618.92, + 621.61, + 618.66, + 608.37, + 609.37, + 612.48, + 614.14, + 616.23, + 615.57, + 615.53, + 613.25, + 614.73, + 614.2, + 615.19, + 612.99, + 609.91, + 610.28, + 611.5, + 612.55, + 613.21, + 612.74, + 611.8, + 612.18, + 612.2, + 612.56, + 614.05, + 614.67, + 614.45, + 614.46, + 614.16, + 614.22, + 615.62, + 614.73, + 614.56, + 613.89, + 613.38, + 614.12, + 612.24, + 612.39, + 611.83, + 611.83, + 611.24, + 611.62, + 611.44, + 612.21, + 612.12, + 613.14, + 613.53, + 613.76, + 613.38, + 612.76, + 613.31, + 613.19, + 613.71, + 613.89, + 613.52, + 612.32, + 611.39, + 610.82, + 611.03, + 611.33, + 611.52, + 612.69, + 612.63, + 612.9, + 613.25, + 612.92, + 613.35, + 614.23, + 614.66, + 615.71, + 615.39, + 614.95, + 614.74, + 614.84, + 615.27, + 615.41, + 615.74, + 615.99, + 615.22, + 615.22, + 614.99, + 614.43, + 614.7, + 616.13, + 616.78, + 618.41, + 618.35, + 618.02, + 615.89, + 616.52, + 617.12, + 617.46, + 616.88, + 616.67, + 616.9, + 615.85, + 614.86, + 614.16, + 614.95, + 615.19, + 615.17, + 615.61, + 617.79, + 619.39, + 618.27, + 618.25, + 617.81, + 617.36, + 617.22, + 614.3, + 613.67, + 613.7, + 613.79, + 614.07, + 612.17, + 613.29, + 612.94, + 612.8, + 613.1, + 611.32, + 612.93, + 612.42, + 611.86, + 611.16, + 609.93, + 610.95, + 610.16, + 603.83, + 606.02, + 606.72, + 604.39, + 604.26, + 607, + 606.84, + 607.84, + 607.8, + 605.64, + 604.26, + 605.22, + 605.08, + 606.01, + 605.48, + 604.73, + 602.64, + 604.64, + 605.7, + 605.58, + 605.68, + 606.05, + 606.2, + 606.62, + 606.16, + 607.36, + 606.38, + 605.48, + 606.12, + 607.16, + 607.51, + 607.4, + 607.27, + 606.66, + 607.21, + 606.1, + 606.66, + 605.73, + 605.29, + 603.52, + 603.46, + 604.73, + 605.57, + 606.04, + 606.05, + 607.02, + 608.31, + 607.69, + 607.68, + 607.45, + 607.36, + 607.18, + 606.89, + 605.9, + 605.03, + 604.85, + 604.16, + 604.49, + 604.43, + 604.94, + 606.74, + 606.51, + 606.44, + 605.03, + 604.28, + 606.71, + 608.23, + 609.64, + 611.5, + 611.6, + 612.57, + 611.81, + 612.12, + 611.87, + 610.47, + 611.22, + 610.86, + 609.75, + 609.76, + 609.06, + 609.52, + 607.76, + 608.81, + 609.2, + 608.17, + 607.88, + 608.37, + 609.27, + 608.69, + 608.33, + 603.37, + 600.33, + 592.31, + 592.43, + 592.5, + 591.56, + 592.21, + 594.3, + 594.29, + 593.4, + 593.25, + 594.53, + 594.85, + 597.23, + 596.2, + 595.5, + 593.74, + 591.08, + 594.12, + 595, + 591.83, + 590.93, + 585.57, + 588.02, + 587.75, + 584.22, + 583.38, + 586.86, + 587.51, + 589.39, + 589.98, + 591.01, + 590.7, + 591.73, + 592.09, + 590.98, + 591.22, + 591.04, + 590.08, + 588.41, + 590.28, + 590.95, + 589.63, + 591.09, + 591.04, + 590.03, + 590.92, + 589.92, + 589.77, + 587.7, + 589.1, + 591.42, + 595.3, + 594.44, + 593.91, + 593.85, + 593.08, + 592.17, + 591.94, + 592.46, + 591.24, + 590.66, + 590.42, + 590.24, + 589.58, + 589.38, + 590.48, + 590, + 589.55, + 589.3, + 586.89, + 584.95, + 588.32, + 583.45, + 586.51, + 586.6, + 586.03, + 584.14, + 583.92, + 585.94, + 584.76, + 587.6, + 588.47, + 589.39, + 588.17, + 588.99, + 588.52, + 589.23, + 589.61, + 590.62, + 589.68, + 589.88, + 590.22, + 589.78, + 589.34, + 588.11, + 588.63, + 588.27, + 588.8, + 587.96, + 589.45, + 589.99, + 591, + 590.82, + 591.62, + 591.17, + 591.14, + 590.68, + 590.78, + 591.87, + 591.02, + 590.61, + 589.85, + 589.66, + 589.83, + 591.09, + 589.6, + 590.89, + 590.83, + 589.29, + 588.84, + 588.51, + 588.56, + 588.26, + 588.83, + 587.95, + 587.32, + 588.3, + 588.96, + 588.54, + 588.87, + 589.12, + 589.01, + 587.54, + 585.49, + 584.81, + 584.11, + 584.77, + 584.68, + 585.07, + 582.69, + 580.38, + 580.83, + 580.53, + 577.67, + 582.09, + 579.3, + 578.17, + 575.88, + 576.2, + 577.53, + 572.94, + 572.63, + 570.55, + 569.16, + 573.56, + 573.78, + 574.3, + 574.69, + 575.85, + 575.59, + 574.36, + 571.4, + 571.9, + 569.9, + 571.62, + 567.24, + 563.9, + 564.1, + 565.2, + 567.49, + 567.81, + 568.34, + 570.68, + 571.31, + 570.79, + 571.36, + 572.66, + 571.79, + 570.58, + 570.63, + 572.96, + 572.04, + 571.64, + 570.91, + 570.17, + 570.94, + 572.12, + 572.18, + 569.6, + 567.31, + 568.21, + 570.32, + 572.85, + 572.21, + 572.63, + 572.74 +]`) + +func buildKLines(prices []fixedpoint.Value) (klines []types.KLine) { + for _, p := range prices { + klines = append(klines, types.KLine{Close: p}) + } + + return klines +} + +func Test_calculateEWMA(t *testing.T) { + type args struct { + allKLines []types.KLine + priceF types.KLineValueMapper + window int + } + var input []fixedpoint.Value + if err := json.Unmarshal(ethusdt5m, &input); err != nil { + panic(err) + } + tests := []struct { + name string + args args + want float64 + }{ + { + name: "ETHUSDT EMA 7", + args: args{ + allKLines: buildKLines(input), + priceF: types.KLineClosePriceMapper, + window: 7, + }, + want: 571.72, // with open price, binance desktop returns 571.45, trading view returns 570.8957, for close price, binance mobile returns 571.72 + }, + { + name: "ETHUSDT EMA 25", + args: args{ + allKLines: buildKLines(input), + priceF: types.KLineClosePriceMapper, + window: 25, + }, + want: 571.30, + }, + { + name: "ETHUSDT EMA 99", + args: args{ + allKLines: buildKLines(input), + priceF: types.KLineClosePriceMapper, + window: 99, + }, + want: 577.62, // binance mobile uses 577.58 + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CalculateKLinesEMA(tt.args.allKLines, tt.args.priceF, tt.args.window) + got = math.Trunc(got*100.0) / 100.0 + if got != tt.want { + t.Errorf("CalculateKLinesEMA() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/indicator/fisher.go b/pkg/indicator/fisher.go new file mode 100644 index 0000000..612929f --- /dev/null +++ b/pkg/indicator/fisher.go @@ -0,0 +1,80 @@ +package indicator + +import ( + "math" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// Fisher Transform +// +// The Fisher Transform is a technical analysis indicator that is used to identify potential turning points in the price of a security. +// It is based on the idea that prices tend to be normally distributed, with most price movements being small and relatively insignificant. +// The Fisher Transform converts this normal distribution into a symmetrical, Gaussian distribution, with a peak at zero and a range of -1 to +1. +// This transformation allows for more accurate identification of price extremes, which can be used to make predictions about potential trend reversals. +// The Fisher Transform is calculated by taking the natural logarithm of the ratio of the security's current price to its moving average, +// and then double-smoothing the result. This resulting line is called the Fisher Transform line, and can be plotted on the price chart +// along with the security's price. +// +//go:generate callbackgen -type FisherTransform +type FisherTransform struct { + types.SeriesBase + types.IntervalWindow + prices *types.Queue + Values floats.Slice + + UpdateCallbacks []func(value float64) +} + +func (inc *FisherTransform) Clone() types.UpdatableSeriesExtend { + out := FisherTransform{ + IntervalWindow: inc.IntervalWindow, + prices: inc.prices.Clone(), + Values: inc.Values[:], + } + out.SeriesBase.Series = &out + return &out +} + +func (inc *FisherTransform) Update(value float64) { + if inc.prices == nil { + inc.prices = types.NewQueue(inc.Window) + inc.SeriesBase.Series = inc + } + inc.prices.Update(value) + highest := inc.prices.Highest(inc.Window) + lowest := inc.prices.Lowest(inc.Window) + if highest == lowest { + inc.Values.Update(0) + return + } + x := 2*((value-lowest)/(highest-lowest)) - 1 + if x == 1 { + x = 0.9999 + } else if x == -1 { + x = -0.9999 + } + inc.Values.Update(0.5 * math.Log((1+x)/(1-x))) + if len(inc.Values) > MaxNumOfEWMA { + inc.Values = inc.Values[MaxNumOfEWMATruncateSize-1:] + } +} + +func (inc *FisherTransform) Last(i int) float64 { + if inc.Values == nil { + return 0.0 + } + return inc.Values.Last(i) +} + +func (inc *FisherTransform) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *FisherTransform) Length() int { + if inc.Values == nil { + return 0 + } + return inc.Values.Length() +} diff --git a/pkg/indicator/fishertransform_callbacks.go b/pkg/indicator/fishertransform_callbacks.go new file mode 100644 index 0000000..8b1e419 --- /dev/null +++ b/pkg/indicator/fishertransform_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type FisherTransform"; DO NOT EDIT. + +package indicator + +import () + +func (inc *FisherTransform) OnUpdate(cb func(value float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *FisherTransform) EmitUpdate(value float64) { + for _, cb := range inc.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/ghfilter.go b/pkg/indicator/ghfilter.go new file mode 100644 index 0000000..85f7ad0 --- /dev/null +++ b/pkg/indicator/ghfilter.go @@ -0,0 +1,66 @@ +package indicator + +import ( + "math" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// Refer: https://jamesgoulding.com/Research_II/Ehlers/Ehlers%20(Optimal%20Tracking%20Filters).doc +// Ehler's Optimal Tracking Filter, an alpha-beta filter, also called g-h filter + +//go:generate callbackgen -type GHFilter +type GHFilter struct { + types.SeriesBase + types.IntervalWindow + a float64 // maneuverability uncertainty + b float64 // measurement uncertainty + lastMeasurement float64 + Values floats.Slice + + UpdateCallbacks []func(value float64) +} + +func (inc *GHFilter) Update(value float64) { + inc.update(value, math.Abs(value-inc.lastMeasurement)) +} + +func (inc *GHFilter) update(value, uncertainty float64) { + if len(inc.Values) == 0 { + inc.a = 0 + inc.b = uncertainty / 2 + inc.lastMeasurement = value + inc.Values.Push(value) + return + } + multiplier := 2.0 / float64(1+inc.Window) // EMA multiplier + inc.a = multiplier*(value-inc.lastMeasurement) + (1-multiplier)*inc.a + inc.b = multiplier*uncertainty/2 + (1-multiplier)*inc.b + lambda := inc.a / inc.b + lambda2 := lambda * lambda + alpha := (-lambda2 + math.Sqrt(lambda2*lambda2+16*lambda2)) / 8 + filtered := alpha*value + (1-alpha)*inc.Values.Last(0) + inc.Values.Push(filtered) + inc.lastMeasurement = value +} + +func (inc *GHFilter) Length() int { + return inc.Values.Length() +} + +func (inc *GHFilter) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *GHFilter) Index(i int) float64 { + return inc.Last(i) +} + +// interfaces implementation check +var _ Simple = &GHFilter{} +var _ types.SeriesExtend = &GHFilter{} + +func (inc *GHFilter) PushK(k types.KLine) { + inc.update(k.Close.Float64(), k.High.Float64()-k.Low.Float64()) +} diff --git a/pkg/indicator/ghfilter_callbacks.go b/pkg/indicator/ghfilter_callbacks.go new file mode 100644 index 0000000..345355a --- /dev/null +++ b/pkg/indicator/ghfilter_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type GHFilter"; DO NOT EDIT. + +package indicator + +import () + +func (inc *GHFilter) OnUpdate(cb func(value float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *GHFilter) EmitUpdate(value float64) { + for _, cb := range inc.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/ghfilter_test.go b/pkg/indicator/ghfilter_test.go new file mode 100644 index 0000000..2921274 --- /dev/null +++ b/pkg/indicator/ghfilter_test.go @@ -0,0 +1,6151 @@ +package indicator + +import ( + "encoding/json" + "math" + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// generated from Binance 2022/07/27 00:00 +// https://www.binance.com/api/v3/klines?symbol=ETHUSDT&interval=5m&endTime=1658851200000&limit=1000 +var testGHFilterDataEthusdt5m = []byte(`[ + { + "open": 1591.11, + "high": 1593.62, + "low": 1589.04, + "close": 1590.14 + }, + { + "open": 1590.14, + "high": 1596.51, + "low": 1590.13, + "close": 1592.06 + }, + { + "open": 1592.07, + "high": 1594.41, + "low": 1586.05, + "close": 1587.02 + }, + { + "open": 1587.02, + "high": 1588.38, + "low": 1583.86, + "close": 1585.33 + }, + { + "open": 1585.34, + "high": 1595.2, + "low": 1583.74, + "close": 1594.69 + }, + { + "open": 1594.69, + "high": 1594.75, + "low": 1589.89, + "close": 1591.36 + }, + { + "open": 1591.35, + "high": 1592.55, + "low": 1586.36, + "close": 1588.95 + }, + { + "open": 1588.95, + "high": 1589.75, + "low": 1588.39, + "close": 1589.38 + }, + { + "open": 1589.38, + "high": 1589.39, + "low": 1586.17, + "close": 1588.53 + }, + { + "open": 1588.52, + "high": 1588.62, + "low": 1581.95, + "close": 1583.4 + }, + { + "open": 1583.4, + "high": 1584.67, + "low": 1582.1, + "close": 1582.36 + }, + { + "open": 1582.35, + "high": 1584.29, + "low": 1577.82, + "close": 1578.14 + }, + { + "open": 1578.14, + "high": 1581.95, + "low": 1575.72, + "close": 1581.52 + }, + { + "open": 1581.52, + "high": 1584.86, + "low": 1578.51, + "close": 1580.88 + }, + { + "open": 1580.88, + "high": 1581.77, + "low": 1578.74, + "close": 1581.11 + }, + { + "open": 1581.1, + "high": 1582.72, + "low": 1579.07, + "close": 1579.4 + }, + { + "open": 1579.4, + "high": 1580.93, + "low": 1578, + "close": 1579.6 + }, + { + "open": 1579.59, + "high": 1583.81, + "low": 1579.59, + "close": 1582.7 + }, + { + "open": 1582.7, + "high": 1583, + "low": 1577.24, + "close": 1579.45 + }, + { + "open": 1579.46, + "high": 1581.59, + "low": 1577.44, + "close": 1579.59 + }, + { + "open": 1579.58, + "high": 1581.41, + "low": 1579.22, + "close": 1580.56 + }, + { + "open": 1580.57, + "high": 1586.23, + "low": 1579.86, + "close": 1584.23 + }, + { + "open": 1584.22, + "high": 1587.36, + "low": 1584.22, + "close": 1585.15 + }, + { + "open": 1585.15, + "high": 1585.15, + "low": 1579.83, + "close": 1583.75 + }, + { + "open": 1583.74, + "high": 1592.49, + "low": 1583.45, + "close": 1587.76 + }, + { + "open": 1587.76, + "high": 1590.7, + "low": 1585.62, + "close": 1587.5 + }, + { + "open": 1587.51, + "high": 1587.51, + "low": 1579.53, + "close": 1581.16 + }, + { + "open": 1581.15, + "high": 1585.71, + "low": 1581.15, + "close": 1582.47 + }, + { + "open": 1582.46, + "high": 1582.86, + "low": 1567.58, + "close": 1571.52 + }, + { + "open": 1571.53, + "high": 1577.8, + "low": 1571.03, + "close": 1575.16 + }, + { + "open": 1575.16, + "high": 1578.06, + "low": 1572.18, + "close": 1576.66 + }, + { + "open": 1576.66, + "high": 1578, + "low": 1574.62, + "close": 1577.21 + }, + { + "open": 1577.2, + "high": 1584.57, + "low": 1576.61, + "close": 1584.05 + }, + { + "open": 1584.06, + "high": 1585.61, + "low": 1580, + "close": 1582.08 + }, + { + "open": 1582.08, + "high": 1583.4, + "low": 1579.43, + "close": 1579.43 + }, + { + "open": 1579.43, + "high": 1579.98, + "low": 1574.53, + "close": 1575.06 + }, + { + "open": 1575.06, + "high": 1578.52, + "low": 1574.57, + "close": 1576.49 + }, + { + "open": 1576.5, + "high": 1577, + "low": 1572.5, + "close": 1573.26 + }, + { + "open": 1573.26, + "high": 1579.41, + "low": 1573.06, + "close": 1578.35 + }, + { + "open": 1578.35, + "high": 1585, + "low": 1577.16, + "close": 1584.32 + }, + { + "open": 1584.31, + "high": 1587.97, + "low": 1580.67, + "close": 1585.7 + }, + { + "open": 1585.7, + "high": 1588.35, + "low": 1584.37, + "close": 1585.95 + }, + { + "open": 1585.94, + "high": 1587.09, + "low": 1580.66, + "close": 1580.97 + }, + { + "open": 1580.97, + "high": 1583.38, + "low": 1577, + "close": 1581.64 + }, + { + "open": 1581.64, + "high": 1586.79, + "low": 1581.22, + "close": 1585.42 + }, + { + "open": 1585.42, + "high": 1585.42, + "low": 1581.67, + "close": 1582.37 + }, + { + "open": 1582.38, + "high": 1584.86, + "low": 1581.01, + "close": 1581.02 + }, + { + "open": 1581.03, + "high": 1582.05, + "low": 1578.99, + "close": 1579.46 + }, + { + "open": 1579.46, + "high": 1579.89, + "low": 1566.85, + "close": 1567.99 + }, + { + "open": 1567.99, + "high": 1567.99, + "low": 1553.2, + "close": 1554.87 + }, + { + "open": 1554.87, + "high": 1558, + "low": 1546.9, + "close": 1550.4 + }, + { + "open": 1550.4, + "high": 1554.98, + "low": 1546.27, + "close": 1549.67 + }, + { + "open": 1549.68, + "high": 1555, + "low": 1546.97, + "close": 1553.88 + }, + { + "open": 1553.89, + "high": 1557.86, + "low": 1553.6, + "close": 1557.85 + }, + { + "open": 1557.86, + "high": 1558.37, + "low": 1554.9, + "close": 1556.3 + }, + { + "open": 1556.31, + "high": 1557.4, + "low": 1552.81, + "close": 1557.18 + }, + { + "open": 1557.18, + "high": 1563.78, + "low": 1556.5, + "close": 1562.72 + }, + { + "open": 1562.72, + "high": 1564.11, + "low": 1558.76, + "close": 1560.64 + }, + { + "open": 1560.64, + "high": 1562.31, + "low": 1560.5, + "close": 1561.24 + }, + { + "open": 1561.25, + "high": 1565.69, + "low": 1561.23, + "close": 1564.79 + }, + { + "open": 1564.79, + "high": 1565.33, + "low": 1558.23, + "close": 1559.9 + }, + { + "open": 1559.89, + "high": 1561.77, + "low": 1555.87, + "close": 1560.79 + }, + { + "open": 1560.78, + "high": 1562.07, + "low": 1557.89, + "close": 1560.36 + }, + { + "open": 1560.36, + "high": 1561.2, + "low": 1556.13, + "close": 1558.26 + }, + { + "open": 1558.25, + "high": 1563.12, + "low": 1558.25, + "close": 1562.35 + }, + { + "open": 1562.36, + "high": 1564.02, + "low": 1561.76, + "close": 1563.32 + }, + { + "open": 1563.31, + "high": 1564.29, + "low": 1557.79, + "close": 1559.87 + }, + { + "open": 1559.86, + "high": 1562.71, + "low": 1558.77, + "close": 1559.8 + }, + { + "open": 1559.81, + "high": 1559.91, + "low": 1557.6, + "close": 1559.19 + }, + { + "open": 1559.2, + "high": 1559.95, + "low": 1554.3, + "close": 1557.16 + }, + { + "open": 1557.16, + "high": 1557.17, + "low": 1536.25, + "close": 1541.89 + }, + { + "open": 1541.89, + "high": 1544.39, + "low": 1538.55, + "close": 1539.33 + }, + { + "open": 1539.33, + "high": 1546.28, + "low": 1533.67, + "close": 1543.99 + }, + { + "open": 1543.99, + "high": 1544.5, + "low": 1538.21, + "close": 1539.17 + }, + { + "open": 1539.17, + "high": 1543.33, + "low": 1537.73, + "close": 1543 + }, + { + "open": 1543.2, + "high": 1544, + "low": 1535.81, + "close": 1541.12 + }, + { + "open": 1541.12, + "high": 1541.13, + "low": 1534.12, + "close": 1536.89 + }, + { + "open": 1536.9, + "high": 1539.09, + "low": 1528.25, + "close": 1531.02 + }, + { + "open": 1531.01, + "high": 1532.91, + "low": 1525.28, + "close": 1532.32 + }, + { + "open": 1532.33, + "high": 1537.58, + "low": 1532.32, + "close": 1535.07 + }, + { + "open": 1535.06, + "high": 1541.28, + "low": 1535.06, + "close": 1539.52 + }, + { + "open": 1539.53, + "high": 1539.85, + "low": 1533.37, + "close": 1536.22 + }, + { + "open": 1536.21, + "high": 1536.22, + "low": 1524.81, + "close": 1527.09 + }, + { + "open": 1527.1, + "high": 1529.2, + "low": 1520.62, + "close": 1525.04 + }, + { + "open": 1525.04, + "high": 1528.2, + "low": 1522.12, + "close": 1523.72 + }, + { + "open": 1523.71, + "high": 1525.54, + "low": 1519, + "close": 1522.82 + }, + { + "open": 1522.82, + "high": 1524.98, + "low": 1521, + "close": 1522.19 + }, + { + "open": 1522.19, + "high": 1524.27, + "low": 1512.68, + "close": 1513.27 + }, + { + "open": 1513.26, + "high": 1514.55, + "low": 1501.65, + "close": 1514.14 + }, + { + "open": 1514.14, + "high": 1524.43, + "low": 1513.03, + "close": 1520.08 + }, + { + "open": 1520.08, + "high": 1525.07, + "low": 1518.63, + "close": 1520.61 + }, + { + "open": 1520.61, + "high": 1525.68, + "low": 1517.43, + "close": 1524.86 + }, + { + "open": 1524.86, + "high": 1525.04, + "low": 1519.65, + "close": 1520.26 + }, + { + "open": 1520.27, + "high": 1521.19, + "low": 1517.91, + "close": 1518.46 + }, + { + "open": 1518.46, + "high": 1525.25, + "low": 1518.46, + "close": 1524.83 + }, + { + "open": 1524.84, + "high": 1526.94, + "low": 1521.69, + "close": 1521.9 + }, + { + "open": 1521.9, + "high": 1524.8, + "low": 1519.25, + "close": 1519.88 + }, + { + "open": 1519.88, + "high": 1520.5, + "low": 1517.33, + "close": 1518.76 + }, + { + "open": 1518.76, + "high": 1522.64, + "low": 1518.14, + "close": 1520.46 + }, + { + "open": 1520.46, + "high": 1522.96, + "low": 1518.63, + "close": 1522.52 + }, + { + "open": 1522.51, + "high": 1522.52, + "low": 1519.19, + "close": 1520.61 + }, + { + "open": 1520.61, + "high": 1526.26, + "low": 1519.52, + "close": 1526.1 + }, + { + "open": 1526.11, + "high": 1530.26, + "low": 1524.63, + "close": 1528.65 + }, + { + "open": 1528.65, + "high": 1529.64, + "low": 1521.24, + "close": 1523.18 + }, + { + "open": 1523.19, + "high": 1525.92, + "low": 1521.79, + "close": 1525.6 + }, + { + "open": 1525.61, + "high": 1525.79, + "low": 1522.78, + "close": 1525.31 + }, + { + "open": 1525.31, + "high": 1529.6, + "low": 1525.3, + "close": 1528.93 + }, + { + "open": 1528.93, + "high": 1530.33, + "low": 1527.02, + "close": 1527.61 + }, + { + "open": 1527.6, + "high": 1534, + "low": 1527.6, + "close": 1533.98 + }, + { + "open": 1533.98, + "high": 1537.3, + "low": 1532.24, + "close": 1536.49 + }, + { + "open": 1536.5, + "high": 1536.5, + "low": 1531.65, + "close": 1532.92 + }, + { + "open": 1532.92, + "high": 1532.92, + "low": 1529.1, + "close": 1529.58 + }, + { + "open": 1529.58, + "high": 1535.97, + "low": 1528.13, + "close": 1532.48 + }, + { + "open": 1532.49, + "high": 1533.45, + "low": 1530.29, + "close": 1531.01 + }, + { + "open": 1531.02, + "high": 1532.56, + "low": 1524.11, + "close": 1524.37 + }, + { + "open": 1524.36, + "high": 1534.58, + "low": 1524.28, + "close": 1529.52 + }, + { + "open": 1529.52, + "high": 1530.55, + "low": 1521.72, + "close": 1522.54 + }, + { + "open": 1522.55, + "high": 1526.64, + "low": 1522.45, + "close": 1526.49 + }, + { + "open": 1526.48, + "high": 1530.2, + "low": 1525.07, + "close": 1526.92 + }, + { + "open": 1526.92, + "high": 1527.65, + "low": 1525, + "close": 1526.44 + }, + { + "open": 1526.43, + "high": 1527.68, + "low": 1525.46, + "close": 1526.05 + }, + { + "open": 1526.05, + "high": 1526.27, + "low": 1516.23, + "close": 1516.52 + }, + { + "open": 1516.23, + "high": 1520.67, + "low": 1509.21, + "close": 1520.48 + }, + { + "open": 1520.48, + "high": 1530.83, + "low": 1519.77, + "close": 1529.59 + }, + { + "open": 1529.6, + "high": 1531.12, + "low": 1526.99, + "close": 1531.11 + }, + { + "open": 1531.12, + "high": 1533.79, + "low": 1529.25, + "close": 1531.72 + }, + { + "open": 1531.73, + "high": 1532.96, + "low": 1528.52, + "close": 1529.64 + }, + { + "open": 1529.64, + "high": 1530.49, + "low": 1523.16, + "close": 1524.37 + }, + { + "open": 1524.38, + "high": 1524.58, + "low": 1517.86, + "close": 1521.06 + }, + { + "open": 1521.07, + "high": 1530.49, + "low": 1515.75, + "close": 1526.14 + }, + { + "open": 1526.13, + "high": 1526.98, + "low": 1521.57, + "close": 1523.57 + }, + { + "open": 1523.56, + "high": 1523.68, + "low": 1520.39, + "close": 1521.17 + }, + { + "open": 1521.18, + "high": 1521.36, + "low": 1516.78, + "close": 1516.79 + }, + { + "open": 1516.79, + "high": 1521.81, + "low": 1516.48, + "close": 1520.2 + }, + { + "open": 1520.2, + "high": 1524.79, + "low": 1516.97, + "close": 1523.67 + }, + { + "open": 1523.67, + "high": 1527.82, + "low": 1522.79, + "close": 1525.77 + }, + { + "open": 1525.77, + "high": 1527.68, + "low": 1520.25, + "close": 1524.24 + }, + { + "open": 1524.23, + "high": 1530.88, + "low": 1523.28, + "close": 1529.87 + }, + { + "open": 1529.88, + "high": 1532.92, + "low": 1527.6, + "close": 1530.66 + }, + { + "open": 1530.65, + "high": 1531.32, + "low": 1528.31, + "close": 1530.85 + }, + { + "open": 1530.85, + "high": 1532.99, + "low": 1527.78, + "close": 1528.68 + }, + { + "open": 1528.69, + "high": 1529.92, + "low": 1527.13, + "close": 1527.14 + }, + { + "open": 1527.13, + "high": 1527.14, + "low": 1518.31, + "close": 1521.16 + }, + { + "open": 1521.16, + "high": 1530.26, + "low": 1521.15, + "close": 1526.67 + }, + { + "open": 1526.68, + "high": 1528.17, + "low": 1522.22, + "close": 1522.33 + }, + { + "open": 1522.33, + "high": 1526.23, + "low": 1521.09, + "close": 1523.59 + }, + { + "open": 1523.59, + "high": 1523.99, + "low": 1517.48, + "close": 1518.86 + }, + { + "open": 1518.85, + "high": 1523.43, + "low": 1513.25, + "close": 1521.57 + }, + { + "open": 1521.58, + "high": 1521.58, + "low": 1511.11, + "close": 1513.4 + }, + { + "open": 1513.4, + "high": 1515.26, + "low": 1507.7, + "close": 1508.31 + }, + { + "open": 1508.31, + "high": 1512.48, + "low": 1503.49, + "close": 1505.89 + }, + { + "open": 1505.88, + "high": 1509.76, + "low": 1494.63, + "close": 1500.13 + }, + { + "open": 1500.13, + "high": 1510.52, + "low": 1498.39, + "close": 1507.22 + }, + { + "open": 1507.21, + "high": 1508, + "low": 1495.51, + "close": 1501.06 + }, + { + "open": 1501.06, + "high": 1506.84, + "low": 1500.04, + "close": 1504.99 + }, + { + "open": 1505, + "high": 1507.4, + "low": 1497.16, + "close": 1498.46 + }, + { + "open": 1498.46, + "high": 1505.37, + "low": 1495, + "close": 1501.44 + }, + { + "open": 1501.36, + "high": 1504.4, + "low": 1500.27, + "close": 1500.55 + }, + { + "open": 1500.55, + "high": 1502.52, + "low": 1496.63, + "close": 1501.29 + }, + { + "open": 1501.29, + "high": 1501.88, + "low": 1496, + "close": 1496.37 + }, + { + "open": 1496.37, + "high": 1506.67, + "low": 1488, + "close": 1505.21 + }, + { + "open": 1505.21, + "high": 1508.6, + "low": 1502.24, + "close": 1508 + }, + { + "open": 1507.99, + "high": 1514.07, + "low": 1507.03, + "close": 1512.2 + }, + { + "open": 1512.2, + "high": 1513.73, + "low": 1510.89, + "close": 1512.64 + }, + { + "open": 1512.65, + "high": 1514.52, + "low": 1508.88, + "close": 1513.17 + }, + { + "open": 1513.17, + "high": 1513.95, + "low": 1511.68, + "close": 1511.94 + }, + { + "open": 1511.94, + "high": 1512.69, + "low": 1508, + "close": 1508.97 + }, + { + "open": 1508.97, + "high": 1511.92, + "low": 1508, + "close": 1511.71 + }, + { + "open": 1511.71, + "high": 1512.21, + "low": 1502.06, + "close": 1502.3 + }, + { + "open": 1502.29, + "high": 1505.5, + "low": 1499.75, + "close": 1503.8 + }, + { + "open": 1503.8, + "high": 1510.52, + "low": 1497.04, + "close": 1499.02 + }, + { + "open": 1499.03, + "high": 1500.56, + "low": 1497.35, + "close": 1499.88 + }, + { + "open": 1499.88, + "high": 1507.12, + "low": 1498.43, + "close": 1499 + }, + { + "open": 1498.99, + "high": 1501.4, + "low": 1489.93, + "close": 1493.7 + }, + { + "open": 1493.71, + "high": 1495.73, + "low": 1490.72, + "close": 1493.73 + }, + { + "open": 1493.72, + "high": 1495.82, + "low": 1492.44, + "close": 1493.23 + }, + { + "open": 1493.23, + "high": 1501.75, + "low": 1493.06, + "close": 1501.54 + }, + { + "open": 1501.54, + "high": 1506.81, + "low": 1500.45, + "close": 1506.61 + }, + { + "open": 1506.6, + "high": 1507.9, + "low": 1505.1, + "close": 1505.95 + }, + { + "open": 1505.95, + "high": 1509.42, + "low": 1505.69, + "close": 1508.9 + }, + { + "open": 1508.9, + "high": 1516.09, + "low": 1508.3, + "close": 1513.84 + }, + { + "open": 1513.83, + "high": 1516.35, + "low": 1510.74, + "close": 1512 + }, + { + "open": 1512, + "high": 1516.35, + "low": 1511.43, + "close": 1513.33 + }, + { + "open": 1513.25, + "high": 1518.68, + "low": 1511.56, + "close": 1517.19 + }, + { + "open": 1517.18, + "high": 1524.05, + "low": 1516.45, + "close": 1517.64 + }, + { + "open": 1517.64, + "high": 1519.51, + "low": 1514.37, + "close": 1518.03 + }, + { + "open": 1518.04, + "high": 1520.21, + "low": 1516.06, + "close": 1518.91 + }, + { + "open": 1518.91, + "high": 1519.8, + "low": 1516.06, + "close": 1518.1 + }, + { + "open": 1518.11, + "high": 1518.11, + "low": 1515.6, + "close": 1516.43 + }, + { + "open": 1516.43, + "high": 1521.28, + "low": 1515.79, + "close": 1519.82 + }, + { + "open": 1519.81, + "high": 1519.98, + "low": 1518.42, + "close": 1519.68 + }, + { + "open": 1519.69, + "high": 1521.68, + "low": 1518.67, + "close": 1520.28 + }, + { + "open": 1520.29, + "high": 1521.65, + "low": 1519.08, + "close": 1520.24 + }, + { + "open": 1520.25, + "high": 1527.76, + "low": 1520.24, + "close": 1526.28 + }, + { + "open": 1526.27, + "high": 1526.99, + "low": 1522.67, + "close": 1525.11 + }, + { + "open": 1525.1, + "high": 1529.05, + "low": 1523.62, + "close": 1525.51 + }, + { + "open": 1525.5, + "high": 1525.51, + "low": 1520.4, + "close": 1521.62 + }, + { + "open": 1521.62, + "high": 1525.97, + "low": 1521.59, + "close": 1523.41 + }, + { + "open": 1523.42, + "high": 1524.16, + "low": 1523, + "close": 1523.81 + }, + { + "open": 1523.8, + "high": 1523.99, + "low": 1522, + "close": 1523.43 + }, + { + "open": 1523.42, + "high": 1524.99, + "low": 1523.42, + "close": 1524.78 + }, + { + "open": 1524.79, + "high": 1525.35, + "low": 1523.06, + "close": 1523.24 + }, + { + "open": 1523.24, + "high": 1523.24, + "low": 1518.44, + "close": 1520.29 + }, + { + "open": 1520.28, + "high": 1521.95, + "low": 1518.01, + "close": 1521.07 + }, + { + "open": 1521.08, + "high": 1521.3, + "low": 1519.22, + "close": 1519.35 + }, + { + "open": 1519.35, + "high": 1519.63, + "low": 1516.3, + "close": 1517.68 + }, + { + "open": 1517.67, + "high": 1518.24, + "low": 1515.23, + "close": 1516.39 + }, + { + "open": 1516.39, + "high": 1520.22, + "low": 1515.31, + "close": 1519.56 + }, + { + "open": 1519.55, + "high": 1524.64, + "low": 1518, + "close": 1522.74 + }, + { + "open": 1522.74, + "high": 1523.93, + "low": 1520.21, + "close": 1520.29 + }, + { + "open": 1520.29, + "high": 1523.26, + "low": 1520.1, + "close": 1522.73 + }, + { + "open": 1522.74, + "high": 1541.63, + "low": 1522.73, + "close": 1539.67 + }, + { + "open": 1539.67, + "high": 1541.92, + "low": 1535.13, + "close": 1538.82 + }, + { + "open": 1538.82, + "high": 1547.2, + "low": 1538.27, + "close": 1545.55 + }, + { + "open": 1545.55, + "high": 1550, + "low": 1543.77, + "close": 1545.59 + }, + { + "open": 1545.6, + "high": 1546.69, + "low": 1539.57, + "close": 1539.68 + }, + { + "open": 1539.67, + "high": 1543.83, + "low": 1538.46, + "close": 1542.91 + }, + { + "open": 1542.91, + "high": 1545.89, + "low": 1542.34, + "close": 1543.44 + }, + { + "open": 1543.43, + "high": 1544.62, + "low": 1541.84, + "close": 1541.85 + }, + { + "open": 1541.85, + "high": 1554.35, + "low": 1539.93, + "close": 1545.74 + }, + { + "open": 1545.77, + "high": 1554.47, + "low": 1545, + "close": 1549.46 + }, + { + "open": 1549.46, + "high": 1552.24, + "low": 1549.45, + "close": 1551.24 + }, + { + "open": 1551.25, + "high": 1554.87, + "low": 1550.63, + "close": 1551.87 + }, + { + "open": 1551.86, + "high": 1553.53, + "low": 1545.58, + "close": 1546.72 + }, + { + "open": 1546.71, + "high": 1552.74, + "low": 1546.65, + "close": 1551.27 + }, + { + "open": 1551.26, + "high": 1555.26, + "low": 1549.71, + "close": 1551.45 + }, + { + "open": 1551.44, + "high": 1553.45, + "low": 1548.31, + "close": 1548.54 + }, + { + "open": 1548.54, + "high": 1549.16, + "low": 1546.57, + "close": 1547.39 + }, + { + "open": 1547.38, + "high": 1549.99, + "low": 1546.86, + "close": 1548.82 + }, + { + "open": 1548.82, + "high": 1554.04, + "low": 1544.92, + "close": 1552.11 + }, + { + "open": 1552.11, + "high": 1553.01, + "low": 1548.42, + "close": 1548.67 + }, + { + "open": 1548.66, + "high": 1577.24, + "low": 1548.66, + "close": 1568.11 + }, + { + "open": 1568.11, + "high": 1569.11, + "low": 1562, + "close": 1563.15 + }, + { + "open": 1563.16, + "high": 1572.49, + "low": 1562.7, + "close": 1566.75 + }, + { + "open": 1566.76, + "high": 1567.67, + "low": 1563.83, + "close": 1564.03 + }, + { + "open": 1564.03, + "high": 1566.14, + "low": 1561.79, + "close": 1563.28 + }, + { + "open": 1563.27, + "high": 1569.75, + "low": 1562.43, + "close": 1569.75 + }, + { + "open": 1569.75, + "high": 1571.84, + "low": 1566.17, + "close": 1569.27 + }, + { + "open": 1569.27, + "high": 1569.28, + "low": 1563.78, + "close": 1563.84 + }, + { + "open": 1563.84, + "high": 1565.98, + "low": 1563.84, + "close": 1564.01 + }, + { + "open": 1564.01, + "high": 1566, + "low": 1562.21, + "close": 1563.5 + }, + { + "open": 1563.51, + "high": 1566.46, + "low": 1562.51, + "close": 1564.33 + }, + { + "open": 1564.34, + "high": 1566.17, + "low": 1564.07, + "close": 1565.09 + }, + { + "open": 1565.09, + "high": 1570.37, + "low": 1565.09, + "close": 1567.3 + }, + { + "open": 1567.29, + "high": 1567.3, + "low": 1564.01, + "close": 1564.01 + }, + { + "open": 1564.01, + "high": 1564.08, + "low": 1560.55, + "close": 1560.71 + }, + { + "open": 1560.71, + "high": 1565.8, + "low": 1560.71, + "close": 1563.98 + }, + { + "open": 1563.97, + "high": 1566.38, + "low": 1563.86, + "close": 1564.27 + }, + { + "open": 1564.28, + "high": 1564.71, + "low": 1560.12, + "close": 1561.13 + }, + { + "open": 1561.13, + "high": 1561.66, + "low": 1551.32, + "close": 1556.13 + }, + { + "open": 1556.13, + "high": 1562.78, + "low": 1549.89, + "close": 1559.92 + }, + { + "open": 1559.92, + "high": 1559.97, + "low": 1545.67, + "close": 1552.36 + }, + { + "open": 1552.37, + "high": 1554.87, + "low": 1549.84, + "close": 1550.13 + }, + { + "open": 1550.12, + "high": 1555.85, + "low": 1549.76, + "close": 1553.41 + }, + { + "open": 1553.41, + "high": 1562.56, + "low": 1553.08, + "close": 1560.89 + }, + { + "open": 1560.9, + "high": 1561.67, + "low": 1556.7, + "close": 1557.88 + }, + { + "open": 1557.87, + "high": 1559.82, + "low": 1555.63, + "close": 1558.56 + }, + { + "open": 1558.56, + "high": 1558.91, + "low": 1555.59, + "close": 1557.08 + }, + { + "open": 1557.09, + "high": 1557.56, + "low": 1554.21, + "close": 1555.63 + }, + { + "open": 1555.62, + "high": 1556.44, + "low": 1553.5, + "close": 1556.34 + }, + { + "open": 1556.34, + "high": 1560.77, + "low": 1555.66, + "close": 1559.82 + }, + { + "open": 1559.83, + "high": 1567.93, + "low": 1559.82, + "close": 1561.87 + }, + { + "open": 1561.88, + "high": 1567.23, + "low": 1561.69, + "close": 1564.58 + }, + { + "open": 1564.58, + "high": 1565.55, + "low": 1561.26, + "close": 1561.58 + }, + { + "open": 1561.58, + "high": 1563.41, + "low": 1557.54, + "close": 1557.74 + }, + { + "open": 1557.73, + "high": 1559.22, + "low": 1556.8, + "close": 1557.69 + }, + { + "open": 1557.7, + "high": 1565.46, + "low": 1557.69, + "close": 1565.18 + }, + { + "open": 1565.18, + "high": 1566.39, + "low": 1563.34, + "close": 1564.35 + }, + { + "open": 1564.35, + "high": 1565.13, + "low": 1561.71, + "close": 1561.71 + }, + { + "open": 1561.72, + "high": 1561.85, + "low": 1557.41, + "close": 1558.38 + }, + { + "open": 1558.38, + "high": 1559.17, + "low": 1552.3, + "close": 1554.71 + }, + { + "open": 1554.7, + "high": 1555.89, + "low": 1552.21, + "close": 1553.36 + }, + { + "open": 1553.35, + "high": 1556.24, + "low": 1551.78, + "close": 1555.12 + }, + { + "open": 1555.12, + "high": 1557.49, + "low": 1553.78, + "close": 1554.54 + }, + { + "open": 1554.54, + "high": 1554.55, + "low": 1545.75, + "close": 1550.29 + }, + { + "open": 1550.29, + "high": 1554.52, + "low": 1549.21, + "close": 1552.37 + }, + { + "open": 1552.38, + "high": 1554.16, + "low": 1551.93, + "close": 1552.33 + }, + { + "open": 1552.33, + "high": 1553.41, + "low": 1551.41, + "close": 1551.65 + }, + { + "open": 1551.65, + "high": 1552.49, + "low": 1551, + "close": 1551.51 + }, + { + "open": 1551.51, + "high": 1556.79, + "low": 1550.86, + "close": 1553.86 + }, + { + "open": 1553.85, + "high": 1557.95, + "low": 1553.28, + "close": 1555.2 + }, + { + "open": 1555.19, + "high": 1555.45, + "low": 1546.9, + "close": 1553.41 + }, + { + "open": 1553.4, + "high": 1554.25, + "low": 1551.34, + "close": 1551.35 + }, + { + "open": 1551.35, + "high": 1553.57, + "low": 1551.1, + "close": 1551.67 + }, + { + "open": 1551.67, + "high": 1555.66, + "low": 1550.68, + "close": 1554.05 + }, + { + "open": 1554.09, + "high": 1560.4, + "low": 1554.09, + "close": 1559.55 + }, + { + "open": 1559.56, + "high": 1561.81, + "low": 1558.47, + "close": 1561.8 + }, + { + "open": 1561.81, + "high": 1561.81, + "low": 1558.59, + "close": 1559.39 + }, + { + "open": 1559.39, + "high": 1560.98, + "low": 1558.91, + "close": 1558.95 + }, + { + "open": 1558.96, + "high": 1563.63, + "low": 1557.85, + "close": 1558.69 + }, + { + "open": 1558.7, + "high": 1561.62, + "low": 1556.87, + "close": 1561.25 + }, + { + "open": 1561.25, + "high": 1572, + "low": 1560.1, + "close": 1564.23 + }, + { + "open": 1564.22, + "high": 1565.96, + "low": 1563.01, + "close": 1564.81 + }, + { + "open": 1564.81, + "high": 1579, + "low": 1563.25, + "close": 1577.63 + }, + { + "open": 1577.63, + "high": 1592.55, + "low": 1575.76, + "close": 1591.16 + }, + { + "open": 1591.17, + "high": 1603.78, + "low": 1590.69, + "close": 1594.31 + }, + { + "open": 1594.31, + "high": 1600.91, + "low": 1593.95, + "close": 1594.48 + }, + { + "open": 1594.47, + "high": 1599.53, + "low": 1589.52, + "close": 1590.67 + }, + { + "open": 1590.66, + "high": 1597.42, + "low": 1586.33, + "close": 1597.12 + }, + { + "open": 1597.12, + "high": 1608.5, + "low": 1596.08, + "close": 1607.22 + }, + { + "open": 1607.23, + "high": 1608.55, + "low": 1601.27, + "close": 1602.19 + }, + { + "open": 1602.19, + "high": 1604.2, + "low": 1599.23, + "close": 1601.92 + }, + { + "open": 1601.92, + "high": 1603.39, + "low": 1599.15, + "close": 1601.33 + }, + { + "open": 1601.33, + "high": 1604.92, + "low": 1600.37, + "close": 1604.71 + }, + { + "open": 1604.72, + "high": 1604.8, + "low": 1600.12, + "close": 1600.68 + }, + { + "open": 1600.69, + "high": 1605.89, + "low": 1600.23, + "close": 1604.66 + }, + { + "open": 1604.66, + "high": 1619.05, + "low": 1604.36, + "close": 1607.94 + }, + { + "open": 1607.95, + "high": 1613.84, + "low": 1605.04, + "close": 1612.56 + }, + { + "open": 1612.57, + "high": 1619.78, + "low": 1611.98, + "close": 1618.5 + }, + { + "open": 1618.49, + "high": 1619.35, + "low": 1612, + "close": 1614.67 + }, + { + "open": 1614.67, + "high": 1614.68, + "low": 1608.16, + "close": 1608.9 + }, + { + "open": 1608.89, + "high": 1612.96, + "low": 1608.89, + "close": 1610.35 + }, + { + "open": 1610.36, + "high": 1615.02, + "low": 1610.23, + "close": 1613.92 + }, + { + "open": 1613.91, + "high": 1614.82, + "low": 1611.6, + "close": 1612.52 + }, + { + "open": 1612.51, + "high": 1613.49, + "low": 1606.76, + "close": 1610.14 + }, + { + "open": 1610.14, + "high": 1615.15, + "low": 1608.17, + "close": 1613.23 + }, + { + "open": 1613.23, + "high": 1619.99, + "low": 1613.23, + "close": 1615.83 + }, + { + "open": 1615.84, + "high": 1617.05, + "low": 1610.28, + "close": 1610.39 + }, + { + "open": 1610.39, + "high": 1612.38, + "low": 1606.69, + "close": 1608.64 + }, + { + "open": 1608.64, + "high": 1611.02, + "low": 1608.16, + "close": 1610.05 + }, + { + "open": 1610.05, + "high": 1612, + "low": 1607.59, + "close": 1608.4 + }, + { + "open": 1608.39, + "high": 1609.76, + "low": 1603.32, + "close": 1603.65 + }, + { + "open": 1603.66, + "high": 1606.98, + "low": 1603.38, + "close": 1605.03 + }, + { + "open": 1605.03, + "high": 1611.78, + "low": 1605.03, + "close": 1610.29 + }, + { + "open": 1610.29, + "high": 1611.76, + "low": 1608.83, + "close": 1609.91 + }, + { + "open": 1609.9, + "high": 1609.93, + "low": 1604.31, + "close": 1605.01 + }, + { + "open": 1605.01, + "high": 1606.99, + "low": 1604.35, + "close": 1604.44 + }, + { + "open": 1604.44, + "high": 1607.45, + "low": 1599.66, + "close": 1601.09 + }, + { + "open": 1601.09, + "high": 1605, + "low": 1599.56, + "close": 1603.39 + }, + { + "open": 1603.39, + "high": 1604.36, + "low": 1601.89, + "close": 1604.17 + }, + { + "open": 1604.17, + "high": 1604.4, + "low": 1601.11, + "close": 1601.82 + }, + { + "open": 1601.82, + "high": 1602.58, + "low": 1597, + "close": 1598.54 + }, + { + "open": 1598.55, + "high": 1599.26, + "low": 1595, + "close": 1597.39 + }, + { + "open": 1597.4, + "high": 1599.58, + "low": 1595.34, + "close": 1595.5 + }, + { + "open": 1595.49, + "high": 1597.72, + "low": 1594, + "close": 1596.51 + }, + { + "open": 1596.5, + "high": 1608.06, + "low": 1596.03, + "close": 1605.83 + }, + { + "open": 1605.84, + "high": 1610.01, + "low": 1602.77, + "close": 1603.14 + }, + { + "open": 1603.13, + "high": 1605.96, + "low": 1602.02, + "close": 1605.76 + }, + { + "open": 1605.76, + "high": 1606.06, + "low": 1602.56, + "close": 1603.5 + }, + { + "open": 1603.5, + "high": 1608.17, + "low": 1603, + "close": 1606.46 + }, + { + "open": 1606.47, + "high": 1606.57, + "low": 1600.37, + "close": 1600.49 + }, + { + "open": 1600.49, + "high": 1603.15, + "low": 1599, + "close": 1602.38 + }, + { + "open": 1602.38, + "high": 1605.76, + "low": 1602.33, + "close": 1604.24 + }, + { + "open": 1604.24, + "high": 1613.6, + "low": 1603.63, + "close": 1608.86 + }, + { + "open": 1608.85, + "high": 1608.86, + "low": 1605.31, + "close": 1607.69 + }, + { + "open": 1607.69, + "high": 1611.81, + "low": 1606.26, + "close": 1606.85 + }, + { + "open": 1606.85, + "high": 1607.62, + "low": 1602.87, + "close": 1603.66 + }, + { + "open": 1603.67, + "high": 1603.9, + "low": 1600.29, + "close": 1602.19 + }, + { + "open": 1602.19, + "high": 1602.64, + "low": 1598.88, + "close": 1599.6 + }, + { + "open": 1599.6, + "high": 1602.21, + "low": 1599.59, + "close": 1601.28 + }, + { + "open": 1601.29, + "high": 1602.42, + "low": 1596.21, + "close": 1598 + }, + { + "open": 1598, + "high": 1600, + "low": 1597, + "close": 1599.99 + }, + { + "open": 1600, + "high": 1600.46, + "low": 1598.05, + "close": 1598.1 + }, + { + "open": 1598.11, + "high": 1600.46, + "low": 1597.24, + "close": 1598.62 + }, + { + "open": 1598.62, + "high": 1604.32, + "low": 1597.61, + "close": 1599.57 + }, + { + "open": 1599.58, + "high": 1603.67, + "low": 1599.05, + "close": 1602.84 + }, + { + "open": 1602.84, + "high": 1603.41, + "low": 1601.33, + "close": 1601.71 + }, + { + "open": 1601.72, + "high": 1604.38, + "low": 1600.84, + "close": 1601.06 + }, + { + "open": 1601.07, + "high": 1601.07, + "low": 1584.25, + "close": 1585.56 + }, + { + "open": 1585.57, + "high": 1590.35, + "low": 1583, + "close": 1583.3 + }, + { + "open": 1583.31, + "high": 1585.59, + "low": 1582, + "close": 1582.99 + }, + { + "open": 1582.99, + "high": 1587.47, + "low": 1580, + "close": 1585 + }, + { + "open": 1584.87, + "high": 1586.53, + "low": 1584.36, + "close": 1585.54 + }, + { + "open": 1585.53, + "high": 1592, + "low": 1583.94, + "close": 1590.4 + }, + { + "open": 1590.41, + "high": 1591.7, + "low": 1587.77, + "close": 1591.67 + }, + { + "open": 1591.67, + "high": 1627.93, + "low": 1591.67, + "close": 1619.34 + }, + { + "open": 1619.35, + "high": 1627.28, + "low": 1615.54, + "close": 1620.06 + }, + { + "open": 1620.07, + "high": 1627.91, + "low": 1616.57, + "close": 1618.04 + }, + { + "open": 1618.03, + "high": 1622.4, + "low": 1617.04, + "close": 1620.09 + }, + { + "open": 1620.09, + "high": 1628.86, + "low": 1615.37, + "close": 1615.63 + }, + { + "open": 1615.63, + "high": 1622.18, + "low": 1615.37, + "close": 1621.29 + }, + { + "open": 1621.3, + "high": 1622.8, + "low": 1620, + "close": 1620.51 + }, + { + "open": 1620.5, + "high": 1621.89, + "low": 1613.17, + "close": 1615.52 + }, + { + "open": 1615.53, + "high": 1617.4, + "low": 1614.06, + "close": 1615.32 + }, + { + "open": 1615.33, + "high": 1620.03, + "low": 1615.32, + "close": 1615.85 + }, + { + "open": 1615.84, + "high": 1619.22, + "low": 1603.38, + "close": 1606.41 + }, + { + "open": 1606.41, + "high": 1615.27, + "low": 1606.33, + "close": 1614.67 + }, + { + "open": 1614.68, + "high": 1618.39, + "low": 1614, + "close": 1617.37 + }, + { + "open": 1617.38, + "high": 1620.43, + "low": 1615.78, + "close": 1618.73 + }, + { + "open": 1618.72, + "high": 1618.73, + "low": 1610.77, + "close": 1611.04 + }, + { + "open": 1611.03, + "high": 1614.99, + "low": 1611.03, + "close": 1612.59 + }, + { + "open": 1612.59, + "high": 1613.22, + "low": 1605.77, + "close": 1606.25 + }, + { + "open": 1606.25, + "high": 1608.57, + "low": 1604.04, + "close": 1606.66 + }, + { + "open": 1606.66, + "high": 1609.22, + "low": 1594.74, + "close": 1596.49 + }, + { + "open": 1596.48, + "high": 1600.36, + "low": 1596.16, + "close": 1597.82 + }, + { + "open": 1597.82, + "high": 1598.27, + "low": 1586.09, + "close": 1587.01 + }, + { + "open": 1587.01, + "high": 1589.01, + "low": 1576.53, + "close": 1578.03 + }, + { + "open": 1578.03, + "high": 1583.88, + "low": 1575, + "close": 1579.57 + }, + { + "open": 1579.57, + "high": 1585.38, + "low": 1578.04, + "close": 1584.92 + }, + { + "open": 1584.92, + "high": 1585.58, + "low": 1579.7, + "close": 1584.64 + }, + { + "open": 1584.64, + "high": 1585.69, + "low": 1580.78, + "close": 1582.9 + }, + { + "open": 1582.91, + "high": 1582.91, + "low": 1576.04, + "close": 1578.85 + }, + { + "open": 1578.85, + "high": 1584.74, + "low": 1578.84, + "close": 1584.44 + }, + { + "open": 1584.43, + "high": 1584.94, + "low": 1575, + "close": 1579.18 + }, + { + "open": 1579.18, + "high": 1583.31, + "low": 1579.09, + "close": 1579.83 + }, + { + "open": 1579.83, + "high": 1582.08, + "low": 1573, + "close": 1577.81 + }, + { + "open": 1577.8, + "high": 1582.09, + "low": 1576.19, + "close": 1581.71 + }, + { + "open": 1581.71, + "high": 1582.97, + "low": 1579.21, + "close": 1579.75 + }, + { + "open": 1579.74, + "high": 1583.99, + "low": 1579.52, + "close": 1581.78 + }, + { + "open": 1581.79, + "high": 1583.99, + "low": 1580.27, + "close": 1580.67 + }, + { + "open": 1580.68, + "high": 1589.57, + "low": 1573.86, + "close": 1582.09 + }, + { + "open": 1582.09, + "high": 1586.32, + "low": 1578.54, + "close": 1581.81 + }, + { + "open": 1581.81, + "high": 1588.44, + "low": 1581.8, + "close": 1587.46 + }, + { + "open": 1587.46, + "high": 1618, + "low": 1587.45, + "close": 1611.24 + }, + { + "open": 1611.24, + "high": 1614.26, + "low": 1605.36, + "close": 1612.02 + }, + { + "open": 1612.02, + "high": 1616, + "low": 1608.85, + "close": 1610.58 + }, + { + "open": 1610.59, + "high": 1612.66, + "low": 1607.85, + "close": 1609.51 + }, + { + "open": 1609.51, + "high": 1611.59, + "low": 1607.13, + "close": 1610.04 + }, + { + "open": 1610.04, + "high": 1610.16, + "low": 1603.11, + "close": 1609.25 + }, + { + "open": 1609.25, + "high": 1617.77, + "low": 1605.15, + "close": 1611.78 + }, + { + "open": 1611.77, + "high": 1612.94, + "low": 1608.89, + "close": 1610.54 + }, + { + "open": 1610.53, + "high": 1610.79, + "low": 1607.76, + "close": 1609.46 + }, + { + "open": 1609.46, + "high": 1611.35, + "low": 1607.06, + "close": 1608.93 + }, + { + "open": 1608.92, + "high": 1621.42, + "low": 1608.92, + "close": 1615.58 + }, + { + "open": 1615.59, + "high": 1617.94, + "low": 1609.66, + "close": 1610.42 + }, + { + "open": 1610.41, + "high": 1613.09, + "low": 1607.94, + "close": 1610.04 + }, + { + "open": 1610.03, + "high": 1612.39, + "low": 1608.86, + "close": 1612.38 + }, + { + "open": 1612.39, + "high": 1612.39, + "low": 1606.68, + "close": 1607.45 + }, + { + "open": 1607.45, + "high": 1608.6, + "low": 1603.5, + "close": 1606.03 + }, + { + "open": 1606.03, + "high": 1608.42, + "low": 1605.16, + "close": 1606.46 + }, + { + "open": 1606.46, + "high": 1609.6, + "low": 1605.99, + "close": 1608.61 + }, + { + "open": 1608.61, + "high": 1611.82, + "low": 1608.6, + "close": 1609.35 + }, + { + "open": 1609.36, + "high": 1613.69, + "low": 1608.97, + "close": 1612.61 + }, + { + "open": 1612.61, + "high": 1615.38, + "low": 1600.05, + "close": 1605.29 + }, + { + "open": 1605.29, + "high": 1610.87, + "low": 1594.48, + "close": 1595.84 + }, + { + "open": 1595.85, + "high": 1600.32, + "low": 1593.78, + "close": 1595.82 + }, + { + "open": 1595.81, + "high": 1596.14, + "low": 1588.38, + "close": 1590.05 + }, + { + "open": 1590.05, + "high": 1595, + "low": 1587.23, + "close": 1590.98 + }, + { + "open": 1590.98, + "high": 1590.99, + "low": 1584.71, + "close": 1587.17 + }, + { + "open": 1587.16, + "high": 1591.85, + "low": 1583.51, + "close": 1590.73 + }, + { + "open": 1590.74, + "high": 1594.04, + "low": 1590, + "close": 1592.56 + }, + { + "open": 1592.55, + "high": 1596.8, + "low": 1591.81, + "close": 1593.82 + }, + { + "open": 1593.82, + "high": 1594.8, + "low": 1588.17, + "close": 1590.81 + }, + { + "open": 1590.81, + "high": 1593.02, + "low": 1590.45, + "close": 1592.96 + }, + { + "open": 1592.96, + "high": 1593.35, + "low": 1590, + "close": 1591.3 + }, + { + "open": 1591.31, + "high": 1594.96, + "low": 1590.04, + "close": 1593.73 + }, + { + "open": 1593.74, + "high": 1594.35, + "low": 1592.3, + "close": 1593.39 + }, + { + "open": 1593.4, + "high": 1607.22, + "low": 1593, + "close": 1602.44 + }, + { + "open": 1602.45, + "high": 1611.36, + "low": 1602.44, + "close": 1608.78 + }, + { + "open": 1608.79, + "high": 1613.99, + "low": 1608, + "close": 1610.29 + }, + { + "open": 1610.27, + "high": 1610.8, + "low": 1605.45, + "close": 1607.02 + }, + { + "open": 1607.01, + "high": 1607.17, + "low": 1591.77, + "close": 1594.46 + }, + { + "open": 1594.46, + "high": 1598.08, + "low": 1593.34, + "close": 1596.43 + }, + { + "open": 1596.43, + "high": 1606.97, + "low": 1596.42, + "close": 1606.51 + }, + { + "open": 1606.51, + "high": 1606.51, + "low": 1600, + "close": 1601.63 + }, + { + "open": 1601.63, + "high": 1604.92, + "low": 1600.43, + "close": 1603.69 + }, + { + "open": 1603.68, + "high": 1604.44, + "low": 1600.92, + "close": 1603.73 + }, + { + "open": 1603.73, + "high": 1604.5, + "low": 1596.63, + "close": 1599.85 + }, + { + "open": 1599.86, + "high": 1603.51, + "low": 1597.73, + "close": 1601.35 + }, + { + "open": 1601.35, + "high": 1603.01, + "low": 1598.18, + "close": 1599.56 + }, + { + "open": 1599.57, + "high": 1606, + "low": 1598.74, + "close": 1605.58 + }, + { + "open": 1605.57, + "high": 1605.59, + "low": 1600.41, + "close": 1600.46 + }, + { + "open": 1600.45, + "high": 1602.81, + "low": 1599.72, + "close": 1601.36 + }, + { + "open": 1601.36, + "high": 1602.24, + "low": 1595.8, + "close": 1596.99 + }, + { + "open": 1597, + "high": 1599, + "low": 1590.72, + "close": 1596.49 + }, + { + "open": 1596.49, + "high": 1597.31, + "low": 1593.48, + "close": 1593.61 + }, + { + "open": 1593.62, + "high": 1598.96, + "low": 1593, + "close": 1597.03 + }, + { + "open": 1597.02, + "high": 1598, + "low": 1594.17, + "close": 1596.97 + }, + { + "open": 1596.96, + "high": 1599.64, + "low": 1595.32, + "close": 1597.92 + }, + { + "open": 1597.91, + "high": 1600.45, + "low": 1597.28, + "close": 1597.64 + }, + { + "open": 1597.65, + "high": 1599.3, + "low": 1593.85, + "close": 1596.34 + }, + { + "open": 1596.34, + "high": 1607.62, + "low": 1595.76, + "close": 1603.49 + }, + { + "open": 1603.49, + "high": 1606.36, + "low": 1595, + "close": 1596.49 + }, + { + "open": 1596.48, + "high": 1604.91, + "low": 1596.1, + "close": 1604.91 + }, + { + "open": 1604.9, + "high": 1610, + "low": 1602.61, + "close": 1607.95 + }, + { + "open": 1607.94, + "high": 1614.95, + "low": 1607.59, + "close": 1613.14 + }, + { + "open": 1613.14, + "high": 1613.15, + "low": 1610.38, + "close": 1610.46 + }, + { + "open": 1610.46, + "high": 1611.95, + "low": 1607.42, + "close": 1607.63 + }, + { + "open": 1607.62, + "high": 1609.74, + "low": 1603.26, + "close": 1603.53 + }, + { + "open": 1603.53, + "high": 1604.18, + "low": 1599.56, + "close": 1600.77 + }, + { + "open": 1600.78, + "high": 1604.25, + "low": 1600.77, + "close": 1602.76 + }, + { + "open": 1602.75, + "high": 1604.05, + "low": 1601.46, + "close": 1601.89 + }, + { + "open": 1601.89, + "high": 1606.86, + "low": 1601.01, + "close": 1603.48 + }, + { + "open": 1603.49, + "high": 1605.52, + "low": 1600.24, + "close": 1600.55 + }, + { + "open": 1600.55, + "high": 1603.27, + "low": 1598.87, + "close": 1601.6 + }, + { + "open": 1601.6, + "high": 1605.65, + "low": 1600.97, + "close": 1605 + }, + { + "open": 1605, + "high": 1607.97, + "low": 1602.92, + "close": 1604.44 + }, + { + "open": 1604.44, + "high": 1606.44, + "low": 1602.58, + "close": 1605.59 + }, + { + "open": 1605.58, + "high": 1607.69, + "low": 1604.75, + "close": 1607.22 + }, + { + "open": 1607.23, + "high": 1620.12, + "low": 1607.17, + "close": 1618.72 + }, + { + "open": 1618.71, + "high": 1618.86, + "low": 1611.1, + "close": 1613.18 + }, + { + "open": 1613.19, + "high": 1613.72, + "low": 1610.16, + "close": 1612.13 + }, + { + "open": 1612.1, + "high": 1612.64, + "low": 1608.05, + "close": 1608.25 + }, + { + "open": 1608.25, + "high": 1617.11, + "low": 1608, + "close": 1614.17 + }, + { + "open": 1614.16, + "high": 1614.64, + "low": 1607.77, + "close": 1613.48 + }, + { + "open": 1613.48, + "high": 1625, + "low": 1612.54, + "close": 1618.6 + }, + { + "open": 1618.6, + "high": 1622.92, + "low": 1615.17, + "close": 1622.83 + }, + { + "open": 1622.84, + "high": 1626.94, + "low": 1620.14, + "close": 1621.06 + }, + { + "open": 1621.07, + "high": 1621.87, + "low": 1617.82, + "close": 1619.46 + }, + { + "open": 1619.45, + "high": 1634.2, + "low": 1618.5, + "close": 1627.23 + }, + { + "open": 1627.23, + "high": 1627.39, + "low": 1620.18, + "close": 1622.36 + }, + { + "open": 1622.36, + "high": 1626.93, + "low": 1616.66, + "close": 1620.82 + }, + { + "open": 1620.82, + "high": 1623.84, + "low": 1618.85, + "close": 1622.1 + }, + { + "open": 1622.1, + "high": 1622.15, + "low": 1618.52, + "close": 1620.43 + }, + { + "open": 1620.42, + "high": 1625, + "low": 1619.49, + "close": 1622 + }, + { + "open": 1622, + "high": 1625.99, + "low": 1615.31, + "close": 1617.81 + }, + { + "open": 1617.81, + "high": 1627, + "low": 1616.03, + "close": 1624.35 + }, + { + "open": 1624.36, + "high": 1629.49, + "low": 1624.35, + "close": 1626.81 + }, + { + "open": 1626.81, + "high": 1628.9, + "low": 1624.37, + "close": 1624.84 + }, + { + "open": 1624.85, + "high": 1628.28, + "low": 1622.99, + "close": 1623.1 + }, + { + "open": 1623.1, + "high": 1627.99, + "low": 1621.44, + "close": 1624.59 + }, + { + "open": 1624.6, + "high": 1626.35, + "low": 1622.57, + "close": 1623.59 + }, + { + "open": 1623.6, + "high": 1642.89, + "low": 1623.3, + "close": 1640.62 + }, + { + "open": 1640.63, + "high": 1661.98, + "low": 1634.91, + "close": 1660.48 + }, + { + "open": 1660.49, + "high": 1664.34, + "low": 1644.84, + "close": 1645.57 + }, + { + "open": 1645.58, + "high": 1646.67, + "low": 1595, + "close": 1608.22 + }, + { + "open": 1608.22, + "high": 1614.88, + "low": 1601.43, + "close": 1605.36 + }, + { + "open": 1605.36, + "high": 1609.8, + "low": 1591.92, + "close": 1592.38 + }, + { + "open": 1592.39, + "high": 1602.45, + "low": 1589.01, + "close": 1600 + }, + { + "open": 1600, + "high": 1607.26, + "low": 1599, + "close": 1606.64 + }, + { + "open": 1606.64, + "high": 1607.7, + "low": 1594.71, + "close": 1598.66 + }, + { + "open": 1598.66, + "high": 1604.57, + "low": 1595.3, + "close": 1602.48 + }, + { + "open": 1602.48, + "high": 1612.55, + "low": 1601.32, + "close": 1610.62 + }, + { + "open": 1610.63, + "high": 1615.92, + "low": 1608.01, + "close": 1610.8 + }, + { + "open": 1610.79, + "high": 1612.97, + "low": 1605.89, + "close": 1608.29 + }, + { + "open": 1608.3, + "high": 1609.65, + "low": 1605.11, + "close": 1605.12 + }, + { + "open": 1605.12, + "high": 1607.57, + "low": 1598.35, + "close": 1602.85 + }, + { + "open": 1602.85, + "high": 1602.86, + "low": 1598.44, + "close": 1599.51 + }, + { + "open": 1599.5, + "high": 1600.05, + "low": 1593.32, + "close": 1597.7 + }, + { + "open": 1597.7, + "high": 1603.62, + "low": 1596, + "close": 1598.76 + }, + { + "open": 1598.75, + "high": 1601.76, + "low": 1595.1, + "close": 1600.62 + }, + { + "open": 1600.53, + "high": 1604, + "low": 1596.81, + "close": 1602.64 + }, + { + "open": 1602.63, + "high": 1609.55, + "low": 1602, + "close": 1602.44 + }, + { + "open": 1602.45, + "high": 1603.9, + "low": 1592.51, + "close": 1593.89 + }, + { + "open": 1593.89, + "high": 1594.71, + "low": 1565.67, + "close": 1567.64 + }, + { + "open": 1567.63, + "high": 1570, + "low": 1560, + "close": 1561.39 + }, + { + "open": 1561.4, + "high": 1568.6, + "low": 1560, + "close": 1560.15 + }, + { + "open": 1560.14, + "high": 1567.72, + "low": 1558.34, + "close": 1562.48 + }, + { + "open": 1562.47, + "high": 1567.56, + "low": 1556.87, + "close": 1557.4 + }, + { + "open": 1557.39, + "high": 1562.84, + "low": 1555.8, + "close": 1559.22 + }, + { + "open": 1559.22, + "high": 1560.95, + "low": 1555.83, + "close": 1558.29 + }, + { + "open": 1558.3, + "high": 1558.71, + "low": 1552.58, + "close": 1557.29 + }, + { + "open": 1557.29, + "high": 1558.05, + "low": 1550.73, + "close": 1552.73 + }, + { + "open": 1552.73, + "high": 1556.73, + "low": 1551.93, + "close": 1555.62 + }, + { + "open": 1555.62, + "high": 1558.32, + "low": 1553.89, + "close": 1558.24 + }, + { + "open": 1558.23, + "high": 1560.73, + "low": 1555.85, + "close": 1556.41 + }, + { + "open": 1556.42, + "high": 1557.28, + "low": 1546.5, + "close": 1550.45 + }, + { + "open": 1550.44, + "high": 1553.2, + "low": 1548.64, + "close": 1551.76 + }, + { + "open": 1551.76, + "high": 1553.97, + "low": 1550.33, + "close": 1551.83 + }, + { + "open": 1551.83, + "high": 1551.84, + "low": 1536.1, + "close": 1545.23 + }, + { + "open": 1545.23, + "high": 1545.35, + "low": 1536.02, + "close": 1536.77 + }, + { + "open": 1536.78, + "high": 1545.98, + "low": 1535, + "close": 1545.85 + }, + { + "open": 1545.84, + "high": 1546.92, + "low": 1542.7, + "close": 1544.4 + }, + { + "open": 1544.41, + "high": 1544.41, + "low": 1539.46, + "close": 1542.9 + }, + { + "open": 1542.91, + "high": 1543.88, + "low": 1532.08, + "close": 1537.46 + }, + { + "open": 1537.46, + "high": 1539.81, + "low": 1528.5, + "close": 1539.6 + }, + { + "open": 1539.59, + "high": 1539.91, + "low": 1534.97, + "close": 1537.88 + }, + { + "open": 1537.89, + "high": 1538.41, + "low": 1519.26, + "close": 1529.17 + }, + { + "open": 1529.17, + "high": 1531.96, + "low": 1523.22, + "close": 1530.51 + }, + { + "open": 1530.51, + "high": 1530.53, + "low": 1516.53, + "close": 1516.54 + }, + { + "open": 1516.54, + "high": 1523, + "low": 1515.92, + "close": 1520.8 + }, + { + "open": 1520.8, + "high": 1523.87, + "low": 1519.23, + "close": 1520.13 + }, + { + "open": 1520.12, + "high": 1525.47, + "low": 1519.92, + "close": 1522.6 + }, + { + "open": 1522.59, + "high": 1523.37, + "low": 1518.67, + "close": 1520.02 + }, + { + "open": 1520.01, + "high": 1522.76, + "low": 1517.3, + "close": 1520.03 + }, + { + "open": 1520.03, + "high": 1522.47, + "low": 1514.84, + "close": 1522.2 + }, + { + "open": 1522.19, + "high": 1523.61, + "low": 1519.61, + "close": 1523.36 + }, + { + "open": 1523.36, + "high": 1523.74, + "low": 1520.13, + "close": 1523.27 + }, + { + "open": 1523.27, + "high": 1523.78, + "low": 1513.68, + "close": 1515.15 + }, + { + "open": 1515.14, + "high": 1516.51, + "low": 1508.06, + "close": 1512.14 + }, + { + "open": 1512.15, + "high": 1515.4, + "low": 1511.29, + "close": 1515.02 + }, + { + "open": 1515.02, + "high": 1516.74, + "low": 1512.8, + "close": 1515.19 + }, + { + "open": 1515.19, + "high": 1518.29, + "low": 1514.84, + "close": 1517.85 + }, + { + "open": 1517.85, + "high": 1518.5, + "low": 1509.02, + "close": 1511.14 + }, + { + "open": 1511.14, + "high": 1512.83, + "low": 1509.19, + "close": 1509.79 + }, + { + "open": 1509.78, + "high": 1510, + "low": 1502, + "close": 1504.63 + }, + { + "open": 1504.63, + "high": 1512, + "low": 1498.95, + "close": 1511.51 + }, + { + "open": 1511.51, + "high": 1512.42, + "low": 1506.71, + "close": 1506.71 + }, + { + "open": 1506.71, + "high": 1508.89, + "low": 1504.28, + "close": 1505.15 + }, + { + "open": 1505.15, + "high": 1509.41, + "low": 1499.45, + "close": 1509.01 + }, + { + "open": 1509.01, + "high": 1513.92, + "low": 1507.81, + "close": 1513.12 + }, + { + "open": 1513.12, + "high": 1517.5, + "low": 1512, + "close": 1515.83 + }, + { + "open": 1515.84, + "high": 1515.84, + "low": 1511.78, + "close": 1514.56 + }, + { + "open": 1514.56, + "high": 1514.59, + "low": 1509.96, + "close": 1512.45 + }, + { + "open": 1512.46, + "high": 1514, + "low": 1510.35, + "close": 1513.82 + }, + { + "open": 1513.82, + "high": 1516.09, + "low": 1510.83, + "close": 1513.39 + }, + { + "open": 1513.38, + "high": 1514.71, + "low": 1511.55, + "close": 1514.06 + }, + { + "open": 1514.07, + "high": 1518.42, + "low": 1514.06, + "close": 1517.45 + }, + { + "open": 1517.44, + "high": 1524.17, + "low": 1517.24, + "close": 1522.76 + }, + { + "open": 1522.76, + "high": 1522.77, + "low": 1516.74, + "close": 1519.27 + }, + { + "open": 1519.27, + "high": 1526.14, + "low": 1518.36, + "close": 1525.37 + }, + { + "open": 1525.36, + "high": 1526.16, + "low": 1523.05, + "close": 1524.78 + }, + { + "open": 1524.78, + "high": 1529.84, + "low": 1524.12, + "close": 1524.83 + }, + { + "open": 1524.83, + "high": 1527.97, + "low": 1523.69, + "close": 1525.07 + }, + { + "open": 1525.07, + "high": 1525.91, + "low": 1515.94, + "close": 1517.07 + }, + { + "open": 1517.08, + "high": 1521.93, + "low": 1514.76, + "close": 1519.75 + }, + { + "open": 1519.75, + "high": 1523.8, + "low": 1518.82, + "close": 1522.53 + }, + { + "open": 1522.52, + "high": 1527.71, + "low": 1521.24, + "close": 1527.26 + }, + { + "open": 1527.26, + "high": 1527.26, + "low": 1523.51, + "close": 1524.17 + }, + { + "open": 1524.18, + "high": 1526.33, + "low": 1522.5, + "close": 1526.02 + }, + { + "open": 1526.02, + "high": 1528.99, + "low": 1525.63, + "close": 1528.01 + }, + { + "open": 1528, + "high": 1528.98, + "low": 1524, + "close": 1527.85 + }, + { + "open": 1527.85, + "high": 1527.91, + "low": 1524.87, + "close": 1527.02 + }, + { + "open": 1527.01, + "high": 1527.77, + "low": 1525.08, + "close": 1527.53 + }, + { + "open": 1527.54, + "high": 1527.9, + "low": 1525.01, + "close": 1525.54 + }, + { + "open": 1525.54, + "high": 1526.36, + "low": 1517.7, + "close": 1521.32 + }, + { + "open": 1521.33, + "high": 1523.72, + "low": 1520.06, + "close": 1523.15 + }, + { + "open": 1523.16, + "high": 1526.22, + "low": 1521.86, + "close": 1523.7 + }, + { + "open": 1523.7, + "high": 1523.83, + "low": 1517.23, + "close": 1517.7 + }, + { + "open": 1517.69, + "high": 1519.99, + "low": 1514.45, + "close": 1515.5 + }, + { + "open": 1515.49, + "high": 1520.24, + "low": 1515.12, + "close": 1517.38 + }, + { + "open": 1517.38, + "high": 1519, + "low": 1516.54, + "close": 1517.4 + }, + { + "open": 1517.39, + "high": 1521.31, + "low": 1516.5, + "close": 1519.95 + }, + { + "open": 1519.96, + "high": 1521.47, + "low": 1514.7, + "close": 1519.12 + }, + { + "open": 1519.12, + "high": 1523.72, + "low": 1518.67, + "close": 1523.67 + }, + { + "open": 1523.66, + "high": 1528.82, + "low": 1522.18, + "close": 1528.71 + }, + { + "open": 1528.82, + "high": 1529.12, + "low": 1523.63, + "close": 1525.19 + }, + { + "open": 1525.2, + "high": 1525.2, + "low": 1518.09, + "close": 1523.17 + }, + { + "open": 1523.17, + "high": 1525.99, + "low": 1521.52, + "close": 1522.46 + }, + { + "open": 1522.47, + "high": 1522.9, + "low": 1519.17, + "close": 1521.6 + }, + { + "open": 1521.6, + "high": 1524.29, + "low": 1521.54, + "close": 1524.29 + }, + { + "open": 1524.29, + "high": 1526, + "low": 1523.3, + "close": 1524.49 + }, + { + "open": 1524.5, + "high": 1526, + "low": 1524.05, + "close": 1524.89 + }, + { + "open": 1524.88, + "high": 1526.85, + "low": 1524, + "close": 1524.81 + }, + { + "open": 1524.81, + "high": 1524.81, + "low": 1522.11, + "close": 1522.12 + }, + { + "open": 1522.12, + "high": 1525.61, + "low": 1521.27, + "close": 1521.65 + }, + { + "open": 1521.64, + "high": 1522.47, + "low": 1518.51, + "close": 1519.48 + }, + { + "open": 1519.48, + "high": 1519.48, + "low": 1516.15, + "close": 1517.9 + }, + { + "open": 1517.91, + "high": 1521.05, + "low": 1517.3, + "close": 1519.79 + }, + { + "open": 1519.79, + "high": 1519.8, + "low": 1510.7, + "close": 1513.91 + }, + { + "open": 1513.9, + "high": 1516.09, + "low": 1512.83, + "close": 1513.43 + }, + { + "open": 1513.43, + "high": 1518.21, + "low": 1513, + "close": 1516.58 + }, + { + "open": 1516.59, + "high": 1533.5, + "low": 1516.09, + "close": 1531.9 + }, + { + "open": 1531.89, + "high": 1537.27, + "low": 1530.14, + "close": 1534.55 + }, + { + "open": 1534.55, + "high": 1541.23, + "low": 1534.34, + "close": 1536.95 + }, + { + "open": 1536.96, + "high": 1542.82, + "low": 1536.28, + "close": 1539.96 + }, + { + "open": 1539.97, + "high": 1542.51, + "low": 1535.03, + "close": 1536.75 + }, + { + "open": 1536.76, + "high": 1542.68, + "low": 1533.45, + "close": 1541.74 + }, + { + "open": 1541.75, + "high": 1543.99, + "low": 1538.04, + "close": 1538.45 + }, + { + "open": 1538.44, + "high": 1539.6, + "low": 1536.86, + "close": 1537.48 + }, + { + "open": 1537.47, + "high": 1537.48, + "low": 1534.36, + "close": 1536.22 + }, + { + "open": 1536.22, + "high": 1537.19, + "low": 1534.16, + "close": 1536.47 + }, + { + "open": 1536.46, + "high": 1536.56, + "low": 1531.43, + "close": 1532.8 + }, + { + "open": 1532.81, + "high": 1536.41, + "low": 1532.68, + "close": 1536.21 + }, + { + "open": 1536.22, + "high": 1538.61, + "low": 1535.4, + "close": 1537.8 + }, + { + "open": 1537.8, + "high": 1538.88, + "low": 1535.72, + "close": 1535.99 + }, + { + "open": 1535.99, + "high": 1537.93, + "low": 1535.82, + "close": 1537.49 + }, + { + "open": 1537.48, + "high": 1537.49, + "low": 1532.8, + "close": 1533.82 + }, + { + "open": 1533.82, + "high": 1536.84, + "low": 1533.62, + "close": 1535.49 + }, + { + "open": 1535.49, + "high": 1535.76, + "low": 1534.01, + "close": 1535.42 + }, + { + "open": 1535.42, + "high": 1536, + "low": 1532.17, + "close": 1534.71 + }, + { + "open": 1534.72, + "high": 1538, + "low": 1534.71, + "close": 1537.86 + }, + { + "open": 1537.85, + "high": 1548.43, + "low": 1537.85, + "close": 1545.4 + }, + { + "open": 1545.39, + "high": 1546, + "low": 1542.09, + "close": 1544.54 + }, + { + "open": 1544.54, + "high": 1545.49, + "low": 1542.13, + "close": 1544.51 + }, + { + "open": 1544.51, + "high": 1545.82, + "low": 1543.21, + "close": 1545.81 + }, + { + "open": 1545.81, + "high": 1546.46, + "low": 1543.57, + "close": 1544.9 + }, + { + "open": 1544.9, + "high": 1548.69, + "low": 1540, + "close": 1547.55 + }, + { + "open": 1547.64, + "high": 1549.87, + "low": 1544.78, + "close": 1547.71 + }, + { + "open": 1547.7, + "high": 1549.65, + "low": 1546.11, + "close": 1547.1 + }, + { + "open": 1547.1, + "high": 1548.13, + "low": 1546.6, + "close": 1547.01 + }, + { + "open": 1547, + "high": 1548.88, + "low": 1542.95, + "close": 1548.88 + }, + { + "open": 1548.88, + "high": 1553.79, + "low": 1544.51, + "close": 1544.77 + }, + { + "open": 1544.77, + "high": 1546.59, + "low": 1543.06, + "close": 1543.48 + }, + { + "open": 1543.48, + "high": 1543.49, + "low": 1535.69, + "close": 1536.78 + }, + { + "open": 1536.78, + "high": 1542.5, + "low": 1535.31, + "close": 1541.57 + }, + { + "open": 1541.57, + "high": 1543.7, + "low": 1538.61, + "close": 1538.77 + }, + { + "open": 1538.77, + "high": 1541.47, + "low": 1537.85, + "close": 1538.86 + }, + { + "open": 1538.86, + "high": 1541.55, + "low": 1536.48, + "close": 1539.18 + }, + { + "open": 1539.19, + "high": 1541.52, + "low": 1538.97, + "close": 1539.38 + }, + { + "open": 1539.38, + "high": 1543, + "low": 1536.55, + "close": 1540.87 + }, + { + "open": 1540.86, + "high": 1543.23, + "low": 1539.09, + "close": 1539.58 + }, + { + "open": 1539.58, + "high": 1540.92, + "low": 1536.45, + "close": 1537.5 + }, + { + "open": 1537.49, + "high": 1538.34, + "low": 1533.5, + "close": 1534.83 + }, + { + "open": 1534.84, + "high": 1534.84, + "low": 1517.11, + "close": 1518.98 + }, + { + "open": 1518.98, + "high": 1525.5, + "low": 1518.64, + "close": 1523.83 + }, + { + "open": 1523.82, + "high": 1531.92, + "low": 1520.33, + "close": 1529.16 + }, + { + "open": 1529.16, + "high": 1531.5, + "low": 1524, + "close": 1530.11 + }, + { + "open": 1530.12, + "high": 1530.2, + "low": 1525.52, + "close": 1527.15 + }, + { + "open": 1527.15, + "high": 1530.94, + "low": 1526.55, + "close": 1530.8 + }, + { + "open": 1530.79, + "high": 1530.8, + "low": 1526.56, + "close": 1527.75 + }, + { + "open": 1527.75, + "high": 1530.84, + "low": 1526.98, + "close": 1527.33 + }, + { + "open": 1527.34, + "high": 1528.92, + "low": 1523.77, + "close": 1528.01 + }, + { + "open": 1528.01, + "high": 1531.35, + "low": 1527.46, + "close": 1530.32 + }, + { + "open": 1530.32, + "high": 1538.65, + "low": 1529.45, + "close": 1536.07 + }, + { + "open": 1536.08, + "high": 1536.51, + "low": 1533.75, + "close": 1535.46 + }, + { + "open": 1535.46, + "high": 1536.81, + "low": 1533.28, + "close": 1533.29 + }, + { + "open": 1533.28, + "high": 1533.97, + "low": 1522.46, + "close": 1523.87 + }, + { + "open": 1523.86, + "high": 1524.03, + "low": 1517.61, + "close": 1523.73 + }, + { + "open": 1523.72, + "high": 1537.06, + "low": 1523.21, + "close": 1532.35 + }, + { + "open": 1532.36, + "high": 1532.36, + "low": 1525.15, + "close": 1527.63 + }, + { + "open": 1527.63, + "high": 1531.98, + "low": 1521.21, + "close": 1522.82 + }, + { + "open": 1522.82, + "high": 1522.82, + "low": 1506.7, + "close": 1516.24 + }, + { + "open": 1516.24, + "high": 1527.52, + "low": 1514.14, + "close": 1523.97 + }, + { + "open": 1523.97, + "high": 1523.98, + "low": 1516.85, + "close": 1520.44 + }, + { + "open": 1520.45, + "high": 1525.98, + "low": 1517.98, + "close": 1521.25 + }, + { + "open": 1521.26, + "high": 1526.16, + "low": 1520, + "close": 1521.76 + }, + { + "open": 1521.77, + "high": 1525, + "low": 1518.05, + "close": 1518.17 + }, + { + "open": 1518.17, + "high": 1518.66, + "low": 1510.75, + "close": 1516.05 + }, + { + "open": 1516.05, + "high": 1518.01, + "low": 1509.24, + "close": 1509.65 + }, + { + "open": 1509.65, + "high": 1513.46, + "low": 1507.45, + "close": 1511.38 + }, + { + "open": 1511.37, + "high": 1517.77, + "low": 1510.69, + "close": 1513.23 + }, + { + "open": 1513.24, + "high": 1513.85, + "low": 1502.21, + "close": 1510.72 + }, + { + "open": 1510.72, + "high": 1516.44, + "low": 1508.36, + "close": 1512.4 + }, + { + "open": 1512.4, + "high": 1518.94, + "low": 1507.61, + "close": 1517.46 + }, + { + "open": 1517.45, + "high": 1523.41, + "low": 1515.15, + "close": 1522.78 + }, + { + "open": 1522.78, + "high": 1524, + "low": 1518.84, + "close": 1520.49 + }, + { + "open": 1520.49, + "high": 1523.13, + "low": 1519.01, + "close": 1521.68 + }, + { + "open": 1521.68, + "high": 1527.81, + "low": 1520.5, + "close": 1524.68 + }, + { + "open": 1524.68, + "high": 1532, + "low": 1523.05, + "close": 1527.05 + }, + { + "open": 1527.04, + "high": 1528.39, + "low": 1523, + "close": 1523.42 + }, + { + "open": 1523.41, + "high": 1523.64, + "low": 1514, + "close": 1514.71 + }, + { + "open": 1514.72, + "high": 1518, + "low": 1511.35, + "close": 1517.24 + }, + { + "open": 1517.23, + "high": 1518.75, + "low": 1514.48, + "close": 1515.82 + }, + { + "open": 1515.81, + "high": 1522.58, + "low": 1514.01, + "close": 1521.13 + }, + { + "open": 1521.13, + "high": 1530.48, + "low": 1521.11, + "close": 1527.23 + }, + { + "open": 1527.23, + "high": 1528.46, + "low": 1522.57, + "close": 1525.67 + }, + { + "open": 1525.67, + "high": 1528.64, + "low": 1525.67, + "close": 1528.35 + }, + { + "open": 1528.35, + "high": 1531.14, + "low": 1523.51, + "close": 1523.51 + }, + { + "open": 1523.51, + "high": 1528.76, + "low": 1523.48, + "close": 1527.56 + }, + { + "open": 1527.57, + "high": 1527.99, + "low": 1524.99, + "close": 1526.03 + }, + { + "open": 1526.02, + "high": 1534.93, + "low": 1524.67, + "close": 1532.6 + }, + { + "open": 1532.6, + "high": 1534.19, + "low": 1529.44, + "close": 1529.96 + }, + { + "open": 1529.96, + "high": 1531.24, + "low": 1524.14, + "close": 1525.3 + }, + { + "open": 1525.29, + "high": 1526.09, + "low": 1520.01, + "close": 1520.14 + }, + { + "open": 1520.15, + "high": 1520.51, + "low": 1517.04, + "close": 1519.66 + }, + { + "open": 1519.67, + "high": 1522.26, + "low": 1517.77, + "close": 1519.3 + }, + { + "open": 1519.31, + "high": 1523.43, + "low": 1518.31, + "close": 1520.11 + }, + { + "open": 1520.12, + "high": 1521.27, + "low": 1514.68, + "close": 1515.46 + }, + { + "open": 1515.46, + "high": 1523.5, + "low": 1515.46, + "close": 1523.21 + }, + { + "open": 1523.22, + "high": 1530.34, + "low": 1522.49, + "close": 1529.8 + }, + { + "open": 1529.8, + "high": 1530.43, + "low": 1526.66, + "close": 1527.1 + }, + { + "open": 1527.1, + "high": 1531.29, + "low": 1525.61, + "close": 1527.6 + }, + { + "open": 1527.61, + "high": 1530.92, + "low": 1527.6, + "close": 1530.26 + }, + { + "open": 1530.26, + "high": 1534.01, + "low": 1528.36, + "close": 1533 + }, + { + "open": 1532.93, + "high": 1534.94, + "low": 1529, + "close": 1529.91 + }, + { + "open": 1529.92, + "high": 1530.63, + "low": 1524.38, + "close": 1527.8 + }, + { + "open": 1527.8, + "high": 1529.94, + "low": 1524.87, + "close": 1526.36 + }, + { + "open": 1526.36, + "high": 1529.37, + "low": 1526.33, + "close": 1527.87 + }, + { + "open": 1527.87, + "high": 1529.29, + "low": 1526.79, + "close": 1527.17 + }, + { + "open": 1527.18, + "high": 1527.18, + "low": 1518.55, + "close": 1519.61 + }, + { + "open": 1519.6, + "high": 1521.47, + "low": 1517.39, + "close": 1520.78 + }, + { + "open": 1520.78, + "high": 1522.13, + "low": 1514.46, + "close": 1516.81 + }, + { + "open": 1516.8, + "high": 1517.6, + "low": 1512.82, + "close": 1513.66 + }, + { + "open": 1513.65, + "high": 1516.81, + "low": 1511.44, + "close": 1513.86 + }, + { + "open": 1513.86, + "high": 1515.47, + "low": 1507.11, + "close": 1509.29 + }, + { + "open": 1509.29, + "high": 1513.83, + "low": 1509.11, + "close": 1510.83 + }, + { + "open": 1510.83, + "high": 1518.12, + "low": 1510.83, + "close": 1515.33 + }, + { + "open": 1515.33, + "high": 1517.4, + "low": 1511.99, + "close": 1514.89 + }, + { + "open": 1514.88, + "high": 1518.67, + "low": 1513.78, + "close": 1516.93 + }, + { + "open": 1516.93, + "high": 1517.58, + "low": 1514.1, + "close": 1515.4 + }, + { + "open": 1515.4, + "high": 1518.96, + "low": 1514.65, + "close": 1518.43 + }, + { + "open": 1518.44, + "high": 1523.32, + "low": 1515.35, + "close": 1515.78 + }, + { + "open": 1515.78, + "high": 1521.43, + "low": 1515.46, + "close": 1519.21 + }, + { + "open": 1519.2, + "high": 1519.88, + "low": 1514.15, + "close": 1516.82 + }, + { + "open": 1516.82, + "high": 1517.28, + "low": 1510.85, + "close": 1513.86 + }, + { + "open": 1513.87, + "high": 1516.82, + "low": 1509.37, + "close": 1510.88 + }, + { + "open": 1510.88, + "high": 1512, + "low": 1505.9, + "close": 1505.93 + }, + { + "open": 1505.94, + "high": 1509.39, + "low": 1477.01, + "close": 1478.26 + }, + { + "open": 1478.26, + "high": 1484.64, + "low": 1474.16, + "close": 1482.69 + }, + { + "open": 1482.69, + "high": 1487.83, + "low": 1478.76, + "close": 1479.74 + }, + { + "open": 1479.74, + "high": 1481.79, + "low": 1470.8, + "close": 1474.95 + }, + { + "open": 1474.95, + "high": 1477.59, + "low": 1466.58, + "close": 1468.19 + }, + { + "open": 1468.2, + "high": 1474, + "low": 1455.17, + "close": 1466.85 + }, + { + "open": 1466.9, + "high": 1472.67, + "low": 1466.89, + "close": 1471.37 + }, + { + "open": 1471.37, + "high": 1478.04, + "low": 1467.14, + "close": 1470.07 + }, + { + "open": 1470.07, + "high": 1477.76, + "low": 1468.23, + "close": 1475.57 + }, + { + "open": 1475.56, + "high": 1491.72, + "low": 1474.88, + "close": 1488.58 + }, + { + "open": 1488.58, + "high": 1496.76, + "low": 1484.21, + "close": 1495 + }, + { + "open": 1495, + "high": 1523.99, + "low": 1492.01, + "close": 1514.98 + }, + { + "open": 1514.98, + "high": 1522.88, + "low": 1513.78, + "close": 1518.91 + }, + { + "open": 1518.91, + "high": 1522.64, + "low": 1517, + "close": 1520.67 + }, + { + "open": 1520.67, + "high": 1529.77, + "low": 1513.5, + "close": 1513.51 + }, + { + "open": 1513.51, + "high": 1520, + "low": 1513.28, + "close": 1517.2 + }, + { + "open": 1517.2, + "high": 1519.49, + "low": 1512.2, + "close": 1518.95 + }, + { + "open": 1518.95, + "high": 1519.49, + "low": 1513.26, + "close": 1515.54 + }, + { + "open": 1515.54, + "high": 1519.1, + "low": 1513.83, + "close": 1518.88 + }, + { + "open": 1518.89, + "high": 1538.51, + "low": 1518.88, + "close": 1531.01 + }, + { + "open": 1530.95, + "high": 1533.43, + "low": 1520.54, + "close": 1526.77 + }, + { + "open": 1526.77, + "high": 1530.58, + "low": 1524.92, + "close": 1527.44 + }, + { + "open": 1527.44, + "high": 1528.44, + "low": 1521.03, + "close": 1523.01 + }, + { + "open": 1523.01, + "high": 1524.56, + "low": 1517.95, + "close": 1521.65 + }, + { + "open": 1521.63, + "high": 1521.75, + "low": 1516.33, + "close": 1520.27 + }, + { + "open": 1520.28, + "high": 1523.83, + "low": 1518.53, + "close": 1523.21 + }, + { + "open": 1523.21, + "high": 1524, + "low": 1520.51, + "close": 1522.06 + }, + { + "open": 1522.05, + "high": 1524, + "low": 1519.38, + "close": 1520.38 + }, + { + "open": 1520.37, + "high": 1521.46, + "low": 1519.64, + "close": 1520.5 + }, + { + "open": 1520.5, + "high": 1525.95, + "low": 1520.49, + "close": 1523.01 + }, + { + "open": 1523, + "high": 1523.38, + "low": 1520.25, + "close": 1520.78 + }, + { + "open": 1520.77, + "high": 1521.39, + "low": 1512.85, + "close": 1517.63 + }, + { + "open": 1517.63, + "high": 1517.85, + "low": 1507.66, + "close": 1514.06 + }, + { + "open": 1514.05, + "high": 1517.93, + "low": 1514.01, + "close": 1516.79 + }, + { + "open": 1516.8, + "high": 1516.8, + "low": 1512.55, + "close": 1514.02 + }, + { + "open": 1514.02, + "high": 1514.89, + "low": 1508.97, + "close": 1510.44 + }, + { + "open": 1510.44, + "high": 1512.24, + "low": 1506.68, + "close": 1510.1 + }, + { + "open": 1510.09, + "high": 1511.6, + "low": 1494.65, + "close": 1495.68 + }, + { + "open": 1495.69, + "high": 1498.59, + "low": 1492.45, + "close": 1496.12 + }, + { + "open": 1496.13, + "high": 1496.13, + "low": 1482.16, + "close": 1487 + }, + { + "open": 1487, + "high": 1488.44, + "low": 1469, + "close": 1471.65 + }, + { + "open": 1471.65, + "high": 1476.97, + "low": 1467.08, + "close": 1469.98 + }, + { + "open": 1469.98, + "high": 1475.2, + "low": 1469.78, + "close": 1472.26 + }, + { + "open": 1472.25, + "high": 1487.99, + "low": 1472.25, + "close": 1485.05 + }, + { + "open": 1485.05, + "high": 1494.72, + "low": 1473.92, + "close": 1485.16 + }, + { + "open": 1485.16, + "high": 1485.17, + "low": 1478.93, + "close": 1482.48 + }, + { + "open": 1482.48, + "high": 1489, + "low": 1479.21, + "close": 1482.03 + }, + { + "open": 1482.03, + "high": 1484.28, + "low": 1475.43, + "close": 1481.37 + }, + { + "open": 1481.38, + "high": 1482.3, + "low": 1469.55, + "close": 1470.66 + }, + { + "open": 1470.66, + "high": 1479.85, + "low": 1469, + "close": 1477.53 + }, + { + "open": 1477.54, + "high": 1483.67, + "low": 1476.45, + "close": 1482.88 + }, + { + "open": 1482.89, + "high": 1487.37, + "low": 1478.08, + "close": 1483.37 + }, + { + "open": 1483.37, + "high": 1484.36, + "low": 1472.36, + "close": 1474.97 + }, + { + "open": 1474.97, + "high": 1478.78, + "low": 1474.69, + "close": 1477.75 + }, + { + "open": 1477.75, + "high": 1478.84, + "low": 1470, + "close": 1470.96 + }, + { + "open": 1470.95, + "high": 1472.38, + "low": 1464, + "close": 1470 + }, + { + "open": 1470, + "high": 1471.07, + "low": 1436.02, + "close": 1443.03 + }, + { + "open": 1443.04, + "high": 1451.98, + "low": 1435.58, + "close": 1447.55 + }, + { + "open": 1447.55, + "high": 1448.1, + "low": 1437.01, + "close": 1440.79 + }, + { + "open": 1440.79, + "high": 1450, + "low": 1432.99, + "close": 1443.3 + }, + { + "open": 1443.29, + "high": 1446.49, + "low": 1441.15, + "close": 1443.34 + }, + { + "open": 1443.34, + "high": 1446.58, + "low": 1437.11, + "close": 1438.52 + }, + { + "open": 1438.51, + "high": 1441.99, + "low": 1429.68, + "close": 1439.43 + }, + { + "open": 1439.43, + "high": 1440.82, + "low": 1427.1, + "close": 1433.08 + }, + { + "open": 1433.08, + "high": 1434.82, + "low": 1428.65, + "close": 1429.91 + }, + { + "open": 1429.91, + "high": 1433.24, + "low": 1427.02, + "close": 1430.25 + }, + { + "open": 1430.19, + "high": 1430.2, + "low": 1415.6, + "close": 1423.09 + }, + { + "open": 1423.09, + "high": 1432, + "low": 1421.92, + "close": 1427.53 + }, + { + "open": 1427.52, + "high": 1428.64, + "low": 1421.55, + "close": 1427.64 + }, + { + "open": 1427.64, + "high": 1430.1, + "low": 1415, + "close": 1415.98 + }, + { + "open": 1415.99, + "high": 1421.48, + "low": 1412.15, + "close": 1415.38 + }, + { + "open": 1415.38, + "high": 1424, + "low": 1412.45, + "close": 1423.02 + }, + { + "open": 1423.03, + "high": 1426.99, + "low": 1422, + "close": 1425.62 + }, + { + "open": 1425.61, + "high": 1427.39, + "low": 1421.18, + "close": 1421.98 + }, + { + "open": 1421.98, + "high": 1426.36, + "low": 1418.04, + "close": 1423.96 + }, + { + "open": 1423.97, + "high": 1424.1, + "low": 1418.49, + "close": 1418.49 + }, + { + "open": 1418.49, + "high": 1421.16, + "low": 1414.03, + "close": 1421.15 + }, + { + "open": 1421.16, + "high": 1421.16, + "low": 1415, + "close": 1417.04 + }, + { + "open": 1417.04, + "high": 1418.37, + "low": 1413.06, + "close": 1413.36 + }, + { + "open": 1413.37, + "high": 1423.37, + "low": 1413.21, + "close": 1422.91 + }, + { + "open": 1422.9, + "high": 1425.51, + "low": 1421.11, + "close": 1423.99 + }, + { + "open": 1423.98, + "high": 1425.09, + "low": 1422.73, + "close": 1423.61 + }, + { + "open": 1423.62, + "high": 1424.96, + "low": 1420, + "close": 1420.23 + }, + { + "open": 1420.23, + "high": 1421.87, + "low": 1419.01, + "close": 1421.02 + }, + { + "open": 1421.01, + "high": 1426.03, + "low": 1420.88, + "close": 1425.12 + }, + { + "open": 1425.11, + "high": 1425.68, + "low": 1421.85, + "close": 1423.23 + }, + { + "open": 1423.23, + "high": 1424.73, + "low": 1421.37, + "close": 1423.76 + }, + { + "open": 1423.75, + "high": 1424, + "low": 1418.3, + "close": 1420 + }, + { + "open": 1419.99, + "high": 1420.19, + "low": 1415.15, + "close": 1419.8 + }, + { + "open": 1419.79, + "high": 1421.88, + "low": 1418.08, + "close": 1420.88 + }, + { + "open": 1420.89, + "high": 1424.26, + "low": 1420.87, + "close": 1422.07 + }, + { + "open": 1422.06, + "high": 1423.13, + "low": 1420.59, + "close": 1422.39 + }, + { + "open": 1422.39, + "high": 1423.95, + "low": 1421.75, + "close": 1421.9 + }, + { + "open": 1421.9, + "high": 1423.15, + "low": 1419.15, + "close": 1423.14 + }, + { + "open": 1423.15, + "high": 1423.15, + "low": 1417.79, + "close": 1419.37 + }, + { + "open": 1419.38, + "high": 1419.57, + "low": 1416.26, + "close": 1417.29 + }, + { + "open": 1417.28, + "high": 1419.21, + "low": 1416.5, + "close": 1418.07 + }, + { + "open": 1418.06, + "high": 1418.07, + "low": 1400.46, + "close": 1415.32 + }, + { + "open": 1415.31, + "high": 1424.48, + "low": 1414.74, + "close": 1423.85 + }, + { + "open": 1423.86, + "high": 1424.14, + "low": 1419.33, + "close": 1421.39 + }, + { + "open": 1421.38, + "high": 1430.92, + "low": 1420.53, + "close": 1429.17 + }, + { + "open": 1429.17, + "high": 1438.16, + "low": 1428.17, + "close": 1436.56 + }, + { + "open": 1436.56, + "high": 1436.57, + "low": 1427.07, + "close": 1429.86 + }, + { + "open": 1429.86, + "high": 1429.87, + "low": 1422.71, + "close": 1425.41 + }, + { + "open": 1425.4, + "high": 1428.97, + "low": 1423.88, + "close": 1427.36 + }, + { + "open": 1427.36, + "high": 1432, + "low": 1426.91, + "close": 1431.33 + }, + { + "open": 1431.33, + "high": 1434.92, + "low": 1431.32, + "close": 1433.05 + }, + { + "open": 1433.04, + "high": 1435, + "low": 1431.4, + "close": 1434.81 + }, + { + "open": 1434.81, + "high": 1435.37, + "low": 1430.27, + "close": 1430.44 + }, + { + "open": 1430.45, + "high": 1433.4, + "low": 1430.1, + "close": 1432.26 + }, + { + "open": 1432.27, + "high": 1433.74, + "low": 1428.86, + "close": 1429.56 + }, + { + "open": 1429.56, + "high": 1434.14, + "low": 1428.46, + "close": 1432.89 + }, + { + "open": 1432.89, + "high": 1438.3, + "low": 1431.03, + "close": 1432.86 + }, + { + "open": 1432.87, + "high": 1433.99, + "low": 1431, + "close": 1433.84 + }, + { + "open": 1433.85, + "high": 1434.14, + "low": 1431.21, + "close": 1431.54 + }, + { + "open": 1431.53, + "high": 1433.37, + "low": 1430.3, + "close": 1431.32 + }, + { + "open": 1431.32, + "high": 1434, + "low": 1431.05, + "close": 1432.62 + }, + { + "open": 1432.62, + "high": 1433.24, + "low": 1425.55, + "close": 1426.28 + }, + { + "open": 1426.28, + "high": 1429.8, + "low": 1424.16, + "close": 1427.33 + }, + { + "open": 1427.33, + "high": 1427.88, + "low": 1424.32, + "close": 1426.04 + }, + { + "open": 1426.03, + "high": 1426.33, + "low": 1422.21, + "close": 1423.93 + }, + { + "open": 1423.93, + "high": 1425.44, + "low": 1422.55, + "close": 1423.89 + }, + { + "open": 1423.88, + "high": 1424.55, + "low": 1420.83, + "close": 1421.27 + }, + { + "open": 1421.26, + "high": 1423.64, + "low": 1420.6, + "close": 1422.77 + }, + { + "open": 1422.77, + "high": 1426.46, + "low": 1422.77, + "close": 1425.91 + }, + { + "open": 1425.92, + "high": 1426.42, + "low": 1422.2, + "close": 1422.72 + }, + { + "open": 1422.73, + "high": 1427.27, + "low": 1422.14, + "close": 1427.22 + }, + { + "open": 1427.22, + "high": 1430.2, + "low": 1425.67, + "close": 1428.24 + }, + { + "open": 1428.25, + "high": 1432.24, + "low": 1427.87, + "close": 1429.9 + }, + { + "open": 1429.89, + "high": 1430.99, + "low": 1428, + "close": 1428.42 + }, + { + "open": 1428.43, + "high": 1429.92, + "low": 1424.61, + "close": 1426.78 + }, + { + "open": 1426.79, + "high": 1427.3, + "low": 1424.19, + "close": 1424.26 + }, + { + "open": 1424.27, + "high": 1425.86, + "low": 1423.42, + "close": 1424.38 + }, + { + "open": 1424.37, + "high": 1424.47, + "low": 1420.8, + "close": 1421.27 + }, + { + "open": 1421.27, + "high": 1423.26, + "low": 1421.01, + "close": 1422.37 + }, + { + "open": 1422.37, + "high": 1426, + "low": 1421.84, + "close": 1424.07 + }, + { + "open": 1424.07, + "high": 1424.35, + "low": 1421.43, + "close": 1423.56 + }, + { + "open": 1423.55, + "high": 1423.71, + "low": 1416.58, + "close": 1417.41 + }, + { + "open": 1417.4, + "high": 1420.22, + "low": 1413.72, + "close": 1416.05 + }, + { + "open": 1416.06, + "high": 1417.5, + "low": 1414.67, + "close": 1416.57 + }, + { + "open": 1416.57, + "high": 1422.13, + "low": 1415.8, + "close": 1417.42 + }, + { + "open": 1417.42, + "high": 1417.88, + "low": 1415, + "close": 1416 + }, + { + "open": 1416.01, + "high": 1419.27, + "low": 1415.19, + "close": 1417.59 + }, + { + "open": 1417.59, + "high": 1418.69, + "low": 1415.76, + "close": 1416.86 + }, + { + "open": 1416.86, + "high": 1419.7, + "low": 1414.22, + "close": 1419.51 + }, + { + "open": 1419.5, + "high": 1421.79, + "low": 1417.87, + "close": 1420.19 + }, + { + "open": 1420.19, + "high": 1423.7, + "low": 1413.12, + "close": 1422.48 + }, + { + "open": 1422.49, + "high": 1423.28, + "low": 1421.01, + "close": 1422.19 + }, + { + "open": 1422.19, + "high": 1423.55, + "low": 1421.32, + "close": 1423.15 + }, + { + "open": 1423.15, + "high": 1423.67, + "low": 1421.4, + "close": 1421.76 + }, + { + "open": 1421.77, + "high": 1421.77, + "low": 1419.22, + "close": 1420.17 + }, + { + "open": 1420.18, + "high": 1421.76, + "low": 1416.74, + "close": 1417.99 + }, + { + "open": 1418, + "high": 1418.82, + "low": 1414.58, + "close": 1415.97 + }, + { + "open": 1415.97, + "high": 1417.49, + "low": 1409.57, + "close": 1412.33 + }, + { + "open": 1412.29, + "high": 1414.74, + "low": 1406.68, + "close": 1409.46 + }, + { + "open": 1409.46, + "high": 1410.48, + "low": 1406.23, + "close": 1406.35 + }, + { + "open": 1406.36, + "high": 1413.17, + "low": 1406.34, + "close": 1408.12 + }, + { + "open": 1408.12, + "high": 1411.39, + "low": 1406.3, + "close": 1406.63 + }, + { + "open": 1406.64, + "high": 1410.32, + "low": 1403.94, + "close": 1409.74 + }, + { + "open": 1409.74, + "high": 1411.89, + "low": 1407.7, + "close": 1411.32 + }, + { + "open": 1411.33, + "high": 1413.91, + "low": 1410.85, + "close": 1411.65 + }, + { + "open": 1411.64, + "high": 1414.31, + "low": 1410.68, + "close": 1413 + }, + { + "open": 1413, + "high": 1414.29, + "low": 1410.84, + "close": 1413.58 + }, + { + "open": 1413.59, + "high": 1418.54, + "low": 1413.56, + "close": 1417.54 + }, + { + "open": 1417.54, + "high": 1418.17, + "low": 1415.47, + "close": 1416.05 + }, + { + "open": 1416.05, + "high": 1416.25, + "low": 1413.66, + "close": 1414.45 + }, + { + "open": 1414.46, + "high": 1416.15, + "low": 1411.4, + "close": 1412.57 + }, + { + "open": 1412.57, + "high": 1414.98, + "low": 1411.25, + "close": 1414.75 + }, + { + "open": 1414.75, + "high": 1416, + "low": 1411, + "close": 1415.8 + }, + { + "open": 1415.79, + "high": 1417, + "low": 1413.48, + "close": 1414.55 + }, + { + "open": 1414.56, + "high": 1415.39, + "low": 1412.53, + "close": 1413.86 + }, + { + "open": 1413.86, + "high": 1417.88, + "low": 1413.13, + "close": 1417.18 + }, + { + "open": 1417.17, + "high": 1418.7, + "low": 1415.3, + "close": 1417.9 + }, + { + "open": 1417.89, + "high": 1421.6, + "low": 1417.51, + "close": 1420.29 + }, + { + "open": 1420.3, + "high": 1421.17, + "low": 1419, + "close": 1420.01 + }, + { + "open": 1420, + "high": 1422.16, + "low": 1419.5, + "close": 1421.11 + }, + { + "open": 1421.12, + "high": 1423.97, + "low": 1420.9, + "close": 1423.25 + }, + { + "open": 1423.26, + "high": 1423.26, + "low": 1420.76, + "close": 1420.82 + }, + { + "open": 1420.82, + "high": 1421.01, + "low": 1418.92, + "close": 1419.11 + }, + { + "open": 1419.11, + "high": 1421.25, + "low": 1419, + "close": 1421 + }, + { + "open": 1421.01, + "high": 1422.98, + "low": 1417.66, + "close": 1418.53 + }, + { + "open": 1418.52, + "high": 1419.44, + "low": 1415.01, + "close": 1415.47 + }, + { + "open": 1415.47, + "high": 1417.15, + "low": 1413.71, + "close": 1414.16 + }, + { + "open": 1414.15, + "high": 1416.22, + "low": 1413.37, + "close": 1414.09 + }, + { + "open": 1414.1, + "high": 1416.01, + "low": 1413, + "close": 1415.83 + }, + { + "open": 1415.84, + "high": 1418.37, + "low": 1413.65, + "close": 1414.5 + }, + { + "open": 1414.46, + "high": 1414.96, + "low": 1408.49, + "close": 1411.92 + }, + { + "open": 1411.92, + "high": 1412.58, + "low": 1409.72, + "close": 1412.57 + }, + { + "open": 1412.57, + "high": 1413.05, + "low": 1411.23, + "close": 1411.32 + }, + { + "open": 1411.32, + "high": 1412.81, + "low": 1408.13, + "close": 1409.79 + }, + { + "open": 1409.79, + "high": 1412.35, + "low": 1409.34, + "close": 1410.44 + }, + { + "open": 1410.43, + "high": 1412.92, + "low": 1408.61, + "close": 1412.09 + }, + { + "open": 1412.1, + "high": 1412.84, + "low": 1410.56, + "close": 1410.57 + }, + { + "open": 1410.56, + "high": 1411.32, + "low": 1409.19, + "close": 1409.59 + }, + { + "open": 1409.6, + "high": 1412.09, + "low": 1407.68, + "close": 1410.42 + }, + { + "open": 1410.43, + "high": 1414.86, + "low": 1409.94, + "close": 1414.1 + }, + { + "open": 1414.11, + "high": 1414.47, + "low": 1412.17, + "close": 1412.65 + }, + { + "open": 1412.66, + "high": 1413.85, + "low": 1411.93, + "close": 1413.43 + }, + { + "open": 1413.44, + "high": 1415.79, + "low": 1412.99, + "close": 1413.51 + }, + { + "open": 1413.52, + "high": 1414.6, + "low": 1410.26, + "close": 1410.34 + }, + { + "open": 1410.33, + "high": 1413.83, + "low": 1410.33, + "close": 1413.39 + }, + { + "open": 1413.38, + "high": 1414.82, + "low": 1406.46, + "close": 1407.52 + }, + { + "open": 1407.52, + "high": 1413.46, + "low": 1385.08, + "close": 1406.33 + }, + { + "open": 1406.33, + "high": 1406.42, + "low": 1396.54, + "close": 1403.7 + }, + { + "open": 1403.69, + "high": 1413.95, + "low": 1403.69, + "close": 1411.91 + }, + { + "open": 1411.92, + "high": 1420.39, + "low": 1411.91, + "close": 1414.33 + }, + { + "open": 1414.34, + "high": 1414.52, + "low": 1405.66, + "close": 1405.79 + }, + { + "open": 1405.8, + "high": 1409.11, + "low": 1399, + "close": 1399.24 + }, + { + "open": 1399.23, + "high": 1405.73, + "low": 1399.23, + "close": 1400.56 + }, + { + "open": 1400.55, + "high": 1402, + "low": 1396.96, + "close": 1400.96 + }, + { + "open": 1400.96, + "high": 1401.35, + "low": 1390.31, + "close": 1394.24 + }, + { + "open": 1394.24, + "high": 1395.43, + "low": 1387.54, + "close": 1389.76 + }, + { + "open": 1389.87, + "high": 1390.95, + "low": 1381.54, + "close": 1384.88 + }, + { + "open": 1384.87, + "high": 1388.96, + "low": 1383.78, + "close": 1388.13 + }, + { + "open": 1388.13, + "high": 1393.11, + "low": 1387.84, + "close": 1391.98 + }, + { + "open": 1391.99, + "high": 1393.4, + "low": 1387.03, + "close": 1391.28 + }, + { + "open": 1391.38, + "high": 1391.68, + "low": 1384.04, + "close": 1384.51 + }, + { + "open": 1384.52, + "high": 1387.12, + "low": 1369.29, + "close": 1377.99 + }, + { + "open": 1377.99, + "high": 1384.82, + "low": 1371, + "close": 1383.01 + }, + { + "open": 1383.01, + "high": 1388, + "low": 1381.75, + "close": 1383.05 + }, + { + "open": 1383.06, + "high": 1388.47, + "low": 1383.05, + "close": 1387.82 + }, + { + "open": 1387.82, + "high": 1390, + "low": 1382.78, + "close": 1388.01 + }, + { + "open": 1388, + "high": 1392.96, + "low": 1386.5, + "close": 1391.27 + }, + { + "open": 1391.2, + "high": 1392.61, + "low": 1387.5, + "close": 1389.07 + }, + { + "open": 1389.07, + "high": 1389.07, + "low": 1382.39, + "close": 1385.3 + }, + { + "open": 1385.3, + "high": 1392, + "low": 1384.63, + "close": 1387.41 + }, + { + "open": 1387.41, + "high": 1390.71, + "low": 1386.5, + "close": 1389.12 + }, + { + "open": 1389.11, + "high": 1392.6, + "low": 1382.5, + "close": 1391.93 + }, + { + "open": 1391.93, + "high": 1396.92, + "low": 1390.04, + "close": 1391.11 + }, + { + "open": 1391.11, + "high": 1394.3, + "low": 1383.57, + "close": 1385.3 + }, + { + "open": 1385.31, + "high": 1386.85, + "low": 1379.45, + "close": 1382.74 + }, + { + "open": 1382.74, + "high": 1384.96, + "low": 1379.24, + "close": 1382.24 + }, + { + "open": 1382.24, + "high": 1384.6, + "low": 1380.2, + "close": 1380.79 + }, + { + "open": 1380.78, + "high": 1382.77, + "low": 1376, + "close": 1381.3 + }, + { + "open": 1381.29, + "high": 1384.6, + "low": 1379.59, + "close": 1382.74 + }, + { + "open": 1382.75, + "high": 1385.95, + "low": 1381.72, + "close": 1384.68 + }, + { + "open": 1384.67, + "high": 1392.47, + "low": 1384.26, + "close": 1389.48 + }, + { + "open": 1389.49, + "high": 1392, + "low": 1388.82, + "close": 1390.13 + }, + { + "open": 1390.13, + "high": 1392.46, + "low": 1389.7, + "close": 1391.94 + }, + { + "open": 1391.94, + "high": 1393.95, + "low": 1388.32, + "close": 1388.68 + }, + { + "open": 1388.68, + "high": 1389.35, + "low": 1379.59, + "close": 1381.72 + }, + { + "open": 1381.71, + "high": 1384.78, + "low": 1379.64, + "close": 1384.68 + }, + { + "open": 1384.67, + "high": 1384.92, + "low": 1381.13, + "close": 1383.07 + }, + { + "open": 1383.07, + "high": 1383.24, + "low": 1375.5, + "close": 1375.74 + }, + { + "open": 1375.73, + "high": 1378.38, + "low": 1372.01, + "close": 1374.42 + }, + { + "open": 1374.42, + "high": 1377.99, + "low": 1366, + "close": 1377.51 + }, + { + "open": 1377.51, + "high": 1378.73, + "low": 1372.87, + "close": 1375.18 + }, + { + "open": 1375.18, + "high": 1378.38, + "low": 1371.96, + "close": 1376.76 + }, + { + "open": 1376.77, + "high": 1377.59, + "low": 1370.81, + "close": 1370.95 + }, + { + "open": 1370.95, + "high": 1374.62, + "low": 1363.87, + "close": 1367.88 + }, + { + "open": 1367.88, + "high": 1372, + "low": 1365.03, + "close": 1368.68 + }, + { + "open": 1368.67, + "high": 1373.02, + "low": 1367, + "close": 1370.9 + } +]`) + +func Test_GHFilter(t *testing.T) { + type args struct { + allKLines []types.KLine + window int + } + var klines []types.KLine + if err := json.Unmarshal(testGHFilterDataEthusdt5m, &klines); err != nil { + panic(err) + } + tests := []struct { + name string + args args + want float64 + }{ + { + name: "ETHUSDT G-H Filter 7", + args: args{ + allKLines: klines, + window: 7, + }, + want: 1373.71, + }, + { + name: "ETHUSDT G-H Filter 25", + args: args{ + allKLines: klines, + window: 25, + }, + want: 1376.21, + }, + { + name: "ETHUSDT G-H Filter 99", + args: args{ + allKLines: klines, + window: 99, + }, + want: 1378.96, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter := &GHFilter{IntervalWindow: types.IntervalWindow{Window: tt.args.window}} + for _, k := range klines { + filter.PushK(k) + } + got := filter.Last(0) + got = math.Trunc(got*100.0) / 100.0 + if got != tt.want { + t.Errorf("GHFilter.Last() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_GHFilterEstimationAccurate(t *testing.T) { + type args struct { + allKLines []types.KLine + priceF types.KLineValueMapper + window int + } + var klines []types.KLine + if err := json.Unmarshal(testGHFilterDataEthusdt5m, &klines); err != nil { + panic(err) + } + tests := []struct { + name string + args args + want float64 + }{ + { + name: "ETHUSDT G-H Filter square error 7", + args: args{ + allKLines: klines, + window: 7, + }, + }, + { + name: "ETHUSDT G-H Filter square error 25", + args: args{ + allKLines: klines, + window: 25, + }, + }, + { + name: "ETHUSDT G-H Filter square error 99", + args: args{ + allKLines: klines, + window: 99, + }, + }, + } + klineSquareError := func(base float64, k types.KLine) float64 { + openDiff := math.Abs(k.Open.Float64() - base) + highDiff := math.Abs(k.High.Float64() - base) + lowDiff := math.Abs(k.Low.Float64() - base) + closeDiff := math.Abs(k.Close.Float64() - base) + return openDiff*openDiff + highDiff*highDiff + lowDiff*lowDiff + closeDiff*closeDiff + } + closeSquareError := func(base float64, k types.KLine) float64 { + closeDiff := math.Abs(k.Close.Float64() - base) + return closeDiff * closeDiff + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter := &GHFilter{IntervalWindow: types.IntervalWindow{Window: tt.args.window}} + ewma := &EWMA{IntervalWindow: types.IntervalWindow{Window: tt.args.window}} + + var filterDiff2Sum, ewmaDiff2Sum float64 + var filterCloseDiff2Sum, ewmaCloseDiff2Sum float64 + for i, k := range klines { + // square error between last estimated state and current actual state + if i > 0 { + filterDiff2Sum += klineSquareError(filter.Last(0), k) + ewmaDiff2Sum += klineSquareError(ewma.Last(0), k) + filterCloseDiff2Sum += closeSquareError(filter.Last(0), k) + ewmaCloseDiff2Sum += closeSquareError(ewma.Last(0), k) + } + + // update estimations + filter.PushK(k) + ewma.PushK(k) + } + numEstimations := len(klines) - 1 + filterSquareErr := math.Sqrt(filterDiff2Sum / float64(numEstimations*4)) + ewmaSquareErr := math.Sqrt(ewmaDiff2Sum / float64(numEstimations*4)) + if filterSquareErr > ewmaSquareErr { + t.Errorf("filter K-Line square error %f > EWMA K-Line square error %v", filterSquareErr, ewmaSquareErr) + } + filterCloseSquareErr := math.Sqrt(filterCloseDiff2Sum / float64(numEstimations)) + ewmaCloseSquareErr := math.Sqrt(ewmaCloseDiff2Sum / float64(numEstimations)) + if filterCloseSquareErr > ewmaCloseSquareErr { + t.Errorf("filter close price square error %f > EWMA close price square error %v", filterCloseSquareErr, ewmaCloseSquareErr) + } + }) + } +} diff --git a/pkg/indicator/gma.go b/pkg/indicator/gma.go new file mode 100644 index 0000000..b4c573d --- /dev/null +++ b/pkg/indicator/gma.go @@ -0,0 +1,76 @@ +package indicator + +import ( + "math" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// Geometric Moving Average +// +// The Geometric Moving Average (GMA) is a technical analysis indicator that uses the geometric mean of a set of data points instead of +// the simple arithmetic mean used by traditional moving averages. It is calculated by taking the nth root of the product of the last n +// data points, where n is the length of the moving average. The resulting average is then plotted on the price chart as a line, which can +// be used to make predictions about future price movements. Because the GMA gives more weight to recent data points, it is typically more +// responsive to changes in the underlying data than a simple moving average. + +//go:generate callbackgen -type GMA +type GMA struct { + types.SeriesBase + types.IntervalWindow + SMA *SMA + UpdateCallbacks []func(value float64) +} + +func (inc *GMA) Last(i int) float64 { + if inc.SMA == nil { + return 0.0 + } + return math.Exp(inc.SMA.Last(i)) +} + +func (inc *GMA) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *GMA) Length() int { + return inc.SMA.Length() +} + +func (inc *GMA) Update(value float64) { + if inc.SMA == nil { + inc.SMA = &SMA{IntervalWindow: inc.IntervalWindow} + } + inc.SMA.Update(math.Log(value)) +} + +func (inc *GMA) Clone() (out *GMA) { + out = &GMA{ + IntervalWindow: inc.IntervalWindow, + SMA: inc.SMA.Clone().(*SMA), + } + out.SeriesBase.Series = out + return out +} + +func (inc *GMA) TestUpdate(value float64) *GMA { + out := inc.Clone() + out.Update(value) + return out +} + +var _ types.SeriesExtend = &GMA{} + +func (inc *GMA) PushK(k types.KLine) { + inc.Update(k.Close.Float64()) +} + +func (inc *GMA) LoadK(allKLines []types.KLine) { + for _, k := range allKLines { + inc.PushK(k) + } +} + +func (inc *GMA) BindK(target KLineClosedEmitter, symbol string, interval types.Interval) { + target.OnKLineClosed(types.KLineWith(symbol, interval, inc.PushK)) +} diff --git a/pkg/indicator/gma_callbacks.go b/pkg/indicator/gma_callbacks.go new file mode 100644 index 0000000..28e0cd8 --- /dev/null +++ b/pkg/indicator/gma_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type GMA"; DO NOT EDIT. + +package indicator + +import () + +func (inc *GMA) OnUpdate(cb func(value float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *GMA) EmitUpdate(value float64) { + for _, cb := range inc.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/gma_test.go b/pkg/indicator/gma_test.go new file mode 100644 index 0000000..006d0b6 --- /dev/null +++ b/pkg/indicator/gma_test.go @@ -0,0 +1,61 @@ +package indicator + +import ( + "encoding/json" + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +/* +python: + +import pandas as pd +from scipy.stats.mstats import gmean + +data = pd.Series([1.1,1.2,1.3,1.4,1.5,1.6,1.7,1.8,1.9,1.1,1.2,1.3,1.4,1.5,1.6,1.7,1.8,1.9,1.1,1.2,1.3,1.4,1.5,1.6,1.7,1.8,1.9]) +gmean(data[-5:]) +gmean(data[-6:-1]) +gmean(pd.concat(data[-4:], pd.Series([1.3]))) +*/ +func Test_GMA(t *testing.T) { + var randomPrices = []byte(`[1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9]`) + var input []fixedpoint.Value + if err := json.Unmarshal(randomPrices, &input); err != nil { + panic(err) + } + tests := []struct { + name string + kLines []types.KLine + want float64 + next float64 + update float64 + updateResult float64 + all int + }{ + { + name: "test", + kLines: buildKLines(input), + want: 1.6940930229200213, + next: 1.5937204331251167, + update: 1.3, + updateResult: 1.6462950504034335, + all: 24, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gma := GMA{IntervalWindow: types.IntervalWindow{Window: 5}} + for _, k := range tt.kLines { + gma.PushK(k) + } + assert.InDelta(t, tt.want, gma.Last(0), Delta) + assert.InDelta(t, tt.next, gma.Index(1), Delta) + gma.Update(tt.update) + assert.InDelta(t, tt.updateResult, gma.Last(0), Delta) + assert.Equal(t, tt.all, gma.Length()) + }) + } +} diff --git a/pkg/indicator/hull.go b/pkg/indicator/hull.go new file mode 100644 index 0000000..95e3b02 --- /dev/null +++ b/pkg/indicator/hull.go @@ -0,0 +1,68 @@ +package indicator + +import ( + "math" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// Refer: Hull Moving Average +// Refer URL: https://fidelity.com/learning-center/trading-investing/technical-analysis/technical-indicator-guide/hull-moving-average +// +// The Hull Moving Average (HMA) is a technical analysis indicator that uses a weighted moving average to reduce the lag in simple moving averages. +// It was developed by Alan Hull, who sought to create a moving average that was both fast and smooth. The HMA is calculated by first taking +// the weighted moving average of the input data using a weighting factor of W, where W is the square root of the length of the moving average. +// The result is then double-smoothed by taking the weighted moving average of this result using a weighting factor of W/2. This final average +// forms the HMA line, which can be used to make predictions about future price movements. +// +//go:generate callbackgen -type HULL +type HULL struct { + types.SeriesBase + types.IntervalWindow + ma1 *EWMA + ma2 *EWMA + result *EWMA + + updateCallbacks []func(value float64) +} + +var _ types.SeriesExtend = &HULL{} + +func (inc *HULL) Update(value float64) { + if inc.result == nil { + inc.SeriesBase.Series = inc + inc.ma1 = &EWMA{IntervalWindow: types.IntervalWindow{Interval: inc.Interval, Window: inc.Window / 2}} + inc.ma2 = &EWMA{IntervalWindow: inc.IntervalWindow} + inc.result = &EWMA{IntervalWindow: types.IntervalWindow{Interval: inc.Interval, Window: int(math.Sqrt(float64(inc.Window)))}} + } + inc.ma1.Update(value) + inc.ma2.Update(value) + inc.result.Update(2*inc.ma1.Last(0) - inc.ma2.Last(0)) +} + +func (inc *HULL) Last(i int) float64 { + if inc.result == nil { + return 0 + } + return inc.result.Last(i) +} + +func (inc *HULL) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *HULL) Length() int { + if inc.result == nil { + return 0 + } + return inc.result.Length() +} + +func (inc *HULL) PushK(k types.KLine) { + if inc.ma1 != nil && inc.ma1.Length() > 0 && k.EndTime.Before(inc.ma1.EndTime) { + return + } + + inc.Update(k.Close.Float64()) + inc.EmitUpdate(inc.Last(0)) +} diff --git a/pkg/indicator/hull_callbacks.go b/pkg/indicator/hull_callbacks.go new file mode 100644 index 0000000..5f6222f --- /dev/null +++ b/pkg/indicator/hull_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type HULL"; DO NOT EDIT. + +package indicator + +import () + +func (inc *HULL) OnUpdate(cb func(value float64)) { + inc.updateCallbacks = append(inc.updateCallbacks, cb) +} + +func (inc *HULL) EmitUpdate(value float64) { + for _, cb := range inc.updateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/hull_test.go b/pkg/indicator/hull_test.go new file mode 100644 index 0000000..edca1a5 --- /dev/null +++ b/pkg/indicator/hull_test.go @@ -0,0 +1,60 @@ +package indicator + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +/* +python: + +import pandas as pd +s = pd.Series([0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9]) +ma1 = s.ewm(span=8).mean() +ma2 = s.ewm(span=16).mean() +result = (2 * ma1 - ma2).ewm(span=4).mean() +print(result) +*/ +func Test_HULL(t *testing.T) { + var Delta = 1.5e-2 + var randomPrices = []byte(`[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]`) + var input []fixedpoint.Value + if err := json.Unmarshal(randomPrices, &input); err != nil { + panic(err) + } + + tests := []struct { + name string + kLines []types.KLine + want float64 + next float64 + all int + }{ + { + name: "random_case", + kLines: buildKLines(input), + want: 6.002935, + next: 5.167056, + all: 50, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hull := &HULL{IntervalWindow: types.IntervalWindow{Window: 16}} + for _, k := range tt.kLines { + hull.PushK(k) + } + + last := hull.Last(0) + assert.InDelta(t, tt.want, last, Delta) + assert.InDelta(t, tt.next, hull.Index(1), Delta) + assert.Equal(t, tt.all, hull.Length()) + }) + } +} diff --git a/pkg/indicator/interface.go b/pkg/indicator/interface.go new file mode 100644 index 0000000..b263a59 --- /dev/null +++ b/pkg/indicator/interface.go @@ -0,0 +1,34 @@ +package indicator + +import "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + +type KLineWindowUpdater interface { + OnKLineWindowUpdate(func(interval types.Interval, window types.KLineWindow)) +} + +type KLineClosedBinder interface { + BindK(target KLineClosedEmitter, symbol string, interval types.Interval) +} + +// KLineClosedEmitter is currently applied to the market data stream +// the market data stream emits the KLine closed event to the listeners. +type KLineClosedEmitter interface { + OnKLineClosed(func(k types.KLine)) +} + +// KLinePusher provides an interface for API user to push kline value to the indicator. +// The indicator implements its own way to calculate the value from the given kline object. +type KLinePusher interface { + PushK(k types.KLine) +} + +// Simple is the simple indicator that only returns one float64 value +type Simple interface { + KLinePusher + Last(int) float64 + OnUpdate(f func(value float64)) +} + +type KLineCalculateUpdater interface { + CalculateAndUpdate(allKLines []types.KLine) +} diff --git a/pkg/indicator/kalmanfilter.go b/pkg/indicator/kalmanfilter.go new file mode 100644 index 0000000..d892fcf --- /dev/null +++ b/pkg/indicator/kalmanfilter.go @@ -0,0 +1,78 @@ +package indicator + +import ( + "math" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// Refer: https://www.kalmanfilter.net/kalman1d.html +// One-dimensional Kalman filter + +//go:generate callbackgen -type KalmanFilter +type KalmanFilter struct { + types.SeriesBase + types.IntervalWindow + AdditionalSmoothWindow uint + amp2 *types.Queue // measurement uncertainty + k float64 // Kalman gain + measurements *types.Queue + Values floats.Slice + + UpdateCallbacks []func(value float64) +} + +func (inc *KalmanFilter) Update(value float64) { + var measureMove = value + if inc.measurements != nil { + measureMove = value - inc.measurements.Last(0) + } + inc.update(value, math.Abs(measureMove)) +} + +func (inc *KalmanFilter) update(value, amp float64) { + if len(inc.Values) == 0 { + inc.amp2 = types.NewQueue(inc.Window) + inc.amp2.Update(amp * amp) + inc.measurements = types.NewQueue(inc.Window) + inc.measurements.Update(value) + inc.Values.Push(value) + return + } + + // measurement + inc.measurements.Update(value) + inc.amp2.Update(amp * amp) + q := math.Sqrt(types.Mean(inc.amp2)) * float64(1+inc.AdditionalSmoothWindow) + + // update + lastPredict := inc.Values.Last(0) + curState := value + (value - lastPredict) + estimated := lastPredict + inc.k*(curState-lastPredict) + + // predict + inc.Values.Push(estimated) + p := math.Abs(curState - estimated) + inc.k = p / (p + q) +} + +func (inc *KalmanFilter) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *KalmanFilter) Length() int { + return inc.Values.Length() +} + +func (inc *KalmanFilter) Last(i int) float64 { + return inc.Values.Last(i) +} + +// interfaces implementation check +var _ Simple = &KalmanFilter{} +var _ types.SeriesExtend = &KalmanFilter{} + +func (inc *KalmanFilter) PushK(k types.KLine) { + inc.update(k.Close.Float64(), (k.High.Float64()-k.Low.Float64())/2) +} diff --git a/pkg/indicator/kalmanfilter_callbacks.go b/pkg/indicator/kalmanfilter_callbacks.go new file mode 100644 index 0000000..3dff5fc --- /dev/null +++ b/pkg/indicator/kalmanfilter_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type KalmanFilter"; DO NOT EDIT. + +package indicator + +import () + +func (inc *KalmanFilter) OnUpdate(cb func(value float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *KalmanFilter) EmitUpdate(value float64) { + for _, cb := range inc.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/kalmanfilter_test.go b/pkg/indicator/kalmanfilter_test.go new file mode 100644 index 0000000..6353eaa --- /dev/null +++ b/pkg/indicator/kalmanfilter_test.go @@ -0,0 +1,6191 @@ +package indicator + +import ( + "encoding/json" + "math" + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// generated from Binance 2022/07/27 00:00 +// https://www.binance.com/api/v3/klines?symbol=ETHUSDT&interval=5m&endTime=1658851200000&limit=1000 +var testKalmanFilterDataEthusdt5m = []byte(`[ + { + "open": 1591.11, + "high": 1593.62, + "low": 1589.04, + "close": 1590.14 + }, + { + "open": 1590.14, + "high": 1596.51, + "low": 1590.13, + "close": 1592.06 + }, + { + "open": 1592.07, + "high": 1594.41, + "low": 1586.05, + "close": 1587.02 + }, + { + "open": 1587.02, + "high": 1588.38, + "low": 1583.86, + "close": 1585.33 + }, + { + "open": 1585.34, + "high": 1595.2, + "low": 1583.74, + "close": 1594.69 + }, + { + "open": 1594.69, + "high": 1594.75, + "low": 1589.89, + "close": 1591.36 + }, + { + "open": 1591.35, + "high": 1592.55, + "low": 1586.36, + "close": 1588.95 + }, + { + "open": 1588.95, + "high": 1589.75, + "low": 1588.39, + "close": 1589.38 + }, + { + "open": 1589.38, + "high": 1589.39, + "low": 1586.17, + "close": 1588.53 + }, + { + "open": 1588.52, + "high": 1588.62, + "low": 1581.95, + "close": 1583.4 + }, + { + "open": 1583.4, + "high": 1584.67, + "low": 1582.1, + "close": 1582.36 + }, + { + "open": 1582.35, + "high": 1584.29, + "low": 1577.82, + "close": 1578.14 + }, + { + "open": 1578.14, + "high": 1581.95, + "low": 1575.72, + "close": 1581.52 + }, + { + "open": 1581.52, + "high": 1584.86, + "low": 1578.51, + "close": 1580.88 + }, + { + "open": 1580.88, + "high": 1581.77, + "low": 1578.74, + "close": 1581.11 + }, + { + "open": 1581.1, + "high": 1582.72, + "low": 1579.07, + "close": 1579.4 + }, + { + "open": 1579.4, + "high": 1580.93, + "low": 1578, + "close": 1579.6 + }, + { + "open": 1579.59, + "high": 1583.81, + "low": 1579.59, + "close": 1582.7 + }, + { + "open": 1582.7, + "high": 1583, + "low": 1577.24, + "close": 1579.45 + }, + { + "open": 1579.46, + "high": 1581.59, + "low": 1577.44, + "close": 1579.59 + }, + { + "open": 1579.58, + "high": 1581.41, + "low": 1579.22, + "close": 1580.56 + }, + { + "open": 1580.57, + "high": 1586.23, + "low": 1579.86, + "close": 1584.23 + }, + { + "open": 1584.22, + "high": 1587.36, + "low": 1584.22, + "close": 1585.15 + }, + { + "open": 1585.15, + "high": 1585.15, + "low": 1579.83, + "close": 1583.75 + }, + { + "open": 1583.74, + "high": 1592.49, + "low": 1583.45, + "close": 1587.76 + }, + { + "open": 1587.76, + "high": 1590.7, + "low": 1585.62, + "close": 1587.5 + }, + { + "open": 1587.51, + "high": 1587.51, + "low": 1579.53, + "close": 1581.16 + }, + { + "open": 1581.15, + "high": 1585.71, + "low": 1581.15, + "close": 1582.47 + }, + { + "open": 1582.46, + "high": 1582.86, + "low": 1567.58, + "close": 1571.52 + }, + { + "open": 1571.53, + "high": 1577.8, + "low": 1571.03, + "close": 1575.16 + }, + { + "open": 1575.16, + "high": 1578.06, + "low": 1572.18, + "close": 1576.66 + }, + { + "open": 1576.66, + "high": 1578, + "low": 1574.62, + "close": 1577.21 + }, + { + "open": 1577.2, + "high": 1584.57, + "low": 1576.61, + "close": 1584.05 + }, + { + "open": 1584.06, + "high": 1585.61, + "low": 1580, + "close": 1582.08 + }, + { + "open": 1582.08, + "high": 1583.4, + "low": 1579.43, + "close": 1579.43 + }, + { + "open": 1579.43, + "high": 1579.98, + "low": 1574.53, + "close": 1575.06 + }, + { + "open": 1575.06, + "high": 1578.52, + "low": 1574.57, + "close": 1576.49 + }, + { + "open": 1576.5, + "high": 1577, + "low": 1572.5, + "close": 1573.26 + }, + { + "open": 1573.26, + "high": 1579.41, + "low": 1573.06, + "close": 1578.35 + }, + { + "open": 1578.35, + "high": 1585, + "low": 1577.16, + "close": 1584.32 + }, + { + "open": 1584.31, + "high": 1587.97, + "low": 1580.67, + "close": 1585.7 + }, + { + "open": 1585.7, + "high": 1588.35, + "low": 1584.37, + "close": 1585.95 + }, + { + "open": 1585.94, + "high": 1587.09, + "low": 1580.66, + "close": 1580.97 + }, + { + "open": 1580.97, + "high": 1583.38, + "low": 1577, + "close": 1581.64 + }, + { + "open": 1581.64, + "high": 1586.79, + "low": 1581.22, + "close": 1585.42 + }, + { + "open": 1585.42, + "high": 1585.42, + "low": 1581.67, + "close": 1582.37 + }, + { + "open": 1582.38, + "high": 1584.86, + "low": 1581.01, + "close": 1581.02 + }, + { + "open": 1581.03, + "high": 1582.05, + "low": 1578.99, + "close": 1579.46 + }, + { + "open": 1579.46, + "high": 1579.89, + "low": 1566.85, + "close": 1567.99 + }, + { + "open": 1567.99, + "high": 1567.99, + "low": 1553.2, + "close": 1554.87 + }, + { + "open": 1554.87, + "high": 1558, + "low": 1546.9, + "close": 1550.4 + }, + { + "open": 1550.4, + "high": 1554.98, + "low": 1546.27, + "close": 1549.67 + }, + { + "open": 1549.68, + "high": 1555, + "low": 1546.97, + "close": 1553.88 + }, + { + "open": 1553.89, + "high": 1557.86, + "low": 1553.6, + "close": 1557.85 + }, + { + "open": 1557.86, + "high": 1558.37, + "low": 1554.9, + "close": 1556.3 + }, + { + "open": 1556.31, + "high": 1557.4, + "low": 1552.81, + "close": 1557.18 + }, + { + "open": 1557.18, + "high": 1563.78, + "low": 1556.5, + "close": 1562.72 + }, + { + "open": 1562.72, + "high": 1564.11, + "low": 1558.76, + "close": 1560.64 + }, + { + "open": 1560.64, + "high": 1562.31, + "low": 1560.5, + "close": 1561.24 + }, + { + "open": 1561.25, + "high": 1565.69, + "low": 1561.23, + "close": 1564.79 + }, + { + "open": 1564.79, + "high": 1565.33, + "low": 1558.23, + "close": 1559.9 + }, + { + "open": 1559.89, + "high": 1561.77, + "low": 1555.87, + "close": 1560.79 + }, + { + "open": 1560.78, + "high": 1562.07, + "low": 1557.89, + "close": 1560.36 + }, + { + "open": 1560.36, + "high": 1561.2, + "low": 1556.13, + "close": 1558.26 + }, + { + "open": 1558.25, + "high": 1563.12, + "low": 1558.25, + "close": 1562.35 + }, + { + "open": 1562.36, + "high": 1564.02, + "low": 1561.76, + "close": 1563.32 + }, + { + "open": 1563.31, + "high": 1564.29, + "low": 1557.79, + "close": 1559.87 + }, + { + "open": 1559.86, + "high": 1562.71, + "low": 1558.77, + "close": 1559.8 + }, + { + "open": 1559.81, + "high": 1559.91, + "low": 1557.6, + "close": 1559.19 + }, + { + "open": 1559.2, + "high": 1559.95, + "low": 1554.3, + "close": 1557.16 + }, + { + "open": 1557.16, + "high": 1557.17, + "low": 1536.25, + "close": 1541.89 + }, + { + "open": 1541.89, + "high": 1544.39, + "low": 1538.55, + "close": 1539.33 + }, + { + "open": 1539.33, + "high": 1546.28, + "low": 1533.67, + "close": 1543.99 + }, + { + "open": 1543.99, + "high": 1544.5, + "low": 1538.21, + "close": 1539.17 + }, + { + "open": 1539.17, + "high": 1543.33, + "low": 1537.73, + "close": 1543 + }, + { + "open": 1543.2, + "high": 1544, + "low": 1535.81, + "close": 1541.12 + }, + { + "open": 1541.12, + "high": 1541.13, + "low": 1534.12, + "close": 1536.89 + }, + { + "open": 1536.9, + "high": 1539.09, + "low": 1528.25, + "close": 1531.02 + }, + { + "open": 1531.01, + "high": 1532.91, + "low": 1525.28, + "close": 1532.32 + }, + { + "open": 1532.33, + "high": 1537.58, + "low": 1532.32, + "close": 1535.07 + }, + { + "open": 1535.06, + "high": 1541.28, + "low": 1535.06, + "close": 1539.52 + }, + { + "open": 1539.53, + "high": 1539.85, + "low": 1533.37, + "close": 1536.22 + }, + { + "open": 1536.21, + "high": 1536.22, + "low": 1524.81, + "close": 1527.09 + }, + { + "open": 1527.1, + "high": 1529.2, + "low": 1520.62, + "close": 1525.04 + }, + { + "open": 1525.04, + "high": 1528.2, + "low": 1522.12, + "close": 1523.72 + }, + { + "open": 1523.71, + "high": 1525.54, + "low": 1519, + "close": 1522.82 + }, + { + "open": 1522.82, + "high": 1524.98, + "low": 1521, + "close": 1522.19 + }, + { + "open": 1522.19, + "high": 1524.27, + "low": 1512.68, + "close": 1513.27 + }, + { + "open": 1513.26, + "high": 1514.55, + "low": 1501.65, + "close": 1514.14 + }, + { + "open": 1514.14, + "high": 1524.43, + "low": 1513.03, + "close": 1520.08 + }, + { + "open": 1520.08, + "high": 1525.07, + "low": 1518.63, + "close": 1520.61 + }, + { + "open": 1520.61, + "high": 1525.68, + "low": 1517.43, + "close": 1524.86 + }, + { + "open": 1524.86, + "high": 1525.04, + "low": 1519.65, + "close": 1520.26 + }, + { + "open": 1520.27, + "high": 1521.19, + "low": 1517.91, + "close": 1518.46 + }, + { + "open": 1518.46, + "high": 1525.25, + "low": 1518.46, + "close": 1524.83 + }, + { + "open": 1524.84, + "high": 1526.94, + "low": 1521.69, + "close": 1521.9 + }, + { + "open": 1521.9, + "high": 1524.8, + "low": 1519.25, + "close": 1519.88 + }, + { + "open": 1519.88, + "high": 1520.5, + "low": 1517.33, + "close": 1518.76 + }, + { + "open": 1518.76, + "high": 1522.64, + "low": 1518.14, + "close": 1520.46 + }, + { + "open": 1520.46, + "high": 1522.96, + "low": 1518.63, + "close": 1522.52 + }, + { + "open": 1522.51, + "high": 1522.52, + "low": 1519.19, + "close": 1520.61 + }, + { + "open": 1520.61, + "high": 1526.26, + "low": 1519.52, + "close": 1526.1 + }, + { + "open": 1526.11, + "high": 1530.26, + "low": 1524.63, + "close": 1528.65 + }, + { + "open": 1528.65, + "high": 1529.64, + "low": 1521.24, + "close": 1523.18 + }, + { + "open": 1523.19, + "high": 1525.92, + "low": 1521.79, + "close": 1525.6 + }, + { + "open": 1525.61, + "high": 1525.79, + "low": 1522.78, + "close": 1525.31 + }, + { + "open": 1525.31, + "high": 1529.6, + "low": 1525.3, + "close": 1528.93 + }, + { + "open": 1528.93, + "high": 1530.33, + "low": 1527.02, + "close": 1527.61 + }, + { + "open": 1527.6, + "high": 1534, + "low": 1527.6, + "close": 1533.98 + }, + { + "open": 1533.98, + "high": 1537.3, + "low": 1532.24, + "close": 1536.49 + }, + { + "open": 1536.5, + "high": 1536.5, + "low": 1531.65, + "close": 1532.92 + }, + { + "open": 1532.92, + "high": 1532.92, + "low": 1529.1, + "close": 1529.58 + }, + { + "open": 1529.58, + "high": 1535.97, + "low": 1528.13, + "close": 1532.48 + }, + { + "open": 1532.49, + "high": 1533.45, + "low": 1530.29, + "close": 1531.01 + }, + { + "open": 1531.02, + "high": 1532.56, + "low": 1524.11, + "close": 1524.37 + }, + { + "open": 1524.36, + "high": 1534.58, + "low": 1524.28, + "close": 1529.52 + }, + { + "open": 1529.52, + "high": 1530.55, + "low": 1521.72, + "close": 1522.54 + }, + { + "open": 1522.55, + "high": 1526.64, + "low": 1522.45, + "close": 1526.49 + }, + { + "open": 1526.48, + "high": 1530.2, + "low": 1525.07, + "close": 1526.92 + }, + { + "open": 1526.92, + "high": 1527.65, + "low": 1525, + "close": 1526.44 + }, + { + "open": 1526.43, + "high": 1527.68, + "low": 1525.46, + "close": 1526.05 + }, + { + "open": 1526.05, + "high": 1526.27, + "low": 1516.23, + "close": 1516.52 + }, + { + "open": 1516.23, + "high": 1520.67, + "low": 1509.21, + "close": 1520.48 + }, + { + "open": 1520.48, + "high": 1530.83, + "low": 1519.77, + "close": 1529.59 + }, + { + "open": 1529.6, + "high": 1531.12, + "low": 1526.99, + "close": 1531.11 + }, + { + "open": 1531.12, + "high": 1533.79, + "low": 1529.25, + "close": 1531.72 + }, + { + "open": 1531.73, + "high": 1532.96, + "low": 1528.52, + "close": 1529.64 + }, + { + "open": 1529.64, + "high": 1530.49, + "low": 1523.16, + "close": 1524.37 + }, + { + "open": 1524.38, + "high": 1524.58, + "low": 1517.86, + "close": 1521.06 + }, + { + "open": 1521.07, + "high": 1530.49, + "low": 1515.75, + "close": 1526.14 + }, + { + "open": 1526.13, + "high": 1526.98, + "low": 1521.57, + "close": 1523.57 + }, + { + "open": 1523.56, + "high": 1523.68, + "low": 1520.39, + "close": 1521.17 + }, + { + "open": 1521.18, + "high": 1521.36, + "low": 1516.78, + "close": 1516.79 + }, + { + "open": 1516.79, + "high": 1521.81, + "low": 1516.48, + "close": 1520.2 + }, + { + "open": 1520.2, + "high": 1524.79, + "low": 1516.97, + "close": 1523.67 + }, + { + "open": 1523.67, + "high": 1527.82, + "low": 1522.79, + "close": 1525.77 + }, + { + "open": 1525.77, + "high": 1527.68, + "low": 1520.25, + "close": 1524.24 + }, + { + "open": 1524.23, + "high": 1530.88, + "low": 1523.28, + "close": 1529.87 + }, + { + "open": 1529.88, + "high": 1532.92, + "low": 1527.6, + "close": 1530.66 + }, + { + "open": 1530.65, + "high": 1531.32, + "low": 1528.31, + "close": 1530.85 + }, + { + "open": 1530.85, + "high": 1532.99, + "low": 1527.78, + "close": 1528.68 + }, + { + "open": 1528.69, + "high": 1529.92, + "low": 1527.13, + "close": 1527.14 + }, + { + "open": 1527.13, + "high": 1527.14, + "low": 1518.31, + "close": 1521.16 + }, + { + "open": 1521.16, + "high": 1530.26, + "low": 1521.15, + "close": 1526.67 + }, + { + "open": 1526.68, + "high": 1528.17, + "low": 1522.22, + "close": 1522.33 + }, + { + "open": 1522.33, + "high": 1526.23, + "low": 1521.09, + "close": 1523.59 + }, + { + "open": 1523.59, + "high": 1523.99, + "low": 1517.48, + "close": 1518.86 + }, + { + "open": 1518.85, + "high": 1523.43, + "low": 1513.25, + "close": 1521.57 + }, + { + "open": 1521.58, + "high": 1521.58, + "low": 1511.11, + "close": 1513.4 + }, + { + "open": 1513.4, + "high": 1515.26, + "low": 1507.7, + "close": 1508.31 + }, + { + "open": 1508.31, + "high": 1512.48, + "low": 1503.49, + "close": 1505.89 + }, + { + "open": 1505.88, + "high": 1509.76, + "low": 1494.63, + "close": 1500.13 + }, + { + "open": 1500.13, + "high": 1510.52, + "low": 1498.39, + "close": 1507.22 + }, + { + "open": 1507.21, + "high": 1508, + "low": 1495.51, + "close": 1501.06 + }, + { + "open": 1501.06, + "high": 1506.84, + "low": 1500.04, + "close": 1504.99 + }, + { + "open": 1505, + "high": 1507.4, + "low": 1497.16, + "close": 1498.46 + }, + { + "open": 1498.46, + "high": 1505.37, + "low": 1495, + "close": 1501.44 + }, + { + "open": 1501.36, + "high": 1504.4, + "low": 1500.27, + "close": 1500.55 + }, + { + "open": 1500.55, + "high": 1502.52, + "low": 1496.63, + "close": 1501.29 + }, + { + "open": 1501.29, + "high": 1501.88, + "low": 1496, + "close": 1496.37 + }, + { + "open": 1496.37, + "high": 1506.67, + "low": 1488, + "close": 1505.21 + }, + { + "open": 1505.21, + "high": 1508.6, + "low": 1502.24, + "close": 1508 + }, + { + "open": 1507.99, + "high": 1514.07, + "low": 1507.03, + "close": 1512.2 + }, + { + "open": 1512.2, + "high": 1513.73, + "low": 1510.89, + "close": 1512.64 + }, + { + "open": 1512.65, + "high": 1514.52, + "low": 1508.88, + "close": 1513.17 + }, + { + "open": 1513.17, + "high": 1513.95, + "low": 1511.68, + "close": 1511.94 + }, + { + "open": 1511.94, + "high": 1512.69, + "low": 1508, + "close": 1508.97 + }, + { + "open": 1508.97, + "high": 1511.92, + "low": 1508, + "close": 1511.71 + }, + { + "open": 1511.71, + "high": 1512.21, + "low": 1502.06, + "close": 1502.3 + }, + { + "open": 1502.29, + "high": 1505.5, + "low": 1499.75, + "close": 1503.8 + }, + { + "open": 1503.8, + "high": 1510.52, + "low": 1497.04, + "close": 1499.02 + }, + { + "open": 1499.03, + "high": 1500.56, + "low": 1497.35, + "close": 1499.88 + }, + { + "open": 1499.88, + "high": 1507.12, + "low": 1498.43, + "close": 1499 + }, + { + "open": 1498.99, + "high": 1501.4, + "low": 1489.93, + "close": 1493.7 + }, + { + "open": 1493.71, + "high": 1495.73, + "low": 1490.72, + "close": 1493.73 + }, + { + "open": 1493.72, + "high": 1495.82, + "low": 1492.44, + "close": 1493.23 + }, + { + "open": 1493.23, + "high": 1501.75, + "low": 1493.06, + "close": 1501.54 + }, + { + "open": 1501.54, + "high": 1506.81, + "low": 1500.45, + "close": 1506.61 + }, + { + "open": 1506.6, + "high": 1507.9, + "low": 1505.1, + "close": 1505.95 + }, + { + "open": 1505.95, + "high": 1509.42, + "low": 1505.69, + "close": 1508.9 + }, + { + "open": 1508.9, + "high": 1516.09, + "low": 1508.3, + "close": 1513.84 + }, + { + "open": 1513.83, + "high": 1516.35, + "low": 1510.74, + "close": 1512 + }, + { + "open": 1512, + "high": 1516.35, + "low": 1511.43, + "close": 1513.33 + }, + { + "open": 1513.25, + "high": 1518.68, + "low": 1511.56, + "close": 1517.19 + }, + { + "open": 1517.18, + "high": 1524.05, + "low": 1516.45, + "close": 1517.64 + }, + { + "open": 1517.64, + "high": 1519.51, + "low": 1514.37, + "close": 1518.03 + }, + { + "open": 1518.04, + "high": 1520.21, + "low": 1516.06, + "close": 1518.91 + }, + { + "open": 1518.91, + "high": 1519.8, + "low": 1516.06, + "close": 1518.1 + }, + { + "open": 1518.11, + "high": 1518.11, + "low": 1515.6, + "close": 1516.43 + }, + { + "open": 1516.43, + "high": 1521.28, + "low": 1515.79, + "close": 1519.82 + }, + { + "open": 1519.81, + "high": 1519.98, + "low": 1518.42, + "close": 1519.68 + }, + { + "open": 1519.69, + "high": 1521.68, + "low": 1518.67, + "close": 1520.28 + }, + { + "open": 1520.29, + "high": 1521.65, + "low": 1519.08, + "close": 1520.24 + }, + { + "open": 1520.25, + "high": 1527.76, + "low": 1520.24, + "close": 1526.28 + }, + { + "open": 1526.27, + "high": 1526.99, + "low": 1522.67, + "close": 1525.11 + }, + { + "open": 1525.1, + "high": 1529.05, + "low": 1523.62, + "close": 1525.51 + }, + { + "open": 1525.5, + "high": 1525.51, + "low": 1520.4, + "close": 1521.62 + }, + { + "open": 1521.62, + "high": 1525.97, + "low": 1521.59, + "close": 1523.41 + }, + { + "open": 1523.42, + "high": 1524.16, + "low": 1523, + "close": 1523.81 + }, + { + "open": 1523.8, + "high": 1523.99, + "low": 1522, + "close": 1523.43 + }, + { + "open": 1523.42, + "high": 1524.99, + "low": 1523.42, + "close": 1524.78 + }, + { + "open": 1524.79, + "high": 1525.35, + "low": 1523.06, + "close": 1523.24 + }, + { + "open": 1523.24, + "high": 1523.24, + "low": 1518.44, + "close": 1520.29 + }, + { + "open": 1520.28, + "high": 1521.95, + "low": 1518.01, + "close": 1521.07 + }, + { + "open": 1521.08, + "high": 1521.3, + "low": 1519.22, + "close": 1519.35 + }, + { + "open": 1519.35, + "high": 1519.63, + "low": 1516.3, + "close": 1517.68 + }, + { + "open": 1517.67, + "high": 1518.24, + "low": 1515.23, + "close": 1516.39 + }, + { + "open": 1516.39, + "high": 1520.22, + "low": 1515.31, + "close": 1519.56 + }, + { + "open": 1519.55, + "high": 1524.64, + "low": 1518, + "close": 1522.74 + }, + { + "open": 1522.74, + "high": 1523.93, + "low": 1520.21, + "close": 1520.29 + }, + { + "open": 1520.29, + "high": 1523.26, + "low": 1520.1, + "close": 1522.73 + }, + { + "open": 1522.74, + "high": 1541.63, + "low": 1522.73, + "close": 1539.67 + }, + { + "open": 1539.67, + "high": 1541.92, + "low": 1535.13, + "close": 1538.82 + }, + { + "open": 1538.82, + "high": 1547.2, + "low": 1538.27, + "close": 1545.55 + }, + { + "open": 1545.55, + "high": 1550, + "low": 1543.77, + "close": 1545.59 + }, + { + "open": 1545.6, + "high": 1546.69, + "low": 1539.57, + "close": 1539.68 + }, + { + "open": 1539.67, + "high": 1543.83, + "low": 1538.46, + "close": 1542.91 + }, + { + "open": 1542.91, + "high": 1545.89, + "low": 1542.34, + "close": 1543.44 + }, + { + "open": 1543.43, + "high": 1544.62, + "low": 1541.84, + "close": 1541.85 + }, + { + "open": 1541.85, + "high": 1554.35, + "low": 1539.93, + "close": 1545.74 + }, + { + "open": 1545.77, + "high": 1554.47, + "low": 1545, + "close": 1549.46 + }, + { + "open": 1549.46, + "high": 1552.24, + "low": 1549.45, + "close": 1551.24 + }, + { + "open": 1551.25, + "high": 1554.87, + "low": 1550.63, + "close": 1551.87 + }, + { + "open": 1551.86, + "high": 1553.53, + "low": 1545.58, + "close": 1546.72 + }, + { + "open": 1546.71, + "high": 1552.74, + "low": 1546.65, + "close": 1551.27 + }, + { + "open": 1551.26, + "high": 1555.26, + "low": 1549.71, + "close": 1551.45 + }, + { + "open": 1551.44, + "high": 1553.45, + "low": 1548.31, + "close": 1548.54 + }, + { + "open": 1548.54, + "high": 1549.16, + "low": 1546.57, + "close": 1547.39 + }, + { + "open": 1547.38, + "high": 1549.99, + "low": 1546.86, + "close": 1548.82 + }, + { + "open": 1548.82, + "high": 1554.04, + "low": 1544.92, + "close": 1552.11 + }, + { + "open": 1552.11, + "high": 1553.01, + "low": 1548.42, + "close": 1548.67 + }, + { + "open": 1548.66, + "high": 1577.24, + "low": 1548.66, + "close": 1568.11 + }, + { + "open": 1568.11, + "high": 1569.11, + "low": 1562, + "close": 1563.15 + }, + { + "open": 1563.16, + "high": 1572.49, + "low": 1562.7, + "close": 1566.75 + }, + { + "open": 1566.76, + "high": 1567.67, + "low": 1563.83, + "close": 1564.03 + }, + { + "open": 1564.03, + "high": 1566.14, + "low": 1561.79, + "close": 1563.28 + }, + { + "open": 1563.27, + "high": 1569.75, + "low": 1562.43, + "close": 1569.75 + }, + { + "open": 1569.75, + "high": 1571.84, + "low": 1566.17, + "close": 1569.27 + }, + { + "open": 1569.27, + "high": 1569.28, + "low": 1563.78, + "close": 1563.84 + }, + { + "open": 1563.84, + "high": 1565.98, + "low": 1563.84, + "close": 1564.01 + }, + { + "open": 1564.01, + "high": 1566, + "low": 1562.21, + "close": 1563.5 + }, + { + "open": 1563.51, + "high": 1566.46, + "low": 1562.51, + "close": 1564.33 + }, + { + "open": 1564.34, + "high": 1566.17, + "low": 1564.07, + "close": 1565.09 + }, + { + "open": 1565.09, + "high": 1570.37, + "low": 1565.09, + "close": 1567.3 + }, + { + "open": 1567.29, + "high": 1567.3, + "low": 1564.01, + "close": 1564.01 + }, + { + "open": 1564.01, + "high": 1564.08, + "low": 1560.55, + "close": 1560.71 + }, + { + "open": 1560.71, + "high": 1565.8, + "low": 1560.71, + "close": 1563.98 + }, + { + "open": 1563.97, + "high": 1566.38, + "low": 1563.86, + "close": 1564.27 + }, + { + "open": 1564.28, + "high": 1564.71, + "low": 1560.12, + "close": 1561.13 + }, + { + "open": 1561.13, + "high": 1561.66, + "low": 1551.32, + "close": 1556.13 + }, + { + "open": 1556.13, + "high": 1562.78, + "low": 1549.89, + "close": 1559.92 + }, + { + "open": 1559.92, + "high": 1559.97, + "low": 1545.67, + "close": 1552.36 + }, + { + "open": 1552.37, + "high": 1554.87, + "low": 1549.84, + "close": 1550.13 + }, + { + "open": 1550.12, + "high": 1555.85, + "low": 1549.76, + "close": 1553.41 + }, + { + "open": 1553.41, + "high": 1562.56, + "low": 1553.08, + "close": 1560.89 + }, + { + "open": 1560.9, + "high": 1561.67, + "low": 1556.7, + "close": 1557.88 + }, + { + "open": 1557.87, + "high": 1559.82, + "low": 1555.63, + "close": 1558.56 + }, + { + "open": 1558.56, + "high": 1558.91, + "low": 1555.59, + "close": 1557.08 + }, + { + "open": 1557.09, + "high": 1557.56, + "low": 1554.21, + "close": 1555.63 + }, + { + "open": 1555.62, + "high": 1556.44, + "low": 1553.5, + "close": 1556.34 + }, + { + "open": 1556.34, + "high": 1560.77, + "low": 1555.66, + "close": 1559.82 + }, + { + "open": 1559.83, + "high": 1567.93, + "low": 1559.82, + "close": 1561.87 + }, + { + "open": 1561.88, + "high": 1567.23, + "low": 1561.69, + "close": 1564.58 + }, + { + "open": 1564.58, + "high": 1565.55, + "low": 1561.26, + "close": 1561.58 + }, + { + "open": 1561.58, + "high": 1563.41, + "low": 1557.54, + "close": 1557.74 + }, + { + "open": 1557.73, + "high": 1559.22, + "low": 1556.8, + "close": 1557.69 + }, + { + "open": 1557.7, + "high": 1565.46, + "low": 1557.69, + "close": 1565.18 + }, + { + "open": 1565.18, + "high": 1566.39, + "low": 1563.34, + "close": 1564.35 + }, + { + "open": 1564.35, + "high": 1565.13, + "low": 1561.71, + "close": 1561.71 + }, + { + "open": 1561.72, + "high": 1561.85, + "low": 1557.41, + "close": 1558.38 + }, + { + "open": 1558.38, + "high": 1559.17, + "low": 1552.3, + "close": 1554.71 + }, + { + "open": 1554.7, + "high": 1555.89, + "low": 1552.21, + "close": 1553.36 + }, + { + "open": 1553.35, + "high": 1556.24, + "low": 1551.78, + "close": 1555.12 + }, + { + "open": 1555.12, + "high": 1557.49, + "low": 1553.78, + "close": 1554.54 + }, + { + "open": 1554.54, + "high": 1554.55, + "low": 1545.75, + "close": 1550.29 + }, + { + "open": 1550.29, + "high": 1554.52, + "low": 1549.21, + "close": 1552.37 + }, + { + "open": 1552.38, + "high": 1554.16, + "low": 1551.93, + "close": 1552.33 + }, + { + "open": 1552.33, + "high": 1553.41, + "low": 1551.41, + "close": 1551.65 + }, + { + "open": 1551.65, + "high": 1552.49, + "low": 1551, + "close": 1551.51 + }, + { + "open": 1551.51, + "high": 1556.79, + "low": 1550.86, + "close": 1553.86 + }, + { + "open": 1553.85, + "high": 1557.95, + "low": 1553.28, + "close": 1555.2 + }, + { + "open": 1555.19, + "high": 1555.45, + "low": 1546.9, + "close": 1553.41 + }, + { + "open": 1553.4, + "high": 1554.25, + "low": 1551.34, + "close": 1551.35 + }, + { + "open": 1551.35, + "high": 1553.57, + "low": 1551.1, + "close": 1551.67 + }, + { + "open": 1551.67, + "high": 1555.66, + "low": 1550.68, + "close": 1554.05 + }, + { + "open": 1554.09, + "high": 1560.4, + "low": 1554.09, + "close": 1559.55 + }, + { + "open": 1559.56, + "high": 1561.81, + "low": 1558.47, + "close": 1561.8 + }, + { + "open": 1561.81, + "high": 1561.81, + "low": 1558.59, + "close": 1559.39 + }, + { + "open": 1559.39, + "high": 1560.98, + "low": 1558.91, + "close": 1558.95 + }, + { + "open": 1558.96, + "high": 1563.63, + "low": 1557.85, + "close": 1558.69 + }, + { + "open": 1558.7, + "high": 1561.62, + "low": 1556.87, + "close": 1561.25 + }, + { + "open": 1561.25, + "high": 1572, + "low": 1560.1, + "close": 1564.23 + }, + { + "open": 1564.22, + "high": 1565.96, + "low": 1563.01, + "close": 1564.81 + }, + { + "open": 1564.81, + "high": 1579, + "low": 1563.25, + "close": 1577.63 + }, + { + "open": 1577.63, + "high": 1592.55, + "low": 1575.76, + "close": 1591.16 + }, + { + "open": 1591.17, + "high": 1603.78, + "low": 1590.69, + "close": 1594.31 + }, + { + "open": 1594.31, + "high": 1600.91, + "low": 1593.95, + "close": 1594.48 + }, + { + "open": 1594.47, + "high": 1599.53, + "low": 1589.52, + "close": 1590.67 + }, + { + "open": 1590.66, + "high": 1597.42, + "low": 1586.33, + "close": 1597.12 + }, + { + "open": 1597.12, + "high": 1608.5, + "low": 1596.08, + "close": 1607.22 + }, + { + "open": 1607.23, + "high": 1608.55, + "low": 1601.27, + "close": 1602.19 + }, + { + "open": 1602.19, + "high": 1604.2, + "low": 1599.23, + "close": 1601.92 + }, + { + "open": 1601.92, + "high": 1603.39, + "low": 1599.15, + "close": 1601.33 + }, + { + "open": 1601.33, + "high": 1604.92, + "low": 1600.37, + "close": 1604.71 + }, + { + "open": 1604.72, + "high": 1604.8, + "low": 1600.12, + "close": 1600.68 + }, + { + "open": 1600.69, + "high": 1605.89, + "low": 1600.23, + "close": 1604.66 + }, + { + "open": 1604.66, + "high": 1619.05, + "low": 1604.36, + "close": 1607.94 + }, + { + "open": 1607.95, + "high": 1613.84, + "low": 1605.04, + "close": 1612.56 + }, + { + "open": 1612.57, + "high": 1619.78, + "low": 1611.98, + "close": 1618.5 + }, + { + "open": 1618.49, + "high": 1619.35, + "low": 1612, + "close": 1614.67 + }, + { + "open": 1614.67, + "high": 1614.68, + "low": 1608.16, + "close": 1608.9 + }, + { + "open": 1608.89, + "high": 1612.96, + "low": 1608.89, + "close": 1610.35 + }, + { + "open": 1610.36, + "high": 1615.02, + "low": 1610.23, + "close": 1613.92 + }, + { + "open": 1613.91, + "high": 1614.82, + "low": 1611.6, + "close": 1612.52 + }, + { + "open": 1612.51, + "high": 1613.49, + "low": 1606.76, + "close": 1610.14 + }, + { + "open": 1610.14, + "high": 1615.15, + "low": 1608.17, + "close": 1613.23 + }, + { + "open": 1613.23, + "high": 1619.99, + "low": 1613.23, + "close": 1615.83 + }, + { + "open": 1615.84, + "high": 1617.05, + "low": 1610.28, + "close": 1610.39 + }, + { + "open": 1610.39, + "high": 1612.38, + "low": 1606.69, + "close": 1608.64 + }, + { + "open": 1608.64, + "high": 1611.02, + "low": 1608.16, + "close": 1610.05 + }, + { + "open": 1610.05, + "high": 1612, + "low": 1607.59, + "close": 1608.4 + }, + { + "open": 1608.39, + "high": 1609.76, + "low": 1603.32, + "close": 1603.65 + }, + { + "open": 1603.66, + "high": 1606.98, + "low": 1603.38, + "close": 1605.03 + }, + { + "open": 1605.03, + "high": 1611.78, + "low": 1605.03, + "close": 1610.29 + }, + { + "open": 1610.29, + "high": 1611.76, + "low": 1608.83, + "close": 1609.91 + }, + { + "open": 1609.9, + "high": 1609.93, + "low": 1604.31, + "close": 1605.01 + }, + { + "open": 1605.01, + "high": 1606.99, + "low": 1604.35, + "close": 1604.44 + }, + { + "open": 1604.44, + "high": 1607.45, + "low": 1599.66, + "close": 1601.09 + }, + { + "open": 1601.09, + "high": 1605, + "low": 1599.56, + "close": 1603.39 + }, + { + "open": 1603.39, + "high": 1604.36, + "low": 1601.89, + "close": 1604.17 + }, + { + "open": 1604.17, + "high": 1604.4, + "low": 1601.11, + "close": 1601.82 + }, + { + "open": 1601.82, + "high": 1602.58, + "low": 1597, + "close": 1598.54 + }, + { + "open": 1598.55, + "high": 1599.26, + "low": 1595, + "close": 1597.39 + }, + { + "open": 1597.4, + "high": 1599.58, + "low": 1595.34, + "close": 1595.5 + }, + { + "open": 1595.49, + "high": 1597.72, + "low": 1594, + "close": 1596.51 + }, + { + "open": 1596.5, + "high": 1608.06, + "low": 1596.03, + "close": 1605.83 + }, + { + "open": 1605.84, + "high": 1610.01, + "low": 1602.77, + "close": 1603.14 + }, + { + "open": 1603.13, + "high": 1605.96, + "low": 1602.02, + "close": 1605.76 + }, + { + "open": 1605.76, + "high": 1606.06, + "low": 1602.56, + "close": 1603.5 + }, + { + "open": 1603.5, + "high": 1608.17, + "low": 1603, + "close": 1606.46 + }, + { + "open": 1606.47, + "high": 1606.57, + "low": 1600.37, + "close": 1600.49 + }, + { + "open": 1600.49, + "high": 1603.15, + "low": 1599, + "close": 1602.38 + }, + { + "open": 1602.38, + "high": 1605.76, + "low": 1602.33, + "close": 1604.24 + }, + { + "open": 1604.24, + "high": 1613.6, + "low": 1603.63, + "close": 1608.86 + }, + { + "open": 1608.85, + "high": 1608.86, + "low": 1605.31, + "close": 1607.69 + }, + { + "open": 1607.69, + "high": 1611.81, + "low": 1606.26, + "close": 1606.85 + }, + { + "open": 1606.85, + "high": 1607.62, + "low": 1602.87, + "close": 1603.66 + }, + { + "open": 1603.67, + "high": 1603.9, + "low": 1600.29, + "close": 1602.19 + }, + { + "open": 1602.19, + "high": 1602.64, + "low": 1598.88, + "close": 1599.6 + }, + { + "open": 1599.6, + "high": 1602.21, + "low": 1599.59, + "close": 1601.28 + }, + { + "open": 1601.29, + "high": 1602.42, + "low": 1596.21, + "close": 1598 + }, + { + "open": 1598, + "high": 1600, + "low": 1597, + "close": 1599.99 + }, + { + "open": 1600, + "high": 1600.46, + "low": 1598.05, + "close": 1598.1 + }, + { + "open": 1598.11, + "high": 1600.46, + "low": 1597.24, + "close": 1598.62 + }, + { + "open": 1598.62, + "high": 1604.32, + "low": 1597.61, + "close": 1599.57 + }, + { + "open": 1599.58, + "high": 1603.67, + "low": 1599.05, + "close": 1602.84 + }, + { + "open": 1602.84, + "high": 1603.41, + "low": 1601.33, + "close": 1601.71 + }, + { + "open": 1601.72, + "high": 1604.38, + "low": 1600.84, + "close": 1601.06 + }, + { + "open": 1601.07, + "high": 1601.07, + "low": 1584.25, + "close": 1585.56 + }, + { + "open": 1585.57, + "high": 1590.35, + "low": 1583, + "close": 1583.3 + }, + { + "open": 1583.31, + "high": 1585.59, + "low": 1582, + "close": 1582.99 + }, + { + "open": 1582.99, + "high": 1587.47, + "low": 1580, + "close": 1585 + }, + { + "open": 1584.87, + "high": 1586.53, + "low": 1584.36, + "close": 1585.54 + }, + { + "open": 1585.53, + "high": 1592, + "low": 1583.94, + "close": 1590.4 + }, + { + "open": 1590.41, + "high": 1591.7, + "low": 1587.77, + "close": 1591.67 + }, + { + "open": 1591.67, + "high": 1627.93, + "low": 1591.67, + "close": 1619.34 + }, + { + "open": 1619.35, + "high": 1627.28, + "low": 1615.54, + "close": 1620.06 + }, + { + "open": 1620.07, + "high": 1627.91, + "low": 1616.57, + "close": 1618.04 + }, + { + "open": 1618.03, + "high": 1622.4, + "low": 1617.04, + "close": 1620.09 + }, + { + "open": 1620.09, + "high": 1628.86, + "low": 1615.37, + "close": 1615.63 + }, + { + "open": 1615.63, + "high": 1622.18, + "low": 1615.37, + "close": 1621.29 + }, + { + "open": 1621.3, + "high": 1622.8, + "low": 1620, + "close": 1620.51 + }, + { + "open": 1620.5, + "high": 1621.89, + "low": 1613.17, + "close": 1615.52 + }, + { + "open": 1615.53, + "high": 1617.4, + "low": 1614.06, + "close": 1615.32 + }, + { + "open": 1615.33, + "high": 1620.03, + "low": 1615.32, + "close": 1615.85 + }, + { + "open": 1615.84, + "high": 1619.22, + "low": 1603.38, + "close": 1606.41 + }, + { + "open": 1606.41, + "high": 1615.27, + "low": 1606.33, + "close": 1614.67 + }, + { + "open": 1614.68, + "high": 1618.39, + "low": 1614, + "close": 1617.37 + }, + { + "open": 1617.38, + "high": 1620.43, + "low": 1615.78, + "close": 1618.73 + }, + { + "open": 1618.72, + "high": 1618.73, + "low": 1610.77, + "close": 1611.04 + }, + { + "open": 1611.03, + "high": 1614.99, + "low": 1611.03, + "close": 1612.59 + }, + { + "open": 1612.59, + "high": 1613.22, + "low": 1605.77, + "close": 1606.25 + }, + { + "open": 1606.25, + "high": 1608.57, + "low": 1604.04, + "close": 1606.66 + }, + { + "open": 1606.66, + "high": 1609.22, + "low": 1594.74, + "close": 1596.49 + }, + { + "open": 1596.48, + "high": 1600.36, + "low": 1596.16, + "close": 1597.82 + }, + { + "open": 1597.82, + "high": 1598.27, + "low": 1586.09, + "close": 1587.01 + }, + { + "open": 1587.01, + "high": 1589.01, + "low": 1576.53, + "close": 1578.03 + }, + { + "open": 1578.03, + "high": 1583.88, + "low": 1575, + "close": 1579.57 + }, + { + "open": 1579.57, + "high": 1585.38, + "low": 1578.04, + "close": 1584.92 + }, + { + "open": 1584.92, + "high": 1585.58, + "low": 1579.7, + "close": 1584.64 + }, + { + "open": 1584.64, + "high": 1585.69, + "low": 1580.78, + "close": 1582.9 + }, + { + "open": 1582.91, + "high": 1582.91, + "low": 1576.04, + "close": 1578.85 + }, + { + "open": 1578.85, + "high": 1584.74, + "low": 1578.84, + "close": 1584.44 + }, + { + "open": 1584.43, + "high": 1584.94, + "low": 1575, + "close": 1579.18 + }, + { + "open": 1579.18, + "high": 1583.31, + "low": 1579.09, + "close": 1579.83 + }, + { + "open": 1579.83, + "high": 1582.08, + "low": 1573, + "close": 1577.81 + }, + { + "open": 1577.8, + "high": 1582.09, + "low": 1576.19, + "close": 1581.71 + }, + { + "open": 1581.71, + "high": 1582.97, + "low": 1579.21, + "close": 1579.75 + }, + { + "open": 1579.74, + "high": 1583.99, + "low": 1579.52, + "close": 1581.78 + }, + { + "open": 1581.79, + "high": 1583.99, + "low": 1580.27, + "close": 1580.67 + }, + { + "open": 1580.68, + "high": 1589.57, + "low": 1573.86, + "close": 1582.09 + }, + { + "open": 1582.09, + "high": 1586.32, + "low": 1578.54, + "close": 1581.81 + }, + { + "open": 1581.81, + "high": 1588.44, + "low": 1581.8, + "close": 1587.46 + }, + { + "open": 1587.46, + "high": 1618, + "low": 1587.45, + "close": 1611.24 + }, + { + "open": 1611.24, + "high": 1614.26, + "low": 1605.36, + "close": 1612.02 + }, + { + "open": 1612.02, + "high": 1616, + "low": 1608.85, + "close": 1610.58 + }, + { + "open": 1610.59, + "high": 1612.66, + "low": 1607.85, + "close": 1609.51 + }, + { + "open": 1609.51, + "high": 1611.59, + "low": 1607.13, + "close": 1610.04 + }, + { + "open": 1610.04, + "high": 1610.16, + "low": 1603.11, + "close": 1609.25 + }, + { + "open": 1609.25, + "high": 1617.77, + "low": 1605.15, + "close": 1611.78 + }, + { + "open": 1611.77, + "high": 1612.94, + "low": 1608.89, + "close": 1610.54 + }, + { + "open": 1610.53, + "high": 1610.79, + "low": 1607.76, + "close": 1609.46 + }, + { + "open": 1609.46, + "high": 1611.35, + "low": 1607.06, + "close": 1608.93 + }, + { + "open": 1608.92, + "high": 1621.42, + "low": 1608.92, + "close": 1615.58 + }, + { + "open": 1615.59, + "high": 1617.94, + "low": 1609.66, + "close": 1610.42 + }, + { + "open": 1610.41, + "high": 1613.09, + "low": 1607.94, + "close": 1610.04 + }, + { + "open": 1610.03, + "high": 1612.39, + "low": 1608.86, + "close": 1612.38 + }, + { + "open": 1612.39, + "high": 1612.39, + "low": 1606.68, + "close": 1607.45 + }, + { + "open": 1607.45, + "high": 1608.6, + "low": 1603.5, + "close": 1606.03 + }, + { + "open": 1606.03, + "high": 1608.42, + "low": 1605.16, + "close": 1606.46 + }, + { + "open": 1606.46, + "high": 1609.6, + "low": 1605.99, + "close": 1608.61 + }, + { + "open": 1608.61, + "high": 1611.82, + "low": 1608.6, + "close": 1609.35 + }, + { + "open": 1609.36, + "high": 1613.69, + "low": 1608.97, + "close": 1612.61 + }, + { + "open": 1612.61, + "high": 1615.38, + "low": 1600.05, + "close": 1605.29 + }, + { + "open": 1605.29, + "high": 1610.87, + "low": 1594.48, + "close": 1595.84 + }, + { + "open": 1595.85, + "high": 1600.32, + "low": 1593.78, + "close": 1595.82 + }, + { + "open": 1595.81, + "high": 1596.14, + "low": 1588.38, + "close": 1590.05 + }, + { + "open": 1590.05, + "high": 1595, + "low": 1587.23, + "close": 1590.98 + }, + { + "open": 1590.98, + "high": 1590.99, + "low": 1584.71, + "close": 1587.17 + }, + { + "open": 1587.16, + "high": 1591.85, + "low": 1583.51, + "close": 1590.73 + }, + { + "open": 1590.74, + "high": 1594.04, + "low": 1590, + "close": 1592.56 + }, + { + "open": 1592.55, + "high": 1596.8, + "low": 1591.81, + "close": 1593.82 + }, + { + "open": 1593.82, + "high": 1594.8, + "low": 1588.17, + "close": 1590.81 + }, + { + "open": 1590.81, + "high": 1593.02, + "low": 1590.45, + "close": 1592.96 + }, + { + "open": 1592.96, + "high": 1593.35, + "low": 1590, + "close": 1591.3 + }, + { + "open": 1591.31, + "high": 1594.96, + "low": 1590.04, + "close": 1593.73 + }, + { + "open": 1593.74, + "high": 1594.35, + "low": 1592.3, + "close": 1593.39 + }, + { + "open": 1593.4, + "high": 1607.22, + "low": 1593, + "close": 1602.44 + }, + { + "open": 1602.45, + "high": 1611.36, + "low": 1602.44, + "close": 1608.78 + }, + { + "open": 1608.79, + "high": 1613.99, + "low": 1608, + "close": 1610.29 + }, + { + "open": 1610.27, + "high": 1610.8, + "low": 1605.45, + "close": 1607.02 + }, + { + "open": 1607.01, + "high": 1607.17, + "low": 1591.77, + "close": 1594.46 + }, + { + "open": 1594.46, + "high": 1598.08, + "low": 1593.34, + "close": 1596.43 + }, + { + "open": 1596.43, + "high": 1606.97, + "low": 1596.42, + "close": 1606.51 + }, + { + "open": 1606.51, + "high": 1606.51, + "low": 1600, + "close": 1601.63 + }, + { + "open": 1601.63, + "high": 1604.92, + "low": 1600.43, + "close": 1603.69 + }, + { + "open": 1603.68, + "high": 1604.44, + "low": 1600.92, + "close": 1603.73 + }, + { + "open": 1603.73, + "high": 1604.5, + "low": 1596.63, + "close": 1599.85 + }, + { + "open": 1599.86, + "high": 1603.51, + "low": 1597.73, + "close": 1601.35 + }, + { + "open": 1601.35, + "high": 1603.01, + "low": 1598.18, + "close": 1599.56 + }, + { + "open": 1599.57, + "high": 1606, + "low": 1598.74, + "close": 1605.58 + }, + { + "open": 1605.57, + "high": 1605.59, + "low": 1600.41, + "close": 1600.46 + }, + { + "open": 1600.45, + "high": 1602.81, + "low": 1599.72, + "close": 1601.36 + }, + { + "open": 1601.36, + "high": 1602.24, + "low": 1595.8, + "close": 1596.99 + }, + { + "open": 1597, + "high": 1599, + "low": 1590.72, + "close": 1596.49 + }, + { + "open": 1596.49, + "high": 1597.31, + "low": 1593.48, + "close": 1593.61 + }, + { + "open": 1593.62, + "high": 1598.96, + "low": 1593, + "close": 1597.03 + }, + { + "open": 1597.02, + "high": 1598, + "low": 1594.17, + "close": 1596.97 + }, + { + "open": 1596.96, + "high": 1599.64, + "low": 1595.32, + "close": 1597.92 + }, + { + "open": 1597.91, + "high": 1600.45, + "low": 1597.28, + "close": 1597.64 + }, + { + "open": 1597.65, + "high": 1599.3, + "low": 1593.85, + "close": 1596.34 + }, + { + "open": 1596.34, + "high": 1607.62, + "low": 1595.76, + "close": 1603.49 + }, + { + "open": 1603.49, + "high": 1606.36, + "low": 1595, + "close": 1596.49 + }, + { + "open": 1596.48, + "high": 1604.91, + "low": 1596.1, + "close": 1604.91 + }, + { + "open": 1604.9, + "high": 1610, + "low": 1602.61, + "close": 1607.95 + }, + { + "open": 1607.94, + "high": 1614.95, + "low": 1607.59, + "close": 1613.14 + }, + { + "open": 1613.14, + "high": 1613.15, + "low": 1610.38, + "close": 1610.46 + }, + { + "open": 1610.46, + "high": 1611.95, + "low": 1607.42, + "close": 1607.63 + }, + { + "open": 1607.62, + "high": 1609.74, + "low": 1603.26, + "close": 1603.53 + }, + { + "open": 1603.53, + "high": 1604.18, + "low": 1599.56, + "close": 1600.77 + }, + { + "open": 1600.78, + "high": 1604.25, + "low": 1600.77, + "close": 1602.76 + }, + { + "open": 1602.75, + "high": 1604.05, + "low": 1601.46, + "close": 1601.89 + }, + { + "open": 1601.89, + "high": 1606.86, + "low": 1601.01, + "close": 1603.48 + }, + { + "open": 1603.49, + "high": 1605.52, + "low": 1600.24, + "close": 1600.55 + }, + { + "open": 1600.55, + "high": 1603.27, + "low": 1598.87, + "close": 1601.6 + }, + { + "open": 1601.6, + "high": 1605.65, + "low": 1600.97, + "close": 1605 + }, + { + "open": 1605, + "high": 1607.97, + "low": 1602.92, + "close": 1604.44 + }, + { + "open": 1604.44, + "high": 1606.44, + "low": 1602.58, + "close": 1605.59 + }, + { + "open": 1605.58, + "high": 1607.69, + "low": 1604.75, + "close": 1607.22 + }, + { + "open": 1607.23, + "high": 1620.12, + "low": 1607.17, + "close": 1618.72 + }, + { + "open": 1618.71, + "high": 1618.86, + "low": 1611.1, + "close": 1613.18 + }, + { + "open": 1613.19, + "high": 1613.72, + "low": 1610.16, + "close": 1612.13 + }, + { + "open": 1612.1, + "high": 1612.64, + "low": 1608.05, + "close": 1608.25 + }, + { + "open": 1608.25, + "high": 1617.11, + "low": 1608, + "close": 1614.17 + }, + { + "open": 1614.16, + "high": 1614.64, + "low": 1607.77, + "close": 1613.48 + }, + { + "open": 1613.48, + "high": 1625, + "low": 1612.54, + "close": 1618.6 + }, + { + "open": 1618.6, + "high": 1622.92, + "low": 1615.17, + "close": 1622.83 + }, + { + "open": 1622.84, + "high": 1626.94, + "low": 1620.14, + "close": 1621.06 + }, + { + "open": 1621.07, + "high": 1621.87, + "low": 1617.82, + "close": 1619.46 + }, + { + "open": 1619.45, + "high": 1634.2, + "low": 1618.5, + "close": 1627.23 + }, + { + "open": 1627.23, + "high": 1627.39, + "low": 1620.18, + "close": 1622.36 + }, + { + "open": 1622.36, + "high": 1626.93, + "low": 1616.66, + "close": 1620.82 + }, + { + "open": 1620.82, + "high": 1623.84, + "low": 1618.85, + "close": 1622.1 + }, + { + "open": 1622.1, + "high": 1622.15, + "low": 1618.52, + "close": 1620.43 + }, + { + "open": 1620.42, + "high": 1625, + "low": 1619.49, + "close": 1622 + }, + { + "open": 1622, + "high": 1625.99, + "low": 1615.31, + "close": 1617.81 + }, + { + "open": 1617.81, + "high": 1627, + "low": 1616.03, + "close": 1624.35 + }, + { + "open": 1624.36, + "high": 1629.49, + "low": 1624.35, + "close": 1626.81 + }, + { + "open": 1626.81, + "high": 1628.9, + "low": 1624.37, + "close": 1624.84 + }, + { + "open": 1624.85, + "high": 1628.28, + "low": 1622.99, + "close": 1623.1 + }, + { + "open": 1623.1, + "high": 1627.99, + "low": 1621.44, + "close": 1624.59 + }, + { + "open": 1624.6, + "high": 1626.35, + "low": 1622.57, + "close": 1623.59 + }, + { + "open": 1623.6, + "high": 1642.89, + "low": 1623.3, + "close": 1640.62 + }, + { + "open": 1640.63, + "high": 1661.98, + "low": 1634.91, + "close": 1660.48 + }, + { + "open": 1660.49, + "high": 1664.34, + "low": 1644.84, + "close": 1645.57 + }, + { + "open": 1645.58, + "high": 1646.67, + "low": 1595, + "close": 1608.22 + }, + { + "open": 1608.22, + "high": 1614.88, + "low": 1601.43, + "close": 1605.36 + }, + { + "open": 1605.36, + "high": 1609.8, + "low": 1591.92, + "close": 1592.38 + }, + { + "open": 1592.39, + "high": 1602.45, + "low": 1589.01, + "close": 1600 + }, + { + "open": 1600, + "high": 1607.26, + "low": 1599, + "close": 1606.64 + }, + { + "open": 1606.64, + "high": 1607.7, + "low": 1594.71, + "close": 1598.66 + }, + { + "open": 1598.66, + "high": 1604.57, + "low": 1595.3, + "close": 1602.48 + }, + { + "open": 1602.48, + "high": 1612.55, + "low": 1601.32, + "close": 1610.62 + }, + { + "open": 1610.63, + "high": 1615.92, + "low": 1608.01, + "close": 1610.8 + }, + { + "open": 1610.79, + "high": 1612.97, + "low": 1605.89, + "close": 1608.29 + }, + { + "open": 1608.3, + "high": 1609.65, + "low": 1605.11, + "close": 1605.12 + }, + { + "open": 1605.12, + "high": 1607.57, + "low": 1598.35, + "close": 1602.85 + }, + { + "open": 1602.85, + "high": 1602.86, + "low": 1598.44, + "close": 1599.51 + }, + { + "open": 1599.5, + "high": 1600.05, + "low": 1593.32, + "close": 1597.7 + }, + { + "open": 1597.7, + "high": 1603.62, + "low": 1596, + "close": 1598.76 + }, + { + "open": 1598.75, + "high": 1601.76, + "low": 1595.1, + "close": 1600.62 + }, + { + "open": 1600.53, + "high": 1604, + "low": 1596.81, + "close": 1602.64 + }, + { + "open": 1602.63, + "high": 1609.55, + "low": 1602, + "close": 1602.44 + }, + { + "open": 1602.45, + "high": 1603.9, + "low": 1592.51, + "close": 1593.89 + }, + { + "open": 1593.89, + "high": 1594.71, + "low": 1565.67, + "close": 1567.64 + }, + { + "open": 1567.63, + "high": 1570, + "low": 1560, + "close": 1561.39 + }, + { + "open": 1561.4, + "high": 1568.6, + "low": 1560, + "close": 1560.15 + }, + { + "open": 1560.14, + "high": 1567.72, + "low": 1558.34, + "close": 1562.48 + }, + { + "open": 1562.47, + "high": 1567.56, + "low": 1556.87, + "close": 1557.4 + }, + { + "open": 1557.39, + "high": 1562.84, + "low": 1555.8, + "close": 1559.22 + }, + { + "open": 1559.22, + "high": 1560.95, + "low": 1555.83, + "close": 1558.29 + }, + { + "open": 1558.3, + "high": 1558.71, + "low": 1552.58, + "close": 1557.29 + }, + { + "open": 1557.29, + "high": 1558.05, + "low": 1550.73, + "close": 1552.73 + }, + { + "open": 1552.73, + "high": 1556.73, + "low": 1551.93, + "close": 1555.62 + }, + { + "open": 1555.62, + "high": 1558.32, + "low": 1553.89, + "close": 1558.24 + }, + { + "open": 1558.23, + "high": 1560.73, + "low": 1555.85, + "close": 1556.41 + }, + { + "open": 1556.42, + "high": 1557.28, + "low": 1546.5, + "close": 1550.45 + }, + { + "open": 1550.44, + "high": 1553.2, + "low": 1548.64, + "close": 1551.76 + }, + { + "open": 1551.76, + "high": 1553.97, + "low": 1550.33, + "close": 1551.83 + }, + { + "open": 1551.83, + "high": 1551.84, + "low": 1536.1, + "close": 1545.23 + }, + { + "open": 1545.23, + "high": 1545.35, + "low": 1536.02, + "close": 1536.77 + }, + { + "open": 1536.78, + "high": 1545.98, + "low": 1535, + "close": 1545.85 + }, + { + "open": 1545.84, + "high": 1546.92, + "low": 1542.7, + "close": 1544.4 + }, + { + "open": 1544.41, + "high": 1544.41, + "low": 1539.46, + "close": 1542.9 + }, + { + "open": 1542.91, + "high": 1543.88, + "low": 1532.08, + "close": 1537.46 + }, + { + "open": 1537.46, + "high": 1539.81, + "low": 1528.5, + "close": 1539.6 + }, + { + "open": 1539.59, + "high": 1539.91, + "low": 1534.97, + "close": 1537.88 + }, + { + "open": 1537.89, + "high": 1538.41, + "low": 1519.26, + "close": 1529.17 + }, + { + "open": 1529.17, + "high": 1531.96, + "low": 1523.22, + "close": 1530.51 + }, + { + "open": 1530.51, + "high": 1530.53, + "low": 1516.53, + "close": 1516.54 + }, + { + "open": 1516.54, + "high": 1523, + "low": 1515.92, + "close": 1520.8 + }, + { + "open": 1520.8, + "high": 1523.87, + "low": 1519.23, + "close": 1520.13 + }, + { + "open": 1520.12, + "high": 1525.47, + "low": 1519.92, + "close": 1522.6 + }, + { + "open": 1522.59, + "high": 1523.37, + "low": 1518.67, + "close": 1520.02 + }, + { + "open": 1520.01, + "high": 1522.76, + "low": 1517.3, + "close": 1520.03 + }, + { + "open": 1520.03, + "high": 1522.47, + "low": 1514.84, + "close": 1522.2 + }, + { + "open": 1522.19, + "high": 1523.61, + "low": 1519.61, + "close": 1523.36 + }, + { + "open": 1523.36, + "high": 1523.74, + "low": 1520.13, + "close": 1523.27 + }, + { + "open": 1523.27, + "high": 1523.78, + "low": 1513.68, + "close": 1515.15 + }, + { + "open": 1515.14, + "high": 1516.51, + "low": 1508.06, + "close": 1512.14 + }, + { + "open": 1512.15, + "high": 1515.4, + "low": 1511.29, + "close": 1515.02 + }, + { + "open": 1515.02, + "high": 1516.74, + "low": 1512.8, + "close": 1515.19 + }, + { + "open": 1515.19, + "high": 1518.29, + "low": 1514.84, + "close": 1517.85 + }, + { + "open": 1517.85, + "high": 1518.5, + "low": 1509.02, + "close": 1511.14 + }, + { + "open": 1511.14, + "high": 1512.83, + "low": 1509.19, + "close": 1509.79 + }, + { + "open": 1509.78, + "high": 1510, + "low": 1502, + "close": 1504.63 + }, + { + "open": 1504.63, + "high": 1512, + "low": 1498.95, + "close": 1511.51 + }, + { + "open": 1511.51, + "high": 1512.42, + "low": 1506.71, + "close": 1506.71 + }, + { + "open": 1506.71, + "high": 1508.89, + "low": 1504.28, + "close": 1505.15 + }, + { + "open": 1505.15, + "high": 1509.41, + "low": 1499.45, + "close": 1509.01 + }, + { + "open": 1509.01, + "high": 1513.92, + "low": 1507.81, + "close": 1513.12 + }, + { + "open": 1513.12, + "high": 1517.5, + "low": 1512, + "close": 1515.83 + }, + { + "open": 1515.84, + "high": 1515.84, + "low": 1511.78, + "close": 1514.56 + }, + { + "open": 1514.56, + "high": 1514.59, + "low": 1509.96, + "close": 1512.45 + }, + { + "open": 1512.46, + "high": 1514, + "low": 1510.35, + "close": 1513.82 + }, + { + "open": 1513.82, + "high": 1516.09, + "low": 1510.83, + "close": 1513.39 + }, + { + "open": 1513.38, + "high": 1514.71, + "low": 1511.55, + "close": 1514.06 + }, + { + "open": 1514.07, + "high": 1518.42, + "low": 1514.06, + "close": 1517.45 + }, + { + "open": 1517.44, + "high": 1524.17, + "low": 1517.24, + "close": 1522.76 + }, + { + "open": 1522.76, + "high": 1522.77, + "low": 1516.74, + "close": 1519.27 + }, + { + "open": 1519.27, + "high": 1526.14, + "low": 1518.36, + "close": 1525.37 + }, + { + "open": 1525.36, + "high": 1526.16, + "low": 1523.05, + "close": 1524.78 + }, + { + "open": 1524.78, + "high": 1529.84, + "low": 1524.12, + "close": 1524.83 + }, + { + "open": 1524.83, + "high": 1527.97, + "low": 1523.69, + "close": 1525.07 + }, + { + "open": 1525.07, + "high": 1525.91, + "low": 1515.94, + "close": 1517.07 + }, + { + "open": 1517.08, + "high": 1521.93, + "low": 1514.76, + "close": 1519.75 + }, + { + "open": 1519.75, + "high": 1523.8, + "low": 1518.82, + "close": 1522.53 + }, + { + "open": 1522.52, + "high": 1527.71, + "low": 1521.24, + "close": 1527.26 + }, + { + "open": 1527.26, + "high": 1527.26, + "low": 1523.51, + "close": 1524.17 + }, + { + "open": 1524.18, + "high": 1526.33, + "low": 1522.5, + "close": 1526.02 + }, + { + "open": 1526.02, + "high": 1528.99, + "low": 1525.63, + "close": 1528.01 + }, + { + "open": 1528, + "high": 1528.98, + "low": 1524, + "close": 1527.85 + }, + { + "open": 1527.85, + "high": 1527.91, + "low": 1524.87, + "close": 1527.02 + }, + { + "open": 1527.01, + "high": 1527.77, + "low": 1525.08, + "close": 1527.53 + }, + { + "open": 1527.54, + "high": 1527.9, + "low": 1525.01, + "close": 1525.54 + }, + { + "open": 1525.54, + "high": 1526.36, + "low": 1517.7, + "close": 1521.32 + }, + { + "open": 1521.33, + "high": 1523.72, + "low": 1520.06, + "close": 1523.15 + }, + { + "open": 1523.16, + "high": 1526.22, + "low": 1521.86, + "close": 1523.7 + }, + { + "open": 1523.7, + "high": 1523.83, + "low": 1517.23, + "close": 1517.7 + }, + { + "open": 1517.69, + "high": 1519.99, + "low": 1514.45, + "close": 1515.5 + }, + { + "open": 1515.49, + "high": 1520.24, + "low": 1515.12, + "close": 1517.38 + }, + { + "open": 1517.38, + "high": 1519, + "low": 1516.54, + "close": 1517.4 + }, + { + "open": 1517.39, + "high": 1521.31, + "low": 1516.5, + "close": 1519.95 + }, + { + "open": 1519.96, + "high": 1521.47, + "low": 1514.7, + "close": 1519.12 + }, + { + "open": 1519.12, + "high": 1523.72, + "low": 1518.67, + "close": 1523.67 + }, + { + "open": 1523.66, + "high": 1528.82, + "low": 1522.18, + "close": 1528.71 + }, + { + "open": 1528.82, + "high": 1529.12, + "low": 1523.63, + "close": 1525.19 + }, + { + "open": 1525.2, + "high": 1525.2, + "low": 1518.09, + "close": 1523.17 + }, + { + "open": 1523.17, + "high": 1525.99, + "low": 1521.52, + "close": 1522.46 + }, + { + "open": 1522.47, + "high": 1522.9, + "low": 1519.17, + "close": 1521.6 + }, + { + "open": 1521.6, + "high": 1524.29, + "low": 1521.54, + "close": 1524.29 + }, + { + "open": 1524.29, + "high": 1526, + "low": 1523.3, + "close": 1524.49 + }, + { + "open": 1524.5, + "high": 1526, + "low": 1524.05, + "close": 1524.89 + }, + { + "open": 1524.88, + "high": 1526.85, + "low": 1524, + "close": 1524.81 + }, + { + "open": 1524.81, + "high": 1524.81, + "low": 1522.11, + "close": 1522.12 + }, + { + "open": 1522.12, + "high": 1525.61, + "low": 1521.27, + "close": 1521.65 + }, + { + "open": 1521.64, + "high": 1522.47, + "low": 1518.51, + "close": 1519.48 + }, + { + "open": 1519.48, + "high": 1519.48, + "low": 1516.15, + "close": 1517.9 + }, + { + "open": 1517.91, + "high": 1521.05, + "low": 1517.3, + "close": 1519.79 + }, + { + "open": 1519.79, + "high": 1519.8, + "low": 1510.7, + "close": 1513.91 + }, + { + "open": 1513.9, + "high": 1516.09, + "low": 1512.83, + "close": 1513.43 + }, + { + "open": 1513.43, + "high": 1518.21, + "low": 1513, + "close": 1516.58 + }, + { + "open": 1516.59, + "high": 1533.5, + "low": 1516.09, + "close": 1531.9 + }, + { + "open": 1531.89, + "high": 1537.27, + "low": 1530.14, + "close": 1534.55 + }, + { + "open": 1534.55, + "high": 1541.23, + "low": 1534.34, + "close": 1536.95 + }, + { + "open": 1536.96, + "high": 1542.82, + "low": 1536.28, + "close": 1539.96 + }, + { + "open": 1539.97, + "high": 1542.51, + "low": 1535.03, + "close": 1536.75 + }, + { + "open": 1536.76, + "high": 1542.68, + "low": 1533.45, + "close": 1541.74 + }, + { + "open": 1541.75, + "high": 1543.99, + "low": 1538.04, + "close": 1538.45 + }, + { + "open": 1538.44, + "high": 1539.6, + "low": 1536.86, + "close": 1537.48 + }, + { + "open": 1537.47, + "high": 1537.48, + "low": 1534.36, + "close": 1536.22 + }, + { + "open": 1536.22, + "high": 1537.19, + "low": 1534.16, + "close": 1536.47 + }, + { + "open": 1536.46, + "high": 1536.56, + "low": 1531.43, + "close": 1532.8 + }, + { + "open": 1532.81, + "high": 1536.41, + "low": 1532.68, + "close": 1536.21 + }, + { + "open": 1536.22, + "high": 1538.61, + "low": 1535.4, + "close": 1537.8 + }, + { + "open": 1537.8, + "high": 1538.88, + "low": 1535.72, + "close": 1535.99 + }, + { + "open": 1535.99, + "high": 1537.93, + "low": 1535.82, + "close": 1537.49 + }, + { + "open": 1537.48, + "high": 1537.49, + "low": 1532.8, + "close": 1533.82 + }, + { + "open": 1533.82, + "high": 1536.84, + "low": 1533.62, + "close": 1535.49 + }, + { + "open": 1535.49, + "high": 1535.76, + "low": 1534.01, + "close": 1535.42 + }, + { + "open": 1535.42, + "high": 1536, + "low": 1532.17, + "close": 1534.71 + }, + { + "open": 1534.72, + "high": 1538, + "low": 1534.71, + "close": 1537.86 + }, + { + "open": 1537.85, + "high": 1548.43, + "low": 1537.85, + "close": 1545.4 + }, + { + "open": 1545.39, + "high": 1546, + "low": 1542.09, + "close": 1544.54 + }, + { + "open": 1544.54, + "high": 1545.49, + "low": 1542.13, + "close": 1544.51 + }, + { + "open": 1544.51, + "high": 1545.82, + "low": 1543.21, + "close": 1545.81 + }, + { + "open": 1545.81, + "high": 1546.46, + "low": 1543.57, + "close": 1544.9 + }, + { + "open": 1544.9, + "high": 1548.69, + "low": 1540, + "close": 1547.55 + }, + { + "open": 1547.64, + "high": 1549.87, + "low": 1544.78, + "close": 1547.71 + }, + { + "open": 1547.7, + "high": 1549.65, + "low": 1546.11, + "close": 1547.1 + }, + { + "open": 1547.1, + "high": 1548.13, + "low": 1546.6, + "close": 1547.01 + }, + { + "open": 1547, + "high": 1548.88, + "low": 1542.95, + "close": 1548.88 + }, + { + "open": 1548.88, + "high": 1553.79, + "low": 1544.51, + "close": 1544.77 + }, + { + "open": 1544.77, + "high": 1546.59, + "low": 1543.06, + "close": 1543.48 + }, + { + "open": 1543.48, + "high": 1543.49, + "low": 1535.69, + "close": 1536.78 + }, + { + "open": 1536.78, + "high": 1542.5, + "low": 1535.31, + "close": 1541.57 + }, + { + "open": 1541.57, + "high": 1543.7, + "low": 1538.61, + "close": 1538.77 + }, + { + "open": 1538.77, + "high": 1541.47, + "low": 1537.85, + "close": 1538.86 + }, + { + "open": 1538.86, + "high": 1541.55, + "low": 1536.48, + "close": 1539.18 + }, + { + "open": 1539.19, + "high": 1541.52, + "low": 1538.97, + "close": 1539.38 + }, + { + "open": 1539.38, + "high": 1543, + "low": 1536.55, + "close": 1540.87 + }, + { + "open": 1540.86, + "high": 1543.23, + "low": 1539.09, + "close": 1539.58 + }, + { + "open": 1539.58, + "high": 1540.92, + "low": 1536.45, + "close": 1537.5 + }, + { + "open": 1537.49, + "high": 1538.34, + "low": 1533.5, + "close": 1534.83 + }, + { + "open": 1534.84, + "high": 1534.84, + "low": 1517.11, + "close": 1518.98 + }, + { + "open": 1518.98, + "high": 1525.5, + "low": 1518.64, + "close": 1523.83 + }, + { + "open": 1523.82, + "high": 1531.92, + "low": 1520.33, + "close": 1529.16 + }, + { + "open": 1529.16, + "high": 1531.5, + "low": 1524, + "close": 1530.11 + }, + { + "open": 1530.12, + "high": 1530.2, + "low": 1525.52, + "close": 1527.15 + }, + { + "open": 1527.15, + "high": 1530.94, + "low": 1526.55, + "close": 1530.8 + }, + { + "open": 1530.79, + "high": 1530.8, + "low": 1526.56, + "close": 1527.75 + }, + { + "open": 1527.75, + "high": 1530.84, + "low": 1526.98, + "close": 1527.33 + }, + { + "open": 1527.34, + "high": 1528.92, + "low": 1523.77, + "close": 1528.01 + }, + { + "open": 1528.01, + "high": 1531.35, + "low": 1527.46, + "close": 1530.32 + }, + { + "open": 1530.32, + "high": 1538.65, + "low": 1529.45, + "close": 1536.07 + }, + { + "open": 1536.08, + "high": 1536.51, + "low": 1533.75, + "close": 1535.46 + }, + { + "open": 1535.46, + "high": 1536.81, + "low": 1533.28, + "close": 1533.29 + }, + { + "open": 1533.28, + "high": 1533.97, + "low": 1522.46, + "close": 1523.87 + }, + { + "open": 1523.86, + "high": 1524.03, + "low": 1517.61, + "close": 1523.73 + }, + { + "open": 1523.72, + "high": 1537.06, + "low": 1523.21, + "close": 1532.35 + }, + { + "open": 1532.36, + "high": 1532.36, + "low": 1525.15, + "close": 1527.63 + }, + { + "open": 1527.63, + "high": 1531.98, + "low": 1521.21, + "close": 1522.82 + }, + { + "open": 1522.82, + "high": 1522.82, + "low": 1506.7, + "close": 1516.24 + }, + { + "open": 1516.24, + "high": 1527.52, + "low": 1514.14, + "close": 1523.97 + }, + { + "open": 1523.97, + "high": 1523.98, + "low": 1516.85, + "close": 1520.44 + }, + { + "open": 1520.45, + "high": 1525.98, + "low": 1517.98, + "close": 1521.25 + }, + { + "open": 1521.26, + "high": 1526.16, + "low": 1520, + "close": 1521.76 + }, + { + "open": 1521.77, + "high": 1525, + "low": 1518.05, + "close": 1518.17 + }, + { + "open": 1518.17, + "high": 1518.66, + "low": 1510.75, + "close": 1516.05 + }, + { + "open": 1516.05, + "high": 1518.01, + "low": 1509.24, + "close": 1509.65 + }, + { + "open": 1509.65, + "high": 1513.46, + "low": 1507.45, + "close": 1511.38 + }, + { + "open": 1511.37, + "high": 1517.77, + "low": 1510.69, + "close": 1513.23 + }, + { + "open": 1513.24, + "high": 1513.85, + "low": 1502.21, + "close": 1510.72 + }, + { + "open": 1510.72, + "high": 1516.44, + "low": 1508.36, + "close": 1512.4 + }, + { + "open": 1512.4, + "high": 1518.94, + "low": 1507.61, + "close": 1517.46 + }, + { + "open": 1517.45, + "high": 1523.41, + "low": 1515.15, + "close": 1522.78 + }, + { + "open": 1522.78, + "high": 1524, + "low": 1518.84, + "close": 1520.49 + }, + { + "open": 1520.49, + "high": 1523.13, + "low": 1519.01, + "close": 1521.68 + }, + { + "open": 1521.68, + "high": 1527.81, + "low": 1520.5, + "close": 1524.68 + }, + { + "open": 1524.68, + "high": 1532, + "low": 1523.05, + "close": 1527.05 + }, + { + "open": 1527.04, + "high": 1528.39, + "low": 1523, + "close": 1523.42 + }, + { + "open": 1523.41, + "high": 1523.64, + "low": 1514, + "close": 1514.71 + }, + { + "open": 1514.72, + "high": 1518, + "low": 1511.35, + "close": 1517.24 + }, + { + "open": 1517.23, + "high": 1518.75, + "low": 1514.48, + "close": 1515.82 + }, + { + "open": 1515.81, + "high": 1522.58, + "low": 1514.01, + "close": 1521.13 + }, + { + "open": 1521.13, + "high": 1530.48, + "low": 1521.11, + "close": 1527.23 + }, + { + "open": 1527.23, + "high": 1528.46, + "low": 1522.57, + "close": 1525.67 + }, + { + "open": 1525.67, + "high": 1528.64, + "low": 1525.67, + "close": 1528.35 + }, + { + "open": 1528.35, + "high": 1531.14, + "low": 1523.51, + "close": 1523.51 + }, + { + "open": 1523.51, + "high": 1528.76, + "low": 1523.48, + "close": 1527.56 + }, + { + "open": 1527.57, + "high": 1527.99, + "low": 1524.99, + "close": 1526.03 + }, + { + "open": 1526.02, + "high": 1534.93, + "low": 1524.67, + "close": 1532.6 + }, + { + "open": 1532.6, + "high": 1534.19, + "low": 1529.44, + "close": 1529.96 + }, + { + "open": 1529.96, + "high": 1531.24, + "low": 1524.14, + "close": 1525.3 + }, + { + "open": 1525.29, + "high": 1526.09, + "low": 1520.01, + "close": 1520.14 + }, + { + "open": 1520.15, + "high": 1520.51, + "low": 1517.04, + "close": 1519.66 + }, + { + "open": 1519.67, + "high": 1522.26, + "low": 1517.77, + "close": 1519.3 + }, + { + "open": 1519.31, + "high": 1523.43, + "low": 1518.31, + "close": 1520.11 + }, + { + "open": 1520.12, + "high": 1521.27, + "low": 1514.68, + "close": 1515.46 + }, + { + "open": 1515.46, + "high": 1523.5, + "low": 1515.46, + "close": 1523.21 + }, + { + "open": 1523.22, + "high": 1530.34, + "low": 1522.49, + "close": 1529.8 + }, + { + "open": 1529.8, + "high": 1530.43, + "low": 1526.66, + "close": 1527.1 + }, + { + "open": 1527.1, + "high": 1531.29, + "low": 1525.61, + "close": 1527.6 + }, + { + "open": 1527.61, + "high": 1530.92, + "low": 1527.6, + "close": 1530.26 + }, + { + "open": 1530.26, + "high": 1534.01, + "low": 1528.36, + "close": 1533 + }, + { + "open": 1532.93, + "high": 1534.94, + "low": 1529, + "close": 1529.91 + }, + { + "open": 1529.92, + "high": 1530.63, + "low": 1524.38, + "close": 1527.8 + }, + { + "open": 1527.8, + "high": 1529.94, + "low": 1524.87, + "close": 1526.36 + }, + { + "open": 1526.36, + "high": 1529.37, + "low": 1526.33, + "close": 1527.87 + }, + { + "open": 1527.87, + "high": 1529.29, + "low": 1526.79, + "close": 1527.17 + }, + { + "open": 1527.18, + "high": 1527.18, + "low": 1518.55, + "close": 1519.61 + }, + { + "open": 1519.6, + "high": 1521.47, + "low": 1517.39, + "close": 1520.78 + }, + { + "open": 1520.78, + "high": 1522.13, + "low": 1514.46, + "close": 1516.81 + }, + { + "open": 1516.8, + "high": 1517.6, + "low": 1512.82, + "close": 1513.66 + }, + { + "open": 1513.65, + "high": 1516.81, + "low": 1511.44, + "close": 1513.86 + }, + { + "open": 1513.86, + "high": 1515.47, + "low": 1507.11, + "close": 1509.29 + }, + { + "open": 1509.29, + "high": 1513.83, + "low": 1509.11, + "close": 1510.83 + }, + { + "open": 1510.83, + "high": 1518.12, + "low": 1510.83, + "close": 1515.33 + }, + { + "open": 1515.33, + "high": 1517.4, + "low": 1511.99, + "close": 1514.89 + }, + { + "open": 1514.88, + "high": 1518.67, + "low": 1513.78, + "close": 1516.93 + }, + { + "open": 1516.93, + "high": 1517.58, + "low": 1514.1, + "close": 1515.4 + }, + { + "open": 1515.4, + "high": 1518.96, + "low": 1514.65, + "close": 1518.43 + }, + { + "open": 1518.44, + "high": 1523.32, + "low": 1515.35, + "close": 1515.78 + }, + { + "open": 1515.78, + "high": 1521.43, + "low": 1515.46, + "close": 1519.21 + }, + { + "open": 1519.2, + "high": 1519.88, + "low": 1514.15, + "close": 1516.82 + }, + { + "open": 1516.82, + "high": 1517.28, + "low": 1510.85, + "close": 1513.86 + }, + { + "open": 1513.87, + "high": 1516.82, + "low": 1509.37, + "close": 1510.88 + }, + { + "open": 1510.88, + "high": 1512, + "low": 1505.9, + "close": 1505.93 + }, + { + "open": 1505.94, + "high": 1509.39, + "low": 1477.01, + "close": 1478.26 + }, + { + "open": 1478.26, + "high": 1484.64, + "low": 1474.16, + "close": 1482.69 + }, + { + "open": 1482.69, + "high": 1487.83, + "low": 1478.76, + "close": 1479.74 + }, + { + "open": 1479.74, + "high": 1481.79, + "low": 1470.8, + "close": 1474.95 + }, + { + "open": 1474.95, + "high": 1477.59, + "low": 1466.58, + "close": 1468.19 + }, + { + "open": 1468.2, + "high": 1474, + "low": 1455.17, + "close": 1466.85 + }, + { + "open": 1466.9, + "high": 1472.67, + "low": 1466.89, + "close": 1471.37 + }, + { + "open": 1471.37, + "high": 1478.04, + "low": 1467.14, + "close": 1470.07 + }, + { + "open": 1470.07, + "high": 1477.76, + "low": 1468.23, + "close": 1475.57 + }, + { + "open": 1475.56, + "high": 1491.72, + "low": 1474.88, + "close": 1488.58 + }, + { + "open": 1488.58, + "high": 1496.76, + "low": 1484.21, + "close": 1495 + }, + { + "open": 1495, + "high": 1523.99, + "low": 1492.01, + "close": 1514.98 + }, + { + "open": 1514.98, + "high": 1522.88, + "low": 1513.78, + "close": 1518.91 + }, + { + "open": 1518.91, + "high": 1522.64, + "low": 1517, + "close": 1520.67 + }, + { + "open": 1520.67, + "high": 1529.77, + "low": 1513.5, + "close": 1513.51 + }, + { + "open": 1513.51, + "high": 1520, + "low": 1513.28, + "close": 1517.2 + }, + { + "open": 1517.2, + "high": 1519.49, + "low": 1512.2, + "close": 1518.95 + }, + { + "open": 1518.95, + "high": 1519.49, + "low": 1513.26, + "close": 1515.54 + }, + { + "open": 1515.54, + "high": 1519.1, + "low": 1513.83, + "close": 1518.88 + }, + { + "open": 1518.89, + "high": 1538.51, + "low": 1518.88, + "close": 1531.01 + }, + { + "open": 1530.95, + "high": 1533.43, + "low": 1520.54, + "close": 1526.77 + }, + { + "open": 1526.77, + "high": 1530.58, + "low": 1524.92, + "close": 1527.44 + }, + { + "open": 1527.44, + "high": 1528.44, + "low": 1521.03, + "close": 1523.01 + }, + { + "open": 1523.01, + "high": 1524.56, + "low": 1517.95, + "close": 1521.65 + }, + { + "open": 1521.63, + "high": 1521.75, + "low": 1516.33, + "close": 1520.27 + }, + { + "open": 1520.28, + "high": 1523.83, + "low": 1518.53, + "close": 1523.21 + }, + { + "open": 1523.21, + "high": 1524, + "low": 1520.51, + "close": 1522.06 + }, + { + "open": 1522.05, + "high": 1524, + "low": 1519.38, + "close": 1520.38 + }, + { + "open": 1520.37, + "high": 1521.46, + "low": 1519.64, + "close": 1520.5 + }, + { + "open": 1520.5, + "high": 1525.95, + "low": 1520.49, + "close": 1523.01 + }, + { + "open": 1523, + "high": 1523.38, + "low": 1520.25, + "close": 1520.78 + }, + { + "open": 1520.77, + "high": 1521.39, + "low": 1512.85, + "close": 1517.63 + }, + { + "open": 1517.63, + "high": 1517.85, + "low": 1507.66, + "close": 1514.06 + }, + { + "open": 1514.05, + "high": 1517.93, + "low": 1514.01, + "close": 1516.79 + }, + { + "open": 1516.8, + "high": 1516.8, + "low": 1512.55, + "close": 1514.02 + }, + { + "open": 1514.02, + "high": 1514.89, + "low": 1508.97, + "close": 1510.44 + }, + { + "open": 1510.44, + "high": 1512.24, + "low": 1506.68, + "close": 1510.1 + }, + { + "open": 1510.09, + "high": 1511.6, + "low": 1494.65, + "close": 1495.68 + }, + { + "open": 1495.69, + "high": 1498.59, + "low": 1492.45, + "close": 1496.12 + }, + { + "open": 1496.13, + "high": 1496.13, + "low": 1482.16, + "close": 1487 + }, + { + "open": 1487, + "high": 1488.44, + "low": 1469, + "close": 1471.65 + }, + { + "open": 1471.65, + "high": 1476.97, + "low": 1467.08, + "close": 1469.98 + }, + { + "open": 1469.98, + "high": 1475.2, + "low": 1469.78, + "close": 1472.26 + }, + { + "open": 1472.25, + "high": 1487.99, + "low": 1472.25, + "close": 1485.05 + }, + { + "open": 1485.05, + "high": 1494.72, + "low": 1473.92, + "close": 1485.16 + }, + { + "open": 1485.16, + "high": 1485.17, + "low": 1478.93, + "close": 1482.48 + }, + { + "open": 1482.48, + "high": 1489, + "low": 1479.21, + "close": 1482.03 + }, + { + "open": 1482.03, + "high": 1484.28, + "low": 1475.43, + "close": 1481.37 + }, + { + "open": 1481.38, + "high": 1482.3, + "low": 1469.55, + "close": 1470.66 + }, + { + "open": 1470.66, + "high": 1479.85, + "low": 1469, + "close": 1477.53 + }, + { + "open": 1477.54, + "high": 1483.67, + "low": 1476.45, + "close": 1482.88 + }, + { + "open": 1482.89, + "high": 1487.37, + "low": 1478.08, + "close": 1483.37 + }, + { + "open": 1483.37, + "high": 1484.36, + "low": 1472.36, + "close": 1474.97 + }, + { + "open": 1474.97, + "high": 1478.78, + "low": 1474.69, + "close": 1477.75 + }, + { + "open": 1477.75, + "high": 1478.84, + "low": 1470, + "close": 1470.96 + }, + { + "open": 1470.95, + "high": 1472.38, + "low": 1464, + "close": 1470 + }, + { + "open": 1470, + "high": 1471.07, + "low": 1436.02, + "close": 1443.03 + }, + { + "open": 1443.04, + "high": 1451.98, + "low": 1435.58, + "close": 1447.55 + }, + { + "open": 1447.55, + "high": 1448.1, + "low": 1437.01, + "close": 1440.79 + }, + { + "open": 1440.79, + "high": 1450, + "low": 1432.99, + "close": 1443.3 + }, + { + "open": 1443.29, + "high": 1446.49, + "low": 1441.15, + "close": 1443.34 + }, + { + "open": 1443.34, + "high": 1446.58, + "low": 1437.11, + "close": 1438.52 + }, + { + "open": 1438.51, + "high": 1441.99, + "low": 1429.68, + "close": 1439.43 + }, + { + "open": 1439.43, + "high": 1440.82, + "low": 1427.1, + "close": 1433.08 + }, + { + "open": 1433.08, + "high": 1434.82, + "low": 1428.65, + "close": 1429.91 + }, + { + "open": 1429.91, + "high": 1433.24, + "low": 1427.02, + "close": 1430.25 + }, + { + "open": 1430.19, + "high": 1430.2, + "low": 1415.6, + "close": 1423.09 + }, + { + "open": 1423.09, + "high": 1432, + "low": 1421.92, + "close": 1427.53 + }, + { + "open": 1427.52, + "high": 1428.64, + "low": 1421.55, + "close": 1427.64 + }, + { + "open": 1427.64, + "high": 1430.1, + "low": 1415, + "close": 1415.98 + }, + { + "open": 1415.99, + "high": 1421.48, + "low": 1412.15, + "close": 1415.38 + }, + { + "open": 1415.38, + "high": 1424, + "low": 1412.45, + "close": 1423.02 + }, + { + "open": 1423.03, + "high": 1426.99, + "low": 1422, + "close": 1425.62 + }, + { + "open": 1425.61, + "high": 1427.39, + "low": 1421.18, + "close": 1421.98 + }, + { + "open": 1421.98, + "high": 1426.36, + "low": 1418.04, + "close": 1423.96 + }, + { + "open": 1423.97, + "high": 1424.1, + "low": 1418.49, + "close": 1418.49 + }, + { + "open": 1418.49, + "high": 1421.16, + "low": 1414.03, + "close": 1421.15 + }, + { + "open": 1421.16, + "high": 1421.16, + "low": 1415, + "close": 1417.04 + }, + { + "open": 1417.04, + "high": 1418.37, + "low": 1413.06, + "close": 1413.36 + }, + { + "open": 1413.37, + "high": 1423.37, + "low": 1413.21, + "close": 1422.91 + }, + { + "open": 1422.9, + "high": 1425.51, + "low": 1421.11, + "close": 1423.99 + }, + { + "open": 1423.98, + "high": 1425.09, + "low": 1422.73, + "close": 1423.61 + }, + { + "open": 1423.62, + "high": 1424.96, + "low": 1420, + "close": 1420.23 + }, + { + "open": 1420.23, + "high": 1421.87, + "low": 1419.01, + "close": 1421.02 + }, + { + "open": 1421.01, + "high": 1426.03, + "low": 1420.88, + "close": 1425.12 + }, + { + "open": 1425.11, + "high": 1425.68, + "low": 1421.85, + "close": 1423.23 + }, + { + "open": 1423.23, + "high": 1424.73, + "low": 1421.37, + "close": 1423.76 + }, + { + "open": 1423.75, + "high": 1424, + "low": 1418.3, + "close": 1420 + }, + { + "open": 1419.99, + "high": 1420.19, + "low": 1415.15, + "close": 1419.8 + }, + { + "open": 1419.79, + "high": 1421.88, + "low": 1418.08, + "close": 1420.88 + }, + { + "open": 1420.89, + "high": 1424.26, + "low": 1420.87, + "close": 1422.07 + }, + { + "open": 1422.06, + "high": 1423.13, + "low": 1420.59, + "close": 1422.39 + }, + { + "open": 1422.39, + "high": 1423.95, + "low": 1421.75, + "close": 1421.9 + }, + { + "open": 1421.9, + "high": 1423.15, + "low": 1419.15, + "close": 1423.14 + }, + { + "open": 1423.15, + "high": 1423.15, + "low": 1417.79, + "close": 1419.37 + }, + { + "open": 1419.38, + "high": 1419.57, + "low": 1416.26, + "close": 1417.29 + }, + { + "open": 1417.28, + "high": 1419.21, + "low": 1416.5, + "close": 1418.07 + }, + { + "open": 1418.06, + "high": 1418.07, + "low": 1400.46, + "close": 1415.32 + }, + { + "open": 1415.31, + "high": 1424.48, + "low": 1414.74, + "close": 1423.85 + }, + { + "open": 1423.86, + "high": 1424.14, + "low": 1419.33, + "close": 1421.39 + }, + { + "open": 1421.38, + "high": 1430.92, + "low": 1420.53, + "close": 1429.17 + }, + { + "open": 1429.17, + "high": 1438.16, + "low": 1428.17, + "close": 1436.56 + }, + { + "open": 1436.56, + "high": 1436.57, + "low": 1427.07, + "close": 1429.86 + }, + { + "open": 1429.86, + "high": 1429.87, + "low": 1422.71, + "close": 1425.41 + }, + { + "open": 1425.4, + "high": 1428.97, + "low": 1423.88, + "close": 1427.36 + }, + { + "open": 1427.36, + "high": 1432, + "low": 1426.91, + "close": 1431.33 + }, + { + "open": 1431.33, + "high": 1434.92, + "low": 1431.32, + "close": 1433.05 + }, + { + "open": 1433.04, + "high": 1435, + "low": 1431.4, + "close": 1434.81 + }, + { + "open": 1434.81, + "high": 1435.37, + "low": 1430.27, + "close": 1430.44 + }, + { + "open": 1430.45, + "high": 1433.4, + "low": 1430.1, + "close": 1432.26 + }, + { + "open": 1432.27, + "high": 1433.74, + "low": 1428.86, + "close": 1429.56 + }, + { + "open": 1429.56, + "high": 1434.14, + "low": 1428.46, + "close": 1432.89 + }, + { + "open": 1432.89, + "high": 1438.3, + "low": 1431.03, + "close": 1432.86 + }, + { + "open": 1432.87, + "high": 1433.99, + "low": 1431, + "close": 1433.84 + }, + { + "open": 1433.85, + "high": 1434.14, + "low": 1431.21, + "close": 1431.54 + }, + { + "open": 1431.53, + "high": 1433.37, + "low": 1430.3, + "close": 1431.32 + }, + { + "open": 1431.32, + "high": 1434, + "low": 1431.05, + "close": 1432.62 + }, + { + "open": 1432.62, + "high": 1433.24, + "low": 1425.55, + "close": 1426.28 + }, + { + "open": 1426.28, + "high": 1429.8, + "low": 1424.16, + "close": 1427.33 + }, + { + "open": 1427.33, + "high": 1427.88, + "low": 1424.32, + "close": 1426.04 + }, + { + "open": 1426.03, + "high": 1426.33, + "low": 1422.21, + "close": 1423.93 + }, + { + "open": 1423.93, + "high": 1425.44, + "low": 1422.55, + "close": 1423.89 + }, + { + "open": 1423.88, + "high": 1424.55, + "low": 1420.83, + "close": 1421.27 + }, + { + "open": 1421.26, + "high": 1423.64, + "low": 1420.6, + "close": 1422.77 + }, + { + "open": 1422.77, + "high": 1426.46, + "low": 1422.77, + "close": 1425.91 + }, + { + "open": 1425.92, + "high": 1426.42, + "low": 1422.2, + "close": 1422.72 + }, + { + "open": 1422.73, + "high": 1427.27, + "low": 1422.14, + "close": 1427.22 + }, + { + "open": 1427.22, + "high": 1430.2, + "low": 1425.67, + "close": 1428.24 + }, + { + "open": 1428.25, + "high": 1432.24, + "low": 1427.87, + "close": 1429.9 + }, + { + "open": 1429.89, + "high": 1430.99, + "low": 1428, + "close": 1428.42 + }, + { + "open": 1428.43, + "high": 1429.92, + "low": 1424.61, + "close": 1426.78 + }, + { + "open": 1426.79, + "high": 1427.3, + "low": 1424.19, + "close": 1424.26 + }, + { + "open": 1424.27, + "high": 1425.86, + "low": 1423.42, + "close": 1424.38 + }, + { + "open": 1424.37, + "high": 1424.47, + "low": 1420.8, + "close": 1421.27 + }, + { + "open": 1421.27, + "high": 1423.26, + "low": 1421.01, + "close": 1422.37 + }, + { + "open": 1422.37, + "high": 1426, + "low": 1421.84, + "close": 1424.07 + }, + { + "open": 1424.07, + "high": 1424.35, + "low": 1421.43, + "close": 1423.56 + }, + { + "open": 1423.55, + "high": 1423.71, + "low": 1416.58, + "close": 1417.41 + }, + { + "open": 1417.4, + "high": 1420.22, + "low": 1413.72, + "close": 1416.05 + }, + { + "open": 1416.06, + "high": 1417.5, + "low": 1414.67, + "close": 1416.57 + }, + { + "open": 1416.57, + "high": 1422.13, + "low": 1415.8, + "close": 1417.42 + }, + { + "open": 1417.42, + "high": 1417.88, + "low": 1415, + "close": 1416 + }, + { + "open": 1416.01, + "high": 1419.27, + "low": 1415.19, + "close": 1417.59 + }, + { + "open": 1417.59, + "high": 1418.69, + "low": 1415.76, + "close": 1416.86 + }, + { + "open": 1416.86, + "high": 1419.7, + "low": 1414.22, + "close": 1419.51 + }, + { + "open": 1419.5, + "high": 1421.79, + "low": 1417.87, + "close": 1420.19 + }, + { + "open": 1420.19, + "high": 1423.7, + "low": 1413.12, + "close": 1422.48 + }, + { + "open": 1422.49, + "high": 1423.28, + "low": 1421.01, + "close": 1422.19 + }, + { + "open": 1422.19, + "high": 1423.55, + "low": 1421.32, + "close": 1423.15 + }, + { + "open": 1423.15, + "high": 1423.67, + "low": 1421.4, + "close": 1421.76 + }, + { + "open": 1421.77, + "high": 1421.77, + "low": 1419.22, + "close": 1420.17 + }, + { + "open": 1420.18, + "high": 1421.76, + "low": 1416.74, + "close": 1417.99 + }, + { + "open": 1418, + "high": 1418.82, + "low": 1414.58, + "close": 1415.97 + }, + { + "open": 1415.97, + "high": 1417.49, + "low": 1409.57, + "close": 1412.33 + }, + { + "open": 1412.29, + "high": 1414.74, + "low": 1406.68, + "close": 1409.46 + }, + { + "open": 1409.46, + "high": 1410.48, + "low": 1406.23, + "close": 1406.35 + }, + { + "open": 1406.36, + "high": 1413.17, + "low": 1406.34, + "close": 1408.12 + }, + { + "open": 1408.12, + "high": 1411.39, + "low": 1406.3, + "close": 1406.63 + }, + { + "open": 1406.64, + "high": 1410.32, + "low": 1403.94, + "close": 1409.74 + }, + { + "open": 1409.74, + "high": 1411.89, + "low": 1407.7, + "close": 1411.32 + }, + { + "open": 1411.33, + "high": 1413.91, + "low": 1410.85, + "close": 1411.65 + }, + { + "open": 1411.64, + "high": 1414.31, + "low": 1410.68, + "close": 1413 + }, + { + "open": 1413, + "high": 1414.29, + "low": 1410.84, + "close": 1413.58 + }, + { + "open": 1413.59, + "high": 1418.54, + "low": 1413.56, + "close": 1417.54 + }, + { + "open": 1417.54, + "high": 1418.17, + "low": 1415.47, + "close": 1416.05 + }, + { + "open": 1416.05, + "high": 1416.25, + "low": 1413.66, + "close": 1414.45 + }, + { + "open": 1414.46, + "high": 1416.15, + "low": 1411.4, + "close": 1412.57 + }, + { + "open": 1412.57, + "high": 1414.98, + "low": 1411.25, + "close": 1414.75 + }, + { + "open": 1414.75, + "high": 1416, + "low": 1411, + "close": 1415.8 + }, + { + "open": 1415.79, + "high": 1417, + "low": 1413.48, + "close": 1414.55 + }, + { + "open": 1414.56, + "high": 1415.39, + "low": 1412.53, + "close": 1413.86 + }, + { + "open": 1413.86, + "high": 1417.88, + "low": 1413.13, + "close": 1417.18 + }, + { + "open": 1417.17, + "high": 1418.7, + "low": 1415.3, + "close": 1417.9 + }, + { + "open": 1417.89, + "high": 1421.6, + "low": 1417.51, + "close": 1420.29 + }, + { + "open": 1420.3, + "high": 1421.17, + "low": 1419, + "close": 1420.01 + }, + { + "open": 1420, + "high": 1422.16, + "low": 1419.5, + "close": 1421.11 + }, + { + "open": 1421.12, + "high": 1423.97, + "low": 1420.9, + "close": 1423.25 + }, + { + "open": 1423.26, + "high": 1423.26, + "low": 1420.76, + "close": 1420.82 + }, + { + "open": 1420.82, + "high": 1421.01, + "low": 1418.92, + "close": 1419.11 + }, + { + "open": 1419.11, + "high": 1421.25, + "low": 1419, + "close": 1421 + }, + { + "open": 1421.01, + "high": 1422.98, + "low": 1417.66, + "close": 1418.53 + }, + { + "open": 1418.52, + "high": 1419.44, + "low": 1415.01, + "close": 1415.47 + }, + { + "open": 1415.47, + "high": 1417.15, + "low": 1413.71, + "close": 1414.16 + }, + { + "open": 1414.15, + "high": 1416.22, + "low": 1413.37, + "close": 1414.09 + }, + { + "open": 1414.1, + "high": 1416.01, + "low": 1413, + "close": 1415.83 + }, + { + "open": 1415.84, + "high": 1418.37, + "low": 1413.65, + "close": 1414.5 + }, + { + "open": 1414.46, + "high": 1414.96, + "low": 1408.49, + "close": 1411.92 + }, + { + "open": 1411.92, + "high": 1412.58, + "low": 1409.72, + "close": 1412.57 + }, + { + "open": 1412.57, + "high": 1413.05, + "low": 1411.23, + "close": 1411.32 + }, + { + "open": 1411.32, + "high": 1412.81, + "low": 1408.13, + "close": 1409.79 + }, + { + "open": 1409.79, + "high": 1412.35, + "low": 1409.34, + "close": 1410.44 + }, + { + "open": 1410.43, + "high": 1412.92, + "low": 1408.61, + "close": 1412.09 + }, + { + "open": 1412.1, + "high": 1412.84, + "low": 1410.56, + "close": 1410.57 + }, + { + "open": 1410.56, + "high": 1411.32, + "low": 1409.19, + "close": 1409.59 + }, + { + "open": 1409.6, + "high": 1412.09, + "low": 1407.68, + "close": 1410.42 + }, + { + "open": 1410.43, + "high": 1414.86, + "low": 1409.94, + "close": 1414.1 + }, + { + "open": 1414.11, + "high": 1414.47, + "low": 1412.17, + "close": 1412.65 + }, + { + "open": 1412.66, + "high": 1413.85, + "low": 1411.93, + "close": 1413.43 + }, + { + "open": 1413.44, + "high": 1415.79, + "low": 1412.99, + "close": 1413.51 + }, + { + "open": 1413.52, + "high": 1414.6, + "low": 1410.26, + "close": 1410.34 + }, + { + "open": 1410.33, + "high": 1413.83, + "low": 1410.33, + "close": 1413.39 + }, + { + "open": 1413.38, + "high": 1414.82, + "low": 1406.46, + "close": 1407.52 + }, + { + "open": 1407.52, + "high": 1413.46, + "low": 1385.08, + "close": 1406.33 + }, + { + "open": 1406.33, + "high": 1406.42, + "low": 1396.54, + "close": 1403.7 + }, + { + "open": 1403.69, + "high": 1413.95, + "low": 1403.69, + "close": 1411.91 + }, + { + "open": 1411.92, + "high": 1420.39, + "low": 1411.91, + "close": 1414.33 + }, + { + "open": 1414.34, + "high": 1414.52, + "low": 1405.66, + "close": 1405.79 + }, + { + "open": 1405.8, + "high": 1409.11, + "low": 1399, + "close": 1399.24 + }, + { + "open": 1399.23, + "high": 1405.73, + "low": 1399.23, + "close": 1400.56 + }, + { + "open": 1400.55, + "high": 1402, + "low": 1396.96, + "close": 1400.96 + }, + { + "open": 1400.96, + "high": 1401.35, + "low": 1390.31, + "close": 1394.24 + }, + { + "open": 1394.24, + "high": 1395.43, + "low": 1387.54, + "close": 1389.76 + }, + { + "open": 1389.87, + "high": 1390.95, + "low": 1381.54, + "close": 1384.88 + }, + { + "open": 1384.87, + "high": 1388.96, + "low": 1383.78, + "close": 1388.13 + }, + { + "open": 1388.13, + "high": 1393.11, + "low": 1387.84, + "close": 1391.98 + }, + { + "open": 1391.99, + "high": 1393.4, + "low": 1387.03, + "close": 1391.28 + }, + { + "open": 1391.38, + "high": 1391.68, + "low": 1384.04, + "close": 1384.51 + }, + { + "open": 1384.52, + "high": 1387.12, + "low": 1369.29, + "close": 1377.99 + }, + { + "open": 1377.99, + "high": 1384.82, + "low": 1371, + "close": 1383.01 + }, + { + "open": 1383.01, + "high": 1388, + "low": 1381.75, + "close": 1383.05 + }, + { + "open": 1383.06, + "high": 1388.47, + "low": 1383.05, + "close": 1387.82 + }, + { + "open": 1387.82, + "high": 1390, + "low": 1382.78, + "close": 1388.01 + }, + { + "open": 1388, + "high": 1392.96, + "low": 1386.5, + "close": 1391.27 + }, + { + "open": 1391.2, + "high": 1392.61, + "low": 1387.5, + "close": 1389.07 + }, + { + "open": 1389.07, + "high": 1389.07, + "low": 1382.39, + "close": 1385.3 + }, + { + "open": 1385.3, + "high": 1392, + "low": 1384.63, + "close": 1387.41 + }, + { + "open": 1387.41, + "high": 1390.71, + "low": 1386.5, + "close": 1389.12 + }, + { + "open": 1389.11, + "high": 1392.6, + "low": 1382.5, + "close": 1391.93 + }, + { + "open": 1391.93, + "high": 1396.92, + "low": 1390.04, + "close": 1391.11 + }, + { + "open": 1391.11, + "high": 1394.3, + "low": 1383.57, + "close": 1385.3 + }, + { + "open": 1385.31, + "high": 1386.85, + "low": 1379.45, + "close": 1382.74 + }, + { + "open": 1382.74, + "high": 1384.96, + "low": 1379.24, + "close": 1382.24 + }, + { + "open": 1382.24, + "high": 1384.6, + "low": 1380.2, + "close": 1380.79 + }, + { + "open": 1380.78, + "high": 1382.77, + "low": 1376, + "close": 1381.3 + }, + { + "open": 1381.29, + "high": 1384.6, + "low": 1379.59, + "close": 1382.74 + }, + { + "open": 1382.75, + "high": 1385.95, + "low": 1381.72, + "close": 1384.68 + }, + { + "open": 1384.67, + "high": 1392.47, + "low": 1384.26, + "close": 1389.48 + }, + { + "open": 1389.49, + "high": 1392, + "low": 1388.82, + "close": 1390.13 + }, + { + "open": 1390.13, + "high": 1392.46, + "low": 1389.7, + "close": 1391.94 + }, + { + "open": 1391.94, + "high": 1393.95, + "low": 1388.32, + "close": 1388.68 + }, + { + "open": 1388.68, + "high": 1389.35, + "low": 1379.59, + "close": 1381.72 + }, + { + "open": 1381.71, + "high": 1384.78, + "low": 1379.64, + "close": 1384.68 + }, + { + "open": 1384.67, + "high": 1384.92, + "low": 1381.13, + "close": 1383.07 + }, + { + "open": 1383.07, + "high": 1383.24, + "low": 1375.5, + "close": 1375.74 + }, + { + "open": 1375.73, + "high": 1378.38, + "low": 1372.01, + "close": 1374.42 + }, + { + "open": 1374.42, + "high": 1377.99, + "low": 1366, + "close": 1377.51 + }, + { + "open": 1377.51, + "high": 1378.73, + "low": 1372.87, + "close": 1375.18 + }, + { + "open": 1375.18, + "high": 1378.38, + "low": 1371.96, + "close": 1376.76 + }, + { + "open": 1376.77, + "high": 1377.59, + "low": 1370.81, + "close": 1370.95 + }, + { + "open": 1370.95, + "high": 1374.62, + "low": 1363.87, + "close": 1367.88 + }, + { + "open": 1367.88, + "high": 1372, + "low": 1365.03, + "close": 1368.68 + }, + { + "open": 1368.67, + "high": 1373.02, + "low": 1367, + "close": 1370.9 + } +]`) + +func Test_KalmanFilter(t *testing.T) { + type args struct { + allKLines []types.KLine + window int + additionalSmoothWindow uint + } + var klines []types.KLine + if err := json.Unmarshal(testKalmanFilterDataEthusdt5m, &klines); err != nil { + panic(err) + } + tests := []struct { + name string + args args + want float64 + }{ + { + name: "ETHUSDT Kalman Filter 7", + args: args{ + allKLines: klines, + window: 7, + additionalSmoothWindow: 3, + }, + want: 1369.24, + }, + { + name: "ETHUSDT Kalman Filter 25", + args: args{ + allKLines: klines, + window: 25, + additionalSmoothWindow: 0, + }, + want: 1369.84, + }, + { + name: "ETHUSDT Kalman Filter 99", + args: args{ + allKLines: klines, + window: 99, + additionalSmoothWindow: 0, + }, + want: 1369.95, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter := &KalmanFilter{ + IntervalWindow: types.IntervalWindow{Window: tt.args.window}, + AdditionalSmoothWindow: tt.args.additionalSmoothWindow, + } + for _, k := range klines { + filter.PushK(k) + } + got := filter.Last(0) + got = math.Trunc(got*100.0) / 100.0 + if got != tt.want { + t.Errorf("KalmanFilter.Last() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_KalmanFilterEstimationAccurate(t *testing.T) { + type args struct { + allKLines []types.KLine + partialInfo bool + window int + } + var klines []types.KLine + if err := json.Unmarshal(testKalmanFilterDataEthusdt5m, &klines); err != nil { + panic(err) + } + tests := []struct { + name string + args args + want float64 + }{ + { + name: "ETHUSDT Kalman Filter full K-line square error 7", + args: args{ + allKLines: klines, + partialInfo: false, + window: 7, + }, + }, + { + name: "ETHUSDT Kalman Filter full K-line square error 25", + args: args{ + allKLines: klines, + partialInfo: false, + window: 25, + }, + }, + { + name: "ETHUSDT Kalman Filter full K-line square error 99", + args: args{ + allKLines: klines, + partialInfo: false, + window: 99, + }, + }, + { + name: "ETHUSDT Kalman Filter partial K-line square error 7", + args: args{ + allKLines: klines, + partialInfo: true, + window: 7, + }, + }, + { + name: "ETHUSDT Kalman Filter partial K-line square error 25", + args: args{ + allKLines: klines, + partialInfo: true, + window: 25, + }, + }, + { + name: "ETHUSDT Kalman Filter partial K-line square error 99", + args: args{ + allKLines: klines, + partialInfo: true, + window: 99, + }, + }, + } + klineSquareError := func(base float64, k types.KLine) float64 { + openDiff := math.Abs(k.Open.Float64() - base) + highDiff := math.Abs(k.High.Float64() - base) + lowDiff := math.Abs(k.Low.Float64() - base) + closeDiff := math.Abs(k.Close.Float64() - base) + return openDiff*openDiff + highDiff*highDiff + lowDiff*lowDiff + closeDiff*closeDiff + } + closeSquareError := func(base float64, k types.KLine) float64 { + closeDiff := math.Abs(k.Close.Float64() - base) + return closeDiff * closeDiff + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter := &KalmanFilter{IntervalWindow: types.IntervalWindow{Window: tt.args.window}} + ewma := &EWMA{IntervalWindow: types.IntervalWindow{Window: tt.args.window}} + + var filterDiff2Sum, ewmaDiff2Sum float64 + var filterCloseDiff2Sum, ewmaCloseDiff2Sum float64 + var numEstimations = 0 + for _, k := range klines { + // square error between last estimated state and current actual state + if ewma.Length() > 0 { + filterDiff2Sum += klineSquareError(filter.Last(0), k) + ewmaDiff2Sum += klineSquareError(ewma.Last(0), k) + filterCloseDiff2Sum += closeSquareError(filter.Last(0), k) + ewmaCloseDiff2Sum += closeSquareError(ewma.Last(0), k) + numEstimations++ + } + + // update estimations + if tt.args.partialInfo { + filter.Update(k.Close.Float64()) + ewma.Update(k.Close.Float64()) + } else { + filter.PushK(k) + ewma.PushK(k) + } + } + filterSquareErr := math.Sqrt(filterDiff2Sum / float64(numEstimations*4)) + ewmaSquareErr := math.Sqrt(ewmaDiff2Sum / float64(numEstimations*4)) + if filterSquareErr > ewmaSquareErr { + t.Errorf("filter K-Line square error %f > EWMA K-Line square error %v", filterSquareErr, ewmaSquareErr) + } + filterCloseSquareErr := math.Sqrt(filterCloseDiff2Sum / float64(numEstimations)) + ewmaCloseSquareErr := math.Sqrt(ewmaCloseDiff2Sum / float64(numEstimations)) + if filterCloseSquareErr > ewmaCloseSquareErr { + t.Errorf("filter close price square error %f > EWMA close price square error %v", filterCloseSquareErr, ewmaCloseSquareErr) + } + }) + } +} diff --git a/pkg/indicator/klingeroscillator.go b/pkg/indicator/klingeroscillator.go new file mode 100644 index 0000000..84057fd --- /dev/null +++ b/pkg/indicator/klingeroscillator.go @@ -0,0 +1,99 @@ +package indicator + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// Refer: Klinger Oscillator +// Refer URL: https://www.investopedia.com/terms/k/klingeroscillator.asp +// Explanation: +// The Klinger Oscillator is a technical indicator that was developed by Stephen Klinger. +// It is based on the assumption that there is a relationship between money flow and price movement in the stock market. +// The Klinger Oscillator is calculated by taking the difference between a 34-period and 55-period moving average. +// Usually the indicator is using together with a 9-period or 13-period of moving average as the signal line. +// This indicator is often used to identify potential turning points in the market, as well as to confirm the strength of a trend. +// +//go:generate callbackgen -type KlingerOscillator +type KlingerOscillator struct { + types.SeriesBase + types.IntervalWindow + Fast types.UpdatableSeries + Slow types.UpdatableSeries + VF VolumeForce + + updateCallbacks []func(value float64) +} + +func (inc *KlingerOscillator) Length() int { + if inc.Fast == nil || inc.Slow == nil { + return 0 + } + return inc.Fast.Length() +} + +func (inc *KlingerOscillator) Last(i int) float64 { + if inc.Fast == nil || inc.Slow == nil { + return 0 + } + return inc.Fast.Last(i) - inc.Slow.Last(i) +} + +func (inc *KlingerOscillator) Update(high, low, cloze, volume float64) { + if inc.Fast == nil { + inc.SeriesBase.Series = inc + inc.Fast = &EWMA{IntervalWindow: types.IntervalWindow{Window: 34, Interval: inc.Interval}} + inc.Slow = &EWMA{IntervalWindow: types.IntervalWindow{Window: 55, Interval: inc.Interval}} + } + + if inc.VF.lastSum > 0 { + inc.VF.Update(high, low, cloze, volume) + inc.Fast.Update(inc.VF.Value) + inc.Slow.Update(inc.VF.Value) + } else { + inc.VF.Update(high, low, cloze, volume) + } +} + +var _ types.SeriesExtend = &KlingerOscillator{} + +func (inc *KlingerOscillator) PushK(k types.KLine) { + inc.Update(k.High.Float64(), k.Low.Float64(), k.Close.Float64(), k.Volume.Float64()) +} + +func (inc *KlingerOscillator) BindK(target KLineClosedEmitter, symbol string, interval types.Interval) { + target.OnKLineClosed(types.KLineWith(symbol, interval, inc.PushK)) +} + +// Utility to hold the state of calculation +type VolumeForce struct { + dm float64 + cm float64 + trend float64 + lastSum float64 + Value float64 +} + +func (inc *VolumeForce) Update(high, low, cloze, volume float64) { + if inc.lastSum == 0 { + inc.dm = high - low + inc.cm = inc.dm + inc.trend = 1. + inc.lastSum = high + low + cloze + inc.Value = volume // first volume is not calculated + return + } + trend := 1. + if high+low+cloze <= inc.lastSum { + trend = -1. + } + dm := high - low + if inc.trend == trend { + inc.cm = inc.cm + dm + } else { + inc.cm = inc.dm + dm + } + inc.trend = trend + inc.lastSum = high + low + cloze + inc.dm = dm + inc.Value = volume * (2.*(inc.dm/inc.cm) - 1.) * trend +} diff --git a/pkg/indicator/klingeroscillator_callbacks.go b/pkg/indicator/klingeroscillator_callbacks.go new file mode 100644 index 0000000..3f03f97 --- /dev/null +++ b/pkg/indicator/klingeroscillator_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type KlingerOscillator"; DO NOT EDIT. + +package indicator + +import () + +func (inc *KlingerOscillator) OnUpdate(cb func(value float64)) { + inc.updateCallbacks = append(inc.updateCallbacks, cb) +} + +func (inc *KlingerOscillator) EmitUpdate(value float64) { + for _, cb := range inc.updateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/klingeroscillator_test.go b/pkg/indicator/klingeroscillator_test.go new file mode 100644 index 0000000..8e9fdd1 --- /dev/null +++ b/pkg/indicator/klingeroscillator_test.go @@ -0,0 +1,53 @@ +package indicator + +import ( + "encoding/json" + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +/* +import pandas as pd +import pandas_ta as ta +high = pd.Series([1.1, 1.3, 1.5, 1.7, 1.9, 2.2, 2.4, 2.1, 1.8, 1.7]) +low = pd.Series([0.9, 1.1, 1.2, 1.5, 1.7, 2.0, 2.2, 1.9, 1.6, 1.5]) +close = pd.Series([1.0, 1.2, 1.4, 1.6, 1.8, 2.1, 2.3, 2.0, 1.7, 1.6]) +vol = pd.Series([300., 200., 200., 150., 150., 200., 200., 150., 300., 350.]) +# kvo = ta.kvo(high, low, close, vol, fast=3, slow=5, signal=1) +# print(kvo) +# # The implementation of kvo in pandas_ta is different from the one defined in investopedia +# # VF is not simply multipying trend +# # Also the value is not multiplied by 100 in pandas_ta +*/ + +func Test_KlingerOscillator(t *testing.T) { + var high, low, cloze, vResult, vol []fixedpoint.Value + if err := json.Unmarshal([]byte(`[1.1, 1.3, 1.5, 1.7, 1.9, 2.2, 2.4, 2.1, 1.8, 1.7]`), &high); err != nil { + panic(err) + } + if err := json.Unmarshal([]byte(`[0.9, 1.1, 1.2, 1.5, 1.7, 2.0, 2.2, 1.9, 1.6, 1.5]`), &low); err != nil { + panic(err) + } + if err := json.Unmarshal([]byte(`[1.0, 1.2, 1.4, 1.6, 1.8, 2.1, 2.3, 2.0, 1.7, 1.6]`), &cloze); err != nil { + panic(err) + } + if err := json.Unmarshal([]byte(`[300.0, 200.0, 200.0, 150.0, 150.0, 200.0, 200.0, 150.0, 300.0, 350.0]`), &vol); err != nil { + panic(err) + } + if err := json.Unmarshal([]byte(`[300.0, 0.0, -28.5, -83, -95, -138, -146.7, 0, 100, 175]`), &vResult); err != nil { + panic(err) + } + + k := KlingerOscillator{ + Fast: &EWMA{IntervalWindow: types.IntervalWindow{Window: 3}}, + Slow: &EWMA{IntervalWindow: types.IntervalWindow{Window: 5}}, + } + var Delta = 0.5 + for i := 0; i < len(high); i++ { + k.Update(high[i].Float64(), low[i].Float64(), cloze[i].Float64(), vol[i].Float64()) + assert.InDelta(t, k.VF.Value, vResult[i].Float64(), Delta) + } +} diff --git a/pkg/indicator/line.go b/pkg/indicator/line.go new file mode 100644 index 0000000..e4fe0ef --- /dev/null +++ b/pkg/indicator/line.go @@ -0,0 +1,80 @@ +package indicator + +import ( + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// Line indicator is a utility that helps to simulate either the +// 1. trend +// 2. support +// 3. resistance +// of the market data, defined with series interface +type Line struct { + types.SeriesBase + types.IntervalWindow + start float64 + end float64 + startIndex int + endIndex int + currentTime time.Time + Interval types.Interval +} + +func (l *Line) handleKLineWindowUpdate(interval types.Interval, allKLines types.KLineWindow) { + if interval != l.Interval { + return + } + + newTime := allKLines.Last().EndTime.Time() + delta := int(newTime.Sub(l.currentTime).Minutes()) / l.Interval.Minutes() + l.startIndex += delta + l.endIndex += delta + l.currentTime = newTime +} + +func (l *Line) Bind(updater KLineWindowUpdater) { + updater.OnKLineWindowUpdate(l.handleKLineWindowUpdate) +} + +func (l *Line) Last(i int) float64 { + return (l.end-l.start)/float64(l.startIndex-l.endIndex)*float64(l.endIndex-i) + l.end +} + +func (l *Line) Index(i int) float64 { + return l.Last(i) +} + +func (l *Line) Length() int { + if l.startIndex > l.endIndex { + return l.startIndex - l.endIndex + } else { + return l.endIndex - l.startIndex + } +} + +func (l *Line) SetXY1(index int, value float64) { + l.startIndex = index + l.start = value +} + +func (l *Line) SetXY2(index int, value float64) { + l.endIndex = index + l.end = value +} + +func NewLine(startIndex int, startValue float64, endIndex int, endValue float64, interval types.Interval) *Line { + line := &Line{ + start: startValue, + end: endValue, + startIndex: startIndex, + endIndex: endIndex, + currentTime: time.Time{}, + Interval: interval, + } + line.SeriesBase.Series = line + return line +} + +var _ types.SeriesExtend = &Line{} diff --git a/pkg/indicator/linreg.go b/pkg/indicator/linreg.go new file mode 100644 index 0000000..a0eca75 --- /dev/null +++ b/pkg/indicator/linreg.go @@ -0,0 +1,120 @@ +package indicator + +import ( + "time" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +var logLinReg = logrus.WithField("indicator", "LinReg") + +// LinReg is Linear Regression baseline +// +//go:generate callbackgen -type LinReg +type LinReg struct { + types.SeriesBase + types.IntervalWindow + + // Values are the slopes of linear regression baseline + Values floats.Slice + // ValueRatios are the ratio of slope to the price + ValueRatios floats.Slice + + klines types.KLineWindow + + EndTime time.Time + UpdateCallbacks []func(value float64) +} + +// Last slope of linear regression baseline +func (lr *LinReg) Last(i int) float64 { + return lr.Values.Last(i) +} + +// LastRatio of slope to price +func (lr *LinReg) LastRatio() float64 { + if lr.ValueRatios.Length() == 0 { + return 0.0 + } + return lr.ValueRatios.Last(0) +} + +// Index returns the slope of specified index +func (lr *LinReg) Index(i int) float64 { + return lr.Values.Last(i) +} + +// IndexRatio returns the slope ratio +func (lr *LinReg) IndexRatio(i int) float64 { + if i >= lr.ValueRatios.Length() { + return 0.0 + } + + return lr.ValueRatios.Last(i) +} + +// Length of the slope values +func (lr *LinReg) Length() int { + return lr.Values.Length() +} + +// LengthRatio of the slope ratio values +func (lr *LinReg) LengthRatio() int { + return lr.ValueRatios.Length() +} + +var _ types.SeriesExtend = &LinReg{} + +// Update Linear Regression baseline slope +func (lr *LinReg) Update(kline types.KLine) { + lr.klines.Add(kline) + lr.klines.Truncate(lr.Window) + if len(lr.klines) < lr.Window { + lr.Values.Push(0) + lr.ValueRatios.Push(0) + return + } + + var sumX, sumY, sumXSqr, sumXY float64 = 0, 0, 0, 0 + end := len(lr.klines) - 1 // The last kline + for i := end; i >= end-lr.Window+1; i-- { + val := lr.klines[i].GetClose().Float64() + per := float64(end - i + 1) + sumX += per + sumY += val + sumXSqr += per * per + sumXY += val * per + } + length := float64(lr.Window) + slope := (length*sumXY - sumX*sumY) / (length*sumXSqr - sumX*sumX) + average := sumY / length + endPrice := average - slope*sumX/length + slope + startPrice := endPrice + slope*(length-1) + lr.Values.Push((endPrice - startPrice) / (length - 1)) + lr.ValueRatios.Push(lr.Values.Last(0) / kline.GetClose().Float64()) + + logLinReg.Debugf("linear regression baseline slope: %f", lr.Last(0)) +} + +func (lr *LinReg) BindK(target KLineClosedEmitter, symbol string, interval types.Interval) { + target.OnKLineClosed(types.KLineWith(symbol, interval, lr.PushK)) +} + +func (lr *LinReg) PushK(k types.KLine) { + var zeroTime = time.Time{} + if lr.EndTime != zeroTime && k.EndTime.Before(lr.EndTime) { + return + } + + lr.Update(k) + lr.EndTime = k.EndTime.Time() +} + +func (lr *LinReg) LoadK(allKLines []types.KLine) { + for _, k := range allKLines { + lr.PushK(k) + } +} diff --git a/pkg/indicator/linreg_callbacks.go b/pkg/indicator/linreg_callbacks.go new file mode 100644 index 0000000..c719c8f --- /dev/null +++ b/pkg/indicator/linreg_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type LinReg"; DO NOT EDIT. + +package indicator + +import () + +func (lr *LinReg) OnUpdate(cb func(value float64)) { + lr.UpdateCallbacks = append(lr.UpdateCallbacks, cb) +} + +func (lr *LinReg) EmitUpdate(value float64) { + for _, cb := range lr.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/macd.go b/pkg/indicator/macd.go new file mode 100644 index 0000000..5038555 --- /dev/null +++ b/pkg/indicator/macd.go @@ -0,0 +1,115 @@ +package indicator + +import ( + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// macd implements moving average convergence divergence indicator +// +// Moving Average Convergence Divergence (MACD) +// - https://www.investopedia.com/terms/m/macd.asp +// - https://school.stockcharts.com/doku.php?id=technical_indicators:macd-histogram +// The Moving Average Convergence Divergence (MACD) is a technical analysis indicator that is used to measure the relationship between +// two moving averages of a security's price. It is calculated by subtracting the longer-term moving average from the shorter-term moving +// average, and then plotting the resulting value on the price chart as a line. This line is known as the MACD line, and is typically +// used to identify potential buy or sell signals. The MACD is typically used in conjunction with a signal line, which is a moving average +// of the MACD line, to generate more accurate buy and sell signals. + +type MACDConfig struct { + types.IntervalWindow // 9 + + // ShortPeriod is the short term period EMA, usually 12 + ShortPeriod int `json:"short"` + // LongPeriod is the long term period EMA, usually 26 + LongPeriod int `json:"long"` +} + +//go:generate callbackgen -type MACDLegacy +type MACDLegacy struct { + MACDConfig + + Values floats.Slice `json:"-"` + fastEWMA, slowEWMA, signalLine *EWMA + Histogram floats.Slice `json:"-"` + + updateCallbacks []func(macd, signal, histogram float64) + EndTime time.Time +} + +func (inc *MACDLegacy) Update(x float64) { + if len(inc.Values) == 0 { + // apply default values + inc.fastEWMA = &EWMA{IntervalWindow: types.IntervalWindow{Window: inc.ShortPeriod}} + inc.slowEWMA = &EWMA{IntervalWindow: types.IntervalWindow{Window: inc.LongPeriod}} + inc.signalLine = &EWMA{IntervalWindow: types.IntervalWindow{Window: inc.Window}} + if inc.ShortPeriod == 0 { + inc.ShortPeriod = 12 + } + + if inc.LongPeriod == 0 { + inc.LongPeriod = 26 + } + } + + // update fast and slow ema + inc.fastEWMA.Update(x) + inc.slowEWMA.Update(x) + + // update MACD value, it's also the signal line + fast := inc.fastEWMA.Last(0) + slow := inc.slowEWMA.Last(0) + macd := fast - slow + inc.Values.Push(macd) + + // update signal line + inc.signalLine.Update(macd) + signal := inc.signalLine.Last(0) + + // update histogram + histogram := macd - signal + inc.Histogram.Push(histogram) + + inc.EmitUpdate(macd, signal, histogram) +} + +func (inc *MACDLegacy) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *MACDLegacy) Length() int { + return len(inc.Values) +} + +func (inc *MACDLegacy) PushK(k types.KLine) { + inc.Update(k.Close.Float64()) +} + +func (inc *MACDLegacy) MACD() types.SeriesExtend { + out := &MACDValues{MACDLegacy: inc} + out.SeriesBase.Series = out + return out +} + +func (inc *MACDLegacy) Singals() types.SeriesExtend { + return inc.signalLine +} + +type MACDValues struct { + types.SeriesBase + *MACDLegacy +} + +func (inc *MACDValues) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *MACDValues) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *MACDValues) Length() int { + return len(inc.Values) +} diff --git a/pkg/indicator/macd_test.go b/pkg/indicator/macd_test.go new file mode 100644 index 0000000..d08c696 --- /dev/null +++ b/pkg/indicator/macd_test.go @@ -0,0 +1,55 @@ +package indicator + +import ( + "encoding/json" + "math" + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +/* +python: + +import pandas as pd +s = pd.Series([0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9]) +slow = s.ewm(span=26, adjust=False).mean() +fast = s.ewm(span=12, adjust=False).mean() +print(fast - slow) +*/ + +func Test_calculateMACD(t *testing.T) { + var randomPrices = []byte(`[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]`) + var input []fixedpoint.Value + if err := json.Unmarshal(randomPrices, &input); err != nil { + panic(err) + } + tests := []struct { + name string + kLines []types.KLine + want float64 + }{ + { + name: "random_case", + kLines: buildKLines(input), + want: 0.7967670223776384, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + iw := types.IntervalWindow{Window: 9} + macd := MACDLegacy{MACDConfig: MACDConfig{IntervalWindow: iw, ShortPeriod: 12, LongPeriod: 26}} + for _, k := range tt.kLines { + macd.PushK(k) + } + + got := macd.Last(0) + diff := math.Trunc((got-tt.want)*100) / 100 + if diff != 0 { + t.Errorf("calculateMACD() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/indicator/macdlegacy_callbacks.go b/pkg/indicator/macdlegacy_callbacks.go new file mode 100644 index 0000000..ed4d108 --- /dev/null +++ b/pkg/indicator/macdlegacy_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type MACDLegacy"; DO NOT EDIT. + +package indicator + +import () + +func (inc *MACDLegacy) OnUpdate(cb func(macd float64, signal float64, histogram float64)) { + inc.updateCallbacks = append(inc.updateCallbacks, cb) +} + +func (inc *MACDLegacy) EmitUpdate(macd float64, signal float64, histogram float64) { + for _, cb := range inc.updateCallbacks { + cb(macd, signal, histogram) + } +} diff --git a/pkg/indicator/obv.go b/pkg/indicator/obv.go new file mode 100644 index 0000000..5324082 --- /dev/null +++ b/pkg/indicator/obv.go @@ -0,0 +1,86 @@ +package indicator + +import ( + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +/* +obv implements on-balance volume indicator + +On-Balance Volume (OBV) Definition +- https://www.investopedia.com/terms/o/onbalancevolume.asp + +On-Balance Volume (OBV) is a technical analysis indicator that uses volume information to predict changes in stock price. +The idea behind OBV is that volume precedes price: when the OBV is rising, it means that buyers are becoming more aggressive and +that the stock price is likely to follow suit. When the OBV is falling, it indicates that sellers are becoming more aggressive and +that the stock price is likely to decrease. OBV is calculated by adding the volume on days when the stock price closes higher and +subtracting the volume on days when the stock price closes lower. This running total forms the OBV line, which can then be used +to make predictions about future stock price movements. +*/ +//go:generate callbackgen -type OBV +type OBV struct { + types.SeriesBase + types.IntervalWindow + Values floats.Slice + PrePrice float64 + EndTime time.Time + + updateCallbacks []func(value float64) +} + +func (inc *OBV) Update(price, volume float64) { + if len(inc.Values) == 0 { + inc.SeriesBase.Series = inc + inc.PrePrice = price + inc.Values.Push(volume) + return + } + + if volume < inc.PrePrice { + inc.Values.Push(inc.Last(0) - volume) + } else { + inc.Values.Push(inc.Last(0) + volume) + } +} + +func (inc *OBV) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *OBV) Index(i int) float64 { + return inc.Last(i) +} + +var _ types.SeriesExtend = &OBV{} + +func (inc *OBV) PushK(k types.KLine) { + inc.Update(k.Close.Float64(), k.Volume.Float64()) +} + +func (inc *OBV) CalculateAndUpdate(kLines []types.KLine) { + for _, k := range kLines { + if inc.EndTime != zeroTime && !k.EndTime.After(inc.EndTime) { + continue + } + + inc.PushK(k) + } + + inc.EmitUpdate(inc.Last(0)) + inc.EndTime = kLines[len(kLines)-1].EndTime.Time() +} + +func (inc *OBV) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *OBV) Bind(updater KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} diff --git a/pkg/indicator/obv_callbacks.go b/pkg/indicator/obv_callbacks.go new file mode 100644 index 0000000..2b1ce69 --- /dev/null +++ b/pkg/indicator/obv_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type OBV"; DO NOT EDIT. + +package indicator + +import () + +func (inc *OBV) OnUpdate(cb func(value float64)) { + inc.updateCallbacks = append(inc.updateCallbacks, cb) +} + +func (inc *OBV) EmitUpdate(value float64) { + for _, cb := range inc.updateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/obv_test.go b/pkg/indicator/obv_test.go new file mode 100644 index 0000000..751c2b6 --- /dev/null +++ b/pkg/indicator/obv_test.go @@ -0,0 +1,62 @@ +package indicator + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const Delta = 1e-9 + +func Test_calculateOBV(t *testing.T) { + buildKLines := func(prices, volumes []fixedpoint.Value) (kLines []types.KLine) { + for i, p := range prices { + kLines = append(kLines, types.KLine{High: p, Low: p, Close: p, Volume: volumes[i]}) + } + return kLines + } + var easy1 = []byte(`[3, 2, 1, 4]`) + var easy2 = []byte(`[3, 2, 2, 6]`) + var input1 []fixedpoint.Value + var input2 []fixedpoint.Value + _ = json.Unmarshal(easy1, &input1) + _ = json.Unmarshal(easy2, &input2) + + tests := []struct { + name string + kLines []types.KLine + window int + want floats.Slice + }{ + { + name: "trivial_case", + kLines: buildKLines( + []fixedpoint.Value{fixedpoint.Zero}, []fixedpoint.Value{fixedpoint.One}, + ), + window: 0, + want: floats.Slice{1.0}, + }, + { + name: "easy_case", + kLines: buildKLines(input1, input2), + window: 0, + want: floats.Slice{3, 1, -1, 5}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obv := OBV{IntervalWindow: types.IntervalWindow{Window: tt.window}} + obv.CalculateAndUpdate(tt.kLines) + assert.Equal(t, len(obv.Values), len(tt.want)) + for i, v := range obv.Values { + assert.InDelta(t, v, tt.want[i], Delta) + } + }) + } +} diff --git a/pkg/indicator/pivot.go b/pkg/indicator/pivot.go new file mode 100644 index 0000000..fc1f23d --- /dev/null +++ b/pkg/indicator/pivot.go @@ -0,0 +1,117 @@ +package indicator + +import ( + "fmt" + "time" + + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +//go:generate callbackgen -type Pivot +type Pivot struct { + types.IntervalWindow + + // Values + Lows floats.Slice // higher low + Highs floats.Slice // lower high + + EndTime time.Time + + updateCallbacks []func(valueLow, valueHigh float64) +} + +func (inc *Pivot) LastLow() float64 { + if len(inc.Lows) == 0 { + return 0.0 + } + return inc.Lows[len(inc.Lows)-1] +} + +func (inc *Pivot) LastHigh() float64 { + if len(inc.Highs) == 0 { + return 0.0 + } + return inc.Highs[len(inc.Highs)-1] +} + +func (inc *Pivot) CalculateAndUpdate(klines []types.KLine) { + if len(klines) < inc.Window { + return + } + + var end = len(klines) - 1 + var lastKLine = klines[end] + + // skip old data + if inc.EndTime != zeroTime && lastKLine.GetEndTime().Before(inc.EndTime) { + return + } + + recentT := klines[end-(inc.Window-1) : end+1] + + l, h, err := calculatePivot(recentT, inc.Window, types.KLineLowPriceMapper, types.KLineHighPriceMapper) + if err != nil { + log.WithError(err).Error("can not calculate pivots") + return + } + + if l > 0.0 { + inc.Lows.Push(l) + } + if h > 0.0 { + inc.Highs.Push(h) + } + + if len(inc.Lows) > MaxNumOfVOL { + inc.Lows = inc.Lows[MaxNumOfVOLTruncateSize-1:] + } + if len(inc.Highs) > MaxNumOfVOL { + inc.Highs = inc.Highs[MaxNumOfVOLTruncateSize-1:] + } + + inc.EndTime = klines[end].GetEndTime().Time() + + inc.EmitUpdate(l, h) + +} + +func (inc *Pivot) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *Pivot) Bind(updater KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} + +func calculatePivot(klines []types.KLine, window int, valLow types.KLineValueMapper, valHigh types.KLineValueMapper) (float64, float64, error) { + length := len(klines) + if length == 0 || length < window { + return 0., 0., fmt.Errorf("insufficient elements for calculating with window = %d", window) + } + + var lows floats.Slice + var highs floats.Slice + for _, k := range klines { + lows.Push(valLow(k)) + highs.Push(valHigh(k)) + } + + pl := 0. + if lows.Min() == lows.Last(int(window/2.)-1) { + pl = lows.Min() + } + + ph := 0. + if highs.Max() == highs.Last(int(window/2.)-1) { + ph = highs.Max() + } + + return pl, ph, nil +} diff --git a/pkg/indicator/pivot_callbacks.go b/pkg/indicator/pivot_callbacks.go new file mode 100644 index 0000000..4c3a90c --- /dev/null +++ b/pkg/indicator/pivot_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type Pivot"; DO NOT EDIT. + +package indicator + +import () + +func (inc *Pivot) OnUpdate(cb func(valueLow float64, valueHigh float64)) { + inc.updateCallbacks = append(inc.updateCallbacks, cb) +} + +func (inc *Pivot) EmitUpdate(valueLow float64, valueHigh float64) { + for _, cb := range inc.updateCallbacks { + cb(valueLow, valueHigh) + } +} diff --git a/pkg/indicator/pivothigh.go b/pkg/indicator/pivothigh.go new file mode 100644 index 0000000..ebcb57e --- /dev/null +++ b/pkg/indicator/pivothigh.go @@ -0,0 +1,64 @@ +package indicator + +import ( + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +//go:generate callbackgen -type PivotHigh +type PivotHigh struct { + types.SeriesBase + + types.IntervalWindow + + Highs floats.Slice + Values floats.Slice + EndTime time.Time + + updateCallbacks []func(value float64) +} + +func (inc *PivotHigh) Length() int { + return inc.Values.Length() +} + +func (inc *PivotHigh) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *PivotHigh) Update(value float64) { + if len(inc.Highs) == 0 { + inc.SeriesBase.Series = inc + } + + inc.Highs.Push(value) + + if len(inc.Highs) < inc.Window { + return + } + + if inc.RightWindow == nil { + inc.RightWindow = &inc.Window + } + + high, ok := calculatePivotHigh(inc.Highs, inc.Window, *inc.RightWindow) + if !ok { + return + } + + if high > 0.0 { + inc.Values.Push(high) + } +} + +func (inc *PivotHigh) PushK(k types.KLine) { + if k.EndTime.Before(inc.EndTime) { + return + } + + inc.Update(k.High.Float64()) + inc.EndTime = k.EndTime.Time() + inc.EmitUpdate(inc.Last(0)) +} diff --git a/pkg/indicator/pivothigh_callbacks.go b/pkg/indicator/pivothigh_callbacks.go new file mode 100644 index 0000000..64891ad --- /dev/null +++ b/pkg/indicator/pivothigh_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type PivotHigh"; DO NOT EDIT. + +package indicator + +import () + +func (inc *PivotHigh) OnUpdate(cb func(value float64)) { + inc.updateCallbacks = append(inc.updateCallbacks, cb) +} + +func (inc *PivotHigh) EmitUpdate(value float64) { + for _, cb := range inc.updateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/pivotlow.go b/pkg/indicator/pivotlow.go new file mode 100644 index 0000000..0cc8124 --- /dev/null +++ b/pkg/indicator/pivotlow.go @@ -0,0 +1,76 @@ +package indicator + +import ( + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +//go:generate callbackgen -type PivotLow +type PivotLow struct { + types.SeriesBase + + types.IntervalWindow + + Lows floats.Slice + Values floats.Slice + EndTime time.Time + + updateCallbacks []func(value float64) +} + +func (inc *PivotLow) Length() int { + return inc.Values.Length() +} + +func (inc *PivotLow) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *PivotLow) Update(value float64) { + if len(inc.Lows) == 0 { + inc.SeriesBase.Series = inc + } + + inc.Lows.Push(value) + + if len(inc.Lows) < inc.Window { + return + } + + if inc.RightWindow == nil { + inc.RightWindow = &inc.Window + } + + low, ok := calculatePivotLow(inc.Lows, inc.Window, *inc.RightWindow) + if !ok { + return + } + + if low > 0.0 { + inc.Values.Push(low) + } +} + +func (inc *PivotLow) PushK(k types.KLine) { + if k.EndTime.Before(inc.EndTime) { + return + } + + inc.Update(k.Low.Float64()) + inc.EndTime = k.EndTime.Time() + inc.EmitUpdate(inc.Last(0)) +} + +func calculatePivotHigh(highs floats.Slice, left, right int) (float64, bool) { + return floats.FindPivot(highs, left, right, func(a, pivot float64) bool { + return a < pivot + }) +} + +func calculatePivotLow(lows floats.Slice, left, right int) (float64, bool) { + return floats.FindPivot(lows, left, right, func(a, pivot float64) bool { + return a > pivot + }) +} diff --git a/pkg/indicator/pivotlow_callbacks.go b/pkg/indicator/pivotlow_callbacks.go new file mode 100644 index 0000000..5ea139c --- /dev/null +++ b/pkg/indicator/pivotlow_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type PivotLow"; DO NOT EDIT. + +package indicator + +import () + +func (inc *PivotLow) OnUpdate(cb func(value float64)) { + inc.updateCallbacks = append(inc.updateCallbacks, cb) +} + +func (inc *PivotLow) EmitUpdate(value float64) { + for _, cb := range inc.updateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/pivotlow_test.go b/pkg/indicator/pivotlow_test.go new file mode 100644 index 0000000..2425f53 --- /dev/null +++ b/pkg/indicator/pivotlow_test.go @@ -0,0 +1,51 @@ +package indicator + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_calculatePivotLow(t *testing.T) { + t.Run("normal", func(t *testing.T) { + low, ok := calculatePivotLow([]float64{15.0, 13.0, 12.0, 10.0, 14.0, 15.0}, 2, 2) + // ^left ----- ^pivot ---- ^right + assert.True(t, ok) + assert.Equal(t, 10.0, low) + + low, ok = calculatePivotLow([]float64{15.0, 13.0, 12.0, 10.0, 14.0, 9.0}, 2, 2) + // ^left ----- ^pivot ---- ^right + assert.False(t, ok) + + low, ok = calculatePivotLow([]float64{15.0, 9.0, 12.0, 10.0, 14.0, 15.0}, 2, 2) + // ^left ----- ^pivot ---- ^right + assert.False(t, ok) + }) + + t.Run("different left and right", func(t *testing.T) { + low, ok := calculatePivotLow([]float64{11.0, 12.0, 16.0, 15.0, 13.0, 12.0, 10.0, 14.0, 15.0}, 5, 2) + // ^left ---------------------- ^pivot ---- ^right + + assert.True(t, ok) + assert.Equal(t, 10.0, low) + + low, ok = calculatePivotLow([]float64{9.0, 8.0, 16.0, 15.0, 13.0, 12.0, 10.0, 14.0, 15.0}, 5, 2) + // ^left ---------------------- ^pivot ---- ^right + // 8.0 < 10.0 + assert.False(t, ok) + assert.Equal(t, 0.0, low) + }) + + t.Run("right window same", func(t *testing.T) { + low, ok := calculatePivotLow([]float64{15.0, 13.0, 12.0, 10.0, 14.0, 15.0}, 2, 2) + assert.True(t, ok) + assert.Equal(t, 10.0, low) + }) + + t.Run("insufficient length", func(t *testing.T) { + low, ok := calculatePivotLow([]float64{15.0, 13.0, 12.0, 10.0, 14.0, 15.0}, 3, 3) + assert.False(t, ok) + assert.Equal(t, 0.0, low) + }) + +} diff --git a/pkg/indicator/psar.go b/pkg/indicator/psar.go new file mode 100644 index 0000000..2d3a7c4 --- /dev/null +++ b/pkg/indicator/psar.go @@ -0,0 +1,121 @@ +package indicator + +import ( + "math" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// Parabolic SAR(Stop and Reverse) / SAR +// Refer: https://www.investopedia.com/terms/p/parabolicindicator.asp +// The parabolic SAR indicator, developed by J. Wells Wilder, is used by traders to determine +// trend direction and potential reversals in price. The indicator uses a trailing stop and +// reverse method called "SAR," or stop and reverse, to identify suitable exit and entry points. +// Traders also refer to the indicator as to the parabolic stop and reverse, parabolic SAR, or PSAR. +// +// The parabolic SAR indicator appears on a chart as a series of dots, either above or below an asset's +// price, depending on the direction the price is moving. A dot is placed below the price when it is +// trending upward, and above the price when it is trending downward. + +//go:generate callbackgen -type PSAR +type PSAR struct { + types.SeriesBase + types.IntervalWindow + High *types.Queue + Low *types.Queue + Values floats.Slice // Stop and Reverse + AF float64 // Acceleration Factor + EP float64 + Falling bool + + EndTime time.Time + UpdateCallbacks []func(value float64) +} + +func (inc *PSAR) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *PSAR) Length() int { + return len(inc.Values) +} + +func (inc *PSAR) falling() bool { + up := inc.High.Last(0) - inc.High.Index(1) + dn := inc.Low.Index(1) - inc.Low.Last(0) + return (dn > up) && (dn > 0) +} + +func (inc *PSAR) Update(high, low float64) { + if inc.High == nil { + inc.SeriesBase.Series = inc + inc.High = types.NewQueue(inc.Window) + inc.Low = types.NewQueue(inc.Window) + inc.Values = floats.Slice{} + inc.AF = 0.02 + inc.High.Update(high) + inc.Low.Update(low) + return + } + isFirst := inc.High.Length() < inc.Window + inc.High.Update(high) + inc.Low.Update(low) + if !isFirst { + ppsar := inc.Values.Last(0) + if inc.Falling { // falling formula + psar := ppsar - inc.AF*(ppsar-inc.EP) + h := inc.High.Shift(1).Highest(2) + inc.Values.Push(math.Max(psar, h)) + if low < inc.EP { + inc.EP = low + if inc.AF <= 0.18 { + inc.AF += 0.02 + } + } + if high > psar { // reverse + inc.AF = 0.02 + inc.Values[len(inc.Values)-1] = inc.EP + inc.EP = high + inc.Falling = false + } + } else { // rising formula + psar := ppsar + inc.AF*(inc.EP-ppsar) + l := inc.Low.Shift(1).Lowest(2) + inc.Values.Push(math.Min(psar, l)) + if high > inc.EP { + inc.EP = high + if inc.AF <= 0.18 { + inc.AF += 0.02 + } + } + if low < psar { // reverse + inc.AF = 0.02 + inc.Values[len(inc.Values)-1] = inc.EP + inc.EP = low + inc.Falling = true + } + + } + } else { + inc.Falling = inc.falling() + if inc.Falling { + inc.Values.Push(inc.High.Index(1)) + inc.EP = inc.Low.Index(1) + } else { + inc.Values.Push(inc.Low.Index(1)) + inc.EP = inc.High.Index(1) + } + } +} + +var _ types.SeriesExtend = &PSAR{} + +func (inc *PSAR) PushK(k types.KLine) { + inc.Update(k.High.Float64(), k.Low.Float64()) +} + +func (inc *PSAR) BindK(target KLineClosedEmitter, symbol string, interval types.Interval) { + target.OnKLineClosed(types.KLineWith(symbol, interval, inc.PushK)) +} diff --git a/pkg/indicator/psar_callbacks.go b/pkg/indicator/psar_callbacks.go new file mode 100644 index 0000000..37d4837 --- /dev/null +++ b/pkg/indicator/psar_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type PSAR"; DO NOT EDIT. + +package indicator + +import () + +func (inc *PSAR) OnUpdate(cb func(value float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *PSAR) EmitUpdate(value float64) { + for _, cb := range inc.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/psar_test.go b/pkg/indicator/psar_test.go new file mode 100644 index 0000000..01fee69 --- /dev/null +++ b/pkg/indicator/psar_test.go @@ -0,0 +1,40 @@ +package indicator + +import ( + "encoding/json" + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +/* +import pandas as pd +import pandas_ta as ta + +data = pd.Series([0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9]) +out = ta.psar(data, data) +print(out) +*/ + +func Test_PSAR(t *testing.T) { + var randomPrices = []byte(`[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]`) + var input []fixedpoint.Value + if err := json.Unmarshal(randomPrices, &input); err != nil { + panic(err) + } + psar := PSAR{ + IntervalWindow: types.IntervalWindow{Window: 2}, + } + for _, v := range input { + kline := types.KLine{ + High: v, + Low: v, + } + psar.PushK(kline) + } + assert.Equal(t, psar.Length(), 29) + assert.Equal(t, psar.AF, 0.04) + assert.Equal(t, psar.Last(0), 0.16) +} diff --git a/pkg/indicator/rma.go b/pkg/indicator/rma.go new file mode 100644 index 0000000..8ee22c2 --- /dev/null +++ b/pkg/indicator/rma.go @@ -0,0 +1,123 @@ +package indicator + +import ( + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const MaxNumOfRMA = 1000 +const MaxNumOfRMATruncateSize = 500 + +// Running Moving Average +// Refer: https://github.com/twopirllc/pandas-ta/blob/main/pandas_ta/overlap/rma.py#L5 +// Refer: https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.ewm.html#pandas-dataframe-ewm +// +// The Running Moving Average (RMA) is a technical analysis indicator that is used to smooth price data and reduce the lag associated +// with traditional moving averages. It is calculated by taking the weighted moving average of the input data, with the weighting factors +// determined by the length of the moving average. The resulting average is then plotted on the price chart as a line, which can be used to +// make predictions about future price movements. The RMA is typically more responsive to changes in the underlying data than a simple +// moving average, but may be less reliable in trending markets. It is often used in conjunction with other technical analysis indicators +// to confirm signals or provide additional information about the security's price. + +//go:generate callbackgen -type RMA +type RMA struct { + types.SeriesBase + types.IntervalWindow + + Values floats.Slice + EndTime time.Time + + counter int + Adjust bool + tmp float64 + sum float64 + + updateCallbacks []func(value float64) +} + +func (inc *RMA) Clone() types.UpdatableSeriesExtend { + out := &RMA{ + IntervalWindow: inc.IntervalWindow, + Values: inc.Values[:], + counter: inc.counter, + Adjust: inc.Adjust, + tmp: inc.tmp, + sum: inc.sum, + EndTime: inc.EndTime, + } + out.SeriesBase.Series = out + return out +} + +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 { + if inc.Adjust { + inc.sum = inc.sum*(1-lambda) + 1 + inc.tmp = inc.tmp + (x-inc.tmp)/inc.sum + } else { + inc.tmp = inc.tmp*(1-lambda) + x*lambda + } + } + inc.counter++ + + inc.Values.Push(inc.tmp) + if len(inc.Values) > MaxNumOfRMA { + inc.Values = inc.Values[MaxNumOfRMATruncateSize-1:] + } +} + +func (inc *RMA) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *RMA) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *RMA) Length() int { + return len(inc.Values) +} + +var _ types.SeriesExtend = &RMA{} + +func (inc *RMA) PushK(k types.KLine) { + inc.Update(k.Close.Float64()) + inc.EndTime = k.EndTime.Time() +} + +func (inc *RMA) CalculateAndUpdate(kLines []types.KLine) { + last := kLines[len(kLines)-1] + + if len(inc.Values) == 0 { + for _, k := range kLines { + if inc.EndTime != zeroTime && !k.EndTime.After(inc.EndTime) { + continue + } + + inc.PushK(k) + } + } else { + inc.PushK(last) + } + + inc.EmitUpdate(inc.Last(0)) +} + +func (inc *RMA) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *RMA) Bind(updater KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} diff --git a/pkg/indicator/rma_callbacks.go b/pkg/indicator/rma_callbacks.go new file mode 100644 index 0000000..e08b306 --- /dev/null +++ b/pkg/indicator/rma_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type RMA"; DO NOT EDIT. + +package indicator + +import () + +func (inc *RMA) OnUpdate(cb func(value float64)) { + inc.updateCallbacks = append(inc.updateCallbacks, cb) +} + +func (inc *RMA) EmitUpdate(value float64) { + for _, cb := range inc.updateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/rma_test.go b/pkg/indicator/rma_test.go new file mode 100644 index 0000000..c349d31 --- /dev/null +++ b/pkg/indicator/rma_test.go @@ -0,0 +1,72 @@ +package indicator + +import ( + "encoding/json" + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +/* +python + +import pandas as pd +import pandas_ta as ta + +data = [40105.78, 39935.23, 40183.97, 40182.03, 40212.26, 40149.99, 40378.0, 40618.37, 40401.03, 39990.39, 40179.13, 40097.23, 40014.72, 39667.85, 39303.1, 39519.99, 39693.79, 39827.96, 40074.94, 40059.84] + +close = pd.Series(data) +result = ta.rma(close, length=14) +print(result) +*/ +func Test_RMA(t *testing.T) { + var bytes = []byte(`[40105.78, 39935.23, 40183.97, 40182.03, 40212.26, 40149.99, 40378.0, 40618.37, 40401.03, 39990.39, 40179.13, 40097.23, 40014.72, 39667.85, 39303.1, 39519.99, 39693.79, 39827.96, 40074.94, 40059.84]`) + var values []fixedpoint.Value + err := json.Unmarshal(bytes, &values) + assert.NoError(t, err) + + var kLines []types.KLine + for _, p := range values { + kLines = append(kLines, types.KLine{High: p, Low: p, Close: p}) + } + + tests := []struct { + name string + window int + want []float64 + }{ + { + name: "test_binance_btcusdt_1h", + window: 14, + want: []float64{ + 40129.841000, + 40041.830291, + 39988.157743, + 39958.803719, + 39946.115094, + 39958.296741, + 39967.681562, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rma := RMA{ + IntervalWindow: types.IntervalWindow{Window: tt.window}, + Adjust: true, + } + rma.CalculateAndUpdate(kLines) + + if assert.Equal(t, len(tt.want), len(rma.Values)-tt.window+1) { + for i, v := range tt.want { + j := tt.window - 1 + i + got := rma.Values[j] + assert.InDelta(t, v, got, 0.01, "Expected rma.slice[%d] to be %v, but got %v", j, v, got) + } + } + }) + } +} diff --git a/pkg/indicator/rsi.go b/pkg/indicator/rsi.go new file mode 100644 index 0000000..e9f9b45 --- /dev/null +++ b/pkg/indicator/rsi.go @@ -0,0 +1,109 @@ +package indicator + +import ( + "math" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// rsi implements Relative Strength Index (RSI) +// https://www.investopedia.com/terms/r/rsi.asp +// +// The Relative Strength Index (RSI) is a technical analysis indicator that is used to measure the strength of a security's price. It is +// calculated by taking the average of the gains and losses of the security over a specified period of time, and then dividing the average gain +// by the average loss. This resulting value is then plotted as a line on the price chart, with values above 70 indicating overbought conditions +// and values below 30 indicating oversold conditions. The RSI can be used by traders to identify potential entry and exit points for trades, +// or to confirm other technical analysis signals. It is typically used in conjunction with other indicators to provide a more comprehensive +// view of the security's price. + +//go:generate callbackgen -type RSI +type RSI struct { + types.SeriesBase + types.IntervalWindow + Values floats.Slice + Prices floats.Slice + PreviousAvgLoss float64 + PreviousAvgGain float64 + + EndTime time.Time + updateCallbacks []func(value float64) +} + +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 { + return + } + + var avgGain float64 + var avgLoss float64 + if len(inc.Prices) == inc.Window+1 { + priceDifferences := inc.Prices.Diff() + + avgGain = priceDifferences.PositiveValuesOrZero().Abs().Sum() / float64(inc.Window) + avgLoss = priceDifferences.NegativeValuesOrZero().Abs().Sum() / float64(inc.Window) + } else { + difference := price - inc.Prices[len(inc.Prices)-2] + currentGain := math.Max(difference, 0) + currentLoss := -math.Min(difference, 0) + + avgGain = (inc.PreviousAvgGain*13 + currentGain) / float64(inc.Window) + avgLoss = (inc.PreviousAvgLoss*13 + currentLoss) / float64(inc.Window) + } + + rs := avgGain / avgLoss + rsi := 100 - (100 / (1 + rs)) + inc.Values.Push(rsi) + + inc.PreviousAvgGain = avgGain + inc.PreviousAvgLoss = avgLoss +} + +func (inc *RSI) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *RSI) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *RSI) Length() int { + return len(inc.Values) +} + +var _ types.SeriesExtend = &RSI{} + +func (inc *RSI) PushK(k types.KLine) { + inc.Update(k.Close.Float64()) +} + +func (inc *RSI) CalculateAndUpdate(kLines []types.KLine) { + for _, k := range kLines { + if inc.EndTime != zeroTime && !k.EndTime.After(inc.EndTime) { + continue + } + + inc.PushK(k) + } + + inc.EmitUpdate(inc.Last(0)) + inc.EndTime = kLines[len(kLines)-1].EndTime.Time() +} + +func (inc *RSI) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *RSI) Bind(updater KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} diff --git a/pkg/indicator/rsi_callbacks.go b/pkg/indicator/rsi_callbacks.go new file mode 100644 index 0000000..ea79593 --- /dev/null +++ b/pkg/indicator/rsi_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type RSI"; DO NOT EDIT. + +package indicator + +import () + +func (inc *RSI) OnUpdate(cb func(value float64)) { + inc.updateCallbacks = append(inc.updateCallbacks, cb) +} + +func (inc *RSI) EmitUpdate(value float64) { + for _, cb := range inc.updateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/rsi_test.go b/pkg/indicator/rsi_test.go new file mode 100644 index 0000000..bcdd65b --- /dev/null +++ b/pkg/indicator/rsi_test.go @@ -0,0 +1,70 @@ +package indicator + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func Test_calculateRSI(t *testing.T) { + // test case from https://school.stockcharts.com/doku.php?id=technical_indicators:relative_strength_index_rsi + buildKLines := func(prices []fixedpoint.Value) (kLines []types.KLine) { + for _, p := range prices { + kLines = append(kLines, types.KLine{High: p, Low: p, Close: p}) + } + return kLines + } + var data = []byte(`[44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08, 45.89, 46.03, 45.61, 46.28, 46.28, 46.00, 46.03, 46.41, 46.22, 45.64, 46.21, 46.25, 45.71, 46.45, 45.78, 45.35, 44.03, 44.18, 44.22, 44.57, 43.42, 42.66, 43.13]`) + var values []fixedpoint.Value + _ = json.Unmarshal(data, &values) + + tests := []struct { + name string + kLines []types.KLine + window int + want floats.Slice + }{ + { + name: "RSI", + kLines: buildKLines(values), + window: 14, + want: floats.Slice{ + 70.46413502109704, + 66.24961855355505, + 66.48094183471265, + 69.34685316290864, + 66.29471265892624, + 57.91502067008556, + 62.88071830996241, + 63.208788718287764, + 56.01158478954758, + 62.33992931089789, + 54.67097137765515, + 50.386815195114224, + 40.01942379131357, + 41.49263540422282, + 41.902429678458105, + 45.499497238680405, + 37.32277831337995, + 33.090482572723396, + 37.78877198205783, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rsi := RSI{IntervalWindow: types.IntervalWindow{Window: tt.window}} + rsi.CalculateAndUpdate(tt.kLines) + assert.Equal(t, len(rsi.Values), len(tt.want)) + for i, v := range rsi.Values { + assert.InDelta(t, v, tt.want[i], Delta) + } + }) + } +} diff --git a/pkg/indicator/sma.go b/pkg/indicator/sma.go new file mode 100644 index 0000000..55d4c40 --- /dev/null +++ b/pkg/indicator/sma.go @@ -0,0 +1,83 @@ +package indicator + +import ( + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const MaxNumOfSMA = 5_000 +const MaxNumOfSMATruncateSize = 100 + +//go:generate callbackgen -type SMA +type SMA struct { + types.SeriesBase + types.IntervalWindow + Values floats.Slice + rawValues *types.Queue + EndTime time.Time + + UpdateCallbacks []func(value float64) +} + +func (inc *SMA) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *SMA) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *SMA) Length() int { + return inc.Values.Length() +} + +func (inc *SMA) Clone() types.UpdatableSeriesExtend { + out := &SMA{ + Values: inc.Values[:], + rawValues: inc.rawValues.Clone(), + EndTime: inc.EndTime, + } + out.SeriesBase.Series = out + return out +} + +var _ types.SeriesExtend = &SMA{} + +func (inc *SMA) Update(value float64) { + if inc.rawValues == nil { + inc.rawValues = types.NewQueue(inc.Window) + inc.SeriesBase.Series = inc + } + + inc.rawValues.Update(value) + if inc.rawValues.Length() < inc.Window { + return + } + + inc.Values.Push(types.Mean(inc.rawValues)) + if len(inc.Values) > MaxNumOfSMA { + inc.Values = inc.Values[MaxNumOfSMATruncateSize-1:] + } +} + +func (inc *SMA) BindK(target KLineClosedEmitter, symbol string, interval types.Interval) { + target.OnKLineClosed(types.KLineWith(symbol, interval, inc.PushK)) +} + +func (inc *SMA) PushK(k types.KLine) { + if inc.EndTime != zeroTime && k.EndTime.Before(inc.EndTime) { + return + } + + inc.Update(k.Close.Float64()) + inc.EndTime = k.EndTime.Time() + inc.EmitUpdate(inc.Values.Last(0)) +} + +func (inc *SMA) LoadK(allKLines []types.KLine) { + for _, k := range allKLines { + inc.PushK(k) + } +} diff --git a/pkg/indicator/sma_callbacks.go b/pkg/indicator/sma_callbacks.go new file mode 100644 index 0000000..cdf26e6 --- /dev/null +++ b/pkg/indicator/sma_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type SMA"; DO NOT EDIT. + +package indicator + +import () + +func (inc *SMA) OnUpdate(cb func(value float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *SMA) EmitUpdate(value float64) { + for _, cb := range inc.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/sma_test.go b/pkg/indicator/sma_test.go new file mode 100644 index 0000000..a43ffe9 --- /dev/null +++ b/pkg/indicator/sma_test.go @@ -0,0 +1,68 @@ +package indicator + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +/* +python: + +import pandas as pd +import pandas_ta as ta + +data = pd.Series([0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0]) +size = 5 + +result = ta.sma(data, size) +print(result) +*/ +func Test_SMA(t *testing.T) { + Delta := 0.001 + var randomPrices = []byte(`[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]`) + var input []fixedpoint.Value + if err := json.Unmarshal(randomPrices, &input); err != nil { + panic(err) + } + tests := []struct { + name string + kLines []types.KLine + want float64 + next float64 + update float64 + updateResult float64 + all int + }{ + { + name: "test", + kLines: buildKLines(input), + want: 7.0, + next: 6.0, + update: 0, + updateResult: 6.0, + all: 27, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sma := SMA{ + IntervalWindow: types.IntervalWindow{Window: 5}, + } + + for _, k := range tt.kLines { + sma.PushK(k) + } + + assert.InDelta(t, tt.want, sma.Last(0), Delta) + assert.InDelta(t, tt.next, sma.Index(1), Delta) + sma.Update(tt.update) + assert.InDelta(t, tt.updateResult, sma.Last(0), Delta) + assert.Equal(t, tt.all, sma.Length()) + }) + } +} diff --git a/pkg/indicator/ssf.go b/pkg/indicator/ssf.go new file mode 100644 index 0000000..d467820 --- /dev/null +++ b/pkg/indicator/ssf.go @@ -0,0 +1,103 @@ +package indicator + +import ( + "math" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// Refer: https://easylanguagemastery.com/indicators/predictive-indicators/ +// Refer: https://github.com/twopirllc/pandas-ta/blob/main/pandas_ta/overlap/ssf.py +// Ehler's Super Smoother Filter +// +// John F. Ehlers's solution to reduce lag and remove aliasing noise with his +// research in aerospace analog filter design. This indicator comes with two +// versions determined by the keyword poles. By default, it uses two poles but +// there is an option for three poles. Since SSF is a (Resursive) Digital Filter, +// the number of poles determine how many prior recursive SSF bars to include in +// the design of the filter. So two poles uses two prior SSF bars and three poles +// uses three prior SSF bars for their filter calculations. +// +//go:generate callbackgen -type SSF +type SSF struct { + types.SeriesBase + types.IntervalWindow + Poles int + c1 float64 + c2 float64 + c3 float64 + c4 float64 + Values floats.Slice + + UpdateCallbacks []func(value float64) +} + +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) + c0 := a0 * a0 + + inc.c4 = c0 * c0 + inc.c3 = -c0 * (1. + b0) + inc.c2 = c0 + b0 + inc.c1 = 1. - inc.c2 - inc.c3 - inc.c4 + inc.Values = floats.Slice{} + } + + result := inc.c1*value + + inc.c2*inc.Values.Last(0) + + inc.c3*inc.Values.Last(1) + + inc.c4*inc.Values.Last(2) + 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 + inc.c2 = 2. * a0 * math.Cos(x) + inc.c1 = 1. - inc.c2 - inc.c3 + inc.Values = floats.Slice{} + } + result := inc.c1*value + + inc.c2*inc.Values.Last(0) + + inc.c3*inc.Values.Last(1) + inc.Values.Push(result) + } +} + +func (inc *SSF) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *SSF) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *SSF) Length() int { + return inc.Values.Length() +} + +var _ types.SeriesExtend = &SSF{} + +func (inc *SSF) PushK(k types.KLine) { + inc.Update(k.Close.Float64()) +} + +func (inc *SSF) CalculateAndUpdate(allKLines []types.KLine) { + if inc.Values != nil { + k := allKLines[len(allKLines)-1] + inc.PushK(k) + inc.EmitUpdate(inc.Last(0)) + return + } + for _, k := range allKLines { + inc.PushK(k) + inc.EmitUpdate(inc.Last(0)) + } +} diff --git a/pkg/indicator/ssf_callbacks.go b/pkg/indicator/ssf_callbacks.go new file mode 100644 index 0000000..cdd2e8a --- /dev/null +++ b/pkg/indicator/ssf_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type SSF"; DO NOT EDIT. + +package indicator + +import () + +func (inc *SSF) OnUpdate(cb func(value float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *SSF) EmitUpdate(value float64) { + for _, cb := range inc.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/ssf_test.go b/pkg/indicator/ssf_test.go new file mode 100644 index 0000000..afe995d --- /dev/null +++ b/pkg/indicator/ssf_test.go @@ -0,0 +1,71 @@ +package indicator + +import ( + "encoding/json" + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +/* +python: + +import pandas as pd +import pandas_ta as ta + +data = pd.Series([0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9]) +size = 5 + +result = ta.ssf(data, size, 2) +print(result) + +result = ta.ssf(data, size, 3) +print(result) +*/ +func Test_SSF(t *testing.T) { + var Delta = 0.00001 + var randomPrices = []byte(`[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]`) + var input []fixedpoint.Value + if err := json.Unmarshal(randomPrices, &input); err != nil { + panic(err) + } + tests := []struct { + name string + kLines []types.KLine + poles int + want float64 + next float64 + all int + }{ + { + name: "pole2", + kLines: buildKLines(input), + poles: 2, + want: 8.721776, + next: 7.723223, + all: 30, + }, + { + name: "pole3", + kLines: buildKLines(input), + poles: 3, + want: 8.687588, + next: 7.668013, + all: 30, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ssf := SSF{ + IntervalWindow: types.IntervalWindow{Window: 5}, + Poles: tt.poles, + } + ssf.CalculateAndUpdate(tt.kLines) + assert.InDelta(t, tt.want, ssf.Last(0), Delta) + assert.InDelta(t, tt.next, ssf.Index(1), Delta) + assert.Equal(t, tt.all, ssf.Length()) + }) + } +} diff --git a/pkg/indicator/stddev.go b/pkg/indicator/stddev.go new file mode 100644 index 0000000..179d1ba --- /dev/null +++ b/pkg/indicator/stddev.go @@ -0,0 +1,56 @@ +package indicator + +import ( + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const MaxNumOfStdev = 600 +const MaxNumOfStdevTruncateSize = 300 + +//go:generate callbackgen -type StdDev +type StdDev struct { + types.SeriesBase + types.IntervalWindow + Values floats.Slice + rawValues *types.Queue + + EndTime time.Time + updateCallbacks []func(value float64) +} + +func (inc *StdDev) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *StdDev) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *StdDev) Length() int { + return inc.Values.Length() +} + +var _ types.SeriesExtend = &StdDev{} + +func (inc *StdDev) Update(value float64) { + if inc.rawValues == nil { + inc.rawValues = types.NewQueue(inc.Window) + inc.SeriesBase.Series = inc + } + + inc.rawValues.Update(value) + + var std = inc.rawValues.Stdev() + inc.Values.Push(std) + if len(inc.Values) > MaxNumOfStdev { + inc.Values = inc.Values[MaxNumOfStdevTruncateSize-1:] + } +} + +func (inc *StdDev) PushK(k types.KLine) { + inc.Update(k.Close.Float64()) + inc.EndTime = k.EndTime.Time() +} diff --git a/pkg/indicator/stddev_callbacks.go b/pkg/indicator/stddev_callbacks.go new file mode 100644 index 0000000..745f006 --- /dev/null +++ b/pkg/indicator/stddev_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type StdDev"; DO NOT EDIT. + +package indicator + +import () + +func (inc *StdDev) OnUpdate(cb func(value float64)) { + inc.updateCallbacks = append(inc.updateCallbacks, cb) +} + +func (inc *StdDev) EmitUpdate(value float64) { + for _, cb := range inc.updateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/stoch.go b/pkg/indicator/stoch.go new file mode 100644 index 0000000..d5b0b7a --- /dev/null +++ b/pkg/indicator/stoch.go @@ -0,0 +1,83 @@ +package indicator + +import ( + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const DPeriod int = 3 + +// Stochastic Oscillator +// - https://www.investopedia.com/terms/s/stochasticoscillator.asp +// +// The Stochastic Oscillator is a technical analysis indicator that is used to identify potential overbought or oversold conditions +// in a security's price. It is calculated by taking the current closing price of the security and comparing it to the high and low prices +// over a specified period of time. This comparison is then plotted as a line on the price chart, with values above 80 indicating overbought +// conditions and values below 20 indicating oversold conditions. The Stochastic Oscillator can be used by traders to identify potential +// entry and exit points for trades, or to confirm other technical analysis signals. It is typically used in conjunction with other indicators +// to provide a more comprehensive view of the security's price. + +//go:generate callbackgen -type STOCH +type STOCH struct { + types.IntervalWindow + K floats.Slice + D floats.Slice + + HighValues floats.Slice + LowValues floats.Slice + + EndTime time.Time + UpdateCallbacks []func(k float64, d float64) +} + +func (inc *STOCH) Update(high, low, cloze float64) { + inc.HighValues.Push(high) + inc.LowValues.Push(low) + + lowest := inc.LowValues.Tail(inc.Window).Min() + highest := inc.HighValues.Tail(inc.Window).Max() + + if highest == lowest { + inc.K.Push(50.0) + } else { + k := 100.0 * (cloze - lowest) / (highest - lowest) + inc.K.Push(k) + } + + d := inc.K.Tail(DPeriod).Mean() + inc.D.Push(d) +} + +func (inc *STOCH) LastK() float64 { + if len(inc.K) == 0 { + return 0.0 + } + return inc.K[len(inc.K)-1] +} + +func (inc *STOCH) LastD() float64 { + if len(inc.K) == 0 { + return 0.0 + } + return inc.D[len(inc.D)-1] +} + +func (inc *STOCH) PushK(k types.KLine) { + if inc.EndTime != zeroTime && !k.EndTime.After(inc.EndTime) { + return + } + + inc.Update(k.High.Float64(), k.Low.Float64(), k.Close.Float64()) + inc.EndTime = k.EndTime.Time() + inc.EmitUpdate(inc.LastK(), inc.LastD()) +} + +func (inc *STOCH) GetD() types.Series { + return &inc.D +} + +func (inc *STOCH) GetK() types.Series { + return &inc.K +} diff --git a/pkg/indicator/stoch_callbacks.go b/pkg/indicator/stoch_callbacks.go new file mode 100644 index 0000000..dcc07e7 --- /dev/null +++ b/pkg/indicator/stoch_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type STOCH"; DO NOT EDIT. + +package indicator + +import () + +func (inc *STOCH) OnUpdate(cb func(k float64, d float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *STOCH) EmitUpdate(k float64, d float64) { + for _, cb := range inc.UpdateCallbacks { + cb(k, d) + } +} diff --git a/pkg/indicator/stoch_test.go b/pkg/indicator/stoch_test.go new file mode 100644 index 0000000..408aa37 --- /dev/null +++ b/pkg/indicator/stoch_test.go @@ -0,0 +1,78 @@ +package indicator + +import ( + "encoding/json" + "math" + "testing" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +/* +python +import pandas as pd +import pandas_ta as ta + +klines = ... +df = pd.DataFrame(klines, columns=['open', 'high', 'low', 'close', 'volume']) + + print(df.ta.stoch(df['high'], df['low'], df['close'], k=14, d=3, smooth_k=1)) +*/ +func TestSTOCH_update(t *testing.T) { + open := []byte(`[8273.0, 8280.0, 8280.0, 8275.0, 8281.0, 8277.0, 8279.0, 8280.0, 8284.0, 8286.0, 8283.0, 8283.0, 8284.0, 8286.0, 8285.0, 8287.0, 8289.0, 8282.0, 8286.0, 8279.0, 8275.0, 8276.0, 8276.0, 8281.0, 8269.0, 8256.0, 8258.0, 8252.0, 8241.0, 8232.0, 8218.0, 8221.0, 8216.0, 8210.0, 8212.0, 8201.0, 8197.0, 8200.0, 8193.0, 8181.0, 8185.0, 8190.0, 8184.0, 8185.0, 8163.0, 8153.0, 8162.0, 8165.0, 8162.0, 8157.0, 8159.0, 8141.0, 8140.0, 8141.0, 8130.0, 8144.0, 8141.0, 8148.0, 8145.0, 8134.0, 8123.0, 8127.0, 8130.0, 8125.0, 8122.0, 8105.0, 8096.0, 8103.0, 8102.0, 8110.0, 8104.0, 8109.0, 8103.0, 8111.0, 8112.0, 8109.0, 8092.0, 8100.0, 8101.0, 8100.0, 8096.0, 8095.0, 8094.0, 8101.0, 8095.0, 8069.0, 8067.0, 8070.0, 8069.0, 8066.0, 8047.0, 8046.0, 8042.0, 8039.0, 8049.0, 8055.0, 8063.0, 8061.0, 8056.0, 8057.0, 8056.0, 8057.0, 8057.0, 8054.0, 8056.0, 8056.0, 8065.0, 8065.0, 8070.0, 8065.0, 8064.0, 8063.0, 8060.0, 8065.0, 8068.0, 8068.0, 8069.0, 8073.0, 8073.0, 8084.0, 8084.0, 8076.0, 8074.0, 8074.0, 8074.0, 8078.0, 8080.0, 8082.0, 8085.0, 8083.0, 8087.0, 8087.0, 8083.0, 8083.0, 8082.0, 8074.0, 8074.0, 8071.0, 8071.0, 8072.0, 8075.0, 8075.0, 8076.0, 8073.0, 8071.0, 8070.0, 8075.0, 8078.0, 8077.0, 8075.0, 8073.0, 8079.0, 8084.0, 8082.0, 8085.0, 8085.0, 8085.0, 8101.0, 8106.0, 8113.0, 8109.0, 8104.0, 8105.0, 8105.0, 8107.0, 8106.0, 8104.0, 8106.0, 8106.0, 8110.0, 8107.0, 8110.0, 8111.0, 8104.0, 8098.0, 8098.0, 8098.0, 8098.0, 8094.0, 8097.0, 8096.0, 8099.0, 8098.0, 8099.0, 8098.0, 8095.0, 8096.0, 8086.0, 8088.0, 8093.0, 8092.0, 8096.0, 8100.0, 8104.0, 8104.0, 8108.0, 8107.0, 8103.0, 8104.0, 8110.0, 8105.0, 8102.0, 8104.0, 8096.0, 8099.0, 8103.0, 8102.0, 8108.0, 8107.0, 8107.0, 8104.0, 8095.0, 8091.0, 8092.0, 8090.0, 8093.0, 8093.0, 8094.0, 8095.0, 8096.0, 8088.0, 8090.0, 8079.0, 8077.0, 8079.0, 8081.0, 8083.0, 8084.0, 8084.0, 8087.0, 8091.0, 8089.0, 8089.0, 8091.0, 8087.0, 8093.0, 8090.0, 8090.0, 8095.0, 8093.0, 8088.0, 8087.0, 8090.0, 8089.0, 8087.0, 8084.0, 8087.0, 8084.0, 8080.0, 8078.0, 8077.0, 8077.0, 8076.0, 8072.0, 8072.0, 8075.0, 8076.0, 8074.0, 8077.0, 8081.0, 8080.0, 8076.0, 8075.0, 8077.0, 8080.0, 8077.0, 8076.0, 8076.0, 8070.0, 8071.0, 8070.0, 8073.0, 8069.0, 8069.0, 8068.0, 8072.0, 8078.0, 8077.0, 8079.0, 8081.0, 8076.0, 8076.0, 8077.0, 8077.0, 8078.0, 8075.0, 8066.0, 8064.0, 8064.0, 8062.0, 8062.0, 8065.0, 8062.0, 8063.0, 8074.0, 8070.0, 8069.0, 8068.0, 8074.0, 8075.0]`) + high := []byte(`[8279.0, 8282.0, 8280.0, 8280.0, 8284.0, 8284.0, 8280.0, 8282.0, 8284.0, 8289.0, 8288.0, 8285.0, 8284.0, 8287.0, 8286.0, 8294.0, 8290.0, 8292.0, 8289.0, 8288.0, 8278.0, 8279.0, 8279.0, 8284.0, 8282.0, 8270.0, 8261.0, 8260.0, 8252.0, 8244.0, 8233.0, 8227.0, 8222.0, 8217.0, 8217.0, 8211.0, 8202.0, 8203.0, 8203.0, 8196.0, 8186.0, 8193.0, 8194.0, 8187.0, 8185.0, 8168.0, 8165.0, 8169.0, 8166.0, 8163.0, 8162.0, 8159.0, 8143.0, 8148.0, 8143.0, 8146.0, 8152.0, 8149.0, 8152.0, 8147.0, 8138.0, 8128.0, 8134.0, 8131.0, 8133.0, 8123.0, 8106.0, 8105.0, 8104.0, 8113.0, 8112.0, 8112.0, 8111.0, 8114.0, 8115.0, 8114.0, 8110.0, 8101.0, 8107.0, 8103.0, 8100.0, 8101.0, 8100.0, 8102.0, 8101.0, 8100.0, 8070.0, 8076.0, 8072.0, 8072.0, 8069.0, 8050.0, 8048.0, 8044.0, 8049.0, 8055.0, 8063.0, 8070.0, 8067.0, 8061.0, 8059.0, 8060.0, 8063.0, 8058.0, 8061.0, 8061.0, 8068.0, 8066.0, 8071.0, 8073.0, 8068.0, 8066.0, 8066.0, 8065.0, 8070.0, 8072.0, 8072.0, 8075.0, 8078.0, 8084.0, 8085.0, 8084.0, 8077.0, 8076.0, 8075.0, 8079.0, 8081.0, 8083.0, 8088.0, 8086.0, 8088.0, 8088.0, 8092.0, 8086.0, 8086.0, 8083.0, 8075.0, 8074.0, 8073.0, 8073.0, 8077.0, 8077.0, 8078.0, 8077.0, 8076.0, 8073.0, 8075.0, 8079.0, 8079.0, 8078.0, 8074.0, 8080.0, 8086.0, 8086.0, 8085.0, 8085.0, 8087.0, 8102.0, 8109.0, 8113.0, 8114.0, 8110.0, 8105.0, 8106.0, 8109.0, 8114.0, 8107.0, 8106.0, 8106.0, 8110.0, 8111.0, 8110.0, 8112.0, 8112.0, 8109.0, 8102.0, 8098.0, 8099.0, 8098.0, 8097.0, 8099.0, 8099.0, 8099.0, 8102.0, 8099.0, 8099.0, 8096.0, 8097.0, 8091.0, 8094.0, 8094.0, 8096.0, 8102.0, 8106.0, 8109.0, 8109.0, 8110.0, 8108.0, 8106.0, 8110.0, 8122.0, 8105.0, 8105.0, 8104.0, 8103.0, 8104.0, 8103.0, 8110.0, 8110.0, 8107.0, 8109.0, 8105.0, 8097.0, 8095.0, 8093.0, 8094.0, 8097.0, 8096.0, 8096.0, 8096.0, 8097.0, 8092.0, 8090.0, 8081.0, 8081.0, 8083.0, 8087.0, 8085.0, 8085.0, 8087.0, 8092.0, 8094.0, 8090.0, 8093.0, 8092.0, 8094.0, 8093.0, 8091.0, 8095.0, 8095.0, 8092.0, 8089.0, 8090.0, 8090.0, 8091.0, 8088.0, 8089.0, 8089.0, 8085.0, 8081.0, 8080.0, 8078.0, 8078.0, 8076.0, 8073.0, 8077.0, 8078.0, 8077.0, 8077.0, 8083.0, 8082.0, 8082.0, 8077.0, 8079.0, 8082.0, 8080.0, 8077.0, 8078.0, 8076.0, 8073.0, 8074.0, 8073.0, 8073.0, 8070.0, 8070.0, 8072.0, 8079.0, 8078.0, 8079.0, 8081.0, 8083.0, 8077.0, 8078.0, 8080.0, 8079.0, 8080.0, 8077.0, 8069.0, 8071.0, 8066.0, 8064.0, 8066.0, 8066.0, 8063.0, 8074.0, 8075.0, 8071.0, 8070.0, 8075.0, 8075.0]`) + low := []byte(`[8260.0, 8272.0, 8275.0, 8274.0, 8275.0, 8277.0, 8276.0, 8278.0, 8277.0, 8283.0, 8282.0, 8283.0, 8283.0, 8283.0, 8283.0, 8279.0, 8281.0, 8282.0, 8277.0, 8276.0, 8273.0, 8275.0, 8274.0, 8275.0, 8266.0, 8256.0, 8255.0, 8250.0, 8239.0, 8230.0, 8214.0, 8218.0, 8216.0, 8208.0, 8209.0, 8201.0, 8190.0, 8195.0, 8193.0, 8181.0, 8175.0, 8183.0, 8182.0, 8181.0, 8159.0, 8152.0, 8150.0, 8160.0, 8161.0, 8153.0, 8153.0, 8137.0, 8135.0, 8139.0, 8130.0, 8130.0, 8140.0, 8137.0, 8145.0, 8134.0, 8123.0, 8116.0, 8122.0, 8124.0, 8122.0, 8105.0, 8096.0, 8096.0, 8097.0, 8100.0, 8100.0, 8104.0, 8101.0, 8103.0, 8109.0, 8108.0, 8089.0, 8092.0, 8097.0, 8098.0, 8094.0, 8092.0, 8087.0, 8094.0, 8094.0, 8069.0, 8058.0, 8065.0, 8066.0, 8065.0, 8046.0, 8041.0, 8036.0, 8038.0, 8039.0, 8047.0, 8053.0, 8058.0, 8056.0, 8056.0, 8053.0, 8052.0, 8054.0, 8051.0, 8053.0, 8056.0, 8055.0, 8063.0, 8064.0, 8063.0, 8062.0, 8061.0, 8059.0, 8059.0, 8063.0, 8066.0, 8067.0, 8068.0, 8071.0, 8071.0, 8079.0, 8074.0, 8073.0, 8074.0, 8073.0, 8073.0, 8076.0, 8079.0, 8080.0, 8083.0, 8083.0, 8085.0, 8082.0, 8082.0, 8081.0, 8072.0, 8072.0, 8068.0, 8070.0, 8070.0, 8072.0, 8074.0, 8075.0, 8073.0, 8071.0, 8070.0, 8067.0, 8074.0, 8076.0, 8072.0, 8070.0, 8072.0, 8079.0, 8081.0, 8082.0, 8082.0, 8084.0, 8083.0, 8097.0, 8103.0, 8107.0, 8104.0, 8103.0, 8104.0, 8103.0, 8105.0, 8103.0, 8102.0, 8102.0, 8103.0, 8106.0, 8107.0, 8108.0, 8102.0, 8098.0, 8096.0, 8095.0, 8096.0, 8093.0, 8094.0, 8094.0, 8096.0, 8097.0, 8097.0, 8096.0, 8094.0, 8094.0, 8086.0, 8086.0, 8087.0, 8090.0, 8091.0, 8095.0, 8099.0, 8104.0, 8102.0, 8106.0, 8101.0, 8103.0, 8104.0, 8104.0, 8101.0, 8102.0, 8096.0, 8096.0, 8098.0, 8100.0, 8102.0, 8106.0, 8103.0, 8103.0, 8094.0, 8090.0, 8090.0, 8089.0, 8088.0, 8090.0, 8093.0, 8094.0, 8094.0, 8088.0, 8087.0, 8079.0, 8075.0, 8076.0, 8077.0, 8081.0, 8083.0, 8083.0, 8084.0, 8087.0, 8089.0, 8088.0, 8088.0, 8086.0, 8087.0, 8090.0, 8088.0, 8090.0, 8091.0, 8087.0, 8087.0, 8086.0, 8088.0, 8087.0, 8082.0, 8083.0, 8083.0, 8078.0, 8077.0, 8077.0, 8072.0, 8074.0, 8071.0, 8070.0, 8072.0, 8073.0, 8073.0, 8072.0, 8076.0, 8079.0, 8075.0, 8075.0, 8075.0, 8076.0, 8076.0, 8074.0, 8076.0, 8069.0, 8068.0, 8069.0, 8069.0, 8065.0, 8067.0, 8067.0, 8067.0, 8073.0, 8075.0, 8076.0, 8077.0, 8075.0, 8072.0, 8074.0, 8075.0, 8074.0, 8072.0, 8066.0, 8063.0, 8062.0, 8058.0, 8060.0, 8059.0, 8060.0, 8059.0, 8062.0, 8067.0, 8068.0, 8067.0, 8068.0, 8071.0]`) + close := []byte(`[8262.0, 8273.0, 8279.0, 8279.0, 8275.0, 8282.0, 8278.0, 8279.0, 8281.0, 8285.0, 8287.0, 8284.0, 8283.0, 8283.0, 8285.0, 8286.0, 8287.0, 8290.0, 8283.0, 8287.0, 8278.0, 8275.0, 8276.0, 8275.0, 8281.0, 8270.0, 8257.0, 8258.0, 8252.0, 8243.0, 8231.0, 8219.0, 8220.0, 8216.0, 8210.0, 8211.0, 8201.0, 8197.0, 8201.0, 8193.0, 8183.0, 8184.0, 8191.0, 8184.0, 8185.0, 8161.0, 8154.0, 8163.0, 8164.0, 8162.0, 8156.0, 8158.0, 8141.0, 8139.0, 8142.0, 8130.0, 8145.0, 8140.0, 8149.0, 8146.0, 8136.0, 8123.0, 8126.0, 8130.0, 8125.0, 8122.0, 8106.0, 8096.0, 8103.0, 8102.0, 8111.0, 8105.0, 8111.0, 8103.0, 8112.0, 8113.0, 8109.0, 8093.0, 8101.0, 8101.0, 8100.0, 8095.0, 8096.0, 8095.0, 8100.0, 8095.0, 8069.0, 8068.0, 8072.0, 8068.0, 8067.0, 8046.0, 8045.0, 8043.0, 8040.0, 8049.0, 8055.0, 8062.0, 8062.0, 8058.0, 8056.0, 8055.0, 8058.0, 8057.0, 8054.0, 8056.0, 8057.0, 8066.0, 8065.0, 8069.0, 8064.0, 8063.0, 8064.0, 8059.0, 8065.0, 8069.0, 8068.0, 8069.0, 8072.0, 8074.0, 8084.0, 8084.0, 8076.0, 8074.0, 8074.0, 8075.0, 8077.0, 8080.0, 8082.0, 8086.0, 8084.0, 8087.0, 8087.0, 8083.0, 8083.0, 8082.0, 8074.0, 8073.0, 8072.0, 8071.0, 8072.0, 8075.0, 8076.0, 8076.0, 8074.0, 8071.0, 8071.0, 8075.0, 8079.0, 8077.0, 8074.0, 8072.0, 8079.0, 8084.0, 8082.0, 8085.0, 8086.0, 8084.0, 8102.0, 8107.0, 8113.0, 8109.0, 8104.0, 8104.0, 8105.0, 8108.0, 8106.0, 8104.0, 8106.0, 8105.0, 8110.0, 8107.0, 8109.0, 8112.0, 8104.0, 8099.0, 8097.0, 8097.0, 8098.0, 8095.0, 8096.0, 8097.0, 8099.0, 8098.0, 8099.0, 8099.0, 8095.0, 8097.0, 8086.0, 8088.0, 8093.0, 8092.0, 8096.0, 8101.0, 8105.0, 8105.0, 8109.0, 8107.0, 8103.0, 8104.0, 8109.0, 8105.0, 8102.0, 8104.0, 8097.0, 8100.0, 8103.0, 8103.0, 8109.0, 8107.0, 8106.0, 8104.0, 8096.0, 8090.0, 8092.0, 8089.0, 8093.0, 8093.0, 8094.0, 8095.0, 8096.0, 8088.0, 8089.0, 8079.0, 8077.0, 8079.0, 8082.0, 8083.0, 8084.0, 8084.0, 8087.0, 8091.0, 8088.0, 8088.0, 8091.0, 8087.0, 8092.0, 8090.0, 8091.0, 8095.0, 8092.0, 8088.0, 8087.0, 8090.0, 8089.0, 8087.0, 8084.0, 8088.0, 8084.0, 8079.0, 8078.0, 8078.0, 8076.0, 8075.0, 8071.0, 8072.0, 8074.0, 8077.0, 8074.0, 8077.0, 8081.0, 8080.0, 8076.0, 8076.0, 8078.0, 8079.0, 8076.0, 8076.0, 8076.0, 8070.0, 8072.0, 8069.0, 8072.0, 8070.0, 8069.0, 8069.0, 8073.0, 8078.0, 8077.0, 8079.0, 8080.0, 8076.0, 8076.0, 8076.0, 8077.0, 8078.0, 8075.0, 8067.0, 8064.0, 8064.0, 8062.0, 8062.0, 8065.0, 8062.0, 8063.0, 8074.0, 8070.0, 8069.0, 8068.0, 8074.0]`) + + buildKLines := func(open, high, low, close []fixedpoint.Value) (kLines []types.KLine) { + for i := range high { + kLines = append(kLines, types.KLine{Open: open[i], High: high[i], Low: low[i], Close: close[i], EndTime: types.Time(time.Now())}) + } + return kLines + } + var o, h, l, c []fixedpoint.Value + _ = json.Unmarshal(open, &o) + _ = json.Unmarshal(high, &h) + _ = json.Unmarshal(low, &l) + _ = json.Unmarshal(close, &c) + + tests := []struct { + name string + kLines []types.KLine + window int + want_k float64 + want_d float64 + }{ + { + name: "TXF1-1min_2016/1/4", + kLines: buildKLines(o, h, l, c), + window: 14, + want_k: 84.210526, + want_d: 59.888357, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + kd := STOCH{IntervalWindow: types.IntervalWindow{Window: tt.window}} + + for _, k := range tt.kLines { + kd.PushK(k) + } + + got_k := kd.LastK() + diff_k := math.Trunc((got_k-tt.want_k)*100) / 100 + if diff_k != 0 { + t.Errorf("%%K() = %v, want %v", got_k, tt.want_k) + } + + got_d := kd.LastD() + diff_d := math.Trunc((got_d-tt.want_d)*100) / 100 + if diff_d != 0 { + t.Errorf("%%D() = %v, want %v", got_d, tt.want_d) + } + }) + } +} diff --git a/pkg/indicator/supertrend.go b/pkg/indicator/supertrend.go new file mode 100644 index 0000000..8febb85 --- /dev/null +++ b/pkg/indicator/supertrend.go @@ -0,0 +1,205 @@ +package indicator + +import ( + "math" + "time" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +var logst = logrus.WithField("indicator", "supertrend") + +// The Super Trend is a technical analysis indicator that is used to identify potential buy and sell signals in a security's price. It is +// calculated by combining the exponential moving average (EMA) and the average true range (ATR) of the security's price, and then plotting +// the resulting value on the price chart as a line. The Super Trend line is typically used to identify potential entry and exit points +// for trades, and can be used to confirm other technical analysis signals. It is typically more responsive to changes in the underlying +// data than other trend-following indicators, but may be less reliable in trending markets. It is important to note that the Super Trend is a +// lagging indicator, which means that it may not always provide accurate or timely signals. +// +// To use Super Trend, identify potential entry and exit points for trades by looking for crossovers or divergences between the Super Trend line +// and the security's price. For example, a buy signal may be generated when the Super Trend line crosses above the security's price, while a sell +// signal may be generated when the Super Trend line crosses below the security's price. + +//go:generate callbackgen -type Supertrend +type Supertrend struct { + types.SeriesBase + types.IntervalWindow + ATRMultiplier float64 `json:"atrMultiplier"` + + AverageTrueRange *ATR + + trendPrices floats.Slice // Value of the trend line (buy or sell) + supportLine floats.Slice // The support line in an uptrend (green) + resistanceLine floats.Slice // The resistance line in a downtrend (red) + + closePrice float64 + previousClosePrice float64 + uptrendPrice float64 + previousUptrendPrice float64 + downtrendPrice float64 + previousDowntrendPrice float64 + + trend types.Direction + previousTrend types.Direction + tradeSignal types.Direction + + EndTime time.Time + UpdateCallbacks []func(value float64) +} + +func (inc *Supertrend) Last(i int) float64 { + return inc.trendPrices.Last(i) +} + +func (inc *Supertrend) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *Supertrend) Length() int { + return len(inc.trendPrices) +} + +func (inc *Supertrend) Update(highPrice, lowPrice, closePrice float64) { + if inc.Window <= 0 { + 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 + } + + // Update ATR + inc.AverageTrueRange.Update(highPrice, lowPrice, closePrice) + + // Update last prices + inc.previousUptrendPrice = inc.uptrendPrice + inc.previousDowntrendPrice = inc.downtrendPrice + inc.previousClosePrice = inc.closePrice + inc.previousTrend = inc.trend + + inc.closePrice = closePrice + + src := (highPrice + lowPrice) / 2 + + // Update uptrend + inc.uptrendPrice = src - inc.AverageTrueRange.Last(0)*inc.ATRMultiplier + if inc.previousClosePrice > inc.previousUptrendPrice { + inc.uptrendPrice = math.Max(inc.uptrendPrice, inc.previousUptrendPrice) + } + + // Update downtrend + inc.downtrendPrice = src + inc.AverageTrueRange.Last(0)*inc.ATRMultiplier + if inc.previousClosePrice < inc.previousDowntrendPrice { + inc.downtrendPrice = math.Min(inc.downtrendPrice, inc.previousDowntrendPrice) + } + + // Update trend + if inc.previousTrend == types.DirectionUp && inc.closePrice < inc.previousUptrendPrice { + inc.trend = types.DirectionDown + } else if inc.previousTrend == types.DirectionDown && inc.closePrice > inc.previousDowntrendPrice { + inc.trend = types.DirectionUp + } else { + inc.trend = inc.previousTrend + } + + // Update signal + if inc.AverageTrueRange.Last(0) <= 0 { + inc.tradeSignal = types.DirectionNone + } else if inc.trend == types.DirectionUp && inc.previousTrend == types.DirectionDown { + inc.tradeSignal = types.DirectionUp + } else if inc.trend == types.DirectionDown && inc.previousTrend == types.DirectionUp { + inc.tradeSignal = types.DirectionDown + } else { + inc.tradeSignal = types.DirectionNone + } + + // Update trend price + if inc.trend == types.DirectionDown { + inc.trendPrices.Push(inc.downtrendPrice) + } else { + inc.trendPrices.Push(inc.uptrendPrice) + } + + // Save the trend lines + inc.supportLine.Push(inc.uptrendPrice) + inc.resistanceLine.Push(inc.downtrendPrice) + + logst.Debugf("Update supertrend result: closePrice: %v, uptrendPrice: %v, downtrendPrice: %v, trend: %v,"+ + " tradeSignal: %v, AverageTrueRange.Last(): %v", inc.closePrice, inc.uptrendPrice, inc.downtrendPrice, + inc.trend, inc.tradeSignal, inc.AverageTrueRange.Last(0)) +} + +func (inc *Supertrend) GetSignal() types.Direction { + return inc.tradeSignal +} + +// GetDirection return the current trend +func (inc *Supertrend) Direction() types.Direction { + return inc.trend +} + +// LastSupertrendSupport return the current supertrend support +func (inc *Supertrend) LastSupertrendSupport() float64 { + return inc.supportLine.Last(0) +} + +// LastSupertrendResistance return the current supertrend resistance +func (inc *Supertrend) LastSupertrendResistance() float64 { + return inc.resistanceLine.Last(0) +} + +var _ types.SeriesExtend = &Supertrend{} + +func (inc *Supertrend) PushK(k types.KLine) { + if inc.EndTime != zeroTime && k.EndTime.Before(inc.EndTime) { + return + } + + inc.Update(k.GetHigh().Float64(), k.GetLow().Float64(), k.GetClose().Float64()) + inc.EndTime = k.EndTime.Time() + inc.EmitUpdate(inc.Last(0)) + +} + +func (inc *Supertrend) BindK(target KLineClosedEmitter, symbol string, interval types.Interval) { + target.OnKLineClosed(types.KLineWith(symbol, interval, inc.PushK)) +} + +func (inc *Supertrend) LoadK(allKLines []types.KLine) { + for _, k := range allKLines { + inc.PushK(k) + } +} + +func (inc *Supertrend) CalculateAndUpdate(kLines []types.KLine) { + for _, k := range kLines { + if inc.EndTime != zeroTime && !k.EndTime.After(inc.EndTime) { + continue + } + + inc.PushK(k) + } + + inc.EmitUpdate(inc.Last(0)) + inc.EndTime = kLines[len(kLines)-1].EndTime.Time() +} + +func (inc *Supertrend) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *Supertrend) Bind(updater KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} diff --git a/pkg/indicator/supertrendPivot.go b/pkg/indicator/supertrendPivot.go new file mode 100644 index 0000000..99caca3 --- /dev/null +++ b/pkg/indicator/supertrendPivot.go @@ -0,0 +1,213 @@ +package indicator + +import ( + "math" + "time" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// based on "Pivot Point Supertrend by LonesomeTheBlue" from tradingview + +var logpst = logrus.WithField("indicator", "pivotSupertrend") + +//go:generate callbackgen -type PivotSupertrend +type PivotSupertrend struct { + types.SeriesBase + types.IntervalWindow + ATRMultiplier float64 `json:"atrMultiplier"` + PivotWindow int `json:"pivotWindow"` + + AverageTrueRange *ATR // Value must be set when initialized in strategy + + PivotLow *PivotLow // Value must be set when initialized in strategy + PivotHigh *PivotHigh // Value must be set when initialized in strategy + + trendPrices floats.Slice // Tsl: value of the trend line (buy or sell) + supportLine floats.Slice // The support line in an uptrend (green) + resistanceLine floats.Slice // The resistance line in a downtrend (red) + + closePrice float64 + previousClosePrice float64 + uptrendPrice float64 + previousUptrendPrice float64 + downtrendPrice float64 + previousDowntrendPrice float64 + + lastPp float64 + src float64 // center + previousPivotHigh float64 // temp variable to save the last value + previousPivotLow float64 // temp variable to save the last value + + trend types.Direction + previousTrend types.Direction + tradeSignal types.Direction + + EndTime time.Time + UpdateCallbacks []func(value float64) +} + +func (inc *PivotSupertrend) Last(i int) float64 { + return inc.trendPrices.Last(i) +} + +func (inc *PivotSupertrend) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *PivotSupertrend) Length() int { + return len(inc.trendPrices) +} + +func (inc *PivotSupertrend) Update(highPrice, lowPrice, closePrice float64) { + if inc.Window <= 0 { + 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 + } + + inc.previousPivotLow = inc.PivotLow.Last(0) + inc.previousPivotHigh = inc.PivotHigh.Last(0) + + // Update High / Low pivots + inc.PivotLow.Update(lowPrice) + inc.PivotHigh.Update(highPrice) + + // Update ATR + inc.AverageTrueRange.Update(highPrice, lowPrice, closePrice) + + // Update last prices + inc.previousUptrendPrice = inc.uptrendPrice + inc.previousDowntrendPrice = inc.downtrendPrice + inc.previousClosePrice = inc.closePrice + inc.previousTrend = inc.trend + + inc.closePrice = closePrice + + // Initialize lastPp as soon as pivots are made + if inc.lastPp == 0 || math.IsNaN(inc.lastPp) { + if inc.PivotHigh.Length() > 0 { + inc.lastPp = inc.PivotHigh.Last(0) + } else if inc.PivotLow.Length() > 0 { + inc.lastPp = inc.PivotLow.Last(0) + } else { + inc.lastPp = math.NaN() + return + } + } + + // Set lastPp to the latest pivotPoint (only changed when new pivot is found) + if inc.PivotHigh.Last(0) != inc.previousPivotHigh { + inc.lastPp = inc.PivotHigh.Last(0) + } else if inc.PivotLow.Last(0) != inc.previousPivotLow { + inc.lastPp = inc.PivotLow.Last(0) + } + + // calculate the Center line using pivot points + if inc.src == 0 || math.IsNaN(inc.src) { + inc.src = inc.lastPp + } else { + // weighted calculation + inc.src = (inc.src*2 + inc.lastPp) / 3 + } + + // Update uptrend + inc.uptrendPrice = inc.src - inc.AverageTrueRange.Last(0)*inc.ATRMultiplier + if inc.previousClosePrice > inc.previousUptrendPrice { + inc.uptrendPrice = math.Max(inc.uptrendPrice, inc.previousUptrendPrice) + } + + // Update downtrend + inc.downtrendPrice = inc.src + inc.AverageTrueRange.Last(0)*inc.ATRMultiplier + if inc.previousClosePrice < inc.previousDowntrendPrice { + inc.downtrendPrice = math.Min(inc.downtrendPrice, inc.previousDowntrendPrice) + } + + // Update trend + if inc.previousTrend == types.DirectionUp && inc.closePrice < inc.previousUptrendPrice { + inc.trend = types.DirectionDown + } else if inc.previousTrend == types.DirectionDown && inc.closePrice > inc.previousDowntrendPrice { + inc.trend = types.DirectionUp + } else { + inc.trend = inc.previousTrend + } + + // Update signal + if inc.AverageTrueRange.Last(0) <= 0 { + inc.tradeSignal = types.DirectionNone + } else if inc.trend == types.DirectionUp && inc.previousTrend == types.DirectionDown { + inc.tradeSignal = types.DirectionUp + } else if inc.trend == types.DirectionDown && inc.previousTrend == types.DirectionUp { + inc.tradeSignal = types.DirectionDown + } else { + inc.tradeSignal = types.DirectionNone + } + + // Update trend price + if inc.trend == types.DirectionDown { + inc.trendPrices.Push(inc.downtrendPrice) + } else { + inc.trendPrices.Push(inc.uptrendPrice) + } + + // Save the trend lines + inc.supportLine.Push(inc.uptrendPrice) + inc.resistanceLine.Push(inc.downtrendPrice) + + logpst.Debugf("Update pivot point supertrend result: closePrice: %v, uptrendPrice: %v, downtrendPrice: %v, trend: %v,"+ + " tradeSignal: %v, AverageTrueRange.Last(): %v", inc.closePrice, inc.uptrendPrice, inc.downtrendPrice, + inc.trend, inc.tradeSignal, inc.AverageTrueRange.Last(0)) +} + +// GetSignal returns signal (Down, None or Up) +func (inc *PivotSupertrend) GetSignal() types.Direction { + return inc.tradeSignal +} + +// GetDirection return the current trend +func (inc *PivotSupertrend) Direction() types.Direction { + return inc.trend +} + +// LastSupertrendSupport return the current supertrend support value +func (inc *PivotSupertrend) LastSupertrendSupport() float64 { + return inc.supportLine.Last(0) +} + +// LastSupertrendResistance return the current supertrend resistance value +func (inc *PivotSupertrend) LastSupertrendResistance() float64 { + return inc.resistanceLine.Last(0) +} + +var _ types.SeriesExtend = &PivotSupertrend{} + +func (inc *PivotSupertrend) PushK(k types.KLine) { + if inc.EndTime != zeroTime && !k.EndTime.After(inc.EndTime) { + return + } + + inc.Update(k.GetHigh().Float64(), k.GetLow().Float64(), k.GetClose().Float64()) + inc.EndTime = k.EndTime.Time() + inc.EmitUpdate(inc.Last(0)) +} + +func (inc *PivotSupertrend) BindK(target KLineClosedEmitter, symbol string, interval types.Interval) { + target.OnKLineClosed(types.KLineWith(symbol, interval, inc.PushK)) +} + +func (inc *PivotSupertrend) LoadK(allKLines []types.KLine) { + inc.SeriesBase.Series = inc + for _, k := range allKLines { + inc.PushK(k) + } +} diff --git a/pkg/indicator/supertrendPivot_callbacks.go b/pkg/indicator/supertrendPivot_callbacks.go new file mode 100644 index 0000000..8827909 --- /dev/null +++ b/pkg/indicator/supertrendPivot_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type Supertrend"; DO NOT EDIT. + +package indicator + +import () + +func (inc *PivotSupertrend) OnUpdate(cb func(value float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *PivotSupertrend) EmitUpdate(value float64) { + for _, cb := range inc.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/supertrend_callbacks.go b/pkg/indicator/supertrend_callbacks.go new file mode 100644 index 0000000..d023457 --- /dev/null +++ b/pkg/indicator/supertrend_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type Supertrend"; DO NOT EDIT. + +package indicator + +import () + +func (inc *Supertrend) OnUpdate(cb func(value float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *Supertrend) EmitUpdate(value float64) { + for _, cb := range inc.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/tema.go b/pkg/indicator/tema.go new file mode 100644 index 0000000..41186f0 --- /dev/null +++ b/pkg/indicator/tema.go @@ -0,0 +1,88 @@ +package indicator + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// Refer: Triple Exponential Moving Average (TEMA) +// URL: https://investopedia.com/terms/t/triple-exponential-moving-average.asp +// +// The Triple Exponential Moving Average (TEMA) is a technical analysis indicator that is used to smooth price data and reduce the lag +// associated with traditional moving averages. It is calculated by taking the exponentially weighted moving average of the input data, +// and then taking the exponentially weighted moving average of that result, and then taking the exponentially weighted moving average of +// that result. This triple-smoothing process helps to eliminate much of the noise in the original data and provides a more accurate +// representation of the underlying trend. The TEMA line is then plotted on the price chart, which can be used to make predictions about +// future price movements. The TEMA is typically more responsive to changes in the underlying data than a simple moving average, but may be +// less reliable in trending markets. + +//go:generate callbackgen -type TEMA +type TEMA struct { + types.SeriesBase + types.IntervalWindow + Values floats.Slice + A1 *EWMA + A2 *EWMA + A3 *EWMA + + UpdateCallbacks []func(value float64) +} + +func (inc *TEMA) Update(value float64) { + if len(inc.Values) == 0 { + inc.SeriesBase.Series = inc + inc.A1 = &EWMA{IntervalWindow: inc.IntervalWindow} + inc.A2 = &EWMA{IntervalWindow: inc.IntervalWindow} + inc.A3 = &EWMA{IntervalWindow: inc.IntervalWindow} + } + inc.A1.Update(value) + a1 := inc.A1.Last(0) + inc.A2.Update(a1) + a2 := inc.A2.Last(0) + inc.A3.Update(a2) + a3 := inc.A3.Last(0) + inc.Values.Push(3*a1 - 3*a2 + a3) +} + +func (inc *TEMA) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *TEMA) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *TEMA) Length() int { + return len(inc.Values) +} + +var _ types.SeriesExtend = &TEMA{} + +func (inc *TEMA) PushK(k types.KLine) { + inc.Update(k.Close.Float64()) +} + +func (inc *TEMA) CalculateAndUpdate(allKLines []types.KLine) { + if inc.A1 == nil { + for _, k := range allKLines { + inc.PushK(k) + inc.EmitUpdate(inc.Last(0)) + } + } else { + k := allKLines[len(allKLines)-1] + inc.PushK(k) + inc.EmitUpdate(inc.Last(0)) + } +} + +func (inc *TEMA) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *TEMA) Bind(updater KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} diff --git a/pkg/indicator/tema_callbacks.go b/pkg/indicator/tema_callbacks.go new file mode 100644 index 0000000..ed63757 --- /dev/null +++ b/pkg/indicator/tema_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type TEMA"; DO NOT EDIT. + +package indicator + +import () + +func (inc *TEMA) OnUpdate(cb func(value float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *TEMA) EmitUpdate(value float64) { + for _, cb := range inc.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/tema_test.go b/pkg/indicator/tema_test.go new file mode 100644 index 0000000..9bd84cb --- /dev/null +++ b/pkg/indicator/tema_test.go @@ -0,0 +1,56 @@ +package indicator + +import ( + "encoding/json" + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +/* +python: + +import pandas as pd +s = pd.Series([0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9]) +ma1 = s.ewm(span=16).mean() +ma2 = ma1.ewm(span=16).mean() +ma3 = ma2.ewm(span=16).mean() +result = (3 * ma1 - 3 * ma2 + ma3) +print(result) +*/ +func Test_TEMA(t *testing.T) { + var Delta = 4.3e-2 + var randomPrices = []byte(`[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]`) + var input []fixedpoint.Value + if err := json.Unmarshal(randomPrices, &input); err != nil { + panic(err) + } + tests := []struct { + name string + kLines []types.KLine + want float64 + next float64 + all int + }{ + { + name: "random_case", + kLines: buildKLines(input), + want: 7.163145, + next: 6.106229, + all: 50, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tema := TEMA{IntervalWindow: types.IntervalWindow{Window: 16}} + tema.CalculateAndUpdate(tt.kLines) + last := tema.Last(0) + assert.InDelta(t, tt.want, last, Delta) + assert.InDelta(t, tt.next, tema.Index(1), Delta) + assert.Equal(t, tt.all, tema.Length()) + }) + } +} diff --git a/pkg/indicator/till.go b/pkg/indicator/till.go new file mode 100644 index 0000000..d62eadc --- /dev/null +++ b/pkg/indicator/till.go @@ -0,0 +1,134 @@ +package indicator + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const defaultVolumeFactor = 0.7 + +// Refer: Tillson T3 Moving Average +// Refer URL: https://tradingpedia.com/forex-trading-indicator/t3-moving-average-indicator/ +// +// The Tillson T3 Moving Average (T3) is a technical analysis indicator that is used to smooth price data and reduce the lag associated +// with traditional moving averages. It was developed by Tim Tillson and is based on the exponential moving average, with the weighting +// factors determined using a modified version of the cubic polynomial. The T3 is calculated by taking the weighted moving average of the +// input data using weighting factors that are based on the standard deviation of the data and the specified length of the moving average. +// This resulting average is then plotted on the price chart as a line, which can be used to make predictions about future price movements. +// The T3 is typically more responsive to changes in the underlying data than a simple moving average, but may be less reliable in trending +// markets. + +//go:generate callbackgen -type TILL +type TILL struct { + types.SeriesBase + types.IntervalWindow + VolumeFactor float64 + e1 *EWMA + e2 *EWMA + e3 *EWMA + e4 *EWMA + e5 *EWMA + e6 *EWMA + c1 float64 + c2 float64 + c3 float64 + c4 float64 + + updateCallbacks []func(value float64) +} + +func (inc *TILL) Update(value float64) { + if inc.e1 == nil || inc.e1.Length() == 0 { + if inc.VolumeFactor == 0 { + inc.VolumeFactor = defaultVolumeFactor + } + inc.SeriesBase.Series = inc + inc.e1 = &EWMA{IntervalWindow: inc.IntervalWindow} + inc.e2 = &EWMA{IntervalWindow: inc.IntervalWindow} + inc.e3 = &EWMA{IntervalWindow: inc.IntervalWindow} + inc.e4 = &EWMA{IntervalWindow: inc.IntervalWindow} + inc.e5 = &EWMA{IntervalWindow: inc.IntervalWindow} + inc.e6 = &EWMA{IntervalWindow: inc.IntervalWindow} + square := inc.VolumeFactor * inc.VolumeFactor + cube := inc.VolumeFactor * square + inc.c1 = -cube + inc.c2 = 3.*square + 3.*cube + inc.c3 = -6.*square - 3*inc.VolumeFactor - 3*cube + inc.c4 = 1. + 3.*inc.VolumeFactor + cube + 3.*square + } + + inc.e1.Update(value) + inc.e2.Update(inc.e1.Last(0)) + inc.e3.Update(inc.e2.Last(0)) + inc.e4.Update(inc.e3.Last(0)) + inc.e5.Update(inc.e4.Last(0)) + inc.e6.Update(inc.e5.Last(0)) +} + +func (inc *TILL) Last(i int) float64 { + if inc.e1 == nil || inc.e1.Length() <= i { + return 0 + } + + e3 := inc.e3.Index(i) + e4 := inc.e4.Index(i) + e5 := inc.e5.Index(i) + e6 := inc.e6.Index(i) + return inc.c1*e6 + inc.c2*e5 + inc.c3*e4 + inc.c4*e3 +} + +func (inc *TILL) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *TILL) Length() int { + if inc.e1 == nil { + return 0 + } + return inc.e1.Length() +} + +var _ types.Series = &TILL{} + +func (inc *TILL) PushK(k types.KLine) { + if inc.e1 != nil && inc.e1.EndTime != zeroTime && k.EndTime.Before(inc.e1.EndTime) { + return + } + + inc.Update(k.Close.Float64()) + inc.EmitUpdate(inc.Last(0)) +} + +func (inc *TILL) LoadK(allKLines []types.KLine) { + for _, k := range allKLines { + inc.PushK(k) + } +} + +func (inc *TILL) BindK(target KLineClosedEmitter, symbol string, interval types.Interval) { + target.OnKLineClosed(types.KLineWith(symbol, interval, inc.PushK)) +} + +func (inc *TILL) CalculateAndUpdate(allKLines []types.KLine) { + if inc.e1 == nil { + for _, k := range allKLines { + inc.PushK(k) + } + } else { + end := len(allKLines) + last := allKLines[end-1] + inc.PushK(last) + } + +} + +func (inc *TILL) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *TILL) Bind(updater KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} diff --git a/pkg/indicator/till_callbacks.go b/pkg/indicator/till_callbacks.go new file mode 100644 index 0000000..d17a8dc --- /dev/null +++ b/pkg/indicator/till_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type TILL"; DO NOT EDIT. + +package indicator + +import () + +func (inc *TILL) OnUpdate(cb func(value float64)) { + inc.updateCallbacks = append(inc.updateCallbacks, cb) +} + +func (inc *TILL) EmitUpdate(value float64) { + for _, cb := range inc.updateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/till_test.go b/pkg/indicator/till_test.go new file mode 100644 index 0000000..f462bee --- /dev/null +++ b/pkg/indicator/till_test.go @@ -0,0 +1,66 @@ +package indicator + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +/* +python: + +import pandas as pd +s = pd.Series([0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9]) +ma1 = s.ewm(span=16).mean() +ma2 = ma1.ewm(span=16).mean() +ma3 = ma2.ewm(span=16).mean() +ma4 = ma3.ewm(span=16).mean() +ma5 = ma4.ewm(span=16).mean() +ma6 = ma5.ewm(span=16).mean() +square = 0.7 * 0.7 +cube = 0.7 ** 3 +c1 = -cube +c2 = 3 * square + 3 * cube +c3 = -6 * square - 3 * 0.7 - 3 * cube +c4 = 1 + 3 * 0.7 + cube + 3 * square +result = (c1 * ma6 + c2 * ma5 + c3 * ma4 + c4 * ma3) +print(result) +*/ +func Test_TILL(t *testing.T) { + var Delta = 0.18 + var randomPrices = []byte(`[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]`) + var input []fixedpoint.Value + if err := json.Unmarshal(randomPrices, &input); err != nil { + panic(err) + } + tests := []struct { + name string + kLines []types.KLine + want float64 + next float64 + all int + }{ + { + name: "random_case", + kLines: buildKLines(input), + want: 4.528608, + next: 4.457134, + all: 50, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + till := TILL{IntervalWindow: types.IntervalWindow{Window: 16}} + till.CalculateAndUpdate(tt.kLines) + last := till.Last(0) + assert.InDelta(t, tt.want, last, Delta) + assert.InDelta(t, tt.next, till.Index(1), Delta) + assert.Equal(t, tt.all, till.Length()) + }) + } +} diff --git a/pkg/indicator/tma.go b/pkg/indicator/tma.go new file mode 100644 index 0000000..5dfcf2e --- /dev/null +++ b/pkg/indicator/tma.go @@ -0,0 +1,50 @@ +package indicator + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// Refer: Triangular Moving Average +// Refer URL: https://ja.wikipedia.org/wiki/移動平均 +// +//go:generate callbackgen -type TMA +type TMA struct { + types.SeriesBase + types.IntervalWindow + s1 *SMA + s2 *SMA + UpdateCallbacks []func(value float64) +} + +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{Interval: inc.Interval, Window: w}} + inc.s2 = &SMA{IntervalWindow: types.IntervalWindow{Interval: inc.Interval, Window: w}} + } + + inc.s1.Update(value) + inc.s2.Update(inc.s1.Last(0)) +} + +func (inc *TMA) Last(i int) float64 { + return inc.s2.Last(i) +} + +func (inc *TMA) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *TMA) Length() int { + if inc.s2 == nil { + return 0 + } + return inc.s2.Length() +} + +var _ types.SeriesExtend = &TMA{} + +func (inc *TMA) PushK(k types.KLine) { + inc.Update(k.Close.Float64()) +} diff --git a/pkg/indicator/tma_callbacks.go b/pkg/indicator/tma_callbacks.go new file mode 100644 index 0000000..7c468f5 --- /dev/null +++ b/pkg/indicator/tma_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type TMA"; DO NOT EDIT. + +package indicator + +import () + +func (inc *TMA) OnUpdate(cb func(value float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *TMA) EmitUpdate(value float64) { + for _, cb := range inc.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/tsi.go b/pkg/indicator/tsi.go new file mode 100644 index 0000000..03b5528 --- /dev/null +++ b/pkg/indicator/tsi.go @@ -0,0 +1,100 @@ +package indicator + +import ( + "math" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// Refer: True Strength Index +// Refer URL: https://www.investopedia.com/terms/t/tsi.asp +// +//go:generate callbackgen -type TSI +type TSI struct { + types.SeriesBase + types.Interval + FastWindow int + SlowWindow int + PrevValue float64 + Values floats.Slice + Pcs *EWMA + Pcds *EWMA + Apcs *EWMA + Apcds *EWMA + updateCallbacks []func(value float64) +} + +func (inc *TSI) Update(value float64) { + if inc.Pcs == nil { + if inc.FastWindow == 0 { + inc.FastWindow = 13 + } + if inc.SlowWindow == 0 { + inc.SlowWindow = 25 + } + inc.Pcs = &EWMA{ + IntervalWindow: types.IntervalWindow{ + Window: inc.SlowWindow, + Interval: inc.Interval, + }, + } + inc.Pcds = &EWMA{ + IntervalWindow: types.IntervalWindow{ + Window: inc.FastWindow, + Interval: inc.Interval, + }, + } + inc.Apcs = &EWMA{ + IntervalWindow: types.IntervalWindow{ + Window: inc.SlowWindow, + Interval: inc.Interval, + }, + } + inc.Apcds = &EWMA{ + IntervalWindow: types.IntervalWindow{ + Window: inc.FastWindow, + Interval: inc.Interval, + }, + } + inc.SeriesBase.Series = inc + inc.PrevValue = value + return + } + pc := value - inc.PrevValue + inc.PrevValue = value + inc.Pcs.Update(pc) + apc := math.Abs(pc) + inc.Apcs.Update(apc) + + inc.Pcds.Update(inc.Pcs.Last(0)) + inc.Apcds.Update(inc.Apcs.Last(0)) + + tsi := (inc.Pcds.Last(0) / inc.Apcds.Last(0)) * 100. + inc.Values.Push(tsi) + if inc.Values.Length() > MaxNumOfEWMA { + inc.Values = inc.Values[MaxNumOfEWMATruncateSize-1:] + } +} + +func (inc *TSI) Length() int { + return inc.Values.Length() +} + +func (inc *TSI) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *TSI) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *TSI) PushK(k types.KLine) { + inc.Update(k.Close.Float64()) +} + +var _ types.SeriesExtend = &TSI{} + +func (inc *TSI) BindK(target KLineClosedEmitter, symbol string, interval types.Interval) { + target.OnKLineClosed(types.KLineWith(symbol, interval, inc.PushK)) +} diff --git a/pkg/indicator/tsi_callbacks.go b/pkg/indicator/tsi_callbacks.go new file mode 100644 index 0000000..4c5155f --- /dev/null +++ b/pkg/indicator/tsi_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type TSI"; DO NOT EDIT. + +package indicator + +import () + +func (inc *TSI) OnUpdate(cb func(value float64)) { + inc.updateCallbacks = append(inc.updateCallbacks, cb) +} + +func (inc *TSI) EmitUpdate(value float64) { + for _, cb := range inc.updateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/tsi_test.go b/pkg/indicator/tsi_test.go new file mode 100644 index 0000000..e3fe869 --- /dev/null +++ b/pkg/indicator/tsi_test.go @@ -0,0 +1,36 @@ +package indicator + +import ( + "encoding/json" + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "github.com/stretchr/testify/assert" +) + +/* +import pandas as pd + +data = pd.Series([0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9]) +ma1 = data.diff(1).ewm(span=25, adjust=False).mean() +ma2 = ma1.ewm(span=13, adjust=False).mean() +ma3 = data.diff(1).abs().ewm(span=25, adjust=False).mean() +ma4 = ma3.ewm(span=13, adjust=False).mean() +print(ma2/ma4*100.) +*/ + +func Test_TSI(t *testing.T) { + var randomPrices = []byte(`[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]`) + var input []fixedpoint.Value + if err := json.Unmarshal(randomPrices, &input); err != nil { + panic(err) + } + tsi := TSI{} + klines := buildKLines(input) + for _, kline := range klines { + tsi.PushK(kline) + } + assert.Equal(t, tsi.Length(), 29) + Delta := 1.5e-2 + assert.InDelta(t, tsi.Last(0), 22.89, Delta) +} diff --git a/pkg/indicator/utBotAlert.go b/pkg/indicator/utBotAlert.go new file mode 100644 index 0000000..e1ece6f --- /dev/null +++ b/pkg/indicator/utBotAlert.go @@ -0,0 +1,154 @@ +package indicator + +import ( + "math" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/bools" + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// based on "UT Bot Alerts by QuantNomad" from tradingview + +//go:generate callbackgen -type UtBotAlert +type UtBotAlert struct { + types.IntervalWindow + KeyValue float64 `json:"keyValue"` // Should be ATRMultiplier + + Values []types.Direction + buyValue bools.BoolSlice + sellValue bools.BoolSlice + + AverageTrueRange *ATR // Value must be set when initialized in strategy + + xATRTrailingStop floats.Slice + pos types.Direction // NB: This is currently not in use (kept in case of expanding as it is in the tradingview version) + previousPos types.Direction // NB: This is currently not in use (kept in case of expanding as it is in the tradingview version) + + previousClosePrice float64 + + EndTime time.Time + UpdateCallbacks []func(value types.Direction) +} + +func NewUtBotAlert(iw types.IntervalWindow, keyValue float64) *UtBotAlert { + return &UtBotAlert{ + IntervalWindow: iw, + KeyValue: keyValue, + AverageTrueRange: &ATR{ + IntervalWindow: iw, + }, + } +} + +func (inc *UtBotAlert) Last() types.Direction { + length := len(inc.Values) + if length > 0 { + return inc.Values[length-1] + } + return types.DirectionNone +} + +func (inc *UtBotAlert) Index(i int) types.Direction { + length := inc.Length() + if length == 0 || length-i-1 < 0 { + return 0 + } + return inc.Values[length-i-1] +} + +func (inc *UtBotAlert) Length() int { + return len(inc.Values) +} + +func (inc *UtBotAlert) Update(highPrice, lowPrice, closePrice float64) { + if inc.Window <= 0 { + panic("window must be greater than 0") + } + + // Update ATR + inc.AverageTrueRange.Update(highPrice, lowPrice, closePrice) + + nLoss := inc.AverageTrueRange.Last(0) * inc.KeyValue + + // xATRTrailingStop + if inc.xATRTrailingStop.Length() == 0 { + // For first run + inc.xATRTrailingStop.Update(0) + + } else if closePrice > inc.xATRTrailingStop.Last(1) && inc.previousClosePrice > inc.xATRTrailingStop.Last(1) { + inc.xATRTrailingStop.Update(math.Max(inc.xATRTrailingStop.Last(1), closePrice-nLoss)) + + } else if closePrice < inc.xATRTrailingStop.Last(1) && inc.previousClosePrice < inc.xATRTrailingStop.Last(1) { + inc.xATRTrailingStop.Update(math.Min(inc.xATRTrailingStop.Last(1), closePrice+nLoss)) + + } else if closePrice > inc.xATRTrailingStop.Last(1) { + inc.xATRTrailingStop.Update(closePrice - nLoss) + + } else { + inc.xATRTrailingStop.Update(closePrice + nLoss) + } + + // pos + if inc.previousClosePrice < inc.xATRTrailingStop.Last(1) && closePrice > inc.xATRTrailingStop.Last(1) { + inc.pos = types.DirectionUp + } else if inc.previousClosePrice > inc.xATRTrailingStop.Last(1) && closePrice < inc.xATRTrailingStop.Last(1) { + inc.pos = types.DirectionDown + } else { + inc.pos = inc.previousPos + } + + above := closePrice > inc.xATRTrailingStop.Last(0) && inc.previousClosePrice < inc.xATRTrailingStop.Last(1) + below := closePrice < inc.xATRTrailingStop.Last(0) && inc.previousClosePrice > inc.xATRTrailingStop.Last(1) + + buy := closePrice > inc.xATRTrailingStop.Last(0) && above // buy + sell := closePrice < inc.xATRTrailingStop.Last(0) && below // sell + + inc.buyValue.Push(buy) + inc.sellValue.Push(sell) + + if buy { + inc.Values = append(inc.Values, types.DirectionUp) + } else if sell { + inc.Values = append(inc.Values, types.DirectionDown) + } else { + inc.Values = append(inc.Values, types.DirectionNone) + } + + // Update last prices + inc.previousClosePrice = closePrice + inc.previousPos = inc.pos + +} + +// GetSignal returns signal (down, none or up) +func (inc *UtBotAlert) GetSignal() types.Direction { + length := len(inc.Values) + if length > 0 { + return inc.Values[length-1] + } + return types.DirectionNone +} + +func (inc *UtBotAlert) PushK(k types.KLine) { + if inc.EndTime != zeroTime && !k.EndTime.After(inc.EndTime) { + return + } + + inc.Update(k.GetHigh().Float64(), k.GetLow().Float64(), k.GetClose().Float64()) + inc.EndTime = k.EndTime.Time() + inc.EmitUpdate(inc.Last()) + +} + +func (inc *UtBotAlert) BindK(target KLineClosedEmitter, symbol string, interval types.Interval) { + target.OnKLineClosed(types.KLineWith(symbol, interval, inc.PushK)) +} + +// LoadK calculates the initial values +func (inc *UtBotAlert) LoadK(allKLines []types.KLine) { + for _, k := range allKLines { + inc.PushK(k) + } +} diff --git a/pkg/indicator/utBotAlert_callbacks.go b/pkg/indicator/utBotAlert_callbacks.go new file mode 100644 index 0000000..57b60b7 --- /dev/null +++ b/pkg/indicator/utBotAlert_callbacks.go @@ -0,0 +1,17 @@ +// Code generated by "callbackgen -type Supertrend"; DO NOT EDIT. + +package indicator + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func (inc *UtBotAlert) OnUpdate(cb func(value types.Direction)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *UtBotAlert) EmitUpdate(value types.Direction) { + for _, cb := range inc.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/util.go b/pkg/indicator/util.go new file mode 100644 index 0000000..ce5c209 --- /dev/null +++ b/pkg/indicator/util.go @@ -0,0 +1,15 @@ +package indicator + +func max(x, y int) int { + if x > y { + return x + } + return y +} + +func Min(x, y int) int { + if x < y { + return x + } + return y +} diff --git a/pkg/indicator/v2/adx.go b/pkg/indicator/v2/adx.go new file mode 100644 index 0000000..e4a1dbb --- /dev/null +++ b/pkg/indicator/v2/adx.go @@ -0,0 +1,72 @@ +package indicatorv2 + +import ( + "math" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type ADXStream struct { + *RMAStream + + Plus, Minus *types.Float64Series + + window int + prevHigh, prevLow fixedpoint.Value +} + +func ADX(source KLineSubscription, window int) *ADXStream { + var ( + atr = ATR2(source, window) + dmp = types.NewFloat64Series() + dmn = types.NewFloat64Series() + adx = types.NewFloat64Series() + sdmp = RMA2(dmp, window, true) + sdmn = RMA2(dmn, window, true) + s = &ADXStream{ + window: window, + Plus: types.NewFloat64Series(), + Minus: types.NewFloat64Series(), + prevHigh: fixedpoint.Zero, + prevLow: fixedpoint.Zero, + RMAStream: RMA2(adx, window, true), + } + ) + + source.AddSubscriber(func(k types.KLine) { + if s.prevHigh.IsZero() || s.prevLow.IsZero() { + s.prevHigh, s.prevLow = k.High, k.Low + return + } + + up, dn := k.High.Sub(s.prevHigh), s.prevLow.Sub(k.Low) + if up.Compare(dn) > 0 && up.Float64() > 0 { + dmp.PushAndEmit(up.Float64()) + } else { + dmp.PushAndEmit(0.0) + } + if dn.Compare(up) > 0 && dn.Float64() > 0 { + dmn.PushAndEmit(dn.Float64()) + } else { + dmn.PushAndEmit(0.0) + } + + s.Plus.PushAndEmit(sdmp.Last(0) * 100 / atr.Last(0)) + s.Minus.PushAndEmit(sdmn.Last(0) * 100 / atr.Last(0)) + dx := math.Abs(s.Plus.Last(0)-s.Minus.Last(0)) / (s.Plus.Last(0) + s.Minus.Last(0)) + if !math.IsNaN(dx) { + adx.PushAndEmit(dx * 100.0) + } else { + adx.PushAndEmit(0.0) + } + + s.prevHigh, s.prevLow = k.High, k.Low + s.Truncate() + }) + return s +} + +func (s *ADXStream) Truncate() { + s.Slice = s.Slice.Truncate(MaxNumOfRMA) +} diff --git a/pkg/indicator/v2/adx_test.go b/pkg/indicator/v2/adx_test.go new file mode 100644 index 0000000..2831a07 --- /dev/null +++ b/pkg/indicator/v2/adx_test.go @@ -0,0 +1,77 @@ +package indicatorv2 + +import ( + "encoding/json" + "math" + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +/* +import pandas as pd +import pandas_ta as ta + + data = { + "high": [40145.0, 40186.36, 40196.39, 40344.6, 40245.48, 40273.24, 40464.0, 40699.0, 40627.48, 40436.31, 40370.0, 40376.8, 40227.03, 40056.52, 39721.7, 39597.94, 39750.15, 39927.0, 40289.02, 40189.0], + "low": [39870.71, 39834.98, 39866.31, 40108.31, 40016.09, 40094.66, 40105.0, 40196.48, 40154.99, 39800.0, 39959.21, 39922.98, 39940.02, 39632.0, 39261.39, 39254.63, 39473.91, 39555.51, 39819.0, 40006.84], + "close": [40105.78, 39935.23, 40183.97, 40182.03, 40212.26, 40149.99, 40378.0, 40618.37, 40401.03, 39990.39, 40179.13, 40097.23, 40014.72, 39667.85, 39303.1, 39519.99,39693.79, 39827.96, 40074.94, 40059.84] + } + +high = pd.Series(data['high']) +low = pd.Series(data['low']) +close = pd.Series(data['close']) +result = ta.adx(high, low, close, length=7) +print(result) +*/ +func Test_ADX(t *testing.T) { + var bytes = []byte(`{ + "high": [40145.0, 40186.36, 40196.39, 40344.6, 40245.48, 40273.24, 40464.0, 40699.0, 40627.48, 40436.31, 40370.0, 40376.8, 40227.03, 40056.52, 39721.7, 39597.94, 39750.15, 39927.0, 40289.02, 40189.0], + "low": [39870.71, 39834.98, 39866.31, 40108.31, 40016.09, 40094.66, 40105.0, 40196.48, 40154.99, 39800.0, 39959.21, 39922.98, 39940.02, 39632.0, 39261.39, 39254.63, 39473.91, 39555.51, 39819.0, 40006.84], + "close": [40105.78, 39935.23, 40183.97, 40182.03, 40212.26, 40149.99, 40378.0, 40618.37, 40401.03, 39990.39, 40179.13, 40097.23, 40014.72, 39667.85, 39303.1, 39519.99,39693.79, 39827.96, 40074.94, 40059.84] + }`) + + var buildKLines = func(bytes []byte) (kLines []types.KLine) { + var prices map[string][]fixedpoint.Value + _ = json.Unmarshal(bytes, &prices) + for i, h := range prices["high"] { + kLine := types.KLine{High: h, Low: prices["low"][i], Close: prices["close"][i]} + kLines = append(kLines, kLine) + } + return kLines + } + + tests := []struct { + name string + kLines []types.KLine + window int + want float64 + }{ + { + name: "test_binance_btcusdt_1h", + kLines: buildKLines(bytes), + window: 7, + want: 34.83952, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stream := &types.StandardStream{} + + kLines := KLines(stream, "", "") + adx := ADX(kLines, tt.window) + + for _, k := range tt.kLines { + stream.EmitKLineClosed(k) + } + + got := adx.Last(0) + diff := math.Trunc((got-tt.want)*100) / 100 + if diff != 0 { + t.Errorf("ADX() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/indicator/v2/atr.go b/pkg/indicator/v2/atr.go new file mode 100644 index 0000000..da92c5c --- /dev/null +++ b/pkg/indicator/v2/atr.go @@ -0,0 +1,12 @@ +package indicatorv2 + +type ATRStream struct { + // embedded struct + *RMAStream +} + +func ATR2(source KLineSubscription, window int) *ATRStream { + tr := TR2(source) + rma := RMA2(tr, window, true) + return &ATRStream{RMAStream: rma} +} diff --git a/pkg/indicator/v2/atr_test.go b/pkg/indicator/v2/atr_test.go new file mode 100644 index 0000000..ecaa7a8 --- /dev/null +++ b/pkg/indicator/v2/atr_test.go @@ -0,0 +1,81 @@ +package indicatorv2 + +import ( + "encoding/json" + "math" + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +/* +python + +import pandas as pd +import pandas_ta as ta + + data = { + "high": [40145.0, 40186.36, 40196.39, 40344.6, 40245.48, 40273.24, 40464.0, 40699.0, 40627.48, 40436.31, 40370.0, 40376.8, 40227.03, 40056.52, 39721.7, 39597.94, 39750.15, 39927.0, 40289.02, 40189.0], + "low": [39870.71, 39834.98, 39866.31, 40108.31, 40016.09, 40094.66, 40105.0, 40196.48, 40154.99, 39800.0, 39959.21, 39922.98, 39940.02, 39632.0, 39261.39, 39254.63, 39473.91, 39555.51, 39819.0, 40006.84], + "close": [40105.78, 39935.23, 40183.97, 40182.03, 40212.26, 40149.99, 40378.0, 40618.37, 40401.03, 39990.39, 40179.13, 40097.23, 40014.72, 39667.85, 39303.1, 39519.99, + +39693.79, 39827.96, 40074.94, 40059.84] +} + +high = pd.Series(data['high']) +low = pd.Series(data['low']) +close = pd.Series(data['close']) +result = ta.atr(high, low, close, length=14) +print(result) +*/ +func Test_ATR2(t *testing.T) { + var bytes = []byte(`{ + "high": [40145.0, 40186.36, 40196.39, 40344.6, 40245.48, 40273.24, 40464.0, 40699.0, 40627.48, 40436.31, 40370.0, 40376.8, 40227.03, 40056.52, 39721.7, 39597.94, 39750.15, 39927.0, 40289.02, 40189.0], + "low": [39870.71, 39834.98, 39866.31, 40108.31, 40016.09, 40094.66, 40105.0, 40196.48, 40154.99, 39800.0, 39959.21, 39922.98, 39940.02, 39632.0, 39261.39, 39254.63, 39473.91, 39555.51, 39819.0, 40006.84], + "close": [40105.78, 39935.23, 40183.97, 40182.03, 40212.26, 40149.99, 40378.0, 40618.37, 40401.03, 39990.39, 40179.13, 40097.23, 40014.72, 39667.85, 39303.1, 39519.99, 39693.79, 39827.96, 40074.94, 40059.84] + }`) + + var buildKLines = func(bytes []byte) (kLines []types.KLine) { + var prices map[string][]fixedpoint.Value + _ = json.Unmarshal(bytes, &prices) + for i, h := range prices["high"] { + kLine := types.KLine{High: h, Low: prices["low"][i], Close: prices["close"][i]} + kLines = append(kLines, kLine) + } + return kLines + } + + tests := []struct { + name string + kLines []types.KLine + window int + want float64 + }{ + { + name: "test_binance_btcusdt_1h", + kLines: buildKLines(bytes), + window: 14, + want: 367.913903, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stream := &types.StandardStream{} + + kLines := KLines(stream, "", "") + atr := ATR2(kLines, tt.window) + + for _, k := range tt.kLines { + stream.EmitKLineClosed(k) + } + + got := atr.Last(0) + diff := math.Trunc((got-tt.want)*100) / 100 + if diff != 0 { + t.Errorf("ATR2() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/indicator/v2/atrp.go b/pkg/indicator/v2/atrp.go new file mode 100644 index 0000000..439f11d --- /dev/null +++ b/pkg/indicator/v2/atrp.go @@ -0,0 +1,23 @@ +package indicatorv2 + +import "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + +type ATRPStream struct { + *types.Float64Series +} + +func ATRP2(source KLineSubscription, window int) *ATRPStream { + s := &ATRPStream{ + Float64Series: types.NewFloat64Series(), + } + tr := TR2(source) + atr := RMA2(tr, window, true) + atr.OnUpdate(func(x float64) { + // x is the last rma + k := source.Last(0) + cloze := k.Close.Float64() + atrp := x / cloze + s.PushAndEmit(atrp) + }) + return s +} diff --git a/pkg/indicator/v2/boll.go b/pkg/indicator/v2/boll.go new file mode 100644 index 0000000..ea3551d --- /dev/null +++ b/pkg/indicator/v2/boll.go @@ -0,0 +1,57 @@ +package indicatorv2 + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type BOLLStream struct { + // the band series + *types.Float64Series + + UpBand, DownBand *types.Float64Series + + window int + k float64 + + SMA *SMAStream + StdDev *StdDevStream +} + +// BOOL2 is bollinger indicator +// the data flow: +// +// priceSource -> +// +// -> calculate SMA +// -> calculate stdDev -> calculate bandWidth -> get latest SMA -> upBand, downBand +func BOLL(source types.Float64Source, window int, k float64) *BOLLStream { + // bind these indicators before our main calculator + sma := SMA(source, window) + stdDev := StdDev(source, window) + + s := &BOLLStream{ + Float64Series: types.NewFloat64Series(), + UpBand: types.NewFloat64Series(), + DownBand: types.NewFloat64Series(), + window: window, + k: k, + SMA: sma, + StdDev: stdDev, + } + + // on band update + s.Float64Series.OnUpdate(func(band float64) { + mid := s.SMA.Last(0) + s.UpBand.PushAndEmit(mid + band) + s.DownBand.PushAndEmit(mid - band) + }) + + s.Bind(source, s) + return s +} + +func (s *BOLLStream) Calculate(v float64) float64 { + stdDev := s.StdDev.Last(0) + band := stdDev * s.k + return band +} diff --git a/pkg/indicator/v2/cci.go b/pkg/indicator/v2/cci.go new file mode 100644 index 0000000..646d455 --- /dev/null +++ b/pkg/indicator/v2/cci.go @@ -0,0 +1,65 @@ +package indicatorv2 + +import ( + "math" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// Refer: Commodity Channel Index +// Refer URL: http://www.andrewshamlet.net/2017/07/08/python-tutorial-cci +// with modification of ddof=0 to let standard deviation to be divided by N instead of N-1 +// +// CCI = (Typical Price - n-period SMA of TP) / (Constant x Mean Deviation) +// +// Typical Price (TP) = (High + Low + Close)/3 +// +// Constant = .015 +// +// The Commodity Channel Index (CCI) is a technical analysis indicator that is used to identify potential overbought or oversold conditions +// in a security's price. It was originally developed for use in commodity markets, but can be applied to any security that has a sufficient +// amount of price data. The CCI is calculated by taking the difference between the security's typical price (the average of its high, low, and +// closing prices) and its moving average, and then dividing the result by the mean absolute deviation of the typical price. This resulting value +// is then plotted as a line on the price chart, with values above +100 indicating overbought conditions and values below -100 indicating +// oversold conditions. The CCI can be used by traders to identify potential entry and exit points for trades, or to confirm other technical +// analysis signals. + +type CCIStream struct { + *types.Float64Series + + TypicalPrice *types.Float64Series + + source types.Float64Source + window int +} + +func CCI(source types.Float64Source, window int) *CCIStream { + s := &CCIStream{ + Float64Series: types.NewFloat64Series(), + TypicalPrice: types.NewFloat64Series(), + source: source, + window: window, + } + s.Bind(source, s) + return s +} + +func (s *CCIStream) Calculate(value float64) float64 { + var tp = value + if s.TypicalPrice.Length() > 0 { + tp = s.TypicalPrice.Last(0) - s.source.Last(s.window) + value + } + + s.TypicalPrice.Push(tp) + + ma := tp / float64(s.window) + md := 0. + for i := 0; i < s.window; i++ { + diff := s.source.Last(i) - ma + md += diff * diff + } + + md = math.Sqrt(md / float64(s.window)) + cci := (value - ma) / (0.015 * md) + return cci +} diff --git a/pkg/indicator/v2/cci_test.go b/pkg/indicator/v2/cci_test.go new file mode 100644 index 0000000..a662553 --- /dev/null +++ b/pkg/indicator/v2/cci_test.go @@ -0,0 +1,39 @@ +package indicatorv2 + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +/* +python: + +import pandas as pd +s = pd.Series([0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9]) +cci = pd.Series((s - s.rolling(16).mean()) / (0.015 * s.rolling(16).std(ddof=0)), name="CCI") +print(cci) +*/ +func Test_CCI(t *testing.T) { + var randomPrices = []byte(`[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]`) + var input []float64 + var delta = 4.3e-2 + if err := json.Unmarshal(randomPrices, &input); err != nil { + panic(err) + } + t.Run("random_case", func(t *testing.T) { + price := Price(nil, nil) + cci := CCI(price, 16) + for _, value := range input { + price.PushAndEmit(value) + } + + t.Logf("cci: %+v", cci.Slice) + + last := cci.Last(0) + assert.InDelta(t, 93.250481, last, delta) + assert.InDelta(t, 81.813449, cci.Index(1), delta) + assert.Equal(t, 50, cci.Length(), "length") + }) +} diff --git a/pkg/indicator/v2/cma.go b/pkg/indicator/v2/cma.go new file mode 100644 index 0000000..2eecef5 --- /dev/null +++ b/pkg/indicator/v2/cma.go @@ -0,0 +1,28 @@ +package indicatorv2 + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type CMAStream struct { + *types.Float64Series +} + +func CMA2(source types.Float64Source) *CMAStream { + s := &CMAStream{ + Float64Series: types.NewFloat64Series(), + } + s.Bind(source, s) + return s +} + +func (s *CMAStream) Calculate(x float64) float64 { + l := float64(s.Slice.Length()) + cma := (s.Slice.Last(0)*l + x) / (l + 1.) + return cma +} + +func (s *CMAStream) Truncate() { + s.Slice.Truncate(indicator.MaxNumOfEWMA) +} diff --git a/pkg/indicator/v2/const.go b/pkg/indicator/v2/const.go new file mode 100644 index 0000000..3f8ef0c --- /dev/null +++ b/pkg/indicator/v2/const.go @@ -0,0 +1,5 @@ +package indicatorv2 + +import "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + +var three = fixedpoint.NewFromInt(3) diff --git a/pkg/indicator/v2/cross.go b/pkg/indicator/v2/cross.go new file mode 100644 index 0000000..d62b475 --- /dev/null +++ b/pkg/indicator/v2/cross.go @@ -0,0 +1,60 @@ +package indicatorv2 + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type CrossType float64 + +const ( + CrossOver CrossType = 1.0 + CrossUnder CrossType = -1.0 +) + +// CrossStream subscribes 2 upstreams, and calculate the cross signal +type CrossStream struct { + *types.Float64Series + + a, b floats.Slice +} + +// Cross creates the CrossStream object: +// +// cross := Cross(fastEWMA, slowEWMA) +func Cross(a, b types.Float64Source) *CrossStream { + s := &CrossStream{ + Float64Series: types.NewFloat64Series(), + } + a.OnUpdate(func(v float64) { + s.a.Push(v) + s.calculate() + }) + b.OnUpdate(func(v float64) { + s.b.Push(v) + s.calculate() + }) + return s +} + +func (s *CrossStream) calculate() { + if s.a.Length() != s.b.Length() { + return + } + + current := s.a.Last(0) - s.b.Last(0) + previous := s.a.Last(1) - s.b.Last(1) + + if previous == 0.0 { + return + } + + // cross over or cross under + if current*previous < 0 { + if current > 0 { + s.PushAndEmit(float64(CrossOver)) + } else { + s.PushAndEmit(float64(CrossUnder)) + } + } +} diff --git a/pkg/indicator/v2/ewma.go b/pkg/indicator/v2/ewma.go new file mode 100644 index 0000000..4c4c4eb --- /dev/null +++ b/pkg/indicator/v2/ewma.go @@ -0,0 +1,30 @@ +package indicatorv2 + +import "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + +type EWMAStream struct { + *types.Float64Series + + window int + multiplier float64 +} + +func EWMA2(source types.Float64Source, window int) *EWMAStream { + s := &EWMAStream{ + Float64Series: types.NewFloat64Series(), + window: window, + multiplier: 2.0 / float64(1+window), + } + s.Bind(source, s) + return s +} + +func (s *EWMAStream) Calculate(v float64) float64 { + last := s.Slice.Last(0) + if last == 0.0 { + return v + } + + m := s.multiplier + return (1.0-m)*last + m*v +} diff --git a/pkg/indicator/v2/keltner.go b/pkg/indicator/v2/keltner.go new file mode 100644 index 0000000..715e174 --- /dev/null +++ b/pkg/indicator/v2/keltner.go @@ -0,0 +1,61 @@ +package indicatorv2 + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type KeltnerStream struct { + types.SeriesBase + + window, atrLength int + + EWMA *EWMAStream + StdDev *StdDevStream + ATR *ATRStream + + highPrices, lowPrices, closePrices *PriceStream + + Mid *types.Float64Series + FirstUpperBand, FirstLowerBand *types.Float64Series + SecondUpperBand, SecondLowerBand *types.Float64Series + ThirdUpperBand, ThirdLowerBand *types.Float64Series +} + +func Keltner(source KLineSubscription, window, atrLength int) *KeltnerStream { + atr := ATR2(source, atrLength) + + highPrices := HighPrices(source) + lowPrices := LowPrices(source) + closePrices := ClosePrices(source) + ewma := EWMA2(closePrices, window) + + s := &KeltnerStream{ + window: window, + atrLength: atrLength, + highPrices: highPrices, + lowPrices: lowPrices, + closePrices: closePrices, + ATR: atr, + EWMA: ewma, + Mid: types.NewFloat64Series(), + FirstUpperBand: types.NewFloat64Series(), + FirstLowerBand: types.NewFloat64Series(), + SecondUpperBand: types.NewFloat64Series(), + SecondLowerBand: types.NewFloat64Series(), + ThirdUpperBand: types.NewFloat64Series(), + ThirdLowerBand: types.NewFloat64Series(), + } + + source.AddSubscriber(func(kLine types.KLine) { + mid := s.EWMA.Last(0) + atr := s.ATR.Last(0) + s.Mid.PushAndEmit(mid) + s.FirstUpperBand.PushAndEmit(mid + atr) + s.FirstLowerBand.PushAndEmit(mid - atr) + s.SecondUpperBand.PushAndEmit(mid + 2*atr) + s.SecondLowerBand.PushAndEmit(mid - 2*atr) + s.ThirdUpperBand.PushAndEmit(mid + 3*atr) + s.ThirdLowerBand.PushAndEmit(mid - 3*atr) + }) + return s +} diff --git a/pkg/indicator/v2/klines.go b/pkg/indicator/v2/klines.go new file mode 100644 index 0000000..9526dc3 --- /dev/null +++ b/pkg/indicator/v2/klines.go @@ -0,0 +1,68 @@ +package indicatorv2 + +import "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + +const MaxNumOfKLines = 4_000 + +//go:generate callbackgen -type KLineStream +type KLineStream struct { + updateCallbacks []func(k types.KLine) + + kLines []types.KLine +} + +func (s *KLineStream) Length() int { + return len(s.kLines) +} + +func (s *KLineStream) Last(i int) *types.KLine { + l := len(s.kLines) + if i < 0 || l-1-i < 0 { + return nil + } + + return &s.kLines[l-1-i] +} + +// AddSubscriber adds the subscriber function and push historical data to the subscriber +func (s *KLineStream) AddSubscriber(f func(k types.KLine)) { + s.OnUpdate(f) + + if len(s.kLines) == 0 { + return + } + + // push historical klines to the subscriber + for _, k := range s.kLines { + f(k) + } +} + +func (s *KLineStream) BackFill(kLines []types.KLine) { + for _, k := range kLines { + s.kLines = append(s.kLines, k) + s.EmitUpdate(k) + } +} + +// KLines creates a KLine stream that pushes the klines to the subscribers +func KLines(source types.Stream, symbol string, interval types.Interval) *KLineStream { + s := &KLineStream{} + + source.OnKLineClosed(types.KLineWith(symbol, interval, func(k types.KLine) { + s.kLines = append(s.kLines, k) + s.EmitUpdate(k) + + if len(s.kLines) > MaxNumOfKLines { + s.kLines = s.kLines[len(s.kLines)-1-MaxNumOfKLines:] + } + })) + + return s +} + +type KLineSubscription interface { + AddSubscriber(f func(k types.KLine)) + Length() int + Last(i int) *types.KLine +} diff --git a/pkg/indicator/v2/klinestream_callbacks.go b/pkg/indicator/v2/klinestream_callbacks.go new file mode 100644 index 0000000..98b732b --- /dev/null +++ b/pkg/indicator/v2/klinestream_callbacks.go @@ -0,0 +1,17 @@ +// Code generated by "callbackgen -type KLineStream"; DO NOT EDIT. + +package indicatorv2 + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func (s *KLineStream) OnUpdate(cb func(k types.KLine)) { + s.updateCallbacks = append(s.updateCallbacks, cb) +} + +func (s *KLineStream) EmitUpdate(k types.KLine) { + for _, cb := range s.updateCallbacks { + cb(k) + } +} diff --git a/pkg/indicator/v2/macd.go b/pkg/indicator/v2/macd.go new file mode 100644 index 0000000..efc666e --- /dev/null +++ b/pkg/indicator/v2/macd.go @@ -0,0 +1,33 @@ +package indicatorv2 + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type MACDStream struct { + *SubtractStream + + shortWindow, longWindow, signalWindow int + + FastEWMA, SlowEWMA, Signal *EWMAStream + Histogram *SubtractStream +} + +func MACD2(source types.Float64Source, shortWindow, longWindow, signalWindow int) *MACDStream { + // bind and calculate these first + fastEWMA := EWMA2(source, shortWindow) + slowEWMA := EWMA2(source, longWindow) + macd := Subtract(fastEWMA, slowEWMA) + signal := EWMA2(macd, signalWindow) + histogram := Subtract(macd, signal) + return &MACDStream{ + SubtractStream: macd, + shortWindow: shortWindow, + longWindow: longWindow, + signalWindow: signalWindow, + FastEWMA: fastEWMA, + SlowEWMA: slowEWMA, + Signal: signal, + Histogram: histogram, + } +} diff --git a/pkg/indicator/v2/macd_test.go b/pkg/indicator/v2/macd_test.go new file mode 100644 index 0000000..cc744ba --- /dev/null +++ b/pkg/indicator/v2/macd_test.go @@ -0,0 +1,65 @@ +package indicatorv2 + +import ( + "encoding/json" + "math" + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +/* +python: + +import pandas as pd +s = pd.Series([0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9]) +slow = s.ewm(span=26, adjust=False).mean() +fast = s.ewm(span=12, adjust=False).mean() +print(fast - slow) +*/ + +func buildKLines(prices []fixedpoint.Value) (klines []types.KLine) { + for _, p := range prices { + klines = append(klines, types.KLine{Close: p}) + } + + return klines +} + +func Test_MACD2(t *testing.T) { + var randomPrices = []byte(`[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]`) + var input []fixedpoint.Value + err := json.Unmarshal(randomPrices, &input) + assert.NoError(t, err) + + tests := []struct { + name string + kLines []types.KLine + want float64 + }{ + { + name: "random_case", + kLines: buildKLines(input), + want: 0.7740187187598249, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prices := ClosePrices(nil) + macd := MACD2(prices, 12, 26, 9) + for _, k := range tt.kLines { + prices.EmitUpdate(k.Close.Float64()) + } + + got := macd.Last(0) + diff := math.Trunc((got-tt.want)*100) / 100 + if diff != 0 { + t.Errorf("MACD2() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/indicator/v2/multiply.go b/pkg/indicator/v2/multiply.go new file mode 100644 index 0000000..525fa2e --- /dev/null +++ b/pkg/indicator/v2/multiply.go @@ -0,0 +1,45 @@ +package indicatorv2 + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type MultiplyStream struct { + *types.Float64Series + a, b floats.Slice +} + +func Multiply(a, b types.Float64Source) *MultiplyStream { + s := &MultiplyStream{ + Float64Series: types.NewFloat64Series(), + } + + a.OnUpdate(func(v float64) { + s.a.Push(v) + s.calculate() + }) + b.OnUpdate(func(v float64) { + s.b.Push(v) + s.calculate() + }) + + return s +} + +func (s *MultiplyStream) calculate() { + if s.a.Length() != s.b.Length() { + return + } + + if s.a.Length() > s.Slice.Length() { + var numNewElems = s.a.Length() - s.Slice.Length() + var tailA = s.a.Tail(numNewElems) + var tailB = s.b.Tail(numNewElems) + var tailC = tailA.Mul(tailB) + for _, f := range tailC { + s.Slice.Push(f) + s.EmitUpdate(f) + } + } +} diff --git a/pkg/indicator/v2/pivothigh.go b/pkg/indicator/v2/pivothigh.go new file mode 100644 index 0000000..22ca42d --- /dev/null +++ b/pkg/indicator/v2/pivothigh.go @@ -0,0 +1,39 @@ +package indicatorv2 + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type PivotHighStream struct { + *types.Float64Series + rawValues floats.Slice + window, rightWindow int +} + +func PivotHigh(source types.Float64Source, window int, args ...int) *PivotHighStream { + rightWindow := window + if len(args) > 0 { + rightWindow = args[0] + } + + s := &PivotHighStream{ + Float64Series: types.NewFloat64Series(), + window: window, + rightWindow: rightWindow, + } + + s.Subscribe(source, func(x float64) { + s.rawValues.Push(x) + if low, ok := s.calculatePivotHigh(s.rawValues, s.window, s.rightWindow); ok { + s.PushAndEmit(low) + } + }) + return s +} + +func (s *PivotHighStream) calculatePivotHigh(highs floats.Slice, left, right int) (float64, bool) { + return floats.FindPivot(highs, left, right, func(a, pivot float64) bool { + return a < pivot + }) +} diff --git a/pkg/indicator/v2/pivotlow.go b/pkg/indicator/v2/pivotlow.go new file mode 100644 index 0000000..3283699 --- /dev/null +++ b/pkg/indicator/v2/pivotlow.go @@ -0,0 +1,39 @@ +package indicatorv2 + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type PivotLowStream struct { + *types.Float64Series + rawValues floats.Slice + window, rightWindow int +} + +func PivotLow(source types.Float64Source, window int, args ...int) *PivotLowStream { + rightWindow := window + if len(args) > 0 { + rightWindow = args[0] + } + + s := &PivotLowStream{ + Float64Series: types.NewFloat64Series(), + window: window, + rightWindow: rightWindow, + } + + s.Subscribe(source, func(x float64) { + s.rawValues.Push(x) + if low, ok := s.calculatePivotLow(s.rawValues, s.window, s.rightWindow); ok { + s.PushAndEmit(low) + } + }) + return s +} + +func (s *PivotLowStream) calculatePivotLow(lows floats.Slice, left, right int) (float64, bool) { + return floats.FindPivot(lows, left, right, func(a, pivot float64) bool { + return a > pivot + }) +} diff --git a/pkg/indicator/v2/price.go b/pkg/indicator/v2/price.go new file mode 100644 index 0000000..e307168 --- /dev/null +++ b/pkg/indicator/v2/price.go @@ -0,0 +1,71 @@ +package indicatorv2 + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type PriceStream struct { + *types.Float64Series + + mapper types.KLineValueMapper +} + +func Price(source KLineSubscription, mapper types.KLineValueMapper) *PriceStream { + s := &PriceStream{ + Float64Series: types.NewFloat64Series(), + mapper: mapper, + } + + if source == nil { + return s + } + + source.AddSubscriber(func(k types.KLine) { + v := s.mapper(k) + s.PushAndEmit(v) + }) + return s +} + +// AddSubscriber adds the subscriber function and push historical data to the subscriber +func (s *PriceStream) AddSubscriber(f func(v float64)) { + s.OnUpdate(f) + + if len(s.Slice) == 0 { + return + } + + // push historical value to the subscriber + for _, v := range s.Slice { + f(v) + } +} + +func (s *PriceStream) PushAndEmit(v float64) { + s.Slice.Push(v) + s.EmitUpdate(v) +} + +func ClosePrices(source KLineSubscription) *PriceStream { + return Price(source, types.KLineClosePriceMapper) +} + +func LowPrices(source KLineSubscription) *PriceStream { + return Price(source, types.KLineLowPriceMapper) +} + +func HighPrices(source KLineSubscription) *PriceStream { + return Price(source, types.KLineHighPriceMapper) +} + +func OpenPrices(source KLineSubscription) *PriceStream { + return Price(source, types.KLineOpenPriceMapper) +} + +func Volumes(source KLineSubscription) *PriceStream { + return Price(source, types.KLineVolumeMapper) +} + +func HLC3(source KLineSubscription) *PriceStream { + return Price(source, types.KLineHLC3Mapper) +} diff --git a/pkg/indicator/v2/rma.go b/pkg/indicator/v2/rma.go new file mode 100644 index 0000000..86fc0d9 --- /dev/null +++ b/pkg/indicator/v2/rma.go @@ -0,0 +1,63 @@ +package indicatorv2 + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const MaxNumOfRMA = 1000 +const MaxNumOfRMATruncateSize = 500 + +type RMAStream struct { + // embedded structs + *types.Float64Series + + // config fields + Adjust bool + + window int + counter int + sum, previous float64 +} + +func RMA2(source types.Float64Source, window int, adjust bool) *RMAStream { + checkWindow(window) + + s := &RMAStream{ + Float64Series: types.NewFloat64Series(), + window: window, + Adjust: adjust, + } + + s.Bind(source, s) + return s +} + +func (s *RMAStream) Calculate(x float64) float64 { + lambda := 1 / float64(s.window) + if s.counter == 0 { + s.sum = 1 + s.previous = x + } else { + if s.Adjust { + s.sum = s.sum*(1-lambda) + 1 + s.previous = s.previous + (x-s.previous)/s.sum + } else { + s.previous = s.previous*(1-lambda) + x*lambda + } + } + s.counter++ + + return s.previous +} + +func (s *RMAStream) Truncate() { + if len(s.Slice) > MaxNumOfRMA { + s.Slice = s.Slice[MaxNumOfRMATruncateSize-1:] + } +} + +func checkWindow(window int) { + if window == 0 { + panic("window can not be zero") + } +} diff --git a/pkg/indicator/v2/rma_test.go b/pkg/indicator/v2/rma_test.go new file mode 100644 index 0000000..b06605f --- /dev/null +++ b/pkg/indicator/v2/rma_test.go @@ -0,0 +1,65 @@ +package indicatorv2 + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +/* +python + +import pandas as pd +import pandas_ta as ta + +data = [40105.78, 39935.23, 40183.97, 40182.03, 40212.26, 40149.99, 40378.0, 40618.37, 40401.03, 39990.39, 40179.13, 40097.23, 40014.72, 39667.85, 39303.1, 39519.99, 39693.79, 39827.96, 40074.94, 40059.84] + +close = pd.Series(data) +result = ta.rma(close, length=14) +print(result) +*/ +func Test_RMA2(t *testing.T) { + var bytes = []byte(`[40105.78, 39935.23, 40183.97, 40182.03, 40212.26, 40149.99, 40378.0, 40618.37, 40401.03, 39990.39, 40179.13, 40097.23, 40014.72, 39667.85, 39303.1, 39519.99, 39693.79, 39827.96, 40074.94, 40059.84]`) + var values []float64 + err := json.Unmarshal(bytes, &values) + assert.NoError(t, err) + + prices := ClosePrices(nil) + for _, v := range values { + prices.Push(v) + } + + tests := []struct { + name string + window int + want []float64 + }{ + { + name: "test_binance_btcusdt_1h", + window: 14, + want: []float64{ + 40129.841000, + 40041.830291, + 39988.157743, + 39958.803719, + 39946.115094, + 39958.296741, + 39967.681562, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rma := RMA2(prices, tt.window, true) + if assert.Equal(t, len(tt.want), len(rma.Slice)-tt.window+1) { + for i, v := range tt.want { + j := tt.window - 1 + i + got := rma.Slice[j] + assert.InDelta(t, v, got, 0.01, "Expected rma.slice[%d] to be %v, but got %v", j, v, got) + } + } + }) + } +} diff --git a/pkg/indicator/v2/rsi.go b/pkg/indicator/v2/rsi.go new file mode 100644 index 0000000..a47998f --- /dev/null +++ b/pkg/indicator/v2/rsi.go @@ -0,0 +1,62 @@ +package indicatorv2 + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type RSIStream struct { + // embedded structs + *types.Float64Series + + // config fields + window int + + // private states + source types.Float64Source +} + +func RSI2(source types.Float64Source, window int) *RSIStream { + s := &RSIStream{ + source: source, + Float64Series: types.NewFloat64Series(), + window: window, + } + s.Bind(source, s) + return s +} + +func (s *RSIStream) Calculate(_ float64) float64 { + var gainSum, lossSum float64 + var sourceLen = s.source.Length() + var limit = min(s.window, sourceLen) + for i := 0; i < limit; i++ { + value := s.source.Last(i) + prev := s.source.Last(i + 1) + change := value - prev + if change >= 0 { + gainSum += change + } else { + lossSum += -change + } + } + + avgGain := gainSum / float64(limit) + avgLoss := lossSum / float64(limit) + rs := avgGain / avgLoss + rsi := 100.0 - (100.0 / (1.0 + rs)) + return rsi +} + +func max(x, y int) int { + if x > y { + return x + } + return y +} + +func min(x, y int) int { + if x < y { + return x + } + return y +} diff --git a/pkg/indicator/v2/rsi_test.go b/pkg/indicator/v2/rsi_test.go new file mode 100644 index 0000000..6459637 --- /dev/null +++ b/pkg/indicator/v2/rsi_test.go @@ -0,0 +1,87 @@ +package indicatorv2 + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" +) + +func Test_RSI2(t *testing.T) { + // test case from https://school.stockcharts.com/doku.php?id=technical_indicators:relative_strength_index_rsi + var data = []byte(`[44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08, 45.89, 46.03, 45.61, 46.28, 46.28, 46.00, 46.03, 46.41, 46.22, 45.64, 46.21, 46.25, 45.71, 46.45, 45.78, 45.35, 44.03, 44.18, 44.22, 44.57, 43.42, 42.66, 43.13]`) + var values []float64 + err := json.Unmarshal(data, &values) + assert.NoError(t, err) + + tests := []struct { + name string + values []float64 + window int + want floats.Slice + }{ + { + name: "RSI", + values: values, + window: 14, + want: floats.Slice{ + 100.000000, + 99.439336, + 99.440090, + 98.251826, + 98.279242, + 98.297781, + 98.307626, + 98.319149, + 98.334036, + 98.342426, + 97.951933, + 97.957908, + 97.108036, + 97.147514, + 70.464135, + 70.020964, + 69.831224, + 80.567686, + 73.333333, + 59.806295, + 62.528217, + 60.000000, + 48.477752, + 53.878407, + 48.952381, + 43.862816, + 37.732919, + 32.263514, + 32.718121, + 38.142620, + 31.748252, + 25.099602, + 30.217670, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // RSI2() + prices := ClosePrices(nil) + rsi := RSI2(prices, tt.window) + + t.Logf("data length: %d", len(tt.values)) + for _, price := range tt.values { + prices.PushAndEmit(price) + } + + assert.Equal(t, floats.Slice(tt.values), prices.Slice) + + if assert.Equal(t, len(tt.want), len(rsi.Slice)) { + for i, v := range tt.want { + assert.InDelta(t, v, rsi.Slice[i], 0.000001, "Expected rsi.slice[%d] to be %v, but got %v", i, v, rsi.Slice[i]) + } + } + }) + } +} diff --git a/pkg/indicator/v2/sma.go b/pkg/indicator/v2/sma.go new file mode 100644 index 0000000..daf3a9d --- /dev/null +++ b/pkg/indicator/v2/sma.go @@ -0,0 +1,33 @@ +package indicatorv2 + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const MaxNumOfSMA = 5_000 + +type SMAStream struct { + *types.Float64Series + window int + rawValues *types.Queue +} + +func SMA(source types.Float64Source, window int) *SMAStream { + s := &SMAStream{ + Float64Series: types.NewFloat64Series(), + window: window, + rawValues: types.NewQueue(window), + } + s.Bind(source, s) + return s +} + +func (s *SMAStream) Calculate(v float64) float64 { + s.rawValues.Update(v) + sma := s.rawValues.Mean(s.window) + return sma +} + +func (s *SMAStream) Truncate() { + s.Slice = s.Slice.Truncate(MaxNumOfSMA) +} diff --git a/pkg/indicator/v2/sma_test.go b/pkg/indicator/v2/sma_test.go new file mode 100644 index 0000000..f6e7630 --- /dev/null +++ b/pkg/indicator/v2/sma_test.go @@ -0,0 +1,22 @@ +package indicatorv2 + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func TestSMA(t *testing.T) { + source := types.NewFloat64Series() + sma := SMA(source, 9) + + data := []float64{1, 2, 3, 4, 5, 6, 7, 8, 9} + for _, d := range data { + source.PushAndEmit(d) + } + + assert.InDelta(t, 5, sma.Last(0), 0.001) + assert.InDelta(t, 4.5, sma.Last(1), 0.001) +} diff --git a/pkg/indicator/v2/smma.go b/pkg/indicator/v2/smma.go new file mode 100644 index 0000000..96b7128 --- /dev/null +++ b/pkg/indicator/v2/smma.go @@ -0,0 +1,47 @@ +package indicatorv2 + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type SMMAStream struct { + *types.Float64Series + window int + rawValues *types.Queue + source types.Float64Source +} + +func SMMA2(source types.Float64Source, window int) *SMMAStream { + s := &SMMAStream{ + Float64Series: types.NewFloat64Series(), + window: window, + rawValues: types.NewQueue(window), + source: source, + } + s.Bind(source, s) + return s +} + +func (s *SMMAStream) Calculate(v float64) float64 { + var out float64 + sourceLen := s.source.Length() + + if sourceLen < s.window { + // Until we reach the end of the period, sum the prices. + + // First, calculate the sum, and it will be automatically saved too. + s.rawValues.Sum(s.window) + // Then save the input value to use it later on. + s.rawValues.Update(v) + } else if sourceLen == s.window { + // We need the SMA for the first time. + s.rawValues.Update(v) + out = s.rawValues.Mean(s.window) + } else { + // For all the rest values, just use the formula. + last := s.Slice.Last(0) + out = (last*float64((s.window-1.0)) + v) / float64(s.window) + } + + return out +} diff --git a/pkg/indicator/v2/smma_test.go b/pkg/indicator/v2/smma_test.go new file mode 100644 index 0000000..6dce260 --- /dev/null +++ b/pkg/indicator/v2/smma_test.go @@ -0,0 +1,27 @@ +package indicatorv2 + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func TestSMMA(t *testing.T) { + source := types.NewFloat64Series() + smma := SMMA2(source, 3) + + data := []float64{10, 20, 30, 40, 50, 60, 70, 80, 90} + for _, d := range data { + source.PushAndEmit(d) + } + + // Assert the first 3 and last 3 value outputs. + assert.InDelta(t, 0, smma.Last(len(data)-1), 0.001) + assert.InDelta(t, 0, smma.Last(len(data)-2), 0.001) + assert.InDelta(t, 20, smma.Last(len(data)-3), 0.001) + assert.InDelta(t, 51.97530864197531, smma.Last(2), 0.001) + assert.InDelta(t, 61.31687242798355, smma.Last(1), 0.001) + assert.InDelta(t, 70.87791495198904, smma.Last(0), 0.001) +} diff --git a/pkg/indicator/v2/stddev.go b/pkg/indicator/v2/stddev.go new file mode 100644 index 0000000..6afe60c --- /dev/null +++ b/pkg/indicator/v2/stddev.go @@ -0,0 +1,28 @@ +package indicatorv2 + +import "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + +type StdDevStream struct { + *types.Float64Series + + rawValues *types.Queue + + window int + multiplier float64 +} + +func StdDev(source types.Float64Source, window int) *StdDevStream { + s := &StdDevStream{ + Float64Series: types.NewFloat64Series(), + rawValues: types.NewQueue(window), + window: window, + } + s.Bind(source, s) + return s +} + +func (s *StdDevStream) Calculate(x float64) float64 { + s.rawValues.Update(x) + var std = s.rawValues.Stdev() + return std +} diff --git a/pkg/indicator/v2/stoch.go b/pkg/indicator/v2/stoch.go new file mode 100644 index 0000000..339f72c --- /dev/null +++ b/pkg/indicator/v2/stoch.go @@ -0,0 +1,64 @@ +package indicatorv2 + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const DPeriod int = 3 + +// Stochastic Oscillator +// - https://www.investopedia.com/terms/s/stochasticoscillator.asp +// +// The Stochastic Oscillator is a technical analysis indicator that is used to identify potential overbought or oversold conditions +// in a security's price. It is calculated by taking the current closing price of the security and comparing it to the high and low prices +// over a specified period of time. This comparison is then plotted as a line on the price chart, with values above 80 indicating overbought +// conditions and values below 20 indicating oversold conditions. The Stochastic Oscillator can be used by traders to identify potential +// entry and exit points for trades, or to confirm other technical analysis signals. It is typically used in conjunction with other indicators +// to provide a more comprehensive view of the security's price. + +//go:generate callbackgen -type StochStream +type StochStream struct { + types.SeriesBase + + K, D floats.Slice + + window int + dPeriod int + + highPrices, lowPrices *PriceStream + + updateCallbacks []func(k, d float64) +} + +// Stochastic Oscillator +func Stoch(source KLineSubscription, window, dPeriod int) *StochStream { + highPrices := HighPrices(source) + lowPrices := LowPrices(source) + + s := &StochStream{ + window: window, + dPeriod: dPeriod, + highPrices: highPrices, + lowPrices: lowPrices, + } + + source.AddSubscriber(func(kLine types.KLine) { + lowest := s.lowPrices.Slice.Tail(s.window).Min() + highest := s.highPrices.Slice.Tail(s.window).Max() + + var k = 50.0 + var d = 0.0 + + if highest != lowest { + k = 100.0 * (kLine.Close.Float64() - lowest) / (highest - lowest) + } + + s.K.Push(k) + + d = s.K.Tail(s.dPeriod).Mean() + s.D.Push(d) + s.EmitUpdate(k, d) + }) + return s +} diff --git a/pkg/indicator/v2/stoch_test.go b/pkg/indicator/v2/stoch_test.go new file mode 100644 index 0000000..3b6b391 --- /dev/null +++ b/pkg/indicator/v2/stoch_test.go @@ -0,0 +1,80 @@ +package indicatorv2 + +import ( + "encoding/json" + "math" + "testing" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +/* +python +import pandas as pd +import pandas_ta as ta + +klines = ... +df = pd.DataFrame(klines, columns=['open', 'high', 'low', 'close', 'volume']) + + print(df.ta.stoch(df['high'], df['low'], df['close'], k=14, d=3, smooth_k=1)) +*/ +func TestSTOCH2_update(t *testing.T) { + open := []byte(`[8273.0, 8280.0, 8280.0, 8275.0, 8281.0, 8277.0, 8279.0, 8280.0, 8284.0, 8286.0, 8283.0, 8283.0, 8284.0, 8286.0, 8285.0, 8287.0, 8289.0, 8282.0, 8286.0, 8279.0, 8275.0, 8276.0, 8276.0, 8281.0, 8269.0, 8256.0, 8258.0, 8252.0, 8241.0, 8232.0, 8218.0, 8221.0, 8216.0, 8210.0, 8212.0, 8201.0, 8197.0, 8200.0, 8193.0, 8181.0, 8185.0, 8190.0, 8184.0, 8185.0, 8163.0, 8153.0, 8162.0, 8165.0, 8162.0, 8157.0, 8159.0, 8141.0, 8140.0, 8141.0, 8130.0, 8144.0, 8141.0, 8148.0, 8145.0, 8134.0, 8123.0, 8127.0, 8130.0, 8125.0, 8122.0, 8105.0, 8096.0, 8103.0, 8102.0, 8110.0, 8104.0, 8109.0, 8103.0, 8111.0, 8112.0, 8109.0, 8092.0, 8100.0, 8101.0, 8100.0, 8096.0, 8095.0, 8094.0, 8101.0, 8095.0, 8069.0, 8067.0, 8070.0, 8069.0, 8066.0, 8047.0, 8046.0, 8042.0, 8039.0, 8049.0, 8055.0, 8063.0, 8061.0, 8056.0, 8057.0, 8056.0, 8057.0, 8057.0, 8054.0, 8056.0, 8056.0, 8065.0, 8065.0, 8070.0, 8065.0, 8064.0, 8063.0, 8060.0, 8065.0, 8068.0, 8068.0, 8069.0, 8073.0, 8073.0, 8084.0, 8084.0, 8076.0, 8074.0, 8074.0, 8074.0, 8078.0, 8080.0, 8082.0, 8085.0, 8083.0, 8087.0, 8087.0, 8083.0, 8083.0, 8082.0, 8074.0, 8074.0, 8071.0, 8071.0, 8072.0, 8075.0, 8075.0, 8076.0, 8073.0, 8071.0, 8070.0, 8075.0, 8078.0, 8077.0, 8075.0, 8073.0, 8079.0, 8084.0, 8082.0, 8085.0, 8085.0, 8085.0, 8101.0, 8106.0, 8113.0, 8109.0, 8104.0, 8105.0, 8105.0, 8107.0, 8106.0, 8104.0, 8106.0, 8106.0, 8110.0, 8107.0, 8110.0, 8111.0, 8104.0, 8098.0, 8098.0, 8098.0, 8098.0, 8094.0, 8097.0, 8096.0, 8099.0, 8098.0, 8099.0, 8098.0, 8095.0, 8096.0, 8086.0, 8088.0, 8093.0, 8092.0, 8096.0, 8100.0, 8104.0, 8104.0, 8108.0, 8107.0, 8103.0, 8104.0, 8110.0, 8105.0, 8102.0, 8104.0, 8096.0, 8099.0, 8103.0, 8102.0, 8108.0, 8107.0, 8107.0, 8104.0, 8095.0, 8091.0, 8092.0, 8090.0, 8093.0, 8093.0, 8094.0, 8095.0, 8096.0, 8088.0, 8090.0, 8079.0, 8077.0, 8079.0, 8081.0, 8083.0, 8084.0, 8084.0, 8087.0, 8091.0, 8089.0, 8089.0, 8091.0, 8087.0, 8093.0, 8090.0, 8090.0, 8095.0, 8093.0, 8088.0, 8087.0, 8090.0, 8089.0, 8087.0, 8084.0, 8087.0, 8084.0, 8080.0, 8078.0, 8077.0, 8077.0, 8076.0, 8072.0, 8072.0, 8075.0, 8076.0, 8074.0, 8077.0, 8081.0, 8080.0, 8076.0, 8075.0, 8077.0, 8080.0, 8077.0, 8076.0, 8076.0, 8070.0, 8071.0, 8070.0, 8073.0, 8069.0, 8069.0, 8068.0, 8072.0, 8078.0, 8077.0, 8079.0, 8081.0, 8076.0, 8076.0, 8077.0, 8077.0, 8078.0, 8075.0, 8066.0, 8064.0, 8064.0, 8062.0, 8062.0, 8065.0, 8062.0, 8063.0, 8074.0, 8070.0, 8069.0, 8068.0, 8074.0, 8075.0]`) + high := []byte(`[8279.0, 8282.0, 8280.0, 8280.0, 8284.0, 8284.0, 8280.0, 8282.0, 8284.0, 8289.0, 8288.0, 8285.0, 8284.0, 8287.0, 8286.0, 8294.0, 8290.0, 8292.0, 8289.0, 8288.0, 8278.0, 8279.0, 8279.0, 8284.0, 8282.0, 8270.0, 8261.0, 8260.0, 8252.0, 8244.0, 8233.0, 8227.0, 8222.0, 8217.0, 8217.0, 8211.0, 8202.0, 8203.0, 8203.0, 8196.0, 8186.0, 8193.0, 8194.0, 8187.0, 8185.0, 8168.0, 8165.0, 8169.0, 8166.0, 8163.0, 8162.0, 8159.0, 8143.0, 8148.0, 8143.0, 8146.0, 8152.0, 8149.0, 8152.0, 8147.0, 8138.0, 8128.0, 8134.0, 8131.0, 8133.0, 8123.0, 8106.0, 8105.0, 8104.0, 8113.0, 8112.0, 8112.0, 8111.0, 8114.0, 8115.0, 8114.0, 8110.0, 8101.0, 8107.0, 8103.0, 8100.0, 8101.0, 8100.0, 8102.0, 8101.0, 8100.0, 8070.0, 8076.0, 8072.0, 8072.0, 8069.0, 8050.0, 8048.0, 8044.0, 8049.0, 8055.0, 8063.0, 8070.0, 8067.0, 8061.0, 8059.0, 8060.0, 8063.0, 8058.0, 8061.0, 8061.0, 8068.0, 8066.0, 8071.0, 8073.0, 8068.0, 8066.0, 8066.0, 8065.0, 8070.0, 8072.0, 8072.0, 8075.0, 8078.0, 8084.0, 8085.0, 8084.0, 8077.0, 8076.0, 8075.0, 8079.0, 8081.0, 8083.0, 8088.0, 8086.0, 8088.0, 8088.0, 8092.0, 8086.0, 8086.0, 8083.0, 8075.0, 8074.0, 8073.0, 8073.0, 8077.0, 8077.0, 8078.0, 8077.0, 8076.0, 8073.0, 8075.0, 8079.0, 8079.0, 8078.0, 8074.0, 8080.0, 8086.0, 8086.0, 8085.0, 8085.0, 8087.0, 8102.0, 8109.0, 8113.0, 8114.0, 8110.0, 8105.0, 8106.0, 8109.0, 8114.0, 8107.0, 8106.0, 8106.0, 8110.0, 8111.0, 8110.0, 8112.0, 8112.0, 8109.0, 8102.0, 8098.0, 8099.0, 8098.0, 8097.0, 8099.0, 8099.0, 8099.0, 8102.0, 8099.0, 8099.0, 8096.0, 8097.0, 8091.0, 8094.0, 8094.0, 8096.0, 8102.0, 8106.0, 8109.0, 8109.0, 8110.0, 8108.0, 8106.0, 8110.0, 8122.0, 8105.0, 8105.0, 8104.0, 8103.0, 8104.0, 8103.0, 8110.0, 8110.0, 8107.0, 8109.0, 8105.0, 8097.0, 8095.0, 8093.0, 8094.0, 8097.0, 8096.0, 8096.0, 8096.0, 8097.0, 8092.0, 8090.0, 8081.0, 8081.0, 8083.0, 8087.0, 8085.0, 8085.0, 8087.0, 8092.0, 8094.0, 8090.0, 8093.0, 8092.0, 8094.0, 8093.0, 8091.0, 8095.0, 8095.0, 8092.0, 8089.0, 8090.0, 8090.0, 8091.0, 8088.0, 8089.0, 8089.0, 8085.0, 8081.0, 8080.0, 8078.0, 8078.0, 8076.0, 8073.0, 8077.0, 8078.0, 8077.0, 8077.0, 8083.0, 8082.0, 8082.0, 8077.0, 8079.0, 8082.0, 8080.0, 8077.0, 8078.0, 8076.0, 8073.0, 8074.0, 8073.0, 8073.0, 8070.0, 8070.0, 8072.0, 8079.0, 8078.0, 8079.0, 8081.0, 8083.0, 8077.0, 8078.0, 8080.0, 8079.0, 8080.0, 8077.0, 8069.0, 8071.0, 8066.0, 8064.0, 8066.0, 8066.0, 8063.0, 8074.0, 8075.0, 8071.0, 8070.0, 8075.0, 8075.0]`) + low := []byte(`[8260.0, 8272.0, 8275.0, 8274.0, 8275.0, 8277.0, 8276.0, 8278.0, 8277.0, 8283.0, 8282.0, 8283.0, 8283.0, 8283.0, 8283.0, 8279.0, 8281.0, 8282.0, 8277.0, 8276.0, 8273.0, 8275.0, 8274.0, 8275.0, 8266.0, 8256.0, 8255.0, 8250.0, 8239.0, 8230.0, 8214.0, 8218.0, 8216.0, 8208.0, 8209.0, 8201.0, 8190.0, 8195.0, 8193.0, 8181.0, 8175.0, 8183.0, 8182.0, 8181.0, 8159.0, 8152.0, 8150.0, 8160.0, 8161.0, 8153.0, 8153.0, 8137.0, 8135.0, 8139.0, 8130.0, 8130.0, 8140.0, 8137.0, 8145.0, 8134.0, 8123.0, 8116.0, 8122.0, 8124.0, 8122.0, 8105.0, 8096.0, 8096.0, 8097.0, 8100.0, 8100.0, 8104.0, 8101.0, 8103.0, 8109.0, 8108.0, 8089.0, 8092.0, 8097.0, 8098.0, 8094.0, 8092.0, 8087.0, 8094.0, 8094.0, 8069.0, 8058.0, 8065.0, 8066.0, 8065.0, 8046.0, 8041.0, 8036.0, 8038.0, 8039.0, 8047.0, 8053.0, 8058.0, 8056.0, 8056.0, 8053.0, 8052.0, 8054.0, 8051.0, 8053.0, 8056.0, 8055.0, 8063.0, 8064.0, 8063.0, 8062.0, 8061.0, 8059.0, 8059.0, 8063.0, 8066.0, 8067.0, 8068.0, 8071.0, 8071.0, 8079.0, 8074.0, 8073.0, 8074.0, 8073.0, 8073.0, 8076.0, 8079.0, 8080.0, 8083.0, 8083.0, 8085.0, 8082.0, 8082.0, 8081.0, 8072.0, 8072.0, 8068.0, 8070.0, 8070.0, 8072.0, 8074.0, 8075.0, 8073.0, 8071.0, 8070.0, 8067.0, 8074.0, 8076.0, 8072.0, 8070.0, 8072.0, 8079.0, 8081.0, 8082.0, 8082.0, 8084.0, 8083.0, 8097.0, 8103.0, 8107.0, 8104.0, 8103.0, 8104.0, 8103.0, 8105.0, 8103.0, 8102.0, 8102.0, 8103.0, 8106.0, 8107.0, 8108.0, 8102.0, 8098.0, 8096.0, 8095.0, 8096.0, 8093.0, 8094.0, 8094.0, 8096.0, 8097.0, 8097.0, 8096.0, 8094.0, 8094.0, 8086.0, 8086.0, 8087.0, 8090.0, 8091.0, 8095.0, 8099.0, 8104.0, 8102.0, 8106.0, 8101.0, 8103.0, 8104.0, 8104.0, 8101.0, 8102.0, 8096.0, 8096.0, 8098.0, 8100.0, 8102.0, 8106.0, 8103.0, 8103.0, 8094.0, 8090.0, 8090.0, 8089.0, 8088.0, 8090.0, 8093.0, 8094.0, 8094.0, 8088.0, 8087.0, 8079.0, 8075.0, 8076.0, 8077.0, 8081.0, 8083.0, 8083.0, 8084.0, 8087.0, 8089.0, 8088.0, 8088.0, 8086.0, 8087.0, 8090.0, 8088.0, 8090.0, 8091.0, 8087.0, 8087.0, 8086.0, 8088.0, 8087.0, 8082.0, 8083.0, 8083.0, 8078.0, 8077.0, 8077.0, 8072.0, 8074.0, 8071.0, 8070.0, 8072.0, 8073.0, 8073.0, 8072.0, 8076.0, 8079.0, 8075.0, 8075.0, 8075.0, 8076.0, 8076.0, 8074.0, 8076.0, 8069.0, 8068.0, 8069.0, 8069.0, 8065.0, 8067.0, 8067.0, 8067.0, 8073.0, 8075.0, 8076.0, 8077.0, 8075.0, 8072.0, 8074.0, 8075.0, 8074.0, 8072.0, 8066.0, 8063.0, 8062.0, 8058.0, 8060.0, 8059.0, 8060.0, 8059.0, 8062.0, 8067.0, 8068.0, 8067.0, 8068.0, 8071.0]`) + cloze := []byte(`[8262.0, 8273.0, 8279.0, 8279.0, 8275.0, 8282.0, 8278.0, 8279.0, 8281.0, 8285.0, 8287.0, 8284.0, 8283.0, 8283.0, 8285.0, 8286.0, 8287.0, 8290.0, 8283.0, 8287.0, 8278.0, 8275.0, 8276.0, 8275.0, 8281.0, 8270.0, 8257.0, 8258.0, 8252.0, 8243.0, 8231.0, 8219.0, 8220.0, 8216.0, 8210.0, 8211.0, 8201.0, 8197.0, 8201.0, 8193.0, 8183.0, 8184.0, 8191.0, 8184.0, 8185.0, 8161.0, 8154.0, 8163.0, 8164.0, 8162.0, 8156.0, 8158.0, 8141.0, 8139.0, 8142.0, 8130.0, 8145.0, 8140.0, 8149.0, 8146.0, 8136.0, 8123.0, 8126.0, 8130.0, 8125.0, 8122.0, 8106.0, 8096.0, 8103.0, 8102.0, 8111.0, 8105.0, 8111.0, 8103.0, 8112.0, 8113.0, 8109.0, 8093.0, 8101.0, 8101.0, 8100.0, 8095.0, 8096.0, 8095.0, 8100.0, 8095.0, 8069.0, 8068.0, 8072.0, 8068.0, 8067.0, 8046.0, 8045.0, 8043.0, 8040.0, 8049.0, 8055.0, 8062.0, 8062.0, 8058.0, 8056.0, 8055.0, 8058.0, 8057.0, 8054.0, 8056.0, 8057.0, 8066.0, 8065.0, 8069.0, 8064.0, 8063.0, 8064.0, 8059.0, 8065.0, 8069.0, 8068.0, 8069.0, 8072.0, 8074.0, 8084.0, 8084.0, 8076.0, 8074.0, 8074.0, 8075.0, 8077.0, 8080.0, 8082.0, 8086.0, 8084.0, 8087.0, 8087.0, 8083.0, 8083.0, 8082.0, 8074.0, 8073.0, 8072.0, 8071.0, 8072.0, 8075.0, 8076.0, 8076.0, 8074.0, 8071.0, 8071.0, 8075.0, 8079.0, 8077.0, 8074.0, 8072.0, 8079.0, 8084.0, 8082.0, 8085.0, 8086.0, 8084.0, 8102.0, 8107.0, 8113.0, 8109.0, 8104.0, 8104.0, 8105.0, 8108.0, 8106.0, 8104.0, 8106.0, 8105.0, 8110.0, 8107.0, 8109.0, 8112.0, 8104.0, 8099.0, 8097.0, 8097.0, 8098.0, 8095.0, 8096.0, 8097.0, 8099.0, 8098.0, 8099.0, 8099.0, 8095.0, 8097.0, 8086.0, 8088.0, 8093.0, 8092.0, 8096.0, 8101.0, 8105.0, 8105.0, 8109.0, 8107.0, 8103.0, 8104.0, 8109.0, 8105.0, 8102.0, 8104.0, 8097.0, 8100.0, 8103.0, 8103.0, 8109.0, 8107.0, 8106.0, 8104.0, 8096.0, 8090.0, 8092.0, 8089.0, 8093.0, 8093.0, 8094.0, 8095.0, 8096.0, 8088.0, 8089.0, 8079.0, 8077.0, 8079.0, 8082.0, 8083.0, 8084.0, 8084.0, 8087.0, 8091.0, 8088.0, 8088.0, 8091.0, 8087.0, 8092.0, 8090.0, 8091.0, 8095.0, 8092.0, 8088.0, 8087.0, 8090.0, 8089.0, 8087.0, 8084.0, 8088.0, 8084.0, 8079.0, 8078.0, 8078.0, 8076.0, 8075.0, 8071.0, 8072.0, 8074.0, 8077.0, 8074.0, 8077.0, 8081.0, 8080.0, 8076.0, 8076.0, 8078.0, 8079.0, 8076.0, 8076.0, 8076.0, 8070.0, 8072.0, 8069.0, 8072.0, 8070.0, 8069.0, 8069.0, 8073.0, 8078.0, 8077.0, 8079.0, 8080.0, 8076.0, 8076.0, 8076.0, 8077.0, 8078.0, 8075.0, 8067.0, 8064.0, 8064.0, 8062.0, 8062.0, 8065.0, 8062.0, 8063.0, 8074.0, 8070.0, 8069.0, 8068.0, 8074.0]`) + + buildKLines := func(open, high, low, cloze []fixedpoint.Value) (kLines []types.KLine) { + for i := range high { + kLines = append(kLines, types.KLine{Open: open[i], High: high[i], Low: low[i], Close: cloze[i], EndTime: types.Time(time.Now())}) + } + return kLines + } + var o, h, l, c []fixedpoint.Value + _ = json.Unmarshal(open, &o) + _ = json.Unmarshal(high, &h) + _ = json.Unmarshal(low, &l) + _ = json.Unmarshal(cloze, &c) + + tests := []struct { + name string + kLines []types.KLine + window int + wantK float64 + wantD float64 + }{ + { + name: "TXF1-1min_2016/1/4", + kLines: buildKLines(o, h, l, c), + window: 14, + wantK: 84.210526, + wantD: 59.888357, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stream := &types.StandardStream{} + kLines := KLines(stream, "", "") + kd := Stoch(kLines, tt.window, DPeriod) + + for _, k := range tt.kLines { + stream.EmitKLineClosed(k) + } + + gotK := kd.K.Last(0) + diff_k := math.Trunc((gotK-tt.wantK)*100) / 100 + if diff_k != 0 { + t.Errorf("%%K() = %v, want %v", gotK, tt.wantK) + } + + gotD := kd.D.Last(0) + diff_d := math.Trunc((gotD-tt.wantD)*100) / 100 + if diff_d != 0 { + t.Errorf("%%D() = %v, want %v", gotD, tt.wantD) + } + }) + } +} diff --git a/pkg/indicator/v2/stochstream_callbacks.go b/pkg/indicator/v2/stochstream_callbacks.go new file mode 100644 index 0000000..b4bc8bb --- /dev/null +++ b/pkg/indicator/v2/stochstream_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type StochStream"; DO NOT EDIT. + +package indicatorv2 + +import () + +func (S *StochStream) OnUpdate(cb func(k float64, d float64)) { + S.updateCallbacks = append(S.updateCallbacks, cb) +} + +func (S *StochStream) EmitUpdate(k float64, d float64) { + for _, cb := range S.updateCallbacks { + cb(k, d) + } +} diff --git a/pkg/indicator/v2/subtract.go b/pkg/indicator/v2/subtract.go new file mode 100644 index 0000000..c47a6f2 --- /dev/null +++ b/pkg/indicator/v2/subtract.go @@ -0,0 +1,49 @@ +package indicatorv2 + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// SubtractStream subscribes 2 upstream data, and then subtract these 2 values +type SubtractStream struct { + *types.Float64Series + + a, b floats.Slice + i int +} + +// Subtract creates the SubtractStream object +// subtract := Subtract(longEWMA, shortEWMA) +func Subtract(a, b types.Float64Source) *SubtractStream { + s := &SubtractStream{ + Float64Series: types.NewFloat64Series(), + } + + a.OnUpdate(func(v float64) { + s.a.Push(v) + s.calculate() + }) + b.OnUpdate(func(v float64) { + s.b.Push(v) + s.calculate() + }) + return s +} + +func (s *SubtractStream) calculate() { + if s.a.Length() != s.b.Length() { + return + } + + if s.a.Length() > s.Slice.Length() { + var numNewElems = s.a.Length() - s.Slice.Length() + var tailA = s.a.Tail(numNewElems) + var tailB = s.b.Tail(numNewElems) + var tailC = tailA.Sub(tailB) + for _, f := range tailC { + s.Slice.Push(f) + s.EmitUpdate(f) + } + } +} diff --git a/pkg/indicator/v2/subtract_test.go b/pkg/indicator/v2/subtract_test.go new file mode 100644 index 0000000..5d28d72 --- /dev/null +++ b/pkg/indicator/v2/subtract_test.go @@ -0,0 +1,30 @@ +package indicatorv2 + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func Test_v2_Subtract(t *testing.T) { + stream := &types.StandardStream{} + kLines := KLines(stream, "", "") + closePrices := ClosePrices(kLines) + fastEMA := EWMA2(closePrices, 10) + slowEMA := EWMA2(closePrices, 25) + subtract := Subtract(fastEMA, slowEMA) + + for i := .0; i < 50.0; i++ { + stream.EmitKLineClosed(types.KLine{Close: fixedpoint.NewFromFloat(19_000.0 + i)}) + } + + t.Logf("fastEMA: %+v", fastEMA.Slice) + t.Logf("slowEMA: %+v", slowEMA.Slice) + + assert.Equal(t, len(subtract.a), len(subtract.b)) + assert.Equal(t, len(subtract.a), len(subtract.Slice)) + assert.InDelta(t, subtract.Slice[0], subtract.a[0]-subtract.b[0], 0.0001) +} diff --git a/pkg/indicator/v2/tr.go b/pkg/indicator/v2/tr.go new file mode 100644 index 0000000..68e4a7d --- /dev/null +++ b/pkg/indicator/v2/tr.go @@ -0,0 +1,49 @@ +package indicatorv2 + +import ( + "math" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// This TRStream calculates the ATR first +type TRStream struct { + // embedded struct + *types.Float64Series + + // private states + previousClose float64 +} + +func TR2(source KLineSubscription) *TRStream { + s := &TRStream{ + Float64Series: types.NewFloat64Series(), + } + + source.AddSubscriber(func(k types.KLine) { + s.calculateAndPush(k.High.Float64(), k.Low.Float64(), k.Close.Float64()) + }) + return s +} + +func (s *TRStream) calculateAndPush(high, low, cls float64) { + if s.previousClose == .0 { + s.previousClose = cls + return + } + + trueRange := high - low + hc := math.Abs(high - s.previousClose) + lc := math.Abs(low - s.previousClose) + if trueRange < hc { + trueRange = hc + } + + if trueRange < lc { + trueRange = lc + } + + s.previousClose = cls + + s.PushAndEmit(trueRange) +} diff --git a/pkg/indicator/v2/tr_test.go b/pkg/indicator/v2/tr_test.go new file mode 100644 index 0000000..c5a0ec3 --- /dev/null +++ b/pkg/indicator/v2/tr_test.go @@ -0,0 +1,82 @@ +package indicatorv2 + +import ( + "encoding/json" + "math" + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +/* +python + +import pandas as pd +import pandas_ta as ta + + data = { + "high": [40145.0, 40186.36, 40196.39, 40344.6, 40245.48, 40273.24, 40464.0, 40699.0, 40627.48, 40436.31, 40370.0, 40376.8, 40227.03, 40056.52, 39721.7, 39597.94, 39750.15, 39927.0, 40289.02, 40189.0], + "low": [39870.71, 39834.98, 39866.31, 40108.31, 40016.09, 40094.66, 40105.0, 40196.48, 40154.99, 39800.0, 39959.21, 39922.98, 39940.02, 39632.0, 39261.39, 39254.63, 39473.91, 39555.51, 39819.0, 40006.84], + "close": [40105.78, 39935.23, 40183.97, 40182.03, 40212.26, 40149.99, 40378.0, 40618.37, 40401.03, 39990.39, 40179.13, 40097.23, 40014.72, 39667.85, 39303.1, 39519.99, + +39693.79, 39827.96, 40074.94, 40059.84] +} + +high = pd.Series(data['high']) +low = pd.Series(data['low']) +close = pd.Series(data['close']) +result = ta.atr(high, low, close, length=14) +print(result) +*/ +func Test_TR_and_RMA(t *testing.T) { + var bytes = []byte(`{ + "high": [40145.0, 40186.36, 40196.39, 40344.6, 40245.48, 40273.24, 40464.0, 40699.0, 40627.48, 40436.31, 40370.0, 40376.8, 40227.03, 40056.52, 39721.7, 39597.94, 39750.15, 39927.0, 40289.02, 40189.0], + "low": [39870.71, 39834.98, 39866.31, 40108.31, 40016.09, 40094.66, 40105.0, 40196.48, 40154.99, 39800.0, 39959.21, 39922.98, 39940.02, 39632.0, 39261.39, 39254.63, 39473.91, 39555.51, 39819.0, 40006.84], + "close": [40105.78, 39935.23, 40183.97, 40182.03, 40212.26, 40149.99, 40378.0, 40618.37, 40401.03, 39990.39, 40179.13, 40097.23, 40014.72, 39667.85, 39303.1, 39519.99, 39693.79, 39827.96, 40074.94, 40059.84] + }`) + + var buildKLines = func(bytes []byte) (kLines []types.KLine) { + var prices map[string][]fixedpoint.Value + _ = json.Unmarshal(bytes, &prices) + for i, h := range prices["high"] { + kLine := types.KLine{High: h, Low: prices["low"][i], Close: prices["close"][i]} + kLines = append(kLines, kLine) + } + return kLines + } + + tests := []struct { + name string + kLines []types.KLine + window int + want float64 + }{ + { + name: "test_binance_btcusdt_1h", + kLines: buildKLines(bytes), + window: 14, + want: 367.913903, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stream := &types.StandardStream{} + + kLines := KLines(stream, "", "") + atr := TR2(kLines) + rma := RMA2(atr, tt.window, true) + + for _, k := range tt.kLines { + stream.EmitKLineClosed(k) + } + + got := rma.Last(0) + diff := math.Trunc((got-tt.want)*100) / 100 + if diff != 0 { + t.Errorf("RMA(TR()) = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/indicator/vidya.go b/pkg/indicator/vidya.go new file mode 100644 index 0000000..3572ef5 --- /dev/null +++ b/pkg/indicator/vidya.go @@ -0,0 +1,108 @@ +package indicator + +import ( + "math" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// Refer: Variable Index Dynamic Average +// Refer URL: https://metatrader5.com/en/terminal/help/indicators/trend_indicators/vida +// The Variable Index Dynamic Average (VIDYA) is a technical analysis indicator that is used to smooth price data and reduce the lag +// associated with traditional moving averages. It is calculated by taking the weighted moving average of the input data, with the +// weighting factors determined using a variable index that is based on the standard deviation of the data and the specified length of +// the moving average. This resulting average is then plotted on the price chart as a line, which can be used to make predictions about +// future price movements. The VIDYA is typically more responsive to changes in the underlying data than a simple moving average, but may +// be less reliable in trending markets. + +//go:generate callbackgen -type VIDYA +type VIDYA struct { + types.SeriesBase + types.IntervalWindow + Values floats.Slice + input floats.Slice + + updateCallbacks []func(value float64) +} + +func (inc *VIDYA) Update(value float64) { + if inc.Values.Length() == 0 { + inc.SeriesBase.Series = inc + inc.Values.Push(value) + inc.input.Push(value) + return + } + inc.input.Push(value) + if len(inc.input) > MaxNumOfEWMA { + inc.input = inc.input[MaxNumOfEWMATruncateSize-1:] + } + /*upsum := 0. + downsum := 0. + for i := 0; i < inc.Window; i++ { + if len(inc.input) <= i+1 { + break + } + diff := inc.input.Index(i) - inc.input.Index(i+1) + if diff > 0 { + upsum += diff + } else { + downsum += -diff + } + + } + if upsum == 0 && downsum == 0 { + return + } + CMO := math.Abs((upsum - downsum) / (upsum + downsum))*/ + change := types.Change(&inc.input) + CMO := math.Abs(types.Sum(change, inc.Window) / types.Sum(types.Abs(change), inc.Window)) + alpha := 2. / float64(inc.Window+1) + inc.Values.Push(value*alpha*CMO + inc.Values.Last(0)*(1.-alpha*CMO)) + if inc.Values.Length() > MaxNumOfEWMA { + inc.Values = inc.Values[MaxNumOfEWMATruncateSize-1:] + } +} + +func (inc *VIDYA) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *VIDYA) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *VIDYA) Length() int { + return inc.Values.Length() +} + +var _ types.SeriesExtend = &VIDYA{} + +func (inc *VIDYA) PushK(k types.KLine) { + inc.Update(k.Close.Float64()) +} + +func (inc *VIDYA) CalculateAndUpdate(allKLines []types.KLine) { + if inc.input.Length() == 0 { + for _, k := range allKLines { + inc.PushK(k) + inc.EmitUpdate(inc.Last(0)) + } + } else { + k := allKLines[len(allKLines)-1] + inc.PushK(k) + inc.EmitUpdate(inc.Last(0)) + } +} + +func (inc *VIDYA) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *VIDYA) Bind(updater KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} diff --git a/pkg/indicator/vidya_callbacks.go b/pkg/indicator/vidya_callbacks.go new file mode 100644 index 0000000..c05d0a2 --- /dev/null +++ b/pkg/indicator/vidya_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type VIDYA"; DO NOT EDIT. + +package indicator + +import () + +func (inc *VIDYA) OnUpdate(cb func(value float64)) { + inc.updateCallbacks = append(inc.updateCallbacks, cb) +} + +func (inc *VIDYA) EmitUpdate(value float64) { + for _, cb := range inc.updateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/vidya_test.go b/pkg/indicator/vidya_test.go new file mode 100644 index 0000000..1cc7276 --- /dev/null +++ b/pkg/indicator/vidya_test.go @@ -0,0 +1,19 @@ +package indicator + +import ( + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +func Test_VIDYA(t *testing.T) { + vidya := &VIDYA{IntervalWindow: types.IntervalWindow{Window: 16}} + vidya.Update(1) + assert.Equal(t, vidya.Last(0), 1.) + vidya.Update(2) + newV := 2./17.*2. + 1.*(1.-2./17.) + assert.Equal(t, vidya.Last(0), newV) + vidya.Update(1) + assert.Equal(t, vidya.Last(0), vidya.Index(1)) +} diff --git a/pkg/indicator/volatility.go b/pkg/indicator/volatility.go new file mode 100644 index 0000000..1ec8970 --- /dev/null +++ b/pkg/indicator/volatility.go @@ -0,0 +1,110 @@ +package indicator + +import ( + "fmt" + "math" + "time" + + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const MaxNumOfVOL = 5_000 +const MaxNumOfVOLTruncateSize = 100 + +// var zeroTime time.Time + +//go:generate callbackgen -type Volatility +type Volatility struct { + types.SeriesBase + types.IntervalWindow + Values floats.Slice + EndTime time.Time + + UpdateCallbacks []func(value float64) +} + +func (inc *Volatility) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *Volatility) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *Volatility) Length() int { + return len(inc.Values) +} + +var _ types.SeriesExtend = &Volatility{} + +func (inc *Volatility) CalculateAndUpdate(allKLines []types.KLine) { + if len(allKLines) < inc.Window { + return + } + + var end = len(allKLines) - 1 + var lastKLine = allKLines[end] + + if inc.EndTime != zeroTime && lastKLine.GetEndTime().Before(inc.EndTime) { + return + } + + if len(inc.Values) == 0 { + inc.SeriesBase.Series = inc + } + + var recentT = allKLines[end-(inc.Window-1) : end+1] + + volatility, err := calculateVOLATILITY(recentT, inc.Window, types.KLineClosePriceMapper) + if err != nil { + log.WithError(err).Error("can not calculate volatility") + return + } + inc.Values.Push(volatility) + + if len(inc.Values) > MaxNumOfVOL { + inc.Values = inc.Values[MaxNumOfVOLTruncateSize-1:] + } + + inc.EndTime = allKLines[end].GetEndTime().Time() + + inc.EmitUpdate(volatility) +} + +func (inc *Volatility) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *Volatility) Bind(updater KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} + +func calculateVOLATILITY(klines []types.KLine, window int, priceF types.KLineValueMapper) (float64, error) { + length := len(klines) + if length == 0 || length < window { + return 0.0, fmt.Errorf("insufficient elements for calculating VOL with window = %d", window) + } + + sum := 0.0 + for _, k := range klines { + sum += priceF(k) + } + + avg := sum / float64(window) + sv := 0.0 // sum of variance + + for _, j := range klines { + // The use of Pow math function func Pow(x, y float64) float64 + sv += math.Pow(priceF(j)-avg, 2) + } + // The use of Sqrt math function func Sqrt(x float64) float64 + sd := math.Sqrt(sv / float64(len(klines))) + return sd, nil +} diff --git a/pkg/indicator/volatility_callbacks.go b/pkg/indicator/volatility_callbacks.go new file mode 100644 index 0000000..c04211a --- /dev/null +++ b/pkg/indicator/volatility_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type Volatility"; DO NOT EDIT. + +package indicator + +import () + +func (inc *Volatility) OnUpdate(cb func(value float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *Volatility) EmitUpdate(value float64) { + for _, cb := range inc.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/volumeprofile.go b/pkg/indicator/volumeprofile.go new file mode 100644 index 0000000..f773149 --- /dev/null +++ b/pkg/indicator/volumeprofile.go @@ -0,0 +1,117 @@ +package indicator + +import ( + "math" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type trade struct { + price float64 + volume float64 // +: buy, -: sell + timestamp types.Time +} + +// The Volume Profile is a technical analysis tool that is used to visualize the distribution of trading volume at different price levels +// in a security. It is typically plotted as a histogram or heatmap on the price chart, with the x-axis representing the price levels and +// the y-axis representing the trading volume. The Volume Profile can be used to identify areas of support and resistance, as well as +// potential entry and exit points for trades. +type VolumeProfile struct { + types.IntervalWindow + Delta float64 + profile map[float64]float64 + trades []trade + minPrice float64 + maxPrice float64 +} + +func (inc *VolumeProfile) Update(price, volume float64, timestamp types.Time) { + inc.minPrice = math.Inf(1) + inc.maxPrice = math.Inf(-1) + if inc.profile == nil { + inc.profile = make(map[float64]float64) + } + inc.profile[math.Round(price/inc.Delta)] += volume + filter := timestamp.Time().Add(-time.Duration(inc.Window) * inc.Interval.Duration()) + inc.trades = append(inc.trades, trade{ + price: price, + volume: volume, + timestamp: timestamp, + }) + var i int + for i = 0; i < len(inc.trades); i++ { + td := inc.trades[i] + if td.timestamp.After(filter) { + inc.trades = inc.trades[i:len(inc.trades)] + break + } + inc.profile[math.Round(td.price/inc.Delta)] -= td.volume + } + + for i = 0; i < len(inc.trades); i++ { + k := math.Round(inc.trades[i].price / inc.Delta) + if k < inc.minPrice { + inc.minPrice = k + } + if k > inc.maxPrice { + inc.maxPrice = k + } + } +} + +// The Point of Control (POC) is a term used in the context of Volume Profile analysis. It refers to the price level at which the most +// volume has been traded in a security over a specified period of time. The POC is typically identified by looking for the highest +// peak in the Volume Profile, and is considered to be an important level of support or resistance. It can be used by traders to +// identify potential entry and exit points for trades, or to confirm other technical analysis signals. + +// Get Resistance Level by finding PoC +func (inc *VolumeProfile) PointOfControlAboveEqual(price float64, limit ...float64) (resultPrice float64, vol float64) { + filter := inc.maxPrice + if len(limit) > 0 { + filter = limit[0] + } + if inc.Delta == 0 { + panic("Delta for volumeprofile shouldn't be zero") + } + start := math.Round(price / inc.Delta) + vol = math.Inf(-1) + if start > filter { + return 0, 0 + } + for ; start <= filter; start += inc.Delta { + abs := math.Abs(inc.profile[start]) + if vol < abs { + vol = abs + resultPrice = start + } + + } + return resultPrice, vol +} + +// Get Support Level by finding PoC +func (inc *VolumeProfile) PointOfControlBelowEqual(price float64, limit ...float64) (resultPrice float64, vol float64) { + filter := inc.minPrice + if len(limit) > 0 { + filter = limit[0] + } + if inc.Delta == 0 { + panic("Delta for volumeprofile shouldn't be zero") + } + start := math.Round(price / inc.Delta) + vol = math.Inf(-1) + + if start < filter { + return 0, 0 + } + + for ; start >= filter; start -= inc.Delta { + abs := math.Abs(inc.profile[start]) + if vol < abs { + vol = abs + resultPrice = start + } + } + return resultPrice, vol +} diff --git a/pkg/indicator/volumeprofile_test.go b/pkg/indicator/volumeprofile_test.go new file mode 100644 index 0000000..3f00798 --- /dev/null +++ b/pkg/indicator/volumeprofile_test.go @@ -0,0 +1,29 @@ +package indicator + +import ( + "testing" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +func Test_DelVolumeProfile(t *testing.T) { + + vp := VolumeProfile{IntervalWindow: types.IntervalWindow{Window: 1, Interval: types.Interval1s}, Delta: 1.0} + vp.Update(1., 100., types.Time(time.Now())) + r, v := vp.PointOfControlAboveEqual(1.) + assert.Equal(t, r, 1.) + assert.Equal(t, v, 100.) + vp.Update(2., 100., types.Time(time.Now().Add(time.Second*10))) + r, v = vp.PointOfControlAboveEqual(1.) + assert.Equal(t, r, 2.) + assert.Equal(t, v, 100.) + r, v = vp.PointOfControlBelowEqual(1.) + assert.Equal(t, r, 0.) + assert.Equal(t, v, 0.) + r, v = vp.PointOfControlBelowEqual(2.) + assert.Equal(t, r, 2.) + assert.Equal(t, v, 100.) + +} diff --git a/pkg/indicator/vwap.go b/pkg/indicator/vwap.go new file mode 100644 index 0000000..829203f --- /dev/null +++ b/pkg/indicator/vwap.go @@ -0,0 +1,108 @@ +package indicator + +import ( + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// vwap implements the volume weighted average price (VWAP) indicator: +// +// Volume Weighted Average Price (VWAP) Definition +// - https://www.investopedia.com/terms/v/vwap.asp +// +// Volume-Weighted Average Price (VWAP) Explained +// - https://academy.binance.com/en/articles/volume-weighted-average-price-vwap-explained +// +// The Volume Weighted Average Price (VWAP) is a technical analysis indicator that is used to measure the average price of a security +// over a specified period of time, with the weighting factors determined by the volume of the security. It is calculated by taking the +// sum of the product of the price and volume for each trade, and then dividing that sum by the total volume of the security over the +// specified period of time. This resulting average is then plotted on the price chart as a line, which can be used to make predictions +// about future price movements. The VWAP is typically more accurate than other simple moving averages, as it takes into account the +// volume of the security, but may be less reliable in markets with low trading volume. + +//go:generate callbackgen -type VWAP +type VWAP struct { + types.SeriesBase + types.IntervalWindow + Values floats.Slice + Prices floats.Slice + Volumes floats.Slice + WeightedSum float64 + VolumeSum float64 + + EndTime time.Time + UpdateCallbacks []func(value float64) +} + +func (inc *VWAP) Update(price, volume float64) { + if len(inc.Prices) == 0 { + inc.SeriesBase.Series = inc + } + inc.Prices.Push(price) + inc.Volumes.Push(volume) + + if inc.Window != 0 && len(inc.Prices) > inc.Window { + popIndex := len(inc.Prices) - inc.Window - 1 + inc.WeightedSum -= inc.Prices[popIndex] * inc.Volumes[popIndex] + inc.VolumeSum -= inc.Volumes[popIndex] + } + + inc.WeightedSum += price * volume + inc.VolumeSum += volume + + vwap := inc.WeightedSum / inc.VolumeSum + inc.Values.Push(vwap) +} + +func (inc *VWAP) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *VWAP) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *VWAP) Length() int { + return len(inc.Values) +} + +var _ types.SeriesExtend = &VWAP{} + +func (inc *VWAP) PushK(k types.KLine) { + inc.Update(types.KLineTypicalPriceMapper(k), k.Volume.Float64()) +} + +func (inc *VWAP) CalculateAndUpdate(allKLines []types.KLine) { + for _, k := range allKLines { + if inc.EndTime != zeroTime && !k.EndTime.After(inc.EndTime) { + continue + } + + inc.PushK(k) + } + + inc.EmitUpdate(inc.Last(0)) + inc.EndTime = allKLines[len(allKLines)-1].EndTime.Time() +} + +func (inc *VWAP) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *VWAP) Bind(updater KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} + +func calculateVWAP(klines []types.KLine, priceF types.KLineValueMapper, window int) float64 { + vwap := VWAP{IntervalWindow: types.IntervalWindow{Window: window}} + for _, k := range klines { + vwap.Update(priceF(k), k.Volume.Float64()) + } + return vwap.Last(0) +} diff --git a/pkg/indicator/vwap_callbacks.go b/pkg/indicator/vwap_callbacks.go new file mode 100644 index 0000000..918ddcf --- /dev/null +++ b/pkg/indicator/vwap_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type VWAP"; DO NOT EDIT. + +package indicator + +import () + +func (inc *VWAP) OnUpdate(cb func(value float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *VWAP) EmitUpdate(value float64) { + for _, cb := range inc.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/vwap_test.go b/pkg/indicator/vwap_test.go new file mode 100644 index 0000000..4964e4a --- /dev/null +++ b/pkg/indicator/vwap_test.go @@ -0,0 +1,74 @@ +package indicator + +import ( + "encoding/json" + "math" + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func Test_calculateVWAP(t *testing.T) { + var trivialPrices = []byte(`[0]`) + var trivialVolumes = []byte(`[1]`) + var easyPrices = []byte(`[1, 2, 3]`) + var easyVolumes = []byte(`[4, 5, 6]`) + var windowPrices = []byte(`[1, 2, 3, 4]`) + var windowVolumes = []byte(`[4, 5, 6, 7]`) + var randomPrices = []byte(`[0.6046702879796195, 0.9405190880450124, 0.6645700532184904, 0.4377241871869802, 0.4246474970712657, 0.6868330728671094, 0.06564701921747622, 0.15652925473279125, 0.09697951891448456, 0.3009218605852871]`) + var randomVolumes = []byte(`[0.5152226285020653, 0.8136499609900968, 0.21427387258237493, 0.380667189299686, 0.31806817433032986, 0.4688998449024232, 0.2830441511804452, 0.2931118573368158, 0.6790946759202162, 0.2185630525927643]`) + + buildKLines := func(pb, vb []byte) (kLines []types.KLine) { + var prices, volumes []fixedpoint.Value + _ = json.Unmarshal(pb, &prices) + _ = json.Unmarshal(vb, &volumes) + for i, p := range prices { + kLines = append(kLines, types.KLine{High: p, Low: p, Close: p, Volume: volumes[i]}) + } + return kLines + } + + tests := []struct { + name string + kLines []types.KLine + window int + want float64 + }{ + { + name: "trivial_case", + kLines: buildKLines(trivialPrices, trivialVolumes), + window: 0, + want: 0.0, + }, + { + name: "easy_case", + kLines: buildKLines(easyPrices, easyVolumes), + window: 0, + want: (1*4 + 2*5 + 3*6) / float64(4+5+6), + }, + { + name: "window_case", + kLines: buildKLines(windowPrices, windowVolumes), + window: 3, + want: (2*5 + 3*6 + 4*7) / float64(5+6+7), + }, + { + name: "random_case", + kLines: buildKLines(randomPrices, randomVolumes), + window: 0, + want: 0.48727133857423566, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + priceF := types.KLineTypicalPriceMapper + got := calculateVWAP(tt.kLines, priceF, tt.window) + diff := math.Trunc((got-tt.want)*100) / 100 + if diff != 0 { + t.Errorf("calculateVWAP() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/indicator/vwma.go b/pkg/indicator/vwma.go new file mode 100644 index 0000000..4be9e97 --- /dev/null +++ b/pkg/indicator/vwma.go @@ -0,0 +1,113 @@ +package indicator + +import ( + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// vwma implements the volume weighted moving average (VWMA) indicator: +// +// Calculation: +// pv = element-wise multiplication of close prices and volumes +// VWMA = SMA(pv, window) / SMA(volumes, window) +// +// Volume Weighted Moving Average +// - https://www.motivewave.com/studies/volume_weighted_moving_average.htm +// +// The Volume Weighted Moving Average (VWMA) is a technical analysis indicator that is used to smooth price data and reduce the lag +// associated with traditional moving averages. It is calculated by taking the weighted moving average of the input data, with the +// weighting factors determined by the volume of the security. This resulting average is then plotted on the price chart as a line, +// which can be used to make predictions about future price movements. The VWMA is typically more accurate than other simple moving +// averages, as it takes into account the volume of the security, but may be less reliable in markets with low trading volume. + +//go:generate callbackgen -type VWMA +type VWMA struct { + types.SeriesBase + types.IntervalWindow + + Values floats.Slice + PriceVolumeSMA *SMA + VolumeSMA *SMA + + EndTime time.Time + + updateCallbacks []func(value float64) +} + +func (inc *VWMA) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *VWMA) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *VWMA) Length() int { + return len(inc.Values) +} + +var _ types.SeriesExtend = &VWMA{} + +func (inc *VWMA) Update(price, volume float64) { + if inc.PriceVolumeSMA == nil { + inc.PriceVolumeSMA = &SMA{IntervalWindow: inc.IntervalWindow} + inc.SeriesBase.Series = inc + } + + if inc.VolumeSMA == nil { + inc.VolumeSMA = &SMA{IntervalWindow: inc.IntervalWindow} + } + + inc.PriceVolumeSMA.Update(price * volume) + inc.VolumeSMA.Update(volume) + + pv := inc.PriceVolumeSMA.Last(0) + v := inc.VolumeSMA.Last(0) + vwma := pv / v + inc.Values.Push(vwma) +} + +func (inc *VWMA) PushK(k types.KLine) { + if inc.EndTime != zeroTime && k.EndTime.Before(inc.EndTime) { + return + } + + inc.Update(k.Close.Float64(), k.Volume.Float64()) +} + +func (inc *VWMA) CalculateAndUpdate(allKLines []types.KLine) { + if len(allKLines) < inc.Window { + return + } + + var last = allKLines[len(allKLines)-1] + + if inc.VolumeSMA == nil { + for _, k := range allKLines { + if inc.EndTime != zeroTime && k.EndTime.Before(inc.EndTime) { + return + } + + inc.Update(k.Close.Float64(), k.Volume.Float64()) + } + } else { + inc.Update(last.Close.Float64(), last.Volume.Float64()) + } + + inc.EndTime = last.EndTime.Time() + inc.EmitUpdate(inc.Values.Last(0)) +} + +func (inc *VWMA) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *VWMA) Bind(updater KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} diff --git a/pkg/indicator/vwma_callbacks.go b/pkg/indicator/vwma_callbacks.go new file mode 100644 index 0000000..375aee1 --- /dev/null +++ b/pkg/indicator/vwma_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type VWMA"; DO NOT EDIT. + +package indicator + +import () + +func (inc *VWMA) OnUpdate(cb func(value float64)) { + inc.updateCallbacks = append(inc.updateCallbacks, cb) +} + +func (inc *VWMA) EmitUpdate(value float64) { + for _, cb := range inc.updateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/wdrift.go b/pkg/indicator/wdrift.go new file mode 100644 index 0000000..ad70377 --- /dev/null +++ b/pkg/indicator/wdrift.go @@ -0,0 +1,147 @@ +package indicator + +import ( + "math" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// Refer: https://tradingview.com/script/aDymGrFx-Drift-Study-Inspired-by-Monte-Carlo-Simulations-with-BM-KL/ +// Brownian Motion's drift factor +// could be used in Monte Carlo Simulations +// +//go:generate callbackgen -type WeightedDrift +type WeightedDrift struct { + types.SeriesBase + types.IntervalWindow + chng *types.Queue + Values floats.Slice + MA types.UpdatableSeriesExtend + Weight *types.Queue + LastValue float64 + UpdateCallbacks []func(value float64) +} + +func (inc *WeightedDrift) Update(value float64, weight float64) { + if weight == 0 { + inc.LastValue = value + return + } + if inc.chng == nil { + inc.SeriesBase.Series = inc + if inc.MA == nil { + inc.MA = &SMA{IntervalWindow: types.IntervalWindow{Interval: inc.Interval, Window: inc.Window}} + } + inc.Weight = types.NewQueue(inc.Window) + inc.chng = types.NewQueue(inc.Window) + inc.LastValue = value + inc.Weight.Update(weight) + return + } + inc.Weight.Update(weight) + base := inc.Weight.Lowest(inc.Window) + multiplier := int(weight / base) + var chng float64 + if value == 0 { + chng = 0 + } else { + chng = math.Log(value/inc.LastValue) / weight * base + inc.LastValue = value + } + for i := 0; i < multiplier; i++ { + inc.MA.Update(chng) + inc.chng.Update(chng) + } + if inc.chng.Length() >= inc.Window { + stdev := types.Stdev(inc.chng, inc.Window) + drift := inc.MA.Last(0) - stdev*stdev*0.5 + inc.Values.Push(drift) + } +} + +// Assume that MA is SMA +func (inc *WeightedDrift) ZeroPoint() float64 { + window := float64(inc.Window) + stdev := types.Stdev(inc.chng, inc.Window) + chng := inc.chng.Index(inc.Window - 1) + /*b := -2 * inc.MA.Last() - 2 + c := window * stdev * stdev - chng * chng + 2 * chng * (inc.MA.Last() + 1) - 2 * inc.MA.Last() * window + + root := math.Sqrt(b*b - 4*c) + K1 := (-b + root)/2 + K2 := (-b - root)/2 + N1 := math.Exp(K1) * inc.LastValue + N2 := math.Exp(K2) * inc.LastValue + if math.Abs(inc.LastValue-N1) < math.Abs(inc.LastValue-N2) { + return N1 + } else { + return N2 + }*/ + return inc.LastValue * math.Exp(window*(0.5*stdev*stdev)+chng-inc.MA.Last(0)*window) +} + +func (inc *WeightedDrift) Clone() (out *WeightedDrift) { + out = &WeightedDrift{ + IntervalWindow: inc.IntervalWindow, + chng: inc.chng.Clone(), + Values: inc.Values[:], + MA: types.Clone(inc.MA), + Weight: inc.Weight.Clone(), + LastValue: inc.LastValue, + } + out.SeriesBase.Series = out + return out +} + +func (inc *WeightedDrift) TestUpdate(value float64, weight float64) *WeightedDrift { + out := inc.Clone() + out.Update(value, weight) + return out +} + +func (inc *WeightedDrift) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *WeightedDrift) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *WeightedDrift) Length() int { + if inc.Values == nil { + return 0 + } + return inc.Values.Length() +} + +var _ types.SeriesExtend = &Drift{} + +func (inc *WeightedDrift) PushK(k types.KLine) { + inc.Update(k.Close.Float64(), k.Volume.Abs().Float64()) +} + +func (inc *WeightedDrift) CalculateAndUpdate(allKLines []types.KLine) { + if inc.chng == nil { + for _, k := range allKLines { + inc.PushK(k) + inc.EmitUpdate(inc.Last(0)) + } + } else { + k := allKLines[len(allKLines)-1] + inc.PushK(k) + inc.EmitUpdate(inc.Last(0)) + } +} + +func (inc *WeightedDrift) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *WeightedDrift) Bind(updater KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} diff --git a/pkg/indicator/wdrift_test.go b/pkg/indicator/wdrift_test.go new file mode 100644 index 0000000..a7f6a67 --- /dev/null +++ b/pkg/indicator/wdrift_test.go @@ -0,0 +1,47 @@ +package indicator + +import ( + "encoding/json" + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +func Test_WDrift(t *testing.T) { + var randomPrices = []byte(`[1, 1, 2, 3, 4, 5, 6, 7, 8, 9, 4, 1, 2, 3, 4, 5, 6, 7, 8, 9, 4, 1, 2, 3, 4, 5, 6, 7, 8, 9, 1, 1, 2, 3, 4, 5, 6, 7, 8, 9, 4, 1, 2, 3, 4, 5, 6, 7, 8, 9]`) + var input []fixedpoint.Value + if err := json.Unmarshal(randomPrices, &input); err != nil { + panic(err) + } + buildKLines := func(prices []fixedpoint.Value) (klines []types.KLine) { + for _, p := range prices { + klines = append(klines, types.KLine{Close: p, Volume: fixedpoint.One}) + } + + return klines + } + tests := []struct { + name string + kLines []types.KLine + all int + }{ + { + name: "random_case", + kLines: buildKLines(input), + all: 47, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + drift := WeightedDrift{IntervalWindow: types.IntervalWindow{Window: 3}} + drift.CalculateAndUpdate(tt.kLines) + assert.Equal(t, drift.Length(), tt.all) + for _, v := range drift.Values { + assert.LessOrEqual(t, v, 1.0) + } + }) + } +} diff --git a/pkg/indicator/weighteddrift_callbacks.go b/pkg/indicator/weighteddrift_callbacks.go new file mode 100644 index 0000000..476e615 --- /dev/null +++ b/pkg/indicator/weighteddrift_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type WeightedDrift"; DO NOT EDIT. + +package indicator + +import () + +func (inc *WeightedDrift) OnUpdate(cb func(value float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *WeightedDrift) EmitUpdate(value float64) { + for _, cb := range inc.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/wwma.go b/pkg/indicator/wwma.go new file mode 100644 index 0000000..676ce1c --- /dev/null +++ b/pkg/indicator/wwma.go @@ -0,0 +1,88 @@ +package indicator + +import ( + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// Refer: Welles Wilder's Moving Average +// Refer URL: http://fxcorporate.com/help/MS/NOTFIFO/i_WMA.html +// TODO: Cannot see any difference between RMA and this + +const MaxNumOfWWMA = 5_000 +const MaxNumOfWWMATruncateSize = 100 + +//go:generate callbackgen -type WWMA +type WWMA struct { + types.SeriesBase + types.IntervalWindow + Values floats.Slice + LastOpenTime time.Time + + UpdateCallbacks []func(value float64) +} + +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 { + inc.Values = inc.Values[MaxNumOfWWMATruncateSize-1:] + } + + last := inc.Last(0) + wma := last + (value-last)/float64(inc.Window) + inc.Values.Push(wma) +} + +func (inc *WWMA) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *WWMA) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *WWMA) Length() int { + return len(inc.Values) +} + +func (inc *WWMA) PushK(k types.KLine) { + inc.Update(k.Close.Float64()) +} + +func (inc *WWMA) CalculateAndUpdate(allKLines []types.KLine) { + if len(allKLines) < inc.Window { + // we can't calculate + return + } + + doable := false + for _, k := range allKLines { + if !doable && k.StartTime.After(inc.LastOpenTime) { + doable = true + } + if doable { + inc.PushK(k) + inc.LastOpenTime = k.StartTime.Time() + inc.EmitUpdate(inc.Last(0)) + } + } +} + +func (inc *WWMA) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *WWMA) Bind(updater KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} + +var _ types.SeriesExtend = &WWMA{} diff --git a/pkg/indicator/wwma_callbacks.go b/pkg/indicator/wwma_callbacks.go new file mode 100644 index 0000000..2c5f57b --- /dev/null +++ b/pkg/indicator/wwma_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type WWMA"; DO NOT EDIT. + +package indicator + +import () + +func (inc *WWMA) OnUpdate(cb func(value float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *WWMA) EmitUpdate(value float64) { + for _, cb := range inc.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/zlema.go b/pkg/indicator/zlema.go new file mode 100644 index 0000000..856f2b1 --- /dev/null +++ b/pkg/indicator/zlema.go @@ -0,0 +1,93 @@ +package indicator + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// Refer: Zero Lag Exponential Moving Average +// Refer URL: https://en.wikipedia.org/wiki/Zero_lag_exponential_moving_average +// +// The Zero Lag Exponential Moving Average (ZLEMA) is a technical analysis indicator that is used to smooth price data and reduce the +// lag associated with traditional moving averages. It is calculated by taking the exponentially weighted moving average of the input +// data, and then applying a digital filter to the resulting average to eliminate any remaining lag. This filtered average is then +// plotted on the price chart as a line, which can be used to make predictions about future price movements. The ZLEMA is typically more +// responsive to changes in the underlying data than a simple moving average, but may be less reliable in trending markets. + +//go:generate callbackgen -type ZLEMA +type ZLEMA struct { + types.SeriesBase + types.IntervalWindow + + data floats.Slice + zlema *EWMA + lag int + + updateCallbacks []func(value float64) +} + +func (inc *ZLEMA) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *ZLEMA) Last(i int) float64 { + if inc.zlema == nil { + return 0 + } + return inc.zlema.Last(i) +} + +func (inc *ZLEMA) Length() int { + if inc.zlema == nil { + return 0 + } + return inc.zlema.Length() +} + +func (inc *ZLEMA) Update(value float64) { + if inc.lag == 0 || inc.zlema == nil { + inc.SeriesBase.Series = inc + inc.zlema = &EWMA{IntervalWindow: inc.IntervalWindow} + inc.lag = int((float64(inc.Window)-1.)/2. + 0.5) + } + inc.data.Push(value) + if len(inc.data) > MaxNumOfEWMA { + inc.data = inc.data[MaxNumOfEWMATruncateSize-1:] + } + if inc.lag >= inc.data.Length() { + return + } + emaData := 2.*value - inc.data[len(inc.data)-1-inc.lag] + inc.zlema.Update(emaData) +} + +var _ types.SeriesExtend = &ZLEMA{} + +func (inc *ZLEMA) PushK(k types.KLine) { + inc.Update(k.Close.Float64()) +} + +func (inc *ZLEMA) CalculateAndUpdate(allKLines []types.KLine) { + if inc.zlema == nil { + for _, k := range allKLines { + inc.PushK(k) + inc.EmitUpdate(inc.Last(0)) + } + } else { + k := allKLines[len(allKLines)-1] + inc.PushK(k) + inc.EmitUpdate(inc.Last(0)) + } +} + +func (inc *ZLEMA) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *ZLEMA) Bind(updater KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} diff --git a/pkg/indicator/zlema_callbacks.go b/pkg/indicator/zlema_callbacks.go new file mode 100644 index 0000000..98a84c6 --- /dev/null +++ b/pkg/indicator/zlema_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type ZLEMA"; DO NOT EDIT. + +package indicator + +import () + +func (inc *ZLEMA) OnUpdate(cb func(value float64)) { + inc.updateCallbacks = append(inc.updateCallbacks, cb) +} + +func (inc *ZLEMA) EmitUpdate(value float64) { + for _, cb := range inc.updateCallbacks { + cb(value) + } +} diff --git a/pkg/indicator/zlema_test.go b/pkg/indicator/zlema_test.go new file mode 100644 index 0000000..87d0db2 --- /dev/null +++ b/pkg/indicator/zlema_test.go @@ -0,0 +1,55 @@ +package indicator + +import ( + "encoding/json" + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +/* +python: + +import pandas as pd +s = pd.Series([0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9]) +lag = int((16-1)/2 + 0.5) +emadata = s + (s - s.shift(lag)) +result = emadata.ewm(span=16).mean() +print(result) +*/ +func Test_ZLEMA(t *testing.T) { + var Delta = 6.5e-2 + var randomPrices = []byte(`[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]`) + var input []fixedpoint.Value + if err := json.Unmarshal(randomPrices, &input); err != nil { + panic(err) + } + tests := []struct { + name string + kLines []types.KLine + want float64 + next float64 + all int + }{ + { + name: "random_case", + kLines: buildKLines(input), + want: 6.622881, + next: 5.231044, + all: 42, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + zlema := ZLEMA{IntervalWindow: types.IntervalWindow{Window: 16}} + zlema.CalculateAndUpdate(tt.kLines) + last := zlema.Last(0) + assert.InDelta(t, tt.want, last, Delta) + assert.InDelta(t, tt.next, zlema.Index(1), Delta) + assert.Equal(t, tt.all, zlema.Length()) + }) + } +} diff --git a/pkg/interact/auth.go b/pkg/interact/auth.go new file mode 100644 index 0000000..343ff38 --- /dev/null +++ b/pkg/interact/auth.go @@ -0,0 +1,129 @@ +package interact + +import ( + "errors" + "os" + "time" + + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" + log "github.com/sirupsen/logrus" +) + +type AuthMode string + +const ( + AuthModeOTP AuthMode = "OTP" + AuthModeToken AuthMode = "TOKEN" +) + +var ErrAuthenticationFailed = errors.New("authentication failed") + +type Authorizer interface { + StartAuthorizing() + Authorize() error +} + +type AuthInteract struct { + Strict bool `json:"strict,omitempty"` + + Mode AuthMode `json:"authMode"` + + Token string `json:"authToken,omitempty"` + + OneTimePasswordKey *otp.Key `json:"otpKey,omitempty"` +} + +func (it *AuthInteract) Commands(interact *Interact) { + if it.Strict { + // generate a one-time-use otp + // pragma: allowlist nextline secret + if it.OneTimePasswordKey == nil { + opts := totp.GenerateOpts{ + Issuer: "interact", + AccountName: os.Getenv("USER"), + Period: 30, + } + log.Infof("[interact] one-time password key is not configured, generating one with %+v", opts) + key, err := totp.Generate(opts) + if err != nil { + panic(err) + } + // pragma: allowlist nextline secret + it.OneTimePasswordKey = key + } + interact.Command("/auth", "authorize", func(reply Reply, session Session) error { + reply.Message("Please enter your authentication token") + session.SetAuthorizing(true) + return nil + }).Next(func(token string, reply Reply) error { + if token == it.Token { + reply.Message("Token passed, please enter your one-time password") + + code, err := totp.GenerateCode(it.OneTimePasswordKey.Secret(), time.Now()) + if err != nil { + return err + } + + log.Infof("[interact] ======================================") + log.Infof("[interact] your one-time password code: %s", code) + log.Infof("[interact] ======================================") + return nil + } + + return ErrAuthenticationFailed + }).NamedNext(StateAuthenticated, func(code string, reply Reply, session Session) error { + if totp.Validate(code, it.OneTimePasswordKey.Secret()) { + reply.Message("Great! You're authenticated!") + session.SetOriginState(StateAuthenticated) + session.SetAuthorized() + return nil + } + + reply.Message("Incorrect authentication code") + return ErrAuthenticationFailed + }) + } else { + interact.Command("/auth", "authorize", func(reply Reply, session Session) error { + switch it.Mode { + case AuthModeToken: + session.SetAuthorizing(true) + reply.Message("Enter your authentication token") + + case AuthModeOTP: + session.SetAuthorizing(true) + reply.Message("Enter your one-time password") + + default: + log.Warnf("unexpected auth mode: %s", it.Mode) + } + return nil + }).NamedNext(StateAuthenticated, func(code string, reply Reply, session Session) error { + switch it.Mode { + case AuthModeToken: + if code == it.Token { + reply.Message("Great! You're authenticated!") + session.SetOriginState(StateAuthenticated) + session.SetAuthorized() + return nil + } + reply.Message("Incorrect authentication token") + + case AuthModeOTP: + if totp.Validate(code, it.OneTimePasswordKey.Secret()) { + reply.Message("Great! You're authenticated!") + session.SetOriginState(StateAuthenticated) + session.SetAuthorized() + return nil + } + reply.Message("Incorrect one-time pass code") + + default: + log.Warnf("unexpected auth mode: %s", it.Mode) + } + + return ErrAuthenticationFailed + }) + } + +} diff --git a/pkg/interact/command.go b/pkg/interact/command.go new file mode 100644 index 0000000..9155730 --- /dev/null +++ b/pkg/interact/command.go @@ -0,0 +1,89 @@ +package interact + +import "strconv" + +// Command is a domain specific language syntax helper +// It's used for helping developer define the state and transition function +type Command struct { + // Name is the command name + Name string + + // Desc is the command description + Desc string + + // StateF is the command handler function + F interface{} + + stateID int + states map[State]State + statesFunc map[State]interface{} + initState, lastState State +} + +func NewCommand(name, desc string, f interface{}) *Command { + c := &Command{ + Name: name, + Desc: desc, + F: f, + states: make(map[State]State), + statesFunc: make(map[State]interface{}), + initState: State(name + "_" + strconv.Itoa(0)), + } + return c.Next(f) +} + +// Transit defines the state transition that is not related to the last defined state. +func (c *Command) Transit(state1, state2 State, f interface{}) *Command { + c.states[state1] = state2 + c.statesFunc[state1] = f + return c +} + +func (c *Command) NamedNext(n State, f interface{}) *Command { + var curState State + if c.lastState == "" { + curState = State(c.Name + "_" + strconv.Itoa(c.stateID)) + } else { + curState = c.lastState + } + + nextState := n + c.states[curState] = nextState + c.statesFunc[curState] = f + c.lastState = nextState + return c +} + +func (c *Command) Cycle(f interface{}) *Command { + var curState State + if c.lastState == "" { + curState = State(c.Name + "_" + strconv.Itoa(c.stateID)) + } else { + curState = c.lastState + } + + nextState := curState + c.states[curState] = nextState + c.statesFunc[curState] = f + c.lastState = nextState + return c +} + +// Next defines the next state with the transition function from the last defined state. +func (c *Command) Next(f interface{}) *Command { + var curState State + if c.lastState == "" { + curState = State(c.Name + "_" + strconv.Itoa(c.stateID)) + } else { + curState = c.lastState + } + + // generate the next state by the stateID + c.stateID++ + nextState := State(c.Name + "_" + strconv.Itoa(c.stateID)) + + c.states[curState] = nextState + c.statesFunc[curState] = f + c.lastState = nextState + return c +} diff --git a/pkg/interact/default.go b/pkg/interact/default.go new file mode 100644 index 0000000..4174122 --- /dev/null +++ b/pkg/interact/default.go @@ -0,0 +1,21 @@ +package interact + +import "context" + +var defaultInteraction = New() + +func Default() *Interact { + return defaultInteraction +} + +func AddMessenger(messenger Messenger) { + defaultInteraction.AddMessenger(messenger) +} + +func AddCustomInteraction(custom CustomInteraction) { + defaultInteraction.AddCustomInteraction(custom) +} + +func Start(ctx context.Context) error { + return defaultInteraction.Start(ctx) +} diff --git a/pkg/interact/interact.go b/pkg/interact/interact.go new file mode 100644 index 0000000..6c0c97d --- /dev/null +++ b/pkg/interact/interact.go @@ -0,0 +1,281 @@ +package interact + +import ( + "context" + "fmt" + "sync" + "time" + + log "github.com/sirupsen/logrus" +) + +type CustomInteraction interface { + Commands(interact *Interact) +} + +type Initializer interface { + Initialize() error +} + +type Messenger interface { + TextMessageResponder + CommandResponder + Start(ctx context.Context) +} + +type Session interface { + ID() string + SetOriginState(state State) + GetOriginState() State + SetState(state State) + GetState() State + IsAuthorized() bool + SetAuthorized() + SetAuthorizing(b bool) +} + +// Interact implements the interaction between bot and message software. +type Interact struct { + startTime time.Time + + // commands is the default public command map + commands map[string]*Command + + // privateCommands is the private command map, need auth + privateCommands map[string]*Command + + states map[State]State + statesFunc map[State]interface{} + + customInteractions []CustomInteraction + + messengers []Messenger + + mu sync.Mutex +} + +func New() *Interact { + return &Interact{ + startTime: time.Now(), + commands: make(map[string]*Command), + privateCommands: make(map[string]*Command), + states: make(map[State]State), + statesFunc: make(map[State]interface{}), + } +} + +func (it *Interact) AddCustomInteraction(custom CustomInteraction) { + custom.Commands(it) + + it.mu.Lock() + it.customInteractions = append(it.customInteractions, custom) + it.mu.Unlock() +} + +func (it *Interact) PrivateCommand(command, desc string, f interface{}) *Command { + cmd := NewCommand(command, desc, f) + it.mu.Lock() + it.privateCommands[command] = cmd + it.mu.Unlock() + return cmd +} + +func (it *Interact) Command(command string, desc string, f interface{}) *Command { + cmd := NewCommand(command, desc, f) + it.mu.Lock() + it.commands[command] = cmd + it.mu.Unlock() + return cmd +} + +func (it *Interact) getNextState(session Session, currentState State) (nextState State, final bool) { + var ok bool + final = false + + it.mu.Lock() + nextState, ok = it.states[currentState] + it.mu.Unlock() + + if ok { + // check if it's the final state + if _, hasTransition := it.statesFunc[nextState]; !hasTransition { + final = true + } + + return nextState, final + } + + // state not found, return to the origin state + return session.GetOriginState(), final +} + +func (it *Interact) handleResponse(session Session, text string, ctxObjects ...interface{}) error { + // We only need response when executing a command + switch session.GetState() { + case StatePublic, StateAuthenticated: + return nil + + } + + args := parseCommand(text) + + state := session.GetState() + f, ok := it.statesFunc[state] + if !ok { + return fmt.Errorf("state function of %s is not defined", state) + } + + ctxObjects = append(ctxObjects, session) + _, err := ParseFuncArgsAndCall(f, args, ctxObjects...) + if err != nil { + return err + } + + nextState, end := it.getNextState(session, state) + if end { + session.SetState(session.GetOriginState()) + return nil + } + + session.SetState(nextState) + return nil +} + +func (it *Interact) getCommand(session Session, command string) (*Command, error) { + it.mu.Lock() + defer it.mu.Unlock() + + if session.IsAuthorized() { + if cmd, ok := it.privateCommands[command]; ok { + return cmd, nil + } + } else { + if _, ok := it.privateCommands[command]; ok { + return nil, fmt.Errorf("private command can not be executed in the public mode, type /auth to get authorized") + } + } + + // find any public command + if cmd, ok := it.commands[command]; ok { + return cmd, nil + } + + return nil, fmt.Errorf("command %s not found", command) +} + +func (it *Interact) runCommand(session Session, command string, args []string, ctxObjects ...interface{}) error { + cmd, err := it.getCommand(session, command) + if err != nil { + return err + } + + ctxObjects = append(ctxObjects, session) + session.SetState(cmd.initState) + if _, err := ParseFuncArgsAndCall(cmd.F, args, ctxObjects...); err != nil { + return err + } + + // if we can successfully execute the command, then we can go to the next state. + state := session.GetState() + nextState, end := it.getNextState(session, state) + if end { + session.SetState(session.GetOriginState()) + return nil + } + + session.SetState(nextState) + return nil +} + +func (it *Interact) AddMessenger(messenger Messenger) { + // pass Responder function + messenger.SetTextMessageResponder(func(session Session, message string, reply Reply, ctxObjects ...interface{}) error { + return it.handleResponse(session, message, append(ctxObjects, reply)...) + }) + it.messengers = append(it.messengers, messenger) +} + +// builtin initializes the built-in commands +func (it *Interact) builtin() error { + it.Command("/uptime", "show bot uptime", func(reply Reply) error { + uptime := time.Since(it.startTime) + reply.Message(fmt.Sprintf("uptime %s", uptime)) + return nil + }) + + return nil +} + +func (it *Interact) init() error { + + if err := it.builtin(); err != nil { + return err + } + + if err := it.registerCommands(it.commands); err != nil { + return err + } + + if err := it.registerCommands(it.privateCommands); err != nil { + return err + } + + return nil +} + +func (it *Interact) registerCommands(commands map[string]*Command) error { + for n, cmd := range commands { + for s1, s2 := range cmd.states { + if _, exist := it.states[s1]; exist { + return fmt.Errorf("state %s already exists", s1) + } + + it.states[s1] = s2 + } + for s, f := range cmd.statesFunc { + it.statesFunc[s] = f + } + + // register commands to the service + if len(it.messengers) == 0 { + return fmt.Errorf("messenger is not set") + } + + // commandName is used in the closure, we need to copy the variable + commandName := n + for _, messenger := range it.messengers { + messenger.AddCommand(cmd, func(session Session, message string, reply Reply, ctxObjects ...interface{}) error { + args := parseCommand(message) + return it.runCommand(session, commandName, args, append(ctxObjects, reply)...) + }) + } + } + return nil +} + +func (it *Interact) Start(ctx context.Context) error { + if len(it.messengers) == 0 { + log.Warn("messenger is not set, skip initializing") + return nil + } + + if err := it.init(); err != nil { + return err + } + + for _, custom := range it.customInteractions { + log.Debugf("checking %T custom interaction...", custom) + if initializer, ok := custom.(Initializer); ok { + log.Debugf("initializing %T custom interaction...", custom) + if err := initializer.Initialize(); err != nil { + return err + } + } + } + + // TODO: use go routine and context + for _, m := range it.messengers { + go m.Start(ctx) + } + return nil +} diff --git a/pkg/interact/interact_test.go b/pkg/interact/interact_test.go new file mode 100644 index 0000000..8402ba1 --- /dev/null +++ b/pkg/interact/interact_test.go @@ -0,0 +1,143 @@ +package interact + +import ( + "bytes" + "errors" + "io" + "testing" + + "github.com/stretchr/testify/assert" + tb "gopkg.in/tucnak/telebot.v2" +) + +func Test_parseFuncArgsAndCall_NoErrorFunction(t *testing.T) { + noErrorFunc := func(a string, b float64, c bool) error { + assert.Equal(t, "BTCUSDT", a) + assert.Equal(t, 0.123, b) + assert.Equal(t, true, c) + return nil + } + + _, err := ParseFuncArgsAndCall(noErrorFunc, []string{"BTCUSDT", "0.123", "true"}) + assert.NoError(t, err) +} + +func Test_parseFuncArgsAndCall_ErrorFunction(t *testing.T) { + errorFunc := func(a string, b float64) error { + return errors.New("error") + } + + _, err := ParseFuncArgsAndCall(errorFunc, []string{"BTCUSDT", "0.123"}) + assert.Error(t, err) +} + +func Test_parseFuncArgsAndCall_InterfaceInjection(t *testing.T) { + f := func(w io.Writer, a string, b float64) error { + _, err := w.Write([]byte("123")) + return err + } + + buf := bytes.NewBuffer(nil) + _, err := ParseFuncArgsAndCall(f, []string{"BTCUSDT", "0.123"}, buf) + assert.NoError(t, err) + assert.Equal(t, "123", buf.String()) +} + +func Test_parseCommand(t *testing.T) { + args := parseCommand(`closePosition "BTC USDT" 3.1415926 market`) + t.Logf("args: %+v", args) + for i, a := range args { + t.Logf("args(%d): %#v", i, a) + } + + assert.Equal(t, 4, len(args)) + assert.Equal(t, "closePosition", args[0]) + assert.Equal(t, "BTC USDT", args[1]) + assert.Equal(t, "3.1415926", args[2]) + assert.Equal(t, "market", args[3]) +} + +type closePositionTask struct { + symbol string + percentage float64 + confirmed bool +} + +type TestInteraction struct { + closePositionTask closePositionTask +} + +func (m *TestInteraction) Commands(interact *Interact) { + interact.Command("/closePosition", "", func(reply Reply) error { + // send symbol options + return nil + }).Next(func(symbol string) error { + // get symbol from user + m.closePositionTask.symbol = symbol + + // send percentage options + return nil + }).Next(func(percentage float64) error { + // get percentage from user + m.closePositionTask.percentage = percentage + + // send confirmation + return nil + }).Next(func(confirmed bool) error { + m.closePositionTask.confirmed = confirmed + // call position close + + // reply result + return nil + }) +} + +func TestCustomInteraction(t *testing.T) { + b, err := tb.NewBot(tb.Settings{ + Offline: true, + }) + if !assert.NoError(t, err, "should have bot setup without error") { + return + } + + globalInteraction := New() + + telegram := &Telegram{ + Bot: b, + } + globalInteraction.AddMessenger(telegram) + + testInteraction := &TestInteraction{} + testInteraction.Commands(globalInteraction) + + err = globalInteraction.init() + assert.NoError(t, err) + + m := &tb.Message{ + Chat: &tb.Chat{ID: 22}, + Sender: &tb.User{ID: 999}, + } + session := telegram.loadSession(m) + err = globalInteraction.runCommand(session, "/closePosition", []string{}, telegram.newReply(session)) + assert.NoError(t, err) + + assert.Equal(t, State("/closePosition_1"), session.CurrentState) + + err = globalInteraction.handleResponse(session, "BTCUSDT", telegram.newReply(session)) + assert.NoError(t, err) + assert.Equal(t, State("/closePosition_2"), session.CurrentState) + + err = globalInteraction.handleResponse(session, "0.20", telegram.newReply(session)) + assert.NoError(t, err) + assert.Equal(t, State("/closePosition_3"), session.CurrentState) + + err = globalInteraction.handleResponse(session, "true", telegram.newReply(session)) + assert.NoError(t, err) + assert.Equal(t, State("public"), session.CurrentState) + + assert.Equal(t, closePositionTask{ + symbol: "BTCUSDT", + percentage: 0.2, + confirmed: true, + }, testInteraction.closePositionTask) +} diff --git a/pkg/interact/parse.go b/pkg/interact/parse.go new file mode 100644 index 0000000..64f5587 --- /dev/null +++ b/pkg/interact/parse.go @@ -0,0 +1,134 @@ +package interact + +import ( + "reflect" + "strconv" + "strings" + "text/scanner" + + "github.com/mattn/go-shellwords" + log "github.com/sirupsen/logrus" +) + +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) + objV := reflect.ValueOf(obj) + + log.Debugln( + at.PkgPath(), + at.Name(), + objT, "implements", at, "=", objT.Implements(at), + ) + + if objT.Implements(at) { + found = true + rArgs = append(rArgs, objV) + break + } + } + + if !found { + v := reflect.Zero(at) + rArgs = append(rArgs, v) + } + + case reflect.String: + av := reflect.ValueOf(args[argIndex]) + rArgs = append(rArgs, av) + argIndex++ + + case reflect.Bool: + bv, err := strconv.ParseBool(args[argIndex]) + if err != nil { + return "", err + } + av := reflect.ValueOf(bv) + rArgs = append(rArgs, av) + argIndex++ + + case reflect.Int64: + nf, err := strconv.ParseInt(args[argIndex], 10, 64) + if err != nil { + return "", err + } + + av := reflect.ValueOf(nf) + rArgs = append(rArgs, av) + argIndex++ + + case reflect.Float64: + nf, err := strconv.ParseFloat(args[argIndex], 64) + if err != nil { + return "", err + } + + av := reflect.ValueOf(nf) + rArgs = append(rArgs, av) + argIndex++ + } + } + + out := fv.Call(rArgs) + if ft.NumOut() == 0 { + return "", nil + } + + // try to get the error object from the return value + var err error + var state State + for i := 0; i < ft.NumOut(); i++ { + outType := ft.Out(i) + switch outType.Kind() { + case reflect.String: + if outType.Name() == "State" { + state = State(out[i].String()) + } + + case reflect.Interface: + o := out[i].Interface() + switch ov := o.(type) { + case error: + err = ov + + } + } + } + return state, err +} + +func parseCommand(src string) (args []string) { + var err error + args, err = shellwords.Parse(src) + if err == nil { + return args + } + + // fallback to go text/scanner + var s scanner.Scanner + s.Init(strings.NewReader(src)) + s.Filename = "command" + for tok := s.Scan(); tok != scanner.EOF; tok = s.Scan() { + text := s.TokenText() + if text[0] == '"' && text[len(text)-1] == '"' { + text, _ = strconv.Unquote(text) + } + args = append(args, text) + } + + return args +} diff --git a/pkg/interact/reply.go b/pkg/interact/reply.go new file mode 100644 index 0000000..11ead2f --- /dev/null +++ b/pkg/interact/reply.go @@ -0,0 +1,65 @@ +package interact + +type Button struct { + Text string + Name string + Value string +} + +type TextField struct { + // Name is the form field name + Name string + + // Label is the field label + Label string + + // PlaceHolder is the sample text in the text input + PlaceHolder string +} + +type Option struct { + // Name is the form field name + Name string + + // Label is the option label for display + Label string + + // Value is the option value + Value string +} + +type Reply interface { + // Send sends the message directly to the client's session + Send(message string) + + // Message sets the message to the reply + Message(message string) + + // AddButton adds the button to the reply + AddButton(text string, name, value string) + + // AddMultipleButtons adds multiple buttons to the reply + AddMultipleButtons(buttonsForm [][3]string) + + // Choose(prompt string, options ...Option) + // Confirm shows the confirm dialog or confirm button in the user interface + // Confirm(prompt string) +} + +// KeyboardController is used when messenger supports keyboard controls +type KeyboardController interface { + // RemoveKeyboard hides the keyboard from the client user interface + RemoveKeyboard() +} + +// ButtonReply can be used if your reply needs button user interface. +type ButtonReply interface { + // AddButton adds the button to the reply + AddButton(text string) +} + +// DialogReply can be used if your reply needs Dialog user interface +type DialogReply interface { + // AddButton adds the button to the reply + Dialog(title, text string, buttons []string) +} diff --git a/pkg/interact/responder.go b/pkg/interact/responder.go new file mode 100644 index 0000000..1adfffe --- /dev/null +++ b/pkg/interact/responder.go @@ -0,0 +1,16 @@ +package interact + +// Responder defines the logic of responding the message +type Responder func(session Session, message string, reply Reply, ctxObjects ...interface{}) error + +type CallbackResponder interface { + SetCallbackResponder(responder Responder) +} + +type TextMessageResponder interface { + SetTextMessageResponder(responder Responder) +} + +type CommandResponder interface { + AddCommand(command *Command, responder Responder) +} diff --git a/pkg/interact/session.go b/pkg/interact/session.go new file mode 100644 index 0000000..ae8f01e --- /dev/null +++ b/pkg/interact/session.go @@ -0,0 +1,47 @@ +package interact + +import ( + "time" + + log "github.com/sirupsen/logrus" +) + +type BaseSession struct { + OriginState State `json:"originState,omitempty"` + CurrentState State `json:"currentState,omitempty"` + Authorized bool `json:"authorized,omitempty"` + StartedTime time.Time `json:"startedTime,omitempty"` + + // authorizing -- the user started authorizing himself/herself, do not ignore the message + authorizing bool +} + +func (s *BaseSession) SetOriginState(state State) { + s.OriginState = state +} + +func (s *BaseSession) GetOriginState() State { + return s.OriginState +} + +func (s *BaseSession) SetState(state State) { + log.Infof("[interact] transiting state from %s -> %s", s.CurrentState, state) + s.CurrentState = state +} + +func (s *BaseSession) GetState() State { + return s.CurrentState +} + +func (s *BaseSession) SetAuthorized() { + s.Authorized = true + s.authorizing = false +} + +func (s *BaseSession) IsAuthorized() bool { + return s.Authorized +} + +func (s *BaseSession) SetAuthorizing(b bool) { + s.authorizing = b +} diff --git a/pkg/interact/slack.go b/pkg/interact/slack.go new file mode 100644 index 0000000..9a9e04d --- /dev/null +++ b/pkg/interact/slack.go @@ -0,0 +1,551 @@ +package interact + +import ( + "context" + "encoding/json" + "fmt" + stdlog "log" + "os" + "time" + + "github.com/google/uuid" + log "github.com/sirupsen/logrus" + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + "github.com/slack-go/slack/socketmode" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +type SlackReply struct { + // uuid is the unique id of this question + // can be used as the callback id + uuid string + + session *SlackSession + + client *slack.Client + + message string + + buttons []Button + + textInputModalViewRequest *slack.ModalViewRequest +} + +func (reply *SlackReply) Send(message string) { + cID, tsID, err := reply.client.PostMessage( + reply.session.ChannelID, + slack.MsgOptionText(message, false), + slack.MsgOptionAsUser(false), // Add this if you want that the bot would post message as a user, otherwise it will send response using the default slackbot + ) + if err != nil { + log.WithError(err).Errorf("slack post message error: channel=%s thread=%s", cID, tsID) + return + } +} + +func (reply *SlackReply) InputText(prompt string, textFields ...TextField) { + reply.message = prompt + reply.textInputModalViewRequest = generateTextInputModalRequest(prompt, prompt, textFields...) +} + +func (reply *SlackReply) Choose(prompt string, options ...Option) { +} + +func (reply *SlackReply) Message(message string) { + reply.message = message +} + +// RemoveKeyboard is not supported by Slack +func (reply *SlackReply) RemoveKeyboard() {} + +func (reply *SlackReply) AddButton(text string, name string, value string) { + reply.buttons = append(reply.buttons, Button{ + Text: text, + Name: name, + Value: value, + }) +} + +func (reply *SlackReply) AddMultipleButtons(buttonsForm [][3]string) { + for _, buttonForm := range buttonsForm { + reply.AddButton(buttonForm[0], buttonForm[1], buttonForm[2]) + } +} + +func (reply *SlackReply) build() interface{} { + // you should avoid using this modal view request, because it interrupts the interaction flow + // once we send the modal view request, we can't go back to the channel. + // (we don't know which channel the user started the interaction) + if reply.textInputModalViewRequest != nil { + return reply.textInputModalViewRequest + } + + if len(reply.message) > 0 { + return reply.message + } + + var blocks slack.Blocks + blocks.BlockSet = append(blocks.BlockSet, slack.NewSectionBlock( + &slack.TextBlockObject{ + Type: slack.MarkdownType, + Text: reply.message, + }, + nil, // fields + nil, // accessory + slack.SectionBlockOptionBlockID(reply.uuid), + )) + + if len(reply.buttons) > 0 { + var buttons []slack.BlockElement + for _, btn := range reply.buttons { + actionID := reply.uuid + ":" + btn.Value + buttons = append(buttons, + slack.NewButtonBlockElement( + // action id should be unique + actionID, + btn.Value, + &slack.TextBlockObject{ + Type: slack.PlainTextType, + Text: btn.Text, + }, + ), + ) + } + blocks.BlockSet = append(blocks.BlockSet, slack.NewActionBlock(reply.uuid, buttons...)) + } + + return blocks +} + +type SlackSession struct { + BaseSession + + slack *Slack + ChannelID string + UserID string +} + +func NewSlackSession(slack *Slack, userID, channelID string) *SlackSession { + return &SlackSession{ + BaseSession: BaseSession{ + OriginState: StatePublic, + CurrentState: StatePublic, + Authorized: false, + authorizing: false, + + StartedTime: time.Now(), + }, + slack: slack, + UserID: userID, + ChannelID: channelID, + } +} + +func (s *SlackSession) ID() string { + return fmt.Sprintf("%s-%s", s.UserID, s.ChannelID) +} + +func (s *SlackSession) SetAuthorized() { + s.BaseSession.SetAuthorized() + s.slack.EmitAuthorized(s) +} + +type SlackSessionMap map[string]*SlackSession + +//go:generate callbackgen -type Slack +type Slack struct { + client *slack.Client + socket *socketmode.Client + + sessions SlackSessionMap + + commands map[string]*Command + commandResponders map[string]Responder + + // textMessageResponder is used for interact to register its message handler + textMessageResponder Responder + + authorizedCallbacks []func(userSession *SlackSession) + + eventsApiCallbacks []func(evt slackevents.EventsAPIEvent) +} + +func NewSlack(client *slack.Client) *Slack { + var opts = []socketmode.Option{ + socketmode.OptionLog( + stdlog.New(os.Stdout, "socketmode: ", + stdlog.Lshortfile|stdlog.LstdFlags)), + } + + if b, ok := util.GetEnvVarBool("DEBUG_SLACK"); ok { + opts = append(opts, socketmode.OptionDebug(b)) + } + + socket := socketmode.New(client, opts...) + return &Slack{ + client: client, + socket: socket, + sessions: make(SlackSessionMap), + commands: make(map[string]*Command), + commandResponders: make(map[string]Responder), + } +} + +func (s *Slack) SetTextMessageResponder(responder Responder) { + s.textMessageResponder = responder +} + +func (s *Slack) AddCommand(command *Command, responder Responder) { + if _, exists := s.commands[command.Name]; exists { + panic(fmt.Errorf("command %s already exists, can not be re-defined", command.Name)) + } + + s.commands[command.Name] = command + s.commandResponders[command.Name] = responder +} + +func (s *Slack) listen(ctx context.Context) { + for evt := range s.socket.Events { + log.Debugf("event: %+v", evt) + + switch evt.Type { + case socketmode.EventTypeConnecting: + log.Infof("connecting to slack with socket mode...") + + case socketmode.EventTypeConnectionError: + log.Infof("connection failed. retrying later...") + + case socketmode.EventTypeConnected: + log.Infof("connected to slack with socket mode.") + + case socketmode.EventTypeDisconnect: + log.Infof("slack socket mode disconnected") + + case socketmode.EventTypeEventsAPI: + eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) + if !ok { + log.Debugf("ignored %+v", evt) + continue + } + + log.Debugf("event received: %+v", eventsAPIEvent) + + // events api don't have response trigger, we can't set the response + s.socket.Ack(*evt.Request) + + s.EmitEventsApi(eventsAPIEvent) + + switch eventsAPIEvent.Type { + case slackevents.CallbackEvent: + innerEvent := eventsAPIEvent.InnerEvent + switch ev := innerEvent.Data.(type) { + case *slackevents.MessageEvent: + log.Debugf("message event: text=%+v", ev.Text) + + if len(ev.BotID) > 0 { + log.Debug("skip bot message") + continue + } + + session := s.loadSession(evt, ev.User, ev.Channel) + + if !session.authorizing && !session.Authorized { + log.Warn("[slack] session is not authorizing nor authorized, skipping message handler") + continue + } + + if s.textMessageResponder != nil { + reply := s.newReply(session) + if err := s.textMessageResponder(session, ev.Text, reply); err != nil { + log.WithError(err).Errorf("[slack] response handling error") + continue + } + + // build the response + response := reply.build() + + log.Debugln("response payload", toJson(response)) + switch response := response.(type) { + + case string: + _, _, err := s.client.PostMessage(ev.Channel, slack.MsgOptionText(response, false)) + if err != nil { + log.WithError(err).Error("failed posting plain text message") + } + case slack.Blocks: + _, _, err := s.client.PostMessage(ev.Channel, slack.MsgOptionBlocks(response.BlockSet...)) + if err != nil { + log.WithError(err).Error("failed posting blocks message") + } + + default: + log.Errorf("[slack] unexpected message type %T: %+v", response, response) + + } + } + + case *slackevents.AppMentionEvent: + log.Infof("app mention event: %+v", ev) + s.socket.Ack(*evt.Request) + + case *slackevents.MemberJoinedChannelEvent: + log.Infof("user %q joined to channel %q", ev.User, ev.Channel) + s.socket.Ack(*evt.Request) + } + default: + s.socket.Debugf("unsupported Events API event received") + } + + case socketmode.EventTypeInteractive: + callback, ok := evt.Data.(slack.InteractionCallback) + if !ok { + log.Debugf("ignored %+v", evt) + continue + } + + log.Debugf("interaction received: %+v", callback) + + var payload interface{} + + switch callback.Type { + case slack.InteractionTypeBlockActions: + // See https://api.slack.com/apis/connections/socket-implement#button + log.Debugf("InteractionTypeBlockActions: %+v", callback) + + case slack.InteractionTypeShortcut: + log.Debugf("InteractionTypeShortcut: %+v", callback) + + case slack.InteractionTypeViewSubmission: + + // See https://api.slack.com/apis/connections/socket-implement#modal + log.Debugf("[slack] InteractionTypeViewSubmission: %+v", callback) + var values = simplifyStateValues(callback.View.State) + + if len(values) > 1 { + log.Warnf("[slack] more than 1 values received from the modal view submission, the value choosen from the state values might be incorrect") + } + + log.Debugln(toJson(values)) + if inputValue, ok := takeOneValue(values); ok { + session := s.loadSession(evt, callback.User.ID, callback.Channel.ID) + + if !session.authorizing && !session.Authorized { + log.Warn("[slack] telegram is set to private mode, skipping message") + continue + } + + reply := s.newReply(session) + if s.textMessageResponder != nil { + if err := s.textMessageResponder(session, inputValue, reply); err != nil { + log.WithError(err).Errorf("[slack] response handling error") + continue + } + } + + // close the modal view by sending a null payload + s.socket.Ack(*evt.Request) + + // build the response + response := reply.build() + + log.Debugln("response payload", toJson(response)) + switch response := response.(type) { + + case string: + payload = map[string]interface{}{ + "blocks": []slack.Block{ + translateMessageToBlock(response), + }, + } + + case slack.Blocks: + payload = map[string]interface{}{ + "blocks": response.BlockSet, + } + default: + s.socket.Ack(*evt.Request, response) + } + } + + case slack.InteractionTypeDialogSubmission: + log.Debugf("[slack] InteractionTypeDialogSubmission: %+v", callback) + + default: + log.Debugf("[slack] unexpected callback type: %+v", callback) + + } + + s.socket.Ack(*evt.Request, payload) + + case socketmode.EventTypeHello: + log.Debugf("[slack] hello command received: %+v", evt) + + case socketmode.EventTypeSlashCommand: + slashCmd, ok := evt.Data.(slack.SlashCommand) + if !ok { + log.Debugf("[slack] ignored %+v", evt) + continue + } + + log.Debugf("[slack] slash command received: %+v", slashCmd) + responder, exists := s.commandResponders[slashCmd.Command] + if !exists { + log.Errorf("[slack] command %s does not exist", slashCmd.Command) + s.socket.Ack(*evt.Request) + continue + } + + session := s.loadSession(evt, slashCmd.UserID, slashCmd.ChannelID) + reply := s.newReply(session) + if err := responder(session, slashCmd.Text, reply); err != nil { + log.WithError(err).Errorf("[slack] responder returns error") + s.socket.Ack(*evt.Request) + continue + } + + payload := reply.build() + if payload == nil { + log.Warnf("[slack] reply returns nil payload") + // ack with empty payload + s.socket.Ack(*evt.Request) + continue + } + + switch o := payload.(type) { + + case string: + s.socket.Ack(*evt.Request, map[string]interface{}{ + "blocks": []slack.Block{ + translateMessageToBlock(o), + }, + }) + + case *slack.ModalViewRequest: + if resp, err := s.socket.OpenView(slashCmd.TriggerID, *o); err != nil { + log.WithError(err).Errorf("[slack] view open error, resp: %+v", resp) + } + s.socket.Ack(*evt.Request) + + case slack.Blocks: + s.socket.Ack(*evt.Request, map[string]interface{}{ + "blocks": o.BlockSet, + }) + default: + s.socket.Ack(*evt.Request, o) + } + + default: + log.Debugf("[slack] unexpected event type received: %s", evt.Type) + } + } +} + +func (s *Slack) loadSession(evt socketmode.Event, userID, channelID string) *SlackSession { + key := userID + "-" + channelID + if session, ok := s.sessions[key]; ok { + log.Infof("[slack] an existing session %q found, session: %+v", key, session) + return session + } + + session := NewSlackSession(s, userID, channelID) + s.sessions[key] = session + log.Infof("[slack] allocated a new session %q, session: %+v", key, session) + return session +} + +func (s *Slack) newReply(session *SlackSession) *SlackReply { + return &SlackReply{ + uuid: uuid.New().String(), + session: session, + } +} + +func (s *Slack) Start(ctx context.Context) { + go s.listen(ctx) + if err := s.socket.Run(); err != nil { + log.WithError(err).Errorf("slack socketmode error") + } +} + +// generateTextInputModalRequest generates a general slack modal view request with the given text fields +// see also https://api.slack.com/surfaces/modals/using#opening +func generateTextInputModalRequest(title string, prompt string, textFields ...TextField) *slack.ModalViewRequest { + // create a ModalViewRequest with a header and two inputs + titleText := slack.NewTextBlockObject("plain_text", title, false, false) + closeText := slack.NewTextBlockObject("plain_text", "Close", false, false) + submitText := slack.NewTextBlockObject("plain_text", "Submit", false, false) + + headerText := slack.NewTextBlockObject("mrkdwn", prompt, false, false) + headerSection := slack.NewSectionBlock(headerText, nil, nil) + + blocks := slack.Blocks{ + BlockSet: []slack.Block{ + headerSection, + }, + } + + for _, textField := range textFields { + labelObject := slack.NewTextBlockObject("plain_text", textField.Label, false, false) + placeHolderObject := slack.NewTextBlockObject("plain_text", textField.PlaceHolder, false, false) + textInputObject := slack.NewPlainTextInputBlockElement(placeHolderObject, textField.Name) + + // Notice that blockID is a unique identifier for a block + inputBlock := slack.NewInputBlock("block-"+textField.Name+"-"+uuid.NewString(), labelObject, textInputObject) + blocks.BlockSet = append(blocks.BlockSet, inputBlock) + } + + var modalRequest slack.ModalViewRequest + modalRequest.Type = slack.ViewType("modal") + modalRequest.Title = titleText + modalRequest.Close = closeText + modalRequest.Submit = submitText + modalRequest.Blocks = blocks + return &modalRequest +} + +// simplifyStateValues simplifies the multi-layer structured values into just name=value mapping +func simplifyStateValues(state *slack.ViewState) map[string]string { + var values = make(map[string]string) + + if state == nil { + return values + } + + for blockID, fields := range state.Values { + _ = blockID + for fieldName, fieldValues := range fields { + values[fieldName] = fieldValues.Value + } + } + return values +} + +func takeOneValue(values map[string]string) (string, bool) { + for _, v := range values { + return v, true + } + return "", false +} + +func toJson(v interface{}) string { + o, err := json.MarshalIndent(v, "", " ") + if err != nil { + log.WithError(err).Errorf("json marshal error") + return "" + } + return string(o) +} + +func translateMessageToBlock(message string) slack.Block { + return slack.NewSectionBlock( + &slack.TextBlockObject{ + Type: slack.MarkdownType, + Text: message, + }, + nil, // fields + nil, // accessory + // slack.SectionBlockOptionBlockID(reply.uuid), + ) +} diff --git a/pkg/interact/slack_callbacks.go b/pkg/interact/slack_callbacks.go new file mode 100644 index 0000000..40460f3 --- /dev/null +++ b/pkg/interact/slack_callbacks.go @@ -0,0 +1,27 @@ +// Code generated by "callbackgen -type Slack"; DO NOT EDIT. + +package interact + +import ( + "github.com/slack-go/slack/slackevents" +) + +func (s *Slack) OnAuthorized(cb func(userSession *SlackSession)) { + s.authorizedCallbacks = append(s.authorizedCallbacks, cb) +} + +func (s *Slack) EmitAuthorized(userSession *SlackSession) { + for _, cb := range s.authorizedCallbacks { + cb(userSession) + } +} + +func (s *Slack) OnEventsApi(cb func(evt slackevents.EventsAPIEvent)) { + s.eventsApiCallbacks = append(s.eventsApiCallbacks, cb) +} + +func (s *Slack) EmitEventsApi(evt slackevents.EventsAPIEvent) { + for _, cb := range s.eventsApiCallbacks { + cb(evt) + } +} diff --git a/pkg/interact/state.go b/pkg/interact/state.go new file mode 100644 index 0000000..3bf517d --- /dev/null +++ b/pkg/interact/state.go @@ -0,0 +1,8 @@ +package interact + +type State string + +const ( + StatePublic State = "public" + StateAuthenticated State = "authenticated" +) diff --git a/pkg/interact/telegram.go b/pkg/interact/telegram.go new file mode 100644 index 0000000..4ed7485 --- /dev/null +++ b/pkg/interact/telegram.go @@ -0,0 +1,285 @@ +package interact + +import ( + "context" + "fmt" + "strings" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" + log "github.com/sirupsen/logrus" + "golang.org/x/time/rate" +) + +func init() { + // force interface type check + _ = Reply(&TelegramReply{}) +} + +var sendLimiter = rate.NewLimiter(10, 2) + +const maxMessageSize int = 3000 + +type TelegramSessionMap map[int64]*TelegramSession + +type TelegramSession struct { + BaseSession + + telegram *Telegram + + User *telebot.User `json:"user"` + Chat *telebot.Chat `json:"chat"` +} + +func (s *TelegramSession) ID() string { + return fmt.Sprintf("telegram-%d-%d", s.User.ID, s.Chat.ID) +} + +func (s *TelegramSession) SetAuthorized() { + s.BaseSession.SetAuthorized() + s.telegram.EmitAuthorized(s) +} + +func NewTelegramSession(telegram *Telegram, message *telebot.Message) *TelegramSession { + return &TelegramSession{ + BaseSession: BaseSession{ + OriginState: StatePublic, + CurrentState: StatePublic, + Authorized: false, + authorizing: false, + + StartedTime: time.Now(), + }, + telegram: telegram, + User: message.Sender, + Chat: message.Chat, + } +} + +type TelegramReply struct { + bot *telebot.Bot + session *TelegramSession + + message string + menu *telebot.ReplyMarkup + buttons []telebot.Btn + set bool +} + +func (r *TelegramReply) Send(message string) { + ctx := context.Background() + splits := util.StringSplitByLength(message, maxMessageSize) + for _, split := range splits { + if err := sendLimiter.Wait(ctx); err != nil { + log.WithError(err).Errorf("telegram send limit exceeded") + return + } + checkSendErr(r.bot.Send(r.session.Chat, split)) + } +} + +func (r *TelegramReply) Message(message string) { + r.message = message + r.set = true +} + +func (r *TelegramReply) RemoveKeyboard() { + r.menu.ReplyKeyboardRemove = true + r.set = true +} + +func (r *TelegramReply) AddButton(text string, name string, value string) { + var button = r.menu.Text(text) + r.buttons = append(r.buttons, button) + r.set = true +} + +func (r *TelegramReply) AddMultipleButtons(buttonsForm [][3]string) { + for _, buttonForm := range buttonsForm { + r.AddButton(buttonForm[0], buttonForm[1], buttonForm[2]) + } +} + +func (r *TelegramReply) build() { + var rows []telebot.Row + for _, button := range r.buttons { + rows = append(rows, telebot.Row{ + button, + }) + } + r.menu.Reply(rows...) +} + +//go:generate callbackgen -type Telegram +type Telegram struct { + Bot *telebot.Bot `json:"-"` + + // Private is used to protect the telegram bot, users not authenticated can not see messages or sending commands + Private bool `json:"private,omitempty"` + + sessions TelegramSessionMap + + // textMessageResponder is used for interact to register its message handler + textMessageResponder Responder + + callbackResponder CallbackResponder + + commands []*Command + + authorizedCallbacks []func(s *TelegramSession) +} + +func NewTelegram(bot *telebot.Bot) *Telegram { + return &Telegram{ + Bot: bot, + Private: true, + sessions: make(map[int64]*TelegramSession), + } +} + +func (tm *Telegram) SetCallbackResponder(responder CallbackResponder) { + tm.callbackResponder = responder +} + +func (tm *Telegram) SetTextMessageResponder(responder Responder) { + tm.textMessageResponder = responder +} + +func (tm *Telegram) Start(ctx context.Context) { + tm.Bot.Handle(telebot.OnCallback, func(c *telebot.Callback) { + log.Infof("[telegram] onCallback: %+v", c) + }) + + tm.Bot.Handle(telebot.OnText, func(m *telebot.Message) { + log.Infof("[telegram] onText: %+v", m) + + session := tm.loadSession(m) + if tm.Private { + if !session.authorizing && !session.Authorized { + log.Warn("[telegram] telegram is set to private mode, skipping message") + return + } + } + + reply := tm.newReply(session) + if tm.textMessageResponder != nil { + if err := tm.textMessageResponder(session, m.Text, reply); err != nil { + log.WithError(err).Errorf("[telegram] response handling error") + } + } + + if reply.set { + reply.build() + if len(reply.message) > 0 || reply.menu != nil { + splits := util.StringSplitByLength(reply.message, maxMessageSize) + for i, split := range splits { + if err := sendLimiter.Wait(ctx); err != nil { + log.WithError(err).Errorf("telegram send limit exceeded") + return + } + if i == len(splits)-1 { + // only set menu on the last message + checkSendErr(tm.Bot.Send(m.Chat, split, reply.menu)) + } else { + checkSendErr(tm.Bot.Send(m.Chat, split)) + } + } + } + } + }) + + var cmdList []telebot.Command + for _, cmd := range tm.commands { + if len(cmd.Desc) == 0 { + continue + } + + cmdList = append(cmdList, telebot.Command{ + Text: strings.ToLower(strings.TrimLeft(cmd.Name, "/")), + Description: cmd.Desc, + }) + } + if err := tm.Bot.SetCommands(cmdList); err != nil { + log.WithError(err).Errorf("[telegram] set commands error") + } + + tm.Bot.Start() +} + +func checkSendErr(m *telebot.Message, err error) { + if err != nil { + log.WithError(err).Errorf("[telegram] message send error") + } +} + +func (tm *Telegram) loadSession(m *telebot.Message) *TelegramSession { + if tm.sessions == nil { + tm.sessions = make(map[int64]*TelegramSession) + } + + session, ok := tm.sessions[m.Chat.ID] + if ok { + log.Infof("[telegram] loaded existing session: %+v", session) + return session + } + + session = NewTelegramSession(tm, m) + tm.sessions[m.Chat.ID] = session + + log.Infof("[telegram] allocated a new session: %+v", session) + return session +} + +func (tm *Telegram) AddCommand(cmd *Command, responder Responder) { + tm.commands = append(tm.commands, cmd) + tm.Bot.Handle(cmd.Name, func(m *telebot.Message) { + session := tm.loadSession(m) + reply := tm.newReply(session) + if err := responder(session, m.Payload, reply); err != nil { + log.WithError(err).Errorf("[telegram] responder error") + checkSendErr(tm.Bot.Send(m.Chat, fmt.Sprintf("error: %v", err))) + return + } + + // build up the response objects + if reply.set { + reply.build() + checkSendErr(tm.Bot.Send(m.Chat, reply.message, reply.menu)) + } + }) +} + +func (tm *Telegram) newReply(session *TelegramSession) *TelegramReply { + return &TelegramReply{ + bot: tm.Bot, + session: session, + menu: &telebot.ReplyMarkup{ResizeReplyKeyboard: true}, + } +} + +func (tm *Telegram) Sessions() TelegramSessionMap { + return tm.sessions +} + +func (tm *Telegram) RestoreSessions(sessions TelegramSessionMap) { + if len(sessions) == 0 { + return + } + + log.Infof("[telegram] restoring telegram %d sessions", len(sessions)) + tm.sessions = sessions + for _, session := range sessions { + if session.Chat == nil || session.User == nil { + continue + } + + // update telegram context reference + session.telegram = tm + + if session.IsAuthorized() { + if _, err := tm.Bot.Send(session.Chat, fmt.Sprintf("Hi %s, I'm back. Your telegram session is restored.", session.User.Username)); err != nil { + log.WithError(err).Error("[telegram] can not send telegram message") + } + } + } +} diff --git a/pkg/interact/telegram_callbacks.go b/pkg/interact/telegram_callbacks.go new file mode 100644 index 0000000..bc3c15b --- /dev/null +++ b/pkg/interact/telegram_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type Telegram"; DO NOT EDIT. + +package interact + +import () + +func (tm *Telegram) OnAuthorized(cb func(s *TelegramSession)) { + tm.authorizedCallbacks = append(tm.authorizedCallbacks, cb) +} + +func (tm *Telegram) EmitAuthorized(s *TelegramSession) { + for _, cb := range tm.authorizedCallbacks { + cb(s) + } +} diff --git a/pkg/migrations/mysql/main_20200721225616_trades.go b/pkg/migrations/mysql/main_20200721225616_trades.go new file mode 100644 index 0000000..bc01d80 --- /dev/null +++ b/pkg/migrations/mysql/main_20200721225616_trades.go @@ -0,0 +1,53 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_trades, down_main_trades) +} + +func up_main_trades(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `trades`\n(\n `gid` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n `id` BIGINT UNSIGNED,\n `order_id` BIGINT UNSIGNED NOT NULL,\n `exchange` VARCHAR(24) NOT NULL DEFAULT '',\n `symbol` VARCHAR(20) NOT NULL,\n `price` DECIMAL(16, 8) UNSIGNED NOT NULL,\n `quantity` DECIMAL(16, 8) UNSIGNED NOT NULL,\n `quote_quantity` DECIMAL(16, 8) UNSIGNED NOT NULL,\n `fee` DECIMAL(16, 8) UNSIGNED NOT NULL,\n `fee_currency` VARCHAR(10) NOT NULL,\n `is_buyer` BOOLEAN NOT NULL DEFAULT FALSE,\n `is_maker` BOOLEAN NOT NULL DEFAULT FALSE,\n `side` VARCHAR(4) NOT NULL DEFAULT '',\n `traded_at` DATETIME(3) NOT NULL,\n `is_margin` BOOLEAN NOT NULL DEFAULT FALSE,\n `is_isolated` BOOLEAN NOT NULL DEFAULT FALSE,\n `strategy` VARCHAR(32) NULL,\n `pnl` DECIMAL NULL,\n PRIMARY KEY (`gid`),\n UNIQUE KEY `id` (`exchange`, `symbol`, `side`, `id`)\n);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX trades_symbol ON trades (exchange, symbol);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX trades_symbol_fee_currency ON trades (exchange, symbol, fee_currency, traded_at);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX trades_traded_at_symbol ON trades (exchange, traded_at, symbol);") + if err != nil { + return err + } + return err +} + +func down_main_trades(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP TABLE IF EXISTS `trades`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP INDEX trades_symbol ON trades;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP INDEX trades_symbol_fee_currency ON trades;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP INDEX trades_traded_at_symbol ON trades;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20200819054742_trade_index.go b/pkg/migrations/mysql/main_20200819054742_trade_index.go new file mode 100644 index 0000000..fbdeee0 --- /dev/null +++ b/pkg/migrations/mysql/main_20200819054742_trade_index.go @@ -0,0 +1,29 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_tradeIndex, down_main_tradeIndex) +} + +func up_main_tradeIndex(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} + +func down_main_tradeIndex(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20201102222546_orders.go b/pkg/migrations/mysql/main_20201102222546_orders.go new file mode 100644 index 0000000..0025dab --- /dev/null +++ b/pkg/migrations/mysql/main_20201102222546_orders.go @@ -0,0 +1,45 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_orders, down_main_orders) +} + +func up_main_orders(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `orders`\n(\n `gid` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n `exchange` VARCHAR(24) NOT NULL DEFAULT '',\n -- order_id is the order id returned from the exchange\n `order_id` BIGINT UNSIGNED NOT NULL,\n `client_order_id` VARCHAR(122) NOT NULL DEFAULT '',\n `order_type` VARCHAR(16) NOT NULL,\n `symbol` VARCHAR(20) NOT NULL,\n `status` VARCHAR(12) NOT NULL,\n `time_in_force` VARCHAR(4) NOT NULL,\n `price` DECIMAL(16, 8) UNSIGNED NOT NULL,\n `stop_price` DECIMAL(16, 8) UNSIGNED NOT NULL,\n `quantity` DECIMAL(16, 8) UNSIGNED NOT NULL,\n `executed_quantity` DECIMAL(16, 8) UNSIGNED NOT NULL DEFAULT 0.0,\n `side` VARCHAR(4) NOT NULL DEFAULT '',\n `is_working` BOOL NOT NULL DEFAULT FALSE,\n `created_at` DATETIME(3) NOT NULL,\n `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),\n `is_margin` BOOLEAN NOT NULL DEFAULT FALSE,\n `is_isolated` BOOLEAN NOT NULL DEFAULT FALSE,\n PRIMARY KEY (`gid`)\n);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX orders_symbol ON orders (exchange, symbol);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE UNIQUE INDEX orders_order_id ON orders (order_id, exchange);") + if err != nil { + return err + } + return err +} + +func down_main_orders(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP INDEX orders_symbol ON orders;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP INDEX orders_order_id ON orders;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP TABLE `orders`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20201103173342_trades_add_order_id.go b/pkg/migrations/mysql/main_20201103173342_trades_add_order_id.go new file mode 100644 index 0000000..50417a3 --- /dev/null +++ b/pkg/migrations/mysql/main_20201103173342_trades_add_order_id.go @@ -0,0 +1,29 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_tradesAddOrderId, down_main_tradesAddOrderId) +} + +func up_main_tradesAddOrderId(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} + +func down_main_tradesAddOrderId(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20201105092857_trades_index_fix.go b/pkg/migrations/mysql/main_20201105092857_trades_index_fix.go new file mode 100644 index 0000000..a1a8440 --- /dev/null +++ b/pkg/migrations/mysql/main_20201105092857_trades_index_fix.go @@ -0,0 +1,29 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_tradesIndexFix, down_main_tradesIndexFix) +} + +func up_main_tradesIndexFix(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} + +func down_main_tradesIndexFix(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20201105093056_orders_add_index.go b/pkg/migrations/mysql/main_20201105093056_orders_add_index.go new file mode 100644 index 0000000..4d39739 --- /dev/null +++ b/pkg/migrations/mysql/main_20201105093056_orders_add_index.go @@ -0,0 +1,29 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_ordersAddIndex, down_main_ordersAddIndex) +} + +func up_main_ordersAddIndex(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} + +func down_main_ordersAddIndex(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20201106114742_klines.go b/pkg/migrations/mysql/main_20201106114742_klines.go new file mode 100644 index 0000000..ca02f0c --- /dev/null +++ b/pkg/migrations/mysql/main_20201106114742_klines.go @@ -0,0 +1,61 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_klines, down_main_klines) +} + +func up_main_klines(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `klines`\n(\n `gid` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n `exchange` VARCHAR(10) NOT NULL,\n `start_time` DATETIME(3) NOT NULL,\n `end_time` DATETIME(3) NOT NULL,\n `interval` VARCHAR(3) NOT NULL,\n `symbol` VARCHAR(20) NOT NULL,\n `open` DECIMAL(20, 8) UNSIGNED NOT NULL,\n `high` DECIMAL(20, 8) UNSIGNED NOT NULL,\n `low` DECIMAL(20, 8) UNSIGNED NOT NULL,\n `close` DECIMAL(20, 8) UNSIGNED NOT NULL DEFAULT 0.0,\n `volume` DECIMAL(20, 8) UNSIGNED NOT NULL DEFAULT 0.0,\n `closed` BOOL NOT NULL DEFAULT TRUE,\n `last_trade_id` INT UNSIGNED NOT NULL DEFAULT 0,\n `num_trades` INT UNSIGNED NOT NULL DEFAULT 0,\n PRIMARY KEY (`gid`)\n);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX `klines_end_time_symbol_interval` ON klines (`end_time`, `symbol`, `interval`);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE TABLE `okex_klines` LIKE `klines`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE TABLE `binance_klines` LIKE `klines`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE TABLE `max_klines` LIKE `klines`;") + if err != nil { + return err + } + return err +} + +func down_main_klines(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP INDEX `klines_end_time_symbol_interval` ON `klines`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP TABLE `binance_klines`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP TABLE `okex_klines`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP TABLE `max_klines`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP TABLE `klines`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20201211175751_fix_symbol_length.go b/pkg/migrations/mysql/main_20201211175751_fix_symbol_length.go new file mode 100644 index 0000000..7e8c57c --- /dev/null +++ b/pkg/migrations/mysql/main_20201211175751_fix_symbol_length.go @@ -0,0 +1,29 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_fixSymbolLength, down_main_fixSymbolLength) +} + +func up_main_fixSymbolLength(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} + +func down_main_fixSymbolLength(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20210118163847_fix_unique_index.go b/pkg/migrations/mysql/main_20210118163847_fix_unique_index.go new file mode 100644 index 0000000..f4b9d7b --- /dev/null +++ b/pkg/migrations/mysql/main_20210118163847_fix_unique_index.go @@ -0,0 +1,29 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_fixUniqueIndex, down_main_fixUniqueIndex) +} + +func up_main_fixUniqueIndex(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} + +func down_main_fixUniqueIndex(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20210119232826_add_margin_columns.go b/pkg/migrations/mysql/main_20210119232826_add_margin_columns.go new file mode 100644 index 0000000..2c8bf85 --- /dev/null +++ b/pkg/migrations/mysql/main_20210119232826_add_margin_columns.go @@ -0,0 +1,29 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addMarginColumns, down_main_addMarginColumns) +} + +func up_main_addMarginColumns(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} + +func down_main_addMarginColumns(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20210129182704_trade_price_quantity_index.go b/pkg/migrations/mysql/main_20210129182704_trade_price_quantity_index.go new file mode 100644 index 0000000..adf3eba --- /dev/null +++ b/pkg/migrations/mysql/main_20210129182704_trade_price_quantity_index.go @@ -0,0 +1,29 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_tradePriceQuantityIndex, down_main_tradePriceQuantityIndex) +} + +func up_main_tradePriceQuantityIndex(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE INDEX trades_price_quantity ON trades (order_id,price,quantity);") + if err != nil { + return err + } + return err +} + +func down_main_tradePriceQuantityIndex(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP INDEX trades_price_quantity ON trades") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20210215203116_add_pnl_column.go b/pkg/migrations/mysql/main_20210215203116_add_pnl_column.go new file mode 100644 index 0000000..b9c1b88 --- /dev/null +++ b/pkg/migrations/mysql/main_20210215203116_add_pnl_column.go @@ -0,0 +1,29 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addPnlColumn, down_main_addPnlColumn) +} + +func up_main_addPnlColumn(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} + +func down_main_addPnlColumn(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20210223080622_add_rewards_table.go b/pkg/migrations/mysql/main_20210223080622_add_rewards_table.go new file mode 100644 index 0000000..0468944 --- /dev/null +++ b/pkg/migrations/mysql/main_20210223080622_add_rewards_table.go @@ -0,0 +1,29 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addRewardsTable, down_main_addRewardsTable) +} + +func up_main_addRewardsTable(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `rewards`\n(\n `gid` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n -- for exchange\n `exchange` VARCHAR(24) NOT NULL DEFAULT '',\n -- reward record id\n `uuid` VARCHAR(32) NOT NULL,\n `reward_type` VARCHAR(24) NOT NULL DEFAULT '',\n -- currency symbol, BTC, MAX, USDT ... etc\n `currency` VARCHAR(5) NOT NULL,\n -- the quantity of the rewards\n `quantity` DECIMAL(16, 8) UNSIGNED NOT NULL,\n `state` VARCHAR(5) NOT NULL,\n `created_at` DATETIME NOT NULL,\n `spent` BOOLEAN NOT NULL DEFAULT FALSE,\n `note` TEXT NULL,\n PRIMARY KEY (`gid`),\n UNIQUE KEY `uuid` (`exchange`, `uuid`)\n);") + if err != nil { + return err + } + return err +} + +func down_main_addRewardsTable(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP TABLE IF EXISTS `rewards`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20210301140656_add_withdraws_table.go b/pkg/migrations/mysql/main_20210301140656_add_withdraws_table.go new file mode 100644 index 0000000..6b2d2fc --- /dev/null +++ b/pkg/migrations/mysql/main_20210301140656_add_withdraws_table.go @@ -0,0 +1,29 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addWithdrawsTable, down_main_addWithdrawsTable) +} + +func up_main_addWithdrawsTable(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `withdraws`\n(\n `gid` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n `exchange` VARCHAR(24) NOT NULL DEFAULT '',\n -- asset is the asset name (currency)\n `asset` VARCHAR(10) NOT NULL,\n `address` VARCHAR(128) NOT NULL,\n `network` VARCHAR(32) NOT NULL DEFAULT '',\n `amount` DECIMAL(16, 8) NOT NULL,\n `txn_id` VARCHAR(256) NOT NULL,\n `txn_fee` DECIMAL(16, 8) NOT NULL DEFAULT 0,\n `txn_fee_currency` VARCHAR(32) NOT NULL DEFAULT '',\n `time` DATETIME(3) NOT NULL,\n PRIMARY KEY (`gid`),\n UNIQUE KEY `txn_id` (`exchange`, `txn_id`)\n);") + if err != nil { + return err + } + return err +} + +func down_main_addWithdrawsTable(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP TABLE IF EXISTS `withdraws`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20210307201830_add_deposits_table.go b/pkg/migrations/mysql/main_20210307201830_add_deposits_table.go new file mode 100644 index 0000000..b5264e2 --- /dev/null +++ b/pkg/migrations/mysql/main_20210307201830_add_deposits_table.go @@ -0,0 +1,29 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addDepositsTable, down_main_addDepositsTable) +} + +func up_main_addDepositsTable(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `deposits`\n(\n `gid` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n `exchange` VARCHAR(24) NOT NULL,\n -- asset is the asset name (currency)\n `asset` VARCHAR(10) NOT NULL,\n `address` VARCHAR(128) NOT NULL DEFAULT '',\n `amount` DECIMAL(16, 8) NOT NULL,\n `txn_id` VARCHAR(256) NOT NULL,\n `time` DATETIME(3) NOT NULL,\n PRIMARY KEY (`gid`),\n UNIQUE KEY `txn_id` (`exchange`, `txn_id`)\n);") + if err != nil { + return err + } + return err +} + +func down_main_addDepositsTable(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP TABLE IF EXISTS `deposits`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20210416230730_klines_symbol_length.go b/pkg/migrations/mysql/main_20210416230730_klines_symbol_length.go new file mode 100644 index 0000000..3a0584e --- /dev/null +++ b/pkg/migrations/mysql/main_20210416230730_klines_symbol_length.go @@ -0,0 +1,53 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_klinesSymbolLength, down_main_klinesSymbolLength) +} + +func up_main_klinesSymbolLength(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "ALTER TABLE `klines`\nMODIFY COLUMN `symbol` VARCHAR(10) NOT NULL;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `okex_klines`\nMODIFY COLUMN `symbol` VARCHAR(10) NOT NULL;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `binance_klines`\nMODIFY COLUMN `symbol` VARCHAR(10) NOT NULL;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `max_klines`\nMODIFY COLUMN `symbol` VARCHAR(10) NOT NULL;") + if err != nil { + return err + } + return err +} + +func down_main_klinesSymbolLength(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "ALTER TABLE `klines`\nMODIFY COLUMN `symbol` VARCHAR(7) NOT NULL;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `okex_klines`\nMODIFY COLUMN `symbol` VARCHAR(7) NOT NULL;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `binance_klines`\nMODIFY COLUMN `symbol` VARCHAR(7) NOT NULL;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `max_klines`\nMODIFY COLUMN `symbol` VARCHAR(7) NOT NULL;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20210421091430_increase_symbol_length.go b/pkg/migrations/mysql/main_20210421091430_increase_symbol_length.go new file mode 100644 index 0000000..d7a1cc6 --- /dev/null +++ b/pkg/migrations/mysql/main_20210421091430_increase_symbol_length.go @@ -0,0 +1,53 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_increaseSymbolLength, down_main_increaseSymbolLength) +} + +func up_main_increaseSymbolLength(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "ALTER TABLE `klines`\nMODIFY COLUMN `symbol` VARCHAR(12) NOT NULL;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `okex_klines`\nMODIFY COLUMN `symbol` VARCHAR(12) NOT NULL;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `binance_klines`\nMODIFY COLUMN `symbol` VARCHAR(12) NOT NULL;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `max_klines`\nMODIFY COLUMN `symbol` VARCHAR(12) NOT NULL;") + if err != nil { + return err + } + return err +} + +func down_main_increaseSymbolLength(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "ALTER TABLE `klines`\nMODIFY COLUMN `symbol` VARCHAR(10) NOT NULL;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `okex_klines`\nMODIFY COLUMN `symbol` VARCHAR(10) NOT NULL;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `binance_klines`\nMODIFY COLUMN `symbol` VARCHAR(10) NOT NULL;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `max_klines`\nMODIFY COLUMN `symbol` VARCHAR(10) NOT NULL;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20210421095030_increase_decimal_length.go b/pkg/migrations/mysql/main_20210421095030_increase_decimal_length.go new file mode 100644 index 0000000..6e05411 --- /dev/null +++ b/pkg/migrations/mysql/main_20210421095030_increase_decimal_length.go @@ -0,0 +1,53 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_increaseDecimalLength, down_main_increaseDecimalLength) +} + +func up_main_increaseDecimalLength(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "ALTER TABLE `klines`\nMODIFY COLUMN `volume` decimal(20,8) unsigned NOT NULL DEFAULT '0.00000000';") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `okex_klines`\nMODIFY COLUMN `volume` decimal(20,8) unsigned NOT NULL DEFAULT '0.00000000';") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `binance_klines`\nMODIFY COLUMN `volume` decimal(20,8) unsigned NOT NULL DEFAULT '0.00000000';") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `max_klines`\nMODIFY COLUMN `volume` decimal(20,8) unsigned NOT NULL DEFAULT '0.00000000';") + if err != nil { + return err + } + return err +} + +func down_main_increaseDecimalLength(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "ALTER TABLE `klines`\nMODIFY COLUMN `volume` decimal(16,8) unsigned NOT NULL DEFAULT '0.00000000';") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `okex_klines`\nMODIFY COLUMN `volume` decimal(16,8) unsigned NOT NULL DEFAULT '0.00000000';") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `binance_klines`\nMODIFY COLUMN `volume` decimal(16,8) unsigned NOT NULL DEFAULT '0.00000000';") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `max_klines`\nMODIFY COLUMN `volume` decimal(16,8) unsigned NOT NULL DEFAULT '0.00000000';") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20210531234123_add_kline_taker_buy_columns.go b/pkg/migrations/mysql/main_20210531234123_add_kline_taker_buy_columns.go new file mode 100644 index 0000000..f343b5b --- /dev/null +++ b/pkg/migrations/mysql/main_20210531234123_add_kline_taker_buy_columns.go @@ -0,0 +1,49 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addKlineTakerBuyColumns, down_main_addKlineTakerBuyColumns) +} + +func up_main_addKlineTakerBuyColumns(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "ALTER TABLE `binance_klines`\n ADD COLUMN `quote_volume` DECIMAL(32, 8) NOT NULL DEFAULT 0.0,\n ADD COLUMN `taker_buy_base_volume` DECIMAL(32, 8) NOT NULL DEFAULT 0.0,\n ADD COLUMN `taker_buy_quote_volume` DECIMAL(32, 8) NOT NULL DEFAULT 0.0;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `max_klines`\n ADD COLUMN `quote_volume` DECIMAL(32, 8) NOT NULL DEFAULT 0.0,\n ADD COLUMN `taker_buy_base_volume` DECIMAL(32, 8) NOT NULL DEFAULT 0.0,\n ADD COLUMN `taker_buy_quote_volume` DECIMAL(32, 8) NOT NULL DEFAULT 0.0;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `okex_klines`\n ADD COLUMN `quote_volume` DECIMAL(32, 8) NOT NULL DEFAULT 0.0,\n ADD COLUMN `taker_buy_base_volume` DECIMAL(32, 8) NOT NULL DEFAULT 0.0,\n ADD COLUMN `taker_buy_quote_volume` DECIMAL(32, 8) NOT NULL DEFAULT 0.0;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `klines`\n ADD COLUMN `quote_volume` DECIMAL(32, 8) NOT NULL DEFAULT 0.0,\n ADD COLUMN `taker_buy_base_volume` DECIMAL(32, 8) NOT NULL DEFAULT 0.0,\n ADD COLUMN `taker_buy_quote_volume` DECIMAL(32, 8) NOT NULL DEFAULT 0.0;") + if err != nil { + return err + } + return err +} + +func down_main_addKlineTakerBuyColumns(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "ALTER TABLE `binance_klines`\n DROP COLUMN `quote_volume`,\n DROP COLUMN `taker_buy_base_volume`,\n DROP COLUMN `taker_buy_quote_volume`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `max_klines`\n DROP COLUMN `quote_volume`,\n DROP COLUMN `taker_buy_base_volume`,\n DROP COLUMN `taker_buy_quote_volume`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `okex_klines`\n DROP COLUMN `quote_volume`,\n DROP COLUMN `taker_buy_base_volume`,\n DROP COLUMN `taker_buy_quote_volume`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20211205162043_add_is_futures_column.go b/pkg/migrations/mysql/main_20211205162043_add_is_futures_column.go new file mode 100644 index 0000000..b6f6832 --- /dev/null +++ b/pkg/migrations/mysql/main_20211205162043_add_is_futures_column.go @@ -0,0 +1,37 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addIsFuturesColumn, down_main_addIsFuturesColumn) +} + +func up_main_addIsFuturesColumn(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "ALTER TABLE `trades` ADD COLUMN `is_futures` BOOLEAN NOT NULL DEFAULT FALSE;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `orders` ADD COLUMN `is_futures` BOOLEAN NOT NULL DEFAULT FALSE;") + if err != nil { + return err + } + return err +} + +func down_main_addIsFuturesColumn(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "ALTER TABLE `trades` DROP COLUMN `is_futures`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `orders` DROP COLUMN `is_futures`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20211211020303_add_ftx_kline.go b/pkg/migrations/mysql/main_20211211020303_add_ftx_kline.go new file mode 100644 index 0000000..a4be4ef --- /dev/null +++ b/pkg/migrations/mysql/main_20211211020303_add_ftx_kline.go @@ -0,0 +1,33 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addFtxKline, down_main_addFtxKline) +} + +func up_main_addFtxKline(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "create table if not exists ftx_klines\n(\n gid bigint unsigned auto_increment\n primary key,\n exchange varchar(10) not null,\n start_time datetime(3) not null,\n end_time datetime(3) not null,\n `interval` varchar(3) not null,\n symbol varchar(20) not null,\n open decimal(20,8) unsigned not null,\n high decimal(20,8) unsigned not null,\n low decimal(20,8) unsigned not null,\n close decimal(20,8) unsigned default 0.00000000 not null,\n volume decimal(20,8) unsigned default 0.00000000 not null,\n closed tinyint(1) default 1 not null,\n last_trade_id int unsigned default '0' not null,\n num_trades int unsigned default '0' not null,\n quote_volume decimal(32,4) default 0.0000 not null,\n taker_buy_base_volume decimal(32,8) not null,\n taker_buy_quote_volume decimal(32,4) default 0.0000 not null\n );") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "create index klines_end_time_symbol_interval\n on ftx_klines (end_time, symbol, `interval`);") + if err != nil { + return err + } + return err +} + +func down_main_addFtxKline(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "drop table ftx_klines;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20211211034819_add_nav_history_details.go b/pkg/migrations/mysql/main_20211211034819_add_nav_history_details.go new file mode 100644 index 0000000..db6d508 --- /dev/null +++ b/pkg/migrations/mysql/main_20211211034819_add_nav_history_details.go @@ -0,0 +1,33 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addNavHistoryDetails, down_main_addNavHistoryDetails) +} + +func up_main_addNavHistoryDetails(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE nav_history_details\n(\n gid bigint unsigned auto_increment PRIMARY KEY,\n exchange VARCHAR(30) NOT NULL,\n subaccount VARCHAR(30) NOT NULL,\n time DATETIME(3) NOT NULL,\n currency VARCHAR(12) NOT NULL,\n balance_in_usd DECIMAL(32, 8) UNSIGNED DEFAULT 0.00000000 NOT NULL,\n balance_in_btc DECIMAL(32, 8) UNSIGNED DEFAULT 0.00000000 NOT NULL,\n balance DECIMAL(32, 8) UNSIGNED DEFAULT 0.00000000 NOT NULL,\n available DECIMAL(32, 8) UNSIGNED DEFAULT 0.00000000 NOT NULL,\n locked DECIMAL(32, 8) UNSIGNED DEFAULT 0.00000000 NOT NULL\n);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX idx_nav_history_details\n on nav_history_details (time, currency, exchange);") + if err != nil { + return err + } + return err +} + +func down_main_addNavHistoryDetails(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP TABLE nav_history_details;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20211211103657_update_fee_currency_length.go b/pkg/migrations/mysql/main_20211211103657_update_fee_currency_length.go new file mode 100644 index 0000000..6d56e93 --- /dev/null +++ b/pkg/migrations/mysql/main_20211211103657_update_fee_currency_length.go @@ -0,0 +1,29 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_updateFeeCurrencyLength, down_main_updateFeeCurrencyLength) +} + +func up_main_updateFeeCurrencyLength(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} + +func down_main_updateFeeCurrencyLength(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20211226022411_add_kucoin_klines.go b/pkg/migrations/mysql/main_20211226022411_add_kucoin_klines.go new file mode 100644 index 0000000..f09a1fc --- /dev/null +++ b/pkg/migrations/mysql/main_20211226022411_add_kucoin_klines.go @@ -0,0 +1,29 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addKucoinKlines, down_main_addKucoinKlines) +} + +func up_main_addKucoinKlines(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `kucoin_klines` LIKE `binance_klines`;") + if err != nil { + return err + } + return err +} + +func down_main_addKucoinKlines(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP TABLE `kucoin_klines`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20220304153317_add_profit_table.go b/pkg/migrations/mysql/main_20220304153317_add_profit_table.go new file mode 100644 index 0000000..43d1b0b --- /dev/null +++ b/pkg/migrations/mysql/main_20220304153317_add_profit_table.go @@ -0,0 +1,29 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addProfitTable, down_main_addProfitTable) +} + +func up_main_addProfitTable(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `profits`\n(\n `gid` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n `strategy` VARCHAR(32) NOT NULL,\n `strategy_instance_id` VARCHAR(64) NOT NULL,\n `symbol` VARCHAR(8) NOT NULL,\n -- average_cost is the position average cost\n `average_cost` DECIMAL(16, 8) UNSIGNED NOT NULL,\n -- profit is the pnl (profit and loss)\n `profit` DECIMAL(16, 8) NOT NULL,\n -- net_profit is the pnl (profit and loss)\n `net_profit` DECIMAL(16, 8) NOT NULL,\n -- profit_margin is the pnl (profit and loss)\n `profit_margin` DECIMAL(16, 8) NOT NULL,\n -- net_profit_margin is the pnl (profit and loss)\n `net_profit_margin` DECIMAL(16, 8) NOT NULL,\n `quote_currency` VARCHAR(10) NOT NULL,\n `base_currency` VARCHAR(10) NOT NULL,\n -- -------------------------------------------------------\n -- embedded trade data --\n -- -------------------------------------------------------\n `exchange` VARCHAR(24) NOT NULL DEFAULT '',\n `is_futures` BOOLEAN NOT NULL DEFAULT FALSE,\n `is_margin` BOOLEAN NOT NULL DEFAULT FALSE,\n `is_isolated` BOOLEAN NOT NULL DEFAULT FALSE,\n `trade_id` BIGINT UNSIGNED NOT NULL,\n -- side is the side of the trade that makes profit\n `side` VARCHAR(4) NOT NULL DEFAULT '',\n `is_buyer` BOOLEAN NOT NULL DEFAULT FALSE,\n `is_maker` BOOLEAN NOT NULL DEFAULT FALSE,\n -- price is the price of the trade that makes profit\n `price` DECIMAL(16, 8) UNSIGNED NOT NULL,\n -- quantity is the quantity of the trade that makes profit\n `quantity` DECIMAL(16, 8) UNSIGNED NOT NULL,\n -- quote_quantity is the quote quantity of the trade that makes profit\n `quote_quantity` DECIMAL(16, 8) UNSIGNED NOT NULL,\n `traded_at` DATETIME(3) NOT NULL,\n -- fee\n `fee_in_usd` DECIMAL(16, 8),\n `fee` DECIMAL(16, 8) NOT NULL,\n `fee_currency` VARCHAR(10) NOT NULL,\n PRIMARY KEY (`gid`),\n UNIQUE KEY `trade_id` (`trade_id`)\n);") + if err != nil { + return err + } + return err +} + +func down_main_addProfitTable(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP TABLE IF EXISTS `profits`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20220307132917_add_positions.go b/pkg/migrations/mysql/main_20220307132917_add_positions.go new file mode 100644 index 0000000..51fa785 --- /dev/null +++ b/pkg/migrations/mysql/main_20220307132917_add_positions.go @@ -0,0 +1,29 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addPositions, down_main_addPositions) +} + +func up_main_addPositions(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `positions`\n(\n `gid` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n `strategy` VARCHAR(32) NOT NULL,\n `strategy_instance_id` VARCHAR(64) NOT NULL,\n `symbol` VARCHAR(20) NOT NULL,\n `quote_currency` VARCHAR(10) NOT NULL,\n `base_currency` VARCHAR(10) NOT NULL,\n -- average_cost is the position average cost\n `average_cost` DECIMAL(16, 8) UNSIGNED NOT NULL,\n `base` DECIMAL(16, 8) NOT NULL,\n `quote` DECIMAL(16, 8) NOT NULL,\n `profit` DECIMAL(16, 8) NULL,\n -- trade related columns\n `trade_id` BIGINT UNSIGNED NOT NULL, -- the trade id in the exchange\n `side` VARCHAR(4) NOT NULL, -- side of the trade\n `exchange` VARCHAR(12) NOT NULL, -- exchange of the trade\n `traded_at` DATETIME(3) NOT NULL, -- millisecond timestamp\n PRIMARY KEY (`gid`),\n UNIQUE KEY `trade_id` (`trade_id`, `side`, `exchange`)\n);") + if err != nil { + return err + } + return err +} + +func down_main_addPositions(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP TABLE IF EXISTS `positions`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20220317125555_fix_trade_indexes.go b/pkg/migrations/mysql/main_20220317125555_fix_trade_indexes.go new file mode 100644 index 0000000..ea8bfc8 --- /dev/null +++ b/pkg/migrations/mysql/main_20220317125555_fix_trade_indexes.go @@ -0,0 +1,69 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_fixTradeIndexes, down_main_fixTradeIndexes) +} + +func up_main_fixTradeIndexes(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "DROP INDEX trades_symbol ON trades;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP INDEX trades_symbol_fee_currency ON trades;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP INDEX trades_traded_at_symbol ON trades;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX trades_traded_at ON trades (traded_at, symbol, exchange, id, fee_currency, fee);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX trades_id_traded_at ON trades (id, traded_at);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX trades_order_id_traded_at ON trades (order_id, traded_at);") + if err != nil { + return err + } + return err +} + +func down_main_fixTradeIndexes(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP INDEX trades_traded_at ON trades;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP INDEX trades_id_traded_at ON trades;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP INDEX trades_order_id_traded_at ON trades;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX trades_symbol ON trades (exchange, symbol);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX trades_symbol_fee_currency ON trades (exchange, symbol, fee_currency, traded_at);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX trades_traded_at_symbol ON trades (exchange, traded_at, symbol);") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20220419121046_fix_fee_column.go b/pkg/migrations/mysql/main_20220419121046_fix_fee_column.go new file mode 100644 index 0000000..adf2d38 --- /dev/null +++ b/pkg/migrations/mysql/main_20220419121046_fix_fee_column.go @@ -0,0 +1,37 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_fixFeeColumn, down_main_fixFeeColumn) +} + +func up_main_fixFeeColumn(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "ALTER TABLE trades\n CHANGE fee fee DECIMAL(16, 8) NOT NULL;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE profits\n CHANGE fee fee DECIMAL(16, 8) NOT NULL;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE profits\n CHANGE fee_in_usd fee_in_usd DECIMAL(16, 8);") + if err != nil { + return err + } + return err +} + +func down_main_fixFeeColumn(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20220503144849_add_margin_info_to_nav.go b/pkg/migrations/mysql/main_20220503144849_add_margin_info_to_nav.go new file mode 100644 index 0000000..49deefa --- /dev/null +++ b/pkg/migrations/mysql/main_20220503144849_add_margin_info_to_nav.go @@ -0,0 +1,29 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addMarginInfoToNav, down_main_addMarginInfoToNav) +} + +func up_main_addMarginInfoToNav(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "ALTER TABLE `nav_history_details`\n ADD COLUMN `session` VARCHAR(30) NOT NULL,\n ADD COLUMN `is_margin` BOOLEAN NOT NULL DEFAULT FALSE,\n ADD COLUMN `is_isolated` BOOLEAN NOT NULL DEFAULT FALSE,\n ADD COLUMN `isolated_symbol` VARCHAR(30) NOT NULL DEFAULT '',\n ADD COLUMN `net_asset` DECIMAL(32, 8) UNSIGNED DEFAULT 0.00000000 NOT NULL,\n ADD COLUMN `borrowed` DECIMAL(32, 8) UNSIGNED DEFAULT 0.00000000 NOT NULL,\n ADD COLUMN `price_in_usd` DECIMAL(32, 8) UNSIGNED DEFAULT 0.00000000 NOT NULL\n;") + if err != nil { + return err + } + return err +} + +func down_main_addMarginInfoToNav(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "ALTER TABLE `nav_history_details`\n DROP COLUMN `session`,\n DROP COLUMN `net_asset`,\n DROP COLUMN `borrowed`,\n DROP COLUMN `price_in_usd`,\n DROP COLUMN `is_margin`,\n DROP COLUMN `is_isolated`,\n DROP COLUMN `isolated_symbol`\n;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20220504184155_fix_net_asset_column.go b/pkg/migrations/mysql/main_20220504184155_fix_net_asset_column.go new file mode 100644 index 0000000..ed4344b --- /dev/null +++ b/pkg/migrations/mysql/main_20220504184155_fix_net_asset_column.go @@ -0,0 +1,33 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_fixNetAssetColumn, down_main_fixNetAssetColumn) +} + +func up_main_fixNetAssetColumn(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "ALTER TABLE `nav_history_details`\n MODIFY COLUMN `net_asset` DECIMAL(32, 8) DEFAULT 0.00000000 NOT NULL,\n CHANGE COLUMN `balance_in_usd` `net_asset_in_usd` DECIMAL(32, 2) DEFAULT 0.00000000 NOT NULL,\n CHANGE COLUMN `balance_in_btc` `net_asset_in_btc` DECIMAL(32, 20) DEFAULT 0.00000000 NOT NULL;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `nav_history_details`\n ADD COLUMN `interest` DECIMAL(32, 20) UNSIGNED DEFAULT 0.00000000 NOT NULL;") + if err != nil { + return err + } + return err +} + +func down_main_fixNetAssetColumn(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "ALTER TABLE `nav_history_details`\n DROP COLUMN `interest`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20220512170322_fix_profit_symbol_length.go b/pkg/migrations/mysql/main_20220512170322_fix_profit_symbol_length.go new file mode 100644 index 0000000..90166fe --- /dev/null +++ b/pkg/migrations/mysql/main_20220512170322_fix_profit_symbol_length.go @@ -0,0 +1,29 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_fixProfitSymbolLength, down_main_fixProfitSymbolLength) +} + +func up_main_fixProfitSymbolLength(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "ALTER TABLE profits\n CHANGE symbol symbol VARCHAR(20) NOT NULL;") + if err != nil { + return err + } + return err +} + +func down_main_fixProfitSymbolLength(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20220520140707_kline_unique_idx.go b/pkg/migrations/mysql/main_20220520140707_kline_unique_idx.go new file mode 100644 index 0000000..0ef3644 --- /dev/null +++ b/pkg/migrations/mysql/main_20220520140707_kline_unique_idx.go @@ -0,0 +1,61 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_klineUniqueIdx, down_main_klineUniqueIdx) +} + +func up_main_klineUniqueIdx(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE UNIQUE INDEX idx_kline_binance_unique\n ON binance_klines (`symbol`, `interval`, `start_time`);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE UNIQUE INDEX idx_kline_max_unique\n ON max_klines (`symbol`, `interval`, `start_time`);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE UNIQUE INDEX `idx_kline_ftx_unique`\n ON ftx_klines (`symbol`, `interval`, `start_time`);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE UNIQUE INDEX `idx_kline_kucoin_unique`\n ON kucoin_klines (`symbol`, `interval`, `start_time`);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE UNIQUE INDEX `idx_kline_okex_unique`\n ON okex_klines (`symbol`, `interval`, `start_time`);") + if err != nil { + return err + } + return err +} + +func down_main_klineUniqueIdx(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP INDEX `idx_kline_ftx_unique` ON `ftx_klines`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP INDEX `idx_kline_max_unique` ON `max_klines`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP INDEX `idx_kline_binance_unique` ON `binance_klines`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP INDEX `idx_kline_kucoin_unique` ON `kucoin_klines`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP INDEX `idx_kline_okex_unique` ON `okex_klines`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20220531012226_margin_loans.go b/pkg/migrations/mysql/main_20220531012226_margin_loans.go new file mode 100644 index 0000000..68fe149 --- /dev/null +++ b/pkg/migrations/mysql/main_20220531012226_margin_loans.go @@ -0,0 +1,29 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_marginLoans, down_main_marginLoans) +} + +func up_main_marginLoans(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `margin_loans`\n(\n `gid` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n `transaction_id` BIGINT UNSIGNED NOT NULL,\n `exchange` VARCHAR(24) NOT NULL DEFAULT '',\n `asset` VARCHAR(24) NOT NULL DEFAULT '',\n `isolated_symbol` VARCHAR(24) NOT NULL DEFAULT '',\n -- quantity is the quantity of the trade that makes profit\n `principle` DECIMAL(16, 8) UNSIGNED NOT NULL,\n `time` DATETIME(3) NOT NULL,\n PRIMARY KEY (`gid`),\n UNIQUE KEY (`transaction_id`)\n);") + if err != nil { + return err + } + return err +} + +func down_main_marginLoans(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP TABLE IF EXISTS `margin_loans`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20220531013327_margin_repays.go b/pkg/migrations/mysql/main_20220531013327_margin_repays.go new file mode 100644 index 0000000..97ce512 --- /dev/null +++ b/pkg/migrations/mysql/main_20220531013327_margin_repays.go @@ -0,0 +1,29 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_marginRepays, down_main_marginRepays) +} + +func up_main_marginRepays(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `margin_repays`\n(\n `gid` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n `transaction_id` BIGINT UNSIGNED NOT NULL,\n `exchange` VARCHAR(24) NOT NULL DEFAULT '',\n `asset` VARCHAR(24) NOT NULL DEFAULT '',\n `isolated_symbol` VARCHAR(24) NOT NULL DEFAULT '',\n -- quantity is the quantity of the trade that makes profit\n `principle` DECIMAL(16, 8) UNSIGNED NOT NULL,\n `time` DATETIME(3) NOT NULL,\n PRIMARY KEY (`gid`),\n UNIQUE KEY (`transaction_id`)\n);") + if err != nil { + return err + } + return err +} + +func down_main_marginRepays(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP TABLE IF EXISTS `margin_repays`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20220531013542_margin_interests.go b/pkg/migrations/mysql/main_20220531013542_margin_interests.go new file mode 100644 index 0000000..f42f60d --- /dev/null +++ b/pkg/migrations/mysql/main_20220531013542_margin_interests.go @@ -0,0 +1,29 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_marginInterests, down_main_marginInterests) +} + +func up_main_marginInterests(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `margin_interests`\n(\n `gid` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n `exchange` VARCHAR(24) NOT NULL DEFAULT '',\n `asset` VARCHAR(24) NOT NULL DEFAULT '',\n `isolated_symbol` VARCHAR(24) NOT NULL DEFAULT '',\n `principle` DECIMAL(16, 8) UNSIGNED NOT NULL,\n `interest` DECIMAL(20, 16) UNSIGNED NOT NULL,\n `interest_rate` DECIMAL(20, 16) UNSIGNED NOT NULL,\n `time` DATETIME(3) NOT NULL,\n PRIMARY KEY (`gid`)\n);") + if err != nil { + return err + } + return err +} + +func down_main_marginInterests(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP TABLE IF EXISTS `margin_interests`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20220531015005_margin_liquidations.go b/pkg/migrations/mysql/main_20220531015005_margin_liquidations.go new file mode 100644 index 0000000..dbdfafc --- /dev/null +++ b/pkg/migrations/mysql/main_20220531015005_margin_liquidations.go @@ -0,0 +1,29 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_marginLiquidations, down_main_marginLiquidations) +} + +func up_main_marginLiquidations(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `margin_liquidations`\n(\n `gid` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n `exchange` VARCHAR(24) NOT NULL DEFAULT '',\n `symbol` VARCHAR(24) NOT NULL DEFAULT '',\n `order_id` BIGINT UNSIGNED NOT NULL,\n `is_isolated` BOOL NOT NULL DEFAULT false,\n `average_price` DECIMAL(16, 8) UNSIGNED NOT NULL,\n `price` DECIMAL(16, 8) UNSIGNED NOT NULL,\n `quantity` DECIMAL(16, 8) UNSIGNED NOT NULL,\n `executed_quantity` DECIMAL(16, 8) UNSIGNED NOT NULL,\n `side` VARCHAR(5) NOT NULL DEFAULT '',\n `time_in_force` VARCHAR(5) NOT NULL DEFAULT '',\n `time` DATETIME(3) NOT NULL,\n PRIMARY KEY (`gid`),\n UNIQUE KEY (`order_id`, `exchange`)\n);") + if err != nil { + return err + } + return err +} + +func down_main_marginLiquidations(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP TABLE IF EXISTS `margin_liquidations`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20230815173104_add_bybit_klines.go b/pkg/migrations/mysql/main_20230815173104_add_bybit_klines.go new file mode 100644 index 0000000..47897cb --- /dev/null +++ b/pkg/migrations/mysql/main_20230815173104_add_bybit_klines.go @@ -0,0 +1,29 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addBybitKlines, down_main_addBybitKlines) +} + +func up_main_addBybitKlines(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `bybit_klines` LIKE `binance_klines`;") + if err != nil { + return err + } + return err +} + +func down_main_addBybitKlines(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP TABLE `bybit_klines`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/main_20231123125402_fix_order_status_length.go b/pkg/migrations/mysql/main_20231123125402_fix_order_status_length.go new file mode 100644 index 0000000..ef9c57f --- /dev/null +++ b/pkg/migrations/mysql/main_20231123125402_fix_order_status_length.go @@ -0,0 +1,29 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_fixOrderStatusLength, down_main_fixOrderStatusLength) +} + +func up_main_fixOrderStatusLength(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "ALTER TABLE `orders`\n CHANGE `status` `status` varchar(20) NOT NULL;") + if err != nil { + return err + } + return err +} + +func down_main_fixOrderStatusLength(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/mysql/migration_api.go b/pkg/migrations/mysql/migration_api.go new file mode 100644 index 0000000..95c14a5 --- /dev/null +++ b/pkg/migrations/mysql/migration_api.go @@ -0,0 +1,91 @@ +package mysql + +import ( + "fmt" + "log" + "runtime" + "strings" + + "github.com/c9s/rockhopper/v2" +) + +var registeredGoMigrations = map[rockhopper.RegistryKey]*rockhopper.Migration{} + +func MergeMigrationsMap(ms map[rockhopper.RegistryKey]*rockhopper.Migration) { + for k, m := range ms { + if _, ok := registeredGoMigrations[k]; !ok { + registeredGoMigrations[k] = m + } else { + log.Printf("the migration key %+v is duplicated: %+v", k, m) + } + } +} + +func GetMigrationsMap() map[rockhopper.RegistryKey]*rockhopper.Migration { + return registeredGoMigrations +} + +// SortedMigrations builds up the migration objects, sort them by timestamp and return as a slice +func SortedMigrations() rockhopper.MigrationSlice { + return Migrations() +} + +// Migrations builds up the migration objects, sort them by timestamp and return as a slice +func Migrations() rockhopper.MigrationSlice { + var migrations = rockhopper.MigrationSlice{} + for _, migration := range registeredGoMigrations { + migrations = append(migrations, migration) + } + + return migrations.SortAndConnect() +} + +// AddMigration adds a migration with its runtime caller information +func AddMigration(packageName string, up, down rockhopper.TransactionHandler) { + pc, filename, _, _ := runtime.Caller(1) + + if packageName == "" { + funcName := runtime.FuncForPC(pc).Name() + packageName = _parseFuncPackageName(funcName) + } + + AddNamedMigration(packageName, filename, up, down) +} + +// parseFuncPackageName parses the package name from a given runtime caller function name +func _parseFuncPackageName(funcName string) string { + lastSlash := strings.LastIndexByte(funcName, '/') + if lastSlash < 0 { + lastSlash = 0 + } + + lastDot := strings.LastIndexByte(funcName[lastSlash:], '.') + lastSlash + packageName := funcName[:lastDot] + return packageName +} + +// AddNamedMigration adds a named migration to the registered go migration map +func AddNamedMigration(packageName, filename string, up, down rockhopper.TransactionHandler) { + v, err := rockhopper.FileNumericComponent(filename) + if err != nil { + panic(fmt.Errorf("unable to parse numeric component from filename %s: %v", filename, err)) + } + + migration := &rockhopper.Migration{ + Package: packageName, + Registered: true, + + Version: v, + UpFn: up, + DownFn: down, + Source: filename, + UseTx: true, + } + + key := rockhopper.RegistryKey{Package: packageName, Version: v} + if existing, ok := registeredGoMigrations[key]; ok { + panic(fmt.Sprintf("failed to add migration %q: version conflicts with key %+v: %+v", filename, key, existing)) + } + + registeredGoMigrations[key] = migration +} diff --git a/pkg/migrations/mysql/migration_api_test.go b/pkg/migrations/mysql/migration_api_test.go new file mode 100644 index 0000000..4e5154e --- /dev/null +++ b/pkg/migrations/mysql/migration_api_test.go @@ -0,0 +1,21 @@ +package mysql + +import ( + "testing" + + "github.com/c9s/rockhopper/v2" + + "github.com/stretchr/testify/assert" +) + +func TestGetMigrationsMap(t *testing.T) { + mm := GetMigrationsMap() + assert.NotEmpty(t, mm) +} + +func TestMergeMigrationsMap(t *testing.T) { + MergeMigrationsMap(map[rockhopper.RegistryKey]*rockhopper.Migration{ + rockhopper.RegistryKey{Version: 2}: &rockhopper.Migration{}, + rockhopper.RegistryKey{Version: 2}: &rockhopper.Migration{}, + }) +} diff --git a/pkg/migrations/sqlite3/main_20200721225616_trades.go b/pkg/migrations/sqlite3/main_20200721225616_trades.go new file mode 100644 index 0000000..1500346 --- /dev/null +++ b/pkg/migrations/sqlite3/main_20200721225616_trades.go @@ -0,0 +1,29 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_trades, down_main_trades) +} + +func up_main_trades(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `trades`\n(\n `gid` INTEGER PRIMARY KEY AUTOINCREMENT,\n `id` INTEGER,\n `exchange` TEXT NOT NULL DEFAULT '',\n `symbol` TEXT NOT NULL,\n `price` DECIMAL(16, 8) NOT NULL,\n `quantity` DECIMAL(16, 8) NOT NULL,\n `quote_quantity` DECIMAL(16, 8) NOT NULL,\n `fee` DECIMAL(16, 8) NOT NULL,\n `fee_currency` VARCHAR(4) NOT NULL,\n `is_buyer` BOOLEAN NOT NULL DEFAULT FALSE,\n `is_maker` BOOLEAN NOT NULL DEFAULT FALSE,\n `side` VARCHAR(4) NOT NULL DEFAULT '',\n `traded_at` DATETIME(3) NOT NULL\n);") + if err != nil { + return err + } + return err +} + +func down_main_trades(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP TABLE IF EXISTS `trades`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20200819054742_trade_index.go b/pkg/migrations/sqlite3/main_20200819054742_trade_index.go new file mode 100644 index 0000000..289e2ad --- /dev/null +++ b/pkg/migrations/sqlite3/main_20200819054742_trade_index.go @@ -0,0 +1,45 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_tradeIndex, down_main_tradeIndex) +} + +func up_main_tradeIndex(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE INDEX trades_symbol ON trades(symbol);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX trades_symbol_fee_currency ON trades(symbol, fee_currency, traded_at);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX trades_traded_at_symbol ON trades(traded_at, symbol);") + if err != nil { + return err + } + return err +} + +func down_main_tradeIndex(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP INDEX trades_symbol;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP INDEX trades_symbol_fee_currency;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP INDEX trades_traded_at_symbol;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20201102222546_orders.go b/pkg/migrations/sqlite3/main_20201102222546_orders.go new file mode 100644 index 0000000..6bf0288 --- /dev/null +++ b/pkg/migrations/sqlite3/main_20201102222546_orders.go @@ -0,0 +1,29 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_orders, down_main_orders) +} + +func up_main_orders(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `orders`\n(\n `gid` INTEGER PRIMARY KEY AUTOINCREMENT,\n `exchange` VARCHAR NOT NULL DEFAULT '',\n -- order_id is the order id returned from the exchange\n `order_id` INTEGER NOT NULL,\n `client_order_id` VARCHAR NOT NULL DEFAULT '',\n `order_type` VARCHAR NOT NULL,\n `symbol` VARCHAR NOT NULL,\n `status` VARCHAR NOT NULL,\n `time_in_force` VARCHAR NOT NULL,\n `price` DECIMAL(16, 8) NOT NULL,\n `stop_price` DECIMAL(16, 8) NOT NULL,\n `quantity` DECIMAL(16, 8) NOT NULL,\n `executed_quantity` DECIMAL(16, 8) NOT NULL DEFAULT 0.0,\n `side` VARCHAR NOT NULL DEFAULT '',\n `is_working` BOOLEAN NOT NULL DEFAULT FALSE,\n `created_at` DATETIME(3) NOT NULL,\n `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP\n);") + if err != nil { + return err + } + return err +} + +func down_main_orders(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP TABLE IF EXISTS `orders`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20201103173342_trades_add_order_id.go b/pkg/migrations/sqlite3/main_20201103173342_trades_add_order_id.go new file mode 100644 index 0000000..ad12444 --- /dev/null +++ b/pkg/migrations/sqlite3/main_20201103173342_trades_add_order_id.go @@ -0,0 +1,29 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_tradesAddOrderId, down_main_tradesAddOrderId) +} + +func up_main_tradesAddOrderId(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "ALTER TABLE `trades` ADD COLUMN `order_id` INTEGER;") + if err != nil { + return err + } + return err +} + +func down_main_tradesAddOrderId(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "ALTER TABLE `trades` RENAME COLUMN `order_id` TO `order_id_deleted`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20201105092857_trades_index_fix.go b/pkg/migrations/sqlite3/main_20201105092857_trades_index_fix.go new file mode 100644 index 0000000..ae15782 --- /dev/null +++ b/pkg/migrations/sqlite3/main_20201105092857_trades_index_fix.go @@ -0,0 +1,69 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_tradesIndexFix, down_main_tradesIndexFix) +} + +func up_main_tradesIndexFix(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "DROP INDEX IF EXISTS trades_symbol;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP INDEX IF EXISTS trades_symbol_fee_currency;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP INDEX IF EXISTS trades_traded_at_symbol;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX trades_symbol ON trades (exchange, symbol);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX trades_symbol_fee_currency ON trades (exchange, symbol, fee_currency, traded_at);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX trades_traded_at_symbol ON trades (exchange, traded_at, symbol);") + if err != nil { + return err + } + return err +} + +func down_main_tradesIndexFix(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP INDEX IF EXISTS trades_symbol;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP INDEX IF EXISTS trades_symbol_fee_currency;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP INDEX IF EXISTS trades_traded_at_symbol;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX trades_symbol ON trades (symbol);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX trades_symbol_fee_currency ON trades (symbol, fee_currency, traded_at);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX trades_traded_at_symbol ON trades (traded_at, symbol);") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20201105093056_orders_add_index.go b/pkg/migrations/sqlite3/main_20201105093056_orders_add_index.go new file mode 100644 index 0000000..f826aa2 --- /dev/null +++ b/pkg/migrations/sqlite3/main_20201105093056_orders_add_index.go @@ -0,0 +1,37 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_ordersAddIndex, down_main_ordersAddIndex) +} + +func up_main_ordersAddIndex(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE INDEX orders_symbol ON orders (exchange, symbol);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE UNIQUE INDEX orders_order_id ON orders (order_id, exchange);") + if err != nil { + return err + } + return err +} + +func down_main_ordersAddIndex(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP INDEX IF EXISTS orders_symbol;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP INDEX IF EXISTS orders_order_id;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20201106114742_klines.go b/pkg/migrations/sqlite3/main_20201106114742_klines.go new file mode 100644 index 0000000..a1d9960 --- /dev/null +++ b/pkg/migrations/sqlite3/main_20201106114742_klines.go @@ -0,0 +1,61 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_klines, down_main_klines) +} + +func up_main_klines(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `klines`\n(\n `gid` INTEGER PRIMARY KEY AUTOINCREMENT,\n `exchange` VARCHAR(10) NOT NULL,\n `start_time` DATETIME(3) NOT NULL,\n `end_time` DATETIME(3) NOT NULL,\n `interval` VARCHAR(3) NOT NULL,\n `symbol` VARCHAR(7) NOT NULL,\n `open` DECIMAL(16, 8) NOT NULL,\n `high` DECIMAL(16, 8) NOT NULL,\n `low` DECIMAL(16, 8) NOT NULL,\n `close` DECIMAL(16, 8) NOT NULL DEFAULT 0.0,\n `volume` DECIMAL(16, 8) NOT NULL DEFAULT 0.0,\n `closed` BOOLEAN NOT NULL DEFAULT TRUE,\n `last_trade_id` INT NOT NULL DEFAULT 0,\n `num_trades` INT NOT NULL DEFAULT 0\n);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE TABLE `okex_klines`\n(\n `gid` INTEGER PRIMARY KEY AUTOINCREMENT,\n `exchange` VARCHAR(10) NOT NULL,\n `start_time` DATETIME(3) NOT NULL,\n `end_time` DATETIME(3) NOT NULL,\n `interval` VARCHAR(3) NOT NULL,\n `symbol` VARCHAR(7) NOT NULL,\n `open` DECIMAL(16, 8) NOT NULL,\n `high` DECIMAL(16, 8) NOT NULL,\n `low` DECIMAL(16, 8) NOT NULL,\n `close` DECIMAL(16, 8) NOT NULL DEFAULT 0.0,\n `volume` DECIMAL(16, 8) NOT NULL DEFAULT 0.0,\n `closed` BOOLEAN NOT NULL DEFAULT TRUE,\n `last_trade_id` INT NOT NULL DEFAULT 0,\n `num_trades` INT NOT NULL DEFAULT 0\n);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE TABLE `binance_klines`\n(\n `gid` INTEGER PRIMARY KEY AUTOINCREMENT,\n `exchange` VARCHAR(10) NOT NULL,\n `start_time` DATETIME(3) NOT NULL,\n `end_time` DATETIME(3) NOT NULL,\n `interval` VARCHAR(3) NOT NULL,\n `symbol` VARCHAR(7) NOT NULL,\n `open` DECIMAL(16, 8) NOT NULL,\n `high` DECIMAL(16, 8) NOT NULL,\n `low` DECIMAL(16, 8) NOT NULL,\n `close` DECIMAL(16, 8) NOT NULL DEFAULT 0.0,\n `volume` DECIMAL(16, 8) NOT NULL DEFAULT 0.0,\n `closed` BOOLEAN NOT NULL DEFAULT TRUE,\n `last_trade_id` INT NOT NULL DEFAULT 0,\n `num_trades` INT NOT NULL DEFAULT 0\n);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE TABLE `max_klines`\n(\n `gid` INTEGER PRIMARY KEY AUTOINCREMENT,\n `exchange` VARCHAR(10) NOT NULL,\n `start_time` DATETIME(3) NOT NULL,\n `end_time` DATETIME(3) NOT NULL,\n `interval` VARCHAR(3) NOT NULL,\n `symbol` VARCHAR(7) NOT NULL,\n `open` DECIMAL(16, 8) NOT NULL,\n `high` DECIMAL(16, 8) NOT NULL,\n `low` DECIMAL(16, 8) NOT NULL,\n `close` DECIMAL(16, 8) NOT NULL DEFAULT 0.0,\n `volume` DECIMAL(16, 8) NOT NULL DEFAULT 0.0,\n `closed` BOOLEAN NOT NULL DEFAULT TRUE,\n `last_trade_id` INT NOT NULL DEFAULT 0,\n `num_trades` INT NOT NULL DEFAULT 0\n);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX `klines_end_time_symbol_interval` ON `klines` (`end_time`, `symbol`, `interval`);\nCREATE INDEX `binance_klines_end_time_symbol_interval` ON `binance_klines` (`end_time`, `symbol`, `interval`);\nCREATE INDEX `okex_klines_end_time_symbol_interval` ON `okex_klines` (`end_time`, `symbol`, `interval`);\nCREATE INDEX `max_klines_end_time_symbol_interval` ON `max_klines` (`end_time`, `symbol`, `interval`);") + if err != nil { + return err + } + return err +} + +func down_main_klines(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP INDEX IF EXISTS `klines_end_time_symbol_interval`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP TABLE IF EXISTS `binance_klines`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP TABLE IF EXISTS `okex_klines`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP TABLE IF EXISTS `max_klines`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP TABLE IF EXISTS `klines`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20210118163847_fix_unique_index.go b/pkg/migrations/sqlite3/main_20210118163847_fix_unique_index.go new file mode 100644 index 0000000..42fc0fd --- /dev/null +++ b/pkg/migrations/sqlite3/main_20210118163847_fix_unique_index.go @@ -0,0 +1,29 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_fixUniqueIndex, down_main_fixUniqueIndex) +} + +func up_main_fixUniqueIndex(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE UNIQUE INDEX `trade_unique_id` ON `trades` (`exchange`,`symbol`, `side`, `id`);") + if err != nil { + return err + } + return err +} + +func down_main_fixUniqueIndex(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP INDEX IF EXISTS `trade_unique_id`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20210119232826_add_margin_columns.go b/pkg/migrations/sqlite3/main_20210119232826_add_margin_columns.go new file mode 100644 index 0000000..945efbb --- /dev/null +++ b/pkg/migrations/sqlite3/main_20210119232826_add_margin_columns.go @@ -0,0 +1,53 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addMarginColumns, down_main_addMarginColumns) +} + +func up_main_addMarginColumns(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "ALTER TABLE `trades` ADD COLUMN `is_margin` BOOLEAN NOT NULL DEFAULT FALSE;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `trades` ADD COLUMN `is_isolated` BOOLEAN NOT NULL DEFAULT FALSE;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `orders` ADD COLUMN `is_margin` BOOLEAN NOT NULL DEFAULT FALSE;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `orders` ADD COLUMN `is_isolated` BOOLEAN NOT NULL DEFAULT FALSE;") + if err != nil { + return err + } + return err +} + +func down_main_addMarginColumns(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "ALTER TABLE `trades` RENAME COLUMN `is_margin` TO `is_margin_deleted`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `trades` RENAME COLUMN `is_isolated` TO `is_isolated_deleted`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `orders` RENAME COLUMN `is_margin` TO `is_margin_deleted`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `orders` RENAME COLUMN `is_isolated` TO `is_isolated_deleted`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20210129182704_trade_price_quantity_index.go b/pkg/migrations/sqlite3/main_20210129182704_trade_price_quantity_index.go new file mode 100644 index 0000000..faa0854 --- /dev/null +++ b/pkg/migrations/sqlite3/main_20210129182704_trade_price_quantity_index.go @@ -0,0 +1,29 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_tradePriceQuantityIndex, down_main_tradePriceQuantityIndex) +} + +func up_main_tradePriceQuantityIndex(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE INDEX trades_price_quantity ON trades (order_id,price,quantity);") + if err != nil { + return err + } + return err +} + +func down_main_tradePriceQuantityIndex(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP INDEX trades_price_quantity;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20210215203111_add_pnl_column.go b/pkg/migrations/sqlite3/main_20210215203111_add_pnl_column.go new file mode 100644 index 0000000..303d6b4 --- /dev/null +++ b/pkg/migrations/sqlite3/main_20210215203111_add_pnl_column.go @@ -0,0 +1,37 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addPnlColumn, down_main_addPnlColumn) +} + +func up_main_addPnlColumn(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "ALTER TABLE `trades` ADD COLUMN `pnl` DECIMAL NULL;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `trades` ADD COLUMN `strategy` TEXT;") + if err != nil { + return err + } + return err +} + +func down_main_addPnlColumn(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "ALTER TABLE `trades` RENAME COLUMN `pnl` TO `pnl_deleted`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `trades` RENAME COLUMN `strategy` TO `strategy_deleted`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20210223080622_add_rewards_table.go b/pkg/migrations/sqlite3/main_20210223080622_add_rewards_table.go new file mode 100644 index 0000000..f146d72 --- /dev/null +++ b/pkg/migrations/sqlite3/main_20210223080622_add_rewards_table.go @@ -0,0 +1,29 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addRewardsTable, down_main_addRewardsTable) +} + +func up_main_addRewardsTable(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `rewards`\n(\n `gid` INTEGER PRIMARY KEY AUTOINCREMENT,\n -- for exchange\n `exchange` VARCHAR(24) NOT NULL DEFAULT '',\n -- reward record id\n `uuid` VARCHAR(32) NOT NULL,\n `reward_type` VARCHAR(24) NOT NULL DEFAULT '',\n -- currency symbol, BTC, MAX, USDT ... etc\n `currency` VARCHAR(5) NOT NULL,\n -- the quantity of the rewards\n `quantity` DECIMAL(16, 8) NOT NULL,\n `state` VARCHAR(5) NOT NULL,\n `created_at` DATETIME NOT NULL,\n `spent` BOOLEAN NOT NULL DEFAULT FALSE,\n `note` TEXT NULL\n);") + if err != nil { + return err + } + return err +} + +func down_main_addRewardsTable(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP TABLE IF EXISTS `rewards`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20210301140656_add_withdraws_table.go b/pkg/migrations/sqlite3/main_20210301140656_add_withdraws_table.go new file mode 100644 index 0000000..ed87cca --- /dev/null +++ b/pkg/migrations/sqlite3/main_20210301140656_add_withdraws_table.go @@ -0,0 +1,37 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addWithdrawsTable, down_main_addWithdrawsTable) +} + +func up_main_addWithdrawsTable(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `withdraws`\n(\n `gid` INTEGER PRIMARY KEY AUTOINCREMENT,\n `exchange` VARCHAR(24) NOT NULL DEFAULT '',\n -- asset is the asset name (currency)\n `asset` VARCHAR(10) NOT NULL,\n `address` VARCHAR(128) NOT NULL,\n `network` VARCHAR(32) NOT NULL DEFAULT '',\n `amount` DECIMAL(16, 8) NOT NULL,\n `txn_id` VARCHAR(256) NOT NULL,\n `txn_fee` DECIMAL(16, 8) NOT NULL DEFAULT 0,\n `txn_fee_currency` VARCHAR(32) NOT NULL DEFAULT '',\n `time` DATETIME(3) NOT NULL\n);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE UNIQUE INDEX `withdraws_txn_id` ON `withdraws` (`exchange`, `txn_id`);") + if err != nil { + return err + } + return err +} + +func down_main_addWithdrawsTable(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP INDEX IF EXISTS `withdraws_txn_id`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP TABLE IF EXISTS `withdraws`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20210307201830_add_deposits_table.go b/pkg/migrations/sqlite3/main_20210307201830_add_deposits_table.go new file mode 100644 index 0000000..a72225c --- /dev/null +++ b/pkg/migrations/sqlite3/main_20210307201830_add_deposits_table.go @@ -0,0 +1,37 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addDepositsTable, down_main_addDepositsTable) +} + +func up_main_addDepositsTable(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `deposits`\n(\n `gid` INTEGER PRIMARY KEY AUTOINCREMENT,\n `exchange` VARCHAR(24) NOT NULL,\n -- asset is the asset name (currency)\n `asset` VARCHAR(10) NOT NULL,\n `address` VARCHAR(128) NOT NULL DEFAULT '',\n `amount` DECIMAL(16, 8) NOT NULL,\n `txn_id` VARCHAR(256) NOT NULL,\n `time` DATETIME(3) NOT NULL\n);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE UNIQUE INDEX `deposits_txn_id` ON `deposits` (`exchange`, `txn_id`);") + if err != nil { + return err + } + return err +} + +func down_main_addDepositsTable(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP INDEX IF EXISTS `deposits_txn_id`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP TABLE IF EXISTS `deposits`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20210531234123_add_kline_taker_buy_columns.go b/pkg/migrations/sqlite3/main_20210531234123_add_kline_taker_buy_columns.go new file mode 100644 index 0000000..27a76d3 --- /dev/null +++ b/pkg/migrations/sqlite3/main_20210531234123_add_kline_taker_buy_columns.go @@ -0,0 +1,37 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addKlineTakerBuyColumns, down_main_addKlineTakerBuyColumns) +} + +func up_main_addKlineTakerBuyColumns(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "ALTER TABLE `binance_klines`\n ADD COLUMN `quote_volume` DECIMAL NOT NULL DEFAULT 0.0;\nALTER TABLE `binance_klines`\n ADD COLUMN `taker_buy_base_volume` DECIMAL NOT NULL DEFAULT 0.0;\nALTER TABLE `binance_klines`\n ADD COLUMN `taker_buy_quote_volume` DECIMAL NOT NULL DEFAULT 0.0;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `max_klines`\n ADD COLUMN `quote_volume` DECIMAL NOT NULL DEFAULT 0.0;\nALTER TABLE `max_klines`\n ADD COLUMN `taker_buy_base_volume` DECIMAL NOT NULL DEFAULT 0.0;\nALTER TABLE `max_klines`\n ADD COLUMN `taker_buy_quote_volume` DECIMAL NOT NULL DEFAULT 0.0;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `okex_klines`\n ADD COLUMN `quote_volume` DECIMAL NOT NULL DEFAULT 0.0;\nALTER TABLE `okex_klines`\n ADD COLUMN `taker_buy_base_volume` DECIMAL NOT NULL DEFAULT 0.0;\nALTER TABLE `okex_klines`\n ADD COLUMN `taker_buy_quote_volume` DECIMAL NOT NULL DEFAULT 0.0;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `klines`\n ADD COLUMN `quote_volume` DECIMAL NOT NULL DEFAULT 0.0;\nALTER TABLE `klines`\n ADD COLUMN `taker_buy_base_volume` DECIMAL NOT NULL DEFAULT 0.0;\nALTER TABLE `klines`\n ADD COLUMN `taker_buy_quote_volume` DECIMAL NOT NULL DEFAULT 0.0;") + if err != nil { + return err + } + return err +} + +func down_main_addKlineTakerBuyColumns(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + return err +} diff --git a/pkg/migrations/sqlite3/main_20211205162302_add_is_futures_column.go b/pkg/migrations/sqlite3/main_20211205162302_add_is_futures_column.go new file mode 100644 index 0000000..53c7568 --- /dev/null +++ b/pkg/migrations/sqlite3/main_20211205162302_add_is_futures_column.go @@ -0,0 +1,37 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addIsFuturesColumn, down_main_addIsFuturesColumn) +} + +func up_main_addIsFuturesColumn(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "ALTER TABLE `trades` ADD COLUMN `is_futures` BOOLEAN NOT NULL DEFAULT FALSE;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `orders` ADD COLUMN `is_futures` BOOLEAN NOT NULL DEFAULT FALSE;") + if err != nil { + return err + } + return err +} + +func down_main_addIsFuturesColumn(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "ALTER TABLE `trades` RENAME COLUMN `is_futures` TO `is_futures_deleted`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `orders` RENAME COLUMN `is_futures` TO `is_futures_deleted`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20211211020303_add_ftx_kline.go b/pkg/migrations/sqlite3/main_20211211020303_add_ftx_kline.go new file mode 100644 index 0000000..e132800 --- /dev/null +++ b/pkg/migrations/sqlite3/main_20211211020303_add_ftx_kline.go @@ -0,0 +1,29 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addFtxKline, down_main_addFtxKline) +} + +func up_main_addFtxKline(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `ftx_klines`\n(\n `gid` INTEGER PRIMARY KEY AUTOINCREMENT,\n `exchange` VARCHAR(10) NOT NULL,\n `start_time` DATETIME(3) NOT NULL,\n `end_time` DATETIME(3) NOT NULL,\n `interval` VARCHAR(3) NOT NULL,\n `symbol` VARCHAR(7) NOT NULL,\n `open` DECIMAL(16, 8) NOT NULL,\n `high` DECIMAL(16, 8) NOT NULL,\n `low` DECIMAL(16, 8) NOT NULL,\n `close` DECIMAL(16, 8) NOT NULL DEFAULT 0.0,\n `volume` DECIMAL(16, 8) NOT NULL DEFAULT 0.0,\n `closed` BOOLEAN NOT NULL DEFAULT TRUE,\n `last_trade_id` INT NOT NULL DEFAULT 0,\n `num_trades` INT NOT NULL DEFAULT 0,\n `quote_volume` DECIMAL NOT NULL DEFAULT 0.0,\n `taker_buy_base_volume` DECIMAL NOT NULL DEFAULT 0.0,\n `taker_buy_quote_volume` DECIMAL NOT NULL DEFAULT 0.0\n);") + if err != nil { + return err + } + return err +} + +func down_main_addFtxKline(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP TABLE ftx_klines;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20211211034818_add_nav_history_details.go b/pkg/migrations/sqlite3/main_20211211034818_add_nav_history_details.go new file mode 100644 index 0000000..4639ce5 --- /dev/null +++ b/pkg/migrations/sqlite3/main_20211211034818_add_nav_history_details.go @@ -0,0 +1,33 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addNavHistoryDetails, down_main_addNavHistoryDetails) +} + +func up_main_addNavHistoryDetails(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `nav_history_details`\n(\n `gid` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,\n `exchange` VARCHAR(30) NOT NULL DEFAULT '',\n `subaccount` VARCHAR(30) NOT NULL DEFAULT '',\n `time` DATETIME(3) NOT NULL DEFAULT (strftime('%s', 'now')),\n `currency` VARCHAR(30) NOT NULL,\n `net_asset_in_usd` DECIMAL DEFAULT 0.00000000 NOT NULL,\n `net_asset_in_btc` DECIMAL DEFAULT 0.00000000 NOT NULL,\n `balance` DECIMAL DEFAULT 0.00000000 NOT NULL,\n `available` DECIMAL DEFAULT 0.00000000 NOT NULL,\n `locked` DECIMAL DEFAULT 0.00000000 NOT NULL\n);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX idx_nav_history_details\n on nav_history_details (time, currency, exchange);") + if err != nil { + return err + } + return err +} + +func down_main_addNavHistoryDetails(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP TABLE nav_history_details;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20211211103657_update_fee_currency_length.go b/pkg/migrations/sqlite3/main_20211211103657_update_fee_currency_length.go new file mode 100644 index 0000000..8f346ee --- /dev/null +++ b/pkg/migrations/sqlite3/main_20211211103657_update_fee_currency_length.go @@ -0,0 +1,29 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_updateFeeCurrencyLength, down_main_updateFeeCurrencyLength) +} + +func up_main_updateFeeCurrencyLength(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} + +func down_main_updateFeeCurrencyLength(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20211226022411_add_kucoin_klines.go b/pkg/migrations/sqlite3/main_20211226022411_add_kucoin_klines.go new file mode 100644 index 0000000..9fe9bd5 --- /dev/null +++ b/pkg/migrations/sqlite3/main_20211226022411_add_kucoin_klines.go @@ -0,0 +1,29 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addKucoinKlines, down_main_addKucoinKlines) +} + +func up_main_addKucoinKlines(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `kucoin_klines`\n(\n `gid` INTEGER PRIMARY KEY AUTOINCREMENT,\n `exchange` VARCHAR(10) NOT NULL,\n `start_time` DATETIME(3) NOT NULL,\n `end_time` DATETIME(3) NOT NULL,\n `interval` VARCHAR(3) NOT NULL,\n `symbol` VARCHAR(7) NOT NULL,\n `open` DECIMAL(16, 8) NOT NULL,\n `high` DECIMAL(16, 8) NOT NULL,\n `low` DECIMAL(16, 8) NOT NULL,\n `close` DECIMAL(16, 8) NOT NULL DEFAULT 0.0,\n `volume` DECIMAL(16, 8) NOT NULL DEFAULT 0.0,\n `closed` BOOLEAN NOT NULL DEFAULT TRUE,\n `last_trade_id` INT NOT NULL DEFAULT 0,\n `num_trades` INT NOT NULL DEFAULT 0,\n `quote_volume` DECIMAL NOT NULL DEFAULT 0.0,\n `taker_buy_base_volume` DECIMAL NOT NULL DEFAULT 0.0,\n `taker_buy_quote_volume` DECIMAL NOT NULL DEFAULT 0.0\n);") + if err != nil { + return err + } + return err +} + +func down_main_addKucoinKlines(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP TABLE kucoin_klines;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20220304153309_add_profit_table.go b/pkg/migrations/sqlite3/main_20220304153309_add_profit_table.go new file mode 100644 index 0000000..560ab11 --- /dev/null +++ b/pkg/migrations/sqlite3/main_20220304153309_add_profit_table.go @@ -0,0 +1,29 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addProfitTable, down_main_addProfitTable) +} + +func up_main_addProfitTable(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `profits`\n(\n `gid` INTEGER PRIMARY KEY AUTOINCREMENT,\n `strategy` VARCHAR(32) NOT NULL,\n `strategy_instance_id` VARCHAR(64) NOT NULL,\n `symbol` VARCHAR(8) NOT NULL,\n -- average_cost is the position average cost\n `average_cost` DECIMAL(16, 8) NOT NULL,\n -- profit is the pnl (profit and loss)\n `profit` DECIMAL(16, 8) NOT NULL,\n -- net_profit is the pnl (profit and loss)\n `net_profit` DECIMAL(16, 8) NOT NULL,\n -- profit_margin is the pnl (profit and loss)\n `profit_margin` DECIMAL(16, 8) NOT NULL,\n -- net_profit_margin is the pnl (profit and loss)\n `net_profit_margin` DECIMAL(16, 8) NOT NULL,\n `quote_currency` VARCHAR(10) NOT NULL,\n `base_currency` VARCHAR(10) NOT NULL,\n -- -------------------------------------------------------\n -- embedded trade data --\n -- -------------------------------------------------------\n `exchange` VARCHAR(24) NOT NULL DEFAULT '',\n `is_futures` BOOLEAN NOT NULL DEFAULT FALSE,\n `is_margin` BOOLEAN NOT NULL DEFAULT FALSE,\n `is_isolated` BOOLEAN NOT NULL DEFAULT FALSE,\n `trade_id` BIGINT NOT NULL,\n -- side is the side of the trade that makes profit\n `side` VARCHAR(4) NOT NULL DEFAULT '',\n `is_buyer` BOOLEAN NOT NULL DEFAULT FALSE,\n `is_maker` BOOLEAN NOT NULL DEFAULT FALSE,\n -- price is the price of the trade that makes profit\n `price` DECIMAL(16, 8) NOT NULL,\n -- quantity is the quantity of the trade that makes profit\n `quantity` DECIMAL(16, 8) NOT NULL,\n -- trade_amount is the quote quantity of the trade that makes profit\n `quote_quantity` DECIMAL(16, 8) NOT NULL,\n `traded_at` DATETIME(3) NOT NULL,\n -- fee\n `fee_in_usd` DECIMAL(16, 8),\n `fee` DECIMAL(16, 8) NOT NULL,\n `fee_currency` VARCHAR(10) NOT NULL\n);") + if err != nil { + return err + } + return err +} + +func down_main_addProfitTable(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP TABLE IF EXISTS `profits`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20220307132917_add_positions.go b/pkg/migrations/sqlite3/main_20220307132917_add_positions.go new file mode 100644 index 0000000..87a5ad2 --- /dev/null +++ b/pkg/migrations/sqlite3/main_20220307132917_add_positions.go @@ -0,0 +1,29 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addPositions, down_main_addPositions) +} + +func up_main_addPositions(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `positions`\n(\n `gid` INTEGER PRIMARY KEY AUTOINCREMENT,\n `strategy` VARCHAR(32) NOT NULL,\n `strategy_instance_id` VARCHAR(64) NOT NULL,\n `symbol` VARCHAR(20) NOT NULL,\n `quote_currency` VARCHAR(10) NOT NULL,\n `base_currency` VARCHAR(10) NOT NULL,\n -- average_cost is the position average cost\n `average_cost` DECIMAL(16, 8) NOT NULL,\n `base` DECIMAL(16, 8) NOT NULL,\n `quote` DECIMAL(16, 8) NOT NULL,\n `profit` DECIMAL(16, 8) NULL,\n -- trade related columns\n `trade_id` BIGINT NOT NULL,\n `side` VARCHAR(4) NOT NULL, -- side of the trade\n `exchange` VARCHAR(12) NOT NULL, -- exchange of the trade\n `traded_at` DATETIME(3) NOT NULL\n);") + if err != nil { + return err + } + return err +} + +func down_main_addPositions(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP TABLE IF EXISTS `positions`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20220317125555_fix_trade_indexes.go b/pkg/migrations/sqlite3/main_20220317125555_fix_trade_indexes.go new file mode 100644 index 0000000..6e08a10 --- /dev/null +++ b/pkg/migrations/sqlite3/main_20220317125555_fix_trade_indexes.go @@ -0,0 +1,69 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_fixTradeIndexes, down_main_fixTradeIndexes) +} + +func up_main_fixTradeIndexes(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "DROP INDEX IF EXISTS trades_symbol;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP INDEX IF EXISTS trades_symbol_fee_currency;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP INDEX IF EXISTS trades_traded_at_symbol;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX trades_traded_at ON trades (traded_at, symbol, exchange, id, fee_currency, fee);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX trades_id_traded_at ON trades (id, traded_at);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX trades_order_id_traded_at ON trades (order_id, traded_at);") + if err != nil { + return err + } + return err +} + +func down_main_fixTradeIndexes(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP INDEX IF EXISTS trades_traded_at;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP INDEX IF EXISTS trades_id_traded_at;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP INDEX IF EXISTS trades_order_id_traded_at;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX trades_symbol ON trades (exchange, symbol);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX trades_symbol_fee_currency ON trades (exchange, symbol, fee_currency, traded_at);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE INDEX trades_traded_at_symbol ON trades (exchange, traded_at, symbol);") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20220419121046_fix_fee_column.go b/pkg/migrations/sqlite3/main_20220419121046_fix_fee_column.go new file mode 100644 index 0000000..f190db6 --- /dev/null +++ b/pkg/migrations/sqlite3/main_20220419121046_fix_fee_column.go @@ -0,0 +1,29 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_fixFeeColumn, down_main_fixFeeColumn) +} + +func up_main_fixFeeColumn(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} + +func down_main_fixFeeColumn(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20220503144849_add_margin_info_to_nav.go b/pkg/migrations/sqlite3/main_20220503144849_add_margin_info_to_nav.go new file mode 100644 index 0000000..ca25e9e --- /dev/null +++ b/pkg/migrations/sqlite3/main_20220503144849_add_margin_info_to_nav.go @@ -0,0 +1,53 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addMarginInfoToNav, down_main_addMarginInfoToNav) +} + +func up_main_addMarginInfoToNav(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "ALTER TABLE `nav_history_details` ADD COLUMN `session` VARCHAR(50) NOT NULL;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `nav_history_details` ADD COLUMN `borrowed` DECIMAL DEFAULT 0.00000000 NOT NULL;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `nav_history_details` ADD COLUMN `net_asset` DECIMAL DEFAULT 0.00000000 NOT NULL;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `nav_history_details` ADD COLUMN `price_in_usd` DECIMAL DEFAULT 0.00000000 NOT NULL;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `nav_history_details` ADD COLUMN `is_margin` BOOL DEFAULT FALSE NOT NULL;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `nav_history_details` ADD COLUMN `is_isolated` BOOL DEFAULT FALSE NOT NULL;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "ALTER TABLE `nav_history_details` ADD COLUMN `isolated_symbol` VARCHAR(30) DEFAULT '' NOT NULL;") + if err != nil { + return err + } + return err +} + +func down_main_addMarginInfoToNav(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20220504184155_fix_net_asset_column.go b/pkg/migrations/sqlite3/main_20220504184155_fix_net_asset_column.go new file mode 100644 index 0000000..996a5cf --- /dev/null +++ b/pkg/migrations/sqlite3/main_20220504184155_fix_net_asset_column.go @@ -0,0 +1,29 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_fixNetAssetColumn, down_main_fixNetAssetColumn) +} + +func up_main_fixNetAssetColumn(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "ALTER TABLE `nav_history_details` ADD COLUMN `interest` DECIMAL DEFAULT 0.00000000 NOT NULL;") + if err != nil { + return err + } + return err +} + +func down_main_fixNetAssetColumn(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20220512170330_fix_profit_symbol_length.go b/pkg/migrations/sqlite3/main_20220512170330_fix_profit_symbol_length.go new file mode 100644 index 0000000..511c841 --- /dev/null +++ b/pkg/migrations/sqlite3/main_20220512170330_fix_profit_symbol_length.go @@ -0,0 +1,29 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_fixProfitSymbolLength, down_main_fixProfitSymbolLength) +} + +func up_main_fixProfitSymbolLength(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} + +func down_main_fixProfitSymbolLength(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20220520140707_kline_unique_idx.go b/pkg/migrations/sqlite3/main_20220520140707_kline_unique_idx.go new file mode 100644 index 0000000..238c172 --- /dev/null +++ b/pkg/migrations/sqlite3/main_20220520140707_kline_unique_idx.go @@ -0,0 +1,61 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_klineUniqueIdx, down_main_klineUniqueIdx) +} + +func up_main_klineUniqueIdx(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE UNIQUE INDEX idx_kline_binance_unique\n ON binance_klines (`symbol`, `interval`, `start_time`);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE UNIQUE INDEX idx_kline_max_unique\n ON max_klines (`symbol`, `interval`, `start_time`);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE UNIQUE INDEX `idx_kline_ftx_unique`\n ON ftx_klines (`symbol`, `interval`, `start_time`);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE UNIQUE INDEX `idx_kline_kucoin_unique`\n ON kucoin_klines (`symbol`, `interval`, `start_time`);") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "CREATE UNIQUE INDEX `idx_kline_okex_unique`\n ON okex_klines (`symbol`, `interval`, `start_time`);") + if err != nil { + return err + } + return err +} + +func down_main_klineUniqueIdx(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP INDEX `idx_kline_ftx_unique` ON `ftx_klines`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP INDEX `idx_kline_max_unique` ON `max_klines`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP INDEX `idx_kline_binance_unique` ON `binance_klines`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP INDEX `idx_kline_kucoin_unique` ON `kucoin_klines`;") + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DROP INDEX `idx_kline_okex_unique` ON `okex_klines`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20220531012226_margin_loans.go b/pkg/migrations/sqlite3/main_20220531012226_margin_loans.go new file mode 100644 index 0000000..d0c27e3 --- /dev/null +++ b/pkg/migrations/sqlite3/main_20220531012226_margin_loans.go @@ -0,0 +1,29 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_marginLoans, down_main_marginLoans) +} + +func up_main_marginLoans(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `margin_loans`\n(\n `gid` INTEGER PRIMARY KEY AUTOINCREMENT,\n `transaction_id` INTEGER NOT NULL,\n `exchange` VARCHAR(24) NOT NULL DEFAULT '',\n `asset` VARCHAR(24) NOT NULL DEFAULT '',\n `isolated_symbol` VARCHAR(24) NOT NULL DEFAULT '',\n -- quantity is the quantity of the trade that makes profit\n `principle` DECIMAL(16, 8) NOT NULL,\n `time` DATETIME(3) NOT NULL\n);") + if err != nil { + return err + } + return err +} + +func down_main_marginLoans(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP TABLE IF EXISTS `margin_loans`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20220531013327_margin_repays.go b/pkg/migrations/sqlite3/main_20220531013327_margin_repays.go new file mode 100644 index 0000000..4f5ae38 --- /dev/null +++ b/pkg/migrations/sqlite3/main_20220531013327_margin_repays.go @@ -0,0 +1,29 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_marginRepays, down_main_marginRepays) +} + +func up_main_marginRepays(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `margin_repays`\n(\n `gid` INTEGER PRIMARY KEY AUTOINCREMENT,\n `transaction_id` INTEGER NOT NULL,\n `exchange` VARCHAR(24) NOT NULL DEFAULT '',\n `asset` VARCHAR(24) NOT NULL DEFAULT '',\n `isolated_symbol` VARCHAR(24) NOT NULL DEFAULT '',\n -- quantity is the quantity of the trade that makes profit\n `principle` DECIMAL(16, 8) NOT NULL,\n `time` DATETIME(3) NOT NULL\n);") + if err != nil { + return err + } + return err +} + +func down_main_marginRepays(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP TABLE IF EXISTS `margin_repays`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20220531013541_margin_interests.go b/pkg/migrations/sqlite3/main_20220531013541_margin_interests.go new file mode 100644 index 0000000..22d8990 --- /dev/null +++ b/pkg/migrations/sqlite3/main_20220531013541_margin_interests.go @@ -0,0 +1,29 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_marginInterests, down_main_marginInterests) +} + +func up_main_marginInterests(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `margin_interests`\n(\n `gid` INTEGER PRIMARY KEY AUTOINCREMENT,\n `exchange` VARCHAR(24) NOT NULL DEFAULT '',\n `asset` VARCHAR(24) NOT NULL DEFAULT '',\n `isolated_symbol` VARCHAR(24) NOT NULL DEFAULT '',\n `principle` DECIMAL(16, 8) NOT NULL,\n `interest` DECIMAL(20, 16) NOT NULL,\n `interest_rate` DECIMAL(20, 16) NOT NULL,\n `time` DATETIME(3) NOT NULL\n);") + if err != nil { + return err + } + return err +} + +func down_main_marginInterests(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP TABLE IF EXISTS `margin_interests`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20220531015005_margin_liquidations.go b/pkg/migrations/sqlite3/main_20220531015005_margin_liquidations.go new file mode 100644 index 0000000..8761a51 --- /dev/null +++ b/pkg/migrations/sqlite3/main_20220531015005_margin_liquidations.go @@ -0,0 +1,29 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_marginLiquidations, down_main_marginLiquidations) +} + +func up_main_marginLiquidations(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `margin_liquidations`\n(\n `gid` INTEGER PRIMARY KEY AUTOINCREMENT,\n `exchange` VARCHAR(24) NOT NULL DEFAULT '',\n `symbol` VARCHAR(24) NOT NULL DEFAULT '',\n `order_id` INTEGER NOT NULL,\n `is_isolated` BOOL NOT NULL DEFAULT false,\n `average_price` DECIMAL(16, 8) NOT NULL,\n `price` DECIMAL(16, 8) NOT NULL,\n `quantity` DECIMAL(16, 8) NOT NULL,\n `executed_quantity` DECIMAL(16, 8) NOT NULL,\n `side` VARCHAR(5) NOT NULL DEFAULT '',\n `time_in_force` VARCHAR(5) NOT NULL DEFAULT '',\n `time` DATETIME(3) NOT NULL\n);") + if err != nil { + return err + } + return err +} + +func down_main_marginLiquidations(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP TABLE IF EXISTS `margin_liquidations`;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20230815173104_add_bybit_klines.go b/pkg/migrations/sqlite3/main_20230815173104_add_bybit_klines.go new file mode 100644 index 0000000..b46c1b0 --- /dev/null +++ b/pkg/migrations/sqlite3/main_20230815173104_add_bybit_klines.go @@ -0,0 +1,29 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_addBybitKlines, down_main_addBybitKlines) +} + +func up_main_addBybitKlines(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "CREATE TABLE `bybit_klines`\n(\n `gid` INTEGER PRIMARY KEY AUTOINCREMENT,\n `exchange` VARCHAR(10) NOT NULL,\n `start_time` DATETIME(3) NOT NULL,\n `end_time` DATETIME(3) NOT NULL,\n `interval` VARCHAR(3) NOT NULL,\n `symbol` VARCHAR(7) NOT NULL,\n `open` DECIMAL(16, 8) NOT NULL,\n `high` DECIMAL(16, 8) NOT NULL,\n `low` DECIMAL(16, 8) NOT NULL,\n `close` DECIMAL(16, 8) NOT NULL DEFAULT 0.0,\n `volume` DECIMAL(16, 8) NOT NULL DEFAULT 0.0,\n `closed` BOOLEAN NOT NULL DEFAULT TRUE,\n `last_trade_id` INT NOT NULL DEFAULT 0,\n `num_trades` INT NOT NULL DEFAULT 0,\n `quote_volume` DECIMAL NOT NULL DEFAULT 0.0,\n `taker_buy_base_volume` DECIMAL NOT NULL DEFAULT 0.0,\n `taker_buy_quote_volume` DECIMAL NOT NULL DEFAULT 0.0\n);") + if err != nil { + return err + } + return err +} + +func down_main_addBybitKlines(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "DROP TABLE bybit_klines;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/main_20231123125402_fix_order_status_length.go b/pkg/migrations/sqlite3/main_20231123125402_fix_order_status_length.go new file mode 100644 index 0000000..c3c1ebf --- /dev/null +++ b/pkg/migrations/sqlite3/main_20231123125402_fix_order_status_length.go @@ -0,0 +1,29 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper/v2" +) + +func init() { + AddMigration("main", up_main_fixOrderStatusLength, down_main_fixOrderStatusLength) +} + +func up_main_fixOrderStatusLength(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} + +func down_main_fixOrderStatusLength(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + _, err = tx.ExecContext(ctx, "SELECT 1;") + if err != nil { + return err + } + return err +} diff --git a/pkg/migrations/sqlite3/migration_api.go b/pkg/migrations/sqlite3/migration_api.go new file mode 100644 index 0000000..cd67716 --- /dev/null +++ b/pkg/migrations/sqlite3/migration_api.go @@ -0,0 +1,91 @@ +package sqlite3 + +import ( + "fmt" + "log" + "runtime" + "strings" + + "github.com/c9s/rockhopper/v2" +) + +var registeredGoMigrations = map[rockhopper.RegistryKey]*rockhopper.Migration{} + +func MergeMigrationsMap(ms map[rockhopper.RegistryKey]*rockhopper.Migration) { + for k, m := range ms { + if _, ok := registeredGoMigrations[k]; !ok { + registeredGoMigrations[k] = m + } else { + log.Printf("the migration key %+v is duplicated: %+v", k, m) + } + } +} + +func GetMigrationsMap() map[rockhopper.RegistryKey]*rockhopper.Migration { + return registeredGoMigrations +} + +// SortedMigrations builds up the migration objects, sort them by timestamp and return as a slice +func SortedMigrations() rockhopper.MigrationSlice { + return Migrations() +} + +// Migrations builds up the migration objects, sort them by timestamp and return as a slice +func Migrations() rockhopper.MigrationSlice { + var migrations = rockhopper.MigrationSlice{} + for _, migration := range registeredGoMigrations { + migrations = append(migrations, migration) + } + + return migrations.SortAndConnect() +} + +// AddMigration adds a migration with its runtime caller information +func AddMigration(packageName string, up, down rockhopper.TransactionHandler) { + pc, filename, _, _ := runtime.Caller(1) + + if packageName == "" { + funcName := runtime.FuncForPC(pc).Name() + packageName = _parseFuncPackageName(funcName) + } + + AddNamedMigration(packageName, filename, up, down) +} + +// parseFuncPackageName parses the package name from a given runtime caller function name +func _parseFuncPackageName(funcName string) string { + lastSlash := strings.LastIndexByte(funcName, '/') + if lastSlash < 0 { + lastSlash = 0 + } + + lastDot := strings.LastIndexByte(funcName[lastSlash:], '.') + lastSlash + packageName := funcName[:lastDot] + return packageName +} + +// AddNamedMigration adds a named migration to the registered go migration map +func AddNamedMigration(packageName, filename string, up, down rockhopper.TransactionHandler) { + v, err := rockhopper.FileNumericComponent(filename) + if err != nil { + panic(fmt.Errorf("unable to parse numeric component from filename %s: %v", filename, err)) + } + + migration := &rockhopper.Migration{ + Package: packageName, + Registered: true, + + Version: v, + UpFn: up, + DownFn: down, + Source: filename, + UseTx: true, + } + + key := rockhopper.RegistryKey{Package: packageName, Version: v} + if existing, ok := registeredGoMigrations[key]; ok { + panic(fmt.Sprintf("failed to add migration %q: version conflicts with key %+v: %+v", filename, key, existing)) + } + + registeredGoMigrations[key] = migration +} diff --git a/pkg/migrations/sqlite3/migration_api_test.go b/pkg/migrations/sqlite3/migration_api_test.go new file mode 100644 index 0000000..cb295ee --- /dev/null +++ b/pkg/migrations/sqlite3/migration_api_test.go @@ -0,0 +1,21 @@ +package sqlite3 + +import ( + "testing" + + "github.com/c9s/rockhopper/v2" + + "github.com/stretchr/testify/assert" +) + +func TestGetMigrationsMap(t *testing.T) { + mm := GetMigrationsMap() + assert.NotEmpty(t, mm) +} + +func TestMergeMigrationsMap(t *testing.T) { + MergeMigrationsMap(map[rockhopper.RegistryKey]*rockhopper.Migration{ + rockhopper.RegistryKey{Version: 2}: &rockhopper.Migration{}, + rockhopper.RegistryKey{Version: 2}: &rockhopper.Migration{}, + }) +} diff --git a/pkg/net/websocketbase/client.go b/pkg/net/websocketbase/client.go new file mode 100644 index 0000000..0754777 --- /dev/null +++ b/pkg/net/websocketbase/client.go @@ -0,0 +1,100 @@ +package websocketbase + +import ( + "context" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +// WebsocketClientBase is a legacy base client +// Deprecated: please use standard stream instead. +//go:generate callbackgen -type WebsocketClientBase +type WebsocketClientBase struct { + baseURL string + + // mu protects conn + mu sync.Mutex + conn *websocket.Conn + reconnectC chan struct{} + reconnectDuration time.Duration + + connectedCallbacks []func(conn *websocket.Conn) + disconnectedCallbacks []func(conn *websocket.Conn) + messageCallbacks []func(message []byte) + errorCallbacks []func(err error) +} + +func NewWebsocketClientBase(baseURL string, reconnectDuration time.Duration) *WebsocketClientBase { + return &WebsocketClientBase{ + baseURL: baseURL, + reconnectC: make(chan struct{}, 1), + reconnectDuration: reconnectDuration, + } +} + +func (s *WebsocketClientBase) Listen(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case <-s.reconnectC: + time.Sleep(s.reconnectDuration) + if err := s.connect(ctx); err != nil { + s.Reconnect() + } + default: + conn := s.Conn() + mt, msg, err := conn.ReadMessage() + + if err != nil { + s.Reconnect() + continue + } + + if mt != websocket.TextMessage { + continue + } + + s.EmitMessage(msg) + } + } +} + +func (s *WebsocketClientBase) Connect(ctx context.Context) error { + if err := s.connect(ctx); err != nil { + return err + } + go s.Listen(ctx) + return nil +} + +func (s *WebsocketClientBase) Reconnect() { + select { + case s.reconnectC <- struct{}{}: + default: + } +} + +func (s *WebsocketClientBase) connect(ctx context.Context) error { + dialer := websocket.DefaultDialer + conn, _, err := dialer.DialContext(ctx, s.baseURL, nil) + if err != nil { + return err + } + + s.mu.Lock() + s.conn = conn + s.mu.Unlock() + + s.EmitConnected(conn) + + return nil +} + +func (s *WebsocketClientBase) Conn() *websocket.Conn { + s.mu.Lock() + defer s.mu.Unlock() + return s.conn +} diff --git a/pkg/net/websocketbase/websocketclientbase_callbacks.go b/pkg/net/websocketbase/websocketclientbase_callbacks.go new file mode 100644 index 0000000..4445357 --- /dev/null +++ b/pkg/net/websocketbase/websocketclientbase_callbacks.go @@ -0,0 +1,47 @@ +// Code generated by "callbackgen -type WebsocketClientBase"; DO NOT EDIT. + +package websocketbase + +import ( + "github.com/gorilla/websocket" +) + +func (s *WebsocketClientBase) OnConnected(cb func(conn *websocket.Conn)) { + s.connectedCallbacks = append(s.connectedCallbacks, cb) +} + +func (s *WebsocketClientBase) EmitConnected(conn *websocket.Conn) { + for _, cb := range s.connectedCallbacks { + cb(conn) + } +} + +func (s *WebsocketClientBase) OnDisconnected(cb func(conn *websocket.Conn)) { + s.disconnectedCallbacks = append(s.disconnectedCallbacks, cb) +} + +func (s *WebsocketClientBase) EmitDisconnected(conn *websocket.Conn) { + for _, cb := range s.disconnectedCallbacks { + cb(conn) + } +} + +func (s *WebsocketClientBase) OnMessage(cb func(message []byte)) { + s.messageCallbacks = append(s.messageCallbacks, cb) +} + +func (s *WebsocketClientBase) EmitMessage(message []byte) { + for _, cb := range s.messageCallbacks { + cb(message) + } +} + +func (s *WebsocketClientBase) OnError(cb func(err error)) { + s.errorCallbacks = append(s.errorCallbacks, cb) +} + +func (s *WebsocketClientBase) EmitError(err error) { + for _, cb := range s.errorCallbacks { + cb(err) + } +} diff --git a/pkg/notifier/slacknotifier/slack.go b/pkg/notifier/slacknotifier/slack.go new file mode 100644 index 0000000..8e57a36 --- /dev/null +++ b/pkg/notifier/slacknotifier/slack.go @@ -0,0 +1,164 @@ +package slacknotifier + +import ( + "bytes" + "context" + "fmt" + "time" + + "golang.org/x/time/rate" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + + log "github.com/sirupsen/logrus" + "github.com/slack-go/slack" +) + +var limiter = rate.NewLimiter(rate.Every(1*time.Second), 3) + +type notifyTask struct { + Channel string + Opts []slack.MsgOption +} + +type Notifier struct { + client *slack.Client + channel string + + taskC chan notifyTask +} + +type NotifyOption func(notifier *Notifier) + +func New(client *slack.Client, channel string, options ...NotifyOption) *Notifier { + notifier := &Notifier{ + channel: channel, + client: client, + taskC: make(chan notifyTask, 100), + } + + for _, o := range options { + o(notifier) + } + + go notifier.worker() + + return notifier +} + +func (n *Notifier) worker() { + ctx := context.Background() + for { + select { + case <-ctx.Done(): + return + + case task := <-n.taskC: + limiter.Wait(ctx) + + _, _, err := n.client.PostMessageContext(ctx, task.Channel, task.Opts...) + if err != nil { + log.WithError(err). + WithField("channel", task.Channel). + Errorf("slack api error: %s", err.Error()) + } + } + } +} + +func (n *Notifier) Notify(obj interface{}, args ...interface{}) { + n.NotifyTo(n.channel, obj, args...) +} + +func filterSlackAttachments(args []interface{}) (slackAttachments []slack.Attachment, pureArgs []interface{}) { + var firstAttachmentOffset = -1 + for idx, arg := range args { + switch a := arg.(type) { + + // concrete type assert first + case slack.Attachment: + if firstAttachmentOffset == -1 { + firstAttachmentOffset = idx + } + + slackAttachments = append(slackAttachments, a) + + case *slack.Attachment: + if firstAttachmentOffset == -1 { + firstAttachmentOffset = idx + } + + slackAttachments = append(slackAttachments, *a) + + case types.SlackAttachmentCreator: + if firstAttachmentOffset == -1 { + firstAttachmentOffset = idx + } + + slackAttachments = append(slackAttachments, a.SlackAttachment()) + + case types.PlainText: + if firstAttachmentOffset == -1 { + firstAttachmentOffset = idx + } + + // fallback to PlainText if it's not supported + // convert plain text to slack attachment + text := a.PlainText() + slackAttachments = append(slackAttachments, slack.Attachment{ + Title: text, + }) + } + } + + pureArgs = args + if firstAttachmentOffset > -1 { + pureArgs = args[:firstAttachmentOffset] + } + + return slackAttachments, pureArgs +} + +func (n *Notifier) NotifyTo(channel string, obj interface{}, args ...interface{}) { + if len(channel) == 0 { + channel = n.channel + } + + slackAttachments, pureArgs := filterSlackAttachments(args) + + var opts []slack.MsgOption + + switch a := obj.(type) { + case string: + opts = append(opts, slack.MsgOptionText(fmt.Sprintf(a, pureArgs...), true), + slack.MsgOptionAttachments(slackAttachments...)) + + case slack.Attachment: + opts = append(opts, slack.MsgOptionAttachments(append([]slack.Attachment{a}, slackAttachments...)...)) + + case types.SlackAttachmentCreator: + // convert object to slack attachment (if supported) + opts = append(opts, slack.MsgOptionAttachments(append([]slack.Attachment{a.SlackAttachment()}, slackAttachments...)...)) + + default: + log.Errorf("slack message conversion error, unsupported object: %T %+v", a, a) + + } + + select { + case n.taskC <- notifyTask{ + Channel: channel, + Opts: opts, + }: + case <-time.After(50 * time.Millisecond): + return + } +} + +func (n *Notifier) SendPhoto(buffer *bytes.Buffer) { + n.SendPhotoTo(n.channel, buffer) +} + +func (n *Notifier) SendPhotoTo(channel string, buffer *bytes.Buffer) { + // TODO +} diff --git a/pkg/notifier/telegramnotifier/logrus_look.go b/pkg/notifier/telegramnotifier/logrus_look.go new file mode 100644 index 0000000..dfe9247 --- /dev/null +++ b/pkg/notifier/telegramnotifier/logrus_look.go @@ -0,0 +1,45 @@ +package telegramnotifier + +import ( + "fmt" + "time" + + "github.com/sirupsen/logrus" + "golang.org/x/time/rate" +) + +var limiter = rate.NewLimiter(rate.Every(time.Minute), 3) + +type LogHook struct { + notifier *Notifier +} + +func NewLogHook(notifier *Notifier) *LogHook { + return &LogHook{ + notifier: notifier, + } +} + +func (t *LogHook) Levels() []logrus.Level { + return []logrus.Level{ + logrus.ErrorLevel, + logrus.FatalLevel, + logrus.PanicLevel, + } +} + +func (t *LogHook) Fire(e *logrus.Entry) error { + if !limiter.Allow() { + return nil + } + + var message = fmt.Sprintf("[%s] %s", e.Level.String(), e.Message) + if errData, ok := e.Data[logrus.ErrorKey]; ok && errData != nil { + if err, isErr := errData.(error); isErr { + message += " Error: " + err.Error() + } + } + + t.notifier.Notify(message) + return nil +} diff --git a/pkg/notifier/telegramnotifier/telegram.go b/pkg/notifier/telegramnotifier/telegram.go new file mode 100644 index 0000000..1e300e3 --- /dev/null +++ b/pkg/notifier/telegramnotifier/telegram.go @@ -0,0 +1,254 @@ +package telegramnotifier + +import ( + "bytes" + "context" + "fmt" + "reflect" + "strconv" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/sirupsen/logrus" + "golang.org/x/time/rate" +) + +var apiLimiter = rate.NewLimiter(rate.Every(50*time.Millisecond), 20) + +var log = logrus.WithField("service", "telegram") + +type notifyTask struct { + message string + texts []string + photoBuffer *bytes.Buffer +} + +type Notifier struct { + bot *telebot.Bot + + // Subscribers stores the Chat objects for broadcasting public notification + Subscribers map[int64]time.Time `json:"subscribers"` + + // Chats are the private chats that we will send private notification + Chats map[int64]*telebot.Chat `json:"chats"` + + broadcast bool + + taskC chan notifyTask +} + +type Option func(notifier *Notifier) + +func UseBroadcast() Option { + return func(notifier *Notifier) { + notifier.broadcast = true + } +} + +// New returns a telegram notifier instance +func New(bot *telebot.Bot, options ...Option) *Notifier { + notifier := &Notifier{ + bot: bot, + Chats: make(map[int64]*telebot.Chat), + Subscribers: make(map[int64]time.Time), + taskC: make(chan notifyTask, 100), + } + + for _, o := range options { + o(notifier) + } + + go notifier.worker() + + return notifier +} + +func (n *Notifier) worker() { + ctx := context.Background() + for { + select { + case <-ctx.Done(): + return + case task := <-n.taskC: + apiLimiter.Wait(ctx) + n.consume(task) + } + } +} + +func (n *Notifier) consume(task notifyTask) { + if n.broadcast { + if n.Subscribers == nil { + return + } + if task.message != "" { + n.Broadcast(task.message) + } + for _, text := range task.texts { + n.Broadcast(text) + } + if task.photoBuffer == nil { + return + } + + for chatID := range n.Subscribers { + chat, err := n.bot.ChatByID(strconv.FormatInt(chatID, 10)) + if err != nil { + log.WithError(err).Error("can not get chat by ID") + continue + } + album := telebot.Album{ + photoFromBuffer(task.photoBuffer), + } + if _, err := n.bot.SendAlbum(chat, album); err != nil { + log.WithError(err).Error("failed to send message") + } + } + } else if n.Chats != nil { + for _, chat := range n.Chats { + if task.message != "" { + if _, err := n.bot.Send(chat, task.message); err != nil { + log.WithError(err).Error("telegram send error") + } + } + + for _, text := range task.texts { + if _, err := n.bot.Send(chat, text); err != nil { + log.WithError(err).Error("telegram send error") + } + } + if task.photoBuffer != nil { + album := telebot.Album{ + photoFromBuffer(task.photoBuffer), + } + if _, err := n.bot.SendAlbum(chat, album); err != nil { + log.WithError(err).Error("telegram send error") + } + } + } + } +} + +func (n *Notifier) Notify(obj interface{}, args ...interface{}) { + n.NotifyTo("", obj, args...) +} + +func filterPlaintextMessages(args []interface{}) (texts []string, pureArgs []interface{}) { + var firstObjectOffset = -1 + for idx, arg := range args { + rt := reflect.TypeOf(arg) + if rt.Kind() == reflect.Ptr { + switch a := arg.(type) { + + case nil: + texts = append(texts, "nil") + if firstObjectOffset == -1 { + firstObjectOffset = idx + } + + case types.PlainText: + texts = append(texts, a.PlainText()) + if firstObjectOffset == -1 { + firstObjectOffset = idx + } + + case types.Stringer: + texts = append(texts, a.String()) + if firstObjectOffset == -1 { + firstObjectOffset = idx + } + } + } + } + + pureArgs = args + if firstObjectOffset > -1 { + pureArgs = args[:firstObjectOffset] + } + + return texts, pureArgs +} + +func (n *Notifier) NotifyTo(channel string, obj interface{}, args ...interface{}) { + var texts, pureArgs = filterPlaintextMessages(args) + var message string + + switch a := obj.(type) { + + case string: + message = fmt.Sprintf(a, pureArgs...) + + case types.PlainText: + message = a.PlainText() + + case types.Stringer: + message = a.String() + + default: + log.Errorf("unsupported notification format: %T %+v", a, a) + + } + + select { + case n.taskC <- notifyTask{ + texts: texts, + message: message, + }: + default: + log.Error("[telegram] cannot send task to notify") + } +} + +func (n *Notifier) SendPhoto(buffer *bytes.Buffer) { + n.SendPhotoTo("", buffer) +} + +func photoFromBuffer(buffer *bytes.Buffer) telebot.InputMedia { + reader := bytes.NewReader(buffer.Bytes()) + return &telebot.Photo{ + File: telebot.FromReader(reader), + } +} + +func (n *Notifier) SendPhotoTo(channel string, buffer *bytes.Buffer) { + select { + case n.taskC <- notifyTask{ + photoBuffer: buffer, + }: + case <-time.After(50 * time.Millisecond): + return + } +} + +func (n *Notifier) AddChat(c *telebot.Chat) { + if n.Chats == nil { + n.Chats = make(map[int64]*telebot.Chat) + } + n.Chats[c.ID] = c +} + +func (n *Notifier) AddSubscriber(m *telebot.Message) { + if n.Subscribers == nil { + n.Subscribers = make(map[int64]time.Time) + } + + n.Subscribers[m.Chat.ID] = m.Time() +} + +func (n *Notifier) Broadcast(message string) { + if n.Subscribers == nil { + return + } + + for chatID := range n.Subscribers { + chat, err := n.bot.ChatByID(strconv.FormatInt(chatID, 10)) + if err != nil { + log.WithError(err).Error("can not get chat by ID") + continue + } + + if _, err := n.bot.Send(chat, message); err != nil { + log.WithError(err).Error("failed to send message") + } + } +} diff --git a/pkg/optimizer/config.go b/pkg/optimizer/config.go new file mode 100644 index 0000000..c6b224c --- /dev/null +++ b/pkg/optimizer/config.go @@ -0,0 +1,105 @@ +package optimizer + +import ( + "fmt" + "io/ioutil" + "strings" + + "gopkg.in/yaml.v3" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +const ( + selectorTypeRange = "range" // deprecated: replaced by selectorTypeRangeFloat + selectorTypeRangeFloat = "rangeFloat" + selectorTypeRangeInt = "rangeInt" + selectorTypeIterate = "iterate" // deprecated: replaced by selectorTypeString + selectorTypeString = "string" + selectorTypeBool = "bool" +) + +type SelectorConfig struct { + Type string `json:"type" yaml:"type"` + Label string `json:"label,omitempty" yaml:"label,omitempty"` + Path string `json:"path" yaml:"path"` + Values []string `json:"values,omitempty" yaml:"values,omitempty"` + Min fixedpoint.Value `json:"min,omitempty" yaml:"min,omitempty"` + Max fixedpoint.Value `json:"max,omitempty" yaml:"max,omitempty"` + Step fixedpoint.Value `json:"step,omitempty" yaml:"step,omitempty"` +} + +type LocalExecutorConfig struct { + MaxNumberOfProcesses int `json:"maxNumberOfProcesses" yaml:"maxNumberOfProcesses"` +} + +type ExecutorConfig struct { + Type string `json:"type" yaml:"type"` + LocalExecutorConfig *LocalExecutorConfig `json:"local" yaml:"local"` +} + +type Config struct { + Executor *ExecutorConfig `json:"executor" yaml:"executor"` + MaxThread int `yaml:"maxThread,omitempty"` + Matrix []SelectorConfig `yaml:"matrix"` + Algorithm string `yaml:"algorithm,omitempty"` + Objective string `yaml:"objectiveBy,omitempty"` + MaxEvaluation int `yaml:"maxEvaluation"` +} + +var defaultExecutorConfig = &ExecutorConfig{ + Type: "local", + LocalExecutorConfig: defaultLocalExecutorConfig, +} + +var defaultLocalExecutorConfig = &LocalExecutorConfig{ + MaxNumberOfProcesses: 10, +} + +func LoadConfig(yamlConfigFileName string) (*Config, error) { + configYaml, err := ioutil.ReadFile(yamlConfigFileName) + if err != nil { + return nil, err + } + + var optConfig Config + if err := yaml.Unmarshal(configYaml, &optConfig); err != nil { + return nil, err + } + + switch alg := strings.ToLower(optConfig.Algorithm); alg { + case "", "default": + optConfig.Algorithm = HpOptimizerAlgorithmTPE + case HpOptimizerAlgorithmTPE, HpOptimizerAlgorithmCMAES, HpOptimizerAlgorithmSOBOL, HpOptimizerAlgorithmRandom: + optConfig.Algorithm = alg + default: + return nil, fmt.Errorf(`unknown algorithm "%s"`, optConfig.Algorithm) + } + + switch objective := strings.ToLower(optConfig.Objective); objective { + case "", "default": + optConfig.Objective = HpOptimizerObjectiveEquity + case HpOptimizerObjectiveEquity, HpOptimizerObjectiveProfit, HpOptimizerObjectiveVolume, HpOptimizerObjectiveProfitFactor: + optConfig.Objective = objective + default: + return nil, fmt.Errorf(`unknown objective "%s"`, optConfig.Objective) + } + + if optConfig.MaxEvaluation <= 0 { + optConfig.MaxEvaluation = 100 + } + + if optConfig.Executor == nil { + optConfig.Executor = defaultExecutorConfig + } + + if optConfig.Executor.Type == "" { + optConfig.Executor.Type = "local" + } + + if optConfig.Executor.Type == "local" && optConfig.Executor.LocalExecutorConfig == nil { + optConfig.Executor.LocalExecutorConfig = defaultLocalExecutorConfig + } + + return &optConfig, nil +} diff --git a/pkg/optimizer/format.go b/pkg/optimizer/format.go new file mode 100644 index 0000000..0546600 --- /dev/null +++ b/pkg/optimizer/format.go @@ -0,0 +1,142 @@ +package optimizer + +import ( + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/data/tsv" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "io" + "strconv" +) + +func FormatResultsTsv(writer io.WriteCloser, labelPaths map[string]string, results []*HyperparameterOptimizeTrialResult) error { + headerLen := len(labelPaths) + headers := make([]string, 0, headerLen) + for label := range labelPaths { + headers = append(headers, label) + } + + rows := make([][]interface{}, len(labelPaths)) + for ri, result := range results { + row := make([]interface{}, headerLen) + for ci, columnKey := range headers { + var ok bool + if row[ci], ok = result.Parameters[columnKey]; !ok { + return fmt.Errorf(`missing parameter "%s" from trial result (%v)`, columnKey, result.Parameters) + } + } + rows[ri] = row + } + + 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 FormatMetricsTsv(writer io.WriteCloser, metrics map[string][]Metric) 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 transformMetricsToRows(metrics map[string][]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 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/optimizer/grid.go b/pkg/optimizer/grid.go new file mode 100644 index 0000000..7e5d816 --- /dev/null +++ b/pkg/optimizer/grid.go @@ -0,0 +1,322 @@ +package optimizer + +import ( + "context" + "encoding/json" + "fmt" + "sort" + + jsonpatch "github.com/evanphx/json-patch/v5" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/backtest" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +type MetricValueFunc func(summaryReport *backtest.SummaryReport) float64 + +var TotalProfitMetricValueFunc = func(summaryReport *backtest.SummaryReport) float64 { + return summaryReport.TotalProfit.Float64() +} + +var TotalVolume = func(summaryReport *backtest.SummaryReport) float64 { + if len(summaryReport.SymbolReports) == 0 { + return 0 + } + + buyVolume := summaryReport.SymbolReports[0].PnL.BuyVolume.Float64() + sellVolume := summaryReport.SymbolReports[0].PnL.SellVolume.Float64() + return buyVolume + sellVolume +} + +var TotalEquityDiff = func(summaryReport *backtest.SummaryReport) float64 { + if len(summaryReport.SymbolReports) == 0 { + return 0 + } + + initEquity := summaryReport.InitialEquityValue.Float64() + finalEquity := summaryReport.FinalEquityValue.Float64() + return finalEquity - initEquity +} + +var ProfitFactorMetricValueFunc = func(summaryReport *backtest.SummaryReport) float64 { + if len(summaryReport.SymbolReports) == 0 { + return 0 + } + if len(summaryReport.SymbolReports) > 1 { + panic("multiple symbols' profitfactor optimization not supported") + } + report := summaryReport.SymbolReports[0] + pf := report.ProfitFactor.Float64() + win := report.WinningRatio.Float64() + return pf*0.9 + win*0.1 +} + +type Metric struct { + // 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 float64 `json:"value,omitempty"` +} + +func copyParams(params []interface{}) []interface{} { + var c = make([]interface{}, len(params)) + copy(c, params) + return c +} + +func copyLabels(labels []string) []string { + var c = make([]string, len(labels)) + copy(c, labels) + return c +} + +type GridOptimizer struct { + Config *Config + + ParamLabels []string + CurrentParams []interface{} +} + +func (o *GridOptimizer) buildOps() []OpFunc { + var ops []OpFunc + + o.CurrentParams = make([]interface{}, len(o.Config.Matrix)) + o.ParamLabels = make([]string, len(o.Config.Matrix)) + + for i, selector := range o.Config.Matrix { + var path = selector.Path + var ii = i // copy variable because we need to use them in the closure + + if selector.Label != "" { + o.ParamLabels[ii] = selector.Label + } else { + o.ParamLabels[ii] = selector.Path + } + + switch selector.Type { + case selectorTypeRange, selectorTypeRangeFloat, selectorTypeRangeInt: + min := selector.Min + max := selector.Max + step := selector.Step + if step.IsZero() { + step = fixedpoint.One + } + + var values []fixedpoint.Value + for val := min; val.Compare(max) <= 0; val = val.Add(step) { + values = append(values, val) + } + + f := func(configJson []byte, next func(configJson []byte) error) error { + for _, val := range values { + jsonOp := []byte(reformatJson(fmt.Sprintf(`[{"op": "replace", "path": "%s", "value": %v }]`, path, val))) + patch, err := jsonpatch.DecodePatch(jsonOp) + if err != nil { + return err + } + + log.Debugf("json op: %s", jsonOp) + + patchedJson, err := patch.ApplyIndent(configJson, " ") + if err != nil { + return err + } + + valCopy := val + o.CurrentParams[ii] = valCopy + if err := next(patchedJson); err != nil { + return err + } + } + + return nil + } + ops = append(ops, f) + + case selectorTypeIterate, selectorTypeString: + values := selector.Values + f := func(configJson []byte, next func(configJson []byte) error) error { + for _, val := range values { + log.Debugf("%d %s: %v of %v", ii, path, val, values) + + jsonOp := []byte(reformatJson(fmt.Sprintf(`[{"op": "replace", "path": "%s", "value": "%s"}]`, path, val))) + patch, err := jsonpatch.DecodePatch(jsonOp) + if err != nil { + return err + } + + log.Debugf("json op: %s", jsonOp) + + patchedJson, err := patch.ApplyIndent(configJson, " ") + if err != nil { + return err + } + + valCopy := val + o.CurrentParams[ii] = valCopy + if err := next(patchedJson); err != nil { + return err + } + } + + return nil + } + ops = append(ops, f) + case selectorTypeBool: + values := []bool{true, false} + f := func(configJson []byte, next func(configJson []byte) error) error { + for _, val := range values { + log.Debugf("%d %s: %v of %v", ii, path, val, values) + + jsonOp := []byte(reformatJson(fmt.Sprintf(`[{"op": "replace", "path": "%s", "value": %v}]`, path, val))) + patch, err := jsonpatch.DecodePatch(jsonOp) + if err != nil { + return err + } + + log.Debugf("json op: %s", jsonOp) + + patchedJson, err := patch.ApplyIndent(configJson, " ") + if err != nil { + return err + } + + valCopy := val + o.CurrentParams[ii] = valCopy + if err := next(patchedJson); err != nil { + return err + } + } + + return nil + } + ops = append(ops, f) + } + } + return ops +} + +func (o *GridOptimizer) Run(executor Executor, configJson []byte) (map[string][]Metric, error) { + o.CurrentParams = make([]interface{}, len(o.Config.Matrix)) + + var valueFunctions = map[string]MetricValueFunc{ + "totalProfit": TotalProfitMetricValueFunc, + "totalVolume": TotalVolume, + "totalEquityDiff": TotalEquityDiff, + "profitFactor": ProfitFactorMetricValueFunc, + } + var metrics = map[string][]Metric{} + + var ops = o.buildOps() + + var taskC = make(chan BacktestTask, 10000) + + var taskCnt = 0 + var app = func(configJson []byte, next func(configJson []byte) error) error { + var labels = copyLabels(o.ParamLabels) + var params = copyParams(o.CurrentParams) + taskC <- BacktestTask{ + ConfigJson: configJson, + Params: params, + Labels: labels, + } + return nil + } + var appCnt = func(configJson []byte, next func(configJson []byte) error) error { + taskCnt++ + return nil + } + + log.Debugf("build %d ops", len(ops)) + + var wrapper = func(configJson []byte) error { + return app(configJson, nil) + } + var wrapperCnt = func(configJson []byte) error { + return appCnt(configJson, nil) + } + + for i := len(ops) - 1; i >= 0; i-- { + cur := ops[i] + inner := wrapper + innerCnt := wrapperCnt + wrapper = func(configJson []byte) error { + return cur(configJson, inner) + } + wrapperCnt = func(configJson []byte) error { + return cur(configJson, innerCnt) + } + } + + if err := wrapperCnt(configJson); err != nil { + return nil, err + } + var bar = pb.Full.New(taskCnt) + bar.SetTemplateString(`{{ string . "log" | green}} | {{counters . }} {{bar . }} {{percent . }} {{etime . }} {{rtime . "ETA %s"}}`) + + ctx := context.Background() + var taskGenErr error + go func() { + taskGenErr = wrapper(configJson) + close(taskC) // this will shut down the executor + }() + + resultsC, err := executor.Run(ctx, taskC, bar) + if err != nil { + return nil, err + } + + for result := range resultsC { + bar.Increment() + + 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, metricKey, metricValue)) + + metrics[metricKey] = append(metrics[metricKey], Metric{ + Params: result.Params, + Labels: result.Labels, + Key: metricKey, + Value: metricValue, + }) + } + } + bar.Finish() + + for n := range metrics { + sort.Slice(metrics[n], func(i, j int) bool { + a := metrics[n][i].Value + b := metrics[n][j].Value + return a > b + }) + } + + if taskGenErr != nil { + return metrics, taskGenErr + } else { + return metrics, err + } +} + +func reformatJson(text string) string { + var a interface{} + var err = json.Unmarshal([]byte(text), &a) + if err != nil { + return "{invalid json}" + } + + out, _ := json.MarshalIndent(a, "", " ") + return string(out) +} diff --git a/pkg/optimizer/hpoptimizer.go b/pkg/optimizer/hpoptimizer.go new file mode 100644 index 0000000..8c0c753 --- /dev/null +++ b/pkg/optimizer/hpoptimizer.go @@ -0,0 +1,302 @@ +package optimizer + +import ( + "context" + "fmt" + "math" + "sync" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "github.com/c-bata/goptuna" + goptunaCMAES "github.com/c-bata/goptuna/cmaes" + goptunaSOBOL "github.com/c-bata/goptuna/sobol" + goptunaTPE "github.com/c-bata/goptuna/tpe" + "github.com/sirupsen/logrus" + "golang.org/x/sync/errgroup" +) + +// WARNING: the text here could only be lower cases +const ( + // HpOptimizerObjectiveEquity optimize the parameters to maximize equity gain + HpOptimizerObjectiveEquity = "equity" + // HpOptimizerObjectiveProfit optimize the parameters to maximize trading profit + HpOptimizerObjectiveProfit = "profit" + // HpOptimizerObjectiveVolume optimize the parameters to maximize trading volume + HpOptimizerObjectiveVolume = "volume" + // HpOptimizerObjectiveProfitFactor optimize the parameters to maximize profit factor + HpOptimizerObjectiveProfitFactor = "profitfactor" +) + +const ( + // HpOptimizerAlgorithmTPE is the implementation of Tree-structured Parzen Estimators + HpOptimizerAlgorithmTPE = "tpe" + // HpOptimizerAlgorithmCMAES is the implementation Covariance Matrix Adaptation Evolution Strategy + HpOptimizerAlgorithmCMAES = "cmaes" + // HpOptimizerAlgorithmSOBOL is the implementation Quasi-monte carlo sampling based on Sobol sequence + HpOptimizerAlgorithmSOBOL = "sobol" + // HpOptimizerAlgorithmRandom is the implementation random search + HpOptimizerAlgorithmRandom = "random" +) + +type HyperparameterOptimizeTrialResult struct { + Value fixedpoint.Value `json:"value"` + Parameters map[string]interface{} `json:"parameters"` + ID *int `json:"id,omitempty"` + State string `json:"state,omitempty"` +} + +type HyperparameterOptimizeReport struct { + Name string `json:"studyName"` + Objective string `json:"objective"` + Parameters map[string]string `json:"domains"` + Best *HyperparameterOptimizeTrialResult `json:"best"` + Trials []*HyperparameterOptimizeTrialResult `json:"trials,omitempty"` +} + +func buildBestHyperparameterOptimizeResult(study *goptuna.Study) *HyperparameterOptimizeTrialResult { + val, _ := study.GetBestValue() + params, _ := study.GetBestParams() + return &HyperparameterOptimizeTrialResult{ + Value: fixedpoint.NewFromFloat(val), + Parameters: params, + } +} + +func buildHyperparameterOptimizeTrialResults(study *goptuna.Study) []*HyperparameterOptimizeTrialResult { + trials, _ := study.GetTrials() + results := make([]*HyperparameterOptimizeTrialResult, len(trials)) + for i, trial := range trials { + trialId := trial.ID + trialResult := &HyperparameterOptimizeTrialResult{ + ID: &trialId, + Value: fixedpoint.NewFromFloat(trial.Value), + Parameters: trial.Params, + } + results[i] = trialResult + } + return results +} + +type HyperparameterOptimizer struct { + SessionName string + Config *Config + + // Workaround for goptuna/tpe parameter suggestion. Remove this after fixed. + // ref: https://github.com/c-bata/goptuna/issues/236 + paramSuggestionLock sync.Mutex +} + +func (o *HyperparameterOptimizer) buildStudy(trialFinishChan chan goptuna.FrozenTrial) (*goptuna.Study, error) { + var studyOpts = make([]goptuna.StudyOption, 0, 2) + + // maximum the profit, volume, equity gain, ...etc + studyOpts = append(studyOpts, goptuna.StudyOptionDirection(goptuna.StudyDirectionMaximize)) + + // disable search log and collect trial progress + studyOpts = append(studyOpts, goptuna.StudyOptionLogger(nil)) + studyOpts = append(studyOpts, goptuna.StudyOptionTrialNotifyChannel(trialFinishChan)) + + // the search algorithm + var sampler goptuna.Sampler = nil + var relativeSampler goptuna.RelativeSampler = nil + switch o.Config.Algorithm { + case HpOptimizerAlgorithmRandom: + sampler = goptuna.NewRandomSampler() + case HpOptimizerAlgorithmTPE: + sampler = goptunaTPE.NewSampler() + case HpOptimizerAlgorithmCMAES: + relativeSampler = goptunaCMAES.NewSampler(goptunaCMAES.SamplerOptionNStartupTrials(5)) + case HpOptimizerAlgorithmSOBOL: + relativeSampler = goptunaSOBOL.NewSampler() + } + if sampler != nil { + studyOpts = append(studyOpts, goptuna.StudyOptionSampler(sampler)) + } else { + studyOpts = append(studyOpts, goptuna.StudyOptionRelativeSampler(relativeSampler)) + } + + return goptuna.CreateStudy(o.SessionName, studyOpts...) +} + +func (o *HyperparameterOptimizer) buildParamDomains() (map[string]string, []paramDomain) { + labelPaths := make(map[string]string) + domains := make([]paramDomain, 0, len(o.Config.Matrix)) + + for _, selector := range o.Config.Matrix { + var domain paramDomain + switch selector.Type { + case selectorTypeRange, selectorTypeRangeFloat: + if selector.Step.IsZero() { + domain = &floatRangeDomain{ + paramDomainBase: paramDomainBase{ + label: selector.Label, + path: selector.Path, + }, + min: selector.Min.Float64(), + max: selector.Max.Float64(), + } + } else { + domain = &floatDiscreteRangeDomain{ + paramDomainBase: paramDomainBase{ + label: selector.Label, + path: selector.Path, + }, + min: selector.Min.Float64(), + max: selector.Max.Float64(), + step: selector.Step.Float64(), + } + } + case selectorTypeRangeInt: + if selector.Step.IsZero() { + domain = &intRangeDomain{ + paramDomainBase: paramDomainBase{ + label: selector.Label, + path: selector.Path, + }, + min: selector.Min.Int(), + max: selector.Max.Int(), + } + } else { + domain = &intStepRangeDomain{ + paramDomainBase: paramDomainBase{ + label: selector.Label, + path: selector.Path, + }, + min: selector.Min.Int(), + max: selector.Max.Int(), + step: selector.Step.Int(), + } + } + case selectorTypeIterate, selectorTypeString: + domain = &stringDomain{ + paramDomainBase: paramDomainBase{ + label: selector.Label, + path: selector.Path, + }, + options: selector.Values, + } + case selectorTypeBool: + domain = &boolDomain{ + paramDomainBase: paramDomainBase{ + label: selector.Label, + path: selector.Path, + }, + } + default: + // unknown parameter type, skip + continue + } + labelPaths[selector.Label] = selector.Path + domains = append(domains, domain) + } + return labelPaths, domains +} + +func (o *HyperparameterOptimizer) buildObjective(executor Executor, configJson []byte, paramDomains []paramDomain) goptuna.FuncObjective { + var metricValueFunc MetricValueFunc + switch o.Config.Objective { + case HpOptimizerObjectiveProfit: + metricValueFunc = TotalProfitMetricValueFunc + case HpOptimizerObjectiveVolume: + metricValueFunc = TotalVolume + case HpOptimizerObjectiveEquity: + metricValueFunc = TotalEquityDiff + case HpOptimizerObjectiveProfitFactor: + metricValueFunc = ProfitFactorMetricValueFunc + } + + return func(trial goptuna.Trial) (float64, error) { + trialConfig, err := func(trialConfig []byte) ([]byte, error) { + o.paramSuggestionLock.Lock() + defer o.paramSuggestionLock.Unlock() + + for _, domain := range paramDomains { + if patch, err := domain.buildPatch(&trial); err != nil { + return nil, err + } else if patchedConfig, err := patch.ApplyIndent(trialConfig, " "); err != nil { + return nil, err + } else { + trialConfig = patchedConfig + } + } + return trialConfig, nil + }(configJson) + if err != nil { + return 0.0, err + } + + summary, err := executor.Execute(trialConfig) + if err != nil { + return 0.0, err + } + // By config, the Goptuna optimize the parameters by maximize the objective output. + return metricValueFunc(summary), nil + } +} + +func (o *HyperparameterOptimizer) Run(ctx context.Context, executor Executor, configJson []byte) (*HyperparameterOptimizeReport, error) { + labelPaths, paramDomains := o.buildParamDomains() + objective := o.buildObjective(executor, configJson, paramDomains) + + maxEvaluation := o.Config.MaxEvaluation + numOfProcesses := o.Config.Executor.LocalExecutorConfig.MaxNumberOfProcesses + if numOfProcesses > maxEvaluation { + numOfProcesses = maxEvaluation + } + maxEvaluationPerProcess := maxEvaluation / numOfProcesses + if maxEvaluation%numOfProcesses > 0 { + maxEvaluationPerProcess++ + } + + trialFinishChan := make(chan goptuna.FrozenTrial, 128) + allTrailFinishChan := make(chan struct{}) + bar := pb.Full.Start(maxEvaluation) + bar.SetTemplateString(`{{ string . "log" | green}} | {{counters . }} {{bar . }} {{percent . }} {{etime . }} {{rtime . "ETA %s"}}`) + + go func() { + defer close(allTrailFinishChan) + var bestVal = math.Inf(-1) + for result := range trialFinishChan { + log.WithFields(logrus.Fields{"ID": result.ID, "evaluation": result.Value, "state": result.State}).Debug("trial finished") + if result.State == goptuna.TrialStateFail { + log.WithFields(result.Params).Errorf("failed at trial #%d", result.ID) + } + if result.Value > bestVal { + bestVal = result.Value + } + bar.Set("log", fmt.Sprintf("best value: %v", bestVal)) + bar.Increment() + } + }() + + study, err := o.buildStudy(trialFinishChan) + if err != nil { + return nil, err + } + eg, studyCtx := errgroup.WithContext(ctx) + study.WithContext(studyCtx) + for i := 0; i < numOfProcesses; i++ { + processEvaluations := maxEvaluationPerProcess + if processEvaluations > maxEvaluation { + processEvaluations = maxEvaluation + } + eg.Go(func() error { + return study.Optimize(objective, processEvaluations) + }) + maxEvaluation -= processEvaluations + } + if err := eg.Wait(); err != nil && ctx.Err() != context.Canceled { + return nil, err + } + close(trialFinishChan) + <-allTrailFinishChan + bar.Finish() + + return &HyperparameterOptimizeReport{ + Name: o.SessionName, + Objective: o.Config.Objective, + Parameters: labelPaths, + Best: buildBestHyperparameterOptimizeResult(study), + Trials: buildHyperparameterOptimizeTrialResults(study), + }, nil +} diff --git a/pkg/optimizer/hpoptimizer_test.go b/pkg/optimizer/hpoptimizer_test.go new file mode 100644 index 0000000..919a619 --- /dev/null +++ b/pkg/optimizer/hpoptimizer_test.go @@ -0,0 +1,201 @@ +package optimizer + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "reflect" + "testing" +) + +func TestBuildParamDomains(t *testing.T) { + var floatRangeDomainVerifier = func(domain paramDomain, expect SelectorConfig) bool { + concrete := domain.(*floatRangeDomain) + return *concrete == floatRangeDomain{ + paramDomainBase: paramDomainBase{label: expect.Label, path: expect.Path}, + min: expect.Min.Float64(), + max: expect.Max.Float64(), + } + } + var floatDiscreteRangeDomainVerifier = func(domain paramDomain, expect SelectorConfig) bool { + concrete := domain.(*floatDiscreteRangeDomain) + return *concrete == floatDiscreteRangeDomain{ + paramDomainBase: paramDomainBase{label: expect.Label, path: expect.Path}, + min: expect.Min.Float64(), + max: expect.Max.Float64(), + step: expect.Step.Float64(), + } + } + var intRangeDomainVerifier = func(domain paramDomain, expect SelectorConfig) bool { + concrete := domain.(*intRangeDomain) + return *concrete == intRangeDomain{ + paramDomainBase: paramDomainBase{label: expect.Label, path: expect.Path}, + min: expect.Min.Int(), + max: expect.Max.Int(), + } + } + var intStepRangeDomainVerifier = func(domain paramDomain, expect SelectorConfig) bool { + concrete := domain.(*intStepRangeDomain) + return *concrete == intStepRangeDomain{ + paramDomainBase: paramDomainBase{label: expect.Label, path: expect.Path}, + min: expect.Min.Int(), + max: expect.Max.Int(), + step: expect.Step.Int(), + } + } + var stringDomainVerifier = func(domain paramDomain, expect SelectorConfig) bool { + concrete := domain.(*stringDomain) + expectBase := paramDomainBase{label: expect.Label, path: expect.Path} + if concrete.paramDomainBase != expectBase { + return false + } + if len(concrete.options) != len(expect.Values) { + return false + } + for i, item := range concrete.options { + if item != expect.Values[i] { + return false + } + } + return true + } + var boolDomainVerifier = func(domain paramDomain, expect SelectorConfig) bool { + concrete := domain.(*boolDomain) + return *concrete == boolDomain{ + paramDomainBase: paramDomainBase{label: expect.Label, path: expect.Path}, + } + } + + tests := []struct { + config SelectorConfig + verify func(domain paramDomain, expect SelectorConfig) bool + }{ + { + config: SelectorConfig{ + Type: selectorTypeRange, + Label: "range label", + Path: "range path", + Values: []string{"ignore", "ignore"}, + Min: fixedpoint.NewFromFloat(7.0), + Max: fixedpoint.NewFromFloat(80.0), + Step: fixedpoint.NewFromFloat(0.0), + }, + verify: floatRangeDomainVerifier, + }, { + config: SelectorConfig{ + Type: selectorTypeRangeFloat, + Label: "rangeFloat label", + Path: "rangeFloat path", + Values: []string{"ignore", "ignore"}, + Min: fixedpoint.NewFromFloat(6.0), + Max: fixedpoint.NewFromFloat(10.0), + Step: fixedpoint.NewFromFloat(0.0), + }, + verify: floatRangeDomainVerifier, + }, { + config: SelectorConfig{ + Type: selectorTypeRangeFloat, + Label: "rangeDiscreteFloat label", + Path: "rangeDiscreteFloat path", + Values: []string{"ignore", "ignore"}, + Min: fixedpoint.NewFromFloat(6.0), + Max: fixedpoint.NewFromFloat(10.0), + Step: fixedpoint.NewFromFloat(2.0), + }, + verify: floatDiscreteRangeDomainVerifier, + }, { + config: SelectorConfig{ + Type: selectorTypeRangeInt, + Label: "rangeInt label", + Path: "rangeInt path", + Values: []string{"ignore", "ignore"}, + Min: fixedpoint.NewFromInt(3), + Max: fixedpoint.NewFromInt(100), + Step: fixedpoint.NewFromInt(0), + }, + verify: intRangeDomainVerifier, + }, { + config: SelectorConfig{ + Type: selectorTypeRangeInt, + Label: "rangeInt label", + Path: "rangeInt path", + Values: []string{"ignore", "ignore"}, + Min: fixedpoint.NewFromInt(3), + Max: fixedpoint.NewFromInt(100), + Step: fixedpoint.NewFromInt(7), + }, + verify: intStepRangeDomainVerifier, + }, { + config: SelectorConfig{ + Type: selectorTypeIterate, + Label: "iterate label", + Path: "iterate path", + Values: nil, + Min: fixedpoint.NewFromInt(0), + Max: fixedpoint.NewFromInt(-8), + Step: fixedpoint.NewFromInt(-1), + }, + verify: stringDomainVerifier, + }, { + config: SelectorConfig{ + Type: selectorTypeString, + Label: "string label", + Path: "string path", + Values: []string{"option1", "option2", "option3"}, + Min: fixedpoint.NewFromInt(0), + Max: fixedpoint.NewFromInt(-8), + Step: fixedpoint.NewFromInt(-1), + }, + verify: stringDomainVerifier, + }, { + config: SelectorConfig{ + Type: selectorTypeBool, + Label: "bool label", + Path: "bool path", + Values: []string{"ignore"}, + Min: fixedpoint.NewFromInt(99), + Max: fixedpoint.NewFromInt(1064), + Step: fixedpoint.NewFromInt(-89), + }, + verify: boolDomainVerifier, + }, { + config: SelectorConfig{ + Type: "unknown type", + Label: "unknown label", + Path: "unknown path", + Values: []string{"unknown option"}, + Min: fixedpoint.NewFromInt(99), + Max: fixedpoint.NewFromFloat(1064), + Step: fixedpoint.NewFromInt(0), + }, + verify: nil, + }, + } + + selectors := make([]SelectorConfig, len(tests)) + expectLabelPaths := make(map[string]string) + verifiers := make([]func(domain paramDomain) bool, 0, len(tests)) + for i, testItem := range tests { + itemConfig, itemVerify := testItem.config, testItem.verify + selectors[i] = itemConfig + if itemVerify != nil { + expectLabelPaths[testItem.config.Label] = testItem.config.Path + verifiers = append(verifiers, func(domain paramDomain) bool { + return itemVerify(domain, itemConfig) + }) + } + } + optimizer := &HyperparameterOptimizer{Config: &Config{Matrix: selectors}} + exactLabelPaths, exactParamDomains := optimizer.buildParamDomains() + + if !reflect.DeepEqual(exactLabelPaths, expectLabelPaths) { + t.Errorf("expectLabelPaths=%v, exactLabelPaths=%v", expectLabelPaths, exactLabelPaths) + } + if len(exactParamDomains) != len(verifiers) { + t.Errorf("expect %d param domains, got %d", len(verifiers), len(exactParamDomains)) + } + for i, verifier := range verifiers { + pd := exactParamDomains[i] + if !verifier(pd) { + t.Errorf("unexpect param domain at #%d: %#v", i, pd) + } + } +} diff --git a/pkg/optimizer/hyperparam.go b/pkg/optimizer/hyperparam.go new file mode 100644 index 0000000..ae62228 --- /dev/null +++ b/pkg/optimizer/hyperparam.go @@ -0,0 +1,105 @@ +package optimizer + +import ( + "fmt" + "github.com/c-bata/goptuna" + jsonpatch "github.com/evanphx/json-patch/v5" +) + +type paramDomain interface { + buildPatch(trail *goptuna.Trial) (jsonpatch.Patch, error) +} + +type paramDomainBase struct { + label string + path string +} + +type intRangeDomain struct { + paramDomainBase + min int + max int +} + +func (d *intRangeDomain) buildPatch(trial *goptuna.Trial) (jsonpatch.Patch, error) { + val, err := trial.SuggestInt(d.label, d.min, d.max) + if err != nil { + return nil, err + } + jsonOp := []byte(reformatJson(fmt.Sprintf(`[{"op": "replace", "path": "%s", "value": %v }]`, d.path, val))) + return jsonpatch.DecodePatch(jsonOp) +} + +type intStepRangeDomain struct { + paramDomainBase + min int + max int + step int +} + +func (d *intStepRangeDomain) buildPatch(trial *goptuna.Trial) (jsonpatch.Patch, error) { + val, err := trial.SuggestStepInt(d.label, d.min, d.max, d.step) + if err != nil { + return nil, err + } + jsonOp := []byte(reformatJson(fmt.Sprintf(`[{"op": "replace", "path": "%s", "value": %v }]`, d.path, val))) + return jsonpatch.DecodePatch(jsonOp) +} + +type floatRangeDomain struct { + paramDomainBase + min float64 + max float64 +} + +func (d *floatRangeDomain) buildPatch(trial *goptuna.Trial) (jsonpatch.Patch, error) { + val, err := trial.SuggestFloat(d.label, d.min, d.max) + if err != nil { + return nil, err + } + jsonOp := []byte(reformatJson(fmt.Sprintf(`[{"op": "replace", "path": "%s", "value": %v }]`, d.path, val))) + return jsonpatch.DecodePatch(jsonOp) +} + +type floatDiscreteRangeDomain struct { + paramDomainBase + min float64 + max float64 + step float64 +} + +func (d *floatDiscreteRangeDomain) buildPatch(trial *goptuna.Trial) (jsonpatch.Patch, error) { + val, err := trial.SuggestDiscreteFloat(d.label, d.min, d.max, d.step) + if err != nil { + return nil, err + } + jsonOp := []byte(reformatJson(fmt.Sprintf(`[{"op": "replace", "path": "%s", "value": %v }]`, d.path, val))) + return jsonpatch.DecodePatch(jsonOp) +} + +type stringDomain struct { + paramDomainBase + options []string +} + +func (d *stringDomain) buildPatch(trial *goptuna.Trial) (jsonpatch.Patch, error) { + val, err := trial.SuggestCategorical(d.label, d.options) + if err != nil { + return nil, err + } + jsonOp := []byte(reformatJson(fmt.Sprintf(`[{"op": "replace", "path": "%s", "value": "%v" }]`, d.path, val))) + return jsonpatch.DecodePatch(jsonOp) +} + +type boolDomain struct { + paramDomainBase +} + +func (d *boolDomain) buildPatch(trial *goptuna.Trial) (jsonpatch.Patch, error) { + valStr, err := trial.SuggestCategorical(d.label, []string{"false", "true"}) + if err != nil { + return nil, err + } + jsonOp := []byte(reformatJson(fmt.Sprintf(`[{"op": "replace", "path": "%s", "value": %s }]`, d.path, valStr))) + return jsonpatch.DecodePatch(jsonOp) +} diff --git a/pkg/optimizer/local.go b/pkg/optimizer/local.go new file mode 100644 index 0000000..69aa4c8 --- /dev/null +++ b/pkg/optimizer/local.go @@ -0,0 +1,203 @@ +package optimizer + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "sync" + + "github.com/pkg/errors" + + "github.com/sirupsen/logrus" + "gopkg.in/yaml.v3" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/backtest" +) + +var log = logrus.WithField("component", "optimizer") + +type BacktestTask struct { + ConfigJson []byte + Params []interface{} + Labels []string + Report *backtest.SummaryReport + Error error +} + +type Executor interface { + Execute(configJson []byte) (*backtest.SummaryReport, error) + Run(ctx context.Context, taskC chan BacktestTask, bar *pb.ProgressBar) (chan BacktestTask, error) +} + +type AsyncHandle struct { + Error error + Report *backtest.SummaryReport + Done chan struct{} +} + +type LocalProcessExecutor struct { + Config *LocalExecutorConfig + Bin string + WorkDir string + ConfigDir string + OutputDir string +} + +func (e *LocalProcessExecutor) ExecuteAsync(configJson []byte) *AsyncHandle { + handle := &AsyncHandle{ + Done: make(chan struct{}), + } + + go func() { + defer close(handle.Done) + report, err := e.Execute(configJson) + handle.Error = err + handle.Report = report + }() + + return handle +} + +func (e *LocalProcessExecutor) readReport(reportPath string) (*backtest.SummaryReport, error) { + summaryReportFilepath := strings.TrimSpace(reportPath) + _, err := os.Stat(summaryReportFilepath) + if os.IsNotExist(err) { + return nil, err + } + + summaryReport, err := backtest.ReadSummaryReport(summaryReportFilepath) + if err != nil { + return nil, err + } + + return summaryReport, nil +} + +// Prepare prepares the environment for the following back tests +// this is a blocking operation +func (e *LocalProcessExecutor) Prepare(configJson []byte) error { + log.Debugln("syncing backtest data before starting backtests...") + tf, err := jsonToYamlConfig(e.ConfigDir, configJson) + if err != nil { + return err + } + + c := exec.Command(e.Bin, "backtest", "--sync", "--sync-only", "--config", tf.Name()) + output, err := c.Output() + if err != nil { + return errors.Wrapf(err, "failed to sync backtest data: %s", string(output)) + } + + return nil +} + +func (e *LocalProcessExecutor) Run(ctx context.Context, taskC chan BacktestTask, bar *pb.ProgressBar) (chan BacktestTask, error) { + var maxNumOfProcess = e.Config.MaxNumberOfProcesses + var resultsC = make(chan BacktestTask, maxNumOfProcess*2) + + wg := sync.WaitGroup{} + wg.Add(maxNumOfProcess) + + go func() { + wg.Wait() + close(resultsC) + }() + + for i := 0; i < maxNumOfProcess; i++ { + // fork workers + go func(id int, taskC chan BacktestTask) { + taskCnt := 0 + bar.Set("log", fmt.Sprintf("starting local worker #%d", id)) + bar.Write() + defer wg.Done() + for { + select { + case <-ctx.Done(): + return + + case task, ok := <-taskC: + if !ok { + return + } + + taskCnt++ + bar.Set("log", fmt.Sprintf("local worker #%d received param task: %v", id, task.Params)) + bar.Write() + + report, err := e.Execute(task.ConfigJson) + if err != nil { + if err2, ok := err.(*exec.ExitError); ok { + log.WithError(err).Errorf("execute error: %s", err2.Stderr) + } else { + log.WithError(err).Errorf("execute error") + } + } + + task.Error = err + task.Report = report + + resultsC <- task + } + } + }(i+1, taskC) + } + + return resultsC, nil +} + +// Execute runs the config json and returns the summary report. This is a blocking operation. +func (e *LocalProcessExecutor) Execute(configJson []byte) (*backtest.SummaryReport, error) { + tf, err := jsonToYamlConfig(e.ConfigDir, configJson) + if err != nil { + return nil, err + } + + c := exec.Command(e.Bin, "backtest", "--config", tf.Name(), "--output", e.OutputDir, "--subdir") + output, err := c.Output() + if err != nil { + log.WithError(err).WithField("command", []string{e.Bin, "backtest", "--config", tf.Name(), "--output", e.OutputDir, "--subdir"}).Errorf("failed to execute backtest") + return nil, err + } + + // the last line is the report path + scanner := bufio.NewScanner(bytes.NewBuffer(output)) + var reportFilePath string + for scanner.Scan() { + reportFilePath = scanner.Text() + } + return e.readReport(reportFilePath) +} + +// jsonToYamlConfig translate json format config into a YAML format config file +// The generated file is a temp file +func jsonToYamlConfig(dir string, configJson []byte) (*os.File, error) { + var o map[string]interface{} + if err := json.Unmarshal(configJson, &o); err != nil { + return nil, err + } + + yamlConfig, err := yaml.Marshal(o) + if err != nil { + return nil, err + } + + tf, err := os.CreateTemp(dir, "qbtrade-*.yaml") + if err != nil { + return nil, err + } + + if _, err = tf.Write(yamlConfig); err != nil { + return nil, err + } + + if err := tf.Close(); err != nil { + return nil, err + } + + return tf, nil +} diff --git a/pkg/optimizer/local_test.go b/pkg/optimizer/local_test.go new file mode 100644 index 0000000..1c0298f --- /dev/null +++ b/pkg/optimizer/local_test.go @@ -0,0 +1,21 @@ +package optimizer + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_jsonToYamlConfig(t *testing.T) { + err := os.Mkdir(".tmpconfig", 0755) + assert.NoError(t, err) + + tf, err := jsonToYamlConfig(".tmpconfig", []byte(`{ + }`)) + assert.NoError(t, err) + assert.NotNil(t, tf) + assert.NotEmpty(t, tf.Name()) + + _ = os.RemoveAll(".tmpconfig") +} diff --git a/pkg/optimizer/operator.go b/pkg/optimizer/operator.go new file mode 100644 index 0000000..c4ac89c --- /dev/null +++ b/pkg/optimizer/operator.go @@ -0,0 +1,3 @@ +package optimizer + +type OpFunc func(configJson []byte, next func(configJson []byte) error) error diff --git a/pkg/pb/README.md b/pkg/pb/README.md new file mode 100644 index 0000000..4dca741 --- /dev/null +++ b/pkg/pb/README.md @@ -0,0 +1,9 @@ +# Protocol Buffers + +## Generate code + +```sh +go install google.golang.org/protobuf/cmd/protoc-gen-go@latest +cd /pkg/protobuf +protoc -I=. --go_out=. qbtrade.proto +``` diff --git a/pkg/pb/bbgo.pb.go b/pkg/pb/bbgo.pb.go new file mode 100644 index 0000000..fd3cfa7 --- /dev/null +++ b/pkg/pb/bbgo.pb.go @@ -0,0 +1,3208 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.26.0 +// protoc v3.19.3 +// source: pkg/pb/qbtrade.proto + +package pb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Event int32 + +const ( + Event_UNKNOWN Event = 0 + Event_SUBSCRIBED Event = 1 + Event_UNSUBSCRIBED Event = 2 + Event_SNAPSHOT Event = 3 + Event_UPDATE Event = 4 + Event_AUTHENTICATED Event = 5 + Event_ERROR Event = 99 +) + +// Enum value maps for Event. +var ( + Event_name = map[int32]string{ + 0: "UNKNOWN", + 1: "SUBSCRIBED", + 2: "UNSUBSCRIBED", + 3: "SNAPSHOT", + 4: "UPDATE", + 5: "AUTHENTICATED", + 99: "ERROR", + } + Event_value = map[string]int32{ + "UNKNOWN": 0, + "SUBSCRIBED": 1, + "UNSUBSCRIBED": 2, + "SNAPSHOT": 3, + "UPDATE": 4, + "AUTHENTICATED": 5, + "ERROR": 99, + } +) + +func (x Event) Enum() *Event { + p := new(Event) + *p = x + return p +} + +func (x Event) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Event) Descriptor() protoreflect.EnumDescriptor { + return file_pkg_pb_qbtrade_proto_enumTypes[0].Descriptor() +} + +func (Event) Type() protoreflect.EnumType { + return &file_pkg_pb_qbtrade_proto_enumTypes[0] +} + +func (x Event) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Event.Descriptor instead. +func (Event) EnumDescriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{0} +} + +type Channel int32 + +const ( + Channel_BOOK Channel = 0 + Channel_TRADE Channel = 1 + Channel_TICKER Channel = 2 + Channel_KLINE Channel = 3 + Channel_BALANCE Channel = 4 + Channel_ORDER Channel = 5 +) + +// Enum value maps for Channel. +var ( + Channel_name = map[int32]string{ + 0: "BOOK", + 1: "TRADE", + 2: "TICKER", + 3: "KLINE", + 4: "BALANCE", + 5: "ORDER", + } + Channel_value = map[string]int32{ + "BOOK": 0, + "TRADE": 1, + "TICKER": 2, + "KLINE": 3, + "BALANCE": 4, + "ORDER": 5, + } +) + +func (x Channel) Enum() *Channel { + p := new(Channel) + *p = x + return p +} + +func (x Channel) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Channel) Descriptor() protoreflect.EnumDescriptor { + return file_pkg_pb_qbtrade_proto_enumTypes[1].Descriptor() +} + +func (Channel) Type() protoreflect.EnumType { + return &file_pkg_pb_qbtrade_proto_enumTypes[1] +} + +func (x Channel) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Channel.Descriptor instead. +func (Channel) EnumDescriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{1} +} + +type Side int32 + +const ( + Side_BUY Side = 0 + Side_SELL Side = 1 +) + +// Enum value maps for Side. +var ( + Side_name = map[int32]string{ + 0: "BUY", + 1: "SELL", + } + Side_value = map[string]int32{ + "BUY": 0, + "SELL": 1, + } +) + +func (x Side) Enum() *Side { + p := new(Side) + *p = x + return p +} + +func (x Side) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Side) Descriptor() protoreflect.EnumDescriptor { + return file_pkg_pb_qbtrade_proto_enumTypes[2].Descriptor() +} + +func (Side) Type() protoreflect.EnumType { + return &file_pkg_pb_qbtrade_proto_enumTypes[2] +} + +func (x Side) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Side.Descriptor instead. +func (Side) EnumDescriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{2} +} + +type OrderType int32 + +const ( + OrderType_MARKET OrderType = 0 + OrderType_LIMIT OrderType = 1 + OrderType_STOP_MARKET OrderType = 2 + OrderType_STOP_LIMIT OrderType = 3 + OrderType_POST_ONLY OrderType = 4 + OrderType_IOC_LIMIT OrderType = 5 +) + +// Enum value maps for OrderType. +var ( + OrderType_name = map[int32]string{ + 0: "MARKET", + 1: "LIMIT", + 2: "STOP_MARKET", + 3: "STOP_LIMIT", + 4: "POST_ONLY", + 5: "IOC_LIMIT", + } + OrderType_value = map[string]int32{ + "MARKET": 0, + "LIMIT": 1, + "STOP_MARKET": 2, + "STOP_LIMIT": 3, + "POST_ONLY": 4, + "IOC_LIMIT": 5, + } +) + +func (x OrderType) Enum() *OrderType { + p := new(OrderType) + *p = x + return p +} + +func (x OrderType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (OrderType) Descriptor() protoreflect.EnumDescriptor { + return file_pkg_pb_qbtrade_proto_enumTypes[3].Descriptor() +} + +func (OrderType) Type() protoreflect.EnumType { + return &file_pkg_pb_qbtrade_proto_enumTypes[3] +} + +func (x OrderType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use OrderType.Descriptor instead. +func (OrderType) EnumDescriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{3} +} + +type Empty struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *Empty) Reset() { + *x = Empty{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Empty) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Empty) ProtoMessage() {} + +func (x *Empty) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Empty.ProtoReflect.Descriptor instead. +func (*Empty) Descriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{0} +} + +type Error struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ErrorCode int64 `protobuf:"varint,1,opt,name=error_code,json=errorCode,proto3" json:"error_code,omitempty"` + ErrorMessage string `protobuf:"bytes,2,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"` +} + +func (x *Error) Reset() { + *x = Error{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Error) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Error) ProtoMessage() {} + +func (x *Error) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Error.ProtoReflect.Descriptor instead. +func (*Error) Descriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{1} +} + +func (x *Error) GetErrorCode() int64 { + if x != nil { + return x.ErrorCode + } + return 0 +} + +func (x *Error) GetErrorMessage() string { + if x != nil { + return x.ErrorMessage + } + return "" +} + +type UserDataRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Session string `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"` +} + +func (x *UserDataRequest) Reset() { + *x = UserDataRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *UserDataRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserDataRequest) ProtoMessage() {} + +func (x *UserDataRequest) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserDataRequest.ProtoReflect.Descriptor instead. +func (*UserDataRequest) Descriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{2} +} + +func (x *UserDataRequest) GetSession() string { + if x != nil { + return x.Session + } + return "" +} + +type UserData struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Session string `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"` + Exchange string `protobuf:"bytes,2,opt,name=exchange,proto3" json:"exchange,omitempty"` + Channel Channel `protobuf:"varint,3,opt,name=channel,proto3,enum=qbtrade.Channel" json:"channel,omitempty"` // trade, order, balance + Event Event `protobuf:"varint,4,opt,name=event,proto3,enum=qbtrade.Event" json:"event,omitempty"` // snapshot, update ... + Balances []*Balance `protobuf:"bytes,5,rep,name=balances,proto3" json:"balances,omitempty"` + Trades []*Trade `protobuf:"bytes,6,rep,name=trades,proto3" json:"trades,omitempty"` + Orders []*Order `protobuf:"bytes,7,rep,name=orders,proto3" json:"orders,omitempty"` +} + +func (x *UserData) Reset() { + *x = UserData{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *UserData) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserData) ProtoMessage() {} + +func (x *UserData) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserData.ProtoReflect.Descriptor instead. +func (*UserData) Descriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{3} +} + +func (x *UserData) GetSession() string { + if x != nil { + return x.Session + } + return "" +} + +func (x *UserData) GetExchange() string { + if x != nil { + return x.Exchange + } + return "" +} + +func (x *UserData) GetChannel() Channel { + if x != nil { + return x.Channel + } + return Channel_BOOK +} + +func (x *UserData) GetEvent() Event { + if x != nil { + return x.Event + } + return Event_UNKNOWN +} + +func (x *UserData) GetBalances() []*Balance { + if x != nil { + return x.Balances + } + return nil +} + +func (x *UserData) GetTrades() []*Trade { + if x != nil { + return x.Trades + } + return nil +} + +func (x *UserData) GetOrders() []*Order { + if x != nil { + return x.Orders + } + return nil +} + +type SubscribeRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Subscriptions []*Subscription `protobuf:"bytes,1,rep,name=subscriptions,proto3" json:"subscriptions,omitempty"` +} + +func (x *SubscribeRequest) Reset() { + *x = SubscribeRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SubscribeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubscribeRequest) ProtoMessage() {} + +func (x *SubscribeRequest) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubscribeRequest.ProtoReflect.Descriptor instead. +func (*SubscribeRequest) Descriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{4} +} + +func (x *SubscribeRequest) GetSubscriptions() []*Subscription { + if x != nil { + return x.Subscriptions + } + return nil +} + +type Subscription struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Exchange string `protobuf:"bytes,1,opt,name=exchange,proto3" json:"exchange,omitempty"` + Channel Channel `protobuf:"varint,2,opt,name=channel,proto3,enum=qbtrade.Channel" json:"channel,omitempty"` // book, trade, ticker + Symbol string `protobuf:"bytes,3,opt,name=symbol,proto3" json:"symbol,omitempty"` + Depth string `protobuf:"bytes,4,opt,name=depth,proto3" json:"depth,omitempty"` // depth is for book, valid values are full, medium, 1, 5 and 20 + Interval string `protobuf:"bytes,5,opt,name=interval,proto3" json:"interval,omitempty"` // interval is for kline channel +} + +func (x *Subscription) Reset() { + *x = Subscription{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Subscription) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Subscription) ProtoMessage() {} + +func (x *Subscription) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Subscription.ProtoReflect.Descriptor instead. +func (*Subscription) Descriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{5} +} + +func (x *Subscription) GetExchange() string { + if x != nil { + return x.Exchange + } + return "" +} + +func (x *Subscription) GetChannel() Channel { + if x != nil { + return x.Channel + } + return Channel_BOOK +} + +func (x *Subscription) GetSymbol() string { + if x != nil { + return x.Symbol + } + return "" +} + +func (x *Subscription) GetDepth() string { + if x != nil { + return x.Depth + } + return "" +} + +func (x *Subscription) GetInterval() string { + if x != nil { + return x.Interval + } + return "" +} + +type MarketData struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Session string `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"` + Exchange string `protobuf:"bytes,2,opt,name=exchange,proto3" json:"exchange,omitempty"` + Symbol string `protobuf:"bytes,3,opt,name=symbol,proto3" json:"symbol,omitempty"` + Channel Channel `protobuf:"varint,4,opt,name=channel,proto3,enum=qbtrade.Channel" json:"channel,omitempty"` // book, trade, ticker, user + Event Event `protobuf:"varint,5,opt,name=event,proto3,enum=qbtrade.Event" json:"event,omitempty"` // snapshot or update + Depth *Depth `protobuf:"bytes,6,opt,name=depth,proto3" json:"depth,omitempty"` // depth: used by book + Kline *KLine `protobuf:"bytes,7,opt,name=kline,proto3" json:"kline,omitempty"` + Ticker *Ticker `protobuf:"bytes,9,opt,name=ticker,proto3" json:"ticker,omitempty"` // market ticker + Trades []*Trade `protobuf:"bytes,8,rep,name=trades,proto3" json:"trades,omitempty"` // market trades + SubscribedAt int64 `protobuf:"varint,12,opt,name=subscribed_at,json=subscribedAt,proto3" json:"subscribed_at,omitempty"` + Error *Error `protobuf:"bytes,13,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *MarketData) Reset() { + *x = MarketData{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *MarketData) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MarketData) ProtoMessage() {} + +func (x *MarketData) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MarketData.ProtoReflect.Descriptor instead. +func (*MarketData) Descriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{6} +} + +func (x *MarketData) GetSession() string { + if x != nil { + return x.Session + } + return "" +} + +func (x *MarketData) GetExchange() string { + if x != nil { + return x.Exchange + } + return "" +} + +func (x *MarketData) GetSymbol() string { + if x != nil { + return x.Symbol + } + return "" +} + +func (x *MarketData) GetChannel() Channel { + if x != nil { + return x.Channel + } + return Channel_BOOK +} + +func (x *MarketData) GetEvent() Event { + if x != nil { + return x.Event + } + return Event_UNKNOWN +} + +func (x *MarketData) GetDepth() *Depth { + if x != nil { + return x.Depth + } + return nil +} + +func (x *MarketData) GetKline() *KLine { + if x != nil { + return x.Kline + } + return nil +} + +func (x *MarketData) GetTicker() *Ticker { + if x != nil { + return x.Ticker + } + return nil +} + +func (x *MarketData) GetTrades() []*Trade { + if x != nil { + return x.Trades + } + return nil +} + +func (x *MarketData) GetSubscribedAt() int64 { + if x != nil { + return x.SubscribedAt + } + return 0 +} + +func (x *MarketData) GetError() *Error { + if x != nil { + return x.Error + } + return nil +} + +type Depth struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Exchange string `protobuf:"bytes,1,opt,name=exchange,proto3" json:"exchange,omitempty"` + Symbol string `protobuf:"bytes,2,opt,name=symbol,proto3" json:"symbol,omitempty"` + Asks []*PriceVolume `protobuf:"bytes,3,rep,name=asks,proto3" json:"asks,omitempty"` + Bids []*PriceVolume `protobuf:"bytes,4,rep,name=bids,proto3" json:"bids,omitempty"` +} + +func (x *Depth) Reset() { + *x = Depth{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Depth) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Depth) ProtoMessage() {} + +func (x *Depth) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Depth.ProtoReflect.Descriptor instead. +func (*Depth) Descriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{7} +} + +func (x *Depth) GetExchange() string { + if x != nil { + return x.Exchange + } + return "" +} + +func (x *Depth) GetSymbol() string { + if x != nil { + return x.Symbol + } + return "" +} + +func (x *Depth) GetAsks() []*PriceVolume { + if x != nil { + return x.Asks + } + return nil +} + +func (x *Depth) GetBids() []*PriceVolume { + if x != nil { + return x.Bids + } + return nil +} + +type PriceVolume struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Price string `protobuf:"bytes,1,opt,name=price,proto3" json:"price,omitempty"` + Volume string `protobuf:"bytes,2,opt,name=volume,proto3" json:"volume,omitempty"` +} + +func (x *PriceVolume) Reset() { + *x = PriceVolume{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *PriceVolume) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PriceVolume) ProtoMessage() {} + +func (x *PriceVolume) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PriceVolume.ProtoReflect.Descriptor instead. +func (*PriceVolume) Descriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{8} +} + +func (x *PriceVolume) GetPrice() string { + if x != nil { + return x.Price + } + return "" +} + +func (x *PriceVolume) GetVolume() string { + if x != nil { + return x.Volume + } + return "" +} + +// https://maicoin.github.io/max-websocket-docs/#/private_channels?id=trade-response +// https://maicoin.github.io/max-websocket-docs/#/public_trade?id=success-response +type Trade struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Session string `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"` + Exchange string `protobuf:"bytes,2,opt,name=exchange,proto3" json:"exchange,omitempty"` + Symbol string `protobuf:"bytes,3,opt,name=symbol,proto3" json:"symbol,omitempty"` + Id string `protobuf:"bytes,4,opt,name=id,proto3" json:"id,omitempty"` + Price string `protobuf:"bytes,5,opt,name=price,proto3" json:"price,omitempty"` + Quantity string `protobuf:"bytes,6,opt,name=quantity,proto3" json:"quantity,omitempty"` + CreatedAt int64 `protobuf:"varint,7,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + Side Side `protobuf:"varint,8,opt,name=side,proto3,enum=qbtrade.Side" json:"side,omitempty"` + FeeCurrency string `protobuf:"bytes,9,opt,name=fee_currency,json=feeCurrency,proto3" json:"fee_currency,omitempty"` + Fee string `protobuf:"bytes,10,opt,name=fee,proto3" json:"fee,omitempty"` + Maker bool `protobuf:"varint,11,opt,name=maker,proto3" json:"maker,omitempty"` +} + +func (x *Trade) Reset() { + *x = Trade{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Trade) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Trade) ProtoMessage() {} + +func (x *Trade) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Trade.ProtoReflect.Descriptor instead. +func (*Trade) Descriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{9} +} + +func (x *Trade) GetSession() string { + if x != nil { + return x.Session + } + return "" +} + +func (x *Trade) GetExchange() string { + if x != nil { + return x.Exchange + } + return "" +} + +func (x *Trade) GetSymbol() string { + if x != nil { + return x.Symbol + } + return "" +} + +func (x *Trade) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Trade) GetPrice() string { + if x != nil { + return x.Price + } + return "" +} + +func (x *Trade) GetQuantity() string { + if x != nil { + return x.Quantity + } + return "" +} + +func (x *Trade) GetCreatedAt() int64 { + if x != nil { + return x.CreatedAt + } + return 0 +} + +func (x *Trade) GetSide() Side { + if x != nil { + return x.Side + } + return Side_BUY +} + +func (x *Trade) GetFeeCurrency() string { + if x != nil { + return x.FeeCurrency + } + return "" +} + +func (x *Trade) GetFee() string { + if x != nil { + return x.Fee + } + return "" +} + +func (x *Trade) GetMaker() bool { + if x != nil { + return x.Maker + } + return false +} + +// https://maicoin.github.io/max-websocket-docs/#/public_ticker?id=success-response +type Ticker struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Exchange string `protobuf:"bytes,1,opt,name=exchange,proto3" json:"exchange,omitempty"` + Symbol string `protobuf:"bytes,2,opt,name=symbol,proto3" json:"symbol,omitempty"` + Open float64 `protobuf:"fixed64,3,opt,name=open,proto3" json:"open,omitempty"` + High float64 `protobuf:"fixed64,4,opt,name=high,proto3" json:"high,omitempty"` + Low float64 `protobuf:"fixed64,5,opt,name=low,proto3" json:"low,omitempty"` + Close float64 `protobuf:"fixed64,6,opt,name=close,proto3" json:"close,omitempty"` + Volume float64 `protobuf:"fixed64,7,opt,name=volume,proto3" json:"volume,omitempty"` +} + +func (x *Ticker) Reset() { + *x = Ticker{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Ticker) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Ticker) ProtoMessage() {} + +func (x *Ticker) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[10] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Ticker.ProtoReflect.Descriptor instead. +func (*Ticker) Descriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{10} +} + +func (x *Ticker) GetExchange() string { + if x != nil { + return x.Exchange + } + return "" +} + +func (x *Ticker) GetSymbol() string { + if x != nil { + return x.Symbol + } + return "" +} + +func (x *Ticker) GetOpen() float64 { + if x != nil { + return x.Open + } + return 0 +} + +func (x *Ticker) GetHigh() float64 { + if x != nil { + return x.High + } + return 0 +} + +func (x *Ticker) GetLow() float64 { + if x != nil { + return x.Low + } + return 0 +} + +func (x *Ticker) GetClose() float64 { + if x != nil { + return x.Close + } + return 0 +} + +func (x *Ticker) GetVolume() float64 { + if x != nil { + return x.Volume + } + return 0 +} + +// https://maicoin.github.io/max-websocket-docs/#/private_channels?id=snapshot +type Order struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Exchange string `protobuf:"bytes,1,opt,name=exchange,proto3" json:"exchange,omitempty"` + Symbol string `protobuf:"bytes,2,opt,name=symbol,proto3" json:"symbol,omitempty"` + Id string `protobuf:"bytes,3,opt,name=id,proto3" json:"id,omitempty"` + Side Side `protobuf:"varint,4,opt,name=side,proto3,enum=qbtrade.Side" json:"side,omitempty"` + OrderType OrderType `protobuf:"varint,5,opt,name=order_type,json=orderType,proto3,enum=qbtrade.OrderType" json:"order_type,omitempty"` + Price string `protobuf:"bytes,6,opt,name=price,proto3" json:"price,omitempty"` + StopPrice string `protobuf:"bytes,7,opt,name=stop_price,json=stopPrice,proto3" json:"stop_price,omitempty"` + Status string `protobuf:"bytes,9,opt,name=status,proto3" json:"status,omitempty"` + Quantity string `protobuf:"bytes,11,opt,name=quantity,proto3" json:"quantity,omitempty"` + ExecutedQuantity string `protobuf:"bytes,12,opt,name=executed_quantity,json=executedQuantity,proto3" json:"executed_quantity,omitempty"` + ClientOrderId string `protobuf:"bytes,14,opt,name=client_order_id,json=clientOrderId,proto3" json:"client_order_id,omitempty"` + GroupId int64 `protobuf:"varint,15,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"` + CreatedAt int64 `protobuf:"varint,10,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` +} + +func (x *Order) Reset() { + *x = Order{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Order) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Order) ProtoMessage() {} + +func (x *Order) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[11] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Order.ProtoReflect.Descriptor instead. +func (*Order) Descriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{11} +} + +func (x *Order) GetExchange() string { + if x != nil { + return x.Exchange + } + return "" +} + +func (x *Order) GetSymbol() string { + if x != nil { + return x.Symbol + } + return "" +} + +func (x *Order) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Order) GetSide() Side { + if x != nil { + return x.Side + } + return Side_BUY +} + +func (x *Order) GetOrderType() OrderType { + if x != nil { + return x.OrderType + } + return OrderType_MARKET +} + +func (x *Order) GetPrice() string { + if x != nil { + return x.Price + } + return "" +} + +func (x *Order) GetStopPrice() string { + if x != nil { + return x.StopPrice + } + return "" +} + +func (x *Order) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *Order) GetQuantity() string { + if x != nil { + return x.Quantity + } + return "" +} + +func (x *Order) GetExecutedQuantity() string { + if x != nil { + return x.ExecutedQuantity + } + return "" +} + +func (x *Order) GetClientOrderId() string { + if x != nil { + return x.ClientOrderId + } + return "" +} + +func (x *Order) GetGroupId() int64 { + if x != nil { + return x.GroupId + } + return 0 +} + +func (x *Order) GetCreatedAt() int64 { + if x != nil { + return x.CreatedAt + } + return 0 +} + +type SubmitOrder struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Session string `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"` + Exchange string `protobuf:"bytes,2,opt,name=exchange,proto3" json:"exchange,omitempty"` + Symbol string `protobuf:"bytes,3,opt,name=symbol,proto3" json:"symbol,omitempty"` + Side Side `protobuf:"varint,4,opt,name=side,proto3,enum=qbtrade.Side" json:"side,omitempty"` + Price string `protobuf:"bytes,6,opt,name=price,proto3" json:"price,omitempty"` + Quantity string `protobuf:"bytes,5,opt,name=quantity,proto3" json:"quantity,omitempty"` + StopPrice string `protobuf:"bytes,7,opt,name=stop_price,json=stopPrice,proto3" json:"stop_price,omitempty"` + OrderType OrderType `protobuf:"varint,8,opt,name=order_type,json=orderType,proto3,enum=qbtrade.OrderType" json:"order_type,omitempty"` + ClientOrderId string `protobuf:"bytes,9,opt,name=client_order_id,json=clientOrderId,proto3" json:"client_order_id,omitempty"` + GroupId int64 `protobuf:"varint,10,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"` +} + +func (x *SubmitOrder) Reset() { + *x = SubmitOrder{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SubmitOrder) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubmitOrder) ProtoMessage() {} + +func (x *SubmitOrder) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[12] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubmitOrder.ProtoReflect.Descriptor instead. +func (*SubmitOrder) Descriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{12} +} + +func (x *SubmitOrder) GetSession() string { + if x != nil { + return x.Session + } + return "" +} + +func (x *SubmitOrder) GetExchange() string { + if x != nil { + return x.Exchange + } + return "" +} + +func (x *SubmitOrder) GetSymbol() string { + if x != nil { + return x.Symbol + } + return "" +} + +func (x *SubmitOrder) GetSide() Side { + if x != nil { + return x.Side + } + return Side_BUY +} + +func (x *SubmitOrder) GetPrice() string { + if x != nil { + return x.Price + } + return "" +} + +func (x *SubmitOrder) GetQuantity() string { + if x != nil { + return x.Quantity + } + return "" +} + +func (x *SubmitOrder) GetStopPrice() string { + if x != nil { + return x.StopPrice + } + return "" +} + +func (x *SubmitOrder) GetOrderType() OrderType { + if x != nil { + return x.OrderType + } + return OrderType_MARKET +} + +func (x *SubmitOrder) GetClientOrderId() string { + if x != nil { + return x.ClientOrderId + } + return "" +} + +func (x *SubmitOrder) GetGroupId() int64 { + if x != nil { + return x.GroupId + } + return 0 +} + +// https://maicoin.github.io/max-websocket-docs/#/private_channels?id=account-response +type Balance struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Session string `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"` + Exchange string `protobuf:"bytes,2,opt,name=exchange,proto3" json:"exchange,omitempty"` + Currency string `protobuf:"bytes,3,opt,name=currency,proto3" json:"currency,omitempty"` + Available string `protobuf:"bytes,4,opt,name=available,proto3" json:"available,omitempty"` + Locked string `protobuf:"bytes,5,opt,name=locked,proto3" json:"locked,omitempty"` + Borrowed string `protobuf:"bytes,6,opt,name=borrowed,proto3" json:"borrowed,omitempty"` +} + +func (x *Balance) Reset() { + *x = Balance{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Balance) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Balance) ProtoMessage() {} + +func (x *Balance) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[13] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Balance.ProtoReflect.Descriptor instead. +func (*Balance) Descriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{13} +} + +func (x *Balance) GetSession() string { + if x != nil { + return x.Session + } + return "" +} + +func (x *Balance) GetExchange() string { + if x != nil { + return x.Exchange + } + return "" +} + +func (x *Balance) GetCurrency() string { + if x != nil { + return x.Currency + } + return "" +} + +func (x *Balance) GetAvailable() string { + if x != nil { + return x.Available + } + return "" +} + +func (x *Balance) GetLocked() string { + if x != nil { + return x.Locked + } + return "" +} + +func (x *Balance) GetBorrowed() string { + if x != nil { + return x.Borrowed + } + return "" +} + +type SubmitOrderRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Session string `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"` + SubmitOrders []*SubmitOrder `protobuf:"bytes,2,rep,name=submit_orders,json=submitOrders,proto3" json:"submit_orders,omitempty"` +} + +func (x *SubmitOrderRequest) Reset() { + *x = SubmitOrderRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SubmitOrderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubmitOrderRequest) ProtoMessage() {} + +func (x *SubmitOrderRequest) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[14] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubmitOrderRequest.ProtoReflect.Descriptor instead. +func (*SubmitOrderRequest) Descriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{14} +} + +func (x *SubmitOrderRequest) GetSession() string { + if x != nil { + return x.Session + } + return "" +} + +func (x *SubmitOrderRequest) GetSubmitOrders() []*SubmitOrder { + if x != nil { + return x.SubmitOrders + } + return nil +} + +type SubmitOrderResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Session string `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"` + Orders []*Order `protobuf:"bytes,2,rep,name=orders,proto3" json:"orders,omitempty"` + Error *Error `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *SubmitOrderResponse) Reset() { + *x = SubmitOrderResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SubmitOrderResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubmitOrderResponse) ProtoMessage() {} + +func (x *SubmitOrderResponse) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[15] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubmitOrderResponse.ProtoReflect.Descriptor instead. +func (*SubmitOrderResponse) Descriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{15} +} + +func (x *SubmitOrderResponse) GetSession() string { + if x != nil { + return x.Session + } + return "" +} + +func (x *SubmitOrderResponse) GetOrders() []*Order { + if x != nil { + return x.Orders + } + return nil +} + +func (x *SubmitOrderResponse) GetError() *Error { + if x != nil { + return x.Error + } + return nil +} + +type CancelOrderRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Session string `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"` + OrderId string `protobuf:"bytes,2,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"` + ClientOrderId string `protobuf:"bytes,3,opt,name=client_order_id,json=clientOrderId,proto3" json:"client_order_id,omitempty"` +} + +func (x *CancelOrderRequest) Reset() { + *x = CancelOrderRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CancelOrderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CancelOrderRequest) ProtoMessage() {} + +func (x *CancelOrderRequest) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[16] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CancelOrderRequest.ProtoReflect.Descriptor instead. +func (*CancelOrderRequest) Descriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{16} +} + +func (x *CancelOrderRequest) GetSession() string { + if x != nil { + return x.Session + } + return "" +} + +func (x *CancelOrderRequest) GetOrderId() string { + if x != nil { + return x.OrderId + } + return "" +} + +func (x *CancelOrderRequest) GetClientOrderId() string { + if x != nil { + return x.ClientOrderId + } + return "" +} + +type CancelOrderResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Order *Order `protobuf:"bytes,1,opt,name=order,proto3" json:"order,omitempty"` + Error *Error `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *CancelOrderResponse) Reset() { + *x = CancelOrderResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CancelOrderResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CancelOrderResponse) ProtoMessage() {} + +func (x *CancelOrderResponse) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[17] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CancelOrderResponse.ProtoReflect.Descriptor instead. +func (*CancelOrderResponse) Descriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{17} +} + +func (x *CancelOrderResponse) GetOrder() *Order { + if x != nil { + return x.Order + } + return nil +} + +func (x *CancelOrderResponse) GetError() *Error { + if x != nil { + return x.Error + } + return nil +} + +type QueryOrderRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Session string `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"` + Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` + ClientOrderId string `protobuf:"bytes,3,opt,name=client_order_id,json=clientOrderId,proto3" json:"client_order_id,omitempty"` +} + +func (x *QueryOrderRequest) Reset() { + *x = QueryOrderRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *QueryOrderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QueryOrderRequest) ProtoMessage() {} + +func (x *QueryOrderRequest) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[18] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QueryOrderRequest.ProtoReflect.Descriptor instead. +func (*QueryOrderRequest) Descriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{18} +} + +func (x *QueryOrderRequest) GetSession() string { + if x != nil { + return x.Session + } + return "" +} + +func (x *QueryOrderRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *QueryOrderRequest) GetClientOrderId() string { + if x != nil { + return x.ClientOrderId + } + return "" +} + +type QueryOrderResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Order *Order `protobuf:"bytes,1,opt,name=order,proto3" json:"order,omitempty"` + Error *Error `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *QueryOrderResponse) Reset() { + *x = QueryOrderResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *QueryOrderResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QueryOrderResponse) ProtoMessage() {} + +func (x *QueryOrderResponse) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[19] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QueryOrderResponse.ProtoReflect.Descriptor instead. +func (*QueryOrderResponse) Descriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{19} +} + +func (x *QueryOrderResponse) GetOrder() *Order { + if x != nil { + return x.Order + } + return nil +} + +func (x *QueryOrderResponse) GetError() *Error { + if x != nil { + return x.Error + } + return nil +} + +type QueryOrdersRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Session string `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"` + Symbol string `protobuf:"bytes,2,opt,name=symbol,proto3" json:"symbol,omitempty"` + State []string `protobuf:"bytes,3,rep,name=state,proto3" json:"state,omitempty"` + OrderBy string `protobuf:"bytes,4,opt,name=order_by,json=orderBy,proto3" json:"order_by,omitempty"` + GroupId int64 `protobuf:"varint,5,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"` + Pagination bool `protobuf:"varint,6,opt,name=pagination,proto3" json:"pagination,omitempty"` + Page int64 `protobuf:"varint,7,opt,name=page,proto3" json:"page,omitempty"` + Limit int64 `protobuf:"varint,8,opt,name=limit,proto3" json:"limit,omitempty"` + Offset int64 `protobuf:"varint,9,opt,name=offset,proto3" json:"offset,omitempty"` +} + +func (x *QueryOrdersRequest) Reset() { + *x = QueryOrdersRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *QueryOrdersRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QueryOrdersRequest) ProtoMessage() {} + +func (x *QueryOrdersRequest) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[20] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QueryOrdersRequest.ProtoReflect.Descriptor instead. +func (*QueryOrdersRequest) Descriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{20} +} + +func (x *QueryOrdersRequest) GetSession() string { + if x != nil { + return x.Session + } + return "" +} + +func (x *QueryOrdersRequest) GetSymbol() string { + if x != nil { + return x.Symbol + } + return "" +} + +func (x *QueryOrdersRequest) GetState() []string { + if x != nil { + return x.State + } + return nil +} + +func (x *QueryOrdersRequest) GetOrderBy() string { + if x != nil { + return x.OrderBy + } + return "" +} + +func (x *QueryOrdersRequest) GetGroupId() int64 { + if x != nil { + return x.GroupId + } + return 0 +} + +func (x *QueryOrdersRequest) GetPagination() bool { + if x != nil { + return x.Pagination + } + return false +} + +func (x *QueryOrdersRequest) GetPage() int64 { + if x != nil { + return x.Page + } + return 0 +} + +func (x *QueryOrdersRequest) GetLimit() int64 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *QueryOrdersRequest) GetOffset() int64 { + if x != nil { + return x.Offset + } + return 0 +} + +type QueryOrdersResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Orders []*Order `protobuf:"bytes,1,rep,name=orders,proto3" json:"orders,omitempty"` + Error *Error `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *QueryOrdersResponse) Reset() { + *x = QueryOrdersResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *QueryOrdersResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QueryOrdersResponse) ProtoMessage() {} + +func (x *QueryOrdersResponse) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[21] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QueryOrdersResponse.ProtoReflect.Descriptor instead. +func (*QueryOrdersResponse) Descriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{21} +} + +func (x *QueryOrdersResponse) GetOrders() []*Order { + if x != nil { + return x.Orders + } + return nil +} + +func (x *QueryOrdersResponse) GetError() *Error { + if x != nil { + return x.Error + } + return nil +} + +type QueryTradesRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Exchange string `protobuf:"bytes,1,opt,name=exchange,proto3" json:"exchange,omitempty"` + Symbol string `protobuf:"bytes,2,opt,name=symbol,proto3" json:"symbol,omitempty"` + Timestamp int64 `protobuf:"varint,3,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + From int64 `protobuf:"varint,4,opt,name=from,proto3" json:"from,omitempty"` + To int64 `protobuf:"varint,5,opt,name=to,proto3" json:"to,omitempty"` + OrderBy string `protobuf:"bytes,6,opt,name=order_by,json=orderBy,proto3" json:"order_by,omitempty"` + Pagination bool `protobuf:"varint,7,opt,name=pagination,proto3" json:"pagination,omitempty"` + Page int64 `protobuf:"varint,8,opt,name=page,proto3" json:"page,omitempty"` + Limit int64 `protobuf:"varint,9,opt,name=limit,proto3" json:"limit,omitempty"` + Offset int64 `protobuf:"varint,10,opt,name=offset,proto3" json:"offset,omitempty"` +} + +func (x *QueryTradesRequest) Reset() { + *x = QueryTradesRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *QueryTradesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QueryTradesRequest) ProtoMessage() {} + +func (x *QueryTradesRequest) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[22] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QueryTradesRequest.ProtoReflect.Descriptor instead. +func (*QueryTradesRequest) Descriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{22} +} + +func (x *QueryTradesRequest) GetExchange() string { + if x != nil { + return x.Exchange + } + return "" +} + +func (x *QueryTradesRequest) GetSymbol() string { + if x != nil { + return x.Symbol + } + return "" +} + +func (x *QueryTradesRequest) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +func (x *QueryTradesRequest) GetFrom() int64 { + if x != nil { + return x.From + } + return 0 +} + +func (x *QueryTradesRequest) GetTo() int64 { + if x != nil { + return x.To + } + return 0 +} + +func (x *QueryTradesRequest) GetOrderBy() string { + if x != nil { + return x.OrderBy + } + return "" +} + +func (x *QueryTradesRequest) GetPagination() bool { + if x != nil { + return x.Pagination + } + return false +} + +func (x *QueryTradesRequest) GetPage() int64 { + if x != nil { + return x.Page + } + return 0 +} + +func (x *QueryTradesRequest) GetLimit() int64 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *QueryTradesRequest) GetOffset() int64 { + if x != nil { + return x.Offset + } + return 0 +} + +type QueryTradesResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Trades []*Trade `protobuf:"bytes,1,rep,name=trades,proto3" json:"trades,omitempty"` + Error *Error `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *QueryTradesResponse) Reset() { + *x = QueryTradesResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *QueryTradesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QueryTradesResponse) ProtoMessage() {} + +func (x *QueryTradesResponse) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[23] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QueryTradesResponse.ProtoReflect.Descriptor instead. +func (*QueryTradesResponse) Descriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{23} +} + +func (x *QueryTradesResponse) GetTrades() []*Trade { + if x != nil { + return x.Trades + } + return nil +} + +func (x *QueryTradesResponse) GetError() *Error { + if x != nil { + return x.Error + } + return nil +} + +type QueryKLinesRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Exchange string `protobuf:"bytes,1,opt,name=exchange,proto3" json:"exchange,omitempty"` + Symbol string `protobuf:"bytes,2,opt,name=symbol,proto3" json:"symbol,omitempty"` + Interval string `protobuf:"bytes,3,opt,name=interval,proto3" json:"interval,omitempty"` // time period of K line in minute + StartTime int64 `protobuf:"varint,4,opt,name=start_time,json=startTime,proto3" json:"start_time,omitempty"` + EndTime int64 `protobuf:"varint,5,opt,name=end_time,json=endTime,proto3" json:"end_time,omitempty"` + Limit int64 `protobuf:"varint,6,opt,name=limit,proto3" json:"limit,omitempty"` +} + +func (x *QueryKLinesRequest) Reset() { + *x = QueryKLinesRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *QueryKLinesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QueryKLinesRequest) ProtoMessage() {} + +func (x *QueryKLinesRequest) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[24] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QueryKLinesRequest.ProtoReflect.Descriptor instead. +func (*QueryKLinesRequest) Descriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{24} +} + +func (x *QueryKLinesRequest) GetExchange() string { + if x != nil { + return x.Exchange + } + return "" +} + +func (x *QueryKLinesRequest) GetSymbol() string { + if x != nil { + return x.Symbol + } + return "" +} + +func (x *QueryKLinesRequest) GetInterval() string { + if x != nil { + return x.Interval + } + return "" +} + +func (x *QueryKLinesRequest) GetStartTime() int64 { + if x != nil { + return x.StartTime + } + return 0 +} + +func (x *QueryKLinesRequest) GetEndTime() int64 { + if x != nil { + return x.EndTime + } + return 0 +} + +func (x *QueryKLinesRequest) GetLimit() int64 { + if x != nil { + return x.Limit + } + return 0 +} + +type QueryKLinesResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Klines []*KLine `protobuf:"bytes,1,rep,name=klines,proto3" json:"klines,omitempty"` + Error *Error `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *QueryKLinesResponse) Reset() { + *x = QueryKLinesResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *QueryKLinesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QueryKLinesResponse) ProtoMessage() {} + +func (x *QueryKLinesResponse) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[25] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QueryKLinesResponse.ProtoReflect.Descriptor instead. +func (*QueryKLinesResponse) Descriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{25} +} + +func (x *QueryKLinesResponse) GetKlines() []*KLine { + if x != nil { + return x.Klines + } + return nil +} + +func (x *QueryKLinesResponse) GetError() *Error { + if x != nil { + return x.Error + } + return nil +} + +type KLine struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Session string `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"` + Exchange string `protobuf:"bytes,2,opt,name=exchange,proto3" json:"exchange,omitempty"` + Symbol string `protobuf:"bytes,3,opt,name=symbol,proto3" json:"symbol,omitempty"` + Open string `protobuf:"bytes,4,opt,name=open,proto3" json:"open,omitempty"` + High string `protobuf:"bytes,5,opt,name=high,proto3" json:"high,omitempty"` + Low string `protobuf:"bytes,6,opt,name=low,proto3" json:"low,omitempty"` + Close string `protobuf:"bytes,7,opt,name=close,proto3" json:"close,omitempty"` + Volume string `protobuf:"bytes,8,opt,name=volume,proto3" json:"volume,omitempty"` + QuoteVolume string `protobuf:"bytes,9,opt,name=quote_volume,json=quoteVolume,proto3" json:"quote_volume,omitempty"` + StartTime int64 `protobuf:"varint,10,opt,name=start_time,json=startTime,proto3" json:"start_time,omitempty"` + EndTime int64 `protobuf:"varint,11,opt,name=end_time,json=endTime,proto3" json:"end_time,omitempty"` + Closed bool `protobuf:"varint,12,opt,name=closed,proto3" json:"closed,omitempty"` +} + +func (x *KLine) Reset() { + *x = KLine{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *KLine) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*KLine) ProtoMessage() {} + +func (x *KLine) ProtoReflect() protoreflect.Message { + mi := &file_pkg_pb_qbtrade_proto_msgTypes[26] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use KLine.ProtoReflect.Descriptor instead. +func (*KLine) Descriptor() ([]byte, []int) { + return file_pkg_pb_qbtrade_proto_rawDescGZIP(), []int{26} +} + +func (x *KLine) GetSession() string { + if x != nil { + return x.Session + } + return "" +} + +func (x *KLine) GetExchange() string { + if x != nil { + return x.Exchange + } + return "" +} + +func (x *KLine) GetSymbol() string { + if x != nil { + return x.Symbol + } + return "" +} + +func (x *KLine) GetOpen() string { + if x != nil { + return x.Open + } + return "" +} + +func (x *KLine) GetHigh() string { + if x != nil { + return x.High + } + return "" +} + +func (x *KLine) GetLow() string { + if x != nil { + return x.Low + } + return "" +} + +func (x *KLine) GetClose() string { + if x != nil { + return x.Close + } + return "" +} + +func (x *KLine) GetVolume() string { + if x != nil { + return x.Volume + } + return "" +} + +func (x *KLine) GetQuoteVolume() string { + if x != nil { + return x.QuoteVolume + } + return "" +} + +func (x *KLine) GetStartTime() int64 { + if x != nil { + return x.StartTime + } + return 0 +} + +func (x *KLine) GetEndTime() int64 { + if x != nil { + return x.EndTime + } + return 0 +} + +func (x *KLine) GetClosed() bool { + if x != nil { + return x.Closed + } + return false +} + +var File_pkg_pb_qbtrade_proto protoreflect.FileDescriptor + +var file_pkg_pb_qbtrade_proto_rawDesc = []byte{ + 0x0a, 0x11, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x62, 0x2f, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x12, 0x04, 0x62, 0x62, 0x67, 0x6f, 0x22, 0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, + 0x74, 0x79, 0x22, 0x4b, 0x0a, 0x05, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x1d, 0x0a, 0x0a, 0x65, + 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x09, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x65, 0x72, + 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, + 0x2b, 0x0a, 0x0f, 0x55, 0x73, 0x65, 0x72, 0x44, 0x61, 0x74, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x81, 0x02, 0x0a, + 0x08, 0x55, 0x73, 0x65, 0x72, 0x44, 0x61, 0x74, 0x61, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, + 0x27, 0x0a, 0x07, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, + 0x32, 0x0d, 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x52, + 0x07, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x12, 0x21, 0x0a, 0x05, 0x65, 0x76, 0x65, 0x6e, + 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0b, 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x45, + 0x76, 0x65, 0x6e, 0x74, 0x52, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x29, 0x0a, 0x08, 0x62, + 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0d, 0x2e, + 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x52, 0x08, 0x62, 0x61, + 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x12, 0x23, 0x0a, 0x06, 0x74, 0x72, 0x61, 0x64, 0x65, 0x73, + 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x54, 0x72, + 0x61, 0x64, 0x65, 0x52, 0x06, 0x74, 0x72, 0x61, 0x64, 0x65, 0x73, 0x12, 0x23, 0x0a, 0x06, 0x6f, + 0x72, 0x64, 0x65, 0x72, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x62, 0x62, + 0x67, 0x6f, 0x2e, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x06, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x73, + 0x22, 0x4c, 0x0a, 0x10, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x38, 0x0a, 0x0d, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, + 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x62, 0x62, + 0x67, 0x6f, 0x2e, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, + 0x0d, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x9d, + 0x01, 0x0a, 0x0c, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, + 0x1a, 0x0a, 0x08, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x08, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x27, 0x0a, 0x07, 0x63, + 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0d, 0x2e, 0x62, + 0x62, 0x67, 0x6f, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x52, 0x07, 0x63, 0x68, 0x61, + 0x6e, 0x6e, 0x65, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x79, 0x6d, 0x62, 0x6f, 0x6c, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x79, 0x6d, 0x62, 0x6f, 0x6c, 0x12, 0x14, 0x0a, 0x05, + 0x64, 0x65, 0x70, 0x74, 0x68, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x64, 0x65, 0x70, + 0x74, 0x68, 0x12, 0x1a, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x22, 0xff, + 0x02, 0x0a, 0x0a, 0x4d, 0x61, 0x72, 0x6b, 0x65, 0x74, 0x44, 0x61, 0x74, 0x61, 0x12, 0x18, 0x0a, + 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, + 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x65, 0x78, 0x63, 0x68, 0x61, + 0x6e, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x65, 0x78, 0x63, 0x68, 0x61, + 0x6e, 0x67, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x79, 0x6d, 0x62, 0x6f, 0x6c, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x79, 0x6d, 0x62, 0x6f, 0x6c, 0x12, 0x27, 0x0a, 0x07, 0x63, + 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0d, 0x2e, 0x62, + 0x62, 0x67, 0x6f, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x52, 0x07, 0x63, 0x68, 0x61, + 0x6e, 0x6e, 0x65, 0x6c, 0x12, 0x21, 0x0a, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x0e, 0x32, 0x0b, 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x45, 0x76, 0x65, 0x6e, 0x74, + 0x52, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x21, 0x0a, 0x05, 0x64, 0x65, 0x70, 0x74, 0x68, + 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x44, 0x65, + 0x70, 0x74, 0x68, 0x52, 0x05, 0x64, 0x65, 0x70, 0x74, 0x68, 0x12, 0x21, 0x0a, 0x05, 0x6b, 0x6c, + 0x69, 0x6e, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x62, 0x62, 0x67, 0x6f, + 0x2e, 0x4b, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x05, 0x6b, 0x6c, 0x69, 0x6e, 0x65, 0x12, 0x24, 0x0a, + 0x06, 0x74, 0x69, 0x63, 0x6b, 0x65, 0x72, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, + 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x72, 0x52, 0x06, 0x74, 0x69, 0x63, + 0x6b, 0x65, 0x72, 0x12, 0x23, 0x0a, 0x06, 0x74, 0x72, 0x61, 0x64, 0x65, 0x73, 0x18, 0x08, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x54, 0x72, 0x61, 0x64, 0x65, + 0x52, 0x06, 0x74, 0x72, 0x61, 0x64, 0x65, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x75, 0x62, 0x73, + 0x63, 0x72, 0x69, 0x62, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x0c, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x64, 0x41, 0x74, 0x12, 0x21, 0x0a, + 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x62, + 0x62, 0x67, 0x6f, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, + 0x22, 0x89, 0x01, 0x0a, 0x05, 0x44, 0x65, 0x70, 0x74, 0x68, 0x12, 0x1a, 0x0a, 0x08, 0x65, 0x78, + 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x65, 0x78, + 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x79, 0x6d, 0x62, 0x6f, 0x6c, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x79, 0x6d, 0x62, 0x6f, 0x6c, 0x12, 0x25, + 0x0a, 0x04, 0x61, 0x73, 0x6b, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x62, + 0x62, 0x67, 0x6f, 0x2e, 0x50, 0x72, 0x69, 0x63, 0x65, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x52, + 0x04, 0x61, 0x73, 0x6b, 0x73, 0x12, 0x25, 0x0a, 0x04, 0x62, 0x69, 0x64, 0x73, 0x18, 0x04, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x50, 0x72, 0x69, 0x63, 0x65, + 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x52, 0x04, 0x62, 0x69, 0x64, 0x73, 0x22, 0x3b, 0x0a, 0x0b, + 0x50, 0x72, 0x69, 0x63, 0x65, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x70, + 0x72, 0x69, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x70, 0x72, 0x69, 0x63, + 0x65, 0x12, 0x16, 0x0a, 0x06, 0x76, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x06, 0x76, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x22, 0xa1, 0x02, 0x0a, 0x05, 0x54, 0x72, + 0x61, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1a, 0x0a, + 0x08, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x08, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x79, 0x6d, + 0x62, 0x6f, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x79, 0x6d, 0x62, 0x6f, + 0x6c, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, + 0x64, 0x12, 0x14, 0x0a, 0x05, 0x70, 0x72, 0x69, 0x63, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x05, 0x70, 0x72, 0x69, 0x63, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, + 0x69, 0x74, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, + 0x69, 0x74, 0x79, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, + 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, + 0x41, 0x74, 0x12, 0x1e, 0x0a, 0x04, 0x73, 0x69, 0x64, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0e, + 0x32, 0x0a, 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x53, 0x69, 0x64, 0x65, 0x52, 0x04, 0x73, 0x69, + 0x64, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x66, 0x65, 0x65, 0x5f, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, + 0x63, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x66, 0x65, 0x65, 0x43, 0x75, 0x72, + 0x72, 0x65, 0x6e, 0x63, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x66, 0x65, 0x65, 0x18, 0x0a, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x03, 0x66, 0x65, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x61, 0x6b, 0x65, 0x72, + 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x6d, 0x61, 0x6b, 0x65, 0x72, 0x22, 0xa4, 0x01, + 0x0a, 0x06, 0x54, 0x69, 0x63, 0x6b, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x65, 0x78, 0x63, 0x68, + 0x61, 0x6e, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x65, 0x78, 0x63, 0x68, + 0x61, 0x6e, 0x67, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x79, 0x6d, 0x62, 0x6f, 0x6c, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x79, 0x6d, 0x62, 0x6f, 0x6c, 0x12, 0x12, 0x0a, 0x04, + 0x6f, 0x70, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x01, 0x52, 0x04, 0x6f, 0x70, 0x65, 0x6e, + 0x12, 0x12, 0x0a, 0x04, 0x68, 0x69, 0x67, 0x68, 0x18, 0x04, 0x20, 0x01, 0x28, 0x01, 0x52, 0x04, + 0x68, 0x69, 0x67, 0x68, 0x12, 0x10, 0x0a, 0x03, 0x6c, 0x6f, 0x77, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x01, 0x52, 0x03, 0x6c, 0x6f, 0x77, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6c, 0x6f, 0x73, 0x65, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x01, 0x52, 0x05, 0x63, 0x6c, 0x6f, 0x73, 0x65, 0x12, 0x16, 0x0a, 0x06, + 0x76, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x01, 0x52, 0x06, 0x76, 0x6f, + 0x6c, 0x75, 0x6d, 0x65, 0x22, 0x93, 0x03, 0x0a, 0x05, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x1a, + 0x0a, 0x08, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x79, + 0x6d, 0x62, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x79, 0x6d, 0x62, + 0x6f, 0x6c, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, + 0x69, 0x64, 0x12, 0x1e, 0x0a, 0x04, 0x73, 0x69, 0x64, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, + 0x32, 0x0a, 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x53, 0x69, 0x64, 0x65, 0x52, 0x04, 0x73, 0x69, + 0x64, 0x65, 0x12, 0x2e, 0x0a, 0x0a, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x74, 0x79, 0x70, 0x65, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0f, 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x4f, 0x72, + 0x64, 0x65, 0x72, 0x54, 0x79, 0x70, 0x65, 0x52, 0x09, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x54, 0x79, + 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x70, 0x72, 0x69, 0x63, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x70, 0x72, 0x69, 0x63, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x74, 0x6f, 0x70, + 0x5f, 0x70, 0x72, 0x69, 0x63, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x74, + 0x6f, 0x70, 0x50, 0x72, 0x69, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, + 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x0b, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x2b, 0x0a, 0x11, 0x65, + 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x64, 0x5f, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, + 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x65, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x64, + 0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x26, 0x0a, 0x0f, 0x63, 0x6c, 0x69, 0x65, + 0x6e, 0x74, 0x5f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x0e, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0d, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x49, 0x64, + 0x12, 0x19, 0x0a, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x0f, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x07, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, + 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x22, 0xbf, 0x02, 0x0a, 0x0b, 0x53, + 0x75, 0x62, 0x6d, 0x69, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, + 0x12, 0x16, 0x0a, 0x06, 0x73, 0x79, 0x6d, 0x62, 0x6f, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x73, 0x79, 0x6d, 0x62, 0x6f, 0x6c, 0x12, 0x1e, 0x0a, 0x04, 0x73, 0x69, 0x64, 0x65, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0a, 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x53, 0x69, + 0x64, 0x65, 0x52, 0x04, 0x73, 0x69, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x70, 0x72, 0x69, 0x63, + 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x70, 0x72, 0x69, 0x63, 0x65, 0x12, 0x1a, + 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x74, + 0x6f, 0x70, 0x5f, 0x70, 0x72, 0x69, 0x63, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, + 0x73, 0x74, 0x6f, 0x70, 0x50, 0x72, 0x69, 0x63, 0x65, 0x12, 0x2e, 0x0a, 0x0a, 0x6f, 0x72, 0x64, + 0x65, 0x72, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0f, 0x2e, + 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x54, 0x79, 0x70, 0x65, 0x52, 0x09, + 0x6f, 0x72, 0x64, 0x65, 0x72, 0x54, 0x79, 0x70, 0x65, 0x12, 0x26, 0x0a, 0x0f, 0x63, 0x6c, 0x69, + 0x65, 0x6e, 0x74, 0x5f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x09, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0d, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x49, + 0x64, 0x12, 0x19, 0x0a, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x0a, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x07, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x22, 0xad, 0x01, 0x0a, + 0x07, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x1a, + 0x0a, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x76, + 0x61, 0x69, 0x6c, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, + 0x76, 0x61, 0x69, 0x6c, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x6c, 0x6f, 0x63, 0x6b, + 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, + 0x12, 0x1a, 0x0a, 0x08, 0x62, 0x6f, 0x72, 0x72, 0x6f, 0x77, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x08, 0x62, 0x6f, 0x72, 0x72, 0x6f, 0x77, 0x65, 0x64, 0x22, 0x66, 0x0a, 0x12, + 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x36, 0x0a, 0x0d, + 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x5f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x53, 0x75, 0x62, 0x6d, 0x69, + 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x0c, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x4f, 0x72, + 0x64, 0x65, 0x72, 0x73, 0x22, 0x77, 0x0a, 0x13, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x4f, 0x72, + 0x64, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, + 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x65, + 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x23, 0x0a, 0x06, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x73, 0x18, + 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x4f, 0x72, 0x64, + 0x65, 0x72, 0x52, 0x06, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x73, 0x12, 0x21, 0x0a, 0x05, 0x65, 0x72, + 0x72, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x62, 0x62, 0x67, 0x6f, + 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x71, 0x0a, + 0x12, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x19, 0x0a, + 0x08, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x49, 0x64, 0x12, 0x26, 0x0a, 0x0f, 0x63, 0x6c, 0x69, 0x65, + 0x6e, 0x74, 0x5f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0d, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x49, 0x64, + 0x22, 0x5b, 0x0a, 0x13, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x21, 0x0a, 0x05, 0x6f, 0x72, 0x64, 0x65, 0x72, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x4f, 0x72, + 0x64, 0x65, 0x72, 0x52, 0x05, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x21, 0x0a, 0x05, 0x65, 0x72, + 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x62, 0x62, 0x67, 0x6f, + 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x65, 0x0a, + 0x11, 0x51, 0x75, 0x65, 0x72, 0x79, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x0e, 0x0a, 0x02, + 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x26, 0x0a, 0x0f, + 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x4f, 0x72, 0x64, + 0x65, 0x72, 0x49, 0x64, 0x22, 0x5a, 0x0a, 0x12, 0x51, 0x75, 0x65, 0x72, 0x79, 0x4f, 0x72, 0x64, + 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x21, 0x0a, 0x05, 0x6f, 0x72, + 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x62, 0x62, 0x67, 0x6f, + 0x2e, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x05, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x21, 0x0a, + 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x62, + 0x62, 0x67, 0x6f, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, + 0x22, 0xf4, 0x01, 0x0a, 0x12, 0x51, 0x75, 0x65, 0x72, 0x79, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x73, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x79, 0x6d, 0x62, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x06, 0x73, 0x79, 0x6d, 0x62, 0x6f, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, + 0x74, 0x65, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, + 0x19, 0x0a, 0x08, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x62, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x42, 0x79, 0x12, 0x19, 0x0a, 0x08, 0x67, 0x72, + 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x67, 0x72, + 0x6f, 0x75, 0x70, 0x49, 0x64, 0x12, 0x1e, 0x0a, 0x0a, 0x70, 0x61, 0x67, 0x69, 0x6e, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x70, 0x61, 0x67, 0x69, 0x6e, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x67, 0x65, 0x18, 0x07, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x04, 0x70, 0x61, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, + 0x69, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, + 0x16, 0x0a, 0x06, 0x6f, 0x66, 0x66, 0x73, 0x65, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x06, 0x6f, 0x66, 0x66, 0x73, 0x65, 0x74, 0x22, 0x5d, 0x0a, 0x13, 0x51, 0x75, 0x65, 0x72, 0x79, + 0x4f, 0x72, 0x64, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x23, + 0x0a, 0x06, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0b, + 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x06, 0x6f, 0x72, 0x64, + 0x65, 0x72, 0x73, 0x12, 0x21, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, + 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x87, 0x02, 0x0a, 0x12, 0x51, 0x75, 0x65, 0x72, 0x79, + 0x54, 0x72, 0x61, 0x64, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, + 0x08, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x08, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x79, 0x6d, + 0x62, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x79, 0x6d, 0x62, 0x6f, + 0x6c, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, + 0x12, 0x0a, 0x04, 0x66, 0x72, 0x6f, 0x6d, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x66, + 0x72, 0x6f, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x74, 0x6f, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x02, 0x74, 0x6f, 0x12, 0x19, 0x0a, 0x08, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x62, 0x79, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x42, 0x79, 0x12, 0x1e, + 0x0a, 0x0a, 0x70, 0x61, 0x67, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x0a, 0x70, 0x61, 0x67, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, + 0x0a, 0x04, 0x70, 0x61, 0x67, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x70, 0x61, + 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x66, 0x66, 0x73, + 0x65, 0x74, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x6f, 0x66, 0x66, 0x73, 0x65, 0x74, + 0x22, 0x5d, 0x0a, 0x13, 0x51, 0x75, 0x65, 0x72, 0x79, 0x54, 0x72, 0x61, 0x64, 0x65, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x23, 0x0a, 0x06, 0x74, 0x72, 0x61, 0x64, 0x65, + 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x54, + 0x72, 0x61, 0x64, 0x65, 0x52, 0x06, 0x74, 0x72, 0x61, 0x64, 0x65, 0x73, 0x12, 0x21, 0x0a, 0x05, + 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x62, 0x62, + 0x67, 0x6f, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, + 0xb4, 0x01, 0x0a, 0x12, 0x51, 0x75, 0x65, 0x72, 0x79, 0x4b, 0x4c, 0x69, 0x6e, 0x65, 0x73, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, + 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, + 0x67, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x79, 0x6d, 0x62, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x73, 0x79, 0x6d, 0x62, 0x6f, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x69, 0x6e, + 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x69, 0x6e, + 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x5f, + 0x74, 0x69, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x73, 0x74, 0x61, 0x72, + 0x74, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x65, 0x6e, 0x64, 0x5f, 0x74, 0x69, 0x6d, + 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x65, 0x6e, 0x64, 0x54, 0x69, 0x6d, 0x65, + 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x22, 0x5d, 0x0a, 0x13, 0x51, 0x75, 0x65, 0x72, 0x79, 0x4b, + 0x4c, 0x69, 0x6e, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x23, 0x0a, + 0x06, 0x6b, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0b, 0x2e, + 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x4b, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x06, 0x6b, 0x6c, 0x69, 0x6e, + 0x65, 0x73, 0x12, 0x21, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x0b, 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x05, + 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0xb2, 0x02, 0x0a, 0x05, 0x4b, 0x4c, 0x69, 0x6e, 0x65, 0x12, + 0x18, 0x0a, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x65, 0x78, 0x63, + 0x68, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x65, 0x78, 0x63, + 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x79, 0x6d, 0x62, 0x6f, 0x6c, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x79, 0x6d, 0x62, 0x6f, 0x6c, 0x12, 0x12, 0x0a, + 0x04, 0x6f, 0x70, 0x65, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6f, 0x70, 0x65, + 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x69, 0x67, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x04, 0x68, 0x69, 0x67, 0x68, 0x12, 0x10, 0x0a, 0x03, 0x6c, 0x6f, 0x77, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x03, 0x6c, 0x6f, 0x77, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6c, 0x6f, 0x73, 0x65, + 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x63, 0x6c, 0x6f, 0x73, 0x65, 0x12, 0x16, 0x0a, + 0x06, 0x76, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x76, + 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x71, 0x75, 0x6f, 0x74, 0x65, 0x5f, 0x76, + 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x71, 0x75, 0x6f, + 0x74, 0x65, 0x56, 0x6f, 0x6c, 0x75, 0x6d, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, + 0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x73, 0x74, + 0x61, 0x72, 0x74, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x65, 0x6e, 0x64, 0x5f, 0x74, + 0x69, 0x6d, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x65, 0x6e, 0x64, 0x54, 0x69, + 0x6d, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x6c, 0x6f, 0x73, 0x65, 0x64, 0x18, 0x0c, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x06, 0x63, 0x6c, 0x6f, 0x73, 0x65, 0x64, 0x2a, 0x6e, 0x0a, 0x05, 0x45, 0x76, + 0x65, 0x6e, 0x74, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, + 0x12, 0x0e, 0x0a, 0x0a, 0x53, 0x55, 0x42, 0x53, 0x43, 0x52, 0x49, 0x42, 0x45, 0x44, 0x10, 0x01, + 0x12, 0x10, 0x0a, 0x0c, 0x55, 0x4e, 0x53, 0x55, 0x42, 0x53, 0x43, 0x52, 0x49, 0x42, 0x45, 0x44, + 0x10, 0x02, 0x12, 0x0c, 0x0a, 0x08, 0x53, 0x4e, 0x41, 0x50, 0x53, 0x48, 0x4f, 0x54, 0x10, 0x03, + 0x12, 0x0a, 0x0a, 0x06, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x10, 0x04, 0x12, 0x11, 0x0a, 0x0d, + 0x41, 0x55, 0x54, 0x48, 0x45, 0x4e, 0x54, 0x49, 0x43, 0x41, 0x54, 0x45, 0x44, 0x10, 0x05, 0x12, + 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x63, 0x2a, 0x4d, 0x0a, 0x07, 0x43, 0x68, + 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x12, 0x08, 0x0a, 0x04, 0x42, 0x4f, 0x4f, 0x4b, 0x10, 0x00, 0x12, + 0x09, 0x0a, 0x05, 0x54, 0x52, 0x41, 0x44, 0x45, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x54, 0x49, + 0x43, 0x4b, 0x45, 0x52, 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x4b, 0x4c, 0x49, 0x4e, 0x45, 0x10, + 0x03, 0x12, 0x0b, 0x0a, 0x07, 0x42, 0x41, 0x4c, 0x41, 0x4e, 0x43, 0x45, 0x10, 0x04, 0x12, 0x09, + 0x0a, 0x05, 0x4f, 0x52, 0x44, 0x45, 0x52, 0x10, 0x05, 0x2a, 0x19, 0x0a, 0x04, 0x53, 0x69, 0x64, + 0x65, 0x12, 0x07, 0x0a, 0x03, 0x42, 0x55, 0x59, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x53, 0x45, + 0x4c, 0x4c, 0x10, 0x01, 0x2a, 0x61, 0x0a, 0x09, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x54, 0x79, 0x70, + 0x65, 0x12, 0x0a, 0x0a, 0x06, 0x4d, 0x41, 0x52, 0x4b, 0x45, 0x54, 0x10, 0x00, 0x12, 0x09, 0x0a, + 0x05, 0x4c, 0x49, 0x4d, 0x49, 0x54, 0x10, 0x01, 0x12, 0x0f, 0x0a, 0x0b, 0x53, 0x54, 0x4f, 0x50, + 0x5f, 0x4d, 0x41, 0x52, 0x4b, 0x45, 0x54, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, 0x53, 0x54, 0x4f, + 0x50, 0x5f, 0x4c, 0x49, 0x4d, 0x49, 0x54, 0x10, 0x03, 0x12, 0x0d, 0x0a, 0x09, 0x50, 0x4f, 0x53, + 0x54, 0x5f, 0x4f, 0x4e, 0x4c, 0x59, 0x10, 0x04, 0x12, 0x0d, 0x0a, 0x09, 0x49, 0x4f, 0x43, 0x5f, + 0x4c, 0x49, 0x4d, 0x49, 0x54, 0x10, 0x05, 0x32, 0x94, 0x01, 0x0a, 0x11, 0x4d, 0x61, 0x72, 0x6b, + 0x65, 0x74, 0x44, 0x61, 0x74, 0x61, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x39, 0x0a, + 0x09, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x12, 0x16, 0x2e, 0x62, 0x62, 0x67, + 0x6f, 0x2e, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x10, 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x4d, 0x61, 0x72, 0x6b, 0x65, 0x74, + 0x44, 0x61, 0x74, 0x61, 0x22, 0x00, 0x30, 0x01, 0x12, 0x44, 0x0a, 0x0b, 0x51, 0x75, 0x65, 0x72, + 0x79, 0x4b, 0x4c, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x18, 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x51, + 0x75, 0x65, 0x72, 0x79, 0x4b, 0x4c, 0x69, 0x6e, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x19, 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x4b, 0x4c, + 0x69, 0x6e, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x32, 0x49, + 0x0a, 0x0f, 0x55, 0x73, 0x65, 0x72, 0x44, 0x61, 0x74, 0x61, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, + 0x65, 0x12, 0x36, 0x0a, 0x09, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x12, 0x15, + 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x44, 0x61, 0x74, 0x61, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0e, 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x55, 0x73, 0x65, + 0x72, 0x44, 0x61, 0x74, 0x61, 0x22, 0x00, 0x30, 0x01, 0x32, 0xeb, 0x02, 0x0a, 0x0e, 0x54, 0x72, + 0x61, 0x64, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x44, 0x0a, 0x0b, + 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x18, 0x2e, 0x62, 0x62, + 0x67, 0x6f, 0x2e, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x53, 0x75, 0x62, + 0x6d, 0x69, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x22, 0x00, 0x12, 0x44, 0x0a, 0x0b, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x4f, 0x72, 0x64, 0x65, + 0x72, 0x12, 0x18, 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x4f, + 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x62, 0x62, + 0x67, 0x6f, 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x41, 0x0a, 0x0a, 0x51, 0x75, 0x65, 0x72, + 0x79, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x17, 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x51, 0x75, + 0x65, 0x72, 0x79, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x18, 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x4f, 0x72, 0x64, 0x65, + 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x44, 0x0a, 0x0b, 0x51, + 0x75, 0x65, 0x72, 0x79, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x73, 0x12, 0x18, 0x2e, 0x62, 0x62, 0x67, + 0x6f, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x51, 0x75, 0x65, 0x72, + 0x79, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, + 0x00, 0x12, 0x44, 0x0a, 0x0b, 0x51, 0x75, 0x65, 0x72, 0x79, 0x54, 0x72, 0x61, 0x64, 0x65, 0x73, + 0x12, 0x18, 0x2e, 0x62, 0x62, 0x67, 0x6f, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x54, 0x72, 0x61, + 0x64, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x62, 0x62, 0x67, + 0x6f, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x54, 0x72, 0x61, 0x64, 0x65, 0x73, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x07, 0x5a, 0x05, 0x2e, 0x2e, 0x2f, 0x70, 0x62, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_pkg_pb_qbtrade_proto_rawDescOnce sync.Once + file_pkg_pb_qbtrade_proto_rawDescData = file_pkg_pb_qbtrade_proto_rawDesc +) + +func file_pkg_pb_qbtrade_proto_rawDescGZIP() []byte { + file_pkg_pb_qbtrade_proto_rawDescOnce.Do(func() { + file_pkg_pb_qbtrade_proto_rawDescData = protoimpl.X.CompressGZIP(file_pkg_pb_qbtrade_proto_rawDescData) + }) + return file_pkg_pb_qbtrade_proto_rawDescData +} + +var file_pkg_pb_qbtrade_proto_enumTypes = make([]protoimpl.EnumInfo, 4) +var file_pkg_pb_qbtrade_proto_msgTypes = make([]protoimpl.MessageInfo, 27) +var file_pkg_pb_qbtrade_proto_goTypes = []interface{}{ + (Event)(0), // 0: qbtrade.Event + (Channel)(0), // 1: qbtrade.Channel + (Side)(0), // 2: qbtrade.Side + (OrderType)(0), // 3: qbtrade.OrderType + (*Empty)(nil), // 4: qbtrade.Empty + (*Error)(nil), // 5: qbtrade.Error + (*UserDataRequest)(nil), // 6: qbtrade.UserDataRequest + (*UserData)(nil), // 7: qbtrade.UserData + (*SubscribeRequest)(nil), // 8: qbtrade.SubscribeRequest + (*Subscription)(nil), // 9: qbtrade.Subscription + (*MarketData)(nil), // 10: qbtrade.MarketData + (*Depth)(nil), // 11: qbtrade.Depth + (*PriceVolume)(nil), // 12: qbtrade.PriceVolume + (*Trade)(nil), // 13: qbtrade.Trade + (*Ticker)(nil), // 14: qbtrade.Ticker + (*Order)(nil), // 15: qbtrade.Order + (*SubmitOrder)(nil), // 16: qbtrade.SubmitOrder + (*Balance)(nil), // 17: qbtrade.Balance + (*SubmitOrderRequest)(nil), // 18: qbtrade.SubmitOrderRequest + (*SubmitOrderResponse)(nil), // 19: qbtrade.SubmitOrderResponse + (*CancelOrderRequest)(nil), // 20: qbtrade.CancelOrderRequest + (*CancelOrderResponse)(nil), // 21: qbtrade.CancelOrderResponse + (*QueryOrderRequest)(nil), // 22: qbtrade.QueryOrderRequest + (*QueryOrderResponse)(nil), // 23: qbtrade.QueryOrderResponse + (*QueryOrdersRequest)(nil), // 24: qbtrade.QueryOrdersRequest + (*QueryOrdersResponse)(nil), // 25: qbtrade.QueryOrdersResponse + (*QueryTradesRequest)(nil), // 26: qbtrade.QueryTradesRequest + (*QueryTradesResponse)(nil), // 27: qbtrade.QueryTradesResponse + (*QueryKLinesRequest)(nil), // 28: qbtrade.QueryKLinesRequest + (*QueryKLinesResponse)(nil), // 29: qbtrade.QueryKLinesResponse + (*KLine)(nil), // 30: qbtrade.KLine +} +var file_pkg_pb_qbtrade_proto_depIdxs = []int32{ + 1, // 0: qbtrade.UserData.channel:type_name -> qbtrade.Channel + 0, // 1: qbtrade.UserData.event:type_name -> qbtrade.Event + 17, // 2: qbtrade.UserData.balances:type_name -> qbtrade.Balance + 13, // 3: qbtrade.UserData.trades:type_name -> qbtrade.Trade + 15, // 4: qbtrade.UserData.orders:type_name -> qbtrade.Order + 9, // 5: qbtrade.SubscribeRequest.subscriptions:type_name -> qbtrade.Subscription + 1, // 6: qbtrade.Subscription.channel:type_name -> qbtrade.Channel + 1, // 7: qbtrade.MarketData.channel:type_name -> qbtrade.Channel + 0, // 8: qbtrade.MarketData.event:type_name -> qbtrade.Event + 11, // 9: qbtrade.MarketData.depth:type_name -> qbtrade.Depth + 30, // 10: qbtrade.MarketData.kline:type_name -> qbtrade.KLine + 14, // 11: qbtrade.MarketData.ticker:type_name -> qbtrade.Ticker + 13, // 12: qbtrade.MarketData.trades:type_name -> qbtrade.Trade + 5, // 13: qbtrade.MarketData.error:type_name -> qbtrade.Error + 12, // 14: qbtrade.Depth.asks:type_name -> qbtrade.PriceVolume + 12, // 15: qbtrade.Depth.bids:type_name -> qbtrade.PriceVolume + 2, // 16: qbtrade.Trade.side:type_name -> qbtrade.Side + 2, // 17: qbtrade.Order.side:type_name -> qbtrade.Side + 3, // 18: qbtrade.Order.order_type:type_name -> qbtrade.OrderType + 2, // 19: qbtrade.SubmitOrder.side:type_name -> qbtrade.Side + 3, // 20: qbtrade.SubmitOrder.order_type:type_name -> qbtrade.OrderType + 16, // 21: qbtrade.SubmitOrderRequest.submit_orders:type_name -> qbtrade.SubmitOrder + 15, // 22: qbtrade.SubmitOrderResponse.orders:type_name -> qbtrade.Order + 5, // 23: qbtrade.SubmitOrderResponse.error:type_name -> qbtrade.Error + 15, // 24: qbtrade.CancelOrderResponse.order:type_name -> qbtrade.Order + 5, // 25: qbtrade.CancelOrderResponse.error:type_name -> qbtrade.Error + 15, // 26: qbtrade.QueryOrderResponse.order:type_name -> qbtrade.Order + 5, // 27: qbtrade.QueryOrderResponse.error:type_name -> qbtrade.Error + 15, // 28: qbtrade.QueryOrdersResponse.orders:type_name -> qbtrade.Order + 5, // 29: qbtrade.QueryOrdersResponse.error:type_name -> qbtrade.Error + 13, // 30: qbtrade.QueryTradesResponse.trades:type_name -> qbtrade.Trade + 5, // 31: qbtrade.QueryTradesResponse.error:type_name -> qbtrade.Error + 30, // 32: qbtrade.QueryKLinesResponse.klines:type_name -> qbtrade.KLine + 5, // 33: qbtrade.QueryKLinesResponse.error:type_name -> qbtrade.Error + 8, // 34: qbtrade.MarketDataService.Subscribe:input_type -> qbtrade.SubscribeRequest + 28, // 35: qbtrade.MarketDataService.QueryKLines:input_type -> qbtrade.QueryKLinesRequest + 6, // 36: qbtrade.UserDataService.Subscribe:input_type -> qbtrade.UserDataRequest + 18, // 37: qbtrade.TradingService.SubmitOrder:input_type -> qbtrade.SubmitOrderRequest + 20, // 38: qbtrade.TradingService.CancelOrder:input_type -> qbtrade.CancelOrderRequest + 22, // 39: qbtrade.TradingService.QueryOrder:input_type -> qbtrade.QueryOrderRequest + 24, // 40: qbtrade.TradingService.QueryOrders:input_type -> qbtrade.QueryOrdersRequest + 26, // 41: qbtrade.TradingService.QueryTrades:input_type -> qbtrade.QueryTradesRequest + 10, // 42: qbtrade.MarketDataService.Subscribe:output_type -> qbtrade.MarketData + 29, // 43: qbtrade.MarketDataService.QueryKLines:output_type -> qbtrade.QueryKLinesResponse + 7, // 44: qbtrade.UserDataService.Subscribe:output_type -> qbtrade.UserData + 19, // 45: qbtrade.TradingService.SubmitOrder:output_type -> qbtrade.SubmitOrderResponse + 21, // 46: qbtrade.TradingService.CancelOrder:output_type -> qbtrade.CancelOrderResponse + 23, // 47: qbtrade.TradingService.QueryOrder:output_type -> qbtrade.QueryOrderResponse + 25, // 48: qbtrade.TradingService.QueryOrders:output_type -> qbtrade.QueryOrdersResponse + 27, // 49: qbtrade.TradingService.QueryTrades:output_type -> qbtrade.QueryTradesResponse + 42, // [42:50] is the sub-list for method output_type + 34, // [34:42] is the sub-list for method input_type + 34, // [34:34] is the sub-list for extension type_name + 34, // [34:34] is the sub-list for extension extendee + 0, // [0:34] is the sub-list for field type_name +} + +func init() { file_pkg_pb_qbtrade_proto_init() } +func file_pkg_pb_qbtrade_proto_init() { + if File_pkg_pb_qbtrade_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_pkg_pb_qbtrade_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Empty); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_pb_qbtrade_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Error); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_pb_qbtrade_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*UserDataRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_pb_qbtrade_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*UserData); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_pb_qbtrade_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SubscribeRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_pb_qbtrade_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Subscription); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_pb_qbtrade_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*MarketData); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_pb_qbtrade_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Depth); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_pb_qbtrade_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*PriceVolume); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_pb_qbtrade_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Trade); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_pb_qbtrade_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Ticker); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_pb_qbtrade_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Order); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_pb_qbtrade_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SubmitOrder); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_pb_qbtrade_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Balance); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_pb_qbtrade_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SubmitOrderRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_pb_qbtrade_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SubmitOrderResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_pb_qbtrade_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CancelOrderRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_pb_qbtrade_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CancelOrderResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_pb_qbtrade_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*QueryOrderRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_pb_qbtrade_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*QueryOrderResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_pb_qbtrade_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*QueryOrdersRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_pb_qbtrade_proto_msgTypes[21].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*QueryOrdersResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_pb_qbtrade_proto_msgTypes[22].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*QueryTradesRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_pb_qbtrade_proto_msgTypes[23].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*QueryTradesResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_pb_qbtrade_proto_msgTypes[24].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*QueryKLinesRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_pb_qbtrade_proto_msgTypes[25].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*QueryKLinesResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_pb_qbtrade_proto_msgTypes[26].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*KLine); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_pkg_pb_qbtrade_proto_rawDesc, + NumEnums: 4, + NumMessages: 27, + NumExtensions: 0, + NumServices: 3, + }, + GoTypes: file_pkg_pb_qbtrade_proto_goTypes, + DependencyIndexes: file_pkg_pb_qbtrade_proto_depIdxs, + EnumInfos: file_pkg_pb_qbtrade_proto_enumTypes, + MessageInfos: file_pkg_pb_qbtrade_proto_msgTypes, + }.Build() + File_pkg_pb_qbtrade_proto = out.File + file_pkg_pb_qbtrade_proto_rawDesc = nil + file_pkg_pb_qbtrade_proto_goTypes = nil + file_pkg_pb_qbtrade_proto_depIdxs = nil +} diff --git a/pkg/pb/bbgo.proto b/pkg/pb/bbgo.proto new file mode 100644 index 0000000..03b2618 --- /dev/null +++ b/pkg/pb/bbgo.proto @@ -0,0 +1,280 @@ +syntax = "proto3"; + +package qbtrade; + +option go_package = "../pb"; + +service MarketDataService { + rpc Subscribe(SubscribeRequest) returns (stream MarketData) {} + rpc QueryKLines(QueryKLinesRequest) returns (QueryKLinesResponse) {} +} + +service UserDataService { + rpc Subscribe(UserDataRequest) returns (stream UserData) {} +} + +service TradingService { + // request-response + rpc SubmitOrder(SubmitOrderRequest) returns (SubmitOrderResponse) {} + rpc CancelOrder(CancelOrderRequest) returns (CancelOrderResponse) {} + rpc QueryOrder(QueryOrderRequest) returns (QueryOrderResponse) {} + rpc QueryOrders(QueryOrdersRequest) returns (QueryOrdersResponse) {} + rpc QueryTrades(QueryTradesRequest) returns (QueryTradesResponse) {} +} + +enum Event { + UNKNOWN = 0; + SUBSCRIBED = 1; + UNSUBSCRIBED = 2; + SNAPSHOT = 3; + UPDATE = 4; + AUTHENTICATED = 5; + ERROR = 99; +} + +enum Channel { + BOOK = 0; + TRADE = 1; + TICKER = 2; + KLINE = 3; + BALANCE = 4; + ORDER = 5; +} + +enum Side { + BUY = 0; + SELL = 1; +} + +enum OrderType { + MARKET = 0; + LIMIT = 1; + STOP_MARKET = 2; + STOP_LIMIT = 3; + POST_ONLY = 4; + IOC_LIMIT = 5; +} + +message Empty {} + +message Error { + int64 error_code = 1; + string error_message = 2; +} + +message UserDataRequest { + string session = 1; +} + +message UserData { + string session = 1; + string exchange = 2; + Channel channel = 3; // trade, order, balance + Event event = 4; // snapshot, update ... + repeated Balance balances = 5; + repeated Trade trades = 6; + repeated Order orders = 7; +} + +message SubscribeRequest { + repeated Subscription subscriptions = 1; +} + +message Subscription { + string exchange = 1; + Channel channel = 2; // book, trade, ticker + string symbol = 3; + string depth = 4; // depth is for book, valid values are full, medium, 1, 5 and 20 + string interval = 5; // interval is for kline channel +} + +message MarketData { + string session = 1; + string exchange = 2; + string symbol = 3; + Channel channel = 4; // book, trade, ticker, user + Event event = 5; // snapshot or update + Depth depth = 6; // depth: used by book + KLine kline = 7; + Ticker ticker = 9; // market ticker + repeated Trade trades = 8; // market trades + int64 subscribed_at = 12; + Error error = 13; +} + + +message Depth { + string exchange = 1; + string symbol = 2; + repeated PriceVolume asks = 3; + repeated PriceVolume bids = 4; +} + +message PriceVolume { + string price = 1; + string volume = 2; +} + +// https://maicoin.github.io/max-websocket-docs/#/private_channels?id=trade-response +// https://maicoin.github.io/max-websocket-docs/#/public_trade?id=success-response +message Trade { + string session = 1; + string exchange = 2; + string symbol = 3; + string id = 4; + string price = 5; + string quantity = 6; + int64 created_at = 7; + Side side = 8; + string fee_currency = 9; + string fee = 10; + bool maker = 11; +} + +// https://maicoin.github.io/max-websocket-docs/#/public_ticker?id=success-response +message Ticker { + string exchange = 1; + string symbol = 2; + double open = 3; + double high = 4; + double low = 5; + double close = 6; + double volume = 7; +} + +// https://maicoin.github.io/max-websocket-docs/#/private_channels?id=snapshot +message Order { + string exchange = 1; + string symbol = 2; + string id = 3; + Side side = 4; + OrderType order_type = 5; + string price = 6; + string stop_price = 7; + string status = 9; + string quantity = 11; + string executed_quantity = 12; + string client_order_id = 14; + int64 group_id = 15; + int64 created_at = 10; +} + +message SubmitOrder { + string session = 1; + string exchange = 2; + string symbol = 3; + Side side = 4; + string price = 6; + string quantity = 5; + string stop_price = 7; + OrderType order_type = 8; + string client_order_id = 9; + int64 group_id = 10; +} + +// https://maicoin.github.io/max-websocket-docs/#/private_channels?id=account-response +message Balance { + string session = 1; + string exchange = 2; + string currency = 3; + string available = 4; + string locked = 5; + string borrowed = 6; +} + +message SubmitOrderRequest { + string session = 1; + repeated SubmitOrder submit_orders = 2; +} + +message SubmitOrderResponse { + string session = 1; + repeated Order orders = 2; + Error error = 3; +} + +message CancelOrderRequest { + string session = 1; + string order_id = 2; + string client_order_id = 3; +} + +message CancelOrderResponse { + Order order = 1; + Error error = 2; +} + +message QueryOrderRequest { + string session = 1; + string id = 2; + string client_order_id = 3; +} + +message QueryOrderResponse { + Order order = 1; + Error error = 2; +} + +message QueryOrdersRequest { + string session = 1; + string symbol = 2; + repeated string state = 3; + string order_by = 4; + int64 group_id = 5; + bool pagination = 6; + int64 page = 7; + int64 limit = 8; + int64 offset = 9; +} + +message QueryOrdersResponse { + repeated Order orders = 1; + Error error = 2; +} + +message QueryTradesRequest { + string exchange = 1; + string symbol = 2; + int64 timestamp = 3; + int64 from = 4; + int64 to = 5; + string order_by = 6; + bool pagination = 7; + int64 page = 8; + int64 limit = 9; + int64 offset = 10; +} + +message QueryTradesResponse { + repeated Trade trades = 1; + Error error = 2; +} + +message QueryKLinesRequest { + string exchange = 1; + string symbol = 2; + string interval = 3; // time period of K line in minute + int64 start_time = 4; + int64 end_time = 5; + int64 limit = 6; +} + +message QueryKLinesResponse { + repeated KLine klines = 1; + Error error = 2; +} + +message KLine { + string session = 1; + string exchange = 2; + string symbol = 3; + string open = 4; + string high = 5; + string low = 6; + string close = 7; + string volume = 8; + string quote_volume = 9; + int64 start_time = 10; + int64 end_time = 11; + bool closed = 12; +} diff --git a/pkg/pb/bbgo_grpc.pb.go b/pkg/pb/bbgo_grpc.pb.go new file mode 100644 index 0000000..6d40c71 --- /dev/null +++ b/pkg/pb/bbgo_grpc.pb.go @@ -0,0 +1,510 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. + +package pb + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.32.0 or later. +const _ = grpc.SupportPackageIsVersion7 + +// MarketDataServiceClient is the client API for MarketDataService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type MarketDataServiceClient interface { + Subscribe(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (MarketDataService_SubscribeClient, error) + QueryKLines(ctx context.Context, in *QueryKLinesRequest, opts ...grpc.CallOption) (*QueryKLinesResponse, error) +} + +type marketDataServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewMarketDataServiceClient(cc grpc.ClientConnInterface) MarketDataServiceClient { + return &marketDataServiceClient{cc} +} + +func (c *marketDataServiceClient) Subscribe(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (MarketDataService_SubscribeClient, error) { + stream, err := c.cc.NewStream(ctx, &MarketDataService_ServiceDesc.Streams[0], "/qbtrade.MarketDataService/Subscribe", opts...) + if err != nil { + return nil, err + } + x := &marketDataServiceSubscribeClient{stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +type MarketDataService_SubscribeClient interface { + Recv() (*MarketData, error) + grpc.ClientStream +} + +type marketDataServiceSubscribeClient struct { + grpc.ClientStream +} + +func (x *marketDataServiceSubscribeClient) Recv() (*MarketData, error) { + m := new(MarketData) + if err := x.ClientStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + +func (c *marketDataServiceClient) QueryKLines(ctx context.Context, in *QueryKLinesRequest, opts ...grpc.CallOption) (*QueryKLinesResponse, error) { + out := new(QueryKLinesResponse) + err := c.cc.Invoke(ctx, "/qbtrade.MarketDataService/QueryKLines", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// MarketDataServiceServer is the server API for MarketDataService service. +// All implementations must embed UnimplementedMarketDataServiceServer +// for forward compatibility +type MarketDataServiceServer interface { + Subscribe(*SubscribeRequest, MarketDataService_SubscribeServer) error + QueryKLines(context.Context, *QueryKLinesRequest) (*QueryKLinesResponse, error) + mustEmbedUnimplementedMarketDataServiceServer() +} + +// UnimplementedMarketDataServiceServer must be embedded to have forward compatible implementations. +type UnimplementedMarketDataServiceServer struct { +} + +func (UnimplementedMarketDataServiceServer) Subscribe(*SubscribeRequest, MarketDataService_SubscribeServer) error { + return status.Errorf(codes.Unimplemented, "method Subscribe not implemented") +} +func (UnimplementedMarketDataServiceServer) QueryKLines(context.Context, *QueryKLinesRequest) (*QueryKLinesResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method QueryKLines not implemented") +} +func (UnimplementedMarketDataServiceServer) mustEmbedUnimplementedMarketDataServiceServer() {} + +// UnsafeMarketDataServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to MarketDataServiceServer will +// result in compilation errors. +type UnsafeMarketDataServiceServer interface { + mustEmbedUnimplementedMarketDataServiceServer() +} + +func RegisterMarketDataServiceServer(s grpc.ServiceRegistrar, srv MarketDataServiceServer) { + s.RegisterService(&MarketDataService_ServiceDesc, srv) +} + +func _MarketDataService_Subscribe_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(SubscribeRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(MarketDataServiceServer).Subscribe(m, &marketDataServiceSubscribeServer{stream}) +} + +type MarketDataService_SubscribeServer interface { + Send(*MarketData) error + grpc.ServerStream +} + +type marketDataServiceSubscribeServer struct { + grpc.ServerStream +} + +func (x *marketDataServiceSubscribeServer) Send(m *MarketData) error { + return x.ServerStream.SendMsg(m) +} + +func _MarketDataService_QueryKLines_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(QueryKLinesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(MarketDataServiceServer).QueryKLines(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/qbtrade.MarketDataService/QueryKLines", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(MarketDataServiceServer).QueryKLines(ctx, req.(*QueryKLinesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// MarketDataService_ServiceDesc is the grpc.ServiceDesc for MarketDataService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var MarketDataService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "qbtrade.MarketDataService", + HandlerType: (*MarketDataServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "QueryKLines", + Handler: _MarketDataService_QueryKLines_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "Subscribe", + Handler: _MarketDataService_Subscribe_Handler, + ServerStreams: true, + }, + }, + Metadata: "pkg/pb/qbtrade.proto", +} + +// UserDataServiceClient is the client API for UserDataService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type UserDataServiceClient interface { + Subscribe(ctx context.Context, in *UserDataRequest, opts ...grpc.CallOption) (UserDataService_SubscribeClient, error) +} + +type userDataServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewUserDataServiceClient(cc grpc.ClientConnInterface) UserDataServiceClient { + return &userDataServiceClient{cc} +} + +func (c *userDataServiceClient) Subscribe(ctx context.Context, in *UserDataRequest, opts ...grpc.CallOption) (UserDataService_SubscribeClient, error) { + stream, err := c.cc.NewStream(ctx, &UserDataService_ServiceDesc.Streams[0], "/qbtrade.UserDataService/Subscribe", opts...) + if err != nil { + return nil, err + } + x := &userDataServiceSubscribeClient{stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +type UserDataService_SubscribeClient interface { + Recv() (*UserData, error) + grpc.ClientStream +} + +type userDataServiceSubscribeClient struct { + grpc.ClientStream +} + +func (x *userDataServiceSubscribeClient) Recv() (*UserData, error) { + m := new(UserData) + if err := x.ClientStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + +// UserDataServiceServer is the server API for UserDataService service. +// All implementations must embed UnimplementedUserDataServiceServer +// for forward compatibility +type UserDataServiceServer interface { + Subscribe(*UserDataRequest, UserDataService_SubscribeServer) error + mustEmbedUnimplementedUserDataServiceServer() +} + +// UnimplementedUserDataServiceServer must be embedded to have forward compatible implementations. +type UnimplementedUserDataServiceServer struct { +} + +func (UnimplementedUserDataServiceServer) Subscribe(*UserDataRequest, UserDataService_SubscribeServer) error { + return status.Errorf(codes.Unimplemented, "method Subscribe not implemented") +} +func (UnimplementedUserDataServiceServer) mustEmbedUnimplementedUserDataServiceServer() {} + +// UnsafeUserDataServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to UserDataServiceServer will +// result in compilation errors. +type UnsafeUserDataServiceServer interface { + mustEmbedUnimplementedUserDataServiceServer() +} + +func RegisterUserDataServiceServer(s grpc.ServiceRegistrar, srv UserDataServiceServer) { + s.RegisterService(&UserDataService_ServiceDesc, srv) +} + +func _UserDataService_Subscribe_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(UserDataRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(UserDataServiceServer).Subscribe(m, &userDataServiceSubscribeServer{stream}) +} + +type UserDataService_SubscribeServer interface { + Send(*UserData) error + grpc.ServerStream +} + +type userDataServiceSubscribeServer struct { + grpc.ServerStream +} + +func (x *userDataServiceSubscribeServer) Send(m *UserData) error { + return x.ServerStream.SendMsg(m) +} + +// UserDataService_ServiceDesc is the grpc.ServiceDesc for UserDataService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var UserDataService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "qbtrade.UserDataService", + HandlerType: (*UserDataServiceServer)(nil), + Methods: []grpc.MethodDesc{}, + Streams: []grpc.StreamDesc{ + { + StreamName: "Subscribe", + Handler: _UserDataService_Subscribe_Handler, + ServerStreams: true, + }, + }, + Metadata: "pkg/pb/qbtrade.proto", +} + +// TradingServiceClient is the client API for TradingService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type TradingServiceClient interface { + // request-response + SubmitOrder(ctx context.Context, in *SubmitOrderRequest, opts ...grpc.CallOption) (*SubmitOrderResponse, error) + CancelOrder(ctx context.Context, in *CancelOrderRequest, opts ...grpc.CallOption) (*CancelOrderResponse, error) + QueryOrder(ctx context.Context, in *QueryOrderRequest, opts ...grpc.CallOption) (*QueryOrderResponse, error) + QueryOrders(ctx context.Context, in *QueryOrdersRequest, opts ...grpc.CallOption) (*QueryOrdersResponse, error) + QueryTrades(ctx context.Context, in *QueryTradesRequest, opts ...grpc.CallOption) (*QueryTradesResponse, error) +} + +type tradingServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewTradingServiceClient(cc grpc.ClientConnInterface) TradingServiceClient { + return &tradingServiceClient{cc} +} + +func (c *tradingServiceClient) SubmitOrder(ctx context.Context, in *SubmitOrderRequest, opts ...grpc.CallOption) (*SubmitOrderResponse, error) { + out := new(SubmitOrderResponse) + err := c.cc.Invoke(ctx, "/qbtrade.TradingService/SubmitOrder", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *tradingServiceClient) CancelOrder(ctx context.Context, in *CancelOrderRequest, opts ...grpc.CallOption) (*CancelOrderResponse, error) { + out := new(CancelOrderResponse) + err := c.cc.Invoke(ctx, "/qbtrade.TradingService/CancelOrder", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *tradingServiceClient) QueryOrder(ctx context.Context, in *QueryOrderRequest, opts ...grpc.CallOption) (*QueryOrderResponse, error) { + out := new(QueryOrderResponse) + err := c.cc.Invoke(ctx, "/qbtrade.TradingService/QueryOrder", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *tradingServiceClient) QueryOrders(ctx context.Context, in *QueryOrdersRequest, opts ...grpc.CallOption) (*QueryOrdersResponse, error) { + out := new(QueryOrdersResponse) + err := c.cc.Invoke(ctx, "/qbtrade.TradingService/QueryOrders", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *tradingServiceClient) QueryTrades(ctx context.Context, in *QueryTradesRequest, opts ...grpc.CallOption) (*QueryTradesResponse, error) { + out := new(QueryTradesResponse) + err := c.cc.Invoke(ctx, "/qbtrade.TradingService/QueryTrades", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// TradingServiceServer is the server API for TradingService service. +// All implementations must embed UnimplementedTradingServiceServer +// for forward compatibility +type TradingServiceServer interface { + // request-response + SubmitOrder(context.Context, *SubmitOrderRequest) (*SubmitOrderResponse, error) + CancelOrder(context.Context, *CancelOrderRequest) (*CancelOrderResponse, error) + QueryOrder(context.Context, *QueryOrderRequest) (*QueryOrderResponse, error) + QueryOrders(context.Context, *QueryOrdersRequest) (*QueryOrdersResponse, error) + QueryTrades(context.Context, *QueryTradesRequest) (*QueryTradesResponse, error) + mustEmbedUnimplementedTradingServiceServer() +} + +// UnimplementedTradingServiceServer must be embedded to have forward compatible implementations. +type UnimplementedTradingServiceServer struct { +} + +func (UnimplementedTradingServiceServer) SubmitOrder(context.Context, *SubmitOrderRequest) (*SubmitOrderResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SubmitOrder not implemented") +} +func (UnimplementedTradingServiceServer) CancelOrder(context.Context, *CancelOrderRequest) (*CancelOrderResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method CancelOrder not implemented") +} +func (UnimplementedTradingServiceServer) QueryOrder(context.Context, *QueryOrderRequest) (*QueryOrderResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method QueryOrder not implemented") +} +func (UnimplementedTradingServiceServer) QueryOrders(context.Context, *QueryOrdersRequest) (*QueryOrdersResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method QueryOrders not implemented") +} +func (UnimplementedTradingServiceServer) QueryTrades(context.Context, *QueryTradesRequest) (*QueryTradesResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method QueryTrades not implemented") +} +func (UnimplementedTradingServiceServer) mustEmbedUnimplementedTradingServiceServer() {} + +// UnsafeTradingServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to TradingServiceServer will +// result in compilation errors. +type UnsafeTradingServiceServer interface { + mustEmbedUnimplementedTradingServiceServer() +} + +func RegisterTradingServiceServer(s grpc.ServiceRegistrar, srv TradingServiceServer) { + s.RegisterService(&TradingService_ServiceDesc, srv) +} + +func _TradingService_SubmitOrder_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SubmitOrderRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TradingServiceServer).SubmitOrder(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/qbtrade.TradingService/SubmitOrder", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TradingServiceServer).SubmitOrder(ctx, req.(*SubmitOrderRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _TradingService_CancelOrder_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CancelOrderRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TradingServiceServer).CancelOrder(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/qbtrade.TradingService/CancelOrder", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TradingServiceServer).CancelOrder(ctx, req.(*CancelOrderRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _TradingService_QueryOrder_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(QueryOrderRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TradingServiceServer).QueryOrder(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/qbtrade.TradingService/QueryOrder", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TradingServiceServer).QueryOrder(ctx, req.(*QueryOrderRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _TradingService_QueryOrders_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(QueryOrdersRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TradingServiceServer).QueryOrders(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/qbtrade.TradingService/QueryOrders", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TradingServiceServer).QueryOrders(ctx, req.(*QueryOrdersRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _TradingService_QueryTrades_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(QueryTradesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TradingServiceServer).QueryTrades(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/qbtrade.TradingService/QueryTrades", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TradingServiceServer).QueryTrades(ctx, req.(*QueryTradesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// TradingService_ServiceDesc is the grpc.ServiceDesc for TradingService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var TradingService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "qbtrade.TradingService", + HandlerType: (*TradingServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "SubmitOrder", + Handler: _TradingService_SubmitOrder_Handler, + }, + { + MethodName: "CancelOrder", + Handler: _TradingService_CancelOrder_Handler, + }, + { + MethodName: "QueryOrder", + Handler: _TradingService_QueryOrder_Handler, + }, + { + MethodName: "QueryOrders", + Handler: _TradingService_QueryOrders_Handler, + }, + { + MethodName: "QueryTrades", + Handler: _TradingService_QueryTrades_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "pkg/pb/qbtrade.proto", +} diff --git a/pkg/qbtrade/activeorderbook.go b/pkg/qbtrade/activeorderbook.go new file mode 100644 index 0000000..f56cba2 --- /dev/null +++ b/pkg/qbtrade/activeorderbook.go @@ -0,0 +1,485 @@ +package qbtrade + +import ( + "context" + "encoding/json" + "sync" + "time" + + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/sigchan" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const DefaultCancelOrderWaitTime = 20 * time.Millisecond + +// ActiveOrderBook manages the local active order books. +// +//go:generate callbackgen -type ActiveOrderBook +type ActiveOrderBook struct { + Symbol string + orders *types.SyncOrderMap + + newCallbacks []func(o types.Order) + filledCallbacks []func(o types.Order) + canceledCallbacks []func(o types.Order) + + pendingOrderUpdates *types.SyncOrderMap + + // sig is the order update signal + // this signal will be emitted when a new order is added or removed. + C sigchan.Chan + + mu sync.Mutex + + cancelOrderWaitTime time.Duration +} + +func NewActiveOrderBook(symbol string) *ActiveOrderBook { + return &ActiveOrderBook{ + Symbol: symbol, + orders: types.NewSyncOrderMap(), + pendingOrderUpdates: types.NewSyncOrderMap(), + C: sigchan.New(1), + cancelOrderWaitTime: DefaultCancelOrderWaitTime, + } +} + +func (b *ActiveOrderBook) SetCancelOrderWaitTime(duration time.Duration) { + b.cancelOrderWaitTime = duration +} + +func (b *ActiveOrderBook) MarshalJSON() ([]byte, error) { + orders := b.Backup() + return json.Marshal(orders) +} + +func (b *ActiveOrderBook) Backup() []types.SubmitOrder { + return b.orders.Backup() +} + +func (b *ActiveOrderBook) BindStream(stream types.Stream) { + stream.OnOrderUpdate(b.orderUpdateHandler) +} + +func (b *ActiveOrderBook) waitClear( + ctx context.Context, order types.Order, waitTime, timeout time.Duration, +) (bool, error) { + if !b.orders.Exists(order.OrderID) { + return true, nil + } + + timeoutC := time.After(timeout) + for { + select { + case <-time.After(waitTime): + case <-b.C: + } + + clear := !b.orders.Exists(order.OrderID) + + select { + case <-timeoutC: + return clear, nil + + case <-ctx.Done(): + return clear, ctx.Err() + + default: + if clear { + return clear, nil + } + } + } +} + +// waitAllClear waits for the order book be clear (meaning every order is removed) +// if err != nil, it's the context error. +func (b *ActiveOrderBook) waitAllClear(ctx context.Context, waitTime, timeout time.Duration) (bool, error) { + clear := b.NumOfOrders() == 0 + if clear { + return clear, nil + } + + timeoutC := time.After(timeout) + for { + select { + case <-time.After(waitTime): + case <-b.C: + } + + // update clear flag + clear = b.NumOfOrders() == 0 + + select { + case <-timeoutC: + return clear, nil + + case <-ctx.Done(): + return clear, ctx.Err() + + default: + if clear { + return clear, nil + } + } + } +} + +// FastCancel cancels the orders without verification +// It calls the exchange cancel order api and then remove the orders from the active orderbook directly. +func (b *ActiveOrderBook) FastCancel(ctx context.Context, ex types.Exchange, orders ...types.Order) error { + // if no orders are given, set to cancelAll + hasSymbol := b.Symbol != "" + if len(orders) == 0 { + orders = b.Orders() + } else { + // simple check on given input + for _, o := range orders { + if hasSymbol && o.Symbol != b.Symbol { + return errors.New("[ActiveOrderBook] cancel " + b.Symbol + " orderbook with different order symbol: " + o.Symbol) + } + } + } + + // optimize order cancel for back-testing + if IsBackTesting { + return ex.CancelOrders(context.Background(), orders...) + } + + log.Debugf("[ActiveOrderBook] no wait cancelling %s orders...", b.Symbol) + // since ctx might be canceled, we should use background context here + if err := ex.CancelOrders(context.Background(), orders...); err != nil { + log.WithError(err).Errorf("[ActiveOrderBook] no wait can not cancel %s orders", b.Symbol) + } + + for _, o := range orders { + b.orders.Remove(o.OrderID) + } + return nil +} + +// GracefulCancel cancels the active orders gracefully +func (b *ActiveOrderBook) GracefulCancel(ctx context.Context, ex types.Exchange, specifiedOrders ...types.Order) error { + cancelAll := false + orders := specifiedOrders + + // if no orders are given, set to cancelAll + if len(specifiedOrders) == 0 { + orders = b.Orders() + cancelAll = true + } else { + // simple check on given input + hasSymbol := b.Symbol != "" + for _, o := range orders { + if hasSymbol && o.Symbol != b.Symbol { + return errors.New("[ActiveOrderBook] cancel " + b.Symbol + " orderbook with different symbol: " + o.Symbol) + } + } + } + + // optimize order cancel for back-testing + if IsBackTesting { + return ex.CancelOrders(context.Background(), orders...) + } + + log.Debugf("[ActiveOrderBook] gracefully cancelling %s orders...", b.Symbol) + waitTime := b.cancelOrderWaitTime + orderCancelTimeout := 5 * time.Second + + startTime := time.Now() + // ensure every order is canceled + for { + // Some orders in the variable are not created on the server side yet, + // If we cancel these orders directly, we will get an unsent order error + // We wait here for a while for server to create these orders. + // time.Sleep(SentOrderWaitTime) + + // since ctx might be canceled, we should use background context here + if err := ex.CancelOrders(context.Background(), orders...); err != nil { + log.WithError(err).Warnf("[ActiveOrderBook] can not cancel %d %s orders", len(orders), b.Symbol) + } + + log.Debugf("[ActiveOrderBook] waiting %s for %d %s orders to be cancelled...", waitTime, len(orders), b.Symbol) + + if cancelAll { + clear, err := b.waitAllClear(ctx, waitTime, orderCancelTimeout) + if err != nil { + if !errors.Is(err, context.Canceled) { + log.WithError(err).Errorf("order cancel error") + } + + break + } + + if clear { + log.Debugf("[ActiveOrderBook] %d %s orders are canceled", len(orders), b.Symbol) + break + } + + log.Warnf("[ActiveOrderBook] %d/%d %s orders are not cancelled yet", b.NumOfOrders(), len(orders), b.Symbol) + b.Print() + + } else { + existingOrders := b.filterExistingOrders(orders) + if len(existingOrders) == 0 { + log.Debugf("[ActiveOrderBook] orders are canceled") + break + } + } + + // verify the current open orders via the RESTful API + log.Warnf("[ActiveOrderBook] using open orders API to verify the active orders...") + + var symbolOrdersMap = categorizeOrderBySymbol(orders) + var errOccurred bool + var leftOrders types.OrderSlice + for symbol, symbolOrders := range symbolOrdersMap { + openOrders, err := ex.QueryOpenOrders(ctx, symbol) + if err != nil { + errOccurred = true + log.WithError(err).Errorf("can not query %s open orders", symbol) + break + } + + openOrderMap := types.NewOrderMap(openOrders...) + for _, o := range symbolOrders { + // if it's not on the order book (open orders), + // we should remove it from our local side + if !openOrderMap.Exists(o.OrderID) { + b.Remove(o) + } else { + leftOrders.Add(o) + } + } + } + + // if an error occurs, we cannot update the orders because it will result in an empty order slice. + if !errOccurred { + // update order slice for the next try + orders = leftOrders + } + } + + log.Debugf("[ActiveOrderBook] all %s orders are cancelled successfully in %s", b.Symbol, time.Since(startTime)) + return nil +} + +func (b *ActiveOrderBook) orderUpdateHandler(order types.Order) { + if oldOrder, ok := b.Get(order.OrderID); ok { + order.Tag = oldOrder.Tag + order.GroupID = oldOrder.GroupID + } + b.Update(order) +} + +func (b *ActiveOrderBook) Print() { + orders := b.orders.Orders() + orders = types.SortOrdersByPrice(orders, true) + orders.Print() +} + +// Update updates the order by the order status and emit the related events. +// When order is filled, the order will be removed from the internal order storage. +// When order is New or PartiallyFilled, the internal order will be updated according to the latest order update. +// When the order is cancelled, it will be removed from the internal order storage. +func (b *ActiveOrderBook) Update(order types.Order) { + hasSymbol := len(b.Symbol) > 0 + if hasSymbol && order.Symbol != b.Symbol { + return + } + + b.mu.Lock() + if !b.orders.Exists(order.OrderID) { + log.Debugf("[ActiveOrderBook] order #%d %s does not exist, adding it to pending order update", order.OrderID, order.Status) + b.pendingOrderUpdates.Add(order) + b.mu.Unlock() + return + } + + // if order update time is too old, skip it + if previousOrder, ok := b.orders.Get(order.OrderID); ok { + // the arguments ordering is important here + // if we can't detect which is newer, isNewerOrderUpdate returns false + // if you pass two same objects to isNewerOrderUpdate, it returns false + if !isNewerOrderUpdate(order, previousOrder) { + log.Infof("[ActiveOrderBook] order #%d updateTime %s is out of date, skip it", order.OrderID, order.UpdateTime) + b.mu.Unlock() + return + } + } + + switch order.Status { + case types.OrderStatusFilled: + // make sure we have the order and we remove it + removed := b.orders.Remove(order.OrderID) + b.mu.Unlock() + + if removed { + log.Infof("[ActiveOrderBook] order #%d is filled: %s", order.OrderID, order.String()) + b.EmitFilled(order) + } + b.C.Emit() + + case types.OrderStatusPartiallyFilled: + b.orders.Update(order) + b.mu.Unlock() + + case types.OrderStatusNew: + b.orders.Update(order) + b.mu.Unlock() + + b.C.Emit() + + case types.OrderStatusCanceled, types.OrderStatusRejected: + // TODO: note that orders transit to "canceled" may have partially filled + log.Debugf("[ActiveOrderBook] order is %s, removing order %s", order.Status, order) + b.orders.Remove(order.OrderID) + b.mu.Unlock() + + if order.Status == types.OrderStatusCanceled { + b.EmitCanceled(order) + } + b.C.Emit() + + default: + b.mu.Unlock() + log.Warnf("[ActiveOrderBook] unhandled order status: %s", order.Status) + } +} + +func (b *ActiveOrderBook) Add(orders ...types.Order) { + hasSymbol := len(b.Symbol) > 0 + + for _, order := range orders { + if hasSymbol && b.Symbol != order.Symbol { + continue + } + + b.add(order) + } +} + +func isNewerOrderUpdate(a, b types.Order) bool { + // compare state first + switch a.Status { + + case types.OrderStatusCanceled, types.OrderStatusRejected: // canceled is a final state + switch b.Status { + case types.OrderStatusNew, types.OrderStatusPartiallyFilled: + return true + } + + case types.OrderStatusPartiallyFilled: + switch b.Status { + case types.OrderStatusNew: + return true + case types.OrderStatusPartiallyFilled: + // unknown for equal + if a.ExecutedQuantity.Compare(b.ExecutedQuantity) > 0 { + return true + } + + } + + case types.OrderStatusFilled: + switch b.Status { + case types.OrderStatusFilled, types.OrderStatusPartiallyFilled, types.OrderStatusNew: + return true + } + } + + return isNewerOrderUpdateTime(a, b) +} + +func isNewerOrderUpdateTime(a, b types.Order) bool { + au := time.Time(a.UpdateTime) + bu := time.Time(b.UpdateTime) + + if !au.IsZero() && !bu.IsZero() && au.After(bu) { + return true + } + + if !au.IsZero() && bu.IsZero() { + return true + } + + return false +} + +// add the order to the active order book and check the pending order +func (b *ActiveOrderBook) add(order types.Order) { + if pendingOrder, ok := b.pendingOrderUpdates.Get(order.OrderID); ok { + // if the pending order update time is newer than the adding order + // we should use the pending order rather than the adding order. + // if the pending order is older, then we should add the new one, and drop the pending order + log.Debugf("found pending order update: %+v", pendingOrder) + if isNewerOrderUpdate(pendingOrder, order) { + log.Debugf("pending order update is newer: %+v", pendingOrder) + order = pendingOrder + } + + b.orders.Add(order) + b.pendingOrderUpdates.Remove(pendingOrder.OrderID) + + // when using add(order), it's usually a new maker order on the order book. + // so, when it's not status=new, we should trigger order update handler + if order.Status != types.OrderStatusNew { + // emit the order update handle function to trigger callback + b.Update(order) + } + + } else { + b.orders.Add(order) + } +} + +func (b *ActiveOrderBook) Exists(order types.Order) bool { + b.mu.Lock() + defer b.mu.Unlock() + return b.orders.Exists(order.OrderID) +} + +func (b *ActiveOrderBook) Get(orderID uint64) (types.Order, bool) { + return b.orders.Get(orderID) +} + +func (b *ActiveOrderBook) Remove(order types.Order) bool { + b.mu.Lock() + defer b.mu.Unlock() + return b.orders.Remove(order.OrderID) +} + +func (b *ActiveOrderBook) NumOfOrders() int { + return b.orders.Len() +} + +func (b *ActiveOrderBook) Orders() types.OrderSlice { + return b.orders.Orders() +} + +func (b *ActiveOrderBook) Lookup(f func(o types.Order) bool) *types.Order { + return b.orders.Lookup(f) +} + +func (b *ActiveOrderBook) filterExistingOrders(orders []types.Order) (existingOrders types.OrderSlice) { + for _, o := range orders { + if b.Exists(o) { + existingOrders.Add(o) + } + } + + return existingOrders +} + +func categorizeOrderBySymbol(orders types.OrderSlice) map[string]types.OrderSlice { + orderMap := map[string]types.OrderSlice{} + + for _, order := range orders { + orderMap[order.Symbol] = append(orderMap[order.Symbol], order) + } + + return orderMap +} diff --git a/pkg/qbtrade/activeorderbook_callbacks.go b/pkg/qbtrade/activeorderbook_callbacks.go new file mode 100644 index 0000000..f1aa542 --- /dev/null +++ b/pkg/qbtrade/activeorderbook_callbacks.go @@ -0,0 +1,37 @@ +// Code generated by "callbackgen -type ActiveOrderBook"; DO NOT EDIT. + +package qbtrade + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func (b *ActiveOrderBook) OnNew(cb func(o types.Order)) { + b.newCallbacks = append(b.newCallbacks, cb) +} + +func (b *ActiveOrderBook) EmitNew(o types.Order) { + for _, cb := range b.newCallbacks { + cb(o) + } +} + +func (b *ActiveOrderBook) OnFilled(cb func(o types.Order)) { + b.filledCallbacks = append(b.filledCallbacks, cb) +} + +func (b *ActiveOrderBook) EmitFilled(o types.Order) { + for _, cb := range b.filledCallbacks { + cb(o) + } +} + +func (b *ActiveOrderBook) OnCanceled(cb func(o types.Order)) { + b.canceledCallbacks = append(b.canceledCallbacks, cb) +} + +func (b *ActiveOrderBook) EmitCanceled(o types.Order) { + for _, cb := range b.canceledCallbacks { + cb(o) + } +} diff --git a/pkg/qbtrade/activeorderbook_test.go b/pkg/qbtrade/activeorderbook_test.go new file mode 100644 index 0000000..bf2c2b7 --- /dev/null +++ b/pkg/qbtrade/activeorderbook_test.go @@ -0,0 +1,154 @@ +package qbtrade + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + . "git.qtrade.icu/lychiyu/qbtrade/pkg/testing/testhelper" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func TestActiveOrderBook_pendingOrders(t *testing.T) { + now := time.Now() + t1 := now + t2 := now.Add(time.Millisecond) + + ob := NewActiveOrderBook("BTCUSDT") + + filled := false + ob.OnFilled(func(o types.Order) { + filled = true + }) + + quantity := Number("0.01") + orderUpdate1 := types.Order{ + OrderID: 99, + SubmitOrder: types.SubmitOrder{ + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: quantity, + Price: Number(19000.0), + AveragePrice: fixedpoint.Zero, + StopPrice: fixedpoint.Zero, + }, + ExecutedQuantity: Number(0.0), + Status: types.OrderStatusNew, + CreationTime: types.Time(t1), + UpdateTime: types.Time(t1), + } + + orderUpdate2 := types.Order{ + OrderID: 99, + SubmitOrder: types.SubmitOrder{ + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: quantity, + Price: Number(19000.0), + AveragePrice: fixedpoint.Zero, + StopPrice: fixedpoint.Zero, + }, + ExecutedQuantity: quantity, + Status: types.OrderStatusFilled, + CreationTime: types.Time(t1), + UpdateTime: types.Time(t2), + } + + assert.True(t, isNewerOrderUpdate(orderUpdate2, orderUpdate1), "orderUpdate2 should be newer than orderUpdate1") + + // if we received filled order first + // should be added to pending orders + ob.Update(orderUpdate2) + assert.Len(t, ob.pendingOrderUpdates.Orders(), 1) + + o99, ok := ob.pendingOrderUpdates.Get(99) + if assert.True(t, ok) { + assert.Equal(t, types.OrderStatusFilled, o99.Status) + } + + // when adding the older order update to the book, + // it should trigger the filled event once the order is registered to the active order book + ob.Add(orderUpdate1) + assert.True(t, filled, "filled event should be fired") +} + +func Test_RestoreParametersOnUpdateHandler(t *testing.T) { + now := time.Now() + t1 := now + t2 := now.Add(time.Millisecond) + ob := NewActiveOrderBook("BTCUSDT") + + var updatedOrder types.Order + ob.OnFilled(func(o types.Order) { + updatedOrder = o + }) + quantity := Number("0.01") + orderUpdate1 := types.Order{ + OrderID: 99, + SubmitOrder: types.SubmitOrder{ + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: quantity, + Price: Number(19000.0), + AveragePrice: fixedpoint.Zero, + StopPrice: fixedpoint.Zero, + Tag: "tag1", + GroupID: uint32(1), + }, + ExecutedQuantity: Number(0.0), + Status: types.OrderStatusNew, + CreationTime: types.Time(t1), + UpdateTime: types.Time(t1), + } + + orderUpdate2 := types.Order{ + OrderID: 99, + SubmitOrder: types.SubmitOrder{ + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: quantity, + Price: Number(19000.0), + AveragePrice: fixedpoint.Zero, + StopPrice: fixedpoint.Zero, + }, + ExecutedQuantity: quantity, + Status: types.OrderStatusFilled, + CreationTime: types.Time(t1), + UpdateTime: types.Time(t2), + } + ob.add(orderUpdate1) + ob.orderUpdateHandler(orderUpdate2) + assert.Equal(t, "tag1", updatedOrder.Tag) + assert.Equal(t, uint32(1), updatedOrder.GroupID) + +} + +func Test_isNewerUpdate(t *testing.T) { + a := types.Order{ + Status: types.OrderStatusPartiallyFilled, + ExecutedQuantity: number(0.2), + } + b := types.Order{ + Status: types.OrderStatusPartiallyFilled, + ExecutedQuantity: number(0.1), + } + ret := isNewerOrderUpdate(a, b) + assert.True(t, ret) +} + +func Test_isNewerUpdateTime(t *testing.T) { + a := types.Order{ + UpdateTime: types.NewTimeFromUnix(200, 0), + } + b := types.Order{ + UpdateTime: types.NewTimeFromUnix(100, 0), + } + ret := isNewerOrderUpdateTime(a, b) + assert.True(t, ret) +} diff --git a/pkg/qbtrade/backtestfeemode_enumer.go b/pkg/qbtrade/backtestfeemode_enumer.go new file mode 100644 index 0000000..258b9df --- /dev/null +++ b/pkg/qbtrade/backtestfeemode_enumer.go @@ -0,0 +1,117 @@ +// Code generated by "enumer -type=BacktestFeeMode -transform=snake -trimprefix BacktestFeeMode -yaml -json"; DO NOT EDIT. + +package qbtrade + +import ( + "encoding/json" + "fmt" + "strings" +) + +const _BacktestFeeModeName = "quotenativetoken" + +var _BacktestFeeModeIndex = [...]uint8{0, 5, 11, 16} + +const _BacktestFeeModeLowerName = "quotenativetoken" + +func (i BacktestFeeMode) String() string { + if i < 0 || i >= BacktestFeeMode(len(_BacktestFeeModeIndex)-1) { + return fmt.Sprintf("BacktestFeeMode(%d)", i) + } + return _BacktestFeeModeName[_BacktestFeeModeIndex[i]:_BacktestFeeModeIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _BacktestFeeModeNoOp() { + var x [1]struct{} + _ = x[BacktestFeeModeQuote-(0)] + _ = x[BacktestFeeModeNative-(1)] + _ = x[BacktestFeeModeToken-(2)] +} + +var _BacktestFeeModeValues = []BacktestFeeMode{BacktestFeeModeQuote, BacktestFeeModeNative, BacktestFeeModeToken} + +var _BacktestFeeModeNameToValueMap = map[string]BacktestFeeMode{ + _BacktestFeeModeName[0:5]: BacktestFeeModeQuote, + _BacktestFeeModeLowerName[0:5]: BacktestFeeModeQuote, + _BacktestFeeModeName[5:11]: BacktestFeeModeNative, + _BacktestFeeModeLowerName[5:11]: BacktestFeeModeNative, + _BacktestFeeModeName[11:16]: BacktestFeeModeToken, + _BacktestFeeModeLowerName[11:16]: BacktestFeeModeToken, +} + +var _BacktestFeeModeNames = []string{ + _BacktestFeeModeName[0:5], + _BacktestFeeModeName[5:11], + _BacktestFeeModeName[11:16], +} + +// BacktestFeeModeString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func BacktestFeeModeString(s string) (BacktestFeeMode, error) { + if val, ok := _BacktestFeeModeNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _BacktestFeeModeNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to BacktestFeeMode values", s) +} + +// BacktestFeeModeValues returns all values of the enum +func BacktestFeeModeValues() []BacktestFeeMode { + return _BacktestFeeModeValues +} + +// BacktestFeeModeStrings returns a slice of all String values of the enum +func BacktestFeeModeStrings() []string { + strs := make([]string, len(_BacktestFeeModeNames)) + copy(strs, _BacktestFeeModeNames) + return strs +} + +// IsABacktestFeeMode returns "true" if the value is listed in the enum definition. "false" otherwise +func (i BacktestFeeMode) IsABacktestFeeMode() bool { + for _, v := range _BacktestFeeModeValues { + if i == v { + return true + } + } + return false +} + +// MarshalJSON implements the json.Marshaler interface for BacktestFeeMode +func (i BacktestFeeMode) MarshalJSON() ([]byte, error) { + return json.Marshal(i.String()) +} + +// UnmarshalJSON implements the json.Unmarshaler interface for BacktestFeeMode +func (i *BacktestFeeMode) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("BacktestFeeMode should be a string, got %s", data) + } + + var err error + *i, err = BacktestFeeModeString(s) + return err +} + +// MarshalYAML implements a YAML Marshaler for BacktestFeeMode +func (i BacktestFeeMode) MarshalYAML() (interface{}, error) { + return i.String(), nil +} + +// UnmarshalYAML implements a YAML Unmarshaler for BacktestFeeMode +func (i *BacktestFeeMode) UnmarshalYAML(unmarshal func(interface{}) error) error { + var s string + if err := unmarshal(&s); err != nil { + return err + } + + var err error + *i, err = BacktestFeeModeString(s) + return err +} diff --git a/pkg/qbtrade/bootstrap.go b/pkg/qbtrade/bootstrap.go new file mode 100644 index 0000000..065e1fc --- /dev/null +++ b/pkg/qbtrade/bootstrap.go @@ -0,0 +1,70 @@ +package qbtrade + +import ( + "context" + + "github.com/pkg/errors" +) + +// BootstrapEnvironmentLightweight bootstrap the environment in lightweight mode +// - no database configuration +// - no notification +func BootstrapEnvironmentLightweight(ctx context.Context, environ *Environment, userConfig *Config) error { + if err := environ.ConfigureExchangeSessions(userConfig); err != nil { + return errors.Wrap(err, "exchange session configure error") + } + + if userConfig.Logging != nil { + environ.SetLogging(userConfig.Logging) + } + + if userConfig.Persistence != nil { + if err := ConfigurePersistence(ctx, environ, userConfig.Persistence); err != nil { + return errors.Wrap(err, "persistence configure error") + } + } + + if userConfig.Service != nil { + if err := environ.ConfigureService(ctx, userConfig.Service); err != nil { + return err + } + } + + return nil +} + +func BootstrapEnvironment(ctx context.Context, environ *Environment, userConfig *Config) error { + if err := environ.ConfigureDatabase(ctx, userConfig); err != nil { + return err + } + + if err := environ.ConfigureExchangeSessions(userConfig); err != nil { + return errors.Wrap(err, "exchange session configure error") + } + + if userConfig.Logging != nil { + environ.SetLogging(userConfig.Logging) + } + + if userConfig.Persistence != nil { + if err := ConfigurePersistence(ctx, environ, userConfig.Persistence); err != nil { + return errors.Wrap(err, "persistence configure error") + } + } + + if userConfig.Service != nil { + if err := environ.ConfigureService(ctx, userConfig.Service); err != nil { + return err + } + } + + if err := environ.ConfigureNotificationSystem(ctx, userConfig); err != nil { + return errors.Wrap(err, "notification configure error") + } + + return nil +} + +func BootstrapBacktestEnvironment(ctx context.Context, environ *Environment) error { + return environ.ConfigureDatabase(ctx, nil) +} diff --git a/pkg/qbtrade/builder.go b/pkg/qbtrade/builder.go new file mode 100644 index 0000000..c3a6284 --- /dev/null +++ b/pkg/qbtrade/builder.go @@ -0,0 +1,145 @@ +package qbtrade + +import ( + "bytes" + "context" + "fmt" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "runtime" + "text/template" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" +) + +var wrapperTemplate = template.Must(template.New("main").Parse(`// Code generated by qbtrade; DO NOT EDIT. + +package main + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/cmd" + +{{- range .Imports }} + _ "{{ . }}" +{{- end }} +) + +func init() { + qbtrade.SetWrapperBinary() +} + +func main() { + cmd.Execute() +} + +`)) + +// generateRunFile renders the wrapper main.go template +func generateRunFile(filepath string, config *Config, imports []string) error { + var buf = bytes.NewBuffer(nil) + if err := wrapperTemplate.Execute(buf, struct { + Config *Config + Imports []string + }{ + Config: config, + Imports: imports, + }); err != nil { + return err + } + + return ioutil.WriteFile(filepath, buf.Bytes(), 0644) +} + +// compilePackage generates the main.go file of the wrapper package +func compilePackage(packageDir string, userConfig *Config, imports []string) error { + if _, err := os.Stat(packageDir); os.IsNotExist(err) { + if err := os.MkdirAll(packageDir, 0777); err != nil { + return errors.Wrapf(err, "can not create wrapper package directory: %s", packageDir) + } + } + + mainFile := filepath.Join(packageDir, "main.go") + if err := generateRunFile(mainFile, userConfig, imports); err != nil { + return errors.Wrap(err, "compile error") + } + + return nil +} + +// Build builds the qbtrade wrapper binary with the given build target config +func Build(ctx context.Context, userConfig *Config, targetConfig BuildTargetConfig) (string, error) { + // combine global imports and target imports + imports := append(userConfig.Build.Imports, targetConfig.Imports...) + + buildDir := userConfig.Build.BuildDir + if len(buildDir) == 0 { + buildDir = "build" + } + + packageDir, err := ioutil.TempDir(buildDir, "qbtradew-") // with prefix qbtradew + if err != nil { + return "", err + } + + if err := compilePackage(packageDir, userConfig, imports); err != nil { + return "", err + } + + cwd, err := os.Getwd() + if err != nil { + return "", err + } + + var buildEnvs []string + + if targetConfig.OS != runtime.GOOS { + buildEnvs = append(buildEnvs, "GOOS="+targetConfig.OS) + } + + if targetConfig.Arch != runtime.GOARCH { + buildEnvs = append(buildEnvs, "GOARCH="+targetConfig.Arch) + } + + buildTarget := filepath.Join(cwd, packageDir) + + binary := targetConfig.Name + if len(binary) == 0 { + binary = fmt.Sprintf("qbtradew-%s-%s", targetConfig.OS, targetConfig.Arch) + } + + output := filepath.Join(buildDir, binary) + + args := []string{"build", "-tags", "wrapper", "-o", output, buildTarget} + logrus.Debugf("building binary %s from %s: go %v", output, buildTarget, args) + buildCmd := exec.CommandContext(ctx, "go", args...) + buildCmd.Env = append(os.Environ(), buildEnvs...) + buildCmd.Stdout = os.Stdout + buildCmd.Stderr = os.Stderr + if err := buildCmd.Run(); err != nil { + return output, err + } + + return output, os.RemoveAll(packageDir) +} + +// BuildTarget builds the one of the targets. +func BuildTarget(ctx context.Context, userConfig *Config, target BuildTargetConfig) (string, error) { + buildDir := userConfig.Build.BuildDir + if len(buildDir) == 0 { + buildDir = "build" + } + + if _, err := os.Stat(buildDir); os.IsNotExist(err) { + err = os.Mkdir(buildDir, 0777) + if err != nil { + return "", err + } + } + + buildDir = filepath.Join(userConfig.Build.BuildDir, target.Name) + return Build(ctx, userConfig, target) +} diff --git a/pkg/qbtrade/config.go b/pkg/qbtrade/config.go new file mode 100644 index 0000000..d21f576 --- /dev/null +++ b/pkg/qbtrade/config.go @@ -0,0 +1,706 @@ +package qbtrade + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "reflect" + "runtime" + "strings" + + "github.com/pkg/errors" + "gopkg.in/yaml.v3" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype" + "git.qtrade.icu/lychiyu/qbtrade/pkg/dynamic" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/service" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// DefaultFeeRate set the fee rate for most cases +// BINANCE uses 0.1% for both maker and taker +// +// for BNB holders, it's 0.075% for both maker and taker +// +// MAX uses 0.050% for maker and 0.15% for taker +var DefaultFeeRate = fixedpoint.NewFromFloat(0.075 * 0.01) + +type PnLReporterConfig struct { + AverageCostBySymbols datatype.StringSlice `json:"averageCostBySymbols" yaml:"averageCostBySymbols"` + Of datatype.StringSlice `json:"of" yaml:"of"` + When datatype.StringSlice `json:"when" yaml:"when"` +} + +// ExchangeStrategyMount wraps the SingleExchangeStrategy with the ExchangeSession name for mounting +type ExchangeStrategyMount struct { + // Mounts contains the ExchangeSession name to mount + Mounts []string `json:"mounts"` + + // Strategy is the strategy we loaded from config + Strategy SingleExchangeStrategy `json:"strategy"` +} + +func (m *ExchangeStrategyMount) Map() (map[string]interface{}, error) { + strategyID := m.Strategy.ID() + + var params map[string]interface{} + + out, err := json.Marshal(m.Strategy) + if err != nil { + return nil, err + } + + if err := json.Unmarshal(out, ¶ms); err != nil { + return nil, err + } + + return map[string]interface{}{ + "on": m.Mounts, + strategyID: params, + }, nil +} + +type SlackNotification struct { + DefaultChannel string `json:"defaultChannel,omitempty" yaml:"defaultChannel,omitempty"` + ErrorChannel string `json:"errorChannel,omitempty" yaml:"errorChannel,omitempty"` +} + +type SlackNotificationRouting struct { + Trade string `json:"trade,omitempty" yaml:"trade,omitempty"` + Order string `json:"order,omitempty" yaml:"order,omitempty"` + SubmitOrder string `json:"submitOrder,omitempty" yaml:"submitOrder,omitempty"` + PnL string `json:"pnL,omitempty" yaml:"pnL,omitempty"` +} + +type TelegramNotification struct { + Broadcast bool `json:"broadcast" yaml:"broadcast"` +} + +type NotificationSwitches struct { + Trade bool `json:"trade" yaml:"trade"` + Position bool `json:"position" yaml:"position"` + OrderUpdate bool `json:"orderUpdate" yaml:"orderUpdate"` + SubmitOrder bool `json:"submitOrder" yaml:"submitOrder"` +} + +type NotificationConfig struct { + Slack *SlackNotification `json:"slack,omitempty" yaml:"slack,omitempty"` + Telegram *TelegramNotification `json:"telegram,omitempty" yaml:"telegram,omitempty"` + Switches *NotificationSwitches `json:"switches" yaml:"switches"` +} + +type LoggingConfig struct { + Trade bool `json:"trade,omitempty"` + Order bool `json:"order,omitempty"` + Balance bool `json:"balance,omitempty"` + FilledOrderOnly bool `json:"filledOrder,omitempty"` + Fields map[string]interface{} `json:"fields,omitempty"` +} + +type Session struct { + Name string `json:"name,omitempty" yaml:"name,omitempty"` + ExchangeName string `json:"exchange" yaml:"exchange"` + EnvVarPrefix string `json:"envVarPrefix" yaml:"envVarPrefix"` + + Key string `json:"key,omitempty" yaml:"key,omitempty"` + Secret string `json:"secret,omitempty" yaml:"secret,omitempty"` + + PublicOnly bool `json:"publicOnly,omitempty" yaml:"publicOnly"` + Margin bool `json:"margin,omitempty" yaml:"margin,omitempty"` + IsolatedMargin bool `json:"isolatedMargin,omitempty" yaml:"isolatedMargin,omitempty"` + IsolatedMarginSymbol string `json:"isolatedMarginSymbol,omitempty" yaml:"isolatedMarginSymbol,omitempty"` +} + +//go:generate go run github.com/dmarkham/enumer -type=BacktestFeeMode -transform=snake -trimprefix BacktestFeeMode -yaml -json +type BacktestFeeMode int + +const ( + // BackTestFeeModeQuoteFee is designed for clean position but which also counts the fee in the quote balance. + // buy order = quote currency fee + // sell order = quote currency fee + BacktestFeeModeQuote BacktestFeeMode = iota // quote + + // BackTestFeeModeNativeFee is the default crypto exchange fee mode. + // buy order = base currency fee + // sell order = quote currency fee + BacktestFeeModeNative // BackTestFeeMode = "native" + + // BackTestFeeModeFeeToken is the mode which calculates fee from the outside of the balances. + // the fee will not be included in the balances nor the profit. + BacktestFeeModeToken // BackTestFeeMode = "token" +) + +type Backtest struct { + StartTime types.LooseFormatTime `json:"startTime,omitempty" yaml:"startTime,omitempty"` + EndTime *types.LooseFormatTime `json:"endTime,omitempty" yaml:"endTime,omitempty"` + + // RecordTrades is an option, if set to true, back-testing should record the trades into database + RecordTrades bool `json:"recordTrades,omitempty" yaml:"recordTrades,omitempty"` + + // Deprecated: + // Account is deprecated, use Accounts instead + Account map[string]BacktestAccount `json:"account" yaml:"account"` + + FeeMode BacktestFeeMode `json:"feeMode" yaml:"feeMode"` + + Accounts map[string]BacktestAccount `json:"accounts" yaml:"accounts"` + Symbols []string `json:"symbols" yaml:"symbols"` + Sessions []string `json:"sessions" yaml:"sessions"` + + // sync 1 second interval KLines + SyncSecKLines bool `json:"syncSecKLines,omitempty" yaml:"syncSecKLines,omitempty"` +} + +func (b *Backtest) GetAccount(n string) BacktestAccount { + accountConfig, ok := b.Accounts[n] + if ok { + return accountConfig + } + + accountConfig, ok = b.Account[n] + if ok { + return accountConfig + } + + return DefaultBacktestAccount +} + +type BacktestAccount struct { + MakerFeeRate fixedpoint.Value `json:"makerFeeRate,omitempty" yaml:"makerFeeRate,omitempty"` + TakerFeeRate fixedpoint.Value `json:"takerFeeRate,omitempty" yaml:"takerFeeRate,omitempty"` + + Balances BacktestAccountBalanceMap `json:"balances" yaml:"balances"` +} + +var DefaultBacktestAccount = BacktestAccount{ + MakerFeeRate: fixedpoint.MustNewFromString("0.050%"), + TakerFeeRate: fixedpoint.MustNewFromString("0.075%"), + Balances: BacktestAccountBalanceMap{ + "USDT": fixedpoint.NewFromFloat(10000), + }, +} + +type BA BacktestAccount + +func (b *BacktestAccount) UnmarshalYAML(value *yaml.Node) error { + bb := &BA{MakerFeeRate: DefaultFeeRate, TakerFeeRate: DefaultFeeRate} + if err := value.Decode(bb); err != nil { + return err + } + *b = BacktestAccount(*bb) + return nil +} + +func (b *BacktestAccount) UnmarshalJSON(input []byte) error { + bb := &BA{MakerFeeRate: DefaultFeeRate, TakerFeeRate: DefaultFeeRate} + if err := json.Unmarshal(input, bb); err != nil { + return err + } + *b = BacktestAccount(*bb) + return nil +} + +type BacktestAccountBalanceMap map[string]fixedpoint.Value + +func (m BacktestAccountBalanceMap) BalanceMap() types.BalanceMap { + balances := make(types.BalanceMap) + for currency, value := range m { + balances[currency] = types.Balance{ + Currency: currency, + Available: value, + Locked: fixedpoint.Zero, + } + } + return balances +} + +type PersistenceConfig struct { + Redis *service.RedisPersistenceConfig `json:"redis,omitempty" yaml:"redis,omitempty"` + Json *service.JsonPersistenceConfig `json:"json,omitempty" yaml:"json,omitempty"` +} + +type BuildTargetConfig struct { + Name string `json:"name" yaml:"name"` + Arch string `json:"arch" yaml:"arch"` + OS string `json:"os" yaml:"os"` + LDFlags datatype.StringSlice `json:"ldflags,omitempty" yaml:"ldflags,omitempty"` + GCFlags datatype.StringSlice `json:"gcflags,omitempty" yaml:"gcflags,omitempty"` + Imports []string `json:"imports,omitempty" yaml:"imports,omitempty"` +} + +type BuildConfig struct { + BuildDir string `json:"buildDir,omitempty" yaml:"buildDir,omitempty"` + Imports []string `json:"imports,omitempty" yaml:"imports,omitempty"` + Targets []BuildTargetConfig `json:"targets,omitempty" yaml:"targets,omitempty"` +} + +func GetNativeBuildTargetConfig() BuildTargetConfig { + return BuildTargetConfig{ + Name: "qbtradew", + Arch: runtime.GOARCH, + OS: runtime.GOOS, + } +} + +type SyncSymbol struct { + Symbol string `json:"symbol" yaml:"symbol"` + Session string `json:"session" yaml:"session"` +} + +func (ss *SyncSymbol) UnmarshalYAML(unmarshal func(a interface{}) error) (err error) { + var s string + if err = unmarshal(&s); err == nil { + aa := strings.SplitN(s, ":", 2) + if len(aa) > 1 { + ss.Session = aa[0] + ss.Symbol = aa[1] + } else { + ss.Symbol = aa[0] + } + return nil + } + + type localSyncSymbol SyncSymbol + var ssNew localSyncSymbol + if err = unmarshal(&ssNew); err == nil { + *ss = SyncSymbol(ssNew) + return nil + } + + return err +} + +func categorizeSyncSymbol(slice []SyncSymbol) (map[string][]string, []string) { + var rest []string + var m = make(map[string][]string) + for _, ss := range slice { + if len(ss.Session) > 0 { + m[ss.Session] = append(m[ss.Session], ss.Symbol) + } else { + rest = append(rest, ss.Symbol) + } + } + return m, rest +} + +type SyncConfig struct { + // Sessions to sync, if ignored, all defined sessions will sync + Sessions []string `json:"sessions,omitempty" yaml:"sessions,omitempty"` + + // Symbols is the list of session:symbol pair to sync, if ignored, symbols wlll be discovered by your existing crypto balances + // Valid formats are: {session}:{symbol}, {symbol} or in YAML object form {symbol: "BTCUSDT", session:"max" } + Symbols []SyncSymbol `json:"symbols,omitempty" yaml:"symbols,omitempty"` + + // DepositHistory is for syncing deposit history + DepositHistory bool `json:"depositHistory" yaml:"depositHistory"` + + // WithdrawHistory is for syncing withdraw history + WithdrawHistory bool `json:"withdrawHistory" yaml:"withdrawHistory"` + + // RewardHistory is for syncing reward history + RewardHistory bool `json:"rewardHistory" yaml:"rewardHistory"` + + // MarginHistory is for syncing margin related history: loans, repays, interests and liquidations + MarginHistory bool `json:"marginHistory" yaml:"marginHistory"` + + MarginAssets []string `json:"marginAssets" yaml:"marginAssets"` + + // Since is the date where you want to start syncing data + Since *types.LooseFormatTime `json:"since,omitempty"` + + // UserDataStream is for real-time sync with websocket user data stream + UserDataStream *struct { + Trades bool `json:"trades,omitempty" yaml:"trades,omitempty"` + FilledOrders bool `json:"filledOrders,omitempty" yaml:"filledOrders,omitempty"` + } `json:"userDataStream,omitempty" yaml:"userDataStream,omitempty"` +} + +type GoogleSpreadSheetServiceConfig struct { + JsonTokenFile string `json:"jsonTokenFile" yaml:"jsonTokenFile"` + SpreadSheetID string `json:"spreadSheetId" yaml:"spreadSheetId"` +} + +type ServiceConfig struct { + GoogleSpreadSheetService *GoogleSpreadSheetServiceConfig `json:"googleSpreadSheet" yaml:"googleSpreadSheet"` +} + +type DatabaseConfig struct { + Driver string `json:"driver" yaml:"driver"` + DSN string `json:"dsn" yaml:"dsn"` + + ExtraMigrationPackages []string `json:"extraMigrationPackages" yaml:"extraMigrationPackages"` +} + +type EnvironmentConfig struct { + DisableDefaultKLineSubscription bool `json:"disableDefaultKLineSubscription"` + DisableHistoryKLinePreload bool `json:"disableHistoryKLinePreload"` + + // DisableStartUpBalanceQuery disables the balance query in the startup process + // which initializes the session.Account with the QueryAccount method. + DisableStartupBalanceQuery bool `json:"disableStartupBalanceQuery"` + + DisableSessionTradeBuffer bool `json:"disableSessionTradeBuffer"` + + DisableMarketDataStore bool `json:"disableMarketDataStore"` + + MaxSessionTradeBufferSize int `json:"maxSessionTradeBufferSize"` +} + +type Config struct { + Build *BuildConfig `json:"build,omitempty" yaml:"build,omitempty"` + + // Imports is deprecated + // Deprecated: use BuildConfig instead + Imports []string `json:"imports,omitempty" yaml:"imports,omitempty"` + + Backtest *Backtest `json:"backtest,omitempty" yaml:"backtest,omitempty"` + + Sync *SyncConfig `json:"sync,omitempty" yaml:"sync,omitempty"` + + Notifications *NotificationConfig `json:"notifications,omitempty" yaml:"notifications,omitempty"` + + Persistence *PersistenceConfig `json:"persistence,omitempty" yaml:"persistence,omitempty"` + + Service *ServiceConfig `json:"services,omitempty" yaml:"services,omitempty"` + + DatabaseConfig *DatabaseConfig `json:"database,omitempty" yaml:"database,omitempty"` + + Environment *EnvironmentConfig `json:"environment,omitempty" yaml:"environment,omitempty"` + + Sessions map[string]*ExchangeSession `json:"sessions,omitempty" yaml:"sessions,omitempty"` + + RiskControls *RiskControls `json:"riskControls,omitempty" yaml:"riskControls,omitempty"` + + Logging *LoggingConfig `json:"logging,omitempty"` + + ExchangeStrategies []ExchangeStrategyMount `json:"-" yaml:"-"` + CrossExchangeStrategies []CrossExchangeStrategy `json:"-" yaml:"-"` + + PnLReporters []PnLReporterConfig `json:"reportPnL,omitempty" yaml:"reportPnL,omitempty"` +} + +func (c *Config) Map() (map[string]interface{}, error) { + text, err := json.Marshal(c) + if err != nil { + return nil, err + } + + var data map[string]interface{} + err = json.Unmarshal(text, &data) + if err != nil { + return nil, err + } + + // convert strategy config back to the DSL format + var exchangeStrategies []map[string]interface{} + for _, m := range c.ExchangeStrategies { + params, err := m.Map() + if err != nil { + return nil, err + } + + exchangeStrategies = append(exchangeStrategies, params) + } + + if len(exchangeStrategies) > 0 { + data["exchangeStrategies"] = exchangeStrategies + } + + var crossExchangeStrategies []map[string]interface{} + for _, st := range c.CrossExchangeStrategies { + strategyID := st.ID() + + var params Stash + + out, err := json.Marshal(st) + if err != nil { + return nil, err + } + + if err := json.Unmarshal(out, ¶ms); err != nil { + return nil, err + } + + crossExchangeStrategies = append(crossExchangeStrategies, map[string]interface{}{ + strategyID: params, + }) + } + + if len(crossExchangeStrategies) > 0 { + data["crossExchangeStrategies"] = crossExchangeStrategies + } + + return data, err +} + +func (c *Config) YAML() ([]byte, error) { + m, err := c.Map() + if err != nil { + return nil, err + } + + var buf bytes.Buffer + var enc = yaml.NewEncoder(&buf) + enc.SetIndent(2) + err = enc.Encode(m) + return buf.Bytes(), err +} + +func (c *Config) GetSignature() string { + var s string + + var ps []string + + // for single exchange strategy + if len(c.ExchangeStrategies) == 1 && len(c.CrossExchangeStrategies) == 0 { + mount := c.ExchangeStrategies[0].Mounts[0] + ps = append(ps, mount) + + strategy := c.ExchangeStrategies[0].Strategy + + id := strategy.ID() + ps = append(ps, id) + + if symbol, ok := dynamic.LookupSymbolField(reflect.ValueOf(strategy)); ok { + ps = append(ps, symbol) + } + } + + startTime := c.Backtest.StartTime.Time() + ps = append(ps, startTime.Format("2006-01-02")) + + if c.Backtest.EndTime != nil { + endTime := c.Backtest.EndTime.Time() + ps = append(ps, endTime.Format("2006-01-02")) + } + + s = strings.Join(ps, "_") + return s +} + +type Stash map[string]interface{} + +func loadStash(config []byte) (Stash, error) { + stash := make(Stash) + if err := yaml.Unmarshal(config, stash); err != nil { + return nil, err + } + + return stash, nil +} + +func LoadBuildConfig(configFile string) (*Config, error) { + var config Config + + content, err := ioutil.ReadFile(configFile) + if err != nil { + return nil, err + } + + if err := yaml.Unmarshal(content, &config); err != nil { + return nil, err + } + + // for backward compatible + if config.Build == nil { + if len(config.Imports) > 0 { + config.Build = &BuildConfig{ + BuildDir: "build", + Imports: config.Imports, + Targets: []BuildTargetConfig{ + {Name: "qbtradew-amd64-darwin", Arch: "amd64", OS: "darwin"}, + {Name: "qbtradew-amd64-linux", Arch: "amd64", OS: "linux"}, + }, + } + } + } + + return &config, nil +} + +// Load parses the config +func Load(configFile string, loadStrategies bool) (*Config, error) { + var config Config + + content, err := ioutil.ReadFile(configFile) + if err != nil { + return nil, err + } + + if err := yaml.Unmarshal(content, &config); err != nil { + return nil, err + } + + // for backward compatible + if config.Build == nil { + config.Build = &BuildConfig{ + BuildDir: "build", + Imports: config.Imports, + Targets: []BuildTargetConfig{ + {Name: "qbtradew-amd64-darwin", Arch: "amd64", OS: "darwin"}, + {Name: "qbtradew-amd64-linux", Arch: "amd64", OS: "linux"}, + }, + } + } + + stash, err := loadStash(content) + if err != nil { + return nil, err + } + + if loadStrategies { + if err := loadExchangeStrategies(&config, stash); err != nil { + return nil, err + } + + if err := loadCrossExchangeStrategies(&config, stash); err != nil { + return nil, err + } + } + + return &config, nil +} + +func loadCrossExchangeStrategies(config *Config, stash Stash) (err error) { + exchangeStrategiesConf, ok := stash["crossExchangeStrategies"] + if !ok { + return nil + } + + if len(LoadedCrossExchangeStrategies) == 0 { + return errors.New("no cross exchange strategy is registered") + } + + configList, ok := exchangeStrategiesConf.([]interface{}) + if !ok { + return errors.New("expecting list in crossExchangeStrategies") + } + + for _, entry := range configList { + configStash, ok := entry.(Stash) + if !ok { + return fmt.Errorf("strategy config should be a map, given: %T %+v", entry, entry) + } + + for id, conf := range configStash { + // look up the real struct type + if st, ok := LoadedCrossExchangeStrategies[id]; ok { + val, err := reUnmarshal(conf, st) + if err != nil { + return err + } + + config.CrossExchangeStrategies = append(config.CrossExchangeStrategies, val.(CrossExchangeStrategy)) + } + } + } + + return nil +} + +func NewStrategyFromMap(id string, conf interface{}) (SingleExchangeStrategy, error) { + if st, ok := LoadedExchangeStrategies[id]; ok { + val, err := reUnmarshal(conf, st) + if err != nil { + return nil, err + } + return val.(SingleExchangeStrategy), nil + } + + return nil, fmt.Errorf("strategy %s not found", id) +} + +func loadExchangeStrategies(config *Config, stash Stash) (err error) { + exchangeStrategiesConf, ok := stash["exchangeStrategies"] + if !ok { + exchangeStrategiesConf, ok = stash["strategies"] + if !ok { + return nil + } + } + + if len(LoadedExchangeStrategies) == 0 { + return errors.New("no exchange strategy is registered") + } + + configList, ok := exchangeStrategiesConf.([]interface{}) + if !ok { + return errors.New("expecting list in exchangeStrategies") + } + + for _, entry := range configList { + configStash, ok := entry.(Stash) + if !ok { + return fmt.Errorf("strategy config should be a map, given: %T %+v", entry, entry) + } + + var mounts []string + if val, ok := configStash["on"]; ok { + switch tv := val.(type) { + + case []string: + mounts = append(mounts, tv...) + + case string: + mounts = append(mounts, tv) + + case []interface{}: + for _, f := range tv { + s, ok := f.(string) + if !ok { + return fmt.Errorf("%+v (%T) is not a string", f, f) + } + + mounts = append(mounts, s) + } + + default: + return fmt.Errorf("unexpected mount type: %T value: %+v", val, val) + } + } + for id, conf := range configStash { + + // look up the real struct type + if _, ok := LoadedExchangeStrategies[id]; ok { + st, err := NewStrategyFromMap(id, conf) + if err != nil { + return err + } + + config.ExchangeStrategies = append(config.ExchangeStrategies, ExchangeStrategyMount{ + Mounts: mounts, + Strategy: st, + }) + } else if id != "on" && id != "off" { + // Show error when we didn't find the Strategy + return fmt.Errorf("strategy %s in config not found", id) + } + } + } + + return nil +} + +func reUnmarshal(conf interface{}, tpe interface{}) (interface{}, error) { + // get the type "*Strategy" + rt := reflect.TypeOf(tpe) + + // allocate new object from the given type + val := reflect.New(rt) + + // now we have &(*Strategy) -> **Strategy + valRef := val.Interface() + + plain, err := json.Marshal(conf) + if err != nil { + return nil, err + } + + if err := json.Unmarshal(plain, valRef); err != nil { + return nil, errors.Wrapf(err, "json parsing error, given payload: %s", plain) + } + + return val.Elem().Interface(), nil +} diff --git a/pkg/qbtrade/config_test.go b/pkg/qbtrade/config_test.go new file mode 100644 index 0000000..403ee0a --- /dev/null +++ b/pkg/qbtrade/config_test.go @@ -0,0 +1,274 @@ +package qbtrade + +import ( + "context" + "io/ioutil" + "testing" + + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +func init() { + RegisterStrategy("test", &TestStrategy{}) +} + +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 (s *TestStrategy) ID() string { + return "test" +} + +func (s *TestStrategy) Run(ctx context.Context, orderExecutor OrderExecutor, session *ExchangeSession) error { + return nil +} + +func TestLoadConfig(t *testing.T) { + type args struct { + configFile string + } + + tests := []struct { + name string + args args + wantErr bool + f func(t *testing.T, config *Config) + }{ + { + name: "notification", + args: args{configFile: "testdata/notification.yaml"}, + wantErr: false, + f: func(t *testing.T, config *Config) { + assert.NotNil(t, config.Notifications) + assert.Equal(t, "#dev-qbtrade", config.Notifications.Slack.DefaultChannel) + assert.Equal(t, "#error", config.Notifications.Slack.ErrorChannel) + }, + }, + + { + name: "strategy", + args: args{configFile: "testdata/strategy.yaml"}, + wantErr: false, + f: func(t *testing.T, config *Config) { + assert.Len(t, config.ExchangeStrategies, 1) + assert.Equal(t, []ExchangeStrategyMount{{ + Mounts: []string{"binance"}, + Strategy: &TestStrategy{ + Symbol: "BTCUSDT", + Interval: "1m", + BaseQuantity: fixedpoint.NewFromFloat(0.1), + MaxAssetQuantity: fixedpoint.NewFromFloat(1.1), + MinDropPercentage: fixedpoint.NewFromFloat(-0.05), + }, + }}, config.ExchangeStrategies) + + m, err := config.Map() + assert.NoError(t, err) + assert.Equal(t, map[string]interface{}{ + "sessions": map[string]interface{}{ + "max": map[string]interface{}{ + "exchange": "max", + "envVarPrefix": "MAX", + "takerFeeRate": 0., + "makerFeeRate": 0., + "modifyOrderAmountForFee": false, + }, + "binance": map[string]interface{}{ + "exchange": "binance", + "envVarPrefix": "BINANCE", + "takerFeeRate": 0., + "makerFeeRate": 0., + "modifyOrderAmountForFee": false, + }, + "ftx": map[string]interface{}{ + "exchange": "ftx", + "envVarPrefix": "FTX", + "takerFeeRate": 0., + "makerFeeRate": 0., + "modifyOrderAmountForFee": true, + }, + }, + "build": map[string]interface{}{ + "buildDir": "build", + "targets": []interface{}{ + map[string]interface{}{ + "name": "qbtradew-amd64-darwin", + "arch": "amd64", + "os": "darwin", + }, + map[string]interface{}{ + "name": "qbtradew-amd64-linux", + "arch": "amd64", + "os": "linux", + }, + }, + }, + "exchangeStrategies": []map[string]interface{}{ + { + "on": []string{"binance"}, + "test": map[string]interface{}{ + "symbol": "BTCUSDT", + "baseQuantity": 0.1, + "interval": "1m", + "maxAssetQuantity": 1.1, + "minDropPercentage": -0.05, + }, + }, + }, + }, m) + + yamlText, err := config.YAML() + assert.NoError(t, err) + + yamlTextSource, err := ioutil.ReadFile("testdata/strategy.yaml") + assert.NoError(t, err) + + var sourceMap map[string]interface{} + err = yaml.Unmarshal(yamlTextSource, &sourceMap) + assert.NoError(t, err) + delete(sourceMap, "build") + + var actualMap map[string]interface{} + err = yaml.Unmarshal(yamlText, &actualMap) + assert.NoError(t, err) + delete(actualMap, "build") + + assert.Equal(t, sourceMap, actualMap) + }, + }, + + { + name: "persistence", + args: args{configFile: "testdata/persistence.yaml"}, + wantErr: false, + f: func(t *testing.T, config *Config) { + assert.NotNil(t, config.Persistence) + assert.NotNil(t, config.Persistence.Redis) + assert.NotNil(t, config.Persistence.Json) + }, + }, + + { + name: "order_executor", + args: args{configFile: "testdata/order_executor.yaml"}, + wantErr: false, + f: func(t *testing.T, config *Config) { + assert.Len(t, config.Sessions, 2) + + session, ok := config.Sessions["max"] + assert.True(t, ok) + assert.NotNil(t, session) + + riskControls := config.RiskControls + assert.NotNil(t, riskControls) + assert.NotNil(t, riskControls.SessionBasedRiskControl) + + conf, ok := riskControls.SessionBasedRiskControl["max"] + assert.True(t, ok) + assert.NotNil(t, conf) + assert.NotNil(t, conf.OrderExecutor) + assert.NotNil(t, conf.OrderExecutor.BySymbol) + + executorConf, ok := conf.OrderExecutor.BySymbol["BTCUSDT"] + assert.True(t, ok) + assert.NotNil(t, executorConf) + }, + }, + { + name: "backtest", + args: args{configFile: "testdata/backtest.yaml"}, + wantErr: false, + f: func(t *testing.T, config *Config) { + assert.Len(t, config.ExchangeStrategies, 1) + assert.NotNil(t, config.Backtest) + assert.NotNil(t, config.Backtest.Account) + assert.NotNil(t, config.Backtest.Account["binance"].Balances) + assert.Len(t, config.Backtest.Account["binance"].Balances, 2) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config, err := Load(tt.args.configFile, true) + if err != nil { + t.Errorf("Load() error = %v", err) + return + } else { + if tt.wantErr { + t.Errorf("Load() error = %v, wantErr %v", err, tt.wantErr) + return + } + } + + assert.NotNil(t, config) + + if tt.f != nil { + tt.f(t, config) + } + }) + } +} + +func TestSyncSymbol(t *testing.T) { + t.Run("symbol", func(t *testing.T) { + var ss []SyncSymbol + var err = yaml.Unmarshal([]byte(`- BTCUSDT`), &ss) + assert.NoError(t, err) + assert.Equal(t, []SyncSymbol{ + {Symbol: "BTCUSDT"}, + }, ss) + }) + + t.Run("session:symbol", func(t *testing.T) { + var ss []SyncSymbol + var err = yaml.Unmarshal([]byte(`- max:BTCUSDT`), &ss) + assert.NoError(t, err) + assert.Equal(t, []SyncSymbol{ + {Session: "max", Symbol: "BTCUSDT"}, + }, ss) + }) + + t.Run("object", func(t *testing.T) { + var ss []SyncSymbol + var err = yaml.Unmarshal([]byte(`- { session: "max", symbol: "BTCUSDT" }`), &ss) + assert.NoError(t, err) + assert.Equal(t, []SyncSymbol{ + {Session: "max", Symbol: "BTCUSDT"}, + }, ss) + }) +} + +func TestBackTestFeeMode(t *testing.T) { + var mode BacktestFeeMode + var err = yaml.Unmarshal([]byte(`quote`), &mode) + assert.NoError(t, err) + assert.Equal(t, BacktestFeeModeQuote, mode) +} + +func Test_categorizeSyncSymbol(t *testing.T) { + var ss []SyncSymbol + var err = yaml.Unmarshal([]byte(` +- BTCUSDT +- ETHUSDT +- max:MAXUSDT +- max:USDTTWD +- binance:BNBUSDT +`), &ss) + assert.NoError(t, err) + assert.NotEmpty(t, ss) + + sm, rest := categorizeSyncSymbol(ss) + assert.NotEmpty(t, rest) + assert.NotEmpty(t, sm) + assert.Equal(t, []string{"MAXUSDT", "USDTTWD"}, sm["max"]) + assert.Equal(t, []string{"BNBUSDT"}, sm["binance"]) +} diff --git a/pkg/qbtrade/consts.go b/pkg/qbtrade/consts.go new file mode 100644 index 0000000..e85b5a9 --- /dev/null +++ b/pkg/qbtrade/consts.go @@ -0,0 +1,5 @@ +package qbtrade + +import "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + +var one = fixedpoint.One diff --git a/pkg/qbtrade/doc.go b/pkg/qbtrade/doc.go new file mode 100644 index 0000000..9031c88 --- /dev/null +++ b/pkg/qbtrade/doc.go @@ -0,0 +1,3 @@ +// Package qbtrade provides the core qbtrade API for strategies + +package qbtrade diff --git a/pkg/qbtrade/environment.go b/pkg/qbtrade/environment.go new file mode 100644 index 0000000..aae64c4 --- /dev/null +++ b/pkg/qbtrade/environment.go @@ -0,0 +1,1038 @@ +package qbtrade + +import ( + "bytes" + "context" + "fmt" + "image/png" + "io/ioutil" + stdlog "log" + "math/rand" + "os" + "strings" + "sync" + "time" + + "github.com/pkg/errors" + "github.com/pquerna/otp" + log "github.com/sirupsen/logrus" + "github.com/slack-go/slack" + "github.com/spf13/viper" + "gopkg.in/tucnak/telebot.v2" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/interact" + "git.qtrade.icu/lychiyu/qbtrade/pkg/notifier/slacknotifier" + "git.qtrade.icu/lychiyu/qbtrade/pkg/notifier/telegramnotifier" + "git.qtrade.icu/lychiyu/qbtrade/pkg/service" + googleservice "git.qtrade.icu/lychiyu/qbtrade/pkg/service/google" + "git.qtrade.icu/lychiyu/qbtrade/pkg/slack/slacklog" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +func init() { + // randomize pulling + rand.Seed(time.Now().UnixNano()) +} + +// IsBackTesting is a global variable that indicates the current environment is back-test or not. +var IsBackTesting = false + +var BackTestService *service.BacktestService + +func SetBackTesting(s *service.BacktestService) { + BackTestService = s + IsBackTesting = s != nil +} + +var LoadedExchangeStrategies = make(map[string]SingleExchangeStrategy) +var LoadedCrossExchangeStrategies = make(map[string]CrossExchangeStrategy) + +func RegisterStrategy(key string, s interface{}) { + loaded := 0 + if d, ok := s.(SingleExchangeStrategy); ok { + LoadedExchangeStrategies[key] = d + loaded++ + } + + if d, ok := s.(CrossExchangeStrategy); ok { + LoadedCrossExchangeStrategies[key] = d + loaded++ + } + + if loaded == 0 { + panic(fmt.Errorf("%T does not implement SingleExchangeStrategy or CrossExchangeStrategy", s)) + } +} + +var emptyTime time.Time + +type SyncStatus int + +const ( + SyncNotStarted SyncStatus = iota + Syncing + SyncDone +) + +// Environment presents the real exchange data layer +type Environment struct { + // built-in service + DatabaseService *service.DatabaseService + OrderService *service.OrderService + TradeService *service.TradeService + ProfitService *service.ProfitService + PositionService *service.PositionService + BacktestService *service.BacktestService + RewardService *service.RewardService + MarginService *service.MarginService + SyncService *service.SyncService + AccountService *service.AccountService + WithdrawService *service.WithdrawService + DepositService *service.DepositService + PersistentService *service.PersistenceServiceFacade + + // external services + GoogleSpreadSheetService *googleservice.SpreadSheetService + + // startTime is the time of start point (which is used in the backtest) + startTime time.Time + + // syncStartTime is the time point we want to start the sync (for trades and orders) + syncStartTime time.Time + syncMutex sync.Mutex + + syncStatusMutex sync.Mutex + syncStatus SyncStatus + syncConfig *SyncConfig + + loggingConfig *LoggingConfig + environmentConfig *EnvironmentConfig + + sessions map[string]*ExchangeSession +} + +func NewEnvironment() *Environment { + now := time.Now() + return &Environment{ + // default trade scan time + syncStartTime: now.AddDate(-1, 0, 0), // defaults to sync from 1 year ago + sessions: make(map[string]*ExchangeSession), + startTime: now, + + syncStatus: SyncNotStarted, + } +} + +func (environ *Environment) Logger() log.FieldLogger { + if environ.loggingConfig != nil && len(environ.loggingConfig.Fields) > 0 { + return log.WithFields(environ.loggingConfig.Fields) + } + + return log.StandardLogger() +} + +func (environ *Environment) Session(name string) (*ExchangeSession, bool) { + s, ok := environ.sessions[name] + return s, ok +} + +func (environ *Environment) Sessions() map[string]*ExchangeSession { + return environ.sessions +} + +func (environ *Environment) SetLogging(config *LoggingConfig) { + environ.loggingConfig = config +} + +func (environ *Environment) SelectSessions(names ...string) map[string]*ExchangeSession { + if len(names) == 0 { + return environ.sessions + } + + sessions := make(map[string]*ExchangeSession) + for _, name := range names { + if s, ok := environ.Session(name); ok { + sessions[name] = s + } + } + + return sessions +} + +func (environ *Environment) ConfigureDatabase(ctx context.Context, config *Config) error { + // configureDB configures the database service based on the environment variable + var dbDriver string + var dbDSN string + var extraPkgNames []string + + if config != nil && config.DatabaseConfig != nil { + dbDriver = config.DatabaseConfig.Driver + dbDSN = config.DatabaseConfig.DSN + extraPkgNames = config.DatabaseConfig.ExtraMigrationPackages + } + + if val, ok := os.LookupEnv("DB_DRIVER"); ok { + dbDriver = val + } + + if val, ok := os.LookupEnv("DB_DSN"); ok { + dbDSN = val + } else if val, ok := os.LookupEnv("SQLITE3_DSN"); ok && (dbDriver == "" || dbDriver == "sqlite3") { + dbDSN = val + dbDriver = "sqlite3" + } else if val, ok := os.LookupEnv("MYSQL_URL"); ok && (dbDriver == "" || dbDriver == "mysql") { + dbDSN = val + dbDriver = "mysql" + } + + // database is optional + if dbDriver == "" || dbDSN == "" { + return nil + } + + return environ.ConfigureDatabaseDriver(ctx, dbDriver, dbDSN, extraPkgNames...) +} + +func (environ *Environment) ConfigureDatabaseDriver(ctx context.Context, driver string, dsn string, extraPkgNames ...string) error { + environ.DatabaseService = service.NewDatabaseService(driver, dsn) + environ.DatabaseService.AddMigrationPackages(extraPkgNames...) + + err := environ.DatabaseService.Connect() + if err != nil { + return err + } + + if err := environ.DatabaseService.Upgrade(ctx); err != nil { + return err + } + + // get the db connection pool object to create other services + db := environ.DatabaseService.DB + environ.OrderService = &service.OrderService{DB: db} + environ.TradeService = &service.TradeService{DB: db} + environ.RewardService = &service.RewardService{DB: db} + environ.AccountService = &service.AccountService{DB: db} + environ.ProfitService = &service.ProfitService{DB: db} + environ.PositionService = &service.PositionService{DB: db} + environ.MarginService = &service.MarginService{DB: db} + environ.WithdrawService = &service.WithdrawService{DB: db} + environ.DepositService = &service.DepositService{DB: db} + environ.SyncService = &service.SyncService{ + TradeService: environ.TradeService, + OrderService: environ.OrderService, + RewardService: environ.RewardService, + MarginService: environ.MarginService, + WithdrawService: &service.WithdrawService{DB: db}, + DepositService: &service.DepositService{DB: db}, + } + + return nil +} + +// AddExchangeSession adds the existing exchange session or pre-created exchange session +func (environ *Environment) AddExchangeSession(name string, session *ExchangeSession) *ExchangeSession { + environ.sessions[name] = session + return session +} + +// AddExchange adds the given exchange with the session name, this is the default +func (environ *Environment) AddExchange(name string, exchange types.Exchange) (session *ExchangeSession) { + session = NewExchangeSession(name, exchange) + return environ.AddExchangeSession(name, session) +} + +func (environ *Environment) ConfigureService(ctx context.Context, srvConfig *ServiceConfig) error { + if srvConfig.GoogleSpreadSheetService != nil { + environ.GoogleSpreadSheetService = googleservice.NewSpreadSheetService(ctx, srvConfig.GoogleSpreadSheetService.JsonTokenFile, srvConfig.GoogleSpreadSheetService.SpreadSheetID) + } + + return nil +} + +func (environ *Environment) ConfigureExchangeSessions(userConfig *Config) error { + // if sessions are not defined, we detect the sessions automatically + if len(userConfig.Sessions) == 0 { + return environ.AddExchangesByViperKeys() + } + + return environ.AddExchangesFromSessionConfig(userConfig.Sessions) +} + +func (environ *Environment) AddExchangesByViperKeys() error { + for _, n := range types.SupportedExchanges { + if viper.IsSet(string(n) + "-api-key") { + exMinimal, err := exchange.NewWithEnvVarPrefix(n, "") + if err != nil { + return err + } + + if ex, ok := exMinimal.(types.Exchange); ok { + environ.AddExchange(n.String(), ex) + } else { + log.Errorf("exchange %T does not implement types.Exchange", exMinimal) + } + } + } + + return nil +} + +func (environ *Environment) AddExchangesFromSessionConfig(sessions map[string]*ExchangeSession) error { + for sessionName, session := range sessions { + if err := session.InitExchange(sessionName, nil); err != nil { + return err + } + + environ.AddExchangeSession(sessionName, session) + } + + return nil +} + +func (environ *Environment) IsBackTesting() bool { + return environ.BacktestService != nil +} + +// Init prepares the data that will be used by the strategies +func (environ *Environment) Init(ctx context.Context) (err error) { + for n := range environ.sessions { + var session = environ.sessions[n] + if err = session.Init(ctx, environ); err != nil { + // we can skip initialized sessions + if err != ErrSessionAlreadyInitialized { + return err + } + } + } + + return +} + +// Start initializes the symbols data streams +func (environ *Environment) Start(ctx context.Context) (err error) { + for n := range environ.sessions { + var session = environ.sessions[n] + if err = session.InitSymbols(ctx, environ); err != nil { + return err + } + } + return +} + +func (environ *Environment) SetStartTime(t time.Time) *Environment { + environ.startTime = t + return environ +} + +func (environ *Environment) StartTime() time.Time { + return environ.startTime +} + +// SetSyncStartTime overrides the default trade scan time (-7 days) +func (environ *Environment) SetSyncStartTime(t time.Time) *Environment { + environ.syncStartTime = t + return environ +} + +func (environ *Environment) BindSync(config *SyncConfig) { + // skip this if we are running back-test + if environ.BacktestService != nil { + return + } + + // If trade service is configured, we have the db configured + if environ.TradeService == nil { + return + } + + if config == nil || config.UserDataStream == nil { + return + } + + environ.syncConfig = config + + tradeWriterCreator := func(session *ExchangeSession) func(trade types.Trade) { + return func(trade types.Trade) { + trade.IsMargin = session.Margin + trade.IsFutures = session.Futures + if session.Margin { + trade.IsIsolated = session.IsolatedMargin + } else if session.Futures { + trade.IsIsolated = session.IsolatedFutures + } + + // The StrategyID field and the PnL field needs to be updated by the strategy. + // trade.StrategyID, trade.PnL + if err := environ.TradeService.Insert(trade); err != nil { + log.WithError(err).Errorf("trade insert error: %+v", trade) + } + } + } + + orderWriterCreator := func(session *ExchangeSession) func(order types.Order) { + return func(order types.Order) { + order.IsMargin = session.Margin + order.IsFutures = session.Futures + if session.Margin { + order.IsIsolated = session.IsolatedMargin + } else if session.Futures { + order.IsIsolated = session.IsolatedFutures + } + + switch order.Status { + case types.OrderStatusFilled, types.OrderStatusCanceled: + if order.ExecutedQuantity.Sign() > 0 { + if err := environ.OrderService.Insert(order); err != nil { + log.WithError(err).Errorf("order insert error: %+v", order) + } + } + } + } + } + + for _, session := range environ.sessions { + // avoid using the iterator variable. + s2 := session + // if trade sync is on, we will write all received trades + if config.UserDataStream.Trades { + tradeWriter := tradeWriterCreator(s2) + session.UserDataStream.OnTradeUpdate(tradeWriter) + } + + if config.UserDataStream.FilledOrders { + orderWriter := orderWriterCreator(s2) + session.UserDataStream.OnOrderUpdate(orderWriter) + } + } +} + +func (environ *Environment) Connect(ctx context.Context) error { + log.Debugf("starting interaction...") + if err := interact.Start(ctx); err != nil { + return err + } + + for n := range environ.sessions { + // avoid using the placeholder variable for the session because we use that in the callbacks + var session = environ.sessions[n] + var logger = log.WithField("session", n) + + if len(session.Subscriptions) == 0 { + logger.Warnf("exchange session %s has no subscriptions", session.Name) + } else { + // add the subscribe requests to the stream + for _, s := range session.Subscriptions { + logger.Infof("subscribing %s %s %v", s.Symbol, s.Channel, s.Options) + session.MarketDataStream.Subscribe(s.Channel, s.Symbol, s.Options) + } + } + + logger.Infof("connecting %s market data stream...", session.Name) + if err := session.MarketDataStream.Connect(ctx); err != nil { + return err + } + + if !session.PublicOnly { + logger.Infof("connecting %s user data stream...", session.Name) + if err := session.UserDataStream.Connect(ctx); err != nil { + return err + } + } + } + + return nil +} + +func (environ *Environment) IsSyncing() (status SyncStatus) { + environ.syncStatusMutex.Lock() + status = environ.syncStatus + environ.syncStatusMutex.Unlock() + return status +} + +func (environ *Environment) setSyncing(status SyncStatus) { + environ.syncStatusMutex.Lock() + environ.syncStatus = status + environ.syncStatusMutex.Unlock() +} + +func (environ *Environment) syncWithUserConfig(ctx context.Context, userConfig *Config) error { + sessions := environ.sessions + selectedSessions := userConfig.Sync.Sessions + if len(selectedSessions) > 0 { + sessions = environ.SelectSessions(selectedSessions...) + } + + since := defaultSyncSinceTime() + if userConfig.Sync.Since != nil { + since = userConfig.Sync.Since.Time() + } + + environ.SetSyncStartTime(since) + + syncSymbolMap, restSymbols := categorizeSyncSymbol(userConfig.Sync.Symbols) + for _, session := range sessions { + syncSymbols := restSymbols + if ss, ok := syncSymbolMap[session.Name]; ok { + syncSymbols = append(syncSymbols, ss...) + } + + if err := environ.syncSession(ctx, session, since, syncSymbols...); err != nil { + return err + } + + if userConfig.Sync.DepositHistory { + if err := environ.SyncService.SyncDepositHistory(ctx, session.Exchange, since); err != nil { + return err + } + } + + if userConfig.Sync.WithdrawHistory { + if err := environ.SyncService.SyncWithdrawHistory(ctx, session.Exchange, since); err != nil { + return err + } + } + + if userConfig.Sync.RewardHistory { + if err := environ.SyncService.SyncRewardHistory(ctx, session.Exchange, since); err != nil { + return err + } + } + + if userConfig.Sync.MarginHistory { + if err := environ.SyncService.SyncMarginHistory(ctx, session.Exchange, + since, + userConfig.Sync.MarginAssets...); err != nil { + return err + } + } + } + + return nil +} + +// Sync syncs all registered exchange sessions +func (environ *Environment) Sync(ctx context.Context, userConfig ...*Config) error { + if environ.SyncService == nil { + return nil + } + + environ.syncMutex.Lock() + defer environ.syncMutex.Unlock() + + environ.setSyncing(Syncing) + defer environ.setSyncing(SyncDone) + + // sync by the defined user config + if len(userConfig) > 0 && userConfig[0] != nil && userConfig[0].Sync != nil { + return environ.syncWithUserConfig(ctx, userConfig[0]) + } + + // the default sync logics + since := defaultSyncSinceTime() + for _, session := range environ.sessions { + if err := environ.syncSession(ctx, session, since); err != nil { + return err + } + } + + return nil +} + +func (environ *Environment) RecordAsset(t time.Time, session *ExchangeSession, assets types.AssetMap) { + // skip for back-test + if environ.BacktestService != nil { + return + } + + if environ.DatabaseService == nil || environ.AccountService == nil { + return + } + + if err := environ.AccountService.InsertAsset( + t, + session.Name, + session.ExchangeName, + session.SubAccount, + session.Margin, + session.IsolatedMargin, + session.IsolatedMarginSymbol, + assets); err != nil { + log.WithError(err).Errorf("can not insert asset record") + } +} + +func (environ *Environment) RecordPosition(position *types.Position, trade types.Trade, profit *types.Profit) { + // skip for back-test + if environ.BacktestService != nil { + return + } + + if environ.DatabaseService == nil || environ.ProfitService == nil || environ.PositionService == nil { + return + } + + // guard: set profit info to position if the strategy info is empty + if profit != nil { + if position.Strategy == "" && profit.Strategy != "" { + position.Strategy = profit.Strategy + } + + if position.StrategyInstanceID == "" && profit.StrategyInstanceID != "" { + position.StrategyInstanceID = profit.StrategyInstanceID + } + } + + log.Infof("recordPosition: position = %s, trade = %+v, profit = %+v", position.Base.String(), trade, profit) + if profit != nil { + if err := environ.PositionService.Insert(position, trade, profit.Profit); err != nil { + log.WithError(err).Errorf("can not insert position record") + } + + if err := environ.ProfitService.Insert(*profit); err != nil { + log.WithError(err).Errorf("can not insert profit record: %+v", profit) + } + } else { + if err := environ.PositionService.Insert(position, trade, fixedpoint.Zero); err != nil { + log.WithError(err).Errorf("can not insert position record") + } + } +} + +func (environ *Environment) RecordProfit(profit types.Profit) { + // skip for back-test + if environ.BacktestService != nil { + return + } + + if environ.DatabaseService == nil { + return + } + if environ.ProfitService == nil { + return + } + + if err := environ.ProfitService.Insert(profit); err != nil { + log.WithError(err).Errorf("can not insert profit record: %+v", profit) + } +} + +func (environ *Environment) SyncSession(ctx context.Context, session *ExchangeSession, defaultSymbols ...string) error { + if environ.SyncService == nil { + return nil + } + + environ.syncMutex.Lock() + defer environ.syncMutex.Unlock() + + environ.setSyncing(Syncing) + defer environ.setSyncing(SyncDone) + + since := defaultSyncSinceTime() + return environ.syncSession(ctx, session, since, defaultSymbols...) +} + +func (environ *Environment) syncSession( + ctx context.Context, session *ExchangeSession, syncStartTime time.Time, defaultSymbols ...string, +) error { + symbols, err := session.getSessionSymbols(defaultSymbols...) + if err != nil { + return err + } + + log.Infof("syncing symbols %v from session %s", symbols, session.Name) + + return environ.SyncService.SyncSessionSymbols(ctx, session.Exchange, syncStartTime, symbols...) +} + +func (environ *Environment) ConfigureNotificationSystem(ctx context.Context, userConfig *Config) error { + // setup default notification config + if userConfig.Notifications == nil { + userConfig.Notifications = &NotificationConfig{} + } + + var isolation = GetIsolationFromContext(ctx) + var persistence = isolation.persistenceServiceFacade.Get() + + err := environ.setupInteraction(persistence) + if err != nil { + return err + } + + // setup slack + slackToken := viper.GetString("slack-token") + if len(slackToken) > 0 && userConfig.Notifications != nil { + environ.setupSlack(userConfig, slackToken, persistence) + } + + // check if telegram bot token is defined + telegramBotToken := viper.GetString("telegram-bot-token") + if len(telegramBotToken) > 0 { + if err := environ.setupTelegram(userConfig, telegramBotToken, persistence); err != nil { + return err + } + } + + if userConfig.Notifications != nil { + if err := environ.ConfigureNotification(userConfig.Notifications); err != nil { + return err + } + } + + return nil +} + +func (environ *Environment) ConfigureNotification(config *NotificationConfig) error { + if config.Switches != nil { + if config.Switches.Trade { + tradeHandler := func(trade types.Trade) { + Notify(trade) + } + + for _, session := range environ.sessions { + session.UserDataStream.OnTradeUpdate(tradeHandler) + } + } + + if config.Switches.OrderUpdate { + orderUpdateHandler := func(order types.Order) { + Notify(order) + } + + for _, session := range environ.sessions { + session.UserDataStream.OnOrderUpdate(orderUpdateHandler) + } + } + } + + return nil +} + +// getAuthStoreID returns the authentication store id +// if telegram bot token is defined, the bot id will be used. +// if not, env var $USER will be used. +// if both are not defined, a default "default" will be used. +func getAuthStoreID() string { + telegramBotToken := viper.GetString("telegram-bot-token") + if len(telegramBotToken) > 0 { + tt := strings.Split(telegramBotToken, ":") + return tt[0] + } + + userEnv := os.Getenv("USER") + if userEnv != "" { + return userEnv + } + + return "default" +} + +func (environ *Environment) setupInteraction(persistence service.PersistenceService) error { + var otpQRCodeImagePath = "otp.png" + var key *otp.Key + var keyURL string + var authStore = environ.getAuthStore(persistence) + + if v, ok := util.GetEnvVarBool("FLUSH_OTP_KEY"); v && ok { + log.Warnf("flushing otp key...") + if err := authStore.Reset(); err != nil { + return err + } + } + + if err := authStore.Load(&keyURL); err != nil { + log.Warnf("telegram session not found, generating new one-time password key for new telegram session...") + + newKey, err := setupNewOTPKey(otpQRCodeImagePath) + if err != nil { + return errors.Wrapf(err, "failed to setup totp (time-based one time password) key") + } + + key = newKey + keyURL = key.URL() + if err := authStore.Save(keyURL); err != nil { + return err + } + + printOtpAuthGuide(otpQRCodeImagePath) + + } else if keyURL != "" { + key, err = otp.NewKeyFromURL(keyURL) + if err != nil { + log.WithError(err).Errorf("can not load otp key from url: %s, generating new otp key", keyURL) + + newKey, err := setupNewOTPKey(otpQRCodeImagePath) + if err != nil { + return errors.Wrapf(err, "failed to setup totp (time-based one time password) key") + } + + key = newKey + keyURL = key.URL() + if err := authStore.Save(keyURL); err != nil { + return err + } + + printOtpAuthGuide(otpQRCodeImagePath) + } else { + log.Infof("otp key loaded: %s", util.MaskKey(key.Secret())) + printOtpAuthGuide(otpQRCodeImagePath) + } + } + + authStrict := false + authMode := interact.AuthModeToken + authToken := viper.GetString("telegram-bot-auth-token") + + if authToken != "" && key != nil { + authStrict = true + } else if authToken != "" { + authMode = interact.AuthModeToken + } else if key != nil { + authMode = interact.AuthModeOTP + } + + if authMode == interact.AuthModeToken { + log.Debugf("found interaction auth token, using token mode for authorization...") + printAuthTokenGuide(authToken) + } + + interact.AddCustomInteraction(&interact.AuthInteract{ + Strict: authStrict, + Mode: authMode, + Token: authToken, // can be empty string here + // pragma: allowlist nextline secret + OneTimePasswordKey: key, // can be nil here + }) + return nil +} + +func (environ *Environment) getAuthStore(persistence service.PersistenceService) service.Store { + id := getAuthStoreID() + return persistence.NewStore("qbtrade", "auth", id) +} + +func (environ *Environment) setupSlack(userConfig *Config, slackToken string, persistence service.PersistenceService) { + conf := userConfig.Notifications.Slack + if conf == nil { + return + } + + if !strings.HasPrefix(slackToken, "xoxb-") { + log.Error("SLACK_BOT_TOKEN must have the prefix \"xoxb-\".") + return + } + + // app-level token (for specific api) + slackAppToken := viper.GetString("slack-app-token") + if len(slackAppToken) > 0 && !strings.HasPrefix(slackAppToken, "xapp-") { + log.Errorf("SLACK_APP_TOKEN must have the prefix \"xapp-\".") + return + } + + if conf.ErrorChannel != "" { + log.Debugf("found slack configured, setting up log hook...") + log.AddHook(slacklog.NewLogHook(slackToken, conf.ErrorChannel)) + } + + log.Debugf("adding slack notifier with default channel: %s", conf.DefaultChannel) + + var slackOpts = []slack.Option{ + slack.OptionLog(stdlog.New(os.Stdout, "api: ", stdlog.Lshortfile|stdlog.LstdFlags)), + } + + if len(slackAppToken) > 0 { + slackOpts = append(slackOpts, slack.OptionAppLevelToken(slackAppToken)) + } + + if b, ok := util.GetEnvVarBool("DEBUG_SLACK"); ok { + slackOpts = append(slackOpts, slack.OptionDebug(b)) + } + + var client = slack.New(slackToken, slackOpts...) + + var notifier = slacknotifier.New(client, conf.DefaultChannel) + Notification.AddNotifier(notifier) + + // allocate a store, so that we can save the chatID for the owner + var messenger = interact.NewSlack(client) + + var sessions = interact.SlackSessionMap{} + var sessionStore = persistence.NewStore("qbtrade", "slack") + if err := sessionStore.Load(&sessions); err != nil { + + } else { + // TODO: this is not necessary for slack, but we should find a way to restore the sessions + /* + for _, session := range sessions { + if session.IsAuthorized() { + // notifier.AddChat(session.Chat) + } + } + messenger.RestoreSessions(sessions) + messenger.OnAuthorized(func(userSession *interact.SlackSession) { + if userSession.IsAuthorized() { + // notifier.AddChat(userSession.Chat) + } + }) + */ + } + + interact.AddMessenger(messenger) +} + +func (environ *Environment) setupTelegram( + userConfig *Config, telegramBotToken string, persistence service.PersistenceService, +) error { + tt := strings.Split(telegramBotToken, ":") + telegramID := tt[0] + + bot, err := telebot.NewBot(telebot.Settings{ + // You can also set custom API URL. + // If field is empty it equals to "https://api.telegram.org". + // URL: "http://195.129.111.17:8012", + Token: telegramBotToken, + Poller: &telebot.LongPoller{Timeout: 10 * time.Second}, + }) + + if err != nil { + return err + } + + var opts []telegramnotifier.Option + if userConfig.Notifications != nil && userConfig.Notifications.Telegram != nil { + log.Infof("telegram broadcast is enabled") + opts = append(opts, telegramnotifier.UseBroadcast()) + } + + var notifier = telegramnotifier.New(bot, opts...) + Notification.AddNotifier(notifier) + + log.AddHook(telegramnotifier.NewLogHook(notifier)) + + // allocate a store, so that we can save the chatID for the owner + var messenger = interact.NewTelegram(bot) + + var sessions = interact.TelegramSessionMap{} + var sessionStore = persistence.NewStore("qbtrade", "telegram", telegramID) + if err := sessionStore.Load(&sessions); err != nil { + if err != service.ErrPersistenceNotExists { + log.WithError(err).Errorf("unexpected persistence error") + } + } else { + for _, session := range sessions { + if session.IsAuthorized() { + notifier.AddChat(session.Chat) + } + } + + // you must restore the session after the notifier updates + messenger.RestoreSessions(sessions) + } + + messenger.OnAuthorized(func(userSession *interact.TelegramSession) { + if userSession.IsAuthorized() { + notifier.AddChat(userSession.Chat) + } + + log.Infof("user session %d got authorized, saving telegram sessions...", userSession.User.ID) + if err := sessionStore.Save(messenger.Sessions()); err != nil { + log.WithError(err).Errorf("telegram session save error") + } + }) + + interact.AddMessenger(messenger) + return nil +} + +func writeOTPKeyAsQRCodePNG(key *otp.Key, imagePath string) error { + // Convert TOTP key into a PNG + var buf bytes.Buffer + img, err := key.Image(512, 512) + if err != nil { + return err + } + + if err := png.Encode(&buf, img); err != nil { + return err + } + + if err := ioutil.WriteFile(imagePath, buf.Bytes(), 0644); err != nil { + return err + } + + return nil +} + +// setupNewOTPKey generates a new otp key and save the secret as a qrcode image +func setupNewOTPKey(qrcodeImagePath string) (*otp.Key, error) { + key, err := service.NewDefaultTotpKey() + if err != nil { + return nil, errors.Wrapf(err, "failed to setup totp (time-based one time password) key") + } + + printOtpKey(key) + + if err := writeOTPKeyAsQRCodePNG(key, qrcodeImagePath); err != nil { + return nil, err + } + + return key, nil +} + +func printOtpKey(key *otp.Key) { + fmt.Println("") + fmt.Println("====================================================================") + fmt.Println(" PLEASE STORE YOUR OTP KEY SAFELY ") + fmt.Println("====================================================================") + fmt.Printf(" Issuer: %s\n", key.Issuer()) + fmt.Printf(" AccountName: %s\n", key.AccountName()) + fmt.Printf(" Secret: %s\n", key.Secret()) + fmt.Printf(" Key URL: %s\n", key.URL()) + fmt.Println("====================================================================") + fmt.Println("") +} + +func printOtpAuthGuide(qrcodeImagePath string) { + fmt.Printf(` +To scan your OTP QR code, please run the following command: + + open %s + +For telegram, send the auth command with the generated one-time password to the qbtrade bot you created to enable the notification: + + /auth + +`, qrcodeImagePath) +} + +func printAuthTokenGuide(token string) { + fmt.Printf(` +For telegram, send the following command to the qbtrade bot you created to enable the notification: + + /auth + +And then enter your token + + %s + +`, token) +} + +func (session *ExchangeSession) getSessionSymbols(defaultSymbols ...string) ([]string, error) { + if session.IsolatedMargin { + return []string{session.IsolatedMarginSymbol}, nil + } + + if len(defaultSymbols) > 0 { + return defaultSymbols, nil + } + + return session.FindPossibleAssetSymbols() +} + +func defaultSyncSinceTime() time.Time { + return time.Now().AddDate(0, -6, 0) +} diff --git a/pkg/qbtrade/envvar.go b/pkg/qbtrade/envvar.go new file mode 100644 index 0000000..f7e5588 --- /dev/null +++ b/pkg/qbtrade/envvar.go @@ -0,0 +1,48 @@ +package qbtrade + +import ( + "os" + + log "github.com/sirupsen/logrus" + prefixed "github.com/x-cray/logrus-prefixed-formatter" +) + +func GetCurrentEnv() string { + env := os.Getenv("qbtrade_ENV") + if env == "" { + env = "development" + } + + return env +} + +func NewLogFormatterWithEnv(env string) log.Formatter { + switch env { + case "production", "prod", "stag", "staging": + // always use json formatter for production and staging + return &log.JSONFormatter{} + } + + return &prefixed.TextFormatter{} +} + +type LogFormatterType string + +const ( + LogFormatterTypePrefixed LogFormatterType = "prefixed" + LogFormatterTypeText LogFormatterType = "text" + LogFormatterTypeJson LogFormatterType = "json" +) + +func NewLogFormatter(logFormatter LogFormatterType) log.Formatter { + switch logFormatter { + case LogFormatterTypePrefixed: + return &prefixed.TextFormatter{} + case LogFormatterTypeText: + return &log.TextFormatter{} + case LogFormatterTypeJson: + return &log.JSONFormatter{} + } + + return &prefixed.TextFormatter{} +} diff --git a/pkg/qbtrade/errors.go b/pkg/qbtrade/errors.go new file mode 100644 index 0000000..c1f2e0e --- /dev/null +++ b/pkg/qbtrade/errors.go @@ -0,0 +1,5 @@ +package qbtrade + +import "errors" + +var ErrSessionAlreadyInitialized = errors.New("session is already initialized") diff --git a/pkg/qbtrade/exchangeorderexecutor_callbacks.go b/pkg/qbtrade/exchangeorderexecutor_callbacks.go new file mode 100644 index 0000000..5d230d9 --- /dev/null +++ b/pkg/qbtrade/exchangeorderexecutor_callbacks.go @@ -0,0 +1,27 @@ +// Code generated by "callbackgen -type ExchangeOrderExecutor"; DO NOT EDIT. + +package qbtrade + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func (e *ExchangeOrderExecutor) OnTradeUpdate(cb func(trade types.Trade)) { + e.tradeUpdateCallbacks = append(e.tradeUpdateCallbacks, cb) +} + +func (e *ExchangeOrderExecutor) EmitTradeUpdate(trade types.Trade) { + for _, cb := range e.tradeUpdateCallbacks { + cb(trade) + } +} + +func (e *ExchangeOrderExecutor) OnOrderUpdate(cb func(order types.Order)) { + e.orderUpdateCallbacks = append(e.orderUpdateCallbacks, cb) +} + +func (e *ExchangeOrderExecutor) EmitOrderUpdate(order types.Order) { + for _, cb := range e.orderUpdateCallbacks { + cb(order) + } +} diff --git a/pkg/qbtrade/exit.go b/pkg/qbtrade/exit.go new file mode 100644 index 0000000..84fd423 --- /dev/null +++ b/pkg/qbtrade/exit.go @@ -0,0 +1,157 @@ +package qbtrade + +import ( + "bytes" + "encoding/json" + "reflect" + + "github.com/pkg/errors" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/dynamic" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +var enableMarketTradeStop = true + +func init() { + if v, defined := util.GetEnvVarBool("DISABLE_MARKET_TRADE_STOP"); defined && v { + enableMarketTradeStop = false + } +} + +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) + } +} + +func (s *ExitMethodSet) Bind(session *ExchangeSession, orderExecutor *GeneralOrderExecutor) { + for _, method := range *s { + method.Bind(session, orderExecutor) + } +} + +type ExitMethod struct { + RoiStopLoss *RoiStopLoss `json:"roiStopLoss"` + ProtectiveStopLoss *ProtectiveStopLoss `json:"protectiveStopLoss"` + RoiTakeProfit *RoiTakeProfit `json:"roiTakeProfit"` + TrailingStop *TrailingStop2 `json:"trailingStop"` + HigherHighLowerLowStop *HigherHighLowerLowStop `json:"higherHighLowerLowStopLoss"` + + // Exit methods for short positions + // ================================================= + LowerShadowTakeProfit *LowerShadowTakeProfit `json:"lowerShadowTakeProfit"` + CumulatedVolumeTakeProfit *CumulatedVolumeTakeProfit `json:"cumulatedVolumeTakeProfit"` + SupportTakeProfit *SupportTakeProfit `json:"supportTakeProfit"` +} + +func (e ExitMethod) String() string { + var buf bytes.Buffer + if e.RoiStopLoss != nil { + b, _ := json.Marshal(e.RoiStopLoss) + buf.WriteString("roiStopLoss: " + string(b) + ", ") + } + + if e.ProtectiveStopLoss != nil { + b, _ := json.Marshal(e.ProtectiveStopLoss) + buf.WriteString("protectiveStopLoss: " + string(b) + ", ") + } + + if e.RoiTakeProfit != nil { + b, _ := json.Marshal(e.RoiTakeProfit) + buf.WriteString("rioTakeProft: " + string(b) + ", ") + } + + if e.LowerShadowTakeProfit != nil { + b, _ := json.Marshal(e.LowerShadowTakeProfit) + buf.WriteString("lowerShadowTakeProft: " + string(b) + ", ") + } + + if e.CumulatedVolumeTakeProfit != nil { + b, _ := json.Marshal(e.CumulatedVolumeTakeProfit) + buf.WriteString("cumulatedVolumeTakeProfit: " + string(b) + ", ") + } + + if e.TrailingStop != nil { + b, _ := json.Marshal(e.TrailingStop) + buf.WriteString("trailingStop: " + string(b) + ", ") + } + + if e.SupportTakeProfit != nil { + b, _ := json.Marshal(e.SupportTakeProfit) + buf.WriteString("supportTakeProfit: " + string(b) + ", ") + } + + if e.HigherHighLowerLowStop != nil { + b, _ := json.Marshal(e.HigherHighLowerLowStop) + buf.WriteString("hhllStop: " + string(b) + ", ") + } + + return buf.String() +} + +// 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) + } + + if m.RoiStopLoss != nil { + m.RoiStopLoss.Bind(session, orderExecutor) + } + + if m.RoiTakeProfit != nil { + m.RoiTakeProfit.Bind(session, orderExecutor) + } + + if m.LowerShadowTakeProfit != nil { + m.LowerShadowTakeProfit.Bind(session, orderExecutor) + } + + if m.CumulatedVolumeTakeProfit != nil { + m.CumulatedVolumeTakeProfit.Bind(session, orderExecutor) + } + + if m.SupportTakeProfit != nil { + m.SupportTakeProfit.Bind(session, orderExecutor) + } + + if m.TrailingStop != nil { + m.TrailingStop.Bind(session, orderExecutor) + } + + if m.HigherHighLowerLowStop != nil { + m.HigherHighLowerLowStop.Bind(session, orderExecutor) + } +} diff --git a/pkg/qbtrade/exit_cumulated_volume_take_profit.go b/pkg/qbtrade/exit_cumulated_volume_take_profit.go new file mode 100644 index 0000000..1015acb --- /dev/null +++ b/pkg/qbtrade/exit_cumulated_volume_take_profit.go @@ -0,0 +1,95 @@ +package qbtrade + +import ( + "context" + + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// CumulatedVolumeTakeProfit +// This exit method cumulate the volume by N bars, if the cumulated volume exceeded a threshold, then we take profit. +// +// To query the historical quote volume, use the following query: +// +// > 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"` + + session *ExchangeSession + orderExecutor *GeneralOrderExecutor +} + +func (s *CumulatedVolumeTakeProfit) Bind(session *ExchangeSession, orderExecutor *GeneralOrderExecutor) { + s.session = session + s.orderExecutor = orderExecutor + + position := orderExecutor.Position() + + store, _ := session.MarketDataStore(position.Symbol) + + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.Interval, func(kline types.KLine) { + closePrice := kline.Close + openPrice := kline.Open + if position.IsClosed() || position.IsDust(closePrice) || position.IsClosing() { + return + } + + roi := position.ROI(closePrice) + if roi.Sign() < 0 { + 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 { + return + } + + // If the closed price is below the open price, it means the sell taker is still strong. + if closePrice.Compare(openPrice) < 0 { + log.Infof("[CumulatedVolumeTakeProfit] closePrice %f is below openPrice %f, skip taking profit", closePrice.Float64(), openPrice.Float64()) + return + } + + upperShadow := kline.GetUpperShadowHeight() + lowerShadow := kline.GetLowerShadowHeight() + if upperShadow.Compare(lowerShadow) > 0 { + log.Infof("[CumulatedVolumeTakeProfit] upper shadow is longer than the lower shadow, skip taking profit") + return + } + + Notify("[CumulatedVolumeTakeProfit] %s TakeProfit triggered by cumulated volume (window: %d) %f > %f, price = %f", + position.Symbol, + s.Window, + cqv.Float64(), + s.MinQuoteVolume.Float64(), kline.Close.Float64()) + + if err := orderExecutor.ClosePosition(context.Background(), fixedpoint.One, "cumulatedVolumeTakeProfit"); err != nil { + log.WithError(err).Errorf("close position error") + } + })) +} diff --git a/pkg/qbtrade/exit_hh_ll_stop.go b/pkg/qbtrade/exit_hh_ll_stop.go new file mode 100644 index 0000000..54704dd --- /dev/null +++ b/pkg/qbtrade/exit_hh_ll_stop.go @@ -0,0 +1,253 @@ +package qbtrade + +import ( + "context" + "fmt" + + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type HigherHighLowerLowStop struct { + Symbol string `json:"symbol"` + + // Interval is the kline interval used by this exit. Window is used as the range to determining higher highs and + // lower lows + types.IntervalWindow + + // HighLowWindow is the range to calculate the number of higher highs and lower lows + HighLowWindow int `json:"highLowWindow"` + + // If the number of higher highs or lower lows with in HighLowWindow is more than MaxHighLow, the exit is triggered. + // 0 disables this parameter. Either one of MaxHighLow and MinHighLow must be larger than 0 + MaxHighLow int `json:"maxHighLow"` + + // If the number of higher highs or lower lows with in HighLowWindow is less than MinHighLow, the exit is triggered. + // 0 disables this parameter. Either one of MaxHighLow and MinHighLow must be larger than 0 + MinHighLow int `json:"minHighLow"` + + // ActivationRatio is the trigger condition + // When the price goes higher (lower for short position) than this ratio, the stop will be activated. + // You can use this to combine several exits + ActivationRatio fixedpoint.Value `json:"activationRatio"` + + // DeactivationRatio is the kill condition + // When the price goes higher (lower for short position) than this ratio, the stop will be deactivated. + // You can use this to combine several exits + DeactivationRatio fixedpoint.Value `json:"deactivationRatio"` + + // If true, looking for lower lows in long position and higher highs in short position. If false, looking for higher + // highs in long position and lower lows in short position + OppositeDirectionAsPosition bool `json:"oppositeDirectionAsPosition"` + + klines types.KLineWindow + + // activated: when the price reaches the min profit price, we set the activated to true to enable hhll stop + activated bool + + highLows []types.Direction + + session *ExchangeSession + orderExecutor *GeneralOrderExecutor +} + +// Subscribe required k-line stream +func (s *HigherHighLowerLowStop) Subscribe(session *ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) +} + +// updateActivated checks the position cost against the close price, activation ratio, and deactivation ratio to +// determine whether this stop should be activated +func (s *HigherHighLowerLowStop) updateActivated(position *types.Position, closePrice fixedpoint.Value) { + // deactivate when no position + if position.IsClosed() || position.IsDust(closePrice) || position.IsClosing() { + + s.activated = false + return + + } + + // activation/deactivation price + var priceDeactive fixedpoint.Value + var priceActive fixedpoint.Value + if position.IsLong() { + priceDeactive = position.AverageCost.Mul(fixedpoint.One.Add(s.DeactivationRatio)) + priceActive = position.AverageCost.Mul(fixedpoint.One.Add(s.ActivationRatio)) + } else { + priceDeactive = position.AverageCost.Mul(fixedpoint.One.Sub(s.DeactivationRatio)) + priceActive = position.AverageCost.Mul(fixedpoint.One.Sub(s.ActivationRatio)) + } + + if s.activated { + + if position.IsLong() { + + if closePrice.Compare(priceDeactive) >= 0 { + + s.activated = false + Notify("[hhllStop] Stop of %s deactivated for long position, deactivation ratio %s", s.Symbol, s.DeactivationRatio.Percentage()) + + } else if closePrice.Compare(priceActive) < 0 { + + s.activated = false + Notify("[hhllStop] Stop of %s deactivated for long position, activation ratio %s", s.Symbol, s.ActivationRatio.Percentage()) + + } + + } else if position.IsShort() { + + // for short position, if the close price is less than the activation price then this is a profit position. + if closePrice.Compare(priceDeactive) <= 0 { + + s.activated = false + Notify("[hhllStop] Stop of %s deactivated for short position, deactivation ratio %s", s.Symbol, s.DeactivationRatio.Percentage()) + + } else if closePrice.Compare(priceActive) > 0 { + + s.activated = false + Notify("[hhllStop] Stop of %s deactivated for short position, activation ratio %s", s.Symbol, s.ActivationRatio.Percentage()) + + } + + } + } else { + + if position.IsLong() { + + if closePrice.Compare(priceActive) >= 0 && closePrice.Compare(priceDeactive) < 0 { + + s.activated = true + Notify("[hhllStop] %s stop is activated for long position, activation ratio %s, deactivation ratio %s", s.Symbol, s.ActivationRatio.Percentage(), s.DeactivationRatio.Percentage()) + + } + + } else if position.IsShort() { + + // for short position, if the close price is less than the activation price then this is a profit position. + if closePrice.Compare(priceActive) <= 0 && closePrice.Compare(priceDeactive) > 0 { + + s.activated = true + Notify("[hhllStop] %s stop is activated for short position, activation ratio %s, deactivation ratio %s", s.Symbol, s.ActivationRatio.Percentage(), s.DeactivationRatio.Percentage()) + + } + + } + + } +} + +func (s *HigherHighLowerLowStop) updateHighLowNumber(kline types.KLine) { + s.klines.Truncate(s.Window - 1) + + if s.klines.Len() >= s.Window-1 { + high := kline.GetHigh() + low := kline.GetLow() + if s.klines.GetHigh().Compare(high) < 0 { + s.highLows = append(s.highLows, types.DirectionUp) + log.Debugf("[hhllStop] detected %s new higher high %f", s.Symbol, high.Float64()) + } else if s.klines.GetLow().Compare(low) > 0 { + s.highLows = append(s.highLows, types.DirectionDown) + log.Debugf("[hhllStop] detected %s new lower low %f", s.Symbol, low.Float64()) + } else { + s.highLows = append(s.highLows, types.DirectionNone) + } + + // Truncate highLows + if len(s.highLows) > s.HighLowWindow { + end := len(s.highLows) + start := end - s.HighLowWindow + if start < 0 { + start = 0 + } + kn := s.highLows[start:] + s.highLows = kn + } + + } else { + s.highLows = append(s.highLows, types.DirectionNone) + } + + s.klines.Add(kline) +} + +func (s *HigherHighLowerLowStop) shouldStop(position *types.Position) bool { + if s.klines.Len() < s.Window || len(s.highLows) < s.HighLowWindow { + log.Debugf("[hhllStop] not enough data for %s yet", s.Symbol) + return false + } + + if s.activated { + highs := 0 + lows := 0 + for _, hl := range s.highLows { + switch hl { + case types.DirectionUp: + highs++ + case types.DirectionDown: + lows++ + } + } + + log.Debugf("[hhllStop] %d higher highs and %d lower lows in window of %d", highs, lows, s.HighLowWindow) + + // Check higher highs + if (position.IsLong() && !s.OppositeDirectionAsPosition) || (position.IsShort() && s.OppositeDirectionAsPosition) { + if (s.MinHighLow > 0 && highs < s.MinHighLow) || (s.MaxHighLow > 0 && highs > s.MaxHighLow) { + return true + } + // Check lower lows + } else if (position.IsShort() && !s.OppositeDirectionAsPosition) || (position.IsLong() && s.OppositeDirectionAsPosition) { + if (s.MinHighLow > 0 && lows < s.MinHighLow) || (s.MaxHighLow > 0 && lows > s.MaxHighLow) { + return true + } + } + } + return false +} + +func (s *HigherHighLowerLowStop) Bind(session *ExchangeSession, orderExecutor *GeneralOrderExecutor) { + // Check parameters + if s.Window <= 0 { + panic(fmt.Errorf("[hhllStop] window must be larger than zero")) + } + if s.HighLowWindow <= 0 { + panic(fmt.Errorf("[hhllStop] highLowWindow must be larger than zero")) + } + if s.MaxHighLow <= 0 && s.MinHighLow <= 0 { + panic(fmt.Errorf("[hhllStop] either maxHighLow or minHighLow must be larger than zero")) + } + + s.session = session + s.orderExecutor = orderExecutor + + position := orderExecutor.Position() + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.Interval, func(kline types.KLine) { + s.updateActivated(position, kline.GetClose()) + + s.updateHighLowNumber(kline) + + // Close position & reset + if s.shouldStop(position) { + defer func() { + s.activated = false + }() + + err := s.orderExecutor.ClosePosition(context.Background(), fixedpoint.One, "hhllStop") + if err != nil { + Notify("[hhllStop] Stop of %s triggered but failed to close %s position:", s.Symbol, err) + return + } + + Notify("[hhllStop] Stop of %s triggered and position closed", s.Symbol) + } + })) + + // Make sure the stop is reset when position is closed or dust + orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { + if position.IsClosed() || position.IsDust(position.AverageCost) { + s.activated = false + } + }) +} diff --git a/pkg/qbtrade/exit_lower_shadow_take_profit.go b/pkg/qbtrade/exit_lower_shadow_take_profit.go new file mode 100644 index 0000000..a015946 --- /dev/null +++ b/pkg/qbtrade/exit_lower_shadow_take_profit.go @@ -0,0 +1,65 @@ +package qbtrade + +import ( + "context" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type LowerShadowTakeProfit struct { + // 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 + + 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) || position.IsClosing() { + return + } + + roi := position.ROI(closePrice) + if roi.Sign() < 0 { + return + } + + if s.Ratio.IsZero() { + return + } + + // skip close price higher than the ewma + if closePrice.Float64() > ewma.Last(0) { + return + } + + if kline.GetLowerShadowHeight().Div(kline.Close).Compare(s.Ratio) > 0 { + Notify("%s TakeProfit triggered by shadow ratio %f, price = %f", + position.Symbol, + kline.GetLowerShadowRatio().Float64(), + kline.Close.Float64(), + kline) + + _ = orderExecutor.ClosePosition(context.Background(), fixedpoint.One) + return + } + })) +} diff --git a/pkg/qbtrade/exit_protective_stop_loss.go b/pkg/qbtrade/exit_protective_stop_loss.go new file mode 100644 index 0000000..d4b0037 --- /dev/null +++ b/pkg/qbtrade/exit_protective_stop_loss.go @@ -0,0 +1,219 @@ +package qbtrade + +import ( + "context" + + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// ProtectiveStopLoss provides a way to protect your profit but also keep a room for the price volatility +// Set ActivationRatio to 1% means if the price is away from your average cost by 1%, we will activate the protective stop loss +// and the StopLossRatio is the minimal profit ratio you want to keep for your position. +// If you set StopLossRatio to 0.1% and ActivationRatio to 1%, +// when the price goes away from your average cost by 1% and then goes back to below your (average_cost * (1 - 0.1%)) +// The stop will trigger. +type ProtectiveStopLoss struct { + Symbol string `json:"symbol"` + + // ActivationRatio is the trigger condition of this ROI protection stop loss + // When the price goes lower (for short position) with the ratio, the protection stop will be activated. + // This number should be positive to protect the profit + ActivationRatio fixedpoint.Value `json:"activationRatio"` + + // StopLossRatio is the ratio for stop loss. This number should be positive to protect the profit. + // negative ratio will cause loss. + StopLossRatio fixedpoint.Value `json:"stopLossRatio"` + + // PlaceStopOrder places the stop order on exchange and lock the balance + PlaceStopOrder bool `json:"placeStopOrder"` + + // 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"` + + session *ExchangeSession + orderExecutor *GeneralOrderExecutor + stopLossPrice fixedpoint.Value + stopLossOrder *types.Order +} + +func (s *ProtectiveStopLoss) Subscribe(session *ExchangeSession) { + if s.Interval == "" { + s.Interval = types.Interval1m + } + // use kline to handle roi stop + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) +} + +func (s *ProtectiveStopLoss) shouldActivate(position *types.Position, closePrice fixedpoint.Value) bool { + if position.IsLong() { + r := one.Add(s.ActivationRatio) + activationPrice := position.AverageCost.Mul(r) + return closePrice.Compare(activationPrice) > 0 + } else if position.IsShort() { + r := one.Sub(s.ActivationRatio) + activationPrice := position.AverageCost.Mul(r) + // for short position, if the close price is less than the activation price then this is a profit position. + return closePrice.Compare(activationPrice) < 0 + } + + return false +} + +func (s *ProtectiveStopLoss) placeStopOrder(ctx context.Context, position *types.Position, orderExecutor OrderExecutor) error { + if s.stopLossOrder != nil { + if err := orderExecutor.CancelOrders(ctx, *s.stopLossOrder); err != nil { + log.WithError(err).Errorf("failed to cancel stop limit order: %+v", s.stopLossOrder) + } + s.stopLossOrder = nil + } + + createdOrders, err := orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ + Symbol: position.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeStopLimit, + Quantity: position.GetQuantity(), + Price: s.stopLossPrice.Mul(one.Add(fixedpoint.NewFromFloat(0.005))), // +0.5% from the trigger price, slippage protection + StopPrice: s.stopLossPrice, + Market: position.Market, + Tag: "protectiveStopLoss", + MarginSideEffect: types.SideEffectTypeAutoRepay, + }) + + if len(createdOrders) > 0 { + s.stopLossOrder = &createdOrders[0] + } + return err +} + +func (s *ProtectiveStopLoss) shouldStop(closePrice fixedpoint.Value, position *types.Position) bool { + if s.stopLossPrice.IsZero() { + return false + } + + if position.IsShort() { + return closePrice.Compare(s.stopLossPrice) >= 0 + } else if position.IsLong() { + return closePrice.Compare(s.stopLossPrice) <= 0 + } + + return false +} + +func (s *ProtectiveStopLoss) Bind(session *ExchangeSession, orderExecutor *GeneralOrderExecutor) { + s.session = session + s.orderExecutor = orderExecutor + + orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { + if position.IsClosed() { + s.stopLossOrder = nil + s.stopLossPrice = fixedpoint.Zero + } + }) + + session.UserDataStream.OnOrderUpdate(func(order types.Order) { + if s.stopLossOrder == nil { + return + } + + if order.OrderID == s.stopLossOrder.OrderID { + switch order.Status { + case types.OrderStatusFilled, types.OrderStatusCanceled: + s.stopLossOrder = nil + s.stopLossPrice = fixedpoint.Zero + } + } + }) + + position := orderExecutor.Position() + + f := func(kline types.KLine) { + isPositionOpened := !position.IsClosed() && !position.IsDust(kline.Close) + if isPositionOpened { + s.handleChange(context.Background(), position, kline.Close, s.orderExecutor) + } else { + s.stopLossPrice = fixedpoint.Zero + } + } + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.Interval, f)) + session.MarketDataStream.OnKLine(types.KLineWith(s.Symbol, s.Interval, f)) + + if !IsBackTesting && enableMarketTradeStop { + session.MarketDataStream.OnMarketTrade(func(trade types.Trade) { + if trade.Symbol != position.Symbol { + return + } + + if s.stopLossPrice.IsZero() || s.PlaceStopOrder { + return + } + + s.checkStopPrice(trade.Price, position) + }) + } +} + +func (s *ProtectiveStopLoss) handleChange(ctx context.Context, position *types.Position, closePrice fixedpoint.Value, orderExecutor *GeneralOrderExecutor) { + if s.stopLossOrder != nil { + // use RESTful to query the order status + // orderQuery := orderExecutor.Session().Exchange.(types.ExchangeOrderQueryService) + // order, err := orderQuery.QueryOrder(ctx, types.OrderQuery{ + // Symbol: s.stopLossOrder.Symbol, + // OrderID: strconv.FormatUint(s.stopLossOrder.OrderID, 10), + // }) + // if err != nil { + // log.WithError(err).Errorf("query order failed") + // } + } + + if s.stopLossPrice.IsZero() { + if s.shouldActivate(position, closePrice) { + // calculate stop loss price + if position.IsShort() { + s.stopLossPrice = position.AverageCost.Mul(one.Sub(s.StopLossRatio)) + } else if position.IsLong() { + s.stopLossPrice = position.AverageCost.Mul(one.Add(s.StopLossRatio)) + } + + Notify("[ProtectiveStopLoss] %s protection (%s) stop loss activated, SL = %f, currentPrice = %f, averageCost = %f", + position.Symbol, + s.StopLossRatio.Percentage(), + s.stopLossPrice.Float64(), + closePrice.Float64(), + position.AverageCost.Float64()) + + if s.PlaceStopOrder { + if err := s.placeStopOrder(ctx, position, orderExecutor); err != nil { + log.WithError(err).Errorf("failed to place stop limit order") + } + return + } + } else { + // not activated, skip setup stop order + return + } + } + + // check stop price + s.checkStopPrice(closePrice, position) +} + +func (s *ProtectiveStopLoss) checkStopPrice(closePrice fixedpoint.Value, position *types.Position) { + if s.stopLossPrice.IsZero() { + return + } + + if s.shouldStop(closePrice, position) { + Notify("[ProtectiveStopLoss] %s protection stop (%s) is triggered at price %f", + s.Symbol, + s.StopLossRatio.Percentage(), + closePrice.Float64(), + position) + if err := s.orderExecutor.ClosePosition(context.Background(), one, "protectiveStopLoss"); err != nil { + log.WithError(err).Errorf("failed to close position") + } + } +} diff --git a/pkg/qbtrade/exit_roi_stop_loss.go b/pkg/qbtrade/exit_roi_stop_loss.go new file mode 100644 index 0000000..b7f46cb --- /dev/null +++ b/pkg/qbtrade/exit_roi_stop_loss.go @@ -0,0 +1,69 @@ +package qbtrade + +import ( + "context" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type RoiStopLoss struct { + Symbol string + Percentage fixedpoint.Value `json:"percentage"` + CancelActiveOrders bool `json:"cancelActiveOrders"` + // 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"` + + session *ExchangeSession + orderExecutor *GeneralOrderExecutor +} + +func (s *RoiStopLoss) Subscribe(session *ExchangeSession) { + // use kline to handle roi stop + if s.Interval == "" { + s.Interval = types.Interval1m + } + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) +} + +func (s *RoiStopLoss) Bind(session *ExchangeSession, orderExecutor *GeneralOrderExecutor) { + s.session = session + s.orderExecutor = orderExecutor + + position := orderExecutor.Position() + f := func(kline types.KLine) { + s.checkStopPrice(kline.Close, position) + } + + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.Interval, f)) + session.MarketDataStream.OnKLine(types.KLineWith(s.Symbol, s.Interval, f)) + + if !IsBackTesting && enableMarketTradeStop { + session.MarketDataStream.OnMarketTrade(func(trade types.Trade) { + if trade.Symbol != position.Symbol { + return + } + + s.checkStopPrice(trade.Price, position) + }) + } +} + +func (s *RoiStopLoss) checkStopPrice(closePrice fixedpoint.Value, position *types.Position) { + if position.IsClosed() || position.IsDust(closePrice) || position.IsClosing() { + return + } + + roi := position.ROI(closePrice) + // logrus.Debugf("ROIStopLoss: price=%f roi=%s stop=%s", closePrice.Float64(), roi.Percentage(), s.Percentage.Neg().Percentage()) + if roi.Compare(s.Percentage.Neg()) < 0 { + // stop loss + Notify("[RoiStopLoss] %s stop loss triggered by ROI %s/%s, currentPrice = %f", position.Symbol, roi.Percentage(), s.Percentage.Neg().Percentage(), closePrice.Float64()) + if s.CancelActiveOrders { + _ = s.orderExecutor.GracefulCancel(context.Background()) + } + _ = s.orderExecutor.ClosePosition(context.Background(), fixedpoint.One, "roiStopLoss") + return + } +} diff --git a/pkg/qbtrade/exit_roi_take_profit.go b/pkg/qbtrade/exit_roi_take_profit.go new file mode 100644 index 0000000..d237ac7 --- /dev/null +++ b/pkg/qbtrade/exit_roi_take_profit.go @@ -0,0 +1,54 @@ +package qbtrade + +import ( + "context" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// RoiTakeProfit force takes the profit by the given ROI percentage. +type RoiTakeProfit struct { + Symbol string `json:"symbol"` + Percentage fixedpoint.Value `json:"percentage"` + CancelActiveOrders bool `json:"cancelActiveOrders"` + + // 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"` + + session *ExchangeSession + orderExecutor *GeneralOrderExecutor +} + +func (s *RoiTakeProfit) Subscribe(session *ExchangeSession) { + // use kline to handle roi stop + if s.Interval == "" { + s.Interval = types.Interval1m + } + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) +} + +func (s *RoiTakeProfit) Bind(session *ExchangeSession, orderExecutor *GeneralOrderExecutor) { + s.session = session + s.orderExecutor = orderExecutor + + 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) || position.IsClosing() { + return + } + + roi := position.ROI(closePrice) + if roi.Compare(s.Percentage) >= 0 { + // stop loss + Notify("[RoiTakeProfit] %s take profit is triggered by ROI %s/%s, price: %f", position.Symbol, roi.Percentage(), s.Percentage.Percentage(), kline.Close.Float64()) + if s.CancelActiveOrders { + _ = s.orderExecutor.GracefulCancel(context.Background()) + } + _ = orderExecutor.ClosePosition(context.Background(), fixedpoint.One, "roiTakeProfit") + return + } + })) +} diff --git a/pkg/qbtrade/exit_support_take_profit.go b/pkg/qbtrade/exit_support_take_profit.go new file mode 100644 index 0000000..c7e1815 --- /dev/null +++ b/pkg/qbtrade/exit_support_take_profit.go @@ -0,0 +1,132 @@ +package qbtrade + +import ( + "context" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// SupportTakeProfit finds the previous support price and take profit at the previous low. +type SupportTakeProfit struct { + Symbol string + types.IntervalWindow + + Ratio fixedpoint.Value `json:"ratio"` + + pivot *indicator.PivotLow + orderExecutor *GeneralOrderExecutor + session *ExchangeSession + activeOrders *ActiveOrderBook + currentSupportPrice fixedpoint.Value + + triggeredPrices []fixedpoint.Value +} + +func (s *SupportTakeProfit) Subscribe(session *ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) +} + +func (s *SupportTakeProfit) Bind(session *ExchangeSession, orderExecutor *GeneralOrderExecutor) { + s.session = session + s.orderExecutor = orderExecutor + s.activeOrders = 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() + + s.pivot = session.StandardIndicatorSet(s.Symbol).PivotLow(s.IntervalWindow) + + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.Interval, func(kline types.KLine) { + if !s.updateSupportPrice(kline.Close) { + return + } + + if !position.IsOpened(kline.Close) { + logrus.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 { + logrus.WithError(err).Errorf("cancel order failed") + } + + Notify("placing %s take profit order at price %f", s.Symbol, buyPrice.Float64()) + createdOrders, err := orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ + Symbol: s.Symbol, + Type: types.OrderTypeLimitMaker, + Side: types.SideTypeBuy, + Price: buyPrice, + Quantity: quantity, + Tag: "supportTakeProfit", + MarginSideEffect: types.SideEffectTypeAutoRepay, + }) + + if err != nil { + logrus.WithError(err).Errorf("can not submit orders: %+v", createdOrders) + } + + s.activeOrders.Add(createdOrders...) + })) +} + +func (s *SupportTakeProfit) updateSupportPrice(closePrice fixedpoint.Value) bool { + logrus.Infof("[supportTakeProfit] lows: %v", s.pivot.Values) + + groupDistance := 0.01 + minDistance := 0.05 + supportPrices := findPossibleSupportPrices(closePrice.Float64()*(1.0-minDistance), groupDistance, s.pivot.Values) + if len(supportPrices) == 0 { + return false + } + + logrus.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() { + logrus.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 { + logrus.Infof("setup next support take profit price at %f", nextSupportPrice.Float64()) + s.currentSupportPrice = nextSupportPrice + return true + } + + return false +} + +func findPossibleSupportPrices(closePrice float64, groupDistance float64, lows []float64) []float64 { + return floats.Group(floats.Lower(lows, closePrice), groupDistance) +} diff --git a/pkg/qbtrade/exit_test.go b/pkg/qbtrade/exit_test.go new file mode 100644 index 0000000..745da73 --- /dev/null +++ b/pkg/qbtrade/exit_test.go @@ -0,0 +1,8 @@ +package qbtrade + +import "testing" + +func TestExitMethod(t *testing.T) { + em := &ExitMethod{} + em.Subscribe(&ExchangeSession{}) +} diff --git a/pkg/qbtrade/exit_trailing_stop.go b/pkg/qbtrade/exit_trailing_stop.go new file mode 100644 index 0000000..b4ad180 --- /dev/null +++ b/pkg/qbtrade/exit_trailing_stop.go @@ -0,0 +1,192 @@ +package qbtrade + +import ( + "context" + "fmt" + + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/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) { + if s.Interval == "" { + s.Interval = types.Interval1m + } + // 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() + f := func(kline types.KLine) { + if err := s.checkStopPrice(kline.Close, position); err != nil { + log.WithError(err).Errorf("error") + } + } + + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.Interval, f)) + session.MarketDataStream.OnKLine(types.KLineWith(s.Symbol, s.Interval, f)) + + if !IsBackTesting && enableMarketTradeStop { + session.MarketDataStream.OnMarketTrade(types.TradeWith(position.Symbol, func(trade types.Trade) { + if err := s.checkStopPrice(trade.Price, position); err != nil { + log.WithError(err).Errorf("error") + } + })) + } +} + +// getRatio returns the ratio between the price and the average cost of the position +func (s *TrailingStop2) getRatio(price fixedpoint.Value, position *types.Position) (fixedpoint.Value, error) { + switch s.Side { + case types.SideTypeBuy: + // for short position, it's: + // (avg_cost - price) / avg_cost + return position.AverageCost.Sub(price).Div(position.AverageCost), nil + + case types.SideTypeSell: + return price.Sub(position.AverageCost).Div(position.AverageCost), nil + + default: + if position.IsLong() { + return price.Sub(position.AverageCost).Div(position.AverageCost), nil + } else if position.IsShort() { + return position.AverageCost.Sub(price).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) || position.IsClosing() { + 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: %s ROI %s > minimal profit ratio %s", s.Symbol, roi.Percentage(), s.MinProfit.Percentage()) + 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) + default: + if position.IsLong() { + s.latestHigh = fixedpoint.Max(price, s.latestHigh) + } else if position.IsShort() { + s.latestHigh = fixedpoint.Min(price, s.latestHigh) + } + } + } + + if !s.activated { + return nil + } + + switch s.Side { + case types.SideTypeBuy: + change := price.Sub(s.latestHigh).Div(s.latestHigh) + if change.Compare(s.CallbackRate) >= 0 { + // submit order + return s.triggerStop(price) + } + case types.SideTypeSell: + change := s.latestHigh.Sub(price).Div(s.latestHigh) + if change.Compare(s.CallbackRate) >= 0 { + // submit order + return s.triggerStop(price) + } + default: + if position.IsLong() { + change := s.latestHigh.Sub(price).Div(s.latestHigh) + if change.Compare(s.CallbackRate) >= 0 { + // submit order + return s.triggerStop(price) + } + } else if position.IsShort() { + change := price.Sub(s.latestHigh).Div(s.latestHigh) + 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 %s tailingStop is triggered. price: %f callbackRate: %s", s.Symbol, s.ActivationRatio.Percentage(), price.Float64(), s.CallbackRate.Percentage()) + ctx := context.Background() + p := fixedpoint.One + if !s.ClosePosition.IsZero() { + p = s.ClosePosition + } + + tagName := fmt.Sprintf("trailingStop:activation=%s,callback=%s", s.ActivationRatio.Percentage(), s.CallbackRate.Percentage()) + + return s.orderExecutor.ClosePosition(ctx, p, tagName) +} diff --git a/pkg/qbtrade/exit_trailing_stop_test.go b/pkg/qbtrade/exit_trailing_stop_test.go new file mode 100644 index 0000000..21e6bf5 --- /dev/null +++ b/pkg/qbtrade/exit_trailing_stop_test.go @@ -0,0 +1,184 @@ +package qbtrade + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/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().SubmitOrder(gomock.Any(), types.SubmitOrder{ + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + Type: types.OrderTypeMarket, + Market: market, + Quantity: fixedpoint.NewFromFloat(1.0), + Tag: "trailingStop:activation=1%,callback=1%", + MarginSideEffect: types.SideEffectTypeAutoRepay, + }) + + 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().SubmitOrder(gomock.Any(), types.SubmitOrder{ + Symbol: "BTCUSDT", + Side: types.SideTypeSell, + Type: types.OrderTypeMarket, + Market: market, + Quantity: fixedpoint.NewFromFloat(1.0), + Tag: "trailingStop:activation=1%,callback=1%", + MarginSideEffect: types.SideEffectTypeAutoRepay, + }) + + 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/qbtrade/graceful_shutdown.go b/pkg/qbtrade/graceful_shutdown.go new file mode 100644 index 0000000..520edbb --- /dev/null +++ b/pkg/qbtrade/graceful_shutdown.go @@ -0,0 +1,44 @@ +package qbtrade + +import ( + "context" + "sync" + + "github.com/sirupsen/logrus" +) + +type ShutdownHandler func(ctx context.Context, wg *sync.WaitGroup) + +//go:generate callbackgen -type GracefulShutdown +type GracefulShutdown struct { + shutdownCallbacks []ShutdownHandler +} + +// Shutdown is a blocking call to emit all shutdown callbacks at the same time. +// The context object here should not be canceled context, you need to create a todo context. +func (g *GracefulShutdown) Shutdown(shutdownCtx context.Context) { + var wg sync.WaitGroup + wg.Add(len(g.shutdownCallbacks)) + go g.EmitShutdown(shutdownCtx, &wg) + wg.Wait() +} + +// OnShutdown helps you register your shutdown handler +// the first context object is where you want to register your shutdown handler, where the context has the isolated storage. +// in your handler, you will get another context for the timeout context. +func OnShutdown(ctx context.Context, f ShutdownHandler) { + isolatedContext := GetIsolationFromContext(ctx) + isolatedContext.gracefulShutdown.OnShutdown(f) +} + +func Shutdown(shutdownCtx context.Context) { + + isolatedContext := GetIsolationFromContext(shutdownCtx) + if isolatedContext == defaultIsolation { + logrus.Infof("qbtrade shutting down...") + } else { + logrus.Infof("qbtrade shutting down (custom isolation)...") + } + + isolatedContext.gracefulShutdown.Shutdown(shutdownCtx) +} diff --git a/pkg/qbtrade/gracefulshutdown_callbacks.go b/pkg/qbtrade/gracefulshutdown_callbacks.go new file mode 100644 index 0000000..fe10c01 --- /dev/null +++ b/pkg/qbtrade/gracefulshutdown_callbacks.go @@ -0,0 +1,18 @@ +// Code generated by "callbackgen -type GracefulShutdown"; DO NOT EDIT. + +package qbtrade + +import ( + "context" + "sync" +) + +func (g *GracefulShutdown) OnShutdown(cb ShutdownHandler) { + g.shutdownCallbacks = append(g.shutdownCallbacks, cb) +} + +func (g *GracefulShutdown) EmitShutdown(ctx context.Context, wg *sync.WaitGroup) { + for _, cb := range g.shutdownCallbacks { + cb(ctx, wg) + } +} diff --git a/pkg/qbtrade/indicator_set.go b/pkg/qbtrade/indicator_set.go new file mode 100644 index 0000000..ec78f24 --- /dev/null +++ b/pkg/qbtrade/indicator_set.go @@ -0,0 +1,114 @@ +package qbtrade + +import ( + "github.com/sirupsen/logrus" + + indicatorv2 "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator/v2" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// IndicatorSet is the v2 standard indicator set +// This will replace StandardIndicator in the future +type IndicatorSet struct { + Symbol string + + stream types.Stream + store *MarketDataStore + + // caches + kLines map[types.Interval]*indicatorv2.KLineStream + closePrices map[types.Interval]*indicatorv2.PriceStream +} + +func NewIndicatorSet(symbol string, stream types.Stream, store *MarketDataStore) *IndicatorSet { + return &IndicatorSet{ + Symbol: symbol, + store: store, + stream: stream, + + kLines: make(map[types.Interval]*indicatorv2.KLineStream), + closePrices: make(map[types.Interval]*indicatorv2.PriceStream), + } +} + +func (i *IndicatorSet) KLines(interval types.Interval) *indicatorv2.KLineStream { + if kLines, ok := i.kLines[interval]; ok { + return kLines + } + + kLines := indicatorv2.KLines(i.stream, i.Symbol, interval) + if kLinesWindow, ok := i.store.KLinesOfInterval(interval); ok { + kLines.BackFill(*kLinesWindow) + } else { + logrus.Warnf("market data store %s kline history not found, unable to backfill the kline stream data", interval) + } + + i.kLines[interval] = kLines + return kLines +} + +func (i *IndicatorSet) OPEN(interval types.Interval) *indicatorv2.PriceStream { + return indicatorv2.OpenPrices(i.KLines(interval)) +} + +func (i *IndicatorSet) HIGH(interval types.Interval) *indicatorv2.PriceStream { + return indicatorv2.HighPrices(i.KLines(interval)) +} + +func (i *IndicatorSet) LOW(interval types.Interval) *indicatorv2.PriceStream { + return indicatorv2.LowPrices(i.KLines(interval)) +} + +func (i *IndicatorSet) CLOSE(interval types.Interval) *indicatorv2.PriceStream { + if closePrices, ok := i.closePrices[interval]; ok { + return closePrices + } + + closePrices := indicatorv2.ClosePrices(i.KLines(interval)) + i.closePrices[interval] = closePrices + return closePrices +} + +func (i *IndicatorSet) VOLUME(interval types.Interval) *indicatorv2.PriceStream { + return indicatorv2.Volumes(i.KLines(interval)) +} + +func (i *IndicatorSet) RSI(iw types.IntervalWindow) *indicatorv2.RSIStream { + return indicatorv2.RSI2(i.CLOSE(iw.Interval), iw.Window) +} + +func (i *IndicatorSet) EMA(iw types.IntervalWindow) *indicatorv2.EWMAStream { + return i.EWMA(iw) +} + +func (i *IndicatorSet) EWMA(iw types.IntervalWindow) *indicatorv2.EWMAStream { + return indicatorv2.EWMA2(i.CLOSE(iw.Interval), iw.Window) +} + +func (i *IndicatorSet) STOCH(iw types.IntervalWindow, dPeriod int) *indicatorv2.StochStream { + return indicatorv2.Stoch(i.KLines(iw.Interval), iw.Window, dPeriod) +} + +func (i *IndicatorSet) BOLL(iw types.IntervalWindow, k float64) *indicatorv2.BOLLStream { + return indicatorv2.BOLL(i.CLOSE(iw.Interval), iw.Window, k) +} + +func (i *IndicatorSet) Keltner(iw types.IntervalWindow, atrLength int) *indicatorv2.KeltnerStream { + return indicatorv2.Keltner(i.KLines(iw.Interval), iw.Window, atrLength) +} + +func (i *IndicatorSet) MACD(interval types.Interval, shortWindow, longWindow, signalWindow int) *indicatorv2.MACDStream { + return indicatorv2.MACD2(i.CLOSE(interval), shortWindow, longWindow, signalWindow) +} + +func (i *IndicatorSet) ATR(interval types.Interval, window int) *indicatorv2.ATRStream { + return indicatorv2.ATR2(i.KLines(interval), window) +} + +func (i *IndicatorSet) ATRP(interval types.Interval, window int) *indicatorv2.ATRPStream { + return indicatorv2.ATRP2(i.KLines(interval), window) +} + +func (i *IndicatorSet) ADX(interval types.Interval, window int) *indicatorv2.ADXStream { + return indicatorv2.ADX(i.KLines(interval), window) +} diff --git a/pkg/qbtrade/indicator_set_test.go b/pkg/qbtrade/indicator_set_test.go new file mode 100644 index 0000000..3797ce2 --- /dev/null +++ b/pkg/qbtrade/indicator_set_test.go @@ -0,0 +1,58 @@ +package qbtrade + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func newTestIndicatorSet() *IndicatorSet { + symbol := "BTCUSDT" + store := NewMarketDataStore(symbol) + store.KLineWindows[types.Interval1m] = &types.KLineWindow{ + {Open: number(19000.0), Close: number(19100.0)}, + {Open: number(19100.0), Close: number(19200.0)}, + {Open: number(19200.0), Close: number(19300.0)}, + {Open: number(19300.0), Close: number(19200.0)}, + {Open: number(19200.0), Close: number(19100.0)}, + {Open: number(19100.0), Close: number(19500.0)}, + {Open: number(19500.0), Close: number(19600.0)}, + {Open: number(19600.0), Close: number(19700.0)}, + } + + stream := types.NewStandardStream() + indicatorSet := NewIndicatorSet(symbol, &stream, store) + return indicatorSet +} + +func TestIndicatorSet_closeCache(t *testing.T) { + indicatorSet := newTestIndicatorSet() + + close1m := indicatorSet.CLOSE(types.Interval1m) + assert.NotNil(t, close1m) + + close1m2 := indicatorSet.CLOSE(types.Interval1m) + assert.Equal(t, close1m, close1m2) +} + +func TestIndicatorSet_RSI(t *testing.T) { + indicatorSet := newTestIndicatorSet() + + rsi1m := indicatorSet.RSI(types.IntervalWindow{Interval: types.Interval1m, Window: 7}) + assert.NotNil(t, rsi1m) + + rsiLast := rsi1m.Last(0) + assert.InDelta(t, 80, rsiLast, 0.0000001) +} + +func TestIndicatorSet_EWMA(t *testing.T) { + indicatorSet := newTestIndicatorSet() + + ema1m := indicatorSet.EWMA(types.IntervalWindow{Interval: types.Interval1m, Window: 7}) + assert.NotNil(t, ema1m) + + emaLast := ema1m.Last(0) + assert.InDelta(t, 19424.224853515625, emaLast, 0.0000001) +} diff --git a/pkg/qbtrade/interact.go b/pkg/qbtrade/interact.go new file mode 100644 index 0000000..be78c98 --- /dev/null +++ b/pkg/qbtrade/interact.go @@ -0,0 +1,635 @@ +package qbtrade + +import ( + "context" + "fmt" + "path" + "reflect" + "strconv" + "strings" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/dynamic" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/interact" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type PositionCloser interface { + ClosePosition(ctx context.Context, percentage fixedpoint.Value) error +} + +type PositionResetter interface { + ResetPosition() error +} + +type PositionReader interface { + CurrentPosition() *types.Position +} + +type closePositionContext struct { + signature string + closer PositionCloser + percentage fixedpoint.Value +} + +type modifyPositionContext struct { + signature string + modifier *types.Position + target string + value fixedpoint.Value +} + +type CoreInteraction struct { + environment *Environment + trader *Trader + + exchangeStrategies map[string]SingleExchangeStrategy + closePositionContext closePositionContext + modifyPositionContext modifyPositionContext +} + +func NewCoreInteraction(environment *Environment, trader *Trader) *CoreInteraction { + return &CoreInteraction{ + environment: environment, + trader: trader, + exchangeStrategies: make(map[string]SingleExchangeStrategy), + } +} + +type SimpleInteraction struct { + Command string + Description string + F interface{} + Cmd *interact.Command +} + +func (it *SimpleInteraction) Commands(i *interact.Interact) { + it.Cmd = i.PrivateCommand(it.Command, it.Description, it.F) +} + +func RegisterCommand(command, desc string, f interface{}) *interact.Command { + it := &SimpleInteraction{ + Command: command, + Description: desc, + F: f, + } + interact.AddCustomInteraction(it) + return it.Cmd +} + +func (it *CoreInteraction) Commands(i *interact.Interact) { + i.PrivateCommand("/sessions", "List Exchange Sessions", func(reply interact.Reply) error { + switch r := reply.(type) { + case *interact.SlackReply: + // call slack specific api to build the reply object + _ = r + } + + message := "Your connected sessions:\n" + for name, session := range it.environment.Sessions() { + message += "- " + name + " (" + session.ExchangeName.String() + ")\n" + } + + reply.Message(message) + return nil + }) + + i.PrivateCommand("/balances", "Show balances", func(reply interact.Reply) error { + reply.Message("Please select an exchange session") + for name := range it.environment.Sessions() { + reply.AddButton(name, "session", name) + } + return nil + }).Next(func(sessionName string, reply interact.Reply) error { + session, ok := it.environment.Session(sessionName) + if !ok { + reply.Message(fmt.Sprintf("Session %s not found", sessionName)) + return fmt.Errorf("session %s not found", sessionName) + } + + message := "Your balances\n" + balances := session.GetAccount().Balances() + for _, balance := range balances { + if balance.Total().IsZero() { + continue + } + + message += "- " + balance.String() + "\n" + } + + reply.Message(message) + return nil + }) + + i.PrivateCommand("/position", "Show Position", func(reply interact.Reply) error { + // it.trader.exchangeStrategies + // send symbol options + if strategies, err := filterStrategiesByInterface(it.exchangeStrategies, (*PositionReader)(nil)); err == nil && len(strategies) > 0 { + reply.AddMultipleButtons(generateStrategyButtonsForm(strategies)) + reply.Message("Please choose one strategy") + } else { + reply.Message("No any strategy supports PositionReader") + } + return nil + }).Cycle(func(signature string, reply interact.Reply) error { + strategy, ok := it.exchangeStrategies[signature] + if !ok { + reply.Message("Strategy not found") + return fmt.Errorf("strategy %s not found", signature) + } + + reader, implemented := strategy.(PositionReader) + if !implemented { + reply.Message(fmt.Sprintf("Strategy %s does not support position close", signature)) + return fmt.Errorf("strategy %s does not implement PositionCloser interface", signature) + } + + position := reader.CurrentPosition() + if position == nil || position.Base.IsZero() { + reply.Message(fmt.Sprintf("Strategy %q has no opened position", signature)) + return nil + } + + reply.Send("Your current position:") + reply.Message(position.PlainText()) + + if kc, ok := reply.(interact.KeyboardController); ok { + kc.RemoveKeyboard() + } + + return nil + }) + + i.PrivateCommand("/resetposition", "Reset position", func(reply interact.Reply) error { + strategies, err := filterStrategies(it.exchangeStrategies, func(s SingleExchangeStrategy) bool { + return testInterface(s, (*PositionResetter)(nil)) || hasTypeField(s, &types.Position{}) + }) + + if err == nil && len(strategies) > 0 { + reply.AddMultipleButtons(generateStrategyButtonsForm(strategies)) + reply.Message("Please choose one strategy") + } else { + reply.Message("No strategy supports PositionResetter interface") + } + return nil + }).Next(func(signature string, reply interact.Reply) error { + strategy, ok := it.exchangeStrategies[signature] + if !ok { + reply.Message("Strategy not found") + return fmt.Errorf("strategy %s not found", signature) + } + + resetter, implemented := strategy.(PositionResetter) + if implemented { + return resetter.ResetPosition() + } + + reset := false + err := dynamic.IterateFields(strategy, func(ft reflect.StructField, fv reflect.Value) error { + posType := reflect.TypeOf(&types.Position{}) + if ft.Type == posType { + if pos, typeOk := fv.Interface().(*types.Position); typeOk { + pos.Reset() + reset = true + } + } + return nil + }) + + if reset { + reply.Message("Position is reset") + } + + return err + }) + + i.PrivateCommand("/closeposition", "Close position", func(reply interact.Reply) error { + // it.trader.exchangeStrategies + // send symbol options + if strategies, err := filterStrategiesByInterface(it.exchangeStrategies, (*PositionCloser)(nil)); err == nil && len(strategies) > 0 { + reply.AddMultipleButtons(generateStrategyButtonsForm(strategies)) + reply.Message("Please choose one strategy") + } else { + reply.Message("No strategy supports PositionCloser interface") + } + return nil + }).Next(func(signature string, reply interact.Reply) error { + strategy, ok := it.exchangeStrategies[signature] + if !ok { + reply.Message("Strategy not found") + return fmt.Errorf("strategy %s not found", signature) + } + + closer, implemented := strategy.(PositionCloser) + if !implemented { + reply.Message(fmt.Sprintf("Strategy %s does not support position close", signature)) + return fmt.Errorf("strategy %s does not implement PositionCloser interface", signature) + } + + it.closePositionContext.closer = closer + it.closePositionContext.signature = signature + + if reader, implemented := strategy.(PositionReader); implemented { + position := reader.CurrentPosition() + if position != nil { + reply.Send("Your current position:") + reply.Send(position.PlainText()) + + if position.Base.IsZero() { + reply.Message("No opened position") + if kc, ok := reply.(interact.KeyboardController); ok { + kc.RemoveKeyboard() + } + return fmt.Errorf("no opened position") + } + } + } + + reply.Message("Choose or enter the percentage to close") + for _, p := range []string{"5%", "25%", "50%", "80%", "100%"} { + reply.AddButton(p, "percentage", p) + } + + return nil + }).Next(func(percentageStr string, reply interact.Reply) error { + percentage, err := fixedpoint.NewFromString(percentageStr) + if err != nil { + reply.Message(fmt.Sprintf("%q is not a valid percentage string", percentageStr)) + return err + } + + if kc, ok := reply.(interact.KeyboardController); ok { + kc.RemoveKeyboard() + } + + err = it.closePositionContext.closer.ClosePosition(context.Background(), percentage) + if err != nil { + reply.Message(fmt.Sprintf("Failed to close the position, %s", err.Error())) + return err + } + + reply.Message("Done") + return nil + }) + + i.PrivateCommand("/status", "Strategy Status", func(reply interact.Reply) error { + // it.trader.exchangeStrategies + // send symbol options + if strategies, err := filterStrategiesByInterface(it.exchangeStrategies, (*StrategyStatusReader)(nil)); err == nil && len(strategies) > 0 { + reply.AddMultipleButtons(generateStrategyButtonsForm(strategies)) + reply.Message("Please choose a strategy") + } else { + reply.Message("No strategy supports StrategyStatusReader") + } + return nil + }).Next(func(signature string, reply interact.Reply) error { + defer func() { + if kc, ok := reply.(interact.KeyboardController); ok { + kc.RemoveKeyboard() + } + }() + + strategy, ok := it.exchangeStrategies[signature] + if !ok { + reply.Message("Strategy not found") + return fmt.Errorf("strategy %s not found", signature) + } + + controller, implemented := strategy.(StrategyStatusReader) + if !implemented { + reply.Message(fmt.Sprintf("Strategy %s does not support StrategyStatusReader", signature)) + return fmt.Errorf("strategy %s does not implement StrategyStatusReader", signature) + } + + status := controller.GetStatus() + + if status == types.StrategyStatusRunning { + reply.Message(fmt.Sprintf("Strategy %s is running.", signature)) + } else if status == types.StrategyStatusStopped { + reply.Message(fmt.Sprintf("Strategy %s is not running.", signature)) + } + + return nil + }) + + i.PrivateCommand("/suspend", "Suspend Strategy", func(reply interact.Reply) error { + // it.trader.exchangeStrategies + // send symbol options + if strategies, err := filterStrategiesByInterface(it.exchangeStrategies, (*StrategyToggler)(nil)); err == nil && len(strategies) > 0 { + reply.AddMultipleButtons(generateStrategyButtonsForm(strategies)) + reply.Message("Please choose one strategy") + } else { + reply.Message("No strategy supports StrategyToggler") + } + return nil + }).Next(func(signature string, reply interact.Reply) error { + defer func() { + if kc, ok := reply.(interact.KeyboardController); ok { + kc.RemoveKeyboard() + } + }() + + strategy, ok := it.exchangeStrategies[signature] + if !ok { + reply.Message("Strategy not found") + return fmt.Errorf("strategy %s not found", signature) + } + + controller, implemented := strategy.(StrategyToggler) + if !implemented { + reply.Message(fmt.Sprintf("Strategy %s does not support StrategyToggler", signature)) + return fmt.Errorf("strategy %s does not implement StrategyToggler", signature) + } + + // Check strategy status before suspend + if controller.GetStatus() != types.StrategyStatusRunning { + reply.Message(fmt.Sprintf("Strategy %s is not running.", signature)) + return nil + } + + if err := controller.Suspend(); err != nil { + reply.Message(fmt.Sprintf("Failed to suspend the strategy, %s", err.Error())) + return err + } + + reply.Message(fmt.Sprintf("Strategy %s is now suspended.", signature)) + return nil + }) + + i.PrivateCommand("/resume", "Resume Strategy", func(reply interact.Reply) error { + // it.trader.exchangeStrategies + // send symbol options + if strategies, err := filterStrategiesByInterface(it.exchangeStrategies, (*StrategyToggler)(nil)); err == nil && len(strategies) > 0 { + reply.AddMultipleButtons(generateStrategyButtonsForm(strategies)) + reply.Message("Please choose one strategy") + } else { + reply.Message("No strategy supports StrategyToggler") + } + return nil + }).Next(func(signature string, reply interact.Reply) error { + defer func() { + if kc, ok := reply.(interact.KeyboardController); ok { + kc.RemoveKeyboard() + } + }() + + strategy, ok := it.exchangeStrategies[signature] + if !ok { + reply.Message("Strategy not found") + return fmt.Errorf("strategy %s not found", signature) + } + + controller, implemented := strategy.(StrategyToggler) + if !implemented { + reply.Message(fmt.Sprintf("Strategy %s does not support StrategyToggler", signature)) + return fmt.Errorf("strategy %s does not implement StrategyToggler", signature) + } + + // Check strategy status before suspend + if controller.GetStatus() != types.StrategyStatusStopped { + reply.Message(fmt.Sprintf("Strategy %s is running.", signature)) + return nil + } + + if err := controller.Resume(); err != nil { + reply.Message(fmt.Sprintf("Failed to resume the strategy, %s", err.Error())) + return err + } + + reply.Message(fmt.Sprintf("Strategy %s is now resumed.", signature)) + return nil + }) + + i.PrivateCommand("/emergencystop", "Emergency Stop", func(reply interact.Reply) error { + // it.trader.exchangeStrategies + // send symbol options + if strategies, err := filterStrategiesByInterface(it.exchangeStrategies, (*EmergencyStopper)(nil)); err == nil && len(strategies) > 0 { + reply.AddMultipleButtons(generateStrategyButtonsForm(strategies)) + reply.Message("Please choose one strategy") + } else { + reply.Message("No strategy supports EmergencyStopper") + } + return nil + }).Next(func(signature string, reply interact.Reply) error { + strategy, ok := it.exchangeStrategies[signature] + if !ok { + reply.Message("Strategy not found") + return fmt.Errorf("strategy %s not found", signature) + } + + controller, implemented := strategy.(EmergencyStopper) + if !implemented { + reply.Message(fmt.Sprintf("Strategy %s does not support EmergencyStopper", signature)) + return fmt.Errorf("strategy %s does not implement EmergencyStopper", signature) + } + + if kc, ok := reply.(interact.KeyboardController); ok { + kc.RemoveKeyboard() + } + + if err := controller.EmergencyStop(); err != nil { + reply.Message(fmt.Sprintf("Failed to emergency stop the strategy, %s", err.Error())) + return err + } + + reply.Message(fmt.Sprintf("Strategy %s stopped and the position closed.", signature)) + return nil + }) + + // Position updater + i.PrivateCommand("/modifyposition", "Modify Strategy Position", func(reply interact.Reply) error { + // it.trader.exchangeStrategies + // send symbol options + if strategies, err := filterStrategiesByField(it.exchangeStrategies, "Position", reflect.TypeOf(&types.Position{})); err == nil && len(strategies) > 0 { + reply.AddMultipleButtons(generateStrategyButtonsForm(strategies)) + reply.Message("Please choose one strategy") + } else { + reply.Message("No strategy supports Position Modify") + } + return nil + }).Next(func(signature string, reply interact.Reply) error { + strategy, ok := it.exchangeStrategies[signature] + if !ok { + reply.Message("Strategy not found") + return fmt.Errorf("strategy %s not found", signature) + } + + r := reflect.ValueOf(strategy).Elem() + f := r.FieldByName("Position") + positionModifier, implemented := f.Interface().(*types.Position) + if !implemented { + reply.Message(fmt.Sprintf("Strategy %s does not support Position Modify", signature)) + return fmt.Errorf("strategy %s does not implement Position Modify", signature) + } + + it.modifyPositionContext.modifier = positionModifier + it.modifyPositionContext.signature = signature + + reply.Message("Please choose what you want to change") + reply.AddButton("base", "Base", "base") + reply.AddButton("quote", "Quote", "quote") + reply.AddButton("cost", "Average Cost", "cost") + + return nil + }).Next(func(target string, reply interact.Reply) error { + if target != "base" && target != "quote" && target != "cost" { + reply.Message(fmt.Sprintf("%q is not a valid target string", target)) + return fmt.Errorf("%q is not a valid target string", target) + } + + it.modifyPositionContext.target = target + + reply.Message("Enter the amount to change") + + return nil + }).Next(func(valueStr string, reply interact.Reply) error { + value, err := fixedpoint.NewFromString(valueStr) + if err != nil { + reply.Message(fmt.Sprintf("%q is not a valid value string", valueStr)) + return err + } + + if kc, ok := reply.(interact.KeyboardController); ok { + kc.RemoveKeyboard() + } + + if it.modifyPositionContext.target == "base" { + err = it.modifyPositionContext.modifier.ModifyBase(value) + } else if it.modifyPositionContext.target == "quote" { + err = it.modifyPositionContext.modifier.ModifyQuote(value) + } else if it.modifyPositionContext.target == "cost" { + err = it.modifyPositionContext.modifier.ModifyAverageCost(value) + } + + if err != nil { + reply.Message(fmt.Sprintf("Failed to modify position of the strategy, %s", err.Error())) + return err + } + + reply.Message(fmt.Sprintf("Position of strategy %s modified.", it.modifyPositionContext.signature)) + return nil + }) +} + +func (it *CoreInteraction) Initialize() error { + // re-map exchange strategies into the signature-object map + for sessionID, strategies := range it.trader.exchangeStrategies { + for _, strategy := range strategies { + signature, err := getStrategySignature(strategy) + if err != nil { + return err + } + + key := sessionID + "." + signature + it.exchangeStrategies[key] = strategy + } + } + return nil +} + +// getStrategySignature returns strategy instance unique signature +func getStrategySignature(strategy SingleExchangeStrategy) (string, error) { + // Returns instance ID + var signature = dynamic.CallID(strategy) + if signature != "" { + return signature, nil + } + + // Use reflect to build instance signature + rv := reflect.ValueOf(strategy).Elem() + if rv.Kind() != reflect.Struct { + return "", fmt.Errorf("strategy %T instance is not a struct", strategy) + } + + signature = path.Base(rv.Type().PkgPath()) + for i := 0; i < rv.NumField(); i++ { + field := rv.Field(i) + fieldName := rv.Type().Field(i).Name + if field.Kind() == reflect.String && fieldName != "Status" { + str := field.String() + if len(str) > 0 { + signature += "." + field.String() + } + } + } + + return signature, nil +} + +func parseFloatPercent(s string, bitSize int) (f float64, err error) { + i := strings.Index(s, "%") + if i < 0 { + return strconv.ParseFloat(s, bitSize) + } + + f, err = strconv.ParseFloat(s[:i], bitSize) + if err != nil { + return 0, err + } + return f / 100.0, nil +} + +func getStrategySignatures(exchangeStrategies map[string]SingleExchangeStrategy) []string { + var strategies []string + for signature := range exchangeStrategies { + strategies = append(strategies, signature) + } + + return strategies +} + +// filterStrategies filters the exchange strategies by a filter tester function +// if filter() returns true, the strategy will be added to the returned map. +func filterStrategies(exchangeStrategies map[string]SingleExchangeStrategy, filter func(s SingleExchangeStrategy) bool) (map[string]SingleExchangeStrategy, error) { + retStrategies := make(map[string]SingleExchangeStrategy) + for signature, strategy := range exchangeStrategies { + if ok := filter(strategy); ok { + retStrategies[signature] = strategy + } + } + + return retStrategies, nil +} + +func hasTypeField(obj interface{}, typ interface{}) bool { + targetType := reflect.TypeOf(typ) + found := false + _ = dynamic.IterateFields(obj, func(ft reflect.StructField, fv reflect.Value) error { + if fv.Type() == targetType { + found = true + } + + return nil + }) + return found +} + +func testInterface(obj interface{}, checkType interface{}) bool { + rt := reflect.TypeOf(checkType).Elem() + return reflect.TypeOf(obj).Implements(rt) +} + +func filterStrategiesByInterface(exchangeStrategies map[string]SingleExchangeStrategy, checkInterface interface{}) (map[string]SingleExchangeStrategy, error) { + rt := reflect.TypeOf(checkInterface).Elem() + return filterStrategies(exchangeStrategies, func(s SingleExchangeStrategy) bool { + return reflect.TypeOf(s).Implements(rt) + }) +} + +func filterStrategiesByField(exchangeStrategies map[string]SingleExchangeStrategy, fieldName string, fieldType reflect.Type) (map[string]SingleExchangeStrategy, error) { + return filterStrategies(exchangeStrategies, func(s SingleExchangeStrategy) bool { + r := reflect.ValueOf(s).Elem() + f := r.FieldByName(fieldName) + return !f.IsZero() && f.Type() == fieldType + }) +} + +func generateStrategyButtonsForm(strategies map[string]SingleExchangeStrategy) [][3]string { + var buttonsForm [][3]string + signatures := getStrategySignatures(strategies) + for _, signature := range signatures { + buttonsForm = append(buttonsForm, [3]string{signature, "strategy", signature}) + } + + return buttonsForm +} diff --git a/pkg/qbtrade/interact_modify.go b/pkg/qbtrade/interact_modify.go new file mode 100644 index 0000000..74b2575 --- /dev/null +++ b/pkg/qbtrade/interact_modify.go @@ -0,0 +1,66 @@ +package qbtrade + +import ( + "encoding/json" + "fmt" + "reflect" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/dynamic" + "git.qtrade.icu/lychiyu/qbtrade/pkg/interact" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" + log "github.com/sirupsen/logrus" +) + +func RegisterModifier(s interface{}) { + val := reflect.ValueOf(s) + if val.Type().Kind() == util.Pointer { + val = val.Elem() + } + var targetName string + var currVal interface{} + var mapping map[string]string + // currently we only allow users to modify the first layer of fields + RegisterCommand("/modify", "Modify config", func(reply interact.Reply) { + reply.Message("Please choose the field name in config to modify:") + mapping = make(map[string]string) + dynamic.GetModifiableFields(val, func(tagName, name string) { + mapping[tagName] = name + reply.AddButton(tagName, tagName, tagName) + }) + }).Next(func(target string, reply interact.Reply) error { + targetName = mapping[target] + field, ok := dynamic.GetModifiableField(val, targetName) + if !ok { + reply.Message(fmt.Sprintf("target %s is not modifiable", targetName)) + return fmt.Errorf("target %s is not modifiable", targetName) + } + currVal = field.Interface() + if e, err := json.Marshal(currVal); err == nil { + currVal = string(e) + } + reply.Message(fmt.Sprintf("Please enter the new value, current value: %v", currVal)) + return nil + }).Next(func(value string, reply interact.Reply) { + log.Infof("try to modify from %s to %s", currVal, value) + if kc, ok := reply.(interact.KeyboardController); ok { + kc.RemoveKeyboard() + } + field, ok := dynamic.GetModifiableField(val, targetName) + if !ok { + reply.Message(fmt.Sprintf("target %s is not modifiable", targetName)) + return + } + x := reflect.New(field.Type()) + xi := x.Interface() + if err := json.Unmarshal([]byte(value), &xi); err != nil { + reply.Message(fmt.Sprintf("fail to unmarshal the value: %s, err: %v", value, err)) + return + } + field.Set(x.Elem()) + newVal := field.Interface() + if e, err := json.Marshal(value); err == nil { + newVal = string(e) + } + reply.Message(fmt.Sprintf("update to %v successfully", newVal)) + }) +} diff --git a/pkg/qbtrade/interact_test.go b/pkg/qbtrade/interact_test.go new file mode 100644 index 0000000..d4028db --- /dev/null +++ b/pkg/qbtrade/interact_test.go @@ -0,0 +1,58 @@ +package qbtrade + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type myStrategy struct { + Symbol string `json:"symbol"` + Position *types.Position +} + +func (m *myStrategy) ID() string { + return "mystrategy" +} + +func (m *myStrategy) InstanceID() string { + return fmt.Sprintf("%s:%s", m.ID(), m.Symbol) +} + +func (m *myStrategy) ClosePosition(ctx context.Context, percentage fixedpoint.Value) error { + return nil +} + +func (m *myStrategy) Run(ctx context.Context, orderExecutor OrderExecutor, session *ExchangeSession) error { + return nil +} + +func Test_getStrategySignature(t *testing.T) { + signature, err := getStrategySignature(&myStrategy{ + Symbol: "BTCUSDT", + }) + assert.NoError(t, err) + assert.Equal(t, "mystrategy:BTCUSDT", signature) +} + +func Test_hasTypeField(t *testing.T) { + s := &myStrategy{ + Symbol: "BTCUSDT", + } + ok := hasTypeField(s, &types.Position{}) + assert.True(t, ok) +} + +func Test_testInterface(t *testing.T) { + s := &myStrategy{ + Symbol: "BTCUSDT", + } + + ok := testInterface(s, (*PositionCloser)(nil)) + assert.True(t, ok) +} diff --git a/pkg/qbtrade/isolation.go b/pkg/qbtrade/isolation.go new file mode 100644 index 0000000..b2f4739 --- /dev/null +++ b/pkg/qbtrade/isolation.go @@ -0,0 +1,56 @@ +package qbtrade + +import ( + "context" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/service" +) + +const IsolationContextKey = "qbtrade" + +var defaultIsolation = NewDefaultIsolation() + +type Isolation struct { + gracefulShutdown GracefulShutdown + persistenceServiceFacade *service.PersistenceServiceFacade +} + +func NewDefaultIsolation() *Isolation { + return &Isolation{ + gracefulShutdown: GracefulShutdown{}, + persistenceServiceFacade: defaultPersistenceServiceFacade, + } +} + +func NewIsolation(persistenceFacade *service.PersistenceServiceFacade) *Isolation { + return &Isolation{ + gracefulShutdown: GracefulShutdown{}, + persistenceServiceFacade: persistenceFacade, + } +} + +func GetIsolationFromContext(ctx context.Context) *Isolation { + isolatedContext, ok := ctx.Value(IsolationContextKey).(*Isolation) + if ok { + return isolatedContext + } + + return defaultIsolation +} + +// NewTodoContextWithExistingIsolation creates a new context object with the existing isolation of the parent context. +func NewTodoContextWithExistingIsolation(parent context.Context) context.Context { + isolatedContext := GetIsolationFromContext(parent) + todo := context.WithValue(context.TODO(), IsolationContextKey, isolatedContext) + return todo +} + +// NewContextWithIsolation creates a new context from the parent context with a custom isolation +func NewContextWithIsolation(parent context.Context, isolation *Isolation) context.Context { + return context.WithValue(parent, IsolationContextKey, isolation) +} + +// NewContextWithDefaultIsolation creates a new context from the parent context with a default isolation +func NewContextWithDefaultIsolation(parent context.Context) context.Context { + return context.WithValue(parent, IsolationContextKey, defaultIsolation) +} diff --git a/pkg/qbtrade/isolation_test.go b/pkg/qbtrade/isolation_test.go new file mode 100644 index 0000000..5d47fe6 --- /dev/null +++ b/pkg/qbtrade/isolation_test.go @@ -0,0 +1,24 @@ +package qbtrade + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetIsolationFromContext(t *testing.T) { + ctx := context.Background() + isolation := GetIsolationFromContext(ctx) + assert.NotNil(t, isolation) + assert.NotNil(t, isolation.persistenceServiceFacade) + assert.NotNil(t, isolation.gracefulShutdown) +} + +func TestNewDefaultIsolation(t *testing.T) { + isolation := NewDefaultIsolation() + assert.NotNil(t, isolation) + assert.NotNil(t, isolation.persistenceServiceFacade) + assert.NotNil(t, isolation.gracefulShutdown) + assert.Equal(t, defaultPersistenceServiceFacade, isolation.persistenceServiceFacade) +} diff --git a/pkg/qbtrade/log.go b/pkg/qbtrade/log.go new file mode 100644 index 0000000..12757c5 --- /dev/null +++ b/pkg/qbtrade/log.go @@ -0,0 +1 @@ +package qbtrade diff --git a/pkg/qbtrade/marketdatastore.go b/pkg/qbtrade/marketdatastore.go new file mode 100644 index 0000000..6feae89 --- /dev/null +++ b/pkg/qbtrade/marketdatastore.go @@ -0,0 +1,67 @@ +package qbtrade + +import "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + +const MaxNumOfKLines = 5_000 +const MaxNumOfKLinesTruncate = 100 + +// MarketDataStore receives and maintain the public market data of a single symbol +// +//go:generate callbackgen -type MarketDataStore +type MarketDataStore struct { + Symbol string + + // KLineWindows stores all loaded klines per interval + KLineWindows map[types.Interval]*types.KLineWindow `json:"-"` + + kLineWindowUpdateCallbacks []func(interval types.Interval, klines types.KLineWindow) + kLineClosedCallbacks []func(k types.KLine) +} + +func NewMarketDataStore(symbol string) *MarketDataStore { + return &MarketDataStore{ + Symbol: symbol, + + // KLineWindows stores all loaded klines per interval + KLineWindows: make(map[types.Interval]*types.KLineWindow, len(types.SupportedIntervals)), // 13 interval, 1s,1m,5m,15m,30m,1h,2h,4h,6h,12h,1d,3d,1w + } +} + +func (store *MarketDataStore) SetKLineWindows(windows map[types.Interval]*types.KLineWindow) { + store.KLineWindows = windows +} + +// KLinesOfInterval returns the kline window of the given interval +func (store *MarketDataStore) KLinesOfInterval(interval types.Interval) (kLines *types.KLineWindow, ok bool) { + kLines, ok = store.KLineWindows[interval] + return kLines, ok +} + +func (store *MarketDataStore) BindStream(stream types.Stream) { + stream.OnKLineClosed(store.handleKLineClosed) +} + +func (store *MarketDataStore) handleKLineClosed(kline types.KLine) { + if kline.Symbol != store.Symbol { + return + } + + store.AddKLine(kline) +} + +func (store *MarketDataStore) AddKLine(k types.KLine) { + window, ok := store.KLineWindows[k.Interval] + if !ok { + var tmp = make(types.KLineWindow, 0, 1000) + store.KLineWindows[k.Interval] = &tmp + window = &tmp + } + window.Add(k) + + if len(*window) > MaxNumOfKLines { + *window = (*window)[MaxNumOfKLinesTruncate-1:] + } + + store.EmitKLineClosed(k) + store.EmitKLineWindowUpdate(k.Interval, *window) +} diff --git a/pkg/qbtrade/marketdatastore_callbacks.go b/pkg/qbtrade/marketdatastore_callbacks.go new file mode 100644 index 0000000..0352573 --- /dev/null +++ b/pkg/qbtrade/marketdatastore_callbacks.go @@ -0,0 +1,27 @@ +// Code generated by "callbackgen -type MarketDataStore"; DO NOT EDIT. + +package qbtrade + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func (store *MarketDataStore) OnKLineWindowUpdate(cb func(interval types.Interval, klines types.KLineWindow)) { + store.kLineWindowUpdateCallbacks = append(store.kLineWindowUpdateCallbacks, cb) +} + +func (store *MarketDataStore) EmitKLineWindowUpdate(interval types.Interval, klines types.KLineWindow) { + for _, cb := range store.kLineWindowUpdateCallbacks { + cb(interval, klines) + } +} + +func (store *MarketDataStore) OnKLineClosed(cb func(k types.KLine)) { + store.kLineClosedCallbacks = append(store.kLineClosedCallbacks, cb) +} + +func (store *MarketDataStore) EmitKLineClosed(k types.KLine) { + for _, cb := range store.kLineClosedCallbacks { + cb(k) + } +} diff --git a/pkg/qbtrade/metrics.go b/pkg/qbtrade/metrics.go new file mode 100644 index 0000000..fc55327 --- /dev/null +++ b/pkg/qbtrade/metrics.go @@ -0,0 +1,112 @@ +package qbtrade + +import "github.com/prometheus/client_golang/prometheus" + +var ( + metricsConnectionStatus = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_connection_status", + Help: "qbtrade exchange session connection status", + }, + []string{ + "exchange", // exchange name + "channel", // channel: user or market + "margin", // margin type: none, margin or isolated + "symbol", // margin symbol of the connection. + }, + ) + + metricsLockedBalances = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_balances_locked", + Help: "qbtrade exchange locked balances", + }, + []string{ + "exchange", // exchange name + "margin", // margin of connection. 1 or 0 + "symbol", // margin symbol of the connection. + "currency", + }, + ) + + metricsAvailableBalances = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_balances_available", + Help: "qbtrade exchange available balances", + }, + []string{ + "exchange", // exchange name + "margin", // margin of connection. none, margin or isolated + "symbol", // margin symbol of the connection. + "currency", + }, + ) + + metricsTotalBalances = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_balances_total", + Help: "qbtrade exchange session total balances", + }, + []string{ + "exchange", // exchange name + "margin", // margin of connection. none, margin or isolated + "symbol", // margin symbol of the connection. + "currency", + }, + ) + + metricsTradesTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "qbtrade_trades_total", + Help: "qbtrade exchange session trades", + }, + []string{ + "exchange", // exchange name + "margin", // margin of connection. none, margin or isolated + "symbol", // margin symbol of the connection. + "side", // side: buy or sell + "liquidity", // maker or taker + }, + ) + + metricsTradingVolume = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_trading_volume", + Help: "qbtrade trading volume", + }, + []string{ + "exchange", // exchange name + "margin", // margin of connection. none, margin or isolated + "symbol", // margin symbol of the connection. + "side", // side: buy or sell + "liquidity", // maker or taker + }, + ) + + metricsLastUpdateTimeBalance = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_last_update_time", + Help: "qbtrade last update time of different channel", + }, + []string{ + "exchange", // exchange name + "margin", // margin of connection. none, margin or isolated + "channel", // channel: user, market + "data_type", // type: balance, ticker, kline, orderbook, trade, order + "symbol", // for market data, trade and order + "currency", // for balance + }, + ) +) + +func init() { + prometheus.MustRegister( + metricsConnectionStatus, + metricsTotalBalances, + metricsLockedBalances, + metricsAvailableBalances, + metricsTradesTotal, + metricsTradingVolume, + metricsLastUpdateTimeBalance, + ) +} diff --git a/pkg/qbtrade/mocks/mock_order_executor_extended.go b/pkg/qbtrade/mocks/mock_order_executor_extended.go new file mode 100644 index 0000000..8703853 --- /dev/null +++ b/pkg/qbtrade/mocks/mock_order_executor_extended.go @@ -0,0 +1,109 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade (interfaces: OrderExecutorExtended) +// +// Generated by this command: +// +// mockgen -destination=mocks/mock_order_executor_extended.go -package=mocks . OrderExecutorExtended +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + core "git.qtrade.icu/lychiyu/qbtrade/pkg/core" + types "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + gomock "go.uber.org/mock/gomock" +) + +// MockOrderExecutorExtended is a mock of OrderExecutorExtended interface. +type MockOrderExecutorExtended struct { + ctrl *gomock.Controller + recorder *MockOrderExecutorExtendedMockRecorder +} + +// MockOrderExecutorExtendedMockRecorder is the mock recorder for MockOrderExecutorExtended. +type MockOrderExecutorExtendedMockRecorder struct { + mock *MockOrderExecutorExtended +} + +// NewMockOrderExecutorExtended creates a new mock instance. +func NewMockOrderExecutorExtended(ctrl *gomock.Controller) *MockOrderExecutorExtended { + mock := &MockOrderExecutorExtended{ctrl: ctrl} + mock.recorder = &MockOrderExecutorExtendedMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockOrderExecutorExtended) EXPECT() *MockOrderExecutorExtendedMockRecorder { + return m.recorder +} + +// CancelOrders mocks base method. +func (m *MockOrderExecutorExtended) CancelOrders(arg0 context.Context, arg1 ...types.Order) error { + m.ctrl.T.Helper() + varargs := []any{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 *MockOrderExecutorExtendedMockRecorder) CancelOrders(arg0 any, arg1 ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CancelOrders", reflect.TypeOf((*MockOrderExecutorExtended)(nil).CancelOrders), varargs...) +} + +// Position mocks base method. +func (m *MockOrderExecutorExtended) Position() *types.Position { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Position") + ret0, _ := ret[0].(*types.Position) + return ret0 +} + +// Position indicates an expected call of Position. +func (mr *MockOrderExecutorExtendedMockRecorder) Position() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Position", reflect.TypeOf((*MockOrderExecutorExtended)(nil).Position)) +} + +// SubmitOrders mocks base method. +func (m *MockOrderExecutorExtended) SubmitOrders(arg0 context.Context, arg1 ...types.SubmitOrder) (types.OrderSlice, error) { + m.ctrl.T.Helper() + varargs := []any{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 *MockOrderExecutorExtendedMockRecorder) SubmitOrders(arg0 any, arg1 ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitOrders", reflect.TypeOf((*MockOrderExecutorExtended)(nil).SubmitOrders), varargs...) +} + +// TradeCollector mocks base method. +func (m *MockOrderExecutorExtended) TradeCollector() *core.TradeCollector { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TradeCollector") + ret0, _ := ret[0].(*core.TradeCollector) + return ret0 +} + +// TradeCollector indicates an expected call of TradeCollector. +func (mr *MockOrderExecutorExtendedMockRecorder) TradeCollector() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TradeCollector", reflect.TypeOf((*MockOrderExecutorExtended)(nil).TradeCollector)) +} diff --git a/pkg/qbtrade/moving_average_settings.go b/pkg/qbtrade/moving_average_settings.go new file mode 100644 index 0000000..bec61f2 --- /dev/null +++ b/pkg/qbtrade/moving_average_settings.go @@ -0,0 +1,46 @@ +package qbtrade + +import ( + "fmt" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type MovingAverageSettings struct { + Type string `json:"type"` + Interval types.Interval `json:"interval"` + Window int `json:"window"` + + Side *types.SideType `json:"side"` + + QuantityOrAmount +} + +func (settings MovingAverageSettings) IntervalWindow() types.IntervalWindow { + var window = 99 + if settings.Window > 0 { + window = settings.Window + } + + return types.IntervalWindow{ + Interval: settings.Interval, + Window: window, + } +} + +func (settings *MovingAverageSettings) Indicator(indicatorSet *StandardIndicatorSet) (inc types.Float64Indicator, err error) { + var iw = settings.IntervalWindow() + + switch settings.Type { + case "SMA": + inc = indicatorSet.SMA(iw) + + case "EWMA", "EMA": + inc = indicatorSet.EWMA(iw) + + default: + return nil, fmt.Errorf("unsupported moving average type: %s", settings.Type) + } + + return inc, nil +} diff --git a/pkg/qbtrade/notification.go b/pkg/qbtrade/notification.go new file mode 100644 index 0000000..02ac2b0 --- /dev/null +++ b/pkg/qbtrade/notification.go @@ -0,0 +1,113 @@ +package qbtrade + +import ( + "bytes" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +var Notification = &Notifiability{ + SymbolChannelRouter: NewPatternChannelRouter(nil), + SessionChannelRouter: NewPatternChannelRouter(nil), + ObjectChannelRouter: NewObjectChannelRouter(), +} + +func Notify(obj interface{}, args ...interface{}) { + Notification.Notify(obj, args...) +} + +func NotifyTo(channel string, obj interface{}, args ...interface{}) { + Notification.NotifyTo(channel, obj, args...) +} + +func SendPhoto(buffer *bytes.Buffer) { + Notification.SendPhoto(buffer) +} + +func SendPhotoTo(channel string, buffer *bytes.Buffer) { + Notification.SendPhotoTo(channel, buffer) +} + +type Notifier interface { + NotifyTo(channel string, obj interface{}, args ...interface{}) + Notify(obj interface{}, args ...interface{}) + SendPhotoTo(channel string, buffer *bytes.Buffer) + SendPhoto(buffer *bytes.Buffer) +} + +type NullNotifier struct{} + +func (n *NullNotifier) NotifyTo(channel string, obj interface{}, args ...interface{}) {} + +func (n *NullNotifier) Notify(obj interface{}, args ...interface{}) {} + +func (n *NullNotifier) SendPhoto(buffer *bytes.Buffer) {} + +func (n *NullNotifier) SendPhotoTo(channel string, buffer *bytes.Buffer) {} + +type Notifiability struct { + notifiers []Notifier + SessionChannelRouter *PatternChannelRouter `json:"-"` + SymbolChannelRouter *PatternChannelRouter `json:"-"` + ObjectChannelRouter *ObjectChannelRouter `json:"-"` +} + +// RouteSymbol routes symbol name to channel +func (m *Notifiability) RouteSymbol(symbol string) (channel string, ok bool) { + if m.SymbolChannelRouter != nil { + return m.SymbolChannelRouter.Route(symbol) + } + return "", false +} + +// RouteSession routes Session name to channel +func (m *Notifiability) RouteSession(session string) (channel string, ok bool) { + if m.SessionChannelRouter != nil { + return m.SessionChannelRouter.Route(session) + } + return "", false +} + +// RouteObject routes object to channel +func (m *Notifiability) RouteObject(obj interface{}) (channel string, ok bool) { + if m.ObjectChannelRouter != nil { + return m.ObjectChannelRouter.Route(obj) + } + return "", false +} + +// AddNotifier adds the notifier that implements the Notifier interface. +func (m *Notifiability) AddNotifier(notifier Notifier) { + m.notifiers = append(m.notifiers, notifier) +} + +func (m *Notifiability) Notify(obj interface{}, args ...interface{}) { + if str, ok := obj.(string); ok { + simpleArgs := util.FilterSimpleArgs(args) + logrus.Infof(str, simpleArgs...) + } + + for _, n := range m.notifiers { + n.Notify(obj, args...) + } +} + +func (m *Notifiability) NotifyTo(channel string, obj interface{}, args ...interface{}) { + for _, n := range m.notifiers { + n.NotifyTo(channel, obj, args...) + } +} + +func (m *Notifiability) SendPhoto(buffer *bytes.Buffer) { + for _, n := range m.notifiers { + n.SendPhoto(buffer) + } +} + +func (m *Notifiability) SendPhotoTo(channel string, buffer *bytes.Buffer) { + for _, n := range m.notifiers { + n.SendPhotoTo(channel, buffer) + } +} diff --git a/pkg/qbtrade/order_execution.go b/pkg/qbtrade/order_execution.go new file mode 100644 index 0000000..24768f7 --- /dev/null +++ b/pkg/qbtrade/order_execution.go @@ -0,0 +1,421 @@ +package qbtrade + +import ( + "context" + "fmt" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "go.uber.org/multierr" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/core" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +var DefaultSubmitOrderRetryTimeout = 5 * time.Minute + +func init() { + if du, ok := util.GetEnvVarDuration("qbtrade_SUBMIT_ORDER_RETRY_TIMEOUT"); ok && du > 0 { + DefaultSubmitOrderRetryTimeout = du + } +} + +type OrderExecutor interface { + SubmitOrders(ctx context.Context, orders ...types.SubmitOrder) (createdOrders types.OrderSlice, err error) + CancelOrders(ctx context.Context, orders ...types.Order) error +} + +//go:generate mockgen -destination=mocks/mock_order_executor_extended.go -package=mocks . OrderExecutorExtended +type OrderExecutorExtended interface { + SubmitOrders(ctx context.Context, orders ...types.SubmitOrder) (createdOrders types.OrderSlice, err error) + CancelOrders(ctx context.Context, orders ...types.Order) error + TradeCollector() *core.TradeCollector + Position() *types.Position +} + +type OrderExecutionRouter interface { + // SubmitOrdersTo submit order to a specific exchange Session + SubmitOrdersTo(ctx context.Context, session string, orders ...types.SubmitOrder) (createdOrders types.OrderSlice, err error) + CancelOrdersTo(ctx context.Context, session string, orders ...types.Order) error +} + +type ExchangeOrderExecutionRouter struct { + sessions map[string]*ExchangeSession + executors map[string]OrderExecutor +} + +func (e *ExchangeOrderExecutionRouter) SubmitOrdersTo(ctx context.Context, session string, orders ...types.SubmitOrder) (types.OrderSlice, error) { + if executor, ok := e.executors[session]; ok { + return executor.SubmitOrders(ctx, orders...) + } + + es, ok := e.sessions[session] + if !ok { + return nil, fmt.Errorf("exchange session %s not found", session) + } + + formattedOrders, err := es.FormatOrders(orders) + if err != nil { + return nil, err + } + + createdOrders, _, err := BatchPlaceOrder(ctx, es.Exchange, nil, formattedOrders...) + return createdOrders, err +} + +func (e *ExchangeOrderExecutionRouter) CancelOrdersTo(ctx context.Context, session string, orders ...types.Order) error { + if executor, ok := e.executors[session]; ok { + return executor.CancelOrders(ctx, orders...) + } + es, ok := e.sessions[session] + if !ok { + return fmt.Errorf("exchange session %s not found", session) + } + + return es.Exchange.CancelOrders(ctx, orders...) +} + +// ExchangeOrderExecutor is an order executor wrapper for single exchange instance. +// +//go:generate callbackgen -type ExchangeOrderExecutor +type ExchangeOrderExecutor struct { + // MinQuoteBalance fixedpoint.Value `json:"minQuoteBalance,omitempty" yaml:"minQuoteBalance,omitempty"` + + Session *ExchangeSession `json:"-" yaml:"-"` + + // private trade update callbacks + tradeUpdateCallbacks []func(trade types.Trade) + + // private order update callbacks + orderUpdateCallbacks []func(order types.Order) +} + +func (e *ExchangeOrderExecutor) SubmitOrders(ctx context.Context, orders ...types.SubmitOrder) (types.OrderSlice, error) { + formattedOrders, err := e.Session.FormatOrders(orders) + if err != nil { + return nil, err + } + + for _, order := range formattedOrders { + log.Infof("submitting order: %s", order.String()) + } + + createdOrders, _, err := BatchPlaceOrder(ctx, e.Session.Exchange, nil, formattedOrders...) + return createdOrders, err +} + +func (e *ExchangeOrderExecutor) CancelOrders(ctx context.Context, orders ...types.Order) error { + for _, order := range orders { + log.Infof("cancelling order: %s", order) + } + return e.Session.Exchange.CancelOrders(ctx, orders...) +} + +type BasicRiskController struct { + Logger *log.Logger + + MaxOrderAmount fixedpoint.Value `json:"maxOrderAmount,omitempty" yaml:"maxOrderAmount,omitempty"` + MinQuoteBalance fixedpoint.Value `json:"minQuoteBalance,omitempty" yaml:"minQuoteBalance,omitempty"` + MaxBaseAssetBalance fixedpoint.Value `json:"maxBaseAssetBalance,omitempty" yaml:"maxBaseAssetBalance,omitempty"` + MinBaseAssetBalance fixedpoint.Value `json:"minBaseAssetBalance,omitempty" yaml:"minBaseAssetBalance,omitempty"` +} + +// ProcessOrders filters and modifies the submit order objects by: +// 1. Increase the quantity by the minimal requirement +// 2. Decrease the quantity by risk controls +// 3. If the quantity does not meet minimal requirement, we should ignore the submit order. +func (c *BasicRiskController) ProcessOrders(session *ExchangeSession, orders ...types.SubmitOrder) (outOrders []types.SubmitOrder, errs []error) { + balances := session.GetAccount().Balances() + + addError := func(err error) { + errs = append(errs, err) + } + + accumulativeQuoteAmount := fixedpoint.Zero + accumulativeBaseSellQuantity := fixedpoint.Zero + increaseFactor := fixedpoint.NewFromFloat(1.01) + + for _, order := range orders { + lastPrice, ok := session.LastPrice(order.Symbol) + if !ok { + addError(fmt.Errorf("the last price of symbol %q is not found, order: %s", order.Symbol, order.String())) + continue + } + + market, ok := session.Market(order.Symbol) + if !ok { + addError(fmt.Errorf("the market config of symbol %q is not found, order: %s", order.Symbol, order.String())) + continue + } + + price := order.Price + quantity := order.Quantity + switch order.Type { + case types.OrderTypeMarket: + price = lastPrice + } + + switch order.Side { + case types.SideTypeBuy: + minAmount := market.MinAmount.Mul(increaseFactor) + // Critical conditions for placing buy orders + quoteBalance, ok := balances[market.QuoteCurrency] + if !ok { + addError(fmt.Errorf("can not place buy order, quote balance %s not found", market.QuoteCurrency)) + continue + } + + if quoteBalance.Available.Compare(c.MinQuoteBalance) < 0 { + addError(errors.Wrapf(ErrQuoteBalanceLevelTooLow, "can not place buy order, quote balance level is too low: %s < %s, order: %s", + types.USD.FormatMoney(quoteBalance.Available), + types.USD.FormatMoney(c.MinQuoteBalance), order.String())) + continue + } + + // Increase the quantity if the amount is not enough, + // this is the only increase op, later we will decrease the quantity if it meets the criteria + quantity = AdjustFloatQuantityByMinAmount(quantity, price, minAmount) + + if c.MaxOrderAmount.Sign() > 0 { + quantity = AdjustFloatQuantityByMaxAmount(quantity, price, c.MaxOrderAmount) + } + + quoteAssetQuota := fixedpoint.Max( + fixedpoint.Zero, quoteBalance.Available.Sub(c.MinQuoteBalance)) + if quoteAssetQuota.Compare(market.MinAmount) < 0 { + addError( + errors.Wrapf( + ErrInsufficientQuoteBalance, + "can not place buy order, insufficient quote balance: quota %s < min amount %s, order: %s", + quoteAssetQuota.String(), market.MinAmount.String(), order.String())) + continue + } + + quantity = AdjustFloatQuantityByMaxAmount(quantity, price, quoteAssetQuota) + + // if MaxBaseAssetBalance is enabled, we should check the current base asset balance + if baseBalance, hasBaseAsset := balances[market.BaseCurrency]; hasBaseAsset && c.MaxBaseAssetBalance.Sign() > 0 { + if baseBalance.Available.Compare(c.MaxBaseAssetBalance) > 0 { + addError( + errors.Wrapf( + ErrAssetBalanceLevelTooHigh, + "should not place buy order, asset balance level is too high: %s > %s, order: %s", + baseBalance.Available.String(), + c.MaxBaseAssetBalance.String(), + order.String())) + continue + } + + baseAssetQuota := fixedpoint.Max(fixedpoint.Zero, c.MaxBaseAssetBalance.Sub(baseBalance.Available)) + if quantity.Compare(baseAssetQuota) > 0 { + quantity = baseAssetQuota + } + } + + // if the amount is still too small, we should skip it. + notional := quantity.Mul(lastPrice) + if notional.Compare(market.MinAmount) < 0 { + addError( + fmt.Errorf( + "can not place buy order, quote amount too small: notional %s < min amount %s, order: %s", + notional.String(), + market.MinAmount.String(), + order.String())) + continue + } + + accumulativeQuoteAmount = accumulativeQuoteAmount.Add(notional) + + case types.SideTypeSell: + minNotion := market.MinNotional.Mul(increaseFactor) + + // Critical conditions for placing SELL orders + baseAssetBalance, ok := balances[market.BaseCurrency] + if !ok { + addError( + fmt.Errorf( + "can not place sell order, no base asset balance %s, order: %s", + market.BaseCurrency, + order.String())) + continue + } + + // if the amount is too small, we should increase it. + quantity = AdjustFloatQuantityByMinAmount(quantity, price, minNotion) + + // we should not SELL too much + quantity = fixedpoint.Min(quantity, baseAssetBalance.Available) + + if c.MinBaseAssetBalance.Sign() > 0 { + if baseAssetBalance.Available.Compare(c.MinBaseAssetBalance) < 0 { + addError( + errors.Wrapf( + ErrAssetBalanceLevelTooLow, + "asset balance level is too low: %s > %s", baseAssetBalance.Available.String(), c.MinBaseAssetBalance.String())) + continue + } + + quantity = fixedpoint.Min(quantity, baseAssetBalance.Available.Sub(c.MinBaseAssetBalance)) + if quantity.Compare(market.MinQuantity) < 0 { + addError( + errors.Wrapf( + ErrInsufficientAssetBalance, + "insufficient asset balance: %s > minimal quantity %s", + baseAssetBalance.Available.String(), + market.MinQuantity.String())) + continue + } + } + + if c.MaxOrderAmount.Sign() > 0 { + quantity = AdjustFloatQuantityByMaxAmount(quantity, price, c.MaxOrderAmount) + } + + notional := quantity.Mul(lastPrice) + if notional.Compare(market.MinNotional) < 0 { + addError( + fmt.Errorf( + "can not place sell order, notional %s < min notional: %s, order: %s", + notional.String(), + market.MinNotional.String(), + order.String())) + continue + } + + if quantity.Compare(market.MinQuantity) < 0 { + addError( + fmt.Errorf( + "can not place sell order, quantity %s is less than the minimal lot %s, order: %s", + quantity.String(), + market.MinQuantity.String(), + order.String())) + continue + } + + accumulativeBaseSellQuantity = accumulativeBaseSellQuantity.Add(quantity) + } + + // update quantity and format the order + order.Quantity = quantity + outOrders = append(outOrders, order) + } + + return outOrders, nil +} + +type OrderCallback func(order types.Order) + +// BatchPlaceOrder +func BatchPlaceOrder(ctx context.Context, exchange types.Exchange, orderCallback OrderCallback, submitOrders ...types.SubmitOrder) (types.OrderSlice, []int, error) { + var createdOrders types.OrderSlice + var err error + + var errIndexes []int + for i, submitOrder := range submitOrders { + createdOrder, err2 := exchange.SubmitOrder(ctx, submitOrder) + if err2 != nil { + err = multierr.Append(err, err2) + errIndexes = append(errIndexes, i) + } else if createdOrder != nil { + createdOrder.Tag = submitOrder.Tag + + if orderCallback != nil { + orderCallback(*createdOrder) + } + + createdOrders = append(createdOrders, *createdOrder) + } + } + + return createdOrders, errIndexes, err +} + +// BatchRetryPlaceOrder places the orders and retries the failed orders +func BatchRetryPlaceOrder(ctx context.Context, exchange types.Exchange, errIdx []int, orderCallback OrderCallback, logger log.FieldLogger, submitOrders ...types.SubmitOrder) (types.OrderSlice, []int, error) { + if logger == nil { + logger = log.StandardLogger() + } + + var createdOrders types.OrderSlice + var werr error + + // if the errIdx is nil, then we should iterate all the submit orders + // allocate a variable for new error index + if len(errIdx) == 0 { + var err2 error + createdOrders, errIdx, err2 = BatchPlaceOrder(ctx, exchange, orderCallback, submitOrders...) + if err2 != nil { + werr = multierr.Append(werr, err2) + } else { + return createdOrders, nil, nil + } + } + + timeoutCtx, cancelTimeout := context.WithTimeout(ctx, DefaultSubmitOrderRetryTimeout) + defer cancelTimeout() + + // if we got any error, we should re-iterate the errored orders + coolDownTime := 200 * time.Millisecond + + // set backoff max retries to 101 because https://ja.wikipedia.org/wiki/101%E5%9B%9E%E7%9B%AE%E3%81%AE%E3%83%97%E3%83%AD%E3%83%9D%E3%83%BC%E3%82%BA + backoffMaxRetries := uint64(101) + var errIdxNext []int +batchRetryOrder: + for retryRound := 0; len(errIdx) > 0 && retryRound < 10; retryRound++ { + // sleep for 200 millisecond between each retry + logger.Warnf("retry round #%d, cooling down for %s", retryRound+1, coolDownTime) + time.Sleep(coolDownTime) + + // reset error index since it's a new retry + errIdxNext = nil + + // iterate the error index and re-submit the order + logger.Warnf("starting retry round #%d...", retryRound+1) + for _, idx := range errIdx { + submitOrder := submitOrders[idx] + + op := func() error { + // can allocate permanent error backoff.Permanent(err) to stop backoff + createdOrder, err2 := exchange.SubmitOrder(timeoutCtx, submitOrder) + if err2 != nil { + logger.WithError(err2).Errorf("submit order error: %s", submitOrder.String()) + } + + if err2 == nil && createdOrder != nil { + // if the order is successfully created, then we should copy the order tag + createdOrder.Tag = submitOrder.Tag + + if orderCallback != nil { + orderCallback(*createdOrder) + } + + createdOrders = append(createdOrders, *createdOrder) + } + + return err2 + } + + var bo backoff.BackOff = backoff.NewExponentialBackOff() + bo = backoff.WithMaxRetries(bo, backoffMaxRetries) + bo = backoff.WithContext(bo, timeoutCtx) + if err2 := backoff.Retry(op, bo); err2 != nil { + if err2 == context.Canceled { + logger.Warnf("context canceled error, stop retry") + break batchRetryOrder + } + + werr = multierr.Append(werr, err2) + errIdxNext = append(errIdxNext, idx) + } + } + + // update the error index + errIdx = errIdxNext + } + + return createdOrders, errIdx, werr +} diff --git a/pkg/qbtrade/order_executor_fast.go b/pkg/qbtrade/order_executor_fast.go new file mode 100644 index 0000000..99848bd --- /dev/null +++ b/pkg/qbtrade/order_executor_fast.go @@ -0,0 +1,67 @@ +package qbtrade + +import ( + "context" + + "github.com/pkg/errors" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// FastOrderExecutor provides shorter submit order / cancel order round-trip time +// for strategies that need to response more faster, e.g. 1s kline or market trades related strategies. +type FastOrderExecutor struct { + *GeneralOrderExecutor +} + +func NewFastOrderExecutor(session *ExchangeSession, symbol, strategy, strategyInstanceID string, position *types.Position) *FastOrderExecutor { + oe := NewGeneralOrderExecutor(session, symbol, strategy, strategyInstanceID, position) + return &FastOrderExecutor{ + GeneralOrderExecutor: oe, + } +} + +// SubmitOrders sends []types.SubmitOrder directly to the exchange without blocking wait on the status update. +// This is a faster version of GeneralOrderExecutor.SubmitOrders(). Created orders will be consumed in newly created goroutine (in non-backteset session). +// @param ctx: golang context type. +// @param submitOrders: Lists of types.SubmitOrder to be sent to the exchange. +// @return *types.SubmitOrder: SubmitOrder with calculated quantity and price. +// @return error: Error message. +func (e *FastOrderExecutor) SubmitOrders(ctx context.Context, submitOrders ...types.SubmitOrder) (types.OrderSlice, error) { + formattedOrders, err := e.session.FormatOrders(submitOrders) + if err != nil { + return nil, err + } + + createdOrders, errIdx, err := BatchPlaceOrder(ctx, e.session.Exchange, nil, formattedOrders...) + if len(errIdx) > 0 { + return nil, err + } + + if IsBackTesting { + e.orderStore.Add(createdOrders...) + e.activeMakerOrders.Add(createdOrders...) + e.tradeCollector.Process() + } else { + go func() { + e.orderStore.Add(createdOrders...) + e.activeMakerOrders.Add(createdOrders...) + e.tradeCollector.Process() + }() + } + return createdOrders, err + +} + +// Cancel cancels all active maker orders if orders is not given, otherwise cancel the given orders +func (e *FastOrderExecutor) Cancel(ctx context.Context, orders ...types.Order) error { + if e.activeMakerOrders.NumOfOrders() == 0 { + return nil + } + + if err := e.activeMakerOrders.FastCancel(ctx, e.session.Exchange, orders...); err != nil { + return errors.Wrap(err, "fast cancel order error") + } + + return nil +} diff --git a/pkg/qbtrade/order_executor_general.go b/pkg/qbtrade/order_executor_general.go new file mode 100644 index 0000000..08a8d5e --- /dev/null +++ b/pkg/qbtrade/order_executor_general.go @@ -0,0 +1,578 @@ +package qbtrade + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "go.uber.org/multierr" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/core" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/retry" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util/backoff" +) + +var ErrExceededSubmitOrderRetryLimit = errors.New("exceeded submit order retry limit") + +// quantityReduceDelta is used to modify the order to submit, especially for the market order +var quantityReduceDelta = fixedpoint.NewFromFloat(0.005) + +// submitOrderRetryLimit is used when SubmitOrder failed, we will re-submit the order. +// This is for the maximum retries +const submitOrderRetryLimit = 5 + +type BaseOrderExecutor struct { + session *ExchangeSession + activeMakerOrders *ActiveOrderBook + orderStore *core.OrderStore +} + +func (e *BaseOrderExecutor) OrderStore() *core.OrderStore { + return e.orderStore +} + +func (e *BaseOrderExecutor) ActiveMakerOrders() *ActiveOrderBook { + return e.activeMakerOrders +} + +// GracefulCancel cancels all active maker orders if orders are not given, otherwise cancel all the given orders +func (e *BaseOrderExecutor) GracefulCancel(ctx context.Context, orders ...types.Order) error { + if err := e.activeMakerOrders.GracefulCancel(ctx, e.session.Exchange, orders...); err != nil { + return errors.Wrap(err, "graceful cancel error") + } + + return nil +} + +// GeneralOrderExecutor implements the general order executor for strategy +type GeneralOrderExecutor struct { + BaseOrderExecutor + + symbol string + strategy string + strategyInstanceID string + position *types.Position + tradeCollector *core.TradeCollector + + logger log.FieldLogger + + marginBaseMaxBorrowable, marginQuoteMaxBorrowable fixedpoint.Value + + maxRetries uint + disableNotify bool +} + +// NewGeneralOrderExecutor allocates a GeneralOrderExecutor +// which has its own order store, trade collector +func NewGeneralOrderExecutor( + session *ExchangeSession, + symbol, strategy, strategyInstanceID string, + position *types.Position, +) *GeneralOrderExecutor { + // Always update the position fields + position.Strategy = strategy + position.StrategyInstanceID = strategyInstanceID + + orderStore := core.NewOrderStore(symbol) + + executor := &GeneralOrderExecutor{ + BaseOrderExecutor: BaseOrderExecutor{ + session: session, + activeMakerOrders: NewActiveOrderBook(symbol), + orderStore: orderStore, + }, + + symbol: symbol, + strategy: strategy, + strategyInstanceID: strategyInstanceID, + position: position, + tradeCollector: core.NewTradeCollector(symbol, position, orderStore), + } + + if session != nil && session.Margin { + executor.startMarginAssetUpdater(context.Background()) + } + + return executor +} + +func (e *GeneralOrderExecutor) DisableNotify() { + e.disableNotify = true +} + +func (e *GeneralOrderExecutor) SetMaxRetries(maxRetries uint) { + e.maxRetries = maxRetries +} + +func (e *GeneralOrderExecutor) startMarginAssetUpdater(ctx context.Context) { + marginService, ok := e.session.Exchange.(types.MarginBorrowRepayService) + if !ok { + log.Warnf("session %s (%T) exchange does not support MarginBorrowRepayService", e.session.Name, e.session.Exchange) + return + } + + go e.marginAssetMaxBorrowableUpdater(ctx, 30*time.Minute, marginService, e.position.Market) +} + +func (e *GeneralOrderExecutor) updateMarginAssetMaxBorrowable( + ctx context.Context, marginService types.MarginBorrowRepayService, market types.Market, +) { + maxBorrowable, err := marginService.QueryMarginAssetMaxBorrowable(ctx, market.BaseCurrency) + if err != nil { + log.WithError(err).Warnf("can not query margin base asset %s max borrowable", market.BaseCurrency) + } else { + log.Infof("updating margin base asset %s max borrowable amount: %f", market.BaseCurrency, maxBorrowable.Float64()) + e.marginBaseMaxBorrowable = maxBorrowable + } + + maxBorrowable, err = marginService.QueryMarginAssetMaxBorrowable(ctx, market.QuoteCurrency) + if err != nil { + log.WithError(err).Warnf("can not query margin quote asset %s max borrowable", market.QuoteCurrency) + } else { + log.Infof("updating margin quote asset %s max borrowable amount: %f", market.QuoteCurrency, maxBorrowable.Float64()) + e.marginQuoteMaxBorrowable = maxBorrowable + } +} + +func (e *GeneralOrderExecutor) marginAssetMaxBorrowableUpdater( + ctx context.Context, interval time.Duration, marginService types.MarginBorrowRepayService, market types.Market, +) { + t := time.NewTicker(util.MillisecondsJitter(interval, 500)) + defer t.Stop() + + e.updateMarginAssetMaxBorrowable(ctx, marginService, market) + for { + select { + case <-ctx.Done(): + return + + case <-t.C: + e.updateMarginAssetMaxBorrowable(ctx, marginService, market) + } + } +} + +func (e *GeneralOrderExecutor) BindEnvironment(environ *Environment) { + e.tradeCollector.OnProfit(func(trade types.Trade, profit *types.Profit) { + environ.RecordPosition(e.position, trade, profit) + }) +} + +func (e *GeneralOrderExecutor) BindTradeStats(tradeStats *types.TradeStats) { + e.tradeCollector.OnProfit(func(trade types.Trade, profit *types.Profit) { + if profit == nil { + return + } + + tradeStats.Add(profit) + }) +} + +func (e *GeneralOrderExecutor) BindProfitStats(profitStats *types.ProfitStats) { + e.tradeCollector.OnProfit(func(trade types.Trade, profit *types.Profit) { + profitStats.AddTrade(trade) + if profit == nil { + return + } + + profitStats.AddProfit(*profit) + + if !e.disableNotify { + Notify(profit) + Notify(profitStats) + } + }) +} + +func (e *GeneralOrderExecutor) Bind() { + e.activeMakerOrders.BindStream(e.session.UserDataStream) + e.orderStore.BindStream(e.session.UserDataStream) + + if !e.disableNotify { + // trade notify + e.tradeCollector.OnTrade(func(trade types.Trade, profit, netProfit fixedpoint.Value) { + Notify(trade) + }) + + e.tradeCollector.OnPositionUpdate(func(position *types.Position) { + Notify(position) + }) + } + + 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...) + if err != nil { // Retry once + err = e.session.Exchange.CancelOrders(ctx, orders...) + } + return err +} + +func (e *GeneralOrderExecutor) SetLogger(logger log.FieldLogger) { + e.logger = logger +} + +func (e *GeneralOrderExecutor) SubmitOrders( + ctx context.Context, submitOrders ...types.SubmitOrder, +) (types.OrderSlice, error) { + formattedOrders, err := e.session.FormatOrders(submitOrders) + if err != nil { + return nil, err + } + + orderCreateCallback := func(createdOrder types.Order) { + e.orderStore.Add(createdOrder) + e.activeMakerOrders.Add(createdOrder) + } + + defer e.tradeCollector.Process() + + if e.maxRetries == 0 { + createdOrders, _, err := BatchPlaceOrder(ctx, e.session.Exchange, orderCreateCallback, formattedOrders...) + return createdOrders, err + } + + createdOrders, _, err := BatchRetryPlaceOrder(ctx, e.session.Exchange, nil, orderCreateCallback, e.logger, formattedOrders...) + return createdOrders, err +} + +type OpenPositionOptions struct { + // Long is for open a long position + // Long or Short must be set, avoid loading it from the config file + // it should be set from the strategy code + Long bool `json:"-" yaml:"-"` + + // Short is for open a short position + // Long or Short must be set + Short bool `json:"-" yaml:"-"` + + // Leverage is used for leveraged position and account + // Leverage is not effected when using non-leverage spot account + Leverage fixedpoint.Value `json:"leverage,omitempty" modifiable:"true"` + + // Quantity will be used first, it will override the leverage if it's given + Quantity fixedpoint.Value `json:"quantity,omitempty" modifiable:"true"` + + // LimitOrder set to true to open a position with a limit order + // default is false, and will send MarketOrder + LimitOrder bool `json:"limitOrder,omitempty" modifiable:"true"` + + // LimitOrderTakerRatio is used when LimitOrder = true, it adjusts the price of the limit order with a ratio. + // So you can ensure that the limit order can be a taker order. Higher the ratio, higher the chance it could be a taker order. + // + // limitOrderTakerRatio is the price ratio to adjust your limit order as a taker order. e.g., 0.1% + // for sell order, 0.1% ratio means your final price = price * (1 - 0.1%) + // for buy order, 0.1% ratio means your final price = price * (1 + 0.1%) + // this is only enabled when the limitOrder option set to true + LimitOrderTakerRatio fixedpoint.Value `json:"limitOrderTakerRatio,omitempty"` + + Price fixedpoint.Value `json:"-" yaml:"-"` + Tags []string `json:"-" yaml:"-"` +} + +func (e *GeneralOrderExecutor) reduceQuantityAndSubmitOrder( + ctx context.Context, price fixedpoint.Value, submitOrder types.SubmitOrder, +) (types.OrderSlice, error) { + var err error + for i := 0; i < submitOrderRetryLimit; i++ { + q := submitOrder.Quantity.Mul(fixedpoint.One.Sub(quantityReduceDelta)) + if !e.session.Futures && !e.session.Margin { + if submitOrder.Side == types.SideTypeSell { + if baseBalance, ok := e.session.GetAccount().Balance(e.position.Market.BaseCurrency); ok { + q = fixedpoint.Min(q, baseBalance.Available) + } + } else { + if quoteBalance, ok := e.session.GetAccount().Balance(e.position.Market.QuoteCurrency); ok { + q = fixedpoint.Min(q, quoteBalance.Available.Div(price)) + } + } + } + log.Warnf("retrying order, adjusting order quantity: %v -> %v", submitOrder.Quantity, q) + + submitOrder.Quantity = q + if e.position.Market.IsDustQuantity(submitOrder.Quantity, price) { + return nil, types.NewZeroAssetError(fmt.Errorf("dust quantity, quantity = %f, price = %f", submitOrder.Quantity.Float64(), price.Float64())) + } + + createdOrder, err2 := e.SubmitOrders(ctx, submitOrder) + if err2 != nil { + // collect the error object + err = multierr.Append(err, err2) + continue + } + + log.Infof("created order: %+v", createdOrder) + return createdOrder, nil + } + + return nil, multierr.Append(ErrExceededSubmitOrderRetryLimit, err) +} + +// Create new submitOrder from OpenPositionOptions. +// @param ctx: golang context type. +// @param options: OpenPositionOptions to control the generated SubmitOrder in a higher level way. Notice that the Price in options will be updated as the submitOrder price. +// @return *types.SubmitOrder: SubmitOrder with calculated quantity and price. +// @return error: Error message. +func (e *GeneralOrderExecutor) NewOrderFromOpenPosition( + ctx context.Context, options *OpenPositionOptions, +) (*types.SubmitOrder, error) { + price := options.Price + submitOrder := types.SubmitOrder{ + Symbol: e.position.Symbol, + Type: types.OrderTypeMarket, + MarginSideEffect: types.SideEffectTypeMarginBuy, + Tag: strings.Join(options.Tags, ","), + } + + baseBalance, _ := e.session.GetAccount().Balance(e.position.Market.BaseCurrency) + + // FIXME: fix the max quote borrowing checking + // quoteBalance, _ := e.session.Account.Balance(e.position.Market.QuoteCurrency) + + if !options.LimitOrderTakerRatio.IsZero() { + if options.Price.IsZero() { + return nil, fmt.Errorf("OpenPositionOptions.Price is zero, can not adjust limit taker order price, options given: %+v", options) + } + + if options.Long { + // use higher price to buy (this ensures that our order will be filled) + price = price.Mul(one.Add(options.LimitOrderTakerRatio)) + options.Price = price + } else if options.Short { + // use lower price to sell (this ensures that our order will be filled) + price = price.Mul(one.Sub(options.LimitOrderTakerRatio)) + options.Price = price + } + } + + if options.LimitOrder { + submitOrder.Type = types.OrderTypeLimit + submitOrder.Price = price + } + + quantity := options.Quantity + + if options.Long { + if quantity.IsZero() { + quoteQuantity, err := CalculateQuoteQuantity(ctx, e.session, e.position.QuoteCurrency, options.Leverage) + if err != nil { + return nil, err + } + + if price.IsZero() { + return nil, errors.New("unable to calculate quantity: zero price given") + } + + quantity = quoteQuantity.Div(price) + } + + if e.position.Market.IsDustQuantity(quantity, price) { + log.Errorf("can not submit order: dust quantity, quantity = %f, price = %f", quantity.Float64(), price.Float64()) + return nil, nil + } + + quoteQuantity := quantity.Mul(price) + if e.session.Margin && !e.marginQuoteMaxBorrowable.IsZero() && quoteQuantity.Compare(e.marginQuoteMaxBorrowable) > 0 { + log.Warnf("adjusting quantity %f according to the max margin quote borrowable amount: %f", quantity.Float64(), e.marginQuoteMaxBorrowable.Float64()) + quantity = AdjustQuantityByMaxAmount(quantity, price, e.marginQuoteMaxBorrowable) + } + + submitOrder.Side = types.SideTypeBuy + submitOrder.Quantity = quantity + + return &submitOrder, nil + } else if options.Short { + if quantity.IsZero() { + var err error + quantity, err = CalculateBaseQuantity(e.session, e.position.Market, price, quantity, options.Leverage) + if err != nil { + return nil, err + } + } + if e.position.Market.IsDustQuantity(quantity, price) { + log.Warnf("dust quantity: %v", quantity) + return nil, nil + } + + if e.session.Margin && !e.marginBaseMaxBorrowable.IsZero() && quantity.Sub(baseBalance.Available).Compare(e.marginBaseMaxBorrowable) > 0 { + log.Warnf("adjusting %f quantity according to the max margin base borrowable amount: %f", quantity.Float64(), e.marginBaseMaxBorrowable.Float64()) + // quantity = fixedpoint.Min(quantity, e.marginBaseMaxBorrowable) + quantity = baseBalance.Available.Add(e.marginBaseMaxBorrowable) + } + + submitOrder.Side = types.SideTypeSell + submitOrder.Quantity = quantity + + return &submitOrder, nil + } + + return nil, errors.New("options Long or Short must be set") +} + +// OpenPosition sends the orders generated from OpenPositionOptions to the exchange by calling SubmitOrders or reduceQuantityAndSubmitOrder. +// @param ctx: golang context type. +// @param options: OpenPositionOptions to control the generated SubmitOrder in a higher level way. Notice that the Price in options will be updated as the submitOrder price. +// @return types.OrderSlice: Created orders with information from exchange. +// @return error: Error message. +func (e *GeneralOrderExecutor) OpenPosition( + ctx context.Context, options OpenPositionOptions, +) (types.OrderSlice, error) { + if e.position.IsClosing() { + return nil, errors.Wrap(ErrPositionAlreadyClosing, "unable to open position") + } + + submitOrder, err := e.NewOrderFromOpenPosition(ctx, &options) + if err != nil { + return nil, err + } + + if submitOrder == nil { + return nil, nil + } + + price := options.Price + + side := "long" + if submitOrder.Side == types.SideTypeSell { + side = "short" + } + + Notify("Opening %s %s position with quantity %f at price %f", e.position.Symbol, side, submitOrder.Quantity.Float64(), price.Float64()) + + createdOrder, err := e.SubmitOrders(ctx, *submitOrder) + if err == nil { + return createdOrder, nil + } + + log.WithError(err).Errorf("unable to submit order: %v", err) + log.Infof("reduce quantity and retry order") + return e.reduceQuantityAndSubmitOrder(ctx, price, *submitOrder) +} + +// GracefulCancelActiveOrderBook cancels the orders from the active orderbook. +func (e *GeneralOrderExecutor) GracefulCancelActiveOrderBook(ctx context.Context, activeOrders *ActiveOrderBook) error { + if activeOrders.NumOfOrders() == 0 { + return nil + } + + defer e.tradeCollector.Process() + + op := func() error { return activeOrders.GracefulCancel(ctx, e.session.Exchange) } + return backoff.RetryGeneral(ctx, op) +} + +// GracefulCancel cancels all active maker orders if orders are not given, otherwise cancel all the given orders +func (e *GeneralOrderExecutor) GracefulCancel(ctx context.Context, orders ...types.Order) error { + if err := e.activeMakerOrders.GracefulCancel(ctx, e.session.Exchange, orders...); err != nil { + return errors.Wrap(err, "graceful cancel error") + } + + return nil +} + +var ErrPositionAlreadyClosing = errors.New("position is already in closing process") + +// ClosePosition closes the current position by a percentage. +// percentage 0.1 means close 10% position +// tag is the order tag you want to attach, you may pass multiple tags, the tags will be combined into one tag string by commas. +func (e *GeneralOrderExecutor) ClosePosition(ctx context.Context, percentage fixedpoint.Value, tags ...string) error { + if !e.position.SetClosing(true) { + return ErrPositionAlreadyClosing + } + defer e.position.SetClosing(false) + + submitOrder := e.position.NewMarketCloseOrder(percentage) + if submitOrder == nil { + return nil + } + + if e.session.Futures { // Futures: Use base qty in e.position + submitOrder.Quantity = e.position.GetBase().Abs() + submitOrder.ReduceOnly = true + + if e.position.IsLong() { + submitOrder.Side = types.SideTypeSell + } else if e.position.IsShort() { + submitOrder.Side = types.SideTypeBuy + } else { + return fmt.Errorf("unexpected position side: %+v", e.position) + } + + } else { // Spot and spot margin + // check base balance and adjust the close position order + if e.position.IsLong() { + if baseBalance, ok := e.session.Account.Balance(e.position.Market.BaseCurrency); ok { + submitOrder.Quantity = fixedpoint.Min(submitOrder.Quantity, baseBalance.Available) + } + if submitOrder.Quantity.IsZero() { + return fmt.Errorf("insufficient base balance, can not sell: %+v", submitOrder) + } + } else if e.position.IsShort() { + if quoteBalance, ok := e.session.Account.Balance(e.position.Market.QuoteCurrency); ok { + ticker, err := e.session.Exchange.QueryTicker(ctx, e.position.Symbol) + if err != nil { + return err + } + currentPrice := ticker.Sell + submitOrder.Quantity = AdjustQuantityByMaxAmount(submitOrder.Quantity, currentPrice, quoteBalance.Available) + if submitOrder.Quantity.IsZero() { + return fmt.Errorf("insufficient quote balance, can not buy: %+v", submitOrder) + } + } + } + } + + tagStr := strings.Join(tags, ",") + submitOrder.Tag = tagStr + + Notify("Closing %s position %s with tags: %s", e.symbol, percentage.Percentage(), tagStr) + + createdOrders, err := e.SubmitOrders(ctx, *submitOrder) + if err != nil { + return err + } + + if queryOrderService, ok := e.session.Exchange.(types.ExchangeOrderQueryService); ok && !IsBackTesting { + switch submitOrder.Type { + case types.OrderTypeMarket: + _, err2 := retry.QueryOrderUntilFilled(ctx, queryOrderService, createdOrders[0].Symbol, createdOrders[0].OrderID) + if err2 != nil { + log.WithError(err2).Errorf("unable to query order") + } + } + } + + return nil +} + +func (e *GeneralOrderExecutor) TradeCollector() *core.TradeCollector { + return e.tradeCollector +} + +func (e *GeneralOrderExecutor) Session() *ExchangeSession { + return e.session +} + +func (e *GeneralOrderExecutor) Position() *types.Position { + return e.position +} + +// This implements PositionReader interface +func (e *GeneralOrderExecutor) CurrentPosition() *types.Position { + return e.position +} + +// This implements PositionResetter interface +func (e *GeneralOrderExecutor) ResetPosition() error { + e.position.Reset() + return nil +} diff --git a/pkg/qbtrade/order_executor_simple.go b/pkg/qbtrade/order_executor_simple.go new file mode 100644 index 0000000..b5e14a6 --- /dev/null +++ b/pkg/qbtrade/order_executor_simple.go @@ -0,0 +1,70 @@ +package qbtrade + +import ( + "context" + + log "github.com/sirupsen/logrus" + "go.uber.org/multierr" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/core" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// SimpleOrderExecutor implements the minimal order executor +// This order executor does not handle position and profit stats update +type SimpleOrderExecutor struct { + BaseOrderExecutor + + logger log.FieldLogger +} + +func NewSimpleOrderExecutor(session *ExchangeSession) *SimpleOrderExecutor { + return &SimpleOrderExecutor{ + BaseOrderExecutor: BaseOrderExecutor{ + session: session, + activeMakerOrders: NewActiveOrderBook(""), + orderStore: core.NewOrderStore(""), + }, + } +} + +func (e *SimpleOrderExecutor) SubmitOrders(ctx context.Context, submitOrders ...types.SubmitOrder) (types.OrderSlice, error) { + formattedOrders, err := e.session.FormatOrders(submitOrders) + if err != nil { + return nil, err + } + + orderCreateCallback := func(createdOrder types.Order) { + e.orderStore.Add(createdOrder) + e.activeMakerOrders.Add(createdOrder) + } + + createdOrders, _, err := BatchPlaceOrder(ctx, e.session.Exchange, orderCreateCallback, formattedOrders...) + return createdOrders, err +} + +// CancelOrders cancels the given order objects directly +func (e *SimpleOrderExecutor) CancelOrders(ctx context.Context, orders ...types.Order) error { + if len(orders) == 0 { + orders = e.activeMakerOrders.Orders() + } + + if len(orders) == 0 { + return nil + } + + err := e.session.Exchange.CancelOrders(ctx, orders...) + if err != nil { // Retry once + err2 := e.session.Exchange.CancelOrders(ctx, orders...) + if err2 != nil { + return multierr.Append(err, err2) + } + } + + return err +} + +func (e *SimpleOrderExecutor) Bind() { + e.activeMakerOrders.BindStream(e.session.UserDataStream) + e.orderStore.BindStream(e.session.UserDataStream) +} diff --git a/pkg/qbtrade/order_processor.go b/pkg/qbtrade/order_processor.go new file mode 100644 index 0000000..bd25276 --- /dev/null +++ b/pkg/qbtrade/order_processor.go @@ -0,0 +1,62 @@ +package qbtrade + +import ( + "github.com/pkg/errors" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +var ( + ErrQuoteBalanceLevelTooLow = errors.New("quote balance level is too low") + ErrInsufficientQuoteBalance = errors.New("insufficient quote balance") + + ErrAssetBalanceLevelTooLow = errors.New("asset balance level too low") + ErrInsufficientAssetBalance = errors.New("insufficient asset balance") + ErrAssetBalanceLevelTooHigh = errors.New("asset balance level too high") +) + +// AdjustQuantityByMaxAmount adjusts the quantity to make the amount less than the given maxAmount +func AdjustQuantityByMaxAmount(quantity, currentPrice, maxAmount fixedpoint.Value) fixedpoint.Value { + // modify quantity for the min amount + amount := currentPrice.Mul(quantity) + if amount.Compare(maxAmount) < 0 { + return quantity + } + + ratio := maxAmount.Div(amount) + return quantity.Mul(ratio) +} + +// AdjustQuantityByMinAmount adjusts the quantity to make the amount greater than the given minAmount +func AdjustQuantityByMinAmount(quantity, currentPrice, minAmount fixedpoint.Value) fixedpoint.Value { + // modify quantity for the min amount + amount := currentPrice.Mul(quantity) + if amount.Compare(minAmount) < 0 { + ratio := minAmount.Div(amount) + return quantity.Mul(ratio) + } + + return quantity +} + +// AdjustFloatQuantityByMinAmount adjusts the quantity to make the amount greater than the given minAmount +func AdjustFloatQuantityByMinAmount(quantity, currentPrice, minAmount fixedpoint.Value) fixedpoint.Value { + // modify quantity for the min amount + amount := currentPrice.Mul(quantity) + if amount.Compare(minAmount) < 0 { + ratio := minAmount.Div(amount) + return quantity.Mul(ratio) + } + + return quantity +} + +func AdjustFloatQuantityByMaxAmount(quantity fixedpoint.Value, price fixedpoint.Value, maxAmount fixedpoint.Value) fixedpoint.Value { + amount := price.Mul(quantity) + if amount.Compare(maxAmount) > 0 { + ratio := maxAmount.Div(amount) + return quantity.Mul(ratio) + } + + return quantity +} diff --git a/pkg/qbtrade/order_processor_test.go b/pkg/qbtrade/order_processor_test.go new file mode 100644 index 0000000..75d9f8e --- /dev/null +++ b/pkg/qbtrade/order_processor_test.go @@ -0,0 +1,56 @@ +package qbtrade + +import ( + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "github.com/stretchr/testify/assert" +) + +func TestAdjustQuantityByMinAmount(t *testing.T) { + type args struct { + quantity, price, minAmount fixedpoint.Value + } + type testcase struct { + name string + args args + wanted string + } + + tests := []testcase{ + { + name: "amount too small", + args: args{ + fixedpoint.MustNewFromString("0.1"), + fixedpoint.MustNewFromString("10.0"), + fixedpoint.MustNewFromString("10.0"), + }, + wanted: "1.0", + }, + { + name: "amount equals to min amount", + args: args{ + fixedpoint.MustNewFromString("1.0"), + fixedpoint.MustNewFromString("10.0"), + fixedpoint.MustNewFromString("10.0"), + }, + wanted: "1.0", + }, + { + name: "amount is greater than min amount", + args: args{ + fixedpoint.MustNewFromString("2.0"), + fixedpoint.MustNewFromString("10.0"), + fixedpoint.MustNewFromString("10.0"), + }, + wanted: "2.0", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + q := AdjustFloatQuantityByMinAmount(test.args.quantity, test.args.price, test.args.minAmount) + assert.Equal(t, fixedpoint.MustNewFromString(test.wanted), q) + }) + } +} diff --git a/pkg/qbtrade/persistence.go b/pkg/qbtrade/persistence.go new file mode 100644 index 0000000..9399e30 --- /dev/null +++ b/pkg/qbtrade/persistence.go @@ -0,0 +1,122 @@ +package qbtrade + +import ( + "context" + "os" + "reflect" + "sync" + + "github.com/codingconcepts/env" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/dynamic" + "git.qtrade.icu/lychiyu/qbtrade/pkg/service" +) + +var defaultPersistenceServiceFacade = &service.PersistenceServiceFacade{ + Memory: service.NewMemoryService(), +} + +// Sync syncs the object properties into the persistence layer +func Sync(ctx context.Context, obj interface{}) { + id := dynamic.CallID(obj) + if len(id) == 0 { + log.Warnf("InstanceID() is not provided, can not sync persistence") + return + } + + isolation := GetIsolationFromContext(ctx) + + ps := isolation.persistenceServiceFacade.Get() + + locker, ok := obj.(sync.Locker) + if ok { + locker.Lock() + defer locker.Unlock() + } + + err := storePersistenceFields(obj, id, ps) + if err != nil { + log.WithError(err).Errorf("persistence sync failed") + } +} + +func loadPersistenceFields(obj interface{}, id string, persistence service.PersistenceService) error { + return dynamic.IterateFieldsByTag(obj, "persistence", true, 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 := dynamic.NewTypeValueInterface(value.Type()) + // inf := value.Interface() + store := persistence.NewStore("state", id, tag) + if err := store.Load(&newValueInf); err != nil { + if err == service.ErrPersistenceNotExists { + log.Debugf("[loadPersistenceFields] state key does not exist, id = %v, tag = %s", id, tag) + return nil + } + + return err + } + + newValue := reflect.ValueOf(newValueInf) + if value.Kind() != reflect.Ptr && newValue.Kind() == reflect.Ptr { + newValue = newValue.Elem() + } + + log.Debugf("[loadPersistenceFields] %v = %v -> %v\n", field, value, newValue) + + value.Set(newValue) + return nil + }) +} + +func storePersistenceFields(obj interface{}, id string, persistence service.PersistenceService) error { + return dynamic.IterateFieldsByTag(obj, "persistence", true, 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() + store := persistence.NewStore("state", id, tag) + return store.Save(inf) + }) +} + +func NewPersistenceServiceFacade(conf *PersistenceConfig) (*service.PersistenceServiceFacade, error) { + facade := &service.PersistenceServiceFacade{ + Memory: service.NewMemoryService(), + } + + if conf.Redis != nil { + if err := env.Set(conf.Redis); err != nil { + return nil, err + } + + redisPersistence := service.NewRedisPersistenceService(conf.Redis) + facade.Redis = redisPersistence + } + + if conf.Json != nil { + if _, err := os.Stat(conf.Json.Directory); os.IsNotExist(err) { + if err2 := os.MkdirAll(conf.Json.Directory, 0777); err2 != nil { + return nil, errors.Wrapf(err2, "can not create directory: %s", conf.Json.Directory) + } + } + + jsonPersistence := &service.JsonPersistenceService{Directory: conf.Json.Directory} + facade.Json = jsonPersistence + } + + return facade, nil +} + +func ConfigurePersistence(ctx context.Context, environ *Environment, conf *PersistenceConfig) error { + facade, err := NewPersistenceServiceFacade(conf) + if err != nil { + return err + } + + isolation := GetIsolationFromContext(ctx) + isolation.persistenceServiceFacade = facade + + environ.PersistentService = facade + return nil +} diff --git a/pkg/qbtrade/persistence_test.go b/pkg/qbtrade/persistence_test.go new file mode 100644 index 0000000..824e529 --- /dev/null +++ b/pkg/qbtrade/persistence_test.go @@ -0,0 +1,152 @@ +package qbtrade + +import ( + "os" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/dynamic" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/service" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type TestStructWithoutInstanceID struct { + Symbol string +} + +func (s *TestStructWithoutInstanceID) ID() string { + return "test-struct-no-instance-id" +} + +type TestStruct struct { + *Environment + + Position *types.Position `persistence:"position"` + Integer int64 `persistence:"integer"` + Integer2 int64 `persistence:"integer2"` + Float int64 `persistence:"float"` + String string `persistence:"string"` +} + +func (t *TestStruct) InstanceID() string { + return "test-struct" +} + +func preparePersistentServices() []service.PersistenceService { + mem := service.NewMemoryService() + jsonDir := &service.JsonPersistenceService{Directory: "testoutput/persistence"} + pss := []service.PersistenceService{ + mem, + jsonDir, + } + + if _, ok := os.LookupEnv("TEST_REDIS"); ok { + redisP := service.NewRedisPersistenceService(&service.RedisPersistenceConfig{ + Host: "localhost", + Port: "6379", + DB: 0, + }) + pss = append(pss, redisP) + } + + return pss +} + +func Test_CallID(t *testing.T) { + t.Run("default", func(t *testing.T) { + id := dynamic.CallID(&TestStruct{}) + assert.NotEmpty(t, id) + assert.Equal(t, "test-struct", id) + }) + + t.Run("fallback", func(t *testing.T) { + id := dynamic.CallID(&TestStructWithoutInstanceID{Symbol: "BTCUSDT"}) + assert.Equal(t, "test-struct-no-instance-id:BTCUSDT", id) + }) +} + +func Test_loadPersistenceFields(t *testing.T) { + var pss = preparePersistentServices() + + for _, ps := range pss { + psName := reflect.TypeOf(ps).Elem().String() + t.Run(psName+"/empty", func(t *testing.T) { + b := &TestStruct{} + err := loadPersistenceFields(b, "test-empty", ps) + assert.NoError(t, err) + }) + + t.Run(psName+"/nil", func(t *testing.T) { + var b *TestStruct = nil + err := loadPersistenceFields(b, "test-nil", ps) + assert.Equal(t, dynamic.ErrCanNotIterateNilPointer, err) + }) + + t.Run(psName+"/pointer-field", func(t *testing.T) { + var a = &TestStruct{ + Position: types.NewPosition("BTCUSDT", "BTC", "USDT"), + } + a.Position.Base = fixedpoint.NewFromFloat(10.0) + a.Position.AverageCost = fixedpoint.NewFromFloat(3343.0) + err := storePersistenceFields(a, "pointer-field-test", ps) + assert.NoError(t, err) + + b := &TestStruct{} + err = loadPersistenceFields(b, "pointer-field-test", ps) + assert.NoError(t, err) + + assert.Equal(t, "10", a.Position.Base.String()) + assert.Equal(t, "3343", a.Position.AverageCost.String()) + }) + } +} + +func Test_storePersistenceFields(t *testing.T) { + var pss = preparePersistentServices() + + var a = &TestStruct{ + Integer: 1, + Integer2: 2, + Float: 3.0, + String: "foobar", + Position: types.NewPosition("BTCUSDT", "BTC", "USDT"), + } + + a.Position.Base = fixedpoint.NewFromFloat(10.0) + a.Position.AverageCost = fixedpoint.NewFromFloat(3343.0) + + for _, ps := range pss { + psName := reflect.TypeOf(ps).Elem().String() + t.Run("all/"+psName, func(t *testing.T) { + id := dynamic.CallID(a) + err := storePersistenceFields(a, id, ps) + assert.NoError(t, err) + + var i int64 + store := ps.NewStore("state", "test-struct", "integer") + err = store.Load(&i) + assert.NoError(t, err) + assert.Equal(t, int64(1), i) + + var p *types.Position + store = ps.NewStore("state", "test-struct", "position") + err = store.Load(&p) + assert.NoError(t, err) + assert.Equal(t, fixedpoint.NewFromFloat(10.0), p.Base) + assert.Equal(t, fixedpoint.NewFromFloat(3343.0), p.AverageCost) + + var b = &TestStruct{} + err = loadPersistenceFields(b, id, ps) + assert.NoError(t, err) + assert.Equal(t, a.Integer, b.Integer) + assert.Equal(t, a.Integer2, b.Integer2) + assert.Equal(t, a.Float, b.Float) + assert.Equal(t, a.String, b.String) + assert.Equal(t, a.Position, b.Position) + }) + } + +} diff --git a/pkg/qbtrade/profitstats.go b/pkg/qbtrade/profitstats.go new file mode 100644 index 0000000..12757c5 --- /dev/null +++ b/pkg/qbtrade/profitstats.go @@ -0,0 +1 @@ +package qbtrade diff --git a/pkg/qbtrade/quantity_amount.go b/pkg/qbtrade/quantity_amount.go new file mode 100644 index 0000000..eba7341 --- /dev/null +++ b/pkg/qbtrade/quantity_amount.go @@ -0,0 +1,40 @@ +package qbtrade + +import ( + "errors" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +// QuantityOrAmount is a setting structure used for quantity/amount settings +// You can embed this struct into your strategy to share the setting methods +type QuantityOrAmount struct { + // Quantity is the base order quantity for your buy/sell order. + // when quantity is set, the amount option will be not used. + Quantity fixedpoint.Value `json:"quantity"` + + // Amount is the order quote amount for your buy/sell order. + Amount fixedpoint.Value `json:"amount,omitempty"` +} + +func (qa *QuantityOrAmount) IsSet() bool { + return qa.Quantity.Sign() > 0 || qa.Amount.Sign() > 0 +} + +func (qa *QuantityOrAmount) Validate() error { + if qa.Quantity.IsZero() && qa.Amount.IsZero() { + return errors.New("either quantity or amount can not be empty") + } + return nil +} + +// CalculateQuantity calculates the equivalent quantity of the given price when amount is set +// it returns the quantity if the quantity is set +func (qa *QuantityOrAmount) CalculateQuantity(currentPrice fixedpoint.Value) fixedpoint.Value { + if qa.Amount.Sign() > 0 { + quantity := qa.Amount.Div(currentPrice) + return quantity + } + + return qa.Quantity +} diff --git a/pkg/qbtrade/quota.go b/pkg/qbtrade/quota.go new file mode 100644 index 0000000..6fd6392 --- /dev/null +++ b/pkg/qbtrade/quota.go @@ -0,0 +1,67 @@ +package qbtrade + +import ( + "sync" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +type Quota struct { + mu sync.Mutex + Available fixedpoint.Value + Locked fixedpoint.Value +} + +func (q *Quota) Add(fund fixedpoint.Value) { + q.mu.Lock() + q.Available = q.Available.Add(fund) + q.mu.Unlock() +} + +func (q *Quota) Lock(fund fixedpoint.Value) bool { + if fund.Compare(q.Available) > 0 { + return false + } + + q.mu.Lock() + q.Available = q.Available.Sub(fund) + q.Locked = q.Locked.Add(fund) + q.mu.Unlock() + + return true +} + +func (q *Quota) Commit() { + q.mu.Lock() + q.Locked = fixedpoint.Zero + q.mu.Unlock() +} + +func (q *Quota) Rollback() { + q.mu.Lock() + q.Available = q.Available.Add(q.Locked) + q.Locked = fixedpoint.Zero + q.mu.Unlock() +} + +type QuotaTransaction struct { + mu sync.Mutex + BaseAsset Quota + QuoteAsset Quota +} + +func (m *QuotaTransaction) Commit() bool { + m.mu.Lock() + m.BaseAsset.Commit() + m.QuoteAsset.Commit() + m.mu.Unlock() + return true +} + +func (m *QuotaTransaction) Rollback() bool { + m.mu.Lock() + m.BaseAsset.Rollback() + m.QuoteAsset.Rollback() + m.mu.Unlock() + return true +} diff --git a/pkg/qbtrade/reflect.go b/pkg/qbtrade/reflect.go new file mode 100644 index 0000000..12757c5 --- /dev/null +++ b/pkg/qbtrade/reflect.go @@ -0,0 +1 @@ +package qbtrade diff --git a/pkg/qbtrade/reflect_test.go b/pkg/qbtrade/reflect_test.go new file mode 100644 index 0000000..12757c5 --- /dev/null +++ b/pkg/qbtrade/reflect_test.go @@ -0,0 +1 @@ +package qbtrade diff --git a/pkg/qbtrade/reporter.go b/pkg/qbtrade/reporter.go new file mode 100644 index 0000000..f94d441 --- /dev/null +++ b/pkg/qbtrade/reporter.go @@ -0,0 +1,151 @@ +package qbtrade + +import ( + "regexp" + + "github.com/robfig/cron/v3" +) + +type PnLReporter interface { + Run() +} + +type baseReporter struct { + notifier Notifier + cron *cron.Cron + environment *Environment +} + +type PnLReporterManager struct { + baseReporter + + reporters []PnLReporter +} + +func NewPnLReporter(notifier Notifier) *PnLReporterManager { + return &PnLReporterManager{ + baseReporter: baseReporter{ + notifier: notifier, + cron: cron.New(), + }, + } +} + +func (manager *PnLReporterManager) AverageCostBySymbols(symbols ...string) *AverageCostPnLReporter { + reporter := &AverageCostPnLReporter{ + baseReporter: manager.baseReporter, + Symbols: symbols, + } + + manager.reporters = append(manager.reporters, reporter) + return reporter +} + +type AverageCostPnLReporter struct { + baseReporter + + Sessions []string + Symbols []string +} + +func (reporter *AverageCostPnLReporter) Of(sessions ...string) *AverageCostPnLReporter { + reporter.Sessions = sessions + return reporter +} + +func (reporter *AverageCostPnLReporter) When(specs ...string) *AverageCostPnLReporter { + for _, spec := range specs { + _, err := reporter.cron.AddJob(spec, reporter) + if err != nil { + panic(err) + } + } + + return reporter +} + +func (reporter *AverageCostPnLReporter) Run() { + // FIXME: this is causing cyclic import + /* + for _, sessionName := range reporter.Sessions { + session := reporter.environment.sessions[sessionName] + calculator := &pnl.AverageCostCalculator{ + TradingFeeCurrency: session.Exchange.PlatformFeeCurrency(), + } + + for _, symbol := range reporter.Symbols { + report := calculator.NetValue(symbol, session.Trades[symbol].Copy(), session.lastPrices[symbol]) + report.Print() + } + } + */ +} + +type PatternChannelRouter struct { + routes map[*regexp.Regexp]string +} + +func NewPatternChannelRouter(routes map[string]string) *PatternChannelRouter { + router := &PatternChannelRouter{ + routes: make(map[*regexp.Regexp]string), + } + if routes != nil { + router.AddRoute(routes) + } + return router +} + +func (router *PatternChannelRouter) AddRoute(routes map[string]string) { + if routes == nil { + return + } + + if router.routes == nil { + router.routes = make(map[*regexp.Regexp]string) + } + + for pattern, channel := range routes { + router.routes[regexp.MustCompile(pattern)] = channel + } +} + +func (router *PatternChannelRouter) Route(text string) (channel string, ok bool) { + for pattern, channel := range router.routes { + if pattern.MatchString(text) { + ok = true + return channel, ok + } + } + + return channel, ok +} + +type ObjectChannelHandler func(obj interface{}) (channel string, ok bool) + +type ObjectChannelRouter struct { + routes []ObjectChannelHandler +} + +func NewObjectChannelRouter() *ObjectChannelRouter { + return &ObjectChannelRouter{} +} + +func (router *ObjectChannelRouter) AddRoute(f ObjectChannelHandler) { + router.routes = append(router.routes, f) +} + +func (router *ObjectChannelRouter) Route(obj interface{}) (channel string, ok bool) { + for _, f := range router.routes { + channel, ok = f(obj) + if ok { + return + } + } + return +} + +type TradeReporter struct { + *Notifiability +} + +const TemplateOrderReport = `:handshake: {{ .Symbol }} {{ .Side }} Order Update @ {{ .Price }}` diff --git a/pkg/qbtrade/risk.go b/pkg/qbtrade/risk.go new file mode 100644 index 0000000..908cf21 --- /dev/null +++ b/pkg/qbtrade/risk.go @@ -0,0 +1,372 @@ +package qbtrade + +import ( + "context" + "fmt" + "time" + + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/risk" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +var defaultLeverage = fixedpoint.NewFromInt(3) + +var maxIsolatedMarginLeverage = fixedpoint.NewFromInt(10) + +var maxCrossMarginLeverage = fixedpoint.NewFromInt(3) + +type AccountValueCalculator struct { + session *ExchangeSession + quoteCurrency string + prices map[string]fixedpoint.Value + tickers map[string]types.Ticker + updateTime time.Time +} + +func NewAccountValueCalculator(session *ExchangeSession, quoteCurrency string) *AccountValueCalculator { + return &AccountValueCalculator{ + session: session, + quoteCurrency: quoteCurrency, + prices: make(map[string]fixedpoint.Value), + tickers: make(map[string]types.Ticker), + } +} + +func (c *AccountValueCalculator) UpdatePrices(ctx context.Context) error { + balances := c.session.Account.Balances() + currencies := balances.Currencies() + var symbols []string + for _, currency := range currencies { + if currency == c.quoteCurrency { + continue + } + + symbol := currency + c.quoteCurrency + symbols = append(symbols, symbol) + } + + tickers, err := c.session.Exchange.QueryTickers(ctx, symbols...) + if err != nil { + return err + } + + c.tickers = tickers + for symbol, ticker := range tickers { + c.prices[symbol] = ticker.Last + if ticker.Time.After(c.updateTime) { + c.updateTime = ticker.Time + } + } + return nil +} + +func (c *AccountValueCalculator) DebtValue(ctx context.Context) (fixedpoint.Value, error) { + debtValue := fixedpoint.Zero + + if len(c.prices) == 0 { + if err := c.UpdatePrices(ctx); err != nil { + return debtValue, err + } + } + + balances := c.session.Account.Balances() + for _, b := range balances { + symbol := b.Currency + c.quoteCurrency + price, ok := c.prices[symbol] + if !ok { + continue + } + + debtValue = debtValue.Add(b.Debt().Mul(price)) + } + + return debtValue, nil +} + +func (c *AccountValueCalculator) MarketValue(ctx context.Context) (fixedpoint.Value, error) { + marketValue := fixedpoint.Zero + + if len(c.prices) == 0 { + if err := c.UpdatePrices(ctx); err != nil { + return marketValue, err + } + } + + balances := c.session.Account.Balances() + for _, b := range balances { + if b.Currency == c.quoteCurrency { + marketValue = marketValue.Add(b.Total()) + continue + } + + symbol := b.Currency + c.quoteCurrency + price, ok := c.prices[symbol] + if !ok { + continue + } + + marketValue = marketValue.Add(b.Total().Mul(price)) + } + + return marketValue, nil +} + +func (c *AccountValueCalculator) NetValue(ctx context.Context) (fixedpoint.Value, error) { + if len(c.prices) == 0 { + if err := c.UpdatePrices(ctx); err != nil { + return fixedpoint.Zero, err + } + } + + balances := c.session.Account.Balances() + accountValue := calculateNetValueInQuote(balances, c.prices, c.quoteCurrency) + return accountValue, nil +} + +func calculateNetValueInQuote(balances types.BalanceMap, prices types.PriceMap, quoteCurrency string) (accountValue fixedpoint.Value) { + accountValue = fixedpoint.Zero + + for _, b := range balances { + if b.Currency == quoteCurrency { + accountValue = accountValue.Add(b.Net()) + continue + } + + symbol := b.Currency + quoteCurrency // for BTC/USDT, ETH/USDT pairs + symbolReverse := quoteCurrency + b.Currency // for USDT/USDC or USDT/TWD pairs + if price, ok := prices[symbol]; ok { + accountValue = accountValue.Add(b.Net().Mul(price)) + } else if priceReverse, ok2 := prices[symbolReverse]; ok2 { + accountValue = accountValue.Add(b.Net().Div(priceReverse)) + } + } + + return accountValue +} + +func (c *AccountValueCalculator) AvailableQuote(ctx context.Context) (fixedpoint.Value, error) { + accountValue := fixedpoint.Zero + + if len(c.prices) == 0 { + if err := c.UpdatePrices(ctx); err != nil { + return accountValue, err + } + } + + balances := c.session.Account.Balances() + for _, b := range balances { + if b.Currency == c.quoteCurrency { + accountValue = accountValue.Add(b.Net()) + continue + } + + symbol := b.Currency + c.quoteCurrency + price, ok := c.prices[symbol] + if !ok { + continue + } + + accountValue = accountValue.Add(b.Net().Mul(price)) + } + + return accountValue, nil +} + +// MarginLevel calculates the margin level from the asset market value and the debt value +// See https://www.binance.com/en/support/faq/360030493931 +func (c *AccountValueCalculator) MarginLevel(ctx context.Context) (fixedpoint.Value, error) { + marginLevel := fixedpoint.Zero + marketValue, err := c.MarketValue(ctx) + if err != nil { + return marginLevel, err + } + + debtValue, err := c.DebtValue(ctx) + if err != nil { + return marginLevel, err + } + + marginLevel = marketValue.Div(debtValue) + return marginLevel, nil +} + +func aggregateUsdNetValue(balances types.BalanceMap) fixedpoint.Value { + totalUsdValue := fixedpoint.Zero + // get all usd value if any + for currency, balance := range balances { + if types.IsUSDFiatCurrency(currency) { + totalUsdValue = totalUsdValue.Add(balance.Net()) + } + } + + return totalUsdValue +} + +func usdFiatBalances(balances types.BalanceMap) (fiats types.BalanceMap, rest types.BalanceMap) { + rest = make(types.BalanceMap) + fiats = make(types.BalanceMap) + for currency, balance := range balances { + if types.IsUSDFiatCurrency(currency) { + fiats[currency] = balance + } else { + rest[currency] = balance + } + } + + return fiats, rest +} + +func CalculateBaseQuantity(session *ExchangeSession, market types.Market, price, quantity, leverage fixedpoint.Value) (fixedpoint.Value, error) { + // default leverage guard + if leverage.IsZero() { + leverage = defaultLeverage + } + + baseBalance, hasBaseBalance := session.Account.Balance(market.BaseCurrency) + balances := session.Account.Balances() + + usingLeverage := session.Margin || session.IsolatedMargin || session.Futures || session.IsolatedFutures + if !usingLeverage { + // For spot, we simply sell the base quoteCurrency + if hasBaseBalance { + if quantity.IsZero() { + log.Warnf("sell quantity is not set, using all available base balance: %v", baseBalance) + if !baseBalance.Available.IsZero() { + return baseBalance.Available, nil + } + } else { + return fixedpoint.Min(quantity, baseBalance.Available), nil + } + } + + return quantity, types.NewZeroAssetError( + fmt.Errorf("quantity is zero, can not submit sell order, please check your quantity settings, your account balances: %+v", balances)) + } + + usdBalances, restBalances := usdFiatBalances(balances) + + // for isolated margin we can calculate from these two pair + totalUsdValue := fixedpoint.Zero + if len(restBalances) == 1 && types.IsUSDFiatCurrency(market.QuoteCurrency) { + totalUsdValue = aggregateUsdNetValue(balances) + } else if len(restBalances) > 1 { + accountValue := NewAccountValueCalculator(session, "USDT") + netValue, err := accountValue.NetValue(context.Background()) + if err != nil { + return quantity, err + } + + totalUsdValue = netValue + } else { + // TODO: translate quote currency like BTC of ETH/BTC to usd value + totalUsdValue = aggregateUsdNetValue(usdBalances) + } + + if !quantity.IsZero() { + return quantity, nil + } + + if price.IsZero() { + return quantity, fmt.Errorf("%s price can not be zero", market.Symbol) + } + + // using leverage -- starts from here + log.Infof("calculating available leveraged base quantity: base balance = %+v, total usd value %f", baseBalance, totalUsdValue.Float64()) + + // calculate the quantity automatically + if session.Margin || session.IsolatedMargin { + baseBalanceValue := baseBalance.Net().Mul(price) + accountUsdValue := baseBalanceValue.Add(totalUsdValue) + + // avoid using all account value since there will be some trade loss for interests and the fee + accountUsdValue = accountUsdValue.Mul(one.Sub(fixedpoint.NewFromFloat(0.01))) + + log.Infof("calculated account usd value %f %s", accountUsdValue.Float64(), market.QuoteCurrency) + + originLeverage := leverage + if session.IsolatedMargin { + leverage = fixedpoint.Min(leverage, maxIsolatedMarginLeverage) + log.Infof("using isolated margin, maxLeverage=%f originalLeverage=%f currentLeverage=%f", + maxIsolatedMarginLeverage.Float64(), + originLeverage.Float64(), + leverage.Float64()) + } else { + leverage = fixedpoint.Min(leverage, maxCrossMarginLeverage) + log.Infof("using cross margin, maxLeverage=%f originalLeverage=%f currentLeverage=%f", + maxCrossMarginLeverage.Float64(), + originLeverage.Float64(), + leverage.Float64()) + } + + // spot margin use the equity value, so we use the total quote balance here + maxPosition := risk.CalculateMaxPosition(price, accountUsdValue, leverage) + debt := baseBalance.Debt() + maxQuantity := maxPosition.Sub(debt) + + log.Infof("margin leverage: calculated maxQuantity=%f maxPosition=%f debt=%f price=%f accountValue=%f %s leverage=%f", + maxQuantity.Float64(), + maxPosition.Float64(), + debt.Float64(), + price.Float64(), + accountUsdValue.Float64(), + market.QuoteCurrency, + leverage.Float64()) + + return maxQuantity, nil + } + + if session.Futures || session.IsolatedFutures { + maxPositionQuantity := risk.CalculateMaxPosition(price, totalUsdValue, leverage) + + return maxPositionQuantity, nil + } + + return quantity, types.NewZeroAssetError( + errors.New("quantity is zero, can not submit sell order, please check your settings")) +} + +func CalculateQuoteQuantity(ctx context.Context, session *ExchangeSession, quoteCurrency string, leverage fixedpoint.Value) (fixedpoint.Value, error) { + // default leverage guard + if leverage.IsZero() { + leverage = defaultLeverage + } + + quoteBalance, _ := session.Account.Balance(quoteCurrency) + + usingLeverage := session.Margin || session.IsolatedMargin || session.Futures || session.IsolatedFutures + if !usingLeverage { + // For spot, we simply return the quote balance + return quoteBalance.Available.Mul(fixedpoint.Min(leverage, fixedpoint.One)), nil + } + + originLeverage := leverage + if session.IsolatedMargin { + leverage = fixedpoint.Min(leverage, maxIsolatedMarginLeverage) + log.Infof("using isolated margin, maxLeverage=%f originalLeverage=%f currentLeverage=%f", + maxIsolatedMarginLeverage.Float64(), + originLeverage.Float64(), + leverage.Float64()) + } else { + leverage = fixedpoint.Min(leverage, maxCrossMarginLeverage) + log.Infof("using cross margin, maxLeverage=%f originalLeverage=%f currentLeverage=%f", + maxCrossMarginLeverage.Float64(), + originLeverage.Float64(), + leverage.Float64()) + } + + // using leverage -- starts from here + accountValue := NewAccountValueCalculator(session, quoteCurrency) + availableQuote, err := accountValue.AvailableQuote(ctx) + if err != nil { + log.WithError(err).Errorf("can not update available quote") + return fixedpoint.Zero, err + } + + log.Infof("calculating available leveraged quote quantity: account available quote = %+v", availableQuote) + + return availableQuote.Mul(leverage), nil +} diff --git a/pkg/qbtrade/risk_controls.go b/pkg/qbtrade/risk_controls.go new file mode 100644 index 0000000..9d544fd --- /dev/null +++ b/pkg/qbtrade/risk_controls.go @@ -0,0 +1,74 @@ +package qbtrade + +import ( + "context" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type SymbolBasedRiskController struct { + BasicRiskController *BasicRiskController `json:"basic,omitempty" yaml:"basic,omitempty"` +} + +type RiskControlOrderExecutor struct { + *ExchangeOrderExecutor + + // Symbol => Executor config + BySymbol map[string]*SymbolBasedRiskController `json:"bySymbol,omitempty" yaml:"bySymbol,omitempty"` +} + +func (e *RiskControlOrderExecutor) SubmitOrders(ctx context.Context, orders ...types.SubmitOrder) (retOrders types.OrderSlice, err error) { + var symbolOrders = groupSubmitOrdersBySymbol(orders) + for symbol, orders := range symbolOrders { + if controller, ok := e.BySymbol[symbol]; ok && controller != nil { + var riskErrs []error + + orders, riskErrs = controller.BasicRiskController.ProcessOrders(e.Session, orders...) + for _, riskErr := range riskErrs { + // use logger from ExchangeOrderExecutor + logrus.Warnf("RISK ERROR: %s", riskErr.Error()) + } + } + + formattedOrders, err := e.Session.FormatOrders(orders) + if err != nil { + return retOrders, err + } + + retOrders2, err := e.ExchangeOrderExecutor.SubmitOrders(ctx, formattedOrders...) + if err != nil { + return retOrders, err + } + + retOrders = append(retOrders, retOrders2...) + } + + return +} + +type SessionBasedRiskControl struct { + OrderExecutor *RiskControlOrderExecutor `json:"orderExecutor,omitempty" yaml:"orderExecutor"` +} + +func (control *SessionBasedRiskControl) SetBaseOrderExecutor(executor *ExchangeOrderExecutor) { + if control.OrderExecutor == nil { + return + } + + control.OrderExecutor.ExchangeOrderExecutor = executor +} + +func groupSubmitOrdersBySymbol(orders []types.SubmitOrder) map[string][]types.SubmitOrder { + var symbolOrders = make(map[string][]types.SubmitOrder, len(orders)) + for _, order := range orders { + symbolOrders[order.Symbol] = append(symbolOrders[order.Symbol], order) + } + + return symbolOrders +} + +type RiskControls struct { + SessionBasedRiskControl map[string]*SessionBasedRiskControl `json:"sessionBased,omitempty" yaml:"sessionBased,omitempty"` +} diff --git a/pkg/qbtrade/risk_test.go b/pkg/qbtrade/risk_test.go new file mode 100644 index 0000000..79cc23a --- /dev/null +++ b/pkg/qbtrade/risk_test.go @@ -0,0 +1,323 @@ +package qbtrade + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types/mocks" +) + +func newTestTicker() types.Ticker { + return types.Ticker{ + Time: time.Now(), + Volume: fixedpoint.Zero, + Last: fixedpoint.NewFromFloat(19000.0), + Open: fixedpoint.NewFromFloat(19500.0), + High: fixedpoint.NewFromFloat(19900.0), + Low: fixedpoint.NewFromFloat(18800.0), + Buy: fixedpoint.NewFromFloat(19500.0), + Sell: fixedpoint.NewFromFloat(18900.0), + } +} + +func TestAccountValueCalculator_NetValue(t *testing.T) { + + t.Run("borrow and available", func(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockEx := mocks.NewMockExchange(mockCtrl) + // for market data stream and user data stream + mockEx.EXPECT().NewStream().Return(&types.StandardStream{}).Times(2) + mockEx.EXPECT().QueryTickers(gomock.Any(), []string{"BTCUSDT"}).Return(map[string]types.Ticker{ + "BTCUSDT": newTestTicker(), + }, nil) + + session := NewExchangeSession("test", mockEx) + session.Account.UpdateBalances(types.BalanceMap{ + "BTC": { + Currency: "BTC", + Available: fixedpoint.NewFromFloat(2.0), + Locked: fixedpoint.Zero, + Borrowed: fixedpoint.NewFromFloat(1.0), + Interest: fixedpoint.Zero, + NetAsset: fixedpoint.Zero, + }, + "USDT": { + Currency: "USDT", + Available: fixedpoint.NewFromFloat(1000.0), + Locked: fixedpoint.Zero, + Borrowed: fixedpoint.Zero, + Interest: fixedpoint.Zero, + NetAsset: fixedpoint.Zero, + }, + }) + assert.NotNil(t, session) + + cal := NewAccountValueCalculator(session, "USDT") + assert.NotNil(t, cal) + + ctx := context.Background() + netValue, err := cal.NetValue(ctx) + assert.NoError(t, err) + assert.Equal(t, "20000", netValue.String()) + }) + + t.Run("borrowed and sold", func(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockEx := mocks.NewMockExchange(mockCtrl) + // for market data stream and user data stream + mockEx.EXPECT().NewStream().Return(&types.StandardStream{}).Times(2) + mockEx.EXPECT().QueryTickers(gomock.Any(), []string{"BTCUSDT"}).Return(map[string]types.Ticker{ + "BTCUSDT": newTestTicker(), + }, nil) + + session := NewExchangeSession("test", mockEx) + session.Account.UpdateBalances(types.BalanceMap{ + "BTC": { + Currency: "BTC", + Available: fixedpoint.Zero, + Locked: fixedpoint.Zero, + Borrowed: fixedpoint.NewFromFloat(1.0), + Interest: fixedpoint.Zero, + NetAsset: fixedpoint.Zero, + }, + "USDT": { + Currency: "USDT", + Available: fixedpoint.NewFromFloat(21000.0), + Locked: fixedpoint.Zero, + Borrowed: fixedpoint.Zero, + Interest: fixedpoint.Zero, + NetAsset: fixedpoint.Zero, + }, + }) + assert.NotNil(t, session) + + cal := NewAccountValueCalculator(session, "USDT") + assert.NotNil(t, cal) + + ctx := context.Background() + netValue, err := cal.NetValue(ctx) + assert.NoError(t, err) + assert.Equal(t, "2000", netValue.String()) // 21000-19000 + }) +} + +func TestNewAccountValueCalculator_MarginLevel(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockEx := mocks.NewMockExchange(mockCtrl) + // for market data stream and user data stream + mockEx.EXPECT().NewStream().Return(&types.StandardStream{}).Times(2) + mockEx.EXPECT().QueryTickers(gomock.Any(), []string{"BTCUSDT"}).Return(map[string]types.Ticker{ + "BTCUSDT": newTestTicker(), + }, nil) + + session := NewExchangeSession("test", mockEx) + session.Account.UpdateBalances(types.BalanceMap{ + "BTC": { + Currency: "BTC", + Available: fixedpoint.Zero, + Locked: fixedpoint.Zero, + Borrowed: fixedpoint.NewFromFloat(1.0), + Interest: fixedpoint.NewFromFloat(0.003), + NetAsset: fixedpoint.Zero, + }, + "USDT": { + Currency: "USDT", + Available: fixedpoint.NewFromFloat(21000.0), + Locked: fixedpoint.Zero, + Borrowed: fixedpoint.Zero, + Interest: fixedpoint.Zero, + NetAsset: fixedpoint.Zero, + }, + }) + assert.NotNil(t, session) + + cal := NewAccountValueCalculator(session, "USDT") + assert.NotNil(t, cal) + + ctx := context.Background() + marginLevel, err := cal.MarginLevel(ctx) + assert.NoError(t, err) + + // expected (21000 / 19000 * 1.003) + assert.Equal(t, + fixedpoint.NewFromFloat(21000.0).Div(fixedpoint.NewFromFloat(19000.0).Mul(fixedpoint.NewFromFloat(1.003))).FormatString(6), + marginLevel.FormatString(6)) +} + +func number(n float64) fixedpoint.Value { + return fixedpoint.NewFromFloat(n) +} + +func Test_aggregateUsdValue(t *testing.T) { + type args struct { + balances types.BalanceMap + } + tests := []struct { + name string + args args + want fixedpoint.Value + }{ + { + name: "mixed", + args: args{ + balances: types.BalanceMap{ + "USDC": types.Balance{Currency: "USDC", Available: number(70.0)}, + "USDT": types.Balance{Currency: "USDT", Available: number(100.0)}, + "BUSD": types.Balance{Currency: "BUSD", Available: number(80.0)}, + "BTC": types.Balance{Currency: "BTC", Available: number(0.01)}, + }, + }, + want: number(250.0), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, aggregateUsdNetValue(tt.args.balances), "aggregateUsdNetValue(%v)", tt.args.balances) + }) + } +} + +func Test_usdFiatBalances(t *testing.T) { + type args struct { + balances types.BalanceMap + } + tests := []struct { + name string + args args + wantFiats types.BalanceMap + wantRest types.BalanceMap + }{ + { + args: args{ + balances: types.BalanceMap{ + "USDC": types.Balance{Currency: "USDC", Available: number(70.0)}, + "USDT": types.Balance{Currency: "USDT", Available: number(100.0)}, + "BUSD": types.Balance{Currency: "BUSD", Available: number(80.0)}, + "BTC": types.Balance{Currency: "BTC", Available: number(0.01)}, + }, + }, + wantFiats: types.BalanceMap{ + "USDC": types.Balance{Currency: "USDC", Available: number(70.0)}, + "USDT": types.Balance{Currency: "USDT", Available: number(100.0)}, + "BUSD": types.Balance{Currency: "BUSD", Available: number(80.0)}, + }, + wantRest: types.BalanceMap{ + "BTC": types.Balance{Currency: "BTC", Available: number(0.01)}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotFiats, gotRest := usdFiatBalances(tt.args.balances) + assert.Equalf(t, tt.wantFiats, gotFiats, "usdFiatBalances(%v)", tt.args.balances) + assert.Equalf(t, tt.wantRest, gotRest, "usdFiatBalances(%v)", tt.args.balances) + }) + } +} + +func Test_calculateNetValueInQuote(t *testing.T) { + type args struct { + balances types.BalanceMap + prices types.PriceMap + quoteCurrency string + } + tests := []struct { + name string + args args + wantAccountValue fixedpoint.Value + }{ + { + name: "positive asset", + args: args{ + balances: types.BalanceMap{ + "USDC": types.Balance{Currency: "USDC", Available: number(70.0)}, + "USDT": types.Balance{Currency: "USDT", Available: number(100.0)}, + "BUSD": types.Balance{Currency: "BUSD", Available: number(80.0)}, + "BTC": types.Balance{Currency: "BTC", Available: number(0.01)}, + }, + prices: types.PriceMap{ + "USDCUSDT": number(1.0), + "BUSDUSDT": number(1.0), + "BTCUSDT": number(19000.0), + }, + quoteCurrency: "USDT", + }, + wantAccountValue: number(19000.0*0.01 + 100.0 + 80.0 + 70.0), + }, + { + name: "reversed usdt price", + args: args{ + balances: types.BalanceMap{ + "USDC": types.Balance{Currency: "USDC", Available: number(70.0)}, + "TWD": types.Balance{Currency: "TWD", Available: number(3000.0)}, + "USDT": types.Balance{Currency: "USDT", Available: number(100.0)}, + "BUSD": types.Balance{Currency: "BUSD", Available: number(80.0)}, + "BTC": types.Balance{Currency: "BTC", Available: number(0.01)}, + }, + prices: types.PriceMap{ + "USDTTWD": number(30.0), + "USDCUSDT": number(1.0), + "BUSDUSDT": number(1.0), + "BTCUSDT": number(19000.0), + }, + quoteCurrency: "USDT", + }, + wantAccountValue: number(19000.0*0.01 + 100.0 + 80.0 + 70.0 + (3000.0 / 30.0)), + }, + { + name: "borrow base asset", + args: args{ + balances: types.BalanceMap{ + "USDT": types.Balance{Currency: "USDT", Available: number(20000.0 * 2)}, + "USDC": types.Balance{Currency: "USDC", Available: number(70.0)}, + "BUSD": types.Balance{Currency: "BUSD", Available: number(80.0)}, + "BTC": types.Balance{Currency: "BTC", Available: number(0), Borrowed: number(2.0)}, + }, + prices: types.PriceMap{ + "USDCUSDT": number(1.0), + "BUSDUSDT": number(1.0), + "BTCUSDT": number(19000.0), + }, + quoteCurrency: "USDT", + }, + wantAccountValue: number(19000.0*-2.0 + 20000.0*2 + 80.0 + 70.0), + }, + { + name: "multi base asset", + args: args{ + balances: types.BalanceMap{ + "USDT": types.Balance{Currency: "USDT", Available: number(20000.0 * 2)}, + "USDC": types.Balance{Currency: "USDC", Available: number(70.0)}, + "BUSD": types.Balance{Currency: "BUSD", Available: number(80.0)}, + "ETH": types.Balance{Currency: "ETH", Available: number(10.0)}, + "BTC": types.Balance{Currency: "BTC", Available: number(0), Borrowed: number(2.0)}, + }, + prices: types.PriceMap{ + "USDCUSDT": number(1.0), + "BUSDUSDT": number(1.0), + "ETHUSDT": number(1700.0), + "BTCUSDT": number(19000.0), + }, + quoteCurrency: "USDT", + }, + wantAccountValue: number(19000.0*-2.0 + 1700.0*10.0 + 20000.0*2 + 80.0 + 70.0), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.wantAccountValue, calculateNetValueInQuote(tt.args.balances, tt.args.prices, tt.args.quoteCurrency), "calculateNetValueInQuote(%v, %v, %v)", tt.args.balances, tt.args.prices, tt.args.quoteCurrency) + }) + } +} diff --git a/pkg/qbtrade/scale.go b/pkg/qbtrade/scale.go new file mode 100644 index 0000000..5c1b1d8 --- /dev/null +++ b/pkg/qbtrade/scale.go @@ -0,0 +1,441 @@ +package qbtrade + +import ( + "encoding/json" + "fmt" + "math" + + "github.com/pkg/errors" +) + +type Scale interface { + Solve() error + Formula() string + FormulaOf(x float64) string + Call(x float64) (y float64) + Sum(step float64) float64 +} + +func init() { + _ = Scale(&ExponentialScale{}) + _ = Scale(&LogarithmicScale{}) + _ = Scale(&LinearScale{}) + _ = Scale(&QuadraticScale{}) +} + +// f(x) := ab^x +// y := ab^x +// shift xs[0] to 0 (x - h) +// a = y1 +// +// y := ab^(x-h) +// y2/a = b^(x2-h) +// y2/y1 = b^(x2-h) +// +// also posted at https://play.golang.org/p/JlWlwZjoebE +type ExponentialScale struct { + Domain [2]float64 `json:"domain"` + Range [2]float64 `json:"range"` + + a float64 + b float64 + h float64 + s float64 +} + +func (s *ExponentialScale) Solve() error { + if s.Domain[0] > s.Domain[1] { + return errors.New("domain[0] can not greater than domain[1]") + } + + if s.Range[0] == 0 { + return errors.New("for ExponentialScale, range can not start from 0") + } + + s.h = s.Domain[0] + s.a = s.Range[0] + s.b = math.Pow(s.Range[1]/s.Range[0], 1/(s.Domain[1]-s.h)) + s.s = s.Domain[1] - s.h + return nil +} + +func (s *ExponentialScale) Sum(step float64) float64 { + sum := 0.0 + for x := s.Domain[0]; x <= s.Domain[1]; x += step { + sum += s.Call(x) + } + return sum +} + +func (s *ExponentialScale) String() string { + return s.Formula() +} + +func (s *ExponentialScale) Formula() string { + return fmt.Sprintf("f(x) = %f * %f ^ (x - %f)", s.a, s.b, s.h) +} + +func (s *ExponentialScale) FormulaOf(x float64) string { + return fmt.Sprintf("f(%f) = %f * %f ^ (%f - %f)", x, s.a, s.b, x, s.h) +} + +func (s *ExponentialScale) Call(x float64) (y float64) { + if x < s.Domain[0] { + x = s.Domain[0] + } else if x > s.Domain[1] { + x = s.Domain[1] + } + + y = s.a * math.Pow(s.Range[1]/s.Range[0], (x-s.h)/s.s) + return y +} + +type LogarithmicScale struct { + Domain [2]float64 `json:"domain"` + Range [2]float64 `json:"range"` + + h float64 + s float64 + a float64 +} + +func (s *LogarithmicScale) Call(x float64) (y float64) { + if x < s.Domain[0] { + x = s.Domain[0] + } else if x > s.Domain[1] { + x = s.Domain[1] + } + + // y = a * log(x - h) + s + y = s.a*math.Log(x-s.h) + s.s + return y +} + +func (s *LogarithmicScale) Sum(step float64) float64 { + sum := 0.0 + for x := s.Domain[0]; x <= s.Domain[1]; x += step { + sum += s.Call(x) + } + return sum +} + +func (s *LogarithmicScale) String() string { + return s.Formula() +} + +func (s *LogarithmicScale) Formula() string { + return fmt.Sprintf("f(x) = %f * log(x - %f) + %f", s.a, s.h, s.s) +} + +func (s *LogarithmicScale) FormulaOf(x float64) string { + return fmt.Sprintf("f(%f) = %f * log(%f - %f) + %f", x, s.a, x, s.h, s.s) +} + +func (s *LogarithmicScale) Solve() error { + // f(x) = a * log2(x - h) + s + // + // log2(1) = 0 + // + // h = x1 - 1 + // s = y1 + // + // y2 = a * log(x2 - h) + s + // y2 = a * log(x2 - h) + y1 + // y2 - y1 = a * log(x2 - h) + // a = (y2 - y1) / log(x2 - h) + s.h = s.Domain[0] - 1 + s.s = s.Range[0] + s.a = (s.Range[1] - s.Range[0]) / math.Log(s.Domain[1]-s.h) + return nil +} + +type LinearScale struct { + Domain [2]float64 `json:"domain"` + Range [2]float64 `json:"range"` + + // a is the ratio for Range to Domain + a float64 +} + +func (s *LinearScale) Solve() error { + xs := s.Domain + ys := s.Range + + s.a = (ys[1] - ys[0]) / (xs[1] - xs[0]) + + return nil +} + +func (s *LinearScale) Call(x float64) (y float64) { + if x <= s.Domain[0] { + return s.Range[0] + } else if x >= s.Domain[1] { + return s.Range[1] + } + + y = s.Range[0] + (x-s.Domain[0])*s.a + return y +} + +func (s *LinearScale) Sum(step float64) float64 { + sum := 0.0 + for x := s.Domain[0]; x <= s.Domain[1]; x += step { + sum += s.Call(x) + } + return sum +} + +func (s *LinearScale) String() string { + return s.Formula() +} + +func (s *LinearScale) Formula() string { + return fmt.Sprintf("f(x) = %f + (x - %f) * %f", s.Range[0], s.Domain[0], s.a) +} + +func (s *LinearScale) FormulaOf(x float64) string { + return fmt.Sprintf("f(%f) = %f + (%f - %f) * %f", x, s.Range[0], x, s.Domain[0], s.a) +} + +// see also: http://www.vb-helper.com/howto_find_quadratic_curve.html +type QuadraticScale struct { + Domain [3]float64 `json:"domain"` + Range [3]float64 `json:"range"` + + a, b, c float64 +} + +func (s *QuadraticScale) Solve() error { + xs := s.Domain + ys := s.Range + s.a = ((ys[1]-ys[0])*(xs[0]-xs[2]) + (ys[2]-ys[0])*(xs[1]-xs[0])) / + ((xs[0]-xs[2])*(math.Pow(xs[1], 2)-math.Pow(xs[0], 2)) + (xs[1]-xs[0])*(math.Pow(xs[2], 2)-math.Pow(xs[0], 2))) + + s.b = ((ys[1] - ys[0]) - s.a*(math.Pow(xs[1], 2)-math.Pow(xs[0], 2))) / (xs[1] - xs[0]) + s.c = ys[1] - s.a*math.Pow(xs[1], 2) - s.b*xs[1] + return nil +} + +func (s *QuadraticScale) Call(x float64) (y float64) { + if x < s.Domain[0] { + x = s.Domain[0] + } else if x > s.Domain[2] { + x = s.Domain[2] + } + + // y = a * log(x - h) + s + y = s.a*math.Pow(x, 2) + s.b*x + s.c + return y +} + +func (s *QuadraticScale) Sum(step float64) float64 { + sum := 0.0 + for x := s.Domain[0]; x <= s.Domain[1]; x += step { + sum += s.Call(x) + } + return sum +} + +func (s *QuadraticScale) String() string { + return s.Formula() +} + +func (s *QuadraticScale) Formula() string { + return fmt.Sprintf("f(x) = %f * x ^ 2 + %f * x + %f", s.a, s.b, s.c) +} + +func (s *QuadraticScale) FormulaOf(x float64) string { + return fmt.Sprintf("f(%f) = %f * %f ^ 2 + %f * %f + %f", x, s.a, x, s.b, x, s.c) +} + +type SlideRule struct { + // Scale type could be one of "log", "exp", "linear", "quadratic" + // this is similar to the d3.scale + LinearScale *LinearScale `json:"linear"` + LogScale *LogarithmicScale `json:"log"` + ExpScale *ExponentialScale `json:"exp"` + QuadraticScale *QuadraticScale `json:"quadratic"` +} + +func (rule *SlideRule) Range() ([2]float64, error) { + if rule.LogScale != nil { + return rule.LogScale.Range, nil + } + + if rule.ExpScale != nil { + return rule.ExpScale.Range, nil + } + + if rule.LinearScale != nil { + return rule.LinearScale.Range, nil + } + + if rule.QuadraticScale != nil { + r := rule.QuadraticScale.Range + return [2]float64{r[0], r[len(r)-1]}, nil + } + + return [2]float64{}, errors.New("no any scale domain is defined") +} + +func (rule *SlideRule) Scale() (Scale, error) { + if rule.LogScale != nil { + return rule.LogScale, nil + } + + if rule.ExpScale != nil { + return rule.ExpScale, nil + } + + if rule.LinearScale != nil { + return rule.LinearScale, nil + } + + if rule.QuadraticScale != nil { + return rule.QuadraticScale, nil + } + + return nil, errors.New("no any scale is defined") +} + +// LayerScale defines the scale DSL for maker layers, e.g., +// +// quantityScale: +// +// byLayer: +// exp: +// domain: [1, 5] +// range: [0.01, 1.0] +// +// and +// +// quantityScale: +// +// byLayer: +// linear: +// domain: [1, 3] +// range: [0.01, 1.0] +type LayerScale struct { + LayerRule *SlideRule `json:"byLayer"` +} + +func (s *LayerScale) UnmarshalJSON(data []byte) error { + type T LayerScale + var p T + err := json.Unmarshal(data, &p) + if err != nil { + return err + } + + *s = LayerScale(p) + return nil +} + +func (s *LayerScale) Scale(layer int) (quantity float64, err error) { + if s.LayerRule == nil { + err = errors.New("either price or volume scale is not defined") + return + } + + scale, err := s.LayerRule.Scale() + if err != nil { + return 0, err + } + + if err := scale.Solve(); err != nil { + return 0, err + } + + return scale.Call(float64(layer)), nil +} + +// PriceVolumeScale defines the scale DSL for strategy, e.g., +// +// quantityScale: +// +// byPrice: +// exp: +// domain: [10_000, 50_000] +// range: [0.01, 1.0] +// +// and +// +// quantityScale: +// +// byVolume: +// linear: +// domain: [10_000, 50_000] +// range: [0.01, 1.0] +type PriceVolumeScale struct { + ByPriceRule *SlideRule `json:"byPrice"` + ByVolumeRule *SlideRule `json:"byVolume"` +} + +func (s *PriceVolumeScale) Scale(price float64, volume float64) (quantity float64, err error) { + if s.ByPriceRule != nil { + quantity, err = s.ScaleByPrice(price) + return + } else if s.ByVolumeRule != nil { + quantity, err = s.ScaleByVolume(volume) + } else { + err = errors.New("either price or volume scale is not defined") + } + return +} + +// ScaleByPrice scale quantity by the given price +func (s *PriceVolumeScale) ScaleByPrice(price float64) (float64, error) { + if s.ByPriceRule == nil { + return 0, errors.New("byPrice scale is not defined") + } + + scale, err := s.ByPriceRule.Scale() + if err != nil { + return 0, err + } + + if err := scale.Solve(); err != nil { + return 0, err + } + + return scale.Call(price), nil +} + +// ScaleByVolume scale quantity by the given volume +func (s *PriceVolumeScale) ScaleByVolume(volume float64) (float64, error) { + if s.ByVolumeRule == nil { + return 0, errors.New("byVolume scale is not defined") + } + + scale, err := s.ByVolumeRule.Scale() + if err != nil { + return 0, err + } + + if err := scale.Solve(); err != nil { + return 0, err + } + + return scale.Call(volume), nil +} + +type PercentageScale struct { + ByPercentage *SlideRule `json:"byPercentage"` +} + +func (s *PercentageScale) Scale(percentage float64) (float64, error) { + if s.ByPercentage == nil { + return 0.0, errors.New("percentage scale is not defined") + } + + scale, err := s.ByPercentage.Scale() + if err != nil { + return 0.0, err + } + + if err := scale.Solve(); err != nil { + return 0.0, err + } + + return scale.Call(percentage), nil +} diff --git a/pkg/qbtrade/scale_test.go b/pkg/qbtrade/scale_test.go new file mode 100644 index 0000000..ec3ff7a --- /dev/null +++ b/pkg/qbtrade/scale_test.go @@ -0,0 +1,236 @@ +package qbtrade + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +const delta = 1e-9 + +func TestLayerScale_UnmarshalJSON(t *testing.T) { + var s LayerScale + err := json.Unmarshal([]byte(`{ + "byLayer": { + "linear": { + "domain": [ 1, 3 ], + "range": [ 10000.0, 30000.0 ] + } + } + }`), &s) + assert.NoError(t, err) + + if assert.NotNil(t, s.LayerRule) { + assert.NotNil(t, s.LayerRule.LinearScale.Range) + assert.NotNil(t, s.LayerRule.LinearScale.Domain) + } +} + +func TestExponentialScale(t *testing.T) { + // graph see: https://www.desmos.com/calculator/ip0ijbcbbf + scale := ExponentialScale{ + Domain: [2]float64{1000, 2000}, + Range: [2]float64{0.001, 0.01}, + } + + err := scale.Solve() + assert.NoError(t, err) + + assert.Equal(t, "f(x) = 0.001000 * 1.002305 ^ (x - 1000.000000)", scale.String()) + assert.InDelta(t, 0.001, scale.Call(1000.0), delta) + assert.InDelta(t, 0.01, scale.Call(2000.0), delta) + + for x := 1000; x <= 2000; x += 100 { + y := scale.Call(float64(x)) + t.Logf("%s = %f", scale.FormulaOf(float64(x)), y) + } +} + +func TestExponentialScale_Reverse(t *testing.T) { + scale := ExponentialScale{ + Domain: [2]float64{1000, 2000}, + Range: [2]float64{0.1, 0.001}, + } + + err := scale.Solve() + assert.NoError(t, err) + + assert.Equal(t, "f(x) = 0.100000 * 0.995405 ^ (x - 1000.000000)", scale.String()) + assert.InDelta(t, 0.1, scale.Call(1000.0), delta) + assert.InDelta(t, 0.001, scale.Call(2000.0), delta) + + for x := 1000; x <= 2000; x += 100 { + y := scale.Call(float64(x)) + t.Logf("%s = %f", scale.FormulaOf(float64(x)), y) + } +} + +func TestLogScale(t *testing.T) { + // see https://www.desmos.com/calculator/q1ufxx5gry + scale := LogarithmicScale{ + Domain: [2]float64{1000, 2000}, + Range: [2]float64{0.001, 0.01}, + } + + err := scale.Solve() + assert.NoError(t, err) + assert.Equal(t, "f(x) = 0.001303 * log(x - 999.000000) + 0.001000", scale.String()) + assert.InDelta(t, 0.001, scale.Call(1000.0), delta) + assert.InDelta(t, 0.01, scale.Call(2000.0), delta) + for x := 1000; x <= 2000; x += 100 { + y := scale.Call(float64(x)) + t.Logf("%s = %f", scale.FormulaOf(float64(x)), y) + } +} + +func TestLinearScale(t *testing.T) { + scale := LinearScale{ + Domain: [2]float64{1000, 2000}, + Range: [2]float64{3, 10}, + } + + err := scale.Solve() + assert.NoError(t, err) + assert.Equal(t, "f(x) = 3.000000 + (x - 1000.000000) * 0.007000", scale.String()) + assert.InDelta(t, 3, scale.Call(1000), delta) + assert.InDelta(t, 6.5, scale.Call(1500), delta) + assert.InDelta(t, 10, scale.Call(2000), delta) + for x := 1000; x <= 2000; x += 100 { + y := scale.Call(float64(x)) + t.Logf("%s = %f", scale.FormulaOf(float64(x)), y) + } +} + +func TestLinearScale2(t *testing.T) { + scale := LinearScale{ + Domain: [2]float64{1, 3}, + Range: [2]float64{0.1, 0.4}, + } + + err := scale.Solve() + assert.NoError(t, err) + assert.Equal(t, "f(x) = 0.100000 + (x - 1.000000) * 0.150000", scale.String()) + assert.InDelta(t, 0.1, scale.Call(1), delta) + assert.InDelta(t, 0.25, scale.Call(2), delta) + assert.InDelta(t, 0.4, scale.Call(3), delta) +} + +func TestLinearScaleNegative(t *testing.T) { + scale := LinearScale{ + Domain: [2]float64{-1, 3}, + Range: [2]float64{0.1, 0.4}, + } + + err := scale.Solve() + assert.NoError(t, err) + assert.Equal(t, "f(x) = 0.100000 + (x - -1.000000) * 0.075000", scale.String()) + assert.InDelta(t, 0.1, scale.Call(-1), delta) + assert.InDelta(t, 0.25, scale.Call(1), delta) + assert.InDelta(t, 0.4, scale.Call(3), delta) +} + +func TestQuadraticScale(t *testing.T) { + // see https://www.desmos.com/calculator/vfqntrxzpr + scale := QuadraticScale{ + Domain: [3]float64{0, 100, 200}, + Range: [3]float64{1, 20, 50}, + } + + err := scale.Solve() + assert.NoError(t, err) + assert.Equal(t, "f(x) = 0.000550 * x ^ 2 + 0.135000 * x + 1.000000", scale.String()) + assert.InDelta(t, 1, scale.Call(0), delta) + assert.InDelta(t, 20, scale.Call(100.0), delta) + assert.InDelta(t, 50.0, scale.Call(200.0), delta) + for x := 0; x <= 200; x += 1 { + y := scale.Call(float64(x)) + t.Logf("%s = %f", scale.FormulaOf(float64(x)), y) + } +} + +func TestPercentageScale(t *testing.T) { + t.Run("from 0.0 to 1.0", func(t *testing.T) { + s := &PercentageScale{ + ByPercentage: &SlideRule{ + ExpScale: &ExponentialScale{ + Domain: [2]float64{0.0, 1.0}, + Range: [2]float64{1.0, 100.0}, + }, + }, + } + + v, err := s.Scale(0.0) + assert.NoError(t, err) + assert.InDelta(t, 1.0, v, delta) + + v, err = s.Scale(1.0) + assert.NoError(t, err) + assert.InDelta(t, 100.0, v, delta) + }) + + t.Run("from -1.0 to 1.0", func(t *testing.T) { + s := &PercentageScale{ + ByPercentage: &SlideRule{ + ExpScale: &ExponentialScale{ + Domain: [2]float64{-1.0, 1.0}, + Range: [2]float64{10.0, 100.0}, + }, + }, + } + + v, err := s.Scale(-1.0) + assert.NoError(t, err) + assert.InDelta(t, 10.0, v, delta) + + v, err = s.Scale(1.0) + assert.NoError(t, err) + assert.InDelta(t, 100.0, v, delta) + }) + + t.Run("reverse -1.0 to 1.0", func(t *testing.T) { + s := &PercentageScale{ + ByPercentage: &SlideRule{ + ExpScale: &ExponentialScale{ + Domain: [2]float64{-1.0, 1.0}, + Range: [2]float64{100.0, 10.0}, + }, + }, + } + + v, err := s.Scale(-1.0) + assert.NoError(t, err) + assert.InDelta(t, 100.0, v, delta) + + v, err = s.Scale(1.0) + assert.NoError(t, err) + assert.InDelta(t, 10.0, v, delta) + + v, err = s.Scale(2.0) + assert.NoError(t, err) + assert.InDelta(t, 10.0, v, delta) + + v, err = s.Scale(-2.0) + assert.NoError(t, err) + assert.InDelta(t, 100.0, v, delta) + }) + + t.Run("negative range", func(t *testing.T) { + s := &PercentageScale{ + ByPercentage: &SlideRule{ + ExpScale: &ExponentialScale{ + Domain: [2]float64{0.0, 1.0}, + Range: [2]float64{-100.0, 100.0}, + }, + }, + } + + v, err := s.Scale(0.0) + assert.NoError(t, err) + assert.InDelta(t, -100.0, v, delta) + + v, err = s.Scale(1.0) + assert.NoError(t, err) + assert.InDelta(t, 100.0, v, delta) + }) +} diff --git a/pkg/qbtrade/serialmarketdatastore.go b/pkg/qbtrade/serialmarketdatastore.go new file mode 100644 index 0000000..2a0f44e --- /dev/null +++ b/pkg/qbtrade/serialmarketdatastore.go @@ -0,0 +1,172 @@ +package qbtrade + +import ( + "context" + "sync" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + log "github.com/sirupsen/logrus" +) + +type SerialMarketDataStore struct { + *MarketDataStore + UseMarketTrade bool + KLines map[types.Interval]*types.KLine + MinInterval types.Interval + Subscription []types.Interval + o, h, l, c, v, qv, price fixedpoint.Value + mu sync.Mutex +} + +// @param symbol: symbol to trace on +// @param minInterval: unit interval, related to your signal timeframe +// @param useMarketTrade: if not assigned, default to false. if assigned to true, will use MarketTrade signal to generate klines +func NewSerialMarketDataStore(symbol string, minInterval types.Interval, useMarketTrade ...bool) *SerialMarketDataStore { + return &SerialMarketDataStore{ + MarketDataStore: NewMarketDataStore(symbol), + KLines: make(map[types.Interval]*types.KLine), + UseMarketTrade: len(useMarketTrade) > 0 && useMarketTrade[0], + Subscription: []types.Interval{}, + MinInterval: minInterval, + } +} + +func (store *SerialMarketDataStore) Subscribe(interval types.Interval) { + // dedup + for _, i := range store.Subscription { + if i == interval { + return + } + } + store.Subscription = append(store.Subscription, interval) +} + +func (store *SerialMarketDataStore) BindStream(ctx context.Context, stream types.Stream) { + if store.UseMarketTrade { + if IsBackTesting { + log.Errorf("right now in backtesting, aggTrade event is not yet supported. Use OnKLineClosed instead.") + stream.OnKLineClosed(store.handleKLineClosed) + return + } + go store.tickerProcessor(ctx) + stream.OnMarketTrade(store.handleMarketTrade) + } else { + stream.OnKLineClosed(store.handleKLineClosed) + } +} + +func (store *SerialMarketDataStore) handleKLineClosed(kline types.KLine) { + store.AddKLine(kline) +} + +func (store *SerialMarketDataStore) handleMarketTrade(trade types.Trade) { + store.mu.Lock() + store.price = trade.Price + store.c = store.price + if store.price.Compare(store.h) > 0 { + store.h = store.price + } + if !store.l.IsZero() { + if store.price.Compare(store.l) < 0 { + store.l = store.price + } + } else { + store.l = store.price + } + if store.o.IsZero() { + store.o = store.price + } + store.v = store.v.Add(trade.Quantity) + store.qv = store.qv.Add(trade.QuoteQuantity) + store.mu.Unlock() +} + +func (store *SerialMarketDataStore) tickerProcessor(ctx context.Context) { + duration := store.MinInterval.Duration() + relativeTime := time.Now().UnixNano() % int64(duration) + waitTime := int64(duration) - relativeTime + select { + case <-time.After(time.Duration(waitTime)): + case <-ctx.Done(): + return + } + intervalCloseTicker := time.NewTicker(duration) + defer intervalCloseTicker.Stop() + + for { + select { + case time := <-intervalCloseTicker.C: + kline := types.KLine{ + Symbol: store.Symbol, + StartTime: types.Time(time.Add(-1 * duration).Round(duration)), + EndTime: types.Time(time), + Interval: store.MinInterval, + Closed: true, + } + store.mu.Lock() + if store.c.IsZero() { + kline.Open = store.price + kline.Close = store.price + kline.High = store.price + kline.Low = store.price + kline.Volume = fixedpoint.Zero + kline.QuoteVolume = fixedpoint.Zero + } else { + kline.Open = store.o + kline.Close = store.c + kline.High = store.h + kline.Low = store.l + kline.Volume = store.v + kline.QuoteVolume = store.qv + store.o = fixedpoint.Zero + store.c = fixedpoint.Zero + store.h = fixedpoint.Zero + store.l = fixedpoint.Zero + store.v = fixedpoint.Zero + store.qv = fixedpoint.Zero + } + store.mu.Unlock() + store.AddKLine(kline, true) + case <-ctx.Done(): + return + } + } + +} + +func (store *SerialMarketDataStore) AddKLine(kline types.KLine, async ...bool) { + if kline.Symbol != store.Symbol { + return + } + // only consumes MinInterval + if kline.Interval != store.MinInterval { + return + } + // endtime + duration := store.MinInterval.Duration() + timestamp := kline.StartTime.Time().Add(duration) + for _, val := range store.Subscription { + k, ok := store.KLines[val] + if !ok { + k = &types.KLine{} + k.Set(&kline) + k.Interval = val + k.Closed = false + store.KLines[val] = k + } else { + k.Merge(&kline) + k.Closed = false + } + if timestamp.Round(val.Duration()) == timestamp { + k.Closed = true + if len(async) > 0 && async[0] { + go store.MarketDataStore.AddKLine(*k) + } else { + store.MarketDataStore.AddKLine(*k) + } + delete(store.KLines, val) + } + } +} diff --git a/pkg/qbtrade/session.go b/pkg/qbtrade/session.go new file mode 100644 index 0000000..2ff8e15 --- /dev/null +++ b/pkg/qbtrade/session.go @@ -0,0 +1,1054 @@ +package qbtrade + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/slack-go/slack" + + "github.com/prometheus/client_golang/prometheus" + log "github.com/sirupsen/logrus" + "github.com/spf13/viper" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/cache" + "git.qtrade.icu/lychiyu/qbtrade/pkg/core" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/retry" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util/templateutil" + + exchange2 "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +var KLinePreloadLimit int64 = 1000 + +var ErrEmptyMarketInfo = errors.New("market info should not be empty, 0 markets loaded") + +// ExchangeSession presents the exchange connection Session +// It also maintains and collects the data returned from the stream. +type ExchangeSession struct { + // --------------------------- + // Session config fields + // --------------------------- + + // Exchange Session name + Name string `json:"name,omitempty" yaml:"name,omitempty"` + ExchangeName types.ExchangeName `json:"exchange" yaml:"exchange"` + EnvVarPrefix string `json:"envVarPrefix" yaml:"envVarPrefix"` + Key string `json:"key,omitempty" yaml:"key,omitempty"` + Secret string `json:"secret,omitempty" yaml:"secret,omitempty"` + Passphrase string `json:"passphrase,omitempty" yaml:"passphrase,omitempty"` + SubAccount string `json:"subAccount,omitempty" yaml:"subAccount,omitempty"` + + // Withdrawal is used for enabling withdrawal functions + Withdrawal bool `json:"withdrawal,omitempty" yaml:"withdrawal,omitempty"` + MakerFeeRate fixedpoint.Value `json:"makerFeeRate" yaml:"makerFeeRate"` + TakerFeeRate fixedpoint.Value `json:"takerFeeRate" yaml:"takerFeeRate"` + ModifyOrderAmountForFee bool `json:"modifyOrderAmountForFee" yaml:"modifyOrderAmountForFee"` + + // PublicOnly is used for setting the session to public only (without authentication, no private user data) + PublicOnly bool `json:"publicOnly,omitempty" yaml:"publicOnly"` + + // PrivateChannels is used for filtering the private user data channel, .e.g, orders, trades, balances.. etc + // This option is exchange specific + PrivateChannels []string `json:"privateChannels,omitempty" yaml:"privateChannels,omitempty"` + + // PrivateChannelSymbols is used for filtering the private user data channel, .e.g, order symbol subscription. + // This option is exchange specific + PrivateChannelSymbols []string `json:"privateChannelSymbols,omitempty" yaml:"privateChannelSymbols,omitempty"` + + Margin bool `json:"margin,omitempty" yaml:"margin"` + IsolatedMargin bool `json:"isolatedMargin,omitempty" yaml:"isolatedMargin,omitempty"` + IsolatedMarginSymbol string `json:"isolatedMarginSymbol,omitempty" yaml:"isolatedMarginSymbol,omitempty"` + + Futures bool `json:"futures,omitempty" yaml:"futures"` + IsolatedFutures bool `json:"isolatedFutures,omitempty" yaml:"isolatedFutures,omitempty"` + IsolatedFuturesSymbol string `json:"isolatedFuturesSymbol,omitempty" yaml:"isolatedFuturesSymbol,omitempty"` + + // --------------------------- + // Runtime fields + // --------------------------- + + // The exchange account states + Account *types.Account `json:"-" yaml:"-"` + accountMutex sync.Mutex + + IsInitialized bool `json:"-" yaml:"-"` + + OrderExecutor *ExchangeOrderExecutor `json:"orderExecutor,omitempty" yaml:"orderExecutor,omitempty"` + + // UserDataStream is the connection stream of the exchange + UserDataStream types.Stream `json:"-" yaml:"-"` + MarketDataStream types.Stream `json:"-" yaml:"-"` + + // Subscriptions + // this is a read-only field when running strategy + Subscriptions map[types.Subscription]types.Subscription `json:"-" yaml:"-"` + + Exchange types.Exchange `json:"-" yaml:"-"` + + UseHeikinAshi bool `json:"heikinAshi,omitempty" yaml:"heikinAshi,omitempty"` + + // Trades collects the executed trades from the exchange + // map: symbol -> []trade + Trades map[string]*types.TradeSlice `json:"-" yaml:"-"` + + // markets defines market configuration of a symbol + markets map[string]types.Market + + // orderBooks stores the streaming order book + orderBooks map[string]*types.StreamOrderBook + + // startPrices is used for backtest + startPrices map[string]fixedpoint.Value + + lastPrices map[string]fixedpoint.Value + lastPriceUpdatedAt time.Time + + // marketDataStores contains the market data store of each market + marketDataStores map[string]*MarketDataStore + + positions map[string]*types.Position + + // standard indicators of each market + standardIndicatorSets map[string]*StandardIndicatorSet + + // indicators is the v2 api indicators + indicators map[string]*IndicatorSet + + orderStores map[string]*core.OrderStore + + usedSymbols map[string]struct{} + initializedSymbols map[string]struct{} + + logger log.FieldLogger +} + +func NewExchangeSession(name string, exchange types.Exchange) *ExchangeSession { + userDataStream := exchange.NewStream() + marketDataStream := exchange.NewStream() + marketDataStream.SetPublicOnly() + + session := &ExchangeSession{ + Name: name, + Exchange: exchange, + UserDataStream: userDataStream, + MarketDataStream: marketDataStream, + Subscriptions: make(map[types.Subscription]types.Subscription), + Account: &types.Account{}, + Trades: make(map[string]*types.TradeSlice), + + orderBooks: make(map[string]*types.StreamOrderBook), + markets: make(map[string]types.Market), + startPrices: make(map[string]fixedpoint.Value), + lastPrices: make(map[string]fixedpoint.Value), + positions: make(map[string]*types.Position), + marketDataStores: make(map[string]*MarketDataStore), + standardIndicatorSets: make(map[string]*StandardIndicatorSet), + indicators: make(map[string]*IndicatorSet), + orderStores: make(map[string]*core.OrderStore), + usedSymbols: make(map[string]struct{}), + initializedSymbols: make(map[string]struct{}), + logger: log.WithField("session", name), + } + + session.OrderExecutor = &ExchangeOrderExecutor{ + // copy the notification system so that we can route + Session: session, + } + + return session +} + +func (session *ExchangeSession) GetAccount() (a *types.Account) { + session.accountMutex.Lock() + a = session.Account + session.accountMutex.Unlock() + return a +} + +// UpdateAccount locks the account mutex and update the account object +func (session *ExchangeSession) UpdateAccount(ctx context.Context) (*types.Account, error) { + account, err := session.Exchange.QueryAccount(ctx) + if err != nil { + return nil, err + } + + session.setAccount(account) + return account, nil +} + +func (session *ExchangeSession) setAccount(a *types.Account) { + session.accountMutex.Lock() + session.Account = a + session.accountMutex.Unlock() +} + +// Init initializes the basic data structure and market information by its exchange. +// Note that the subscribed symbols are not loaded in this stage. +func (session *ExchangeSession) Init(ctx context.Context, environ *Environment) error { + if session.IsInitialized { + return ErrSessionAlreadyInitialized + } + + var logger = environ.Logger() + logger = logger.WithField("session", session.Name) + + // override the default logger + session.logger = logger + + // load markets first + logger.Infof("querying market info from %s...", session.Name) + + var disableMarketsCache = false + var markets types.MarketMap + var err error + if util.SetEnvVarBool("DISABLE_MARKETS_CACHE", &disableMarketsCache); disableMarketsCache { + markets, err = session.Exchange.QueryMarkets(ctx) + } else { + markets, err = cache.LoadExchangeMarketsWithCache(ctx, session.Exchange) + if err != nil { + return err + } + } + + if len(markets) == 0 { + return ErrEmptyMarketInfo + } + + session.markets = markets + + if feeRateProvider, ok := session.Exchange.(types.ExchangeDefaultFeeRates); ok { + defaultFeeRates := feeRateProvider.DefaultFeeRates() + if session.MakerFeeRate.IsZero() { + session.MakerFeeRate = defaultFeeRates.MakerFeeRate + } + if session.TakerFeeRate.IsZero() { + session.TakerFeeRate = defaultFeeRates.TakerFeeRate + } + } + + if session.ModifyOrderAmountForFee { + amountProtectExchange, ok := session.Exchange.(types.ExchangeAmountFeeProtect) + if !ok { + return fmt.Errorf("exchange %s does not support order amount protection", session.ExchangeName.String()) + } + + fees := types.ExchangeFee{MakerFeeRate: session.MakerFeeRate, TakerFeeRate: session.TakerFeeRate} + amountProtectExchange.SetModifyOrderAmountForFee(fees) + } + + if session.UseHeikinAshi { + session.MarketDataStream = &types.HeikinAshiStream{ + StandardStreamEmitter: session.MarketDataStream.(types.StandardStreamEmitter), + } + } + + // query and initialize the balances + if !session.PublicOnly { + if len(session.PrivateChannels) > 0 { + if setter, ok := session.UserDataStream.(types.PrivateChannelSetter); ok { + setter.SetPrivateChannels(session.PrivateChannels) + } + } + if len(session.PrivateChannelSymbols) > 0 { + if setter, ok := session.UserDataStream.(types.PrivateChannelSymbolSetter); ok { + setter.SetPrivateChannelSymbols(session.PrivateChannelSymbols) + } + } + + disableStartupBalanceQuery := environ.environmentConfig != nil && environ.environmentConfig.DisableStartupBalanceQuery + if disableStartupBalanceQuery { + session.accountMutex.Lock() + session.Account = types.NewAccount() + session.accountMutex.Unlock() + } else { + logger.Infof("querying account balances...") + account, err := retry.QueryAccountUntilSuccessful(ctx, session.Exchange) + if err != nil { + return err + } + + session.setAccount(account) + session.metricsBalancesUpdater(account.Balances()) + logger.Infof("account %s balances:\n%s", session.Name, account.Balances().String()) + } + + // forward trade updates and order updates to the order executor + session.UserDataStream.OnTradeUpdate(session.OrderExecutor.EmitTradeUpdate) + session.UserDataStream.OnOrderUpdate(session.OrderExecutor.EmitOrderUpdate) + + session.UserDataStream.OnBalanceSnapshot(func(balances types.BalanceMap) { + session.accountMutex.Lock() + session.Account.UpdateBalances(balances) + session.accountMutex.Unlock() + }) + + session.UserDataStream.OnBalanceUpdate(func(balances types.BalanceMap) { + session.accountMutex.Lock() + session.Account.UpdateBalances(balances) + session.accountMutex.Unlock() + }) + + session.bindConnectionStatusNotification(session.UserDataStream, "user data") + + // if metrics mode is enabled, we bind the callbacks to update metrics + if viper.GetBool("metrics") { + session.bindUserDataStreamMetrics(session.UserDataStream) + } + } + + if environ.loggingConfig != nil { + if environ.loggingConfig.Balance { + session.UserDataStream.OnBalanceSnapshot(func(balances types.BalanceMap) { + logger.Info(balances.String()) + }) + session.UserDataStream.OnBalanceUpdate(func(balances types.BalanceMap) { + logger.Info(balances.String()) + }) + } + + if environ.loggingConfig.Trade { + session.UserDataStream.OnTradeUpdate(func(trade types.Trade) { + logger.Info(trade.String()) + }) + } + + if environ.loggingConfig.FilledOrderOnly { + session.UserDataStream.OnOrderUpdate(func(order types.Order) { + if order.Status == types.OrderStatusFilled { + logger.Info(order.String()) + } + }) + } else if environ.loggingConfig.Order { + session.UserDataStream.OnOrderUpdate(func(order types.Order) { + logger.Info(order.String()) + }) + } + } else { + // if logging config is nil, then apply default logging setup + // add trade logger + session.UserDataStream.OnTradeUpdate(func(trade types.Trade) { + logger.Info(trade.String()) + }) + } + + if viper.GetBool("debug-kline") { + session.MarketDataStream.OnKLine(func(kline types.KLine) { + logger.WithField("marketData", "kline").Infof("kline: %+v", kline) + }) + session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { + logger.WithField("marketData", "kline").Infof("kline closed: %+v", kline) + }) + } + + // update last prices + if session.UseHeikinAshi { + session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { + if _, ok := session.startPrices[kline.Symbol]; !ok { + session.startPrices[kline.Symbol] = kline.Open + } + + session.lastPrices[kline.Symbol] = session.MarketDataStream.(*types.HeikinAshiStream).LastOrigin[kline.Symbol][kline.Interval].Close + }) + } else { + session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { + if _, ok := session.startPrices[kline.Symbol]; !ok { + session.startPrices[kline.Symbol] = kline.Open + } + + session.lastPrices[kline.Symbol] = kline.Close + }) + } + + session.MarketDataStream.OnMarketTrade(func(trade types.Trade) { + session.lastPrices[trade.Symbol] = trade.Price + }) + + session.IsInitialized = true + return nil +} + +func (session *ExchangeSession) InitSymbols(ctx context.Context, environ *Environment) error { + if err := session.initUsedSymbols(ctx, environ); err != nil { + return err + } + + return nil +} + +// initUsedSymbols uses usedSymbols to initialize the related data structure +func (session *ExchangeSession) initUsedSymbols(ctx context.Context, environ *Environment) error { + for symbol := range session.usedSymbols { + if err := session.initSymbol(ctx, environ, symbol); err != nil { + return err + } + } + + return nil +} + +// initSymbol loads trades for the symbol, bind stream callbacks, init positions, market data store. +// please note, initSymbol can not be called for the same symbol for twice +func (session *ExchangeSession) initSymbol(ctx context.Context, environ *Environment, symbol string) error { + if _, ok := session.initializedSymbols[symbol]; ok { + // return fmt.Errorf("symbol %s is already initialized", symbol) + return nil + } + + market, ok := session.markets[symbol] + if !ok { + return fmt.Errorf("market %s is not defined", symbol) + } + + disableMarketDataStore := environ.environmentConfig != nil && environ.environmentConfig.DisableMarketDataStore + disableSessionTradeBuffer := environ.environmentConfig != nil && environ.environmentConfig.DisableSessionTradeBuffer + maxSessionTradeBufferSize := 0 + if environ.environmentConfig != nil && environ.environmentConfig.MaxSessionTradeBufferSize > 0 { + maxSessionTradeBufferSize = environ.environmentConfig.MaxSessionTradeBufferSize + } + + session.Trades[symbol] = &types.TradeSlice{Trades: nil} + + if !disableSessionTradeBuffer { + session.UserDataStream.OnTradeUpdate(func(trade types.Trade) { + if trade.Symbol != symbol { + return + } + + session.Trades[symbol].Append(trade) + + if maxSessionTradeBufferSize > 0 { + session.Trades[symbol].Truncate(maxSessionTradeBufferSize) + } + }) + } + + // session wide position + position := &types.Position{ + Symbol: symbol, + BaseCurrency: market.BaseCurrency, + QuoteCurrency: market.QuoteCurrency, + } + position.BindStream(session.UserDataStream) + session.positions[symbol] = position + + orderStore := core.NewOrderStore(symbol) + orderStore.AddOrderUpdate = true + orderStore.BindStream(session.UserDataStream) + session.orderStores[symbol] = orderStore + + marketDataStore := NewMarketDataStore(symbol) + if !disableMarketDataStore { + if _, ok := session.marketDataStores[symbol]; !ok { + marketDataStore.BindStream(session.MarketDataStream) + } + } + session.marketDataStores[symbol] = marketDataStore + + if _, ok := session.standardIndicatorSets[symbol]; !ok { + standardIndicatorSet := NewStandardIndicatorSet(symbol, session.MarketDataStream, marketDataStore) + session.standardIndicatorSets[symbol] = standardIndicatorSet + } + + // used kline intervals by the given symbol + var klineSubscriptions = map[types.Interval]struct{}{} + + minInterval := types.Interval1m + + // Aggregate the intervals that we are using in the subscriptions. + for _, sub := range session.Subscriptions { + switch sub.Channel { + case types.BookChannel: + book := types.NewStreamBook(sub.Symbol) + book.BindStream(session.MarketDataStream) + session.orderBooks[sub.Symbol] = book + + case types.KLineChannel: + if sub.Options.Interval == "" { + continue + } + + if minInterval.Seconds() > sub.Options.Interval.Seconds() { + minInterval = sub.Options.Interval + } + + if sub.Symbol == symbol { + klineSubscriptions[sub.Options.Interval] = struct{}{} + } + } + } + + if !(environ.environmentConfig != nil && environ.environmentConfig.DisableDefaultKLineSubscription) { + // subscribe the 1m kline by default so we can make sure the connection persists. + klineSubscriptions[minInterval] = struct{}{} + } + + if !(environ.environmentConfig != nil && environ.environmentConfig.DisableHistoryKLinePreload) { + for interval := range klineSubscriptions { + // avoid querying the last unclosed kline + endTime := environ.startTime + var i int64 + for i = 0; i < KLinePreloadLimit; i += 1000 { + var duration time.Duration = time.Duration(-i * int64(interval.Duration())) + e := endTime.Add(duration) + + kLines, err := session.Exchange.QueryKLines(ctx, symbol, interval, types.KLineQueryOptions{ + EndTime: &e, + Limit: 1000, // indicators need at least 100 + }) + if err != nil { + return err + } + + if len(kLines) == 0 { + log.Warnf("no kline data for %s %s (end time <= %s)", symbol, interval, e) + continue + } + + // update last prices by the given kline + lastKLine := kLines[len(kLines)-1] + if interval == minInterval { + session.lastPrices[symbol] = lastKLine.Close + } + + for _, k := range kLines { + // let market data store trigger the update, so that the indicator could be updated too. + marketDataStore.AddKLine(k) + } + } + } + + log.Infof("%s last price: %v", symbol, session.lastPrices[symbol]) + } + + session.initializedSymbols[symbol] = struct{}{} + return nil +} + +// Indicators returns the IndicatorSet struct that maintains the kLines stream cache and price stream cache +// It also provides helper methods +func (session *ExchangeSession) Indicators(symbol string) *IndicatorSet { + set, ok := session.indicators[symbol] + if ok { + return set + } + + store, _ := session.MarketDataStore(symbol) + set = NewIndicatorSet(symbol, session.MarketDataStream, store) + session.indicators[symbol] = set + return set +} + +func (session *ExchangeSession) StandardIndicatorSet(symbol string) *StandardIndicatorSet { + log.Warnf("StandardIndicatorSet() is deprecated in v1.49.0 and which will be removed in the next version, please use Indicators() instead") + + set, ok := session.standardIndicatorSets[symbol] + if ok { + return set + } + + store, _ := session.MarketDataStore(symbol) + set = NewStandardIndicatorSet(symbol, session.MarketDataStream, store) + session.standardIndicatorSets[symbol] = set + return set +} + +func (session *ExchangeSession) Position(symbol string) (pos *types.Position, ok bool) { + pos, ok = session.positions[symbol] + if ok { + return pos, ok + } + + market, ok := session.markets[symbol] + if !ok { + return nil, false + } + + pos = &types.Position{ + Symbol: symbol, + BaseCurrency: market.BaseCurrency, + QuoteCurrency: market.QuoteCurrency, + } + ok = true + session.positions[symbol] = pos + return pos, ok +} + +func (session *ExchangeSession) Positions() map[string]*types.Position { + return session.positions +} + +// MarketDataStore returns the market data store of a symbol +func (session *ExchangeSession) MarketDataStore(symbol string) (s *MarketDataStore, ok bool) { + s, ok = session.marketDataStores[symbol] + if ok { + return s, true + } + + s = NewMarketDataStore(symbol) + s.BindStream(session.MarketDataStream) + session.marketDataStores[symbol] = s + return s, true +} + +// KLine updates will be received in the order listend in intervals array +func (session *ExchangeSession) SerialMarketDataStore( + ctx context.Context, symbol string, intervals []types.Interval, useAggTrade ...bool, +) (store *SerialMarketDataStore, ok bool) { + st, ok := session.MarketDataStore(symbol) + if !ok { + return nil, false + } + minInterval := types.Interval1m + for _, i := range intervals { + if minInterval.Seconds() > i.Seconds() { + minInterval = i + } + } + store = NewSerialMarketDataStore(symbol, minInterval, useAggTrade...) + klines, ok := st.KLinesOfInterval(minInterval) + if !ok { + log.Errorf("SerialMarketDataStore: cannot get %s history", minInterval) + return nil, false + } + for _, interval := range intervals { + store.Subscribe(interval) + } + for _, kline := range *klines { + store.AddKLine(kline) + } + store.BindStream(ctx, session.MarketDataStream) + return store, true +} + +// OrderBook returns the personal orderbook of a symbol +func (session *ExchangeSession) OrderBook(symbol string) (s *types.StreamOrderBook, ok bool) { + s, ok = session.orderBooks[symbol] + return s, ok +} + +func (session *ExchangeSession) StartPrice(symbol string) (price fixedpoint.Value, ok bool) { + price, ok = session.startPrices[symbol] + return price, ok +} + +func (session *ExchangeSession) LastPrice(symbol string) (price fixedpoint.Value, ok bool) { + price, ok = session.lastPrices[symbol] + return price, ok +} + +func (session *ExchangeSession) AllLastPrices() map[string]fixedpoint.Value { + return session.lastPrices +} + +func (session *ExchangeSession) LastPrices() map[string]fixedpoint.Value { + return session.lastPrices +} + +func (session *ExchangeSession) Market(symbol string) (market types.Market, ok bool) { + market, ok = session.markets[symbol] + return market, ok +} + +func (session *ExchangeSession) Markets() types.MarketMap { + return session.markets +} + +func (session *ExchangeSession) SetMarkets(markets types.MarketMap) { + session.markets = markets +} + +func (session *ExchangeSession) OrderStore(symbol string) (store *core.OrderStore, ok bool) { + store, ok = session.orderStores[symbol] + return store, ok +} + +func (session *ExchangeSession) OrderStores() map[string]*core.OrderStore { + return session.orderStores +} + +// Subscribe save the subscription info, later it will be assigned to the stream +func (session *ExchangeSession) Subscribe( + channel types.Channel, symbol string, options types.SubscribeOptions, +) *ExchangeSession { + if channel == types.KLineChannel && len(options.Interval) == 0 { + panic("subscription interval for kline can not be empty") + } + + sub := types.Subscription{ + Channel: channel, + Symbol: symbol, + Options: options, + } + + // add to the loaded symbol table + session.usedSymbols[symbol] = struct{}{} + session.Subscriptions[sub] = sub + return session +} + +func (session *ExchangeSession) FormatOrder(order types.SubmitOrder) (types.SubmitOrder, error) { + market, ok := session.Market(order.Symbol) + if !ok { + return order, fmt.Errorf("market is not defined: %s", order.Symbol) + } + + order.Market = market + return order, nil +} + +func (session *ExchangeSession) UpdatePrices(ctx context.Context, currencies []string, fiat string) (err error) { + // TODO: move this cache check to the http routes + // if session.lastPriceUpdatedAt.After(time.Now().Add(-time.Hour)) { + // return nil + // } + + markets := session.Markets() + var symbols []string + for _, c := range currencies { + possibleSymbols := findPossibleMarketSymbols(markets, c, fiat) + symbols = append(symbols, possibleSymbols...) + } + + if len(symbols) == 0 { + return nil + } + + tickers, err := session.Exchange.QueryTickers(ctx, symbols...) + if err != nil || len(tickers) == 0 { + return err + } + + var lastTime time.Time + for k, v := range tickers { + // for {Crypto}/USDT markets + // map things like BTCUSDT = {price} + if market, ok := markets[k]; ok { + if types.IsFiatCurrency(market.BaseCurrency) { + session.lastPrices[k] = v.Last.Div(fixedpoint.One) + } else { + session.lastPrices[k] = v.Last + } + } else { + session.lastPrices[k] = v.Last + } + + if v.Time.After(lastTime) { + lastTime = v.Time + } + } + + session.lastPriceUpdatedAt = lastTime + return err +} + +func (session *ExchangeSession) FindPossibleAssetSymbols() (symbols []string, err error) { + // If the session is an isolated margin session, there will be only the isolated margin symbol + if session.Margin && session.IsolatedMargin { + return []string{ + session.IsolatedMarginSymbol, + }, nil + } + + var balances = session.GetAccount().Balances() + var fiatAssets []string + + for _, currency := range types.FiatCurrencies { + if balance, ok := balances[currency]; ok && balance.Total().Sign() > 0 { + fiatAssets = append(fiatAssets, currency) + } + } + + var symbolMap = map[string]struct{}{} + + for _, market := range session.Markets() { + // ignore the markets that are not fiat currency markets + if !util.StringSliceContains(fiatAssets, market.QuoteCurrency) { + continue + } + + // ignore the asset that we don't have in the balance sheet + balance, hasAsset := balances[market.BaseCurrency] + if !hasAsset || balance.Total().IsZero() { + continue + } + + symbolMap[market.Symbol] = struct{}{} + } + + for s := range symbolMap { + symbols = append(symbols, s) + } + + return symbols, nil +} + +// newBasicPrivateExchange allocates a basic exchange instance with the user private credentials +func (session *ExchangeSession) newBasicPrivateExchange(exchangeName types.ExchangeName) (types.Exchange, error) { + var err error + var exMinimal types.ExchangeMinimal + if session.Key != "" && session.Secret != "" { + exMinimal, err = exchange2.New(exchangeName, session.Key, session.Secret, session.Passphrase) + } else { + exMinimal, err = exchange2.NewWithEnvVarPrefix(exchangeName, session.EnvVarPrefix) + } + + if err != nil { + return nil, err + } + + if ex, ok := exMinimal.(types.Exchange); ok { + return ex, nil + } + + return nil, fmt.Errorf("exchange %T does not implement types.Exchange", exMinimal) +} + +// InitExchange initialize the exchange instance and allocate memory for fields +// In this stage, the session var could be loaded from the JSON config, so the pointer fields are still nil +// The Init method will be called after this stage, environment.Init will call the session.Init method later. +func (session *ExchangeSession) InitExchange(name string, ex types.Exchange) error { + var err error + var exchangeName = session.ExchangeName + + if ex == nil { + if session.PublicOnly { + ex, err = exchange2.NewPublic(exchangeName) + } else { + ex, err = session.newBasicPrivateExchange(exchangeName) + } + } + + if err != nil { + return err + } + + // configure exchange + if session.Margin { + marginExchange, ok := ex.(types.MarginExchange) + if !ok { + return fmt.Errorf("exchange %s does not support margin", exchangeName) + } + + if session.IsolatedMargin { + marginExchange.UseIsolatedMargin(session.IsolatedMarginSymbol) + } else { + marginExchange.UseMargin() + } + } + + if session.Futures { + futuresExchange, ok := ex.(types.FuturesExchange) + if !ok { + return fmt.Errorf("exchange %s does not support futures", exchangeName) + } + + if session.IsolatedFutures { + futuresExchange.UseIsolatedFutures(session.IsolatedFuturesSymbol) + } else { + futuresExchange.UseFutures() + } + } + + session.Name = name + session.Exchange = ex + session.UserDataStream = ex.NewStream() + session.MarketDataStream = ex.NewStream() + session.MarketDataStream.SetPublicOnly() + + // pointer fields + session.Subscriptions = make(map[types.Subscription]types.Subscription) + session.Account = &types.Account{} + session.Trades = make(map[string]*types.TradeSlice) + + session.orderBooks = make(map[string]*types.StreamOrderBook) + session.markets = make(map[string]types.Market) + session.lastPrices = make(map[string]fixedpoint.Value) + session.startPrices = make(map[string]fixedpoint.Value) + session.marketDataStores = make(map[string]*MarketDataStore) + session.positions = make(map[string]*types.Position) + session.standardIndicatorSets = make(map[string]*StandardIndicatorSet) + session.indicators = make(map[string]*IndicatorSet) + session.orderStores = make(map[string]*core.OrderStore) + session.OrderExecutor = &ExchangeOrderExecutor{ + // copy the notification system so that we can route + Session: session, + } + + session.usedSymbols = make(map[string]struct{}) + session.initializedSymbols = make(map[string]struct{}) + session.logger = log.WithField("session", name) + return nil +} + +func (session *ExchangeSession) MarginType() string { + margin := "none" + if session.Margin { + margin = "margin" + if session.IsolatedMargin { + margin = "isolated" + } + } + return margin +} + +func (session *ExchangeSession) metricsBalancesUpdater(balances types.BalanceMap) { + for currency, balance := range balances { + labels := prometheus.Labels{ + "exchange": session.ExchangeName.String(), + "margin": session.MarginType(), + "symbol": session.IsolatedMarginSymbol, + "currency": currency, + } + + metricsTotalBalances.With(labels).Set(balance.Total().Float64()) + metricsLockedBalances.With(labels).Set(balance.Locked.Float64()) + metricsAvailableBalances.With(labels).Set(balance.Available.Float64()) + metricsLastUpdateTimeBalance.With(prometheus.Labels{ + "exchange": session.ExchangeName.String(), + "margin": session.MarginType(), + "channel": "user", + "data_type": "balance", + "symbol": "", + "currency": currency, + }).SetToCurrentTime() + } + +} + +func (session *ExchangeSession) metricsOrderUpdater(order types.Order) { + metricsLastUpdateTimeBalance.With(prometheus.Labels{ + "exchange": session.ExchangeName.String(), + "margin": session.MarginType(), + "channel": "user", + "data_type": "order", + "symbol": order.Symbol, + "currency": "", + }).SetToCurrentTime() +} + +func (session *ExchangeSession) metricsTradeUpdater(trade types.Trade) { + labels := prometheus.Labels{ + "exchange": session.ExchangeName.String(), + "margin": session.MarginType(), + "side": trade.Side.String(), + "symbol": trade.Symbol, + "liquidity": trade.Liquidity(), + } + metricsTradingVolume.With(labels).Add(trade.Quantity.Mul(trade.Price).Float64()) + metricsTradesTotal.With(labels).Inc() + metricsLastUpdateTimeBalance.With(prometheus.Labels{ + "exchange": session.ExchangeName.String(), + "margin": session.MarginType(), + "channel": "user", + "data_type": "trade", + "symbol": trade.Symbol, + "currency": "", + }).SetToCurrentTime() +} + +func (session *ExchangeSession) bindMarketDataStreamMetrics(stream types.Stream) { + stream.OnBookUpdate(func(book types.SliceOrderBook) { + metricsLastUpdateTimeBalance.With(prometheus.Labels{ + "exchange": session.ExchangeName.String(), + "margin": session.MarginType(), + "channel": "market", + "data_type": "book", + "symbol": book.Symbol, + "currency": "", + }).SetToCurrentTime() + }) + stream.OnKLineClosed(func(kline types.KLine) { + metricsLastUpdateTimeBalance.With(prometheus.Labels{ + "exchange": session.ExchangeName.String(), + "margin": session.MarginType(), + "channel": "market", + "data_type": "kline", + "symbol": kline.Symbol, + "currency": "", + }).SetToCurrentTime() + }) +} + +func (session *ExchangeSession) bindUserDataStreamMetrics(stream types.Stream) { + stream.OnBalanceUpdate(session.metricsBalancesUpdater) + stream.OnBalanceSnapshot(session.metricsBalancesUpdater) + stream.OnTradeUpdate(session.metricsTradeUpdater) + stream.OnOrderUpdate(session.metricsOrderUpdater) + stream.OnDisconnect(func() { + metricsConnectionStatus.With(prometheus.Labels{ + "channel": "user", + "exchange": session.ExchangeName.String(), + "margin": session.MarginType(), + "symbol": session.IsolatedMarginSymbol, + }).Set(0.0) + }) + stream.OnConnect(func() { + metricsConnectionStatus.With(prometheus.Labels{ + "channel": "user", + "exchange": session.ExchangeName.String(), + "margin": session.MarginType(), + "symbol": session.IsolatedMarginSymbol, + }).Set(1.0) + }) +} + +func (session *ExchangeSession) bindConnectionStatusNotification(stream types.Stream, streamName string) { + stream.OnDisconnect(func() { + Notify("session %s %s stream disconnected", session.Name, streamName) + }) + stream.OnConnect(func() { + Notify("session %s %s stream connected", session.Name, streamName) + }) +} + +func (session *ExchangeSession) SlackAttachment() slack.Attachment { + var fields []slack.AttachmentField + var footerIcon = types.ExchangeFooterIcon(session.ExchangeName) + return slack.Attachment{ + // Pretext: "", + // Text: text, + Title: session.Name, + Fields: fields, + FooterIcon: footerIcon, + Footer: templateutil.Render("update time {{ . }}", time.Now().Format(time.RFC822)), + } +} + +func (session *ExchangeSession) FormatOrders(orders []types.SubmitOrder) (formattedOrders []types.SubmitOrder, err error) { + for _, order := range orders { + o, err := session.FormatOrder(order) + if err != nil { + return formattedOrders, err + } + formattedOrders = append(formattedOrders, o) + } + + return formattedOrders, err +} + +func findPossibleMarketSymbols(markets types.MarketMap, c, fiat string) (symbols []string) { + var tries []string + // expand USD stable coin currencies + if types.IsUSDFiatCurrency(fiat) { + for _, usdFiat := range types.USDFiatCurrencies { + tries = append(tries, c+usdFiat, usdFiat+c) + } + } else { + tries = []string{c + fiat, fiat + c} + } + + for _, try := range tries { + if markets.Has(try) { + symbols = append(symbols, try) + break + } + } + + return symbols +} diff --git a/pkg/qbtrade/session_test.go b/pkg/qbtrade/session_test.go new file mode 100644 index 0000000..ab7b3b3 --- /dev/null +++ b/pkg/qbtrade/session_test.go @@ -0,0 +1,44 @@ +package qbtrade + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func Test_findPossibleMarketSymbols(t *testing.T) { + t.Run("btcusdt", func(t *testing.T) { + markets := types.MarketMap{ + "BTCUSDT": types.Market{}, + "BTCUSDC": types.Market{}, + "BTCUSD": types.Market{}, + "BTCBUSD": types.Market{}, + } + symbols := findPossibleMarketSymbols(markets, "BTC", "USDT") + if assert.Len(t, symbols, 1) { + assert.Equal(t, "BTCUSDT", symbols[0]) + } + }) + + t.Run("btcusd only", func(t *testing.T) { + markets := types.MarketMap{ + "BTCUSD": types.Market{}, + } + symbols := findPossibleMarketSymbols(markets, "BTC", "USDT") + if assert.Len(t, symbols, 1) { + assert.Equal(t, "BTCUSD", symbols[0]) + } + }) + + t.Run("usd to stable coin", func(t *testing.T) { + markets := types.MarketMap{ + "BTCUSDT": types.Market{}, + } + symbols := findPossibleMarketSymbols(markets, "BTC", "USD") + if assert.Len(t, symbols, 1) { + assert.Equal(t, "BTCUSDT", symbols[0]) + } + }) +} diff --git a/pkg/qbtrade/source.go b/pkg/qbtrade/source.go new file mode 100644 index 0000000..c06ece1 --- /dev/null +++ b/pkg/qbtrade/source.go @@ -0,0 +1,82 @@ +package qbtrade + +import ( + "encoding/json" + "strings" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + log "github.com/sirupsen/logrus" +) + +type SourceFunc func(*types.KLine) fixedpoint.Value + +type selectorInternal struct { + Source string + sourceGetter SourceFunc +} + +func (s *selectorInternal) UnmarshalJSON(d []byte) error { + if err := json.Unmarshal(d, &s.Source); err != nil { + return err + } + s.init() + return nil +} + +func (s selectorInternal) MarshalJSON() ([]byte, error) { + if s.Source == "" { + s.Source = "close" + s.init() + } + return []byte("\"" + s.Source + "\""), nil +} + +type SourceSelector struct { + Source selectorInternal `json:"source,omitempty"` +} + +func (s *selectorInternal) init() { + switch strings.ToLower(s.Source) { + case "close": + s.sourceGetter = func(kline *types.KLine) fixedpoint.Value { return kline.Close } + case "high": + s.sourceGetter = func(kline *types.KLine) fixedpoint.Value { return kline.High } + case "low": + s.sourceGetter = func(kline *types.KLine) fixedpoint.Value { return kline.Low } + case "hl2": + s.sourceGetter = func(kline *types.KLine) fixedpoint.Value { return kline.High.Add(kline.Low).Div(fixedpoint.Two) } + case "hlc3": + s.sourceGetter = func(kline *types.KLine) fixedpoint.Value { + return kline.High.Add(kline.Low).Add(kline.Close).Div(fixedpoint.Three) + } + case "ohlc4": + s.sourceGetter = func(kline *types.KLine) fixedpoint.Value { + return kline.High.Add(kline.Low).Add(kline.Close).Add(kline.Open).Div(fixedpoint.Four) + } + case "open": + s.sourceGetter = func(kline *types.KLine) fixedpoint.Value { return kline.Open } + case "oc2": + s.sourceGetter = func(kline *types.KLine) fixedpoint.Value { return kline.Open.Add(kline.Close).Div(fixedpoint.Two) } + default: + log.Infof("source not set: %s, use hl2 by default", s.Source) + s.sourceGetter = func(kline *types.KLine) fixedpoint.Value { return kline.High.Add(kline.Low).Div(fixedpoint.Two) } + } +} + +func (s *selectorInternal) String() string { + if s.Source == "" { + s.Source = "close" + s.init() + } + return s.Source +} + +// lazy init if empty struct is passed in +func (s *SourceSelector) GetSource(kline *types.KLine) fixedpoint.Value { + if s.Source.Source == "" { + s.Source.Source = "close" + s.Source.init() + } + return s.Source.sourceGetter(kline) +} diff --git a/pkg/qbtrade/source_test.go b/pkg/qbtrade/source_test.go new file mode 100644 index 0000000..742a8da --- /dev/null +++ b/pkg/qbtrade/source_test.go @@ -0,0 +1,34 @@ +package qbtrade + +import ( + "encoding/json" + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +func TestSource(t *testing.T) { + input := "{\"source\":\"high\"}" + type Strategy struct { + SourceSelector + } + s := Strategy{} + assert.NoError(t, json.Unmarshal([]byte(input), &s)) + assert.Equal(t, s.Source.Source, "high") + assert.NotNil(t, s.Source.sourceGetter) + e, err := json.Marshal(&s) + assert.NoError(t, err) + assert.Equal(t, input, string(e)) + + input = "{}" + s = Strategy{} + assert.NoError(t, json.Unmarshal([]byte(input), &s)) + assert.Equal(t, fixedpoint.Zero, s.GetSource(&types.KLine{})) + + e, err = json.Marshal(&Strategy{}) + assert.NoError(t, err) + assert.Equal(t, "{\"source\":\"close\"}", string(e)) + +} diff --git a/pkg/qbtrade/standard_indicator_set.go b/pkg/qbtrade/standard_indicator_set.go new file mode 100644 index 0000000..5076dbc --- /dev/null +++ b/pkg/qbtrade/standard_indicator_set.go @@ -0,0 +1,187 @@ +package qbtrade + +import ( + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +var ( + debugBOLL = false +) + +func init() { + // when using --dotenv option, the dotenv is loaded from command.PersistentPreRunE, not init. + // hence here the env var won't enable the debug flag + util.SetEnvVarBool("DEBUG_BOLL", &debugBOLL) +} + +type MACDConfig struct { + types.IntervalWindow +} + +type StandardIndicatorSet struct { + Symbol string + + // Standard indicators + // interval -> window + iwbIndicators map[types.IntervalWindowBandWidth]*indicator.BOLL + iwIndicators map[indicatorKey]indicator.KLinePusher + macdIndicators map[indicator.MACDConfig]*indicator.MACDLegacy + + stream types.Stream + store *MarketDataStore +} + +type indicatorKey struct { + iw types.IntervalWindow + id string +} + +func NewStandardIndicatorSet(symbol string, stream types.Stream, store *MarketDataStore) *StandardIndicatorSet { + return &StandardIndicatorSet{ + Symbol: symbol, + store: store, + stream: stream, + iwIndicators: make(map[indicatorKey]indicator.KLinePusher), + iwbIndicators: make(map[types.IntervalWindowBandWidth]*indicator.BOLL), + macdIndicators: make(map[indicator.MACDConfig]*indicator.MACDLegacy), + } +} + +func (s *StandardIndicatorSet) initAndBind(inc indicator.KLinePusher, interval types.Interval) { + if klines, ok := s.store.KLinesOfInterval(interval); ok { + for _, k := range *klines { + inc.PushK(k) + } + } + + s.stream.OnKLineClosed(types.KLineWith(s.Symbol, interval, inc.PushK)) +} + +func (s *StandardIndicatorSet) allocateSimpleIndicator(t indicator.KLinePusher, iw types.IntervalWindow, id string) indicator.KLinePusher { + k := indicatorKey{ + iw: iw, + id: id, + } + inc, ok := s.iwIndicators[k] + if ok { + return inc + } + + inc = t + s.initAndBind(inc, iw.Interval) + s.iwIndicators[k] = inc + return t +} + +// SMA is a helper function that returns the simple moving average indicator of the given interval and the window size. +func (s *StandardIndicatorSet) SMA(iw types.IntervalWindow) *indicator.SMA { + inc := s.allocateSimpleIndicator(&indicator.SMA{IntervalWindow: iw}, iw, "sma") + return inc.(*indicator.SMA) +} + +// EWMA is a helper function that returns the exponential weighed moving average indicator of the given interval and the window size. +func (s *StandardIndicatorSet) EWMA(iw types.IntervalWindow) *indicator.EWMA { + inc := s.allocateSimpleIndicator(&indicator.EWMA{IntervalWindow: iw}, iw, "ewma") + return inc.(*indicator.EWMA) +} + +// VWMA +func (s *StandardIndicatorSet) VWMA(iw types.IntervalWindow) *indicator.VWMA { + inc := s.allocateSimpleIndicator(&indicator.VWMA{IntervalWindow: iw}, iw, "vwma") + return inc.(*indicator.VWMA) +} + +func (s *StandardIndicatorSet) PivotHigh(iw types.IntervalWindow) *indicator.PivotHigh { + inc := s.allocateSimpleIndicator(&indicator.PivotHigh{IntervalWindow: iw}, iw, "pivothigh") + return inc.(*indicator.PivotHigh) +} + +func (s *StandardIndicatorSet) PivotLow(iw types.IntervalWindow) *indicator.PivotLow { + inc := s.allocateSimpleIndicator(&indicator.PivotLow{IntervalWindow: iw}, iw, "pivotlow") + return inc.(*indicator.PivotLow) +} + +func (s *StandardIndicatorSet) ATR(iw types.IntervalWindow) *indicator.ATR { + inc := s.allocateSimpleIndicator(&indicator.ATR{IntervalWindow: iw}, iw, "atr") + return inc.(*indicator.ATR) +} + +func (s *StandardIndicatorSet) ATRP(iw types.IntervalWindow) *indicator.ATRP { + inc := s.allocateSimpleIndicator(&indicator.ATRP{IntervalWindow: iw}, iw, "atrp") + return inc.(*indicator.ATRP) +} + +func (s *StandardIndicatorSet) EMV(iw types.IntervalWindow) *indicator.EMV { + inc := s.allocateSimpleIndicator(&indicator.EMV{IntervalWindow: iw}, iw, "emv") + return inc.(*indicator.EMV) +} + +func (s *StandardIndicatorSet) CCI(iw types.IntervalWindow) *indicator.CCI { + inc := s.allocateSimpleIndicator(&indicator.CCI{IntervalWindow: iw}, iw, "cci") + return inc.(*indicator.CCI) +} + +func (s *StandardIndicatorSet) HULL(iw types.IntervalWindow) *indicator.HULL { + inc := s.allocateSimpleIndicator(&indicator.HULL{IntervalWindow: iw}, iw, "hull") + return inc.(*indicator.HULL) +} + +func (s *StandardIndicatorSet) STOCH(iw types.IntervalWindow) *indicator.STOCH { + inc := s.allocateSimpleIndicator(&indicator.STOCH{IntervalWindow: iw}, iw, "stoch") + return inc.(*indicator.STOCH) +} + +// BOLL returns the bollinger band indicator of the given interval, the window and bandwidth +func (s *StandardIndicatorSet) BOLL(iw types.IntervalWindow, bandWidth float64) *indicator.BOLL { + iwb := types.IntervalWindowBandWidth{IntervalWindow: iw, BandWidth: bandWidth} + inc, ok := s.iwbIndicators[iwb] + if !ok { + inc = &indicator.BOLL{IntervalWindow: iw, K: bandWidth, SMA: &indicator.SMA{IntervalWindow: iw}} + s.initAndBind(inc, iw.Interval) + + if debugBOLL { + inc.OnUpdate(func(sma float64, upBand float64, downBand float64) { + logrus.Infof("%s BOLL %s: sma=%f up=%f down=%f", s.Symbol, iw.String(), sma, upBand, downBand) + }) + } + s.iwbIndicators[iwb] = inc + } + + return inc +} + +func (s *StandardIndicatorSet) MACD(iw types.IntervalWindow, shortPeriod, longPeriod int) *indicator.MACDLegacy { + config := indicator.MACDConfig{IntervalWindow: iw, ShortPeriod: shortPeriod, LongPeriod: longPeriod} + + inc, ok := s.macdIndicators[config] + if ok { + return inc + } + + inc = &indicator.MACDLegacy{MACDConfig: config} + s.macdIndicators[config] = inc + s.initAndBind(inc, config.IntervalWindow.Interval) + return inc +} + +func (s *StandardIndicatorSet) RSI(iw types.IntervalWindow) *indicator.RSI { + inc := s.allocateSimpleIndicator(&indicator.RSI{IntervalWindow: iw}, iw, "rsi") + return inc.(*indicator.RSI) +} + +// GHFilter is a helper function that returns the G-H (alpha beta) digital filter of the given interval and the window size. +func (s *StandardIndicatorSet) GHFilter(iw types.IntervalWindow) *indicator.GHFilter { + inc := s.allocateSimpleIndicator(&indicator.GHFilter{IntervalWindow: iw}, iw, "ghfilter") + return inc.(*indicator.GHFilter) +} + +// KalmanFilter is a helper function that returns the Kalman digital filter of the given interval and the window size. +// Note that the additional smooth window is set to zero in standard indicator set. Users have to create their own instance and push K-lines if a smoother filter is needed. +func (s *StandardIndicatorSet) KalmanFilter(iw types.IntervalWindow) *indicator.KalmanFilter { + inc := s.allocateSimpleIndicator(&indicator.KalmanFilter{IntervalWindow: iw, AdditionalSmoothWindow: 0}, iw, "kalmanfilter") + return inc.(*indicator.KalmanFilter) +} diff --git a/pkg/qbtrade/stop_ema.go b/pkg/qbtrade/stop_ema.go new file mode 100644 index 0000000..237a786 --- /dev/null +++ b/pkg/qbtrade/stop_ema.go @@ -0,0 +1,42 @@ +package qbtrade + +import ( + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type StopEMA struct { + types.IntervalWindow + Range fixedpoint.Value `json:"range"` + + stopEWMA *indicator.EWMA +} + +func (s *StopEMA) Bind(session *ExchangeSession, orderExecutor *GeneralOrderExecutor) { + symbol := orderExecutor.Position().Symbol + s.stopEWMA = session.StandardIndicatorSet(symbol).EWMA(s.IntervalWindow) +} + +func (s *StopEMA) Allowed(closePrice fixedpoint.Value) bool { + ema := fixedpoint.NewFromFloat(s.stopEWMA.Last(0)) + if ema.IsZero() { + logrus.Infof("stopEMA protection: value is zero, skip") + return false + } + + emaStopShortPrice := ema.Mul(fixedpoint.One.Sub(s.Range)) + if closePrice.Compare(emaStopShortPrice) < 0 { + Notify("stopEMA %s protection: close price %f less than stopEMA %f = EMA(%f) * (1 - RANGE %f)", + s.IntervalWindow.String(), + closePrice.Float64(), + emaStopShortPrice.Float64(), + ema.Float64(), + s.Range.Float64()) + return false + } + + return true +} diff --git a/pkg/qbtrade/strategy_controller.go b/pkg/qbtrade/strategy_controller.go new file mode 100644 index 0000000..94f1521 --- /dev/null +++ b/pkg/qbtrade/strategy_controller.go @@ -0,0 +1,57 @@ +package qbtrade + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +//go:generate callbackgen -type StrategyController -interface +type StrategyController struct { + Status types.StrategyStatus + + // Callbacks + suspendCallbacks []func() + resumeCallbacks []func() + emergencyStopCallbacks []func() +} + +func (s *StrategyController) GetStatus() types.StrategyStatus { + return s.Status +} + +func (s *StrategyController) Suspend() error { + s.Status = types.StrategyStatusStopped + + s.EmitSuspend() + + return nil +} + +func (s *StrategyController) Resume() error { + s.Status = types.StrategyStatusRunning + + s.EmitResume() + + return nil +} + +func (s *StrategyController) EmergencyStop() error { + s.Status = types.StrategyStatusStopped + + s.EmitEmergencyStop() + + return nil +} + +type StrategyStatusReader interface { + GetStatus() types.StrategyStatus +} + +type StrategyToggler interface { + StrategyStatusReader + Suspend() error + Resume() error +} + +type EmergencyStopper interface { + EmergencyStop() error +} diff --git a/pkg/qbtrade/strategy_test.go b/pkg/qbtrade/strategy_test.go new file mode 100644 index 0000000..327309f --- /dev/null +++ b/pkg/qbtrade/strategy_test.go @@ -0,0 +1,64 @@ +package qbtrade + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/binance" + "git.qtrade.icu/lychiyu/qbtrade/pkg/service" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func TestTradeService(t *testing.T) { + db, mock, err := sqlmock.New() + assert.NoError(t, err) + _ = mock + + xdb := sqlx.NewDb(db, "mysql") + service.NewTradeService(xdb) + /* + + stmt := mock.ExpectQuery(`SELECT \* FROM trades WHERE symbol = \? ORDER BY gid DESC LIMIT 1`) + stmt.WithArgs("BTCUSDT") + stmt.WillReturnRows(sqlmock.NewRows([]string{"gid", "id", "exchange", "symbol", "price", "quantity"})) + + stmt2 := mock.ExpectQuery(`INSERT INTO trades (id, exchange, symbol, price, quantity, quote_quantity, side, is_buyer, is_maker, fee, fee_currency, traded_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + stmt2.WithArgs() + */ +} + +func TestEnvironment_Connect(t *testing.T) { + mysqlURL := os.Getenv("MYSQL_URL") + if len(mysqlURL) == 0 { + t.Skip("require mysql url") + } + mysqlURL = fmt.Sprintf("%s?parseTime=true", mysqlURL) + + key, secret := os.Getenv("BINANCE_API_KEY"), os.Getenv("BINANCE_API_SECRET") + if len(key) == 0 || len(secret) == 0 { + t.Skip("require key and secret") + } + + exchange := binance.New(key, secret) + assert.NotNil(t, exchange) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + environment := NewEnvironment() + environment.AddExchange("binance", exchange). + Subscribe(types.KLineChannel, "BTCUSDT", types.SubscribeOptions{}) + + err := environment.Connect(ctx) + assert.NoError(t, err) + + time.Sleep(5 * time.Second) +} diff --git a/pkg/qbtrade/strategycontroller_callbacks.go b/pkg/qbtrade/strategycontroller_callbacks.go new file mode 100644 index 0000000..39e9f47 --- /dev/null +++ b/pkg/qbtrade/strategycontroller_callbacks.go @@ -0,0 +1,43 @@ +// Code generated by "callbackgen -type StrategyController -interface"; DO NOT EDIT. + +package qbtrade + +import () + +func (s *StrategyController) OnSuspend(cb func()) { + s.suspendCallbacks = append(s.suspendCallbacks, cb) +} + +func (s *StrategyController) EmitSuspend() { + for _, cb := range s.suspendCallbacks { + cb() + } +} + +func (s *StrategyController) OnResume(cb func()) { + s.resumeCallbacks = append(s.resumeCallbacks, cb) +} + +func (s *StrategyController) EmitResume() { + for _, cb := range s.resumeCallbacks { + cb() + } +} + +func (s *StrategyController) OnEmergencyStop(cb func()) { + s.emergencyStopCallbacks = append(s.emergencyStopCallbacks, cb) +} + +func (s *StrategyController) EmitEmergencyStop() { + for _, cb := range s.emergencyStopCallbacks { + cb() + } +} + +type StrategyControllerEventHub interface { + OnSuspend(cb func()) + + OnResume(cb func()) + + OnEmergencyStop(cb func()) +} diff --git a/pkg/qbtrade/string.go b/pkg/qbtrade/string.go new file mode 100644 index 0000000..12757c5 --- /dev/null +++ b/pkg/qbtrade/string.go @@ -0,0 +1 @@ +package qbtrade diff --git a/pkg/qbtrade/testdata/backtest.yaml b/pkg/qbtrade/testdata/backtest.yaml new file mode 100644 index 0000000..7c96b81 --- /dev/null +++ b/pkg/qbtrade/testdata/backtest.yaml @@ -0,0 +1,32 @@ +--- +sessions: + max: + exchange: max + envVarPrefix: max + + binance: + exchange: binance + envVarPrefix: binance + +backtest: + # for testing max draw down (MDD) at 03-12 + # see here for more details + # https://www.investopedia.com/terms/m/maximum-drawdown-mdd.asp + startTime: "2020-01-01" + account: + binance: + makerFeeRate: 15 + takerFeeRate: 15 + balances: + BTC: 1.0 + USDT: 5000.0 + + +exchangeStrategies: +- on: binance + test: + symbol: "BTCUSDT" + interval: "1m" + baseQuantity: 0.1 + minDropPercentage: -0.05 + diff --git a/pkg/qbtrade/testdata/notification.yaml b/pkg/qbtrade/testdata/notification.yaml new file mode 100644 index 0000000..de3d59a --- /dev/null +++ b/pkg/qbtrade/testdata/notification.yaml @@ -0,0 +1,40 @@ +--- +notifications: + slack: + defaultChannel: "#dev-qbtrade" + errorChannel: "#error" + + # if you want to route channel by symbol + symbolChannels: + "^BTC": "#btc" + "^ETH": "#eth" + + # if you want to route channel by exchange session + sessionChannels: + max: "#qbtrade-max" + binance: "#qbtrade-binance" + + # routing rules + routing: + trade: "$symbol" + order: "$symbol" + submitOrder: "$session" + pnL: "#qbtrade-pnl" + +sessions: + max: + exchange: max + envVarPrefix: max + + binance: + exchange: binance + envVarPrefix: binance + +exchangeStrategies: +- on: binance + test: + symbol: "BTCUSDT" + interval: "1m" + baseQuantity: 0.1 + minDropPercentage: -0.05 + diff --git a/pkg/qbtrade/testdata/order_executor.yaml b/pkg/qbtrade/testdata/order_executor.yaml new file mode 100644 index 0000000..306c374 --- /dev/null +++ b/pkg/qbtrade/testdata/order_executor.yaml @@ -0,0 +1,27 @@ +--- +imports: +- git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/buyandhold + +sessions: + max: + exchange: max + envVarPrefix: max + + binance: + exchange: binance + envVarPrefix: binance + +riskControls: + # session-based risk controller + sessionBased: + # max is the session name that you want to configure the risk control + max: + orderExecutor: + bySymbol: + BTCUSDT: + basic: + minQuoteBalance: 1000.0 + maxBaseAssetBalance: 10.0 + minBaseAssetBalance: 1.0 + maxOrderAmount: 100.0 + diff --git a/pkg/qbtrade/testdata/persistence.yaml b/pkg/qbtrade/testdata/persistence.yaml new file mode 100644 index 0000000..c0dff82 --- /dev/null +++ b/pkg/qbtrade/testdata/persistence.yaml @@ -0,0 +1,28 @@ +--- +sessions: + max: + exchange: max + envVarPrefix: max + + binance: + exchange: binance + envVarPrefix: binance + +persistence: + json: + directory: testdata + redis: + host: 127.0.0.1 + port: 6379 + db: 0 + mysql: + database: "persistence" + +strategies: +- on: max + test: + symbolPosition: + persistence: + type: json + json: + file: swing.json diff --git a/pkg/qbtrade/testdata/strategy.yaml b/pkg/qbtrade/testdata/strategy.yaml new file mode 100644 index 0000000..9c1ecc4 --- /dev/null +++ b/pkg/qbtrade/testdata/strategy.yaml @@ -0,0 +1,29 @@ +--- +sessions: + max: + exchange: max + envVarPrefix: MAX + takerFeeRate: 0 + makerFeeRate: 0 + modifyOrderAmountForFee: false + binance: + exchange: binance + envVarPrefix: BINANCE + takerFeeRate: 0 + makerFeeRate: 0 + modifyOrderAmountForFee: false + ftx: + exchange: ftx + envVarPrefix: FTX + takerFeeRate: 0 + makerFeeRate: 0 + modifyOrderAmountForFee: true + +exchangeStrategies: +- on: ["binance"] + test: + symbol: "BTCUSDT" + interval: "1m" + baseQuantity: 0.1 + minDropPercentage: -0.05 + maxAssetQuantity: 1.1 diff --git a/pkg/qbtrade/time.go b/pkg/qbtrade/time.go new file mode 100644 index 0000000..74df028 --- /dev/null +++ b/pkg/qbtrade/time.go @@ -0,0 +1,15 @@ +package qbtrade + +import ( + "time" +) + +var LocalTimeZone *time.Location + +func init() { + var err error + LocalTimeZone, err = time.LoadLocation("Local") + if err != nil { + panic(err) + } +} diff --git a/pkg/qbtrade/trader.go b/pkg/qbtrade/trader.go new file mode 100644 index 0000000..5a4f6ca --- /dev/null +++ b/pkg/qbtrade/trader.go @@ -0,0 +1,465 @@ +package qbtrade + +import ( + "context" + "fmt" + "reflect" + "sync" + + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + + _ "github.com/go-sql-driver/mysql" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/dynamic" + "git.qtrade.icu/lychiyu/qbtrade/pkg/interact" +) + +// Strategy method calls: +// -> Initialize() (optional method) +// -> Defaults() (optional method) +// -> Validate() (optional method) +// -> Run() (optional method) +// -> Shutdown(shutdownCtx context.Context, wg *sync.WaitGroup) +type StrategyID interface { + ID() string +} + +// SingleExchangeStrategy represents the single Exchange strategy +type SingleExchangeStrategy interface { + StrategyID + Run(ctx context.Context, orderExecutor OrderExecutor, session *ExchangeSession) error +} + +// StrategyInitializer's Initialize method is called before the Subscribe method call. +type StrategyInitializer interface { + Initialize() error +} + +type StrategyDefaulter interface { + Defaults() error +} + +type StrategyValidator interface { + Validate() error +} + +type StrategyShutdown interface { + Shutdown(ctx context.Context, wg *sync.WaitGroup) +} + +// ExchangeSessionSubscriber provides an interface for collecting subscriptions from different strategies +// Subscribe method will be called before the user data stream connection is created. +type ExchangeSessionSubscriber interface { + Subscribe(session *ExchangeSession) +} + +type CrossExchangeSessionSubscriber interface { + CrossSubscribe(sessions map[string]*ExchangeSession) +} + +type CrossExchangeStrategy interface { + StrategyID + CrossRun(ctx context.Context, orderExecutionRouter OrderExecutionRouter, sessions map[string]*ExchangeSession) error +} + +type Logging interface { + EnableLogging() + DisableLogging() +} + +type Logger interface { + Warnf(message string, args ...interface{}) + Errorf(message string, args ...interface{}) + Infof(message string, args ...interface{}) +} + +type SilentLogger struct{} + +func (logger *SilentLogger) Infof(string, ...interface{}) {} +func (logger *SilentLogger) Warnf(string, ...interface{}) {} +func (logger *SilentLogger) Errorf(string, ...interface{}) {} + +type Trader struct { + environment *Environment + + riskControls *RiskControls + + crossExchangeStrategies []CrossExchangeStrategy + exchangeStrategies map[string][]SingleExchangeStrategy + + // gracefulShutdown is used for registering strategy's Shutdown calls + // when strategy implements Shutdown(ctx), the func ref will be stored in the callback. + gracefulShutdown GracefulShutdown + + logger Logger +} + +func NewTrader(environ *Environment) *Trader { + return &Trader{ + environment: environ, + exchangeStrategies: make(map[string][]SingleExchangeStrategy), + logger: log.StandardLogger(), + } +} + +func (trader *Trader) EnableLogging() { + trader.logger = log.StandardLogger() +} + +func (trader *Trader) DisableLogging() { + trader.logger = &SilentLogger{} +} + +func (trader *Trader) Configure(userConfig *Config) error { + if userConfig.RiskControls != nil { + trader.SetRiskControls(userConfig.RiskControls) + } + + for _, entry := range userConfig.ExchangeStrategies { + for _, mount := range entry.Mounts { + log.Infof("attaching strategy %T on %s...", entry.Strategy, mount) + if err := trader.AttachStrategyOn(mount, entry.Strategy); err != nil { + return err + } + } + } + + for _, strategy := range userConfig.CrossExchangeStrategies { + log.Infof("attaching cross exchange strategy %T", strategy) + trader.AttachCrossExchangeStrategy(strategy) + } + + return nil +} + +// AttachStrategyOn attaches the single exchange strategy on an exchange Session. +// Single exchange strategy is the default behavior. +func (trader *Trader) AttachStrategyOn(session string, strategies ...SingleExchangeStrategy) error { + if len(trader.environment.sessions) == 0 { + return fmt.Errorf("you don't have any session configured, please check your environment variable or config file") + } + + if _, ok := trader.environment.sessions[session]; !ok { + var keys []string + for k := range trader.environment.sessions { + keys = append(keys, k) + } + + return fmt.Errorf("session %s is not defined, valid sessions are: %v", session, keys) + } + + trader.exchangeStrategies[session] = append( + trader.exchangeStrategies[session], strategies...) + + return nil +} + +// AttachCrossExchangeStrategy attaches the cross exchange strategy +func (trader *Trader) AttachCrossExchangeStrategy(strategy CrossExchangeStrategy) *Trader { + trader.crossExchangeStrategies = append(trader.crossExchangeStrategies, strategy) + + return trader +} + +// SetRiskControls sets the risk controller +// TODO: provide a more DSL way to configure risk controls +func (trader *Trader) SetRiskControls(riskControls *RiskControls) { + trader.riskControls = riskControls +} + +func (trader *Trader) RunSingleExchangeStrategy( + ctx context.Context, strategy SingleExchangeStrategy, session *ExchangeSession, orderExecutor OrderExecutor, +) error { + if v, ok := strategy.(StrategyValidator); ok { + if err := v.Validate(); err != nil { + return fmt.Errorf("failed to validate the config: %w", err) + } + } + + if shutdown, ok := strategy.(StrategyShutdown); ok { + trader.gracefulShutdown.OnShutdown(shutdown.Shutdown) + } + + return strategy.Run(ctx, orderExecutor, session) +} + +func (trader *Trader) getSessionOrderExecutor(sessionName string) OrderExecutor { + var session = trader.environment.sessions[sessionName] + + // default to base order executor + var orderExecutor OrderExecutor = session.OrderExecutor + + // Since the risk controls are loaded from the config file + if trader.riskControls != nil && trader.riskControls.SessionBasedRiskControl != nil { + if control, ok := trader.riskControls.SessionBasedRiskControl[sessionName]; ok { + control.SetBaseOrderExecutor(session.OrderExecutor) + + // pick the wrapped order executor + if control.OrderExecutor != nil { + return control.OrderExecutor + } + } + } + + return orderExecutor +} + +func (trader *Trader) RunAllSingleExchangeStrategy(ctx context.Context) error { + // load and run Session strategies + for sessionName, strategies := range trader.exchangeStrategies { + var session = trader.environment.sessions[sessionName] + var orderExecutor = trader.getSessionOrderExecutor(sessionName) + for _, strategy := range strategies { + if err := trader.RunSingleExchangeStrategy(ctx, strategy, session, orderExecutor); err != nil { + return err + } + } + } + + return nil +} + +func (trader *Trader) injectFieldsAndSubscribe(ctx context.Context) error { + // load and run Session strategies + for sessionName, strategies := range trader.exchangeStrategies { + var session = trader.environment.sessions[sessionName] + for _, strategy := range strategies { + rs := reflect.ValueOf(strategy) + + // get the struct element + rs = rs.Elem() + + if rs.Kind() != reflect.Struct { + return errors.New("strategy object is not a struct") + } + + if err := trader.injectCommonServices(ctx, strategy); err != nil { + return err + } + + if defaulter, ok := strategy.(StrategyDefaulter); ok { + if err := defaulter.Defaults(); err != nil { + panic(err) + } + } + + if subscriber, ok := strategy.(ExchangeSessionSubscriber); ok { + subscriber.Subscribe(session) + } else { + log.Errorf("strategy %s does not implement ExchangeSessionSubscriber", strategy.ID()) + } + + if symbol, ok := dynamic.LookupSymbolField(rs); ok && symbol != "" { + log.Infof("found symbol %s based strategy from %s", symbol, rs.Type()) + + if err := session.initSymbol(ctx, trader.environment, symbol); err != nil { + return errors.Wrapf(err, "failed to inject object into %T when initSymbol", strategy) + } + + market, ok := session.Market(symbol) + if !ok { + return fmt.Errorf("market of symbol %s not found", symbol) + } + + indicatorSet := session.StandardIndicatorSet(symbol) + if !ok { + return fmt.Errorf("standardIndicatorSet of symbol %s not found", symbol) + } + + store, ok := session.MarketDataStore(symbol) + if !ok { + return fmt.Errorf("marketDataStore of symbol %s not found", symbol) + } + + if err := dynamic.ParseStructAndInject(strategy, + market, + session, + session.OrderExecutor, + indicatorSet, + store, + ); err != nil { + return errors.Wrapf(err, "failed to inject object into %T", strategy) + } + } + } + } + + for _, strategy := range trader.crossExchangeStrategies { + rs := reflect.ValueOf(strategy) + + // get the struct element from the struct pointer + rs = rs.Elem() + if rs.Kind() != reflect.Struct { + continue + } + + if err := trader.injectCommonServices(ctx, strategy); err != nil { + return err + } + + if defaulter, ok := strategy.(StrategyDefaulter); ok { + if err := defaulter.Defaults(); err != nil { + return err + } + } + + if initializer, ok := strategy.(StrategyInitializer); ok { + if err := initializer.Initialize(); err != nil { + return err + } + } + + if subscriber, ok := strategy.(CrossExchangeSessionSubscriber); ok { + subscriber.CrossSubscribe(trader.environment.sessions) + } else { + log.Errorf("strategy %s does not implement CrossExchangeSessionSubscriber", strategy.ID()) + } + } + + return nil +} + +func (trader *Trader) Run(ctx context.Context) error { + // before we start the interaction, + // register the core interaction, because we can only get the strategies in this scope + // trader.environment.Connect will call interact.Start + interact.AddCustomInteraction(NewCoreInteraction(trader.environment, trader)) + + if err := trader.injectFieldsAndSubscribe(ctx); err != nil { + return err + } + + if err := trader.environment.Start(ctx); err != nil { + return err + } + + if err := trader.RunAllSingleExchangeStrategy(ctx); err != nil { + return err + } + + router := &ExchangeOrderExecutionRouter{ + sessions: trader.environment.sessions, + executors: make(map[string]OrderExecutor), + } + for sessionID := range trader.environment.sessions { + var orderExecutor = trader.getSessionOrderExecutor(sessionID) + router.executors[sessionID] = orderExecutor + } + + for _, strategy := range trader.crossExchangeStrategies { + if err := strategy.CrossRun(ctx, router, trader.environment.sessions); err != nil { + return err + } + } + + return trader.environment.Connect(ctx) +} + +func (trader *Trader) Initialize(ctx context.Context) error { + return trader.IterateStrategies(func(strategy StrategyID) error { + if initializer, ok := strategy.(StrategyInitializer); ok { + return initializer.Initialize() + } + + return nil + }) +} + +func (trader *Trader) LoadState(ctx context.Context) error { + if trader.environment.BacktestService != nil { + return nil + } + + isolation := GetIsolationFromContext(ctx) + ps := isolation.persistenceServiceFacade.Get() + + log.Infof("loading strategies states...") + return trader.IterateStrategies(func(strategy StrategyID) error { + id := dynamic.CallID(strategy) + return loadPersistenceFields(strategy, id, ps) + }) +} + +func (trader *Trader) IterateStrategies(f func(st StrategyID) error) error { + for _, strategies := range trader.exchangeStrategies { + for _, strategy := range strategies { + if err := f(strategy); err != nil { + return err + } + } + } + + for _, strategy := range trader.crossExchangeStrategies { + if err := f(strategy); err != nil { + return err + } + } + + return nil +} + +// NOTICE: the ctx here is the trading context, which could already be canceled. +func (trader *Trader) SaveState(ctx context.Context) error { + if trader.environment.BacktestService != nil { + return nil + } + + isolation := GetIsolationFromContext(ctx) + + ps := isolation.persistenceServiceFacade.Get() + + log.Debugf("saving strategy persistence states...") + return trader.IterateStrategies(func(strategy StrategyID) error { + id := dynamic.CallID(strategy) + if len(id) == 0 { + return nil + } + + return storePersistenceFields(strategy, id, ps) + }) +} + +func (trader *Trader) Shutdown(ctx context.Context) { + trader.gracefulShutdown.Shutdown(ctx) +} + +func (trader *Trader) injectCommonServices(ctx context.Context, s interface{}) error { + isolation := GetIsolationFromContext(ctx) + + ps := isolation.persistenceServiceFacade + + // 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 := dynamic.HasField(sv, "Persistence"); ok { + // the selector is set, but we need to update the facade pointer + if !field.IsNil() { + elem := field.Elem() + if elem.Kind() != reflect.Struct { + return fmt.Errorf("field Persistence is not a struct element, %s given", field) + } + + if err := dynamic.InjectField(elem.Interface(), "Facade", ps, true); err != nil { + return err + } + + /* + if err := ParseStructAndInject(field.Interface(), persistenceFacade); err != nil { + return err + } + */ + } + } + + return dynamic.ParseStructAndInject(s, + &trader.logger, + Notification, + trader.environment.TradeService, + trader.environment.OrderService, + trader.environment.DatabaseService, + trader.environment.AccountService, + trader.environment, + ps, // if the strategy use persistence facade separately + ) +} diff --git a/pkg/qbtrade/trader_test.go b/pkg/qbtrade/trader_test.go new file mode 100644 index 0000000..12757c5 --- /dev/null +++ b/pkg/qbtrade/trader_test.go @@ -0,0 +1 @@ +package qbtrade diff --git a/pkg/qbtrade/trend_ema.go b/pkg/qbtrade/trend_ema.go new file mode 100644 index 0000000..5cac957 --- /dev/null +++ b/pkg/qbtrade/trend_ema.go @@ -0,0 +1,66 @@ +package qbtrade + +import ( + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type TrendEMA struct { + types.IntervalWindow + + // MaxGradient is the maximum gradient allowed for the entry. + MaxGradient float64 `json:"maxGradient"` + MinGradient float64 `json:"minGradient"` + + ewma *indicator.EWMA + + last, current float64 +} + +func (s *TrendEMA) Bind(session *ExchangeSession, orderExecutor *GeneralOrderExecutor) { + symbol := orderExecutor.Position().Symbol + s.ewma = session.StandardIndicatorSet(symbol).EWMA(s.IntervalWindow) + + session.MarketDataStream.OnStart(func() { + if s.ewma.Length() < 2 { + return + } + + s.last = s.ewma.Values[s.ewma.Length()-2] + s.current = s.ewma.Last(0) + }) + + session.MarketDataStream.OnKLineClosed(types.KLineWith(symbol, s.Interval, func(kline types.KLine) { + s.last = s.current + s.current = s.ewma.Last(0) + })) +} + +func (s *TrendEMA) Gradient() float64 { + if s.last > 0.0 && s.current > 0.0 { + return s.current / s.last + } + return 0.0 +} + +func (s *TrendEMA) GradientAllowed() bool { + gradient := s.Gradient() + + logrus.Infof("trendEMA %+v current=%f last=%f gradient=%f", s, s.current, s.last, gradient) + + if gradient == .0 { + return false + } + + if s.MaxGradient > 0.0 && gradient > s.MaxGradient { + return false + } + + if s.MinGradient > 0.0 && gradient < s.MinGradient { + return false + } + + return true +} diff --git a/pkg/qbtrade/trend_ema_test.go b/pkg/qbtrade/trend_ema_test.go new file mode 100644 index 0000000..63b31da --- /dev/null +++ b/pkg/qbtrade/trend_ema_test.go @@ -0,0 +1,21 @@ +package qbtrade + +import ( + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func Test_TrendEMA(t *testing.T) { + t.Run("Test Trend EMA", func(t *testing.T) { + trendEMA_test := TrendEMA{ + IntervalWindow: types.IntervalWindow{Window: 1}, + } + trendEMA_test.last = 1000.0 + trendEMA_test.current = 1200.0 + + if trendEMA_test.Gradient() != 1.2 { + t.Errorf("Gradient() = %v, want %v", trendEMA_test.Gradient(), 1.2) + } + }) +} diff --git a/pkg/qbtrade/twap_order_executor.go b/pkg/qbtrade/twap_order_executor.go new file mode 100644 index 0000000..13aa75c --- /dev/null +++ b/pkg/qbtrade/twap_order_executor.go @@ -0,0 +1,468 @@ +package qbtrade + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "golang.org/x/time/rate" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/core" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type TwapExecution struct { + Session *ExchangeSession + Symbol string + Side types.SideType + TargetQuantity fixedpoint.Value + SliceQuantity fixedpoint.Value + StopPrice fixedpoint.Value + NumOfTicks int + UpdateInterval time.Duration + DeadlineTime time.Time + + market types.Market + marketDataStream types.Stream + + userDataStream types.Stream + userDataStreamCtx context.Context + cancelUserDataStream context.CancelFunc + + orderBook *types.StreamOrderBook + currentPrice fixedpoint.Value + activePosition fixedpoint.Value + + activeMakerOrders *ActiveOrderBook + orderStore *core.OrderStore + position *types.Position + + executionCtx context.Context + cancelExecution context.CancelFunc + + stoppedC chan struct{} + + state int + + mu sync.Mutex +} + +func (e *TwapExecution) connectMarketData(ctx context.Context) { + log.Infof("connecting market data stream...") + if err := e.marketDataStream.Connect(ctx); err != nil { + log.WithError(err).Errorf("market data stream connect error") + } +} + +func (e *TwapExecution) connectUserData(ctx context.Context) { + log.Infof("connecting user data stream...") + if err := e.userDataStream.Connect(ctx); err != nil { + log.WithError(err).Errorf("user data stream connect error") + } +} + +func (e *TwapExecution) newBestPriceOrder() (orderForm types.SubmitOrder, err error) { + book := e.orderBook.Copy() + sideBook := book.SideBook(e.Side) + + first, ok := sideBook.First() + if !ok { + return orderForm, fmt.Errorf("empty %s %s side book", e.Symbol, e.Side) + } + + newPrice := first.Price + spread, ok := book.Spread() + if !ok { + return orderForm, errors.New("can not calculate spread, neither bid price or ask price exists") + } + + // for example, we have tickSize = 0.01, and spread is 28.02 - 28.00 = 0.02 + // assign tickSpread = min(spread - tickSize, tickSpread) + // + // if number of ticks = 0, than the tickSpread is 0 + // tickSpread = min(0.02 - 0.01, 0) + // price = first bid price 28.00 + tickSpread (0.00) = 28.00 + // + // if number of ticks = 1, than the tickSpread is 0.01 + // tickSpread = min(0.02 - 0.01, 0.01) + // price = first bid price 28.00 + tickSpread (0.01) = 28.01 + // + // if number of ticks = 2, than the tickSpread is 0.02 + // tickSpread = min(0.02 - 0.01, 0.02) + // price = first bid price 28.00 + tickSpread (0.01) = 28.01 + tickSize := e.market.TickSize + tickSpread := tickSize.Mul(fixedpoint.NewFromInt(int64(e.NumOfTicks))) + if spread.Compare(tickSize) > 0 { + // there is a gap in the spread + tickSpread = fixedpoint.Min(tickSpread, spread.Sub(tickSize)) + switch e.Side { + case types.SideTypeSell: + newPrice = newPrice.Sub(tickSpread) + case types.SideTypeBuy: + newPrice = newPrice.Add(tickSpread) + } + } + + if e.StopPrice.Sign() > 0 { + switch e.Side { + case types.SideTypeSell: + if newPrice.Compare(e.StopPrice) < 0 { + log.Infof("%s order price %s is lower than the stop sell price %s, setting order price to the stop sell price %s", + e.Symbol, + newPrice.String(), + e.StopPrice.String(), + e.StopPrice.String()) + newPrice = e.StopPrice + } + + case types.SideTypeBuy: + if newPrice.Compare(e.StopPrice) > 0 { + log.Infof("%s order price %s is higher than the stop buy price %s, setting order price to the stop buy price %s", + e.Symbol, + newPrice.String(), + e.StopPrice.String(), + e.StopPrice.String()) + newPrice = e.StopPrice + } + } + } + + minQuantity := e.market.MinQuantity + base := e.position.GetBase() + + restQuantity := e.TargetQuantity.Sub(base.Abs()) + + if restQuantity.Sign() <= 0 { + if e.cancelContextIfTargetQuantityFilled() { + return + } + } + + if restQuantity.Compare(minQuantity) < 0 { + return orderForm, fmt.Errorf("can not continue placing orders, rest quantity %s is less than the min quantity %s", restQuantity.String(), minQuantity.String()) + } + + // when slice = 1000, if we only have 998, we should adjust our quantity to 998 + orderQuantity := fixedpoint.Min(e.SliceQuantity, restQuantity) + + // if the rest quantity in the next round is not enough, we should merge the rest quantity into this round + // if there are rest slices + nextRestQuantity := restQuantity.Sub(e.SliceQuantity) + if nextRestQuantity.Sign() > 0 && nextRestQuantity.Compare(minQuantity) < 0 { + orderQuantity = restQuantity + } + + minNotional := e.market.MinNotional + orderQuantity = AdjustQuantityByMinAmount(orderQuantity, newPrice, minNotional) + + switch e.Side { + case types.SideTypeSell: + // check base balance for sell, try to sell as more as possible + if b, ok := e.Session.GetAccount().Balance(e.market.BaseCurrency); ok { + orderQuantity = fixedpoint.Min(b.Available, orderQuantity) + } + + case types.SideTypeBuy: + // check base balance for sell, try to sell as more as possible + if b, ok := e.Session.GetAccount().Balance(e.market.QuoteCurrency); ok { + orderQuantity = AdjustQuantityByMaxAmount(orderQuantity, newPrice, b.Available) + } + } + + if e.DeadlineTime != emptyTime { + now := time.Now() + if now.After(e.DeadlineTime) { + orderForm = types.SubmitOrder{ + Symbol: e.Symbol, + Side: e.Side, + Type: types.OrderTypeMarket, + Quantity: restQuantity, + Market: e.market, + } + return orderForm, nil + } + } + + orderForm = types.SubmitOrder{ + // ClientOrderID: "", + Symbol: e.Symbol, + Side: e.Side, + Type: types.OrderTypeLimitMaker, + Quantity: orderQuantity, + Price: newPrice, + Market: e.market, + TimeInForce: "GTC", + } + return orderForm, err +} + +func (e *TwapExecution) updateOrder(ctx context.Context) error { + book := e.orderBook.Copy() + sideBook := book.SideBook(e.Side) + + first, ok := sideBook.First() + if !ok { + return fmt.Errorf("empty %s %s side book", e.Symbol, e.Side) + } + + // if there is no gap between the first price entry and the second price entry + second, ok := sideBook.Second() + if !ok { + return fmt.Errorf("no secoond price on the %s order book %s, can not update", e.Symbol, e.Side) + } + + tickSize := e.market.TickSize + numOfTicks := fixedpoint.NewFromInt(int64(e.NumOfTicks)) + tickSpread := tickSize.Mul(numOfTicks) + + // check and see if we need to cancel the existing active orders + for e.activeMakerOrders.NumOfOrders() > 0 { + orders := e.activeMakerOrders.Orders() + + if len(orders) > 1 { + log.Warnf("more than 1 %s open orders in the strategy...", e.Symbol) + } + + // get the first order + order := orders[0] + orderPrice := order.Price + // quantity := fixedpoint.NewFromFloat(order.Quantity) + + remainingQuantity := order.Quantity.Sub(order.ExecutedQuantity) + if remainingQuantity.Compare(e.market.MinQuantity) <= 0 { + log.Infof("order remaining quantity %s is less than the market minimal quantity %s, skip updating order", remainingQuantity.String(), e.market.MinQuantity.String()) + return nil + } + + // if the first bid price or first ask price is the same to the current active order + // we should skip updating the order + // DO NOT UPDATE IF: + // tickSpread > 0 AND current order price == second price + tickSpread + // current order price == first price + log.Infof("orderPrice = %s first.Price = %s second.Price = %s tickSpread = %s", orderPrice.String(), first.Price.String(), second.Price.String(), tickSpread.String()) + + switch e.Side { + case types.SideTypeBuy: + if tickSpread.Sign() > 0 && orderPrice == second.Price.Add(tickSpread) { + log.Infof("the current order is already on the best ask price %s", orderPrice.String()) + return nil + } else if orderPrice == first.Price { + log.Infof("the current order is already on the best bid price %s", orderPrice.String()) + return nil + } + + case types.SideTypeSell: + if tickSpread.Sign() > 0 && orderPrice == second.Price.Sub(tickSpread) { + log.Infof("the current order is already on the best ask price %s", orderPrice.String()) + return nil + } else if orderPrice == first.Price { + log.Infof("the current order is already on the best ask price %s", orderPrice.String()) + return nil + } + } + + e.cancelActiveOrders() + } + + orderForm, err := e.newBestPriceOrder() + if err != nil { + return err + } + + createdOrders, err := e.Session.OrderExecutor.SubmitOrders(ctx, orderForm) + if err != nil { + return err + } + + e.activeMakerOrders.Add(createdOrders...) + e.orderStore.Add(createdOrders...) + return nil +} + +func (e *TwapExecution) cancelActiveOrders() { + gracefulCtx, gracefulCancel := context.WithTimeout(context.TODO(), 30*time.Second) + defer gracefulCancel() + e.activeMakerOrders.GracefulCancel(gracefulCtx, e.Session.Exchange) +} + +func (e *TwapExecution) orderUpdater(ctx context.Context) { + updateLimiter := rate.NewLimiter(rate.Every(3*time.Second), 1) + ticker := time.NewTimer(e.UpdateInterval) + defer ticker.Stop() + + // we should stop updater and clean up our open orders, if + // 1. the given context is canceled. + // 2. the base quantity equals to or greater than the target quantity + defer func() { + e.cancelActiveOrders() + e.cancelUserDataStream() + e.emitDone() + }() + + for { + select { + case <-ctx.Done(): + return + + case <-e.orderBook.C: + if !updateLimiter.Allow() { + break + } + + if e.cancelContextIfTargetQuantityFilled() { + return + } + + log.Infof("%s order book changed, checking order...", e.Symbol) + if err := e.updateOrder(ctx); err != nil { + log.WithError(err).Errorf("order update failed") + } + + case <-ticker.C: + if !updateLimiter.Allow() { + break + } + + if e.cancelContextIfTargetQuantityFilled() { + return + } + + if err := e.updateOrder(ctx); err != nil { + log.WithError(err).Errorf("order update failed") + } + + } + } +} + +func (e *TwapExecution) cancelContextIfTargetQuantityFilled() bool { + base := e.position.GetBase() + + if base.Abs().Compare(e.TargetQuantity) >= 0 { + log.Infof("filled target quantity, canceling the order execution context") + e.cancelExecution() + return true + } + return false +} + +func (e *TwapExecution) handleTradeUpdate(trade types.Trade) { + // ignore trades that are not in the symbol we interested + if trade.Symbol != e.Symbol { + return + } + + if !e.orderStore.Exists(trade.OrderID) { + return + } + + log.Info(trade.String()) + + e.position.AddTrade(trade) + log.Infof("position updated: %+v", e.position) +} + +func (e *TwapExecution) handleFilledOrder(order types.Order) { + log.Info(order.String()) + + // filled event triggers the order removal from the active order store + // we need to ensure we received every order update event before the execution is done. + e.cancelContextIfTargetQuantityFilled() +} + +func (e *TwapExecution) Run(parentCtx context.Context) error { + e.mu.Lock() + e.stoppedC = make(chan struct{}) + e.executionCtx, e.cancelExecution = context.WithCancel(parentCtx) + e.userDataStreamCtx, e.cancelUserDataStream = context.WithCancel(context.Background()) + e.mu.Unlock() + + if e.UpdateInterval == 0 { + e.UpdateInterval = 10 * time.Second + } + + var ok bool + e.market, ok = e.Session.Market(e.Symbol) + if !ok { + return fmt.Errorf("market %s not found", e.Symbol) + } + + e.marketDataStream = e.Session.Exchange.NewStream() + e.marketDataStream.SetPublicOnly() + e.marketDataStream.Subscribe(types.BookChannel, e.Symbol, types.SubscribeOptions{}) + + e.orderBook = types.NewStreamBook(e.Symbol) + e.orderBook.BindStream(e.marketDataStream) + go e.connectMarketData(e.executionCtx) + + e.userDataStream = e.Session.Exchange.NewStream() + e.userDataStream.OnTradeUpdate(e.handleTradeUpdate) + e.position = &types.Position{ + Symbol: e.Symbol, + BaseCurrency: e.market.BaseCurrency, + QuoteCurrency: e.market.QuoteCurrency, + } + + e.orderStore = core.NewOrderStore(e.Symbol) + e.orderStore.BindStream(e.userDataStream) + e.activeMakerOrders = NewActiveOrderBook(e.Symbol) + e.activeMakerOrders.OnFilled(e.handleFilledOrder) + e.activeMakerOrders.BindStream(e.userDataStream) + + go e.connectUserData(e.userDataStreamCtx) + go e.orderUpdater(e.executionCtx) + return nil +} + +func (e *TwapExecution) emitDone() { + e.mu.Lock() + if e.stoppedC == nil { + e.stoppedC = make(chan struct{}) + } + close(e.stoppedC) + e.mu.Unlock() +} + +func (e *TwapExecution) Done() (c <-chan struct{}) { + e.mu.Lock() + // if the channel is not allocated, it means it's not started yet, we need to return a closed channel + if e.stoppedC == nil { + e.stoppedC = make(chan struct{}) + close(e.stoppedC) + c = e.stoppedC + } else { + c = e.stoppedC + } + + e.mu.Unlock() + return c +} + +// Shutdown stops the execution +// If we call this method, it means the execution is still running, +// We need to: +// 1. stop the order updater (by using the execution context) +// 2. the order updater cancels all open orders and close the user data stream +func (e *TwapExecution) Shutdown(shutdownCtx context.Context) { + e.mu.Lock() + if e.cancelExecution != nil { + e.cancelExecution() + } + e.mu.Unlock() + + for { + select { + + case <-shutdownCtx.Done(): + return + + case <-e.Done(): + return + + } + } +} diff --git a/pkg/qbtrade/wrapper.go b/pkg/qbtrade/wrapper.go new file mode 100644 index 0000000..2dfbd4f --- /dev/null +++ b/pkg/qbtrade/wrapper.go @@ -0,0 +1,7 @@ +package qbtrade + +var IsWrapperBinary = false + +func SetWrapperBinary() { + IsWrapperBinary = true +} diff --git a/pkg/report/profit_report.go b/pkg/report/profit_report.go new file mode 100644 index 0000000..67daa12 --- /dev/null +++ b/pkg/report/profit_report.go @@ -0,0 +1,177 @@ +package report + +import ( + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/data/tsv" + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + indicatorv2 "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator/v2" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "strconv" +) + +// AccumulatedProfitReport For accumulated profit report output +type AccumulatedProfitReport struct { + // ProfitMAWindow Accumulated profit SMA window + ProfitMAWindow int `json:"profitMAWindow"` + + // ShortTermProfitWindow The window to sum up the short-term profit + ShortTermProfitWindow int `json:"shortTermProfitWindow"` + + // TsvReportPath The path to output report to + TsvReportPath string `json:"tsvReportPath"` + + symbol string + + types.IntervalWindow + + // ProfitMAWindow Accumulated profit SMA window + AccumulateTradeWindow int `json:"accumulateTradeWindow"` + + // Accumulated profit + accumulatedProfit fixedpoint.Value + accumulatedProfitPerInterval *types.Float64Series + + // Accumulated profit MA + profitMA *indicatorv2.SMAStream + profitMAPerInterval floats.Slice + + // Profit of each interval + ProfitPerInterval floats.Slice + + // Accumulated fee + accumulatedFee fixedpoint.Value + accumulatedFeePerInterval floats.Slice + + // Win ratio + winRatioPerInterval floats.Slice + + // Profit factor + profitFactorPerInterval floats.Slice + + // Trade number + accumulatedTrades int + accumulatedTradesPerInterval floats.Slice + + // Extra values + strategyParameters [][2]string +} + +func (r *AccumulatedProfitReport) Initialize(symbol string, interval types.Interval, window int) { + r.symbol = symbol + r.Interval = interval + r.Window = window + + if r.ProfitMAWindow <= 0 { + r.ProfitMAWindow = 60 + } + + if r.Window <= 0 { + r.Window = 7 + } + + if r.ShortTermProfitWindow <= 0 { + r.ShortTermProfitWindow = 7 + } + + r.accumulatedProfitPerInterval = types.NewFloat64Series() + r.profitMA = indicatorv2.SMA(r.accumulatedProfitPerInterval, r.ProfitMAWindow) +} + +func (r *AccumulatedProfitReport) AddStrategyParameter(title string, value string) { + r.strategyParameters = append(r.strategyParameters, [2]string{title, value}) +} + +func (r *AccumulatedProfitReport) AddTrade(trade types.Trade) { + r.accumulatedFee = r.accumulatedFee.Add(trade.Fee) + r.accumulatedTrades += 1 +} + +func (r *AccumulatedProfitReport) Rotate(ps *types.ProfitStats, ts *types.TradeStats) { + // Accumulated profit + r.accumulatedProfit = r.accumulatedProfit.Add(ps.AccumulatedNetProfit) + r.accumulatedProfitPerInterval.PushAndEmit(r.accumulatedProfit.Float64()) + + // Profit of each interval + r.ProfitPerInterval.Update(ps.AccumulatedNetProfit.Float64()) + + // Profit MA + r.profitMAPerInterval.Update(r.profitMA.Last(0)) + + // Accumulated Fee + r.accumulatedFeePerInterval.Update(r.accumulatedFee.Float64()) + + // Trades + r.accumulatedTradesPerInterval.Update(float64(r.accumulatedTrades)) + + // Win ratio + r.winRatioPerInterval.Update(ts.WinningRatio.Float64()) + + // Profit factor + r.profitFactorPerInterval.Update(ts.ProfitFactor.Float64()) +} + +// CsvHeader returns a header slice +func (r *AccumulatedProfitReport) CsvHeader() []string { + titles := []string{ + "#", + "Symbol", + "Total Net Profit", + fmt.Sprintf("Total Net Profit %sMA%d", r.Interval, r.ProfitMAWindow), + fmt.Sprintf("%s%d Net Profit", r.Interval, r.ShortTermProfitWindow), + "accumulatedFee", + "winRatio", + "profitFactor", + fmt.Sprintf("%s%d Trades", r.Interval, r.AccumulateTradeWindow), + } + + for i := 0; i < len(r.strategyParameters); i++ { + titles = append(titles, r.strategyParameters[i][0]) + } + + return titles +} + +// CsvRecords returns a data slice +func (r *AccumulatedProfitReport) CsvRecords() [][]string { + var data [][]string + + for i := 0; i <= r.Window-1; i++ { + values := []string{ + strconv.Itoa(i + 1), + r.symbol, + strconv.FormatFloat(r.accumulatedProfitPerInterval.Last(i), 'f', 4, 64), + strconv.FormatFloat(r.profitMAPerInterval.Last(i), 'f', 4, 64), + strconv.FormatFloat(r.accumulatedProfitPerInterval.Last(i)-r.accumulatedProfitPerInterval.Last(i+r.ShortTermProfitWindow), 'f', 4, 64), + strconv.FormatFloat(r.accumulatedFeePerInterval.Last(i), 'f', 4, 64), + strconv.FormatFloat(r.winRatioPerInterval.Last(i), 'f', 4, 64), + strconv.FormatFloat(r.profitFactorPerInterval.Last(i), 'f', 4, 64), + strconv.FormatFloat(r.accumulatedTradesPerInterval.Last(i)-r.accumulatedTradesPerInterval.Last(i+r.AccumulateTradeWindow), 'f', 4, 64), + } + for j := 0; j < len(r.strategyParameters); j++ { + values = append(values, r.strategyParameters[j][1]) + } + + data = append(data, values) + } + + return data +} + +// Output Accumulated profit report to a TSV file +func (r *AccumulatedProfitReport) Output() { + if r.TsvReportPath != "" { + // Open specified file for appending + tsvwiter, err := tsv.AppendWriterFile(r.TsvReportPath) + if err != nil { + panic(err) + } + defer tsvwiter.Close() + + // Column Title + _ = tsvwiter.Write(r.CsvHeader()) + + // Output data rows + _ = tsvwiter.WriteAll(r.CsvRecords()) + } +} diff --git a/pkg/report/profit_stats_tracker.go b/pkg/report/profit_stats_tracker.go new file mode 100644 index 0000000..b91260e --- /dev/null +++ b/pkg/report/profit_stats_tracker.go @@ -0,0 +1,96 @@ +package report + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/core" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type ProfitStatsTracker struct { + types.IntervalWindow + + // Accumulated profit report + AccumulatedProfitReport *AccumulatedProfitReport `json:"accumulatedProfitReport"` + + Market types.Market + + ProfitStatsSlice []*types.ProfitStats + CurrentProfitStats **types.ProfitStats + + tradeStats *types.TradeStats +} + +func (p *ProfitStatsTracker) Subscribe(session *qbtrade.ExchangeSession, symbol string) { + session.Subscribe(types.KLineChannel, symbol, types.SubscribeOptions{Interval: p.Interval}) +} + +// InitLegacy is for backward capability. ps is the ProfitStats of the strategy, Market is the strategy Market +func (p *ProfitStatsTracker) InitLegacy(market types.Market, ps **types.ProfitStats, ts *types.TradeStats) { + p.Market = market + + if *ps == nil { + *ps = types.NewProfitStats(p.Market) + } + + p.tradeStats = ts + + p.CurrentProfitStats = ps + p.ProfitStatsSlice = append(p.ProfitStatsSlice, *ps) + + if p.AccumulatedProfitReport != nil { + p.AccumulatedProfitReport.Initialize(p.Market.Symbol, p.Interval, p.Window) + } +} + +// Init initialize the tracker with the given Market +func (p *ProfitStatsTracker) Init(market types.Market, ts *types.TradeStats) { + ps := types.NewProfitStats(p.Market) + p.InitLegacy(market, &ps, ts) +} + +func (p *ProfitStatsTracker) Bind(session *qbtrade.ExchangeSession, tradeCollector *core.TradeCollector) { + tradeCollector.OnProfit(func(trade types.Trade, profit *types.Profit) { + if profit == nil { + return + } + + p.AddProfit(*profit) + }) + + tradeCollector.OnTrade(func(trade types.Trade, profit fixedpoint.Value, netProfit fixedpoint.Value) { + p.AddTrade(trade) + }) + + // Rotate profitStats slice + session.MarketDataStream.OnKLineClosed(types.KLineWith(p.Market.Symbol, p.Interval, func(kline types.KLine) { + p.Rotate() + })) +} + +// Rotate the tracker to make a new ProfitStats to record the profits +func (p *ProfitStatsTracker) Rotate() { + // Update report + if p.AccumulatedProfitReport != nil { + p.AccumulatedProfitReport.Rotate(*p.CurrentProfitStats, p.tradeStats) + } + + *p.CurrentProfitStats = types.NewProfitStats(p.Market) + p.ProfitStatsSlice = append(p.ProfitStatsSlice, *p.CurrentProfitStats) + // Truncate + if len(p.ProfitStatsSlice) > p.Window { + p.ProfitStatsSlice = p.ProfitStatsSlice[len(p.ProfitStatsSlice)-p.Window:] + } +} + +func (p *ProfitStatsTracker) AddProfit(profit types.Profit) { + (*p.CurrentProfitStats).AddProfit(profit) +} + +func (p *ProfitStatsTracker) AddTrade(trade types.Trade) { + (*p.CurrentProfitStats).AddTrade(trade) + + if p.AccumulatedProfitReport != nil { + p.AccumulatedProfitReport.AddTrade(trade) + } +} diff --git a/pkg/risk/circuitbreaker/basic.go b/pkg/risk/circuitbreaker/basic.go new file mode 100644 index 0000000..d0adb2f --- /dev/null +++ b/pkg/risk/circuitbreaker/basic.go @@ -0,0 +1,255 @@ +package circuitbreaker + +import ( + "fmt" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + + log "github.com/sirupsen/logrus" +) + +var consecutiveTotalLossMetrics = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_circuit_breaker_consecutive_total_loss", + Help: "", + }, []string{"strategy", "strategyInstance"}) + +var consecutiveLossTimesCounterMetrics = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_circuit_breaker_consecutive_loss_times", + Help: "", + }, []string{"strategy", "strategyInstance"}) + +var haltCounterMetrics = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_circuit_breaker_halt_counter", + Help: "", + }, []string{"strategy", "strategyInstance"}) + +var haltMetrics = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_circuit_breaker_halt", + Help: "", + }, []string{"strategy", "strategyInstance"}) + +var totalProfitMetrics = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_circuit_breaker_total_profit", + Help: "", + }, []string{"strategy", "strategyInstance"}) + +var profitWinCounterMetrics = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "qbtrade_circuit_breaker_profit_win_counter", + Help: "profit winning counter", + }, []string{"strategy", "strategyInstance"}) + +var profitLossCounterMetrics = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "qbtrade_circuit_breaker_profit_loss_counter", + Help: "profit los counter", + }, []string{"strategy", "strategyInstance"}) + +var winningRatioMetrics = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_circuit_breaker_winning_ratio", + Help: "winning ratio", + }, []string{"strategy", "strategyInstance"}) + +func init() { + prometheus.MustRegister( + consecutiveTotalLossMetrics, + consecutiveLossTimesCounterMetrics, + haltCounterMetrics, + totalProfitMetrics, + haltMetrics, + profitWinCounterMetrics, + profitLossCounterMetrics, + winningRatioMetrics, + ) +} + +type BasicCircuitBreaker struct { + MaximumConsecutiveTotalLoss fixedpoint.Value `json:"maximumConsecutiveTotalLoss"` + + MaximumConsecutiveLossTimes int `json:"maximumConsecutiveLossTimes"` + + IgnoreDustLoss bool `json:"ignoreConsecutiveDustLoss"` + ConsecutiveDustLossThreshold fixedpoint.Value `json:"consecutiveDustLossThreshold"` + + MaximumLossPerRound fixedpoint.Value `json:"maximumLossPerRound"` + + MaximumTotalLoss fixedpoint.Value `json:"maximumTotalLoss"` + + MaximumHaltTimes int `json:"maximumHaltTimes"` + + MaximumHaltTimesExceededPanic bool `json:"maximumHaltTimesExceededPanic"` + + HaltDuration types.Duration `json:"haltDuration"` + + strategyID, strategyInstance string + + haltCounter int + haltReason string + halted bool + + haltedAt, haltTo time.Time + + // totalProfit is the total PnL, could be negative or positive + totalProfit fixedpoint.Value + consecutiveLossTimes int + + winTimes, lossTimes int + winRatio float64 + + // consecutiveLoss is a negative number, presents the consecutive loss + consecutiveLoss fixedpoint.Value + + mu sync.Mutex + + metricsLabels prometheus.Labels +} + +func NewBasicCircuitBreaker(strategyID, strategyInstance string) *BasicCircuitBreaker { + return &BasicCircuitBreaker{ + MaximumConsecutiveLossTimes: 8, + MaximumHaltTimes: 3, + MaximumHaltTimesExceededPanic: false, + HaltDuration: types.Duration(30 * time.Minute), + strategyID: strategyID, + strategyInstance: strategyInstance, + metricsLabels: prometheus.Labels{"strategy": strategyID, "strategyInstance": strategyInstance}, + } +} + +func (b *BasicCircuitBreaker) getMetricsLabels() prometheus.Labels { + if b.metricsLabels != nil { + return b.metricsLabels + } + + return prometheus.Labels{"strategy": b.strategyID, "strategyInstance": b.strategyInstance} +} + +func (b *BasicCircuitBreaker) updateMetrics() { + labels := b.getMetricsLabels() + consecutiveTotalLossMetrics.With(labels).Set(b.consecutiveLoss.Float64()) + consecutiveLossTimesCounterMetrics.With(labels).Set(float64(b.consecutiveLossTimes)) + totalProfitMetrics.With(labels).Set(b.totalProfit.Float64()) + + if b.halted { + haltMetrics.With(labels).Set(1.0) + } else { + haltMetrics.With(labels).Set(0.0) + } + + haltCounterMetrics.With(labels).Set(float64(b.haltCounter)) + winningRatioMetrics.With(labels).Set(b.winRatio) +} + +func (b *BasicCircuitBreaker) RecordProfit(profit fixedpoint.Value, now time.Time) { + b.mu.Lock() + defer b.mu.Unlock() + + b.totalProfit = b.totalProfit.Add(profit) + + if profit.Sign() < 0 { + if b.IgnoreDustLoss && profit.Abs().Compare(b.ConsecutiveDustLossThreshold) <= 0 { + // ignore dust loss + log.Infof("ignore dust loss (threshold %f): %f", b.ConsecutiveDustLossThreshold.Float64(), profit.Float64()) + } else { + b.lossTimes++ + b.consecutiveLossTimes++ + b.consecutiveLoss = b.consecutiveLoss.Add(profit) + profitLossCounterMetrics.With(b.getMetricsLabels()).Inc() + } + } else { + b.winTimes++ + b.consecutiveLossTimes = 0 + b.consecutiveLoss = fixedpoint.Zero + profitWinCounterMetrics.With(b.getMetricsLabels()).Inc() + } + + if b.lossTimes == 0 { + b.winRatio = 999.0 + } else { + b.winRatio = float64(b.winTimes) / float64(b.lossTimes) + } + + b.updateMetrics() + + if b.MaximumConsecutiveLossTimes > 0 && b.consecutiveLossTimes >= b.MaximumConsecutiveLossTimes { + b.halt(now, "exceeded MaximumConsecutiveLossTimes") + return + } + + if b.MaximumConsecutiveTotalLoss.Sign() > 0 && b.consecutiveLoss.Neg().Compare(b.MaximumConsecutiveTotalLoss) >= 0 { + b.halt(now, "exceeded MaximumConsecutiveTotalLoss") + return + } + + if b.MaximumLossPerRound.Sign() > 0 && profit.Sign() < 0 && profit.Neg().Compare(b.MaximumLossPerRound) >= 0 { + b.halt(now, "exceeded MaximumLossPerRound") + return + } + + // - (-120 [profit]) > 100 [maximum total loss] + if b.MaximumTotalLoss.Sign() > 0 && b.totalProfit.Neg().Compare(b.MaximumTotalLoss) >= 0 { + b.halt(now, "exceeded MaximumTotalLoss") + return + } +} + +func (b *BasicCircuitBreaker) Reset() { + b.mu.Lock() + b.reset() + b.mu.Unlock() +} + +func (b *BasicCircuitBreaker) reset() { + b.halted = false + b.haltedAt = time.Time{} + b.haltTo = time.Time{} + + b.totalProfit = fixedpoint.Zero + b.consecutiveLossTimes = 0 + b.consecutiveLoss = fixedpoint.Zero + b.updateMetrics() +} + +func (b *BasicCircuitBreaker) IsHalted(now time.Time) bool { + b.mu.Lock() + defer b.mu.Unlock() + + if !b.halted { + return false + } + + // check if it's an expired halt + if now.After(b.haltTo) { + b.reset() + return false + } + + return true +} + +func (b *BasicCircuitBreaker) halt(now time.Time, reason string) { + b.halted = true + b.haltCounter++ + b.haltReason = reason + b.haltedAt = now + b.haltTo = now.Add(b.HaltDuration.Duration()) + + labels := b.getMetricsLabels() + haltCounterMetrics.With(labels).Set(float64(b.haltCounter)) + haltMetrics.With(labels).Set(1.0) + + if b.MaximumHaltTimesExceededPanic && b.haltCounter > b.MaximumHaltTimes { + panic(fmt.Errorf("total %d halt times > maximumHaltTimesExceededPanic %d", b.haltCounter, b.MaximumHaltTimes)) + } +} diff --git a/pkg/risk/dynamicrisk/dynamic_exposure.go b/pkg/risk/dynamicrisk/dynamic_exposure.go new file mode 100644 index 0000000..ce50c5a --- /dev/null +++ b/pkg/risk/dynamicrisk/dynamic_exposure.go @@ -0,0 +1,86 @@ +package dynamicrisk + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "math" +) + +type DynamicExposure struct { + // BollBandExposure calculates the max exposure with the Bollinger Band + BollBandExposure *DynamicExposureBollBand `json:"bollBandExposure"` +} + +// Initialize dynamic exposure +func (d *DynamicExposure) Initialize(symbol string, session *qbtrade.ExchangeSession) { + switch { + case d.BollBandExposure != nil: + d.BollBandExposure.initialize(symbol, session) + } +} + +func (d *DynamicExposure) IsEnabled() bool { + return d.BollBandExposure != nil +} + +// GetMaxExposure returns the max exposure +func (d *DynamicExposure) GetMaxExposure(price float64, trend types.Direction) (maxExposure fixedpoint.Value, err error) { + switch { + case d.BollBandExposure != nil: + return d.BollBandExposure.getMaxExposure(price, trend) + default: + return fixedpoint.Zero, errors.New("dynamic exposure is not enabled") + } +} + +// DynamicExposureBollBand calculates the max exposure with the Bollinger Band +type DynamicExposureBollBand struct { + // DynamicExposureBollBandScale is used to define the exposure range with the given percentage. + DynamicExposureBollBandScale *qbtrade.PercentageScale `json:"dynamicExposurePositionScale"` + + types.IntervalWindowBandWidth + + dynamicExposureBollBand *indicator.BOLL +} + +// initialize dynamic exposure with Bollinger Band +func (d *DynamicExposureBollBand) initialize(symbol string, session *qbtrade.ExchangeSession) { + d.dynamicExposureBollBand = session.StandardIndicatorSet(symbol).BOLL(d.IntervalWindow, d.BandWidth) + + // Subscribe kline + session.Subscribe(types.KLineChannel, symbol, types.SubscribeOptions{ + Interval: d.dynamicExposureBollBand.Interval, + }) +} + +// getMaxExposure returns the max exposure +func (d *DynamicExposureBollBand) getMaxExposure(price float64, trend types.Direction) (fixedpoint.Value, error) { + downBand := d.dynamicExposureBollBand.DownBand.Last(0) + upBand := d.dynamicExposureBollBand.UpBand.Last(0) + sma := d.dynamicExposureBollBand.SMA.Last(0) + log.Infof("dynamicExposureBollBand bollinger band: up %f sma %f down %f", upBand, sma, downBand) + + bandPercentage := 0.0 + if price < sma { + // should be negative percentage + bandPercentage = (price - sma) / math.Abs(sma-downBand) + } else if price > sma { + // should be positive percentage + bandPercentage = (price - sma) / math.Abs(upBand-sma) + } + + // Reverse if downtrend + if trend == types.DirectionDown { + bandPercentage = 0 - bandPercentage + } + + v, err := d.DynamicExposureBollBandScale.Scale(bandPercentage) + if err != nil { + return fixedpoint.Zero, err + } + return fixedpoint.NewFromFloat(v), nil +} diff --git a/pkg/risk/dynamicrisk/dynamic_quantity.go b/pkg/risk/dynamicrisk/dynamic_quantity.go new file mode 100644 index 0000000..a3cee12 --- /dev/null +++ b/pkg/risk/dynamicrisk/dynamic_quantity.go @@ -0,0 +1,100 @@ +package dynamicrisk + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/pkg/errors" +) + +// DynamicQuantitySet uses multiple dynamic quantity rules to calculate the total quantity +type DynamicQuantitySet []DynamicQuantity + +// Initialize dynamic quantity set +func (d *DynamicQuantitySet) Initialize(symbol string, session *qbtrade.ExchangeSession) { + for i := range *d { + (*d)[i].Initialize(symbol, session) + } +} + +// GetQuantity returns the quantity +func (d *DynamicQuantitySet) GetQuantity(reverse bool) (fixedpoint.Value, error) { + quantity := fixedpoint.Zero + for i := range *d { + v, err := (*d)[i].getQuantity(reverse) + if err != nil { + return fixedpoint.Zero, err + } + quantity = quantity.Add(v) + } + + return quantity, nil +} + +type DynamicQuantity struct { + // LinRegQty calculates quantity based on LinReg slope + LinRegDynamicQuantity *DynamicQuantityLinReg `json:"linRegDynamicQuantity"` +} + +// Initialize dynamic quantity +func (d *DynamicQuantity) Initialize(symbol string, session *qbtrade.ExchangeSession) { + switch { + case d.LinRegDynamicQuantity != nil: + d.LinRegDynamicQuantity.initialize(symbol, session) + } +} + +func (d *DynamicQuantity) IsEnabled() bool { + return d.LinRegDynamicQuantity != nil +} + +// getQuantity returns quantity +func (d *DynamicQuantity) getQuantity(reverse bool) (fixedpoint.Value, error) { + switch { + case d.LinRegDynamicQuantity != nil: + return d.LinRegDynamicQuantity.getQuantity(reverse) + default: + return fixedpoint.Zero, errors.New("dynamic quantity is not enabled") + } +} + +// DynamicQuantityLinReg uses LinReg slope to calculate quantity +type DynamicQuantityLinReg struct { + // DynamicQuantityLinRegScale is used to define the quantity range with the given parameters. + DynamicQuantityLinRegScale *qbtrade.PercentageScale `json:"dynamicQuantityLinRegScale"` + + // QuantityLinReg to define the interval and window of the LinReg + QuantityLinReg *indicator.LinReg `json:"quantityLinReg"` +} + +// initialize LinReg dynamic quantity +func (d *DynamicQuantityLinReg) initialize(symbol string, session *qbtrade.ExchangeSession) { + // Subscribe for LinReg + session.Subscribe(types.KLineChannel, symbol, types.SubscribeOptions{ + Interval: d.QuantityLinReg.Interval, + }) + + // Initialize LinReg + kLineStore, _ := session.MarketDataStore(symbol) + d.QuantityLinReg.BindK(session.MarketDataStream, symbol, d.QuantityLinReg.Interval) + if klines, ok := kLineStore.KLinesOfInterval(d.QuantityLinReg.Interval); ok { + d.QuantityLinReg.LoadK((*klines)[0:]) + } +} + +// getQuantity returns quantity +// If reverse is true, the LinReg slope ratio is reversed, ie -0.01 becomes 0.01. This is for short orders. +func (d *DynamicQuantityLinReg) getQuantity(reverse bool) (fixedpoint.Value, error) { + var linregRatio float64 + if reverse { + linregRatio = -d.QuantityLinReg.LastRatio() + } else { + linregRatio = d.QuantityLinReg.LastRatio() + } + v, err := d.DynamicQuantityLinRegScale.Scale(linregRatio) + if err != nil { + return fixedpoint.Zero, err + } + return fixedpoint.NewFromFloat(v), nil +} diff --git a/pkg/risk/dynamicrisk/dynamic_spread.go b/pkg/risk/dynamicrisk/dynamic_spread.go new file mode 100644 index 0000000..bd432c9 --- /dev/null +++ b/pkg/risk/dynamicrisk/dynamic_spread.go @@ -0,0 +1,247 @@ +package dynamicrisk + +import ( + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "math" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type DynamicSpread struct { + // AmpSpread calculates spreads based on kline amplitude + AmpSpread *DynamicAmpSpread `json:"amplitude"` + + // WeightedBollWidthRatioSpread calculates spreads based on two Bollinger Bands + WeightedBollWidthRatioSpread *DynamicSpreadBollWidthRatio `json:"weightedBollWidth"` +} + +// Initialize dynamic spread +func (ds *DynamicSpread) Initialize(symbol string, session *qbtrade.ExchangeSession) { + switch { + case ds.AmpSpread != nil: + ds.AmpSpread.initialize(symbol, session) + case ds.WeightedBollWidthRatioSpread != nil: + ds.WeightedBollWidthRatioSpread.initialize(symbol, session) + } +} + +func (ds *DynamicSpread) IsEnabled() bool { + return ds.AmpSpread != nil || ds.WeightedBollWidthRatioSpread != nil +} + +// GetAskSpread returns current ask spread +func (ds *DynamicSpread) GetAskSpread() (askSpread float64, err error) { + switch { + case ds.AmpSpread != nil: + return ds.AmpSpread.getAskSpread() + case ds.WeightedBollWidthRatioSpread != nil: + return ds.WeightedBollWidthRatioSpread.getAskSpread() + default: + return 0, errors.New("dynamic spread is not enabled") + } +} + +// GetBidSpread returns current dynamic bid spread +func (ds *DynamicSpread) GetBidSpread() (bidSpread float64, err error) { + switch { + case ds.AmpSpread != nil: + return ds.AmpSpread.getBidSpread() + case ds.WeightedBollWidthRatioSpread != nil: + return ds.WeightedBollWidthRatioSpread.getBidSpread() + default: + return 0, errors.New("dynamic spread is not enabled") + } +} + +// DynamicAmpSpread uses kline amplitude to calculate spreads +type DynamicAmpSpread struct { + types.IntervalWindow + + // AskSpreadScale is used to define the ask spread range with the given percentage. + AskSpreadScale *qbtrade.PercentageScale `json:"askSpreadScale"` + + // BidSpreadScale is used to define the bid spread range with the given percentage. + BidSpreadScale *qbtrade.PercentageScale `json:"bidSpreadScale"` + + dynamicAskSpread *indicator.SMA + dynamicBidSpread *indicator.SMA +} + +// initialize amplitude dynamic spread and preload SMAs +func (ds *DynamicAmpSpread) initialize(symbol string, session *qbtrade.ExchangeSession) { + ds.dynamicBidSpread = &indicator.SMA{IntervalWindow: types.IntervalWindow{Interval: ds.Interval, Window: ds.Window}} + ds.dynamicAskSpread = &indicator.SMA{IntervalWindow: types.IntervalWindow{Interval: ds.Interval, Window: ds.Window}} + + // Subscribe kline + session.Subscribe(types.KLineChannel, symbol, types.SubscribeOptions{ + Interval: ds.Interval, + }) + + // Update on kline closed + session.MarketDataStream.OnKLineClosed(types.KLineWith(symbol, ds.Interval, func(kline types.KLine) { + ds.update(kline) + })) + + // Preload + kLineStore, _ := session.MarketDataStore(symbol) + if klines, ok := kLineStore.KLinesOfInterval(ds.Interval); ok { + for i := 0; i < len(*klines); i++ { + ds.update((*klines)[i]) + } + } +} + +// update amplitude dynamic spread with kline +func (ds *DynamicAmpSpread) update(kline types.KLine) { + // ampl is the amplitude of kline + ampl := (kline.GetHigh().Float64() - kline.GetLow().Float64()) / kline.GetOpen().Float64() + + switch kline.Direction() { + case types.DirectionUp: + ds.dynamicAskSpread.Update(ampl) + ds.dynamicBidSpread.Update(0) + case types.DirectionDown: + ds.dynamicBidSpread.Update(ampl) + ds.dynamicAskSpread.Update(0) + default: + ds.dynamicAskSpread.Update(0) + ds.dynamicBidSpread.Update(0) + } +} + +func (ds *DynamicAmpSpread) getAskSpread() (askSpread float64, err error) { + if ds.AskSpreadScale != nil && ds.dynamicAskSpread.Length() >= ds.Window { + askSpread, err = ds.AskSpreadScale.Scale(ds.dynamicAskSpread.Last(0)) + if err != nil { + log.WithError(err).Errorf("can not calculate dynamicAskSpread") + return 0, err + } + + return askSpread, nil + } + + return 0, errors.New("incomplete dynamic spread settings or not enough data yet") +} + +func (ds *DynamicAmpSpread) getBidSpread() (bidSpread float64, err error) { + if ds.BidSpreadScale != nil && ds.dynamicBidSpread.Length() >= ds.Window { + bidSpread, err = ds.BidSpreadScale.Scale(ds.dynamicBidSpread.Last(0)) + if err != nil { + log.WithError(err).Errorf("can not calculate dynamicBidSpread") + return 0, err + } + + return bidSpread, nil + } + + return 0, errors.New("incomplete dynamic spread settings or not enough data yet") +} + +type DynamicSpreadBollWidthRatio struct { + // AskSpreadScale is used to define the ask spread range with the given percentage. + AskSpreadScale *qbtrade.PercentageScale `json:"askSpreadScale"` + + // BidSpreadScale is used to define the bid spread range with the given percentage. + BidSpreadScale *qbtrade.PercentageScale `json:"bidSpreadScale"` + + // Sensitivity factor of the weighting function: 1 / (1 + exp(-(x - mid) * sensitivity / width)) + // A positive number. The greater factor, the sharper weighting function. Default set to 1.0 . + Sensitivity float64 `json:"sensitivity"` + + DefaultBollinger types.IntervalWindowBandWidth `json:"defaultBollinger"` + NeutralBollinger types.IntervalWindowBandWidth `json:"neutralBollinger"` + + neutralBoll *indicator.BOLL + defaultBoll *indicator.BOLL +} + +func (ds *DynamicSpreadBollWidthRatio) initialize(symbol string, session *qbtrade.ExchangeSession) { + ds.neutralBoll = session.StandardIndicatorSet(symbol).BOLL(ds.NeutralBollinger.IntervalWindow, ds.NeutralBollinger.BandWidth) + ds.defaultBoll = session.StandardIndicatorSet(symbol).BOLL(ds.DefaultBollinger.IntervalWindow, ds.DefaultBollinger.BandWidth) + + // Subscribe kline + session.Subscribe(types.KLineChannel, symbol, types.SubscribeOptions{ + Interval: ds.NeutralBollinger.Interval, + }) + session.Subscribe(types.KLineChannel, symbol, types.SubscribeOptions{ + Interval: ds.DefaultBollinger.Interval, + }) + + if ds.Sensitivity <= 0. { + ds.Sensitivity = 1. + } +} + +func (ds *DynamicSpreadBollWidthRatio) getAskSpread() (askSpread float64, err error) { + askSpread, err = ds.AskSpreadScale.Scale(ds.getWeightedBBWidthRatio(true)) + if err != nil { + log.WithError(err).Errorf("can not calculate dynamicAskSpread") + return 0, err + } + + return askSpread, nil +} + +func (ds *DynamicSpreadBollWidthRatio) getBidSpread() (bidSpread float64, err error) { + bidSpread, err = ds.BidSpreadScale.Scale(ds.getWeightedBBWidthRatio(false)) + if err != nil { + log.WithError(err).Errorf("can not calculate dynamicAskSpread") + return 0, err + } + + return bidSpread, nil +} + +func (ds *DynamicSpreadBollWidthRatio) getWeightedBBWidthRatio(positiveSigmoid bool) float64 { + // Weight the width of Boll bands with sigmoid function and calculate the ratio after integral. + // + // Given the default band: moving average default_BB_mid, band from default_BB_lower to default_BB_upper. + // And the neutral band: from neutral_BB_lower to neutral_BB_upper. + // And a sensitivity factor alpha, which is a positive constant. + // + // width of default BB w = default_BB_upper - default_BB_lower + // + // 1 x - default_BB_mid + // sigmoid weighting function f(y) = ------------- where y = -------------------- + // 1 + exp(-y) w / alpha + // Set the sigmoid weighting function: + // - To ask spread, the weighting density function d_weight(x) is sigmoid((x - default_BB_mid) / (w / alpha)) + // - To bid spread, the weighting density function d_weight(x) is sigmoid((default_BB_mid - x) / (w / alpha)) + // - The higher sensitivity factor alpha, the sharper weighting function. + // + // Then calculate the weighted bandwidth ratio by taking integral of d_weight(x) from neutral_BB_lower to neutral_BB_upper: + // infinite integral of ask spread sigmoid weighting density function F(x) = (w / alpha) * ln(exp(x / (w / alpha)) + exp(default_BB_mid / (w / alpha))) + // infinite integral of bid spread sigmoid weighting density function F(x) = x - (w / alpha) * ln(exp(x / (w / alpha)) + exp(default_BB_mid / (w / alpha))) + // Note that we've rescaled the sigmoid function to fit default BB, + // the weighted default BB width is always calculated by integral(f of x from default_BB_lower to default_BB_upper) + // F(neutral_BB_upper) - F(neutral_BB_lower) + // weighted ratio = ------------------------------------------- + // F(default_BB_upper) - F(default_BB_lower) + // - The wider neutral band get greater ratio + // - To ask spread, the higher neutral band get greater ratio + // - To bid spread, the lower neutral band get greater ratio + + defaultMid := ds.defaultBoll.SMA.Last(0) + defaultUpper := ds.defaultBoll.UpBand.Last(0) + defaultLower := ds.defaultBoll.DownBand.Last(0) + defaultWidth := defaultUpper - defaultLower + neutralUpper := ds.neutralBoll.UpBand.Last(0) + neutralLower := ds.neutralBoll.DownBand.Last(0) + factor := defaultWidth / ds.Sensitivity + var weightedUpper, weightedLower, weightedDivUpper, weightedDivLower float64 + if positiveSigmoid { + weightedUpper = factor * math.Log(math.Exp(neutralUpper/factor)+math.Exp(defaultMid/factor)) + weightedLower = factor * math.Log(math.Exp(neutralLower/factor)+math.Exp(defaultMid/factor)) + weightedDivUpper = factor * math.Log(math.Exp(defaultUpper/factor)+math.Exp(defaultMid/factor)) + weightedDivLower = factor * math.Log(math.Exp(defaultLower/factor)+math.Exp(defaultMid/factor)) + } else { + weightedUpper = neutralUpper - factor*math.Log(math.Exp(neutralUpper/factor)+math.Exp(defaultMid/factor)) + weightedLower = neutralLower - factor*math.Log(math.Exp(neutralLower/factor)+math.Exp(defaultMid/factor)) + weightedDivUpper = defaultUpper - factor*math.Log(math.Exp(defaultUpper/factor)+math.Exp(defaultMid/factor)) + weightedDivLower = defaultLower - factor*math.Log(math.Exp(defaultLower/factor)+math.Exp(defaultMid/factor)) + } + return (weightedUpper - weightedLower) / (weightedDivUpper - weightedDivLower) +} diff --git a/pkg/risk/leverage.go b/pkg/risk/leverage.go new file mode 100644 index 0000000..8673d00 --- /dev/null +++ b/pkg/risk/leverage.go @@ -0,0 +1,51 @@ +package risk + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// How to Calculate Cost Required to Open a Position in Perpetual Futures Contracts +// +// See +// +// For Long Position: +// = Number of Contract * Absolute Value {min[0, direction of order x (mark price - order price)]} +// +// For short position: +// = Number of Contract * Absolute Value {min[0, direction of order x (mark price - order price)]} +func CalculateOpenLoss(numContract, markPrice, orderPrice fixedpoint.Value, side types.SideType) fixedpoint.Value { + var d = fixedpoint.One + if side == types.SideTypeSell { + d = fixedpoint.NegOne + } + + var openLoss = numContract.Mul(fixedpoint.Min(fixedpoint.Zero, d.Mul(markPrice.Sub(orderPrice))).Abs()) + return openLoss +} + +// CalculateMarginCost calculate the margin cost of the given notional position by price * quantity +func CalculateMarginCost(price, quantity, leverage fixedpoint.Value) fixedpoint.Value { + var notionalValue = price.Mul(quantity) + var cost = notionalValue.Div(leverage) + return cost +} + +func CalculatePositionCost(markPrice, orderPrice, quantity, leverage fixedpoint.Value, side types.SideType) fixedpoint.Value { + var marginCost = CalculateMarginCost(orderPrice, quantity, leverage) + var openLoss = CalculateOpenLoss(quantity, markPrice, orderPrice, side) + return marginCost.Add(openLoss) +} + +// CalculateMaxPosition calculates the maximum notional value of the position and return the max quantity you can use. +func CalculateMaxPosition(price, availableMargin, leverage fixedpoint.Value) fixedpoint.Value { + var maxNotionalValue = availableMargin.Mul(leverage) + var maxQuantity = maxNotionalValue.Div(price) + return maxQuantity +} + +// CalculateMinRequiredLeverage calculates the leverage of the given position (price and quantity) +func CalculateMinRequiredLeverage(price, quantity, availableMargin fixedpoint.Value) fixedpoint.Value { + var notional = price.Mul(quantity) + return notional.Div(availableMargin) +} diff --git a/pkg/risk/leverage_test.go b/pkg/risk/leverage_test.go new file mode 100644 index 0000000..7f42c89 --- /dev/null +++ b/pkg/risk/leverage_test.go @@ -0,0 +1,145 @@ +package risk + +import ( + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func TestCalculateMarginCost(t *testing.T) { + type args struct { + price fixedpoint.Value + quantity fixedpoint.Value + leverage fixedpoint.Value + } + tests := []struct { + name string + args args + want fixedpoint.Value + }{ + { + name: "simple", + args: args{ + price: fixedpoint.NewFromFloat(9000.0), + quantity: fixedpoint.NewFromFloat(2.0), + leverage: fixedpoint.NewFromFloat(3.0), + }, + want: fixedpoint.NewFromFloat(9000.0 * 2.0 / 3.0), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := CalculateMarginCost(tt.args.price, tt.args.quantity, tt.args.leverage); got.String() != tt.want.String() { + t.Errorf("CalculateMarginCost() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCalculatePositionCost(t *testing.T) { + type args struct { + markPrice fixedpoint.Value + orderPrice fixedpoint.Value + quantity fixedpoint.Value + leverage fixedpoint.Value + side types.SideType + } + tests := []struct { + name string + args args + want fixedpoint.Value + }{ + { + // long position does not have openLoss + name: "long", + args: args{ + markPrice: fixedpoint.NewFromFloat(9050.0), + orderPrice: fixedpoint.NewFromFloat(9000.0), + quantity: fixedpoint.NewFromFloat(2.0), + leverage: fixedpoint.NewFromFloat(3.0), + side: types.SideTypeBuy, + }, + want: fixedpoint.NewFromFloat(6000.0), + }, + { + // long position does not have openLoss + name: "short", + args: args{ + markPrice: fixedpoint.NewFromFloat(9050.0), + orderPrice: fixedpoint.NewFromFloat(9000.0), + quantity: fixedpoint.NewFromFloat(2.0), + leverage: fixedpoint.NewFromFloat(3.0), + side: types.SideTypeSell, + }, + want: fixedpoint.NewFromFloat(6100.0), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := CalculatePositionCost(tt.args.markPrice, tt.args.orderPrice, tt.args.quantity, tt.args.leverage, tt.args.side); got.String() != tt.want.String() { + t.Errorf("CalculatePositionCost() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCalculateMaxPosition(t *testing.T) { + type args struct { + price fixedpoint.Value + availableMargin fixedpoint.Value + leverage fixedpoint.Value + } + tests := []struct { + name string + args args + want fixedpoint.Value + }{ + { + name: "3x", + args: args{ + price: fixedpoint.NewFromFloat(9000.0), + availableMargin: fixedpoint.NewFromFloat(300.0), + leverage: fixedpoint.NewFromFloat(3.0), + }, + want: fixedpoint.NewFromFloat(0.1), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := CalculateMaxPosition(tt.args.price, tt.args.availableMargin, tt.args.leverage); got.String() != tt.want.String() { + t.Errorf("CalculateMaxPosition() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCalculateMinRequiredLeverage(t *testing.T) { + type args struct { + price fixedpoint.Value + quantity fixedpoint.Value + availableMargin fixedpoint.Value + } + tests := []struct { + name string + args args + want fixedpoint.Value + }{ + { + name: "30x", + args: args{ + price: fixedpoint.NewFromFloat(9000.0), + quantity: fixedpoint.NewFromFloat(10.0), + availableMargin: fixedpoint.NewFromFloat(3000.0), + }, + want: fixedpoint.NewFromFloat(30.0), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := CalculateMinRequiredLeverage(tt.args.price, tt.args.quantity, tt.args.availableMargin); got.String() != tt.want.String() { + t.Errorf("CalculateMinRequiredLeverage() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/risk/riskcontrol/circuit_break.go b/pkg/risk/riskcontrol/circuit_break.go new file mode 100644 index 0000000..761b230 --- /dev/null +++ b/pkg/risk/riskcontrol/circuit_break.go @@ -0,0 +1,67 @@ +package riskcontrol + +import ( + "time" + + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + indicatorv2 "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator/v2" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type CircuitBreakRiskControl struct { + // Since price could be fluctuated large, + // use an EWMA to smooth it in running time + price *indicatorv2.EWMAStream + position *types.Position + profitStats *types.ProfitStats + lossThreshold fixedpoint.Value + haltedDuration time.Duration + + haltedAt time.Time +} + +func NewCircuitBreakRiskControl( + position *types.Position, + price *indicatorv2.EWMAStream, + lossThreshold fixedpoint.Value, + profitStats *types.ProfitStats, + haltedDuration time.Duration, +) *CircuitBreakRiskControl { + return &CircuitBreakRiskControl{ + price: price, + position: position, + profitStats: profitStats, + lossThreshold: lossThreshold, + haltedDuration: haltedDuration, + } +} + +func (c *CircuitBreakRiskControl) IsOverHaltedDuration() bool { + return time.Since(c.haltedAt) >= c.haltedDuration +} + +// IsHalted returns whether we reached the circuit break condition set for this day? +func (c *CircuitBreakRiskControl) IsHalted(t time.Time) bool { + if c.profitStats.IsOver24Hours() { + c.profitStats.ResetToday(t) + } + + // if we are not over the halted duration, we don't need to check the condition + if !c.IsOverHaltedDuration() { + return false + } + + var unrealized = c.position.UnrealizedProfit(fixedpoint.NewFromFloat(c.price.Last(0))) + log.Infof("[CircuitBreakRiskControl] realized PnL = %f, unrealized PnL = %f\n", + c.profitStats.TodayPnL.Float64(), + unrealized.Float64()) + + isHalted := unrealized.Add(c.profitStats.TodayPnL).Compare(c.lossThreshold) <= 0 + if isHalted { + c.haltedAt = t + } + + return isHalted +} diff --git a/pkg/risk/riskcontrol/circuit_break_test.go b/pkg/risk/riskcontrol/circuit_break_test.go new file mode 100644 index 0000000..621d5e7 --- /dev/null +++ b/pkg/risk/riskcontrol/circuit_break_test.go @@ -0,0 +1,81 @@ +package riskcontrol + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + indicatorv2 "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator/v2" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func Test_IsHalted(t *testing.T) { + var ( + price = 30000.00 + realizedPnL = fixedpoint.NewFromFloat(-100.0) + breakCondition = fixedpoint.NewFromFloat(-500.00) + ) + + window := types.IntervalWindow{Window: 30, Interval: types.Interval1m} + priceEWMA := indicatorv2.EWMA2(nil, window.Window) + priceEWMA.PushAndEmit(price) + + cases := []struct { + name string + position fixedpoint.Value + averageCost fixedpoint.Value + isHalted bool + }{ + { + name: "PositivePositionReachBreakCondition", + position: fixedpoint.NewFromFloat(10.0), + averageCost: fixedpoint.NewFromFloat(30040.0), + isHalted: true, + }, { + name: "PositivePositionOverBreakCondition", + position: fixedpoint.NewFromFloat(10.0), + averageCost: fixedpoint.NewFromFloat(30050.0), + isHalted: true, + }, { + name: "PositivePositionUnderBreakCondition", + position: fixedpoint.NewFromFloat(10.0), + averageCost: fixedpoint.NewFromFloat(30030.0), + isHalted: false, + }, { + name: "NegativePositionReachBreakCondition", + position: fixedpoint.NewFromFloat(-10.0), + averageCost: fixedpoint.NewFromFloat(29960.0), + isHalted: true, + }, { + name: "NegativePositionOverBreakCondition", + position: fixedpoint.NewFromFloat(-10.0), + averageCost: fixedpoint.NewFromFloat(29950.0), + isHalted: true, + }, { + name: "NegativePositionUnderBreakCondition", + position: fixedpoint.NewFromFloat(-10.0), + averageCost: fixedpoint.NewFromFloat(29970.0), + isHalted: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var riskControl = NewCircuitBreakRiskControl( + &types.Position{ + Base: tc.position, + AverageCost: tc.averageCost, + }, + priceEWMA, + breakCondition, + &types.ProfitStats{}, + 24*time.Hour, + ) + now := time.Now() + riskControl.profitStats.ResetToday(now) + riskControl.profitStats.TodayPnL = realizedPnL + assert.Equal(t, tc.isHalted, riskControl.IsHalted(now.Add(time.Hour))) + }) + } +} diff --git a/pkg/risk/riskcontrol/order_price_risk.go b/pkg/risk/riskcontrol/order_price_risk.go new file mode 100644 index 0000000..00ee615 --- /dev/null +++ b/pkg/risk/riskcontrol/order_price_risk.go @@ -0,0 +1,35 @@ +package riskcontrol + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + indicatorv2 "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator/v2" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + log "github.com/sirupsen/logrus" +) + +type OrderPriceRiskControl struct { + referencePrice *indicatorv2.EWMAStream + lossThreshold fixedpoint.Value +} + +func NewOrderPriceRiskControl(referencePrice *indicatorv2.EWMAStream, threshold fixedpoint.Value) *OrderPriceRiskControl { + return &OrderPriceRiskControl{ + referencePrice: referencePrice, + lossThreshold: threshold, + } +} + +func (r *OrderPriceRiskControl) IsSafe(side types.SideType, price fixedpoint.Value, quantity fixedpoint.Value) bool { + refPrice := fixedpoint.NewFromFloat(r.referencePrice.Last(0)) + // calculate profit + var profit fixedpoint.Value + if side == types.SideTypeBuy { + profit = refPrice.Sub(price).Mul(quantity) + } else if side == types.SideTypeSell { + profit = price.Sub(refPrice).Mul(quantity) + } else { + log.Warnf("OrderPriceRiskControl: unsupported side type: %s", side) + return false + } + return profit.Compare(r.lossThreshold) > 0 +} diff --git a/pkg/risk/riskcontrol/order_price_risk_test.go b/pkg/risk/riskcontrol/order_price_risk_test.go new file mode 100644 index 0000000..cc3b4fb --- /dev/null +++ b/pkg/risk/riskcontrol/order_price_risk_test.go @@ -0,0 +1,63 @@ +package riskcontrol + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + indicatorv2 "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator/v2" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func Test_OrderPriceRiskControl_IsSafe(t *testing.T) { + refPrice := 30000.00 + lossThreshold := fixedpoint.NewFromFloat(-100) + + window := types.IntervalWindow{Window: 30, Interval: types.Interval1m} + refPriceEWMA := indicatorv2.EWMA2(nil, window.Window) + refPriceEWMA.PushAndEmit(refPrice) + + cases := []struct { + name string + side types.SideType + price fixedpoint.Value + quantity fixedpoint.Value + isSafe bool + }{ + { + name: "BuyingHighSafe", + side: types.SideTypeBuy, + price: fixedpoint.NewFromFloat(30040.0), + quantity: fixedpoint.NewFromFloat(1.0), + isSafe: true, + }, + { + name: "SellingLowSafe", + side: types.SideTypeSell, + price: fixedpoint.NewFromFloat(29960.0), + quantity: fixedpoint.NewFromFloat(1.0), + isSafe: true, + }, + { + name: "BuyingHighLoss", + side: types.SideTypeBuy, + price: fixedpoint.NewFromFloat(30040.0), + quantity: fixedpoint.NewFromFloat(10.0), + isSafe: false, + }, + { + name: "SellingLowLoss", + side: types.SideTypeSell, + price: fixedpoint.NewFromFloat(29960.0), + quantity: fixedpoint.NewFromFloat(10.0), + isSafe: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var riskControl = NewOrderPriceRiskControl(refPriceEWMA, lossThreshold) + assert.Equal(t, tc.isSafe, riskControl.IsSafe(tc.side, tc.price, tc.quantity)) + }) + } +} diff --git a/pkg/risk/riskcontrol/position.go b/pkg/risk/riskcontrol/position.go new file mode 100644 index 0000000..330d182 --- /dev/null +++ b/pkg/risk/riskcontrol/position.go @@ -0,0 +1,103 @@ +package riskcontrol + +import ( + "context" + + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// PositionRiskControl controls the position with the given hard limit +// TODO: add a decorator for the order executor and move the order submission logics into the decorator +// +//go:generate callbackgen -type PositionRiskControl +type PositionRiskControl struct { + orderExecutor qbtrade.OrderExecutorExtended + + // hardLimit is the maximum base position you can hold + hardLimit fixedpoint.Value + + // sliceQuantity is the maximum quantity of the order you want to place. + // only used in the ModifiedQuantity method + sliceQuantity fixedpoint.Value + + // activeOrderBook is used to store orders created by the risk control. + // This allows us to cancel them before submitting the position release + // orders, preventing duplicate orders. + activeOrderBook *qbtrade.ActiveOrderBook + + releasePositionCallbacks []func(quantity fixedpoint.Value, side types.SideType) +} + +func NewPositionRiskControl(orderExecutor qbtrade.OrderExecutorExtended, hardLimit, quantity fixedpoint.Value) *PositionRiskControl { + control := &PositionRiskControl{ + orderExecutor: orderExecutor, + hardLimit: hardLimit, + sliceQuantity: quantity, + } + + // register position update handler: check if position is over the hard limit + orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { + if fixedpoint.Compare(position.Base, hardLimit) > 0 { + log.Infof("position %f is over hardlimit %f, releasing position...", position.Base.Float64(), hardLimit.Float64()) + control.EmitReleasePosition(position.Base.Sub(hardLimit), types.SideTypeSell) + } else if fixedpoint.Compare(position.Base, hardLimit.Neg()) < 0 { + log.Infof("position %f is over hardlimit %f, releasing position...", position.Base.Float64(), hardLimit.Float64()) + control.EmitReleasePosition(position.Base.Neg().Sub(hardLimit), types.SideTypeBuy) + } + }) + + return control +} + +func (p *PositionRiskControl) Initialize(ctx context.Context, session *qbtrade.ExchangeSession) { + p.activeOrderBook = qbtrade.NewActiveOrderBook("") + p.activeOrderBook.BindStream(session.UserDataStream) + + p.OnReleasePosition(func(quantity fixedpoint.Value, side types.SideType) { + if err := p.activeOrderBook.GracefulCancel(ctx, session.Exchange); err != nil { + log.WithError(err).Errorf("failed to cancel orders") + } + + pos := p.orderExecutor.Position() + submitOrder := types.SubmitOrder{ + Symbol: pos.Symbol, + Market: pos.Market, + Side: side, + Type: types.OrderTypeMarket, + Quantity: quantity, + } + + log.Infof("RiskControl: position limit exceeded, submitting order to reduce position: %+v", submitOrder) + createdOrders, err := p.orderExecutor.SubmitOrders(ctx, submitOrder) + if err != nil { + log.WithError(err).Errorf("failed to submit orders") + return + } + + log.Infof("created position release orders: %+v", createdOrders) + + p.activeOrderBook.Add(createdOrders...) + }) +} + +// ModifiedQuantity returns sliceQuantity controlled by position risks +// For buy orders, modify sliceQuantity = min(hardLimit - position, sliceQuantity), limiting by positive position +// For sell orders, modify sliceQuantity = min(hardLimit - (-position), sliceQuantity), limiting by negative position +// +// Pass the current base position to this method, and it returns the maximum sliceQuantity for placing the orders. +// This works for both Long/Short position +func (p *PositionRiskControl) ModifiedQuantity(position fixedpoint.Value) (buyQuantity, sellQuantity fixedpoint.Value) { + if p.sliceQuantity.IsZero() { + buyQuantity = p.hardLimit.Sub(position) + sellQuantity = p.hardLimit.Add(position) + return buyQuantity, sellQuantity + } + + buyQuantity = fixedpoint.Min(p.hardLimit.Sub(position), p.sliceQuantity) + sellQuantity = fixedpoint.Min(p.hardLimit.Add(position), p.sliceQuantity) + return buyQuantity, sellQuantity +} diff --git a/pkg/risk/riskcontrol/position_test.go b/pkg/risk/riskcontrol/position_test.go new file mode 100644 index 0000000..9283a59 --- /dev/null +++ b/pkg/risk/riskcontrol/position_test.go @@ -0,0 +1,118 @@ +package riskcontrol + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/core" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade/mocks" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func Test_ModifiedQuantity(t *testing.T) { + pos := &types.Position{ + Market: types.Market{ + Symbol: "BTCUSDT", + PricePrecision: 8, + VolumePrecision: 8, + QuoteCurrency: "USDT", + BaseCurrency: "BTC", + }, + } + orderExecutor := qbtrade.NewGeneralOrderExecutor(nil, "BTCUSDT", "strategy", "strategy-1", pos) + riskControl := NewPositionRiskControl(orderExecutor, fixedpoint.NewFromInt(10), fixedpoint.NewFromInt(2)) + + cases := []struct { + name string + position fixedpoint.Value + buyQuantity fixedpoint.Value + sellQuantity fixedpoint.Value + }{ + { + name: "BuyOverHardLimit", + position: fixedpoint.NewFromInt(9), + buyQuantity: fixedpoint.NewFromInt(1), + sellQuantity: fixedpoint.NewFromInt(2), + }, + { + name: "SellOverHardLimit", + position: fixedpoint.NewFromInt(-9), + buyQuantity: fixedpoint.NewFromInt(2), + sellQuantity: fixedpoint.NewFromInt(1), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + buyQuantity, sellQuantity := riskControl.ModifiedQuantity(tc.position) + assert.Equal(t, tc.buyQuantity, buyQuantity) + assert.Equal(t, tc.sellQuantity, sellQuantity) + }) + } +} + +func TestReleasePositionCallbacks(t *testing.T) { + cases := []struct { + name string + position fixedpoint.Value + resultPosition fixedpoint.Value + }{ + { + name: "PositivePositionWithinLimit", + position: fixedpoint.NewFromInt(8), + resultPosition: fixedpoint.NewFromInt(8), + }, + { + name: "NegativePositionWithinLimit", + position: fixedpoint.NewFromInt(-8), + resultPosition: fixedpoint.NewFromInt(-8), + }, + { + name: "PositivePositionOverLimit", + position: fixedpoint.NewFromInt(11), + resultPosition: fixedpoint.NewFromInt(10), + }, + { + name: "NegativePositionOverLimit", + position: fixedpoint.NewFromInt(-11), + resultPosition: fixedpoint.NewFromInt(-10), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + pos := &types.Position{ + Base: tc.position, + Market: types.Market{ + Symbol: "BTCUSDT", + PricePrecision: 8, + VolumePrecision: 8, + QuoteCurrency: "USDT", + BaseCurrency: "BTC", + }, + } + + tradeCollector := &core.TradeCollector{} + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + orderExecutor := mocks.NewMockOrderExecutorExtended(mockCtrl) + orderExecutor.EXPECT().TradeCollector().Return(tradeCollector).AnyTimes() + orderExecutor.EXPECT().Position().Return(pos).AnyTimes() + orderExecutor.EXPECT().SubmitOrders(gomock.Any(), gomock.Any()).AnyTimes() + + riskControl := NewPositionRiskControl(orderExecutor, fixedpoint.NewFromInt(10), fixedpoint.NewFromInt(2)) + riskControl.OnReleasePosition(func(quantity fixedpoint.Value, side types.SideType) { + if side == types.SideTypeBuy { + pos.Base = pos.Base.Add(quantity) + } else { + pos.Base = pos.Base.Sub(quantity) + } + }) + + orderExecutor.TradeCollector().EmitPositionUpdate(&types.Position{Base: tc.position}) + assert.Equal(t, tc.resultPosition, pos.Base) + }) + } +} diff --git a/pkg/risk/riskcontrol/positionriskcontrol_callbacks.go b/pkg/risk/riskcontrol/positionriskcontrol_callbacks.go new file mode 100644 index 0000000..c343cf9 --- /dev/null +++ b/pkg/risk/riskcontrol/positionriskcontrol_callbacks.go @@ -0,0 +1,18 @@ +// Code generated by "callbackgen -type PositionRiskControl"; DO NOT EDIT. + +package riskcontrol + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func (p *PositionRiskControl) OnReleasePosition(cb func(quantity fixedpoint.Value, side types.SideType)) { + p.releasePositionCallbacks = append(p.releasePositionCallbacks, cb) +} + +func (p *PositionRiskControl) EmitReleasePosition(quantity fixedpoint.Value, side types.SideType) { + for _, cb := range p.releasePositionCallbacks { + cb(quantity, side) + } +} diff --git a/pkg/server/asset_fs.go b/pkg/server/asset_fs.go new file mode 100644 index 0000000..43b2eec --- /dev/null +++ b/pkg/server/asset_fs.go @@ -0,0 +1,22 @@ +//go:build web + +package server + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +func (s *Server) assetsHandler(c *gin.Context) { + // redirect to .html page if the page exists + if pageRoutePattern.MatchString(c.Request.URL.Path) { + _, err := FS.Open(c.Request.URL.Path + ".html") + if err == nil { + c.Request.URL.Path += ".html" + } + } + + fs := http.FileServer(FS) + fs.ServeHTTP(c.Writer, c.Request) +} diff --git a/pkg/server/assets_dummy.go b/pkg/server/assets_dummy.go new file mode 100644 index 0000000..4e50d2f --- /dev/null +++ b/pkg/server/assets_dummy.go @@ -0,0 +1,9 @@ +//go:build !web + +package server + +import ( + "github.com/gin-gonic/gin" +) + +func (s *Server) assetsHandler(c *gin.Context) {} diff --git a/pkg/server/envvars.go b/pkg/server/envvars.go new file mode 100644 index 0000000..760779d --- /dev/null +++ b/pkg/server/envvars.go @@ -0,0 +1,41 @@ +package server + +import ( + "fmt" + "strings" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" +) + +func collectSessionEnvVars(sessions map[string]*qbtrade.ExchangeSession) (envVars map[string]string, err error) { + envVars = make(map[string]string) + + for _, session := range sessions { + if len(session.Key) == 0 && len(session.Secret) == 0 { + err = fmt.Errorf("session %s key & secret is not empty", session.Name) + return + } + + if len(session.EnvVarPrefix) > 0 { + // pragma: allowlist nextline secret + envVars[session.EnvVarPrefix+"_API_KEY"] = session.Key + // pragma: allowlist nextline secret + envVars[session.EnvVarPrefix+"_API_SECRET"] = session.Secret + } else if len(session.Name) > 0 { + sn := strings.ToUpper(session.Name) + // pragma: allowlist nextline secret + envVars[sn+"_API_KEY"] = session.Key + // pragma: allowlist nextline secret + envVars[sn+"_API_SECRET"] = session.Secret + } else { + err = fmt.Errorf("session %s name or env var prefix is not defined", session.Name) + return + } + + // reset key and secret so that we won't marshal them to the config file + session.Key = "" + session.Secret = "" + } + + return +} diff --git a/pkg/server/ping.go b/pkg/server/ping.go new file mode 100644 index 0000000..39de99b --- /dev/null +++ b/pkg/server/ping.go @@ -0,0 +1,45 @@ +package server + +import ( + "context" + "time" + + "github.com/sirupsen/logrus" +) + +func PingUntil(ctx context.Context, interval time.Duration, baseURL string, callback func()) { + pingURL := baseURL + "/api/ping" + timeout := time.NewTimer(3 * time.Minute) + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + + case <-timeout.C: + logrus.Warnf("ping hits 1 minute timeout") + return + + case <-ctx.Done(): + return + + case <-ticker.C: + var response map[string]interface{} + var err = getJSON(pingURL, &response) + if err == nil { + callback() + return + } + } + } +} + +func pingAndOpenURL(ctx context.Context, baseURL string) { + setupURL := baseURL + "/setup" + go PingUntil(ctx, time.Second, baseURL, func() { + if err := openURL(setupURL); err != nil { + logrus.WithError(err).Errorf("can not call open command to open the web page") + } + }) +} diff --git a/pkg/server/routes.go b/pkg/server/routes.go new file mode 100644 index 0000000..b828508 --- /dev/null +++ b/pkg/server/routes.go @@ -0,0 +1,650 @@ +package server + +import ( + "context" + "fmt" + "io/ioutil" + "math/rand" + "net" + "net/http" + "os" + "regexp" + "strconv" + "time" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" + "github.com/joho/godotenv" + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/service" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const DefaultBindAddress = "localhost:8080" + +type Setup struct { + // Context is the trader context + Context context.Context + + // Cancel is the trader context cancel function you want to cancel + Cancel context.CancelFunc + + // Token is used for setup api authentication + Token string + + BeforeRestart func() +} + +type Server struct { + Config *qbtrade.Config + Environ *qbtrade.Environment + Trader *qbtrade.Trader + Setup *Setup + OpenInBrowser bool + + srv *http.Server +} + +func (s *Server) newEngine(ctx context.Context) *gin.Engine { + r := gin.Default() + r.Use(cors.New(cors.Config{ + AllowOrigins: []string{"*"}, + AllowHeaders: []string{"Origin", "Content-Type"}, + ExposeHeaders: []string{"Content-Length"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, + AllowWebSockets: true, + AllowCredentials: true, + MaxAge: 12 * time.Hour, + })) + + r.GET("/api/ping", s.ping) + + if s.Setup != nil { + r.POST("/api/setup/test-db", s.setupTestDB) + r.POST("/api/setup/configure-db", s.setupConfigureDB) + r.POST("/api/setup/strategy/single/:id/session/:session", s.setupAddStrategy) + r.POST("/api/setup/save", s.setupSaveConfig) + r.POST("/api/setup/restart", s.setupRestart) + } + + r.GET("/api/environment/syncing", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "syncing": s.Environ.IsSyncing(), + }) + }) + + r.POST("/api/environment/sync", func(c *gin.Context) { + if s.Environ.IsSyncing() != qbtrade.Syncing { + go func() { + // We use the root context here because the syncing operation is a background goroutine. + // It should not be terminated if the request is disconnected. + if err := s.Environ.Sync(ctx); err != nil { + logrus.WithError(err).Error("sync error") + } + }() + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + }) + }) + + r.GET("/api/outbound-ip", func(c *gin.Context) { + outboundIP, err := GetOutboundIP() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + } + + c.JSON(http.StatusOK, gin.H{ + "outboundIP": outboundIP.String(), + }) + }) + + r.GET("/api/trades", func(c *gin.Context) { + if s.Environ.TradeService == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database is not configured"}) + return + } + + exchange := c.Query("exchange") + symbol := c.Query("symbol") + gidStr := c.DefaultQuery("gid", "0") + lastGID, err := strconv.ParseInt(gidStr, 10, 64) + if err != nil { + logrus.WithError(err).Error("last gid parse error") + c.Status(http.StatusBadRequest) + return + } + + trades, err := s.Environ.TradeService.Query(service.QueryTradesOptions{ + Exchange: types.ExchangeName(exchange), + Symbol: symbol, + LastGID: lastGID, + Ordering: "DESC", + }) + if err != nil { + c.Status(http.StatusBadRequest) + logrus.WithError(err).Error("order query error") + return + } + + c.JSON(http.StatusOK, gin.H{ + "trades": trades, + }) + }) + + r.GET("/api/orders/closed", s.listClosedOrders) + r.GET("/api/trading-volume", s.tradingVolume) + + r.POST("/api/sessions/test", func(c *gin.Context) { + var session qbtrade.ExchangeSession + if err := c.BindJSON(&session); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + err := session.InitExchange(session.ExchangeName.String(), nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + var anyErr error + _, openOrdersErr := session.Exchange.QueryOpenOrders(c, "BTCUSDT") + if openOrdersErr != nil { + anyErr = openOrdersErr + } + + _, balanceErr := session.Exchange.QueryAccountBalances(c) + if balanceErr != nil { + anyErr = balanceErr + } + + c.JSON(http.StatusOK, gin.H{ + "success": anyErr == nil, + "error": anyErr, + "balance": balanceErr == nil, + "openOrders": openOrdersErr == nil, + }) + }) + + r.GET("/api/sessions", func(c *gin.Context) { + var sessions []*qbtrade.ExchangeSession + for _, session := range s.Environ.Sessions() { + sessions = append(sessions, session) + } + + if len(sessions) == 0 { + c.JSON(http.StatusOK, gin.H{"sessions": []int{}}) + } + + c.JSON(http.StatusOK, gin.H{"sessions": sessions}) + }) + + r.POST("/api/sessions", func(c *gin.Context) { + var session qbtrade.ExchangeSession + if err := c.BindJSON(&session); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + if err := session.InitExchange(session.ExchangeName.String(), nil); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + if s.Config.Sessions == nil { + s.Config.Sessions = make(map[string]*qbtrade.ExchangeSession) + } + s.Config.Sessions[session.Name] = &session + + s.Environ.AddExchangeSession(session.Name, &session) + + if err := session.Init(c, s.Environ); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true}) + }) + + r.GET("/api/assets", s.listAssets) + r.GET("/api/sessions/:session", s.listSessions) + r.GET("/api/sessions/:session/trades", s.listSessionTrades) + r.GET("/api/sessions/:session/open-orders", s.listSessionOpenOrders) + r.GET("/api/sessions/:session/account", s.getSessionAccount) + r.GET("/api/sessions/:session/account/balances", s.getSessionAccountBalance) + r.GET("/api/sessions/:session/symbols", s.listSessionSymbols) + + r.GET("/api/sessions/:session/pnl", func(c *gin.Context) { + c.JSON(200, gin.H{"message": "pong"}) + }) + + r.GET("/api/sessions/:session/market/:symbol/open-orders", func(c *gin.Context) { + c.JSON(200, gin.H{"message": "pong"}) + }) + + r.GET("/api/sessions/:session/market/:symbol/trades", func(c *gin.Context) { + c.JSON(200, gin.H{"message": "pong"}) + }) + + r.GET("/api/sessions/:session/market/:symbol/pnl", func(c *gin.Context) { + c.JSON(200, gin.H{"message": "pong"}) + }) + + r.GET("/api/strategies/single", s.listStrategies) + r.NoRoute(s.assetsHandler) + return r +} + +func (s *Server) RunWithListener(ctx context.Context, l net.Listener) error { + r := s.newEngine(ctx) + bind := l.Addr().String() + + if s.OpenInBrowser { + openBrowser(ctx, bind) + } + + s.srv = newServer(r, bind) + return serve(s.srv, l) +} + +func (s *Server) Run(ctx context.Context, bindArgs ...string) error { + r := s.newEngine(ctx) + bind := resolveBind(bindArgs) + if s.OpenInBrowser { + openBrowser(ctx, bind) + } + + s.srv = newServer(r, bind) + return listenAndServe(s.srv) +} + +func (s *Server) ping(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"message": "pong"}) +} + +func (s *Server) listClosedOrders(c *gin.Context) { + if s.Environ.OrderService == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database is not configured"}) + return + } + + exchange := c.Query("exchange") + symbol := c.Query("symbol") + gidStr := c.DefaultQuery("gid", "0") + + lastGID, err := strconv.ParseInt(gidStr, 10, 64) + if err != nil { + logrus.WithError(err).Error("last gid parse error") + c.Status(http.StatusBadRequest) + return + } + + orders, err := s.Environ.OrderService.Query(service.QueryOrdersOptions{ + Exchange: types.ExchangeName(exchange), + Symbol: symbol, + LastGID: lastGID, + Ordering: "DESC", + }) + if err != nil { + c.Status(http.StatusBadRequest) + logrus.WithError(err).Error("order query error") + return + } + + c.JSON(http.StatusOK, gin.H{ + "orders": orders, + }) +} + +func (s *Server) listStrategies(c *gin.Context) { + var stashes []map[string]interface{} + + for _, mount := range s.Config.ExchangeStrategies { + stash, err := mount.Map() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + stash["strategy"] = mount.Strategy.ID() + + stashes = append(stashes, stash) + } + + if len(stashes) == 0 { + c.JSON(http.StatusOK, gin.H{"strategies": []int{}}) + } + c.JSON(http.StatusOK, gin.H{"strategies": stashes}) +} + +func (s *Server) listSessions(c *gin.Context) { + sessionName := c.Param("session") + session, ok := s.Environ.Session(sessionName) + + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("session %s not found", sessionName)}) + return + } + + c.JSON(http.StatusOK, gin.H{"session": session}) +} + +func (s *Server) listSessionSymbols(c *gin.Context) { + sessionName := c.Param("session") + session, ok := s.Environ.Session(sessionName) + + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("session %s not found", sessionName)}) + return + } + + var symbols []string + for symbol := range session.Markets() { + symbols = append(symbols, symbol) + } + + c.JSON(http.StatusOK, gin.H{"symbols": symbols}) +} + +func (s *Server) listSessionTrades(c *gin.Context) { + sessionName := c.Param("session") + session, ok := s.Environ.Session(sessionName) + + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("session %s not found", sessionName)}) + return + } + + c.JSON(http.StatusOK, gin.H{"trades": session.Trades}) +} + +func (s *Server) getSessionAccount(c *gin.Context) { + sessionName := c.Param("session") + session, ok := s.Environ.Session(sessionName) + + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("session %s not found", sessionName)}) + return + } + + c.JSON(http.StatusOK, gin.H{"account": session.GetAccount()}) +} + +func (s *Server) getSessionAccountBalance(c *gin.Context) { + sessionName := c.Param("session") + session, ok := s.Environ.Session(sessionName) + + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("session %s not found", sessionName)}) + return + } + + if session.Account == nil { + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("the account of session %s is nil", sessionName)}) + return + } + + c.JSON(http.StatusOK, gin.H{"balances": session.GetAccount().Balances()}) +} + +func (s *Server) listSessionOpenOrders(c *gin.Context) { + sessionName := c.Param("session") + session, ok := s.Environ.Session(sessionName) + + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("session %s not found", sessionName)}) + return + } + + marketOrders := make(map[string][]types.Order) + for symbol, orderStore := range session.OrderStores() { + marketOrders[symbol] = orderStore.Orders() + } + + c.JSON(http.StatusOK, gin.H{"orders": marketOrders}) +} + +func genFakeAssets() types.AssetMap { + + totalAssets := types.AssetMap{} + balances := types.BalanceMap{ + "BTC": types.Balance{Currency: "BTC", Available: fixedpoint.NewFromFloat(10.0 * rand.Float64())}, + "BCH": types.Balance{Currency: "BCH", Available: fixedpoint.NewFromFloat(0.01 * rand.Float64())}, + "LTC": types.Balance{Currency: "LTC", Available: fixedpoint.NewFromFloat(200.0 * rand.Float64())}, + "ETH": types.Balance{Currency: "ETH", Available: fixedpoint.NewFromFloat(50.0 * rand.Float64())}, + "SAND": types.Balance{Currency: "SAND", Available: fixedpoint.NewFromFloat(11500.0 * rand.Float64())}, + "BNB": types.Balance{Currency: "BNB", Available: fixedpoint.NewFromFloat(1000.0 * rand.Float64())}, + "GRT": types.Balance{Currency: "GRT", Available: fixedpoint.NewFromFloat(1000.0 * rand.Float64())}, + "MAX": types.Balance{Currency: "MAX", Available: fixedpoint.NewFromFloat(200000.0 * rand.Float64())}, + "COMP": types.Balance{Currency: "COMP", Available: fixedpoint.NewFromFloat(100.0 * rand.Float64())}, + } + assets := balances.Assets(map[string]fixedpoint.Value{ + "BTCUSDT": fixedpoint.NewFromFloat(38000.0), + "BCHUSDT": fixedpoint.NewFromFloat(478.0), + "LTCUSDT": fixedpoint.NewFromFloat(150.0), + "COMPUSDT": fixedpoint.NewFromFloat(450.0), + "ETHUSDT": fixedpoint.NewFromFloat(1700.0), + "BNBUSDT": fixedpoint.NewFromFloat(70.0), + "GRTUSDT": fixedpoint.NewFromFloat(0.89), + "DOTUSDT": fixedpoint.NewFromFloat(20.0), + "SANDUSDT": fixedpoint.NewFromFloat(0.13), + "MAXUSDT": fixedpoint.NewFromFloat(0.122), + }, time.Now()) + for currency, asset := range assets { + totalAssets[currency] = asset + } + + return totalAssets +} + +func (s *Server) listAssets(c *gin.Context) { + if ok, err := strconv.ParseBool(os.Getenv("USE_FAKE_ASSETS")); err == nil && ok { + c.JSON(http.StatusOK, gin.H{"assets": genFakeAssets()}) + return + } + + totalAssets := types.AssetMap{} + for _, session := range s.Environ.Sessions() { + balances := session.GetAccount().Balances() + + if err := session.UpdatePrices(c, balances.Currencies(), "USDT"); err != nil { + logrus.WithError(err).Error("price update failed") + c.Status(http.StatusInternalServerError) + return + } + + assets := balances.Assets(session.LastPrices(), time.Now()) + + for currency, asset := range assets { + totalAssets[currency] = asset + } + } + + c.JSON(http.StatusOK, gin.H{"assets": totalAssets}) +} + +func (s *Server) setupSaveConfig(c *gin.Context) { + if len(s.Config.Sessions) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "session is not configured"}) + return + } + + envVars, err := collectSessionEnvVars(s.Config.Sessions) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if s.Environ.DatabaseService != nil { + envVars["DB_DRIVER"] = s.Environ.DatabaseService.Driver + envVars["DB_DSN"] = s.Environ.DatabaseService.DSN + } + + dotenvFile := ".env.local" + if err := moveFileToBackup(dotenvFile); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if err := godotenv.Write(envVars, dotenvFile); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + out, err := s.Config.YAML() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + fmt.Println("config file") + fmt.Println("=================================================") + fmt.Println(string(out)) + fmt.Println("=================================================") + + filename := "qbtrade.yaml" + if err := moveFileToBackup(filename); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if err := ioutil.WriteFile(filename, out, 0666); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +var pageRoutePattern = regexp.MustCompile("/[a-z]+$") + +func moveFileToBackup(filename string) error { + stat, err := os.Stat(filename) + + if err == nil && stat != nil { + err := os.Rename(filename, filename+"."+time.Now().Format("20060102_150405_07_00")) + if err != nil { + return err + } + } + + return nil +} + +func (s *Server) tradingVolume(c *gin.Context) { + if s.Environ.TradeService == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database is not configured"}) + return + } + + period := c.DefaultQuery("period", "day") + segment := c.DefaultQuery("segment", "exchange") + startTimeStr := c.Query("start-time") + + var startTime time.Time + + if startTimeStr != "" { + v, err := time.Parse(time.RFC3339, startTimeStr) + if err != nil { + c.Status(http.StatusBadRequest) + logrus.WithError(err).Error("start-time format incorrect") + return + } + startTime = v + + } else { + switch period { + case "day": + startTime = time.Now().AddDate(0, 0, -30) + + case "month": + startTime = time.Now().AddDate(0, -6, 0) + + case "year": + startTime = time.Now().AddDate(-2, 0, 0) + + default: + startTime = time.Now().AddDate(0, 0, -7) + + } + } + + rows, err := s.Environ.TradeService.QueryTradingVolume(startTime, service.TradingVolumeQueryOptions{ + SegmentBy: segment, + GroupByPeriod: period, + }) + if err != nil { + logrus.WithError(err).Error("trading volume query error") + c.Status(http.StatusInternalServerError) + return + } + + c.JSON(http.StatusOK, gin.H{"tradingVolumes": rows}) +} + +func newServer(r http.Handler, bind string) *http.Server { + return &http.Server{ + Addr: bind, + Handler: r, + } +} + +func serve(srv *http.Server, l net.Listener) (err error) { + defer func() { + if err != nil && err != http.ErrServerClosed { + logrus.WithError(err).Error("unexpected http server error") + } + }() + + err = srv.Serve(l) + if err != http.ErrServerClosed { + return err + } + + return nil +} + +func listenAndServe(srv *http.Server) error { + var err error + + defer func() { + if err != nil && err != http.ErrServerClosed { + logrus.WithError(err).Error("unexpected http server error") + } + }() + + err = srv.ListenAndServe() + if err != http.ErrServerClosed { + return err + } + + return nil +} + +func GetOutboundIP() (net.IP, error) { + conn, err := net.Dial("udp", "8.8.8.8:80") + if err != nil { + return nil, err + } + defer conn.Close() + + localAddr := conn.LocalAddr().(*net.UDPAddr) + return localAddr.IP, nil +} diff --git a/pkg/server/setup.go b/pkg/server/setup.go new file mode 100644 index 0000000..5695dcc --- /dev/null +++ b/pkg/server/setup.go @@ -0,0 +1,159 @@ +package server + +import ( + "context" + "net/http" + "os" + "syscall" + "time" + + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" +) + +func (s *Server) setupTestDB(c *gin.Context) { + payload := struct { + Driver string `json:"driver"` + DSN string `json:"dsn"` + }{} + + if err := c.BindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing arguments"}) + return + } + + if len(payload.Driver) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing driver parameter"}) + return + } + + if len(payload.DSN) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing dsn parameter"}) + return + } + + db, err := sqlx.Connect(payload.Driver, payload.DSN) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if err := db.Close(); err != nil { + logrus.WithError(err).Error("db connection close error") + } + + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +func (s *Server) setupConfigureDB(c *gin.Context) { + payload := struct { + Driver string `json:"driver"` + DSN string `json:"dsn"` + }{} + + if err := c.BindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing parameters"}) + return + } + + if len(payload.Driver) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing driver parameter"}) + return + } + + if len(payload.DSN) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing dsn parameter"}) + return + } + + if err := s.Environ.ConfigureDatabaseDriver(c, payload.Driver, payload.DSN); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "driver": payload.Driver, + "dsn": payload.DSN, + }) +} + +func (s *Server) setupAddStrategy(c *gin.Context) { + sessionName := c.Param("session") + strategyID := c.Param("id") + + _, ok := s.Environ.Session(sessionName) + if !ok { + c.JSON(http.StatusNotFound, "session not found") + return + } + + var conf map[string]interface{} + + if err := c.BindJSON(&conf); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing arguments"}) + return + } + + strategy, err := qbtrade.NewStrategyFromMap(strategyID, conf) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + mount := qbtrade.ExchangeStrategyMount{ + Mounts: []string{sessionName}, + Strategy: strategy, + } + + s.Config.ExchangeStrategies = append(s.Config.ExchangeStrategies, mount) + + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +func (s *Server) setupRestart(c *gin.Context) { + if s.srv == nil { + logrus.Error("nil srv") + return + } + + go func() { + logrus.Info("shutting down web server...") + + // The context is used to inform the server it has 5 seconds to finish + // the request it is currently handling + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := s.srv.Shutdown(ctx); err != nil { + logrus.WithError(err).Error("server forced to shutdown") + } + + logrus.Info("server shutdown completed") + + if s.Setup.BeforeRestart != nil { + s.Setup.BeforeRestart() + } + + bin := os.Args[0] + args := os.Args[0:] + + // filter out setup parameters + args = filterStrings(args, "--setup") + + envVars := os.Environ() + + logrus.Infof("exec %s %v", bin, args) + + if err := syscall.Exec(bin, args, envVars); err != nil { + logrus.WithError(err).Errorf("failed to restart %s", bin) + } + + s.Setup.Cancel() + }() + + c.JSON(http.StatusOK, gin.H{"success": true}) +} diff --git a/pkg/server/utils.go b/pkg/server/utils.go new file mode 100644 index 0000000..96ffa9d --- /dev/null +++ b/pkg/server/utils.go @@ -0,0 +1,67 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "os/exec" + "runtime" + "time" + + "github.com/sirupsen/logrus" +) + +func getJSON(url string, data interface{}) error { + var client = &http.Client{ + Timeout: 200 * time.Millisecond, + } + r, err := client.Get(url) + if err != nil { + return err + } + + defer r.Body.Close() + + return json.NewDecoder(r.Body).Decode(data) +} + +func openURL(url string) error { + cmd := exec.Command("open", url) + return cmd.Start() +} + +func filterStrings(slice []string, needle string) (ns []string) { + for _, str := range slice { + if str == needle { + continue + } + + ns = append(ns, str) + } + + return ns +} + +func openBrowser(ctx context.Context, bind string) { + if runtime.GOOS == "darwin" { + baseURL := "http://" + bind + go pingAndOpenURL(ctx, baseURL) + } else { + logrus.Warnf("%s is not supported for opening browser automatically", runtime.GOOS) + } +} + +func resolveBind(a []string) string { + switch len(a) { + case 0: + return DefaultBindAddress + + case 1: + return a[0] + + default: + panic("too many parameters for binding") + } + + return "" +} diff --git a/pkg/service/account.go b/pkg/service/account.go new file mode 100644 index 0000000..be021f9 --- /dev/null +++ b/pkg/service/account.go @@ -0,0 +1,65 @@ +package service + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/jmoiron/sqlx" + "go.uber.org/multierr" + "time" +) + +type AccountService struct { + DB *sqlx.DB +} + +func NewAccountService(db *sqlx.DB) *AccountService { + return &AccountService{DB: db} +} + +// TODO: should pass qbtrade.ExchangeSession to this function, but that might cause cyclic import +func (s *AccountService) InsertAsset(time time.Time, session string, name types.ExchangeName, account string, isMargin bool, isIsolatedMargin bool, isolatedMarginSymbol string, assets types.AssetMap) error { + if s.DB == nil { + // skip db insert when no db connection setting. + return nil + } + + var err error + for _, v := range assets { + _, _err := s.DB.Exec(` + INSERT INTO nav_history_details ( + session, + exchange, + subaccount, + time, + currency, + net_asset_in_usd, + net_asset_in_btc, + balance, + available, + locked, + borrowed, + net_asset, + price_in_usd, + is_margin, is_isolated, isolated_symbol) + values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);`, + session, + name, + account, + time, + v.Currency, + v.InUSD, + v.InBTC, + v.Total, + v.Available, + v.Locked, + v.Borrowed, + v.NetAsset, + v.PriceInUSD, + isMargin, + isIsolatedMargin, + isolatedMarginSymbol) + + err = multierr.Append(err, _err) // successful request + + } + return err +} diff --git a/pkg/service/account_test.go b/pkg/service/account_test.go new file mode 100644 index 0000000..f9c670d --- /dev/null +++ b/pkg/service/account_test.go @@ -0,0 +1,41 @@ +package service + +import ( + "testing" + "time" + + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func TestAccountService(t *testing.T) { + db, err := prepareDB(t) + if err != nil { + t.Fatal(err) + } + + defer db.Close() + + xdb := sqlx.NewDb(db.DB, "sqlite3") + service := &AccountService{DB: xdb} + + t1 := time.Now() + err = service.InsertAsset(t1, "binance", types.ExchangeBinance, "main", false, false, "", types.AssetMap{ + "BTC": types.Asset{ + Currency: "BTC", + Total: fixedpoint.MustNewFromString("1.0"), + InUSD: fixedpoint.MustNewFromString("10.0"), + InBTC: fixedpoint.MustNewFromString("0.0001"), + Time: t1, + Locked: fixedpoint.MustNewFromString("0"), + Available: fixedpoint.MustNewFromString("1.0"), + Borrowed: fixedpoint.MustNewFromString("0"), + NetAsset: fixedpoint.MustNewFromString("1"), + PriceInUSD: fixedpoint.MustNewFromString("44870"), + }, + }) + assert.NoError(t, err) +} diff --git a/pkg/service/backtest.go b/pkg/service/backtest.go new file mode 100644 index 0000000..034621c --- /dev/null +++ b/pkg/service/backtest.go @@ -0,0 +1,584 @@ +package service + +import ( + "context" + "database/sql" + "fmt" + "strconv" + "strings" + "time" + + sq "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + + exchange2 "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/batch" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type BacktestService struct { + DB *sqlx.DB +} + +func (s *BacktestService) SyncKLineByInterval( + ctx context.Context, exchange types.Exchange, symbol string, interval types.Interval, startTime, endTime time.Time, +) error { + _, isFutures, isIsolated, isolatedSymbol := exchange2.GetSessionAttributes(exchange) + + // override symbol if isolatedSymbol is not empty + if isIsolated && len(isolatedSymbol) > 0 { + symbol = isolatedSymbol + } + + if isFutures { + log.Infof("synchronizing %s futures klines with interval %s: %s <=> %s", exchange.Name(), interval, startTime, endTime) + } else { + log.Infof("synchronizing %s klines with interval %s: %s <=> %s", exchange.Name(), interval, startTime, endTime) + } + + if s.DB.DriverName() == "sqlite3" { + _, _ = s.DB.Exec("PRAGMA journal_mode = WAL") + _, _ = s.DB.Exec("PRAGMA synchronous = NORMAL") + } + + now := time.Now() + tasks := []SyncTask{ + { + Type: types.KLine{}, + Select: s.SelectLastKLines(exchange, symbol, interval, startTime, endTime, 100), + Time: func(obj interface{}) time.Time { + return obj.(types.KLine).StartTime.Time() + }, + Filter: func(obj interface{}) bool { + k := obj.(types.KLine) + if k.EndTime.Before(k.StartTime.Time().Add(k.Interval.Duration() - time.Second)) { + return false + } + + // Filter klines that has the endTime closed in the future + if k.EndTime.After(now) { + return false + } + + return true + }, + ID: func(obj interface{}) string { + kline := obj.(types.KLine) + return strconv.FormatInt(kline.StartTime.UnixMilli(), 10) + // return kline.Symbol + kline.Interval.String() + strconv.FormatInt(kline.StartTime.UnixMilli(), 10) + }, + BatchQuery: func(ctx context.Context, startTime, endTime time.Time) (interface{}, chan error) { + q := &batch.KLineBatchQuery{Exchange: exchange} + return q.Query(ctx, symbol, interval, startTime, endTime) + }, + BatchInsertBuffer: 1000, + BatchInsert: func(obj interface{}) error { + kLines := obj.([]types.KLine) + return s.BatchInsert(kLines, exchange) + }, + Insert: func(obj interface{}) error { + kline := obj.(types.KLine) + return s.Insert(kline, exchange) + }, + LogInsert: log.GetLevel() == log.DebugLevel, + }, + } + + for _, sel := range tasks { + if err := sel.execute(ctx, s.DB, startTime, endTime); err != nil { + return err + } + } + + return nil +} + +func (s *BacktestService) Verify(sourceExchange types.Exchange, symbols []string, startTime time.Time, endTime time.Time) error { + var corruptCnt = 0 + for _, symbol := range symbols { + for interval := range types.SupportedIntervals { + log.Infof("verifying %s %s backtesting data: %s to %s...", symbol, interval, startTime, endTime) + + timeRanges, err := s.FindMissingTimeRanges(context.Background(), sourceExchange, symbol, interval, + startTime, endTime) + if err != nil { + return err + } + + if len(timeRanges) == 0 { + continue + } + + log.Warnf("%s %s found missing time ranges:", symbol, interval) + corruptCnt += len(timeRanges) + for _, timeRange := range timeRanges { + log.Warnf("- %s", timeRange.String()) + } + } + } + + log.Infof("backtest verification completed") + if corruptCnt > 0 { + log.Errorf("found %d corruptions", corruptCnt) + } else { + log.Infof("found %d corruptions", corruptCnt) + } + + return nil +} + +func (s *BacktestService) SyncFresh( + ctx context.Context, exchange types.Exchange, symbol string, interval types.Interval, startTime, endTime time.Time, +) error { + log.Infof("starting fresh sync %s %s %s: %s <=> %s", exchange.Name(), symbol, interval, startTime, endTime) + startTime = startTime.Truncate(time.Minute).Add(-2 * time.Second) + endTime = endTime.Truncate(time.Minute).Add(2 * time.Second) + return s.SyncKLineByInterval(ctx, exchange, symbol, interval, startTime, endTime) +} + +// QueryKLine queries the klines from the database +func (s *BacktestService) QueryKLine( + ex types.Exchange, symbol string, interval types.Interval, orderBy string, limit int, +) (*types.KLine, error) { + log.Infof("querying last kline exchange = %s AND symbol = %s AND interval = %s", ex, symbol, interval) + + tableName := targetKlineTable(ex) + // make the SQL syntax IDE friendly, so that it can analyze it. + sql := fmt.Sprintf("SELECT * FROM `%s` WHERE `symbol` = :symbol AND `interval` = :interval ORDER BY end_time "+orderBy+" LIMIT "+strconv.Itoa(limit), tableName) + + rows, err := s.DB.NamedQuery(sql, map[string]interface{}{ + "interval": interval, + "symbol": symbol, + }) + defer rows.Close() + + if err != nil { + return nil, errors.Wrap(err, "query kline error") + } + + if rows.Err() != nil { + return nil, rows.Err() + } + + if rows.Next() { + var kline types.KLine + err = rows.StructScan(&kline) + return &kline, err + } + + return nil, rows.Err() +} + +// QueryKLinesForward is used for querying klines to back-testing +func (s *BacktestService) QueryKLinesForward( + exchange types.Exchange, symbol string, interval types.Interval, startTime time.Time, limit int, +) ([]types.KLine, error) { + tableName := targetKlineTable(exchange) + sql := "SELECT * FROM `binance_klines` WHERE `end_time` >= :start_time AND `symbol` = :symbol AND `interval` = :interval and exchange = :exchange ORDER BY end_time ASC LIMIT :limit" + sql = strings.ReplaceAll(sql, "binance_klines", tableName) + + rows, err := s.DB.NamedQuery(sql, map[string]interface{}{ + "start_time": startTime, + "limit": limit, + "symbol": symbol, + "interval": interval, + "exchange": exchange.Name().String(), + }) + if err != nil { + return nil, err + } + + return s.scanRows(rows) +} + +func (s *BacktestService) QueryKLinesBackward( + exchange types.Exchange, symbol string, interval types.Interval, endTime time.Time, limit int, +) ([]types.KLine, error) { + tableName := targetKlineTable(exchange) + + sql := "SELECT * FROM `binance_klines` WHERE `end_time` <= :end_time and exchange = :exchange AND `symbol` = :symbol AND `interval` = :interval ORDER BY end_time DESC LIMIT :limit" + sql = strings.ReplaceAll(sql, "binance_klines", tableName) + sql = "SELECT t.* FROM (" + sql + ") AS t ORDER BY t.end_time ASC" + + rows, err := s.DB.NamedQuery(sql, map[string]interface{}{ + "limit": limit, + "end_time": endTime, + "symbol": symbol, + "interval": interval, + "exchange": exchange.Name().String(), + }) + if err != nil { + return nil, err + } + + return s.scanRows(rows) +} + +func (s *BacktestService) QueryKLinesCh( + since, until time.Time, exchange types.Exchange, symbols []string, intervals []types.Interval, +) (chan types.KLine, chan error) { + if len(symbols) == 0 { + return returnError(errors.Errorf("symbols is empty when querying kline, plesae check your strategy setting. ")) + } + + tableName := targetKlineTable(exchange) + var query string + + // need to sort by start_time desc in order to let matching engine process 1m first + // otherwise any other close event could peek on the final close price + if len(symbols) == 1 { + query = "SELECT * FROM `binance_klines` WHERE `end_time` BETWEEN :since AND :until AND `symbol` = :symbols AND `interval` IN (:intervals) ORDER BY end_time ASC, start_time DESC" + } else { + query = "SELECT * FROM `binance_klines` WHERE `end_time` BETWEEN :since AND :until AND `symbol` IN (:symbols) AND `interval` IN (:intervals) ORDER BY end_time ASC, start_time DESC" + } + + query = strings.ReplaceAll(query, "binance_klines", tableName) + + sql, args, err := sqlx.Named(query, map[string]interface{}{ + "since": since, + "until": until, + "symbol": symbols[0], + "symbols": symbols, + "intervals": types.IntervalSlice(intervals), + }) + + sql, args, err = sqlx.In(sql, args...) + if err != nil { + return returnError(err) + } + sql = s.DB.Rebind(sql) + + rows, err := s.DB.Queryx(sql, args...) + if err != nil { + return returnError(err) + } + + return s.scanRowsCh(rows) +} + +func returnError(err error) (chan types.KLine, chan error) { + ch := make(chan types.KLine) + close(ch) + log.WithError(err).Error("backtest query error") + + errC := make(chan error, 1) + // avoid blocking + go func() { + errC <- err + close(errC) + }() + return ch, errC +} + +// scanRowsCh scan rows into channel +func (s *BacktestService) scanRowsCh(rows *sqlx.Rows) (chan types.KLine, chan error) { + ch := make(chan types.KLine, 500) + errC := make(chan error, 1) + + go func() { + defer close(errC) + defer close(ch) + defer rows.Close() + + for rows.Next() { + var kline types.KLine + if err := rows.StructScan(&kline); err != nil { + errC <- err + return + } + + ch <- kline + } + + if err := rows.Err(); err != nil { + errC <- err + return + } + + }() + + return ch, errC +} + +func (s *BacktestService) scanRows(rows *sqlx.Rows) (klines []types.KLine, err error) { + defer rows.Close() + for rows.Next() { + var kline types.KLine + if err := rows.StructScan(&kline); err != nil { + return nil, err + } + + klines = append(klines, kline) + } + + return klines, rows.Err() +} + +func targetKlineTable(exchange types.Exchange) string { + _, isFutures, _, _ := exchange2.GetSessionAttributes(exchange) + + tableName := strings.ToLower(exchange.Name().String()) + if isFutures { + return tableName + "_futures_klines" + } else { + return tableName + "_klines" + } +} + +var errExchangeFieldIsUnset = errors.New("kline.Exchange field should not be empty") + +func (s *BacktestService) Insert(kline types.KLine, ex types.Exchange) error { + if len(kline.Exchange) == 0 { + return errExchangeFieldIsUnset + } + + tableName := targetKlineTable(ex) + + sql := fmt.Sprintf("INSERT INTO `%s` (`exchange`, `start_time`, `end_time`, `symbol`, `interval`, `open`, `high`, `low`, `close`, `closed`, `volume`, `quote_volume`, `taker_buy_base_volume`, `taker_buy_quote_volume`)"+ + "VALUES (:exchange, :start_time, :end_time, :symbol, :interval, :open, :high, :low, :close, :closed, :volume, :quote_volume, :taker_buy_base_volume, :taker_buy_quote_volume)", tableName) + + _, err := s.DB.NamedExec(sql, kline) + return err +} + +// BatchInsert Note: all kline should be same exchange, or it will cause issue. +func (s *BacktestService) BatchInsert(kline []types.KLine, ex types.Exchange) error { + if len(kline) == 0 { + return nil + } + + tableName := targetKlineTable(ex) + + sql := fmt.Sprintf("INSERT INTO `%s` (`exchange`, `start_time`, `end_time`, `symbol`, `interval`, `open`, `high`, `low`, `close`, `closed`, `volume`, `quote_volume`, `taker_buy_base_volume`, `taker_buy_quote_volume`)"+ + " VALUES (:exchange, :start_time, :end_time, :symbol, :interval, :open, :high, :low, :close, :closed, :volume, :quote_volume, :taker_buy_base_volume, :taker_buy_quote_volume); ", tableName) + + tx := s.DB.MustBegin() + if _, err := tx.NamedExec(sql, kline); err != nil { + if e := tx.Rollback(); e != nil { + log.WithError(e).Fatalf("cannot rollback insertion %v", err) + } + return err + } + return tx.Commit() +} + +type TimeRange struct { + Start time.Time + End time.Time +} + +func (t *TimeRange) String() string { + return t.Start.String() + " ~ " + t.End.String() +} + +func (s *BacktestService) Sync( + ctx context.Context, ex types.Exchange, symbol string, interval types.Interval, since, until time.Time, +) error { + t1, t2, err := s.QueryExistingDataRange(ctx, ex, symbol, interval, since, until) + if err != nil && err != sql.ErrNoRows { + return err + } + + if err == sql.ErrNoRows || t1 == nil || t2 == nil { + // fallback to fresh sync + return s.SyncFresh(ctx, ex, symbol, interval, since, until) + } + + return s.SyncPartial(ctx, ex, symbol, interval, since, until) +} + +// SyncPartial +// find the existing data time range (t1, t2) +// scan if there is a missing part +// create a time range slice []TimeRange +// iterate the []TimeRange slice to sync data. +func (s *BacktestService) SyncPartial( + ctx context.Context, ex types.Exchange, symbol string, interval types.Interval, since, until time.Time, +) error { + log.Infof("starting partial sync %s %s %s: %s <=> %s", ex.Name(), symbol, interval, since, until) + + t1, t2, err := s.QueryExistingDataRange(ctx, ex, symbol, interval, since, until) + if err != nil && err != sql.ErrNoRows { + return err + } + + if err == sql.ErrNoRows || t1 == nil || t2 == nil { + // fallback to fresh sync + return s.SyncFresh(ctx, ex, symbol, interval, since, until) + } + + timeRanges, err := s.FindMissingTimeRanges(ctx, ex, symbol, interval, t1.Time(), t2.Time()) + if err != nil { + return err + } + + if len(timeRanges) > 0 { + log.Infof("found missing data time ranges: %v", timeRanges) + } + + // there are few cases: + // t1 == since && t2 == until + // [since] ------- [t1] data [t2] ------ [until] + if since.Before(t1.Time()) && t1.Time().Sub(since) > interval.Duration() { + // shift slice + timeRanges = append([]TimeRange{ + {Start: since.Add(-2 * time.Second), End: t1.Time()}, // we should include since + }, timeRanges...) + } + + if t2.Time().Before(until) && until.Sub(t2.Time()) > interval.Duration() { + timeRanges = append(timeRanges, TimeRange{ + Start: t2.Time(), + End: until.Add(-interval.Duration()), // include until + }) + } + + for _, timeRange := range timeRanges { + err = s.SyncKLineByInterval(ctx, ex, symbol, interval, timeRange.Start.Add(time.Second), timeRange.End.Add(-time.Second)) + if err != nil { + return err + } + } + + return nil +} + +// FindMissingTimeRanges returns the missing time ranges, the start/end time represents the existing data time points. +// So when sending kline query to the exchange API, we need to add one second to the start time and minus one second to the end time. +func (s *BacktestService) FindMissingTimeRanges( + ctx context.Context, ex types.Exchange, symbol string, interval types.Interval, since, until time.Time, +) ([]TimeRange, error) { + query := s.SelectKLineTimePoints(ex, symbol, interval, since, until) + sql, args, err := query.ToSql() + if err != nil { + return nil, err + } + + rows, err := s.DB.QueryContext(ctx, sql, args...) + defer rows.Close() + if err != nil { + return nil, err + } + + var timeRanges []TimeRange + var lastTime = since + var intervalDuration = interval.Duration() + for rows.Next() { + var tt types.Time + if err := rows.Scan(&tt); err != nil { + return nil, err + } + + var t = time.Time(tt) + if t.Sub(lastTime) > intervalDuration { + timeRanges = append(timeRanges, TimeRange{ + Start: lastTime, + End: t, + }) + } + + lastTime = t + } + + if lastTime.Before(until) && until.Sub(lastTime) > intervalDuration { + timeRanges = append(timeRanges, TimeRange{ + Start: lastTime, + End: until, + }) + } + + return timeRanges, nil +} + +func (s *BacktestService) QueryExistingDataRange( + ctx context.Context, ex types.Exchange, symbol string, interval types.Interval, tArgs ...time.Time, +) (start, end *types.Time, err error) { + sel := s.SelectKLineTimeRange(ex, symbol, interval, tArgs...) + sql, args, err := sel.ToSql() + if err != nil { + return nil, nil, err + } + + var t1, t2 types.Time + + row := s.DB.QueryRowContext(ctx, sql, args...) + + if err := row.Scan(&t1, &t2); err != nil { + return nil, nil, err + } + + if err := row.Err(); err != nil { + return nil, nil, err + } + + if t1.Time().IsZero() || t2.Time().IsZero() { + return nil, nil, nil + } + + return &t1, &t2, nil +} + +func (s *BacktestService) SelectKLineTimePoints( + ex types.Exchange, symbol string, interval types.Interval, args ...time.Time, +) sq.SelectBuilder { + conditions := sq.And{ + sq.Eq{"symbol": symbol}, + sq.Eq{"`interval`": interval.String()}, + } + + if len(args) == 2 { + since := args[0] + until := args[1] + conditions = append(conditions, sq.Expr("`start_time` BETWEEN ? AND ?", since, until)) + } + + tableName := targetKlineTable(ex) + + return sq.Select("start_time"). + From(tableName). + Where(conditions). + OrderBy("start_time ASC") +} + +// SelectKLineTimeRange returns the existing klines time range (since < kline.start_time < until) +func (s *BacktestService) SelectKLineTimeRange( + ex types.Exchange, symbol string, interval types.Interval, args ...time.Time, +) sq.SelectBuilder { + conditions := sq.And{ + sq.Eq{"symbol": symbol}, + sq.Eq{"`interval`": interval.String()}, + } + + if len(args) == 2 { + // NOTE + // sqlite does not support timezone format, so we are converting to local timezone + // mysql works in this case, so this is a workaround + since := args[0] + until := args[1] + conditions = append(conditions, sq.Expr("`start_time` BETWEEN ? AND ?", since, until)) + } + + tableName := targetKlineTable(ex) + + return sq.Select("MIN(start_time) AS t1, MAX(start_time) AS t2"). + From(tableName). + Where(conditions) +} + +// TODO: add is_futures column since the klines data is different +func (s *BacktestService) SelectLastKLines( + ex types.Exchange, symbol string, interval types.Interval, startTime, endTime time.Time, limit uint64, +) sq.SelectBuilder { + tableName := targetKlineTable(ex) + return sq.Select("*"). + From(tableName). + Where(sq.And{ + sq.Eq{"symbol": symbol}, + sq.Eq{"`interval`": interval.String()}, + sq.Expr("start_time BETWEEN ? AND ?", startTime, endTime), + }). + OrderBy("start_time DESC"). + Limit(limit) +} diff --git a/pkg/service/backtest_test.go b/pkg/service/backtest_test.go new file mode 100644 index 0000000..f46dbd1 --- /dev/null +++ b/pkg/service/backtest_test.go @@ -0,0 +1,187 @@ +package service + +import ( + "context" + "database/sql" + "os" + "strconv" + "testing" + "time" + + "github.com/jmoiron/sqlx" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func TestBacktestService_FindMissingTimeRanges_EmptyData(t *testing.T) { + if b, _ := strconv.ParseBool(os.Getenv("CI")); b { + t.Skip("skip test for CI") + } + + db, err := prepareDB(t) + if err != nil { + t.Fatal(err) + } + + defer db.Close() + + ctx := context.Background() + dbx := sqlx.NewDb(db.DB, "sqlite3") + + ex, err := exchange.NewPublic(types.ExchangeBinance) + assert.NoError(t, err) + + service := &BacktestService{DB: dbx} + + symbol := "BTCUSDT" + now := time.Now() + startTime1 := now.AddDate(0, 0, -7).Truncate(time.Hour) + endTime1 := now.AddDate(0, 0, -6).Truncate(time.Hour) + timeRanges, err := service.FindMissingTimeRanges(ctx, ex, symbol, types.Interval1h, startTime1, endTime1) + assert.NoError(t, err) + assert.NotEmpty(t, timeRanges) +} + +func TestBacktestService_QueryExistingDataRange(t *testing.T) { + if b, _ := strconv.ParseBool(os.Getenv("CI")); b { + t.Skip("skip test for CI") + } + + db, err := prepareDB(t) + if err != nil { + t.Fatal(err) + } + + defer db.Close() + + ctx := context.Background() + dbx := sqlx.NewDb(db.DB, "sqlite3") + + ex, err := exchange.NewPublic(types.ExchangeBinance) + assert.NoError(t, err) + + service := &BacktestService{DB: dbx} + + symbol := "BTCUSDT" + now := time.Now() + startTime1 := now.AddDate(0, 0, -7).Truncate(time.Hour) + endTime1 := now.AddDate(0, 0, -6).Truncate(time.Hour) + // empty range + t1, t2, err := service.QueryExistingDataRange(ctx, ex, symbol, types.Interval1h, startTime1, endTime1) + assert.Error(t, sql.ErrNoRows, err) + assert.Nil(t, t1) + assert.Nil(t, t2) +} + +func TestBacktestService_SyncPartial(t *testing.T) { + if b, _ := strconv.ParseBool(os.Getenv("CI")); b { + t.Skip("skip test for CI") + } + + db, err := prepareDB(t) + if err != nil { + t.Fatal(err) + } + + defer db.Close() + + ctx := context.Background() + dbx := sqlx.NewDb(db.DB, "sqlite3") + + ex, err := exchange.NewPublic(types.ExchangeBinance) + assert.NoError(t, err) + + service := &BacktestService{DB: dbx} + + symbol := "BTCUSDT" + now := time.Now() + startTime1 := now.AddDate(0, 0, -7).Truncate(time.Hour) + endTime1 := now.AddDate(0, 0, -6).Truncate(time.Hour) + + startTime2 := now.AddDate(0, 0, -5).Truncate(time.Hour) + endTime2 := now.AddDate(0, 0, -4).Truncate(time.Hour) + + // kline query is exclusive + err = service.SyncKLineByInterval(ctx, ex, symbol, types.Interval1h, startTime1.Add(-time.Second), endTime1.Add(time.Second)) + assert.NoError(t, err) + + err = service.SyncKLineByInterval(ctx, ex, symbol, types.Interval1h, startTime2.Add(-time.Second), endTime2.Add(time.Second)) + assert.NoError(t, err) + + timeRanges, err := service.FindMissingTimeRanges(ctx, ex, symbol, types.Interval1h, startTime1, endTime2) + assert.NoError(t, err) + assert.NotEmpty(t, timeRanges) + assert.Len(t, timeRanges, 1) + + t.Run("fill missing time ranges", func(t *testing.T) { + err = service.SyncPartial(ctx, ex, symbol, types.Interval1h, startTime1, endTime2) + assert.NoError(t, err, "sync partial should not return error") + + timeRanges2, err := service.FindMissingTimeRanges(ctx, ex, symbol, types.Interval1h, startTime1, endTime2) + assert.NoError(t, err) + assert.Empty(t, timeRanges2) + }) +} + +func TestBacktestService_FindMissingTimeRanges(t *testing.T) { + if b, _ := strconv.ParseBool(os.Getenv("CI")); b { + t.Skip("skip test for CI") + } + + db, err := prepareDB(t) + if err != nil { + t.Fatal(err) + } + + defer db.Close() + + ctx := context.Background() + dbx := sqlx.NewDb(db.DB, "sqlite3") + + ex, err := exchange.NewPublic(types.ExchangeBinance) + assert.NoError(t, err) + + service := &BacktestService{DB: dbx} + + symbol := "BTCUSDT" + now := time.Now() + startTime1 := now.AddDate(0, 0, -6).Truncate(time.Hour) + endTime1 := now.AddDate(0, 0, -5).Truncate(time.Hour) + + startTime2 := now.AddDate(0, 0, -4).Truncate(time.Hour) + endTime2 := now.AddDate(0, 0, -3).Truncate(time.Hour) + + // kline query is exclusive + err = service.SyncKLineByInterval(ctx, ex, symbol, types.Interval1h, startTime1.Add(-time.Second), endTime1.Add(time.Second)) + assert.NoError(t, err) + + err = service.SyncKLineByInterval(ctx, ex, symbol, types.Interval1h, startTime2.Add(-time.Second), endTime2.Add(time.Second)) + assert.NoError(t, err) + + t1, t2, err := service.QueryExistingDataRange(ctx, ex, symbol, types.Interval1h) + if assert.NoError(t, err) { + assert.Equal(t, startTime1, t1.Time(), "start time point should match") + assert.Equal(t, endTime2, t2.Time(), "end time point should match") + } + + timeRanges, err := service.FindMissingTimeRanges(ctx, ex, symbol, types.Interval1h, startTime1, endTime2) + if assert.NoError(t, err) { + assert.NotEmpty(t, timeRanges) + assert.Len(t, timeRanges, 1, "should find one missing time range") + t.Logf("found timeRanges: %+v", timeRanges) + + log.SetLevel(log.DebugLevel) + + for _, timeRange := range timeRanges { + err = service.SyncKLineByInterval(ctx, ex, symbol, types.Interval1h, timeRange.Start.Add(time.Second), timeRange.End.Add(-time.Second)) + assert.NoError(t, err) + } + + timeRanges, err = service.FindMissingTimeRanges(ctx, ex, symbol, types.Interval1h, startTime1, endTime2) + assert.NoError(t, err) + assert.Empty(t, timeRanges, "after partial sync, missing time ranges should be back-filled") + } +} diff --git a/pkg/service/database.go b/pkg/service/database.go new file mode 100644 index 0000000..ac98c61 --- /dev/null +++ b/pkg/service/database.go @@ -0,0 +1,103 @@ +package service + +import ( + "context" + + "github.com/go-sql-driver/mysql" + "github.com/jmoiron/sqlx" + + "github.com/c9s/rockhopper/v2" + + mysqlMigrations "git.qtrade.icu/lychiyu/qbtrade/pkg/migrations/mysql" + sqlite3Migrations "git.qtrade.icu/lychiyu/qbtrade/pkg/migrations/sqlite3" +) + +// reflect cache for database +var dbCache = NewReflectCache() + +type DatabaseService struct { + Driver string + DSN string + DB *sqlx.DB + + migrationPackages []string +} + +func NewDatabaseService(driver, dsn string) *DatabaseService { + if driver == "mysql" { + var err error + dsn, err = ReformatMysqlDSN(dsn) + if err != nil { + // incorrect mysql dsn is logical exception + panic(err) + } + } + + return &DatabaseService{ + Driver: driver, + DSN: dsn, + } +} + +func (s *DatabaseService) Connect() error { + var err error + s.DB, err = sqlx.Connect(s.Driver, s.DSN) + return err +} + +func (s *DatabaseService) Insert(record interface{}) error { + sql := dbCache.InsertSqlOf(record) + _, err := s.DB.NamedExec(sql, record) + return err +} + +func (s *DatabaseService) AddMigrationPackages(pkgNames ...string) { + s.migrationPackages = append(s.migrationPackages, pkgNames...) +} + +func (s *DatabaseService) Close() error { + return s.DB.Close() +} + +func (s *DatabaseService) Upgrade(ctx context.Context) error { + dialect, err := rockhopper.LoadDialect(s.Driver) + if err != nil { + return err + } + + var migrations rockhopper.MigrationSlice + + switch s.Driver { + case "sqlite3": + migrations = sqlite3Migrations.Migrations() + case "mysql": + migrations = mysqlMigrations.Migrations() + + } + + // sqlx.DB is different from sql.DB + rh := rockhopper.New(s.Driver, dialect, s.DB.DB, rockhopper.TableName) + + if err := rh.Touch(ctx); err != nil { + return err + } + + if len(migrations) == 0 { + return nil + } + + pkgNames := append([]string{rockhopper.DefaultPackageName}, s.migrationPackages...) + return rockhopper.Upgrade(ctx, rh, migrations.FilterPackage(pkgNames)) +} + +func ReformatMysqlDSN(dsn string) (string, error) { + config, err := mysql.ParseDSN(dsn) + if err != nil { + return "", err + } + + // we need timestamp and datetime fields to be parsed into time.Time struct + config.ParseTime = true + dsn = config.FormatDSN() + return dsn, nil +} diff --git a/pkg/service/db_test.go b/pkg/service/db_test.go new file mode 100644 index 0000000..8c205d3 --- /dev/null +++ b/pkg/service/db_test.go @@ -0,0 +1,48 @@ +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/c9s/rockhopper/v2" +) + +func prepareDB(t *testing.T) (*rockhopper.DB, error) { + ctx := context.Background() + + dialect, err := rockhopper.LoadDialect("sqlite3") + if !assert.NoError(t, err) { + return nil, err + } + + assert.NotNil(t, dialect) + + db, err := rockhopper.Open("sqlite3", dialect, ":memory:", rockhopper.TableName) + if !assert.NoError(t, err) { + return nil, err + } + + assert.NotNil(t, db) + + err = db.Touch(ctx) + if !assert.NoError(t, err) { + return nil, err + } + + var loader = &rockhopper.SqlMigrationLoader{} + + migrations, err := loader.Load("../../migrations/sqlite3") + if !assert.NoError(t, err) { + return nil, err + } + + migrations = migrations.Sort().Connect() + assert.NotEmpty(t, migrations) + + err = rockhopper.Up(ctx, db, migrations.Head(), 0) + assert.NoError(t, err, "should migrate successfully") + + return db, err +} diff --git a/pkg/service/deposit.go b/pkg/service/deposit.go new file mode 100644 index 0000000..bdac3dc --- /dev/null +++ b/pkg/service/deposit.go @@ -0,0 +1,102 @@ +package service + +import ( + "context" + "time" + + sq "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/batch" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type DepositService struct { + DB *sqlx.DB +} + +// Sync syncs the withdraw records into db +func (s *DepositService) Sync(ctx context.Context, ex types.Exchange, startTime time.Time) error { + isMargin, isFutures, isIsolated, _ := exchange.GetSessionAttributes(ex) + if isMargin || isFutures || isIsolated { + // only works in spot + return nil + } + + transferApi, ok := ex.(types.ExchangeTransferService) + if !ok { + return nil + } + + tasks := []SyncTask{ + { + Type: types.Deposit{}, + Select: SelectLastDeposits(ex.Name(), 100), + BatchQuery: func(ctx context.Context, startTime, endTime time.Time) (interface{}, chan error) { + query := &batch.DepositBatchQuery{ + ExchangeTransferService: transferApi, + } + return query.Query(ctx, "", startTime, endTime) + }, + Time: func(obj interface{}) time.Time { + return obj.(types.Deposit).Time.Time() + }, + ID: func(obj interface{}) string { + deposit := obj.(types.Deposit) + return deposit.TransactionID + }, + Filter: func(obj interface{}) bool { + deposit := obj.(types.Deposit) + return len(deposit.TransactionID) != 0 + }, + LogInsert: true, + }, + } + + for _, sel := range tasks { + if err := sel.execute(ctx, s.DB, startTime); err != nil { + return err + } + } + + return nil +} + +func (s *DepositService) Query(exchangeName types.ExchangeName) ([]types.Deposit, error) { + args := map[string]interface{}{ + "exchange": exchangeName, + } + sql := "SELECT * FROM `deposits` WHERE `exchange` = :exchange ORDER BY `time` ASC" + rows, err := s.DB.NamedQuery(sql, args) + if err != nil { + return nil, err + } + + defer rows.Close() + + return s.scanRows(rows) +} + +func (s *DepositService) scanRows(rows *sqlx.Rows) (deposits []types.Deposit, err error) { + for rows.Next() { + var deposit types.Deposit + if err := rows.StructScan(&deposit); err != nil { + return deposits, err + } + + deposits = append(deposits, deposit) + } + + return deposits, rows.Err() +} + +func SelectLastDeposits(ex types.ExchangeName, limit uint64) sq.SelectBuilder { + return sq.Select("*"). + From("deposits"). + Where(sq.And{ + sq.Eq{"exchange": ex}, + }). + OrderBy("time DESC"). + Limit(limit) +} diff --git a/pkg/service/deposit_test.go b/pkg/service/deposit_test.go new file mode 100644 index 0000000..6d43c33 --- /dev/null +++ b/pkg/service/deposit_test.go @@ -0,0 +1 @@ +package service diff --git a/pkg/service/errors.go b/pkg/service/errors.go new file mode 100644 index 0000000..516301d --- /dev/null +++ b/pkg/service/errors.go @@ -0,0 +1,5 @@ +package service + +import "github.com/pkg/errors" + +var ErrPersistenceNotExists = errors.New("persistent data does not exists") diff --git a/pkg/service/google/sheets.go b/pkg/service/google/sheets.go new file mode 100644 index 0000000..375a611 --- /dev/null +++ b/pkg/service/google/sheets.go @@ -0,0 +1,178 @@ +package google + +import ( + "context" + "fmt" + "reflect" + "time" + + "github.com/sirupsen/logrus" + "google.golang.org/api/option" + "google.golang.org/api/sheets/v4" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +var log = logrus.WithField("service", "google") + +type SpreadSheetService struct { + SpreadsheetID string + TokenFile string + + service *sheets.Service +} + +func NewSpreadSheetService(ctx context.Context, tokenFile string, spreadsheetID string) *SpreadSheetService { + if len(tokenFile) == 0 { + log.Panicf("google.SpreadSheetService: jsonTokenFile is not set") + } + + srv, err := sheets.NewService(ctx, + option.WithCredentialsFile(tokenFile), + ) + + if err != nil { + log.Panicf("google.SpreadSheetService: unable to initialize spreadsheet service: %v", err) + } + + return &SpreadSheetService{ + SpreadsheetID: spreadsheetID, + service: srv, + } +} + +func ReadSheetValuesRange(srv *sheets.Service, spreadsheetId, readRange string) (*sheets.ValueRange, error) { + log.Infof("ReadSheetValuesRange: %s", readRange) + resp, err := srv.Spreadsheets.Values.Get(spreadsheetId, readRange).Do() + return resp, err +} + +func AddNewSheet(srv *sheets.Service, spreadsheetId string, title string) (*sheets.BatchUpdateSpreadsheetResponse, error) { + log.Infof("AddNewSheet: %s", title) + return srv.Spreadsheets.BatchUpdate(spreadsheetId, &sheets.BatchUpdateSpreadsheetRequest{ + IncludeSpreadsheetInResponse: false, + Requests: []*sheets.Request{ + { + AddSheet: &sheets.AddSheetRequest{ + Properties: &sheets.SheetProperties{ + Hidden: false, + TabColor: nil, + TabColorStyle: nil, + Title: title, + }, + }, + }, + }, + }).Do() +} + +func ValuesToCellData(values []interface{}) (cells []*sheets.CellData) { + for _, anyValue := range values { + switch typedValue := anyValue.(type) { + case string: + cells = append(cells, &sheets.CellData{ + UserEnteredValue: &sheets.ExtendedValue{StringValue: &typedValue}, + }) + case float64: + cells = append(cells, &sheets.CellData{ + UserEnteredValue: &sheets.ExtendedValue{NumberValue: &typedValue}, + }) + case int: + v := float64(typedValue) + cells = append(cells, &sheets.CellData{UserEnteredValue: &sheets.ExtendedValue{NumberValue: &v}}) + case int64: + v := float64(typedValue) + cells = append(cells, &sheets.CellData{UserEnteredValue: &sheets.ExtendedValue{NumberValue: &v}}) + case bool: + cells = append(cells, &sheets.CellData{ + UserEnteredValue: &sheets.ExtendedValue{BoolValue: &typedValue}, + }) + } + } + + return cells +} + +func GetSpreadSheetURL(spreadsheetId string) string { + return fmt.Sprintf("https://docs.google.com/spreadsheets/d/%s/edit#gid=0", spreadsheetId) +} + +func WriteStructHeader(srv *sheets.Service, spreadsheetId string, sheetId int64, structTag string, st interface{}) (*sheets.BatchUpdateSpreadsheetResponse, error) { + typeOfSt := reflect.TypeOf(st) + typeOfSt = typeOfSt.Elem() + + var headerTexts []interface{} + for i := 0; i < typeOfSt.NumField(); i++ { + tag := typeOfSt.Field(i).Tag + tagValue := tag.Get(structTag) + if len(tagValue) == 0 { + continue + } + + headerTexts = append(headerTexts, tagValue) + } + + return AppendRow(srv, spreadsheetId, sheetId, headerTexts) +} + +func WriteStructValues(srv *sheets.Service, spreadsheetId string, sheetId int64, structTag string, st interface{}) (*sheets.BatchUpdateSpreadsheetResponse, error) { + typeOfSt := reflect.TypeOf(st) + typeOfSt = typeOfSt.Elem() + + valueOfSt := reflect.ValueOf(st) + valueOfSt = valueOfSt.Elem() + + var texts []interface{} + for i := 0; i < typeOfSt.NumField(); i++ { + tag := typeOfSt.Field(i).Tag + tagValue := tag.Get(structTag) + if len(tagValue) == 0 { + continue + } + + valueInf := valueOfSt.Field(i).Interface() + + switch typedValue := valueInf.(type) { + case string: + texts = append(texts, typedValue) + case float64: + texts = append(texts, typedValue) + case int64: + texts = append(texts, typedValue) + case *float64: + texts = append(texts, typedValue) + case fixedpoint.Value: + texts = append(texts, typedValue.String()) + case *fixedpoint.Value: + texts = append(texts, typedValue.String()) + case time.Time: + texts = append(texts, typedValue.Format(time.RFC3339)) + } + } + + return AppendRow(srv, spreadsheetId, sheetId, texts) +} + +func AppendRow(srv *sheets.Service, spreadsheetId string, sheetId int64, values []interface{}) (*sheets.BatchUpdateSpreadsheetResponse, error) { + row := &sheets.RowData{} + row.Values = ValuesToCellData(values) + + log.Infof("AppendRow: %+v", row.Values) + return srv.Spreadsheets.BatchUpdate(spreadsheetId, &sheets.BatchUpdateSpreadsheetRequest{ + Requests: []*sheets.Request{ + { + AppendCells: &sheets.AppendCellsRequest{ + Fields: "*", + Rows: []*sheets.RowData{row}, + SheetId: sheetId, + }, + }, + }, + }).Do() +} + +func DebugBatchUpdateSpreadsheetResponse(resp *sheets.BatchUpdateSpreadsheetResponse) { + log.Infof("BatchUpdateSpreadsheetResponse.SpreadsheetId: %+v", resp.SpreadsheetId) + log.Infof("BatchUpdateSpreadsheetResponse.UpdatedSpreadsheet: %+v", resp.UpdatedSpreadsheet) + log.Infof("BatchUpdateSpreadsheetResponse.Replies: %+v", resp.Replies) +} diff --git a/pkg/service/margin.go b/pkg/service/margin.go new file mode 100644 index 0000000..900f7af --- /dev/null +++ b/pkg/service/margin.go @@ -0,0 +1,147 @@ +package service + +import ( + "context" + "strconv" + "time" + + sq "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/batch" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type MarginService struct { + DB *sqlx.DB +} + +func (s *MarginService) Sync(ctx context.Context, ex types.Exchange, asset string, startTime time.Time) error { + api, ok := ex.(types.MarginHistoryService) + if !ok { + return nil + } + + marginExchange, ok := ex.(types.MarginExchange) + if !ok { + return nil + } + + marginSettings := marginExchange.GetMarginSettings() + if !marginSettings.IsMargin { + return nil + } + + tasks := []SyncTask{ + { + Select: SelectLastMarginLoans(ex.Name(), asset, 100), + Type: types.MarginLoan{}, + BatchQuery: func(ctx context.Context, startTime, endTime time.Time) (interface{}, chan error) { + query := &batch.MarginLoanBatchQuery{ + MarginHistoryService: api, + } + return query.Query(ctx, asset, startTime, endTime) + }, + Time: func(obj interface{}) time.Time { + return obj.(types.MarginLoan).Time.Time() + }, + ID: func(obj interface{}) string { + return strconv.FormatUint(obj.(types.MarginLoan).TransactionID, 10) + }, + LogInsert: true, + }, + { + Select: SelectLastMarginRepays(ex.Name(), asset, 100), + Type: types.MarginRepay{}, + BatchQuery: func(ctx context.Context, startTime, endTime time.Time) (interface{}, chan error) { + query := &batch.MarginRepayBatchQuery{ + MarginHistoryService: api, + } + return query.Query(ctx, asset, startTime, endTime) + }, + Time: func(obj interface{}) time.Time { + return obj.(types.MarginRepay).Time.Time() + }, + ID: func(obj interface{}) string { + return strconv.FormatUint(obj.(types.MarginRepay).TransactionID, 10) + }, + LogInsert: true, + }, + { + Select: SelectLastMarginInterests(ex.Name(), asset, 100), + Type: types.MarginInterest{}, + BatchQuery: func(ctx context.Context, startTime, endTime time.Time) (interface{}, chan error) { + query := &batch.MarginInterestBatchQuery{ + MarginHistoryService: api, + } + return query.Query(ctx, asset, startTime, endTime) + }, + Time: func(obj interface{}) time.Time { + return obj.(types.MarginInterest).Time.Time() + }, + ID: func(obj interface{}) string { + m := obj.(types.MarginInterest) + return m.Asset + m.IsolatedSymbol + strconv.FormatInt(m.Time.UnixMilli(), 10) + }, + LogInsert: true, + }, + { + Select: SelectLastMarginLiquidations(ex.Name(), 100), + Type: types.MarginLiquidation{}, + BatchQuery: func(ctx context.Context, startTime, endTime time.Time) (interface{}, chan error) { + query := &batch.MarginLiquidationBatchQuery{ + MarginHistoryService: api, + } + return query.Query(ctx, startTime, endTime) + }, + Time: func(obj interface{}) time.Time { + return obj.(types.MarginLiquidation).UpdatedTime.Time() + }, + ID: func(obj interface{}) string { + m := obj.(types.MarginLiquidation) + return strconv.FormatUint(m.OrderID, 10) + }, + LogInsert: true, + }, + } + + for _, sel := range tasks { + if err := sel.execute(ctx, s.DB, startTime); err != nil { + return err + } + } + + return nil +} + +func SelectLastMarginLoans(ex types.ExchangeName, asset string, limit uint64) sq.SelectBuilder { + return sq.Select("*"). + From("margin_loans"). + Where(sq.Eq{"exchange": ex, "asset": asset}). + OrderBy("time DESC"). + Limit(limit) +} + +func SelectLastMarginRepays(ex types.ExchangeName, asset string, limit uint64) sq.SelectBuilder { + return sq.Select("*"). + From("margin_repays"). + Where(sq.Eq{"exchange": ex, "asset": asset}). + OrderBy("time DESC"). + Limit(limit) +} + +func SelectLastMarginInterests(ex types.ExchangeName, asset string, limit uint64) sq.SelectBuilder { + return sq.Select("*"). + From("margin_interests"). + Where(sq.Eq{"exchange": ex, "asset": asset}). + OrderBy("time DESC"). + Limit(limit) +} + +func SelectLastMarginLiquidations(ex types.ExchangeName, limit uint64) sq.SelectBuilder { + return sq.Select("*"). + From("margin_liquidations"). + Where(sq.Eq{"exchange": ex}). + OrderBy("time DESC"). + Limit(limit) +} diff --git a/pkg/service/margin_test.go b/pkg/service/margin_test.go new file mode 100644 index 0000000..4415ead --- /dev/null +++ b/pkg/service/margin_test.go @@ -0,0 +1,52 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/jmoiron/sqlx" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/binance" + "git.qtrade.icu/lychiyu/qbtrade/pkg/testutil" +) + +func TestMarginService(t *testing.T) { + key, secret, ok := testutil.IntegrationTestConfigured(t, "BINANCE") + if !ok { + t.SkipNow() + return + } + + ex := binance.New(key, secret) + ex.MarginSettings.IsMargin = true + ex.MarginSettings.IsIsolatedMargin = true + ex.MarginSettings.IsolatedMarginSymbol = "DOTUSDT" + + logrus.SetLevel(logrus.ErrorLevel) + db, err := prepareDB(t) + + assert.NoError(t, err) + + if err != nil { + t.Fail() + return + } + + defer db.Close() + + ctx := context.Background() + + dbx := sqlx.NewDb(db.DB, "sqlite3") + service := &MarginService{DB: dbx} + + logrus.SetLevel(logrus.DebugLevel) + err = service.Sync(ctx, ex, "USDT", time.Date(2022, time.February, 1, 0, 0, 0, 0, time.UTC)) + assert.NoError(t, err) + + // sync second time to ensure that we can query records + err = service.Sync(ctx, ex, "USDT", time.Date(2022, time.February, 1, 0, 0, 0, 0, time.UTC)) + assert.NoError(t, err) +} diff --git a/pkg/service/memory.go b/pkg/service/memory.go new file mode 100644 index 0000000..efa0716 --- /dev/null +++ b/pkg/service/memory.go @@ -0,0 +1,62 @@ +package service + +import ( + "reflect" + "strings" + "sync" +) + +type MemoryService struct { + Slots map[string]interface{} +} + +func NewMemoryService() *MemoryService { + return &MemoryService{ + Slots: make(map[string]interface{}), + } +} + +func (s *MemoryService) NewStore(id string, subIDs ...string) Store { + key := strings.Join(append([]string{id}, subIDs...), ":") + return &MemoryStore{ + Key: key, + memory: s, + } +} + +type MemoryStore struct { + Key string + memory *MemoryService + mu sync.Mutex +} + +func (store *MemoryStore) Save(val interface{}) error { + store.mu.Lock() + defer store.mu.Unlock() + + store.memory.Slots[store.Key] = val + return nil +} + +func (store *MemoryStore) Load(val interface{}) error { + store.mu.Lock() + defer store.mu.Unlock() + + v := reflect.ValueOf(val) + if data, ok := store.memory.Slots[store.Key]; ok { + dataRV := reflect.ValueOf(data) + v.Elem().Set(dataRV) + } else { + return ErrPersistenceNotExists + } + + return nil +} + +func (store *MemoryStore) Reset() error { + store.mu.Lock() + defer store.mu.Unlock() + + delete(store.memory.Slots, store.Key) + return nil +} diff --git a/pkg/service/memory_test.go b/pkg/service/memory_test.go new file mode 100644 index 0000000..e6106d7 --- /dev/null +++ b/pkg/service/memory_test.go @@ -0,0 +1,33 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMemoryService(t *testing.T) { + t.Run("load_empty", func(t *testing.T) { + service := NewMemoryService() + store := service.NewStore("test") + + j := 0 + err := store.Load(&j) + assert.Error(t, err) + }) + + t.Run("save_and_load", func(t *testing.T) { + service := NewMemoryService() + store := service.NewStore("test") + + i := 3 + err := store.Save(i) + + assert.NoError(t, err) + + var j = 0 + err = store.Load(&j) + assert.NoError(t, err) + assert.Equal(t, i, j) + }) +} diff --git a/pkg/service/order.go b/pkg/service/order.go new file mode 100644 index 0000000..2750756 --- /dev/null +++ b/pkg/service/order.go @@ -0,0 +1,211 @@ +package service + +import ( + "context" + "strconv" + "strings" + "time" + + sq "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" + log "github.com/sirupsen/logrus" + + exchange2 "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/batch" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type OrderService struct { + DB *sqlx.DB +} + +func (s *OrderService) Sync(ctx context.Context, exchange types.Exchange, symbol string, startTime time.Time) error { + isMargin, isFutures, isIsolated, isolatedSymbol := exchange2.GetSessionAttributes(exchange) + // override symbol if isolatedSymbol is not empty + if isIsolated && len(isolatedSymbol) > 0 { + symbol = isolatedSymbol + } + + api, ok := exchange.(types.ExchangeTradeHistoryService) + if !ok { + return nil + } + + lastOrderID := uint64(0) + tasks := []SyncTask{ + { + Type: types.Order{}, + Time: func(obj interface{}) time.Time { + return obj.(types.Order).CreationTime.Time() + }, + ID: func(obj interface{}) string { + order := obj.(types.Order) + return strconv.FormatUint(order.OrderID, 10) + }, + Select: SelectLastOrders(exchange.Name(), symbol, isMargin, isFutures, isIsolated, 100), + OnLoad: func(objs interface{}) { + // update last order ID + orders := objs.([]types.Order) + if len(orders) > 0 { + end := len(orders) - 1 + last := orders[end] + lastOrderID = last.OrderID + } + }, + BatchQuery: func(ctx context.Context, startTime, endTime time.Time) (interface{}, chan error) { + query := &batch.ClosedOrderBatchQuery{ + ExchangeTradeHistoryService: api, + } + + return query.Query(ctx, symbol, startTime, endTime, lastOrderID) + }, + Filter: func(obj interface{}) bool { + // skip canceled and not filled orders + order := obj.(types.Order) + if order.Status == types.OrderStatusCanceled && order.ExecutedQuantity.IsZero() { + return false + } + + return true + }, + Insert: func(obj interface{}) error { + order := obj.(types.Order) + return s.Insert(order) + }, + LogInsert: true, + }, + } + + for _, sel := range tasks { + if err := sel.execute(ctx, s.DB, startTime); err != nil { + return err + } + } + + return nil +} + +func SelectLastOrders(ex types.ExchangeName, symbol string, isMargin, isFutures, isIsolated bool, limit uint64) sq.SelectBuilder { + return sq.Select("*"). + From("orders"). + Where(sq.And{ + sq.Eq{"symbol": symbol}, + sq.Eq{"exchange": ex}, + sq.Eq{"is_margin": isMargin}, + sq.Eq{"is_futures": isFutures}, + sq.Eq{"is_isolated": isIsolated}, + }). + OrderBy("created_at DESC"). + Limit(limit) +} + +type AggOrder struct { + types.Order + AveragePrice *float64 `json:"averagePrice" db:"average_price"` +} + +type QueryOrdersOptions struct { + Exchange types.ExchangeName + Symbol string + LastGID int64 + Ordering string +} + +func (s *OrderService) Query(options QueryOrdersOptions) ([]AggOrder, error) { + sql := genOrderSQL(options) + + rows, err := s.DB.NamedQuery(sql, map[string]interface{}{ + "exchange": options.Exchange, + "symbol": options.Symbol, + "gid": options.LastGID, + }) + if err != nil { + return nil, err + } + + defer rows.Close() + + return s.scanAggRows(rows) +} + +func genOrderSQL(options QueryOrdersOptions) string { + // ascending + ordering := "ASC" + switch v := strings.ToUpper(options.Ordering); v { + case "DESC", "ASC": + ordering = options.Ordering + } + + var where []string + if options.LastGID > 0 { + switch ordering { + case "ASC": + where = append(where, "gid > :gid") + case "DESC": + where = append(where, "gid < :gid") + + } + } + + if len(options.Exchange) > 0 { + where = append(where, "exchange = :exchange") + } + if len(options.Symbol) > 0 { + where = append(where, "symbol = :symbol") + } + + sql := `SELECT orders.*, IFNULL(SUM(t.price * t.quantity)/SUM(t.quantity), orders.price) AS average_price FROM orders` + + ` LEFT JOIN trades AS t ON (t.order_id = orders.order_id)` + if len(where) > 0 { + sql += ` WHERE ` + strings.Join(where, " AND ") + } + sql += ` GROUP BY orders.gid ` + sql += ` ORDER BY orders.gid ` + ordering + sql += ` LIMIT ` + strconv.Itoa(500) + + log.Info(sql) + return sql +} + +func (s *OrderService) scanAggRows(rows *sqlx.Rows) (orders []AggOrder, err error) { + for rows.Next() { + var order AggOrder + if err := rows.StructScan(&order); err != nil { + return nil, err + } + + orders = append(orders, order) + } + + return orders, rows.Err() +} + +func (s *OrderService) scanRows(rows *sqlx.Rows) (orders []types.Order, err error) { + for rows.Next() { + var order types.Order + if err := rows.StructScan(&order); err != nil { + return nil, err + } + + orders = append(orders, order) + } + + return orders, rows.Err() +} + +func (s *OrderService) Insert(order types.Order) (err error) { + if s.DB.DriverName() == "mysql" { + _, err = s.DB.NamedExec(` + INSERT INTO orders (exchange, order_id, client_order_id, order_type, status, symbol, price, stop_price, quantity, executed_quantity, side, is_working, time_in_force, created_at, updated_at, is_margin, is_futures, is_isolated) + VALUES (:exchange, :order_id, :client_order_id, :order_type, :status, :symbol, :price, :stop_price, :quantity, :executed_quantity, :side, :is_working, :time_in_force, :created_at, :updated_at, :is_margin, :is_futures, :is_isolated) + ON DUPLICATE KEY UPDATE status=:status, executed_quantity=:executed_quantity, is_working=:is_working, updated_at=:updated_at`, order) + return err + } + + _, err = s.DB.NamedExec(` + INSERT INTO orders (exchange, order_id, client_order_id, order_type, status, symbol, price, stop_price, quantity, executed_quantity, side, is_working, time_in_force, created_at, updated_at, is_margin, is_futures, is_isolated) + VALUES (:exchange, :order_id, :client_order_id, :order_type, :status, :symbol, :price, :stop_price, :quantity, :executed_quantity, :side, :is_working, :time_in_force, :created_at, :updated_at, :is_margin, :is_futures, :is_isolated) + `, order) + + return err +} diff --git a/pkg/service/order_test.go b/pkg/service/order_test.go new file mode 100644 index 0000000..d7efd53 --- /dev/null +++ b/pkg/service/order_test.go @@ -0,0 +1,24 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_genOrderSQL(t *testing.T) { + t.Run("accept empty options", func(t *testing.T) { + o := QueryOrdersOptions{} + assert.Equal(t, "SELECT orders.*, IFNULL(SUM(t.price * t.quantity)/SUM(t.quantity), orders.price) AS average_price FROM orders LEFT JOIN trades AS t ON (t.order_id = orders.order_id) GROUP BY orders.gid ORDER BY orders.gid ASC LIMIT 500", genOrderSQL(o)) + }) + + t.Run("different ordering ", func(t *testing.T) { + o := QueryOrdersOptions{} + assert.Equal(t, "SELECT orders.*, IFNULL(SUM(t.price * t.quantity)/SUM(t.quantity), orders.price) AS average_price FROM orders LEFT JOIN trades AS t ON (t.order_id = orders.order_id) GROUP BY orders.gid ORDER BY orders.gid ASC LIMIT 500", genOrderSQL(o)) + o.Ordering = "ASC" + assert.Equal(t, "SELECT orders.*, IFNULL(SUM(t.price * t.quantity)/SUM(t.quantity), orders.price) AS average_price FROM orders LEFT JOIN trades AS t ON (t.order_id = orders.order_id) GROUP BY orders.gid ORDER BY orders.gid ASC LIMIT 500", genOrderSQL(o)) + o.Ordering = "DESC" + assert.Equal(t, "SELECT orders.*, IFNULL(SUM(t.price * t.quantity)/SUM(t.quantity), orders.price) AS average_price FROM orders LEFT JOIN trades AS t ON (t.order_id = orders.order_id) GROUP BY orders.gid ORDER BY orders.gid DESC LIMIT 500", genOrderSQL(o)) + }) + +} diff --git a/pkg/service/persistence.go b/pkg/service/persistence.go new file mode 100644 index 0000000..0cd04ac --- /dev/null +++ b/pkg/service/persistence.go @@ -0,0 +1,29 @@ +package service + +import "time" + +type PersistenceService interface { + NewStore(id string, subIDs ...string) Store +} + +type Store interface { + Load(val interface{}) error + Save(val interface{}) error + Reset() error +} + +type Expirable interface { + Expiration() time.Duration +} + +type RedisPersistenceConfig struct { + Host string `yaml:"host" json:"host" env:"REDIS_HOST"` + Port string `yaml:"port" json:"port" env:"REDIS_PORT"` + Password string `yaml:"password,omitempty" json:"password,omitempty" env:"REDIS_PASSWORD"` + DB int `yaml:"db" json:"db" env:"REDIS_DB"` + Namespace string `yaml:"namespace" json:"namespace" env:"REDIS_NAMESPACE"` +} + +type JsonPersistenceConfig struct { + Directory string `yaml:"directory" json:"directory"` +} diff --git a/pkg/service/persistence_facade.go b/pkg/service/persistence_facade.go new file mode 100644 index 0000000..2adce0e --- /dev/null +++ b/pkg/service/persistence_facade.go @@ -0,0 +1,21 @@ +package service + +type PersistenceServiceFacade struct { + Redis *RedisPersistenceService + Json *JsonPersistenceService + Memory *MemoryService +} + +// Get returns the preferred persistence service by fallbacks +// Redis will be preferred at the first position. +func (facade *PersistenceServiceFacade) Get() PersistenceService { + if facade.Redis != nil { + return facade.Redis + } + + if facade.Json != nil { + return facade.Json + } + + return facade.Memory +} diff --git a/pkg/service/persistence_json.go b/pkg/service/persistence_json.go new file mode 100644 index 0000000..3bea745 --- /dev/null +++ b/pkg/service/persistence_json.go @@ -0,0 +1,78 @@ +package service + +import ( + "encoding/json" + "io/ioutil" + "os" + "path/filepath" +) + +type JsonPersistenceService struct { + Directory string +} + +func (s *JsonPersistenceService) NewStore(id string, subIDs ...string) Store { + return &JsonStore{ + ID: id, + Directory: filepath.Join(append([]string{s.Directory}, subIDs...)...), + } +} + +type JsonStore struct { + ID string + Directory string +} + +func (store JsonStore) Reset() error { + if _, err := os.Stat(store.Directory); os.IsNotExist(err) { + return nil + } + + p := filepath.Join(store.Directory, store.ID) + ".json" + if _, err := os.Stat(p); os.IsNotExist(err) { + return nil + } + + return os.Remove(p) +} + +func (store JsonStore) Load(val interface{}) error { + if _, err := os.Stat(store.Directory); os.IsNotExist(err) { + if err2 := os.MkdirAll(store.Directory, 0777); err2 != nil { + return err2 + } + } + + p := filepath.Join(store.Directory, store.ID) + ".json" + + if _, err := os.Stat(p); os.IsNotExist(err) { + return ErrPersistenceNotExists + } + + data, err := ioutil.ReadFile(p) + if err != nil { + return err + } + + if len(data) == 0 { + return ErrPersistenceNotExists + } + + return json.Unmarshal(data, val) +} + +func (store JsonStore) Save(val interface{}) error { + if _, err := os.Stat(store.Directory); os.IsNotExist(err) { + if err2 := os.MkdirAll(store.Directory, 0777); err2 != nil { + return err2 + } + } + + data, err := json.Marshal(val) + if err != nil { + return err + } + + p := filepath.Join(store.Directory, store.ID) + ".json" + return ioutil.WriteFile(p, data, 0666) +} diff --git a/pkg/service/persistence_redis.go b/pkg/service/persistence_redis.go new file mode 100644 index 0000000..8688c42 --- /dev/null +++ b/pkg/service/persistence_redis.go @@ -0,0 +1,112 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "net" + "strings" + "time" + + "github.com/go-redis/redis/v8" + log "github.com/sirupsen/logrus" +) + +var redisLogger = log.WithFields(log.Fields{ + "persistence": "redis", +}) + +type RedisPersistenceService struct { + redis *redis.Client + config *RedisPersistenceConfig +} + +func NewRedisPersistenceService(config *RedisPersistenceConfig) *RedisPersistenceService { + client := redis.NewClient(&redis.Options{ + Addr: net.JoinHostPort(config.Host, config.Port), + // Username: "", // username is only for redis 6.0 + // pragma: allowlist nextline secret + Password: config.Password, // no password set + DB: config.DB, // use default DB + }) + + return &RedisPersistenceService{ + redis: client, + config: config, + } +} + +func (s *RedisPersistenceService) NewStore(id string, subIDs ...string) Store { + if len(subIDs) > 0 { + id += ":" + strings.Join(subIDs, ":") + } + + if s.config != nil && s.config.Namespace != "" { + id = s.config.Namespace + ":" + id + } + + return &RedisStore{ + redis: s.redis, + ID: id, + } +} + +type RedisStore struct { + redis *redis.Client + + ID string +} + +func (store *RedisStore) Load(val interface{}) error { + if store.redis == nil { + return errors.New("can not load from redis, possible cause: redis persistence is not configured, or you are trying to use redis in back-test") + } + + cmd := store.redis.Get(context.Background(), store.ID) + data, err := cmd.Result() + + redisLogger.Debugf("[redis] get key %q, data = %s", store.ID, string(data)) + + if err != nil { + if err == redis.Nil { + return ErrPersistenceNotExists + } + + return err + } + + // skip null data + if len(data) == 0 || data == "null" { + return ErrPersistenceNotExists + } + + return json.Unmarshal([]byte(data), val) +} + +func (store *RedisStore) Save(val interface{}) error { + if val == nil { + return nil + } + + var expiration time.Duration + if expiringData, ok := val.(Expirable); ok { + expiration = expiringData.Expiration() + } + + data, err := json.Marshal(val) + if err != nil { + return err + } + + cmd := store.redis.Set(context.Background(), store.ID, data, expiration) + _, err = cmd.Result() + + redisLogger.Debugf("[redis] set key %q, data = %s, expiration = %s", store.ID, string(data), expiration) + + return err +} + +func (store *RedisStore) Reset() error { + _, err := store.redis.Del(context.Background(), store.ID).Result() + return err +} diff --git a/pkg/service/persistence_redis_test.go b/pkg/service/persistence_redis_test.go new file mode 100644 index 0000000..5397824 --- /dev/null +++ b/pkg/service/persistence_redis_test.go @@ -0,0 +1,41 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +func TestRedisPersistentService(t *testing.T) { + redisService := NewRedisPersistenceService(&RedisPersistenceConfig{ + Host: "127.0.0.1", + Port: "6379", + DB: 0, + }) + assert.NotNil(t, redisService) + + store := redisService.NewStore("qbtrade", "test") + assert.NotNil(t, store) + + err := store.Reset() + assert.NoError(t, err) + + var fp fixedpoint.Value + err = store.Load(fp) + assert.Error(t, err) + assert.EqualError(t, ErrPersistenceNotExists, err.Error()) + + fp = fixedpoint.NewFromFloat(3.1415) + err = store.Save(&fp) + assert.NoError(t, err, "should store value without error") + + var fp2 fixedpoint.Value + err = store.Load(&fp2) + assert.NoError(t, err, "should load value without error") + assert.Equal(t, fp, fp2) + + err = store.Reset() + assert.NoError(t, err) +} diff --git a/pkg/service/position.go b/pkg/service/position.go new file mode 100644 index 0000000..3985dd1 --- /dev/null +++ b/pkg/service/position.go @@ -0,0 +1,101 @@ +package service + +import ( + "context" + + "github.com/jmoiron/sqlx" + "github.com/pkg/errors" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type PositionService struct { + DB *sqlx.DB +} + +func NewPositionService(db *sqlx.DB) *PositionService { + return &PositionService{db} +} + +func (s *PositionService) Load(ctx context.Context, id int64) (*types.Position, error) { + var pos types.Position + + rows, err := s.DB.NamedQuery("SELECT * FROM positions WHERE id = :id", map[string]interface{}{ + "id": id, + }) + if err != nil { + return nil, err + } + + defer rows.Close() + + if rows.Next() { + err = rows.StructScan(&pos) + return &pos, err + } + + return nil, errors.Wrapf(ErrTradeNotFound, "position id:%d not found", id) +} + +func (s *PositionService) scanRows(rows *sqlx.Rows) (positions []types.Position, err error) { + for rows.Next() { + var p types.Position + if err := rows.StructScan(&p); err != nil { + return positions, err + } + + positions = append(positions, p) + } + + return positions, rows.Err() +} + +func (s *PositionService) Insert(position *types.Position, trade types.Trade, profit fixedpoint.Value) error { + _, err := s.DB.NamedExec(` + INSERT INTO positions ( + strategy, + strategy_instance_id, + symbol, + quote_currency, + base_currency, + average_cost, + base, + quote, + profit, + trade_id, + exchange, + side, + traded_at + ) VALUES ( + :strategy, + :strategy_instance_id, + :symbol, + :quote_currency, + :base_currency, + :average_cost, + :base, + :quote, + :profit, + :trade_id, + :exchange, + :side, + :traded_at + )`, + map[string]interface{}{ + "strategy": position.Strategy, + "strategy_instance_id": position.StrategyInstanceID, + "symbol": position.Symbol, + "quote_currency": position.QuoteCurrency, + "base_currency": position.BaseCurrency, + "average_cost": position.AverageCost, + "base": position.Base, + "quote": position.Quote, + "profit": profit, + "trade_id": trade.ID, + "exchange": trade.Exchange, + "side": trade.Side, + "traded_at": trade.Time, + }) + return err +} diff --git a/pkg/service/position_test.go b/pkg/service/position_test.go new file mode 100644 index 0000000..222d433 --- /dev/null +++ b/pkg/service/position_test.go @@ -0,0 +1,61 @@ +package service + +import ( + "testing" + "time" + + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func TestPositionService(t *testing.T) { + db, err := prepareDB(t) + if err != nil { + t.Fatal(err) + } + + defer func() { + err := db.Close() + assert.NoError(t, err) + }() + + xdb := sqlx.NewDb(db.DB, "sqlite3") + service := &PositionService{DB: xdb} + + t.Run("minimal fields", func(t *testing.T) { + err = service.Insert(&types.Position{ + Symbol: "BTCUSDT", + BaseCurrency: "BTC", + QuoteCurrency: "USDT", + AverageCost: fixedpoint.NewFromFloat(44000), + ChangedAt: time.Now(), + }, types.Trade{ + Time: types.Time(time.Now()), + }, fixedpoint.Zero) + assert.NoError(t, err) + }) + + t.Run("full fields", func(t *testing.T) { + err = service.Insert(&types.Position{ + Symbol: "BTCUSDT", + BaseCurrency: "BTC", + QuoteCurrency: "USDT", + AverageCost: fixedpoint.NewFromFloat(44000), + Base: fixedpoint.NewFromFloat(0.1), + Quote: fixedpoint.NewFromFloat(-44000.0), + ChangedAt: time.Now(), + Strategy: "bollmaker", + StrategyInstanceID: "bollmaker-BTCUSDT-1m", + }, types.Trade{ + ID: 9, + Exchange: types.ExchangeBinance, + Side: types.SideTypeSell, + Time: types.Time(time.Now()), + }, fixedpoint.NewFromFloat(10.9)) + assert.NoError(t, err) + }) + +} diff --git a/pkg/service/profit.go b/pkg/service/profit.go new file mode 100644 index 0000000..6f3707a --- /dev/null +++ b/pkg/service/profit.go @@ -0,0 +1,106 @@ +package service + +import ( + "context" + + "github.com/jmoiron/sqlx" + "github.com/pkg/errors" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type ProfitService struct { + DB *sqlx.DB +} + +func (s *ProfitService) Load(ctx context.Context, id int64) (*types.Trade, error) { + var trade types.Trade + + rows, err := s.DB.NamedQuery("SELECT * FROM trades WHERE id = :id", map[string]interface{}{ + "id": id, + }) + if err != nil { + return nil, err + } + + defer rows.Close() + + if rows.Next() { + err = rows.StructScan(&trade) + return &trade, err + } + + return nil, errors.Wrapf(ErrTradeNotFound, "trade id:%d not found", id) +} + +func (s *ProfitService) scanRows(rows *sqlx.Rows) (profits []types.Profit, err error) { + for rows.Next() { + var profit types.Profit + if err := rows.StructScan(&profit); err != nil { + return profits, err + } + + profits = append(profits, profit) + } + + return profits, rows.Err() +} + +func (s *ProfitService) Insert(profit types.Profit) error { + _, err := s.DB.NamedExec(` + INSERT INTO profits ( + strategy, + strategy_instance_id, + symbol, + quote_currency, + base_currency, + average_cost, + profit, + net_profit, + profit_margin, + net_profit_margin, + trade_id, + price, + quantity, + quote_quantity, + side, + is_buyer, + is_maker, + fee, + fee_currency, + fee_in_usd, + traded_at, + exchange, + is_margin, + is_futures, + is_isolated + ) VALUES ( + :strategy, + :strategy_instance_id, + :symbol, + :quote_currency, + :base_currency, + :average_cost, + :profit, + :net_profit, + :profit_margin, + :net_profit_margin, + :trade_id, + :price, + :quantity, + :quote_quantity, + :side, + :is_buyer, + :is_maker, + :fee, + :fee_currency, + :fee_in_usd, + :traded_at, + :exchange, + :is_margin, + :is_futures, + :is_isolated + )`, + profit) + return err +} diff --git a/pkg/service/profit_test.go b/pkg/service/profit_test.go new file mode 100644 index 0000000..a8220c8 --- /dev/null +++ b/pkg/service/profit_test.go @@ -0,0 +1,41 @@ +package service + +import ( + "testing" + "time" + + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func TestProfitService(t *testing.T) { + db, err := prepareDB(t) + if err != nil { + t.Fatal(err) + } + + defer db.Close() + + xdb := sqlx.NewDb(db.DB, "sqlite3") + service := &ProfitService{DB: xdb} + + err = service.Insert(types.Profit{ + Symbol: "BTCUSDT", + BaseCurrency: "BTC", + QuoteCurrency: "USDT", + AverageCost: fixedpoint.NewFromFloat(44000), + Profit: fixedpoint.NewFromFloat(1.01), + NetProfit: fixedpoint.NewFromFloat(0.98), + TradeID: 99, + Side: types.SideTypeSell, + Price: fixedpoint.NewFromFloat(44300), + Quantity: fixedpoint.NewFromFloat(0.001), + QuoteQuantity: fixedpoint.NewFromFloat(44.0), + Exchange: types.ExchangeMax, + TradedAt: time.Now(), + }) + assert.NoError(t, err) +} diff --git a/pkg/service/reflect.go b/pkg/service/reflect.go new file mode 100644 index 0000000..1f8ce68 --- /dev/null +++ b/pkg/service/reflect.go @@ -0,0 +1,231 @@ +package service + +import ( + "context" + "reflect" + "strings" + + "github.com/Masterminds/squirrel" + "github.com/fatih/camelcase" + gopluralize "github.com/gertd/go-pluralize" + "github.com/jmoiron/sqlx" + "github.com/sirupsen/logrus" +) + +var pluralize = gopluralize.NewClient() + +func tableNameOf(record interface{}) string { + rt := reflect.TypeOf(record) + if rt.Kind() == reflect.Ptr { + rt = rt.Elem() + } + + typeName := rt.Name() + tableName := strings.Join(camelcase.Split(typeName), "_") + tableName = strings.ToLower(tableName) + return pluralize.Plural(tableName) +} + +func placeholdersOf(record interface{}) []string { + rt := reflect.TypeOf(record) + if rt.Kind() == reflect.Ptr { + rt = rt.Elem() + } + + if rt.Kind() != reflect.Struct { + return nil + } + + var dbFields []string + for i := 0; i < rt.NumField(); i++ { + fieldType := rt.Field(i) + if tag, ok := fieldType.Tag.Lookup("db"); ok { + if tag == "gid" || tag == "-" || tag == "" { + continue + } + + dbFields = append(dbFields, ":"+tag) + } + } + + return dbFields +} + +func fieldsNamesOf(record interface{}) []string { + rt := reflect.TypeOf(record) + if rt.Kind() == reflect.Ptr { + rt = rt.Elem() + } + + if rt.Kind() != reflect.Struct { + return nil + } + + var dbFields []string + for i := 0; i < rt.NumField(); i++ { + fieldType := rt.Field(i) + if tag, ok := fieldType.Tag.Lookup("db"); ok { + if tag == "gid" || tag == "-" || tag == "" { + continue + } + + dbFields = append(dbFields, tag) + } + } + + return dbFields +} + +func ParseStructTag(s string) (string, map[string]string) { + opts := make(map[string]string) + ss := strings.Split(s, ",") + if len(ss) > 1 { + for _, opt := range ss[1:] { + aa := strings.SplitN(opt, "=", 2) + if len(aa) == 2 { + opts[aa[0]] = aa[1] + } else { + opts[aa[0]] = "" + } + } + } + + return ss[0], opts +} + +type ReflectCache struct { + tableNames map[string]string + fields map[string][]string + placeholders map[string][]string + insertSqls map[string]string +} + +func NewReflectCache() *ReflectCache { + return &ReflectCache{ + tableNames: make(map[string]string), + fields: make(map[string][]string), + placeholders: make(map[string][]string), + insertSqls: make(map[string]string), + } +} + +func (c *ReflectCache) InsertSqlOf(t interface{}) string { + rt := reflect.TypeOf(t) + if rt.Kind() == reflect.Ptr { + rt = rt.Elem() + } + + typeName := rt.Name() + sql, ok := c.insertSqls[typeName] + if ok { + return sql + } + + tableName := dbCache.TableNameOf(t) + fields := dbCache.FieldsOf(t) + placeholders := dbCache.PlaceholderOf(t) + fieldClause := strings.Join(fields, ", ") + placeholderClause := strings.Join(placeholders, ", ") + + sql = `INSERT INTO ` + tableName + ` (` + fieldClause + `) VALUES (` + placeholderClause + `)` + c.insertSqls[typeName] = sql + return sql +} + +func (c *ReflectCache) TableNameOf(t interface{}) string { + rt := reflect.TypeOf(t) + if rt.Kind() == reflect.Ptr { + rt = rt.Elem() + } + + typeName := rt.Name() + tableName, ok := c.tableNames[typeName] + if ok { + return tableName + } + + tableName = tableNameOf(t) + c.tableNames[typeName] = tableName + return tableName +} + +func (c *ReflectCache) PlaceholderOf(t interface{}) []string { + rt := reflect.TypeOf(t) + if rt.Kind() == reflect.Ptr { + rt = rt.Elem() + } + typeName := rt.Name() + placeholders, ok := c.placeholders[typeName] + if ok { + return placeholders + } + + placeholders = placeholdersOf(t) + c.placeholders[typeName] = placeholders + return placeholders +} + +func (c *ReflectCache) FieldsOf(t interface{}) []string { + rt := reflect.TypeOf(t) + if rt.Kind() == reflect.Ptr { + rt = rt.Elem() + } + + typeName := rt.Name() + fields, ok := c.fields[typeName] + if ok { + return fields + } + + fields = fieldsNamesOf(t) + c.fields[typeName] = fields + return fields +} + +// scanRowsOfType use the given type to scan rows +// this is usually slower than the native one since it uses reflect. +func scanRowsOfType(rows *sqlx.Rows, tpe interface{}) (interface{}, error) { + refType := reflect.TypeOf(tpe) + + if refType.Kind() == reflect.Ptr { + refType = refType.Elem() + } + + sliceRef := reflect.MakeSlice(reflect.SliceOf(refType), 0, 100) + // sliceRef := reflect.New(reflect.SliceOf(refType)) + for rows.Next() { + var recordRef = reflect.New(refType) + var record = recordRef.Interface() + if err := rows.StructScan(record); err != nil { + return sliceRef.Interface(), err + } + + sliceRef = reflect.Append(sliceRef, recordRef.Elem()) + } + + return sliceRef.Interface(), rows.Err() +} + +func insertType(db *sqlx.DB, record interface{}) error { + sql := dbCache.InsertSqlOf(record) + _, err := db.NamedExec(sql, record) + return err +} + +func selectAndScanType(ctx context.Context, db *sqlx.DB, sel squirrel.SelectBuilder, tpe interface{}) (interface{}, error) { + sql, args, err := sel.ToSql() + if err != nil { + return nil, err + } + + logrus.Debugf("selectAndScanType: %T <- %s", tpe, sql) + logrus.Debugf("queryArgs: %v", args) + + rows, err := db.QueryxContext(ctx, sql, args...) + if err != nil { + return nil, err + } + + defer rows.Close() + return scanRowsOfType(rows, tpe) +} diff --git a/pkg/service/reflect_test.go b/pkg/service/reflect_test.go new file mode 100644 index 0000000..f0907c1 --- /dev/null +++ b/pkg/service/reflect_test.go @@ -0,0 +1,71 @@ +package service + +import ( + "reflect" + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func Test_tableNameOf(t *testing.T) { + type args struct { + record interface{} + } + tests := []struct { + name string + args args + want string + }{ + { + name: "MarginInterest", + args: args{record: &types.MarginInterest{}}, + want: "margin_interests", + }, + { + name: "MarginLoan", + args: args{record: &types.MarginLoan{}}, + want: "margin_loans", + }, + { + name: "MarginRepay", + args: args{record: &types.MarginRepay{}}, + want: "margin_repays", + }, + { + name: "MarginLiquidation", + args: args{record: &types.MarginLiquidation{}}, + want: "margin_liquidations", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tableNameOf(tt.args.record); got != tt.want { + t.Errorf("tableNameOf() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_fieldsNamesOf(t *testing.T) { + type args struct { + record interface{} + } + tests := []struct { + name string + args args + want []string + }{ + { + name: "MarginInterest", + args: args{record: &types.MarginInterest{}}, + want: []string{"exchange", "asset", "principle", "interest", "interest_rate", "isolated_symbol", "time"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := fieldsNamesOf(tt.args.record); !reflect.DeepEqual(got, tt.want) { + t.Errorf("fieldsNamesOf() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/service/reward.go b/pkg/service/reward.go new file mode 100644 index 0000000..6d33d7b --- /dev/null +++ b/pkg/service/reward.go @@ -0,0 +1,199 @@ +package service + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + sq "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" + + exchange2 "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/batch" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// RewardService collects the reward records from the exchange, +// currently it's only available for MAX exchange. +// TODO: add summary query for calculating the reward amounts +// CREATE VIEW reward_summary_by_years AS SELECT YEAR(created_at) as year, reward_type, currency, SUM(quantity) FROM rewards WHERE reward_type != 'airdrop' GROUP BY YEAR(created_at), reward_type, currency ORDER BY year DESC; +type RewardService struct { + DB *sqlx.DB +} + +func (s *RewardService) Sync(ctx context.Context, exchange types.Exchange, startTime time.Time) error { + api, ok := exchange.(types.ExchangeRewardService) + if !ok { + return ErrExchangeRewardServiceNotImplemented + } + + isMargin, isFutures, _, _ := exchange2.GetSessionAttributes(exchange) + if isMargin || isFutures { + return nil + } + + tasks := []SyncTask{ + { + Type: types.Reward{}, + Select: SelectLastRewards(exchange.Name(), 100), + BatchQuery: func(ctx context.Context, startTime, endTime time.Time) (interface{}, chan error) { + query := &batch.RewardBatchQuery{ + Service: api, + } + return query.Query(ctx, startTime, endTime) + }, + Time: func(obj interface{}) time.Time { + return obj.(types.Reward).CreatedAt.Time() + }, + ID: func(obj interface{}) string { + reward := obj.(types.Reward) + return string(reward.Type) + "_" + reward.UUID + }, + LogInsert: true, + }, + } + + for _, sel := range tasks { + if err := sel.execute(ctx, s.DB, startTime); err != nil { + return err + } + } + + return nil +} + +type CurrencyPositionMap map[string]fixedpoint.Value + +func (s *RewardService) AggregateUnspentCurrencyPosition(ctx context.Context, ex types.ExchangeName, since time.Time) (CurrencyPositionMap, error) { + m := make(CurrencyPositionMap) + + rewards, err := s.QueryUnspentSince(ctx, ex, since) + if err != nil { + return nil, err + } + + for _, reward := range rewards { + m[reward.Currency] = m[reward.Currency].Add(reward.Quantity) + } + + return m, nil +} + +func (s *RewardService) QueryUnspentSince(ctx context.Context, ex types.ExchangeName, since time.Time, rewardTypes ...types.RewardType) ([]types.Reward, error) { + sql := "SELECT * FROM rewards WHERE created_at >= :since AND exchange = :exchange AND spent IS FALSE " + + if len(rewardTypes) == 0 { + sql += " AND `reward_type` NOT IN ('airdrop') " + } else { + var args []string + for _, n := range rewardTypes { + args = append(args, strconv.Quote(string(n))) + } + sql += " AND `reward_type` IN (" + strings.Join(args, ", ") + ") " + } + + sql += " ORDER BY created_at ASC" + + rows, err := s.DB.NamedQueryContext(ctx, sql, map[string]interface{}{ + "exchange": ex, + "since": since, + }) + + if err != nil { + return nil, err + } + + defer rows.Close() + return s.scanRows(rows) +} + +func (s *RewardService) QueryUnspent(ctx context.Context, ex types.ExchangeName, rewardTypes ...types.RewardType) ([]types.Reward, error) { + sql := "SELECT * FROM rewards WHERE exchange = :exchange AND spent IS FALSE " + if len(rewardTypes) == 0 { + sql += " AND `reward_type` NOT IN ('airdrop') " + } else { + var args []string + for _, n := range rewardTypes { + args = append(args, strconv.Quote(string(n))) + } + sql += " AND `reward_type` IN (" + strings.Join(args, ", ") + ") " + } + + sql += " ORDER BY created_at ASC" + rows, err := s.DB.NamedQueryContext(ctx, sql, map[string]interface{}{ + "exchange": ex, + }) + if err != nil { + return nil, err + } + + defer rows.Close() + return s.scanRows(rows) +} + +func (s *RewardService) MarkCurrencyAsSpent(ctx context.Context, currency string) error { + result, err := s.DB.NamedExecContext(ctx, "UPDATE `rewards` SET `spent` = TRUE WHERE `currency` = :currency AND `spent` IS FALSE", map[string]interface{}{ + "currency": currency, + }) + + if err != nil { + return err + } + + _, err = result.RowsAffected() + return err +} + +func (s *RewardService) MarkAsSpent(ctx context.Context, uuid string) error { + result, err := s.DB.NamedExecContext(ctx, "UPDATE `rewards` SET `spent` = TRUE WHERE `uuid` = :uuid", map[string]interface{}{ + "uuid": uuid, + }) + if err != nil { + return err + } + + cnt, err := result.RowsAffected() + if err != nil { + return err + } + + if cnt == 0 { + return fmt.Errorf("reward uuid:%s not found", uuid) + } + + return nil +} + +func (s *RewardService) scanRows(rows *sqlx.Rows) (rewards []types.Reward, err error) { + for rows.Next() { + var reward types.Reward + if err := rows.StructScan(&reward); err != nil { + return rewards, err + } + + rewards = append(rewards, reward) + } + + return rewards, rows.Err() +} + +func (s *RewardService) Insert(reward types.Reward) error { + _, err := s.DB.NamedExec(` + INSERT INTO rewards (exchange, uuid, reward_type, currency, quantity, state, note, created_at) + VALUES (:exchange, :uuid, :reward_type, :currency, :quantity, :state, :note, :created_at)`, + reward) + return err +} + +func SelectLastRewards(ex types.ExchangeName, limit uint64) sq.SelectBuilder { + return sq.Select("*"). + From("rewards"). + Where(sq.And{ + sq.Eq{"exchange": ex}, + }). + OrderBy("created_at DESC"). + Limit(limit) +} diff --git a/pkg/service/reward_test.go b/pkg/service/reward_test.go new file mode 100644 index 0000000..512a571 --- /dev/null +++ b/pkg/service/reward_test.go @@ -0,0 +1,140 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func TestRewardService_InsertAndQueryUnspent(t *testing.T) { + db, err := prepareDB(t) + if err != nil { + t.Fatal(err) + } + + defer db.Close() + + ctx := context.Background() + + xdb := sqlx.NewDb(db.DB, "sqlite3") + service := &RewardService{DB: xdb} + + err = service.Insert(types.Reward{ + UUID: "test01", + Exchange: "max", + Type: "commission", + Currency: "BTC", + Quantity: fixedpoint.One, + State: "done", + Spent: false, + CreatedAt: types.Time(time.Now()), + }) + assert.NoError(t, err) + + rewards, err := service.QueryUnspent(ctx, types.ExchangeMax) + assert.NoError(t, err) + assert.NotEmpty(t, rewards) + assert.Len(t, rewards, 1) + assert.Equal(t, types.RewardCommission, rewards[0].Type) + + err = service.Insert(types.Reward{ + UUID: "test02", + Exchange: "max", + Type: "airdrop", + Currency: "MAX", + Quantity: fixedpoint.NewFromInt(1000000), + State: "done", + Spent: false, + CreatedAt: types.Time(time.Now()), + }) + assert.NoError(t, err) + + rewards, err = service.QueryUnspent(ctx, types.ExchangeMax) + assert.NoError(t, err) + assert.NotEmpty(t, rewards) + assert.Len(t, rewards, 1, "airdrop should not be included") + assert.Equal(t, types.RewardCommission, rewards[0].Type) + + rewards, err = service.QueryUnspent(ctx, types.ExchangeMax, types.RewardAirdrop) + assert.NoError(t, err) + assert.NotEmpty(t, rewards) + assert.Len(t, rewards, 1, "airdrop should be included") + assert.Equal(t, types.RewardAirdrop, rewards[0].Type) + + rewards, err = service.QueryUnspent(ctx, types.ExchangeMax, types.RewardCommission) + assert.NoError(t, err) + assert.NotEmpty(t, rewards) + assert.Len(t, rewards, 1, "should select 1 reward") + assert.Equal(t, types.RewardCommission, rewards[0].Type) +} + +func TestRewardService_AggregateUnspentCurrencyPosition(t *testing.T) { + db, err := prepareDB(t) + if err != nil { + t.Fatal(err) + } + + defer db.Close() + + ctx := context.Background() + + xdb := sqlx.NewDb(db.DB, "sqlite3") + service := &RewardService{DB: xdb} + + now := time.Now() + + err = service.Insert(types.Reward{ + UUID: "test01", + Exchange: "max", + Type: "commission", + Currency: "BTC", + Quantity: fixedpoint.One, + State: "done", + Spent: false, + CreatedAt: types.Time(now), + }) + assert.NoError(t, err) + + err = service.Insert(types.Reward{ + UUID: "test02", + Exchange: "max", + Type: "commission", + Currency: "LTC", + Quantity: fixedpoint.NewFromInt(2), + State: "done", + Spent: false, + CreatedAt: types.Time(now), + }) + assert.NoError(t, err) + + err = service.Insert(types.Reward{ + UUID: "test03", + Exchange: "max", + Type: "airdrop", + Currency: "MAX", + Quantity: fixedpoint.NewFromInt(1000000), + State: "done", + Spent: false, + CreatedAt: types.Time(now), + }) + assert.NoError(t, err) + + currencyPositions, err := service.AggregateUnspentCurrencyPosition(ctx, types.ExchangeMax, now.Add(-10*time.Second)) + assert.NoError(t, err) + assert.NotEmpty(t, currencyPositions) + assert.Len(t, currencyPositions, 2) + + v, ok := currencyPositions["LTC"] + assert.True(t, ok) + assert.Equal(t, fixedpoint.NewFromInt(2), v) + + v, ok = currencyPositions["BTC"] + assert.True(t, ok) + assert.Equal(t, fixedpoint.One, v) +} diff --git a/pkg/service/sync.go b/pkg/service/sync.go new file mode 100644 index 0000000..e0ed7bc --- /dev/null +++ b/pkg/service/sync.go @@ -0,0 +1,135 @@ +package service + +import ( + "context" + "errors" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/cache" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" + + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +var ErrNotImplemented = errors.New("not implemented") +var ErrExchangeRewardServiceNotImplemented = errors.New("exchange does not implement ExchangeRewardService interface") + +type SyncService struct { + TradeService *TradeService + OrderService *OrderService + RewardService *RewardService + WithdrawService *WithdrawService + DepositService *DepositService + MarginService *MarginService +} + +// SyncSessionSymbols syncs the trades from the given exchange session +func (s *SyncService) SyncSessionSymbols( + ctx context.Context, exchange types.Exchange, startTime time.Time, symbols ...string, +) error { + markets, err := cache.LoadExchangeMarketsWithCache(ctx, exchange) + if err != nil { + return err + } + + for _, symbol := range symbols { + // skip symbols do not exist in the market info + if _, ok := markets[symbol]; !ok { + continue + } + + log.Infof("syncing %s %s trades from %s...", exchange.Name(), symbol, startTime) + if err := s.TradeService.Sync(ctx, exchange, symbol, startTime); err != nil { + return err + } + + log.Infof("syncing %s %s orders from %s...", exchange.Name(), symbol, startTime) + if err := s.OrderService.Sync(ctx, exchange, symbol, startTime); err != nil { + return err + } + } + + return nil +} + +func (s *SyncService) SyncMarginHistory( + ctx context.Context, exchange types.Exchange, startTime time.Time, assets ...string, +) error { + if _, implemented := exchange.(types.MarginHistoryService); !implemented { + log.Debugf("exchange %T does not support types.MarginHistoryService", exchange) + return nil + } + + if marginExchange, implemented := exchange.(types.MarginExchange); !implemented { + log.Debugf("exchange %T does not implement types.MarginExchange", exchange) + return nil + } else { + marginSettings := marginExchange.GetMarginSettings() + if !marginSettings.IsMargin { + log.Debugf("exchange %T is not using margin", exchange) + return nil + } + } + + log.Infof("syncing %s margin history: %v...", exchange.Name(), assets) + for _, asset := range assets { + if err := s.MarginService.Sync(ctx, exchange, asset, startTime); err != nil { + return err + } + } + + return nil +} + +func (s *SyncService) SyncRewardHistory(ctx context.Context, exchange types.Exchange, startTime time.Time) error { + if _, implemented := exchange.(types.ExchangeRewardService); !implemented { + return nil + } + + log.Infof("syncing %s reward records...", exchange.Name()) + if util.IsPaperTrade() { + log.Info("reward is not supported in paper trading") + return nil + } + if err := s.RewardService.Sync(ctx, exchange, startTime); err != nil { + return err + } + + return nil +} + +func (s *SyncService) SyncDepositHistory(ctx context.Context, exchange types.Exchange, startTime time.Time) error { + log.Infof("syncing %s deposit records...", exchange.Name()) + if util.IsPaperTrade() { + log.Info("deposit is not supported in paper trading") + return nil + } + + if err := s.DepositService.Sync(ctx, exchange, startTime); err != nil { + if err != ErrNotImplemented { + log.Warnf("%s deposit service is not supported", exchange.Name()) + return err + } + } + + return nil +} + +func (s *SyncService) SyncWithdrawHistory(ctx context.Context, exchange types.Exchange, startTime time.Time) error { + log.Infof("syncing %s withdraw records...", exchange.Name()) + if util.IsPaperTrade() { + log.Info("withdraw is not supported in paper trading") + return nil + } + + if err := s.WithdrawService.Sync(ctx, exchange, startTime); err != nil { + if err != ErrNotImplemented { + log.Warnf("%s withdraw service is not supported", exchange.Name()) + return err + } + } + + return nil +} diff --git a/pkg/service/sync_task.go b/pkg/service/sync_task.go new file mode 100644 index 0000000..8ccfc99 --- /dev/null +++ b/pkg/service/sync_task.go @@ -0,0 +1,210 @@ +package service + +import ( + "context" + "reflect" + "sort" + "time" + + "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" +) + +// SyncTask defines the behaviors for syncing remote records +type SyncTask struct { + // Type is the element type of this sync task + // Since it will create a []Type slice from this type, you should not set pointer to this field + Type interface{} + + // ID is a function that returns the unique identity of the object + // This function will be used for detecting duplicated objects. + ID func(obj interface{}) string + + // Time is a function that returns the time of the object + // This function will be used for sorting records + Time func(obj interface{}) time.Time + + // Select is the select query builder for querying existing db records + // The built SQL will be used for querying existing db records. + // And then the ID function will be used for filtering duplicated object. + Select squirrel.SelectBuilder + + // OnLoad is an optional field, which is called when the records are loaded from the database + OnLoad func(objs interface{}) + + // Filter is an optional field, which is used for filtering the remote records + // Return true to keep the record, + // Return false to filter the record. + Filter func(obj interface{}) bool + + // BatchQuery is used for querying remote records. + BatchQuery func(ctx context.Context, startTime, endTime time.Time) (interface{}, chan error) + + // Insert is an option field, which is used for customizing the record insert + Insert func(obj interface{}) error + + // Insert is an option field, which is used for customizing the record batch insert + BatchInsert func(obj interface{}) error + + BatchInsertBuffer int + + // LogInsert logs the insert record in INFO level + LogInsert bool +} + +func (sel SyncTask) execute(ctx context.Context, db *sqlx.DB, startTime time.Time, args ...time.Time) error { + batchBufferRefVal := reflect.MakeSlice(reflect.SliceOf(reflect.TypeOf(sel.Type)), 0, sel.BatchInsertBuffer) + + // query from db + recordSlice, err := selectAndScanType(ctx, db, sel.Select, sel.Type) + if err != nil { + return err + } + + recordSliceRef := reflect.ValueOf(recordSlice) + if recordSliceRef.Kind() == reflect.Ptr { + recordSliceRef = recordSliceRef.Elem() + } + + logrus.Debugf("loaded %d %T records", recordSliceRef.Len(), sel.Type) + + ids := buildIdMap(sel, recordSliceRef) + + if err := sortRecordsAscending(sel, recordSliceRef); err != nil { + return err + } + + if sel.OnLoad != nil { + sel.OnLoad(recordSliceRef.Interface()) + } + + // default since time point + startTime = lastRecordTime(sel, recordSliceRef, startTime) + + endTime := time.Now() + if len(args) > 0 { + endTime = args[0] + } + + // asset "" means all assets + dataC, errC := sel.BatchQuery(ctx, startTime, endTime) + dataCRef := reflect.ValueOf(dataC) + + defer func() { + if sel.BatchInsert != nil && batchBufferRefVal.Len() > 0 { + slice := batchBufferRefVal.Interface() + if err := sel.BatchInsert(slice); err != nil { + logrus.WithError(err).Errorf("batch insert error: %+v", slice) + } + } + }() + + for { + select { + case <-ctx.Done(): + logrus.Warnf("context is cancelled, stop syncing") + return ctx.Err() + + default: + v, ok := dataCRef.Recv() + if !ok { + err := <-errC + return err + } + + obj := v.Interface() + id := sel.ID(obj) + if _, exists := ids[id]; exists { + logrus.Debugf("object %s already exists, skipping", id) + continue + } + + tt := sel.Time(obj) + if tt.Before(startTime) || tt.After(endTime) { + logrus.Debugf("object %s time %s is outside of the time range", id, tt) + continue + } + + if sel.Filter != nil { + if !sel.Filter(obj) { + logrus.Debugf("object %s is filtered", id) + continue + } + } + + ids[id] = struct{}{} + if sel.BatchInsert != nil { + if batchBufferRefVal.Len() > sel.BatchInsertBuffer-1 { + if sel.LogInsert { + logrus.Infof("batch inserting %d %T", batchBufferRefVal.Len(), obj) + } else { + logrus.Debugf("batch inserting %d %T", batchBufferRefVal.Len(), obj) + } + + if err := sel.BatchInsert(batchBufferRefVal.Interface()); err != nil { + return err + } + + batchBufferRefVal = reflect.MakeSlice(reflect.SliceOf(reflect.TypeOf(sel.Type)), 0, sel.BatchInsertBuffer) + } + batchBufferRefVal = reflect.Append(batchBufferRefVal, v) + } else { + if sel.LogInsert { + logrus.Infof("inserting %T: %+v", obj, obj) + } else { + logrus.Debugf("inserting %T: %+v", obj, obj) + } + if sel.Insert != nil { + // for custom insert + if err := sel.Insert(obj); err != nil { + logrus.WithError(err).Errorf("can not insert record: %v", obj) + return err + } + } else { + if err := insertType(db, obj); err != nil { + logrus.WithError(err).Errorf("can not insert record: %v", obj) + return err + } + } + } + } + } +} + +func lastRecordTime(sel SyncTask, recordSlice reflect.Value, defaultTime time.Time) time.Time { + since := defaultTime + length := recordSlice.Len() + if length > 0 { + last := recordSlice.Index(length - 1) + since = sel.Time(last.Interface()) + } + + return since +} + +func sortRecordsAscending(sel SyncTask, recordSlice reflect.Value) error { + if sel.Time == nil { + return errors.New("time field is not set, can not sort records") + } + + // always sort + sort.Slice(recordSlice.Interface(), func(i, j int) bool { + a := sel.Time(recordSlice.Index(i).Interface()) + b := sel.Time(recordSlice.Index(j).Interface()) + return a.Before(b) + }) + return nil +} + +func buildIdMap(sel SyncTask, recordSliceRef reflect.Value) map[string]struct{} { + ids := map[string]struct{}{} + for i := 0; i < recordSliceRef.Len(); i++ { + entryRef := recordSliceRef.Index(i) + id := sel.ID(entryRef.Interface()) + ids[id] = struct{}{} + } + + return ids +} diff --git a/pkg/service/totp.go b/pkg/service/totp.go new file mode 100644 index 0000000..89f9a9e --- /dev/null +++ b/pkg/service/totp.go @@ -0,0 +1,53 @@ +package service + +import ( + "os" + + "github.com/pkg/errors" + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" + log "github.com/sirupsen/logrus" + "github.com/spf13/viper" +) + +func NewDefaultTotpKey() (*otp.Key, error) { + if keyURL := viper.GetString("totp-key-url"); len(keyURL) > 0 { + return otp.NewKeyFromURL(keyURL) + } + + // The issuer parameter is a string value indicating the provider or service this account is associated with, URL-encoded according to RFC 3986. + // If the issuer parameter is absent, issuer information may be taken from the issuer prefix of the label. + // If both issuer parameter and issuer label prefix are present, they should be equal. + // Valid values corresponding to the label prefix examples above would be: issuer=Example, issuer=Provider1, and issuer=Big%20Corporation. + totpIssuer := viper.GetString("totp-issuer") + totpAccountName := viper.GetString("totp-account-name") + + if len(totpIssuer) == 0 { + hostname, err := os.Hostname() + if err != nil { + return nil, errors.Wrapf(err, "can not get hostname from os for totp issuer") + } + totpIssuer = hostname + } + + if len(totpAccountName) == 0 { + + //unix like os + user, ok := os.LookupEnv("USER") + if !ok { + user, ok = os.LookupEnv("USERNAME") + } + + if !ok { + log.Warnf("can not get USER or USERNAME env var, use default name 'qbtrade' for totp account name") + user = "qbtrade" + } + + totpAccountName = user + } + + return totp.Generate(totp.GenerateOpts{ + Issuer: totpIssuer, + AccountName: totpAccountName, + }) +} diff --git a/pkg/service/trade.go b/pkg/service/trade.go new file mode 100644 index 0000000..e43d03a --- /dev/null +++ b/pkg/service/trade.go @@ -0,0 +1,427 @@ +package service + +import ( + "context" + "strconv" + "strings" + "time" + + sq "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + + exchange2 "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/batch" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +var ErrTradeNotFound = errors.New("trade not found") + +type QueryTradesOptions struct { + Exchange types.ExchangeName + Sessions []string + Symbol string + LastGID int64 + + // inclusive + Since *time.Time + + // exclusive + Until *time.Time + + // ASC or DESC + Ordering string + Limit uint64 +} + +type TradingVolume struct { + Year int `db:"year" json:"year"` + Month int `db:"month" json:"month,omitempty"` + Day int `db:"day" json:"day,omitempty"` + Time time.Time `json:"time,omitempty"` + Exchange string `db:"exchange" json:"exchange,omitempty"` + Symbol string `db:"symbol" json:"symbol,omitempty"` + QuoteVolume float64 `db:"quote_volume" json:"quoteVolume"` +} + +type TradingVolumeQueryOptions struct { + GroupByPeriod string + SegmentBy string +} + +type TradeService struct { + DB *sqlx.DB +} + +func NewTradeService(db *sqlx.DB) *TradeService { + return &TradeService{db} +} + +func (s *TradeService) Sync(ctx context.Context, exchange types.Exchange, symbol string, startTime time.Time) error { + isMargin, isFutures, isIsolated, isolatedSymbol := exchange2.GetSessionAttributes(exchange) + // override symbol if isolatedSymbol is not empty + if isIsolated && len(isolatedSymbol) > 0 { + symbol = isolatedSymbol + } + + api, ok := exchange.(types.ExchangeTradeHistoryService) + if !ok { + return nil + } + + lastTradeID := uint64(1) + tasks := []SyncTask{ + { + Type: types.Trade{}, + Select: SelectLastTrades(exchange.Name(), symbol, isMargin, isFutures, isIsolated, 100), + OnLoad: func(objs interface{}) { + // update last trade ID + trades := objs.([]types.Trade) + if len(trades) > 0 { + end := len(trades) - 1 + last := trades[end] + lastTradeID = last.ID + } + }, + BatchQuery: func(ctx context.Context, startTime, endTime time.Time) (interface{}, chan error) { + query := &batch.TradeBatchQuery{ + ExchangeTradeHistoryService: api, + } + return query.Query(ctx, symbol, &types.TradeQueryOptions{ + StartTime: &startTime, + EndTime: &endTime, + LastTradeID: lastTradeID, + }) + }, + Time: func(obj interface{}) time.Time { + return obj.(types.Trade).Time.Time() + }, + ID: func(obj interface{}) string { + trade := obj.(types.Trade) + return strconv.FormatUint(trade.ID, 10) + trade.Side.String() + }, + LogInsert: true, + }, + } + + for _, sel := range tasks { + if err := sel.execute(ctx, s.DB, startTime); err != nil { + return err + } + } + + return nil +} + +func (s *TradeService) QueryTradingVolume(startTime time.Time, options TradingVolumeQueryOptions) ([]TradingVolume, error) { + args := map[string]interface{}{ + // "symbol": symbol, + // "exchange": ex, + // "is_margin": isMargin, + // "is_isolated": isIsolated, + "start_time": startTime, + } + + sql := "" + driverName := s.DB.DriverName() + if driverName == "mysql" { + sql = generateMysqlTradingVolumeQuerySQL(options) + } else { + sql = generateSqliteTradingVolumeSQL(options) + } + + log.Info(sql) + + rows, err := s.DB.NamedQuery(sql, args) + if err != nil { + return nil, errors.Wrap(err, "query last trade error") + } + + if rows.Err() != nil { + return nil, rows.Err() + } + + defer rows.Close() + + var records []TradingVolume + for rows.Next() { + var record TradingVolume + err = rows.StructScan(&record) + if err != nil { + return records, err + } + + record.Time = time.Date(record.Year, time.Month(record.Month), record.Day, 0, 0, 0, 0, time.Local) + records = append(records, record) + } + + return records, rows.Err() +} + +func generateSqliteTradingVolumeSQL(options TradingVolumeQueryOptions) string { + timeRangeColumn := "traded_at" + sel, groupBys, orderBys := generateSqlite3TimeRangeClauses(timeRangeColumn, options.GroupByPeriod) + + switch options.SegmentBy { + case "symbol": + sel = append(sel, "symbol") + groupBys = append([]string{"symbol"}, groupBys...) + orderBys = append(orderBys, "symbol") + case "exchange": + sel = append(sel, "exchange") + groupBys = append([]string{"exchange"}, groupBys...) + orderBys = append(orderBys, "exchange") + } + + sel = append(sel, "SUM(quantity * price) AS quote_volume") + where := []string{timeRangeColumn + " > :start_time"} + sql := `SELECT ` + strings.Join(sel, ", ") + ` FROM trades` + + ` WHERE ` + strings.Join(where, " AND ") + + ` GROUP BY ` + strings.Join(groupBys, ", ") + + ` ORDER BY ` + strings.Join(orderBys, ", ") + + return sql +} + +func generateSqlite3TimeRangeClauses(timeRangeColumn, period string) (selectors []string, groupBys []string, orderBys []string) { + switch period { + case "month": + selectors = append(selectors, "strftime('%Y',"+timeRangeColumn+") AS year", "strftime('%m',"+timeRangeColumn+") AS month") + groupBys = append([]string{"month", "year"}, groupBys...) + orderBys = append(orderBys, "year ASC", "month ASC") + + case "year": + selectors = append(selectors, "strftime('%Y',"+timeRangeColumn+") AS year") + groupBys = append([]string{"year"}, groupBys...) + orderBys = append(orderBys, "year ASC") + + case "day": + fallthrough + + default: + selectors = append(selectors, "strftime('%Y',"+timeRangeColumn+") AS year", "strftime('%m',"+timeRangeColumn+") AS month", "strftime('%d',"+timeRangeColumn+") AS day") + groupBys = append([]string{"day", "month", "year"}, groupBys...) + orderBys = append(orderBys, "year ASC", "month ASC", "day ASC") + } + + return +} + +func generateMysqlTimeRangeClauses(timeRangeColumn, period string) (selectors []string, groupBys []string, orderBys []string) { + switch period { + case "month": + selectors = append(selectors, "YEAR("+timeRangeColumn+") AS year", "MONTH("+timeRangeColumn+") AS month") + groupBys = append([]string{"MONTH(" + timeRangeColumn + ")", "YEAR(" + timeRangeColumn + ")"}, groupBys...) + orderBys = append(orderBys, "year ASC", "month ASC") + + case "year": + selectors = append(selectors, "YEAR("+timeRangeColumn+") AS year") + groupBys = append([]string{"YEAR(" + timeRangeColumn + ")"}, groupBys...) + orderBys = append(orderBys, "year ASC") + + case "day": + fallthrough + + default: + selectors = append(selectors, "YEAR("+timeRangeColumn+") AS year", "MONTH("+timeRangeColumn+") AS month", "DAY("+timeRangeColumn+") AS day") + groupBys = append([]string{"DAY(" + timeRangeColumn + ")", "MONTH(" + timeRangeColumn + ")", "YEAR(" + timeRangeColumn + ")"}, groupBys...) + orderBys = append(orderBys, "year ASC", "month ASC", "day ASC") + } + + return +} + +func generateMysqlTradingVolumeQuerySQL(options TradingVolumeQueryOptions) string { + timeRangeColumn := "traded_at" + sel, groupBys, orderBys := generateMysqlTimeRangeClauses(timeRangeColumn, options.GroupByPeriod) + + switch options.SegmentBy { + case "symbol": + sel = append(sel, "symbol") + groupBys = append([]string{"symbol"}, groupBys...) + orderBys = append(orderBys, "symbol") + case "exchange": + sel = append(sel, "exchange") + groupBys = append([]string{"exchange"}, groupBys...) + orderBys = append(orderBys, "exchange") + } + + sel = append(sel, "SUM(quantity * price) AS quote_volume") + where := []string{timeRangeColumn + " > :start_time"} + sql := `SELECT ` + strings.Join(sel, ", ") + ` FROM trades` + + ` WHERE ` + strings.Join(where, " AND ") + + ` GROUP BY ` + strings.Join(groupBys, ", ") + + ` ORDER BY ` + strings.Join(orderBys, ", ") + + return sql +} + +func (s *TradeService) QueryForTradingFeeCurrency(ex types.ExchangeName, symbol string, feeCurrency string) ([]types.Trade, error) { + sql := "SELECT * FROM trades WHERE exchange = :exchange AND (symbol = :symbol OR fee_currency = :fee_currency) ORDER BY traded_at ASC" + rows, err := s.DB.NamedQuery(sql, map[string]interface{}{ + "exchange": ex, + "symbol": symbol, + "fee_currency": feeCurrency, + }) + if err != nil { + return nil, err + } + + defer rows.Close() + + return s.scanRows(rows) +} + +func (s *TradeService) Query(options QueryTradesOptions) ([]types.Trade, error) { + sel := sq.Select("*"). + From("trades") + + if options.LastGID != 0 { + sel = sel.Where(sq.Gt{"gid": options.LastGID}) + } + if options.Since != nil { + sel = sel.Where(sq.GtOrEq{"traded_at": options.Since}) + } + if options.Until != nil { + sel = sel.Where(sq.Lt{"traded_at": options.Until}) + } + + if options.Symbol != "" { + sel = sel.Where(sq.Eq{"symbol": options.Symbol}) + } + + if options.Exchange != "" { + sel = sel.Where(sq.Eq{"exchange": options.Exchange}) + } + + if len(options.Sessions) > 0 { + // FIXME: right now we only have the exchange field in the db, we might need to add the session field too. + sel = sel.Where(sq.Eq{"exchange": options.Sessions}) + } + + if options.Ordering != "" { + sel = sel.OrderBy("traded_at " + options.Ordering) + } else { + sel = sel.OrderBy("traded_at ASC") + } + + if options.Limit > 0 { + sel = sel.Limit(options.Limit) + } + + sql, args, err := sel.ToSql() + if err != nil { + return nil, err + } + + log.Debug(sql) + log.Debug(args) + + rows, err := s.DB.Queryx(sql, args...) + if err != nil { + return nil, err + } + + defer rows.Close() + + return s.scanRows(rows) +} + +func (s *TradeService) Load(ctx context.Context, id int64) (*types.Trade, error) { + var trade types.Trade + + rows, err := s.DB.NamedQuery("SELECT * FROM trades WHERE id = :id", map[string]interface{}{ + "id": id, + }) + if err != nil { + return nil, err + } + + defer rows.Close() + + if rows.Next() { + err = rows.StructScan(&trade) + return &trade, err + } + + return nil, errors.Wrapf(ErrTradeNotFound, "trade id:%d not found", id) +} + +func queryTradesSQL(options QueryTradesOptions) string { + ordering := "ASC" + switch v := strings.ToUpper(options.Ordering); v { + case "DESC", "ASC": + ordering = v + } + + var where []string + + if options.LastGID > 0 { + switch ordering { + case "ASC": + where = append(where, "gid > :gid") + case "DESC": + where = append(where, "gid < :gid") + } + } + + if len(options.Symbol) > 0 { + where = append(where, `symbol = :symbol`) + } + + if len(options.Exchange) > 0 { + where = append(where, `exchange = :exchange`) + } + + sql := `SELECT * FROM trades` + if len(where) > 0 { + sql += ` WHERE ` + strings.Join(where, " AND ") + } + + sql += ` ORDER BY gid ` + ordering + + if options.Limit > 0 { + sql += ` LIMIT ` + strconv.FormatUint(options.Limit, 10) + } + + return sql +} + +func (s *TradeService) scanRows(rows *sqlx.Rows) (trades []types.Trade, err error) { + for rows.Next() { + var trade types.Trade + if err := rows.StructScan(&trade); err != nil { + return trades, err + } + + trades = append(trades, trade) + } + + return trades, rows.Err() +} + +func (s *TradeService) Insert(trade types.Trade) error { + sql := dbCache.InsertSqlOf(trade) + _, err := s.DB.NamedExec(sql, trade) + return err +} + +func (s *TradeService) DeleteAll() error { + _, err := s.DB.Exec(`DELETE FROM trades`) + return err +} + +func SelectLastTrades(ex types.ExchangeName, symbol string, isMargin, isFutures, isIsolated bool, limit uint64) sq.SelectBuilder { + return sq.Select("*"). + From("trades"). + Where(sq.And{ + sq.Eq{"symbol": symbol}, + sq.Eq{"exchange": ex}, + sq.Eq{"is_margin": isMargin}, + sq.Eq{"is_futures": isFutures}, + sq.Eq{"is_isolated": isIsolated}, + }). + OrderBy("traded_at DESC"). + Limit(limit) +} diff --git a/pkg/service/trade_test.go b/pkg/service/trade_test.go new file mode 100644 index 0000000..48656c9 --- /dev/null +++ b/pkg/service/trade_test.go @@ -0,0 +1,89 @@ +package service + +import ( + "testing" + "time" + + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func Test_tradeService(t *testing.T) { + db, err := prepareDB(t) + if err != nil { + t.Fatal(err) + } + + defer db.Close() + + xdb := sqlx.NewDb(db.DB, "sqlite3") + service := &TradeService{DB: xdb} + + err = service.Insert(types.Trade{ + ID: 1, + OrderID: 1, + Exchange: "binance", + Price: fixedpoint.NewFromInt(1000), + Quantity: fixedpoint.NewFromFloat(0.1), + QuoteQuantity: fixedpoint.NewFromFloat(1000.0 * 0.1), + Symbol: "BTCUSDT", + Side: "BUY", + IsBuyer: true, + Time: types.Time(time.Now()), + }) + assert.NoError(t, err) +} + +func Test_queryTradingVolumeSQL(t *testing.T) { + t.Run("group by different period", func(t *testing.T) { + o := TradingVolumeQueryOptions{ + GroupByPeriod: "month", + } + assert.Equal(t, "SELECT YEAR(traded_at) AS year, MONTH(traded_at) AS month, SUM(quantity * price) AS quote_volume FROM trades WHERE traded_at > :start_time GROUP BY MONTH(traded_at), YEAR(traded_at) ORDER BY year ASC, month ASC", generateMysqlTradingVolumeQuerySQL(o)) + + o.GroupByPeriod = "year" + assert.Equal(t, "SELECT YEAR(traded_at) AS year, SUM(quantity * price) AS quote_volume FROM trades WHERE traded_at > :start_time GROUP BY YEAR(traded_at) ORDER BY year ASC", generateMysqlTradingVolumeQuerySQL(o)) + + expectedDefaultSQL := "SELECT YEAR(traded_at) AS year, MONTH(traded_at) AS month, DAY(traded_at) AS day, SUM(quantity * price) AS quote_volume FROM trades WHERE traded_at > :start_time GROUP BY DAY(traded_at), MONTH(traded_at), YEAR(traded_at) ORDER BY year ASC, month ASC, day ASC" + for _, s := range []string{"", "day"} { + o.GroupByPeriod = s + assert.Equal(t, expectedDefaultSQL, generateMysqlTradingVolumeQuerySQL(o)) + } + }) + +} + +func Test_queryTradesSQL(t *testing.T) { + t.Run("generate order by clause by Ordering option", func(t *testing.T) { + assert.Equal(t, "SELECT * FROM trades ORDER BY gid ASC LIMIT 500", queryTradesSQL(QueryTradesOptions{Limit: 500})) + assert.Equal(t, "SELECT * FROM trades ORDER BY gid ASC LIMIT 500", queryTradesSQL(QueryTradesOptions{Ordering: "ASC", Limit: 500})) + assert.Equal(t, "SELECT * FROM trades ORDER BY gid DESC LIMIT 500", queryTradesSQL(QueryTradesOptions{Ordering: "DESC", Limit: 500})) + }) + + t.Run("filter by exchange name", func(t *testing.T) { + assert.Equal(t, "SELECT * FROM trades WHERE exchange = :exchange ORDER BY gid ASC LIMIT 500", queryTradesSQL(QueryTradesOptions{Exchange: "max", Limit: 500})) + }) + + t.Run("filter by symbol", func(t *testing.T) { + assert.Equal(t, "SELECT * FROM trades WHERE symbol = :symbol ORDER BY gid ASC LIMIT 500", queryTradesSQL(QueryTradesOptions{Symbol: "eth", Limit: 500})) + }) + + t.Run("GID ordering", func(t *testing.T) { + assert.Equal(t, "SELECT * FROM trades WHERE gid > :gid ORDER BY gid ASC LIMIT 500", queryTradesSQL(QueryTradesOptions{LastGID: 1, Limit: 500})) + assert.Equal(t, "SELECT * FROM trades WHERE gid > :gid ORDER BY gid ASC LIMIT 500", queryTradesSQL(QueryTradesOptions{LastGID: 1, Ordering: "ASC", Limit: 500})) + assert.Equal(t, "SELECT * FROM trades WHERE gid < :gid ORDER BY gid DESC LIMIT 500", queryTradesSQL(QueryTradesOptions{LastGID: 1, Ordering: "DESC", Limit: 500})) + }) + + t.Run("convert all options", func(t *testing.T) { + assert.Equal(t, "SELECT * FROM trades WHERE gid < :gid AND symbol = :symbol AND exchange = :exchange ORDER BY gid DESC LIMIT 500", queryTradesSQL(QueryTradesOptions{ + Exchange: "max", + Symbol: "btc", + LastGID: 123, + Ordering: "DESC", + Limit: 500, + })) + }) +} diff --git a/pkg/service/withdraw.go b/pkg/service/withdraw.go new file mode 100644 index 0000000..37234f6 --- /dev/null +++ b/pkg/service/withdraw.go @@ -0,0 +1,131 @@ +package service + +import ( + "context" + "time" + + sq "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/batch" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type WithdrawService struct { + DB *sqlx.DB +} + +// Sync syncs the withdrawal records into db +func (s *WithdrawService) Sync(ctx context.Context, ex types.Exchange, startTime time.Time) error { + isMargin, isFutures, isIsolated, _ := exchange.GetSessionAttributes(ex) + if isMargin || isFutures || isIsolated { + // only works in spot + return nil + } + + transferApi, ok := ex.(types.ExchangeTransferService) + if !ok { + return nil + } + + tasks := []SyncTask{ + { + Type: types.Withdraw{}, + Select: SelectLastWithdraws(ex.Name(), 100), + BatchQuery: func(ctx context.Context, startTime, endTime time.Time) (interface{}, chan error) { + query := &batch.WithdrawBatchQuery{ + ExchangeTransferService: transferApi, + } + return query.Query(ctx, "", startTime, endTime) + }, + Time: func(obj interface{}) time.Time { + return obj.(types.Withdraw).ApplyTime.Time() + }, + ID: func(obj interface{}) string { + withdraw := obj.(types.Withdraw) + return withdraw.TransactionID + }, + Filter: func(obj interface{}) bool { + withdraw := obj.(types.Withdraw) + if withdraw.Status == "rejected" { + return false + } + + if len(withdraw.TransactionID) == 0 { + return false + } + + return true + }, + LogInsert: true, + }, + } + + for _, sel := range tasks { + if err := sel.execute(ctx, s.DB, startTime); err != nil { + return err + } + } + + return nil +} + +func SelectLastWithdraws(ex types.ExchangeName, limit uint64) sq.SelectBuilder { + return sq.Select("*"). + From("withdraws"). + Where(sq.And{ + sq.Eq{"exchange": ex}, + }). + OrderBy("time DESC"). + Limit(limit) +} + +func (s *WithdrawService) QueryLast(ex types.ExchangeName, limit int) ([]types.Withdraw, error) { + sql := "SELECT * FROM `withdraws` WHERE `exchange` = :exchange ORDER BY `time` DESC LIMIT :limit" + rows, err := s.DB.NamedQuery(sql, map[string]interface{}{ + "exchange": ex, + "limit": limit, + }) + if err != nil { + return nil, err + } + + defer rows.Close() + return s.scanRows(rows) +} + +func (s *WithdrawService) Query(exchangeName types.ExchangeName) ([]types.Withdraw, error) { + args := map[string]interface{}{ + "exchange": exchangeName, + } + sql := "SELECT * FROM `withdraws` WHERE `exchange` = :exchange ORDER BY `time` ASC" + rows, err := s.DB.NamedQuery(sql, args) + if err != nil { + return nil, err + } + + defer rows.Close() + + return s.scanRows(rows) +} + +func (s *WithdrawService) scanRows(rows *sqlx.Rows) (withdraws []types.Withdraw, err error) { + for rows.Next() { + var withdraw types.Withdraw + if err := rows.StructScan(&withdraw); err != nil { + return withdraws, err + } + + withdraws = append(withdraws, withdraw) + } + + return withdraws, rows.Err() +} + +func (s *WithdrawService) Insert(withdrawal types.Withdraw) error { + sql := `INSERT INTO withdraws (exchange, asset, network, address, amount, txn_id, txn_fee, time) + VALUES (:exchange, :asset, :network, :address, :amount, :txn_id, :txn_fee, :time)` + _, err := s.DB.NamedExec(sql, withdrawal) + return err +} diff --git a/pkg/service/withdraw_test.go b/pkg/service/withdraw_test.go new file mode 100644 index 0000000..57e9032 --- /dev/null +++ b/pkg/service/withdraw_test.go @@ -0,0 +1,41 @@ +package service + +import ( + "testing" + "time" + + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func TestWithdrawService(t *testing.T) { + db, err := prepareDB(t) + if err != nil { + t.Fatal(err) + } + + defer db.Close() + + xdb := sqlx.NewDb(db.DB, "sqlite3") + service := &WithdrawService{DB: xdb} + + err = service.Insert(types.Withdraw{ + Exchange: types.ExchangeMax, + Asset: "BTC", + Amount: fixedpoint.NewFromFloat(0.0001), + Address: "test", + TransactionID: "01", + TransactionFee: fixedpoint.NewFromFloat(0.0001), + Network: "omni", + ApplyTime: types.Time(time.Now()), + }) + assert.NoError(t, err) + + withdraws, err := service.Query(types.ExchangeMax) + assert.NoError(t, err) + assert.NotEmpty(t, withdraws) + assert.Equal(t, types.ExchangeMax, withdraws[0].Exchange) +} diff --git a/pkg/sigchan/sigchan.go b/pkg/sigchan/sigchan.go new file mode 100644 index 0000000..5f8ad69 --- /dev/null +++ b/pkg/sigchan/sigchan.go @@ -0,0 +1,38 @@ +package sigchan + +import "time" + +type Chan chan struct{} + +func New(cap int) Chan { + return make(Chan, cap) +} + +func (c Chan) Drain(duration, deadline time.Duration) (cnt int) { + cnt = 0 + + deadlineC := time.After(deadline) + for { + select { + case <-c: + cnt++ + + case <-deadlineC: + return cnt + + case <-time.After(duration): + return cnt + } + } +} + +func (c Chan) Emit() { + select { + case c <- struct{}{}: + default: + } +} + +func (c Chan) Close() { + close(c) +} diff --git a/pkg/slack/slacklog/logrus_look.go b/pkg/slack/slacklog/logrus_look.go new file mode 100644 index 0000000..dce4c26 --- /dev/null +++ b/pkg/slack/slacklog/logrus_look.go @@ -0,0 +1,77 @@ +package slacklog + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/sirupsen/logrus" + "github.com/slack-go/slack" + "golang.org/x/time/rate" +) + +var limiter = rate.NewLimiter(rate.Every(time.Minute), 3) + +type LogHook struct { + Slack *slack.Client + ErrorChannel string +} + +func NewLogHook(token string, channel string) *LogHook { + var client = slack.New(token) + return &LogHook{ + Slack: client, + ErrorChannel: channel, + } +} + +func (t *LogHook) Levels() []logrus.Level { + return []logrus.Level{ + // log.InfoLevel, + logrus.ErrorLevel, + logrus.PanicLevel, + // log.WarnLevel, + } +} + +func (t *LogHook) Fire(e *logrus.Entry) error { + if !limiter.Allow() { + return nil + } + + var color string + + switch e.Level { + case logrus.DebugLevel: + color = "#9B30FF" + case logrus.InfoLevel: + color = "good" + case logrus.ErrorLevel, logrus.FatalLevel, logrus.PanicLevel: + color = "danger" + default: + color = "warning" + } + + var slackAttachments []slack.Attachment = nil + + // error fields + var fields []slack.AttachmentField + for k, d := range e.Data { + fields = append(fields, slack.AttachmentField{ + Title: k, Value: fmt.Sprintf("%v", d), + }) + } + + slackAttachments = append(slackAttachments, slack.Attachment{ + Color: color, + Title: strings.ToUpper(e.Level.String()), + Fields: fields, + }) + + _, _, err := t.Slack.PostMessageContext(context.Background(), t.ErrorChannel, + slack.MsgOptionText(":balloon: "+e.Message, true), + slack.MsgOptionAttachments(slackAttachments...)) + + return err +} diff --git a/pkg/slack/slackstyle/style.go b/pkg/slack/slackstyle/style.go new file mode 100644 index 0000000..46914aa --- /dev/null +++ b/pkg/slack/slackstyle/style.go @@ -0,0 +1,19 @@ +package slackstyle + +// Green is the green hex color +const Green = "#228B22" + +// Red is the red hex color +const Red = "#800000" + +// TrendIcon returns the slack emoji of trends +// 1: uptrend +// -1: downtrend +func TrendIcon(trend int) string { + if trend < 0 { + return ":chart_with_downwards_trend:" + } else if trend > 0 { + return ":chart_with_upwards_trend:" + } + return "" +} diff --git a/pkg/strategy/atrpin/strategy.go b/pkg/strategy/atrpin/strategy.go new file mode 100644 index 0000000..f028143 --- /dev/null +++ b/pkg/strategy/atrpin/strategy.go @@ -0,0 +1,220 @@ +package atrpin + +import ( + "context" + "fmt" + "sync" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/common" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/sirupsen/logrus" +) + +const ID = "atrpin" + +var log = logrus.WithField("strategy", ID) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + *common.Strategy + + Environment *qbtrade.Environment + Market types.Market + + Symbol string `json:"symbol"` + + Interval types.Interval `json:"interval"` + Window int `json:"window"` + Multiplier float64 `json:"multiplier"` + MinPriceRange fixedpoint.Value `json:"minPriceRange"` + + qbtrade.QuantityOrAmount + // qbtrade.OpenPositionOptions + + logger *logrus.Entry +} + +func (s *Strategy) Initialize() error { + if s.Strategy == nil { + s.Strategy = &common.Strategy{} + } + + s.logger = log.WithFields(logrus.Fields{ + "symbol": s.Symbol, + "window": s.Window, + }) + return nil +} +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s:%s:%d", ID, s.Symbol, s.Interval, s.Window) +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: types.Interval1m}) +} + +func (s *Strategy) Defaults() error { + if s.Multiplier == 0.0 { + s.Multiplier = 10.0 + } + + if s.Interval == "" { + s.Interval = types.Interval5m + } + return nil +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + s.Strategy.Initialize(ctx, s.Environment, session, s.Market, ID, s.InstanceID()) + + atr := session.Indicators(s.Symbol).ATR(s.Interval, s.Window) + + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.Interval, func(k types.KLine) { + if err := s.Strategy.OrderExecutor.GracefulCancel(ctx); err != nil { + s.logger.WithError(err).Error("unable to cancel open orders...") + return + } + + account, err := session.UpdateAccount(ctx) + if err != nil { + s.logger.WithError(err).Error("unable to update account") + return + } + + baseBalance, ok := account.Balance(s.Market.BaseCurrency) + if !ok { + s.logger.Errorf("%s balance not found", s.Market.BaseCurrency) + return + } + quoteBalance, ok := account.Balance(s.Market.QuoteCurrency) + if !ok { + s.logger.Errorf("%s balance not found", s.Market.QuoteCurrency) + return + } + + lastAtr := atr.Last(0) + s.logger.Infof("atr: %f", lastAtr) + + // protection + if lastAtr <= k.High.Sub(k.Low).Float64() { + lastAtr = k.High.Sub(k.Low).Float64() + } + + priceRange := fixedpoint.NewFromFloat(lastAtr * s.Multiplier) + + // if the atr is too small, apply the price range protection with 10% + // priceRange protection 10% + priceRange = fixedpoint.Max(priceRange, k.Close.Mul(s.MinPriceRange)) + s.logger.Infof("priceRange: %f", priceRange.Float64()) + + ticker, err := session.Exchange.QueryTicker(ctx, s.Symbol) + if err != nil { + s.logger.WithError(err).Error("unable to query ticker") + return + } + + s.logger.Info(ticker.String()) + + bidPrice := fixedpoint.Max(ticker.Buy.Sub(priceRange), s.Market.TickSize) + askPrice := ticker.Sell.Add(priceRange) + + bidQuantity := s.QuantityOrAmount.CalculateQuantity(bidPrice) + askQuantity := s.QuantityOrAmount.CalculateQuantity(askPrice) + + var orderForms []types.SubmitOrder + + position := s.Strategy.OrderExecutor.Position() + s.logger.Infof("position: %+v", position) + + side := types.SideTypeBuy + takerPrice := ticker.Sell + if position.IsLong() { + side = types.SideTypeSell + takerPrice = ticker.Buy + } + + if !position.IsDust(takerPrice) { + s.logger.Infof("%s position is not dust", s.Symbol) + + orderForms = append(orderForms, types.SubmitOrder{ + Symbol: s.Symbol, + Type: types.OrderTypeLimit, + Side: side, + Price: takerPrice, + Quantity: position.GetQuantity(), + Market: s.Market, + TimeInForce: types.TimeInForceGTC, + Tag: "takeProfit", + }) + + s.logger.Infof("SUBMIT TAKER ORDER: %+v", orderForms) + + if _, err := s.Strategy.OrderExecutor.SubmitOrders(ctx, orderForms...); err != nil { + s.logger.WithError(err).Errorf("unable to submit orders: %+v", orderForms) + } + + return + } + + askQuantity = s.Market.AdjustQuantityByMinNotional(askQuantity, askPrice) + if !s.Market.IsDustQuantity(askQuantity, askPrice) && askQuantity.Compare(baseBalance.Available) < 0 { + orderForms = append(orderForms, types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeLimitMaker, + Quantity: askQuantity, + Price: askPrice, + Market: s.Market, + TimeInForce: types.TimeInForceGTC, + Tag: "pinOrder", + }) + } + + bidQuantity = s.Market.AdjustQuantityByMinNotional(bidQuantity, bidPrice) + if !s.Market.IsDustQuantity(bidQuantity, bidPrice) && bidQuantity.Mul(bidPrice).Compare(quoteBalance.Available) < 0 { + orderForms = append(orderForms, types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimitMaker, + Price: bidPrice, + Quantity: bidQuantity, + Market: s.Market, + Tag: "pinOrder", + }) + } + + if len(orderForms) == 0 { + s.logger.Infof("no %s order to place", s.Symbol) + return + } + + s.logger.Infof("%s bid/ask: %f/%f", s.Symbol, bidPrice.Float64(), askPrice.Float64()) + + s.logger.Infof("submit orders: %+v", orderForms) + if _, err := s.Strategy.OrderExecutor.SubmitOrders(ctx, orderForms...); err != nil { + s.logger.WithError(err).Errorf("unable to submit orders: %+v", orderForms) + } + })) + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + if err := s.Strategy.OrderExecutor.GracefulCancel(ctx); err != nil { + s.logger.WithError(err).Error("unable to cancel open orders...") + } + + qbtrade.Sync(ctx, s) + }) + + return nil +} diff --git a/pkg/strategy/audacitymaker/orderflow.go b/pkg/strategy/audacitymaker/orderflow.go new file mode 100644 index 0000000..2cabdd4 --- /dev/null +++ b/pkg/strategy/audacitymaker/orderflow.go @@ -0,0 +1,176 @@ +package audacitymaker + +import ( + "context" + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + + "gonum.org/v1/gonum/stat" +) + +type PerTrade struct { + Symbol string + Market types.Market `json:"-"` + types.IntervalWindow + + // MarketOrder is the option to enable market order short. + MarketOrder bool `json:"marketOrder"` + + Quantity fixedpoint.Value `json:"quantity"` + + orderExecutor *qbtrade.GeneralOrderExecutor + session *qbtrade.ExchangeSession + activeOrders *qbtrade.ActiveOrderBook + + StreamBook *types.StreamOrderBook + + midPrice fixedpoint.Value + + qbtrade.QuantityOrAmount +} + +func (s *PerTrade) Bind(session *qbtrade.ExchangeSession, orderExecutor *qbtrade.GeneralOrderExecutor) { + s.session = session + s.orderExecutor = orderExecutor + + position := orderExecutor.Position() + symbol := position.Symbol + // ger best bid/ask, not used yet + s.StreamBook = types.NewStreamBook(symbol) + s.StreamBook.BindStream(session.MarketDataStream) + + // use queue to do time-series rolling + buyTradeSize := types.NewQueue(200) + sellTradeSize := types.NewQueue(200) + buyTradesNumber := types.NewQueue(200) + sellTradesNumber := types.NewQueue(200) + // [WIP] Order Aggressiveness refers to the percentage of orders that are submitted at market prices, as opposed to limit prices. + + // Order flow is the difference between buyer-initiated and seller-initiated trading volume or number of trades. + var orderFlowSize floats.Slice + var orderFlowNumber floats.Slice + + var orderFlowSizeMinMax floats.Slice + var orderFlowNumberMinMax floats.Slice + + threshold := 3. + + session.MarketDataStream.OnMarketTrade(func(trade types.Trade) { + + //log.Infof("%s trade @ %f", trade.Side, trade.Price.Float64()) + + ctx := context.Background() + + if trade.Side == types.SideTypeBuy { + // accumulating trading volume from buyer + buyTradeSize.Update(trade.Quantity.Float64()) + sellTradeSize.Update(0) + // counting trades of number from seller + buyTradesNumber.Update(1) + sellTradesNumber.Update(0) + + } else if trade.Side == types.SideTypeSell { + // accumulating trading volume from buyer + buyTradeSize.Update(0) + sellTradeSize.Update(trade.Quantity.Float64()) + // counting trades of number from seller + buyTradesNumber.Update(0) + sellTradesNumber.Update(1) + } + + //canceled := s.orderExecutor.GracefulCancel(ctx) + //if canceled != nil { + // _ = s.orderExecutor.GracefulCancel(ctx) + //} + + sizeFraction := buyTradeSize.Sum() / sellTradeSize.Sum() + numberFraction := buyTradesNumber.Sum() / sellTradesNumber.Sum() + orderFlowSize.Push(sizeFraction) + if orderFlowSize.Length() > 100 { + // min-max scaling + ofsMax := orderFlowSize.Tail(100).Max() + ofsMin := orderFlowSize.Tail(100).Min() + ofsMinMax := (orderFlowSize.Last(0) - ofsMin) / (ofsMax - ofsMin) + // preserves temporal dependency via polar encoded angles + orderFlowSizeMinMax.Push(ofsMinMax) + } + + orderFlowNumber.Push(numberFraction) + if orderFlowNumber.Length() > 100 { + // min-max scaling + ofnMax := orderFlowNumber.Tail(100).Max() + ofnMin := orderFlowNumber.Tail(100).Min() + ofnMinMax := (orderFlowNumber.Last(0) - ofnMin) / (ofnMax - ofnMin) + // preserves temporal dependency via polar encoded angles + orderFlowNumberMinMax.Push(ofnMinMax) + } + + if orderFlowSizeMinMax.Length() > 100 && orderFlowNumberMinMax.Length() > 100 { + bid, ask, _ := s.StreamBook.BestBidAndAsk() + if outlier(orderFlowSizeMinMax.Tail(100), threshold) > 0 && outlier(orderFlowNumberMinMax.Tail(100), threshold) > 0 { + _ = s.orderExecutor.GracefulCancel(ctx) + log.Infof("long!!") + //_ = s.placeTrade(ctx, types.SideTypeBuy, s.Quantity, symbol) + _ = s.placeOrder(ctx, types.SideTypeBuy, s.Quantity, bid.Price, symbol) + //_ = s.placeOrder(ctx, types.SideTypeSell, s.Quantity, ask.Price.Mul(fixedpoint.NewFromFloat(1.0005)), symbol) + } else if outlier(orderFlowSizeMinMax.Tail(100), threshold) < 0 && outlier(orderFlowNumberMinMax.Tail(100), threshold) < 0 { + _ = s.orderExecutor.GracefulCancel(ctx) + log.Infof("short!!") + //_ = s.placeTrade(ctx, types.SideTypeSell, s.Quantity, symbol) + _ = s.placeOrder(ctx, types.SideTypeSell, s.Quantity, ask.Price, symbol) + //_ = s.placeOrder(ctx, types.SideTypeBuy, s.Quantity, bid.Price.Mul(fixedpoint.NewFromFloat(0.9995)), symbol) + } + } + + }) + + session.MarketDataStream.OnKLineClosed(types.KLineWith(symbol, s.Interval, func(kline types.KLine) { + + log.Info(kline.NumberOfTrades) + + })) + + if !qbtrade.IsBackTesting { + session.MarketDataStream.OnMarketTrade(func(trade types.Trade) { + }) + } +} + +func (s *PerTrade) placeOrder(ctx context.Context, side types.SideType, quantity fixedpoint.Value, price fixedpoint.Value, symbol string) error { + market, _ := s.session.Market(symbol) + _, err := s.orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ + Symbol: symbol, + Market: market, + Side: side, + Type: types.OrderTypeLimitMaker, + Quantity: quantity, + Price: price, + Tag: "audacity-limit", + }) + return err +} + +func (s *PerTrade) placeTrade(ctx context.Context, side types.SideType, quantity fixedpoint.Value, symbol string) error { + market, _ := s.session.Market(symbol) + _, err := s.orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ + Symbol: symbol, + Market: market, + Side: side, + Type: types.OrderTypeMarket, + Quantity: quantity, + Tag: "audacity-market", + }) + return err +} + +func outlier(fs floats.Slice, multiplier float64) int { + stddev := stat.StdDev(fs, nil) + if fs.Last(0) > fs.Mean()+multiplier*stddev { + return 1 + } else if fs.Last(0) < fs.Mean()-multiplier*stddev { + return -1 + } + return 0 +} diff --git a/pkg/strategy/audacitymaker/strategy.go b/pkg/strategy/audacitymaker/strategy.go new file mode 100644 index 0000000..6a0596d --- /dev/null +++ b/pkg/strategy/audacitymaker/strategy.go @@ -0,0 +1,135 @@ +package audacitymaker + +import ( + "context" + "fmt" + "os" + "sync" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ID = "audacitymaker" + +var one = fixedpoint.One +var zero = fixedpoint.Zero + +var log = logrus.WithField("strategy", ID) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type IntervalWindowSetting struct { + types.IntervalWindow +} + +type Strategy struct { + Environment *qbtrade.Environment + Symbol string `json:"symbol"` + Market types.Market + + types.IntervalWindow + + // persistence fields + Position *types.Position `persistence:"position"` + ProfitStats *types.ProfitStats `persistence:"profit_stats"` + TradeStats *types.TradeStats `persistence:"trade_stats"` + + activeOrders *qbtrade.ActiveOrderBook + + OrderFlow *PerTrade `json:"orderFlow"` + + ExitMethods qbtrade.ExitMethodSet `json:"exits"` + + session *qbtrade.ExchangeSession + orderExecutor *qbtrade.GeneralOrderExecutor + + // StrategyController + qbtrade.StrategyController +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.BookChannel, s.Symbol, types.SubscribeOptions{}) + session.Subscribe(types.MarketTradeChannel, s.Symbol, types.SubscribeOptions{}) + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.OrderFlow.Interval}) + + if !qbtrade.IsBackTesting { + session.Subscribe(types.MarketTradeChannel, s.Symbol, types.SubscribeOptions{}) + } + + s.ExitMethods.SetAndSubscribe(session, s) +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s", ID, s.Symbol) +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + var instanceID = s.InstanceID() + + 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) + } + + // StrategyController + s.Status = types.StrategyStatusRunning + + s.OnSuspend(func() { + // Cancel active orders + _ = s.orderExecutor.GracefulCancel(ctx) + }) + + s.OnEmergencyStop(func() { + // Cancel active orders + _ = s.orderExecutor.GracefulCancel(ctx) + // Close 100% position + // _ = s.ClosePosition(ctx, fixedpoint.One) + }) + + // initial required information + s.session = session + + s.orderExecutor = qbtrade.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, s.Position) + s.orderExecutor.BindEnvironment(s.Environment) + s.orderExecutor.BindProfitStats(s.ProfitStats) + s.orderExecutor.BindTradeStats(s.TradeStats) + s.orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { + qbtrade.Sync(ctx, s) + }) + s.orderExecutor.Bind() + s.activeOrders = qbtrade.NewActiveOrderBook(s.Symbol) + + for _, method := range s.ExitMethods { + method.Bind(session, s.orderExecutor) + } + + if s.OrderFlow != nil { + s.OrderFlow.Bind(session, s.orderExecutor) + } + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + _, _ = fmt.Fprintln(os.Stderr, s.TradeStats.String()) + _ = s.orderExecutor.GracefulCancel(ctx) + }) + + return nil +} diff --git a/pkg/strategy/autoborrow/strategy.go b/pkg/strategy/autoborrow/strategy.go new file mode 100644 index 0000000..49ad52e --- /dev/null +++ b/pkg/strategy/autoborrow/strategy.go @@ -0,0 +1,681 @@ +package autoborrow + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/sirupsen/logrus" + "github.com/slack-go/slack" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/binance" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ID = "autoborrow" + +var log = logrus.WithField("strategy", ID) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +/* + - on: binance + autoborrow: + interval: 30m + repayWhenDeposit: true + + # minMarginLevel for triggering auto borrow + minMarginLevel: 1.5 + assets: + + - asset: ETH + low: 3.0 + maxQuantityPerBorrow: 1.0 + maxTotalBorrow: 10.0 + + - asset: USDT + low: 1000.0 + maxQuantityPerBorrow: 100.0 + maxTotalBorrow: 10.0 +*/ + +// MarginAlert is used to send the slack mention alerts when the current margin is less than the required margin level +type MarginAlert struct { + CurrentMarginLevel fixedpoint.Value + MinimalMarginLevel fixedpoint.Value + SlackMentions []string + SessionName string +} + +func (m *MarginAlert) SlackAttachment() slack.Attachment { + return slack.Attachment{ + Color: "red", + Title: fmt.Sprintf("Margin Level Alert: %s session - current margin level %f < required margin level %f", + m.SessionName, m.CurrentMarginLevel.Float64(), m.MinimalMarginLevel.Float64()), + Text: strings.Join(m.SlackMentions, " "), + Fields: []slack.AttachmentField{ + { + Title: "Session", + Value: m.SessionName, + Short: true, + }, + { + Title: "Current Margin Level", + Value: m.CurrentMarginLevel.String(), + Short: true, + }, + { + Title: "Minimal Margin Level", + Value: m.MinimalMarginLevel.String(), + Short: true, + }, + }, + // Footer: "", + // FooterIcon: "", + } +} + +// RepaidAlert +type RepaidAlert struct { + SessionName string + Asset string + Amount fixedpoint.Value + SlackMentions []string +} + +func (m *RepaidAlert) SlackAttachment() slack.Attachment { + return slack.Attachment{ + Color: "red", + Title: fmt.Sprintf("Margin Repaid on %s session", m.SessionName), + Text: strings.Join(m.SlackMentions, " "), + Fields: []slack.AttachmentField{ + { + Title: "Session", + Value: m.SessionName, + Short: true, + }, + { + Title: "Asset", + Value: m.Amount.String() + " " + m.Asset, + Short: true, + }, + }, + // Footer: "", + // FooterIcon: "", + } +} + +type MarginAsset struct { + Asset string `json:"asset"` + Low fixedpoint.Value `json:"low"` + MaxTotalBorrow fixedpoint.Value `json:"maxTotalBorrow"` + MaxQuantityPerBorrow fixedpoint.Value `json:"maxQuantityPerBorrow"` + MinQuantityPerBorrow fixedpoint.Value `json:"minQuantityPerBorrow"` + DebtRatio fixedpoint.Value `json:"debtRatio"` +} + +type MarginLevelAlert struct { + Interval types.Duration `json:"interval"` + MinMargin fixedpoint.Value `json:"minMargin"` + SlackMentions []string `json:"slackMentions"` +} + +type MarginRepayAlert struct { + SlackMentions []string `json:"slackMentions"` +} + +type Strategy struct { + Interval types.Interval `json:"interval"` + MinMarginLevel fixedpoint.Value `json:"minMarginLevel"` + MaxMarginLevel fixedpoint.Value `json:"maxMarginLevel"` + AutoRepayWhenDeposit bool `json:"autoRepayWhenDeposit"` + + MarginLevelAlert *MarginLevelAlert `json:"marginLevelAlert"` + MarginRepayAlert *MarginRepayAlert `json:"marginRepayAlert"` + + Assets []MarginAsset `json:"assets"` + + ExchangeSession *qbtrade.ExchangeSession + + marginBorrowRepay types.MarginBorrowRepayService +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + // session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: "1m"}) +} + +func (s *Strategy) tryToRepayAnyDebt(ctx context.Context) { + log.Infof("trying to repay any debt...") + + account, err := s.ExchangeSession.UpdateAccount(ctx) + if err != nil { + log.WithError(err).Errorf("can not update account") + return + } + + minMarginLevel := s.MinMarginLevel + curMarginLevel := account.MarginLevel + + balances := account.Balances() + for _, b := range balances { + debt := b.Debt() + + if debt.Sign() <= 0 { + continue + } + + if b.Available.IsZero() { + continue + } + + toRepay := fixedpoint.Min(b.Available, debt) + if toRepay.IsZero() { + continue + } + + qbtrade.Notify(&MarginAction{ + Exchange: s.ExchangeSession.ExchangeName, + Action: "Repay", + Asset: b.Currency, + Amount: toRepay, + MarginLevel: curMarginLevel, + MinMarginLevel: minMarginLevel, + }) + + log.Infof("repaying %f %s", toRepay.Float64(), b.Currency) + if err := s.marginBorrowRepay.RepayMarginAsset(context.Background(), b.Currency, toRepay); err != nil { + log.WithError(err).Errorf("margin repay error") + } + + if s.MarginRepayAlert != nil { + qbtrade.Notify(&RepaidAlert{ + SessionName: s.ExchangeSession.Name, + Asset: b.Currency, + Amount: toRepay, + SlackMentions: s.MarginRepayAlert.SlackMentions, + }) + } + + return + } +} + +func (s *Strategy) reBalanceDebt(ctx context.Context) { + log.Infof("rebalancing debt...") + + account, err := s.ExchangeSession.UpdateAccount(ctx) + if err != nil { + log.WithError(err).Errorf("can not update account") + return + } + + minMarginLevel := s.MinMarginLevel + + balances := account.Balances().NotZero() + if len(balances) == 0 { + log.Warn("balance is empty, skip repay") + return + } + + log.Infof("non-zero balances: %+v", balances) + + for _, marginAsset := range s.Assets { + b, ok := balances[marginAsset.Asset] + if !ok { + continue + } + + // debt / total + debt := b.Debt() + total := b.Total() + + debtRatio := debt.Div(total) + + if marginAsset.DebtRatio.IsZero() { + marginAsset.DebtRatio = fixedpoint.One + } + + if total.Compare(marginAsset.Low) <= 0 { + log.Infof("%s total %f is less than margin asset low %f, skip early repay", marginAsset.Asset, total.Float64(), marginAsset.Low.Float64()) + continue + } + + log.Infof("checking debtRatio: session = %s asset = %s, debt = %f, total = %f, debtRatio = %f", s.ExchangeSession.Name, marginAsset.Asset, debt.Float64(), total.Float64(), debtRatio.Float64()) + + // if debt is greater than total, skip repay + if debt.Compare(total) > 0 { + log.Infof("%s debt %f is greater than total %f, skip early repay", marginAsset.Asset, debt.Float64(), total.Float64()) + continue + } + + // if debtRatio is lesser, means that we have more spot, we should try to repay as much as we can + if debtRatio.Compare(marginAsset.DebtRatio) > 0 { + log.Infof("%s debt ratio %f is greater than min debt ratio %f, skip", marginAsset.Asset, debtRatio.Float64(), marginAsset.DebtRatio.Float64()) + continue + } + + log.Infof("checking repayable balance: %+v", b) + + toRepay := debt + + if b.Available.IsZero() { + log.Errorf("%s available balance is 0, can not repay, balance = %+v", marginAsset.Asset, b) + continue + } + + toRepay = fixedpoint.Min(toRepay, b.Available) + + if !marginAsset.Low.IsZero() { + extra := b.Available.Sub(marginAsset.Low) + if extra.Sign() > 0 { + toRepay = fixedpoint.Min(extra, toRepay) + } + } + + if toRepay.Sign() <= 0 { + log.Warnf("%s repay = %f, available = %f, borrowed = %f, can not repay", + marginAsset.Asset, + toRepay.Float64(), + b.Available.Float64(), + b.Borrowed.Float64()) + continue + } + + log.Infof("%s repay %f", marginAsset.Asset, toRepay.Float64()) + + qbtrade.Notify(&MarginAction{ + Exchange: s.ExchangeSession.ExchangeName, + Action: fmt.Sprintf("Repay for Debt Ratio %f < Minimal Debt Ratio %f", debtRatio.Float64(), marginAsset.DebtRatio.Float64()), + Asset: b.Currency, + Amount: toRepay, + MarginLevel: account.MarginLevel, + MinMarginLevel: minMarginLevel, + }) + + if err := s.marginBorrowRepay.RepayMarginAsset(context.Background(), b.Currency, toRepay); err != nil { + log.WithError(err).Errorf("margin repay error") + } + + if s.MarginRepayAlert != nil { + qbtrade.Notify(&RepaidAlert{ + SessionName: s.ExchangeSession.Name, + Asset: b.Currency, + Amount: toRepay, + SlackMentions: s.MarginRepayAlert.SlackMentions, + }) + } + + if accountUpdate, err2 := s.ExchangeSession.UpdateAccount(ctx); err2 != nil { + log.WithError(err).Errorf("unable to update account") + } else { + account = accountUpdate + } + } +} + +func (s *Strategy) checkAndBorrow(ctx context.Context) { + s.reBalanceDebt(ctx) + + if s.MinMarginLevel.IsZero() { + return + } + + account, err := s.ExchangeSession.UpdateAccount(ctx) + if err != nil { + log.WithError(err).Errorf("can not update account") + return + } + + minMarginLevel := s.MinMarginLevel + curMarginLevel := account.MarginLevel + + log.Infof("%s: current margin level: %s, margin ratio: %s, margin tolerance: %s", + s.ExchangeSession.Name, + account.MarginLevel.String(), + account.MarginRatio.String(), + account.MarginTolerance.String(), + ) + + // if margin ratio is too low, do not borrow + for maxTries := 5; account.MarginLevel.Compare(minMarginLevel) < 0 && maxTries > 0; maxTries-- { + log.Infof("current margin level %f < min margin level %f, skip autoborrow", account.MarginLevel.Float64(), minMarginLevel.Float64()) + + qbtrade.Notify("Warning!!! %s Current Margin Level %f < Minimal Margin Level %f", + s.ExchangeSession.Name, + account.MarginLevel.Float64(), + minMarginLevel.Float64(), + account.Balances().Debts(), + ) + + s.tryToRepayAnyDebt(ctx) + + select { + case <-ctx.Done(): + return + case <-time.After(time.Second * 5): + } + + // update account info after the repay + account, err = s.ExchangeSession.UpdateAccount(ctx) + if err != nil { + log.WithError(err).Errorf("can not update account") + return + } + } + + balances := account.Balances() + if len(balances) == 0 { + log.Warn("balance is empty, skip autoborrow") + return + } + + for _, marginAsset := range s.Assets { + changed := false + + if marginAsset.Low.IsZero() { + log.Warnf("margin asset low balance is not set: %+v", marginAsset) + continue + } + + b, ok := balances[marginAsset.Asset] + if ok { + toBorrow := marginAsset.Low.Sub(b.Total()) + if toBorrow.Sign() < 0 { + log.Infof("balance %f > low %f. no need to borrow asset %+v", + b.Total().Float64(), + marginAsset.Low.Float64(), + marginAsset) + continue + } + + if !marginAsset.MaxQuantityPerBorrow.IsZero() { + toBorrow = fixedpoint.Min(toBorrow, marginAsset.MaxQuantityPerBorrow) + } + + if !marginAsset.MaxTotalBorrow.IsZero() { + // check if we over borrow + newTotalBorrow := toBorrow.Add(b.Borrowed) + if newTotalBorrow.Compare(marginAsset.MaxTotalBorrow) > 0 { + toBorrow = toBorrow.Sub(newTotalBorrow.Sub(marginAsset.MaxTotalBorrow)) + if toBorrow.Sign() < 0 { + log.Warnf("margin asset %s is over borrowed, skip", marginAsset.Asset) + continue + } + } + } + + maxBorrowable, err2 := s.marginBorrowRepay.QueryMarginAssetMaxBorrowable(ctx, marginAsset.Asset) + if err2 != nil { + log.WithError(err).Errorf("max borrowable query error") + continue + } + + if toBorrow.Compare(maxBorrowable) > 0 { + qbtrade.Notify("Trying to borrow %f %s, which is greater than the max borrowable amount %f, will adjust borrow amount to %f", + toBorrow.Float64(), + marginAsset.Asset, + maxBorrowable.Float64(), + maxBorrowable.Float64()) + + toBorrow = fixedpoint.Min(maxBorrowable, toBorrow) + } + + if toBorrow.IsZero() { + continue + } + + qbtrade.Notify(&MarginAction{ + Exchange: s.ExchangeSession.ExchangeName, + Action: "Borrow", + Asset: marginAsset.Asset, + Amount: toBorrow, + MarginLevel: account.MarginLevel, + MinMarginLevel: minMarginLevel, + }) + log.Infof("sending borrow request %f %s", toBorrow.Float64(), marginAsset.Asset) + if err := s.marginBorrowRepay.BorrowMarginAsset(ctx, marginAsset.Asset, toBorrow); err != nil { + log.WithError(err).Errorf("borrow error") + continue + } + changed = true + } else { + // available balance is less than marginAsset.Low, we should trigger borrow + toBorrow := marginAsset.Low + + if !marginAsset.MaxQuantityPerBorrow.IsZero() { + toBorrow = fixedpoint.Min(toBorrow, marginAsset.MaxQuantityPerBorrow) + } + + if toBorrow.IsZero() { + continue + } + + qbtrade.Notify(&MarginAction{ + Exchange: s.ExchangeSession.ExchangeName, + Action: "Borrow", + Asset: marginAsset.Asset, + Amount: toBorrow, + MarginLevel: curMarginLevel, + MinMarginLevel: minMarginLevel, + }) + + log.Infof("sending borrow request %f %s", toBorrow.Float64(), marginAsset.Asset) + if err := s.marginBorrowRepay.BorrowMarginAsset(ctx, marginAsset.Asset, toBorrow); err != nil { + log.WithError(err).Errorf("borrow error") + continue + } + + changed = true + } + + // if debt is changed, we need to update account + if changed { + account, err = s.ExchangeSession.UpdateAccount(ctx) + if err != nil { + log.WithError(err).Errorf("can not update account") + return + } + } + } +} + +func (s *Strategy) run(ctx context.Context, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + s.checkAndBorrow(ctx) + for { + select { + case <-ctx.Done(): + return + + case <-ticker.C: + s.checkAndBorrow(ctx) + + } + } +} + +func (s *Strategy) handleBalanceUpdate(balances types.BalanceMap) { + if s.MinMarginLevel.IsZero() { + return + } + + if s.ExchangeSession.GetAccount().MarginLevel.Compare(s.MinMarginLevel) > 0 { + return + } + + for _, b := range balances { + if b.Available.IsZero() && b.Borrowed.IsZero() { + continue + } + } +} + +func (s *Strategy) handleBinanceBalanceUpdateEvent(event *binance.BalanceUpdateEvent) { + qbtrade.Notify(event) + + account := s.ExchangeSession.GetAccount() + + delta := event.Delta + + // ignore outflow + if delta.Sign() < 0 { + return + } + + minMarginLevel := s.MinMarginLevel + curMarginLevel := account.MarginLevel + + // margin repay/borrow also trigger this update event + if curMarginLevel.Compare(minMarginLevel) > 0 { + return + } + + if b, ok := account.Balance(event.Asset); ok { + if b.Available.IsZero() { + return + } + + debt := b.Debt() + if debt.IsZero() { + return + } + + toRepay := fixedpoint.Min(debt, b.Available) + if toRepay.IsZero() { + return + } + + qbtrade.Notify(&MarginAction{ + Exchange: s.ExchangeSession.ExchangeName, + Action: "Repay", + Asset: b.Currency, + Amount: toRepay, + MarginLevel: curMarginLevel, + MinMarginLevel: minMarginLevel, + }) + + if err := s.marginBorrowRepay.RepayMarginAsset(context.Background(), event.Asset, toRepay); err != nil { + log.WithError(err).Errorf("margin repay error") + } + } +} + +type MarginAction struct { + Exchange types.ExchangeName `json:"exchange"` + Action string `json:"action"` + Asset string `json:"asset"` + Amount fixedpoint.Value `json:"amount"` + MarginLevel fixedpoint.Value `json:"marginLevel"` + MinMarginLevel fixedpoint.Value `json:"minMarginLevel"` +} + +func (a *MarginAction) SlackAttachment() slack.Attachment { + return slack.Attachment{ + Title: fmt.Sprintf("%s %s %s", a.Action, a.Amount, a.Asset), + Color: "warning", + Fields: []slack.AttachmentField{ + { + Title: "Exchange", + Value: a.Exchange.String(), + Short: true, + }, + { + Title: "Action", + Value: a.Action, + Short: true, + }, + { + Title: "Asset", + Value: a.Asset, + Short: true, + }, + { + Title: "Amount", + Value: a.Amount.String(), + Short: true, + }, + { + Title: "Current Margin Level", + Value: a.MarginLevel.String(), + Short: true, + }, + { + Title: "Min Margin Level", + Value: a.MinMarginLevel.String(), + Short: true, + }, + }, + } +} + +// This strategy simply spent all available quote currency to buy the symbol whenever kline gets closed +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + if s.MinMarginLevel.IsZero() { + log.Warnf("%s: minMarginLevel is 0, you should configure this minimal margin ratio for controlling the liquidation risk", session.Name) + } + + s.ExchangeSession = session + + marginBorrowRepay, ok := session.Exchange.(types.MarginBorrowRepayService) + if !ok { + return fmt.Errorf("exchange %s does not implement types.MarginBorrowRepayService", session.Name) + } + + s.marginBorrowRepay = marginBorrowRepay + + if s.AutoRepayWhenDeposit { + binanceStream, ok := session.UserDataStream.(*binance.Stream) + if ok { + binanceStream.OnBalanceUpdateEvent(s.handleBinanceBalanceUpdateEvent) + } else { + session.UserDataStream.OnBalanceUpdate(s.handleBalanceUpdate) + } + } + + if s.MarginLevelAlert != nil && !s.MarginLevelAlert.MinMargin.IsZero() { + alertInterval := time.Minute * 5 + if s.MarginLevelAlert.Interval > 0 { + alertInterval = s.MarginLevelAlert.Interval.Duration() + } + + go s.marginAlertWorker(ctx, alertInterval) + } + + go s.run(ctx, s.Interval.Duration()) + return nil +} + +func (s *Strategy) marginAlertWorker(ctx context.Context, alertInterval time.Duration) { + go func() { + ticker := time.NewTicker(alertInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + account := s.ExchangeSession.GetAccount() + if s.MarginLevelAlert != nil && account.MarginLevel.Compare(s.MarginLevelAlert.MinMargin) <= 0 { + qbtrade.Notify(&MarginAlert{ + CurrentMarginLevel: account.MarginLevel, + MinimalMarginLevel: s.MarginLevelAlert.MinMargin, + SlackMentions: s.MarginLevelAlert.SlackMentions, + SessionName: s.ExchangeSession.Name, + }) + qbtrade.Notify(account.Balances().Debts()) + } + } + } + }() +} diff --git a/pkg/strategy/autobuy/strategy.go b/pkg/strategy/autobuy/strategy.go new file mode 100644 index 0000000..b0bf331 --- /dev/null +++ b/pkg/strategy/autobuy/strategy.go @@ -0,0 +1,157 @@ +package autobuy + +import ( + "context" + "fmt" + "sync" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + indicatorv2 "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator/v2" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/common" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/robfig/cron/v3" + "github.com/sirupsen/logrus" +) + +const ID = "autobuy" + +var log = logrus.WithField("strategy", ID) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + *common.Strategy + + Environment *qbtrade.Environment + Market types.Market + + Symbol string `json:"symbol"` + Schedule string `json:"schedule"` + Threshold fixedpoint.Value `json:"threshold"` + PriceType types.PriceType `json:"priceType"` + Bollinger *types.BollingerSetting `json:"bollinger"` + DryRun bool `json:"dryRun"` + + qbtrade.QuantityOrAmount + + boll *indicatorv2.BOLLStream + cron *cron.Cron +} + +func (s *Strategy) Initialize() error { + if s.Strategy == nil { + s.Strategy = &common.Strategy{} + } + return nil +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s", ID, s.Symbol) +} + +func (s *Strategy) Validate() error { + if err := s.QuantityOrAmount.Validate(); err != nil { + return err + } + return nil +} + +func (s *Strategy) Defaults() error { + if s.PriceType == "" { + s.PriceType = types.PriceTypeMaker + } + return nil +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Bollinger.Interval}) +} + +func (s *Strategy) Run(ctx context.Context, _ qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + s.Strategy.Initialize(ctx, s.Environment, session, s.Market, ID, s.InstanceID()) + + s.boll = session.Indicators(s.Symbol).BOLL(s.Bollinger.IntervalWindow, s.Bollinger.BandWidth) + + s.OrderExecutor.ActiveMakerOrders().OnFilled(func(order types.Order) { + s.autobuy(ctx) + }) + + // the shutdown handler, you can cancel all orders + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + s.cancelOrders(ctx) + qbtrade.Sync(ctx, s) + }) + + s.cron = cron.New() + s.cron.AddFunc(s.Schedule, func() { + s.autobuy(ctx) + }) + s.cron.Start() + + return nil +} + +func (s *Strategy) cancelOrders(ctx context.Context) { + if err := s.OrderExecutor.GracefulCancel(ctx); err != nil { + log.WithError(err).Errorf("failed to cancel orders") + } +} + +func (s *Strategy) autobuy(ctx context.Context) { + s.cancelOrders(ctx) + + balance, ok := s.Session.GetAccount().Balance(s.Market.BaseCurrency) + if !ok { + log.Errorf("%s balance not found", s.Market.BaseCurrency) + return + } + log.Infof("balance: %s", balance.String()) + + ticker, err := s.Session.Exchange.QueryTicker(ctx, s.Symbol) + if err != nil { + log.WithError(err).Errorf("failed to query ticker") + return + } + + side := types.SideTypeBuy + price := s.PriceType.Map(ticker, side) + + if price.Float64() > s.boll.UpBand.Last(0) { + log.Infof("price %s is higher than upper band %f, skip", price.String(), s.boll.UpBand.Last(0)) + return + } + + if balance.Available.Compare(s.Threshold) > 0 { + log.Infof("balance %s is higher than threshold %s", balance.Available.String(), s.Threshold.String()) + return + } + log.Infof("balance %s is lower than threshold %s", balance.Available.String(), s.Threshold.String()) + + quantity := s.CalculateQuantity(price) + submitOrder := types.SubmitOrder{ + Symbol: s.Symbol, + Side: side, + Type: types.OrderTypeLimitMaker, + Quantity: quantity, + Price: price, + } + + if s.DryRun { + log.Infof("dry run, skip") + return + } + + log.Infof("submitting order: %s", submitOrder.String()) + _, err = s.OrderExecutor.SubmitOrders(ctx, submitOrder) + if err != nil { + log.WithError(err).Errorf("submit order error") + } +} diff --git a/pkg/strategy/bollgrid/strategy.go b/pkg/strategy/bollgrid/strategy.go new file mode 100644 index 0000000..79520a6 --- /dev/null +++ b/pkg/strategy/bollgrid/strategy.go @@ -0,0 +1,395 @@ +package bollgrid + +import ( + "context" + "fmt" + "sync" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/core" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ID = "bollgrid" + +var log = logrus.WithField("strategy", ID) + +func init() { + // Register the pointer of the strategy struct, + // so that qbtrade knows what struct to be used to unmarshal the configs (YAML or JSON) + // Note: built-in strategies need to imported manually in the qbtrade cmd package. + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + // OrderExecutor is an interface for submitting order. + // This field will be injected automatically since it's a single exchange strategy. + qbtrade.OrderExecutor + + // if Symbol string field is defined, qbtrade will know it's a symbol-based strategy + // The following embedded fields will be injected with the corresponding instances. + + // MarketDataStore is a pointer only injection field. public trades, k-lines (candlestick) + // and order book updates are maintained in the market data store. + // This field will be injected automatically since we defined the Symbol field. + *qbtrade.MarketDataStore + + // StandardIndicatorSet contains the standard indicators of a market (symbol) + // This field will be injected automatically since we defined the Symbol field. + *qbtrade.StandardIndicatorSet + + // 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 + + // These fields will be filled from the config file (it translates YAML to JSON) + Symbol string `json:"symbol"` + + // Interval is the interval used by the BOLLINGER indicator (which uses K-Line as its source price) + Interval types.Interval `json:"interval"` + + // RepostInterval is the interval for re-posting maker orders + RepostInterval types.Interval `json:"repostInterval"` + + // GridPips is the pips of grid + // e.g., 0.001, so that your orders will be submitted at price like 0.127, 0.128, 0.129, 0.130 + GridPips fixedpoint.Value `json:"gridPips"` + + ProfitSpread fixedpoint.Value `json:"profitSpread"` + + // GridNum is the grid number, how many orders you want to post on the orderbook. + GridNum int `json:"gridNumber"` + + // Quantity is the quantity you want to submit for each order. + Quantity fixedpoint.Value `json:"quantity"` + + // activeOrders is the locally maintained active order book of the maker orders. + activeOrders *qbtrade.ActiveOrderBook + + profitOrders *qbtrade.ActiveOrderBook + + orders *core.OrderStore + + // boll is the BOLLINGER indicator we used for predicting the price. + boll *indicator.BOLL + + CancelProfitOrdersOnShutdown bool `json: "shutdownCancelProfitOrders"` +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) Validate() error { + if s.ProfitSpread.Sign() <= 0 { + // If profitSpread is empty or its value is negative + return fmt.Errorf("profit spread should bigger than 0") + } + if s.Quantity.Sign() <= 0 { + // If quantity is empty or its value is negative + return fmt.Errorf("quantity should bigger than 0") + } + return nil +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + if s.Interval == "" { + panic("bollgrid interval can not be empty") + } + + // currently we need the 1m kline to update the last close price and indicators + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) + + if len(s.RepostInterval) > 0 && s.Interval != s.RepostInterval { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.RepostInterval}) + } +} + +func (s *Strategy) generateGridBuyOrders(session *qbtrade.ExchangeSession) ([]types.SubmitOrder, error) { + balances := session.GetAccount().Balances() + quoteBalance := balances[s.Market.QuoteCurrency].Available + if quoteBalance.Sign() <= 0 { + return nil, fmt.Errorf("quote balance %s is zero: %v", s.Market.QuoteCurrency, quoteBalance) + } + + upBand, downBand := s.boll.LastUpBand(), s.boll.LastDownBand() + if upBand <= 0.0 { + return nil, fmt.Errorf("up band == 0") + } + if downBand <= 0.0 { + return nil, fmt.Errorf("down band == 0") + } + + currentPrice, ok := session.LastPrice(s.Symbol) + if !ok { + return nil, fmt.Errorf("last price not found") + } + + if currentPrice.Float64() > upBand || currentPrice.Float64() < downBand { + return nil, fmt.Errorf("current price %v exceed the bollinger band %f <> %f", currentPrice, upBand, downBand) + } + + ema99 := s.StandardIndicatorSet.EWMA(types.IntervalWindow{Interval: s.Interval, Window: 99}) + ema25 := s.StandardIndicatorSet.EWMA(types.IntervalWindow{Interval: s.Interval, Window: 25}) + ema7 := s.StandardIndicatorSet.EWMA(types.IntervalWindow{Interval: s.Interval, Window: 7}) + if ema7.Last(0) > ema25.Last(0)*1.001 && ema25.Last(0) > ema99.Last(0)*1.0005 { + log.Infof("all ema lines trend up, skip buy") + return nil, nil + } + + priceRange := upBand - downBand + gridSize := priceRange / float64(s.GridNum) + + var orders []types.SubmitOrder + for pricef := upBand; pricef >= downBand; pricef -= gridSize { + if pricef >= currentPrice.Float64() { + continue + } + price := fixedpoint.NewFromFloat(pricef) + // adjust buy quantity using current quote balance + quantity := qbtrade.AdjustFloatQuantityByMaxAmount(s.Quantity, price, quoteBalance) + order := types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Market: s.Market, + Quantity: quantity, + Price: price, + TimeInForce: types.TimeInForceGTC, + } + quoteQuantity := order.Quantity.Mul(price) + if quantity.Compare(s.MinQuantity) < 0 { + // don't submit this order if buy quantity is too small + log.Infof("quote balance %v is not enough, stop generating buy orders", quoteBalance) + break + } + quoteBalance = quoteBalance.Sub(quoteQuantity) + log.Infof("submitting order: %s", order.String()) + orders = append(orders, order) + } + return orders, nil +} + +func (s *Strategy) generateGridSellOrders(session *qbtrade.ExchangeSession) ([]types.SubmitOrder, error) { + balances := session.GetAccount().Balances() + baseBalance := balances[s.Market.BaseCurrency].Available + if baseBalance.Sign() <= 0 { + return nil, fmt.Errorf("base balance %s is zero: %+v", s.Market.BaseCurrency, baseBalance) + } + + upBand, downBand := s.boll.LastUpBand(), s.boll.LastDownBand() + if upBand <= 0.0 { + return nil, fmt.Errorf("up band == 0") + } + if downBand <= 0.0 { + return nil, fmt.Errorf("down band == 0") + } + + currentPrice, ok := session.LastPrice(s.Symbol) + if !ok { + return nil, fmt.Errorf("last price not found") + } + + currentPricef := currentPrice.Float64() + + if currentPricef > upBand || currentPricef < downBand { + return nil, fmt.Errorf("current price exceed the bollinger band") + } + + ema99 := s.StandardIndicatorSet.EWMA(types.IntervalWindow{Interval: s.Interval, Window: 99}) + ema25 := s.StandardIndicatorSet.EWMA(types.IntervalWindow{Interval: s.Interval, Window: 25}) + ema7 := s.StandardIndicatorSet.EWMA(types.IntervalWindow{Interval: s.Interval, Window: 7}) + if ema7.Last(0) < ema25.Last(0)*(1-0.004) && ema25.Last(0) < ema99.Last(0)*(1-0.0005) { + log.Infof("all ema lines trend down, skip sell") + return nil, nil + } + + priceRange := upBand - downBand + gridSize := priceRange / float64(s.GridNum) + + var orders []types.SubmitOrder + for pricef := downBand; pricef <= upBand; pricef += gridSize { + if pricef <= currentPricef { + continue + } + price := fixedpoint.NewFromFloat(pricef) + // adjust sell quantity using current base balance + quantity := fixedpoint.Min(s.Quantity, baseBalance) + order := types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeLimit, + Market: s.Market, + Quantity: quantity, + Price: price, + TimeInForce: types.TimeInForceGTC, + } + baseQuantity := order.Quantity + if quantity.Compare(s.MinQuantity) < 0 { + // don't submit this order if sell quantity is too small + log.Infof("base balance %s is not enough, stop generating sell orders", baseBalance) + break + } + baseBalance = baseBalance.Sub(baseQuantity) + log.Infof("submitting order: %s", order.String()) + orders = append(orders, order) + } + return orders, nil +} + +func (s *Strategy) placeGridOrders(orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) { + sellOrders, err := s.generateGridSellOrders(session) + if err != nil { + log.Warn(err.Error()) + } + createdSellOrders, err := orderExecutor.SubmitOrders(context.Background(), sellOrders...) + if err != nil { + log.WithError(err).Errorf("can not place sell orders") + } + + buyOrders, err := s.generateGridBuyOrders(session) + if err != nil { + log.Warn(err.Error()) + } + createdBuyOrders, err := orderExecutor.SubmitOrders(context.Background(), buyOrders...) + if err != nil { + log.WithError(err).Errorf("can not place buy orders") + } + + createdOrders := append(createdSellOrders, createdBuyOrders...) + s.activeOrders.Add(createdOrders...) + s.orders.Add(createdOrders...) +} + +func (s *Strategy) updateOrders(orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) { + if err := orderExecutor.CancelOrders(context.Background(), s.activeOrders.Orders()...); err != nil { + log.WithError(err).Errorf("cancel order error") + } + + // skip order updates if up-band - down-band < min profit spread + if (s.boll.LastUpBand() - s.boll.LastDownBand()) <= s.ProfitSpread.Float64() { + log.Infof("boll: down band price == up band price, skipping...") + return + } + + s.placeGridOrders(orderExecutor, session) + + s.activeOrders.Print() +} + +func (s *Strategy) submitReverseOrder(order types.Order, session *qbtrade.ExchangeSession) { + balances := session.GetAccount().Balances() + + var side = order.Side.Reverse() + var price = order.Price + var quantity = order.Quantity + + switch side { + case types.SideTypeSell: + price = price.Add(s.ProfitSpread) + maxQuantity := balances[s.Market.BaseCurrency].Available + quantity = fixedpoint.Min(quantity, maxQuantity) + + case types.SideTypeBuy: + price = price.Sub(s.ProfitSpread) + maxQuantity := balances[s.Market.QuoteCurrency].Available.Div(price) + quantity = fixedpoint.Min(quantity, maxQuantity) + } + + submitOrder := types.SubmitOrder{ + Symbol: s.Symbol, + Side: side, + Type: types.OrderTypeLimit, + Quantity: quantity, + Price: price, + TimeInForce: types.TimeInForceGTC, + } + + log.Infof("submitting reverse order: %s against %s", submitOrder.String(), order.String()) + + createdOrders, err := s.OrderExecutor.SubmitOrders(context.Background(), submitOrder) + if err != nil { + log.WithError(err).Errorf("can not place orders") + return + } + + s.profitOrders.Add(createdOrders...) + s.orders.Add(createdOrders...) +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + if s.GridNum == 0 { + s.GridNum = 2 + } + + s.boll = s.StandardIndicatorSet.BOLL(types.IntervalWindow{ + Interval: s.Interval, + Window: 21, + }, 2.0) + + s.orders = core.NewOrderStore(s.Symbol) + s.orders.BindStream(session.UserDataStream) + + // we don't persist orders so that we can not clear the previous orders for now. just need time to support this. + s.activeOrders = qbtrade.NewActiveOrderBook(s.Symbol) + s.activeOrders.OnFilled(func(o types.Order) { + s.submitReverseOrder(o, session) + }) + s.activeOrders.BindStream(session.UserDataStream) + + s.profitOrders = qbtrade.NewActiveOrderBook(s.Symbol) + s.profitOrders.OnFilled(func(o types.Order) { + // we made profit here! + }) + s.profitOrders.BindStream(session.UserDataStream) + + // setup graceful shutting down handler + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + // call Done to notify the main process. + defer wg.Done() + log.Infof("canceling active orders...") + + if err := orderExecutor.CancelOrders(ctx, s.activeOrders.Orders()...); err != nil { + log.WithError(err).Errorf("cancel order error") + } + + if s.CancelProfitOrdersOnShutdown { + log.Infof("canceling profit orders...") + err := orderExecutor.CancelOrders(ctx, s.profitOrders.Orders()...) + + if err != nil { + log.WithError(err).Errorf("cancel profit order error") + } + } + }) + + session.UserDataStream.OnStart(func() { + log.Infof("connected, submitting the first round of the orders") + s.updateOrders(orderExecutor, session) + }) + + // avoid using time ticker since we will need back testing here + session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { + // skip kline events that does not belong to this symbol + if kline.Symbol != s.Symbol { + log.Infof("%s != %s", kline.Symbol, s.Symbol) + return + } + + if s.RepostInterval != "" { + // see if we have enough balances and then we create limit orders on the up band and the down band. + if s.RepostInterval == kline.Interval { + s.updateOrders(orderExecutor, session) + } + + } else if s.Interval == kline.Interval { + s.updateOrders(orderExecutor, session) + } + }) + + return nil +} diff --git a/pkg/strategy/bollmaker/doc.go b/pkg/strategy/bollmaker/doc.go new file mode 100644 index 0000000..f432009 --- /dev/null +++ b/pkg/strategy/bollmaker/doc.go @@ -0,0 +1,6 @@ +// bollmaker is a maker strategy depends on the bollinger band +// +// bollmaker uses two bollinger bands for trading: +// 1) the first bollinger is a long-term time frame bollinger, it controls your position. (how much you can hold) +// 2) the second bollinger is a short-term time frame bollinger, it controls whether places the orders or not. +package bollmaker diff --git a/pkg/strategy/bollmaker/dynamic_spread.go b/pkg/strategy/bollmaker/dynamic_spread.go new file mode 100644 index 0000000..ccd7e40 --- /dev/null +++ b/pkg/strategy/bollmaker/dynamic_spread.go @@ -0,0 +1,248 @@ +package bollmaker + +import ( + "math" + + "github.com/pkg/errors" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + indicatorv2 "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator/v2" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type DynamicSpreadSettings struct { + AmpSpreadSettings *DynamicSpreadAmpSettings `json:"amplitude"` + WeightedBollWidthRatioSpreadSettings *DynamicSpreadBollWidthRatioSettings `json:"weightedBollWidth"` + + // deprecated + Enabled *bool `json:"enabled"` + + // deprecated + types.IntervalWindow + + // deprecated. AskSpreadScale is used to define the ask spread range with the given percentage. + AskSpreadScale *qbtrade.PercentageScale `json:"askSpreadScale"` + + // deprecated. BidSpreadScale is used to define the bid spread range with the given percentage. + BidSpreadScale *qbtrade.PercentageScale `json:"bidSpreadScale"` +} + +// Initialize dynamic spreads and preload SMAs +func (ds *DynamicSpreadSettings) Initialize(symbol string, session *qbtrade.ExchangeSession, neutralBoll, defaultBoll *indicatorv2.BOLLStream) { + switch { + case ds.AmpSpreadSettings != nil: + ds.AmpSpreadSettings.initialize(symbol, session) + case ds.WeightedBollWidthRatioSpreadSettings != nil: + ds.WeightedBollWidthRatioSpreadSettings.initialize(neutralBoll, defaultBoll) + case ds.Enabled != nil && *ds.Enabled: + // backward compatibility + ds.AmpSpreadSettings = &DynamicSpreadAmpSettings{ + IntervalWindow: ds.IntervalWindow, + AskSpreadScale: ds.AskSpreadScale, + BidSpreadScale: ds.BidSpreadScale, + } + ds.AmpSpreadSettings.initialize(symbol, session) + } +} + +func (ds *DynamicSpreadSettings) IsEnabled() bool { + return ds.AmpSpreadSettings != nil || ds.WeightedBollWidthRatioSpreadSettings != nil +} + +// Update dynamic spreads +func (ds *DynamicSpreadSettings) Update(kline types.KLine) { + switch { + case ds.AmpSpreadSettings != nil: + ds.AmpSpreadSettings.update(kline) + case ds.WeightedBollWidthRatioSpreadSettings != nil: + // Boll bands are updated outside of settings. Do nothing. + default: + // Disabled. Do nothing. + } +} + +// GetAskSpread returns current ask spread +func (ds *DynamicSpreadSettings) GetAskSpread() (askSpread float64, err error) { + switch { + case ds.AmpSpreadSettings != nil: + return ds.AmpSpreadSettings.getAskSpread() + case ds.WeightedBollWidthRatioSpreadSettings != nil: + return ds.WeightedBollWidthRatioSpreadSettings.getAskSpread() + default: + return 0, errors.New("dynamic spread is not enabled") + } +} + +// GetBidSpread returns current dynamic bid spread +func (ds *DynamicSpreadSettings) GetBidSpread() (bidSpread float64, err error) { + switch { + case ds.AmpSpreadSettings != nil: + return ds.AmpSpreadSettings.getBidSpread() + case ds.WeightedBollWidthRatioSpreadSettings != nil: + return ds.WeightedBollWidthRatioSpreadSettings.getBidSpread() + default: + return 0, errors.New("dynamic spread is not enabled") + } +} + +type DynamicSpreadAmpSettings struct { + types.IntervalWindow + + // AskSpreadScale is used to define the ask spread range with the given percentage. + AskSpreadScale *qbtrade.PercentageScale `json:"askSpreadScale"` + + // BidSpreadScale is used to define the bid spread range with the given percentage. + BidSpreadScale *qbtrade.PercentageScale `json:"bidSpreadScale"` + + dynamicAskSpread *indicator.SMA + dynamicBidSpread *indicator.SMA +} + +func (ds *DynamicSpreadAmpSettings) initialize(symbol string, session *qbtrade.ExchangeSession) { + ds.dynamicBidSpread = &indicator.SMA{IntervalWindow: types.IntervalWindow{Interval: ds.Interval, Window: ds.Window}} + ds.dynamicAskSpread = &indicator.SMA{IntervalWindow: types.IntervalWindow{Interval: ds.Interval, Window: ds.Window}} + kLineStore, _ := session.MarketDataStore(symbol) + if klines, ok := kLineStore.KLinesOfInterval(ds.Interval); ok { + for i := 0; i < len(*klines); i++ { + ds.update((*klines)[i]) + } + } +} + +func (ds *DynamicSpreadAmpSettings) update(kline types.KLine) { + ampl := (kline.GetHigh().Float64() - kline.GetLow().Float64()) / kline.GetOpen().Float64() + + switch kline.Direction() { + case types.DirectionUp: + ds.dynamicAskSpread.Update(ampl) + ds.dynamicBidSpread.Update(0) + case types.DirectionDown: + ds.dynamicBidSpread.Update(ampl) + ds.dynamicAskSpread.Update(0) + default: + ds.dynamicAskSpread.Update(0) + ds.dynamicBidSpread.Update(0) + } +} + +func (ds *DynamicSpreadAmpSettings) getAskSpread() (askSpread float64, err error) { + if ds.AskSpreadScale != nil && ds.dynamicAskSpread.Length() >= ds.Window { + askSpread, err = ds.AskSpreadScale.Scale(ds.dynamicAskSpread.Last(0)) + if err != nil { + log.WithError(err).Errorf("can not calculate dynamicAskSpread") + return 0, err + } + + return askSpread, nil + } + + return 0, errors.New("incomplete dynamic spread settings or not enough data yet") +} + +func (ds *DynamicSpreadAmpSettings) getBidSpread() (bidSpread float64, err error) { + if ds.BidSpreadScale != nil && ds.dynamicBidSpread.Length() >= ds.Window { + bidSpread, err = ds.BidSpreadScale.Scale(ds.dynamicBidSpread.Last(0)) + if err != nil { + log.WithError(err).Errorf("can not calculate dynamicBidSpread") + return 0, err + } + + return bidSpread, nil + } + + return 0, errors.New("incomplete dynamic spread settings or not enough data yet") +} + +type DynamicSpreadBollWidthRatioSettings struct { + // AskSpreadScale is used to define the ask spread range with the given percentage. + AskSpreadScale *qbtrade.PercentageScale `json:"askSpreadScale"` + + // BidSpreadScale is used to define the bid spread range with the given percentage. + BidSpreadScale *qbtrade.PercentageScale `json:"bidSpreadScale"` + + // Sensitivity factor of the weighting function: 1 / (1 + exp(-(x - mid) * sensitivity / width)) + // A positive number. The greater factor, the sharper weighting function. Default set to 1.0 . + Sensitivity float64 `json:"sensitivity"` + + defaultBoll, neutralBoll *indicatorv2.BOLLStream +} + +func (ds *DynamicSpreadBollWidthRatioSettings) initialize(neutralBoll, defaultBoll *indicatorv2.BOLLStream) { + ds.neutralBoll = neutralBoll + ds.defaultBoll = defaultBoll + if ds.Sensitivity <= 0. { + ds.Sensitivity = 1. + } +} + +func (ds *DynamicSpreadBollWidthRatioSettings) getAskSpread() (askSpread float64, err error) { + askSpread, err = ds.AskSpreadScale.Scale(ds.getWeightedBBWidthRatio(true)) + if err != nil { + log.WithError(err).Errorf("can not calculate dynamicAskSpread") + return 0, err + } + + return askSpread, nil +} + +func (ds *DynamicSpreadBollWidthRatioSettings) getBidSpread() (bidSpread float64, err error) { + bidSpread, err = ds.BidSpreadScale.Scale(ds.getWeightedBBWidthRatio(false)) + if err != nil { + log.WithError(err).Errorf("can not calculate dynamicAskSpread") + return 0, err + } + + return bidSpread, nil +} + +func (ds *DynamicSpreadBollWidthRatioSettings) getWeightedBBWidthRatio(positiveSigmoid bool) float64 { + // Weight the width of Boll bands with sigmoid function and calculate the ratio after integral. + // + // Given the default band: moving average default_BB_mid, band from default_BB_lower to default_BB_upper. + // And the neutral band: from neutral_BB_lower to neutral_BB_upper. + // And a sensitivity factor alpha, which is a positive constant. + // + // width of default BB w = default_BB_upper - default_BB_lower + // + // 1 x - default_BB_mid + // sigmoid weighting function f(y) = ------------- where y = -------------------- + // 1 + exp(-y) w / alpha + // Set the sigmoid weighting function: + // - To ask spread, the weighting density function d_weight(x) is sigmoid((x - default_BB_mid) / (w / alpha)) + // - To bid spread, the weighting density function d_weight(x) is sigmoid((default_BB_mid - x) / (w / alpha)) + // - The higher sensitivity factor alpha, the sharper weighting function. + // + // Then calculate the weighted band width ratio by taking integral of d_weight(x) from neutral_BB_lower to neutral_BB_upper: + // infinite integral of ask spread sigmoid weighting density function F(x) = (w / alpha) * ln(exp(x / (w / alpha)) + exp(default_BB_mid / (w / alpha))) + // infinite integral of bid spread sigmoid weighting density function F(x) = x - (w / alpha) * ln(exp(x / (w / alpha)) + exp(default_BB_mid / (w / alpha))) + // Note that we've rescaled the sigmoid function to fit default BB, + // the weighted default BB width is always calculated by integral(f of x from default_BB_lower to default_BB_upper) + // F(neutral_BB_upper) - F(neutral_BB_lower) + // weighted ratio = ------------------------------------------- + // F(default_BB_upper) - F(default_BB_lower) + // - The wider neutral band get greater ratio + // - To ask spread, the higher neutral band get greater ratio + // - To bid spread, the lower neutral band get greater ratio + + defaultMid := ds.defaultBoll.SMA.Last(0) + defaultUpper := ds.defaultBoll.UpBand.Last(0) + defaultLower := ds.defaultBoll.DownBand.Last(0) + defaultWidth := defaultUpper - defaultLower + neutralUpper := ds.neutralBoll.UpBand.Last(0) + neutralLower := ds.neutralBoll.DownBand.Last(0) + factor := defaultWidth / ds.Sensitivity + var weightedUpper, weightedLower, weightedDivUpper, weightedDivLower float64 + if positiveSigmoid { + weightedUpper = factor * math.Log(math.Exp(neutralUpper/factor)+math.Exp(defaultMid/factor)) + weightedLower = factor * math.Log(math.Exp(neutralLower/factor)+math.Exp(defaultMid/factor)) + weightedDivUpper = factor * math.Log(math.Exp(defaultUpper/factor)+math.Exp(defaultMid/factor)) + weightedDivLower = factor * math.Log(math.Exp(defaultLower/factor)+math.Exp(defaultMid/factor)) + } else { + weightedUpper = neutralUpper - factor*math.Log(math.Exp(neutralUpper/factor)+math.Exp(defaultMid/factor)) + weightedLower = neutralLower - factor*math.Log(math.Exp(neutralLower/factor)+math.Exp(defaultMid/factor)) + weightedDivUpper = defaultUpper - factor*math.Log(math.Exp(defaultUpper/factor)+math.Exp(defaultMid/factor)) + weightedDivLower = defaultLower - factor*math.Log(math.Exp(defaultLower/factor)+math.Exp(defaultMid/factor)) + } + return (weightedUpper - weightedLower) / (weightedDivUpper - weightedDivLower) +} diff --git a/pkg/strategy/bollmaker/strategy.go b/pkg/strategy/bollmaker/strategy.go new file mode 100644 index 0000000..a79b1b0 --- /dev/null +++ b/pkg/strategy/bollmaker/strategy.go @@ -0,0 +1,692 @@ +package bollmaker + +import ( + "context" + "fmt" + "math" + "sync" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + indicatorv2 "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator/v2" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// TODO: +// 1) add option for placing orders only when in neutral band +// 2) add option for only placing buy orders when price is below the SMA line + +const ID = "bollmaker" + +var notionModifier = fixedpoint.NewFromFloat(1.1) +var two = fixedpoint.NewFromInt(2) + +var log = logrus.WithField("strategy", ID) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +// Deprecated: State is deprecated, please use the persistence tag +type State struct { + // Deprecated: Position is deprecated, please define the Position field in the strategy struct directly. + Position *types.Position `json:"position,omitempty"` + + // Deprecated: ProfitStats is deprecated, please define the ProfitStats field in the strategy struct directly. + ProfitStats types.ProfitStats `json:"profitStats,omitempty"` +} + +type BollingerSetting struct { + types.IntervalWindow + BandWidth float64 `json:"bandWidth"` +} + +type EMACrossSetting struct { + Enabled bool `json:"enabled"` + Interval types.Interval `json:"interval"` + FastWindow int `json:"fastWindow"` + SlowWindow int `json:"slowWindow"` + + fastEMA, slowEMA *indicatorv2.EWMAStream + cross *indicatorv2.CrossStream +} + +type Strategy struct { + Environment *qbtrade.Environment + StandardIndicatorSet *qbtrade.StandardIndicatorSet + Market types.Market + + // Symbol is the market symbol you want to trade + Symbol string `json:"symbol"` + + types.IntervalWindow + + qbtrade.QuantityOrAmount + + // BidQuantity is used for placing buy order, this will override the default quantity + BidQuantity fixedpoint.Value `json:"bidQuantity"` + + // AskQuantity is used for placing sell order, this will override the default quantity + AskQuantity fixedpoint.Value `json:"askQuantity"` + + // TrendEMA is used for detecting the trend by a given EMA + // you can define interval and window + TrendEMA *qbtrade.TrendEMA `json:"trendEMA"` + + // Spread is the price spread from the middle price. + // For ask orders, the ask price is ((bestAsk + bestBid) / 2 * (1.0 + spread)) + // For bid orders, the bid price is ((bestAsk + bestBid) / 2 * (1.0 - spread)) + // Spread can be set by percentage or floating number. e.g., 0.1% or 0.001 + Spread fixedpoint.Value `json:"spread"` + + // BidSpread overrides the spread setting, this spread will be used for the buy order + BidSpread fixedpoint.Value `json:"bidSpread,omitempty"` + + // AskSpread overrides the spread setting, this spread will be used for the sell order + AskSpread fixedpoint.Value `json:"askSpread,omitempty"` + + // DynamicSpread enables the automatic adjustment to bid and ask spread. + DynamicSpread DynamicSpreadSettings `json:"dynamicSpread,omitempty"` + + // MinProfitSpread is the minimal order price spread from the current average cost. + // For long position, you will only place sell order above the price (= average cost * (1 + minProfitSpread)) + // For short position, you will only place buy order below the price (= average cost * (1 - minProfitSpread)) + MinProfitSpread fixedpoint.Value `json:"minProfitSpread"` + + // MinProfitActivationRate activates MinProfitSpread when position RoI higher than the specified percentage + MinProfitActivationRate *fixedpoint.Value `json:"minProfitActivationRate"` + + // UseTickerPrice use the ticker api to get the mid-price instead of the closed kline price. + // The back-test engine is kline-based, so the ticker price api is not supported. + // Turn this on if you want to do real trading. + UseTickerPrice bool `json:"useTickerPrice"` + + // MaxExposurePosition is the maximum position you can hold + // +10 means you can hold 10 ETH long position by maximum + // -10 means you can hold -10 ETH short position by maximum + MaxExposurePosition fixedpoint.Value `json:"maxExposurePosition"` + + // DynamicExposurePositionScale is used to define the exposure position range with the given percentage + // when DynamicExposurePositionScale is set, + // your MaxExposurePosition will be calculated dynamically according to the bollinger band you set. + DynamicExposurePositionScale *qbtrade.PercentageScale `json:"dynamicExposurePositionScale"` + + // Long means your position will be long position + // Currently not used yet + Long *bool `json:"long,omitempty"` + + // Short means your position will be long position + // Currently not used yet + Short *bool `json:"short,omitempty"` + + // DisableShort means you can don't want short position during the market making + // Set to true if you want to hold more spot during market making. + DisableShort bool `json:"disableShort"` + + // BuyBelowNeutralSMA if true, the market maker will only place buy order when the current price is below the neutral band SMA. + BuyBelowNeutralSMA bool `json:"buyBelowNeutralSMA"` + + // EMACrossSetting is used for defining ema cross signal to turn on/off buy + EMACrossSetting *EMACrossSetting `json:"emaCross"` + + // NeutralBollinger is the smaller range of the bollinger band + // If price is in this band, it usually means the price is oscillating. + // If price goes out of this band, we tend to not place sell orders or buy orders + NeutralBollinger *BollingerSetting `json:"neutralBollinger"` + + // DefaultBollinger is the wide range of the bollinger band + // for controlling your exposure position + DefaultBollinger *BollingerSetting `json:"defaultBollinger"` + + // DowntrendSkew is the order quantity skew for normal downtrend band. + // The price is still in the default bollinger band. + // greater than 1.0 means when placing buy order, place sell order with less quantity + // less than 1.0 means when placing sell order, place buy order with less quantity + DowntrendSkew fixedpoint.Value `json:"downtrendSkew"` + + // UptrendSkew is the order quantity skew for normal uptrend band. + // The price is still in the default bollinger band. + // greater than 1.0 means when placing buy order, place sell order with less quantity + // less than 1.0 means when placing sell order, place buy order with less quantity + UptrendSkew fixedpoint.Value `json:"uptrendSkew"` + + // TradeInBand + // When this is on, places orders only when the current price is in the bollinger band. + TradeInBand bool `json:"tradeInBand"` + + // ShadowProtection is used to avoid placing bid order when price goes down strongly (without shadow) + ShadowProtection bool `json:"shadowProtection"` + ShadowProtectionRatio fixedpoint.Value `json:"shadowProtectionRatio"` + + ExitMethods qbtrade.ExitMethodSet `json:"exits"` + + // persistence fields + Position *types.Position `json:"position,omitempty" persistence:"position"` + ProfitStats *types.ProfitStats `json:"profitStats,omitempty" persistence:"profit_stats"` + + session *qbtrade.ExchangeSession + book *types.StreamOrderBook + orderExecutor *qbtrade.GeneralOrderExecutor + + // defaultBoll is the BOLLINGER indicator we used for predicting the price. + defaultBoll *indicatorv2.BOLLStream + + // neutralBoll is the neutral price section + neutralBoll *indicatorv2.BOLLStream + + shouldBuy bool + + // StrategyController + qbtrade.StrategyController +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s", ID, s.Symbol) +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{ + Interval: s.Interval, + }) + + if s.DefaultBollinger != nil && s.DefaultBollinger.Interval != "" { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{ + Interval: s.DefaultBollinger.Interval, + }) + } + + if s.NeutralBollinger != nil && s.NeutralBollinger.Interval != "" { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{ + Interval: s.NeutralBollinger.Interval, + }) + } + + if s.TrendEMA != nil { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.TrendEMA.Interval}) + } + + if s.EMACrossSetting != nil && s.EMACrossSetting.Enabled { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.EMACrossSetting.Interval}) + } + + s.ExitMethods.SetAndSubscribe(session, s) +} + +func (s *Strategy) Validate() error { + if len(s.Symbol) == 0 { + return errors.New("symbol is required") + } + + return nil +} + +func (s *Strategy) CurrentPosition() *types.Position { + return s.Position +} + +func (s *Strategy) ClosePosition(ctx context.Context, percentage fixedpoint.Value) error { + return s.orderExecutor.ClosePosition(ctx, percentage) +} + +func (s *Strategy) getCurrentAllowedExposurePosition(bandPercentage float64) (fixedpoint.Value, error) { + if s.DynamicExposurePositionScale != nil { + v, err := s.DynamicExposurePositionScale.Scale(bandPercentage) + if err != nil { + return fixedpoint.Zero, err + } + return fixedpoint.NewFromFloat(v), nil + } + + return s.MaxExposurePosition, nil +} + +func (s *Strategy) placeOrders(ctx context.Context, midPrice fixedpoint.Value, kline *types.KLine) { + bidSpread := s.Spread + if s.BidSpread.Sign() > 0 { + bidSpread = s.BidSpread + } + + askSpread := s.Spread + if s.AskSpread.Sign() > 0 { + askSpread = s.AskSpread + } + + askPrice := midPrice.Mul(fixedpoint.One.Add(askSpread)) + bidPrice := midPrice.Mul(fixedpoint.One.Sub(bidSpread)) + base := s.Position.GetBase() + balances := s.session.GetAccount().Balances() + + log.Infof("mid price:%v spread: %s ask:%v bid: %v position: %s", + midPrice, + s.Spread.Percentage(), + askPrice, + bidPrice, + s.Position, + ) + + sellQuantity := s.AskQuantity + if sellQuantity.IsZero() { + sellQuantity = s.QuantityOrAmount.CalculateQuantity(askPrice) + } + + buyQuantity := s.BidQuantity + if buyQuantity.IsZero() { + buyQuantity = s.QuantityOrAmount.CalculateQuantity(bidPrice) + } + + sellOrder := types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeLimitMaker, + Quantity: sellQuantity, + Price: askPrice, + Market: s.Market, + } + + buyOrder := types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimitMaker, + Quantity: buyQuantity, + Price: bidPrice, + Market: s.Market, + } + + var submitOrders []types.SubmitOrder + + baseBalance, hasBaseBalance := balances[s.Market.BaseCurrency] + quoteBalance, hasQuoteBalance := balances[s.Market.QuoteCurrency] + + downBand := s.defaultBoll.DownBand.Last(0) + upBand := s.defaultBoll.UpBand.Last(0) + sma := s.defaultBoll.SMA.Last(0) + log.Infof("%s bollinger band: up %f sma %f down %f", s.Symbol, upBand, sma, downBand) + + bandPercentage := calculateBandPercentage(upBand, downBand, sma, midPrice.Float64()) + log.Infof("%s mid price band percentage: %v", s.Symbol, bandPercentage) + + maxExposurePosition, err := s.getCurrentAllowedExposurePosition(bandPercentage) + if err != nil { + log.WithError(err).Errorf("can not calculate %s CurrentAllowedExposurePosition", s.Symbol) + return + } + + log.Infof("calculated %s max exposure position: %v", s.Symbol, maxExposurePosition) + + if !s.Position.IsClosed() && !s.Position.IsDust(midPrice) { + log.Infof("current %s unrealized profit: %f %s", s.Symbol, s.Position.UnrealizedProfit(midPrice).Float64(), s.Market.QuoteCurrency) + } + + // by default, we turn both sell and buy on, + // which means we will place buy and sell orders + canSell := true + canBuy := true + + if maxExposurePosition.Sign() > 0 && base.Compare(maxExposurePosition) > 0 { + canBuy = false + } + + if maxExposurePosition.Sign() > 0 { + if s.hasLongSet() && base.Sign() < 0 { + canSell = false + } else if base.Compare(maxExposurePosition.Neg()) < 0 { + canSell = false + } + } + + if s.ShadowProtection && kline != nil { + switch kline.Direction() { + case types.DirectionDown: + shadowHeight := kline.GetLowerShadowHeight() + shadowRatio := kline.GetLowerShadowRatio() + if shadowHeight.IsZero() && shadowRatio.Compare(s.ShadowProtectionRatio) < 0 { + log.Infof("%s shadow protection enabled, lower shadow ratio %v < %v", s.Symbol, shadowRatio, s.ShadowProtectionRatio) + canBuy = false + } + case types.DirectionUp: + shadowHeight := kline.GetUpperShadowHeight() + shadowRatio := kline.GetUpperShadowRatio() + if shadowHeight.IsZero() || shadowRatio.Compare(s.ShadowProtectionRatio) < 0 { + log.Infof("%s shadow protection enabled, upper shadow ratio %v < %v", s.Symbol, shadowRatio, s.ShadowProtectionRatio) + canSell = false + } + } + } + + // Apply quantity skew + // CASE #1: + // WHEN: price is in the neutral bollinger band (window 1) == neutral + // THEN: we don't apply skew + // CASE #2: + // WHEN: price is in the upper band (window 2 > price > window 1) == upTrend + // THEN: we apply upTrend skew + // CASE #3: + // WHEN: price is in the lower band (window 2 < price < window 1) == downTrend + // THEN: we apply downTrend skew + // CASE #4: + // WHEN: price breaks the lower band (price < window 2) == strongDownTrend + // THEN: we apply strongDownTrend skew + // CASE #5: + // WHEN: price breaks the upper band (price > window 2) == strongUpTrend + // THEN: we apply strongUpTrend skew + if s.TradeInBand { + if !inBetween(midPrice.Float64(), s.neutralBoll.DownBand.Last(0), s.neutralBoll.UpBand.Last(0)) { + log.Infof("tradeInBand is set, skip placing orders when the price is outside of the band") + return + } + } + + trend := detectPriceTrend(s.neutralBoll, midPrice.Float64()) + switch trend { + case NeutralTrend: + // do nothing + + case UpTrend: + skew := s.UptrendSkew + buyOrder.Quantity = fixedpoint.Max(s.Market.MinQuantity, sellOrder.Quantity.Mul(skew)) + + case DownTrend: + skew := s.DowntrendSkew + ratio := fixedpoint.One.Div(skew) + sellOrder.Quantity = fixedpoint.Max(s.Market.MinQuantity, buyOrder.Quantity.Mul(ratio)) + + } + + // check balance and switch the orders + if !hasQuoteBalance || buyOrder.Quantity.Mul(buyOrder.Price).Compare(quoteBalance.Available) > 0 { + canBuy = false + } + + if !hasBaseBalance || sellOrder.Quantity.Compare(baseBalance.Available) > 0 { + canSell = false + } + + isLongPosition := s.Position.IsLong() + isShortPosition := s.Position.IsShort() + + if s.MinProfitActivationRate == nil || (s.MinProfitActivationRate != nil && s.Position.ROI(midPrice).Compare(*s.MinProfitActivationRate) >= 0) { + minProfitPrice := s.Position.AverageCost.Mul(fixedpoint.One.Add(s.MinProfitSpread)) + if isShortPosition { + minProfitPrice = s.Position.AverageCost.Mul(fixedpoint.One.Sub(s.MinProfitSpread)) + } + + if isLongPosition { + // for long position if the current price is lower than the minimal profitable price then we should stop sell + // this avoids loss trade + if midPrice.Compare(minProfitPrice) < 0 { + canSell = false + } + } else if isShortPosition { + // for short position if the current price is higher than the minimal profitable price then we should stop buy + // this avoids loss trade + if midPrice.Compare(minProfitPrice) > 0 { + canBuy = false + } + } + } + + if s.hasLongSet() && base.Sub(sellOrder.Quantity).Sign() < 0 { + canSell = false + } + + if s.BuyBelowNeutralSMA && midPrice.Float64() > s.neutralBoll.SMA.Last(0) { + canBuy = false + } + + // trend EMA protection + if s.TrendEMA != nil { + if !s.TrendEMA.GradientAllowed() { + log.Infof("trendEMA protection: midPrice price %f, gradient %f, turning buy order off", midPrice.Float64(), s.TrendEMA.Gradient()) + canBuy = false + } + } + + if !s.shouldBuy { + log.Infof("shouldBuy is turned off, skip placing buy order") + canBuy = false + } + + if canSell { + submitOrders = append(submitOrders, sellOrder) + } + + if canBuy { + submitOrders = append(submitOrders, buyOrder) + } + + // condition for lowering the average cost + /* + if midPrice < s.Position.AverageCost.MulFloat64(1.0-s.MinProfitSpread.Float64()) && canBuy { + submitOrders = append(submitOrders, buyOrder) + } + */ + + if len(submitOrders) == 0 { + return + } + + for i := range submitOrders { + submitOrders[i] = adjustOrderQuantity(submitOrders[i], s.Market) + } + + _, err = s.orderExecutor.SubmitOrders(ctx, submitOrders...) + if err != nil { + log.WithError(err).Errorf("submit order error") + } +} + +func (s *Strategy) hasLongSet() bool { + return s.Long != nil && *s.Long +} + +func (s *Strategy) hasShortSet() bool { + return s.Short != nil && *s.Short +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + // initial required information + s.session = session + + // StrategyController + s.Status = types.StrategyStatusRunning + + s.shouldBuy = true + s.neutralBoll = session.Indicators(s.Symbol).BOLL(s.NeutralBollinger.IntervalWindow, s.NeutralBollinger.BandWidth) + s.defaultBoll = session.Indicators(s.Symbol).BOLL(s.DefaultBollinger.IntervalWindow, s.DefaultBollinger.BandWidth) + + if s.EMACrossSetting != nil && s.EMACrossSetting.Enabled { + s.EMACrossSetting.fastEMA = session.Indicators(s.Symbol).EWMA(types.IntervalWindow{Interval: s.Interval, Window: s.EMACrossSetting.FastWindow}) + s.EMACrossSetting.slowEMA = session.Indicators(s.Symbol).EWMA(types.IntervalWindow{Interval: s.Interval, Window: s.EMACrossSetting.SlowWindow}) + s.EMACrossSetting.cross = indicatorv2.Cross(s.EMACrossSetting.fastEMA, s.EMACrossSetting.slowEMA) + s.EMACrossSetting.cross.OnUpdate(func(v float64) { + switch indicatorv2.CrossType(v) { + case indicatorv2.CrossOver: + s.shouldBuy = true + case indicatorv2.CrossUnder: + s.shouldBuy = false + // TODO: can partially close position when necessary + // s.orderExecutor.ClosePosition(ctx) + } + }) + } + + // Setup dynamic spread + if s.DynamicSpread.IsEnabled() { + if s.DynamicSpread.Interval == "" { + s.DynamicSpread.Interval = s.Interval + } + s.DynamicSpread.Initialize(s.Symbol, s.session, s.neutralBoll, s.defaultBoll) + } + + if s.DisableShort { + s.Long = &[]bool{true}[0] + } + + if s.MinProfitSpread.IsZero() { + s.MinProfitSpread = fixedpoint.NewFromFloat(0.001) + } + + if s.UptrendSkew.IsZero() { + s.UptrendSkew = fixedpoint.NewFromFloat(1.0 / 1.2) + } + + if s.DowntrendSkew.IsZero() { + s.DowntrendSkew = fixedpoint.NewFromFloat(1.2) + } + + if s.ShadowProtectionRatio.IsZero() { + s.ShadowProtectionRatio = fixedpoint.NewFromFloat(0.01) + } + + // calculate group id for orders + instanceID := s.InstanceID() + + // If position is nil, we need to allocate a new position for calculation + if s.Position == nil { + s.Position = types.NewPositionFromMarket(s.Market) + } + + if s.session.MakerFeeRate.Sign() > 0 || s.session.TakerFeeRate.Sign() > 0 { + s.Position.SetExchangeFeeRate(s.session.ExchangeName, types.ExchangeFee{ + MakerFeeRate: s.session.MakerFeeRate, + TakerFeeRate: s.session.TakerFeeRate, + }) + } + + if s.ProfitStats == nil { + s.ProfitStats = types.NewProfitStats(s.Market) + } + + // Always update the position fields + s.Position.Strategy = ID + s.Position.StrategyInstanceID = instanceID + + s.orderExecutor = qbtrade.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, s.Position) + s.orderExecutor.BindEnvironment(s.Environment) + s.orderExecutor.BindProfitStats(s.ProfitStats) + s.orderExecutor.Bind() + s.orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { + qbtrade.Sync(ctx, s) + }) + s.ExitMethods.Bind(session, s.orderExecutor) + + if s.TrendEMA != nil { + s.TrendEMA.Bind(session, s.orderExecutor) + } + + if qbtrade.IsBackTesting { + log.Warn("turning of useTickerPrice option in the back-testing environment...") + s.UseTickerPrice = false + } + + s.OnSuspend(func() { + _ = s.orderExecutor.GracefulCancel(ctx) + qbtrade.Sync(ctx, s) + }) + + s.OnEmergencyStop(func() { + // Close 100% position + percentage := fixedpoint.NewFromFloat(1.0) + _ = s.ClosePosition(ctx, percentage) + }) + + session.UserDataStream.OnStart(func() { + if !qbtrade.IsBackTesting && s.UseTickerPrice { + ticker, err := s.session.Exchange.QueryTicker(ctx, s.Symbol) + if err != nil { + return + } + + midPrice := ticker.Buy.Add(ticker.Sell).Div(two) + s.placeOrders(ctx, midPrice, nil) + } else { + if price, ok := session.LastPrice(s.Symbol); ok { + s.placeOrders(ctx, price, nil) + } + } + }) + + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.Interval, func(kline types.KLine) { + // StrategyController + if s.Status != types.StrategyStatusRunning { + return + } + + // Update spreads with dynamic spread + if s.DynamicSpread.IsEnabled() { + s.DynamicSpread.Update(kline) + dynamicBidSpread, err := s.DynamicSpread.GetBidSpread() + if err == nil && dynamicBidSpread > 0 { + s.BidSpread = fixedpoint.NewFromFloat(dynamicBidSpread) + log.Infof("%s dynamic bid spread updated: %s", s.Symbol, s.BidSpread.Percentage()) + } + dynamicAskSpread, err := s.DynamicSpread.GetAskSpread() + if err == nil && dynamicAskSpread > 0 { + s.AskSpread = fixedpoint.NewFromFloat(dynamicAskSpread) + log.Infof("%s dynamic ask spread updated: %s", s.Symbol, s.AskSpread.Percentage()) + } + } + + _ = s.orderExecutor.GracefulCancel(ctx) + + if s.UseTickerPrice { + ticker, err := s.session.Exchange.QueryTicker(ctx, s.Symbol) + if err != nil { + return + } + + midPrice := ticker.Buy.Add(ticker.Sell).Div(two) + log.Infof("using ticker price: bid %v / ask %v, mid price %v", ticker.Buy, ticker.Sell, midPrice) + s.placeOrders(ctx, midPrice, &kline) + } else { + s.placeOrders(ctx, kline.Close, &kline) + } + })) + + // s.book = types.NewStreamBook(s.Symbol) + // s.book.BindStreamForBackground(session.MarketDataStream) + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + _ = s.orderExecutor.GracefulCancel(ctx) + }) + + return nil +} + +func calculateBandPercentage(up, down, sma, midPrice float64) float64 { + if midPrice < sma { + // should be negative percentage + return (midPrice - sma) / math.Abs(sma-down) + } else if midPrice > sma { + // should be positive percentage + return (midPrice - sma) / math.Abs(up-sma) + } + + return 0.0 +} + +func inBetween(x, a, b float64) bool { + return a < x && x < b +} + +func adjustOrderQuantity(submitOrder types.SubmitOrder, market types.Market) types.SubmitOrder { + if submitOrder.Quantity.Mul(submitOrder.Price).Compare(market.MinNotional) < 0 { + submitOrder.Quantity = qbtrade.AdjustFloatQuantityByMinAmount(submitOrder.Quantity, submitOrder.Price, market.MinNotional.Mul(notionModifier)) + } + + if submitOrder.Quantity.Compare(market.MinQuantity) < 0 { + submitOrder.Quantity = fixedpoint.Max(submitOrder.Quantity, market.MinQuantity) + } + + return submitOrder +} diff --git a/pkg/strategy/bollmaker/strategy_test.go b/pkg/strategy/bollmaker/strategy_test.go new file mode 100644 index 0000000..9f80302 --- /dev/null +++ b/pkg/strategy/bollmaker/strategy_test.go @@ -0,0 +1,69 @@ +package bollmaker + +import ( + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +func Test_calculateBandPercentage(t *testing.T) { + type args struct { + up float64 + down float64 + sma float64 + midPrice float64 + } + tests := []struct { + name string + args args + want fixedpoint.Value + }{ + { + name: "positive boundary", + args: args{ + up: 2000.0, + sma: 1500.0, + down: 1000.0, + midPrice: 2000.0, + }, + want: fixedpoint.NewFromFloat(1.0), + }, + { + name: "inside positive boundary", + args: args{ + up: 2000.0, + sma: 1500.0, + down: 1000.0, + midPrice: 1600.0, + }, + want: fixedpoint.NewFromFloat(0.2), // 20% + }, + { + name: "negative boundary", + args: args{ + up: 2000.0, + sma: 1500.0, + down: 1000.0, + midPrice: 1000.0, + }, + want: fixedpoint.NewFromFloat(-1.0), + }, + { + name: "out of negative boundary", + args: args{ + up: 2000.0, + sma: 1500.0, + down: 1000.0, + midPrice: 800.0, + }, + want: fixedpoint.NewFromFloat(-1.4), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := calculateBandPercentage(tt.args.up, tt.args.down, tt.args.sma, tt.args.midPrice); fixedpoint.NewFromFloat(got) != tt.want { + t.Errorf("calculateBandPercentage() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/strategy/bollmaker/trend.go b/pkg/strategy/bollmaker/trend.go new file mode 100644 index 0000000..e1eb07c --- /dev/null +++ b/pkg/strategy/bollmaker/trend.go @@ -0,0 +1,30 @@ +package bollmaker + +import ( + indicatorv2 "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator/v2" +) + +type PriceTrend string + +const ( + NeutralTrend PriceTrend = "neutral" + UpTrend PriceTrend = "upTrend" + DownTrend PriceTrend = "downTrend" + UnknownTrend PriceTrend = "unknown" +) + +func detectPriceTrend(inc *indicatorv2.BOLLStream, price float64) PriceTrend { + if inBetween(price, inc.DownBand.Last(0), inc.UpBand.Last(0)) { + return NeutralTrend + } + + if price < inc.DownBand.Last(0) { + return DownTrend + } + + if price > inc.UpBand.Last(0) { + return UpTrend + } + + return UnknownTrend +} diff --git a/pkg/strategy/common/callbacks.go b/pkg/strategy/common/callbacks.go new file mode 100644 index 0000000..3df6c87 --- /dev/null +++ b/pkg/strategy/common/callbacks.go @@ -0,0 +1,38 @@ +package common + +//go:generate callbackgen -type StatusCallbacks +type StatusCallbacks struct { + readyCallbacks []func() + closedCallbacks []func() + errorCallbacks []func(error) +} + +func (c *StatusCallbacks) OnReady(cb func()) { + c.readyCallbacks = append(c.readyCallbacks, cb) +} + +func (c *StatusCallbacks) EmitReady() { + for _, cb := range c.readyCallbacks { + cb() + } +} + +func (c *StatusCallbacks) OnClosed(cb func()) { + c.closedCallbacks = append(c.closedCallbacks, cb) +} + +func (c *StatusCallbacks) EmitClosed() { + for _, cb := range c.closedCallbacks { + cb() + } +} + +func (c *StatusCallbacks) OnError(cb func(err error)) { + c.errorCallbacks = append(c.errorCallbacks, cb) +} + +func (c *StatusCallbacks) EmitError(err error) { + for _, cb := range c.errorCallbacks { + cb(err) + } +} diff --git a/pkg/strategy/common/inventory_skew.go b/pkg/strategy/common/inventory_skew.go new file mode 100644 index 0000000..f92d96e --- /dev/null +++ b/pkg/strategy/common/inventory_skew.go @@ -0,0 +1,56 @@ +package common + +import ( + "fmt" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +var ( + zero = fixedpoint.Zero + two = fixedpoint.NewFromFloat(2.0) +) + +type InventorySkewBidAskRatios struct { + BidRatio fixedpoint.Value + AskRatio fixedpoint.Value +} + +// https://hummingbot.org/strategy-configs/inventory-skew/ +// https://github.com/hummingbot/hummingbot/blob/31fc61d5e71b2c15732142d30983f3ea2be4d466/hummingbot/strategy/pure_market_making/inventory_skew_calculator.pyx +type InventorySkew struct { + InventoryRangeMultiplier fixedpoint.Value `json:"inventoryRangeMultiplier"` + TargetBaseRatio fixedpoint.Value `json:"targetBaseRatio"` +} + +func (s *InventorySkew) Validate() error { + if s.InventoryRangeMultiplier.Float64() < 0 { + return fmt.Errorf("inventoryRangeMultiplier should be positive") + } + + if s.TargetBaseRatio.Float64() < 0 { + return fmt.Errorf("targetBaseRatio should be positive") + } + return nil +} + +func (s *InventorySkew) CalculateBidAskRatios(quantity fixedpoint.Value, price fixedpoint.Value, baseBalance fixedpoint.Value, quoteBalance fixedpoint.Value) *InventorySkewBidAskRatios { + baseValue := baseBalance.Mul(price) + totalValue := baseValue.Add(quoteBalance) + + inventoryRange := s.InventoryRangeMultiplier.Mul(quantity.Mul(two)).Mul(price) + leftLimit := s.TargetBaseRatio.Mul(totalValue).Sub(inventoryRange) + rightLimit := s.TargetBaseRatio.Mul(totalValue).Add(inventoryRange) + + bidAdjustment := interp(baseValue, leftLimit, rightLimit, two, zero).Clamp(zero, two) + askAdjustment := interp(baseValue, leftLimit, rightLimit, zero, two).Clamp(zero, two) + + return &InventorySkewBidAskRatios{ + BidRatio: bidAdjustment, + AskRatio: askAdjustment, + } +} + +func interp(x, x0, x1, y0, y1 fixedpoint.Value) fixedpoint.Value { + return y0.Add(x.Sub(x0).Mul(y1.Sub(y0)).Div(x1.Sub(x0))) +} diff --git a/pkg/strategy/common/inventory_skew_test.go b/pkg/strategy/common/inventory_skew_test.go new file mode 100644 index 0000000..2b3c7ab --- /dev/null +++ b/pkg/strategy/common/inventory_skew_test.go @@ -0,0 +1,69 @@ +package common + +import ( + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "github.com/stretchr/testify/assert" +) + +func Test_InventorySkew_CalculateBidAskRatios(t *testing.T) { + cases := []struct { + quantity fixedpoint.Value + price fixedpoint.Value + baseBalance fixedpoint.Value + quoteBalance fixedpoint.Value + want *InventorySkewBidAskRatios + }{ + { + quantity: fixedpoint.NewFromFloat(1.0), + price: fixedpoint.NewFromFloat(1000), + baseBalance: fixedpoint.NewFromFloat(1.0), + quoteBalance: fixedpoint.NewFromFloat(1000), + want: &InventorySkewBidAskRatios{ + BidRatio: fixedpoint.NewFromFloat(1.0), + AskRatio: fixedpoint.NewFromFloat(1.0), + }, + }, + { + quantity: fixedpoint.NewFromFloat(1.0), + price: fixedpoint.NewFromFloat(1000), + baseBalance: fixedpoint.NewFromFloat(1.0), + quoteBalance: fixedpoint.NewFromFloat(1200), + want: &InventorySkewBidAskRatios{ + BidRatio: fixedpoint.NewFromFloat(1.5), + AskRatio: fixedpoint.NewFromFloat(0.5), + }, + }, + { + quantity: fixedpoint.NewFromFloat(1.0), + price: fixedpoint.NewFromFloat(1000), + baseBalance: fixedpoint.NewFromFloat(0.0), + quoteBalance: fixedpoint.NewFromFloat(10000), + want: &InventorySkewBidAskRatios{ + BidRatio: fixedpoint.NewFromFloat(2.0), + AskRatio: fixedpoint.NewFromFloat(0.0), + }, + }, + { + quantity: fixedpoint.NewFromFloat(1.0), + price: fixedpoint.NewFromFloat(1000), + baseBalance: fixedpoint.NewFromFloat(2.0), + quoteBalance: fixedpoint.NewFromFloat(0.0), + want: &InventorySkewBidAskRatios{ + BidRatio: fixedpoint.NewFromFloat(0.0), + AskRatio: fixedpoint.NewFromFloat(2.0), + }, + }, + } + + for _, c := range cases { + s := &InventorySkew{ + InventoryRangeMultiplier: fixedpoint.NewFromFloat(0.1), + TargetBaseRatio: fixedpoint.NewFromFloat(0.5), + } + got := s.CalculateBidAskRatios(c.quantity, c.price, c.baseBalance, c.quoteBalance) + assert.Equal(t, c.want.BidRatio.Float64(), got.BidRatio.Float64()) + assert.Equal(t, c.want.AskRatio.Float64(), got.AskRatio.Float64()) + } +} diff --git a/pkg/strategy/common/profit_fixer.go b/pkg/strategy/common/profit_fixer.go new file mode 100644 index 0000000..bbe2572 --- /dev/null +++ b/pkg/strategy/common/profit_fixer.go @@ -0,0 +1,103 @@ +package common + +import ( + "context" + "sync" + "time" + + log "github.com/sirupsen/logrus" + "golang.org/x/sync/errgroup" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/batch" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// ProfitFixerConfig is used for fixing profitStats and position by re-playing the trade history +type ProfitFixerConfig struct { + TradesSince types.Time `json:"tradesSince,omitempty"` +} + +// ProfitFixer implements a trade-history-based profit fixer +type ProfitFixer struct { + sessions map[string]types.ExchangeTradeHistoryService +} + +func NewProfitFixer() *ProfitFixer { + return &ProfitFixer{ + sessions: make(map[string]types.ExchangeTradeHistoryService), + } +} + +func (f *ProfitFixer) AddExchange(sessionName string, service types.ExchangeTradeHistoryService) { + f.sessions[sessionName] = service +} + +func (f *ProfitFixer) batchQueryTrades( + ctx context.Context, + service types.ExchangeTradeHistoryService, + symbol string, + since, until time.Time, +) ([]types.Trade, error) { + q := &batch.TradeBatchQuery{ExchangeTradeHistoryService: service} + return q.QueryTrades(ctx, symbol, &types.TradeQueryOptions{ + StartTime: &since, + EndTime: &until, + }) +} + +func (f *ProfitFixer) aggregateAllTrades(ctx context.Context, symbol string, since, until time.Time) ([]types.Trade, error) { + var mu sync.Mutex + var allTrades = make([]types.Trade, 0, 1000) + + g, subCtx := errgroup.WithContext(ctx) + for n, s := range f.sessions { + // allocate a copy of the iteration variables + sessionName := n + service := s + g.Go(func() error { + log.Infof("batch querying %s trade history from %s since %s until %s", symbol, sessionName, since.String(), until.String()) + trades, err := f.batchQueryTrades(subCtx, service, symbol, since, until) + if err != nil { + log.WithError(err).Errorf("unable to batch query trades for fixer") + return err + } + + mu.Lock() + allTrades = append(allTrades, trades...) + mu.Unlock() + return nil + }) + } + + if err := g.Wait(); err != nil { + return nil, err + } + + allTrades = types.SortTradesAscending(allTrades) + return allTrades, nil +} + +func (f *ProfitFixer) Fix( + ctx context.Context, symbol string, since, until time.Time, stats *types.ProfitStats, position *types.Position, +) error { + log.Infof("starting profitFixer with time range %s <=> %s", since, until) + allTrades, err := f.aggregateAllTrades(ctx, symbol, since, until) + if err != nil { + return err + } + + return f.FixFromTrades(allTrades, stats, position) +} + +func (f *ProfitFixer) FixFromTrades(allTrades []types.Trade, stats *types.ProfitStats, position *types.Position) error { + for _, trade := range allTrades { + profit, netProfit, madeProfit := position.AddTrade(trade) + if madeProfit { + p := position.NewProfit(trade, profit, netProfit) + stats.AddProfit(p) + } + } + + log.Infof("profitFixer fix finished: profitStats and position are updated from %d trades", len(allTrades)) + return nil +} diff --git a/pkg/strategy/common/strategy.go b/pkg/strategy/common/strategy.go new file mode 100644 index 0000000..f7d208e --- /dev/null +++ b/pkg/strategy/common/strategy.go @@ -0,0 +1,95 @@ +package common + +import ( + "context" + "time" + + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/risk/riskcontrol" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type RiskController struct { + PositionHardLimit fixedpoint.Value `json:"positionHardLimit"` + MaxPositionQuantity fixedpoint.Value `json:"maxPositionQuantity"` + CircuitBreakLossThreshold fixedpoint.Value `json:"circuitBreakLossThreshold"` + CircuitBreakEMA types.IntervalWindow `json:"circuitBreakEMA"` + + positionRiskControl *riskcontrol.PositionRiskControl + circuitBreakRiskControl *riskcontrol.CircuitBreakRiskControl +} + +// Strategy provides the core functionality that is required by a long/short strategy. +type Strategy struct { + Position *types.Position `json:"position,omitempty" persistence:"position"` + ProfitStats *types.ProfitStats `json:"profitStats,omitempty" persistence:"profit_stats"` + + parent, ctx context.Context + cancel context.CancelFunc + + Environ *qbtrade.Environment + Session *qbtrade.ExchangeSession + OrderExecutor *qbtrade.GeneralOrderExecutor + + RiskController +} + +func (s *Strategy) Initialize(ctx context.Context, environ *qbtrade.Environment, session *qbtrade.ExchangeSession, market types.Market, strategyID, instanceID string) { + s.parent = ctx + s.ctx, s.cancel = context.WithCancel(ctx) + + s.Environ = environ + s.Session = session + + if s.ProfitStats == nil { + s.ProfitStats = types.NewProfitStats(market) + } + + if s.Position == nil { + s.Position = types.NewPositionFromMarket(market) + } + + // Always update the position fields + s.Position.Strategy = strategyID + s.Position.StrategyInstanceID = instanceID + + // if anyone of the fee rate is defined, this assumes that both are defined. + // so that zero maker fee could be applied + if session.MakerFeeRate.Sign() > 0 || session.TakerFeeRate.Sign() > 0 { + s.Position.SetExchangeFeeRate(session.ExchangeName, types.ExchangeFee{ + MakerFeeRate: session.MakerFeeRate, + TakerFeeRate: session.TakerFeeRate, + }) + } + + s.OrderExecutor = qbtrade.NewGeneralOrderExecutor(session, market.Symbol, strategyID, instanceID, s.Position) + s.OrderExecutor.BindEnvironment(environ) + s.OrderExecutor.BindProfitStats(s.ProfitStats) + s.OrderExecutor.Bind() + + if !s.PositionHardLimit.IsZero() && !s.MaxPositionQuantity.IsZero() { + log.Infof("positionHardLimit and maxPositionQuantity are configured, setting up PositionRiskControl...") + s.positionRiskControl = riskcontrol.NewPositionRiskControl(s.OrderExecutor, s.PositionHardLimit, s.MaxPositionQuantity) + s.positionRiskControl.Initialize(ctx, session) + } + + if !s.CircuitBreakLossThreshold.IsZero() { + log.Infof("circuitBreakLossThreshold is configured, setting up CircuitBreakRiskControl...") + s.circuitBreakRiskControl = riskcontrol.NewCircuitBreakRiskControl( + s.Position, + session.Indicators(market.Symbol).EWMA(s.CircuitBreakEMA), + s.CircuitBreakLossThreshold, + s.ProfitStats, + 24*time.Hour) + } +} + +func (s *Strategy) IsHalted(t time.Time) bool { + if s.circuitBreakRiskControl == nil { + return false + } + return s.circuitBreakRiskControl.IsHalted(t) +} diff --git a/pkg/strategy/common/sync.go b/pkg/strategy/common/sync.go new file mode 100644 index 0000000..7edca04 --- /dev/null +++ b/pkg/strategy/common/sync.go @@ -0,0 +1,104 @@ +package common + +import ( + "context" + "fmt" + "strconv" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/retry" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/sirupsen/logrus" + "go.uber.org/multierr" +) + +func SyncActiveOrder(ctx context.Context, ex types.Exchange, orderQueryService types.ExchangeOrderQueryService, activeOrderBook *qbtrade.ActiveOrderBook, orderID uint64, syncBefore time.Time) (isOrderUpdated bool, err error) { + //isMax := exchange.IsMaxExchange(ex) + + updatedOrder, err := retry.QueryOrderUntilSuccessful(ctx, orderQueryService, types.OrderQuery{ + Symbol: activeOrderBook.Symbol, + OrderID: strconv.FormatUint(orderID, 10), + }) + + if err != nil { + return isOrderUpdated, err + } + + if updatedOrder == nil { + return isOrderUpdated, fmt.Errorf("unexpected error, order object (%d) is a nil pointer, please check common.SyncActiveOrder()", orderID) + } + + // maxapi.OrderStateFinalizing does not mean the fee is calculated + // we should only consider order state done for MAX + //if isMax && updatedOrder.OriginalStatus != string(maxapi.OrderStateDone) { + // return isOrderUpdated, nil + //} + + // should only trigger order update when the updated time is old enough + isOrderUpdated = updatedOrder.UpdateTime.Before(syncBefore) + if isOrderUpdated { + activeOrderBook.Update(*updatedOrder) + } + + return isOrderUpdated, nil +} + +type SyncActiveOrdersOpts struct { + Logger *logrus.Entry + Exchange types.Exchange + OrderQueryService types.ExchangeOrderQueryService + ActiveOrderBook *qbtrade.ActiveOrderBook + OpenOrders []types.Order +} + +func SyncActiveOrders(ctx context.Context, opts SyncActiveOrdersOpts) error { + opts.Logger.Infof("[ActiveOrderRecover] syncActiveOrders") + + // only sync orders which is updated over 3 min, because we may receive from websocket and handle it twice + syncBefore := time.Now().Add(-3 * time.Minute) + + activeOrders := opts.ActiveOrderBook.Orders() + + openOrdersMap := make(map[uint64]types.Order) + for _, openOrder := range opts.OpenOrders { + openOrdersMap[openOrder.OrderID] = openOrder + } + + var errs error + // update active orders not in open orders + for _, activeOrder := range activeOrders { + if _, exist := openOrdersMap[activeOrder.OrderID]; exist { + // no need to sync active order already in active orderbook, because we only need to know if it filled or not. + delete(openOrdersMap, activeOrder.OrderID) + } else { + opts.Logger.Infof("[ActiveOrderRecover] found active order #%d is not in the open orders, updating...", activeOrder.OrderID) + + isActiveOrderBookUpdated, err := SyncActiveOrder(ctx, opts.Exchange, opts.OrderQueryService, opts.ActiveOrderBook, activeOrder.OrderID, syncBefore) + if err != nil { + opts.Logger.WithError(err).Errorf("[ActiveOrderRecover] unable to query order #%d", activeOrder.OrderID) + errs = multierr.Append(errs, err) + continue + } + + if !isActiveOrderBookUpdated { + opts.Logger.Infof("[ActiveOrderRecover] active order #%d is updated in 3 min, skip updating...", activeOrder.OrderID) + } + } + } + + // update open orders not in active orders + for _, openOrder := range openOrdersMap { + opts.Logger.Infof("found open order #%d is not in active orderbook, updating...", openOrder.OrderID) + // we don't add open orders into active orderbook if updated in 3 min, because we may receive message from websocket and add it twice. + if openOrder.UpdateTime.After(syncBefore) { + opts.Logger.Infof("open order #%d is updated in 3 min, skip updating...", openOrder.OrderID) + continue + } + + opts.ActiveOrderBook.Add(openOrder) + } + + return errs +} diff --git a/pkg/strategy/common/sync_test.go b/pkg/strategy/common/sync_test.go new file mode 100644 index 0000000..5903b3b --- /dev/null +++ b/pkg/strategy/common/sync_test.go @@ -0,0 +1,168 @@ +package common + +import ( + "context" + "strconv" + "testing" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types/mocks" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestSyncActiveOrders(t *testing.T) { + assert := assert.New(t) + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + log := logrus.WithField("strategy", "test") + symbol := "ETHUSDT" + t.Run("all open orders are match with active orderbook", func(t *testing.T) { + mockOrderQueryService := mocks.NewMockExchangeOrderQueryService(mockCtrl) + mockExchange := mocks.NewMockExchange(mockCtrl) + activeOrderbook := qbtrade.NewActiveOrderBook(symbol) + + order := types.Order{ + OrderID: 1, + Status: types.OrderStatusNew, + } + order.Symbol = symbol + + opts := SyncActiveOrdersOpts{ + Logger: log, + Exchange: mockExchange, + OrderQueryService: mockOrderQueryService, + ActiveOrderBook: activeOrderbook, + OpenOrders: []types.Order{order}, + } + + activeOrderbook.Add(order) + + assert.NoError(SyncActiveOrders(ctx, opts)) + + // verify active orderbook + activeOrders := activeOrderbook.Orders() + assert.Equal(1, len(activeOrders)) + assert.Equal(uint64(1), activeOrders[0].OrderID) + assert.Equal(types.OrderStatusNew, activeOrders[0].Status) + }) + + t.Run("there is order in active orderbook but not in open orders", func(t *testing.T) { + mockOrderQueryService := mocks.NewMockExchangeOrderQueryService(mockCtrl) + mockExchange := mocks.NewMockExchange(mockCtrl) + activeOrderbook := qbtrade.NewActiveOrderBook(symbol) + + order := types.Order{ + OrderID: 1, + Status: types.OrderStatusNew, + SubmitOrder: types.SubmitOrder{ + Symbol: symbol, + }, + } + updatedOrder := order + updatedOrder.Status = types.OrderStatusFilled + + opts := SyncActiveOrdersOpts{ + Logger: log, + ActiveOrderBook: activeOrderbook, + OrderQueryService: mockOrderQueryService, + Exchange: mockExchange, + OpenOrders: nil, + } + + activeOrderbook.Add(order) + mockOrderQueryService.EXPECT().QueryOrder(ctx, types.OrderQuery{ + Symbol: symbol, + OrderID: strconv.FormatUint(order.OrderID, 10), + }).Return(&updatedOrder, nil) + + assert.NoError(SyncActiveOrders(ctx, opts)) + + // verify active orderbook + activeOrders := activeOrderbook.Orders() + assert.Equal(0, len(activeOrders)) + }) + + t.Run("there is order on open orders but not in active orderbook", func(t *testing.T) { + mockOrderQueryService := mocks.NewMockExchangeOrderQueryService(mockCtrl) + mockExchange := mocks.NewMockExchange(mockCtrl) + activeOrderbook := qbtrade.NewActiveOrderBook(symbol) + + order := types.Order{ + OrderID: 1, + Status: types.OrderStatusNew, + SubmitOrder: types.SubmitOrder{ + Symbol: symbol, + }, + CreationTime: types.Time(time.Now()), + } + + opts := SyncActiveOrdersOpts{ + Logger: log, + ActiveOrderBook: activeOrderbook, + OrderQueryService: mockOrderQueryService, + Exchange: mockExchange, + OpenOrders: []types.Order{order}, + } + assert.NoError(SyncActiveOrders(ctx, opts)) + + // verify active orderbook + activeOrders := activeOrderbook.Orders() + assert.Equal(1, len(activeOrders)) + assert.Equal(uint64(1), activeOrders[0].OrderID) + assert.Equal(types.OrderStatusNew, activeOrders[0].Status) + }) + + t.Run("there is order on open order but not in active orderbook also order in active orderbook but not on open orders", func(t *testing.T) { + mockOrderQueryService := mocks.NewMockExchangeOrderQueryService(mockCtrl) + mockExchange := mocks.NewMockExchange(mockCtrl) + activeOrderbook := qbtrade.NewActiveOrderBook(symbol) + + order1 := types.Order{ + OrderID: 1, + Status: types.OrderStatusNew, + SubmitOrder: types.SubmitOrder{ + Symbol: symbol, + }, + } + updatedOrder1 := order1 + updatedOrder1.Status = types.OrderStatusFilled + order2 := types.Order{ + OrderID: 2, + Status: types.OrderStatusNew, + SubmitOrder: types.SubmitOrder{ + Symbol: symbol, + }, + } + + opts := SyncActiveOrdersOpts{ + Logger: log, + ActiveOrderBook: activeOrderbook, + OrderQueryService: mockOrderQueryService, + Exchange: mockExchange, + OpenOrders: []types.Order{order2}, + } + + activeOrderbook.Add(order1) + mockOrderQueryService.EXPECT().QueryOrder(ctx, types.OrderQuery{ + Symbol: symbol, + OrderID: strconv.FormatUint(order1.OrderID, 10), + }).Return(&updatedOrder1, nil) + + assert.NoError(SyncActiveOrders(ctx, opts)) + + // verify active orderbook + activeOrders := activeOrderbook.Orders() + assert.Equal(1, len(activeOrders)) + assert.Equal(uint64(2), activeOrders[0].OrderID) + assert.Equal(types.OrderStatusNew, activeOrders[0].Status) + }) +} diff --git a/pkg/strategy/convert/strategy.go b/pkg/strategy/convert/strategy.go new file mode 100644 index 0000000..eb2f3c8 --- /dev/null +++ b/pkg/strategy/convert/strategy.go @@ -0,0 +1,435 @@ +package convert + +import ( + "context" + "fmt" + "strconv" + "sync" + "time" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util/tradingutil" +) + +const ID = "convert" + +var log = logrus.WithField("strategy", ID) + +var stableCoins = []string{"USDT", "USDC"} + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +// Strategy "convert" converts your specific asset into other asset +type Strategy struct { + Market types.Market + + From string `json:"from"` + To string `json:"to"` + + // Interval is the period that you want to submit order + Interval types.Interval `json:"interval"` + + UseLimitOrder bool `json:"useLimitOrder"` + + UseTakerOrder bool `json:"useTakerOrder"` + + MinBalance fixedpoint.Value `json:"minBalance"` + MaxQuantity fixedpoint.Value `json:"maxQuantity"` + + Position *types.Position `persistence:"position"` + + directMarket *types.Market + indirectMarkets []types.Market + + markets map[string]types.Market + session *qbtrade.ExchangeSession + orderExecutor *qbtrade.SimpleOrderExecutor + + pendingQuantity map[string]fixedpoint.Value `persistence:"pendingQuantities"` + pendingQuantityLock sync.Mutex +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s-%s", ID, s.From, s.To) +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + +} + +func (s *Strategy) Validate() error { + return nil +} + +func (s *Strategy) handleOrderFilled(ctx context.Context, order types.Order) { + var fees = map[string]fixedpoint.Value{} + + if service, ok := s.session.Exchange.(types.ExchangeOrderQueryService); ok { + trades, err := service.QueryOrderTrades(ctx, types.OrderQuery{ + Symbol: order.Symbol, + OrderID: strconv.FormatUint(order.OrderID, 10), + }) + + if err != nil { + return + } + + fees = tradingutil.CollectTradeFee(trades) + log.Infof("aggregated order fees: %+v", fees) + } + + if s.directMarket != nil { + if order.Symbol != s.directMarket.Symbol { + return + } + + // TODO: notification + return + } else if len(s.indirectMarkets) > 0 { + for i := 0; i < len(s.indirectMarkets); i++ { + market := s.indirectMarkets[i] + if market.Symbol != order.Symbol { + continue + } + + if i == len(s.indirectMarkets)-1 { + // TODO: handle the final order here + continue + } + + nextMarket := s.indirectMarkets[i+1] + + quantity := order.Quantity + quoteQuantity := quantity.Mul(order.Price) + + switch order.Side { + case types.SideTypeSell: + // convert quote asset + if quoteFee, ok := fees[market.QuoteCurrency]; ok { + quoteQuantity = quoteQuantity.Sub(quoteFee) + } + + if err := s.convertBalance(ctx, market.QuoteCurrency, quoteQuantity, nextMarket); err != nil { + log.WithError(err).Errorf("unable to convert balance") + } + + case types.SideTypeBuy: + if baseFee, ok := fees[market.BaseCurrency]; ok { + quantity = quantity.Sub(baseFee) + } + + if err := s.convertBalance(ctx, market.BaseCurrency, quantity, nextMarket); err != nil { + log.WithError(err).Errorf("unable to convert balance") + } + } + } + } + +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + s.pendingQuantity = make(map[string]fixedpoint.Value) + s.session = session + s.markets = session.Markets() + + if market, ok := findDirectMarket(s.markets, s.From, s.To); ok { + s.directMarket = &market + } else if marketChain, ok := findIndirectMarket(s.markets, s.From, s.To); ok { + s.indirectMarkets = marketChain + } + + s.orderExecutor = qbtrade.NewSimpleOrderExecutor(session) + s.orderExecutor.ActiveMakerOrders().OnFilled(func(o types.Order) { + s.handleOrderFilled(ctx, o) + }) + s.orderExecutor.Bind() + + if s.Interval != "" { + session.UserDataStream.OnStart(func() { + go s.tickWatcher(ctx, s.Interval.Duration()) + }) + } + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + s.collectPendingQuantity(ctx) + + _ = s.orderExecutor.GracefulCancel(ctx) + }) + + return nil +} + +func (s *Strategy) tickWatcher(ctx context.Context, interval time.Duration) { + if err := s.convert(ctx); err != nil { + log.WithError(err).Errorf("unable to convert asset %s", s.From) + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + + case <-ticker.C: + if err := s.convert(ctx); err != nil { + log.WithError(err).Errorf("unable to convert asset %s", s.From) + } + } + } +} + +func (s *Strategy) getSourceMarket() (types.Market, bool) { + if s.directMarket != nil { + return *s.directMarket, true + } else if len(s.indirectMarkets) > 0 { + return s.indirectMarkets[0], true + } + + return types.Market{}, false +} + +// convert triggers a convert order +func (s *Strategy) convert(ctx context.Context) error { + s.collectPendingQuantity(ctx) + + // sleep one second for exchange to unlock the balance + time.Sleep(time.Second) + + account := s.session.GetAccount() + fromAsset, ok := account.Balance(s.From) + if !ok { + return nil + } + + log.Debugf("converting %s to %s, current balance: %+v", s.From, s.To, fromAsset) + + if sourceMarket, ok := s.getSourceMarket(); ok { + quantity := fromAsset.Available + + if !s.MinBalance.IsZero() { + quantity = quantity.Sub(s.MinBalance) + if quantity.Sign() < 0 { + return nil + } + } + + if !s.MaxQuantity.IsZero() { + quantity = fixedpoint.Min(s.MaxQuantity, quantity) + } + + if err := s.convertBalance(ctx, fromAsset.Currency, quantity, sourceMarket); err != nil { + return err + } + } + + return nil +} + +func (s *Strategy) addPendingQuantity(asset string, q fixedpoint.Value) { + if q2, ok := s.pendingQuantity[asset]; ok { + s.pendingQuantity[asset] = q2.Add(q) + } else { + s.pendingQuantity[asset] = q + } +} + +func (s *Strategy) collectPendingQuantity(ctx context.Context) { + log.Infof("collecting pending quantity...") + + s.pendingQuantityLock.Lock() + defer s.pendingQuantityLock.Unlock() + + activeOrders := s.orderExecutor.ActiveMakerOrders().Orders() + log.Infof("found %d active orders", len(activeOrders)) + + if err := s.orderExecutor.GracefulCancel(ctx); err != nil { + log.WithError(err).Warn("unable to cancel orders") + } + + for _, o := range activeOrders { + log.Infof("checking order: %+v", o) + + if service, ok := s.session.Exchange.(types.ExchangeOrderQueryService); ok { + trades, err := service.QueryOrderTrades(ctx, types.OrderQuery{ + Symbol: o.Symbol, + OrderID: strconv.FormatUint(o.OrderID, 10), + }) + + if err != nil { + return + } + + o.ExecutedQuantity = tradingutil.AggregateTradesQuantity(trades) + + log.Infof("updated executed quantity to %s", o.ExecutedQuantity) + } + + if m, ok := s.markets[o.Symbol]; ok { + switch o.Side { + case types.SideTypeBuy: + if !o.ExecutedQuantity.IsZero() { + s.addPendingQuantity(m.BaseCurrency, o.ExecutedQuantity) + } + + if m.QuoteCurrency == s.From { + continue + } + + qq := o.Quantity.Sub(o.ExecutedQuantity).Mul(o.Price) + s.addPendingQuantity(m.QuoteCurrency, qq) + case types.SideTypeSell: + + if !o.ExecutedQuantity.IsZero() { + s.addPendingQuantity(m.QuoteCurrency, o.ExecutedQuantity.Mul(o.Price)) + } + + if m.BaseCurrency == s.From { + continue + } + + q := o.Quantity.Sub(o.ExecutedQuantity) + s.addPendingQuantity(m.BaseCurrency, q) + } + } + } + + log.Infof("collected pending quantity: %+v", s.pendingQuantity) +} + +func (s *Strategy) convertBalance(ctx context.Context, fromAsset string, available fixedpoint.Value, market types.Market) error { + ticker, err2 := s.session.Exchange.QueryTicker(ctx, market.Symbol) + if err2 != nil { + return err2 + } + + s.pendingQuantityLock.Lock() + if pendingQ, ok := s.pendingQuantity[fromAsset]; ok { + + log.Infof("adding pending quantity %s to the current quantity %s", pendingQ, available) + available = available.Add(pendingQ) + + delete(s.pendingQuantity, fromAsset) + } + s.pendingQuantityLock.Unlock() + + switch fromAsset { + + case market.BaseCurrency: + price := ticker.Sell + if s.UseTakerOrder { + price = ticker.Buy + } + + log.Infof("converting %s %s to %s...", available, fromAsset, market.QuoteCurrency) + + quantity, ok := market.GreaterThanMinimalOrderQuantity(types.SideTypeSell, price, available) + if !ok { + log.Debugf("asset %s %s is less than MoQ, skip convert", available, fromAsset) + return nil + } + + orderForm := types.SubmitOrder{ + Symbol: market.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeLimit, + Quantity: quantity, + Price: price, + Market: market, + TimeInForce: types.TimeInForceGTC, + } + if _, err := s.orderExecutor.SubmitOrders(ctx, orderForm); err != nil { + log.WithError(err).Errorf("unable to submit order: %+v", orderForm) + } + + case market.QuoteCurrency: + price := ticker.Buy + if s.UseTakerOrder { + price = ticker.Sell + } + + log.Infof("converting %s %s to %s...", available, fromAsset, market.BaseCurrency) + + quantity, ok := market.GreaterThanMinimalOrderQuantity(types.SideTypeBuy, price, available) + if !ok { + log.Debugf("asset %s %s is less than MoQ, skip convert", available, fromAsset) + return nil + } + + orderForm := types.SubmitOrder{ + Symbol: market.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: quantity, + Price: price, + Market: market, + TimeInForce: types.TimeInForceGTC, + } + if _, err := s.orderExecutor.SubmitOrders(ctx, orderForm); err != nil { + log.WithError(err).Errorf("unable to submit order: %+v", orderForm) + } + } + + return nil +} + +func findIndirectMarket(markets map[string]types.Market, from, to string) ([]types.Market, bool) { + var sourceMarkets = map[string]types.Market{} + var targetMarkets = map[string]types.Market{} + + for _, market := range markets { + if market.BaseCurrency == from { + sourceMarkets[market.QuoteCurrency] = market + } else if market.QuoteCurrency == from { + sourceMarkets[market.BaseCurrency] = market + } + + if market.BaseCurrency == to { + targetMarkets[market.QuoteCurrency] = market + } else if market.QuoteCurrency == to { + targetMarkets[market.BaseCurrency] = market + } + } + + // prefer stable coins for better liquidity + for _, stableCoin := range stableCoins { + m1, ok1 := sourceMarkets[stableCoin] + m2, ok2 := targetMarkets[stableCoin] + if ok1 && ok2 { + return []types.Market{m1, m2}, true + } + } + + for sourceCurrency, m1 := range sourceMarkets { + if m2, ok := targetMarkets[sourceCurrency]; ok { + return []types.Market{m1, m2}, true + } + } + + return nil, false +} + +func findDirectMarket(markets map[string]types.Market, from, to string) (types.Market, bool) { + symbol := from + to + if m, ok := markets[symbol]; ok { + return m, true + } + + symbol = to + from + if m, ok := markets[symbol]; ok { + return m, true + } + + return types.Market{}, false +} diff --git a/pkg/strategy/dca/strategy.go b/pkg/strategy/dca/strategy.go new file mode 100644 index 0000000..8ae39de --- /dev/null +++ b/pkg/strategy/dca/strategy.go @@ -0,0 +1,157 @@ +package dca + +import ( + "context" + "fmt" + "time" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ID = "dca" + +var log = logrus.WithField("strategy", ID) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type BudgetPeriod string + +const ( + BudgetPeriodDay BudgetPeriod = "day" + BudgetPeriodWeek BudgetPeriod = "week" + BudgetPeriodMonth BudgetPeriod = "month" +) + +func (b BudgetPeriod) Duration() time.Duration { + var period time.Duration + switch b { + case BudgetPeriodDay: + period = 24 * time.Hour + + case BudgetPeriodWeek: + period = 24 * time.Hour * 7 + + case BudgetPeriodMonth: + period = 24 * time.Hour * 30 + + } + + return period +} + +// Strategy is the Dollar-Cost-Average strategy +type Strategy struct { + Environment *qbtrade.Environment + Symbol string `json:"symbol"` + Market types.Market + + // BudgetPeriod is how long your budget quota will be reset. + // day, week, month + BudgetPeriod BudgetPeriod `json:"budgetPeriod"` + + // Budget is the amount you invest per budget period + Budget fixedpoint.Value `json:"budget"` + + // InvestmentInterval is the interval of each investment + InvestmentInterval types.Interval `json:"investmentInterval"` + + budgetPerInvestment fixedpoint.Value + + Position *types.Position `persistence:"position"` + ProfitStats *types.ProfitStats `persistence:"profit_stats"` + BudgetQuota fixedpoint.Value `persistence:"budget_quota"` + BudgetPeriodStartTime time.Time `persistence:"budget_period_start_time"` + + session *qbtrade.ExchangeSession + orderExecutor *qbtrade.GeneralOrderExecutor + + qbtrade.StrategyController +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.InvestmentInterval}) +} + +func (s *Strategy) ClosePosition(ctx context.Context, percentage fixedpoint.Value) error { + return s.orderExecutor.ClosePosition(ctx, percentage) +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s", ID, s.Symbol) +} + +func (s *Strategy) Run(ctx context.Context, _ qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + if s.BudgetQuota.IsZero() { + s.BudgetQuota = s.Budget + } + + if s.Position == nil { + s.Position = types.NewPositionFromMarket(s.Market) + } + + if s.ProfitStats == nil { + s.ProfitStats = types.NewProfitStats(s.Market) + } + + instanceID := s.InstanceID() + s.session = session + s.orderExecutor = qbtrade.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, s.Position) + s.orderExecutor.BindEnvironment(s.Environment) + s.orderExecutor.BindProfitStats(s.ProfitStats) + s.orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { + qbtrade.Sync(ctx, s) + }) + s.orderExecutor.Bind() + + numOfInvestmentPerPeriod := fixedpoint.NewFromFloat(float64(s.BudgetPeriod.Duration()) / float64(s.InvestmentInterval.Duration())) + s.budgetPerInvestment = s.Budget.Div(numOfInvestmentPerPeriod) + + session.UserDataStream.OnStart(func() {}) + session.MarketDataStream.OnKLine(func(kline types.KLine) {}) + session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { + if kline.Symbol != s.Symbol || kline.Interval != s.InvestmentInterval { + return + } + + if s.BudgetPeriodStartTime == (time.Time{}) { + s.BudgetPeriodStartTime = kline.StartTime.Time().Truncate(time.Minute) + } + + if kline.EndTime.Time().Sub(s.BudgetPeriodStartTime) >= s.BudgetPeriod.Duration() { + // reset budget quota + s.BudgetQuota = s.Budget + s.BudgetPeriodStartTime = kline.StartTime.Time() + } + + // check if we have quota + if s.BudgetQuota.Compare(s.budgetPerInvestment) <= 0 { + return + } + + price := kline.Close + quantity := s.budgetPerInvestment.Div(price) + + _, err := s.orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeMarket, + Quantity: quantity, + Market: s.Market, + }) + if err != nil { + log.WithError(err).Errorf("submit order failed") + } + }) + + return nil +} diff --git a/pkg/strategy/dca2/collector.go b/pkg/strategy/dca2/collector.go new file mode 100644 index 0000000..bce54a5 --- /dev/null +++ b/pkg/strategy/dca2/collector.go @@ -0,0 +1,203 @@ +package dca2 + +import ( + "context" + "strconv" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/retry" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/sirupsen/logrus" +) + +// Round contains the open-position orders and the take-profit orders +// 1. len(OpenPositionOrders) == 0 -> not open position +// 2. len(TakeProfitOrders) == 0 -> not in the take-profit stage +// 3. There are take-profit orders only when open-position orders are cancelled +// 4. We need to make sure the order: open-position (BUY) -> take-profit (SELL) -> open-position (BUY) -> take-profit (SELL) -> ... +// 5. When there is one filled take-profit order, this round must be finished. We need to verify all take-profit orders are not active +type Round struct { + OpenPositionOrders []types.Order + TakeProfitOrders []types.Order +} + +type Collector struct { + logger *logrus.Entry + symbol string + groupID uint32 + filterGroupID bool + + // service + ex types.Exchange + historyService types.ExchangeTradeHistoryService + queryService types.ExchangeOrderQueryService + tradeService types.ExchangeTradeService + queryClosedOrderDesc descendingClosedOrderQueryService +} + +func NewCollector(logger *logrus.Entry, symbol string, groupID uint32, filterGroupID bool, ex types.Exchange) *Collector { + historyService, ok := ex.(types.ExchangeTradeHistoryService) + if !ok { + logger.Errorf("exchange %s doesn't support ExchangeTradeHistoryService", ex.Name()) + return nil + } + + queryService, ok := ex.(types.ExchangeOrderQueryService) + if !ok { + logger.Errorf("exchange %s doesn't support ExchangeOrderQueryService", ex.Name()) + return nil + } + + tradeService, ok := ex.(types.ExchangeTradeService) + if !ok { + logger.Errorf("exchange %s doesn't support ExchangeTradeService", ex.Name()) + return nil + } + + queryClosedOrderDesc, ok := ex.(descendingClosedOrderQueryService) + if !ok { + logger.Errorf("exchange %s doesn't support query closed orders desc", ex.Name()) + return nil + } + + return &Collector{ + logger: logger, + symbol: symbol, + groupID: groupID, + filterGroupID: filterGroupID, + ex: ex, + historyService: historyService, + queryService: queryService, + tradeService: tradeService, + queryClosedOrderDesc: queryClosedOrderDesc, + } +} + +func (rc Collector) CollectCurrentRound(ctx context.Context) (Round, error) { + openOrders, err := retry.QueryOpenOrdersUntilSuccessful(ctx, rc.ex, rc.symbol) + if err != nil { + return Round{}, err + } + + var closedOrders []types.Order + var op = func() (err2 error) { + closedOrders, err2 = rc.queryClosedOrderDesc.QueryClosedOrdersDesc(ctx, rc.symbol, recoverSinceLimit, time.Now(), 0) + return err2 + } + if err := retry.GeneralBackoff(ctx, op); err != nil { + return Round{}, err + } + + openPositionSide := types.SideTypeBuy + takeProfitSide := types.SideTypeSell + + var allOrders []types.Order + allOrders = append(allOrders, openOrders...) + allOrders = append(allOrders, closedOrders...) + + types.SortOrdersDescending(allOrders) + + var currentRound Round + lastSide := takeProfitSide + for _, order := range allOrders { + // group id filter is used for debug when local running + if rc.filterGroupID && order.GroupID != rc.groupID { + continue + } + + if order.Side == takeProfitSide && lastSide == openPositionSide { + break + } + + switch order.Side { + case openPositionSide: + currentRound.OpenPositionOrders = append(currentRound.OpenPositionOrders, order) + case takeProfitSide: + currentRound.TakeProfitOrders = append(currentRound.TakeProfitOrders, order) + default: + } + + lastSide = order.Side + } + + return currentRound, nil +} + +func (rc *Collector) CollectFinishRounds(ctx context.Context, fromOrderID uint64) ([]Round, error) { + // TODO: pagination for it + // query the orders + rc.logger.Infof("query %s closed orders from order id #%d", rc.symbol, fromOrderID) + orders, err := retry.QueryClosedOrdersUntilSuccessfulLite(ctx, rc.historyService, rc.symbol, time.Time{}, time.Time{}, fromOrderID) + if err != nil { + return nil, err + } + rc.logger.Infof("there are %d closed orders from order id #%d", len(orders), fromOrderID) + + var rounds []Round + var round Round + for _, order := range orders { + // skip not this strategy order + if rc.filterGroupID && order.GroupID != rc.groupID { + continue + } + + switch order.Side { + case types.SideTypeBuy: + round.OpenPositionOrders = append(round.OpenPositionOrders, order) + case types.SideTypeSell: + round.TakeProfitOrders = append(round.TakeProfitOrders, order) + + if order.Status != types.OrderStatusFilled { + rc.logger.Infof("take-profit order is not filled (%s), so this round is not finished. Keep collecting", order.Status) + continue + } + + for _, o := range round.TakeProfitOrders { + if types.IsActiveOrder(o) { + // Should not happen ! but we only log it + rc.logger.Errorf("unexpected error, there is at least one take-profit order #%d is still active, please check it. %s", o.OrderID, o.String()) + } + } + + rounds = append(rounds, round) + round = Round{} + default: + rc.logger.Errorf("there is order with unsupported side") + } + } + + return rounds, nil +} + +// CollectRoundTrades collect the trades of the orders in the given round. The trades' fee are processed (feeProcessing = false) +func (rc *Collector) CollectRoundTrades(ctx context.Context, round Round) ([]types.Trade, error) { + debugRoundOrders(rc.logger, "collect round trades", round) + + var roundTrades []types.Trade + var roundOrders []types.Order = round.OpenPositionOrders + + roundOrders = append(roundOrders, round.TakeProfitOrders...) + + for _, order := range roundOrders { + if order.ExecutedQuantity.IsZero() { + rc.logger.Info("collect trads from order but no executed quantity ", order.String()) + continue + } else { + rc.logger.Info("collect trades from order ", order.String()) + } + + // QueryOrderTradesUntilSuccessful will query trades and their feeProcessing = false + trades, err := retry.QueryOrderTradesUntilSuccessful(ctx, rc.queryService, types.OrderQuery{ + Symbol: order.Symbol, + OrderID: strconv.FormatUint(order.OrderID, 10), + }) + + if err != nil { + return nil, err + } + + roundTrades = append(roundTrades, trades...) + } + + return roundTrades, nil +} diff --git a/pkg/strategy/dca2/collector_test.go b/pkg/strategy/dca2/collector_test.go new file mode 100644 index 0000000..a619755 --- /dev/null +++ b/pkg/strategy/dca2/collector_test.go @@ -0,0 +1,72 @@ +package dca2 + +import ( + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types/mocks" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func Test_NewCollector(t *testing.T) { + symbol := "ETHUSDT" + logger := log.WithField("strategy", ID) + + t.Run("return nil if the exchange doesn't support ExchangeTradeHistoryService", func(t *testing.T) { + mockCtrl := gomock.NewController(t) + mockEx := mocks.NewMockExchange(mockCtrl) + mockEx.EXPECT().Name().Return(types.ExchangeMax) + + collector := NewCollector(logger, symbol, 0, false, mockEx) + + assert.Nil(t, collector) + }) + + t.Run("return nil if the exchange doesn't support ExchangeOrderQueryService", func(t *testing.T) { + mockCtrl := gomock.NewController(t) + mockEx := mocks.NewMockExchange(mockCtrl) + mockEx.EXPECT().Name().Return(types.ExchangeMax) + + mockTradeHistoryService := mocks.NewMockExchangeTradeHistoryService(mockCtrl) + + type TestEx struct { + types.Exchange + types.ExchangeTradeHistoryService + } + + ex := TestEx{ + Exchange: mockEx, + ExchangeTradeHistoryService: mockTradeHistoryService, + } + + collector := NewCollector(logger, symbol, 0, false, ex) + + assert.Nil(t, collector) + }) + + t.Run("return nil if the exchange doesn't support descendingClosedOrderQueryService", func(t *testing.T) { + mockCtrl := gomock.NewController(t) + mockEx := mocks.NewMockExchange(mockCtrl) + mockEx.EXPECT().Name().Return(types.ExchangeMax) + + mockTradeHistoryService := mocks.NewMockExchangeTradeHistoryService(mockCtrl) + mockOrderQueryService := mocks.NewMockExchangeOrderQueryService(mockCtrl) + + type TestEx struct { + types.Exchange + types.ExchangeTradeHistoryService + types.ExchangeOrderQueryService + } + + ex := TestEx{ + Exchange: mockEx, + ExchangeTradeHistoryService: mockTradeHistoryService, + ExchangeOrderQueryService: mockOrderQueryService, + } + + collector := NewCollector(logger, symbol, 0, false, ex) + + assert.Nil(t, collector) + }) +} diff --git a/pkg/strategy/dca2/debug.go b/pkg/strategy/dca2/debug.go new file mode 100644 index 0000000..477a0af --- /dev/null +++ b/pkg/strategy/dca2/debug.go @@ -0,0 +1,34 @@ +package dca2 + +import ( + "fmt" + "strings" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/sirupsen/logrus" +) + +func (s *Strategy) debugOrders(submitOrders []types.Order) { + var sb strings.Builder + sb.WriteString("DCA ORDERS[\n") + for i, order := range submitOrders { + sb.WriteString(fmt.Sprintf("%3d) ", i+1) + order.String() + "\n") + } + sb.WriteString("] END OF DCA ORDERS") + + s.logger.Info(sb.String()) +} + +func debugRoundOrders(logger *logrus.Entry, roundName string, round Round) { + var sb strings.Builder + sb.WriteString("ROUND " + roundName + " [\n") + for i, order := range round.TakeProfitOrders { + sb.WriteString(fmt.Sprintf("%3d) ", i+1) + order.String() + "\n") + } + sb.WriteString("------------------------------------------------\n") + for i, order := range round.OpenPositionOrders { + sb.WriteString(fmt.Sprintf("%3d) ", i+1) + order.String() + "\n") + } + sb.WriteString("] END OF ROUND") + logger.Info(sb.String()) +} diff --git a/pkg/strategy/dca2/metrics.go b/pkg/strategy/dca2/metrics.go new file mode 100644 index 0000000..edadfcd --- /dev/null +++ b/pkg/strategy/dca2/metrics.go @@ -0,0 +1,116 @@ +package dca2 + +import ( + "strconv" + + "github.com/prometheus/client_golang/prometheus" +) + +var ( + metricsState *prometheus.GaugeVec + metricsNumOfActiveOrders *prometheus.GaugeVec + metricsNumOfOpenOrders *prometheus.GaugeVec + metricsProfit *prometheus.GaugeVec +) + +func labelKeys(labels prometheus.Labels) []string { + var keys []string + for k := range labels { + keys = append(keys, k) + } + + return keys +} + +func mergeLabels(a, b prometheus.Labels) prometheus.Labels { + labels := prometheus.Labels{} + for k, v := range a { + labels[k] = v + } + + for k, v := range b { + labels[k] = v + } + return labels +} + +func initMetrics(extendedLabels []string) { + if metricsState == nil { + metricsState = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_dca2_state", + Help: "state of this DCA2 strategy", + }, + append([]string{ + "exchange", + "symbol", + }, extendedLabels...), + ) + } + + if metricsNumOfActiveOrders == nil { + metricsNumOfActiveOrders = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_dca2_num_of_active_orders", + Help: "number of active orders", + }, + append([]string{ + "exchange", + "symbol", + }, extendedLabels...), + ) + } + + if metricsNumOfOpenOrders == nil { + metricsNumOfOpenOrders = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_dca2_num_of_open_orders", + Help: "number of open orders", + }, + append([]string{ + "exchange", + "symbol", + }, extendedLabels...), + ) + } + + if metricsProfit == nil { + metricsProfit = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_dca2_profit", + Help: "profit of this DCA@ strategy", + }, + append([]string{ + "exchange", + "symbol", + "round", + }, extendedLabels...), + ) + } +} + +var metricsRegistered = false + +func registerMetrics() { + if metricsRegistered { + return + } + + initMetrics(nil) + + prometheus.MustRegister( + metricsState, + metricsNumOfActiveOrders, + metricsNumOfOpenOrders, + metricsProfit, + ) + + metricsRegistered = true +} + +func updateProfitMetrics(round int64, profit float64) { + labels := mergeLabels(baseLabels, prometheus.Labels{ + "round": strconv.FormatInt(round, 10), + }) + metricsProfit.With(labels).Set(profit) +} diff --git a/pkg/strategy/dca2/open_position.go b/pkg/strategy/dca2/open_position.go new file mode 100644 index 0000000..c339f25 --- /dev/null +++ b/pkg/strategy/dca2/open_position.go @@ -0,0 +1,131 @@ +package dca2 + +import ( + "context" + "fmt" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/retry" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type cancelOrdersByGroupIDApi interface { + CancelOrdersByGroupID(ctx context.Context, groupID int64) ([]types.Order, error) +} + +func (s *Strategy) placeOpenPositionOrders(ctx context.Context) error { + s.logger.Infof("start placing open position orders") + price, err := getBestPriceUntilSuccess(ctx, s.ExchangeSession.Exchange, s.Symbol) + if err != nil { + return err + } + + orders, err := generateOpenPositionOrders(s.Market, s.EnableQuoteInvestmentReallocate, s.QuoteInvestment, s.ProfitStats.TotalProfit, price, s.PriceDeviation, s.MaxOrderCount, s.OrderGroupID) + if err != nil { + return err + } + + createdOrders, err := s.OrderExecutor.SubmitOrders(ctx, orders...) + if err != nil { + return err + } + + s.debugOrders(createdOrders) + + // store price quantity pairs into persistence + var pvs []types.PriceVolume + for _, createdOrder := range createdOrders { + pvs = append(pvs, types.PriceVolume{Price: createdOrder.Price, Volume: createdOrder.Quantity}) + } + + s.ProfitStats.OpenPositionPVs = pvs + + qbtrade.Sync(ctx, s) + + return nil +} + +func getBestPriceUntilSuccess(ctx context.Context, ex types.Exchange, symbol string) (fixedpoint.Value, error) { + ticker, err := retry.QueryTickerUntilSuccessful(ctx, ex, symbol) + if err != nil { + return fixedpoint.Zero, err + } + + return ticker.Sell, nil +} + +func generateOpenPositionOrders(market types.Market, enableQuoteInvestmentReallocate bool, quoteInvestment, profit, price, priceDeviation fixedpoint.Value, maxOrderCount int64, orderGroupID uint32) ([]types.SubmitOrder, error) { + factor := fixedpoint.One.Sub(priceDeviation) + profit = market.TruncatePrice(profit) + + // calculate all valid prices + var prices []fixedpoint.Value + for i := 0; i < int(maxOrderCount); i++ { + if i > 0 { + price = price.Mul(factor) + } + price = market.TruncatePrice(price) + if price.Compare(market.MinPrice) < 0 { + break + } + + prices = append(prices, price) + } + + notional, orderNum := calculateNotionalAndNumOrders(market, quoteInvestment, prices) + if orderNum == 0 { + return nil, fmt.Errorf("failed to calculate notional and num of open position orders, price: %s, quote investment: %s", price, quoteInvestment) + } + + if !enableQuoteInvestmentReallocate && orderNum != int(maxOrderCount) { + return nil, fmt.Errorf("failed to generate open-position orders due to the orders may be under min notional or quantity") + } + + side := types.SideTypeBuy + + var submitOrders []types.SubmitOrder + for i := 0; i < orderNum; i++ { + var quantity fixedpoint.Value + // all the profit will use in the first order + if i == 0 { + quantity = market.TruncateQuantity(notional.Add(profit).Div(prices[i])) + } else { + quantity = market.TruncateQuantity(notional.Div(prices[i])) + } + submitOrders = append(submitOrders, types.SubmitOrder{ + Symbol: market.Symbol, + Market: market, + Type: types.OrderTypeLimit, + Price: prices[i], + Side: side, + TimeInForce: types.TimeInForceGTC, + Quantity: quantity, + Tag: orderTag, + GroupID: orderGroupID, + }) + } + + return submitOrders, nil +} + +// calculateNotionalAndNumOrders calculates the notional and num of open position orders +// DCA2 is notional-based, every order has the same notional +func calculateNotionalAndNumOrders(market types.Market, quoteInvestment fixedpoint.Value, prices []fixedpoint.Value) (fixedpoint.Value, int) { + for num := len(prices); num > 0; num-- { + notional := quoteInvestment.Div(fixedpoint.NewFromInt(int64(num))) + if notional.Compare(market.MinNotional) < 0 { + continue + } + + maxPriceIdx := 0 + quantity := market.TruncateQuantity(notional.Div(prices[maxPriceIdx])) + if quantity.Compare(market.MinQuantity) < 0 { + continue + } + + return market.TruncatePrice(notional), num + } + + return fixedpoint.Zero, 0 +} diff --git a/pkg/strategy/dca2/open_position_test.go b/pkg/strategy/dca2/open_position_test.go new file mode 100644 index 0000000..3e00018 --- /dev/null +++ b/pkg/strategy/dca2/open_position_test.go @@ -0,0 +1,96 @@ +package dca2 + +import ( + "testing" + + "github.com/sirupsen/logrus" + + "github.com/stretchr/testify/assert" + + . "git.qtrade.icu/lychiyu/qbtrade/pkg/testing/testhelper" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func newTestMarket() types.Market { + return types.Market{ + Symbol: "BTCUSDT", + BaseCurrency: "BTC", + QuoteCurrency: "USDT", + TickSize: Number(0.01), + StepSize: Number(0.000001), + PricePrecision: 2, + VolumePrecision: 8, + MinNotional: Number(8.0), + MinQuantity: Number(0.0003), + } +} + +func newTestStrategy(va ...string) *Strategy { + symbol := "BTCUSDT" + + if len(va) > 0 { + symbol = va[0] + } + + market := newTestMarket() + s := &Strategy{ + logger: logrus.NewEntry(logrus.New()), + Symbol: symbol, + Market: market, + TakeProfitRatio: Number("10%"), + } + return s +} + +func TestGenerateOpenPositionOrders(t *testing.T) { + assert := assert.New(t) + + strategy := newTestStrategy() + + t.Run("case 1: all config is valid and we can place enough orders", func(t *testing.T) { + quoteInvestment := Number("10500") + profit := Number("300") + askPrice := Number("30000") + margin := Number("0.05") + submitOrders, err := generateOpenPositionOrders(strategy.Market, false, quoteInvestment, profit, askPrice, margin, 4, strategy.OrderGroupID) + if !assert.NoError(err) { + return + } + + assert.Len(submitOrders, 4) + assert.Equal(Number("30000"), submitOrders[0].Price) + assert.Equal(Number("0.0975"), submitOrders[0].Quantity) + assert.Equal(Number("28500"), submitOrders[1].Price) + assert.Equal(Number("0.092105"), submitOrders[1].Quantity) + assert.Equal(Number("27075"), submitOrders[2].Price) + assert.Equal(Number("0.096952"), submitOrders[2].Quantity) + assert.Equal(Number("25721.25"), submitOrders[3].Price) + assert.Equal(Number("0.102055"), submitOrders[3].Quantity) + }) + + t.Run("case 2: profit need to be truncated to avoid precision problem", func(t *testing.T) { + quoteInvestment := Number("1000") + profit := Number("99.47871711") + askPrice := Number("40409.72") + margin := Number("0.1") + submitOrders, err := generateOpenPositionOrders(strategy.Market, false, quoteInvestment, profit, askPrice, margin, 2, strategy.OrderGroupID) + if !assert.NoError(err) { + return + } + + assert.Len(submitOrders, 2) + assert.Equal(Number("40409.72"), submitOrders[0].Price) + assert.Equal(Number("0.014834"), submitOrders[0].Quantity) + assert.Equal(Number("36368.74"), submitOrders[1].Price) + assert.Equal(Number("0.013748"), submitOrders[1].Quantity) + }) + + t.Run("case 3: some orders' price will below 0, so we should not create such order", func(t *testing.T) { + }) + + t.Run("case 4: notional is too small, so we should decrease num of orders", func(t *testing.T) { + }) + + t.Run("case 5: quantity is too small, so we should decrease num of orders", func(t *testing.T) { + }) +} diff --git a/pkg/strategy/dca2/profit_stats.go b/pkg/strategy/dca2/profit_stats.go new file mode 100644 index 0000000..a0b25d9 --- /dev/null +++ b/pkg/strategy/dca2/profit_stats.go @@ -0,0 +1,96 @@ +package dca2 + +import ( + "fmt" + "strings" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type ProfitStats struct { + Symbol string `json:"symbol"` + Market types.Market `json:"market,omitempty"` + + FromOrderID uint64 `json:"fromOrderID,omitempty"` + Round int64 `json:"round,omitempty"` + QuoteInvestment fixedpoint.Value `json:"quoteInvestment,omitempty"` + + CurrentRoundProfit fixedpoint.Value `json:"currentRoundProfit,omitempty"` + CurrentRoundFee map[string]fixedpoint.Value `json:"currentRoundFee,omitempty"` + TotalProfit fixedpoint.Value `json:"totalProfit,omitempty"` + TotalFee map[string]fixedpoint.Value `json:"totalFee,omitempty"` + + // used to flexible recovery + OpenPositionPVs []types.PriceVolume `json:"openPositionPVs,omitempty"` + + types.PersistenceTTL +} + +func newProfitStats(market types.Market, quoteInvestment fixedpoint.Value) *ProfitStats { + return &ProfitStats{ + Symbol: market.Symbol, + Market: market, + Round: 1, + QuoteInvestment: quoteInvestment, + CurrentRoundFee: make(map[string]fixedpoint.Value), + TotalFee: make(map[string]fixedpoint.Value), + } +} + +func (s *ProfitStats) AddTrade(trade types.Trade) { + if s.CurrentRoundFee == nil { + s.CurrentRoundFee = make(map[string]fixedpoint.Value) + } + + if fee, ok := s.CurrentRoundFee[trade.FeeCurrency]; ok { + s.CurrentRoundFee[trade.FeeCurrency] = fee.Add(trade.Fee) + } else { + s.CurrentRoundFee[trade.FeeCurrency] = trade.Fee + } + + if s.TotalFee == nil { + s.TotalFee = make(map[string]fixedpoint.Value) + } + + if fee, ok := s.TotalFee[trade.FeeCurrency]; ok { + s.TotalFee[trade.FeeCurrency] = fee.Add(trade.Fee) + } else { + s.TotalFee[trade.FeeCurrency] = trade.Fee + } + + quoteQuantity := trade.QuoteQuantity + if trade.Side == types.SideTypeBuy { + quoteQuantity = quoteQuantity.Neg() + } + + s.CurrentRoundProfit = s.CurrentRoundProfit.Add(quoteQuantity) + s.TotalProfit = s.TotalProfit.Add(quoteQuantity) + + if s.Market.QuoteCurrency == trade.FeeCurrency { + s.CurrentRoundProfit = s.CurrentRoundProfit.Sub(trade.Fee) + s.TotalProfit = s.TotalProfit.Sub(trade.Fee) + } +} + +func (s *ProfitStats) NewRound() { + s.Round++ + s.CurrentRoundProfit = fixedpoint.Zero + s.CurrentRoundFee = make(map[string]fixedpoint.Value) +} + +func (s *ProfitStats) String() string { + var sb strings.Builder + sb.WriteString("[------------------ Profit Stats ------------------]\n") + sb.WriteString(fmt.Sprintf("Round: %d\n", s.Round)) + sb.WriteString(fmt.Sprintf("From Order ID: %d\n", s.FromOrderID)) + sb.WriteString(fmt.Sprintf("Quote Investment: %s\n", s.QuoteInvestment)) + sb.WriteString(fmt.Sprintf("Current Round Profit: %s\n", s.CurrentRoundProfit)) + sb.WriteString(fmt.Sprintf("Total Profit: %s\n", s.TotalProfit)) + for currency, fee := range s.CurrentRoundFee { + sb.WriteString(fmt.Sprintf("FEE (%s): %s\n", currency, fee)) + } + sb.WriteString("[------------------ Profit Stats ------------------]\n") + + return sb.String() +} diff --git a/pkg/strategy/dca2/recover.go b/pkg/strategy/dca2/recover.go new file mode 100644 index 0000000..f69ca9e --- /dev/null +++ b/pkg/strategy/dca2/recover.go @@ -0,0 +1,210 @@ +package dca2 + +import ( + "context" + "fmt" + "strconv" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/retry" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/pkg/errors" +) + +var recoverSinceLimit = time.Date(2024, time.January, 29, 12, 0, 0, 0, time.Local) + +type descendingClosedOrderQueryService interface { + QueryClosedOrdersDesc(ctx context.Context, symbol string, since, until time.Time, lastOrderID uint64) ([]types.Order, error) +} + +func (s *Strategy) recover(ctx context.Context) error { + s.logger.Info("[DCA] recover") + currentRound, err := s.collector.CollectCurrentRound(ctx) + debugRoundOrders(s.logger, "current", currentRound) + + // recover profit stats + if s.DisableProfitStatsRecover { + s.logger.Info("disableProfitStatsRecover is set, skip profit stats recovery") + } else { + if err := recoverProfitStats(ctx, s); err != nil { + return err + } + s.logger.Info("recover profit stats DONE") + } + + // recover position + if s.DisablePositionRecover { + s.logger.Info("disablePositionRecover is set, skip position recovery") + } else { + if err := recoverPosition(ctx, s.Position, currentRound, s.collector.queryService); err != nil { + return err + } + s.logger.Info("recover position DONE") + } + + // recover startTimeOfNextRound + startTimeOfNextRound := recoverStartTimeOfNextRound(ctx, currentRound, s.CoolDownInterval) + s.startTimeOfNextRound = startTimeOfNextRound + + // recover state + state, err := recoverState(ctx, int(s.MaxOrderCount), currentRound, s.OrderExecutor) + if err != nil { + return err + } + s.updateState(state) + s.logger.Info("recover stats DONE") + + return nil +} + +// recover state +func recoverState(ctx context.Context, maxOrderCount int, currentRound Round, orderExecutor *qbtrade.GeneralOrderExecutor) (State, error) { + activeOrderBook := orderExecutor.ActiveMakerOrders() + orderStore := orderExecutor.OrderStore() + + // dca stop at take-profit order stage + if len(currentRound.TakeProfitOrders) > 0 { + openedOrders, cancelledOrders, filledOrders, unexpectedOrders := classifyOrders(currentRound.TakeProfitOrders) + + if len(unexpectedOrders) > 0 { + return None, fmt.Errorf("there is unexpected status in orders %+v", unexpectedOrders) + } + + if len(filledOrders) > 0 && len(openedOrders) == 0 { + return WaitToOpenPosition, nil + } + + if len(filledOrders) == 0 && len(openedOrders) > 0 { + // add opened order into order store + for _, order := range openedOrders { + activeOrderBook.Add(order) + orderStore.Add(order) + } + return TakeProfitReady, nil + } + + return None, fmt.Errorf("the classify orders count is not expected (opened: %d, cancelled: %d, filled: %d)", len(openedOrders), len(cancelledOrders), len(filledOrders)) + } + + // dca stop at no take-profit order stage + openPositionOrders := currentRound.OpenPositionOrders + + // new strategy + if len(openPositionOrders) == 0 { + return WaitToOpenPosition, nil + } + + // collect open-position orders' status + openedOrders, cancelledOrders, filledOrders, unexpectedOrders := classifyOrders(currentRound.OpenPositionOrders) + if len(unexpectedOrders) > 0 { + return None, fmt.Errorf("there is unexpected status of orders %+v", unexpectedOrders) + } + for _, order := range openedOrders { + activeOrderBook.Add(order) + orderStore.Add(order) + } + + // no order is filled -> OpenPositionReady + if len(filledOrders) == 0 { + return OpenPositionReady, nil + } + + // there are at least one open-position orders filled + if len(cancelledOrders) == 0 { + if len(openedOrders) > 0 { + return OpenPositionOrderFilled, nil + } else { + // all open-position orders filled, change to cancelling and place the take-profit order + return OpenPositionOrdersCancelling, nil + } + } + + // there are at last one open-position orders cancelled and at least one filled order -> open position order cancelling + return OpenPositionOrdersCancelling, nil +} + +func recoverPosition(ctx context.Context, position *types.Position, currentRound Round, queryService types.ExchangeOrderQueryService) error { + if position == nil { + return fmt.Errorf("position is nil, please check it") + } + + // reset position to recover + position.Reset() + + var positionOrders []types.Order + + var filledCnt int64 + for _, order := range currentRound.TakeProfitOrders { + if !types.IsActiveOrder(order) { + filledCnt++ + } + positionOrders = append(positionOrders, order) + } + + // all take-profit orders are filled + if len(currentRound.TakeProfitOrders) > 0 && filledCnt == int64(len(currentRound.TakeProfitOrders)) { + return nil + } + + for _, order := range currentRound.OpenPositionOrders { + // no executed quantity order, no need to get trades + if order.ExecutedQuantity.IsZero() { + continue + } + + positionOrders = append(positionOrders, order) + } + + for _, positionOrder := range positionOrders { + trades, err := retry.QueryOrderTradesUntilSuccessful(ctx, queryService, types.OrderQuery{ + Symbol: position.Symbol, + OrderID: strconv.FormatUint(positionOrder.OrderID, 10), + }) + + if err != nil { + return errors.Wrapf(err, "failed to get order (%d) trades", positionOrder.OrderID) + } + position.AddTrades(trades) + } + + return nil +} + +func recoverProfitStats(ctx context.Context, strategy *Strategy) error { + if strategy.ProfitStats == nil { + return fmt.Errorf("profit stats is nil, please check it") + } + + _, err := strategy.UpdateProfitStats(ctx) + return err +} + +func recoverStartTimeOfNextRound(ctx context.Context, currentRound Round, coolDownInterval types.Duration) time.Time { + var startTimeOfNextRound time.Time + + for _, order := range currentRound.TakeProfitOrders { + if t := order.UpdateTime.Time().Add(coolDownInterval.Duration()); t.After(startTimeOfNextRound) { + startTimeOfNextRound = t + } + } + + return startTimeOfNextRound +} + +func classifyOrders(orders []types.Order) (opened, cancelled, filled, unexpected []types.Order) { + for _, order := range orders { + switch order.Status { + case types.OrderStatusNew, types.OrderStatusPartiallyFilled: + opened = append(opened, order) + case types.OrderStatusFilled: + filled = append(filled, order) + case types.OrderStatusCanceled: + cancelled = append(cancelled, order) + default: + unexpected = append(unexpected, order) + } + } + + return opened, cancelled, filled, unexpected +} diff --git a/pkg/strategy/dca2/recover_test.go b/pkg/strategy/dca2/recover_test.go new file mode 100644 index 0000000..1b59d15 --- /dev/null +++ b/pkg/strategy/dca2/recover_test.go @@ -0,0 +1,172 @@ +package dca2 + +import ( + "context" + "math/rand" + "testing" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +func generateTestOrder(side types.SideType, status types.OrderStatus, createdAt time.Time) types.Order { + return types.Order{ + OrderID: rand.Uint64(), + SubmitOrder: types.SubmitOrder{ + Side: side, + }, + Status: status, + CreationTime: types.Time(createdAt), + } + +} + +func Test_RecoverState(t *testing.T) { + strategy := newTestStrategy() + + t.Run("new strategy", func(t *testing.T) { + currentRound := Round{} + position := types.NewPositionFromMarket(strategy.Market) + orderExecutor := qbtrade.NewGeneralOrderExecutor(nil, strategy.Symbol, ID, "", position) + state, err := recoverState(context.Background(), 5, currentRound, orderExecutor) + assert.NoError(t, err) + assert.Equal(t, WaitToOpenPosition, state) + }) + + t.Run("at open position stage and no filled order", func(t *testing.T) { + now := time.Now() + currentRound := Round{ + OpenPositionOrders: []types.Order{ + generateTestOrder(types.SideTypeBuy, types.OrderStatusPartiallyFilled, now.Add(-1*time.Second)), + generateTestOrder(types.SideTypeBuy, types.OrderStatusNew, now.Add(-2*time.Second)), + generateTestOrder(types.SideTypeBuy, types.OrderStatusNew, now.Add(-3*time.Second)), + generateTestOrder(types.SideTypeBuy, types.OrderStatusNew, now.Add(-4*time.Second)), + generateTestOrder(types.SideTypeBuy, types.OrderStatusNew, now.Add(-5*time.Second)), + }, + } + position := types.NewPositionFromMarket(strategy.Market) + orderExecutor := qbtrade.NewGeneralOrderExecutor(nil, strategy.Symbol, ID, "", position) + state, err := recoverState(context.Background(), 5, currentRound, orderExecutor) + assert.NoError(t, err) + assert.Equal(t, OpenPositionReady, state) + }) + + t.Run("at open position stage and there at least one filled order", func(t *testing.T) { + now := time.Now() + currentRound := Round{ + OpenPositionOrders: []types.Order{ + generateTestOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-1*time.Second)), + generateTestOrder(types.SideTypeBuy, types.OrderStatusNew, now.Add(-2*time.Second)), + generateTestOrder(types.SideTypeBuy, types.OrderStatusNew, now.Add(-3*time.Second)), + generateTestOrder(types.SideTypeBuy, types.OrderStatusNew, now.Add(-4*time.Second)), + generateTestOrder(types.SideTypeBuy, types.OrderStatusNew, now.Add(-5*time.Second)), + }, + } + position := types.NewPositionFromMarket(strategy.Market) + orderExecutor := qbtrade.NewGeneralOrderExecutor(nil, strategy.Symbol, ID, "", position) + state, err := recoverState(context.Background(), 5, currentRound, orderExecutor) + assert.NoError(t, err) + assert.Equal(t, OpenPositionOrderFilled, state) + }) + + t.Run("open position stage finish, but stop at cancelling", func(t *testing.T) { + now := time.Now() + currentRound := Round{ + OpenPositionOrders: []types.Order{ + generateTestOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-1*time.Second)), + generateTestOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-2*time.Second)), + generateTestOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-3*time.Second)), + generateTestOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-4*time.Second)), + generateTestOrder(types.SideTypeBuy, types.OrderStatusNew, now.Add(-5*time.Second)), + }, + } + position := types.NewPositionFromMarket(strategy.Market) + orderExecutor := qbtrade.NewGeneralOrderExecutor(nil, strategy.Symbol, ID, "", position) + state, err := recoverState(context.Background(), 5, currentRound, orderExecutor) + assert.NoError(t, err) + assert.Equal(t, OpenPositionOrdersCancelling, state) + }) + + t.Run("open-position orders are cancelled", func(t *testing.T) { + now := time.Now() + currentRound := Round{ + OpenPositionOrders: []types.Order{ + generateTestOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-1*time.Second)), + generateTestOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-2*time.Second)), + generateTestOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-3*time.Second)), + generateTestOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-4*time.Second)), + generateTestOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-5*time.Second)), + }, + } + position := types.NewPositionFromMarket(strategy.Market) + orderExecutor := qbtrade.NewGeneralOrderExecutor(nil, strategy.Symbol, ID, "", position) + state, err := recoverState(context.Background(), 5, currentRound, orderExecutor) + assert.NoError(t, err) + assert.Equal(t, OpenPositionOrdersCancelling, state) + }) + + t.Run("at take profit stage, and not filled yet", func(t *testing.T) { + now := time.Now() + currentRound := Round{ + TakeProfitOrders: []types.Order{ + generateTestOrder(types.SideTypeSell, types.OrderStatusNew, now), + }, + OpenPositionOrders: []types.Order{ + generateTestOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-1*time.Second)), + generateTestOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-2*time.Second)), + generateTestOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-3*time.Second)), + generateTestOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-4*time.Second)), + generateTestOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-5*time.Second)), + }, + } + position := types.NewPositionFromMarket(strategy.Market) + orderExecutor := qbtrade.NewGeneralOrderExecutor(nil, strategy.Symbol, ID, "", position) + state, err := recoverState(context.Background(), 5, currentRound, orderExecutor) + assert.NoError(t, err) + assert.Equal(t, TakeProfitReady, state) + }) + + t.Run("at take profit stage, take-profit order filled", func(t *testing.T) { + now := time.Now() + currentRound := Round{ + TakeProfitOrders: []types.Order{ + generateTestOrder(types.SideTypeSell, types.OrderStatusFilled, now), + }, + OpenPositionOrders: []types.Order{ + generateTestOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-1*time.Second)), + generateTestOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-2*time.Second)), + generateTestOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-3*time.Second)), + generateTestOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-4*time.Second)), + generateTestOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-5*time.Second)), + }, + } + position := types.NewPositionFromMarket(strategy.Market) + orderExecutor := qbtrade.NewGeneralOrderExecutor(nil, strategy.Symbol, ID, "", position) + state, err := recoverState(context.Background(), 5, currentRound, orderExecutor) + assert.NoError(t, err) + assert.Equal(t, WaitToOpenPosition, state) + }) +} + +func Test_classifyOrders(t *testing.T) { + orders := []types.Order{ + types.Order{Status: types.OrderStatusCanceled}, + types.Order{Status: types.OrderStatusFilled}, + types.Order{Status: types.OrderStatusCanceled}, + types.Order{Status: types.OrderStatusFilled}, + types.Order{Status: types.OrderStatusPartiallyFilled}, + types.Order{Status: types.OrderStatusCanceled}, + types.Order{Status: types.OrderStatusPartiallyFilled}, + types.Order{Status: types.OrderStatusNew}, + types.Order{Status: types.OrderStatusRejected}, + types.Order{Status: types.OrderStatusCanceled}, + } + + opened, cancelled, filled, unexpected := classifyOrders(orders) + assert.Equal(t, 3, len(opened)) + assert.Equal(t, 4, len(cancelled)) + assert.Equal(t, 2, len(filled)) + assert.Equal(t, 1, len(unexpected)) +} diff --git a/pkg/strategy/dca2/state.go b/pkg/strategy/dca2/state.go new file mode 100644 index 0000000..78677be --- /dev/null +++ b/pkg/strategy/dca2/state.go @@ -0,0 +1,244 @@ +package dca2 + +import ( + "context" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" +) + +const ( + openPositionRetryInterval = 10 * time.Minute +) + +type State int64 + +const ( + None State = iota + WaitToOpenPosition + PositionOpening + OpenPositionReady + OpenPositionOrderFilled + OpenPositionOrdersCancelling + OpenPositionOrdersCancelled + TakeProfitReady +) + +var stateTransition map[State]State = map[State]State{ + WaitToOpenPosition: PositionOpening, + PositionOpening: OpenPositionReady, + OpenPositionReady: OpenPositionOrderFilled, + OpenPositionOrderFilled: OpenPositionOrdersCancelling, + OpenPositionOrdersCancelling: OpenPositionOrdersCancelled, + OpenPositionOrdersCancelled: TakeProfitReady, + TakeProfitReady: WaitToOpenPosition, +} + +func (s *Strategy) initializeNextStateC() bool { + s.mu.Lock() + defer s.mu.Unlock() + + isInitialize := false + if s.nextStateC == nil { + s.logger.Info("[DCA] initializing next state channel") + s.nextStateC = make(chan State, 1) + } else { + s.logger.Info("[DCA] nextStateC is already initialized") + isInitialize = true + } + + return isInitialize +} + +func (s *Strategy) updateState(state State) { + s.state = state + + s.logger.Infof("[state] update state to %d", state) + metricsState.With(baseLabels).Set(float64(s.state)) +} + +func (s *Strategy) emitNextState(nextState State) { + select { + case s.nextStateC <- nextState: + default: + s.logger.Info("[DCA] nextStateC is full or not initialized") + } +} + +// runState +// WaitToOpenPosition -> after startTimeOfNextRound, place dca orders -> +// PositionOpening +// OpenPositionReady -> any dca maker order filled -> +// OpenPositionOrderFilled -> price hit the take profit ration, start cancelling -> +// OpenPositionOrdersCancelled -> place the takeProfit order -> +// TakeProfitReady -> the takeProfit order filled -> +func (s *Strategy) runState(ctx context.Context) { + s.logger.Info("[DCA] runState") + stateTriggerTicker := time.NewTicker(1 * time.Minute) + defer stateTriggerTicker.Stop() + + for { + select { + case <-ctx.Done(): + s.logger.Info("[DCA] runState DONE") + return + case <-stateTriggerTicker.C: + // move triggerNextState to the end of next state handler, this ticker is used to avoid the state is stopped unexpectedly + s.triggerNextState() + case nextState := <-s.nextStateC: + // next state == current state -> skip + if nextState == s.state { + continue + } + + // check the next state is valid + validNextState, exist := stateTransition[s.state] + if !exist { + s.logger.Warnf("[DCA] %d not in stateTransition", s.state) + continue + } + + if nextState != validNextState { + s.logger.Warnf("[DCA] %d is not valid next state of curreny state %d", nextState, s.state) + continue + } + + // move to next state + if triggerImmediately := s.moveToNextState(ctx, nextState); triggerImmediately { + s.triggerNextState() + } + } + } +} + +func (s *Strategy) triggerNextState() { + switch s.state { + case OpenPositionReady: + // only trigger from order filled event + case OpenPositionOrderFilled: + // only trigger from kline event + case TakeProfitReady: + // only trigger from order filled event + default: + if nextState, ok := stateTransition[s.state]; ok { + s.emitNextState(nextState) + } + } +} + +// moveToNextState will run the process when moving current state to next state +// it will return true if we want it trigger the next state immediately +func (s *Strategy) moveToNextState(ctx context.Context, nextState State) bool { + switch s.state { + case WaitToOpenPosition: + return s.runWaitToOpenPositionState(ctx, nextState) + case PositionOpening: + return s.runPositionOpening(ctx, nextState) + case OpenPositionReady: + return s.runOpenPositionReady(ctx, nextState) + case OpenPositionOrderFilled: + return s.runOpenPositionOrderFilled(ctx, nextState) + case OpenPositionOrdersCancelling: + return s.runOpenPositionOrdersCancelling(ctx, nextState) + case OpenPositionOrdersCancelled: + return s.runOpenPositionOrdersCancelled(ctx, nextState) + case TakeProfitReady: + return s.runTakeProfitReady(ctx, nextState) + } + + s.logger.Errorf("unexpected state: %d, please check it", s.state) + return false +} + +func (s *Strategy) runWaitToOpenPositionState(ctx context.Context, next State) bool { + if s.nextRoundPaused { + s.logger.Info("[State] WaitToOpenPosition - nextRoundPaused is set") + return false + } + + if time.Now().Before(s.startTimeOfNextRound) { + return false + } + + s.updateState(PositionOpening) + s.logger.Info("[State] WaitToOpenPosition -> PositionOpening") + return true +} + +func (s *Strategy) runPositionOpening(ctx context.Context, next State) bool { + s.logger.Info("[State] PositionOpening - start placing open-position orders") + + if err := s.placeOpenPositionOrders(ctx); err != nil { + s.logger.WithError(err).Error("failed to place open-position orders, please check it.") + return false + } + + s.updateState(OpenPositionReady) + s.logger.Info("[State] PositionOpening -> OpenPositionReady") + // do not trigger next state immediately, because OpenPositionReady state only trigger by kline to move to the next state + return false +} + +func (s *Strategy) runOpenPositionReady(_ context.Context, next State) bool { + s.updateState(OpenPositionOrderFilled) + s.logger.Info("[State] OpenPositionReady -> OpenPositionOrderFilled") + // do not trigger next state immediately, because OpenPositionOrderFilled state only trigger by kline to move to the next state + return false +} + +func (s *Strategy) runOpenPositionOrderFilled(_ context.Context, next State) bool { + s.updateState(OpenPositionOrdersCancelling) + s.logger.Info("[State] OpenPositionOrderFilled -> OpenPositionOrdersCancelling") + return true +} + +func (s *Strategy) runOpenPositionOrdersCancelling(ctx context.Context, next State) bool { + s.logger.Info("[State] OpenPositionOrdersCancelling - start cancelling open-position orders") + if err := s.OrderExecutor.GracefulCancel(ctx); err != nil { + s.logger.WithError(err).Error("failed to cancel maker orders") + return false + } + s.updateState(OpenPositionOrdersCancelled) + s.logger.Info("[State] OpenPositionOrdersCancelling -> OpenPositionOrdersCancelled") + return true +} + +func (s *Strategy) runOpenPositionOrdersCancelled(ctx context.Context, next State) bool { + s.logger.Info("[State] OpenPositionOrdersCancelled - start placing take-profit orders") + if err := s.placeTakeProfitOrders(ctx); err != nil { + s.logger.WithError(err).Error("failed to open take profit orders") + return false + } + s.updateState(TakeProfitReady) + s.logger.Info("[State] OpenPositionOrdersCancelled -> TakeProfitReady") + // do not trigger next state immediately, because TakeProfitReady state only trigger by kline to move to the next state + return false +} + +func (s *Strategy) runTakeProfitReady(ctx context.Context, next State) bool { + // wait 3 seconds to avoid position not update + time.Sleep(3 * time.Second) + + s.logger.Info("[State] TakeProfitReady - start reseting position and calculate quote investment for next round") + + // update profit stats + if err := s.UpdateProfitStatsUntilSuccessful(ctx); err != nil { + s.logger.WithError(err).Warn("failed to calculate and emit profit") + } + + // reset position and open new round for profit stats before position opening + s.Position.Reset() + + // emit position + s.OrderExecutor.TradeCollector().EmitPositionUpdate(s.Position) + + // store into redis + qbtrade.Sync(ctx, s) + + // set the start time of the next round + s.startTimeOfNextRound = time.Now().Add(s.CoolDownInterval.Duration()) + s.updateState(WaitToOpenPosition) + s.logger.Infof("[State] TakeProfitReady -> WaitToOpenPosition (startTimeOfNextRound: %s)", s.startTimeOfNextRound.String()) + + return false +} diff --git a/pkg/strategy/dca2/strategy.go b/pkg/strategy/dca2/strategy.go new file mode 100644 index 0000000..9fbf7af --- /dev/null +++ b/pkg/strategy/dca2/strategy.go @@ -0,0 +1,509 @@ +package dca2 + +import ( + "context" + "fmt" + "math" + "sync" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/pkg/errors" + "github.com/prometheus/client_golang/prometheus" + "github.com/sirupsen/logrus" + "go.uber.org/multierr" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/retry" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/common" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util/tradingutil" +) + +const ( + ID = "dca2" + orderTag = "dca2" +) + +var ( + log = logrus.WithField("strategy", ID) + baseLabels prometheus.Labels +) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type advancedOrderCancelApi interface { + CancelAllOrders(ctx context.Context) ([]types.Order, error) + CancelOrdersBySymbol(ctx context.Context, symbol string) ([]types.Order, error) + CancelOrdersByGroupID(ctx context.Context, groupID uint32) ([]types.Order, error) +} + +//go:generate callbackgen -type Strateg +type Strategy struct { + Position *types.Position `json:"position,omitempty" persistence:"position"` + ProfitStats *ProfitStats `json:"profitStats,omitempty" persistence:"profit_stats"` + PersistenceTTL types.Duration `json:"persistenceTTL"` + + Environment *qbtrade.Environment + ExchangeSession *qbtrade.ExchangeSession + OrderExecutor *qbtrade.GeneralOrderExecutor + Market types.Market + + Symbol string `json:"symbol"` + + // setting + QuoteInvestment fixedpoint.Value `json:"quoteInvestment"` + MaxOrderCount int64 `json:"maxOrderCount"` + PriceDeviation fixedpoint.Value `json:"priceDeviation"` + TakeProfitRatio fixedpoint.Value `json:"takeProfitRatio"` + CoolDownInterval types.Duration `json:"coolDownInterval"` + + // OrderGroupID is the group ID used for the strategy instance for canceling orders + OrderGroupID uint32 `json:"orderGroupID"` + DisableOrderGroupIDFilter bool `json:"disableOrderGroupIDFilter"` + + // RecoverWhenStart option is used for recovering dca states + RecoverWhenStart bool `json:"recoverWhenStart"` + DisableProfitStatsRecover bool `json:"disableProfitStatsRecover"` + DisablePositionRecover bool `json:"disablePositionRecover"` + + // EnableQuoteInvestmentReallocate set to true, the quote investment will be reallocated when the notional or quantity is under minimum. + EnableQuoteInvestmentReallocate bool `json:"enableQuoteInvestmentReallocate"` + + // KeepOrdersWhenShutdown option is used for keeping the grid orders when shutting down qbtrade + KeepOrdersWhenShutdown bool `json:"keepOrdersWhenShutdown"` + + // UniversalCancelAllOrdersWhenClose close all orders even though the orders don't belong to this strategy + UniversalCancelAllOrdersWhenClose bool `json:"universalCancelAllOrdersWhenClose"` + + // log + logger *logrus.Entry + LogFields logrus.Fields `json:"logFields"` + + // PrometheusLabels will be used as the base prometheus labels + PrometheusLabels prometheus.Labels `json:"prometheusLabels"` + + // private field + mu sync.Mutex + nextStateC chan State + state State + collector *Collector + takeProfitPrice fixedpoint.Value + startTimeOfNextRound time.Time + nextRoundPaused bool + + // callbacks + common.StatusCallbacks + profitCallbacks []func(*ProfitStats) + positionUpdateCallbacks []func(*types.Position) +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) Validate() error { + if s.MaxOrderCount < 1 { + return fmt.Errorf("maxOrderCount can not be < 1") + } + + if s.TakeProfitRatio.Sign() <= 0 { + return fmt.Errorf("takeProfitSpread can not be <= 0") + } + + if s.PriceDeviation.Sign() <= 0 { + return fmt.Errorf("margin can not be <= 0") + } + + // TODO: validate balance is enough + return nil +} + +func (s *Strategy) Defaults() error { + if s.LogFields == nil { + s.LogFields = logrus.Fields{} + } + + s.LogFields["symbol"] = s.Symbol + s.LogFields["strategy"] = ID + + return nil +} + +func (s *Strategy) Initialize() error { + s.logger = log.WithFields(s.LogFields) + return nil +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s-%s", ID, s.Symbol) +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: types.Interval1m}) +} + +func (s *Strategy) newPrometheusLabels() prometheus.Labels { + labels := prometheus.Labels{ + "exchange": "default", + "symbol": s.Symbol, + } + + if s.ExchangeSession != nil { + labels["exchange"] = s.ExchangeSession.Name + } + + if s.PrometheusLabels == nil { + return labels + } + + return mergeLabels(s.PrometheusLabels, labels) +} + +func (s *Strategy) Run(ctx context.Context, _ qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + instanceID := s.InstanceID() + s.ExchangeSession = session + + s.logger.Infof("persistence ttl: %s", s.PersistenceTTL.Duration()) + if s.ProfitStats == nil { + s.ProfitStats = newProfitStats(s.Market, s.QuoteInvestment) + } + + if s.Position == nil { + s.Position = types.NewPositionFromMarket(s.Market) + } + + // set ttl for persistence + s.Position.SetTTL(s.PersistenceTTL.Duration()) + s.ProfitStats.SetTTL(s.PersistenceTTL.Duration()) + + if s.OrderGroupID == 0 { + s.OrderGroupID = util.FNV32(instanceID) % math.MaxInt32 + } + + // collector + s.collector = NewCollector(s.logger, s.Symbol, s.OrderGroupID, !s.DisableOrderGroupIDFilter, s.ExchangeSession.Exchange) + if s.collector == nil { + return fmt.Errorf("failed to initialize collector") + } + + // prometheus + if s.PrometheusLabels != nil { + initMetrics(labelKeys(s.PrometheusLabels)) + } + registerMetrics() + + // prometheus labels + baseLabels = s.newPrometheusLabels() + + s.Position.Strategy = ID + s.Position.StrategyInstanceID = instanceID + + if session.MakerFeeRate.Sign() > 0 || session.TakerFeeRate.Sign() > 0 { + s.Position.SetExchangeFeeRate(session.ExchangeName, types.ExchangeFee{ + MakerFeeRate: session.MakerFeeRate, + TakerFeeRate: session.TakerFeeRate, + }) + } + + s.OrderExecutor = qbtrade.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, s.Position) + s.OrderExecutor.SetMaxRetries(50) + s.OrderExecutor.BindEnvironment(s.Environment) + s.OrderExecutor.Bind() + + // order executor + s.OrderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { + s.logger.Infof("POSITION UPDATE: %s", s.Position.String()) + qbtrade.Sync(ctx, s) + + // update take profit price here + s.updateTakeProfitPrice() + + // emit position update + s.EmitPositionUpdate(position) + }) + + s.OrderExecutor.ActiveMakerOrders().OnFilled(func(o types.Order) { + s.logger.Infof("FILLED ORDER: %s", o.String()) + openPositionSide := types.SideTypeBuy + takeProfitSide := types.SideTypeSell + + switch o.Side { + case openPositionSide: + s.emitNextState(OpenPositionOrderFilled) + case takeProfitSide: + s.emitNextState(WaitToOpenPosition) + default: + s.logger.Infof("unsupported side (%s) of order: %s", o.Side, o) + } + + openOrders, err := retry.QueryOpenOrdersUntilSuccessful(ctx, s.ExchangeSession.Exchange, s.Symbol) + if err != nil { + s.logger.WithError(err).Warn("failed to query open orders when order filled") + } else { + // update open orders metrics + metricsNumOfOpenOrders.With(baseLabels).Set(float64(len(openOrders))) + } + + // update active orders metrics + numActiveMakerOrders := s.OrderExecutor.ActiveMakerOrders().NumOfOrders() + metricsNumOfActiveOrders.With(baseLabels).Set(float64(numActiveMakerOrders)) + + if len(openOrders) != numActiveMakerOrders { + s.logger.Warnf("num of open orders (%d) and active orders (%d) is different when order filled, please check it.", len(openOrders), numActiveMakerOrders) + } + + if err == nil && o.Side == openPositionSide && numActiveMakerOrders == 0 && len(openOrders) == 0 { + s.emitNextState(OpenPositionOrdersCancelling) + } + }) + + session.MarketDataStream.OnKLine(func(kline types.KLine) { + switch s.state { + case OpenPositionOrderFilled: + if s.takeProfitPrice.IsZero() { + s.logger.Warn("take profit price should not be 0 when there is at least one open-position order filled, please check it") + return + } + + compRes := kline.Close.Compare(s.takeProfitPrice) + // price doesn't hit the take profit price + if compRes < 0 { + return + } + + s.emitNextState(OpenPositionOrdersCancelling) + default: + return + } + }) + + session.UserDataStream.OnAuth(func() { + s.logger.Info("user data stream authenticated") + time.AfterFunc(3*time.Second, func() { + if isInitialize := s.initializeNextStateC(); !isInitialize { + + // no need to recover when two situation + // 1. recoverWhenStart is false + // 2. dev mode is on and it's not new strategy + if !s.RecoverWhenStart { + s.updateState(WaitToOpenPosition) + } else { + // recover + maxTry := 3 + for try := 1; try <= maxTry; try++ { + s.logger.Infof("try #%d recover", try) + + err := s.recover(ctx) + if err == nil { + s.logger.Infof("recover successfully at #%d", try) + break + } + + s.logger.WithError(err).Warnf("failed to recover at #%d", try) + + if try == 3 { + s.logger.Errorf("failed to recover after %d trying, please check it", maxTry) + return + } + + // sleep 10 second to retry the recovery + time.Sleep(10 * time.Second) + } + } + + s.logger.Infof("state: %d", s.state) + s.logger.Infof("position %s", s.Position.String()) + s.logger.Infof("profit stats %s", s.ProfitStats.String()) + s.logger.Infof("startTimeOfNextRound %s", s.startTimeOfNextRound) + + // emit position after recovery + s.OrderExecutor.TradeCollector().EmitPositionUpdate(s.Position) + + s.updateTakeProfitPrice() + + // store persistence + qbtrade.Sync(ctx, s) + + // ready + s.EmitReady() + + // start to sync periodically + go s.syncPeriodically(ctx) + + // try to trigger position opening immediately + if s.state == WaitToOpenPosition { + s.emitNextState(PositionOpening) + } + + // start running state machine + s.runState(ctx) + } + }) + }) + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + if s.KeepOrdersWhenShutdown { + s.logger.Infof("keepOrdersWhenShutdown is set, will keep the orders on the exchange") + return + } + + if err := s.Close(ctx); err != nil { + s.logger.WithError(err).Errorf("dca2 graceful order cancel error") + } + }) + + return nil +} + +func (s *Strategy) updateTakeProfitPrice() { + takeProfitRatio := s.TakeProfitRatio + s.takeProfitPrice = s.Market.TruncatePrice(s.Position.AverageCost.Mul(fixedpoint.One.Add(takeProfitRatio))) + s.logger.Infof("cost: %s, ratio: %s, price: %s", s.Position.AverageCost.String(), takeProfitRatio.String(), s.takeProfitPrice.String()) +} + +func (s *Strategy) Close(ctx context.Context) error { + s.logger.Infof("closing %s dca2", s.Symbol) + + defer s.EmitClosed() + + var err error + if s.UniversalCancelAllOrdersWhenClose { + err = tradingutil.UniversalCancelAllOrders(ctx, s.ExchangeSession.Exchange, nil) + } else { + err = s.OrderExecutor.GracefulCancel(ctx) + } + + if err != nil { + s.logger.WithError(err).Errorf("there are errors when cancelling orders when closing (UniversalCancelAllOrdersWhenClose = %t)", s.UniversalCancelAllOrdersWhenClose) + } + + qbtrade.Sync(ctx, s) + return err +} + +func (s *Strategy) CleanUp(ctx context.Context) error { + _ = s.Initialize() + defer s.EmitClosed() + + session := s.ExchangeSession + if session == nil { + return fmt.Errorf("Session is nil, please check it") + } + + // ignore the first cancel error, this skips one open-orders query request + if err := tradingutil.UniversalCancelAllOrders(ctx, session.Exchange, nil); err == nil { + return nil + } + + // if cancel all orders returns error, get the open orders and retry the cancel in each round + var werr error + for { + s.logger.Infof("checking %s open orders...", s.Symbol) + + openOrders, err := retry.QueryOpenOrdersUntilSuccessful(ctx, session.Exchange, s.Symbol) + if err != nil { + s.logger.WithError(err).Errorf("unable to query open orders") + continue + } + + // all clean up + if len(openOrders) == 0 { + break + } + + if err := tradingutil.UniversalCancelAllOrders(ctx, session.Exchange, openOrders); err != nil { + s.logger.WithError(err).Errorf("unable to cancel all orders") + werr = multierr.Append(werr, err) + } + + time.Sleep(1 * time.Second) + } + + return werr +} + +// PauseNextRound will stop openning open-position orders at the next round +func (s *Strategy) PauseNextRound() { + s.nextRoundPaused = true +} + +func (s *Strategy) ContinueNextRound() { + s.nextRoundPaused = false +} + +func (s *Strategy) UpdateProfitStatsUntilSuccessful(ctx context.Context) error { + var op = func() error { + if updated, err := s.UpdateProfitStats(ctx); err != nil { + return errors.Wrapf(err, "failed to update profit stats, please check it") + } else if !updated { + return fmt.Errorf("there is no round to update profit stats, please check it") + } + + return nil + } + + // exponential increased interval retry until success + bo := backoff.NewExponentialBackOff() + bo.InitialInterval = 5 * time.Second + bo.MaxInterval = 20 * time.Minute + bo.MaxElapsedTime = 0 + + return backoff.Retry(op, backoff.WithContext(bo, ctx)) +} + +// UpdateProfitStats will collect round from closed orders and emit update profit stats +// return true, nil -> there is at least one finished round and all the finished rounds we collect update profit stats successfully +// return false, nil -> there is no finished round! +// return true, error -> At least one round update profit stats successfully but there is error when collecting other rounds +func (s *Strategy) UpdateProfitStats(ctx context.Context) (bool, error) { + s.logger.Info("update profit stats") + rounds, err := s.collector.CollectFinishRounds(ctx, s.ProfitStats.FromOrderID) + if err != nil { + return false, errors.Wrapf(err, "failed to collect finish rounds from #%d", s.ProfitStats.FromOrderID) + } + + var updated bool = false + for _, round := range rounds { + trades, err := s.collector.CollectRoundTrades(ctx, round) + if err != nil { + return updated, errors.Wrapf(err, "failed to collect the trades of round") + } + + for _, trade := range trades { + s.logger.Infof("update profit stats from trade: %s", trade.String()) + s.ProfitStats.AddTrade(trade) + } + + // update profit stats FromOrderID to make sure we will not collect duplicated rounds + for _, order := range round.TakeProfitOrders { + if order.OrderID >= s.ProfitStats.FromOrderID { + s.ProfitStats.FromOrderID = order.OrderID + 1 + } + } + + // update quote investment + s.ProfitStats.QuoteInvestment = s.ProfitStats.QuoteInvestment.Add(s.ProfitStats.CurrentRoundProfit) + + // sync to persistence + qbtrade.Sync(ctx, s) + updated = true + + s.logger.Infof("profit stats:\n%s", s.ProfitStats.String()) + + // emit profit + s.EmitProfit(s.ProfitStats) + updateProfitMetrics(s.ProfitStats.Round, s.ProfitStats.CurrentRoundProfit.Float64()) + + // make profit stats forward to new round + s.ProfitStats.NewRound() + } + + return updated, nil +} diff --git a/pkg/strategy/dca2/strategy_callbacks.go b/pkg/strategy/dca2/strategy_callbacks.go new file mode 100644 index 0000000..ee65430 --- /dev/null +++ b/pkg/strategy/dca2/strategy_callbacks.go @@ -0,0 +1,27 @@ +// Code generated by "callbackgen -type Strategy"; DO NOT EDIT. + +package dca2 + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func (s *Strategy) OnProfit(cb func(*ProfitStats)) { + s.profitCallbacks = append(s.profitCallbacks, cb) +} + +func (s *Strategy) EmitProfit(profitStats *ProfitStats) { + for _, cb := range s.profitCallbacks { + cb(profitStats) + } +} + +func (s *Strategy) OnPositionUpdate(cb func(*types.Position)) { + s.positionUpdateCallbacks = append(s.positionUpdateCallbacks, cb) +} + +func (s *Strategy) EmitPositionUpdate(position *types.Position) { + for _, cb := range s.positionUpdateCallbacks { + cb(position) + } +} diff --git a/pkg/strategy/dca2/sync.go b/pkg/strategy/dca2/sync.go new file mode 100644 index 0000000..54688f0 --- /dev/null +++ b/pkg/strategy/dca2/sync.go @@ -0,0 +1,71 @@ +package dca2 + +import ( + "context" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/retry" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/common" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +func (s *Strategy) syncPeriodically(ctx context.Context) { + s.logger.Info("sync periodically") + + // sync persistence + syncPersistenceTicker := time.NewTicker(1 * time.Hour) + defer syncPersistenceTicker.Stop() + + // sync active orders + syncActiveOrdersTicker := time.NewTicker(util.MillisecondsJitter(10*time.Minute, 5*60*1000)) + defer syncActiveOrdersTicker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-syncPersistenceTicker.C: + qbtrade.Sync(ctx, s) + case <-syncActiveOrdersTicker.C: + if err := s.syncActiveOrders(ctx); err != nil { + s.logger.WithError(err).Warn(err, "failed to sync active orders") + } + } + } +} + +func (s *Strategy) syncActiveOrders(ctx context.Context) error { + s.logger.Info("recover active orders...") + openOrders, err := retry.QueryOpenOrdersUntilSuccessfulLite(ctx, s.ExchangeSession.Exchange, s.Symbol) + if err != nil { + s.logger.WithError(err).Warn("failed to query open orders") + return err + } + + activeOrders := s.OrderExecutor.ActiveMakerOrders() + + // update num of open orders metrics + if metricsNumOfOpenOrders != nil { + metricsNumOfOpenOrders.With(baseLabels).Set(float64(len(openOrders))) + } + + // update num of active orders metrics + if metricsNumOfActiveOrders != nil { + metricsNumOfActiveOrders.With(baseLabels).Set(float64(activeOrders.NumOfOrders())) + } + + if len(openOrders) != activeOrders.NumOfOrders() { + s.logger.Warnf("num of open orders (%d) and active orders (%d) is different before active orders recovery, please check it.", len(openOrders), activeOrders.NumOfOrders()) + } + + opts := common.SyncActiveOrdersOpts{ + Logger: s.logger, + Exchange: s.ExchangeSession.Exchange, + OrderQueryService: s.collector.queryService, + ActiveOrderBook: activeOrders, + OpenOrders: openOrders, + } + + return common.SyncActiveOrders(ctx, opts) +} diff --git a/pkg/strategy/dca2/take_profit.go b/pkg/strategy/dca2/take_profit.go new file mode 100644 index 0000000..2a2596b --- /dev/null +++ b/pkg/strategy/dca2/take_profit.go @@ -0,0 +1,90 @@ +package dca2 + +import ( + "context" + "fmt" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/retry" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/pkg/errors" +) + +func (s *Strategy) placeTakeProfitOrders(ctx context.Context) error { + s.logger.Info("start placing take profit orders") + currentRound, err := s.collector.CollectCurrentRound(ctx) + if err != nil { + return errors.Wrap(err, "failed to place the take-profit order when collecting current round") + } + + if len(currentRound.TakeProfitOrders) > 0 { + return fmt.Errorf("there is a take-profit order before placing the take-profit order, please check it and manually fix it") + } + + trades, err := s.collector.CollectRoundTrades(ctx, currentRound) + if err != nil { + return errors.Wrap(err, "failed to place the take-profit order when collecting round trades") + } + + roundPosition := types.NewPositionFromMarket(s.Market) + + for _, trade := range trades { + s.logger.Infof("add trade into the position of this round %s", trade.String()) + if trade.FeeProcessing { + return fmt.Errorf("failed to place the take-profit order because there is a trade's fee not ready") + } + + roundPosition.AddTrade(trade) + } + + s.logger.Infof("position of this round before place the take-profit order: %s", roundPosition.String()) + + order := generateTakeProfitOrder(s.Market, s.TakeProfitRatio, roundPosition, s.OrderGroupID) + + // verify the volume of order + bals, err := retry.QueryAccountBalancesUntilSuccessfulLite(ctx, s.ExchangeSession.Exchange) + if err != nil { + return errors.Wrapf(err, "failed to query balance to verify") + } + + bal, exist := bals[s.Market.BaseCurrency] + if !exist { + return fmt.Errorf("there is no %s in the balances %+v", s.Market.BaseCurrency, bals) + } + + quantityDiff := bal.Available.Sub(order.Quantity) + if quantityDiff.Sign() < 0 { + return fmt.Errorf("the balance (%s) is not enough for the order (%s)", bal.String(), order.Quantity.String()) + } + + if quantityDiff.Compare(s.Market.MinQuantity) > 0 { + s.logger.Warnf("the diff between balance (%s) and the take-profit order (%s) is larger than min quantity %s", bal.String(), order.Quantity.String(), s.Market.MinQuantity.String()) + } + + createdOrders, err := s.OrderExecutor.SubmitOrders(ctx, order) + if err != nil { + return err + } + + for _, createdOrder := range createdOrders { + s.logger.Info("SUBMIT TAKE PROFIT ORDER ", createdOrder.String()) + } + + return nil +} + +func generateTakeProfitOrder(market types.Market, takeProfitRatio fixedpoint.Value, position *types.Position, orderGroupID uint32) types.SubmitOrder { + side := types.SideTypeSell + takeProfitPrice := market.TruncatePrice(position.AverageCost.Mul(fixedpoint.One.Add(takeProfitRatio))) + return types.SubmitOrder{ + Symbol: market.Symbol, + Market: market, + Type: types.OrderTypeLimit, + Price: takeProfitPrice, + Side: side, + TimeInForce: types.TimeInForceGTC, + Quantity: position.GetBase().Abs(), + Tag: orderTag, + GroupID: orderGroupID, + } +} diff --git a/pkg/strategy/dca2/take_profit_test.go b/pkg/strategy/dca2/take_profit_test.go new file mode 100644 index 0000000..6a21ae5 --- /dev/null +++ b/pkg/strategy/dca2/take_profit_test.go @@ -0,0 +1,47 @@ +package dca2 + +import ( + "testing" + + . "git.qtrade.icu/lychiyu/qbtrade/pkg/testing/testhelper" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +func TestGenerateTakeProfitOrder(t *testing.T) { + assert := assert.New(t) + + strategy := newTestStrategy() + + position := types.NewPositionFromMarket(strategy.Market) + position.AddTrade(types.Trade{ + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + Price: Number("28500"), + Quantity: Number("1"), + QuoteQuantity: Number("28500"), + Fee: Number("0.0015"), + FeeCurrency: strategy.Market.BaseCurrency, + }) + + o := generateTakeProfitOrder(strategy.Market, strategy.TakeProfitRatio, position, strategy.OrderGroupID) + assert.Equal(Number("31397.09"), o.Price) + assert.Equal(Number("0.9985"), o.Quantity) + assert.Equal(types.SideTypeSell, o.Side) + assert.Equal(strategy.Symbol, o.Symbol) + + position.AddTrade(types.Trade{ + Side: types.SideTypeBuy, + Price: Number("27000"), + Quantity: Number("0.5"), + QuoteQuantity: Number("13500"), + Fee: Number("0.00075"), + FeeCurrency: strategy.Market.BaseCurrency, + }) + o = generateTakeProfitOrder(strategy.Market, strategy.TakeProfitRatio, position, strategy.OrderGroupID) + assert.Equal(Number("30846.26"), o.Price) + assert.Equal(Number("1.49775"), o.Quantity) + assert.Equal(types.SideTypeSell, o.Side) + assert.Equal(strategy.Symbol, o.Symbol) + +} diff --git a/pkg/strategy/deposit2transfer/strategy.go b/pkg/strategy/deposit2transfer/strategy.go new file mode 100644 index 0000000..b9b2f00 --- /dev/null +++ b/pkg/strategy/deposit2transfer/strategy.go @@ -0,0 +1,284 @@ +package deposit2transfer + +import ( + "context" + "errors" + "fmt" + "sort" + "sync" + "time" + + "github.com/sirupsen/logrus" + "golang.org/x/time/rate" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/retry" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type marginTransferService interface { + TransferMarginAccountAsset(ctx context.Context, asset string, amount fixedpoint.Value, io types.TransferDirection) error +} + +type spotAccountQueryService interface { + QuerySpotAccount(ctx context.Context) (*types.Account, error) +} + +const ID = "deposit2transfer" + +var log = logrus.WithField("strategy", ID) + +var errMarginTransferNotSupport = errors.New("exchange session does not support margin transfer") + +var errDepositHistoryNotSupport = errors.New("exchange session does not support deposit history query") + +func init() { + // Register the pointer of the strategy struct, + // so that qbtrade knows what struct to be used to unmarshal the configs (YAML or JSON) + // Note: built-in strategies need to imported manually in the qbtrade cmd package. + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + Environment *qbtrade.Environment + + Assets []string `json:"assets"` + + Interval types.Duration `json:"interval"` + + marginTransferService marginTransferService + depositHistoryService types.ExchangeTransferService + + session *qbtrade.ExchangeSession + watchingDeposits map[string]types.Deposit + mu sync.Mutex + + logger logrus.FieldLogger + + lastAssetDepositTimes map[string]time.Time +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) {} + +func (s *Strategy) Defaults() error { + if s.Interval == 0 { + s.Interval = types.Duration(5 * time.Minute) + } + + if s.logger == nil { + s.logger = log.Dup() + } + + return nil +} + +func (s *Strategy) Validate() error { + return nil +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s-%s", ID, s.Assets) +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + s.session = session + s.watchingDeposits = make(map[string]types.Deposit) + s.lastAssetDepositTimes = make(map[string]time.Time) + s.logger = s.logger.WithField("exchange", session.ExchangeName) + + var ok bool + + s.marginTransferService, ok = session.Exchange.(marginTransferService) + if !ok { + return errMarginTransferNotSupport + } + + s.depositHistoryService, ok = session.Exchange.(types.ExchangeTransferService) + if !ok { + return errDepositHistoryNotSupport + } + + session.UserDataStream.OnStart(func() { + go s.tickWatcher(ctx, s.Interval.Duration()) + }) + + return nil +} + +func (s *Strategy) tickWatcher(ctx context.Context, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + s.checkDeposits(ctx) + + for { + select { + case <-ctx.Done(): + return + + case <-ticker.C: + s.checkDeposits(ctx) + } + } +} + +func (s *Strategy) checkDeposits(ctx context.Context) { + accountLimiter := rate.NewLimiter(rate.Every(3*time.Second), 1) + + for _, asset := range s.Assets { + logger := s.logger.WithField("asset", asset) + + logger.Debugf("checking %s deposits...", asset) + + succeededDeposits, err := s.scanDepositHistory(ctx, asset, 4*time.Hour) + if err != nil { + logger.WithError(err).Errorf("unable to scan deposit history") + return + } + + if len(succeededDeposits) == 0 { + logger.Debugf("no %s deposit found", asset) + continue + } + + for _, d := range succeededDeposits { + logger.Infof("found succeeded %s deposit: %+v", asset, d) + + if err2 := accountLimiter.Wait(ctx); err2 != nil { + logger.WithError(err2).Errorf("rate limiter error") + return + } + + // we can't use the account from margin + amount := d.Amount + if service, ok := s.session.Exchange.(spotAccountQueryService); ok { + var account *types.Account + err = retry.GeneralBackoff(ctx, func() (err error) { + account, err = service.QuerySpotAccount(ctx) + return err + }) + + if err != nil || account == nil { + logger.WithError(err).Errorf("unable to query spot account") + continue + } + + if bal, ok := account.Balance(d.Asset); ok { + logger.Infof("spot account balance %s: %+v", d.Asset, bal) + amount = fixedpoint.Min(bal.Available, amount) + } else { + logger.Errorf("unexpected error: %s balance not found", d.Asset) + } + } + + qbtrade.Notify("Found succeeded deposit %s %s, transferring %s %s into the margin account", + d.Amount.String(), d.Asset, + amount.String(), d.Asset) + + err2 := retry.GeneralBackoff(ctx, func() error { + return s.marginTransferService.TransferMarginAccountAsset(ctx, d.Asset, amount, types.TransferIn) + }) + if err2 != nil { + logger.WithError(err2).Errorf("unable to transfer deposit asset into the margin account") + } + } + } +} + +func (s *Strategy) scanDepositHistory(ctx context.Context, asset string, duration time.Duration) ([]types.Deposit, error) { + logger := s.logger.WithField("asset", asset) + logger.Debugf("scanning %s deposit history...", asset) + + now := time.Now() + since := now.Add(-duration) + + var deposits []types.Deposit + err := retry.GeneralBackoff(ctx, func() (err error) { + deposits, err = s.depositHistoryService.QueryDepositHistory(ctx, asset, since, now) + return err + }) + + if err != nil { + return nil, err + } + + // sort the recent deposit records in ascending order + sort.Slice(deposits, func(i, j int) bool { + return deposits[i].Time.Time().Before(deposits[j].Time.Time()) + }) + + s.mu.Lock() + defer s.mu.Unlock() + + for _, deposit := range deposits { + logger.Debugf("checking deposit: %+v", deposit) + + if deposit.Asset != asset { + continue + } + + if _, ok := s.watchingDeposits[deposit.TransactionID]; ok { + // if the deposit record is in the watch list, update it + s.watchingDeposits[deposit.TransactionID] = deposit + } else { + switch deposit.Status { + + case types.DepositSuccess: + if depositTime, ok := s.lastAssetDepositTimes[asset]; ok { + // if it's newer than the latest deposit time, then we just add it the monitoring list + if deposit.Time.After(depositTime) { + logger.Infof("adding new success deposit: %s", deposit.TransactionID) + s.watchingDeposits[deposit.TransactionID] = deposit + } + } else { + // ignore all initial deposit history that are already success + logger.Infof("ignored succeess deposit: %s %+v", deposit.TransactionID, deposit) + } + + case types.DepositCredited, types.DepositPending: + logger.Infof("adding pending deposit: %s", deposit.TransactionID) + s.watchingDeposits[deposit.TransactionID] = deposit + } + } + } + + if len(deposits) > 0 { + lastDeposit := deposits[len(deposits)-1] + if lastTime, ok := s.lastAssetDepositTimes[asset]; ok { + s.lastAssetDepositTimes[asset] = later(lastDeposit.Time.Time(), lastTime) + } else { + s.lastAssetDepositTimes[asset] = lastDeposit.Time.Time() + } + } + + var succeededDeposits []types.Deposit + for _, deposit := range s.watchingDeposits { + if deposit.Status == types.DepositSuccess { + logger.Infof("found pending -> success deposit: %+v", deposit) + + current, required := deposit.GetCurrentConfirmation() + if required > 0 && deposit.UnlockConfirm > 0 && current < deposit.UnlockConfirm { + logger.Infof("deposit %s unlock confirm %d is not reached, current: %d, required: %d, skip this round", deposit.TransactionID, deposit.UnlockConfirm, current, required) + continue + } + + succeededDeposits = append(succeededDeposits, deposit) + delete(s.watchingDeposits, deposit.TransactionID) + } + } + + return succeededDeposits, nil +} + +func later(a, b time.Time) time.Time { + if a.After(b) { + return a + } + + return b +} diff --git a/pkg/strategy/drift/draw.go b/pkg/strategy/drift/draw.go new file mode 100644 index 0000000..2728a4d --- /dev/null +++ b/pkg/strategy/drift/draw.go @@ -0,0 +1,176 @@ +package drift + +import ( + "bytes" + "fmt" + "os" + + "github.com/wcharczuk/go-chart/v2" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/interact" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func (s *Strategy) InitDrawCommands(profit, cumProfit types.Series) { + qbtrade.RegisterCommand("/draw", "Draw Indicators", func(reply interact.Reply) { + go func() { + canvas := s.DrawIndicators(s.frameKLine.StartTime) + var buffer bytes.Buffer + if err := canvas.Render(chart.PNG, &buffer); err != nil { + log.WithError(err).Errorf("cannot render indicators in drift") + return + } + qbtrade.SendPhoto(&buffer) + }() + }) + + qbtrade.RegisterCommand("/pnl", "Draw PNL(%) per trade", func(reply interact.Reply) { + go func() { + canvas := s.DrawPNL(profit) + var buffer bytes.Buffer + if err := canvas.Render(chart.PNG, &buffer); err != nil { + log.WithError(err).Errorf("cannot render pnl in drift") + return + } + qbtrade.SendPhoto(&buffer) + }() + }) + + qbtrade.RegisterCommand("/cumpnl", "Draw Cummulative PNL(Quote)", func(reply interact.Reply) { + go func() { + canvas := s.DrawCumPNL(cumProfit) + var buffer bytes.Buffer + if err := canvas.Render(chart.PNG, &buffer); err != nil { + log.WithError(err).Errorf("cannot render cumpnl in drift") + return + } + qbtrade.SendPhoto(&buffer) + }() + }) + + qbtrade.RegisterCommand("/elapsed", "Draw Elapsed time for handlers for each kline close event", func(reply interact.Reply) { + go func() { + canvas := s.DrawElapsed() + var buffer bytes.Buffer + if err := canvas.Render(chart.PNG, &buffer); err != nil { + log.WithError(err).Errorf("cannot render elapsed in drift") + return + } + qbtrade.SendPhoto(&buffer) + }() + }) +} + +func (s *Strategy) DrawIndicators(time types.Time) *types.Canvas { + canvas := types.NewCanvas(s.InstanceID(), s.Interval) + length := s.priceLines.Length() + if length > 300 { + length = 300 + } + log.Infof("draw indicators with %d data", length) + mean := s.priceLines.Mean(length) + highestPrice := s.priceLines.Minus(mean).Abs().Highest(length) + highestDrift := s.drift.Abs().Highest(length) + hi := s.drift.drift.Abs().Highest(length) + ratio := highestPrice / highestDrift + + // canvas.Plot("upband", s.ma.Add(s.stdevHigh), time, length) + canvas.Plot("ma", s.ma, time, length) + // canvas.Plot("downband", s.ma.Sub(s.stdevLow), time, length) + fmt.Printf("%f %f\n", highestPrice, hi) + + canvas.Plot("trend", s.trendLine, time, length) + canvas.Plot("drift", s.drift.Mul(ratio).Add(mean), time, length) + canvas.Plot("driftOrig", s.drift.drift.Mul(highestPrice/hi).Add(mean), time, length) + canvas.Plot("zero", types.NumberSeries(mean), time, length) + canvas.Plot("price", s.priceLines, time, length) + return canvas +} + +func (s *Strategy) DrawPNL(profit types.Series) *types.Canvas { + canvas := types.NewCanvas(s.InstanceID()) + log.Errorf("pnl Highest: %f, Lowest: %f", types.Highest(profit, profit.Length()), types.Lowest(profit, profit.Length())) + length := profit.Length() + if s.GraphPNLDeductFee { + canvas.PlotRaw("pnl % (with Fee Deducted)", profit, length) + } else { + canvas.PlotRaw("pnl %", profit, length) + } + canvas.YAxis = chart.YAxis{ + ValueFormatter: func(v interface{}) string { + if vf, isFloat := v.(float64); isFloat { + return fmt.Sprintf("%.4f", vf) + } + return "" + }, + } + canvas.PlotRaw("1", types.NumberSeries(1), length) + return canvas +} + +func (s *Strategy) DrawCumPNL(cumProfit types.Series) *types.Canvas { + canvas := types.NewCanvas(s.InstanceID()) + canvas.PlotRaw("cummulative pnl", cumProfit, cumProfit.Length()) + canvas.YAxis = chart.YAxis{ + ValueFormatter: func(v interface{}) string { + if vf, isFloat := v.(float64); isFloat { + return fmt.Sprintf("%.4f", vf) + } + return "" + }, + } + return canvas +} + +func (s *Strategy) DrawElapsed() *types.Canvas { + canvas := types.NewCanvas(s.InstanceID()) + canvas.PlotRaw("elapsed time(ms)", s.elapsed, s.elapsed.Length()) + return canvas +} + +func (s *Strategy) Draw(time types.Time, profit types.Series, cumProfit types.Series) { + canvas := s.DrawIndicators(time) + f, err := os.Create(s.CanvasPath) + if err != nil { + log.WithError(err).Errorf("cannot create on %s", s.CanvasPath) + return + } + if err := canvas.Render(chart.PNG, f); err != nil { + log.WithError(err).Errorf("cannot render in drift") + } + f.Close() + + canvas = s.DrawPNL(profit) + f, err = os.Create(s.GraphPNLPath) + if err != nil { + log.WithError(err).Errorf("open pnl") + return + } + if err := canvas.Render(chart.PNG, f); err != nil { + log.WithError(err).Errorf("render pnl") + } + f.Close() + + canvas = s.DrawCumPNL(cumProfit) + f, err = os.Create(s.GraphCumPNLPath) + if err != nil { + log.WithError(err).Errorf("open cumpnl") + return + } + if err := canvas.Render(chart.PNG, f); err != nil { + log.WithError(err).Errorf("render cumpnl") + } + f.Close() + + canvas = s.DrawElapsed() + f, err = os.Create(s.GraphElapsedPath) + if err != nil { + log.WithError(err).Errorf("open elapsed") + return + } + if err := canvas.Render(chart.PNG, f); err != nil { + log.WithError(err).Errorf("render elapsed") + } + f.Close() +} diff --git a/pkg/strategy/drift/driftma.go b/pkg/strategy/drift/driftma.go new file mode 100644 index 0000000..08dee2c --- /dev/null +++ b/pkg/strategy/drift/driftma.go @@ -0,0 +1,57 @@ +package drift + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type DriftMA struct { + types.SeriesBase + drift *indicator.WeightedDrift + ma1 types.UpdatableSeriesExtend + ma2 types.UpdatableSeriesExtend +} + +func (s *DriftMA) Update(value, weight float64) { + s.ma1.Update(value) + if s.ma1.Length() == 0 { + return + } + s.drift.Update(s.ma1.Last(0), weight) + if s.drift.Length() == 0 { + return + } + s.ma2.Update(s.drift.Last(0)) +} + +func (s *DriftMA) Last(i int) float64 { + return s.ma2.Last(i) +} + +func (s *DriftMA) Index(i int) float64 { + return s.ma2.Last(i) +} + +func (s *DriftMA) Length() int { + return s.ma2.Length() +} + +func (s *DriftMA) ZeroPoint() float64 { + return s.drift.ZeroPoint() +} + +func (s *DriftMA) Clone() *DriftMA { + out := DriftMA{ + drift: s.drift.Clone(), + ma1: types.Clone(s.ma1), + ma2: types.Clone(s.ma2), + } + out.SeriesBase.Series = &out + return &out +} + +func (s *DriftMA) TestUpdate(v, weight float64) *DriftMA { + out := s.Clone() + out.Update(v, weight) + return out +} diff --git a/pkg/strategy/drift/output.go b/pkg/strategy/drift/output.go new file mode 100644 index 0000000..9ecfc52 --- /dev/null +++ b/pkg/strategy/drift/output.go @@ -0,0 +1,18 @@ +package drift + +import ( + "io" + + "github.com/jedib0t/go-pretty/v6/table" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/dynamic" + "git.qtrade.icu/lychiyu/qbtrade/pkg/style" +) + +func (s *Strategy) Print(f io.Writer, pretty bool, withColor ...bool) { + var tableStyle *table.Style + if pretty { + tableStyle = style.NewDefaultTableStyle() + } + dynamic.PrintConfig(s, f, tableStyle, len(withColor) > 0 && withColor[0], dynamic.DefaultWhiteList()...) +} diff --git a/pkg/strategy/drift/stoploss.go b/pkg/strategy/drift/stoploss.go new file mode 100644 index 0000000..56d985f --- /dev/null +++ b/pkg/strategy/drift/stoploss.go @@ -0,0 +1,19 @@ +package drift + +func (s *Strategy) CheckStopLoss() bool { + if s.UseStopLoss { + stoploss := s.StopLoss.Float64() + if s.sellPrice > 0 && s.sellPrice*(1.+stoploss) <= s.highestPrice || + s.buyPrice > 0 && s.buyPrice*(1.-stoploss) >= s.lowestPrice { + return true + } + } + if s.UseAtr { + atr := s.atr.Last(0) + if s.sellPrice > 0 && s.sellPrice+atr <= s.highestPrice || + s.buyPrice > 0 && s.buyPrice-atr >= s.lowestPrice { + return true + } + } + return false +} diff --git a/pkg/strategy/drift/stoploss_test.go b/pkg/strategy/drift/stoploss_test.go new file mode 100644 index 0000000..831ca56 --- /dev/null +++ b/pkg/strategy/drift/stoploss_test.go @@ -0,0 +1,58 @@ +package drift + +import ( + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "github.com/stretchr/testify/assert" +) + +func Test_StopLossLong(t *testing.T) { + s := &Strategy{} + s.highestPrice = 30. + s.buyPrice = 30. + s.lowestPrice = 29.7 + s.StopLoss = fixedpoint.NewFromFloat(0.01) + s.UseAtr = false + s.UseStopLoss = true + assert.True(t, s.CheckStopLoss()) +} + +func Test_StopLossShort(t *testing.T) { + s := &Strategy{} + s.lowestPrice = 30. + s.sellPrice = 30. + s.highestPrice = 30.3 + s.StopLoss = fixedpoint.NewFromFloat(0.01) + s.UseAtr = false + s.UseStopLoss = true + assert.True(t, s.CheckStopLoss()) +} + +func Test_ATRLong(t *testing.T) { + s := &Strategy{} + s.highestPrice = 30. + s.buyPrice = 30. + s.lowestPrice = 28.7 + s.UseAtr = true + s.UseStopLoss = false + s.atr = &indicator.ATR{RMA: &indicator.RMA{ + Values: floats.Slice{1., 1.2, 1.3}, + }} + assert.True(t, s.CheckStopLoss()) +} + +func Test_ATRShort(t *testing.T) { + s := &Strategy{} + s.highestPrice = 31.3 + s.sellPrice = 30. + s.lowestPrice = 30. + s.UseAtr = true + s.UseStopLoss = false + s.atr = &indicator.ATR{RMA: &indicator.RMA{ + Values: floats.Slice{1., 1.2, 1.3}, + }} + assert.True(t, s.CheckStopLoss()) +} diff --git a/pkg/strategy/drift/strategy.go b/pkg/strategy/drift/strategy.go new file mode 100644 index 0000000..202c611 --- /dev/null +++ b/pkg/strategy/drift/strategy.go @@ -0,0 +1,960 @@ +package drift + +import ( + "bytes" + "context" + "errors" + "fmt" + "math" + "os" + "strconv" + "sync" + "time" + + "github.com/sirupsen/logrus" + "go.uber.org/multierr" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/dynamic" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/interact" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +const ID = "drift" + +var log = logrus.WithField("strategy", ID) +var Four fixedpoint.Value = fixedpoint.NewFromInt(4) +var Three fixedpoint.Value = fixedpoint.NewFromInt(3) +var Two fixedpoint.Value = fixedpoint.NewFromInt(2) +var Delta fixedpoint.Value = fixedpoint.NewFromFloat(0.01) +var Fee = 0.0008 // taker fee % * 2, for upper bound + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +func filterErrors(errs []error) (es []error) { + for _, e := range errs { + if _, ok := e.(types.ZeroAssetError); ok { + continue + } + if qbtrade.ErrExceededSubmitOrderRetryLimit == e { + continue + } + es = append(es, e) + } + return es +} + +type Strategy struct { + Symbol string `json:"symbol"` + + qbtrade.OpenPositionOptions + qbtrade.StrategyController + types.Market + types.IntervalWindow + qbtrade.SourceSelector + + *qbtrade.Environment + *types.Position `persistence:"position"` + *types.ProfitStats `persistence:"profit_stats"` + *types.TradeStats `persistence:"trade_stats"` + + MinInterval types.Interval `json:"MinInterval"` // minimum interval referred for doing stoploss/trailing exists and updating highest/lowest + + elapsed *types.Queue + priceLines *types.Queue + trendLine types.UpdatableSeriesExtend + ma types.UpdatableSeriesExtend + stdevHigh *indicator.StdDev + stdevLow *indicator.StdDev + drift *DriftMA + atr *indicator.ATR + midPrice fixedpoint.Value // the midPrice is the average of bestBid and bestAsk in public orderbook + lock sync.RWMutex `ignore:"true"` // lock for midPrice + positionLock sync.RWMutex `ignore:"true"` // lock for highest/lowest and p + pendingLock sync.Mutex `ignore:"true"` + startTime time.Time // trading start time + maxCounterBuyCanceled int // the largest counter of the order on the buy side been cancelled. meaning the latest cancelled buy order. + maxCounterSellCanceled int // the largest counter of the order on the sell side been cancelled. meaning the latest cancelled sell order. + orderPendingCounter map[uint64]int // records the timepoint when the orders are created, using the counter at the time. + frameKLine *types.KLine // last kline in Interval + klineMin *types.KLine // last kline in MinInterval + + beta float64 // last beta value from trendline's linear regression (previous slope of the trendline) + + Debug bool `json:"debug" modifiable:"true"` // to print debug message or not + UseStopLoss bool `json:"useStopLoss" modifiable:"true"` // whether to use stoploss rate to do stoploss + UseAtr bool `json:"useAtr" modifiable:"true"` // use atr as stoploss + StopLoss fixedpoint.Value `json:"stoploss" modifiable:"true"` // stoploss rate + PredictOffset int `json:"predictOffset"` // the lookback length for the prediction using linear regression + HighLowVarianceMultiplier float64 `json:"hlVarianceMultiplier" modifiable:"true"` // modifier to set the limit order price + NoTrailingStopLoss bool `json:"noTrailingStopLoss" modifiable:"true"` // turn off the trailing exit and stoploss + HLRangeWindow int `json:"hlRangeWindow"` // ma window for kline high/low changes + SmootherWindow int `json:"smootherWindow"` // window that controls the smoothness of drift + FisherTransformWindow int `json:"fisherTransformWindow"` // fisher transform window to filter drift's negative signals + ATRWindow int `json:"atrWindow"` // window for atr indicator + PendingMinInterval int `json:"pendingMinInterval" modifiable:"true"` // if order not be traded for pendingMinInterval of time, cancel it. + NoRebalance bool `json:"noRebalance" modifiable:"true"` // disable rebalance + TrendWindow int `json:"trendWindow"` // trendLine is used for rebalancing the position. When trendLine goes up, hold base, otherwise hold quote + RebalanceFilter float64 `json:"rebalanceFilter" modifiable:"true"` // beta filter on the Linear Regression of trendLine + TrailingCallbackRate []float64 `json:"trailingCallbackRate" modifiable:"true"` + TrailingActivationRatio []float64 `json:"trailingActivationRatio" modifiable:"true"` + + buyPrice float64 `persistence:"buy_price"` // price when a long position is opened + sellPrice float64 `persistence:"sell_price"` // price when a short position is opened + highestPrice float64 `persistence:"highest_price"` // highestPrice when the position is opened + lowestPrice float64 `persistence:"lowest_price"` // lowestPrice when the position is opened + + // This is not related to trade but for statistics graph generation + // Will deduct fee in percentage from every trade + GraphPNLDeductFee bool `json:"graphPNLDeductFee"` + CanvasPath string `json:"canvasPath"` // backtest related. the path to store the indicator graph + GraphPNLPath string `json:"graphPNLPath"` // backtest related. the path to store the pnl % graph per trade graph. + GraphCumPNLPath string `json:"graphCumPNLPath"` // backtest related. the path to store the asset changes in graph + GraphElapsedPath string `json:"graphElapsedPath"` // the path to store the elapsed time in ms + GenerateGraph bool `json:"generateGraph"` // whether to generate graph when shutdown + + ExitMethods qbtrade.ExitMethodSet `json:"exits"` + Session *qbtrade.ExchangeSession + + *qbtrade.FastOrderExecutor + + getLastPrice func() fixedpoint.Value +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s:%v", ID, s.Symbol, qbtrade.IsBackTesting) +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + // by default, qbtrade only pre-subscribe 1000 klines. + // this is not enough if we're subscribing 30m intervals using SerialMarketDataStore + + if !qbtrade.IsBackTesting { + session.Subscribe(types.BookTickerChannel, s.Symbol, types.SubscribeOptions{}) + session.Subscribe(types.MarketTradeChannel, s.Symbol, types.SubscribeOptions{}) + // able to preload + if s.MinInterval.Milliseconds() >= types.Interval1s.Milliseconds() && s.MinInterval.Milliseconds()%types.Interval1s.Milliseconds() == 0 { + maxWindow := (s.Window + s.SmootherWindow + s.FisherTransformWindow) * (s.Interval.Milliseconds() / s.MinInterval.Milliseconds()) + qbtrade.KLinePreloadLimit = int64((maxWindow/1000 + 1) * 1000) + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{ + Interval: s.MinInterval, + }) + } else { + qbtrade.KLinePreloadLimit = 0 + } + } else { + maxWindow := (s.Window + s.SmootherWindow + s.FisherTransformWindow) * (s.Interval.Milliseconds() / s.MinInterval.Milliseconds()) + qbtrade.KLinePreloadLimit = int64((maxWindow/1000 + 1) * 1000) + // gave up preload + if s.Interval.Milliseconds() < s.MinInterval.Milliseconds() { + qbtrade.KLinePreloadLimit = 0 + } + log.Errorf("set kLinePreloadLimit to %d, %d %d", qbtrade.KLinePreloadLimit, s.Interval.Milliseconds()/s.MinInterval.Milliseconds(), maxWindow) + if qbtrade.KLinePreloadLimit > 0 { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{ + Interval: s.MinInterval, + }) + } + } + s.ExitMethods.SetAndSubscribe(session, s) +} + +func (s *Strategy) CurrentPosition() *types.Position { + return s.Position +} + +func (s *Strategy) SubmitOrder(ctx context.Context, submitOrder types.SubmitOrder) (*types.Order, error) { + formattedOrder, err := s.Session.FormatOrder(submitOrder) + if err != nil { + return nil, err + } + createdOrders, errIdx, err := qbtrade.BatchPlaceOrder(ctx, s.Session.Exchange, nil, formattedOrder) + if len(errIdx) > 0 { + return nil, err + } + return &createdOrders[0], err +} + +const closeOrderRetryLimit = 5 + +func (s *Strategy) ClosePosition(ctx context.Context, percentage fixedpoint.Value) error { + order := s.Position.NewMarketCloseOrder(percentage) + if order == nil { + return nil + } + order.Tag = "close" + order.TimeInForce = "" + + order.MarginSideEffect = types.SideEffectTypeAutoRepay + for i := 0; i < closeOrderRetryLimit; i++ { + price := s.getLastPrice() + balances := s.Session.GetAccount().Balances() + baseBalance := balances[s.Market.BaseCurrency].Total() + if order.Side == types.SideTypeBuy { + quoteAmount := balances[s.Market.QuoteCurrency].Total().Div(price) + if order.Quantity.Compare(quoteAmount) > 0 { + order.Quantity = quoteAmount + } + } else if order.Side == types.SideTypeSell && order.Quantity.Compare(baseBalance) > 0 { + order.Quantity = baseBalance + } + if s.Market.IsDustQuantity(order.Quantity, price) { + return nil + } + o, err := s.SubmitOrder(ctx, *order) + if err != nil { + order.Quantity = order.Quantity.Mul(fixedpoint.One.Sub(Delta)) + continue + } + + if o != nil { + if o.Status == types.OrderStatusNew || o.Status == types.OrderStatusPartiallyFilled { + log.Errorf("created Order when Close: %v", o) + } + } + return nil + } + return errors.New("exceed retry limit") +} + +func (s *Strategy) initIndicators(store *qbtrade.SerialMarketDataStore) error { + s.ma = &indicator.SMA{IntervalWindow: types.IntervalWindow{Interval: s.Interval, Window: s.HLRangeWindow}} + s.stdevHigh = &indicator.StdDev{IntervalWindow: types.IntervalWindow{Interval: s.Interval, Window: s.HLRangeWindow}} + s.stdevLow = &indicator.StdDev{IntervalWindow: types.IntervalWindow{Interval: s.Interval, Window: s.HLRangeWindow}} + s.drift = &DriftMA{ + drift: &indicator.WeightedDrift{ + MA: &indicator.SMA{IntervalWindow: s.IntervalWindow}, + IntervalWindow: s.IntervalWindow, + }, + ma1: &indicator.EWMA{ + IntervalWindow: types.IntervalWindow{Interval: s.Interval, Window: s.SmootherWindow}, + }, + ma2: &indicator.FisherTransform{ + IntervalWindow: types.IntervalWindow{Interval: s.Interval, Window: s.FisherTransformWindow}, + }, + } + s.drift.SeriesBase.Series = s.drift + s.atr = &indicator.ATR{IntervalWindow: types.IntervalWindow{Interval: s.Interval, Window: s.ATRWindow}} + s.trendLine = &indicator.EWMA{IntervalWindow: types.IntervalWindow{Interval: s.Interval, Window: s.TrendWindow}} + + if qbtrade.KLinePreloadLimit == 0 { + return nil + } + klines, ok := store.KLinesOfInterval(s.Interval) + klinesLength := len(*klines) + if !ok || klinesLength == 0 { + return errors.New("klines not exists") + } + log.Infof("loaded %d klines", klinesLength) + for _, kline := range *klines { + source := s.GetSource(&kline).Float64() + high := kline.High.Float64() + low := kline.Low.Float64() + s.ma.Update(source) + s.stdevHigh.Update(high - s.ma.Last(0)) + s.stdevLow.Update(s.ma.Last(0) - low) + s.drift.Update(source, kline.Volume.Abs().Float64()) + s.trendLine.Update(source) + s.atr.PushK(kline) + s.priceLines.Update(source) + } + if s.frameKLine != nil && klines != nil { + s.frameKLine.Set(&(*klines)[len(*klines)-1]) + } + klines, ok = store.KLinesOfInterval(s.MinInterval) + klinesLength = len(*klines) + if !ok || klinesLength == 0 { + return errors.New("klines not exists") + } + log.Infof("loaded %d klines%s", klinesLength, s.MinInterval) + if s.klineMin != nil && klines != nil { + s.klineMin.Set(&(*klines)[len(*klines)-1]) + } + return nil +} + +func (s *Strategy) smartCancel(ctx context.Context, pricef, atr float64, syscounter int) (int, error) { + nonTraded := s.FastOrderExecutor.ActiveMakerOrders().Orders() + if len(nonTraded) > 0 { + if len(nonTraded) > 1 { + log.Errorf("should only have one order to cancel, got %d", len(nonTraded)) + } + toCancel := false + + for _, order := range nonTraded { + if order.Status != types.OrderStatusNew && order.Status != types.OrderStatusPartiallyFilled { + continue + } + s.pendingLock.Lock() + counter := s.orderPendingCounter[order.OrderID] + s.pendingLock.Unlock() + + log.Warnf("%v | counter: %d, system: %d", order, counter, syscounter) + if syscounter-counter > s.PendingMinInterval { + toCancel = true + } else if order.Side == types.SideTypeBuy { + // 75% of the probability + if order.Price.Float64()+atr*2 <= pricef { + toCancel = true + } + } else if order.Side == types.SideTypeSell { + // 75% of the probability + if order.Price.Float64()-atr*2 >= pricef { + toCancel = true + } + } else { + panic("not supported side for the order") + } + } + if toCancel { + err := s.FastOrderExecutor.Cancel(ctx) + s.pendingLock.Lock() + counters := s.orderPendingCounter + s.orderPendingCounter = make(map[uint64]int) + s.pendingLock.Unlock() + // TODO: clean orderPendingCounter on cancel/trade + for _, order := range nonTraded { + counter := counters[order.OrderID] + if order.Side == types.SideTypeSell { + if s.maxCounterSellCanceled < counter { + s.maxCounterSellCanceled = counter + } + } else { + if s.maxCounterBuyCanceled < counter { + s.maxCounterBuyCanceled = counter + } + } + } + log.Warnf("cancel all %v", err) + return 0, err + } + } + return len(nonTraded), nil +} + +func (s *Strategy) trailingCheck(price float64, direction string) bool { + if s.highestPrice > 0 && s.highestPrice < price { + s.highestPrice = price + } + if s.lowestPrice > 0 && s.lowestPrice > price { + s.lowestPrice = price + } + isShort := direction == "short" + if isShort && s.sellPrice == 0 || !isShort && s.buyPrice == 0 { + return false + } + for i := len(s.TrailingCallbackRate) - 1; i >= 0; i-- { + trailingCallbackRate := s.TrailingCallbackRate[i] + trailingActivationRatio := s.TrailingActivationRatio[i] + if isShort { + if (s.sellPrice-s.lowestPrice)/s.lowestPrice > trailingActivationRatio { + return (price-s.lowestPrice)/s.lowestPrice > trailingCallbackRate + } + } else { + if (s.highestPrice-s.buyPrice)/s.buyPrice > trailingActivationRatio { + return (s.highestPrice-price)/s.buyPrice > trailingCallbackRate + } + } + } + return false +} + +func (s *Strategy) initTickerFunctions(ctx context.Context) { + if s.IsBackTesting() { + s.getLastPrice = func() fixedpoint.Value { + lastPrice, ok := s.Session.LastPrice(s.Symbol) + if !ok { + log.Error("cannot get lastprice") + } + return lastPrice + } + } else { + s.Session.MarketDataStream.OnBookTickerUpdate(func(ticker types.BookTicker) { + bestBid := ticker.Buy + bestAsk := ticker.Sell + + if !util.TryLock(&s.lock) { + return + } + if !bestAsk.IsZero() && !bestBid.IsZero() { + s.midPrice = bestAsk.Add(bestBid).Div(Two) + } else if !bestAsk.IsZero() { + s.midPrice = bestAsk + } else { + s.midPrice = bestBid + } + s.lock.Unlock() + + // we removed realtime stoploss and trailingStop. + + }) + s.getLastPrice = func() (lastPrice fixedpoint.Value) { + var ok bool + s.lock.RLock() + defer s.lock.RUnlock() + if s.midPrice.IsZero() { + lastPrice, ok = s.Session.LastPrice(s.Symbol) + if !ok { + log.Error("cannot get lastprice") + return lastPrice + } + } else { + lastPrice = s.midPrice + } + return lastPrice + } + } + +} + +// Sending new rebalance orders cost too much. +// Modify the position instead to expect the strategy itself rebalance on Close +func (s *Strategy) Rebalance(ctx context.Context) { + price := s.getLastPrice() + _, beta := types.LinearRegression(s.trendLine, 3) + if math.Abs(beta) > s.RebalanceFilter && math.Abs(s.beta) > s.RebalanceFilter || math.Abs(s.beta) < s.RebalanceFilter && math.Abs(beta) < s.RebalanceFilter { + return + } + balances := s.FastOrderExecutor.Session().GetAccount().Balances() + baseBalance := balances[s.Market.BaseCurrency].Total() + quoteBalance := balances[s.Market.QuoteCurrency].Total() + total := baseBalance.Add(quoteBalance.Div(price)) + percentage := fixedpoint.One.Sub(Delta) + log.Infof("rebalance beta %f %v", beta, s.Position) + if beta > s.RebalanceFilter { + if total.Mul(percentage).Compare(baseBalance) > 0 { + q := total.Mul(percentage).Sub(baseBalance) + s.Position.Lock() + defer s.Position.Unlock() + s.Position.Base = q.Neg() + s.Position.Quote = q.Mul(price) + s.Position.AverageCost = price + } + } else if beta <= -s.RebalanceFilter { + if total.Mul(percentage).Compare(quoteBalance.Div(price)) > 0 { + q := total.Mul(percentage).Sub(quoteBalance.Div(price)) + s.Position.Lock() + defer s.Position.Unlock() + s.Position.Base = q + s.Position.Quote = q.Mul(price).Neg() + s.Position.AverageCost = price + } + } else { + if total.Div(Two).Compare(quoteBalance.Div(price)) > 0 { + q := total.Div(Two).Sub(quoteBalance.Div(price)) + s.Position.Lock() + defer s.Position.Unlock() + s.Position.Base = q + s.Position.Quote = q.Mul(price).Neg() + s.Position.AverageCost = price + } else if total.Div(Two).Compare(baseBalance) > 0 { + q := total.Div(Two).Sub(baseBalance) + s.Position.Lock() + defer s.Position.Unlock() + s.Position.Base = q.Neg() + s.Position.Quote = q.Mul(price) + s.Position.AverageCost = price + } else { + s.Position.Lock() + defer s.Position.Unlock() + s.Position.Reset() + } + } + log.Infof("rebalanceafter %v %v %v", baseBalance, quoteBalance, s.Position) + s.beta = beta +} + +func (s *Strategy) CalcAssetValue(price fixedpoint.Value) fixedpoint.Value { + balances := s.Session.GetAccount().Balances() + return balances[s.Market.BaseCurrency].Total().Mul(price).Add(balances[s.Market.QuoteCurrency].Total()) +} + +func (s *Strategy) klineHandlerMin(ctx context.Context, kline types.KLine, counter int) { + s.klineMin.Set(&kline) + if s.Status != types.StrategyStatusRunning { + return + } + // for doing the trailing stoploss during backtesting + atr := s.atr.Last(0) + price := s.getLastPrice() + pricef := price.Float64() + + lowf := math.Min(kline.Low.Float64(), pricef) + highf := math.Max(kline.High.Float64(), pricef) + s.positionLock.Lock() + if s.lowestPrice > 0 && lowf < s.lowestPrice { + s.lowestPrice = lowf + } + if s.highestPrice > 0 && highf > s.highestPrice { + s.highestPrice = highf + } + s.positionLock.Unlock() + + numPending := 0 + var err error + if numPending, err = s.smartCancel(ctx, pricef, atr, counter); err != nil { + log.WithError(err).Errorf("cannot cancel orders") + return + } + if numPending > 0 { + return + } + + if s.NoTrailingStopLoss { + return + } + + exitCondition := s.CheckStopLoss() || s.trailingCheck(highf, "short") || s.trailingCheck(lowf, "long") + if exitCondition { + _ = s.ClosePosition(ctx, fixedpoint.One) + } +} + +func (s *Strategy) klineHandler(ctx context.Context, kline types.KLine, counter int) { + start := time.Now() + defer func() { + end := time.Now() + elapsed := end.Sub(start) + s.elapsed.Update(float64(elapsed) / 1000000) + }() + s.frameKLine.Set(&kline) + + source := s.GetSource(&kline) + sourcef := source.Float64() + + s.priceLines.Update(sourcef) + s.ma.Update(sourcef) + s.trendLine.Update(sourcef) + + s.drift.Update(sourcef, kline.Volume.Abs().Float64()) + s.atr.PushK(kline) + atr := s.atr.Last(0) + + price := kline.Close // s.getLastPrice() + pricef := price.Float64() + lowf := math.Min(kline.Low.Float64(), pricef) + highf := math.Max(kline.High.Float64(), pricef) + lowdiff := pricef - lowf + s.stdevLow.Update(lowdiff) + highdiff := highf - pricef + s.stdevHigh.Update(highdiff) + + drift := s.drift.Array(2) + + if len(drift) < 2 || len(drift) < s.PredictOffset { + return + } + ddrift := s.drift.drift.Array(2) + if len(ddrift) < 2 || len(ddrift) < s.PredictOffset { + return + } + + if s.Status != types.StrategyStatusRunning { + return + } + + log.Infof("highdiff: %3.2f open: %8v, close: %8v, high: %8v, low: %8v, time: %v %v", s.stdevHigh.Last(0), kline.Open, kline.Close, kline.High, kline.Low, kline.StartTime, kline.EndTime) + + s.positionLock.Lock() + if s.lowestPrice > 0 && lowf < s.lowestPrice { + s.lowestPrice = lowf + } + if s.highestPrice > 0 && highf > s.highestPrice { + s.highestPrice = highf + } + s.positionLock.Unlock() + + if !s.NoRebalance { + s.Rebalance(ctx) + } + + if s.Debug { + balances := s.FastOrderExecutor.Session().GetAccount().Balances() + qbtrade.Notify("source: %.4f, price: %.4f, drift[0]: %.4f, ddrift[0]: %.4f, lowf %.4f, highf: %.4f lowest: %.4f highest: %.4f sp %.4f bp %.4f", + sourcef, pricef, drift[0], ddrift[0], atr, lowf, highf, s.lowestPrice, s.highestPrice, s.sellPrice, s.buyPrice) + // Notify will parse args to strings and process separately + qbtrade.Notify("balances: [Total] %v %s [Base] %s(%v %s) [Quote] %s", + s.CalcAssetValue(price), + s.Market.QuoteCurrency, + balances[s.Market.BaseCurrency].String(), + balances[s.Market.BaseCurrency].Total().Mul(price), + s.Market.QuoteCurrency, + balances[s.Market.QuoteCurrency].String(), + ) + } + + shortCondition := drift[1] >= 0 && drift[0] <= 0 || (drift[1] >= drift[0] && drift[1] <= 0) || ddrift[1] >= 0 && ddrift[0] <= 0 || (ddrift[1] >= ddrift[0] && ddrift[1] <= 0) + longCondition := drift[1] <= 0 && drift[0] >= 0 || (drift[1] <= drift[0] && drift[1] >= 0) || ddrift[1] <= 0 && ddrift[0] >= 0 || (ddrift[1] <= ddrift[0] && ddrift[1] >= 0) + if shortCondition && longCondition { + if s.priceLines.Index(1) > s.priceLines.Last(0) { + longCondition = false + } else { + shortCondition = false + } + } + exitCondition := !s.NoTrailingStopLoss && (s.CheckStopLoss() || s.trailingCheck(pricef, "short") || s.trailingCheck(pricef, "long")) + + if exitCondition || longCondition || shortCondition { + var err error + var hold int + if hold, err = s.smartCancel(ctx, pricef, atr, counter); err != nil { + log.WithError(err).Errorf("cannot cancel orders") + } + if hold > 0 { + return + } + } else { + if _, err := s.smartCancel(ctx, pricef, atr, counter); err != nil { + log.WithError(err).Errorf("cannot cancel orders") + } + return + } + if exitCondition { + _ = s.ClosePosition(ctx, fixedpoint.One) + return + } + + if longCondition { + source = source.Sub(fixedpoint.NewFromFloat(s.stdevLow.Last(0) * s.HighLowVarianceMultiplier)) + if source.Compare(price) > 0 { + source = price + } + + opt := s.OpenPositionOptions + opt.Long = true + opt.LimitOrder = true + // force to use market taker + if counter-s.maxCounterBuyCanceled <= s.PendingMinInterval && s.maxCounterBuyCanceled > s.maxCounterSellCanceled { + opt.LimitOrder = false + source = price + } + opt.Price = source + opt.Tags = []string{"long"} + + submitOrder, err := s.FastOrderExecutor.NewOrderFromOpenPosition(ctx, &opt) + if err != nil { + errs := filterErrors(multierr.Errors(err)) + if len(errs) > 0 { + log.Errorf("%v", errs) + log.WithError(err).Errorf("cannot place buy order") + } + return + } + if submitOrder == nil { + return + } + + log.Infof("source in long %v %v %f", source, price, s.stdevLow.Last(0)) + + o, err := s.SubmitOrder(ctx, *submitOrder) + if err != nil { + log.WithError(err).Errorf("cannot place buy order") + return + } + + log.Infof("order %v", o) + if o != nil { + if o.Status == types.OrderStatusNew || o.Status == types.OrderStatusPartiallyFilled { + s.pendingLock.Lock() + if _, ok := s.orderPendingCounter[o.OrderID]; !ok { + s.orderPendingCounter[o.OrderID] = counter + } + s.pendingLock.Unlock() + } + } + return + } + if shortCondition { + source = source.Add(fixedpoint.NewFromFloat(s.stdevHigh.Last(0) * s.HighLowVarianceMultiplier)) + if source.Compare(price) < 0 { + source = price + } + + log.Infof("source in short: %v %v %f", source, price, s.stdevLow.Last(0)) + + opt := s.OpenPositionOptions + opt.Short = true + opt.LimitOrder = true + if counter-s.maxCounterSellCanceled <= s.PendingMinInterval && s.maxCounterSellCanceled > s.maxCounterBuyCanceled { + opt.LimitOrder = false + source = price + } + opt.Price = source + opt.Tags = []string{"short"} + submitOrder, err := s.FastOrderExecutor.NewOrderFromOpenPosition(ctx, &opt) + if err != nil { + errs := filterErrors(multierr.Errors(err)) + if len(errs) > 0 { + log.WithError(err).Errorf("cannot place sell order") + } + return + } + if submitOrder == nil { + return + } + o, err := s.SubmitOrder(ctx, *submitOrder) + if err != nil { + log.WithError(err).Errorf("cannot place sell order") + return + } + log.Infof("order %v", o) + if o != nil { + if o.Status == types.OrderStatusNew || o.Status == types.OrderStatusPartiallyFilled { + s.pendingLock.Lock() + if _, ok := s.orderPendingCounter[o.OrderID]; !ok { + s.orderPendingCounter[o.OrderID] = counter + } + s.pendingLock.Unlock() + } + } + return + } +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + if s.Leverage == fixedpoint.Zero { + s.Leverage = fixedpoint.One + } + instanceID := s.InstanceID() + // Will be set by persistence if there's any from DB + if s.Position == nil { + s.Position = types.NewPositionFromMarket(s.Market) + } + if s.Session.MakerFeeRate.Sign() > 0 || s.Session.TakerFeeRate.Sign() > 0 { + s.Position.SetExchangeFeeRate(s.Session.ExchangeName, types.ExchangeFee{ + MakerFeeRate: s.Session.MakerFeeRate, + TakerFeeRate: s.Session.TakerFeeRate, + }) + } + if s.ProfitStats == nil { + s.ProfitStats = types.NewProfitStats(s.Market) + } + if s.TradeStats == nil { + s.TradeStats = types.NewTradeStats(s.Symbol) + } + // StrategyController + s.Status = types.StrategyStatusRunning + + s.OnSuspend(func() { + _ = s.FastOrderExecutor.GracefulCancel(ctx) + }) + + s.OnEmergencyStop(func() { + _ = s.FastOrderExecutor.GracefulCancel(ctx) + _ = s.ClosePosition(ctx, fixedpoint.One) + }) + + profitChart := floats.Slice{1., 1.} + price, _ := s.Session.LastPrice(s.Symbol) + initAsset := s.CalcAssetValue(price).Float64() + cumProfit := floats.Slice{initAsset, initAsset} + modify := func(p float64) float64 { + return p + } + if s.GraphPNLDeductFee { + modify = func(p float64) float64 { + return p * (1. - Fee) + } + } + + s.FastOrderExecutor = qbtrade.NewFastOrderExecutor(session, s.Symbol, ID, instanceID, s.Position) + s.FastOrderExecutor.DisableNotify() + orderStore := s.FastOrderExecutor.OrderStore() + orderStore.AddOrderUpdate = true + orderStore.RemoveCancelled = true + orderStore.RemoveFilled = true + activeOrders := s.FastOrderExecutor.ActiveMakerOrders() + tradeCollector := s.FastOrderExecutor.TradeCollector() + tradeStore := tradeCollector.TradeStore() + + syscounter := 0 + + // Modify activeOrders to force write order updates + s.Session.UserDataStream.OnOrderUpdate(func(order types.Order) { + hasSymbol := len(activeOrders.Symbol) > 0 + if hasSymbol && order.Symbol != activeOrders.Symbol { + return + } + + switch order.Status { + case types.OrderStatusFilled: + s.pendingLock.Lock() + s.orderPendingCounter = make(map[uint64]int) + s.pendingLock.Unlock() + // make sure we have the order and we remove it + activeOrders.Remove(order) + + case types.OrderStatusPartiallyFilled: + s.pendingLock.Lock() + if _, ok := s.orderPendingCounter[order.OrderID]; !ok { + s.orderPendingCounter[order.OrderID] = syscounter + } + s.pendingLock.Unlock() + activeOrders.Add(order) + + case types.OrderStatusNew: + s.pendingLock.Lock() + if _, ok := s.orderPendingCounter[order.OrderID]; !ok { + s.orderPendingCounter[order.OrderID] = syscounter + } + s.pendingLock.Unlock() + activeOrders.Add(order) + + case types.OrderStatusCanceled, types.OrderStatusRejected: + log.Debugf("[ActiveOrderBook] order status %s, removing order %s", order.Status, order) + s.pendingLock.Lock() + s.orderPendingCounter = make(map[uint64]int) + s.pendingLock.Unlock() + activeOrders.Remove(order) + + default: + log.Errorf("unhandled order status: %s", order.Status) + } + orderStore.HandleOrderUpdate(order) + }) + s.Session.UserDataStream.OnTradeUpdate(func(trade types.Trade) { + if trade.Symbol != s.Symbol { + return + } + profit, netProfit, madeProfit := s.Position.AddTrade(trade) + tradeStore.Add(trade) + if madeProfit { + p := s.Position.NewProfit(trade, profit, netProfit) + s.Environment.RecordPosition(s.Position, trade, &p) + s.TradeStats.Add(&p) + s.ProfitStats.AddTrade(trade) + s.ProfitStats.AddProfit(p) + qbtrade.Notify(&p) + qbtrade.Notify(s.ProfitStats) + } + + price := trade.Price.Float64() + + if s.buyPrice > 0 { + profitChart.Update(modify(price / s.buyPrice)) + cumProfit.Update(s.CalcAssetValue(trade.Price).Float64()) + } else if s.sellPrice > 0 { + profitChart.Update(modify(s.sellPrice / price)) + cumProfit.Update(s.CalcAssetValue(trade.Price).Float64()) + } + s.positionLock.Lock() + if s.Position.IsDust(trade.Price) { + s.buyPrice = 0 + s.sellPrice = 0 + s.highestPrice = 0 + s.lowestPrice = 0 + } else if s.Position.IsLong() { + s.buyPrice = s.Position.ApproximateAverageCost.Float64() + s.sellPrice = 0 + s.highestPrice = math.Max(s.buyPrice, s.highestPrice) + s.lowestPrice = s.buyPrice + } else if s.Position.IsShort() { + s.sellPrice = s.Position.ApproximateAverageCost.Float64() + s.buyPrice = 0 + s.highestPrice = s.sellPrice + if s.lowestPrice == 0 { + s.lowestPrice = s.sellPrice + } else { + s.lowestPrice = math.Min(s.lowestPrice, s.sellPrice) + } + } + s.positionLock.Unlock() + }) + + s.orderPendingCounter = make(map[uint64]int) + + // Exit methods from config + for _, method := range s.ExitMethods { + method.Bind(session, s.GeneralOrderExecutor) + } + + s.frameKLine = &types.KLine{} + s.klineMin = &types.KLine{} + s.priceLines = types.NewQueue(300) + s.elapsed = types.NewQueue(60000) + + s.initTickerFunctions(ctx) + s.startTime = s.Environment.StartTime() + s.TradeStats.SetIntervalProfitCollector(types.NewIntervalProfitCollector(types.Interval1d, s.startTime)) + s.TradeStats.SetIntervalProfitCollector(types.NewIntervalProfitCollector(types.Interval1w, s.startTime)) + + s.InitDrawCommands(&profitChart, &cumProfit) + + qbtrade.RegisterCommand("/config", "Show latest config", func(reply interact.Reply) { + var buffer bytes.Buffer + s.Print(&buffer, false) + reply.Message(buffer.String()) + }) + + qbtrade.RegisterCommand("/dump", "Dump internal params", func(reply interact.Reply) { + reply.Message("Please enter series output length:") + }).Next(func(length string, reply interact.Reply) { + var buffer bytes.Buffer + l, err := strconv.Atoi(length) + if err != nil { + dynamic.ParamDump(s, &buffer) + } else { + dynamic.ParamDump(s, &buffer, l) + } + reply.Message(buffer.String()) + }) + + qbtrade.RegisterModifier(s) + + // event trigger order: s.Interval => s.MinInterval + store, ok := session.SerialMarketDataStore(ctx, s.Symbol, []types.Interval{s.Interval, s.MinInterval}, !qbtrade.IsBackTesting) + if !ok { + panic("cannot get " + s.MinInterval + " history") + } + if err := s.initIndicators(store); err != nil { + log.WithError(err).Errorf("initIndicator failed") + return nil + } + + store.OnKLineClosed(func(kline types.KLine) { + counter := int(kline.StartTime.Time().Add(kline.Interval.Duration()).Sub(s.startTime).Milliseconds()) / s.MinInterval.Milliseconds() + syscounter = counter + if kline.Interval == s.Interval { + s.klineHandler(ctx, kline, counter) + } else if kline.Interval == s.MinInterval { + s.klineHandlerMin(ctx, kline, counter) + } + }) + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + + if !qbtrade.IsBackTesting { + qbtrade.Sync(ctx, s) + } + + var buffer bytes.Buffer + + s.Print(&buffer, true, true) + + fmt.Fprintln(&buffer, "--- NonProfitable Dates ---") + for _, daypnl := range s.TradeStats.IntervalProfits[types.Interval1d].GetNonProfitableIntervals() { + fmt.Fprintf(&buffer, "%s\n", daypnl) + } + fmt.Fprintln(&buffer, s.TradeStats.BriefString()) + + fmt.Fprintf(&buffer, "%v\n", s.orderPendingCounter) + + os.Stdout.Write(buffer.Bytes()) + + if s.GenerateGraph { + s.Draw(s.frameKLine.StartTime, &profitChart, &cumProfit) + } + wg.Done() + }) + return nil +} diff --git a/pkg/strategy/drift/strategy_test.go b/pkg/strategy/drift/strategy_test.go new file mode 100644 index 0000000..7ab05f7 --- /dev/null +++ b/pkg/strategy/drift/strategy_test.go @@ -0,0 +1,37 @@ +package drift + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_TrailingCheckLong(t *testing.T) { + s := &Strategy{} + s.highestPrice = 30. + s.buyPrice = 30. + s.TrailingActivationRatio = []float64{0.002, 0.01} + s.TrailingCallbackRate = []float64{0.0008, 0.0016} + assert.False(t, s.trailingCheck(31., "long")) + assert.False(t, s.trailingCheck(31., "short")) + assert.False(t, s.trailingCheck(30.96, "short")) + assert.False(t, s.trailingCheck(30.96, "long")) + assert.False(t, s.trailingCheck(30.95, "short")) + assert.True(t, s.trailingCheck(30.95, "long")) +} + +func Test_TrailingCheckShort(t *testing.T) { + s := &Strategy{} + s.lowestPrice = 30. + s.sellPrice = 30. + s.TrailingActivationRatio = []float64{0.002, 0.01} + s.TrailingCallbackRate = []float64{0.0008, 0.0016} + assert.False(t, s.trailingCheck(29.96, "long")) + assert.False(t, s.trailingCheck(29.96, "short")) + assert.False(t, s.trailingCheck(29.99, "short")) + assert.False(t, s.trailingCheck(29.99, "long")) + assert.False(t, s.trailingCheck(29.93, "short")) + assert.False(t, s.trailingCheck(29.93, "long")) + assert.True(t, s.trailingCheck(29.96, "short")) + assert.False(t, s.trailingCheck(29.96, "long")) +} diff --git a/pkg/strategy/elliottwave/draw.go b/pkg/strategy/elliottwave/draw.go new file mode 100644 index 0000000..ca89863 --- /dev/null +++ b/pkg/strategy/elliottwave/draw.go @@ -0,0 +1,137 @@ +package elliottwave + +import ( + "bytes" + "fmt" + "os" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/interact" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/wcharczuk/go-chart/v2" +) + +func (s *Strategy) InitDrawCommands(store *qbtrade.SerialMarketDataStore, profit, cumProfit types.Series) { + qbtrade.RegisterCommand("/draw", "Draw Indicators", func(reply interact.Reply) { + go func() { + canvas := s.DrawIndicators(store) + if canvas == nil { + reply.Send("cannot render indicators") + return + } + var buffer bytes.Buffer + if err := canvas.Render(chart.PNG, &buffer); err != nil { + log.WithError(err).Errorf("cannot render indicators in ewo") + return + } + qbtrade.SendPhoto(&buffer) + }() + }) + qbtrade.RegisterCommand("/pnl", "Draw PNL(%) per trade", func(reply interact.Reply) { + go func() { + canvas := s.DrawPNL(profit) + var buffer bytes.Buffer + if err := canvas.Render(chart.PNG, &buffer); err != nil { + log.WithError(err).Errorf("cannot render pnl in ewo") + return + } + qbtrade.SendPhoto(&buffer) + }() + }) + qbtrade.RegisterCommand("/cumpnl", "Draw Cummulative PNL(Quote)", func(reply interact.Reply) { + go func() { + canvas := s.DrawCumPNL(cumProfit) + var buffer bytes.Buffer + if err := canvas.Render(chart.PNG, &buffer); err != nil { + log.WithError(err).Errorf("cannot render cumpnl in ewo") + return + } + qbtrade.SendPhoto(&buffer) + }() + }) +} + +func (s *Strategy) DrawIndicators(store *qbtrade.SerialMarketDataStore) *types.Canvas { + time := types.Time(s.startTime) + canvas := types.NewCanvas(s.InstanceID(), s.Interval) + Length := s.priceLines.Length() + if Length > 300 { + Length = 300 + } + log.Infof("draw indicators with %d data", Length) + mean := s.priceLines.Mean(Length) + high := s.priceLines.Highest(Length) + low := s.priceLines.Lowest(Length) + ehigh := types.Highest(s.ewo, Length) + elow := types.Lowest(s.ewo, Length) + canvas.Plot("ewo", types.Add(types.Mul(s.ewo, (high-low)/(ehigh-elow)), mean), time, Length) + canvas.Plot("zero", types.NumberSeries(mean), time, Length) + canvas.Plot("price", s.priceLines, time, Length) + return canvas +} + +func (s *Strategy) DrawPNL(profit types.Series) *types.Canvas { + canvas := types.NewCanvas(s.InstanceID()) + length := profit.Length() + log.Errorf("pnl Highest: %f, Lowest: %f", types.Highest(profit, length), types.Lowest(profit, length)) + canvas.PlotRaw("pnl %", profit, length) + canvas.YAxis = chart.YAxis{ + ValueFormatter: func(v interface{}) string { + if vf, isFloat := v.(float64); isFloat { + return fmt.Sprintf("%.4f", vf) + } + return "" + }, + } + canvas.PlotRaw("1", types.NumberSeries(1), length) + return canvas +} + +func (s *Strategy) DrawCumPNL(cumProfit types.Series) *types.Canvas { + canvas := types.NewCanvas(s.InstanceID()) + canvas.PlotRaw("cummulative pnl", cumProfit, cumProfit.Length()) + canvas.YAxis = chart.YAxis{ + ValueFormatter: func(v interface{}) string { + if vf, isFloat := v.(float64); isFloat { + return fmt.Sprintf("%.4f", vf) + } + return "" + }, + } + return canvas +} + +func (s *Strategy) Draw(store *qbtrade.SerialMarketDataStore, profit, cumProfit types.Series) { + canvas := s.DrawIndicators(store) + f, err := os.Create(s.GraphIndicatorPath) + if err != nil { + log.WithError(err).Errorf("cannot create on path " + s.GraphIndicatorPath) + return + } + if err = canvas.Render(chart.PNG, f); err != nil { + log.WithError(err).Errorf("cannot render elliottwave") + } + f.Close() + + canvas = s.DrawPNL(profit) + f, err = os.Create(s.GraphPNLPath) + if err != nil { + log.WithError(err).Errorf("cannot create on path " + s.GraphPNLPath) + return + } + if err = canvas.Render(chart.PNG, f); err != nil { + log.WithError(err).Errorf("cannot render pnl") + return + } + f.Close() + canvas = s.DrawCumPNL(cumProfit) + f, err = os.Create(s.GraphCumPNLPath) + if err != nil { + log.WithError(err).Errorf("cannot create on path " + s.GraphCumPNLPath) + return + } + if err = canvas.Render(chart.PNG, f); err != nil { + log.WithError(err).Errorf("cannot render cumpnl") + } + f.Close() +} diff --git a/pkg/strategy/elliottwave/ewo.go b/pkg/strategy/elliottwave/ewo.go new file mode 100644 index 0000000..7b97884 --- /dev/null +++ b/pkg/strategy/elliottwave/ewo.go @@ -0,0 +1,25 @@ +package elliottwave + +import "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + +type ElliottWave struct { + maSlow *indicator.SMA + maQuick *indicator.SMA +} + +func (s *ElliottWave) Index(i int) float64 { + return s.Last(i) +} + +func (s *ElliottWave) Last(i int) float64 { + return s.maQuick.Index(i)/s.maSlow.Index(i) - 1.0 +} + +func (s *ElliottWave) Length() int { + return s.maSlow.Length() +} + +func (s *ElliottWave) Update(v float64) { + s.maSlow.Update(v) + s.maQuick.Update(v) +} diff --git a/pkg/strategy/elliottwave/heikinashi.go b/pkg/strategy/elliottwave/heikinashi.go new file mode 100644 index 0000000..1f6e60d --- /dev/null +++ b/pkg/strategy/elliottwave/heikinashi.go @@ -0,0 +1,46 @@ +package elliottwave + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type HeikinAshi struct { + Values []types.KLine + size int +} + +func NewHeikinAshi(size int) *HeikinAshi { + return &HeikinAshi{ + Values: make([]types.KLine, size), + size: size, + } +} + +func (inc *HeikinAshi) Last() *types.KLine { + if len(inc.Values) == 0 { + return &types.KLine{} + } + return &inc.Values[len(inc.Values)-1] +} + +func (inc *HeikinAshi) Update(kline types.KLine) { + open := kline.Open + cloze := kline.Close + high := kline.High + low := kline.Low + lastOpen := inc.Last().Open + lastClose := inc.Last().Close + + newClose := open.Add(high).Add(low).Add(cloze).Div(Four) + newOpen := lastOpen.Add(lastClose).Div(Two) + + kline.Close = newClose + kline.Open = newOpen + kline.High = fixedpoint.Max(fixedpoint.Max(high, newOpen), newClose) + kline.Low = fixedpoint.Max(fixedpoint.Min(low, newOpen), newClose) + inc.Values = append(inc.Values, kline) + if len(inc.Values) > inc.size { + inc.Values = inc.Values[len(inc.Values)-inc.size:] + } +} diff --git a/pkg/strategy/elliottwave/output.go b/pkg/strategy/elliottwave/output.go new file mode 100644 index 0000000..fe20b8f --- /dev/null +++ b/pkg/strategy/elliottwave/output.go @@ -0,0 +1,43 @@ +package elliottwave + +import ( + "bytes" + "io" + "strconv" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/dynamic" + "git.qtrade.icu/lychiyu/qbtrade/pkg/interact" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/style" + "github.com/jedib0t/go-pretty/v6/table" +) + +func (s *Strategy) initOutputCommands() { + qbtrade.RegisterCommand("/config", "Show latest config", func(reply interact.Reply) { + var buffer bytes.Buffer + s.Print(&buffer, false) + reply.Message(buffer.String()) + }) + qbtrade.RegisterCommand("/dump", "Dump internal params", func(reply interact.Reply) { + reply.Message("Please enter series output length:") + }).Next(func(length string, reply interact.Reply) { + var buffer bytes.Buffer + l, err := strconv.Atoi(length) + if err != nil { + dynamic.ParamDump(s, &buffer) + } else { + dynamic.ParamDump(s, &buffer, l) + } + reply.Message(buffer.String()) + }) + + qbtrade.RegisterModifier(s) +} + +func (s *Strategy) Print(f io.Writer, pretty bool, withColor ...bool) { + var tableStyle *table.Style + if pretty { + tableStyle = style.NewDefaultTableStyle() + } + dynamic.PrintConfig(s, f, tableStyle, len(withColor) > 0 && withColor[0], dynamic.DefaultWhiteList()...) +} diff --git a/pkg/strategy/elliottwave/strategy.go b/pkg/strategy/elliottwave/strategy.go new file mode 100644 index 0000000..4ebcb7d --- /dev/null +++ b/pkg/strategy/elliottwave/strategy.go @@ -0,0 +1,562 @@ +package elliottwave + +import ( + "bytes" + "context" + "errors" + "fmt" + "math" + "os" + "sync" + "time" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +const ID = "elliottwave" + +var log = logrus.WithField("strategy", ID) +var Two fixedpoint.Value = fixedpoint.NewFromInt(2) +var Three fixedpoint.Value = fixedpoint.NewFromInt(3) +var Four fixedpoint.Value = fixedpoint.NewFromInt(4) +var Delta fixedpoint.Value = fixedpoint.NewFromFloat(0.00001) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type SourceFunc func(*types.KLine) fixedpoint.Value + +type Strategy struct { + Symbol string `json:"symbol"` + + qbtrade.OpenPositionOptions + qbtrade.StrategyController + qbtrade.SourceSelector + types.Market + Session *qbtrade.ExchangeSession + + Interval types.Interval `json:"interval"` + MinInterval types.Interval `json:"minInterval"` + Stoploss fixedpoint.Value `json:"stoploss" modifiable:"true"` + WindowATR int `json:"windowATR"` + WindowQuick int `json:"windowQuick"` + WindowSlow int `json:"windowSlow"` + PendingMinInterval int `json:"pendingMinInterval" modifiable:"true"` + UseHeikinAshi bool `json:"useHeikinAshi"` + + // whether to draw graph or not by the end of backtest + DrawGraph bool `json:"drawGraph"` + GraphIndicatorPath string `json:"graphIndicatorPath"` + GraphPNLPath string `json:"graphPNLPath"` + GraphCumPNLPath string `json:"graphCumPNLPath"` + + *qbtrade.Environment + *qbtrade.GeneralOrderExecutor + *types.Position `persistence:"position"` + *types.ProfitStats `persistence:"profit_stats"` + *types.TradeStats `persistence:"trade_stats"` + + ewo *ElliottWave + atr *indicator.ATR + heikinAshi *HeikinAshi + + priceLines *types.Queue + + getLastPrice func() fixedpoint.Value + + // for smart cancel + orderPendingCounter map[uint64]int + startTime time.Time + counter int + + // for position + buyPrice float64 `persistence:"buy_price"` + sellPrice float64 `persistence:"sell_price"` + highestPrice float64 `persistence:"highest_price"` + lowestPrice float64 `persistence:"lowest_price"` + + TrailingCallbackRate []float64 `json:"trailingCallbackRate" modifiable:"true"` + TrailingActivationRatio []float64 `json:"trailingActivationRatio" modifiable:"true"` + ExitMethods qbtrade.ExitMethodSet `json:"exits"` + + midPrice fixedpoint.Value + lock sync.RWMutex `ignore:"true"` +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s:%v", ID, s.Symbol, qbtrade.IsBackTesting) +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + // by default, qbtrade only pre-subscribe 1000 klines. + // this is not enough if we're subscribing 30m intervals using SerialMarketDataStore + if !qbtrade.IsBackTesting { + session.Subscribe(types.BookTickerChannel, s.Symbol, types.SubscribeOptions{}) + session.Subscribe(types.MarketTradeChannel, s.Symbol, types.SubscribeOptions{}) + if s.MinInterval.Milliseconds() >= types.Interval1s.Milliseconds() && s.MinInterval.Milliseconds()%types.Interval1s.Milliseconds() == 0 { + qbtrade.KLinePreloadLimit = int64(((s.Interval.Milliseconds()/s.MinInterval.Milliseconds())*s.WindowSlow/1000 + 1) + 1000) + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{ + Interval: s.MinInterval, + }) + } else { + qbtrade.KLinePreloadLimit = 0 + } + } else { + qbtrade.KLinePreloadLimit = int64((s.Interval.Milliseconds()/s.MinInterval.Milliseconds()*s.WindowSlow/1000 + 1) + 1000) + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{ + Interval: s.MinInterval, + }) + } + s.ExitMethods.SetAndSubscribe(session, s) +} + +func (s *Strategy) CurrentPosition() *types.Position { + return s.Position +} + +func (s *Strategy) ClosePosition(ctx context.Context, percentage fixedpoint.Value) error { + order := s.Position.NewMarketCloseOrder(percentage) + if order == nil { + return nil + } + order.Tag = "close" + order.TimeInForce = "" + balances := s.GeneralOrderExecutor.Session().GetAccount().Balances() + baseBalance := balances[s.Market.BaseCurrency].Available + price := s.getLastPrice() + if order.Side == types.SideTypeBuy { + quoteAmount := balances[s.Market.QuoteCurrency].Available.Div(price) + if order.Quantity.Compare(quoteAmount) > 0 { + order.Quantity = quoteAmount + } + } else if order.Side == types.SideTypeSell && order.Quantity.Compare(baseBalance) > 0 { + order.Quantity = baseBalance + } + order.MarginSideEffect = types.SideEffectTypeAutoRepay + for { + if s.Market.IsDustQuantity(order.Quantity, price) { + return nil + } + _, err := s.GeneralOrderExecutor.SubmitOrders(ctx, *order) + if err != nil { + order.Quantity = order.Quantity.Mul(fixedpoint.One.Sub(Delta)) + continue + } + return nil + } + +} + +func (s *Strategy) initIndicators(store *qbtrade.SerialMarketDataStore) error { + s.priceLines = types.NewQueue(300) + maSlow := &indicator.SMA{IntervalWindow: types.IntervalWindow{Interval: s.Interval, Window: s.WindowSlow}} + maQuick := &indicator.SMA{IntervalWindow: types.IntervalWindow{Interval: s.Interval, Window: s.WindowQuick}} + s.ewo = &ElliottWave{ + maSlow: maSlow, + maQuick: maQuick, + } + s.atr = &indicator.ATR{IntervalWindow: types.IntervalWindow{Interval: s.Interval, Window: s.WindowATR}} + klines, ok := store.KLinesOfInterval(s.Interval) + klineLength := len(*klines) + if !ok || klineLength == 0 { + return errors.New("klines not exists") + } + s.heikinAshi = NewHeikinAshi(500) + + for _, kline := range *klines { + source := s.GetSource(&kline).Float64() + s.ewo.Update(source) + s.atr.PushK(kline) + s.priceLines.Update(source) + s.heikinAshi.Update(kline) + } + return nil +} + +// FIXME: stdevHigh +func (s *Strategy) smartCancel(ctx context.Context, pricef float64) int { + nonTraded := s.GeneralOrderExecutor.ActiveMakerOrders().Orders() + if len(nonTraded) > 0 { + left := 0 + for _, order := range nonTraded { + if order.Status != types.OrderStatusNew && order.Status != types.OrderStatusPartiallyFilled { + continue + } + log.Warnf("%v | counter: %d, system: %d", order, s.orderPendingCounter[order.OrderID], s.counter) + toCancel := false + if s.counter-s.orderPendingCounter[order.OrderID] >= s.PendingMinInterval { + toCancel = true + } else if order.Side == types.SideTypeBuy { + if order.Price.Float64()+s.atr.Last(0)*2 <= pricef { + toCancel = true + } + } else if order.Side == types.SideTypeSell { + // 75% of the probability + if order.Price.Float64()-s.atr.Last(0)*2 >= pricef { + toCancel = true + } + } else { + panic("not supported side for the order") + } + if toCancel { + err := s.GeneralOrderExecutor.CancelOrders(ctx, order) + if err == nil { + delete(s.orderPendingCounter, order.OrderID) + } else { + log.WithError(err).Errorf("failed to cancel %v", order.OrderID) + } + log.Warnf("cancel %v", order.OrderID) + } else { + left += 1 + } + } + return left + } + return len(nonTraded) +} + +func (s *Strategy) trailingCheck(price float64, direction string) bool { + if s.highestPrice > 0 && s.highestPrice < price { + s.highestPrice = price + } + if s.lowestPrice > 0 && s.lowestPrice > price { + s.lowestPrice = price + } + isShort := direction == "short" + if isShort && s.sellPrice == 0 || !isShort && s.buyPrice == 0 { + return false + } + for i := len(s.TrailingCallbackRate) - 1; i >= 0; i-- { + trailingCallbackRate := s.TrailingCallbackRate[i] + trailingActivationRatio := s.TrailingActivationRatio[i] + if isShort { + if (s.sellPrice-s.lowestPrice)/s.lowestPrice > trailingActivationRatio { + return (price-s.lowestPrice)/s.lowestPrice > trailingCallbackRate + } + } else { + if (s.highestPrice-s.buyPrice)/s.buyPrice > trailingActivationRatio { + return (s.highestPrice-price)/s.buyPrice > trailingCallbackRate + } + } + } + return false +} + +func (s *Strategy) initTickerFunctions() { + if s.IsBackTesting() { + s.getLastPrice = func() fixedpoint.Value { + lastPrice, ok := s.Session.LastPrice(s.Symbol) + if !ok { + log.Error("cannot get lastprice") + } + return lastPrice + } + } else { + s.Session.MarketDataStream.OnBookTickerUpdate(func(ticker types.BookTicker) { + bestBid := ticker.Buy + bestAsk := ticker.Sell + if !util.TryLock(&s.lock) { + return + } + if !bestAsk.IsZero() && !bestBid.IsZero() { + s.midPrice = bestAsk.Add(bestBid).Div(Two) + } else if !bestAsk.IsZero() { + s.midPrice = bestAsk + } else if !bestBid.IsZero() { + s.midPrice = bestBid + } + s.lock.Unlock() + }) + s.getLastPrice = func() (lastPrice fixedpoint.Value) { + var ok bool + s.lock.RLock() + defer s.lock.RUnlock() + if s.midPrice.IsZero() { + lastPrice, ok = s.Session.LastPrice(s.Symbol) + if !ok { + log.Error("cannot get lastprice") + return lastPrice + } + } else { + lastPrice = s.midPrice + } + return lastPrice + } + } +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + instanceID := s.InstanceID() + 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) + } + // StrategyController + s.Status = types.StrategyStatusRunning + s.OnSuspend(func() { + _ = s.GeneralOrderExecutor.GracefulCancel(ctx) + }) + s.OnEmergencyStop(func() { + _ = s.GeneralOrderExecutor.GracefulCancel(ctx) + _ = s.ClosePosition(ctx, fixedpoint.One) + }) + s.GeneralOrderExecutor = qbtrade.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, s.Position) + s.GeneralOrderExecutor.BindEnvironment(s.Environment) + s.GeneralOrderExecutor.BindProfitStats(s.ProfitStats) + s.GeneralOrderExecutor.BindTradeStats(s.TradeStats) + s.GeneralOrderExecutor.TradeCollector().OnPositionUpdate(func(p *types.Position) { + qbtrade.Sync(ctx, s) + }) + s.GeneralOrderExecutor.Bind() + + s.orderPendingCounter = make(map[uint64]int) + s.counter = 0 + + for _, method := range s.ExitMethods { + method.Bind(session, s.GeneralOrderExecutor) + } + profit := floats.Slice{1., 1.} + price, _ := s.Session.LastPrice(s.Symbol) + initAsset := s.CalcAssetValue(price).Float64() + cumProfit := floats.Slice{initAsset, initAsset} + modify := func(p float64) float64 { + return p + } + s.GeneralOrderExecutor.TradeCollector().OnTrade(func(trade types.Trade, _profit, _netProfit fixedpoint.Value) { + price := trade.Price.Float64() + if s.buyPrice > 0 { + profit.Update(modify(price / s.buyPrice)) + cumProfit.Update(s.CalcAssetValue(trade.Price).Float64()) + } else if s.sellPrice > 0 { + profit.Update(modify(s.sellPrice / price)) + cumProfit.Update(s.CalcAssetValue(trade.Price).Float64()) + } + if s.Position.IsDust(trade.Price) { + s.buyPrice = 0 + s.sellPrice = 0 + s.highestPrice = 0 + s.lowestPrice = 0 + } else if s.Position.IsLong() { + s.buyPrice = s.Position.ApproximateAverageCost.Float64() + s.sellPrice = 0 + s.highestPrice = math.Max(s.buyPrice, s.highestPrice) + s.lowestPrice = 0 + } else { + s.sellPrice = s.Position.ApproximateAverageCost.Float64() + s.buyPrice = 0 + s.highestPrice = 0 + if s.lowestPrice == 0 { + s.lowestPrice = s.sellPrice + } else { + s.lowestPrice = math.Min(s.lowestPrice, s.sellPrice) + } + } + }) + s.initTickerFunctions() + + s.startTime = s.Environment.StartTime() + s.TradeStats.SetIntervalProfitCollector(types.NewIntervalProfitCollector(types.Interval1d, s.startTime)) + s.TradeStats.SetIntervalProfitCollector(types.NewIntervalProfitCollector(types.Interval1w, s.startTime)) + + s.initOutputCommands() + + // event trigger order: s.Interval => minInterval + store, ok := session.SerialMarketDataStore(ctx, s.Symbol, []types.Interval{s.Interval, s.MinInterval}, !qbtrade.IsBackTesting) + if !ok { + panic("cannot get " + s.MinInterval + " history") + } + if err := s.initIndicators(store); err != nil { + log.WithError(err).Errorf("initIndicator failed") + return nil + } + s.InitDrawCommands(store, &profit, &cumProfit) + store.OnKLineClosed(func(kline types.KLine) { + s.counter = int(kline.StartTime.Time().Add(kline.Interval.Duration()).Sub(s.startTime).Milliseconds()) + if kline.Interval == s.Interval { + s.klineHandler(ctx, kline) + } else if kline.Interval == s.MinInterval { + s.klineHandlerMin(ctx, kline) + } + }) + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + var buffer bytes.Buffer + for _, daypnl := range s.TradeStats.IntervalProfits[types.Interval1d].GetNonProfitableIntervals() { + fmt.Fprintf(&buffer, "%s\n", daypnl) + } + fmt.Fprintln(&buffer, s.TradeStats.BriefString()) + s.Print(&buffer, true, true) + os.Stdout.Write(buffer.Bytes()) + if s.DrawGraph { + s.Draw(store, &profit, &cumProfit) + } + wg.Done() + }) + return nil +} + +func (s *Strategy) CalcAssetValue(price fixedpoint.Value) fixedpoint.Value { + balances := s.Session.GetAccount().Balances() + return balances[s.Market.BaseCurrency].Total().Mul(price).Add(balances[s.Market.QuoteCurrency].Total()) +} + +func (s *Strategy) klineHandlerMin(ctx context.Context, kline types.KLine) { + if s.Status != types.StrategyStatusRunning { + return + } + + stoploss := s.Stoploss.Float64() + price := s.getLastPrice() + pricef := price.Float64() + atr := s.atr.Last(0) + + numPending := s.smartCancel(ctx, pricef) + if numPending > 0 { + log.Infof("pending orders: %d, exit", numPending) + return + } + lowf := math.Min(kline.Low.Float64(), pricef) + highf := math.Max(kline.High.Float64(), pricef) + if s.lowestPrice > 0 && lowf < s.lowestPrice { + s.lowestPrice = lowf + } + if s.highestPrice > 0 && highf > s.highestPrice { + s.highestPrice = highf + } + exitShortCondition := s.sellPrice > 0 && (s.sellPrice*(1.+stoploss) <= highf || s.sellPrice+atr <= highf || + s.trailingCheck(highf, "short")) + exitLongCondition := s.buyPrice > 0 && (s.buyPrice*(1.-stoploss) >= lowf || s.buyPrice-atr >= lowf || + s.trailingCheck(lowf, "long")) + + if exitShortCondition || exitLongCondition { + _ = s.ClosePosition(ctx, fixedpoint.One) + } +} + +func (s *Strategy) klineHandler(ctx context.Context, kline types.KLine) { + s.heikinAshi.Update(kline) + source := s.GetSource(&kline) + sourcef := source.Float64() + s.priceLines.Update(sourcef) + if s.UseHeikinAshi { + source := s.GetSource(s.heikinAshi.Last()) + sourcef := source.Float64() + s.ewo.Update(sourcef) + } else { + s.ewo.Update(sourcef) + } + s.atr.PushK(kline) + + if s.Status != types.StrategyStatusRunning { + return + } + + stoploss := s.Stoploss.Float64() + price := s.getLastPrice() + pricef := price.Float64() + lowf := math.Min(kline.Low.Float64(), pricef) + highf := math.Min(kline.High.Float64(), pricef) + + s.smartCancel(ctx, pricef) + + atr := s.atr.Last(0) + ewo := types.Array(s.ewo, 4) + if len(ewo) < 4 { + return + } + bull := kline.Close.Compare(kline.Open) > 0 + + balances := s.GeneralOrderExecutor.Session().GetAccount().Balances() + startTime := kline.StartTime.Time() + if startTime.Round(time.Second) == startTime.Round(time.Minute) { + qbtrade.Notify("source: %.4f, price: %.4f lowest: %.4f highest: %.4f sp %.4f bp %.4f", sourcef, pricef, s.lowestPrice, s.highestPrice, s.sellPrice, s.buyPrice) + qbtrade.Notify("balances: [Total] %v %s [Base] %s(%v %s) [Quote] %s", + s.CalcAssetValue(price), + s.Market.QuoteCurrency, + balances[s.Market.BaseCurrency].String(), + balances[s.Market.BaseCurrency].Total().Mul(price), + s.Market.QuoteCurrency, + balances[s.Market.QuoteCurrency].String(), + ) + } + + shortCondition := ewo[0] < ewo[1] && ewo[1] >= ewo[2] && (ewo[1] <= ewo[2] || ewo[2] >= ewo[3]) || s.sellPrice == 0 && ewo[0] < ewo[1] && ewo[1] < ewo[2] + longCondition := ewo[0] > ewo[1] && ewo[1] <= ewo[2] && (ewo[1] >= ewo[2] || ewo[2] <= ewo[3]) || s.buyPrice == 0 && ewo[0] > ewo[1] && ewo[1] > ewo[2] + + exitShortCondition := s.sellPrice > 0 && !shortCondition && s.sellPrice*(1.+stoploss) <= highf || s.sellPrice+atr <= highf || s.trailingCheck(highf, "short") + exitLongCondition := s.buyPrice > 0 && !longCondition && s.buyPrice*(1.-stoploss) >= lowf || s.buyPrice-atr >= lowf || s.trailingCheck(lowf, "long") + + if exitShortCondition || exitLongCondition || (longCondition && bull) || (shortCondition && !bull) { + if hold := s.smartCancel(ctx, pricef); hold > 0 { + return + } + } else { + s.smartCancel(ctx, pricef) + return + } + if exitShortCondition || exitLongCondition { + s.ClosePosition(ctx, fixedpoint.One) + } + + if longCondition && bull { + if source.Compare(price) > 0 { + source = price + } + opt := s.OpenPositionOptions + opt.Long = true + opt.Price = source + opt.Tags = []string{"long"} + log.Infof("source in long %v %v", source, price) + createdOrders, err := s.GeneralOrderExecutor.OpenPosition(ctx, opt) + if err != nil { + if _, ok := err.(types.ZeroAssetError); ok { + return + } + log.WithError(err).Errorf("cannot place buy order: %v %v", source, kline) + return + } + if createdOrders != nil { + s.orderPendingCounter[createdOrders[0].OrderID] = s.counter + } + return + } + if shortCondition && !bull { + if source.Compare(price) < 0 { + source = price + } + opt := s.OpenPositionOptions + opt.Short = true + opt.Price = source + opt.Tags = []string{"short"} + log.Infof("source in short %v %v", source, price) + createdOrders, err := s.GeneralOrderExecutor.OpenPosition(ctx, opt) + if err != nil { + if _, ok := err.(types.ZeroAssetError); ok { + return + } + log.WithError(err).Errorf("cannot place sell order: %v %v", source, kline) + return + } + if createdOrders != nil { + s.orderPendingCounter[createdOrders[0].OrderID] = s.counter + } + return + } +} diff --git a/pkg/strategy/emacross/strategy.go b/pkg/strategy/emacross/strategy.go new file mode 100644 index 0000000..9051707 --- /dev/null +++ b/pkg/strategy/emacross/strategy.go @@ -0,0 +1,102 @@ +package emacross + +import ( + "context" + "fmt" + "sync" + + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + indicatorv2 "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator/v2" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/common" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +const ID = "emacross" + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + *common.Strategy + + Environment *qbtrade.Environment + Market types.Market + + Symbol string `json:"symbol"` + Interval types.Interval `json:"interval"` + SlowWindow int `json:"slowWindow"` + FastWindow int `json:"fastWindow"` + OpenBelow fixedpoint.Value `json:"openBelow"` + CloseAbove fixedpoint.Value `json:"closeAbove"` + + lastKLine types.KLine + + qbtrade.OpenPositionOptions +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s:%s:%d-%d", ID, s.Symbol, s.Interval, s.FastWindow, s.SlowWindow) +} + +func (s *Strategy) Initialize() error { + if s.Strategy == nil { + s.Strategy = &common.Strategy{} + } + return nil +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) +} + +func (s *Strategy) Run(ctx context.Context, _ qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + s.Strategy.Initialize(ctx, s.Environment, session, s.Market, ID, s.InstanceID()) + + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, types.Interval5m, func(k types.KLine) { + s.lastKLine = k + })) + + fastEMA := session.Indicators(s.Symbol).EWMA(types.IntervalWindow{Interval: s.Interval, Window: s.FastWindow}) + slowEMA := session.Indicators(s.Symbol).EWMA(types.IntervalWindow{Interval: s.Interval, Window: s.SlowWindow}) + + cross := indicatorv2.Cross(fastEMA, slowEMA) + cross.OnUpdate(func(v float64) { + switch indicatorv2.CrossType(v) { + + case indicatorv2.CrossOver: + if err := s.Strategy.OrderExecutor.GracefulCancel(ctx); err != nil { + log.WithError(err).Errorf("unable to cancel order") + } + + opts := s.OpenPositionOptions + opts.Long = true + if price, ok := session.LastPrice(s.Symbol); ok { + opts.Price = price + } + + opts.Tags = []string{"emaCrossOver"} + + _, err := s.Strategy.OrderExecutor.OpenPosition(ctx, opts) + util.LogErr(err, "unable to open position") + case indicatorv2.CrossUnder: + err := s.Strategy.OrderExecutor.ClosePosition(ctx, fixedpoint.One) + util.LogErr(err, "unable to submit close position order") + } + }) + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + qbtrade.Sync(ctx, s) + }) + + return nil +} diff --git a/pkg/strategy/emastop/strategy.go b/pkg/strategy/emastop/strategy.go new file mode 100644 index 0000000..8d72a2e --- /dev/null +++ b/pkg/strategy/emastop/strategy.go @@ -0,0 +1,269 @@ +package emastop + +import ( + "context" + "fmt" + "strings" + "sync" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ID = "emastop" + +var log = logrus.WithField("strategy", ID) + +func init() { + // Register the pointer of the strategy struct, + // so that qbtrade knows what struct to be used to unmarshal the configs (YAML or JSON) + // Note: built-in strategies need to imported manually in the qbtrade cmd package. + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + SourceExchangeName string `json:"sourceExchange"` + + TargetExchangeName string `json:"targetExchange"` + + // These fields will be filled from the config file (it translates YAML to JSON) + Symbol string `json:"symbol"` + + // Interval is the interval of the kline channel we want to subscribe, + // the kline event will trigger the strategy to check if we need to submit order. + Interval types.Interval `json:"interval"` + + Quantity fixedpoint.Value `json:"quantity"` + + BalancePercentage fixedpoint.Value `json:"balancePercentage"` + + OrderType string `json:"orderType"` + + PriceRatio fixedpoint.Value `json:"priceRatio"` + + StopPriceRatio fixedpoint.Value `json:"stopPriceRatio"` + + // MovingAverageType is the moving average indicator type that we want to use, + // it could be SMA or EWMA + MovingAverageType string `json:"movingAverageType"` + + // MovingAverageInterval is the interval of k-lines for the moving average indicator to calculate, + // it could be "1m", "5m", "1h" and so on. note that, the moving averages are calculated from + // the k-line data we subscribed + MovingAverageInterval types.Interval `json:"movingAverageInterval"` + + // MovingAverageWindow is the number of the window size of the moving average indicator. + // The number of k-lines in the window. generally used window sizes are 7, 25 and 99 in the TradingView. + MovingAverageWindow int `json:"movingAverageWindow"` + + order types.Order +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.MovingAverageInterval}) +} + +func (s *Strategy) CrossSubscribe(sessions map[string]*qbtrade.ExchangeSession) { + sourceSession := sessions[s.SourceExchangeName] + s.Subscribe(sourceSession) + + // make sure we have the connection alive + targetSession := sessions[s.TargetExchangeName] + targetSession.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) +} + +func (s *Strategy) clear(ctx context.Context, orderExecutor qbtrade.OrderExecutor) { + if s.order.OrderID > 0 { + if err := orderExecutor.CancelOrders(ctx, s.order); err != nil { + log.WithError(err).Errorf("can not cancel trailingstop order: %+v", s.order) + } + + // clear out the existing order + s.order = types.Order{} + } +} + +func (s *Strategy) place(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession, indicator types.Float64Indicator, closePrice fixedpoint.Value) { + closePriceF := closePrice.Float64() + movingAveragePriceF := indicator.Last(0) + + // skip it if it's near zero because it's not loaded yet + if movingAveragePriceF < 0.0001 { + log.Warnf("moving average price is near 0: %f", movingAveragePriceF) + return + } + + // place stop limit order only when the closed price is greater than the moving average price + if closePriceF <= movingAveragePriceF { + log.Warnf("close price %v is less than moving average price %f", closePrice, movingAveragePriceF) + return + } + + movingAveragePrice := fixedpoint.NewFromFloat(movingAveragePriceF) + + var price = fixedpoint.Zero + var orderType = types.OrderTypeStopMarket + + switch strings.ToLower(s.OrderType) { + case "market": + orderType = types.OrderTypeStopMarket + case "limit": + orderType = types.OrderTypeStopLimit + price = movingAveragePrice + if s.PriceRatio.Sign() > 0 { + price = price.Mul(s.PriceRatio) + } + } + + market, ok := session.Market(s.Symbol) + if !ok { + log.Errorf("market not found, symbol %s", s.Symbol) + return + } + + quantity := s.Quantity + if s.BalancePercentage.Sign() > 0 { + + if balance, ok := session.GetAccount().Balance(market.BaseCurrency); ok { + quantity = balance.Available.Mul(s.BalancePercentage) + } + } + + amount := quantity.Mul(closePrice) + if amount.Compare(market.MinNotional) < 0 { + log.Errorf("the amount of stop order (%v) is less than min notional %v", amount, market.MinNotional) + return + } + + var stopPrice = movingAveragePrice + if s.StopPriceRatio.Sign() > 0 { + stopPrice = stopPrice.Mul(s.StopPriceRatio) + } + + log.Infof("placing trailingstop order %s at stop price %v, quantity %v", s.Symbol, stopPrice, quantity) + + retOrders, err := orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeSell, + Type: orderType, + Price: price, + StopPrice: stopPrice, + Quantity: quantity, + }) + if err != nil { + log.WithError(err).Error("submit order error") + } + + if len(retOrders) > 0 { + s.order = retOrders[0] + } +} + +func (s *Strategy) handleOrderUpdate(order types.Order) { + if order.OrderID == s.order.OrderID { + s.order = order + } +} + +func (s *Strategy) loadIndicator(sourceSession *qbtrade.ExchangeSession) (types.Float64Indicator, error) { + var standardIndicatorSet = sourceSession.StandardIndicatorSet(s.Symbol) + var iw = types.IntervalWindow{Interval: s.MovingAverageInterval, Window: s.MovingAverageWindow} + + switch strings.ToUpper(s.MovingAverageType) { + case "SMA": + return standardIndicatorSet.SMA(iw), nil + + case "EWMA", "EMA": + return standardIndicatorSet.EWMA(iw), nil + + } + + return nil, fmt.Errorf("unsupported moving average type: %s", s.MovingAverageType) +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + indicator, err := s.loadIndicator(session) + if err != nil { + return err + } + + session.UserDataStream.OnOrderUpdate(s.handleOrderUpdate) + + // session.UserDataStream.OnKLineClosed + session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { + // skip k-lines from other symbols + if kline.Symbol != s.Symbol || kline.Interval != s.Interval { + return + } + + closePrice := kline.Close + + // ok, it's our call, we need to cancel the stop limit order first + s.clear(ctx, orderExecutor) + s.place(ctx, orderExecutor, session, indicator, closePrice) + }) + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + log.Infof("canceling trailingstop order...") + s.clear(ctx, orderExecutor) + }) + + if lastPrice, ok := session.LastPrice(s.Symbol); ok { + s.place(ctx, orderExecutor, session, indicator, lastPrice) + } + + return nil +} + +func (s *Strategy) CrossRun(ctx context.Context, _ qbtrade.OrderExecutionRouter, sessions map[string]*qbtrade.ExchangeSession) error { + // source session + sourceSession := sessions[s.SourceExchangeName] + + // target exchange + session := sessions[s.TargetExchangeName] + orderExecutor := qbtrade.ExchangeOrderExecutor{ + Session: session, + } + + indicator, err := s.loadIndicator(sourceSession) + if err != nil { + return err + } + + session.UserDataStream.OnOrderUpdate(s.handleOrderUpdate) + + // session.UserDataStream.OnKLineClosed + sourceSession.MarketDataStream.OnKLineClosed(func(kline types.KLine) { + // skip k-lines from other symbols + if kline.Symbol != s.Symbol || kline.Interval != s.Interval { + return + } + + closePrice := kline.Close + + // ok, it's our call, we need to cancel the stop limit order first + s.clear(ctx, &orderExecutor) + s.place(ctx, &orderExecutor, session, indicator, closePrice) + }) + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + log.Infof("canceling trailingstop order...") + s.clear(ctx, &orderExecutor) + }) + + if lastPrice, ok := session.LastPrice(s.Symbol); ok { + s.place(ctx, &orderExecutor, session, indicator, lastPrice) + } + + return nil +} diff --git a/pkg/strategy/etf/strategy.go b/pkg/strategy/etf/strategy.go new file mode 100644 index 0000000..48e31fe --- /dev/null +++ b/pkg/strategy/etf/strategy.go @@ -0,0 +1,107 @@ +package etf + +import ( + "context" + "time" + + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ID = "etf" + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + Market types.Market + + TotalAmount fixedpoint.Value `json:"totalAmount,omitempty"` + + // Interval is the period that you want to submit order + Duration types.Duration `json:"duration"` + + Index map[string]fixedpoint.Value `json:"index"` +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { +} + +func (s *Strategy) Validate() error { + if s.TotalAmount.IsZero() { + return errors.New("amount can not be empty") + } + + return nil +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + go func() { + ticker := time.NewTicker(s.Duration.Duration()) + defer ticker.Stop() + + qbtrade.Notify("ETF orders will be executed every %s", s.Duration.Duration().String()) + + for { + select { + case <-ctx.Done(): + return + + case <-ticker.C: + totalAmount := s.TotalAmount + for symbol, ratio := range s.Index { + amount := totalAmount.Mul(ratio) + + ticker, err := session.Exchange.QueryTicker(ctx, symbol) + if err != nil { + qbtrade.Notify("query ticker error: %s", err.Error()) + log.WithError(err).Error("query ticker error") + break + } + + askPrice := ticker.Sell + quantity := askPrice.Div(amount) + + // execute orders + quoteBalance, ok := session.GetAccount().Balance(s.Market.QuoteCurrency) + if !ok { + break + } + if quoteBalance.Available.Compare(amount) < 0 { + qbtrade.Notify("Quote balance %s is not enough: %s < %s", s.Market.QuoteCurrency, quoteBalance.Available.String(), amount.String()) + break + } + + qbtrade.Notify("Submitting etf order %s quantity %s at price %s (index ratio %s)", + symbol, + quantity.String(), + askPrice.String(), + ratio.Percentage()) + _, err = orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ + Symbol: symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeMarket, + Quantity: quantity, + }) + + if err != nil { + log.WithError(err).Error("submit order error") + } + + } + } + } + }() + + return nil +} diff --git a/pkg/strategy/ewoDgtrd/heikinashi.go b/pkg/strategy/ewoDgtrd/heikinashi.go new file mode 100644 index 0000000..d94f482 --- /dev/null +++ b/pkg/strategy/ewoDgtrd/heikinashi.go @@ -0,0 +1,49 @@ +package ewoDgtrd + +import ( + "fmt" + "math" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type HeikinAshi struct { + Close *types.Queue + Open *types.Queue + High *types.Queue + Low *types.Queue + Volume *types.Queue +} + +func NewHeikinAshi(size int) *HeikinAshi { + return &HeikinAshi{ + Close: types.NewQueue(size), + Open: types.NewQueue(size), + High: types.NewQueue(size), + Low: types.NewQueue(size), + Volume: types.NewQueue(size), + } +} + +func (s *HeikinAshi) Print() string { + return fmt.Sprintf("Heikin c: %.3f, o: %.3f, h: %.3f, l: %.3f, v: %.3f", + s.Close.Last(0), + s.Open.Last(0), + s.High.Last(0), + s.Low.Last(0), + s.Volume.Last(0)) +} + +func (inc *HeikinAshi) Update(kline types.KLine) { + open := kline.Open.Float64() + cloze := kline.Close.Float64() + high := kline.High.Float64() + low := kline.Low.Float64() + newClose := (open + high + low + cloze) / 4. + newOpen := (inc.Open.Last(0) + inc.Close.Last(0)) / 2. + inc.Close.Update(newClose) + inc.Open.Update(newOpen) + inc.High.Update(math.Max(math.Max(high, newOpen), newClose)) + inc.Low.Update(math.Min(math.Min(low, newOpen), newClose)) + inc.Volume.Update(kline.Volume.Float64()) +} diff --git a/pkg/strategy/ewoDgtrd/strategy.go b/pkg/strategy/ewoDgtrd/strategy.go new file mode 100644 index 0000000..dc05cc2 --- /dev/null +++ b/pkg/strategy/ewoDgtrd/strategy.go @@ -0,0 +1,1269 @@ +package ewoDgtrd + +import ( + "context" + "errors" + "fmt" + "math" + "os" + "sync" + + "github.com/fatih/color" + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +const ID = "ewo_dgtrd" + +var log = logrus.WithField("strategy", ID) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + // Embedded components + // =================== + *qbtrade.Environment + qbtrade.StrategyController + + // Auto-Injection fields + // ==================== + + // Market of the symbol + Market types.Market + + // Session is the trading session of this strategy + Session *qbtrade.ExchangeSession + + orderExecutor *qbtrade.GeneralOrderExecutor + + // Persistence fields + // ==================== + // Position + Position *types.Position `json:"position,omitempty" persistence:"position"` + + // ProfitStats + ProfitStats *types.ProfitStats `json:"profitStats,omitempty" persistence:"profit_stats"` + + // Settings fields + // ========================= + UseHeikinAshi bool `json:"useHeikinAshi"` // use heikinashi kline + StopLoss fixedpoint.Value `json:"stoploss"` + Symbol string `json:"symbol"` + Interval types.Interval `json:"interval"` + UseEma bool `json:"useEma"` // use exponential ma or not + UseSma bool `json:"useSma"` // if UseEma == false, use simple ma or not + SignalWindow int `json:"sigWin"` // signal window + DisableShortStop bool `json:"disableShortStop"` // disable SL on short + DisableLongStop bool `json:"disableLongStop"` // disable SL on long + FilterHigh float64 `json:"cciStochFilterHigh"` // high filter for CCI Stochastic indicator + FilterLow float64 `json:"cciStochFilterLow"` // low filter for CCI Stochastic indicator + EwoChangeFilterHigh float64 `json:"ewoChangeFilterHigh"` // high filter for ewo histogram + EwoChangeFilterLow float64 `json:"ewoChangeFilterLow"` // low filter for ewo histogram + + Record bool `json:"record"` // print record messages on position exit point + + KLineStartTime types.Time + KLineEndTime types.Time + + entryPrice fixedpoint.Value + waitForTrade bool + + atr *indicator.ATR + emv *indicator.EMV + ccis *CCISTOCH + ma5 types.SeriesExtend + ma34 types.SeriesExtend + ewo types.SeriesExtend + ewoSignal types.SeriesExtend + ewoHistogram types.SeriesExtend + ewoChangeRate float64 + heikinAshi *HeikinAshi + peakPrice fixedpoint.Value + bottomPrice fixedpoint.Value + midPrice fixedpoint.Value + lock sync.RWMutex + + buyPrice fixedpoint.Value + sellPrice fixedpoint.Value +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s", ID, s.Symbol) +} + +func (s *Strategy) Initialize() error { + return nil +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: types.Interval1m}) + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) + + if !qbtrade.IsBackTesting { + session.Subscribe(types.BookTickerChannel, s.Symbol, types.SubscribeOptions{}) + } +} + +// Refer: https://tw.tradingview.com/script/XZyG5SOx-CCI-Stochastic-and-a-quick-lesson-on-Scalping-Trading-Systems/ +type CCISTOCH struct { + cci *indicator.CCI + stoch *indicator.STOCH + ma *indicator.SMA + filterHigh float64 + filterLow float64 +} + +func NewCCISTOCH(i types.Interval, filterHigh, filterLow float64) *CCISTOCH { + cci := &indicator.CCI{IntervalWindow: types.IntervalWindow{Interval: i, Window: 28}} + stoch := &indicator.STOCH{IntervalWindow: types.IntervalWindow{Interval: i, Window: 28}} + ma := &indicator.SMA{IntervalWindow: types.IntervalWindow{Interval: i, Window: 3}} + return &CCISTOCH{ + cci: cci, + stoch: stoch, + ma: ma, + filterHigh: filterHigh, + filterLow: filterLow, + } +} + +func (inc *CCISTOCH) Update(cloze float64) { + inc.cci.Update(cloze) + inc.stoch.Update(inc.cci.Last(0), inc.cci.Last(0), inc.cci.Last(0)) + inc.ma.Update(inc.stoch.LastD()) +} + +func (inc *CCISTOCH) BuySignal() bool { + hasGrey := false + for i := 0; i < inc.ma.Values.Length(); i++ { + v := inc.ma.Index(i) + if v > inc.filterHigh { + return false + } else if v >= inc.filterLow && v <= inc.filterHigh { + hasGrey = true + continue + } else if v < inc.filterLow { + return hasGrey + } + } + return false +} + +func (inc *CCISTOCH) SellSignal() bool { + hasGrey := false + for i := 0; i < inc.ma.Values.Length(); i++ { + v := inc.ma.Index(i) + if v < inc.filterLow { + return false + } else if v >= inc.filterLow && v <= inc.filterHigh { + hasGrey = true + continue + } else if v > inc.filterHigh { + return hasGrey + } + } + return false +} + +type VWEMA struct { + PV types.UpdatableSeries + V types.UpdatableSeries +} + +func (inc *VWEMA) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *VWEMA) Last(i int) float64 { + if i >= inc.PV.Length() { + return 0 + } + vi := inc.V.Last(i) + if vi == 0 { + return 0 + } + return inc.PV.Last(i) / vi +} + +func (inc *VWEMA) Length() int { + pvl := inc.PV.Length() + vl := inc.V.Length() + if pvl < vl { + return pvl + } + return vl +} + +func (inc *VWEMA) Update(kline types.KLine) { + inc.PV.Update(kline.Close.Mul(kline.Volume).Float64()) + inc.V.Update(kline.Volume.Float64()) +} + +func (inc *VWEMA) UpdateVal(price float64, vol float64) { + inc.PV.Update(price * vol) + inc.V.Update(vol) +} + +// Setup the Indicators going to be used +func (s *Strategy) SetupIndicators(store *qbtrade.MarketDataStore) { + window5 := types.IntervalWindow{Interval: s.Interval, Window: 5} + window34 := types.IntervalWindow{Interval: s.Interval, Window: 34} + s.atr = &indicator.ATR{IntervalWindow: window34} + s.emv = &indicator.EMV{IntervalWindow: types.IntervalWindow{Interval: s.Interval, Window: 14}} + s.ccis = NewCCISTOCH(s.Interval, s.FilterHigh, s.FilterLow) + + getSource := func(window types.KLineWindow) types.Series { + if s.UseHeikinAshi { + return s.heikinAshi.Close + } + return window.Close() + } + getVol := func(window types.KLineWindow) types.Series { + if s.UseHeikinAshi { + return s.heikinAshi.Volume + } + return window.Volume() + } + s.heikinAshi = NewHeikinAshi(500) + store.OnKLineWindowUpdate(func(interval types.Interval, window types.KLineWindow) { + if interval == s.atr.Interval { + if s.atr.RMA == nil { + for _, kline := range window { + high := kline.High.Float64() + low := kline.Low.Float64() + cloze := kline.Close.Float64() + vol := kline.Volume.Float64() + s.atr.Update(high, low, cloze) + s.emv.Update(high, low, vol) + } + } else { + kline := window[len(window)-1] + high := kline.High.Float64() + low := kline.Low.Float64() + cloze := kline.Close.Float64() + vol := kline.Volume.Float64() + s.atr.Update(high, low, cloze) + s.emv.Update(high, low, vol) + } + } + if s.Interval != interval { + return + } + if s.heikinAshi.Close.Length() == 0 { + for _, kline := range window { + s.heikinAshi.Update(kline) + s.ccis.Update(getSource(window).Last(0)) + } + } else { + s.heikinAshi.Update(window[len(window)-1]) + s.ccis.Update(getSource(window).Last(0)) + } + }) + if s.UseEma { + ema5 := &indicator.EWMA{IntervalWindow: window5} + ema34 := &indicator.EWMA{IntervalWindow: window34} + store.OnKLineWindowUpdate(func(interval types.Interval, window types.KLineWindow) { + if s.Interval != interval { + return + } + if ema5.Length() == 0 { + closes := types.Reverse(getSource(window)) + for _, cloze := range closes { + ema5.Update(cloze) + ema34.Update(cloze) + } + } else { + cloze := getSource(window).Last(0) + ema5.Update(cloze) + ema34.Update(cloze) + } + + }) + + s.ma5 = ema5 + s.ma34 = ema34 + } else if s.UseSma { + sma5 := &indicator.SMA{IntervalWindow: window5} + sma34 := &indicator.SMA{IntervalWindow: window34} + store.OnKLineWindowUpdate(func(interval types.Interval, window types.KLineWindow) { + if s.Interval != interval { + return + } + if sma5.Length() == 0 { + closes := types.Reverse(getSource(window)) + for _, cloze := range closes { + sma5.Update(cloze) + sma34.Update(cloze) + } + } else { + cloze := getSource(window).Last(0) + sma5.Update(cloze) + sma34.Update(cloze) + } + }) + s.ma5 = sma5 + s.ma34 = sma34 + } else { + evwma5 := &VWEMA{ + PV: &indicator.EWMA{IntervalWindow: window5}, + V: &indicator.EWMA{IntervalWindow: window5}, + } + evwma34 := &VWEMA{ + PV: &indicator.EWMA{IntervalWindow: window34}, + V: &indicator.EWMA{IntervalWindow: window34}, + } + store.OnKLineWindowUpdate(func(interval types.Interval, window types.KLineWindow) { + if s.Interval != interval { + return + } + clozes := getSource(window) + vols := getVol(window) + if evwma5.PV.Length() == 0 { + for i := clozes.Length() - 1; i >= 0; i-- { + price := clozes.Last(i) + vol := vols.Last(i) + evwma5.UpdateVal(price, vol) + evwma34.UpdateVal(price, vol) + } + } else { + price := clozes.Last(0) + vol := vols.Last(0) + evwma5.UpdateVal(price, vol) + evwma34.UpdateVal(price, vol) + } + }) + s.ma5 = types.NewSeries(evwma5) + s.ma34 = types.NewSeries(evwma34) + } + + 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} + store.OnKLineWindowUpdate(func(interval types.Interval, _ types.KLineWindow) { + if interval != s.Interval { + return + } + + if sig.Length() == 0 { + // lazy init + ewoVals := types.Reverse(s.ewo) + for _, ewoValue := range ewoVals { + sig.Update(ewoValue) + } + } else { + sig.Update(s.ewo.Last(0)) + } + }) + s.ewoSignal = sig + } else if s.UseSma { + sig := &indicator.SMA{IntervalWindow: windowSignal} + store.OnKLineWindowUpdate(func(interval types.Interval, _ types.KLineWindow) { + if interval != s.Interval { + return + } + + if sig.Length() == 0 { + // lazy init + ewoVals := s.ewo.Reverse() + for _, ewoValue := range ewoVals { + sig.Update(ewoValue) + } + } else { + sig.Update(s.ewo.Last(0)) + } + }) + s.ewoSignal = sig + } else { + sig := &VWEMA{ + PV: &indicator.EWMA{IntervalWindow: windowSignal}, + V: &indicator.EWMA{IntervalWindow: windowSignal}, + } + store.OnKLineWindowUpdate(func(interval types.Interval, window types.KLineWindow) { + if interval != s.Interval { + return + } + if sig.Length() == 0 { + // lazy init + ewoVals := s.ewo.Reverse() + for i, ewoValue := range ewoVals { + vol := window.Volume().Last(i) + sig.PV.Update(ewoValue * vol) + sig.V.Update(vol) + } + } else { + vol := window.Volume().Last(0) + sig.PV.Update(s.ewo.Last(0) * vol) + sig.V.Update(vol) + } + }) + s.ewoSignal = types.NewSeries(sig) + } +} + +// Utility to evaluate if the order is valid or not to send to the exchange +func (s *Strategy) validateOrder(order *types.SubmitOrder) error { + if order.Type == types.OrderTypeMarket && order.TimeInForce != "" { + return errors.New("wrong field: market vs TimeInForce") + } + if order.Side == types.SideTypeSell { + baseBalance, ok := s.Session.GetAccount().Balance(s.Market.BaseCurrency) + if !ok { + log.Error("cannot get account") + return errors.New("cannot get account") + } + if order.Quantity.Compare(baseBalance.Available) > 0 { + log.Errorf("qty %v > avail %v", order.Quantity, baseBalance.Available) + return errors.New("qty > avail") + } + price := order.Price + if price.IsZero() { + price, ok = s.Session.LastPrice(s.Symbol) + if !ok { + log.Error("no price") + return errors.New("no price") + } + } + orderAmount := order.Quantity.Mul(price) + if order.Quantity.Sign() <= 0 || + order.Quantity.Compare(s.Market.MinQuantity) < 0 || + orderAmount.Compare(s.Market.MinNotional) < 0 { + log.Debug("amount fail") + return fmt.Errorf("amount fail: quantity: %v, amount: %v", order.Quantity, orderAmount) + } + return nil + } else if order.Side == types.SideTypeBuy { + quoteBalance, ok := s.Session.GetAccount().Balance(s.Market.QuoteCurrency) + if !ok { + log.Error("cannot get account") + return errors.New("cannot get account") + } + price := order.Price + if price.IsZero() { + price, ok = s.Session.LastPrice(s.Symbol) + if !ok { + log.Error("no price") + return errors.New("no price") + } + } + totalQuantity := quoteBalance.Available.Div(price) + if order.Quantity.Compare(totalQuantity) > 0 { + log.Errorf("qty %v > avail %v", order.Quantity, totalQuantity) + return errors.New("qty > avail") + } + orderAmount := order.Quantity.Mul(price) + if order.Quantity.Sign() <= 0 || + orderAmount.Compare(s.Market.MinNotional) < 0 || + order.Quantity.Compare(s.Market.MinQuantity) < 0 { + log.Debug("amount fail") + return fmt.Errorf("amount fail: quantity: %v, amount: %v", order.Quantity, orderAmount) + } + return nil + } + log.Error("side error") + return errors.New("side error") + +} + +func (s *Strategy) PlaceBuyOrder(ctx context.Context, price fixedpoint.Value) (*types.Order, *types.Order) { + var closeOrder *types.Order + var ok bool + waitForTrade := false + base := s.Position.GetBase() + if base.Abs().Compare(s.Market.MinQuantity) >= 0 && base.Mul(s.GetLastPrice()).Abs().Compare(s.Market.MinNotional) >= 0 && base.Sign() < 0 { + if closeOrder, ok = s.ClosePosition(ctx); !ok { + log.Errorf("sell position %v remained not closed, skip placing order", base) + return closeOrder, nil + } + } + if s.Position.GetBase().Sign() < 0 { + // we are not able to make close trade at this moment, + // will close the rest of the position by normal limit order + // s.entryPrice is set in the last trade + waitForTrade = true + } + quoteBalance, ok := s.Session.GetAccount().Balance(s.Market.QuoteCurrency) + if !ok { + log.Infof("buy order at price %v failed", price) + return closeOrder, nil + } + quantityAmount := quoteBalance.Available + totalQuantity := quantityAmount.Div(price) + order := types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Price: price, + Quantity: totalQuantity, + Market: s.Market, + TimeInForce: types.TimeInForceGTC, + } + if err := s.validateOrder(&order); err != nil { + log.Infof("validation failed %v: %v", order, err) + return closeOrder, nil + } + log.Warnf("long at %v, position %v, closeOrder %v, timestamp: %s", price, s.Position.GetBase(), closeOrder, s.KLineStartTime) + + createdOrders, err := s.orderExecutor.SubmitOrders(ctx, order) + if err != nil { + log.WithError(err).Errorf("cannot place order") + return closeOrder, nil + } + + log.Infof("post order c: %v, entryPrice: %v o: %v", waitForTrade, s.entryPrice, createdOrders) + s.waitForTrade = waitForTrade + return closeOrder, &createdOrders[0] +} + +func (s *Strategy) PlaceSellOrder(ctx context.Context, price fixedpoint.Value) (*types.Order, *types.Order) { + var closeOrder *types.Order + var ok bool + waitForTrade := false + base := s.Position.GetBase() + if base.Abs().Compare(s.Market.MinQuantity) >= 0 && base.Abs().Mul(s.GetLastPrice()).Compare(s.Market.MinNotional) >= 0 && base.Sign() > 0 { + if closeOrder, ok = s.ClosePosition(ctx); !ok { + log.Errorf("buy position %v remained not closed, skip placing order", base) + return closeOrder, nil + } + } + if s.Position.GetBase().Sign() > 0 { + // we are not able to make close trade at this moment, + // will close the rest of the position by normal limit order + // s.entryPrice is set in the last trade + waitForTrade = true + } + baseBalance, ok := s.Session.GetAccount().Balance(s.Market.BaseCurrency) + if !ok { + return closeOrder, nil + } + order := types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeLimit, + Market: s.Market, + Quantity: baseBalance.Available, + Price: price, + TimeInForce: types.TimeInForceGTC, + } + if err := s.validateOrder(&order); err != nil { + log.Infof("validation failed %v: %v", order, err) + return closeOrder, nil + } + + log.Warnf("short at %v, position %v closeOrder %v, timestamp: %s", price, s.Position.GetBase(), closeOrder, s.KLineStartTime) + createdOrders, err := s.orderExecutor.SubmitOrders(ctx, order) + if err != nil { + log.WithError(err).Errorf("cannot place order") + return closeOrder, nil + } + + log.Infof("post order, c: %v, entryPrice: %v o: %v", waitForTrade, s.entryPrice, createdOrders) + s.waitForTrade = waitForTrade + return closeOrder, &createdOrders[0] +} + +// ClosePosition(context.Context) -> (closeOrder *types.Order, ok bool) +// this will decorate the generated order from NewMarketCloseOrder +// add do necessary checks +// if available quantity is zero, will return (nil, true) +// if any of the checks failed, will return (nil, false) +// otherwise, return the created close order and true +func (s *Strategy) ClosePosition(ctx context.Context) (*types.Order, bool) { + order := s.Position.NewMarketCloseOrder(fixedpoint.One) + // no position exists + if order == nil { + // no base + s.sellPrice = fixedpoint.Zero + s.buyPrice = fixedpoint.Zero + return nil, true + } + order.TimeInForce = "" + // If there's any order not yet been traded in the orderbook, + // we need this additional check to make sure we have enough balance to post a close order + balances := s.Session.GetAccount().Balances() + baseBalance := balances[s.Market.BaseCurrency].Available + if order.Side == types.SideTypeBuy { + price := s.GetLastPrice() + quoteAmount := balances[s.Market.QuoteCurrency].Available.Div(price) + if order.Quantity.Compare(quoteAmount) > 0 { + order.Quantity = quoteAmount + } + } else if order.Side == types.SideTypeSell && order.Quantity.Compare(baseBalance) > 0 { + order.Quantity = baseBalance + } + + // if no available balance... + if order.Quantity.IsZero() { + return nil, true + } + if err := s.validateOrder(order); err != nil { + log.Errorf("cannot place close order %v: %v", order, err) + return nil, false + } + + createdOrders, err := s.orderExecutor.SubmitOrders(ctx, *order) + if err != nil { + log.WithError(err).Errorf("cannot place close order") + return nil, false + } + + log.Infof("close order %v", createdOrders) + return &createdOrders[0], true +} + +func (s *Strategy) CancelAll(ctx context.Context) { + if err := s.orderExecutor.GracefulCancel(ctx); err != nil { + log.WithError(err).Errorf("graceful cancel order error") + } + + s.waitForTrade = false +} + +func (s *Strategy) GetLastPrice() fixedpoint.Value { + var lastPrice fixedpoint.Value + var ok bool + if s.Environment.IsBackTesting() { + lastPrice, ok = s.Session.LastPrice(s.Symbol) + if !ok { + log.Errorf("cannot get last price") + return lastPrice + } + } else { + s.lock.RLock() + if s.midPrice.IsZero() { + lastPrice, ok = s.Session.LastPrice(s.Symbol) + if !ok { + log.Errorf("cannot get last price") + return lastPrice + } + } else { + lastPrice = s.midPrice + } + s.lock.RUnlock() + } + return lastPrice +} + +// Trading Rules: +// - buy / sell the whole asset +// - SL by atr (lastprice < buyprice - atr) || (lastprice > sellprice + atr) +// - TP by detecting if there's a ewo pivotHigh(1,1) -> close long, or pivotLow(1,1) -> close short +// - TP by ma34 +- atr * 2 +// - TP by (lastprice < peak price - atr) || (lastprice > bottom price + atr) +// - SL by s.StopLoss (Abs(price_diff / price) > s.StopLoss) +// - entry condition on ewo(Elliott wave oscillator) Crosses ewoSignal(ma on ewo, signalWindow) +// - buy signal on (crossover on previous K bar and no crossunder on latest K bar) +// - sell signal on (crossunder on previous K bar and no crossunder on latest K bar) +// +// - and filtered by the following rules: +// - buy: buy signal ON, kline Close > Open, Close > ma5, Close > ma34, CCI Stochastic Buy signal +// - sell: sell signal ON, kline Close < Open, Close < ma5, Close < ma34, CCI Stochastic Sell signal +// +// - or entry when ma34 +- atr * 3 gets touched +// - entry price: latestPrice +- atr / 2 (short,long), close at market price +// Cancel non-fully filled orders on new signal (either in same direction or not) +// +// ps: kline might refer to heikinashi or normal ohlc +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + s.buyPrice = fixedpoint.Zero + s.sellPrice = fixedpoint.Zero + s.peakPrice = fixedpoint.Zero + s.bottomPrice = fixedpoint.Zero + + counterTPfromPeak := 0 + percentAvgTPfromPeak := 0.0 + counterTPfromCCI := 0 + percentAvgTPfromCCI := 0.0 + counterTPfromLongShort := 0 + percentAvgTPfromLongShort := 0.0 + counterTPfromAtr := 0 + percentAvgTPfromAtr := 0.0 + counterTPfromOrder := 0 + percentAvgTPfromOrder := 0.0 + counterSLfromSL := 0 + percentAvgSLfromSL := 0.0 + counterSLfromOrder := 0 + percentAvgSLfromOrder := 0.0 + + if s.Position == nil { + s.Position = types.NewPositionFromMarket(s.Market) + } + + if s.ProfitStats == nil { + s.ProfitStats = types.NewProfitStats(s.Market) + } + + instanceID := s.InstanceID() + s.orderExecutor = qbtrade.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, s.Position) + s.orderExecutor.BindEnvironment(s.Environment) + s.orderExecutor.BindProfitStats(s.ProfitStats) + // s.orderExecutor.BindTradeStats(s.TradeStats) + s.orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { + qbtrade.Sync(ctx, s) + }) + s.orderExecutor.Bind() + + s.orderExecutor.TradeCollector().OnTrade(func(trade types.Trade, profit, netprofit fixedpoint.Value) { + // calculate report for the position that cannot be closed by close order (amount too small) + if s.waitForTrade { + price := s.entryPrice + if price.IsZero() { + panic("no price found") + } + pnlRate := trade.Price.Sub(price).Abs().Div(trade.Price).Float64() + if s.Record { + log.Errorf("record avg %v trade %v", price, trade) + } + if trade.Side == types.SideTypeBuy { + if trade.Price.Compare(price) < 0 { + percentAvgTPfromOrder = percentAvgTPfromOrder*float64(counterTPfromOrder) + pnlRate + counterTPfromOrder += 1 + percentAvgTPfromOrder /= float64(counterTPfromOrder) + } else { + percentAvgSLfromOrder = percentAvgSLfromOrder*float64(counterSLfromOrder) + pnlRate + counterSLfromOrder += 1 + percentAvgSLfromOrder /= float64(counterSLfromOrder) + } + } else if trade.Side == types.SideTypeSell { + if trade.Price.Compare(price) > 0 { + percentAvgTPfromOrder = percentAvgTPfromOrder*float64(counterTPfromOrder) + pnlRate + counterTPfromOrder += 1 + percentAvgTPfromOrder /= float64(counterTPfromOrder) + } else { + percentAvgSLfromOrder = percentAvgSLfromOrder*float64(counterSLfromOrder) + pnlRate + counterSLfromOrder += 1 + percentAvgSLfromOrder /= float64(counterSLfromOrder) + } + } else { + panic(fmt.Sprintf("no sell(%v) or buy price(%v), %v", s.sellPrice, s.buyPrice, trade)) + } + s.waitForTrade = false + } + + if s.Position.GetBase().Abs().Compare(s.Market.MinQuantity) >= 0 && s.Position.GetBase().Abs().Mul(trade.Price).Compare(s.Market.MinNotional) >= 0 { + sign := s.Position.GetBase().Sign() + if sign > 0 { + log.Infof("base become positive, %v", trade) + s.buyPrice = s.Position.AverageCost + s.sellPrice = fixedpoint.Zero + s.peakPrice = s.Position.AverageCost + } else if sign == 0 { + panic("not going to happen") + } else { + log.Infof("base become negative, %v", trade) + s.buyPrice = fixedpoint.Zero + s.sellPrice = s.Position.AverageCost + s.bottomPrice = s.Position.AverageCost + } + s.entryPrice = trade.Price + } else { + log.Infof("base become zero, rest of base: %v", s.Position.GetBase()) + if s.Position.GetBase().IsZero() { + s.entryPrice = fixedpoint.Zero + } + s.buyPrice = fixedpoint.Zero + s.sellPrice = fixedpoint.Zero + s.peakPrice = fixedpoint.Zero + s.bottomPrice = fixedpoint.Zero + } + }) + + store, ok := s.Session.MarketDataStore(s.Symbol) + if !ok { + return fmt.Errorf("cannot get marketdatastore of %s", s.Symbol) + } + s.SetupIndicators(store) + + // local peak of ewo + shortSig := s.ewo.Last(0) < s.ewo.Last(1) && s.ewo.Last(1) > s.ewo.Last(2) + longSig := s.ewo.Last(0) > s.ewo.Last(1) && s.ewo.Last(1) < s.ewo.Last(2) + + sellOrderTPSL := func(price fixedpoint.Value) { + lastPrice := s.GetLastPrice() + base := s.Position.GetBase().Abs() + if base.Mul(lastPrice).Compare(s.Market.MinNotional) < 0 || base.Compare(s.Market.MinQuantity) < 0 { + return + } + if s.sellPrice.IsZero() { + return + } + balances := session.GetAccount().Balances() + quoteBalance := balances[s.Market.QuoteCurrency].Available + atr := fixedpoint.NewFromFloat(s.atr.Last(0)) + atrx2 := fixedpoint.NewFromFloat(s.atr.Last(0) * 2) + buyall := false + if s.bottomPrice.IsZero() || s.bottomPrice.Compare(price) > 0 { + s.bottomPrice = price + } + takeProfit := false + bottomBack := s.bottomPrice + spBack := s.sellPrice + reason := -1 + if quoteBalance.Div(lastPrice).Compare(s.Market.MinQuantity) >= 0 && quoteBalance.Compare(s.Market.MinNotional) >= 0 { + base := fixedpoint.NewFromFloat(s.ma34.Last(0)) + // TP + if lastPrice.Compare(s.sellPrice) < 0 && (longSig || + (!atrx2.IsZero() && base.Sub(atrx2).Compare(lastPrice) >= 0)) { + buyall = true + takeProfit = true + + // calculate report + if longSig { + reason = 1 + } else { + reason = 2 + } + + } + if !atr.IsZero() && s.bottomPrice.Add(atr).Compare(lastPrice) <= 0 && + lastPrice.Compare(s.sellPrice) < 0 { + buyall = true + takeProfit = true + reason = 3 + } + + // SL + /*if (!atrx2.IsZero() && s.bottomPrice.Add(atrx2).Compare(lastPrice) <= 0) || + lastPrice.Sub(s.bottomPrice).Div(lastPrice).Compare(s.StopLoss) > 0 { + if lastPrice.Compare(s.sellPrice) < 0 { + takeProfit = true + } + buyall = true + s.bottomPrice = fixedpoint.Zero + }*/ + if !s.DisableShortStop && ((!atr.IsZero() && s.sellPrice.Sub(atr).Compare(lastPrice) >= 0) || + lastPrice.Sub(s.sellPrice).Div(s.sellPrice).Compare(s.StopLoss) > 0) { + buyall = true + reason = 4 + } + } + if buyall { + log.Warnf("buyall TPSL %v %v", s.Position.GetBase(), quoteBalance) + p := s.sellPrice + if order, ok := s.ClosePosition(ctx); order != nil && ok { + if takeProfit { + log.Errorf("takeprofit buy at %v, avg %v, l: %v, atrx2: %v", lastPrice, spBack, bottomBack, atrx2) + } else { + log.Errorf("stoploss buy at %v, avg %v, l: %v, atrx2: %v", lastPrice, spBack, bottomBack, atrx2) + } + + // calculate report + if s.Record { + log.Error("record ba") + } + var pnlRate float64 + if takeProfit { + pnlRate = p.Sub(lastPrice).Div(lastPrice).Float64() + } else { + pnlRate = lastPrice.Sub(p).Div(lastPrice).Float64() + } + switch reason { + case 0: + percentAvgTPfromCCI = percentAvgTPfromCCI*float64(counterTPfromCCI) + pnlRate + counterTPfromCCI += 1 + percentAvgTPfromCCI /= float64(counterTPfromCCI) + case 1: + percentAvgTPfromLongShort = percentAvgTPfromLongShort*float64(counterTPfromLongShort) + pnlRate + counterTPfromLongShort += 1 + percentAvgTPfromLongShort /= float64(counterTPfromLongShort) + case 2: + percentAvgTPfromAtr = percentAvgTPfromAtr*float64(counterTPfromAtr) + pnlRate + counterTPfromAtr += 1 + percentAvgTPfromAtr /= float64(counterTPfromAtr) + case 3: + percentAvgTPfromPeak = percentAvgTPfromPeak*float64(counterTPfromPeak) + pnlRate + counterTPfromPeak += 1 + percentAvgTPfromPeak /= float64(counterTPfromPeak) + case 4: + percentAvgSLfromSL = percentAvgSLfromSL*float64(counterSLfromSL) + pnlRate + counterSLfromSL += 1 + percentAvgSLfromSL /= float64(counterSLfromSL) + + } + } + } + } + buyOrderTPSL := func(price fixedpoint.Value) { + lastPrice := s.GetLastPrice() + base := s.Position.GetBase().Abs() + if base.Mul(lastPrice).Compare(s.Market.MinNotional) < 0 || base.Compare(s.Market.MinQuantity) < 0 { + return + } + if s.buyPrice.IsZero() { + return + } + balances := session.GetAccount().Balances() + baseBalance := balances[s.Market.BaseCurrency].Available + atr := fixedpoint.NewFromFloat(s.atr.Last(0)) + atrx2 := fixedpoint.NewFromFloat(s.atr.Last(0) * 2) + sellall := false + if s.peakPrice.IsZero() || s.peakPrice.Compare(price) < 0 { + s.peakPrice = price + } + takeProfit := false + peakBack := s.peakPrice + bpBack := s.buyPrice + reason := -1 + if baseBalance.Compare(s.Market.MinQuantity) >= 0 && baseBalance.Mul(lastPrice).Compare(s.Market.MinNotional) >= 0 { + // TP + base := fixedpoint.NewFromFloat(s.ma34.Last(0)) + if lastPrice.Compare(s.buyPrice) > 0 && (shortSig || + (!atrx2.IsZero() && base.Add(atrx2).Compare(lastPrice) <= 0)) { + sellall = true + takeProfit = true + + // calculate report + if shortSig { + reason = 1 + } else { + reason = 2 + } + } + if !atr.IsZero() && s.peakPrice.Sub(atr).Compare(lastPrice) >= 0 && + lastPrice.Compare(s.buyPrice) > 0 { + sellall = true + takeProfit = true + reason = 3 + } + + // SL + /*if s.peakPrice.Sub(lastPrice).Div(s.peakPrice).Compare(s.StopLoss) > 0 || + (!atrx2.IsZero() && s.peakPrice.Sub(atrx2).Compare(lastPrice) >= 0) { + if lastPrice.Compare(s.buyPrice) > 0 { + takeProfit = true + } + sellall = true + s.peakPrice = fixedpoint.Zero + }*/ + if !s.DisableLongStop && (s.buyPrice.Sub(lastPrice).Div(s.buyPrice).Compare(s.StopLoss) > 0 || + (!atr.IsZero() && s.buyPrice.Sub(atr).Compare(lastPrice) >= 0)) { + sellall = true + reason = 4 + } + } + + if sellall { + log.Warnf("sellall TPSL %v", s.Position.GetBase()) + p := s.buyPrice + if order, ok := s.ClosePosition(ctx); order != nil && ok { + if takeProfit { + log.Errorf("takeprofit sell at %v, avg %v, h: %v, atrx2: %v", lastPrice, bpBack, peakBack, atrx2) + } else { + log.Errorf("stoploss sell at %v, avg %v, h: %v, atrx2: %v", lastPrice, bpBack, peakBack, atrx2) + } + // calculate report + if s.Record { + log.Error("record sa") + } + var pnlRate float64 + if takeProfit { + pnlRate = lastPrice.Sub(p).Div(p).Float64() + } else { + pnlRate = p.Sub(lastPrice).Div(p).Float64() + } + switch reason { + case 0: + percentAvgTPfromCCI = percentAvgTPfromCCI*float64(counterTPfromCCI) + pnlRate + counterTPfromCCI += 1 + percentAvgTPfromCCI /= float64(counterTPfromCCI) + case 1: + percentAvgTPfromLongShort = percentAvgTPfromLongShort*float64(counterTPfromLongShort) + pnlRate + counterTPfromLongShort += 1 + percentAvgTPfromLongShort /= float64(counterTPfromLongShort) + case 2: + percentAvgTPfromAtr = percentAvgTPfromAtr*float64(counterTPfromAtr) + pnlRate + counterTPfromAtr += 1 + percentAvgTPfromAtr /= float64(counterTPfromAtr) + case 3: + percentAvgTPfromPeak = percentAvgTPfromPeak*float64(counterTPfromPeak) + pnlRate + counterTPfromPeak += 1 + percentAvgTPfromPeak /= float64(counterTPfromPeak) + case 4: + percentAvgSLfromSL = percentAvgSLfromSL*float64(counterSLfromSL) + pnlRate + counterSLfromSL += 1 + percentAvgSLfromSL /= float64(counterSLfromSL) + } + } + } + } + + // set last price by realtime book ticker update + // to trigger TP/SL + session.MarketDataStream.OnBookTickerUpdate(func(ticker types.BookTicker) { + if s.Environment.IsBackTesting() { + return + } + bestBid := ticker.Buy + bestAsk := ticker.Sell + var midPrice fixedpoint.Value + + if util.TryLock(&s.lock) { + if !bestAsk.IsZero() && !bestBid.IsZero() { + s.midPrice = bestAsk.Add(bestBid).Div(types.Two) + } else if !bestAsk.IsZero() { + s.midPrice = bestAsk + } else { + s.midPrice = bestBid + } + midPrice = s.midPrice + s.lock.Unlock() + } + + if !midPrice.IsZero() { + buyOrderTPSL(midPrice) + sellOrderTPSL(midPrice) + // log.Debugf("best bid %v, best ask %v, mid %v", bestBid, bestAsk, midPrice) + } + }) + + getHigh := func(window types.KLineWindow) types.Series { + if s.UseHeikinAshi { + return s.heikinAshi.High + } + return window.High() + } + getLow := func(window types.KLineWindow) types.Series { + if s.UseHeikinAshi { + return s.heikinAshi.Low + } + return window.Low() + } + getClose := func(window types.KLineWindow) types.Series { + if s.UseHeikinAshi { + return s.heikinAshi.Close + } + return window.Close() + } + getOpen := func(window types.KLineWindow) types.Series { + if s.UseHeikinAshi { + return s.heikinAshi.Open + } + return window.Open() + } + + store.OnKLineWindowUpdate(func(interval types.Interval, window types.KLineWindow) { + kline := window[len(window)-1] + s.KLineStartTime = kline.StartTime + s.KLineEndTime = kline.EndTime + + // well, only track prices on 1m + if interval == types.Interval1m { + + if s.Environment.IsBackTesting() { + buyOrderTPSL(kline.High) + sellOrderTPSL(kline.Low) + + } + } + + var lastPrice fixedpoint.Value + var ok bool + if s.Environment.IsBackTesting() { + lastPrice, ok = session.LastPrice(s.Symbol) + if !ok { + log.Errorf("cannot get last price") + return + } + } else { + s.lock.RLock() + if s.midPrice.IsZero() { + lastPrice, ok = session.LastPrice(s.Symbol) + if !ok { + log.Errorf("cannot get last price") + return + } + } else { + lastPrice = s.midPrice + } + s.lock.RUnlock() + } + balances := session.GetAccount().Balances() + baseBalance := balances[s.Market.BaseCurrency].Total() + quoteBalance := balances[s.Market.QuoteCurrency].Total() + atr := fixedpoint.NewFromFloat(s.atr.Last(0)) + if !s.Environment.IsBackTesting() { + log.Infof("Get last price: %v, ewo %f, ewoSig %f, ccis: %f, atr %v, kline: %v, balance[base]: %v balance[quote]: %v", + lastPrice, s.ewo.Last(0), s.ewoSignal.Last(0), s.ccis.ma.Last(0), atr, kline, baseBalance, quoteBalance) + } + + if kline.Interval != s.Interval { + return + } + + priceHighest := types.Highest(getHigh(window), 233) + priceLowest := types.Lowest(getLow(window), 233) + priceChangeRate := (priceHighest - priceLowest) / priceHighest / 14 + ewoHighest := types.Highest(s.ewoHistogram, 233) + + s.ewoChangeRate = math.Abs(s.ewoHistogram.Last(0) / ewoHighest * priceChangeRate) + + longSignal := types.CrossOver(s.ewo, s.ewoSignal) + shortSignal := types.CrossUnder(s.ewo, s.ewoSignal) + + base := s.ma34.Last(0) + sellLine := base + s.atr.Last(0)*3 + buyLine := base - s.atr.Last(0)*3 + clozes := getClose(window) + opens := getOpen(window) + + // get trend flags + bull := clozes.Last(0) > opens.Last(0) + breakThrough := clozes.Last(0) > s.ma5.Last(0) && clozes.Last(0) > s.ma34.Last(0) + breakDown := clozes.Last(0) < s.ma5.Last(0) && clozes.Last(0) < s.ma34.Last(0) + + // kline breakthrough ma5, ma34 trend up, and cci Stochastic bull + IsBull := bull && breakThrough && s.ccis.BuySignal() && s.ewoChangeRate < s.EwoChangeFilterHigh && s.ewoChangeRate > s.EwoChangeFilterLow + // kline downthrough ma5, ma34 trend down, and cci Stochastic bear + IsBear := !bull && breakDown && s.ccis.SellSignal() && s.ewoChangeRate < s.EwoChangeFilterHigh && s.ewoChangeRate > s.EwoChangeFilterLow + + if !s.Environment.IsBackTesting() { + log.Infof("IsBull: %v, bull: %v, longSignal[1]: %v, shortSignal: %v, lastPrice: %v", + IsBull, bull, longSignal.Index(1), shortSignal.Last(), lastPrice) + log.Infof("IsBear: %v, bear: %v, shortSignal[1]: %v, longSignal: %v, lastPrice: %v", + IsBear, !bull, shortSignal.Index(1), longSignal.Last(), lastPrice) + } + + if (longSignal.Index(1) && !shortSignal.Last() && IsBull) || lastPrice.Float64() <= buyLine { + price := lastPrice.Sub(atr.Div(types.Two)) + // if total asset (including locked) could be used to buy + if quoteBalance.Div(price).Compare(s.Market.MinQuantity) >= 0 && quoteBalance.Compare(s.Market.MinNotional) >= 0 { + // cancel all orders to release lock + s.CancelAll(ctx) + + // backup, since the s.sellPrice will be cleared when doing ClosePosition + sellPrice := s.sellPrice + log.Errorf("ewoChangeRate %v, emv %v", s.ewoChangeRate, s.emv.Last(0)) + + // calculate report + if closeOrder, _ := s.PlaceBuyOrder(ctx, price); closeOrder != nil { + if s.Record { + log.Error("record l") + } + if !sellPrice.IsZero() { + if lastPrice.Compare(sellPrice) > 0 { + pnlRate := lastPrice.Sub(sellPrice).Div(lastPrice).Float64() + percentAvgTPfromOrder = percentAvgTPfromOrder*float64(counterTPfromOrder) + pnlRate + counterTPfromOrder += 1 + percentAvgTPfromOrder /= float64(counterTPfromOrder) + } else { + pnlRate := sellPrice.Sub(lastPrice).Div(lastPrice).Float64() + percentAvgSLfromOrder = percentAvgSLfromOrder*float64(counterSLfromOrder) + pnlRate + counterSLfromOrder += 1 + percentAvgSLfromOrder /= float64(counterSLfromOrder) + } + } else { + panic("no sell price") + } + } + } + } + if (shortSignal.Index(1) && !longSignal.Last() && IsBear) || lastPrice.Float64() >= sellLine { + price := lastPrice.Add(atr.Div(types.Two)) + // if total asset (including locked) could be used to sell + if baseBalance.Mul(price).Compare(s.Market.MinNotional) >= 0 && baseBalance.Compare(s.Market.MinQuantity) >= 0 { + // cancel all orders to release lock + s.CancelAll(ctx) + + // backup, since the s.buyPrice will be cleared when doing ClosePosition + buyPrice := s.buyPrice + log.Errorf("ewoChangeRate: %v, emv %v", s.ewoChangeRate, s.emv.Last(0)) + + // calculate report + if closeOrder, _ := s.PlaceSellOrder(ctx, price); closeOrder != nil { + if s.Record { + log.Error("record s") + } + if !buyPrice.IsZero() { + if lastPrice.Compare(buyPrice) > 0 { + pnlRate := lastPrice.Sub(buyPrice).Div(buyPrice).Float64() + percentAvgTPfromOrder = percentAvgTPfromOrder*float64(counterTPfromOrder) + pnlRate + counterTPfromOrder += 1 + percentAvgTPfromOrder /= float64(counterTPfromOrder) + } else { + pnlRate := buyPrice.Sub(lastPrice).Div(buyPrice).Float64() + percentAvgSLfromOrder = percentAvgSLfromOrder*float64(counterSLfromOrder) + pnlRate + counterSLfromOrder += 1 + percentAvgSLfromOrder /= float64(counterSLfromOrder) + } + } else { + panic("no buy price") + } + } + } + } + }) + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + log.Infof("canceling active orders...") + + _ = s.orderExecutor.GracefulCancel(ctx) + + hiblue := color.New(color.FgHiBlue).FprintfFunc() + blue := color.New(color.FgBlue).FprintfFunc() + hiyellow := color.New(color.FgHiYellow).FprintfFunc() + hiblue(os.Stderr, "---- Trade Report (Without Fee) ----\n") + hiblue(os.Stderr, "TP:\n") + blue(os.Stderr, "\tpeak / bottom with atr: %d, avg pnl rate: %f\n", counterTPfromPeak, percentAvgTPfromPeak) + blue(os.Stderr, "\tCCI Stochastic: %d, avg pnl rate: %f\n", counterTPfromCCI, percentAvgTPfromCCI) + blue(os.Stderr, "\tLongSignal/ShortSignal: %d, avg pnl rate: %f\n", counterTPfromLongShort, percentAvgTPfromLongShort) + blue(os.Stderr, "\tma34 and Atrx2: %d, avg pnl rate: %f\n", counterTPfromAtr, percentAvgTPfromAtr) + blue(os.Stderr, "\tActive Order: %d, avg pnl rate: %f\n", counterTPfromOrder, percentAvgTPfromOrder) + + totalTP := counterTPfromPeak + counterTPfromCCI + counterTPfromLongShort + counterTPfromAtr + counterTPfromOrder + avgProfit := (float64(counterTPfromPeak)*percentAvgTPfromPeak + + float64(counterTPfromCCI)*percentAvgTPfromCCI + + float64(counterTPfromLongShort)*percentAvgTPfromLongShort + + float64(counterTPfromAtr)*percentAvgTPfromAtr + + float64(counterTPfromOrder)*percentAvgTPfromOrder) / float64(totalTP) + hiblue(os.Stderr, "\tSum: %d, avg pnl rate: %f\n", totalTP, avgProfit) + + hiblue(os.Stderr, "SL:\n") + blue(os.Stderr, "\tentry SL: %d, avg pnl rate: -%f\n", counterSLfromSL, percentAvgSLfromSL) + blue(os.Stderr, "\tActive Order: %d, avg pnl rate: -%f\n", counterSLfromOrder, percentAvgSLfromOrder) + + totalSL := counterSLfromSL + counterSLfromOrder + avgLoss := (float64(counterSLfromSL)*percentAvgSLfromSL + float64(counterSLfromOrder)*percentAvgSLfromOrder) / float64(totalSL) + hiblue(os.Stderr, "\tSum: %d, avg pnl rate: -%f\n", totalSL, avgLoss) + + hiblue(os.Stderr, "WinRate: %f\n", float64(totalTP)/float64(totalTP+totalSL)) + + maString := "vwema" + if s.UseSma { + maString = "sma" + } + if s.UseEma { + maString = "ema" + } + + hiyellow(os.Stderr, "----- EWO Settings -------\n") + hiyellow(os.Stderr, "General:\n") + hiyellow(os.Stderr, "\tuseHeikinAshi: %v\n", s.UseHeikinAshi) + hiyellow(os.Stderr, "\tstoploss: %v\n", s.StopLoss) + hiyellow(os.Stderr, "\tsymbol: %s\n", s.Symbol) + hiyellow(os.Stderr, "\tinterval: %s\n", s.Interval) + hiyellow(os.Stderr, "\tMA type: %s\n", maString) + hiyellow(os.Stderr, "\tdisableShortStop: %v\n", s.DisableShortStop) + hiyellow(os.Stderr, "\tdisableLongStop: %v\n", s.DisableLongStop) + hiyellow(os.Stderr, "\trecord: %v\n", s.Record) + hiyellow(os.Stderr, "CCI Stochastic:\n") + hiyellow(os.Stderr, "\tccistochFilterHigh: %f\n", s.FilterHigh) + hiyellow(os.Stderr, "\tccistochFilterLow: %f\n", s.FilterLow) + hiyellow(os.Stderr, "Ewo && Ewo Histogram:\n") + hiyellow(os.Stderr, "\tsigWin: %d\n", s.SignalWindow) + hiyellow(os.Stderr, "\tewoChngFilterHigh: %f\n", s.EwoChangeFilterHigh) + hiyellow(os.Stderr, "\tewoChngFilterLow: %f\n", s.EwoChangeFilterLow) + }) + return nil +} diff --git a/pkg/strategy/factorzoo/factors/mom_callbacks.go b/pkg/strategy/factorzoo/factors/mom_callbacks.go new file mode 100644 index 0000000..055aa51 --- /dev/null +++ b/pkg/strategy/factorzoo/factors/mom_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type MOM"; DO NOT EDIT. + +package factorzoo + +import () + +func (inc *MOM) OnUpdate(cb func(val float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *MOM) EmitUpdate(val float64) { + for _, cb := range inc.UpdateCallbacks { + cb(val) + } +} diff --git a/pkg/strategy/factorzoo/factors/momentum.go b/pkg/strategy/factorzoo/factors/momentum.go new file mode 100644 index 0000000..5c025d8 --- /dev/null +++ b/pkg/strategy/factorzoo/factors/momentum.go @@ -0,0 +1,105 @@ +package factorzoo + +import ( + "fmt" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// gap jump momentum +// if the gap between current open price and previous close price gets larger +// meaning an opening price jump was happened, the larger momentum we get is our alpha, MOM + +//go:generate callbackgen -type MOM +type MOM struct { + types.SeriesBase + types.IntervalWindow + + // Values + Values floats.Slice + LastValue float64 + + opens *types.Queue + closes *types.Queue + + EndTime time.Time + + UpdateCallbacks []func(val float64) +} + +func (inc *MOM) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *MOM) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *MOM) Length() int { + return inc.Values.Length() +} + +// var _ types.SeriesExtend = &MOM{} + +func (inc *MOM) Update(open, close float64) { + if inc.SeriesBase.Series == nil { + inc.SeriesBase.Series = inc + inc.opens = types.NewQueue(inc.Window) + inc.closes = types.NewQueue(inc.Window + 1) + } + inc.opens.Update(open) + inc.closes.Update(close) + if inc.opens.Length() >= inc.Window && inc.closes.Length() >= inc.Window { + gap := inc.opens.Last(0)/inc.closes.Index(1) - 1 + inc.Values.Push(gap) + } +} + +func (inc *MOM) CalculateAndUpdate(allKLines []types.KLine) { + if len(inc.Values) == 0 { + for _, k := range allKLines { + inc.PushK(k) + } + inc.EmitUpdate(inc.Last(0)) + } else { + k := allKLines[len(allKLines)-1] + inc.PushK(k) + inc.EmitUpdate(inc.Last(0)) + } +} + +func (inc *MOM) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *MOM) Bind(updater indicator.KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} + +func (inc *MOM) PushK(k types.KLine) { + if inc.EndTime != zeroTime && k.EndTime.Before(inc.EndTime) { + return + } + + inc.Update(k.Open.Float64(), k.Close.Float64()) + inc.EndTime = k.EndTime.Time() + inc.EmitUpdate(inc.Last(0)) +} + +func calculateMomentum(klines []types.KLine, window int, valA KLineValueMapper, valB KLineValueMapper) (float64, error) { + length := len(klines) + if length == 0 || length < window { + return 0.0, fmt.Errorf("insufficient elements for calculating VOL with window = %d", window) + } + + momentum := (1 - valA(klines[length-1])/valB(klines[length-1])) * -1 + + return momentum, nil +} diff --git a/pkg/strategy/factorzoo/factors/pmr_callbacks.go b/pkg/strategy/factorzoo/factors/pmr_callbacks.go new file mode 100644 index 0000000..a90c99a --- /dev/null +++ b/pkg/strategy/factorzoo/factors/pmr_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type PMR"; DO NOT EDIT. + +package factorzoo + +import () + +func (inc *PMR) OnUpdate(cb func(value float64)) { + inc.updateCallbacks = append(inc.updateCallbacks, cb) +} + +func (inc *PMR) EmitUpdate(value float64) { + for _, cb := range inc.updateCallbacks { + cb(value) + } +} diff --git a/pkg/strategy/factorzoo/factors/price_mean_reversion.go b/pkg/strategy/factorzoo/factors/price_mean_reversion.go new file mode 100644 index 0000000..e5fb7d0 --- /dev/null +++ b/pkg/strategy/factorzoo/factors/price_mean_reversion.go @@ -0,0 +1,102 @@ +package factorzoo + +import ( + "time" + + "gonum.org/v1/gonum/stat" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// price mean reversion +// assume that the quotient of SMA over close price will dynamically revert into one. +// so this fraction value is our alpha, PMR + +//go:generate callbackgen -type PMR +type PMR struct { + types.IntervalWindow + types.SeriesBase + + Values floats.Slice + SMA *indicator.SMA + EndTime time.Time + + updateCallbacks []func(value float64) +} + +var _ types.SeriesExtend = &PMR{} + +func (inc *PMR) Update(price float64) { + if inc.SeriesBase.Series == nil { + inc.SeriesBase.Series = inc + inc.SMA = &indicator.SMA{IntervalWindow: inc.IntervalWindow} + } + inc.SMA.Update(price) + if inc.SMA.Length() >= inc.Window { + reversion := inc.SMA.Last(0) / price + inc.Values.Push(reversion) + } +} + +func (inc *PMR) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *PMR) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *PMR) Length() int { + return len(inc.Values) +} + +func (inc *PMR) CalculateAndUpdate(allKLines []types.KLine) { + if len(inc.Values) == 0 { + for _, k := range allKLines { + inc.PushK(k) + } + inc.EmitUpdate(inc.Last(0)) + } else { + k := allKLines[len(allKLines)-1] + inc.PushK(k) + inc.EmitUpdate(inc.Last(0)) + } +} + +func (inc *PMR) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *PMR) Bind(updater indicator.KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} + +func (inc *PMR) PushK(k types.KLine) { + if inc.EndTime != zeroTime && k.EndTime.Before(inc.EndTime) { + return + } + + inc.Update(types.KLineClosePriceMapper(k)) + inc.EndTime = k.EndTime.Time() + inc.EmitUpdate(inc.Last(0)) +} + +func CalculateKLinesPMR(allKLines []types.KLine, window int) float64 { + return pmr(types.MapKLinePrice(allKLines, types.KLineClosePriceMapper), window) +} + +func pmr(prices []float64, window int) float64 { + var end = len(prices) - 1 + if end == 0 { + return prices[0] + } + + reversion := -stat.Mean(prices[end-window:end], nil) / prices[end] + return reversion +} diff --git a/pkg/strategy/factorzoo/factors/price_volume_divergence.go b/pkg/strategy/factorzoo/factors/price_volume_divergence.go new file mode 100644 index 0000000..271d764 --- /dev/null +++ b/pkg/strategy/factorzoo/factors/price_volume_divergence.go @@ -0,0 +1,109 @@ +package factorzoo + +import ( + "time" + + "gonum.org/v1/gonum/stat" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// price volume divergence +// if the correlation of two time series gets smaller, they are diverging. +// so the negative value of the correlation of close price and volume is our alpha, PVD + +var zeroTime time.Time + +type KLineValueMapper func(k types.KLine) float64 + +//go:generate callbackgen -type PVD +type PVD struct { + types.IntervalWindow + types.SeriesBase + + Values floats.Slice + Prices *types.Queue + Volumes *types.Queue + EndTime time.Time + + updateCallbacks []func(value float64) +} + +var _ types.SeriesExtend = &PVD{} + +func (inc *PVD) Update(price float64, volume float64) { + if inc.SeriesBase.Series == nil { + inc.SeriesBase.Series = inc + inc.Prices = types.NewQueue(inc.Window) + inc.Volumes = types.NewQueue(inc.Window) + } + inc.Prices.Update(price) + inc.Volumes.Update(volume) + if inc.Prices.Length() >= inc.Window && inc.Volumes.Length() >= inc.Window { + divergence := -types.Correlation(inc.Prices, inc.Volumes, inc.Window) + inc.Values.Push(divergence) + } +} + +func (inc *PVD) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *PVD) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *PVD) Length() int { + return len(inc.Values) +} + +func (inc *PVD) CalculateAndUpdate(allKLines []types.KLine) { + if len(inc.Values) == 0 { + for _, k := range allKLines { + inc.PushK(k) + } + inc.EmitUpdate(inc.Last(0)) + } else { + k := allKLines[len(allKLines)-1] + inc.PushK(k) + inc.EmitUpdate(inc.Last(0)) + } +} + +func (inc *PVD) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *PVD) Bind(updater indicator.KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} + +func (inc *PVD) PushK(k types.KLine) { + if inc.EndTime != zeroTime && k.EndTime.Before(inc.EndTime) { + return + } + + inc.Update(types.KLineClosePriceMapper(k), types.KLineVolumeMapper(k)) + inc.EndTime = k.EndTime.Time() + inc.EmitUpdate(inc.Last(0)) +} + +func CalculateKLinesPVD(allKLines []types.KLine, window int) float64 { + return pvd(types.MapKLinePrice(allKLines, types.KLineClosePriceMapper), types.MapKLinePrice(allKLines, types.KLineVolumeMapper), window) +} + +func pvd(prices []float64, volumes []float64, window int) float64 { + var end = len(prices) - 1 + if end == 0 { + return prices[0] + } + + divergence := -stat.Correlation(prices[end-window:end], volumes[end-window:end], nil) + return divergence +} diff --git a/pkg/strategy/factorzoo/factors/pvd_callbacks.go b/pkg/strategy/factorzoo/factors/pvd_callbacks.go new file mode 100644 index 0000000..f8dead4 --- /dev/null +++ b/pkg/strategy/factorzoo/factors/pvd_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type PVD"; DO NOT EDIT. + +package factorzoo + +import () + +func (inc *PVD) OnUpdate(cb func(value float64)) { + inc.updateCallbacks = append(inc.updateCallbacks, cb) +} + +func (inc *PVD) EmitUpdate(value float64) { + for _, cb := range inc.updateCallbacks { + cb(value) + } +} diff --git a/pkg/strategy/factorzoo/factors/return_rate.go b/pkg/strategy/factorzoo/factors/return_rate.go new file mode 100644 index 0000000..5b0b470 --- /dev/null +++ b/pkg/strategy/factorzoo/factors/return_rate.go @@ -0,0 +1,105 @@ +package factorzoo + +import ( + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// simply internal return rate over certain timeframe(interval) + +//go:generate callbackgen -type RR +type RR struct { + types.IntervalWindow + types.SeriesBase + + prices *types.Queue + Values floats.Slice + EndTime time.Time + + updateCallbacks []func(value float64) +} + +var _ types.SeriesExtend = &RR{} + +func (inc *RR) Update(price float64) { + if inc.SeriesBase.Series == nil { + inc.SeriesBase.Series = inc + inc.prices = types.NewQueue(inc.Window) + } + inc.prices.Update(price) + irr := inc.prices.Last(0)/inc.prices.Index(1) - 1 + inc.Values.Push(irr) + +} + +func (inc *RR) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *RR) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *RR) Length() int { + return len(inc.Values) +} + +func (inc *RR) CalculateAndUpdate(allKLines []types.KLine) { + if len(inc.Values) == 0 { + for _, k := range allKLines { + inc.PushK(k) + } + inc.EmitUpdate(inc.Last(0)) + } else { + k := allKLines[len(allKLines)-1] + inc.PushK(k) + inc.EmitUpdate(inc.Last(0)) + } +} + +func (inc *RR) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *RR) Bind(updater indicator.KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} + +func (inc *RR) BindK(target indicator.KLineClosedEmitter, symbol string, interval types.Interval) { + target.OnKLineClosed(types.KLineWith(symbol, interval, inc.PushK)) +} + +func (inc *RR) PushK(k types.KLine) { + if inc.EndTime != zeroTime && k.EndTime.Before(inc.EndTime) { + return + } + + inc.Update(types.KLineClosePriceMapper(k)) + inc.EndTime = k.EndTime.Time() + inc.EmitUpdate(inc.Last(0)) +} + +func (inc *RR) LoadK(allKLines []types.KLine) { + for _, k := range allKLines { + inc.PushK(k) + } + inc.EmitUpdate(inc.Last(0)) +} + +// func calculateReturn(klines []types.KLine, window int, val KLineValueMapper) (float64, error) { +// length := len(klines) +// if length == 0 || length < window { +// return 0.0, fmt.Errorf("insufficient elements for calculating VOL with window = %d", window) +// } +// +// rate := val(klines[length-1])/val(klines[length-2]) - 1 +// +// return rate, nil +// } diff --git a/pkg/strategy/factorzoo/factors/rr_callbacks.go b/pkg/strategy/factorzoo/factors/rr_callbacks.go new file mode 100644 index 0000000..301837d --- /dev/null +++ b/pkg/strategy/factorzoo/factors/rr_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type RR"; DO NOT EDIT. + +package factorzoo + +import () + +func (inc *RR) OnUpdate(cb func(value float64)) { + inc.updateCallbacks = append(inc.updateCallbacks, cb) +} + +func (inc *RR) EmitUpdate(value float64) { + for _, cb := range inc.updateCallbacks { + cb(value) + } +} diff --git a/pkg/strategy/factorzoo/factors/vmom_callbacks.go b/pkg/strategy/factorzoo/factors/vmom_callbacks.go new file mode 100644 index 0000000..9ef858e --- /dev/null +++ b/pkg/strategy/factorzoo/factors/vmom_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type VMOM"; DO NOT EDIT. + +package factorzoo + +import () + +func (inc *VMOM) OnUpdate(cb func(val float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *VMOM) EmitUpdate(val float64) { + for _, cb := range inc.UpdateCallbacks { + cb(val) + } +} diff --git a/pkg/strategy/factorzoo/factors/volume_momentum.go b/pkg/strategy/factorzoo/factors/volume_momentum.go new file mode 100644 index 0000000..790d1cd --- /dev/null +++ b/pkg/strategy/factorzoo/factors/volume_momentum.go @@ -0,0 +1,107 @@ +package factorzoo + +import ( + "fmt" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// quarterly volume momentum +// assume that the quotient of volume SMA over latest volume will dynamically revert into one. +// so this fraction value is our alpha, PMR + +//go:generate callbackgen -type VMOM +type VMOM struct { + types.SeriesBase + types.IntervalWindow + + // Values + Values floats.Slice + LastValue float64 + + volumes *types.Queue + + EndTime time.Time + + UpdateCallbacks []func(val float64) +} + +func (inc *VMOM) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *VMOM) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *VMOM) Length() int { + return inc.Values.Length() +} + +var _ types.SeriesExtend = &VMOM{} + +func (inc *VMOM) Update(volume float64) { + if inc.SeriesBase.Series == nil { + inc.SeriesBase.Series = inc + inc.volumes = types.NewQueue(inc.Window) + } + inc.volumes.Update(volume) + if inc.volumes.Length() >= inc.Window { + v := inc.volumes.Last(0) / inc.volumes.Mean() + inc.Values.Push(v) + } +} + +func (inc *VMOM) CalculateAndUpdate(allKLines []types.KLine) { + if len(inc.Values) == 0 { + for _, k := range allKLines { + inc.PushK(k) + } + inc.EmitUpdate(inc.Last(0)) + } else { + k := allKLines[len(allKLines)-1] + inc.PushK(k) + inc.EmitUpdate(inc.Last(0)) + } +} + +func (inc *VMOM) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *VMOM) Bind(updater indicator.KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} + +func (inc *VMOM) PushK(k types.KLine) { + if inc.EndTime != zeroTime && k.EndTime.Before(inc.EndTime) { + return + } + + inc.Update(k.Volume.Float64()) + inc.EndTime = k.EndTime.Time() + inc.EmitUpdate(inc.Last(0)) +} + +func calculateVolumeMomentum(klines []types.KLine, window int, valV KLineValueMapper, valP KLineValueMapper) (float64, error) { + length := len(klines) + if length == 0 || length < window { + return 0.0, fmt.Errorf("insufficient elements for calculating VOL with window = %d", window) + } + + vma := 0. + for _, p := range klines[length-window : length-1] { + vma += valV(p) + } + vma /= float64(window) + momentum := valV(klines[length-1]) / vma // * (valP(klines[length-1-2]) / valP(klines[length-1])) + + return momentum, nil +} diff --git a/pkg/strategy/factorzoo/linear_regression.go b/pkg/strategy/factorzoo/linear_regression.go new file mode 100644 index 0000000..b26de4d --- /dev/null +++ b/pkg/strategy/factorzoo/linear_regression.go @@ -0,0 +1,194 @@ +package factorzoo + +import ( + "context" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/factorzoo/factors" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type Linear struct { + Symbol string + Market types.Market `json:"-"` + types.IntervalWindow + + // MarketOrder is the option to enable market order short. + MarketOrder bool `json:"marketOrder"` + + Quantity fixedpoint.Value `json:"quantity"` + StopEMARange fixedpoint.Value `json:"stopEMARange"` + StopEMA *types.IntervalWindow `json:"stopEMA"` + + // Xs (input), factors & indicators + divergence *factorzoo.PVD // price volume divergence + reversion *factorzoo.PMR // price mean reversion + momentum *factorzoo.MOM // price momentum from paper, alpha 101 + drift *indicator.Drift // GBM + volume *factorzoo.VMOM // quarterly volume momentum + + // Y (output), internal rate of return + irr *factorzoo.RR + + orderExecutor *qbtrade.GeneralOrderExecutor + session *qbtrade.ExchangeSession + activeOrders *qbtrade.ActiveOrderBook + + qbtrade.QuantityOrAmount +} + +func (s *Linear) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) +} + +func (s *Linear) Bind(session *qbtrade.ExchangeSession, orderExecutor *qbtrade.GeneralOrderExecutor) { + s.session = session + s.orderExecutor = orderExecutor + + position := orderExecutor.Position() + symbol := position.Symbol + store, _ := session.MarketDataStore(symbol) + + // initialize factor indicators + s.divergence = &factorzoo.PVD{IntervalWindow: types.IntervalWindow{Window: 60, Interval: s.Interval}} + s.divergence.Bind(store) + s.reversion = &factorzoo.PMR{IntervalWindow: types.IntervalWindow{Window: 60, Interval: s.Interval}} + s.reversion.Bind(store) + s.drift = &indicator.Drift{IntervalWindow: types.IntervalWindow{Window: 7, Interval: s.Interval}} + s.drift.Bind(store) + s.momentum = &factorzoo.MOM{IntervalWindow: types.IntervalWindow{Window: 1, Interval: s.Interval}} + s.momentum.Bind(store) + s.volume = &factorzoo.VMOM{IntervalWindow: types.IntervalWindow{Window: 90, Interval: s.Interval}} + s.volume.Bind(store) + + s.irr = &factorzoo.RR{IntervalWindow: types.IntervalWindow{Window: 2, Interval: s.Interval}} + s.irr.Bind(store) + + predLst := types.NewQueue(s.Window) + session.MarketDataStream.OnKLineClosed(types.KLineWith(symbol, s.Interval, func(kline types.KLine) { + + ctx := context.Background() + + // graceful cancel all active orders + _ = orderExecutor.GracefulCancel(ctx) + + // take past window days' values to predict future return + // (e.g., 5 here in default configuration file) + a := []floats.Slice{ + s.divergence.Values[len(s.divergence.Values)-s.Window-2 : len(s.divergence.Values)-2], + s.reversion.Values[len(s.reversion.Values)-s.Window-2 : len(s.reversion.Values)-2], + s.drift.Values[len(s.drift.Values)-s.Window-2 : len(s.drift.Values)-2], + s.momentum.Values[len(s.momentum.Values)-s.Window-2 : len(s.momentum.Values)-2], + s.volume.Values[len(s.volume.Values)-s.Window-2 : len(s.volume.Values)-2], + } + // e.g., s.window is 5 + // factors array from day -4 to day 0, [[0.1, 0.2, 0.35, 0.3 , 0.25], [1.1, -0.2, 1.35, -0.3 , -0.25], ...] + // the binary(+/-) daily return rate from day -3 to day 1, [0, 1, 1, 0, 0] + // then we take the latest available factors array into linear regression model + b := []floats.Slice{filter(s.irr.Values[len(s.irr.Values)-s.Window-1:len(s.irr.Values)-1], binary)} + var x []types.Series + var y []types.Series + + x = append(x, &a[0]) + x = append(x, &a[1]) + x = append(x, &a[2]) + x = append(x, &a[3]) + x = append(x, &a[4]) + //x = append(x, &a[5]) + + y = append(y, &b[0]) + model := types.LogisticRegression(x, y[0], s.Window, 8000, 0.0001) + + // use the last value from indicators, or the SeriesExtends' predict function. (e.g., look back: 5) + input := []float64{ + s.divergence.Last(0), + s.reversion.Last(0), + s.drift.Last(0), + s.momentum.Last(0), + s.volume.Last(0), + } + pred := model.Predict(input) + predLst.Update(pred) + + qty := s.Quantity //s.QuantityOrAmount.CalculateQuantity(kline.Close) + + // the scale of pred is from 0.0 to 1.0 + // 0.5 can be used as the threshold + // we use the time-series rolling prediction values here + if pred > predLst.Mean() { + if position.IsShort() { + s.ClosePosition(ctx, one) + s.placeMarketOrder(ctx, types.SideTypeBuy, qty, symbol) + } else if position.IsClosed() { + s.placeMarketOrder(ctx, types.SideTypeBuy, qty, symbol) + } + } else if pred < predLst.Mean() { + if position.IsLong() { + s.ClosePosition(ctx, one) + s.placeMarketOrder(ctx, types.SideTypeSell, qty, symbol) + } else if position.IsClosed() { + s.placeMarketOrder(ctx, types.SideTypeSell, qty, symbol) + } + } + // pass if position is opened and not dust, and remain the same direction with alpha signal + + // alpha-weighted inventory and cash + //alpha := fixedpoint.NewFromFloat(s.r1.Last()) + //targetBase := s.QuantityOrAmount.CalculateQuantity(kline.Close).Mul(alpha) + ////s.ClosePosition(ctx, one) + //diffQty := targetBase.Sub(position.Base) + //log.Info(alpha.Float64(), position.Base, diffQty.Float64()) + // + //if diffQty.Sign() > 0 { + // s.placeMarketOrder(ctx, types.SideTypeBuy, diffQty.Abs(), symbol) + //} else if diffQty.Sign() < 0 { + // s.placeMarketOrder(ctx, types.SideTypeSell, diffQty.Abs(), symbol) + //} + })) + + if !qbtrade.IsBackTesting { + session.MarketDataStream.OnMarketTrade(func(trade types.Trade) { + }) + } +} + +func (s *Linear) ClosePosition(ctx context.Context, percentage fixedpoint.Value) error { + return s.orderExecutor.ClosePosition(ctx, percentage) +} + +func (s *Linear) placeMarketOrder(ctx context.Context, side types.SideType, quantity fixedpoint.Value, symbol string) { + market, _ := s.session.Market(symbol) + _, err := s.orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ + Symbol: symbol, + Market: market, + Side: side, + Type: types.OrderTypeMarket, + Quantity: quantity, + //TimeInForce: types.TimeInForceGTC, + Tag: "linear", + }) + if err != nil { + log.WithError(err).Errorf("can not place market order") + } +} + +func binary(val float64) float64 { + if val > 0. { + return 1. + } else { + return 0. + } +} + +func filter(data []float64, f func(float64) float64) []float64 { + fltd := make([]float64, 0) + for _, e := range data { + //if f(e) >= 0. { + fltd = append(fltd, f(e)) + //} + } + return fltd +} diff --git a/pkg/strategy/factorzoo/strategy.go b/pkg/strategy/factorzoo/strategy.go new file mode 100644 index 0000000..6e234f1 --- /dev/null +++ b/pkg/strategy/factorzoo/strategy.go @@ -0,0 +1,133 @@ +package factorzoo + +import ( + "context" + "fmt" + "os" + "sync" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ID = "factorzoo" + +var one = fixedpoint.One +var zero = fixedpoint.Zero + +var log = logrus.WithField("strategy", ID) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type IntervalWindowSetting struct { + types.IntervalWindow +} + +type Strategy struct { + Environment *qbtrade.Environment + Symbol string `json:"symbol"` + Market types.Market + + types.IntervalWindow + + // persistence fields + Position *types.Position `persistence:"position"` + ProfitStats *types.ProfitStats `persistence:"profit_stats"` + TradeStats *types.TradeStats `persistence:"trade_stats"` + + activeOrders *qbtrade.ActiveOrderBook + + Linear *Linear `json:"linear"` + + ExitMethods qbtrade.ExitMethodSet `json:"exits"` + + session *qbtrade.ExchangeSession + orderExecutor *qbtrade.GeneralOrderExecutor + + // StrategyController + qbtrade.StrategyController +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Linear.Interval}) + + if !qbtrade.IsBackTesting { + session.Subscribe(types.MarketTradeChannel, s.Symbol, types.SubscribeOptions{}) + } + + s.ExitMethods.SetAndSubscribe(session, s) +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s", ID, s.Symbol) +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + var instanceID = s.InstanceID() + + 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) + } + + // StrategyController + s.Status = types.StrategyStatusRunning + + s.OnSuspend(func() { + // Cancel active orders + _ = s.orderExecutor.GracefulCancel(ctx) + }) + + s.OnEmergencyStop(func() { + // Cancel active orders + _ = s.orderExecutor.GracefulCancel(ctx) + // Close 100% position + // _ = s.ClosePosition(ctx, fixedpoint.One) + }) + + // initial required information + s.session = session + + s.orderExecutor = qbtrade.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, s.Position) + s.orderExecutor.BindEnvironment(s.Environment) + s.orderExecutor.BindProfitStats(s.ProfitStats) + s.orderExecutor.BindTradeStats(s.TradeStats) + s.orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { + qbtrade.Sync(ctx, s) + }) + s.orderExecutor.Bind() + s.activeOrders = qbtrade.NewActiveOrderBook(s.Symbol) + + for _, method := range s.ExitMethods { + method.Bind(session, s.orderExecutor) + } + + if s.Linear != nil { + s.Linear.Bind(session, s.orderExecutor) + } + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + _, _ = fmt.Fprintln(os.Stderr, s.TradeStats.String()) + _ = s.orderExecutor.GracefulCancel(ctx) + }) + + return nil +} diff --git a/pkg/strategy/fixedmaker/strategy.go b/pkg/strategy/fixedmaker/strategy.go new file mode 100644 index 0000000..67cfaa4 --- /dev/null +++ b/pkg/strategy/fixedmaker/strategy.go @@ -0,0 +1,232 @@ +package fixedmaker + +import ( + "context" + "fmt" + "sync" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/common" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ID = "fixedmaker" + +var log = logrus.WithField("strategy", ID) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +// Fixed spread market making strategy +type Strategy struct { + *common.Strategy + + Environment *qbtrade.Environment + Market types.Market + + Symbol string `json:"symbol"` + Interval types.Interval `json:"interval"` + Quantity fixedpoint.Value `json:"quantity"` + HalfSpread fixedpoint.Value `json:"halfSpread"` + OrderType types.OrderType `json:"orderType"` + DryRun bool `json:"dryRun"` + + InventorySkew common.InventorySkew `json:"inventorySkew"` + + activeOrderBook *qbtrade.ActiveOrderBook +} + +func (s *Strategy) Defaults() error { + if s.OrderType == "" { + log.Infof("order type is not set, using limit maker order type") + s.OrderType = types.OrderTypeLimitMaker + } + return nil +} + +func (s *Strategy) Initialize() error { + if s.Strategy == nil { + s.Strategy = &common.Strategy{} + } + + return nil +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s", ID, s.Symbol) +} + +func (s *Strategy) Validate() error { + if s.Quantity.Float64() <= 0 { + return fmt.Errorf("quantity should be positive") + } + + if s.HalfSpread.Float64() <= 0 { + return fmt.Errorf("halfSpread should be positive") + } + + if err := s.InventorySkew.Validate(); err != nil { + return err + } + return nil +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) + + if !s.CircuitBreakLossThreshold.IsZero() { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.CircuitBreakEMA.Interval}) + } +} + +func (s *Strategy) Run(ctx context.Context, _ qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + s.Strategy.Initialize(ctx, s.Environment, session, s.Market, ID, s.InstanceID()) + + s.activeOrderBook = qbtrade.NewActiveOrderBook(s.Symbol) + s.activeOrderBook.BindStream(session.UserDataStream) + + s.activeOrderBook.OnFilled(func(order types.Order) { + if s.IsHalted(order.UpdateTime.Time()) { + log.Infof("circuit break halted") + return + } + + if s.activeOrderBook.NumOfOrders() == 0 { + log.Infof("no active orders, placing orders...") + s.placeOrders(ctx) + } + }) + + session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { + log.Infof("%s", kline.String()) + + if s.IsHalted(kline.EndTime.Time()) { + log.Infof("circuit break halted") + return + } + + if kline.Interval == s.Interval { + s.cancelOrders(ctx) + s.placeOrders(ctx) + } + }) + + // the shutdown handler, you can cancel all orders + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + _ = s.OrderExecutor.GracefulCancel(ctx) + qbtrade.Sync(ctx, s) + }) + + return nil +} + +func (s *Strategy) cancelOrders(ctx context.Context) { + if err := s.activeOrderBook.GracefulCancel(ctx, s.Session.Exchange); err != nil { + log.WithError(err).Errorf("failed to cancel orders") + } +} + +func (s *Strategy) placeOrders(ctx context.Context) { + orders, err := s.generateOrders(ctx) + if err != nil { + log.WithError(err).Error("failed to generate orders") + return + } + log.Infof("orders: %+v", orders) + + if s.DryRun { + log.Infof("dry run, not submitting orders") + return + } + + createdOrders, err := s.OrderExecutor.SubmitOrders(ctx, orders...) + if err != nil { + log.WithError(err).Error("failed to submit orders") + return + } + log.Infof("created orders: %+v", createdOrders) + + s.activeOrderBook.Add(createdOrders...) +} + +func (s *Strategy) generateOrders(ctx context.Context) ([]types.SubmitOrder, error) { + orders := []types.SubmitOrder{} + + baseBalance, ok := s.Session.GetAccount().Balance(s.Market.BaseCurrency) + if !ok { + return nil, fmt.Errorf("base currency %s balance not found", s.Market.BaseCurrency) + } + log.Infof("base balance: %+v", baseBalance) + + quoteBalance, ok := s.Session.GetAccount().Balance(s.Market.QuoteCurrency) + if !ok { + return nil, fmt.Errorf("quote currency %s balance not found", s.Market.QuoteCurrency) + } + log.Infof("quote balance: %+v", quoteBalance) + + ticker, err := s.Session.Exchange.QueryTicker(ctx, s.Symbol) + if err != nil { + return nil, err + } + midPrice := ticker.Buy.Add(ticker.Sell).Div(fixedpoint.NewFromFloat(2.0)) + log.Infof("mid price: %+v", midPrice) + + // calculate bid and ask price + // sell price = mid price * (1 + r)) + // buy price = mid price * (1 - r)) + sellPrice := midPrice.Mul(fixedpoint.One.Add(s.HalfSpread)).Round(s.Market.PricePrecision, fixedpoint.Up) + buyPrice := midPrice.Mul(fixedpoint.One.Sub(s.HalfSpread)).Round(s.Market.PricePrecision, fixedpoint.Down) + log.Infof("sell price: %s, buy price: %s", sellPrice.String(), buyPrice.String()) + + buyQuantity := s.Quantity + sellQuantity := s.Quantity + if !s.InventorySkew.InventoryRangeMultiplier.IsZero() { + ratios := s.InventorySkew.CalculateBidAskRatios( + s.Quantity, + midPrice, + baseBalance.Total(), + quoteBalance.Total(), + ) + log.Infof("bid ratio: %s, ask ratio: %s", ratios.BidRatio.String(), ratios.AskRatio.String()) + buyQuantity = s.Quantity.Mul(ratios.BidRatio) + sellQuantity = s.Quantity.Mul(ratios.AskRatio) + log.Infof("buy quantity: %s, sell quantity: %s", buyQuantity.String(), sellQuantity.String()) + } + + // check balance and generate orders + amount := s.Quantity.Mul(buyPrice) + if quoteBalance.Available.Compare(amount) > 0 { + orders = append(orders, types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeBuy, + Type: s.OrderType, + Price: buyPrice, + Quantity: buyQuantity, + }) + } else { + log.Infof("not enough quote balance to buy, available: %s, amount: %s", quoteBalance.Available, amount) + } + + if baseBalance.Available.Compare(s.Quantity) > 0 { + orders = append(orders, types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeSell, + Type: s.OrderType, + Price: sellPrice, + Quantity: sellQuantity, + }) + } else { + log.Infof("not enough base balance to sell, available: %s, quantity: %s", baseBalance.Available, s.Quantity) + } + + return orders, nil +} diff --git a/pkg/strategy/flashcrash/strategy.go b/pkg/strategy/flashcrash/strategy.go new file mode 100644 index 0000000..fb8f970 --- /dev/null +++ b/pkg/strategy/flashcrash/strategy.go @@ -0,0 +1,137 @@ +// flashcrash strategy tries to place the orders at 30%~50% of the current price, +// so that you can catch the orders while flashcrash happens +package flashcrash + +import ( + "context" + "sync" + + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ID = "flashcrash" + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + // These fields will be filled from the config file (it translates YAML to JSON) + // Symbol is the symbol of market you want to run this strategy + Symbol string `json:"symbol"` + + // Interval is the interval used to trigger order updates + Interval types.Interval `json:"interval"` + + // GridNum is the grid number, how many orders you want to places + GridNum int `json:"gridNumber"` + + Percentage fixedpoint.Value `json:"percentage"` + + // BaseQuantity is the quantity you want to submit for each order. + BaseQuantity fixedpoint.Value `json:"baseQuantity"` + + // activeOrders is the locally maintained active order book of the maker orders. + activeOrders *qbtrade.ActiveOrderBook + + // Injection fields start + // -------------------------- + // 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 + + // StandardIndicatorSet contains the standard indicators of a market (symbol) + // This field will be injected automatically since we defined the Symbol field. + *qbtrade.StandardIndicatorSet + + // ewma is the exponential weighted moving average indicator + ewma *indicator.EWMA +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) updateOrders(orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) { + if err := s.activeOrders.GracefulCancel(context.Background(), session.Exchange); err != nil { + log.WithError(err).Errorf("cancel order error") + } + + s.updateBidOrders(orderExecutor, session) +} + +func (s *Strategy) updateBidOrders(orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) { + quoteCurrency := s.Market.QuoteCurrency + balances := session.GetAccount().Balances() + + balance, ok := balances[quoteCurrency] + if !ok || balance.Available.Sign() <= 0 { + log.Infof("insufficient balance of %s: %v", quoteCurrency, balance.Available) + return + } + + var startPrice = fixedpoint.NewFromFloat(s.ewma.Last(0)).Mul(s.Percentage) + + var submitOrders []types.SubmitOrder + for i := 0; i < s.GridNum; i++ { + submitOrders = append(submitOrders, types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Market: s.Market, + Quantity: s.BaseQuantity, + Price: startPrice, + TimeInForce: types.TimeInForceGTC, + }) + + startPrice = startPrice.Mul(s.Percentage) + } + + orders, err := orderExecutor.SubmitOrders(context.Background(), submitOrders...) + if err != nil { + log.WithError(err).Error("submit bid order error") + return + } + + s.activeOrders.Add(orders...) +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + // we don't persist orders so that we can not clear the previous orders for now. just need time to support this. + s.activeOrders = qbtrade.NewActiveOrderBook(s.Symbol) + s.activeOrders.BindStream(session.UserDataStream) + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + log.Infof("canceling active orders...") + + if err := orderExecutor.CancelOrders(ctx, s.activeOrders.Orders()...); err != nil { + log.WithError(err).Errorf("cancel order error") + } + }) + + s.ewma = s.StandardIndicatorSet.EWMA(types.IntervalWindow{ + Interval: s.Interval, + Window: 25, + }) + + session.UserDataStream.OnStart(func() { + s.updateOrders(orderExecutor, session) + }) + + session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { + s.updateOrders(orderExecutor, session) + }) + + return nil +} diff --git a/pkg/strategy/fmaker/A18.go b/pkg/strategy/fmaker/A18.go new file mode 100644 index 0000000..415782d --- /dev/null +++ b/pkg/strategy/fmaker/A18.go @@ -0,0 +1,92 @@ +package fmaker + +import ( + "fmt" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +//go:generate callbackgen -type A18 +type A18 struct { + types.IntervalWindow + + // Values + Values floats.Slice + + EndTime time.Time + + UpdateCallbacks []func(val float64) +} + +func (inc *A18) Last(int) float64 { + if len(inc.Values) == 0 { + return 0.0 + } + return inc.Values[len(inc.Values)-1] +} + +func (inc *A18) CalculateAndUpdate(klines []types.KLine) { + if len(klines) < inc.Window { + return + } + + var end = len(klines) - 1 + var lastKLine = klines[end] + + if inc.EndTime != zeroTime && lastKLine.GetEndTime().Before(inc.EndTime) { + return + } + + var recentT = klines[end-(inc.Window-1) : end+1] + + val, err := calculateA18(recentT, types.KLineClosePriceMapper) + if err != nil { + log.WithError(err).Error("can not calculate") + return + } + inc.Values.Push(val) + + if len(inc.Values) > indicator.MaxNumOfVOL { + inc.Values = inc.Values[indicator.MaxNumOfVOLTruncateSize-1:] + } + + inc.EndTime = klines[end].GetEndTime().Time() + + inc.EmitUpdate(val) + +} + +func (inc *A18) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *A18) Bind(updater indicator.KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} + +// CLOSE/DELAY(CLOSE,5) +func calculateA18(klines []types.KLine, valClose KLineValueMapper) (float64, error) { + window := 5 + length := len(klines) + if length == 0 || length < window { + return 0., fmt.Errorf("insufficient elements for calculating with window = %d", window) + } + var closes floats.Slice + + for _, k := range klines { + closes.Push(valClose(k)) + } + + delay5 := closes.Last(4) + curr := closes.Last(0) + alpha := curr / delay5 + + return alpha, nil +} diff --git a/pkg/strategy/fmaker/A2.go b/pkg/strategy/fmaker/A2.go new file mode 100644 index 0000000..3d7ceb3 --- /dev/null +++ b/pkg/strategy/fmaker/A2.go @@ -0,0 +1,104 @@ +package fmaker + +import ( + "fmt" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +//go:generate callbackgen -type A2 +type A2 struct { + types.IntervalWindow + + // Values + Values floats.Slice + + EndTime time.Time + + UpdateCallbacks []func(val float64) +} + +func (inc *A2) Last(int) float64 { + if len(inc.Values) == 0 { + return 0.0 + } + return inc.Values[len(inc.Values)-1] +} + +func (inc *A2) CalculateAndUpdate(klines []types.KLine) { + if len(klines) < inc.Window { + return + } + + var end = len(klines) - 1 + var lastKLine = klines[end] + + if inc.EndTime != zeroTime && lastKLine.GetEndTime().Before(inc.EndTime) { + return + } + + var recentT = klines[end-(inc.Window-1) : end+1] + + val, err := calculateA2(recentT, KLineLowPriceMapper, KLineHighPriceMapper, types.KLineClosePriceMapper) + if err != nil { + log.WithError(err).Error("can not calculate") + return + } + inc.Values.Push(val) + + if len(inc.Values) > indicator.MaxNumOfVOL { + inc.Values = inc.Values[indicator.MaxNumOfVOLTruncateSize-1:] + } + + inc.EndTime = klines[end].GetEndTime().Time() + + inc.EmitUpdate(val) + +} + +func (inc *A2) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *A2) Bind(updater indicator.KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} + +// (-1 * DELTA((((CLOSE - LOW) - (HIGH - CLOSE)) / (HIGH - LOW)), 1)) +func calculateA2(klines []types.KLine, valLow KLineValueMapper, valHigh KLineValueMapper, valClose KLineValueMapper) (float64, error) { + window := 2 + length := len(klines) + if length == 0 || length < window { + return 0., fmt.Errorf("insufficient elements for calculating with window = %d", window) + } + var lows floats.Slice + var highs floats.Slice + var closes floats.Slice + + for _, k := range klines { + lows.Push(valLow(k)) + highs.Push(valHigh(k)) + closes.Push(valClose(k)) + } + + prev := ((closes.Last(1) - lows.Index(1)) - (highs.Index(1) - closes.Index(1))) / (highs.Index(1) - lows.Index(1)) + curr := ((closes.Last(0) - lows.Index(0)) - (highs.Index(0) - closes.Index(0))) / (highs.Index(0) - lows.Index(0)) + alpha := (curr - prev) * -1 // delta(1 interval) + + return alpha, nil +} + +func KLineLowPriceMapper(k types.KLine) float64 { + return k.Low.Float64() +} + +func KLineHighPriceMapper(k types.KLine) float64 { + return k.High.Float64() +} diff --git a/pkg/strategy/fmaker/A3.go b/pkg/strategy/fmaker/A3.go new file mode 100644 index 0000000..fa299a5 --- /dev/null +++ b/pkg/strategy/fmaker/A3.go @@ -0,0 +1,110 @@ +package fmaker + +import ( + "fmt" + "math" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +//go:generate callbackgen -type A3 +type A3 struct { + types.IntervalWindow + + // Values + Values floats.Slice + + EndTime time.Time + + UpdateCallbacks []func(val float64) +} + +func (inc *A3) Last(int) float64 { + if len(inc.Values) == 0 { + return 0.0 + } + return inc.Values[len(inc.Values)-1] +} + +func (inc *A3) CalculateAndUpdate(klines []types.KLine) { + if len(klines) < inc.Window { + return + } + + var end = len(klines) - 1 + var lastKLine = klines[end] + + if inc.EndTime != zeroTime && lastKLine.GetEndTime().Before(inc.EndTime) { + return + } + + var recentT = klines[end-(inc.Window-1) : end+1] + + val, err := calculateA3(recentT, KLineLowPriceMapper, KLineHighPriceMapper, types.KLineClosePriceMapper) + if err != nil { + log.WithError(err).Error("can not calculate pivots") + return + } + inc.Values.Push(val) + + if len(inc.Values) > indicator.MaxNumOfVOL { + inc.Values = inc.Values[indicator.MaxNumOfVOLTruncateSize-1:] + } + + inc.EndTime = klines[end].GetEndTime().Time() + + inc.EmitUpdate(val) + +} + +func (inc *A3) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *A3) Bind(updater indicator.KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} + +// SUM((CLOSE = DELAY(CLOSE, 1)?0:CLOSE-(CLOSE>DELAY(CLOSE, 1)?MIN(LOW, DELAY(CLOSE, 1)):MAX(HIGH, DELAY(CLOSE, 1)))), 6) +func calculateA3(klines []types.KLine, valLow KLineValueMapper, valHigh KLineValueMapper, valClose KLineValueMapper) (float64, error) { + window := 6 + 2 + length := len(klines) + if length == 0 || length < window { + return 0., fmt.Errorf("insufficient elements for calculating with window = %d", window) + } + var lows floats.Slice + var highs floats.Slice + var closes floats.Slice + + for _, k := range klines { + lows.Push(valLow(k)) + highs.Push(valHigh(k)) + closes.Push(valClose(k)) + } + + a := 0. + sumA := 0. + for i := 1; i <= 6; i++ { + if closes.Index(len(closes)-i) == closes.Index(len(closes)-i-1) { + a = 0. + } else { + if closes.Index(len(closes)-i) > closes.Index(1) { + a = closes.Index(len(closes)-i) - math.Min(lows.Index(len(lows)-i), closes.Index(len(closes)-i-1)) + } else { + a = closes.Index(len(closes)-i) - math.Max(highs.Index(len(highs)-i), closes.Index(len(closes)-i-1)) + } + } + sumA += a + } + + alpha := sumA // sum(a, 6 interval) + + return alpha, nil +} diff --git a/pkg/strategy/fmaker/A34.go b/pkg/strategy/fmaker/A34.go new file mode 100644 index 0000000..26d275b --- /dev/null +++ b/pkg/strategy/fmaker/A34.go @@ -0,0 +1,98 @@ +package fmaker + +import ( + "fmt" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +//go:generate callbackgen -type A34 +type A34 struct { + types.IntervalWindow + + // Values + Values floats.Slice + + EndTime time.Time + + UpdateCallbacks []func(val float64) +} + +func (inc *A34) Last(int) float64 { + if len(inc.Values) == 0 { + return 0.0 + } + return inc.Values[len(inc.Values)-1] +} + +func (inc *A34) CalculateAndUpdate(klines []types.KLine) { + if len(klines) < inc.Window { + return + } + + var end = len(klines) - 1 + var lastKLine = klines[end] + + if inc.EndTime != zeroTime && lastKLine.GetEndTime().Before(inc.EndTime) { + return + } + + var recentT = klines[end-(inc.Window-1) : end+1] + + val, err := calculateA34(recentT, types.KLineClosePriceMapper) + if err != nil { + log.WithError(err).Error("can not calculate pivots") + return + } + inc.Values.Push(val) + + if len(inc.Values) > indicator.MaxNumOfVOL { + inc.Values = inc.Values[indicator.MaxNumOfVOLTruncateSize-1:] + } + + inc.EndTime = klines[end].GetEndTime().Time() + + inc.EmitUpdate(val) + +} + +func (inc *A34) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *A34) Bind(updater indicator.KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} + +func calculateA34(klines []types.KLine, valClose KLineValueMapper) (float64, error) { + window := 12 + length := len(klines) + if length == 0 || length < window { + return 0., fmt.Errorf("insufficient elements for calculating with window = %d", window) + } + var closes floats.Slice + + for _, k := range klines { + closes.Push(valClose(k)) + } + + c := closes.Last(0) + + sumC := 0. + for i := 1; i <= 12; i++ { + sumC += closes.Index(len(closes) - i) + } + + meanC := sumC / 12 + + alpha := meanC / c + + return alpha, nil +} diff --git a/pkg/strategy/fmaker/R.go b/pkg/strategy/fmaker/R.go new file mode 100644 index 0000000..0b43271 --- /dev/null +++ b/pkg/strategy/fmaker/R.go @@ -0,0 +1,95 @@ +package fmaker + +import ( + "fmt" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +var zeroTime time.Time + +type KLineValueMapper func(k types.KLine) float64 + +//go:generate callbackgen -type R +type R struct { + types.IntervalWindow + + // Values + Values floats.Slice + + EndTime time.Time + + UpdateCallbacks []func(val float64) +} + +func (inc *R) Last(int) float64 { + if len(inc.Values) == 0 { + return 0.0 + } + return inc.Values[len(inc.Values)-1] +} + +func (inc *R) CalculateAndUpdate(klines []types.KLine) { + if len(klines) < inc.Window { + return + } + + var end = len(klines) - 1 + var lastKLine = klines[end] + + if inc.EndTime != zeroTime && lastKLine.GetEndTime().Before(inc.EndTime) { + return + } + + var recentT = klines[end-(inc.Window-1) : end+1] + + val, err := calculateR(recentT, types.KLineOpenPriceMapper, types.KLineClosePriceMapper) + if err != nil { + log.WithError(err).Error("can not calculate pivots") + return + } + inc.Values.Push(val) + + if len(inc.Values) > indicator.MaxNumOfVOL { + inc.Values = inc.Values[indicator.MaxNumOfVOLTruncateSize-1:] + } + + inc.EndTime = klines[end].GetEndTime().Time() + + inc.EmitUpdate(val) + +} + +func (inc *R) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *R) Bind(updater indicator.KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} + +func calculateR(klines []types.KLine, valOpen KLineValueMapper, valClose KLineValueMapper) (float64, error) { + window := 1 + length := len(klines) + if length == 0 || length < window { + return 0., fmt.Errorf("insufficient elements for calculating with window = %d", window) + } + var opens floats.Slice + var closes floats.Slice + + for _, k := range klines { + opens.Push(valOpen(k)) + closes.Push(valClose(k)) + } + + ret := opens.Index(0)/closes.Index(0) - 1 // delta(1 interval) + + return ret, nil +} diff --git a/pkg/strategy/fmaker/S0.go b/pkg/strategy/fmaker/S0.go new file mode 100644 index 0000000..af44678 --- /dev/null +++ b/pkg/strategy/fmaker/S0.go @@ -0,0 +1,90 @@ +package fmaker + +import ( + "fmt" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +//go:generate callbackgen -type S0 +type S0 struct { + types.IntervalWindow + + // Values + Values floats.Slice + + EndTime time.Time + + UpdateCallbacks []func(val float64) +} + +func (inc *S0) Last(int) float64 { + if len(inc.Values) == 0 { + return 0.0 + } + return inc.Values[len(inc.Values)-1] +} + +func (inc *S0) CalculateAndUpdate(klines []types.KLine) { + if len(klines) < inc.Window { + return + } + + var end = len(klines) - 1 + var lastKLine = klines[end] + + if inc.EndTime != zeroTime && lastKLine.GetEndTime().Before(inc.EndTime) { + return + } + + var recentT = klines[end-(inc.Window-1) : end+1] + + val, err := calculateS0(recentT, types.KLineClosePriceMapper) + if err != nil { + log.WithError(err).Error("can not calculate") + return + } + inc.Values.Push(val) + + if len(inc.Values) > indicator.MaxNumOfVOL { + inc.Values = inc.Values[indicator.MaxNumOfVOLTruncateSize-1:] + } + + inc.EndTime = klines[end].GetEndTime().Time() + + inc.EmitUpdate(val) + +} + +func (inc *S0) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *S0) Bind(updater indicator.KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} + +func calculateS0(klines []types.KLine, valClose KLineValueMapper) (float64, error) { + window := 20 + length := len(klines) + if length == 0 || length < window { + return 0., fmt.Errorf("insufficient elements for calculating with window = %d", window) + } + var closes floats.Slice + + for _, k := range klines { + closes.Push(valClose(k)) + } + + sma := floats.Slice.Sum(closes[len(closes)-window:len(closes)-1]) / float64(window) + alpha := sma / closes.Last(0) + + return alpha, nil +} diff --git a/pkg/strategy/fmaker/S1.go b/pkg/strategy/fmaker/S1.go new file mode 100644 index 0000000..d9ee56a --- /dev/null +++ b/pkg/strategy/fmaker/S1.go @@ -0,0 +1,100 @@ +package fmaker + +import ( + "fmt" + "math" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +//go:generate callbackgen -type S1 +type S1 struct { + types.IntervalWindow + Values floats.Slice + EndTime time.Time + + UpdateCallbacks []func(value float64) +} + +func (inc *S1) Last(int) float64 { + if len(inc.Values) == 0 { + return 0.0 + } + return inc.Values[len(inc.Values)-1] +} + +func (inc *S1) CalculateAndUpdate(klines []types.KLine) { + if len(klines) < inc.Window { + return + } + + var end = len(klines) - 1 + var lastKLine = klines[end] + + if inc.EndTime != zeroTime && lastKLine.GetEndTime().Before(inc.EndTime) { + return + } + + var recentT = klines[end-(inc.Window-1) : end+1] + + correlation, err := calculateS1(recentT, inc.Window, KLineAmplitudeMapper, types.KLineVolumeMapper) + if err != nil { + log.WithError(err).Error("can not calculate correlation") + return + } + inc.Values.Push(correlation) + + if len(inc.Values) > indicator.MaxNumOfVOL { + inc.Values = inc.Values[indicator.MaxNumOfVOLTruncateSize-1:] + } + + inc.EndTime = klines[end].GetEndTime().Time() + + inc.EmitUpdate(correlation) +} + +func (inc *S1) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *S1) Bind(updater indicator.KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} + +func calculateS1(klines []types.KLine, window int, valA KLineValueMapper, valB KLineValueMapper) (float64, error) { + length := len(klines) + if length == 0 || length < window { + return 0.0, fmt.Errorf("insufficient elements for calculating VOL with window = %d", window) + } + + sumA, sumB, sumAB, squareSumA, squareSumB := 0., 0., 0., 0., 0. + for _, k := range klines { + // sum of elements of array A + sumA += valA(k) + // sum of elements of array B + sumB += valB(k) + + // sum of A[i] * B[i]. + sumAB = sumAB + valA(k)*valB(k) + + // sum of square of array elements. + squareSumA = squareSumA + valA(k)*valA(k) + squareSumB = squareSumB + valB(k)*valB(k) + } + // use formula for calculating correlation coefficient. + corr := (float64(window)*sumAB - sumA*sumB) / + math.Sqrt((float64(window)*squareSumA-sumA*sumA)*(float64(window)*squareSumB-sumB*sumB)) + + return -corr, nil +} + +func KLineAmplitudeMapper(k types.KLine) float64 { + return k.High.Div(k.Low).Float64() +} diff --git a/pkg/strategy/fmaker/S2.go b/pkg/strategy/fmaker/S2.go new file mode 100644 index 0000000..9f20d55 --- /dev/null +++ b/pkg/strategy/fmaker/S2.go @@ -0,0 +1,96 @@ +package fmaker + +import ( + "fmt" + "math" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +//go:generate callbackgen -type S2 +type S2 struct { + types.IntervalWindow + Values floats.Slice + EndTime time.Time + + UpdateCallbacks []func(value float64) +} + +func (inc *S2) Last(int) float64 { + if len(inc.Values) == 0 { + return 0.0 + } + return inc.Values[len(inc.Values)-1] +} + +func (inc *S2) CalculateAndUpdate(klines []types.KLine) { + if len(klines) < inc.Window { + return + } + + var end = len(klines) - 1 + var lastKLine = klines[end] + + if inc.EndTime != zeroTime && lastKLine.GetEndTime().Before(inc.EndTime) { + return + } + + var recentT = klines[end-(inc.Window-1) : end+1] + + correlation, err := calculateS2(recentT, inc.Window, types.KLineOpenPriceMapper, types.KLineVolumeMapper) + if err != nil { + log.WithError(err).Error("can not calculate correlation") + return + } + inc.Values.Push(correlation) + + if len(inc.Values) > indicator.MaxNumOfVOL { + inc.Values = inc.Values[indicator.MaxNumOfVOLTruncateSize-1:] + } + + inc.EndTime = klines[end].GetEndTime().Time() + + inc.EmitUpdate(correlation) +} + +func (inc *S2) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *S2) Bind(updater indicator.KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} + +func calculateS2(klines []types.KLine, window int, valA KLineValueMapper, valB KLineValueMapper) (float64, error) { + length := len(klines) + if length == 0 || length < window { + return 0.0, fmt.Errorf("insufficient elements for calculating VOL with window = %d", window) + } + + sumA, sumB, sumAB, squareSumA, squareSumB := 0., 0., 0., 0., 0. + for _, k := range klines { + // sum of elements of array A + sumA += valA(k) + // sum of elements of array B + sumB += valB(k) + + // sum of A[i] * B[i]. + sumAB = sumAB + valA(k)*valB(k) + + // sum of square of array elements. + squareSumA = squareSumA + valA(k)*valA(k) + squareSumB = squareSumB + valB(k)*valB(k) + } + // use formula for calculating correlation coefficient. + corr := (float64(window)*sumAB - sumA*sumB) / + math.Sqrt((float64(window)*squareSumA-sumA*sumA)*(float64(window)*squareSumB-sumB*sumB)) + + return -corr, nil +} diff --git a/pkg/strategy/fmaker/S3.go b/pkg/strategy/fmaker/S3.go new file mode 100644 index 0000000..f1ac189 --- /dev/null +++ b/pkg/strategy/fmaker/S3.go @@ -0,0 +1,93 @@ +package fmaker + +import ( + "fmt" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +//go:generate callbackgen -type S3 +type S3 struct { + types.IntervalWindow + + // Values + Values floats.Slice + + EndTime time.Time + + UpdateCallbacks []func(val float64) +} + +func (inc *S3) Last(int) float64 { + if len(inc.Values) == 0 { + return 0.0 + } + return inc.Values[len(inc.Values)-1] +} + +func (inc *S3) CalculateAndUpdate(klines []types.KLine) { + if len(klines) < inc.Window { + return + } + + var end = len(klines) - 1 + var lastKLine = klines[end] + + if inc.EndTime != zeroTime && lastKLine.GetEndTime().Before(inc.EndTime) { + return + } + + var recentT = klines[end-(inc.Window-1) : end+1] + + val, err := calculateS3(recentT, types.KLineClosePriceMapper, types.KLineOpenPriceMapper) + if err != nil { + log.WithError(err).Error("can not calculate") + return + } + inc.Values.Push(val) + + if len(inc.Values) > indicator.MaxNumOfVOL { + inc.Values = inc.Values[indicator.MaxNumOfVOLTruncateSize-1:] + } + + inc.EndTime = klines[end].GetEndTime().Time() + + inc.EmitUpdate(val) + +} + +func (inc *S3) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *S3) Bind(updater indicator.KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} + +func calculateS3(klines []types.KLine, valClose KLineValueMapper, valOpen KLineValueMapper) (float64, error) { + window := 2 + length := len(klines) + if length == 0 || length < window { + return 0., fmt.Errorf("insufficient elements for calculating with window = %d", window) + } + var closes floats.Slice + var opens floats.Slice + + for _, k := range klines { + closes.Push(valClose(k)) + opens.Push(valOpen(k)) + } + + prevC := closes.Index(1) + currO := opens.Index(0) + alpha := currO / prevC + + return alpha, nil +} diff --git a/pkg/strategy/fmaker/S4.go b/pkg/strategy/fmaker/S4.go new file mode 100644 index 0000000..70f4f9f --- /dev/null +++ b/pkg/strategy/fmaker/S4.go @@ -0,0 +1,90 @@ +package fmaker + +import ( + "fmt" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +//go:generate callbackgen -type S4 +type S4 struct { + types.IntervalWindow + + // Values + Values floats.Slice + + EndTime time.Time + + UpdateCallbacks []func(val float64) +} + +func (inc *S4) Last(int) float64 { + if len(inc.Values) == 0 { + return 0.0 + } + return inc.Values[len(inc.Values)-1] +} + +func (inc *S4) CalculateAndUpdate(klines []types.KLine) { + if len(klines) < inc.Window { + return + } + + var end = len(klines) - 1 + var lastKLine = klines[end] + + if inc.EndTime != zeroTime && lastKLine.GetEndTime().Before(inc.EndTime) { + return + } + + var recentT = klines[end-(inc.Window-1) : end+1] + + val, err := calculateS4(recentT, types.KLineClosePriceMapper) + if err != nil { + log.WithError(err).Error("can not calculate") + return + } + inc.Values.Push(val) + + if len(inc.Values) > indicator.MaxNumOfVOL { + inc.Values = inc.Values[indicator.MaxNumOfVOLTruncateSize-1:] + } + + inc.EndTime = klines[end].GetEndTime().Time() + + inc.EmitUpdate(val) + +} + +func (inc *S4) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *S4) Bind(updater indicator.KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} + +func calculateS4(klines []types.KLine, valClose KLineValueMapper) (float64, error) { + window := 2 + length := len(klines) + if length == 0 || length < window { + return 0., fmt.Errorf("insufficient elements for calculating with window = %d", window) + } + var closes floats.Slice + + for _, k := range klines { + closes.Push(valClose(k)) + } + + currC := closes.Index(0) + alpha := 1 / currC + + return alpha, nil +} diff --git a/pkg/strategy/fmaker/S5.go b/pkg/strategy/fmaker/S5.go new file mode 100644 index 0000000..6d6e9d1 --- /dev/null +++ b/pkg/strategy/fmaker/S5.go @@ -0,0 +1,98 @@ +package fmaker + +import ( + "fmt" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +//go:generate callbackgen -type S5 +type S5 struct { + types.IntervalWindow + + // Values + Values floats.Slice + + EndTime time.Time + + UpdateCallbacks []func(val float64) +} + +func (inc *S5) Last(int) float64 { + if len(inc.Values) == 0 { + return 0.0 + } + return inc.Values[len(inc.Values)-1] +} + +func (inc *S5) CalculateAndUpdate(klines []types.KLine) { + if len(klines) < inc.Window { + return + } + + var end = len(klines) - 1 + var lastKLine = klines[end] + + if inc.EndTime != zeroTime && lastKLine.GetEndTime().Before(inc.EndTime) { + return + } + + var recentT = klines[end-(inc.Window-1) : end+1] + + val, err := calculateS5(recentT, types.KLineVolumeMapper) + if err != nil { + log.WithError(err).Error("can not calculate pivots") + return + } + inc.Values.Push(val) + + if len(inc.Values) > indicator.MaxNumOfVOL { + inc.Values = inc.Values[indicator.MaxNumOfVOLTruncateSize-1:] + } + + inc.EndTime = klines[end].GetEndTime().Time() + + inc.EmitUpdate(val) + +} + +func (inc *S5) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *S5) Bind(updater indicator.KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} + +func calculateS5(klines []types.KLine, valVolume KLineValueMapper) (float64, error) { + window := 10 + length := len(klines) + if length == 0 || length < window { + return 0., fmt.Errorf("insufficient elements for calculating with window = %d", window) + } + var volumes floats.Slice + + for _, k := range klines { + volumes.Push(valVolume(k)) + } + + v := volumes.Last(0) + + sumV := 0. + for i := 1; i <= 10; i++ { + sumV += volumes.Index(len(volumes) - i) + } + + meanV := sumV / 10 + + alpha := -v / meanV + + return alpha, nil +} diff --git a/pkg/strategy/fmaker/S6.go b/pkg/strategy/fmaker/S6.go new file mode 100644 index 0000000..a9d97a9 --- /dev/null +++ b/pkg/strategy/fmaker/S6.go @@ -0,0 +1,100 @@ +package fmaker + +import ( + "fmt" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +//go:generate callbackgen -type S6 +type S6 struct { + types.IntervalWindow + + // Values + Values floats.Slice + + EndTime time.Time + + UpdateCallbacks []func(val float64) +} + +func (inc *S6) Last(int) float64 { + if len(inc.Values) == 0 { + return 0.0 + } + return inc.Values[len(inc.Values)-1] +} + +func (inc *S6) CalculateAndUpdate(klines []types.KLine) { + if len(klines) < inc.Window { + return + } + + var end = len(klines) - 1 + var lastKLine = klines[end] + + if inc.EndTime != zeroTime && lastKLine.GetEndTime().Before(inc.EndTime) { + return + } + + var recentT = klines[end-(inc.Window-1) : end+1] + + val, err := calculateS6(recentT, types.KLineHighPriceMapper, types.KLineLowPriceMapper, types.KLineClosePriceMapper, types.KLineVolumeMapper) + if err != nil { + log.WithError(err).Error("can not calculate") + return + } + inc.Values.Push(val) + + if len(inc.Values) > indicator.MaxNumOfVOL { + inc.Values = inc.Values[indicator.MaxNumOfVOLTruncateSize-1:] + } + + inc.EndTime = klines[end].GetEndTime().Time() + + inc.EmitUpdate(val) + +} + +func (inc *S6) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *S6) Bind(updater indicator.KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} + +func calculateS6(klines []types.KLine, valHigh KLineValueMapper, valLow KLineValueMapper, valClose KLineValueMapper, valVolume KLineValueMapper) (float64, error) { + window := 2 + length := len(klines) + if length == 0 || length < window { + return 0., fmt.Errorf("insufficient elements for calculating with window = %d", window) + } + var highs floats.Slice + var lows floats.Slice + var closes floats.Slice + var volumes floats.Slice + + for _, k := range klines { + highs.Push(valHigh(k)) + lows.Push(valLow(k)) + closes.Push(valClose(k)) + volumes.Push(valVolume(k)) + + } + + H := highs.Last(0) + L := lows.Last(0) + C := closes.Last(0) + V := volumes.Last(0) + alpha := (H + L + C) / 3 * V + + return alpha, nil +} diff --git a/pkg/strategy/fmaker/S7.go b/pkg/strategy/fmaker/S7.go new file mode 100644 index 0000000..f7699e7 --- /dev/null +++ b/pkg/strategy/fmaker/S7.go @@ -0,0 +1,94 @@ +package fmaker + +import ( + "fmt" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +//go:generate callbackgen -type S7 +type S7 struct { + types.IntervalWindow + + // Values + Values floats.Slice + + EndTime time.Time + + UpdateCallbacks []func(val float64) +} + +func (inc *S7) Last(int) float64 { + if len(inc.Values) == 0 { + return 0.0 + } + return inc.Values[len(inc.Values)-1] +} + +func (inc *S7) CalculateAndUpdate(klines []types.KLine) { + if len(klines) < inc.Window { + return + } + + var end = len(klines) - 1 + var lastKLine = klines[end] + + if inc.EndTime != zeroTime && lastKLine.GetEndTime().Before(inc.EndTime) { + return + } + + var recentT = klines[end-(inc.Window-1) : end+1] + + val, err := calculateS7(recentT, types.KLineOpenPriceMapper, types.KLineClosePriceMapper) + if err != nil { + log.WithError(err).Error("can not calculate") + return + } + inc.Values.Push(val) + + if len(inc.Values) > indicator.MaxNumOfVOL { + inc.Values = inc.Values[indicator.MaxNumOfVOLTruncateSize-1:] + } + + inc.EndTime = klines[end].GetEndTime().Time() + + inc.EmitUpdate(val) + +} + +func (inc *S7) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) { + if inc.Interval != interval { + return + } + + inc.CalculateAndUpdate(window) +} + +func (inc *S7) Bind(updater indicator.KLineWindowUpdater) { + updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) +} + +func calculateS7(klines []types.KLine, valOpen KLineValueMapper, valClose KLineValueMapper) (float64, error) { + window := 2 + length := len(klines) + if length == 0 || length < window { + return 0., fmt.Errorf("insufficient elements for calculating with window = %d", window) + } + var opens floats.Slice + var closes floats.Slice + + for _, k := range klines { + opens.Push(valOpen(k)) + closes.Push(valClose(k)) + + } + + O := opens.Last(0) + C := closes.Last(0) + alpha := -(1 - O/C) + + return alpha, nil +} diff --git a/pkg/strategy/fmaker/a18_callbacks.go b/pkg/strategy/fmaker/a18_callbacks.go new file mode 100644 index 0000000..c6bd0c4 --- /dev/null +++ b/pkg/strategy/fmaker/a18_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type A18"; DO NOT EDIT. + +package fmaker + +import () + +func (inc *A18) OnUpdate(cb func(val float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *A18) EmitUpdate(val float64) { + for _, cb := range inc.UpdateCallbacks { + cb(val) + } +} diff --git a/pkg/strategy/fmaker/a2_callbacks.go b/pkg/strategy/fmaker/a2_callbacks.go new file mode 100644 index 0000000..d1fdf00 --- /dev/null +++ b/pkg/strategy/fmaker/a2_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type A2"; DO NOT EDIT. + +package fmaker + +import () + +func (inc *A2) OnUpdate(cb func(val float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *A2) EmitUpdate(val float64) { + for _, cb := range inc.UpdateCallbacks { + cb(val) + } +} diff --git a/pkg/strategy/fmaker/a34_callbacks.go b/pkg/strategy/fmaker/a34_callbacks.go new file mode 100644 index 0000000..fb128ef --- /dev/null +++ b/pkg/strategy/fmaker/a34_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type A34"; DO NOT EDIT. + +package fmaker + +import () + +func (inc *A34) OnUpdate(cb func(val float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *A34) EmitUpdate(val float64) { + for _, cb := range inc.UpdateCallbacks { + cb(val) + } +} diff --git a/pkg/strategy/fmaker/a3_callbacks.go b/pkg/strategy/fmaker/a3_callbacks.go new file mode 100644 index 0000000..ad83cd8 --- /dev/null +++ b/pkg/strategy/fmaker/a3_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type A3"; DO NOT EDIT. + +package fmaker + +import () + +func (inc *A3) OnUpdate(cb func(val float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *A3) EmitUpdate(val float64) { + for _, cb := range inc.UpdateCallbacks { + cb(val) + } +} diff --git a/pkg/strategy/fmaker/r_callbacks.go b/pkg/strategy/fmaker/r_callbacks.go new file mode 100644 index 0000000..afc55e4 --- /dev/null +++ b/pkg/strategy/fmaker/r_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type R"; DO NOT EDIT. + +package fmaker + +import () + +func (inc *R) OnUpdate(cb func(val float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *R) EmitUpdate(val float64) { + for _, cb := range inc.UpdateCallbacks { + cb(val) + } +} diff --git a/pkg/strategy/fmaker/s0_callbacks.go b/pkg/strategy/fmaker/s0_callbacks.go new file mode 100644 index 0000000..1d384c8 --- /dev/null +++ b/pkg/strategy/fmaker/s0_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type S0"; DO NOT EDIT. + +package fmaker + +import () + +func (inc *S0) OnUpdate(cb func(val float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *S0) EmitUpdate(val float64) { + for _, cb := range inc.UpdateCallbacks { + cb(val) + } +} diff --git a/pkg/strategy/fmaker/s1_callbacks.go b/pkg/strategy/fmaker/s1_callbacks.go new file mode 100644 index 0000000..5d7eb01 --- /dev/null +++ b/pkg/strategy/fmaker/s1_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type S1"; DO NOT EDIT. + +package fmaker + +import () + +func (inc *S1) OnUpdate(cb func(value float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *S1) EmitUpdate(value float64) { + for _, cb := range inc.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/strategy/fmaker/s2_callbacks.go b/pkg/strategy/fmaker/s2_callbacks.go new file mode 100644 index 0000000..c65a7af --- /dev/null +++ b/pkg/strategy/fmaker/s2_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type S2"; DO NOT EDIT. + +package fmaker + +import () + +func (inc *S2) OnUpdate(cb func(value float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *S2) EmitUpdate(value float64) { + for _, cb := range inc.UpdateCallbacks { + cb(value) + } +} diff --git a/pkg/strategy/fmaker/s3_callbacks.go b/pkg/strategy/fmaker/s3_callbacks.go new file mode 100644 index 0000000..01a6ea0 --- /dev/null +++ b/pkg/strategy/fmaker/s3_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type S3"; DO NOT EDIT. + +package fmaker + +import () + +func (inc *S3) OnUpdate(cb func(val float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *S3) EmitUpdate(val float64) { + for _, cb := range inc.UpdateCallbacks { + cb(val) + } +} diff --git a/pkg/strategy/fmaker/s4_callbacks.go b/pkg/strategy/fmaker/s4_callbacks.go new file mode 100644 index 0000000..0d00584 --- /dev/null +++ b/pkg/strategy/fmaker/s4_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type S4"; DO NOT EDIT. + +package fmaker + +import () + +func (inc *S4) OnUpdate(cb func(val float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *S4) EmitUpdate(val float64) { + for _, cb := range inc.UpdateCallbacks { + cb(val) + } +} diff --git a/pkg/strategy/fmaker/s5_callbacks.go b/pkg/strategy/fmaker/s5_callbacks.go new file mode 100644 index 0000000..65f7f9a --- /dev/null +++ b/pkg/strategy/fmaker/s5_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type S5"; DO NOT EDIT. + +package fmaker + +import () + +func (inc *S5) OnUpdate(cb func(val float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *S5) EmitUpdate(val float64) { + for _, cb := range inc.UpdateCallbacks { + cb(val) + } +} diff --git a/pkg/strategy/fmaker/s6_callbacks.go b/pkg/strategy/fmaker/s6_callbacks.go new file mode 100644 index 0000000..33daec7 --- /dev/null +++ b/pkg/strategy/fmaker/s6_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type S6"; DO NOT EDIT. + +package fmaker + +import () + +func (inc *S6) OnUpdate(cb func(val float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *S6) EmitUpdate(val float64) { + for _, cb := range inc.UpdateCallbacks { + cb(val) + } +} diff --git a/pkg/strategy/fmaker/s7_callbacks.go b/pkg/strategy/fmaker/s7_callbacks.go new file mode 100644 index 0000000..fec9457 --- /dev/null +++ b/pkg/strategy/fmaker/s7_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type S7"; DO NOT EDIT. + +package fmaker + +import () + +func (inc *S7) OnUpdate(cb func(val float64)) { + inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb) +} + +func (inc *S7) EmitUpdate(val float64) { + for _, cb := range inc.UpdateCallbacks { + cb(val) + } +} diff --git a/pkg/strategy/fmaker/strategy.go b/pkg/strategy/fmaker/strategy.go new file mode 100644 index 0000000..2d9b781 --- /dev/null +++ b/pkg/strategy/fmaker/strategy.go @@ -0,0 +1,534 @@ +package fmaker + +import ( + "context" + "fmt" + "math" + + "github.com/sajari/regression" + "github.com/sirupsen/logrus" + "gonum.org/v1/gonum/floats" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/core" + floats2 "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ID = "fmaker" + +var fifteen = fixedpoint.NewFromInt(15) +var three = fixedpoint.NewFromInt(3) +var two = fixedpoint.NewFromInt(2) + +var log = logrus.WithField("strategy", ID) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type IntervalWindowSetting struct { + types.IntervalWindow +} + +type Strategy struct { + Environment *qbtrade.Environment + Symbol string `json:"symbol"` + Market types.Market + Interval types.Interval `json:"interval"` + Quantity fixedpoint.Value `json:"quantity"` + + // persistence fields + Position *types.Position `json:"position,omitempty" persistence:"position"` + ProfitStats *types.ProfitStats `json:"profitStats,omitempty" persistence:"profit_stats"` + + Spread fixedpoint.Value `json:"spread" persistence:"spread"` + + activeMakerOrders *qbtrade.ActiveOrderBook + // closePositionOrders *qbtrade.LocalActiveOrderBook + + orderStore *core.OrderStore + tradeCollector *core.TradeCollector + + session *qbtrade.ExchangeSession + + qbtrade.QuantityOrAmount + + S0 *S0 + S1 *S1 + S2 *S2 + S3 *S3 + S4 *S4 + S5 *S5 + S6 *S6 + S7 *S7 + + A2 *A2 + A3 *A3 + A18 *A18 + A34 *A34 + + R *R + + // StrategyController + qbtrade.StrategyController +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + log.Infof("subscribe %s", s.Symbol) + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: types.Interval15m}) + +} + +func (s *Strategy) placeOrder(ctx context.Context, price fixedpoint.Value, qty fixedpoint.Value, orderExecutor qbtrade.OrderExecutor) { + submitOrder := types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeLimit, + Price: price, + Quantity: qty, + } + createdOrders, err := orderExecutor.SubmitOrders(ctx, submitOrder) + if err != nil { + log.WithError(err).Errorf("can not place orders") + } + s.orderStore.Add(createdOrders...) + s.activeMakerOrders.Add(createdOrders...) + // s.tradeCollector.Process() +} + +func (s *Strategy) ClosePosition(ctx context.Context, percentage fixedpoint.Value) error { + base := s.Position.GetBase() + if base.IsZero() { + return fmt.Errorf("no opened %s position", s.Position.Symbol) + } + + // make it negative + quantity := base.Mul(percentage).Abs() + side := types.SideTypeBuy + if base.Sign() > 0 { + side = types.SideTypeSell + } + + if quantity.Compare(s.Market.MinQuantity) < 0 { + return fmt.Errorf("order quantity %v is too small, less than %v", quantity, s.Market.MinQuantity) + } + + submitOrder := types.SubmitOrder{ + Symbol: s.Symbol, + Side: side, + Type: types.OrderTypeMarket, + Quantity: quantity, + // Price: closePrice, + Market: s.Market, + } + + // s.Notify("Submitting %s %s order to close position by %v", s.Symbol, side.String(), percentage, submitOrder) + + createdOrder, err := s.session.Exchange.SubmitOrder(ctx, submitOrder) + if err != nil { + log.WithError(err).Errorf("can not place position close order") + } else if createdOrder != nil { + s.orderStore.Add(*createdOrder) + s.activeMakerOrders.Add(*createdOrder) + } + + return err +} +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s", ID, s.Symbol) +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + // initial required information + s.session = session + // s.prevClose = fixedpoint.Zero + + // first we need to get market data store(cached market data) from the exchange session + // st, _ := session.MarketDataStore(s.Symbol) + + s.activeMakerOrders = qbtrade.NewActiveOrderBook(s.Symbol) + s.activeMakerOrders.BindStream(session.UserDataStream) + + // s.closePositionOrders = qbtrade.NewLocalActiveOrderBook(s.Symbol) + // s.closePositionOrders.BindStream(session.UserDataStream) + + s.orderStore = core.NewOrderStore(s.Symbol) + s.orderStore.BindStream(session.UserDataStream) + + if s.Position == nil { + s.Position = types.NewPositionFromMarket(s.Market) + } + + // calculate group id for orders + instanceID := s.InstanceID() + // s.groupID = util.FNV32(instanceID) + + // Always update the position fields + s.Position.Strategy = ID + s.Position.StrategyInstanceID = instanceID + + s.tradeCollector = core.NewTradeCollector(s.Symbol, s.Position, s.orderStore) + s.tradeCollector.OnTrade(func(trade types.Trade, profit, netProfit fixedpoint.Value) { + // StrategyController + if s.Status != types.StrategyStatusRunning { + return + } + + qbtrade.Notify(trade) + s.ProfitStats.AddTrade(trade) + + if profit.Compare(fixedpoint.Zero) == 0 { + s.Environment.RecordPosition(s.Position, trade, nil) + } else { + log.Infof("%s generated profit: %v", s.Symbol, profit) + p := s.Position.NewProfit(trade, profit, netProfit) + p.Strategy = ID + p.StrategyInstanceID = instanceID + qbtrade.Notify(&p) + + s.ProfitStats.AddProfit(p) + qbtrade.Notify(&s.ProfitStats) + + s.Environment.RecordPosition(s.Position, trade, &p) + } + }) + + s.tradeCollector.OnPositionUpdate(func(position *types.Position) { + log.Infof("position changed: %s", s.Position) + qbtrade.Notify(s.Position) + }) + s.tradeCollector.BindStream(session.UserDataStream) + st, _ := session.MarketDataStore(s.Symbol) + + riw := types.IntervalWindow{Window: 1, Interval: s.Interval} + s.R = &R{IntervalWindow: riw} + s.R.Bind(st) + + s0iw := types.IntervalWindow{Window: 20, Interval: s.Interval} + s.S0 = &S0{IntervalWindow: s0iw} + s.S0.Bind(st) + + s1iw := types.IntervalWindow{Window: 20, Interval: s.Interval} + s.S1 = &S1{IntervalWindow: s1iw} + s.S1.Bind(st) + + s2iw := types.IntervalWindow{Window: 20, Interval: s.Interval} + s.S2 = &S2{IntervalWindow: s2iw} + s.S2.Bind(st) + + s3iw := types.IntervalWindow{Window: 2, Interval: s.Interval} + s.S3 = &S3{IntervalWindow: s3iw} + s.S3.Bind(st) + + s4iw := types.IntervalWindow{Window: 2, Interval: s.Interval} + s.S4 = &S4{IntervalWindow: s4iw} + s.S4.Bind(st) + + s5iw := types.IntervalWindow{Window: 10, Interval: s.Interval} + s.S5 = &S5{IntervalWindow: s5iw} + s.S5.Bind(st) + + s6iw := types.IntervalWindow{Window: 2, Interval: s.Interval} + s.S6 = &S6{IntervalWindow: s6iw} + s.S6.Bind(st) + + s7iw := types.IntervalWindow{Window: 2, Interval: s.Interval} + s.S7 = &S7{IntervalWindow: s7iw} + s.S7.Bind(st) + + a2iw := types.IntervalWindow{Window: 2, Interval: s.Interval} + s.A2 = &A2{IntervalWindow: a2iw} + s.A2.Bind(st) + + a3iw := types.IntervalWindow{Window: 8, Interval: s.Interval} + s.A3 = &A3{IntervalWindow: a3iw} + s.A3.Bind(st) + + a18iw := types.IntervalWindow{Window: 5, Interval: s.Interval} + s.A18 = &A18{IntervalWindow: a18iw} + s.A18.Bind(st) + + a34iw := types.IntervalWindow{Window: 12, Interval: s.Interval} + s.A34 = &A34{IntervalWindow: a34iw} + s.A34.Bind(st) + + session.UserDataStream.OnStart(func() { + log.Infof("connected") + }) + + outlook := 1 + + // futuresMode := s.session.Futures || s.session.IsolatedFutures + cnt := 0 + + // var prevEr float64 + session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { + + // if kline.Interval == types.Interval15m && kline.Symbol == s.Symbol && !s.Market.IsDustQuantity(s.Position.GetBase(), kline.Close) { + // if err := s.activeMakerOrders.GracefulCancel(ctx, s.session.Exchange); err != nil { + // log.WithError(err).Errorf("graceful cancel order error") + // } + // s.ClosePosition(ctx, fixedpoint.One) + // s.tradeCollector.Process() + // } + if kline.Symbol != s.Symbol || kline.Interval != s.Interval { + return + } + + if err := s.activeMakerOrders.GracefulCancel(ctx, s.session.Exchange); err != nil { + log.WithError(err).Errorf("graceful cancel order error") + } + + cnt += 1 + if cnt < 15+1+outlook { + return + } + + r := new(regression.Regression) + r.SetObserved("Return Rate Per Interval") + r.SetVar(0, "S0") + r.SetVar(1, "S1") + r.SetVar(2, "S2") + // r.SetVar(2, "S3") + r.SetVar(3, "S4") + r.SetVar(4, "S5") + r.SetVar(5, "S6") + r.SetVar(6, "S7") + r.SetVar(7, "A2") + r.SetVar(8, "A3") + r.SetVar(9, "A18") + r.SetVar(10, "A34") + + var rdps regression.DataPoints + + for i := 1; i <= 15; i++ { + s0 := s.S0.Values[len(s.S0.Values)-i-outlook] + s1 := s.S1.Values[len(s.S1.Values)-i-outlook] + s2 := s.S2.Values[len(s.S2.Values)-i-outlook] + // s3 := s.S3.Values[len(s.S3.Values)-i-1] + s4 := s.S4.Values[len(s.S4.Values)-i-outlook] + s5 := s.S5.Values[len(s.S5.Values)-i-outlook] + s6 := s.S6.Values[len(s.S6.Values)-i-outlook] + s7 := s.S7.Values[len(s.S7.Values)-i-outlook] + a2 := s.A2.Values[len(s.A2.Values)-i-outlook] + a3 := s.A3.Values[len(s.A3.Values)-i-outlook] + a18 := s.A18.Values[len(s.A18.Values)-i-outlook] + a34 := s.A34.Values[len(s.A34.Values)-i-outlook] + + ret := s.R.Values[len(s.R.Values)-i] + rdps = append(rdps, regression.DataPoint(ret, floats2.Slice{s0, s1, s2, s4, s5, s6, s7, a2, a3, a18, a34})) + } + // for i := 40; i > 20; i-- { + // s0 := preprocessing(s.S0.Values[len(s.S0.Values)-i : len(s.S0.Values)-i+20-outlook]) + // s1 := preprocessing(s.S1.Values[len(s.S1.Values)-i : len(s.S1.Values)-i+20-outlook]) + // s2 := preprocessing(s.S2.Values[len(s.S2.Values)-i : len(s.S2.Values)-i+20-outlook]) + // //s3 := s.S3.Values[len(s.S3.Values)-i-1] + // s4 := preprocessing(s.S4.Values[len(s.S4.Values)-i : len(s.S4.Values)-i+20-outlook]) + // s5 := preprocessing(s.S5.Values[len(s.S5.Values)-i : len(s.S5.Values)-i+20-outlook]) + // a2 := preprocessing(s.A2.Values[len(s.A2.Values)-i : len(s.A2.Values)-i+20-outlook]) + // a3 := preprocessing(s.A3.Values[len(s.A3.Values)-i : len(s.A3.Values)-i+20-outlook]) + // a18 := preprocessing(s.A18.Values[len(s.A18.Values)-i : len(s.A18.Values)-i+20-outlook]) + // a34 := preprocessing(s.A18.Values[len(s.A18.Values)-i : len(s.A18.Values)-i+20-outlook]) + // + // ret := s.R.Values[len(s.R.Values)-i] + // rdps = append(rdps, regression.DataPoint(ret, types.Float64Slice{s0, s1, s2, s4, s5, a2, a3, a18, a34})) + // } + r.Train(rdps...) + r.Run() + er, _ := r.Predict(floats2.Slice{s.S0.Last(0), s.S1.Last(0), s.S2.Last(0), s.S4.Last(0), s.S5.Last(0), s.S6.Last(0), s.S7.Last(0), s.A2.Last(0), s.A3.Last(0), s.A18.Last(0), s.A34.Last(0)}) + log.Infof("Expected Return Rate: %f", er) + + q := new(regression.Regression) + q.SetObserved("Order Quantity Per Interval") + q.SetVar(0, "S0") + q.SetVar(1, "S1") + q.SetVar(2, "S2") + // q.SetVar(2, "S3") + q.SetVar(3, "S4") + q.SetVar(4, "S5") + q.SetVar(5, "S6") + q.SetVar(6, "S7") + q.SetVar(7, "A2") + q.SetVar(8, "A3") + q.SetVar(9, "A18") + q.SetVar(10, "A34") + + var qdps regression.DataPoints + + for i := 1; i <= 15; i++ { + s0 := math.Pow(s.S0.Values[len(s.S0.Values)-i-outlook], 1) + s1 := math.Pow(s.S1.Values[len(s.S1.Values)-i-outlook], 1) + s2 := math.Pow(s.S2.Values[len(s.S2.Values)-i-outlook], 1) + // s3 := s.S3.Values[len(s.S3.Values)-i-1] + s4 := math.Pow(s.S4.Values[len(s.S4.Values)-i-outlook], 1) + s5 := math.Pow(s.S5.Values[len(s.S5.Values)-i-outlook], 1) + s6 := s.S6.Values[len(s.S6.Values)-i-outlook] + s7 := s.S7.Values[len(s.S7.Values)-i-outlook] + a2 := math.Pow(s.A2.Values[len(s.A2.Values)-i-outlook], 1) + a3 := math.Pow(s.A3.Values[len(s.A3.Values)-i-outlook], 1) + a18 := math.Pow(s.A18.Values[len(s.A18.Values)-i-outlook], 1) + a34 := math.Pow(s.A34.Values[len(s.A34.Values)-i-outlook], 1) + + ret := s.R.Values[len(s.R.Values)-i] + qty := math.Abs(ret) + qdps = append(qdps, regression.DataPoint(qty, floats2.Slice{s0, s1, s2, s4, s5, s6, s7, a2, a3, a18, a34})) + } + // for i := 40; i > 20; i-- { + // s0 := preprocessing(s.S0.Values[len(s.S0.Values)-i : len(s.S0.Values)-i+20-outlook]) + // s1 := preprocessing(s.S1.Values[len(s.S1.Values)-i : len(s.S1.Values)-i+20-outlook]) + // s2 := preprocessing(s.S2.Values[len(s.S2.Values)-i : len(s.S2.Values)-i+20-outlook]) + // //s3 := s.S3.Values[len(s.S3.Values)-i-1] + // s4 := preprocessing(s.S4.Values[len(s.S4.Values)-i : len(s.S4.Values)-i+20-outlook]) + // s5 := preprocessing(s.S5.Values[len(s.S5.Values)-i : len(s.S5.Values)-i+20-outlook]) + // a2 := preprocessing(s.A2.Values[len(s.A2.Values)-i : len(s.A2.Values)-i+20-outlook]) + // a3 := preprocessing(s.A3.Values[len(s.A3.Values)-i : len(s.A3.Values)-i+20-outlook]) + // a18 := preprocessing(s.A18.Values[len(s.A18.Values)-i : len(s.A18.Values)-i+20-outlook]) + // a34 := preprocessing(s.A18.Values[len(s.A18.Values)-i : len(s.A18.Values)-i+20-outlook]) + // + // ret := s.R.Values[len(s.R.Values)-i] + // qty := math.Abs(ret) + // qdps = append(qdps, regression.DataPoint(qty, types.Float64Slice{s0, s1, s2, s4, s5, a2, a3, a18, a34})) + // } + q.Train(qdps...) + + q.Run() + + log.Info(s.S0.Last(0), s.S1.Last(0), s.S2.Last(0), s.S3.Last(0), s.S4.Last(0), s.S5.Last(0), s.S6.Last(0), s.S7.Last(0), s.A2.Last(0), s.A3.Last(0), s.A18.Last(0), s.A34.Last(0)) + + log.Infof("Return Rate Regression formula:\n%v", r.Formula) + log.Infof("Order Quantity Regression formula:\n%v", q.Formula) + + // s0 := preprocessing(s.S0.Values[len(s.S0.Values)-20 : len(s.S0.Values)-1]) + // s1 := preprocessing(s.S1.Values[len(s.S1.Values)-20 : len(s.S1.Values)-1-outlook]) + // s2 := preprocessing(s.S2.Values[len(s.S2.Values)-20 : len(s.S2.Values)-1-outlook]) + // //s3 := s.S3.Values[len(s.S3.Values)-i-1] + // s4 := preprocessing(s.S4.Values[len(s.S4.Values)-20 : len(s.S4.Values)-1-outlook]) + // s5 := preprocessing(s.S5.Values[len(s.S5.Values)-20 : len(s.S5.Values)-1-outlook]) + // a2 := preprocessing(s.A2.Values[len(s.A2.Values)-20 : len(s.A2.Values)-1-outlook]) + // a3 := preprocessing(s.A3.Values[len(s.A3.Values)-20 : len(s.A3.Values)-1-outlook]) + // a18 := preprocessing(s.A18.Values[len(s.A18.Values)-20 : len(s.A18.Values)-1-outlook]) + // a34 := preprocessing(s.A18.Values[len(s.A18.Values)-20 : len(s.A18.Values)-1-outlook]) + // er, _ := r.Predict(types.Float64Slice{s0, s1, s2, s4, s5, a2, a3, a18, a34}) + // eq, _ := q.Predict(types.Float64Slice{s0, s1, s2, s4, s5, a2, a3, a18, a34}) + eq, _ := q.Predict(floats2.Slice{s.S0.Last(0), s.S1.Last(0), s.S2.Last(0), s.S4.Last(0), s.S5.Last(0), s.S6.Last(0), s.S7.Last(0), s.A2.Last(0), s.A3.Last(0), s.A18.Last(0), s.A34.Last(0), er}) + log.Infof("Expected Order Quantity: %f", eq) + // if float64(s.Position.GetBase().Sign())*er < 0 { + // s.ClosePosition(ctx, fixedpoint.One, kline.Close) + // s.tradeCollector.Process() + // } + // prevEr = er + + // spd := s.Spread.Float64() + + // inventory = m * alpha + spread + AskAlphaBoundary := (s.Position.GetBase().Mul(kline.Close).Float64() - 100) / 10000 + BidAlphaBoundary := (s.Position.GetBase().Mul(kline.Close).Float64() + 100) / 10000 + + log.Info(s.Position.GetBase().Mul(kline.Close).Float64(), AskAlphaBoundary, er, BidAlphaBoundary) + + BidPrice := kline.Close.Mul(fixedpoint.One.Sub(s.Spread)) + BidQty := s.QuantityOrAmount.CalculateQuantity(BidPrice) + BidQty = BidQty // .Mul(fixedpoint.One.Add(fixedpoint.NewFromFloat(eq))) + + AskPrice := kline.Close.Mul(fixedpoint.One.Add(s.Spread)) + AskQty := s.QuantityOrAmount.CalculateQuantity(AskPrice) + AskQty = AskQty // .Mul(fixedpoint.One.Add(fixedpoint.NewFromFloat(eq))) + + if er > 0 || (er < 0 && er > AskAlphaBoundary/kline.Close.Float64()) { + submitOrder := types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimitMaker, + Price: BidPrice, + Quantity: BidQty, // 0.0005 + } + createdOrders, err := orderExecutor.SubmitOrders(ctx, submitOrder) + if err != nil { + log.WithError(err).Errorf("can not place orders") + } + s.orderStore.Add(createdOrders...) + s.activeMakerOrders.Add(createdOrders...) + s.tradeCollector.Process() + + // submitOrder = types.SubmitOrder{ + // Symbol: s.Symbol, + // Side: types.SideTypeSell, + // Type: types.OrderTypeLimitMaker, + // Price: kline.Close.Mul(fixedpoint.One.Add(s.Spread)), + // Quantity: fixedpoint.NewFromFloat(math.Max(math.Min(eq, 0.003), 0.0005)), //0.0005 + // } + // createdOrders, err = orderExecutor.SubmitOrder(ctx, submitOrder) + // if err != nil { + // log.WithError(err).Errorf("can not place orders") + // } + // s.orderStore.Add(createdOrders...) + // s.activeMakerOrders.Add(createdOrders...) + // s.tradeCollector.Process() + } + if er < 0 || (er > 0 && er < BidAlphaBoundary/kline.Close.Float64()) { + submitOrder := types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeLimitMaker, + Price: AskPrice, + Quantity: AskQty, // 0.0005 + } + createdOrders, err := orderExecutor.SubmitOrders(ctx, submitOrder) + if err != nil { + log.WithError(err).Errorf("can not place orders") + } + s.orderStore.Add(createdOrders...) + s.activeMakerOrders.Add(createdOrders...) + s.tradeCollector.Process() + + // submitOrder = types.SubmitOrder{ + // Symbol: s.Symbol, + // Side: types.SideTypeBuy, + // Type: types.OrderTypeLimitMaker, + // Price: kline.Close.Mul(fixedpoint.One.Sub(s.Spread)), + // Quantity: fixedpoint.NewFromFloat(math.Max(math.Min(eq, 0.003), 0.0005)), //0.0005 + // } + // createdOrders, err = orderExecutor.SubmitOrder(ctx, submitOrder) + // if err != nil { + // log.WithError(err).Errorf("can not place orders") + // } + // s.orderStore.Add(createdOrders...) + // s.activeMakerOrders.Add(createdOrders...) + // s.tradeCollector.Process() + } + + }) + + return nil +} + +func tanh(x float64) float64 { + y := (math.Exp(x) - math.Exp(-x)) / (math.Exp(x) + math.Exp(-x)) + return y +} + +func mean(xs []float64) float64 { + return floats.Sum(xs) / float64(len(xs)) +} + +func stddev(xs []float64) float64 { + mu := mean(xs) + squaresum := 0. + for _, x := range xs { + squaresum += (x - mu) * (x - mu) + } + return math.Sqrt(squaresum / float64(len(xs)-1)) +} + +func preprocessing(xs []float64) float64 { + // return 0.5 * tanh(0.01*((xs[len(xs)-1]-mean(xs))/stddev(xs))) // tanh estimator + return tanh((xs[len(xs)-1] - mean(xs)) / stddev(xs)) // tanh z-score + return (xs[len(xs)-1] - mean(xs)) / stddev(xs) // z-score +} diff --git a/pkg/strategy/grid/strategy.go b/pkg/strategy/grid/strategy.go new file mode 100644 index 0000000..7a5dd48 --- /dev/null +++ b/pkg/strategy/grid/strategy.go @@ -0,0 +1,636 @@ +package grid + +import ( + "context" + "fmt" + "sync" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/core" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/service" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +const ID = "grid" + +var log = logrus.WithField("strategy", ID) + +var notionalModifier = fixedpoint.NewFromFloat(1.0001) + +func init() { + // Register the pointer of the strategy struct, + // so that qbtrade knows what struct to be used to unmarshal the configs (YAML or JSON) + // Note: built-in strategies need to imported manually in the qbtrade cmd package. + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +// State is the grid snapshot +type State struct { + Orders []types.SubmitOrder `json:"orders,omitempty"` + FilledBuyGrids map[fixedpoint.Value]struct{} `json:"filledBuyGrids"` + FilledSellGrids map[fixedpoint.Value]struct{} `json:"filledSellGrids"` + Position *types.Position `json:"position,omitempty"` + + AccumulativeArbitrageProfit fixedpoint.Value `json:"accumulativeArbitrageProfit"` + + // any created orders for tracking trades + // [source Order ID] -> arbitrage order + ArbitrageOrders map[uint64]types.Order `json:"arbitrageOrders"` +} + +type Strategy struct { + // OrderExecutor is an interface for submitting order. + // This field will be injected automatically since it's a single exchange strategy. + qbtrade.OrderExecutor `json:"-" yaml:"-"` + + // 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 `json:"-" yaml:"-"` + + TradeService *service.TradeService `json:"-" yaml:"-"` + + // These fields will be filled from the config file (it translates YAML to JSON) + Symbol string `json:"symbol" yaml:"symbol"` + + // ProfitSpread is the fixed profit spread you want to submit the sell order + ProfitSpread fixedpoint.Value `json:"profitSpread" yaml:"profitSpread"` + + // GridNum is the grid number, how many orders you want to post on the orderbook. + GridNum int64 `json:"gridNumber" yaml:"gridNumber"` + + UpperPrice fixedpoint.Value `json:"upperPrice" yaml:"upperPrice"` + + LowerPrice fixedpoint.Value `json:"lowerPrice" yaml:"lowerPrice"` + + // Quantity is the quantity you want to submit for each order. + Quantity fixedpoint.Value `json:"quantity,omitempty"` + + // QuantityScale helps user to define the quantity by price scale or volume scale + QuantityScale *qbtrade.PriceVolumeScale `json:"quantityScale,omitempty"` + + // FixedAmount is used for fixed amount (dynamic quantity) if you don't want to use fixed quantity. + FixedAmount fixedpoint.Value `json:"amount,omitempty" yaml:"amount"` + + // Side is the initial maker orders side. defaults to "both" + Side types.SideType `json:"side" yaml:"side"` + + // CatchUp let the maker grid catch up with the price change. + CatchUp bool `json:"catchUp" yaml:"catchUp"` + + // Long means you want to hold more base asset than the quote asset. + Long bool `json:"long,omitempty" yaml:"long,omitempty"` + + State *State `persistence:"state"` + + ProfitStats *types.ProfitStats `persistence:"profit_stats"` + + // orderStore is used to store all the created orders, so that we can filter the trades. + orderStore *core.OrderStore + + // activeOrders is the locally maintained active order book of the maker orders. + activeOrders *qbtrade.ActiveOrderBook + + tradeCollector *core.TradeCollector + + // groupID is the group ID used for the strategy instance for canceling orders + groupID uint32 +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) Validate() error { + if s.UpperPrice.IsZero() { + return errors.New("upperPrice can not be zero, you forgot to set?") + } + if s.LowerPrice.IsZero() { + return errors.New("lowerPrice can not be zero, you forgot to set?") + } + if s.UpperPrice.Compare(s.LowerPrice) <= 0 { + return fmt.Errorf("upperPrice (%s) should not be less than or equal to lowerPrice (%s)", s.UpperPrice.String(), s.LowerPrice.String()) + } + + if s.ProfitSpread.Sign() <= 0 { + // If profitSpread is empty or its value is negative + return fmt.Errorf("profit spread should bigger than 0") + } + + if s.Quantity.IsZero() && s.QuantityScale == nil && s.FixedAmount.IsZero() { + return fmt.Errorf("amount, quantity or scaleQuantity can not be zero") + } + + return nil +} + +func (s *Strategy) generateGridSellOrders(session *qbtrade.ExchangeSession) ([]types.SubmitOrder, error) { + currentPrice, ok := session.LastPrice(s.Symbol) + if !ok { + return nil, fmt.Errorf("can not generate sell orders, %s last price not found", s.Symbol) + } + + if currentPrice.Compare(s.UpperPrice) > 0 { + return nil, fmt.Errorf("can not generate sell orders, the current price %s is higher than upper price %s", currentPrice.String(), s.UpperPrice.String()) + } + + priceRange := s.UpperPrice.Sub(s.LowerPrice) + numGrids := fixedpoint.NewFromInt(s.GridNum) + gridSpread := priceRange.Div(numGrids) + + if gridSpread.IsZero() { + return nil, fmt.Errorf( + "either numGrids(%v) is too big or priceRange(%v) is too small, "+ + "the differences of grid prices become zero", numGrids, priceRange) + } + + // find the nearest grid price from the current price + startPrice := fixedpoint.Max( + s.LowerPrice, + s.UpperPrice.Sub( + s.UpperPrice.Sub(currentPrice).Div(gridSpread).Trunc().Mul(gridSpread))) + + if startPrice.Compare(s.UpperPrice) > 0 { + return nil, fmt.Errorf("current price %v exceeded the upper price boundary %v", + currentPrice, + s.UpperPrice) + } + + balances := session.GetAccount().Balances() + baseBalance, ok := balances[s.Market.BaseCurrency] + if !ok { + return nil, fmt.Errorf("base balance %s not found", s.Market.BaseCurrency) + } + + if baseBalance.Available.IsZero() { + return nil, fmt.Errorf("base balance %s is zero: %s", + s.Market.BaseCurrency, baseBalance.String()) + } + + log.Infof("placing grid sell orders from %s ~ %s, grid spread %s", + startPrice.String(), + s.UpperPrice.String(), + gridSpread.String()) + + var orders []types.SubmitOrder + for price := startPrice; price.Compare(s.UpperPrice) <= 0; price = price.Add(gridSpread) { + var quantity fixedpoint.Value + if s.Quantity.Sign() > 0 { + quantity = s.Quantity + } else if s.QuantityScale != nil { + qf, err := s.QuantityScale.Scale(price.Float64(), 0) + if err != nil { + return nil, err + } + quantity = fixedpoint.NewFromFloat(qf) + } else if s.FixedAmount.Sign() > 0 { + quantity = s.FixedAmount.Div(price) + } + + // quoteQuantity := price.Mul(quantity) + if baseBalance.Available.Compare(quantity) < 0 { + return orders, fmt.Errorf("base balance %s %s is not enough, stop generating sell orders", + baseBalance.Currency, + baseBalance.Available.String()) + } + + if _, filled := s.State.FilledSellGrids[price]; filled { + log.Debugf("sell grid at price %s is already filled, skipping", price.String()) + continue + } + + orders = append(orders, types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeLimit, + Market: s.Market, + Quantity: quantity, + Price: price.Add(s.ProfitSpread), + TimeInForce: types.TimeInForceGTC, + GroupID: s.groupID, + }) + baseBalance.Available = baseBalance.Available.Sub(quantity) + + s.State.FilledSellGrids[price] = struct{}{} + } + + return orders, nil +} + +func (s *Strategy) generateGridBuyOrders(session *qbtrade.ExchangeSession) ([]types.SubmitOrder, error) { + // session.Exchange.QueryTicker() + currentPrice, ok := session.LastPrice(s.Symbol) + if !ok { + return nil, fmt.Errorf("%s last price not found, skipping", s.Symbol) + } + + if currentPrice.Compare(s.LowerPrice) < 0 { + return nil, fmt.Errorf("current price %v is lower than the lower price %v", + currentPrice, s.LowerPrice) + } + + priceRange := s.UpperPrice.Sub(s.LowerPrice) + numGrids := fixedpoint.NewFromInt(s.GridNum) + gridSpread := priceRange.Div(numGrids) + + if gridSpread.IsZero() { + return nil, fmt.Errorf( + "either numGrids(%v) is too big or priceRange(%v) is too small, "+ + "the differences of grid prices become zero", numGrids, priceRange) + } + + // Find the nearest grid price for placing buy orders: + // buyRange = currentPrice - lowerPrice + // numOfBuyGrids = Floor(buyRange / gridSpread) + // startPrice = lowerPrice + numOfBuyGrids * gridSpread + // priceOfBuyOrder1 = startPrice + // priceOfBuyOrder2 = startPrice - gridSpread + // priceOfBuyOrder3 = startPrice - gridSpread * 2 + startPrice := fixedpoint.Min( + s.UpperPrice, + s.LowerPrice.Add( + currentPrice.Sub(s.LowerPrice).Div(gridSpread).Trunc().Mul(gridSpread))) + + if startPrice.Compare(s.LowerPrice) < 0 { + return nil, fmt.Errorf("current price %v exceeded the lower price boundary %v", + currentPrice, + s.UpperPrice) + } + + balances := session.GetAccount().Balances() + balance, ok := balances[s.Market.QuoteCurrency] + if !ok { + return nil, fmt.Errorf("quote balance %s not found", s.Market.QuoteCurrency) + } + + if balance.Available.IsZero() { + return nil, fmt.Errorf("quote balance %s is zero: %v", s.Market.QuoteCurrency, balance) + } + + log.Infof("placing grid buy orders from %v to %v, grid spread %v", + startPrice, + s.LowerPrice, + gridSpread) + + var orders []types.SubmitOrder + for price := startPrice; s.LowerPrice.Compare(price) <= 0; price = price.Sub(gridSpread) { + var quantity fixedpoint.Value + if s.Quantity.Sign() > 0 { + quantity = s.Quantity + } else if s.QuantityScale != nil { + qf, err := s.QuantityScale.Scale(price.Float64(), 0) + if err != nil { + return nil, err + } + quantity = fixedpoint.NewFromFloat(qf) + } else if s.FixedAmount.Sign() > 0 { + quantity = s.FixedAmount.Div(price) + } + + quoteQuantity := price.Mul(quantity) + if balance.Available.Compare(quoteQuantity) < 0 { + return orders, fmt.Errorf("quote balance %s %v is not enough for %v, stop generating buy orders", + balance.Currency, + balance.Available, + quoteQuantity) + } + + if _, filled := s.State.FilledBuyGrids[price]; filled { + log.Debugf("buy grid at price %v is already filled, skipping", price) + continue + } + + orders = append(orders, types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Market: s.Market, + Quantity: quantity, + Price: price, + TimeInForce: types.TimeInForceGTC, + GroupID: s.groupID, + }) + balance.Available = balance.Available.Sub(quoteQuantity) + + s.State.FilledBuyGrids[price] = struct{}{} + } + + return orders, nil +} + +func (s *Strategy) placeGridSellOrders(orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + orderForms, err := s.generateGridSellOrders(session) + + if len(orderForms) == 0 { + if err != nil { + return err + } + + return errors.New("none of sell order is generated") + } + + log.Infof("submitting %d sell orders...", len(orderForms)) + createdOrders, err := orderExecutor.SubmitOrders(context.Background(), orderForms...) + s.activeOrders.Add(createdOrders...) + return err +} + +func (s *Strategy) placeGridBuyOrders(orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + orderForms, err := s.generateGridBuyOrders(session) + + if len(orderForms) == 0 { + if err != nil { + return err + } + + return errors.New("none of buy order is generated") + } + + log.Infof("submitting %d buy orders...", len(orderForms)) + createdOrders, err := orderExecutor.SubmitOrders(context.Background(), orderForms...) + s.activeOrders.Add(createdOrders...) + + return err +} + +func (s *Strategy) placeGridOrders(orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) { + log.Infof("placing grid orders on side %s...", s.Side) + + switch s.Side { + + case types.SideTypeBuy: + if err := s.placeGridBuyOrders(orderExecutor, session); err != nil { + log.Warn(err.Error()) + } + + case types.SideTypeSell: + if err := s.placeGridSellOrders(orderExecutor, session); err != nil { + log.Warn(err.Error()) + } + + case types.SideTypeBoth: + if err := s.placeGridSellOrders(orderExecutor, session); err != nil { + log.Warn(err.Error()) + } + + if err := s.placeGridBuyOrders(orderExecutor, session); err != nil { + log.Warn(err.Error()) + } + + default: + log.Errorf("invalid side %s", s.Side) + } +} + +func (s *Strategy) handleFilledOrder(filledOrder types.Order) { + // generate arbitrage order + var side = filledOrder.Side.Reverse() + var price = filledOrder.Price + var quantity = filledOrder.Quantity + var amount = filledOrder.Price.Mul(filledOrder.Quantity) + + switch side { + case types.SideTypeSell: + price = price.Add(s.ProfitSpread) + case types.SideTypeBuy: + price = price.Sub(s.ProfitSpread) + } + + if s.FixedAmount.Sign() > 0 { + quantity = s.FixedAmount.Div(price) + } else if s.Long { + // long = use the same amount to buy more quantity back + quantity = amount.Div(price) + amount = quantity.Mul(price) + } + + if quantity.Compare(s.Market.MinQuantity) < 0 { + quantity = s.Market.MinQuantity + amount = quantity.Mul(price) + } + + if amount.Compare(s.Market.MinNotional) <= 0 { + quantity = qbtrade.AdjustFloatQuantityByMinAmount( + quantity, price, s.Market.MinNotional.Mul(notionalModifier)) + + // update amount + amount = quantity.Mul(price) + } + + submitOrder := types.SubmitOrder{ + Symbol: s.Symbol, + Side: side, + Type: types.OrderTypeLimit, + Quantity: quantity, + Price: price, + TimeInForce: types.TimeInForceGTC, + GroupID: s.groupID, + } + + log.Infof("submitting arbitrage order: %v against filled order %v", submitOrder, filledOrder) + + createdOrders, err := s.OrderExecutor.SubmitOrders(context.Background(), submitOrder) + + // create one-way link from the newly created orders + for _, o := range createdOrders { + s.State.ArbitrageOrders[o.OrderID] = filledOrder + } + + s.orderStore.Add(createdOrders...) + s.activeOrders.Add(createdOrders...) + + if err != nil { + log.WithError(err).Errorf("can not place orders: %+v", submitOrder) + return + } + + // calculate arbitrage profit + // TODO: apply fee rate here + if s.Long { + switch filledOrder.Side { + case types.SideTypeSell: + if buyOrder, ok := s.State.ArbitrageOrders[filledOrder.OrderID]; ok { + // use base asset quantity here + baseProfit := buyOrder.Quantity.Sub(filledOrder.Quantity) + s.State.AccumulativeArbitrageProfit = s.State.AccumulativeArbitrageProfit. + Add(baseProfit) + qbtrade.Notify("%s grid arbitrage profit %v %s, accumulative arbitrage profit %v %s", + s.Symbol, + baseProfit, s.Market.BaseCurrency, + s.State.AccumulativeArbitrageProfit, s.Market.BaseCurrency, + ) + } + + case types.SideTypeBuy: + if sellOrder, ok := s.State.ArbitrageOrders[filledOrder.OrderID]; ok { + // use base asset quantity here + baseProfit := filledOrder.Quantity.Sub(sellOrder.Quantity) + s.State.AccumulativeArbitrageProfit = s.State.AccumulativeArbitrageProfit.Add(baseProfit) + qbtrade.Notify("%s grid arbitrage profit %v %s, accumulative arbitrage profit %v %s", + s.Symbol, + baseProfit, s.Market.BaseCurrency, + s.State.AccumulativeArbitrageProfit, s.Market.BaseCurrency, + ) + } + } + } else if !s.Long && s.Quantity.Sign() > 0 { + switch filledOrder.Side { + case types.SideTypeSell: + if buyOrder, ok := s.State.ArbitrageOrders[filledOrder.OrderID]; ok { + // use base asset quantity here + quoteProfit := filledOrder.Quantity.Mul(filledOrder.Price).Sub( + buyOrder.Quantity.Mul(buyOrder.Price)) + s.State.AccumulativeArbitrageProfit = s.State.AccumulativeArbitrageProfit.Add(quoteProfit) + qbtrade.Notify("%s grid arbitrage profit %v %s, accumulative arbitrage profit %v %s", + s.Symbol, + quoteProfit, s.Market.QuoteCurrency, + s.State.AccumulativeArbitrageProfit, s.Market.QuoteCurrency, + ) + } + case types.SideTypeBuy: + if sellOrder, ok := s.State.ArbitrageOrders[filledOrder.OrderID]; ok { + // use base asset quantity here + quoteProfit := sellOrder.Quantity.Mul(sellOrder.Price). + Sub(filledOrder.Quantity.Mul(filledOrder.Price)) + s.State.AccumulativeArbitrageProfit = s.State.AccumulativeArbitrageProfit.Add(quoteProfit) + qbtrade.Notify("%s grid arbitrage profit %v %s, accumulative arbitrage profit %v %s", s.Symbol, + quoteProfit, s.Market.QuoteCurrency, + s.State.AccumulativeArbitrageProfit, s.Market.QuoteCurrency, + ) + } + } + } +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: "1m"}) +} + +func (s *Strategy) LoadState() error { + if s.State == nil { + s.State = &State{ + FilledBuyGrids: make(map[fixedpoint.Value]struct{}), + FilledSellGrids: make(map[fixedpoint.Value]struct{}), + ArbitrageOrders: make(map[uint64]types.Order), + Position: types.NewPositionFromMarket(s.Market), + } + } + + // field guards + if s.State.ArbitrageOrders == nil { + s.State.ArbitrageOrders = make(map[uint64]types.Order) + } + if s.State.FilledBuyGrids == nil { + s.State.FilledBuyGrids = make(map[fixedpoint.Value]struct{}) + } + if s.State.FilledSellGrids == nil { + s.State.FilledSellGrids = make(map[fixedpoint.Value]struct{}) + } + + return nil +} + +// InstanceID returns the instance identifier from the current grid configuration parameters +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s-%s-%d-%d-%d", ID, s.Symbol, s.GridNum, s.UpperPrice.Int(), s.LowerPrice.Int()) +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + // do some basic validation + if s.GridNum == 0 { + s.GridNum = 10 + } + + if s.Side == "" { + s.Side = types.SideTypeBoth + } + + instanceID := s.InstanceID() + s.groupID = util.FNV32(instanceID) + log.Infof("using group id %d from fnv(%s)", s.groupID, instanceID) + + if s.ProfitStats == nil { + s.ProfitStats = types.NewProfitStats(s.Market) + } + + if err := s.LoadState(); err != nil { + return err + } + + qbtrade.Notify("grid %s position", s.Symbol, s.State.Position) + + s.orderStore = core.NewOrderStore(s.Symbol) + s.orderStore.BindStream(session.UserDataStream) + + // we don't persist orders so that we can not clear the previous orders for now. just need time to support this. + s.activeOrders = qbtrade.NewActiveOrderBook(s.Symbol) + s.activeOrders.OnFilled(s.handleFilledOrder) + s.activeOrders.BindStream(session.UserDataStream) + + s.tradeCollector = core.NewTradeCollector(s.Symbol, s.State.Position, s.orderStore) + + s.tradeCollector.OnTrade(func(trade types.Trade, profit, netProfit fixedpoint.Value) { + qbtrade.Notify(trade) + s.ProfitStats.AddTrade(trade) + }) + + /* + if s.TradeService != nil { + s.tradeCollector.OnTrade(func(trade types.Trade) { + if err := s.TradeService.Mark(ctx, trade.ID, ID); err != nil { + log.WithError(err).Error("trade mark error") + } + }) + } + */ + + s.tradeCollector.OnPositionUpdate(func(position *types.Position) { + qbtrade.Notify(position) + }) + s.tradeCollector.BindStream(session.UserDataStream) + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + submitOrders := s.activeOrders.Backup() + s.State.Orders = submitOrders + qbtrade.Sync(ctx, s) + + // now we can cancel the open orders + log.Infof("canceling active orders...") + if err := session.Exchange.CancelOrders(context.Background(), s.activeOrders.Orders()...); err != nil { + log.WithError(err).Errorf("cancel order error") + } + }) + + session.UserDataStream.OnStart(func() { + // if we have orders in the state data, we can restore them + if len(s.State.Orders) > 0 { + qbtrade.Notify("restoring %s %d grid orders...", s.Symbol, len(s.State.Orders)) + + createdOrders, err := orderExecutor.SubmitOrders(ctx, s.State.Orders...) + if err != nil { + log.WithError(err).Error("active orders restore error") + } + s.activeOrders.Add(createdOrders...) + s.orderStore.Add(createdOrders...) + } else { + // or place new orders + s.placeGridOrders(orderExecutor, session) + } + }) + + if s.CatchUp { + session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { + log.Infof("catchUp mode is enabled, updating grid orders...") + // update grid + s.placeGridOrders(orderExecutor, session) + }) + } + + return nil +} diff --git a/pkg/strategy/grid2/active_order_recover.go b/pkg/strategy/grid2/active_order_recover.go new file mode 100644 index 0000000..a30811e --- /dev/null +++ b/pkg/strategy/grid2/active_order_recover.go @@ -0,0 +1,98 @@ +package grid2 + +import ( + "context" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/retry" + "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/common" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +func (s *Strategy) initializeRecoverC() bool { + s.mu.Lock() + defer s.mu.Unlock() + + isInitialize := false + + if s.recoverC == nil { + s.logger.Info("initializing recover channel") + s.recoverC = make(chan struct{}, 1) + } else { + s.logger.Info("recover channel is already initialized, trigger active orders recover") + isInitialize = true + + select { + case s.recoverC <- struct{}{}: + s.logger.Info("trigger active orders recover") + default: + s.logger.Info("activeOrdersRecoverC is full") + } + } + + return isInitialize +} + +func (s *Strategy) recoverActiveOrdersPeriodically(ctx context.Context) { + // every time we activeOrdersRecoverC receive signal, do active orders recover + if isInitialize := s.initializeRecoverC(); isInitialize { + return + } + + // make ticker's interval random in 25 min ~ 35 min + interval := util.MillisecondsJitter(25*time.Minute, 10*60*1000) + s.logger.Infof("[ActiveOrderRecover] interval: %s", interval) + + metricsLabel := s.newPrometheusLabels() + + orderQueryService, ok := s.session.Exchange.(types.ExchangeOrderQueryService) + if !ok { + s.logger.Errorf("exchange %s doesn't support ExchangeOrderQueryService, please check it", s.session.ExchangeName) + return + } + + opts := common.SyncActiveOrdersOpts{ + Logger: s.logger, + Exchange: s.session.Exchange, + OrderQueryService: orderQueryService, + ActiveOrderBook: s.orderExecutor.ActiveMakerOrders(), + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + var lastRecoverTime time.Time + + for { + select { + case <-ctx.Done(): + return + + case <-ticker.C: + s.recoverC <- struct{}{} + case <-s.recoverC: + if !time.Now().After(lastRecoverTime.Add(10 * time.Minute)) { + continue + } + + openOrders, err := retry.QueryOpenOrdersUntilSuccessfulLite(ctx, s.session.Exchange, s.Symbol) + if err != nil { + s.logger.WithError(err).Error("[ActiveOrderRecover] failed to query open orders, skip this time") + continue + } + + if metricsNumOfOpenOrders != nil { + metricsNumOfOpenOrders.With(metricsLabel).Set(float64(len(openOrders))) + } + + opts.OpenOrders = openOrders + + if err := common.SyncActiveOrders(ctx, opts); err != nil { + log.WithError(err).Errorf("unable to sync active orders") + } else { + lastRecoverTime = time.Now() + } + } + } +} diff --git a/pkg/strategy/grid2/backtest_test.go b/pkg/strategy/grid2/backtest_test.go new file mode 100644 index 0000000..57930b6 --- /dev/null +++ b/pkg/strategy/grid2/backtest_test.go @@ -0,0 +1,184 @@ +//go:build !dnum + +package grid2 + +import ( + "context" + "os" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/backtest" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/service" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +func RunBacktest(t *testing.T, strategy qbtrade.SingleExchangeStrategy) { + // TEMPLATE {{{ start backtest + const sqliteDbFile = "../../../data/qbtrade_test.sqlite3" + const backtestExchangeName = "binance" + const backtestStartTime = "2022-06-01" + const backtestEndTime = "2022-06-30" + + startTime, err := types.ParseLooseFormatTime(backtestStartTime) + assert.NoError(t, err) + + endTime, err := types.ParseLooseFormatTime(backtestEndTime) + assert.NoError(t, err) + + backtestConfig := &qbtrade.Backtest{ + StartTime: startTime, + EndTime: &endTime, + RecordTrades: false, + FeeMode: qbtrade.BacktestFeeModeToken, + Accounts: map[string]qbtrade.BacktestAccount{ + backtestExchangeName: { + MakerFeeRate: number(0.075 * 0.01), + TakerFeeRate: number(0.075 * 0.01), + Balances: qbtrade.BacktestAccountBalanceMap{ + "USDT": number(10_000.0), + "BTC": number(1.0), + }, + }, + }, + Symbols: []string{"BTCUSDT"}, + Sessions: []string{backtestExchangeName}, + SyncSecKLines: false, + } + + t.Logf("backtestConfig: %+v", backtestConfig) + + ctx := context.Background() + environ := qbtrade.NewEnvironment() + environ.SetStartTime(startTime.Time()) + + info, err := os.Stat(sqliteDbFile) + assert.NoError(t, err) + t.Logf("sqlite: %+v", info) + + err = environ.ConfigureDatabaseDriver(ctx, "sqlite3", sqliteDbFile) + if !assert.NoError(t, err) { + return + } + + backtestService := &service.BacktestService{DB: environ.DatabaseService.DB} + defer func() { + err := environ.DatabaseService.DB.Close() + assert.NoError(t, err) + }() + + environ.BacktestService = backtestService + qbtrade.SetBackTesting(backtestService) + defer qbtrade.SetBackTesting(nil) + + exName, err := types.ValidExchangeName(backtestExchangeName) + if !assert.NoError(t, err) { + return + } + + t.Logf("using exchange source: %s", exName) + + publicExchange, err := exchange.NewPublic(exName) + if !assert.NoError(t, err) { + return + } + + backtestExchange, err := backtest.NewExchange(exName, publicExchange, backtestService, backtestConfig) + if !assert.NoError(t, err) { + return + } + + session := environ.AddExchange(backtestExchangeName, backtestExchange) + assert.NotNil(t, session) + + err = environ.Init(ctx) + assert.NoError(t, err) + + for _, ses := range environ.Sessions() { + userDataStream := ses.UserDataStream.(types.StandardStreamEmitter) + backtestEx := ses.Exchange.(*backtest.Exchange) + backtestEx.MarketDataStream = ses.MarketDataStream.(types.StandardStreamEmitter) + backtestEx.BindUserData(userDataStream) + } + + trader := qbtrade.NewTrader(environ) + if assert.NotNil(t, trader) { + trader.DisableLogging() + } + + userConfig := &qbtrade.Config{ + Backtest: backtestConfig, + ExchangeStrategies: []qbtrade.ExchangeStrategyMount{ + { + Mounts: []string{backtestExchangeName}, + Strategy: strategy, + }, + }, + } + + err = trader.Configure(userConfig) + assert.NoError(t, err) + + err = trader.Run(ctx) + assert.NoError(t, err) + + allKLineIntervals, requiredInterval, backTestIntervals := backtest.CollectSubscriptionIntervals(environ) + t.Logf("requiredInterval: %s backTestIntervals: %v", requiredInterval, backTestIntervals) + + _ = allKLineIntervals + exchangeSources, err := backtest.InitializeExchangeSources(environ.Sessions(), startTime.Time(), endTime.Time(), requiredInterval, backTestIntervals...) + if !assert.NoError(t, err) { + return + } + + doneC := make(chan struct{}) + go func() { + count := 0 + exSource := exchangeSources[0] + for k := range exSource.C { + exSource.Exchange.ConsumeKLine(k, requiredInterval) + count++ + } + + err = exSource.Exchange.CloseMarketData() + assert.NoError(t, err) + + assert.Greater(t, count, 0, "kLines count must be greater than 0, please check your backtest date range and symbol settings") + + close(doneC) + }() + + <-doneC + // }}} +} + +func TestBacktestStrategy(t *testing.T) { + if v, ok := util.GetEnvVarBool("TEST_BACKTEST"); !ok || !v { + t.Skip("backtest flag is required") + return + } + + market := types.Market{ + BaseCurrency: "BTC", + QuoteCurrency: "USDT", + TickSize: number(0.01), + PricePrecision: 2, + VolumePrecision: 8, + } + strategy := &Strategy{ + logger: logrus.NewEntry(logrus.New()), + Symbol: "BTCUSDT", + Market: market, + GridProfitStats: newGridProfitStats(market), + UpperPrice: number(60_000), + LowerPrice: number(28_000), + GridNum: 100, + QuoteInvestment: number(9000.0), + } + RunBacktest(t, strategy) +} diff --git a/pkg/strategy/grid2/debug.go b/pkg/strategy/grid2/debug.go new file mode 100644 index 0000000..6da84d8 --- /dev/null +++ b/pkg/strategy/grid2/debug.go @@ -0,0 +1,56 @@ +package grid2 + +import ( + "fmt" + "strings" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func debugGrid(logger logrus.FieldLogger, grid *Grid, book *qbtrade.ActiveOrderBook) { + var sb strings.Builder + + sb.WriteString("================== GRID ORDERS ==================\n") + + pins := grid.Pins + missingPins := scanMissingPinPrices(book, pins) + missing := len(missingPins) + + for i := len(pins) - 1; i >= 0; i-- { + pin := pins[i] + price := fixedpoint.Value(pin) + + sb.WriteString(fmt.Sprintf("%s -> ", price.String())) + + existingOrder := book.Lookup(func(o types.Order) bool { + return o.Price.Eq(price) + }) + + if existingOrder != nil { + sb.WriteString(existingOrder.String()) + + switch existingOrder.Status { + case types.OrderStatusFilled: + sb.WriteString(" | 🔧") + case types.OrderStatusCanceled: + sb.WriteString(" | 🔄") + default: + sb.WriteString(" | ✅") + } + } else { + sb.WriteString("ORDER MISSING ⚠️ ") + if missing == 1 { + sb.WriteString(" COULD BE EMPTY SLOT") + } + } + sb.WriteString("\n") + } + + sb.WriteString("================== END OF GRID ORDERS ===================") + + logger.Infoln(sb.String()) +} diff --git a/pkg/strategy/grid2/grid.go b/pkg/strategy/grid2/grid.go new file mode 100644 index 0000000..5b4cfa9 --- /dev/null +++ b/pkg/strategy/grid2/grid.go @@ -0,0 +1,255 @@ +package grid2 + +import ( + "fmt" + "math" + "sort" + "strconv" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type PinCalculator func() []Pin + +type Grid struct { + UpperPrice fixedpoint.Value `json:"upperPrice"` + LowerPrice fixedpoint.Value `json:"lowerPrice"` + + // Size is the number of total grids + Size fixedpoint.Value `json:"size"` + + // TickSize is the price tick size, this is used for truncating price + TickSize fixedpoint.Value `json:"tickSize"` + + // Spread is a immutable number + Spread fixedpoint.Value `json:"spread"` + + // Pins are the pinned grid prices, from low to high + Pins []Pin `json:"pins"` + + pinsCache map[Pin]struct{} `json:"-"` + + calculator PinCalculator +} + +type Pin fixedpoint.Value + +// roundAndTruncatePrice rounds the given price at prec-1 and then truncate the price at prec +func roundAndTruncatePrice(p fixedpoint.Value, prec int) fixedpoint.Value { + var pow10 = math.Pow10(prec) + pp := math.Round(p.Float64()*pow10*10.0) / 10.0 + pp = math.Trunc(pp) / pow10 + + pps := strconv.FormatFloat(pp, 'f', prec, 64) + price := fixedpoint.MustNewFromString(pps) + return price +} + +func removeDuplicatedPins(pins []Pin) []Pin { + var buckets = map[string]struct{}{} + var out []Pin + + for _, pin := range pins { + p := fixedpoint.Value(pin) + + if _, exists := buckets[p.String()]; exists { + continue + } + + out = append(out, pin) + buckets[p.String()] = struct{}{} + } + + return out +} + +func calculateArithmeticPins(lower, upper, spread, tickSize fixedpoint.Value) []Pin { + var pins []Pin + + // tickSize number is like 0.01, 0.1, 0.001 + var ts = tickSize.Float64() + var prec = int(math.Round(math.Log10(ts) * -1.0)) + for p := lower; p.Compare(upper.Sub(spread)) <= 0; p = p.Add(spread) { + price := roundAndTruncatePrice(p, prec) + pins = append(pins, Pin(price)) + } + + // this makes sure there is no error at the upper price + upperPrice := roundAndTruncatePrice(upper, prec) + pins = append(pins, Pin(upperPrice)) + + return pins +} + +func buildPinCache(pins []Pin) map[Pin]struct{} { + cache := make(map[Pin]struct{}, len(pins)) + for _, pin := range pins { + cache[pin] = struct{}{} + } + + return cache +} + +func NewGrid(lower, upper, size, tickSize fixedpoint.Value) *Grid { + height := upper.Sub(lower) + one := fixedpoint.NewFromInt(1) + spread := height.Div(size.Sub(one)) + + grid := &Grid{ + UpperPrice: upper, + LowerPrice: lower, + Size: size, + TickSize: tickSize, + Spread: spread, + } + + return grid +} + +func (g *Grid) CalculateGeometricPins() { + g.calculator = func() []Pin { + // TODO: implement geometric calculator + // return calculateArithmeticPins(g.LowerPrice, g.UpperPrice, g.Spread, g.TickSize) + return nil + } + + g.addPins(removeDuplicatedPins(g.calculator())) +} + +func (g *Grid) CalculateArithmeticPins() { + g.calculator = func() []Pin { + one := fixedpoint.NewFromInt(1) + height := g.UpperPrice.Sub(g.LowerPrice) + spread := height.Div(g.Size.Sub(one)) + return calculateArithmeticPins(g.LowerPrice, g.UpperPrice, spread, g.TickSize) + } + + g.addPins(g.calculator()) +} + +func (g *Grid) Height() fixedpoint.Value { + return g.UpperPrice.Sub(g.LowerPrice) +} + +func (g *Grid) Above(price fixedpoint.Value) bool { + return price.Compare(g.UpperPrice) > 0 +} + +func (g *Grid) Below(price fixedpoint.Value) bool { + return price.Compare(g.LowerPrice) < 0 +} + +func (g *Grid) OutOfRange(price fixedpoint.Value) bool { + return price.Compare(g.LowerPrice) < 0 || price.Compare(g.UpperPrice) > 0 +} + +func (g *Grid) HasPin(pin Pin) (ok bool) { + _, ok = g.pinsCache[pin] + return ok +} + +// NextHigherPin finds the next higher pin +func (g *Grid) NextHigherPin(price fixedpoint.Value) (Pin, bool) { + i := g.SearchPin(price) + if i < len(g.Pins) && fixedpoint.Value(g.Pins[i]).Compare(price) == 0 && i+1 < len(g.Pins) { + return g.Pins[i+1], true + } + + return Pin(fixedpoint.Zero), false +} + +// NextLowerPin finds the next lower pin +func (g *Grid) NextLowerPin(price fixedpoint.Value) (Pin, bool) { + i := g.SearchPin(price) + if i < len(g.Pins) && fixedpoint.Value(g.Pins[i]).Compare(price) == 0 && i-1 >= 0 { + return g.Pins[i-1], true + } + + return Pin(fixedpoint.Zero), false +} + +func (g *Grid) FilterOrders(orders []types.Order) (ret []types.Order) { + for _, o := range orders { + if !g.HasPrice(o.Price) { + continue + } + + ret = append(ret, o) + } + + return ret +} + +func (g *Grid) HasPrice(price fixedpoint.Value) bool { + if _, exists := g.pinsCache[Pin(price)]; exists { + return exists + } + + i := g.SearchPin(price) + if i >= 0 && i < len(g.Pins) { + return fixedpoint.Value(g.Pins[i]).Compare(price) == 0 + } + return false +} + +func (g *Grid) SearchPin(price fixedpoint.Value) int { + i := sort.Search(len(g.Pins), func(i int) bool { + a := fixedpoint.Value(g.Pins[i]) + return a.Compare(price) >= 0 + }) + return i +} + +func (g *Grid) ExtendUpperPrice(upper fixedpoint.Value) (newPins []Pin) { + if upper.Compare(g.UpperPrice) <= 0 { + return nil + } + + newPins = calculateArithmeticPins(g.UpperPrice.Add(g.Spread), upper, g.Spread, g.TickSize) + g.UpperPrice = upper + g.addPins(newPins) + return newPins +} + +func (g *Grid) ExtendLowerPrice(lower fixedpoint.Value) (newPins []Pin) { + if lower.Compare(g.LowerPrice) >= 0 { + return nil + } + + n := g.LowerPrice.Sub(lower).Div(g.Spread).Floor() + lower = g.LowerPrice.Sub(g.Spread.Mul(n)) + newPins = calculateArithmeticPins(lower, g.LowerPrice.Sub(g.Spread), g.Spread, g.TickSize) + + g.LowerPrice = lower + g.addPins(newPins) + return newPins +} + +func (g *Grid) TopPin() Pin { + return g.Pins[len(g.Pins)-1] +} + +func (g *Grid) BottomPin() Pin { + return g.Pins[0] +} + +func (g *Grid) addPins(pins []Pin) { + g.Pins = append(g.Pins, pins...) + + sort.Slice(g.Pins, func(i, j int) bool { + a := fixedpoint.Value(g.Pins[i]) + b := fixedpoint.Value(g.Pins[j]) + return a.Compare(b) < 0 + }) + + g.updatePinsCache() +} + +func (g *Grid) updatePinsCache() { + g.pinsCache = buildPinCache(g.Pins) +} + +func (g *Grid) String() string { + return fmt.Sprintf("GRID: priceRange: %f <=> %f size: %f spread: %f tickSize: %f", g.LowerPrice.Float64(), g.UpperPrice.Float64(), g.Size.Float64(), g.Spread.Float64(), g.TickSize.Float64()) +} diff --git a/pkg/strategy/grid2/grid_dnum_test.go b/pkg/strategy/grid2/grid_dnum_test.go new file mode 100644 index 0000000..5070a65 --- /dev/null +++ b/pkg/strategy/grid2/grid_dnum_test.go @@ -0,0 +1,93 @@ +//go:build dnum + +package grid2 + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGrid_HasPrice_Dnum(t *testing.T) { + t.Run("case1", func(t *testing.T) { + upper := number(500.0) + lower := number(100.0) + size := number(5.0) + grid := NewGrid(lower, upper, size, number(0.01)) + grid.CalculateArithmeticPins() + + assert.True(t, grid.HasPrice(number(500.0)), "upper price") + assert.True(t, grid.HasPrice(number(100.0)), "lower price") + assert.True(t, grid.HasPrice(number(200.0)), "found 200 price ok") + assert.True(t, grid.HasPrice(number(300.0)), "found 300 price ok") + }) + + t.Run("case2", func(t *testing.T) { + upper := number(0.9) + lower := number(0.1) + size := number(7.0) + grid := NewGrid(lower, upper, size, number(0.00000001)) + grid.CalculateArithmeticPins() + + assert.Equal(t, []Pin{ + Pin(number("0.1")), + Pin(number("0.23333333")), + Pin(number("0.36666666")), + Pin(number("0.50000000")), + Pin(number("0.63333333")), + Pin(number("0.76666666")), + Pin(number("0.9")), + }, grid.Pins) + + assert.False(t, grid.HasPrice(number(200.0)), "out of range") + assert.True(t, grid.HasPrice(number(0.9)), "upper price") + assert.True(t, grid.HasPrice(number(0.1)), "lower price") + assert.True(t, grid.HasPrice(number(0.5)), "found 0.49999999 price ok") + }) + + t.Run("case3", func(t *testing.T) { + upper := number(0.9) + lower := number(0.1) + size := number(7.0) + grid := NewGrid(lower, upper, size, number(0.0001)) + grid.CalculateArithmeticPins() + + assert.Equal(t, []Pin{ + Pin(number(0.1)), + Pin(number(0.2333)), + Pin(number(0.3666)), + Pin(number(0.5000)), + Pin(number(0.6333)), + Pin(number(0.7666)), + Pin(number(0.9)), + }, grid.Pins) + + assert.False(t, grid.HasPrice(number(200.0)), "out of range") + assert.True(t, grid.HasPrice(number(0.9)), "upper price") + assert.True(t, grid.HasPrice(number(0.1)), "lower price") + assert.True(t, grid.HasPrice(number(0.5)), "found 0.5 price ok") + assert.True(t, grid.HasPrice(number(0.2333)), "found 0.2333 price ok") + }) + + t.Run("case4", func(t *testing.T) { + upper := number(90.0) + lower := number(10.0) + size := number(7.0) + grid := NewGrid(lower, upper, size, number(0.001)) + grid.CalculateArithmeticPins() + + assert.Equal(t, []Pin{ + Pin(number("10.0")), + Pin(number("23.333")), + Pin(number("36.666")), + Pin(number("50.00")), + Pin(number("63.333")), + Pin(number("76.666")), + Pin(number("90.0")), + }, grid.Pins) + + assert.False(t, grid.HasPrice(number(200.0)), "out of range") + assert.True(t, grid.HasPrice(number("36.666")), "found 36.666 price ok") + }) + +} diff --git a/pkg/strategy/grid2/grid_int64_test.go b/pkg/strategy/grid2/grid_int64_test.go new file mode 100644 index 0000000..73ba412 --- /dev/null +++ b/pkg/strategy/grid2/grid_int64_test.go @@ -0,0 +1,93 @@ +//go:build !dnum + +package grid2 + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGrid_HasPrice(t *testing.T) { + t.Run("case1", func(t *testing.T) { + upper := number(500.0) + lower := number(100.0) + size := number(5.0) + grid := NewGrid(lower, upper, size, number(0.01)) + grid.CalculateArithmeticPins() + + assert.True(t, grid.HasPrice(number(500.0)), "upper price") + assert.True(t, grid.HasPrice(number(100.0)), "lower price") + assert.True(t, grid.HasPrice(number(200.0)), "found 200 price ok") + assert.True(t, grid.HasPrice(number(300.0)), "found 300 price ok") + }) + + t.Run("case2", func(t *testing.T) { + upper := number(0.9) + lower := number(0.1) + size := number(7.0) + grid := NewGrid(lower, upper, size, number(0.00000001)) + grid.CalculateArithmeticPins() + + assert.Equal(t, []Pin{ + Pin(number(0.1)), + Pin(number(0.23333333)), + Pin(number(0.36666666)), + Pin(number(0.49999999)), + Pin(number(0.63333332)), + Pin(number(0.76666665)), + Pin(number(0.9)), + }, grid.Pins) + + assert.False(t, grid.HasPrice(number(200.0)), "out of range") + assert.True(t, grid.HasPrice(number(0.9)), "upper price") + assert.True(t, grid.HasPrice(number(0.1)), "lower price") + assert.True(t, grid.HasPrice(number(0.49999999)), "found 0.49999999 price ok") + }) + + t.Run("case3", func(t *testing.T) { + upper := number(0.9) + lower := number(0.1) + size := number(7.0) + grid := NewGrid(lower, upper, size, number(0.0001)) + grid.CalculateArithmeticPins() + + assert.Equal(t, []Pin{ + Pin(number(0.1)), + Pin(number(0.2333)), + Pin(number(0.3666)), + Pin(number(0.5000)), + Pin(number(0.6333)), + Pin(number(0.7666)), + Pin(number(0.9)), + }, grid.Pins) + + assert.False(t, grid.HasPrice(number(200.0)), "out of range") + assert.True(t, grid.HasPrice(number(0.9)), "upper price") + assert.True(t, grid.HasPrice(number(0.1)), "lower price") + assert.True(t, grid.HasPrice(number(0.5)), "found 0.5 price ok") + assert.True(t, grid.HasPrice(number(0.2333)), "found 0.2333 price ok") + }) + + t.Run("case4", func(t *testing.T) { + upper := number(90.0) + lower := number(10.0) + size := number(7.0) + grid := NewGrid(lower, upper, size, number(0.001)) + grid.CalculateArithmeticPins() + + assert.Equal(t, []Pin{ + Pin(number("10.0")), + Pin(number("23.333")), + Pin(number("36.666")), + Pin(number("50.00")), + Pin(number("63.333")), + Pin(number("76.666")), + Pin(number("90.0")), + }, grid.Pins) + + assert.False(t, grid.HasPrice(number(200.0)), "out of range") + assert.True(t, grid.HasPrice(number("36.666")), "found 36.666 price ok") + }) + +} diff --git a/pkg/strategy/grid2/grid_recover.go b/pkg/strategy/grid2/grid_recover.go new file mode 100644 index 0000000..e27704b --- /dev/null +++ b/pkg/strategy/grid2/grid_recover.go @@ -0,0 +1,365 @@ +package grid2 + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/pkg/errors" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/retry" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func (s *Strategy) recoverByScanningTrades(ctx context.Context, session *qbtrade.ExchangeSession) error { + defer func() { + s.updateGridNumOfOrdersMetricsWithLock() + }() + isMax := exchange.IsMaxExchange(session.Exchange) + s.logger.Infof("isMax: %t", isMax) + + historyService, implemented := session.Exchange.(types.ExchangeTradeHistoryService) + // if the exchange doesn't support ExchangeTradeHistoryService, do not run recover + if !implemented { + s.logger.Warn("ExchangeTradeHistoryService is not implemented, can not recover grid") + return nil + } + + openOrders, err := session.Exchange.QueryOpenOrders(ctx, s.Symbol) + if err != nil { + return errors.Wrapf(err, "unable to query open orders when recovering") + } + + s.logger.Infof("found %d open orders left on the %s order book", len(openOrders), s.Symbol) + + if s.GridProfitStats.InitialOrderID != 0 { + s.logger.Info("InitialOrderID is already there, need to recover") + } else if len(openOrders) != 0 { + s.logger.Info("even though InitialOrderID is 0, there are open orders so need to recover") + } else { + s.logger.Info("InitialOrderID is 0 and there is no open orders, query trades to check it") + // initial order id may be new strategy or lost data in redis, so we need to check trades + open orders + // if there are open orders or trades, we need to recover + trades, err := historyService.QueryTrades(ctx, s.Symbol, &types.TradeQueryOptions{ + // from 1, because some API will ignore 0 last trade id + LastTradeID: 1, + // if there is any trades, we need to recover. + Limit: 1, + }) + + if err != nil { + return errors.Wrapf(err, "unable to query trades when recovering") + } + + if len(trades) == 0 { + s.logger.Info("0 trades found, it's a new strategy so no need to recover") + return nil + } + } + + s.logger.Infof("start recovering") + filledOrders, err := s.getFilledOrdersByScanningTrades(ctx, historyService, s.orderQueryService, openOrders) + if err != nil { + return errors.Wrap(err, "grid recover error") + } + s.debugOrders("emit filled orders", filledOrders) + + // add open orders into avtive maker orders + s.addOrdersToActiveOrderBook(openOrders) + + // emit the filled orders + activeOrderBook := s.orderExecutor.ActiveMakerOrders() + for _, filledOrder := range filledOrders { + activeOrderBook.EmitFilled(filledOrder) + } + + // emit ready after recover + s.EmitGridReady() + + // debug and send metrics + // wait for the reverse order to be placed + time.Sleep(2 * time.Second) + debugGrid(s.logger, s.grid, s.orderExecutor.ActiveMakerOrders()) + + defer qbtrade.Sync(ctx, s) + + if s.EnableProfitFixer { + until := time.Now() + since := until.Add(-7 * 24 * time.Hour) + if s.FixProfitSince != nil { + since = s.FixProfitSince.Time() + } + + fixer := newProfitFixer(s.grid, s.Symbol, historyService) + fixer.SetLogger(s.logger) + + // set initial order ID = 0 instead of s.GridProfitStats.InitialOrderID because the order ID could be incorrect + if err := fixer.Fix(ctx, since, until, 0, s.GridProfitStats); err != nil { + return err + } + + s.logger.Infof("fixed profitStats: %#v", s.GridProfitStats) + + s.EmitGridProfit(s.GridProfitStats, nil) + } + + return nil +} + +func (s *Strategy) getFilledOrdersByScanningTrades(ctx context.Context, queryTradesService types.ExchangeTradeHistoryService, queryOrderService types.ExchangeOrderQueryService, openOrdersOnGrid []types.Order) ([]types.Order, error) { + // set grid + grid := s.newGrid() + s.setGrid(grid) + + expectedNumOfOrders := s.GridNum - 1 + numGridOpenOrders := int64(len(openOrdersOnGrid)) + s.debugLog("open orders nums: %d, expected nums: %d", numGridOpenOrders, expectedNumOfOrders) + if expectedNumOfOrders == numGridOpenOrders { + // no need to recover, only need to add open orders back to active order book + return nil, nil + } else if expectedNumOfOrders < numGridOpenOrders { + return nil, fmt.Errorf("amount of grid's open orders should not > amount of expected grid's orders") + } + + // 1. build twin-order map + twinOrdersOpen, err := s.buildTwinOrderMap(grid.Pins, openOrdersOnGrid) + if err != nil { + return nil, errors.Wrapf(err, "failed to build pin order map with open orders") + } + + // 2. build the filled twin-order map by querying trades + expectedFilledNum := int(expectedNumOfOrders - numGridOpenOrders) + twinOrdersFilled, err := s.buildFilledTwinOrderMapFromTrades(ctx, queryTradesService, queryOrderService, twinOrdersOpen, expectedFilledNum) + if err != nil { + return nil, errors.Wrapf(err, "failed to build filled pin order map") + } + + // 3. get the filled orders from twin-order map + filledOrders := twinOrdersFilled.AscendingOrders() + + // 4. verify the grid + if err := s.verifyFilledTwinGrid(s.grid.Pins, twinOrdersOpen, filledOrders); err != nil { + return nil, errors.Wrapf(err, "verify grid with error") + } + + return filledOrders, nil +} + +func (s *Strategy) verifyFilledTwinGrid(pins []Pin, twinOrders TwinOrderMap, filledOrders []types.Order) error { + s.debugLog("verifying filled grid - pins: %+v", pins) + s.debugOrders("verifying filled grid - filled orders", filledOrders) + s.debugLog("verifying filled grid - open twin orders:\n%s", twinOrders.String()) + + if err := s.addOrdersIntoTwinOrderMap(twinOrders, filledOrders); err != nil { + return errors.Wrapf(err, "verifying filled grid error when add orders into twin order map") + } + + s.debugLog("verifying filled grid - filled twin orders:\n%+v", twinOrders.String()) + + for i, pin := range pins { + // we use twinOrderMap to make sure there are no duplicated order at one grid, and we use the sell price as key so we skip the pins[0] which is only for buy price + if i == 0 { + continue + } + + twin, exist := twinOrders[fixedpoint.Value(pin)] + if !exist { + return fmt.Errorf("there is no order at price (%+v)", pin) + } + + if !twin.Exist() { + return fmt.Errorf("all the price need a twin") + } + + if !twin.IsValid() { + return fmt.Errorf("all the twins need to be valid") + } + } + + return nil +} + +// buildTwinOrderMap build the pin-order map with grid and open orders. +// The keys of this map contains all required pins of this grid. +// If the Order of the pin is empty types.Order (OrderID == 0), it means there is no open orders at this pin. +func (s *Strategy) buildTwinOrderMap(pins []Pin, openOrders []types.Order) (TwinOrderMap, error) { + twinOrderMap := make(TwinOrderMap) + + for i, pin := range pins { + // twin order map only use sell price as key, so skip 0 + if i == 0 { + continue + } + + twinOrderMap[fixedpoint.Value(pin)] = TwinOrder{} + } + + for _, openOrder := range openOrders { + twinKey, err := findTwinOrderMapKey(s.grid, openOrder) + if err != nil { + return nil, errors.Wrapf(err, "failed to build twin order map") + } + + twinOrder, exist := twinOrderMap[twinKey] + if !exist { + return nil, fmt.Errorf("the price of the openOrder (id: %d) is not in pins", openOrder.OrderID) + } + + if twinOrder.Exist() { + return nil, fmt.Errorf("there are multiple order in a twin") + } + + twinOrder.SetOrder(openOrder) + twinOrderMap[twinKey] = twinOrder + } + + return twinOrderMap, nil +} + +// buildFilledTwinOrderMapFromTrades will query the trades from last 24 hour and use them to build a pin order map +// It will skip the orders on pins at which open orders are already +func (s *Strategy) buildFilledTwinOrderMapFromTrades(ctx context.Context, queryTradesService types.ExchangeTradeHistoryService, queryOrderService types.ExchangeOrderQueryService, twinOrdersOpen TwinOrderMap, expectedFillNum int) (TwinOrderMap, error) { + twinOrdersFilled := make(TwinOrderMap) + + // existedOrders is used to avoid re-query the same orders + existedOrders := twinOrdersOpen.SyncOrderMap() + + // get the filled orders when qbtrade is down in order from trades + until := time.Now() + // the first query only query the last 1 hour, because mostly shutdown and recovery happens within 1 hour + since := until.Add(-1 * time.Hour) + // hard limit for recover + recoverSinceLimit := time.Date(2023, time.March, 10, 0, 0, 0, 0, time.UTC) + + if s.RecoverGridWithin != 0 && until.Add(-1*s.RecoverGridWithin).After(recoverSinceLimit) { + recoverSinceLimit = until.Add(-1 * s.RecoverGridWithin) + } + + for { + if err := s.queryTradesToUpdateTwinOrdersMap(ctx, queryTradesService, queryOrderService, twinOrdersOpen, twinOrdersFilled, existedOrders, since, until); err != nil { + return nil, errors.Wrapf(err, "failed to query trades to update twin orders map") + } + + until = since + since = until.Add(-6 * time.Hour) + + if len(twinOrdersFilled) >= expectedFillNum { + s.logger.Infof("stop querying trades because twin orders filled (%d) >= expected filled nums (%d)", len(twinOrdersFilled), expectedFillNum) + break + } + + if s.GridProfitStats != nil && s.GridProfitStats.Since != nil && until.Before(*s.GridProfitStats.Since) { + s.logger.Infof("stop querying trades because the time range is out of the strategy's since (%s)", *s.GridProfitStats.Since) + break + } + + if until.Before(recoverSinceLimit) { + s.logger.Infof("stop querying trades because the time range is out of the limit (%s)", recoverSinceLimit) + break + } + } + + return twinOrdersFilled, nil +} + +func (s *Strategy) queryTradesToUpdateTwinOrdersMap(ctx context.Context, queryTradesService types.ExchangeTradeHistoryService, queryOrderService types.ExchangeOrderQueryService, twinOrdersOpen, twinOrdersFilled TwinOrderMap, existedOrders *types.SyncOrderMap, since, until time.Time) error { + var fromTradeID uint64 = 0 + var limit int64 = 1000 + for { + trades, err := retry.QueryTradesUntilSuccessful(ctx, queryTradesService, s.Symbol, &types.TradeQueryOptions{ + StartTime: &since, + EndTime: &until, + LastTradeID: fromTradeID, + Limit: limit, + }) + + if err != nil { + return errors.Wrapf(err, "failed to query trades to recover the grid with open orders") + } + + s.debugLog("QueryTrades from %s <-> %s (from: %d) return %d trades", since, until, fromTradeID, len(trades)) + + for _, trade := range trades { + if trade.Time.After(until) { + return nil + } + + s.debugLog(trade.String()) + + if existedOrders.Exists(trade.OrderID) { + // already queries, skip + continue + } + order, err := retry.QueryOrderUntilSuccessful(ctx, queryOrderService, types.OrderQuery{ + Symbol: trade.Symbol, + OrderID: strconv.FormatUint(trade.OrderID, 10), + }) + + if err != nil { + return errors.Wrapf(err, "failed to query order by trade (trade id: %d, order id: %d)", trade.ID, trade.OrderID) + } + + s.debugLog(order.String()) + // avoid query this order again + existedOrders.Add(*order) + // add 1 to avoid duplicate + fromTradeID = trade.ID + 1 + + twinOrderKey, err := findTwinOrderMapKey(s.grid, *order) + if err != nil { + return errors.Wrapf(err, "failed to find grid order map's key when recover") + } + + twinOrderOpen, exist := twinOrdersOpen[twinOrderKey] + if !exist { + return fmt.Errorf("the price of the order with the same GroupID is not in pins") + } + + if twinOrderOpen.Exist() { + continue + } + + if twinOrder, exist := twinOrdersFilled[twinOrderKey]; exist { + to := twinOrder.GetOrder() + if to.UpdateTime.Time().After(order.UpdateTime.Time()) { + s.logger.Infof("twinOrder's update time (%s) should not be after order's update time (%s)", to.UpdateTime, order.UpdateTime) + continue + } + } + + twinOrder := TwinOrder{} + twinOrder.SetOrder(*order) + twinOrdersFilled[twinOrderKey] = twinOrder + } + + // stop condition + if int64(len(trades)) < limit { + return nil + } + } +} + +func (s *Strategy) addOrdersIntoTwinOrderMap(twinOrders TwinOrderMap, orders []types.Order) error { + for _, order := range orders { + k, err := findTwinOrderMapKey(s.grid, order) + if err != nil { + return errors.Wrap(err, "failed to add orders into twin order map") + } + + if v, exist := twinOrders[k]; !exist { + return fmt.Errorf("the price (%+v) is not in pins", k) + } else if v.Exist() { + return fmt.Errorf("there is already a twin order at this price (%+v)", k) + } else { + twin := TwinOrder{} + twin.SetOrder(order) + twinOrders[k] = twin + } + } + + return nil +} diff --git a/pkg/strategy/grid2/grid_recover_test.go b/pkg/strategy/grid2/grid_recover_test.go new file mode 100644 index 0000000..eed7d80 --- /dev/null +++ b/pkg/strategy/grid2/grid_recover_test.go @@ -0,0 +1,295 @@ +package grid2 + +import ( + "context" + "encoding/csv" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "os" + "sort" + "strconv" + "testing" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +type TestData struct { + Market types.Market `json:"market" yaml:"market"` + Strategy Strategy `json:"strategy" yaml:"strategy"` + OpenOrders []types.Order `json:"openOrders" yaml:"openOrders"` + ClosedOrders []types.Order `json:"closedOrders" yaml:"closedOrders"` + Trades []types.Trade `json:"trades" yaml:"trades"` +} + +type TestDataService struct { + Orders map[string]types.Order + Trades []types.Trade +} + +func (t *TestDataService) QueryTrades(ctx context.Context, symbol string, options *types.TradeQueryOptions) ([]types.Trade, error) { + var i int = 0 + if options.LastTradeID != 0 { + for idx, trade := range t.Trades { + if trade.ID < options.LastTradeID { + continue + } + + i = idx + break + } + } + + var trades []types.Trade + l := len(t.Trades) + for ; i < l && len(trades) < int(options.Limit); i++ { + trades = append(trades, t.Trades[i]) + } + + return trades, nil +} + +func (t *TestDataService) QueryOrder(ctx context.Context, q types.OrderQuery) (*types.Order, error) { + if len(q.OrderID) == 0 { + return nil, fmt.Errorf("order id should not be empty") + } + + order, exist := t.Orders[q.OrderID] + if !exist { + return nil, fmt.Errorf("order not found") + } + + return &order, nil +} + +// dummy method for interface +func (t *TestDataService) QueryClosedOrders(ctx context.Context, symbol string, since, until time.Time, lastOrderID uint64) (orders []types.Order, err error) { + return nil, nil +} + +// dummy method for interface +func (t *TestDataService) QueryOrderTrades(ctx context.Context, q types.OrderQuery) ([]types.Trade, error) { + return nil, nil +} + +func NewStrategy(t *TestData) *Strategy { + s := t.Strategy + s.Debug = true + s.Initialize() + s.Market = t.Market + s.Position = types.NewPositionFromMarket(t.Market) + s.orderExecutor = qbtrade.NewGeneralOrderExecutor(&qbtrade.ExchangeSession{}, t.Market.Symbol, ID, s.InstanceID(), s.Position) + return &s +} + +func NewTestDataService(t *TestData) *TestDataService { + var orders map[string]types.Order = make(map[string]types.Order) + for _, order := range t.OpenOrders { + orders[strconv.FormatUint(order.OrderID, 10)] = order + } + + for _, order := range t.ClosedOrders { + orders[strconv.FormatUint(order.OrderID, 10)] = order + } + + trades := t.Trades + sort.Slice(t.Trades, func(i, j int) bool { + return trades[i].ID < trades[j].ID + }) + + return &TestDataService{ + Orders: orders, + Trades: trades, + } +} + +func readSpec(fileName string) (*TestData, error) { + content, err := ioutil.ReadFile(fileName) + if err != nil { + return nil, err + } + + market := types.Market{} + if err := json.Unmarshal(content, &market); err != nil { + return nil, err + } + + strategy := Strategy{} + if err := json.Unmarshal(content, &strategy); err != nil { + return nil, err + } + + data := TestData{ + Market: market, + Strategy: strategy, + } + return &data, nil +} + +func readOrdersFromCSV(fileName string) ([]types.Order, error) { + csvFile, err := os.Open(fileName) + if err != nil { + return nil, err + } + defer csvFile.Close() + csvReader := csv.NewReader(csvFile) + + keys, err := csvReader.Read() + if err != nil { + return nil, err + } + + var orders []types.Order + for { + row, err := csvReader.Read() + if err == io.EOF { + break + } + + if err != nil { + return nil, err + } + + if len(row) != len(keys) { + return nil, fmt.Errorf("length of row should be equal to length of keys") + } + + var m map[string]interface{} = make(map[string]interface{}) + for i, key := range keys { + if key == "orderID" { + x, err := strconv.ParseUint(row[i], 10, 64) + if err != nil { + return nil, err + } + m[key] = x + } else { + m[key] = row[i] + } + } + + b, err := json.Marshal(m) + if err != nil { + return nil, err + } + + order := types.Order{} + if err = json.Unmarshal(b, &order); err != nil { + return nil, err + } + + orders = append(orders, order) + } + + return orders, nil +} + +func readTradesFromCSV(fileName string) ([]types.Trade, error) { + csvFile, err := os.Open(fileName) + if err != nil { + return nil, err + } + defer csvFile.Close() + csvReader := csv.NewReader(csvFile) + + keys, err := csvReader.Read() + if err != nil { + return nil, err + } + + var trades []types.Trade + for { + row, err := csvReader.Read() + if err == io.EOF { + break + } + + if err != nil { + return nil, err + } + + if len(row) != len(keys) { + return nil, fmt.Errorf("length of row should be equal to length of keys") + } + + var m map[string]interface{} = make(map[string]interface{}) + for i, key := range keys { + switch key { + case "id", "orderID": + x, err := strconv.ParseUint(row[i], 10, 64) + if err != nil { + return nil, err + } + m[key] = x + default: + m[key] = row[i] + } + } + + b, err := json.Marshal(m) + if err != nil { + return nil, err + } + + trade := types.Trade{} + if err = json.Unmarshal(b, &trade); err != nil { + return nil, err + } + + trades = append(trades, trade) + } + + return trades, nil +} + +func readTestDataFrom(fileDir string) (*TestData, error) { + data, err := readSpec(fmt.Sprintf("%s/spec", fileDir)) + if err != nil { + return nil, err + } + + openOrders, err := readOrdersFromCSV(fmt.Sprintf("%s/open_orders.csv", fileDir)) + if err != nil { + return nil, err + } + + closedOrders, err := readOrdersFromCSV(fmt.Sprintf("%s/closed_orders.csv", fileDir)) + if err != nil { + return nil, err + } + + trades, err := readTradesFromCSV(fmt.Sprintf("%s/trades.csv", fileDir)) + if err != nil { + return nil, err + } + + data.OpenOrders = openOrders + data.ClosedOrders = closedOrders + data.Trades = trades + return data, nil +} + +func TestRecoverByScanningTrades(t *testing.T) { + assert := assert.New(t) + + t.Run("test case 1", func(t *testing.T) { + fileDir := "recovery_testcase/testcase1/" + + data, err := readTestDataFrom(fileDir) + if !assert.NoError(err) { + return + } + + testService := NewTestDataService(data) + strategy := NewStrategy(data) + filledOrders, err := strategy.getFilledOrdersByScanningTrades(context.Background(), testService, testService, data.OpenOrders) + if !assert.NoError(err) { + return + } + + assert.Len(filledOrders, 0) + }) +} diff --git a/pkg/strategy/grid2/grid_test.go b/pkg/strategy/grid2/grid_test.go new file mode 100644 index 0000000..6c170a3 --- /dev/null +++ b/pkg/strategy/grid2/grid_test.go @@ -0,0 +1,267 @@ +package grid2 + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +func number(a interface{}) fixedpoint.Value { + switch v := a.(type) { + case string: + return fixedpoint.MustNewFromString(v) + case int: + return fixedpoint.NewFromInt(int64(v)) + case int64: + return fixedpoint.NewFromInt(int64(v)) + case float64: + return fixedpoint.NewFromFloat(v) + } + + return fixedpoint.Zero +} + +func TestNewGrid(t *testing.T) { + upper := fixedpoint.NewFromFloat(500.0) + lower := fixedpoint.NewFromFloat(100.0) + size := fixedpoint.NewFromFloat(101.0) + grid := NewGrid(lower, upper, size, number(0.01)) + grid.CalculateArithmeticPins() + + assert.Equal(t, upper, grid.UpperPrice) + assert.Equal(t, lower, grid.LowerPrice) + assert.Equal(t, fixedpoint.NewFromFloat(4), grid.Spread) + if assert.Len(t, grid.Pins, 101) { + assert.Equal(t, Pin(number(100.0)), grid.Pins[0]) + assert.Equal(t, Pin(number(500.0)), grid.Pins[100]) + } +} + +func TestGrid_HasPin(t *testing.T) { + upper := fixedpoint.NewFromFloat(500.0) + lower := fixedpoint.NewFromFloat(100.0) + size := fixedpoint.NewFromFloat(101.0) + grid := NewGrid(lower, upper, size, number(0.01)) + grid.CalculateArithmeticPins() + + assert.True(t, grid.HasPin(Pin(number(100.0)))) + assert.True(t, grid.HasPin(Pin(number(500.0)))) + assert.False(t, grid.HasPin(Pin(number(101.0)))) +} + +func TestGrid_ExtendUpperPrice(t *testing.T) { + upper := number(500.0) + lower := number(100.0) + size := number(5.0) + grid := NewGrid(lower, upper, size, number(0.01)) + grid.CalculateArithmeticPins() + + originalSpread := grid.Spread + + t.Logf("pins: %+v", grid.Pins) + assert.Equal(t, number(100.0), originalSpread) + assert.Len(t, grid.Pins, 5) + + newPins := grid.ExtendUpperPrice(number(1000.0)) + assert.Len(t, grid.Pins, 10) + assert.Len(t, newPins, 5) + assert.Equal(t, originalSpread, grid.Spread) + t.Logf("pins: %+v", grid.Pins) +} + +func TestGrid_ExtendLowerPrice(t *testing.T) { + upper := fixedpoint.NewFromFloat(3000.0) + lower := fixedpoint.NewFromFloat(2000.0) + size := fixedpoint.NewFromFloat(11.0) + grid := NewGrid(lower, upper, size, number(0.01)) + grid.CalculateArithmeticPins() + + assert.Equal(t, Pin(number(2000.0)), grid.BottomPin(), "bottom pin should be 1000.0") + assert.Equal(t, Pin(number(3000.0)), grid.TopPin(), "top pin should be 3000.0") + assert.Len(t, grid.Pins, 11) + + // spread = (3000 - 2000) / 10.0 + expectedSpread := fixedpoint.NewFromFloat(100.0) + assert.Equal(t, expectedSpread, grid.Spread) + + originalSpread := grid.Spread + newPins := grid.ExtendLowerPrice(fixedpoint.NewFromFloat(1000.0)) + assert.Equal(t, originalSpread, grid.Spread) + + t.Logf("newPins: %+v", newPins) + + // 100 = (2000-1000) / 10 + if assert.Len(t, newPins, 10) { + assert.Equal(t, Pin(number(1000.0)), newPins[0]) + assert.Equal(t, Pin(number(1900.0)), newPins[len(newPins)-1]) + } + + assert.Equal(t, expectedSpread, grid.Spread) + + if assert.Len(t, grid.Pins, 21) { + assert.Equal(t, Pin(number(1000.0)), grid.BottomPin(), "bottom pin should be 1000.0") + assert.Equal(t, Pin(number(3000.0)), grid.TopPin(), "top pin should be 3000.0") + } +} + +func TestGrid_NextLowerPin(t *testing.T) { + upper := number(500.0) + lower := number(100.0) + size := number(5.0) + grid := NewGrid(lower, upper, size, number(0.01)) + grid.CalculateArithmeticPins() + + t.Logf("pins: %+v", grid.Pins) + + next, ok := grid.NextLowerPin(number(200.0)) + assert.True(t, ok) + assert.Equal(t, Pin(number(100.0)), next) + + next, ok = grid.NextLowerPin(number(150.0)) + assert.False(t, ok) + assert.Equal(t, Pin(fixedpoint.Zero), next) +} + +func TestGrid_NextHigherPin(t *testing.T) { + upper := number(500.0) + lower := number(100.0) + size := number(5.0) + grid := NewGrid(lower, upper, size, number(0.01)) + grid.CalculateArithmeticPins() + t.Logf("pins: %+v", grid.Pins) + + next, ok := grid.NextHigherPin(number(100.0)) + assert.True(t, ok) + assert.Equal(t, Pin(number(200.0)), next) + + next, ok = grid.NextHigherPin(number(400.0)) + assert.True(t, ok) + assert.Equal(t, Pin(number(500.0)), next) + + next, ok = grid.NextHigherPin(number(500.0)) + assert.False(t, ok) + assert.Equal(t, Pin(fixedpoint.Zero), next) +} + +func Test_calculateArithmeticPins(t *testing.T) { + type args struct { + lower fixedpoint.Value + upper fixedpoint.Value + size fixedpoint.Value + tickSize fixedpoint.Value + } + tests := []struct { + name string + args args + want []Pin + }{ + { + // (3000-1000)/30 = 66.6666666 + name: "simple", + args: args{ + lower: number(1000.0), + upper: number(3000.0), + size: number(30.0), + tickSize: number(0.01), + }, + want: []Pin{ + Pin(number(1000.0)), + Pin(number(1066.660)), + Pin(number(1133.330)), + Pin(number("1200.00")), + Pin(number(1266.660)), + Pin(number(1333.330)), + Pin(number(1400.000)), + Pin(number(1466.660)), + Pin(number(1533.330)), + Pin(number(1600.000)), + Pin(number(1666.660)), + Pin(number(1733.330)), + Pin(number(1800.000)), + Pin(number(1866.660)), + Pin(number(1933.330)), + Pin(number(2000.000)), + Pin(number(2066.660)), + Pin(number(2133.330)), + Pin(number("2200.00")), + Pin(number(2266.660)), + Pin(number(2333.330)), + Pin(number("2400.00")), + Pin(number(2466.660)), + Pin(number(2533.330)), + Pin(number("2600.00")), + Pin(number(2666.660)), + Pin(number(2733.330)), + Pin(number(2800.000)), + Pin(number(2866.660)), + Pin(number(2933.330)), + Pin(number("3000.00")), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + spread := tt.args.upper.Sub(tt.args.lower).Div(tt.args.size) + pins := calculateArithmeticPins(tt.args.lower, tt.args.upper, spread, tt.args.tickSize) + for i := 0; i < len(tt.want); i++ { + assert.InDelta(t, fixedpoint.Value(tt.want[i]).Float64(), + fixedpoint.Value(pins[i]).Float64(), + 0.001, + "calculateArithmeticPins(%v, %v, %v, %v)", tt.args.lower, tt.args.upper, tt.args.size, tt.args.tickSize) + } + }) + } +} + +func Test_filterPrice1(t *testing.T) { + type args struct { + p fixedpoint.Value + prec int + } + tests := []struct { + name string + args args + want string + }{ + { + name: "basic", + args: args{p: number("31.2222"), prec: 3}, + want: "31.222", + }, + { + name: "roundup", + args: args{p: number("31.22295"), prec: 3}, + want: "31.223", + }, + { + name: "roundup2", + args: args{p: number("31.22290"), prec: 3}, + want: "31.222", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rst := roundAndTruncatePrice(tt.args.p, tt.args.prec) + assert.Equalf(t, tt.want, rst.String(), "roundAndTruncatePrice(%v, %v)", tt.args.p, tt.args.prec) + }) + } +} + +func Test_removeDuplicatedPins(t *testing.T) { + pins := []Pin{ + Pin(number("31.222")), + Pin(number("31.222")), + Pin(number("31.223")), + Pin(number("31.224")), + Pin(number("31.224")), + } + out := removeDuplicatedPins(pins) + assert.Equal(t, []Pin{ + Pin(number("31.222")), + Pin(number("31.223")), + Pin(number("31.224")), + }, out) + +} diff --git a/pkg/strategy/grid2/metrics.go b/pkg/strategy/grid2/metrics.go new file mode 100644 index 0000000..a49abf9 --- /dev/null +++ b/pkg/strategy/grid2/metrics.go @@ -0,0 +1,213 @@ +package grid2 + +import ( + "github.com/prometheus/client_golang/prometheus" +) + +var ( + metricsGridNum *prometheus.GaugeVec + metricsGridNumOfOrders *prometheus.GaugeVec + metricsGridNumOfOrdersWithCorrectPrice *prometheus.GaugeVec + metricsGridNumOfMissingOrders *prometheus.GaugeVec + metricsGridNumOfMissingOrdersWithCorrectPrice *prometheus.GaugeVec + metricsGridProfit *prometheus.GaugeVec + + metricsGridUpperPrice *prometheus.GaugeVec + metricsGridLowerPrice *prometheus.GaugeVec + metricsGridQuoteInvestment *prometheus.GaugeVec + metricsGridBaseInvestment *prometheus.GaugeVec + + metricsGridFilledOrderPrice *prometheus.GaugeVec + + metricsNumOfOpenOrders *prometheus.GaugeVec +) + +func labelKeys(labels prometheus.Labels) []string { + var keys []string + for k := range labels { + keys = append(keys, k) + } + + return keys +} + +func mergeLabels(a, b prometheus.Labels) prometheus.Labels { + labels := prometheus.Labels{} + for k, v := range a { + labels[k] = v + } + + for k, v := range b { + labels[k] = v + } + return labels +} + +func initMetrics(extendedLabels []string) { + if metricsGridNum != nil { + return + } + + metricsGridNum = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_grid2_num", + Help: "number of grids", + }, + append([]string{ + "exchange", // exchange name + "symbol", // symbol of the market + }, extendedLabels...), + ) + + metricsGridNumOfOrders = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_grid2_num_of_orders", + Help: "number of orders", + }, + append([]string{ + "exchange", // exchange name + "symbol", // symbol of the market + }, extendedLabels...), + ) + + metricsGridNumOfOrdersWithCorrectPrice = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_grid2_num_of_correct_price_orders", + Help: "number of orders with correct grid prices", + }, + append([]string{ + "exchange", // exchange name + "symbol", // symbol of the market + }, extendedLabels...), + ) + + metricsGridNumOfMissingOrders = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_grid2_num_of_missing_orders", + Help: "number of missing orders", + }, + append([]string{ + "exchange", // exchange name + "symbol", // symbol of the market + }, extendedLabels...), + ) + + metricsGridNumOfMissingOrdersWithCorrectPrice = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_grid2_num_of_missing_correct_price_orders", + Help: "number of missing orders with correct prices", + }, + append([]string{ + "exchange", // exchange name + "symbol", // symbol of the market + }, extendedLabels...), + ) + + metricsGridProfit = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_grid2_profit", + Help: "realized grid profit", + }, + append([]string{ + "exchange", // exchange name + "symbol", // symbol of the market + }, extendedLabels...), + ) + + metricsGridUpperPrice = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_grid2_upper_price", + Help: "the upper price of grid", + }, + append([]string{ + "exchange", // exchange name + "symbol", // symbol of the market + }, extendedLabels...), + ) + + metricsGridLowerPrice = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_grid2_lower_price", + Help: "the lower price of grid", + }, + append([]string{ + "exchange", // exchange name + "symbol", // symbol of the market + }, extendedLabels...), + ) + + metricsGridQuoteInvestment = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_grid2_quote_investment", + Help: "the quote investment of grid", + }, + append([]string{ + "exchange", // exchange name + "symbol", // symbol of the market + }, extendedLabels...), + ) + + metricsGridBaseInvestment = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_grid2_base_investment", + Help: "the base investment of grid", + }, + append([]string{ + "exchange", // exchange name + "symbol", // symbol of the market + }, extendedLabels...), + ) + + metricsGridFilledOrderPrice = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_grid2_filled_order_price", + Help: "the price of filled grid order", + }, + append([]string{ + "exchange", // exchange name + "symbol", // symbol of the market + "side", + }, extendedLabels...), + ) + + metricsNumOfOpenOrders = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "qbtrade_grid2_num_of_open_orders", + Help: "number of open orders", + }, + append([]string{ + "exchange", // exchange name + "symbol", // symbol of the market + }, extendedLabels...), + ) + +} + +var metricsRegistered = false + +func registerMetrics() { + if metricsRegistered { + return + } + + if metricsGridNum == nil { + // default setup + initMetrics(nil) + } + + prometheus.MustRegister( + metricsGridNum, + metricsGridNumOfOrders, + metricsGridNumOfOrdersWithCorrectPrice, + metricsGridNumOfMissingOrders, + metricsGridNumOfMissingOrdersWithCorrectPrice, + metricsGridProfit, + metricsGridLowerPrice, + metricsGridUpperPrice, + metricsGridQuoteInvestment, + metricsGridBaseInvestment, + metricsGridFilledOrderPrice, + metricsNumOfOpenOrders, + ) + metricsRegistered = true +} diff --git a/pkg/strategy/grid2/mocks/order_executor.go b/pkg/strategy/grid2/mocks/order_executor.go new file mode 100644 index 0000000..20151cc --- /dev/null +++ b/pkg/strategy/grid2/mocks/order_executor.go @@ -0,0 +1,115 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/grid2 (interfaces: OrderExecutor) +// +// Generated by this command: +// +// mockgen -destination=mocks/order_executor.go -package=mocks . OrderExecutor +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + qbtrade "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + fixedpoint "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + types "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + gomock "go.uber.org/mock/gomock" +) + +// MockOrderExecutor is a mock of OrderExecutor interface. +type MockOrderExecutor struct { + ctrl *gomock.Controller + recorder *MockOrderExecutorMockRecorder +} + +// MockOrderExecutorMockRecorder is the mock recorder for MockOrderExecutor. +type MockOrderExecutorMockRecorder struct { + mock *MockOrderExecutor +} + +// NewMockOrderExecutor creates a new mock instance. +func NewMockOrderExecutor(ctrl *gomock.Controller) *MockOrderExecutor { + mock := &MockOrderExecutor{ctrl: ctrl} + mock.recorder = &MockOrderExecutorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockOrderExecutor) EXPECT() *MockOrderExecutorMockRecorder { + return m.recorder +} + +// ActiveMakerOrders mocks base method. +func (m *MockOrderExecutor) ActiveMakerOrders() *qbtrade.ActiveOrderBook { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ActiveMakerOrders") + ret0, _ := ret[0].(*qbtrade.ActiveOrderBook) + return ret0 +} + +// ActiveMakerOrders indicates an expected call of ActiveMakerOrders. +func (mr *MockOrderExecutorMockRecorder) ActiveMakerOrders() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActiveMakerOrders", reflect.TypeOf((*MockOrderExecutor)(nil).ActiveMakerOrders)) +} + +// ClosePosition mocks base method. +func (m *MockOrderExecutor) ClosePosition(arg0 context.Context, arg1 fixedpoint.Value, arg2 ...string) error { + m.ctrl.T.Helper() + varargs := []any{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ClosePosition", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// ClosePosition indicates an expected call of ClosePosition. +func (mr *MockOrderExecutorMockRecorder) ClosePosition(arg0, arg1 any, arg2 ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClosePosition", reflect.TypeOf((*MockOrderExecutor)(nil).ClosePosition), varargs...) +} + +// GracefulCancel mocks base method. +func (m *MockOrderExecutor) GracefulCancel(arg0 context.Context, arg1 ...types.Order) error { + m.ctrl.T.Helper() + varargs := []any{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "GracefulCancel", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// GracefulCancel indicates an expected call of GracefulCancel. +func (mr *MockOrderExecutorMockRecorder) GracefulCancel(arg0 any, arg1 ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GracefulCancel", reflect.TypeOf((*MockOrderExecutor)(nil).GracefulCancel), varargs...) +} + +// SubmitOrders mocks base method. +func (m *MockOrderExecutor) SubmitOrders(arg0 context.Context, arg1 ...types.SubmitOrder) (types.OrderSlice, error) { + m.ctrl.T.Helper() + varargs := []any{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 *MockOrderExecutorMockRecorder) SubmitOrders(arg0 any, arg1 ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitOrders", reflect.TypeOf((*MockOrderExecutor)(nil).SubmitOrders), varargs...) +} diff --git a/pkg/strategy/grid2/pin_order_map.go b/pkg/strategy/grid2/pin_order_map.go new file mode 100644 index 0000000..6c0ef88 --- /dev/null +++ b/pkg/strategy/grid2/pin_order_map.go @@ -0,0 +1,49 @@ +package grid2 + +import ( + "fmt" + "strings" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// PinOrderMap store the pin-order's relation, we will change key from string to fixedpoint.Value when FormatString fixed +type PinOrderMap map[fixedpoint.Value]types.Order + +// AscendingOrders get the orders from pin order map and sort it in asc order +func (m PinOrderMap) AscendingOrders() []types.Order { + var orders []types.Order + for _, order := range m { + // skip empty order + if order.OrderID == 0 { + continue + } + + orders = append(orders, order) + } + + types.SortOrdersUpdateTimeAscending(orders) + + return orders +} + +func (m PinOrderMap) SyncOrderMap() *types.SyncOrderMap { + orderMap := types.NewSyncOrderMap() + for _, order := range m { + orderMap.Add(order) + } + + return orderMap +} + +func (m PinOrderMap) String() string { + var sb strings.Builder + + sb.WriteString("================== PIN ORDER MAP ==================\n") + for pin, order := range m { + sb.WriteString(fmt.Sprintf("%+v -> %s\n", pin, order.String())) + } + sb.WriteString("================== END OF PIN ORDER MAP ==================\n") + return sb.String() +} diff --git a/pkg/strategy/grid2/pricemap.go b/pkg/strategy/grid2/pricemap.go new file mode 100644 index 0000000..fa193df --- /dev/null +++ b/pkg/strategy/grid2/pricemap.go @@ -0,0 +1,5 @@ +package grid2 + +import "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + +type PriceMap map[string]fixedpoint.Value diff --git a/pkg/strategy/grid2/profit.go b/pkg/strategy/grid2/profit.go new file mode 100644 index 0000000..8ba96e3 --- /dev/null +++ b/pkg/strategy/grid2/profit.go @@ -0,0 +1,48 @@ +package grid2 + +import ( + "fmt" + "strconv" + "time" + + "github.com/slack-go/slack" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/style" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type GridProfit struct { + Currency string `json:"currency"` + Profit fixedpoint.Value `json:"profit"` + Time time.Time `json:"time"` + Order types.Order `json:"order"` +} + +func (p *GridProfit) String() string { + return fmt.Sprintf("GRID PROFIT: %f %s @ %s orderID %d", p.Profit.Float64(), p.Currency, p.Time.String(), p.Order.OrderID) +} + +func (p *GridProfit) PlainText() string { + return fmt.Sprintf("Grid profit: %f %s @ %s orderID %d", p.Profit.Float64(), p.Currency, p.Time.String(), p.Order.OrderID) +} + +func (p *GridProfit) SlackAttachment() slack.Attachment { + title := fmt.Sprintf("Grid Profit %s %s", style.PnLSignString(p.Profit), p.Currency) + return slack.Attachment{ + Title: title, + Color: "warning", + Fields: []slack.AttachmentField{ + { + Title: "OrderID", + Value: strconv.FormatUint(p.Order.OrderID, 10), + Short: true, + }, + { + Title: "Time", + Value: p.Time.String(), + Short: true, + }, + }, + } +} diff --git a/pkg/strategy/grid2/profit_fixer.go b/pkg/strategy/grid2/profit_fixer.go new file mode 100644 index 0000000..6997d77 --- /dev/null +++ b/pkg/strategy/grid2/profit_fixer.go @@ -0,0 +1,105 @@ +package grid2 + +import ( + "context" + "time" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/batch" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type ProfitFixer struct { + symbol string + grid *Grid + historyService types.ExchangeTradeHistoryService + + logger logrus.FieldLogger +} + +func newProfitFixer(grid *Grid, symbol string, historyService types.ExchangeTradeHistoryService) *ProfitFixer { + return &ProfitFixer{ + symbol: symbol, + grid: grid, + historyService: historyService, + logger: logrus.StandardLogger(), + } +} + +func (f *ProfitFixer) SetLogger(logger logrus.FieldLogger) { + f.logger = logger +} + +// Fix fixes the total quote profit of the given grid +func (f *ProfitFixer) Fix(parent context.Context, since, until time.Time, initialOrderID uint64, profitStats *GridProfitStats) error { + // reset profit + profitStats.TotalQuoteProfit = fixedpoint.Zero + profitStats.ArbitrageCount = 0 + + defer f.logger.Infof("profitFixer: done") + + if profitStats.Since != nil && !profitStats.Since.IsZero() && profitStats.Since.Before(since) { + f.logger.Infof("profitFixer: profitStats.since %s is earlier than the given since %s, setting since to %s", profitStats.Since, since, profitStats.Since) + since = *profitStats.Since + } + + ctx, cancel := context.WithTimeout(parent, 15*time.Minute) + defer cancel() + + q := &batch.ClosedOrderBatchQuery{ExchangeTradeHistoryService: f.historyService} + orderC, errC := q.Query(ctx, f.symbol, since, until, initialOrderID) + + defer func() { + f.logger.Infof("profitFixer: fixed profitStats=%#v", profitStats) + }() + + for { + select { + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.Canceled) { + return nil + } + + return ctx.Err() + + case order, ok := <-orderC: + if !ok { + return <-errC + } + + if !f.grid.HasPrice(order.Price) { + continue + } + + if profitStats.InitialOrderID == 0 || order.OrderID < profitStats.InitialOrderID { + profitStats.InitialOrderID = order.OrderID + } + + if profitStats.Since == nil || profitStats.Since.IsZero() || order.CreationTime.Time().Before(*profitStats.Since) { + ct := order.CreationTime.Time() + profitStats.Since = &ct + } + + if order.Status != types.OrderStatusFilled { + continue + } + + if order.Type != types.OrderTypeLimit { + continue + } + + if order.Side != types.SideTypeSell { + continue + } + + quoteProfit := order.Quantity.Mul(f.grid.Spread) + profitStats.TotalQuoteProfit = profitStats.TotalQuoteProfit.Add(quoteProfit) + profitStats.ArbitrageCount++ + + f.logger.Debugf("profitFixer: filledSellOrder=%#v", order) + } + } +} diff --git a/pkg/strategy/grid2/profit_fixer_test.go b/pkg/strategy/grid2/profit_fixer_test.go new file mode 100644 index 0000000..7534969 --- /dev/null +++ b/pkg/strategy/grid2/profit_fixer_test.go @@ -0,0 +1,102 @@ +package grid2 + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types/mocks" +) + +func mustNewTime(v string) time.Time { + t, err := time.Parse(time.RFC3339, v) + if err != nil { + panic(err) + } + + return t +} + +var testClosedOrderID = uint64(0) + +func newClosedLimitOrder(symbol string, side types.SideType, price, quantity fixedpoint.Value, ta ...time.Time) types.Order { + testClosedOrderID++ + creationTime := time.Now() + updateTime := creationTime + + if len(ta) > 0 { + creationTime = ta[0] + if len(ta) > 1 { + updateTime = ta[1] + } + } + + return types.Order{ + SubmitOrder: types.SubmitOrder{ + Symbol: symbol, + Side: side, + Type: types.OrderTypeLimit, + Quantity: quantity, + Price: price, + }, + Exchange: types.ExchangeBinance, + OrderID: testClosedOrderID, + Status: types.OrderStatusFilled, + ExecutedQuantity: quantity, + CreationTime: types.Time(creationTime), + UpdateTime: types.Time(updateTime), + } +} + +func TestProfitFixer(t *testing.T) { + testClosedOrderID = 0 + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + ctx := context.Background() + mockHistoryService := mocks.NewMockExchangeTradeHistoryService(mockCtrl) + + mockHistoryService.EXPECT().QueryClosedOrders(gomock.Any(), "ETHUSDT", mustNewTime("2022-01-01T00:00:00Z"), mustNewTime("2022-01-07T00:00:00Z"), uint64(0)). + Return([]types.Order{ + newClosedLimitOrder("ETHUSDT", types.SideTypeBuy, number(1800.0), number(0.1), mustNewTime("2022-01-01T00:01:00Z")), + newClosedLimitOrder("ETHUSDT", types.SideTypeBuy, number(1700.0), number(0.1), mustNewTime("2022-01-01T00:01:00Z")), + newClosedLimitOrder("ETHUSDT", types.SideTypeSell, number(1800.0), number(0.1), mustNewTime("2022-01-01T00:01:00Z")), + newClosedLimitOrder("ETHUSDT", types.SideTypeSell, number(1900.0), number(0.1), mustNewTime("2022-01-01T00:03:00Z")), + newClosedLimitOrder("ETHUSDT", types.SideTypeSell, number(1905.0), number(0.1), mustNewTime("2022-01-01T00:03:00Z")), + }, nil) + + mockHistoryService.EXPECT().QueryClosedOrders(gomock.Any(), "ETHUSDT", mustNewTime("2022-01-01T00:03:00Z"), mustNewTime("2022-01-07T00:00:00Z"), uint64(5)). + Return([]types.Order{ + newClosedLimitOrder("ETHUSDT", types.SideTypeBuy, number(1900.0), number(0.1), mustNewTime("2022-01-01T00:04:00Z")), + newClosedLimitOrder("ETHUSDT", types.SideTypeBuy, number(1800.0), number(0.1), mustNewTime("2022-01-01T00:04:00Z")), + newClosedLimitOrder("ETHUSDT", types.SideTypeBuy, number(1700.0), number(0.1), mustNewTime("2022-01-01T00:04:00Z")), + newClosedLimitOrder("ETHUSDT", types.SideTypeSell, number(1800.0), number(0.1), mustNewTime("2022-01-01T00:04:00Z")), + newClosedLimitOrder("ETHUSDT", types.SideTypeSell, number(1900.0), number(0.1), mustNewTime("2022-01-01T00:08:00Z")), + }, nil) + + mockHistoryService.EXPECT().QueryClosedOrders(gomock.Any(), "ETHUSDT", mustNewTime("2022-01-01T00:08:00Z"), mustNewTime("2022-01-07T00:00:00Z"), uint64(10)). + Return([]types.Order{}, nil) + + grid := NewGrid(number(1000.0), number(2000.0), number(11), number(0.01)) + grid.CalculateArithmeticPins() + + since, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z") + assert.NoError(t, err) + + until, err := time.Parse(time.RFC3339, "2022-01-07T00:00:00Z") + assert.NoError(t, err) + + stats := &GridProfitStats{} + fixer := newProfitFixer(grid, "ETHUSDT", mockHistoryService) + err = fixer.Fix(ctx, since, until, 0, stats) + assert.NoError(t, err) + + assert.Equal(t, "40", stats.TotalQuoteProfit.String()) + assert.Equal(t, 4, stats.ArbitrageCount) +} diff --git a/pkg/strategy/grid2/profit_stats.go b/pkg/strategy/grid2/profit_stats.go new file mode 100644 index 0000000..3c68f27 --- /dev/null +++ b/pkg/strategy/grid2/profit_stats.go @@ -0,0 +1,189 @@ +package grid2 + +import ( + "fmt" + "strconv" + "time" + + "github.com/slack-go/slack" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/style" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type GridProfitStats struct { + Symbol string `json:"symbol"` + TotalBaseProfit fixedpoint.Value `json:"totalBaseProfit,omitempty"` + TotalQuoteProfit fixedpoint.Value `json:"totalQuoteProfit,omitempty"` + FloatProfit fixedpoint.Value `json:"floatProfit,omitempty"` + GridProfit fixedpoint.Value `json:"gridProfit,omitempty"` + ArbitrageCount int `json:"arbitrageCount,omitempty"` + TotalFee map[string]fixedpoint.Value `json:"totalFee,omitempty"` + Volume fixedpoint.Value `json:"volume,omitempty"` + Market types.Market `json:"market,omitempty"` + Since *time.Time `json:"since,omitempty"` + InitialOrderID uint64 `json:"initialOrderID"` + + // ttl is the ttl to keep in persistence + ttl time.Duration +} + +func newGridProfitStats(market types.Market) *GridProfitStats { + return &GridProfitStats{ + Symbol: market.Symbol, + TotalBaseProfit: fixedpoint.Zero, + TotalQuoteProfit: fixedpoint.Zero, + FloatProfit: fixedpoint.Zero, + GridProfit: fixedpoint.Zero, + ArbitrageCount: 0, + TotalFee: make(map[string]fixedpoint.Value), + Volume: fixedpoint.Zero, + Market: market, + } +} + +func (s *GridProfitStats) SetTTL(ttl time.Duration) { + if ttl.Nanoseconds() <= 0 { + return + } + s.ttl = ttl +} + +func (s *GridProfitStats) Expiration() time.Duration { + return s.ttl +} + +func (s *GridProfitStats) AddTrade(trade types.Trade) { + if s.TotalFee == nil { + s.TotalFee = make(map[string]fixedpoint.Value) + } + + if fee, ok := s.TotalFee[trade.FeeCurrency]; ok { + s.TotalFee[trade.FeeCurrency] = fee.Add(trade.Fee) + } else { + s.TotalFee[trade.FeeCurrency] = trade.Fee + } + + if s.Since == nil { + t := trade.Time.Time() + s.Since = &t + } +} + +func (s *GridProfitStats) AddProfit(profit *GridProfit) { + // increase arbitrage count per profit round + s.ArbitrageCount++ + + switch profit.Currency { + case s.Market.QuoteCurrency: + s.TotalQuoteProfit = s.TotalQuoteProfit.Add(profit.Profit) + case s.Market.BaseCurrency: + s.TotalBaseProfit = s.TotalBaseProfit.Add(profit.Profit) + } +} + +func (s *GridProfitStats) SlackAttachment() slack.Attachment { + var fields = []slack.AttachmentField{ + { + Title: "Arbitrage Count", + Value: strconv.Itoa(s.ArbitrageCount), + Short: true, + }, + } + + if !s.FloatProfit.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Float Profit", + Value: style.PnLSignString(s.FloatProfit), + Short: true, + }) + } + + if !s.GridProfit.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Total Grid Profit", + Value: style.PnLSignString(s.GridProfit), + Short: true, + }) + } + + if !s.TotalQuoteProfit.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Total Quote Profit", + Value: style.PnLSignString(s.TotalQuoteProfit), + Short: true, + }) + } + + if !s.TotalBaseProfit.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Total Base Profit", + Value: style.PnLSignString(s.TotalBaseProfit), + Short: true, + }) + } + + if len(s.TotalFee) > 0 { + for feeCurrency, fee := range s.TotalFee { + fields = append(fields, slack.AttachmentField{ + Title: fmt.Sprintf("Fee (%s)", feeCurrency), + Value: fee.String() + " " + feeCurrency, + Short: true, + }) + } + } + + footer := "Total grid profit stats" + if s.Since != nil { + footer += fmt.Sprintf(" since %s", s.Since.String()) + } + + title := fmt.Sprintf("%s Grid Profit Stats", s.Symbol) + return slack.Attachment{ + Title: title, + Color: "warning", + Fields: fields, + Footer: footer, + } +} + +func (s *GridProfitStats) String() string { + return s.PlainText() +} + +func (s *GridProfitStats) PlainText() string { + var o string + + o = fmt.Sprintf("%s Grid Profit Stats", s.Symbol) + + o += fmt.Sprintf(" Arbitrage count: %d", s.ArbitrageCount) + + if !s.FloatProfit.IsZero() { + o += " Float profit: " + style.PnLSignString(s.FloatProfit) + } + + if !s.GridProfit.IsZero() { + o += " Grid profit: " + style.PnLSignString(s.GridProfit) + } + + if !s.TotalQuoteProfit.IsZero() { + o += " Total quote profit: " + style.PnLSignString(s.TotalQuoteProfit) + " " + s.Market.QuoteCurrency + } + + if !s.TotalBaseProfit.IsZero() { + o += " Total base profit: " + style.PnLSignString(s.TotalBaseProfit) + " " + s.Market.BaseCurrency + } + + if len(s.TotalFee) > 0 { + for feeCurrency, fee := range s.TotalFee { + o += fmt.Sprintf(" Fee (%s)", feeCurrency) + fee.String() + " " + feeCurrency + } + } + + if s.Since != nil { + o += fmt.Sprintf(" Since %s", s.Since.String()) + } + + return o +} diff --git a/pkg/strategy/grid2/recover.go b/pkg/strategy/grid2/recover.go new file mode 100644 index 0000000..20fc9d6 --- /dev/null +++ b/pkg/strategy/grid2/recover.go @@ -0,0 +1,375 @@ +package grid2 + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/pkg/errors" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange" + //maxapi "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/max/maxapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/retry" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +var syncWindow = -3 * time.Minute + +/* + Background knowledge + 1. active orderbook add orders only when receive new order event or call Add/Update method manually + 2. active orderbook remove orders only when receive filled/cancelled event or call Remove/Update method manually + As a result + 1. at the same twin-order-price, there is order in open orders but not in active orderbook + - not receive new order event + => add order into active orderbook + 2. at the same twin-order-price, there is order in active orderbook but not in open orders + - not receive filled event + => query the filled order and call Update method + 3. at the same twin-order-price, there is no order in open orders and no order in active orderbook + - failed to create the order + => query the last order from trades to emit filled, and it will submit again + - not receive new order event and the order filled before we find it. + => query the untracked order (also is the last order) from trades to emit filled and it will submit the reversed order + 4. at the same twin-order-price, there are different orders in open orders and active orderbook + - should not happen !!! + => log error + 5. at the same twin-order-price, there is the same order in open orders and active orderbook + - normal case + => no need to do anything + After killing pod, active orderbook must be empty. we can think it is the same as not receive new event. + Process + 1. build twin orderbook with pins and open orders. + 2. build twin orderbook with pins and active orders. + 3. compare above twin orderbooks to add open orders into active orderbook and update active orders. + 4. run grid recover to make sure all the twin price has its order. +*/ + +func (s *Strategy) recover(ctx context.Context) error { + historyService, implemented := s.session.Exchange.(types.ExchangeTradeHistoryService) + // if the exchange doesn't support ExchangeTradeHistoryService, do not run recover + if !implemented { + s.logger.Warn("ExchangeTradeHistoryService is not implemented, can not recover grid") + return nil + } + + activeOrderBook := s.orderExecutor.ActiveMakerOrders() + activeOrders := activeOrderBook.Orders() + + openOrders, err := retry.QueryOpenOrdersUntilSuccessfulLite(ctx, s.session.Exchange, s.Symbol) + if err != nil { + return err + } + + // check if it's new strategy or need to recover + if len(activeOrders) == 0 && len(openOrders) == 0 && s.GridProfitStats.InitialOrderID == 0 { + // even though there is no open orders and initial orderID is 0 + // we still need to query trades to make sure if we need to recover or not + trades, err := historyService.QueryTrades(ctx, s.Symbol, &types.TradeQueryOptions{ + // from 1, because some API will ignore 0 last trade id + LastTradeID: 1, + // if there is any trades, we need to recover. + Limit: 1, + }) + + if err != nil { + return errors.Wrapf(err, "unable to query trades when recovering") + } + + if len(trades) == 0 { + s.logger.Info("no open order, no active order, no trade, it's a new strategy so no need to recover") + return nil + } + } + + s.logger.Info("start recovering") + + if s.getGrid() == nil { + s.setGrid(s.newGrid()) + } + + s.mu.Lock() + defer s.mu.Unlock() + + pins := s.getGrid().Pins + + syncBefore := time.Now().Add(syncWindow) + + activeOrdersInTwinOrderBook, err := buildTwinOrderBook(pins, activeOrders) + openOrdersInTwinOrderBook, err := buildTwinOrderBook(pins, openOrders) + + s.logger.Infof("[Recover] active orders' twin orderbook\n%s", activeOrdersInTwinOrderBook.String()) + s.logger.Infof("[Recover] open orders in twin orderbook\n%s", openOrdersInTwinOrderBook.String()) + + // remove index 0, because twin orderbook's price is from the second one + pins = pins[1:] + var noTwinOrderPins []fixedpoint.Value + + for _, pin := range pins { + v := fixedpoint.Value(pin) + activeOrder := activeOrdersInTwinOrderBook.GetTwinOrder(v) + openOrder := openOrdersInTwinOrderBook.GetTwinOrder(v) + if activeOrder == nil || openOrder == nil { + return fmt.Errorf("there is no any twin order at this pin, can not recover") + } + + var activeOrderID uint64 = 0 + if activeOrder.Exist() { + activeOrderID = activeOrder.GetOrder().OrderID + } + + var openOrderID uint64 = 0 + if openOrder.Exist() { + openOrderID = openOrder.GetOrder().OrderID + } + + // case 3 + if activeOrderID == 0 && openOrderID == 0 { + noTwinOrderPins = append(noTwinOrderPins, v) + continue + } + + // case 1 + if activeOrderID == 0 { + order := openOrder.GetOrder() + s.logger.Infof("[Recover] found open order #%d is not in the active orderbook, adding...", order.OrderID) + activeOrderBook.Add(order) + // also add open orders into active order's twin orderbook, we will use this active orderbook to recover empty price grid + activeOrdersInTwinOrderBook.AddTwinOrder(v, openOrder) + continue + } + + // case 2 + if openOrderID == 0 { + order := activeOrder.GetOrder() + s.logger.Infof("[Recover] found active order #%d is not in the open orders, updating...", order.OrderID) + isActiveOrderBookUpdated, err := syncActiveOrder(ctx, activeOrderBook, s.orderQueryService, order.OrderID, syncBefore) + if err != nil { + s.logger.WithError(err).Errorf("[Recover] unable to query order #%d", order.OrderID) + continue + } + + if !isActiveOrderBookUpdated { + s.logger.Infof("[Recover] active order #%d is updated in 3 min, skip updating...", order.OrderID) + } + + continue + } + + // case 4 + if activeOrderID != openOrderID { + return fmt.Errorf("there are two different orders in the same pin, can not recover") + } + + // case 5 + // do nothing + } + + s.logger.Infof("twin orderbook after adding open orders\n%s", activeOrdersInTwinOrderBook.String()) + + if len(noTwinOrderPins) != 0 { + if err := s.recoverEmptyGridOnTwinOrderBook(ctx, activeOrdersInTwinOrderBook, historyService, s.orderQueryService); err != nil { + s.logger.WithError(err).Error("failed to recover empty grid") + return err + } + + s.logger.Infof("twin orderbook after recovering no twin order on grid\n%s", activeOrdersInTwinOrderBook.String()) + + if activeOrdersInTwinOrderBook.EmptyTwinOrderSize() > 0 { + return fmt.Errorf("there is still empty grid in twin orderbook") + } + + for _, pin := range noTwinOrderPins { + twinOrder := activeOrdersInTwinOrderBook.GetTwinOrder(pin) + if twinOrder == nil { + return fmt.Errorf("should not get nil twin order after recovering empty grid, check it") + } + + if !twinOrder.Exist() { + return fmt.Errorf("should not get empty twin order after recovering empty grid, check it") + } + + activeOrderBook.EmitFilled(twinOrder.GetOrder()) + + time.Sleep(100 * time.Millisecond) + } + } + + // TODO: do not emit ready here, emit ready only once when opening grid or recovering grid after worker stopped + // s.EmitGridReady() + + time.Sleep(2 * time.Second) + debugGrid(s.logger, s.grid, s.orderExecutor.ActiveMakerOrders()) + + qbtrade.Sync(ctx, s) + + return nil +} + +func (s *Strategy) recoverEmptyGridOnTwinOrderBook( + ctx context.Context, + twinOrderBook *TwinOrderBook, + queryTradesService types.ExchangeTradeHistoryService, + queryOrderService types.ExchangeOrderQueryService, +) error { + if twinOrderBook.EmptyTwinOrderSize() == 0 { + s.logger.Info("no empty grid") + return nil + } + + existedOrders := twinOrderBook.SyncOrderMap() + + until := time.Now() + since := until.Add(-1 * time.Hour) + // hard limit for recover + recoverSinceLimit := time.Date(2023, time.March, 10, 0, 0, 0, 0, time.UTC) + + if s.RecoverGridWithin != 0 && until.Add(-1*s.RecoverGridWithin).After(recoverSinceLimit) { + recoverSinceLimit = until.Add(-1 * s.RecoverGridWithin) + } + + for { + if err := queryTradesToUpdateTwinOrderBook(ctx, s.Symbol, twinOrderBook, queryTradesService, queryOrderService, existedOrders, since, until, s.debugLog); err != nil { + return errors.Wrapf(err, "failed to query trades to update twin orderbook") + } + + until = since + since = until.Add(-6 * time.Hour) + + if twinOrderBook.EmptyTwinOrderSize() == 0 { + s.logger.Infof("stop querying trades because there is no empty twin order on twin orderbook") + break + } + + if s.GridProfitStats != nil && s.GridProfitStats.Since != nil && until.Before(*s.GridProfitStats.Since) { + s.logger.Infof("stop querying trades because the time range is out of the strategy's since (%s)", *s.GridProfitStats.Since) + break + } + + if until.Before(recoverSinceLimit) { + s.logger.Infof("stop querying trades because the time range is out of the limit (%s)", recoverSinceLimit) + break + } + } + + return nil +} + +func buildTwinOrderBook(pins []Pin, orders []types.Order) (*TwinOrderBook, error) { + book := newTwinOrderBook(pins) + + for _, order := range orders { + if err := book.AddOrder(order); err != nil { + return nil, err + } + } + + return book, nil +} + +func syncActiveOrder( + ctx context.Context, activeOrderBook *qbtrade.ActiveOrderBook, orderQueryService types.ExchangeOrderQueryService, + orderID uint64, syncBefore time.Time, +) (isOrderUpdated bool, err error) { + //isMax := exchange.IsMaxExchange(orderQueryService) + + updatedOrder, err := retry.QueryOrderUntilSuccessful(ctx, orderQueryService, types.OrderQuery{ + Symbol: activeOrderBook.Symbol, + OrderID: strconv.FormatUint(orderID, 10), + }) + + if err != nil { + return isOrderUpdated, err + } + + // maxapi.OrderStateFinalizing does not mean the fee is calculated + // we should only consider order state done for MAX + //if isMax && updatedOrder.OriginalStatus != string(maxapi.OrderStateDone) { + // return isOrderUpdated, nil + //} + + // should only trigger order update when the updated time is old enough + isOrderUpdated = updatedOrder.UpdateTime.Before(syncBefore) + if isOrderUpdated { + activeOrderBook.Update(*updatedOrder) + } + + return isOrderUpdated, nil +} + +func queryTradesToUpdateTwinOrderBook( + ctx context.Context, + symbol string, + twinOrderBook *TwinOrderBook, + queryTradesService types.ExchangeTradeHistoryService, + queryOrderService types.ExchangeOrderQueryService, + existedOrders *types.SyncOrderMap, + since, until time.Time, + logger func(format string, args ...interface{}), +) error { + if twinOrderBook == nil { + return fmt.Errorf("twin orderbook should not be nil, please check it") + } + + var fromTradeID uint64 = 0 + var limit int64 = 1000 + for { + trades, err := retry.QueryTradesUntilSuccessful(ctx, queryTradesService, symbol, &types.TradeQueryOptions{ + StartTime: &since, + EndTime: &until, + LastTradeID: fromTradeID, + Limit: limit, + }) + + if err != nil { + return errors.Wrapf(err, "failed to query trades to recover the grid") + } + + if logger != nil { + logger("QueryTrades from %s <-> %s (from: %d) return %d trades", since, until, fromTradeID, len(trades)) + } + + for _, trade := range trades { + if trade.Time.After(until) { + return nil + } + + if logger != nil { + logger(trade.String()) + } + + if existedOrders.Exists(trade.OrderID) { + // already queries, skip + continue + } + order, err := retry.QueryOrderUntilSuccessful(ctx, queryOrderService, types.OrderQuery{ + Symbol: trade.Symbol, + OrderID: strconv.FormatUint(trade.OrderID, 10), + }) + + if err != nil { + return errors.Wrapf(err, "failed to query order by trade (trade id: %d, order id: %d)", trade.ID, trade.OrderID) + } + + if logger != nil { + logger(order.String()) + } + // avoid query this order again + existedOrders.Add(*order) + // add 1 to avoid duplicate + fromTradeID = trade.ID + 1 + + if err := twinOrderBook.AddOrder(*order); err != nil { + return errors.Wrapf(err, "failed to add queried order into twin orderbook") + } + } + + // stop condition + if int64(len(trades)) < limit { + return nil + } + } +} diff --git a/pkg/strategy/grid2/recover_test.go b/pkg/strategy/grid2/recover_test.go new file mode 100644 index 0000000..8e1642b --- /dev/null +++ b/pkg/strategy/grid2/recover_test.go @@ -0,0 +1,239 @@ +package grid2 + +import ( + "context" + "strconv" + "testing" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types/mocks" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func newStrategy(t *TestData) *Strategy { + s := t.Strategy + s.Debug = true + s.Initialize() + s.Market = t.Market + s.Position = types.NewPositionFromMarket(t.Market) + s.orderExecutor = qbtrade.NewGeneralOrderExecutor(&qbtrade.ExchangeSession{}, t.Market.Symbol, ID, s.InstanceID(), s.Position) + return &s +} + +func TestBuildTwinOrderBook(t *testing.T) { + assert := assert.New(t) + + pins := []Pin{ + Pin(fixedpoint.NewFromInt(200)), + Pin(fixedpoint.NewFromInt(300)), + Pin(fixedpoint.NewFromInt(500)), + Pin(fixedpoint.NewFromInt(400)), + Pin(fixedpoint.NewFromInt(100)), + } + t.Run("build twin orderbook with no order", func(t *testing.T) { + b, err := buildTwinOrderBook(pins, nil) + if !assert.NoError(err) { + return + } + + assert.Equal(0, b.Size()) + assert.Nil(b.GetTwinOrder(fixedpoint.NewFromInt(100))) + assert.False(b.GetTwinOrder(fixedpoint.NewFromInt(200)).Exist()) + assert.False(b.GetTwinOrder(fixedpoint.NewFromInt(300)).Exist()) + assert.False(b.GetTwinOrder(fixedpoint.NewFromInt(400)).Exist()) + assert.False(b.GetTwinOrder(fixedpoint.NewFromInt(500)).Exist()) + }) + + t.Run("build twin orderbook with some valid orders", func(t *testing.T) { + orders := []types.Order{ + { + OrderID: 1, + SubmitOrder: types.SubmitOrder{ + Side: types.SideTypeBuy, + Price: fixedpoint.NewFromInt(100), + }, + }, + { + OrderID: 5, + SubmitOrder: types.SubmitOrder{ + Side: types.SideTypeSell, + Price: fixedpoint.NewFromInt(500), + }, + }, + } + b, err := buildTwinOrderBook(pins, orders) + if !assert.NoError(err) { + return + } + + assert.Equal(2, b.Size()) + assert.Equal(2, b.EmptyTwinOrderSize()) + assert.Nil(b.GetTwinOrder(fixedpoint.NewFromInt(100))) + assert.True(b.GetTwinOrder(fixedpoint.NewFromInt(200)).Exist()) + assert.False(b.GetTwinOrder(fixedpoint.NewFromInt(300)).Exist()) + assert.False(b.GetTwinOrder(fixedpoint.NewFromInt(400)).Exist()) + assert.True(b.GetTwinOrder(fixedpoint.NewFromInt(500)).Exist()) + }) + + t.Run("build twin orderbook with invalid orders", func(t *testing.T) {}) +} + +func TestSyncActiveOrder(t *testing.T) { + assert := assert.New(t) + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + symbol := "ETHUSDT" + + t.Run("sync filled order in active orderbook, active orderbook should remove this order", func(t *testing.T) { + mockOrderQueryService := mocks.NewMockExchangeOrderQueryService(mockCtrl) + activeOrderbook := qbtrade.NewActiveOrderBook(symbol) + + order := types.Order{ + OrderID: 1, + Status: types.OrderStatusNew, + SubmitOrder: types.SubmitOrder{ + Symbol: symbol, + }, + } + activeOrderbook.Add(order) + + updatedOrder := order + updatedOrder.Status = types.OrderStatusFilled + + mockOrderQueryService.EXPECT().QueryOrder(ctx, types.OrderQuery{ + Symbol: symbol, + OrderID: strconv.FormatUint(order.OrderID, 10), + }).Return(&updatedOrder, nil) + + _, err := syncActiveOrder(ctx, activeOrderbook, mockOrderQueryService, order.OrderID, time.Now()) + if !assert.NoError(err) { + return + } + + // verify active orderbook + activeOrders := activeOrderbook.Orders() + assert.Equal(0, len(activeOrders)) + }) + + t.Run("sync partial-filled order in active orderbook, active orderbook should still keep this order", func(t *testing.T) { + mockOrderQueryService := mocks.NewMockExchangeOrderQueryService(mockCtrl) + activeOrderbook := qbtrade.NewActiveOrderBook(symbol) + + order := types.Order{ + OrderID: 1, + Status: types.OrderStatusNew, + SubmitOrder: types.SubmitOrder{ + Symbol: symbol, + }, + } + activeOrderbook.Add(order) + + updatedOrder := order + updatedOrder.Status = types.OrderStatusPartiallyFilled + + mockOrderQueryService.EXPECT().QueryOrder(ctx, types.OrderQuery{ + Symbol: symbol, + OrderID: strconv.FormatUint(order.OrderID, 10), + }).Return(&updatedOrder, nil) + + _, err := syncActiveOrder(ctx, activeOrderbook, mockOrderQueryService, order.OrderID, time.Now()) + if !assert.NoError(err) { + return + } + + // verify active orderbook + activeOrders := activeOrderbook.Orders() + assert.Equal(1, len(activeOrders)) + assert.Equal(order.OrderID, activeOrders[0].OrderID) + assert.Equal(updatedOrder.Status, activeOrders[0].Status) + }) +} + +func TestQueryTradesToUpdateTwinOrderBook(t *testing.T) { + assert := assert.New(t) + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + symbol := "ETHUSDT" + pins := []Pin{ + Pin(fixedpoint.NewFromInt(100)), + Pin(fixedpoint.NewFromInt(200)), + Pin(fixedpoint.NewFromInt(300)), + Pin(fixedpoint.NewFromInt(400)), + Pin(fixedpoint.NewFromInt(500)), + } + + t.Run("query trades and update twin orderbook successfully in one page", func(t *testing.T) { + book := newTwinOrderBook(pins) + mockTradeHistoryService := mocks.NewMockExchangeTradeHistoryService(mockCtrl) + mockOrderQueryService := mocks.NewMockExchangeOrderQueryService(mockCtrl) + + trades := []types.Trade{ + { + ID: 1, + OrderID: 1, + Symbol: symbol, + Time: types.Time(time.Now().Add(-2 * time.Hour)), + }, + { + ID: 2, + OrderID: 2, + Symbol: symbol, + Time: types.Time(time.Now().Add(-1 * time.Hour)), + }, + } + orders := []types.Order{ + { + OrderID: 1, + Status: types.OrderStatusNew, + SubmitOrder: types.SubmitOrder{ + Symbol: symbol, + Side: types.SideTypeBuy, + Price: fixedpoint.NewFromInt(100), + }, + }, + { + OrderID: 2, + Status: types.OrderStatusFilled, + SubmitOrder: types.SubmitOrder{ + Symbol: symbol, + Side: types.SideTypeSell, + Price: fixedpoint.NewFromInt(500), + }, + }, + } + mockTradeHistoryService.EXPECT().QueryTrades(gomock.Any(), gomock.Any(), gomock.Any()).Return(trades, nil).Times(1) + mockOrderQueryService.EXPECT().QueryOrder(gomock.Any(), types.OrderQuery{ + Symbol: symbol, + OrderID: "1", + }).Return(&orders[0], nil) + mockOrderQueryService.EXPECT().QueryOrder(gomock.Any(), types.OrderQuery{ + Symbol: symbol, + OrderID: "2", + }).Return(&orders[1], nil) + + assert.Equal(0, book.Size()) + if !assert.NoError(queryTradesToUpdateTwinOrderBook(ctx, symbol, book, mockTradeHistoryService, mockOrderQueryService, book.SyncOrderMap(), time.Now().Add(-24*time.Hour), time.Now(), nil)) { + return + } + + assert.Equal(2, book.Size()) + assert.True(book.GetTwinOrder(fixedpoint.NewFromInt(200)).Exist()) + assert.Equal(orders[0].OrderID, book.GetTwinOrder(fixedpoint.NewFromInt(200)).GetOrder().OrderID) + assert.True(book.GetTwinOrder(fixedpoint.NewFromInt(500)).Exist()) + assert.Equal(orders[1].OrderID, book.GetTwinOrder(fixedpoint.NewFromInt(500)).GetOrder().OrderID) + }) +} diff --git a/pkg/strategy/grid2/recovery_testcase/testcase1/closed_orders.csv b/pkg/strategy/grid2/recovery_testcase/testcase1/closed_orders.csv new file mode 100644 index 0000000..8617872 --- /dev/null +++ b/pkg/strategy/grid2/recovery_testcase/testcase1/closed_orders.csv @@ -0,0 +1 @@ +orderID,price,quantity,remaining_quantity,side,creationTime,updateTime \ No newline at end of file diff --git a/pkg/strategy/grid2/recovery_testcase/testcase1/open_orders.csv b/pkg/strategy/grid2/recovery_testcase/testcase1/open_orders.csv new file mode 100644 index 0000000..6748a09 --- /dev/null +++ b/pkg/strategy/grid2/recovery_testcase/testcase1/open_orders.csv @@ -0,0 +1,3 @@ +orderID,price,quantity,remaining_quantity,side,creationTime,updateTime +1,20000.0000000000000000,1.0000000000000000,1.0000000000000000,buy,2023-05-01T08:00:00.000Z,2023-05-01T08:00:00.000Z +2,25000.0000000000000000,1.0000000000000000,1.0000000000000000,buy,2023-05-01T08:00:00.000Z,2023-05-01T08:00:00.000Z \ No newline at end of file diff --git a/pkg/strategy/grid2/recovery_testcase/testcase1/spec b/pkg/strategy/grid2/recovery_testcase/testcase1/spec new file mode 100644 index 0000000..c4baf09 --- /dev/null +++ b/pkg/strategy/grid2/recovery_testcase/testcase1/spec @@ -0,0 +1,12 @@ +{ + "symbol":"BTCUSDT", + "quoteCurrency": "USDT", + "baseCurrency": "BTC", + "mode":"arithmetic", + "lowerPrice":"20000.0", + "upperPrice":"30000.0", + "gridNumber":3, + "quoteInvestment":"45000.0", + "tickSize":0.1, + "stepSize":0.00000001 +} \ No newline at end of file diff --git a/pkg/strategy/grid2/recovery_testcase/testcase1/trades.csv b/pkg/strategy/grid2/recovery_testcase/testcase1/trades.csv new file mode 100644 index 0000000..ade45cd --- /dev/null +++ b/pkg/strategy/grid2/recovery_testcase/testcase1/trades.csv @@ -0,0 +1 @@ +id,price,quantity,side,fee,feeCurrency,orderID,created_at,tradedAt \ No newline at end of file diff --git a/pkg/strategy/grid2/strategy.go b/pkg/strategy/grid2/strategy.go new file mode 100644 index 0000000..9276bbd --- /dev/null +++ b/pkg/strategy/grid2/strategy.go @@ -0,0 +1,2185 @@ +package grid2 + +import ( + "context" + "fmt" + "math" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/pkg/errors" + "github.com/prometheus/client_golang/prometheus" + "github.com/sirupsen/logrus" + "go.uber.org/multierr" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/core" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/retry" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util/tradingutil" +) + +const ID = "grid2" + +const orderTag = "grid2" + +var log = logrus.WithField("strategy", ID) + +var maxNumberOfOrderTradesQueryTries = 10 + +const historyRollbackDuration = 3 * 24 * time.Hour +const historyRollbackOrderIdRange = 1000 + +func init() { + // Register the pointer of the strategy struct, + // so that qbtrade knows what struct to be used to unmarshal the configs (YAML or JSON) + // Note: built-in strategies need to imported manually in the qbtrade cmd package. + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type PrettyPins []Pin + +func (pp PrettyPins) String() string { + var ss []string + + for _, p := range pp { + price := fixedpoint.Value(p) + ss = append(ss, price.String()) + } + + return fmt.Sprintf("%v", ss) +} + +//go:generate mockgen -destination=mocks/order_executor.go -package=mocks . OrderExecutor +type OrderExecutor interface { + SubmitOrders(ctx context.Context, submitOrders ...types.SubmitOrder) (types.OrderSlice, error) + ClosePosition(ctx context.Context, percentage fixedpoint.Value, tags ...string) error + GracefulCancel(ctx context.Context, orders ...types.Order) error + ActiveMakerOrders() *qbtrade.ActiveOrderBook +} + +type advancedOrderCancelApi interface { + CancelAllOrders(ctx context.Context) ([]types.Order, error) + CancelOrdersBySymbol(ctx context.Context, symbol string) ([]types.Order, error) + CancelOrdersByGroupID(ctx context.Context, groupID uint32) ([]types.Order, error) +} + +//go:generate callbackgen -type Strategy +type Strategy struct { + Environment *qbtrade.Environment + + // 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 `json:"-"` + + // These fields will be filled from the config file (it translates YAML to JSON) + Symbol string `json:"symbol"` + + // ProfitSpread is the fixed profit spread you want to submit the sell order + // When ProfitSpread is enabled, the grid will shift up, e.g., + // If you opened a grid with the price range 10_000 to 20_000 + // With profit spread set to 3_000 + // The sell orders will be placed in the range 13_000 to 23_000 + // And the buy orders will be placed in the original price range 10_000 to 20_000 + ProfitSpread fixedpoint.Value `json:"profitSpread"` + + // GridNum is the grid number, how many orders you want to post on the orderbook. + GridNum int64 `json:"gridNumber"` + + // BaseGridNum is an optional field used for base investment sell orders + BaseGridNum int `json:"baseGridNumber,omitempty"` + + AutoRange *types.SimpleDuration `json:"autoRange"` + + UpperPrice fixedpoint.Value `json:"upperPrice"` + + LowerPrice fixedpoint.Value `json:"lowerPrice"` + + // Compound option is used for buying more inventory when + // the profit is made by the filled sell order. + Compound bool `json:"compound"` + + // EarnBase option is used for earning profit in base currency. + // e.g. earn BTC in BTCUSDT and earn ETH in ETHUSDT + // instead of earn USDT in BTCUSD + EarnBase bool `json:"earnBase"` + + // QuantityOrAmount embeds the Quantity field and the Amount field + // If you set up the Quantity field or the Amount field, you don't need to set the QuoteInvestment and BaseInvestment + qbtrade.QuantityOrAmount + + // If Quantity and Amount is not set, we can use the quote investment to calculate our quantity. + QuoteInvestment fixedpoint.Value `json:"quoteInvestment"` + + // BaseInvestment is the total base quantity you want to place as the sell order. + BaseInvestment fixedpoint.Value `json:"baseInvestment"` + + TriggerPrice fixedpoint.Value `json:"triggerPrice"` + + StopLossPrice fixedpoint.Value `json:"stopLossPrice"` + TakeProfitPrice fixedpoint.Value `json:"takeProfitPrice"` + + // CloseWhenCancelOrder option is used to close the grid if any of the order is canceled. + // This option let you simply remote control the grid from the crypto exchange mobile app. + CloseWhenCancelOrder bool `json:"closeWhenCancelOrder"` + + // KeepOrdersWhenShutdown option is used for keeping the grid orders when shutting down qbtrade + KeepOrdersWhenShutdown bool `json:"keepOrdersWhenShutdown"` + + // RecoverOrdersWhenStart option is used for recovering grid orders + RecoverOrdersWhenStart bool `json:"recoverOrdersWhenStart"` + + // ClearOpenOrdersWhenStart + // If this is set, when qbtrade started, it will clear the open orders in the same market (by symbol) + ClearOpenOrdersWhenStart bool `json:"clearOpenOrdersWhenStart"` + + ClearOpenOrdersIfMismatch bool `json:"clearOpenOrdersIfMismatch"` + + ClearDuplicatedPriceOpenOrders bool `json:"clearDuplicatedPriceOpenOrders"` + + // UseCancelAllOrdersApiWhenClose uses a different API to cancel all the orders on the market when closing a grid + UseCancelAllOrdersApiWhenClose bool `json:"useCancelAllOrdersApiWhenClose"` + + // ResetPositionWhenStart resets the position when the strategy is started + ResetPositionWhenStart bool `json:"resetPositionWhenStart"` + + // StopIfLessThanMinimalQuoteInvestment stops the strategy if the quote investment does not match + StopIfLessThanMinimalQuoteInvestment bool `json:"stopIfLessThanMinimalQuoteInvestment"` + + OrderFillDelay types.Duration `json:"orderFillDelay"` + + // PrometheusLabels will be used as the base prometheus labels + PrometheusLabels prometheus.Labels `json:"prometheusLabels"` + + // OrderGroupID is the group ID used for the strategy instance for canceling orders + OrderGroupID uint32 `json:"orderGroupID"` + + LogFields logrus.Fields `json:"logFields"` + + // FeeRate is used for calculating the minimal profit spread. + // it makes sure that your grid configuration is profitable. + FeeRate fixedpoint.Value `json:"feeRate"` + + SkipSpreadCheck bool `json:"skipSpreadCheck"` + RecoverGridByScanningTrades bool `json:"recoverGridByScanningTrades"` + RecoverGridWithin time.Duration `json:"recoverGridWithin"` + + EnableProfitFixer bool `json:"enableProfitFixer"` + FixProfitSince *types.Time `json:"fixProfitSince"` + + // Debug enables the debug mode + Debug bool `json:"debug"` + + GridProfitStats *GridProfitStats `persistence:"grid_profit_stats"` + Position *types.Position `persistence:"position"` + PersistenceTTL types.Duration `json:"persistenceTTL"` + + // ExchangeSession is an injection field + ExchangeSession *qbtrade.ExchangeSession + + grid *Grid + session *qbtrade.ExchangeSession + orderQueryService types.ExchangeOrderQueryService + + orderExecutor OrderExecutor + historicalTrades *core.TradeStore + + logger *logrus.Entry + + gridReadyCallbacks []func() + gridProfitCallbacks []func(stats *GridProfitStats, profit *GridProfit) + gridClosedCallbacks []func() + gridErrorCallbacks []func(err error) + + // filledOrderIDMap is used to prevent processing the same order ID twice. + filledOrderIDMap *types.SyncOrderMap + + // mu is used for locking the grid object field, avoid double grid opening + mu sync.Mutex + + tradingCtx, writeCtx context.Context + cancelWrite context.CancelFunc + + recoverC chan struct{} + + // this ensures that qbtrade.Sync to lock the object + sync.Mutex +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) Validate() error { + if s.AutoRange == nil { + if s.UpperPrice.IsZero() { + return errors.New("upperPrice can not be zero, you forgot to set?") + } + + if s.LowerPrice.IsZero() { + return errors.New("lowerPrice can not be zero, you forgot to set?") + } + + if s.UpperPrice.Compare(s.LowerPrice) <= 0 { + return fmt.Errorf("upperPrice (%s) should not be less than or equal to lowerPrice (%s)", s.UpperPrice.String(), s.LowerPrice.String()) + } + } + + if s.GridNum == 0 || s.GridNum == 1 { + return fmt.Errorf("gridNum can not be zero or one") + } + + if !s.SkipSpreadCheck { + if err := s.checkSpread(); err != nil { + return errors.Wrapf(err, "spread is too small, please try to reduce your gridNum or increase the price range (upperPrice and lowerPrice)") + } + } + + if !s.QuantityOrAmount.IsSet() && s.QuoteInvestment.IsZero() && s.BaseInvestment.IsZero() { + return fmt.Errorf("either quantity, amount or quoteInvestment must be set") + } + + return nil +} + +func (s *Strategy) Defaults() error { + if s.LogFields == nil { + s.LogFields = logrus.Fields{} + } + + s.LogFields["symbol"] = s.Symbol + s.LogFields["strategy"] = ID + return nil +} + +func (s *Strategy) Initialize() error { + s.filledOrderIDMap = types.NewSyncOrderMap() + s.logger = log.WithFields(s.LogFields) + return nil +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + if !s.TriggerPrice.IsZero() || !s.StopLossPrice.IsZero() || !s.TakeProfitPrice.IsZero() { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: types.Interval1m}) + } + + if s.AutoRange != nil { + interval := s.AutoRange.Interval() + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: interval}) + } +} + +// InstanceID returns the instance identifier from the current grid configuration parameters +func (s *Strategy) InstanceID() string { + id := fmt.Sprintf("%s-%s-size-%d", ID, s.Symbol, s.GridNum) + + if s.AutoRange != nil { + id += "-autoRange-" + s.AutoRange.String() + } else { + id += "-" + s.UpperPrice.String() + "-" + s.LowerPrice.String() + } + + return id +} + +func (s *Strategy) checkSpread() error { + gridNum := fixedpoint.NewFromInt(s.GridNum) + spread := s.ProfitSpread + if spread.IsZero() { + spread = s.UpperPrice.Sub(s.LowerPrice).Div(gridNum) + } + + feeRate := s.FeeRate + if feeRate.IsZero() { + feeRate = fixedpoint.NewFromFloat(0.075 * 0.01) + } + + // the min fee rate from 2 maker/taker orders (with 0.1 rate for profit) + gridFeeRate := feeRate.Mul(fixedpoint.NewFromFloat(2.01)) + + if spread.Div(s.LowerPrice).Compare(gridFeeRate) < 0 { + return fmt.Errorf("profitSpread %f %s is too small for lower price, less than the grid fee rate: %s", spread.Float64(), spread.Div(s.LowerPrice).Percentage(), gridFeeRate.Percentage()) + } + + if spread.Div(s.UpperPrice).Compare(gridFeeRate) < 0 { + return fmt.Errorf("profitSpread %f %s is too small for upper price, less than the grid fee rate: %s", spread.Float64(), spread.Div(s.UpperPrice).Percentage(), gridFeeRate.Percentage()) + } + + return nil +} + +func (s *Strategy) handleOrderCanceled(o types.Order) { + s.logger.Infof("GRID ORDER CANCELED: %s", o.String()) + + ctx := context.Background() + if s.CloseWhenCancelOrder { + s.logger.Infof("one of the grid orders is canceled, now closing grid...") + if err := s.CloseGrid(ctx); err != nil { + s.logger.WithError(err).Errorf("graceful order cancel error") + } + } +} + +func (s *Strategy) calculateProfit(o types.Order, buyPrice, buyQuantity fixedpoint.Value) *GridProfit { + if s.EarnBase { + // sell quantity - buy quantity + profitQuantity := o.Quantity.Sub(buyQuantity) + profit := &GridProfit{ + Currency: s.Market.BaseCurrency, + Profit: profitQuantity, + Time: o.UpdateTime.Time(), + Order: o, + } + return profit + } + + // earn quote + // (sell_price - buy_price) * quantity + profitQuantity := o.Price.Sub(buyPrice).Mul(o.Quantity) + profit := &GridProfit{ + Currency: s.Market.QuoteCurrency, + Profit: profitQuantity, + Time: o.UpdateTime.Time(), + Order: o, + } + return profit +} + +func (s *Strategy) verifyOrderTrades(o types.Order, trades []types.Trade) bool { + tq := tradingutil.AggregateTradesQuantity(trades) + + // on MAX: if order.status == filled, it does not mean order.executedQuantity == order.quantity + // order.executedQuantity can be less than order.quantity + // so here we use executed quantity to check if the total trade quantity matches to order.executedQuantity + executedQuantity := o.ExecutedQuantity + if executedQuantity.IsZero() { + // fall back to the original quantity if the executed quantity is zero + executedQuantity = o.Quantity + } + + // early return here if it matches + c := tq.Compare(executedQuantity) + if c == 0 { + return true + } + + if c < 0 { + s.logger.Warnf("order trades missing. expected: %s got: %s", + executedQuantity.String(), + tq.String()) + return false + } else if c > 0 { + s.logger.Errorf("aggregated trade quantity %s > order executed quantity %s, something is wrong, please check", tq.String(), executedQuantity.String()) + return true + } + + // shouldn't reach here + return true +} + +// aggregateOrderQuoteAmountAndFee collects the base fee quantity from the given order +// it falls back to query the trades via the RESTful API when the websocket trades are not all received. +func (s *Strategy) aggregateOrderQuoteAmountAndFee(o types.Order) (fixedpoint.Value, fixedpoint.Value, string) { + // try to get the received trades (websocket trades) + orderTrades := s.historicalTrades.GetOrderTrades(o) + if len(orderTrades) > 0 { + s.logger.Infof("GRID: found filled order trades: %+v", orderTrades) + } + + feeCurrency := s.Market.BaseCurrency + if o.Side == types.SideTypeSell { + feeCurrency = s.Market.QuoteCurrency + } + + for maxTries := maxNumberOfOrderTradesQueryTries; maxTries > 0; maxTries-- { + // if one of the trades is missing, we need to query the trades from the RESTful API + if s.verifyOrderTrades(o, orderTrades) { + // if trades are verified + quoteAmount := tradingutil.AggregateTradesQuoteQuantity(orderTrades) + fees := tradingutil.CollectTradeFee(orderTrades) + if fee, ok := fees[feeCurrency]; ok { + return quoteAmount, fee, feeCurrency + } + return quoteAmount, fixedpoint.Zero, feeCurrency + } + + // if we don't support orderQueryService, then we should just skip + if s.orderQueryService == nil { + return fixedpoint.Zero, fixedpoint.Zero, feeCurrency + } + + s.logger.Warnf("GRID: missing #%d order trades or missing trade fee, pulling order trades from API", o.OrderID) + + // if orderQueryService is supported, use it to query the trades of the filled order + apiOrderTrades, err := retry.QueryOrderTradesUntilSuccessful(context.Background(), s.orderQueryService, types.OrderQuery{ + Symbol: o.Symbol, + OrderID: strconv.FormatUint(o.OrderID, 10), + }) + if err != nil { + s.logger.WithError(err).Errorf("query #%d order trades error", o.OrderID) + } else { + s.logger.Infof("GRID: fetched api #%d order trades: %+v", o.OrderID, apiOrderTrades) + orderTrades = apiOrderTrades + } + } + + quoteAmount := tradingutil.AggregateTradesQuoteQuantity(orderTrades) + // still try to aggregate the trades quantity if we can: + fees := tradingutil.CollectTradeFee(orderTrades) + if fee, ok := fees[feeCurrency]; ok { + return quoteAmount, fee, feeCurrency + } + + return quoteAmount, fixedpoint.Zero, feeCurrency +} + +func (s *Strategy) processFilledOrder(o types.Order) { + var profit *GridProfit = nil + + // check order fee + newSide := types.SideTypeSell + newPrice := o.Price + + executedQuantity := o.ExecutedQuantity + // A safeguard check, fallback to the original quantity + if executedQuantity.IsZero() { + executedQuantity = o.Quantity + } + + newQuantity := executedQuantity + + if o.ExecutedQuantity.Compare(o.Quantity) != 0 { + s.logger.Warnf("order #%d is filled, but order executed quantity %s != order quantity %s, something is wrong", o.OrderID, o.ExecutedQuantity, o.Quantity) + } + + /* + if o.AveragePrice.Sign() > 0 { + executedPrice = o.AveragePrice + } + */ + + // collect trades for fee + // fee calculation is used to reduce the order quantity + // because when 1.0 BTC buy order is filled without FEE token, then we will actually get 1.0 * (1 - feeRate) BTC + // if we don't reduce the sell quantity, than we might fail to place the sell order + orderExecutedQuoteAmount, fee, feeCurrency := s.aggregateOrderQuoteAmountAndFee(o) + s.logger.Infof("GRID ORDER #%d %s FEE: %s %s", + o.OrderID, o.Side, + fee.String(), feeCurrency) + + switch o.Side { + case types.SideTypeSell: + newSide = types.SideTypeBuy + + if !s.ProfitSpread.IsZero() { + newPrice = newPrice.Sub(s.ProfitSpread) + } else { + if pin, ok := s.grid.NextLowerPin(newPrice); ok { + newPrice = fixedpoint.Value(pin) + } + } + + // use the profit to buy more inventory in the grid + if s.Compound || s.EarnBase { + // if it's not using the platform fee currency, reduce the quote quantity for the buy order + if feeCurrency == s.Market.QuoteCurrency && fee.Sign() > 0 { + orderExecutedQuoteAmount = orderExecutedQuoteAmount.Sub(fee) + } + + // for quote amount, always round down with price precision to prevent the quote currency fund locking rounding issue + origQuoteAmount := orderExecutedQuoteAmount + orderExecutedQuoteAmount = orderExecutedQuoteAmount.Round(s.Market.PricePrecision, fixedpoint.Down) + s.logger.Infof("round down %s %s order quote quantity %s to %s by quote precision %d", s.Symbol, newSide, origQuoteAmount.String(), orderExecutedQuoteAmount.String(), s.Market.PricePrecision) + + newQuantity = orderExecutedQuoteAmount.Div(newPrice) + + origQuantity := newQuantity + newQuantity = newQuantity.Round(s.Market.VolumePrecision, fixedpoint.Down) + s.logger.Infof("round down %s %s order base quantity %s to %s by base precision %d", s.Symbol, newSide, origQuantity.String(), newQuantity.String(), s.Market.VolumePrecision) + + newQuantity = fixedpoint.Max(newQuantity, s.Market.MinQuantity) + } else if s.QuantityOrAmount.Quantity.Sign() > 0 { + newQuantity = s.QuantityOrAmount.Quantity + } + + // TODO: need to consider sell order fee for the profit calculation + profit = s.calculateProfit(o, newPrice, newQuantity) + + case types.SideTypeBuy: + newSide = types.SideTypeSell + if !s.ProfitSpread.IsZero() { + newPrice = newPrice.Add(s.ProfitSpread) + } else { + if pin, ok := s.grid.NextHigherPin(newPrice); ok { + newPrice = fixedpoint.Value(pin) + } + } + + if feeCurrency == s.Market.BaseCurrency && fee.Sign() > 0 { + newQuantity = newQuantity.Sub(fee) + } + + // if EarnBase is enabled, we should sell less to get the same quote amount back + if s.EarnBase { + newQuantity = fixedpoint.Max(orderExecutedQuoteAmount.Div(newPrice).Sub(fee), s.Market.MinQuantity) + } + + // always round down the base quantity for placing sell order to avoid the base currency fund locking issue + origQuantity := newQuantity + newQuantity = newQuantity.Round(s.Market.VolumePrecision, fixedpoint.Down) + s.logger.Infof("round down sell order quantity %s to %s by base quantity precision %d", origQuantity.String(), newQuantity.String(), s.Market.VolumePrecision) + } + + orderForm := types.SubmitOrder{ + Symbol: s.Symbol, + Market: s.Market, + Type: types.OrderTypeLimit, + Price: newPrice, + Side: newSide, + TimeInForce: types.TimeInForceGTC, + Quantity: newQuantity, + Tag: orderTag, + GroupID: s.OrderGroupID, + ClientOrderID: s.newClientOrderID(), + } + + s.logger.Infof("SUBMIT GRID REVERSE ORDER: %s", orderForm.String()) + + writeCtx := s.getWriteContext() + createdOrders, err := s.orderExecutor.SubmitOrders(writeCtx, orderForm) + if err != nil { + s.logger.WithError(err).Errorf("GRID REVERSE ORDER SUBMISSION ERROR: order: %s", orderForm.String()) + return + } + + s.logger.Infof("GRID REVERSE ORDER IS CREATED: %+v", createdOrders) + + // we calculate profit only when the order is placed successfully + if profit != nil { + s.GridProfitStats.AddProfit(profit) + s.logger.Infof("GENERATED GRID PROFIT: %+v; TOTAL GRID PROFIT BECOMES: %f", profit, s.GridProfitStats.TotalQuoteProfit.Float64()) + s.EmitGridProfit(s.GridProfitStats, profit) + } +} + +// handleOrderFilled is called when an order status is FILLED +func (s *Strategy) handleOrderFilled(o types.Order) { + if s.grid == nil { + s.logger.Warn("grid is not opened yet, skip order update event") + return + } + + if s.filledOrderIDMap.Exists(o.OrderID) { + s.logger.Warnf("duplicated id (%d) of filled order detected", o.OrderID) + return + } + s.filledOrderIDMap.Add(o) + + s.logger.Infof("GRID ORDER FILLED: %s", o.String()) + s.updateFilledOrderMetrics(o) + s.processFilledOrder(o) +} + +func (s *Strategy) checkRequiredInvestmentByQuantity( + baseBalance, quoteBalance, quantity, lastPrice fixedpoint.Value, pins []Pin, +) (requiredBase, requiredQuote fixedpoint.Value, err error) { + // check more investment budget details + requiredBase = fixedpoint.Zero + requiredQuote = fixedpoint.Zero + + // when we need to place a buy-to-sell conversion order, we need to mark the price + si := -1 + for i := len(pins) - 1; i >= 0; i-- { + pin := pins[i] + price := fixedpoint.Value(pin) + + // TODO: add fee if we don't have the platform token. BNB, OKB or MAX... + if price.Compare(lastPrice) >= 0 { + si = i + // for orders that sell + // if we still have the base balance + if requiredBase.Add(quantity).Compare(baseBalance) <= 0 { + requiredBase = requiredBase.Add(quantity) + } else if i > 0 { // we do not want to sell at i == 0 + // convert sell to buy quote and add to requiredQuote + nextLowerPin := pins[i-1] + nextLowerPrice := fixedpoint.Value(nextLowerPin) + requiredQuote = requiredQuote.Add(quantity.Mul(nextLowerPrice)) + } + } else { + // for orders that buy + if i+1 == si { + continue + } + requiredQuote = requiredQuote.Add(quantity.Mul(price)) + } + } + + if requiredBase.Compare(baseBalance) > 0 && requiredQuote.Compare(quoteBalance) > 0 { + return requiredBase, requiredQuote, fmt.Errorf("both base balance (%f %s) or quote balance (%f %s) is not enough, required = base %f + quote %f", + baseBalance.Float64(), s.Market.BaseCurrency, + quoteBalance.Float64(), s.Market.QuoteCurrency, + requiredBase.Float64(), + requiredQuote.Float64()) + } + + if requiredBase.Compare(baseBalance) > 0 { + return requiredBase, requiredQuote, fmt.Errorf("base balance (%f %s), required = base %f", + baseBalance.Float64(), s.Market.BaseCurrency, + requiredBase.Float64(), + ) + } + + if requiredQuote.Compare(quoteBalance) > 0 { + return requiredBase, requiredQuote, fmt.Errorf("quote balance (%f %s) is not enough, required = quote %f", + quoteBalance.Float64(), s.Market.QuoteCurrency, + requiredQuote.Float64(), + ) + } + + return requiredBase, requiredQuote, nil +} + +func (s *Strategy) checkRequiredInvestmentByAmount( + baseBalance, quoteBalance, amount, lastPrice fixedpoint.Value, pins []Pin, +) (requiredBase, requiredQuote fixedpoint.Value, err error) { + + // check more investment budget details + requiredBase = fixedpoint.Zero + requiredQuote = fixedpoint.Zero + + // when we need to place a buy-to-sell conversion order, we need to mark the price + si := -1 + for i := len(pins) - 1; i >= 0; i-- { + pin := pins[i] + price := fixedpoint.Value(pin) + + // TODO: add fee if we don't have the platform token. BNB, OKB or MAX... + if price.Compare(lastPrice) >= 0 { + si = i + // for orders that sell + // if we still have the base balance + quantity := amount.Div(lastPrice) + if requiredBase.Add(quantity).Compare(baseBalance) <= 0 { + requiredBase = requiredBase.Add(quantity) + } else if i > 0 { // we do not want to sell at i == 0 + // convert sell to buy quote and add to requiredQuote + nextLowerPin := pins[i-1] + nextLowerPrice := fixedpoint.Value(nextLowerPin) + requiredQuote = requiredQuote.Add(quantity.Mul(nextLowerPrice)) + } + } else { + // for orders that buy + if s.ProfitSpread.IsZero() && i+1 == si { + continue + } + + requiredQuote = requiredQuote.Add(amount) + } + } + + if requiredBase.Compare(baseBalance) > 0 && requiredQuote.Compare(quoteBalance) > 0 { + return requiredBase, requiredQuote, fmt.Errorf("both base balance (%f %s) or quote balance (%f %s) is not enough, required = base %f + quote %f", + baseBalance.Float64(), s.Market.BaseCurrency, + quoteBalance.Float64(), s.Market.QuoteCurrency, + requiredBase.Float64(), + requiredQuote.Float64()) + } + + if requiredBase.Compare(baseBalance) > 0 { + return requiredBase, requiredQuote, fmt.Errorf("base balance (%f %s), required = base %f", + baseBalance.Float64(), s.Market.BaseCurrency, + requiredBase.Float64(), + ) + } + + if requiredQuote.Compare(quoteBalance) > 0 { + return requiredBase, requiredQuote, fmt.Errorf("quote balance (%f %s) is not enough, required = quote %f", + quoteBalance.Float64(), s.Market.QuoteCurrency, + requiredQuote.Float64(), + ) + } + + return requiredBase, requiredQuote, nil +} + +func (s *Strategy) calculateQuoteInvestmentQuantity( + quoteInvestment, lastPrice fixedpoint.Value, pins []Pin, +) (fixedpoint.Value, error) { + // quoteInvestment = (p1 * q) + (p2 * q) + (p3 * q) + .... + // => + // quoteInvestment = (p1 + p2 + p3) * q + // q = quoteInvestment / (p1 + p2 + p3) + totalQuotePrice := fixedpoint.Zero + si := len(pins) + cntOrder := 0 + for i := len(pins) - 1; i >= 0; i-- { + pin := pins[i] + price := fixedpoint.Value(pin) + + if price.Compare(lastPrice) >= 0 { + si = i + + // do not place sell order on the bottom price + if i == 0 { + continue + } + + // for orders that sell + // if we still have the base balance + // quantity := amount.Div(lastPrice) + if s.ProfitSpread.Sign() > 0 { + totalQuotePrice = totalQuotePrice.Add(price) + } else { // we do not want to sell at i == 0 + // convert sell to buy quote and add to requiredQuote + nextLowerPin := pins[i-1] + nextLowerPrice := fixedpoint.Value(nextLowerPin) + totalQuotePrice = totalQuotePrice.Add(nextLowerPrice) + } + + cntOrder++ + } else { + // for orders that buy + if s.ProfitSpread.IsZero() && i+1 == si { + continue + } + + // should never place a buy order at the upper price + if i == len(pins)-1 { + continue + } + + totalQuotePrice = totalQuotePrice.Add(price) + cntOrder++ + } + } + + orderDusts := fixedpoint.NewFromFloat(math.Pow10(-s.Market.PricePrecision) * float64(cntOrder)) + adjustedQuoteInvestment := quoteInvestment.Sub(orderDusts) + q := adjustedQuoteInvestment.Div(totalQuotePrice) + s.logger.Infof("calculateQuoteInvestmentQuantity: adjustedQuoteInvestment=%f sumOfPrice=%f quantity=%f", adjustedQuoteInvestment.Float64(), totalQuotePrice.Float64(), q.Float64()) + return q, nil +} + +func (s *Strategy) calculateBaseQuoteInvestmentQuantity( + quoteInvestment, baseInvestment, lastPrice fixedpoint.Value, pins []Pin, +) (fixedpoint.Value, error) { + s.logger.Infof("calculating quantity by base/quote investment: %f / %f", baseInvestment.Float64(), quoteInvestment.Float64()) + // q_p1 = q_p2 = q_p3 = q_p4 + // baseInvestment = q_p1 + q_p2 + q_p3 + q_p4 + .... + // baseInvestment = numberOfSellOrders * q + // maxBaseQuantity = baseInvestment / numberOfSellOrders + // if maxBaseQuantity < minQuantity or maxBaseQuantity * priceLowest < minNotional + // then reduce the numberOfSellOrders + numberOfSellOrders := s.BaseGridNum + + // if it's not configured, calculate the number of sell orders + if numberOfSellOrders == 0 { + for i := len(pins) - 1; i >= 0; i-- { + pin := pins[i] + price := fixedpoint.Value(pin) + sellPrice := price + if s.ProfitSpread.Sign() > 0 { + sellPrice = sellPrice.Add(s.ProfitSpread) + } + + if sellPrice.Compare(lastPrice) < 0 { + break + } + + numberOfSellOrders++ + } + + // avoid placing a sell order above the last price + if numberOfSellOrders > 0 { + numberOfSellOrders-- + } + + s.logger.Infof("calculated number of sell orders: %d", numberOfSellOrders) + } + + // if the maxBaseQuantity is less than minQuantity, then we need to reduce the number of the sell orders + // so that the quantity can be increased. + baseQuantity := s.Market.TruncateQuantity( + baseInvestment.Div( + fixedpoint.NewFromInt( + int64(numberOfSellOrders)))) + + minBaseQuantity := fixedpoint.Max( + s.Market.MinNotional.Div(s.UpperPrice), + s.Market.MinQuantity) + + if baseQuantity.Compare(minBaseQuantity) <= 0 { + s.logger.Infof("base quantity %s is less than min base quantity: %s, adjusting...", baseQuantity.String(), minBaseQuantity.String()) + + baseQuantity = s.Market.RoundUpQuantityByPrecision(minBaseQuantity) + numberOfSellOrders = int(math.Floor(baseInvestment.Div(baseQuantity).Float64())) + + s.logger.Infof("adjusted base quantity to %s", baseQuantity.String()) + } + + s.logger.Infof("grid base investment sell orders: %d", numberOfSellOrders) + s.logger.Infof("grid base investment quantity: %f (base investment) / %d (number of sell orders) = %f (base quantity per order)", baseInvestment.Float64(), numberOfSellOrders, baseQuantity.Float64()) + + // calculate quantity with quote investment + totalQuotePrice := fixedpoint.Zero + // quoteInvestment = (p1 * q) + (p2 * q) + (p3 * q) + .... + // => + // quoteInvestment = (p1 + p2 + p3) * q + // maxBuyQuantity = quoteInvestment / (p1 + p2 + p3) + si := -1 + end := len(pins) - 1 + for i := end - numberOfSellOrders - 1; i >= 0; i-- { + pin := pins[i] + price := fixedpoint.Value(pin) + + // buy price greater than the last price will trigger taker order. + if price.Compare(lastPrice) >= 0 { + si = i + + // when profit spread is set, we count all the grid prices as buy prices + if s.ProfitSpread.Sign() > 0 { + totalQuotePrice = totalQuotePrice.Add(price) + } else if i > 0 { + // when profit spread is not set + // we do not want to place sell order at i == 0 + // here we submit an order to convert a buy order into a sell order + nextLowerPin := pins[i-1] + nextLowerPrice := fixedpoint.Value(nextLowerPin) + // requiredQuote = requiredQuote.Add(quantity.Mul(nextLowerPrice)) + totalQuotePrice = totalQuotePrice.Add(nextLowerPrice) + } + + } else { + // for orders that buy + if s.ProfitSpread.IsZero() && i+1 == si { + continue + } + + // should never place a buy order at the upper price + if i == end { + continue + } + + totalQuotePrice = totalQuotePrice.Add(price) + } + } + + s.logger.Infof("total quote price: %f", totalQuotePrice.Float64()) + if totalQuotePrice.Sign() > 0 && quoteInvestment.Sign() > 0 { + quoteSideQuantity := quoteInvestment.Div(totalQuotePrice) + + s.logger.Infof("quote side quantity: %f = %f / %f", quoteSideQuantity.Float64(), quoteInvestment.Float64(), totalQuotePrice.Float64()) + if numberOfSellOrders > 0 { + return fixedpoint.Min(quoteSideQuantity, baseQuantity), nil + } + + return quoteSideQuantity, nil + } + + return baseQuantity, nil +} + +func (s *Strategy) newTriggerPriceHandler(ctx context.Context, session *qbtrade.ExchangeSession) types.KLineCallback { + return types.KLineWith(s.Symbol, types.Interval1m, func(k types.KLine) { + if s.TriggerPrice.Compare(k.High) > 0 || s.TriggerPrice.Compare(k.Low) < 0 { + return + } + + if s.grid != nil { + return + } + + s.logger.Infof("the last price %f hits triggerPrice %f, opening grid", k.Close.Float64(), s.TriggerPrice.Float64()) + if err := s.openGrid(ctx, session); err != nil { + s.logger.WithError(err).Errorf("failed to setup grid orders") + return + } + }) +} + +func (s *Strategy) newOrderUpdateHandler(ctx context.Context, session *qbtrade.ExchangeSession) func(o types.Order) { + return func(o types.Order) { + if s.OrderFillDelay > 0 { + time.Sleep(s.OrderFillDelay.Duration()) + } + + s.handleOrderFilled(o) + + // sync the profits to redis + qbtrade.Sync(ctx, s) + + s.updateGridNumOfOrdersMetricsWithLock() + } +} + +func (s *Strategy) newStopLossPriceHandler(ctx context.Context, session *qbtrade.ExchangeSession) types.KLineCallback { + return types.KLineWith(s.Symbol, types.Interval1m, func(k types.KLine) { + if s.StopLossPrice.Compare(k.Low) < 0 { + return + } + + s.logger.Infof("last low price %f hits stopLossPrice %f, closing grid", k.Low.Float64(), s.StopLossPrice.Float64()) + + if err := s.CloseGrid(ctx); err != nil { + s.logger.WithError(err).Errorf("can not close grid") + return + } + + base := s.Position.GetBase() + if base.Sign() < 0 { + return + } + + s.logger.Infof("position base %f > 0, closing position...", base.Float64()) + if err := s.orderExecutor.ClosePosition(ctx, fixedpoint.One, "grid2:stopLoss"); err != nil { + s.logger.WithError(err).Errorf("can not close position") + return + } + }) +} + +func (s *Strategy) newTakeProfitHandler(ctx context.Context, session *qbtrade.ExchangeSession) types.KLineCallback { + return types.KLineWith(s.Symbol, types.Interval1m, func(k types.KLine) { + if s.TakeProfitPrice.Compare(k.High) > 0 { + return + } + + s.logger.Infof("last high price %f hits takeProfitPrice %f, closing grid", k.High.Float64(), s.TakeProfitPrice.Float64()) + + if err := s.CloseGrid(ctx); err != nil { + s.logger.WithError(err).Errorf("can not close grid") + return + } + + base := s.Position.GetBase() + if base.Sign() < 0 { + return + } + + s.logger.Infof("position base %f > 0, closing position...", base.Float64()) + if err := s.orderExecutor.ClosePosition(ctx, fixedpoint.One, "grid2:takeProfit"); err != nil { + s.logger.WithError(err).Errorf("can not close position") + return + } + }) +} + +func (s *Strategy) OpenGrid(ctx context.Context) error { + return s.openGrid(ctx, s.session) +} + +// TODO: make sure the context here is the trading context or the shutdown context? +func (s *Strategy) cancelAll(ctx context.Context) error { + var werr error + + session := s.session + if session == nil { + session = s.ExchangeSession + } + + service, support := session.Exchange.(advancedOrderCancelApi) + if s.UseCancelAllOrdersApiWhenClose && !support { + s.logger.Warnf("advancedOrderCancelApi interface is not implemented, fallback to default graceful cancel, exchange %T", session) + s.UseCancelAllOrdersApiWhenClose = false + } + + if s.UseCancelAllOrdersApiWhenClose { + s.logger.Infof("useCancelAllOrdersApiWhenClose is set, using advanced order cancel api for canceling...") + + for { + s.logger.Infof("checking %s open orders...", s.Symbol) + + openOrders, err := retry.QueryOpenOrdersUntilSuccessful(ctx, session.Exchange, s.Symbol) + if err != nil { + s.logger.WithError(err).Errorf("CancelOrdersByGroupID api call error") + werr = multierr.Append(werr, err) + } + + if len(openOrders) == 0 { + break + } + + s.logger.Infof("found %d open orders left, using cancel all orders api", len(openOrders)) + + s.logger.Infof("using cancal all orders api for canceling grid orders...") + if err := retry.CancelAllOrdersUntilSuccessful(ctx, service); err != nil { + s.logger.WithError(err).Errorf("CancelAllOrders api call error") + werr = multierr.Append(werr, err) + } + + time.Sleep(1 * time.Second) + } + } else { + if err := s.orderExecutor.GracefulCancel(ctx); err != nil { + werr = multierr.Append(werr, err) + } + } + + return werr +} + +// CloseGrid closes the grid orders +func (s *Strategy) CloseGrid(ctx context.Context) error { + s.logger.Infof("closing %s grid", s.Symbol) + + defer s.EmitGridClosed() + + qbtrade.Sync(ctx, s) + + // now we can cancel the open orders + s.logger.Infof("canceling grid orders...") + + err := s.cancelAll(ctx) + + // free the grid object + s.setGrid(nil) + s.updateGridNumOfOrdersMetricsWithLock() + return err +} + +func (s *Strategy) newGrid() *Grid { + grid := NewGrid(s.LowerPrice, s.UpperPrice, fixedpoint.NewFromInt(s.GridNum), s.Market.TickSize) + grid.CalculateArithmeticPins() + return grid +} + +// openGrid +// 1) if quantity or amount is set, we should use quantity/amount directly instead of using investment amount to calculate. +// 2) if baseInvestment, quoteInvestment is set, then we should calculate the quantity from the given base investment and quote investment. +func (s *Strategy) openGrid(ctx context.Context, session *qbtrade.ExchangeSession) error { + // grid object guard + s.mu.Lock() + defer s.mu.Unlock() + + if s.grid != nil { + return nil + } + + grid := s.newGrid() + s.grid = grid + s.logger.Info("OPENING GRID: ", s.grid.String()) + + lastPrice, err := s.getLastTradePrice(ctx, session) + if err != nil { + err2 := errors.Wrap(err, "unable to get the last trade price") + s.EmitGridError(err2) + return err2 + } + + if s.BaseGridNum > 0 { + sell1 := fixedpoint.Value(s.grid.Pins[len(s.grid.Pins)-1-s.BaseGridNum]) + lastPrice = sell1.Sub(s.Market.TickSize) + } + + // check if base and quote are enough + var totalBase = fixedpoint.Zero + var totalQuote = fixedpoint.Zero + + baseBalance, ok := session.Account.Balance(s.Market.BaseCurrency) + if ok { + totalBase = baseBalance.Available + } + + quoteBalance, ok := session.Account.Balance(s.Market.QuoteCurrency) + if ok { + totalQuote = quoteBalance.Available + } + + // shift 1 grid because we will start from the buy order + // if the buy order is filled, then we will submit another sell order at the higher grid. + if s.QuantityOrAmount.IsSet() { + if quantity := s.QuantityOrAmount.Quantity; !quantity.IsZero() { + if _, _, err2 := s.checkRequiredInvestmentByQuantity(totalBase, totalQuote, lastPrice, s.QuantityOrAmount.Quantity, s.grid.Pins); err != nil { + s.EmitGridError(err2) + return err2 + } + } + if amount := s.QuantityOrAmount.Amount; !amount.IsZero() { + if _, _, err2 := s.checkRequiredInvestmentByAmount(totalBase, totalQuote, lastPrice, amount, s.grid.Pins); err != nil { + s.EmitGridError(err2) + return err2 + } + } + } else { + // calculate the quantity from the investment configuration + if !s.BaseInvestment.IsZero() { + quantity, err2 := s.calculateBaseQuoteInvestmentQuantity(s.QuoteInvestment, s.BaseInvestment, lastPrice, s.grid.Pins) + if err2 != nil { + s.EmitGridError(err2) + return err2 + } + + s.QuantityOrAmount.Quantity = quantity + + } else if !s.QuoteInvestment.IsZero() { + quantity, err2 := s.calculateQuoteInvestmentQuantity(s.QuoteInvestment, lastPrice, s.grid.Pins) + if err2 != nil { + s.EmitGridError(err2) + return err2 + } + + s.QuantityOrAmount.Quantity = quantity + } + } + + // if base investment and quote investment is set, when we should check if the + // investment configuration is valid with the current balances + if !s.BaseInvestment.IsZero() && !s.QuoteInvestment.IsZero() { + if s.BaseInvestment.Compare(totalBase) > 0 { + err2 := fmt.Errorf("baseInvestment setup %f is greater than the total base balance %f", s.BaseInvestment.Float64(), totalBase.Float64()) + s.EmitGridError(err2) + return err2 + } + if s.QuoteInvestment.Compare(totalQuote) > 0 { + err2 := fmt.Errorf("quoteInvestment setup %f is greater than the total quote balance %f", s.QuoteInvestment.Float64(), totalQuote.Float64()) + s.EmitGridError(err2) + return err2 + } + } + + var submitOrders []types.SubmitOrder + + if !s.BaseInvestment.IsZero() || !s.QuoteInvestment.IsZero() { + submitOrders, err = s.generateGridOrders(s.QuoteInvestment, s.BaseInvestment, lastPrice) + } else { + submitOrders, err = s.generateGridOrders(totalQuote, totalBase, lastPrice) + } + + if err != nil { + s.EmitGridError(err) + return err + } + + s.debugGridOrders(submitOrders, lastPrice) + + writeCtx := s.getWriteContext(ctx) + + createdOrders, err2 := s.orderExecutor.SubmitOrders(writeCtx, submitOrders...) + if err2 != nil { + s.EmitGridError(err2) + return err2 + } + + // try to always emit grid ready + defer s.EmitGridReady() + + // update the number of orders to metrics + baseLabels := s.newPrometheusLabels() + metricsGridNumOfOrders.With(baseLabels).Set(float64(len(createdOrders))) + + var orderIds []uint64 + + for _, order := range createdOrders { + orderIds = append(orderIds, order.OrderID) + + s.logger.Info(order.String()) + } + + sort.Slice(orderIds, func(i, j int) bool { + return orderIds[i] < orderIds[j] + }) + + if len(orderIds) > 0 { + s.GridProfitStats.InitialOrderID = orderIds[0] + qbtrade.Sync(ctx, s) + } + + s.logger.Infof("ALL GRID ORDERS SUBMITTED") + + s.updateGridNumOfOrdersMetrics(grid) + return nil +} + +func (s *Strategy) updateFilledOrderMetrics(order types.Order) { + labels := s.newPrometheusLabels() + labels["side"] = order.Side.String() + metricsGridFilledOrderPrice.With(labels).Set(order.Price.Float64()) +} + +func (s *Strategy) updateGridNumOfOrdersMetricsWithLock() { + if s.mu.TryLock() { + grid := s.grid + s.mu.Unlock() + s.updateGridNumOfOrdersMetrics(grid) + } else { + s.logger.Warnf("updateGridNumOfOrdersMetricsWithLock: failed to acquire the lock to update metrics") + } +} + +func (s *Strategy) updateGridNumOfOrdersMetrics(grid *Grid) { + baseLabels := s.newPrometheusLabels() + makerOrders := s.orderExecutor.ActiveMakerOrders() + numOfOrders := makerOrders.NumOfOrders() + metricsGridNumOfOrders.With(baseLabels).Set(float64(numOfOrders)) + metricsGridLowerPrice.With(baseLabels).Set(s.LowerPrice.Float64()) + metricsGridUpperPrice.With(baseLabels).Set(s.UpperPrice.Float64()) + metricsGridQuoteInvestment.With(baseLabels).Set(s.QuoteInvestment.Float64()) + metricsGridBaseInvestment.With(baseLabels).Set(s.BaseInvestment.Float64()) + + if grid != nil { + gridNum := grid.Size.Int() + metricsGridNum.With(baseLabels).Set(float64(gridNum)) + numOfMissingOrders := gridNum - 1 - numOfOrders + metricsGridNumOfMissingOrders.With(baseLabels).Set(float64(numOfMissingOrders)) + + var numOfOrdersWithCorrectPrice int + priceSet := make(map[fixedpoint.Value]struct{}) + for _, order := range makerOrders.Orders() { + // filter out duplicated prices + if _, ok := priceSet[order.Price]; ok { + continue + } + priceSet[order.Price] = struct{}{} + + if grid.HasPin(Pin(order.Price)) { + numOfOrdersWithCorrectPrice++ + } + } + numOfMissingOrdersWithCorrectPrice := gridNum - 1 - numOfOrdersWithCorrectPrice + metricsGridNumOfOrdersWithCorrectPrice.With(baseLabels).Set(float64(numOfOrdersWithCorrectPrice)) + metricsGridNumOfMissingOrdersWithCorrectPrice.With(baseLabels).Set(float64(numOfMissingOrdersWithCorrectPrice)) + } +} + +func (s *Strategy) debugGridOrders(submitOrders []types.SubmitOrder, lastPrice fixedpoint.Value) { + if !s.Debug { + return + } + + var sb strings.Builder + + sb.WriteString("GRID ORDERS [\n") + for i, order := range submitOrders { + if i > 0 && lastPrice.Compare(order.Price) >= 0 && lastPrice.Compare(submitOrders[i-1].Price) <= 0 { + sb.WriteString(fmt.Sprintf(" - LAST PRICE: %f\n", lastPrice.Float64())) + } + + sb.WriteString(" - " + order.String() + "\n") + } + sb.WriteString("] END OF GRID ORDERS") + + s.logger.Infof(sb.String()) +} + +func (s *Strategy) debugOrders(desc string, orders []types.Order) { + if !s.Debug { + return + } + + var sb strings.Builder + + if desc == "" { + desc = "ORDERS" + } + + sb.WriteString(desc + " [\n") + for i, order := range orders { + sb.WriteString(fmt.Sprintf(" - %d) %s\n", i, order.String())) + } + sb.WriteString("]") + + s.logger.Infof(sb.String()) +} + +func (s *Strategy) debugLog(format string, args ...interface{}) { + if !s.Debug { + return + } + + s.logger.Infof(format, args...) +} +func (s *Strategy) generateGridOrders(totalQuote, totalBase, lastPrice fixedpoint.Value) ([]types.SubmitOrder, error) { + var pins = s.grid.Pins + var usedBase = fixedpoint.Zero + var usedQuote = fixedpoint.Zero + var submitOrders []types.SubmitOrder + + // si is for sell order price index + var si = len(pins) + for i := len(pins) - 1; i >= 0; i-- { + pin := pins[i] + price := fixedpoint.Value(pin) + sellPrice := price + + // when profitSpread is set, the sell price is shift upper with the given spread + if s.ProfitSpread.Sign() > 0 { + sellPrice = sellPrice.Add(s.ProfitSpread) + } + + quantity := s.QuantityOrAmount.Quantity + if quantity.IsZero() { + quantity = s.QuantityOrAmount.Amount.Div(price) + } + + placeSell := price.Compare(lastPrice) >= 0 + + // override the relative price position for sell order if BaseGridNum is defined + if s.BaseGridNum > 0 { + placeSell = i >= len(pins)-1-s.BaseGridNum + } + + if placeSell { + si = i + + // do not place sell order when i == 0 (the bottom of grid) + if i == 0 { + continue + } + + if usedBase.Add(quantity).Compare(totalBase) <= 0 { + submitOrders = append(submitOrders, types.SubmitOrder{ + Symbol: s.Symbol, + Type: types.OrderTypeLimit, + Side: types.SideTypeSell, + Price: sellPrice, + Quantity: quantity, + Market: s.Market, + TimeInForce: types.TimeInForceGTC, + Tag: orderTag, + GroupID: s.OrderGroupID, + ClientOrderID: s.newClientOrderID(), + }) + usedBase = usedBase.Add(quantity) + } else { + // if we don't have enough base asset + // then we need to place a buy order at the next price. + nextPin := pins[i-1] + nextPrice := fixedpoint.Value(nextPin) + submitOrders = append(submitOrders, types.SubmitOrder{ + Symbol: s.Symbol, + Type: types.OrderTypeLimit, + Side: types.SideTypeBuy, + Price: nextPrice, + Quantity: quantity, + Market: s.Market, + TimeInForce: types.TimeInForceGTC, + Tag: orderTag, + GroupID: s.OrderGroupID, + ClientOrderID: s.newClientOrderID(), + }) + quoteQuantity := quantity.Mul(nextPrice) + + // because the precision issue, we need to round up quote quantity and add it into used quote + // e.g. quote we calculate : 8888.85, but it may lock 8888.9 due to their precision. + roundUpQuoteQuantity := quoteQuantity.Round(s.Market.PricePrecision, fixedpoint.Up) + usedQuote = usedQuote.Add(roundUpQuoteQuantity) + } + } else { + // if price spread is not enabled, and we have already placed a sell order index on the top of this price, + // then we should skip + if s.ProfitSpread.IsZero() && i+1 == si { + continue + } + + // should never place a buy order at the upper price + if i == len(pins)-1 { + continue + } + + quoteQuantity := quantity.Mul(price) + + // because the precision issue, we need to round up quote quantity and add it into used quote + // e.g. quote we calculate : 8888.85, but it may lock 8888.9 due to their precision. + roundUpQuoteQuantity := quoteQuantity.Round(s.Market.PricePrecision, fixedpoint.Up) + if usedQuote.Add(roundUpQuoteQuantity).Compare(totalQuote) > 0 { + if i > 0 { + return nil, fmt.Errorf("used quote %f > total quote %f, this should not happen", usedQuote.Add(quoteQuantity).Float64(), totalQuote.Float64()) + } else { + restQuote := totalQuote.Sub(usedQuote) + quantity = restQuote.Div(price).Round(s.Market.VolumePrecision, fixedpoint.Down) + if s.Market.MinQuantity.Compare(quantity) > 0 { + return nil, fmt.Errorf("the round down quantity (%s) is less than min quantity (%s), we cannot place this order", quantity, s.Market.MinQuantity) + } + } + } + + submitOrders = append(submitOrders, types.SubmitOrder{ + Symbol: s.Symbol, + Type: types.OrderTypeLimit, + Side: types.SideTypeBuy, + Price: price, + Quantity: quantity, + Market: s.Market, + TimeInForce: types.TimeInForceGTC, + Tag: orderTag, + GroupID: s.OrderGroupID, + ClientOrderID: s.newClientOrderID(), + }) + usedQuote = usedQuote.Add(roundUpQuoteQuantity) + } + } + + return submitOrders, nil +} + +func (s *Strategy) clearOpenOrders(ctx context.Context, session *qbtrade.ExchangeSession) error { + // clear open orders when start + openOrders, err := retry.QueryOpenOrdersUntilSuccessful(ctx, session.Exchange, s.Symbol) + if err != nil { + return err + } + + return retry.CancelOrdersUntilSuccessful(ctx, session.Exchange, openOrders...) +} + +func (s *Strategy) getLastTradePrice(ctx context.Context, session *qbtrade.ExchangeSession) (fixedpoint.Value, error) { + if qbtrade.IsBackTesting { + price, ok := session.LastPrice(s.Symbol) + if !ok { + return fixedpoint.Zero, fmt.Errorf("last price of %s not found", s.Symbol) + } + + return price, nil + } + + tickers, err := session.Exchange.QueryTickers(ctx, s.Symbol) + if err != nil { + return fixedpoint.Zero, err + } + + if ticker, ok := tickers[s.Symbol]; ok { + if !ticker.Last.IsZero() { + return ticker.Last, nil + } + + // fallback to buy price + return ticker.Buy, nil + } + + return fixedpoint.Zero, fmt.Errorf("%s ticker price not found", s.Symbol) +} + +func calculateMinimalQuoteInvestment(market types.Market, grid *Grid) fixedpoint.Value { + // upperPrice for buy order + lowerPrice := grid.LowerPrice + minQuantity := fixedpoint.Max(market.MinNotional.Div(lowerPrice), market.MinQuantity) + + var pins = grid.Pins + var totalQuote = fixedpoint.Zero + for i := len(pins) - 2; i >= 0; i-- { + pin := pins[i] + price := fixedpoint.Value(pin) + + // TODO: should we round the quote here before adding? + totalQuote = totalQuote.Add(price.Mul(minQuantity)) + } + + return totalQuote +} + +func (s *Strategy) checkMinimalQuoteInvestment(grid *Grid) error { + minimalQuoteInvestment := calculateMinimalQuoteInvestment(s.Market, grid) + if s.QuoteInvestment.Compare(minimalQuoteInvestment) <= 0 { + return fmt.Errorf("need at least %f %s for quote investment, %f %s given", + minimalQuoteInvestment.Float64(), + s.Market.QuoteCurrency, + s.QuoteInvestment.Float64(), + s.Market.QuoteCurrency) + } + return nil +} + +func (s *Strategy) recoverGridWithOpenOrders( + ctx context.Context, historyService types.ExchangeTradeHistoryService, openOrders []types.Order, +) error { + grid := s.newGrid() + + s.logger.Infof("GRID RECOVER: %s", grid.String()) + + lastOrderID := uint64(1) + now := time.Now() + firstOrderTime := now.AddDate(0, 0, -7) + lastOrderTime := firstOrderTime + if since, until, ok := scanOrderCreationTimeRange(openOrders); ok { + firstOrderTime = since + lastOrderTime = until + } + _ = lastOrderTime + + // for MAX exchange we need the order ID to query the closed order history + if s.GridProfitStats != nil && s.GridProfitStats.InitialOrderID > 0 { + lastOrderID = s.GridProfitStats.InitialOrderID + s.logger.Infof("found initial order id #%d from grid stats", lastOrderID) + } else { + if oid, ok := findEarliestOrderID(openOrders); ok { + lastOrderID = oid + s.logger.Infof("found earliest order id #%d from open orders", lastOrderID) + } + } + + // Allocate a local order book for querying the history orders + orderBook := qbtrade.NewActiveOrderBook(s.Symbol) + + // Ensure that orders are grid orders + // The price must be at the grid pin + gridOrders := grid.FilterOrders(openOrders) + for _, gridOrder := range gridOrders { + orderBook.Add(gridOrder) + } + + // if all open orders are the grid orders, then we don't have to recover + s.logger.Infof("GRID RECOVER: verifying pins %v", PrettyPins(grid.Pins)) + missingPrices := scanMissingPinPrices(orderBook, grid.Pins) + if numMissing := len(missingPrices); numMissing <= 1 { + s.logger.Infof("GRID RECOVER: no missing grid prices, stop re-playing order history") + s.addOrdersToActiveOrderBook(gridOrders) + s.setGrid(grid) + s.EmitGridReady() + s.updateGridNumOfOrdersMetricsWithLock() + return nil + } else { + s.logger.Infof("GRID RECOVER: found missing prices: %v", missingPrices) + // Note that for MAX Exchange, the order history API only uses fromID parameter to query history order. + // The time range does not matter. + // TODO: handle context correctly + startTime := firstOrderTime + endTime := now + maxTries := 5 + localHistoryRollbackDuration := historyRollbackDuration + for maxTries > 0 { + maxTries-- + if err := s.replayOrderHistory(ctx, grid, orderBook, historyService, startTime, endTime, lastOrderID); err != nil { + return err + } + + // Verify if there are still missing prices + missingPrices = scanMissingPinPrices(orderBook, grid.Pins) + if len(missingPrices) <= 1 { + // skip this order history loop and start recovering + break + } + + // history rollback range + startTime = startTime.Add(-localHistoryRollbackDuration) + if newFromOrderID := lastOrderID - historyRollbackOrderIdRange; newFromOrderID > 1 { + lastOrderID = newFromOrderID + } + + s.logger.Infof("GRID RECOVER: there are still more than two missing orders, rolling back query start time to earlier time point %s, fromID %d", startTime.String(), lastOrderID) + localHistoryRollbackDuration = localHistoryRollbackDuration * 2 + } + } + + debugGrid(s.logger, grid, orderBook) + + // note that the tmpOrders contains FILLED and NEW orders + tmpOrders := orderBook.Orders() + + // if all orders on the order book are active orders, we don't need to recover. + if isCompleteGridOrderBook(orderBook, s.GridNum) { + s.logger.Infof("GRID RECOVER: all orders are active orders, do not need recover") + s.addOrdersToActiveOrderBook(gridOrders) + s.setGrid(grid) + s.EmitGridReady() + s.updateGridNumOfOrdersMetricsWithLock() + return nil + } + + // for reverse order recovering, we need the orders to be sort by update time ascending-ly + types.SortOrdersUpdateTimeAscending(tmpOrders) + + // we will only submit reverse orders for filled orders + filledOrders := types.OrdersFilled(tmpOrders) + + // if the number of FILLED orders and NEW orders equals to GridNum, then we need to remove an extra filled order for the replay events + if len(tmpOrders) == int(s.GridNum) && len(filledOrders) > 0 { + // remove the latest updated order because it's near the empty slot + filledOrders = filledOrders[1:] + } + + s.logger.Infof("GRID RECOVER: found %d/%d filled grid orders, gridNumber=%d, will re-replay the order event in the following order:", len(filledOrders), len(tmpOrders), int(s.GridNum)) + for i, o := range filledOrders { + s.logger.Infof("%d) %s", i+1, o.String()) + } + + // before we re-play the orders, + // we need to add these open orders to the active order book + s.addOrdersToActiveOrderBook(gridOrders) + s.setGrid(grid) + s.EmitGridReady() + s.updateGridNumOfOrdersMetricsWithLock() + + for i := range filledOrders { + // avoid using the iterator + o := filledOrders[i] + s.processFilledOrder(o) + time.Sleep(100 * time.Millisecond) + } + + // wait for the reverse order to be placed + time.Sleep(2 * time.Second) + + s.logger.Infof("GRID RECOVER COMPLETE") + + debugGrid(s.logger, grid, s.orderExecutor.ActiveMakerOrders()) + + s.updateGridNumOfOrdersMetricsWithLock() + return nil +} + +func (s *Strategy) addOrdersToActiveOrderBook(gridOrders []types.Order) { + activeOrderBook := s.orderExecutor.ActiveMakerOrders() + for _, gridOrder := range gridOrders { + // put the order back to the active order book so that we can receive order update + activeOrderBook.Add(gridOrder) + } +} + +func (s *Strategy) setGrid(grid *Grid) { + s.mu.Lock() + s.grid = grid + s.mu.Unlock() +} + +func (s *Strategy) getGrid() *Grid { + s.mu.Lock() + grid := s.grid + s.mu.Unlock() + return grid +} + +// replayOrderHistory queries the closed order history from the API and rebuild the orderbook from the order history. +// startTime, endTime is the time range of the order history. +func (s *Strategy) replayOrderHistory( + ctx context.Context, grid *Grid, orderBook *qbtrade.ActiveOrderBook, historyService types.ExchangeTradeHistoryService, + startTime, endTime time.Time, lastOrderID uint64, +) error { + // a simple guard, in reality, this startTime is not possible to exceed the endTime + // because the queries closed orders might still in the range. + orderIdChanged := true + for startTime.Before(endTime) && orderIdChanged { + closedOrders, err := historyService.QueryClosedOrders(ctx, s.Symbol, startTime, endTime, lastOrderID) + if err != nil { + return err + } + + // need to prevent infinite loop for: + // if there is only one order and the order creation time matches our startTime + if len(closedOrders) == 0 || len(closedOrders) == 1 && closedOrders[0].OrderID == lastOrderID { + break + } + + // for each closed order, if it's newer than the open order's update time, we will update it. + orderIdChanged = false + for _, closedOrder := range closedOrders { + if closedOrder.OrderID > lastOrderID { + lastOrderID = closedOrder.OrderID + orderIdChanged = true + } + + // skip orders that are not limit order + if closedOrder.Type != types.OrderTypeLimit { + continue + } + + // skip canceled orders (?) + if closedOrder.Status == types.OrderStatusCanceled { + continue + } + + creationTime := closedOrder.CreationTime.Time() + if creationTime.After(startTime) { + startTime = creationTime + } + + // skip non-grid order prices + + if !grid.HasPrice(closedOrder.Price) { + continue + } + + existingOrder := orderBook.Lookup(func(o types.Order) bool { + return o.Price.Eq(closedOrder.Price) + }) + + if existingOrder == nil { + orderBook.Add(closedOrder) + } else { + // To update order, we need to remove the old order, because it's using order ID as the key of the map. + if creationTime.After(existingOrder.CreationTime.Time()) { + orderBook.Remove(*existingOrder) + orderBook.Add(closedOrder) + } + } + } + } + + return nil +} + +// isCompleteGridOrderBook checks if the number of open orders == gridNum - 1 and all orders are active order +func isCompleteGridOrderBook(orderBook *qbtrade.ActiveOrderBook, gridNum int64) bool { + tmpOrders := orderBook.Orders() + activeOrders := types.OrdersActive(tmpOrders) + return len(activeOrders) == int(gridNum)-1 +} + +func findEarliestOrderID(orders []types.Order) (uint64, bool) { + if len(orders) == 0 { + return 0, false + } + + earliestOrderID := orders[0].OrderID + for _, o := range orders { + if o.OrderID < earliestOrderID { + earliestOrderID = o.OrderID + } + } + + return earliestOrderID, true +} + +// scanOrderCreationTimeRange finds the earliest creation time and the latest creation time from the given orders +func scanOrderCreationTimeRange(orders []types.Order) (time.Time, time.Time, bool) { + if len(orders) == 0 { + return time.Time{}, time.Time{}, false + } + + firstOrderTime := orders[0].CreationTime.Time() + lastOrderTime := firstOrderTime + for _, o := range orders { + createTime := o.CreationTime.Time() + if createTime.Before(firstOrderTime) { + firstOrderTime = createTime + } else if createTime.After(lastOrderTime) { + lastOrderTime = createTime + } + } + + return firstOrderTime, lastOrderTime, true +} + +// scanMissingPinPrices finds the missing grid order prices +func scanMissingPinPrices(orderBook *qbtrade.ActiveOrderBook, pins []Pin) PriceMap { + // Add all open orders to the local order book + gridPrices := make(PriceMap) + missingPrices := make(PriceMap) + for _, pin := range pins { + price := fixedpoint.Value(pin) + gridPrices[price.String()] = price + existingOrder := orderBook.Lookup(func(o types.Order) bool { + return o.Price.Compare(price) == 0 + }) + if existingOrder == nil { + missingPrices[price.String()] = price + } + } + + return missingPrices +} + +func (s *Strategy) newPrometheusLabels() prometheus.Labels { + labels := prometheus.Labels{ + "exchange": "default", + "symbol": s.Symbol, + } + + if s.session != nil { + labels["exchange"] = s.session.Name + } + + if s.PrometheusLabels == nil { + return labels + } + + return mergeLabels(s.PrometheusLabels, labels) +} + +func (s *Strategy) CleanUp(ctx context.Context) error { + if s.ExchangeSession != nil { + s.session = s.ExchangeSession + } + + _ = s.Initialize() + + defer s.EmitGridClosed() + return s.cancelAll(ctx) +} + +func (s *Strategy) getWriteContext(fallbackCtxList ...context.Context) context.Context { + if s.writeCtx != nil { + return s.writeCtx + } + + for _, c := range fallbackCtxList { + if c != nil { + return c + } + } + + if s.tradingCtx != nil { + return s.tradingCtx + } + + // final fallback to context background + return context.Background() +} + +func (s *Strategy) Run(ctx context.Context, _ qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + instanceID := s.InstanceID() + + // allocate a context for write operation (submitting orders) + s.tradingCtx = ctx + s.writeCtx, s.cancelWrite = context.WithCancel(ctx) + + s.session = session + + if service, ok := session.Exchange.(types.ExchangeOrderQueryService); ok { + s.orderQueryService = service + } + + if s.OrderGroupID == 0 { + s.OrderGroupID = util.FNV32(instanceID) % math.MaxInt32 + } + + if s.AutoRange != nil { + indicatorSet := session.StandardIndicatorSet(s.Symbol) + interval := s.AutoRange.Interval() + pivotLow := indicatorSet.PivotLow(types.IntervalWindow{Interval: interval, Window: s.AutoRange.Num}) + pivotHigh := indicatorSet.PivotHigh(types.IntervalWindow{Interval: interval, Window: s.AutoRange.Num}) + s.UpperPrice = fixedpoint.NewFromFloat(pivotHigh.Last(0)) + s.LowerPrice = fixedpoint.NewFromFloat(pivotLow.Last(0)) + s.logger.Infof("autoRange is enabled, using pivot high %f and pivot low %f", s.UpperPrice.Float64(), s.LowerPrice.Float64()) + } + + if s.ProfitSpread.Sign() > 0 { + s.ProfitSpread = s.Market.TruncatePrice(s.ProfitSpread) + } + + s.logger.Infof("persistence ttl: %s", s.PersistenceTTL.Duration()) + + if s.GridProfitStats == nil { + s.GridProfitStats = newGridProfitStats(s.Market) + } + s.GridProfitStats.SetTTL(s.PersistenceTTL.Duration()) + + if s.Position == nil { + s.Position = types.NewPositionFromMarket(s.Market) + } + s.Position.SetTTL(s.PersistenceTTL.Duration()) + + // initialize and register prometheus metrics + if s.PrometheusLabels != nil { + initMetrics(labelKeys(s.PrometheusLabels)) + } else { + initMetrics(nil) + } + registerMetrics() + + if s.ResetPositionWhenStart { + s.Position.Reset() + } + + // we need to check the minimal quote investment here, because we need the market info + if s.QuoteInvestment.Sign() > 0 { + grid := s.newGrid() + if err := s.checkMinimalQuoteInvestment(grid); err != nil { + if s.StopIfLessThanMinimalQuoteInvestment { + s.logger.WithError(err).Errorf("check minimal quote investment failed, market info: %+v", s.Market) + return err + } else { + // if no, just warning + s.logger.WithError(err).Warnf("minimal quote investment may be not enough, market info: %+v", s.Market) + } + } + } + + s.historicalTrades = core.NewTradeStore() + s.historicalTrades.EnablePrune = true + s.historicalTrades.BindStream(session.UserDataStream) + + orderExecutor := qbtrade.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, s.Position) + orderExecutor.BindEnvironment(s.Environment) + orderExecutor.Bind() + orderExecutor.TradeCollector().OnTrade(func(trade types.Trade, _, _ fixedpoint.Value) { + s.GridProfitStats.AddTrade(trade) + }) + orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { + qbtrade.Sync(ctx, s) + }) + orderExecutor.ActiveMakerOrders().OnFilled(s.newOrderUpdateHandler(ctx, session)) + orderExecutor.SetMaxRetries(5) + + if s.logger != nil { + orderExecutor.SetLogger(s.logger) + } + + s.orderExecutor = orderExecutor + + s.OnGridProfit(func(stats *GridProfitStats, profit *GridProfit) { + if profit != nil { + qbtrade.Notify(profit) + } + qbtrade.Notify(stats) + }) + + s.OnGridProfit(func(stats *GridProfitStats, profit *GridProfit) { + labels := s.newPrometheusLabels() + metricsGridProfit.With(labels).Set(stats.TotalQuoteProfit.Float64()) + }) + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + if s.cancelWrite != nil { + s.cancelWrite() + } + + if s.KeepOrdersWhenShutdown { + s.logger.Infof("keepOrdersWhenShutdown is set, will keep the orders on the exchange") + return + } + + if err := s.CloseGrid(ctx); err != nil { + s.logger.WithError(err).Errorf("grid graceful order cancel error") + } + }) + + if !s.TriggerPrice.IsZero() { + session.MarketDataStream.OnKLineClosed(s.newTriggerPriceHandler(ctx, session)) + } + + if !s.StopLossPrice.IsZero() { + session.MarketDataStream.OnKLineClosed(s.newStopLossPriceHandler(ctx, session)) + } + + if !s.TakeProfitPrice.IsZero() { + session.MarketDataStream.OnKLineClosed(s.newTakeProfitHandler(ctx, session)) + } + + // detect if there are previous grid orders on the order book + session.UserDataStream.OnStart(func() { + if s.ClearOpenOrdersWhenStart { + s.logger.Infof("clearOpenOrdersWhenStart is set, clearing open orders...") + if err := s.clearOpenOrders(ctx, session); err != nil { + s.logger.WithError(err).Errorf("clearOpenOrdersWhenStart error") + } + } + + if s.ClearOpenOrdersIfMismatch { + s.logger.Infof("clearOpenOrdersIfMismatch is set, checking mismatched orders...") + mismatch, err := s.openOrdersMismatches(ctx, session) + if err != nil { + s.logger.WithError(err).Errorf("clearOpenOrdersIfMismatch error") + } else if mismatch { + if err2 := s.clearOpenOrders(ctx, session); err2 != nil { + s.logger.WithError(err2).Errorf("clearOpenOrders error") + } + } + } + + if s.ClearDuplicatedPriceOpenOrders { + s.logger.Infof("clearDuplicatedPriceOpenOrders is set, finding duplicated open orders...") + if err := s.cancelDuplicatedPriceOpenOrders(ctx, session); err != nil { + s.logger.WithError(err).Errorf("cancelDuplicatedPriceOpenOrders error") + } + } + }) + + // if TriggerPrice is zero, that means we need to open the grid when start up + if s.TriggerPrice.IsZero() { + session.UserDataStream.OnAuth(func() { + s.logger.Infof("user data stream authenticated, start the process") + if !qbtrade.IsBackTesting { + time.AfterFunc(3*time.Second, func() { + if err := s.startProcess(ctx, session); err != nil { + return + } + + s.recoverActiveOrdersPeriodically(ctx) + }) + } else { + s.startProcess(ctx, session) + } + }) + } + + return nil +} + +func (s *Strategy) startProcess(ctx context.Context, session *qbtrade.ExchangeSession) error { + if s.RecoverOrdersWhenStart { + // do recover only when triggerPrice is not set and not in the back-test mode + s.logger.Infof("recoverWhenStart is set, trying to recover grid orders...") + if err := s.recoverGrid(ctx, session); err != nil { + // if recover fail, return and do not open grid + s.logger.WithError(err).Error("failed to start process, recover error") + s.EmitGridError(errors.Wrapf(err, "failed to start process, recover error")) + return err + } + } + + // avoid using goroutine here for back-test + if err := s.openGrid(ctx, session); err != nil { + s.EmitGridError(errors.Wrapf(err, "failed to start process, setup grid orders error")) + return err + } + + return nil +} + +func (s *Strategy) recoverGrid(ctx context.Context, session *qbtrade.ExchangeSession) error { + if s.RecoverGridByScanningTrades { + s.debugLog("recovering grid by scanning trades") + return s.recoverByScanningTrades(ctx, session) + } + + s.debugLog("recovering grid by scanning orders") + return s.recoverByScanningOrders(ctx, session) +} + +func (s *Strategy) recoverByScanningOrders(ctx context.Context, session *qbtrade.ExchangeSession) error { + openOrders, err := retry.QueryOpenOrdersUntilSuccessful(ctx, session.Exchange, s.Symbol) + if err != nil { + return err + } + + // do recover only when openOrders > 0 + if len(openOrders) == 0 { + s.logger.Warn("0 open orders, skip recovery process") + return nil + } + + s.logger.Infof("found %d open orders left on the %s order book", len(openOrders), s.Symbol) + + historyService, implemented := session.Exchange.(types.ExchangeTradeHistoryService) + if !implemented { + s.logger.Warn("ExchangeTradeHistoryService is not implemented, can not recover grid") + return nil + } + + if err := s.recoverGridWithOpenOrders(ctx, historyService, openOrders); err != nil { + return errors.Wrap(err, "grid recover error") + } + + return nil +} + +// openOrdersMismatches verifies if the open orders are on the grid pins +// return true if mismatches +func (s *Strategy) openOrdersMismatches(ctx context.Context, session *qbtrade.ExchangeSession) (bool, error) { + openOrders, err := session.Exchange.QueryOpenOrders(ctx, s.Symbol) + if err != nil { + return false, err + } + + if len(openOrders) == 0 { + return false, nil + } + + grid := s.newGrid() + for _, o := range openOrders { + // if any of the open order is not on the grid, or out of the range + // we should cancel all of them + if !grid.HasPrice(o.Price) || grid.OutOfRange(o.Price) { + return true, nil + } + } + + return false, nil +} + +func (s *Strategy) cancelDuplicatedPriceOpenOrders(ctx context.Context, session *qbtrade.ExchangeSession) error { + openOrders, err := retry.QueryOpenOrdersUntilSuccessful(ctx, session.Exchange, s.Symbol) + if err != nil { + return err + } + + if len(openOrders) == 0 { + return nil + } + + dupOrders := s.findDuplicatedPriceOpenOrders(openOrders) + + if len(dupOrders) > 0 { + s.debugOrders("DUPLICATED ORDERS", dupOrders) + return session.Exchange.CancelOrders(ctx, dupOrders...) + } + + s.logger.Infof("no duplicated order found") + return nil +} + +func (s *Strategy) findDuplicatedPriceOpenOrders(openOrders []types.Order) (dupOrders []types.Order) { + orderBook := qbtrade.NewActiveOrderBook(s.Symbol) + for _, openOrder := range openOrders { + existingOrder := orderBook.Lookup(func(o types.Order) bool { + return o.Price.Compare(openOrder.Price) == 0 + }) + + if existingOrder != nil { + // found duplicated order + // compare creation time and remove the latest created order + // if the creation time equals, then we can just cancel one of them + s.debugOrders( + fmt.Sprintf("found duplicated order at price %s, comparing orders", openOrder.Price.String()), + []types.Order{*existingOrder, openOrder}) + + dupOrder := *existingOrder + if openOrder.CreationTime.After(existingOrder.CreationTime.Time()) { + dupOrder = openOrder + } else if openOrder.CreationTime.Before(existingOrder.CreationTime.Time()) { + // override the existing order and take the existing order as a duplicated one + orderBook.Add(openOrder) + } + + dupOrders = append(dupOrders, dupOrder) + } else { + orderBook.Add(openOrder) + } + } + + return dupOrders +} + +func (s *Strategy) newClientOrderID() string { + if s.session != nil && s.session.ExchangeName == types.ExchangeMax { + return uuid.New().String() + } + return "" +} + +func (s *Strategy) recoverActiveOrders(ctx context.Context, session *qbtrade.ExchangeSession) { + s.logger.Infof("recovering active orders after websocket connect") + + grid := s.getGrid() + if grid == nil { + return + } + + // this lock avoids recovering the active orders while the openGrid is executing + s.mu.Lock() + defer s.mu.Unlock() + + // TODO: move this logics into the active maker orders component, like activeOrders.Sync(ctx) + activeOrderBook := s.orderExecutor.ActiveMakerOrders() + activeOrders := activeOrderBook.Orders() + if len(activeOrders) == 0 { + return + } + + s.logger.Infof("found %d active orders to update...", len(activeOrders)) + for i, o := range activeOrders { + s.logger.Infof("updating %d/%d order #%d...", i+1, len(activeOrders), o.OrderID) + + updatedOrder, err := retry.QueryOrderUntilSuccessful(ctx, s.orderQueryService, types.OrderQuery{ + Symbol: o.Symbol, + OrderID: strconv.FormatUint(o.OrderID, 10), + }) + + if err != nil { + s.logger.WithError(err).Errorf("unable to query order") + return + } + + s.logger.Infof("triggering updated order #%d: %s", o.OrderID, o.String()) + activeOrderBook.Update(*updatedOrder) + } +} diff --git a/pkg/strategy/grid2/strategy_callbacks.go b/pkg/strategy/grid2/strategy_callbacks.go new file mode 100644 index 0000000..18caeed --- /dev/null +++ b/pkg/strategy/grid2/strategy_callbacks.go @@ -0,0 +1,45 @@ +// Code generated by "callbackgen -type Strategy"; DO NOT EDIT. + +package grid2 + +import () + +func (s *Strategy) OnGridReady(cb func()) { + s.gridReadyCallbacks = append(s.gridReadyCallbacks, cb) +} + +func (s *Strategy) EmitGridReady() { + for _, cb := range s.gridReadyCallbacks { + cb() + } +} + +func (s *Strategy) OnGridProfit(cb func(stats *GridProfitStats, profit *GridProfit)) { + s.gridProfitCallbacks = append(s.gridProfitCallbacks, cb) +} + +func (s *Strategy) EmitGridProfit(stats *GridProfitStats, profit *GridProfit) { + for _, cb := range s.gridProfitCallbacks { + cb(stats, profit) + } +} + +func (s *Strategy) OnGridClosed(cb func()) { + s.gridClosedCallbacks = append(s.gridClosedCallbacks, cb) +} + +func (s *Strategy) EmitGridClosed() { + for _, cb := range s.gridClosedCallbacks { + cb() + } +} + +func (s *Strategy) OnGridError(cb func(err error)) { + s.gridErrorCallbacks = append(s.gridErrorCallbacks, cb) +} + +func (s *Strategy) EmitGridError(err error) { + for _, cb := range s.gridErrorCallbacks { + cb(err) + } +} diff --git a/pkg/strategy/grid2/strategy_test.go b/pkg/strategy/grid2/strategy_test.go new file mode 100644 index 0000000..156a65e --- /dev/null +++ b/pkg/strategy/grid2/strategy_test.go @@ -0,0 +1,1725 @@ +//go:build !dnum + +package grid2 + +import ( + "context" + "errors" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/core" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + gridmocks "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/grid2/mocks" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types/mocks" +) + +func init() { + registerMetrics() +} + +func equalOrdersIgnoreClientOrderID(a, b types.SubmitOrder) bool { + return a.Symbol == b.Symbol && + a.Side == b.Side && + a.Type == b.Type && + a.Quantity == b.Quantity && + a.Price == b.Price && + a.AveragePrice == b.AveragePrice && + a.StopPrice == b.StopPrice && + a.Market == b.Market && + a.TimeInForce == b.TimeInForce && + a.GroupID == b.GroupID && + a.MarginSideEffect == b.MarginSideEffect && + a.ReduceOnly == b.ReduceOnly && + a.ClosePosition == b.ClosePosition && + a.Tag == b.Tag +} + +func TestStrategy_checkRequiredInvestmentByQuantity(t *testing.T) { + s := &Strategy{ + logger: logrus.NewEntry(logrus.New()), + + Market: types.Market{ + BaseCurrency: "BTC", + QuoteCurrency: "USDT", + }, + } + + t.Run("quote to base balance conversion check", func(t *testing.T) { + _, requiredQuote, err := s.checkRequiredInvestmentByQuantity(number(0.0), number(10_000.0), number(0.1), number(13_500.0), []Pin{ + Pin(number(10_000.0)), // 0.1 * 10_000 = 1000 USD (buy) + Pin(number(11_000.0)), // 0.1 * 11_000 = 1100 USD (buy) + Pin(number(12_000.0)), // 0.1 * 12_000 = 1200 USD (buy) + Pin(number(13_000.0)), // 0.1 * 13_000 = 1300 USD (buy) + Pin(number(14_000.0)), // 0.1 * 14_000 = 1400 USD (buy) + Pin(number(15_000.0)), // 0.1 * 15_000 = 1500 USD + }) + assert.NoError(t, err) + assert.Equal(t, number(6000.0), requiredQuote) + }) + + t.Run("quote to base balance conversion not enough", func(t *testing.T) { + _, requiredQuote, err := s.checkRequiredInvestmentByQuantity(number(0.0), number(5_000.0), number(0.1), number(13_500.0), []Pin{ + Pin(number(10_000.0)), // 0.1 * 10_000 = 1000 USD (buy) + Pin(number(11_000.0)), // 0.1 * 11_000 = 1100 USD (buy) + Pin(number(12_000.0)), // 0.1 * 12_000 = 1200 USD (buy) + Pin(number(13_000.0)), // 0.1 * 13_000 = 1300 USD (buy) + Pin(number(14_000.0)), // 0.1 * 14_000 = 1400 USD (buy) + Pin(number(15_000.0)), // 0.1 * 15_000 = 1500 USD + }) + assert.EqualError(t, err, "quote balance (5000.000000 USDT) is not enough, required = quote 6000.000000") + assert.Equal(t, number(6000.0), requiredQuote) + }) +} + +type PriceSideAssert struct { + Price fixedpoint.Value + Side types.SideType +} + +func assertPriceSide(t *testing.T, priceSideAsserts []PriceSideAssert, orders []types.SubmitOrder) { + for i, a := range priceSideAsserts { + assert.Equalf(t, a.Price, orders[i].Price, "order #%d price should be %f", i+1, a.Price.Float64()) + assert.Equalf(t, a.Side, orders[i].Side, "order at price %f should be %s", a.Price.Float64(), a.Side) + } +} + +func TestStrategy_generateGridOrders(t *testing.T) { + t.Run("quote only", func(t *testing.T) { + s := newTestStrategy() + s.grid = NewGrid(s.LowerPrice, s.UpperPrice, fixedpoint.NewFromInt(s.GridNum), s.Market.TickSize) + s.grid.CalculateArithmeticPins() + s.QuantityOrAmount.Quantity = number(0.01) + + lastPrice := number(15300) + quoteInvestment := number(10000.0) + baseInvestment := number(0) + orders, err := s.generateGridOrders(quoteInvestment, baseInvestment, lastPrice) + assert.NoError(t, err) + if !assert.Equal(t, 10, len(orders)) { + for _, o := range orders { + t.Logf("- %s %s", o.Price.String(), o.Side) + } + } + + assertPriceSide(t, []PriceSideAssert{ + {number(19000.0), types.SideTypeBuy}, + {number(18000.0), types.SideTypeBuy}, + {number(17000.0), types.SideTypeBuy}, + {number(16000.0), types.SideTypeBuy}, + {number(15000.0), types.SideTypeBuy}, + {number(14000.0), types.SideTypeBuy}, + {number(13000.0), types.SideTypeBuy}, + {number(12000.0), types.SideTypeBuy}, + {number(11000.0), types.SideTypeBuy}, + {number(10000.0), types.SideTypeBuy}, + }, orders) + }) + + t.Run("quote only + buy only", func(t *testing.T) { + s := newTestStrategy() + s.UpperPrice = number(0.9) + s.LowerPrice = number(0.1) + s.GridNum = 7 + s.grid = NewGrid(s.LowerPrice, s.UpperPrice, fixedpoint.NewFromInt(s.GridNum), s.Market.TickSize) + s.grid.CalculateArithmeticPins() + + assert.Equal(t, []Pin{ + Pin(number(0.1)), + Pin(number(0.23)), + Pin(number(0.36)), + Pin(number(0.50)), + Pin(number(0.63)), + Pin(number(0.76)), + Pin(number(0.9)), + }, s.grid.Pins, "pins are correct") + + lastPrice := number(22100) + quoteInvestment := number(100.0) + baseInvestment := number(0) + + quantity, err := s.calculateQuoteInvestmentQuantity(quoteInvestment, lastPrice, s.grid.Pins) + assert.NoError(t, err) + assert.InDelta(t, 38.7364341, quantity.Float64(), 0.00001) + + s.QuantityOrAmount.Quantity = quantity + + orders, err := s.generateGridOrders(quoteInvestment, baseInvestment, lastPrice) + assert.NoError(t, err) + if !assert.Equal(t, 6, len(orders)) { + for _, o := range orders { + t.Logf("- %s %s", o.Price.String(), o.Side) + } + } + + assertPriceSide(t, []PriceSideAssert{ + {number(0.76), types.SideTypeBuy}, + {number(0.63), types.SideTypeBuy}, + {number(0.5), types.SideTypeBuy}, + {number(0.36), types.SideTypeBuy}, + {number(0.23), types.SideTypeBuy}, + {number(0.1), types.SideTypeBuy}, + }, orders) + }) + + t.Run("base and quote", func(t *testing.T) { + s := newTestStrategy() + s.grid = NewGrid(s.LowerPrice, s.UpperPrice, fixedpoint.NewFromInt(s.GridNum), s.Market.TickSize) + s.grid.CalculateArithmeticPins() + + quoteInvestment := number(10_000.0) + baseInvestment := number(0.1) + lastPrice := number(15300) + + quantity, err := s.calculateBaseQuoteInvestmentQuantity(quoteInvestment, baseInvestment, lastPrice, s.grid.Pins) + assert.NoError(t, err) + assert.Equal(t, number(0.025), quantity) + + s.QuantityOrAmount.Quantity = quantity + + orders, err := s.generateGridOrders(quoteInvestment, baseInvestment, lastPrice) + assert.NoError(t, err) + if !assert.Equal(t, 10, len(orders)) { + for _, o := range orders { + t.Logf("- %s %s", o.Price.String(), o.Side) + } + } + + assertPriceSide(t, []PriceSideAssert{ + {number(20000.0), types.SideTypeSell}, + {number(19000.0), types.SideTypeSell}, + {number(18000.0), types.SideTypeSell}, + {number(17000.0), types.SideTypeSell}, + // -- 16_000 should be empty + {number(15000.0), types.SideTypeBuy}, + {number(14000.0), types.SideTypeBuy}, + {number(13000.0), types.SideTypeBuy}, + {number(12000.0), types.SideTypeBuy}, + {number(11000.0), types.SideTypeBuy}, + {number(10000.0), types.SideTypeBuy}, + }, orders) + }) + + t.Run("base and quote with predefined base grid num", func(t *testing.T) { + gridNum := int64(22) + upperPrice := number(35500.000000) + lowerPrice := number(34450.000000) + quoteInvestment := number(18.47) + baseInvestment := number(0.010700) + lastPrice := number(34522.930000) + baseGridNum := int(20) + + s := newTestStrategy() + s.GridNum = gridNum + s.BaseGridNum = baseGridNum + s.LowerPrice = lowerPrice + s.UpperPrice = upperPrice + s.grid = NewGrid(lowerPrice, upperPrice, fixedpoint.NewFromInt(s.GridNum), s.Market.TickSize) + s.grid.CalculateArithmeticPins() + assert.Equal(t, 22, len(s.grid.Pins)) + + quantity, err := s.calculateBaseQuoteInvestmentQuantity(quoteInvestment, baseInvestment, lastPrice, s.grid.Pins) + assert.NoError(t, err) + assert.Equal(t, "0.000535", quantity.String()) + + s.QuantityOrAmount.Quantity = quantity + + orders, err := s.generateGridOrders(quoteInvestment, baseInvestment, lastPrice) + assert.NoError(t, err) + if !assert.Equal(t, 21, len(orders)) { + for _, o := range orders { + t.Logf("- %s %s", o.Price.String(), o.Side) + } + } + + assertPriceSide(t, []PriceSideAssert{ + {number(35500.0), types.SideTypeSell}, + {number(35450.0), types.SideTypeSell}, + {number(35400.0), types.SideTypeSell}, + {number(35350.0), types.SideTypeSell}, + {number(35300.0), types.SideTypeSell}, + {number(35250.0), types.SideTypeSell}, + {number(35200.0), types.SideTypeSell}, + {number(35150.0), types.SideTypeSell}, + {number(35100.0), types.SideTypeSell}, + {number(35050.0), types.SideTypeSell}, + {number(35000.0), types.SideTypeSell}, + {number(34950.0), types.SideTypeSell}, + {number(34900.0), types.SideTypeSell}, + {number(34850.0), types.SideTypeSell}, + {number(34800.0), types.SideTypeSell}, + {number(34750.0), types.SideTypeSell}, + {number(34700.0), types.SideTypeSell}, + {number(34650.0), types.SideTypeSell}, + {number(34600.0), types.SideTypeSell}, + {number(34550.0), types.SideTypeSell}, + // -- fake trade price at 34549.9 + // -- 34500 should be empty + {number(34450.0), types.SideTypeBuy}, + }, orders) + }) + + t.Run("base and quote", func(t *testing.T) { + gridNum := int64(22) + upperPrice := number(35500.000000) + lowerPrice := number(34450.000000) + quoteInvestment := number(20.0) + baseInvestment := number(0.010700) + lastPrice := number(34522.930000) + baseGridNum := int(0) + + s := newTestStrategy() + s.GridNum = gridNum + s.BaseGridNum = baseGridNum + s.LowerPrice = lowerPrice + s.UpperPrice = upperPrice + s.grid = NewGrid(lowerPrice, upperPrice, fixedpoint.NewFromInt(s.GridNum), s.Market.TickSize) + s.grid.CalculateArithmeticPins() + assert.Equal(t, 22, len(s.grid.Pins)) + + quantity, err := s.calculateBaseQuoteInvestmentQuantity(quoteInvestment, baseInvestment, lastPrice, s.grid.Pins) + assert.NoError(t, err) + assert.Equal(t, "0.00029006", quantity.String()) + + s.QuantityOrAmount.Quantity = quantity + + orders, err := s.generateGridOrders(quoteInvestment, baseInvestment, lastPrice) + assert.NoError(t, err) + if !assert.Equal(t, 21, len(orders)) { + for _, o := range orders { + t.Logf("- %s %s", o.Price.String(), o.Side) + } + } + + assertPriceSide(t, []PriceSideAssert{ + {number(35500.0), types.SideTypeSell}, + {number(35450.0), types.SideTypeSell}, + {number(35400.0), types.SideTypeSell}, + {number(35350.0), types.SideTypeSell}, + {number(35300.0), types.SideTypeSell}, + {number(35250.0), types.SideTypeSell}, + {number(35200.0), types.SideTypeSell}, + {number(35150.0), types.SideTypeSell}, + {number(35100.0), types.SideTypeSell}, + {number(35050.0), types.SideTypeSell}, + {number(35000.0), types.SideTypeSell}, + {number(34950.0), types.SideTypeSell}, + {number(34900.0), types.SideTypeSell}, + {number(34850.0), types.SideTypeSell}, + {number(34800.0), types.SideTypeSell}, + {number(34750.0), types.SideTypeSell}, + {number(34700.0), types.SideTypeSell}, + {number(34650.0), types.SideTypeSell}, + {number(34600.0), types.SideTypeSell}, + {number(34550.0), types.SideTypeSell}, + // -- 34500 should be empty + {number(34450.0), types.SideTypeBuy}, + }, orders) + }) + + t.Run("base and quote with pre-calculated baseGridNumber", func(t *testing.T) { + s := newTestStrategy() + s.grid = NewGrid(s.LowerPrice, s.UpperPrice, fixedpoint.NewFromInt(s.GridNum), s.Market.TickSize) + s.grid.CalculateArithmeticPins() + + s.BaseGridNum = 4 + + quoteInvestment := number(10_000.0) + baseInvestment := number(0.1) + lastPrice := number(12300) // last price should not affect the sell order calculation + + quantity, err := s.calculateBaseQuoteInvestmentQuantity(quoteInvestment, baseInvestment, lastPrice, s.grid.Pins) + assert.NoError(t, err) + assert.Equal(t, number(0.025), quantity) + + s.QuantityOrAmount.Quantity = quantity + + orders, err := s.generateGridOrders(quoteInvestment, baseInvestment, lastPrice) + assert.NoError(t, err) + if !assert.Equal(t, 10, len(orders)) { + for _, o := range orders { + t.Logf("- %s %s", o.Price.String(), o.Side) + } + } + + assertPriceSide(t, []PriceSideAssert{ + {number(20000.0), types.SideTypeSell}, + {number(19000.0), types.SideTypeSell}, + {number(18000.0), types.SideTypeSell}, + {number(17000.0), types.SideTypeSell}, + // -- 16_000 should be empty + {number(15000.0), types.SideTypeBuy}, + {number(14000.0), types.SideTypeBuy}, + {number(13000.0), types.SideTypeBuy}, + {number(12000.0), types.SideTypeBuy}, + {number(11000.0), types.SideTypeBuy}, + {number(10000.0), types.SideTypeBuy}, + }, orders) + }) + + t.Run("base and quote with last price eq sell price", func(t *testing.T) { + s := newTestStrategy() + s.grid = NewGrid(s.LowerPrice, s.UpperPrice, fixedpoint.NewFromInt(s.GridNum), s.Market.TickSize) + s.grid.CalculateArithmeticPins() + s.BaseGridNum = 4 + + quoteInvestment := number(10_000.0) + baseInvestment := number(0.1) + lastPrice := number(17000) // last price should not affect the sell order calculation + + quantity, err := s.calculateBaseQuoteInvestmentQuantity(quoteInvestment, baseInvestment, lastPrice, s.grid.Pins) + assert.NoError(t, err) + assert.Equal(t, number(0.025), quantity) + + s.QuantityOrAmount.Quantity = quantity + + orders, err := s.generateGridOrders(quoteInvestment, baseInvestment, lastPrice) + assert.NoError(t, err) + if !assert.Equal(t, 10, len(orders)) { + for _, o := range orders { + t.Logf("- %s %s", o.Price.String(), o.Side) + } + } + + assertPriceSide(t, []PriceSideAssert{ + {number(20000.0), types.SideTypeSell}, + {number(19000.0), types.SideTypeSell}, + {number(18000.0), types.SideTypeSell}, + {number(17000.0), types.SideTypeSell}, + // -- 16_000 should be empty + {number(15000.0), types.SideTypeBuy}, + {number(14000.0), types.SideTypeBuy}, + {number(13000.0), types.SideTypeBuy}, + {number(12000.0), types.SideTypeBuy}, + {number(11000.0), types.SideTypeBuy}, + {number(10000.0), types.SideTypeBuy}, + }, orders) + }) + + t.Run("enough base + quote", func(t *testing.T) { + s := newTestStrategy() + s.grid = NewGrid(s.LowerPrice, s.UpperPrice, fixedpoint.NewFromInt(s.GridNum), s.Market.TickSize) + s.grid.CalculateArithmeticPins() + s.QuantityOrAmount.Quantity = number(0.01) + + lastPrice := number(15300) + orders, err := s.generateGridOrders(number(10000.0), number(1.0), lastPrice) + assert.NoError(t, err) + if !assert.Equal(t, 10, len(orders)) { + for _, o := range orders { + t.Logf("- %s %s", o.Price.String(), o.Side) + } + } + + assertPriceSide(t, []PriceSideAssert{ + {number(20000.0), types.SideTypeSell}, + {number(19000.0), types.SideTypeSell}, + {number(18000.0), types.SideTypeSell}, + {number(17000.0), types.SideTypeSell}, + {number(16000.0), types.SideTypeSell}, + {number(14000.0), types.SideTypeBuy}, + {number(13000.0), types.SideTypeBuy}, + {number(12000.0), types.SideTypeBuy}, + {number(11000.0), types.SideTypeBuy}, + {number(10000.0), types.SideTypeBuy}, + }, orders) + }) + + t.Run("enough base + quote + profitSpread", func(t *testing.T) { + s := newTestStrategy() + s.ProfitSpread = number(1_000) + s.grid = NewGrid(s.LowerPrice, s.UpperPrice, fixedpoint.NewFromInt(s.GridNum), s.Market.TickSize) + s.grid.CalculateArithmeticPins() + s.QuantityOrAmount.Quantity = number(0.01) + + lastPrice := number(15300) + orders, err := s.generateGridOrders(number(10000.0), number(1.0), lastPrice) + assert.NoError(t, err) + if !assert.Equal(t, 11, len(orders)) { + for _, o := range orders { + t.Logf("- %s %s", o.Price.String(), o.Side) + } + } + + assertPriceSide(t, []PriceSideAssert{ + {number(21000.0), types.SideTypeSell}, + {number(20000.0), types.SideTypeSell}, + {number(19000.0), types.SideTypeSell}, + {number(18000.0), types.SideTypeSell}, + {number(17000.0), types.SideTypeSell}, + + {number(15000.0), types.SideTypeBuy}, + {number(14000.0), types.SideTypeBuy}, + {number(13000.0), types.SideTypeBuy}, + {number(12000.0), types.SideTypeBuy}, + {number(11000.0), types.SideTypeBuy}, + {number(10000.0), types.SideTypeBuy}, + }, orders) + }) + +} + +func TestStrategy_checkRequiredInvestmentByAmount(t *testing.T) { + s := &Strategy{ + logger: logrus.NewEntry(logrus.New()), + Market: types.Market{ + BaseCurrency: "BTC", + QuoteCurrency: "USDT", + }, + } + + t.Run("quote to base balance conversion", func(t *testing.T) { + _, requiredQuote, err := s.checkRequiredInvestmentByAmount( + number(0.0), number(3_000.0), + number(1000.0), + number(13_500.0), []Pin{ + Pin(number(10_000.0)), + Pin(number(11_000.0)), + Pin(number(12_000.0)), + Pin(number(13_000.0)), + Pin(number(14_000.0)), + Pin(number(15_000.0)), + }) + assert.EqualError(t, err, "quote balance (3000.000000 USDT) is not enough, required = quote 4999.999890") + assert.InDelta(t, 4999.999890, requiredQuote.Float64(), number(0.001).Float64()) + }) +} + +func TestStrategy_calculateBaseQuoteInvestmentQuantity(t *testing.T) { + t.Run("1 sell", func(t *testing.T) { + s := newTestStrategy() + s.Market = newTestMarket("ETHUSDT") + s.UpperPrice = number(200.0) + s.LowerPrice = number(100.0) + s.GridNum = 7 + s.Compound = true + + lastPrice := number(180.0) + quoteInvestment := number(334.0) // 333.33 + baseInvestment := number(0.5) + quantity, err := s.calculateBaseQuoteInvestmentQuantity(quoteInvestment, baseInvestment, lastPrice, []Pin{ + Pin(number(100.00)), + Pin(number(116.67)), + Pin(number(133.33)), + Pin(number(150.00)), + Pin(number(166.67)), + Pin(number(183.33)), + Pin(number(200.00)), + }) + assert.NoError(t, err) + assert.InDelta(t, 0.5, quantity.Float64(), 0.0001) + }) + + t.Run("6 sell", func(t *testing.T) { + s := newTestStrategy() + s.Market = newTestMarket("ETHUSDT") + s.UpperPrice = number(200.0) + s.LowerPrice = number(100.0) + s.GridNum = 7 + s.Compound = true + + lastPrice := number(95.0) + quoteInvestment := number(334.0) // 333.33 + baseInvestment := number(0.5) + quantity, err := s.calculateBaseQuoteInvestmentQuantity(quoteInvestment, baseInvestment, lastPrice, []Pin{ + Pin(number(100.00)), + Pin(number(116.67)), + Pin(number(133.33)), + Pin(number(150.00)), + Pin(number(166.67)), + Pin(number(183.33)), + Pin(number(200.00)), + }) + assert.NoError(t, err) + assert.InDelta(t, 0.08333, quantity.Float64(), 0.0001) + }) + +} + +func TestStrategy_calculateQuoteInvestmentQuantity(t *testing.T) { + t.Run("quote quantity", func(t *testing.T) { + // quoteInvestment = (10,000 + 11,000 + 12,000 + 13,000 + 14,000) * q + // q = quoteInvestment / (10,000 + 11,000 + 12,000 + 13,000 + 14,000) + // q = 12_000 / (10,000 + 11,000 + 12,000 + 13,000 + 14,000) + // q = 0.2 + s := newTestStrategy() + lastPrice := number(13_500.0) + quoteInvestment := number(12_000.0) + quantity, err := s.calculateQuoteInvestmentQuantity(quoteInvestment, lastPrice, []Pin{ + Pin(number(10_000.0)), // buy + Pin(number(11_000.0)), // buy + Pin(number(12_000.0)), // buy + Pin(number(13_000.0)), // buy + Pin(number(14_000.0)), // buy + Pin(number(15_000.0)), + }) + assert.NoError(t, err) + assert.InDelta(t, 0.199999916, quantity.Float64(), 0.0001) + }) + + t.Run("quote quantity #2", func(t *testing.T) { + s := newTestStrategy() + lastPrice := number(160.0) + quoteInvestment := number(1_000.0) + quantity, err := s.calculateQuoteInvestmentQuantity(quoteInvestment, lastPrice, []Pin{ + Pin(number(100.0)), // buy + Pin(number(116.67)), // buy + Pin(number(133.33)), // buy + Pin(number(150.00)), // buy + Pin(number(166.67)), // buy + Pin(number(183.33)), + Pin(number(200.00)), + }) + assert.NoError(t, err) + assert.InDelta(t, 1.1764, quantity.Float64(), 0.00001) + }) + + t.Run("quote quantity #3", func(t *testing.T) { + s := newTestStrategy() + lastPrice := number(22000.0) + quoteInvestment := number(100.0) + pins := []Pin{ + Pin(number(0.1)), + Pin(number(0.23)), + Pin(number(0.36)), + Pin(number(0.50)), + Pin(number(0.63)), + Pin(number(0.76)), + Pin(number(0.90)), + } + quantity, err := s.calculateQuoteInvestmentQuantity(quoteInvestment, lastPrice, pins) + assert.NoError(t, err) + assert.InDelta(t, 38.736434, quantity.Float64(), 0.0001) + + var totalQuoteUsed = fixedpoint.Zero + for i, pin := range pins { + if i == len(pins)-1 { + continue + } + + price := fixedpoint.Value(pin) + totalQuoteUsed = totalQuoteUsed.Add(price.Mul(quantity)) + } + assert.LessOrEqualf(t, totalQuoteUsed, number(100.0), "total quote used: %f", totalQuoteUsed.Float64()) + }) + + t.Run("profit spread", func(t *testing.T) { + // quoteInvestment = (10,000 + 11,000 + 12,000 + 13,000 + 14,000 + 15,000) * q + // q = quoteInvestment / (10,000 + 11,000 + 12,000 + 13,000 + 14,000 + 15,000) + // q = 7500 / (10,000 + 11,000 + 12,000 + 13,000 + 14,000 + 15,000) + // q = 0.1 + s := newTestStrategy() + s.ProfitSpread = number(2000.0) + lastPrice := number(13_500.0) + quoteInvestment := number(7500.0) + quantity, err := s.calculateQuoteInvestmentQuantity(quoteInvestment, lastPrice, []Pin{ + Pin(number(10_000.0)), // sell order @ 12_000 + Pin(number(11_000.0)), // sell order @ 13_000 + Pin(number(12_000.0)), // sell order @ 14_000 + Pin(number(13_000.0)), // sell order @ 15_000 + Pin(number(14_000.0)), // sell order @ 16_000 + Pin(number(15_000.0)), // sell order @ 17_000 + }) + assert.NoError(t, err) + assert.InDelta(t, 0.099992, quantity.Float64(), 0.0001) + }) +} + +func newTestMarket(symbol string) types.Market { + switch symbol { + case "BTCUSDT": + return types.Market{ + BaseCurrency: "BTC", + QuoteCurrency: "USDT", + TickSize: number(0.01), + StepSize: number(0.000001), + PricePrecision: 2, + VolumePrecision: 8, + MinNotional: number(8.0), + MinQuantity: number(0.0003), + } + case "ETHUSDT": + return types.Market{ + BaseCurrency: "ETH", + QuoteCurrency: "USDT", + TickSize: number(0.01), + StepSize: number(0.00001), + PricePrecision: 2, + VolumePrecision: 6, + MinNotional: number(8.000), + MinQuantity: number(0.0046), + } + } + + // default + return types.Market{ + BaseCurrency: "BTC", + QuoteCurrency: "USDT", + TickSize: number(0.01), + StepSize: number(0.00001), + PricePrecision: 2, + VolumePrecision: 8, + MinNotional: number(10.0), + MinQuantity: number(0.001), + } +} + +var testOrderID = uint64(0) + +func newTestOrder(price, quantity fixedpoint.Value, side types.SideType) types.Order { + market := newTestMarket("BTCUSDT") + testOrderID++ + return types.Order{ + SubmitOrder: types.SubmitOrder{ + Symbol: "BTCUSDT", + Side: side, + Type: types.OrderTypeLimit, + Quantity: quantity, + Price: price, + AveragePrice: fixedpoint.Zero, + StopPrice: fixedpoint.Zero, + Market: market, + TimeInForce: types.TimeInForceGTC, + }, + Exchange: "binance", + GID: testOrderID, + OrderID: testOrderID, + Status: types.OrderStatusNew, + ExecutedQuantity: fixedpoint.Zero, + IsWorking: true, + } +} + +func newTestStrategy(va ...string) *Strategy { + symbol := "BTCUSDT" + + if len(va) > 0 { + symbol = va[0] + } + + market := newTestMarket(symbol) + s := &Strategy{ + logger: logrus.NewEntry(logrus.New()), + Symbol: symbol, + Market: market, + GridProfitStats: newGridProfitStats(market), + UpperPrice: number(20_000), + LowerPrice: number(10_000), + GridNum: 11, + historicalTrades: core.NewTradeStore(), + + filledOrderIDMap: types.NewSyncOrderMap(), + + // QuoteInvestment: number(9000.0), + } + return s +} + +func TestStrategy_calculateProfit(t *testing.T) { + t.Run("earn quote without compound", func(t *testing.T) { + s := newTestStrategy() + profit := s.calculateProfit(types.Order{ + SubmitOrder: types.SubmitOrder{ + Price: number(13_000), + Quantity: number(1.0), + }, + }, number(12_000), number(1.0)) + assert.NotNil(t, profit) + assert.Equal(t, "USDT", profit.Currency) + assert.InDelta(t, 1000.0, profit.Profit.Float64(), 0.1) + }) + + t.Run("earn quote with compound", func(t *testing.T) { + s := newTestStrategy() + s.Compound = true + + profit := s.calculateProfit(types.Order{ + SubmitOrder: types.SubmitOrder{ + Price: number(13_000), + Quantity: number(1.0), + }, + }, number(12_000), number(1.0)) + assert.NotNil(t, profit) + assert.Equal(t, "USDT", profit.Currency) + assert.InDelta(t, 1000.0, profit.Profit.Float64(), 0.1) + }) + + t.Run("earn base without compound", func(t *testing.T) { + s := newTestStrategy() + s.EarnBase = true + s.Compound = false + + quoteQuantity := number(12_000).Mul(number(1.0)) + sellQuantity := quoteQuantity.Div(number(13_000.0)) + + buyOrder := types.SubmitOrder{ + Price: number(12_000.0), + Quantity: number(1.0), + } + + profit := s.calculateProfit(types.Order{ + SubmitOrder: types.SubmitOrder{ + Price: number(13_000.0), + Quantity: sellQuantity, + }, + }, buyOrder.Price, buyOrder.Quantity) + assert.NotNil(t, profit) + assert.Equal(t, "BTC", profit.Currency) + assert.InDelta(t, sellQuantity.Float64()-buyOrder.Quantity.Float64(), profit.Profit.Float64(), 0.001) + }) +} + +func TestStrategy_aggregateOrderQuoteAmountAndFee(t *testing.T) { + s := newTestStrategy() + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockService := mocks.NewMockExchangeOrderQueryService(mockCtrl) + s.orderQueryService = mockService + + ctx := context.Background() + mockService.EXPECT().QueryOrderTrades(ctx, types.OrderQuery{ + Symbol: "BTCUSDT", + OrderID: "3", + }).Return([]types.Trade{ + { + ID: 1, + OrderID: 3, + Exchange: "binance", + Price: number(20000.0), + Quantity: number(0.2), + QuoteQuantity: number(4000), + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + IsBuyer: true, + FeeCurrency: "BTC", + Fee: number(0.2 * 0.01), + }, + { + ID: 1, + OrderID: 3, + Exchange: "binance", + Price: number(20000.0), + Quantity: number(0.8), + QuoteQuantity: number(16000), + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + IsBuyer: true, + FeeCurrency: "BTC", + Fee: number(0.8 * 0.01), + }, + }, nil) + + quoteAmount, fee, _ := s.aggregateOrderQuoteAmountAndFee(types.Order{ + SubmitOrder: types.SubmitOrder{ + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: number(1.0), + Price: number(20000.0), + AveragePrice: number(0), + StopPrice: number(0), + Market: types.Market{}, + TimeInForce: types.TimeInForceGTC, + }, + Exchange: "binance", + GID: 1, + OrderID: 3, + Status: types.OrderStatusFilled, + ExecutedQuantity: number(1.0), + IsWorking: false, + }) + assert.Equal(t, "0.01", fee.String()) + assert.Equal(t, "20000", quoteAmount.String()) +} + +func TestStrategy_findDuplicatedPriceOpenOrders(t *testing.T) { + t.Run("no duplicated open orders", func(t *testing.T) { + s := newTestStrategy() + s.grid = s.newGrid() + + dupOrders := s.findDuplicatedPriceOpenOrders([]types.Order{ + newTestOrder(number(1900.0), number(0.1), types.SideTypeSell), + newTestOrder(number(1800.0), number(0.1), types.SideTypeSell), + newTestOrder(number(1700.0), number(0.1), types.SideTypeSell), + }) + assert.Empty(t, dupOrders) + assert.Len(t, dupOrders, 0) + }) + + t.Run("1 duplicated open order SELL", func(t *testing.T) { + s := newTestStrategy() + s.grid = s.newGrid() + + dupOrders := s.findDuplicatedPriceOpenOrders([]types.Order{ + newTestOrder(number(1900.0), number(0.1), types.SideTypeSell), + newTestOrder(number(1900.0), number(0.1), types.SideTypeSell), + newTestOrder(number(1800.0), number(0.1), types.SideTypeSell), + }) + assert.Len(t, dupOrders, 1) + }) +} + +func TestStrategy_handleOrderFilled(t *testing.T) { + ctx := context.Background() + + t.Run("no fee token", func(t *testing.T) { + gridQuantity := number(0.1) + orderID := uint64(1) + + s := newTestStrategy() + s.Quantity = gridQuantity + s.grid = s.newGrid() + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockService := mocks.NewMockExchangeOrderQueryService(mockCtrl) + mockService.EXPECT().QueryOrderTrades(ctx, types.OrderQuery{ + Symbol: "BTCUSDT", + OrderID: "1", + }).Return([]types.Trade{ + { + ID: 1, + OrderID: orderID, + Exchange: "binance", + Price: number(11000.0), + Quantity: gridQuantity, + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + IsBuyer: true, + FeeCurrency: "BTC", + Fee: number(gridQuantity.Float64() * 0.1 * 0.01), + }, + }, nil) + + s.orderQueryService = mockService + + expectedSubmitOrder := types.SubmitOrder{ + Symbol: "BTCUSDT", + Type: types.OrderTypeLimit, + Price: number(12_000.0), + Quantity: number(0.0999), + Side: types.SideTypeSell, + TimeInForce: types.TimeInForceGTC, + Market: s.Market, + Tag: orderTag, + } + + orderExecutor := gridmocks.NewMockOrderExecutor(mockCtrl) + orderExecutor.EXPECT().SubmitOrders(ctx, gomock.Any()).DoAndReturn(func( + ctx context.Context, order types.SubmitOrder, + ) (types.OrderSlice, error) { + assert.True(t, equalOrdersIgnoreClientOrderID(expectedSubmitOrder, order), "%+v is not equal to %+v", order, expectedSubmitOrder) + return []types.Order{ + {SubmitOrder: expectedSubmitOrder}, + }, nil + }) + s.orderExecutor = orderExecutor + + s.handleOrderFilled(types.Order{ + SubmitOrder: types.SubmitOrder{ + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: gridQuantity, + Price: number(11000.0), + TimeInForce: types.TimeInForceGTC, + }, + Exchange: "binance", + OrderID: orderID, + Status: types.OrderStatusFilled, + ExecutedQuantity: gridQuantity, + }) + }) + + t.Run("with fee token", func(t *testing.T) { + gridQuantity := number(0.1) + orderID := uint64(1) + + s := newTestStrategy() + s.Quantity = gridQuantity + s.grid = s.newGrid() + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockService := mocks.NewMockExchangeOrderQueryService(mockCtrl) + mockService.EXPECT().QueryOrderTrades(ctx, types.OrderQuery{ + Symbol: "BTCUSDT", + OrderID: "1", + }).Return([]types.Trade{ + { + ID: 1, + OrderID: orderID, + Exchange: "binance", + Price: number(11000.0), + Quantity: gridQuantity, + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + IsBuyer: true, + FeeCurrency: "BTC", + Fee: fixedpoint.Zero, + }, + }, nil) + + s.orderQueryService = mockService + + expectedSubmitOrder := types.SubmitOrder{ + Symbol: "BTCUSDT", + Type: types.OrderTypeLimit, + Price: number(12_000.0), + Quantity: gridQuantity, + Side: types.SideTypeSell, + TimeInForce: types.TimeInForceGTC, + Market: s.Market, + Tag: orderTag, + } + + orderExecutor := gridmocks.NewMockOrderExecutor(mockCtrl) + orderExecutor.EXPECT().SubmitOrders(ctx, gomock.Any()).DoAndReturn(func( + ctx context.Context, order types.SubmitOrder, + ) (types.OrderSlice, error) { + assert.True(t, equalOrdersIgnoreClientOrderID(expectedSubmitOrder, order), "%+v is not equal to %+v", order, expectedSubmitOrder) + return []types.Order{ + {SubmitOrder: expectedSubmitOrder}, + }, nil + }) + s.orderExecutor = orderExecutor + + s.handleOrderFilled(types.Order{ + SubmitOrder: types.SubmitOrder{ + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: gridQuantity, + Price: number(11000.0), + TimeInForce: types.TimeInForceGTC, + }, + Exchange: "binance", + OrderID: orderID, + Status: types.OrderStatusFilled, + ExecutedQuantity: gridQuantity, + }) + }) + + t.Run("with fee token and EarnBase", func(t *testing.T) { + gridQuantity := number(0.1) + orderID := uint64(1) + + s := newTestStrategy() + s.Quantity = gridQuantity + s.EarnBase = true + s.grid = s.newGrid() + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockService := mocks.NewMockExchangeOrderQueryService(mockCtrl) + + mockService.EXPECT().QueryOrderTrades(ctx, types.OrderQuery{ + Symbol: "BTCUSDT", + OrderID: "1", + }).Return([]types.Trade{ + { + ID: 1, + OrderID: orderID, + Exchange: "binance", + Price: number(11000.0), + Quantity: number("0.1"), + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + IsBuyer: true, + FeeCurrency: "BTC", + Fee: fixedpoint.Zero, + }, + }, nil).Times(1) + + mockService.EXPECT().QueryOrderTrades(ctx, types.OrderQuery{ + Symbol: "BTCUSDT", + OrderID: "2", + }).Return([]types.Trade{ + { + ID: 2, + OrderID: orderID, + Exchange: "binance", + Price: number(12000.0), + Quantity: number(0.09166666666), + Symbol: "BTCUSDT", + Side: types.SideTypeSell, + IsBuyer: true, + FeeCurrency: "BTC", + Fee: fixedpoint.Zero, + }, + }, nil).Times(1) + + s.orderQueryService = mockService + + orderExecutor := gridmocks.NewMockOrderExecutor(mockCtrl) + + expectedSubmitOrder := types.SubmitOrder{ + Symbol: "BTCUSDT", + Type: types.OrderTypeLimit, + Side: types.SideTypeSell, + Price: number(12_000.0), + Quantity: number(0.09166666), + TimeInForce: types.TimeInForceGTC, + Market: s.Market, + Tag: orderTag, + } + orderExecutor.EXPECT().SubmitOrders(ctx, gomock.Any()).DoAndReturn(func( + ctx context.Context, order types.SubmitOrder, + ) (types.OrderSlice, error) { + assert.True(t, equalOrdersIgnoreClientOrderID(expectedSubmitOrder, order), "%+v is not equal to %+v", order, expectedSubmitOrder) + return []types.Order{ + {SubmitOrder: expectedSubmitOrder}, + }, nil + }) + + expectedSubmitOrder2 := types.SubmitOrder{ + Symbol: "BTCUSDT", + Type: types.OrderTypeLimit, + Side: types.SideTypeBuy, + Price: number(11_000.0), + Quantity: number(0.09999909), + TimeInForce: types.TimeInForceGTC, + Market: s.Market, + Tag: orderTag, + } + orderExecutor.EXPECT().SubmitOrders(ctx, gomock.Any()).DoAndReturn(func( + ctx context.Context, order types.SubmitOrder, + ) (types.OrderSlice, error) { + assert.True(t, equalOrdersIgnoreClientOrderID(expectedSubmitOrder2, order), "%+v is not equal to %+v", order, expectedSubmitOrder2) + return []types.Order{ + {SubmitOrder: expectedSubmitOrder2}, + }, nil + }) + + s.orderExecutor = orderExecutor + + s.handleOrderFilled(types.Order{ + SubmitOrder: types.SubmitOrder{ + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: gridQuantity, + Price: number(11000.0), + TimeInForce: types.TimeInForceGTC, + }, + Exchange: "binance", + OrderID: 1, + Status: types.OrderStatusFilled, + ExecutedQuantity: gridQuantity, + }) + + s.handleOrderFilled(types.Order{ + SubmitOrder: expectedSubmitOrder, + Exchange: "binance", + OrderID: 2, + Status: types.OrderStatusFilled, + ExecutedQuantity: expectedSubmitOrder.Quantity, + }) + }) + + t.Run("with fee token and compound", func(t *testing.T) { + gridQuantity := number(0.1) + orderID := uint64(1) + + s := newTestStrategy() + s.Quantity = gridQuantity + s.Compound = true + s.grid = s.newGrid() + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockService := mocks.NewMockExchangeOrderQueryService(mockCtrl) + + mockService.EXPECT().QueryOrderTrades(ctx, types.OrderQuery{ + Symbol: "BTCUSDT", + OrderID: "1", + }).Return([]types.Trade{ + { + ID: 1, + OrderID: orderID, + Exchange: "binance", + Price: number(11000.0), + Quantity: gridQuantity, + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + IsBuyer: true, + FeeCurrency: "BTC", + Fee: number("0.00001"), + }, + }, nil) + + mockService.EXPECT().QueryOrderTrades(ctx, types.OrderQuery{ + Symbol: "BTCUSDT", + OrderID: "2", + }).Return([]types.Trade{ + { + ID: 2, + OrderID: orderID, + Exchange: "binance", + Price: number(12000.0), + Quantity: gridQuantity, + Symbol: "BTCUSDT", + Side: types.SideTypeSell, + IsBuyer: true, + FeeCurrency: "USDT", + Fee: number("0.01"), + }, + }, nil) + + s.orderQueryService = mockService + + expectedSubmitOrder := types.SubmitOrder{ + Symbol: "BTCUSDT", + Type: types.OrderTypeLimit, + Price: number(12_000.0), + Quantity: number(0.09999), + Side: types.SideTypeSell, + TimeInForce: types.TimeInForceGTC, + Market: s.Market, + Tag: orderTag, + } + + orderExecutor := gridmocks.NewMockOrderExecutor(mockCtrl) + orderExecutor.EXPECT().SubmitOrders(ctx, gomock.Any()).DoAndReturn(func( + ctx context.Context, order types.SubmitOrder, + ) (types.OrderSlice, error) { + assert.True(t, equalOrdersIgnoreClientOrderID(expectedSubmitOrder, order), "%+v is not equal to %+v", order, expectedSubmitOrder) + return []types.Order{ + {SubmitOrder: expectedSubmitOrder}, + }, nil + }) + + expectedSubmitOrder2 := types.SubmitOrder{ + Symbol: "BTCUSDT", + Type: types.OrderTypeLimit, + Price: number(11_000.0), + Quantity: number(0.10909), + Side: types.SideTypeBuy, + TimeInForce: types.TimeInForceGTC, + Market: s.Market, + Tag: orderTag, + } + + orderExecutor.EXPECT().SubmitOrders(ctx, gomock.Any()).DoAndReturn(func( + ctx context.Context, order types.SubmitOrder, + ) (types.OrderSlice, error) { + assert.True(t, equalOrdersIgnoreClientOrderID(expectedSubmitOrder2, order), "%+v is not equal to %+v", order, expectedSubmitOrder2) + return []types.Order{ + {SubmitOrder: expectedSubmitOrder2}, + }, nil + }) + s.orderExecutor = orderExecutor + + s.handleOrderFilled(types.Order{ + SubmitOrder: types.SubmitOrder{ + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: gridQuantity, + Price: number(11000.0), + TimeInForce: types.TimeInForceGTC, + }, + Exchange: "binance", + OrderID: 1, + Status: types.OrderStatusFilled, + ExecutedQuantity: gridQuantity, + }) + + s.handleOrderFilled(types.Order{ + SubmitOrder: types.SubmitOrder{ + Symbol: "BTCUSDT", + Side: types.SideTypeSell, + Type: types.OrderTypeLimit, + Quantity: gridQuantity, + Price: number(12000.0), + TimeInForce: types.TimeInForceGTC, + }, + Exchange: "binance", + OrderID: 2, + Status: types.OrderStatusFilled, + ExecutedQuantity: gridQuantity, + }) + + }) +} + +func TestStrategy_aggregateOrderQuoteAmountAndFeeRetry(t *testing.T) { + s := newTestStrategy() + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockService := mocks.NewMockExchangeOrderQueryService(mockCtrl) + s.orderQueryService = mockService + + ctx := context.Background() + mockService.EXPECT().QueryOrderTrades(ctx, types.OrderQuery{ + Symbol: "BTCUSDT", + OrderID: "3", + }).Return(nil, errors.New("api error")) + + mockService.EXPECT().QueryOrderTrades(ctx, types.OrderQuery{ + Symbol: "BTCUSDT", + OrderID: "3", + }).Return([]types.Trade{ + { + ID: 1, + OrderID: 3, + Exchange: "binance", + Price: number(20000.0), + Quantity: number(0.2), + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + IsBuyer: true, + FeeCurrency: "BTC", + Fee: number(0.2 * 0.01), + }, + { + ID: 1, + OrderID: 3, + Exchange: "binance", + Price: number(20000.0), + Quantity: number(0.8), + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + IsBuyer: true, + FeeCurrency: "BTC", + Fee: number(0.8 * 0.01), + }, + }, nil) + + quoteAmount, fee, _ := s.aggregateOrderQuoteAmountAndFee(types.Order{ + SubmitOrder: types.SubmitOrder{ + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: number(1.0), + Price: number(20000.0), + AveragePrice: number(0), + StopPrice: number(0), + Market: types.Market{}, + TimeInForce: types.TimeInForceGTC, + }, + Exchange: "binance", + GID: 1, + OrderID: 3, + Status: types.OrderStatusFilled, + ExecutedQuantity: number(1.0), + IsWorking: false, + }) + assert.Equal(t, "0.01", fee.String()) + assert.Equal(t, "20000", quoteAmount.String()) +} + +func TestStrategy_checkMinimalQuoteInvestment(t *testing.T) { + + t.Run("7 grids", func(t *testing.T) { + s := newTestStrategy("ETHUSDT") + s.UpperPrice = number(1660) + s.LowerPrice = number(1630) + s.QuoteInvestment = number(61) + s.GridNum = 7 + grid := s.newGrid() + minQuoteInvestment := calculateMinimalQuoteInvestment(s.Market, grid) + assert.InDelta(t, 48.36, minQuoteInvestment.Float64(), 0.01) + + err := s.checkMinimalQuoteInvestment(grid) + assert.NoError(t, err) + }) + + t.Run("10 grids", func(t *testing.T) { + s := newTestStrategy() + // 10_000 * 0.001 = 10USDT + // 20_000 * 0.001 = 20USDT + s.QuoteInvestment = number(10_000) + s.GridNum = 10 + grid := s.newGrid() + minQuoteInvestment := calculateMinimalQuoteInvestment(s.Market, grid) + assert.InDelta(t, 103.999, minQuoteInvestment.Float64(), 0.01) + + err := s.checkMinimalQuoteInvestment(grid) + assert.NoError(t, err) + }) + + t.Run("1000 grids", func(t *testing.T) { + s := newTestStrategy() + s.QuoteInvestment = number(10_000) + s.GridNum = 1000 + + grid := s.newGrid() + minQuoteInvestment := calculateMinimalQuoteInvestment(s.Market, grid) + assert.InDelta(t, 11983.996400, minQuoteInvestment.Float64(), 0.001) + + err := s.checkMinimalQuoteInvestment(grid) + assert.Error(t, err) + assert.EqualError(t, err, "need at least 11983.996400 USDT for quote investment, 10000.000000 USDT given") + }) +} + +/* +func Test_buildPinOrderMap(t *testing.T) { + assert := assert.New(t) + s := newTestStrategy() + s.UpperPrice = number(2000.0) + s.LowerPrice = number(1000.0) + s.GridNum = 11 + s.grid = s.newGrid() + + t.Run("successful case", func(t *testing.T) { + openOrders := []types.Order{ + { + SubmitOrder: types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: number(1.0), + Price: number(1000.0), + AveragePrice: number(0), + StopPrice: number(0), + Market: s.Market, + TimeInForce: types.TimeInForceGTC, + }, + Exchange: "max", + GID: 1, + OrderID: 1, + Status: types.OrderStatusNew, + ExecutedQuantity: number(0.0), + IsWorking: false, + }, + } + m, err := s.buildPinOrderMap(s.grid.Pins, openOrders) + assert.NoError(err) + assert.Len(m, 11) + + for pin, order := range m { + if pin == openOrders[0].Price { + assert.Equal(openOrders[0].OrderID, order.OrderID) + } else { + assert.Equal(uint64(0), order.OrderID) + } + } + }) + + t.Run("there is one order with non-pin price in openOrders", func(t *testing.T) { + openOrders := []types.Order{ + { + SubmitOrder: types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: number(1.0), + Price: number(1111.0), + AveragePrice: number(0), + StopPrice: number(0), + Market: s.Market, + TimeInForce: types.TimeInForceGTC, + }, + Exchange: "max", + GID: 1, + OrderID: 1, + Status: types.OrderStatusNew, + ExecutedQuantity: number(0.0), + IsWorking: false, + }, + } + _, err := s.buildPinOrderMap(s.grid.Pins, openOrders) + assert.Error(err) + }) + + t.Run("there are duplicated open orders at same pin", func(t *testing.T) { + openOrders := []types.Order{ + { + SubmitOrder: types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: number(1.0), + Price: number(1000.0), + AveragePrice: number(0), + StopPrice: number(0), + Market: s.Market, + TimeInForce: types.TimeInForceGTC, + }, + Exchange: "max", + GID: 1, + OrderID: 1, + Status: types.OrderStatusNew, + ExecutedQuantity: number(0.0), + IsWorking: false, + }, + { + SubmitOrder: types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: number(1.0), + Price: number(1000.0), + AveragePrice: number(0), + StopPrice: number(0), + Market: s.Market, + TimeInForce: types.TimeInForceGTC, + }, + Exchange: "max", + GID: 2, + OrderID: 2, + Status: types.OrderStatusNew, + ExecutedQuantity: number(0.0), + IsWorking: false, + }, + } + _, err := s.buildPinOrderMap(s.grid.Pins, openOrders) + assert.Error(err) + }) +} + +func Test_getOrdersFromPinOrderMapInAscOrder(t *testing.T) { + assert := assert.New(t) + now := time.Now() + pinOrderMap := PinOrderMap{ + number("1000"): types.Order{ + OrderID: 1, + CreationTime: types.Time(now.Add(1 * time.Hour)), + UpdateTime: types.Time(now.Add(5 * time.Hour)), + }, + number("1100"): types.Order{}, + number("1200"): types.Order{}, + number("1300"): types.Order{ + OrderID: 3, + CreationTime: types.Time(now.Add(3 * time.Hour)), + UpdateTime: types.Time(now.Add(6 * time.Hour)), + }, + number("1400"): types.Order{ + OrderID: 2, + CreationTime: types.Time(now.Add(2 * time.Hour)), + UpdateTime: types.Time(now.Add(4 * time.Hour)), + }, + } + + orders := pinOrderMap.AscendingOrders() + assert.Len(orders, 3) + assert.Equal(uint64(2), orders[0].OrderID) + assert.Equal(uint64(1), orders[1].OrderID) + assert.Equal(uint64(3), orders[2].OrderID) +} + +func Test_verifyFilledGrid(t *testing.T) { + assert := assert.New(t) + s := newTestStrategy() + s.UpperPrice = number(400.0) + s.LowerPrice = number(100.0) + s.GridNum = 4 + s.grid = s.newGrid() + + t.Run("valid grid with buy/sell orders", func(t *testing.T) { + pinOrderMap := PinOrderMap{ + number("100.00"): types.Order{ + OrderID: 1, + SubmitOrder: types.SubmitOrder{ + Side: types.SideTypeBuy, + }, + }, + number("200.00"): types.Order{}, + number("300.00"): types.Order{ + OrderID: 3, + SubmitOrder: types.SubmitOrder{ + Side: types.SideTypeSell, + }, + }, + number("400.00"): types.Order{ + OrderID: 4, + SubmitOrder: types.SubmitOrder{ + Side: types.SideTypeSell, + }, + }, + } + + assert.NoError(s.verifyFilledGrid(s.grid.Pins, pinOrderMap, nil)) + }) + t.Run("valid grid with only buy orders", func(t *testing.T) { + pinOrderMap := PinOrderMap{ + number("100.00"): types.Order{ + OrderID: 1, + SubmitOrder: types.SubmitOrder{ + Side: types.SideTypeBuy, + }, + }, + number("200.00"): types.Order{ + OrderID: 2, + SubmitOrder: types.SubmitOrder{ + Side: types.SideTypeBuy, + }, + }, + number("300.00"): types.Order{ + OrderID: 3, + SubmitOrder: types.SubmitOrder{ + Side: types.SideTypeBuy, + }, + }, + number("400.00"): types.Order{}, + } + + assert.NoError(s.verifyFilledGrid(s.grid.Pins, pinOrderMap, nil)) + }) + t.Run("valid grid with only sell orders", func(t *testing.T) { + pinOrderMap := PinOrderMap{ + number("100.00"): types.Order{}, + number("200.00"): types.Order{ + OrderID: 2, + SubmitOrder: types.SubmitOrder{ + Side: types.SideTypeSell, + }, + }, + number("300.00"): types.Order{ + OrderID: 3, + SubmitOrder: types.SubmitOrder{ + Side: types.SideTypeSell, + }, + }, + number("400.00"): types.Order{ + OrderID: 4, + SubmitOrder: types.SubmitOrder{ + Side: types.SideTypeSell, + }, + }, + } + + assert.NoError(s.verifyFilledGrid(s.grid.Pins, pinOrderMap, nil)) + }) + t.Run("invalid grid with multiple empty pins", func(t *testing.T) { + pinOrderMap := PinOrderMap{ + number("100.00"): types.Order{ + OrderID: 1, + SubmitOrder: types.SubmitOrder{ + Side: types.SideTypeBuy, + }, + }, + number("200.00"): types.Order{}, + number("300.00"): types.Order{}, + number("400.00"): types.Order{ + OrderID: 4, + SubmitOrder: types.SubmitOrder{ + Side: types.SideTypeSell, + }, + }, + } + + assert.Error(s.verifyFilledGrid(s.grid.Pins, pinOrderMap, nil)) + }) + t.Run("invalid grid without empty pin", func(t *testing.T) { + pinOrderMap := PinOrderMap{ + number("100.00"): types.Order{ + OrderID: 1, + SubmitOrder: types.SubmitOrder{ + Side: types.SideTypeBuy, + }, + }, + number("200.00"): types.Order{ + OrderID: 2, + SubmitOrder: types.SubmitOrder{ + Side: types.SideTypeBuy, + }, + }, + number("300.00"): types.Order{ + OrderID: 3, + SubmitOrder: types.SubmitOrder{ + Side: types.SideTypeSell, + }, + }, + number("400.00"): types.Order{ + OrderID: 4, + SubmitOrder: types.SubmitOrder{ + Side: types.SideTypeSell, + }, + }, + } + + assert.Error(s.verifyFilledGrid(s.grid.Pins, pinOrderMap, nil)) + }) + t.Run("invalid grid with Buy-empty-Sell-Buy order", func(t *testing.T) { + pinOrderMap := PinOrderMap{ + number("100.00"): types.Order{ + OrderID: 1, + SubmitOrder: types.SubmitOrder{ + Side: types.SideTypeBuy, + }, + }, + number("200.00"): types.Order{}, + number("300.00"): types.Order{ + OrderID: 3, + SubmitOrder: types.SubmitOrder{ + Side: types.SideTypeSell, + }, + }, + number("400.00"): types.Order{ + OrderID: 4, + SubmitOrder: types.SubmitOrder{ + Side: types.SideTypeBuy, + }, + }, + } + + assert.Error(s.verifyFilledGrid(s.grid.Pins, pinOrderMap, nil)) + }) + t.Run("invalid grid with Sell-empty order", func(t *testing.T) { + pinOrderMap := PinOrderMap{ + number("100.00"): types.Order{ + OrderID: 1, + SubmitOrder: types.SubmitOrder{ + Side: types.SideTypeSell, + }, + }, + number("200.00"): types.Order{ + OrderID: 2, + SubmitOrder: types.SubmitOrder{ + Side: types.SideTypeSell, + }, + }, + number("300.00"): types.Order{ + OrderID: 3, + SubmitOrder: types.SubmitOrder{ + Side: types.SideTypeSell, + }, + }, + number("400.00"): types.Order{}, + } + + assert.Error(s.verifyFilledGrid(s.grid.Pins, pinOrderMap, nil)) + }) + t.Run("invalid grid with empty-Buy order", func(t *testing.T) { + pinOrderMap := PinOrderMap{ + number("100.00"): types.Order{}, + number("200.00"): types.Order{ + OrderID: 2, + SubmitOrder: types.SubmitOrder{ + Side: types.SideTypeBuy, + }, + }, + number("300.00"): types.Order{ + OrderID: 3, + SubmitOrder: types.SubmitOrder{ + Side: types.SideTypeBuy, + }, + }, + number("400.00"): types.Order{ + OrderID: 4, + SubmitOrder: types.SubmitOrder{ + Side: types.SideTypeBuy, + }, + }, + } + + assert.Error(s.verifyFilledGrid(s.grid.Pins, pinOrderMap, nil)) + }) + +} +*/ diff --git a/pkg/strategy/grid2/twin_order.go b/pkg/strategy/grid2/twin_order.go new file mode 100644 index 0000000..d109b6a --- /dev/null +++ b/pkg/strategy/grid2/twin_order.go @@ -0,0 +1,283 @@ +package grid2 + +import ( + "fmt" + "sort" + "strings" + "sync" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// For grid trading, there are twin orders between a grid +// e.g. 100, 200, 300, 400, 500 +// BUY 100 and SELL 200 are a twin. +// BUY 200 and SELL 300 are a twin. +// Because they can't be placed on orderbook at the same time + +type TwinOrder struct { + BuyOrder types.Order + SellOrder types.Order +} + +func (t *TwinOrder) IsValid() bool { + // XOR + return (t.BuyOrder.OrderID == 0) != (t.SellOrder.OrderID == 0) +} + +func (t *TwinOrder) Exist() bool { + return t.BuyOrder.OrderID != 0 || t.SellOrder.OrderID != 0 +} + +func (t *TwinOrder) GetOrder() types.Order { + if t.BuyOrder.OrderID != 0 { + return t.BuyOrder + } + + return t.SellOrder +} + +func (t *TwinOrder) SetOrder(order types.Order) { + if order.Side == types.SideTypeBuy { + t.BuyOrder = order + t.SellOrder = types.Order{} + } else { + t.SellOrder = order + t.BuyOrder = types.Order{} + } +} + +type TwinOrderMap map[fixedpoint.Value]TwinOrder + +func findTwinOrderMapKey(grid *Grid, order types.Order) (fixedpoint.Value, error) { + if order.Side == types.SideTypeSell { + return order.Price, nil + } + + if order.Side == types.SideTypeBuy { + pin, ok := grid.NextHigherPin(order.Price) + if !ok { + return fixedpoint.Zero, fmt.Errorf("there is no next higher price for this order (%d, price: %s)", order.OrderID, order.Price) + } + + return fixedpoint.Value(pin), nil + } + + return fixedpoint.Zero, fmt.Errorf("unsupported side: %s of this order (%d)", order.Side, order.OrderID) +} + +func (m TwinOrderMap) AscendingOrders() []types.Order { + var orders []types.Order + for _, twinOrder := range m { + // skip empty order + if !twinOrder.Exist() { + continue + } + + orders = append(orders, twinOrder.GetOrder()) + } + + types.SortOrdersUpdateTimeAscending(orders) + + return orders +} + +func (m TwinOrderMap) SyncOrderMap() *types.SyncOrderMap { + orderMap := types.NewSyncOrderMap() + for _, twin := range m { + orderMap.Add(twin.GetOrder()) + } + + return orderMap +} + +func (m TwinOrderMap) String() string { + var sb strings.Builder + var pins []fixedpoint.Value + for pin, _ := range m { + pins = append(pins, pin) + } + + sort.Slice(pins, func(i, j int) bool { + return pins[j].Compare(pins[i]) < 0 + }) + + sb.WriteString("================== TWIN ORDER MAP ==================\n") + for _, pin := range pins { + twin := m[pin] + twinOrder := twin.GetOrder() + sb.WriteString(fmt.Sprintf("-> %8s) %s\n", pin, twinOrder.String())) + } + sb.WriteString("================== END OF PIN ORDER MAP ==================\n") + return sb.String() +} + +// TwinOrderBook is to verify grid +// For grid trading, there are twin orders between a grid +// e.g. 100, 200, 300, 400, 500 +// +// BUY 100 and SELL 200 are a twin. +// BUY 200 and SELL 300 are a twin. +// +// Because they can't be placed on orderbook at the same time. +// We use sell price to be the twin orderbook's key +// New the twin orderbook with pins, and it will sort the pins in asc order. +// There must be a non nil TwinOrder on the every pin (except the first one). +// But the TwinOrder.Exist() may be false. It means there is no twin order on this grid +type TwinOrderBook struct { + // used to protect orderbook update + mu sync.Mutex + + // sort in asc order + pins []fixedpoint.Value + + // pin index, use to find the next or last pin in desc order + pinIdx map[fixedpoint.Value]int + + // orderbook + m map[fixedpoint.Value]*TwinOrder + + // size is the amount on twin orderbook + size int +} + +func newTwinOrderBook(pins []Pin) *TwinOrderBook { + var v []fixedpoint.Value + for _, pin := range pins { + v = append(v, fixedpoint.Value(pin)) + } + + // sort it in asc order + sort.Slice(v, func(i, j int) bool { + return v[j].Compare(v[i]) > 0 + }) + + pinIdx := make(map[fixedpoint.Value]int) + m := make(map[fixedpoint.Value]*TwinOrder) + for i, pin := range v { + // we use sell price for twin orderbook's price, so we skip the first pin as price + if i > 0 { + m[pin] = &TwinOrder{} + } + pinIdx[pin] = i + } + + return &TwinOrderBook{ + pins: v, + pinIdx: pinIdx, + m: m, + size: 0, + } +} + +func (b *TwinOrderBook) String() string { + var sb strings.Builder + + sb.WriteString("================== TWIN ORDERBOOK ==================\n") + for _, pin := range b.pins { + twin := b.m[fixedpoint.Value(pin)] + twinOrder := twin.GetOrder() + sb.WriteString(fmt.Sprintf("-> %8s) %s\n", pin, twinOrder.String())) + } + sb.WriteString("================== END OF TWINORDERBOOK ==================\n") + return sb.String() +} + +func (b *TwinOrderBook) GetTwinOrderPin(order types.Order) (fixedpoint.Value, error) { + idx, exist := b.pinIdx[order.Price] + if !exist { + return fixedpoint.Zero, fmt.Errorf("the order's (%d) price (%s) is not in pins", order.OrderID, order.Price) + } + + if order.Side == types.SideTypeBuy { + // we use sell price as twin orderbook's key, so if the order's side is buy. + // we need to find its next price on grid. + // e.g. + // BUY 100 <- twin -> SELL 200 + // BUY 200 <- twin -> SELL 300 + // BUY 300 <- twin -> SELL 400 + // BUY 400 <- twin -> SELL 500 + // if the order is BUY 100, we need to find its twin order's price to be the twin orderbook's key + // so we plus 1 here and use sorted pins to find the next price (200) + // there must no BUY 500 in the grid, so we need to make sure the idx should always not over the len(pins) + // also, there must no SELL 100 in the grid, so we need to make sure the idx should always not be 0 + idx++ + if idx >= len(b.pins) { + return fixedpoint.Zero, fmt.Errorf("this order's twin order price is not in pins, %+v", order) + } + } else if order.Side == types.SideTypeSell { + if idx == 0 { + return fixedpoint.Zero, fmt.Errorf("this order's twin order price is at zero index, %+v", order) + } + // do nothing + } else { + // should not happen + return fixedpoint.Zero, fmt.Errorf("the order's (%d) side (%s) is not supported", order.OrderID, order.Side) + } + + return b.pins[idx], nil +} + +func (b *TwinOrderBook) AddOrder(order types.Order) error { + b.mu.Lock() + defer b.mu.Unlock() + + pin, err := b.GetTwinOrderPin(order) + if err != nil { + return err + } + + // At all the pins, we already create the empty TwinOrder{} + // As a result,if the exist is false, it means the pin is not in the twin orderbook. + // That's invalid pin, or we have something wrong when new TwinOrderBook + twinOrder, exist := b.m[pin] + if !exist { + // should not happen + return fmt.Errorf("no any empty twin order at pins, should not happen, check it") + } + + // Exist == false means there is no twin order on this pin + if !twinOrder.Exist() { + b.size++ + } + if b.size >= len(b.pins) { + return fmt.Errorf("the maximum size of twin orderbook is len(pins) - 1, need to check it") + } + twinOrder.SetOrder(order) + + return nil +} + +func (b *TwinOrderBook) GetTwinOrder(pin fixedpoint.Value) *TwinOrder { + return b.m[pin] +} + +func (b *TwinOrderBook) AddTwinOrder(pin fixedpoint.Value, order *TwinOrder) { + b.mu.Lock() + defer b.mu.Unlock() + + b.m[pin] = order +} + +// Size is the valid twin order on grid. +func (b *TwinOrderBook) Size() int { + return b.size +} + +// EmptyTwinOrderSize is the amount of grid there is no twin order on it. +func (b *TwinOrderBook) EmptyTwinOrderSize() int { + // for grid, there is only pins - 1 order on the grid, so we need to minus 1. + return len(b.pins) - 1 - b.size +} + +func (b *TwinOrderBook) SyncOrderMap() *types.SyncOrderMap { + orderMap := types.NewSyncOrderMap() + for _, twin := range b.m { + if twin.Exist() { + orderMap.Add(twin.GetOrder()) + } + } + + return orderMap +} diff --git a/pkg/strategy/grid2/twin_order_test.go b/pkg/strategy/grid2/twin_order_test.go new file mode 100644 index 0000000..bfc4083 --- /dev/null +++ b/pkg/strategy/grid2/twin_order_test.go @@ -0,0 +1,73 @@ +package grid2 + +import ( + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +func TestTwinOrderBook(t *testing.T) { + assert := assert.New(t) + pins := []Pin{ + Pin(fixedpoint.NewFromInt(3)), + Pin(fixedpoint.NewFromInt(4)), + Pin(fixedpoint.NewFromInt(1)), + Pin(fixedpoint.NewFromInt(5)), + Pin(fixedpoint.NewFromInt(2)), + } + + book := newTwinOrderBook(pins) + assert.Equal(0, book.Size()) + assert.Equal(4, book.EmptyTwinOrderSize()) + for _, pin := range pins { + twinOrder := book.GetTwinOrder(fixedpoint.Value(pin)) + if fixedpoint.NewFromInt(1) == fixedpoint.Value(pin) { + assert.Nil(twinOrder) + continue + } + + if !assert.NotNil(twinOrder) { + continue + } + + assert.False(twinOrder.Exist()) + } + + orders := []types.Order{ + { + OrderID: 1, + SubmitOrder: types.SubmitOrder{ + Price: fixedpoint.NewFromInt(2), + Side: types.SideTypeBuy, + }, + }, + { + OrderID: 2, + SubmitOrder: types.SubmitOrder{ + Price: fixedpoint.NewFromInt(4), + Side: types.SideTypeSell, + }, + }, + } + + for _, order := range orders { + assert.NoError(book.AddOrder(order)) + } + assert.Equal(2, book.Size()) + assert.Equal(2, book.EmptyTwinOrderSize()) + + for _, order := range orders { + pin, err := book.GetTwinOrderPin(order) + if !assert.NoError(err) { + continue + } + twinOrder := book.GetTwinOrder(pin) + if !assert.True(twinOrder.Exist()) { + continue + } + + assert.Equal(order.OrderID, twinOrder.GetOrder().OrderID) + } +} diff --git a/pkg/strategy/harmonic/draw.go b/pkg/strategy/harmonic/draw.go new file mode 100644 index 0000000..0038d9f --- /dev/null +++ b/pkg/strategy/harmonic/draw.go @@ -0,0 +1,90 @@ +package harmonic + +import ( + "bytes" + "fmt" + "os" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/interact" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/wcharczuk/go-chart/v2" +) + +func (s *Strategy) InitDrawCommands(profit, cumProfit types.Series) { + qbtrade.RegisterCommand("/pnl", "Draw PNL(%) per trade", func(reply interact.Reply) { + canvas := DrawPNL(s.InstanceID(), profit) + var buffer bytes.Buffer + if err := canvas.Render(chart.PNG, &buffer); err != nil { + log.WithError(err).Errorf("cannot render pnl in drift") + reply.Message(fmt.Sprintf("[error] cannot render pnl in ewo: %v", err)) + return + } + qbtrade.SendPhoto(&buffer) + }) + qbtrade.RegisterCommand("/cumpnl", "Draw Cummulative PNL(Quote)", func(reply interact.Reply) { + canvas := DrawCumPNL(s.InstanceID(), cumProfit) + var buffer bytes.Buffer + if err := canvas.Render(chart.PNG, &buffer); err != nil { + log.WithError(err).Errorf("cannot render cumpnl in drift") + reply.Message(fmt.Sprintf("[error] canot render cumpnl in drift: %v", err)) + return + } + qbtrade.SendPhoto(&buffer) + }) +} + +func (s *Strategy) Draw(profit, cumProfit types.Series) error { + + canvas := DrawPNL(s.InstanceID(), profit) + fPnL, err := os.Create(s.GraphPNLPath) + if err != nil { + return fmt.Errorf("cannot create on path " + s.GraphPNLPath) + } + defer fPnL.Close() + if err = canvas.Render(chart.PNG, fPnL); err != nil { + return fmt.Errorf("cannot render pnl") + } + canvas = DrawCumPNL(s.InstanceID(), cumProfit) + fCumPnL, err := os.Create(s.GraphCumPNLPath) + if err != nil { + return fmt.Errorf("cannot create on path " + s.GraphCumPNLPath) + } + defer fCumPnL.Close() + if err = canvas.Render(chart.PNG, fCumPnL); err != nil { + return fmt.Errorf("cannot render cumpnl") + } + + return nil +} + +func DrawPNL(instanceID string, profit types.Series) *types.Canvas { + canvas := types.NewCanvas(instanceID) + length := profit.Length() + log.Infof("pnl Highest: %f, Lowest: %f", types.Highest(profit, length), types.Lowest(profit, length)) + canvas.PlotRaw("pnl %", profit, length) + canvas.YAxis = chart.YAxis{ + ValueFormatter: func(v interface{}) string { + if vf, isFloat := v.(float64); isFloat { + return fmt.Sprintf("%.4f", vf) + } + return "" + }, + } + canvas.PlotRaw("1", types.NumberSeries(1), length) + return canvas +} + +func DrawCumPNL(instanceID string, cumProfit types.Series) *types.Canvas { + canvas := types.NewCanvas(instanceID) + canvas.PlotRaw("cummulative pnl", cumProfit, cumProfit.Length()) + canvas.YAxis = chart.YAxis{ + ValueFormatter: func(v interface{}) string { + if vf, isFloat := v.(float64); isFloat { + return fmt.Sprintf("%.4f", vf) + } + return "" + }, + } + return canvas +} diff --git a/pkg/strategy/harmonic/shark.go b/pkg/strategy/harmonic/shark.go new file mode 100644 index 0000000..bc2b81c --- /dev/null +++ b/pkg/strategy/harmonic/shark.go @@ -0,0 +1,193 @@ +package harmonic + +import ( + "math" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +var zeroTime time.Time + +//go:generate callbackgen -type SHARK +type SHARK struct { + types.IntervalWindow + types.SeriesBase + + Lows floats.Slice + Highs floats.Slice + LongScores floats.Slice + ShortScores floats.Slice + + Values floats.Slice + + EndTime time.Time + + updateCallbacks []func(value float64) +} + +var _ types.SeriesExtend = &SHARK{} + +func (inc *SHARK) Update(high, low, price float64) { + if inc.SeriesBase.Series == nil { + inc.SeriesBase.Series = inc + } + inc.Highs.Update(high) + inc.Lows.Update(low) + + if inc.Highs.Length() < inc.Window || inc.Lows.Length() < inc.Window { + return + } + + longScore := inc.SharkLong(inc.Highs, inc.Lows, price, inc.Window) + shortScore := inc.SharkShort(inc.Highs, inc.Lows, price, inc.Window) + + inc.LongScores.Push(longScore) + inc.ShortScores.Push(shortScore) + + inc.Values.Push(longScore - shortScore) + +} + +func (inc *SHARK) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *SHARK) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *SHARK) Length() int { + return len(inc.Values) +} + +func (inc *SHARK) BindK(target indicator.KLineClosedEmitter, symbol string, interval types.Interval) { + target.OnKLineClosed(types.KLineWith(symbol, interval, inc.PushK)) +} + +func (inc *SHARK) PushK(k types.KLine) { + if inc.EndTime != zeroTime && k.EndTime.Before(inc.EndTime) { + return + } + + inc.Update(types.KLineHighPriceMapper(k), types.KLineLowPriceMapper(k), types.KLineClosePriceMapper(k)) + inc.EndTime = k.EndTime.Time() + inc.EmitUpdate(inc.Last(0)) +} + +func (inc *SHARK) LoadK(allKLines []types.KLine) { + for _, k := range allKLines { + inc.PushK(k) + } + inc.EmitUpdate(inc.Last(0)) +} + +func (inc SHARK) SharkLong(highs, lows floats.Slice, p float64, lookback int) float64 { + score := 0. + for x := 5; x < lookback; x++ { + if lows.Index(x-1) > lows.Index(x) && lows.Index(x) < lows.Index(x+1) { + X := lows.Index(x) + for a := 4; a < x; a++ { + if highs.Index(a-1) < highs.Index(a) && highs.Index(a) > highs.Index(a+1) { + A := highs.Index(a) + XA := math.Abs(X - A) + hB := A - 0.382*XA + lB := A - 0.618*XA + for b := 3; b < a; b++ { + if lows.Index(b-1) > lows.Index(b) && lows.Index(b) < lows.Index(b+1) { + B := lows.Index(b) + if hB > B && B > lB { + // log.Infof("got point B:%f", B) + AB := math.Abs(A - B) + hC := B + 1.618*AB + lC := B + 1.13*AB + for c := 2; c < b; c++ { + if highs.Index(c-1) < highs.Index(c) && highs.Index(c) > highs.Index(c+1) { + C := highs.Index(c) + if hC > C && C > lC { + // log.Infof("got point C:%f", C) + XC := math.Abs(X - C) + hD := C - 0.886*XC + lD := C - 1.13*XC + // for d := 1; d < c; d++ { + // if lows.Index(d-1) > lows.Index(d) && lows.Index(d) < lows.Index(d+1) { + D := p // lows.Index(d) + if hD > D && D > lD { + BC := math.Abs(B - C) + hD2 := C - 1.618*BC + lD2 := C - 2.24*BC + if hD2 > D && D > lD2 { + // log.Infof("got point D:%f", D) + score++ + } + } + // } + // } + } + } + } + } + } + } + } + } + } + } + return score +} + +func (inc SHARK) SharkShort(highs, lows floats.Slice, p float64, lookback int) float64 { + score := 0. + for x := 5; x < lookback; x++ { + if highs.Index(x-1) < highs.Index(x) && highs.Index(x) > highs.Index(x+1) { + X := highs.Index(x) + for a := 4; a < x; a++ { + if lows.Index(a-1) > lows.Index(a) && lows.Index(a) < lows.Index(a+1) { + A := lows.Index(a) + XA := math.Abs(X - A) + lB := A + 0.382*XA + hB := A + 0.618*XA + for b := 3; b < a; b++ { + if highs.Index(b-1) > highs.Index(b) && highs.Index(b) < highs.Index(b+1) { + B := highs.Index(b) + if hB > B && B > lB { + // log.Infof("got point B:%f", B) + AB := math.Abs(A - B) + lC := B - 1.618*AB + hC := B - 1.13*AB + for c := 2; c < b; c++ { + if lows.Index(c-1) < lows.Index(c) && lows.Index(c) > lows.Index(c+1) { + C := lows.Index(c) + if hC > C && C > lC { + // log.Infof("got point C:%f", C) + XC := math.Abs(X - C) + lD := C + 0.886*XC + hD := C + 1.13*XC + // for d := 1; d < c; d++ { + // if lows.Index(d-1) > lows.Index(d) && lows.Index(d) < lows.Index(d+1) { + D := p // lows.Index(d) + if hD > D && D > lD { + BC := math.Abs(B - C) + lD2 := C + 1.618*BC + hD2 := C + 2.24*BC + if hD2 > D && D > lD2 { + // log.Infof("got point D:%f", D) + score++ + } + } + // } + // } + } + } + } + } + } + } + } + } + } + } + return score +} diff --git a/pkg/strategy/harmonic/shark_callbacks.go b/pkg/strategy/harmonic/shark_callbacks.go new file mode 100644 index 0000000..7d26526 --- /dev/null +++ b/pkg/strategy/harmonic/shark_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type SHARK"; DO NOT EDIT. + +package harmonic + +import () + +func (inc *SHARK) OnUpdate(cb func(value float64)) { + inc.updateCallbacks = append(inc.updateCallbacks, cb) +} + +func (inc *SHARK) EmitUpdate(value float64) { + for _, cb := range inc.updateCallbacks { + cb(value) + } +} diff --git a/pkg/strategy/harmonic/strategy.go b/pkg/strategy/harmonic/strategy.go new file mode 100644 index 0000000..c68d366 --- /dev/null +++ b/pkg/strategy/harmonic/strategy.go @@ -0,0 +1,482 @@ +package harmonic + +import ( + "context" + "fmt" + "os" + "sync" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/data/tsv" + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/sirupsen/logrus" +) + +const ID = "harmonic" + +var log = logrus.WithField("strategy", ID) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + Environment *qbtrade.Environment + Symbol string `json:"symbol"` + Market types.Market + + types.IntervalWindow + //qbtrade.OpenPositionOptions + + // persistence fields + Position *types.Position `persistence:"position"` + ProfitStats *types.ProfitStats `persistence:"profit_stats"` + TradeStats *types.TradeStats `persistence:"trade_stats"` + + ExitMethods qbtrade.ExitMethodSet `json:"exits"` + + session *qbtrade.ExchangeSession + orderExecutor *qbtrade.GeneralOrderExecutor + + qbtrade.QuantityOrAmount + + // StrategyController + qbtrade.StrategyController + + shark *SHARK + + AccountValueCalculator *qbtrade.AccountValueCalculator + + // whether to draw graph or not by the end of backtest + DrawGraph bool `json:"drawGraph"` + GraphPNLPath string `json:"graphPNLPath"` + GraphCumPNLPath string `json:"graphCumPNLPath"` + + // for position + buyPrice float64 `persistence:"buy_price"` + sellPrice float64 `persistence:"sell_price"` + highestPrice float64 `persistence:"highest_price"` + lowestPrice float64 `persistence:"lowest_price"` + + // Accumulated profit report + AccumulatedProfitReport *AccumulatedProfitReport `json:"accumulatedProfitReport"` +} + +// AccumulatedProfitReport For accumulated profit report output +type AccumulatedProfitReport struct { + // AccumulatedProfitMAWindow Accumulated profit SMA window, in number of trades + AccumulatedProfitMAWindow int `json:"accumulatedProfitMAWindow"` + + // IntervalWindow interval window, in days + IntervalWindow int `json:"intervalWindow"` + + // NumberOfInterval How many intervals to output to TSV + NumberOfInterval int `json:"NumberOfInterval"` + + // TsvReportPath The path to output report to + TsvReportPath string `json:"tsvReportPath"` + + // AccumulatedDailyProfitWindow The window to sum up the daily profit, in days + AccumulatedDailyProfitWindow int `json:"accumulatedDailyProfitWindow"` + + // Accumulated profit + accumulatedProfit fixedpoint.Value + accumulatedProfitPerDay floats.Slice + previousAccumulatedProfit fixedpoint.Value + + // Accumulated profit MA + accumulatedProfitMA *indicator.SMA + accumulatedProfitMAPerDay floats.Slice + + // Daily profit + dailyProfit floats.Slice + + // Accumulated fee + accumulatedFee fixedpoint.Value + accumulatedFeePerDay floats.Slice + + // Win ratio + winRatioPerDay floats.Slice + + // Profit factor + profitFactorPerDay floats.Slice + + // Trade number + dailyTrades floats.Slice + accumulatedTrades int + previousAccumulatedTrades int +} + +func (r *AccumulatedProfitReport) Initialize() { + if r.AccumulatedProfitMAWindow <= 0 { + r.AccumulatedProfitMAWindow = 60 + } + if r.IntervalWindow <= 0 { + r.IntervalWindow = 7 + } + if r.AccumulatedDailyProfitWindow <= 0 { + r.AccumulatedDailyProfitWindow = 7 + } + if r.NumberOfInterval <= 0 { + r.NumberOfInterval = 1 + } + r.accumulatedProfitMA = &indicator.SMA{IntervalWindow: types.IntervalWindow{Interval: types.Interval1d, Window: r.AccumulatedProfitMAWindow}} +} + +func (r *AccumulatedProfitReport) RecordProfit(profit fixedpoint.Value) { + r.accumulatedProfit = r.accumulatedProfit.Add(profit) +} + +func (r *AccumulatedProfitReport) RecordTrade(fee fixedpoint.Value) { + r.accumulatedFee = r.accumulatedFee.Add(fee) + r.accumulatedTrades += 1 +} + +func (r *AccumulatedProfitReport) DailyUpdate(tradeStats *types.TradeStats) { + // Daily profit + r.dailyProfit.Update(r.accumulatedProfit.Sub(r.previousAccumulatedProfit).Float64()) + r.previousAccumulatedProfit = r.accumulatedProfit + + // Accumulated profit + r.accumulatedProfitPerDay.Update(r.accumulatedProfit.Float64()) + + // Accumulated profit MA + r.accumulatedProfitMA.Update(r.accumulatedProfit.Float64()) + r.accumulatedProfitMAPerDay.Update(r.accumulatedProfitMA.Last(0)) + + // Accumulated Fee + r.accumulatedFeePerDay.Update(r.accumulatedFee.Float64()) + + // Win ratio + r.winRatioPerDay.Update(tradeStats.WinningRatio.Float64()) + + // Profit factor + r.profitFactorPerDay.Update(tradeStats.ProfitFactor.Float64()) + + // Daily trades + r.dailyTrades.Update(float64(r.accumulatedTrades - r.previousAccumulatedTrades)) + r.previousAccumulatedTrades = r.accumulatedTrades +} + +// Output Accumulated profit report to a TSV file +func (r *AccumulatedProfitReport) Output(symbol string) { + if r.TsvReportPath != "" { + tsvwiter, err := tsv.AppendWriterFile(r.TsvReportPath) + if err != nil { + panic(err) + } + defer tsvwiter.Close() + // Output symbol, total acc. profit, acc. profit 60MA, interval acc. profit, fee, win rate, profit factor + _ = tsvwiter.Write([]string{"#", "Symbol", "accumulatedProfit", "accumulatedProfitMA", fmt.Sprintf("%dd profit", r.AccumulatedDailyProfitWindow), "accumulatedFee", "winRatio", "profitFactor", "60D trades"}) + for i := 0; i <= r.NumberOfInterval-1; i++ { + accumulatedProfit := r.accumulatedProfitPerDay.Index(r.IntervalWindow * i) + accumulatedProfitStr := fmt.Sprintf("%f", accumulatedProfit) + accumulatedProfitMA := r.accumulatedProfitMAPerDay.Index(r.IntervalWindow * i) + accumulatedProfitMAStr := fmt.Sprintf("%f", accumulatedProfitMA) + intervalAccumulatedProfit := r.dailyProfit.Tail(r.AccumulatedDailyProfitWindow+r.IntervalWindow*i).Sum() - r.dailyProfit.Tail(r.IntervalWindow*i).Sum() + intervalAccumulatedProfitStr := fmt.Sprintf("%f", intervalAccumulatedProfit) + accumulatedFee := fmt.Sprintf("%f", r.accumulatedFeePerDay.Index(r.IntervalWindow*i)) + winRatio := fmt.Sprintf("%f", r.winRatioPerDay.Index(r.IntervalWindow*i)) + profitFactor := fmt.Sprintf("%f", r.profitFactorPerDay.Index(r.IntervalWindow*i)) + trades := r.dailyTrades.Tail(60+r.IntervalWindow*i).Sum() - r.dailyTrades.Tail(r.IntervalWindow*i).Sum() + tradesStr := fmt.Sprintf("%f", trades) + + _ = tsvwiter.Write([]string{fmt.Sprintf("%d", i+1), symbol, accumulatedProfitStr, accumulatedProfitMAStr, intervalAccumulatedProfitStr, accumulatedFee, winRatio, profitFactor, tradesStr}) + } + } +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) + + if !qbtrade.IsBackTesting { + session.Subscribe(types.MarketTradeChannel, s.Symbol, types.SubscribeOptions{}) + } + + s.ExitMethods.SetAndSubscribe(session, s) +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s", ID, s.Symbol) +} + +func (s *Strategy) CalcAssetValue(price fixedpoint.Value) fixedpoint.Value { + balances := s.session.GetAccount().Balances() + return balances[s.Market.BaseCurrency].Total().Mul(price).Add(balances[s.Market.QuoteCurrency].Total()) +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + var instanceID = s.InstanceID() + + 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) + } + + // StrategyController + s.Status = types.StrategyStatusRunning + + s.OnSuspend(func() { + // Cancel active orders + _ = s.orderExecutor.GracefulCancel(ctx) + }) + + s.OnEmergencyStop(func() { + // Cancel active orders + _ = s.orderExecutor.GracefulCancel(ctx) + // Close 100% position + //_ = s.ClosePosition(ctx, fixedpoint.One) + }) + + s.session = session + + // Set fee rate + if s.session.MakerFeeRate.Sign() > 0 || s.session.TakerFeeRate.Sign() > 0 { + s.Position.SetExchangeFeeRate(s.session.ExchangeName, types.ExchangeFee{ + MakerFeeRate: s.session.MakerFeeRate, + TakerFeeRate: s.session.TakerFeeRate, + }) + } + + s.orderExecutor = qbtrade.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, s.Position) + s.orderExecutor.BindEnvironment(s.Environment) + s.orderExecutor.BindProfitStats(s.ProfitStats) + s.orderExecutor.BindTradeStats(s.TradeStats) + + // AccountValueCalculator + s.AccountValueCalculator = qbtrade.NewAccountValueCalculator(s.session, s.Market.QuoteCurrency) + + // Accumulated profit report + if qbtrade.IsBackTesting { + if s.AccumulatedProfitReport == nil { + s.AccumulatedProfitReport = &AccumulatedProfitReport{} + } + s.AccumulatedProfitReport.Initialize() + s.orderExecutor.TradeCollector().OnProfit(func(trade types.Trade, profit *types.Profit) { + if profit == nil { + return + } + + s.AccumulatedProfitReport.RecordProfit(profit.Profit) + }) + // s.orderExecutor.TradeCollector().OnTrade(func(trade types.Trade, profit fixedpoint.Value, netProfit fixedpoint.Value) { + // s.AccumulatedProfitReport.RecordTrade(trade.Fee) + // }) + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, types.Interval1d, func(kline types.KLine) { + s.AccumulatedProfitReport.DailyUpdate(s.TradeStats) + })) + } + + // For drawing + profitSlice := floats.Slice{1., 1.} + price, _ := session.LastPrice(s.Symbol) + initAsset := s.CalcAssetValue(price).Float64() + cumProfitSlice := floats.Slice{initAsset, initAsset} + + s.orderExecutor.TradeCollector().OnTrade(func(trade types.Trade, profit fixedpoint.Value, netProfit fixedpoint.Value) { + if qbtrade.IsBackTesting { + s.AccumulatedProfitReport.RecordTrade(trade.Fee) + } + + // For drawing/charting + price := trade.Price.Float64() + if s.buyPrice > 0 { + profitSlice.Update(price / s.buyPrice) + cumProfitSlice.Update(s.CalcAssetValue(trade.Price).Float64()) + } else if s.sellPrice > 0 { + profitSlice.Update(s.sellPrice / price) + cumProfitSlice.Update(s.CalcAssetValue(trade.Price).Float64()) + } + if s.Position.IsDust(trade.Price) { + s.buyPrice = 0 + s.sellPrice = 0 + s.highestPrice = 0 + s.lowestPrice = 0 + } else if s.Position.IsLong() { + s.buyPrice = price + s.sellPrice = 0 + s.highestPrice = s.buyPrice + s.lowestPrice = 0 + } else { + s.sellPrice = price + s.buyPrice = 0 + s.highestPrice = 0 + s.lowestPrice = s.sellPrice + } + }) + + s.InitDrawCommands(&profitSlice, &cumProfitSlice) + s.orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { + qbtrade.Sync(ctx, s) + }) + s.orderExecutor.Bind() + + for _, method := range s.ExitMethods { + method.Bind(session, s.orderExecutor) + } + + kLineStore, _ := s.session.MarketDataStore(s.Symbol) + s.shark = &SHARK{IntervalWindow: types.IntervalWindow{Window: s.Window, Interval: s.Interval}} + s.shark.BindK(s.session.MarketDataStream, s.Symbol, s.shark.Interval) + if klines, ok := kLineStore.KLinesOfInterval(s.shark.Interval); ok { + s.shark.LoadK((*klines)[0:]) + } + + states := types.NewQueue(s.Window) + + states.Update(0) + s.session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.Interval, func(kline types.KLine) { + + log.Infof("shark score: %f, current price: %f", s.shark.Last(0), kline.Close.Float64()) + + nextState := hmm(s.shark.Array(s.Window), states.Array(s.Window), s.Window) + states.Update(nextState) + log.Infof("Denoised signal via HMM: %f", states.Last(0)) + + if states.Length() < s.Window { + return + } + direction := 0. + if s.Position.IsLong() { + direction = 1. + } else if s.Position.IsShort() { + direction = -1. + } + + if s.Position.IsOpened(kline.Close) && states.Mean(5) == 0 { + s.orderExecutor.ClosePosition(ctx, fixedpoint.One) + } + if states.Mean(5) == 1 && direction != 1 { + _, _ = s.orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeBuy, + Quantity: s.Quantity, + Type: types.OrderTypeMarket, + Tag: "sharkLong", + }) + } else if states.Mean(5) == -1 && direction != -1 { + _, _ = s.orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeSell, + Quantity: s.Quantity, + Type: types.OrderTypeMarket, + Tag: "sharkShort", + }) + } + })) + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + // Output accumulated profit report + if qbtrade.IsBackTesting { + defer s.AccumulatedProfitReport.Output(s.Symbol) + + if s.DrawGraph { + if err := s.Draw(&profitSlice, &cumProfitSlice); err != nil { + log.WithError(err).Errorf("cannot draw graph") + } + } + } + + _, _ = fmt.Fprintln(os.Stderr, s.TradeStats.String()) + _ = s.orderExecutor.GracefulCancel(ctx) + }) + + return nil +} + +// TODO: dirichlet distribution is a too naive solution +func observeDistribution(y_t, x_t float64) float64 { + if x_t == 0. && y_t == 0 { + // observed zero value from indicator when in neutral state + return 1. + } else if x_t > 0. && y_t > 0. { + // observed positive value from indicator when in long state + return 1. + } else if x_t < 0. && y_t < 0. { + // observed negative value from indicator when in short state + return 1. + } else { + return 0. + } +} + +func transitProbability(x_t0, x_t1 int) float64 { + // stick to the same sate + if x_t0 == x_t1 { + return 0.99 + } + // transit to next new state + return 1 - 0.99 +} + +// HMM main function, ref: https://tr8dr.github.io/HMMFiltering/ +/* +# initialize time step 0 using state priors and observation dist p(y | x = s) +for si in states: + alpha[t = 0, state = si] = pi[si] * p(y[0] | x = si) + +# determine alpha for t = 1 .. n +for t in 1 .. n: + for sj in states: + alpha[t,sj] = max([alpha[t-1,si] * M[si,sj] for si in states]) * p(y[t] | x = sj) + +# determine current state at time t +return argmax(alpha[t,si] over si) +*/ +func hmm(y_t []float64, x_t []float64, l int) float64 { + al := make([]float64, l) + an := make([]float64, l) + as := make([]float64, l) + long := 0. + neut := 0. + short := 0. + // n is the incremental time steps + for n := 2; n <= len(x_t); n++ { + for j := -1; j <= 1; j++ { + sil := make([]float64, 3) + sin := make([]float64, 3) + sis := make([]float64, 3) + for i := -1; i <= 1; i++ { + sil = append(sil, x_t[n-1-1]*transitProbability(i, j)) + sin = append(sin, x_t[n-1-1]*transitProbability(i, j)) + sis = append(sis, x_t[n-1-1]*transitProbability(i, j)) + } + if j > 0 { + _, longArr := floats.MinMax(sil, 3) + long = longArr[0] * observeDistribution(y_t[n-1], float64(j)) + al = append(al, long) + } else if j == 0 { + _, neutArr := floats.MinMax(sin, 3) + neut = neutArr[0] * observeDistribution(y_t[n-1], float64(j)) + an = append(an, neut) + } else if j < 0 { + _, shortArr := floats.MinMax(sis, 3) + short = shortArr[0] * observeDistribution(y_t[n-1], float64(j)) + as = append(as, short) + } + } + } + _, maximum := floats.MinMax([]float64{long, neut, short}, 3) + if maximum[0] == long { + return 1 + } else if maximum[0] == short { + return -1 + } + return 0 +} diff --git a/pkg/strategy/irr/draw.go b/pkg/strategy/irr/draw.go new file mode 100644 index 0000000..0339a39 --- /dev/null +++ b/pkg/strategy/irr/draw.go @@ -0,0 +1,103 @@ +package irr + +import ( + "bytes" + "fmt" + "os" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/interact" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/wcharczuk/go-chart/v2" +) + +func (s *Strategy) InitDrawCommands(profit, cumProfit, cumProfitDollar types.Series) { + qbtrade.RegisterCommand("/rt", "Draw Return Rate(%) Per Trade", func(reply interact.Reply) { + + canvas := DrawPNL(s.InstanceID(), profit) + var buffer bytes.Buffer + if err := canvas.Render(chart.PNG, &buffer); err != nil { + log.WithError(err).Errorf("cannot render return in irr") + reply.Message(fmt.Sprintf("[error] cannot render return in irr: %v", err)) + return + } + qbtrade.SendPhoto(&buffer) + }) + qbtrade.RegisterCommand("/nav", "Draw Net Assets Value", func(reply interact.Reply) { + + canvas := DrawCumPNL(s.InstanceID(), cumProfit) + var buffer bytes.Buffer + if err := canvas.Render(chart.PNG, &buffer); err != nil { + log.WithError(err).Errorf("cannot render nav in irr") + reply.Message(fmt.Sprintf("[error] canot render nav in irr: %v", err)) + return + } + qbtrade.SendPhoto(&buffer) + }) + qbtrade.RegisterCommand("/pnl", "Draw Cumulative Profit & Loss", func(reply interact.Reply) { + + canvas := DrawCumPNL(s.InstanceID(), cumProfitDollar) + var buffer bytes.Buffer + if err := canvas.Render(chart.PNG, &buffer); err != nil { + log.WithError(err).Errorf("cannot render pnl in irr") + reply.Message(fmt.Sprintf("[error] canot render pnl in irr: %v", err)) + return + } + qbtrade.SendPhoto(&buffer) + }) +} + +func (s *Strategy) Draw(profit, cumProfit types.Series) error { + + canvas := DrawPNL(s.InstanceID(), profit) + fPnL, err := os.Create(s.GraphPNLPath) + if err != nil { + return fmt.Errorf("cannot create on path " + s.GraphPNLPath) + } + defer fPnL.Close() + if err = canvas.Render(chart.PNG, fPnL); err != nil { + return fmt.Errorf("cannot render pnl") + } + canvas = DrawCumPNL(s.InstanceID(), cumProfit) + fCumPnL, err := os.Create(s.GraphCumPNLPath) + if err != nil { + return fmt.Errorf("cannot create on path " + s.GraphCumPNLPath) + } + defer fCumPnL.Close() + if err = canvas.Render(chart.PNG, fCumPnL); err != nil { + return fmt.Errorf("cannot render cumpnl") + } + + return nil +} + +func DrawPNL(instanceID string, profit types.Series) *types.Canvas { + canvas := types.NewCanvas(instanceID) + length := profit.Length() + log.Infof("pnl Highest: %f, Lowest: %f", types.Highest(profit, length), types.Lowest(profit, length)) + canvas.PlotRaw("pnl %", profit, length) + canvas.YAxis = chart.YAxis{ + ValueFormatter: func(v interface{}) string { + if vf, isFloat := v.(float64); isFloat { + return fmt.Sprintf("%.4f", vf) + } + return "" + }, + } + canvas.PlotRaw("1", types.NumberSeries(1), length) + return canvas +} + +func DrawCumPNL(instanceID string, cumProfit types.Series) *types.Canvas { + canvas := types.NewCanvas(instanceID) + canvas.PlotRaw("cumulative pnl", cumProfit, cumProfit.Length()) + canvas.YAxis = chart.YAxis{ + ValueFormatter: func(v interface{}) string { + if vf, isFloat := v.(float64); isFloat { + return fmt.Sprintf("%.4f", vf) + } + return "" + }, + } + return canvas +} diff --git a/pkg/strategy/irr/neg_return_rate.go b/pkg/strategy/irr/neg_return_rate.go new file mode 100644 index 0000000..5dc5d74 --- /dev/null +++ b/pkg/strategy/irr/neg_return_rate.go @@ -0,0 +1,87 @@ +package irr + +import ( + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +var zeroTime time.Time + +// simple negative internal return rate over certain timeframe(interval) + +//go:generate callbackgen -type NRR +type NRR struct { + types.IntervalWindow + types.SeriesBase + + RankingWindow int + delay bool + prices *types.Queue + + Values floats.Slice + RankedValues floats.Slice + ReturnValues floats.Slice + + EndTime time.Time + + updateCallbacks []func(value float64) +} + +var _ types.SeriesExtend = &NRR{} + +func (inc *NRR) Update(openPrice, closePrice float64) { + if inc.SeriesBase.Series == nil { + inc.SeriesBase.Series = inc + inc.prices = types.NewQueue(inc.Window) + } + inc.prices.Update(closePrice) + + // D0 + nirr := (openPrice - closePrice) / openPrice + irr := (closePrice - openPrice) / openPrice + if inc.prices.Length() >= inc.Window && inc.delay { + // D1 + nirr = -1 * ((inc.prices.Last(0) / inc.prices.Index(inc.Window-1)) - 1) + irr = (inc.prices.Last(0) / inc.prices.Index(inc.Window-1)) - 1 + } + + inc.Values.Push(nirr) // neg ret here + inc.RankedValues.Push(inc.Rank(inc.RankingWindow).Last(0) / float64(inc.RankingWindow)) // ranked neg ret here + inc.ReturnValues.Push(irr) +} + +func (inc *NRR) Last(i int) float64 { + return inc.Values.Last(i) +} + +func (inc *NRR) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *NRR) Length() int { + return len(inc.Values) +} + +func (inc *NRR) BindK(target indicator.KLineClosedEmitter, symbol string, interval types.Interval) { + target.OnKLineClosed(types.KLineWith(symbol, interval, inc.PushK)) +} + +func (inc *NRR) PushK(k types.KLine) { + if inc.EndTime != zeroTime && k.EndTime.Before(inc.EndTime) { + return + } + + inc.Update(types.KLineOpenPriceMapper(k), types.KLineClosePriceMapper(k)) + inc.EndTime = k.EndTime.Time() + inc.EmitUpdate(inc.Last(0)) +} + +func (inc *NRR) LoadK(allKLines []types.KLine) { + for _, k := range allKLines { + inc.PushK(k) + } + inc.EmitUpdate(inc.Last(0)) +} diff --git a/pkg/strategy/irr/nrr_callbacks.go b/pkg/strategy/irr/nrr_callbacks.go new file mode 100644 index 0000000..40552ef --- /dev/null +++ b/pkg/strategy/irr/nrr_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type NRR"; DO NOT EDIT. + +package irr + +import () + +func (inc *NRR) OnUpdate(cb func(value float64)) { + inc.updateCallbacks = append(inc.updateCallbacks, cb) +} + +func (inc *NRR) EmitUpdate(value float64) { + for _, cb := range inc.updateCallbacks { + cb(value) + } +} diff --git a/pkg/strategy/irr/strategy.go b/pkg/strategy/irr/strategy.go new file mode 100644 index 0000000..711843e --- /dev/null +++ b/pkg/strategy/irr/strategy.go @@ -0,0 +1,392 @@ +package irr + +import ( + "context" + "fmt" + "os" + "sync" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/data/tsv" + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + + "github.com/sirupsen/logrus" +) + +const ID = "irr" + +var one = fixedpoint.One +var zero = fixedpoint.Zero + +var log = logrus.WithField("strategy", ID) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + Environment *qbtrade.Environment + Symbol string `json:"symbol"` + Market types.Market + + types.IntervalWindow + + // persistence fields + Position *types.Position `persistence:"position"` + ProfitStats *types.ProfitStats `persistence:"profit_stats"` + TradeStats *types.TradeStats `persistence:"trade_stats"` + + activeOrders *qbtrade.ActiveOrderBook + + ExitMethods qbtrade.ExitMethodSet `json:"exits"` + session *qbtrade.ExchangeSession + orderExecutor *qbtrade.GeneralOrderExecutor + + qbtrade.QuantityOrAmount + + // for negative return rate + nrr *NRR + + stopC chan struct{} + + // StrategyController + qbtrade.StrategyController + + AccountValueCalculator *qbtrade.AccountValueCalculator + + // whether to draw graph or not by the end of backtest + DrawGraph bool `json:"drawGraph"` + GraphPNLPath string `json:"graphPNLPath"` + GraphCumPNLPath string `json:"graphCumPNLPath"` + + // for position + buyPrice float64 `persistence:"buy_price"` + sellPrice float64 `persistence:"sell_price"` + highestPrice float64 `persistence:"highest_price"` + lowestPrice float64 `persistence:"lowest_price"` + + // Accumulated profit report + AccumulatedProfitReport *AccumulatedProfitReport `json:"accumulatedProfitReport"` +} + +// AccumulatedProfitReport For accumulated profit report output +type AccumulatedProfitReport struct { + // AccumulatedProfitMAWindow Accumulated profit SMA window, in number of trades + AccumulatedProfitMAWindow int `json:"accumulatedProfitMAWindow"` + + // IntervalWindow interval window, in days + IntervalWindow int `json:"intervalWindow"` + + // NumberOfInterval How many intervals to output to TSV + NumberOfInterval int `json:"NumberOfInterval"` + + // TsvReportPath The path to output report to + TsvReportPath string `json:"tsvReportPath"` + + // AccumulatedDailyProfitWindow The window to sum up the daily profit, in days + AccumulatedDailyProfitWindow int `json:"accumulatedDailyProfitWindow"` + + // Accumulated profit + accumulatedProfit fixedpoint.Value + accumulatedProfitPerDay floats.Slice + previousAccumulatedProfit fixedpoint.Value + + // Accumulated profit MA + accumulatedProfitMA *indicator.SMA + accumulatedProfitMAPerDay floats.Slice + + // Daily profit + dailyProfit floats.Slice + + // Accumulated fee + accumulatedFee fixedpoint.Value + accumulatedFeePerDay floats.Slice + + // Win ratio + winRatioPerDay floats.Slice + + // Profit factor + profitFactorPerDay floats.Slice + + // Trade number + dailyTrades floats.Slice + accumulatedTrades int + previousAccumulatedTrades int +} + +func (r *AccumulatedProfitReport) Initialize() { + if r.AccumulatedProfitMAWindow <= 0 { + r.AccumulatedProfitMAWindow = 60 + } + if r.IntervalWindow <= 0 { + r.IntervalWindow = 7 + } + if r.AccumulatedDailyProfitWindow <= 0 { + r.AccumulatedDailyProfitWindow = 7 + } + if r.NumberOfInterval <= 0 { + r.NumberOfInterval = 1 + } + r.accumulatedProfitMA = &indicator.SMA{IntervalWindow: types.IntervalWindow{Interval: types.Interval1d, Window: r.AccumulatedProfitMAWindow}} +} + +func (r *AccumulatedProfitReport) RecordProfit(profit fixedpoint.Value) { + r.accumulatedProfit = r.accumulatedProfit.Add(profit) +} + +func (r *AccumulatedProfitReport) RecordTrade(fee fixedpoint.Value) { + r.accumulatedFee = r.accumulatedFee.Add(fee) + r.accumulatedTrades += 1 +} + +func (r *AccumulatedProfitReport) DailyUpdate(tradeStats *types.TradeStats) { + // Daily profit + r.dailyProfit.Update(r.accumulatedProfit.Sub(r.previousAccumulatedProfit).Float64()) + r.previousAccumulatedProfit = r.accumulatedProfit + + // Accumulated profit + r.accumulatedProfitPerDay.Update(r.accumulatedProfit.Float64()) + + // Accumulated profit MA + r.accumulatedProfitMA.Update(r.accumulatedProfit.Float64()) + r.accumulatedProfitMAPerDay.Update(r.accumulatedProfitMA.Last(0)) + + // Accumulated Fee + r.accumulatedFeePerDay.Update(r.accumulatedFee.Float64()) + + // Win ratio + r.winRatioPerDay.Update(tradeStats.WinningRatio.Float64()) + + // Profit factor + r.profitFactorPerDay.Update(tradeStats.ProfitFactor.Float64()) + + // Daily trades + r.dailyTrades.Update(float64(r.accumulatedTrades - r.previousAccumulatedTrades)) + r.previousAccumulatedTrades = r.accumulatedTrades +} + +// Output Accumulated profit report to a TSV file +func (r *AccumulatedProfitReport) Output(symbol string) { + if r.TsvReportPath != "" { + tsvwiter, err := tsv.AppendWriterFile(r.TsvReportPath) + if err != nil { + panic(err) + } + defer tsvwiter.Close() + // Output symbol, total acc. profit, acc. profit 60MA, interval acc. profit, fee, win rate, profit factor + _ = tsvwiter.Write([]string{"#", "Symbol", "accumulatedProfit", "accumulatedProfitMA", fmt.Sprintf("%dd profit", r.AccumulatedDailyProfitWindow), "accumulatedFee", "winRatio", "profitFactor", "60D trades"}) + for i := 0; i <= r.NumberOfInterval-1; i++ { + accumulatedProfit := r.accumulatedProfitPerDay.Index(r.IntervalWindow * i) + accumulatedProfitStr := fmt.Sprintf("%f", accumulatedProfit) + accumulatedProfitMA := r.accumulatedProfitMAPerDay.Index(r.IntervalWindow * i) + accumulatedProfitMAStr := fmt.Sprintf("%f", accumulatedProfitMA) + intervalAccumulatedProfit := r.dailyProfit.Tail(r.AccumulatedDailyProfitWindow+r.IntervalWindow*i).Sum() - r.dailyProfit.Tail(r.IntervalWindow*i).Sum() + intervalAccumulatedProfitStr := fmt.Sprintf("%f", intervalAccumulatedProfit) + accumulatedFee := fmt.Sprintf("%f", r.accumulatedFeePerDay.Index(r.IntervalWindow*i)) + winRatio := fmt.Sprintf("%f", r.winRatioPerDay.Index(r.IntervalWindow*i)) + profitFactor := fmt.Sprintf("%f", r.profitFactorPerDay.Index(r.IntervalWindow*i)) + trades := r.dailyTrades.Tail(60+r.IntervalWindow*i).Sum() - r.dailyTrades.Tail(r.IntervalWindow*i).Sum() + tradesStr := fmt.Sprintf("%f", trades) + + _ = tsvwiter.Write([]string{fmt.Sprintf("%d", i+1), symbol, accumulatedProfitStr, accumulatedProfitMAStr, intervalAccumulatedProfitStr, accumulatedFee, winRatio, profitFactor, tradesStr}) + } + } +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s", ID, s.Symbol) +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + var instanceID = s.InstanceID() + + 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) + } + + // StrategyController + s.Status = types.StrategyStatusRunning + + s.OnSuspend(func() { + // Cancel active orders + _ = s.orderExecutor.GracefulCancel(ctx) + }) + + s.OnEmergencyStop(func() { + // Cancel active orders + _ = s.orderExecutor.GracefulCancel(ctx) + // Close 100% position + _ = s.orderExecutor.ClosePosition(ctx, fixedpoint.One) + }) + + // initial required information + s.session = session + + // Set fee rate + if s.session.MakerFeeRate.Sign() > 0 || s.session.TakerFeeRate.Sign() > 0 { + s.Position.SetExchangeFeeRate(s.session.ExchangeName, types.ExchangeFee{ + MakerFeeRate: s.session.MakerFeeRate, + TakerFeeRate: s.session.TakerFeeRate, + }) + } + + s.orderExecutor = qbtrade.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, s.Position) + s.orderExecutor.BindEnvironment(s.Environment) + s.orderExecutor.BindProfitStats(s.ProfitStats) + s.orderExecutor.BindTradeStats(s.TradeStats) + + // AccountValueCalculator + s.AccountValueCalculator = qbtrade.NewAccountValueCalculator(s.session, s.Market.QuoteCurrency) + + // Accumulated profit report + if qbtrade.IsBackTesting { + if s.AccumulatedProfitReport == nil { + s.AccumulatedProfitReport = &AccumulatedProfitReport{} + } + s.AccumulatedProfitReport.Initialize() + s.orderExecutor.TradeCollector().OnProfit(func(trade types.Trade, profit *types.Profit) { + if profit == nil { + return + } + + s.AccumulatedProfitReport.RecordProfit(profit.Profit) + }) + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, types.Interval1d, func(kline types.KLine) { + s.AccumulatedProfitReport.DailyUpdate(s.TradeStats) + })) + } + + // For drawing + profitSlice := floats.Slice{1., 1.} + price, _ := session.LastPrice(s.Symbol) + initAsset := s.CalcAssetValue(price).Float64() + cumProfitSlice := floats.Slice{initAsset, initAsset} + profitDollarSlice := floats.Slice{0, 0} + cumProfitDollarSlice := floats.Slice{0, 0} + + s.orderExecutor.TradeCollector().OnTrade(func(trade types.Trade, profit fixedpoint.Value, netProfit fixedpoint.Value) { + if qbtrade.IsBackTesting { + s.AccumulatedProfitReport.RecordTrade(trade.Fee) + } + + // For drawing/charting + price := trade.Price.Float64() + if s.buyPrice > 0 { + profitSlice.Update(price / s.buyPrice) + cumProfitSlice.Update(s.CalcAssetValue(trade.Price).Float64()) + } else if s.sellPrice > 0 { + profitSlice.Update(s.sellPrice / price) + cumProfitSlice.Update(s.CalcAssetValue(trade.Price).Float64()) + } + profitDollarSlice.Update(profit.Float64()) + cumProfitDollarSlice.Update(profitDollarSlice.Sum()) + if s.Position.IsDust(trade.Price) { + s.buyPrice = 0 + s.sellPrice = 0 + s.highestPrice = 0 + s.lowestPrice = 0 + } else if s.Position.IsLong() { + s.buyPrice = price + s.sellPrice = 0 + s.highestPrice = s.buyPrice + s.lowestPrice = 0 + } else { + s.sellPrice = price + s.buyPrice = 0 + s.highestPrice = 0 + s.lowestPrice = s.sellPrice + } + }) + + s.InitDrawCommands(&profitSlice, &cumProfitSlice, &cumProfitDollarSlice) + + s.orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { + qbtrade.Sync(ctx, s) + }) + s.orderExecutor.Bind() + s.activeOrders = qbtrade.NewActiveOrderBook(s.Symbol) + + kLineStore, _ := s.session.MarketDataStore(s.Symbol) + // window = 2 means day-to-day return, previousClose/currentClose -1 + // delay = false means use open/close-1 as D0 return (default) + // delay = true means use open/close-1 as 10 return + s.nrr = &NRR{IntervalWindow: types.IntervalWindow{Interval: s.Interval, Window: 2}, RankingWindow: s.Window, delay: true} + s.nrr.BindK(s.session.MarketDataStream, s.Symbol, s.nrr.Interval) + if klines, ok := kLineStore.KLinesOfInterval(s.nrr.Interval); ok { + s.nrr.LoadK((*klines)[0:]) + } + + s.session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.Interval, func(kline types.KLine) { + alphaNrr := fixedpoint.NewFromFloat(s.nrr.RankedValues.Index(1)) + + // alpha-weighted inventory and cash + targetBase := s.QuantityOrAmount.CalculateQuantity(kline.Close).Mul(alphaNrr) + diffQty := targetBase.Sub(s.Position.Base) + log.Info(alphaNrr.Float64(), s.Position.Base, diffQty.Float64()) + + if err := s.orderExecutor.CancelOrders(ctx); err != nil { + log.WithError(err).Errorf("cancel order error") + } + + if diffQty.Sign() > 0 { + _, _ = s.orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeBuy, + Quantity: diffQty.Abs(), + Type: types.OrderTypeMarket, + Tag: "irrBuy", + }) + } else if diffQty.Sign() < 0 { + _, _ = s.orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeSell, + Quantity: diffQty.Abs(), + Type: types.OrderTypeMarket, + Tag: "irrSell", + }) + } + })) + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + // Output accumulated profit report + if qbtrade.IsBackTesting { + defer s.AccumulatedProfitReport.Output(s.Symbol) + + if s.DrawGraph { + if err := s.Draw(&profitSlice, &cumProfitSlice); err != nil { + log.WithError(err).Errorf("cannot draw graph") + } + } + } else { + close(s.stopC) + } + _, _ = fmt.Fprintln(os.Stderr, s.TradeStats.String()) + _ = s.orderExecutor.GracefulCancel(ctx) + }) + return nil +} + +func (s *Strategy) CalcAssetValue(price fixedpoint.Value) fixedpoint.Value { + balances := s.session.GetAccount().Balances() + return balances[s.Market.BaseCurrency].Total().Mul(price).Add(balances[s.Market.QuoteCurrency].Total()) +} diff --git a/pkg/strategy/kline/strategy.go b/pkg/strategy/kline/strategy.go new file mode 100644 index 0000000..bf78016 --- /dev/null +++ b/pkg/strategy/kline/strategy.go @@ -0,0 +1,43 @@ +package kline + +import ( + "context" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ID = "kline" + +var log = logrus.WithField("strategy", ID) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + Symbol string `json:"symbol"` + MovingAverage types.IntervalWindow `json:"movingAverage"` +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.MovingAverage.Interval}) +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { + // skip k-lines from other symbols + if kline.Symbol != s.Symbol { + return + } + + log.Infof("%s", kline.String()) + }) + return nil +} diff --git a/pkg/strategy/linregmaker/doc.go b/pkg/strategy/linregmaker/doc.go new file mode 100644 index 0000000..a4bddf9 --- /dev/null +++ b/pkg/strategy/linregmaker/doc.go @@ -0,0 +1,6 @@ +// Linregmaker is a maker strategy depends on the linear regression baseline slopes +// +// Linregmaker uses two linear regression baseline slopes for trading: +// 1) The fast linReg is to determine the short-term trend. It controls whether placing buy/sell orders or not. +// 2) The slow linReg is to determine the mid-term trend. It controls whether the creation of opposite direction position is allowed. +package linregmaker diff --git a/pkg/strategy/linregmaker/strategy.go b/pkg/strategy/linregmaker/strategy.go new file mode 100644 index 0000000..5328c78 --- /dev/null +++ b/pkg/strategy/linregmaker/strategy.go @@ -0,0 +1,861 @@ +package linregmaker + +import ( + "context" + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/report" + "os" + "strconv" + "sync" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/risk/dynamicrisk" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// TODO: Docs + +const ID = "linregmaker" + +var notionModifier = fixedpoint.NewFromFloat(1.1) +var two = fixedpoint.NewFromInt(2) + +var log = logrus.WithField("strategy", ID) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + // Symbol is the market symbol you want to trade + Symbol string `json:"symbol"` + + // Leverage uses the account net value to calculate the allowed margin + Leverage fixedpoint.Value `json:"leverage"` + + types.IntervalWindow + + // ReverseEMA is used to determine the long-term trend. + // Above the ReverseEMA is the long trend and vise versa. + // All the opposite trend position will be closed upon the trend change + ReverseEMA *indicator.EWMA `json:"reverseEMA"` + + // ReverseInterval is the interval to check trend reverse against ReverseEMA. Close price of this interval crossing + // the ReverseEMA triggers main trend change. + ReverseInterval types.Interval `json:"reverseInterval"` + + // mainTrendCurrent is the current long-term trend + mainTrendCurrent types.Direction + // mainTrendPrevious is the long-term trend of previous kline + mainTrendPrevious types.Direction + + // FastLinReg is to determine the short-term trend. + // Buy/sell orders are placed if the FastLinReg and the ReverseEMA trend are in the same direction, and only orders + // that reduce position are placed if the FastLinReg and the ReverseEMA trend are in different directions. + FastLinReg *indicator.LinReg `json:"fastLinReg"` + + // SlowLinReg is to determine the midterm trend. + // When the SlowLinReg and the ReverseEMA trend are in different directions, creation of opposite position is + // allowed. + SlowLinReg *indicator.LinReg `json:"slowLinReg"` + + // AllowOppositePosition if true, the creation of opposite position is allowed when both fast and slow LinReg are in + // the opposite direction to main trend + AllowOppositePosition bool `json:"allowOppositePosition"` + + // FasterDecreaseRatio the quantity of decreasing position orders are multiplied by this ratio when both fast and + // slow LinReg are in the opposite direction to main trend + FasterDecreaseRatio fixedpoint.Value `json:"fasterDecreaseRatio,omitempty"` + + // NeutralBollinger is the smaller range of the bollinger band + // If price is in this band, it usually means the price is oscillating. + // If price goes out of this band, we tend to not place sell orders or buy orders + NeutralBollinger types.IntervalWindowBandWidth `json:"neutralBollinger"` + + // neutralBoll is the neutral price section for TradeInBand + neutralBoll *indicator.BOLL + + // TradeInBand + // When this is on, places orders only when the current price is in the bollinger band. + TradeInBand bool `json:"tradeInBand"` + + // Spread is the price spread from the middle price. + // For ask orders, the ask price is ((bestAsk + bestBid) / 2 * (1.0 + spread)) + // For bid orders, the bid price is ((bestAsk + bestBid) / 2 * (1.0 - spread)) + // Spread can be set by percentage or floating number. e.g., 0.1% or 0.001 + Spread fixedpoint.Value `json:"spread"` + + // BidSpread overrides the spread setting, this spread will be used for the buy order + BidSpread fixedpoint.Value `json:"bidSpread,omitempty"` + + // AskSpread overrides the spread setting, this spread will be used for the sell order + AskSpread fixedpoint.Value `json:"askSpread,omitempty"` + + // DynamicSpread enables the automatic adjustment to bid and ask spread. + // Overrides Spread, BidSpread, and AskSpread + DynamicSpread dynamicrisk.DynamicSpread `json:"dynamicSpread,omitempty"` + + // MaxExposurePosition is the maximum position you can hold + // 10 means you can hold 10 ETH long/short position by maximum + MaxExposurePosition fixedpoint.Value `json:"maxExposurePosition"` + + // DynamicExposure is used to define the exposure position range with the given percentage. + // When DynamicExposure is set, your MaxExposurePosition will be calculated dynamically + DynamicExposure dynamicrisk.DynamicExposure `json:"dynamicExposure"` + + qbtrade.QuantityOrAmount + + // DynamicQuantityIncrease calculates the increase position order quantity dynamically + DynamicQuantityIncrease dynamicrisk.DynamicQuantitySet `json:"dynamicQuantityIncrease"` + + // DynamicQuantityDecrease calculates the decrease position order quantity dynamically + DynamicQuantityDecrease dynamicrisk.DynamicQuantitySet `json:"dynamicQuantityDecrease"` + + // UseDynamicQuantityAsAmount calculates amount instead of quantity + UseDynamicQuantityAsAmount bool `json:"useDynamicQuantityAsAmount"` + + // MinProfitSpread is the minimal order price spread from the current average cost. + // For long position, you will only place sell order above the price (= average cost * (1 + minProfitSpread)) + // For short position, you will only place buy order below the price (= average cost * (1 - minProfitSpread)) + MinProfitSpread fixedpoint.Value `json:"minProfitSpread"` + + // MinProfitActivationRate activates MinProfitSpread when position RoI higher than the specified percentage + MinProfitActivationRate fixedpoint.Value `json:"minProfitActivationRate"` + + // ExitMethods are various TP/SL methods + ExitMethods qbtrade.ExitMethodSet `json:"exits"` + + // persistence fields + Position *types.Position `persistence:"position"` + ProfitStats *types.ProfitStats `persistence:"profit_stats"` + TradeStats *types.TradeStats `persistence:"trade_stats"` + + // ProfitStatsTracker tracks profit related status and generates report + ProfitStatsTracker *report.ProfitStatsTracker `json:"profitStatsTracker"` + TrackParameters bool `json:"trackParameters"` + + Environment *qbtrade.Environment + StandardIndicatorSet *qbtrade.StandardIndicatorSet + Market types.Market + ctx context.Context + + session *qbtrade.ExchangeSession + + orderExecutor *qbtrade.GeneralOrderExecutor + + groupID uint32 + + // StrategyController + qbtrade.StrategyController +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s", ID, s.Symbol) +} + +// Validate basic config parameters +func (s *Strategy) Validate() error { + if len(s.Symbol) == 0 { + return errors.New("symbol is required") + } + + if len(s.Interval) == 0 { + return errors.New("interval is required") + } + + if s.ReverseEMA == nil { + return errors.New("reverseEMA must be set") + } + + if s.FastLinReg == nil { + return errors.New("fastLinReg must be set") + } + + if s.SlowLinReg == nil { + return errors.New("slowLinReg must be set") + } + + return nil +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + // Subscribe for ReverseEMA + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{ + Interval: s.ReverseEMA.Interval, + }) + + // Subscribe for ReverseInterval. Use interval of ReverseEMA if ReverseInterval is omitted + if s.ReverseInterval == "" { + s.ReverseInterval = s.ReverseEMA.Interval + } + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{ + Interval: s.ReverseInterval, + }) + + // Subscribe for LinRegs + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{ + Interval: s.FastLinReg.Interval, + }) + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{ + Interval: s.SlowLinReg.Interval, + }) + // Initialize LinRegs + kLineStore, _ := session.MarketDataStore(s.Symbol) + s.FastLinReg.BindK(session.MarketDataStream, s.Symbol, s.FastLinReg.Interval) + if klines, ok := kLineStore.KLinesOfInterval(s.FastLinReg.Interval); ok { + s.FastLinReg.LoadK((*klines)[0:]) + } + s.SlowLinReg.BindK(session.MarketDataStream, s.Symbol, s.SlowLinReg.Interval) + if klines, ok := kLineStore.KLinesOfInterval(s.SlowLinReg.Interval); ok { + s.SlowLinReg.LoadK((*klines)[0:]) + } + + // Subscribe for BBs + if s.NeutralBollinger.Interval != "" { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{ + Interval: s.NeutralBollinger.Interval, + }) + } + + // Initialize Exits + s.ExitMethods.SetAndSubscribe(session, s) + + // Initialize dynamic spread + if s.DynamicSpread.IsEnabled() { + s.DynamicSpread.Initialize(s.Symbol, session) + } + + // Initialize dynamic exposure + if s.DynamicExposure.IsEnabled() { + s.DynamicExposure.Initialize(s.Symbol, session) + } + + // Initialize dynamic quantities + if len(s.DynamicQuantityIncrease) > 0 { + s.DynamicQuantityIncrease.Initialize(s.Symbol, session) + } + if len(s.DynamicQuantityDecrease) > 0 { + s.DynamicQuantityDecrease.Initialize(s.Symbol, session) + } + + // Profit tracker + if s.ProfitStatsTracker != nil { + s.ProfitStatsTracker.Subscribe(session, s.Symbol) + } +} + +func (s *Strategy) CurrentPosition() *types.Position { + return s.Position +} + +func (s *Strategy) ClosePosition(ctx context.Context, percentage fixedpoint.Value) error { + return s.orderExecutor.ClosePosition(ctx, percentage) +} + +// isAllowOppositePosition returns if opening opposite position is allowed +func (s *Strategy) isAllowOppositePosition() bool { + if !s.AllowOppositePosition { + return false + } + + if (s.mainTrendCurrent == types.DirectionUp && s.FastLinReg.Last(0) < 0 && s.SlowLinReg.Last(0) < 0) || + (s.mainTrendCurrent == types.DirectionDown && s.FastLinReg.Last(0) > 0 && s.SlowLinReg.Last(0) > 0) { + log.Infof("%s allow opposite position is enabled: MainTrend %v, FastLinReg: %f, SlowLinReg: %f", s.Symbol, s.mainTrendCurrent, s.FastLinReg.Last(0), s.SlowLinReg.Last(0)) + return true + } + log.Infof("%s allow opposite position is disabled: MainTrend %v, FastLinReg: %f, SlowLinReg: %f", s.Symbol, s.mainTrendCurrent, s.FastLinReg.Last(0), s.SlowLinReg.Last(0)) + + return false +} + +// updateSpread for ask and bid price +func (s *Strategy) updateSpread() { + // Update spreads with dynamic spread + if s.DynamicSpread.IsEnabled() { + dynamicBidSpread, err := s.DynamicSpread.GetBidSpread() + if err == nil && dynamicBidSpread > 0 { + s.BidSpread = fixedpoint.NewFromFloat(dynamicBidSpread) + log.Infof("%s dynamic bid spread updated: %s", s.Symbol, s.BidSpread.Percentage()) + } + dynamicAskSpread, err := s.DynamicSpread.GetAskSpread() + if err == nil && dynamicAskSpread > 0 { + s.AskSpread = fixedpoint.NewFromFloat(dynamicAskSpread) + log.Infof("%s dynamic ask spread updated: %s", s.Symbol, s.AskSpread.Percentage()) + } + } + + if s.BidSpread.Sign() <= 0 { + s.BidSpread = s.Spread + } + + if s.BidSpread.Sign() <= 0 { + s.AskSpread = s.Spread + } +} + +// updateMaxExposure with dynamic exposure +func (s *Strategy) updateMaxExposure(midPrice fixedpoint.Value) { + // Calculate max exposure + if s.DynamicExposure.IsEnabled() { + var err error + maxExposurePosition, err := s.DynamicExposure.GetMaxExposure(midPrice.Float64(), s.mainTrendCurrent) + if err != nil { + log.WithError(err).Errorf("can not calculate DynamicExposure of %s, use previous MaxExposurePosition instead", s.Symbol) + qbtrade.Notify("can not calculate DynamicExposure of %s, use previous MaxExposurePosition instead", s.Symbol) + } else { + s.MaxExposurePosition = maxExposurePosition + } + log.Infof("calculated %s max exposure position: %v", s.Symbol, s.MaxExposurePosition) + } +} + +// getOrderPrices returns ask and bid prices +func (s *Strategy) getOrderPrices(midPrice fixedpoint.Value) (askPrice fixedpoint.Value, bidPrice fixedpoint.Value) { + askPrice = midPrice.Mul(fixedpoint.One.Add(s.AskSpread)) + bidPrice = midPrice.Mul(fixedpoint.One.Sub(s.BidSpread)) + log.Infof("%s mid price:%v ask:%v bid: %v", s.Symbol, midPrice, askPrice, bidPrice) + + return askPrice, bidPrice +} + +// adjustQuantity to meet the min notional and qty requirement +func (s *Strategy) adjustQuantity(quantity, price fixedpoint.Value) fixedpoint.Value { + adjustedQty := quantity + if quantity.Mul(price).Compare(s.Market.MinNotional) < 0 { + adjustedQty = qbtrade.AdjustFloatQuantityByMinAmount(quantity, price, s.Market.MinNotional.Mul(notionModifier)) + } + + if adjustedQty.Compare(s.Market.MinQuantity) < 0 { + adjustedQty = fixedpoint.Max(adjustedQty, s.Market.MinQuantity) + } + + return adjustedQty +} + +// getOrderQuantities returns sell and buy qty +func (s *Strategy) getOrderQuantities(askPrice fixedpoint.Value, bidPrice fixedpoint.Value) (sellQuantity fixedpoint.Value, buyQuantity fixedpoint.Value) { + // Default + sellQuantity = s.QuantityOrAmount.CalculateQuantity(askPrice) + buyQuantity = s.QuantityOrAmount.CalculateQuantity(bidPrice) + + // Dynamic qty + switch { + case s.mainTrendCurrent == types.DirectionUp: + if len(s.DynamicQuantityIncrease) > 0 { + qty, err := s.DynamicQuantityIncrease.GetQuantity(false) + if err == nil { + buyQuantity = qty + } else { + log.WithError(err).Errorf("cannot get dynamic buy qty of %s, use default qty instead", s.Symbol) + qbtrade.Notify("cannot get dynamic buy qty of %s, use default qty instead", s.Symbol) + } + } + if len(s.DynamicQuantityDecrease) > 0 { + qty, err := s.DynamicQuantityDecrease.GetQuantity(false) + if err == nil { + sellQuantity = qty + } else { + log.WithError(err).Errorf("cannot get dynamic sell qty of %s, use default qty instead", s.Symbol) + qbtrade.Notify("cannot get dynamic sell qty of %s, use default qty instead", s.Symbol) + } + } + case s.mainTrendCurrent == types.DirectionDown: + if len(s.DynamicQuantityIncrease) > 0 { + qty, err := s.DynamicQuantityIncrease.GetQuantity(true) + if err == nil { + sellQuantity = qty + } else { + log.WithError(err).Errorf("cannot get dynamic sell qty of %s, use default qty instead", s.Symbol) + qbtrade.Notify("cannot get dynamic sell qty of %s, use default qty instead", s.Symbol) + } + } + if len(s.DynamicQuantityDecrease) > 0 { + qty, err := s.DynamicQuantityDecrease.GetQuantity(true) + if err == nil { + buyQuantity = qty + } else { + log.WithError(err).Errorf("cannot get dynamic buy qty of %s, use default qty instead", s.Symbol) + qbtrade.Notify("cannot get dynamic buy qty of %s, use default qty instead", s.Symbol) + } + } + } + if s.UseDynamicQuantityAsAmount { + log.Infof("caculated %s buy amount %v, sell amount %v", s.Symbol, buyQuantity, sellQuantity) + qtyAmount := qbtrade.QuantityOrAmount{Amount: buyQuantity} + buyQuantity = qtyAmount.CalculateQuantity(bidPrice) + qtyAmount.Amount = sellQuantity + sellQuantity = qtyAmount.CalculateQuantity(askPrice) + log.Infof("convert %s amount to buy qty %v, sell qty %v", s.Symbol, buyQuantity, sellQuantity) + } else { + log.Infof("caculated %s buy qty %v, sell qty %v", s.Symbol, buyQuantity, sellQuantity) + } + + // Faster position decrease + if s.mainTrendCurrent == types.DirectionUp && s.SlowLinReg.Last(0) < 0 { + sellQuantity = sellQuantity.Mul(s.FasterDecreaseRatio) + log.Infof("faster %s position decrease: sell qty %v", s.Symbol, sellQuantity) + } else if s.mainTrendCurrent == types.DirectionDown && s.SlowLinReg.Last(0) > 0 { + buyQuantity = buyQuantity.Mul(s.FasterDecreaseRatio) + log.Infof("faster %s position decrease: buy qty %v", s.Symbol, buyQuantity) + } + + // Reduce order qty to fit current position + if !s.isAllowOppositePosition() { + if s.Position.IsLong() && s.Position.Base.Abs().Compare(sellQuantity) < 0 { + sellQuantity = s.Position.Base.Abs() + } else if s.Position.IsShort() && s.Position.Base.Abs().Compare(buyQuantity) < 0 { + buyQuantity = s.Position.Base.Abs() + } + } + + if buyQuantity.Compare(fixedpoint.Zero) > 0 { + buyQuantity = s.adjustQuantity(buyQuantity, bidPrice) + } + if sellQuantity.Compare(fixedpoint.Zero) > 0 { + sellQuantity = s.adjustQuantity(sellQuantity, askPrice) + } + + log.Infof("adjusted sell qty:%v buy qty: %v", sellQuantity, buyQuantity) + + return sellQuantity, buyQuantity +} + +// getAllowedBalance returns the allowed qty of orders +func (s *Strategy) getAllowedBalance() (baseQty, quoteQty fixedpoint.Value) { + // Default + baseQty = fixedpoint.PosInf + quoteQty = fixedpoint.PosInf + + balances := s.session.GetAccount().Balances() + baseBalance, hasBaseBalance := balances[s.Market.BaseCurrency] + quoteBalance, hasQuoteBalance := balances[s.Market.QuoteCurrency] + lastPrice, _ := s.session.LastPrice(s.Symbol) + + if qbtrade.IsBackTesting { // Backtesting + baseQty = s.Position.Base + quoteQty = quoteBalance.Available.Sub(fixedpoint.Max(s.Position.Quote.Mul(fixedpoint.Two), fixedpoint.Zero)) + } else if s.session.Margin || s.session.IsolatedMargin || s.session.Futures || s.session.IsolatedFutures { // Leveraged + quoteQ, err := qbtrade.CalculateQuoteQuantity(s.ctx, s.session, s.Market.QuoteCurrency, s.Leverage) + if err != nil { + quoteQ = fixedpoint.Zero + } + quoteQty = quoteQ + baseQty = quoteQ.Div(lastPrice) + } else { // Spot + if !hasBaseBalance { + baseQty = fixedpoint.Zero + } else { + baseQty = baseBalance.Available + } + if !hasQuoteBalance { + quoteQty = fixedpoint.Zero + } else { + quoteQty = quoteBalance.Available + } + } + + return baseQty, quoteQty +} + +// getCanBuySell returns the buy sell switches +func (s *Strategy) getCanBuySell(buyQuantity, bidPrice, sellQuantity, askPrice, midPrice fixedpoint.Value) (canBuy bool, canSell bool) { + // By default, both buy and sell are on, which means we will place buy and sell orders + canBuy = true + canSell = true + + // Check if current position > maxExposurePosition + if s.Position.GetBase().Abs().Compare(s.MaxExposurePosition) > 0 { + if s.Position.IsLong() { + canBuy = false + } else if s.Position.IsShort() { + canSell = false + } + log.Infof("current position %v larger than max exposure %v, skip increase position", s.Position.GetBase().Abs(), s.MaxExposurePosition) + } + + // Check TradeInBand + if s.TradeInBand { + // Price too high + if bidPrice.Float64() > s.neutralBoll.UpBand.Last(0) { + canBuy = false + log.Infof("tradeInBand is set, skip buy due to the price is higher than the neutralBB") + } + // Price too low in uptrend + if askPrice.Float64() < s.neutralBoll.DownBand.Last(0) { + canSell = false + log.Infof("tradeInBand is set, skip sell due to the price is lower than the neutralBB") + } + } + + // Stop decrease when position closed unless both LinRegs are in the opposite direction to the main trend + if !s.isAllowOppositePosition() { + if s.mainTrendCurrent == types.DirectionUp && (s.Position.IsClosed() || s.Position.IsDust(askPrice)) { + canSell = false + log.Infof("skip sell due to the long position is closed") + } else if s.mainTrendCurrent == types.DirectionDown && (s.Position.IsClosed() || s.Position.IsDust(bidPrice)) { + canBuy = false + log.Infof("skip buy due to the short position is closed") + } + } + + // Min profit + roi := s.Position.ROI(midPrice) + if roi.Compare(s.MinProfitActivationRate) >= 0 { + if s.Position.IsLong() && !s.Position.IsDust(askPrice) { + minProfitPrice := s.Position.AverageCost.Mul(fixedpoint.One.Add(s.MinProfitSpread)) + if askPrice.Compare(minProfitPrice) < 0 { + canSell = false + log.Infof("askPrice %v is less than minProfitPrice %v. skip sell", askPrice, minProfitPrice) + } + } else if s.Position.IsShort() && s.Position.IsDust(bidPrice) { + minProfitPrice := s.Position.AverageCost.Mul(fixedpoint.One.Sub(s.MinProfitSpread)) + if bidPrice.Compare(minProfitPrice) > 0 { + canBuy = false + log.Infof("bidPrice %v is greater than minProfitPrice %v. skip buy", bidPrice, minProfitPrice) + } + } + } else { + log.Infof("position RoI %v is less than minProfitActivationRate %v. min profit protection is not active", roi, s.MinProfitActivationRate) + } + + // Check against account balance + baseQty, quoteQty := s.getAllowedBalance() + if s.session.Margin || s.session.IsolatedMargin || s.session.Futures || s.session.IsolatedFutures { // Leveraged + if quoteQty.Compare(fixedpoint.Zero) <= 0 { + if s.Position.IsLong() { + canBuy = false + log.Infof("skip buy due to the account has no available balance") + } else if s.Position.IsShort() { + canSell = false + log.Infof("skip sell due to the account has no available balance") + } + } + } else { + if buyQuantity.Compare(quoteQty.Div(bidPrice)) > 0 { // Spot + canBuy = false + log.Infof("skip buy due to the account has no available balance") + } + if sellQuantity.Compare(baseQty) > 0 { + canSell = false + log.Infof("skip sell due to the account has no available balance") + } + } + + log.Infof("canBuy %t, canSell %t", canBuy, canSell) + return canBuy, canSell +} + +// getOrderForms returns buy and sell order form for submission +func (s *Strategy) getOrderForms(buyQuantity, bidPrice, sellQuantity, askPrice fixedpoint.Value) (buyOrder types.SubmitOrder, sellOrder types.SubmitOrder) { + sellOrder = types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeLimitMaker, + Quantity: sellQuantity, + Price: askPrice, + Market: s.Market, + GroupID: s.groupID, + } + buyOrder = types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimitMaker, + Quantity: buyQuantity, + Price: bidPrice, + Market: s.Market, + GroupID: s.groupID, + } + + isMargin := s.session.Margin || s.session.IsolatedMargin + isFutures := s.session.Futures || s.session.IsolatedFutures + + if s.Position.IsClosed() { + if isMargin { + buyOrder.MarginSideEffect = types.SideEffectTypeMarginBuy + sellOrder.MarginSideEffect = types.SideEffectTypeMarginBuy + } else if isFutures { + buyOrder.ReduceOnly = false + sellOrder.ReduceOnly = false + } + } else if s.Position.IsLong() { + if isMargin { + buyOrder.MarginSideEffect = types.SideEffectTypeMarginBuy + sellOrder.MarginSideEffect = types.SideEffectTypeAutoRepay + } else if isFutures { + buyOrder.ReduceOnly = false + sellOrder.ReduceOnly = true + } + + if s.Position.Base.Abs().Compare(sellOrder.Quantity) < 0 { + if isMargin { + sellOrder.MarginSideEffect = types.SideEffectTypeMarginBuy + } else if isFutures { + sellOrder.ReduceOnly = false + } + } + } else if s.Position.IsShort() { + if isMargin { + buyOrder.MarginSideEffect = types.SideEffectTypeAutoRepay + sellOrder.MarginSideEffect = types.SideEffectTypeMarginBuy + } else if isFutures { + buyOrder.ReduceOnly = true + sellOrder.ReduceOnly = false + } + + if s.Position.Base.Abs().Compare(buyOrder.Quantity) < 0 { + if isMargin { + sellOrder.MarginSideEffect = types.SideEffectTypeMarginBuy + } else if isFutures { + sellOrder.ReduceOnly = false + } + } + } + + return buyOrder, sellOrder +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + log.Debugf("%v", orderExecutor) // Here just to suppress GoLand warning + // initial required information + s.session = session + s.ctx = ctx + + // Calculate group id for orders + instanceID := s.InstanceID() + s.groupID = util.FNV32(instanceID) + + // If position is nil, we need to allocate a new position for calculation + if s.Position == nil { + s.Position = types.NewPositionFromMarket(s.Market) + } + + // Set fee rate + if s.session.MakerFeeRate.Sign() > 0 || s.session.TakerFeeRate.Sign() > 0 { + s.Position.SetExchangeFeeRate(s.session.ExchangeName, types.ExchangeFee{ + MakerFeeRate: s.session.MakerFeeRate, + TakerFeeRate: s.session.TakerFeeRate, + }) + } + + // If position is nil, we need to allocate a new position for calculation + if s.Position == nil { + s.Position = types.NewPositionFromMarket(s.Market) + } + // Always update the position fields + s.Position.Strategy = ID + s.Position.StrategyInstanceID = s.InstanceID() + + // Profit stats + if s.ProfitStats == nil { + s.ProfitStats = types.NewProfitStats(s.Market) + } + + if s.TradeStats == nil { + s.TradeStats = types.NewTradeStats(s.Symbol) + } + + s.orderExecutor = qbtrade.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, s.Position) + s.orderExecutor.BindEnvironment(s.Environment) + s.orderExecutor.BindProfitStats(s.ProfitStats) + s.orderExecutor.BindTradeStats(s.TradeStats) + s.orderExecutor.Bind() + s.orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { + qbtrade.Sync(ctx, s) + }) + s.ExitMethods.Bind(session, s.orderExecutor) + + // Setup profit tracker + if s.ProfitStatsTracker != nil { + if s.ProfitStatsTracker.CurrentProfitStats == nil { + s.ProfitStatsTracker.InitLegacy(s.Market, &s.ProfitStats, s.TradeStats) + } + + // Add strategy parameters to report + if s.TrackParameters && s.ProfitStatsTracker.AccumulatedProfitReport != nil { + s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("ReverseEMAWindow", strconv.Itoa(s.ReverseEMA.Window)) + s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("FastLinRegWindow", strconv.Itoa(s.FastLinReg.Window)) + s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("FastLinRegInterval", s.FastLinReg.Interval.String()) + s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("SlowLinRegWindow", strconv.Itoa(s.SlowLinReg.Window)) + s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("SlowLinRegInterval", s.SlowLinReg.Interval.String()) + s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("FasterDecreaseRatio", strconv.FormatFloat(s.FasterDecreaseRatio.Float64(), 'f', 4, 64)) + s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("NeutralBollingerWindow", strconv.Itoa(s.NeutralBollinger.Window)) + s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("NeutralBollingerBandWidth", strconv.FormatFloat(s.NeutralBollinger.BandWidth, 'f', 4, 64)) + s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("Spread", strconv.FormatFloat(s.Spread.Float64(), 'f', 4, 64)) + } + + s.ProfitStatsTracker.Bind(s.session, s.orderExecutor.TradeCollector()) + } + + // Indicators initialized by StandardIndicatorSet must be initialized in Run() + // Initialize ReverseEMA + s.ReverseEMA = s.StandardIndicatorSet.EWMA(s.ReverseEMA.IntervalWindow) + // Initialize BBs + s.neutralBoll = s.StandardIndicatorSet.BOLL(s.NeutralBollinger.IntervalWindow, s.NeutralBollinger.BandWidth) + + // Default spread + if s.Spread == fixedpoint.Zero { + s.Spread = fixedpoint.NewFromFloat(0.001) + } + + // StrategyController + s.Status = types.StrategyStatusRunning + s.OnSuspend(func() { + _ = s.orderExecutor.GracefulCancel(ctx) + qbtrade.Sync(ctx, s) + }) + s.OnEmergencyStop(func() { + // Close whole position + _ = s.ClosePosition(ctx, fixedpoint.NewFromFloat(1.0)) + }) + + // Initial trend + session.UserDataStream.OnStart(func() { + var closePrice fixedpoint.Value + if !qbtrade.IsBackTesting { + ticker, err := s.session.Exchange.QueryTicker(ctx, s.Symbol) + if err != nil { + return + } + + closePrice = ticker.Buy.Add(ticker.Sell).Div(two) + } else { + if price, ok := session.LastPrice(s.Symbol); ok { + closePrice = price + } + } + priceReverseEMA := fixedpoint.NewFromFloat(s.ReverseEMA.Last(0)) + + // Main trend by ReverseEMA + if closePrice.Compare(priceReverseEMA) > 0 { + s.mainTrendCurrent = types.DirectionUp + } else if closePrice.Compare(priceReverseEMA) < 0 { + s.mainTrendCurrent = types.DirectionDown + } + }) + + // Check trend reversal + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.ReverseInterval, func(kline types.KLine) { + // closePrice is the close price of current kline + closePrice := kline.GetClose() + // priceReverseEMA is the current ReverseEMA price + priceReverseEMA := fixedpoint.NewFromFloat(s.ReverseEMA.Last(0)) + + // Main trend by ReverseEMA + s.mainTrendPrevious = s.mainTrendCurrent + if closePrice.Compare(priceReverseEMA) > 0 { + s.mainTrendCurrent = types.DirectionUp + } else if closePrice.Compare(priceReverseEMA) < 0 { + s.mainTrendCurrent = types.DirectionDown + } + log.Infof("%s current trend is %v", s.Symbol, s.mainTrendCurrent) + + // Trend reversal + if s.mainTrendCurrent != s.mainTrendPrevious { + log.Infof("%s trend reverse to %v", s.Symbol, s.mainTrendCurrent) + qbtrade.Notify("%s trend reverse to %v", s.Symbol, s.mainTrendCurrent) + // Close on-hand position that is not in the same direction as the new trend + if !s.Position.IsDust(closePrice) && + ((s.Position.IsLong() && s.mainTrendCurrent == types.DirectionDown) || + (s.Position.IsShort() && s.mainTrendCurrent == types.DirectionUp)) { + log.Infof("%s closing on-hand position due to trend reverse", s.Symbol) + qbtrade.Notify("%s closing on-hand position due to trend reverse", s.Symbol) + if err := s.ClosePosition(ctx, fixedpoint.One); err != nil { + log.WithError(err).Errorf("cannot close on-hand position of %s", s.Symbol) + qbtrade.Notify("cannot close on-hand position of %s", s.Symbol) + } + } + } + })) + + // Main interval + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.Interval, func(kline types.KLine) { + // StrategyController + if s.Status != types.StrategyStatusRunning { + return + } + + _ = s.orderExecutor.GracefulCancel(ctx) + + // closePrice is the close price of current kline + closePrice := kline.GetClose() + + // midPrice for ask and bid prices + var midPrice fixedpoint.Value + if !qbtrade.IsBackTesting { + ticker, err := s.session.Exchange.QueryTicker(ctx, s.Symbol) + if err != nil { + return + } + + midPrice = ticker.Buy.Add(ticker.Sell).Div(two) + log.Infof("using ticker price: bid %v / ask %v, mid price %v", ticker.Buy, ticker.Sell, midPrice) + } else { + midPrice = closePrice + } + + // Update price spread + s.updateSpread() + + // Update max exposure + s.updateMaxExposure(midPrice) + + // Current position status + log.Infof("position: %s", s.Position) + if !s.Position.IsClosed() && !s.Position.IsDust(midPrice) { + log.Infof("current %s unrealized profit: %f %s", s.Symbol, s.Position.UnrealizedProfit(midPrice).Float64(), s.Market.QuoteCurrency) + } + + // Order prices + askPrice, bidPrice := s.getOrderPrices(midPrice) + + // Order qty + sellQuantity, buyQuantity := s.getOrderQuantities(askPrice, bidPrice) + + buyOrder, sellOrder := s.getOrderForms(buyQuantity, bidPrice, sellQuantity, askPrice) + + canBuy, canSell := s.getCanBuySell(buyQuantity, bidPrice, sellQuantity, askPrice, midPrice) + + // Submit orders + var submitOrders []types.SubmitOrder + if canSell && sellOrder.Quantity.Compare(fixedpoint.Zero) > 0 { + submitOrders = append(submitOrders, sellOrder) + } + if canBuy && buyOrder.Quantity.Compare(fixedpoint.Zero) > 0 { + submitOrders = append(submitOrders, buyOrder) + } + + if len(submitOrders) == 0 { + return + } + log.Infof("submitting order(s): %v", submitOrders) + _, _ = s.orderExecutor.SubmitOrders(ctx, submitOrders...) + })) + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + // Output profit report + if s.ProfitStatsTracker != nil { + if s.ProfitStatsTracker.AccumulatedProfitReport != nil { + s.ProfitStatsTracker.AccumulatedProfitReport.Output() + } + } + + _ = s.orderExecutor.GracefulCancel(ctx) + _, _ = fmt.Fprintln(os.Stderr, s.TradeStats.String()) + }) + + return nil +} diff --git a/pkg/strategy/liquiditymaker/generator.go b/pkg/strategy/liquiditymaker/generator.go new file mode 100644 index 0000000..07e0510 --- /dev/null +++ b/pkg/strategy/liquiditymaker/generator.go @@ -0,0 +1,101 @@ +package liquiditymaker + +import ( + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// input: liquidityOrderGenerator( +// +// totalLiquidityAmount, +// startPrice, +// endPrice, +// numLayers, +// quantityScale) +// +// when side == sell +// +// priceAsk1 * scale(1) * f = amount1 +// priceAsk2 * scale(2) * f = amount2 +// priceAsk3 * scale(3) * f = amount3 +// +// totalLiquidityAmount = priceAsk1 * scale(1) * f + priceAsk2 * scale(2) * f + priceAsk3 * scale(3) * f + .... +// totalLiquidityAmount = f * (priceAsk1 * scale(1) + priceAsk2 * scale(2) + priceAsk3 * scale(3) + ....) +// f = totalLiquidityAmount / (priceAsk1 * scale(1) + priceAsk2 * scale(2) + priceAsk3 * scale(3) + ....) +// +// when side == buy +// +// priceBid1 * scale(1) * f = amount1 +type LiquidityOrderGenerator struct { + Symbol string + Market types.Market + + logger log.FieldLogger +} + +func (g *LiquidityOrderGenerator) Generate( + side types.SideType, totalAmount, startPrice, endPrice fixedpoint.Value, numLayers int, scale qbtrade.Scale, +) (orders []types.SubmitOrder) { + + if g.logger == nil { + logger := log.New() + logger.SetLevel(log.ErrorLevel) + g.logger = logger + } + + layerSpread := endPrice.Sub(startPrice).Div(fixedpoint.NewFromInt(int64(numLayers - 1))) + switch side { + case types.SideTypeSell: + if layerSpread.Compare(g.Market.TickSize) < 0 { + layerSpread = g.Market.TickSize + } + + case types.SideTypeBuy: + if layerSpread.Compare(g.Market.TickSize.Neg()) > 0 { + layerSpread = g.Market.TickSize.Neg() + } + } + + quantityBase := 0.0 + var layerPrices []fixedpoint.Value + var layerScales []float64 + for i := 0; i < numLayers; i++ { + fi := fixedpoint.NewFromInt(int64(i)) + layerPrice := g.Market.TruncatePrice(startPrice.Add(layerSpread.Mul(fi))) + layerPrices = append(layerPrices, layerPrice) + + layerScale := scale.Call(float64(i + 1)) + layerScales = append(layerScales, layerScale) + + quantityBase += layerPrice.Float64() * layerScale + } + + factor := totalAmount.Float64() / quantityBase + + g.logger.Infof("liquidity amount base: %f, factor: %f", quantityBase, factor) + + for i := 0; i < numLayers; i++ { + price := layerPrices[i] + s := layerScales[i] + + quantity := g.Market.TruncateQuantity(fixedpoint.NewFromFloat(factor * s)) + + if g.Market.IsDustQuantity(quantity, price) { + continue + } + + orders = append(orders, types.SubmitOrder{ + Symbol: g.Symbol, + Price: price, + Type: types.OrderTypeLimitMaker, + Quantity: quantity, + Side: side, + Market: g.Market, + }) + } + + return orders +} diff --git a/pkg/strategy/liquiditymaker/generator_test.go b/pkg/strategy/liquiditymaker/generator_test.go new file mode 100644 index 0000000..6619bea --- /dev/null +++ b/pkg/strategy/liquiditymaker/generator_test.go @@ -0,0 +1,114 @@ +//go:build !dnum + +package liquiditymaker + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + . "git.qtrade.icu/lychiyu/qbtrade/pkg/testing/testhelper" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func newTestMarket() types.Market { + return types.Market{ + BaseCurrency: "XML", + QuoteCurrency: "USDT", + TickSize: Number(0.0001), + StepSize: Number(0.01), + PricePrecision: 4, + VolumePrecision: 8, + MinNotional: Number(8.0), + MinQuantity: Number(40.0), + } +} + +func TestLiquidityOrderGenerator(t *testing.T) { + g := &LiquidityOrderGenerator{ + Symbol: "XMLUSDT", + Market: newTestMarket(), + } + + scale := &qbtrade.ExponentialScale{ + Domain: [2]float64{1.0, 30.0}, + Range: [2]float64{1.0, 4.0}, + } + + err := scale.Solve() + assert.NoError(t, err) + assert.InDelta(t, 1.0, scale.Call(1.0), 0.00001) + assert.InDelta(t, 4.0, scale.Call(30.0), 0.00001) + + totalAmount := Number(20_000.0) + + t.Run("ask orders", func(t *testing.T) { + orders := g.Generate(types.SideTypeSell, totalAmount, Number(2.0), Number(2.04), 30, scale) + assert.Len(t, orders, 30) + + totalQuoteQuantity := fixedpoint.NewFromInt(0) + for _, o := range orders { + totalQuoteQuantity = totalQuoteQuantity.Add(o.Quantity.Mul(o.Price)) + } + assert.InDelta(t, totalAmount.Float64(), totalQuoteQuantity.Float64(), 1.0) + + AssertOrdersPriceSideQuantity(t, []PriceSideQuantityAssert{ + {Side: types.SideTypeSell, Price: Number("2.0000"), Quantity: Number("151.34")}, + {Side: types.SideTypeSell, Price: Number("2.0013"), Quantity: Number("158.75")}, + {Side: types.SideTypeSell, Price: Number("2.0027"), Quantity: Number("166.52")}, + {Side: types.SideTypeSell, Price: Number("2.0041"), Quantity: Number("174.67")}, + {Side: types.SideTypeSell, Price: Number("2.0055"), Quantity: Number("183.23")}, + {Side: types.SideTypeSell, Price: Number("2.0068"), Quantity: Number("192.20")}, + {Side: types.SideTypeSell, Price: Number("2.0082"), Quantity: Number("201.61")}, + {Side: types.SideTypeSell, Price: Number("2.0096"), Quantity: Number("211.48")}, + {Side: types.SideTypeSell, Price: Number("2.0110"), Quantity: Number("221.84")}, + {Side: types.SideTypeSell, Price: Number("2.0124"), Quantity: Number("232.70")}, + {Side: types.SideTypeSell, Price: Number("2.0137"), Quantity: Number("244.09")}, + {Side: types.SideTypeSell, Price: Number("2.0151"), Quantity: Number("256.04")}, + {Side: types.SideTypeSell, Price: Number("2.0165"), Quantity: Number("268.58")}, + {Side: types.SideTypeSell, Price: Number("2.0179"), Quantity: Number("281.73")}, + {Side: types.SideTypeSell, Price: Number("2.0193"), Quantity: Number("295.53")}, + }, orders[0:15]) + + AssertOrdersPriceSideQuantity(t, []PriceSideQuantityAssert{ + {Side: types.SideTypeSell, Price: Number("2.0386"), Quantity: Number("577.10")}, + {Side: types.SideTypeSell, Price: Number("2.0399"), Quantity: Number("605.36")}, + }, orders[28:30]) + }) + + t.Run("bid orders", func(t *testing.T) { + orders := g.Generate(types.SideTypeBuy, totalAmount, Number(2.0), Number(1.96), 30, scale) + assert.Len(t, orders, 30) + + totalQuoteQuantity := fixedpoint.NewFromInt(0) + for _, o := range orders { + totalQuoteQuantity = totalQuoteQuantity.Add(o.Quantity.Mul(o.Price)) + } + assert.InDelta(t, totalAmount.Float64(), totalQuoteQuantity.Float64(), 1.0) + + AssertOrdersPriceSideQuantity(t, []PriceSideQuantityAssert{ + {Side: types.SideTypeBuy, Price: Number("2.0000"), Quantity: Number("155.13")}, + {Side: types.SideTypeBuy, Price: Number("1.9986"), Quantity: Number("162.73")}, + {Side: types.SideTypeBuy, Price: Number("1.9972"), Quantity: Number("170.70")}, + {Side: types.SideTypeBuy, Price: Number("1.9958"), Quantity: Number("179.05")}, + {Side: types.SideTypeBuy, Price: Number("1.9944"), Quantity: Number("187.82")}, + {Side: types.SideTypeBuy, Price: Number("1.9931"), Quantity: Number("197.02")}, + {Side: types.SideTypeBuy, Price: Number("1.9917"), Quantity: Number("206.67")}, + {Side: types.SideTypeBuy, Price: Number("1.9903"), Quantity: Number("216.79")}, + {Side: types.SideTypeBuy, Price: Number("1.9889"), Quantity: Number("227.40")}, + {Side: types.SideTypeBuy, Price: Number("1.9875"), Quantity: Number("238.54")}, + {Side: types.SideTypeBuy, Price: Number("1.9862"), Quantity: Number("250.22")}, + {Side: types.SideTypeBuy, Price: Number("1.9848"), Quantity: Number("262.47")}, + {Side: types.SideTypeBuy, Price: Number("1.9834"), Quantity: Number("275.32")}, + {Side: types.SideTypeBuy, Price: Number("1.9820"), Quantity: Number("288.80")}, + {Side: types.SideTypeBuy, Price: Number("1.9806"), Quantity: Number("302.94")}, + }, orders[0:15]) + + AssertOrdersPriceSideQuantity(t, []PriceSideQuantityAssert{ + {Side: types.SideTypeBuy, Price: Number("1.9613"), Quantity: Number("591.58")}, + {Side: types.SideTypeBuy, Price: Number("1.9600"), Quantity: Number("620.54")}, + }, orders[28:30]) + }) +} diff --git a/pkg/strategy/liquiditymaker/strategy.go b/pkg/strategy/liquiditymaker/strategy.go new file mode 100644 index 0000000..a5c0589 --- /dev/null +++ b/pkg/strategy/liquiditymaker/strategy.go @@ -0,0 +1,385 @@ +package liquiditymaker + +import ( + "context" + "fmt" + "sync" + + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + . "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator/v2" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/common" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util/tradingutil" +) + +const ID = "liquiditymaker" + +type advancedOrderCancelApi interface { + CancelAllOrders(ctx context.Context) ([]types.Order, error) + CancelOrdersBySymbol(ctx context.Context, symbol string) ([]types.Order, error) +} + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +// Strategy is the strategy struct of LiquidityMaker +// liquidity maker does not care about the current price, it tries to place liquidity orders (limit maker orders) +// around the current mid price +// liquidity maker's target: +// - place enough total liquidity amount on the order book, for example, 20k USDT value liquidity on both sell and buy +// - ensure the spread by placing the orders from the mid price (or the last trade price) +type Strategy struct { + *common.Strategy + + Environment *qbtrade.Environment + Market types.Market + + Symbol string `json:"symbol"` + + LiquidityUpdateInterval types.Interval `json:"liquidityUpdateInterval"` + + AdjustmentUpdateInterval types.Interval `json:"adjustmentUpdateInterval"` + MaxAdjustmentOrderQuantity fixedpoint.Value `json:"maxAdjustmentOrderQuantity"` + + NumOfLiquidityLayers int `json:"numOfLiquidityLayers"` + LiquiditySlideRule *qbtrade.SlideRule `json:"liquidityScale"` + LiquidityPriceRange fixedpoint.Value `json:"liquidityPriceRange"` + AskLiquidityAmount fixedpoint.Value `json:"askLiquidityAmount"` + BidLiquidityAmount fixedpoint.Value `json:"bidLiquidityAmount"` + + UseProtectedPriceRange bool `json:"useProtectedPriceRange"` + + UseLastTradePrice bool `json:"useLastTradePrice"` + Spread fixedpoint.Value `json:"spread"` + MaxPrice fixedpoint.Value `json:"maxPrice"` + MinPrice fixedpoint.Value `json:"minPrice"` + + MaxExposure fixedpoint.Value `json:"maxExposure"` + + MinProfit fixedpoint.Value `json:"minProfit"` + + liquidityOrderBook, adjustmentOrderBook *qbtrade.ActiveOrderBook + + liquidityScale qbtrade.Scale + + orderGenerator *LiquidityOrderGenerator +} + +func (s *Strategy) Initialize() error { + if s.Strategy == nil { + s.Strategy = &common.Strategy{} + } + return nil +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s", ID, s.Symbol) +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.AdjustmentUpdateInterval}) + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.LiquidityUpdateInterval}) +} + +func (s *Strategy) Run(ctx context.Context, _ qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + s.Strategy.Initialize(ctx, s.Environment, session, s.Market, ID, s.InstanceID()) + + s.orderGenerator = &LiquidityOrderGenerator{ + Symbol: s.Symbol, + Market: s.Market, + } + + s.liquidityOrderBook = qbtrade.NewActiveOrderBook(s.Symbol) + s.liquidityOrderBook.BindStream(session.UserDataStream) + + s.adjustmentOrderBook = qbtrade.NewActiveOrderBook(s.Symbol) + s.adjustmentOrderBook.BindStream(session.UserDataStream) + + scale, err := s.LiquiditySlideRule.Scale() + if err != nil { + return err + } + + if err := scale.Solve(); err != nil { + return err + } + + if cancelApi, ok := session.Exchange.(advancedOrderCancelApi); ok { + _, _ = cancelApi.CancelOrdersBySymbol(ctx, s.Symbol) + } + + s.liquidityScale = scale + + session.UserDataStream.OnStart(func() { + s.placeLiquidityOrders(ctx) + }) + + session.MarketDataStream.OnKLineClosed(func(k types.KLine) { + if k.Interval == s.AdjustmentUpdateInterval { + s.placeAdjustmentOrders(ctx) + } + + if k.Interval == s.LiquidityUpdateInterval { + s.placeLiquidityOrders(ctx) + } + }) + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + if err := s.liquidityOrderBook.GracefulCancel(ctx, s.Session.Exchange); err != nil { + util.LogErr(err, "unable to cancel liquidity orders") + } + + if err := s.adjustmentOrderBook.GracefulCancel(ctx, s.Session.Exchange); err != nil { + util.LogErr(err, "unable to cancel adjustment orders") + } + + if err := tradingutil.UniversalCancelAllOrders(ctx, s.Session.Exchange, nil); err != nil { + util.LogErr(err, "unable to cancel all orders") + } + + qbtrade.Sync(ctx, s) + }) + + return nil +} + +func (s *Strategy) placeAdjustmentOrders(ctx context.Context) { + _ = s.adjustmentOrderBook.GracefulCancel(ctx, s.Session.Exchange) + + if s.Position.IsDust() { + return + } + + ticker, err := s.Session.Exchange.QueryTicker(ctx, s.Symbol) + if util.LogErr(err, "unable to query ticker") { + return + } + + if _, err := s.Session.UpdateAccount(ctx); err != nil { + util.LogErr(err, "unable to update account") + return + } + + baseBal, _ := s.Session.Account.Balance(s.Market.BaseCurrency) + quoteBal, _ := s.Session.Account.Balance(s.Market.QuoteCurrency) + + var adjOrders []types.SubmitOrder + + posSize := s.Position.Base.Abs() + + if !s.MaxAdjustmentOrderQuantity.IsZero() { + posSize = fixedpoint.Min(posSize, s.MaxAdjustmentOrderQuantity) + } + + tickSize := s.Market.TickSize + + if s.Position.IsShort() { + price := profitProtectedPrice(types.SideTypeBuy, s.Position.AverageCost, ticker.Sell.Add(tickSize.Neg()), s.Session.MakerFeeRate, s.MinProfit) + quoteQuantity := fixedpoint.Min(price.Mul(posSize), quoteBal.Available) + bidQuantity := quoteQuantity.Div(price) + + if s.Market.IsDustQuantity(bidQuantity, price) { + return + } + + adjOrders = append(adjOrders, types.SubmitOrder{ + Symbol: s.Symbol, + Type: types.OrderTypeLimitMaker, + Side: types.SideTypeBuy, + Price: price, + Quantity: bidQuantity, + Market: s.Market, + TimeInForce: types.TimeInForceGTC, + }) + } else if s.Position.IsLong() { + price := profitProtectedPrice(types.SideTypeSell, s.Position.AverageCost, ticker.Buy.Add(tickSize), s.Session.MakerFeeRate, s.MinProfit) + askQuantity := fixedpoint.Min(posSize, baseBal.Available) + + if s.Market.IsDustQuantity(askQuantity, price) { + return + } + + adjOrders = append(adjOrders, types.SubmitOrder{ + Symbol: s.Symbol, + Type: types.OrderTypeLimitMaker, + Side: types.SideTypeSell, + Price: price, + Quantity: askQuantity, + Market: s.Market, + TimeInForce: types.TimeInForceGTC, + }) + } + + createdOrders, err := s.OrderExecutor.SubmitOrders(ctx, adjOrders...) + if util.LogErr(err, "unable to place liquidity orders") { + return + } + + s.adjustmentOrderBook.Add(createdOrders...) +} + +func (s *Strategy) placeLiquidityOrders(ctx context.Context) { + err := s.liquidityOrderBook.GracefulCancel(ctx, s.Session.Exchange) + if util.LogErr(err, "unable to cancel orders") { + return + } + + ticker, err := s.Session.Exchange.QueryTicker(ctx, s.Symbol) + if util.LogErr(err, "unable to query ticker") { + return + } + + if s.IsHalted(ticker.Time) { + log.Warn("circuitBreakRiskControl: trading halted") + return + } + + if _, err := s.Session.UpdateAccount(ctx); err != nil { + util.LogErr(err, "unable to update account") + return + } + + baseBal, _ := s.Session.Account.Balance(s.Market.BaseCurrency) + quoteBal, _ := s.Session.Account.Balance(s.Market.QuoteCurrency) + + if ticker.Buy.IsZero() && ticker.Sell.IsZero() { + ticker.Sell = ticker.Last.Add(s.Market.TickSize) + ticker.Buy = ticker.Last.Sub(s.Market.TickSize) + } else if ticker.Buy.IsZero() { + ticker.Buy = ticker.Sell.Sub(s.Market.TickSize) + } else if ticker.Sell.IsZero() { + ticker.Sell = ticker.Buy.Add(s.Market.TickSize) + } + + log.Infof("ticker: %+v", ticker) + + lastTradedPrice := ticker.Last + midPrice := ticker.Sell.Add(ticker.Buy).Div(fixedpoint.Two) + currentSpread := ticker.Sell.Sub(ticker.Buy) + sideSpread := s.Spread.Div(fixedpoint.Two) + + if s.UseLastTradePrice && !lastTradedPrice.IsZero() { + midPrice = lastTradedPrice + } + + log.Infof("current spread: %f lastTradedPrice: %f midPrice: %f", currentSpread.Float64(), lastTradedPrice.Float64(), midPrice.Float64()) + + ask1Price := midPrice.Mul(fixedpoint.One.Add(sideSpread)) + bid1Price := midPrice.Mul(fixedpoint.One.Sub(sideSpread)) + + askLastPrice := midPrice.Mul(fixedpoint.One.Add(s.LiquidityPriceRange)) + bidLastPrice := midPrice.Mul(fixedpoint.One.Sub(s.LiquidityPriceRange)) + log.Infof("wanted side spread: %f askRange: %f ~ %f bidRange: %f ~ %f", + sideSpread.Float64(), + ask1Price.Float64(), askLastPrice.Float64(), + bid1Price.Float64(), bidLastPrice.Float64()) + + availableBase := baseBal.Available + availableQuote := quoteBal.Available + + log.Infof("balances before liq orders: %s, %s", + baseBal.String(), + quoteBal.String()) + + if !s.Position.IsDust() { + if s.Position.IsLong() { + availableBase = availableBase.Sub(s.Position.Base) + availableBase = s.Market.RoundDownQuantityByPrecision(availableBase) + + if s.UseProtectedPriceRange { + ask1Price = profitProtectedPrice(types.SideTypeSell, s.Position.AverageCost, ask1Price, s.Session.MakerFeeRate, s.MinProfit) + } + } else if s.Position.IsShort() { + posSizeInQuote := s.Position.Base.Mul(ticker.Sell) + availableQuote = availableQuote.Sub(posSizeInQuote) + + if s.UseProtectedPriceRange { + bid1Price = profitProtectedPrice(types.SideTypeBuy, s.Position.AverageCost, bid1Price, s.Session.MakerFeeRate, s.MinProfit) + } + } + } + + bidOrders := s.orderGenerator.Generate(types.SideTypeBuy, + fixedpoint.Min(s.BidLiquidityAmount, quoteBal.Available), + bid1Price, + bidLastPrice, + s.NumOfLiquidityLayers, + s.liquidityScale) + + askOrders := s.orderGenerator.Generate(types.SideTypeSell, + s.AskLiquidityAmount, + ask1Price, + askLastPrice, + s.NumOfLiquidityLayers, + s.liquidityScale) + + askOrders = filterAskOrders(askOrders, baseBal.Available) + + orderForms := append(bidOrders, askOrders...) + + createdOrders, err := s.OrderExecutor.SubmitOrders(ctx, orderForms...) + if util.LogErr(err, "unable to place liquidity orders") { + return + } + + s.liquidityOrderBook.Add(createdOrders...) + log.Infof("%d liq orders are placed successfully", len(orderForms)) + for _, o := range createdOrders { + log.Infof("liq order: %+v", o) + } +} + +func profitProtectedPrice( + side types.SideType, averageCost, price, feeRate, minProfit fixedpoint.Value, +) fixedpoint.Value { + switch side { + case types.SideTypeSell: + minProfitPrice := averageCost.Add( + averageCost.Mul(feeRate.Add(minProfit))) + return fixedpoint.Max(minProfitPrice, price) + + case types.SideTypeBuy: + minProfitPrice := averageCost.Sub( + averageCost.Mul(feeRate.Add(minProfit))) + return fixedpoint.Min(minProfitPrice, price) + + } + return price +} + +func filterAskOrders(askOrders []types.SubmitOrder, available fixedpoint.Value) (out []types.SubmitOrder) { + usedBase := fixedpoint.Zero + for _, askOrder := range askOrders { + if usedBase.Add(askOrder.Quantity).Compare(available) > 0 { + return out + } + + usedBase = usedBase.Add(askOrder.Quantity) + out = append(out, askOrder) + } + + return out +} + +func preloadKLines( + inc *KLineStream, session *qbtrade.ExchangeSession, symbol string, interval types.Interval, +) { + if store, ok := session.MarketDataStore(symbol); ok { + if kLinesData, ok := store.KLinesOfInterval(interval); ok { + for _, k := range *kLinesData { + inc.EmitUpdate(k) + } + } + } +} diff --git a/pkg/strategy/marketcap/strategy.go b/pkg/strategy/marketcap/strategy.go new file mode 100644 index 0000000..8935650 --- /dev/null +++ b/pkg/strategy/marketcap/strategy.go @@ -0,0 +1,269 @@ +package marketcap + +import ( + "context" + "fmt" + "os" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datasource/coinmarketcap" + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ID = "marketcap" + +var log = logrus.WithField("strategy", ID) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + datasource *coinmarketcap.DataSource + + // interval to rebalance the portfolio + Interval types.Interval `json:"interval"` + QuoteCurrency string `json:"quoteCurrency"` + QuoteCurrencyWeight fixedpoint.Value `json:"quoteCurrencyWeight"` + BaseCurrencies []string `json:"baseCurrencies"` + Threshold fixedpoint.Value `json:"threshold"` + // max amount to buy or sell per order + MaxAmount fixedpoint.Value `json:"maxAmount"` + // interval to query marketcap data from coinmarketcap + QueryInterval types.Interval `json:"queryInterval"` + OrderType types.OrderType `json:"orderType"` + DryRun bool `json:"dryRun"` + + subscribeSymbol string + activeOrderBook *qbtrade.ActiveOrderBook + targetWeights types.ValueMap +} + +func (s *Strategy) Defaults() error { + if s.OrderType == "" { + s.OrderType = types.OrderTypeLimitMaker + } + return nil +} + +func (s *Strategy) Initialize() error { + apiKey := os.Getenv("COINMARKETCAP_API_KEY") + s.datasource = coinmarketcap.New(apiKey) + + // select one symbol to subscribe + s.subscribeSymbol = s.BaseCurrencies[0] + s.QuoteCurrency + + s.activeOrderBook = qbtrade.NewActiveOrderBook("") + s.targetWeights = types.ValueMap{} + return nil +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) Validate() error { + if len(s.BaseCurrencies) == 0 { + return fmt.Errorf("taretCurrencies should not be empty") + } + + for _, c := range s.BaseCurrencies { + if c == s.QuoteCurrency { + return fmt.Errorf("targetCurrencies contain baseCurrency") + } + } + + if s.Threshold.Sign() < 0 { + return fmt.Errorf("threshold should not less than 0") + } + + if s.MaxAmount.Sign() < 0 { + return fmt.Errorf("maxAmount shoud not less than 0") + } + return nil +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + symbol := s.BaseCurrencies[0] + s.QuoteCurrency + session.Subscribe(types.KLineChannel, symbol, types.SubscribeOptions{Interval: s.Interval}) + session.Subscribe(types.KLineChannel, symbol, types.SubscribeOptions{Interval: s.QueryInterval}) +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + s.activeOrderBook.BindStream(session.UserDataStream) + + s.updateTargetWeights(ctx) + session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { + if kline.Interval == s.QueryInterval { + s.updateTargetWeights(ctx) + } + + if kline.Interval == s.Interval { + s.rebalance(ctx, orderExecutor, session) + } + }) + return nil +} + +func (s *Strategy) rebalance(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) { + if err := orderExecutor.CancelOrders(ctx, s.activeOrderBook.Orders()...); err != nil { + log.WithError(err).Error("failed to cancel orders") + } + + submitOrders := s.generateSubmitOrders(ctx, session) + for _, submitOrder := range submitOrders { + log.Infof("generated submit order: %s", submitOrder.String()) + } + + if s.DryRun { + return + } + + createdOrders, err := orderExecutor.SubmitOrders(ctx, submitOrders...) + if err != nil { + log.WithError(err).Error("failed to submit orders") + return + } + + s.activeOrderBook.Add(createdOrders...) +} + +func (s *Strategy) generateSubmitOrders(ctx context.Context, session *qbtrade.ExchangeSession) (submitOrders []types.SubmitOrder) { + prices := s.prices(ctx, session) + marketValues := prices.Mul(s.quantities(session)) + currentWeights := marketValues.Normalize() + + for currency, targetWeight := range s.targetWeights { + if currency == s.QuoteCurrency { + continue + } + symbol := currency + s.QuoteCurrency + currentWeight := currentWeights[currency] + currentPrice := prices[currency] + + log.Infof("%s price: %v, current weight: %v, target weight: %v", + symbol, + currentPrice, + currentWeight, + targetWeight) + + // calculate the difference between current weight and target weight + // if the difference is less than threshold, then we will not create the order + weightDifference := targetWeight.Sub(currentWeight) + if weightDifference.Abs().Compare(s.Threshold) < 0 { + log.Infof("%s weight distance |%v - %v| = |%v| less than the threshold: %v", + symbol, + currentWeight, + targetWeight, + weightDifference, + s.Threshold) + continue + } + + quantity := weightDifference.Mul(marketValues.Sum()).Div(currentPrice) + + side := types.SideTypeBuy + if quantity.Sign() < 0 { + side = types.SideTypeSell + quantity = quantity.Abs() + } + + if s.MaxAmount.Sign() > 0 { + quantity = qbtrade.AdjustQuantityByMaxAmount(quantity, currentPrice, s.MaxAmount) + log.Infof("adjust the quantity %v (%s %s @ %v) by max amount %v", + quantity, + symbol, + side.String(), + currentPrice, + s.MaxAmount) + } + + order := types.SubmitOrder{ + Symbol: symbol, + Side: side, + Type: s.OrderType, + Quantity: quantity, + Price: currentPrice, + } + + submitOrders = append(submitOrders, order) + } + return submitOrders +} + +func (s *Strategy) updateTargetWeights(ctx context.Context) { + m := floats.Map{} + + // get marketcap from coinmarketcap + // set higher query limit to avoid target currency not in the list + marketcaps, err := s.datasource.QueryMarketCapInUSD(ctx, 100) + if err != nil { + log.WithError(err).Error("failed to query market cap") + } + + for _, currency := range s.BaseCurrencies { + m[currency] = marketcaps[currency] + } + + // normalize + m = m.Normalize() + + // rescale by 1 - baseWeight + m = m.MulScalar(1.0 - s.QuoteCurrencyWeight.Float64()) + + // append base weight + m[s.QuoteCurrency] = s.QuoteCurrencyWeight.Float64() + + // convert to types.ValueMap + for currency, weight := range m { + s.targetWeights[currency] = fixedpoint.NewFromFloat(weight) + } + + log.Infof("target weights: %v", s.targetWeights) +} + +func (s *Strategy) prices(ctx context.Context, session *qbtrade.ExchangeSession) types.ValueMap { + tickers, err := session.Exchange.QueryTickers(ctx, s.symbols()...) + if err != nil { + log.WithError(err).Error("failed to query tickers") + return nil + } + + prices := types.ValueMap{} + for _, currency := range s.BaseCurrencies { + prices[currency] = tickers[currency+s.QuoteCurrency].Last + } + + // append base currency price + prices[s.QuoteCurrency] = fixedpoint.One + + return prices +} + +func (s *Strategy) quantities(session *qbtrade.ExchangeSession) types.ValueMap { + balances := session.Account.Balances() + + quantities := types.ValueMap{} + for _, currency := range s.currencies() { + quantities[currency] = balances[currency].Total() + } + + return quantities +} + +func (s *Strategy) symbols() (symbols []string) { + for _, currency := range s.BaseCurrencies { + symbols = append(symbols, currency+s.QuoteCurrency) + } + return symbols +} + +func (s *Strategy) currencies() (currencies []string) { + currencies = append(currencies, s.BaseCurrencies...) + currencies = append(currencies, s.QuoteCurrency) + return currencies +} diff --git a/pkg/strategy/pivotshort/breaklow.go b/pkg/strategy/pivotshort/breaklow.go new file mode 100644 index 0000000..763e205 --- /dev/null +++ b/pkg/strategy/pivotshort/breaklow.go @@ -0,0 +1,286 @@ +package pivotshort + +import ( + "context" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type FakeBreakStop struct { + types.IntervalWindow +} + +// BreakLow -- when price breaks the previous pivot low, we set a trade entry +type BreakLow struct { + Symbol string + Market types.Market + types.IntervalWindow + + // FastWindow is used for fast pivot (this is to filter the nearest high/low) + FastWindow int `json:"fastWindow"` + + // Ratio is a number less than 1.0, price * ratio will be the price triggers the short order. + Ratio fixedpoint.Value `json:"ratio"` + + qbtrade.OpenPositionOptions + + // BounceRatio is a ratio used for placing the limit order sell price + // limit sell price = breakLowPrice * (1 + BounceRatio) + BounceRatio fixedpoint.Value `json:"bounceRatio"` + + StopEMA *qbtrade.StopEMA `json:"stopEMA"` + + TrendEMA *qbtrade.TrendEMA `json:"trendEMA"` + + FakeBreakStop *FakeBreakStop `json:"fakeBreakStop"` + + lastLow, lastFastLow fixedpoint.Value + + lastLowInvalidated bool + + // lastBreakLow is the low that the price just break + lastBreakLow fixedpoint.Value + + pivotLow, fastPivotLow *indicator.PivotLow + pivotLowPrices []fixedpoint.Value + + orderExecutor *qbtrade.GeneralOrderExecutor + session *qbtrade.ExchangeSession + + // StrategyController + qbtrade.StrategyController +} + +func (s *BreakLow) Subscribe(session *qbtrade.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.StopEMA != nil { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.StopEMA.Interval}) + } + + if s.TrendEMA != nil { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.TrendEMA.Interval}) + } + + if s.FakeBreakStop != nil { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.FakeBreakStop.Interval}) + } +} + +func (s *BreakLow) Bind(session *qbtrade.ExchangeSession, orderExecutor *qbtrade.GeneralOrderExecutor) { + if s.FastWindow == 0 { + s.FastWindow = 3 + } + + s.session = session + s.orderExecutor = orderExecutor + + // StrategyController + s.Status = types.StrategyStatusRunning + + position := orderExecutor.Position() + symbol := position.Symbol + standardIndicator := session.StandardIndicatorSet(s.Symbol) + + s.lastLow = fixedpoint.Zero + s.pivotLow = standardIndicator.PivotLow(s.IntervalWindow) + s.fastPivotLow = standardIndicator.PivotLow(types.IntervalWindow{ + Interval: s.Interval, + Window: s.FastWindow, // make it faster + }) + + if s.StopEMA != nil { + s.StopEMA.Bind(session, orderExecutor) + } + + if s.TrendEMA != nil { + s.TrendEMA.Bind(session, orderExecutor) + } + + // update pivot low data + session.MarketDataStream.OnStart(func() { + if s.updatePivotLow() { + qbtrade.Notify("%s new pivot low: %f", s.Symbol, s.pivotLow.Last(0)) + } + + s.pilotQuantityCalculation() + }) + + session.MarketDataStream.OnKLineClosed(types.KLineWith(symbol, s.Interval, func(kline types.KLine) { + if s.updatePivotLow() { + // when position is opened, do not send pivot low notify + if position.IsOpened(kline.Close) { + return + } + + qbtrade.Notify("%s new pivot low: %f", s.Symbol, s.pivotLow.Last(0)) + } + })) + + if s.FakeBreakStop != nil { + // if the position is already opened, and we just break the low, this checks if the kline closed above the low, + // so that we can close the position earlier + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.FakeBreakStop.Interval, func(k types.KLine) { + // make sure the position is opened, and it's a short position + if !position.IsOpened(k.Close) || !position.IsShort() { + return + } + + // make sure we recorded the last break low + if s.lastBreakLow.IsZero() { + return + } + + // the kline opened below the last break low, and closed above the last break low + if k.Open.Compare(s.lastBreakLow) < 0 && k.Close.Compare(s.lastBreakLow) > 0 { + qbtrade.Notify("kLine closed above the last break low, triggering stop earlier") + if err := s.orderExecutor.ClosePosition(context.Background(), one, "fakeBreakStop"); err != nil { + log.WithError(err).Error("position close error") + } + + // reset to zero + s.lastBreakLow = fixedpoint.Zero + } + })) + } + + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, types.Interval1m, func(kline types.KLine) { + if len(s.pivotLowPrices) == 0 || s.lastLow.IsZero() { + log.Infof("currently there is no pivot low prices, can not check break low...") + return + } + + if s.lastLowInvalidated { + log.Infof("the last low is invalidated, skip") + return + } + + previousLow := s.lastLow + ratio := fixedpoint.One.Add(s.Ratio) + breakPrice := previousLow.Mul(ratio) + + // StrategyController + if s.Status != types.StrategyStatusRunning { + return + } + + 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: + // 1) open > break price > close price + // 2) high > break price > open price and close price + // v2 + if !((openPrice.Compare(breakPrice) > 0 && closePrice.Compare(breakPrice) < 0) || + (kline.High.Compare(breakPrice) > 0 && openPrice.Compare(breakPrice) < 0 && closePrice.Compare(breakPrice) < 0)) { + return + } + + // force direction to be down + if closePrice.Compare(openPrice) >= 0 { + qbtrade.Notify("%s price %f is closed higher than the open price %f, skip this break", kline.Symbol, closePrice.Float64(), openPrice.Float64()) + // skip UP klines + return + } + + qbtrade.Notify("%s breakLow signal detected, closed price %f < breakPrice %f", kline.Symbol, closePrice.Float64(), breakPrice.Float64(), kline) + + if s.lastBreakLow.IsZero() || previousLow.Compare(s.lastBreakLow) < 0 { + s.lastBreakLow = previousLow + } + + if position.IsOpened(kline.Close) { + qbtrade.Notify("%s position is already opened, skip", s.Symbol) + return + } + + // trend EMA protection + if s.TrendEMA != nil && !s.TrendEMA.GradientAllowed() { + qbtrade.Notify("trendEMA protection: %s close price %f, gradient %f", s.Symbol, kline.Close.Float64(), s.TrendEMA.Gradient()) + return + } + + // stop EMA protection + if s.StopEMA != nil { + if !s.StopEMA.Allowed(closePrice) { + return + } + } + + ctx := context.Background() + + // graceful cancel all active orders + _ = orderExecutor.GracefulCancel(ctx) + + qbtrade.Notify("%s price %f breaks the previous low %f with ratio %f, opening short position", symbol, kline.Close.Float64(), previousLow.Float64(), s.Ratio.Float64()) + opts := s.OpenPositionOptions + opts.Short = true + opts.Price = closePrice + opts.Tags = []string{"breakLowMarket"} + if opts.LimitOrder && !s.BounceRatio.IsZero() { + opts.Price = previousLow.Mul(fixedpoint.One.Add(s.BounceRatio)) + } + + if _, err := s.orderExecutor.OpenPosition(ctx, opts); err != nil { + log.WithError(err).Errorf("failed to open short position") + } + })) +} + +func (s *BreakLow) pilotQuantityCalculation() { + if s.lastLow.IsZero() { + return + } + + log.Infof("pilot calculation for max position: last low = %f, quantity = %f, leverage = %f", + s.lastLow.Float64(), + s.Quantity.Float64(), + s.Leverage.Float64()) + + quantity, err := qbtrade.CalculateBaseQuantity(s.session, s.Market, s.lastLow, s.Quantity, s.Leverage) + if err != nil { + log.WithError(err).Errorf("quantity calculation error") + } + + if quantity.IsZero() { + log.WithError(err).Errorf("quantity is zero, can not submit order") + return + } + + qbtrade.Notify("%s %f quantity will be used for shorting", s.Symbol, quantity.Float64()) +} + +func (s *BreakLow) updatePivotLow() bool { + low := fixedpoint.NewFromFloat(s.pivotLow.Last(0)) + if low.IsZero() { + return false + } + + // if the last low is different + lastLowChanged := low.Compare(s.lastLow) != 0 + if lastLowChanged { + s.lastLow = low + s.lastLowInvalidated = false + s.pivotLowPrices = append(s.pivotLowPrices, low) + } + + fastLow := fixedpoint.NewFromFloat(s.fastPivotLow.Last(0)) + if !fastLow.IsZero() { + if fastLow.Compare(s.lastLow) < 0 { + s.lastLowInvalidated = true + lastLowChanged = false + } + s.lastFastLow = fastLow + } + + return lastLowChanged +} diff --git a/pkg/strategy/pivotshort/failedbreakhigh.go b/pkg/strategy/pivotshort/failedbreakhigh.go new file mode 100644 index 0000000..71c09e1 --- /dev/null +++ b/pkg/strategy/pivotshort/failedbreakhigh.go @@ -0,0 +1,404 @@ +package pivotshort + +import ( + "context" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type MACDDivergence struct { + *indicator.MACDConfig + PivotWindow int `json:"pivotWindow"` +} + +// FailedBreakHigh -- when price breaks the previous pivot low, we set a trade entry +type FailedBreakHigh struct { + Symbol string + Market types.Market + + // IntervalWindow is used for finding the pivot high + types.IntervalWindow + + FastWindow int + + qbtrade.OpenPositionOptions + + // BreakInterval is used for checking failed break + BreakInterval types.Interval `json:"breakInterval"` + + Enabled bool `json:"enabled"` + + // Ratio is a number less than 1.0, price * ratio will be the price triggers the short order. + Ratio fixedpoint.Value `json:"ratio"` + + // EarlyStopRatio adjusts the break high price with the given ratio + // this is for stop loss earlier if the price goes above the previous price + EarlyStopRatio fixedpoint.Value `json:"earlyStopRatio"` + + VWMA *types.IntervalWindow `json:"vwma"` + + StopEMA *qbtrade.StopEMA `json:"stopEMA"` + + TrendEMA *qbtrade.TrendEMA `json:"trendEMA"` + + MACDDivergence *MACDDivergence `json:"macdDivergence"` + + macd *indicator.MACDLegacy + + macdTopDivergence bool + + lastFailedBreakHigh, lastHigh, lastFastHigh fixedpoint.Value + lastHighInvalidated bool + pivotHighPrices []fixedpoint.Value + + pivotHigh, fastPivotHigh *indicator.PivotHigh + vwma *indicator.VWMA + + orderExecutor *qbtrade.GeneralOrderExecutor + session *qbtrade.ExchangeSession + + // StrategyController + qbtrade.StrategyController +} + +func (s *FailedBreakHigh) Subscribe(session *qbtrade.ExchangeSession) { + if s.BreakInterval == "" { + s.BreakInterval = types.Interval1m + } + + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: types.Interval1m}) + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.BreakInterval}) + + if s.StopEMA != nil { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.StopEMA.Interval}) + } + + if s.TrendEMA != nil { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.TrendEMA.Interval}) + } + + if s.MACDDivergence != nil { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.MACDDivergence.Interval}) + } +} + +func (s *FailedBreakHigh) Bind(session *qbtrade.ExchangeSession, orderExecutor *qbtrade.GeneralOrderExecutor) { + s.session = session + s.orderExecutor = orderExecutor + + if !s.Enabled { + return + } + + // set default value for StrategyController + s.Status = types.StrategyStatusRunning + + if s.FastWindow == 0 { + s.FastWindow = 3 + } + + position := orderExecutor.Position() + symbol := position.Symbol + standardIndicator := session.StandardIndicatorSet(s.Symbol) + + s.lastHigh = fixedpoint.Zero + s.pivotHigh = standardIndicator.PivotHigh(s.IntervalWindow) + s.fastPivotHigh = standardIndicator.PivotHigh(types.IntervalWindow{ + Interval: s.IntervalWindow.Interval, + Window: s.FastWindow, + }) + + // Experimental: MACD divergence detection + if s.MACDDivergence != nil { + log.Infof("MACD divergence detection is enabled") + s.macd = standardIndicator.MACD(s.MACDDivergence.IntervalWindow, s.MACDDivergence.ShortPeriod, s.MACDDivergence.LongPeriod) + s.macd.OnUpdate(func(macd, signal, histogram float64) { + log.Infof("MACD %+v: macd: %f, signal: %f histogram: %f", s.macd.IntervalWindow, macd, signal, histogram) + s.detectMacdDivergence() + }) + s.detectMacdDivergence() + } + + if s.VWMA != nil { + s.vwma = standardIndicator.VWMA(types.IntervalWindow{ + Interval: s.BreakInterval, + Window: s.VWMA.Window, + }) + } + + if s.StopEMA != nil { + s.StopEMA.Bind(session, orderExecutor) + } + + if s.TrendEMA != nil { + s.TrendEMA.Bind(session, orderExecutor) + } + + // update pivot low data + session.MarketDataStream.OnStart(func() { + if s.updatePivotHigh() { + qbtrade.Notify("%s new pivot high: %f", s.Symbol, s.pivotHigh.Last(0)) + } + + s.pilotQuantityCalculation() + }) + + session.MarketDataStream.OnKLineClosed(types.KLineWith(symbol, s.Interval, func(kline types.KLine) { + if s.updatePivotHigh() { + // when position is opened, do not send pivot low notify + if position.IsOpened(kline.Close) { + return + } + + qbtrade.Notify("%s new pivot high: %f", s.Symbol, s.pivotHigh.Last(0)) + } + })) + + // if the position is already opened, and we just break the low, this checks if the kline closed above the low, + // so that we can close the position earlier + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.BreakInterval, func(k types.KLine) { + if !s.Enabled { + return + } + + // StrategyController + if s.Status != types.StrategyStatusRunning { + return + } + + // make sure the position is opened, and it's a short position + if !position.IsOpened(k.Close) || !position.IsShort() { + return + } + + // make sure we recorded the last break low + if s.lastFailedBreakHigh.IsZero() { + return + } + + lastHigh := s.lastFastHigh + + if !s.EarlyStopRatio.IsZero() { + lastHigh = lastHigh.Mul(one.Add(s.EarlyStopRatio)) + } + + // the kline opened below the last break low, and closed above the last break low + if k.Open.Compare(lastHigh) < 0 && k.Close.Compare(lastHigh) > 0 && k.Open.Compare(k.Close) > 0 { + qbtrade.Notify("kLine closed %f above the last break high %f (ratio %f), triggering stop earlier", k.Close.Float64(), lastHigh.Float64(), s.EarlyStopRatio.Float64()) + + if err := s.orderExecutor.ClosePosition(context.Background(), one, "failedBreakHighStop"); err != nil { + log.WithError(err).Error("position close error") + } + + // reset to zero + s.lastFailedBreakHigh = fixedpoint.Zero + } + })) + + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.BreakInterval, func(kline types.KLine) { + if len(s.pivotHighPrices) == 0 || s.lastHigh.IsZero() { + log.Infof("%s currently there is no pivot high prices, can not check failed break high...", s.Symbol) + return + } + + if s.lastHighInvalidated { + log.Infof("%s last high %f is invalidated by the fast pivot", s.Symbol, s.lastHigh.Float64()) + return + } + + // StrategyController + if s.Status != types.StrategyStatusRunning { + return + } + + previousHigh := s.lastHigh + ratio := fixedpoint.One.Add(s.Ratio) + breakPrice := previousHigh.Mul(ratio) + + openPrice := kline.Open + closePrice := kline.Close + + // we need few conditions: + // 1) kline.High is higher than the previous high + // 2) kline.Close is lower than the previous high + if kline.High.Compare(breakPrice) < 0 || closePrice.Compare(breakPrice) >= 0 { + return + } + + // 3) kline.Close is lower than kline.Open + if closePrice.Compare(openPrice) > 0 { + qbtrade.Notify("the %s closed price %f is higher than the open price %f, skip failed break high short", s.Symbol, closePrice.Float64(), openPrice.Float64()) + return + } + + if s.vwma != nil { + vma := fixedpoint.NewFromFloat(s.vwma.Last(0)) + if kline.Volume.Compare(vma) < 0 { + qbtrade.Notify("%s %s kline volume %f is less than VMA %f, skip failed break high short", kline.Symbol, kline.Interval, kline.Volume.Float64(), vma.Float64()) + return + } + } + + qbtrade.Notify("%s FailedBreakHigh signal detected, closed price %f < breakPrice %f", kline.Symbol, closePrice.Float64(), breakPrice.Float64()) + + if position.IsOpened(kline.Close) { + qbtrade.Notify("%s position is already opened, skip", s.Symbol) + return + } + + // trend EMA protection + if s.TrendEMA != nil && !s.TrendEMA.GradientAllowed() { + qbtrade.Notify("trendEMA protection: close price %f, gradient %f", kline.Close.Float64(), s.TrendEMA.Gradient()) + return + } + + // stop EMA protection + if s.StopEMA != nil { + if !s.StopEMA.Allowed(closePrice) { + qbtrade.Notify("stopEMA protection: close price %f %s", kline.Close.Float64(), s.StopEMA.String()) + return + } + } + + if s.macd != nil && !s.macdTopDivergence { + qbtrade.Notify("Detected MACD top divergence") + return + } + + if s.lastFailedBreakHigh.IsZero() || previousHigh.Compare(s.lastFailedBreakHigh) < 0 { + s.lastFailedBreakHigh = previousHigh + } + + ctx := context.Background() + + qbtrade.Notify("%s price %f failed breaking the previous high %f with ratio %f, opening short position", + symbol, + kline.Close.Float64(), + previousHigh.Float64(), + s.Ratio.Float64()) + + // graceful cancel all active orders + _ = orderExecutor.GracefulCancel(ctx) + + opts := s.OpenPositionOptions + opts.Short = true + opts.Price = closePrice + opts.Tags = []string{"FailedBreakHighMarket"} + if _, err := s.orderExecutor.OpenPosition(ctx, opts); err != nil { + log.WithError(err).Errorf("failed to open short position") + } + })) +} + +func (s *FailedBreakHigh) pilotQuantityCalculation() { + if s.lastHigh.IsZero() { + return + } + + log.Infof("pilot calculation for max position: last low = %f, quantity = %f, leverage = %f", + s.lastHigh.Float64(), + s.Quantity.Float64(), + s.Leverage.Float64()) + + quantity, err := qbtrade.CalculateBaseQuantity(s.session, s.Market, s.lastHigh, s.Quantity, s.Leverage) + if err != nil { + log.WithError(err).Errorf("quantity calculation error") + } + + if quantity.IsZero() { + log.WithError(err).Errorf("quantity is zero, can not submit order") + return + } + + qbtrade.Notify("%s %f quantity will be used for failed break high short", s.Symbol, quantity.Float64()) +} + +func (s *FailedBreakHigh) detectMacdDivergence() { + if s.MACDDivergence == nil { + return + } + + // always reset the top divergence to false + s.macdTopDivergence = false + + histogramValues := s.macd.Histogram + + pivotWindow := s.MACDDivergence.PivotWindow + if pivotWindow == 0 { + pivotWindow = 3 + } + + if len(histogramValues) < pivotWindow*2 { + log.Warnf("histogram values is not enough for finding pivots, length=%d", len(histogramValues)) + return + } + + var histogramPivots floats.Slice + for i := pivotWindow; i > 0 && i < len(histogramValues); i++ { + // find positive histogram and the top + pivot, ok := floats.FindPivot(histogramValues[0:i], pivotWindow, pivotWindow, func(a, pivot float64) bool { + return pivot > 0 && pivot > a + }) + if ok { + histogramPivots = append(histogramPivots, pivot) + } + } + log.Infof("histogram pivots: %+v", histogramPivots) + + // take the last 2-3 pivots to check if there is a divergence + if len(histogramPivots) < 3 { + return + } + + histogramPivots = histogramPivots[len(histogramPivots)-3:] + minDiff := 0.01 + for i := len(histogramPivots) - 1; i > 0; i-- { + p1 := histogramPivots[i] + p2 := histogramPivots[i-1] + diff := p1 - p2 + + if diff > -minDiff || diff > minDiff { + continue + } + + // negative value = MACD top divergence + if diff < -minDiff { + log.Infof("MACD TOP DIVERGENCE DETECTED: diff %f", diff) + s.macdTopDivergence = true + } else { + s.macdTopDivergence = false + } + return + } +} + +func (s *FailedBreakHigh) updatePivotHigh() bool { + high := fixedpoint.NewFromFloat(s.pivotHigh.Last(0)) + if high.IsZero() { + return false + } + + lastHighChanged := high.Compare(s.lastHigh) != 0 + if lastHighChanged { + s.lastHigh = high + s.lastHighInvalidated = false + s.pivotHighPrices = append(s.pivotHighPrices, high) + } + + fastHigh := fixedpoint.NewFromFloat(s.fastPivotHigh.Last(0)) + if !fastHigh.IsZero() { + if fastHigh.Compare(s.lastHigh) > 0 { + // invalidate the last low + lastHighChanged = false + s.lastHighInvalidated = true + } + s.lastFastHigh = fastHigh + } + + return lastHighChanged +} diff --git a/pkg/strategy/pivotshort/resistance.go b/pkg/strategy/pivotshort/resistance.go new file mode 100644 index 0000000..ae1de8b --- /dev/null +++ b/pkg/strategy/pivotshort/resistance.go @@ -0,0 +1,207 @@ +package pivotshort + +import ( + "context" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/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"` + Leverage fixedpoint.Value `json:"leverage"` + Ratio fixedpoint.Value `json:"ratio"` + + TrendEMA *qbtrade.TrendEMA `json:"trendEMA"` + + session *qbtrade.ExchangeSession + orderExecutor *qbtrade.GeneralOrderExecutor + + resistancePivot *indicator.PivotLow + resistancePrices []float64 + currentResistancePrice fixedpoint.Value + + activeOrders *qbtrade.ActiveOrderBook + + // StrategyController + qbtrade.StrategyController +} + +func (s *ResistanceShort) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) + + if s.TrendEMA != nil { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.TrendEMA.Interval}) + } +} + +func (s *ResistanceShort) Bind(session *qbtrade.ExchangeSession, orderExecutor *qbtrade.GeneralOrderExecutor) { + if s.GroupDistance.IsZero() { + s.GroupDistance = fixedpoint.NewFromFloat(0.01) + } + + s.session = session + s.orderExecutor = orderExecutor + s.activeOrders = qbtrade.NewActiveOrderBook(s.Symbol) + s.activeOrders.OnFilled(func(o types.Order) { + // reset resistance price + s.currentResistancePrice = fixedpoint.Zero + }) + s.activeOrders.BindStream(session.UserDataStream) + + // StrategyController + s.Status = types.StrategyStatusRunning + + if s.TrendEMA != nil { + s.TrendEMA.Bind(session, orderExecutor) + } + + s.resistancePivot = session.StandardIndicatorSet(s.Symbol).PivotLow(s.IntervalWindow) + + // use the last kline from the history before we get the next closed kline + s.updateResistanceOrders(fixedpoint.NewFromFloat(s.resistancePivot.Last(0))) + + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.Interval, func(kline types.KLine) { + // StrategyController + if s.Status != types.StrategyStatusRunning { + return + } + + // trend EMA protection + if s.TrendEMA != nil && !s.TrendEMA.GradientAllowed() { + return + } + + position := s.orderExecutor.Position() + if position.IsOpened(kline.Close) { + return + } + + s.updateResistanceOrders(kline.Close) + })) +} + +// updateCurrentResistancePrice updates the current resistance price +// we should only update the resistance price when: +// 1) the close price is already above the current resistance price by (1 + minDistance) +// 2) the next resistance price is lower than the current resistance price. +func (s *ResistanceShort) updateCurrentResistancePrice(closePrice fixedpoint.Value) bool { + minDistance := s.MinDistance.Float64() + groupDistance := s.GroupDistance.Float64() + resistancePrices := findPossibleResistancePrices(closePrice.Float64()*(1.0+minDistance), groupDistance, s.resistancePivot.Values.Tail(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 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. + minPriceToUpdate := s.currentResistancePrice.Mul(one.Add(s.MinDistance)) + if closePrice.Compare(minPriceToUpdate) > 0 || nextResistancePrice.Compare(s.currentResistancePrice) < 0 { + s.currentResistancePrice = nextResistancePrice + return true + } + + return false +} + +func (s *ResistanceShort) updateResistanceOrders(closePrice fixedpoint.Value) { + ctx := context.Background() + resistanceUpdated := s.updateCurrentResistancePrice(closePrice) + if resistanceUpdated { + s.placeResistanceOrders(ctx, s.currentResistancePrice) + } else if s.activeOrders.NumOfOrders() == 0 && !s.currentResistancePrice.IsZero() { + s.placeResistanceOrders(ctx, s.currentResistancePrice) + } +} + +func (s *ResistanceShort) placeResistanceOrders(ctx context.Context, resistancePrice fixedpoint.Value) { + totalQuantity, err := qbtrade.CalculateBaseQuantity(s.session, s.Market, resistancePrice, s.Quantity, s.Leverage) + if err != nil { + log.WithError(err).Errorf("quantity calculation error") + } + + if totalQuantity.IsZero() { + return + } + + qbtrade.Notify("Next %s resistance price at %f, updating resistance orders with total quantity %f", s.Symbol, s.currentResistancePrice.Float64(), totalQuantity.Float64()) + + 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("resistance sell 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, + }) + } + + 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 floats.Group(floats.Lower(lows, closePrice), groupDistance) +} + +func findPossibleResistancePrices(closePrice float64, groupDistance float64, lows []float64) []float64 { + return floats.Group(floats.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 0000000..6a46017 --- /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 new file mode 100644 index 0000000..8468265 --- /dev/null +++ b/pkg/strategy/pivotshort/strategy.go @@ -0,0 +1,194 @@ +package pivotshort + +import ( + "context" + "fmt" + "os" + "sync" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/dynamic" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ID = "pivotshort" + +var one = fixedpoint.One + +var log = logrus.WithField("strategy", ID) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + Environment *qbtrade.Environment + Symbol string `json:"symbol"` + Market types.Market + + // pivot interval and window + types.IntervalWindow + + Leverage fixedpoint.Value `json:"leverage"` + Quantity fixedpoint.Value `json:"quantity"` + + // persistence fields + + Position *types.Position `persistence:"position"` + ProfitStats *types.ProfitStats `persistence:"profit_stats"` + TradeStats *types.TradeStats `persistence:"trade_stats"` + + // BreakLow is one of the entry method + BreakLow *BreakLow `json:"breakLow"` + FailedBreakHigh *FailedBreakHigh `json:"failedBreakHigh"` + + // ResistanceShort is one of the entry method + ResistanceShort *ResistanceShort `json:"resistanceShort"` + + ExitMethods qbtrade.ExitMethodSet `json:"exits"` + + session *qbtrade.ExchangeSession + orderExecutor *qbtrade.GeneralOrderExecutor + + // StrategyController + qbtrade.StrategyController +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s", ID, s.Symbol) +} + +func (s *Strategy) Subscribe(session *qbtrade.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.ResistanceShort != nil && s.ResistanceShort.Enabled { + dynamic.InheritStructValues(s.ResistanceShort, s) + s.ResistanceShort.Subscribe(session) + } + + if s.BreakLow != nil { + dynamic.InheritStructValues(s.BreakLow, s) + s.BreakLow.Subscribe(session) + } + + if s.FailedBreakHigh != nil { + dynamic.InheritStructValues(s.FailedBreakHigh, s) + s.FailedBreakHigh.Subscribe(session) + } + + if !qbtrade.IsBackTesting { + session.Subscribe(types.MarketTradeChannel, s.Symbol, types.SubscribeOptions{}) + } + + s.ExitMethods.SetAndSubscribe(session, s) +} + +func (s *Strategy) CurrentPosition() *types.Position { + return s.Position +} + +func (s *Strategy) ClosePosition(ctx context.Context, percentage fixedpoint.Value) error { + return s.orderExecutor.ClosePosition(ctx, percentage) +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + var instanceID = s.InstanceID() + + 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) + } + + if s.Leverage.IsZero() { + // the default leverage is 3x + s.Leverage = fixedpoint.NewFromInt(3) + } + + // StrategyController + s.Status = types.StrategyStatusRunning + + s.OnSuspend(func() { + // Cancel active orders + _ = s.orderExecutor.GracefulCancel(ctx) + + if s.BreakLow != nil { + s.BreakLow.Suspend() + } + + if s.ResistanceShort != nil { + s.ResistanceShort.Suspend() + } + + if s.FailedBreakHigh != nil { + s.FailedBreakHigh.Suspend() + } + }) + + s.OnEmergencyStop(func() { + // Cancel active orders + _ = s.orderExecutor.GracefulCancel(ctx) + // Close 100% position + _ = s.ClosePosition(ctx, fixedpoint.One) + + if s.BreakLow != nil { + s.BreakLow.EmergencyStop() + } + + if s.ResistanceShort != nil { + s.ResistanceShort.EmergencyStop() + } + + if s.FailedBreakHigh != nil { + s.FailedBreakHigh.EmergencyStop() + } + }) + + // initial required information + s.session = session + s.orderExecutor = qbtrade.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, s.Position) + s.orderExecutor.BindEnvironment(s.Environment) + s.orderExecutor.BindProfitStats(s.ProfitStats) + s.orderExecutor.BindTradeStats(s.TradeStats) + s.orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { + qbtrade.Sync(ctx, s) + }) + s.orderExecutor.Bind() + + s.ExitMethods.Bind(session, s.orderExecutor) + + if s.ResistanceShort != nil && s.ResistanceShort.Enabled { + s.ResistanceShort.Bind(session, s.orderExecutor) + } + + if s.BreakLow != nil { + s.BreakLow.Bind(session, s.orderExecutor) + } + + if s.FailedBreakHigh != nil { + s.FailedBreakHigh.Bind(session, s.orderExecutor) + } + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + _, _ = fmt.Fprintln(os.Stderr, s.TradeStats.String()) + _ = s.orderExecutor.GracefulCancel(ctx) + }) + + return nil +} diff --git a/pkg/strategy/pricealert/strategy.go b/pkg/strategy/pricealert/strategy.go new file mode 100644 index 0000000..6f98583 --- /dev/null +++ b/pkg/strategy/pricealert/strategy.go @@ -0,0 +1,48 @@ +package pricealert + +import ( + "context" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ID = "pricealert" + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + // These fields will be filled from the config file (it translates YAML to JSON) + Symbol string `json:"symbol"` + Interval types.Interval `json:"interval"` + MinChange fixedpoint.Value `json:"minChange"` +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + session.MarketDataStream.OnKLine(func(kline types.KLine) { + market, ok := session.Market(kline.Symbol) + if !ok { + return + } + + if kline.GetChange().Abs().Compare(s.MinChange) > 0 { + if channel, ok := qbtrade.Notification.RouteSymbol(s.Symbol); ok { + qbtrade.NotifyTo(channel, "%s hit price %s, change %v", s.Symbol, market.FormatPrice(kline.Close), kline.GetChange()) + } else { + qbtrade.Notify("%s hit price %s, change %v", s.Symbol, market.FormatPrice(kline.Close), kline.GetChange()) + } + } + }) + return nil +} diff --git a/pkg/strategy/pricedrop/strategy.go b/pkg/strategy/pricedrop/strategy.go new file mode 100644 index 0000000..89ae4dc --- /dev/null +++ b/pkg/strategy/pricedrop/strategy.go @@ -0,0 +1,112 @@ +package pricedrop + +import ( + "context" + "fmt" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ID = "pricedrop" + +var log = logrus.WithField("strategy", ID) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + Symbol string `json:"symbol"` + + Interval types.Interval `json:"interval"` + BaseQuantity fixedpoint.Value `json:"baseQuantity"` + MinDropPercentage fixedpoint.Value `json:"minDropPercentage"` + MinDropChange fixedpoint.Value `json:"minDropChange"` + + MovingAverageWindow int `json:"movingAverageWindow"` +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + if s.Interval == "" { + s.Interval = types.Interval5m + } + + if s.MovingAverageWindow == 0 { + s.MovingAverageWindow = 99 + } + + // buy when price drops -8% + market, ok := session.Market(s.Symbol) + if !ok { + return fmt.Errorf("market %s is not defined", s.Symbol) + } + + standardIndicatorSet := session.StandardIndicatorSet(s.Symbol) + if !ok { + return fmt.Errorf("standardIndicatorSet is nil, symbol %s", s.Symbol) + } + + var iw = types.IntervalWindow{Interval: s.Interval, Window: s.MovingAverageWindow} + var ema = standardIndicatorSet.EWMA(iw) + + session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { + // skip k-lines from other symbols + if kline.Symbol != s.Symbol { + return + } + + change := kline.GetChange() + + // skip positive change + if change.Sign() > 0 { + return + } + + if kline.Close.Float64() > ema.Last(0) { + log.Warnf("kline close price %v is above EMA %s %f", kline.Close, ema.IntervalWindow, ema.Last(0)) + return + } + + changeP := change.Div(kline.Open).Abs() + + if !s.MinDropPercentage.IsZero() { + if changeP.Compare(s.MinDropPercentage.Abs()) < 0 { + return + } + } else if !s.MinDropChange.IsZero() { + if change.Abs().Compare(s.MinDropChange.Abs()) < 0 { + return + } + } else { + // not configured, we shall skip + log.Warnf("parameters are not configured, skipping action...") + return + } + + quantity := s.BaseQuantity.Mul(fixedpoint.One.Add(changeP)) + _, err := orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ + Symbol: kline.Symbol, + Market: market, + Side: types.SideTypeBuy, + Type: types.OrderTypeMarket, + Quantity: quantity, + }) + if err != nil { + log.WithError(err).Error("submit order error") + } + }) + + return nil +} diff --git a/pkg/strategy/random/strategy.go b/pkg/strategy/random/strategy.go new file mode 100644 index 0000000..ec20ce1 --- /dev/null +++ b/pkg/strategy/random/strategy.go @@ -0,0 +1,162 @@ +package random + +import ( + "context" + "fmt" + "math/rand" + "sync" + + "github.com/robfig/cron/v3" + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/common" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ID = "random" + +var log = logrus.WithField("strategy", ID) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + *common.Strategy + + Environment *qbtrade.Environment + Market types.Market + + Symbol string `json:"symbol"` + Schedule string `json:"schedule"` + OnStart bool `json:"onStart"` + DryRun bool `json:"dryRun"` + + qbtrade.QuantityOrAmount + cron *cron.Cron +} + +func (s *Strategy) Defaults() error { + return nil +} + +func (s *Strategy) Initialize() error { + if s.Strategy == nil { + s.Strategy = &common.Strategy{} + } + return nil +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s", ID, s.Symbol) +} + +func (s *Strategy) Validate() error { + if s.Schedule == "" { + return fmt.Errorf("schedule is required") + } + + if err := s.QuantityOrAmount.Validate(); err != nil { + return err + } + return nil +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) {} + +func (s *Strategy) Run(ctx context.Context, _ qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + s.Strategy.Initialize(ctx, s.Environment, session, s.Market, s.ID(), s.InstanceID()) + + session.UserDataStream.OnStart(func() { + if s.OnStart { + s.placeOrder() + } + }) + + // the shutdown handler, you can cancel all orders + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + _ = s.OrderExecutor.GracefulCancel(ctx) + qbtrade.Sync(ctx, s) + }) + + s.cron = cron.New() + s.cron.AddFunc(s.Schedule, s.placeOrder) + s.cron.Start() + + return nil +} + +func (s *Strategy) placeOrder() { + ctx := context.Background() + + baseBalance, ok := s.Session.GetAccount().Balance(s.Market.BaseCurrency) + if !ok { + log.Errorf("base balance not found") + return + } + quoteBalance, ok := s.Session.GetAccount().Balance(s.Market.QuoteCurrency) + if !ok { + log.Errorf("quote balance not found") + return + } + + ticker, err := s.Session.Exchange.QueryTicker(ctx, s.Symbol) + if err != nil { + log.WithError(err).Error("query ticker error") + return + } + + sellQuantity := s.CalculateQuantity(ticker.Sell) + buyQuantity := s.CalculateQuantity(ticker.Buy) + sellQuantity = s.Market.AdjustQuantityByMinNotional(sellQuantity, ticker.Sell) + buyQuantity = s.Market.AdjustQuantityByMinNotional(buyQuantity, ticker.Buy) + + orderForm := []types.SubmitOrder{} + if baseBalance.Available.Compare(sellQuantity) > 0 { + orderForm = append(orderForm, types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeMarket, + Quantity: sellQuantity, + }) + } else { + log.Infof("base balance: %s is not enough", baseBalance.Available.String()) + } + + if quoteBalance.Available.Div(ticker.Buy).Compare(buyQuantity) > 0 { + orderForm = append(orderForm, types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeMarket, + Quantity: buyQuantity, + }) + } else { + log.Infof("quote balance: %s is not enough", quoteBalance.Available.String()) + } + + var order types.SubmitOrder + if len(orderForm) == 0 { + log.Infof("both base and quote balance are not enough, skip submit order") + return + } else { + order = orderForm[rand.Intn(len(orderForm))] + } + log.Infof("submit order: %s", order.String()) + + if s.DryRun { + log.Infof("dry run, skip submit order") + return + } + + _, err = s.OrderExecutor.SubmitOrders(ctx, order) + if err != nil { + log.WithError(err).Error("submit order error") + return + } +} diff --git a/pkg/strategy/rebalance/multi_market_strategy.go b/pkg/strategy/rebalance/multi_market_strategy.go new file mode 100644 index 0000000..0f92d44 --- /dev/null +++ b/pkg/strategy/rebalance/multi_market_strategy.go @@ -0,0 +1,68 @@ +package rebalance + +import ( + "context" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type PositionMap map[string]*types.Position +type ProfitStatsMap map[string]*types.ProfitStats + +type MultiMarketStrategy struct { + Environ *qbtrade.Environment + Session *qbtrade.ExchangeSession + + PositionMap PositionMap `persistence:"position_map"` + ProfitStatsMap ProfitStatsMap `persistence:"profit_stats_map"` + OrderExecutorMap GeneralOrderExecutorMap + + parent, ctx context.Context + cancel context.CancelFunc +} + +func (s *MultiMarketStrategy) Initialize(ctx context.Context, environ *qbtrade.Environment, session *qbtrade.ExchangeSession, markets map[string]types.Market, strategyID string, instanceID string) { + s.parent = ctx + s.ctx, s.cancel = context.WithCancel(ctx) + + s.Environ = environ + s.Session = session + + // initialize position map + if s.PositionMap == nil { + log.Infof("creating position map") + s.PositionMap = make(PositionMap) + } + for symbol, market := range markets { + if _, ok := s.PositionMap[symbol]; ok { + continue + } + + log.Infof("creating position for symbol %s", symbol) + position := types.NewPositionFromMarket(market) + position.Strategy = ID + position.StrategyInstanceID = instanceID + s.PositionMap[symbol] = position + } + + // initialize profit stats map + if s.ProfitStatsMap == nil { + log.Infof("creating profit stats map") + s.ProfitStatsMap = make(ProfitStatsMap) + } + for symbol, market := range markets { + if _, ok := s.ProfitStatsMap[symbol]; ok { + continue + } + + log.Infof("creating profit stats for symbol %s", symbol) + s.ProfitStatsMap[symbol] = types.NewProfitStats(market) + } + + // initialize order executor map + s.OrderExecutorMap = NewGeneralOrderExecutorMap(session, strategyID, instanceID, s.PositionMap) + s.OrderExecutorMap.BindEnvironment(environ) + s.OrderExecutorMap.BindProfitStats(s.ProfitStatsMap) + s.OrderExecutorMap.Bind() +} diff --git a/pkg/strategy/rebalance/order_executor_map.go b/pkg/strategy/rebalance/order_executor_map.go new file mode 100644 index 0000000..5a7e91b --- /dev/null +++ b/pkg/strategy/rebalance/order_executor_map.go @@ -0,0 +1,71 @@ +package rebalance + +import ( + "context" + "fmt" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type GeneralOrderExecutorMap map[string]*qbtrade.GeneralOrderExecutor + +func NewGeneralOrderExecutorMap(session *qbtrade.ExchangeSession, strategyID string, instanceID string, positionMap PositionMap) GeneralOrderExecutorMap { + m := make(GeneralOrderExecutorMap) + + for symbol, position := range positionMap { + log.Infof("creating order executor for symbol %s", symbol) + orderExecutor := qbtrade.NewGeneralOrderExecutor(session, symbol, strategyID, instanceID, position) + m[symbol] = orderExecutor + } + + return m +} + +func (m GeneralOrderExecutorMap) BindEnvironment(environ *qbtrade.Environment) { + for _, orderExecutor := range m { + orderExecutor.BindEnvironment(environ) + } +} + +func (m GeneralOrderExecutorMap) BindProfitStats(profitStatsMap ProfitStatsMap) { + for symbol, orderExecutor := range m { + log.Infof("binding profit stats for symbol %s", symbol) + orderExecutor.BindProfitStats(profitStatsMap[symbol]) + } +} + +func (m GeneralOrderExecutorMap) Bind() { + for _, orderExecutor := range m { + orderExecutor.Bind() + } +} + +func (m GeneralOrderExecutorMap) SubmitOrders(ctx context.Context, submitOrders ...types.SubmitOrder) (types.OrderSlice, error) { + var allCreatedOrders types.OrderSlice + for _, submitOrder := range submitOrders { + log.Infof("submitting order: %+v", submitOrder) + orderExecutor, ok := m[submitOrder.Symbol] + if !ok { + return nil, fmt.Errorf("order executor not found for symbol %s", submitOrder.Symbol) + } + + createdOrders, err := orderExecutor.SubmitOrders(ctx, submitOrder) + if err != nil { + return nil, err + } + allCreatedOrders = append(allCreatedOrders, createdOrders...) + } + + return allCreatedOrders, nil +} + +func (m GeneralOrderExecutorMap) GracefulCancel(ctx context.Context) error { + for _, orderExecutor := range m { + err := orderExecutor.GracefulCancel(ctx) + if err != nil { + return err + } + } + return nil +} diff --git a/pkg/strategy/rebalance/strategy.go b/pkg/strategy/rebalance/strategy.go new file mode 100644 index 0000000..fad5431 --- /dev/null +++ b/pkg/strategy/rebalance/strategy.go @@ -0,0 +1,301 @@ +package rebalance + +import ( + "context" + "fmt" + "sync" + + "github.com/robfig/cron/v3" + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ID = "rebalance" + +var log = logrus.WithField("strategy", ID) +var two = fixedpoint.NewFromFloat(2.0) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + *MultiMarketStrategy + + Environment *qbtrade.Environment + + Schedule string `json:"schedule"` + QuoteCurrency string `json:"quoteCurrency"` + TargetWeights types.ValueMap `json:"targetWeights"` + Threshold fixedpoint.Value `json:"threshold"` + MaxAmount fixedpoint.Value `json:"maxAmount"` // max amount to buy or sell per order + OrderType types.OrderType `json:"orderType"` + PriceType types.PriceType `json:"priceType"` + BalanceType types.BalanceType `json:"balanceType"` + DryRun bool `json:"dryRun"` + OnStart bool `json:"onStart"` // rebalance on start + + symbols []string + markets map[string]types.Market + activeOrderBook *qbtrade.ActiveOrderBook + cron *cron.Cron +} + +func (s *Strategy) Defaults() error { + if s.OrderType == "" { + s.OrderType = types.OrderTypeLimitMaker + } + + if s.PriceType == "" { + s.PriceType = types.PriceTypeMaker + } + + if s.BalanceType == "" { + s.BalanceType = types.BalanceTypeAvailable + } + return nil +} + +func (s *Strategy) Initialize() error { + if s.MultiMarketStrategy == nil { + s.MultiMarketStrategy = &MultiMarketStrategy{} + } + + for currency := range s.TargetWeights { + if currency == s.QuoteCurrency { + continue + } + + s.symbols = append(s.symbols, currency+s.QuoteCurrency) + } + return nil +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return ID +} + +func (s *Strategy) Validate() error { + if len(s.TargetWeights) == 0 { + return fmt.Errorf("targetWeights should not be empty") + } + + if !s.TargetWeights.Sum().Eq(fixedpoint.One) { + return fmt.Errorf("the sum of targetWeights should be 1") + } + + for currency, weight := range s.TargetWeights { + if weight.Float64() < 0 { + return fmt.Errorf("%s weight: %f should not less than 0", currency, weight.Float64()) + } + } + + if s.Threshold.Sign() < 0 { + return fmt.Errorf("threshold should not less than 0") + } + + if s.MaxAmount.Sign() < 0 { + return fmt.Errorf("maxAmount shoud not less than 0") + } + return nil +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) {} + +func (s *Strategy) Run(ctx context.Context, _ qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + s.markets = make(map[string]types.Market) + for _, symbol := range s.symbols { + market, ok := session.Market(symbol) + if !ok { + return fmt.Errorf("market %s not found", symbol) + } + s.markets[symbol] = market + } + + s.MultiMarketStrategy.Initialize(ctx, s.Environment, session, s.markets, ID, s.InstanceID()) + + s.activeOrderBook = qbtrade.NewActiveOrderBook("") + s.activeOrderBook.BindStream(session.UserDataStream) + s.activeOrderBook.OnFilled(func(order types.Order) { + s.rebalance(ctx) + }) + + session.UserDataStream.OnStart(func() { + if s.OnStart { + s.rebalance(ctx) + } + }) + + // the shutdown handler, you can cancel all orders + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + _ = s.OrderExecutorMap.GracefulCancel(ctx) + qbtrade.Sync(ctx, s) + }) + + s.cron = cron.New() + s.cron.AddFunc(s.Schedule, func() { + s.rebalance(ctx) + }) + s.cron.Start() + + return nil +} + +func (s *Strategy) rebalance(ctx context.Context) { + // cancel active orders before rebalance + if err := s.activeOrderBook.GracefulCancel(ctx, s.Session.Exchange); err != nil { + log.WithError(err).Errorf("failed to cancel orders") + } + + order, err := s.generateOrder(ctx) + if err != nil { + log.WithError(err).Error("failed to generate order") + return + } + + if order == nil { + log.Info("no order generated") + return + } + log.Infof("generated order: %s", order.String()) + + if s.DryRun { + log.Infof("dry run, not submitting orders") + return + } + + createdOrders, err := s.OrderExecutorMap.SubmitOrders(ctx, *order) + if err != nil { + log.WithError(err).Error("failed to submit orders") + return + } + s.activeOrderBook.Add(createdOrders...) +} + +func (s *Strategy) queryMidPrices(ctx context.Context) (types.ValueMap, error) { + m := make(types.ValueMap) + for currency := range s.TargetWeights { + if currency == s.QuoteCurrency { + m[s.QuoteCurrency] = fixedpoint.One + continue + } + + ticker, err := s.Session.Exchange.QueryTicker(ctx, currency+s.QuoteCurrency) + if err != nil { + return nil, err + } + + m[currency] = ticker.Buy.Add(ticker.Sell).Div(two) + } + return m, nil +} + +func (s *Strategy) selectBalances() (types.BalanceMap, error) { + m := make(types.BalanceMap) + balances := s.Session.GetAccount().Balances() + for currency := range s.TargetWeights { + balance, ok := balances[currency] + if !ok { + return nil, fmt.Errorf("no balance for %s", currency) + } + m[currency] = balance + } + return m, nil +} + +func (s *Strategy) generateOrder(ctx context.Context) (*types.SubmitOrder, error) { + prices, err := s.queryMidPrices(ctx) + if err != nil { + return nil, err + } + + balances, err := s.selectBalances() + if err != nil { + return nil, err + } + + values := prices.Mul(s.toValueMap(balances)) + weights := values.Normalize() + + for symbol, market := range s.markets { + target := s.TargetWeights[market.BaseCurrency] + weight := weights[market.BaseCurrency] + midPrice := prices[market.BaseCurrency] + + log.Infof("%s mid price: %s", symbol, midPrice.String()) + log.Infof("%s weight: %.2f%%, target: %.2f%%", market.BaseCurrency, weight.Float64()*100, target.Float64()*100) + + // calculate the difference between current weight and target weight + // if the difference is less than threshold, then we will not create the order + diff := target.Sub(weight) + if diff.Abs().Compare(s.Threshold) < 0 { + log.Infof("%s weight is close to target, skip", market.BaseCurrency) + continue + } + + quantity := diff.Mul(values.Sum()).Div(midPrice) + + side := types.SideTypeBuy + if quantity.Sign() < 0 { + side = types.SideTypeSell + quantity = quantity.Abs() + } + + ticker, err := s.Session.Exchange.QueryTicker(ctx, symbol) + if err != nil { + return nil, err + } + + if side == types.SideTypeBuy { + quantity = fixedpoint.Min(quantity, balances[s.QuoteCurrency].Available.Div(ticker.Sell)) + } else if side == types.SideTypeSell { + quantity = fixedpoint.Min(quantity, balances[market.BaseCurrency].Available) + } + + price := s.PriceType.Map(ticker, side) + + if s.MaxAmount.Float64() > 0 { + quantity = qbtrade.AdjustQuantityByMaxAmount(quantity, price, s.MaxAmount) + log.Infof("adjusted quantity %s (%s %s @ %s) by max amount %s", + quantity.String(), + symbol, + side.String(), + price.String(), + s.MaxAmount.String()) + } + + if market.IsDustQuantity(quantity, price) { + log.Infof("quantity %s (%s %s @ %s) is dust quantity, skip", + quantity.String(), + symbol, + side.String(), + price.String()) + continue + } + + return &types.SubmitOrder{ + Symbol: symbol, + Side: side, + Type: s.OrderType, + Quantity: quantity, + Price: price, + }, nil + } + return nil, nil +} + +func (s *Strategy) toValueMap(balances types.BalanceMap) types.ValueMap { + m := make(types.ValueMap) + for _, b := range balances { + m[b.Currency] = s.BalanceType.Map(b) + } + return m +} diff --git a/pkg/strategy/rsicross/strategy.go b/pkg/strategy/rsicross/strategy.go new file mode 100644 index 0000000..21594bb --- /dev/null +++ b/pkg/strategy/rsicross/strategy.go @@ -0,0 +1,102 @@ +package rsicross + +import ( + "context" + "fmt" + "sync" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + indicatorv2 "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator/v2" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/common" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +const ID = "rsicross" + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + *common.Strategy + + Environment *qbtrade.Environment + Market types.Market + + Symbol string `json:"symbol"` + Interval types.Interval `json:"interval"` + SlowWindow int `json:"slowWindow"` + FastWindow int `json:"fastWindow"` + OpenBelow fixedpoint.Value `json:"openBelow"` + CloseAbove fixedpoint.Value `json:"closeAbove"` + + qbtrade.OpenPositionOptions +} + +func (s *Strategy) Initialize() error { + if s.Strategy == nil { + s.Strategy = &common.Strategy{} + } + + return nil +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s:%s:%d-%d", ID, s.Symbol, s.Interval, s.FastWindow, s.SlowWindow) +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + s.Strategy.Initialize(ctx, s.Environment, session, s.Market, ID, s.InstanceID()) + + fastRsi := session.Indicators(s.Symbol).RSI(types.IntervalWindow{Interval: s.Interval, Window: s.FastWindow}) + slowRsi := session.Indicators(s.Symbol).RSI(types.IntervalWindow{Interval: s.Interval, Window: s.SlowWindow}) + rsiCross := indicatorv2.Cross(fastRsi, slowRsi) + rsiCross.OnUpdate(func(v float64) { + switch indicatorv2.CrossType(v) { + case indicatorv2.CrossOver: + if s.OpenBelow.Sign() > 0 && fastRsi.Last(0) > s.OpenBelow.Float64() { + return + } + + opts := s.OpenPositionOptions + opts.Long = true + + if price, ok := session.LastPrice(s.Symbol); ok { + opts.Price = price + } + + // opts.Price = closePrice + opts.Tags = []string{"rsiCrossOver"} + if _, err := s.OrderExecutor.OpenPosition(ctx, opts); err != nil { + util.LogErr(err, "unable to open position") + } + + case indicatorv2.CrossUnder: + if s.CloseAbove.Sign() > 0 && fastRsi.Last(0) < s.CloseAbove.Float64() { + return + } + + if err := s.OrderExecutor.ClosePosition(ctx, fixedpoint.One); err != nil { + util.LogErr(err, "failed to close position") + } + + } + }) + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + qbtrade.Sync(ctx, s) + }) + + return nil +} diff --git a/pkg/strategy/rsmaker/strategy.go b/pkg/strategy/rsmaker/strategy.go new file mode 100644 index 0000000..06eeaee --- /dev/null +++ b/pkg/strategy/rsmaker/strategy.go @@ -0,0 +1,470 @@ +package rsmaker + +import ( + "context" + "fmt" + "math" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "github.com/muesli/clusters" + "github.com/muesli/kmeans" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ID = "rsmaker" + +var notionModifier = fixedpoint.NewFromFloat(1.1) + +var log = logrus.WithField("strategy", ID) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + Environment *qbtrade.Environment + StandardIndicatorSet *qbtrade.StandardIndicatorSet + Market types.Market + + // Symbol is the market symbol you want to trade + Symbol string `json:"symbol"` + + // Interval is how long do you want to update your order price and quantity + Interval types.Interval `json:"interval"` + + qbtrade.QuantityOrAmount + + // Spread is the price spread from the middle price. + // For ask orders, the ask price is ((bestAsk + bestBid) / 2 * (1.0 + spread)) + // For bid orders, the bid price is ((bestAsk + bestBid) / 2 * (1.0 - spread)) + // Spread can be set by percentage or floating number. e.g., 0.1% or 0.001 + Spread fixedpoint.Value `json:"spread"` + + // BidSpread overrides the spread setting, this spread will be used for the buy order + BidSpread fixedpoint.Value `json:"bidSpread,omitempty"` + + // AskSpread overrides the spread setting, this spread will be used for the sell order + AskSpread fixedpoint.Value `json:"askSpread,omitempty"` + + // MinProfitSpread is the minimal order price spread from the current average cost. + // For long position, you will only place sell order above the price (= average cost * (1 + minProfitSpread)) + // For short position, you will only place buy order below the price (= average cost * (1 - minProfitSpread)) + MinProfitSpread fixedpoint.Value `json:"minProfitSpread"` + + // UseTickerPrice use the ticker api to get the mid price instead of the closed kline price. + // The back-test engine is kline-based, so the ticker price api is not supported. + // Turn this on if you want to do real trading. + UseTickerPrice bool `json:"useTickerPrice"` + + // MaxExposurePosition is the maximum position you can hold + // +10 means you can hold 10 ETH long position by maximum + // -10 means you can hold -10 ETH short position by maximum + MaxExposurePosition fixedpoint.Value `json:"maxExposurePosition"` + + // DynamicExposurePositionScale is used to define the exposure position range with the given percentage + // when DynamicExposurePositionScale is set, + // your MaxExposurePosition will be calculated dynamically according to the bollinger band you set. + DynamicExposurePositionScale *qbtrade.PercentageScale `json:"dynamicExposurePositionScale"` + + // Long means your position will be long position + // Currently not used yet + Long *bool `json:"long,omitempty"` + + // Short means your position will be long position + // Currently not used yet + Short *bool `json:"short,omitempty"` + + // DisableShort means you can don't want short position during the market making + // Set to true if you want to hold more spot during market making. + DisableShort bool `json:"disableShort"` + + // BuyBelowNeutralSMA if true, the market maker will only place buy order when the current price is below the neutral band SMA. + BuyBelowNeutralSMA bool `json:"buyBelowNeutralSMA"` + + // NeutralBollinger is the smaller range of the bollinger band + // If price is in this band, it usually means the price is oscillating. + // If price goes out of this band, we tend to not place sell orders or buy orders + NeutralBollinger *types.BollingerSetting `json:"neutralBollinger"` + + // DefaultBollinger is the wide range of the bollinger band + // for controlling your exposure position + DefaultBollinger *types.BollingerSetting `json:"defaultBollinger"` + + // DowntrendSkew is the order quantity skew for normal downtrend band. + // The price is still in the default bollinger band. + // greater than 1.0 means when placing buy order, place sell order with less quantity + // less than 1.0 means when placing sell order, place buy order with less quantity + DowntrendSkew fixedpoint.Value `json:"downtrendSkew"` + + // UptrendSkew is the order quantity skew for normal uptrend band. + // The price is still in the default bollinger band. + // greater than 1.0 means when placing buy order, place sell order with less quantity + // less than 1.0 means when placing sell order, place buy order with less quantity + UptrendSkew fixedpoint.Value `json:"uptrendSkew"` + + // TradeInBand + // When this is on, places orders only when the current price is in the bollinger band. + TradeInBand bool `json:"tradeInBand"` + + // ShadowProtection is used to avoid placing bid order when price goes down strongly (without shadow) + ShadowProtection bool `json:"shadowProtection"` + ShadowProtectionRatio fixedpoint.Value `json:"shadowProtectionRatio"` + + Position *types.Position `persistence:"position"` + ProfitStats *types.ProfitStats `persistence:"profit_stats"` + TradeStats *types.TradeStats `persistence:"trade_stats"` + + session *qbtrade.ExchangeSession + orderExecutor *qbtrade.GeneralOrderExecutor + book *types.StreamOrderBook + + groupID uint32 + + // defaultBoll is the BOLLINGER indicator we used for predicting the price. + defaultBoll *indicator.BOLL + + // neutralBoll is the neutral price section + neutralBoll *indicator.BOLL + + // StrategyController + status types.StrategyStatus +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{ + Interval: s.Interval, + }) +} + +func (s *Strategy) Validate() error { + if len(s.Symbol) == 0 { + return errors.New("symbol is required") + } + + return nil +} + +func (s *Strategy) CurrentPosition() *types.Position { + return s.Position +} + +func (s *Strategy) ClosePosition(ctx context.Context, percentage fixedpoint.Value) error { + return s.orderExecutor.ClosePosition(ctx, percentage) +} + +// StrategyController +func (s *Strategy) GetStatus() types.StrategyStatus { + return s.status +} + +func (s *Strategy) Suspend(ctx context.Context) error { + s.status = types.StrategyStatusStopped + + if err := s.orderExecutor.GracefulCancel(ctx); err != nil { + log.WithError(err).Errorf("graceful cancel order error") + } + + qbtrade.Sync(ctx, s) + return nil +} + +func (s *Strategy) Resume(ctx context.Context) error { + s.status = types.StrategyStatusRunning + + return nil +} + +func (s *Strategy) getCurrentAllowedExposurePosition(bandPercentage float64) (fixedpoint.Value, error) { + if s.DynamicExposurePositionScale != nil { + v, err := s.DynamicExposurePositionScale.Scale(bandPercentage) + if err != nil { + return fixedpoint.Zero, err + } + return fixedpoint.NewFromFloat(v), nil + } + + return s.MaxExposurePosition, nil +} + +func (s *Strategy) placeOrders(ctx context.Context, midPrice fixedpoint.Value, klines []*types.KLine) { + // preprocessing + max := 0. + min := 100000. + + mv := 0. + for x := 0; x < 50; x++ { + if klines[x].High.Float64() > max { + max = klines[x].High.Float64() + } + if klines[x].Low.Float64() < min { + min = klines[x].High.Float64() + } + + mv += klines[x].Volume.Float64() + } + mv = mv / 50 + + // logrus.Info(max, min) + // set up a random two-dimensional data set (float64 values between 0.0 and 1.0) + var d clusters.Observations + for x := 0; x < 50; x++ { + // if klines[x].High.Float64() < max || klines[x].Low.Float64() > min { + if klines[x].Volume.Float64() > mv*0.3 { + d = append(d, clusters.Coordinates{ + klines[x].High.Float64(), + klines[x].Low.Float64(), + // klines[x].Open.Float64(), + // klines[x].Close.Float64(), + // klines[x].Volume.Float64(), + }) + } + // } + + } + log.Info(len(d)) + + // Partition the data points into 2 clusters + km := kmeans.New() + clusters, err := km.Partition(d, 3) + + // for _, c := range clusters { + // fmt.Printf("Centered at x: %.2f y: %.2f\n", c.Center[0], c.Center[1]) + // fmt.Printf("Matching data points: %+v\n\n", c.Observations) + // } + // clustered virtual kline_1's mid price + // vk1mp := fixedpoint.NewFromFloat((clusters[0].Center[0] + clusters[0].Center[1]) / 2.) + // clustered virtual kline_2's mid price + // vk2mp := fixedpoint.NewFromFloat((clusters[1].Center[0] + clusters[1].Center[1]) / 2.) + // clustered virtual kline_3's mid price + // vk3mp := fixedpoint.NewFromFloat((clusters[2].Center[0] + clusters[2].Center[1]) / 2.) + + // clustered virtual kline_1's high price + vk1hp := fixedpoint.NewFromFloat(clusters[0].Center[0]) + // clustered virtual kline_2's high price + vk2hp := fixedpoint.NewFromFloat(clusters[1].Center[0]) + // clustered virtual kline_3's high price + vk3hp := fixedpoint.NewFromFloat(clusters[2].Center[0]) + + // clustered virtual kline_1's low price + vk1lp := fixedpoint.NewFromFloat(clusters[0].Center[1]) + // clustered virtual kline_2's low price + vk2lp := fixedpoint.NewFromFloat(clusters[1].Center[1]) + // clustered virtual kline_3's low price + vk3lp := fixedpoint.NewFromFloat(clusters[2].Center[1]) + + askPrice := fixedpoint.NewFromFloat(math.Max(math.Max(vk1hp.Float64(), vk2hp.Float64()), vk3hp.Float64())) // fixedpoint.NewFromFloat(math.Max(math.Max(vk1mp.Float64(), vk2mp.Float64()), vk3mp.Float64())) + bidPrice := fixedpoint.NewFromFloat(math.Min(math.Min(vk1lp.Float64(), vk2lp.Float64()), vk3lp.Float64())) // fixedpoint.NewFromFloat(math.Min(math.Min(vk1mp.Float64(), vk2mp.Float64()), vk3mp.Float64())) + + // if vk1mp.Compare(vk2mp) > 0 { + // askPrice = vk1mp //.Mul(fixedpoint.NewFromFloat(1.001)) + // bidPrice = vk2mp //.Mul(fixedpoint.NewFromFloat(0.999)) + // } else if vk1mp.Compare(vk2mp) < 0 { + // askPrice = vk2mp //.Mul(fixedpoint.NewFromFloat(1.001)) + // bidPrice = vk1mp //.Mul(fixedpoint.NewFromFloat(0.999)) + // } + // midPrice.Mul(fixedpoint.One.Add(askSpread)) + // midPrice.Mul(fixedpoint.One.Sub(bidSpread)) + base := s.Position.GetBase() + // balances := s.session.GetAccount().Balances() + + canSell := true + canBuy := true + + // predMidPrice := (askPrice + bidPrice) / 2. + + // if midPrice.Float64() > predMidPrice.Float64() { + // bidPrice = predMidPrice.Mul(fixedpoint.NewFromFloat(0.999)) + // } + // + // if midPrice.Float64() < predMidPrice.Float64() { + // askPrice = predMidPrice.Mul(fixedpoint.NewFromFloat(1.001)) + // } + // + // if midPrice.Float64() > askPrice.Float64() { + // canBuy = false + // askPrice = midPrice.Mul(fixedpoint.NewFromFloat(1.001)) + // } + // + // if midPrice.Float64() < bidPrice.Float64() { + // canSell = false + // bidPrice = midPrice.Mul(fixedpoint.NewFromFloat(0.999)) + // } + + sellQuantity := s.QuantityOrAmount.CalculateQuantity(askPrice) + buyQuantity := s.QuantityOrAmount.CalculateQuantity(bidPrice) + + sellOrder := types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeLimitMaker, + Quantity: sellQuantity, + Price: askPrice, + Market: s.Market, + GroupID: s.groupID, + } + buyOrder := types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimitMaker, + Quantity: buyQuantity, + Price: bidPrice, + Market: s.Market, + GroupID: s.groupID, + } + + var submitBuyOrders []types.SubmitOrder + var submitSellOrders []types.SubmitOrder + + // baseBalance, hasBaseBalance := balances[s.Market.BaseCurrency] + // quoteBalance, hasQuoteBalance := balances[s.Market.QuoteCurrency] + + downBand := s.defaultBoll.DownBand.Last(0) + upBand := s.defaultBoll.UpBand.Last(0) + sma := s.defaultBoll.SMA.Last(0) + log.Infof("bollinger band: up %f sma %f down %f", upBand, sma, downBand) + + bandPercentage := calculateBandPercentage(upBand, downBand, sma, midPrice.Float64()) + log.Infof("mid price band percentage: %v", bandPercentage) + + maxExposurePosition, err := s.getCurrentAllowedExposurePosition(bandPercentage) + if err != nil { + log.WithError(err).Errorf("can not calculate CurrentAllowedExposurePosition") + return + } + + log.Infof("calculated max exposure position: %v", maxExposurePosition) + + if maxExposurePosition.Sign() > 0 && base.Compare(maxExposurePosition) > 0 { + canBuy = false + } + + if maxExposurePosition.Sign() > 0 { + if s.Long != nil && *s.Long && base.Sign() < 0 { + canSell = false + } else if base.Compare(maxExposurePosition.Neg()) < 0 { + canSell = false + } + } + + if canSell { + submitSellOrders = append(submitSellOrders, sellOrder) + } + if canBuy { + submitBuyOrders = append(submitBuyOrders, buyOrder) + } + + for i := range submitBuyOrders { + submitBuyOrders[i] = s.adjustOrderQuantity(submitBuyOrders[i]) + } + + for i := range submitSellOrders { + submitSellOrders[i] = s.adjustOrderQuantity(submitSellOrders[i]) + } + + if _, err := s.orderExecutor.SubmitOrders(ctx, submitBuyOrders...); err != nil { + log.WithError(err).Errorf("can not place orders") + } + if _, err := s.orderExecutor.SubmitOrders(ctx, submitSellOrders...); err != nil { + log.WithError(err).Errorf("can not place orders") + } +} + +func (s *Strategy) adjustOrderQuantity(submitOrder types.SubmitOrder) types.SubmitOrder { + if submitOrder.Quantity.Mul(submitOrder.Price).Compare(s.Market.MinNotional) < 0 { + submitOrder.Quantity = qbtrade.AdjustFloatQuantityByMinAmount(submitOrder.Quantity, submitOrder.Price, s.Market.MinNotional.Mul(notionModifier)) + } + + if submitOrder.Quantity.Compare(s.Market.MinQuantity) < 0 { + submitOrder.Quantity = fixedpoint.Max(submitOrder.Quantity, s.Market.MinQuantity) + } + + return submitOrder +} + +func (s *Strategy) Run(ctx context.Context, _ qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + instanceID := fmt.Sprintf("%s-%s", ID, s.Symbol) + + s.status = types.StrategyStatusRunning + + 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) + } + + // initial required information + s.session = session + s.orderExecutor = qbtrade.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, s.Position) + s.orderExecutor.BindEnvironment(s.Environment) + s.orderExecutor.BindProfitStats(s.ProfitStats) + s.orderExecutor.BindTradeStats(s.TradeStats) + s.orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { + qbtrade.Sync(ctx, s) + }) + s.orderExecutor.Bind() + + s.neutralBoll = s.StandardIndicatorSet.BOLL(s.NeutralBollinger.IntervalWindow, s.NeutralBollinger.BandWidth) + s.defaultBoll = s.StandardIndicatorSet.BOLL(s.DefaultBollinger.IntervalWindow, s.DefaultBollinger.BandWidth) + + var klines []*types.KLine + session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { + // StrategyController + if s.status != types.StrategyStatusRunning { + return + } + + // if kline.Symbol != s.Symbol || kline.Interval != s.Interval { + // return + // } + + if kline.Interval == s.Interval { + klines = append(klines, &kline) + } + + if len(klines) > 50 { + if kline.Interval == s.Interval { + if err := s.orderExecutor.GracefulCancel(ctx); err != nil { + log.WithError(err).Errorf("graceful cancel order error") + } + + s.placeOrders(ctx, kline.Close, klines[len(klines)-50:]) + } + } + + }) + + return nil +} + +func calculateBandPercentage(up, down, sma, midPrice float64) float64 { + if midPrice < sma { + // should be negative percentage + return (midPrice - sma) / math.Abs(sma-down) + } else if midPrice > sma { + // should be positive percentage + return (midPrice - sma) / math.Abs(up-sma) + } + + return 0.0 +} + +func inBetween(x, a, b float64) bool { + return a < x && x < b +} diff --git a/pkg/strategy/schedule/strategy.go b/pkg/strategy/schedule/strategy.go new file mode 100644 index 0000000..48265ed --- /dev/null +++ b/pkg/strategy/schedule/strategy.go @@ -0,0 +1,250 @@ +package schedule + +import ( + "context" + "fmt" + + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ID = "schedule" + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + Market types.Market + + // StandardIndicatorSet contains the standard indicators of a market (symbol) + // This field will be injected automatically since we defined the Symbol field. + *qbtrade.StandardIndicatorSet + + // Interval is the period that you want to submit order + Interval types.Interval `json:"interval"` + + // Symbol is the symbol of the market + Symbol string `json:"symbol"` + + // Side is the order side type, which can be buy or sell + Side types.SideType `json:"side,omitempty"` + + UseLimitOrder bool `json:"useLimitOrder"` + + qbtrade.QuantityOrAmount + + MinBaseBalance fixedpoint.Value `json:"minBaseBalance"` + MaxBaseBalance fixedpoint.Value `json:"maxBaseBalance"` + + BelowMovingAverage *qbtrade.MovingAverageSettings `json:"belowMovingAverage,omitempty"` + + AboveMovingAverage *qbtrade.MovingAverageSettings `json:"aboveMovingAverage,omitempty"` + + Position *types.Position `persistence:"position"` + + session *qbtrade.ExchangeSession + orderExecutor *qbtrade.GeneralOrderExecutor +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s", ID, s.Symbol) +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) + if s.BelowMovingAverage != nil { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.BelowMovingAverage.Interval}) + } + if s.AboveMovingAverage != nil { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.AboveMovingAverage.Interval}) + } +} + +func (s *Strategy) Validate() error { + if err := s.QuantityOrAmount.Validate(); err != nil { + return err + } + + return nil +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + s.session = session + + if s.StandardIndicatorSet == nil { + return errors.New("StandardIndicatorSet can not be nil, injection failed?") + } + + if s.Position == nil { + s.Position = types.NewPositionFromMarket(s.Market) + } + + instanceID := s.InstanceID() + s.orderExecutor = qbtrade.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, s.Position) + s.orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { + qbtrade.Sync(ctx, s) + }) + s.orderExecutor.Bind() + + var belowMA types.Float64Indicator + var aboveMA types.Float64Indicator + var err error + if s.BelowMovingAverage != nil { + belowMA, err = s.BelowMovingAverage.Indicator(s.StandardIndicatorSet) + if err != nil { + return err + } + } + + if s.AboveMovingAverage != nil { + aboveMA, err = s.AboveMovingAverage.Indicator(s.StandardIndicatorSet) + if err != nil { + return err + } + } + + session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { + if kline.Symbol != s.Symbol { + return + } + + if kline.Interval != s.Interval { + return + } + + closePrice := kline.Close + closePriceF := closePrice.Float64() + quantity := s.QuantityOrAmount.CalculateQuantity(closePrice) + side := s.Side + + if s.BelowMovingAverage != nil || s.AboveMovingAverage != nil { + + match := false + // if any of the conditions satisfies then we execute order + if belowMA != nil && closePriceF < belowMA.Last(0) { + match = true + if s.BelowMovingAverage != nil { + if s.BelowMovingAverage.Side != nil { + side = *s.BelowMovingAverage.Side + } + + // override the default quantity or amount + if s.BelowMovingAverage.QuantityOrAmount.IsSet() { + quantity = s.BelowMovingAverage.QuantityOrAmount.CalculateQuantity(closePrice) + } + } + } else if aboveMA != nil && closePriceF > aboveMA.Last(0) { + match = true + if s.AboveMovingAverage != nil { + if s.AboveMovingAverage.Side != nil { + side = *s.AboveMovingAverage.Side + } + + if s.AboveMovingAverage.QuantityOrAmount.IsSet() { + quantity = s.AboveMovingAverage.QuantityOrAmount.CalculateQuantity(closePrice) + } + } + } + + if !match { + qbtrade.Notify("skip, the %s closed price %v is below or above moving average", s.Symbol, closePrice) + return + } + } + + // calculate quote quantity for balance checking + quoteQuantity := quantity.Mul(closePrice) + + quoteBalance, ok := session.GetAccount().Balance(s.Market.QuoteCurrency) + if !ok { + log.Errorf("can not place scheduled %s order, quote balance %s is empty", s.Symbol, s.Market.QuoteCurrency) + return + } + + baseBalance, ok := session.GetAccount().Balance(s.Market.BaseCurrency) + if !ok { + log.Errorf("can not place scheduled %s order, base balance %s is empty", s.Symbol, s.Market.BaseCurrency) + return + } + + totalBase := baseBalance.Total() + + // execute orders + switch side { + case types.SideTypeBuy: + + if !s.MaxBaseBalance.IsZero() { + if totalBase.Add(quantity).Compare(s.MaxBaseBalance) >= 0 { + quantity = s.MaxBaseBalance.Sub(totalBase) + quoteQuantity = quantity.Mul(closePrice) + } + } + + // if min base balance is defined + if !s.MinBaseBalance.IsZero() && s.MinBaseBalance.Compare(totalBase) > 0 { + quantity = fixedpoint.Max(quantity, s.MinBaseBalance.Sub(totalBase)) + quantity = fixedpoint.Max(quantity, s.Market.MinQuantity) + } + + if quoteBalance.Available.Compare(quoteQuantity) < 0 { + log.Errorf("can not place scheduled %s order: quote balance %s is not enough: %v < %v", s.Symbol, s.Market.QuoteCurrency, quoteBalance.Available, quoteQuantity) + return + } + + case types.SideTypeSell: + quantity = fixedpoint.Min(quantity, baseBalance.Available) + + // skip sell if we hit the minBaseBalance line + if !s.MinBaseBalance.IsZero() { + if totalBase.Sub(quantity).Compare(s.MinBaseBalance) < 0 { + return + } + } + + quoteQuantity = quantity.Mul(closePrice) + } + + // truncate quantity by its step size + quantity = s.Market.TruncateQuantity(quantity) + + if s.Market.IsDustQuantity(quantity, closePrice) { + log.Warnf("%s: quantity %f is too small, skip order", s.Symbol, quantity.Float64()) + return + } + + submitOrder := types.SubmitOrder{ + Symbol: s.Symbol, + Side: side, + Type: types.OrderTypeMarket, + Quantity: quantity, + Market: s.Market, + } + + if s.UseLimitOrder { + submitOrder.Type = types.OrderTypeLimit + submitOrder.Price = closePrice + } + + if err := s.orderExecutor.GracefulCancel(ctx); err != nil { + log.WithError(err).Errorf("cancel order error") + } + + qbtrade.Notify("Submitting scheduled %s order with quantity %s at price %s", s.Symbol, quantity.String(), closePrice.String()) + _, err := s.orderExecutor.SubmitOrders(ctx, submitOrder) + if err != nil { + qbtrade.Notify("Can not place scheduled %s order: submit error %s", s.Symbol, err.Error()) + log.WithError(err).Errorf("can not place scheduled %s order error", s.Symbol) + } + }) + + return nil +} diff --git a/pkg/strategy/scmaker/intensity.go b/pkg/strategy/scmaker/intensity.go new file mode 100644 index 0000000..c74c553 --- /dev/null +++ b/pkg/strategy/scmaker/intensity.go @@ -0,0 +1,44 @@ +package scmaker + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + indicatorv2 "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator/v2" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type IntensityStream struct { + *types.Float64Series + + Buy, Sell *indicatorv2.RMAStream + window int +} + +func Intensity(source indicatorv2.KLineSubscription, window int) *IntensityStream { + s := &IntensityStream{ + Float64Series: types.NewFloat64Series(), + window: window, + + Buy: indicatorv2.RMA2(types.NewFloat64Series(), window, false), + Sell: indicatorv2.RMA2(types.NewFloat64Series(), window, false), + } + + threshold := fixedpoint.NewFromFloat(100.0) + source.AddSubscriber(func(k types.KLine) { + volume := k.Volume.Float64() + + // ignore zero volume events or <= 10usd events + if volume == 0.0 || k.Close.Mul(k.Volume).Compare(threshold) <= 0 { + return + } + + c := k.Close.Compare(k.Open) + if c > 0 { + s.Buy.PushAndEmit(volume) + } else if c < 0 { + s.Sell.PushAndEmit(volume) + } + s.Float64Series.PushAndEmit(k.High.Sub(k.Low).Float64()) + }) + + return s +} diff --git a/pkg/strategy/scmaker/strategy.go b/pkg/strategy/scmaker/strategy.go new file mode 100644 index 0000000..de7321f --- /dev/null +++ b/pkg/strategy/scmaker/strategy.go @@ -0,0 +1,478 @@ +package scmaker + +import ( + "context" + "fmt" + "math" + "sync" + + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + . "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator/v2" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/common" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +const ID = "scmaker" + +type advancedOrderCancelApi interface { + CancelAllOrders(ctx context.Context) ([]types.Order, error) + CancelOrdersBySymbol(ctx context.Context, symbol string) ([]types.Order, error) +} + +type BollingerConfig struct { + Interval types.Interval `json:"interval"` + Window int `json:"window"` + K float64 `json:"k"` +} + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +// Strategy scmaker is a stable coin market maker +type Strategy struct { + *common.Strategy + + Environment *qbtrade.Environment + Market types.Market + + Symbol string `json:"symbol"` + + NumOfLiquidityLayers int `json:"numOfLiquidityLayers"` + + LiquidityUpdateInterval types.Interval `json:"liquidityUpdateInterval"` + PriceRangeBollinger *BollingerConfig `json:"priceRangeBollinger"` + StrengthInterval types.Interval `json:"strengthInterval"` + + AdjustmentUpdateInterval types.Interval `json:"adjustmentUpdateInterval"` + + MidPriceEMA *types.IntervalWindow `json:"midPriceEMA"` + LiquiditySlideRule *qbtrade.SlideRule `json:"liquidityScale"` + LiquidityLayerTickSize fixedpoint.Value `json:"liquidityLayerTickSize"` + LiquiditySkew fixedpoint.Value `json:"liquiditySkew"` + + MaxExposure fixedpoint.Value `json:"maxExposure"` + + MinProfit fixedpoint.Value `json:"minProfit"` + + liquidityOrderBook, adjustmentOrderBook *qbtrade.ActiveOrderBook + book *types.StreamOrderBook + + liquidityScale qbtrade.Scale + + // indicators + ewma *EWMAStream + boll *BOLLStream + intensity *IntensityStream +} + +func (s *Strategy) Initialize() error { + if s.Strategy == nil { + s.Strategy = &common.Strategy{} + } + return nil +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s", ID, s.Symbol) +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.BookChannel, s.Symbol, types.SubscribeOptions{}) + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.AdjustmentUpdateInterval}) + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.LiquidityUpdateInterval}) + + if s.MidPriceEMA != nil { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.MidPriceEMA.Interval}) + } +} + +func (s *Strategy) Run(ctx context.Context, _ qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + s.Strategy.Initialize(ctx, s.Environment, session, s.Market, ID, s.InstanceID()) + + s.book = types.NewStreamBook(s.Symbol) + s.book.BindStream(session.MarketDataStream) + + s.liquidityOrderBook = qbtrade.NewActiveOrderBook(s.Symbol) + s.liquidityOrderBook.BindStream(session.UserDataStream) + + s.adjustmentOrderBook = qbtrade.NewActiveOrderBook(s.Symbol) + s.adjustmentOrderBook.BindStream(session.UserDataStream) + + scale, err := s.LiquiditySlideRule.Scale() + if err != nil { + return err + } + + if err := scale.Solve(); err != nil { + return err + } + + if cancelApi, ok := session.Exchange.(advancedOrderCancelApi); ok { + _, _ = cancelApi.CancelOrdersBySymbol(ctx, s.Symbol) + } + + s.liquidityScale = scale + + s.initializeMidPriceEMA(session) + s.initializePriceRangeBollinger(session) + s.initializeIntensityIndicator(session) + + session.UserDataStream.OnStart(func() { + s.placeLiquidityOrders(ctx) + }) + + session.MarketDataStream.OnKLineClosed(func(k types.KLine) { + if k.Interval == s.AdjustmentUpdateInterval { + s.placeAdjustmentOrders(ctx) + } + + if k.Interval == s.LiquidityUpdateInterval { + s.placeLiquidityOrders(ctx) + } + }) + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + err := s.liquidityOrderBook.GracefulCancel(ctx, s.Session.Exchange) + util.LogErr(err, "unable to cancel liquidity orders") + + err = s.adjustmentOrderBook.GracefulCancel(ctx, s.Session.Exchange) + util.LogErr(err, "unable to cancel adjustment orders") + + qbtrade.Sync(ctx, s) + }) + + return nil +} + +func (s *Strategy) preloadKLines( + inc *KLineStream, session *qbtrade.ExchangeSession, symbol string, interval types.Interval, +) { + if store, ok := session.MarketDataStore(symbol); ok { + if kLinesData, ok := store.KLinesOfInterval(interval); ok { + for _, k := range *kLinesData { + inc.EmitUpdate(k) + } + } + } +} + +func (s *Strategy) initializeMidPriceEMA(session *qbtrade.ExchangeSession) { + kLines := KLines(session.MarketDataStream, s.Symbol, s.MidPriceEMA.Interval) + s.ewma = EWMA2(ClosePrices(kLines), s.MidPriceEMA.Window) + + s.preloadKLines(kLines, session, s.Symbol, s.MidPriceEMA.Interval) +} + +func (s *Strategy) initializeIntensityIndicator(session *qbtrade.ExchangeSession) { + kLines := KLines(session.MarketDataStream, s.Symbol, s.StrengthInterval) + s.intensity = Intensity(kLines, 10) + + s.preloadKLines(kLines, session, s.Symbol, s.StrengthInterval) +} + +func (s *Strategy) initializePriceRangeBollinger(session *qbtrade.ExchangeSession) { + kLines := KLines(session.MarketDataStream, s.Symbol, s.PriceRangeBollinger.Interval) + closePrices := ClosePrices(kLines) + s.boll = BOLL(closePrices, s.PriceRangeBollinger.Window, s.PriceRangeBollinger.K) + + s.preloadKLines(kLines, session, s.Symbol, s.PriceRangeBollinger.Interval) +} + +func (s *Strategy) placeAdjustmentOrders(ctx context.Context) { + _ = s.adjustmentOrderBook.GracefulCancel(ctx, s.Session.Exchange) + + if s.Position.IsDust() { + return + } + + ticker, err := s.Session.Exchange.QueryTicker(ctx, s.Symbol) + if util.LogErr(err, "unable to query ticker") { + return + } + + if _, err := s.Session.UpdateAccount(ctx); err != nil { + util.LogErr(err, "unable to update account") + return + } + + baseBal, _ := s.Session.Account.Balance(s.Market.BaseCurrency) + quoteBal, _ := s.Session.Account.Balance(s.Market.QuoteCurrency) + + var adjOrders []types.SubmitOrder + + posSize := s.Position.Base.Abs() + tickSize := s.Market.TickSize + + if s.Position.IsShort() { + price := profitProtectedPrice(types.SideTypeBuy, s.Position.AverageCost, ticker.Sell.Add(tickSize.Neg()), s.Session.MakerFeeRate, s.MinProfit) + quoteQuantity := fixedpoint.Min(price.Mul(posSize), quoteBal.Available) + bidQuantity := quoteQuantity.Div(price) + + if s.Market.IsDustQuantity(bidQuantity, price) { + return + } + + adjOrders = append(adjOrders, types.SubmitOrder{ + Symbol: s.Symbol, + Type: types.OrderTypeLimitMaker, + Side: types.SideTypeBuy, + Price: price, + Quantity: bidQuantity, + Market: s.Market, + TimeInForce: types.TimeInForceGTC, + }) + } else if s.Position.IsLong() { + price := profitProtectedPrice(types.SideTypeSell, s.Position.AverageCost, ticker.Buy.Add(tickSize), s.Session.MakerFeeRate, s.MinProfit) + askQuantity := fixedpoint.Min(posSize, baseBal.Available) + + if s.Market.IsDustQuantity(askQuantity, price) { + return + } + + adjOrders = append(adjOrders, types.SubmitOrder{ + Symbol: s.Symbol, + Type: types.OrderTypeLimitMaker, + Side: types.SideTypeSell, + Price: price, + Quantity: askQuantity, + Market: s.Market, + TimeInForce: types.TimeInForceGTC, + }) + } + + createdOrders, err := s.OrderExecutor.SubmitOrders(ctx, adjOrders...) + if util.LogErr(err, "unable to place liquidity orders") { + return + } + + s.adjustmentOrderBook.Add(createdOrders...) +} + +func (s *Strategy) placeLiquidityOrders(ctx context.Context) { + ticker, err := s.Session.Exchange.QueryTicker(ctx, s.Symbol) + if util.LogErr(err, "unable to query ticker") { + return + } + + if s.IsHalted(ticker.Time) { + log.Warn("circuitBreakRiskControl: trading halted") + return + } + + err = s.liquidityOrderBook.GracefulCancel(ctx, s.Session.Exchange) + if util.LogErr(err, "unable to cancel orders") { + return + } + + if ticker.Buy.IsZero() && ticker.Sell.IsZero() { + ticker.Sell = ticker.Last.Add(s.Market.TickSize) + ticker.Buy = ticker.Last.Sub(s.Market.TickSize) + } else if ticker.Buy.IsZero() { + ticker.Buy = ticker.Sell.Sub(s.Market.TickSize) + } else if ticker.Sell.IsZero() { + ticker.Sell = ticker.Buy.Add(s.Market.TickSize) + } + + if _, err := s.Session.UpdateAccount(ctx); err != nil { + util.LogErr(err, "unable to update account") + return + } + + baseBal, _ := s.Session.Account.Balance(s.Market.BaseCurrency) + quoteBal, _ := s.Session.Account.Balance(s.Market.QuoteCurrency) + + spread := ticker.Sell.Sub(ticker.Buy) + tickSize := fixedpoint.Max(s.LiquidityLayerTickSize, s.Market.TickSize) + + midPriceEMA := s.ewma.Last(0) + midPrice := fixedpoint.NewFromFloat(midPriceEMA) + + bandWidth := s.boll.Last(0) + + log.Infof("spread: %f mid price ema: %f boll band width: %f", spread.Float64(), midPriceEMA, bandWidth) + + sum := s.liquidityScale.Sum(1.0) + askSum := sum + bidSum := sum + + log.Infof("liquidity sum: %f / %f", askSum, bidSum) + + skew := s.LiquiditySkew.Float64() + useSkew := !s.LiquiditySkew.IsZero() + if useSkew { + askSum = sum / skew + bidSum = sum * skew + log.Infof("adjusted liqudity skew: %f / %f", askSum, bidSum) + } + + var bidPrices []fixedpoint.Value + var askPrices []fixedpoint.Value + + // calculate and collect prices + for i := 0; i <= s.NumOfLiquidityLayers; i++ { + fi := fixedpoint.NewFromInt(int64(i)) + sp := tickSize.Mul(fi) + spreadBidPrice := midPrice.Sub(sp) + spreadAskPrice := midPrice.Add(sp) + + bidPrice := ticker.Buy + askPrice := ticker.Sell + + if i == s.NumOfLiquidityLayers { + bwf := fixedpoint.NewFromFloat(bandWidth) + bidPrice = fixedpoint.Min(midPrice.Add(bwf.Neg()), spreadBidPrice) + askPrice = fixedpoint.Max(midPrice.Add(bwf), spreadAskPrice) + } else if i > 0 { + bidPrice = spreadBidPrice + askPrice = spreadAskPrice + } + + if i > 0 && bidPrice.Compare(ticker.Buy) > 0 { + bidPrice = ticker.Buy.Sub(sp) + } + + if i > 0 && askPrice.Compare(ticker.Sell) < 0 { + askPrice = ticker.Sell.Add(sp) + } + + bidPrice = s.Market.TruncatePrice(bidPrice) + askPrice = s.Market.TruncatePrice(askPrice) + + bidPrices = append(bidPrices, bidPrice) + askPrices = append(askPrices, askPrice) + } + + availableBase := baseBal.Available + availableQuote := quoteBal.Available + + // check max exposure + if s.MaxExposure.Sign() > 0 { + availableQuote = fixedpoint.Min(availableQuote, s.MaxExposure) + + baseQuoteValue := availableBase.Mul(ticker.Sell) + if baseQuoteValue.Compare(s.MaxExposure) > 0 { + availableBase = s.MaxExposure.Div(ticker.Sell) + } + } + + makerQuota := &qbtrade.QuotaTransaction{} + makerQuota.QuoteAsset.Add(availableQuote) + makerQuota.BaseAsset.Add(availableBase) + + log.Infof("balances before liq orders: %s, %s", + baseBal.String(), + quoteBal.String()) + + if !s.Position.IsDust() { + if s.Position.IsLong() { + availableBase = availableBase.Sub(s.Position.Base) + availableBase = s.Market.RoundDownQuantityByPrecision(availableBase) + } else if s.Position.IsShort() { + posSizeInQuote := s.Position.Base.Mul(ticker.Sell) + availableQuote = availableQuote.Sub(posSizeInQuote) + } + } + + askX := availableBase.Float64() / askSum + bidX := availableQuote.Float64() / (bidSum * (fixedpoint.Sum(bidPrices).Float64())) + + askX = math.Trunc(askX*1e8) / 1e8 + bidX = math.Trunc(bidX*1e8) / 1e8 + + var liqOrders []types.SubmitOrder + for i := 0; i <= s.NumOfLiquidityLayers; i++ { + bidQuantity := fixedpoint.NewFromFloat(s.liquidityScale.Call(float64(i)) * bidX) + askQuantity := fixedpoint.NewFromFloat(s.liquidityScale.Call(float64(i)) * askX) + bidPrice := bidPrices[i] + askPrice := askPrices[i] + + log.Infof("liqudity layer #%d %f/%f = %f/%f", i, askPrice.Float64(), bidPrice.Float64(), askQuantity.Float64(), bidQuantity.Float64()) + + placeBuy := true + placeSell := true + averageCost := s.Position.AverageCost + // when long position, do not place sell orders below the average cost + if !s.Position.IsDust() { + if s.Position.IsLong() && askPrice.Compare(averageCost) < 0 { + placeSell = false + } + + if s.Position.IsShort() && bidPrice.Compare(averageCost) > 0 { + placeBuy = false + } + } + + quoteQuantity := bidQuantity.Mul(bidPrice) + + if s.Market.IsDustQuantity(bidQuantity, bidPrice) || !makerQuota.QuoteAsset.Lock(quoteQuantity) { + placeBuy = false + } + + if s.Market.IsDustQuantity(askQuantity, askPrice) || !makerQuota.BaseAsset.Lock(askQuantity) { + placeSell = false + } + + if placeBuy { + liqOrders = append(liqOrders, types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimitMaker, + Quantity: bidQuantity, + Price: bidPrice, + Market: s.Market, + TimeInForce: types.TimeInForceGTC, + }) + } + + if placeSell { + liqOrders = append(liqOrders, types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeLimitMaker, + Quantity: askQuantity, + Price: askPrice, + Market: s.Market, + TimeInForce: types.TimeInForceGTC, + }) + } + } + + makerQuota.Commit() + + createdOrders, err := s.OrderExecutor.SubmitOrders(ctx, liqOrders...) + if util.LogErr(err, "unable to place liquidity orders") { + return + } + + s.liquidityOrderBook.Add(createdOrders...) + log.Infof("%d liq orders are placed successfully", len(liqOrders)) +} + +func profitProtectedPrice( + side types.SideType, averageCost, price, feeRate, minProfit fixedpoint.Value, +) fixedpoint.Value { + switch side { + case types.SideTypeSell: + minProfitPrice := averageCost.Add( + averageCost.Mul(feeRate.Add(minProfit))) + return fixedpoint.Max(minProfitPrice, price) + + case types.SideTypeBuy: + minProfitPrice := averageCost.Sub( + averageCost.Mul(feeRate.Add(minProfit))) + return fixedpoint.Min(minProfitPrice, price) + + } + return price +} diff --git a/pkg/strategy/skeleton/strategy.go b/pkg/strategy/skeleton/strategy.go new file mode 100644 index 0000000..053e20f --- /dev/null +++ b/pkg/strategy/skeleton/strategy.go @@ -0,0 +1,167 @@ +package skeleton + +import ( + "context" + "fmt" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// ID is the unique strategy ID, it needs to be in all lower case +// For example, grid strategy uses "grid" +const ID = "skeleton" + +// log is a logrus.Entry that will be reused. +// This line attaches the strategy field to the logger with our ID, so that the logs from this strategy will be tagged with our ID +var log = logrus.WithField("strategy", ID) + +var ten = fixedpoint.NewFromInt(10) + +// init is a special function of golang, it will be called when the program is started +// importing this package will trigger the init function call. +func init() { + // Register our struct type to qbtrade + // Note that you don't need to field the fields. + // qbtrade uses reflect to parse your type information. + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +// State is a struct contains the information that we want to keep in the persistence layer, +// for example, redis or json file. +type State struct { + Counter int `json:"counter,omitempty"` +} + +// Strategy is a struct that contains the settings of your strategy. +// These settings will be loaded from the qbtrade YAML config file "qbtrade.yaml" automatically. +type Strategy struct { + Symbol string `json:"symbol"` + + // State is a state of your strategy + // When qbtrade shuts down, everything in the memory will be dropped + // If you need to store something and restore this information back, + // Simply define the "persistence" tag + State *State `persistence:"state"` +} + +// ID should return the identity of this strategy +func (s *Strategy) ID() string { + return ID +} + +// InstanceID returns the identity of the current instance of this strategy. +// You may have multiple instance of a strategy, with different symbols and settings. +// This value will be used for persistence layer to separate the storage. +// +// Run: +// +// redis-cli KEYS "*" +// +// And you will see how this instance ID is used in redis. +func (s *Strategy) InstanceID() string { + return ID + ":" + s.Symbol +} + +// Subscribe method subscribes specific market data from the given session. +// Before qbtrade is connected to the exchange, we need to collect what we want to subscribe. +// Here the strategy needs kline data, so it adds the kline subscription. +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + // We want 1m kline data of the symbol + // It will be BTCUSDT 1m if our s.Symbol is BTCUSDT + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: "1m"}) +} + +// This strategy simply spent all available quote currency to buy the symbol whenever kline gets closed +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + // Initialize the default value for state + if s.State == nil { + s.State = &State{Counter: 1} + } + + indicators := session.StandardIndicatorSet(s.Symbol) + atr := indicators.ATR(types.IntervalWindow{ + Interval: types.Interval1m, + Window: 14, + }) + + // To get the market information from the current session + // The market object provides the precision, MoQ (minimal of quantity) information + market, ok := session.Market(s.Symbol) + if !ok { + return fmt.Errorf("market %s not found", s.Symbol) + } + + // here we define a kline callback + // when a kline is closed, we will do something + callback := func(kline types.KLine) { + // get the latest ATR value from the indicator object that we just defined. + atrValue := atr.Last(0) + log.Infof("atr %f", atrValue) + + // Update our counter and sync the changes to the persistence layer on time + // If you don't do this, qbtrade will sync it automatically when qbtrade shuts down. + s.State.Counter++ + qbtrade.Sync(ctx, s) + + // To check if we have the quote balance + // When symbol = "BTCUSDT", the quote currency is USDT + // We can get this information from the market object + quoteBalance, ok := session.GetAccount().Balance(market.QuoteCurrency) + if !ok { + // if not ok, it means we don't have this currency in the account + return + } + + // For each balance, we have Available and Locked balance. + // balance.Available is the balance you can use to place an order. + // Note that the available balance is a fixed-point object, so you can not compare it with integer directly. + // Instead, you should call valueA.Compare(valueB) + quantityAmount := quoteBalance.Available + if quantityAmount.Sign() <= 0 || quantityAmount.Compare(ten) < 0 { + return + } + + // Call LastPrice(symbol) If you need to get the latest price + // Note this last price is updated by the closed kline + currentPrice, ok := session.LastPrice(s.Symbol) + if !ok { + return + } + + // totalQuantity = quantityAmount / currentPrice + totalQuantity := quantityAmount.Div(currentPrice) + + // Place a market order to the exchange + createdOrders, err := orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ + Symbol: kline.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeMarket, + Price: currentPrice, + Quantity: totalQuantity, + }) + + if err != nil { + log.WithError(err).Error("submit order error") + } + + log.Infof("createdOrders: %+v", createdOrders) + + // send notification to slack or telegram if you have configured it + qbtrade.Notify("order created") + } + + // register our kline event handler + session.MarketDataStream.OnKLineClosed(callback) + + // if you need to do something when the user data stream is ready + // note that you only receive order update, trade update, balance update when the user data stream is connect. + session.UserDataStream.OnStart(func() { + log.Infof("connected") + }) + + return nil +} diff --git a/pkg/strategy/supertrend/double_dema.go b/pkg/strategy/supertrend/double_dema.go new file mode 100644 index 0000000..4e30ba4 --- /dev/null +++ b/pkg/strategy/supertrend/double_dema.go @@ -0,0 +1,67 @@ +package supertrend + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type DoubleDema struct { + Interval types.Interval `json:"interval"` + + // FastDEMAWindow DEMA window for checking breakout + FastDEMAWindow int `json:"fastDEMAWindow"` + // SlowDEMAWindow DEMA window for checking breakout + SlowDEMAWindow int `json:"slowDEMAWindow"` + fastDEMA *indicator.DEMA + slowDEMA *indicator.DEMA +} + +// getDemaSignal get current DEMA signal +func (dd *DoubleDema) getDemaSignal(openPrice float64, closePrice float64) types.Direction { + var demaSignal types.Direction = types.DirectionNone + + if closePrice > dd.fastDEMA.Last(0) && closePrice > dd.slowDEMA.Last(0) && !(openPrice > dd.fastDEMA.Last(0) && openPrice > dd.slowDEMA.Last(0)) { + demaSignal = types.DirectionUp + } else if closePrice < dd.fastDEMA.Last(0) && closePrice < dd.slowDEMA.Last(0) && !(openPrice < dd.fastDEMA.Last(0) && openPrice < dd.slowDEMA.Last(0)) { + demaSignal = types.DirectionDown + } + + return demaSignal +} + +// preloadDema preloads DEMA indicators +func (dd *DoubleDema) preloadDema(kLineStore *qbtrade.MarketDataStore) { + if klines, ok := kLineStore.KLinesOfInterval(dd.fastDEMA.Interval); ok { + for i := 0; i < len(*klines); i++ { + dd.fastDEMA.Update((*klines)[i].GetClose().Float64()) + } + } + if klines, ok := kLineStore.KLinesOfInterval(dd.slowDEMA.Interval); ok { + for i := 0; i < len(*klines); i++ { + dd.slowDEMA.Update((*klines)[i].GetClose().Float64()) + } + } +} + +// newDoubleDema initializes double DEMA indicators +func newDoubleDema(kLineStore *qbtrade.MarketDataStore, interval types.Interval, fastDEMAWindow int, slowDEMAWindow int) *DoubleDema { + dd := DoubleDema{Interval: interval, FastDEMAWindow: fastDEMAWindow, SlowDEMAWindow: slowDEMAWindow} + + // DEMA + if dd.FastDEMAWindow == 0 { + dd.FastDEMAWindow = 144 + } + dd.fastDEMA = &indicator.DEMA{IntervalWindow: types.IntervalWindow{Interval: dd.Interval, Window: dd.FastDEMAWindow}} + dd.fastDEMA.Bind(kLineStore) + + if dd.SlowDEMAWindow == 0 { + dd.SlowDEMAWindow = 169 + } + dd.slowDEMA = &indicator.DEMA{IntervalWindow: types.IntervalWindow{Interval: dd.Interval, Window: dd.SlowDEMAWindow}} + dd.slowDEMA.Bind(kLineStore) + + dd.preloadDema(kLineStore) + + return &dd +} diff --git a/pkg/strategy/supertrend/draw.go b/pkg/strategy/supertrend/draw.go new file mode 100644 index 0000000..be73831 --- /dev/null +++ b/pkg/strategy/supertrend/draw.go @@ -0,0 +1,90 @@ +package supertrend + +import ( + "bytes" + "fmt" + "os" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/interact" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/wcharczuk/go-chart/v2" +) + +func (s *Strategy) InitDrawCommands(profit, cumProfit types.Series) { + qbtrade.RegisterCommand("/pnl", "Draw PNL(%) per trade", func(reply interact.Reply) { + canvas := DrawPNL(s.InstanceID(), profit) + var buffer bytes.Buffer + if err := canvas.Render(chart.PNG, &buffer); err != nil { + log.WithError(err).Errorf("cannot render pnl in drift") + reply.Message(fmt.Sprintf("[error] cannot render pnl in ewo: %v", err)) + return + } + qbtrade.SendPhoto(&buffer) + }) + qbtrade.RegisterCommand("/cumpnl", "Draw Cummulative PNL(Quote)", func(reply interact.Reply) { + canvas := DrawCumPNL(s.InstanceID(), cumProfit) + var buffer bytes.Buffer + if err := canvas.Render(chart.PNG, &buffer); err != nil { + log.WithError(err).Errorf("cannot render cumpnl in drift") + reply.Message(fmt.Sprintf("[error] canot render cumpnl in drift: %v", err)) + return + } + qbtrade.SendPhoto(&buffer) + }) +} + +func (s *Strategy) Draw(profit, cumProfit types.Series) error { + + canvas := DrawPNL(s.InstanceID(), profit) + f, err := os.Create(s.GraphPNLPath) + if err != nil { + return fmt.Errorf("cannot create on path " + s.GraphPNLPath) + } + defer f.Close() + if err = canvas.Render(chart.PNG, f); err != nil { + return fmt.Errorf("cannot render pnl") + } + canvas = DrawCumPNL(s.InstanceID(), cumProfit) + f, err = os.Create(s.GraphCumPNLPath) + if err != nil { + return fmt.Errorf("cannot create on path " + s.GraphCumPNLPath) + } + defer f.Close() + if err = canvas.Render(chart.PNG, f); err != nil { + return fmt.Errorf("cannot render cumpnl") + } + + return nil +} + +func DrawPNL(instanceID string, profit types.Series) *types.Canvas { + canvas := types.NewCanvas(instanceID) + length := profit.Length() + log.Infof("pnl Highest: %f, Lowest: %f", types.Highest(profit, length), types.Lowest(profit, length)) + canvas.PlotRaw("pnl %", profit, length) + canvas.YAxis = chart.YAxis{ + ValueFormatter: func(v interface{}) string { + if vf, isFloat := v.(float64); isFloat { + return fmt.Sprintf("%.4f", vf) + } + return "" + }, + } + canvas.PlotRaw("1", types.NumberSeries(1), length) + return canvas +} + +func DrawCumPNL(instanceID string, cumProfit types.Series) *types.Canvas { + canvas := types.NewCanvas(instanceID) + canvas.PlotRaw("cummulative pnl", cumProfit, cumProfit.Length()) + canvas.YAxis = chart.YAxis{ + ValueFormatter: func(v interface{}) string { + if vf, isFloat := v.(float64); isFloat { + return fmt.Sprintf("%.4f", vf) + } + return "" + }, + } + return canvas +} diff --git a/pkg/strategy/supertrend/linreg.go b/pkg/strategy/supertrend/linreg.go new file mode 100644 index 0000000..7ad10ad --- /dev/null +++ b/pkg/strategy/supertrend/linreg.go @@ -0,0 +1,99 @@ +package supertrend + +import ( + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// LinReg is Linear Regression baseline +type LinReg struct { + types.SeriesBase + types.IntervalWindow + // Values are the slopes of linear regression baseline + Values floats.Slice + klines types.KLineWindow + EndTime time.Time +} + +// Last slope of linear regression baseline +func (lr *LinReg) Last(i int) float64 { + return lr.Values.Last(i) +} + +// Index returns the slope of specified index +func (lr *LinReg) Index(i int) float64 { + return lr.Last(i) +} + +// Length of the slope values +func (lr *LinReg) Length() int { + return lr.Values.Length() +} + +var _ types.SeriesExtend = &LinReg{} + +// Update Linear Regression baseline slope +func (lr *LinReg) Update(kline types.KLine) { + lr.klines.Add(kline) + lr.klines.Truncate(lr.Window) + if len(lr.klines) < lr.Window { + lr.Values.Push(0) + return + } + + var sumX, sumY, sumXSqr, sumXY float64 = 0, 0, 0, 0 + end := len(lr.klines) - 1 // The last kline + for i := end; i >= end-lr.Window+1; i-- { + val := lr.klines[i].GetClose().Float64() + per := float64(end - i + 1) + sumX += per + sumY += val + sumXSqr += per * per + sumXY += val * per + } + length := float64(lr.Window) + slope := (length*sumXY - sumX*sumY) / (length*sumXSqr - sumX*sumX) + average := sumY / length + endPrice := average - slope*sumX/length + slope + startPrice := endPrice + slope*(length-1) + lr.Values.Push((endPrice - startPrice) / (length - 1)) + + log.Debugf("linear regression baseline slope: %f", lr.Last(0)) +} + +func (lr *LinReg) BindK(target indicator.KLineClosedEmitter, symbol string, interval types.Interval) { + target.OnKLineClosed(types.KLineWith(symbol, interval, lr.PushK)) +} + +func (lr *LinReg) PushK(k types.KLine) { + var zeroTime = time.Time{} + if lr.EndTime != zeroTime && k.EndTime.Before(lr.EndTime) { + return + } + + lr.Update(k) + lr.EndTime = k.EndTime.Time() +} + +func (lr *LinReg) LoadK(allKLines []types.KLine) { + for _, k := range allKLines { + lr.PushK(k) + } +} + +// GetSignal get linear regression signal +func (lr *LinReg) GetSignal() types.Direction { + var lrSignal types.Direction = types.DirectionNone + + switch { + case lr.Last(0) > 0: + lrSignal = types.DirectionUp + case lr.Last(0) < 0: + lrSignal = types.DirectionDown + } + + return lrSignal +} diff --git a/pkg/strategy/supertrend/strategy.go b/pkg/strategy/supertrend/strategy.go new file mode 100644 index 0000000..62b775a --- /dev/null +++ b/pkg/strategy/supertrend/strategy.go @@ -0,0 +1,555 @@ +package supertrend + +import ( + "context" + "fmt" + "os" + "strconv" + "sync" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/report" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ID = "supertrend" + +var log = logrus.WithField("strategy", ID) + +// TODO: limit order for ATR TP +func init() { + // Register the pointer of the strategy struct, + // so that qbtrade knows what struct to be used to unmarshal the configs (YAML or JSON) + // Note: built-in strategies need to imported manually in the qbtrade cmd package. + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + Environment *qbtrade.Environment + Market types.Market + + // persistence fields + Position *types.Position `persistence:"position"` + ProfitStats *types.ProfitStats `persistence:"profit_stats"` + TradeStats *types.TradeStats `persistence:"trade_stats"` + + // ProfitStatsTracker tracks profit related status and generates report + ProfitStatsTracker *report.ProfitStatsTracker `json:"profitStatsTracker"` + TrackParameters bool `json:"trackParameters"` + + // Symbol is the market symbol you want to trade + Symbol string `json:"symbol"` + + types.IntervalWindow + + // Double DEMA + doubleDema *DoubleDema + // FastDEMAWindow DEMA window for checking breakout + FastDEMAWindow int `json:"fastDEMAWindow"` + // SlowDEMAWindow DEMA window for checking breakout + SlowDEMAWindow int `json:"slowDEMAWindow"` + + // SuperTrend indicator + Supertrend *indicator.Supertrend + // SupertrendMultiplier ATR multiplier for calculation of supertrend + SupertrendMultiplier float64 `json:"supertrendMultiplier"` + + // LinearRegression Use linear regression as trend confirmation + LinearRegression *LinReg `json:"linearRegression,omitempty"` + + // Leverage uses the account net value to calculate the order qty + Leverage fixedpoint.Value `json:"leverage"` + // Quantity sets the fixed order qty, takes precedence over Leverage + Quantity fixedpoint.Value `json:"quantity"` + AccountValueCalculator *qbtrade.AccountValueCalculator + + // TakeProfitAtrMultiplier TP according to ATR multiple, 0 to disable this + TakeProfitAtrMultiplier float64 `json:"takeProfitAtrMultiplier"` + + // StopLossByTriggeringK Set SL price to the low/high of the triggering Kline + StopLossByTriggeringK bool `json:"stopLossByTriggeringK"` + + // StopByReversedSupertrend TP/SL by reversed supertrend signal + StopByReversedSupertrend bool `json:"stopByReversedSupertrend"` + + // StopByReversedDema TP/SL by reversed DEMA signal + StopByReversedDema bool `json:"stopByReversedDema"` + + // StopByReversedLinGre TP/SL by reversed linear regression signal + StopByReversedLinGre bool `json:"stopByReversedLinGre"` + + // ExitMethods Exit methods + ExitMethods qbtrade.ExitMethodSet `json:"exits"` + + // whether to draw graph or not by the end of backtest + DrawGraph bool `json:"drawGraph"` + GraphPNLPath string `json:"graphPNLPath"` + GraphCumPNLPath string `json:"graphCumPNLPath"` + + // for position + buyPrice float64 `persistence:"buy_price"` + sellPrice float64 `persistence:"sell_price"` + highestPrice float64 `persistence:"highest_price"` + lowestPrice float64 `persistence:"lowest_price"` + + session *qbtrade.ExchangeSession + orderExecutor *qbtrade.GeneralOrderExecutor + currentTakeProfitPrice fixedpoint.Value + currentStopLossPrice fixedpoint.Value + + // StrategyController + qbtrade.StrategyController +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s", ID, s.Symbol) +} + +func (s *Strategy) Validate() error { + if len(s.Symbol) == 0 { + return errors.New("symbol is required") + } + + if len(s.Interval) == 0 { + return errors.New("interval is required") + } + + return nil +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.LinearRegression.Interval}) + + s.ExitMethods.SetAndSubscribe(session, s) + + // Profit tracker + if s.ProfitStatsTracker != nil { + s.ProfitStatsTracker.Subscribe(session, s.Symbol) + } +} + +// Position control + +func (s *Strategy) CurrentPosition() *types.Position { + return s.Position +} + +func (s *Strategy) ClosePosition(ctx context.Context, percentage fixedpoint.Value) error { + base := s.Position.GetBase() + if base.IsZero() { + return fmt.Errorf("no opened %s position", s.Position.Symbol) + } + + // make it negative + quantity := base.Mul(percentage).Abs() + side := types.SideTypeBuy + if base.Sign() > 0 { + side = types.SideTypeSell + } + + if quantity.Compare(s.Market.MinQuantity) < 0 { + return fmt.Errorf("%s order quantity %v is too small, less than %v", s.Symbol, quantity, s.Market.MinQuantity) + } + + orderForm := s.generateOrderForm(side, quantity, types.SideEffectTypeAutoRepay) + + qbtrade.Notify("submitting %s %s order to close position by %v", s.Symbol, side.String(), percentage, orderForm) + + _, err := s.orderExecutor.SubmitOrders(ctx, orderForm) + if err != nil { + log.WithError(err).Errorf("can not place %s position close order", s.Symbol) + qbtrade.Notify("can not place %s position close order", s.Symbol) + } + + return err +} + +// setupIndicators initializes indicators +func (s *Strategy) setupIndicators() { + // K-line store for indicators + kLineStore, _ := s.session.MarketDataStore(s.Symbol) + + // Double DEMA + s.doubleDema = newDoubleDema(kLineStore, s.Interval, s.FastDEMAWindow, s.SlowDEMAWindow) + + // Supertrend + if s.Window == 0 { + s.Window = 39 + } + if s.SupertrendMultiplier == 0 { + s.SupertrendMultiplier = 3 + } + s.Supertrend = &indicator.Supertrend{IntervalWindow: types.IntervalWindow{Window: s.Window, Interval: s.Interval}, ATRMultiplier: s.SupertrendMultiplier} + s.Supertrend.AverageTrueRange = &indicator.ATR{IntervalWindow: types.IntervalWindow{Window: s.Window, Interval: s.Interval}} + s.Supertrend.BindK(s.session.MarketDataStream, s.Symbol, s.Supertrend.Interval) + if klines, ok := kLineStore.KLinesOfInterval(s.Supertrend.Interval); ok { + s.Supertrend.LoadK((*klines)[0:]) + } + + // Linear Regression + if s.LinearRegression != nil { + if s.LinearRegression.Window == 0 { + s.LinearRegression = nil + } else if s.LinearRegression.Interval == "" { + s.LinearRegression = nil + } else { + s.LinearRegression.BindK(s.session.MarketDataStream, s.Symbol, s.LinearRegression.Interval) + if klines, ok := kLineStore.KLinesOfInterval(s.LinearRegression.Interval); ok { + s.LinearRegression.LoadK((*klines)[0:]) + } + } + } +} + +func (s *Strategy) shouldStop(kline types.KLine, stSignal types.Direction, demaSignal types.Direction, lgSignal types.Direction) bool { + stopNow := false + base := s.Position.GetBase() + baseSign := base.Sign() + + if s.StopLossByTriggeringK && !s.currentStopLossPrice.IsZero() && ((baseSign < 0 && kline.GetClose().Compare(s.currentStopLossPrice) > 0) || (baseSign > 0 && kline.GetClose().Compare(s.currentStopLossPrice) < 0)) { + // SL by triggering Kline low/high + qbtrade.Notify("%s stop loss by triggering the kline low/high", s.Symbol) + stopNow = true + } else if s.TakeProfitAtrMultiplier > 0 && !s.currentTakeProfitPrice.IsZero() && ((baseSign < 0 && kline.GetClose().Compare(s.currentTakeProfitPrice) < 0) || (baseSign > 0 && kline.GetClose().Compare(s.currentTakeProfitPrice) > 0)) { + // TP by multiple of ATR + qbtrade.Notify("%s take profit by multiple of ATR", s.Symbol) + stopNow = true + } else if s.StopByReversedSupertrend && ((baseSign < 0 && stSignal == types.DirectionUp) || (baseSign > 0 && stSignal == types.DirectionDown)) { + // Use supertrend signal to TP/SL + qbtrade.Notify("%s stop by the reversed signal of Supertrend", s.Symbol) + stopNow = true + } else if s.StopByReversedDema && ((baseSign < 0 && demaSignal == types.DirectionUp) || (baseSign > 0 && demaSignal == types.DirectionDown)) { + // Use DEMA signal to TP/SL + qbtrade.Notify("%s stop by the reversed signal of DEMA", s.Symbol) + stopNow = true + } else if s.StopByReversedLinGre && ((baseSign < 0 && lgSignal == types.DirectionUp) || (baseSign > 0 && lgSignal == types.DirectionDown)) { + // Use linear regression signal to TP/SL + qbtrade.Notify("%s stop by the reversed signal of linear regression", s.Symbol) + stopNow = true + } + + return stopNow +} + +func (s *Strategy) getSide(stSignal types.Direction, demaSignal types.Direction, lgSignal types.Direction) types.SideType { + var side types.SideType + + if stSignal == types.DirectionUp && demaSignal == types.DirectionUp && (s.LinearRegression == nil || lgSignal == types.DirectionUp) { + side = types.SideTypeBuy + } else if stSignal == types.DirectionDown && demaSignal == types.DirectionDown && (s.LinearRegression == nil || lgSignal == types.DirectionDown) { + side = types.SideTypeSell + } + + return side +} + +func (s *Strategy) generateOrderForm(side types.SideType, quantity fixedpoint.Value, marginOrderSideEffect types.MarginOrderSideEffectType) types.SubmitOrder { + orderForm := types.SubmitOrder{ + Symbol: s.Symbol, + Market: s.Market, + Side: side, + Type: types.OrderTypeMarket, + Quantity: quantity, + MarginSideEffect: marginOrderSideEffect, + } + + return orderForm +} + +// calculateQuantity returns leveraged quantity +func (s *Strategy) calculateQuantity(ctx context.Context, currentPrice fixedpoint.Value, side types.SideType) fixedpoint.Value { + // Quantity takes precedence + if !s.Quantity.IsZero() { + return s.Quantity + } + + usingLeverage := s.session.Margin || s.session.IsolatedMargin || s.session.Futures || s.session.IsolatedFutures + + if qbtrade.IsBackTesting { // Backtesting + balance, ok := s.session.GetAccount().Balance(s.Market.QuoteCurrency) + if !ok { + log.Errorf("can not update %s quote balance from exchange", s.Symbol) + return fixedpoint.Zero + } + + return balance.Available.Mul(fixedpoint.Min(s.Leverage, fixedpoint.One)).Div(currentPrice) + } else if !usingLeverage && side == types.SideTypeSell { // Spot sell + balance, ok := s.session.GetAccount().Balance(s.Market.BaseCurrency) + if !ok { + log.Errorf("can not update %s base balance from exchange", s.Symbol) + return fixedpoint.Zero + } + + return balance.Available.Mul(fixedpoint.Min(s.Leverage, fixedpoint.One)) + } else { // Using leverage or spot buy + quoteQty, err := qbtrade.CalculateQuoteQuantity(ctx, s.session, s.Market.QuoteCurrency, s.Leverage) + if err != nil { + log.WithError(err).Errorf("can not update %s quote balance from exchange", s.Symbol) + return fixedpoint.Zero + } + + return quoteQty.Div(currentPrice) + } +} + +func (s *Strategy) CalcAssetValue(price fixedpoint.Value) fixedpoint.Value { + balances := s.session.GetAccount().Balances() + return balances[s.Market.BaseCurrency].Total().Mul(price).Add(balances[s.Market.QuoteCurrency].Total()) +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + s.session = session + + s.currentStopLossPrice = fixedpoint.Zero + s.currentTakeProfitPrice = fixedpoint.Zero + + // calculate group id for orders + instanceID := s.InstanceID() + + // If position is nil, we need to allocate a new position for calculation + if s.Position == nil { + s.Position = types.NewPositionFromMarket(s.Market) + } + // Always update the position fields + s.Position.Strategy = ID + s.Position.StrategyInstanceID = s.InstanceID() + + // Profit stats + if s.ProfitStats == nil { + s.ProfitStats = types.NewProfitStats(s.Market) + } + + if s.TradeStats == nil { + s.TradeStats = types.NewTradeStats(s.Symbol) + } + + // Interval profit report + if qbtrade.IsBackTesting { + startTime := s.Environment.StartTime() + s.TradeStats.SetIntervalProfitCollector(types.NewIntervalProfitCollector(types.Interval1d, startTime)) + s.TradeStats.SetIntervalProfitCollector(types.NewIntervalProfitCollector(types.Interval1w, startTime)) + s.TradeStats.SetIntervalProfitCollector(types.NewIntervalProfitCollector(types.Interval1mo, startTime)) + } + + // Set fee rate + if s.session.MakerFeeRate.Sign() > 0 || s.session.TakerFeeRate.Sign() > 0 { + s.Position.SetExchangeFeeRate(s.session.ExchangeName, types.ExchangeFee{ + MakerFeeRate: s.session.MakerFeeRate, + TakerFeeRate: s.session.TakerFeeRate, + }) + } + + // Setup order executor + s.orderExecutor = qbtrade.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, s.Position) + s.orderExecutor.BindEnvironment(s.Environment) + s.orderExecutor.BindProfitStats(s.ProfitStats) + s.orderExecutor.BindTradeStats(s.TradeStats) + s.orderExecutor.Bind() + + // Setup profit tracker + if s.ProfitStatsTracker != nil { + if s.ProfitStatsTracker.CurrentProfitStats == nil { + s.ProfitStatsTracker.InitLegacy(s.Market, &s.ProfitStats, s.TradeStats) + } + + // Add strategy parameters to report + if s.TrackParameters && s.ProfitStatsTracker.AccumulatedProfitReport != nil { + s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("window", strconv.Itoa(s.Window)) + s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("multiplier", strconv.FormatFloat(s.SupertrendMultiplier, 'f', 2, 64)) + s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("fastDEMA", strconv.Itoa(s.FastDEMAWindow)) + s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("slowDEMA", strconv.Itoa(s.SlowDEMAWindow)) + s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("linReg", strconv.Itoa(s.LinearRegression.Window)) + s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("takeProfitAtrMultiplier", strconv.FormatFloat(s.TakeProfitAtrMultiplier, 'f', 2, 64)) + s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("stopLossByTriggeringK", strconv.FormatBool(s.StopLossByTriggeringK)) + s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("stopByReversedSupertrend", strconv.FormatBool(s.StopByReversedSupertrend)) + s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("stopByReversedDema", strconv.FormatBool(s.StopByReversedDema)) + s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("stopByReversedLinGre", strconv.FormatBool(s.StopByReversedLinGre)) + } + + s.ProfitStatsTracker.Bind(s.session, s.orderExecutor.TradeCollector()) + } + + // AccountValueCalculator + s.AccountValueCalculator = qbtrade.NewAccountValueCalculator(s.session, s.Market.QuoteCurrency) + + // For drawing + profitSlice := floats.Slice{1., 1.} + price, _ := session.LastPrice(s.Symbol) + initAsset := s.CalcAssetValue(price).Float64() + cumProfitSlice := floats.Slice{initAsset, initAsset} + + s.orderExecutor.TradeCollector().OnTrade(func(trade types.Trade, profit fixedpoint.Value, netProfit fixedpoint.Value) { + // For drawing/charting + price := trade.Price.Float64() + if s.buyPrice > 0 { + profitSlice.Update(price / s.buyPrice) + cumProfitSlice.Update(s.CalcAssetValue(trade.Price).Float64()) + } else if s.sellPrice > 0 { + profitSlice.Update(s.sellPrice / price) + cumProfitSlice.Update(s.CalcAssetValue(trade.Price).Float64()) + } + if s.Position.IsDust(trade.Price) { + s.buyPrice = 0 + s.sellPrice = 0 + s.highestPrice = 0 + s.lowestPrice = 0 + } else if s.Position.IsLong() { + s.buyPrice = price + s.sellPrice = 0 + s.highestPrice = s.buyPrice + s.lowestPrice = 0 + } else { + s.sellPrice = price + s.buyPrice = 0 + s.highestPrice = 0 + s.lowestPrice = s.sellPrice + } + }) + + s.InitDrawCommands(&profitSlice, &cumProfitSlice) + + // Sync position to redis on trade + s.orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { + qbtrade.Sync(ctx, s) + }) + + // StrategyController + s.Status = types.StrategyStatusRunning + s.OnSuspend(func() { + _ = s.orderExecutor.GracefulCancel(ctx) + qbtrade.Sync(ctx, s) + }) + s.OnEmergencyStop(func() { + _ = s.orderExecutor.GracefulCancel(ctx) + // Close 100% position + _ = s.ClosePosition(ctx, fixedpoint.One) + }) + + // Setup indicators + s.setupIndicators() + + // Exit methods + for _, method := range s.ExitMethods { + method.Bind(session, s.orderExecutor) + } + + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.Interval, func(kline types.KLine) { + // StrategyController + if s.Status != types.StrategyStatusRunning { + return + } + + closePrice := kline.GetClose() + openPrice := kline.GetOpen() + closePrice64 := closePrice.Float64() + openPrice64 := openPrice.Float64() + + // Supertrend signal + stSignal := s.Supertrend.GetSignal() + + // DEMA signal + demaSignal := s.doubleDema.getDemaSignal(openPrice64, closePrice64) + + // Linear Regression signal + var lgSignal types.Direction + if s.LinearRegression != nil { + lgSignal = s.LinearRegression.GetSignal() + } + + // TP/SL if there's non-dust position and meets the criteria + if !s.Market.IsDustQuantity(s.Position.GetBase().Abs(), closePrice) && s.shouldStop(kline, stSignal, demaSignal, lgSignal) { + if err := s.ClosePosition(ctx, fixedpoint.One); err == nil { + s.currentStopLossPrice = fixedpoint.Zero + s.currentTakeProfitPrice = fixedpoint.Zero + } + } + + // Get order side + side := s.getSide(stSignal, demaSignal, lgSignal) + // Set TP/SL price if needed + if side == types.SideTypeBuy { + if s.StopLossByTriggeringK { + s.currentStopLossPrice = kline.GetLow() + } + if s.TakeProfitAtrMultiplier > 0 { + s.currentTakeProfitPrice = closePrice.Add(fixedpoint.NewFromFloat(s.Supertrend.AverageTrueRange.Last(0) * s.TakeProfitAtrMultiplier)) + } + } else if side == types.SideTypeSell { + if s.StopLossByTriggeringK { + s.currentStopLossPrice = kline.GetHigh() + } + if s.TakeProfitAtrMultiplier > 0 { + s.currentTakeProfitPrice = closePrice.Sub(fixedpoint.NewFromFloat(s.Supertrend.AverageTrueRange.Last(0) * s.TakeProfitAtrMultiplier)) + } + } + + // Open position + // The default value of side is an empty string. Unless side is set by the checks above, the result of the following condition is false + if side == types.SideTypeSell || side == types.SideTypeBuy { + qbtrade.Notify("open %s position for signal %v", s.Symbol, side) + + amount := s.calculateQuantity(ctx, closePrice, side) + + // Add opposite position amount if any + if (side == types.SideTypeSell && s.Position.IsLong()) || (side == types.SideTypeBuy && s.Position.IsShort()) { + if qbtrade.IsBackTesting { + _ = s.ClosePosition(ctx, fixedpoint.One) + qbtrade.Notify("close existing %s position before open a new position", s.Symbol) + amount = s.calculateQuantity(ctx, closePrice, side) + } else { + qbtrade.Notify("add existing opposite position amount %f of %s to the amount %f of open new position order", s.Position.GetQuantity().Float64(), s.Symbol, amount.Float64()) + amount = amount.Add(s.Position.GetQuantity()) + } + } else if !s.Position.IsDust(closePrice) { + qbtrade.Notify("existing %s position has the same direction as the signal", s.Symbol) + return + } + + orderForm := s.generateOrderForm(side, amount, types.SideEffectTypeMarginBuy) + log.Infof("submit open position order %v", orderForm) + _, err := s.orderExecutor.SubmitOrders(ctx, orderForm) + if err != nil { + log.WithError(err).Errorf("can not place %s open position order", s.Symbol) + qbtrade.Notify("can not place %s open position order", s.Symbol) + } + } + })) + + // Graceful shutdown + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + // Output profit report + if s.ProfitStatsTracker != nil { + if s.ProfitStatsTracker.AccumulatedProfitReport != nil { + s.ProfitStatsTracker.AccumulatedProfitReport.Output() + } + } + + if qbtrade.IsBackTesting { + // Draw graph + if s.DrawGraph { + if err := s.Draw(&profitSlice, &cumProfitSlice); err != nil { + log.WithError(err).Errorf("cannot draw graph") + } + } + } + + _ = s.orderExecutor.GracefulCancel(ctx) + _, _ = fmt.Fprintln(os.Stderr, s.TradeStats.String()) + }) + + return nil +} diff --git a/pkg/strategy/support/strategy.go b/pkg/strategy/support/strategy.go new file mode 100644 index 0000000..5149f58 --- /dev/null +++ b/pkg/strategy/support/strategy.go @@ -0,0 +1,622 @@ +package support + +import ( + "context" + "fmt" + "sync" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ID = "support" + +var log = logrus.WithField("strategy", ID) + +var zeroiw = types.IntervalWindow{} + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type State struct { + Position *types.Position `json:"position,omitempty"` + CurrentHighestPrice *fixedpoint.Value `json:"currentHighestPrice,omitempty"` +} + +type Target struct { + ProfitPercentage fixedpoint.Value `json:"profitPercentage"` + QuantityPercentage fixedpoint.Value `json:"quantityPercentage"` + MarginOrderSideEffect types.MarginOrderSideEffectType `json:"marginOrderSideEffect"` +} + +// PercentageTargetStop is a kind of stop order by setting fixed percentage target +type PercentageTargetStop struct { + Targets []Target `json:"targets"` +} + +// GenerateOrders generates the orders from the given targets +func (stop *PercentageTargetStop) GenerateOrders(market types.Market, pos *types.Position) []types.SubmitOrder { + var price = pos.AverageCost + var quantity = pos.GetBase() + + // submit target orders + var targetOrders []types.SubmitOrder + for _, target := range stop.Targets { + targetPrice := price.Mul(fixedpoint.One.Add(target.ProfitPercentage)) + targetQuantity := quantity.Mul(target.QuantityPercentage) + targetQuoteQuantity := targetPrice.Mul(targetQuantity) + + if targetQuoteQuantity.Compare(market.MinNotional) <= 0 { + continue + } + + if targetQuantity.Compare(market.MinQuantity) <= 0 { + continue + } + + targetOrders = append(targetOrders, types.SubmitOrder{ + Symbol: market.Symbol, + Market: market, + Type: types.OrderTypeLimit, + Side: types.SideTypeSell, + Price: targetPrice, + Quantity: targetQuantity, + MarginSideEffect: target.MarginOrderSideEffect, + TimeInForce: types.TimeInForceGTC, + }) + } + + return targetOrders +} + +type TrailingStopTarget struct { + TrailingStopCallbackRatio fixedpoint.Value `json:"callbackRatio"` + MinimumProfitPercentage fixedpoint.Value `json:"minimumProfitPercentage"` +} + +type TrailingStopControl struct { + symbol string + market types.Market + marginSideEffect types.MarginOrderSideEffectType + + trailingStopCallbackRatio fixedpoint.Value + minimumProfitPercentage fixedpoint.Value + + CurrentHighestPrice fixedpoint.Value + StopOrder *types.Order +} + +func (control *TrailingStopControl) UpdateCurrentHighestPrice(p fixedpoint.Value) bool { + orig := control.CurrentHighestPrice + control.CurrentHighestPrice = fixedpoint.Max(control.CurrentHighestPrice, p) + return orig.Compare(control.CurrentHighestPrice) == 0 +} + +func (control *TrailingStopControl) IsHigherThanMin(minTargetPrice fixedpoint.Value) bool { + targetPrice := control.CurrentHighestPrice.Mul(fixedpoint.One.Sub(control.trailingStopCallbackRatio)) + + return targetPrice.Compare(minTargetPrice) >= 0 +} + +func (control *TrailingStopControl) GenerateStopOrder(quantity fixedpoint.Value) types.SubmitOrder { + targetPrice := control.CurrentHighestPrice.Mul(fixedpoint.One.Sub(control.trailingStopCallbackRatio)) + + orderForm := types.SubmitOrder{ + Symbol: control.symbol, + Market: control.market, + Side: types.SideTypeSell, + Type: types.OrderTypeStopLimit, + Quantity: quantity, + MarginSideEffect: control.marginSideEffect, + TimeInForce: types.TimeInForceGTC, + + Price: targetPrice, + StopPrice: targetPrice, + } + + return orderForm +} + +// Not implemented yet +// ResistanceStop is a kind of stop order by detecting resistance +// type ResistanceStop struct { +// Interval types.Interval `json:"interval"` +// sensitivity fixedpoint.Value `json:"sensitivity"` +// MinVolume fixedpoint.Value `json:"minVolume"` +// TakerBuyRatio fixedpoint.Value `json:"takerBuyRatio"` +// } + +type Strategy struct { + *qbtrade.Environment `json:"-"` + + session *qbtrade.ExchangeSession + + Symbol string `json:"symbol"` + Market types.Market `json:"-"` + + // Interval for checking support + Interval types.Interval `json:"interval"` + + // moving average window for checking support (support should be under the moving average line) + TriggerMovingAverage types.IntervalWindow `json:"triggerMovingAverage"` + + // LongTermMovingAverage is the second moving average line for checking support position + LongTermMovingAverage types.IntervalWindow `json:"longTermMovingAverage"` + + Quantity fixedpoint.Value `json:"quantity"` + MinVolume fixedpoint.Value `json:"minVolume"` + Sensitivity fixedpoint.Value `json:"sensitivity"` + TakerBuyRatio fixedpoint.Value `json:"takerBuyRatio"` + MarginOrderSideEffect types.MarginOrderSideEffectType `json:"marginOrderSideEffect"` + Targets []Target `json:"targets"` + + // Not implemented yet + // ResistanceStop *ResistanceStop `json:"resistanceStop"` + // + // ResistanceTakerBuyRatio fixedpoint.Value `json:"resistanceTakerBuyRatio"` + + // Min BaseAsset balance to keep + MinBaseAssetBalance fixedpoint.Value `json:"minBaseAssetBalance"` + // Max BaseAsset balance to buy + MaxBaseAssetBalance fixedpoint.Value `json:"maxBaseAssetBalance"` + MinQuoteAssetBalance fixedpoint.Value `json:"minQuoteAssetBalance"` + + ScaleQuantity *qbtrade.PriceVolumeScale `json:"scaleQuantity"` + + orderExecutor *qbtrade.GeneralOrderExecutor + + Position *types.Position `persistence:"position"` + ProfitStats *types.ProfitStats `persistence:"profit_stats"` + TradeStats *types.TradeStats `persistence:"trade_stats"` + CurrentHighestPrice fixedpoint.Value `persistence:"current_highest_price"` + + triggerEMA *indicator.EWMA + longTermEMA *indicator.EWMA + + // Trailing stop + TrailingStopTarget TrailingStopTarget `json:"trailingStopTarget"` + trailingStopControl *TrailingStopControl + + // StrategyController + qbtrade.StrategyController +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s", ID, s.Symbol) +} + +func (s *Strategy) Validate() error { + if s.Quantity.IsZero() && s.ScaleQuantity == nil { + return fmt.Errorf("quantity or scaleQuantity can not be zero") + } + + if s.MinVolume.IsZero() && s.Sensitivity.IsZero() { + return fmt.Errorf("either minVolume nor sensitivity can not be zero") + } + + return nil +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) + + if s.TriggerMovingAverage != zeroiw { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.TriggerMovingAverage.Interval}) + } + + if s.LongTermMovingAverage != zeroiw { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.LongTermMovingAverage.Interval}) + } +} + +func (s *Strategy) CurrentPosition() *types.Position { + return s.Position +} + +func (s *Strategy) ClosePosition(ctx context.Context, percentage fixedpoint.Value) error { + base := s.Position.GetBase() + if base.IsZero() { + return fmt.Errorf("no opened %s position", s.Position.Symbol) + } + + // make it negative + quantity := base.Mul(percentage).Abs() + side := types.SideTypeBuy + if base.Sign() > 0 { + side = types.SideTypeSell + } + + if quantity.Compare(s.Market.MinQuantity) < 0 { + return fmt.Errorf("order quantity %v is too small, less than %v", quantity, s.Market.MinQuantity) + } + + submitOrder := types.SubmitOrder{ + Symbol: s.Symbol, + Side: side, + Type: types.OrderTypeMarket, + Quantity: quantity, + Market: s.Market, + } + + qbtrade.Notify("Submitting %s %s order to close position by %v", s.Symbol, side.String(), percentage, submitOrder) + _, err := s.orderExecutor.SubmitOrders(ctx, submitOrder) + return err +} + +func (s *Strategy) submitOrders(ctx context.Context, orderExecutor qbtrade.OrderExecutor, orderForms ...types.SubmitOrder) (types.OrderSlice, error) { + return s.orderExecutor.SubmitOrders(ctx, orderForms...) +} + +var slippageModifier = fixedpoint.NewFromFloat(1.003) + +func (s *Strategy) calculateQuantity(session *qbtrade.ExchangeSession, side types.SideType, closePrice fixedpoint.Value, volume fixedpoint.Value) (fixedpoint.Value, error) { + var quantity fixedpoint.Value + if s.Quantity.Sign() > 0 { + quantity = s.Quantity + } else if s.ScaleQuantity != nil { + q, err := s.ScaleQuantity.Scale(closePrice.Float64(), volume.Float64()) + if err != nil { + return fixedpoint.Zero, err + } + quantity = fixedpoint.NewFromFloat(q) + } + + baseBalance, _ := session.GetAccount().Balance(s.Market.BaseCurrency) + if side == types.SideTypeSell { + // quantity = qbtrade.AdjustQuantityByMaxAmount(quantity, closePrice, quota) + if s.MinBaseAssetBalance.Sign() > 0 && + baseBalance.Total().Sub(quantity).Compare(s.MinBaseAssetBalance) < 0 { + quota := baseBalance.Available.Sub(s.MinBaseAssetBalance) + quantity = qbtrade.AdjustQuantityByMaxAmount(quantity, closePrice, quota) + } + + } else if side == types.SideTypeBuy { + if s.MaxBaseAssetBalance.Sign() > 0 && + baseBalance.Total().Add(quantity).Compare(s.MaxBaseAssetBalance) > 0 { + quota := s.MaxBaseAssetBalance.Sub(baseBalance.Total()) + quantity = qbtrade.AdjustQuantityByMaxAmount(quantity, closePrice, quota) + } + + quoteBalance, ok := session.GetAccount().Balance(s.Market.QuoteCurrency) + if !ok { + return fixedpoint.Zero, fmt.Errorf("quote balance %s not found", s.Market.QuoteCurrency) + } + + // for spot, we need to modify the quantity according to the quote balance + if !session.Margin { + // add 0.3% for price slippage + notional := closePrice.Mul(quantity).Mul(slippageModifier) + + if s.MinQuoteAssetBalance.Sign() > 0 && + quoteBalance.Available.Sub(notional).Compare(s.MinQuoteAssetBalance) < 0 { + log.Warnf("modifying quantity %v according to the min quote asset balance %v %s", + quantity, + quoteBalance.Available, + s.Market.QuoteCurrency) + quota := quoteBalance.Available.Sub(s.MinQuoteAssetBalance) + quantity = qbtrade.AdjustQuantityByMinAmount(quantity, closePrice, quota) + } else if notional.Compare(quoteBalance.Available) > 0 { + log.Warnf("modifying quantity %v according to the quote asset balance %v %s", + quantity, + quoteBalance.Available, + s.Market.QuoteCurrency) + quantity = qbtrade.AdjustQuantityByMaxAmount(quantity, closePrice, quoteBalance.Available) + } + } + } + + return quantity, nil +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + s.session = session + instanceID := s.InstanceID() + + if s.Position == nil { + s.Position = types.NewPositionFromMarket(s.Market) + } + + if s.ProfitStats == nil { + s.ProfitStats = types.NewProfitStats(s.Market) + } + + // trade stats + if s.TradeStats == nil { + s.TradeStats = types.NewTradeStats(s.Symbol) + } + + s.orderExecutor = qbtrade.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, s.Position) + s.orderExecutor.BindEnvironment(s.Environment) + s.orderExecutor.BindProfitStats(s.ProfitStats) + s.orderExecutor.BindTradeStats(s.TradeStats) + s.orderExecutor.Bind() + + // StrategyController + s.Status = types.StrategyStatusRunning + + s.OnSuspend(func() { + // Cancel all order + _ = s.orderExecutor.GracefulCancel(ctx) + qbtrade.Sync(ctx, s) + }) + + s.OnEmergencyStop(func() { + // Close 100% position + percentage := fixedpoint.NewFromFloat(1.0) + if err := s.ClosePosition(context.Background(), percentage); err != nil { + errMsg := "failed to close position" + log.WithError(err).Errorf(errMsg) + qbtrade.Notify(errMsg) + } + + if err := s.Suspend(); err != nil { + errMsg := "failed to suspend strategy" + log.WithError(err).Errorf(errMsg) + qbtrade.Notify(errMsg) + } + }) + + // set default values + if s.Interval == "" { + s.Interval = types.Interval5m + } + + if s.Sensitivity.Sign() > 0 { + volRange, err := s.ScaleQuantity.ByVolumeRule.Range() + if err != nil { + return err + } + + scaleUp := fixedpoint.NewFromFloat(volRange[1]) + scaleLow := fixedpoint.NewFromFloat(volRange[0]) + s.MinVolume = scaleUp.Sub(scaleLow). + Mul(fixedpoint.One.Sub(s.Sensitivity)). + Add(scaleLow) + log.Infof("adjusted minimal support volume to %s according to sensitivity %s", s.MinVolume.String(), s.Sensitivity.String()) + } + + standardIndicatorSet := session.StandardIndicatorSet(s.Symbol) + + if s.TriggerMovingAverage != zeroiw { + s.triggerEMA = standardIndicatorSet.EWMA(s.TriggerMovingAverage) + } else { + s.triggerEMA = standardIndicatorSet.EWMA(types.IntervalWindow{ + Interval: s.Interval, + Window: 99, // default window + }) + } + + if s.LongTermMovingAverage != zeroiw { + s.longTermEMA = standardIndicatorSet.EWMA(s.LongTermMovingAverage) + } + + if !s.TrailingStopTarget.TrailingStopCallbackRatio.IsZero() { + s.trailingStopControl = &TrailingStopControl{ + symbol: s.Symbol, + market: s.Market, + marginSideEffect: s.MarginOrderSideEffect, + trailingStopCallbackRatio: s.TrailingStopTarget.TrailingStopCallbackRatio, + minimumProfitPercentage: s.TrailingStopTarget.MinimumProfitPercentage, + CurrentHighestPrice: s.CurrentHighestPrice, + } + } + + if !s.TrailingStopTarget.TrailingStopCallbackRatio.IsZero() { + // Update trailing stop when the position changes + s.orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { + // StrategyController + if s.Status != types.StrategyStatusRunning { + return + } + + if !position.IsLong() || position.IsDust(position.AverageCost) { + return + } + + s.updateStopOrder(ctx) + }) + } + + session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { + // StrategyController + if s.Status != types.StrategyStatusRunning { + return + } + + // skip k-lines from other symbols + if kline.Symbol != s.Symbol { + return + } + if kline.Interval != s.Interval { + return + } + + closePrice := kline.GetClose() + highPrice := kline.GetHigh() + + // check our trailing stop + if s.TrailingStopTarget.TrailingStopCallbackRatio.Sign() > 0 { + if s.Position.IsLong() && !s.Position.IsDust(closePrice) { + changed := s.trailingStopControl.UpdateCurrentHighestPrice(highPrice) + if changed { + // Cancel the original order + s.updateStopOrder(ctx) + } + } + } + + // check support volume + if kline.Volume.Compare(s.MinVolume) < 0 { + return + } + + // check taker buy ratio, we need strong buy taker + if s.TakerBuyRatio.Sign() > 0 { + takerBuyRatio := kline.TakerBuyBaseAssetVolume.Div(kline.Volume) + takerBuyBaseVolumeThreshold := kline.Volume.Mul(s.TakerBuyRatio) + if takerBuyRatio.Compare(s.TakerBuyRatio) < 0 { + qbtrade.Notify("%s: taker buy base volume %s (volume ratio %s) is less than %s (volume ratio %s)", + s.Symbol, + kline.TakerBuyBaseAssetVolume.String(), + takerBuyRatio.String(), + takerBuyBaseVolumeThreshold.String(), + kline.Volume.String(), + s.TakerBuyRatio.String(), + kline, + ) + return + } + } + + if s.longTermEMA != nil && closePrice.Float64() < s.longTermEMA.Last(0) { + qbtrade.Notify("%s: closed price is below the long term moving average line %f, skipping this support", + s.Symbol, + s.longTermEMA.Last(0), + kline, + ) + return + } + + if s.triggerEMA != nil && closePrice.Float64() > s.triggerEMA.Last(0) { + qbtrade.Notify("%s: closed price is above the trigger moving average line %f, skipping this support", + s.Symbol, + s.triggerEMA.Last(0), + kline, + ) + return + } + + if s.triggerEMA != nil && s.longTermEMA != nil { + qbtrade.Notify("Found %s support: the close price %s is below trigger EMA %f and above long term EMA %f and volume %s > minimum volume %s", + s.Symbol, + closePrice.String(), + s.triggerEMA.Last(0), + s.longTermEMA.Last(0), + kline.Volume.String(), + s.MinVolume.String(), + kline) + } else { + qbtrade.Notify("Found %s support: the close price %s and volume %s > minimum volume %s", + s.Symbol, + closePrice.String(), + kline.Volume.String(), + s.MinVolume.String(), + kline) + } + + quantity, err := s.calculateQuantity(session, types.SideTypeBuy, closePrice, kline.Volume) + if err != nil { + log.WithError(err).Errorf("%s quantity calculation error", s.Symbol) + return + } + + orderForm := types.SubmitOrder{ + Symbol: s.Symbol, + Market: s.Market, + Side: types.SideTypeBuy, + Type: types.OrderTypeMarket, + Quantity: quantity, + MarginSideEffect: s.MarginOrderSideEffect, + } + + qbtrade.Notify("Submitting %s market order buy with quantity %s according to the base volume %s, taker buy base volume %s", + s.Symbol, + quantity.String(), + kline.Volume.String(), + kline.TakerBuyBaseAssetVolume.String(), + orderForm) + + if _, err := s.submitOrders(ctx, orderExecutor, orderForm); err != nil { + log.WithError(err).Error("submit order error") + return + } + + if s.TrailingStopTarget.TrailingStopCallbackRatio.IsZero() { // submit fixed target orders + var targetOrders []types.SubmitOrder + for _, target := range s.Targets { + targetPrice := closePrice.Mul(fixedpoint.One.Add(target.ProfitPercentage)) + targetQuantity := quantity.Mul(target.QuantityPercentage) + targetQuoteQuantity := targetPrice.Mul(targetQuantity) + + if targetQuoteQuantity.Compare(s.Market.MinNotional) <= 0 { + continue + } + + if targetQuantity.Compare(s.Market.MinQuantity) <= 0 { + continue + } + + targetOrders = append(targetOrders, types.SubmitOrder{ + Symbol: kline.Symbol, + Market: s.Market, + Type: types.OrderTypeLimit, + Side: types.SideTypeSell, + Price: targetPrice, + Quantity: targetQuantity, + + MarginSideEffect: target.MarginOrderSideEffect, + TimeInForce: types.TimeInForceGTC, + }) + } + + _, err = s.orderExecutor.SubmitOrders(ctx, targetOrders...) + if err != nil { + qbtrade.Notify("submit %s profit trailing stop order error: %s", s.Symbol, err.Error()) + } + } + }) + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + // Cancel trailing stop order + if s.TrailingStopTarget.TrailingStopCallbackRatio.Sign() > 0 { + _ = s.orderExecutor.GracefulCancel(ctx) + } + }) + + return nil +} + +func (s *Strategy) updateStopOrder(ctx context.Context) { + // cancel the original stop order + if s.trailingStopControl.StopOrder != nil { + if err := s.session.Exchange.CancelOrders(ctx, *s.trailingStopControl.StopOrder); err != nil { + log.WithError(err).Error("cancel order error") + } + s.trailingStopControl.StopOrder = nil + s.orderExecutor.TradeCollector().Process() + } + + // Calculate minimum target price + var minTargetPrice = fixedpoint.Zero + if s.trailingStopControl.minimumProfitPercentage.Sign() > 0 { + minTargetPrice = s.Position.AverageCost.Mul(fixedpoint.One.Add(s.trailingStopControl.minimumProfitPercentage)) + } + + // Place new order if the target price is higher than the minimum target price + if s.trailingStopControl.IsHigherThanMin(minTargetPrice) { + orderForm := s.trailingStopControl.GenerateStopOrder(s.Position.Base) + orders, err := s.orderExecutor.SubmitOrders(ctx, orderForm) + if err != nil { + qbtrade.Notify("failed to submit the trailing stop order on %s", s.Symbol) + log.WithError(err).Error("submit profit trailing stop order error") + } + + if len(orders) == 0 { + log.Error("unexpected error: len(createdOrders) = 0") + return + } + + s.trailingStopControl.StopOrder = &orders[0] + } +} diff --git a/pkg/strategy/swing/strategy.go b/pkg/strategy/swing/strategy.go new file mode 100644 index 0000000..4322db5 --- /dev/null +++ b/pkg/strategy/swing/strategy.go @@ -0,0 +1,168 @@ +package swing + +import ( + "context" + "fmt" + + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ID = "swing" + +// Float64Indicator is the indicators (SMA and EWMA) that we want to use are returning float64 data. +type Float64Indicator interface { + Last(int) float64 +} + +func init() { + // Register the pointer of the strategy struct, + // so that qbtrade knows what struct to be used to unmarshal the configs (YAML or JSON) + // Note: built-in strategies need to imported manually in the qbtrade cmd package. + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + // OrderExecutor is an interface for submitting order. + // This field will be injected automatically since it's a single exchange strategy. + qbtrade.OrderExecutor + + // if Symbol string field is defined, qbtrade will know it's a symbol-based strategy + // The following embedded fields will be injected with the corresponding instances. + + // MarketDataStore is a pointer only injection field. public trades, k-lines (candlestick) + // and order book updates are maintained in the market data store. + // This field will be injected automatically since we defined the Symbol field. + *qbtrade.MarketDataStore + + // StandardIndicatorSet contains the standard indicators of a market (symbol) + // This field will be injected automatically since we defined the Symbol field. + *qbtrade.StandardIndicatorSet + + // 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 + + // These fields will be filled from the config file (it translates YAML to JSON) + Symbol string `json:"symbol"` + + // Interval is the interval of the kline channel we want to subscribe, + // the kline event will trigger the strategy to check if we need to submit order. + Interval types.Interval `json:"interval"` + + // MinChange filters out the k-lines with small changes. so that our strategy will only be triggered + // in specific events. + MinChange fixedpoint.Value `json:"minChange"` + + // BaseQuantity is the base quantity of the submit order. for both BUY and SELL, market order will be used. + BaseQuantity fixedpoint.Value `json:"baseQuantity"` + + // MovingAverageType is the moving average indicator type that we want to use, + // it could be SMA or EWMA + MovingAverageType string `json:"movingAverageType"` + + // MovingAverageInterval is the interval of k-lines for the moving average indicator to calculate, + // it could be "1m", "5m", "1h" and so on. note that, the moving averages are calculated from + // the k-line data we subscribed + MovingAverageInterval types.Interval `json:"movingAverageInterval"` + + // MovingAverageWindow is the number of the window size of the moving average indicator. + // The number of k-lines in the window. generally used window sizes are 7, 25 and 99 in the TradingView. + MovingAverageWindow int `json:"movingAverageWindow"` +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + var inc Float64Indicator + var iw = types.IntervalWindow{Interval: s.MovingAverageInterval, Window: s.MovingAverageWindow} + + switch s.MovingAverageType { + case "SMA": + inc = s.StandardIndicatorSet.SMA(iw) + + case "EWMA", "EMA": + inc = s.StandardIndicatorSet.EWMA(iw) + + default: + return fmt.Errorf("unsupported moving average type: %s", s.MovingAverageType) + + } + + session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { + // skip k-lines from other symbols + if kline.Symbol != s.Symbol { + return + } + + movingAveragePrice := inc.Last(0) + + // skip it if it's near zero + if movingAveragePrice < 0.0001 { + return + } + + // skip if the change is not above the minChange + if kline.GetChange().Abs().Compare(s.MinChange) < 0 { + return + } + + closePrice := kline.Close + changePercentage := kline.GetChange().Div(kline.Open) + quantity := s.BaseQuantity.Mul(fixedpoint.One.Add(changePercentage.Abs())) + + trend := kline.Direction() + switch trend { + case types.DirectionUp: + // if it goes up and it's above the moving average price, then we sell + if closePrice.Float64() > movingAveragePrice { + s.notify(":chart_with_upwards_trend: closePrice %v is above movingAveragePrice %v, submitting SELL order", closePrice, movingAveragePrice) + + _, err := orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ + Symbol: s.Symbol, + Market: s.Market, + Side: types.SideTypeSell, + Type: types.OrderTypeMarket, + Quantity: quantity, + }) + if err != nil { + log.WithError(err).Error("submit order error") + } + } + case types.DirectionDown: + // if it goes down and it's below the moving average price, then we buy + if closePrice.Float64() < movingAveragePrice { + s.notify(":chart_with_downwards_trend: closePrice %v is below movingAveragePrice %v, submitting BUY order", closePrice, movingAveragePrice) + + _, err := orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ + Symbol: s.Symbol, + Market: s.Market, + Side: types.SideTypeBuy, + Type: types.OrderTypeMarket, + Quantity: quantity, + }) + if err != nil { + log.WithError(err).Error("submit order error") + } + } + } + }) + return nil +} + +func (s *Strategy) notify(format string, args ...interface{}) { + if channel, ok := qbtrade.Notification.RouteSymbol(s.Symbol); ok { + qbtrade.NotifyTo(channel, format, args...) + } else { + qbtrade.Notify(format, args...) + } +} diff --git a/pkg/strategy/techsignal/strategy.go b/pkg/strategy/techsignal/strategy.go new file mode 100644 index 0000000..73e58f2 --- /dev/null +++ b/pkg/strategy/techsignal/strategy.go @@ -0,0 +1,221 @@ +package techsignal + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/binance" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ID = "techsignal" + +var log = logrus.WithField("strategy", ID) + +func init() { + // Register the pointer of the strategy struct, + // so that qbtrade knows what struct to be used to unmarshal the configs (YAML or JSON) + // Note: built-in strategies need to imported manually in the qbtrade cmd package. + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + // These fields will be filled from the config file (it translates YAML to JSON) + Symbol string `json:"symbol"` + Market types.Market `json:"-"` + + FundingRate *struct { + High fixedpoint.Value `json:"high"` + Neutral fixedpoint.Value `json:"neutral"` + DiffThreshold fixedpoint.Value `json:"diffThreshold"` + } `json:"fundingRate"` + + SupportDetection []struct { + Interval types.Interval `json:"interval"` + + // MovingAverageType is the moving average indicator type that we want to use, + // it could be SMA or EWMA + MovingAverageType string `json:"movingAverageType"` + + // MovingAverageInterval is the interval of k-lines for the moving average indicator to calculate, + // it could be "1m", "5m", "1h" and so on. note that, the moving averages are calculated from + // the k-line data we subscribed + MovingAverageInterval types.Interval `json:"movingAverageInterval"` + + // MovingAverageWindow is the number of the window size of the moving average indicator. + // The number of k-lines in the window. generally used window sizes are 7, 25 and 99 in the TradingView. + MovingAverageWindow int `json:"movingAverageWindow"` + + MinVolume fixedpoint.Value `json:"minVolume"` + + MinQuoteVolume fixedpoint.Value `json:"minQuoteVolume"` + } `json:"supportDetection"` +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + // session.Subscribe(types.BookChannel, s.Symbol, types.SubscribeOptions{}) + for _, detection := range s.SupportDetection { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{ + Interval: detection.Interval, + }) + + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{ + Interval: detection.MovingAverageInterval, + }) + } +} + +func (s *Strategy) Validate() error { + if len(s.Symbol) == 0 { + return errors.New("symbol is required") + } + + return nil +} + +func (s *Strategy) listenToFundingRate(ctx context.Context, exchange *binance.Exchange) { + var previousIndex, fundingRate24HoursLowIndex *types.PremiumIndex + + fundingRateTicker := time.NewTicker(1 * time.Hour) + defer fundingRateTicker.Stop() + for { + select { + + case <-ctx.Done(): + return + + case <-fundingRateTicker.C: + index, err := exchange.QueryPremiumIndex(ctx, s.Symbol) + if err != nil { + log.WithError(err).Error("can not query last funding rate") + continue + } + + fundingRate := index.LastFundingRate + + if fundingRate.Compare(s.FundingRate.High) >= 0 { + qbtrade.Notify("%s funding rate %s is too high! threshold %s", + s.Symbol, + fundingRate.Percentage(), + s.FundingRate.High.Percentage(), + ) + } else { + if previousIndex != nil { + if s.FundingRate.DiffThreshold.IsZero() { + // 0.6% + s.FundingRate.DiffThreshold = fixedpoint.NewFromFloat(0.006 * 0.01) + } + + diff := fundingRate.Sub(previousIndex.LastFundingRate) + if diff.Abs().Compare(s.FundingRate.DiffThreshold) > 0 { + qbtrade.Notify("%s funding rate changed %s, current funding rate %s", + s.Symbol, + diff.SignedPercentage(), + fundingRate.Percentage(), + ) + } + } + } + + previousIndex = index + if fundingRate24HoursLowIndex != nil { + if fundingRate24HoursLowIndex.Time.Before(time.Now().Add(24 * time.Hour)) { + fundingRate24HoursLowIndex = index + } + if fundingRate.Compare(fundingRate24HoursLowIndex.LastFundingRate) < 0 { + fundingRate24HoursLowIndex = index + } + } else { + fundingRate24HoursLowIndex = index + } + } + } +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + standardIndicatorSet := session.StandardIndicatorSet(s.Symbol) + + if s.FundingRate != nil { + if binanceExchange, ok := session.Exchange.(*binance.Exchange); ok { + go s.listenToFundingRate(ctx, binanceExchange) + } else { + log.Error("exchange does not support funding rate api") + } + } + + session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { + // skip k-lines from other symbols + if kline.Symbol != s.Symbol { + return + } + + for _, detection := range s.SupportDetection { + if kline.Interval != detection.Interval { + continue + } + + closePrice := kline.GetClose() + + var ma types.Float64Indicator + + switch strings.ToLower(detection.MovingAverageType) { + case "sma": + ma = standardIndicatorSet.SMA(types.IntervalWindow{ + Interval: detection.MovingAverageInterval, + Window: detection.MovingAverageWindow, + }) + case "ema", "ewma": + ma = standardIndicatorSet.EWMA(types.IntervalWindow{ + Interval: detection.MovingAverageInterval, + Window: detection.MovingAverageWindow, + }) + default: + ma = standardIndicatorSet.EWMA(types.IntervalWindow{ + Interval: detection.MovingAverageInterval, + Window: detection.MovingAverageWindow, + }) + } + + var lastMA = ma.Last(0) + + // skip if the closed price is above the moving average + if closePrice.Float64() > lastMA { + log.Infof("skip %s support closed price %f > last ma %f", s.Symbol, closePrice.Float64(), lastMA) + return + } + + prettyBaseVolume := s.Market.BaseCurrencyFormatter() + prettyQuoteVolume := s.Market.QuoteCurrencyFormatter() + + if detection.MinVolume.Sign() > 0 && kline.Volume.Compare(detection.MinVolume) > 0 { + qbtrade.Notify("Detected %s %s support base volume %s > min base volume %s, quote volume %s", + s.Symbol, detection.Interval.String(), + prettyBaseVolume.FormatMoney(kline.Volume.Trunc()), + prettyBaseVolume.FormatMoney(detection.MinVolume.Trunc()), + prettyQuoteVolume.FormatMoney(kline.QuoteVolume.Trunc()), + ) + qbtrade.Notify(kline) + } else if detection.MinQuoteVolume.Sign() > 0 && kline.QuoteVolume.Compare(detection.MinQuoteVolume) > 0 { + qbtrade.Notify("Detected %s %s support quote volume %s > min quote volume %s, base volume %s", + s.Symbol, detection.Interval.String(), + prettyQuoteVolume.FormatMoney(kline.QuoteVolume.Trunc()), + prettyQuoteVolume.FormatMoney(detection.MinQuoteVolume.Trunc()), + prettyBaseVolume.FormatMoney(kline.Volume.Trunc()), + ) + qbtrade.Notify(kline) + } + } + }) + return nil +} diff --git a/pkg/strategy/trendtrader/strategy.go b/pkg/strategy/trendtrader/strategy.go new file mode 100644 index 0000000..2d3115a --- /dev/null +++ b/pkg/strategy/trendtrader/strategy.go @@ -0,0 +1,136 @@ +package trendtrader + +import ( + "context" + "fmt" + "os" + "sync" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/dynamic" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ID = "trendtrader" + +var one = fixedpoint.One +var zero = fixedpoint.Zero + +var log = logrus.WithField("strategy", ID) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type IntervalWindowSetting struct { + types.IntervalWindow +} + +type Strategy struct { + Environment *qbtrade.Environment + Symbol string `json:"symbol"` + Market types.Market + + types.IntervalWindow + + // persistence fields + Position *types.Position `persistence:"position"` + ProfitStats *types.ProfitStats `persistence:"profit_stats"` + TradeStats *types.TradeStats `persistence:"trade_stats"` + + activeOrders *qbtrade.ActiveOrderBook + + TrendLine *TrendLine `json:"trendLine"` + + ExitMethods qbtrade.ExitMethodSet `json:"exits"` + + session *qbtrade.ExchangeSession + orderExecutor *qbtrade.GeneralOrderExecutor + + // StrategyController + qbtrade.StrategyController +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + // session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Trend.Interval}) + // session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: types.Interval1m}) + + if s.TrendLine != nil { + dynamic.InheritStructValues(s.TrendLine, s) + s.TrendLine.Subscribe(session) + } + s.ExitMethods.SetAndSubscribe(session, s) +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s", ID, s.Symbol) +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + var instanceID = s.InstanceID() + + 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) + } + + // StrategyController + s.Status = types.StrategyStatusRunning + + s.OnSuspend(func() { + // Cancel active orders + _ = s.orderExecutor.GracefulCancel(ctx) + }) + + s.OnEmergencyStop(func() { + // Cancel active orders + _ = s.orderExecutor.GracefulCancel(ctx) + // Close 100% position + // _ = s.ClosePosition(ctx, fixedpoint.One) + }) + + // initial required information + s.session = session + + s.orderExecutor = qbtrade.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, s.Position) + s.orderExecutor.BindEnvironment(s.Environment) + s.orderExecutor.BindProfitStats(s.ProfitStats) + s.orderExecutor.BindTradeStats(s.TradeStats) + s.orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { + qbtrade.Sync(ctx, s) + }) + s.orderExecutor.Bind() + s.activeOrders = qbtrade.NewActiveOrderBook(s.Symbol) + + for _, method := range s.ExitMethods { + method.Bind(session, s.orderExecutor) + } + + if s.TrendLine != nil { + s.TrendLine.Bind(session, s.orderExecutor) + } + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + _, _ = fmt.Fprintln(os.Stderr, s.TradeStats.String()) + _ = s.orderExecutor.GracefulCancel(ctx) + }) + + return nil +} diff --git a/pkg/strategy/trendtrader/trend.go b/pkg/strategy/trendtrader/trend.go new file mode 100644 index 0000000..4e5d72e --- /dev/null +++ b/pkg/strategy/trendtrader/trend.go @@ -0,0 +1,162 @@ +package trendtrader + +import ( + "context" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type TrendLine struct { + Symbol string + Market types.Market `json:"-"` + types.IntervalWindow + + PivotRightWindow int `json:"pivotRightWindow"` + + // MarketOrder is the option to enable market order short. + MarketOrder bool `json:"marketOrder"` + + Quantity fixedpoint.Value `json:"quantity"` + + orderExecutor *qbtrade.GeneralOrderExecutor + session *qbtrade.ExchangeSession + activeOrders *qbtrade.ActiveOrderBook + + pivotHigh *indicator.PivotHigh + pivotLow *indicator.PivotLow + + qbtrade.QuantityOrAmount +} + +func (s *TrendLine) Subscribe(session *qbtrade.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.pivot != nil { + // session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) + // } +} + +func (s *TrendLine) Bind(session *qbtrade.ExchangeSession, orderExecutor *qbtrade.GeneralOrderExecutor) { + s.session = session + s.orderExecutor = orderExecutor + + position := orderExecutor.Position() + symbol := position.Symbol + standardIndicator := session.StandardIndicatorSet(s.Symbol) + + s.pivotHigh = standardIndicator.PivotHigh(types.IntervalWindow{ + Interval: s.Interval, + Window: int(3. * s.PivotRightWindow), RightWindow: &s.PivotRightWindow}) + + s.pivotLow = standardIndicator.PivotLow(types.IntervalWindow{ + Interval: s.Interval, + Window: int(3. * s.PivotRightWindow), RightWindow: &s.PivotRightWindow}) + + resistancePrices := types.NewQueue(3) + pivotHighDurationCounter := 0. + resistanceDuration := types.NewQueue(2) + supportPrices := types.NewQueue(3) + pivotLowDurationCounter := 0. + supportDuration := types.NewQueue(2) + + resistanceSlope := 0. + resistanceSlope1 := 0. + resistanceSlope2 := 0. + supportSlope := 0. + supportSlope1 := 0. + supportSlope2 := 0. + + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.Interval, func(kline types.KLine) { + if s.pivotHigh.Last(0) != resistancePrices.Last(0) { + resistancePrices.Update(s.pivotHigh.Last(0)) + resistanceDuration.Update(pivotHighDurationCounter) + pivotHighDurationCounter = 0 + } else { + pivotHighDurationCounter++ + } + if s.pivotLow.Last(0) != supportPrices.Last(0) { + supportPrices.Update(s.pivotLow.Last(0)) + supportDuration.Update(pivotLowDurationCounter) + pivotLowDurationCounter = 0 + } else { + pivotLowDurationCounter++ + } + + if line(resistancePrices.Index(2), resistancePrices.Index(1), resistancePrices.Index(0)) < 0 { + resistanceSlope1 = (resistancePrices.Index(1) - resistancePrices.Index(2)) / resistanceDuration.Index(1) + resistanceSlope2 = (resistancePrices.Index(0) - resistancePrices.Index(1)) / resistanceDuration.Index(0) + + resistanceSlope = (resistanceSlope1 + resistanceSlope2) / 2. + } + if line(supportPrices.Index(2), supportPrices.Index(1), supportPrices.Index(0)) > 0 { + supportSlope1 = (supportPrices.Index(1) - supportPrices.Index(2)) / supportDuration.Index(1) + supportSlope2 = (supportPrices.Index(0) - supportPrices.Index(1)) / supportDuration.Index(0) + + supportSlope = (supportSlope1 + supportSlope2) / 2. + } + + if converge(resistanceSlope, supportSlope) { + // y = mx+b + currentResistance := resistanceSlope*pivotHighDurationCounter + resistancePrices.Last(0) + currentSupport := supportSlope*pivotLowDurationCounter + supportPrices.Last(0) + log.Info(currentResistance, currentSupport, kline.Close) + + if kline.High.Float64() > currentResistance { + if position.IsShort() { + s.orderExecutor.ClosePosition(context.Background(), one) + } + if position.IsDust(kline.Close) || position.IsClosed() { + s.placeOrder(context.Background(), types.SideTypeBuy, s.Quantity, symbol) // OrAmount.CalculateQuantity(kline.Close) + } + + } else if kline.Low.Float64() < currentSupport { + if position.IsLong() { + s.orderExecutor.ClosePosition(context.Background(), one) + } + if position.IsDust(kline.Close) || position.IsClosed() { + s.placeOrder(context.Background(), types.SideTypeSell, s.Quantity, symbol) // OrAmount.CalculateQuantity(kline.Close) + } + } + } + })) + + if !qbtrade.IsBackTesting { + session.MarketDataStream.OnMarketTrade(func(trade types.Trade) { + }) + } +} + +func (s *TrendLine) placeOrder( + ctx context.Context, side types.SideType, quantity fixedpoint.Value, symbol string, +) error { + market, _ := s.session.Market(symbol) + _, err := s.orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ + Symbol: symbol, + Market: market, + Side: side, + Type: types.OrderTypeMarket, + Quantity: quantity, + Tag: "trend-break", + }) + if err != nil { + log.WithError(err).Errorf("can not place market order") + } + return err +} + +func line(p1, p2, p3 float64) int64 { + if p1 >= p2 && p2 >= p3 { + return -1 + } else if p1 <= p2 && p2 <= p3 { + return +1 + } + return 0 +} + +func converge(mr, ms float64) bool { + return ms > mr +} diff --git a/pkg/strategy/tri/debug.go b/pkg/strategy/tri/debug.go new file mode 100644 index 0000000..d5b9403 --- /dev/null +++ b/pkg/strategy/tri/debug.go @@ -0,0 +1,16 @@ +//go:build debug +// +build debug + +package tri + +const debugMode = true + +func debug(msg string, args ...any) { + log.Infof(msg, args...) +} + +func debugAssert(expr bool, msg string, args ...any) { + if !expr { + log.Errorf(msg, args...) + } +} diff --git a/pkg/strategy/tri/debug_null.go b/pkg/strategy/tri/debug_null.go new file mode 100644 index 0000000..efc6f21 --- /dev/null +++ b/pkg/strategy/tri/debug_null.go @@ -0,0 +1,9 @@ +//go:build !debug + +package tri + +const debugMode = false + +func debug(msg string, args ...any) {} + +func debugAssert(expr bool, msg string, args ...any) {} diff --git a/pkg/strategy/tri/market.go b/pkg/strategy/tri/market.go new file mode 100644 index 0000000..4caeab0 --- /dev/null +++ b/pkg/strategy/tri/market.go @@ -0,0 +1,131 @@ +package tri + +import ( + "fmt" + "math" + "strconv" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/sigchan" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type ArbMarket struct { + Symbol string + BaseCurrency, QuoteCurrency string + market types.Market + + stream types.Stream + book *types.StreamOrderBook + bestBid, bestAsk types.PriceVolume + buyRate, sellRate float64 + sigC sigchan.Chan + + truncateBaseQuantity, truncateQuoteQuantity QuantityTruncator +} + +func (m *ArbMarket) String() string { + return m.Symbol +} + +type QuantityTruncator func(value fixedpoint.Value) fixedpoint.Value + +func createBaseQuantityTruncator(m types.Market) QuantityTruncator { + var stepSizeFloat = m.StepSize.Float64() + var truncPrec = int(math.Round(math.Log10(stepSizeFloat) * -1.0)) + return createRoundedTruncator(truncPrec) +} + +func createPricePrecisionBasedQuoteQuantityTruncator(m types.Market) QuantityTruncator { + // note that MAX uses the price precision for its quote asset precision + return createRoundedTruncator(m.PricePrecision) +} + +func createRoundedTruncator(truncPrec int) QuantityTruncator { + var truncPow10 = math.Pow10(truncPrec) + var roundPrec = truncPrec + 1 + var roundPow10 = math.Pow10(roundPrec) + return func(value fixedpoint.Value) fixedpoint.Value { + v := value.Float64() + v = math.Trunc(math.Round(v*roundPow10)/10.0) / truncPow10 + s := strconv.FormatFloat(v, 'f', truncPrec, 64) + return fixedpoint.MustNewFromString(s) + } +} + +func (m *ArbMarket) getInitialBalance(balances types.BalanceMap, dir int) (fixedpoint.Value, string) { + if dir == 1 { // sell 1 BTC -> 19000 USDT + b, ok := balances[m.BaseCurrency] + if !ok { + return fixedpoint.Zero, m.BaseCurrency + } + + return m.truncateBaseQuantity(b.Available), m.BaseCurrency + } else if dir == -1 { + b, ok := balances[m.QuoteCurrency] + if !ok { + return fixedpoint.Zero, m.QuoteCurrency + } + + return m.truncateQuoteQuantity(b.Available), m.QuoteCurrency + } + + return fixedpoint.Zero, "" +} + +func (m *ArbMarket) calculateRatio(dir int) float64 { + if dir == 1 { // direct 1 = sell + if m.bestBid.Price.IsZero() || m.bestBid.Volume.Compare(m.market.MinQuantity) <= 0 { + return 0.0 + } + + return m.sellRate + } else if dir == -1 { + if m.bestAsk.Price.IsZero() || m.bestAsk.Volume.Compare(m.market.MinQuantity) <= 0 { + return 0.0 + } + + return m.buyRate + } + + return 0.0 +} + +func (m *ArbMarket) updateRate() { + m.buyRate = 1.0 / m.bestAsk.Price.Float64() + m.sellRate = m.bestBid.Price.Float64() + + if m.bestBid.Volume.Compare(m.market.MinQuantity) <= 0 && m.bestAsk.Volume.Compare(m.market.MinQuantity) <= 0 { + return + } + + m.sigC.Emit() +} + +func (m *ArbMarket) newOrder(dir int, transitingQuantity float64) (types.SubmitOrder, float64) { + if dir == 1 { // sell ETH -> BTC, sell USDT -> TWD + q, r := fitQuantityByBase(m.truncateBaseQuantity(m.bestBid.Volume).Float64(), transitingQuantity) + return types.SubmitOrder{ + Symbol: m.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeLimit, + Quantity: fixedpoint.NewFromFloat(q), + Price: m.bestBid.Price, + Market: m.market, + }, r + } else if dir == -1 { // use 1 BTC to buy X ETH + q, r := fitQuantityByQuote(m.bestAsk.Price.Float64(), m.truncateBaseQuantity(m.bestAsk.Volume).Float64(), transitingQuantity) + return types.SubmitOrder{ + Symbol: m.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: fixedpoint.NewFromFloat(q), + Price: m.bestAsk.Price, + Market: m.market, + }, r + } else { + panic(fmt.Errorf("unexpected direction: %v, valid values are (1, -1)", dir)) + } + + return types.SubmitOrder{}, 0.0 +} diff --git a/pkg/strategy/tri/path.go b/pkg/strategy/tri/path.go new file mode 100644 index 0000000..7ba2a24 --- /dev/null +++ b/pkg/strategy/tri/path.go @@ -0,0 +1,83 @@ +package tri + +import ( + "fmt" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type Path struct { + marketA, marketB, marketC *ArbMarket + dirA, dirB, dirC int +} + +func (p *Path) solveDirection() error { + // check if we should reverse the rate + // ETHUSDT -> ETHBTC + if p.marketA.QuoteCurrency == p.marketB.BaseCurrency || p.marketA.QuoteCurrency == p.marketB.QuoteCurrency { + p.dirA = 1 + } else if p.marketA.BaseCurrency == p.marketB.BaseCurrency || p.marketA.BaseCurrency == p.marketB.QuoteCurrency { + p.dirA = -1 + } else { + return fmt.Errorf("marketA and marketB is not related") + } + + if p.marketB.QuoteCurrency == p.marketC.BaseCurrency || p.marketB.QuoteCurrency == p.marketC.QuoteCurrency { + p.dirB = 1 + } else if p.marketB.BaseCurrency == p.marketC.BaseCurrency || p.marketB.BaseCurrency == p.marketC.QuoteCurrency { + p.dirB = -1 + } else { + return fmt.Errorf("marketB and marketC is not related") + } + + if p.marketC.QuoteCurrency == p.marketA.BaseCurrency || p.marketC.QuoteCurrency == p.marketA.QuoteCurrency { + p.dirC = 1 + } else if p.marketC.BaseCurrency == p.marketA.BaseCurrency || p.marketC.BaseCurrency == p.marketA.QuoteCurrency { + p.dirC = -1 + } else { + return fmt.Errorf("marketC and marketA is not related") + } + + return nil +} + +func (p *Path) Ready() bool { + return !(p.marketA.bestAsk.Price.IsZero() || p.marketA.bestBid.Price.IsZero() || + p.marketB.bestAsk.Price.IsZero() || p.marketB.bestBid.Price.IsZero() || + p.marketC.bestAsk.Price.IsZero() || p.marketC.bestBid.Price.IsZero()) +} + +func (p *Path) String() string { + return p.marketA.String() + " " + p.marketB.String() + " " + p.marketC.String() +} + +func (p *Path) newOrders(balances types.BalanceMap, sign int) [3]types.SubmitOrder { + var orders [3]types.SubmitOrder + var transitingQuantity float64 + + initialBalance, _ := p.marketA.getInitialBalance(balances, p.dirA*sign) + orderA, _ := p.marketA.newOrder(p.dirA*sign, initialBalance.Float64()) + orders[0] = orderA + + q, _ := orderA.Out() + transitingQuantity = q.Float64() + + // orderB + orderB, rateB := p.marketB.newOrder(p.dirB*sign, transitingQuantity) + orders = adjustOrderQuantityByRate(orders, rateB) + + q, _ = orderB.Out() + transitingQuantity = q.Float64() + orders[1] = orderB + + orderC, rateC := p.marketC.newOrder(p.dirC*sign, transitingQuantity) + orders = adjustOrderQuantityByRate(orders, rateC) + + q, _ = orderC.Out() + orders[2] = orderC + + orders[0].Quantity = p.marketA.market.TruncateQuantity(orders[0].Quantity) + orders[1].Quantity = p.marketB.market.TruncateQuantity(orders[1].Quantity) + orders[2].Quantity = p.marketC.market.TruncateQuantity(orders[2].Quantity) + return orders +} diff --git a/pkg/strategy/tri/position.go b/pkg/strategy/tri/position.go new file mode 100644 index 0000000..b8bcd1d --- /dev/null +++ b/pkg/strategy/tri/position.go @@ -0,0 +1,139 @@ +package tri + +import ( + "fmt" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type MultiCurrencyPosition struct { + Currencies map[string]fixedpoint.Value `json:"currencies"` + Markets map[string]types.Market `json:"markets"` + TotalProfits map[string]fixedpoint.Value `json:"totalProfits"` + Fees map[string]fixedpoint.Value `json:"fees"` + TradePrices map[string]fixedpoint.Value `json:"prices"` +} + +func NewMultiCurrencyPosition(markets map[string]types.Market) *MultiCurrencyPosition { + p := &MultiCurrencyPosition{ + Currencies: make(map[string]fixedpoint.Value), + Markets: make(map[string]types.Market), + TotalProfits: make(map[string]fixedpoint.Value), + TradePrices: make(map[string]fixedpoint.Value), + Fees: make(map[string]fixedpoint.Value), + } + + for _, market := range markets { + p.Markets[market.Symbol] = market + p.Currencies[market.BaseCurrency] = fixedpoint.Zero + p.Currencies[market.QuoteCurrency] = fixedpoint.Zero + p.TotalProfits[market.QuoteCurrency] = fixedpoint.Zero + p.TotalProfits[market.BaseCurrency] = fixedpoint.Zero + p.Fees[market.QuoteCurrency] = fixedpoint.Zero + p.Fees[market.BaseCurrency] = fixedpoint.Zero + p.TradePrices[market.QuoteCurrency] = fixedpoint.Zero + p.TradePrices[market.BaseCurrency] = fixedpoint.Zero + } + + return p +} + +func (p *MultiCurrencyPosition) handleTrade(trade types.Trade) { + market := p.Markets[trade.Symbol] + switch trade.Side { + case types.SideTypeBuy: + p.Currencies[market.BaseCurrency] = p.Currencies[market.BaseCurrency].Add(trade.Quantity) + p.Currencies[market.QuoteCurrency] = p.Currencies[market.QuoteCurrency].Sub(trade.QuoteQuantity) + + case types.SideTypeSell: + p.Currencies[market.BaseCurrency] = p.Currencies[market.BaseCurrency].Sub(trade.Quantity) + p.Currencies[market.QuoteCurrency] = p.Currencies[market.QuoteCurrency].Add(trade.QuoteQuantity) + } + + if types.IsUSDFiatCurrency(market.QuoteCurrency) { + p.TradePrices[market.BaseCurrency] = trade.Price + } else if types.IsUSDFiatCurrency(market.BaseCurrency) { // For USDT/TWD pair, convert USDT/TWD price to TWD/USDT + p.TradePrices[market.QuoteCurrency] = one.Div(trade.Price) + } + + if !trade.Fee.IsZero() { + if f, ok := p.Fees[trade.FeeCurrency]; ok { + p.Fees[trade.FeeCurrency] = f.Add(trade.Fee) + } else { + p.Fees[trade.FeeCurrency] = trade.Fee + } + } +} + +func (p *MultiCurrencyPosition) CollectProfits() []Profit { + var profits []Profit + for currency, base := range p.Currencies { + if base.IsZero() { + continue + } + + profit := Profit{ + Asset: currency, + Profit: base, + ProfitInUSD: fixedpoint.Zero, + } + + if price, ok := p.TradePrices[currency]; ok && !price.IsZero() { + profit.ProfitInUSD = base.Mul(price) + } else if types.IsUSDFiatCurrency(currency) { + profit.ProfitInUSD = base + } + + profits = append(profits, profit) + + if total, ok := p.TotalProfits[currency]; ok { + p.TotalProfits[currency] = total.Add(base) + } else { + p.TotalProfits[currency] = base + } + } + + p.Reset() + return profits +} + +func (p *MultiCurrencyPosition) Reset() { + for currency := range p.Currencies { + p.Currencies[currency] = fixedpoint.Zero + } +} + +func (p *MultiCurrencyPosition) String() (o string) { + o += "position: \n" + + for currency, base := range p.Currencies { + if base.IsZero() { + continue + } + + o += fmt.Sprintf("- %s: %f\n", currency, base.Float64()) + } + + o += "totalProfits: \n" + + for currency, total := range p.TotalProfits { + if total.IsZero() { + continue + } + + o += fmt.Sprintf("- %s: %f\n", currency, total.Float64()) + } + + o += "fees: \n" + + for currency, fee := range p.Fees { + if fee.IsZero() { + continue + } + + o += fmt.Sprintf("- %s: %f\n", currency, fee.Float64()) + } + + return o +} diff --git a/pkg/strategy/tri/profit.go b/pkg/strategy/tri/profit.go new file mode 100644 index 0000000..9c712ab --- /dev/null +++ b/pkg/strategy/tri/profit.go @@ -0,0 +1,60 @@ +package tri + +import ( + "github.com/slack-go/slack" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/style" +) + +type Profit struct { + Asset string `json:"asset"` + Profit fixedpoint.Value `json:"profit"` + ProfitInUSD fixedpoint.Value `json:"profitInUSD"` +} + +func (p *Profit) PlainText() string { + var title = "Arbitrage Profit " + title += style.PnLEmojiSimple(p.Profit) + " " + title += style.PnLSignString(p.Profit) + " " + p.Asset + + if !p.ProfitInUSD.IsZero() { + title += " ~= " + style.PnLSignString(p.ProfitInUSD) + " USD" + } + return title +} + +func (p *Profit) SlackAttachment() slack.Attachment { + var color = style.PnLColor(p.Profit) + var title = "Triangular PnL " + title += style.PnLEmojiSimple(p.Profit) + " " + title += style.PnLSignString(p.Profit) + " " + p.Asset + + if !p.ProfitInUSD.IsZero() { + title += " ~= " + style.PnLSignString(p.ProfitInUSD) + " USD" + } + + var fields []slack.AttachmentField + if !p.Profit.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Profit", + Value: style.PnLSignString(p.Profit) + " " + p.Asset, + Short: true, + }) + } + + if !p.ProfitInUSD.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Profit (~= USD)", + Value: style.PnLSignString(p.ProfitInUSD) + " USD", + Short: true, + }) + } + + return slack.Attachment{ + Color: color, + Title: title, + Fields: fields, + // Footer: "", + } +} diff --git a/pkg/strategy/tri/strategy.go b/pkg/strategy/tri/strategy.go new file mode 100644 index 0000000..500b1b4 --- /dev/null +++ b/pkg/strategy/tri/strategy.go @@ -0,0 +1,959 @@ +package tri + +import ( + "context" + "errors" + "fmt" + "math" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/sirupsen/logrus" + "go.uber.org/multierr" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/core" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/retry" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/sigchan" + "git.qtrade.icu/lychiyu/qbtrade/pkg/style" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +//go:generate bash symbols.sh + +const ID = "tri" + +var log = logrus.WithField("strategy", ID) + +var one = fixedpoint.One +var marketOrderProtectiveRatio = fixedpoint.NewFromFloat(0.008) +var balanceBufferRatio = fixedpoint.NewFromFloat(0.002) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Side int + +const Buy Side = 1 +const Sell Side = -1 + +func (s Side) String() string { + return s.SideType().String() +} + +func (s Side) SideType() types.SideType { + if s == 1 { + return types.SideTypeBuy + } + + return types.SideTypeSell +} + +type PathRank struct { + Path *Path + Ratio float64 +} + +// backward buy -> buy -> sell +func calculateBackwardRate(p *Path) float64 { + var ratio = 1.0 + ratio *= p.marketA.calculateRatio(-p.dirA) + ratio *= p.marketB.calculateRatio(-p.dirB) + ratio *= p.marketC.calculateRatio(-p.dirC) + return ratio +} + +// calculateForwardRatio +// path: BTCUSDT (0.000044 / 22830.410000) => USDTTWD (0.033220 / 30.101000) => BTCTWD (0.000001 / 687500.000000) <= -> 0.9995899221105569 <- 1.0000373943873788 +// 1.0 * 22830 * 30.101000 / 687500.000 + +// BTCUSDT (0.000044 / 22856.910000) => USDTTWD (0.033217 / 30.104000) => BTCTWD (0.000001 / 688002.100000) +// sell -> rate * 22856 +// sell -> rate * 30.104 +// buy -> rate / 688002.1 +// 1.0000798312 +func calculateForwardRatio(p *Path) float64 { + var ratio = 1.0 + ratio *= p.marketA.calculateRatio(p.dirA) + ratio *= p.marketB.calculateRatio(p.dirB) + ratio *= p.marketC.calculateRatio(p.dirC) + return ratio +} + +func adjustOrderQuantityByRate(orders [3]types.SubmitOrder, rate float64) [3]types.SubmitOrder { + if rate == 1.0 || math.IsNaN(rate) { + return orders + } + + for i, o := range orders { + orders[i].Quantity = o.Quantity.Mul(fixedpoint.NewFromFloat(rate)) + } + + return orders +} + +type State struct { + IOCWinTimes int `json:"iocWinningTimes"` + IOCLossTimes int `json:"iocLossTimes"` + IOCWinningRatio float64 `json:"iocWinningRatio"` +} + +type Strategy struct { + *qbtrade.Environment + + Symbols []string `json:"symbols"` + Paths [][]string `json:"paths"` + MinSpreadRatio fixedpoint.Value `json:"minSpreadRatio"` + SeparateStream bool `json:"separateStream"` + Limits map[string]fixedpoint.Value `json:"limits"` + CoolingDownTime types.Duration `json:"coolingDownTime"` + NotifyTrade bool `json:"notifyTrade"` + ResetPosition bool `json:"resetPosition"` + MarketOrderProtectiveRatio fixedpoint.Value `json:"marketOrderProtectiveRatio"` + IocOrderRatio fixedpoint.Value `json:"iocOrderRatio"` + DryRun bool `json:"dryRun"` + + markets map[string]types.Market + arbMarkets map[string]*ArbMarket + paths []*Path + + session *qbtrade.ExchangeSession + + activeOrders *qbtrade.ActiveOrderBook + orderStore *core.OrderStore + tradeCollector *core.TradeCollector + Position *MultiCurrencyPosition `persistence:"position"` + State *State `persistence:"state"` + TradeState *types.TradeStats `persistence:"trade_stats"` + sigC sigchan.Chan +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return ID + strings.Join(s.Symbols, "-") +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + if !s.SeparateStream { + for _, symbol := range s.Symbols { + session.Subscribe(types.BookChannel, symbol, types.SubscribeOptions{ + Depth: types.DepthLevelFull, + }) + } + } +} + +func (s *Strategy) executeOrder(ctx context.Context, order types.SubmitOrder) *types.Order { + waitTime := 100 * time.Millisecond + for maxTries := 100; maxTries >= 0; maxTries-- { + createdOrder, err := s.session.Exchange.SubmitOrder(ctx, order) + if err != nil { + log.WithError(err).Errorf("can not submit orders") + time.Sleep(waitTime) + waitTime *= 2 + continue + } + + s.orderStore.Add(*createdOrder) + s.activeOrders.Add(*createdOrder) + return createdOrder + } + + return nil +} + +func (s *Strategy) Defaults() error { + if s.TradeState == nil { + s.TradeState = types.NewTradeStats("") + } + + if len(s.Symbols) == 0 { + s.Symbols = collectSymbols(s.Paths) + } + + return nil +} + +func (s *Strategy) Initialize() error { + return nil +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + + s.Symbols = compileSymbols(s.Symbols) + + if s.MarketOrderProtectiveRatio.IsZero() { + s.MarketOrderProtectiveRatio = marketOrderProtectiveRatio + } + + if s.MinSpreadRatio.IsZero() { + s.MinSpreadRatio = fixedpoint.NewFromFloat(1.002) + } + + if s.State == nil { + s.State = &State{} + } + + s.markets = make(map[string]types.Market) + s.sigC = sigchan.New(10) + + s.session = session + s.orderStore = core.NewOrderStore("") + s.orderStore.AddOrderUpdate = true + s.orderStore.BindStream(session.UserDataStream) + + s.activeOrders = qbtrade.NewActiveOrderBook("") + s.activeOrders.BindStream(session.UserDataStream) + s.tradeCollector = core.NewTradeCollector("", nil, s.orderStore) + + for _, symbol := range s.Symbols { + market, ok := session.Market(symbol) + if !ok { + return fmt.Errorf("market not found: %s", symbol) + } + s.markets[symbol] = market + } + s.optimizeMarketQuantityPrecision() + + arbMarkets, err := s.buildArbMarkets(session, s.Symbols, s.SeparateStream, s.sigC) + if err != nil { + return err + } + + s.arbMarkets = arbMarkets + + if s.Position == nil { + s.Position = NewMultiCurrencyPosition(s.markets) + } + + if s.ResetPosition { + s.Position = NewMultiCurrencyPosition(s.markets) + } + + s.tradeCollector.OnTrade(func(trade types.Trade, profit fixedpoint.Value, netProfit fixedpoint.Value) { + s.Position.handleTrade(trade) + }) + + if s.NotifyTrade { + s.tradeCollector.OnTrade(func(trade types.Trade, profit fixedpoint.Value, netProfit fixedpoint.Value) { + qbtrade.Notify(trade) + }) + } + + s.tradeCollector.BindStream(session.UserDataStream) + + for _, market := range s.arbMarkets { + m := market + if s.SeparateStream { + log.Infof("connecting %s market stream...", m.Symbol) + if err := m.stream.Connect(ctx); err != nil { + return err + } + } + } + + // build paths + // rate update and check paths + for _, pathSymbols := range s.Paths { + if len(pathSymbols) != 3 { + return errors.New("a path must contains 3 symbols") + } + + p := &Path{ + marketA: s.arbMarkets[pathSymbols[0]], + marketB: s.arbMarkets[pathSymbols[1]], + marketC: s.arbMarkets[pathSymbols[2]], + } + + if p.marketA == nil { + return fmt.Errorf("market object of %s is missing", pathSymbols[0]) + } + + if p.marketB == nil { + return fmt.Errorf("market object of %s is missing", pathSymbols[1]) + } + + if p.marketC == nil { + return fmt.Errorf("market object of %s is missing", pathSymbols[2]) + } + + if err := p.solveDirection(); err != nil { + return err + } + + s.paths = append(s.paths, p) + } + + session.UserDataStream.OnAuth(func() { + go func() { + fs := []ratioFunction{calculateForwardRatio, calculateBackwardRate} + log.Infof("waiting for market prices ready...") + wait := true + for wait { + wait = false + for _, p := range s.paths { + if !p.Ready() { + wait = true + break + } + } + } + + log.Infof("all markets ready") + + for { + select { + case <-ctx.Done(): + return + case <-s.sigC: + minRatio := s.MinSpreadRatio.Float64() + for side, f := range fs { + ranks := s.calculateRanks(minRatio, f) + if len(ranks) == 0 { + break + } + + forward := side == 0 + bestRank := ranks[0] + if forward { + debug("%d paths elected, found best forward path %s profit %.5f%%", len(ranks), bestRank.Path, (bestRank.Ratio-1.0)*100.0) + } else { + debug("%d paths elected, found best backward path %s profit %.5f%%", len(ranks), bestRank.Path, (bestRank.Ratio-1.0)*100.0) + } + + logMarketPath(bestRank.Path) + + s.executePath(ctx, session, bestRank.Path, bestRank.Ratio, forward) + } + } + } + }() + }) + + return nil +} + +type ratioFunction func(p *Path) float64 + +func (s *Strategy) checkMinimalOrderQuantity(orders [3]types.SubmitOrder) error { + for _, order := range orders { + if order.Quantity.Compare(order.Market.MinQuantity) <= 0 { + return fmt.Errorf("%s order quantity is too small: %f < %f", + order.Symbol, + order.Quantity.Float64(), order.Market.MinQuantity.Float64()) + } + + if order.Quantity.Mul(order.Price).Compare(order.Market.MinNotional) <= 0 { + return fmt.Errorf("%s order min notional is too small: %f < %f", + order.Symbol, + order.Quantity.Mul(order.Price).Float64(), order.Market.MinNotional.Float64()) + } + } + + return nil +} + +func (s *Strategy) optimizeMarketQuantityPrecision() { + var baseMarkets = make(map[string][]types.Market) + for _, m := range s.markets { + baseMarkets[m.BaseCurrency] = append(baseMarkets[m.BaseCurrency], m) + } + + for _, markets := range baseMarkets { + var prec = -1 + for _, m := range markets { + if prec == -1 || m.VolumePrecision < prec { + prec = m.VolumePrecision + } + } + + if prec == -1 { + continue + } + + for _, m := range markets { + m.VolumePrecision = prec + s.markets[m.Symbol] = m + } + } +} + +func (s *Strategy) applyBalanceMaxQuantity(balances types.BalanceMap) types.BalanceMap { + if s.Limits == nil || len(s.Limits) == 0 { + return balances + } + + for c, b := range balances { + if limit, ok := s.Limits[c]; ok { + b.Available = fixedpoint.Min(b.Available, limit) + balances[c] = b + } + } + + return balances +} + +func (s *Strategy) addBalanceBuffer(balances types.BalanceMap) (out types.BalanceMap) { + out = types.BalanceMap{} + for c, b := range balances { + ab := b + ab.Available = ab.Available.Mul(one.Sub(balanceBufferRatio)) + out[c] = ab + } + + return out +} + +func (s *Strategy) toProtectiveMarketOrder(order types.SubmitOrder, ratio fixedpoint.Value) types.SubmitOrder { + sellRatio := one.Sub(ratio) + buyRatio := one.Add(ratio) + + switch order.Side { + case types.SideTypeSell: + order.Price = order.Price.Mul(sellRatio) + + case types.SideTypeBuy: + order.Price = order.Price.Mul(buyRatio) + } + + return order +} + +func (s *Strategy) toProtectiveMarketOrders(orders [3]types.SubmitOrder, ratio fixedpoint.Value) [3]types.SubmitOrder { + sellRatio := one.Sub(ratio) + buyRatio := one.Add(ratio) + for i, order := range orders { + switch order.Side { + case types.SideTypeSell: + order.Price = order.Price.Mul(sellRatio) + + case types.SideTypeBuy: + order.Price = order.Price.Mul(buyRatio) + } + + // order.Quantity = order.Market.TruncateQuantity(order.Quantity) + // order.Type = types.OrderTypeMarket + orders[i] = order + } + + return orders +} + +func (s *Strategy) executePath(ctx context.Context, session *qbtrade.ExchangeSession, p *Path, ratio float64, dir bool) { + balances := session.Account.Balances() + balances = s.addBalanceBuffer(balances) + balances = s.applyBalanceMaxQuantity(balances) + + var orders [3]types.SubmitOrder + if dir { + orders = p.newOrders(balances, 1) + } else { + orders = p.newOrders(balances, -1) + } + + if err := s.checkMinimalOrderQuantity(orders); err != nil { + log.WithError(err).Warnf("order quantity too small, skip") + return + } + + if s.DryRun { + logSubmitOrders(orders) + return + } + + createdOrders, err := s.iocOrderExecution(ctx, session, orders, ratio) + if err != nil { + log.WithError(err).Errorf("order execute error") + return + } + + if len(createdOrders) == 0 { + return + } + + log.Info(s.Position.String()) + + profits := s.Position.CollectProfits() + profitInUSD := fixedpoint.Zero + for _, profit := range profits { + qbtrade.Notify(&profit) + log.Info(profit.PlainText()) + + profitInUSD = profitInUSD.Add(profit.ProfitInUSD) + + // FIXME: + // s.TradeState.Add(&profit) + } + + notifyUsdPnL(profitInUSD) + + log.Info(s.TradeState.BriefString()) + + qbtrade.Sync(ctx, s) + + if s.CoolingDownTime > 0 { + log.Infof("cooling down for %s", s.CoolingDownTime.Duration().String()) + time.Sleep(s.CoolingDownTime.Duration()) + } +} + +func notifyUsdPnL(profit fixedpoint.Value) { + var title = "Triangular Sum PnL ~= " + title += style.PnLEmojiSimple(profit) + " " + title += style.PnLSignString(profit) + " USD" + qbtrade.Notify(title) +} + +func (s *Strategy) iocOrderExecution(ctx context.Context, session *qbtrade.ExchangeSession, orders [3]types.SubmitOrder, ratio float64) (types.OrderSlice, error) { + service, ok := session.Exchange.(types.ExchangeOrderQueryService) + if !ok { + return nil, errors.New("exchange does not support ExchangeOrderQueryService") + } + + var filledQuantity = fixedpoint.Zero + + // Change the first order to IOC + orders[0].Type = types.OrderTypeLimit + orders[0].TimeInForce = types.TimeInForceIOC + + var originalOrders [3]types.SubmitOrder + originalOrders[0] = orders[0] + originalOrders[1] = orders[1] + originalOrders[2] = orders[2] + logSubmitOrders(orders) + + if !s.IocOrderRatio.IsZero() { + orders[0] = s.toProtectiveMarketOrder(orders[0], s.IocOrderRatio) + } + + iocOrder := s.executeOrder(ctx, orders[0]) + if iocOrder == nil { + return nil, errors.New("ioc order submit error") + } + + iocOrderC := make(chan types.Order, 2) + defer func() { + close(iocOrderC) + }() + + go func() { + o, err := s.waitWebSocketOrderDone(ctx, iocOrder.OrderID, 300*time.Millisecond) + if err != nil { + // log.WithError(err).Errorf("ioc order wait error") + return + } else if o != nil { + select { + case <-ctx.Done(): + return + case iocOrderC <- *o: + default: + } + } + }() + + go func() { + o, err := retry.QueryOrderUntilFilled(ctx, service, iocOrder.Symbol, iocOrder.OrderID) + if err != nil { + log.WithError(err).Errorf("ioc order restful wait error") + return + } else if o != nil { + select { + case <-ctx.Done(): + return + case iocOrderC <- *o: + default: + } + } + }() + + o := <-iocOrderC + + filledQuantity = o.ExecutedQuantity + + if filledQuantity.IsZero() { + s.State.IOCLossTimes++ + + // we didn't get filled + log.Infof("%s %s IOC order did not get filled, skip: %+v", o.Symbol, o.Side, o) + return nil, nil + } + + filledRatio := filledQuantity.Div(iocOrder.Quantity) + qbtrade.Notify("%s %s IOC order got filled %f/%f (%s)", iocOrder.Symbol, iocOrder.Side, filledQuantity.Float64(), iocOrder.Quantity.Float64(), filledRatio.Percentage()) + log.Infof("%s %s IOC order got filled %f/%f", iocOrder.Symbol, iocOrder.Side, filledQuantity.Float64(), iocOrder.Quantity.Float64()) + + orders[1].Quantity = orders[1].Quantity.Mul(filledRatio) + orders[2].Quantity = orders[2].Quantity.Mul(filledRatio) + + orders[1].Quantity = orders[1].Market.TruncateQuantity(orders[1].Quantity) + orders[2].Quantity = orders[2].Market.TruncateQuantity(orders[2].Quantity) + + if orders[1].Quantity.Compare(orders[1].Market.MinQuantity) <= 0 { + log.Warnf("order #2 quantity %f is less than min quantity %f, skip", orders[1].Quantity.Float64(), orders[1].Market.MinQuantity.Float64()) + return nil, nil + } + + if orders[2].Quantity.Compare(orders[2].Market.MinQuantity) <= 0 { + log.Warnf("order #3 quantity %f is less than min quantity %f, skip", orders[2].Quantity.Float64(), orders[2].Market.MinQuantity.Float64()) + return nil, nil + } + + orders[1] = s.toProtectiveMarketOrder(orders[1], s.MarketOrderProtectiveRatio) + orders[2] = s.toProtectiveMarketOrder(orders[2], s.MarketOrderProtectiveRatio) + + var orderC = make(chan types.Order, 2) + var wg sync.WaitGroup + wg.Add(2) + + go func() { + o := s.executeOrder(ctx, orders[1]) + orderC <- *o + wg.Done() + }() + + go func() { + o := s.executeOrder(ctx, orders[2]) + orderC <- *o + wg.Done() + }() + + wg.Wait() + + var createdOrders = make(types.OrderSlice, 3) + createdOrders[0] = *iocOrder + createdOrders[1] = <-orderC + createdOrders[2] = <-orderC + close(orderC) + + orderTrades, updatedOrders, err := s.waitOrdersAndCollectTrades(ctx, service, createdOrders) + if err != nil { + log.WithError(err).Errorf("trade collecting error") + } else { + for i, order := range updatedOrders { + trades, hasTrades := orderTrades[order.OrderID] + if !hasTrades { + continue + } + averagePrice := tradeAveragePrice(trades, order.OrderID) + updatedOrders[i].AveragePrice = averagePrice + + if market, hasMarket := s.markets[order.Symbol]; hasMarket { + updatedOrders[i].Market = market + } + + for _, originalOrder := range originalOrders { + if originalOrder.Symbol == updatedOrders[i].Symbol { + updatedOrders[i].Price = originalOrder.Price + } + } + } + s.analyzeOrders(updatedOrders) + } + + // update ioc winning ratio + s.State.IOCWinTimes++ + if s.State.IOCLossTimes == 0 { + s.State.IOCWinningRatio = 999.0 + } else { + s.State.IOCWinningRatio = float64(s.State.IOCWinTimes) / float64(s.State.IOCLossTimes) + } + + log.Infof("ioc winning ratio update: %f", s.State.IOCWinningRatio) + + return createdOrders, nil +} + +func (s *Strategy) waitWebSocketOrderDone(ctx context.Context, orderID uint64, timeoutDuration time.Duration) (*types.Order, error) { + prof := util.StartTimeProfile("waitWebSocketOrderDone") + defer prof.StopAndLog(log.Infof) + + if order, ok := s.orderStore.Get(orderID); ok { + if order.Status == types.OrderStatusFilled || order.Status == types.OrderStatusCanceled { + return &order, nil + } + } + + timeoutC := time.After(timeoutDuration) + for { + select { + + case <-ctx.Done(): + return nil, ctx.Err() + + case <-timeoutC: + return nil, fmt.Errorf("order wait time timeout %s", timeoutDuration) + + case order := <-s.orderStore.C: + if orderID == order.OrderID && (order.Status == types.OrderStatusFilled || order.Status == types.OrderStatusCanceled) { + return &order, nil + } + } + } +} + +func (s *Strategy) waitOrdersAndCollectTrades(ctx context.Context, service types.ExchangeOrderQueryService, createdOrders types.OrderSlice) (map[uint64][]types.Trade, types.OrderSlice, error) { + var err error + var orderTrades = make(map[uint64][]types.Trade) + var updatedOrders types.OrderSlice + for _, o := range createdOrders { + updatedOrder, err2 := waitForOrderFilled(ctx, service, o, time.Second) + if err2 != nil { + err = multierr.Append(err, err2) + continue + } + + trades, err3 := service.QueryOrderTrades(ctx, types.OrderQuery{ + Symbol: o.Symbol, + OrderID: strconv.FormatUint(o.OrderID, 10), + }) + if err3 != nil { + err = multierr.Append(err, err3) + continue + } + + for _, t := range trades { + s.tradeCollector.ProcessTrade(t) + } + + orderTrades[o.OrderID] = trades + updatedOrders = append(updatedOrders, *updatedOrder) + } + + /* + */ + return orderTrades, updatedOrders, nil +} + +func (s *Strategy) analyzeOrders(orders types.OrderSlice) { + sort.Slice(orders, func(i, j int) bool { + // o1 < o2 -- earlier first + return orders[i].CreationTime.Before(orders[i].CreationTime.Time()) + }) + + log.Infof("ANALYZING ORDERS (Earlier First)") + for i, o := range orders { + in, inCurrency := o.In() + out, outCurrency := o.Out() + log.Infof("#%d %s IN %f %s -> OUT %f %s", i, o.String(), in.Float64(), inCurrency, out.Float64(), outCurrency) + } + + for _, o := range orders { + switch o.Side { + case types.SideTypeSell: + price := o.Price + priceDiff := o.AveragePrice.Sub(price) + slippage := priceDiff.Div(price) + log.Infof("%-8s %-4s %-10s AVG PRICE %f PRICE %f Q %f SLIPPAGE %.3f%%", o.Symbol, o.Side, o.Type, o.AveragePrice.Float64(), price.Float64(), o.Quantity.Float64(), slippage.Float64()*100.0) + + case types.SideTypeBuy: + price := o.Price + priceDiff := price.Sub(o.AveragePrice) + slippage := priceDiff.Div(price) + log.Infof("%-8s %-4s %-10s AVG PRICE %f PRICE %f Q %f SLIPPAGE %.3f%%", o.Symbol, o.Side, o.Type, o.AveragePrice.Float64(), price.Float64(), o.Quantity.Float64(), slippage.Float64()*100.0) + } + } +} + +func (s *Strategy) buildArbMarkets(session *qbtrade.ExchangeSession, symbols []string, separateStream bool, sigC sigchan.Chan) (map[string]*ArbMarket, error) { + markets := make(map[string]*ArbMarket) + // build market object + for _, symbol := range symbols { + market, ok := s.markets[symbol] + if !ok { + return nil, fmt.Errorf("market not found: %s", symbol) + } + + m := &ArbMarket{ + Symbol: symbol, + market: market, + BaseCurrency: market.BaseCurrency, + QuoteCurrency: market.QuoteCurrency, + sigC: sigC, + truncateBaseQuantity: createBaseQuantityTruncator(market), + truncateQuoteQuantity: createPricePrecisionBasedQuoteQuantityTruncator(market), + } + + if separateStream { + stream := session.Exchange.NewStream() + stream.SetPublicOnly() + stream.Subscribe(types.BookChannel, symbol, types.SubscribeOptions{ + Depth: types.DepthLevelFull, + Speed: types.SpeedHigh, + }) + + book := types.NewStreamBook(symbol) + priceUpdater := func(_ types.SliceOrderBook) { + bestBid, bestAsk, _ := book.BestBidAndAsk() + if bestAsk.Equals(m.bestAsk) && bestBid.Equals(m.bestBid) { + return + } + + m.bestBid = bestBid + m.bestAsk = bestAsk + m.updateRate() + } + book.OnUpdate(priceUpdater) + book.OnSnapshot(priceUpdater) + book.BindStream(stream) + + stream.OnDisconnect(func() { + // reset price and volume + m.bestBid = types.PriceVolume{} + m.bestAsk = types.PriceVolume{} + }) + + m.book = book + m.stream = stream + } else { + book, _ := session.OrderBook(symbol) + priceUpdater := func(_ types.SliceOrderBook) { + bestAsk, bestBid, _ := book.BestBidAndAsk() + if bestAsk.Equals(m.bestAsk) && bestBid.Equals(m.bestBid) { + return + } + + m.bestBid = bestBid + m.bestAsk = bestAsk + m.updateRate() + } + book.OnUpdate(priceUpdater) + book.OnSnapshot(priceUpdater) + + m.book = book + m.stream = session.MarketDataStream + } + + markets[symbol] = m + } + + return markets, nil +} + +func (s *Strategy) calculateRanks(minRatio float64, method func(p *Path) float64) []PathRank { + ranks := make([]PathRank, 0, len(s.paths)) + + // ranking paths here + for _, path := range s.paths { + ratio := method(path) + if ratio < minRatio { + continue + } + + p := path + ranks = append(ranks, PathRank{Path: p, Ratio: ratio}) + } + + // sort and pick up the top rank path + sort.Slice(ranks, func(i, j int) bool { + return ranks[i].Ratio > ranks[j].Ratio + }) + + return ranks +} + +func waitForOrderFilled( + ctx context.Context, ex types.ExchangeOrderQueryService, order types.Order, timeout time.Duration, +) (*types.Order, error) { + prof := util.StartTimeProfile("waitForOrderFilled") + defer prof.StopAndLog(log.Infof) + + timeoutC := time.After(timeout) + + for { + select { + case <-timeoutC: + return nil, fmt.Errorf("order wait timeout %s", timeout) + + default: + p := util.StartTimeProfile("queryOrder") + remoteOrder, err2 := ex.QueryOrder(ctx, types.OrderQuery{ + Symbol: order.Symbol, + OrderID: strconv.FormatUint(order.OrderID, 10), + }) + p.StopAndLog(log.Infof) + + if err2 != nil { + log.WithError(err2).Errorf("order query error") + time.Sleep(100 * time.Millisecond) + continue + } + + switch remoteOrder.Status { + case types.OrderStatusFilled, types.OrderStatusCanceled: + return remoteOrder, nil + default: + log.Infof("WAITING: %s", remoteOrder.String()) + time.Sleep(5 * time.Millisecond) + } + } + } +} + +func tradeAveragePrice(trades []types.Trade, orderID uint64) fixedpoint.Value { + totalAmount := fixedpoint.Zero + totalQuantity := fixedpoint.Zero + for _, trade := range trades { + if trade.OrderID != orderID { + continue + } + + totalAmount = totalAmount.Add(trade.Price.Mul(trade.Quantity)) + totalQuantity = totalQuantity.Add(trade.Quantity) + } + + return totalAmount.Div(totalQuantity) +} + +func displayBook(id string, market *ArbMarket) { + if !debugMode { + return + } + + var s strings.Builder + + s.WriteString(id + ") " + market.market.Symbol + "\n") + s.WriteString(fmt.Sprintf("bestAsk: %-12s x %s\n", + market.bestAsk.Price.String(), + market.bestAsk.Volume.String(), + )) + s.WriteString(fmt.Sprintf("bestBid: %-12s x %s\n", + market.bestBid.Price.String(), + market.bestBid.Volume.String(), + )) + + debug(s.String()) +} + +func logMarketPath(path *Path) { + if !debugMode { + return + } + + displayBook("A", path.marketA) + displayBook("B", path.marketB) + displayBook("C", path.marketC) +} + +func collectSymbols(paths [][]string) (symbols []string) { + symbolMap := make(map[string]struct{}) + for _, path := range paths { + for _, s := range path { + symbolMap[s] = struct{}{} + } + } + + for s := range symbolMap { + symbols = append(symbols, s) + } + + return symbols +} diff --git a/pkg/strategy/tri/strategy_test.go b/pkg/strategy/tri/strategy_test.go new file mode 100644 index 0000000..f9045ad --- /dev/null +++ b/pkg/strategy/tri/strategy_test.go @@ -0,0 +1,222 @@ +//go:build !dnum + +package tri + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +var markets = make(types.MarketMap) + +func init() { + if err := util.ReadJsonFile("../../../testdata/binance-markets.json", &markets); err != nil { + panic(err) + } +} + +func loadMarket(symbol string) types.Market { + if market, ok := markets[symbol]; ok { + return market + } + + panic(fmt.Errorf("market %s not found", symbol)) +} + +func newArbMarket(symbol, base, quote string, askPrice, askVolume, bidPrice, bidVolume float64) *ArbMarket { + market := loadMarket(symbol) + return &ArbMarket{ + Symbol: symbol, + BaseCurrency: base, + QuoteCurrency: quote, + market: market, + book: nil, + bestBid: types.PriceVolume{ + Price: fixedpoint.NewFromFloat(bidPrice), + Volume: fixedpoint.NewFromFloat(bidVolume), + }, + bestAsk: types.PriceVolume{ + Price: fixedpoint.NewFromFloat(askPrice), + Volume: fixedpoint.NewFromFloat(askVolume), + }, + buyRate: 1.0 / askPrice, + sellRate: bidPrice, + truncateBaseQuantity: createBaseQuantityTruncator(market), + truncateQuoteQuantity: createPricePrecisionBasedQuoteQuantityTruncator(market), + } + +} + +func TestPath_calculateBackwardRatio(t *testing.T) { + // BTCUSDT 22800.0 22700.0 + // ETHBTC 0.074, 0.073 + // ETHUSDT 1630.0 1620.0 + + // sell BTCUSDT @ 22700 ( 0.1 BTC => 2270 USDT) + // buy ETHUSDT @ 1630 ( 2270 USDT => 1.3926380368 ETH) + // sell ETHBTC @ 0.073 (1.3926380368 ETH => 0.1016625767 BTC) + marketA := newArbMarket("BTCUSDT", "BTC", "USDT", 22800.0, 1.0, 22700.0, 1.0) + marketB := newArbMarket("ETHBTC", "ETH", "BTC", 0.074, 2.0, 0.073, 2.0) + marketC := newArbMarket("ETHUSDT", "ETH", "USDT", 1630.0, 2.0, 1620.0, 2.0) + path := &Path{ + marketA: marketA, + marketB: marketB, + marketC: marketC, + dirA: -1, + dirB: -1, + dirC: 1, + } + ratio := calculateForwardRatio(path) + assert.Equal(t, 0.9601706970128022, ratio) + + ratio = calculateBackwardRate(path) + assert.Equal(t, 1.0166257668711656, ratio) +} + +func TestPath_CalculateForwardRatio(t *testing.T) { + // BTCUSDT 22800.0 22700.0 + // ETHBTC 0.070, 0.069 + // ETHUSDT 1630.0 1620.0 + + // buy BTCUSDT @ 22800 ( 2280 usdt => 0.1 BTC) + // buy ETHBTC @ 0.070 ( 0.1 BTC => 1.4285714286 ETH) + // sell ETHUSDT @ 1620 ( 1.4285714286 ETH => 2,314.285714332 USDT) + marketA := newArbMarket("BTCUSDT", "BTC", "USDT", 22800.0, 1.0, 22700.0, 1.0) + marketB := newArbMarket("ETHBTC", "ETH", "BTC", 0.070, 2.0, 0.069, 2.0) + marketC := newArbMarket("ETHUSDT", "ETH", "USDT", 1630.0, 2.0, 1620.0, 2.0) + path := &Path{ + marketA: marketA, + marketB: marketB, + marketC: marketC, + dirA: -1, + dirB: -1, + dirC: 1, + } + ratio := calculateForwardRatio(path) + assert.Equal(t, 1.015037593984962, ratio) + + ratio = calculateBackwardRate(path) + assert.Equal(t, 0.9609202453987732, ratio) +} + +func TestPath_newForwardOrders(t *testing.T) { + // BTCUSDT 22800.0 22700.0 + // ETHBTC 0.070, 0.069 + // ETHUSDT 1630.0 1620.0 + + // buy BTCUSDT @ 22800 ( 2280 usdt => 0.1 BTC) + // buy ETHBTC @ 0.070 ( 0.1 BTC => 1.4285714286 ETH) + // sell ETHUSDT @ 1620 ( 1.4285714286 ETH => 2,314.285714332 USDT) + marketA := newArbMarket("BTCUSDT", "BTC", "USDT", 22800.0, 1.0, 22700.0, 1.0) + marketB := newArbMarket("ETHBTC", "ETH", "BTC", 0.070, 2.0, 0.069, 2.0) + marketC := newArbMarket("ETHUSDT", "ETH", "USDT", 1630.0, 2.0, 1620.0, 2.0) + path := &Path{ + marketA: marketA, + marketB: marketB, + marketC: marketC, + dirA: -1, + dirB: -1, + dirC: 1, + } + orders := path.newOrders(types.BalanceMap{ + "USDT": { + Currency: "USDT", + Available: fixedpoint.NewFromFloat(2280.0), + Locked: fixedpoint.Zero, + Borrowed: fixedpoint.Zero, + Interest: fixedpoint.Zero, + NetAsset: fixedpoint.Zero, + }, + }, 1) + for i, order := range orders { + t.Logf("order #%d: %+v", i, order.String()) + } + + assert.InDelta(t, 2314.17, orders[2].Price.Mul(orders[2].Quantity).Float64(), 0.01) +} + +func TestPath_newForwardOrdersWithAdjustRate(t *testing.T) { + // BTCUSDT 22800.0 22700.0 + // ETHBTC 0.070, 0.069 + // ETHUSDT 1630.0 1620.0 + + // buy BTCUSDT @ 22800 (2280 usdt => 0.1 BTC) + // buy ETHBTC @ 0.070 (0.1 BTC => 1.4285714286 ETH) + + // APPLY ADJUST RATE B: 0.7 = 1 ETH / 1.4285714286 ETH + // buy BTCUSDT @ 22800 ( 1596 usdt => 0.07 BTC) + // buy ETHBTC @ 0.070 (0.07 BTC => 1 ETH) + // sell ETHUSDT @ 1620.0 (1 ETH => 1620 USDT) + + // APPLY ADJUST RATE C: 0.5 = 0.5 ETH / 1 ETH + // buy BTCUSDT @ 22800 ( 798 usdt => 0.0035 BTC) + // buy ETHBTC @ 0.070 (0.035 BTC => 0.5 ETH) + // sell ETHUSDT @ 1620.0 (0.5 ETH => 1620 USDT) + + // sell ETHUSDT @ 1620 ( 1.4285714286 ETH => 2,314.285714332 USDT) + marketA := newArbMarket("BTCUSDT", "BTC", "USDT", 22800.0, 1.0, 22700.0, 1.0) + marketB := newArbMarket("ETHBTC", "ETH", "BTC", 0.070, 1.0, 0.069, 2.0) + marketC := newArbMarket("ETHUSDT", "ETH", "USDT", 1630.0, 0.5, 1620.0, 0.5) + path := &Path{ + marketA: marketA, + marketB: marketB, + marketC: marketC, + dirA: -1, + dirB: -1, + dirC: 1, + } + orders := path.newOrders(types.BalanceMap{ + "USDT": { + Currency: "USDT", + Available: fixedpoint.NewFromFloat(2280.0), + Locked: fixedpoint.Zero, + Borrowed: fixedpoint.Zero, + Interest: fixedpoint.Zero, + NetAsset: fixedpoint.Zero, + }, + }, 1) + for i, order := range orders { + t.Logf("order #%d: %+v", i, order.String()) + } + + assert.Equal(t, "0.03499", orders[0].Quantity.String()) + assert.Equal(t, "0.5", orders[1].Quantity.String()) + assert.Equal(t, "0.5", orders[2].Quantity.String()) +} + +func Test_fitQuantityByQuote(t *testing.T) { + type args struct { + price float64 + quantity float64 + quoteBalance float64 + } + tests := []struct { + name string + args args + want float64 + }{ + { + name: "simple", + args: args{ + price: 1630.0, + quantity: 2.0, + quoteBalance: 1000, + }, + want: 0.6134969325153374, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, _ := fitQuantityByQuote(tt.args.price, tt.args.quantity, tt.args.quoteBalance) + if !assert.Equal(t, got, tt.want) { + t.Errorf("fitQuantityByQuote() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/strategy/tri/symbols.go b/pkg/strategy/tri/symbols.go new file mode 100644 index 0000000..9daebbf --- /dev/null +++ b/pkg/strategy/tri/symbols.go @@ -0,0 +1,2257 @@ +// Code generated by "bash symbols.sh"; DO NOT EDIT. +package tri +const ( + AAVEBKRW = "AAVEBKRW" + AAVEBNB = "AAVEBNB" + AAVEBRL = "AAVEBRL" + AAVEBTC = "AAVEBTC" + AAVEBUSD = "AAVEBUSD" + AAVEETH = "AAVEETH" + AAVEUSDT = "AAVEUSDT" + ACABTC = "ACABTC" + ACABUSD = "ACABUSD" + ACAUSDT = "ACAUSDT" + ACHBTC = "ACHBTC" + ACHBUSD = "ACHBUSD" + ACHTRY = "ACHTRY" + ACHUSDT = "ACHUSDT" + ACMBTC = "ACMBTC" + ACMBUSD = "ACMBUSD" + ACMUSDT = "ACMUSDT" + ADAAUD = "ADAAUD" + ADABIDR = "ADABIDR" + ADABKRW = "ADABKRW" + ADABNB = "ADABNB" + ADABRL = "ADABRL" + ADABTC = "ADABTC" + ADABUSD = "ADABUSD" + ADAETH = "ADAETH" + ADAEUR = "ADAEUR" + ADAGBP = "ADAGBP" + ADAPAX = "ADAPAX" + ADARUB = "ADARUB" + ADATRY = "ADATRY" + ADATUSD = "ADATUSD" + ADATWD = "ADATWD" + ADAUSDC = "ADAUSDC" + ADAUSDT = "ADAUSDT" + ADXBNB = "ADXBNB" + ADXBTC = "ADXBTC" + ADXBUSD = "ADXBUSD" + ADXETH = "ADXETH" + ADXUSDT = "ADXUSDT" + AEBNB = "AEBNB" + AEBTC = "AEBTC" + AEETH = "AEETH" + AERGOBTC = "AERGOBTC" + AERGOBUSD = "AERGOBUSD" + AERGOUSDT = "AERGOUSDT" + AGIBNB = "AGIBNB" + AGIBTC = "AGIBTC" + AGIETH = "AGIETH" + AGIXBTC = "AGIXBTC" + AGIXBUSD = "AGIXBUSD" + AGIXTRY = "AGIXTRY" + AGIXUSDT = "AGIXUSDT" + AGLDBNB = "AGLDBNB" + AGLDBTC = "AGLDBTC" + AGLDBUSD = "AGLDBUSD" + AGLDUSDT = "AGLDUSDT" + AIONBNB = "AIONBNB" + AIONBTC = "AIONBTC" + AIONBUSD = "AIONBUSD" + AIONETH = "AIONETH" + AIONUSDT = "AIONUSDT" + AKROBTC = "AKROBTC" + AKROBUSD = "AKROBUSD" + AKROUSDT = "AKROUSDT" + ALCXBTC = "ALCXBTC" + ALCXBUSD = "ALCXBUSD" + ALCXUSDT = "ALCXUSDT" + ALGOBIDR = "ALGOBIDR" + ALGOBNB = "ALGOBNB" + ALGOBTC = "ALGOBTC" + ALGOBUSD = "ALGOBUSD" + ALGOETH = "ALGOETH" + ALGOPAX = "ALGOPAX" + ALGORUB = "ALGORUB" + ALGOTRY = "ALGOTRY" + ALGOTUSD = "ALGOTUSD" + ALGOUSDC = "ALGOUSDC" + ALGOUSDT = "ALGOUSDT" + ALICEBIDR = "ALICEBIDR" + ALICEBNB = "ALICEBNB" + ALICEBTC = "ALICEBTC" + ALICEBUSD = "ALICEBUSD" + ALICETRY = "ALICETRY" + ALICETWD = "ALICETWD" + ALICEUSDT = "ALICEUSDT" + ALPACABNB = "ALPACABNB" + ALPACABTC = "ALPACABTC" + ALPACABUSD = "ALPACABUSD" + ALPACAUSDT = "ALPACAUSDT" + ALPHABNB = "ALPHABNB" + ALPHABTC = "ALPHABTC" + ALPHABUSD = "ALPHABUSD" + ALPHAUSDT = "ALPHAUSDT" + ALPINEBTC = "ALPINEBTC" + ALPINEBUSD = "ALPINEBUSD" + ALPINEEUR = "ALPINEEUR" + ALPINETRY = "ALPINETRY" + ALPINEUSDT = "ALPINEUSDT" + AMBBNB = "AMBBNB" + AMBBTC = "AMBBTC" + AMBBUSD = "AMBBUSD" + AMBETH = "AMBETH" + AMBUSDT = "AMBUSDT" + AMPBNB = "AMPBNB" + AMPBTC = "AMPBTC" + AMPBUSD = "AMPBUSD" + AMPUSDT = "AMPUSDT" + ANCBNB = "ANCBNB" + ANCBTC = "ANCBTC" + ANCBUSD = "ANCBUSD" + ANCUSDT = "ANCUSDT" + ANKRBNB = "ANKRBNB" + ANKRBTC = "ANKRBTC" + ANKRBUSD = "ANKRBUSD" + ANKRPAX = "ANKRPAX" + ANKRTRY = "ANKRTRY" + ANKRTUSD = "ANKRTUSD" + ANKRUSDC = "ANKRUSDC" + ANKRUSDT = "ANKRUSDT" + ANTBNB = "ANTBNB" + ANTBTC = "ANTBTC" + ANTBUSD = "ANTBUSD" + ANTUSDT = "ANTUSDT" + ANYBTC = "ANYBTC" + ANYBUSD = "ANYBUSD" + ANYUSDT = "ANYUSDT" + APEAUD = "APEAUD" + APEBNB = "APEBNB" + APEBRL = "APEBRL" + APEBTC = "APEBTC" + APEBUSD = "APEBUSD" + APEETH = "APEETH" + APEEUR = "APEEUR" + APEGBP = "APEGBP" + APETRY = "APETRY" + APETWD = "APETWD" + APEUSDT = "APEUSDT" + API3BNB = "API3BNB" + API3BTC = "API3BTC" + API3BUSD = "API3BUSD" + API3TRY = "API3TRY" + API3USDT = "API3USDT" + APPCBNB = "APPCBNB" + APPCBTC = "APPCBTC" + APPCETH = "APPCETH" + APTBRL = "APTBRL" + APTBTC = "APTBTC" + APTBUSD = "APTBUSD" + APTETH = "APTETH" + APTEUR = "APTEUR" + APTTRY = "APTTRY" + APTUSDT = "APTUSDT" + ARBBTC = "ARBBTC" + ARBEUR = "ARBEUR" + ARBNB = "ARBNB" + ARBRUB = "ARBRUB" + ARBTC = "ARBTC" + ARBTRY = "ARBTRY" + ARBTUSD = "ARBTUSD" + ARBUSD = "ARBUSD" + ARBUSDT = "ARBUSDT" + ARDRBNB = "ARDRBNB" + ARDRBTC = "ARDRBTC" + ARDRETH = "ARDRETH" + ARDRUSDT = "ARDRUSDT" + ARKBTC = "ARKBTC" + ARKBUSD = "ARKBUSD" + ARKETH = "ARKETH" + ARNBTC = "ARNBTC" + ARNETH = "ARNETH" + ARPABNB = "ARPABNB" + ARPABTC = "ARPABTC" + ARPABUSD = "ARPABUSD" + ARPAETH = "ARPAETH" + ARPARUB = "ARPARUB" + ARPATRY = "ARPATRY" + ARPAUSDT = "ARPAUSDT" + ARUSDT = "ARUSDT" + ASRBTC = "ASRBTC" + ASRBUSD = "ASRBUSD" + ASRUSDT = "ASRUSDT" + ASTBTC = "ASTBTC" + ASTETH = "ASTETH" + ASTRBTC = "ASTRBTC" + ASTRBUSD = "ASTRBUSD" + ASTRETH = "ASTRETH" + ASTRUSDT = "ASTRUSDT" + ASTUSDT = "ASTUSDT" + ATABNB = "ATABNB" + ATABTC = "ATABTC" + ATABUSD = "ATABUSD" + ATAUSDT = "ATAUSDT" + ATMBTC = "ATMBTC" + ATMBUSD = "ATMBUSD" + ATMUSDT = "ATMUSDT" + ATOMBIDR = "ATOMBIDR" + ATOMBNB = "ATOMBNB" + ATOMBRL = "ATOMBRL" + ATOMBTC = "ATOMBTC" + ATOMBUSD = "ATOMBUSD" + ATOMETH = "ATOMETH" + ATOMEUR = "ATOMEUR" + ATOMPAX = "ATOMPAX" + ATOMTRY = "ATOMTRY" + ATOMTUSD = "ATOMTUSD" + ATOMUSDC = "ATOMUSDC" + ATOMUSDT = "ATOMUSDT" + AUCTIONBTC = "AUCTIONBTC" + AUCTIONBUSD = "AUCTIONBUSD" + AUCTIONUSDT = "AUCTIONUSDT" + AUDBUSD = "AUDBUSD" + AUDIOBTC = "AUDIOBTC" + AUDIOBUSD = "AUDIOBUSD" + AUDIOTRY = "AUDIOTRY" + AUDIOUSDT = "AUDIOUSDT" + AUDUSDC = "AUDUSDC" + AUDUSDT = "AUDUSDT" + AUTOBTC = "AUTOBTC" + AUTOBUSD = "AUTOBUSD" + AUTOUSDT = "AUTOUSDT" + AVABNB = "AVABNB" + AVABTC = "AVABTC" + AVABUSD = "AVABUSD" + AVAUSDT = "AVAUSDT" + AVAXAUD = "AVAXAUD" + AVAXBIDR = "AVAXBIDR" + AVAXBNB = "AVAXBNB" + AVAXBRL = "AVAXBRL" + AVAXBTC = "AVAXBTC" + AVAXBUSD = "AVAXBUSD" + AVAXETH = "AVAXETH" + AVAXEUR = "AVAXEUR" + AVAXGBP = "AVAXGBP" + AVAXTRY = "AVAXTRY" + AVAXUSDT = "AVAXUSDT" + AXSAUD = "AXSAUD" + AXSBNB = "AXSBNB" + AXSBRL = "AXSBRL" + AXSBTC = "AXSBTC" + AXSBUSD = "AXSBUSD" + AXSETH = "AXSETH" + AXSTRY = "AXSTRY" + AXSUSDT = "AXSUSDT" + BADGERBTC = "BADGERBTC" + BADGERBUSD = "BADGERBUSD" + BADGERUSDT = "BADGERUSDT" + BAKEBNB = "BAKEBNB" + BAKEBTC = "BAKEBTC" + BAKEBUSD = "BAKEBUSD" + BAKEUSDT = "BAKEUSDT" + BALBNB = "BALBNB" + BALBTC = "BALBTC" + BALBUSD = "BALBUSD" + BALUSDT = "BALUSDT" + BANDBNB = "BANDBNB" + BANDBTC = "BANDBTC" + BANDBUSD = "BANDBUSD" + BANDUSDT = "BANDUSDT" + BARBTC = "BARBTC" + BARBUSD = "BARBUSD" + BARUSDT = "BARUSDT" + BATBNB = "BATBNB" + BATBTC = "BATBTC" + BATBUSD = "BATBUSD" + BATETH = "BATETH" + BATPAX = "BATPAX" + BATTUSD = "BATTUSD" + BATUSDC = "BATUSDC" + BATUSDT = "BATUSDT" + BCCBNB = "BCCBNB" + BCCBTC = "BCCBTC" + BCCETH = "BCCETH" + BCCUSDT = "BCCUSDT" + BCDBTC = "BCDBTC" + BCDETH = "BCDETH" + BCHABCBTC = "BCHABCBTC" + BCHABCBUSD = "BCHABCBUSD" + BCHABCPAX = "BCHABCPAX" + BCHABCTUSD = "BCHABCTUSD" + BCHABCUSDC = "BCHABCUSDC" + BCHABCUSDT = "BCHABCUSDT" + BCHABUSD = "BCHABUSD" + BCHBNB = "BCHBNB" + BCHBTC = "BCHBTC" + BCHBUSD = "BCHBUSD" + BCHEUR = "BCHEUR" + BCHPAX = "BCHPAX" + BCHSVBTC = "BCHSVBTC" + BCHSVPAX = "BCHSVPAX" + BCHSVTUSD = "BCHSVTUSD" + BCHSVUSDC = "BCHSVUSDC" + BCHSVUSDT = "BCHSVUSDT" + BCHTUSD = "BCHTUSD" + BCHTWD = "BCHTWD" + BCHUSDC = "BCHUSDC" + BCHUSDT = "BCHUSDT" + BCNBNB = "BCNBNB" + BCNBTC = "BCNBTC" + BCNETH = "BCNETH" + BCNTTWD = "BCNTTWD" + BCNTUSDT = "BCNTUSDT" + BCPTBNB = "BCPTBNB" + BCPTBTC = "BCPTBTC" + BCPTETH = "BCPTETH" + BCPTPAX = "BCPTPAX" + BCPTTUSD = "BCPTTUSD" + BCPTUSDC = "BCPTUSDC" + BDOTDOT = "BDOTDOT" + BEAMBNB = "BEAMBNB" + BEAMBTC = "BEAMBTC" + BEAMUSDT = "BEAMUSDT" + BEARBUSD = "BEARBUSD" + BEARUSDT = "BEARUSDT" + BELBNB = "BELBNB" + BELBTC = "BELBTC" + BELBUSD = "BELBUSD" + BELETH = "BELETH" + BELTRY = "BELTRY" + BELUSDT = "BELUSDT" + BETABNB = "BETABNB" + BETABTC = "BETABTC" + BETABUSD = "BETABUSD" + BETAETH = "BETAETH" + BETAUSDT = "BETAUSDT" + BETHBUSD = "BETHBUSD" + BETHETH = "BETHETH" + BETHUSDT = "BETHUSDT" + BGBPUSDC = "BGBPUSDC" + BICOBTC = "BICOBTC" + BICOBUSD = "BICOBUSD" + BICOUSDT = "BICOUSDT" + BIFIBNB = "BIFIBNB" + BIFIBUSD = "BIFIBUSD" + BIFIUSDT = "BIFIUSDT" + BKRWBUSD = "BKRWBUSD" + BKRWUSDT = "BKRWUSDT" + BLZBNB = "BLZBNB" + BLZBTC = "BLZBTC" + BLZBUSD = "BLZBUSD" + BLZETH = "BLZETH" + BLZUSDT = "BLZUSDT" + BNBAUD = "BNBAUD" + BNBBEARBUSD = "BNBBEARBUSD" + BNBBEARUSDT = "BNBBEARUSDT" + BNBBIDR = "BNBBIDR" + BNBBKRW = "BNBBKRW" + BNBBRL = "BNBBRL" + BNBBTC = "BNBBTC" + BNBBULLBUSD = "BNBBULLBUSD" + BNBBULLUSDT = "BNBBULLUSDT" + BNBBUSD = "BNBBUSD" + BNBDAI = "BNBDAI" + BNBETH = "BNBETH" + BNBEUR = "BNBEUR" + BNBGBP = "BNBGBP" + BNBIDRT = "BNBIDRT" + BNBNGN = "BNBNGN" + BNBPAX = "BNBPAX" + BNBRUB = "BNBRUB" + BNBTRY = "BNBTRY" + BNBTUSD = "BNBTUSD" + BNBTWD = "BNBTWD" + BNBUAH = "BNBUAH" + BNBUSDC = "BNBUSDC" + BNBUSDP = "BNBUSDP" + BNBUSDS = "BNBUSDS" + BNBUSDT = "BNBUSDT" + BNBUST = "BNBUST" + BNBZAR = "BNBZAR" + BNTBTC = "BNTBTC" + BNTBUSD = "BNTBUSD" + BNTETH = "BNTETH" + BNTUSDT = "BNTUSDT" + BNXBNB = "BNXBNB" + BNXBTC = "BNXBTC" + BNXBUSD = "BNXBUSD" + BNXUSDT = "BNXUSDT" + BONDBNB = "BONDBNB" + BONDBTC = "BONDBTC" + BONDBUSD = "BONDBUSD" + BONDETH = "BONDETH" + BONDUSDT = "BONDUSDT" + BOTBTC = "BOTBTC" + BOTBUSD = "BOTBUSD" + BQXBTC = "BQXBTC" + BQXETH = "BQXETH" + BRDBNB = "BRDBNB" + BRDBTC = "BRDBTC" + BRDETH = "BRDETH" + BSWBNB = "BSWBNB" + BSWBUSD = "BSWBUSD" + BSWETH = "BSWETH" + BSWTRY = "BSWTRY" + BSWUSDT = "BSWUSDT" + BTCARS = "BTCARS" + BTCAUD = "BTCAUD" + BTCBBTC = "BTCBBTC" + BTCBIDR = "BTCBIDR" + BTCBKRW = "BTCBKRW" + BTCBRL = "BTCBRL" + BTCBUSD = "BTCBUSD" + BTCDAI = "BTCDAI" + BTCEUR = "BTCEUR" + BTCGBP = "BTCGBP" + BTCIDRT = "BTCIDRT" + BTCNGN = "BTCNGN" + BTCPAX = "BTCPAX" + BTCPLN = "BTCPLN" + BTCRON = "BTCRON" + BTCRUB = "BTCRUB" + BTCSTBTC = "BTCSTBTC" + BTCSTBUSD = "BTCSTBUSD" + BTCSTUSDT = "BTCSTUSDT" + BTCTRY = "BTCTRY" + BTCTUSD = "BTCTUSD" + BTCTWD = "BTCTWD" + BTCUAH = "BTCUAH" + BTCUSDC = "BTCUSDC" + BTCUSDP = "BTCUSDP" + BTCUSDS = "BTCUSDS" + BTCUSDT = "BTCUSDT" + BTCUST = "BTCUST" + BTCVAI = "BTCVAI" + BTCZAR = "BTCZAR" + BTGBTC = "BTGBTC" + BTGBUSD = "BTGBUSD" + BTGETH = "BTGETH" + BTGUSDT = "BTGUSDT" + BTSBNB = "BTSBNB" + BTSBTC = "BTSBTC" + BTSBUSD = "BTSBUSD" + BTSETH = "BTSETH" + BTSUSDT = "BTSUSDT" + BTTBNB = "BTTBNB" + BTTBRL = "BTTBRL" + BTTBTC = "BTTBTC" + BTTBUSD = "BTTBUSD" + BTTCBUSD = "BTTCBUSD" + BTTCTRY = "BTTCTRY" + BTTCUSDC = "BTTCUSDC" + BTTCUSDT = "BTTCUSDT" + BTTEUR = "BTTEUR" + BTTPAX = "BTTPAX" + BTTTRX = "BTTTRX" + BTTTRY = "BTTTRY" + BTTTUSD = "BTTTUSD" + BTTUSDC = "BTTUSDC" + BTTUSDT = "BTTUSDT" + BULLBUSD = "BULLBUSD" + BULLUSDT = "BULLUSDT" + BURGERBNB = "BURGERBNB" + BURGERBUSD = "BURGERBUSD" + BURGERETH = "BURGERETH" + BURGERUSDT = "BURGERUSDT" + BUSDBIDR = "BUSDBIDR" + BUSDBKRW = "BUSDBKRW" + BUSDBRL = "BUSDBRL" + BUSDBVND = "BUSDBVND" + BUSDDAI = "BUSDDAI" + BUSDIDRT = "BUSDIDRT" + BUSDNGN = "BUSDNGN" + BUSDPLN = "BUSDPLN" + BUSDRON = "BUSDRON" + BUSDRUB = "BUSDRUB" + BUSDTRY = "BUSDTRY" + BUSDUAH = "BUSDUAH" + BUSDUSDT = "BUSDUSDT" + BUSDVAI = "BUSDVAI" + BUSDZAR = "BUSDZAR" + BZRXBNB = "BZRXBNB" + BZRXBTC = "BZRXBTC" + BZRXBUSD = "BZRXBUSD" + BZRXUSDT = "BZRXUSDT" + C98BNB = "C98BNB" + C98BRL = "C98BRL" + C98BTC = "C98BTC" + C98BUSD = "C98BUSD" + C98USDT = "C98USDT" + CAKEAUD = "CAKEAUD" + CAKEBNB = "CAKEBNB" + CAKEBRL = "CAKEBRL" + CAKEBTC = "CAKEBTC" + CAKEBUSD = "CAKEBUSD" + CAKEGBP = "CAKEGBP" + CAKEUSDT = "CAKEUSDT" + CDTBTC = "CDTBTC" + CDTETH = "CDTETH" + CELOBTC = "CELOBTC" + CELOBUSD = "CELOBUSD" + CELOUSDT = "CELOUSDT" + CELRBNB = "CELRBNB" + CELRBTC = "CELRBTC" + CELRBUSD = "CELRBUSD" + CELRETH = "CELRETH" + CELRUSDT = "CELRUSDT" + CFXBTC = "CFXBTC" + CFXBUSD = "CFXBUSD" + CFXTRY = "CFXTRY" + CFXTUSD = "CFXTUSD" + CFXUSDT = "CFXUSDT" + CHATBTC = "CHATBTC" + CHATETH = "CHATETH" + CHESSBNB = "CHESSBNB" + CHESSBTC = "CHESSBTC" + CHESSBUSD = "CHESSBUSD" + CHESSUSDT = "CHESSUSDT" + CHRBNB = "CHRBNB" + CHRBTC = "CHRBTC" + CHRBUSD = "CHRBUSD" + CHRETH = "CHRETH" + CHRUSDT = "CHRUSDT" + CHZBNB = "CHZBNB" + CHZBRL = "CHZBRL" + CHZBTC = "CHZBTC" + CHZBUSD = "CHZBUSD" + CHZEUR = "CHZEUR" + CHZGBP = "CHZGBP" + CHZTRY = "CHZTRY" + CHZUSDT = "CHZUSDT" + CITYBNB = "CITYBNB" + CITYBTC = "CITYBTC" + CITYBUSD = "CITYBUSD" + CITYTRY = "CITYTRY" + CITYUSDT = "CITYUSDT" + CKBBTC = "CKBBTC" + CKBBUSD = "CKBBUSD" + CKBUSDT = "CKBUSDT" + CLOAKBTC = "CLOAKBTC" + CLOAKETH = "CLOAKETH" + CLVBNB = "CLVBNB" + CLVBTC = "CLVBTC" + CLVBUSD = "CLVBUSD" + CLVUSDT = "CLVUSDT" + CMTBNB = "CMTBNB" + CMTBTC = "CMTBTC" + CMTETH = "CMTETH" + CNDBNB = "CNDBNB" + CNDBTC = "CNDBTC" + CNDETH = "CNDETH" + COCOSBNB = "COCOSBNB" + COCOSBTC = "COCOSBTC" + COCOSBUSD = "COCOSBUSD" + COCOSTRY = "COCOSTRY" + COCOSUSDT = "COCOSUSDT" + COMBOBNB = "COMBOBNB" + COMBOTRY = "COMBOTRY" + COMBOUSDT = "COMBOUSDT" + COMPBNB = "COMPBNB" + COMPBTC = "COMPBTC" + COMPBUSD = "COMPBUSD" + COMPTWD = "COMPTWD" + COMPUSDT = "COMPUSDT" + COSBNB = "COSBNB" + COSBTC = "COSBTC" + COSBUSD = "COSBUSD" + COSTRY = "COSTRY" + COSUSDT = "COSUSDT" + COTIBNB = "COTIBNB" + COTIBTC = "COTIBTC" + COTIBUSD = "COTIBUSD" + COTIUSDT = "COTIUSDT" + COVERBUSD = "COVERBUSD" + COVERETH = "COVERETH" + CREAMBNB = "CREAMBNB" + CREAMBUSD = "CREAMBUSD" + CRVBNB = "CRVBNB" + CRVBTC = "CRVBTC" + CRVBUSD = "CRVBUSD" + CRVETH = "CRVETH" + CRVUSDT = "CRVUSDT" + CTKBNB = "CTKBNB" + CTKBTC = "CTKBTC" + CTKBUSD = "CTKBUSD" + CTKUSDT = "CTKUSDT" + CTSIBNB = "CTSIBNB" + CTSIBTC = "CTSIBTC" + CTSIBUSD = "CTSIBUSD" + CTSIUSDT = "CTSIUSDT" + CTXCBNB = "CTXCBNB" + CTXCBTC = "CTXCBTC" + CTXCBUSD = "CTXCBUSD" + CTXCUSDT = "CTXCUSDT" + CVCBNB = "CVCBNB" + CVCBTC = "CVCBTC" + CVCBUSD = "CVCBUSD" + CVCETH = "CVCETH" + CVCUSDT = "CVCUSDT" + CVPBUSD = "CVPBUSD" + CVPETH = "CVPETH" + CVPUSDT = "CVPUSDT" + CVXBTC = "CVXBTC" + CVXBUSD = "CVXBUSD" + CVXUSDT = "CVXUSDT" + DAIBNB = "DAIBNB" + DAIBTC = "DAIBTC" + DAIBUSD = "DAIBUSD" + DAIUSDT = "DAIUSDT" + DARBNB = "DARBNB" + DARBTC = "DARBTC" + DARBUSD = "DARBUSD" + DARETH = "DARETH" + DAREUR = "DAREUR" + DARTRY = "DARTRY" + DARUSDT = "DARUSDT" + DASHBNB = "DASHBNB" + DASHBTC = "DASHBTC" + DASHBUSD = "DASHBUSD" + DASHETH = "DASHETH" + DASHUSDT = "DASHUSDT" + DATABTC = "DATABTC" + DATABUSD = "DATABUSD" + DATAETH = "DATAETH" + DATAUSDT = "DATAUSDT" + DCRBNB = "DCRBNB" + DCRBTC = "DCRBTC" + DCRBUSD = "DCRBUSD" + DCRUSDT = "DCRUSDT" + DEGOBTC = "DEGOBTC" + DEGOBUSD = "DEGOBUSD" + DEGOUSDT = "DEGOUSDT" + DENTBTC = "DENTBTC" + DENTBUSD = "DENTBUSD" + DENTETH = "DENTETH" + DENTTRY = "DENTTRY" + DENTUSDT = "DENTUSDT" + DEXEBUSD = "DEXEBUSD" + DEXEETH = "DEXEETH" + DEXEUSDT = "DEXEUSDT" + DFBUSD = "DFBUSD" + DFETH = "DFETH" + DFUSDT = "DFUSDT" + DGBBTC = "DGBBTC" + DGBBUSD = "DGBBUSD" + DGBUSDT = "DGBUSDT" + DGDBTC = "DGDBTC" + DGDETH = "DGDETH" + DIABNB = "DIABNB" + DIABTC = "DIABTC" + DIABUSD = "DIABUSD" + DIAUSDT = "DIAUSDT" + DLTBNB = "DLTBNB" + DLTBTC = "DLTBTC" + DLTETH = "DLTETH" + DNTBTC = "DNTBTC" + DNTBUSD = "DNTBUSD" + DNTETH = "DNTETH" + DNTUSDT = "DNTUSDT" + DOCKBTC = "DOCKBTC" + DOCKBUSD = "DOCKBUSD" + DOCKETH = "DOCKETH" + DOCKUSDT = "DOCKUSDT" + DODOBTC = "DODOBTC" + DODOBUSD = "DODOBUSD" + DODOUSDT = "DODOUSDT" + DOGEAUD = "DOGEAUD" + DOGEBIDR = "DOGEBIDR" + DOGEBNB = "DOGEBNB" + DOGEBRL = "DOGEBRL" + DOGEBTC = "DOGEBTC" + DOGEBUSD = "DOGEBUSD" + DOGEEUR = "DOGEEUR" + DOGEGBP = "DOGEGBP" + DOGEPAX = "DOGEPAX" + DOGERUB = "DOGERUB" + DOGETRY = "DOGETRY" + DOGETUSD = "DOGETUSD" + DOGETWD = "DOGETWD" + DOGEUSDC = "DOGEUSDC" + DOGEUSDT = "DOGEUSDT" + DOTAUD = "DOTAUD" + DOTBIDR = "DOTBIDR" + DOTBKRW = "DOTBKRW" + DOTBNB = "DOTBNB" + DOTBRL = "DOTBRL" + DOTBTC = "DOTBTC" + DOTBUSD = "DOTBUSD" + DOTETH = "DOTETH" + DOTEUR = "DOTEUR" + DOTGBP = "DOTGBP" + DOTNGN = "DOTNGN" + DOTRUB = "DOTRUB" + DOTTRY = "DOTTRY" + DOTTWD = "DOTTWD" + DOTUSDT = "DOTUSDT" + DREPBNB = "DREPBNB" + DREPBTC = "DREPBTC" + DREPBUSD = "DREPBUSD" + DREPUSDT = "DREPUSDT" + DUSKBNB = "DUSKBNB" + DUSKBTC = "DUSKBTC" + DUSKBUSD = "DUSKBUSD" + DUSKPAX = "DUSKPAX" + DUSKUSDC = "DUSKUSDC" + DUSKUSDT = "DUSKUSDT" + DYDXBNB = "DYDXBNB" + DYDXBTC = "DYDXBTC" + DYDXBUSD = "DYDXBUSD" + DYDXETH = "DYDXETH" + DYDXUSDT = "DYDXUSDT" + EASYBTC = "EASYBTC" + EASYETH = "EASYETH" + EDOBTC = "EDOBTC" + EDOETH = "EDOETH" + EDUBNB = "EDUBNB" + EDUBTC = "EDUBTC" + EDUEUR = "EDUEUR" + EDUTRY = "EDUTRY" + EDUTUSD = "EDUTUSD" + EDUUSDT = "EDUUSDT" + EGLDBNB = "EGLDBNB" + EGLDBTC = "EGLDBTC" + EGLDBUSD = "EGLDBUSD" + EGLDETH = "EGLDETH" + EGLDEUR = "EGLDEUR" + EGLDRON = "EGLDRON" + EGLDUSDT = "EGLDUSDT" + ELFBTC = "ELFBTC" + ELFBUSD = "ELFBUSD" + ELFETH = "ELFETH" + ELFUSDT = "ELFUSDT" + ENGBTC = "ENGBTC" + ENGETH = "ENGETH" + ENJBNB = "ENJBNB" + ENJBRL = "ENJBRL" + ENJBTC = "ENJBTC" + ENJBUSD = "ENJBUSD" + ENJETH = "ENJETH" + ENJEUR = "ENJEUR" + ENJGBP = "ENJGBP" + ENJTRY = "ENJTRY" + ENJUSDT = "ENJUSDT" + ENSBNB = "ENSBNB" + ENSBTC = "ENSBTC" + ENSBUSD = "ENSBUSD" + ENSTRY = "ENSTRY" + ENSTWD = "ENSTWD" + ENSUSDT = "ENSUSDT" + EOSAUD = "EOSAUD" + EOSBEARBUSD = "EOSBEARBUSD" + EOSBEARUSDT = "EOSBEARUSDT" + EOSBNB = "EOSBNB" + EOSBTC = "EOSBTC" + EOSBULLBUSD = "EOSBULLBUSD" + EOSBULLUSDT = "EOSBULLUSDT" + EOSBUSD = "EOSBUSD" + EOSETH = "EOSETH" + EOSEUR = "EOSEUR" + EOSPAX = "EOSPAX" + EOSTRY = "EOSTRY" + EOSTUSD = "EOSTUSD" + EOSUSDC = "EOSUSDC" + EOSUSDT = "EOSUSDT" + EPSBTC = "EPSBTC" + EPSBUSD = "EPSBUSD" + EPSUSDT = "EPSUSDT" + EPXBUSD = "EPXBUSD" + EPXUSDT = "EPXUSDT" + ERDBNB = "ERDBNB" + ERDBTC = "ERDBTC" + ERDBUSD = "ERDBUSD" + ERDPAX = "ERDPAX" + ERDUSDC = "ERDUSDC" + ERDUSDT = "ERDUSDT" + ERNBNB = "ERNBNB" + ERNBUSD = "ERNBUSD" + ERNUSDT = "ERNUSDT" + ETCBNB = "ETCBNB" + ETCBRL = "ETCBRL" + ETCBTC = "ETCBTC" + ETCBUSD = "ETCBUSD" + ETCETH = "ETCETH" + ETCEUR = "ETCEUR" + ETCGBP = "ETCGBP" + ETCPAX = "ETCPAX" + ETCTRY = "ETCTRY" + ETCTUSD = "ETCTUSD" + ETCTWD = "ETCTWD" + ETCUSDC = "ETCUSDC" + ETCUSDT = "ETCUSDT" + ETCUSDTETHBTC = "ETCUSDTETHBTC" + ETHAUD = "ETHAUD" + ETHBEARBUSD = "ETHBEARBUSD" + ETHBEARUSDT = "ETHBEARUSDT" + ETHBIDR = "ETHBIDR" + ETHBKRW = "ETHBKRW" + ETHBRL = "ETHBRL" + ETHBTC = "ETHBTC" + ETHBULLBUSD = "ETHBULLBUSD" + ETHBULLUSDT = "ETHBULLUSDT" + ETHBUSD = "ETHBUSD" + ETHDAI = "ETHDAI" + ETHEUR = "ETHEUR" + ETHGBP = "ETHGBP" + ETHNGN = "ETHNGN" + ETHPAX = "ETHPAX" + ETHPLN = "ETHPLN" + ETHRUB = "ETHRUB" + ETHTRY = "ETHTRY" + ETHTUSD = "ETHTUSD" + ETHTWD = "ETHTWD" + ETHUAH = "ETHUAH" + ETHUSDC = "ETHUSDC" + ETHUSDP = "ETHUSDP" + ETHUSDT = "ETHUSDT" + ETHUST = "ETHUST" + ETHZAR = "ETHZAR" + EURBUSD = "EURBUSD" + EURUSDT = "EURUSDT" + EVXBTC = "EVXBTC" + EVXETH = "EVXETH" + EZBTC = "EZBTC" + EZETH = "EZETH" + FARMBNB = "FARMBNB" + FARMBTC = "FARMBTC" + FARMBUSD = "FARMBUSD" + FARMETH = "FARMETH" + FARMUSDT = "FARMUSDT" + FETBNB = "FETBNB" + FETBTC = "FETBTC" + FETBUSD = "FETBUSD" + FETTRY = "FETTRY" + FETUSDT = "FETUSDT" + FIDABNB = "FIDABNB" + FIDABTC = "FIDABTC" + FIDABUSD = "FIDABUSD" + FIDAUSDT = "FIDAUSDT" + FILBNB = "FILBNB" + FILBTC = "FILBTC" + FILBUSD = "FILBUSD" + FILETH = "FILETH" + FILTRY = "FILTRY" + FILUSDT = "FILUSDT" + FIOBNB = "FIOBNB" + FIOBTC = "FIOBTC" + FIOBUSD = "FIOBUSD" + FIOUSDT = "FIOUSDT" + FIROBTC = "FIROBTC" + FIROBUSD = "FIROBUSD" + FIROETH = "FIROETH" + FIROUSDT = "FIROUSDT" + FISBIDR = "FISBIDR" + FISBRL = "FISBRL" + FISBTC = "FISBTC" + FISBUSD = "FISBUSD" + FISTRY = "FISTRY" + FISUSDT = "FISUSDT" + FLMBNB = "FLMBNB" + FLMBTC = "FLMBTC" + FLMBUSD = "FLMBUSD" + FLMUSDT = "FLMUSDT" + FLOKITRY = "FLOKITRY" + FLOKITUSD = "FLOKITUSD" + FLOKIUSDT = "FLOKIUSDT" + FLOWBNB = "FLOWBNB" + FLOWBTC = "FLOWBTC" + FLOWBUSD = "FLOWBUSD" + FLOWUSDT = "FLOWUSDT" + FLUXBTC = "FLUXBTC" + FLUXBUSD = "FLUXBUSD" + FLUXUSDT = "FLUXUSDT" + FORBNB = "FORBNB" + FORBTC = "FORBTC" + FORBUSD = "FORBUSD" + FORTHBTC = "FORTHBTC" + FORTHBUSD = "FORTHBUSD" + FORTHUSDT = "FORTHUSDT" + FORUSDT = "FORUSDT" + FRONTBTC = "FRONTBTC" + FRONTBUSD = "FRONTBUSD" + FRONTETH = "FRONTETH" + FRONTUSDT = "FRONTUSDT" + FTMAUD = "FTMAUD" + FTMBIDR = "FTMBIDR" + FTMBNB = "FTMBNB" + FTMBRL = "FTMBRL" + FTMBTC = "FTMBTC" + FTMBUSD = "FTMBUSD" + FTMETH = "FTMETH" + FTMEUR = "FTMEUR" + FTMPAX = "FTMPAX" + FTMRUB = "FTMRUB" + FTMTRY = "FTMTRY" + FTMTUSD = "FTMTUSD" + FTMUSDC = "FTMUSDC" + FTMUSDT = "FTMUSDT" + FTTBNB = "FTTBNB" + FTTBTC = "FTTBTC" + FTTBUSD = "FTTBUSD" + FTTETH = "FTTETH" + FTTUSDT = "FTTUSDT" + FUELBTC = "FUELBTC" + FUELETH = "FUELETH" + FUNBNB = "FUNBNB" + FUNBTC = "FUNBTC" + FUNETH = "FUNETH" + FUNUSDT = "FUNUSDT" + FXSBTC = "FXSBTC" + FXSBUSD = "FXSBUSD" + FXSUSDT = "FXSUSDT" + GALAAUD = "GALAAUD" + GALABNB = "GALABNB" + GALABRL = "GALABRL" + GALABTC = "GALABTC" + GALABUSD = "GALABUSD" + GALAETH = "GALAETH" + GALAEUR = "GALAEUR" + GALATRY = "GALATRY" + GALATWD = "GALATWD" + GALAUSDT = "GALAUSDT" + GALBNB = "GALBNB" + GALBRL = "GALBRL" + GALBTC = "GALBTC" + GALBUSD = "GALBUSD" + GALETH = "GALETH" + GALEUR = "GALEUR" + GALTRY = "GALTRY" + GALUSDT = "GALUSDT" + GASBTC = "GASBTC" + GASBUSD = "GASBUSD" + GASUSDT = "GASUSDT" + GBPBUSD = "GBPBUSD" + GBPUSDT = "GBPUSDT" + GFTBUSD = "GFTBUSD" + GHSTBUSD = "GHSTBUSD" + GHSTETH = "GHSTETH" + GHSTUSDT = "GHSTUSDT" + GLMBTC = "GLMBTC" + GLMBUSD = "GLMBUSD" + GLMETH = "GLMETH" + GLMRBNB = "GLMRBNB" + GLMRBTC = "GLMRBTC" + GLMRBUSD = "GLMRBUSD" + GLMRUSDT = "GLMRUSDT" + GLMUSDT = "GLMUSDT" + GMTAUD = "GMTAUD" + GMTBNB = "GMTBNB" + GMTBRL = "GMTBRL" + GMTBTC = "GMTBTC" + GMTBUSD = "GMTBUSD" + GMTETH = "GMTETH" + GMTEUR = "GMTEUR" + GMTGBP = "GMTGBP" + GMTTRY = "GMTTRY" + GMTTWD = "GMTTWD" + GMTUSDT = "GMTUSDT" + GMXBTC = "GMXBTC" + GMXBUSD = "GMXBUSD" + GMXUSDT = "GMXUSDT" + GNOBNB = "GNOBNB" + GNOBTC = "GNOBTC" + GNOBUSD = "GNOBUSD" + GNOUSDT = "GNOUSDT" + GNSBTC = "GNSBTC" + GNSUSDT = "GNSUSDT" + GNTBNB = "GNTBNB" + GNTBTC = "GNTBTC" + GNTETH = "GNTETH" + GOBNB = "GOBNB" + GOBTC = "GOBTC" + GRSBTC = "GRSBTC" + GRSETH = "GRSETH" + GRTBTC = "GRTBTC" + GRTBUSD = "GRTBUSD" + GRTETH = "GRTETH" + GRTEUR = "GRTEUR" + GRTTRY = "GRTTRY" + GRTTWD = "GRTTWD" + GRTUSDT = "GRTUSDT" + GSTTWD = "GSTTWD" + GTCBNB = "GTCBNB" + GTCBTC = "GTCBTC" + GTCBUSD = "GTCBUSD" + GTCUSDT = "GTCUSDT" + GTOBNB = "GTOBNB" + GTOBTC = "GTOBTC" + GTOBUSD = "GTOBUSD" + GTOETH = "GTOETH" + GTOPAX = "GTOPAX" + GTOTUSD = "GTOTUSD" + GTOUSDC = "GTOUSDC" + GTOUSDT = "GTOUSDT" + GVTBTC = "GVTBTC" + GVTETH = "GVTETH" + GXSBNB = "GXSBNB" + GXSBTC = "GXSBTC" + GXSETH = "GXSETH" + GXSUSDT = "GXSUSDT" + HARDBNB = "HARDBNB" + HARDBTC = "HARDBTC" + HARDBUSD = "HARDBUSD" + HARDUSDT = "HARDUSDT" + HBARBNB = "HBARBNB" + HBARBTC = "HBARBTC" + HBARBUSD = "HBARBUSD" + HBARUSDT = "HBARUSDT" + HCBTC = "HCBTC" + HCETH = "HCETH" + HCUSDT = "HCUSDT" + HEGICBUSD = "HEGICBUSD" + HEGICETH = "HEGICETH" + HFTBTC = "HFTBTC" + HFTBUSD = "HFTBUSD" + HFTUSDT = "HFTUSDT" + HIFIETH = "HIFIETH" + HIFIUSDT = "HIFIUSDT" + HIGHBNB = "HIGHBNB" + HIGHBTC = "HIGHBTC" + HIGHBUSD = "HIGHBUSD" + HIGHUSDT = "HIGHUSDT" + HIVEBNB = "HIVEBNB" + HIVEBTC = "HIVEBTC" + HIVEBUSD = "HIVEBUSD" + HIVEUSDT = "HIVEUSDT" + HNTBTC = "HNTBTC" + HNTBUSD = "HNTBUSD" + HNTUSDT = "HNTUSDT" + HOOKBNB = "HOOKBNB" + HOOKBTC = "HOOKBTC" + HOOKBUSD = "HOOKBUSD" + HOOKUSDT = "HOOKUSDT" + HOTBNB = "HOTBNB" + HOTBRL = "HOTBRL" + HOTBTC = "HOTBTC" + HOTBUSD = "HOTBUSD" + HOTETH = "HOTETH" + HOTEUR = "HOTEUR" + HOTTRY = "HOTTRY" + HOTUSDT = "HOTUSDT" + HSRBTC = "HSRBTC" + HSRETH = "HSRETH" + ICNBTC = "ICNBTC" + ICNETH = "ICNETH" + ICPBNB = "ICPBNB" + ICPBTC = "ICPBTC" + ICPBUSD = "ICPBUSD" + ICPETH = "ICPETH" + ICPEUR = "ICPEUR" + ICPRUB = "ICPRUB" + ICPTRY = "ICPTRY" + ICPUSDT = "ICPUSDT" + ICXBNB = "ICXBNB" + ICXBTC = "ICXBTC" + ICXBUSD = "ICXBUSD" + ICXETH = "ICXETH" + ICXUSDT = "ICXUSDT" + IDBNB = "IDBNB" + IDBTC = "IDBTC" + IDEUR = "IDEUR" + IDEXBNB = "IDEXBNB" + IDEXBTC = "IDEXBTC" + IDEXBUSD = "IDEXBUSD" + IDEXUSDT = "IDEXUSDT" + IDTRY = "IDTRY" + IDTUSD = "IDTUSD" + IDUSDT = "IDUSDT" + ILVBNB = "ILVBNB" + ILVBTC = "ILVBTC" + ILVBUSD = "ILVBUSD" + ILVUSDT = "ILVUSDT" + IMXBNB = "IMXBNB" + IMXBTC = "IMXBTC" + IMXBUSD = "IMXBUSD" + IMXUSDT = "IMXUSDT" + INJBNB = "INJBNB" + INJBTC = "INJBTC" + INJBUSD = "INJBUSD" + INJTRY = "INJTRY" + INJUSDT = "INJUSDT" + INSBTC = "INSBTC" + INSETH = "INSETH" + IOSTBTC = "IOSTBTC" + IOSTBUSD = "IOSTBUSD" + IOSTETH = "IOSTETH" + IOSTUSDT = "IOSTUSDT" + IOTABNB = "IOTABNB" + IOTABTC = "IOTABTC" + IOTABUSD = "IOTABUSD" + IOTAETH = "IOTAETH" + IOTAUSDT = "IOTAUSDT" + IOTXBTC = "IOTXBTC" + IOTXBUSD = "IOTXBUSD" + IOTXETH = "IOTXETH" + IOTXUSDT = "IOTXUSDT" + IQBNB = "IQBNB" + IQBUSD = "IQBUSD" + IRISBNB = "IRISBNB" + IRISBTC = "IRISBTC" + IRISBUSD = "IRISBUSD" + IRISUSDT = "IRISUSDT" + JASMYBNB = "JASMYBNB" + JASMYBTC = "JASMYBTC" + JASMYBUSD = "JASMYBUSD" + JASMYETH = "JASMYETH" + JASMYEUR = "JASMYEUR" + JASMYTRY = "JASMYTRY" + JASMYUSDT = "JASMYUSDT" + JOEBTC = "JOEBTC" + JOEBUSD = "JOEBUSD" + JOETRY = "JOETRY" + JOEUSDT = "JOEUSDT" + JSTBNB = "JSTBNB" + JSTBTC = "JSTBTC" + JSTBUSD = "JSTBUSD" + JSTUSDT = "JSTUSDT" + JUVBTC = "JUVBTC" + JUVBUSD = "JUVBUSD" + JUVUSDT = "JUVUSDT" + KAVABNB = "KAVABNB" + KAVABTC = "KAVABTC" + KAVABUSD = "KAVABUSD" + KAVAETH = "KAVAETH" + KAVAUSDT = "KAVAUSDT" + KDABTC = "KDABTC" + KDABUSD = "KDABUSD" + KDAUSDT = "KDAUSDT" + KEEPBNB = "KEEPBNB" + KEEPBTC = "KEEPBTC" + KEEPBUSD = "KEEPBUSD" + KEEPUSDT = "KEEPUSDT" + KEYBTC = "KEYBTC" + KEYBUSD = "KEYBUSD" + KEYETH = "KEYETH" + KEYUSDT = "KEYUSDT" + KLAYBNB = "KLAYBNB" + KLAYBTC = "KLAYBTC" + KLAYBUSD = "KLAYBUSD" + KLAYUSDT = "KLAYUSDT" + KMDBTC = "KMDBTC" + KMDBUSD = "KMDBUSD" + KMDETH = "KMDETH" + KMDUSDT = "KMDUSDT" + KNCBNB = "KNCBNB" + KNCBTC = "KNCBTC" + KNCBUSD = "KNCBUSD" + KNCETH = "KNCETH" + KNCUSDT = "KNCUSDT" + KP3RBNB = "KP3RBNB" + KP3RBUSD = "KP3RBUSD" + KP3RUSDT = "KP3RUSDT" + KSMAUD = "KSMAUD" + KSMBNB = "KSMBNB" + KSMBTC = "KSMBTC" + KSMBUSD = "KSMBUSD" + KSMETH = "KSMETH" + KSMUSDT = "KSMUSDT" + LAZIOBTC = "LAZIOBTC" + LAZIOBUSD = "LAZIOBUSD" + LAZIOEUR = "LAZIOEUR" + LAZIOTRY = "LAZIOTRY" + LAZIOUSDT = "LAZIOUSDT" + LDOBTC = "LDOBTC" + LDOBUSD = "LDOBUSD" + LDOTUSD = "LDOTUSD" + LDOUSDT = "LDOUSDT" + LENDBKRW = "LENDBKRW" + LENDBTC = "LENDBTC" + LENDBUSD = "LENDBUSD" + LENDETH = "LENDETH" + LENDUSDT = "LENDUSDT" + LEVERBUSD = "LEVERBUSD" + LEVERUSDT = "LEVERUSDT" + LINABNB = "LINABNB" + LINABTC = "LINABTC" + LINABUSD = "LINABUSD" + LINAUSDT = "LINAUSDT" + LINKAUD = "LINKAUD" + LINKBKRW = "LINKBKRW" + LINKBNB = "LINKBNB" + LINKBRL = "LINKBRL" + LINKBTC = "LINKBTC" + LINKBUSD = "LINKBUSD" + LINKETH = "LINKETH" + LINKEUR = "LINKEUR" + LINKGBP = "LINKGBP" + LINKNGN = "LINKNGN" + LINKPAX = "LINKPAX" + LINKTRY = "LINKTRY" + LINKTUSD = "LINKTUSD" + LINKTWD = "LINKTWD" + LINKUSDC = "LINKUSDC" + LINKUSDT = "LINKUSDT" + LITBTC = "LITBTC" + LITBUSD = "LITBUSD" + LITETH = "LITETH" + LITUSDT = "LITUSDT" + LOKABNB = "LOKABNB" + LOKABTC = "LOKABTC" + LOKABUSD = "LOKABUSD" + LOKAUSDT = "LOKAUSDT" + LOOKSTWD = "LOOKSTWD" + LOOMBNB = "LOOMBNB" + LOOMBTC = "LOOMBTC" + LOOMBUSD = "LOOMBUSD" + LOOMETH = "LOOMETH" + LOOMUSDT = "LOOMUSDT" + LOOTTWD = "LOOTTWD" + LOOTUSDT = "LOOTUSDT" + LPTBNB = "LPTBNB" + LPTBTC = "LPTBTC" + LPTBUSD = "LPTBUSD" + LPTUSDT = "LPTUSDT" + LQTYBTC = "LQTYBTC" + LQTYUSDT = "LQTYUSDT" + LRCBNB = "LRCBNB" + LRCBTC = "LRCBTC" + LRCBUSD = "LRCBUSD" + LRCETH = "LRCETH" + LRCTRY = "LRCTRY" + LRCUSDT = "LRCUSDT" + LSKBNB = "LSKBNB" + LSKBTC = "LSKBTC" + LSKBUSD = "LSKBUSD" + LSKETH = "LSKETH" + LSKUSDT = "LSKUSDT" + LTCBNB = "LTCBNB" + LTCBRL = "LTCBRL" + LTCBTC = "LTCBTC" + LTCBUSD = "LTCBUSD" + LTCETH = "LTCETH" + LTCEUR = "LTCEUR" + LTCGBP = "LTCGBP" + LTCNGN = "LTCNGN" + LTCPAX = "LTCPAX" + LTCRUB = "LTCRUB" + LTCTRY = "LTCTRY" + LTCTUSD = "LTCTUSD" + LTCTWD = "LTCTWD" + LTCUAH = "LTCUAH" + LTCUSDC = "LTCUSDC" + LTCUSDT = "LTCUSDT" + LTOBNB = "LTOBNB" + LTOBTC = "LTOBTC" + LTOBUSD = "LTOBUSD" + LTOUSDT = "LTOUSDT" + LUNAAUD = "LUNAAUD" + LUNABIDR = "LUNABIDR" + LUNABNB = "LUNABNB" + LUNABRL = "LUNABRL" + LUNABTC = "LUNABTC" + LUNABUSD = "LUNABUSD" + LUNAETH = "LUNAETH" + LUNAEUR = "LUNAEUR" + LUNAGBP = "LUNAGBP" + LUNATRY = "LUNATRY" + LUNAUSDT = "LUNAUSDT" + LUNAUST = "LUNAUST" + LUNBTC = "LUNBTC" + LUNCBUSD = "LUNCBUSD" + LUNCUSDT = "LUNCUSDT" + LUNETH = "LUNETH" + MAGICBTC = "MAGICBTC" + MAGICBUSD = "MAGICBUSD" + MAGICTRY = "MAGICTRY" + MAGICUSDT = "MAGICUSDT" + MANABIDR = "MANABIDR" + MANABNB = "MANABNB" + MANABRL = "MANABRL" + MANABTC = "MANABTC" + MANABUSD = "MANABUSD" + MANAETH = "MANAETH" + MANATRY = "MANATRY" + MANATWD = "MANATWD" + MANAUSDT = "MANAUSDT" + MASKBNB = "MASKBNB" + MASKBUSD = "MASKBUSD" + MASKUSDT = "MASKUSDT" + MATICAUD = "MATICAUD" + MATICBIDR = "MATICBIDR" + MATICBNB = "MATICBNB" + MATICBRL = "MATICBRL" + MATICBTC = "MATICBTC" + MATICBUSD = "MATICBUSD" + MATICETH = "MATICETH" + MATICEUR = "MATICEUR" + MATICGBP = "MATICGBP" + MATICRUB = "MATICRUB" + MATICTRY = "MATICTRY" + MATICTUSD = "MATICTUSD" + MATICTWD = "MATICTWD" + MATICUSDT = "MATICUSDT" + MAVBTC = "MAVBTC" + MAVTUSD = "MAVTUSD" + MAVUSDT = "MAVUSDT" + MAXTWD = "MAXTWD" + MAXUSDT = "MAXUSDT" + MBLBNB = "MBLBNB" + MBLBTC = "MBLBTC" + MBLBUSD = "MBLBUSD" + MBLUSDT = "MBLUSDT" + MBOXBNB = "MBOXBNB" + MBOXBTC = "MBOXBTC" + MBOXBUSD = "MBOXBUSD" + MBOXTRY = "MBOXTRY" + MBOXUSDT = "MBOXUSDT" + MCBNB = "MCBNB" + MCBTC = "MCBTC" + MCBUSD = "MCBUSD" + MCOBNB = "MCOBNB" + MCOBTC = "MCOBTC" + MCOETH = "MCOETH" + MCOUSDT = "MCOUSDT" + MCUSDT = "MCUSDT" + MDABTC = "MDABTC" + MDAETH = "MDAETH" + MDTBNB = "MDTBNB" + MDTBTC = "MDTBTC" + MDTBUSD = "MDTBUSD" + MDTUSDT = "MDTUSDT" + MDXBNB = "MDXBNB" + MDXBTC = "MDXBTC" + MDXBUSD = "MDXBUSD" + MDXUSDT = "MDXUSDT" + MFTBNB = "MFTBNB" + MFTBTC = "MFTBTC" + MFTETH = "MFTETH" + MFTUSDT = "MFTUSDT" + MINABNB = "MINABNB" + MINABTC = "MINABTC" + MINABUSD = "MINABUSD" + MINATRY = "MINATRY" + MINAUSDT = "MINAUSDT" + MIRBTC = "MIRBTC" + MIRBUSD = "MIRBUSD" + MIRUSDT = "MIRUSDT" + MITHBNB = "MITHBNB" + MITHBTC = "MITHBTC" + MITHUSDT = "MITHUSDT" + MKRBNB = "MKRBNB" + MKRBTC = "MKRBTC" + MKRBUSD = "MKRBUSD" + MKRUSDT = "MKRUSDT" + MLNBNB = "MLNBNB" + MLNBTC = "MLNBTC" + MLNBUSD = "MLNBUSD" + MLNUSDT = "MLNUSDT" + MOBBTC = "MOBBTC" + MOBBUSD = "MOBBUSD" + MOBUSDT = "MOBUSDT" + MODBTC = "MODBTC" + MODETH = "MODETH" + MOVRBNB = "MOVRBNB" + MOVRBTC = "MOVRBTC" + MOVRBUSD = "MOVRBUSD" + MOVRUSDT = "MOVRUSDT" + MTHBTC = "MTHBTC" + MTHETH = "MTHETH" + MTLBTC = "MTLBTC" + MTLBUSD = "MTLBUSD" + MTLETH = "MTLETH" + MTLUSDT = "MTLUSDT" + MULTIBTC = "MULTIBTC" + MULTIBUSD = "MULTIBUSD" + MULTIUSDT = "MULTIUSDT" + NANOBNB = "NANOBNB" + NANOBTC = "NANOBTC" + NANOBUSD = "NANOBUSD" + NANOETH = "NANOETH" + NANOUSDT = "NANOUSDT" + NASBNB = "NASBNB" + NASBTC = "NASBTC" + NASETH = "NASETH" + NAVBNB = "NAVBNB" + NAVBTC = "NAVBTC" + NAVETH = "NAVETH" + NBSBTC = "NBSBTC" + NBSUSDT = "NBSUSDT" + NCASHBNB = "NCASHBNB" + NCASHBTC = "NCASHBTC" + NCASHETH = "NCASHETH" + NEARBNB = "NEARBNB" + NEARBTC = "NEARBTC" + NEARBUSD = "NEARBUSD" + NEARETH = "NEARETH" + NEAREUR = "NEAREUR" + NEARRUB = "NEARRUB" + NEARTRY = "NEARTRY" + NEARUSDT = "NEARUSDT" + NEBLBNB = "NEBLBNB" + NEBLBTC = "NEBLBTC" + NEBLBUSD = "NEBLBUSD" + NEBLUSDT = "NEBLUSDT" + NEOBNB = "NEOBNB" + NEOBTC = "NEOBTC" + NEOBUSD = "NEOBUSD" + NEOETH = "NEOETH" + NEOPAX = "NEOPAX" + NEORUB = "NEORUB" + NEOTRY = "NEOTRY" + NEOTUSD = "NEOTUSD" + NEOUSDC = "NEOUSDC" + NEOUSDT = "NEOUSDT" + NEXOBTC = "NEXOBTC" + NEXOBUSD = "NEXOBUSD" + NEXOUSDT = "NEXOUSDT" + NKNBNB = "NKNBNB" + NKNBTC = "NKNBTC" + NKNBUSD = "NKNBUSD" + NKNUSDT = "NKNUSDT" + NMRBTC = "NMRBTC" + NMRBUSD = "NMRBUSD" + NMRUSDT = "NMRUSDT" + NPXSBTC = "NPXSBTC" + NPXSETH = "NPXSETH" + NPXSUSDC = "NPXSUSDC" + NPXSUSDT = "NPXSUSDT" + NUAUD = "NUAUD" + NUBNB = "NUBNB" + NUBTC = "NUBTC" + NUBUSD = "NUBUSD" + NULSBNB = "NULSBNB" + NULSBTC = "NULSBTC" + NULSBUSD = "NULSBUSD" + NULSETH = "NULSETH" + NULSUSDT = "NULSUSDT" + NURUB = "NURUB" + NUUSDT = "NUUSDT" + NXSBNB = "NXSBNB" + NXSBTC = "NXSBTC" + NXSETH = "NXSETH" + OAXBTC = "OAXBTC" + OAXETH = "OAXETH" + OAXUSDT = "OAXUSDT" + OCEANBNB = "OCEANBNB" + OCEANBTC = "OCEANBTC" + OCEANBUSD = "OCEANBUSD" + OCEANUSDT = "OCEANUSDT" + OGBTC = "OGBTC" + OGBUSD = "OGBUSD" + OGNBNB = "OGNBNB" + OGNBTC = "OGNBTC" + OGNBUSD = "OGNBUSD" + OGNUSDT = "OGNUSDT" + OGTRY = "OGTRY" + OGUSDT = "OGUSDT" + OMBTC = "OMBTC" + OMBUSD = "OMBUSD" + OMGBNB = "OMGBNB" + OMGBTC = "OMGBTC" + OMGBUSD = "OMGBUSD" + OMGETH = "OMGETH" + OMGUSDT = "OMGUSDT" + OMUSDT = "OMUSDT" + ONEBIDR = "ONEBIDR" + ONEBNB = "ONEBNB" + ONEBTC = "ONEBTC" + ONEBUSD = "ONEBUSD" + ONEETH = "ONEETH" + ONEPAX = "ONEPAX" + ONETRY = "ONETRY" + ONETUSD = "ONETUSD" + ONEUSDC = "ONEUSDC" + ONEUSDT = "ONEUSDT" + ONGBNB = "ONGBNB" + ONGBTC = "ONGBTC" + ONGUSDT = "ONGUSDT" + ONTBNB = "ONTBNB" + ONTBTC = "ONTBTC" + ONTBUSD = "ONTBUSD" + ONTETH = "ONTETH" + ONTPAX = "ONTPAX" + ONTTRY = "ONTTRY" + ONTUSDC = "ONTUSDC" + ONTUSDT = "ONTUSDT" + OOKIBNB = "OOKIBNB" + OOKIBUSD = "OOKIBUSD" + OOKIETH = "OOKIETH" + OOKIUSDT = "OOKIUSDT" + OPBNB = "OPBNB" + OPBTC = "OPBTC" + OPBUSD = "OPBUSD" + OPETH = "OPETH" + OPEUR = "OPEUR" + OPTRY = "OPTRY" + OPTUSD = "OPTUSD" + OPUSDT = "OPUSDT" + ORNBTC = "ORNBTC" + ORNBUSD = "ORNBUSD" + ORNUSDT = "ORNUSDT" + OSMOBTC = "OSMOBTC" + OSMOBUSD = "OSMOBUSD" + OSMOUSDT = "OSMOUSDT" + OSTBNB = "OSTBNB" + OSTBTC = "OSTBTC" + OSTETH = "OSTETH" + OXTBTC = "OXTBTC" + OXTBUSD = "OXTBUSD" + OXTUSDT = "OXTUSDT" + PAXBNB = "PAXBNB" + PAXBTC = "PAXBTC" + PAXBUSD = "PAXBUSD" + PAXETH = "PAXETH" + PAXGBNB = "PAXGBNB" + PAXGBTC = "PAXGBTC" + PAXGBUSD = "PAXGBUSD" + PAXGTRY = "PAXGTRY" + PAXGUSDT = "PAXGUSDT" + PAXTUSD = "PAXTUSD" + PAXUSDT = "PAXUSDT" + PENDLEBTC = "PENDLEBTC" + PENDLETUSD = "PENDLETUSD" + PENDLEUSDT = "PENDLEUSDT" + PEOPLEBNB = "PEOPLEBNB" + PEOPLEBTC = "PEOPLEBTC" + PEOPLEBUSD = "PEOPLEBUSD" + PEOPLEETH = "PEOPLEETH" + PEOPLEUSDT = "PEOPLEUSDT" + PEPETRY = "PEPETRY" + PEPETUSD = "PEPETUSD" + PEPEUSDT = "PEPEUSDT" + PERLBNB = "PERLBNB" + PERLBTC = "PERLBTC" + PERLUSDC = "PERLUSDC" + PERLUSDT = "PERLUSDT" + PERPBTC = "PERPBTC" + PERPBUSD = "PERPBUSD" + PERPUSDT = "PERPUSDT" + PHABTC = "PHABTC" + PHABUSD = "PHABUSD" + PHAUSDT = "PHAUSDT" + PHBBNB = "PHBBNB" + PHBBTC = "PHBBTC" + PHBBUSD = "PHBBUSD" + PHBPAX = "PHBPAX" + PHBTUSD = "PHBTUSD" + PHBUSDC = "PHBUSDC" + PHBUSDT = "PHBUSDT" + PHXBNB = "PHXBNB" + PHXBTC = "PHXBTC" + PHXETH = "PHXETH" + PIVXBNB = "PIVXBNB" + PIVXBTC = "PIVXBTC" + PLABNB = "PLABNB" + PLABTC = "PLABTC" + PLABUSD = "PLABUSD" + PLAUSDT = "PLAUSDT" + PNTBTC = "PNTBTC" + PNTUSDT = "PNTUSDT" + POABNB = "POABNB" + POABTC = "POABTC" + POAETH = "POAETH" + POEBTC = "POEBTC" + POEETH = "POEETH" + POLSBNB = "POLSBNB" + POLSBTC = "POLSBTC" + POLSBUSD = "POLSBUSD" + POLSUSDT = "POLSUSDT" + POLYBNB = "POLYBNB" + POLYBTC = "POLYBTC" + POLYBUSD = "POLYBUSD" + POLYUSDT = "POLYUSDT" + POLYXBTC = "POLYXBTC" + POLYXBUSD = "POLYXBUSD" + POLYXUSDT = "POLYXUSDT" + PONDBTC = "PONDBTC" + PONDBUSD = "PONDBUSD" + PONDUSDT = "PONDUSDT" + PORTOBTC = "PORTOBTC" + PORTOBUSD = "PORTOBUSD" + PORTOEUR = "PORTOEUR" + PORTOTRY = "PORTOTRY" + PORTOUSDT = "PORTOUSDT" + POWRBNB = "POWRBNB" + POWRBTC = "POWRBTC" + POWRBUSD = "POWRBUSD" + POWRETH = "POWRETH" + POWRUSDT = "POWRUSDT" + PPTBTC = "PPTBTC" + PPTETH = "PPTETH" + PROMBNB = "PROMBNB" + PROMBTC = "PROMBTC" + PROMBUSD = "PROMBUSD" + PROMUSDT = "PROMUSDT" + PROSBUSD = "PROSBUSD" + PROSETH = "PROSETH" + PROSUSDT = "PROSUSDT" + PSGBTC = "PSGBTC" + PSGBUSD = "PSGBUSD" + PSGUSDT = "PSGUSDT" + PUNDIXBUSD = "PUNDIXBUSD" + PUNDIXETH = "PUNDIXETH" + PUNDIXUSDT = "PUNDIXUSDT" + PYRBTC = "PYRBTC" + PYRBUSD = "PYRBUSD" + PYRUSDT = "PYRUSDT" + QIBNB = "QIBNB" + QIBTC = "QIBTC" + QIBUSD = "QIBUSD" + QIUSDT = "QIUSDT" + QKCBTC = "QKCBTC" + QKCBUSD = "QKCBUSD" + QKCETH = "QKCETH" + QKCUSDT = "QKCUSDT" + QLCBNB = "QLCBNB" + QLCBTC = "QLCBTC" + QLCETH = "QLCETH" + QNTBNB = "QNTBNB" + QNTBTC = "QNTBTC" + QNTBUSD = "QNTBUSD" + QNTUSDT = "QNTUSDT" + QSPBNB = "QSPBNB" + QSPBTC = "QSPBTC" + QSPETH = "QSPETH" + QTUMBNB = "QTUMBNB" + QTUMBTC = "QTUMBTC" + QTUMBUSD = "QTUMBUSD" + QTUMETH = "QTUMETH" + QTUMUSDT = "QTUMUSDT" + QUICKBNB = "QUICKBNB" + QUICKBTC = "QUICKBTC" + QUICKBUSD = "QUICKBUSD" + QUICKUSDT = "QUICKUSDT" + RADBNB = "RADBNB" + RADBTC = "RADBTC" + RADBUSD = "RADBUSD" + RADTRY = "RADTRY" + RADUSDT = "RADUSDT" + RAMPBTC = "RAMPBTC" + RAMPBUSD = "RAMPBUSD" + RAMPUSDT = "RAMPUSDT" + RAREBNB = "RAREBNB" + RAREBTC = "RAREBTC" + RAREBUSD = "RAREBUSD" + RAREUSDT = "RAREUSDT" + RAYBNB = "RAYBNB" + RAYBUSD = "RAYBUSD" + RAYUSDT = "RAYUSDT" + RCNBNB = "RCNBNB" + RCNBTC = "RCNBTC" + RCNETH = "RCNETH" + RDNBNB = "RDNBNB" + RDNBTC = "RDNBTC" + RDNETH = "RDNETH" + RDNTBTC = "RDNTBTC" + RDNTTUSD = "RDNTTUSD" + RDNTUSDT = "RDNTUSDT" + REEFBIDR = "REEFBIDR" + REEFBTC = "REEFBTC" + REEFBUSD = "REEFBUSD" + REEFTRY = "REEFTRY" + REEFUSDT = "REEFUSDT" + REIBNB = "REIBNB" + REIBUSD = "REIBUSD" + REIETH = "REIETH" + REIUSDT = "REIUSDT" + RENBNB = "RENBNB" + RENBTC = "RENBTC" + RENBTCBTC = "RENBTCBTC" + RENBTCETH = "RENBTCETH" + RENBUSD = "RENBUSD" + RENUSDT = "RENUSDT" + REPBNB = "REPBNB" + REPBTC = "REPBTC" + REPBUSD = "REPBUSD" + REPUSDT = "REPUSDT" + REQBTC = "REQBTC" + REQBUSD = "REQBUSD" + REQETH = "REQETH" + REQUSDT = "REQUSDT" + RGTBNB = "RGTBNB" + RGTBTC = "RGTBTC" + RGTBUSD = "RGTBUSD" + RGTUSDT = "RGTUSDT" + RIFBTC = "RIFBTC" + RIFUSDT = "RIFUSDT" + RLCBNB = "RLCBNB" + RLCBTC = "RLCBTC" + RLCBUSD = "RLCBUSD" + RLCETH = "RLCETH" + RLCUSDT = "RLCUSDT" + RLYTWD = "RLYTWD" + RLYUSDT = "RLYUSDT" + RNDRBTC = "RNDRBTC" + RNDRBUSD = "RNDRBUSD" + RNDRTRY = "RNDRTRY" + RNDRUSDT = "RNDRUSDT" + ROSEBNB = "ROSEBNB" + ROSEBTC = "ROSEBTC" + ROSEBUSD = "ROSEBUSD" + ROSEETH = "ROSEETH" + ROSETRY = "ROSETRY" + ROSEUSDT = "ROSEUSDT" + RPLBTC = "RPLBTC" + RPLBUSD = "RPLBUSD" + RPLUSDT = "RPLUSDT" + RPXBNB = "RPXBNB" + RPXBTC = "RPXBTC" + RPXETH = "RPXETH" + RSRBNB = "RSRBNB" + RSRBTC = "RSRBTC" + RSRBUSD = "RSRBUSD" + RSRUSDT = "RSRUSDT" + RUNEAUD = "RUNEAUD" + RUNEBNB = "RUNEBNB" + RUNEBTC = "RUNEBTC" + RUNEBUSD = "RUNEBUSD" + RUNEETH = "RUNEETH" + RUNEEUR = "RUNEEUR" + RUNEGBP = "RUNEGBP" + RUNETRY = "RUNETRY" + RUNEUSDT = "RUNEUSDT" + RVNBTC = "RVNBTC" + RVNBUSD = "RVNBUSD" + RVNTRY = "RVNTRY" + RVNUSDT = "RVNUSDT" + SALTBTC = "SALTBTC" + SALTETH = "SALTETH" + SANDAUD = "SANDAUD" + SANDBIDR = "SANDBIDR" + SANDBNB = "SANDBNB" + SANDBRL = "SANDBRL" + SANDBTC = "SANDBTC" + SANDBUSD = "SANDBUSD" + SANDETH = "SANDETH" + SANDTRY = "SANDTRY" + SANDTWD = "SANDTWD" + SANDUSDT = "SANDUSDT" + SANTOSBRL = "SANTOSBRL" + SANTOSBTC = "SANTOSBTC" + SANTOSBUSD = "SANTOSBUSD" + SANTOSTRY = "SANTOSTRY" + SANTOSUSDT = "SANTOSUSDT" + SCBTC = "SCBTC" + SCBUSD = "SCBUSD" + SCETH = "SCETH" + SCRTBTC = "SCRTBTC" + SCRTBUSD = "SCRTBUSD" + SCRTETH = "SCRTETH" + SCRTUSDT = "SCRTUSDT" + SCUSDT = "SCUSDT" + SFPBTC = "SFPBTC" + SFPBUSD = "SFPBUSD" + SFPUSDT = "SFPUSDT" + SHIBAUD = "SHIBAUD" + SHIBBRL = "SHIBBRL" + SHIBBUSD = "SHIBBUSD" + SHIBDOGE = "SHIBDOGE" + SHIBEUR = "SHIBEUR" + SHIBGBP = "SHIBGBP" + SHIBRUB = "SHIBRUB" + SHIBTRY = "SHIBTRY" + SHIBTWD = "SHIBTWD" + SHIBUAH = "SHIBUAH" + SHIBUSDT = "SHIBUSDT" + SKLBTC = "SKLBTC" + SKLBUSD = "SKLBUSD" + SKLUSDT = "SKLUSDT" + SKYBNB = "SKYBNB" + SKYBTC = "SKYBTC" + SKYETH = "SKYETH" + SLPBIDR = "SLPBIDR" + SLPBNB = "SLPBNB" + SLPBUSD = "SLPBUSD" + SLPETH = "SLPETH" + SLPTRY = "SLPTRY" + SLPUSDT = "SLPUSDT" + SNGLSBTC = "SNGLSBTC" + SNGLSETH = "SNGLSETH" + SNMBTC = "SNMBTC" + SNMBUSD = "SNMBUSD" + SNMETH = "SNMETH" + SNTBTC = "SNTBTC" + SNTBUSD = "SNTBUSD" + SNTETH = "SNTETH" + SNTUSDT = "SNTUSDT" + SNXBNB = "SNXBNB" + SNXBTC = "SNXBTC" + SNXBUSD = "SNXBUSD" + SNXETH = "SNXETH" + SNXUSDT = "SNXUSDT" + SOLAUD = "SOLAUD" + SOLBIDR = "SOLBIDR" + SOLBNB = "SOLBNB" + SOLBRL = "SOLBRL" + SOLBTC = "SOLBTC" + SOLBUSD = "SOLBUSD" + SOLETH = "SOLETH" + SOLEUR = "SOLEUR" + SOLGBP = "SOLGBP" + SOLRUB = "SOLRUB" + SOLTRY = "SOLTRY" + SOLTUSD = "SOLTUSD" + SOLTWD = "SOLTWD" + SOLUSDC = "SOLUSDC" + SOLUSDT = "SOLUSDT" + SPARTABNB = "SPARTABNB" + SPELLBNB = "SPELLBNB" + SPELLBTC = "SPELLBTC" + SPELLBUSD = "SPELLBUSD" + SPELLTRY = "SPELLTRY" + SPELLUSDT = "SPELLUSDT" + SRMBIDR = "SRMBIDR" + SRMBNB = "SRMBNB" + SRMBTC = "SRMBTC" + SRMBUSD = "SRMBUSD" + SRMUSDT = "SRMUSDT" + SSVBTC = "SSVBTC" + SSVBUSD = "SSVBUSD" + SSVETH = "SSVETH" + SSVTUSD = "SSVTUSD" + SSVUSDT = "SSVUSDT" + STEEMBNB = "STEEMBNB" + STEEMBTC = "STEEMBTC" + STEEMBUSD = "STEEMBUSD" + STEEMETH = "STEEMETH" + STEEMUSDT = "STEEMUSDT" + STGBTC = "STGBTC" + STGBUSD = "STGBUSD" + STGUSDT = "STGUSDT" + STMXBTC = "STMXBTC" + STMXBUSD = "STMXBUSD" + STMXETH = "STMXETH" + STMXUSDT = "STMXUSDT" + STORJBTC = "STORJBTC" + STORJBUSD = "STORJBUSD" + STORJETH = "STORJETH" + STORJTRY = "STORJTRY" + STORJUSDT = "STORJUSDT" + STORMBNB = "STORMBNB" + STORMBTC = "STORMBTC" + STORMETH = "STORMETH" + STORMUSDT = "STORMUSDT" + STPTBNB = "STPTBNB" + STPTBTC = "STPTBTC" + STPTBUSD = "STPTBUSD" + STPTUSDT = "STPTUSDT" + STRATBNB = "STRATBNB" + STRATBTC = "STRATBTC" + STRATBUSD = "STRATBUSD" + STRATETH = "STRATETH" + STRATUSDT = "STRATUSDT" + STRAXBTC = "STRAXBTC" + STRAXBUSD = "STRAXBUSD" + STRAXETH = "STRAXETH" + STRAXUSDT = "STRAXUSDT" + STXBNB = "STXBNB" + STXBTC = "STXBTC" + STXBUSD = "STXBUSD" + STXTRY = "STXTRY" + STXUSDT = "STXUSDT" + SUBBTC = "SUBBTC" + SUBETH = "SUBETH" + SUIBNB = "SUIBNB" + SUIBTC = "SUIBTC" + SUIEUR = "SUIEUR" + SUITRY = "SUITRY" + SUITUSD = "SUITUSD" + SUIUSDT = "SUIUSDT" + SUNBTC = "SUNBTC" + SUNBUSD = "SUNBUSD" + SUNUSDT = "SUNUSDT" + SUPERBTC = "SUPERBTC" + SUPERBUSD = "SUPERBUSD" + SUPERUSDT = "SUPERUSDT" + SUSDBTC = "SUSDBTC" + SUSDETH = "SUSDETH" + SUSDUSDT = "SUSDUSDT" + SUSHIBNB = "SUSHIBNB" + SUSHIBTC = "SUSHIBTC" + SUSHIBUSD = "SUSHIBUSD" + SUSHIUSDT = "SUSHIUSDT" + SWRVBNB = "SWRVBNB" + SWRVBUSD = "SWRVBUSD" + SXPAUD = "SXPAUD" + SXPBIDR = "SXPBIDR" + SXPBNB = "SXPBNB" + SXPBTC = "SXPBTC" + SXPBUSD = "SXPBUSD" + SXPEUR = "SXPEUR" + SXPGBP = "SXPGBP" + SXPTRY = "SXPTRY" + SXPUSDT = "SXPUSDT" + SYNBTC = "SYNBTC" + SYNUSDT = "SYNUSDT" + SYSBNB = "SYSBNB" + SYSBTC = "SYSBTC" + SYSBUSD = "SYSBUSD" + SYSETH = "SYSETH" + SYSUSDT = "SYSUSDT" + TBUSD = "TBUSD" + TCTBNB = "TCTBNB" + TCTBTC = "TCTBTC" + TCTUSDT = "TCTUSDT" + TFUELBNB = "TFUELBNB" + TFUELBTC = "TFUELBTC" + TFUELBUSD = "TFUELBUSD" + TFUELPAX = "TFUELPAX" + TFUELTUSD = "TFUELTUSD" + TFUELUSDC = "TFUELUSDC" + TFUELUSDT = "TFUELUSDT" + THETABNB = "THETABNB" + THETABTC = "THETABTC" + THETABUSD = "THETABUSD" + THETAETH = "THETAETH" + THETAEUR = "THETAEUR" + THETAUSDT = "THETAUSDT" + TKOBIDR = "TKOBIDR" + TKOBTC = "TKOBTC" + TKOBUSD = "TKOBUSD" + TKOUSDT = "TKOUSDT" + TLMBNB = "TLMBNB" + TLMBTC = "TLMBTC" + TLMBUSD = "TLMBUSD" + TLMTRY = "TLMTRY" + TLMUSDT = "TLMUSDT" + TNBBTC = "TNBBTC" + TNBETH = "TNBETH" + TNTBTC = "TNTBTC" + TNTETH = "TNTETH" + TOMOBNB = "TOMOBNB" + TOMOBTC = "TOMOBTC" + TOMOBUSD = "TOMOBUSD" + TOMOUSDC = "TOMOUSDC" + TOMOUSDT = "TOMOUSDT" + TORNBNB = "TORNBNB" + TORNBTC = "TORNBTC" + TORNBUSD = "TORNBUSD" + TORNUSDT = "TORNUSDT" + TRBBNB = "TRBBNB" + TRBBTC = "TRBBTC" + TRBBUSD = "TRBBUSD" + TRBUSDT = "TRBUSDT" + TRIBEBNB = "TRIBEBNB" + TRIBEBTC = "TRIBEBTC" + TRIBEBUSD = "TRIBEBUSD" + TRIBEUSDT = "TRIBEUSDT" + TRIGBNB = "TRIGBNB" + TRIGBTC = "TRIGBTC" + TRIGETH = "TRIGETH" + TROYBNB = "TROYBNB" + TROYBTC = "TROYBTC" + TROYBUSD = "TROYBUSD" + TROYUSDT = "TROYUSDT" + TRUBTC = "TRUBTC" + TRUBUSD = "TRUBUSD" + TRURUB = "TRURUB" + TRUUSDT = "TRUUSDT" + TRXAUD = "TRXAUD" + TRXBNB = "TRXBNB" + TRXBTC = "TRXBTC" + TRXBUSD = "TRXBUSD" + TRXETH = "TRXETH" + TRXEUR = "TRXEUR" + TRXNGN = "TRXNGN" + TRXPAX = "TRXPAX" + TRXTRY = "TRXTRY" + TRXTUSD = "TRXTUSD" + TRXUSDC = "TRXUSDC" + TRXUSDT = "TRXUSDT" + TRXXRP = "TRXXRP" + TUSDBNB = "TUSDBNB" + TUSDBTC = "TUSDBTC" + TUSDBTUSD = "TUSDBTUSD" + TUSDBUSD = "TUSDBUSD" + TUSDETH = "TUSDETH" + TUSDT = "TUSDT" + TUSDUSDT = "TUSDUSDT" + TVKBTC = "TVKBTC" + TVKBUSD = "TVKBUSD" + TVKUSDT = "TVKUSDT" + TWTBTC = "TWTBTC" + TWTBUSD = "TWTBUSD" + TWTTRY = "TWTTRY" + TWTUSDT = "TWTUSDT" + UFTBUSD = "UFTBUSD" + UFTETH = "UFTETH" + UFTUSDT = "UFTUSDT" + UMABTC = "UMABTC" + UMABUSD = "UMABUSD" + UMATRY = "UMATRY" + UMAUSDT = "UMAUSDT" + UNFIBNB = "UNFIBNB" + UNFIBTC = "UNFIBTC" + UNFIBUSD = "UNFIBUSD" + UNFIETH = "UNFIETH" + UNFIUSDT = "UNFIUSDT" + UNIAUD = "UNIAUD" + UNIBNB = "UNIBNB" + UNIBTC = "UNIBTC" + UNIBUSD = "UNIBUSD" + UNIETH = "UNIETH" + UNIEUR = "UNIEUR" + UNIUSDT = "UNIUSDT" + USDCBNB = "USDCBNB" + USDCBUSD = "USDCBUSD" + USDCPAX = "USDCPAX" + USDCTUSD = "USDCTUSD" + USDCTWD = "USDCTWD" + USDCUSDT = "USDCUSDT" + USDPBUSD = "USDPBUSD" + USDPUSDT = "USDPUSDT" + USDSBUSDS = "USDSBUSDS" + USDSBUSDT = "USDSBUSDT" + USDSPAX = "USDSPAX" + USDSTUSD = "USDSTUSD" + USDSUSDC = "USDSUSDC" + USDSUSDT = "USDSUSDT" + USDTARS = "USDTARS" + USDTBIDR = "USDTBIDR" + USDTBKRW = "USDTBKRW" + USDTBRL = "USDTBRL" + USDTBVND = "USDTBVND" + USDTDAI = "USDTDAI" + USDTIDRT = "USDTIDRT" + USDTNGN = "USDTNGN" + USDTPLN = "USDTPLN" + USDTRON = "USDTRON" + USDTRUB = "USDTRUB" + USDTTRY = "USDTTRY" + USDTTWD = "USDTTWD" + USDTUAH = "USDTUAH" + USDTZAR = "USDTZAR" + USTBTC = "USTBTC" + USTBUSD = "USTBUSD" + USTCBUSD = "USTCBUSD" + USTCUSDT = "USTCUSDT" + USTUSDT = "USTUSDT" + UTKBTC = "UTKBTC" + UTKBUSD = "UTKBUSD" + UTKUSDT = "UTKUSDT" + VENBNB = "VENBNB" + VENBTC = "VENBTC" + VENETH = "VENETH" + VENUSDT = "VENUSDT" + VETBNB = "VETBNB" + VETBTC = "VETBTC" + VETBUSD = "VETBUSD" + VETETH = "VETETH" + VETEUR = "VETEUR" + VETGBP = "VETGBP" + VETTRY = "VETTRY" + VETUSDT = "VETUSDT" + VGXBTC = "VGXBTC" + VGXETH = "VGXETH" + VGXUSDT = "VGXUSDT" + VIABNB = "VIABNB" + VIABTC = "VIABTC" + VIAETH = "VIAETH" + VIBBTC = "VIBBTC" + VIBBUSD = "VIBBUSD" + VIBEBTC = "VIBEBTC" + VIBEETH = "VIBEETH" + VIBETH = "VIBETH" + VIBUSDT = "VIBUSDT" + VIDTBTC = "VIDTBTC" + VIDTBUSD = "VIDTBUSD" + VIDTUSDT = "VIDTUSDT" + VITEBNB = "VITEBNB" + VITEBTC = "VITEBTC" + VITEBUSD = "VITEBUSD" + VITEUSDT = "VITEUSDT" + VOXELBNB = "VOXELBNB" + VOXELBTC = "VOXELBTC" + VOXELBUSD = "VOXELBUSD" + VOXELETH = "VOXELETH" + VOXELUSDT = "VOXELUSDT" + VTHOBNB = "VTHOBNB" + VTHOBUSD = "VTHOBUSD" + VTHOUSDT = "VTHOUSDT" + WABIBNB = "WABIBNB" + WABIBTC = "WABIBTC" + WABIETH = "WABIETH" + WANBNB = "WANBNB" + WANBTC = "WANBTC" + WANETH = "WANETH" + WANUSDT = "WANUSDT" + WAVESBNB = "WAVESBNB" + WAVESBTC = "WAVESBTC" + WAVESBUSD = "WAVESBUSD" + WAVESETH = "WAVESETH" + WAVESEUR = "WAVESEUR" + WAVESPAX = "WAVESPAX" + WAVESRUB = "WAVESRUB" + WAVESTRY = "WAVESTRY" + WAVESTUSD = "WAVESTUSD" + WAVESUSDC = "WAVESUSDC" + WAVESUSDT = "WAVESUSDT" + WAXPBNB = "WAXPBNB" + WAXPBTC = "WAXPBTC" + WAXPBUSD = "WAXPBUSD" + WAXPUSDT = "WAXPUSDT" + WBETHETH = "WBETHETH" + WBTCBTC = "WBTCBTC" + WBTCBUSD = "WBTCBUSD" + WBTCETH = "WBTCETH" + WBTCUSDT = "WBTCUSDT" + WINBNB = "WINBNB" + WINBRL = "WINBRL" + WINBTC = "WINBTC" + WINBUSD = "WINBUSD" + WINEUR = "WINEUR" + WINGBNB = "WINGBNB" + WINGBTC = "WINGBTC" + WINGBUSD = "WINGBUSD" + WINGETH = "WINGETH" + WINGSBTC = "WINGSBTC" + WINGSETH = "WINGSETH" + WINGUSDT = "WINGUSDT" + WINTRX = "WINTRX" + WINUSDC = "WINUSDC" + WINUSDT = "WINUSDT" + WNXMBNB = "WNXMBNB" + WNXMBTC = "WNXMBTC" + WNXMBUSD = "WNXMBUSD" + WNXMUSDT = "WNXMUSDT" + WOOBNB = "WOOBNB" + WOOBTC = "WOOBTC" + WOOBUSD = "WOOBUSD" + WOOUSDT = "WOOUSDT" + WPRBTC = "WPRBTC" + WPRETH = "WPRETH" + WRXBNB = "WRXBNB" + WRXBTC = "WRXBTC" + WRXBUSD = "WRXBUSD" + WRXEUR = "WRXEUR" + WRXUSDT = "WRXUSDT" + WTCBNB = "WTCBNB" + WTCBTC = "WTCBTC" + WTCETH = "WTCETH" + WTCUSDT = "WTCUSDT" + XECBUSD = "XECBUSD" + XECUSDT = "XECUSDT" + XEMBNB = "XEMBNB" + XEMBTC = "XEMBTC" + XEMBUSD = "XEMBUSD" + XEMETH = "XEMETH" + XEMUSDT = "XEMUSDT" + XLMBNB = "XLMBNB" + XLMBTC = "XLMBTC" + XLMBUSD = "XLMBUSD" + XLMETH = "XLMETH" + XLMEUR = "XLMEUR" + XLMPAX = "XLMPAX" + XLMTRY = "XLMTRY" + XLMTUSD = "XLMTUSD" + XLMUSDC = "XLMUSDC" + XLMUSDT = "XLMUSDT" + XMRBNB = "XMRBNB" + XMRBTC = "XMRBTC" + XMRBUSD = "XMRBUSD" + XMRETH = "XMRETH" + XMRUSDT = "XMRUSDT" + XNOBTC = "XNOBTC" + XNOBUSD = "XNOBUSD" + XNOETH = "XNOETH" + XNOUSDT = "XNOUSDT" + XRPAUD = "XRPAUD" + XRPBEARBUSD = "XRPBEARBUSD" + XRPBEARUSDT = "XRPBEARUSDT" + XRPBIDR = "XRPBIDR" + XRPBKRW = "XRPBKRW" + XRPBNB = "XRPBNB" + XRPBRL = "XRPBRL" + XRPBTC = "XRPBTC" + XRPBULLBUSD = "XRPBULLBUSD" + XRPBULLUSDT = "XRPBULLUSDT" + XRPBUSD = "XRPBUSD" + XRPETH = "XRPETH" + XRPEUR = "XRPEUR" + XRPGBP = "XRPGBP" + XRPNGN = "XRPNGN" + XRPPAX = "XRPPAX" + XRPRUB = "XRPRUB" + XRPTRY = "XRPTRY" + XRPTUSD = "XRPTUSD" + XRPTWD = "XRPTWD" + XRPUSDC = "XRPUSDC" + XRPUSDT = "XRPUSDT" + XTZBNB = "XTZBNB" + XTZBTC = "XTZBTC" + XTZBUSD = "XTZBUSD" + XTZETH = "XTZETH" + XTZTRY = "XTZTRY" + XTZTWD = "XTZTWD" + XTZUSDT = "XTZUSDT" + XVGBTC = "XVGBTC" + XVGBUSD = "XVGBUSD" + XVGETH = "XVGETH" + XVGUSDT = "XVGUSDT" + XVSBNB = "XVSBNB" + XVSBTC = "XVSBTC" + XVSBUSD = "XVSBUSD" + XVSTRY = "XVSTRY" + XVSUSDT = "XVSUSDT" + XZCBNB = "XZCBNB" + XZCBTC = "XZCBTC" + XZCETH = "XZCETH" + XZCUSDT = "XZCUSDT" + XZCXRP = "XZCXRP" + YFIBNB = "YFIBNB" + YFIBTC = "YFIBTC" + YFIBUSD = "YFIBUSD" + YFIEUR = "YFIEUR" + YFIIBNB = "YFIIBNB" + YFIIBTC = "YFIIBTC" + YFIIBUSD = "YFIIBUSD" + YFIIUSDT = "YFIIUSDT" + YFITWD = "YFITWD" + YFIUSDT = "YFIUSDT" + YGGBNB = "YGGBNB" + YGGBTC = "YGGBTC" + YGGBUSD = "YGGBUSD" + YGGUSDT = "YGGUSDT" + YOYOBNB = "YOYOBNB" + YOYOBTC = "YOYOBTC" + YOYOETH = "YOYOETH" + ZECBNB = "ZECBNB" + ZECBTC = "ZECBTC" + ZECBUSD = "ZECBUSD" + ZECETH = "ZECETH" + ZECPAX = "ZECPAX" + ZECTUSD = "ZECTUSD" + ZECUSDC = "ZECUSDC" + ZECUSDT = "ZECUSDT" + ZENBNB = "ZENBNB" + ZENBTC = "ZENBTC" + ZENBUSD = "ZENBUSD" + ZENETH = "ZENETH" + ZENUSDT = "ZENUSDT" + ZILBIDR = "ZILBIDR" + ZILBNB = "ZILBNB" + ZILBTC = "ZILBTC" + ZILBUSD = "ZILBUSD" + ZILETH = "ZILETH" + ZILEUR = "ZILEUR" + ZILTRY = "ZILTRY" + ZILUSDT = "ZILUSDT" + ZRXBNB = "ZRXBNB" + ZRXBTC = "ZRXBTC" + ZRXBUSD = "ZRXBUSD" + ZRXETH = "ZRXETH" + ZRXUSDT = "ZRXUSDT" +) +var symbols = []string{ + AAVEBKRW,AAVEBNB,AAVEBRL,AAVEBTC,AAVEBUSD,AAVEETH,AAVEUSDT,ACABTC,ACABUSD,ACAUSDT,ACHBTC,ACHBUSD,ACHTRY,ACHUSDT,ACMBTC,ACMBUSD,ACMUSDT,ADAAUD,ADABIDR,ADABKRW,ADABNB,ADABRL,ADABTC,ADABUSD,ADAETH,ADAEUR,ADAGBP,ADAPAX,ADARUB,ADATRY,ADATUSD,ADATWD,ADAUSDC,ADAUSDT,ADXBNB,ADXBTC,ADXBUSD,ADXETH,ADXUSDT,AEBNB,AEBTC,AEETH,AERGOBTC,AERGOBUSD,AERGOUSDT,AGIBNB,AGIBTC,AGIETH,AGIXBTC,AGIXBUSD,AGIXTRY,AGIXUSDT,AGLDBNB,AGLDBTC,AGLDBUSD,AGLDUSDT,AIONBNB,AIONBTC,AIONBUSD,AIONETH,AIONUSDT,AKROBTC,AKROBUSD,AKROUSDT,ALCXBTC,ALCXBUSD,ALCXUSDT,ALGOBIDR,ALGOBNB,ALGOBTC,ALGOBUSD,ALGOETH,ALGOPAX,ALGORUB,ALGOTRY,ALGOTUSD,ALGOUSDC,ALGOUSDT,ALICEBIDR,ALICEBNB,ALICEBTC,ALICEBUSD,ALICETRY,ALICETWD,ALICEUSDT,ALPACABNB,ALPACABTC,ALPACABUSD,ALPACAUSDT,ALPHABNB,ALPHABTC,ALPHABUSD,ALPHAUSDT,ALPINEBTC,ALPINEBUSD,ALPINEEUR,ALPINETRY,ALPINEUSDT,AMBBNB,AMBBTC,AMBBUSD,AMBETH,AMBUSDT,AMPBNB,AMPBTC,AMPBUSD,AMPUSDT,ANCBNB,ANCBTC,ANCBUSD,ANCUSDT,ANKRBNB,ANKRBTC,ANKRBUSD,ANKRPAX,ANKRTRY,ANKRTUSD,ANKRUSDC,ANKRUSDT,ANTBNB,ANTBTC,ANTBUSD,ANTUSDT,ANYBTC,ANYBUSD,ANYUSDT,APEAUD,APEBNB,APEBRL,APEBTC,APEBUSD,APEETH,APEEUR,APEGBP,APETRY,APETWD,APEUSDT,API3BNB,API3BTC,API3BUSD,API3TRY,API3USDT,APPCBNB,APPCBTC,APPCETH,APTBRL,APTBTC,APTBUSD,APTETH,APTEUR,APTTRY,APTUSDT,ARBBTC,ARBEUR,ARBNB,ARBRUB,ARBTC,ARBTRY,ARBTUSD,ARBUSD,ARBUSDT,ARDRBNB,ARDRBTC,ARDRETH,ARDRUSDT,ARKBTC,ARKBUSD,ARKETH,ARNBTC,ARNETH,ARPABNB,ARPABTC,ARPABUSD,ARPAETH,ARPARUB,ARPATRY,ARPAUSDT,ARUSDT,ASRBTC,ASRBUSD,ASRUSDT,ASTBTC,ASTETH,ASTRBTC,ASTRBUSD,ASTRETH,ASTRUSDT,ASTUSDT,ATABNB,ATABTC,ATABUSD,ATAUSDT,ATMBTC,ATMBUSD,ATMUSDT,ATOMBIDR,ATOMBNB,ATOMBRL,ATOMBTC,ATOMBUSD,ATOMETH,ATOMEUR,ATOMPAX,ATOMTRY,ATOMTUSD,ATOMUSDC,ATOMUSDT,AUCTIONBTC,AUCTIONBUSD,AUCTIONUSDT,AUDBUSD,AUDIOBTC,AUDIOBUSD,AUDIOTRY,AUDIOUSDT,AUDUSDC,AUDUSDT,AUTOBTC,AUTOBUSD,AUTOUSDT,AVABNB,AVABTC,AVABUSD,AVAUSDT,AVAXAUD,AVAXBIDR,AVAXBNB,AVAXBRL,AVAXBTC,AVAXBUSD,AVAXETH,AVAXEUR,AVAXGBP,AVAXTRY,AVAXUSDT,AXSAUD,AXSBNB,AXSBRL,AXSBTC,AXSBUSD,AXSETH,AXSTRY,AXSUSDT,BADGERBTC,BADGERBUSD,BADGERUSDT,BAKEBNB,BAKEBTC,BAKEBUSD,BAKEUSDT,BALBNB,BALBTC,BALBUSD,BALUSDT,BANDBNB,BANDBTC,BANDBUSD,BANDUSDT,BARBTC,BARBUSD,BARUSDT,BATBNB,BATBTC,BATBUSD,BATETH,BATPAX,BATTUSD,BATUSDC,BATUSDT,BCCBNB,BCCBTC,BCCETH,BCCUSDT,BCDBTC,BCDETH,BCHABCBTC,BCHABCBUSD,BCHABCPAX,BCHABCTUSD,BCHABCUSDC,BCHABCUSDT,BCHABUSD,BCHBNB,BCHBTC,BCHBUSD,BCHEUR,BCHPAX,BCHSVBTC,BCHSVPAX,BCHSVTUSD,BCHSVUSDC,BCHSVUSDT,BCHTUSD,BCHTWD,BCHUSDC,BCHUSDT,BCNBNB,BCNBTC,BCNETH,BCNTTWD,BCNTUSDT,BCPTBNB,BCPTBTC,BCPTETH,BCPTPAX,BCPTTUSD,BCPTUSDC,BDOTDOT,BEAMBNB,BEAMBTC,BEAMUSDT,BEARBUSD,BEARUSDT,BELBNB,BELBTC,BELBUSD,BELETH,BELTRY,BELUSDT,BETABNB,BETABTC,BETABUSD,BETAETH,BETAUSDT,BETHBUSD,BETHETH,BETHUSDT,BGBPUSDC,BICOBTC,BICOBUSD,BICOUSDT,BIFIBNB,BIFIBUSD,BIFIUSDT,BKRWBUSD,BKRWUSDT,BLZBNB,BLZBTC,BLZBUSD,BLZETH,BLZUSDT,BNBAUD,BNBBEARBUSD,BNBBEARUSDT,BNBBIDR,BNBBKRW,BNBBRL,BNBBTC,BNBBULLBUSD,BNBBULLUSDT,BNBBUSD,BNBDAI,BNBETH,BNBEUR,BNBGBP,BNBIDRT,BNBNGN,BNBPAX,BNBRUB,BNBTRY,BNBTUSD,BNBTWD,BNBUAH,BNBUSDC,BNBUSDP,BNBUSDS,BNBUSDT,BNBUST,BNBZAR,BNTBTC,BNTBUSD,BNTETH,BNTUSDT,BNXBNB,BNXBTC,BNXBUSD,BNXUSDT,BONDBNB,BONDBTC,BONDBUSD,BONDETH,BONDUSDT,BOTBTC,BOTBUSD,BQXBTC,BQXETH,BRDBNB,BRDBTC,BRDETH,BSWBNB,BSWBUSD,BSWETH,BSWTRY,BSWUSDT,BTCARS,BTCAUD,BTCBBTC,BTCBIDR,BTCBKRW,BTCBRL,BTCBUSD,BTCDAI,BTCEUR,BTCGBP,BTCIDRT,BTCNGN,BTCPAX,BTCPLN,BTCRON,BTCRUB,BTCSTBTC,BTCSTBUSD,BTCSTUSDT,BTCTRY,BTCTUSD,BTCTWD,BTCUAH,BTCUSDC,BTCUSDP,BTCUSDS,BTCUSDT,BTCUST,BTCVAI,BTCZAR,BTGBTC,BTGBUSD,BTGETH,BTGUSDT,BTSBNB,BTSBTC,BTSBUSD,BTSETH,BTSUSDT,BTTBNB,BTTBRL,BTTBTC,BTTBUSD,BTTCBUSD,BTTCTRY,BTTCUSDC,BTTCUSDT,BTTEUR,BTTPAX,BTTTRX,BTTTRY,BTTTUSD,BTTUSDC,BTTUSDT,BULLBUSD,BULLUSDT,BURGERBNB,BURGERBUSD,BURGERETH,BURGERUSDT,BUSDBIDR,BUSDBKRW,BUSDBRL,BUSDBVND,BUSDDAI,BUSDIDRT,BUSDNGN,BUSDPLN,BUSDRON,BUSDRUB,BUSDTRY,BUSDUAH,BUSDUSDT,BUSDVAI,BUSDZAR,BZRXBNB,BZRXBTC,BZRXBUSD,BZRXUSDT,C98BNB,C98BRL,C98BTC,C98BUSD,C98USDT,CAKEAUD,CAKEBNB,CAKEBRL,CAKEBTC,CAKEBUSD,CAKEGBP,CAKEUSDT,CDTBTC,CDTETH,CELOBTC,CELOBUSD,CELOUSDT,CELRBNB,CELRBTC,CELRBUSD,CELRETH,CELRUSDT,CFXBTC,CFXBUSD,CFXTRY,CFXTUSD,CFXUSDT,CHATBTC,CHATETH,CHESSBNB,CHESSBTC,CHESSBUSD,CHESSUSDT,CHRBNB,CHRBTC,CHRBUSD,CHRETH,CHRUSDT,CHZBNB,CHZBRL,CHZBTC,CHZBUSD,CHZEUR,CHZGBP,CHZTRY,CHZUSDT,CITYBNB,CITYBTC,CITYBUSD,CITYTRY,CITYUSDT,CKBBTC,CKBBUSD,CKBUSDT,CLOAKBTC,CLOAKETH,CLVBNB,CLVBTC,CLVBUSD,CLVUSDT,CMTBNB,CMTBTC,CMTETH,CNDBNB,CNDBTC,CNDETH,COCOSBNB,COCOSBTC,COCOSBUSD,COCOSTRY,COCOSUSDT,COMBOBNB,COMBOTRY,COMBOUSDT,COMPBNB,COMPBTC,COMPBUSD,COMPTWD,COMPUSDT,COSBNB,COSBTC,COSBUSD,COSTRY,COSUSDT,COTIBNB,COTIBTC,COTIBUSD,COTIUSDT,COVERBUSD,COVERETH,CREAMBNB,CREAMBUSD,CRVBNB,CRVBTC,CRVBUSD,CRVETH,CRVUSDT,CTKBNB,CTKBTC,CTKBUSD,CTKUSDT,CTSIBNB,CTSIBTC,CTSIBUSD,CTSIUSDT,CTXCBNB,CTXCBTC,CTXCBUSD,CTXCUSDT,CVCBNB,CVCBTC,CVCBUSD,CVCETH,CVCUSDT,CVPBUSD,CVPETH,CVPUSDT,CVXBTC,CVXBUSD,CVXUSDT,DAIBNB,DAIBTC,DAIBUSD,DAIUSDT,DARBNB,DARBTC,DARBUSD,DARETH,DAREUR,DARTRY,DARUSDT,DASHBNB,DASHBTC,DASHBUSD,DASHETH,DASHUSDT,DATABTC,DATABUSD,DATAETH,DATAUSDT,DCRBNB,DCRBTC,DCRBUSD,DCRUSDT,DEGOBTC,DEGOBUSD,DEGOUSDT,DENTBTC,DENTBUSD,DENTETH,DENTTRY,DENTUSDT,DEXEBUSD,DEXEETH,DEXEUSDT,DFBUSD,DFETH,DFUSDT,DGBBTC,DGBBUSD,DGBUSDT,DGDBTC,DGDETH,DIABNB,DIABTC,DIABUSD,DIAUSDT,DLTBNB,DLTBTC,DLTETH,DNTBTC,DNTBUSD,DNTETH,DNTUSDT,DOCKBTC,DOCKBUSD,DOCKETH,DOCKUSDT,DODOBTC,DODOBUSD,DODOUSDT,DOGEAUD,DOGEBIDR,DOGEBNB,DOGEBRL,DOGEBTC,DOGEBUSD,DOGEEUR,DOGEGBP,DOGEPAX,DOGERUB,DOGETRY,DOGETUSD,DOGETWD,DOGEUSDC,DOGEUSDT,DOTAUD,DOTBIDR,DOTBKRW,DOTBNB,DOTBRL,DOTBTC,DOTBUSD,DOTETH,DOTEUR,DOTGBP,DOTNGN,DOTRUB,DOTTRY,DOTTWD,DOTUSDT,DREPBNB,DREPBTC,DREPBUSD,DREPUSDT,DUSKBNB,DUSKBTC,DUSKBUSD,DUSKPAX,DUSKUSDC,DUSKUSDT,DYDXBNB,DYDXBTC,DYDXBUSD,DYDXETH,DYDXUSDT,EASYBTC,EASYETH,EDOBTC,EDOETH,EDUBNB,EDUBTC,EDUEUR,EDUTRY,EDUTUSD,EDUUSDT,EGLDBNB,EGLDBTC,EGLDBUSD,EGLDETH,EGLDEUR,EGLDRON,EGLDUSDT,ELFBTC,ELFBUSD,ELFETH,ELFUSDT,ENGBTC,ENGETH,ENJBNB,ENJBRL,ENJBTC,ENJBUSD,ENJETH,ENJEUR,ENJGBP,ENJTRY,ENJUSDT,ENSBNB,ENSBTC,ENSBUSD,ENSTRY,ENSTWD,ENSUSDT,EOSAUD,EOSBEARBUSD,EOSBEARUSDT,EOSBNB,EOSBTC,EOSBULLBUSD,EOSBULLUSDT,EOSBUSD,EOSETH,EOSEUR,EOSPAX,EOSTRY,EOSTUSD,EOSUSDC,EOSUSDT,EPSBTC,EPSBUSD,EPSUSDT,EPXBUSD,EPXUSDT,ERDBNB,ERDBTC,ERDBUSD,ERDPAX,ERDUSDC,ERDUSDT,ERNBNB,ERNBUSD,ERNUSDT,ETCBNB,ETCBRL,ETCBTC,ETCBUSD,ETCETH,ETCEUR,ETCGBP,ETCPAX,ETCTRY,ETCTUSD,ETCTWD,ETCUSDC,ETCUSDT,ETCUSDTETHBTC,ETHAUD,ETHBEARBUSD,ETHBEARUSDT,ETHBIDR,ETHBKRW,ETHBRL,ETHBTC,ETHBULLBUSD,ETHBULLUSDT,ETHBUSD,ETHDAI,ETHEUR,ETHGBP,ETHNGN,ETHPAX,ETHPLN,ETHRUB,ETHTRY,ETHTUSD,ETHTWD,ETHUAH,ETHUSDC,ETHUSDP,ETHUSDT,ETHUST,ETHZAR,EURBUSD,EURUSDT,EVXBTC,EVXETH,EZBTC,EZETH,FARMBNB,FARMBTC,FARMBUSD,FARMETH,FARMUSDT,FETBNB,FETBTC,FETBUSD,FETTRY,FETUSDT,FIDABNB,FIDABTC,FIDABUSD,FIDAUSDT,FILBNB,FILBTC,FILBUSD,FILETH,FILTRY,FILUSDT,FIOBNB,FIOBTC,FIOBUSD,FIOUSDT,FIROBTC,FIROBUSD,FIROETH,FIROUSDT,FISBIDR,FISBRL,FISBTC,FISBUSD,FISTRY,FISUSDT,FLMBNB,FLMBTC,FLMBUSD,FLMUSDT,FLOKITRY,FLOKITUSD,FLOKIUSDT,FLOWBNB,FLOWBTC,FLOWBUSD,FLOWUSDT,FLUXBTC,FLUXBUSD,FLUXUSDT,FORBNB,FORBTC,FORBUSD,FORTHBTC,FORTHBUSD,FORTHUSDT,FORUSDT,FRONTBTC,FRONTBUSD,FRONTETH,FRONTUSDT,FTMAUD,FTMBIDR,FTMBNB,FTMBRL,FTMBTC,FTMBUSD,FTMETH,FTMEUR,FTMPAX,FTMRUB,FTMTRY,FTMTUSD,FTMUSDC,FTMUSDT,FTTBNB,FTTBTC,FTTBUSD,FTTETH,FTTUSDT,FUELBTC,FUELETH,FUNBNB,FUNBTC,FUNETH,FUNUSDT,FXSBTC,FXSBUSD,FXSUSDT,GALAAUD,GALABNB,GALABRL,GALABTC,GALABUSD,GALAETH,GALAEUR,GALATRY,GALATWD,GALAUSDT,GALBNB,GALBRL,GALBTC,GALBUSD,GALETH,GALEUR,GALTRY,GALUSDT,GASBTC,GASBUSD,GASUSDT,GBPBUSD,GBPUSDT,GFTBUSD,GHSTBUSD,GHSTETH,GHSTUSDT,GLMBTC,GLMBUSD,GLMETH,GLMRBNB,GLMRBTC,GLMRBUSD,GLMRUSDT,GLMUSDT,GMTAUD,GMTBNB,GMTBRL,GMTBTC,GMTBUSD,GMTETH,GMTEUR,GMTGBP,GMTTRY,GMTTWD,GMTUSDT,GMXBTC,GMXBUSD,GMXUSDT,GNOBNB,GNOBTC,GNOBUSD,GNOUSDT,GNSBTC,GNSUSDT,GNTBNB,GNTBTC,GNTETH,GOBNB,GOBTC,GRSBTC,GRSETH,GRTBTC,GRTBUSD,GRTETH,GRTEUR,GRTTRY,GRTTWD,GRTUSDT,GSTTWD,GTCBNB,GTCBTC,GTCBUSD,GTCUSDT,GTOBNB,GTOBTC,GTOBUSD,GTOETH,GTOPAX,GTOTUSD,GTOUSDC,GTOUSDT,GVTBTC,GVTETH,GXSBNB,GXSBTC,GXSETH,GXSUSDT,HARDBNB,HARDBTC,HARDBUSD,HARDUSDT,HBARBNB,HBARBTC,HBARBUSD,HBARUSDT,HCBTC,HCETH,HCUSDT,HEGICBUSD,HEGICETH,HFTBTC,HFTBUSD,HFTUSDT,HIFIETH,HIFIUSDT,HIGHBNB,HIGHBTC,HIGHBUSD,HIGHUSDT,HIVEBNB,HIVEBTC,HIVEBUSD,HIVEUSDT,HNTBTC,HNTBUSD,HNTUSDT,HOOKBNB,HOOKBTC,HOOKBUSD,HOOKUSDT,HOTBNB,HOTBRL,HOTBTC,HOTBUSD,HOTETH,HOTEUR,HOTTRY,HOTUSDT,HSRBTC,HSRETH,ICNBTC,ICNETH,ICPBNB,ICPBTC,ICPBUSD,ICPETH,ICPEUR,ICPRUB,ICPTRY,ICPUSDT,ICXBNB,ICXBTC,ICXBUSD,ICXETH,ICXUSDT,IDBNB,IDBTC,IDEUR,IDEXBNB,IDEXBTC,IDEXBUSD,IDEXUSDT,IDTRY,IDTUSD,IDUSDT,ILVBNB,ILVBTC,ILVBUSD,ILVUSDT,IMXBNB,IMXBTC,IMXBUSD,IMXUSDT,INJBNB,INJBTC,INJBUSD,INJTRY,INJUSDT,INSBTC,INSETH,IOSTBTC,IOSTBUSD,IOSTETH,IOSTUSDT,IOTABNB,IOTABTC,IOTABUSD,IOTAETH,IOTAUSDT,IOTXBTC,IOTXBUSD,IOTXETH,IOTXUSDT,IQBNB,IQBUSD,IRISBNB,IRISBTC,IRISBUSD,IRISUSDT,JASMYBNB,JASMYBTC,JASMYBUSD,JASMYETH,JASMYEUR,JASMYTRY,JASMYUSDT,JOEBTC,JOEBUSD,JOETRY,JOEUSDT,JSTBNB,JSTBTC,JSTBUSD,JSTUSDT,JUVBTC,JUVBUSD,JUVUSDT,KAVABNB,KAVABTC,KAVABUSD,KAVAETH,KAVAUSDT,KDABTC,KDABUSD,KDAUSDT,KEEPBNB,KEEPBTC,KEEPBUSD,KEEPUSDT,KEYBTC,KEYBUSD,KEYETH,KEYUSDT,KLAYBNB,KLAYBTC,KLAYBUSD,KLAYUSDT,KMDBTC,KMDBUSD,KMDETH,KMDUSDT,KNCBNB,KNCBTC,KNCBUSD,KNCETH,KNCUSDT,KP3RBNB,KP3RBUSD,KP3RUSDT,KSMAUD,KSMBNB,KSMBTC,KSMBUSD,KSMETH,KSMUSDT,LAZIOBTC,LAZIOBUSD,LAZIOEUR,LAZIOTRY,LAZIOUSDT,LDOBTC,LDOBUSD,LDOTUSD,LDOUSDT,LENDBKRW,LENDBTC,LENDBUSD,LENDETH,LENDUSDT,LEVERBUSD,LEVERUSDT,LINABNB,LINABTC,LINABUSD,LINAUSDT,LINKAUD,LINKBKRW,LINKBNB,LINKBRL,LINKBTC,LINKBUSD,LINKETH,LINKEUR,LINKGBP,LINKNGN,LINKPAX,LINKTRY,LINKTUSD,LINKTWD,LINKUSDC,LINKUSDT,LITBTC,LITBUSD,LITETH,LITUSDT,LOKABNB,LOKABTC,LOKABUSD,LOKAUSDT,LOOKSTWD,LOOMBNB,LOOMBTC,LOOMBUSD,LOOMETH,LOOMUSDT,LOOTTWD,LOOTUSDT,LPTBNB,LPTBTC,LPTBUSD,LPTUSDT,LQTYBTC,LQTYUSDT,LRCBNB,LRCBTC,LRCBUSD,LRCETH,LRCTRY,LRCUSDT,LSKBNB,LSKBTC,LSKBUSD,LSKETH,LSKUSDT,LTCBNB,LTCBRL,LTCBTC,LTCBUSD,LTCETH,LTCEUR,LTCGBP,LTCNGN,LTCPAX,LTCRUB,LTCTRY,LTCTUSD,LTCTWD,LTCUAH,LTCUSDC,LTCUSDT,LTOBNB,LTOBTC,LTOBUSD,LTOUSDT,LUNAAUD,LUNABIDR,LUNABNB,LUNABRL,LUNABTC,LUNABUSD,LUNAETH,LUNAEUR,LUNAGBP,LUNATRY,LUNAUSDT,LUNAUST,LUNBTC,LUNCBUSD,LUNCUSDT,LUNETH,MAGICBTC,MAGICBUSD,MAGICTRY,MAGICUSDT,MANABIDR,MANABNB,MANABRL,MANABTC,MANABUSD,MANAETH,MANATRY,MANATWD,MANAUSDT,MASKBNB,MASKBUSD,MASKUSDT,MATICAUD,MATICBIDR,MATICBNB,MATICBRL,MATICBTC,MATICBUSD,MATICETH,MATICEUR,MATICGBP,MATICRUB,MATICTRY,MATICTUSD,MATICTWD,MATICUSDT,MAVBTC,MAVTUSD,MAVUSDT,MAXTWD,MAXUSDT,MBLBNB,MBLBTC,MBLBUSD,MBLUSDT,MBOXBNB,MBOXBTC,MBOXBUSD,MBOXTRY,MBOXUSDT,MCBNB,MCBTC,MCBUSD,MCOBNB,MCOBTC,MCOETH,MCOUSDT,MCUSDT,MDABTC,MDAETH,MDTBNB,MDTBTC,MDTBUSD,MDTUSDT,MDXBNB,MDXBTC,MDXBUSD,MDXUSDT,MFTBNB,MFTBTC,MFTETH,MFTUSDT,MINABNB,MINABTC,MINABUSD,MINATRY,MINAUSDT,MIRBTC,MIRBUSD,MIRUSDT,MITHBNB,MITHBTC,MITHUSDT,MKRBNB,MKRBTC,MKRBUSD,MKRUSDT,MLNBNB,MLNBTC,MLNBUSD,MLNUSDT,MOBBTC,MOBBUSD,MOBUSDT,MODBTC,MODETH,MOVRBNB,MOVRBTC,MOVRBUSD,MOVRUSDT,MTHBTC,MTHETH,MTLBTC,MTLBUSD,MTLETH,MTLUSDT,MULTIBTC,MULTIBUSD,MULTIUSDT,NANOBNB,NANOBTC,NANOBUSD,NANOETH,NANOUSDT,NASBNB,NASBTC,NASETH,NAVBNB,NAVBTC,NAVETH,NBSBTC,NBSUSDT,NCASHBNB,NCASHBTC,NCASHETH,NEARBNB,NEARBTC,NEARBUSD,NEARETH,NEAREUR,NEARRUB,NEARTRY,NEARUSDT,NEBLBNB,NEBLBTC,NEBLBUSD,NEBLUSDT,NEOBNB,NEOBTC,NEOBUSD,NEOETH,NEOPAX,NEORUB,NEOTRY,NEOTUSD,NEOUSDC,NEOUSDT,NEXOBTC,NEXOBUSD,NEXOUSDT,NKNBNB,NKNBTC,NKNBUSD,NKNUSDT,NMRBTC,NMRBUSD,NMRUSDT,NPXSBTC,NPXSETH,NPXSUSDC,NPXSUSDT,NUAUD,NUBNB,NUBTC,NUBUSD,NULSBNB,NULSBTC,NULSBUSD,NULSETH,NULSUSDT,NURUB,NUUSDT,NXSBNB,NXSBTC,NXSETH,OAXBTC,OAXETH,OAXUSDT,OCEANBNB,OCEANBTC,OCEANBUSD,OCEANUSDT,OGBTC,OGBUSD,OGNBNB,OGNBTC,OGNBUSD,OGNUSDT,OGTRY,OGUSDT,OMBTC,OMBUSD,OMGBNB,OMGBTC,OMGBUSD,OMGETH,OMGUSDT,OMUSDT,ONEBIDR,ONEBNB,ONEBTC,ONEBUSD,ONEETH,ONEPAX,ONETRY,ONETUSD,ONEUSDC,ONEUSDT,ONGBNB,ONGBTC,ONGUSDT,ONTBNB,ONTBTC,ONTBUSD,ONTETH,ONTPAX,ONTTRY,ONTUSDC,ONTUSDT,OOKIBNB,OOKIBUSD,OOKIETH,OOKIUSDT,OPBNB,OPBTC,OPBUSD,OPETH,OPEUR,OPTRY,OPTUSD,OPUSDT,ORNBTC,ORNBUSD,ORNUSDT,OSMOBTC,OSMOBUSD,OSMOUSDT,OSTBNB,OSTBTC,OSTETH,OXTBTC,OXTBUSD,OXTUSDT,PAXBNB,PAXBTC,PAXBUSD,PAXETH,PAXGBNB,PAXGBTC,PAXGBUSD,PAXGTRY,PAXGUSDT,PAXTUSD,PAXUSDT,PENDLEBTC,PENDLETUSD,PENDLEUSDT,PEOPLEBNB,PEOPLEBTC,PEOPLEBUSD,PEOPLEETH,PEOPLEUSDT,PEPETRY,PEPETUSD,PEPEUSDT,PERLBNB,PERLBTC,PERLUSDC,PERLUSDT,PERPBTC,PERPBUSD,PERPUSDT,PHABTC,PHABUSD,PHAUSDT,PHBBNB,PHBBTC,PHBBUSD,PHBPAX,PHBTUSD,PHBUSDC,PHBUSDT,PHXBNB,PHXBTC,PHXETH,PIVXBNB,PIVXBTC,PLABNB,PLABTC,PLABUSD,PLAUSDT,PNTBTC,PNTUSDT,POABNB,POABTC,POAETH,POEBTC,POEETH,POLSBNB,POLSBTC,POLSBUSD,POLSUSDT,POLYBNB,POLYBTC,POLYBUSD,POLYUSDT,POLYXBTC,POLYXBUSD,POLYXUSDT,PONDBTC,PONDBUSD,PONDUSDT,PORTOBTC,PORTOBUSD,PORTOEUR,PORTOTRY,PORTOUSDT,POWRBNB,POWRBTC,POWRBUSD,POWRETH,POWRUSDT,PPTBTC,PPTETH,PROMBNB,PROMBTC,PROMBUSD,PROMUSDT,PROSBUSD,PROSETH,PROSUSDT,PSGBTC,PSGBUSD,PSGUSDT,PUNDIXBUSD,PUNDIXETH,PUNDIXUSDT,PYRBTC,PYRBUSD,PYRUSDT,QIBNB,QIBTC,QIBUSD,QIUSDT,QKCBTC,QKCBUSD,QKCETH,QKCUSDT,QLCBNB,QLCBTC,QLCETH,QNTBNB,QNTBTC,QNTBUSD,QNTUSDT,QSPBNB,QSPBTC,QSPETH,QTUMBNB,QTUMBTC,QTUMBUSD,QTUMETH,QTUMUSDT,QUICKBNB,QUICKBTC,QUICKBUSD,QUICKUSDT,RADBNB,RADBTC,RADBUSD,RADTRY,RADUSDT,RAMPBTC,RAMPBUSD,RAMPUSDT,RAREBNB,RAREBTC,RAREBUSD,RAREUSDT,RAYBNB,RAYBUSD,RAYUSDT,RCNBNB,RCNBTC,RCNETH,RDNBNB,RDNBTC,RDNETH,RDNTBTC,RDNTTUSD,RDNTUSDT,REEFBIDR,REEFBTC,REEFBUSD,REEFTRY,REEFUSDT,REIBNB,REIBUSD,REIETH,REIUSDT,RENBNB,RENBTC,RENBTCBTC,RENBTCETH,RENBUSD,RENUSDT,REPBNB,REPBTC,REPBUSD,REPUSDT,REQBTC,REQBUSD,REQETH,REQUSDT,RGTBNB,RGTBTC,RGTBUSD,RGTUSDT,RIFBTC,RIFUSDT,RLCBNB,RLCBTC,RLCBUSD,RLCETH,RLCUSDT,RLYTWD,RLYUSDT,RNDRBTC,RNDRBUSD,RNDRTRY,RNDRUSDT,ROSEBNB,ROSEBTC,ROSEBUSD,ROSEETH,ROSETRY,ROSEUSDT,RPLBTC,RPLBUSD,RPLUSDT,RPXBNB,RPXBTC,RPXETH,RSRBNB,RSRBTC,RSRBUSD,RSRUSDT,RUNEAUD,RUNEBNB,RUNEBTC,RUNEBUSD,RUNEETH,RUNEEUR,RUNEGBP,RUNETRY,RUNEUSDT,RVNBTC,RVNBUSD,RVNTRY,RVNUSDT,SALTBTC,SALTETH,SANDAUD,SANDBIDR,SANDBNB,SANDBRL,SANDBTC,SANDBUSD,SANDETH,SANDTRY,SANDTWD,SANDUSDT,SANTOSBRL,SANTOSBTC,SANTOSBUSD,SANTOSTRY,SANTOSUSDT,SCBTC,SCBUSD,SCETH,SCRTBTC,SCRTBUSD,SCRTETH,SCRTUSDT,SCUSDT,SFPBTC,SFPBUSD,SFPUSDT,SHIBAUD,SHIBBRL,SHIBBUSD,SHIBDOGE,SHIBEUR,SHIBGBP,SHIBRUB,SHIBTRY,SHIBTWD,SHIBUAH,SHIBUSDT,SKLBTC,SKLBUSD,SKLUSDT,SKYBNB,SKYBTC,SKYETH,SLPBIDR,SLPBNB,SLPBUSD,SLPETH,SLPTRY,SLPUSDT,SNGLSBTC,SNGLSETH,SNMBTC,SNMBUSD,SNMETH,SNTBTC,SNTBUSD,SNTETH,SNTUSDT,SNXBNB,SNXBTC,SNXBUSD,SNXETH,SNXUSDT,SOLAUD,SOLBIDR,SOLBNB,SOLBRL,SOLBTC,SOLBUSD,SOLETH,SOLEUR,SOLGBP,SOLRUB,SOLTRY,SOLTUSD,SOLTWD,SOLUSDC,SOLUSDT,SPARTABNB,SPELLBNB,SPELLBTC,SPELLBUSD,SPELLTRY,SPELLUSDT,SRMBIDR,SRMBNB,SRMBTC,SRMBUSD,SRMUSDT,SSVBTC,SSVBUSD,SSVETH,SSVTUSD,SSVUSDT,STEEMBNB,STEEMBTC,STEEMBUSD,STEEMETH,STEEMUSDT,STGBTC,STGBUSD,STGUSDT,STMXBTC,STMXBUSD,STMXETH,STMXUSDT,STORJBTC,STORJBUSD,STORJETH,STORJTRY,STORJUSDT,STORMBNB,STORMBTC,STORMETH,STORMUSDT,STPTBNB,STPTBTC,STPTBUSD,STPTUSDT,STRATBNB,STRATBTC,STRATBUSD,STRATETH,STRATUSDT,STRAXBTC,STRAXBUSD,STRAXETH,STRAXUSDT,STXBNB,STXBTC,STXBUSD,STXTRY,STXUSDT,SUBBTC,SUBETH,SUIBNB,SUIBTC,SUIEUR,SUITRY,SUITUSD,SUIUSDT,SUNBTC,SUNBUSD,SUNUSDT,SUPERBTC,SUPERBUSD,SUPERUSDT,SUSDBTC,SUSDETH,SUSDUSDT,SUSHIBNB,SUSHIBTC,SUSHIBUSD,SUSHIUSDT,SWRVBNB,SWRVBUSD,SXPAUD,SXPBIDR,SXPBNB,SXPBTC,SXPBUSD,SXPEUR,SXPGBP,SXPTRY,SXPUSDT,SYNBTC,SYNUSDT,SYSBNB,SYSBTC,SYSBUSD,SYSETH,SYSUSDT,TBUSD,TCTBNB,TCTBTC,TCTUSDT,TFUELBNB,TFUELBTC,TFUELBUSD,TFUELPAX,TFUELTUSD,TFUELUSDC,TFUELUSDT,THETABNB,THETABTC,THETABUSD,THETAETH,THETAEUR,THETAUSDT,TKOBIDR,TKOBTC,TKOBUSD,TKOUSDT,TLMBNB,TLMBTC,TLMBUSD,TLMTRY,TLMUSDT,TNBBTC,TNBETH,TNTBTC,TNTETH,TOMOBNB,TOMOBTC,TOMOBUSD,TOMOUSDC,TOMOUSDT,TORNBNB,TORNBTC,TORNBUSD,TORNUSDT,TRBBNB,TRBBTC,TRBBUSD,TRBUSDT,TRIBEBNB,TRIBEBTC,TRIBEBUSD,TRIBEUSDT,TRIGBNB,TRIGBTC,TRIGETH,TROYBNB,TROYBTC,TROYBUSD,TROYUSDT,TRUBTC,TRUBUSD,TRURUB,TRUUSDT,TRXAUD,TRXBNB,TRXBTC,TRXBUSD,TRXETH,TRXEUR,TRXNGN,TRXPAX,TRXTRY,TRXTUSD,TRXUSDC,TRXUSDT,TRXXRP,TUSDBNB,TUSDBTC,TUSDBTUSD,TUSDBUSD,TUSDETH,TUSDT,TUSDUSDT,TVKBTC,TVKBUSD,TVKUSDT,TWTBTC,TWTBUSD,TWTTRY,TWTUSDT,UFTBUSD,UFTETH,UFTUSDT,UMABTC,UMABUSD,UMATRY,UMAUSDT,UNFIBNB,UNFIBTC,UNFIBUSD,UNFIETH,UNFIUSDT,UNIAUD,UNIBNB,UNIBTC,UNIBUSD,UNIETH,UNIEUR,UNIUSDT,USDCBNB,USDCBUSD,USDCPAX,USDCTUSD,USDCTWD,USDCUSDT,USDPBUSD,USDPUSDT,USDSBUSDS,USDSBUSDT,USDSPAX,USDSTUSD,USDSUSDC,USDSUSDT,USDTARS,USDTBIDR,USDTBKRW,USDTBRL,USDTBVND,USDTDAI,USDTIDRT,USDTNGN,USDTPLN,USDTRON,USDTRUB,USDTTRY,USDTTWD,USDTUAH,USDTZAR,USTBTC,USTBUSD,USTCBUSD,USTCUSDT,USTUSDT,UTKBTC,UTKBUSD,UTKUSDT,VENBNB,VENBTC,VENETH,VENUSDT,VETBNB,VETBTC,VETBUSD,VETETH,VETEUR,VETGBP,VETTRY,VETUSDT,VGXBTC,VGXETH,VGXUSDT,VIABNB,VIABTC,VIAETH,VIBBTC,VIBBUSD,VIBEBTC,VIBEETH,VIBETH,VIBUSDT,VIDTBTC,VIDTBUSD,VIDTUSDT,VITEBNB,VITEBTC,VITEBUSD,VITEUSDT,VOXELBNB,VOXELBTC,VOXELBUSD,VOXELETH,VOXELUSDT,VTHOBNB,VTHOBUSD,VTHOUSDT,WABIBNB,WABIBTC,WABIETH,WANBNB,WANBTC,WANETH,WANUSDT,WAVESBNB,WAVESBTC,WAVESBUSD,WAVESETH,WAVESEUR,WAVESPAX,WAVESRUB,WAVESTRY,WAVESTUSD,WAVESUSDC,WAVESUSDT,WAXPBNB,WAXPBTC,WAXPBUSD,WAXPUSDT,WBETHETH,WBTCBTC,WBTCBUSD,WBTCETH,WBTCUSDT,WINBNB,WINBRL,WINBTC,WINBUSD,WINEUR,WINGBNB,WINGBTC,WINGBUSD,WINGETH,WINGSBTC,WINGSETH,WINGUSDT,WINTRX,WINUSDC,WINUSDT,WNXMBNB,WNXMBTC,WNXMBUSD,WNXMUSDT,WOOBNB,WOOBTC,WOOBUSD,WOOUSDT,WPRBTC,WPRETH,WRXBNB,WRXBTC,WRXBUSD,WRXEUR,WRXUSDT,WTCBNB,WTCBTC,WTCETH,WTCUSDT,XECBUSD,XECUSDT,XEMBNB,XEMBTC,XEMBUSD,XEMETH,XEMUSDT,XLMBNB,XLMBTC,XLMBUSD,XLMETH,XLMEUR,XLMPAX,XLMTRY,XLMTUSD,XLMUSDC,XLMUSDT,XMRBNB,XMRBTC,XMRBUSD,XMRETH,XMRUSDT,XNOBTC,XNOBUSD,XNOETH,XNOUSDT,XRPAUD,XRPBEARBUSD,XRPBEARUSDT,XRPBIDR,XRPBKRW,XRPBNB,XRPBRL,XRPBTC,XRPBULLBUSD,XRPBULLUSDT,XRPBUSD,XRPETH,XRPEUR,XRPGBP,XRPNGN,XRPPAX,XRPRUB,XRPTRY,XRPTUSD,XRPTWD,XRPUSDC,XRPUSDT,XTZBNB,XTZBTC,XTZBUSD,XTZETH,XTZTRY,XTZTWD,XTZUSDT,XVGBTC,XVGBUSD,XVGETH,XVGUSDT,XVSBNB,XVSBTC,XVSBUSD,XVSTRY,XVSUSDT,XZCBNB,XZCBTC,XZCETH,XZCUSDT,XZCXRP,YFIBNB,YFIBTC,YFIBUSD,YFIEUR,YFIIBNB,YFIIBTC,YFIIBUSD,YFIIUSDT,YFITWD,YFIUSDT,YGGBNB,YGGBTC,YGGBUSD,YGGUSDT,YOYOBNB,YOYOBTC,YOYOETH,ZECBNB,ZECBTC,ZECBUSD,ZECETH,ZECPAX,ZECTUSD,ZECUSDC,ZECUSDT,ZENBNB,ZENBTC,ZENBUSD,ZENETH,ZENUSDT,ZILBIDR,ZILBNB,ZILBTC,ZILBUSD,ZILETH,ZILEUR,ZILTRY,ZILUSDT,ZRXBNB,ZRXBTC,ZRXBUSD,ZRXETH,ZRXUSDT, +} + +func toSymbol(s string) string { + for _, symbol := range symbols { + if s == symbol { + return symbol + } + } + return s +} + +func compileSymbols(symbols []string) []string { + var ss = make([]string, len(symbols)) + for i, s := range symbols { + ss[i] = toSymbol(s) + } + + return ss +} + diff --git a/pkg/strategy/tri/symbols.sh b/pkg/strategy/tri/symbols.sh new file mode 100644 index 0000000..dbf68a8 --- /dev/null +++ b/pkg/strategy/tri/symbols.sh @@ -0,0 +1,33 @@ +#!/bin/bash +echo '// Code generated by "bash symbols.sh"; DO NOT EDIT.' > symbols.go +echo 'package tri' >> symbols.go + +max_symbols=$(curl -s https://max-api.maicoin.com/api/v2/markets | jq -r '.[].id | ascii_upcase') +binance_symbols=$(curl -s https://api.binance.com/api/v3/exchangeInfo | jq -r '.symbols[].symbol | ascii_upcase') +symbols=$(echo "$max_symbols$binance_symbols" | sort | uniq | grep -v -E '^[0-9]' | grep -v "DOWNUSDT" | grep -v "UPUSDT") +echo "$symbols" | perl -l -n -e 'BEGIN { print "const (" } END { print ")" } print qq{\t$_ = "$_"}' >> symbols.go + +cat <> symbols.go +var symbols = []string{ + $(echo -e "$symbols" | tr '\n' ',') +} + +func toSymbol(s string) string { + for _, symbol := range symbols { + if s == symbol { + return symbol + } + } + return s +} + +func compileSymbols(symbols []string) []string { + var ss = make([]string, len(symbols)) + for i, s := range symbols { + ss[i] = toSymbol(s) + } + + return ss +} + +DOC diff --git a/pkg/strategy/tri/utils.go b/pkg/strategy/tri/utils.go new file mode 100644 index 0000000..4aa397e --- /dev/null +++ b/pkg/strategy/tri/utils.go @@ -0,0 +1,28 @@ +package tri + +import ( + "math" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func fitQuantityByBase(quantity, balance float64) (float64, float64) { + q := math.Min(quantity, balance) + r := q / balance + return q, r +} + +// 1620 x 2 , quote balance = 1000 => rate = 1000/(1620*2) = 0.3086419753, quantity = 0.61728395 +func fitQuantityByQuote(price, quantity, quoteBalance float64) (float64, float64) { + quote := quantity * price + minQuote := math.Min(quote, quoteBalance) + q := minQuote / price + r := minQuote / quoteBalance + return q, r +} + +func logSubmitOrders(orders [3]types.SubmitOrder) { + for i, order := range orders { + log.Infof("SUBMIT ORDER #%d: %s", i, order.String()) + } +} diff --git a/pkg/strategy/wall/strategy.go b/pkg/strategy/wall/strategy.go new file mode 100644 index 0000000..1fbcc9e --- /dev/null +++ b/pkg/strategy/wall/strategy.go @@ -0,0 +1,352 @@ +package wall + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/common" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ID = "wall" + +const stateKey = "state-v1" + +var defaultFeeRate = fixedpoint.NewFromFloat(0.001) +var two = fixedpoint.NewFromInt(2) + +var log = logrus.WithField("strategy", ID) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + *common.Strategy + + Environment *qbtrade.Environment + Market types.Market + + // Symbol is the market symbol you want to trade + Symbol string `json:"symbol"` + + Side types.SideType `json:"side"` + + // Interval is how long do you want to update your order price and quantity + Interval types.Interval `json:"interval"` + + FixedPrice fixedpoint.Value `json:"fixedPrice"` + + qbtrade.QuantityOrAmount + + NumLayers int `json:"numLayers"` + + // LayerSpread is the price spread between each layer + LayerSpread fixedpoint.Value `json:"layerSpread"` + + // QuantityScale helps user to define the quantity by layer scale + QuantityScale *qbtrade.LayerScale `json:"quantityScale,omitempty"` + + AdjustmentMinSpread fixedpoint.Value `json:"adjustmentMinSpread"` + AdjustmentQuantity fixedpoint.Value `json:"adjustmentQuantity"` + + session *qbtrade.ExchangeSession + + activeAdjustmentOrders *qbtrade.ActiveOrderBook + activeWallOrders *qbtrade.ActiveOrderBook +} + +func (s *Strategy) Initialize() error { + if s.Strategy == nil { + s.Strategy = &common.Strategy{} + } + return nil +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s", ID, s.Symbol) +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + session.Subscribe(types.BookChannel, s.Symbol, types.SubscribeOptions{ + Depth: types.DepthLevelFull, + }) +} + +func (s *Strategy) Validate() error { + if len(s.Symbol) == 0 { + return errors.New("symbol is required") + } + + if len(s.Side) == 0 { + return errors.New("side is required") + } + + if s.FixedPrice.IsZero() { + return errors.New("fixedPrice can not be zero") + } + + return nil +} + +func (s *Strategy) CurrentPosition() *types.Position { + return s.Position +} + +func (s *Strategy) placeAdjustmentOrders(ctx context.Context, orderExecutor qbtrade.OrderExecutor) error { + var submitOrders []types.SubmitOrder + // position adjustment orders + base := s.Position.GetBase() + if base.IsZero() { + return nil + } + + ticker, err := s.session.Exchange.QueryTicker(ctx, s.Symbol) + if err != nil { + return err + } + + if s.Market.IsDustQuantity(base, ticker.Last) { + return nil + } + + switch s.Side { + case types.SideTypeBuy: + askPrice := ticker.Sell.Mul(s.AdjustmentMinSpread.Add(fixedpoint.One)) + + if s.Position.AverageCost.Compare(askPrice) <= 0 { + return nil + } + + if base.Sign() < 0 { + return nil + } + + quantity := base.Abs() + if quantity.Compare(s.AdjustmentQuantity) >= 0 { + quantity = s.AdjustmentQuantity + } + + submitOrders = append(submitOrders, types.SubmitOrder{ + Symbol: s.Symbol, + Side: s.Side.Reverse(), + Type: types.OrderTypeLimitMaker, + Price: askPrice, + Quantity: quantity, + Market: s.Market, + }) + + case types.SideTypeSell: + bidPrice := ticker.Sell.Mul(fixedpoint.One.Sub(s.AdjustmentMinSpread)) + + if s.Position.AverageCost.Compare(bidPrice) >= 0 { + return nil + } + + if base.Sign() > 0 { + return nil + } + + quantity := base.Abs() + if quantity.Compare(s.AdjustmentQuantity) >= 0 { + quantity = s.AdjustmentQuantity + } + + submitOrders = append(submitOrders, types.SubmitOrder{ + Symbol: s.Symbol, + Side: s.Side.Reverse(), + Type: types.OrderTypeLimitMaker, + Price: bidPrice, + Quantity: quantity, + Market: s.Market, + }) + } + + // condition for lower the average cost + if len(submitOrders) == 0 { + return nil + } + + createdOrders, err := orderExecutor.SubmitOrders(ctx, submitOrders...) + if err != nil { + return err + } + + s.activeAdjustmentOrders.Add(createdOrders...) + return nil +} + +func (s *Strategy) placeWallOrders(ctx context.Context, orderExecutor qbtrade.OrderExecutor) error { + log.Infof("placing wall orders...") + + var submitOrders []types.SubmitOrder + var startPrice = s.FixedPrice + for i := 0; i < s.NumLayers; i++ { + var price = startPrice + var quantity fixedpoint.Value + if s.QuantityOrAmount.IsSet() { + quantity = s.QuantityOrAmount.CalculateQuantity(price) + } else if s.QuantityScale != nil { + qf, err := s.QuantityScale.Scale(i + 1) + if err != nil { + return err + } + quantity = fixedpoint.NewFromFloat(qf) + } + + order := types.SubmitOrder{ + Symbol: s.Symbol, + Side: s.Side, + Type: types.OrderTypeLimitMaker, + Price: price, + Quantity: quantity, + Market: s.Market, + } + submitOrders = append(submitOrders, order) + switch s.Side { + case types.SideTypeSell: + startPrice = startPrice.Add(s.LayerSpread) + + case types.SideTypeBuy: + startPrice = startPrice.Sub(s.LayerSpread) + + } + } + + // condition for lower the average cost + if len(submitOrders) == 0 { + return nil + } + + createdOrders, err := orderExecutor.SubmitOrders(ctx, submitOrders...) + if err != nil { + return err + } + + log.Infof("wall orders placed: %+v", createdOrders) + + s.activeWallOrders.Add(createdOrders...) + return err +} + +func (s *Strategy) Run(ctx context.Context, _ qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + s.Strategy.Initialize(ctx, s.Environment, session, s.Market, ID, s.InstanceID()) + + // initial required information + s.session = session + + s.activeWallOrders = qbtrade.NewActiveOrderBook(s.Symbol) + s.activeWallOrders.BindStream(session.UserDataStream) + + s.activeAdjustmentOrders = qbtrade.NewActiveOrderBook(s.Symbol) + s.activeAdjustmentOrders.BindStream(session.UserDataStream) + + session.UserDataStream.OnStart(func() { + if err := s.placeWallOrders(ctx, s.OrderExecutor); err != nil { + log.WithError(err).Errorf("can not place order") + } + }) + + s.activeAdjustmentOrders.OnFilled(func(o types.Order) { + if err := s.activeAdjustmentOrders.GracefulCancel(ctx, s.session.Exchange); err != nil { + log.WithError(err).Errorf("graceful cancel order error") + } + + // check if there is a canceled order had partially filled. + s.OrderExecutor.TradeCollector().Process() + + if err := s.placeAdjustmentOrders(ctx, s.OrderExecutor); err != nil { + log.WithError(err).Errorf("can not place order") + } + }) + + s.activeWallOrders.OnFilled(func(o types.Order) { + if err := s.activeWallOrders.GracefulCancel(ctx, s.session.Exchange); err != nil { + log.WithError(err).Errorf("graceful cancel order error") + } + + // check if there is a canceled order had partially filled. + s.OrderExecutor.TradeCollector().Process() + + if err := s.placeWallOrders(ctx, s.OrderExecutor); err != nil { + log.WithError(err).Errorf("can not place order") + } + + if err := s.activeAdjustmentOrders.GracefulCancel(ctx, s.session.Exchange); err != nil { + log.WithError(err).Errorf("graceful cancel order error") + } + + // check if there is a canceled order had partially filled. + s.OrderExecutor.TradeCollector().Process() + + if err := s.placeAdjustmentOrders(ctx, s.OrderExecutor); err != nil { + log.WithError(err).Errorf("can not place order") + } + }) + + ticker := time.NewTicker(s.Interval.Duration()) + go func() { + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + + case <-ticker.C: + orders := s.activeWallOrders.Orders() + if anyOrderFilled(orders) { + if err := s.activeWallOrders.GracefulCancel(ctx, s.session.Exchange); err != nil { + log.WithError(err).Errorf("graceful cancel order error") + } + + // check if there is a canceled order had partially filled. + s.OrderExecutor.TradeCollector().Process() + + if err := s.placeWallOrders(ctx, s.OrderExecutor); err != nil { + log.WithError(err).Errorf("can not place order") + } + } + } + } + }() + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + if err := s.activeWallOrders.GracefulCancel(ctx, s.session.Exchange); err != nil { + log.WithError(err).Errorf("graceful cancel order error") + } + + if err := s.activeAdjustmentOrders.GracefulCancel(ctx, s.session.Exchange); err != nil { + log.WithError(err).Errorf("graceful cancel order error") + } + + // check if there is a canceled order had partially filled. + s.OrderExecutor.TradeCollector().Process() + + qbtrade.Sync(ctx, s) + }) + + return nil +} + +func anyOrderFilled(orders []types.Order) bool { + for _, o := range orders { + if o.ExecutedQuantity.Sign() > 0 { + return true + } + } + return false +} diff --git a/pkg/strategy/xalign/strategy.go b/pkg/strategy/xalign/strategy.go new file mode 100644 index 0000000..19390bd --- /dev/null +++ b/pkg/strategy/xalign/strategy.go @@ -0,0 +1,448 @@ +package xalign + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/core" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ID = "xalign" + +var log = logrus.WithField("strategy", ID) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type TimeBalance struct { + types.Balance + + Time time.Time +} + +type QuoteCurrencyPreference struct { + Buy []string `json:"buy"` + Sell []string `json:"sell"` +} + +type Strategy struct { + *qbtrade.Environment + Interval types.Interval `json:"interval"` + PreferredSessions []string `json:"sessions"` + PreferredQuoteCurrencies *QuoteCurrencyPreference `json:"quoteCurrencies"` + ExpectedBalances map[string]fixedpoint.Value `json:"expectedBalances"` + UseTakerOrder bool `json:"useTakerOrder"` + DryRun bool `json:"dryRun"` + BalanceToleranceRange fixedpoint.Value `json:"balanceToleranceRange"` + Duration types.Duration `json:"for"` + MaxAmounts map[string]fixedpoint.Value `json:"maxAmounts"` + + faultBalanceRecords map[string][]TimeBalance + + sessions map[string]*qbtrade.ExchangeSession + orderBooks map[string]*qbtrade.ActiveOrderBook + + orderStore *core.OrderStore +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + var cs []string + + for cur := range s.ExpectedBalances { + cs = append(cs, cur) + } + + return ID + strings.Join(s.PreferredSessions, "-") + strings.Join(cs, "-") +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) { + // session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) +} + +func (s *Strategy) CrossSubscribe(sessions map[string]*qbtrade.ExchangeSession) { + +} + +func (s *Strategy) Defaults() error { + s.BalanceToleranceRange = fixedpoint.NewFromFloat(0.01) + return nil +} + +func (s *Strategy) Validate() error { + if s.PreferredQuoteCurrencies == nil { + return errors.New("quoteCurrencies is not defined") + } + + return nil +} + +func (s *Strategy) aggregateBalances( + ctx context.Context, sessions map[string]*qbtrade.ExchangeSession, +) (totalBalances types.BalanceMap, sessionBalances map[string]types.BalanceMap) { + totalBalances = make(types.BalanceMap) + sessionBalances = make(map[string]types.BalanceMap) + + // iterate the sessions and record them + for sessionName, session := range sessions { + // update the account balances and the margin information + if _, err := session.UpdateAccount(ctx); err != nil { + log.WithError(err).Errorf("can not update account") + return + } + + account := session.GetAccount() + balances := account.Balances() + + sessionBalances[sessionName] = balances + totalBalances = totalBalances.Add(balances) + } + + return totalBalances, sessionBalances +} + +func (s *Strategy) selectSessionForCurrency( + ctx context.Context, sessions map[string]*qbtrade.ExchangeSession, currency string, changeQuantity fixedpoint.Value, +) (*qbtrade.ExchangeSession, *types.SubmitOrder) { + for _, sessionName := range s.PreferredSessions { + session := sessions[sessionName] + + var taker = s.UseTakerOrder + var side types.SideType + var quoteCurrencies []string + if changeQuantity.Sign() > 0 { + quoteCurrencies = s.PreferredQuoteCurrencies.Buy + side = types.SideTypeBuy + } else { + quoteCurrencies = s.PreferredQuoteCurrencies.Sell + side = types.SideTypeSell + } + + for _, fromQuoteCurrency := range quoteCurrencies { + // skip the same currency, because there is no such USDT/USDT market + if currency == fromQuoteCurrency { + continue + } + + // check both fromQuoteCurrency/currency and currency/fromQuoteCurrency + reversed := false + baseCurrency := currency + quoteCurrency := fromQuoteCurrency + symbol := currency + quoteCurrency + market, ok := session.Market(symbol) + if !ok { + // for TWD in USDT/TWD market, buy TWD means sell USDT + baseCurrency = fromQuoteCurrency + quoteCurrency = currency + symbol = baseCurrency + currency + market, ok = session.Market(symbol) + if !ok { + continue + } + + // reverse side + side = side.Reverse() + reversed = true + } + + ticker, err := session.Exchange.QueryTicker(ctx, symbol) + if err != nil { + log.WithError(err).Errorf("unable to query ticker on %s", symbol) + continue + } + + spread := ticker.Sell.Sub(ticker.Buy) + + // changeQuantity > 0 = buy + // changeQuantity < 0 = sell + q := changeQuantity.Abs() + + // a fast filtering + if reversed { + if q.Compare(market.MinNotional) < 0 { + log.Debugf("skip dust notional: %f", q.Float64()) + continue + } + } else { + if q.Compare(market.MinQuantity) < 0 { + log.Debugf("skip dust quantity: %f", q.Float64()) + continue + } + } + + log.Infof("%s changeQuantity: %f ticker: %+v market: %+v", symbol, changeQuantity.Float64(), ticker, market) + + switch side { + + case types.SideTypeBuy: + var price fixedpoint.Value + if taker { + price = ticker.Sell + } else if spread.Compare(market.TickSize) > 0 { + price = ticker.Sell.Sub(market.TickSize) + } else { + price = ticker.Buy + } + + quoteBalance, ok := session.Account.Balance(quoteCurrency) + if !ok { + continue + } + + requiredQuoteAmount := fixedpoint.Zero + if reversed { + requiredQuoteAmount = q + } else { + requiredQuoteAmount = q.Mul(price) + } + + requiredQuoteAmount = requiredQuoteAmount.Round(market.PricePrecision, fixedpoint.Up) + if requiredQuoteAmount.Compare(quoteBalance.Available) > 0 { + log.Warnf("required quote amount %f > quote balance %v, skip", requiredQuoteAmount.Float64(), quoteBalance) + continue + } + + // for currency = TWD in market USDT/TWD + // since the side is reversed, the quote currency is also "TWD" here. + // + // for currency = BTC in market BTC/USDT and the side is buy + // we want to check if the quote currency USDT used up another expected balance. + if quoteCurrency != currency { + if expectedQuoteBalance, ok := s.ExpectedBalances[quoteCurrency]; ok { + rest := quoteBalance.Total().Sub(requiredQuoteAmount) + if rest.Compare(expectedQuoteBalance) < 0 { + log.Warnf("required quote amount %f will use up the expected balance %f, skip", requiredQuoteAmount.Float64(), expectedQuoteBalance.Float64()) + continue + } + } + } + + maxAmount, ok := s.MaxAmounts[market.QuoteCurrency] + if ok && requiredQuoteAmount.Compare(maxAmount) > 0 { + log.Infof("adjusted required quote ammount %f %s by max amount %f %s", requiredQuoteAmount.Float64(), market.QuoteCurrency, maxAmount.Float64(), market.QuoteCurrency) + + requiredQuoteAmount = maxAmount + } + + if quantity, ok := market.GreaterThanMinimalOrderQuantity(side, price, requiredQuoteAmount); ok { + return session, &types.SubmitOrder{ + Symbol: symbol, + Side: side, + Type: types.OrderTypeLimit, + Quantity: quantity, + Price: price, + Market: market, + TimeInForce: types.TimeInForceGTC, + } + } else { + log.Warnf("The amount %f is not greater than the minimal order quantity for %s", requiredQuoteAmount.Float64(), market.Symbol) + } + + case types.SideTypeSell: + var price fixedpoint.Value + if taker { + price = ticker.Buy + } else if spread.Compare(market.TickSize) > 0 { + price = ticker.Buy.Add(market.TickSize) + } else { + price = ticker.Sell + } + + if reversed { + q = q.Div(price) + } + + baseBalance, ok := session.Account.Balance(baseCurrency) + if !ok { + continue + } + + if q.Compare(baseBalance.Available) > 0 { + log.Warnf("required base amount %f < available base balance %v, skip", q.Float64(), baseBalance) + continue + } + + maxAmount, ok := s.MaxAmounts[market.QuoteCurrency] + if ok { + q = qbtrade.AdjustQuantityByMaxAmount(q, price, maxAmount) + log.Infof("adjusted quantity %f %s by max amount %f %s", q.Float64(), market.BaseCurrency, maxAmount.Float64(), market.QuoteCurrency) + } + + if quantity, ok := market.GreaterThanMinimalOrderQuantity(side, price, q); ok { + return session, &types.SubmitOrder{ + Symbol: symbol, + Side: side, + Type: types.OrderTypeLimit, + Quantity: quantity, + Price: price, + Market: market, + TimeInForce: types.TimeInForceGTC, + } + } else { + log.Warnf("The amount %f is not greater than the minimal order quantity for %s", q.Float64(), market.Symbol) + } + } + + } + } + + return nil, nil +} + +func (s *Strategy) CrossRun(ctx context.Context, _ qbtrade.OrderExecutionRouter, sessions map[string]*qbtrade.ExchangeSession) error { + instanceID := s.InstanceID() + _ = instanceID + + s.faultBalanceRecords = make(map[string][]TimeBalance) + s.sessions = make(map[string]*qbtrade.ExchangeSession) + s.orderBooks = make(map[string]*qbtrade.ActiveOrderBook) + + s.orderStore = core.NewOrderStore("") + + for _, sessionName := range s.PreferredSessions { + session, ok := sessions[sessionName] + if !ok { + return fmt.Errorf("incorrect preferred session name: %s is not defined", sessionName) + } + + s.orderStore.BindStream(session.UserDataStream) + + orderBook := qbtrade.NewActiveOrderBook("") + orderBook.BindStream(session.UserDataStream) + s.orderBooks[sessionName] = orderBook + + s.sessions[sessionName] = session + } + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + for n, session := range s.sessions { + if ob, ok := s.orderBooks[n]; ok { + _ = ob.GracefulCancel(ctx, session.Exchange) + } + } + }) + + go func() { + s.align(ctx, s.sessions) + + ticker := time.NewTicker(s.Interval.Duration()) + defer ticker.Stop() + + for { + select { + + case <-ctx.Done(): + return + + case <-ticker.C: + s.align(ctx, s.sessions) + } + } + }() + + return nil +} + +func (s *Strategy) recordBalance(totalBalances types.BalanceMap) { + now := time.Now() + for currency, expectedBalance := range s.ExpectedBalances { + q := s.calculateRefillQuantity(totalBalances, currency, expectedBalance) + rf := q.Div(expectedBalance).Abs().Float64() + tr := s.BalanceToleranceRange.Float64() + if rf > tr { + balance := totalBalances[currency] + s.faultBalanceRecords[currency] = append(s.faultBalanceRecords[currency], TimeBalance{ + Time: now, + Balance: balance, + }) + } else { + // reset counter + s.faultBalanceRecords[currency] = nil + } + } +} + +func (s *Strategy) align(ctx context.Context, sessions map[string]*qbtrade.ExchangeSession) { + + for sessionName, session := range sessions { + ob, ok := s.orderBooks[sessionName] + if !ok { + log.Errorf("orderbook on session %s not found", sessionName) + return + } + if ok { + if err := ob.GracefulCancel(ctx, session.Exchange); err != nil { + log.WithError(err).Errorf("can not cancel order") + } + } + } + + totalBalances, sessionBalances := s.aggregateBalances(ctx, sessions) + _ = sessionBalances + + s.recordBalance(totalBalances) + + for currency, expectedBalance := range s.ExpectedBalances { + q := s.calculateRefillQuantity(totalBalances, currency, expectedBalance) + + if s.Duration > 0 { + log.Infof("checking fault balance records...") + if faultBalance, ok := s.faultBalanceRecords[currency]; ok && len(faultBalance) > 0 { + if time.Since(faultBalance[0].Time) < s.Duration.Duration() { + log.Infof("%s fault record since: %s < persistence period %s", currency, faultBalance[0].Time, s.Duration.Duration()) + continue + } + } + } + + selectedSession, submitOrder := s.selectSessionForCurrency(ctx, sessions, currency, q) + if selectedSession != nil && submitOrder != nil { + log.Infof("placing order on %s: %+v", selectedSession.Name, submitOrder) + + qbtrade.Notify("Aligning position on exchange session %s, delta: %f", selectedSession.Name, q.Float64(), submitOrder) + + if s.DryRun { + return + } + + createdOrder, err := selectedSession.Exchange.SubmitOrder(ctx, *submitOrder) + if err != nil { + log.WithError(err).Errorf("can not place order") + return + } + + if createdOrder != nil { + if ob, ok := s.orderBooks[selectedSession.Name]; ok { + ob.Add(*createdOrder) + } else { + log.Errorf("orderbook %s not found", selectedSession.Name) + } + s.orderBooks[selectedSession.Name].Add(*createdOrder) + } + } + } +} + +func (s *Strategy) calculateRefillQuantity( + totalBalances types.BalanceMap, currency string, expectedBalance fixedpoint.Value, +) fixedpoint.Value { + if b, ok := totalBalances[currency]; ok { + netBalance := b.Net() + return expectedBalance.Sub(netBalance) + } + return expectedBalance +} diff --git a/pkg/strategy/xalign/strategy_test.go b/pkg/strategy/xalign/strategy_test.go new file mode 100644 index 0000000..0b07ffb --- /dev/null +++ b/pkg/strategy/xalign/strategy_test.go @@ -0,0 +1,216 @@ +//go:build !dnum + +package xalign + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + "go.uber.org/mock/gomock" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + . "git.qtrade.icu/lychiyu/qbtrade/pkg/testing/testhelper" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types/mocks" +) + +// cat ~/.qbtrade/cache/max-markets.json | jq '.[] | select(.symbol == "USDTTWD")' +func getTestMarkets() types.MarketMap { + return map[string]types.Market{ + "ETHBTC": { + Exchange: types.ExchangeMax, + Symbol: "ETHBTC", + LocalSymbol: "ETHBTC", + PricePrecision: 6, + VolumePrecision: 4, + BaseCurrency: "ETH", + QuoteCurrency: "BTC", + MinNotional: Number(0.00030000), + MinAmount: Number(0.00030000), + MinQuantity: Number(0.00460000), + StepSize: Number(0.00010000), + TickSize: Number(0.00000100), + }, + "BTCUSDT": { + Exchange: types.ExchangeMax, + Symbol: "BTCUSDT", + LocalSymbol: "BTCUSDT", + PricePrecision: 2, + VolumePrecision: 6, + BaseCurrency: "BTC", + QuoteCurrency: "USDT", + MinNotional: Number(8.00000000), + MinAmount: Number(8.00000000), + MinQuantity: Number(0.00030000), + StepSize: Number(0.00000100), + TickSize: Number(0.01000000), + }, + "BTCTWD": { + Exchange: types.ExchangeMax, + Symbol: "BTCTWD", + LocalSymbol: "BTCTWD", + PricePrecision: 1, + VolumePrecision: 8, + BaseCurrency: "BTC", + QuoteCurrency: "TWD", + MinNotional: Number(250.00000000), + MinAmount: Number(250.00000000), + MinQuantity: Number(0.00030000), + StepSize: Number(0.00000001), + TickSize: Number(0.01000000), + }, + "ETHUSDT": { + Exchange: types.ExchangeMax, + Symbol: "ETHUSDT", + LocalSymbol: "ETHUSDT", + PricePrecision: 2, + VolumePrecision: 6, + BaseCurrency: "ETH", + QuoteCurrency: "USDT", + MinNotional: Number(8.00000000), + MinAmount: Number(8.00000000), + MinQuantity: Number(0.00460000), + StepSize: Number(0.00001000), + TickSize: Number(0.01000000), + }, + "ETHTWD": { + Exchange: types.ExchangeMax, + Symbol: "ETHTWD", + LocalSymbol: "ETHTWD", + PricePrecision: 1, + VolumePrecision: 6, + BaseCurrency: "ETH", + QuoteCurrency: "TWD", + MinNotional: Number(250.00000000), + MinAmount: Number(250.00000000), + MinQuantity: Number(0.00460000), + StepSize: Number(0.00000100), + TickSize: Number(0.10000000), + }, + "USDTTWD": { + Exchange: types.ExchangeMax, + Symbol: "USDTTWD", + LocalSymbol: "USDTTWD", + PricePrecision: 3, + VolumePrecision: 2, + BaseCurrency: "USDT", + QuoteCurrency: "TWD", + MinNotional: Number(250.00000000), + MinAmount: Number(250.00000000), + MinQuantity: Number(8.00000000), + StepSize: Number(0.01000000), + TickSize: Number(0.00100000), + }, + } +} + +func TestStrategy(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + ctx := context.Background() + s := &Strategy{ + ExpectedBalances: map[string]fixedpoint.Value{ + "TWD": Number(10_000), + }, + PreferredQuoteCurrencies: &QuoteCurrencyPreference{ + Buy: []string{"TWD", "USDT"}, + Sell: []string{"USDT"}, + }, + PreferredSessions: []string{"max"}, + UseTakerOrder: true, + } + + testMarkets := getTestMarkets() + + t.Run("buy TWD", func(t *testing.T) { + mockEx := mocks.NewMockExchange(mockCtrl) + mockEx.EXPECT().QueryTicker(ctx, "USDTTWD").Return(&types.Ticker{ + Buy: Number(32.0), + Sell: Number(33.0), + }, nil) + + account := types.NewAccount() + account.AddBalance("TWD", Number(20_000)) + account.AddBalance("USDT", Number(80_000)) + + session := &qbtrade.ExchangeSession{ + Exchange: mockEx, + Account: account, + } + session.SetMarkets(testMarkets) + sessions := map[string]*qbtrade.ExchangeSession{} + sessions["max"] = session + + _, submitOrder := s.selectSessionForCurrency(ctx, sessions, "TWD", Number(70_000)) + assert.NotNil(t, submitOrder) + assert.Equal(t, types.SideTypeSell, submitOrder.Side) + assert.Equal(t, Number(32).String(), submitOrder.Price.String()) + assert.Equal(t, "2187.5", submitOrder.Quantity.String(), "70_000 / 32 best bid = 2187.5") + }) + + t.Run("sell TWD", func(t *testing.T) { + mockEx := mocks.NewMockExchange(mockCtrl) + mockEx.EXPECT().QueryTicker(ctx, "USDTTWD").Return(&types.Ticker{ + Buy: Number(32.0), + Sell: Number(33.0), + }, nil) + + account := types.NewAccount() + account.AddBalance("TWD", Number(20_000)) + account.AddBalance("USDT", Number(80_000)) + + session := &qbtrade.ExchangeSession{ + Exchange: mockEx, + Account: account, + } + session.SetMarkets(testMarkets) + sessions := map[string]*qbtrade.ExchangeSession{} + sessions["max"] = session + + _, submitOrder := s.selectSessionForCurrency(ctx, sessions, "TWD", Number(-10_000)) + assert.NotNil(t, submitOrder) + assert.Equal(t, types.SideTypeBuy, submitOrder.Side) + assert.Equal(t, Number(33).String(), submitOrder.Price.String()) + assert.Equal(t, "303.03", submitOrder.Quantity.String(), "10_000 / 33 best ask = 303.0303030303") + }) + + t.Run("buy BTC with USDT instead of TWD", func(t *testing.T) { + mockEx := mocks.NewMockExchange(mockCtrl) + + mockEx.EXPECT().QueryTicker(ctx, "BTCTWD").Return(&types.Ticker{ + Sell: Number(36000.0 * 32), + Buy: Number(35000.0 * 31), + }, nil) + + mockEx.EXPECT().QueryTicker(ctx, "BTCUSDT").Return(&types.Ticker{ + Sell: Number(36000.0), + Buy: Number(35000.0), + }, nil) + + account := types.NewAccount() + account.AddBalance("BTC", Number(0.955)) + account.AddBalance("TWD", Number(60_000)) + account.AddBalance("USDT", Number(80_000)) + + // 36000.0 * 32 * 0.045 + + session := &qbtrade.ExchangeSession{ + Exchange: mockEx, + Account: account, + } + + session.SetMarkets(testMarkets) + sessions := map[string]*qbtrade.ExchangeSession{} + sessions["max"] = session + + _, submitOrder := s.selectSessionForCurrency(ctx, sessions, "BTC", Number(0.045)) + assert.NotNil(t, submitOrder) + assert.Equal(t, types.SideTypeBuy, submitOrder.Side) + assert.Equal(t, "36000", submitOrder.Price.String()) + assert.Equal(t, "0.045", submitOrder.Quantity.String()) + }) +} diff --git a/pkg/strategy/xbalance/strategy.go b/pkg/strategy/xbalance/strategy.go new file mode 100644 index 0000000..19b0a3b --- /dev/null +++ b/pkg/strategy/xbalance/strategy.go @@ -0,0 +1,368 @@ +package xbalance + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "github.com/slack-go/slack" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util/templateutil" +) + +const ID = "xbalance" + +const stateKey = "state-v1" + +var priceFixer = fixedpoint.NewFromFloat(0.99) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type State struct { + Asset string `json:"asset"` + DailyNumberOfTransfers int `json:"dailyNumberOfTransfers,omitempty"` + DailyAmountOfTransfers fixedpoint.Value `json:"dailyAmountOfTransfers,omitempty"` + Since int64 `json:"since"` +} + +func (s *State) IsOver24Hours() bool { + return time.Since(time.Unix(s.Since, 0)) >= 24*time.Hour +} + +func (s *State) PlainText() string { + return templateutil.Render(`{{ .Asset }} transfer stats: +daily number of transfers: {{ .DailyNumberOfTransfers }} +daily amount of transfers {{ .DailyAmountOfTransfers.Float64 }}`, s) +} + +func (s *State) SlackAttachment() slack.Attachment { + return slack.Attachment{ + // Pretext: "", + // Text: text, + Title: s.Asset + " Transfer States", + Fields: []slack.AttachmentField{ + {Title: "Total Number of Transfers", Value: fmt.Sprintf("%d", s.DailyNumberOfTransfers), Short: true}, + {Title: "Total Amount of Transfers", Value: s.DailyAmountOfTransfers.String(), Short: true}, + }, + Footer: templateutil.Render("Since {{ . }}", time.Unix(s.Since, 0).Format(time.RFC822)), + } +} + +func (s *State) Reset() { + var beginningOfTheDay = types.BeginningOfTheDay(time.Now().Local()) + *s = State{ + DailyNumberOfTransfers: 0, + DailyAmountOfTransfers: fixedpoint.Zero, + Since: beginningOfTheDay.Unix(), + } +} + +type WithdrawalRequest struct { + FromSession string `json:"fromSession"` + ToSession string `json:"toSession"` + Asset string `json:"asset"` + Amount fixedpoint.Value `json:"amount"` +} + +func (r *WithdrawalRequest) String() string { + return fmt.Sprintf("WITHDRAWAL REQUEST: sending %s %s from %s -> %s", + r.Amount.FormatString(4), + r.Asset, + r.FromSession, + r.ToSession, + ) +} + +func (r *WithdrawalRequest) PlainText() string { + return fmt.Sprintf("Withdraw request: sending %s %s from %s -> %s", + r.Amount.FormatString(4), + r.Asset, + r.FromSession, + r.ToSession, + ) +} + +func (r *WithdrawalRequest) SlackAttachment() slack.Attachment { + var color = "#DC143C" + title := templateutil.Render(`Withdraw Request {{ .Asset }}`, r) + return slack.Attachment{ + // Pretext: "", + // Text: text, + Title: title, + Color: color, + Fields: []slack.AttachmentField{ + {Title: "Asset", Value: r.Asset, Short: true}, + {Title: "Amount", Value: r.Amount.FormatString(4), Short: true}, + {Title: "From", Value: r.FromSession}, + {Title: "To", Value: r.ToSession}, + }, + Footer: templateutil.Render("Time {{ . }}", time.Now().Format(time.RFC822)), + // FooterIcon: "", + } +} + +type Address struct { + Address string `json:"address"` + AddressTag string `json:"addressTag"` + Network string `json:"network"` + ForeignFee fixedpoint.Value `json:"foreignFee"` +} + +func (a *Address) UnmarshalJSON(body []byte) error { + var arg interface{} + err := json.Unmarshal(body, &arg) + if err != nil { + return err + } + + switch argT := arg.(type) { + case string: + a.Address = argT + return nil + } + + type addressTemplate Address + return json.Unmarshal(body, (*addressTemplate)(a)) +} + +type Strategy struct { + Interval types.Duration `json:"interval"` + + Addresses map[string]Address `json:"addresses"` + + MaxDailyNumberOfTransfer int `json:"maxDailyNumberOfTransfer"` + MaxDailyAmountOfTransfer fixedpoint.Value `json:"maxDailyAmountOfTransfer"` + + CheckOnStart bool `json:"checkOnStart"` + + Asset string `json:"asset"` + + // Low is the low balance level for triggering transfer + Low fixedpoint.Value `json:"low"` + + // Middle is the middle balance level used for re-fill asset + Middle fixedpoint.Value `json:"middle"` + + Verbose bool `json:"verbose"` + + State *State `persistence:"state"` +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) CrossSubscribe(sessions map[string]*qbtrade.ExchangeSession) {} + +func (s *Strategy) checkBalance(ctx context.Context, sessions map[string]*qbtrade.ExchangeSession) { + if s.Verbose { + qbtrade.Notify("📝 Checking %s low balance level exchange session...", s.Asset) + } + + var total fixedpoint.Value + for _, session := range sessions { + if b, ok := session.GetAccount().Balance(s.Asset); ok { + total = total.Add(b.Total()) + } + } + + lowLevelSession, lowLevelBalance, err := s.findLowBalanceLevelSession(sessions) + if err != nil { + qbtrade.Notify("Can not find low balance level session: %s", err.Error()) + log.WithError(err).Errorf("Can not find low balance level session") + return + } + + if lowLevelSession == nil { + if s.Verbose { + qbtrade.Notify("✅ All %s balances are looking good, total value: %v", s.Asset, total) + } + return + } + + qbtrade.Notify("⚠️ Found low level %s balance from session %s: %v", s.Asset, lowLevelSession.Name, lowLevelBalance) + + middle := s.Middle + if middle.IsZero() { + middle = total.Div(fixedpoint.NewFromInt(int64(len(sessions)))).Mul(priceFixer) + qbtrade.Notify("Total value %v %s, setting middle to %v", total, s.Asset, middle) + } + + requiredAmount := middle.Sub(lowLevelBalance.Available) + + qbtrade.Notify("Need %v %s to satisfy the middle balance level %v", requiredAmount, s.Asset, middle) + + fromSession, _, err := s.findHighestBalanceLevelSession(sessions, requiredAmount) + if err != nil || fromSession == nil { + qbtrade.Notify("Can not find session with enough balance") + log.WithError(err).Errorf("can not find session with enough balance") + return + } + + withdrawalService, ok := fromSession.Exchange.(types.ExchangeWithdrawalService) + if !ok { + log.Errorf("exchange %s does not implement withdrawal service, we can not withdrawal", fromSession.ExchangeName) + return + } + + if !fromSession.Withdrawal { + qbtrade.Notify("The withdrawal function exchange session %s is not enabled", fromSession.Name) + log.Errorf("The withdrawal function of exchange session %s is not enabled", fromSession.Name) + return + } + + toAddress, ok := s.Addresses[lowLevelSession.Name] + if !ok { + log.Errorf("%s address of session %s not found", s.Asset, lowLevelSession.Name) + qbtrade.Notify("%s address of session %s not found", s.Asset, lowLevelSession.Name) + return + } + + if toAddress.ForeignFee.Sign() > 0 { + requiredAmount = requiredAmount.Add(toAddress.ForeignFee) + } + + if s.State != nil { + if s.MaxDailyNumberOfTransfer > 0 { + if s.State.DailyNumberOfTransfers >= s.MaxDailyNumberOfTransfer { + qbtrade.Notify("⚠️ Exceeded %s max daily number of transfers %d (current %d), skipping transfer...", + s.Asset, + s.MaxDailyNumberOfTransfer, + s.State.DailyNumberOfTransfers) + return + } + } + + if s.MaxDailyAmountOfTransfer.Sign() > 0 { + if s.State.DailyAmountOfTransfers.Compare(s.MaxDailyAmountOfTransfer) >= 0 { + qbtrade.Notify("⚠️ Exceeded %s max daily amount of transfers %v (current %v), skipping transfer...", + s.Asset, + s.MaxDailyAmountOfTransfer, + s.State.DailyAmountOfTransfers) + return + } + } + } + + qbtrade.Notify(&WithdrawalRequest{ + FromSession: fromSession.Name, + ToSession: lowLevelSession.Name, + Asset: s.Asset, + Amount: requiredAmount, + }) + + if err := withdrawalService.Withdraw(ctx, s.Asset, requiredAmount, toAddress.Address, &types.WithdrawalOptions{ + Network: toAddress.Network, + AddressTag: toAddress.AddressTag, + }); err != nil { + log.WithError(err).Errorf("withdrawal failed") + qbtrade.Notify("withdrawal request failed, error: %v", err) + return + } + + qbtrade.Notify("%s withdrawal request sent", s.Asset) + + if s.State != nil { + if s.State.IsOver24Hours() { + s.State.Reset() + } + + s.State.DailyNumberOfTransfers += 1 + s.State.DailyAmountOfTransfers = s.State.DailyAmountOfTransfers.Add(requiredAmount) + qbtrade.Sync(ctx, s) + } +} + +func (s *Strategy) findHighestBalanceLevelSession(sessions map[string]*qbtrade.ExchangeSession, requiredAmount fixedpoint.Value) (*qbtrade.ExchangeSession, types.Balance, error) { + var balance types.Balance + var maxBalanceLevel = fixedpoint.Zero + var maxBalanceSession *qbtrade.ExchangeSession = nil + for sessionID := range s.Addresses { + session, ok := sessions[sessionID] + if !ok { + return nil, balance, fmt.Errorf("session %s does not exist", sessionID) + } + + if b, ok := session.GetAccount().Balance(s.Asset); ok { + if b.Available.Sub(requiredAmount).Compare(s.Low) > 0 && b.Available.Compare(maxBalanceLevel) > 0 { + maxBalanceLevel = b.Available + maxBalanceSession = session + balance = b + } + } + } + + return maxBalanceSession, balance, nil +} + +func (s *Strategy) findLowBalanceLevelSession(sessions map[string]*qbtrade.ExchangeSession) (*qbtrade.ExchangeSession, types.Balance, error) { + var balance types.Balance + for sessionID := range s.Addresses { + session, ok := sessions[sessionID] + if !ok { + return nil, balance, fmt.Errorf("session %s does not exist", sessionID) + } + + balance, ok = session.GetAccount().Balance(s.Asset) + if ok { + if balance.Available.Compare(s.Low) <= 0 { + return session, balance, nil + } + } + } + + return nil, balance, nil +} + +func (s *Strategy) newDefaultState() *State { + return &State{ + Asset: s.Asset, + DailyNumberOfTransfers: 0, + DailyAmountOfTransfers: fixedpoint.Zero, + } +} + +func (s *Strategy) CrossRun(ctx context.Context, _ qbtrade.OrderExecutionRouter, sessions map[string]*qbtrade.ExchangeSession) error { + if s.Interval == 0 { + return errors.New("interval can not be zero") + } + + if s.State == nil { + s.State = s.newDefaultState() + } + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + }) + + if s.CheckOnStart { + s.checkBalance(ctx, sessions) + } + + go func() { + ticker := time.NewTicker(util.MillisecondsJitter(s.Interval.Duration(), 1000)) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + + case <-ticker.C: + s.checkBalance(ctx, sessions) + } + } + }() + + return nil +} diff --git a/pkg/strategy/xbalance/strategy_test.go b/pkg/strategy/xbalance/strategy_test.go new file mode 100644 index 0000000..82ffd08 --- /dev/null +++ b/pkg/strategy/xbalance/strategy_test.go @@ -0,0 +1,19 @@ +package xbalance + +import ( + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "github.com/stretchr/testify/assert" +) + +func TestState_PlainText(t *testing.T) { + var state = State{ + Asset: "USDT", + DailyNumberOfTransfers: 1, + DailyAmountOfTransfers: fixedpoint.NewFromFloat(1000.0), + Since: 0, + } + + assert.Equal(t, "USDT transfer stats:\ndaily number of transfers: 1\ndaily amount of transfers 1000", state.PlainText()) +} diff --git a/pkg/strategy/xdepthmaker/profitfixer.go b/pkg/strategy/xdepthmaker/profitfixer.go new file mode 100644 index 0000000..0d76e80 --- /dev/null +++ b/pkg/strategy/xdepthmaker/profitfixer.go @@ -0,0 +1 @@ +package xdepthmaker diff --git a/pkg/strategy/xdepthmaker/state.go b/pkg/strategy/xdepthmaker/state.go new file mode 100644 index 0000000..30a1bfa --- /dev/null +++ b/pkg/strategy/xdepthmaker/state.go @@ -0,0 +1,68 @@ +package xdepthmaker + +import ( + "sync" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type State struct { + CoveredPosition fixedpoint.Value `json:"coveredPosition,omitempty"` + + // Deprecated: + Position *types.Position `json:"position,omitempty"` + + // Deprecated: + ProfitStats ProfitStats `json:"profitStats,omitempty"` +} + +type ProfitStats struct { + *types.ProfitStats + + lock sync.Mutex + + MakerExchange types.ExchangeName `json:"makerExchange"` + + AccumulatedMakerVolume fixedpoint.Value `json:"accumulatedMakerVolume,omitempty"` + AccumulatedMakerBidVolume fixedpoint.Value `json:"accumulatedMakerBidVolume,omitempty"` + AccumulatedMakerAskVolume fixedpoint.Value `json:"accumulatedMakerAskVolume,omitempty"` + + TodayMakerVolume fixedpoint.Value `json:"todayMakerVolume,omitempty"` + TodayMakerBidVolume fixedpoint.Value `json:"todayMakerBidVolume,omitempty"` + TodayMakerAskVolume fixedpoint.Value `json:"todayMakerAskVolume,omitempty"` +} + +func (s *ProfitStats) AddTrade(trade types.Trade) { + s.ProfitStats.AddTrade(trade) + + if trade.Exchange == s.MakerExchange { + s.lock.Lock() + s.AccumulatedMakerVolume = s.AccumulatedMakerVolume.Add(trade.Quantity) + s.TodayMakerVolume = s.TodayMakerVolume.Add(trade.Quantity) + + switch trade.Side { + + case types.SideTypeSell: + s.AccumulatedMakerAskVolume = s.AccumulatedMakerAskVolume.Add(trade.Quantity) + s.TodayMakerAskVolume = s.TodayMakerAskVolume.Add(trade.Quantity) + + case types.SideTypeBuy: + s.AccumulatedMakerBidVolume = s.AccumulatedMakerBidVolume.Add(trade.Quantity) + s.TodayMakerBidVolume = s.TodayMakerBidVolume.Add(trade.Quantity) + + } + s.lock.Unlock() + } +} + +func (s *ProfitStats) ResetToday() { + s.ProfitStats.ResetToday(time.Now()) + + s.lock.Lock() + s.TodayMakerVolume = fixedpoint.Zero + s.TodayMakerBidVolume = fixedpoint.Zero + s.TodayMakerAskVolume = fixedpoint.Zero + s.lock.Unlock() +} diff --git a/pkg/strategy/xdepthmaker/strategy.go b/pkg/strategy/xdepthmaker/strategy.go new file mode 100644 index 0000000..58a9724 --- /dev/null +++ b/pkg/strategy/xdepthmaker/strategy.go @@ -0,0 +1,939 @@ +package xdepthmaker + +import ( + "context" + stderrors "errors" + "fmt" + "sync" + "time" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "golang.org/x/time/rate" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/retry" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/common" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +var lastPriceModifier = fixedpoint.NewFromFloat(1.001) +var minGap = fixedpoint.NewFromFloat(1.02) +var defaultMargin = fixedpoint.NewFromFloat(0.003) + +var Two = fixedpoint.NewFromInt(2) + +const priceUpdateTimeout = 5 * time.Minute + +const ID = "xdepthmaker" + +var log = logrus.WithField("strategy", ID) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type CrossExchangeMarketMakingStrategy struct { + ctx, parent context.Context + cancel context.CancelFunc + + Environ *qbtrade.Environment + + makerSession, hedgeSession *qbtrade.ExchangeSession + makerMarket, hedgeMarket types.Market + + // persistence fields + Position *types.Position `json:"position,omitempty" persistence:"position"` + ProfitStats *types.ProfitStats `json:"profitStats,omitempty" persistence:"profit_stats"` + CoveredPosition fixedpoint.Value `json:"coveredPosition,omitempty" persistence:"covered_position"` + mu sync.Mutex + + MakerOrderExecutor, HedgeOrderExecutor *qbtrade.GeneralOrderExecutor +} + +func (s *CrossExchangeMarketMakingStrategy) Initialize( + ctx context.Context, environ *qbtrade.Environment, + makerSession, hedgeSession *qbtrade.ExchangeSession, + symbol, strategyID, instanceID string, +) error { + s.parent = ctx + s.ctx, s.cancel = context.WithCancel(ctx) + + s.Environ = environ + + s.makerSession = makerSession + s.hedgeSession = hedgeSession + + var ok bool + s.hedgeMarket, ok = s.hedgeSession.Market(symbol) + if !ok { + return fmt.Errorf("source session market %s is not defined", symbol) + } + + s.makerMarket, ok = s.makerSession.Market(symbol) + if !ok { + return fmt.Errorf("maker session market %s is not defined", symbol) + } + + if s.ProfitStats == nil { + s.ProfitStats = types.NewProfitStats(s.makerMarket) + } + + if s.Position == nil { + s.Position = types.NewPositionFromMarket(s.makerMarket) + } + + // Always update the position fields + s.Position.Strategy = strategyID + s.Position.StrategyInstanceID = instanceID + + // if anyone of the fee rate is defined, this assumes that both are defined. + // so that zero maker fee could be applied + for _, ses := range []*qbtrade.ExchangeSession{makerSession, hedgeSession} { + if ses.MakerFeeRate.Sign() > 0 || ses.TakerFeeRate.Sign() > 0 { + s.Position.SetExchangeFeeRate(ses.ExchangeName, types.ExchangeFee{ + MakerFeeRate: ses.MakerFeeRate, + TakerFeeRate: ses.TakerFeeRate, + }) + } + } + + s.MakerOrderExecutor = qbtrade.NewGeneralOrderExecutor( + makerSession, + s.makerMarket.Symbol, + strategyID, instanceID, + s.Position) + s.MakerOrderExecutor.BindEnvironment(environ) + s.MakerOrderExecutor.BindProfitStats(s.ProfitStats) + s.MakerOrderExecutor.Bind() + s.MakerOrderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { + // qbtrade.Sync(ctx, s) + }) + + s.HedgeOrderExecutor = qbtrade.NewGeneralOrderExecutor( + hedgeSession, + s.hedgeMarket.Symbol, + strategyID, instanceID, + s.Position) + s.HedgeOrderExecutor.BindEnvironment(environ) + s.HedgeOrderExecutor.BindProfitStats(s.ProfitStats) + s.HedgeOrderExecutor.Bind() + s.HedgeOrderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { + // qbtrade.Sync(ctx, s) + }) + + s.HedgeOrderExecutor.TradeCollector().OnTrade(func(trade types.Trade, profit, netProfit fixedpoint.Value) { + c := trade.PositionChange() + + // sync covered position + // sell trade -> negative delta -> + // 1) long position -> reduce long position + // 2) short position -> increase short position + // buy trade -> positive delta -> + // 1) short position -> reduce short position + // 2) short position -> increase short position + + // TODO: make this atomic + s.mu.Lock() + s.CoveredPosition = s.CoveredPosition.Add(c) + s.mu.Unlock() + }) + return nil +} + +type Strategy struct { + *CrossExchangeMarketMakingStrategy + + Environment *qbtrade.Environment + + Symbol string `json:"symbol"` + + // HedgeExchange session name + HedgeExchange string `json:"hedgeExchange"` + + // MakerExchange session name + MakerExchange string `json:"makerExchange"` + + UpdateInterval types.Duration `json:"updateInterval"` + HedgeInterval types.Duration `json:"hedgeInterval"` + + FullReplenishInterval types.Duration `json:"fullReplenishInterval"` + + OrderCancelWaitTime types.Duration `json:"orderCancelWaitTime"` + + Margin fixedpoint.Value `json:"margin"` + BidMargin fixedpoint.Value `json:"bidMargin"` + AskMargin fixedpoint.Value `json:"askMargin"` + + StopHedgeQuoteBalance fixedpoint.Value `json:"stopHedgeQuoteBalance"` + StopHedgeBaseBalance fixedpoint.Value `json:"stopHedgeBaseBalance"` + + // Quantity is used for fixed quantity of the first layer + Quantity fixedpoint.Value `json:"quantity"` + + // QuantityScale helps user to define the quantity by layer scale + QuantityScale *qbtrade.LayerScale `json:"quantityScale,omitempty"` + + // DepthScale helps user to define the depth by layer scale + DepthScale *qbtrade.LayerScale `json:"depthScale,omitempty"` + + // MaxExposurePosition defines the unhedged quantity of stop + MaxExposurePosition fixedpoint.Value `json:"maxExposurePosition"` + + DisableHedge bool `json:"disableHedge"` + + NotifyTrade bool `json:"notifyTrade"` + + // RecoverTrade tries to find the missing trades via the REStful API + RecoverTrade bool `json:"recoverTrade"` + + RecoverTradeScanPeriod types.Duration `json:"recoverTradeScanPeriod"` + + NumLayers int `json:"numLayers"` + + // Pips is the pips of the layer prices + Pips fixedpoint.Value `json:"pips"` + + ProfitFixerConfig *common.ProfitFixerConfig `json:"profitFixer"` + + // -------------------------------- + // private fields + // -------------------------------- + + // pricingBook is the order book (depth) from the hedging session + pricingBook *types.StreamOrderBook + + hedgeErrorLimiter *rate.Limiter + hedgeErrorRateReservation *rate.Reservation + + askPriceHeartBeat, bidPriceHeartBeat *types.PriceHeartBeat + + lastPrice fixedpoint.Value + + stopC, authedC chan struct{} +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s:%s-%s", ID, s.Symbol, s.MakerExchange, s.HedgeExchange) +} + +func (s *Strategy) Initialize() error { + if s.CrossExchangeMarketMakingStrategy == nil { + s.CrossExchangeMarketMakingStrategy = &CrossExchangeMarketMakingStrategy{} + } + + s.bidPriceHeartBeat = types.NewPriceHeartBeat(priceUpdateTimeout) + s.askPriceHeartBeat = types.NewPriceHeartBeat(priceUpdateTimeout) + return nil +} + +func (s *Strategy) CrossSubscribe(sessions map[string]*qbtrade.ExchangeSession) { + makerSession, hedgeSession, err := selectSessions2(sessions, s.MakerExchange, s.HedgeExchange) + if err != nil { + panic(err) + } + + hedgeSession.Subscribe(types.BookChannel, s.Symbol, types.SubscribeOptions{ + Depth: types.DepthLevelMedium, + Speed: types.SpeedLow, + }) + + hedgeSession.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: "1m"}) + makerSession.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: "1m"}) +} + +func (s *Strategy) Validate() error { + if s.MakerExchange == "" { + return errors.New("maker exchange is not configured") + } + + if s.HedgeExchange == "" { + return errors.New("maker exchange is not configured") + } + + if s.DepthScale == nil { + return errors.New("depthScale can not be empty") + } + + if len(s.Symbol) == 0 { + return errors.New("symbol is required") + } + + return nil +} + +func (s *Strategy) Defaults() error { + if s.UpdateInterval == 0 { + s.UpdateInterval = types.Duration(5 * time.Second) + } + + if s.FullReplenishInterval == 0 { + s.FullReplenishInterval = types.Duration(10 * time.Minute) + } + + if s.HedgeInterval == 0 { + s.HedgeInterval = types.Duration(3 * time.Second) + } + + if s.NumLayers == 0 { + s.NumLayers = 1 + } + + if s.Margin.IsZero() { + s.Margin = defaultMargin + } + + if s.BidMargin.IsZero() { + if !s.Margin.IsZero() { + s.BidMargin = s.Margin + } else { + s.BidMargin = defaultMargin + } + } + + if s.AskMargin.IsZero() { + if !s.Margin.IsZero() { + s.AskMargin = s.Margin + } else { + s.AskMargin = defaultMargin + } + } + + s.hedgeErrorLimiter = rate.NewLimiter(rate.Every(1*time.Minute), 1) + return nil +} + +func (s *Strategy) CrossRun( + ctx context.Context, _ qbtrade.OrderExecutionRouter, + sessions map[string]*qbtrade.ExchangeSession, +) error { + makerSession, hedgeSession, err := selectSessions2(sessions, s.MakerExchange, s.HedgeExchange) + if err != nil { + return err + } + + log.Infof("makerSession: %s hedgeSession: %s", makerSession.Name, hedgeSession.Name) + + if s.ProfitFixerConfig != nil { + qbtrade.Notify("Fixing %s profitStats and position...", s.Symbol) + + log.Infof("profitFixer is enabled, checking checkpoint: %+v", s.ProfitFixerConfig.TradesSince) + + if s.ProfitFixerConfig.TradesSince.Time().IsZero() { + return errors.New("tradesSince time can not be zero") + } + + makerMarket, _ := makerSession.Market(s.Symbol) + s.CrossExchangeMarketMakingStrategy.Position = types.NewPositionFromMarket(makerMarket) + s.CrossExchangeMarketMakingStrategy.ProfitStats = types.NewProfitStats(makerMarket) + + fixer := common.NewProfitFixer() + if ss, ok := makerSession.Exchange.(types.ExchangeTradeHistoryService); ok { + log.Infof("adding makerSession %s to profitFixer", makerSession.Name) + fixer.AddExchange(makerSession.Name, ss) + } + + if ss, ok := hedgeSession.Exchange.(types.ExchangeTradeHistoryService); ok { + log.Infof("adding hedgeSession %s to profitFixer", hedgeSession.Name) + fixer.AddExchange(hedgeSession.Name, ss) + } + + if err2 := fixer.Fix(ctx, makerMarket.Symbol, + s.ProfitFixerConfig.TradesSince.Time(), + time.Now(), + s.CrossExchangeMarketMakingStrategy.ProfitStats, + s.CrossExchangeMarketMakingStrategy.Position); err2 != nil { + return err2 + } + + qbtrade.Notify("Fixed %s position", s.Symbol, s.CrossExchangeMarketMakingStrategy.Position) + qbtrade.Notify("Fixed %s profitStats", s.Symbol, s.CrossExchangeMarketMakingStrategy.ProfitStats) + } + + if err := s.CrossExchangeMarketMakingStrategy.Initialize(ctx, + s.Environment, + makerSession, + hedgeSession, + s.Symbol, ID, s.InstanceID()); err != nil { + return err + } + + s.pricingBook = types.NewStreamBook(s.Symbol) + s.pricingBook.BindStream(s.hedgeSession.MarketDataStream) + + s.stopC = make(chan struct{}) + + s.authedC = make(chan struct{}, 5) + bindAuthSignal(ctx, s.makerSession.UserDataStream, s.authedC) + bindAuthSignal(ctx, s.hedgeSession.UserDataStream, s.authedC) + + if s.RecoverTrade { + go s.runTradeRecover(ctx) + } + + go func() { + log.Infof("waiting for user data stream to get authenticated") + select { + case <-ctx.Done(): + return + case <-s.authedC: + } + + select { + case <-ctx.Done(): + return + case <-s.authedC: + } + + log.Infof("user data stream authenticated, start placing orders...") + + posTicker := time.NewTicker(util.MillisecondsJitter(s.HedgeInterval.Duration(), 200)) + defer posTicker.Stop() + + fullReplenishTicker := time.NewTicker(util.MillisecondsJitter(s.FullReplenishInterval.Duration(), 200)) + defer fullReplenishTicker.Stop() + + // clean up the previous open orders + if err := s.cleanUpOpenOrders(ctx, s.makerSession); err != nil { + log.WithError(err).Errorf("error cleaning up open orders") + } + + s.updateQuote(ctx, 0) + + lastOrderReplenishTime := time.Now() + for { + select { + + case <-s.stopC: + log.Warnf("%s maker goroutine stopped, due to the stop signal", s.Symbol) + return + + case <-ctx.Done(): + log.Warnf("%s maker goroutine stopped, due to the cancelled context", s.Symbol) + return + + case <-fullReplenishTicker.C: + s.updateQuote(ctx, 0) + lastOrderReplenishTime = time.Now() + + case sig, ok := <-s.pricingBook.C: + // when any book change event happened + if !ok { + return + } + + if time.Since(lastOrderReplenishTime) < 10*time.Second { + continue + } + + switch sig.Type { + case types.BookSignalSnapshot: + s.updateQuote(ctx, 0) + + case types.BookSignalUpdate: + s.updateQuote(ctx, 5) + } + + lastOrderReplenishTime = time.Now() + + case <-posTicker.C: + // For positive position and positive covered position: + // uncover position = +5 - +3 (covered position) = 2 + // + // For positive position and negative covered position: + // uncover position = +5 - (-3) (covered position) = 8 + // + // meaning we bought 5 on MAX and sent buy order with 3 on binance + // + // For negative position: + // uncover position = -5 - -3 (covered position) = -2 + s.HedgeOrderExecutor.TradeCollector().Process() + s.MakerOrderExecutor.TradeCollector().Process() + + position := s.Position.GetBase() + uncoverPosition := position.Sub(s.CoveredPosition) + absPos := uncoverPosition.Abs() + if absPos.Compare(s.hedgeMarket.MinQuantity) > 0 { + log.Infof("%s base position %v coveredPosition: %v uncoverPosition: %v", + s.Symbol, + position, + s.CoveredPosition, + uncoverPosition, + ) + + if !s.DisableHedge { + s.Hedge(ctx, uncoverPosition.Neg()) + } + } + } + } + }() + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + close(s.stopC) + + // wait for the quoter to stop + time.Sleep(s.UpdateInterval.Duration()) + + if err := s.MakerOrderExecutor.GracefulCancel(ctx); err != nil { + log.WithError(err).Errorf("graceful cancel %s order error", s.Symbol) + } + + if err := s.HedgeOrderExecutor.GracefulCancel(ctx); err != nil { + log.WithError(err).Errorf("graceful cancel %s order error", s.Symbol) + } + + qbtrade.Sync(ctx, s) + qbtrade.Notify("%s: %s position", ID, s.Symbol, s.Position) + }) + + return nil +} + +func (s *Strategy) Hedge(ctx context.Context, pos fixedpoint.Value) { + side := types.SideTypeBuy + if pos.IsZero() { + return + } + + quantity := pos.Abs() + + if pos.Sign() < 0 { + side = types.SideTypeSell + } + + lastPrice := s.lastPrice + sourceBook := s.pricingBook.CopyDepth(1) + switch side { + + case types.SideTypeBuy: + if bestAsk, ok := sourceBook.BestAsk(); ok { + lastPrice = bestAsk.Price + } + + case types.SideTypeSell: + if bestBid, ok := sourceBook.BestBid(); ok { + lastPrice = bestBid.Price + } + } + + notional := quantity.Mul(lastPrice) + if notional.Compare(s.hedgeMarket.MinNotional) <= 0 { + log.Warnf("%s %v less than min notional, skipping hedge", s.Symbol, notional) + return + } + + // adjust quantity according to the balances + account := s.hedgeSession.GetAccount() + switch side { + + case types.SideTypeBuy: + // check quote quantity + if quote, ok := account.Balance(s.hedgeMarket.QuoteCurrency); ok { + if quote.Available.Compare(notional) < 0 { + // adjust price to higher 0.1%, so that we can ensure that the order can be executed + quantity = qbtrade.AdjustQuantityByMaxAmount(quantity, lastPrice.Mul(lastPriceModifier), quote.Available) + quantity = s.hedgeMarket.TruncateQuantity(quantity) + } + } + + case types.SideTypeSell: + // check quote quantity + if base, ok := account.Balance(s.hedgeMarket.BaseCurrency); ok { + if base.Available.Compare(quantity) < 0 { + quantity = base.Available + } + } + } + + // truncate quantity for the supported precision + quantity = s.hedgeMarket.TruncateQuantity(quantity) + + if notional.Compare(s.hedgeMarket.MinNotional.Mul(minGap)) <= 0 { + log.Warnf("the adjusted amount %v is less than minimal notional %v, skipping hedge", notional, s.hedgeMarket.MinNotional) + return + } + + if quantity.Compare(s.hedgeMarket.MinQuantity.Mul(minGap)) <= 0 { + log.Warnf("the adjusted quantity %v is less than minimal quantity %v, skipping hedge", quantity, s.hedgeMarket.MinQuantity) + return + } + + if s.hedgeErrorRateReservation != nil { + if !s.hedgeErrorRateReservation.OK() { + return + } + qbtrade.Notify("Hit hedge error rate limit, waiting...") + time.Sleep(s.hedgeErrorRateReservation.Delay()) + s.hedgeErrorRateReservation = nil + } + + log.Infof("submitting %s hedge order %s %v", s.Symbol, side.String(), quantity) + qbtrade.Notify("Submitting %s hedge order %s %v", s.Symbol, side.String(), quantity) + + _, err := s.HedgeOrderExecutor.SubmitOrders(ctx, types.SubmitOrder{ + Market: s.hedgeMarket, + Symbol: s.Symbol, + Type: types.OrderTypeMarket, + Side: side, + Quantity: quantity, + }) + + if err != nil { + s.hedgeErrorRateReservation = s.hedgeErrorLimiter.Reserve() + log.WithError(err).Errorf("market order submit error: %s", err.Error()) + return + } + + // if the hedge is on sell side, then we should add positive position + switch side { + case types.SideTypeSell: + s.mu.Lock() + s.CoveredPosition = s.CoveredPosition.Add(quantity) + s.mu.Unlock() + case types.SideTypeBuy: + s.mu.Lock() + s.CoveredPosition = s.CoveredPosition.Add(quantity.Neg()) + s.mu.Unlock() + } +} + +func (s *Strategy) runTradeRecover(ctx context.Context) { + tradeScanInterval := s.RecoverTradeScanPeriod.Duration() + if tradeScanInterval == 0 { + tradeScanInterval = 30 * time.Minute + } + + tradeScanOverlapBufferPeriod := 5 * time.Minute + + tradeScanTicker := time.NewTicker(tradeScanInterval) + defer tradeScanTicker.Stop() + + for { + select { + case <-ctx.Done(): + return + + case <-tradeScanTicker.C: + log.Infof("scanning trades from %s ago...", tradeScanInterval) + + startTime := time.Now().Add(-tradeScanInterval).Add(-tradeScanOverlapBufferPeriod) + + if err := s.HedgeOrderExecutor.TradeCollector().Recover(ctx, s.hedgeSession.Exchange.(types.ExchangeTradeHistoryService), s.Symbol, startTime); err != nil { + log.WithError(err).Errorf("query trades error") + } + + if err := s.MakerOrderExecutor.TradeCollector().Recover(ctx, s.makerSession.Exchange.(types.ExchangeTradeHistoryService), s.Symbol, startTime); err != nil { + log.WithError(err).Errorf("query trades error") + } + } + } +} + +func (s *Strategy) generateMakerOrders( + pricingBook *types.StreamOrderBook, maxLayer int, availableBase fixedpoint.Value, availableQuote fixedpoint.Value, +) ([]types.SubmitOrder, error) { + _, _, hasPrice := pricingBook.BestBidAndAsk() + if !hasPrice { + return nil, nil + } + + var submitOrders []types.SubmitOrder + var accumulatedBidQuantity = fixedpoint.Zero + var accumulatedAskQuantity = fixedpoint.Zero + var accumulatedBidQuoteQuantity = fixedpoint.Zero + + // copy the pricing book because during the generation the book data could change + dupPricingBook := pricingBook.Copy() + + log.Infof("pricingBook: \n\tbids: %+v \n\tasks: %+v", + dupPricingBook.SideBook(types.SideTypeBuy), + dupPricingBook.SideBook(types.SideTypeSell)) + + if maxLayer == 0 || maxLayer > s.NumLayers { + maxLayer = s.NumLayers + } + + var availableBalances = map[types.SideType]fixedpoint.Value{ + types.SideTypeBuy: availableQuote, + types.SideTypeSell: availableBase, + } + + for _, side := range []types.SideType{types.SideTypeBuy, types.SideTypeSell} { + sideBook := dupPricingBook.SideBook(side) + if sideBook.Len() == 0 { + log.Warnf("orderbook %s side is empty", side) + continue + } + + availableSideBalance, ok := availableBalances[side] + if !ok { + log.Warnf("no available balance for side %s side", side) + continue + } + + layerLoop: + for i := 1; i <= maxLayer; i++ { + // simple break, we need to check the market minNotional and minQuantity later + if !availableSideBalance.Eq(fixedpoint.PosInf) { + if availableSideBalance.IsZero() || availableSideBalance.Sign() < 0 { + break layerLoop + } + } + + requiredDepthFloat, err := s.DepthScale.Scale(i) + if err != nil { + return nil, errors.Wrapf(err, "depthScale scale error") + } + + // requiredDepth is the required depth in quote currency + requiredDepth := fixedpoint.NewFromFloat(requiredDepthFloat) + + index := sideBook.IndexByQuoteVolumeDepth(requiredDepth) + + pvs := types.PriceVolumeSlice{} + if index == -1 { + pvs = sideBook[:] + } else { + pvs = sideBook[0 : index+1] + } + + if len(pvs) == 0 { + continue + } + + log.Infof("side: %s required depth: %f, pvs: %+v", side, requiredDepth.Float64(), pvs) + + depthPrice := pvs.AverageDepthPriceByQuote(fixedpoint.Zero, 0) + + switch side { + case types.SideTypeBuy: + if s.BidMargin.Sign() > 0 { + depthPrice = depthPrice.Mul(fixedpoint.One.Sub(s.BidMargin)) + } + + depthPrice = depthPrice.Round(s.makerMarket.PricePrecision+1, fixedpoint.Down) + + case types.SideTypeSell: + if s.AskMargin.Sign() > 0 { + depthPrice = depthPrice.Mul(fixedpoint.One.Add(s.AskMargin)) + } + + depthPrice = depthPrice.Round(s.makerMarket.PricePrecision+1, fixedpoint.Up) + } + + depthPrice = s.makerMarket.TruncatePrice(depthPrice) + + quantity := requiredDepth.Div(depthPrice) + quantity = s.makerMarket.TruncateQuantity(quantity) + log.Infof("side: %s required depth: %f price: %f quantity: %f", side, requiredDepth.Float64(), depthPrice.Float64(), quantity.Float64()) + + switch side { + case types.SideTypeBuy: + quantity = quantity.Sub(accumulatedBidQuantity) + + accumulatedBidQuantity = accumulatedBidQuantity.Add(quantity) + quoteQuantity := fixedpoint.Mul(quantity, depthPrice) + quoteQuantity = quoteQuantity.Round(s.makerMarket.PricePrecision, fixedpoint.Up) + + if !availableSideBalance.Eq(fixedpoint.PosInf) && availableSideBalance.Compare(quoteQuantity) <= 0 { + quoteQuantity = availableSideBalance + quantity = quoteQuantity.Div(depthPrice).Round(s.makerMarket.PricePrecision, fixedpoint.Down) + } + + if quantity.Compare(s.makerMarket.MinQuantity) <= 0 || quoteQuantity.Compare(s.makerMarket.MinNotional) <= 0 { + break layerLoop + } + + availableSideBalance = availableSideBalance.Sub(quoteQuantity) + + accumulatedBidQuoteQuantity = accumulatedBidQuoteQuantity.Add(quoteQuantity) + + case types.SideTypeSell: + quantity = quantity.Sub(accumulatedAskQuantity) + quoteQuantity := quantity.Mul(depthPrice) + + // balance check + if !availableSideBalance.Eq(fixedpoint.PosInf) && availableSideBalance.Compare(quantity) <= 0 { + break layerLoop + } + + if quantity.Compare(s.makerMarket.MinQuantity) <= 0 || quoteQuantity.Compare(s.makerMarket.MinNotional) <= 0 { + break layerLoop + } + + availableSideBalance = availableSideBalance.Sub(quantity) + + accumulatedAskQuantity = accumulatedAskQuantity.Add(quantity) + } + + submitOrders = append(submitOrders, types.SubmitOrder{ + Symbol: s.Symbol, + Type: types.OrderTypeLimitMaker, + Market: s.makerMarket, + Side: side, + Price: depthPrice, + Quantity: quantity, + }) + } + } + + return submitOrders, nil +} + +func (s *Strategy) partiallyCancelOrders(ctx context.Context, maxLayer int) error { + buyOrders, sellOrders := s.MakerOrderExecutor.ActiveMakerOrders().Orders().SeparateBySide() + buyOrders = types.SortOrdersByPrice(buyOrders, true) + sellOrders = types.SortOrdersByPrice(sellOrders, false) + + buyOrdersToCancel := buyOrders[0:min(maxLayer, len(buyOrders))] + sellOrdersToCancel := sellOrders[0:min(maxLayer, len(sellOrders))] + + err1 := s.MakerOrderExecutor.GracefulCancel(ctx, buyOrdersToCancel...) + err2 := s.MakerOrderExecutor.GracefulCancel(ctx, sellOrdersToCancel...) + return stderrors.Join(err1, err2) +} + +func (s *Strategy) updateQuote(ctx context.Context, maxLayer int) { + if maxLayer == 0 { + if err := s.MakerOrderExecutor.GracefulCancel(ctx); err != nil { + log.WithError(err).Warnf("there are some %s orders not canceled, skipping placing maker orders", s.Symbol) + s.MakerOrderExecutor.ActiveMakerOrders().Print() + return + } + } else { + if err := s.partiallyCancelOrders(ctx, maxLayer); err != nil { + log.WithError(err).Warnf("%s partial order cancel failed", s.Symbol) + return + } + } + + numOfMakerOrders := s.MakerOrderExecutor.ActiveMakerOrders().NumOfOrders() + if numOfMakerOrders > 0 { + log.Warnf("maker orders are not all canceled") + return + } + + bestBid, bestAsk, hasPrice := s.pricingBook.BestBidAndAsk() + if !hasPrice { + return + } + + bestBidPrice := bestBid.Price + bestAskPrice := bestAsk.Price + log.Infof("%s book ticker: best ask / best bid = %v / %v", s.Symbol, bestAskPrice, bestBidPrice) + + s.lastPrice = bestBidPrice.Add(bestAskPrice).Div(Two) + + bookLastUpdateTime := s.pricingBook.LastUpdateTime() + + if _, err := s.bidPriceHeartBeat.Update(bestBid); err != nil { + log.WithError(err).Warnf("quote update error, %s price not updating, order book last update: %s ago", + s.Symbol, + time.Since(bookLastUpdateTime)) + } + + if _, err := s.askPriceHeartBeat.Update(bestAsk); err != nil { + log.WithError(err).Warnf("quote update error, %s price not updating, order book last update: %s ago", + s.Symbol, + time.Since(bookLastUpdateTime)) + } + + balances, err := s.MakerOrderExecutor.Session().Exchange.QueryAccountBalances(ctx) + if err != nil { + log.WithError(err).Errorf("balance query error") + return + } + + log.Infof("balances: %+v", balances.NotZero()) + + quoteBalance, ok := balances[s.makerMarket.QuoteCurrency] + if !ok { + return + } + + baseBalance, ok := balances[s.makerMarket.BaseCurrency] + if !ok { + return + } + + log.Infof("quote balance: %s, base balance: %s", quoteBalance, baseBalance) + + submitOrders, err := s.generateMakerOrders(s.pricingBook, maxLayer, baseBalance.Available, quoteBalance.Available) + if err != nil { + log.WithError(err).Errorf("generate order error") + return + } + + if len(submitOrders) == 0 { + log.Warnf("no orders are generated") + return + } + + _, err = s.MakerOrderExecutor.SubmitOrders(ctx, submitOrders...) + if err != nil { + log.WithError(err).Errorf("order error: %s", err.Error()) + return + } +} + +func (s *Strategy) cleanUpOpenOrders(ctx context.Context, session *qbtrade.ExchangeSession) error { + openOrders, err := retry.QueryOpenOrdersUntilSuccessful(ctx, session.Exchange, s.Symbol) + if err != nil { + return err + } + + if len(openOrders) == 0 { + return nil + } + + log.Infof("found existing open orders:") + types.OrderSlice(openOrders).Print() + + if err := session.Exchange.CancelOrders(ctx, openOrders...); err != nil { + return err + } + + return nil +} + +func selectSessions2( + sessions map[string]*qbtrade.ExchangeSession, n1, n2 string, +) (s1, s2 *qbtrade.ExchangeSession, err error) { + for _, n := range []string{n1, n2} { + if _, ok := sessions[n]; !ok { + return nil, nil, fmt.Errorf("session %s is not defined", n) + } + } + + s1 = sessions[n1] + s2 = sessions[n2] + return s1, s2, nil +} + +func min(a, b int) int { + if a < b { + return a + } + + return b +} + +func bindAuthSignal(ctx context.Context, stream types.Stream, c chan<- struct{}) { + stream.OnAuth(func() { + select { + case <-ctx.Done(): + return + case c <- struct{}{}: + default: + } + }) +} diff --git a/pkg/strategy/xdepthmaker/strategy_test.go b/pkg/strategy/xdepthmaker/strategy_test.go new file mode 100644 index 0000000..433606a --- /dev/null +++ b/pkg/strategy/xdepthmaker/strategy_test.go @@ -0,0 +1,75 @@ +//go:build !dnum + +package xdepthmaker + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + . "git.qtrade.icu/lychiyu/qbtrade/pkg/testing/testhelper" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +func newTestBTCUSDTMarket() types.Market { + return types.Market{ + BaseCurrency: "BTC", + QuoteCurrency: "USDT", + TickSize: Number(0.01), + StepSize: Number(0.000001), + PricePrecision: 2, + VolumePrecision: 8, + MinNotional: Number(8.0), + MinQuantity: Number(0.0003), + } +} + +func TestStrategy_generateMakerOrders(t *testing.T) { + s := &Strategy{ + Symbol: "BTCUSDT", + NumLayers: 3, + DepthScale: &qbtrade.LayerScale{ + LayerRule: &qbtrade.SlideRule{ + LinearScale: &qbtrade.LinearScale{ + Domain: [2]float64{1.0, 3.0}, + Range: [2]float64{1000.0, 15000.0}, + }, + }, + }, + CrossExchangeMarketMakingStrategy: &CrossExchangeMarketMakingStrategy{ + makerMarket: newTestBTCUSDTMarket(), + }, + } + + pricingBook := types.NewStreamBook("BTCUSDT") + pricingBook.Load(types.SliceOrderBook{ + Symbol: "BTCUSDT", + Bids: types.PriceVolumeSlice{ + {Price: Number("25000.00"), Volume: Number("0.1")}, + {Price: Number("24900.00"), Volume: Number("0.2")}, + {Price: Number("24800.00"), Volume: Number("0.3")}, + {Price: Number("24700.00"), Volume: Number("0.4")}, + }, + Asks: types.PriceVolumeSlice{ + {Price: Number("25100.00"), Volume: Number("0.1")}, + {Price: Number("25200.00"), Volume: Number("0.2")}, + {Price: Number("25300.00"), Volume: Number("0.3")}, + {Price: Number("25400.00"), Volume: Number("0.4")}, + }, + Time: time.Now(), + }) + + orders, err := s.generateMakerOrders(pricingBook, 0, fixedpoint.PosInf, fixedpoint.PosInf) + assert.NoError(t, err) + AssertOrdersPriceSideQuantity(t, []PriceSideQuantityAssert{ + {Side: types.SideTypeBuy, Price: Number("25000"), Quantity: Number("0.04")}, // =~ $1000.00 + {Side: types.SideTypeBuy, Price: Number("24866.66"), Quantity: Number("0.281715")}, // =~ $7005.3111219, accumulated amount =~ $1000.00 + $7005.3111219 = $8005.3111219 + {Side: types.SideTypeBuy, Price: Number("24800"), Quantity: Number("0.283123")}, // =~ $7021.4504, accumulated amount =~ $1000.00 + $7005.3111219 + $7021.4504 = $8005.3111219 + $7021.4504 =~ $15026.7615219 + {Side: types.SideTypeSell, Price: Number("25100"), Quantity: Number("0.03984")}, + {Side: types.SideTypeSell, Price: Number("25233.33"), Quantity: Number("0.2772")}, + {Side: types.SideTypeSell, Price: Number("25233.33"), Quantity: Number("0.277411")}, + }, orders) +} diff --git a/pkg/strategy/xfixedmaker/strategy.go b/pkg/strategy/xfixedmaker/strategy.go new file mode 100644 index 0000000..4308c2f --- /dev/null +++ b/pkg/strategy/xfixedmaker/strategy.go @@ -0,0 +1,275 @@ +package xfixedmaker + +import ( + "context" + "fmt" + "sync" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/risk/riskcontrol" + "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/common" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +const ID = "xfixedmaker" + +var log = logrus.WithField("strategy", ID) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +// Fixed spread market making strategy +type Strategy struct { + *common.Strategy + + Environment *qbtrade.Environment + + TradingExchange string `json:"tradingExchange"` + Symbol string `json:"symbol"` + Interval types.Interval `json:"interval"` + Quantity fixedpoint.Value `json:"quantity"` + HalfSpread fixedpoint.Value `json:"halfSpread"` + OrderType types.OrderType `json:"orderType"` + DryRun bool `json:"dryRun"` + + ReferenceExchange string `json:"referenceExchange"` + ReferencePriceEMA types.IntervalWindow `json:"referencePriceEMA"` + OrderPriceLossThreshold fixedpoint.Value `json:"orderPriceLossThreshold"` + InventorySkew common.InventorySkew `json:"inventorySkew"` + + market types.Market + activeOrderBook *qbtrade.ActiveOrderBook + orderPriceRiskControl *riskcontrol.OrderPriceRiskControl +} + +func (s *Strategy) Defaults() error { + if s.OrderType == "" { + log.Infof("order type is not set, using limit maker order type") + s.OrderType = types.OrderTypeLimitMaker + } + return nil +} + +func (s *Strategy) Initialize() error { + if s.Strategy == nil { + s.Strategy = &common.Strategy{} + } + return nil +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s", ID, s.Symbol) +} + +func (s *Strategy) Validate() error { + if s.Quantity.Float64() <= 0 { + return fmt.Errorf("quantity should be positive") + } + + if s.HalfSpread.Float64() <= 0 { + return fmt.Errorf("halfSpread should be positive") + } + + if err := s.InventorySkew.Validate(); err != nil { + return err + } + return nil +} + +func (s *Strategy) CrossSubscribe(sessions map[string]*qbtrade.ExchangeSession) { + tradingSession, ok := sessions[s.TradingExchange] + if !ok { + log.Errorf("trading session %s is not defined", s.TradingExchange) + return + } + tradingSession.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) + if !s.CircuitBreakLossThreshold.IsZero() { + tradingSession.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.CircuitBreakEMA.Interval}) + } + + referenceSession, ok := sessions[s.ReferenceExchange] + if !ok { + log.Errorf("reference session %s is not defined", s.ReferenceExchange) + } + referenceSession.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.ReferencePriceEMA.Interval}) +} + +func (s *Strategy) CrossRun(ctx context.Context, _ qbtrade.OrderExecutionRouter, sessions map[string]*qbtrade.ExchangeSession) error { + tradingSession, ok := sessions[s.TradingExchange] + if !ok { + return fmt.Errorf("trading session %s is not defined", s.TradingExchange) + } + + referenceSession, ok := sessions[s.ReferenceExchange] + if !ok { + return fmt.Errorf("reference session %s is not defined", s.ReferenceExchange) + } + + market, ok := tradingSession.Market(s.Symbol) + if !ok { + return fmt.Errorf("market %s not found", s.Symbol) + } + s.market = market + + s.Strategy.Initialize(ctx, s.Environment, tradingSession, s.market, ID, s.InstanceID()) + + s.orderPriceRiskControl = riskcontrol.NewOrderPriceRiskControl( + referenceSession.Indicators(s.Symbol).EMA(s.ReferencePriceEMA), + s.OrderPriceLossThreshold, + ) + + s.activeOrderBook = qbtrade.NewActiveOrderBook(s.Symbol) + s.activeOrderBook.BindStream(tradingSession.UserDataStream) + s.activeOrderBook.OnFilled(func(order types.Order) { + if s.IsHalted(order.UpdateTime.Time()) { + log.Infof("circuit break halted") + return + } + + if s.activeOrderBook.NumOfOrders() == 0 { + log.Infof("no active orders, placing orders...") + s.placeOrders(ctx) + } + }) + + tradingSession.MarketDataStream.OnKLineClosed(func(kline types.KLine) { + log.Infof("kline: %s", kline.String()) + + if s.IsHalted(kline.EndTime.Time()) { + log.Infof("circuit break halted") + return + } + + if kline.Interval == s.Interval { + s.cancelOrders(ctx) + s.placeOrders(ctx) + } + }) + + // the shutdown handler, you can cancel all orders + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + _ = s.OrderExecutor.GracefulCancel(ctx) + qbtrade.Sync(ctx, s) + }) + return nil +} + +func (s *Strategy) cancelOrders(ctx context.Context) { + if err := s.activeOrderBook.GracefulCancel(ctx, s.Session.Exchange); err != nil { + log.WithError(err).Errorf("failed to cancel orders") + } +} + +func (s *Strategy) placeOrders(ctx context.Context) { + submitOrders, err := s.generateOrders(ctx) + if err != nil { + log.WithError(err).Error("failed to generate orders") + return + } + log.Infof("submit orders: %+v", submitOrders) + + if s.DryRun { + log.Infof("dry run, not submitting orders") + return + } + + createdOrders, err := s.OrderExecutor.SubmitOrders(ctx, submitOrders...) + if err != nil { + log.WithError(err).Error("failed to submit orders") + return + } + log.Infof("created orders: %+v", createdOrders) + + s.activeOrderBook.Add(createdOrders...) +} + +func (s *Strategy) generateOrders(ctx context.Context) ([]types.SubmitOrder, error) { + orders := []types.SubmitOrder{} + + baseBalance, ok := s.Session.GetAccount().Balance(s.market.BaseCurrency) + if !ok { + return nil, fmt.Errorf("base currency %s balance not found", s.market.BaseCurrency) + } + log.Infof("base balance: %s", baseBalance.String()) + + quoteBalance, ok := s.Session.GetAccount().Balance(s.market.QuoteCurrency) + if !ok { + return nil, fmt.Errorf("quote currency %s balance not found", s.market.QuoteCurrency) + } + log.Infof("quote balance: %s", quoteBalance.String()) + + ticker, err := s.Session.Exchange.QueryTicker(ctx, s.Symbol) + if err != nil { + return nil, err + } + midPrice := ticker.Buy.Add(ticker.Sell).Div(fixedpoint.NewFromFloat(2.0)) + log.Infof("mid price: %s", midPrice.String()) + + // calculate bid and ask price + // sell price = mid price * (1 + r)) + // buy price = mid price * (1 - r)) + sellPrice := midPrice.Mul(fixedpoint.One.Add(s.HalfSpread)).Round(s.market.PricePrecision, fixedpoint.Up) + buyPrice := midPrice.Mul(fixedpoint.One.Sub(s.HalfSpread)).Round(s.market.PricePrecision, fixedpoint.Down) + log.Infof("sell price: %s, buy price: %s", sellPrice.String(), buyPrice.String()) + + buyQuantity := s.Quantity + sellQuantity := s.Quantity + if !s.InventorySkew.InventoryRangeMultiplier.IsZero() { + ratios := s.InventorySkew.CalculateBidAskRatios( + s.Quantity, + midPrice, + baseBalance.Total(), + quoteBalance.Total(), + ) + log.Infof("bid ratio: %s, ask ratio: %s", ratios.BidRatio.String(), ratios.AskRatio.String()) + buyQuantity = s.Quantity.Mul(ratios.BidRatio) + sellQuantity = s.Quantity.Mul(ratios.AskRatio) + log.Infof("buy quantity: %s, sell quantity: %s", buyQuantity.String(), sellQuantity.String()) + } + + // check balance and generate orders + amount := s.Quantity.Mul(buyPrice) + if quoteBalance.Available.Compare(amount) > 0 { + if s.orderPriceRiskControl.IsSafe(types.SideTypeBuy, buyPrice, s.Quantity) { + orders = append(orders, types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeBuy, + Type: s.OrderType, + Price: buyPrice, + Quantity: buyQuantity, + }) + + } else { + log.Infof("ref price risk control triggered, not placing buy order") + } + } else { + log.Infof("not enough quote balance to buy, available: %s, amount: %s", quoteBalance.Available, amount) + } + + if baseBalance.Available.Compare(s.Quantity) > 0 { + if s.orderPriceRiskControl.IsSafe(types.SideTypeSell, sellPrice, s.Quantity) { + orders = append(orders, types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeSell, + Type: s.OrderType, + Price: sellPrice, + Quantity: sellQuantity, + }) + } else { + log.Infof("ref price risk control triggered, not placing sell order") + } + } else { + log.Infof("not enough base balance to sell, available: %s, quantity: %s", baseBalance.Available, s.Quantity) + } + + return orders, nil +} diff --git a/pkg/strategy/xfunding/fundingfee.go b/pkg/strategy/xfunding/fundingfee.go new file mode 100644 index 0000000..c600f9c --- /dev/null +++ b/pkg/strategy/xfunding/fundingfee.go @@ -0,0 +1,29 @@ +package xfunding + +import ( + "fmt" + "time" + + "github.com/slack-go/slack" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/style" +) + +type FundingFee struct { + Asset string `json:"asset"` + Amount fixedpoint.Value `json:"amount"` + Txn int64 `json:"txn"` + Time time.Time `json:"time"` +} + +func (f *FundingFee) SlackAttachment() slack.Attachment { + return slack.Attachment{ + Title: "Funding Fee " + fmt.Sprintf("%s %s", style.PnLSignString(f.Amount), f.Asset), + Color: style.PnLColor(f.Amount), + // Pretext: "", + // Text: text, + Fields: []slack.AttachmentField{}, + Footer: fmt.Sprintf("Transation ID: %d Transaction Time %s", f.Txn, f.Time.Format(time.RFC822)), + } +} diff --git a/pkg/strategy/xfunding/positionstate_string.go b/pkg/strategy/xfunding/positionstate_string.go new file mode 100644 index 0000000..67eb948 --- /dev/null +++ b/pkg/strategy/xfunding/positionstate_string.go @@ -0,0 +1,26 @@ +// Code generated by "stringer -type=PositionState"; DO NOT EDIT. + +package xfunding + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[PositionClosed-0] + _ = x[PositionOpening-1] + _ = x[PositionReady-2] + _ = x[PositionClosing-3] +} + +const _PositionState_name = "PositionClosedPositionOpeningPositionReadyPositionClosing" + +var _PositionState_index = [...]uint8{0, 14, 29, 42, 57} + +func (i PositionState) String() string { + if i < 0 || i >= PositionState(len(_PositionState_index)-1) { + return "PositionState(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _PositionState_name[_PositionState_index[i]:_PositionState_index[i+1]] +} diff --git a/pkg/strategy/xfunding/profitstats.go b/pkg/strategy/xfunding/profitstats.go new file mode 100644 index 0000000..a9ad94c --- /dev/null +++ b/pkg/strategy/xfunding/profitstats.go @@ -0,0 +1,54 @@ +package xfunding + +import ( + "fmt" + "time" + + "github.com/slack-go/slack" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/style" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type ProfitStats struct { + *types.ProfitStats + + FundingFeeCurrency string `json:"fundingFeeCurrency"` + TotalFundingFee fixedpoint.Value `json:"totalFundingFee"` + FundingFeeRecords []FundingFee `json:"fundingFeeRecords"` + LastFundingFeeTxn int64 `json:"lastFundingFeeTxn"` + LastFundingFeeTime time.Time `json:"lastFundingFeeTime"` +} + +func (s *ProfitStats) SlackAttachment() slack.Attachment { + var fields []slack.AttachmentField + var totalProfit = fmt.Sprintf("Total Funding Fee Profit: %s %s", style.PnLSignString(s.TotalFundingFee), s.FundingFeeCurrency) + + return slack.Attachment{ + Title: totalProfit, + Color: style.PnLColor(s.TotalFundingFee), + // Pretext: "", + // Text: text, + Fields: fields, + Footer: fmt.Sprintf("Last Funding Fee Transation ID: %d Last Funding Fee Time %s", s.LastFundingFeeTxn, s.LastFundingFeeTime.Format(time.RFC822)), + } +} + +func (s *ProfitStats) AddFundingFee(fee FundingFee) error { + if s.FundingFeeCurrency == "" { + s.FundingFeeCurrency = fee.Asset + } else if s.FundingFeeCurrency != fee.Asset { + return fmt.Errorf("unexpected error, funding fee currency is not matched, given: %s, wanted: %s", fee.Asset, s.FundingFeeCurrency) + } + + if s.LastFundingFeeTxn == fee.Txn { + return errDuplicatedFundingFeeTxnId + } + + s.FundingFeeRecords = append(s.FundingFeeRecords, fee) + s.TotalFundingFee = s.TotalFundingFee.Add(fee.Amount) + s.LastFundingFeeTxn = fee.Txn + s.LastFundingFeeTime = fee.Time + return nil +} diff --git a/pkg/strategy/xfunding/strategy.go b/pkg/strategy/xfunding/strategy.go new file mode 100644 index 0000000..7347300 --- /dev/null +++ b/pkg/strategy/xfunding/strategy.go @@ -0,0 +1,1235 @@ +package xfunding + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/batch" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/binance" + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/binance/binanceapi" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/common" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util/backoff" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// WIP: +// - track fee token price for cost +// - buy enough BNB before creating positions +// - transfer the rest BNB into the futures account +// - add slack notification support +// - use neutral position to calculate the position cost +// - customize profit stats for this funding fee strategy + +const ID = "xfunding" + +// Position State Transitions: +// NoOp -> Opening +// Opening -> Ready -> Closing +// Closing -> Closed -> Opening +// +//go:generate stringer -type=PositionState +type PositionState int + +const ( + PositionClosed PositionState = iota + PositionOpening + PositionReady + PositionClosing +) + +type MovingAverageConfig struct { + Interval types.Interval `json:"interval"` + // MovingAverageType is the moving average indicator type that we want to use, + // it could be SMA or EWMA + MovingAverageType string `json:"movingAverageType"` + + // MovingAverageInterval is the interval of k-lines for the moving average indicator to calculate, + // it could be "1m", "5m", "1h" and so on. note that, the moving averages are calculated from + // the k-line data we subscribed + // MovingAverageInterval types.Interval `json:"movingAverageInterval"` + // + // // MovingAverageWindow is the number of the window size of the moving average indicator. + // // The number of k-lines in the window. generally used window sizes are 7, 25 and 99 in the TradingView. + // MovingAverageWindow int `json:"movingAverageWindow"` + MovingAverageIntervalWindow types.IntervalWindow `json:"movingAverageIntervalWindow"` +} + +var log = logrus.WithField("strategy", ID) + +var errNotBinanceExchange = errors.New("not binance exchange, currently only support binance exchange") + +var errDuplicatedFundingFeeTxnId = errors.New("duplicated funding fee txn id") + +func init() { + // Register the pointer of the strategy struct, + // so that qbtrade knows what struct to be used to unmarshal the configs (YAML or JSON) + // Note: built-in strategies need to imported manually in the qbtrade cmd package. + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type State struct { + PositionStartTime time.Time `json:"positionStartTime"` + + // PositionState is default to NoOp + PositionState PositionState + + PendingBaseTransfer fixedpoint.Value `json:"pendingBaseTransfer"` + TotalBaseTransfer fixedpoint.Value `json:"totalBaseTransfer"` + UsedQuoteInvestment fixedpoint.Value `json:"usedQuoteInvestment"` +} + +func newState() *State { + return &State{ + PositionState: PositionClosed, + PendingBaseTransfer: fixedpoint.Zero, + TotalBaseTransfer: fixedpoint.Zero, + UsedQuoteInvestment: fixedpoint.Zero, + } +} + +func (s *State) Reset() { + s.PositionState = PositionClosed + s.PendingBaseTransfer = fixedpoint.Zero + s.TotalBaseTransfer = fixedpoint.Zero + s.UsedQuoteInvestment = fixedpoint.Zero +} + +// Strategy is the xfunding fee strategy +// Right now it only supports short position in the USDT futures account. +// When opening the short position, it uses spot account to buy inventory, then transfer the inventory to the futures account as collateral assets. +type Strategy struct { + Environment *qbtrade.Environment + + // These fields will be filled from the config file (it translates YAML to JSON) + Symbol string `json:"symbol"` + Interval types.Interval `json:"interval"` + + Market types.Market `json:"-"` + + // Leverage is the leverage of the futures position + Leverage fixedpoint.Value `json:"leverage,omitempty"` + + // IncrementalQuoteQuantity is used for opening position incrementally with a small fixed quote quantity + // for example, 100usdt per order + IncrementalQuoteQuantity fixedpoint.Value `json:"incrementalQuoteQuantity"` + + QuoteInvestment fixedpoint.Value `json:"quoteInvestment"` + + MinHoldingPeriod types.Duration `json:"minHoldingPeriod"` + + // ShortFundingRate is the funding rate range for short positions + // TODO: right now we don't support negative funding rate (long position) since it's rarer + ShortFundingRate *struct { + High fixedpoint.Value `json:"high"` + Low fixedpoint.Value `json:"low"` + } `json:"shortFundingRate"` + + SpotSession string `json:"spotSession"` + FuturesSession string `json:"futuresSession"` + + // Reset your position info + Reset bool `json:"reset"` + + ProfitFixerConfig *common.ProfitFixerConfig `json:"profitFixer"` + + // CloseFuturesPosition can be enabled to close the futures position and then transfer the collateral asset back to the spot account. + CloseFuturesPosition bool `json:"closeFuturesPosition"` + + ProfitStats *ProfitStats `persistence:"profit_stats"` + + // SpotPosition is used for the spot position (usually long position) + // so that we know how much spot we have bought and the average cost of the spot. + SpotPosition *types.Position `persistence:"spot_position"` + + // FuturesPosition is used for the futures position + // this position is the reverse side of the spot position, when spot position is long, then the futures position will be short. + // but the base quantity should be the same as the spot position + FuturesPosition *types.Position `persistence:"futures_position"` + + // NeutralPosition is used for sharing spot/futures position + // when creating the spot position and futures position, there will be a spread between the spot position and the futures position. + // this neutral position can calculate the spread cost between these two positions + NeutralPosition *types.Position `persistence:"neutral_position"` + + State *State `persistence:"state"` + + // mu is used for locking state + mu sync.Mutex + + spotSession, futuresSession *qbtrade.ExchangeSession + spotOrderExecutor, futuresOrderExecutor *qbtrade.GeneralOrderExecutor + spotMarket, futuresMarket types.Market + + binanceFutures, binanceSpot *binance.Exchange + + // positionType is the futures position type + // currently we only support short position for the positive funding rate + positionType types.PositionType + + minQuantity fixedpoint.Value +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) CrossSubscribe(sessions map[string]*qbtrade.ExchangeSession) { + // TODO: add safety check + spotSession := sessions[s.SpotSession] + futuresSession := sessions[s.FuturesSession] + + spotSession.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) + futuresSession.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) +} + +func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) {} + +func (s *Strategy) Defaults() error { + if s.Leverage.IsZero() { + s.Leverage = fixedpoint.One + } + + if s.MinHoldingPeriod == 0 { + s.MinHoldingPeriod = types.Duration(3 * 24 * time.Hour) + } + + if s.Interval == "" { + s.Interval = types.Interval1m + } + + s.positionType = types.PositionShort + + return nil +} + +func (s *Strategy) Validate() error { + if len(s.Symbol) == 0 { + return errors.New("symbol is required") + } + + if len(s.SpotSession) == 0 { + return errors.New("spotSession name is required") + } + + if len(s.FuturesSession) == 0 { + return errors.New("futuresSession name is required") + } + + if s.QuoteInvestment.IsZero() { + return errors.New("quoteInvestment can not be zero") + } + + return nil +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s-%s", ID, s.Symbol) +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error { + // standardIndicatorSet := session.StandardIndicatorSet(s.Symbol) + /* + var ma types.Float64Indicator + for _, detection := range s.SupportDetection { + switch strings.ToLower(detection.MovingAverageType) { + case "sma": + ma = standardIndicatorSet.SMA(types.IntervalWindow{ + Interval: detection.MovingAverageIntervalWindow.Interval, + Window: detection.MovingAverageIntervalWindow.Window, + }) + case "ema", "ewma": + ma = standardIndicatorSet.EWMA(types.IntervalWindow{ + Interval: detection.MovingAverageIntervalWindow.Interval, + Window: detection.MovingAverageIntervalWindow.Window, + }) + default: + ma = standardIndicatorSet.EWMA(types.IntervalWindow{ + Interval: detection.MovingAverageIntervalWindow.Interval, + Window: detection.MovingAverageIntervalWindow.Window, + }) + } + } + */ + return nil +} + +func (s *Strategy) CrossRun( + ctx context.Context, orderExecutionRouter qbtrade.OrderExecutionRouter, sessions map[string]*qbtrade.ExchangeSession, +) error { + instanceID := s.InstanceID() + + s.spotSession = sessions[s.SpotSession] + s.futuresSession = sessions[s.FuturesSession] + + s.spotMarket, _ = s.spotSession.Market(s.Symbol) + s.futuresMarket, _ = s.futuresSession.Market(s.Symbol) + + var ok bool + s.binanceFutures, ok = s.futuresSession.Exchange.(*binance.Exchange) + if !ok { + return errNotBinanceExchange + } + + s.binanceSpot, ok = s.spotSession.Exchange.(*binance.Exchange) + if !ok { + return errNotBinanceExchange + } + + if err := s.checkAndFixMarginMode(ctx); err != nil { + return err + } + + if err := s.setInitialLeverage(ctx); err != nil { + return err + } + + if s.ProfitStats == nil || s.Reset { + s.ProfitStats = &ProfitStats{ + ProfitStats: types.NewProfitStats(s.Market), + // when receiving funding fee, the funding fee asset is the quote currency of that market. + FundingFeeCurrency: s.futuresMarket.QuoteCurrency, + TotalFundingFee: fixedpoint.Zero, + FundingFeeRecords: nil, + LastFundingFeeTime: time.Time{}, + } + } + + // common min quantity + s.minQuantity = fixedpoint.Max(s.futuresMarket.MinQuantity, s.spotMarket.MinQuantity) + + if s.SpotPosition == nil || s.Reset { + s.SpotPosition = types.NewPositionFromMarket(s.spotMarket) + } + + if s.FuturesPosition == nil || s.Reset { + s.FuturesPosition = types.NewPositionFromMarket(s.futuresMarket) + } + + if s.NeutralPosition == nil || s.Reset { + s.NeutralPosition = types.NewPositionFromMarket(s.futuresMarket) + } + + if s.State == nil || s.Reset { + s.State = newState() + } + + if s.ProfitFixerConfig != nil { + log.Infof("profitFixer is enabled, start fixing with config: %+v", s.ProfitFixerConfig) + + s.SpotPosition = types.NewPositionFromMarket(s.spotMarket) + s.FuturesPosition = types.NewPositionFromMarket(s.futuresMarket) + s.ProfitStats.ProfitStats = types.NewProfitStats(s.Market) + + since := s.ProfitFixerConfig.TradesSince.Time() + now := time.Now() + + spotFixer := common.NewProfitFixer() + if ss, ok := s.spotSession.Exchange.(types.ExchangeTradeHistoryService); ok { + spotFixer.AddExchange(s.spotSession.Name, ss) + } + + if err2 := spotFixer.Fix(ctx, s.Symbol, + since, now, + s.ProfitStats.ProfitStats, + s.SpotPosition); err2 != nil { + return err2 + } + + futuresFixer := common.NewProfitFixer() + if ss, ok := s.futuresSession.Exchange.(types.ExchangeTradeHistoryService); ok { + futuresFixer.AddExchange(s.futuresSession.Name, ss) + } + + if err2 := futuresFixer.Fix(ctx, s.Symbol, + since, now, + s.ProfitStats.ProfitStats, + s.FuturesPosition); err2 != nil { + return err2 + } + + qbtrade.Notify("Fixed spot position", s.SpotPosition) + qbtrade.Notify("Fixed futures position", s.FuturesPosition) + qbtrade.Notify("Fixed profit stats", s.ProfitStats.ProfitStats) + } + + if err := s.syncPositionRisks(ctx); err != nil { + return err + } + + if s.CloseFuturesPosition && s.Reset { + return errors.New("reset and closeFuturesPosition can not be used together") + } + + log.Infof("state: %+v", s.State) + log.Infof("loaded spot position: %s", s.SpotPosition.String()) + log.Infof("loaded futures position: %s", s.FuturesPosition.String()) + log.Infof("loaded neutral position: %s", s.NeutralPosition.String()) + + qbtrade.Notify("Spot Position", s.SpotPosition) + qbtrade.Notify("Futures Position", s.FuturesPosition) + qbtrade.Notify("Neutral Position", s.NeutralPosition) + qbtrade.Notify("State: %s", s.State.PositionState.String()) + + // sync funding fee txns + s.syncFundingFeeRecords(ctx, s.ProfitStats.LastFundingFeeTime) + + // TEST CODE: + // s.syncFundingFeeRecords(ctx, time.Now().Add(-3*24*time.Hour)) + + switch s.State.PositionState { + case PositionClosed: + // adjust QuoteInvestment according to the available quote balance + // ONLY when the position is not opening + if b, ok := s.spotSession.Account.Balance(s.spotMarket.QuoteCurrency); ok { + originalQuoteInvestment := s.QuoteInvestment + + // adjust available quote with the fee rate + spotFeeRate := 0.075 + availableQuoteWithoutFee := b.Available.Mul(fixedpoint.NewFromFloat(1.0 - (spotFeeRate * 0.01))) + + s.QuoteInvestment = fixedpoint.Min(availableQuoteWithoutFee, s.QuoteInvestment) + + if originalQuoteInvestment.Compare(s.QuoteInvestment) != 0 { + log.Infof("adjusted quoteInvestment from %f to %f according to the balance", + originalQuoteInvestment.Float64(), + s.QuoteInvestment.Float64(), + ) + } + } + default: + } + + switch s.State.PositionState { + case PositionReady: + + case PositionOpening: + // transfer all base assets from the spot account into the spot account + if err := s.transferIn(ctx, s.binanceSpot, s.spotMarket.BaseCurrency, fixedpoint.Zero); err != nil { + log.WithError(err).Errorf("futures asset transfer in error") + } + + case PositionClosing, PositionClosed: + // transfer all base assets from the futures account back to the spot account + if err := s.transferOut(ctx, s.binanceSpot, s.spotMarket.BaseCurrency, fixedpoint.Zero); err != nil { + log.WithError(err).Errorf("futures asset transfer out error") + } + + } + + s.spotOrderExecutor = s.allocateOrderExecutor(ctx, s.spotSession, instanceID, s.SpotPosition) + s.spotOrderExecutor.TradeCollector().OnTrade(func(trade types.Trade, profit fixedpoint.Value, netProfit fixedpoint.Value) { + // we act differently on the spot account + // when opening a position, we place orders on the spot account first, then the futures account, + // and we need to accumulate the used quote amount + // + // when closing a position, we place orders on the futures account first, then the spot account + // we need to close the position according to its base quantity instead of quote quantity + if s.positionType != types.PositionShort { + return + } + + switch s.State.PositionState { + case PositionOpening: + if trade.Side != types.SideTypeBuy { + log.Errorf("unexpected trade side: %+v, expecting BUY trade", trade) + return + } + + s.mu.Lock() + s.State.UsedQuoteInvestment = s.State.UsedQuoteInvestment.Add(trade.QuoteQuantity) + s.mu.Unlock() + + // if we have trade, try to query the balance and transfer the balance to the futures wallet account + // TODO: handle missing trades here. If the process crashed during the transfer, how to recover? + if err := backoff.RetryGeneral(ctx, func() error { + return s.transferIn(ctx, s.binanceSpot, s.spotMarket.BaseCurrency, trade.Quantity) + }); err != nil { + log.WithError(err).Errorf("spot-to-futures transfer in retry failed") + return + } + + case PositionClosing: + if trade.Side != types.SideTypeSell { + log.Errorf("unexpected trade side: %+v, expecting SELL trade", trade) + return + } + + } + }) + + s.futuresOrderExecutor = s.allocateOrderExecutor(ctx, s.futuresSession, instanceID, s.FuturesPosition) + s.futuresOrderExecutor.TradeCollector().OnTrade(func(trade types.Trade, profit fixedpoint.Value, netProfit fixedpoint.Value) { + if s.positionType != types.PositionShort { + return + } + + switch s.getPositionState() { + case PositionClosing: + // de-leverage and get the collateral base quantity for transfer + quantity := trade.Quantity.Div(s.Leverage) + + if err := backoff.RetryGeneral(ctx, func() error { + return s.transferOut(ctx, s.binanceSpot, s.spotMarket.BaseCurrency, quantity) + }); err != nil { + log.WithError(err).Errorf("spot-to-futures transfer in retry failed") + return + } + + } + }) + + s.futuresSession.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.Interval, func(kline types.KLine) { + s.queryAndDetectPremiumIndex(ctx, s.binanceFutures) + })) + + s.futuresSession.UserDataStream.OnStart(func() { + if s.CloseFuturesPosition { + + openOrders, err := s.futuresSession.Exchange.QueryOpenOrders(ctx, s.Symbol) + if err != nil { + log.WithError(err).Errorf("query open orders error") + } else { + // canceling open orders + if err = s.futuresSession.Exchange.CancelOrders(ctx, openOrders...); err != nil { + log.WithError(err).Errorf("query open orders error") + } + } + + if err := s.futuresOrderExecutor.ClosePosition(ctx, fixedpoint.One); err != nil { + log.WithError(err).Errorf("close position error") + } + + if err := s.resetTransfer(ctx, s.binanceSpot, s.spotMarket.BaseCurrency); err != nil { + log.WithError(err).Errorf("transfer error") + } + + if err := s.resetTransfer(ctx, s.binanceSpot, s.spotMarket.QuoteCurrency); err != nil { + log.WithError(err).Errorf("transfer error") + } + } + + }) + + if binanceStream, ok := s.futuresSession.UserDataStream.(*binance.Stream); ok { + binanceStream.OnAccountUpdateEvent(func(e *binance.AccountUpdateEvent) { + s.handleAccountUpdate(ctx, e) + }) + } + + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + + case <-ticker.C: + s.syncSpotAccount(ctx) + } + } + }() + + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + + case <-ticker.C: + s.syncFuturesAccount(ctx) + } + } + }() + + return nil +} + +func (s *Strategy) handleAccountUpdate(ctx context.Context, e *binance.AccountUpdateEvent) { + switch e.AccountUpdate.EventReasonType { + case binance.AccountUpdateEventReasonDeposit: + case binance.AccountUpdateEventReasonWithdraw: + case binance.AccountUpdateEventReasonFundingFee: + // EventBase:{ + // Event:ACCOUNT_UPDATE + // Time:1679760000932 + // } + // Transaction:1679760000927 + // AccountUpdate:{ + // EventReasonType:FUNDING_FEE + // Balances:[{ + // Asset:USDT + // WalletBalance:56.64251742 + // CrossWalletBalance:56.64251742 + // BalanceChange:-0.00037648 + // }] + // } + // } + for _, b := range e.AccountUpdate.Balances { + if b.Asset != s.ProfitStats.FundingFeeCurrency { + continue + } + txnTime := e.EventBase.Time.Time() + fee := FundingFee{ + Asset: b.Asset, + Amount: b.BalanceChange, + Txn: e.Transaction, + Time: txnTime, + } + err := s.ProfitStats.AddFundingFee(fee) + if err != nil { + log.WithError(err).Error("unable to add funding fee to profitStats") + continue + } + + qbtrade.Notify(&fee) + } + + log.Infof("total collected funding fee: %f %s", s.ProfitStats.TotalFundingFee.Float64(), s.ProfitStats.FundingFeeCurrency) + qbtrade.Sync(ctx, s) + + qbtrade.Notify(s.ProfitStats) + } +} + +func (s *Strategy) syncFundingFeeRecords(ctx context.Context, since time.Time) { + now := time.Now() + + if since.IsZero() { + since = now.AddDate(0, -3, 0) + } + + log.Infof("syncing funding fee records from the income history query: %s <=> %s", since, now) + + defer log.Infof("sync funding fee records done") + + q := batch.BinanceFuturesIncomeBatchQuery{ + BinanceFuturesIncomeHistoryService: s.binanceFutures, + } + + dataC, errC := q.Query(ctx, s.Symbol, binanceapi.FuturesIncomeFundingFee, since, now) + for { + select { + case <-ctx.Done(): + return + + case income, ok := <-dataC: + if !ok { + return + } + + log.Infof("income: %+v", income) + switch income.IncomeType { + case binanceapi.FuturesIncomeFundingFee: + err := s.ProfitStats.AddFundingFee(FundingFee{ + Asset: income.Asset, + Amount: income.Income, + Txn: income.TranId, + Time: income.Time.Time(), + }) + if err != nil { + log.WithError(err).Errorf("can not add funding fee record to ProfitStats") + } + } + + case err, ok := <-errC: + if !ok { + return + } + + log.WithError(err).Errorf("unable to query futures income history") + return + + } + } +} + +func (s *Strategy) queryAndDetectPremiumIndex(ctx context.Context, binanceFutures *binance.Exchange) { + premiumIndex, err := binanceFutures.QueryPremiumIndex(ctx, s.Symbol) + if err != nil { + log.WithError(err).Error("premium index query error") + return + } + + log.Info(premiumIndex) + + if changed := s.detectPremiumIndex(premiumIndex); changed { + log.Infof("position state changed to -> %s %s", s.positionType, s.State.PositionState.String()) + } +} + +func (s *Strategy) syncSpotAccount(ctx context.Context) { + switch s.getPositionState() { + case PositionOpening: + s.increaseSpotPosition(ctx) + case PositionClosing: + s.syncSpotPosition(ctx) + } +} + +func (s *Strategy) syncFuturesAccount(ctx context.Context) { + switch s.getPositionState() { + case PositionOpening: + s.syncFuturesPosition(ctx) + case PositionClosing: + s.reduceFuturesPosition(ctx) + } +} + +func (s *Strategy) reduceFuturesPosition(ctx context.Context) { + if s.notPositionState(PositionClosing) { + return + } + + futuresBase := s.FuturesPosition.GetBase() // should be negative base quantity here + + if futuresBase.Sign() > 0 { + // unexpected error + log.Errorf("unexpected futures position (got positive, expecting negative)") + return + } + + _ = s.futuresOrderExecutor.GracefulCancel(ctx) + + ticker, err := s.futuresSession.Exchange.QueryTicker(ctx, s.Symbol) + if err != nil { + log.WithError(err).Errorf("can not query ticker") + return + } + + spotBase := s.SpotPosition.GetBase() + if !s.spotMarket.IsDustQuantity(spotBase, s.SpotPosition.AverageCost) { + if balance, ok := s.futuresSession.Account.Balance(s.futuresMarket.BaseCurrency); ok && balance.Available.Sign() > 0 { + if err := backoff.RetryGeneral(ctx, func() error { + return s.transferOut(ctx, s.binanceSpot, s.spotMarket.BaseCurrency, balance.Available) + }); err != nil { + log.WithError(err).Errorf("spot-to-futures transfer in retry failed") + } + } + } + + if futuresBase.Compare(fixedpoint.Zero) < 0 { + orderPrice := ticker.Buy + orderQuantity := futuresBase.Abs() + orderQuantity = fixedpoint.Max(orderQuantity, s.minQuantity) + orderQuantity = s.futuresMarket.AdjustQuantityByMinNotional(orderQuantity, orderPrice) + + if s.futuresMarket.IsDustQuantity(orderQuantity, orderPrice) { + submitOrder := types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimitMaker, + Price: orderPrice, + Market: s.futuresMarket, + + // quantity: Cannot be sent with closePosition=true(Close-All) + // reduceOnly: Cannot be sent with closePosition=true + ClosePosition: true, + } + + if _, err := s.futuresOrderExecutor.SubmitOrders(ctx, submitOrder); err != nil { + log.WithError(err).Errorf("can not submit futures order with close position: %+v", submitOrder) + } + return + } + + submitOrder := types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimitMaker, + Quantity: orderQuantity, + Price: orderPrice, + Market: s.futuresMarket, + ReduceOnly: true, + } + createdOrders, err := s.futuresOrderExecutor.SubmitOrders(ctx, submitOrder) + + if err != nil { + log.WithError(err).Errorf("can not submit futures order: %+v", submitOrder) + return + } + + log.Infof("created orders: %+v", createdOrders) + } +} + +// syncFuturesPosition syncs the futures position with the given spot position +// when the spot is transferred successfully, sync futures position +// compare spot position and futures position, increase the position size until they are the same size +func (s *Strategy) syncFuturesPosition(ctx context.Context) { + if s.positionType != types.PositionShort { + return + } + + if s.notPositionState(PositionOpening) { + return + } + + spotBase := s.SpotPosition.GetBase() // should be positive base quantity here + futuresBase := s.FuturesPosition.GetBase() // should be negative base quantity here + + if spotBase.IsZero() || spotBase.Sign() < 0 { + // skip when spot base is zero + return + } + + log.Infof("syncFuturesPosition: position comparision: %s (spot) <=> %s (futures)", spotBase.String(), futuresBase.String()) + + if futuresBase.Sign() > 0 { + // unexpected error + log.Errorf("unexpected futures position, got positive number (long), expecting negative number (short)") + return + } + + // cancel the previous futures order + _ = s.futuresOrderExecutor.GracefulCancel(ctx) + + // get the latest ticker price + ticker, err := s.futuresSession.Exchange.QueryTicker(ctx, s.Symbol) + if err != nil { + log.WithError(err).Errorf("can not query ticker") + return + } + + // compare with the spot position and increase the position + quoteValue, err := qbtrade.CalculateQuoteQuantity(ctx, s.futuresSession, s.futuresMarket.QuoteCurrency, s.Leverage) + if err != nil { + log.WithError(err).Errorf("can not calculate futures account quote value") + return + } + + log.Infof("calculated futures account quote value = %s", quoteValue.String()) + if quoteValue.IsZero() { + return + } + + // max futures base position (without negative sign) + maxFuturesBasePosition := fixedpoint.Min( + spotBase.Mul(s.Leverage), + s.State.TotalBaseTransfer.Mul(s.Leverage)) + + if maxFuturesBasePosition.IsZero() { + return + } + + // if - futures position < max futures position, increase it + // posDiff := futuresBase.Abs().Sub(maxFuturesBasePosition) + if futuresBase.Abs().Compare(maxFuturesBasePosition) >= 0 { + s.setPositionState(PositionReady) + + qbtrade.Notify("Position Ready") + qbtrade.Notify("SpotPosition", s.SpotPosition) + qbtrade.Notify("FuturesPosition", s.FuturesPosition) + qbtrade.Notify("NeutralPosition", s.NeutralPosition) + + // DEBUG CODE - triggering closing position automatically + // s.startClosingPosition() + return + } + + orderPrice := ticker.Sell + diffQuantity := maxFuturesBasePosition.Sub(futuresBase.Neg()) + + if diffQuantity.Sign() < 0 { + log.Errorf("unexpected negative position diff: %s", diffQuantity.String()) + return + } + + log.Infof("position diff quantity: %s", diffQuantity.String()) + + orderQuantity := diffQuantity + orderQuantity = fixedpoint.Max(diffQuantity, s.minQuantity) + orderQuantity = s.futuresMarket.AdjustQuantityByMinNotional(orderQuantity, orderPrice) + + if s.futuresMarket.IsDustQuantity(orderQuantity, orderPrice) { + log.Warnf("unexpected dust quantity, skip futures order with dust quantity %s, market = %+v", orderQuantity.String(), s.futuresMarket) + return + } + + submitOrder := types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeLimitMaker, + Quantity: orderQuantity, + Price: orderPrice, + Market: s.futuresMarket, + } + createdOrders, err := s.futuresOrderExecutor.SubmitOrders(ctx, submitOrder) + + if err != nil { + log.WithError(err).Errorf("can not submit futures order: %+v", submitOrder) + return + } + + log.Infof("created orders: %+v", createdOrders) +} + +func (s *Strategy) syncSpotPosition(ctx context.Context) { + if s.positionType != types.PositionShort { + return + } + + if s.notPositionState(PositionClosing) { + return + } + + spotBase := s.SpotPosition.GetBase() // should be positive base quantity here + futuresBase := s.FuturesPosition.GetBase() // should be negative base quantity here + + if spotBase.IsZero() { + s.setPositionState(PositionClosed) + return + } + + // skip short spot position + if spotBase.Sign() < 0 { + return + } + + log.Infof("syncSpotPosition: spot/futures positions: %s (spot) <=> %s (futures)", spotBase.String(), futuresBase.String()) + + if futuresBase.Sign() > 0 { + // unexpected error + log.Errorf("unexpected futures position (got positive, expecting negative)") + return + } + + _ = s.spotOrderExecutor.GracefulCancel(ctx) + + ticker, err := s.spotSession.Exchange.QueryTicker(ctx, s.Symbol) + if err != nil { + log.WithError(err).Errorf("can not query ticker") + return + } + + if s.SpotPosition.IsDust(ticker.Sell) { + dust := s.SpotPosition.GetBase().Abs() + cost := s.SpotPosition.AverageCost + + log.Warnf("spot dust loss: %f %s (average cost = %f)", dust.Float64(), s.spotMarket.BaseCurrency, cost.Float64()) + + s.SpotPosition.Reset() + + s.setPositionState(PositionClosed) + return + } + + // spot pos size > futures pos size ==> reduce spot position + if spotBase.Compare(futuresBase.Neg()) > 0 { + diffQuantity := spotBase.Sub(futuresBase.Neg()) + + if diffQuantity.Sign() < 0 { + log.Errorf("unexpected negative position diff: %s", diffQuantity.String()) + return + } + + orderPrice := ticker.Sell + orderQuantity := diffQuantity + b, ok := s.spotSession.Account.Balance(s.spotMarket.BaseCurrency) + if !ok { + log.Warnf("%s balance not found, can not sync spot position", s.spotMarket.BaseCurrency) + return + } + + log.Infof("spot balance: %+v", b) + + orderQuantity = fixedpoint.Min(b.Available, orderQuantity) + + // avoid increase the order size + if s.spotMarket.IsDustQuantity(orderQuantity, orderPrice) { + log.Infof("skip spot order with dust quantity %s, market=%+v balance=%+v", orderQuantity.String(), s.spotMarket, b) + return + } + + submitOrder := types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeLimitMaker, + Quantity: orderQuantity, + Price: orderPrice, + Market: s.spotMarket, + } + createdOrders, err := s.spotOrderExecutor.SubmitOrders(ctx, submitOrder) + + if err != nil { + log.WithError(err).Errorf("can not submit spot order: %+v", submitOrder) + return + } + + log.Infof("created spot orders: %+v", createdOrders) + } +} + +func (s *Strategy) increaseSpotPosition(ctx context.Context) { + if s.positionType != types.PositionShort { + log.Errorf("funding long position type is not supported") + return + } + + if s.notPositionState(PositionOpening) { + return + } + + s.mu.Lock() + usedQuoteInvestment := s.State.UsedQuoteInvestment + s.mu.Unlock() + + if usedQuoteInvestment.Compare(s.QuoteInvestment) >= 0 { + // stop increase the stop position + return + } + + _ = s.spotOrderExecutor.GracefulCancel(ctx) + + ticker, err := s.spotSession.Exchange.QueryTicker(ctx, s.Symbol) + if err != nil { + log.WithError(err).Errorf("can not query ticker") + return + } + + leftQuota := s.QuoteInvestment.Sub(usedQuoteInvestment) + + orderPrice := ticker.Buy + orderQuantity := fixedpoint.Min(s.IncrementalQuoteQuantity, leftQuota).Div(orderPrice) + + log.Infof("initial spot order quantity %s", orderQuantity.String()) + + orderQuantity = fixedpoint.Max(orderQuantity, s.minQuantity) + orderQuantity = s.spotMarket.AdjustQuantityByMinNotional(orderQuantity, orderPrice) + + if s.spotMarket.IsDustQuantity(orderQuantity, orderPrice) { + return + } + + submitOrder := types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimitMaker, + Quantity: orderQuantity, + Price: orderPrice, + Market: s.spotMarket, + } + + log.Infof("placing spot order: %+v", submitOrder) + + createdOrders, err := s.spotOrderExecutor.SubmitOrders(ctx, submitOrder) + if err != nil { + log.WithError(err).Errorf("can not submit order") + return + } + + log.Infof("created orders: %+v", createdOrders) +} + +func (s *Strategy) detectPremiumIndex(premiumIndex *types.PremiumIndex) bool { + fundingRate := premiumIndex.LastFundingRate + + log.Infof("last %s funding rate: %s", s.Symbol, fundingRate.Percentage()) + + if s.ShortFundingRate == nil { + return false + } + + switch s.getPositionState() { + + case PositionClosed: + if fundingRate.Compare(s.ShortFundingRate.High) < 0 { + return false + } + + log.Infof("funding rate %s is higher than the High threshold %s, start opening position...", + fundingRate.Percentage(), s.ShortFundingRate.High.Percentage()) + + s.startOpeningPosition(types.PositionShort, premiumIndex.Time) + return true + + case PositionReady: + if fundingRate.Compare(s.ShortFundingRate.Low) > 0 { + return false + } + + log.Infof("funding rate %s is lower than the Low threshold %s, start closing position...", + fundingRate.Percentage(), s.ShortFundingRate.Low.Percentage()) + + qbtrade.Notify("%s funding rate %s is lower than the Low threshold %s, start closing position...", + s.Symbol, fundingRate.Percentage(), s.ShortFundingRate.Low.Percentage()) + + holdingPeriod := premiumIndex.Time.Sub(s.State.PositionStartTime) + if holdingPeriod < time.Duration(s.MinHoldingPeriod) { + log.Warnf("position holding period %s is less than %s, skip closing", holdingPeriod, s.MinHoldingPeriod.Duration()) + return false + } + + s.startClosingPosition() + return true + } + + return false +} + +func (s *Strategy) startOpeningPosition(pt types.PositionType, t time.Time) { + // only open a new position when there is no position + if s.notPositionState(PositionClosed) { + return + } + + log.Infof("startOpeningPosition") + s.setPositionState(PositionOpening) + + s.positionType = pt + + // reset the transfer stats + s.State.PositionStartTime = t + s.State.PendingBaseTransfer = fixedpoint.Zero + s.State.TotalBaseTransfer = fixedpoint.Zero +} + +func (s *Strategy) startClosingPosition() { + // we can't close a position that is not ready + if s.notPositionState(PositionReady) { + return + } + + log.Infof("startClosingPosition") + + qbtrade.Notify("Start to close position", s.FuturesPosition, s.SpotPosition) + + s.setPositionState(PositionClosing) + + // reset the transfer stats + s.State.PendingBaseTransfer = fixedpoint.Zero +} + +func (s *Strategy) setPositionState(state PositionState) { + s.mu.Lock() + origState := s.State.PositionState + s.State.PositionState = state + s.mu.Unlock() + log.Infof("position state transition: %s -> %s", origState.String(), state.String()) +} + +func (s *Strategy) isPositionState(state PositionState) bool { + s.mu.Lock() + ret := s.State.PositionState == state + s.mu.Unlock() + return ret +} + +func (s *Strategy) getPositionState() PositionState { + return s.State.PositionState +} + +func (s *Strategy) notPositionState(state PositionState) bool { + s.mu.Lock() + ret := s.State.PositionState != state + s.mu.Unlock() + return ret +} + +func (s *Strategy) allocateOrderExecutor( + ctx context.Context, session *qbtrade.ExchangeSession, instanceID string, position *types.Position, +) *qbtrade.GeneralOrderExecutor { + orderExecutor := qbtrade.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, position) + orderExecutor.SetMaxRetries(0) + orderExecutor.BindEnvironment(s.Environment) + orderExecutor.Bind() + orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { + qbtrade.Sync(ctx, s) + }) + orderExecutor.TradeCollector().OnTrade(func(trade types.Trade, _ fixedpoint.Value, _ fixedpoint.Value) { + s.ProfitStats.AddTrade(trade) + + if profit, netProfit, madeProfit := s.NeutralPosition.AddTrade(trade); madeProfit { + p := s.NeutralPosition.NewProfit(trade, profit, netProfit) + s.ProfitStats.AddProfit(p) + } + }) + return orderExecutor +} + +func (s *Strategy) setInitialLeverage(ctx context.Context) error { + log.Infof("setting futures leverage to %d", s.Leverage.Int()+1) + + futuresClient := s.binanceFutures.GetFuturesClient() + req := futuresClient.NewFuturesChangeInitialLeverageRequest() + req.Symbol(s.Symbol) + req.Leverage(s.Leverage.Int() + 1) + resp, err := req.Do(ctx) + if err != nil { + return err + } + + log.Infof("adjusted initial leverage: %+v", resp) + return nil +} + +func (s *Strategy) checkAndFixMarginMode(ctx context.Context) error { + futuresClient := s.binanceFutures.GetFuturesClient() + req := futuresClient.NewFuturesGetMultiAssetsModeRequest() + resp, err := req.Do(ctx) + if err != nil { + return err + } + + if resp.MultiAssetsMargin { + return nil + } + + fixReq := futuresClient.NewFuturesChangeMultiAssetsModeRequest() + fixReq.MultiAssetsMargin(binanceapi.MultiAssetsMarginModeOn) + fixResp, err := fixReq.Do(ctx) + if err != nil { + return err + } + + log.Infof("changeMultiAssetsMode response: %+v", fixResp) + return nil +} + +func (s *Strategy) syncPositionRisks(ctx context.Context) error { + futuresClient := s.binanceFutures.GetFuturesClient() + req := futuresClient.NewFuturesGetPositionRisksRequest() + req.Symbol(s.Symbol) + positionRisks, err := req.Do(ctx) + if err != nil { + return err + } + + log.Infof("fetched futures position risks: %+v", positionRisks) + + if len(positionRisks) == 0 { + s.FuturesPosition.Reset() + return nil + } + + for _, positionRisk := range positionRisks { + if positionRisk.Symbol != s.Symbol { + continue + } + + if positionRisk.PositionAmount.IsZero() || positionRisk.EntryPrice.IsZero() { + continue + } + + s.FuturesPosition.Base = positionRisk.PositionAmount + s.FuturesPosition.AverageCost = positionRisk.EntryPrice + log.Infof("restored futures position from positionRisk: base=%s, average_cost=%s, position_risk=%+v", + s.FuturesPosition.Base.String(), + s.FuturesPosition.AverageCost.String(), + positionRisk) + } + + return nil +} diff --git a/pkg/strategy/xfunding/transfer.go b/pkg/strategy/xfunding/transfer.go new file mode 100644 index 0000000..9f85ab4 --- /dev/null +++ b/pkg/strategy/xfunding/transfer.go @@ -0,0 +1,153 @@ +package xfunding + +import ( + "context" + "fmt" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type FuturesTransfer interface { + TransferFuturesAccountAsset(ctx context.Context, asset string, amount fixedpoint.Value, io types.TransferDirection) error + QueryAccountBalances(ctx context.Context) (types.BalanceMap, error) +} + +func (s *Strategy) resetTransfer(ctx context.Context, ex FuturesTransfer, asset string) error { + balances, err := s.futuresSession.Exchange.QueryAccountBalances(ctx) + if err != nil { + return err + } + + b, ok := balances[asset] + if !ok { + return nil + } + + amount := b.MaxWithdrawAmount + if amount.IsZero() { + return nil + } + + log.Infof("transfering out futures account asset %s %s", amount, asset) + + err = ex.TransferFuturesAccountAsset(ctx, asset, amount, types.TransferOut) + if err != nil { + return err + } + + s.State.PendingBaseTransfer = fixedpoint.Zero + s.State.TotalBaseTransfer = fixedpoint.Zero + return nil +} + +func (s *Strategy) transferOut(ctx context.Context, ex FuturesTransfer, asset string, quantity fixedpoint.Value) error { + // if transfer done + // TotalBaseTransfer here is the rest quantity we need to transfer + // (total spot -> futures transfer amount) is recorded in this variable. + // + // TotalBaseTransfer == 0 means we have nothing to transfer. + if s.State.TotalBaseTransfer.IsZero() { + return nil + } + + quantity = quantity.Add(s.State.PendingBaseTransfer) + + // A simple protection here -- we can only transfer the rest quota (total base transfer) back to spot + quantity = fixedpoint.Min(s.State.TotalBaseTransfer, quantity) + + available, pending, err := s.queryAvailableTransfer(ctx, s.futuresSession.Exchange, asset, quantity) + if err != nil { + s.State.PendingBaseTransfer = quantity + return err + } + + s.State.PendingBaseTransfer = pending + + log.Infof("transfering out futures account asset %f %s", available.Float64(), asset) + if err := ex.TransferFuturesAccountAsset(ctx, asset, available, types.TransferOut); err != nil { + s.State.PendingBaseTransfer = s.State.PendingBaseTransfer.Add(available) + return err + } + + // reduce the transfer in the total base transfer + s.State.TotalBaseTransfer = s.State.TotalBaseTransfer.Sub(available) + return nil +} + +// transferIn transfers the asset from the spot account to the futures account +func (s *Strategy) transferIn(ctx context.Context, ex FuturesTransfer, asset string, quantity fixedpoint.Value) error { + s.mu.Lock() + defer s.mu.Unlock() + + // add the pending transfer and reset the pending transfer + quantity = s.State.PendingBaseTransfer.Add(quantity) + + available, pending, err := s.queryAvailableTransfer(ctx, s.spotSession.Exchange, asset, quantity) + if err != nil { + s.State.PendingBaseTransfer = quantity + return err + } + + s.State.PendingBaseTransfer = pending + + if available.IsZero() { + return fmt.Errorf("unable to transfer zero %s from spot wallet to futures wallet", asset) + } + + log.Infof("transfering %f %s from the spot wallet into futures wallet...", available.Float64(), asset) + if err := ex.TransferFuturesAccountAsset(ctx, asset, available, types.TransferIn); err != nil { + s.State.PendingBaseTransfer = s.State.PendingBaseTransfer.Add(available) + return err + } + + // record the transfer in the total base transfer + s.State.TotalBaseTransfer = s.State.TotalBaseTransfer.Add(available) + return nil +} + +func (s *Strategy) queryAvailableTransfer( + ctx context.Context, ex types.Exchange, asset string, quantity fixedpoint.Value, +) (available, pending fixedpoint.Value, err error) { + available = fixedpoint.Zero + pending = fixedpoint.Zero + + // query spot balances to validate the quantity + balances, err := ex.QueryAccountBalances(ctx) + if err != nil { + return available, pending, err + } + + b, ok := balances[asset] + if !ok { + return available, pending, fmt.Errorf("%s balance not found", asset) + } + + log.Infof("loaded %s balance: %+v", asset, b) + + // if quantity = 0, we will transfer all available balance into the futures wallet + if quantity.IsZero() { + quantity = b.Available + } + + limit := b.Available + if b.MaxWithdrawAmount.Sign() > 0 { + limit = fixedpoint.Min(b.MaxWithdrawAmount, limit) + } + + if limit.Compare(quantity) < 0 { + log.Infof("%s available balance is not enough for transfer (%f < %f)", + asset, + b.Available.Float64(), + quantity.Float64()) + + available = fixedpoint.Min(limit, quantity) + pending = quantity.Sub(available) + log.Infof("adjusted transfer quantity from %f to %f", quantity.Float64(), available.Float64()) + return available, pending, nil + } + + available = quantity + pending = fixedpoint.Zero + return available, pending, nil +} diff --git a/pkg/strategy/xgap/strategy.go b/pkg/strategy/xgap/strategy.go new file mode 100644 index 0000000..b8b2f82 --- /dev/null +++ b/pkg/strategy/xgap/strategy.go @@ -0,0 +1,432 @@ +package xgap + +import ( + "context" + "fmt" + "math" + "math/rand" + "sync" + "time" + + "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/strategy/common" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +const ID = "xgap" + +var log = logrus.WithField("strategy", ID) + +var maxStepPercentageGap = fixedpoint.NewFromFloat(0.05) + +var Two = fixedpoint.NewFromInt(2) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s", ID, s.Symbol) +} + +type State struct { + AccumulatedFeeStartedAt time.Time `json:"accumulatedFeeStartedAt,omitempty"` + AccumulatedFees map[string]fixedpoint.Value `json:"accumulatedFees,omitempty"` + AccumulatedVolume fixedpoint.Value `json:"accumulatedVolume,omitempty"` +} + +func (s *State) IsOver24Hours() bool { + return time.Since(s.AccumulatedFeeStartedAt) >= 24*time.Hour +} + +func (s *State) Reset() { + t := time.Now() + dateTime := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) + + log.Infof("resetting accumulated started time to: %s", dateTime) + + s.AccumulatedFeeStartedAt = dateTime + s.AccumulatedFees = make(map[string]fixedpoint.Value) + s.AccumulatedVolume = fixedpoint.Zero +} + +type Strategy struct { + *common.Strategy + + Environment *qbtrade.Environment + + Symbol string `json:"symbol"` + SourceExchange string `json:"sourceExchange"` + TradingExchange string `json:"tradingExchange"` + MinSpread fixedpoint.Value `json:"minSpread"` + Quantity fixedpoint.Value `json:"quantity"` + DryRun bool `json:"dryRun"` + + DailyFeeBudgets map[string]fixedpoint.Value `json:"dailyFeeBudgets,omitempty"` + DailyMaxVolume fixedpoint.Value `json:"dailyMaxVolume,omitempty"` + UpdateInterval types.Duration `json:"updateInterval"` + SimulateVolume bool `json:"simulateVolume"` + SimulatePrice bool `json:"simulatePrice"` + + sourceSession, tradingSession *qbtrade.ExchangeSession + sourceMarket, tradingMarket types.Market + + State *State `persistence:"state"` + + mu sync.Mutex + lastSourceKLine, lastTradingKLine types.KLine + sourceBook, tradingBook *types.StreamOrderBook + + stopC chan struct{} +} + +func (s *Strategy) Initialize() error { + if s.Strategy == nil { + s.Strategy = &common.Strategy{} + } + return nil +} + +func (s *Strategy) Validate() error { + return nil +} + +func (s *Strategy) Defaults() error { + if s.UpdateInterval == 0 { + s.UpdateInterval = types.Duration(time.Second) + } + return nil +} + +func (s *Strategy) isBudgetAllowed() bool { + if s.DailyFeeBudgets == nil { + return true + } + + if s.State.AccumulatedFees == nil { + return true + } + + for asset, budget := range s.DailyFeeBudgets { + if fee, ok := s.State.AccumulatedFees[asset]; ok { + if fee.Compare(budget) >= 0 { + log.Warnf("accumulative fee %s exceeded the fee budget %s, skipping...", fee.String(), budget.String()) + return false + } + } + } + + return true +} + +func (s *Strategy) handleTradeUpdate(trade types.Trade) { + log.Infof("received trade %s", trade.String()) + + if trade.Symbol != s.Symbol { + return + } + + if s.State.IsOver24Hours() { + s.State.Reset() + } + + // safe check + if s.State.AccumulatedFees == nil { + s.State.AccumulatedFees = make(map[string]fixedpoint.Value) + } + + s.State.AccumulatedFees[trade.FeeCurrency] = s.State.AccumulatedFees[trade.FeeCurrency].Add(trade.Fee) + s.State.AccumulatedVolume = s.State.AccumulatedVolume.Add(trade.Quantity) + log.Infof("accumulated fee: %s %s", s.State.AccumulatedFees[trade.FeeCurrency].String(), trade.FeeCurrency) +} + +func (s *Strategy) CrossSubscribe(sessions map[string]*qbtrade.ExchangeSession) { + sourceSession, ok := sessions[s.SourceExchange] + if !ok { + panic(fmt.Errorf("source session %s is not defined", s.SourceExchange)) + } + + sourceSession.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: "1m"}) + sourceSession.Subscribe(types.BookChannel, s.Symbol, types.SubscribeOptions{Depth: types.DepthLevel5}) + + tradingSession, ok := sessions[s.TradingExchange] + if !ok { + panic(fmt.Errorf("trading session %s is not defined", s.TradingExchange)) + } + + tradingSession.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: "1m"}) + tradingSession.Subscribe(types.BookChannel, s.Symbol, types.SubscribeOptions{Depth: types.DepthLevel5}) +} + +func (s *Strategy) CrossRun(ctx context.Context, _ qbtrade.OrderExecutionRouter, sessions map[string]*qbtrade.ExchangeSession) error { + sourceSession, ok := sessions[s.SourceExchange] + if !ok { + return fmt.Errorf("source session %s is not defined", s.SourceExchange) + } + s.sourceSession = sourceSession + + tradingSession, ok := sessions[s.TradingExchange] + if !ok { + return fmt.Errorf("trading session %s is not defined", s.TradingExchange) + } + s.tradingSession = tradingSession + + s.sourceMarket, ok = s.sourceSession.Market(s.Symbol) + if !ok { + return fmt.Errorf("source session market %s is not defined", s.Symbol) + } + + s.tradingMarket, ok = s.tradingSession.Market(s.Symbol) + if !ok { + return fmt.Errorf("trading session market %s is not defined", s.Symbol) + } + + s.Strategy.Initialize(ctx, s.Environment, tradingSession, s.tradingMarket, ID, s.InstanceID()) + + s.stopC = make(chan struct{}) + + if s.State == nil { + s.State = &State{} + s.State.Reset() + } + + if s.State.IsOver24Hours() { + log.Warn("state is over 24 hours, resetting to zero") + s.State.Reset() + } + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + close(s.stopC) + qbtrade.Sync(ctx, s) + }) + + // from here, set data binding + s.sourceSession.MarketDataStream.OnKLine(func(kline types.KLine) { + s.mu.Lock() + s.lastSourceKLine = kline + s.mu.Unlock() + }) + s.tradingSession.MarketDataStream.OnKLine(func(kline types.KLine) { + s.mu.Lock() + s.lastTradingKLine = kline + s.mu.Unlock() + }) + + if s.SourceExchange != "" { + s.sourceBook = types.NewStreamBook(s.Symbol) + s.sourceBook.BindStream(s.sourceSession.MarketDataStream) + } + + s.tradingBook = types.NewStreamBook(s.Symbol) + s.tradingBook.BindStream(s.tradingSession.MarketDataStream) + + s.tradingSession.UserDataStream.OnTradeUpdate(s.handleTradeUpdate) + + go func() { + ticker := time.NewTicker( + util.MillisecondsJitter(s.UpdateInterval.Duration(), 1000), + ) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + + case <-s.stopC: + return + + case <-ticker.C: + if !s.isBudgetAllowed() { + continue + } + + // < 10 seconds jitter sleep + delay := util.MillisecondsJitter(s.UpdateInterval.Duration(), 10*1000) + if delay < s.UpdateInterval.Duration() { + time.Sleep(delay) + } + + s.placeOrders(ctx) + + s.cancelOrders(ctx) + } + } + }() + + return nil +} + +func (s *Strategy) placeOrders(ctx context.Context) { + bestBid, hasBid := s.tradingBook.BestBid() + bestAsk, hasAsk := s.tradingBook.BestAsk() + + // try to use the bid/ask price from the trading book + if hasBid && hasAsk { + var spread = bestAsk.Price.Sub(bestBid.Price) + var spreadPercentage = spread.Div(bestAsk.Price) + log.Infof("trading book spread=%s %s", + spread.String(), spreadPercentage.Percentage()) + + // use the source book price if the spread percentage greater than 5% + if s.SimulatePrice && s.sourceBook != nil && spreadPercentage.Compare(maxStepPercentageGap) > 0 { + log.Warnf("spread too large (%s %s), using source book", + spread.String(), spreadPercentage.Percentage()) + bestBid, hasBid = s.sourceBook.BestBid() + bestAsk, hasAsk = s.sourceBook.BestAsk() + } + + if s.MinSpread.Sign() > 0 { + if spread.Compare(s.MinSpread) < 0 { + log.Warnf("spread < min spread, spread=%s minSpread=%s bid=%s ask=%s", + spread.String(), s.MinSpread.String(), + bestBid.Price.String(), bestAsk.Price.String()) + return + } + } + + // if the spread is less than 100 ticks (100 pips), skip + if spread.Compare(s.tradingMarket.TickSize.MulExp(2)) < 0 { + log.Warnf("spread too small, we can't place orders: spread=%s bid=%s ask=%s", + spread.String(), bestBid.Price.String(), bestAsk.Price.String()) + return + } + + } else if s.sourceBook != nil { + bestBid, hasBid = s.sourceBook.BestBid() + bestAsk, hasAsk = s.sourceBook.BestAsk() + } + + if !hasBid || !hasAsk { + log.Warn("no bids or asks on the source book or the trading book") + return + } + + if bestBid.Price.IsZero() || bestAsk.Price.IsZero() { + log.Warn("bid price or ask price is zero") + return + } + + var spread = bestAsk.Price.Sub(bestBid.Price) + var spreadPercentage = spread.Div(bestAsk.Price) + + log.Infof("spread:%s %s ask:%s bid:%s", + spread.String(), spreadPercentage.Percentage(), + bestAsk.Price.String(), bestBid.Price.String()) + // var spreadPercentage = spread.Float64() / bestBid.Price.Float64() + + var midPrice = bestAsk.Price.Add(bestBid.Price).Div(Two) + var price = midPrice + + log.Infof("mid price %s", midPrice.String()) + + var balances = s.tradingSession.GetAccount().Balances() + + baseBalance, ok := balances[s.tradingMarket.BaseCurrency] + if !ok { + log.Errorf("base balance %s not found", s.tradingMarket.BaseCurrency) + return + } + + quoteBalance, ok := balances[s.tradingMarket.QuoteCurrency] + if !ok { + log.Errorf("quote balance %s not found", s.tradingMarket.QuoteCurrency) + return + } + + minQuantity := s.tradingMarket.AdjustQuantityByMinNotional(s.tradingMarket.MinQuantity, price) + + if baseBalance.Available.Compare(minQuantity) <= 0 { + log.Infof("base balance: %s %s is not enough, skip", baseBalance.Available.String(), s.tradingMarket.BaseCurrency) + return + } + + if quoteBalance.Available.Div(price).Compare(minQuantity) <= 0 { + log.Infof("quote balance: %s %s is not enough, skip", quoteBalance.Available.String(), s.tradingMarket.QuoteCurrency) + return + } + + maxQuantity := baseBalance.Available + if !quoteBalance.Available.IsZero() { + maxQuantity = fixedpoint.Min(maxQuantity, quoteBalance.Available.Div(price)) + } + + quantity := minQuantity + + // if we set the fixed quantity, we should use the fixed + if s.Quantity.Sign() > 0 { + quantity = fixedpoint.Max(s.Quantity, quantity) + } else if s.SimulateVolume { + s.mu.Lock() + if s.lastTradingKLine.Volume.Sign() > 0 && s.lastSourceKLine.Volume.Sign() > 0 { + log.Infof("trading exchange %s price: %s volume: %s", + s.Symbol, s.lastTradingKLine.Close.String(), s.lastTradingKLine.Volume.String()) + log.Infof("source exchange %s price: %s volume: %s", + s.Symbol, s.lastSourceKLine.Close.String(), s.lastSourceKLine.Volume.String()) + + volumeDiff := s.lastSourceKLine.Volume.Sub(s.lastTradingKLine.Volume) + // change the current quantity only diff is positive + if volumeDiff.Sign() > 0 { + quantity = volumeDiff + } + } + s.mu.Unlock() + } else { + // plus a 2% quantity jitter + jitter := 1.0 + math.Max(0.02, rand.Float64()) + quantity = quantity.Mul(fixedpoint.NewFromFloat(jitter)) + } + + log.Infof("%s quantity: %f", s.Symbol, quantity.Float64()) + + quantity = fixedpoint.Min(quantity, maxQuantity) + + log.Infof("%s adjusted quantity: %f", s.Symbol, quantity.Float64()) + + orderForms := []types.SubmitOrder{ + { + Symbol: s.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeLimit, + Quantity: quantity, + Price: price, + Market: s.tradingMarket, + }, + { + Symbol: s.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeLimit, + Quantity: quantity, + Price: price, + Market: s.tradingMarket, + }, + } + log.Infof("order forms: %+v", orderForms) + + if s.DryRun { + log.Infof("dry run, skip") + return + } + + _, err := s.OrderExecutor.SubmitOrders(ctx, orderForms...) + if err != nil { + log.WithError(err).Error("order submit error") + } + + time.Sleep(time.Second) +} + +func (s *Strategy) cancelOrders(ctx context.Context) { + if err := s.OrderExecutor.GracefulCancel(ctx); err != nil { + log.WithError(err).Error("cancel order error") + } +} diff --git a/pkg/strategy/xmaker/state.go b/pkg/strategy/xmaker/state.go new file mode 100644 index 0000000..a0c0b7b --- /dev/null +++ b/pkg/strategy/xmaker/state.go @@ -0,0 +1,68 @@ +package xmaker + +import ( + "sync" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type State struct { + CoveredPosition fixedpoint.Value `json:"coveredPosition,omitempty"` + + // Deprecated: + Position *types.Position `json:"position,omitempty"` + + // Deprecated: + ProfitStats ProfitStats `json:"profitStats,omitempty"` +} + +type ProfitStats struct { + *types.ProfitStats + + lock sync.Mutex + + MakerExchange types.ExchangeName `json:"makerExchange"` + + AccumulatedMakerVolume fixedpoint.Value `json:"accumulatedMakerVolume,omitempty"` + AccumulatedMakerBidVolume fixedpoint.Value `json:"accumulatedMakerBidVolume,omitempty"` + AccumulatedMakerAskVolume fixedpoint.Value `json:"accumulatedMakerAskVolume,omitempty"` + + TodayMakerVolume fixedpoint.Value `json:"todayMakerVolume,omitempty"` + TodayMakerBidVolume fixedpoint.Value `json:"todayMakerBidVolume,omitempty"` + TodayMakerAskVolume fixedpoint.Value `json:"todayMakerAskVolume,omitempty"` +} + +func (s *ProfitStats) AddTrade(trade types.Trade) { + s.ProfitStats.AddTrade(trade) + + if trade.Exchange == s.MakerExchange { + s.lock.Lock() + s.AccumulatedMakerVolume = s.AccumulatedMakerVolume.Add(trade.Quantity) + s.TodayMakerVolume = s.TodayMakerVolume.Add(trade.Quantity) + + switch trade.Side { + + case types.SideTypeSell: + s.AccumulatedMakerAskVolume = s.AccumulatedMakerAskVolume.Add(trade.Quantity) + s.TodayMakerAskVolume = s.TodayMakerAskVolume.Add(trade.Quantity) + + case types.SideTypeBuy: + s.AccumulatedMakerBidVolume = s.AccumulatedMakerBidVolume.Add(trade.Quantity) + s.TodayMakerBidVolume = s.TodayMakerBidVolume.Add(trade.Quantity) + + } + s.lock.Unlock() + } +} + +func (s *ProfitStats) ResetToday() { + s.ProfitStats.ResetToday(time.Now()) + + s.lock.Lock() + s.TodayMakerVolume = fixedpoint.Zero + s.TodayMakerBidVolume = fixedpoint.Zero + s.TodayMakerAskVolume = fixedpoint.Zero + s.lock.Unlock() +} diff --git a/pkg/strategy/xmaker/strategy.go b/pkg/strategy/xmaker/strategy.go new file mode 100644 index 0000000..19383cd --- /dev/null +++ b/pkg/strategy/xmaker/strategy.go @@ -0,0 +1,916 @@ +package xmaker + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "golang.org/x/time/rate" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/core" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/indicator" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" +) + +var defaultMargin = fixedpoint.NewFromFloat(0.003) +var Two = fixedpoint.NewFromInt(2) + +const priceUpdateTimeout = 30 * time.Second + +const ID = "xmaker" + +var log = logrus.WithField("strategy", ID) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + Environment *qbtrade.Environment + + Symbol string `json:"symbol"` + + // SourceExchange session name + SourceExchange string `json:"sourceExchange"` + + // MakerExchange session name + MakerExchange string `json:"makerExchange"` + + UpdateInterval types.Duration `json:"updateInterval"` + HedgeInterval types.Duration `json:"hedgeInterval"` + OrderCancelWaitTime types.Duration `json:"orderCancelWaitTime"` + + Margin fixedpoint.Value `json:"margin"` + BidMargin fixedpoint.Value `json:"bidMargin"` + AskMargin fixedpoint.Value `json:"askMargin"` + UseDepthPrice bool `json:"useDepthPrice"` + DepthQuantity fixedpoint.Value `json:"depthQuantity"` + + EnableBollBandMargin bool `json:"enableBollBandMargin"` + BollBandInterval types.Interval `json:"bollBandInterval"` + BollBandMargin fixedpoint.Value `json:"bollBandMargin"` + BollBandMarginFactor fixedpoint.Value `json:"bollBandMarginFactor"` + + StopHedgeQuoteBalance fixedpoint.Value `json:"stopHedgeQuoteBalance"` + StopHedgeBaseBalance fixedpoint.Value `json:"stopHedgeBaseBalance"` + + // Quantity is used for fixed quantity of the first layer + Quantity fixedpoint.Value `json:"quantity"` + + // QuantityMultiplier is the factor that multiplies the quantity of the previous layer + QuantityMultiplier fixedpoint.Value `json:"quantityMultiplier"` + + // QuantityScale helps user to define the quantity by layer scale + QuantityScale *qbtrade.LayerScale `json:"quantityScale,omitempty"` + + // MaxExposurePosition defines the unhedged quantity of stop + MaxExposurePosition fixedpoint.Value `json:"maxExposurePosition"` + + DisableHedge bool `json:"disableHedge"` + + NotifyTrade bool `json:"notifyTrade"` + + // RecoverTrade tries to find the missing trades via the REStful API + RecoverTrade bool `json:"recoverTrade"` + + RecoverTradeScanPeriod types.Duration `json:"recoverTradeScanPeriod"` + + NumLayers int `json:"numLayers"` + + // Pips is the pips of the layer prices + Pips fixedpoint.Value `json:"pips"` + + // -------------------------------- + // private field + + makerSession, sourceSession *qbtrade.ExchangeSession + + makerMarket, sourceMarket types.Market + + // boll is the BOLLINGER indicator we used for predicting the price. + boll *indicator.BOLL + + state *State + + // persistence fields + Position *types.Position `json:"position,omitempty" persistence:"position"` + ProfitStats *ProfitStats `json:"profitStats,omitempty" persistence:"profit_stats"` + CoveredPosition fixedpoint.Value `json:"coveredPosition,omitempty" persistence:"covered_position"` + + book *types.StreamOrderBook + activeMakerOrders *qbtrade.ActiveOrderBook + + hedgeErrorLimiter *rate.Limiter + hedgeErrorRateReservation *rate.Reservation + + orderStore *core.OrderStore + tradeCollector *core.TradeCollector + + askPriceHeartBeat, bidPriceHeartBeat *types.PriceHeartBeat + + lastPrice fixedpoint.Value + groupID uint32 + + stopC chan struct{} +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s", ID, s.Symbol) +} + +func (s *Strategy) CrossSubscribe(sessions map[string]*qbtrade.ExchangeSession) { + sourceSession, ok := sessions[s.SourceExchange] + if !ok { + panic(fmt.Errorf("source session %s is not defined", s.SourceExchange)) + } + + sourceSession.Subscribe(types.BookChannel, s.Symbol, types.SubscribeOptions{}) + sourceSession.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: "1m"}) + + makerSession, ok := sessions[s.MakerExchange] + if !ok { + panic(fmt.Errorf("maker session %s is not defined", s.MakerExchange)) + } + makerSession.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: "1m"}) +} + +func aggregatePrice(pvs types.PriceVolumeSlice, requiredQuantity fixedpoint.Value) (price fixedpoint.Value) { + q := requiredQuantity + totalAmount := fixedpoint.Zero + + if len(pvs) == 0 { + price = fixedpoint.Zero + return price + } else if pvs[0].Volume.Compare(requiredQuantity) >= 0 { + return pvs[0].Price + } + + for i := 0; i < len(pvs); i++ { + pv := pvs[i] + if pv.Volume.Compare(q) >= 0 { + totalAmount = totalAmount.Add(q.Mul(pv.Price)) + break + } + + q = q.Sub(pv.Volume) + totalAmount = totalAmount.Add(pv.Volume.Mul(pv.Price)) + } + + price = totalAmount.Div(requiredQuantity) + return price +} + +func (s *Strategy) Initialize() error { + s.bidPriceHeartBeat = types.NewPriceHeartBeat(priceUpdateTimeout) + s.askPriceHeartBeat = types.NewPriceHeartBeat(priceUpdateTimeout) + return nil +} + +func (s *Strategy) updateQuote(ctx context.Context, orderExecutionRouter qbtrade.OrderExecutionRouter) { + if err := s.activeMakerOrders.GracefulCancel(ctx, s.makerSession.Exchange); err != nil { + log.Warnf("there are some %s orders not canceled, skipping placing maker orders", s.Symbol) + s.activeMakerOrders.Print() + return + } + + if s.activeMakerOrders.NumOfOrders() > 0 { + return + } + + bestBid, bestAsk, hasPrice := s.book.BestBidAndAsk() + if !hasPrice { + return + } + + // use mid-price for the last price + s.lastPrice = bestBid.Price.Add(bestAsk.Price).Div(Two) + + bookLastUpdateTime := s.book.LastUpdateTime() + + if _, err := s.bidPriceHeartBeat.Update(bestBid); err != nil { + log.WithError(err).Errorf("quote update error, %s price not updating, order book last update: %s ago", + s.Symbol, + time.Since(bookLastUpdateTime)) + return + } + + if _, err := s.askPriceHeartBeat.Update(bestAsk); err != nil { + log.WithError(err).Errorf("quote update error, %s price not updating, order book last update: %s ago", + s.Symbol, + time.Since(bookLastUpdateTime)) + return + } + + sourceBook := s.book.CopyDepth(10) + if valid, err := sourceBook.IsValid(); !valid { + log.WithError(err).Errorf("%s invalid copied order book, skip quoting: %v", s.Symbol, err) + return + } + + var disableMakerBid = false + var disableMakerAsk = false + + // check maker's balance quota + // we load the balances from the account while we're generating the orders, + // the balance may have a chance to be deducted by other strategies or manual orders submitted by the user + makerBalances := s.makerSession.GetAccount().Balances() + makerQuota := &qbtrade.QuotaTransaction{} + if b, ok := makerBalances[s.makerMarket.BaseCurrency]; ok { + if b.Available.Compare(s.makerMarket.MinQuantity) > 0 { + makerQuota.BaseAsset.Add(b.Available) + } else { + disableMakerAsk = true + } + } + + if b, ok := makerBalances[s.makerMarket.QuoteCurrency]; ok { + if b.Available.Compare(s.makerMarket.MinNotional) > 0 { + makerQuota.QuoteAsset.Add(b.Available) + } else { + disableMakerBid = true + } + } + + hedgeBalances := s.sourceSession.GetAccount().Balances() + hedgeQuota := &qbtrade.QuotaTransaction{} + if b, ok := hedgeBalances[s.sourceMarket.BaseCurrency]; ok { + // to make bid orders, we need enough base asset in the foreign exchange, + // if the base asset balance is not enough for selling + if s.StopHedgeBaseBalance.Sign() > 0 { + minAvailable := s.StopHedgeBaseBalance.Add(s.sourceMarket.MinQuantity) + if b.Available.Compare(minAvailable) > 0 { + hedgeQuota.BaseAsset.Add(b.Available.Sub(minAvailable)) + } else { + log.Warnf("%s maker bid disabled: insufficient base balance %s", s.Symbol, b.String()) + disableMakerBid = true + } + } else if b.Available.Compare(s.sourceMarket.MinQuantity) > 0 { + hedgeQuota.BaseAsset.Add(b.Available) + } else { + log.Warnf("%s maker bid disabled: insufficient base balance %s", s.Symbol, b.String()) + disableMakerBid = true + } + } + + if b, ok := hedgeBalances[s.sourceMarket.QuoteCurrency]; ok { + // to make ask orders, we need enough quote asset in the foreign exchange, + // if the quote asset balance is not enough for buying + if s.StopHedgeQuoteBalance.Sign() > 0 { + minAvailable := s.StopHedgeQuoteBalance.Add(s.sourceMarket.MinNotional) + if b.Available.Compare(minAvailable) > 0 { + hedgeQuota.QuoteAsset.Add(b.Available.Sub(minAvailable)) + } else { + log.Warnf("%s maker ask disabled: insufficient quote balance %s", s.Symbol, b.String()) + disableMakerAsk = true + } + } else if b.Available.Compare(s.sourceMarket.MinNotional) > 0 { + hedgeQuota.QuoteAsset.Add(b.Available) + } else { + log.Warnf("%s maker ask disabled: insufficient quote balance %s", s.Symbol, b.String()) + disableMakerAsk = true + } + } + + // if max exposure position is configured, we should not: + // 1. place bid orders when we already bought too much + // 2. place ask orders when we already sold too much + if s.MaxExposurePosition.Sign() > 0 { + pos := s.Position.GetBase() + + if pos.Compare(s.MaxExposurePosition.Neg()) > 0 { + // stop sell if we over-sell + disableMakerAsk = true + } else if pos.Compare(s.MaxExposurePosition) > 0 { + // stop buy if we over buy + disableMakerBid = true + } + } + + if disableMakerAsk && disableMakerBid { + log.Warnf("%s bid/ask maker is disabled due to insufficient balances", s.Symbol) + return + } + + bestBidPrice := bestBid.Price + bestAskPrice := bestAsk.Price + log.Infof("%s book ticker: best ask / best bid = %v / %v", s.Symbol, bestAskPrice, bestBidPrice) + + var submitOrders []types.SubmitOrder + var accumulativeBidQuantity, accumulativeAskQuantity fixedpoint.Value + var bidQuantity = s.Quantity + var askQuantity = s.Quantity + var bidMargin = s.BidMargin + var askMargin = s.AskMargin + var pips = s.Pips + + if s.EnableBollBandMargin { + lastDownBand := fixedpoint.NewFromFloat(s.boll.DownBand.Last(0)) + lastUpBand := fixedpoint.NewFromFloat(s.boll.UpBand.Last(0)) + + if lastUpBand.IsZero() || lastDownBand.IsZero() { + log.Warnf("bollinger band value is zero, skipping") + return + } + + log.Infof("bollinger band: up/down = %f/%f", lastUpBand.Float64(), lastDownBand.Float64()) + + // when bid price is lower than the down band, then it's in the downtrend + // when ask price is higher than the up band, then it's in the uptrend + if bestBidPrice.Compare(lastDownBand) < 0 { + // ratio here should be greater than 1.00 + ratio := lastDownBand.Div(bestBidPrice) + + // so that the original bid margin can be multiplied by 1.x + bollMargin := s.BollBandMargin.Mul(ratio).Mul(s.BollBandMarginFactor) + + log.Infof("%s bollband downtrend: adjusting ask margin %v + %v = %v", + s.Symbol, + askMargin, + bollMargin, + askMargin.Add(bollMargin)) + + askMargin = askMargin.Add(bollMargin) + pips = pips.Mul(ratio) + } + + if bestAskPrice.Compare(lastUpBand) > 0 { + // ratio here should be greater than 1.00 + ratio := bestAskPrice.Div(lastUpBand) + + // so that the original bid margin can be multiplied by 1.x + bollMargin := s.BollBandMargin.Mul(ratio).Mul(s.BollBandMarginFactor) + + log.Infof("%s bollband uptrend adjusting bid margin %v + %v = %v", + s.Symbol, + bidMargin, + bollMargin, + bidMargin.Add(bollMargin)) + + bidMargin = bidMargin.Add(bollMargin) + pips = pips.Mul(ratio) + } + } + + bidPrice := bestBidPrice + askPrice := bestAskPrice + for i := 0; i < s.NumLayers; i++ { + // for maker bid orders + if !disableMakerBid { + if s.QuantityScale != nil { + qf, err := s.QuantityScale.Scale(i + 1) + if err != nil { + log.WithError(err).Errorf("quantityScale error") + return + } + + log.Infof("%s scaling bid #%d quantity to %f", s.Symbol, i+1, qf) + + // override the default bid quantity + bidQuantity = fixedpoint.NewFromFloat(qf) + } + + accumulativeBidQuantity = accumulativeBidQuantity.Add(bidQuantity) + if s.UseDepthPrice { + if s.DepthQuantity.Sign() > 0 { + bidPrice = aggregatePrice(sourceBook.SideBook(types.SideTypeBuy), s.DepthQuantity) + } else { + bidPrice = aggregatePrice(sourceBook.SideBook(types.SideTypeBuy), accumulativeBidQuantity) + } + } + + bidPrice = bidPrice.Mul(fixedpoint.One.Sub(bidMargin)) + if i > 0 && pips.Sign() > 0 { + bidPrice = bidPrice.Sub(pips.Mul(fixedpoint.NewFromInt(int64(i)). + Mul(s.makerMarket.TickSize))) + } + + if makerQuota.QuoteAsset.Lock(bidQuantity.Mul(bidPrice)) && hedgeQuota.BaseAsset.Lock(bidQuantity) { + // if we bought, then we need to sell the base from the hedge session + submitOrders = append(submitOrders, types.SubmitOrder{ + Symbol: s.Symbol, + Type: types.OrderTypeLimit, + Side: types.SideTypeBuy, + Price: bidPrice, + Quantity: bidQuantity, + TimeInForce: types.TimeInForceGTC, + GroupID: s.groupID, + }) + + makerQuota.Commit() + hedgeQuota.Commit() + } else { + makerQuota.Rollback() + hedgeQuota.Rollback() + } + + if s.QuantityMultiplier.Sign() > 0 { + bidQuantity = bidQuantity.Mul(s.QuantityMultiplier) + } + } + + // for maker ask orders + if !disableMakerAsk { + if s.QuantityScale != nil { + qf, err := s.QuantityScale.Scale(i + 1) + if err != nil { + log.WithError(err).Errorf("quantityScale error") + return + } + + log.Infof("%s scaling ask #%d quantity to %f", s.Symbol, i+1, qf) + + // override the default bid quantity + askQuantity = fixedpoint.NewFromFloat(qf) + } + accumulativeAskQuantity = accumulativeAskQuantity.Add(askQuantity) + + if s.UseDepthPrice { + if s.DepthQuantity.Sign() > 0 { + askPrice = aggregatePrice(sourceBook.SideBook(types.SideTypeSell), s.DepthQuantity) + } else { + askPrice = aggregatePrice(sourceBook.SideBook(types.SideTypeSell), accumulativeAskQuantity) + } + } + + askPrice = askPrice.Mul(fixedpoint.One.Add(askMargin)) + if i > 0 && pips.Sign() > 0 { + askPrice = askPrice.Add(pips.Mul(fixedpoint.NewFromInt(int64(i)).Mul(s.makerMarket.TickSize))) + } + + if makerQuota.BaseAsset.Lock(askQuantity) && hedgeQuota.QuoteAsset.Lock(askQuantity.Mul(askPrice)) { + // if we bought, then we need to sell the base from the hedge session + submitOrders = append(submitOrders, types.SubmitOrder{ + Symbol: s.Symbol, + Market: s.makerMarket, + Type: types.OrderTypeLimit, + Side: types.SideTypeSell, + Price: askPrice, + Quantity: askQuantity, + TimeInForce: types.TimeInForceGTC, + GroupID: s.groupID, + }) + makerQuota.Commit() + hedgeQuota.Commit() + } else { + makerQuota.Rollback() + hedgeQuota.Rollback() + } + + if s.QuantityMultiplier.Sign() > 0 { + askQuantity = askQuantity.Mul(s.QuantityMultiplier) + } + } + } + + if len(submitOrders) == 0 { + log.Warnf("no orders generated") + return + } + + makerOrders, err := orderExecutionRouter.SubmitOrdersTo(ctx, s.MakerExchange, submitOrders...) + if err != nil { + log.WithError(err).Errorf("order error: %s", err.Error()) + return + } + + s.activeMakerOrders.Add(makerOrders...) + s.orderStore.Add(makerOrders...) +} + +var lastPriceModifier = fixedpoint.NewFromFloat(1.001) +var minGap = fixedpoint.NewFromFloat(1.02) + +func (s *Strategy) Hedge(ctx context.Context, pos fixedpoint.Value) { + side := types.SideTypeBuy + if pos.IsZero() { + return + } + + quantity := pos.Abs() + + if pos.Sign() < 0 { + side = types.SideTypeSell + } + + lastPrice := s.lastPrice + sourceBook := s.book.CopyDepth(1) + switch side { + + case types.SideTypeBuy: + if bestAsk, ok := sourceBook.BestAsk(); ok { + lastPrice = bestAsk.Price + } + + case types.SideTypeSell: + if bestBid, ok := sourceBook.BestBid(); ok { + lastPrice = bestBid.Price + } + } + + notional := quantity.Mul(lastPrice) + if notional.Compare(s.sourceMarket.MinNotional) <= 0 { + log.Warnf("%s %v less than min notional, skipping hedge", s.Symbol, notional) + return + } + + // adjust quantity according to the balances + account := s.sourceSession.GetAccount() + switch side { + + case types.SideTypeBuy: + // check quote quantity + if quote, ok := account.Balance(s.sourceMarket.QuoteCurrency); ok { + if quote.Available.Compare(notional) < 0 { + // adjust price to higher 0.1%, so that we can ensure that the order can be executed + quantity = qbtrade.AdjustQuantityByMaxAmount(quantity, lastPrice.Mul(lastPriceModifier), quote.Available) + quantity = s.sourceMarket.TruncateQuantity(quantity) + } + } + + case types.SideTypeSell: + // check quote quantity + if base, ok := account.Balance(s.sourceMarket.BaseCurrency); ok { + if base.Available.Compare(quantity) < 0 { + quantity = base.Available + } + } + } + + // truncate quantity for the supported precision + quantity = s.sourceMarket.TruncateQuantity(quantity) + + if notional.Compare(s.sourceMarket.MinNotional.Mul(minGap)) <= 0 { + log.Warnf("the adjusted amount %v is less than minimal notional %v, skipping hedge", notional, s.sourceMarket.MinNotional) + return + } + + if quantity.Compare(s.sourceMarket.MinQuantity.Mul(minGap)) <= 0 { + log.Warnf("the adjusted quantity %v is less than minimal quantity %v, skipping hedge", quantity, s.sourceMarket.MinQuantity) + return + } + + if s.hedgeErrorRateReservation != nil { + if !s.hedgeErrorRateReservation.OK() { + return + } + qbtrade.Notify("Hit hedge error rate limit, waiting...") + time.Sleep(s.hedgeErrorRateReservation.Delay()) + s.hedgeErrorRateReservation = nil + } + + log.Infof("submitting %s hedge order %s %v", s.Symbol, side.String(), quantity) + qbtrade.Notify("Submitting %s hedge order %s %v", s.Symbol, side.String(), quantity) + orderExecutor := &qbtrade.ExchangeOrderExecutor{Session: s.sourceSession} + returnOrders, err := orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ + Market: s.sourceMarket, + Symbol: s.Symbol, + Type: types.OrderTypeMarket, + Side: side, + Quantity: quantity, + }) + + if err != nil { + s.hedgeErrorRateReservation = s.hedgeErrorLimiter.Reserve() + log.WithError(err).Errorf("market order submit error: %s", err.Error()) + return + } + + // if it's selling, than we should add positive position + if side == types.SideTypeSell { + s.CoveredPosition = s.CoveredPosition.Add(quantity) + } else { + s.CoveredPosition = s.CoveredPosition.Add(quantity.Neg()) + } + + s.orderStore.Add(returnOrders...) +} + +func (s *Strategy) tradeRecover(ctx context.Context) { + tradeScanInterval := s.RecoverTradeScanPeriod.Duration() + if tradeScanInterval == 0 { + tradeScanInterval = 30 * time.Minute + } + + tradeScanOverlapBufferPeriod := 5 * time.Minute + + tradeScanTicker := time.NewTicker(tradeScanInterval) + defer tradeScanTicker.Stop() + + for { + select { + case <-ctx.Done(): + return + + case <-tradeScanTicker.C: + log.Infof("scanning trades from %s ago...", tradeScanInterval) + + if s.RecoverTrade { + startTime := time.Now().Add(-tradeScanInterval).Add(-tradeScanOverlapBufferPeriod) + + if err := s.tradeCollector.Recover(ctx, s.sourceSession.Exchange.(types.ExchangeTradeHistoryService), s.Symbol, startTime); err != nil { + log.WithError(err).Errorf("query trades error") + } + + if err := s.tradeCollector.Recover(ctx, s.makerSession.Exchange.(types.ExchangeTradeHistoryService), s.Symbol, startTime); err != nil { + log.WithError(err).Errorf("query trades error") + } + } + } + } +} + +func (s *Strategy) Validate() error { + if s.Quantity.IsZero() || s.QuantityScale == nil { + return errors.New("quantity or quantityScale can not be empty") + } + + if !s.QuantityMultiplier.IsZero() && s.QuantityMultiplier.Sign() < 0 { + return errors.New("quantityMultiplier can not be a negative number") + } + + if len(s.Symbol) == 0 { + return errors.New("symbol is required") + } + + return nil +} + +func (s *Strategy) CrossRun( + ctx context.Context, orderExecutionRouter qbtrade.OrderExecutionRouter, sessions map[string]*qbtrade.ExchangeSession, +) error { + if s.BollBandInterval == "" { + s.BollBandInterval = types.Interval1m + } + + if s.BollBandMarginFactor.IsZero() { + s.BollBandMarginFactor = fixedpoint.One + } + if s.BollBandMargin.IsZero() { + s.BollBandMargin = fixedpoint.NewFromFloat(0.001) + } + + // configure default values + if s.UpdateInterval == 0 { + s.UpdateInterval = types.Duration(time.Second) + } + + if s.HedgeInterval == 0 { + s.HedgeInterval = types.Duration(10 * time.Second) + } + + if s.NumLayers == 0 { + s.NumLayers = 1 + } + + if s.BidMargin.IsZero() { + if !s.Margin.IsZero() { + s.BidMargin = s.Margin + } else { + s.BidMargin = defaultMargin + } + } + + if s.AskMargin.IsZero() { + if !s.Margin.IsZero() { + s.AskMargin = s.Margin + } else { + s.AskMargin = defaultMargin + } + } + + s.hedgeErrorLimiter = rate.NewLimiter(rate.Every(1*time.Minute), 1) + + // configure sessions + sourceSession, ok := sessions[s.SourceExchange] + if !ok { + return fmt.Errorf("source exchange session %s is not defined", s.SourceExchange) + } + + s.sourceSession = sourceSession + + makerSession, ok := sessions[s.MakerExchange] + if !ok { + return fmt.Errorf("maker exchange session %s is not defined", s.MakerExchange) + } + + s.makerSession = makerSession + + s.sourceMarket, ok = s.sourceSession.Market(s.Symbol) + if !ok { + return fmt.Errorf("source session market %s is not defined", s.Symbol) + } + + s.makerMarket, ok = s.makerSession.Market(s.Symbol) + if !ok { + return fmt.Errorf("maker session market %s is not defined", s.Symbol) + } + + standardIndicatorSet := s.sourceSession.StandardIndicatorSet(s.Symbol) + if !ok { + return fmt.Errorf("%s standard indicator set not found", s.Symbol) + } + + s.boll = standardIndicatorSet.BOLL(types.IntervalWindow{ + Interval: s.BollBandInterval, + Window: 21, + }, 1.0) + + if store, ok := s.sourceSession.MarketDataStore(s.Symbol); ok { + if klines, ok2 := store.KLinesOfInterval(s.BollBandInterval); ok2 { + for i := 0; i < len(*klines); i++ { + s.boll.CalculateAndUpdate((*klines)[0 : i+1]) + } + } + } + + // restore state + instanceID := s.InstanceID() + s.groupID = util.FNV32(instanceID) + log.Infof("using group id %d from fnv(%s)", s.groupID, instanceID) + + if s.Position == nil { + s.Position = types.NewPositionFromMarket(s.makerMarket) + + // force update for legacy code + s.Position.Market = s.makerMarket + } + + qbtrade.Notify("xmaker: %s position is restored", s.Symbol, s.Position) + + if s.ProfitStats == nil { + s.ProfitStats = &ProfitStats{ + ProfitStats: types.NewProfitStats(s.makerMarket), + MakerExchange: s.makerSession.ExchangeName, + } + } + + if s.CoveredPosition.IsZero() { + if s.state != nil && !s.CoveredPosition.IsZero() { + s.CoveredPosition = s.state.CoveredPosition + } + } + + if s.makerSession.MakerFeeRate.Sign() > 0 || s.makerSession.TakerFeeRate.Sign() > 0 { + s.Position.SetExchangeFeeRate(types.ExchangeName(s.MakerExchange), types.ExchangeFee{ + MakerFeeRate: s.makerSession.MakerFeeRate, + TakerFeeRate: s.makerSession.TakerFeeRate, + }) + } + + if s.sourceSession.MakerFeeRate.Sign() > 0 || s.sourceSession.TakerFeeRate.Sign() > 0 { + s.Position.SetExchangeFeeRate(types.ExchangeName(s.SourceExchange), types.ExchangeFee{ + MakerFeeRate: s.sourceSession.MakerFeeRate, + TakerFeeRate: s.sourceSession.TakerFeeRate, + }) + } + + s.book = types.NewStreamBook(s.Symbol) + s.book.BindStream(s.sourceSession.MarketDataStream) + + s.activeMakerOrders = qbtrade.NewActiveOrderBook(s.Symbol) + s.activeMakerOrders.BindStream(s.makerSession.UserDataStream) + + s.orderStore = core.NewOrderStore(s.Symbol) + s.orderStore.BindStream(s.sourceSession.UserDataStream) + s.orderStore.BindStream(s.makerSession.UserDataStream) + + s.tradeCollector = core.NewTradeCollector(s.Symbol, s.Position, s.orderStore) + + if s.NotifyTrade { + s.tradeCollector.OnTrade(func(trade types.Trade, profit, netProfit fixedpoint.Value) { + qbtrade.Notify(trade) + }) + } + + s.tradeCollector.OnTrade(func(trade types.Trade, profit, netProfit fixedpoint.Value) { + c := trade.PositionChange() + if trade.Exchange == s.sourceSession.ExchangeName { + s.CoveredPosition = s.CoveredPosition.Add(c) + } + + s.ProfitStats.AddTrade(trade) + + if profit.Compare(fixedpoint.Zero) == 0 { + s.Environment.RecordPosition(s.Position, trade, nil) + } else { + log.Infof("%s generated profit: %v", s.Symbol, profit) + + p := s.Position.NewProfit(trade, profit, netProfit) + p.Strategy = ID + p.StrategyInstanceID = instanceID + qbtrade.Notify(&p) + s.ProfitStats.AddProfit(p) + + s.Environment.RecordPosition(s.Position, trade, &p) + } + }) + + s.tradeCollector.OnPositionUpdate(func(position *types.Position) { + qbtrade.Notify(position) + }) + s.tradeCollector.OnRecover(func(trade types.Trade) { + qbtrade.Notify("Recovered trade", trade) + }) + s.tradeCollector.BindStream(s.sourceSession.UserDataStream) + s.tradeCollector.BindStream(s.makerSession.UserDataStream) + + s.stopC = make(chan struct{}) + + if s.RecoverTrade { + go s.tradeRecover(ctx) + } + + go func() { + posTicker := time.NewTicker(util.MillisecondsJitter(s.HedgeInterval.Duration(), 200)) + defer posTicker.Stop() + + quoteTicker := time.NewTicker(util.MillisecondsJitter(s.UpdateInterval.Duration(), 200)) + defer quoteTicker.Stop() + + reportTicker := time.NewTicker(time.Hour) + defer reportTicker.Stop() + + defer func() { + if err := s.activeMakerOrders.GracefulCancel(context.Background(), s.makerSession.Exchange); err != nil { + log.WithError(err).Errorf("can not cancel %s orders", s.Symbol) + } + }() + + for { + select { + + case <-s.stopC: + log.Warnf("%s maker goroutine stopped, due to the stop signal", s.Symbol) + return + + case <-ctx.Done(): + log.Warnf("%s maker goroutine stopped, due to the cancelled context", s.Symbol) + return + + case <-quoteTicker.C: + s.updateQuote(ctx, orderExecutionRouter) + + case <-reportTicker.C: + qbtrade.Notify(s.ProfitStats) + + case <-posTicker.C: + // For positive position and positive covered position: + // uncover position = +5 - +3 (covered position) = 2 + // + // For positive position and negative covered position: + // uncover position = +5 - (-3) (covered position) = 8 + // + // meaning we bought 5 on MAX and sent buy order with 3 on binance + // + // For negative position: + // uncover position = -5 - -3 (covered position) = -2 + s.tradeCollector.Process() + + position := s.Position.GetBase() + + uncoverPosition := position.Sub(s.CoveredPosition) + absPos := uncoverPosition.Abs() + if !s.DisableHedge && absPos.Compare(s.sourceMarket.MinQuantity) > 0 { + log.Infof("%s base position %v coveredPosition: %v uncoverPosition: %v", + s.Symbol, + position, + s.CoveredPosition, + uncoverPosition, + ) + + s.Hedge(ctx, uncoverPosition.Neg()) + } + } + } + }() + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + close(s.stopC) + + // wait for the quoter to stop + time.Sleep(s.UpdateInterval.Duration()) + + shutdownCtx, cancelShutdown := context.WithTimeout(context.TODO(), time.Minute) + defer cancelShutdown() + + if err := s.activeMakerOrders.GracefulCancel(shutdownCtx, s.makerSession.Exchange); err != nil { + log.WithError(err).Errorf("graceful cancel error") + } + + qbtrade.Notify("%s: %s position", ID, s.Symbol, s.Position) + }) + + return nil +} diff --git a/pkg/strategy/xmaker/strategy_test.go b/pkg/strategy/xmaker/strategy_test.go new file mode 100644 index 0000000..d3b1db0 --- /dev/null +++ b/pkg/strategy/xmaker/strategy_test.go @@ -0,0 +1,36 @@ +package xmaker + +import ( + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "github.com/stretchr/testify/assert" +) + +func Test_aggregatePrice(t *testing.T) { + bids := types.PriceVolumeSlice{ + { + Price: fixedpoint.NewFromFloat(1000.0), + Volume: fixedpoint.NewFromFloat(1.0), + }, + { + Price: fixedpoint.NewFromFloat(1200.0), + Volume: fixedpoint.NewFromFloat(1.0), + }, + { + Price: fixedpoint.NewFromFloat(1400.0), + Volume: fixedpoint.NewFromFloat(1.0), + }, + } + + aggregatedPrice1 := aggregatePrice(bids, fixedpoint.NewFromFloat(0.5)) + assert.Equal(t, fixedpoint.NewFromFloat(1000.0), aggregatedPrice1) + + aggregatedPrice2 := aggregatePrice(bids, fixedpoint.NewFromInt(1)) + assert.Equal(t, fixedpoint.NewFromFloat(1000.0), aggregatedPrice2) + + aggregatedPrice3 := aggregatePrice(bids, fixedpoint.NewFromInt(2)) + assert.Equal(t, fixedpoint.NewFromFloat(1100.0), aggregatedPrice3) + +} diff --git a/pkg/strategy/xnav/csv.go b/pkg/strategy/xnav/csv.go new file mode 100644 index 0000000..d8793c5 --- /dev/null +++ b/pkg/strategy/xnav/csv.go @@ -0,0 +1 @@ +package xnav diff --git a/pkg/strategy/xnav/strategy.go b/pkg/strategy/xnav/strategy.go new file mode 100644 index 0000000..37b6639 --- /dev/null +++ b/pkg/strategy/xnav/strategy.go @@ -0,0 +1,202 @@ +package xnav + +import ( + "context" + "fmt" + "sync" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util/templateutil" + + "github.com/robfig/cron/v3" + "github.com/sirupsen/logrus" + "github.com/slack-go/slack" +) + +const ID = "xnav" + +var log = logrus.WithField("strategy", ID) + +func init() { + qbtrade.RegisterStrategy(ID, &Strategy{}) +} + +type State struct { + Since int64 `json:"since"` +} + +func (s *State) IsOver24Hours() bool { + return types.Over24Hours(time.Unix(s.Since, 0)) +} + +func (s *State) PlainText() string { + return templateutil.Render(`{{ .Asset }} transfer stats: +daily number of transfers: {{ .DailyNumberOfTransfers }} +daily amount of transfers {{ .DailyAmountOfTransfers.Float64 }}`, s) +} + +func (s *State) SlackAttachment() slack.Attachment { + return slack.Attachment{ + // Pretext: "", + // Text: text, + Fields: []slack.AttachmentField{}, + Footer: templateutil.Render("Since {{ . }}", time.Unix(s.Since, 0).Format(time.RFC822)), + } +} + +func (s *State) Reset() { + var beginningOfTheDay = types.BeginningOfTheDay(time.Now().Local()) + *s = State{ + Since: beginningOfTheDay.Unix(), + } +} + +type Strategy struct { + *qbtrade.Environment + + Interval types.Interval `json:"interval"` + Schedule string `json:"schedule"` + ReportOnStart bool `json:"reportOnStart"` + IgnoreDusts bool `json:"ignoreDusts"` + + State *State `persistence:"state"` + + cron *cron.Cron +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) Initialize() error { + return nil +} + +func (s *Strategy) Validate() error { + if s.Interval == "" && s.Schedule == "" { + return fmt.Errorf("interval or schedule is required") + } + return nil +} + +var Ten = fixedpoint.NewFromInt(10) + +func (s *Strategy) CrossSubscribe(sessions map[string]*qbtrade.ExchangeSession) {} + +func (s *Strategy) recordNetAssetValue(ctx context.Context, sessions map[string]*qbtrade.ExchangeSession) { + totalBalances := types.BalanceMap{} + allPrices := map[string]fixedpoint.Value{} + sessionBalances := map[string]types.BalanceMap{} + priceTime := time.Now() + + // iterate the sessions and record them + quoteCurrency := "USDT" + for sessionName, session := range sessions { + if session.PublicOnly { + log.Infof("session %s is public only, skip", sessionName) + continue + } + + // update the account balances and the margin information + if _, err := session.UpdateAccount(ctx); err != nil { + log.WithError(err).Errorf("can not update account") + return + } + + account := session.GetAccount() + balances := account.Balances() + if err := session.UpdatePrices(ctx, balances.Currencies(), quoteCurrency); err != nil { + log.WithError(err).Error("price update failed") + return + } + + sessionBalances[sessionName] = balances + totalBalances = totalBalances.Add(balances) + + prices := session.LastPrices() + assets := balances.Assets(prices, priceTime) + + // merge prices + for m, p := range prices { + allPrices[m] = p + } + + s.Environment.RecordAsset(priceTime, session, assets) + } + + displayAssets := types.AssetMap{} + totalAssets := totalBalances.Assets(allPrices, priceTime) + s.Environment.RecordAsset(priceTime, &qbtrade.ExchangeSession{Name: "ALL"}, totalAssets) + + for currency, asset := range totalAssets { + // calculated if it's dust only when InUSD (usd value) is defined. + if s.IgnoreDusts && !asset.InUSD.IsZero() && asset.InUSD.Compare(Ten) < 0 && asset.InUSD.Compare(Ten.Neg()) > 0 { + continue + } + + displayAssets[currency] = asset + } + + qbtrade.Notify(displayAssets) + + if s.State != nil { + if s.State.IsOver24Hours() { + s.State.Reset() + } + qbtrade.Sync(ctx, s) + } +} + +func (s *Strategy) CrossRun(ctx context.Context, _ qbtrade.OrderExecutionRouter, sessions map[string]*qbtrade.ExchangeSession) error { + if s.State == nil { + s.State = &State{} + s.State.Reset() + } + + qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + qbtrade.Sync(ctx, s) + }) + + if s.ReportOnStart { + s.recordNetAssetValue(ctx, sessions) + } + + if s.Environment.BacktestService != nil { + log.Warnf("xnav does not support backtesting") + } + + if s.Interval != "" { + go func() { + ticker := time.NewTicker(util.MillisecondsJitter(s.Interval.Duration(), 1000)) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + + case <-ticker.C: + s.recordNetAssetValue(ctx, sessions) + } + } + }() + + } else if s.Schedule != "" { + s.cron = cron.New() + _, err := s.cron.AddFunc(s.Schedule, func() { + s.recordNetAssetValue(ctx, sessions) + }) + if err != nil { + return err + } + s.cron.Start() + } + + return nil +} diff --git a/pkg/style/colors.go b/pkg/style/colors.go new file mode 100644 index 0000000..f9447fc --- /dev/null +++ b/pkg/style/colors.go @@ -0,0 +1,5 @@ +package style + +const GreenColor = "#228B22" +const RedColor = "#800000" +const GrayColor = "#f0f0f0" diff --git a/pkg/style/pnl.go b/pkg/style/pnl.go new file mode 100644 index 0000000..e93cd49 --- /dev/null +++ b/pkg/style/pnl.go @@ -0,0 +1,61 @@ +package style + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +var LossEmoji = "🔥" +var ProfitEmoji = "💰" +var DefaultPnLLevelResolution = fixedpoint.NewFromFloat(0.001) + +func PnLColor(pnl fixedpoint.Value) string { + if pnl.Sign() > 0 { + return GreenColor + } + return RedColor +} + +func PnLSignString(pnl fixedpoint.Value) string { + if pnl.Sign() > 0 { + return "+" + pnl.String() + } + return pnl.String() +} + +func PnLEmojiSimple(pnl fixedpoint.Value) string { + if pnl.Sign() < 0 { + return LossEmoji + } + + if pnl.IsZero() { + return "" + } + + return ProfitEmoji +} + +func PnLEmojiMargin(pnl, margin, resolution fixedpoint.Value) (out string) { + if margin.IsZero() { + return PnLEmojiSimple(pnl) + } + + if pnl.Sign() < 0 { + out = LossEmoji + level := (margin.Neg()).Div(resolution).Int() + for i := 1; i < level; i++ { + out += LossEmoji + } + return out + } + + if pnl.IsZero() { + return out + } + + out = ProfitEmoji + level := margin.Div(resolution).Int() + for i := 1; i < level; i++ { + out += ProfitEmoji + } + return out +} diff --git a/pkg/style/table.go b/pkg/style/table.go new file mode 100644 index 0000000..15f426d --- /dev/null +++ b/pkg/style/table.go @@ -0,0 +1,21 @@ +package style + +import ( + "github.com/jedib0t/go-pretty/v6/table" + "github.com/jedib0t/go-pretty/v6/text" +) + +func NewDefaultTableStyle() *table.Style { + style := table.Style{ + Name: "StyleRounded", + Box: table.StyleBoxRounded, + Format: table.FormatOptionsDefault, + HTML: table.DefaultHTMLOptions, + Options: table.OptionsDefault, + Title: table.TitleOptionsDefault, + Color: table.ColorOptionsYellowWhiteOnBlack, + } + style.Color.Row = text.Colors{text.FgHiYellow, text.BgHiBlack} + style.Color.RowAlternate = text.Colors{text.FgYellow, text.BgBlack} + return &style +} diff --git a/pkg/testing/httptesting/client.go b/pkg/testing/httptesting/client.go new file mode 100644 index 0000000..3ff094f --- /dev/null +++ b/pkg/testing/httptesting/client.go @@ -0,0 +1,68 @@ +package httptesting + +import ( + "encoding/json" + "net/http" + "os" +) + +// Simplied client for testing that doesn't require multiple URLs + +type EchoSave struct { + // saveTo provides a way for tests to verify http.Request fields. + + // An http.Client's transport layer has only one method, so there's no way to + // return variables while adhering to it's interface. One solution is to use + // type casting where the caller must know the transport layer is actually + // of type "EchoSave". But a cleaner approach is to pass in the address of + // a local variable, and store the http.Request there. + + // Callers provide the address of a local variable, which is stored here. + saveTo **http.Request + content string + err error +} + +func (st *EchoSave) RoundTrip(req *http.Request) (*http.Response, error) { + if st.saveTo != nil { + // If the caller provided a local variable, update it with the latest http.Request + *st.saveTo = req + } + resp := BuildResponseString(http.StatusOK, st.content) + SetHeader(resp, "Content-Type", "application/json") + return resp, st.err +} + +func HttpClientFromFile(filename string) *http.Client { + rawBytes, err := os.ReadFile(filename) + transport := EchoSave{err: err, content: string(rawBytes)} + return &http.Client{Transport: &transport} +} + +func HttpClientWithContent(content string) *http.Client { + transport := EchoSave{content: content} + return &http.Client{Transport: &transport} +} + +func HttpClientWithError(err error) *http.Client { + transport := EchoSave{err: err} + return &http.Client{Transport: &transport} +} + +func HttpClientWithJson(jsonData interface{}) *http.Client { + jsonBytes, err := json.Marshal(jsonData) + transport := EchoSave{err: err, content: string(jsonBytes)} + return &http.Client{Transport: &transport} +} + +// "Saver" refers to saving the *http.Request in a local variable provided by the caller. +func HttpClientSaver(saved **http.Request, content string) *http.Client { + transport := EchoSave{saveTo: saved, content: content} + return &http.Client{Transport: &transport} +} + +func HttpClientSaverWithJson(saved **http.Request, jsonData interface{}) *http.Client { + jsonBytes, err := json.Marshal(jsonData) + transport := EchoSave{saveTo: saved, err: err, content: string(jsonBytes)} + return &http.Client{Transport: &transport} +} diff --git a/pkg/testing/httptesting/response.go b/pkg/testing/httptesting/response.go new file mode 100644 index 0000000..77b7f21 --- /dev/null +++ b/pkg/testing/httptesting/response.go @@ -0,0 +1,54 @@ +package httptesting + +import ( + "bytes" + "encoding/json" + "io" + "net/http" +) + +func BuildResponse(code int, payload []byte) *http.Response { + return &http.Response{ + StatusCode: code, + Body: io.NopCloser(bytes.NewBuffer(payload)), + ContentLength: int64(len(payload)), + } +} + +func BuildResponseString(code int, payload string) *http.Response { + b := []byte(payload) + return &http.Response{ + StatusCode: code, + Body: io.NopCloser( + bytes.NewBuffer(b), + ), + ContentLength: int64(len(b)), + } +} + +func BuildResponseJson(code int, payload interface{}) *http.Response { + data, err := json.Marshal(payload) + if err != nil { + return BuildResponseString(http.StatusInternalServerError, `{error: "httptesting.MockTransport error calling json.Marshal()"}`) + } + + resp := BuildResponse(code, data) + resp.Header = http.Header{} + resp.Header.Set("Content-Type", "application/json") + return resp +} + +func SetHeader(resp *http.Response, name string, value string) *http.Response { + if resp.Header == nil { + resp.Header = http.Header{} + } + resp.Header.Set(name, value) + return resp +} + +func DeleteHeader(resp *http.Response, name string) *http.Response { + if resp.Header != nil { + resp.Header.Del(name) + } + return resp +} diff --git a/pkg/testing/httptesting/transport.go b/pkg/testing/httptesting/transport.go new file mode 100644 index 0000000..f4ed47d --- /dev/null +++ b/pkg/testing/httptesting/transport.go @@ -0,0 +1,98 @@ +package httptesting + +import ( + "net/http" + "strings" + + "github.com/pkg/errors" +) + +type RoundTripFunc func(req *http.Request) (*http.Response, error) + +type MockTransport struct { + getHandlers map[string]RoundTripFunc + postHandlers map[string]RoundTripFunc + deleteHandlers map[string]RoundTripFunc + putHandlers map[string]RoundTripFunc +} + +func (transport *MockTransport) GET(path string, f RoundTripFunc) { + if transport.getHandlers == nil { + transport.getHandlers = make(map[string]RoundTripFunc) + } + + transport.getHandlers[path] = f +} + +func (transport *MockTransport) POST(path string, f RoundTripFunc) { + if transport.postHandlers == nil { + transport.postHandlers = make(map[string]RoundTripFunc) + } + + transport.postHandlers[path] = f +} + +func (transport *MockTransport) DELETE(path string, f RoundTripFunc) { + if transport.deleteHandlers == nil { + transport.deleteHandlers = make(map[string]RoundTripFunc) + } + + transport.deleteHandlers[path] = f +} + +func (transport *MockTransport) PUT(path string, f RoundTripFunc) { + if transport.putHandlers == nil { + transport.putHandlers = make(map[string]RoundTripFunc) + } + + transport.putHandlers[path] = f +} + +// Used for migration to MAX v3 api, where order cancel uses DELETE (MAX v2 api uses POST). +func (transport *MockTransport) PostOrDelete(isDelete bool, path string, f RoundTripFunc) { + if isDelete { + transport.DELETE(path, f) + } else { + transport.POST(path, f) + } +} + +func (transport *MockTransport) RoundTrip(req *http.Request) (*http.Response, error) { + var handlers map[string]RoundTripFunc + + switch strings.ToUpper(req.Method) { + + case "GET": + handlers = transport.getHandlers + case "POST": + handlers = transport.postHandlers + case "DELETE": + handlers = transport.deleteHandlers + case "PUT": + handlers = transport.putHandlers + + default: + return nil, errors.Errorf("unsupported mock transport request method: %s", req.Method) + + } + + f, ok := handlers[req.URL.Path] + if !ok { + return nil, errors.Errorf("roundtrip mock to %s %s is not defined", req.Method, req.URL.Path) + } + + return f(req) +} + +func MockWithJsonReply(url string, rawData interface{}) *http.Client { + tripFunc := func(_ *http.Request) (*http.Response, error) { + return BuildResponseJson(http.StatusOK, rawData), nil + } + + transport := &MockTransport{} + transport.DELETE(url, tripFunc) + transport.GET(url, tripFunc) + transport.POST(url, tripFunc) + transport.PUT(url, tripFunc) + return &http.Client{Transport: transport} +} diff --git a/pkg/testing/testhelper/assert_priceside.go b/pkg/testing/testhelper/assert_priceside.go new file mode 100644 index 0000000..4abf6b3 --- /dev/null +++ b/pkg/testing/testhelper/assert_priceside.go @@ -0,0 +1,58 @@ +package testhelper + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type PriceSideAssert struct { + Price fixedpoint.Value + Side types.SideType +} + +// AssertOrdersPriceSide asserts the orders with the given price and side (slice) +func AssertOrdersPriceSide(t *testing.T, asserts []PriceSideAssert, orders []types.SubmitOrder) { + for i, a := range asserts { + assert.Equalf(t, a.Price, orders[i].Price, "order #%d price should be %f", i+1, a.Price.Float64()) + assert.Equalf(t, a.Side, orders[i].Side, "order at price %f should be %s", a.Price.Float64(), a.Side) + } +} + +type PriceSideQuantityAssert struct { + Price fixedpoint.Value + Side types.SideType + Quantity fixedpoint.Value +} + +// AssertOrdersPriceSide asserts the orders with the given price and side (slice) +func AssertOrdersPriceSideQuantity( + t *testing.T, asserts []PriceSideQuantityAssert, orders []types.SubmitOrder, +) { + assert.Equalf(t, len(asserts), len(orders), "expecting %d orders", len(asserts)) + + var assertPrices, orderPrices fixedpoint.Slice + var assertPricesFloat, orderPricesFloat []float64 + for _, a := range asserts { + assertPrices = append(assertPrices, a.Price) + assertPricesFloat = append(assertPricesFloat, a.Price.Float64()) + } + + for _, o := range orders { + orderPrices = append(orderPrices, o.Price) + orderPricesFloat = append(orderPricesFloat, o.Price.Float64()) + } + + if !assert.Equalf(t, assertPricesFloat, orderPricesFloat, "assert prices") { + return + } + + for i, a := range asserts { + assert.Equalf(t, a.Price.Float64(), orders[i].Price.Float64(), "order #%d price should be %f", i+1, a.Price.Float64()) + assert.Equalf(t, a.Quantity.Float64(), orders[i].Quantity.Float64(), "order #%d quantity should be %f", i+1, a.Quantity.Float64()) + assert.Equalf(t, a.Side, orders[i].Side, "order at price %f should be %s", a.Price.Float64(), a.Side) + } +} diff --git a/pkg/testing/testhelper/number.go b/pkg/testing/testhelper/number.go new file mode 100644 index 0000000..fd353f9 --- /dev/null +++ b/pkg/testing/testhelper/number.go @@ -0,0 +1,18 @@ +package testhelper + +import "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + +func Number(a interface{}) fixedpoint.Value { + switch v := a.(type) { + case string: + return fixedpoint.MustNewFromString(v) + case int: + return fixedpoint.NewFromInt(int64(v)) + case int64: + return fixedpoint.NewFromInt(int64(v)) + case float64: + return fixedpoint.NewFromFloat(v) + } + + return fixedpoint.Zero +} diff --git a/pkg/testutil/auth.go b/pkg/testutil/auth.go new file mode 100644 index 0000000..a4fae74 --- /dev/null +++ b/pkg/testutil/auth.go @@ -0,0 +1,40 @@ +package testutil + +import ( + "os" + "regexp" + "strings" + "testing" +) + +func maskSecret(s string) string { + re := regexp.MustCompile(`\b(\w{4})\w+\b`) + s = re.ReplaceAllString(s, "$1******") + return s +} + +func IntegrationTestConfigured(t *testing.T, prefix string) (key, secret string, ok bool) { + var hasKey, hasSecret bool + key, hasKey = os.LookupEnv(prefix + "_API_KEY") + secret, hasSecret = os.LookupEnv(prefix + "_API_SECRET") + ok = hasKey && hasSecret && os.Getenv("TEST_"+prefix) == "1" + if ok { + t.Logf(prefix+" api integration test enabled, key = %s, secret = %s", maskSecret(key), maskSecret(secret)) + } + + return key, secret, ok +} + +func IntegrationTestWithPassphraseConfigured(t *testing.T, prefix string) (key, secret, passphrase string, ok bool) { + var hasKey, hasSecret, hasPassphrase bool + prefix = strings.ToUpper(prefix) + key, hasKey = os.LookupEnv(prefix + "_API_KEY") + secret, hasSecret = os.LookupEnv(prefix + "_API_SECRET") + passphrase, hasPassphrase = os.LookupEnv(prefix + "_API_PASSPHRASE") + ok = hasKey && hasSecret && hasPassphrase && os.Getenv("TEST_"+prefix) == "1" + if ok { + t.Logf(prefix+" api integration test enabled, key = %s, secret = %s, passphrase= %s", maskSecret(key), maskSecret(secret), maskSecret(passphrase)) + } + + return key, secret, passphrase, ok +} diff --git a/pkg/types/account.go b/pkg/types/account.go new file mode 100644 index 0000000..84cfe45 --- /dev/null +++ b/pkg/types/account.go @@ -0,0 +1,255 @@ +package types + +import ( + "fmt" + "sync" + + "github.com/sirupsen/logrus" + "github.com/spf13/viper" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +var debugBalance = false + +func init() { + debugBalance = viper.GetBool("debug-balance") +} + +type PositionMap map[string]Position +type IsolatedMarginAssetMap map[string]IsolatedMarginAsset +type MarginAssetMap map[string]MarginUserAsset +type FuturesAssetMap map[string]FuturesUserAsset +type FuturesPositionMap map[string]FuturesPosition + +type AccountType string + +const ( + AccountTypeFutures = AccountType("futures") + AccountTypeMargin = AccountType("margin") + AccountTypeIsolatedMargin = AccountType("isolated_margin") + AccountTypeSpot = AccountType("spot") +) + +type Account struct { + sync.Mutex `json:"-"` + + AccountType AccountType `json:"accountType,omitempty"` + FuturesInfo *FuturesAccountInfo + MarginInfo *MarginAccountInfo + IsolatedMarginInfo *IsolatedMarginAccountInfo + + // Margin related common field + // From binance: + // Margin Level = Total Asset Value / (Total Borrowed + Total Accrued Interest) + // If your margin level drops to 1.3, you will receive a Margin Call, which is a reminder that you should either increase your collateral (by depositing more funds) or reduce your loan (by repaying what you’ve borrowed). + // If your margin level drops to 1.1, your assets will be automatically liquidated, meaning that Binance will sell your funds at market price to repay the loan. + MarginLevel fixedpoint.Value `json:"marginLevel,omitempty"` + MarginTolerance fixedpoint.Value `json:"marginTolerance,omitempty"` + + BorrowEnabled bool `json:"borrowEnabled,omitempty"` + TransferEnabled bool `json:"transferEnabled,omitempty"` + + // isolated margin related fields + // LiquidationPrice is only used when account is in the isolated margin mode + MarginRatio fixedpoint.Value `json:"marginRatio,omitempty"` + LiquidationPrice fixedpoint.Value `json:"liquidationPrice,omitempty"` + LiquidationRate fixedpoint.Value `json:"liquidationRate,omitempty"` + + MakerFeeRate fixedpoint.Value `json:"makerFeeRate,omitempty"` + TakerFeeRate fixedpoint.Value `json:"takerFeeRate,omitempty"` + + TotalAccountValue fixedpoint.Value `json:"totalAccountValue,omitempty"` + + CanDeposit bool `json:"canDeposit"` + CanTrade bool `json:"canTrade"` + CanWithdraw bool `json:"canWithdraw"` + + balances BalanceMap +} + +type FuturesAccountInfo struct { + // Futures fields + Assets FuturesAssetMap `json:"assets"` + Positions FuturesPositionMap `json:"positions"` + TotalInitialMargin fixedpoint.Value `json:"totalInitialMargin"` + TotalMaintMargin fixedpoint.Value `json:"totalMaintMargin"` + TotalMarginBalance fixedpoint.Value `json:"totalMarginBalance"` + TotalOpenOrderInitialMargin fixedpoint.Value `json:"totalOpenOrderInitialMargin"` + TotalPositionInitialMargin fixedpoint.Value `json:"totalPositionInitialMargin"` + TotalUnrealizedProfit fixedpoint.Value `json:"totalUnrealizedProfit"` + TotalWalletBalance fixedpoint.Value `json:"totalWalletBalance"` + UpdateTime int64 `json:"updateTime"` +} + +type MarginAccountInfo struct { + // Margin fields + BorrowEnabled bool `json:"borrowEnabled"` + MarginLevel fixedpoint.Value `json:"marginLevel"` + TotalAssetOfBTC fixedpoint.Value `json:"totalAssetOfBtc"` + TotalLiabilityOfBTC fixedpoint.Value `json:"totalLiabilityOfBtc"` + TotalNetAssetOfBTC fixedpoint.Value `json:"totalNetAssetOfBtc"` + TradeEnabled bool `json:"tradeEnabled"` + TransferEnabled bool `json:"transferEnabled"` + Assets MarginAssetMap `json:"userAssets"` +} + +type IsolatedMarginAccountInfo struct { + TotalAssetOfBTC fixedpoint.Value `json:"totalAssetOfBtc"` + TotalLiabilityOfBTC fixedpoint.Value `json:"totalLiabilityOfBtc"` + TotalNetAssetOfBTC fixedpoint.Value `json:"totalNetAssetOfBtc"` + Assets IsolatedMarginAssetMap `json:"userAssets"` +} + +func NewAccount() *Account { + return &Account{ + AccountType: "spot", + FuturesInfo: nil, + MarginInfo: nil, + IsolatedMarginInfo: nil, + MarginLevel: fixedpoint.Zero, + MarginTolerance: fixedpoint.Zero, + BorrowEnabled: false, + TransferEnabled: false, + MarginRatio: fixedpoint.Zero, + LiquidationPrice: fixedpoint.Zero, + LiquidationRate: fixedpoint.Zero, + MakerFeeRate: fixedpoint.Zero, + TakerFeeRate: fixedpoint.Zero, + TotalAccountValue: fixedpoint.Zero, + CanDeposit: false, + CanTrade: false, + CanWithdraw: false, + balances: make(BalanceMap), + } + +} + +// Balances lock the balances and returned the copied balances +func (a *Account) Balances() (d BalanceMap) { + a.Lock() + d = a.balances.Copy() + a.Unlock() + return d +} + +func (a *Account) Balance(currency string) (balance Balance, ok bool) { + a.Lock() + balance, ok = a.balances[currency] + a.Unlock() + return balance, ok +} + +func (a *Account) AddBalance(currency string, fund fixedpoint.Value) { + a.Lock() + defer a.Unlock() + + balance, ok := a.balances[currency] + if ok { + balance.Available = balance.Available.Add(fund) + a.balances[currency] = balance + return + } + + a.balances[currency] = Balance{ + Currency: currency, + Available: fund, + Locked: fixedpoint.Zero, + } +} + +func (a *Account) UseLockedBalance(currency string, fund fixedpoint.Value) error { + a.Lock() + defer a.Unlock() + + balance, ok := a.balances[currency] + if !ok { + return fmt.Errorf("account balance %s does not exist", currency) + } + + // simple case, using fund less than locked + if balance.Locked.Compare(fund) >= 0 { + balance.Locked = balance.Locked.Sub(fund) + a.balances[currency] = balance + return nil + } + + return fmt.Errorf("trying to use more than locked: locked %v < want to use %v diff %v", balance.Locked, fund, balance.Locked.Sub(fund)) +} + +var QuantityDelta = fixedpoint.MustNewFromString("0.00000000001") + +func (a *Account) UnlockBalance(currency string, unlocked fixedpoint.Value) error { + a.Lock() + defer a.Unlock() + + balance, ok := a.balances[currency] + if !ok { + return fmt.Errorf("trying to unlocked inexisted balance: %s", currency) + } + + // Instead of showing error in UnlockBalance, + // since this function is only called when cancel orders, + // there might be inequivalence in the last order quantity + if unlocked.Compare(balance.Locked) > 0 { + // check if diff is within delta + if unlocked.Sub(balance.Locked).Compare(QuantityDelta) <= 0 { + balance.Available = balance.Available.Add(balance.Locked) + balance.Locked = fixedpoint.Zero + a.balances[currency] = balance + return nil + } + return fmt.Errorf("trying to unlocked more than locked %s: locked %v < want to unlock %v", currency, balance.Locked, unlocked) + } + + balance.Locked = balance.Locked.Sub(unlocked) + balance.Available = balance.Available.Add(unlocked) + a.balances[currency] = balance + return nil +} + +func (a *Account) LockBalance(currency string, locked fixedpoint.Value) error { + a.Lock() + defer a.Unlock() + + balance, ok := a.balances[currency] + if ok && balance.Available.Compare(locked) >= 0 { + balance.Locked = balance.Locked.Add(locked) + balance.Available = balance.Available.Sub(locked) + a.balances[currency] = balance + return nil + } + + return fmt.Errorf("insufficient available balance %s for lock: want to lock %v, available %v", currency, locked, balance.Available) +} + +func (a *Account) UpdateBalances(balances BalanceMap) { + a.Lock() + defer a.Unlock() + + if a.balances == nil { + a.balances = make(BalanceMap) + } + + for _, balance := range balances { + a.balances[balance.Currency] = balance + } +} + +func (a *Account) Print() { + a.Lock() + defer a.Unlock() + + if a.AccountType != "" { + logrus.Infof("account type: %s", a.AccountType) + } + + if a.MakerFeeRate.Sign() > 0 { + logrus.Infof("maker fee rate: %v", a.MakerFeeRate) + } + if a.TakerFeeRate.Sign() > 0 { + logrus.Infof("taker fee rate: %v", a.TakerFeeRate) + } + + a.balances.Print() +} diff --git a/pkg/types/account_test.go b/pkg/types/account_test.go new file mode 100644 index 0000000..6c177f0 --- /dev/null +++ b/pkg/types/account_test.go @@ -0,0 +1,62 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +func TestAccountLockAndUnlock(t *testing.T) { + a := NewAccount() + a.AddBalance("USDT", fixedpoint.NewFromInt(1000)) + + var err error + balance, ok := a.Balance("USDT") + assert.True(t, ok) + assert.Equal(t, balance.Available, fixedpoint.NewFromInt(1000)) + assert.Equal(t, balance.Locked, fixedpoint.Zero) + + err = a.LockBalance("USDT", fixedpoint.NewFromInt(100)) + assert.NoError(t, err) + + balance, ok = a.Balance("USDT") + assert.True(t, ok) + assert.Equal(t, balance.Available, fixedpoint.NewFromInt(900)) + assert.Equal(t, balance.Locked, fixedpoint.NewFromInt(100)) + + err = a.UnlockBalance("USDT", fixedpoint.NewFromInt(100)) + assert.NoError(t, err) + balance, ok = a.Balance("USDT") + assert.True(t, ok) + assert.Equal(t, balance.Available, fixedpoint.NewFromInt(1000)) + assert.Equal(t, balance.Locked, fixedpoint.Zero) +} + +func TestAccountLockAndUse(t *testing.T) { + a := NewAccount() + a.AddBalance("USDT", fixedpoint.NewFromInt(1000)) + + var err error + balance, ok := a.Balance("USDT") + assert.True(t, ok) + assert.Equal(t, balance.Available, fixedpoint.NewFromInt(1000)) + assert.Equal(t, balance.Locked, fixedpoint.Zero) + + err = a.LockBalance("USDT", fixedpoint.NewFromInt(100)) + assert.NoError(t, err) + + balance, ok = a.Balance("USDT") + assert.True(t, ok) + assert.Equal(t, balance.Available, fixedpoint.NewFromInt(900)) + assert.Equal(t, balance.Locked, fixedpoint.NewFromInt(100)) + + err = a.UseLockedBalance("USDT", fixedpoint.NewFromInt(100)) + assert.NoError(t, err) + + balance, ok = a.Balance("USDT") + assert.True(t, ok) + assert.Equal(t, balance.Available, fixedpoint.NewFromInt(900)) + assert.Equal(t, balance.Locked, fixedpoint.Zero) +} diff --git a/pkg/types/asset.go b/pkg/types/asset.go new file mode 100644 index 0000000..ca79ed9 --- /dev/null +++ b/pkg/types/asset.go @@ -0,0 +1,148 @@ +package types + +import ( + "fmt" + "sort" + "time" + + "github.com/slack-go/slack" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +type Asset struct { + Currency string `json:"currency" db:"currency"` + + Total fixedpoint.Value `json:"total" db:"total"` + + NetAsset fixedpoint.Value `json:"netAsset" db:"net_asset"` + + Interest fixedpoint.Value `json:"interest" db:"interest"` + + // InUSD is net asset in USD + InUSD fixedpoint.Value `json:"inUSD" db:"net_asset_in_usd"` + + // InBTC is net asset in BTC + InBTC fixedpoint.Value `json:"inBTC" db:"net_asset_in_btc"` + + Time time.Time `json:"time" db:"time"` + Locked fixedpoint.Value `json:"lock" db:"lock" ` + Available fixedpoint.Value `json:"available" db:"available"` + Borrowed fixedpoint.Value `json:"borrowed" db:"borrowed"` + PriceInUSD fixedpoint.Value `json:"priceInUSD" db:"price_in_usd"` +} + +type AssetMap map[string]Asset + +func (m AssetMap) InUSD() (total fixedpoint.Value) { + for _, a := range m { + if a.InUSD.IsZero() { + continue + } + + total = total.Add(a.InUSD) + } + return total +} + +func (m AssetMap) PlainText() (o string) { + var assets = m.Slice() + + // sort assets + sort.Slice(assets, func(i, j int) bool { + return assets[i].InUSD.Compare(assets[j].InUSD) > 0 + }) + + sumUsd := fixedpoint.Zero + sumBTC := fixedpoint.Zero + for _, a := range assets { + usd := a.InUSD + btc := a.InBTC + if !a.InUSD.IsZero() { + o += fmt.Sprintf(" %s: %s (≈ %s) (≈ %s)", + a.Currency, + a.NetAsset.String(), + USD.FormatMoney(usd), + BTC.FormatMoney(btc), + ) + "\n" + sumUsd = sumUsd.Add(usd) + sumBTC = sumBTC.Add(btc) + } else { + o += fmt.Sprintf(" %s: %s", + a.Currency, + a.NetAsset.String(), + ) + "\n" + } + } + + o += fmt.Sprintf("Net Asset Value: (≈ %s) (≈ %s)", + USD.FormatMoney(sumUsd), + BTC.FormatMoney(sumBTC), + ) + return o +} + +func (m AssetMap) Slice() (assets []Asset) { + for _, a := range m { + assets = append(assets, a) + } + return assets +} + +func (m AssetMap) SlackAttachment() slack.Attachment { + var fields []slack.AttachmentField + var netAssetInBTC, netAssetInUSD fixedpoint.Value + + var assets = m.Slice() + + // sort assets + sort.Slice(assets, func(i, j int) bool { + return assets[i].InUSD.Compare(assets[j].InUSD) > 0 + }) + + for _, a := range assets { + netAssetInUSD = netAssetInUSD.Add(a.InUSD) + netAssetInBTC = netAssetInBTC.Add(a.InBTC) + } + + for _, a := range assets { + if !a.InUSD.IsZero() { + text := fmt.Sprintf("%s (≈ %s) (≈ %s) (%s)", + a.NetAsset.String(), + USD.FormatMoney(a.InUSD), + BTC.FormatMoney(a.InBTC), + a.InUSD.Div(netAssetInUSD).FormatPercentage(2), + ) + + if !a.Borrowed.IsZero() { + text += fmt.Sprintf(" Borrowed: %s", a.Borrowed.String()) + } + + fields = append(fields, slack.AttachmentField{ + Title: a.Currency, + Value: text, + Short: false, + }) + } else { + text := a.NetAsset.String() + + if !a.Borrowed.IsZero() { + text += fmt.Sprintf(" Borrowed: %s", a.Borrowed.String()) + } + + fields = append(fields, slack.AttachmentField{ + Title: a.Currency, + Value: text, + Short: false, + }) + } + } + + return slack.Attachment{ + Title: fmt.Sprintf("Net Asset Value %s (≈ %s)", + USD.FormatMoney(netAssetInUSD), + BTC.FormatMoney(netAssetInBTC), + ), + Fields: fields, + } +} diff --git a/pkg/types/backtest_stream.go b/pkg/types/backtest_stream.go new file mode 100644 index 0000000..dadd0c1 --- /dev/null +++ b/pkg/types/backtest_stream.go @@ -0,0 +1,20 @@ +package types + +import ( + "context" +) + +type BacktestStream struct { + StandardStreamEmitter +} + +func (s *BacktestStream) Connect(ctx context.Context) error { + s.EmitConnect() + s.EmitStart() + s.EmitAuth() + return nil +} + +func (s *BacktestStream) Close() error { + return nil +} diff --git a/pkg/types/balance.go b/pkg/types/balance.go new file mode 100644 index 0000000..819879b --- /dev/null +++ b/pkg/types/balance.go @@ -0,0 +1,279 @@ +package types + +import ( + "fmt" + "strconv" + "strings" + "time" + + log "github.com/sirupsen/logrus" + "github.com/slack-go/slack" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +type PriceMap map[string]fixedpoint.Value + +type Balance struct { + Currency string `json:"currency"` + Available fixedpoint.Value `json:"available"` + Locked fixedpoint.Value `json:"locked,omitempty"` + + // margin related fields + Borrowed fixedpoint.Value `json:"borrowed,omitempty"` + Interest fixedpoint.Value `json:"interest,omitempty"` + + // NetAsset = (Available + Locked) - Borrowed - Interest + NetAsset fixedpoint.Value `json:"net,omitempty"` + + MaxWithdrawAmount fixedpoint.Value `json:"maxWithdrawAmount,omitempty"` +} + +func (b Balance) Add(b2 Balance) Balance { + var newB = b + newB.Available = b.Available.Add(b2.Available) + newB.Locked = b.Locked.Add(b2.Locked) + newB.Borrowed = b.Borrowed.Add(b2.Borrowed) + newB.NetAsset = b.NetAsset.Add(b2.NetAsset) + newB.Interest = b.Interest.Add(b2.Interest) + return newB +} + +func (b Balance) Total() fixedpoint.Value { + return b.Available.Add(b.Locked) +} + +// Net returns the net asset value (total - debt) +func (b Balance) Net() fixedpoint.Value { + total := b.Total() + return total.Sub(b.Debt()) +} + +func (b Balance) Debt() fixedpoint.Value { + return b.Borrowed.Add(b.Interest) +} + +func (b Balance) ValueString() (o string) { + o = b.Net().String() + + if b.Locked.Sign() > 0 { + o += fmt.Sprintf(" (locked %v)", b.Locked) + } + + if b.Borrowed.Sign() > 0 { + o += fmt.Sprintf(" (borrowed: %v)", b.Borrowed) + } + + return o +} + +func (b Balance) String() (o string) { + o = fmt.Sprintf("%s: %s", b.Currency, b.Net().String()) + + if b.Locked.Sign() > 0 { + o += fmt.Sprintf(" (locked %f)", b.Locked.Float64()) + } + + if b.Borrowed.Sign() > 0 { + o += fmt.Sprintf(" (borrowed: %f)", b.Borrowed.Float64()) + } + + if b.Interest.Sign() > 0 { + o += fmt.Sprintf(" (interest: %f)", b.Interest.Float64()) + } + + return o +} + +type BalanceSnapshot struct { + Balances BalanceMap `json:"balances"` + Session string `json:"session"` + Time time.Time `json:"time"` +} + +func (m BalanceSnapshot) CsvHeader() []string { + return []string{"time", "session", "currency", "available", "locked", "borrowed"} +} + +func (m BalanceSnapshot) CsvRecords() [][]string { + var records [][]string + + for cur, b := range m.Balances { + records = append(records, []string{ + strconv.FormatInt(m.Time.Unix(), 10), + m.Session, + cur, + b.Available.String(), + b.Locked.String(), + b.Borrowed.String(), + }) + } + + return records +} + +type BalanceMap map[string]Balance + +func (m BalanceMap) NotZero() BalanceMap { + bm := make(BalanceMap) + for c, b := range m { + if b.Total().IsZero() && b.Debt().IsZero() && b.Net().IsZero() { + continue + } + + bm[c] = b + } + return bm +} + +func (m BalanceMap) Debts() BalanceMap { + bm := make(BalanceMap) + for c, b := range m { + if b.Borrowed.Sign() > 0 || b.Interest.Sign() > 0 { + bm[c] = b + } + } + return bm +} + +func (m BalanceMap) Currencies() (currencies []string) { + for _, b := range m { + currencies = append(currencies, b.Currency) + } + return currencies +} + +func (m BalanceMap) Add(bm BalanceMap) BalanceMap { + var total = m.Copy() + for _, b := range bm { + tb, ok := total[b.Currency] + if ok { + tb = tb.Add(b) + } else { + tb = b + } + total[b.Currency] = tb + } + return total +} + +func (m BalanceMap) String() string { + var ss []string + for _, b := range m { + ss = append(ss, b.String()) + } + + return "BalanceMap[" + strings.Join(ss, ", ") + "]" +} + +func (m BalanceMap) Copy() (d BalanceMap) { + d = make(BalanceMap) + for c, b := range m { + d[c] = b + } + return d +} + +// Assets converts balances into assets with the given prices +func (m BalanceMap) Assets(prices PriceMap, priceTime time.Time) AssetMap { + assets := make(AssetMap) + + _, btcInUSD, hasBtcPrice := findUSDMarketPrice("BTC", prices) + + for currency, b := range m { + total := b.Total() + netAsset := b.Net() + + if total.IsZero() && netAsset.IsZero() { + continue + } + + asset := Asset{ + Currency: currency, + Total: total, + Time: priceTime, + Locked: b.Locked, + Available: b.Available, + Borrowed: b.Borrowed, + Interest: b.Interest, + NetAsset: netAsset, + } + + if IsUSDFiatCurrency(currency) { // for usd + asset.InUSD = netAsset + asset.PriceInUSD = fixedpoint.One + if hasBtcPrice && !asset.InUSD.IsZero() { + asset.InBTC = asset.InUSD.Div(btcInUSD) + } + } else { // for crypto + if market, usdPrice, ok := findUSDMarketPrice(currency, prices); ok { + // this includes USDT, USD, USDC and so on + if strings.HasPrefix(market, "USD") || strings.HasPrefix(market, "BUSD") { // for prices like USDT/TWD, BUSD/USDT + if !asset.NetAsset.IsZero() { + asset.InUSD = asset.NetAsset.Div(usdPrice) + } + asset.PriceInUSD = fixedpoint.One.Div(usdPrice) + } else { // for prices like BTC/USDT + if !asset.NetAsset.IsZero() { + asset.InUSD = asset.NetAsset.Mul(usdPrice) + } + asset.PriceInUSD = usdPrice + } + + if hasBtcPrice && !asset.InUSD.IsZero() { + asset.InBTC = asset.InUSD.Div(btcInUSD) + } + } + } + + assets[currency] = asset + } + + return assets +} + +func (m BalanceMap) Print() { + for _, balance := range m { + if balance.Net().IsZero() { + continue + } + + o := fmt.Sprintf(" %s: %v", balance.Currency, balance.Available) + if balance.Locked.Sign() > 0 { + o += fmt.Sprintf(" (locked %v)", balance.Locked) + } + + if balance.Borrowed.Sign() > 0 { + o += fmt.Sprintf(" (borrowed %v)", balance.Borrowed) + } + + log.Infoln(o) + } +} + +func (m BalanceMap) SlackAttachment() slack.Attachment { + var fields []slack.AttachmentField + + for _, b := range m { + fields = append(fields, slack.AttachmentField{ + Title: b.Currency, + Value: b.ValueString(), + Short: true, + }) + } + + return slack.Attachment{ + Color: "#CCA33F", + Fields: fields, + } +} + +func findUSDMarketPrice(currency string, prices map[string]fixedpoint.Value) (string, fixedpoint.Value, bool) { + usdMarkets := []string{currency + "USDT", currency + "USDC", currency + "USD", "USDT" + currency} + for _, market := range usdMarkets { + if usdPrice, ok := prices[market]; ok { + return market, usdPrice, ok + } + } + return "", fixedpoint.Zero, false +} diff --git a/pkg/types/balance_test.go b/pkg/types/balance_test.go new file mode 100644 index 0000000..0d50147 --- /dev/null +++ b/pkg/types/balance_test.go @@ -0,0 +1,98 @@ +package types + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +func TestBalanceMap_Add(t *testing.T) { + var bm = BalanceMap{} + var bm2 = bm.Add(BalanceMap{ + "BTC": Balance{ + Currency: "BTC", + Available: fixedpoint.MustNewFromString("10.0"), + Locked: fixedpoint.MustNewFromString("0"), + NetAsset: fixedpoint.MustNewFromString("10.0"), + }, + }) + assert.Len(t, bm2, 1) + + var bm3 = bm2.Add(BalanceMap{ + "BTC": Balance{ + Currency: "BTC", + Available: fixedpoint.MustNewFromString("1.0"), + Locked: fixedpoint.MustNewFromString("0"), + NetAsset: fixedpoint.MustNewFromString("1.0"), + }, + "LTC": Balance{ + Currency: "LTC", + Available: fixedpoint.MustNewFromString("20.0"), + Locked: fixedpoint.MustNewFromString("0"), + NetAsset: fixedpoint.MustNewFromString("20.0"), + }, + }) + assert.Len(t, bm3, 2) + assert.Equal(t, fixedpoint.MustNewFromString("11.0"), bm3["BTC"].Available) +} + +func TestBalanceMap_Assets(t *testing.T) { + type args struct { + prices PriceMap + priceTime time.Time + } + tests := []struct { + name string + m BalanceMap + args args + want AssetMap + }{ + { + m: BalanceMap{ + "USDT": Balance{Currency: "USDT", Available: number(100.0)}, + "BTC": Balance{Currency: "BTC", Borrowed: number(2.0)}, + }, + args: args{ + prices: PriceMap{ + "BTCUSDT": number(19000.0), + }, + }, + want: AssetMap{ + "USDT": { + Currency: "USDT", + Total: number(100), + NetAsset: number(100.0), + Interest: number(0), + InUSD: number(100.0), + InBTC: number(100.0 / 19000.0), + Time: time.Time{}, + Locked: number(0), + Available: number(100.0), + Borrowed: number(0), + PriceInUSD: number(1.0), + }, + "BTC": { + Currency: "BTC", + Total: number(0), + NetAsset: number(-2), + Interest: number(0), + InUSD: number(-2 * 19000.0), + InBTC: number(-2), + Time: time.Time{}, + Locked: number(0), + Available: number(0), + Borrowed: number(2), + PriceInUSD: number(19000.0), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, tt.m.Assets(tt.args.prices, tt.args.priceTime), "Assets(%v, %v)", tt.args.prices, tt.args.priceTime) + }) + } +} diff --git a/pkg/types/balance_type.go b/pkg/types/balance_type.go new file mode 100644 index 0000000..2d59010 --- /dev/null +++ b/pkg/types/balance_type.go @@ -0,0 +1,69 @@ +package types + +import ( + "encoding/json" + "strings" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "github.com/pkg/errors" +) + +type BalanceType string + +const ( + BalanceTypeAvailable BalanceType = "AVAILABLE" + BalanceTypeLocked BalanceType = "LOCKED" + BalanceTypeBorrowed BalanceType = "BORROWED" + BalanceTypeInterest BalanceType = "INTEREST" + BalanceTypeNet BalanceType = "NET" + BalanceTypeTotal BalanceType = "TOTAL" + BalanceTypeDebt BalanceType = "DEBT" +) + +var ErrInvalidBalanceType = errors.New("invalid balance type") + +func ParseBalanceType(s string) (b BalanceType, err error) { + b = BalanceType(strings.ToUpper(s)) + switch b { + case BalanceTypeAvailable, BalanceTypeLocked, BalanceTypeBorrowed, BalanceTypeInterest, BalanceTypeNet, BalanceTypeTotal, BalanceTypeDebt: + return b, nil + } + return b, ErrInvalidBalanceType +} + +func (b *BalanceType) UnmarshalJSON(data []byte) error { + var s string + + if err := json.Unmarshal(data, &s); err != nil { + return err + } + + t, err := ParseBalanceType(s) + if err != nil { + return err + } + + *b = t + + return nil +} + +func (b BalanceType) Map(balance Balance) fixedpoint.Value { + switch b { + case BalanceTypeAvailable: + return balance.Available + case BalanceTypeLocked: + return balance.Locked + case BalanceTypeBorrowed: + return balance.Borrowed + case BalanceTypeInterest: + return balance.Interest + case BalanceTypeNet: + return balance.Net() + case BalanceTypeTotal: + return balance.Total() + case BalanceTypeDebt: + return balance.Debt() + } + return fixedpoint.Zero +} diff --git a/pkg/types/bollinger.go b/pkg/types/bollinger.go new file mode 100644 index 0000000..9e7f4b8 --- /dev/null +++ b/pkg/types/bollinger.go @@ -0,0 +1,8 @@ +package types + +// BollingerSetting contains the bollinger indicator setting propers +// Interval, Window and BandWidth +type BollingerSetting struct { + IntervalWindow + BandWidth float64 `json:"bandWidth"` +} diff --git a/pkg/types/bookticker.go b/pkg/types/bookticker.go new file mode 100644 index 0000000..452264c --- /dev/null +++ b/pkg/types/bookticker.go @@ -0,0 +1,22 @@ +package types + +import ( + "fmt" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +// BookTicker time exists in ftx, not in binance +// last exists in ftx, not in binance +type BookTicker struct { + //Time time.Time + Symbol string + Buy fixedpoint.Value // `buy` from Max, `bidPrice` from binance + BuySize fixedpoint.Value + Sell fixedpoint.Value // `sell` from Max, `askPrice` from binance + SellSize fixedpoint.Value + //Last fixedpoint.Value +} + +func (b BookTicker) String() string { + return fmt.Sprintf("BookTicker { Symbol: %s,Buy: %f , BuySize: %f, Sell: %f, SellSize :%f } ", b.Symbol, b.Buy.Float64(), b.BuySize.Float64(), b.Sell.Float64(), b.SellSize.Float64()) +} diff --git a/pkg/types/channel.go b/pkg/types/channel.go new file mode 100644 index 0000000..f9b8a78 --- /dev/null +++ b/pkg/types/channel.go @@ -0,0 +1,20 @@ +package types + +type Channel string + +const ( + BookChannel = Channel("book") + KLineChannel = Channel("kline") + BookTickerChannel = Channel("bookTicker") + MarketTradeChannel = Channel("trade") + AggTradeChannel = Channel("aggTrade") + ForceOrderChannel = Channel("forceOrder") + + // channels for futures + MarkPriceChannel = Channel("markPrice") + + LiquidationOrderChannel = Channel("liquidationOrder") + + // ContractInfoChannel is the contract info provided by the exchange + ContractInfoChannel = Channel("contractInfo") +) diff --git a/pkg/types/cross.go b/pkg/types/cross.go new file mode 100644 index 0000000..5504b03 --- /dev/null +++ b/pkg/types/cross.go @@ -0,0 +1,56 @@ +package types + +// The result structure that maps to the crossing result of `CrossOver` and `CrossUnder` +// Accessible through BoolSeries interface +type CrossResult struct { + a Series + b Series + isOver bool +} + +func (c *CrossResult) Last() bool { + if c.Length() == 0 { + return false + } + if c.isOver { + return c.a.Last(0)-c.b.Last(0) > 0 && c.a.Last(1)-c.b.Last(1) < 0 + } else { + return c.a.Last(0)-c.b.Last(0) < 0 && c.a.Last(1)-c.b.Last(1) > 0 + } +} + +func (c *CrossResult) Index(i int) bool { + if i >= c.Length() { + return false + } + if c.isOver { + return c.a.Last(i)-c.b.Last(i) > 0 && c.a.Last(i+1)-c.b.Last(i+1) < 0 + } else { + return c.a.Last(i)-c.b.Last(i) < 0 && c.a.Last(i+1)-c.b.Last(i+1) > 0 + } +} + +func (c *CrossResult) Length() int { + la := c.a.Length() + lb := c.b.Length() + if la > lb { + return lb + } + return la +} + +// a series cross above b series. +// If in current KLine, a is higher than b, and in previous KLine, a is lower than b, then return true. +// Otherwise return false. +// If accessing index <= length, will always return false +func CrossOver(a Series, b Series) BoolSeries { + return &CrossResult{a, b, true} +} + +// a series cross under b series. +// If in current KLine, a is lower than b, and in previous KLine, a is higher than b, then return true. +// Otherwise return false. +// If accessing index <= length, will always return false +func CrossUnder(a Series, b Series) BoolSeries { + return &CrossResult{a, b, false} +} diff --git a/pkg/types/csv.go b/pkg/types/csv.go new file mode 100644 index 0000000..46a0263 --- /dev/null +++ b/pkg/types/csv.go @@ -0,0 +1,7 @@ +package types + +// CsvFormatter is an interface used for dumping object into csv file +type CsvFormatter interface { + CsvHeader() []string + CsvRecords() [][]string +} diff --git a/pkg/types/currencies.go b/pkg/types/currencies.go new file mode 100644 index 0000000..dbc4840 --- /dev/null +++ b/pkg/types/currencies.go @@ -0,0 +1,54 @@ +package types + +import ( + "math/big" + + "github.com/leekchan/accounting" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +type Acc = accounting.Accounting + +type wrapper struct { + Acc +} + +func (w *wrapper) FormatMoney(v fixedpoint.Value) string { + f := new(big.Float) + f.SetString(v.String()) + return w.Acc.FormatMoneyBigFloat(f) +} + +var USD = wrapper{accounting.Accounting{Symbol: "$ ", Precision: 2}} +var BTC = wrapper{accounting.Accounting{Symbol: "BTC ", Precision: 8}} +var BNB = wrapper{accounting.Accounting{Symbol: "BNB ", Precision: 4}} + +const ( + USDT = "USDT" + USDC = "USDC" + BUSD = "BUSD" +) + +var FiatCurrencies = []string{"USDC", "USDT", "USD", "TWD", "EUR", "GBP", "BUSD"} + +// USDFiatCurrencies lists the USD stable coins +var USDFiatCurrencies = []string{"USDT", "USDC", "USD", "BUSD"} + +func IsUSDFiatCurrency(currency string) bool { + for _, c := range USDFiatCurrencies { + if c == currency { + return true + } + } + return false +} + +func IsFiatCurrency(currency string) bool { + for _, c := range FiatCurrencies { + if c == currency { + return true + } + } + return false +} diff --git a/pkg/types/deposit.go b/pkg/types/deposit.go new file mode 100644 index 0000000..bcb47b9 --- /dev/null +++ b/pkg/types/deposit.go @@ -0,0 +1,83 @@ +package types + +import ( + "fmt" + "strconv" + "strings" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +type DepositStatus string + +const ( + // EMPTY string means not supported + + DepositPending = DepositStatus("pending") + + DepositRejected = DepositStatus("rejected") + + DepositSuccess = DepositStatus("success") + + DepositCancelled = DepositStatus("canceled") + + // created but can not withdraw + DepositCredited = DepositStatus("credited") +) + +type Deposit struct { + GID int64 `json:"gid" db:"gid"` + Exchange ExchangeName `json:"exchange" db:"exchange"` + Time Time `json:"time" db:"time"` + Amount fixedpoint.Value `json:"amount" db:"amount"` + Asset string `json:"asset" db:"asset"` + Address string `json:"address" db:"address"` + AddressTag string `json:"addressTag"` + TransactionID string `json:"transactionID" db:"txn_id"` + Status DepositStatus `json:"status"` + + // Required confirm for unlock balance + UnlockConfirm int `json:"unlockConfirm"` + + // Confirmation format = "current/required", for example: "7/16" + Confirmation string `json:"confirmation"` +} + +func (d Deposit) GetCurrentConfirmation() (current int, required int) { + if len(d.Confirmation) == 0 { + return 0, 0 + } + + strs := strings.Split(d.Confirmation, "/") + if len(strs) < 2 { + return 0, 0 + } + + current, _ = strconv.Atoi(strs[0]) + required, _ = strconv.Atoi(strs[1]) + return current, required +} + +func (d Deposit) EffectiveTime() time.Time { + return d.Time.Time() +} + +func (d Deposit) String() (o string) { + o = fmt.Sprintf("%s deposit %s %v <- ", d.Exchange, d.Asset, d.Amount) + + if len(d.AddressTag) > 0 { + o += fmt.Sprintf("%s (tag: %s) at %s", d.Address, d.AddressTag, d.Time.Time()) + } else { + o += fmt.Sprintf("%s at %s", d.Address, d.Time.Time()) + } + + if len(d.TransactionID) > 0 { + o += fmt.Sprintf("txID: %s", cutstr(d.TransactionID, 12, 4, 4)) + } + if len(d.Status) > 0 { + o += "status: " + string(d.Status) + } + + return o +} diff --git a/pkg/types/duration.go b/pkg/types/duration.go new file mode 100644 index 0000000..c376f98 --- /dev/null +++ b/pkg/types/duration.go @@ -0,0 +1,135 @@ +package types + +import ( + "encoding/json" + "fmt" + "regexp" + "strconv" + "time" + + "github.com/pkg/errors" +) + +var simpleDurationRegExp = regexp.MustCompile(`^(\d+)([hdw])$`) + +var ErrNotSimpleDuration = errors.New("the given input is not simple duration format, valid format: [1-9][0-9]*[hdw]") + +type SimpleDuration struct { + Num int + Unit string + Duration Duration +} + +func (d *SimpleDuration) String() string { + return fmt.Sprintf("%d%s", d.Num, d.Unit) +} + +func (d *SimpleDuration) Interval() Interval { + switch d.Unit { + + case "d": + return Interval1d + case "h": + return Interval1h + + case "w": + return Interval1w + + } + + return "" +} + +func (d *SimpleDuration) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + + sd, err := ParseSimpleDuration(s) + if err != nil { + return err + } + + if sd != nil { + *d = *sd + } + return nil +} + +func ParseSimpleDuration(s string) (*SimpleDuration, error) { + if s == "" { + return nil, nil + } + + if !simpleDurationRegExp.MatchString(s) { + return nil, errors.Wrapf(ErrNotSimpleDuration, "input %q is not a simple duration", s) + } + + matches := simpleDurationRegExp.FindStringSubmatch(s) + numStr := matches[1] + unit := matches[2] + num, err := strconv.Atoi(numStr) + if err != nil { + return nil, err + } + + switch unit { + case "d": + d := Duration(time.Duration(num) * 24 * time.Hour) + return &SimpleDuration{num, unit, d}, nil + case "w": + d := Duration(time.Duration(num) * 7 * 24 * time.Hour) + return &SimpleDuration{num, unit, d}, nil + case "h": + d := Duration(time.Duration(num) * time.Hour) + return &SimpleDuration{num, unit, d}, nil + } + + return nil, errors.Wrapf(ErrNotSimpleDuration, "input %q is not a simple duration", s) +} + +// Duration +type Duration time.Duration + +func (d *Duration) Duration() time.Duration { + return time.Duration(*d) +} + +func (d *Duration) UnmarshalJSON(data []byte) error { + var o interface{} + + if err := json.Unmarshal(data, &o); err != nil { + return err + } + + switch t := o.(type) { + case string: + sd, err := ParseSimpleDuration(t) + if err == nil { + *d = sd.Duration + return nil + } + + dd, err := time.ParseDuration(t) + if err != nil { + return err + } + + *d = Duration(dd) + + case float64: + *d = Duration(int64(t * float64(time.Second))) + + case int64: + *d = Duration(t * int64(time.Second)) + case int: + *d = Duration(t * int(time.Second)) + + default: + return fmt.Errorf("unsupported type %T value: %v", t, t) + + } + + return nil +} diff --git a/pkg/types/duration_test.go b/pkg/types/duration_test.go new file mode 100644 index 0000000..44a56c8 --- /dev/null +++ b/pkg/types/duration_test.go @@ -0,0 +1,55 @@ +package types + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestParseSimpleDuration(t *testing.T) { + type args struct { + s string + } + tests := []struct { + name string + args args + want *SimpleDuration + wantErr assert.ErrorAssertionFunc + }{ + { + name: "3h", + args: args{ + s: "3h", + }, + want: &SimpleDuration{Num: 3, Unit: "h", Duration: Duration(3 * time.Hour)}, + wantErr: assert.NoError, + }, + { + name: "3d", + args: args{ + s: "3d", + }, + want: &SimpleDuration{Num: 3, Unit: "d", Duration: Duration(3 * 24 * time.Hour)}, + wantErr: assert.NoError, + }, + { + name: "3w", + args: args{ + s: "3w", + }, + want: &SimpleDuration{Num: 3, Unit: "w", Duration: Duration(3 * 7 * 24 * time.Hour)}, + wantErr: assert.NoError, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseSimpleDuration(tt.args.s) + if !tt.wantErr(t, err, fmt.Sprintf("ParseSimpleDuration(%v)", tt.args.s)) { + return + } + assert.Equalf(t, tt.want, got, "ParseSimpleDuration(%v)", tt.args.s) + }) + } +} diff --git a/pkg/types/error.go b/pkg/types/error.go new file mode 100644 index 0000000..5d58b57 --- /dev/null +++ b/pkg/types/error.go @@ -0,0 +1,33 @@ +package types + +import "fmt" + +type OrderError struct { + error error + order Order +} + +func (e *OrderError) Error() string { + return fmt.Sprintf("%s exchange: %s orderID:%d", e.error.Error(), e.order.Exchange, e.order.OrderID) +} + +func (e *OrderError) Order() Order { + return e.order +} + +func NewOrderError(e error, o Order) error { + return &OrderError{ + error: e, + order: o, + } +} + +type ZeroAssetError struct { + error +} + +func NewZeroAssetError(e error) ZeroAssetError { + return ZeroAssetError{ + error: e, + } +} diff --git a/pkg/types/exchange.go b/pkg/types/exchange.go new file mode 100644 index 0000000..b11b59d --- /dev/null +++ b/pkg/types/exchange.go @@ -0,0 +1,170 @@ +package types + +import ( + "context" + "database/sql/driver" + "encoding/json" + "fmt" + "strings" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +const DateFormat = "2006-01-02" + +type ExchangeName string + +const ( + ExchangeMax ExchangeName = "max" + ExchangeBinance ExchangeName = "binance" + ExchangeOKEx ExchangeName = "okex" + ExchangeKucoin ExchangeName = "kucoin" + ExchangeBitget ExchangeName = "bitget" + ExchangeBacktest ExchangeName = "backtest" + ExchangeBybit ExchangeName = "bybit" +) + +var SupportedExchanges = []ExchangeName{ + ExchangeMax, + ExchangeBinance, + ExchangeOKEx, + ExchangeKucoin, + ExchangeBitget, + ExchangeBybit, + // note: we are not using "backtest" +} + +func (n *ExchangeName) Value() (driver.Value, error) { + return n.String(), nil +} + +func (n *ExchangeName) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + + *n = ExchangeName(s) + if !n.IsValid() { + return fmt.Errorf("%s is an invalid exchange name", s) + } + + return nil +} + +func (n ExchangeName) IsValid() bool { + switch n { + case ExchangeBinance, ExchangeBitget, ExchangeBybit, ExchangeMax, ExchangeOKEx, ExchangeKucoin: + return true + } + return false +} + +func (n ExchangeName) String() string { + return string(n) +} + +func ValidExchangeName(a string) (ExchangeName, error) { + exName := ExchangeName(strings.ToLower(a)) + if !exName.IsValid() { + return "", fmt.Errorf("invalid exchange name: %s", a) + } + + return exName, nil +} + +type ExchangeMinimal interface { + Name() ExchangeName + PlatformFeeCurrency() string +} + +//go:generate mockgen -destination=mocks/mock_exchange.go -package=mocks . Exchange +type Exchange interface { + ExchangeMinimal + ExchangeMarketDataService + ExchangeAccountService + ExchangeTradeService +} + +//go:generate mockgen -destination=mocks/mock_exchange_public.go -package=mocks . ExchangePublic +type ExchangePublic interface { + ExchangeMinimal + ExchangeMarketDataService +} + +// ExchangeBasic is the new type for replacing the original Exchange interface +type ExchangeBasic = Exchange + +// ExchangeOrderQueryService provides an interface for querying the order status via order ID or client order ID +// +//go:generate mockgen -destination=mocks/mock_exchange_order_query.go -package=mocks . ExchangeOrderQueryService +type ExchangeOrderQueryService interface { + QueryOrder(ctx context.Context, q OrderQuery) (*Order, error) + QueryOrderTrades(ctx context.Context, q OrderQuery) ([]Trade, error) +} + +type ExchangeAccountService interface { + QueryAccount(ctx context.Context) (*Account, error) + + QueryAccountBalances(ctx context.Context) (BalanceMap, error) +} + +type ExchangeTradeService interface { + SubmitOrder(ctx context.Context, order SubmitOrder) (createdOrder *Order, err error) + + QueryOpenOrders(ctx context.Context, symbol string) (orders []Order, err error) + + CancelOrders(ctx context.Context, orders ...Order) error +} + +type ExchangeDefaultFeeRates interface { + DefaultFeeRates() ExchangeFee +} + +type ExchangeAmountFeeProtect interface { + SetModifyOrderAmountForFee(ExchangeFee) +} + +//go:generate mockgen -destination=mocks/mock_exchange_trade_history.go -package=mocks . ExchangeTradeHistoryService +type ExchangeTradeHistoryService interface { + QueryTrades(ctx context.Context, symbol string, options *TradeQueryOptions) ([]Trade, error) + QueryClosedOrders(ctx context.Context, symbol string, since, until time.Time, lastOrderID uint64) (orders []Order, err error) +} + +type ExchangeMarketDataService interface { + NewStream() Stream + + QueryMarkets(ctx context.Context) (MarketMap, error) + + QueryTicker(ctx context.Context, symbol string) (*Ticker, error) + + QueryTickers(ctx context.Context, symbol ...string) (map[string]Ticker, error) + + QueryKLines(ctx context.Context, symbol string, interval Interval, options KLineQueryOptions) ([]KLine, error) +} + +type CustomIntervalProvider interface { + SupportedInterval() map[Interval]int + IsSupportedInterval(interval Interval) bool +} + +type ExchangeTransferService interface { + QueryDepositHistory(ctx context.Context, asset string, since, until time.Time) (allDeposits []Deposit, err error) + QueryWithdrawHistory(ctx context.Context, asset string, since, until time.Time) (allWithdraws []Withdraw, err error) +} + +type ExchangeWithdrawalService interface { + Withdraw(ctx context.Context, asset string, amount fixedpoint.Value, address string, options *WithdrawalOptions) error +} + +type ExchangeRewardService interface { + QueryRewards(ctx context.Context, startTime time.Time) ([]Reward, error) +} + +type TradeQueryOptions struct { + StartTime *time.Time + EndTime *time.Time + Limit int64 + LastTradeID uint64 +} diff --git a/pkg/types/exchange_icon.go b/pkg/types/exchange_icon.go new file mode 100644 index 0000000..7c4df85 --- /dev/null +++ b/pkg/types/exchange_icon.go @@ -0,0 +1,18 @@ +package types + +func ExchangeFooterIcon(exName ExchangeName) string { + footerIcon := "" + + switch exName { + case ExchangeBinance: + footerIcon = "https://bin.bnbstatic.com/static/images/common/favicon.ico" + case ExchangeMax: + footerIcon = "https://max.maicoin.com/favicon-16x16.png" + case ExchangeOKEx: + footerIcon = "https://static.okex.com/cdn/assets/imgs/MjAxODg/D91A7323087D31A588E0D2A379DD7747.png" + case ExchangeKucoin: + footerIcon = "https://assets.staticimg.com/cms/media/7AV75b9jzr9S8H3eNuOuoqj8PwdUjaDQGKGczGqTS.png" + } + + return footerIcon +} diff --git a/pkg/types/exchange_test.go b/pkg/types/exchange_test.go new file mode 100644 index 0000000..6da88f1 --- /dev/null +++ b/pkg/types/exchange_test.go @@ -0,0 +1,17 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_exchangeName(t *testing.T) { + assert.Equal(t, ExchangeMax.String(), "max") + name, err := ValidExchangeName("binance") + assert.Equal(t, name, ExchangeName("binance")) + assert.NoError(t, err) + _, err = ValidExchangeName("dummy") + assert.Error(t, err) + assert.True(t, ExchangeMax.IsValid()) +} diff --git a/pkg/types/filter.go b/pkg/types/filter.go new file mode 100644 index 0000000..3c1a2f8 --- /dev/null +++ b/pkg/types/filter.go @@ -0,0 +1,51 @@ +package types + +type FilterResult struct { + a Series + b func(int, float64) bool + length int + c []int +} + +func (f *FilterResult) Last(j int) float64 { + if j >= f.length { + return 0 + } + if len(f.c) > j { + return f.a.Last(f.c[j]) + } + l := f.a.Length() + k := len(f.c) + i := 0 + if k > 0 { + i = f.c[k-1] + 1 + } + for ; i < l; i++ { + tmp := f.a.Last(i) + if f.b(i, tmp) { + f.c = append(f.c, i) + if j == k { + return tmp + } + k++ + } + } + return 0 +} + +func (f *FilterResult) Index(j int) float64 { + return f.Last(j) +} + +func (f *FilterResult) Length() int { + return f.length +} + +// Filter function filters Series by using a boolean function. +// When the boolean function returns true, the Series value at index i will be included in the returned Series. +// The returned Series will find at most `length` latest matching elements from the input Series. +// Query index larger or equal than length from the returned Series will return 0 instead. +// Notice that any Update on the input Series will make the previously returned Series outdated. +func Filter(a Series, b func(i int, value float64) bool, length int) SeriesExtend { + return NewSeries(&FilterResult{a, b, length, nil}) +} diff --git a/pkg/types/float64updater.go b/pkg/types/float64updater.go new file mode 100644 index 0000000..ba3b5e0 --- /dev/null +++ b/pkg/types/float64updater.go @@ -0,0 +1,6 @@ +package types + +//go:generate callbackgen -type Float64Updater +type Float64Updater struct { + updateCallbacks []func(v float64) +} diff --git a/pkg/types/float64updater_callbacks.go b/pkg/types/float64updater_callbacks.go new file mode 100644 index 0000000..28b2b6f --- /dev/null +++ b/pkg/types/float64updater_callbacks.go @@ -0,0 +1,15 @@ +// Code generated by "callbackgen -type Float64Updater"; DO NOT EDIT. + +package types + +import () + +func (F *Float64Updater) OnUpdate(cb func(v float64)) { + F.updateCallbacks = append(F.updateCallbacks, cb) +} + +func (F *Float64Updater) EmitUpdate(v float64) { + for _, cb := range F.updateCallbacks { + cb(v) + } +} diff --git a/pkg/types/float_map.go b/pkg/types/float_map.go new file mode 100644 index 0000000..8a5cef0 --- /dev/null +++ b/pkg/types/float_map.go @@ -0,0 +1,5 @@ +package types + +import "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + +var _ Series = floats.Slice([]float64{}).Addr() diff --git a/pkg/types/fundingrate.go b/pkg/types/fundingrate.go new file mode 100644 index 0000000..f57dbb0 --- /dev/null +++ b/pkg/types/fundingrate.go @@ -0,0 +1,13 @@ +package types + +import ( + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +type FundingRate struct { + FundingRate fixedpoint.Value + FundingTime time.Time + Time time.Time +} diff --git a/pkg/types/heikinashi_stream.go b/pkg/types/heikinashi_stream.go new file mode 100644 index 0000000..89d883a --- /dev/null +++ b/pkg/types/heikinashi_stream.go @@ -0,0 +1,72 @@ +package types + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +var Four fixedpoint.Value = fixedpoint.NewFromInt(4) + +type HeikinAshiStream struct { + StandardStreamEmitter + lastAshi map[string]map[Interval]*KLine + LastOrigin map[string]map[Interval]*KLine +} + +func (s *HeikinAshiStream) EmitKLineClosed(kline KLine) { + ashi := kline + if s.lastAshi == nil { + s.lastAshi = make(map[string]map[Interval]*KLine) + s.LastOrigin = make(map[string]map[Interval]*KLine) + } + if s.lastAshi[kline.Symbol] == nil { + s.lastAshi[kline.Symbol] = make(map[Interval]*KLine) + s.LastOrigin[kline.Symbol] = make(map[Interval]*KLine) + } + lastAshi := s.lastAshi[kline.Symbol][kline.Interval] + if lastAshi == nil { + ashi.Close = kline.Close.Add(kline.High). + Add(kline.Low). + Add(kline.Open). + Div(Four) + // High and Low are the same + s.lastAshi[kline.Symbol][kline.Interval] = &ashi + s.LastOrigin[kline.Symbol][kline.Interval] = &kline + } else { + ashi.Close = kline.Close.Add(kline.High). + Add(kline.Low). + Add(kline.Open). + Div(Four) + ashi.Open = lastAshi.Open.Add(lastAshi.Close).Div(Two) + // High and Low are the same + s.lastAshi[kline.Symbol][kline.Interval] = &ashi + s.LastOrigin[kline.Symbol][kline.Interval] = &kline + } + s.StandardStreamEmitter.EmitKLineClosed(ashi) +} + +// No writeback to lastAshi +func (s *HeikinAshiStream) EmitKLine(kline KLine) { + ashi := kline + if s.lastAshi == nil { + s.lastAshi = make(map[string]map[Interval]*KLine) + } + if s.lastAshi[kline.Symbol] == nil { + s.lastAshi[kline.Symbol] = make(map[Interval]*KLine) + } + lastAshi := s.lastAshi[kline.Symbol][kline.Interval] + if lastAshi == nil { + ashi.Close = kline.Close.Add(kline.High). + Add(kline.Low). + Add(kline.Open). + Div(Four) + } else { + ashi.Close = kline.Close.Add(kline.High). + Add(kline.Low). + Add(kline.Open). + Div(Four) + ashi.Open = lastAshi.Open.Add(lastAshi.Close).Div(Two) + } + s.StandardStreamEmitter.EmitKLine(ashi) +} + +var _ StandardStreamEmitter = &HeikinAshiStream{} diff --git a/pkg/types/indicator.go b/pkg/types/indicator.go new file mode 100644 index 0000000..bb214c0 --- /dev/null +++ b/pkg/types/indicator.go @@ -0,0 +1,981 @@ +package types + +import ( + "fmt" + "math" + "reflect" + "time" + + "gonum.org/v1/gonum/stat" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" +) + +// Float64Indicator is the indicators (SMA and EWMA) that we want to use are returning float64 data. +type Float64Indicator interface { + Last(i int) float64 +} + +type AbsResult struct { + a Series +} + +func (a *AbsResult) Last(i int) float64 { + return math.Abs(a.a.Last(i)) +} + +func (a *AbsResult) Index(i int) float64 { + return a.Last(i) +} + +func (a *AbsResult) Length() int { + return a.a.Length() +} + +// Return series that having all the elements positive +func Abs(a Series) SeriesExtend { + return NewSeries(&AbsResult{a}) +} + +var _ Series = &AbsResult{} + +func LinearRegression(a Series, lookback int) (alpha float64, beta float64) { + if a.Length() < lookback { + lookback = a.Length() + } + x := make([]float64, lookback) + y := make([]float64, lookback) + var weights []float64 + for i := 0; i < lookback; i++ { + x[i] = float64(i) + y[i] = a.Last(i) + } + alpha, beta = stat.LinearRegression(x, y, weights, false) + return +} + +func Predict(a Series, lookback int, offset ...int) float64 { + alpha, beta := LinearRegression(a, lookback) + o := -1.0 + if len(offset) > 0 { + o = -float64(offset[0]) + } + return alpha + beta*o +} + +// This will make prediction using Linear Regression to get the next cross point +// Return (offset from latest, crossed value, could cross) +// offset from latest should always be positive +// lookback param is to use at most `lookback` points to determine linear regression functions +// +// You may also refer to excel's FORECAST function +func NextCross(a Series, b Series, lookback int) (int, float64, bool) { + if a.Length() < lookback { + lookback = a.Length() + } + if b.Length() < lookback { + lookback = b.Length() + } + x := make([]float64, lookback) + y1 := make([]float64, lookback) + y2 := make([]float64, lookback) + var weights []float64 + for i := 0; i < lookback; i++ { + x[i] = float64(i) + y1[i] = a.Last(i) + y2[i] = b.Last(i) + } + alpha1, beta1 := stat.LinearRegression(x, y1, weights, false) + alpha2, beta2 := stat.LinearRegression(x, y2, weights, false) + if beta2 == beta1 { + return 0, 0, false + } + indexf := (alpha1 - alpha2) / (beta2 - beta1) + + // crossed in different direction + if indexf >= 0 { + return 0, 0, false + } + return int(math.Ceil(-indexf)), alpha1 + beta1*indexf, true +} + +func Highest(a Series, lookback int) float64 { + if lookback > a.Length() { + lookback = a.Length() + } + highest := a.Last(0) + for i := 1; i < lookback; i++ { + current := a.Last(i) + if highest < current { + highest = current + } + } + return highest +} + +func Lowest(a Series, lookback int) float64 { + if lookback > a.Length() { + lookback = a.Length() + } + lowest := a.Last(0) + for i := 1; i < lookback; i++ { + current := a.Last(i) + if lowest > current { + lowest = current + } + } + return lowest +} + +type NumberSeries float64 + +func (a NumberSeries) Last(_ int) float64 { + return float64(a) +} + +func (a NumberSeries) Index(_ int) float64 { + return float64(a) +} + +func (a NumberSeries) Length() int { + return math.MaxInt32 +} + +func (a NumberSeries) Clone() NumberSeries { + return a +} + +var _ Series = NumberSeries(0) + +type AddSeriesResult struct { + a Series + b Series +} + +// Add two series, result[i] = a[i] + b[i] +func Add(a interface{}, b interface{}) SeriesExtend { + aa := switchIface(a) + bb := switchIface(b) + return NewSeries(&AddSeriesResult{aa, bb}) +} + +func (a *AddSeriesResult) Last(i int) float64 { + return a.a.Last(i) + a.b.Last(i) +} + +func (a *AddSeriesResult) Index(i int) float64 { + return a.Last(i) +} + +func (a *AddSeriesResult) Length() int { + lengtha := a.a.Length() + lengthb := a.b.Length() + if lengtha < lengthb { + return lengtha + } + return lengthb +} + +var _ Series = &AddSeriesResult{} + +type MinusSeriesResult struct { + a Series + b Series +} + +// Sub two series, result[i] = a[i] - b[i] +func Sub(a interface{}, b interface{}) SeriesExtend { + aa := switchIface(a) + bb := switchIface(b) + return NewSeries(&MinusSeriesResult{aa, bb}) +} + +func (a *MinusSeriesResult) Last(i int) float64 { + return a.a.Last(i) - a.b.Last(i) +} + +func (a *MinusSeriesResult) Index(i int) float64 { + return a.Last(i) +} + +func (a *MinusSeriesResult) Length() int { + lengtha := a.a.Length() + lengthb := a.b.Length() + if lengtha < lengthb { + return lengtha + } + return lengthb +} + +var _ Series = &MinusSeriesResult{} + +func switchIface(b interface{}) Series { + switch tp := b.(type) { + case float64: + return NumberSeries(tp) + case int32: + return NumberSeries(float64(tp)) + case int64: + return NumberSeries(float64(tp)) + case float32: + return NumberSeries(float64(tp)) + case int: + return NumberSeries(float64(tp)) + case Series: + return tp + default: + fmt.Println(reflect.TypeOf(b)) + panic("input should be either *Series or numbers") + + } +} + +// Divid two series, result[i] = a[i] / b[i] +func Div(a interface{}, b interface{}) SeriesExtend { + aa := switchIface(a) + if 0 == b { + panic("Divid by zero exception") + } + bb := switchIface(b) + return NewSeries(&DivSeriesResult{aa, bb}) + +} + +type DivSeriesResult struct { + a Series + b Series +} + +func (a *DivSeriesResult) Last(i int) float64 { + return a.a.Last(i) / a.b.Last(i) +} + +func (a *DivSeriesResult) Index(i int) float64 { + return a.Last(i) +} + +func (a *DivSeriesResult) Length() int { + lengtha := a.a.Length() + lengthb := a.b.Length() + if lengtha < lengthb { + return lengtha + } + return lengthb +} + +var _ Series = &DivSeriesResult{} + +// Multiple two series, result[i] = a[i] * b[i] +func Mul(a interface{}, b interface{}) SeriesExtend { + aa := switchIface(a) + bb := switchIface(b) + return NewSeries(&MulSeriesResult{aa, bb}) +} + +type MulSeriesResult struct { + a Series + b Series +} + +func (a *MulSeriesResult) Last(i int) float64 { + return a.a.Last(i) * a.b.Last(i) +} + +func (a *MulSeriesResult) Index(i int) float64 { + return a.Last(i) +} + +func (a *MulSeriesResult) Length() int { + lengtha := a.a.Length() + lengthb := a.b.Length() + if lengtha < lengthb { + return lengtha + } + return lengthb +} + +var _ Series = &MulSeriesResult{} + +// Calculate (a dot b). +// if limit is given, will only calculate the first limit numbers (a.Index[0..limit]) +// otherwise will operate on all elements +func Dot(a interface{}, b interface{}, limit ...int) float64 { + var aaf float64 + var aas Series + var bbf float64 + var bbs Series + var isaf, isbf bool + + switch tp := a.(type) { + case float64: + aaf = tp + isaf = true + case int32: + aaf = float64(tp) + isaf = true + case int64: + aaf = float64(tp) + isaf = true + case float32: + aaf = float64(tp) + isaf = true + case int: + aaf = float64(tp) + isaf = true + case Series: + aas = tp + isaf = false + default: + panic("input should be either *Series or numbers") + } + switch tp := b.(type) { + case float64: + bbf = tp + isbf = true + case int32: + bbf = float64(tp) + isbf = true + case int64: + bbf = float64(tp) + isbf = true + case float32: + bbf = float64(tp) + isbf = true + case int: + bbf = float64(tp) + isbf = true + case Series: + bbs = tp + isbf = false + default: + panic("input should be either *Series or numbers") + + } + l := 1 + if len(limit) > 0 { + l = limit[0] + } else if isaf && isbf { + l = 1 + } else { + if !isaf { + l = aas.Length() + } + if !isbf { + if l > bbs.Length() { + l = bbs.Length() + } + } + } + if isaf && isbf { + return aaf * bbf * float64(l) + } else if isaf && !isbf { + sum := 0. + for i := 0; i < l; i++ { + sum += aaf * bbs.Last(i) + } + return sum + } else if !isaf && isbf { + sum := 0. + for i := 0; i < l; i++ { + sum += aas.Last(i) * bbf + } + return sum + } else { + sum := 0. + for i := 0; i < l; i++ { + sum += aas.Last(i) * bbs.Index(i) + } + return sum + } +} + +// Array extracts elements from the Series to a float64 array, following the order of Index(0..limit) +// if limit is given, will only take the first limit numbers (a.Index[0..limit]) +// otherwise will operate on all elements +func Array(a Series, limit ...int) (result []float64) { + l := a.Length() + if len(limit) > 0 && l > limit[0] { + l = limit[0] + } + if l > a.Length() { + l = a.Length() + } + result = make([]float64, l) + for i := 0; i < l; i++ { + result[i] = a.Last(i) + } + return +} + +// Similar to Array but in reverse order. +// Useful when you want to cache series' calculated result as float64 array +// the then reuse the result in multiple places (so that no recalculation will be triggered) +// +// notice that the return type is a Float64Slice, which implements the Series interface +func Reverse(a Series, limit ...int) (result floats.Slice) { + l := a.Length() + if len(limit) > 0 && l > limit[0] { + l = limit[0] + } + result = make([]float64, l) + for i := 0; i < l; i++ { + result[l-i-1] = a.Last(i) + } + return +} + +type ChangeResult struct { + a Series + offset int +} + +func (c *ChangeResult) Last(i int) float64 { + if i+c.offset >= c.a.Length() { + return 0 + } + return c.a.Last(i) - c.a.Last(i+c.offset) +} + +func (c *ChangeResult) Index(i int) float64 { + return c.Last(i) +} + +func (c *ChangeResult) Length() int { + length := c.a.Length() + if length >= c.offset { + return length - c.offset + } + return 0 +} + +// Difference between current value and previous, a - a[offset] +// offset: if not given, offset is 1. +func Change(a Series, offset ...int) SeriesExtend { + o := 1 + if len(offset) > 0 { + o = offset[0] + } + + return NewSeries(&ChangeResult{a, o}) +} + +type PercentageChangeResult struct { + a Series + offset int +} + +func (c *PercentageChangeResult) Last(i int) float64 { + if i+c.offset >= c.a.Length() { + return 0 + } + return c.a.Last(i)/c.a.Last(i+c.offset) - 1 +} + +func (c *PercentageChangeResult) Index(i int) float64 { + return c.Last(i) +} + +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 length == 0 { + return 0 + } + if len(params) > 0 && 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.Last(i) - avg + s += diff * diff + } + if length-ddof == 0 { + return 0 + } + 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.Last(i) - aRanks.Last(j)) * (bRanks.Last(i) - bRanks.Last(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.Last(i) + counter := 0. + for j := 0; j < length; j++ { + if a.Last(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.Last(i) + y[i] = b.Last(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.autocorr() function. +// +// The method computes the Pearson correlation between Series and shifted itself +func AutoCorrelation(a Series, length int, lags ...int) float64 { + lag := 1 + if len(lags) > 0 { + lag = lags[0] + } + return Pearson(a, Shift(a, lag), 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.Last(i) - meana) * (b.Last(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.Last(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(i int) float64 { + if inc.offset+i < 0 { + return 0 + } + if inc.offset+i > inc.a.Length() { + return 0 + } + + return inc.a.Last(inc.offset + i) +} + +func (inc *ShiftResult) Index(i int) float64 { + return inc.Last(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(i int) float64 { + if i >= s.length { + return 0 + } + + return s.a.Last(i + s.start) +} + +func (s *SliceView) Index(i int) float64 { + return s.Last(i) +} + +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} +} + +// SoftMax returns the input value in the range of 0 to 1 +// with sum of all the probabilities being equal to one. +// It is commonly used in machine learning neural networks. +// Will return Softmax SeriesExtend result based in latest [window] numbers from [a] Series +func Softmax(a Series, window int) SeriesExtend { + s := 0.0 + max := Highest(a, window) + for i := 0; i < window; i++ { + s += math.Exp(a.Last(i) - max) + } + out := NewQueue(window) + for i := window - 1; i >= 0; i-- { + out.Update(math.Exp(a.Last(i)-max) / s) + } + return out +} + +// Entropy computes the Shannon entropy of a distribution or the distance between +// two distributions. The natural logarithm is used. +// - sum(v * ln(v)) +func Entropy(a Series, window int) (e float64) { + for i := 0; i < window; i++ { + v := a.Last(i) + if v != 0 { + e -= v * math.Log(v) + } + } + return e +} + +// CrossEntropy computes the cross-entropy between the two distributions +func CrossEntropy(a, b Series, window int) (e float64) { + for i := 0; i < window; i++ { + v := a.Last(i) + if v != 0 { + e -= v * math.Log(b.Last(i)) + } + } + return e +} + +func sigmoid(z float64) float64 { + return 1. / (1. + math.Exp(-z)) +} + +func propagate(w []float64, gradient float64, x [][]float64, y []float64) (float64, []float64, float64) { + logloss_epoch := 0.0 + var activations []float64 + var dw []float64 + m := len(y) + db := 0.0 + for i, xx := range x { + result := 0.0 + for j, ww := range w { + result += ww * xx[j] + } + a := sigmoid(result + gradient) + activations = append(activations, a) + logloss := a*math.Log1p(y[i]) + (1.-a)*math.Log1p(1-y[i]) + logloss_epoch += logloss + + db += a - y[i] + } + for j := range w { + err := 0.0 + for i, xx := range x { + err_i := activations[i] - y[i] + err += err_i * xx[j] + } + err /= float64(m) + dw = append(dw, err) + } + + cost := -(logloss_epoch / float64(len(x))) + db /= float64(m) + return cost, dw, db +} + +func LogisticRegression(x []Series, y Series, lookback, iterations int, learningRate float64) *LogisticRegressionModel { + features := len(x) + if features == 0 { + panic("no feature to train") + } + w := make([]float64, features) + if lookback > x[0].Length() { + lookback = x[0].Length() + } + xx := make([][]float64, lookback) + for i := 0; i < lookback; i++ { + for j := 0; j < features; j++ { + xx[i] = append(xx[i], x[j].Last(lookback-i-1)) + } + } + yy := Reverse(y, lookback) + + b := 0. + for i := 0; i < iterations; i++ { + _, dw, db := propagate(w, b, xx, yy) + for j := range w { + w[j] = w[j] - (learningRate * dw[j]) + } + b -= learningRate * db + } + return &LogisticRegressionModel{ + Weight: w, + Gradient: b, + LearningRate: learningRate, + } +} + +type LogisticRegressionModel struct { + Weight []float64 + Gradient float64 + LearningRate float64 +} + +/* +// Might not be correct. +// Please double check before uncomment this +func (l *LogisticRegressionModel) Update(x []float64, y float64) { + z := 0.0 + for i, w := l.Weight { + z += w * x[i] + } + a := sigmoid(z + l.Gradient) + //logloss := a * math.Log1p(y) + (1.-a)*math.Log1p(1-y) + db = a - y + var dw []float64 + for j, ww := range l.Weight { + err := db * x[j] + dw = append(dw, err) + } + for i := range l.Weight { + l.Weight[i] -= l.LearningRate * dw[i] + } + l.Gradient -= l.LearningRate * db +} +*/ + +func (l *LogisticRegressionModel) Predict(x []float64) float64 { + z := 0.0 + for i, w := range l.Weight { + z += w * x[i] + } + return sigmoid(z + l.Gradient) +} + +type Canvas struct { + chart.Chart + Interval Interval +} + +func NewCanvas(title string, intervals ...Interval) *Canvas { + valueFormatter := chart.TimeValueFormatter + interval := Interval1m + if len(intervals) > 0 { + interval = intervals[0] + if interval.Seconds() > 24*60*60 { + valueFormatter = chart.TimeDateValueFormatter + } else if interval.Seconds() > 60*60 { + valueFormatter = chart.TimeHourValueFormatter + } else { + valueFormatter = chart.TimeMinuteValueFormatter + } + } else { + valueFormatter = chart.IntValueFormatter + } + out := &Canvas{ + Chart: chart.Chart{ + Title: title, + XAxis: chart.XAxis{ + ValueFormatter: valueFormatter, + }, + }, + Interval: interval, + } + out.Chart.Elements = []chart.Renderable{ + chart.LegendLeft(&out.Chart), + } + return out +} + +func expand(a []float64, length int, defaultVal float64) []float64 { + l := len(a) + if l >= length { + return a + } + for i := 0; i < length-l; i++ { + a = append([]float64{defaultVal}, a...) + } + return a +} + +func (canvas *Canvas) Plot(tag string, a Series, endTime Time, length int, intervals ...Interval) { + var timeline []time.Time + e := endTime.Time() + if a.Length() == 0 { + return + } + oldest := a.Last(a.Length() - 1) + interval := canvas.Interval + if len(intervals) > 0 { + interval = intervals[0] + } + for i := length - 1; i >= 0; i-- { + shiftedT := e.Add(-time.Duration(i*interval.Seconds()) * time.Second) + timeline = append(timeline, shiftedT) + } + canvas.Series = append(canvas.Series, chart.TimeSeries{ + Name: tag, + YValues: expand(Reverse(a, length), length, oldest), + XValues: timeline, + }) +} + +func (canvas *Canvas) PlotRaw(tag string, a Series, length int) { + var x []float64 + for i := 0; i < length; i++ { + x = append(x, float64(i)) + } + if a.Length() == 0 { + return + } + oldest := a.Last(a.Length() - 1) + canvas.Series = append(canvas.Series, chart.ContinuousSeries{ + Name: tag, + XValues: x, + YValues: expand(Reverse(a, length), length, oldest), + }) +} + +// TODO: ta.linreg diff --git a/pkg/types/indicator_test.go b/pkg/types/indicator_test.go new file mode 100644 index 0000000..3fa3f2a --- /dev/null +++ b/pkg/types/indicator_test.go @@ -0,0 +1,251 @@ +package types + +import ( + // "os" + "math" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "gonum.org/v1/gonum/stat" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" +) + +func TestQueue(t *testing.T) { + zeroq := NewQueue(0) + assert.Equal(t, zeroq.Last(0), 0.) + assert.Equal(t, zeroq.Index(0), 0.) + zeroq.Update(1.) + assert.Equal(t, zeroq.Length(), 0) +} + +func TestFloat(t *testing.T) { + var a Series = Sub(3., 2.) + assert.Equal(t, a.Last(0), 1.) + assert.Equal(t, a.Last(100), 1.) +} + +func TestNextCross(t *testing.T) { + var a Series = NumberSeries(1.2) + + var b Series = &floats.Slice{100., 80., 60.} + // index 2 1 0 + // predicted 40 20 0 + // offset 1 2 3 + + index, value, ok := NextCross(a, b, 3) + assert.True(t, ok) + assert.Equal(t, value, 1.2) + assert.Equal(t, index, 3) // 2.94, ceil +} + +func TestFloat64Slice(t *testing.T) { + var a = floats.Slice{1.0, 2.0, 3.0} + var b = floats.Slice{1.0, 2.0, 3.0} + var c Series = Sub(&a, &b) + a = append(a, 4.0) + b = append(b, 3.0) + assert.Equal(t, c.Last(0), 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 = floats.Slice{.2, .0, .6, .2} + var b = floats.Slice{.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.Last(0), 2.5) + assert.Equal(t, out.Last(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 = floats.Slice{.2, .0, .6, .2} + var b = floats.Slice{.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 = floats.Slice{.2, .0, .6, .2} + sk := Skew(&a, 4) + assert.InDelta(t, sk, 1.129338, 0.001) +} + +func TestEntropy(t *testing.T) { + var a = floats.Slice{.2, .0, .6, .2} + e := stat.Entropy(a) + assert.InDelta(t, e, Entropy(&a, a.Length()), 0.0001) +} + +func TestCrossEntropy(t *testing.T) { + var a = floats.Slice{.2, .0, .6, .2} + var b = floats.Slice{.3, .6, .0, .1} + e := stat.CrossEntropy(a, b) + assert.InDelta(t, e, CrossEntropy(&a, &b, a.Length()), 0.0001) +} + +func TestSoftmax(t *testing.T) { + var a = floats.Slice{3.0, 1.0, 0.2} + out := Softmax(&a, a.Length()) + r := floats.Slice{0.8360188027814407, 0.11314284146556013, 0.05083835575299916} + for i := 0; i < out.Length(); i++ { + assert.InDelta(t, r.Last(i), out.Last(i), 0.001) + } +} + +func TestSigmoid(t *testing.T) { + a := floats.Slice{3.0, 1.0, 2.1} + out := Sigmoid(&a) + r := floats.Slice{0.9525741268224334, 0.7310585786300049, 0.8909031788043871} + for i := 0; i < out.Length(); i++ { + assert.InDelta(t, r.Last(i), out.Last(i), 0.001, "i=%d", i) + } +} + +func TestHighLowest(t *testing.T) { + a := floats.Slice{3.0, 1.0, 2.1} + assert.Equal(t, 3.0, Highest(&a, 4)) + assert.Equal(t, 1.0, Lowest(&a, 4)) +} + +func TestAdd(t *testing.T) { + var a NumberSeries = 3.0 + var b NumberSeries = 2.0 + out := Add(&a, &b) + assert.Equal(t, out.Last(0), 5.0) + assert.Equal(t, out.Length(), math.MaxInt32) +} + +func TestDiv(t *testing.T) { + a := floats.Slice{3.0, 1.0, 2.0} + b := NumberSeries(2.0) + out := Div(&a, &b) + assert.Equal(t, 1.0, out.Last(0)) + assert.Equal(t, 3, out.Length()) + assert.Equal(t, 0.5, out.Last(1)) +} + +func TestMul(t *testing.T) { + a := floats.Slice{3.0, 1.0, 2.0} + b := NumberSeries(2.0) + out := Mul(&a, &b) + assert.Equal(t, out.Last(0), 4.0) + assert.Equal(t, out.Length(), 3) + assert.Equal(t, out.Last(1), 2.0) +} + +func TestArray(t *testing.T) { + a := floats.Slice{3.0, 1.0, 2.0} + out := Array(&a, 1) + assert.Equal(t, len(out), 1) + out = Array(&a, 4) + assert.Equal(t, len(out), 3) +} + +func TestSwitchInterface(t *testing.T) { + var a int = 1 + var af float64 = 1.0 + var b int32 = 2 + var bf float64 = 2.0 + var c int64 = 3 + var cf float64 = 3.0 + var d float32 = 4.0 + var df float64 = 4.0 + var e float64 = 5.0 + assert.Equal(t, switchIface(a).Last(0), af) + assert.Equal(t, switchIface(b).Last(0), bf) + assert.Equal(t, switchIface(c).Last(0), cf) + assert.Equal(t, switchIface(d).Last(0), df) + assert.Equal(t, switchIface(e).Last(0), e) +} + +// from https://en.wikipedia.org/wiki/Logistic_regression +func TestLogisticRegression(t *testing.T) { + a := []floats.Slice{{0.5, 0.75, 1., 1.25, 1.5, 1.75, 1.75, 2.0, 2.25, 2.5, 2.75, 3., 3.25, 3.5, 4., 4.25, 4.5, 4.75, 5., 5.5}} + b := floats.Slice{0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1} + var x []Series + x = append(x, &a[0]) + + model := LogisticRegression(x, &b, a[0].Length(), 90000, 0.0018) + inputs := []float64{1., 2., 2.7, 3., 4., 5.} + results := []bool{false, false, true, true, true, true} + for i, x := range inputs { + input := []float64{x} + pred := model.Predict(input) + assert.Equal(t, pred >= 0.5, results[i]) + } +} + +func TestDot(t *testing.T) { + a := floats.Slice{7, 6, 5, 4, 3, 2, 1, 0} + b := floats.Slice{200., 201., 203., 204., 203., 199.} + out1 := Dot(&a, &b, 3) + assert.InDelta(t, out1, 611., 0.001) + out2 := Dot(&a, 3., 2) + assert.InDelta(t, out2, 3., 0.001) + out3 := Dot(3., &a, 2) + assert.InDelta(t, out2, out3, 0.001) + out4 := Dot(&a, 3, 2) + assert.InDelta(t, out2, 3., 0.001) + out5 := Dot(3, &a, 2) + assert.InDelta(t, out4, out5, 0.001) +} + +func TestClone(t *testing.T) { + a := NewQueue(3) + a.Update(3.) + b := Clone(a) + b.Update(4.) + assert.Equal(t, a.Last(0), 3.) + assert.Equal(t, b.Last(0), 4.) +} + +func TestPlot(t *testing.T) { + ct := NewCanvas("test", Interval5m) + a := floats.Slice{200., 205., 230., 236} + ct.Plot("test", &a, Time(time.Now()), 4) + assert.Equal(t, ct.Interval, Interval5m) + assert.Equal(t, ct.Series[0].(chart.TimeSeries).Len(), 4) + // f, _ := os.Create("output.png") + // defer f.Close() + // ct.Render(chart.PNG, f) +} + +func TestFilter(t *testing.T) { + a := floats.Slice{200., -200, 0, 1000, -100} + b := Filter(&a, func(i int, val float64) bool { + return val > 0 + }, 4) + assert.Equal(t, b.Length(), 4) + assert.Equal(t, b.Last(0), 1000.) + assert.Equal(t, b.Sum(3), 1200.) +} diff --git a/pkg/types/instance.go b/pkg/types/instance.go new file mode 100644 index 0000000..ac4f26e --- /dev/null +++ b/pkg/types/instance.go @@ -0,0 +1,5 @@ +package types + +type InstanceIDProvider interface { + InstanceID() string +} diff --git a/pkg/types/interval.go b/pkg/types/interval.go new file mode 100644 index 0000000..689da68 --- /dev/null +++ b/pkg/types/interval.go @@ -0,0 +1,192 @@ +package types + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" +) + +type Interval string + +func (i Interval) Minutes() int { + m, ok := SupportedIntervals[i] + if !ok { + return ParseInterval(i) / 60 + } + return m / 60 +} + +func (i Interval) Seconds() int { + m, ok := SupportedIntervals[i] + if !ok { + return ParseInterval(i) + } + return m +} + +// Milliseconds is specially handled, for better precision +// for ms level interval, calling Seconds and Minutes directly might trigger panic error +func (i Interval) Milliseconds() int { + t := 0 + index := 0 + for i, rn := range string(i) { + if rn >= '0' && rn <= '9' { + t = t*10 + int(rn-'0') + } else { + index = i + break + } + } + switch strings.ToLower(string(i[index:])) { + case "ms": + return t + case "s": + return t * 1000 + case "m": + t *= 60 + case "h": + t *= 60 * 60 + case "d": + t *= 60 * 60 * 24 + case "w": + t *= 60 * 60 * 24 * 7 + case "mo": + t *= 60 * 60 * 24 * 30 + default: + panic("unknown interval input: " + i) + } + return t * 1000 +} + +func (i Interval) Duration() time.Duration { + return time.Duration(i.Milliseconds()) * time.Millisecond +} + +func (i *Interval) UnmarshalJSON(b []byte) (err error) { + var a string + err = json.Unmarshal(b, &a) + if err != nil { + return err + } + + *i = Interval(a) + return +} + +func (i Interval) String() string { + return string(i) +} + +type IntervalSlice []Interval + +func (s IntervalSlice) Sort() { + sort.Slice(s, func(i, j int) bool { + return s[i].Duration() < s[j].Duration() + }) +} + +func (s IntervalSlice) StringSlice() (slice []string) { + for _, interval := range s { + slice = append(slice, `"`+interval.String()+`"`) + } + return slice +} + +var Interval1s = Interval("1s") +var Interval1m = Interval("1m") +var Interval3m = Interval("3m") +var Interval5m = Interval("5m") +var Interval15m = Interval("15m") +var Interval30m = Interval("30m") +var Interval1h = Interval("1h") +var Interval2h = Interval("2h") +var Interval4h = Interval("4h") +var Interval6h = Interval("6h") +var Interval12h = Interval("12h") +var Interval1d = Interval("1d") +var Interval3d = Interval("3d") +var Interval1w = Interval("1w") +var Interval2w = Interval("2w") +var Interval1mo = Interval("1mo") + +func ParseInterval(input Interval) int { + t := 0 + index := 0 + for i, rn := range string(input) { + if rn >= '0' && rn <= '9' { + t = t*10 + int(rn-'0') + } else { + index = i + break + } + } + switch strings.ToLower(string(input[index:])) { + case "s": + return t + case "m": + t *= 60 + case "h": + t *= 60 * 60 + case "d": + t *= 60 * 60 * 24 + case "w": + t *= 60 * 60 * 24 * 7 + case "mo": + t *= 60 * 60 * 24 * 30 + default: + panic("unknown interval input: " + input) + } + return t +} + +type IntervalMap map[Interval]int + +func (m IntervalMap) Slice() (slice IntervalSlice) { + for interval := range m { + slice = append(slice, interval) + } + + return slice +} + +var SupportedIntervals = IntervalMap{ + Interval1s: 1, + Interval1m: 1 * 60, + Interval3m: 3 * 60, + Interval5m: 5 * 60, + Interval15m: 15 * 60, + Interval30m: 30 * 60, + Interval1h: 60 * 60, + Interval2h: 60 * 60 * 2, + Interval4h: 60 * 60 * 4, + Interval6h: 60 * 60 * 6, + Interval12h: 60 * 60 * 12, + Interval1d: 60 * 60 * 24, + Interval3d: 60 * 60 * 24 * 3, + Interval1w: 60 * 60 * 24 * 7, + Interval2w: 60 * 60 * 24 * 14, + Interval1mo: 60 * 60 * 24 * 30, +} + +// IntervalWindow is used by the indicators +type IntervalWindow struct { + // The interval of kline + Interval Interval `json:"interval"` + + // The windows size of the indicator (for example, EWMA and SMA) + Window int `json:"window"` + + // RightWindow is used by the pivot indicator + RightWindow *int `json:"rightWindow"` +} + +type IntervalWindowBandWidth struct { + IntervalWindow + BandWidth float64 `json:"bandWidth"` +} + +func (iw IntervalWindow) String() string { + return fmt.Sprintf("%s (%d)", iw.Interval, iw.Window) +} diff --git a/pkg/types/interval_test.go b/pkg/types/interval_test.go new file mode 100644 index 0000000..f181d6e --- /dev/null +++ b/pkg/types/interval_test.go @@ -0,0 +1,26 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseInterval(t *testing.T) { + assert.Equal(t, ParseInterval("1s"), 1) + assert.Equal(t, ParseInterval("3m"), 3*60) + assert.Equal(t, ParseInterval("15h"), 15*60*60) + assert.Equal(t, ParseInterval("72d"), 72*24*60*60) + assert.Equal(t, ParseInterval("3Mo"), 3*30*24*60*60) +} + +func TestIntervalSort(t *testing.T) { + intervals := IntervalSlice{Interval2h, Interval1m, Interval1h, Interval1d} + intervals.Sort() + assert.Equal(t, IntervalSlice{ + Interval1m, + Interval1h, + Interval2h, + Interval1d, + }, intervals) +} diff --git a/pkg/types/json.go b/pkg/types/json.go new file mode 100644 index 0000000..efc54d3 --- /dev/null +++ b/pkg/types/json.go @@ -0,0 +1,14 @@ +package types + +type JsonStruct struct { + Key string + Json string + Type string + Value interface{} +} + +type JsonArr []JsonStruct + +func (a JsonArr) Len() int { return len(a) } +func (a JsonArr) Less(i, j int) bool { return a[i].Key < a[j].Key } +func (a JsonArr) Swap(i, j int) { a[i], a[j] = a[j], a[i] } diff --git a/pkg/types/kline.go b/pkg/types/kline.go new file mode 100644 index 0000000..8d41604 --- /dev/null +++ b/pkg/types/kline.go @@ -0,0 +1,692 @@ +package types + +import ( + "fmt" + "time" + + "github.com/slack-go/slack" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/style" +) + +var Two = fixedpoint.NewFromInt(2) +var Three = fixedpoint.NewFromInt(3) + +type Direction int + +const DirectionUp = 1 +const DirectionNone = 0 +const DirectionDown = -1 + +type KLineOrWindow interface { + GetInterval() string + Direction() Direction + GetChange() fixedpoint.Value + GetMaxChange() fixedpoint.Value + GetThickness() fixedpoint.Value + + Mid() fixedpoint.Value + GetOpen() fixedpoint.Value + GetClose() fixedpoint.Value + GetHigh() fixedpoint.Value + GetLow() fixedpoint.Value + + BounceUp() bool + BounceDown() bool + GetUpperShadowRatio() fixedpoint.Value + GetLowerShadowRatio() fixedpoint.Value + + SlackAttachment() slack.Attachment +} + +type KLineQueryOptions struct { + Limit int + StartTime *time.Time + EndTime *time.Time +} + +// KLine uses binance's kline as the standard structure +type KLine struct { + GID uint64 `json:"gid" db:"gid"` + Exchange ExchangeName `json:"exchange" db:"exchange"` + + Symbol string `json:"symbol" db:"symbol"` + + StartTime Time `json:"startTime" db:"start_time"` + // EndTime follows the binance rule, to avoid endTime overlapping with the next startTime. So if your end time (2023-01-01 01:00:00) + // are overlapping with next start time interval (2023-01-01 01:00:00), you should subtract -1 time.millisecond on EndTime. + EndTime Time `json:"endTime" db:"end_time"` + + Interval Interval `json:"interval" db:"interval"` + + Open fixedpoint.Value `json:"open" db:"open"` + Close fixedpoint.Value `json:"close" db:"close"` + High fixedpoint.Value `json:"high" db:"high"` + Low fixedpoint.Value `json:"low" db:"low"` + Volume fixedpoint.Value `json:"volume" db:"volume"` + QuoteVolume fixedpoint.Value `json:"quoteVolume" db:"quote_volume"` + TakerBuyBaseAssetVolume fixedpoint.Value `json:"takerBuyBaseAssetVolume" db:"taker_buy_base_volume"` + TakerBuyQuoteAssetVolume fixedpoint.Value `json:"takerBuyQuoteAssetVolume" db:"taker_buy_quote_volume"` + + LastTradeID uint64 `json:"lastTradeID" db:"last_trade_id"` + NumberOfTrades uint64 `json:"numberOfTrades" db:"num_trades"` + Closed bool `json:"closed" db:"closed"` +} + +func (k *KLine) Set(o *KLine) { + k.GID = o.GID + k.Exchange = o.Exchange + k.Symbol = o.Symbol + k.StartTime = o.StartTime + k.EndTime = o.EndTime + k.Interval = o.Interval + k.Open = o.Open + k.Close = o.Close + k.High = o.High + k.Low = o.Low + k.Volume = o.Volume + k.QuoteVolume = o.QuoteVolume + k.TakerBuyBaseAssetVolume = o.TakerBuyBaseAssetVolume + k.TakerBuyQuoteAssetVolume = o.TakerBuyQuoteAssetVolume + k.LastTradeID = o.LastTradeID + k.NumberOfTrades = o.NumberOfTrades + k.Closed = o.Closed +} + +func (k *KLine) Merge(o *KLine) { + k.EndTime = o.EndTime + k.Close = o.Close + k.High = fixedpoint.Max(k.High, o.High) + k.Low = fixedpoint.Min(k.Low, o.Low) + k.Volume = k.Volume.Add(o.Volume) + k.QuoteVolume = k.QuoteVolume.Add(o.QuoteVolume) + k.TakerBuyBaseAssetVolume = k.TakerBuyBaseAssetVolume.Add(o.TakerBuyBaseAssetVolume) + k.TakerBuyQuoteAssetVolume = k.TakerBuyQuoteAssetVolume.Add(o.TakerBuyQuoteAssetVolume) + k.LastTradeID = o.LastTradeID + k.NumberOfTrades += o.NumberOfTrades + k.Closed = o.Closed +} + +func (k *KLine) GetStartTime() Time { + return k.StartTime +} + +func (k *KLine) GetEndTime() Time { + return k.EndTime +} + +func (k *KLine) GetInterval() Interval { + return k.Interval +} + +func (k *KLine) Mid() fixedpoint.Value { + return k.High.Add(k.Low).Div(Two) +} + +// green candle with open and close near high price +func (k *KLine) BounceUp() bool { + mid := k.Mid() + trend := k.Direction() + return trend > 0 && k.Open.Compare(mid) > 0 && k.Close.Compare(mid) > 0 +} + +// red candle with open and close near low price +func (k *KLine) BounceDown() bool { + mid := k.Mid() + trend := k.Direction() + return trend > 0 && k.Open.Compare(mid) < 0 && k.Close.Compare(mid) < 0 +} + +func (k *KLine) Direction() Direction { + o := k.GetOpen() + c := k.GetClose() + + if c.Compare(o) > 0 { + return DirectionUp + } else if c.Compare(o) < 0 { + return DirectionDown + } + return DirectionNone +} + +func (k *KLine) GetHigh() fixedpoint.Value { + return k.High +} + +func (k *KLine) GetLow() fixedpoint.Value { + return k.Low +} + +func (k *KLine) GetOpen() fixedpoint.Value { + return k.Open +} + +func (k *KLine) GetClose() fixedpoint.Value { + return k.Close +} + +func (k *KLine) GetMaxChange() fixedpoint.Value { + return k.GetHigh().Sub(k.GetLow()) +} + +func (k *KLine) GetAmplification() fixedpoint.Value { + return k.GetMaxChange().Div(k.GetLow()) +} + +// GetThickness returns the thickness of the kline. 1 => thick, 0.1 => thin +func (k *KLine) GetThickness() fixedpoint.Value { + out := k.GetChange().Div(k.GetMaxChange()) + if out.Sign() < 0 { + return out.Neg() + } + return out +} + +func (k *KLine) GetUpperShadowRatio() fixedpoint.Value { + out := k.GetUpperShadowHeight().Div(k.GetMaxChange()) + if out.Sign() < 0 { + return out.Neg() + } + return out +} + +func (k *KLine) GetUpperShadowHeight() fixedpoint.Value { + high := k.GetHigh() + open := k.GetOpen() + clos := k.GetClose() + if open.Compare(clos) > 0 { + return high.Sub(open) + } + return high.Sub(clos) +} + +func (k *KLine) GetLowerShadowRatio() fixedpoint.Value { + out := k.GetLowerShadowHeight().Div(k.GetMaxChange()) + if out.Sign() < 0 { + return out.Neg() + } + return out +} + +func (k *KLine) GetLowerShadowHeight() fixedpoint.Value { + low := k.Low + if k.Open.Compare(k.Close) < 0 { // uptrend + return k.Open.Sub(low) + } + + // downtrend + return k.Close.Sub(low) +} + +// GetBody returns the height of the candle real body +func (k *KLine) GetBody() fixedpoint.Value { + return k.GetChange() +} + +// GetChange returns Close price - Open price. +func (k *KLine) GetChange() fixedpoint.Value { + return k.Close.Sub(k.Open) +} + +func (k *KLine) Color() string { + if k.Direction() > 0 { + return style.GreenColor + } else if k.Direction() < 0 { + return style.RedColor + } + return style.GrayColor +} + +func (k *KLine) String() string { + return fmt.Sprintf("%s %s %s %s O: %.4f H: %.4f L: %.4f C: %.4f CHG: %.4f MAXCHG: %.4f V: %.4f QV: %.2f TBBV: %.2f", + k.Exchange.String(), + k.StartTime.Time().Format("2006-01-02 15:04"), + k.Symbol, k.Interval, k.Open.Float64(), k.High.Float64(), k.Low.Float64(), k.Close.Float64(), k.GetChange().Float64(), k.GetMaxChange().Float64(), k.Volume.Float64(), k.QuoteVolume.Float64(), k.TakerBuyBaseAssetVolume.Float64()) +} + +func (k *KLine) PlainText() string { + return k.String() +} + +func (k *KLine) SlackAttachment() slack.Attachment { + return slack.Attachment{ + Text: fmt.Sprintf("*%s* KLine %s", k.Symbol, k.Interval), + Color: k.Color(), + Fields: []slack.AttachmentField{ + {Title: "Open", Value: k.Open.FormatString(2), Short: true}, + {Title: "High", Value: k.High.FormatString(2), Short: true}, + {Title: "Low", Value: k.Low.FormatString(2), Short: true}, + {Title: "Close", Value: k.Close.FormatString(2), Short: true}, + {Title: "Mid", Value: k.Mid().FormatString(2), Short: true}, + {Title: "Change", Value: k.GetChange().FormatString(2), Short: true}, + {Title: "Volume", Value: k.Volume.FormatString(2), Short: true}, + {Title: "Taker Buy Base Volume", Value: k.TakerBuyBaseAssetVolume.FormatString(2), Short: true}, + {Title: "Taker Buy Quote Volume", Value: k.TakerBuyQuoteAssetVolume.FormatString(2), Short: true}, + {Title: "Max Change", Value: k.GetMaxChange().FormatString(2), Short: true}, + { + Title: "Thickness", + Value: k.GetThickness().FormatString(4), + Short: true, + }, + { + Title: "UpperShadowRatio", + Value: k.GetUpperShadowRatio().FormatString(4), + Short: true, + }, + { + Title: "LowerShadowRatio", + Value: k.GetLowerShadowRatio().FormatString(4), + Short: true, + }, + }, + Footer: "", + FooterIcon: "", + } +} + +type KLineWindow []KLine + +// ReduceClose reduces the closed prices +func (k KLineWindow) ReduceClose() fixedpoint.Value { + s := fixedpoint.Zero + + for _, kline := range k { + s = s.Add(kline.GetClose()) + } + + return s +} + +func (k KLineWindow) Len() int { + return len(k) +} + +func (k KLineWindow) First() KLine { + return k[0] +} + +func (k KLineWindow) Last() KLine { + return k[len(k)-1] +} + +func (k KLineWindow) GetInterval() Interval { + return k.First().Interval +} + +func (k KLineWindow) GetOpen() fixedpoint.Value { + first := k.First() + return first.GetOpen() +} + +func (k KLineWindow) GetClose() fixedpoint.Value { + end := len(k) - 1 + return k[end].GetClose() +} + +func (k KLineWindow) GetHigh() fixedpoint.Value { + first := k.First() + high := first.GetHigh() + for _, line := range k { + high = fixedpoint.Max(high, line.GetHigh()) + } + + return high +} + +func (k KLineWindow) GetLow() fixedpoint.Value { + first := k.First() + low := first.GetLow() + for _, line := range k { + low = fixedpoint.Min(low, line.GetLow()) + } + + return low +} + +func (k KLineWindow) GetChange() fixedpoint.Value { + return k.GetClose().Sub(k.GetOpen()) +} + +func (k KLineWindow) GetMaxChange() fixedpoint.Value { + return k.GetHigh().Sub(k.GetLow()) +} + +func (k KLineWindow) GetAmplification() fixedpoint.Value { + return k.GetMaxChange().Div(k.GetLow()) +} + +func (k KLineWindow) AllDrop() bool { + for _, n := range k { + if n.Direction() >= 0 { + return false + } + } + return true +} + +func (k KLineWindow) AllRise() bool { + for _, n := range k { + if n.Direction() <= 0 { + return false + } + } + return true +} + +func (k KLineWindow) GetTrend() int { + o := k.GetOpen() + c := k.GetClose() + + if c.Compare(o) > 0 { + return 1 + } else if c.Compare(o) < 0 { + return -1 + } + return 0 +} + +func (k KLineWindow) Color() string { + if k.GetTrend() > 0 { + return style.GreenColor + } else if k.GetTrend() < 0 { + return style.RedColor + } + return style.GrayColor +} + +// Mid price +func (k KLineWindow) Mid() fixedpoint.Value { + return k.GetHigh().Add(k.GetLow()).Div(Two) +} + +// BounceUp returns true if it's green candle with open and close near high price +func (k KLineWindow) BounceUp() bool { + mid := k.Mid() + trend := k.GetTrend() + return trend > 0 && k.GetOpen().Compare(mid) > 0 && k.GetClose().Compare(mid) > 0 +} + +// BounceDown returns true red candle with open and close near low price +func (k KLineWindow) BounceDown() bool { + mid := k.Mid() + trend := k.GetTrend() + return trend > 0 && k.GetOpen().Compare(mid) < 0 && k.GetClose().Compare(mid) < 0 +} + +func (k *KLineWindow) Add(line KLine) { + *k = append(*k, line) +} + +func (k KLineWindow) Take(size int) KLineWindow { + return k[:size] +} + +func (k KLineWindow) Tail(size int) KLineWindow { + length := len(k) + if length <= size { + win := make(KLineWindow, length) + copy(win, k) + return win + } + + win := make(KLineWindow, size) + copy(win, k[length-size:]) + return win +} + +// Truncate removes the old klines from the window +func (k *KLineWindow) Truncate(size int) { + if len(*k) <= size { + return + } + + end := len(*k) + start := end - size + if start < 0 { + start = 0 + } + kn := (*k)[start:] + *k = kn +} + +func (k KLineWindow) GetBody() fixedpoint.Value { + return k.GetChange() +} + +func (k KLineWindow) GetThickness() fixedpoint.Value { + out := k.GetChange().Div(k.GetMaxChange()) + if out.Sign() < 0 { + return out.Neg() + } + return out +} + +func (k KLineWindow) GetUpperShadowRatio() fixedpoint.Value { + out := k.GetUpperShadowHeight().Div(k.GetMaxChange()) + if out.Sign() < 0 { + return out.Neg() + } + return out +} + +func (k KLineWindow) GetUpperShadowHeight() fixedpoint.Value { + high := k.GetHigh() + open := k.GetOpen() + clos := k.GetClose() + if open.Compare(clos) > 0 { + return high.Sub(open) + } + return high.Sub(clos) +} + +func (k KLineWindow) GetLowerShadowRatio() fixedpoint.Value { + out := k.GetLowerShadowHeight().Div(k.GetMaxChange()) + if out.Sign() < 0 { + return out.Neg() + } + return out +} + +func (k KLineWindow) GetLowerShadowHeight() fixedpoint.Value { + low := k.GetLow() + open := k.GetOpen() + clos := k.GetClose() + if open.Compare(clos) < 0 { + return open.Sub(low) + } + return clos.Sub(low) +} + +func (k KLineWindow) SlackAttachment() slack.Attachment { + var first KLine + var end KLine + var windowSize = len(k) + if windowSize > 0 { + first = k[0] + end = k[windowSize-1] + } + + return slack.Attachment{ + Text: fmt.Sprintf("*%s* KLineWindow %s x %d", first.Symbol, first.Interval, windowSize), + Color: k.Color(), + Fields: []slack.AttachmentField{ + {Title: "Open", Value: k.GetOpen().FormatString(2), Short: true}, + {Title: "High", Value: k.GetHigh().FormatString(2), Short: true}, + {Title: "Low", Value: k.GetLow().FormatString(2), Short: true}, + {Title: "Close", Value: k.GetClose().FormatString(2), Short: true}, + {Title: "Mid", Value: k.Mid().FormatPercentage(2), Short: true}, + { + Title: "Change", + Value: k.GetChange().FormatString(2), + Short: true, + }, + { + Title: "Max Change", + Value: k.GetMaxChange().FormatString(2), + Short: true, + }, + { + Title: "Thickness", + Value: k.GetThickness().FormatString(4), + Short: true, + }, + { + Title: "UpperShadowRatio", + Value: k.GetUpperShadowRatio().FormatString(4), + Short: true, + }, + { + Title: "LowerShadowRatio", + Value: k.GetLowerShadowRatio().FormatString(4), + Short: true, + }, + }, + Footer: fmt.Sprintf("Since %s til %s", first.StartTime, end.EndTime), + FooterIcon: "", + } +} + +type KLineCallback func(k KLine) + +type KValueType int + +const ( + kOpUnknown KValueType = iota + kOpenValue + kCloseValue + kHighValue + kLowValue + kVolumeValue +) + +func (k *KLineWindow) High() Series { + return &KLineSeries{ + lines: k, + kv: kHighValue, + } +} + +func (k *KLineWindow) Low() Series { + return &KLineSeries{ + lines: k, + kv: kLowValue, + } +} + +func (k *KLineWindow) Open() Series { + return &KLineSeries{ + lines: k, + kv: kOpenValue, + } +} + +func (k *KLineWindow) Close() Series { + return &KLineSeries{ + lines: k, + kv: kCloseValue, + } +} + +func (k *KLineWindow) Volume() Series { + return &KLineSeries{ + lines: k, + kv: kVolumeValue, + } +} + +type KLineSeries struct { + lines *KLineWindow + kv KValueType +} + +func (k *KLineSeries) Index(i int) float64 { + return k.Last(i) +} + +func (k *KLineSeries) Last(i int) float64 { + length := len(*k.lines) + if length == 0 || length-i-1 < 0 { + return 0 + } + switch k.kv { + case kOpenValue: + return (*k.lines)[length-i-1].GetOpen().Float64() + case kCloseValue: + return (*k.lines)[length-i-1].GetClose().Float64() + case kLowValue: + return (*k.lines)[length-i-1].GetLow().Float64() + case kHighValue: + return (*k.lines)[length-i-1].GetHigh().Float64() + case kVolumeValue: + return (*k.lines)[length-i-1].Volume.Float64() + } + return 0 +} + +func (k *KLineSeries) Length() int { + return len(*k.lines) +} + +var _ Series = &KLineSeries{} + +func TradeWith(symbol string, f func(trade Trade)) func(trade Trade) { + return func(trade Trade) { + if symbol != "" && trade.Symbol != symbol { + return + } + + f(trade) + } +} + +func KLineWith(symbol string, interval Interval, callback KLineCallback) KLineCallback { + return func(k KLine) { + if k.Symbol != symbol || (k.Interval != "" && k.Interval != interval) { + return + } + callback(k) + } +} + +type KLineValueMapper func(k KLine) float64 + +func KLineOpenPriceMapper(k KLine) float64 { + return k.Open.Float64() +} + +func KLineClosePriceMapper(k KLine) float64 { + return k.Close.Float64() +} + +func KLineTypicalPriceMapper(k KLine) float64 { + return (k.High.Float64() + k.Low.Float64() + k.Close.Float64()) / 3. +} + +func KLinePriceVolumeMapper(k KLine) float64 { + return k.Close.Mul(k.Volume).Float64() +} + +func KLineVolumeMapper(k KLine) float64 { + return k.Volume.Float64() +} + +func KLineHLC3Mapper(k KLine) float64 { + return k.High.Add(k.Low).Add(k.Close).Div(Three).Float64() +} + +func MapKLinePrice(kLines []KLine, f KLineValueMapper) (prices []float64) { + for _, k := range kLines { + prices = append(prices, f(k)) + } + + return prices +} + +func KLineLowPriceMapper(k KLine) float64 { + return k.Low.Float64() +} + +func KLineHighPriceMapper(k KLine) float64 { + return k.High.Float64() +} diff --git a/pkg/types/kline_test.go b/pkg/types/kline_test.go new file mode 100644 index 0000000..f125f39 --- /dev/null +++ b/pkg/types/kline_test.go @@ -0,0 +1,58 @@ +package types + +import ( + "encoding/json" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestKLineWindow_Tail(t *testing.T) { + var jsonWin = []byte(`[ + {"open": 11600.0, "close": 11600.0, "high": 11600.0, "low": 11600.0}, + {"open": 11700.0, "close": 11700.0, "high": 11700.0, "low": 11700.0} + ]`) + var win KLineWindow + err := json.Unmarshal(jsonWin, &win) + assert.NoError(t, err) + + /*{ + {Open: 11600.0, Close: 11600.0, High: 11600.0, Low: 11600.0}, + {Open: 11700.0, Close: 11700.0, High: 11700.0, Low: 11700.0}, + }*/ + + var win2 = win.Tail(1) + assert.Len(t, win2, 1) + assert.ElementsMatch(t, win2, win[1:]) + + var win3 = win.Tail(2) + assert.Len(t, win3, 2) + assert.ElementsMatch(t, win3, win) + + var win4 = win.Tail(3) + assert.Len(t, win4, 2) + assert.ElementsMatch(t, win4, win) +} + +func TestKLineWindow_Truncate(t *testing.T) { + var jsonWin = []byte(`[ + {"open": 11600.0, "close": 11600.0, "high": 11600.0, "low": 11600.0}, + {"open": 11601.0, "close": 11600.0, "high": 11600.0, "low": 11600.0}, + {"open": 11602.0, "close": 11600.0, "high": 11600.0, "low": 11600.0}, + {"open": 11603.0, "close": 11600.0, "high": 11600.0, "low": 11600.0} + ]`) + var win KLineWindow + err := json.Unmarshal(jsonWin, &win) + assert.NoError(t, err) + + win.Truncate(5) + assert.Len(t, win, 4) + assert.Equal(t, 11603.0, win.Last().Open.Float64()) + + win.Truncate(3) + assert.Len(t, win, 3) + assert.Equal(t, 11603.0, win.Last().Open.Float64()) + + win.Truncate(1) + assert.Len(t, win, 1) + assert.Equal(t, 11603.0, win.Last().Open.Float64()) +} diff --git a/pkg/types/liquidation_info.go b/pkg/types/liquidation_info.go new file mode 100644 index 0000000..fdf949c --- /dev/null +++ b/pkg/types/liquidation_info.go @@ -0,0 +1,15 @@ +package types + +import "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + +type LiquidationInfo struct { + Symbol string + Side SideType + OrderType OrderType + TimeInForce TimeInForce + Quantity fixedpoint.Value + Price fixedpoint.Value + AveragePrice fixedpoint.Value + OrderStatus OrderStatus + TradeTime Time +} diff --git a/pkg/types/margin.go b/pkg/types/margin.go new file mode 100644 index 0000000..8e6c33f --- /dev/null +++ b/pkg/types/margin.go @@ -0,0 +1,197 @@ +package types + +import ( + "context" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +type FuturesExchange interface { + UseFutures() + UseIsolatedFutures(symbol string) + GetFuturesSettings() FuturesSettings +} + +type FuturesSettings struct { + IsFutures bool + IsIsolatedFutures bool + IsolatedFuturesSymbol string +} + +func (s FuturesSettings) GetFuturesSettings() FuturesSettings { + return s +} + +func (s *FuturesSettings) UseFutures() { + s.IsFutures = true +} + +func (s *FuturesSettings) UseIsolatedFutures(symbol string) { + s.IsFutures = true + s.IsIsolatedFutures = true + s.IsolatedFuturesSymbol = symbol +} + +// FuturesUserAsset define cross/isolated futures account asset +type FuturesUserAsset struct { + Asset string `json:"asset"` + InitialMargin fixedpoint.Value `json:"initialMargin"` + MaintMargin fixedpoint.Value `json:"maintMargin"` + MarginBalance fixedpoint.Value `json:"marginBalance"` + MaxWithdrawAmount fixedpoint.Value `json:"maxWithdrawAmount"` + OpenOrderInitialMargin fixedpoint.Value `json:"openOrderInitialMargin"` + PositionInitialMargin fixedpoint.Value `json:"positionInitialMargin"` + UnrealizedProfit fixedpoint.Value `json:"unrealizedProfit"` + WalletBalance fixedpoint.Value `json:"walletBalance"` +} + +type MarginExchange interface { + UseMargin() + UseIsolatedMargin(symbol string) + GetMarginSettings() MarginSettings +} + +// MarginBorrowRepayService provides repay and borrow actions of an crypto exchange +type MarginBorrowRepayService interface { + RepayMarginAsset(ctx context.Context, asset string, amount fixedpoint.Value) error + BorrowMarginAsset(ctx context.Context, asset string, amount fixedpoint.Value) error + QueryMarginAssetMaxBorrowable(ctx context.Context, asset string) (amount fixedpoint.Value, err error) +} + +type MarginInterest struct { + GID uint64 `json:"gid" db:"gid"` + Exchange ExchangeName `json:"exchange" db:"exchange"` + Asset string `json:"asset" db:"asset"` + Principle fixedpoint.Value `json:"principle" db:"principle"` + Interest fixedpoint.Value `json:"interest" db:"interest"` + InterestRate fixedpoint.Value `json:"interestRate" db:"interest_rate"` + IsolatedSymbol string `json:"isolatedSymbol" db:"isolated_symbol"` + Time Time `json:"time" db:"time"` +} + +type MarginLoan struct { + GID uint64 `json:"gid" db:"gid"` + Exchange ExchangeName `json:"exchange" db:"exchange"` + TransactionID uint64 `json:"transactionID" db:"transaction_id"` + Asset string `json:"asset" db:"asset"` + Principle fixedpoint.Value `json:"principle" db:"principle"` + Time Time `json:"time" db:"time"` + IsolatedSymbol string `json:"isolatedSymbol" db:"isolated_symbol"` +} + +type MarginRepay struct { + GID uint64 `json:"gid" db:"gid"` + Exchange ExchangeName `json:"exchange" db:"exchange"` + TransactionID uint64 `json:"transactionID" db:"transaction_id"` + Asset string `json:"asset" db:"asset"` + Principle fixedpoint.Value `json:"principle" db:"principle"` + Time Time `json:"time" db:"time"` + IsolatedSymbol string `json:"isolatedSymbol" db:"isolated_symbol"` +} + +type MarginLiquidation struct { + GID uint64 `json:"gid" db:"gid"` + Exchange ExchangeName `json:"exchange" db:"exchange"` + AveragePrice fixedpoint.Value `json:"averagePrice" db:"average_price"` + ExecutedQuantity fixedpoint.Value `json:"executedQuantity" db:"executed_quantity"` + OrderID uint64 `json:"orderID" db:"order_id"` + Price fixedpoint.Value `json:"price" db:"price"` + Quantity fixedpoint.Value `json:"quantity" db:"quantity"` + Side SideType `json:"side" db:"side"` + Symbol string `json:"symbol" db:"symbol"` + TimeInForce TimeInForce `json:"timeInForce" db:"time_in_force"` + IsIsolated bool `json:"isIsolated" db:"is_isolated"` + UpdatedTime Time `json:"updatedTime" db:"time"` +} + +// MarginHistoryService provides the service of querying loan history and repay history +type MarginHistoryService interface { + QueryLoanHistory(ctx context.Context, asset string, startTime, endTime *time.Time) ([]MarginLoan, error) + QueryRepayHistory(ctx context.Context, asset string, startTime, endTime *time.Time) ([]MarginRepay, error) + QueryLiquidationHistory(ctx context.Context, startTime, endTime *time.Time) ([]MarginLiquidation, error) + QueryInterestHistory(ctx context.Context, asset string, startTime, endTime *time.Time) ([]MarginInterest, error) +} + +type MarginSettings struct { + IsMargin bool + IsIsolatedMargin bool + IsolatedMarginSymbol string +} + +func (e *MarginSettings) GetMarginSettings() MarginSettings { + return *e +} + +func (e *MarginSettings) UseMargin() { + e.IsMargin = true +} + +func (e *MarginSettings) UseIsolatedMargin(symbol string) { + e.IsMargin = true + e.IsIsolatedMargin = true + e.IsolatedMarginSymbol = symbol +} + +// MarginAccount is for the cross margin account +type MarginAccount struct { + BorrowEnabled bool `json:"borrowEnabled"` + MarginLevel fixedpoint.Value `json:"marginLevel"` + TotalAssetOfBTC fixedpoint.Value `json:"totalAssetOfBtc"` + TotalLiabilityOfBTC fixedpoint.Value `json:"totalLiabilityOfBtc"` + TotalNetAssetOfBTC fixedpoint.Value `json:"totalNetAssetOfBtc"` + TradeEnabled bool `json:"tradeEnabled"` + TransferEnabled bool `json:"transferEnabled"` + UserAssets []MarginUserAsset `json:"userAssets"` +} + +// MarginUserAsset define user assets of margin account +type MarginUserAsset struct { + Asset string `json:"asset"` + Borrowed fixedpoint.Value `json:"borrowed"` + Free fixedpoint.Value `json:"free"` + Interest fixedpoint.Value `json:"interest"` + Locked fixedpoint.Value `json:"locked"` + NetAsset fixedpoint.Value `json:"netAsset"` +} + +// IsolatedMarginAccount defines isolated user assets of margin account +type IsolatedMarginAccount struct { + TotalAssetOfBTC fixedpoint.Value `json:"totalAssetOfBtc"` + TotalLiabilityOfBTC fixedpoint.Value `json:"totalLiabilityOfBtc"` + TotalNetAssetOfBTC fixedpoint.Value `json:"totalNetAssetOfBtc"` + Assets IsolatedMarginAssetMap `json:"assets"` +} + +// IsolatedMarginAsset defines isolated margin asset information, like margin level, liquidation price... etc +type IsolatedMarginAsset struct { + Symbol string `json:"symbol"` + QuoteAsset IsolatedUserAsset `json:"quoteAsset"` + BaseAsset IsolatedUserAsset `json:"baseAsset"` + + IsolatedCreated bool `json:"isolatedCreated"` + MarginLevel fixedpoint.Value `json:"marginLevel"` + MarginLevelStatus string `json:"marginLevelStatus"` + + MarginRatio fixedpoint.Value `json:"marginRatio"` + IndexPrice fixedpoint.Value `json:"indexPrice"` + LiquidatePrice fixedpoint.Value `json:"liquidatePrice"` + LiquidateRate fixedpoint.Value `json:"liquidateRate"` + + TradeEnabled bool `json:"tradeEnabled"` +} + +// IsolatedUserAsset defines isolated user assets of the margin account +type IsolatedUserAsset struct { + Asset string `json:"asset"` + Borrowed fixedpoint.Value `json:"borrowed"` + Free fixedpoint.Value `json:"free"` + Interest fixedpoint.Value `json:"interest"` + Locked fixedpoint.Value `json:"locked"` + NetAsset fixedpoint.Value `json:"netAsset"` + NetAssetOfBtc fixedpoint.Value `json:"netAssetOfBtc"` + + BorrowEnabled bool `json:"borrowEnabled"` + RepayEnabled bool `json:"repayEnabled"` + TotalAsset fixedpoint.Value `json:"totalAsset"` +} diff --git a/pkg/types/market.go b/pkg/types/market.go new file mode 100644 index 0000000..4403dab --- /dev/null +++ b/pkg/types/market.go @@ -0,0 +1,259 @@ +package types + +import ( + "math" + "strconv" + + "github.com/leekchan/accounting" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +type Market struct { + Exchange ExchangeName `json:"exchange,omitempty"` + + Symbol string `json:"symbol"` + + // LocalSymbol is used for exchange's API (exchange package internal) + LocalSymbol string `json:"localSymbol,omitempty"` + + // PricePrecision is the precision used for formatting price, 8 = 8 decimals + // can be converted from price tick step size, e.g. + // int(math.Log10(price step size)) + PricePrecision int `json:"pricePrecision"` + + // VolumePrecision is the precision used for formatting quantity and volume, 8 = 8 decimals + // can be converted from step size, e.g. + // int(math.Log10(quantity step size)) + VolumePrecision int `json:"volumePrecision"` + + // QuoteCurrency is the currency name for quote, e.g. USDT in BTC/USDT, USDC in BTC/USDC + QuoteCurrency string `json:"quoteCurrency"` + + // BaseCurrency is the current name for base, e.g. BTC in BTC/USDT, ETH in ETH/USDC + BaseCurrency string `json:"baseCurrency"` + + // The MIN_NOTIONAL filter defines the minimum notional value allowed for an order on a symbol. + // An order's notional value is the price * quantity + MinNotional fixedpoint.Value `json:"minNotional,omitempty"` + MinAmount fixedpoint.Value `json:"minAmount,omitempty"` + + // The LOT_SIZE filter defines the quantity + MinQuantity fixedpoint.Value `json:"minQuantity,omitempty"` + + // MaxQuantity is currently not used in the code + MaxQuantity fixedpoint.Value `json:"maxQuantity,omitempty"` + + // StepSize is the step size of quantity + // can be converted from precision, e.g. + // 1.0 / math.Pow10(m.BaseUnitPrecision) + StepSize fixedpoint.Value `json:"stepSize,omitempty"` + + // TickSize is the step size of price + TickSize fixedpoint.Value `json:"tickSize,omitempty"` + + MinPrice fixedpoint.Value `json:"minPrice,omitempty"` + MaxPrice fixedpoint.Value `json:"maxPrice,omitempty"` +} + +func (m Market) IsDustQuantity(quantity, price fixedpoint.Value) bool { + return quantity.Compare(m.MinQuantity) <= 0 || quantity.Mul(price).Compare(m.MinNotional) <= 0 +} + +// TruncateQuantity uses the step size to truncate floating number, in order to avoid the rounding issue +func (m Market) TruncateQuantity(quantity fixedpoint.Value) fixedpoint.Value { + var ts = m.StepSize.Float64() + var prec = int(math.Round(math.Log10(ts) * -1.0)) + var pow10 = math.Pow10(prec) + + qf := math.Trunc(quantity.Float64() * pow10) + qf = qf / pow10 + + qs := strconv.FormatFloat(qf, 'f', prec, 64) + return fixedpoint.MustNewFromString(qs) +} + +// TruncateQuoteQuantity uses the tick size to truncate floating number, in order to avoid the rounding issue +// this is usually used for calculating the order size from the quote quantity. +func (m Market) TruncateQuoteQuantity(quantity fixedpoint.Value) fixedpoint.Value { + var ts = m.TickSize.Float64() + var prec = int(math.Round(math.Log10(ts) * -1.0)) + var pow10 = math.Pow10(prec) + + qf := math.Trunc(quantity.Float64() * pow10) + qf = qf / pow10 + + qs := strconv.FormatFloat(qf, 'f', prec, 64) + return fixedpoint.MustNewFromString(qs) +} + +// GreaterThanMinimalOrderQuantity ensures that your given balance could fit the minimal order quantity +// when side = sell, then available = base balance +// when side = buy, then available = quote balance +// The balance will be truncated first in order to calculate the minimal notional and minimal quantity +// The adjusted (truncated) order quantity will be returned +func (m Market) GreaterThanMinimalOrderQuantity( + side SideType, price, available fixedpoint.Value, +) (fixedpoint.Value, bool) { + switch side { + case SideTypeSell: + available = m.TruncateQuantity(available) + + if available.Compare(m.MinQuantity) < 0 { + return fixedpoint.Zero, false + } + + quoteAmount := price.Mul(available) + if quoteAmount.Compare(m.MinNotional) < 0 { + return fixedpoint.Zero, false + } + + return available, true + + case SideTypeBuy: + available = m.TruncateQuoteQuantity(available) + + if available.Compare(m.MinNotional) < 0 { + return fixedpoint.Zero, false + } + + quantity := available.Div(price) + quantity = m.TruncateQuantity(quantity) + if quantity.Compare(m.MinQuantity) < 0 { + return fixedpoint.Zero, false + } + + notional := quantity.Mul(price) + if notional.Compare(m.MinNotional) < 0 { + return fixedpoint.Zero, false + } + + return quantity, true + } + + return available, true +} + +// RoundDownQuantityByPrecision uses the volume precision to round down the quantity +// This is different from the TruncateQuantity, which uses StepSize (it uses fewer fractions to truncate) +func (m Market) RoundDownQuantityByPrecision(quantity fixedpoint.Value) fixedpoint.Value { + return quantity.Round(m.VolumePrecision, fixedpoint.Down) +} + +// RoundUpQuantityByPrecision uses the volume precision to round up the quantity +func (m Market) RoundUpQuantityByPrecision(quantity fixedpoint.Value) fixedpoint.Value { + return quantity.Round(m.VolumePrecision, fixedpoint.Up) +} + +func (m Market) TruncatePrice(price fixedpoint.Value) fixedpoint.Value { + return fixedpoint.MustNewFromString(m.FormatPrice(price)) +} + +func (m Market) BaseCurrencyFormatter() *accounting.Accounting { + a := accounting.DefaultAccounting(m.BaseCurrency, m.VolumePrecision) + a.Format = "%v %s" + return a +} + +func (m Market) QuoteCurrencyFormatter() *accounting.Accounting { + var format, symbol string + + switch m.QuoteCurrency { + case "USDT", "USDC", "USD": + symbol = "$" + format = "%s %v" + + default: + symbol = m.QuoteCurrency + format = "%v %s" + } + + a := accounting.DefaultAccounting(symbol, m.PricePrecision) + a.Format = format + return a +} + +func (m Market) FormatPriceCurrency(val fixedpoint.Value) string { + switch m.QuoteCurrency { + + case "USD", "USDT": + return USD.FormatMoney(val) + + case "BTC": + return BTC.FormatMoney(val) + + case "BNB": + return BNB.FormatMoney(val) + + } + + return m.FormatPrice(val) +} + +func (m Market) FormatPrice(val fixedpoint.Value) string { + // p := math.Pow10(m.PricePrecision) + return FormatPrice(val, m.TickSize) +} + +func FormatPrice(price fixedpoint.Value, tickSize fixedpoint.Value) string { + prec := int(math.Round(math.Log10(tickSize.Float64()) * -1.0)) + return price.FormatString(prec) +} + +func (m Market) FormatQuantity(val fixedpoint.Value) string { + return formatQuantity(val, m.StepSize) +} + +func formatQuantity(quantity fixedpoint.Value, lot fixedpoint.Value) string { + prec := int(math.Round(math.Abs(math.Log10(lot.Float64())))) + return quantity.FormatString(prec) +} + +func (m Market) FormatVolume(val fixedpoint.Value) string { + return val.FormatString(m.VolumePrecision) +} + +func (m Market) CanonicalizeVolume(val fixedpoint.Value) float64 { + // TODO Round + p := math.Pow10(m.VolumePrecision) + return math.Trunc(p*val.Float64()) / p +} + +func (m Market) AdjustQuantityByMinQuantity(quantity fixedpoint.Value) fixedpoint.Value { + return fixedpoint.Max(quantity, m.MinQuantity) +} + +func (m Market) RoundUpByStepSize(quantity fixedpoint.Value) fixedpoint.Value { + ts := m.StepSize.Float64() + prec := int(math.Round(math.Log10(ts) * -1.0)) + return quantity.Round(prec, fixedpoint.Up) +} + +// AdjustQuantityByMinNotional adjusts the quantity to make the amount greater than the given minAmount +func (m Market) AdjustQuantityByMinNotional(quantity, currentPrice fixedpoint.Value) fixedpoint.Value { + // modify quantity for the min amount + if quantity.IsZero() && m.MinNotional.Sign() > 0 { + return m.RoundUpByStepSize(m.MinNotional.Div(currentPrice)) + } + + amount := currentPrice.Mul(quantity) + if amount.Compare(m.MinNotional) < 0 { + ratio := m.MinNotional.Div(amount) + quantity = quantity.Mul(ratio) + + return m.RoundUpByStepSize(quantity) + } + + return quantity +} + +type MarketMap map[string]Market + +func (m MarketMap) Add(market Market) { + m[market.Symbol] = market +} + +func (m MarketMap) Has(symbol string) bool { + _, ok := m[symbol] + return ok +} diff --git a/pkg/types/market_test.go b/pkg/types/market_test.go new file mode 100644 index 0000000..0863cc0 --- /dev/null +++ b/pkg/types/market_test.go @@ -0,0 +1,268 @@ +package types + +import ( + "encoding/json" + "regexp" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +var s func(string) fixedpoint.Value = fixedpoint.MustNewFromString + +func TestMarket_GreaterThanMinimalOrderQuantity(t *testing.T) { + market := Market{ + Symbol: "BTCUSDT", + LocalSymbol: "BTCUSDT", + PricePrecision: 8, + VolumePrecision: 8, + QuoteCurrency: "USDT", + BaseCurrency: "BTC", + MinNotional: number(10.0), + MinAmount: number(10.0), + MinQuantity: number(0.0001), + StepSize: number(0.00001), + TickSize: number(0.01), + } + + _, ok := market.GreaterThanMinimalOrderQuantity(SideTypeSell, number(20000.0), number(0.00051)) + assert.True(t, ok) + + _, ok = market.GreaterThanMinimalOrderQuantity(SideTypeBuy, number(20000.0), number(10.0)) + assert.True(t, ok) + + _, ok = market.GreaterThanMinimalOrderQuantity(SideTypeBuy, number(20000.0), number(0.99999)) + assert.False(t, ok) +} + +func TestFormatQuantity(t *testing.T) { + quantity := formatQuantity( + s("0.12511"), + s("0.01")) + assert.Equal(t, "0.12", quantity) + + quantity = formatQuantity( + s("0.12511"), + s("0.001")) + assert.Equal(t, "0.125", quantity) +} + +func TestFormatPrice(t *testing.T) { + price := FormatPrice( + s("26.288256"), + s("0.0001")) + assert.Equal(t, "26.2882", price) + + price = FormatPrice(s("26.288656"), s("0.001")) + assert.Equal(t, "26.288", price) +} + +func TestDurationParse(t *testing.T) { + type A struct { + Duration Duration `json:"duration"` + } + + type testcase struct { + name string + input string + expected Duration + } + + var tests = []testcase{ + { + name: "int to second", + input: `{ "duration": 1 }`, + expected: Duration(time.Second), + }, + { + name: "float64 to second", + input: `{ "duration": 1.1 }`, + expected: Duration(time.Second + 100*time.Millisecond), + }, + { + name: "2m", + input: `{ "duration": "2m" }`, + expected: Duration(2 * time.Minute), + }, + { + name: "2m3s", + input: `{ "duration": "2m3s" }`, + expected: Duration(2*time.Minute + 3*time.Second), + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var a A + err := json.Unmarshal([]byte(test.input), &a) + assert.NoError(t, err) + assert.Equal(t, test.expected, a.Duration) + }) + } +} + +func Test_FormatPrice(t *testing.T) { + type args struct { + price fixedpoint.Value + tickSize fixedpoint.Value + } + tests := []struct { + name string + args args + want string + }{ + { + name: "no fraction", + args: args{ + price: fixedpoint.NewFromFloat(10.0), + tickSize: fixedpoint.NewFromFloat(0.001), + }, + want: "10.000", + }, + { + name: "fraction truncate", + args: args{ + price: fixedpoint.NewFromFloat(2.334), + tickSize: fixedpoint.NewFromFloat(0.01), + }, + want: "2.33", + }, + { + name: "fraction", + args: args{ + price: fixedpoint.NewFromFloat(2.334), + tickSize: fixedpoint.NewFromFloat(0.0001), + }, + want: "2.3340", + }, + { + name: "more fraction", + args: args{ + price: fixedpoint.MustNewFromString("2.1234567898888"), + tickSize: fixedpoint.NewFromFloat(0.0001), + }, + want: "2.1234", + }, + } + + binanceFormatRE := regexp.MustCompile("^([0-9]{1,20})(.[0-9]{1,20})?$") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FormatPrice(tt.args.price, tt.args.tickSize) + if got != tt.want { + t.Errorf("FormatPrice() = %v, want %v", got, tt.want) + } + + assert.Regexp(t, binanceFormatRE, got) + }) + } +} + +func Test_formatQuantity(t *testing.T) { + type args struct { + quantity fixedpoint.Value + tickSize fixedpoint.Value + } + tests := []struct { + name string + args args + want string + }{ + { + name: "no fraction", + args: args{ + quantity: fixedpoint.NewFromFloat(10.0), + tickSize: fixedpoint.NewFromFloat(0.001), + }, + want: "10.000", + }, + { + name: "fraction truncate", + args: args{ + quantity: fixedpoint.NewFromFloat(2.334), + tickSize: fixedpoint.NewFromFloat(0.01), + }, + want: "2.33", + }, + { + name: "fraction", + args: args{ + quantity: fixedpoint.NewFromFloat(2.334), + tickSize: fixedpoint.NewFromFloat(0.0001), + }, + want: "2.3340", + }, + { + name: "more fraction", + args: args{ + quantity: fixedpoint.MustNewFromString("2.1234567898888"), + tickSize: fixedpoint.NewFromFloat(0.0001), + }, + want: "2.1234", + }, + } + + binanceFormatRE := regexp.MustCompile("^([0-9]{1,20})(.[0-9]{1,20})?$") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatQuantity(tt.args.quantity, tt.args.tickSize) + if got != tt.want { + t.Errorf("formatQuantity() = %v, want %v", got, tt.want) + } + + assert.Regexp(t, binanceFormatRE, got) + }) + } +} + +func TestMarket_TruncateQuantity(t *testing.T) { + market := Market{ + StepSize: fixedpoint.NewFromFloat(0.0001), + } + + testCases := []struct { + input string + expect string + }{ + {"0.00573961", "0.0057"}, + {"0.00579961", "0.0057"}, + {"0.0057", "0.0057"}, + } + + for _, testCase := range testCases { + q := fixedpoint.MustNewFromString(testCase.input) + q2 := market.TruncateQuantity(q) + assert.Equalf(t, testCase.expect, q2.String(), "input: %s stepSize: %s", testCase.input, market.StepSize.String()) + } + +} + +func TestMarket_AdjustQuantityByMinNotional(t *testing.T) { + market := Market{ + Symbol: "ETHUSDT", + StepSize: fixedpoint.NewFromFloat(0.0001), + MinQuantity: fixedpoint.NewFromFloat(0.00045), + MinNotional: fixedpoint.NewFromFloat(10.0), + VolumePrecision: 8, + PricePrecision: 2, + } + + // Quantity:0.00573961 Price:1750.99 + testCases := []struct { + input string + price fixedpoint.Value + expect fixedpoint.Value + }{ + {"0.00573961", number(1750.99), number("0.005739")}, + {"0.0019", number(1757.38), number("0.0057")}, + } + + for _, testCase := range testCases { + q := fixedpoint.MustNewFromString(testCase.input) + q2 := market.AdjustQuantityByMinNotional(q, testCase.price) + assert.InDelta(t, testCase.expect.Float64(), q2.Float64(), 0.000001, "input: %s stepSize: %s", testCase.input, market.StepSize.String()) + assert.False(t, market.IsDustQuantity(q2, testCase.price)) + } +} diff --git a/pkg/types/mocks/mock_exchange.go b/pkg/types/mocks/mock_exchange.go new file mode 100644 index 0000000..ab496a2 --- /dev/null +++ b/pkg/types/mocks/mock_exchange.go @@ -0,0 +1,227 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: git.qtrade.icu/lychiyu/qbtrade/pkg/types (interfaces: Exchange) +// +// Generated by this command: +// +// mockgen -destination=mocks/mock_exchange.go -package=mocks . Exchange +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + types "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + gomock "go.uber.org/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 := []any{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 any, arg1 ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{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 any) *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 any) *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 any) *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 any) *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 any) *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 any) *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 := []any{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 any, arg1 ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryTickers", reflect.TypeOf((*MockExchange)(nil).QueryTickers), varargs...) +} + +// SubmitOrder mocks base method. +func (m *MockExchange) SubmitOrder(arg0 context.Context, arg1 types.SubmitOrder) (*types.Order, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubmitOrder", arg0, arg1) + ret0, _ := ret[0].(*types.Order) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SubmitOrder indicates an expected call of SubmitOrder. +func (mr *MockExchangeMockRecorder) SubmitOrder(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitOrder", reflect.TypeOf((*MockExchange)(nil).SubmitOrder), arg0, arg1) +} diff --git a/pkg/types/mocks/mock_exchange_order_query.go b/pkg/types/mocks/mock_exchange_order_query.go new file mode 100644 index 0000000..e4e2236 --- /dev/null +++ b/pkg/types/mocks/mock_exchange_order_query.go @@ -0,0 +1,71 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: git.qtrade.icu/lychiyu/qbtrade/pkg/types (interfaces: ExchangeOrderQueryService) +// +// Generated by this command: +// +// mockgen -destination=mocks/mock_exchange_order_query.go -package=mocks . ExchangeOrderQueryService +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + types "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + gomock "go.uber.org/mock/gomock" +) + +// MockExchangeOrderQueryService is a mock of ExchangeOrderQueryService interface. +type MockExchangeOrderQueryService struct { + ctrl *gomock.Controller + recorder *MockExchangeOrderQueryServiceMockRecorder +} + +// MockExchangeOrderQueryServiceMockRecorder is the mock recorder for MockExchangeOrderQueryService. +type MockExchangeOrderQueryServiceMockRecorder struct { + mock *MockExchangeOrderQueryService +} + +// NewMockExchangeOrderQueryService creates a new mock instance. +func NewMockExchangeOrderQueryService(ctrl *gomock.Controller) *MockExchangeOrderQueryService { + mock := &MockExchangeOrderQueryService{ctrl: ctrl} + mock.recorder = &MockExchangeOrderQueryServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockExchangeOrderQueryService) EXPECT() *MockExchangeOrderQueryServiceMockRecorder { + return m.recorder +} + +// QueryOrder mocks base method. +func (m *MockExchangeOrderQueryService) QueryOrder(arg0 context.Context, arg1 types.OrderQuery) (*types.Order, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "QueryOrder", arg0, arg1) + ret0, _ := ret[0].(*types.Order) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// QueryOrder indicates an expected call of QueryOrder. +func (mr *MockExchangeOrderQueryServiceMockRecorder) QueryOrder(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryOrder", reflect.TypeOf((*MockExchangeOrderQueryService)(nil).QueryOrder), arg0, arg1) +} + +// QueryOrderTrades mocks base method. +func (m *MockExchangeOrderQueryService) QueryOrderTrades(arg0 context.Context, arg1 types.OrderQuery) ([]types.Trade, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "QueryOrderTrades", arg0, arg1) + ret0, _ := ret[0].([]types.Trade) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// QueryOrderTrades indicates an expected call of QueryOrderTrades. +func (mr *MockExchangeOrderQueryServiceMockRecorder) QueryOrderTrades(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryOrderTrades", reflect.TypeOf((*MockExchangeOrderQueryService)(nil).QueryOrderTrades), arg0, arg1) +} diff --git a/pkg/types/mocks/mock_exchange_public.go b/pkg/types/mocks/mock_exchange_public.go new file mode 100644 index 0000000..b1402ec --- /dev/null +++ b/pkg/types/mocks/mock_exchange_public.go @@ -0,0 +1,148 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: git.qtrade.icu/lychiyu/qbtrade/pkg/types (interfaces: ExchangePublic) +// +// Generated by this command: +// +// mockgen -destination=mocks/mock_exchange_public.go -package=mocks . ExchangePublic +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + types "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + gomock "go.uber.org/mock/gomock" +) + +// MockExchangePublic is a mock of ExchangePublic interface. +type MockExchangePublic struct { + ctrl *gomock.Controller + recorder *MockExchangePublicMockRecorder +} + +// MockExchangePublicMockRecorder is the mock recorder for MockExchangePublic. +type MockExchangePublicMockRecorder struct { + mock *MockExchangePublic +} + +// NewMockExchangePublic creates a new mock instance. +func NewMockExchangePublic(ctrl *gomock.Controller) *MockExchangePublic { + mock := &MockExchangePublic{ctrl: ctrl} + mock.recorder = &MockExchangePublicMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockExchangePublic) EXPECT() *MockExchangePublicMockRecorder { + return m.recorder +} + +// Name mocks base method. +func (m *MockExchangePublic) 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 *MockExchangePublicMockRecorder) Name() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Name", reflect.TypeOf((*MockExchangePublic)(nil).Name)) +} + +// NewStream mocks base method. +func (m *MockExchangePublic) 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 *MockExchangePublicMockRecorder) NewStream() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewStream", reflect.TypeOf((*MockExchangePublic)(nil).NewStream)) +} + +// PlatformFeeCurrency mocks base method. +func (m *MockExchangePublic) 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 *MockExchangePublicMockRecorder) PlatformFeeCurrency() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PlatformFeeCurrency", reflect.TypeOf((*MockExchangePublic)(nil).PlatformFeeCurrency)) +} + +// QueryKLines mocks base method. +func (m *MockExchangePublic) 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 *MockExchangePublicMockRecorder) QueryKLines(arg0, arg1, arg2, arg3 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryKLines", reflect.TypeOf((*MockExchangePublic)(nil).QueryKLines), arg0, arg1, arg2, arg3) +} + +// QueryMarkets mocks base method. +func (m *MockExchangePublic) 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 *MockExchangePublicMockRecorder) QueryMarkets(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryMarkets", reflect.TypeOf((*MockExchangePublic)(nil).QueryMarkets), arg0) +} + +// QueryTicker mocks base method. +func (m *MockExchangePublic) 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 *MockExchangePublicMockRecorder) QueryTicker(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryTicker", reflect.TypeOf((*MockExchangePublic)(nil).QueryTicker), arg0, arg1) +} + +// QueryTickers mocks base method. +func (m *MockExchangePublic) QueryTickers(arg0 context.Context, arg1 ...string) (map[string]types.Ticker, error) { + m.ctrl.T.Helper() + varargs := []any{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 *MockExchangePublicMockRecorder) QueryTickers(arg0 any, arg1 ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryTickers", reflect.TypeOf((*MockExchangePublic)(nil).QueryTickers), varargs...) +} diff --git a/pkg/types/mocks/mock_exchange_trade_history.go b/pkg/types/mocks/mock_exchange_trade_history.go new file mode 100644 index 0000000..60de29d --- /dev/null +++ b/pkg/types/mocks/mock_exchange_trade_history.go @@ -0,0 +1,72 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: git.qtrade.icu/lychiyu/qbtrade/pkg/types (interfaces: ExchangeTradeHistoryService) +// +// Generated by this command: +// +// mockgen -destination=mocks/mock_exchange_trade_history.go -package=mocks . ExchangeTradeHistoryService +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + time "time" + + types "git.qtrade.icu/lychiyu/qbtrade/pkg/types" + gomock "go.uber.org/mock/gomock" +) + +// MockExchangeTradeHistoryService is a mock of ExchangeTradeHistoryService interface. +type MockExchangeTradeHistoryService struct { + ctrl *gomock.Controller + recorder *MockExchangeTradeHistoryServiceMockRecorder +} + +// MockExchangeTradeHistoryServiceMockRecorder is the mock recorder for MockExchangeTradeHistoryService. +type MockExchangeTradeHistoryServiceMockRecorder struct { + mock *MockExchangeTradeHistoryService +} + +// NewMockExchangeTradeHistoryService creates a new mock instance. +func NewMockExchangeTradeHistoryService(ctrl *gomock.Controller) *MockExchangeTradeHistoryService { + mock := &MockExchangeTradeHistoryService{ctrl: ctrl} + mock.recorder = &MockExchangeTradeHistoryServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockExchangeTradeHistoryService) EXPECT() *MockExchangeTradeHistoryServiceMockRecorder { + return m.recorder +} + +// QueryClosedOrders mocks base method. +func (m *MockExchangeTradeHistoryService) QueryClosedOrders(arg0 context.Context, arg1 string, arg2, arg3 time.Time, arg4 uint64) ([]types.Order, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "QueryClosedOrders", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].([]types.Order) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// QueryClosedOrders indicates an expected call of QueryClosedOrders. +func (mr *MockExchangeTradeHistoryServiceMockRecorder) QueryClosedOrders(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryClosedOrders", reflect.TypeOf((*MockExchangeTradeHistoryService)(nil).QueryClosedOrders), arg0, arg1, arg2, arg3, arg4) +} + +// QueryTrades mocks base method. +func (m *MockExchangeTradeHistoryService) QueryTrades(arg0 context.Context, arg1 string, arg2 *types.TradeQueryOptions) ([]types.Trade, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "QueryTrades", arg0, arg1, arg2) + ret0, _ := ret[0].([]types.Trade) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// QueryTrades indicates an expected call of QueryTrades. +func (mr *MockExchangeTradeHistoryServiceMockRecorder) QueryTrades(arg0, arg1, arg2 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryTrades", reflect.TypeOf((*MockExchangeTradeHistoryService)(nil).QueryTrades), arg0, arg1, arg2) +} diff --git a/pkg/types/omega.go b/pkg/types/omega.go new file mode 100644 index 0000000..0556bb0 --- /dev/null +++ b/pkg/types/omega.go @@ -0,0 +1,28 @@ +package types + +// Determines the Omega ratio of a strategy +// See https://en.wikipedia.org/wiki/Omega_ratio for more details +// +// @param returns (Series): Series of profit/loss percentage every specific interval +// @param returnThresholds(float64): threshold for returns filtering +// @return Omega ratio for give return series and threshold +func Omega(returns Series, returnThresholds ...float64) float64 { + threshold := 0.0 + if len(returnThresholds) > 0 { + threshold = returnThresholds[0] + } else { + threshold = Mean(returns) + } + length := returns.Length() + win := 0.0 + loss := 0.0 + for i := 0; i < length; i++ { + out := threshold - returns.Last(i) + if out > 0 { + win += out + } else { + loss -= out + } + } + return win / loss +} diff --git a/pkg/types/omega_test.go b/pkg/types/omega_test.go new file mode 100644 index 0000000..ed27509 --- /dev/null +++ b/pkg/types/omega_test.go @@ -0,0 +1,15 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" +) + +func TestOmega(t *testing.T) { + var a Series = &floats.Slice{0.08, 0.09, 0.07, 0.15, 0.02, 0.03, 0.04, 0.05, 0.06, 0.01} + output := Omega(a) + assert.InDelta(t, output, 1, 0.0001) +} diff --git a/pkg/types/order.go b/pkg/types/order.go new file mode 100644 index 0000000..d1fd9bf --- /dev/null +++ b/pkg/types/order.go @@ -0,0 +1,463 @@ +package types + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/pkg/errors" + "github.com/slack-go/slack" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util/templateutil" +) + +func init() { + // make sure we can cast Order to PlainText + _ = PlainText(Order{}) + _ = PlainText(&Order{}) +} + +type CancelReplaceModeType string + +var ( + StopOnFailure CancelReplaceModeType = "STOP_ON_FAILURE" + AllowFailure CancelReplaceModeType = "ALLOW_FAILURE" +) + +type TimeInForce string + +var ( + TimeInForceGTC TimeInForce = "GTC" + TimeInForceIOC TimeInForce = "IOC" + TimeInForceFOK TimeInForce = "FOK" +) + +// MarginOrderSideEffectType define side effect type for orders +type MarginOrderSideEffectType string + +var ( + SideEffectTypeNoSideEffect MarginOrderSideEffectType = "NO_SIDE_EFFECT" + SideEffectTypeMarginBuy MarginOrderSideEffectType = "MARGIN_BUY" + SideEffectTypeAutoRepay MarginOrderSideEffectType = "AUTO_REPAY" +) + +func (t *MarginOrderSideEffectType) UnmarshalJSON(data []byte) error { + var s string + var err = json.Unmarshal(data, &s) + if err != nil { + return errors.Wrapf(err, "unable to unmarshal side effect type: %s", data) + } + + switch strings.ToUpper(s) { + + case string(SideEffectTypeNoSideEffect), "": + *t = SideEffectTypeNoSideEffect + return nil + + case string(SideEffectTypeMarginBuy), "BORROW", "MARGINBUY": + *t = SideEffectTypeMarginBuy + return nil + + case string(SideEffectTypeAutoRepay), "REPAY", "AUTOREPAY": + *t = SideEffectTypeAutoRepay + return nil + + } + + return fmt.Errorf("invalid side effect type: %s", data) +} + +// OrderType define order type +type OrderType string + +const ( + OrderTypeLimit OrderType = "LIMIT" + OrderTypeLimitMaker OrderType = "LIMIT_MAKER" + OrderTypeMarket OrderType = "MARKET" + OrderTypeStopLimit OrderType = "STOP_LIMIT" + OrderTypeStopMarket OrderType = "STOP_MARKET" +) + +/* +func (t *OrderType) Scan(v interface{}) error { + switch d := v.(type) { + case string: + *t = OrderType(d) + + default: + return errors.New("order type scan error, type unsupported") + + } + return nil +} +*/ + +const NoClientOrderID = "0" + +type OrderStatus string + +const ( + // OrderStatusNew means the order is active on the orderbook without any filling. + OrderStatusNew OrderStatus = "NEW" + + // OrderStatusFilled means the order is fully-filled, it's an end state. + OrderStatusFilled OrderStatus = "FILLED" + + // OrderStatusPartiallyFilled means the order is partially-filled, it's an end state, the order might be canceled in the end. + OrderStatusPartiallyFilled OrderStatus = "PARTIALLY_FILLED" + + // OrderStatusCanceled means the order is canceled without partially filled or filled. + OrderStatusCanceled OrderStatus = "CANCELED" + + // OrderStatusRejected means the order is not placed successfully, it's rejected by the api + OrderStatusRejected OrderStatus = "REJECTED" +) + +func (o OrderStatus) Closed() bool { + return o == OrderStatusFilled || + o == OrderStatusCanceled || + o == OrderStatusRejected +} + +type SubmitOrder struct { + ClientOrderID string `json:"clientOrderID,omitempty" db:"client_order_id"` + + Symbol string `json:"symbol" db:"symbol"` + Side SideType `json:"side" db:"side"` + Type OrderType `json:"orderType" db:"order_type"` + + Quantity fixedpoint.Value `json:"quantity" db:"quantity"` + Price fixedpoint.Value `json:"price" db:"price"` + + // AveragePrice is only used in back-test currently + AveragePrice fixedpoint.Value `json:"averagePrice,omitempty"` + + StopPrice fixedpoint.Value `json:"stopPrice,omitempty" db:"stop_price"` + + Market Market `json:"-" db:"-"` + + TimeInForce TimeInForce `json:"timeInForce,omitempty" db:"time_in_force"` // GTC, IOC, FOK + + GroupID uint32 `json:"groupID,omitempty"` + + MarginSideEffect MarginOrderSideEffectType `json:"marginSideEffect,omitempty"` // AUTO_REPAY = repay, MARGIN_BUY = borrow, defaults to NO_SIDE_EFFECT + + ReduceOnly bool `json:"reduceOnly,omitempty" db:"reduce_only"` + ClosePosition bool `json:"closePosition,omitempty" db:"close_position"` + + Tag string `json:"tag,omitempty" db:"-"` +} + +func (o *SubmitOrder) In() (fixedpoint.Value, string) { + switch o.Side { + case SideTypeBuy: + if o.AveragePrice.IsZero() { + return o.Quantity.Mul(o.Price), o.Market.QuoteCurrency + } else { + return o.Quantity.Mul(o.AveragePrice), o.Market.QuoteCurrency + } + + case SideTypeSell: + return o.Quantity, o.Market.BaseCurrency + + } + + return fixedpoint.Zero, "" +} + +func (o *SubmitOrder) Out() (fixedpoint.Value, string) { + switch o.Side { + case SideTypeBuy: + return o.Quantity, o.Market.BaseCurrency + + case SideTypeSell: + if o.AveragePrice.IsZero() { + return o.Quantity.Mul(o.Price), o.Market.QuoteCurrency + } else { + return o.Quantity.Mul(o.AveragePrice), o.Market.QuoteCurrency + } + } + + return fixedpoint.Zero, "" +} + +func (o *SubmitOrder) String() string { + switch o.Type { + case OrderTypeMarket: + return fmt.Sprintf("SubmitOrder %s %s %s %s", o.Symbol, o.Type, o.Side, o.Quantity.String()) + } + + return fmt.Sprintf("SubmitOrder %s %s %s %s @ %s", o.Symbol, o.Type, o.Side, o.Quantity.String(), o.Price.String()) +} + +func (o *SubmitOrder) PlainText() string { + switch o.Type { + case OrderTypeMarket: + return fmt.Sprintf("SubmitOrder %s %s %s %s", o.Symbol, o.Type, o.Side, o.Quantity.String()) + } + + return fmt.Sprintf("SubmitOrder %s %s %s %s @ %s", o.Symbol, o.Type, o.Side, o.Quantity.String(), o.Price.String()) +} + +func (o *SubmitOrder) SlackAttachment() slack.Attachment { + var fields = []slack.AttachmentField{ + {Title: "Symbol", Value: o.Symbol, Short: true}, + {Title: "Side", Value: string(o.Side), Short: true}, + {Title: "Price", Value: o.Price.String(), Short: true}, + {Title: "Quantity", Value: o.Quantity.String(), Short: true}, + } + + if o.Price.Sign() > 0 && o.Quantity.Sign() > 0 && len(o.Market.QuoteCurrency) > 0 { + if IsFiatCurrency(o.Market.QuoteCurrency) { + fields = append(fields, slack.AttachmentField{ + Title: "Amount", + Value: USD.FormatMoney(o.Price.Mul(o.Quantity)), + Short: true, + }) + } else { + fields = append(fields, slack.AttachmentField{ + Title: "Amount", + Value: fmt.Sprintf("%s %s", o.Price.Mul(o.Quantity).String(), o.Market.QuoteCurrency), + Short: true, + }) + } + } + + if len(o.ClientOrderID) > 0 { + fields = append(fields, slack.AttachmentField{Title: "ClientOrderID", Value: o.ClientOrderID, Short: true}) + } + + if len(o.MarginSideEffect) > 0 { + fields = append(fields, slack.AttachmentField{Title: "MarginSideEffect", Value: string(o.MarginSideEffect), Short: true}) + } + + return slack.Attachment{ + Color: SideToColorName(o.Side), + Title: string(o.Type) + " Order " + string(o.Side), + // Text: "", + Fields: fields, + } +} + +type OrderQuery struct { + Symbol string + OrderID string + ClientOrderID string +} + +type Order struct { + SubmitOrder + + Exchange ExchangeName `json:"exchange" db:"exchange"` + + // GID is used for relational database storage, it's an incremental ID + GID uint64 `json:"gid,omitempty" db:"gid"` + OrderID uint64 `json:"orderID" db:"order_id"` // order id + UUID string `json:"uuid,omitempty"` + + Status OrderStatus `json:"status" db:"status"` + + // OriginalStatus stores the original order status from the specific exchange + OriginalStatus string `json:"originalStatus,omitempty" db:"-"` + + // ExecutedQuantity is how much quantity has been executed + ExecutedQuantity fixedpoint.Value `json:"executedQuantity" db:"executed_quantity"` + + // IsWorking means if the order is still on the order book (active order) + IsWorking bool `json:"isWorking" db:"is_working"` + + // CreationTime is the time when this order is created + CreationTime Time `json:"creationTime" db:"created_at"` + + // UpdateTime is the latest time when this order gets updated + UpdateTime Time `json:"updateTime" db:"updated_at"` + + IsFutures bool `json:"isFutures,omitempty" db:"is_futures"` + IsMargin bool `json:"isMargin,omitempty" db:"is_margin"` + IsIsolated bool `json:"isIsolated,omitempty" db:"is_isolated"` +} + +func (o Order) CsvHeader() []string { + return []string{ + "order_id", + "symbol", + "side", + "order_type", + "status", + "price", + "quantity", + "creation_time", + "update_time", + "tag", + } +} + +func (o Order) CsvRecords() [][]string { + return [][]string{ + { + strconv.FormatUint(o.OrderID, 10), + o.Symbol, + string(o.Side), + string(o.Type), + string(o.Status), + o.Price.String(), + o.Quantity.String(), + o.CreationTime.Time().Local().Format(time.RFC1123), + o.UpdateTime.Time().Local().Format(time.RFC1123), + o.Tag, + }, + } +} + +// Backup backs up the current order quantity to a SubmitOrder object +// so that we can post the order later when we want to restore the orders. +func (o Order) Backup() SubmitOrder { + so := o.SubmitOrder + so.Quantity = o.Quantity.Sub(o.ExecutedQuantity) + + // ClientOrderID can not be reused + so.ClientOrderID = "" + return so +} + +func (o Order) String() string { + var orderID string + if o.UUID != "" { + orderID = fmt.Sprintf("UUID %s (%d)", o.UUID, o.OrderID) + } else { + orderID = strconv.FormatUint(o.OrderID, 10) + } + + desc := fmt.Sprintf("ORDER %s | %s | %s | %s %-4s | %s/%s @ %s", + o.Exchange.String(), + orderID, + o.Symbol, + o.Type, + o.Side, + o.ExecutedQuantity.String(), + o.Quantity.String(), + o.Price.String()) + + if o.Type == OrderTypeStopLimit { + desc += " Stop @ " + o.StopPrice.String() + } + + desc += " | " + string(o.Status) + " | " + + desc += time.Time(o.CreationTime).UTC().Format(time.StampMilli) + + if time.Time(o.UpdateTime).IsZero() { + desc += " -> 0" + } else { + desc += " -> " + time.Time(o.UpdateTime).UTC().Format(time.StampMilli) + } + + return desc +} + +// PlainText is used for telegram-styled messages +func (o Order) PlainText() string { + return fmt.Sprintf("Order %s %s %s %s @ %s %s/%s -> %s", + o.Exchange.String(), + o.Symbol, + o.Type, + o.Side, + o.Price.String(), + o.ExecutedQuantity.String(), + o.Quantity.String(), + o.Status) +} + +func (o Order) SlackAttachment() slack.Attachment { + var fields = []slack.AttachmentField{ + {Title: "Symbol", Value: o.Symbol, Short: true}, + {Title: "Side", Value: string(o.Side), Short: true}, + {Title: "Price", Value: o.Price.String(), Short: true}, + { + Title: "Executed Quantity", + Value: o.ExecutedQuantity.String() + "/" + o.Quantity.String(), + Short: true, + }, + } + + fields = append(fields, slack.AttachmentField{ + Title: "ID", + Value: strconv.FormatUint(o.OrderID, 10), + Short: true, + }) + + orderStatusIcon := "" + + switch o.Status { + case OrderStatusNew: + orderStatusIcon = ":new:" + case OrderStatusCanceled: + orderStatusIcon = ":eject:" + case OrderStatusPartiallyFilled: + orderStatusIcon = ":arrow_forward:" + case OrderStatusFilled: + orderStatusIcon = ":white_check_mark:" + + } + + fields = append(fields, slack.AttachmentField{ + Title: "Status", + Value: string(o.Status) + " " + orderStatusIcon, + Short: true, + }) + + footerIcon := ExchangeFooterIcon(o.Exchange) + + return slack.Attachment{ + Color: SideToColorName(o.Side), + Title: string(o.Type) + " Order " + string(o.Side), + // Text: "", + Fields: fields, + FooterIcon: footerIcon, + Footer: strings.ToLower(o.Exchange.String()) + templateutil.Render(" creation time {{ . }}", o.CreationTime.Time().Format(time.StampMilli)), + } +} + +func OrdersFilter(in []Order, f func(o Order) bool) (out []Order) { + for _, o := range in { + if f(o) { + out = append(out, o) + } + } + return out +} + +func OrdersActive(in []Order) []Order { + return OrdersFilter(in, IsActiveOrder) +} + +func OrdersFilled(in []Order) (out []Order) { + return OrdersFilter(in, func(o Order) bool { + return o.Status == OrderStatusFilled + }) +} + +func OrdersAll(orders []Order, f func(o Order) bool) bool { + for _, o := range orders { + if !f(o) { + return false + } + } + return true +} + +func OrdersAny(orders []Order, f func(o Order) bool) bool { + for _, o := range orders { + if f(o) { + return true + } + } + return false +} + +func IsActiveOrder(o Order) bool { + return o.Status == OrderStatusNew || o.Status == OrderStatusPartiallyFilled +} diff --git a/pkg/types/orderbook.go b/pkg/types/orderbook.go new file mode 100644 index 0000000..116976b --- /dev/null +++ b/pkg/types/orderbook.go @@ -0,0 +1,192 @@ +package types + +import ( + "os" + "strconv" + "sync" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +type OrderBook interface { + Spread() (fixedpoint.Value, bool) + BestAsk() (PriceVolume, bool) + BestBid() (PriceVolume, bool) + LastUpdateTime() time.Time + Reset() + Load(book SliceOrderBook) + Update(book SliceOrderBook) + Copy() OrderBook + SideBook(sideType SideType) PriceVolumeSlice + CopyDepth(depth int) OrderBook + IsValid() (bool, error) +} + +type MutexOrderBook struct { + sync.Mutex + + Symbol string + + orderBook OrderBook +} + +func NewMutexOrderBook(symbol string) *MutexOrderBook { + var book OrderBook = NewSliceOrderBook(symbol) + + if v, _ := strconv.ParseBool(os.Getenv("ENABLE_RBT_ORDERBOOK")); v { + book = NewRBOrderBook(symbol) + } + + return &MutexOrderBook{ + Symbol: symbol, + orderBook: book, + } +} + +func (b *MutexOrderBook) IsValid() (ok bool, err error) { + b.Lock() + ok, err = b.orderBook.IsValid() + b.Unlock() + return ok, err +} + +func (b *MutexOrderBook) SideBook(sideType SideType) PriceVolumeSlice { + b.Lock() + sideBook := b.orderBook.SideBook(sideType) + b.Unlock() + return sideBook +} + +func (b *MutexOrderBook) LastUpdateTime() time.Time { + b.Lock() + t := b.orderBook.LastUpdateTime() + b.Unlock() + return t +} + +func (b *MutexOrderBook) BestBidAndAsk() (bid, ask PriceVolume, ok bool) { + var ok1, ok2 bool + b.Lock() + bid, ok1 = b.orderBook.BestBid() + ask, ok2 = b.orderBook.BestAsk() + b.Unlock() + ok = ok1 && ok2 + return bid, ask, ok +} + +func (b *MutexOrderBook) BestBid() (pv PriceVolume, ok bool) { + b.Lock() + pv, ok = b.orderBook.BestBid() + b.Unlock() + return pv, ok +} + +func (b *MutexOrderBook) BestAsk() (pv PriceVolume, ok bool) { + b.Lock() + pv, ok = b.orderBook.BestAsk() + b.Unlock() + return pv, ok +} + +func (b *MutexOrderBook) Load(book SliceOrderBook) { + b.Lock() + b.orderBook.Load(book) + b.Unlock() +} + +func (b *MutexOrderBook) Reset() { + b.Lock() + b.orderBook.Reset() + b.Unlock() +} + +func (b *MutexOrderBook) CopyDepth(depth int) (ob OrderBook) { + b.Lock() + ob = b.orderBook.CopyDepth(depth) + b.Unlock() + return ob +} + +func (b *MutexOrderBook) Copy() (ob OrderBook) { + b.Lock() + ob = b.orderBook.Copy() + b.Unlock() + + return ob +} + +func (b *MutexOrderBook) Update(update SliceOrderBook) { + b.Lock() + b.orderBook.Update(update) + b.Unlock() +} + +type BookSignalType int + +const ( + BookSignalSnapshot BookSignalType = 1 + BookSignalUpdate BookSignalType = 2 +) + +type BookSignal struct { + Type BookSignalType + Time time.Time +} + +// StreamOrderBook receives streaming data from websocket connection and +// update the order book with mutex lock, so you can safely access it. +// +//go:generate callbackgen -type StreamOrderBook +type StreamOrderBook struct { + *MutexOrderBook + + C chan *BookSignal + + updateCallbacks []func(update SliceOrderBook) + snapshotCallbacks []func(snapshot SliceOrderBook) +} + +func NewStreamBook(symbol string) *StreamOrderBook { + return &StreamOrderBook{ + MutexOrderBook: NewMutexOrderBook(symbol), + C: make(chan *BookSignal, 1), + } +} + +func (sb *StreamOrderBook) BindStream(stream Stream) { + stream.OnBookSnapshot(func(book SliceOrderBook) { + if sb.MutexOrderBook.Symbol != book.Symbol { + return + } + + sb.Load(book) + sb.EmitSnapshot(book) + sb.emitChange(BookSignalSnapshot, book.Time) + }) + + stream.OnBookUpdate(func(book SliceOrderBook) { + if sb.MutexOrderBook.Symbol != book.Symbol { + return + } + + sb.Update(book) + sb.EmitUpdate(book) + sb.emitChange(BookSignalUpdate, book.Time) + }) +} + +func (sb *StreamOrderBook) emitChange(signalType BookSignalType, bookTime time.Time) { + select { + case sb.C <- &BookSignal{Type: signalType, Time: defaultTime(bookTime, time.Now)}: + default: + } +} + +func defaultTime(a time.Time, b func() time.Time) time.Time { + if a.IsZero() { + return b() + } + + return a +} diff --git a/pkg/types/orderbook_test.go b/pkg/types/orderbook_test.go new file mode 100644 index 0000000..f42580b --- /dev/null +++ b/pkg/types/orderbook_test.go @@ -0,0 +1,138 @@ +package types + +import ( + "math/rand" + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +func prepareOrderBookBenchmarkData() (asks, bids PriceVolumeSlice) { + for p := 0.0; p < 1000.0; p++ { + asks = append(asks, PriceVolume{fixedpoint.NewFromFloat(1000 + p), fixedpoint.One}) + bids = append(bids, PriceVolume{fixedpoint.NewFromFloat(1000 - 0.1 - p), fixedpoint.One}) + } + return +} + +func BenchmarkOrderBook_Load(b *testing.B) { + var asks, bids = prepareOrderBookBenchmarkData() + for p := 0.0; p < 1000.0; p++ { + asks = append(asks, PriceVolume{fixedpoint.NewFromFloat(1000 + p), fixedpoint.One}) + bids = append(bids, PriceVolume{fixedpoint.NewFromFloat(1000 - 0.1 - p), fixedpoint.One}) + } + + b.Run("RBTOrderBook", func(b *testing.B) { + book := NewRBOrderBook("ETHUSDT") + for i := 0; i < b.N; i++ { + for _, ask := range asks { + book.Asks.Upsert(ask.Price, ask.Volume) + } + for _, bid := range bids { + book.Bids.Upsert(bid.Price, bid.Volume) + } + } + }) + + b.Run("OrderBook", func(b *testing.B) { + book := &SliceOrderBook{} + for i := 0; i < b.N; i++ { + for _, ask := range asks { + book.Asks = book.Asks.Upsert(ask, false) + } + for _, bid := range bids { + book.Bids = book.Bids.Upsert(bid, true) + } + } + }) +} + +func BenchmarkOrderBook_UpdateAndInsert(b *testing.B) { + var asks, bids = prepareOrderBookBenchmarkData() + for p := 0.0; p < 1000.0; p += 2 { + asks = append(asks, PriceVolume{fixedpoint.NewFromFloat(1000 + p), fixedpoint.One}) + bids = append(bids, PriceVolume{fixedpoint.NewFromFloat(1000 - 0.1 - p), fixedpoint.One}) + } + + rbBook := NewRBOrderBook("ETHUSDT") + for _, ask := range asks { + rbBook.Asks.Upsert(ask.Price, ask.Volume) + } + for _, bid := range bids { + rbBook.Bids.Upsert(bid.Price, bid.Volume) + } + + b.Run("RBTOrderBook", func(b *testing.B) { + for i := 0; i < b.N; i++ { + var price = fixedpoint.NewFromFloat(rand.Float64() * 2000.0) + if price.Compare(fixedpoint.NewFromInt(1000)) >= 0 { + rbBook.Asks.Upsert(price, fixedpoint.One) + } else { + rbBook.Bids.Upsert(price, fixedpoint.One) + } + } + }) + + sliceBook := &SliceOrderBook{} + for i := 0; i < b.N; i++ { + for _, ask := range asks { + sliceBook.Asks = sliceBook.Asks.Upsert(ask, false) + } + for _, bid := range bids { + sliceBook.Bids = sliceBook.Bids.Upsert(bid, true) + } + } + b.Run("OrderBook", func(b *testing.B) { + for i := 0; i < b.N; i++ { + var price = fixedpoint.NewFromFloat(rand.Float64() * 2000.0) + if price.Compare(fixedpoint.NewFromFloat(1000)) >= 0 { + sliceBook.Asks = sliceBook.Asks.Upsert(PriceVolume{Price: price, Volume: fixedpoint.NewFromFloat(1)}, false) + } else { + sliceBook.Bids = sliceBook.Bids.Upsert(PriceVolume{Price: price, Volume: fixedpoint.NewFromFloat(1)}, true) + } + } + }) +} + +func TestOrderBook_IsValid(t *testing.T) { + ob := SliceOrderBook{ + Bids: PriceVolumeSlice{ + {fixedpoint.NewFromFloat(100.0), fixedpoint.NewFromFloat(1.5)}, + {fixedpoint.NewFromFloat(90.0), fixedpoint.NewFromFloat(2.5)}, + }, + + Asks: PriceVolumeSlice{ + {fixedpoint.NewFromFloat(110.0), fixedpoint.NewFromFloat(1.5)}, + {fixedpoint.NewFromFloat(120.0), fixedpoint.NewFromFloat(2.5)}, + }, + } + + isValid, err := ob.IsValid() + assert.True(t, isValid) + assert.NoError(t, err) + + ob.Bids = nil + isValid, err = ob.IsValid() + assert.False(t, isValid) + assert.EqualError(t, err, "empty bids") + + ob.Bids = PriceVolumeSlice{ + {fixedpoint.NewFromFloat(80000.0), fixedpoint.NewFromFloat(1.5)}, + {fixedpoint.NewFromFloat(120.0), fixedpoint.NewFromFloat(2.5)}, + } + + ob.Asks = nil + isValid, err = ob.IsValid() + assert.False(t, isValid) + assert.EqualError(t, err, "empty asks") + + ob.Asks = PriceVolumeSlice{ + {fixedpoint.NewFromFloat(100.0), fixedpoint.NewFromFloat(1.5)}, + {fixedpoint.NewFromFloat(90.0), fixedpoint.NewFromFloat(2.5)}, + } + isValid, err = ob.IsValid() + assert.False(t, isValid) + assert.EqualError(t, err, "bid price 80000 > ask price 100") +} diff --git a/pkg/types/ordermap.go b/pkg/types/ordermap.go new file mode 100644 index 0000000..30d5bfd --- /dev/null +++ b/pkg/types/ordermap.go @@ -0,0 +1,285 @@ +package types + +import ( + "sync" + "time" + + "github.com/sirupsen/logrus" +) + +// OrderMap is used for storing orders by their order id +type OrderMap map[uint64]Order + +func NewOrderMap(os ...Order) OrderMap { + m := OrderMap{} + if len(os) > 0 { + m.Add(os...) + } + return m +} + +func (m OrderMap) Backup() (orderForms []SubmitOrder) { + for _, order := range m { + orderForms = append(orderForms, order.Backup()) + } + + return orderForms +} + +// Add the order the map +func (m OrderMap) Add(os ...Order) { + for _, o := range os { + m[o.OrderID] = o + } +} + +// Update only updates the order when the order ID exists in the map +func (m OrderMap) Update(o Order) { + if _, ok := m[o.OrderID]; ok { + m[o.OrderID] = o + } +} + +func (m OrderMap) Lookup(f func(o Order) bool) *Order { + for _, order := range m { + if f(order) { + // copy and return + o := order + return &o + } + } + return nil +} + +func (m OrderMap) Remove(orderID uint64) { + delete(m, orderID) +} + +func (m OrderMap) IDs() (ids []uint64) { + for id := range m { + ids = append(ids, id) + } + + return ids +} + +func (m OrderMap) Exists(orderID uint64) bool { + _, ok := m[orderID] + return ok +} + +func (m OrderMap) Get(orderID uint64) (Order, bool) { + order, ok := m[orderID] + return order, ok +} + +func (m OrderMap) FindByStatus(status OrderStatus) (orders OrderSlice) { + for _, o := range m { + if o.Status == status { + orders = append(orders, o) + } + } + + return orders +} + +func (m OrderMap) Filled() OrderSlice { + return m.FindByStatus(OrderStatusFilled) +} + +func (m OrderMap) Canceled() OrderSlice { + return m.FindByStatus(OrderStatusCanceled) +} + +func (m OrderMap) Orders() (orders OrderSlice) { + for _, o := range m { + orders = append(orders, o) + } + return orders +} + +type SyncOrderMap struct { + orders OrderMap + + // pendingRemoval is for recording the order remove message for unknown orders. + // the order removal message might arrive before the order update, so if we found there is a pending removal, + // we should not keep the order in the order map + pendingRemoval map[uint64]time.Time + + sync.RWMutex +} + +func NewSyncOrderMap() *SyncOrderMap { + return &SyncOrderMap{ + orders: make(OrderMap), + pendingRemoval: make(map[uint64]time.Time, 10), + } +} + +func (m *SyncOrderMap) Backup() (orders []SubmitOrder) { + m.Lock() + orders = m.orders.Backup() + m.Unlock() + return orders +} + +func (m *SyncOrderMap) Remove(orderID uint64) (exists bool) { + m.Lock() + defer m.Unlock() + + exists = m.orders.Exists(orderID) + if exists { + m.orders.Remove(orderID) + } else { + m.pendingRemoval[orderID] = time.Now() + } + + return exists +} + +func (m *SyncOrderMap) processPendingRemoval() { + m.Lock() + defer m.Unlock() + + if len(m.pendingRemoval) == 0 { + return + } + + expireTime := time.Now().Add(-5 * time.Minute) + removing := make(map[uint64]struct{}) + for orderID, creationTime := range m.pendingRemoval { + if m.orders.Exists(orderID) || creationTime.Before(expireTime) { + m.orders.Remove(orderID) + removing[orderID] = struct{}{} + } + } + + for orderID := range removing { + delete(m.pendingRemoval, orderID) + } +} + +func (m *SyncOrderMap) Add(o Order) { + m.Lock() + m.orders.Add(o) + m.Unlock() + + m.processPendingRemoval() +} + +func (m *SyncOrderMap) Update(o Order) { + m.Lock() + m.orders.Update(o) + m.Unlock() +} + +func (m *SyncOrderMap) Iterate(it func(id uint64, order Order) bool) { + m.Lock() + for id := range m.orders { + if it(id, m.orders[id]) { + break + } + } + m.Unlock() +} + +func (m *SyncOrderMap) Exists(orderID uint64) (exists bool) { + m.Lock() + exists = m.orders.Exists(orderID) + m.Unlock() + return exists +} + +func (m *SyncOrderMap) Get(orderID uint64) (Order, bool) { + m.Lock() + order, ok := m.orders.Get(orderID) + m.Unlock() + return order, ok +} + +func (m *SyncOrderMap) Lookup(f func(o Order) bool) *Order { + m.Lock() + defer m.Unlock() + return m.orders.Lookup(f) +} + +func (m *SyncOrderMap) Len() int { + m.Lock() + defer m.Unlock() + return len(m.orders) +} + +func (m *SyncOrderMap) IDs() (ids []uint64) { + m.Lock() + ids = m.orders.IDs() + m.Unlock() + return ids +} + +func (m *SyncOrderMap) FindByStatus(status OrderStatus) OrderSlice { + m.Lock() + defer m.Unlock() + + return m.orders.FindByStatus(status) +} + +func (m *SyncOrderMap) Filled() OrderSlice { + return m.FindByStatus(OrderStatusFilled) +} + +// AnyFilled find any order is filled and stop iterating the order map +func (m *SyncOrderMap) AnyFilled() (order Order, ok bool) { + m.Lock() + defer m.Unlock() + + for _, o := range m.orders { + if o.Status == OrderStatusFilled { + ok = true + order = o + return order, ok + } + } + + return +} + +func (m *SyncOrderMap) Canceled() OrderSlice { + return m.FindByStatus(OrderStatusCanceled) +} + +func (m *SyncOrderMap) Orders() (slice OrderSlice) { + m.RLock() + slice = m.orders.Orders() + m.RUnlock() + return slice +} + +type OrderSlice []Order + +func (s *OrderSlice) Add(o Order) { + *s = append(*s, o) +} + +// Map builds up an OrderMap by the order id +func (s OrderSlice) Map() OrderMap { + return NewOrderMap(s...) +} + +func (s OrderSlice) SeparateBySide() (buyOrders, sellOrders []Order) { + for _, o := range s { + switch o.Side { + case SideTypeBuy: + buyOrders = append(buyOrders, o) + case SideTypeSell: + sellOrders = append(sellOrders, o) + } + } + + return buyOrders, sellOrders +} + +func (s OrderSlice) Print() { + for _, o := range s { + logrus.Infof("%s", o) + } +} diff --git a/pkg/types/pca.go b/pkg/types/pca.go new file mode 100644 index 0000000..d11a3c1 --- /dev/null +++ b/pkg/types/pca.go @@ -0,0 +1,59 @@ +package types + +import ( + "fmt" + + "gonum.org/v1/gonum/mat" +) + +type PCA struct { + svd *mat.SVD +} + +func (pca *PCA) FitTransform(x []SeriesExtend, lookback, feature int) ([]SeriesExtend, error) { + if err := pca.Fit(x, lookback); err != nil { + return nil, err + } + return pca.Transform(x, lookback, feature), nil +} + +func (pca *PCA) Fit(x []SeriesExtend, lookback int) error { + vec := make([]float64, lookback*len(x)) + for i, xx := range x { + mean := xx.Mean(lookback) + for j := 0; j < lookback; j++ { + vec[i+j*i] = xx.Last(j) - mean + } + } + pca.svd = &mat.SVD{} + diffMatrix := mat.NewDense(lookback, len(x), vec) + if ok := pca.svd.Factorize(diffMatrix, mat.SVDThin); !ok { + return fmt.Errorf("Unable to factorize") + } + return nil +} + +func (pca *PCA) Transform(x []SeriesExtend, lookback int, features int) (result []SeriesExtend) { + result = make([]SeriesExtend, features) + vTemp := new(mat.Dense) + pca.svd.VTo(vTemp) + var ret mat.Dense + vec := make([]float64, lookback*len(x)) + for i, xx := range x { + for j := 0; j < lookback; j++ { + vec[i+j*i] = xx.Last(j) + } + } + newX := mat.NewDense(lookback, len(x), vec) + ret.Mul(newX, vTemp) + newMatrix := mat.NewDense(lookback, features, nil) + newMatrix.Copy(&ret) + for i := 0; i < features; i++ { + queue := NewQueue(lookback) + for j := 0; j < lookback; j++ { + queue.Update(newMatrix.At(lookback-j-1, i)) + } + result[i] = queue + } + return result +} diff --git a/pkg/types/persistence_ttl.go b/pkg/types/persistence_ttl.go new file mode 100644 index 0000000..1b056ca --- /dev/null +++ b/pkg/types/persistence_ttl.go @@ -0,0 +1,18 @@ +package types + +import "time" + +type PersistenceTTL struct { + ttl time.Duration +} + +func (p *PersistenceTTL) SetTTL(ttl time.Duration) { + if ttl.Nanoseconds() <= 0 { + return + } + p.ttl = ttl +} + +func (p *PersistenceTTL) Expiration() time.Duration { + return p.ttl +} diff --git a/pkg/types/plaintext.go b/pkg/types/plaintext.go new file mode 100644 index 0000000..c4c2622 --- /dev/null +++ b/pkg/types/plaintext.go @@ -0,0 +1,9 @@ +package types + +type PlainText interface { + PlainText() string +} + +type Stringer interface { + String() string +} diff --git a/pkg/types/position.go b/pkg/types/position.go new file mode 100644 index 0000000..e80847d --- /dev/null +++ b/pkg/types/position.go @@ -0,0 +1,626 @@ +package types + +import ( + "fmt" + "sync" + "time" + + "github.com/slack-go/slack" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util/templateutil" +) + +type PositionType string + +const ( + PositionShort = PositionType("Short") + PositionLong = PositionType("Long") + PositionClosed = PositionType("Closed") +) + +type ExchangeFee struct { + MakerFeeRate fixedpoint.Value + TakerFeeRate fixedpoint.Value +} + +type PositionRisk struct { + Leverage fixedpoint.Value `json:"leverage"` + LiquidationPrice fixedpoint.Value `json:"liquidationPrice"` +} + +type Position struct { + Symbol string `json:"symbol" db:"symbol"` + BaseCurrency string `json:"baseCurrency" db:"base"` + QuoteCurrency string `json:"quoteCurrency" db:"quote"` + + Market Market `json:"market,omitempty"` + + Base fixedpoint.Value `json:"base" db:"base"` + Quote fixedpoint.Value `json:"quote" db:"quote"` + AverageCost fixedpoint.Value `json:"averageCost" db:"average_cost"` + + // ApproximateAverageCost adds the computed fee in quote in the average cost + // This is used for calculating net profit + ApproximateAverageCost fixedpoint.Value `json:"approximateAverageCost"` + + FeeRate *ExchangeFee `json:"feeRate,omitempty"` + ExchangeFeeRates map[ExchangeName]ExchangeFee `json:"exchangeFeeRates"` + + // TotalFee stores the fee currency -> total fee quantity + TotalFee map[string]fixedpoint.Value `json:"totalFee" db:"-"` + + OpenedAt time.Time `json:"openedAt,omitempty" db:"-"` + ChangedAt time.Time `json:"changedAt,omitempty" db:"changed_at"` + + Strategy string `json:"strategy,omitempty" db:"strategy"` + StrategyInstanceID string `json:"strategyInstanceID,omitempty" db:"strategy_instance_id"` + + AccumulatedProfit fixedpoint.Value `json:"accumulatedProfit,omitempty" db:"accumulated_profit"` + + // closing is a flag for marking this position is closing + closing bool + + sync.Mutex + + // Modify position callbacks + modifyCallbacks []func(baseQty fixedpoint.Value, quoteQty fixedpoint.Value, price fixedpoint.Value) + + // ttl is the ttl to keep in persistence + ttl time.Duration +} + +func (s *Position) SetTTL(ttl time.Duration) { + if ttl.Nanoseconds() <= 0 { + return + } + s.ttl = ttl +} + +func (s *Position) Expiration() time.Duration { + return s.ttl +} + +func (p *Position) CsvHeader() []string { + return []string{ + "symbol", + "time", + "average_cost", + "base", + "quote", + "accumulated_profit", + } +} + +func (p *Position) CsvRecords() [][]string { + if p.AverageCost.IsZero() && p.Base.IsZero() { + return nil + } + + return [][]string{ + { + p.Symbol, + p.ChangedAt.UTC().Format(time.RFC1123), + p.AverageCost.String(), + p.Base.String(), + p.Quote.String(), + p.AccumulatedProfit.String(), + }, + } +} + +// NewProfit generates the profit object from the current position +func (p *Position) NewProfit(trade Trade, profit, netProfit fixedpoint.Value) Profit { + return Profit{ + Symbol: p.Symbol, + QuoteCurrency: p.QuoteCurrency, + BaseCurrency: p.BaseCurrency, + AverageCost: p.AverageCost, + // profit related fields + Profit: profit, + NetProfit: netProfit, + ProfitMargin: profit.Div(trade.QuoteQuantity), + NetProfitMargin: netProfit.Div(trade.QuoteQuantity), + + // trade related fields + Trade: &trade, + TradeID: trade.ID, + OrderID: trade.OrderID, + Side: trade.Side, + IsBuyer: trade.IsBuyer, + IsMaker: trade.IsMaker, + Price: trade.Price, + Quantity: trade.Quantity, + QuoteQuantity: trade.QuoteQuantity, + // FeeInUSD: 0, + Fee: trade.Fee, + FeeCurrency: trade.FeeCurrency, + + Exchange: trade.Exchange, + IsMargin: trade.IsMargin, + IsFutures: trade.IsFutures, + IsIsolated: trade.IsIsolated, + TradedAt: trade.Time.Time(), + Strategy: p.Strategy, + StrategyInstanceID: p.StrategyInstanceID, + + PositionOpenedAt: p.OpenedAt, + } +} + +// ROI -- Return on investment (ROI) is a performance measure used to evaluate the efficiency or profitability of an investment +// or compare the efficiency of a number of different investments. +// ROI tries to directly measure the amount of return on a particular investment, relative to the investment's cost. +func (p *Position) ROI(price fixedpoint.Value) fixedpoint.Value { + unrealizedProfit := p.UnrealizedProfit(price) + cost := p.AverageCost.Mul(p.Base.Abs()) + return unrealizedProfit.Div(cost) +} + +func (p *Position) NewMarketCloseOrder(percentage fixedpoint.Value) *SubmitOrder { + base := p.GetBase() + + quantity := base.Abs() + if percentage.Compare(fixedpoint.One) < 0 { + quantity = quantity.Mul(percentage) + } + + if quantity.Compare(p.Market.MinQuantity) < 0 { + return nil + } + + side := SideTypeSell + sign := base.Sign() + if sign == 0 { + return nil + } else if sign < 0 { + side = SideTypeBuy + } + + return &SubmitOrder{ + Symbol: p.Symbol, + Market: p.Market, + Type: OrderTypeMarket, + Side: side, + Quantity: quantity, + MarginSideEffect: SideEffectTypeAutoRepay, + } +} + +func (p *Position) IsDust(a ...fixedpoint.Value) bool { + price := p.AverageCost + if len(a) > 0 { + price = a[0] + } + + base := p.Base.Abs() + return p.Market.IsDustQuantity(base, price) +} + +// GetBase locks the mutex and return the base quantity +// The base quantity can be negative +func (p *Position) GetBase() (base fixedpoint.Value) { + p.Lock() + base = p.Base + p.Unlock() + return base +} + +// GetQuantity calls GetBase() and then convert the number into a positive number +// that could be treated as a quantity. +func (p *Position) GetQuantity() fixedpoint.Value { + base := p.GetBase() + return base.Abs() +} + +func (p *Position) UnrealizedProfit(price fixedpoint.Value) fixedpoint.Value { + quantity := p.GetBase().Abs() + + if p.IsLong() { + return price.Sub(p.AverageCost).Mul(quantity) + } else if p.IsShort() { + return p.AverageCost.Sub(price).Mul(quantity) + } + + return fixedpoint.Zero +} + +func (p *Position) OnModify(cb func(baseQty fixedpoint.Value, quoteQty fixedpoint.Value, price fixedpoint.Value)) { + p.modifyCallbacks = append(p.modifyCallbacks, cb) +} + +func (p *Position) EmitModify(baseQty fixedpoint.Value, quoteQty fixedpoint.Value, price fixedpoint.Value) { + for _, cb := range p.modifyCallbacks { + cb(baseQty, quoteQty, price) + } +} + +// ModifyBase modifies position base quantity with `qty` +func (p *Position) ModifyBase(qty fixedpoint.Value) error { + p.Base = qty + + p.EmitModify(p.Base, p.Quote, p.AverageCost) + + return nil +} + +// ModifyQuote modifies position quote quantity with `qty` +func (p *Position) ModifyQuote(qty fixedpoint.Value) error { + p.Quote = qty + + p.EmitModify(p.Base, p.Quote, p.AverageCost) + + return nil +} + +// ModifyAverageCost modifies position average cost with `price` +func (p *Position) ModifyAverageCost(price fixedpoint.Value) error { + p.AverageCost = price + + p.EmitModify(p.Base, p.Quote, p.AverageCost) + + return nil +} + +type FuturesPosition struct { + Symbol string `json:"symbol"` + BaseCurrency string `json:"baseCurrency"` + QuoteCurrency string `json:"quoteCurrency"` + + Market Market `json:"market"` + + Base fixedpoint.Value `json:"base"` + Quote fixedpoint.Value `json:"quote"` + AverageCost fixedpoint.Value `json:"averageCost"` + + // ApproximateAverageCost adds the computed fee in quote in the average cost + // This is used for calculating net profit + ApproximateAverageCost fixedpoint.Value `json:"approximateAverageCost"` + + FeeRate *ExchangeFee `json:"feeRate,omitempty"` + ExchangeFeeRates map[ExchangeName]ExchangeFee `json:"exchangeFeeRates"` + + // Futures data fields + Isolated bool `json:"isolated"` + UpdateTime int64 `json:"updateTime"` + PositionRisk *PositionRisk +} + +func NewPositionFromMarket(market Market) *Position { + if len(market.BaseCurrency) == 0 || len(market.QuoteCurrency) == 0 { + panic("logical exception: missing market information, base currency or quote currency is empty") + } + + return &Position{ + Symbol: market.Symbol, + BaseCurrency: market.BaseCurrency, + QuoteCurrency: market.QuoteCurrency, + Market: market, + TotalFee: make(map[string]fixedpoint.Value), + } +} + +func NewPosition(symbol, base, quote string) *Position { + return &Position{ + Symbol: symbol, + BaseCurrency: base, + QuoteCurrency: quote, + TotalFee: make(map[string]fixedpoint.Value), + } +} + +func (p *Position) addTradeFee(trade Trade) { + if p.TotalFee == nil { + p.TotalFee = make(map[string]fixedpoint.Value) + } + p.TotalFee[trade.FeeCurrency] = p.TotalFee[trade.FeeCurrency].Add(trade.Fee) +} + +func (p *Position) Reset() { + p.Base = fixedpoint.Zero + p.Quote = fixedpoint.Zero + p.AverageCost = fixedpoint.Zero + p.TotalFee = make(map[string]fixedpoint.Value) +} + +func (p *Position) SetFeeRate(exchangeFee ExchangeFee) { + p.FeeRate = &exchangeFee +} + +func (p *Position) SetExchangeFeeRate(ex ExchangeName, exchangeFee ExchangeFee) { + if p.ExchangeFeeRates == nil { + p.ExchangeFeeRates = make(map[ExchangeName]ExchangeFee) + } + + p.ExchangeFeeRates[ex] = exchangeFee +} + +func (p *Position) IsShort() bool { + return p.Base.Sign() < 0 +} + +func (p *Position) IsLong() bool { + return p.Base.Sign() > 0 +} + +func (p *Position) IsClosed() bool { + return p.Base.Sign() == 0 +} + +func (p *Position) IsOpened(currentPrice fixedpoint.Value) bool { + return !p.IsClosed() && !p.IsDust(currentPrice) +} + +func (p *Position) Type() PositionType { + if p.Base.Sign() > 0 { + return PositionLong + } else if p.Base.Sign() < 0 { + return PositionShort + } + return PositionClosed +} + +func (p *Position) SlackAttachment() slack.Attachment { + p.Lock() + defer p.Unlock() + + averageCost := p.AverageCost + base := p.Base + quote := p.Quote + + var posType = p.Type() + var color = "" + + sign := p.Base.Sign() + if sign == 0 { + color = "#cccccc" + } else if sign > 0 { + color = "#228B22" + } else if sign < 0 { + color = "#DC143C" + } + + title := templateutil.Render(string(posType)+` Position {{ .Symbol }} `, p) + + fields := []slack.AttachmentField{ + {Title: "Average Cost", Value: averageCost.String() + " " + p.QuoteCurrency, Short: true}, + {Title: p.BaseCurrency, Value: base.String(), Short: true}, + {Title: p.QuoteCurrency, Value: quote.String()}, + } + + if p.TotalFee != nil { + for feeCurrency, fee := range p.TotalFee { + if fee.Sign() > 0 { + fields = append(fields, slack.AttachmentField{ + Title: fmt.Sprintf("Fee (%s)", feeCurrency), + Value: fee.String(), + Short: true, + }) + } + } + } + + return slack.Attachment{ + // Pretext: "", + // Text: text, + Title: title, + Color: color, + Fields: fields, + Footer: templateutil.Render("update time {{ . }}", time.Now().Format(time.RFC822)), + // FooterIcon: "", + } +} + +func (p *Position) PlainText() (msg string) { + posType := p.Type() + msg = fmt.Sprintf("%s Position %s: average cost = %v, base = %v, quote = %v", + posType, + p.Symbol, + p.AverageCost, + p.Base, + p.Quote, + ) + + if p.TotalFee != nil { + for feeCurrency, fee := range p.TotalFee { + msg += fmt.Sprintf("\nfee (%s) = %v", feeCurrency, fee) + } + } + + return msg +} + +func (p *Position) String() string { + return fmt.Sprintf("POSITION %s: average cost = %v, base = %v, quote = %v", + p.Symbol, + p.AverageCost, + p.Base, + p.Quote, + ) +} + +func (p *Position) BindStream(stream Stream) { + stream.OnTradeUpdate(func(trade Trade) { + if p.Symbol == trade.Symbol { + p.AddTrade(trade) + } + }) +} + +func (p *Position) SetClosing(c bool) bool { + p.Lock() + defer p.Unlock() + + if p.closing && c { + return false + } + + p.closing = c + return true +} + +func (p *Position) IsClosing() (c bool) { + p.Lock() + c = p.closing + p.Unlock() + return c +} + +func (p *Position) AddTrades(trades []Trade) (fixedpoint.Value, fixedpoint.Value, bool) { + var totalProfitAmount, totalNetProfit fixedpoint.Value + for _, trade := range trades { + if profit, netProfit, madeProfit := p.AddTrade(trade); madeProfit { + totalProfitAmount = totalProfitAmount.Add(profit) + totalNetProfit = totalNetProfit.Add(netProfit) + } + } + + return totalProfitAmount, totalNetProfit, !totalProfitAmount.IsZero() +} + +func (p *Position) AddTrade(td Trade) (profit fixedpoint.Value, netProfit fixedpoint.Value, madeProfit bool) { + price := td.Price + quantity := td.Quantity + quoteQuantity := td.QuoteQuantity + fee := td.Fee + + // calculated fee in quote (some exchange accounts may enable platform currency fee discount, like BNB) + // convert platform fee token into USD values + var feeInQuote = fixedpoint.Zero + + switch td.FeeCurrency { + + case p.BaseCurrency: + if !td.IsFutures { + quantity = quantity.Sub(fee) + } + + case p.QuoteCurrency: + if !td.IsFutures { + quoteQuantity = quoteQuantity.Sub(fee) + } + + default: + if !td.Fee.IsZero() { + if p.ExchangeFeeRates != nil { + if exchangeFee, ok := p.ExchangeFeeRates[td.Exchange]; ok { + if td.IsMaker { + feeInQuote = feeInQuote.Add(exchangeFee.MakerFeeRate.Mul(quoteQuantity)) + } else { + feeInQuote = feeInQuote.Add(exchangeFee.TakerFeeRate.Mul(quoteQuantity)) + } + } + } else if p.FeeRate != nil { + if td.IsMaker { + feeInQuote = feeInQuote.Add(p.FeeRate.MakerFeeRate.Mul(quoteQuantity)) + } else { + feeInQuote = feeInQuote.Add(p.FeeRate.TakerFeeRate.Mul(quoteQuantity)) + } + } + } + } + + p.Lock() + defer p.Unlock() + + // update changedAt field before we unlock in the defer func + defer func() { + p.ChangedAt = td.Time.Time() + }() + + p.addTradeFee(td) + + // Base > 0 means we're in long position + // Base < 0 means we're in short position + switch td.Side { + + case SideTypeBuy: + // was short position, now trade buy should cover the position + if p.Base.Sign() < 0 { + // convert short position to long position + if p.Base.Add(quantity).Sign() > 0 { + profit = p.AverageCost.Sub(price).Mul(p.Base.Neg()) + netProfit = p.ApproximateAverageCost.Sub(price).Mul(p.Base.Neg()).Sub(feeInQuote) + p.Base = p.Base.Add(quantity) + p.Quote = p.Quote.Sub(quoteQuantity) + p.AverageCost = price + p.ApproximateAverageCost = price + p.AccumulatedProfit = p.AccumulatedProfit.Add(profit) + p.OpenedAt = td.Time.Time() + return profit, netProfit, true + } else { + // after adding quantity it's still short position + p.Base = p.Base.Add(quantity) + p.Quote = p.Quote.Sub(quoteQuantity) + profit = p.AverageCost.Sub(price).Mul(quantity) + netProfit = p.ApproximateAverageCost.Sub(price).Mul(quantity).Sub(feeInQuote) + p.AccumulatedProfit = p.AccumulatedProfit.Add(profit) + return profit, netProfit, true + } + } + + // before adding the quantity, it's already a dust position + // then we should set the openedAt time + if p.IsDust(td.Price) { + p.OpenedAt = td.Time.Time() + } + + // here the case is: base == 0 or base > 0 + divisor := p.Base.Add(quantity) + p.ApproximateAverageCost = p.ApproximateAverageCost.Mul(p.Base). + Add(quoteQuantity). + Add(feeInQuote). + Div(divisor) + p.AverageCost = p.AverageCost.Mul(p.Base).Add(quoteQuantity).Div(divisor) + p.Base = p.Base.Add(quantity) + p.Quote = p.Quote.Sub(quoteQuantity) + return fixedpoint.Zero, fixedpoint.Zero, false + + case SideTypeSell: + // was long position, the sell trade should reduce the base amount + if p.Base.Sign() > 0 { + // convert long position to short position + if p.Base.Compare(quantity) < 0 { + profit = price.Sub(p.AverageCost).Mul(p.Base) + netProfit = price.Sub(p.ApproximateAverageCost).Mul(p.Base).Sub(feeInQuote) + p.Base = p.Base.Sub(quantity) + p.Quote = p.Quote.Add(quoteQuantity) + p.AverageCost = price + p.ApproximateAverageCost = price + p.AccumulatedProfit = p.AccumulatedProfit.Add(profit) + p.OpenedAt = td.Time.Time() + return profit, netProfit, true + } else { + p.Base = p.Base.Sub(quantity) + p.Quote = p.Quote.Add(quoteQuantity) + profit = price.Sub(p.AverageCost).Mul(quantity) + netProfit = price.Sub(p.ApproximateAverageCost).Mul(quantity).Sub(feeInQuote) + p.AccumulatedProfit = p.AccumulatedProfit.Add(profit) + return profit, netProfit, true + } + } + + // before subtracting the quantity, it's already a dust position + // then we should set the openedAt time + if p.IsDust(td.Price) { + p.OpenedAt = td.Time.Time() + } + + // handling short position, since Base here is negative we need to reverse the sign + divisor := quantity.Sub(p.Base) + p.ApproximateAverageCost = p.ApproximateAverageCost.Mul(p.Base.Neg()). + Add(quoteQuantity). + Sub(feeInQuote). + Div(divisor) + + p.AverageCost = p.AverageCost.Mul(p.Base.Neg()). + Add(quoteQuantity). + Div(divisor) + p.Base = p.Base.Sub(quantity) + p.Quote = p.Quote.Add(quoteQuantity) + + return fixedpoint.Zero, fixedpoint.Zero, false + } + + return fixedpoint.Zero, fixedpoint.Zero, false +} diff --git a/pkg/types/position_test.go b/pkg/types/position_test.go new file mode 100644 index 0000000..a3f5845 --- /dev/null +++ b/pkg/types/position_test.go @@ -0,0 +1,327 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +const Delta = 1e-9 + +func TestPosition_ROI(t *testing.T) { + t.Run("short position", func(t *testing.T) { + // Long position + pos := &Position{ + Symbol: "BTCUSDT", + BaseCurrency: "BTC", + QuoteCurrency: "USDT", + Base: fixedpoint.NewFromFloat(-10.0), + AverageCost: fixedpoint.NewFromFloat(8000.0), + Quote: fixedpoint.NewFromFloat(8000.0 * 10.0), + } + + assert.True(t, pos.IsShort(), "should be a short position") + + currentPrice := fixedpoint.NewFromFloat(5000.0) + roi := pos.ROI(currentPrice) + assert.Equal(t, "0.375", roi.String()) + assert.Equal(t, "37.5%", roi.Percentage()) + }) + + t.Run("long position", func(t *testing.T) { + // Long position + pos := &Position{ + Symbol: "BTCUSDT", + BaseCurrency: "BTC", + QuoteCurrency: "USDT", + Base: fixedpoint.NewFromFloat(10.0), + AverageCost: fixedpoint.NewFromFloat(8000.0), + Quote: fixedpoint.NewFromFloat(-8000.0 * 10.0), + } + + assert.True(t, pos.IsLong(), "should be a long position") + + currentPrice := fixedpoint.NewFromFloat(10000.0) + roi := pos.ROI(currentPrice) + assert.Equal(t, "0.25", roi.String()) + assert.Equal(t, "25%", roi.Percentage()) + }) +} + +func TestPosition_ExchangeFeeRate_Short(t *testing.T) { + pos := &Position{ + Symbol: "BTCUSDT", + BaseCurrency: "BTC", + QuoteCurrency: "USDT", + } + + feeRate := fixedpoint.NewFromFloat(0.075 * 0.01) + pos.SetExchangeFeeRate(ExchangeBinance, ExchangeFee{ + MakerFeeRate: feeRate, + TakerFeeRate: feeRate, + }) + + quantity := fixedpoint.NewFromInt(10) + quoteQuantity := fixedpoint.NewFromInt(3000).Mul(quantity) + fee := quoteQuantity.Mul(feeRate) + averageCost := quoteQuantity.Sub(fee).Div(quantity) + bnbPrice := fixedpoint.NewFromInt(570) + pos.AddTrade(Trade{ + Exchange: ExchangeBinance, + Price: fixedpoint.NewFromInt(3000), + Quantity: quantity, + QuoteQuantity: quoteQuantity, + Symbol: "BTCUSDT", + Side: SideTypeSell, + Fee: fee.Div(bnbPrice), + FeeCurrency: "BNB", + }) + + _, netProfit, madeProfit := pos.AddTrade(Trade{ + Exchange: ExchangeBinance, + Price: fixedpoint.NewFromInt(2000), + Quantity: fixedpoint.NewFromInt(10), + QuoteQuantity: fixedpoint.NewFromInt(2000 * 10), + Symbol: "BTCUSDT", + Side: SideTypeBuy, + Fee: fixedpoint.NewFromInt(2000 * 10.0).Mul(feeRate).Div(bnbPrice), + FeeCurrency: "BNB", + }) + + expectedProfit := averageCost.Sub(fixedpoint.NewFromInt(2000)). + Mul(fixedpoint.NewFromInt(10)). + Sub(fixedpoint.NewFromInt(2000).Mul(fixedpoint.NewFromInt(10)).Mul(feeRate)) + assert.True(t, madeProfit) + assert.Equal(t, expectedProfit, netProfit) +} + +func TestPosition_ExchangeFeeRate_Long(t *testing.T) { + pos := &Position{ + Symbol: "BTCUSDT", + BaseCurrency: "BTC", + QuoteCurrency: "USDT", + } + + feeRate := fixedpoint.NewFromFloat(0.075 * 0.01) + pos.SetExchangeFeeRate(ExchangeBinance, ExchangeFee{ + MakerFeeRate: feeRate, + TakerFeeRate: feeRate, + }) + + quantity := fixedpoint.NewFromInt(10) + quoteQuantity := fixedpoint.NewFromInt(3000).Mul(quantity) + fee := quoteQuantity.Mul(feeRate) + averageCost := quoteQuantity.Add(fee).Div(quantity) + bnbPrice := fixedpoint.NewFromInt(570) + pos.AddTrade(Trade{ + Exchange: ExchangeBinance, + Price: fixedpoint.NewFromInt(3000), + Quantity: quantity, + QuoteQuantity: quoteQuantity, + Symbol: "BTCUSDT", + Side: SideTypeBuy, + Fee: fee.Div(bnbPrice), + FeeCurrency: "BNB", + }) + + _, netProfit, madeProfit := pos.AddTrade(Trade{ + Exchange: ExchangeBinance, + Price: fixedpoint.NewFromInt(4000), + Quantity: fixedpoint.NewFromInt(10), + QuoteQuantity: fixedpoint.NewFromInt(4000).Mul(fixedpoint.NewFromInt(10)), + Symbol: "BTCUSDT", + Side: SideTypeSell, + Fee: fixedpoint.NewFromInt(40000).Mul(feeRate).Div(bnbPrice), + FeeCurrency: "BNB", + }) + + expectedProfit := fixedpoint.NewFromInt(4000). + Sub(averageCost).Mul(fixedpoint.NewFromInt(10)). + Sub(fixedpoint.NewFromInt(40000).Mul(feeRate)) + assert.True(t, madeProfit) + assert.Equal(t, expectedProfit, netProfit) +} + +func TestPosition(t *testing.T) { + var feeRate float64 = 0.05 * 0.01 + feeRateValue := fixedpoint.NewFromFloat(feeRate) + var testcases = []struct { + name string + trades []Trade + expectedAverageCost fixedpoint.Value + expectedBase fixedpoint.Value + expectedQuote fixedpoint.Value + expectedProfit fixedpoint.Value + }{ + { + name: "base fee", + trades: []Trade{ + { + Side: SideTypeBuy, + Price: fixedpoint.NewFromInt(1000), + Quantity: fixedpoint.NewFromFloat(0.01), + QuoteQuantity: fixedpoint.NewFromFloat(1000.0 * 0.01), + Fee: fixedpoint.MustNewFromString("0.000005"), // 0.01 * 0.05 * 0.01 + FeeCurrency: "BTC", + }, + }, + expectedAverageCost: fixedpoint.NewFromFloat(1000.0 * 0.01). + Div(fixedpoint.NewFromFloat(0.01).Mul(fixedpoint.One.Sub(feeRateValue))), + expectedBase: fixedpoint.NewFromFloat(0.01). + Sub(fixedpoint.NewFromFloat(0.01).Mul(feeRateValue)), + expectedQuote: fixedpoint.NewFromFloat(0 - 1000.0*0.01), + expectedProfit: fixedpoint.Zero, + }, + { + name: "quote fee", + trades: []Trade{ + { + Side: SideTypeSell, + Price: fixedpoint.NewFromInt(1000), + Quantity: fixedpoint.NewFromFloat(0.01), + QuoteQuantity: fixedpoint.NewFromFloat(1000.0 * 0.01), + Fee: fixedpoint.NewFromFloat((1000.0 * 0.01) * feeRate), // 0.05% + FeeCurrency: "USDT", + }, + }, + expectedAverageCost: fixedpoint.NewFromFloat(1000.0 * 0.01). + Mul(fixedpoint.One.Sub(feeRateValue)). + Div(fixedpoint.NewFromFloat(0.01)), + expectedBase: fixedpoint.NewFromFloat(-0.01), + expectedQuote: fixedpoint.NewFromFloat(0.0 + 1000.0*0.01*(1.0-feeRate)), + expectedProfit: fixedpoint.Zero, + }, + { + name: "long", + trades: []Trade{ + { + Side: SideTypeBuy, + Price: fixedpoint.NewFromInt(1000), + Quantity: fixedpoint.NewFromFloat(0.01), + QuoteQuantity: fixedpoint.NewFromFloat(1000.0 * 0.01), + }, + { + Side: SideTypeBuy, + Price: fixedpoint.NewFromInt(2000), + Quantity: fixedpoint.MustNewFromString("0.03"), + QuoteQuantity: fixedpoint.NewFromFloat(2000.0 * 0.03), + }, + }, + expectedAverageCost: fixedpoint.NewFromFloat((1000.0*0.01 + 2000.0*0.03) / 0.04), + expectedBase: fixedpoint.NewFromFloat(0.01 + 0.03), + expectedQuote: fixedpoint.NewFromFloat(0 - 1000.0*0.01 - 2000.0*0.03), + expectedProfit: fixedpoint.Zero, + }, + + { + name: "long and sell", + trades: []Trade{ + { + Side: SideTypeBuy, + Price: fixedpoint.NewFromInt(1000), + Quantity: fixedpoint.NewFromFloat(0.01), + QuoteQuantity: fixedpoint.NewFromFloat(1000.0 * 0.01), + }, + { + Side: SideTypeBuy, + Price: fixedpoint.NewFromInt(2000), + Quantity: fixedpoint.MustNewFromString("0.03"), + QuoteQuantity: fixedpoint.NewFromFloat(2000.0 * 0.03), + }, + { + Side: SideTypeSell, + Price: fixedpoint.NewFromInt(3000), + Quantity: fixedpoint.NewFromFloat(0.01), + QuoteQuantity: fixedpoint.NewFromFloat(3000.0 * 0.01), + }, + }, + expectedAverageCost: fixedpoint.NewFromFloat((1000.0*0.01 + 2000.0*0.03) / 0.04), + expectedBase: fixedpoint.MustNewFromString("0.03"), + expectedQuote: fixedpoint.NewFromFloat(0 - 1000.0*0.01 - 2000.0*0.03 + 3000.0*0.01), + expectedProfit: fixedpoint.NewFromFloat((3000.0 - (1000.0*0.01+2000.0*0.03)/0.04) * 0.01), + }, + + { + name: "long and sell to short", + trades: []Trade{ + { + Side: SideTypeBuy, + Price: fixedpoint.NewFromInt(1000), + Quantity: fixedpoint.NewFromFloat(0.01), + QuoteQuantity: fixedpoint.NewFromFloat(1000.0 * 0.01), + }, + { + Side: SideTypeBuy, + Price: fixedpoint.NewFromInt(2000), + Quantity: fixedpoint.MustNewFromString("0.03"), + QuoteQuantity: fixedpoint.NewFromFloat(2000.0 * 0.03), + }, + { + Side: SideTypeSell, + Price: fixedpoint.NewFromInt(3000), + Quantity: fixedpoint.NewFromFloat(0.10), + QuoteQuantity: fixedpoint.NewFromFloat(3000.0 * 0.10), + }, + }, + + expectedAverageCost: fixedpoint.NewFromInt(3000), + expectedBase: fixedpoint.MustNewFromString("-0.06"), + expectedQuote: fixedpoint.NewFromFloat(-1000.0*0.01 - 2000.0*0.03 + 3000.0*0.1), + expectedProfit: fixedpoint.NewFromFloat((3000.0 - (1000.0*0.01+2000.0*0.03)/0.04) * 0.04), + }, + + { + name: "short", + trades: []Trade{ + { + Side: SideTypeSell, + Price: fixedpoint.NewFromInt(2000), + Quantity: fixedpoint.NewFromFloat(0.01), + QuoteQuantity: fixedpoint.NewFromFloat(2000.0 * 0.01), + }, + { + Side: SideTypeSell, + Price: fixedpoint.NewFromInt(3000), + Quantity: fixedpoint.MustNewFromString("0.03"), + QuoteQuantity: fixedpoint.NewFromFloat(3000.0 * 0.03), + }, + }, + + expectedAverageCost: fixedpoint.NewFromFloat((2000.0*0.01 + 3000.0*0.03) / (0.01 + 0.03)), + expectedBase: fixedpoint.NewFromFloat(0 - 0.01 - 0.03), + expectedQuote: fixedpoint.NewFromFloat(2000.0*0.01 + 3000.0*0.03), + expectedProfit: fixedpoint.Zero, + }, + } + + for _, testcase := range testcases { + t.Run(testcase.name, func(t *testing.T) { + pos := Position{ + Symbol: "BTCUSDT", + BaseCurrency: "BTC", + QuoteCurrency: "USDT", + } + profitAmount, _, profit := pos.AddTrades(testcase.trades) + assert.Equal(t, testcase.expectedQuote, pos.Quote, "expectedQuote") + assert.Equal(t, testcase.expectedBase, pos.Base, "expectedBase") + assert.Equal(t, testcase.expectedAverageCost, pos.AverageCost, "expectedAverageCost") + if profit { + assert.Equal(t, testcase.expectedProfit, profitAmount, "expectedProfit") + } + }) + } +} + +func TestPosition_SetClosing(t *testing.T) { + p := NewPosition("BTCUSDT", "BTC", "USDT") + ret := p.SetClosing(true) + assert.True(t, ret) + + ret = p.SetClosing(true) + assert.False(t, ret) + + ret = p.SetClosing(false) + assert.True(t, ret) +} diff --git a/pkg/types/premiumindex.go b/pkg/types/premiumindex.go new file mode 100644 index 0000000..5d4a735 --- /dev/null +++ b/pkg/types/premiumindex.go @@ -0,0 +1,20 @@ +package types + +import ( + "fmt" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +type PremiumIndex struct { + Symbol string `json:"symbol"` + MarkPrice fixedpoint.Value `json:"markPrice"` + LastFundingRate fixedpoint.Value `json:"lastFundingRate"` + NextFundingTime time.Time `json:"nextFundingTime"` + Time time.Time `json:"time"` +} + +func (i *PremiumIndex) String() string { + return fmt.Sprintf("PremiumIndex | %s | %.4f | %s | %s | NEXT FUNDING TIME: %s", i.Symbol, i.MarkPrice.Float64(), i.LastFundingRate.Percentage(), i.Time, i.NextFundingTime) +} diff --git a/pkg/types/price_type.go b/pkg/types/price_type.go new file mode 100644 index 0000000..edd25fa --- /dev/null +++ b/pkg/types/price_type.go @@ -0,0 +1,77 @@ +package types + +import ( + "encoding/json" + "strings" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "github.com/pkg/errors" +) + +type PriceType string + +const ( + PriceTypeLast PriceType = "LAST" + PriceTypeBuy PriceType = "BUY" // BID + PriceTypeSell PriceType = "SELL" // ASK + PriceTypeMid PriceType = "MID" + PriceTypeMaker PriceType = "MAKER" + PriceTypeTaker PriceType = "TAKER" +) + +var ErrInvalidPriceType = errors.New("invalid price type") + +func ParsePriceType(s string) (p PriceType, err error) { + p = PriceType(strings.ToUpper(s)) + switch p { + case PriceTypeLast, PriceTypeBuy, PriceTypeSell, PriceTypeMid, PriceTypeMaker, PriceTypeTaker: + return p, err + } + return p, ErrInvalidPriceType +} + +func (p *PriceType) UnmarshalJSON(data []byte) error { + var s string + + if err := json.Unmarshal(data, &s); err != nil { + return err + } + + t, err := ParsePriceType(s) + if err != nil { + return err + } + + *p = t + + return nil +} + +func (p PriceType) Map(ticker *Ticker, side SideType) fixedpoint.Value { + price := ticker.Last + + switch p { + case PriceTypeLast: + price = ticker.Last + case PriceTypeBuy: + price = ticker.Buy + case PriceTypeSell: + price = ticker.Sell + case PriceTypeMid: + price = ticker.Buy.Add(ticker.Sell).Div(fixedpoint.NewFromInt(2)) + case PriceTypeMaker: + if side == SideTypeBuy { + price = ticker.Buy + } else if side == SideTypeSell { + price = ticker.Sell + } + case PriceTypeTaker: + if side == SideTypeBuy { + price = ticker.Sell + } else if side == SideTypeSell { + price = ticker.Buy + } + } + + return price +} diff --git a/pkg/types/price_volume_heartbeat.go b/pkg/types/price_volume_heartbeat.go new file mode 100644 index 0000000..17c3ed5 --- /dev/null +++ b/pkg/types/price_volume_heartbeat.go @@ -0,0 +1,45 @@ +package types + +import ( + "fmt" + "time" +) + +// PriceHeartBeat is used for monitoring the price volume update. +type PriceHeartBeat struct { + last PriceVolume + lastUpdatedTime time.Time + timeout time.Duration +} + +func NewPriceHeartBeat(timeout time.Duration) *PriceHeartBeat { + return &PriceHeartBeat{ + timeout: timeout, + } +} + +func (b *PriceHeartBeat) Last() PriceVolume { + return b.last +} + +// Update updates the price volume object and the last update time +// It returns (bool, error), when the price is successfully updated, it returns true. +// If the price is not updated (same price) and the last time exceeded the timeout, +// Then false, and an error will be returned +func (b *PriceHeartBeat) Update(current PriceVolume) (bool, error) { + if b.last.Price.IsZero() || b.last != current { + b.last = current + b.lastUpdatedTime = time.Now() + return true, nil // successfully updated + } else { + // if price and volume is not changed + if b.last.Equals(current) && time.Since(b.lastUpdatedTime) > b.timeout { + return false, fmt.Errorf("price %s has not been updating for %s, last update: %s, skip quoting", + b.last.String(), + time.Since(b.lastUpdatedTime), + b.lastUpdatedTime) + } + } + + return false, nil +} diff --git a/pkg/types/price_volume_heartbeat_test.go b/pkg/types/price_volume_heartbeat_test.go new file mode 100644 index 0000000..96fbbd0 --- /dev/null +++ b/pkg/types/price_volume_heartbeat_test.go @@ -0,0 +1,30 @@ +package types + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +func TestPriceHeartBeat_Update(t *testing.T) { + hb := NewPriceHeartBeat(time.Minute) + + updated, err := hb.Update(PriceVolume{Price: fixedpoint.NewFromFloat(22.0), Volume: fixedpoint.NewFromFloat(100.0)}) + assert.NoError(t, err) + assert.True(t, updated) + + updated, err = hb.Update(PriceVolume{Price: fixedpoint.NewFromFloat(22.0), Volume: fixedpoint.NewFromFloat(100.0)}) + assert.NoError(t, err) + assert.False(t, updated, "should not be updated when pv is not changed") + + updated, err = hb.Update(PriceVolume{Price: fixedpoint.NewFromFloat(23.0), Volume: fixedpoint.NewFromFloat(100.0)}) + assert.NoError(t, err) + assert.True(t, updated, "should be updated when the price is changed") + + updated, err = hb.Update(PriceVolume{Price: fixedpoint.NewFromFloat(23.0), Volume: fixedpoint.NewFromFloat(200.0)}) + assert.NoError(t, err) + assert.True(t, updated, "should be updated when the volume is changed") +} diff --git a/pkg/types/price_volume_slice.go b/pkg/types/price_volume_slice.go new file mode 100644 index 0000000..5614b26 --- /dev/null +++ b/pkg/types/price_volume_slice.go @@ -0,0 +1,261 @@ +package types + +import ( + "encoding/json" + "fmt" + "sort" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +type PriceVolume struct { + Price, Volume fixedpoint.Value +} + +func (p PriceVolume) InQuote() fixedpoint.Value { + return p.Price.Mul(p.Volume) +} + +func (p PriceVolume) Equals(b PriceVolume) bool { + return p.Price.Eq(b.Price) && p.Volume.Eq(b.Volume) +} + +func (p PriceVolume) String() string { + return fmt.Sprintf("PriceVolume{ Price: %s, Volume: %s }", p.Price.String(), p.Volume.String()) +} + +type PriceVolumeSlice []PriceVolume + +func (slice PriceVolumeSlice) Len() int { return len(slice) } +func (slice PriceVolumeSlice) Less(i, j int) bool { return slice[i].Price.Compare(slice[j].Price) < 0 } +func (slice PriceVolumeSlice) Swap(i, j int) { slice[i], slice[j] = slice[j], slice[i] } + +// Trim removes the pairs that volume = 0 +func (slice PriceVolumeSlice) Trim() (pvs PriceVolumeSlice) { + for _, pv := range slice { + if pv.Volume.Sign() > 0 { + pvs = append(pvs, pv) + } + } + + return pvs +} + +func (slice PriceVolumeSlice) CopyDepth(depth int) PriceVolumeSlice { + if depth == 0 || depth > len(slice) { + return slice.Copy() + } + + var s = make(PriceVolumeSlice, depth) + copy(s, slice[:depth]) + return s +} + +func (slice PriceVolumeSlice) Copy() PriceVolumeSlice { + var s = make(PriceVolumeSlice, len(slice)) + copy(s, slice) + return s +} + +func (slice PriceVolumeSlice) Second() (PriceVolume, bool) { + if len(slice) > 1 { + return slice[1], true + } + return PriceVolume{}, false +} + +func (slice PriceVolumeSlice) First() (PriceVolume, bool) { + if len(slice) > 0 { + return slice[0], true + } + return PriceVolume{}, false +} + +func (slice PriceVolumeSlice) IndexByQuoteVolumeDepth(requiredQuoteVolume fixedpoint.Value) int { + var totalQuoteVolume = fixedpoint.Zero + for x, pv := range slice { + // this should use float64 multiply + quoteVolume := fixedpoint.Mul(pv.Volume, pv.Price) + totalQuoteVolume = totalQuoteVolume.Add(quoteVolume) + if totalQuoteVolume.Compare(requiredQuoteVolume) >= 0 { + return x + } + } + + // depth not enough + return -1 +} + +func (slice PriceVolumeSlice) SumDepth() fixedpoint.Value { + var total = fixedpoint.Zero + for _, pv := range slice { + total = total.Add(pv.Volume) + } + + return total +} + +func (slice PriceVolumeSlice) SumDepthInQuote() fixedpoint.Value { + var total = fixedpoint.Zero + + for _, pv := range slice { + quoteVolume := fixedpoint.Mul(pv.Price, pv.Volume) + total = total.Add(quoteVolume) + } + + return total +} + +func (slice PriceVolumeSlice) IndexByVolumeDepth(requiredVolume fixedpoint.Value) int { + var tv = fixedpoint.Zero + for x, el := range slice { + tv = tv.Add(el.Volume) + if tv.Compare(requiredVolume) >= 0 { + return x + } + } + + // depth not enough + return -1 +} + +func (slice PriceVolumeSlice) InsertAt(idx int, pv PriceVolume) PriceVolumeSlice { + rear := append([]PriceVolume{}, slice[idx:]...) + newSlice := append(slice[:idx], pv) + return append(newSlice, rear...) +} + +func (slice PriceVolumeSlice) Remove(price fixedpoint.Value, descending bool) PriceVolumeSlice { + matched, idx := slice.Find(price, descending) + if matched.Price.Compare(price) != 0 || matched.Price.IsZero() { + return slice + } + + return append(slice[:idx], slice[idx+1:]...) +} + +// Find finds the pair by the given price, this function is a read-only +// operation, so we use the value receiver to avoid copy value from the pointer +// If the price is not found, it will return the index where the price can be inserted at. +// true for descending (bid orders), false for ascending (ask orders) +func (slice PriceVolumeSlice) Find(price fixedpoint.Value, descending bool) (pv PriceVolume, idx int) { + idx = sort.Search(len(slice), func(i int) bool { + if descending { + return slice[i].Price.Compare(price) <= 0 + } + return slice[i].Price.Compare(price) >= 0 + }) + + if idx >= len(slice) || slice[idx].Price.Compare(price) != 0 { + return pv, idx + } + + pv = slice[idx] + + return pv, idx +} + +func (slice PriceVolumeSlice) Upsert(pv PriceVolume, descending bool) PriceVolumeSlice { + if len(slice) == 0 { + return append(slice, pv) + } + + price := pv.Price + _, idx := slice.Find(price, descending) + if idx >= len(slice) || slice[idx].Price.Compare(price) != 0 { + return slice.InsertAt(idx, pv) + } + + slice[idx].Volume = pv.Volume + return slice +} + +func (slice *PriceVolumeSlice) UnmarshalJSON(b []byte) error { + s, err := ParsePriceVolumeSliceJSON(b) + if err != nil { + return err + } + + *slice = s + return nil +} + +// ParsePriceVolumeSliceJSON tries to parse a 2 dimensional string array into a PriceVolumeSlice +// +// [["9000", "10"], ["9900", "10"], ... ] +func ParsePriceVolumeSliceJSON(b []byte) (slice PriceVolumeSlice, err error) { + var as [][]fixedpoint.Value + + err = json.Unmarshal(b, &as) + if err != nil { + return slice, err + } + + for _, a := range as { + var pv PriceVolume + pv.Price = a[0] + pv.Volume = a[1] + + // kucoin returns price in 0, we should skip + if pv.Price.Eq(fixedpoint.Zero) { + continue + } + + slice = append(slice, pv) + } + + return slice, nil +} + +func (slice PriceVolumeSlice) AverageDepthPriceByQuote(requiredDepthInQuote fixedpoint.Value, maxLevel int) fixedpoint.Value { + if len(slice) == 0 { + return fixedpoint.Zero + } + + totalQuoteAmount := fixedpoint.Zero + totalQuantity := fixedpoint.Zero + + l := len(slice) + if maxLevel > 0 && l > maxLevel { + l = maxLevel + } + + for i := 0; i < l; i++ { + pv := slice[i] + quoteAmount := fixedpoint.Mul(pv.Volume, pv.Price) + totalQuoteAmount = totalQuoteAmount.Add(quoteAmount) + totalQuantity = totalQuantity.Add(pv.Volume) + + if requiredDepthInQuote.Sign() > 0 && totalQuoteAmount.Compare(requiredDepthInQuote) > 0 { + return totalQuoteAmount.Div(totalQuantity) + } + } + + return totalQuoteAmount.Div(totalQuantity) +} + +// AverageDepthPrice uses the required total quantity to calculate the corresponding price +func (slice PriceVolumeSlice) AverageDepthPrice(requiredQuantity fixedpoint.Value) fixedpoint.Value { + // rest quantity + rq := requiredQuantity + totalAmount := fixedpoint.Zero + + if len(slice) == 0 { + return fixedpoint.Zero + } else if slice[0].Volume.Compare(requiredQuantity) >= 0 { + return slice[0].Price + } + + for i := 0; i < len(slice); i++ { + pv := slice[i] + if pv.Volume.Compare(rq) >= 0 { + totalAmount = totalAmount.Add(rq.Mul(pv.Price)) + break + } + + rq = rq.Sub(pv.Volume) + totalAmount = totalAmount.Add(pv.Volume.Mul(pv.Price)) + } + + return totalAmount.Div(requiredQuantity.Sub(rq)) +} diff --git a/pkg/types/price_volume_slice_test.go b/pkg/types/price_volume_slice_test.go new file mode 100644 index 0000000..5a42920 --- /dev/null +++ b/pkg/types/price_volume_slice_test.go @@ -0,0 +1,30 @@ +package types + +import ( + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "github.com/stretchr/testify/assert" +) + +func TestPriceVolumeSlice_Remove(t *testing.T) { + for _, descending := range []bool{true, false} { + slice := PriceVolumeSlice{} + slice = slice.Upsert(PriceVolume{Price: fixedpoint.One}, descending) + slice = slice.Upsert(PriceVolume{Price: fixedpoint.NewFromInt(3)}, descending) + slice = slice.Upsert(PriceVolume{Price: fixedpoint.NewFromInt(5)}, descending) + assert.Equal(t, 3, len(slice), "with descending %v", descending) + + slice = slice.Remove(fixedpoint.NewFromInt(2), descending) + assert.Equal(t, 3, len(slice), "with descending %v", descending) + + slice = slice.Remove(fixedpoint.NewFromInt(3), descending) + assert.Equal(t, 2, len(slice), "with descending %v", descending) + + slice = slice.Remove(fixedpoint.NewFromInt(99), descending) + assert.Equal(t, 2, len(slice), "with descending %v", descending) + + slice = slice.Remove(fixedpoint.Zero, descending) + assert.Equal(t, 2, len(slice), "with descending %v", descending) + } +} diff --git a/pkg/types/profit.go b/pkg/types/profit.go new file mode 100644 index 0000000..2c07724 --- /dev/null +++ b/pkg/types/profit.go @@ -0,0 +1,374 @@ +package types + +import ( + "fmt" + "time" + + "github.com/slack-go/slack" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/style" +) + +// Profit struct stores the PnL information +type Profit struct { + // --- position related fields + // ------------------------------------------- + // Symbol is the symbol of the position + Symbol string `json:"symbol"` + QuoteCurrency string `json:"quoteCurrency" db:"quote_currency"` + BaseCurrency string `json:"baseCurrency" db:"base_currency"` + AverageCost fixedpoint.Value `json:"averageCost" db:"average_cost"` + + // profit related fields + // ------------------------------------------- + // Profit is the profit of this trade made. negative profit means loss. + Profit fixedpoint.Value `json:"profit" db:"profit"` + + // NetProfit is (profit - trading fee) + NetProfit fixedpoint.Value `json:"netProfit" db:"net_profit"` + + // ProfitMargin is a percentage of the profit and the capital amount + ProfitMargin fixedpoint.Value `json:"profitMargin" db:"profit_margin"` + + // NetProfitMargin is a percentage of the net profit and the capital amount + NetProfitMargin fixedpoint.Value `json:"netProfitMargin" db:"net_profit_margin"` + + // trade related fields + // -------------------------------------------- + // TradeID is the exchange trade id of that trade + Trade *Trade `json:"trade,omitempty" db:"-"` + TradeID uint64 `json:"tradeID" db:"trade_id"` + OrderID uint64 `json:"orderID,omitempty"` + Side SideType `json:"side" db:"side"` + IsBuyer bool `json:"isBuyer" db:"is_buyer"` + IsMaker bool `json:"isMaker" db:"is_maker"` + Price fixedpoint.Value `json:"price" db:"price"` + Quantity fixedpoint.Value `json:"quantity" db:"quantity"` + QuoteQuantity fixedpoint.Value `json:"quoteQuantity" db:"quote_quantity"` + + // FeeInUSD is the summed fee of this profit, + // you will need to convert the trade fee into USD since the fee currencies can be different. + FeeInUSD fixedpoint.Value `json:"feeInUSD" db:"fee_in_usd"` + Fee fixedpoint.Value `json:"fee" db:"fee"` + FeeCurrency string `json:"feeCurrency" db:"fee_currency"` + Exchange ExchangeName `json:"exchange" db:"exchange"` + IsMargin bool `json:"isMargin" db:"is_margin"` + IsFutures bool `json:"isFutures" db:"is_futures"` + IsIsolated bool `json:"isIsolated" db:"is_isolated"` + TradedAt time.Time `json:"tradedAt" db:"traded_at"` + + PositionOpenedAt time.Time `json:"positionOpenedAt" db:"-"` + + // strategy related fields + Strategy string `json:"strategy" db:"strategy"` + StrategyInstanceID string `json:"strategyInstanceID" db:"strategy_instance_id"` +} + +func (p *Profit) SlackAttachment() slack.Attachment { + var color = style.PnLColor(p.Profit) + var title = fmt.Sprintf("%s PnL ", p.Symbol) + title += style.PnLEmojiMargin(p.Profit, p.ProfitMargin, style.DefaultPnLLevelResolution) + " " + title += style.PnLSignString(p.Profit) + " " + p.QuoteCurrency + + var fields []slack.AttachmentField + + if !p.NetProfit.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Net Profit", + Value: style.PnLSignString(p.NetProfit) + " " + p.QuoteCurrency, + Short: true, + }) + } + + if !p.ProfitMargin.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Profit Margin", + Value: p.ProfitMargin.Percentage(), + Short: true, + }) + } + + if !p.NetProfitMargin.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Net Profit Margin", + Value: p.NetProfitMargin.Percentage(), + Short: true, + }) + } + + if !p.QuoteQuantity.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Trade Amount", + Value: p.QuoteQuantity.String() + " " + p.QuoteCurrency, + Short: true, + }) + } + + if !p.FeeInUSD.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Fee In USD", + Value: p.FeeInUSD.String() + " USD", + Short: true, + }) + } + + if len(p.Strategy) != 0 { + fields = append(fields, slack.AttachmentField{ + Title: "Strategy", + Value: p.Strategy, + Short: true, + }) + } + + return slack.Attachment{ + Color: color, + Title: title, + Fields: fields, + // Footer: "", + } +} + +func (p *Profit) PlainText() string { + var emoji string + if !p.ProfitMargin.IsZero() { + emoji = style.PnLEmojiMargin(p.Profit, p.ProfitMargin, style.DefaultPnLLevelResolution) + } else { + emoji = style.PnLEmojiSimple(p.Profit) + } + + return fmt.Sprintf("%s trade profit %s %s %s (%s), net profit =~ %s %s (%s)", + p.Symbol, + emoji, + p.Profit.String(), p.QuoteCurrency, + p.ProfitMargin.Percentage(), + p.NetProfit.String(), p.QuoteCurrency, + p.NetProfitMargin.Percentage(), + ) +} + +// PeriodProfitStats defined the profit stats for a period +// TODO: replace AccumulatedPnL and TodayPnL fields from the ProfitStats struct +type PeriodProfitStats struct { + PnL fixedpoint.Value `json:"pnl,omitempty"` + NetProfit fixedpoint.Value `json:"netProfit,omitempty"` + GrossProfit fixedpoint.Value `json:"grossProfit,omitempty"` + GrossLoss fixedpoint.Value `json:"grossLoss,omitempty"` + Volume fixedpoint.Value `json:"volume,omitempty"` + VolumeInQuote fixedpoint.Value `json:"volumeInQuote,omitempty"` + MakerVolume fixedpoint.Value `json:"makerVolume,omitempty"` + TakerVolume fixedpoint.Value `json:"takerVolume,omitempty"` + + // time fields + LastTradeTime time.Time `json:"lastTradeTime,omitempty"` + StartTime time.Time `json:"startTime,omitempty"` + EndTime time.Time `json:"endTime,omitempty"` +} + +type ProfitStats struct { + Symbol string `json:"symbol"` + QuoteCurrency string `json:"quoteCurrency"` + BaseCurrency string `json:"baseCurrency"` + + AccumulatedPnL fixedpoint.Value `json:"accumulatedPnL,omitempty"` + AccumulatedNetProfit fixedpoint.Value `json:"accumulatedNetProfit,omitempty"` + AccumulatedGrossProfit fixedpoint.Value `json:"accumulatedGrossProfit,omitempty"` + AccumulatedGrossLoss fixedpoint.Value `json:"accumulatedGrossLoss,omitempty"` + AccumulatedVolume fixedpoint.Value `json:"accumulatedVolume,omitempty"` + AccumulatedSince int64 `json:"accumulatedSince,omitempty"` + + TodayPnL fixedpoint.Value `json:"todayPnL,omitempty"` + TodayNetProfit fixedpoint.Value `json:"todayNetProfit,omitempty"` + TodayGrossProfit fixedpoint.Value `json:"todayGrossProfit,omitempty"` + TodayGrossLoss fixedpoint.Value `json:"todayGrossLoss,omitempty"` + TodaySince int64 `json:"todaySince,omitempty"` +} + +func NewProfitStats(market Market) *ProfitStats { + return &ProfitStats{ + Symbol: market.Symbol, + QuoteCurrency: market.QuoteCurrency, + BaseCurrency: market.BaseCurrency, + AccumulatedPnL: fixedpoint.Zero, + AccumulatedNetProfit: fixedpoint.Zero, + AccumulatedGrossProfit: fixedpoint.Zero, + AccumulatedGrossLoss: fixedpoint.Zero, + AccumulatedVolume: fixedpoint.Zero, + AccumulatedSince: 0, + TodayPnL: fixedpoint.Zero, + TodayNetProfit: fixedpoint.Zero, + TodayGrossProfit: fixedpoint.Zero, + TodayGrossLoss: fixedpoint.Zero, + TodaySince: 0, + // StartTime: time.Now().UTC(), + // EndTime: time.Now().UTC(), + } +} + +// Init +// Deprecated: use NewProfitStats instead +func (s *ProfitStats) Init(market Market) { + s.Symbol = market.Symbol + s.BaseCurrency = market.BaseCurrency + s.QuoteCurrency = market.QuoteCurrency + if s.AccumulatedSince == 0 { + s.AccumulatedSince = time.Now().Unix() + } +} + +func (s *ProfitStats) AddProfit(profit Profit) { + if s.IsOver24Hours() { + s.ResetToday(profit.TradedAt) + } + + // since field guard + if s.AccumulatedSince == 0 { + s.AccumulatedSince = profit.TradedAt.Unix() + } + + if s.TodaySince == 0 { + var beginningOfTheDay = BeginningOfTheDay(profit.TradedAt.Local()) + s.TodaySince = beginningOfTheDay.Unix() + } + + s.AccumulatedPnL = s.AccumulatedPnL.Add(profit.Profit) + s.AccumulatedNetProfit = s.AccumulatedNetProfit.Add(profit.NetProfit) + s.TodayPnL = s.TodayPnL.Add(profit.Profit) + s.TodayNetProfit = s.TodayNetProfit.Add(profit.NetProfit) + + if profit.Profit.Sign() > 0 { + s.AccumulatedGrossProfit = s.AccumulatedGrossProfit.Add(profit.Profit) + s.TodayGrossProfit = s.TodayGrossProfit.Add(profit.Profit) + } else if profit.Profit.Sign() < 0 { + s.AccumulatedGrossLoss = s.AccumulatedGrossLoss.Add(profit.Profit) + s.TodayGrossLoss = s.TodayGrossLoss.Add(profit.Profit) + } + + // s.EndTime = profit.TradedAt.UTC() +} + +func (s *ProfitStats) AddTrade(trade Trade) { + if s.IsOver24Hours() { + s.ResetToday(trade.Time.Time()) + } + + s.AccumulatedVolume = s.AccumulatedVolume.Add(trade.Quantity) +} + +// IsOver24Hours checks if the since time is over 24 hours +func (s *ProfitStats) IsOver24Hours() bool { + if s.TodaySince == 0 { + return false + } + + return time.Since(time.Unix(s.TodaySince, 0)) >= 24*time.Hour +} + +func (s *ProfitStats) ResetToday(t time.Time) { + s.TodayPnL = fixedpoint.Zero + s.TodayNetProfit = fixedpoint.Zero + s.TodayGrossProfit = fixedpoint.Zero + s.TodayGrossLoss = fixedpoint.Zero + + var beginningOfTheDay = BeginningOfTheDay(t.Local()) + s.TodaySince = beginningOfTheDay.Unix() +} + +func (s *ProfitStats) PlainText() string { + since := time.Unix(s.AccumulatedSince, 0).Local() + return fmt.Sprintf("%s Profit Today\n"+ + "Profit %s %s\n"+ + "Net profit %s %s\n"+ + "Gross Loss %s %s\n"+ + "Summary:\n"+ + "Accumulated Profit %s %s\n"+ + "Accumulated Net Profit %s %s\n"+ + "Accumulated Gross Loss %s %s\n"+ + "Since %s", + s.Symbol, + s.TodayPnL.String(), s.QuoteCurrency, + s.TodayNetProfit.String(), s.QuoteCurrency, + s.TodayGrossLoss.String(), s.QuoteCurrency, + s.AccumulatedPnL.String(), s.QuoteCurrency, + s.AccumulatedNetProfit.String(), s.QuoteCurrency, + s.AccumulatedGrossLoss.String(), s.QuoteCurrency, + since.Format(time.RFC822), + ) +} + +func (s *ProfitStats) SlackAttachment() slack.Attachment { + var color = style.PnLColor(s.AccumulatedPnL) + var title = fmt.Sprintf("%s Accumulated PnL %s %s", s.Symbol, style.PnLSignString(s.AccumulatedPnL), s.QuoteCurrency) + + since := time.Unix(s.AccumulatedSince, 0).Local() + title += " Since " + since.Format(time.RFC822) + + var fields []slack.AttachmentField + + if !s.TodayPnL.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "P&L Today", + Value: style.PnLSignString(s.TodayPnL) + " " + s.QuoteCurrency, + Short: true, + }) + } + + if !s.TodayNetProfit.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Net Profit Today", + Value: style.PnLSignString(s.TodayNetProfit) + " " + s.QuoteCurrency, + Short: true, + }) + } + + if !s.TodayGrossProfit.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Gross Profit Today", + Value: style.PnLSignString(s.TodayGrossProfit) + " " + s.QuoteCurrency, + Short: true, + }) + } + + if !s.TodayGrossLoss.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Gross Loss Today", + Value: style.PnLSignString(s.TodayGrossLoss) + " " + s.QuoteCurrency, + Short: true, + }) + } + + if !s.AccumulatedPnL.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Accumulated P&L", + Value: style.PnLSignString(s.AccumulatedPnL) + " " + s.QuoteCurrency, + }) + } + + if !s.AccumulatedGrossProfit.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Accumulated Gross Profit", + Value: style.PnLSignString(s.AccumulatedGrossProfit) + " " + s.QuoteCurrency, + }) + } + + if !s.AccumulatedGrossLoss.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Accumulated Gross Loss", + Value: style.PnLSignString(s.AccumulatedGrossLoss) + " " + s.QuoteCurrency, + }) + } + + if !s.AccumulatedNetProfit.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Accumulated Net Profit", + Value: style.PnLSignString(s.AccumulatedNetProfit) + " " + s.QuoteCurrency, + }) + } + + return slack.Attachment{ + Color: color, + Title: title, + Fields: fields, + // Footer: "", + } +} diff --git a/pkg/types/queue.go b/pkg/types/queue.go new file mode 100644 index 0000000..ce68877 --- /dev/null +++ b/pkg/types/queue.go @@ -0,0 +1,52 @@ +package types + +// 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 { + out := &Queue{ + arr: make([]float64, 0, size), + size: size, + } + out.SeriesBase.Series = out + return out +} + +func (inc *Queue) Last(i int) float64 { + if i < 0 || len(inc.arr)-i-1 < 0 { + return 0 + } + + return inc.arr[len(inc.arr)-1-i] +} + +func (inc *Queue) Index(i int) float64 { + return inc.Last(i) +} + +func (inc *Queue) Length() int { + return len(inc.arr) +} + +func (inc *Queue) Clone() *Queue { + out := &Queue{ + arr: inc.arr[:], + size: inc.size, + } + out.SeriesBase.Series = out + return out +} + +func (inc *Queue) Update(v float64) { + inc.arr = append(inc.arr, v) + if len(inc.arr) > inc.size { + inc.arr = inc.arr[len(inc.arr)-inc.size:] + } +} + +var _ UpdatableSeriesExtend = &Queue{} diff --git a/pkg/types/rbtorderbook.go b/pkg/types/rbtorderbook.go new file mode 100644 index 0000000..fa5db23 --- /dev/null +++ b/pkg/types/rbtorderbook.go @@ -0,0 +1,199 @@ +package types + +import ( + "fmt" + "time" + + "github.com/pkg/errors" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +//go:generate callbackgen -type RBTOrderBook +type RBTOrderBook struct { + Symbol string + Bids *RBTree + Asks *RBTree + + lastUpdateTime time.Time + + loadCallbacks []func(book *RBTOrderBook) + updateCallbacks []func(book *RBTOrderBook) +} + +func NewRBOrderBook(symbol string) *RBTOrderBook { + return &RBTOrderBook{ + Symbol: symbol, + Bids: NewRBTree(), + Asks: NewRBTree(), + } +} + +func (b *RBTOrderBook) LastUpdateTime() time.Time { + return b.lastUpdateTime +} + +func (b *RBTOrderBook) BestBid() (PriceVolume, bool) { + right := b.Bids.Rightmost() + if right != nil { + return PriceVolume{Price: right.key, Volume: right.value}, true + } + + return PriceVolume{}, false +} + +func (b *RBTOrderBook) BestAsk() (PriceVolume, bool) { + left := b.Asks.Leftmost() + if left != nil { + return PriceVolume{Price: left.key, Volume: left.value}, true + } + + return PriceVolume{}, false +} + +func (b *RBTOrderBook) Spread() (fixedpoint.Value, bool) { + bestBid, ok := b.BestBid() + if !ok { + return fixedpoint.Zero, false + } + + bestAsk, ok := b.BestAsk() + if !ok { + return fixedpoint.Zero, false + } + + return bestAsk.Price.Sub(bestBid.Price), true +} + +func (b *RBTOrderBook) IsValid() (bool, error) { + bid, hasBid := b.BestBid() + ask, hasAsk := b.BestAsk() + + if !hasBid { + return false, errors.New("empty bids") + } + + if !hasAsk { + return false, errors.New("empty asks") + } + + if bid.Price.Compare(ask.Price) > 0 { + return false, fmt.Errorf("bid price %s > ask price %s", bid.Price.String(), ask.Price.String()) + } + + return true, nil +} + +func (b *RBTOrderBook) Load(book SliceOrderBook) { + b.Reset() + b.update(book) + b.EmitLoad(b) +} + +func (b *RBTOrderBook) Update(book SliceOrderBook) { + b.update(book) + b.EmitUpdate(b) +} + +func (b *RBTOrderBook) Reset() { + b.Bids = NewRBTree() + b.Asks = NewRBTree() +} + +func (b *RBTOrderBook) updateAsks(pvs PriceVolumeSlice) { + for _, pv := range pvs { + if pv.Volume.IsZero() { + b.Asks.Delete(pv.Price) + } else { + b.Asks.Upsert(pv.Price, pv.Volume) + } + } +} + +func (b *RBTOrderBook) updateBids(pvs PriceVolumeSlice) { + for _, pv := range pvs { + if pv.Volume.IsZero() { + b.Bids.Delete(pv.Price) + } else { + b.Bids.Upsert(pv.Price, pv.Volume) + } + } +} + +func (b *RBTOrderBook) update(book SliceOrderBook) { + b.updateBids(book.Bids) + b.updateAsks(book.Asks) + b.lastUpdateTime = time.Now() +} + +func (b *RBTOrderBook) load(book SliceOrderBook) { + b.Reset() + b.updateBids(book.Bids) + b.updateAsks(book.Asks) + b.lastUpdateTime = time.Now() +} + +func (b *RBTOrderBook) Copy() OrderBook { + var book = NewRBOrderBook(b.Symbol) + book.Asks = b.Asks.CopyInorder(0) + book.Bids = b.Bids.CopyInorder(0) + return book +} + +func (b *RBTOrderBook) CopyDepth(limit int) OrderBook { + var book = NewRBOrderBook(b.Symbol) + book.Asks = b.Asks.CopyInorder(limit) + book.Bids = b.Bids.CopyInorderReverse(limit) + return book +} + +func (b *RBTOrderBook) convertTreeToPriceVolumeSlice(tree *RBTree, limit int, descending bool) (pvs PriceVolumeSlice) { + if descending { + tree.InorderReverse(func(n *RBNode) bool { + pvs = append(pvs, PriceVolume{ + Price: n.key, + Volume: n.value, + }) + + return !(limit > 0 && len(pvs) >= limit) + }) + + return pvs + } + + tree.Inorder(func(n *RBNode) bool { + pvs = append(pvs, PriceVolume{ + Price: n.key, + Volume: n.value, + }) + + return !(limit > 0 && len(pvs) >= limit) + }) + return pvs +} + +func (b *RBTOrderBook) SideBook(sideType SideType) PriceVolumeSlice { + switch sideType { + + case SideTypeBuy: + return b.convertTreeToPriceVolumeSlice(b.Bids, 0, true) + + case SideTypeSell: + return b.convertTreeToPriceVolumeSlice(b.Asks, 0, false) + + default: + return nil + } +} + +func (b *RBTOrderBook) Print() { + b.Asks.Inorder(func(n *RBNode) bool { + fmt.Printf("ask: %s x %s", n.key.String(), n.value.String()) + return true + }) + + b.Bids.InorderReverse(func(n *RBNode) bool { + fmt.Printf("bid: %s x %s", n.key.String(), n.value.String()) + return true + }) +} diff --git a/pkg/types/rbtorderbook_callbacks.go b/pkg/types/rbtorderbook_callbacks.go new file mode 100644 index 0000000..6f26f44 --- /dev/null +++ b/pkg/types/rbtorderbook_callbacks.go @@ -0,0 +1,25 @@ +// Code generated by "callbackgen -type RBTOrderBook"; DO NOT EDIT. + +package types + +import () + +func (b *RBTOrderBook) OnLoad(cb func(book *RBTOrderBook)) { + b.loadCallbacks = append(b.loadCallbacks, cb) +} + +func (b *RBTOrderBook) EmitLoad(book *RBTOrderBook) { + for _, cb := range b.loadCallbacks { + cb(book) + } +} + +func (b *RBTOrderBook) OnUpdate(cb func(book *RBTOrderBook)) { + b.updateCallbacks = append(b.updateCallbacks, cb) +} + +func (b *RBTOrderBook) EmitUpdate(book *RBTOrderBook) { + for _, cb := range b.updateCallbacks { + cb(book) + } +} diff --git a/pkg/types/rbtorderbook_test.go b/pkg/types/rbtorderbook_test.go new file mode 100644 index 0000000..12f2337 --- /dev/null +++ b/pkg/types/rbtorderbook_test.go @@ -0,0 +1,78 @@ +package types + +import ( + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "github.com/stretchr/testify/assert" +) + +func TestRBOrderBook_EmptyBook(t *testing.T) { + book := NewRBOrderBook("BTCUSDT") + bid, ok := book.BestBid() + assert.False(t, ok) + assert.Equal(t, fixedpoint.Zero, bid.Price) + + ask, ok := book.BestAsk() + assert.False(t, ok) + assert.Equal(t, fixedpoint.Zero, ask.Price) +} + +func TestRBOrderBook_Load(t *testing.T) { + book := NewRBOrderBook("BTCUSDT") + + book.Load(SliceOrderBook{ + Symbol: "BTCUSDT", + Bids: PriceVolumeSlice{ + {Price: fixedpoint.NewFromFloat(2800.0), Volume: fixedpoint.One}, + }, + Asks: PriceVolumeSlice{ + {Price: fixedpoint.NewFromFloat(2810.0), Volume: fixedpoint.One}, + }, + }) + + bid, ok := book.BestBid() + assert.True(t, ok) + assert.Equal(t, fixedpoint.NewFromFloat(2800.0), bid.Price) + + ask, ok := book.BestAsk() + assert.True(t, ok) + assert.Equal(t, fixedpoint.NewFromFloat(2810.0), ask.Price) +} + +func TestRBOrderBook_LoadAndDelete(t *testing.T) { + book := NewRBOrderBook("BTCUSDT") + + book.Load(SliceOrderBook{ + Symbol: "BTCUSDT", + Bids: PriceVolumeSlice{ + {Price: fixedpoint.NewFromFloat(2800.0), Volume: fixedpoint.One}, + }, + Asks: PriceVolumeSlice{ + {Price: fixedpoint.NewFromFloat(2810.0), Volume: fixedpoint.One}, + }, + }) + + bid, ok := book.BestBid() + assert.True(t, ok) + assert.Equal(t, fixedpoint.NewFromFloat(2800.0), bid.Price) + + ask, ok := book.BestAsk() + assert.True(t, ok) + assert.Equal(t, fixedpoint.NewFromFloat(2810.0), ask.Price) + + book.Load(SliceOrderBook{ + Symbol: "BTCUSDT", + Bids: PriceVolumeSlice{ + {Price: fixedpoint.NewFromFloat(2800.0), Volume: fixedpoint.Zero}, + }, + Asks: PriceVolumeSlice{ + {Price: fixedpoint.NewFromFloat(2810.0), Volume: fixedpoint.Zero}, + }, + }) + + bid, ok = book.BestBid() + assert.False(t, ok) + ask, ok = book.BestAsk() + assert.False(t, ok) +} diff --git a/pkg/types/rbtree.go b/pkg/types/rbtree.go new file mode 100644 index 0000000..c423bce --- /dev/null +++ b/pkg/types/rbtree.go @@ -0,0 +1,486 @@ +package types + +import ( + "fmt" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +type RBTree struct { + Root *RBNode + size int +} + +func NewRBTree() *RBTree { + var root = NewNil() + root.parent = NewNil() + return &RBTree{ + Root: root, + } +} + +func (tree *RBTree) Delete(key fixedpoint.Value) bool { + var deleting = tree.Search(key) + if deleting == nil { + return false + } + + // y = the node to be deleted + // x (the child of the deleted node) + var x, y *RBNode + // fmt.Printf("neel = %p %+v\n", neel, neel) + // fmt.Printf("deleting = %+v\n", deleting) + + // the deleting node has only one child, it's easy, + // we just connect the child the parent of the deleting node + if deleting.left.isNil() || deleting.right.isNil() { + y = deleting + // fmt.Printf("y = deleting = %+v\n", y) + } else { + // if both children are not NIL (neel), we need to find the successor + // and copy the successor to the memory location of the deleting node. + // since it's successor, it always has no child connecting to it. + y = tree.Successor(deleting) + // fmt.Printf("y = successor = %+v\n", y) + } + + // y.left or y.right could be neel + if y.left.isNil() { + x = y.right + } else { + x = y.left + } + + // fmt.Printf("x = %+v\n", y) + x.parent = y.parent + + if y.parent.isNil() { + tree.Root = x + } else if y == y.parent.left { + y.parent.left = x + } else { + y.parent.right = x + } + + // copy the data from the successor to the memory location of the deleting node + if y != deleting { + deleting.key = y.key + deleting.value = y.value + } + + if y.color == Black { + tree.DeleteFixup(x) + } + + tree.size-- + + return true +} + +func (tree *RBTree) DeleteFixup(current *RBNode) { + for current != tree.Root && current.color == Black { + if current == current.parent.left { + sibling := current.parent.right + if sibling.color == Red { + sibling.color = Black + current.parent.color = Red + tree.RotateLeft(current.parent) + sibling = current.parent.right + } + + // if both are black nodes + if sibling.left.color == Black && sibling.right.color == Black { + sibling.color = Red + current = current.parent + } else { + // only one of the child is black + if sibling.right.color == Black { + sibling.left.color = Black + sibling.color = Red + tree.RotateRight(sibling) + sibling = current.parent.right + } + + sibling.color = current.parent.color + current.parent.color = Black + sibling.right.color = Black + tree.RotateLeft(current.parent) + current = tree.Root + } + } else { // if current is right child + sibling := current.parent.left + if sibling.color == Red { + sibling.color = Black + current.parent.color = Red + tree.RotateRight(current.parent) + sibling = current.parent.left + } + + if sibling.left.color == Black && sibling.right.color == Black { + sibling.color = Red + current = current.parent + } else { // if only one of child is Black + + // the left child of sibling is black, and right child is red + if sibling.left.color == Black { + sibling.right.color = Black + sibling.color = Red + tree.RotateLeft(sibling) + sibling = current.parent.left + } + + sibling.color = current.parent.color + current.parent.color = Black + sibling.left.color = Black + tree.RotateRight(current.parent) + current = tree.Root + } + } + } + + current.color = Black +} + +func (tree *RBTree) Upsert(key, val fixedpoint.Value) { + var y = NewNil() + var x = tree.Root + var node = &RBNode{ + key: key, + value: val, + color: Red, + left: NewNil(), + right: NewNil(), + parent: NewNil(), + } + + for !x.isNil() { + y = x + + if node.key == x.key { + // found node, skip insert and fix + x.value = val + return + } else if node.key.Compare(x.key) < 0 { + x = x.left + } else { + x = x.right + } + } + + node.parent = y + + if y.isNil() { + tree.Root = node + } else if node.key.Compare(y.key) < 0 { + y.left = node + } else { + y.right = node + } + + tree.InsertFixup(node) +} + +func (tree *RBTree) Insert(key, val fixedpoint.Value) { + var y = NewNil() + var x = tree.Root + var node = &RBNode{ + key: key, + value: val, + color: Red, + left: NewNil(), + right: NewNil(), + parent: NewNil(), + } + + for !x.isNil() { + y = x + + if node.key.Compare(x.key) < 0 { + x = x.left + } else { + x = x.right + } + } + + node.parent = y + + if y.isNil() { + tree.Root = node + } else if node.key.Compare(y.key) < 0 { + y.left = node + } else { + y.right = node + } + + tree.size++ + tree.InsertFixup(node) +} + +func (tree *RBTree) Search(key fixedpoint.Value) *RBNode { + var current = tree.Root + for !current.isNil() && key != current.key { + if key.Compare(current.key) < 0 { + current = current.left + } else { + current = current.right + } + } + + if current.isNil() { + return nil + } + + return current +} + +func (tree *RBTree) Size() int { + return tree.size +} + +func (tree *RBTree) InsertFixup(current *RBNode) { + // A red node can't have a red parent, we need to fix it up + for current.parent.color == Red { + if current.parent == current.parent.parent.left { + uncle := current.parent.parent.right + if uncle.color == Red { + current.parent.color = Black + uncle.color = Black + current.parent.parent.color = Red + current = current.parent.parent + } else { // if uncle is black + if current == current.parent.right { + current = current.parent + tree.RotateLeft(current) + } + + current.parent.color = Black + current.parent.parent.color = Red + tree.RotateRight(current.parent.parent) + } + } else { + uncle := current.parent.parent.left + if uncle.color == Red { + current.parent.color = Black + uncle.color = Black + current.parent.parent.color = Red + current = current.parent.parent + } else { + if current == current.parent.left { + current = current.parent + tree.RotateRight(current) + } + + current.parent.color = Black + current.parent.parent.color = Red + tree.RotateLeft(current.parent.parent) + } + } + } + + // ensure that root is black + tree.Root.color = Black +} + +// RotateLeft +// x is the axes of rotation, y is the node that will be replace x's position. +// we need to: +// 1. move y's left child to the x's right child +// 2. change y's parent to x's parent +// 3. change x's parent to y +func (tree *RBTree) RotateLeft(x *RBNode) { + var y = x.right + x.right = y.left + + if !y.left.isNil() { + y.left.parent = x + } + + y.parent = x.parent + + if x.parent.isNil() { + tree.Root = y + } else if x == x.parent.left { + x.parent.left = y + } else { + x.parent.right = y + } + + y.left = x + x.parent = y +} + +func (tree *RBTree) RotateRight(y *RBNode) { + x := y.left + y.left = x.right + + if !x.right.isNil() { + if x.right == nil { + panic(fmt.Errorf("x.right is nil: node = %+v, left = %+v, right = %+v, parent = %+v", x, x.left, x.right, x.parent)) + } + x.right.parent = y + } + + x.parent = y.parent + + if y.parent.isNil() { + tree.Root = x + } else if y == y.parent.left { + y.parent.left = x + } else { + y.parent.right = x + } + + x.right = y + y.parent = x +} + +func (tree *RBTree) Rightmost() *RBNode { + return tree.RightmostOf(tree.Root) +} + +func (tree *RBTree) RightmostOf(current *RBNode) *RBNode { + if current.isNil() || current == nil { + return nil + } + + for !current.right.isNil() { + current = current.right + } + + return current +} + +func (tree *RBTree) Leftmost() *RBNode { + return tree.LeftmostOf(tree.Root) +} + +func (tree *RBTree) LeftmostOf(current *RBNode) *RBNode { + if current.isNil() || current == nil { + return nil + } + + for !current.left.isNil() { + current = current.left + } + + return current +} + +func (tree *RBTree) Successor(current *RBNode) *RBNode { + if !current.right.isNil() { + return tree.LeftmostOf(current.right) + } + + var newNode = current.parent + for !newNode.isNil() && current == newNode.right { + current = newNode + newNode = newNode.parent + } + + return newNode +} + +func (tree *RBTree) Preorder(cb func(n *RBNode)) { + tree.PreorderOf(tree.Root, cb) +} + +func (tree *RBTree) PreorderOf(current *RBNode, cb func(n *RBNode)) { + if !current.isNil() && current != nil { + cb(current) + tree.PreorderOf(current.left, cb) + tree.PreorderOf(current.right, cb) + } +} + +// Inorder traverses the tree in ascending order +func (tree *RBTree) Inorder(cb func(n *RBNode) bool) { + tree.InorderOf(tree.Root, cb) +} + +func (tree *RBTree) InorderOf(current *RBNode, cb func(n *RBNode) bool) { + if !current.isNil() && current != nil { + tree.InorderOf(current.left, cb) + if !cb(current) { + return + } + tree.InorderOf(current.right, cb) + } +} + +// InorderReverse traverses the tree in descending order +func (tree *RBTree) InorderReverse(cb func(n *RBNode) bool) { + tree.InorderReverseOf(tree.Root, cb) +} + +func (tree *RBTree) InorderReverseOf(current *RBNode, cb func(n *RBNode) bool) { + if !current.isNil() && current != nil { + tree.InorderReverseOf(current.right, cb) + if !cb(current) { + return + } + tree.InorderReverseOf(current.left, cb) + } +} + +func (tree *RBTree) Postorder(cb func(n *RBNode) bool) { + tree.PostorderOf(tree.Root, cb) +} + +func (tree *RBTree) PostorderOf(current *RBNode, cb func(n *RBNode) bool) { + if !current.isNil() && current != nil { + tree.PostorderOf(current.left, cb) + tree.PostorderOf(current.right, cb) + if !cb(current) { + return + } + } +} + +func (tree *RBTree) CopyInorderReverse(limit int) *RBTree { + newTree := NewRBTree() + if limit == 0 { + tree.InorderReverse(copyNodeFast(newTree)) + return newTree + } + + tree.InorderReverse(copyNodeLimit(newTree, limit)) + return newTree +} + +func (tree *RBTree) CopyInorder(limit int) *RBTree { + newTree := NewRBTree() + if limit == 0 { + tree.Inorder(copyNodeFast(newTree)) + return newTree + } + + tree.Inorder(copyNodeLimit(newTree, limit)) + return newTree +} + +func (tree *RBTree) Print() { + tree.Inorder(func(n *RBNode) bool { + fmt.Printf("%v -> %v\n", n.key, n.value) + return true + }) +} + +func copyNodeFast(newTree *RBTree) func(n *RBNode) bool { + return func(n *RBNode) bool { + newTree.Insert(n.key, n.value) + return true + } +} + +func copyNodeLimit(newTree *RBTree, limit int) func(n *RBNode) bool { + cnt := 0 + return func(n *RBNode) bool { + if limit > 0 && cnt >= limit { + return false + } + + newTree.Insert(n.key, n.value) + cnt++ + return true + } +} diff --git a/pkg/types/rbtree_node.go b/pkg/types/rbtree_node.go new file mode 100644 index 0000000..6a8a8ec --- /dev/null +++ b/pkg/types/rbtree_node.go @@ -0,0 +1,33 @@ +package types + +import "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + +// Color is the RB Tree color +type Color bool + +const ( + Red = Color(false) + Black = Color(true) +) + +/* +RBNode +A red node always has black children. +A black node may have red or black children +*/ +type RBNode struct { + left, right, parent *RBNode + key, value fixedpoint.Value + color Color +} + +func NewNil() *RBNode { + return &RBNode{color: Black} +} + +func (node *RBNode) isNil() bool { + if node == nil { + return true + } + return node.color == Black && node.left == nil && node.right == nil +} diff --git a/pkg/types/rbtree_test.go b/pkg/types/rbtree_test.go new file mode 100644 index 0000000..284e37d --- /dev/null +++ b/pkg/types/rbtree_test.go @@ -0,0 +1,249 @@ +package types + +import ( + "math/rand" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +var itov func(int64) fixedpoint.Value = fixedpoint.NewFromInt + +func TestRBTree_ConcurrentIndependence(t *testing.T) { + // each RBTree instances must not affect each other in concurrent environment + var wg sync.WaitGroup + for w := 0; w < 10; w++ { + wg.Add(1) + go func() { + defer wg.Done() + tree := NewRBTree() + for stepCnt := 0; stepCnt < 10000; stepCnt++ { + switch opCode := rand.Intn(2); opCode { + case 0: + priceI := rand.Int63n(16) + price := fixedpoint.NewFromInt(priceI) + tree.Delete(price) + case 1: + priceI := rand.Int63n(16) + volumeI := rand.Int63n(8) + tree.Upsert(fixedpoint.NewFromInt(priceI), fixedpoint.NewFromInt(volumeI)) + default: + panic("impossible") + } + } + }() + } + wg.Wait() +} + +func TestRBTree_InsertAndDelete(t *testing.T) { + tree := NewRBTree() + node := tree.Rightmost() + assert.Nil(t, node) + + tree.Insert(itov(10), itov(10)) + tree.Insert(itov(9), itov(9)) + tree.Insert(itov(12), itov(12)) + tree.Insert(itov(11), itov(11)) + tree.Insert(itov(13), itov(13)) + + node = tree.Rightmost() + assert.Equal(t, itov(13), node.key) + assert.Equal(t, itov(13), node.value) + + ok := tree.Delete(fixedpoint.NewFromInt(12)) + assert.True(t, ok, "should delete the node successfully") +} + +func TestRBTree_Rightmost(t *testing.T) { + tree := NewRBTree() + node := tree.Rightmost() + assert.Nil(t, node, "should be nil") + + tree.Insert(itov(10), itov(10)) + node = tree.Rightmost() + assert.Equal(t, itov(10), node.key) + assert.Equal(t, itov(10), node.value) + + tree.Insert(itov(12), itov(12)) + tree.Insert(itov(9), itov(9)) + node = tree.Rightmost() + assert.Equal(t, itov(12), node.key) +} + +func TestRBTree_RandomInsertSearchAndDelete(t *testing.T) { + var keys []fixedpoint.Value + + tree := NewRBTree() + for i := 1; i < 100; i++ { + v := fixedpoint.NewFromFloat(rand.Float64()*100 + 1.0) + keys = append(keys, v) + tree.Insert(v, v) + } + + for _, key := range keys { + node := tree.Search(key) + assert.NotNil(t, node) + + ok := tree.Delete(key) + assert.True(t, ok, "should find and delete the node") + } +} + +func TestRBTree_CopyInorder(t *testing.T) { + tree := NewRBTree() + for i := 1.0; i < 10.0; i += 1.0 { + tree.Insert(fixedpoint.NewFromFloat(i*100.0), fixedpoint.NewFromFloat(i)) + } + + newTree := tree.CopyInorder(3) + assert.Equal(t, 3, newTree.Size()) + + newTree.Print() + + node1 := newTree.Search(fixedpoint.NewFromFloat(100.0)) + assert.NotNil(t, node1) + + node2 := newTree.Search(fixedpoint.NewFromFloat(200.0)) + assert.NotNil(t, node2) + + node3 := newTree.Search(fixedpoint.NewFromFloat(300.0)) + assert.NotNil(t, node3) + + node4 := newTree.Search(fixedpoint.NewFromFloat(400.0)) + assert.Nil(t, node4) +} + +func TestTree_Copy(t *testing.T) { + tree := NewRBTree() + tree.Insert(fixedpoint.NewFromFloat(3000.0), fixedpoint.NewFromFloat(1.0)) + assert.NotNil(t, tree.Root) + + tree.Insert(fixedpoint.NewFromFloat(4000.0), fixedpoint.NewFromFloat(2.0)) + tree.Insert(fixedpoint.NewFromFloat(2000.0), fixedpoint.NewFromFloat(3.0)) + + newTree := tree.CopyInorder(0) + node1 := newTree.Search(fixedpoint.NewFromFloat(2000.0)) + assert.NotNil(t, node1) + assert.Equal(t, fixedpoint.NewFromFloat(2000.0), node1.key) + assert.Equal(t, fixedpoint.NewFromFloat(3.0), node1.value) + + node2 := newTree.Search(fixedpoint.NewFromFloat(3000.0)) + assert.NotNil(t, node2) + assert.Equal(t, fixedpoint.NewFromFloat(3000.0), node2.key) + assert.Equal(t, fixedpoint.NewFromFloat(1.0), node2.value) + + node3 := newTree.Search(fixedpoint.NewFromFloat(4000.0)) + assert.NotNil(t, node3) + assert.Equal(t, fixedpoint.NewFromFloat(4000.0), node3.key) + assert.Equal(t, fixedpoint.NewFromFloat(2.0), node3.value) +} + +func TestRBTree_basic(t *testing.T) { + tree := NewRBTree() + tree.Insert(fixedpoint.NewFromFloat(3000.0), fixedpoint.NewFromFloat(10.0)) + assert.NotNil(t, tree.Root) + + tree.Insert(fixedpoint.NewFromFloat(4000.0), fixedpoint.NewFromFloat(10.0)) + tree.Insert(fixedpoint.NewFromFloat(2000.0), fixedpoint.NewFromFloat(10.0)) + + // root is always black + assert.Equal(t, fixedpoint.NewFromFloat(3000.0), tree.Root.key) + assert.Equal(t, Black, tree.Root.color) + + assert.Equal(t, fixedpoint.NewFromFloat(2000.0), tree.Root.left.key) + assert.Equal(t, Red, tree.Root.left.color) + + assert.Equal(t, fixedpoint.NewFromFloat(4000.0), tree.Root.right.key) + assert.Equal(t, Red, tree.Root.right.color) + + // should rotate + tree.Insert(fixedpoint.NewFromFloat(1500.0), fixedpoint.NewFromFloat(10.0)) + tree.Insert(fixedpoint.NewFromFloat(1000.0), fixedpoint.NewFromFloat(10.0)) + + deleted := tree.Delete(fixedpoint.NewFromFloat(1000.0)) + assert.True(t, deleted) + + deleted = tree.Delete(fixedpoint.NewFromFloat(1500.0)) + assert.True(t, deleted) + +} + +func TestRBTree_bulkInsert(t *testing.T) { + var pvs = map[fixedpoint.Value]fixedpoint.Value{} + var tree = NewRBTree() + for i := 0; i < 1000000; i++ { + price := fixedpoint.NewFromFloat(rand.Float64()) + volume := fixedpoint.NewFromFloat(rand.Float64()) + tree.Upsert(price, volume) + pvs[price] = volume + } + tree.Inorder(func(n *RBNode) bool { + if !n.left.isNil() { + if !assert.True(t, n.key.Compare(n.left.key) > 0) { + return false + } + } + if !n.right.isNil() { + if !assert.True(t, n.key.Compare(n.right.key) < 0) { + return false + } + } + return true + }) +} + +func TestRBTree_bulkInsertAndDelete(t *testing.T) { + var pvs = map[fixedpoint.Value]fixedpoint.Value{} + + var getRandomPrice = func() fixedpoint.Value { + for p := range pvs { + return p + } + return fixedpoint.Zero + } + + var tree = NewRBTree() + for i := 0; i < 1000000; i++ { + price := fixedpoint.NewFromFloat(rand.Float64()) + volume := fixedpoint.NewFromFloat(rand.Float64()) + tree.Upsert(price, volume) + pvs[price] = volume + + if i%3 == 0 || i%7 == 0 { + removePrice := getRandomPrice() + if removePrice.Sign() > 0 { + if !assert.True(t, tree.Delete(removePrice), "existing price %f should be removed at round %d", removePrice.Float64(), i) { + return + } + delete(pvs, removePrice) + } + } + } + + // all prices should be found + for p := range pvs { + node := tree.Search(p) + if !assert.NotNil(t, node, "should found price %f", p.Float64()) { + return + } + } + + // validate tree structure + tree.Inorder(func(n *RBNode) bool { + if !n.left.isNil() { + if !assert.True(t, n.key.Compare(n.left.key) > 0) { + return false + } + } + if !n.right.isNil() { + if !assert.True(t, n.key.Compare(n.right.key) < 0) { + return false + } + } + return true + }) +} diff --git a/pkg/types/reward.go b/pkg/types/reward.go new file mode 100644 index 0000000..f60edaa --- /dev/null +++ b/pkg/types/reward.go @@ -0,0 +1,60 @@ +package types + +import ( + "fmt" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +type RewardType string + +const ( + RewardAirdrop = RewardType("airdrop") + RewardCommission = RewardType("commission") + RewardReferralKickback = RewardType("referral_kickback") + RewardHolding = RewardType("holding") + RewardMining = RewardType("mining") + RewardTrading = RewardType("trading") + RewardVipRebate = RewardType("vip_rebate") +) + +type Reward struct { + GID int64 `json:"gid" db:"gid"` + UUID string `json:"uuid" db:"uuid"` + Exchange ExchangeName `json:"exchange" db:"exchange"` + Type RewardType `json:"reward_type" db:"reward_type"` + Currency string `json:"currency" db:"currency"` + Quantity fixedpoint.Value `json:"quantity" db:"quantity"` + State string `json:"state" db:"state"` + Note string `json:"note" db:"note"` + Spent bool `json:"spent" db:"spent"` + CreatedAt Time `json:"created_at" db:"created_at"` +} + +func (r Reward) String() (s string) { + s = fmt.Sprintf("reward %s %s %20s %20f %5s @ %s", r.Exchange, r.UUID, r.Type, r.Quantity.Float64(), r.Currency, r.CreatedAt.String()) + + if r.Note != "" { + s += ": " + r.Note + } + + return s +} + +type RewardSlice []Reward + +func (s RewardSlice) Len() int { return len(s) } +func (s RewardSlice) Swap(i, j int) { s[i], s[j] = s[j], s[i] } + +type RewardSliceByCreationTime RewardSlice + +func (s RewardSliceByCreationTime) Len() int { return len(s) } +func (s RewardSliceByCreationTime) Swap(i, j int) { s[i], s[j] = s[j], s[i] } + +// Less reports whether x[i] should be ordered before x[j] +func (s RewardSliceByCreationTime) Less(i, j int) bool { + return time.Time(s[i].CreatedAt).Before( + time.Time(s[j].CreatedAt), + ) +} diff --git a/pkg/types/series.go b/pkg/types/series.go new file mode 100644 index 0000000..c3834d7 --- /dev/null +++ b/pkg/types/series.go @@ -0,0 +1,123 @@ +package types + +import ( + "reflect" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" +) + +// The interface maps to pinescript basic type `series` +// Access the internal historical data from the latest to the oldest +// Index(0) always maps to Last() +type Series interface { + Last(i int) float64 + Index(i int) float64 + Length() int +} + +type SeriesExtend interface { + Series + Sum(limit ...int) float64 + Mean(limit ...int) float64 + Abs() SeriesExtend + Predict(lookback int, offset ...int) float64 + NextCross(b Series, lookback int) (int, float64, bool) + CrossOver(b Series) BoolSeries + CrossUnder(b Series) BoolSeries + Highest(lookback int) float64 + Lowest(lookback int) float64 + Add(b interface{}) SeriesExtend + Minus(b interface{}) SeriesExtend + Div(b interface{}) SeriesExtend + Mul(b interface{}) SeriesExtend + Dot(b interface{}, limit ...int) float64 + Array(limit ...int) (result []float64) + Reverse(limit ...int) (result floats.Slice) + Change(offset ...int) SeriesExtend + 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 + AutoCorrelation(length int, lag ...int) float64 + Rank(length int) SeriesExtend + Sigmoid() SeriesExtend + Softmax(window int) SeriesExtend + Entropy(window int) float64 + CrossEntropy(b Series, window int) float64 + Filter(b func(i int, value float64) bool, length int) SeriesExtend +} + +func NewSeries(a Series) SeriesExtend { + return &SeriesBase{ + Series: a, + } +} + +type UpdatableSeries interface { + Series + Update(float64) +} + +type UpdatableSeriesExtend interface { + SeriesExtend + Update(float64) +} + +func Clone(u UpdatableSeriesExtend) UpdatableSeriesExtend { + method, ok := reflect.TypeOf(u).MethodByName("Clone") + if ok { + out := method.Func.Call([]reflect.Value{reflect.ValueOf(u)}) + return out[0].Interface().(UpdatableSeriesExtend) + } + panic("method Clone not exist") +} + +func TestUpdate(u UpdatableSeriesExtend, input float64) UpdatableSeriesExtend { + method, ok := reflect.TypeOf(u).MethodByName("TestUpdate") + if ok { + out := method.Func.Call([]reflect.Value{reflect.ValueOf(u), reflect.ValueOf(input)}) + return out[0].Interface().(UpdatableSeriesExtend) + } + panic("method TestUpdate not exist") +} + +// 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() +type BoolSeries interface { + Last() bool + Index(int) bool + Length() int +} + +// Calculate sum of the series +// if limit is given, will only sum first limit numbers (a.Index[0..limit]) +// otherwise will sum all elements +func Sum(a Series, limit ...int) (sum float64) { + l := a.Length() + if len(limit) > 0 && limit[0] < l { + l = limit[0] + } + for i := 0; i < l; i++ { + sum += a.Last(i) + } + return sum +} + +// Calculate the average value of the series +// if limit is given, will only calculate the average of first limit numbers (a.Index[0..limit]) +// otherwise will operate on all elements +func Mean(a Series, limit ...int) (mean float64) { + l := a.Length() + if l == 0 { + return 0 + } + if len(limit) > 0 && limit[0] < l { + l = limit[0] + } + return Sum(a, l) / float64(l) +} diff --git a/pkg/types/series_float64.go b/pkg/types/series_float64.go new file mode 100644 index 0000000..3c3c731 --- /dev/null +++ b/pkg/types/series_float64.go @@ -0,0 +1,105 @@ +package types + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" +) + +type Float64Series struct { + SeriesBase + Float64Updater + Slice floats.Slice +} + +func NewFloat64Series(v ...float64) *Float64Series { + s := &Float64Series{} + s.Slice = v + s.SeriesBase.Series = s.Slice + return s +} + +func (f *Float64Series) Last(i int) float64 { + return f.Slice.Last(i) +} + +func (f *Float64Series) Index(i int) float64 { + return f.Last(i) +} + +func (f *Float64Series) Length() int { + return len(f.Slice) +} + +func (f *Float64Series) Push(x float64) { + f.Slice.Push(x) +} + +func (f *Float64Series) PushAndEmit(x float64) { + f.Slice.Push(x) + f.EmitUpdate(x) +} + +func (f *Float64Series) Subscribe(source Float64Source, c func(x float64)) { + if sub, ok := source.(Float64Subscription); ok { + sub.AddSubscriber(c) + } else { + source.OnUpdate(c) + } +} + +// AddSubscriber adds the subscriber function and push historical data to the subscriber +func (f *Float64Series) AddSubscriber(fn func(v float64)) { + f.OnUpdate(fn) + + if f.Length() == 0 { + return + } + + // push historical values to the subscriber + for _, vv := range f.Slice { + fn(vv) + } +} + +// Bind binds the source event to the target (Float64Calculator) +// A Float64Calculator should be able to calculate the float64 result from a single float64 argument input +func (f *Float64Series) Bind(source Float64Source, target Float64Calculator) { + var c func(x float64) + + // optimize the truncation check + trc, canTruncate := target.(Float64Truncator) + if canTruncate { + c = func(x float64) { + y := target.Calculate(x) + target.PushAndEmit(y) + trc.Truncate() + } + } else { + c = func(x float64) { + y := target.Calculate(x) + target.PushAndEmit(y) + } + } + + if source != nil { + f.Subscribe(source, c) + } +} + +type Float64Calculator interface { + Calculate(x float64) float64 + PushAndEmit(x float64) +} + +type Float64Source interface { + Series + OnUpdate(f func(v float64)) +} + +type Float64Subscription interface { + Series + AddSubscriber(f func(v float64)) +} + +type Float64Truncator interface { + Truncate() +} diff --git a/pkg/types/seriesbase_imp.go b/pkg/types/seriesbase_imp.go new file mode 100644 index 0000000..ddd3bcd --- /dev/null +++ b/pkg/types/seriesbase_imp.go @@ -0,0 +1,158 @@ +package types + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" +) + +// SeriesBase is a wrapper of the Series interface +// You can assign a data container that implements the Series interface +// And this SeriesBase struct provides the implemented methods for manipulating your data +type SeriesBase struct { + Series +} + +func (s *SeriesBase) Index(i int) float64 { + return s.Last(i) +} + +func (s *SeriesBase) Last(i int) float64 { + if s.Series == nil { + return 0 + } + return s.Series.Last(i) +} + +func (s *SeriesBase) Length() int { + if s.Series == nil { + return 0 + } + return s.Series.Length() +} + +func (s *SeriesBase) Sum(limit ...int) float64 { + return Sum(s, limit...) +} + +func (s *SeriesBase) Mean(limit ...int) float64 { + return Mean(s, limit...) +} + +func (s *SeriesBase) Abs() SeriesExtend { + return Abs(s) +} + +func (s *SeriesBase) Predict(lookback int, offset ...int) float64 { + return Predict(s, lookback, offset...) +} + +func (s *SeriesBase) NextCross(b Series, lookback int) (int, float64, bool) { + return NextCross(s, b, lookback) +} + +func (s *SeriesBase) CrossOver(b Series) BoolSeries { + return CrossOver(s, b) +} + +func (s *SeriesBase) CrossUnder(b Series) BoolSeries { + return CrossUnder(s, b) +} + +func (s *SeriesBase) Highest(lookback int) float64 { + return Highest(s, lookback) +} + +func (s *SeriesBase) Lowest(lookback int) float64 { + return Lowest(s, lookback) +} + +func (s *SeriesBase) Add(b interface{}) SeriesExtend { + return Add(s, b) +} + +func (s *SeriesBase) Minus(b interface{}) SeriesExtend { + return Sub(s, b) +} + +func (s *SeriesBase) Div(b interface{}) SeriesExtend { + return Div(s, b) +} + +func (s *SeriesBase) Mul(b interface{}) SeriesExtend { + return Mul(s, b) +} + +func (s *SeriesBase) Dot(b interface{}, limit ...int) float64 { + return Dot(s, b, limit...) +} + +func (s *SeriesBase) Array(limit ...int) []float64 { + return Array(s, limit...) +} + +func (s *SeriesBase) Reverse(limit ...int) floats.Slice { + return Reverse(s, limit...) +} + +func (s *SeriesBase) Change(offset ...int) SeriesExtend { + return Change(s, offset...) +} + +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) AutoCorrelation(length int, lag ...int) float64 { + return AutoCorrelation(s, length, lag...) +} + +func (s *SeriesBase) Rank(length int) SeriesExtend { + return Rank(s, length) +} + +func (s *SeriesBase) Sigmoid() SeriesExtend { + return Sigmoid(s) +} + +func (s *SeriesBase) Softmax(window int) SeriesExtend { + return Softmax(s, window) +} + +func (s *SeriesBase) Entropy(window int) float64 { + return Entropy(s, window) +} + +func (s *SeriesBase) CrossEntropy(b Series, window int) float64 { + return CrossEntropy(s, b, window) +} + +func (s *SeriesBase) Filter(b func(int, float64) bool, length int) SeriesExtend { + return Filter(s, b, length) +} diff --git a/pkg/types/sharpe.go b/pkg/types/sharpe.go new file mode 100644 index 0000000..a37c635 --- /dev/null +++ b/pkg/types/sharpe.go @@ -0,0 +1,48 @@ +package types + +import ( + "math" +) + +// Sharpe: Calcluates the sharpe ratio of access returns +// +// @param returns (Series): Series of profit/loss percentage every specific interval +// @param periods (int): Freq. of returns (252/365 for daily, 12 for monthy, 1 for annually) +// @param annualize (bool): return annualize sharpe? +// @param smart (bool): return smart sharpe ratio +func Sharpe(returns Series, periods int, annualize bool, smart bool) float64 { + data := returns + var divisor = Stdev(data, data.Length(), 1) + if smart { + divisor *= autocorrPenalty(returns) + } + if divisor == 0 { + mean := Mean(data) + if mean > 0 { + return math.Inf(1) + } else if mean < 0 { + return math.Inf(-1) + } else { + return 0 + } + } + result := Mean(data) / divisor + if annualize { + return result * math.Sqrt(float64(periods)) + } + return result +} + +func avgReturnRate(returnRate float64, periods int) float64 { + return math.Pow(1.+returnRate, 1./float64(periods)) - 1. +} + +func autocorrPenalty(data Series) float64 { + num := data.Length() + coef := math.Abs(Correlation(data, Shift(data, 1), num-1)) + var sum = 0. + for i := 1; i < num; i++ { + sum += float64(num-i) / float64(num) * math.Pow(coef, float64(i)) + } + return math.Sqrt(1. + 2.*sum) +} diff --git a/pkg/types/sharpe_test.go b/pkg/types/sharpe_test.go new file mode 100644 index 0000000..ae02ced --- /dev/null +++ b/pkg/types/sharpe_test.go @@ -0,0 +1,29 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" +) + +/* +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 Series = &floats.Slice{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/types/side.go b/pkg/types/side.go new file mode 100644 index 0000000..7470629 --- /dev/null +++ b/pkg/types/side.go @@ -0,0 +1,91 @@ +package types + +import ( + "encoding/json" + "strings" + + "github.com/pkg/errors" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/style" +) + +// SideType define side type of order +type SideType string + +const ( + SideTypeBuy = SideType("BUY") + SideTypeSell = SideType("SELL") + SideTypeSelf = SideType("SELF") + + // SideTypeBoth is only used for the configuration context + SideTypeBoth = SideType("BOTH") +) + +var ErrInvalidSideType = errors.New("invalid side type") + +func StrToSideType(s string) (side SideType, err error) { + switch strings.ToLower(s) { + case "buy": + side = SideTypeBuy + + case "sell": + side = SideTypeSell + + case "both": + side = SideTypeBoth + + default: + err = ErrInvalidSideType + return side, err + + } + + return side, err +} + +func (side *SideType) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + + ss, err := StrToSideType(s) + if err != nil { + return err + } + + *side = ss + return nil +} + +func (side SideType) Reverse() SideType { + switch side { + case SideTypeBuy: + return SideTypeSell + + case SideTypeSell: + return SideTypeBuy + } + + return side +} + +func (side SideType) String() string { + return string(side) +} + +func (side SideType) Color() string { + if side == SideTypeBuy { + return style.GreenColor + } + + if side == SideTypeSell { + return style.RedColor + } + + return style.GrayColor +} + +func SideToColorName(side SideType) string { + return side.Color() +} diff --git a/pkg/types/sigmoid.go b/pkg/types/sigmoid.go new file mode 100644 index 0000000..4b28bb2 --- /dev/null +++ b/pkg/types/sigmoid.go @@ -0,0 +1,27 @@ +package types + +import "math" + +type SigmoidResult struct { + a Series +} + +func (s *SigmoidResult) Last(i int) float64 { + return 1. / (1. + math.Exp(-s.a.Last(i))) +} + +func (s *SigmoidResult) Index(i int) float64 { + return s.Last(i) +} + +func (s *SigmoidResult) Length() int { + return s.a.Length() +} + +// Sigmoid returns the input values in range of -1 to 1 +// along the sigmoid or s-shaped curve. +// Commonly used in machine learning while training neural networks +// as an activation function. +func Sigmoid(a Series) SeriesExtend { + return NewSeries(&SigmoidResult{a}) +} diff --git a/pkg/types/slack.go b/pkg/types/slack.go new file mode 100644 index 0000000..322220a --- /dev/null +++ b/pkg/types/slack.go @@ -0,0 +1,7 @@ +package types + +import "github.com/slack-go/slack" + +type SlackAttachmentCreator interface { + SlackAttachment() slack.Attachment +} diff --git a/pkg/types/sliceorderbook.go b/pkg/types/sliceorderbook.go new file mode 100644 index 0000000..605cd0c --- /dev/null +++ b/pkg/types/sliceorderbook.go @@ -0,0 +1,213 @@ +package types + +import ( + "fmt" + "strings" + "time" + + "github.com/pkg/errors" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +// SliceOrderBook is a general order book structure which could be used +// for RESTful responses and websocket stream parsing +// +//go:generate callbackgen -type SliceOrderBook +type SliceOrderBook struct { + Symbol string + Bids PriceVolumeSlice + Asks PriceVolumeSlice + // Time represents the server time. If empty, it indicates that the server does not provide this information. + Time time.Time + + // LastUpdateId is the message id from the server + // this field is optional, not every exchange provides this information + // this is for binance right now. + LastUpdateId int64 + + lastUpdateTime time.Time + + loadCallbacks []func(book *SliceOrderBook) + updateCallbacks []func(book *SliceOrderBook) +} + +func NewSliceOrderBook(symbol string) *SliceOrderBook { + return &SliceOrderBook{ + Symbol: symbol, + } +} + +func (b *SliceOrderBook) LastUpdateTime() time.Time { + return b.lastUpdateTime +} + +func (b *SliceOrderBook) Spread() (fixedpoint.Value, bool) { + bestBid, ok := b.BestBid() + if !ok { + return fixedpoint.Zero, false + } + + bestAsk, ok := b.BestAsk() + if !ok { + return fixedpoint.Zero, false + } + + return bestAsk.Price.Sub(bestBid.Price), true +} + +func (b *SliceOrderBook) BestBid() (PriceVolume, bool) { + if len(b.Bids) == 0 { + return PriceVolume{}, false + } + + return b.Bids[0], true +} + +func (b *SliceOrderBook) BestAsk() (PriceVolume, bool) { + if len(b.Asks) == 0 { + return PriceVolume{}, false + } + + return b.Asks[0], true +} + +func (b *SliceOrderBook) SideBook(sideType SideType) PriceVolumeSlice { + switch sideType { + + case SideTypeBuy: + return b.Bids + + case SideTypeSell: + return b.Asks + + default: + return nil + } +} + +func (b *SliceOrderBook) IsValid() (bool, error) { + bid, hasBid := b.BestBid() + ask, hasAsk := b.BestAsk() + + if !hasBid { + return false, errors.New("empty bids") + } + + if !hasAsk { + return false, errors.New("empty asks") + } + + if bid.Price.Compare(ask.Price) > 0 { + return false, fmt.Errorf("bid price %s > ask price %s", bid.Price.String(), ask.Price.String()) + } + + return true, nil +} + +func (b *SliceOrderBook) PriceVolumesBySide(side SideType) PriceVolumeSlice { + switch side { + + case SideTypeBuy: + return b.Bids.Copy() + + case SideTypeSell: + return b.Asks.Copy() + } + + return nil +} + +func (b *SliceOrderBook) updateAsks(pvs PriceVolumeSlice) { + for _, pv := range pvs { + if pv.Volume.IsZero() { + b.Asks = b.Asks.Remove(pv.Price, false) + } else { + b.Asks = b.Asks.Upsert(pv, false) + } + } +} + +func (b *SliceOrderBook) updateBids(pvs PriceVolumeSlice) { + for _, pv := range pvs { + if pv.Volume.IsZero() { + b.Bids = b.Bids.Remove(pv.Price, true) + } else { + b.Bids = b.Bids.Upsert(pv, true) + } + } +} + +func (b *SliceOrderBook) update(book SliceOrderBook) { + b.updateBids(book.Bids) + b.updateAsks(book.Asks) + b.lastUpdateTime = defaultTime(book.Time, time.Now) +} + +func (b *SliceOrderBook) Reset() { + b.Bids = nil + b.Asks = nil +} + +func (b *SliceOrderBook) Load(book SliceOrderBook) { + b.Reset() + b.update(book) + b.EmitLoad(b) +} + +func (b *SliceOrderBook) Update(book SliceOrderBook) { + b.update(book) + b.EmitUpdate(b) +} + +func (b *SliceOrderBook) Print() { + fmt.Print(b.String()) +} + +func (b *SliceOrderBook) String() string { + sb := strings.Builder{} + + sb.WriteString("BOOK ") + sb.WriteString(b.Symbol) + sb.WriteString("\n") + sb.WriteString(b.Time.Format(time.RFC1123)) + sb.WriteString("\n") + + if len(b.Asks) > 0 { + sb.WriteString("ASKS:\n") + for i := len(b.Asks) - 1; i >= 0; i-- { + sb.WriteString("- ASK: ") + sb.WriteString(b.Asks[i].String()) + sb.WriteString("\n") + } + } + + if len(b.Bids) > 0 { + sb.WriteString("BIDS:\n") + for _, bid := range b.Bids { + sb.WriteString("- BID: ") + sb.WriteString(bid.String()) + sb.WriteString("\n") + } + } + + return sb.String() +} + +func (b *SliceOrderBook) CopyDepth(limit int) OrderBook { + var book SliceOrderBook + book.Symbol = b.Symbol + book.Time = b.Time + book.Bids = b.Bids.CopyDepth(limit) + book.Asks = b.Asks.CopyDepth(limit) + return &book +} + +func (b *SliceOrderBook) Copy() OrderBook { + var book SliceOrderBook + book.Symbol = b.Symbol + book.Time = b.Time + book.Bids = b.Bids.Copy() + book.Asks = b.Asks.Copy() + return &book +} diff --git a/pkg/types/sliceorderbook_callbacks.go b/pkg/types/sliceorderbook_callbacks.go new file mode 100644 index 0000000..43aef76 --- /dev/null +++ b/pkg/types/sliceorderbook_callbacks.go @@ -0,0 +1,25 @@ +// Code generated by "callbackgen -type SliceOrderBook"; DO NOT EDIT. + +package types + +import () + +func (b *SliceOrderBook) OnLoad(cb func(book *SliceOrderBook)) { + b.loadCallbacks = append(b.loadCallbacks, cb) +} + +func (b *SliceOrderBook) EmitLoad(book *SliceOrderBook) { + for _, cb := range b.loadCallbacks { + cb(book) + } +} + +func (b *SliceOrderBook) OnUpdate(cb func(book *SliceOrderBook)) { + b.updateCallbacks = append(b.updateCallbacks, cb) +} + +func (b *SliceOrderBook) EmitUpdate(book *SliceOrderBook) { + for _, cb := range b.updateCallbacks { + cb(book) + } +} diff --git a/pkg/types/sliceorderbook_test.go b/pkg/types/sliceorderbook_test.go new file mode 100644 index 0000000..06aedd3 --- /dev/null +++ b/pkg/types/sliceorderbook_test.go @@ -0,0 +1,27 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSliceOrderBook_CopyDepth(t *testing.T) { + b := &SliceOrderBook{ + Bids: PriceVolumeSlice{ + {Price: number(0.119), Volume: number(100.0)}, + {Price: number(0.118), Volume: number(100.0)}, + {Price: number(0.117), Volume: number(100.0)}, + {Price: number(0.116), Volume: number(100.0)}, + }, + Asks: PriceVolumeSlice{ + {Price: number(0.120), Volume: number(100.0)}, + {Price: number(0.121), Volume: number(100.0)}, + {Price: number(0.122), Volume: number(100.0)}, + }, + } + + copied := b.CopyDepth(0) + assert.Equal(t, 3, len(copied.SideBook(SideTypeSell))) + assert.Equal(t, 4, len(copied.SideBook(SideTypeBuy))) +} diff --git a/pkg/types/sort.go b/pkg/types/sort.go new file mode 100644 index 0000000..adaa956 --- /dev/null +++ b/pkg/types/sort.go @@ -0,0 +1,63 @@ +package types + +import ( + "sort" + "time" +) + +func SortTradesAscending(trades []Trade) []Trade { + sort.Slice(trades, func(i, j int) bool { + return trades[i].Time.Before(time.Time(trades[j].Time)) + }) + return trades +} + +// SortOrdersAscending sorts by creation time ascending-ly +func SortOrdersAscending(orders []Order) []Order { + sort.Slice(orders, func(i, j int) bool { + return orders[i].CreationTime.Time().Before(orders[j].CreationTime.Time()) + }) + return orders +} + +// SortOrdersDescending sorts by creation time descending-ly +func SortOrdersDescending(orders []Order) []Order { + sort.Slice(orders, func(i, j int) bool { + return orders[i].CreationTime.Time().After(orders[j].CreationTime.Time()) + }) + return orders +} + +// SortOrdersByPrice sorts by creation time ascending-ly +func SortOrdersByPrice(orders []Order, descending bool) []Order { + var f func(i, j int) bool + + if descending { + f = func(i, j int) bool { + return orders[i].Price.Compare(orders[j].Price) > 0 + } + } else { + f = func(i, j int) bool { + return orders[i].Price.Compare(orders[j].Price) < 0 + } + } + + sort.Slice(orders, f) + return orders +} + +// SortOrdersAscending sorts by update time ascending-ly +func SortOrdersUpdateTimeAscending(orders []Order) []Order { + sort.Slice(orders, func(i, j int) bool { + return orders[i].UpdateTime.Time().Before(orders[j].UpdateTime.Time()) + }) + return orders +} + +func SortKLinesAscending(klines []KLine) []KLine { + sort.Slice(klines, func(i, j int) bool { + return klines[i].StartTime.Unix() < klines[j].StartTime.Unix() + }) + + return klines +} diff --git a/pkg/types/sort_test.go b/pkg/types/sort_test.go new file mode 100644 index 0000000..388a79e --- /dev/null +++ b/pkg/types/sort_test.go @@ -0,0 +1,82 @@ +package types + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +func TestSortTradesAscending(t *testing.T) { + var trades = []Trade{ + { + ID: 1, + Symbol: "BTCUSDT", + Side: SideTypeBuy, + IsBuyer: false, + IsMaker: false, + Time: Time(time.Unix(2000, 0)), + }, + { + ID: 2, + Symbol: "BTCUSDT", + Side: SideTypeBuy, + IsBuyer: false, + IsMaker: false, + Time: Time(time.Unix(1000, 0)), + }, + } + trades = SortTradesAscending(trades) + assert.True(t, trades[0].Time.Before(trades[1].Time.Time())) +} + +func getOrderPrices(orders []Order) (prices fixedpoint.Slice) { + for _, o := range orders { + prices = append(prices, o.Price) + } + + return prices +} + +func TestSortOrdersByPrice(t *testing.T) { + + t.Run("ascending", func(t *testing.T) { + orders := []Order{ + {SubmitOrder: SubmitOrder{Price: number("10.0")}}, + {SubmitOrder: SubmitOrder{Price: number("30.0")}}, + {SubmitOrder: SubmitOrder{Price: number("20.0")}}, + {SubmitOrder: SubmitOrder{Price: number("25.0")}}, + {SubmitOrder: SubmitOrder{Price: number("15.0")}}, + } + orders = SortOrdersByPrice(orders, false) + prices := getOrderPrices(orders) + assert.Equal(t, fixedpoint.Slice{ + number(10.0), + number(15.0), + number(20.0), + number(25.0), + number(30.0), + }, prices) + }) + + t.Run("descending", func(t *testing.T) { + orders := []Order{ + {SubmitOrder: SubmitOrder{Price: number("10.0")}}, + {SubmitOrder: SubmitOrder{Price: number("30.0")}}, + {SubmitOrder: SubmitOrder{Price: number("20.0")}}, + {SubmitOrder: SubmitOrder{Price: number("25.0")}}, + {SubmitOrder: SubmitOrder{Price: number("15.0")}}, + } + orders = SortOrdersByPrice(orders, true) + prices := getOrderPrices(orders) + assert.Equal(t, fixedpoint.Slice{ + number(30.0), + number(25.0), + number(20.0), + number(15.0), + number(10.0), + }, prices) + }) +} diff --git a/pkg/types/sortino.go b/pkg/types/sortino.go new file mode 100644 index 0000000..362b94d --- /dev/null +++ b/pkg/types/sortino.go @@ -0,0 +1,57 @@ +package types + +import ( + "math" +) + +// Sortino: Calcluates the sotino ratio of access returns +// +// ROI_excess E[ROI] - ROI_risk_free +// +// sortino = ---------- = ----------------------- +// +// risk sqrt(E[ROI_drawdown^2]) +// +// @param returns (Series): Series of profit/loss percentage every specific interval +// @param riskFreeReturns (float): risk-free return rate of year +// @param periods (int): Freq. of returns (252/365 for daily, 12 for monthy, 1 for annually) +// @param annualize (bool): return annualize sortino? +// @param smart (bool): return smart sharpe ratio +func Sortino(returns Series, riskFreeReturns float64, periods int, annualize bool, smart bool) float64 { + avgRiskFreeReturns := 0. + excessReturn := Mean(returns) + if riskFreeReturns > 0. && periods > 0 { + avgRiskFreeReturns = avgReturnRate(riskFreeReturns, periods) + excessReturn -= avgRiskFreeReturns + } + + num := returns.Length() + if num == 0 { + return 0 + } + var sum = 0. + for i := 0; i < num; i++ { + exRet := returns.Last(i) - avgRiskFreeReturns + if exRet < 0 { + sum += exRet * exRet + } + } + var risk = math.Sqrt(sum / float64(num)) + if smart { + risk *= autocorrPenalty(returns) + } + if risk == 0 { + if excessReturn > 0 { + return math.Inf(1) + } else if excessReturn < 0 { + return math.Inf(-1) + } else { + return 0 + } + } + result := excessReturn / risk + if annualize { + return result * math.Sqrt(float64(periods)) + } + return result +} diff --git a/pkg/types/sortino_test.go b/pkg/types/sortino_test.go new file mode 100644 index 0000000..bbf1332 --- /dev/null +++ b/pkg/types/sortino_test.go @@ -0,0 +1,29 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" +) + +/* +python + +import quantstats as qx +import pandas as pd + +print(qx.stats.sortino(pd.Series([0.01, -0.03, 0.1, -0.02, 0.001]), 0.00, 0, False, False)) +print(qx.stats.sortino(pd.Series([0.01, -0.03, 0.1, -0.02, 0.001]), 0.03, 252, False, False)) +print(qx.stats.sortino(pd.Series([0.01, -0.03, 0.1, -0.02, 0.001]), 0.03, 252, True, False)) +*/ +func TestSortino(t *testing.T) { + var a Series = &floats.Slice{0.01, -0.03, 0.1, -0.02, 0.001} + output := Sortino(a, 0.03, 0, false, false) + assert.InDelta(t, output, 0.75661, 0.0001) + output = Sortino(a, 0.03, 252, false, false) + assert.InDelta(t, output, 0.74597, 0.0001) + output = Sortino(a, 0.03, 252, true, false) + assert.InDelta(t, output, 11.84192, 0.0001) +} diff --git a/pkg/types/standardstream_callbacks.go b/pkg/types/standardstream_callbacks.go new file mode 100644 index 0000000..c75e752 --- /dev/null +++ b/pkg/types/standardstream_callbacks.go @@ -0,0 +1,235 @@ +// Code generated by "callbackgen -type StandardStream -interface"; DO NOT EDIT. + +package types + +import () + +func (s *StandardStream) OnStart(cb func()) { + s.startCallbacks = append(s.startCallbacks, cb) +} + +func (s *StandardStream) EmitStart() { + for _, cb := range s.startCallbacks { + cb() + } +} + +func (s *StandardStream) OnConnect(cb func()) { + s.connectCallbacks = append(s.connectCallbacks, cb) +} + +func (s *StandardStream) EmitConnect() { + for _, cb := range s.connectCallbacks { + cb() + } +} + +func (s *StandardStream) OnDisconnect(cb func()) { + s.disconnectCallbacks = append(s.disconnectCallbacks, cb) +} + +func (s *StandardStream) EmitDisconnect() { + for _, cb := range s.disconnectCallbacks { + cb() + } +} + +func (s *StandardStream) OnAuth(cb func()) { + s.authCallbacks = append(s.authCallbacks, cb) +} + +func (s *StandardStream) EmitAuth() { + for _, cb := range s.authCallbacks { + cb() + } +} + +func (s *StandardStream) OnRawMessage(cb func(raw []byte)) { + s.rawMessageCallbacks = append(s.rawMessageCallbacks, cb) +} + +func (s *StandardStream) EmitRawMessage(raw []byte) { + for _, cb := range s.rawMessageCallbacks { + cb(raw) + } +} + +func (s *StandardStream) OnTradeUpdate(cb func(trade Trade)) { + s.tradeUpdateCallbacks = append(s.tradeUpdateCallbacks, cb) +} + +func (s *StandardStream) EmitTradeUpdate(trade Trade) { + for _, cb := range s.tradeUpdateCallbacks { + cb(trade) + } +} + +func (s *StandardStream) OnOrderUpdate(cb func(order Order)) { + s.orderUpdateCallbacks = append(s.orderUpdateCallbacks, cb) +} + +func (s *StandardStream) EmitOrderUpdate(order Order) { + for _, cb := range s.orderUpdateCallbacks { + cb(order) + } +} + +func (s *StandardStream) OnBalanceSnapshot(cb func(balances BalanceMap)) { + s.balanceSnapshotCallbacks = append(s.balanceSnapshotCallbacks, cb) +} + +func (s *StandardStream) EmitBalanceSnapshot(balances BalanceMap) { + for _, cb := range s.balanceSnapshotCallbacks { + cb(balances) + } +} + +func (s *StandardStream) OnBalanceUpdate(cb func(balances BalanceMap)) { + s.balanceUpdateCallbacks = append(s.balanceUpdateCallbacks, cb) +} + +func (s *StandardStream) EmitBalanceUpdate(balances BalanceMap) { + for _, cb := range s.balanceUpdateCallbacks { + cb(balances) + } +} + +func (s *StandardStream) OnKLineClosed(cb func(kline KLine)) { + s.kLineClosedCallbacks = append(s.kLineClosedCallbacks, cb) +} + +func (s *StandardStream) EmitKLineClosed(kline KLine) { + for _, cb := range s.kLineClosedCallbacks { + cb(kline) + } +} + +func (s *StandardStream) OnKLine(cb func(kline KLine)) { + s.kLineCallbacks = append(s.kLineCallbacks, cb) +} + +func (s *StandardStream) EmitKLine(kline KLine) { + for _, cb := range s.kLineCallbacks { + cb(kline) + } +} + +func (s *StandardStream) OnBookUpdate(cb func(book SliceOrderBook)) { + s.bookUpdateCallbacks = append(s.bookUpdateCallbacks, cb) +} + +func (s *StandardStream) EmitBookUpdate(book SliceOrderBook) { + for _, cb := range s.bookUpdateCallbacks { + cb(book) + } +} + +func (s *StandardStream) OnBookTickerUpdate(cb func(bookTicker BookTicker)) { + s.bookTickerUpdateCallbacks = append(s.bookTickerUpdateCallbacks, cb) +} + +func (s *StandardStream) EmitBookTickerUpdate(bookTicker BookTicker) { + for _, cb := range s.bookTickerUpdateCallbacks { + cb(bookTicker) + } +} + +func (s *StandardStream) OnBookSnapshot(cb func(book SliceOrderBook)) { + s.bookSnapshotCallbacks = append(s.bookSnapshotCallbacks, cb) +} + +func (s *StandardStream) EmitBookSnapshot(book SliceOrderBook) { + for _, cb := range s.bookSnapshotCallbacks { + cb(book) + } +} + +func (s *StandardStream) OnMarketTrade(cb func(trade Trade)) { + s.marketTradeCallbacks = append(s.marketTradeCallbacks, cb) +} + +func (s *StandardStream) EmitMarketTrade(trade Trade) { + for _, cb := range s.marketTradeCallbacks { + cb(trade) + } +} + +func (s *StandardStream) OnAggTrade(cb func(trade Trade)) { + s.aggTradeCallbacks = append(s.aggTradeCallbacks, cb) +} + +func (s *StandardStream) EmitAggTrade(trade Trade) { + for _, cb := range s.aggTradeCallbacks { + cb(trade) + } +} + +func (s *StandardStream) OnForceOrder(cb func(info LiquidationInfo)) { + s.forceOrderCallbacks = append(s.forceOrderCallbacks, cb) +} + +func (s *StandardStream) EmitForceOrder(info LiquidationInfo) { + for _, cb := range s.forceOrderCallbacks { + cb(info) + } +} + +func (s *StandardStream) OnFuturesPositionUpdate(cb func(futuresPositions FuturesPositionMap)) { + s.FuturesPositionUpdateCallbacks = append(s.FuturesPositionUpdateCallbacks, cb) +} + +func (s *StandardStream) EmitFuturesPositionUpdate(futuresPositions FuturesPositionMap) { + for _, cb := range s.FuturesPositionUpdateCallbacks { + cb(futuresPositions) + } +} + +func (s *StandardStream) OnFuturesPositionSnapshot(cb func(futuresPositions FuturesPositionMap)) { + s.FuturesPositionSnapshotCallbacks = append(s.FuturesPositionSnapshotCallbacks, cb) +} + +func (s *StandardStream) EmitFuturesPositionSnapshot(futuresPositions FuturesPositionMap) { + for _, cb := range s.FuturesPositionSnapshotCallbacks { + cb(futuresPositions) + } +} + +type StandardStreamEventHub interface { + OnStart(cb func()) + + OnConnect(cb func()) + + OnDisconnect(cb func()) + + OnAuth(cb func()) + + OnRawMessage(cb func(raw []byte)) + + OnTradeUpdate(cb func(trade Trade)) + + OnOrderUpdate(cb func(order Order)) + + OnBalanceSnapshot(cb func(balances BalanceMap)) + + OnBalanceUpdate(cb func(balances BalanceMap)) + + OnKLineClosed(cb func(kline KLine)) + + OnKLine(cb func(kline KLine)) + + OnBookUpdate(cb func(book SliceOrderBook)) + + OnBookTickerUpdate(cb func(bookTicker BookTicker)) + + OnBookSnapshot(cb func(book SliceOrderBook)) + + OnMarketTrade(cb func(trade Trade)) + + OnAggTrade(cb func(trade Trade)) + + OnForceOrder(cb func(info LiquidationInfo)) + + OnFuturesPositionUpdate(cb func(futuresPositions FuturesPositionMap)) + + OnFuturesPositionSnapshot(cb func(futuresPositions FuturesPositionMap)) +} diff --git a/pkg/types/strategy_status.go b/pkg/types/strategy_status.go new file mode 100644 index 0000000..00efde4 --- /dev/null +++ b/pkg/types/strategy_status.go @@ -0,0 +1,10 @@ +package types + +// StrategyStatus define strategy status +type StrategyStatus string + +const ( + StrategyStatusRunning StrategyStatus = "RUNNING" + StrategyStatusStopped StrategyStatus = "STOPPED" + StrategyStatusUnknown StrategyStatus = "UNKNOWN" +) diff --git a/pkg/types/stream.go b/pkg/types/stream.go new file mode 100644 index 0000000..a2246ed --- /dev/null +++ b/pkg/types/stream.go @@ -0,0 +1,588 @@ +package types + +import ( + "context" + "net" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "github.com/spf13/viper" +) + +const pingInterval = 30 * time.Second +const readTimeout = 2 * time.Minute +const writeTimeout = 10 * time.Second +const reconnectCoolDownPeriod = 15 * time.Second + +var defaultDialer = &websocket.Dialer{ + Proxy: http.ProxyFromEnvironment, + HandshakeTimeout: 10 * time.Second, + ReadBufferSize: 4096, +} + +type Stream interface { + StandardStreamEventHub + + // Subscribe subscribes the specific channel, but not connect to the server. + Subscribe(channel Channel, symbol string, options SubscribeOptions) + GetSubscriptions() []Subscription + // Resubscribe used to update or renew existing subscriptions. It will reconnect to the server. + Resubscribe(func(oldSubs []Subscription) (newSubs []Subscription, err error)) error + // SetPublicOnly connects to public or private + SetPublicOnly() + GetPublicOnly() bool + + // Connect connects to websocket server + Connect(ctx context.Context) error + Reconnect() + Close() error +} + +type PrivateChannelSetter interface { + SetPrivateChannels(channels []string) +} + +type PrivateChannelSymbolSetter interface { + SetPrivateChannelSymbols(symbols []string) +} + +type Unsubscriber interface { + // Unsubscribe unsubscribes the all subscriptions. + Unsubscribe() +} + +type EndpointCreator func(ctx context.Context) (string, error) + +type Parser func(message []byte) (interface{}, error) + +type Dispatcher func(e interface{}) + +// HeartBeat keeps connection alive by sending the ping packet. +type HeartBeat func(conn *websocket.Conn) error + +type BeforeConnect func(ctx context.Context) error + +type WebsocketPongEvent struct{} + +//go:generate callbackgen -type StandardStream -interface +type StandardStream struct { + parser Parser + dispatcher Dispatcher + pingInterval time.Duration + + endpointCreator EndpointCreator + + // Conn is the websocket connection + Conn *websocket.Conn + + // ConnCtx is the context of the current websocket connection + ConnCtx context.Context + + // ConnCancel is the cancel funcion of the current websocket connection + ConnCancel context.CancelFunc + + // ConnLock is used for locking Conn, ConnCtx and ConnCancel fields. + // When changing these field values, be sure to call ConnLock + ConnLock sync.Mutex + + PublicOnly bool + + // sg is used to wait until the previous routines are closed. + // only handle routines used internally, avoid including external callback func to prevent issues if they have + // bugs and cannot terminate. + sg SyncGroup + + // ReconnectC is a signal channel for reconnecting + ReconnectC chan struct{} + + // CloseC is a signal channel for closing stream + CloseC chan struct{} + + Subscriptions []Subscription + + // subLock is used for locking Subscriptions fields. + // When changing these field values, be sure to call subLock + subLock sync.Mutex + + startCallbacks []func() + + connectCallbacks []func() + + disconnectCallbacks []func() + + authCallbacks []func() + + rawMessageCallbacks []func(raw []byte) + + // private trade update callbacks + tradeUpdateCallbacks []func(trade Trade) + + // private order update callbacks + orderUpdateCallbacks []func(order Order) + + // balance snapshot callbacks + balanceSnapshotCallbacks []func(balances BalanceMap) + + balanceUpdateCallbacks []func(balances BalanceMap) + + kLineClosedCallbacks []func(kline KLine) + + kLineCallbacks []func(kline KLine) + + bookUpdateCallbacks []func(book SliceOrderBook) + + bookTickerUpdateCallbacks []func(bookTicker BookTicker) + + bookSnapshotCallbacks []func(book SliceOrderBook) + + marketTradeCallbacks []func(trade Trade) + + aggTradeCallbacks []func(trade Trade) + + forceOrderCallbacks []func(info LiquidationInfo) + + // Futures + FuturesPositionUpdateCallbacks []func(futuresPositions FuturesPositionMap) + + FuturesPositionSnapshotCallbacks []func(futuresPositions FuturesPositionMap) + + heartBeat HeartBeat + + beforeConnect BeforeConnect +} + +type StandardStreamEmitter interface { + Stream + EmitStart() + EmitConnect() + EmitDisconnect() + EmitAuth() + EmitTradeUpdate(Trade) + EmitOrderUpdate(Order) + EmitBalanceSnapshot(BalanceMap) + EmitBalanceUpdate(BalanceMap) + EmitKLineClosed(KLine) + EmitKLine(KLine) + EmitBookUpdate(SliceOrderBook) + EmitBookTickerUpdate(BookTicker) + EmitBookSnapshot(SliceOrderBook) + EmitMarketTrade(Trade) + EmitAggTrade(Trade) + EmitForceOrder(LiquidationInfo) + EmitFuturesPositionUpdate(FuturesPositionMap) + EmitFuturesPositionSnapshot(FuturesPositionMap) +} + +func NewStandardStream() StandardStream { + return StandardStream{ + ReconnectC: make(chan struct{}, 1), + CloseC: make(chan struct{}), + sg: NewSyncGroup(), + pingInterval: pingInterval, + } +} + +func (s *StandardStream) SetPublicOnly() { + s.PublicOnly = true +} + +func (s *StandardStream) GetPublicOnly() bool { + return s.PublicOnly +} + +func (s *StandardStream) SetEndpointCreator(creator EndpointCreator) { + s.endpointCreator = creator +} + +func (s *StandardStream) SetDispatcher(dispatcher Dispatcher) { + s.dispatcher = dispatcher +} + +func (s *StandardStream) SetParser(parser Parser) { + s.parser = parser +} + +func (s *StandardStream) SetConn(ctx context.Context, conn *websocket.Conn) (context.Context, context.CancelFunc) { + // should only start one connection one time, so we lock the mutex + connCtx, connCancel := context.WithCancel(ctx) + s.ConnLock.Lock() + + // ensure the previous context is cancelled and all routines are closed. + if s.ConnCancel != nil { + s.ConnCancel() + s.sg.WaitAndClear() + } + + // create a new context for this connection + s.Conn = conn + s.ConnCtx = connCtx + s.ConnCancel = connCancel + s.ConnLock.Unlock() + return connCtx, connCancel +} + +func (s *StandardStream) Read(ctx context.Context, conn *websocket.Conn, cancel context.CancelFunc) { + defer func() { + cancel() + s.EmitDisconnect() + }() + + // flag format: debug-{component}-{message type} + debugRawMessage := viper.GetBool("debug-websocket-raw-message") + + hasParser := s.parser != nil + hasDispatcher := s.dispatcher != nil + + for { + select { + + case <-ctx.Done(): + return + + case <-s.CloseC: + return + + default: + if err := conn.SetReadDeadline(time.Now().Add(readTimeout)); err != nil { + log.WithError(err).Errorf("set read deadline error: %s", err.Error()) + } + + mt, message, err := conn.ReadMessage() + if err != nil { + // if it's a network timeout error, we should re-connect + switch err2 := err.(type) { + + // if it's a websocket related error + case *websocket.CloseError: + if err2.Code != websocket.CloseNormalClosure { + log.WithError(err2).Warnf("websocket error abnormal close: %+v", err2) + } + + _ = conn.Close() + // for close error, we should re-connect + // emit reconnect to start a new connection + s.Reconnect() + return + + case net.Error: + log.WithError(err2).Warn("websocket read network error") + _ = conn.Close() + s.Reconnect() + return + + default: + log.WithError(err2).Warn("unexpected websocket error") + _ = conn.Close() + s.Reconnect() + return + } + } + + // skip non-text messages + if mt != websocket.TextMessage { + continue + } + + if debugRawMessage { + log.Info(string(message)) + } + + if !hasParser { + s.EmitRawMessage(message) + continue + } + + var e interface{} + e, err = s.parser(message) + if err != nil { + log.WithError(err).Errorf("websocket event parse error, message: %s", message) + // emit raw message even if occurs error, because we want anything can be detected + s.EmitRawMessage(message) + continue + } + + // skip pong event to avoid the message like spam + if _, ok := e.(*WebsocketPongEvent); !ok { + s.EmitRawMessage(message) + } + + if hasDispatcher { + s.dispatcher(e) + } + } + } +} + +func (s *StandardStream) SetPingInterval(interval time.Duration) { + s.pingInterval = interval +} + +func (s *StandardStream) ping( + ctx context.Context, conn *websocket.Conn, cancel context.CancelFunc, +) { + defer func() { + cancel() + log.Debug("[websocket] ping worker stopped") + }() + + var pingTicker = time.NewTicker(s.pingInterval) + defer pingTicker.Stop() + + for { + select { + + case <-ctx.Done(): + return + + case <-s.CloseC: + return + + case <-pingTicker.C: + if s.heartBeat != nil { + if err := s.heartBeat(conn); err != nil { + // log errors at the concrete class so that we can identify which exchange encountered an error + s.Reconnect() + return + } + } + + if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(writeTimeout)); err != nil { + log.WithError(err).Error("ping error", err) + s.Reconnect() + return + } + } + } +} + +func (s *StandardStream) GetSubscriptions() []Subscription { + s.subLock.Lock() + defer s.subLock.Unlock() + + return s.Subscriptions +} + +// Resubscribe synchronizes the new subscriptions based on the provided function. +// The fn function takes the old subscriptions as input and returns the new subscriptions that will replace the old ones +// in the struct then Reconnect. +// This method is thread-safe. +func (s *StandardStream) Resubscribe(fn func(old []Subscription) (new []Subscription, err error)) error { + s.subLock.Lock() + defer s.subLock.Unlock() + + var err error + subs, err := fn(s.Subscriptions) + if err != nil { + return err + } + s.Subscriptions = subs + s.Reconnect() + return nil +} + +func (s *StandardStream) Subscribe(channel Channel, symbol string, options SubscribeOptions) { + s.subLock.Lock() + defer s.subLock.Unlock() + + s.Subscriptions = append(s.Subscriptions, Subscription{ + Channel: channel, + Symbol: symbol, + Options: options, + }) +} + +func (s *StandardStream) Reconnect() { + select { + case s.ReconnectC <- struct{}{}: + default: + } +} + +// Connect starts the stream and create the websocket connection +func (s *StandardStream) Connect(ctx context.Context) error { + if s.beforeConnect != nil { + if err := s.beforeConnect(ctx); err != nil { + return err + } + } + err := s.DialAndConnect(ctx) + if err != nil { + return err + } + + // start one re-connector goroutine with the base context + // reconnector goroutine does not exit when the connection is closed + go s.reconnector(ctx) + + s.EmitStart() + return nil +} + +func (s *StandardStream) reconnector(ctx context.Context) { + for { + select { + + case <-ctx.Done(): + return + + case <-s.CloseC: + return + + case <-s.ReconnectC: + log.Warnf("received reconnect signal, cooling for %s...", reconnectCoolDownPeriod) + time.Sleep(reconnectCoolDownPeriod) + + log.Warnf("re-connecting...") + if err := s.DialAndConnect(ctx); err != nil { + log.WithError(err).Errorf("re-connect error, try to reconnect later") + + // re-emit the re-connect signal if error + s.Reconnect() + } + } + } +} + +func (s *StandardStream) DialAndConnect(ctx context.Context) error { + conn, err := s.Dial(ctx) + if err != nil { + return err + } + + connCtx, connCancel := s.SetConn(ctx, conn) + s.EmitConnect() + + s.sg.Add(func() { + s.Read(connCtx, conn, connCancel) + }) + s.sg.Add(func() { + s.ping(connCtx, conn, connCancel) + }) + s.sg.Run() + return nil +} + +func (s *StandardStream) Dial(ctx context.Context, args ...string) (*websocket.Conn, error) { + var url string + var err error + if len(args) > 0 { + url = args[0] + } else if s.endpointCreator != nil { + url, err = s.endpointCreator(ctx) + if err != nil { + return nil, errors.Wrap(err, "can not dial, can not create endpoint via the endpoint creator") + } + } else { + return nil, errors.New("can not dial, neither url nor endpoint creator is not defined, you should pass an url to Dial() or call SetEndpointCreator()") + } + + conn, _, err := defaultDialer.Dial(url, nil) + if err != nil { + return nil, err + } + + // use the default ping handler + // The websocket server will send a ping frame every 3 minutes. + // If the websocket server does not receive a pong frame back from the connection within a 10 minutes period, + // the connection will be disconnected. + // Unsolicited pong frames are allowed. + conn.SetPingHandler(nil) + conn.SetPongHandler(func(string) error { + if err := conn.SetReadDeadline(time.Now().Add(readTimeout * 2)); err != nil { + log.WithError(err).Error("pong handler can not set read deadline") + } + return nil + }) + + log.Infof("[websocket] connected, public = %v, read timeout = %v", s.PublicOnly, readTimeout) + return conn, nil +} + +func (s *StandardStream) Close() error { + log.Debugf("[websocket] closing stream...") + + // close the close signal channel, so that reader and ping worker will stop + close(s.CloseC) + + // get the connection object before call the context cancel function + s.ConnLock.Lock() + conn := s.Conn + connCancel := s.ConnCancel + s.ConnLock.Unlock() + + // cancel the context so that the ticker loop and listen key updater will be stopped. + if connCancel != nil { + connCancel() + } + + // gracefully write the close message to the connection + err := conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + if err != nil { + return errors.Wrap(err, "websocket write close message error") + } + + log.Debugf("[websocket] stream closed") + + // let the reader close the connection + <-time.After(time.Second) + return nil +} + +// SetHeartBeat sets the custom heart beat implementation if needed +func (s *StandardStream) SetHeartBeat(fn HeartBeat) { + s.heartBeat = fn +} + +// SetBeforeConnect sets the custom hook function before connect +func (s *StandardStream) SetBeforeConnect(fn BeforeConnect) { + s.beforeConnect = fn +} + +type Depth string + +const ( + DepthLevelFull Depth = "FULL" + DepthLevelMedium Depth = "MEDIUM" + DepthLevel1 Depth = "1" + DepthLevel5 Depth = "5" + DepthLevel10 Depth = "10" + DepthLevel15 Depth = "15" + DepthLevel20 Depth = "20" + DepthLevel50 Depth = "50" + DepthLevel200 Depth = "200" + DepthLevel400 Depth = "400" +) + +type Speed string + +const ( + SpeedHigh Speed = "HIGH" + SpeedMedium Speed = "MEDIUM" + SpeedLow Speed = "LOW" +) + +// SubscribeOptions provides the standard stream options +type SubscribeOptions struct { + // TODO: change to Interval type later + Interval Interval `json:"interval,omitempty"` + Depth Depth `json:"depth,omitempty"` + Speed Speed `json:"speed,omitempty"` +} + +func (o SubscribeOptions) String() string { + if len(o.Interval) > 0 { + return string(o.Interval) + } + + return string(o.Depth) +} + +type Subscription struct { + Symbol string `json:"symbol"` + Channel Channel `json:"channel"` + Options SubscribeOptions `json:"options"` +} diff --git a/pkg/types/streamorderbook_callbacks.go b/pkg/types/streamorderbook_callbacks.go new file mode 100644 index 0000000..ac7e3ed --- /dev/null +++ b/pkg/types/streamorderbook_callbacks.go @@ -0,0 +1,25 @@ +// Code generated by "callbackgen -type StreamOrderBook"; DO NOT EDIT. + +package types + +import () + +func (sb *StreamOrderBook) OnUpdate(cb func(update SliceOrderBook)) { + sb.updateCallbacks = append(sb.updateCallbacks, cb) +} + +func (sb *StreamOrderBook) EmitUpdate(update SliceOrderBook) { + for _, cb := range sb.updateCallbacks { + cb(update) + } +} + +func (sb *StreamOrderBook) OnSnapshot(cb func(snapshot SliceOrderBook)) { + sb.snapshotCallbacks = append(sb.snapshotCallbacks, cb) +} + +func (sb *StreamOrderBook) EmitSnapshot(snapshot SliceOrderBook) { + for _, cb := range sb.snapshotCallbacks { + cb(snapshot) + } +} diff --git a/pkg/types/strint.go b/pkg/types/strint.go new file mode 100644 index 0000000..b806dce --- /dev/null +++ b/pkg/types/strint.go @@ -0,0 +1,50 @@ +package types + +import ( + "encoding/json" + "fmt" + "strconv" +) + +type StrInt64 int64 + +func (s *StrInt64) MarshalJSON() ([]byte, error) { + ss := strconv.FormatInt(int64(*s), 10) + return json.Marshal(ss) +} + +func (s *StrInt64) UnmarshalJSON(body []byte) error { + var arg interface{} + if err := json.Unmarshal(body, &arg); err != nil { + return err + } + + switch ta := arg.(type) { + case string: + // parse string + i, err := strconv.ParseInt(ta, 10, 64) + if err != nil { + return err + } + *s = StrInt64(i) + + case int64: + *s = StrInt64(ta) + case int32: + *s = StrInt64(ta) + case int: + *s = StrInt64(ta) + + default: + return fmt.Errorf("StrInt64 error: unsupported value type %T", ta) + } + + return nil +} + +func (s *StrInt64) String() string { + if s == nil { + return "" + } + return strconv.FormatInt(int64(*s), 10) +} diff --git a/pkg/types/syncgroup.go b/pkg/types/syncgroup.go new file mode 100644 index 0000000..f63829e --- /dev/null +++ b/pkg/types/syncgroup.go @@ -0,0 +1,53 @@ +package types + +import ( + "sync" +) + +type syncGroupFunc func() + +// SyncGroup is essentially a wrapper around sync.WaitGroup, designed for ease of use. You only need to use Add() to +// add routines and Run() to execute them. When it's time to close or reset, you just need to call WaitAndClear(), +// which takes care of waiting for all the routines to complete before clearing routine. +// +// It eliminates the need for manual management of sync.WaitGroup. Specifically, it highlights that SyncGroup takes +// care of sync.WaitGroup.Add() and sync.WaitGroup.Done() automatically, reducing the chances of missing these crucial calls. +type SyncGroup struct { + wg sync.WaitGroup + + sgFuncsMu sync.Mutex + sgFuncs []syncGroupFunc +} + +func NewSyncGroup() SyncGroup { + return SyncGroup{} +} + +func (w *SyncGroup) WaitAndClear() { + w.wg.Wait() + + w.sgFuncsMu.Lock() + w.sgFuncs = []syncGroupFunc{} + w.sgFuncsMu.Unlock() +} + +func (w *SyncGroup) Add(fn syncGroupFunc) { + w.wg.Add(1) + + w.sgFuncsMu.Lock() + w.sgFuncs = append(w.sgFuncs, fn) + w.sgFuncsMu.Unlock() +} + +func (w *SyncGroup) Run() { + w.sgFuncsMu.Lock() + fns := w.sgFuncs + w.sgFuncsMu.Unlock() + + for _, fn := range fns { + go func(doFunc syncGroupFunc) { + defer w.wg.Done() + doFunc() + }(fn) + } +} diff --git a/pkg/types/syncgroup_test.go b/pkg/types/syncgroup_test.go new file mode 100644 index 0000000..a2d69bf --- /dev/null +++ b/pkg/types/syncgroup_test.go @@ -0,0 +1,29 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_waitGroup_Run(t *testing.T) { + closeCh1 := make(chan struct{}) + closeCh2 := make(chan struct{}) + + wg := NewSyncGroup() + wg.Add(func() { + <-closeCh1 + }) + + wg.Add(func() { + <-closeCh2 + }) + + wg.Run() + + close(closeCh1) + close(closeCh2) + wg.WaitAndClear() + + assert.Len(t, wg.sgFuncs, 0) +} diff --git a/pkg/types/ticker.go b/pkg/types/ticker.go new file mode 100644 index 0000000..b06b413 --- /dev/null +++ b/pkg/types/ticker.go @@ -0,0 +1,23 @@ +package types + +import ( + "fmt" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +type Ticker struct { + Time time.Time + Volume fixedpoint.Value // `volume` from Max & binance + Last fixedpoint.Value // `last` from Max, `lastPrice` from binance + Open fixedpoint.Value // `open` from Max, `openPrice` from binance + High fixedpoint.Value // `high` from Max, `highPrice` from binance + Low fixedpoint.Value // `low` from Max, `lowPrice` from binance + Buy fixedpoint.Value // `buy` from Max, `bidPrice` from binance + Sell fixedpoint.Value // `sell` from Max, `askPrice` from binance +} + +func (t *Ticker) String() string { + return fmt.Sprintf("O:%s H:%s L:%s LAST:%s BID/ASK:%s/%s TIME:%s", t.Open, t.High, t.Low, t.Last, t.Buy, t.Sell, t.Time.String()) +} diff --git a/pkg/types/time.go b/pkg/types/time.go new file mode 100644 index 0000000..735514d --- /dev/null +++ b/pkg/types/time.go @@ -0,0 +1,368 @@ +package types + +import ( + "database/sql/driver" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" +) + +var numOfDigitsOfUnixTimestamp = len(strconv.FormatInt(time.Now().Unix(), 10)) +var numOfDigitsOfMilliSecondUnixTimestamp = len(strconv.FormatInt(time.Now().UnixNano()/int64(time.Millisecond), 10)) +var numOfDigitsOfNanoSecondsUnixTimestamp = len(strconv.FormatInt(time.Now().UnixNano(), 10)) + +type NanosecondTimestamp time.Time + +func (t NanosecondTimestamp) Time() time.Time { + return time.Time(t) +} + +func (t *NanosecondTimestamp) UnmarshalJSON(data []byte) error { + var v int64 + + var err = json.Unmarshal(data, &v) + if err != nil { + return err + } + + *t = NanosecondTimestamp(time.Unix(0, v)) + return nil +} + +type MillisecondTimestamp time.Time + +func NewMillisecondTimestampFromInt(i int64) MillisecondTimestamp { + return MillisecondTimestamp(time.Unix(0, i*int64(time.Millisecond))) +} + +func MustParseMillisecondTimestamp(a string) MillisecondTimestamp { + m, err := strconv.ParseInt(a, 10, 64) // startTime + if err != nil { + panic(fmt.Errorf("millisecond timestamp parse error %v", err)) + } + + return NewMillisecondTimestampFromInt(m) +} + +func MustParseUnixTimestamp(a string) time.Time { + m, err := strconv.ParseInt(a, 10, 64) // startTime + if err != nil { + panic(fmt.Errorf("millisecond timestamp parse error %v", err)) + } + + return time.Unix(m, 0) +} + +func (t MillisecondTimestamp) String() string { + return time.Time(t).String() +} + +func (t MillisecondTimestamp) Time() time.Time { + return time.Time(t) +} + +func (t *MillisecondTimestamp) UnmarshalJSON(data []byte) error { + var v interface{} + + var err = json.Unmarshal(data, &v) + if err != nil { + return err + } + + switch vt := v.(type) { + case string: + if vt == "" { + // treat empty string as 0 + *t = MillisecondTimestamp(time.Time{}) + return nil + } + + f, err := strconv.ParseFloat(vt, 64) + if err == nil { + tt, err := convertFloat64ToTime(vt, f) + if err != nil { + return err + } + + *t = MillisecondTimestamp(tt) + return nil + } + + tt, err := time.Parse(time.RFC3339Nano, vt) + if err == nil { + *t = MillisecondTimestamp(tt) + return nil + } + + return err + + case float64: + str := strconv.FormatFloat(vt, 'f', -1, 64) + tt, err := convertFloat64ToTime(str, vt) + if err != nil { + return err + } + + *t = MillisecondTimestamp(tt) + return nil + + default: + return fmt.Errorf("can not parse %T %+v as millisecond timestamp", vt, vt) + + } + + // Unreachable +} + +func convertFloat64ToTime(vt string, f float64) (time.Time, error) { + idx := strings.Index(vt, ".") + if idx > 0 { + vt = vt[0 : idx-1] + } + + if len(vt) <= numOfDigitsOfUnixTimestamp { + return time.Unix(0, int64(f*float64(time.Second))), nil + } else if len(vt) <= numOfDigitsOfMilliSecondUnixTimestamp { + return time.Unix(0, int64(f)*int64(time.Millisecond)), nil + } else if len(vt) <= numOfDigitsOfNanoSecondsUnixTimestamp { + return time.Unix(0, int64(f)), nil + } + + return time.Time{}, fmt.Errorf("the floating point value %f is out of the timestamp range", f) +} + +// Time type implements the driver value for sqlite +type Time time.Time + +// layout defines the time layout without timezone +// because sqlite3 and mysql does not output datetime with timezone. +// +// sqlite3 format (in UTC): 2022-06-01 11:38:45 +// mysql format in (in Local): 2022-06-02 20:28:10 or 2022-06-02 20:28:10.000 +var layout = "2006-01-02 15:04:05.999Z07:00" + +func (t *Time) UnmarshalJSON(data []byte) error { + // fallback to RFC3339 + return (*time.Time)(t).UnmarshalJSON(data) +} + +func (t Time) MarshalJSON() ([]byte, error) { + return time.Time(t).MarshalJSON() +} + +func (t Time) String() string { + return time.Time(t).String() +} + +func (t Time) Time() time.Time { + return time.Time(t) +} + +func (t Time) Unix() int64 { + return time.Time(t).Unix() +} + +func (t Time) UnixMilli() int64 { + return time.Time(t).UnixMilli() +} + +func (t Time) Equal(time2 time.Time) bool { + return time.Time(t).Equal(time2) +} + +func (t Time) After(time2 time.Time) bool { + return time.Time(t).After(time2) +} + +func (t Time) Before(time2 time.Time) bool { + return time.Time(t).Before(time2) +} + +func NewTimeFromUnix(sec int64, nsec int64) Time { + return Time(time.Unix(sec, nsec)) +} + +// Value implements the driver.Valuer interface +// see http://jmoiron.net/blog/built-in-interfaces/ +func (t Time) Value() (driver.Value, error) { + if time.Time(t) == (time.Time{}) { + return nil, nil + } + return time.Time(t), nil +} + +func (t *Time) Scan(src interface{}) error { + // skip nil time + if src == nil { + return nil + } + + switch d := src.(type) { + + case *time.Time: + *t = Time(*d) + return nil + + case time.Time: + *t = Time(d) + return nil + + case string: + // 2020-12-16 05:17:12.994+08:00 + tt, err := time.Parse(layout, d) + if err != nil { + return err + } + + *t = Time(tt) + return nil + + case []byte: + // 2019-10-20 23:01:43.77+08:00 + tt, err := time.Parse(layout, string(d)) + if err != nil { + return err + } + + *t = Time(tt) + return nil + + default: + + } + + return fmt.Errorf("datatype.Time scan error, type: %T is not supported, value; %+v", src, src) +} + +var looseTimeFormats = []string{ + time.RFC3339, + time.RFC822, + "2006-01-02T15:04:05", + "2006-01-02", +} + +// LooseFormatTime parses date time string with a wide range of formats. +type LooseFormatTime time.Time + +func ParseLooseFormatTime(s string) (LooseFormatTime, error) { + var t time.Time + switch s { + case "now": + t = time.Now() + return LooseFormatTime(t), nil + + case "yesterday": + t = time.Now().AddDate(0, 0, -1) + return LooseFormatTime(t), nil + + case "last month": + t = time.Now().AddDate(0, -1, 0) + return LooseFormatTime(t), nil + + case "last 30 days": + t = time.Now().AddDate(0, 0, -30) + return LooseFormatTime(t), nil + + case "last year": + t = time.Now().AddDate(-1, 0, 0) + return LooseFormatTime(t), nil + + } + + tv, err := ParseTimeWithFormats(s, looseTimeFormats) + if err != nil { + return LooseFormatTime{}, err + } + + return LooseFormatTime(tv), nil +} + +func (t *LooseFormatTime) UnmarshalYAML(unmarshal func(interface{}) error) error { + var str string + if err := unmarshal(&str); err != nil { + return err + } + + lt, err := ParseLooseFormatTime(str) + if err != nil { + return err + } + + *t = lt + return nil +} + +func (t *LooseFormatTime) UnmarshalJSON(data []byte) error { + var v string + err := json.Unmarshal(data, &v) + if err != nil { + return err + } + + tv, err := ParseTimeWithFormats(v, looseTimeFormats) + if err != nil { + return err + } + + *t = LooseFormatTime(tv) + return nil +} + +func (t LooseFormatTime) MarshalJSON() ([]byte, error) { + return []byte(strconv.Quote(time.Time(t).Format(time.RFC3339))), nil +} + +func (t LooseFormatTime) Time() time.Time { + return time.Time(t) +} + +// Timestamp is used for parsing unix timestamp (seconds) +type Timestamp time.Time + +func (t Timestamp) Format(layout string) string { + return time.Time(t).Format(layout) +} + +func (t Timestamp) Time() time.Time { + return time.Time(t) +} + +func (t Timestamp) String() string { + return time.Time(t).String() +} + +func (t Timestamp) MarshalJSON() ([]byte, error) { + ts := time.Time(t).Unix() + return json.Marshal(ts) +} + +func (t *Timestamp) UnmarshalJSON(o []byte) error { + var timestamp int64 + if err := json.Unmarshal(o, ×tamp); err != nil { + return err + } + + *t = Timestamp(time.Unix(timestamp, 0)) + return nil +} + +func ParseTimeWithFormats(strTime string, formats []string) (time.Time, error) { + for _, format := range formats { + tt, err := time.Parse(format, strTime) + if err == nil { + return tt, nil + } + } + return time.Time{}, fmt.Errorf("failed to parse time %s, valid formats are %+v", strTime, formats) +} + +func BeginningOfTheDay(t time.Time) time.Time { + year, month, day := t.Date() + return time.Date(year, month, day, 0, 0, 0, 0, t.Location()) +} + +func Over24Hours(since time.Time) bool { + return time.Since(since) >= 24*time.Hour +} diff --git a/pkg/types/time_test.go b/pkg/types/time_test.go new file mode 100644 index 0000000..9fcb613 --- /dev/null +++ b/pkg/types/time_test.go @@ -0,0 +1,89 @@ +package types + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestParseLooseFormatTime_alias_now(t *testing.T) { + lt, err := ParseLooseFormatTime("now") + assert.NoError(t, err) + + now := time.Now() + assert.True(t, now.Sub(lt.Time()) < 10*time.Millisecond) +} + +func TestParseLooseFormatTime_alias_yesterday(t *testing.T) { + lt, err := ParseLooseFormatTime("yesterday") + assert.NoError(t, err) + + tt := time.Now().AddDate(0, 0, -1) + assert.True(t, tt.Sub(lt.Time()) < 10*time.Millisecond) +} + +func TestLooseFormatTime_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + t LooseFormatTime + args []byte + wantErr bool + }{ + { + name: "simple date", + args: []byte("\"2021-01-01\""), + t: LooseFormatTime(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)), + }, + { + name: "utc", + args: []byte("\"2021-01-01T12:10:10\""), + t: LooseFormatTime(time.Date(2021, 1, 1, 12, 10, 10, 0, time.UTC)), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var v LooseFormatTime + if err := v.UnmarshalJSON(tt.args); (err != nil) != tt.wantErr { + t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + } else { + assert.Equal(t, v.Time(), tt.t.Time()) + } + }) + } +} + +func TestMillisecondTimestamp_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + t MillisecondTimestamp + args []byte + wantErr bool + }{ + { + name: "millisecond in string", + args: []byte("\"1620289117764\""), + t: MillisecondTimestamp(time.Unix(0, 1620289117764*int64(time.Millisecond))), + }, + { + name: "millisecond in number", + args: []byte("1620289117764"), + t: MillisecondTimestamp(time.Unix(0, 1620289117764*int64(time.Millisecond))), + }, + { + name: "millisecond in decimal", + args: []byte("1620289117.764"), + t: MillisecondTimestamp(time.Unix(0, 1620289117764*int64(time.Millisecond))), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var v MillisecondTimestamp + if err := v.UnmarshalJSON(tt.args); (err != nil) != tt.wantErr { + t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + } else { + assert.Equal(t, tt.t.Time(), v.Time()) + } + }) + } +} diff --git a/pkg/types/trade.go b/pkg/types/trade.go new file mode 100644 index 0000000..5414fc0 --- /dev/null +++ b/pkg/types/trade.go @@ -0,0 +1,256 @@ +package types + +import ( + "database/sql" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "github.com/slack-go/slack" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/util/templateutil" +) + +func init() { + // make sure we can cast Trade to PlainText + _ = PlainText(Trade{}) + _ = PlainText(&Trade{}) +} + +type TradeSlice struct { + mu sync.Mutex + Trades []Trade +} + +func (s *TradeSlice) Copy() []Trade { + s.mu.Lock() + slice := make([]Trade, len(s.Trades)) + copy(slice, s.Trades) + s.mu.Unlock() + + return slice +} + +func (s *TradeSlice) Reverse() { + slice := s.Trades + for i, j := 0, len(slice)-1; i < j; i, j = i+1, j-1 { + slice[i], slice[j] = slice[j], slice[i] + } +} + +func (s *TradeSlice) Append(t Trade) { + s.mu.Lock() + s.Trades = append(s.Trades, t) + s.mu.Unlock() +} + +func (s *TradeSlice) Truncate(size int) { + s.mu.Lock() + + if len(s.Trades) > size { + s.Trades = s.Trades[len(s.Trades)-1-size:] + } + + s.mu.Unlock() +} + +type Trade struct { + // GID is the global ID + GID int64 `json:"gid" db:"gid"` + + // ID is the source trade ID + ID uint64 `json:"id" db:"id"` + OrderID uint64 `json:"orderID" db:"order_id"` + Exchange ExchangeName `json:"exchange" db:"exchange"` + Price fixedpoint.Value `json:"price" db:"price"` + Quantity fixedpoint.Value `json:"quantity" db:"quantity"` + QuoteQuantity fixedpoint.Value `json:"quoteQuantity" db:"quote_quantity"` + Symbol string `json:"symbol" db:"symbol"` + + Side SideType `json:"side" db:"side"` + IsBuyer bool `json:"isBuyer" db:"is_buyer"` + IsMaker bool `json:"isMaker" db:"is_maker"` + Time Time `json:"tradedAt" db:"traded_at"` + Fee fixedpoint.Value `json:"fee" db:"fee"` + FeeCurrency string `json:"feeCurrency" db:"fee_currency"` + FeeProcessing bool `json:"feeProcessing" db:"-"` + + // FeeDiscounted is an optional field which indicates whether the trade is using the platform fee token for discount. + // When FeeDiscounted = true, means the fee is deducted outside the trade + // By default, it's set to false. + // This is only used by the MAX exchange + FeeDiscounted bool `json:"feeDiscounted" db:"-"` + + IsMargin bool `json:"isMargin" db:"is_margin"` + IsFutures bool `json:"isFutures" db:"is_futures"` + IsIsolated bool `json:"isIsolated" db:"is_isolated"` + + // The following fields are null-able fields + + // StrategyID is the strategy that execute this trade + StrategyID sql.NullString `json:"strategyID" db:"strategy"` + + // PnL is the profit and loss value of the executed trade + PnL sql.NullFloat64 `json:"pnl" db:"pnl"` +} + +func (trade Trade) CsvHeader() []string { + return []string{"id", "order_id", "exchange", "symbol", "price", "quantity", "quote_quantity", "side", "is_buyer", "is_maker", "fee", "fee_currency", "time"} +} + +func (trade Trade) CsvRecords() [][]string { + return [][]string{ + { + strconv.FormatUint(trade.ID, 10), + strconv.FormatUint(trade.OrderID, 10), + trade.Exchange.String(), + trade.Symbol, + trade.Price.String(), + trade.Quantity.String(), + trade.QuoteQuantity.String(), + trade.Side.String(), + strconv.FormatBool(trade.IsBuyer), + strconv.FormatBool(trade.IsMaker), + trade.Fee.String(), + trade.FeeCurrency, + trade.Time.Time().Format(time.RFC1123), + }, + } +} + +// PositionChange returns the position delta of this trade +// BUY trade -> positive quantity +// SELL trade -> negative quantity +func (trade Trade) PositionChange() fixedpoint.Value { + q := trade.Quantity + switch trade.Side { + case SideTypeSell: + return q.Neg() + + case SideTypeBuy: + return q + + case SideTypeSelf: + return fixedpoint.Zero + } + return fixedpoint.Zero +} + +/*func trimTrailingZero(a string) string { + index := strings.Index(a, ".") + if index == -1 { + return a + } + + var c byte + var i int + for i = len(a) - 1; i >= 0; i-- { + c = a[i] + if c == '0' { + continue + } else if c == '.' { + return a[0:i] + } else { + return a[0 : i+1] + } + } + return a +} + +func trimTrailingZero(a float64) string { + return trimTrailingZero(fmt.Sprintf("%f", a)) +}*/ + +// String is for console output +func (trade Trade) String() string { + return fmt.Sprintf("TRADE %s %s %4s %-4s @ %-6s | AMOUNT %s | FEE %s %s | OrderID %d | TID %d | %s", + trade.Exchange.String(), + trade.Symbol, + trade.Side, + trade.Quantity.String(), + trade.Price.String(), + trade.QuoteQuantity.String(), + trade.Fee.String(), + trade.FeeCurrency, + trade.OrderID, + trade.ID, + trade.Time.Time().Format(time.StampMilli), + ) +} + +// PlainText is used for telegram-styled messages +func (trade Trade) PlainText() string { + return fmt.Sprintf("Trade %s %s %s %s @ %s, amount %s, fee %s %s", + trade.Exchange.String(), + trade.Symbol, + trade.Side, + trade.Quantity.String(), + trade.Price.String(), + trade.QuoteQuantity.String(), + trade.Fee.String(), + trade.FeeCurrency) +} + +var slackTradeTextTemplate = ":handshake: Trade {{ .Symbol }} {{ .Side }} {{ .Quantity }} @ {{ .Price }}" + +func (trade Trade) SlackAttachment() slack.Attachment { + var color = "#DC143C" + + if trade.IsBuyer { + color = "#228B22" + } + + liquidity := trade.Liquidity() + text := templateutil.Render(slackTradeTextTemplate, trade) + footerIcon := ExchangeFooterIcon(trade.Exchange) + + return slack.Attachment{ + Text: text, + // Title: ... + // Pretext: pretext, + Color: color, + Fields: []slack.AttachmentField{ + {Title: "Exchange", Value: trade.Exchange.String(), Short: true}, + {Title: "Price", Value: trade.Price.String(), Short: true}, + {Title: "Quantity", Value: trade.Quantity.String(), Short: true}, + {Title: "QuoteQuantity", Value: trade.QuoteQuantity.String(), Short: true}, + {Title: "Fee", Value: trade.Fee.String(), Short: true}, + {Title: "FeeCurrency", Value: trade.FeeCurrency, Short: true}, + {Title: "Liquidity", Value: liquidity, Short: true}, + {Title: "Order ID", Value: strconv.FormatUint(trade.OrderID, 10), Short: true}, + }, + FooterIcon: footerIcon, + Footer: strings.ToLower(trade.Exchange.String()) + templateutil.Render(" creation time {{ . }}", trade.Time.Time().Format(time.StampMilli)), + } +} + +func (trade Trade) Liquidity() (o string) { + if trade.IsMaker { + o = "MAKER" + } else { + o = "TAKER" + } + + return o +} + +func (trade Trade) Key() TradeKey { + return TradeKey{ + Exchange: trade.Exchange, + ID: trade.ID, + Side: trade.Side, + } +} + +type TradeKey struct { + Exchange ExchangeName + ID uint64 + Side SideType +} + +func (k TradeKey) String() string { + return k.Exchange.String() + strconv.FormatUint(k.ID, 10) + k.Side.String() +} diff --git a/pkg/types/trade_stats.go b/pkg/types/trade_stats.go new file mode 100644 index 0000000..a241c25 --- /dev/null +++ b/pkg/types/trade_stats.go @@ -0,0 +1,427 @@ +package types + +import ( + "encoding/json" + "math" + "sort" + "strconv" + "time" + + log "github.com/sirupsen/logrus" + + "gopkg.in/yaml.v3" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/datatype/floats" + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +type IntervalProfitCollector struct { + Interval Interval `json:"interval"` + Profits *floats.Slice `json:"profits"` + Timestamp *floats.Slice `json:"timestamp"` + tmpTime time.Time `json:"tmpTime"` +} + +func NewIntervalProfitCollector(i Interval, startTime time.Time) *IntervalProfitCollector { + return &IntervalProfitCollector{Interval: i, tmpTime: startTime, Profits: &floats.Slice{1.}, Timestamp: &floats.Slice{float64(startTime.Unix())}} +} + +// Update the collector by every traded profit +func (s *IntervalProfitCollector) Update(profit *Profit) { + if s.tmpTime.IsZero() { + panic("No valid start time. Did you create IntervalProfitCollector instance using NewIntervalProfitCollector?") + } else { + duration := s.Interval.Duration() + if profit.TradedAt.Before(s.tmpTime.Add(duration)) { + (*s.Profits)[len(*s.Profits)-1] *= 1. + profit.NetProfitMargin.Float64() + } else { + for { + s.Profits.Update(1.) + s.tmpTime = s.tmpTime.Add(duration) + s.Timestamp.Update(float64(s.tmpTime.Unix())) + if profit.TradedAt.Before(s.tmpTime.Add(duration)) { + (*s.Profits)[len(*s.Profits)-1] *= 1. + profit.NetProfitMargin.Float64() + break + } + } + } + } +} + +type ProfitReport struct { + StartTime time.Time `json:"startTime"` + Profit float64 `json:"profit"` + Interval Interval `json:"interval"` +} + +func (s ProfitReport) String() string { + b, err := json.MarshalIndent(s, "", "\t") + if err != nil { + log.Fatal(err) + } + return string(b) +} + +// Get all none-profitable intervals +func (s *IntervalProfitCollector) GetNonProfitableIntervals() (result []ProfitReport) { + if s.Profits == nil { + return result + } + l := s.Profits.Length() + for i := 0; i < l; i++ { + if s.Profits.Index(i) <= 1. { + result = append(result, ProfitReport{StartTime: time.Unix(int64(s.Timestamp.Index(i)), 0), Profit: s.Profits.Index(i), Interval: s.Interval}) + } + } + return result +} + +// Get all profitable intervals +func (s *IntervalProfitCollector) GetProfitableIntervals() (result []ProfitReport) { + if s.Profits == nil { + return result + } + l := s.Profits.Length() + for i := 0; i < l; i++ { + if s.Profits.Index(i) > 1. { + result = append(result, ProfitReport{StartTime: time.Unix(int64(s.Timestamp.Index(i)), 0), Profit: s.Profits.Index(i), Interval: s.Interval}) + } + } + return result +} + +// Get number of profitable traded intervals +func (s *IntervalProfitCollector) GetNumOfProfitableIntervals() (profit int) { + if s.Profits == nil { + panic("profits array empty. Did you create IntervalProfitCollector instance using NewIntervalProfitCollector?") + } + for _, v := range *s.Profits { + if v > 1. { + profit += 1 + } + } + return profit +} + +// Get number of non-profitable traded intervals +// (no trade within the interval or pnl = 0 will be also included here) +func (s *IntervalProfitCollector) GetNumOfNonProfitableIntervals() (nonprofit int) { + if s.Profits == nil { + panic("profits array empty. Did you create IntervalProfitCollector instance using NewIntervalProfitCollector?") + } + for _, v := range *s.Profits { + if v <= 1. { + nonprofit += 1 + } + } + return nonprofit +} + +// Get sharpe value with the interval of profit collected. +// no smart sharpe ON for the calculated result +func (s *IntervalProfitCollector) GetSharpe() float64 { + if s.tmpTime.IsZero() { + panic("No valid start time. Did you create IntervalProfitCollector instance using NewIntervalProfitCollector?") + } + if s.Profits == nil { + panic("profits array empty. Did you create IntervalProfitCollector instance using NewIntervalProfitCollector?") + } + return Sharpe(Sub(s.Profits, 1.), s.Profits.Length(), true, false) +} + +// Get sortino value with the interval of profit collected. +// No risk-free return rate and smart sortino OFF for the calculated result. +func (s *IntervalProfitCollector) GetSortino() float64 { + if s.tmpTime.IsZero() { + panic("No valid start time. Did you create IntervalProfitCollector instance using NewIntervalProfitCollector?") + } + if s.Profits == nil { + panic("profits array empty. Did you create IntervalProfitCollector instance using NewIntervalProfitCollector?") + } + return Sortino(Sub(s.Profits, 1.), 0., s.Profits.Length(), true, false) +} + +func (s *IntervalProfitCollector) GetOmega() float64 { + return Omega(Sub(s.Profits, 1.)) +} + +func (s IntervalProfitCollector) MarshalYAML() (interface{}, error) { + result := make(map[string]interface{}) + result["Sharpe Ratio"] = s.GetSharpe() + result["Sortino Ratio"] = s.GetSortino() + result["Omega Ratio"] = s.GetOmega() + result["Profitable Count"] = s.GetNumOfProfitableIntervals() + result["NonProfitable Count"] = s.GetNumOfNonProfitableIntervals() + return result, nil +} + +// 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,omitempty"` + + WinningRatio fixedpoint.Value `json:"winningRatio" yaml:"winningRatio"` + NumOfLossTrade int `json:"numOfLossTrade" yaml:"numOfLossTrade"` + NumOfProfitTrade int `json:"numOfProfitTrade" yaml:"numOfProfitTrade"` + + GrossProfit fixedpoint.Value `json:"grossProfit" yaml:"grossProfit"` + GrossLoss fixedpoint.Value `json:"grossLoss" yaml:"grossLoss"` + + Profits []fixedpoint.Value `json:"profits,omitempty" yaml:"profits,omitempty"` + Losses []fixedpoint.Value `json:"losses,omitempty" yaml:"losses,omitempty"` + + orderProfits map[uint64][]*Profit + + LargestProfitTrade fixedpoint.Value `json:"largestProfitTrade,omitempty" yaml:"largestProfitTrade"` + LargestLossTrade fixedpoint.Value `json:"largestLossTrade,omitempty" yaml:"largestLossTrade"` + AverageProfitTrade fixedpoint.Value `json:"averageProfitTrade" yaml:"averageProfitTrade"` + AverageLossTrade fixedpoint.Value `json:"averageLossTrade" yaml:"averageLossTrade"` + + ProfitFactor fixedpoint.Value `json:"profitFactor" yaml:"profitFactor"` + TotalNetProfit fixedpoint.Value `json:"totalNetProfit" yaml:"totalNetProfit"` + IntervalProfits map[Interval]*IntervalProfitCollector `json:"intervalProfits,omitempty" yaml:"intervalProfits,omitempty"` + + // MaximumConsecutiveWins - (counter) the longest series of winning trades + MaximumConsecutiveWins int `json:"maximumConsecutiveWins" yaml:"maximumConsecutiveWins"` + + // MaximumConsecutiveLosses - (counter) the longest series of losing trades + MaximumConsecutiveLosses int `json:"maximumConsecutiveLosses" yaml:"maximumConsecutiveLosses"` + + // MaximumConsecutiveProfit - ($) the longest series of winning trades and their total profit; + MaximumConsecutiveProfit fixedpoint.Value `json:"maximumConsecutiveProfit" yaml:"maximumConsecutiveProfit"` + + // MaximumConsecutiveLoss - ($) the longest series of losing trades and their total loss; + MaximumConsecutiveLoss fixedpoint.Value `json:"maximumConsecutiveLoss" yaml:"maximumConsecutiveLoss"` + + lastOrderID uint64 + consecutiveSide int + consecutiveCounter int + consecutiveAmount fixedpoint.Value +} + +func NewTradeStats(symbol string) *TradeStats { + return &TradeStats{Symbol: symbol, IntervalProfits: make(map[Interval]*IntervalProfitCollector)} +} + +// Set IntervalProfitCollector explicitly to enable the sharpe ratio calculation +func (s *TradeStats) SetIntervalProfitCollector(c *IntervalProfitCollector) { + s.IntervalProfits[c.Interval] = c +} + +func (s *TradeStats) CsvHeader() []string { + return []string{ + "winningRatio", + "numOfProfitTrade", + "numOfLossTrade", + "grossProfit", + "grossLoss", + "profitFactor", + "largestProfitTrade", + "largestLossTrade", + "maximumConsecutiveWins", + "maximumConsecutiveLosses", + } +} + +func (s *TradeStats) CsvRecords() [][]string { + return [][]string{ + { + s.WinningRatio.String(), + strconv.Itoa(s.NumOfProfitTrade), + strconv.Itoa(s.NumOfLossTrade), + s.GrossProfit.String(), + s.GrossLoss.String(), + s.ProfitFactor.String(), + s.LargestProfitTrade.String(), + s.LargestLossTrade.String(), + strconv.Itoa(s.MaximumConsecutiveWins), + strconv.Itoa(s.MaximumConsecutiveLosses), + }, + } +} + +func (s *TradeStats) Add(profit *Profit) { + if s.Symbol != "" && profit.Symbol != s.Symbol { + return + } + + if s.orderProfits == nil { + s.orderProfits = make(map[uint64][]*Profit) + } + + if profit.OrderID > 0 { + s.orderProfits[profit.OrderID] = append(s.orderProfits[profit.OrderID], profit) + } + + s.add(profit) + + for _, v := range s.IntervalProfits { + v.Update(profit) + } +} + +func grossLossReducer(prev, curr fixedpoint.Value) fixedpoint.Value { + if curr.Sign() < 0 { + return prev.Add(curr) + } + + return prev +} + +func grossProfitReducer(prev, curr fixedpoint.Value) fixedpoint.Value { + if curr.Sign() > 0 { + return prev.Add(curr) + } + + return prev +} + +// Recalculate the trade stats fields from the orderProfits +// this is for live-trading, one order may have many trades, and we need to merge them. +func (s *TradeStats) Recalculate() { + if len(s.orderProfits) == 0 { + return + } + + var profitsByOrder []fixedpoint.Value + var netProfitsByOrder []fixedpoint.Value + for _, profits := range s.orderProfits { + var sumProfit = fixedpoint.Zero + var sumNetProfit = fixedpoint.Zero + for _, p := range profits { + sumProfit = sumProfit.Add(p.Profit) + sumNetProfit = sumNetProfit.Add(p.NetProfit) + } + + profitsByOrder = append(profitsByOrder, sumProfit) + netProfitsByOrder = append(netProfitsByOrder, sumNetProfit) + } + + s.NumOfProfitTrade = fixedpoint.Count(profitsByOrder, fixedpoint.PositiveTester) + s.NumOfLossTrade = fixedpoint.Count(profitsByOrder, fixedpoint.NegativeTester) + s.TotalNetProfit = fixedpoint.Reduce(profitsByOrder, fixedpoint.SumReducer) + s.GrossProfit = fixedpoint.Reduce(profitsByOrder, grossProfitReducer) + s.GrossLoss = fixedpoint.Reduce(profitsByOrder, grossLossReducer) + + sort.Sort(fixedpoint.Descending(profitsByOrder)) + sort.Sort(fixedpoint.Descending(netProfitsByOrder)) + + s.Profits = fixedpoint.Filter(profitsByOrder, fixedpoint.PositiveTester) + s.Losses = fixedpoint.Filter(profitsByOrder, fixedpoint.NegativeTester) + s.LargestProfitTrade = profitsByOrder[0] + s.LargestLossTrade = profitsByOrder[len(profitsByOrder)-1] + if s.LargestLossTrade.Sign() > 0 { + s.LargestLossTrade = fixedpoint.Zero + } + + s.ProfitFactor = s.GrossProfit.Div(s.GrossLoss.Abs()) + if len(s.Profits) > 0 { + s.AverageProfitTrade = fixedpoint.Avg(s.Profits) + } + if len(s.Losses) > 0 { + s.AverageLossTrade = fixedpoint.Avg(s.Losses) + } + + s.updateWinningRatio() +} + +func (s *TradeStats) add(profit *Profit) { + pnl := profit.Profit + + // order id changed + if s.lastOrderID != profit.OrderID { + if pnl.Sign() > 0 { + s.NumOfProfitTrade++ + s.GrossProfit = s.GrossProfit.Add(pnl) + + if s.consecutiveSide == 0 { + s.consecutiveSide = 1 + s.consecutiveCounter = 1 + s.consecutiveAmount = pnl + } else if s.consecutiveSide == 1 { + s.consecutiveCounter++ + s.consecutiveAmount = s.consecutiveAmount.Add(pnl) + s.MaximumConsecutiveWins = int(math.Max(float64(s.MaximumConsecutiveWins), float64(s.consecutiveCounter))) + s.MaximumConsecutiveProfit = fixedpoint.Max(s.MaximumConsecutiveProfit, s.consecutiveAmount) + } else { + s.MaximumConsecutiveLosses = int(math.Max(float64(s.MaximumConsecutiveLosses), float64(s.consecutiveCounter))) + s.MaximumConsecutiveLoss = fixedpoint.Min(s.MaximumConsecutiveLoss, s.consecutiveAmount) + s.consecutiveSide = 1 + s.consecutiveCounter = 1 + s.consecutiveAmount = pnl + } + } else { + s.NumOfLossTrade++ + s.GrossLoss = s.GrossLoss.Add(pnl) + + if s.consecutiveSide == 0 { + s.consecutiveSide = -1 + s.consecutiveCounter = 1 + s.consecutiveAmount = pnl + } else if s.consecutiveSide == -1 { + s.consecutiveCounter++ + s.consecutiveAmount = s.consecutiveAmount.Add(pnl) + s.MaximumConsecutiveLosses = int(math.Max(float64(s.MaximumConsecutiveLosses), float64(s.consecutiveCounter))) + s.MaximumConsecutiveLoss = fixedpoint.Min(s.MaximumConsecutiveLoss, s.consecutiveAmount) + } else { // was profit, now loss, store the last win and profit + s.MaximumConsecutiveWins = int(math.Max(float64(s.MaximumConsecutiveWins), float64(s.consecutiveCounter))) + s.MaximumConsecutiveProfit = fixedpoint.Max(s.MaximumConsecutiveProfit, s.consecutiveAmount) + s.consecutiveSide = -1 + s.consecutiveCounter = 1 + s.consecutiveAmount = pnl + } + } + } else { + s.consecutiveAmount = s.consecutiveAmount.Add(pnl) + } + + s.lastOrderID = profit.OrderID + s.TotalNetProfit = s.TotalNetProfit.Add(pnl) + s.ProfitFactor = s.GrossProfit.Div(s.GrossLoss.Abs()) + + s.updateWinningRatio() +} + +func (s *TradeStats) updateWinningRatio() { + // The win/loss ratio is your wins divided by your losses. + // In the example, suppose for the sake of simplicity that 60 trades were winners, and 40 were losers. + // Your win/loss ratio would be 60/40 = 1.5. That would mean that you are winning 50% more often than you are losing. + if s.NumOfLossTrade == 0 && s.NumOfProfitTrade == 0 { + s.WinningRatio = fixedpoint.Zero + } else if s.NumOfLossTrade == 0 && s.NumOfProfitTrade > 0 { + s.WinningRatio = fixedpoint.One + } else { + s.WinningRatio = fixedpoint.NewFromFloat(float64(s.NumOfProfitTrade) / float64(s.NumOfLossTrade)) + } +} + +// Output TradeStats without Profits and Losses +func (s *TradeStats) BriefString() string { + s.Recalculate() + out, _ := yaml.Marshal(&TradeStats{ + Symbol: s.Symbol, + WinningRatio: s.WinningRatio, + NumOfLossTrade: s.NumOfLossTrade, + NumOfProfitTrade: s.NumOfProfitTrade, + GrossProfit: s.GrossProfit, + GrossLoss: s.GrossLoss, + LargestProfitTrade: s.LargestProfitTrade, + LargestLossTrade: s.LargestLossTrade, + AverageProfitTrade: s.AverageProfitTrade, + AverageLossTrade: s.AverageLossTrade, + ProfitFactor: s.ProfitFactor, + TotalNetProfit: s.TotalNetProfit, + IntervalProfits: s.IntervalProfits, + MaximumConsecutiveWins: s.MaximumConsecutiveWins, + MaximumConsecutiveLosses: s.MaximumConsecutiveLosses, + MaximumConsecutiveProfit: s.MaximumConsecutiveProfit, + MaximumConsecutiveLoss: s.MaximumConsecutiveLoss, + }) + return string(out) +} + +func (s *TradeStats) String() string { + s.Recalculate() + out, _ := yaml.Marshal(s) + return string(out) +} diff --git a/pkg/types/trade_stats_test.go b/pkg/types/trade_stats_test.go new file mode 100644 index 0000000..78ffd2e --- /dev/null +++ b/pkg/types/trade_stats_test.go @@ -0,0 +1,59 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +func number(v interface{}) fixedpoint.Value { + switch tv := v.(type) { + case float64: + return fixedpoint.NewFromFloat(tv) + + case int64: + return fixedpoint.NewFromInt(tv) + case int: + return fixedpoint.NewFromInt(int64(tv)) + + case string: + return fixedpoint.MustNewFromString(tv) + + default: + panic("invalid number input") + } +} + +func TestTradeStats_consecutiveCounterAndAmount(t *testing.T) { + stats := NewTradeStats("BTCUSDT") + stats.add(&Profit{OrderID: 1, Profit: number(20.0)}) + stats.add(&Profit{OrderID: 1, Profit: number(30.0)}) + + assert.Equal(t, 1, stats.consecutiveSide) + assert.Equal(t, 1, stats.consecutiveCounter) + assert.Equal(t, "50", stats.consecutiveAmount.String()) + + stats.add(&Profit{OrderID: 2, Profit: number(50.0)}) + stats.add(&Profit{OrderID: 2, Profit: number(50.0)}) + assert.Equal(t, 1, stats.consecutiveSide) + assert.Equal(t, 2, stats.consecutiveCounter) + assert.Equal(t, "150", stats.consecutiveAmount.String()) + assert.Equal(t, 2, stats.MaximumConsecutiveWins) + + stats.add(&Profit{OrderID: 3, Profit: number(-50.0)}) + stats.add(&Profit{OrderID: 3, Profit: number(-50.0)}) + assert.Equal(t, -1, stats.consecutiveSide) + assert.Equal(t, 1, stats.consecutiveCounter) + assert.Equal(t, "-100", stats.consecutiveAmount.String()) + + assert.Equal(t, "150", stats.MaximumConsecutiveProfit.String()) + assert.Equal(t, "0", stats.MaximumConsecutiveLoss.String()) + + stats.add(&Profit{OrderID: 4, Profit: number(-100.0)}) + assert.Equal(t, -1, stats.consecutiveSide) + assert.Equal(t, 2, stats.consecutiveCounter) + assert.Equal(t, "-200", stats.MaximumConsecutiveLoss.String()) + assert.Equal(t, 2, stats.MaximumConsecutiveLosses) +} diff --git a/pkg/types/trade_test.go b/pkg/types/trade_test.go new file mode 100644 index 0000000..ee8471a --- /dev/null +++ b/pkg/types/trade_test.go @@ -0,0 +1,51 @@ +package types + +import "testing" +import "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + +func Test_trimTrailingZero(t *testing.T) { + type args struct { + a string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "trailing floating zero", + args: args{ + a: "1.23400000", + }, + want: "1.234", + }, + { + name: "trailing zero of an integer", + args: args{ + a: "1.00000", + }, + want: "1", + }, + { + name: "non trailing zero", + args: args{ + a: "1.00012345", + }, + want: "1.00012345", + }, + { + name: "integer", + args: args{ + a: "1200000", + }, + want: "1200000", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := fixedpoint.MustNewFromString(tt.args.a).String(); got != tt.want { + t.Errorf("trimTrailingZero() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/types/transfer.go b/pkg/types/transfer.go new file mode 100644 index 0000000..640ef99 --- /dev/null +++ b/pkg/types/transfer.go @@ -0,0 +1,8 @@ +package types + +type TransferDirection int + +const ( + TransferIn TransferDirection = 1 + TransferOut TransferDirection = -1 +) diff --git a/pkg/types/value_map.go b/pkg/types/value_map.go new file mode 100644 index 0000000..4d2a4f3 --- /dev/null +++ b/pkg/types/value_map.go @@ -0,0 +1,157 @@ +package types + +import "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + +type ValueMap map[string]fixedpoint.Value + +func (m ValueMap) Eq(n ValueMap) bool { + if len(m) != len(n) { + return false + } + + for m_k, m_v := range m { + n_v, ok := n[m_k] + if !ok { + return false + } + + if !m_v.Eq(n_v) { + return false + } + } + + return true +} + +func (m ValueMap) Add(n ValueMap) ValueMap { + if len(m) != len(n) { + panic("unequal length") + } + + o := ValueMap{} + + for m_k, m_v := range m { + n_v, ok := n[m_k] + if !ok { + panic("key not found") + } + + o[m_k] = m_v.Add(n_v) + } + + return o +} + +func (m ValueMap) Sub(n ValueMap) ValueMap { + if len(m) != len(n) { + panic("unequal length") + } + + o := ValueMap{} + + for m_k, m_v := range m { + n_v, ok := n[m_k] + if !ok { + panic("key not found") + } + + o[m_k] = m_v.Sub(n_v) + } + + return o +} + +func (m ValueMap) Mul(n ValueMap) ValueMap { + if len(m) != len(n) { + panic("unequal length") + } + + o := ValueMap{} + + for m_k, m_v := range m { + n_v, ok := n[m_k] + if !ok { + panic("key not found") + } + + o[m_k] = m_v.Mul(n_v) + } + + return o +} + +func (m ValueMap) Div(n ValueMap) ValueMap { + if len(m) != len(n) { + panic("unequal length") + } + + o := ValueMap{} + + for m_k, m_v := range m { + n_v, ok := n[m_k] + if !ok { + panic("key not found") + } + + o[m_k] = m_v.Div(n_v) + } + + return o +} + +func (m ValueMap) AddScalar(x fixedpoint.Value) ValueMap { + o := ValueMap{} + + for k, v := range m { + o[k] = v.Add(x) + } + + return o +} + +func (m ValueMap) SubScalar(x fixedpoint.Value) ValueMap { + o := ValueMap{} + + for k, v := range m { + o[k] = v.Sub(x) + } + + return o +} + +func (m ValueMap) MulScalar(x fixedpoint.Value) ValueMap { + o := ValueMap{} + + for k, v := range m { + o[k] = v.Mul(x) + } + + return o +} + +func (m ValueMap) DivScalar(x fixedpoint.Value) ValueMap { + o := ValueMap{} + + for k, v := range m { + o[k] = v.Div(x) + } + + return o +} + +func (m ValueMap) Sum() fixedpoint.Value { + var sum fixedpoint.Value + for _, v := range m { + sum = sum.Add(v) + } + return sum +} + +func (m ValueMap) Normalize() ValueMap { + sum := m.Sum() + if sum.Eq(fixedpoint.Zero) { + panic("zero sum") + } + + return m.DivScalar(sum) +} diff --git a/pkg/types/value_map_test.go b/pkg/types/value_map_test.go new file mode 100644 index 0000000..24d4a72 --- /dev/null +++ b/pkg/types/value_map_test.go @@ -0,0 +1,125 @@ +package types + +import ( + "testing" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "github.com/stretchr/testify/assert" +) + +func Test_ValueMap_Eq(t *testing.T) { + m1 := ValueMap{ + "A": fixedpoint.NewFromFloat(3.0), + "B": fixedpoint.NewFromFloat(4.0), + } + + m2 := ValueMap{} + + m3 := ValueMap{"A": fixedpoint.NewFromFloat(5.0)} + + m4 := ValueMap{ + "A": fixedpoint.NewFromFloat(6.0), + "B": fixedpoint.NewFromFloat(7.0), + } + + m5 := ValueMap{ + "A": fixedpoint.NewFromFloat(3.0), + "B": fixedpoint.NewFromFloat(4.0), + } + + assert.True(t, m1.Eq(m1)) + assert.False(t, m1.Eq(m2)) + assert.False(t, m1.Eq(m3)) + assert.False(t, m1.Eq(m4)) + assert.True(t, m1.Eq(m5)) +} + +func Test_ValueMap_Add(t *testing.T) { + m1 := ValueMap{ + "A": fixedpoint.NewFromFloat(3.0), + "B": fixedpoint.NewFromFloat(4.0), + } + + m2 := ValueMap{ + "A": fixedpoint.NewFromFloat(5.0), + "B": fixedpoint.NewFromFloat(6.0), + } + + m3 := ValueMap{ + "A": fixedpoint.NewFromFloat(8.0), + "B": fixedpoint.NewFromFloat(10.0), + } + + m4 := ValueMap{"A": fixedpoint.NewFromFloat(8.0)} + + assert.Equal(t, m3, m1.Add(m2)) + assert.Panics(t, func() { m1.Add(m4) }) +} + +func Test_ValueMap_AddScalar(t *testing.T) { + x := fixedpoint.NewFromFloat(5.0) + + m1 := ValueMap{ + "A": fixedpoint.NewFromFloat(3.0), + "B": fixedpoint.NewFromFloat(4.0), + } + + m2 := ValueMap{ + "A": fixedpoint.NewFromFloat(3.0).Add(x), + "B": fixedpoint.NewFromFloat(4.0).Add(x), + } + + assert.Equal(t, m2, m1.AddScalar(x)) +} + +func Test_ValueMap_DivScalar(t *testing.T) { + x := fixedpoint.NewFromFloat(5.0) + + m1 := ValueMap{ + "A": fixedpoint.NewFromFloat(3.0), + "B": fixedpoint.NewFromFloat(4.0), + } + + m2 := ValueMap{ + "A": fixedpoint.NewFromFloat(3.0).Div(x), + "B": fixedpoint.NewFromFloat(4.0).Div(x), + } + + assert.Equal(t, m2, m1.DivScalar(x)) +} + +func Test_ValueMap_Sum(t *testing.T) { + m := ValueMap{ + "A": fixedpoint.NewFromFloat(3.0), + "B": fixedpoint.NewFromFloat(4.0), + } + + assert.Equal(t, fixedpoint.NewFromFloat(7.0), m.Sum()) +} + +func Test_ValueMap_Normalize(t *testing.T) { + a := fixedpoint.NewFromFloat(3.0) + b := fixedpoint.NewFromFloat(4.0) + c := a.Add(b) + + m := ValueMap{ + "A": a, + "B": b, + } + + n := ValueMap{ + "A": a.Div(c), + "B": b.Div(c), + } + + assert.True(t, m.Normalize().Eq(n)) +} + +func Test_ValueMap_Normalize_zero_sum(t *testing.T) { + m := ValueMap{ + "A": fixedpoint.Zero, + "B": fixedpoint.Zero, + } + + assert.Panics(t, func() { m.Normalize() }) +} diff --git a/pkg/types/withdraw.go b/pkg/types/withdraw.go new file mode 100644 index 0000000..5e901c4 --- /dev/null +++ b/pkg/types/withdraw.go @@ -0,0 +1,67 @@ +package types + +import ( + "fmt" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +type Withdraw struct { + GID int64 `json:"gid" db:"gid"` + Exchange ExchangeName `json:"exchange" db:"exchange"` + Asset string `json:"asset" db:"asset"` + Amount fixedpoint.Value `json:"amount" db:"amount"` + Address string `json:"address" db:"address"` + AddressTag string `json:"addressTag"` + Status string `json:"status"` + + TransactionID string `json:"transactionID" db:"txn_id"` + TransactionFee fixedpoint.Value `json:"transactionFee" db:"txn_fee"` + TransactionFeeCurrency string `json:"transactionFeeCurrency" db:"txn_fee_currency"` + WithdrawOrderID string `json:"withdrawOrderId"` + ApplyTime Time `json:"applyTime" db:"time"` + Network string `json:"network" db:"network"` +} + +func cutstr(s string, maxLen, head, tail int) string { + if len(s) > maxLen { + l := len(s) + return s[0:head] + "..." + s[l-tail:] + } + return s +} + +func (w Withdraw) String() (o string) { + o = fmt.Sprintf("%s WITHDRAW %8f %s -> ", w.Exchange, w.Amount.Float64(), w.Asset) + + if len(w.Network) > 0 && w.Network != w.Asset { + o += w.Network + ":" + } + + o += fmt.Sprintf("%s @ %s", w.Address, w.ApplyTime.Time()) + + if !w.TransactionFee.IsZero() { + feeCurrency := w.TransactionFeeCurrency + if feeCurrency == "" { + feeCurrency = w.Asset + } + + o += fmt.Sprintf(" FEE %4f %5s", w.TransactionFee.Float64(), feeCurrency) + } + + if len(w.TransactionID) > 0 { + o += fmt.Sprintf(" TxID: %s", cutstr(w.TransactionID, 12, 4, 4)) + } + + return o +} + +func (w Withdraw) EffectiveTime() time.Time { + return w.ApplyTime.Time() +} + +type WithdrawalOptions struct { + Network string + AddressTag string +} diff --git a/pkg/util/backoff/general.go b/pkg/util/backoff/general.go new file mode 100644 index 0000000..19ae49a --- /dev/null +++ b/pkg/util/backoff/general.go @@ -0,0 +1,23 @@ +package backoff + +import ( + "context" + "time" + + "github.com/cenkalti/backoff/v4" +) + +var MaxRetries uint64 = 101 + +// RetryGeneral retries operation with max retry times 101 and with the exponential backoff +func RetryGeneral(parent context.Context, op backoff.Operation) (err error) { + ctx, cancel := context.WithTimeout(parent, 15*time.Minute) + defer cancel() + + err = backoff.Retry(op, backoff.WithContext( + backoff.WithMaxRetries( + backoff.NewExponentialBackOff(), + MaxRetries), + ctx)) + return err +} diff --git a/pkg/util/dir.go b/pkg/util/dir.go new file mode 100644 index 0000000..5e1914c --- /dev/null +++ b/pkg/util/dir.go @@ -0,0 +1,23 @@ +package util + +import ( + "fmt" + "os" +) + +func SafeMkdirAll(p string) error { + st, err := os.Stat(p) + if err == nil { + if !st.IsDir() { + return fmt.Errorf("path %s is not a directory", p) + } + + return nil + } + + if os.IsNotExist(err) { + return os.MkdirAll(p, 0755) + } + + return nil +} diff --git a/pkg/util/envvars.go b/pkg/util/envvars.go new file mode 100644 index 0000000..9ad3a71 --- /dev/null +++ b/pkg/util/envvars.go @@ -0,0 +1,63 @@ +package util + +import ( + "os" + "strconv" + "time" + + "github.com/sirupsen/logrus" +) + +func GetEnvVarDuration(n string) (time.Duration, bool) { + str, ok := os.LookupEnv(n) + if !ok { + return 0, false + } + + du, err := time.ParseDuration(str) + if err != nil { + logrus.WithError(err).Errorf("can not parse env var %q as time.Duration, incorrect format", str) + return 0, false + } + + return du, true +} + +func GetEnvVarInt(n string) (int, bool) { + str, ok := os.LookupEnv(n) + if !ok { + return 0, false + } + + num, err := strconv.Atoi(str) + if err != nil { + logrus.WithError(err).Errorf("can not parse env var %q as int, incorrect format", str) + return 0, false + } + + return num, true +} + +func SetEnvVarBool(n string, v *bool) bool { + b, ok := GetEnvVarBool(n) + if ok { + *v = b + } + + return ok +} + +func GetEnvVarBool(n string) (bool, bool) { + str, ok := os.LookupEnv(n) + if !ok { + return false, false + } + + num, err := strconv.ParseBool(str) + if err != nil { + logrus.WithError(err).Errorf("can not parse env var %q as bool, incorrect format", str) + return false, false + } + + return num, true +} diff --git a/pkg/util/fnv.go b/pkg/util/fnv.go new file mode 100644 index 0000000..9c84294 --- /dev/null +++ b/pkg/util/fnv.go @@ -0,0 +1,9 @@ +package util + +import "hash/fnv" + +func FNV32(s string) uint32 { + h := fnv.New32a() + h.Write([]byte(s)) + return h.Sum32() +} diff --git a/pkg/util/http_response.go b/pkg/util/http_response.go new file mode 100644 index 0000000..392f14b --- /dev/null +++ b/pkg/util/http_response.go @@ -0,0 +1,58 @@ +package util + +import ( + "encoding/json" + "io/ioutil" + "net/http" +) + +// Response is wrapper for standard http.Response and provides +// more methods. +type Response struct { + *http.Response + + // Body overrides the composited Body field. + Body []byte +} + +// NewResponse is a wrapper of the http.Response instance, it reads the response body and close the file. +func NewResponse(r *http.Response) (response *Response, err error) { + body, err := ioutil.ReadAll(r.Body) + if err != nil { + return nil, err + } + + err = r.Body.Close() + response = &Response{Response: r, Body: body} + return response, err +} + +// String converts response body to string. +// An empty string will be returned if error. +func (r *Response) String() string { + return string(r.Body) +} + +func (r *Response) DecodeJSON(o interface{}) error { + return json.Unmarshal(r.Body, o) +} + +func (r *Response) IsError() bool { + return r.StatusCode >= 400 +} + +func (r *Response) IsJSON() bool { + switch r.Header.Get("content-type") { + case "text/json", "application/json", "application/json; charset=utf-8": + return true + } + return false +} + +func (r *Response) IsHTML() bool { + switch r.Header.Get("content-type") { + case "text/html": + return true + } + return false +} diff --git a/pkg/util/http_response_test.go b/pkg/util/http_response_test.go new file mode 100644 index 0000000..af864f8 --- /dev/null +++ b/pkg/util/http_response_test.go @@ -0,0 +1,74 @@ +package util + +import ( + "bytes" + "io/ioutil" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestResponse_DecodeJSON(t *testing.T) { + type temp struct { + Name string `json:"name"` + } + json := `{"name":"Test Name","a":"a"}` + reader := ioutil.NopCloser(bytes.NewReader([]byte(json))) + resp, err := NewResponse(&http.Response{ + StatusCode: 200, + Body: reader, + }) + assert.NoError(t, err) + assert.Equal(t, json, resp.String()) + + var result temp + assert.NoError(t, resp.DecodeJSON(&result)) + assert.Equal(t, "Test Name", result.Name) +} + +func TestResponse_IsError(t *testing.T) { + resp := &Response{Response: &http.Response{}} + cases := map[int]bool{ + 100: false, + 200: false, + 300: false, + 400: true, + 500: true, + } + + for code, isErr := range cases { + resp.StatusCode = code + assert.Equal(t, isErr, resp.IsError()) + } +} + +func TestResponse_IsJSON(t *testing.T) { + cases := map[string]bool{ + "text/json": true, + "application/json": true, + "application/json; charset=utf-8": true, + "text/html": false, + } + for k, v := range cases { + resp := &Response{Response: &http.Response{}} + resp.Header = http.Header{} + resp.Header.Set("content-type", k) + assert.Equal(t, v, resp.IsJSON()) + } +} + +func TestResponse_IsHTML(t *testing.T) { + cases := map[string]bool{ + "text/json": false, + "application/json": false, + "application/json; charset=utf-8": false, + "text/html": true, + } + for k, v := range cases { + resp := &Response{Response: &http.Response{}} + resp.Header = http.Header{} + resp.Header.Set("content-type", k) + assert.Equal(t, v, resp.IsHTML()) + } +} diff --git a/pkg/util/json.go b/pkg/util/json.go new file mode 100644 index 0000000..b11dd89 --- /dev/null +++ b/pkg/util/json.go @@ -0,0 +1,38 @@ +package util + +import ( + "encoding/json" + "io" + "io/ioutil" + "os" +) + +func WriteJsonFile(p string, obj interface{}) error { + out, err := json.Marshal(obj) + if err != nil { + return err + } + + return ioutil.WriteFile(p, out, 0644) +} + +func ReadJsonFile(file string, obj interface{}) error { + f, err := os.Open(file) + if err != nil { + return err + } + + defer f.Close() + + byteResult, err := io.ReadAll(f) + if err != nil { + return err + } + + err = json.Unmarshal([]byte(byteResult), obj) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/util/logerr.go b/pkg/util/logerr.go new file mode 100644 index 0000000..187416e --- /dev/null +++ b/pkg/util/logerr.go @@ -0,0 +1,23 @@ +package util + +import ( + log "github.com/sirupsen/logrus" +) + +func LogErr(err error, msgAndArgs ...interface{}) bool { + if err == nil { + return false + } + + if len(msgAndArgs) == 0 { + log.WithError(err).Error(err.Error()) + } else if len(msgAndArgs) == 1 { + msg := msgAndArgs[0].(string) + log.WithError(err).Error(msg) + } else if len(msgAndArgs) > 1 { + msg := msgAndArgs[0].(string) + log.WithError(err).Errorf(msg, msgAndArgs[1:]...) + } + + return true +} diff --git a/pkg/util/math_test.go b/pkg/util/math_test.go new file mode 100644 index 0000000..c7d8682 --- /dev/null +++ b/pkg/util/math_test.go @@ -0,0 +1 @@ +package util diff --git a/pkg/util/paper_trade.go b/pkg/util/paper_trade.go new file mode 100644 index 0000000..b3a09d6 --- /dev/null +++ b/pkg/util/paper_trade.go @@ -0,0 +1,6 @@ +package util + +func IsPaperTrade() bool { + v, ok := GetEnvVarBool("PAPER_TRADE") + return ok && v +} diff --git a/pkg/util/pointer.go b/pkg/util/pointer.go new file mode 100644 index 0000000..35d469c --- /dev/null +++ b/pkg/util/pointer.go @@ -0,0 +1,8 @@ +//go:build !go1.18 +// +build !go1.18 + +package util + +import "reflect" + +const Pointer = reflect.Ptr diff --git a/pkg/util/pointer_18.go b/pkg/util/pointer_18.go new file mode 100644 index 0000000..d2f1727 --- /dev/null +++ b/pkg/util/pointer_18.go @@ -0,0 +1,8 @@ +//go:build go1.18 +// +build go1.18 + +package util + +import "reflect" + +const Pointer = reflect.Pointer diff --git a/pkg/util/profile.go b/pkg/util/profile.go new file mode 100644 index 0000000..1d3753a --- /dev/null +++ b/pkg/util/profile.go @@ -0,0 +1,42 @@ +package util + +import ( + "time" +) + +type TimeProfile struct { + Name string + StartTime, EndTime time.Time + Duration time.Duration +} + +func StartTimeProfile(args ...string) TimeProfile { + name := "" + if len(args) > 0 { + name = args[0] + } + return TimeProfile{StartTime: time.Now(), Name: name} +} + +func (p *TimeProfile) TilNow() time.Duration { + return time.Since(p.StartTime) +} + +func (p *TimeProfile) Stop() time.Duration { + p.EndTime = time.Now() + p.Duration = p.EndTime.Sub(p.StartTime) + return p.Duration +} + +type logFunction func(format string, args ...interface{}) + +func (p *TimeProfile) StopAndLog(f logFunction) { + duration := p.Stop() + s := "[profile] " + if len(p.Name) > 0 { + s += p.Name + } + + s += " " + duration.String() + f(s) +} diff --git a/pkg/util/rate_limit.go b/pkg/util/rate_limit.go new file mode 100644 index 0000000..63c83bf --- /dev/null +++ b/pkg/util/rate_limit.go @@ -0,0 +1,19 @@ +package util + +import ( + "fmt" + "time" + + "golang.org/x/time/rate" +) + +func ShouldDelay(l *rate.Limiter, minInterval time.Duration) time.Duration { + return l.Reserve().Delay() +} + +func NewValidLimiter(r rate.Limit, b int) (*rate.Limiter, error) { + if b <= 0 || r <= 0 { + return nil, fmt.Errorf("Bad rate limit config. Insufficient tokens. (rate=%f, b=%d)", r, b) + } + return rate.NewLimiter(r, b), nil +} diff --git a/pkg/util/rate_limit_test.go b/pkg/util/rate_limit_test.go new file mode 100644 index 0000000..6efd54a --- /dev/null +++ b/pkg/util/rate_limit_test.go @@ -0,0 +1,43 @@ +package util + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "golang.org/x/time/rate" +) + +func TestNewValidRateLimiter(t *testing.T) { + cases := []struct { + name string + r rate.Limit + b int + hasError bool + }{ + {"valid limiter", 0.1, 1, false}, + {"zero rate", 0, 1, true}, + {"zero burst", 0.1, 0, true}, + {"both zero", 0, 0, true}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + limiter, err := NewValidLimiter(c.r, c.b) + assert.Equal(t, c.hasError, err != nil) + if !c.hasError { + assert.NotNil(t, limiter) + } + }) + } +} + +func TestShouldDelay(t *testing.T) { + minInterval := time.Second * 3 + maxRate := rate.Limit(1 / minInterval.Seconds()) + limiter := rate.NewLimiter(maxRate, 1) + assert.Equal(t, time.Duration(0), ShouldDelay(limiter, minInterval)) + for i := 0; i < 100; i++ { + assert.True(t, ShouldDelay(limiter, minInterval) > 0) + } +} diff --git a/pkg/util/reonce.go b/pkg/util/reonce.go new file mode 100644 index 0000000..f1fae5d --- /dev/null +++ b/pkg/util/reonce.go @@ -0,0 +1,33 @@ +package util + +import ( + "sync" + "sync/atomic" +) + +type Reonce struct { + done uint32 + m sync.Mutex +} + +func (o *Reonce) Reset() { + o.m.Lock() + atomic.StoreUint32(&o.done, 0) + o.m.Unlock() +} + +func (o *Reonce) Do(f func()) { + if atomic.LoadUint32(&o.done) == 0 { + // Outlined slow-path to allow inlining of the fast-path. + o.doSlow(f) + } +} + +func (o *Reonce) doSlow(f func()) { + o.m.Lock() + defer o.m.Unlock() + if o.done == 0 { + defer atomic.StoreUint32(&o.done, 1) + f() + } +} diff --git a/pkg/util/reonce_test.go b/pkg/util/reonce_test.go new file mode 100644 index 0000000..bd35284 --- /dev/null +++ b/pkg/util/reonce_test.go @@ -0,0 +1,39 @@ +package util + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestReonce_DoAndReset(t *testing.T) { + var cnt = 0 + var reonce Reonce + var wgAll, wg sync.WaitGroup + wg.Add(1) + wgAll.Add(2) + go reonce.Do(func() { + t.Log("once #1") + time.Sleep(10 * time.Millisecond) + cnt++ + wg.Done() + wgAll.Done() + }) + + // make sure it's locked + wg.Wait() + t.Logf("reset") + reonce.Reset() + + go reonce.Do(func() { + t.Log("once #2") + time.Sleep(10 * time.Millisecond) + cnt++ + wgAll.Done() + }) + + wgAll.Wait() + assert.Equal(t, 2, cnt) +} diff --git a/pkg/util/simple_args.go b/pkg/util/simple_args.go new file mode 100644 index 0000000..42a564c --- /dev/null +++ b/pkg/util/simple_args.go @@ -0,0 +1,31 @@ +package util + +import ( + "reflect" + "time" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" +) + +// FilterSimpleArgs filters out the simple type arguments +// int, string, bool, and []byte +func FilterSimpleArgs(args []interface{}) (simpleArgs []interface{}) { + for _, arg := range args { + switch arg.(type) { + case int, int64, int32, uint64, uint32, string, []string, []byte, float64, []float64, float32, fixedpoint.Value, time.Time: + simpleArgs = append(simpleArgs, arg) + default: + rt := reflect.TypeOf(arg) + if rt.Kind() == reflect.Ptr { + rt = rt.Elem() + } + + switch rt.Kind() { + case reflect.Float64, reflect.Float32, reflect.String, reflect.Int, reflect.Int32, reflect.Uint32, reflect.Int64, reflect.Uint64, reflect.Bool: + simpleArgs = append(simpleArgs, arg) + } + } + } + + return simpleArgs +} diff --git a/pkg/util/string.go b/pkg/util/string.go new file mode 100644 index 0000000..af3b25b --- /dev/null +++ b/pkg/util/string.go @@ -0,0 +1,46 @@ +package util + +import ( + "strings" + "unicode/utf8" +) + +func StringSliceContains(slice []string, needle string) bool { + for _, s := range slice { + if s == needle { + return true + } + } + + return false +} + +func MaskKey(key string) string { + if len(key) == 0 { + return "{empty}" + } + + h := len(key) / 3 + if h > 5 { + h = 5 + } + + maskKey := key[0:h] + maskKey += strings.Repeat("*", len(key)-h*2) + maskKey += key[len(key)-h:] + return maskKey +} + +func StringSplitByLength(s string, length int) (result []string) { + var left, right int + for left, right = 0, length; right < len(s); left, right = right, right+length { + for !utf8.RuneStart(s[right]) { + right-- + } + result = append(result, s[left:right]) + } + if len(s)-left > 0 { + result = append(result, s[left:]) + } + return result +} diff --git a/pkg/util/string_test.go b/pkg/util/string_test.go new file mode 100644 index 0000000..4f09a3a --- /dev/null +++ b/pkg/util/string_test.go @@ -0,0 +1,53 @@ +package util + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMaskKey(t *testing.T) { + type args struct { + key string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "key length more than 5", + args: args{key: "abcdefghijklmnopqr"}, + want: "abcde********nopqr", + }, + { + name: "key length less than 10", + args: args{key: "12345678"}, + want: "12****78", + }, + { + name: "even", + args: args{key: "1234567"}, + want: "12***67", + }, + { + name: "empty", + args: args{key: ""}, + want: "{empty}", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := MaskKey(tt.args.key); got != tt.want { + t.Errorf("MaskKey() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestStringSplitByLength(t *testing.T) { + result := StringSplitByLength("1234567890", 3) + assert.Equal(t, result, []string{"123", "456", "789", "0"}) + result = StringSplitByLength("123許456", 4) + assert.Equal(t, result, []string{"123", "許4", "56"}) +} diff --git a/pkg/util/templateutil/render.go b/pkg/util/templateutil/render.go new file mode 100644 index 0000000..8e19fc1 --- /dev/null +++ b/pkg/util/templateutil/render.go @@ -0,0 +1,25 @@ +package templateutil + +import ( + "bytes" + "text/template" + + "github.com/sirupsen/logrus" +) + +func Render(tpl string, args interface{}) string { + var buf = bytes.NewBuffer(nil) + tmpl, err := template.New("tmp").Parse(tpl) + if err != nil { + logrus.WithError(err).Error("template parse error") + return "" + } + + err = tmpl.Execute(buf, args) + if err != nil { + logrus.WithError(err).Error("template execute error") + return "" + } + + return buf.String() +} diff --git a/pkg/util/time.go b/pkg/util/time.go new file mode 100644 index 0000000..df1ac8d --- /dev/null +++ b/pkg/util/time.go @@ -0,0 +1,16 @@ +package util + +import ( + "math/rand" + "time" +) + +func MillisecondsJitter(d time.Duration, jitterInMilliseconds int) time.Duration { + n := rand.Intn(jitterInMilliseconds) + return d + time.Duration(n)*time.Millisecond +} + +func UnixMilli() int64 { + return time.Now().UnixNano() / int64(time.Millisecond) +} + diff --git a/pkg/util/tradingutil/cancel.go b/pkg/util/tradingutil/cancel.go new file mode 100644 index 0000000..1c1c9f4 --- /dev/null +++ b/pkg/util/tradingutil/cancel.go @@ -0,0 +1,119 @@ +package tradingutil + +import ( + "context" + "errors" + "fmt" + + log "github.com/sirupsen/logrus" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/retry" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +type CancelAllOrdersService interface { + CancelAllOrders(ctx context.Context) ([]types.Order, error) +} + +type CancelAllOrdersBySymbolService interface { + CancelOrdersBySymbol(ctx context.Context, symbol string) ([]types.Order, error) +} + +type CancelAllOrdersByGroupIDService interface { + CancelOrdersByGroupID(ctx context.Context, groupID uint32) ([]types.Order, error) +} + +// UniversalCancelAllOrders checks if the exchange instance supports the best order cancel strategy +// it tries the first interface CancelAllOrdersService that does not need any existing order information or symbol information. +// +// if CancelAllOrdersService is not supported, then it tries CancelAllOrdersBySymbolService which needs at least one symbol +// for the cancel api request. +func UniversalCancelAllOrders(ctx context.Context, exchange types.Exchange, openOrders []types.Order) error { + if service, ok := exchange.(CancelAllOrdersService); ok { + if _, err := service.CancelAllOrders(ctx); err == nil { + return nil + } else { + log.WithError(err).Errorf("unable to cancel all orders") + } + } + + if len(openOrders) == 0 { + return errors.New("to cancel all orders, openOrders can not be empty") + } + + var anyErr error + if service, ok := exchange.(CancelAllOrdersBySymbolService); ok { + var symbols = CollectOrderSymbols(openOrders) + for _, symbol := range symbols { + _, err := service.CancelOrdersBySymbol(ctx, symbol) + if err != nil { + anyErr = err + } + } + + if anyErr == nil { + return nil + } + } + + if service, ok := exchange.(CancelAllOrdersByGroupIDService); ok { + var groupIds = CollectOrderGroupIds(openOrders) + for _, groupId := range groupIds { + if _, err := service.CancelOrdersByGroupID(ctx, groupId); err != nil { + anyErr = err + } + } + + if anyErr == nil { + return nil + } + } + + if anyErr != nil { + return anyErr + } + + return fmt.Errorf("unable to cancel all orders, openOrders:%+v", openOrders) +} + +func CollectOrderGroupIds(orders []types.Order) (groupIds []uint32) { + groupIdMap := map[uint32]struct{}{} + for _, o := range orders { + if o.GroupID > 0 { + groupIdMap[o.GroupID] = struct{}{} + } + } + + for id := range groupIdMap { + groupIds = append(groupIds, id) + } + + return groupIds +} + +func CollectOrderSymbols(orders []types.Order) (symbols []string) { + symbolMap := map[string]struct{}{} + for _, o := range orders { + symbolMap[o.Symbol] = struct{}{} + } + + for s := range symbolMap { + symbols = append(symbols, s) + } + + return symbols +} + +func CollectOpenOrders(ctx context.Context, ex types.Exchange, symbols ...string) ([]types.Order, error) { + var collectedOrders []types.Order + for _, symbol := range symbols { + openOrders, err := retry.QueryOpenOrdersUntilSuccessful(ctx, ex, symbol) + if err != nil { + return nil, err + } + + collectedOrders = append(collectedOrders, openOrders...) + } + + return collectedOrders, nil +} diff --git a/pkg/util/tradingutil/trades.go b/pkg/util/tradingutil/trades.go new file mode 100644 index 0000000..e19fb44 --- /dev/null +++ b/pkg/util/tradingutil/trades.go @@ -0,0 +1,47 @@ +package tradingutil + +import ( + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +// CollectTradeFee collects the fee from the given trade slice +func CollectTradeFee(trades []types.Trade) map[string]fixedpoint.Value { + fees := make(map[string]fixedpoint.Value) + for _, t := range trades { + if t.FeeDiscounted { + continue + } + + if fee, ok := fees[t.FeeCurrency]; ok { + fees[t.FeeCurrency] = fee.Add(t.Fee) + } else { + fees[t.FeeCurrency] = t.Fee + } + } + return fees +} + +// AggregateTradesQuantity sums up the quantity from the given trades +// totalQuantity = SUM(trade1.Quantity, trade2.Quantity, ...) +func AggregateTradesQuantity(trades []types.Trade) fixedpoint.Value { + tq := fixedpoint.Zero + for _, t := range trades { + tq = tq.Add(t.Quantity) + } + return tq +} + +// AggregateTradesQuoteQuantity aggregates the quote quantity from the given trade slice +func AggregateTradesQuoteQuantity(trades []types.Trade) fixedpoint.Value { + quoteQuantity := fixedpoint.Zero + for _, t := range trades { + if t.QuoteQuantity.IsZero() { + quoteQuantity = quoteQuantity.Add(t.Price.Mul(t.Quantity)) + } else { + quoteQuantity = quoteQuantity.Add(t.QuoteQuantity) + } + } + + return quoteQuantity +} diff --git a/pkg/util/tradingutil/trades_test.go b/pkg/util/tradingutil/trades_test.go new file mode 100644 index 0000000..e15e35d --- /dev/null +++ b/pkg/util/tradingutil/trades_test.go @@ -0,0 +1,41 @@ +package tradingutil + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" + "git.qtrade.icu/lychiyu/qbtrade/pkg/types" +) + +var number = fixedpoint.MustNewFromString + +func Test_CollectTradeFee(t *testing.T) { + trades := []types.Trade{ + { + ID: 1, + Price: number("21000"), + Quantity: number("0.001"), + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + Fee: number("0.00001"), + FeeCurrency: "BTC", + FeeDiscounted: false, + }, + { + ID: 2, + Price: number("21200"), + Quantity: number("0.001"), + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + Fee: number("0.00002"), + FeeCurrency: "BTC", + FeeDiscounted: false, + }, + } + + fees := CollectTradeFee(trades) + assert.NotNil(t, fees) + assert.Equal(t, number("0.00003"), fees["BTC"]) +} diff --git a/pkg/util/trylock.go b/pkg/util/trylock.go new file mode 100644 index 0000000..5913b76 --- /dev/null +++ b/pkg/util/trylock.go @@ -0,0 +1,16 @@ +//go:build !go1.18 +// +build !go1.18 + +package util + +import "sync" + +func TryLock(lock *sync.RWMutex) bool { + lock.Lock() + return true +} + +func TryRLock(lock *sync.RWMutex) bool { + lock.RLock() + return true +} diff --git a/pkg/util/trylock_18.go b/pkg/util/trylock_18.go new file mode 100644 index 0000000..9e93237 --- /dev/null +++ b/pkg/util/trylock_18.go @@ -0,0 +1,14 @@ +//go:build go1.18 +// +build go1.18 + +package util + +import "sync" + +func TryLock(lock *sync.RWMutex) bool { + return lock.TryLock() +} + +func TryRLock(lock *sync.RWMutex) bool { + return lock.TryRLock() +} diff --git a/pkg/util/volatile_memory.go b/pkg/util/volatile_memory.go new file mode 100644 index 0000000..c500868 --- /dev/null +++ b/pkg/util/volatile_memory.go @@ -0,0 +1,43 @@ +package util + +import "time" + +type VolatileMemory struct { + objectTimes map[interface{}]time.Time + textTimes map[string]time.Time +} + +func NewDetectorCache() *VolatileMemory { + return &VolatileMemory{ + objectTimes: make(map[interface{}]time.Time), + textTimes: make(map[string]time.Time), + } +} + +func (i *VolatileMemory) IsObjectFresh(obj interface{}, ttl time.Duration) bool { + now := time.Now() + outdatedTime := now.Add(-ttl) + + if hitTime, ok := i.objectTimes[obj]; ok { + if hitTime.Before(outdatedTime) { + return true + } + } + + i.objectTimes[obj] = now + return false +} + +func (i *VolatileMemory) IsTextFresh(text string, ttl time.Duration) bool { + now := time.Now() + outdatedTime := now.Add(-ttl) + + if hitTime, ok := i.textTimes[text]; ok { + if hitTime.Before(outdatedTime) { + return true + } + } + + i.textTimes[text] = now + return false +} diff --git a/pkg/version/dev.go b/pkg/version/dev.go new file mode 100644 index 0000000..5567fdf --- /dev/null +++ b/pkg/version/dev.go @@ -0,0 +1,8 @@ +//go:build !release +// +build !release + +package version + +const Version = "v1.59.2-2e52d3175-dev" + +const VersionGitRef = "2e52d3175" diff --git a/pkg/version/version.go b/pkg/version/version.go new file mode 100644 index 0000000..7b2c6e9 --- /dev/null +++ b/pkg/version/version.go @@ -0,0 +1,8 @@ +//go:build release +// +build release + +package version + +const Version = "v1.59.2-2e52d3175" + +const VersionGitRef = "2e52d3175"