Files
overnight-trading-bot/internal/position/manager.go
T

169 lines
5.1 KiB
Go
Raw Normal View History

2026-06-07 21:01:40 +00:00
package position
import (
"context"
2026-06-08 14:58:56 +00:00
"errors"
"fmt"
2026-06-07 21:01:40 +00:00
"time"
"github.com/shopspring/decimal"
"overnight-trading-bot/internal/domain"
"overnight-trading-bot/internal/money"
"overnight-trading-bot/internal/repository"
)
2026-06-08 14:58:56 +00:00
var ErrExitFillExceedsPositionLots = errors.New("exit fill exceeds local position lots")
2026-06-07 21:01:40 +00:00
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
}
2026-06-08 14:58:56 +00:00
if exitOrder.FilledLots > pos.Lots {
err := fmt.Errorf("%w: filled_lots=%d position_lots=%d instrument_uid=%s", ErrExitFillExceedsPositionLots, exitOrder.FilledLots, pos.Lots, pos.InstrumentUID)
if m.repo != nil {
_ = m.repo.InsertRiskEvent(ctx, domain.RiskEvent{
TS: now,
Severity: domain.SeverityCritical,
EventType: "exit_overfill",
InstrumentUID: pos.InstrumentUID,
Message: err.Error(),
ContextJSON: fmt.Sprintf(`{"filled_lots":%d,"position_lots":%d}`, exitOrder.FilledLots, pos.Lots),
})
}
return pos, err
}
executedLots := exitOrder.FilledLots
2026-06-07 21:01:40 +00:00
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
}