second version

This commit is contained in:
2026-06-07 21:51:20 +00:00
parent 8e2d7efc32
commit 282c841e11
23 changed files with 869 additions and 151 deletions
+46 -7
View File
@@ -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)
}
+22 -7
View File
@@ -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
}
+7 -3
View File
@@ -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)
}
}
+30
View File
@@ -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())