Add two risk controls for strategies: postion and circuit break.

This commit is contained in:
randy 2023-06-29 15:06:03 +08:00
parent 9254fb3b18
commit 9a98c4995e
6 changed files with 358 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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