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
+126 -14
View File
@@ -3,6 +3,7 @@ package tinvest
import (
"context"
"errors"
"fmt"
"sync"
"time"
@@ -28,24 +29,28 @@ type Gateway interface {
}
type FakeGateway struct {
mu sync.Mutex
Instruments map[string]domain.Instrument
Candles map[string][]domain.Candle
OrderBooks map[string]domain.OrderBook
Statuses map[string]domain.TradingStatus
Orders map[string]domain.Order
Portfolio domain.Portfolio
Operations []domain.Operation
ServerTime time.Time
mu sync.Mutex
Instruments map[string]domain.Instrument
InstrumentErrors map[string]error
Candles map[string][]domain.Candle
CandleErrors map[string]error
OrderBooks map[string]domain.OrderBook
Statuses map[string]domain.TradingStatus
Orders map[string]domain.Order
Portfolio domain.Portfolio
Operations []domain.Operation
ServerTime time.Time
}
func NewFakeGateway() *FakeGateway {
return &FakeGateway{
Instruments: make(map[string]domain.Instrument),
Candles: make(map[string][]domain.Candle),
OrderBooks: make(map[string]domain.OrderBook),
Statuses: make(map[string]domain.TradingStatus),
Orders: make(map[string]domain.Order),
Instruments: make(map[string]domain.Instrument),
InstrumentErrors: make(map[string]error),
Candles: make(map[string][]domain.Candle),
CandleErrors: make(map[string]error),
OrderBooks: make(map[string]domain.OrderBook),
Statuses: make(map[string]domain.TradingStatus),
Orders: make(map[string]domain.Order),
Portfolio: domain.Portfolio{
Equity: decimal.NewFromInt(100_000),
Cash: decimal.NewFromInt(100_000),
@@ -59,6 +64,9 @@ func (f *FakeGateway) GetInstrument(_ context.Context, ticker, classCode string)
defer f.mu.Unlock()
for _, instrument := range f.Instruments {
if instrument.Ticker == ticker && instrument.ClassCode == classCode {
if err := f.InstrumentErrors[instrument.InstrumentUID]; err != nil {
return domain.Instrument{}, err
}
return instrument, nil
}
}
@@ -68,6 +76,9 @@ func (f *FakeGateway) GetInstrument(_ context.Context, ticker, classCode string)
func (f *FakeGateway) GetCandles(_ context.Context, instrumentUID string, _ string, from, to time.Time) ([]domain.Candle, error) {
f.mu.Lock()
defer f.mu.Unlock()
if err := f.CandleErrors[instrumentUID]; err != nil {
return nil, err
}
var out []domain.Candle
for _, candle := range f.Candles[instrumentUID] {
if !candle.TradeDate.Before(from) && !candle.TradeDate.After(to) {
@@ -141,6 +152,40 @@ func (f *FakeGateway) GetOrderState(_ context.Context, _ string, orderID string)
return order, nil
}
func (f *FakeGateway) SimulateOrderBookFill(orderID string, book domain.OrderBook) (domain.Order, error) {
f.mu.Lock()
defer f.mu.Unlock()
order, ok := f.Orders[orderID]
if !ok {
return domain.Order{}, ErrNotFound
}
if isTerminalFakeOrder(order.Status) || order.FilledLots >= order.QuantityLots {
return order, nil
}
price, availableLots, ok := paperFillLevel(order, book)
if !ok || availableLots <= 0 {
return order, nil
}
remaining := order.QuantityLots - order.FilledLots
fillLots := minInt64(remaining, availableLots)
if fillLots <= 0 {
return order, nil
}
order.AvgFillPrice = paperWeightedAvg(order.AvgFillPrice, order.FilledLots, price, fillLots)
order.FilledLots += fillLots
if order.FilledLots >= order.QuantityLots {
order.Status = domain.OrderStatusFilled
} else {
order.Status = domain.OrderStatusPartiallyFilled
}
now := time.Now().UTC()
order.UpdatedAt = now
order.RawStateJSON = fmt.Sprintf(`{"paper_fill":true,"filled_lots":%d}`, order.FilledLots)
f.Orders[orderID] = order
f.recordPaperOperationLocked(order, fillLots, price, now)
return order, nil
}
func (f *FakeGateway) GetActiveOrders(_ context.Context, _ string) ([]domain.Order, error) {
f.mu.Lock()
defer f.mu.Unlock()
@@ -180,3 +225,70 @@ func (f *FakeGateway) GetServerTime(context.Context) (time.Time, error) {
}
return f.ServerTime, nil
}
func isTerminalFakeOrder(status domain.OrderStatus) bool {
return status == domain.OrderStatusFilled ||
status == domain.OrderStatusCancelled ||
status == domain.OrderStatusRejected ||
status == domain.OrderStatusExpired ||
status == domain.OrderStatusFailed
}
func paperFillLevel(order domain.Order, book domain.OrderBook) (decimal.Decimal, int64, bool) {
switch order.Side {
case domain.SideBuy:
if len(book.Asks) == 0 {
return decimal.Zero, 0, false
}
ask := book.Asks[0]
if ask.Price.IsPositive() && order.LimitPrice.GreaterThanOrEqual(ask.Price) {
return ask.Price, ask.QuantityLots, true
}
case domain.SideSell:
if len(book.Bids) == 0 {
return decimal.Zero, 0, false
}
bid := book.Bids[0]
if bid.Price.IsPositive() && order.LimitPrice.LessThanOrEqual(bid.Price) {
return bid.Price, bid.QuantityLots, true
}
}
return decimal.Zero, 0, false
}
func paperWeightedAvg(currentAvg decimal.Decimal, currentLots int64, fillPrice decimal.Decimal, fillLots int64) decimal.Decimal {
if currentLots <= 0 {
return fillPrice
}
totalLots := currentLots + fillLots
if totalLots <= 0 {
return decimal.Zero
}
return currentAvg.Mul(decimal.NewFromInt(currentLots)).
Add(fillPrice.Mul(decimal.NewFromInt(fillLots))).
Div(decimal.NewFromInt(totalLots))
}
func (f *FakeGateway) recordPaperOperationLocked(order domain.Order, fillLots int64, price decimal.Decimal, ts time.Time) {
payment := price.Mul(decimal.NewFromInt(fillLots))
opType := "OPERATION_TYPE_SELL"
if order.Side == domain.SideBuy {
payment = payment.Neg()
opType = "OPERATION_TYPE_BUY"
}
f.Operations = append(f.Operations, domain.Operation{
ID: fmt.Sprintf("%s-%d", order.BrokerOrderID, len(f.Operations)+1),
InstrumentUID: order.InstrumentUID,
Type: opType,
Payment: payment,
Commission: decimal.Zero,
ExecutedAt: ts,
})
}
func minInt64(a, b int64) int64 {
if a < b {
return a
}
return b
}
+18 -1
View File
@@ -62,7 +62,18 @@ func (g *PaperGateway) CancelOrder(ctx context.Context, accountID, orderID strin
}
func (g *PaperGateway) GetOrderState(ctx context.Context, accountID, orderID string) (domain.Order, error) {
return g.Fake().GetOrderState(ctx, accountID, orderID)
order, err := g.Fake().GetOrderState(ctx, accountID, orderID)
if err != nil {
return domain.Order{}, err
}
if !paperOrderCanFill(order) {
return order, nil
}
book, err := g.GetOrderBook(ctx, order.InstrumentUID, 20)
if err != nil {
return domain.Order{}, err
}
return g.Fake().SimulateOrderBookFill(orderID, book)
}
func (g *PaperGateway) GetActiveOrders(ctx context.Context, accountID string) ([]domain.Order, error) {
@@ -83,3 +94,9 @@ func (g *PaperGateway) GetServerTime(ctx context.Context) (time.Time, error) {
}
return g.Fake().GetServerTime(ctx)
}
func paperOrderCanFill(order domain.Order) bool {
return order.Status == domain.OrderStatusSent ||
order.Status == domain.OrderStatusPartiallyFilled ||
order.Status == domain.OrderStatusNew
}
+161 -15
View File
@@ -306,13 +306,19 @@ func (g *RealGateway) GetPortfolio(ctx context.Context, accountID string) (domai
if err != nil {
return domain.Portfolio{}, err
}
return portfolioFromResponse(resp, g.lotForInstrument)
return portfolioFromResponse(resp, func(instrumentUID string) (int64, error) {
return g.resolveInstrumentLot(ctx, instrumentUID)
})
}
func (g *RealGateway) GetOperations(ctx context.Context, accountID string, from, to time.Time) ([]domain.Operation, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
ops, err := g.getOperationsByCursor(ctx, accountID, from, to)
if err == nil {
return ops, nil
}
resp, err := requestWithTimeout(ctx, g.requestTimeout, func(callCtx context.Context) (*pb.OperationsResponse, error) {
return retryValue(callCtx, g.retryAttempts, g.retryBackoff, func() (*pb.OperationsResponse, error) {
return g.operationsPB.GetOperations(callCtx, &pb.OperationsRequest{
@@ -328,30 +334,122 @@ func (g *RealGateway) GetOperations(ctx context.Context, accountID string, from,
return operationsFromResponse(resp), nil
}
func (g *RealGateway) getOperationsByCursor(ctx context.Context, accountID string, from, to time.Time) ([]domain.Operation, error) {
limit := int32(1000)
withoutCommissions := false
withoutTrades := true
withoutOvernights := false
state := pb.OperationState_OPERATION_STATE_EXECUTED
var cursor *string
var out []domain.Operation
for {
resp, err := requestWithTimeout(ctx, g.requestTimeout, func(callCtx context.Context) (*pb.GetOperationsByCursorResponse, error) {
return retryValue(callCtx, g.retryAttempts, g.retryBackoff, func() (*pb.GetOperationsByCursorResponse, error) {
return g.operationsPB.GetOperationsByCursor(callCtx, &pb.GetOperationsByCursorRequest{
AccountId: accountID,
From: investgo.TimeToTimestamp(from),
To: investgo.TimeToTimestamp(to),
Cursor: cursor,
Limit: &limit,
State: &state,
WithoutCommissions: &withoutCommissions,
WithoutTrades: &withoutTrades,
WithoutOvernights: &withoutOvernights,
})
})
})
if err != nil {
return nil, err
}
out = append(out, operationsFromCursorResponse(resp)...)
if !resp.GetHasNext() || resp.GetNextCursor() == "" {
return out, nil
}
next := resp.GetNextCursor()
cursor = &next
}
}
func operationsFromResponse(resp *pb.OperationsResponse) []domain.Operation {
ops := resp.GetOperations()
out := make([]domain.Operation, 0, len(ops))
for _, op := range ops {
payment := money.MoneyValueToDecimal(op.GetPayment())
instrumentUID := op.GetInstrumentUid()
commission := operationCommission(op.GetOperationType(), payment)
childCommission := decimal.Zero
for _, child := range op.GetChildOperations() {
childPayment := money.MoneyValueToDecimal(child.GetPayment())
if instrumentUID == "" {
instrumentUID = child.GetInstrumentUid()
}
childCommission = childCommission.Add(operationCommission(op.GetOperationType(), childPayment))
}
if commission.IsZero() {
commission = childCommission
}
out = append(out, domain.Operation{
ID: op.GetId(),
InstrumentUID: op.GetInstrumentUid(),
InstrumentUID: instrumentUID,
Type: op.GetOperationType().String(),
Payment: payment,
Commission: operationCommission(op.GetOperationType(), payment),
Commission: commission,
ExecutedAt: op.GetDate().AsTime().UTC(),
})
}
return out
}
func portfolioFromResponse(resp *pb.PortfolioResponse, lotForInstrument func(string) int64) (domain.Portfolio, error) {
func operationsFromCursorResponse(resp *pb.GetOperationsByCursorResponse) []domain.Operation {
items := resp.GetItems()
out := make([]domain.Operation, 0, len(items))
for _, item := range items {
payment := money.MoneyValueToDecimal(item.GetPayment())
commission := money.Abs(money.MoneyValueToDecimal(item.GetCommission()))
instrumentUID := item.GetInstrumentUid()
childCommission := decimal.Zero
for _, child := range item.GetChildOperations() {
childPayment := money.Abs(money.MoneyValueToDecimal(child.GetPayment()))
if instrumentUID == "" {
instrumentUID = child.GetInstrumentUid()
}
if operationLooksLikeCommission(item.GetType(), childPayment) {
childCommission = childCommission.Add(childPayment)
}
}
if commission.IsZero() && operationLooksLikeCommission(item.GetType(), payment) {
commission = money.Abs(payment)
}
if commission.IsZero() {
commission = childCommission
}
out = append(out, domain.Operation{
ID: item.GetId(),
InstrumentUID: instrumentUID,
Type: item.GetType().String(),
Payment: payment,
Commission: commission,
ExecutedAt: item.GetDate().AsTime().UTC(),
})
}
return out
}
func portfolioFromResponse(resp *pb.PortfolioResponse, lotForInstrument func(string) (int64, error)) (domain.Portfolio, error) {
positions := resp.GetPositions()
holdings := make([]domain.Holding, 0, len(positions))
for _, position := range positions {
if portfolioPositionIgnored(position) {
continue
}
lot, lotErr := portfolioPositionLot(position, lotForInstrument)
lots, err := portfolioQuantityLots(position, lot, lotErr)
if err != nil {
return domain.Portfolio{}, err
}
holdings = append(holdings, domain.Holding{
InstrumentUID: position.GetInstrumentUid(),
QuantityLots: portfolioQuantityLots(position, portfolioPositionLot(position, lotForInstrument)),
QuantityLots: lots,
AveragePrice: money.MoneyValueToDecimal(position.GetAveragePositionPrice()),
MarketValue: money.MoneyValueToDecimal(position.GetCurrentPrice()).Mul(money.QuotationToDecimal(position.GetQuantity())),
})
@@ -396,14 +494,22 @@ func (g *RealGateway) GetServerTime(ctx context.Context) (time.Time, error) {
}
func operationCommission(operationType pb.OperationType, payment decimal.Decimal) decimal.Decimal {
if operationType != pb.OperationType_OPERATION_TYPE_BROKER_FEE &&
operationType != pb.OperationType_OPERATION_TYPE_SERVICE_FEE &&
operationType != pb.OperationType_OPERATION_TYPE_SUCCESS_FEE {
if !operationTypeIsCommission(operationType) {
return decimal.Zero
}
return money.Abs(payment)
}
func operationTypeIsCommission(operationType pb.OperationType) bool {
return operationType == pb.OperationType_OPERATION_TYPE_BROKER_FEE ||
operationType == pb.OperationType_OPERATION_TYPE_SERVICE_FEE ||
operationType == pb.OperationType_OPERATION_TYPE_SUCCESS_FEE
}
func operationLooksLikeCommission(operationType pb.OperationType, payment decimal.Decimal) bool {
return operationTypeIsCommission(operationType) && !payment.IsZero()
}
func rubMoneyValueToDecimal(value *pb.MoneyValue) (decimal.Decimal, error) {
if value == nil {
return decimal.Zero, nil
@@ -414,25 +520,38 @@ func rubMoneyValueToDecimal(value *pb.MoneyValue) (decimal.Decimal, error) {
return money.MoneyValueToDecimal(value), nil
}
func portfolioPositionLot(position *pb.PortfolioPosition, lotForInstrument func(string) int64) int64 {
func portfolioPositionLot(position *pb.PortfolioPosition, lotForInstrument func(string) (int64, error)) (int64, error) {
if position == nil || lotForInstrument == nil {
return 0
return 0, nil
}
return lotForInstrument(position.GetInstrumentUid())
}
func portfolioQuantityLots(position *pb.PortfolioPosition, lot int64) int64 {
func portfolioPositionIgnored(position *pb.PortfolioPosition) bool {
if position == nil {
return 0
return true
}
if money.QuotationToDecimal(position.GetQuantity()).IsZero() {
return true
}
return strings.EqualFold(position.GetInstrumentType(), "currency")
}
func portfolioQuantityLots(position *pb.PortfolioPosition, lot int64, lotErr error) (int64, error) {
if position == nil {
return 0, nil
}
if lots, ok := portfolioDeprecatedQuantityLots(position); ok {
return lots.IntPart()
return lots.IntPart(), nil
}
if lotErr != nil {
return 0, lotErr
}
quantity := money.QuotationToDecimal(position.GetQuantity())
if lot > 0 {
return quantity.Div(decimal.NewFromInt(lot)).IntPart()
return quantity.Div(decimal.NewFromInt(lot)).IntPart(), nil
}
return quantity.IntPart()
return 0, fmt.Errorf("portfolio lot size is unknown for %s", position.GetInstrumentUid())
}
func (g *RealGateway) storeInstrumentLot(instrument domain.Instrument) {
@@ -457,6 +576,33 @@ func (g *RealGateway) lotForInstrument(instrumentUID string) int64 {
return lot
}
func (g *RealGateway) resolveInstrumentLot(ctx context.Context, instrumentUID string) (int64, error) {
if lot := g.lotForInstrument(instrumentUID); lot > 0 {
return lot, nil
}
if instrumentUID == "" {
return 0, errors.New("portfolio instrument uid is empty")
}
resp, err := requestWithTimeout(ctx, g.requestTimeout, func(callCtx context.Context) (*pb.InstrumentResponse, error) {
return retryValue(callCtx, g.retryAttempts, g.retryBackoff, func() (*pb.InstrumentResponse, error) {
return g.instrumentsPB.GetInstrumentBy(callCtx, &pb.InstrumentRequest{
IdType: pb.InstrumentIdType_INSTRUMENT_ID_TYPE_UID,
Id: instrumentUID,
})
})
})
if err != nil {
return 0, err
}
instrument := resp.GetInstrument()
if instrument == nil || instrument.GetLot() <= 0 {
return 0, fmt.Errorf("portfolio lot size is unavailable for %s", instrumentUID)
}
lot := int64(instrument.GetLot())
g.instrumentLots.Store(instrumentUID, lot)
return lot, nil
}
func portfolioDeprecatedQuantityLots(position *pb.PortfolioPosition) (decimal.Decimal, bool) {
message := position.ProtoReflect()
field := message.Descriptor().Fields().ByName("quantity_lots")
+84 -3
View File
@@ -47,11 +47,11 @@ func TestPortfolioFromResponseConvertsUnitsToLots(t *testing.T) {
CurrentPrice: &pb.MoneyValue{Currency: "rub", Units: 10},
},
},
}, func(instrumentUID string) int64 {
}, func(instrumentUID string) (int64, error) {
if instrumentUID == "uid" {
return 10
return 10, nil
}
return 0
return 0, nil
})
if err != nil {
t.Fatal(err)
@@ -63,3 +63,84 @@ func TestPortfolioFromResponseConvertsUnitsToLots(t *testing.T) {
t.Fatalf("market value=%s, want 200", portfolio.Holdings[0].MarketValue)
}
}
func TestPortfolioFromResponseRejectsUnknownLotWhenQuantityLotsMissing(t *testing.T) {
_, err := portfolioFromResponse(&pb.PortfolioResponse{
Positions: []*pb.PortfolioPosition{
{
InstrumentUid: "uid",
Quantity: &pb.Quotation{Units: 20},
CurrentPrice: &pb.MoneyValue{Currency: "rub", Units: 10},
},
},
}, func(string) (int64, error) {
return 0, nil
})
if err == nil {
t.Fatal("expected unknown lot error")
}
}
func TestPortfolioFromResponseIgnoresCurrencyPositions(t *testing.T) {
portfolio, err := portfolioFromResponse(&pb.PortfolioResponse{
Positions: []*pb.PortfolioPosition{
{
InstrumentUid: "rub",
InstrumentType: "currency",
Quantity: &pb.Quotation{Units: 1000},
CurrentPrice: &pb.MoneyValue{Currency: "rub", Units: 1},
QuantityLots: &pb.Quotation{Units: 1000},
AveragePositionPrice: &pb.MoneyValue{Currency: "rub", Units: 1},
},
},
}, func(string) (int64, error) {
return 0, nil
})
if err != nil {
t.Fatal(err)
}
if len(portfolio.Holdings) != 0 {
t.Fatalf("currency position should be excluded from holdings: %+v", portfolio.Holdings)
}
}
func TestOperationsFromResponseAttributesCommissionChildUID(t *testing.T) {
ops := operationsFromResponse(&pb.OperationsResponse{
Operations: []*pb.Operation{{
Id: "fee",
OperationType: pb.OperationType_OPERATION_TYPE_BROKER_FEE,
Payment: &pb.MoneyValue{Currency: "rub", Units: -1},
ChildOperations: []*pb.ChildOperationItem{{
InstrumentUid: "uid",
Payment: &pb.MoneyValue{Currency: "rub", Units: -1},
}},
}},
})
if len(ops) != 1 {
t.Fatalf("operations=%d, want 1", len(ops))
}
if ops[0].InstrumentUID != "uid" {
t.Fatalf("instrument uid=%q, want uid", ops[0].InstrumentUID)
}
if !ops[0].Commission.Equal(decimal.NewFromInt(1)) {
t.Fatalf("commission=%s, want 1", ops[0].Commission)
}
}
func TestOperationsFromCursorResponseUsesExplicitCommission(t *testing.T) {
ops := operationsFromCursorResponse(&pb.GetOperationsByCursorResponse{
Items: []*pb.OperationItem{{
Id: "trade",
Type: pb.OperationType_OPERATION_TYPE_BUY,
InstrumentUid: "uid",
Payment: &pb.MoneyValue{Currency: "rub", Units: -100},
Commission: &pb.MoneyValue{Currency: "rub", Units: -1},
}},
})
if len(ops) != 1 {
t.Fatalf("operations=%d, want 1", len(ops))
}
if !ops[0].Commission.Equal(decimal.NewFromInt(1)) {
t.Fatalf("commission=%s, want 1", ops[0].Commission)
}
}
+3 -1
View File
@@ -135,7 +135,9 @@ func (g *SandboxGateway) GetPortfolio(ctx context.Context, accountID string) (do
if err != nil {
return domain.Portfolio{}, err
}
return portfolioFromResponse(resp, g.lotForInstrument)
return portfolioFromResponse(resp, func(instrumentUID string) (int64, error) {
return g.resolveInstrumentLot(ctx, instrumentUID)
})
}
func (g *SandboxGateway) GetOperations(ctx context.Context, accountID string, from, to time.Time) ([]domain.Operation, error) {