ninth version
Deploy / Test, build and deploy (push) Failing after 1m6s

This commit is contained in:
2026-06-08 14:25:44 +00:00
parent e8b7d8e27c
commit 20cc8506ad
21 changed files with 847 additions and 148 deletions
+75 -30
View File
@@ -2,6 +2,7 @@ package execution
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
@@ -110,7 +111,7 @@ func (e *Engine) PlaceEntry(ctx context.Context, accountIDHash string, instrumen
QuantityLots: lots,
Status: domain.OrderStatusNew,
AttemptNo: attempt,
RawStateJSON: "{}",
RawStateJSON: orderContextJSON(book),
}, instrument.FreeOrderLimitPerDay)
}
@@ -137,7 +138,7 @@ func (e *Engine) PlaceExit(ctx context.Context, accountIDHash string, instrument
QuantityLots: lots,
Status: domain.OrderStatusNew,
AttemptNo: attempt,
RawStateJSON: "{}",
RawStateJSON: orderContextJSON(book),
}, instrument.FreeOrderLimitPerDay)
}
@@ -152,6 +153,9 @@ func (e *Engine) placeLimit(ctx context.Context, order domain.Order, freeOrderLi
if e.mode != domain.ModePaper && !e.mode.AllowsBrokerOrders() {
return order, ErrBrokerOrdersDisabled
}
if e.gateway == nil {
return domain.Order{}, errors.New("gateway is nil")
}
if e.store != nil {
existing, err := e.findExisting(ctx, order)
if err != nil {
@@ -161,12 +165,6 @@ func (e *Engine) placeLimit(ctx context.Context, order domain.Order, freeOrderLi
return existing, nil
}
}
if e.mode == domain.ModePaper {
return e.placePaperLimit(ctx, order, freeOrderLimit)
}
if e.gateway == nil {
return domain.Order{}, errors.New("gateway is nil")
}
now := e.nowUTC()
draft := order
@@ -203,6 +201,7 @@ func (e *Engine) placeLimit(ctx context.Context, order domain.Order, freeOrderLi
posted.QuantityLots = order.QuantityLots
posted.AttemptNo = order.AttemptNo
posted.TradeDate = order.TradeDate
posted.RawStateJSON = mergeRawStateJSON(order.RawStateJSON, posted.RawStateJSON)
posted.CreatedAt = now
posted.UpdatedAt = posted.CreatedAt
if e.store != nil {
@@ -213,28 +212,6 @@ func (e *Engine) placeLimit(ctx context.Context, order domain.Order, freeOrderLi
return posted, nil
}
func (e *Engine) placePaperLimit(ctx context.Context, order domain.Order, freeOrderLimit int) (domain.Order, error) {
now := e.nowUTC()
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.ReserveFreeOrders(ctx, order.TradeDate, order.InstrumentUID, 1, freeOrderLimit)
}); 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 {
@@ -268,6 +245,7 @@ func (e *Engine) Refresh(ctx context.Context, order domain.Order) (domain.Order,
state.LimitPrice = order.LimitPrice
state.QuantityLots = order.QuantityLots
state.AttemptNo = order.AttemptNo
state.RawStateJSON = mergeRawStateJSON(localRawStateJSON(order.RawStateJSON), state.RawStateJSON)
if e.store != nil {
if err := e.store.UpsertOrder(ctx, state); err != nil {
return domain.Order{}, err
@@ -583,6 +561,73 @@ func quoteTimestamp(book domain.OrderBook) time.Time {
return book.ReceivedAt.UTC()
}
func orderContextJSON(book domain.OrderBook) string {
bid, ask, err := bestBidAsk(book)
if err != nil {
return "{}"
}
mid := bid.Add(ask).Div(decimal.NewFromInt(2))
context := map[string]any{
"local_quote": map[string]string{
"best_bid": bid.String(),
"best_ask": ask.String(),
"mid": mid.String(),
},
}
if ts := quoteTimestamp(book); !ts.IsZero() {
context["local_quote"].(map[string]string)["quote_ts"] = ts.UTC().Format(time.RFC3339Nano)
}
raw, err := json.Marshal(context)
if err != nil {
return "{}"
}
return string(raw)
}
func mergeRawStateJSON(localRaw, brokerRaw string) string {
local := decodeRawJSON(localRaw)
broker := decodeRawJSON(brokerRaw)
raw, err := json.Marshal(map[string]any{
"local": local,
"broker": broker,
})
if err != nil {
return brokerRaw
}
return string(raw)
}
func decodeRawJSON(raw string) any {
if raw == "" {
return map[string]any{}
}
var value any
if err := json.Unmarshal([]byte(raw), &value); err != nil {
return raw
}
return value
}
func localRawStateJSON(raw string) string {
var object map[string]any
if err := json.Unmarshal([]byte(raw), &object); err != nil {
return raw
}
if local, ok := object["local"]; ok {
encoded, err := json.Marshal(local)
if err == nil {
return string(encoded)
}
}
if quote, ok := object["local_quote"]; ok {
encoded, err := json.Marshal(map[string]any{"local_quote": quote})
if err == nil {
return string(encoded)
}
}
return raw
}
func (e *Engine) lockFor(instrumentUID string) *sync.Mutex {
value, _ := e.mu.LoadOrStore(instrumentUID, &sync.Mutex{})
lock, ok := value.(*sync.Mutex)
+67 -4
View File
@@ -3,6 +3,7 @@ package execution
import (
"context"
"errors"
"strings"
"testing"
"time"
@@ -110,6 +111,35 @@ func TestPlaceEntryReservesFreeOrderBudgetAtomically(t *testing.T) {
}
}
func TestRefreshPreservesLocalQuoteContext(t *testing.T) {
ctx := context.Background()
repo := testutil.NewMemoryRepository()
gateway := tinvest.NewFakeGateway()
engine := NewEngine(domain.ModeSandbox, "account", gateway, repo)
instrument := domain.Instrument{
InstrumentUID: "uid",
Lot: 1,
MinPriceIncrement: decimal.NewFromInt(1),
}
book := 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(),
}
order, err := engine.PlaceEntry(ctx, "hash", instrument, time.Now().UTC(), 1, book, 1, 1)
if err != nil {
t.Fatal(err)
}
refreshed, err := engine.Refresh(ctx, order)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(refreshed.RawStateJSON, "local_quote") || !strings.Contains(refreshed.RawStateJSON, `"mid":"100"`) {
t.Fatalf("raw state lost local quote context: %s", refreshed.RawStateJSON)
}
}
func TestMonitorOnceUsesInjectedClockForDeadline(t *testing.T) {
ctx := context.Background()
repo := testutil.NewMemoryRepository()
@@ -160,10 +190,17 @@ func TestMonitorOnceUsesInjectedClockForDeadline(t *testing.T) {
}
}
func TestPaperPlaceEntryFillsAndCountsSubmittedOrder(t *testing.T) {
func TestPaperPlaceEntryFillsOnlyWhenOrderBookCrosses(t *testing.T) {
ctx := context.Background()
repo := testutil.NewMemoryRepository()
engine := NewEngine(domain.ModePaper, "account", tinvest.NewFakeGateway(), repo)
paper := tinvest.NewPaperGateway(nil)
paper.Fake().OrderBooks["uid"] = 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(),
}
engine := NewEngine(domain.ModePaper, "account", paper, repo)
tradeDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC)
order, err := engine.PlaceEntry(ctx, "hash", domain.Instrument{
InstrumentUID: "uid",
@@ -178,8 +215,34 @@ func TestPaperPlaceEntryFillsAndCountsSubmittedOrder(t *testing.T) {
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)
if order.Status != domain.OrderStatusSent || order.FilledLots != 0 || order.BrokerOrderID == "" {
t.Fatalf("paper order=%+v, want sent unfilled broker-like order", order)
}
paper.Fake().OrderBooks["uid"] = domain.OrderBook{
InstrumentUID: "uid",
Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}},
Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(100), QuantityLots: 1}},
ReceivedAt: time.Now().UTC(),
}
partial, err := engine.MonitorOnce(ctx, order, MonitorConfig{})
if err != nil {
t.Fatal(err)
}
if partial.Status != domain.OrderStatusPartiallyFilled || partial.FilledLots != 1 {
t.Fatalf("paper partial order=%+v, want 1 lot partial fill", partial)
}
paper.Fake().OrderBooks["uid"] = domain.OrderBook{
InstrumentUID: "uid",
Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}},
Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(100), QuantityLots: 10}},
ReceivedAt: time.Now().UTC(),
}
filled, err := engine.MonitorOnce(ctx, partial, MonitorConfig{})
if err != nil {
t.Fatal(err)
}
if filled.Status != domain.OrderStatusFilled || filled.FilledLots != 2 {
t.Fatalf("paper filled order=%+v, want full fill", filled)
}
sent, err := repo.GetFreeOrdersSent(ctx, tradeDate, "uid")
if err != nil {