This commit is contained in:
@@ -71,12 +71,102 @@ func ComposeDaily(input DailyInput) string {
|
||||
averageSpread = averageContextDecimal(input.Signals, "spread_bps")
|
||||
}
|
||||
fmt.Fprintf(&b, "Средний spread: %s bps\n", averageSpread.StringFixed(2))
|
||||
fmt.Fprintf(&b, "Среднее проскальзывание: %s bps\n", input.AverageSlipBps.StringFixed(2))
|
||||
averageSlip := input.AverageSlipBps
|
||||
if averageSlip.IsZero() {
|
||||
averageSlip = AverageAdverseSlippageBps(input.Orders, 0)
|
||||
}
|
||||
fmt.Fprintf(&b, "Среднее проскальзывание: %s bps\n", averageSlip.StringFixed(2))
|
||||
writeExecutionErrors(&b, input.Orders)
|
||||
fmt.Fprintf(&b, "Risk: %s", input.RiskStatus)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func AverageAdverseSlippageBps(orders []domain.Order, limit int) decimal.Decimal {
|
||||
if len(orders) == 0 {
|
||||
return decimal.Zero
|
||||
}
|
||||
sorted := append([]domain.Order(nil), orders...)
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].UpdatedAt.After(sorted[j].UpdatedAt)
|
||||
})
|
||||
sum := decimal.Zero
|
||||
weight := decimal.Zero
|
||||
count := 0
|
||||
for _, order := range sorted {
|
||||
slippage, ok := orderAdverseSlippageBps(order)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
lots := decimal.NewFromInt(order.FilledLots)
|
||||
sum = sum.Add(slippage.Mul(lots))
|
||||
weight = weight.Add(lots)
|
||||
count++
|
||||
if limit > 0 && count == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
if weight.IsZero() {
|
||||
return decimal.Zero
|
||||
}
|
||||
return sum.Div(weight)
|
||||
}
|
||||
|
||||
func orderAdverseSlippageBps(order domain.Order) (decimal.Decimal, bool) {
|
||||
if order.FilledLots <= 0 || !order.AvgFillPrice.IsPositive() {
|
||||
return decimal.Zero, false
|
||||
}
|
||||
reference := orderReferencePrice(order)
|
||||
if !reference.IsPositive() {
|
||||
return decimal.Zero, false
|
||||
}
|
||||
var adverse decimal.Decimal
|
||||
switch order.Side {
|
||||
case domain.SideBuy:
|
||||
adverse = order.AvgFillPrice.Sub(reference)
|
||||
case domain.SideSell:
|
||||
adverse = reference.Sub(order.AvgFillPrice)
|
||||
default:
|
||||
return decimal.Zero, false
|
||||
}
|
||||
if adverse.IsNegative() {
|
||||
adverse = decimal.Zero
|
||||
}
|
||||
return adverse.Div(reference).Mul(decimal.NewFromInt(10_000)), true
|
||||
}
|
||||
|
||||
func orderReferencePrice(order domain.Order) decimal.Decimal {
|
||||
if mid := rawMidPrice(order.RawStateJSON); mid.IsPositive() {
|
||||
return mid
|
||||
}
|
||||
return order.LimitPrice
|
||||
}
|
||||
|
||||
func rawMidPrice(raw string) decimal.Decimal {
|
||||
var root map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &root); err != nil {
|
||||
return decimal.Zero
|
||||
}
|
||||
if mid := midFromContainer(root); mid.IsPositive() {
|
||||
return mid
|
||||
}
|
||||
if local, ok := root["local"].(map[string]any); ok {
|
||||
return midFromContainer(local)
|
||||
}
|
||||
return decimal.Zero
|
||||
}
|
||||
|
||||
func midFromContainer(container map[string]any) decimal.Decimal {
|
||||
quote, ok := container["local_quote"].(map[string]any)
|
||||
if !ok {
|
||||
return decimal.Zero
|
||||
}
|
||||
mid, ok := decimalFromAny(quote["mid"])
|
||||
if !ok {
|
||||
return decimal.Zero
|
||||
}
|
||||
return mid
|
||||
}
|
||||
|
||||
func groupedReasons(signals []domain.Signal) map[string]int {
|
||||
out := make(map[string]int)
|
||||
for _, sig := range signals {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
|
||||
"overnight-trading-bot/internal/domain"
|
||||
)
|
||||
|
||||
func TestAverageAdverseSlippageBpsUsesLocalQuoteMid(t *testing.T) {
|
||||
orders := []domain.Order{{
|
||||
InstrumentUID: "uid",
|
||||
Side: domain.SideBuy,
|
||||
LimitPrice: decimal.NewFromInt(100),
|
||||
FilledLots: 2,
|
||||
AvgFillPrice: decimal.NewFromFloat(100.5),
|
||||
RawStateJSON: `{"local":{"local_quote":{"mid":"100"}}}`,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}}
|
||||
got := AverageAdverseSlippageBps(orders, 0)
|
||||
if !got.Equal(decimal.NewFromInt(50)) {
|
||||
t.Fatalf("slippage=%s, want 50", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAverageAdverseSlippageBpsFallsBackToLimit(t *testing.T) {
|
||||
orders := []domain.Order{{
|
||||
InstrumentUID: "uid",
|
||||
Side: domain.SideSell,
|
||||
LimitPrice: decimal.NewFromInt(100),
|
||||
FilledLots: 1,
|
||||
AvgFillPrice: decimal.NewFromFloat(99.5),
|
||||
RawStateJSON: `{}`,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}}
|
||||
got := AverageAdverseSlippageBps(orders, 0)
|
||||
if !got.Equal(decimal.NewFromInt(50)) {
|
||||
t.Fatalf("slippage=%s, want 50", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDailyComputesSlippageWhenInputIsZero(t *testing.T) {
|
||||
msg := ComposeDaily(DailyInput{
|
||||
Date: time.Date(2026, 6, 8, 0, 0, 0, 0, time.UTC),
|
||||
Mode: domain.ModePaper,
|
||||
Orders: []domain.Order{{
|
||||
Side: domain.SideBuy,
|
||||
LimitPrice: decimal.NewFromInt(100),
|
||||
FilledLots: 1,
|
||||
AvgFillPrice: decimal.NewFromFloat(100.5),
|
||||
RawStateJSON: `{"local":{"local_quote":{"mid":"100"}}}`,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}},
|
||||
RiskStatus: "ok",
|
||||
})
|
||||
if !strings.Contains(msg, "Среднее проскальзывание: 50.00 bps") {
|
||||
t.Fatalf("report did not include computed slippage:\n%s", msg)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user