first commit

This commit is contained in:
lychiyu 2024-06-27 22:42:38 +08:00
parent ba6efd2e4c
commit ee09262bf2
1376 changed files with 191849 additions and 1 deletions

7
cmd/qbtrade/main.go Normal file
View File

@ -0,0 +1,7 @@
package main
import "git.qtrade.icu/lychiyu/qbtrade/pkg/cmd"
func main() {
cmd.Execute()
}

40
config/atrpin.yaml Normal file
View File

@ -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

21
config/audacitymaker.yaml Normal file
View File

@ -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

38
config/autoborrow.yaml Normal file
View File

@ -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>'
- '<!subteam^TEAM_ID>'
marginLevelAlert:
interval: 5m
minMargin: 2.0
slackMentions:
- '<@USER_ID>'
- '<!subteam^TEAM_ID>'
assets:
- asset: ETH
low: 3.0
maxQuantityPerBorrow: 1.0
maxTotalBorrow: 10.0
- asset: USDT
low: 1000.0
maxQuantityPerBorrow: 100.0
maxTotalBorrow: 10.0

22
config/autobuy.yaml Normal file
View File

@ -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

23
config/backtest.yaml Normal file
View File

@ -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

View File

@ -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

60
config/bollgrid.yaml Normal file
View File

@ -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

232
config/bollmaker.yaml Normal file
View File

@ -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

View File

@ -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%

26
config/convert.yaml Normal file
View File

@ -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

23
config/dca.yaml Normal file
View File

@ -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

31
config/dca2.yaml Normal file
View File

@ -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

View File

@ -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

141
config/driftBTC.yaml Normal file
View File

@ -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

126
config/elliottwave.yaml Normal file
View File

@ -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

37
config/emacross.yaml Normal file
View File

@ -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

31
config/emastop.yaml Normal file
View File

@ -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

7
config/environment.yaml Normal file
View File

@ -0,0 +1,7 @@
environment:
disableDefaultKLineSubscription: true
disableHistoryKLinePreload: true
disableStartupBalanceQuery: true
disableSessionTradeBuffer: true
disableMarketDataStore: true
maxSessionTradeBufferSize: true

11
config/etf.yaml Normal file
View File

@ -0,0 +1,11 @@
exchangeStrategies:
- on: max
etf:
duration: 24h
totalAmount: 200.0
index:
BTCUSDT: 5%
LTCUSDT: 15%
ETHUSDT: 30%
LINKUSDT: 20%
DOTUSDT: 30%

65
config/ewo_dgtrd.yaml Normal file
View File

@ -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

44
config/factorzoo.yaml Normal file
View File

@ -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

34
config/fixedmaker.yaml Normal file
View File

@ -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

14
config/flashcrash.yaml Normal file
View File

@ -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

26
config/fmaker.yaml Normal file
View File

@ -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

53
config/grid-usdttwd.yaml Normal file
View File

@ -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

61
config/grid.yaml Normal file
View File

@ -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.

94
config/grid2-max.yaml Normal file
View File

@ -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

128
config/grid2.yaml Normal file
View File

@ -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

37
config/harmonic.yaml Normal file
View File

@ -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

40
config/irr.yaml Normal file
View File

@ -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

186
config/linregmaker.yaml Normal file
View File

@ -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

View File

@ -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

27
config/marketcap.yaml Normal file
View File

@ -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

35
config/max-margin.yaml Normal file
View File

@ -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

10
config/minimal.yaml Normal file
View File

@ -0,0 +1,10 @@
---
exchangeStrategies:
- on: max
xpuremaker:
symbol: MAXUSDT
numOrders: 2
side: both
behindVolume: 1000.0
priceTick: 0.01
baseQuantity: 100.0

50
config/multi-session.yaml Normal file
View File

@ -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

View File

@ -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'

26
config/optimizer.yaml Normal file
View File

@ -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%

View File

@ -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

153
config/pivotshort.yaml Normal file
View File

@ -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

View File

@ -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

12
config/pricealert-tg.yaml Normal file
View File

@ -0,0 +1,12 @@
---
sessions:
binance:
exchange: binance
envVarPrefix: binance
exchangeStrategies:
- on: binance
pricealert:
symbol: "BTCUSDT"
interval: "1m"
minChange: 300

22
config/pricealert.yaml Normal file
View File

@ -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

55
config/pricedrop.yaml Normal file
View File

@ -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

10
config/random.yaml Normal file
View File

@ -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

17
config/rebalance.yaml Normal file
View File

@ -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

54
config/rsicross.yaml Normal file
View File

@ -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

101
config/rsmaker.yaml Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

33
config/schedule.yaml Normal file
View File

@ -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

80
config/scmaker.yaml Normal file
View File

@ -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

24
config/skeleton.yaml Normal file
View File

@ -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

124
config/supertrend.yaml Normal file
View File

@ -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

View File

@ -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

56
config/support.yaml Normal file
View File

@ -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

43
config/swing.yaml Normal file
View File

@ -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

64
config/sync.yaml Normal file
View File

@ -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

50
config/trendtrader.yaml Normal file
View File

@ -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

35
config/tri.yaml Normal file
View File

@ -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 ]

46
config/wall.yaml Normal file
View File

@ -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 ]

49
config/xalign.yaml Normal file
View File

@ -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

39
config/xbalance.yaml Normal file
View File

@ -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

67
config/xdepthmaker.yaml Normal file
View File

@ -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

51
config/xfixedmaker.yaml Normal file
View File

@ -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

64
config/xfunding.yaml Normal file
View File

@ -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

41
config/xgap.yaml Normal file
View File

@ -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

View File

@ -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

View File

@ -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

64
config/xmaker.yaml Normal file
View File

@ -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

36
config/xnav.yaml Normal file
View File

@ -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

28
config/xpuremaker.yaml Normal file
View File

@ -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

2
go.mod
View File

@ -1,3 +1,3 @@
module git.qtrade.icu/lychiyu/qbtrade module git.qtrade.icu/lychiyu/qbtrade
go 1.22 go 1.22.0

View File

@ -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)
}

View File

@ -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)
})
}

View File

@ -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,
}
}

View File

@ -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: "",
}
}

File diff suppressed because one or more lines are too long

View File

@ -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 }

97
pkg/backtest/dumper.go Normal file
View File

@ -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
}

View File

@ -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)
}
}
}
}

451
pkg/backtest/exchange.go Normal file
View File

@ -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
}

View File

@ -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)
}

57
pkg/backtest/fee.go Normal file
View File

@ -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
}
}

124
pkg/backtest/fee_test.go Normal file
View File

@ -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)
})
}

View File

@ -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))
}
}

47
pkg/backtest/manifests.go Normal file
View File

@ -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, "", " ")
}

725
pkg/backtest/matching.go Normal file
View File

@ -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))
}

View File

@ -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")
}
}
}

View File

@ -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
}

156
pkg/backtest/recorder.go Normal file
View File

@ -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
}

View File

@ -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)
}

261
pkg/backtest/report.go Normal file
View File

@ -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
}

View File

@ -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)
}
}

57
pkg/backtest/utils.go Normal file
View File

@ -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
}

178
pkg/cache/cache.go vendored Normal file
View File

@ -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
}

103
pkg/cache/cache_test.go vendored Normal file
View File

@ -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
}

31
pkg/cache/home.go vendored Normal file
View File

@ -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)
}

Some files were not shown because too many files have changed in this diff Show More