Merge pull request #812 from c9s/refactor/backtest-report

refactor: improve backtest report and fix issues
This commit is contained in:
Yo-An Lin 2022-07-12 23:37:34 +08:00 committed by GitHub
commit 36857bd142
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 234 additions and 27 deletions

View File

@ -113,6 +113,19 @@ const fetchPositionHistory = (basePath: string, runID: string, filename: string)
});
};
const selectPositionHistory = (data: PositionHistoryEntry[], since: Date, until: Date): PositionHistoryEntry[] => {
const entries: PositionHistoryEntry[] = [];
for (let i = 0; i < data.length; i++) {
const d = data[i];
if (d.time < since || d.time > until) {
continue
}
entries.push(d)
}
return entries
}
const fetchOrders = (basePath: string, runID: string) => {
return fetch(
`${basePath}/${runID}/orders.tsv`,
@ -124,6 +137,19 @@ const fetchOrders = (basePath: string, runID: string) => {
});
}
const selectOrders = (data: Order[], since: Date, until: Date): Order[] => {
const entries: Order[] = [];
for (let i = 0; i < data.length; i++) {
const d = data[i];
if (d.time && (d.time < since || d.time > until)) {
continue
}
entries.push(d);
}
return entries
}
const parseInterval = (s: string) => {
switch (s) {
case "1m":
@ -390,7 +416,7 @@ const TradingViewChart = (props: TradingViewChartProps) => {
const resizeObserver = useRef<any>();
const intervals = props.reportSummary.intervals || [];
intervals.sort((a,b) => {
intervals.sort((a, b) => {
const as = parseInterval(a)
const bs = parseInterval(b)
if (as < bs) {
@ -403,7 +429,6 @@ const TradingViewChart = (props: TradingViewChartProps) => {
const [currentInterval, setCurrentInterval] = useState(intervals.length > 0 ? intervals[intervals.length - 1] : '1m');
const [showPositionBase, setShowPositionBase] = useState(false);
const [showCanceledOrders, setShowCanceledOrders] = useState(false);
const [showPositionAverageCost, setShowPositionAverageCost] = useState(false);
const [orders, setOrders] = useState<Order[]>([]);
@ -412,7 +437,6 @@ const TradingViewChart = (props: TradingViewChartProps) => {
new Date(props.reportSummary.endTime),
]
const [selectedTimeRange, setSelectedTimeRange] = useState(reportTimeRange)
const [timeRange, setTimeRange] = useState(reportTimeRange);
useEffect(() => {
if (!chartContainerRef.current || chartContainerRef.current.children.length > 0) {
@ -423,7 +447,7 @@ const TradingViewChart = (props: TradingViewChartProps) => {
const fetchers = [];
const ordersFetcher = fetchOrders(props.basePath, props.runID).then((orders: Order[] | void) => {
if (orders) {
const markers = ordersToMarkers(currentInterval, orders);
const markers = ordersToMarkers(currentInterval, selectOrders(orders, selectedTimeRange[0], selectedTimeRange[1]));
chartData.orders = orders;
chartData.markers = markers;
setOrders(orders);
@ -436,7 +460,8 @@ const TradingViewChart = (props: TradingViewChartProps) => {
const manifest = props.reportSummary?.manifests[0];
if (manifest && manifest.type === "strategyProperty" && manifest.strategyProperty === "position") {
const positionHistoryFetcher = fetchPositionHistory(props.basePath, props.runID, manifest.filename).then((data) => {
chartData.positionHistory = data;
chartData.positionHistory = selectPositionHistory(data as PositionHistoryEntry[], selectedTimeRange[0], selectedTimeRange[1]);
// chartData.positionHistory = data;
});
fetchers.push(positionHistoryFetcher);
}
@ -594,7 +619,7 @@ const TradingViewChart = (props: TradingViewChartProps) => {
<TimeRangeSlider
selectedInterval={selectedTimeRange}
timelineInterval={timeRange}
timelineInterval={reportTimeRange}
formatTick={(ms: Date) => format(new Date(ms), 'M d HH')}
step={1000 * parseInterval(currentInterval)}
onChange={(tr: any) => {
@ -661,12 +686,12 @@ const createLegendUpdater = (legend: HTMLDivElement, prefix: string) => {
}
}
const formatDate = (d : Date) : string => {
const formatDate = (d: Date): string => {
return moment(d).format("MMM Do YY hh:mm:ss A Z");
}
const createOHLCLegendUpdater = (legend: HTMLDivElement, prefix: string) => {
return (param: any, time : any) => {
return (param: any, time: any) => {
if (param) {
const change = Math.round((param.close - param.open) * 100.0) / 100.0
const changePercentage = Math.round((param.close - param.open) / param.close * 10000.0) / 100.0;

View File

@ -19,6 +19,8 @@ func (c *AverageCostCalculator) Calculate(symbol string, trades []types.Trade, c
var bidVolume = fixedpoint.Zero
var askVolume = fixedpoint.Zero
var feeUSD = fixedpoint.Zero
var grossProfit = fixedpoint.Zero
var grossLoss = fixedpoint.Zero
if len(trades) == 0 {
return &AverageCostPnlReport{
@ -64,6 +66,12 @@ func (c *AverageCostCalculator) Calculate(symbol string, trades []types.Trade, c
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 {
@ -96,8 +104,12 @@ func (c *AverageCostCalculator) Calculate(symbol string, trades []types.Trade, c
Profit: totalProfit,
NetProfit: totalNetProfit,
UnrealizedProfit: unrealizedProfit,
AverageCost: position.AverageCost,
FeeInUSD: totalProfit.Sub(totalNetProfit),
CurrencyFees: currencyFees,
GrossProfit: grossProfit,
GrossLoss: grossLoss,
AverageCost: position.AverageCost,
FeeInUSD: totalProfit.Sub(totalNetProfit),
CurrencyFees: currencyFees,
}
}

View File

@ -20,10 +20,14 @@ type AverageCostPnlReport struct {
Symbol string `json:"symbol"`
Market types.Market `json:"market"`
NumTrades int `json:"numTrades"`
Profit fixedpoint.Value `json:"profit"`
NetProfit fixedpoint.Value `json:"netProfit"`
UnrealizedProfit fixedpoint.Value `json:"unrealizedProfit"`
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"`
AverageCost fixedpoint.Value `json:"averageCost"`
BuyVolume fixedpoint.Value `json:"buyVolume,omitempty"`
SellVolume fixedpoint.Value `json:"sellVolume,omitempty"`

View File

@ -27,6 +27,7 @@ type StateRecorder struct {
outputDirectory string
strategies []Instance
writers map[types.CsvFormatter]*tsv.Writer
lastLines map[types.CsvFormatter][]string
manifests Manifests
}
@ -34,6 +35,7 @@ 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),
}
}
@ -42,11 +44,18 @@ 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()
@ -129,3 +138,19 @@ func (r *StateRecorder) Close() error {
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

@ -39,10 +39,16 @@ type SummaryReport struct {
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"`
@ -75,13 +81,21 @@ type SessionSymbolReport struct {
Manifests Manifests `json:"manifests,omitempty"`
}
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.StartPrice)
}
func (r *SessionSymbolReport) Print(wantBaseAssetBaseline bool) {
color.Green("%s %s PROFIT AND LOSS REPORT", r.Exchange, r.Symbol)
color.Green("===============================================")
r.PnL.Print()
initQuoteAsset := inQuoteAsset(r.InitialBalances, r.Market, r.StartPrice)
finalQuoteAsset := inQuoteAsset(r.FinalBalances, r.Market, r.LastPrice)
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)
@ -186,8 +200,8 @@ func AddReportIndexRun(outputDirectory string, run Run) error {
return WriteReportIndex(outputDirectory, reportIndex)
}
// inQuoteAsset converts all balances in quote asset
func inQuoteAsset(balances types.BalanceMap, market types.Market, price fixedpoint.Value) fixedpoint.Value {
// 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())

View File

@ -340,9 +340,6 @@ var BacktestCmd = &cobra.Command{
})
dumper := backtest.NewKLineDumper(kLineDataDir)
defer func() {
_ = dumper.Close()
}()
defer func() {
if err := dumper.Close(); err != nil {
log.WithError(err).Errorf("kline dumper can not close files")
@ -496,7 +493,6 @@ var BacktestCmd = &cobra.Command{
}
for _, session := range environ.Sessions() {
for symbol, trades := range session.Trades {
symbolReport, err := createSymbolReport(userConfig, session, symbol, trades.Trades)
if err != nil {
@ -507,6 +503,10 @@ var BacktestCmd = &cobra.Command{
summaryReport.SymbolReports = append(summaryReport.SymbolReports, *symbolReport)
summaryReport.TotalProfit = symbolReport.PnL.Profit
summaryReport.TotalUnrealizedProfit = symbolReport.PnL.UnrealizedProfit
summaryReport.InitialEquityValue = summaryReport.InitialEquityValue.Add(symbolReport.InitialEquityValue())
summaryReport.FinalEquityValue = summaryReport.FinalEquityValue.Add(symbolReport.FinalEquityValue())
summaryReport.TotalGrossProfit.Add(symbolReport.PnL.GrossProfit)
summaryReport.TotalGrossLoss.Add(symbolReport.PnL.GrossLoss)
// write report to a file
if generatingReport {

View File

@ -4,12 +4,12 @@ package indicator
import ()
func (A *ATR) OnUpdate(cb func(value float64)) {
A.UpdateCallbacks = append(A.UpdateCallbacks, cb)
func (inc *ATR) OnUpdate(cb func(value float64)) {
inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb)
}
func (A *ATR) EmitUpdate(value float64) {
for _, cb := range A.UpdateCallbacks {
func (inc *ATR) EmitUpdate(value float64) {
for _, cb := range inc.UpdateCallbacks {
cb(value)
}
}

112
pkg/indicator/atrp.go Normal file
View File

@ -0,0 +1,112 @@
package indicator
import (
"math"
"time"
"github.com/c9s/bbgo/pkg/types"
)
// ATRP is the average true range percentage
// See also https://www.fidelity.com/learning-center/trading-investing/technical-analysis/technical-indicator-guide/atrp
//
// Calculation:
//
// ATRP = (Average True Range / Close) * 100
//
//go:generate callbackgen -type ATRP
type ATRP struct {
types.SeriesBase
types.IntervalWindow
PercentageVolatility types.Float64Slice
PreviousClose float64
RMA *RMA
EndTime time.Time
UpdateCallbacks []func(value float64)
}
func (inc *ATRP) Update(high, low, cloze float64) {
if inc.Window <= 0 {
panic("window must be greater than 0")
}
if inc.RMA == nil {
inc.SeriesBase.Series = inc
inc.RMA = &RMA{
IntervalWindow: types.IntervalWindow{Window: inc.Window},
Adjust: true,
}
inc.PreviousClose = cloze
return
}
// calculate true range
trueRange := high - low
hc := math.Abs(high - inc.PreviousClose)
lc := math.Abs(low - inc.PreviousClose)
if trueRange < hc {
trueRange = hc
}
if trueRange < lc {
trueRange = lc
}
// Note: this is the difference from ATR
trueRange = trueRange / inc.PreviousClose * 100.0
inc.PreviousClose = cloze
// apply rolling moving average
inc.RMA.Update(trueRange)
atr := inc.RMA.Last()
inc.PercentageVolatility.Push(atr / cloze)
}
func (inc *ATRP) Last() float64 {
if inc.RMA == nil {
return 0
}
return inc.RMA.Last()
}
func (inc *ATRP) Index(i int) float64 {
if inc.RMA == nil {
return 0
}
return inc.RMA.Index(i)
}
func (inc *ATRP) Length() int {
if inc.RMA == nil {
return 0
}
return inc.RMA.Length()
}
var _ types.SeriesExtend = &ATRP{}
func (inc *ATRP) CalculateAndUpdate(kLines []types.KLine) {
for _, k := range kLines {
if inc.EndTime != zeroTime && !k.EndTime.After(inc.EndTime) {
continue
}
inc.Update(k.High.Float64(), k.Low.Float64(), k.Close.Float64())
}
inc.EmitUpdate(inc.Last())
inc.EndTime = kLines[len(kLines)-1].EndTime.Time()
}
func (inc *ATRP) handleKLineWindowUpdate(interval types.Interval, window types.KLineWindow) {
if inc.Interval != interval {
return
}
inc.CalculateAndUpdate(window)
}
func (inc *ATRP) Bind(updater KLineWindowUpdater) {
updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate)
}

View File

@ -0,0 +1,15 @@
// Code generated by "callbackgen -type ATRP"; DO NOT EDIT.
package indicator
import ()
func (inc *ATRP) OnUpdate(cb func(value float64)) {
inc.UpdateCallbacks = append(inc.UpdateCallbacks, cb)
}
func (inc *ATRP) EmitUpdate(value float64) {
for _, cb := range inc.UpdateCallbacks {
cb(value)
}
}