From 9a98c4995e7a3539f95281084f569324ce2e949e Mon Sep 17 00:00:00 2001 From: randy Date: Thu, 29 Jun 2023 15:06:03 +0800 Subject: [PATCH] Add two risk controls for strategies: postion and circuit break. --- doc/topics/riskcontrols.md | 87 ++++++++++++++++++ pkg/risk/riskcontrol/circuit_break.go | 40 ++++++++ pkg/risk/riskcontrol/circuit_break_test.go | 79 ++++++++++++++++ pkg/risk/riskcontrol/position.go | 42 +++++++++ pkg/risk/riskcontrol/position_test.go | 92 +++++++++++++++++++ .../positionriskcontrol_callbacks.go | 18 ++++ 6 files changed, 358 insertions(+) create mode 100644 doc/topics/riskcontrols.md create mode 100644 pkg/risk/riskcontrol/circuit_break.go create mode 100644 pkg/risk/riskcontrol/circuit_break_test.go create mode 100644 pkg/risk/riskcontrol/position.go create mode 100644 pkg/risk/riskcontrol/position_test.go create mode 100644 pkg/risk/riskcontrol/positionriskcontrol_callbacks.go diff --git a/doc/topics/riskcontrols.md b/doc/topics/riskcontrols.md new file mode 100644 index 000000000..8f548cc1a --- /dev/null +++ b/doc/topics/riskcontrols.md @@ -0,0 +1,87 @@ +# Risk Control +------------ + +### 1. Introduction + +Two types of risk controls for strategies is created: +- Position-limit Risk Control (pkg/risk/riskcontrol/position.go) +- Circuit-break Risk Control (pkg/risk/riskcontrol/circuit_break.go) + +### 2. Position-Limit Risk Control + +Initialization: +``` + s.positionRiskControl = riskcontrol.NewPositionRiskControl(s.HardLimit, s.Quantity, s.orderExecutor.TradeCollector()) + s.positionRiskControl.OnReleasePosition(func(quantity fixedpoint.Value, side types.SideType) { + createdOrders, err := s.orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ + Symbol: s.Symbol, + Market: s.Market, + Side: side, + Type: types.OrderTypeMarket, + Quantity: quantity, + }) + if err != nil { + log.WithError(err).Errorf("failed to submit orders") + return + } + log.Infof("created orders: %+v", createdOrders) + }) +``` + +Strategy should provide OnReleasePosition callback, which will be called when position (positive or negative) is over hard limit. + +Modify quantity before submitting orders: + +``` + buyQuantity, sellQuantity := s.positionRiskControl.ModifiedQuantity(s.Position.Base) +``` + +It calculates buy and sell quantity shrinking by hard limit and position. + +### 3. Circuit-Break Risk Control + +Initialization +``` + s.circuitBreakRiskControl = riskcontrol.NewCircuitBreakRiskControl( + s.Position, + session.StandardIndicatorSet(s.Symbol).EWMA( + types.IntervalWindow{ + Window: EWMAWindow, + Interval: types.Interval1m, + }), + s.CircuitBreakCondition, + s.ProfitStats) +``` +Should pass in position and profit states. Also need an price EWMA to calculate unrealized profit. + + +Validate parameters: +``` + if s.CircuitBreakCondition.Float64() > 0 { + return fmt.Errorf("circuitBreakCondition should be non-positive") + } + return nil +``` +Circuit break condition should be non-greater than zero. + +Check for circuit break before submitting orders: +``` + // Circuit break when accumulated losses are over break condition + if s.circuitBreakRiskControl.IsHalted() { + return + } + + submitOrders, err := s.generateSubmitOrders(ctx) + if err != nil { + log.WithError(err).Error("failed to generate submit orders") + return + } + log.Infof("submit orders: %+v", submitOrders) + + if s.DryRun { + log.Infof("dry run, not submitting orders") + return + } +``` + +Notice that if there are multiple place to submit orders, it is recommended to check in one place in Strategy.Run() and re-use that flag before submitting orders. That can avoid duplicated logs generated from IsHalted(). diff --git a/pkg/risk/riskcontrol/circuit_break.go b/pkg/risk/riskcontrol/circuit_break.go new file mode 100644 index 000000000..db528b3a8 --- /dev/null +++ b/pkg/risk/riskcontrol/circuit_break.go @@ -0,0 +1,40 @@ +package riskcontrol + +import ( + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/indicator" + "github.com/c9s/bbgo/pkg/types" + log "github.com/sirupsen/logrus" +) + +type CircuitBreakRiskControl struct { + // Since price could be fluctuated large, + // use an EWMA to smooth it in running time + price *indicator.EWMA + position *types.Position + profitStats *types.ProfitStats + breakCondition fixedpoint.Value +} + +func NewCircuitBreakRiskControl( + position *types.Position, + price *indicator.EWMA, + breakCondition fixedpoint.Value, + profitStats *types.ProfitStats) *CircuitBreakRiskControl { + + return &CircuitBreakRiskControl{ + price: price, + position: position, + profitStats: profitStats, + breakCondition: breakCondition, + } +} + +// IsHalted returns whether we reached the circuit break condition set for this day? +func (c *CircuitBreakRiskControl) IsHalted() bool { + var unrealized = c.position.UnrealizedProfit(fixedpoint.NewFromFloat(c.price.Last(0))) + log.Infof("[CircuitBreakRiskControl] Realized P&L = %v, Unrealized P&L = %v\n", + c.profitStats.TodayPnL, + unrealized) + return unrealized.Add(c.profitStats.TodayPnL).Compare(c.breakCondition) <= 0 +} diff --git a/pkg/risk/riskcontrol/circuit_break_test.go b/pkg/risk/riskcontrol/circuit_break_test.go new file mode 100644 index 000000000..8d2252634 --- /dev/null +++ b/pkg/risk/riskcontrol/circuit_break_test.go @@ -0,0 +1,79 @@ +package riskcontrol + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/indicator" + "github.com/c9s/bbgo/pkg/types" +) + +func Test_IsHalted(t *testing.T) { + + var ( + price = 30000.00 + realizedPnL = fixedpoint.NewFromFloat(-100.0) + breakCondition = fixedpoint.NewFromFloat(-500.00) + ) + + window := types.IntervalWindow{Window: 30, Interval: types.Interval1m} + priceEWMA := &indicator.EWMA{IntervalWindow: window} + priceEWMA.Update(price) + + cases := []struct { + name string + position fixedpoint.Value + averageCost fixedpoint.Value + isHalted bool + }{ + { + name: "PositivePositionReachBreakCondition", + position: fixedpoint.NewFromFloat(10.0), + averageCost: fixedpoint.NewFromFloat(30040.0), + isHalted: true, + }, { + name: "PositivePositionOverBreakCondition", + position: fixedpoint.NewFromFloat(10.0), + averageCost: fixedpoint.NewFromFloat(30050.0), + isHalted: true, + }, { + name: "PositivePositionUnderBreakCondition", + position: fixedpoint.NewFromFloat(10.0), + averageCost: fixedpoint.NewFromFloat(30030.0), + isHalted: false, + }, { + name: "NegativePositionReachBreakCondition", + position: fixedpoint.NewFromFloat(-10.0), + averageCost: fixedpoint.NewFromFloat(29960.0), + isHalted: true, + }, { + name: "NegativePositionOverBreakCondition", + position: fixedpoint.NewFromFloat(-10.0), + averageCost: fixedpoint.NewFromFloat(29950.0), + isHalted: true, + }, { + name: "NegativePositionUnderBreakCondition", + position: fixedpoint.NewFromFloat(-10.0), + averageCost: fixedpoint.NewFromFloat(29970.0), + isHalted: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var riskControl = NewCircuitBreakRiskControl( + &types.Position{ + Base: tc.position, + AverageCost: tc.averageCost, + }, + priceEWMA, + breakCondition, + &types.ProfitStats{ + TodayPnL: realizedPnL, + }, + ) + assert.Equal(t, tc.isHalted, riskControl.IsHalted()) + }) + } +} diff --git a/pkg/risk/riskcontrol/position.go b/pkg/risk/riskcontrol/position.go new file mode 100644 index 000000000..44022c55d --- /dev/null +++ b/pkg/risk/riskcontrol/position.go @@ -0,0 +1,42 @@ +package riskcontrol + +import ( + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" + log "github.com/sirupsen/logrus" +) + +//go:generate callbackgen -type PositionRiskControl +type PositionRiskControl struct { + hardLimit fixedpoint.Value + quantity fixedpoint.Value + + releasePositionCallbacks []func(quantity fixedpoint.Value, side types.SideType) +} + +func NewPositionRiskControl(hardLimit, quantity fixedpoint.Value, tradeCollector *bbgo.TradeCollector) *PositionRiskControl { + p := &PositionRiskControl{ + hardLimit: hardLimit, + quantity: quantity, + } + // register position update handler: check if position is over hardlimit + tradeCollector.OnPositionUpdate(func(position *types.Position) { + if fixedpoint.Compare(position.Base, hardLimit) > 0 { + log.Infof("Position %v is over hardlimit %v, releasing:\n", position.Base, hardLimit) + p.EmitReleasePosition(position.Base.Sub(hardLimit), types.SideTypeSell) + } else if fixedpoint.Compare(position.Base, hardLimit.Neg()) < 0 { + log.Infof("Position %v is over hardlimit %v, releasing:\n", position.Base, hardLimit) + p.EmitReleasePosition(position.Base.Neg().Sub(hardLimit), types.SideTypeBuy) + } + }) + return p +} + +// ModifiedQuantity returns quantity controlled by position risks +// For buy orders, mod quantity = min(hardlimit - position, quanity), limiting by positive position +// For sell orders, mod quantity = min(hardlimit - (-position), quanity), limiting by negative position +func (p *PositionRiskControl) ModifiedQuantity(position fixedpoint.Value) (buyQuanity, sellQuantity fixedpoint.Value) { + return fixedpoint.Min(p.hardLimit.Sub(position), p.quantity), + fixedpoint.Min(p.hardLimit.Add(position), p.quantity) +} diff --git a/pkg/risk/riskcontrol/position_test.go b/pkg/risk/riskcontrol/position_test.go new file mode 100644 index 000000000..fe0aa7f14 --- /dev/null +++ b/pkg/risk/riskcontrol/position_test.go @@ -0,0 +1,92 @@ +package riskcontrol + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" +) + +func Test_ModifiedQuantity(t *testing.T) { + + riskControl := NewPositionRiskControl(fixedpoint.NewFromInt(10), fixedpoint.NewFromInt(2), &bbgo.TradeCollector{}) + + cases := []struct { + name string + position fixedpoint.Value + buyQuantity fixedpoint.Value + sellQuantity fixedpoint.Value + }{ + { + name: "BuyOverHardLimit", + position: fixedpoint.NewFromInt(9), + buyQuantity: fixedpoint.NewFromInt(1), + sellQuantity: fixedpoint.NewFromInt(2), + }, + { + name: "SellOverHardLimit", + position: fixedpoint.NewFromInt(-9), + buyQuantity: fixedpoint.NewFromInt(2), + sellQuantity: fixedpoint.NewFromInt(1), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + buyQuantity, sellQuantity := riskControl.ModifiedQuantity(tc.position) + assert.Equal(t, tc.buyQuantity, buyQuantity) + assert.Equal(t, tc.sellQuantity, sellQuantity) + }) + } +} + +func TestReleasePositionCallbacks(t *testing.T) { + + var position fixedpoint.Value + + tradeCollector := &bbgo.TradeCollector{} + riskControl := NewPositionRiskControl(fixedpoint.NewFromInt(10), fixedpoint.NewFromInt(2), tradeCollector) + riskControl.OnReleasePosition(func(quantity fixedpoint.Value, side types.SideType) { + if side == types.SideTypeBuy { + position = position.Add(quantity) + } else { + position = position.Sub(quantity) + } + }) + + cases := []struct { + name string + position fixedpoint.Value + resultPosition fixedpoint.Value + }{ + { + name: "PostivePositionWithinLimit", + position: fixedpoint.NewFromInt(8), + resultPosition: fixedpoint.NewFromInt(8), + }, + { + name: "NegativePositionWithinLimit", + position: fixedpoint.NewFromInt(-8), + resultPosition: fixedpoint.NewFromInt(-8), + }, + { + name: "PostivePositionOverLimit", + position: fixedpoint.NewFromInt(11), + resultPosition: fixedpoint.NewFromInt(10), + }, + { + name: "NegativePositionOverLimit", + position: fixedpoint.NewFromInt(-11), + resultPosition: fixedpoint.NewFromInt(-10), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + position = tc.position + tradeCollector.EmitPositionUpdate(&types.Position{Base: tc.position}) + assert.Equal(t, tc.resultPosition, position) + }) + } +} diff --git a/pkg/risk/riskcontrol/positionriskcontrol_callbacks.go b/pkg/risk/riskcontrol/positionriskcontrol_callbacks.go new file mode 100644 index 000000000..63f12385e --- /dev/null +++ b/pkg/risk/riskcontrol/positionriskcontrol_callbacks.go @@ -0,0 +1,18 @@ +// Code generated by "callbackgen -type PositionRiskControl"; DO NOT EDIT. + +package riskcontrol + +import ( + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" +) + +func (p *PositionRiskControl) OnReleasePosition(cb func(quantity fixedpoint.Value, side types.SideType)) { + p.releasePositionCallbacks = append(p.releasePositionCallbacks, cb) +} + +func (p *PositionRiskControl) EmitReleasePosition(quantity fixedpoint.Value, side types.SideType) { + for _, cb := range p.releasePositionCallbacks { + cb(quantity, side) + } +}