first version
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
package position
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
|
||||
"overnight-trading-bot/internal/domain"
|
||||
"overnight-trading-bot/internal/money"
|
||||
"overnight-trading-bot/internal/repository"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
repo repository.Repository
|
||||
}
|
||||
|
||||
func NewManager(repo repository.Repository) Manager {
|
||||
return Manager{repo: repo}
|
||||
}
|
||||
|
||||
func (m Manager) OnEntryFill(ctx context.Context, accountIDHash string, instrument domain.Instrument, order domain.Order) (domain.Position, error) {
|
||||
now := time.Now().UTC()
|
||||
lot := instrument.Lot
|
||||
if lot <= 0 {
|
||||
lot = 1
|
||||
}
|
||||
pos := domain.Position{
|
||||
AccountIDHash: accountIDHash,
|
||||
InstrumentUID: order.InstrumentUID,
|
||||
OpenTradeDate: order.TradeDate,
|
||||
Lots: order.FilledLots,
|
||||
Lot: lot,
|
||||
AvgBuyPrice: order.AvgFillPrice,
|
||||
CommissionTotal: order.Commission,
|
||||
Status: domain.PositionHoldingOvernight,
|
||||
OpenedAt: &now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if pos.Lots < order.QuantityLots {
|
||||
pos.Status = domain.PositionEntryPartiallyFilled
|
||||
}
|
||||
if err := m.repo.UpsertPosition(ctx, pos); err != nil {
|
||||
return domain.Position{}, err
|
||||
}
|
||||
return pos, nil
|
||||
}
|
||||
|
||||
func (m Manager) OnExitFill(ctx context.Context, pos domain.Position, exitOrder domain.Order) (domain.Position, error) {
|
||||
now := time.Now().UTC()
|
||||
lot := pos.Lot
|
||||
if lot <= 0 {
|
||||
lot = 1
|
||||
}
|
||||
executedLots := min(exitOrder.FilledLots, pos.Lots)
|
||||
if executedLots < 0 {
|
||||
executedLots = 0
|
||||
}
|
||||
previousExitLots := pos.ExitFilledLots
|
||||
pos.ExitFilledLots += executedLots
|
||||
if executedLots > 0 {
|
||||
previousValue := pos.AvgSellPrice.Mul(decimal.NewFromInt(previousExitLots))
|
||||
newValue := exitOrder.AvgFillPrice.Mul(decimal.NewFromInt(executedLots))
|
||||
pos.AvgSellPrice = previousValue.Add(newValue).Div(decimal.NewFromInt(pos.ExitFilledLots))
|
||||
}
|
||||
pos.CommissionTotal = pos.CommissionTotal.Add(exitOrder.Commission)
|
||||
executedUnits := decimal.NewFromInt(executedLots).Mul(decimal.NewFromInt(lot))
|
||||
pos.GrossPnL = pos.GrossPnL.Add(exitOrder.AvgFillPrice.Sub(pos.AvgBuyPrice).Mul(executedUnits))
|
||||
pos.NetPnL = pos.GrossPnL.Sub(pos.CommissionTotal)
|
||||
if pos.AvgBuyPrice.IsPositive() {
|
||||
baseLots := pos.ExitFilledLots
|
||||
if baseLots <= 0 {
|
||||
baseLots = pos.Lots
|
||||
}
|
||||
base := pos.AvgBuyPrice.Mul(decimal.NewFromInt(baseLots)).Mul(decimal.NewFromInt(lot))
|
||||
edge, _ := money.Bps(pos.NetPnL, base)
|
||||
pos.RealizedEdgeBps = edge
|
||||
}
|
||||
pos.Status = domain.PositionExitFilled
|
||||
if executedLots < pos.Lots {
|
||||
pos.Lots -= executedLots
|
||||
pos.Status = domain.PositionExitPartiallyFilled
|
||||
pos.ClosedAt = nil
|
||||
} else {
|
||||
pos.Lots = 0
|
||||
pos.ClosedAt = &now
|
||||
}
|
||||
pos.UpdatedAt = now
|
||||
if err := m.repo.UpsertPosition(ctx, pos); err != nil {
|
||||
return domain.Position{}, err
|
||||
}
|
||||
return pos, nil
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package position
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
|
||||
"overnight-trading-bot/internal/domain"
|
||||
"overnight-trading-bot/internal/testutil"
|
||||
)
|
||||
|
||||
func TestOnEntryFillKeepsBuyCommission(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
manager := NewManager(testutil.NewMemoryRepository())
|
||||
pos, err := manager.OnEntryFill(ctx, "hash", domain.Instrument{Lot: 1}, domain.Order{
|
||||
InstrumentUID: "uid",
|
||||
TradeDate: time.Now().UTC(),
|
||||
QuantityLots: 10,
|
||||
FilledLots: 10,
|
||||
AvgFillPrice: decimal.NewFromInt(100),
|
||||
Commission: decimal.NewFromInt(3),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !pos.CommissionTotal.Equal(decimal.NewFromInt(3)) {
|
||||
t.Fatalf("commission=%s, want 3", pos.CommissionTotal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnExitFillPartialUsesExecutedLots(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
manager := NewManager(testutil.NewMemoryRepository())
|
||||
openAt := time.Now().UTC()
|
||||
pos := domain.Position{
|
||||
AccountIDHash: "hash",
|
||||
InstrumentUID: "uid",
|
||||
OpenTradeDate: openAt,
|
||||
Lots: 10,
|
||||
Lot: 1,
|
||||
AvgBuyPrice: decimal.NewFromInt(100),
|
||||
Status: domain.PositionHoldingOvernight,
|
||||
CommissionTotal: decimal.NewFromInt(2),
|
||||
OpenedAt: &openAt,
|
||||
}
|
||||
updated, err := manager.OnExitFill(ctx, pos, domain.Order{
|
||||
InstrumentUID: "uid",
|
||||
FilledLots: 4,
|
||||
AvgFillPrice: decimal.NewFromInt(110),
|
||||
Commission: decimal.NewFromInt(1),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Status != domain.PositionExitPartiallyFilled || updated.ClosedAt != nil {
|
||||
t.Fatalf("unexpected partial status/closed_at: %+v", updated)
|
||||
}
|
||||
if updated.Lots != 6 {
|
||||
t.Fatalf("remaining lots=%d, want 6", updated.Lots)
|
||||
}
|
||||
if !updated.GrossPnL.Equal(decimal.NewFromInt(40)) {
|
||||
t.Fatalf("gross pnl=%s, want 40", updated.GrossPnL)
|
||||
}
|
||||
if updated.ExitFilledLots != 4 || !updated.AvgSellPrice.Equal(decimal.NewFromInt(110)) {
|
||||
t.Fatalf("exit aggregation lots=%d avg=%s", updated.ExitFilledLots, updated.AvgSellPrice)
|
||||
}
|
||||
second, err := manager.OnExitFill(ctx, updated, domain.Order{
|
||||
InstrumentUID: "uid",
|
||||
FilledLots: 3,
|
||||
AvgFillPrice: decimal.NewFromInt(120),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantAvg := decimal.NewFromInt(800).Div(decimal.NewFromInt(7))
|
||||
if second.ExitFilledLots != 7 || !second.AvgSellPrice.Equal(wantAvg) {
|
||||
t.Fatalf("weighted avg sell=%s lots=%d, want %s/7", second.AvgSellPrice, second.ExitFilledLots, wantAvg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnExitFillUsesInstrumentLotForAbsolutePnL(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
manager := NewManager(testutil.NewMemoryRepository())
|
||||
openAt := time.Now().UTC()
|
||||
pos := domain.Position{
|
||||
AccountIDHash: "hash",
|
||||
InstrumentUID: "uid",
|
||||
OpenTradeDate: openAt,
|
||||
Lots: 4,
|
||||
Lot: 10,
|
||||
AvgBuyPrice: decimal.NewFromInt(100),
|
||||
Status: domain.PositionHoldingOvernight,
|
||||
CommissionTotal: decimal.NewFromInt(2),
|
||||
OpenedAt: &openAt,
|
||||
}
|
||||
updated, err := manager.OnExitFill(ctx, pos, domain.Order{
|
||||
InstrumentUID: "uid",
|
||||
FilledLots: 4,
|
||||
AvgFillPrice: decimal.NewFromInt(105),
|
||||
Commission: decimal.NewFromInt(3),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !updated.GrossPnL.Equal(decimal.NewFromInt(200)) {
|
||||
t.Fatalf("gross pnl=%s, want 200", updated.GrossPnL)
|
||||
}
|
||||
if !updated.NetPnL.Equal(decimal.NewFromInt(195)) {
|
||||
t.Fatalf("net pnl=%s, want 195", updated.NetPnL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnExitFillUsesLotInRealizedEdgeCommissionBase(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
manager := NewManager(testutil.NewMemoryRepository())
|
||||
openAt := time.Now().UTC()
|
||||
pos := domain.Position{
|
||||
AccountIDHash: "hash",
|
||||
InstrumentUID: "uid",
|
||||
OpenTradeDate: openAt,
|
||||
Lots: 1,
|
||||
Lot: 100,
|
||||
AvgBuyPrice: decimal.NewFromInt(100),
|
||||
Status: domain.PositionHoldingOvernight,
|
||||
OpenedAt: &openAt,
|
||||
}
|
||||
updated, err := manager.OnExitFill(ctx, pos, domain.Order{
|
||||
InstrumentUID: "uid",
|
||||
FilledLots: 1,
|
||||
AvgFillPrice: decimal.NewFromInt(100),
|
||||
Commission: decimal.NewFromInt(10),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !updated.RealizedEdgeBps.Equal(decimal.NewFromInt(-10)) {
|
||||
t.Fatalf("realized edge=%s, want -10 bps", updated.RealizedEdgeBps)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user