This commit is contained in:
@@ -2,6 +2,7 @@ package features
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
@@ -94,6 +95,9 @@ func Compute(instrument domain.Instrument, candles []domain.Candle, tradeDate ti
|
||||
var lastROn decimal.Decimal
|
||||
var lastRDay decimal.Decimal
|
||||
for i := 1; i < len(candles); i++ {
|
||||
if !consecutiveDailyCandles(candles[i-1].TradeDate, candles[i].TradeDate) {
|
||||
continue
|
||||
}
|
||||
rOn, err := OvernightReturn(candles[i].Open, candles[i-1].Close)
|
||||
if err != nil {
|
||||
return domain.FeatureSet{}, err
|
||||
@@ -107,6 +111,9 @@ func Compute(instrument domain.Instrument, candles []domain.Candle, tradeDate ti
|
||||
lastROn = rOn
|
||||
lastRDay = rDay
|
||||
}
|
||||
if len(overnight) == 0 {
|
||||
return domain.FeatureSet{}, fmt.Errorf("need at least 1 consecutive daily candle pair")
|
||||
}
|
||||
short := Rolling(overnight, cfg.RollingShort, cfg.EWMALambda)
|
||||
long := Rolling(overnight, cfg.RollingLong, cfg.EWMALambda)
|
||||
q05Abs := rollingQ05Abs(overnight, cfg.RollingShort)
|
||||
@@ -118,6 +125,7 @@ func Compute(instrument domain.Instrument, candles []domain.Candle, tradeDate ti
|
||||
Add(cfg.ExitSlippageBps).
|
||||
Add(commission).
|
||||
Add(cfg.RiskBufferBps)
|
||||
costBreakdownJSON := expectedCostBreakdownJSON(spread, cfg, commission, expectedCost)
|
||||
return domain.FeatureSet{
|
||||
InstrumentUID: instrument.InstrumentUID,
|
||||
TradeDate: tradeDate,
|
||||
@@ -135,6 +143,7 @@ func Compute(instrument domain.Instrument, candles []domain.Candle, tradeDate ti
|
||||
TickBps: spread.TickBps,
|
||||
ADV20: adv,
|
||||
ExpectedCostBps: expectedCost,
|
||||
CostBreakdownJSON: costBreakdownJSON,
|
||||
NetEdgeBps: rawEdgeBps.Sub(expectedCost),
|
||||
EntryIntervalVolume: entryVolume,
|
||||
ExitIntervalVolume: exitVolume,
|
||||
@@ -142,6 +151,28 @@ func Compute(instrument domain.Instrument, candles []domain.Candle, tradeDate ti
|
||||
}, nil
|
||||
}
|
||||
|
||||
func expectedCostBreakdownJSON(spread SpreadResult, cfg PipelineConfig, commission, expectedCost decimal.Decimal) string {
|
||||
spreadEntry := spread.HalfSpreadBps
|
||||
if spreadEntry.IsZero() && spread.SpreadBps.IsPositive() {
|
||||
spreadEntry = spread.SpreadBps.Div(decimal.NewFromInt(2))
|
||||
}
|
||||
spreadExit := spread.SpreadBps.Sub(spreadEntry)
|
||||
payload := map[string]string{
|
||||
"expected_spread_entry_bps": spreadEntry.String(),
|
||||
"expected_spread_exit_bps": spreadExit.String(),
|
||||
"expected_slippage_entry_bps": cfg.EntrySlippageBps.String(),
|
||||
"expected_slippage_exit_bps": cfg.ExitSlippageBps.String(),
|
||||
"commission_roundtrip_bps": commission.String(),
|
||||
"risk_buffer_bps": cfg.RiskBufferBps.String(),
|
||||
"expected_cost_bps": expectedCost.String(),
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func rollingQ05Abs(values []float64, window int) decimal.Decimal {
|
||||
if window <= 0 || len(values) < window {
|
||||
return decimal.Zero
|
||||
@@ -170,9 +201,27 @@ func historicalDailyCandles(candles []domain.Candle, tradeDate time.Time) []doma
|
||||
out = append(out, candle)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i].TradeDate.Before(out[j].TradeDate)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func consecutiveDailyCandles(previous, current time.Time) bool {
|
||||
prevDay := dateOnly(previous)
|
||||
currentDay := dateOnly(current)
|
||||
if !currentDay.After(prevDay) {
|
||||
return false
|
||||
}
|
||||
weekdays := 0
|
||||
for day := prevDay.AddDate(0, 0, 1); !day.After(currentDay); day = day.AddDate(0, 0, 1) {
|
||||
if day.Weekday() != time.Saturday && day.Weekday() != time.Sunday {
|
||||
weekdays++
|
||||
}
|
||||
}
|
||||
return weekdays == 1
|
||||
}
|
||||
|
||||
func dateOnly(ts time.Time) time.Time {
|
||||
year, month, day := ts.UTC().Date()
|
||||
return time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
@@ -2,6 +2,7 @@ package features
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -44,6 +45,24 @@ func TestComputeExpectedCostIncludesCommissionAndSlippage(t *testing.T) {
|
||||
if !got.ExpectedCostBps.Equal(decimal.NewFromInt(22)) {
|
||||
t.Fatalf("expected cost=%s, want 22", got.ExpectedCostBps)
|
||||
}
|
||||
var breakdown map[string]string
|
||||
if err := json.Unmarshal([]byte(got.CostBreakdownJSON), &breakdown); err != nil {
|
||||
t.Fatalf("cost breakdown is not valid JSON: %v", err)
|
||||
}
|
||||
wantBreakdown := map[string]string{
|
||||
"expected_spread_entry_bps": "5",
|
||||
"expected_spread_exit_bps": "5",
|
||||
"expected_slippage_entry_bps": "2",
|
||||
"expected_slippage_exit_bps": "3",
|
||||
"commission_roundtrip_bps": "2",
|
||||
"risk_buffer_bps": "5",
|
||||
"expected_cost_bps": "22",
|
||||
}
|
||||
for key, want := range wantBreakdown {
|
||||
if breakdown[key] != want {
|
||||
t.Fatalf("breakdown[%s]=%q, want %q in %s", key, breakdown[key], want, got.CostBreakdownJSON)
|
||||
}
|
||||
}
|
||||
if !got.EntryIntervalVolume.Equal(decimal.NewFromInt(10000)) || !got.ExitIntervalVolume.Equal(decimal.NewFromInt(9000)) {
|
||||
t.Fatalf("interval volumes were not preserved: %+v", got)
|
||||
}
|
||||
@@ -72,7 +91,7 @@ func TestComputeExpectedCostFallsBackToConfigCommission(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestComputeStoresHistoricalQ05Abs(t *testing.T) {
|
||||
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
start := time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC)
|
||||
returns := []string{"-0.10", "0.01", "0.02", "0.03", "0.04"}
|
||||
candles := []domain.Candle{{
|
||||
InstrumentUID: "uid",
|
||||
@@ -89,13 +108,13 @@ func TestComputeStoresHistoricalQ05Abs(t *testing.T) {
|
||||
open := decimal.NewFromInt(100).Mul(decimal.NewFromInt(1).Add(r))
|
||||
candles = append(candles, domain.Candle{
|
||||
InstrumentUID: "uid",
|
||||
TradeDate: start.AddDate(0, 0, i+1),
|
||||
TradeDate: addBusinessDays(start, i+1),
|
||||
Open: open,
|
||||
Close: decimal.NewFromInt(100),
|
||||
VolumeLots: decimal.NewFromInt(1),
|
||||
})
|
||||
}
|
||||
got, err := Compute(domain.Instrument{InstrumentUID: "uid", Lot: 1}, candles, start.AddDate(0, 0, 6), SpreadResult{}, PipelineConfig{
|
||||
got, err := Compute(domain.Instrument{InstrumentUID: "uid", Lot: 1}, candles, addBusinessDays(start, 6), SpreadResult{}, PipelineConfig{
|
||||
RollingShort: 5,
|
||||
RollingLong: 5,
|
||||
EWMALambda: 0.08,
|
||||
@@ -113,6 +132,48 @@ func TestComputeStoresHistoricalQ05Abs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeSkipsOvernightReturnAcrossMissingWeekday(t *testing.T) {
|
||||
start := time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC) // Monday.
|
||||
candles := []domain.Candle{
|
||||
{InstrumentUID: "uid", TradeDate: start, Open: decimal.NewFromInt(100), Close: decimal.NewFromInt(100), VolumeLots: decimal.NewFromInt(1)},
|
||||
{InstrumentUID: "uid", TradeDate: start.AddDate(0, 0, 1), Open: decimal.NewFromInt(101), Close: decimal.NewFromInt(100), VolumeLots: decimal.NewFromInt(1)},
|
||||
{InstrumentUID: "uid", TradeDate: start.AddDate(0, 0, 3), Open: decimal.NewFromInt(50), Close: decimal.NewFromInt(100), VolumeLots: decimal.NewFromInt(1)},
|
||||
}
|
||||
got, err := Compute(domain.Instrument{InstrumentUID: "uid", Lot: 1}, candles, start.AddDate(0, 0, 4), SpreadResult{}, PipelineConfig{
|
||||
RollingShort: 1,
|
||||
RollingLong: 1,
|
||||
EWMALambda: 0.08,
|
||||
}, decimal.Zero, decimal.Zero)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := decimal.RequireFromString("0.01")
|
||||
if !got.ROn.Equal(want) {
|
||||
t.Fatalf("ROn=%s, want %s from last consecutive pair", got.ROn, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeAllowsWeekendGap(t *testing.T) {
|
||||
friday := time.Date(2026, 1, 9, 0, 0, 0, 0, time.UTC)
|
||||
monday := friday.AddDate(0, 0, 3)
|
||||
candles := []domain.Candle{
|
||||
{InstrumentUID: "uid", TradeDate: friday, Open: decimal.NewFromInt(100), Close: decimal.NewFromInt(100), VolumeLots: decimal.NewFromInt(1)},
|
||||
{InstrumentUID: "uid", TradeDate: monday, Open: decimal.NewFromInt(101), Close: decimal.NewFromInt(100), VolumeLots: decimal.NewFromInt(1)},
|
||||
}
|
||||
got, err := Compute(domain.Instrument{InstrumentUID: "uid", Lot: 1}, candles, monday.AddDate(0, 0, 1), SpreadResult{}, PipelineConfig{
|
||||
RollingShort: 1,
|
||||
RollingLong: 1,
|
||||
EWMALambda: 0.08,
|
||||
}, decimal.Zero, decimal.Zero)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := decimal.RequireFromString("0.01")
|
||||
if !got.ROn.Equal(want) {
|
||||
t.Fatalf("ROn=%s, want %s across weekend", got.ROn, want)
|
||||
}
|
||||
}
|
||||
|
||||
func flatCandles(start time.Time, count int) []domain.Candle {
|
||||
candles := make([]domain.Candle, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
@@ -128,6 +189,18 @@ func flatCandles(start time.Time, count int) []domain.Candle {
|
||||
return candles
|
||||
}
|
||||
|
||||
func addBusinessDays(start time.Time, days int) time.Time {
|
||||
out := start
|
||||
for added := 0; added < days; {
|
||||
out = out.AddDate(0, 0, 1)
|
||||
if out.Weekday() == time.Saturday || out.Weekday() == time.Sunday {
|
||||
continue
|
||||
}
|
||||
added++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestIntervalVolume(t *testing.T) {
|
||||
got := IntervalVolume([]domain.Candle{
|
||||
{Close: decimal.NewFromInt(100), VolumeLots: decimal.NewFromInt(10)},
|
||||
|
||||
Reference in New Issue
Block a user