2026-06-07 21:01:40 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-06-08 09:41:20 +00:00
|
|
|
fillLots := order.FilledLots
|
|
|
|
|
if fillLots < 0 {
|
|
|
|
|
fillLots = 0
|
|
|
|
|
}
|
|
|
|
|
fillPrice := order.AvgFillPrice
|
|
|
|
|
if !fillPrice.IsPositive() {
|
|
|
|
|
fillPrice = order.LimitPrice
|
|
|
|
|
}
|
|
|
|
|
if existing, ok, err := m.findEntryPosition(ctx, accountIDHash, order); err != nil {
|
|
|
|
|
return domain.Position{}, err
|
|
|
|
|
} else if ok {
|
|
|
|
|
previousLots := existing.Lots
|
|
|
|
|
totalLots := previousLots + fillLots
|
|
|
|
|
if fillLots > 0 && totalLots > 0 {
|
|
|
|
|
previousValue := existing.AvgBuyPrice.Mul(decimal.NewFromInt(previousLots))
|
|
|
|
|
fillValue := fillPrice.Mul(decimal.NewFromInt(fillLots))
|
|
|
|
|
existing.AvgBuyPrice = previousValue.Add(fillValue).Div(decimal.NewFromInt(totalLots))
|
|
|
|
|
}
|
|
|
|
|
existing.Lots = totalLots
|
|
|
|
|
existing.Lot = lot
|
|
|
|
|
existing.CommissionTotal = existing.CommissionTotal.Add(order.Commission)
|
|
|
|
|
if existing.OpenedAt == nil {
|
|
|
|
|
existing.OpenedAt = &now
|
|
|
|
|
}
|
|
|
|
|
if order.FilledLots < order.QuantityLots {
|
|
|
|
|
existing.Status = domain.PositionEntryPartiallyFilled
|
|
|
|
|
} else if existing.Status != domain.PositionHoldingOvernight {
|
|
|
|
|
existing.Status = domain.PositionEntryFilled
|
|
|
|
|
}
|
|
|
|
|
existing.UpdatedAt = now
|
|
|
|
|
if err := m.repo.UpsertPosition(ctx, existing); err != nil {
|
|
|
|
|
return domain.Position{}, err
|
|
|
|
|
}
|
|
|
|
|
return existing, nil
|
|
|
|
|
}
|
2026-06-07 21:01:40 +00:00
|
|
|
pos := domain.Position{
|
|
|
|
|
AccountIDHash: accountIDHash,
|
|
|
|
|
InstrumentUID: order.InstrumentUID,
|
|
|
|
|
OpenTradeDate: order.TradeDate,
|
2026-06-08 09:41:20 +00:00
|
|
|
Lots: fillLots,
|
2026-06-07 21:01:40 +00:00
|
|
|
Lot: lot,
|
2026-06-08 09:41:20 +00:00
|
|
|
AvgBuyPrice: fillPrice,
|
2026-06-07 21:01:40 +00:00
|
|
|
CommissionTotal: order.Commission,
|
2026-06-08 09:03:37 +00:00
|
|
|
Status: domain.PositionEntryFilled,
|
2026-06-07 21:01:40 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 09:41:20 +00:00
|
|
|
func (m Manager) findEntryPosition(ctx context.Context, accountIDHash string, order domain.Order) (domain.Position, bool, error) {
|
|
|
|
|
positions, err := m.repo.ListPositions(ctx, accountIDHash, order.TradeDate, order.TradeDate)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.Position{}, false, err
|
|
|
|
|
}
|
|
|
|
|
for _, pos := range positions {
|
|
|
|
|
if pos.InstrumentUID != order.InstrumentUID {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
switch pos.Status {
|
|
|
|
|
case domain.PositionEntrySignalled,
|
|
|
|
|
domain.PositionEntryOrderSent,
|
|
|
|
|
domain.PositionEntryPartiallyFilled,
|
|
|
|
|
domain.PositionEntryFilled,
|
|
|
|
|
domain.PositionHoldingOvernight:
|
|
|
|
|
return pos, true, nil
|
|
|
|
|
default:
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return domain.Position{}, false, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-07 21:01:40 +00:00
|
|
|
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
|
|
|
|
|
}
|