sixth version

This commit is contained in:
2026-06-08 09:41:20 +00:00
parent 2d57c4ff1f
commit 8a552dec56
25 changed files with 545 additions and 130 deletions
+85
View File
@@ -0,0 +1,85 @@
package tinvest
import (
"context"
"time"
"github.com/shopspring/decimal"
"overnight-trading-bot/internal/domain"
)
type PaperGateway struct {
market Gateway
fake *FakeGateway
}
func NewPaperGateway(market Gateway) *PaperGateway {
return &PaperGateway{market: market, fake: NewFakeGateway()}
}
func (g *PaperGateway) Fake() *FakeGateway {
if g.fake == nil {
g.fake = NewFakeGateway()
}
return g.fake
}
func (g *PaperGateway) GetInstrument(ctx context.Context, ticker, classCode string) (domain.Instrument, error) {
if g.market != nil {
return g.market.GetInstrument(ctx, ticker, classCode)
}
return g.Fake().GetInstrument(ctx, ticker, classCode)
}
func (g *PaperGateway) GetCandles(ctx context.Context, instrumentUID string, interval string, from, to time.Time) ([]domain.Candle, error) {
if g.market != nil {
return g.market.GetCandles(ctx, instrumentUID, interval, from, to)
}
return g.Fake().GetCandles(ctx, instrumentUID, interval, from, to)
}
func (g *PaperGateway) GetOrderBook(ctx context.Context, instrumentUID string, depth int32) (domain.OrderBook, error) {
if g.market != nil {
return g.market.GetOrderBook(ctx, instrumentUID, depth)
}
return g.Fake().GetOrderBook(ctx, instrumentUID, depth)
}
func (g *PaperGateway) GetTradingStatus(ctx context.Context, instrumentUID string) (domain.TradingStatus, error) {
if g.market != nil {
return g.market.GetTradingStatus(ctx, instrumentUID)
}
return g.Fake().GetTradingStatus(ctx, instrumentUID)
}
func (g *PaperGateway) PostLimitOrder(ctx context.Context, accountID, instrumentUID string, side domain.Side, lots int64, price decimal.Decimal, clientOrderID string) (domain.Order, error) {
return g.Fake().PostLimitOrder(ctx, accountID, instrumentUID, side, lots, price, clientOrderID)
}
func (g *PaperGateway) CancelOrder(ctx context.Context, accountID, orderID string) error {
return g.Fake().CancelOrder(ctx, accountID, orderID)
}
func (g *PaperGateway) GetOrderState(ctx context.Context, accountID, orderID string) (domain.Order, error) {
return g.Fake().GetOrderState(ctx, accountID, orderID)
}
func (g *PaperGateway) GetActiveOrders(ctx context.Context, accountID string) ([]domain.Order, error) {
return g.Fake().GetActiveOrders(ctx, accountID)
}
func (g *PaperGateway) GetPortfolio(ctx context.Context, accountID string) (domain.Portfolio, error) {
return g.Fake().GetPortfolio(ctx, accountID)
}
func (g *PaperGateway) GetOperations(ctx context.Context, accountID string, from, to time.Time) ([]domain.Operation, error) {
return g.Fake().GetOperations(ctx, accountID, from, to)
}
func (g *PaperGateway) GetServerTime(ctx context.Context) (time.Time, error) {
if g.market != nil {
return g.market.GetServerTime(ctx)
}
return g.Fake().GetServerTime(ctx)
}
+84 -58
View File
@@ -23,24 +23,26 @@ import (
)
type Options struct {
Token string
AccountID string
Endpoint string
AppName string
RetryCount int
RetryBackoff time.Duration
Logger *slog.Logger
Token string
AccountID string
Endpoint string
AppName string
RequestTimeout time.Duration
RetryCount int
RetryBackoff time.Duration
Logger *slog.Logger
}
type RealGateway struct {
client *investgo.Client
instruments *investgo.InstrumentsServiceClient
marketData *investgo.MarketDataServiceClient
orders *investgo.OrdersServiceClient
operations *investgo.OperationsServiceClient
users *investgo.UsersServiceClient
retryAttempts int
retryBackoff time.Duration
client *investgo.Client
instruments *investgo.InstrumentsServiceClient
marketData *investgo.MarketDataServiceClient
orders *investgo.OrdersServiceClient
operations *investgo.OperationsServiceClient
users *investgo.UsersServiceClient
requestTimeout time.Duration
retryAttempts int
retryBackoff time.Duration
}
func NewRealGateway(ctx context.Context, opts Options) (*RealGateway, error) {
@@ -58,14 +60,15 @@ func NewRealGateway(ctx context.Context, opts Options) (*RealGateway, error) {
return nil, err
}
return &RealGateway{
client: client,
instruments: client.NewInstrumentsServiceClient(),
marketData: client.NewMarketDataServiceClient(),
orders: client.NewOrdersServiceClient(),
operations: client.NewOperationsServiceClient(),
users: client.NewUsersServiceClient(),
retryAttempts: opts.RetryCount,
retryBackoff: opts.RetryBackoff,
client: client,
instruments: client.NewInstrumentsServiceClient(),
marketData: client.NewMarketDataServiceClient(),
orders: client.NewOrdersServiceClient(),
operations: client.NewOperationsServiceClient(),
users: client.NewUsersServiceClient(),
requestTimeout: opts.RequestTimeout,
retryAttempts: opts.RetryCount,
retryBackoff: opts.RetryBackoff,
}, nil
}
@@ -80,8 +83,10 @@ func (g *RealGateway) GetInstrument(ctx context.Context, ticker, classCode strin
if err := ctx.Err(); err != nil {
return domain.Instrument{}, err
}
resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.EtfResponse, error) {
return g.instruments.EtfByTicker(ticker, classCode)
resp, err := requestWithTimeout(ctx, g.requestTimeout, func() (*investgo.EtfResponse, error) {
return retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.EtfResponse, error) {
return g.instruments.EtfByTicker(ticker, classCode)
})
})
if err != nil {
return domain.Instrument{}, err
@@ -108,8 +113,10 @@ func (g *RealGateway) GetCandles(ctx context.Context, instrumentUID string, inte
if err := ctx.Err(); err != nil {
return nil, err
}
resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.GetCandlesResponse, error) {
return g.marketData.GetCandles(instrumentUID, candleInterval(interval), from, to, pb.GetCandlesRequest_CANDLE_SOURCE_EXCHANGE, 0)
resp, err := requestWithTimeout(ctx, g.requestTimeout, func() (*investgo.GetCandlesResponse, error) {
return retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.GetCandlesResponse, error) {
return g.marketData.GetCandles(instrumentUID, candleInterval(interval), from, to, pb.GetCandlesRequest_CANDLE_SOURCE_EXCHANGE, 0)
})
})
if err != nil {
return nil, err
@@ -136,8 +143,10 @@ func (g *RealGateway) GetOrderBook(ctx context.Context, instrumentUID string, de
if err := ctx.Err(); err != nil {
return domain.OrderBook{}, err
}
resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.GetOrderBookResponse, error) {
return g.marketData.GetOrderBook(instrumentUID, depth)
resp, err := requestWithTimeout(ctx, g.requestTimeout, func() (*investgo.GetOrderBookResponse, error) {
return retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.GetOrderBookResponse, error) {
return g.marketData.GetOrderBook(instrumentUID, depth)
})
})
if err != nil {
return domain.OrderBook{}, err
@@ -155,8 +164,10 @@ func (g *RealGateway) GetTradingStatus(ctx context.Context, instrumentUID string
if err := ctx.Err(); err != nil {
return domain.TradingStatusUnknown, err
}
resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.GetTradingStatusResponse, error) {
return g.marketData.GetTradingStatus(instrumentUID)
resp, err := requestWithTimeout(ctx, g.requestTimeout, func() (*investgo.GetTradingStatusResponse, error) {
return retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.GetTradingStatusResponse, error) {
return g.marketData.GetTradingStatus(instrumentUID)
})
})
if err != nil {
return domain.TradingStatusUnknown, err
@@ -181,17 +192,19 @@ func (g *RealGateway) PostLimitOrder(ctx context.Context, accountID, instrumentU
if err != nil {
return domain.Order{}, err
}
resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.PostOrderResponse, error) {
return g.orders.PostOrder(&investgo.PostOrderRequest{
InstrumentId: instrumentUID,
Quantity: lots,
Price: quotation,
Direction: direction,
AccountId: accountID,
OrderType: pb.OrderType_ORDER_TYPE_LIMIT,
OrderId: clientOrderID,
TimeInForce: pb.TimeInForceType_TIME_IN_FORCE_DAY,
PriceType: pb.PriceType_PRICE_TYPE_CURRENCY,
resp, err := requestWithTimeout(ctx, g.requestTimeout, func() (*investgo.PostOrderResponse, error) {
return retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.PostOrderResponse, error) {
return g.orders.PostOrder(&investgo.PostOrderRequest{
InstrumentId: instrumentUID,
Quantity: lots,
Price: quotation,
Direction: direction,
AccountId: accountID,
OrderType: pb.OrderType_ORDER_TYPE_LIMIT,
OrderId: clientOrderID,
TimeInForce: pb.TimeInForceType_TIME_IN_FORCE_DAY,
PriceType: pb.PriceType_PRICE_TYPE_CURRENCY,
})
})
})
if err != nil {
@@ -204,18 +217,23 @@ func (g *RealGateway) CancelOrder(ctx context.Context, accountID, orderID string
if err := ctx.Err(); err != nil {
return err
}
return withRetry(ctx, g.retryAttempts, g.retryBackoff, func() error {
_, err := g.orders.CancelOrder(accountID, orderID, nil)
return err
_, err := requestWithTimeout(ctx, g.requestTimeout, func() (struct{}, error) {
return struct{}{}, withRetry(ctx, g.retryAttempts, g.retryBackoff, func() error {
_, err := g.orders.CancelOrder(accountID, orderID, nil)
return err
})
})
return err
}
func (g *RealGateway) GetOrderState(ctx context.Context, accountID, orderID string) (domain.Order, error) {
if err := ctx.Err(); err != nil {
return domain.Order{}, err
}
resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.GetOrderStateResponse, error) {
return g.orders.GetOrderState(accountID, orderID, pb.PriceType_PRICE_TYPE_CURRENCY, nil)
resp, err := requestWithTimeout(ctx, g.requestTimeout, func() (*investgo.GetOrderStateResponse, error) {
return retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.GetOrderStateResponse, error) {
return g.orders.GetOrderState(accountID, orderID, pb.PriceType_PRICE_TYPE_CURRENCY, nil)
})
})
if err != nil {
return domain.Order{}, err
@@ -227,8 +245,10 @@ func (g *RealGateway) GetActiveOrders(ctx context.Context, accountID string) ([]
if err := ctx.Err(); err != nil {
return nil, err
}
resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.GetOrdersResponse, error) {
return g.orders.GetOrders(accountID, nil)
resp, err := requestWithTimeout(ctx, g.requestTimeout, func() (*investgo.GetOrdersResponse, error) {
return retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.GetOrdersResponse, error) {
return g.orders.GetOrders(accountID, nil)
})
})
if err != nil {
return nil, err
@@ -245,8 +265,10 @@ func (g *RealGateway) GetPortfolio(ctx context.Context, accountID string) (domai
if err := ctx.Err(); err != nil {
return domain.Portfolio{}, err
}
resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.PortfolioResponse, error) {
return g.operations.GetPortfolio(accountID, pb.PortfolioRequest_RUB)
resp, err := requestWithTimeout(ctx, g.requestTimeout, func() (*investgo.PortfolioResponse, error) {
return retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.PortfolioResponse, error) {
return g.operations.GetPortfolio(accountID, pb.PortfolioRequest_RUB)
})
})
if err != nil {
return domain.Portfolio{}, err
@@ -258,11 +280,13 @@ func (g *RealGateway) GetOperations(ctx context.Context, accountID string, from,
if err := ctx.Err(); err != nil {
return nil, err
}
resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.OperationsResponse, error) {
return g.operations.GetOperations(&investgo.GetOperationsRequest{
AccountId: accountID,
From: from,
To: to,
resp, err := requestWithTimeout(ctx, g.requestTimeout, func() (*investgo.OperationsResponse, error) {
return retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.OperationsResponse, error) {
return g.operations.GetOperations(&investgo.GetOperationsRequest{
AccountId: accountID,
From: from,
To: to,
})
})
})
if err != nil {
@@ -319,8 +343,10 @@ func (g *RealGateway) GetServerTime(ctx context.Context) (time.Time, error) {
if err := ctx.Err(); err != nil {
return time.Time{}, err
}
resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.GetInfoResponse, error) {
return g.users.GetInfo()
resp, err := requestWithTimeout(ctx, g.requestTimeout, func() (*investgo.GetInfoResponse, error) {
return retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.GetInfoResponse, error) {
return g.users.GetInfo()
})
})
if err != nil {
return time.Time{}, err
+24
View File
@@ -62,3 +62,27 @@ func retryValue[T any](ctx context.Context, attempts int, interval time.Duration
}
return out, nil
}
func requestWithTimeout[T any](ctx context.Context, timeout time.Duration, fn func() (T, error)) (T, error) {
if timeout <= 0 {
return fn()
}
callCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
type result struct {
value T
err error
}
done := make(chan result, 1)
go func() {
value, err := fn()
done <- result{value: value, err: err}
}()
select {
case res := <-done:
return res.value, res.err
case <-callCtx.Done():
var zero T
return zero, callCtx.Err()
}
}
+10
View File
@@ -24,6 +24,16 @@ func TestWithRetryRetriesUntilSuccess(t *testing.T) {
}
}
func TestRequestWithTimeoutReturnsDeadline(t *testing.T) {
_, err := requestWithTimeout(context.Background(), time.Millisecond, func() (int, error) {
time.Sleep(50 * time.Millisecond)
return 1, nil
})
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("err=%v, want DeadlineExceeded", err)
}
}
func TestWithRetryStopsOnContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
+38 -25
View File
@@ -43,17 +43,19 @@ func (g *SandboxGateway) PostLimitOrder(ctx context.Context, accountID, instrume
if err != nil {
return domain.Order{}, err
}
resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.PostOrderResponse, error) {
return g.sandbox.PostSandboxOrder(&investgo.PostOrderRequest{
InstrumentId: instrumentUID,
Quantity: lots,
Price: quotation,
Direction: direction,
AccountId: accountID,
OrderType: pb.OrderType_ORDER_TYPE_LIMIT,
OrderId: clientOrderID,
TimeInForce: pb.TimeInForceType_TIME_IN_FORCE_DAY,
PriceType: pb.PriceType_PRICE_TYPE_CURRENCY,
resp, err := requestWithTimeout(ctx, g.requestTimeout, func() (*investgo.PostOrderResponse, error) {
return retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.PostOrderResponse, error) {
return g.sandbox.PostSandboxOrder(&investgo.PostOrderRequest{
InstrumentId: instrumentUID,
Quantity: lots,
Price: quotation,
Direction: direction,
AccountId: accountID,
OrderType: pb.OrderType_ORDER_TYPE_LIMIT,
OrderId: clientOrderID,
TimeInForce: pb.TimeInForceType_TIME_IN_FORCE_DAY,
PriceType: pb.PriceType_PRICE_TYPE_CURRENCY,
})
})
})
if err != nil {
@@ -66,18 +68,23 @@ func (g *SandboxGateway) CancelOrder(ctx context.Context, accountID, orderID str
if err := ctx.Err(); err != nil {
return err
}
return withRetry(ctx, g.retryAttempts, g.retryBackoff, func() error {
_, err := g.sandbox.CancelSandboxOrder(accountID, orderID)
return err
_, err := requestWithTimeout(ctx, g.requestTimeout, func() (struct{}, error) {
return struct{}{}, withRetry(ctx, g.retryAttempts, g.retryBackoff, func() error {
_, err := g.sandbox.CancelSandboxOrder(accountID, orderID)
return err
})
})
return err
}
func (g *SandboxGateway) GetOrderState(ctx context.Context, accountID, orderID string) (domain.Order, error) {
if err := ctx.Err(); err != nil {
return domain.Order{}, err
}
resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.GetOrderStateResponse, error) {
return g.sandbox.GetSandboxOrderState(accountID, orderID)
resp, err := requestWithTimeout(ctx, g.requestTimeout, func() (*investgo.GetOrderStateResponse, error) {
return retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.GetOrderStateResponse, error) {
return g.sandbox.GetSandboxOrderState(accountID, orderID)
})
})
if err != nil {
return domain.Order{}, err
@@ -89,8 +96,10 @@ func (g *SandboxGateway) GetActiveOrders(ctx context.Context, accountID string)
if err := ctx.Err(); err != nil {
return nil, err
}
resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.GetOrdersResponse, error) {
return g.sandbox.GetSandboxOrders(accountID)
resp, err := requestWithTimeout(ctx, g.requestTimeout, func() (*investgo.GetOrdersResponse, error) {
return retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.GetOrdersResponse, error) {
return g.sandbox.GetSandboxOrders(accountID)
})
})
if err != nil {
return nil, err
@@ -107,8 +116,10 @@ func (g *SandboxGateway) GetPortfolio(ctx context.Context, accountID string) (do
if err := ctx.Err(); err != nil {
return domain.Portfolio{}, err
}
resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.PortfolioResponse, error) {
return g.sandbox.GetSandboxPortfolio(accountID, pb.PortfolioRequest_RUB)
resp, err := requestWithTimeout(ctx, g.requestTimeout, func() (*investgo.PortfolioResponse, error) {
return retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.PortfolioResponse, error) {
return g.sandbox.GetSandboxPortfolio(accountID, pb.PortfolioRequest_RUB)
})
})
if err != nil {
return domain.Portfolio{}, err
@@ -120,11 +131,13 @@ func (g *SandboxGateway) GetOperations(ctx context.Context, accountID string, fr
if err := ctx.Err(); err != nil {
return nil, err
}
resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.OperationsResponse, error) {
return g.sandbox.GetSandboxOperations(&investgo.GetOperationsRequest{
AccountId: accountID,
From: from,
To: to,
resp, err := requestWithTimeout(ctx, g.requestTimeout, func() (*investgo.OperationsResponse, error) {
return retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.OperationsResponse, error) {
return g.sandbox.GetSandboxOperations(&investgo.GetOperationsRequest{
AccountId: accountID,
From: from,
To: to,
})
})
})
if err != nil {