second version
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"overnight-trading-bot/internal/domain"
|
||||
"overnight-trading-bot/internal/repository"
|
||||
"overnight-trading-bot/internal/risk"
|
||||
)
|
||||
|
||||
var ErrBrokerOrdersDisabled = errors.New("broker orders are disabled for current mode")
|
||||
@@ -113,6 +114,9 @@ func (e *Engine) PlaceLimit(ctx context.Context, order domain.Order) (domain.Ord
|
||||
return existing, nil
|
||||
}
|
||||
}
|
||||
if e.mode == domain.ModePaper {
|
||||
return e.placePaperLimit(ctx, order)
|
||||
}
|
||||
if !e.mode.AllowsBrokerOrders() {
|
||||
order.Status = domain.OrderStatusNew
|
||||
if e.store != nil {
|
||||
@@ -159,6 +163,28 @@ func (e *Engine) PlaceLimit(ctx context.Context, order domain.Order) (domain.Ord
|
||||
return posted, nil
|
||||
}
|
||||
|
||||
func (e *Engine) placePaperLimit(ctx context.Context, order domain.Order) (domain.Order, error) {
|
||||
now := time.Now().UTC()
|
||||
order.BrokerOrderID = "paper-" + order.ClientOrderID
|
||||
order.FilledLots = order.QuantityLots
|
||||
order.AvgFillPrice = order.LimitPrice
|
||||
order.Status = domain.OrderStatusFilled
|
||||
order.RawStateJSON = `{"paper_fill":true}`
|
||||
order.CreatedAt = now
|
||||
order.UpdatedAt = now
|
||||
if e.store != nil {
|
||||
if err := e.store.RunInTx(ctx, func(ctx context.Context, repo repository.Repository) error {
|
||||
if err := repo.UpsertOrder(ctx, order); err != nil {
|
||||
return fmt.Errorf("persist paper order: %w", err)
|
||||
}
|
||||
return repo.IncrementFreeOrders(ctx, order.TradeDate, order.InstrumentUID, 1)
|
||||
}); err != nil {
|
||||
return domain.Order{}, err
|
||||
}
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
func (e *Engine) findExisting(ctx context.Context, order domain.Order) (domain.Order, error) {
|
||||
orders, err := e.store.ListOrders(ctx, order.AccountIDHash, order.TradeDate, order.TradeDate)
|
||||
if err != nil {
|
||||
@@ -286,6 +312,9 @@ func (e *Engine) MonitorUntil(ctx context.Context, order domain.Order, cfg Monit
|
||||
}
|
||||
|
||||
func (e *Engine) repost(ctx context.Context, order domain.Order, cfg MonitorConfig, remaining int64) (domain.Order, error) {
|
||||
if err := e.ensureRepostBudget(ctx, order, cfg.Instrument); err != nil {
|
||||
return domain.Order{}, err
|
||||
}
|
||||
if err := e.Cancel(ctx, order); err != nil {
|
||||
return domain.Order{}, err
|
||||
}
|
||||
@@ -308,18 +337,28 @@ func (e *Engine) repost(ctx context.Context, order domain.Order, cfg MonitorConf
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) ensureRepostBudget(ctx context.Context, order domain.Order, instrument domain.Instrument) error {
|
||||
if e.store == nil || instrument.FreeOrderLimitPerDay <= 0 {
|
||||
return nil
|
||||
}
|
||||
sent, err := e.store.GetFreeOrdersSent(ctx, order.TradeDate, instrument.InstrumentUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if instrument.FreeOrderLimitPerDay-sent < 1 {
|
||||
return fmt.Errorf("%w: %s remaining=0", risk.ErrFreeOrderBudget, instrument.InstrumentUID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) checkQuoteFresh(book domain.OrderBook) error {
|
||||
if e.maxQuoteAge <= 0 {
|
||||
return nil
|
||||
}
|
||||
receivedAt := book.ReceivedAt
|
||||
if receivedAt.IsZero() {
|
||||
receivedAt = book.Time
|
||||
if book.ReceivedAt.IsZero() {
|
||||
return fmt.Errorf("quote received timestamp is missing")
|
||||
}
|
||||
if receivedAt.IsZero() {
|
||||
return fmt.Errorf("quote timestamp is missing")
|
||||
}
|
||||
age := time.Since(receivedAt)
|
||||
age := time.Since(book.ReceivedAt)
|
||||
if age > e.maxQuoteAge {
|
||||
return fmt.Errorf("quote age %s exceeds %s", age, e.maxQuoteAge)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"overnight-trading-bot/internal/money"
|
||||
)
|
||||
|
||||
var nonIDChar = regexp.MustCompile(`[^A-Za-z0-9_-]+`)
|
||||
const maxClientOrderIDLen = 36
|
||||
|
||||
func LimitBuyPrice(bestBid, bestAsk, tick decimal.Decimal, improveTicks int) (decimal.Decimal, error) {
|
||||
if improveTicks < 0 {
|
||||
@@ -49,10 +49,25 @@ func LimitSellPrice(bestBid, bestAsk, tick decimal.Decimal, improveTicks int) (d
|
||||
func ClientOrderID(tradeDate time.Time, instrumentUID string, side domain.Side, attempt int) string {
|
||||
base := fmt.Sprintf("%s|%s|%s|%d", tradeDate.Format("20060102"), instrumentUID, side, attempt)
|
||||
sum := sha256.Sum256([]byte(base))
|
||||
suffix := hex.EncodeToString(sum[:])[:8]
|
||||
cleanUID := nonIDChar.ReplaceAllString(instrumentUID, "_")
|
||||
if len(cleanUID) > 24 {
|
||||
cleanUID = cleanUID[:24]
|
||||
suffix := hex.EncodeToString(sum[:])
|
||||
sideToken := "b"
|
||||
if side == domain.SideSell {
|
||||
sideToken = "s"
|
||||
}
|
||||
return strings.ToLower(fmt.Sprintf("otb-%s-%s-%s-%02d-%s", tradeDate.Format("20060102"), cleanUID, side, attempt, suffix))
|
||||
prefix := fmt.Sprintf("otb-%s-%s-%s-", tradeDate.Format("20060102"), sideToken, attemptToken(attempt))
|
||||
return strings.ToLower(prefix + suffix[:maxClientOrderIDLen-len(prefix)])
|
||||
}
|
||||
|
||||
func attemptToken(attempt int) string {
|
||||
if attempt < 0 {
|
||||
attempt = 0
|
||||
}
|
||||
token := strings.ToLower(strconv.FormatInt(int64(attempt), 36))
|
||||
if len(token) > 2 {
|
||||
token = token[len(token)-2:]
|
||||
}
|
||||
for len(token) < 2 {
|
||||
token = "0" + token
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
@@ -40,10 +40,14 @@ func TestLimitPricesDoNotCross(t *testing.T) {
|
||||
|
||||
func TestClientOrderIDDeterministic(t *testing.T) {
|
||||
date := time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC)
|
||||
a := ClientOrderID(date, "uid", domain.SideBuy, 1)
|
||||
b := ClientOrderID(date, "uid", domain.SideBuy, 1)
|
||||
c := ClientOrderID(date, "uid", domain.SideBuy, 2)
|
||||
longUID := "a-realistic-instrument-uid-that-is-much-longer-than-the-order-id-limit"
|
||||
a := ClientOrderID(date, longUID, domain.SideBuy, 1)
|
||||
b := ClientOrderID(date, longUID, domain.SideBuy, 1)
|
||||
c := ClientOrderID(date, longUID, domain.SideBuy, 2)
|
||||
if a != b || a == c {
|
||||
t.Fatalf("unexpected ids: %s %s %s", a, b, c)
|
||||
}
|
||||
if len(a) > maxClientOrderIDLen {
|
||||
t.Fatalf("client order id len=%d, want <=%d: %s", len(a), maxClientOrderIDLen, a)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,36 @@ func TestPlaceLimitSuppressesDuplicateSubmit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaperPlaceEntryFillsAndCountsSubmittedOrder(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := testutil.NewMemoryRepository()
|
||||
engine := NewEngine(domain.ModePaper, "account", tinvest.NewFakeGateway(), repo)
|
||||
tradeDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC)
|
||||
order, err := engine.PlaceEntry(ctx, "hash", domain.Instrument{
|
||||
InstrumentUID: "uid",
|
||||
Lot: 1,
|
||||
MinPriceIncrement: decimal.NewFromInt(1),
|
||||
}, tradeDate, 2, domain.OrderBook{
|
||||
InstrumentUID: "uid",
|
||||
Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}},
|
||||
Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(101), QuantityLots: 10}},
|
||||
ReceivedAt: time.Now().UTC(),
|
||||
}, 1, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if order.Status != domain.OrderStatusFilled || order.FilledLots != 2 || order.BrokerOrderID == "" {
|
||||
t.Fatalf("paper order=%+v, want filled broker-like order", order)
|
||||
}
|
||||
sent, err := repo.GetFreeOrdersSent(ctx, tradeDate, "uid")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sent != 1 {
|
||||
t.Fatalf("free order counter=%d, want 1", sent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceEntryRejectsStaleQuote(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
engine := NewEngine(domain.ModeSandbox, "account", tinvest.NewFakeGateway(), testutil.NewMemoryRepository())
|
||||
|
||||
Reference in New Issue
Block a user