first version
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
package tinvest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
|
||||
"overnight-trading-bot/internal/domain"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
type Gateway interface {
|
||||
GetInstrument(ctx context.Context, ticker, classCode string) (domain.Instrument, error)
|
||||
GetCandles(ctx context.Context, instrumentUID string, interval string, from, to time.Time) ([]domain.Candle, error)
|
||||
GetOrderBook(ctx context.Context, instrumentUID string, depth int32) (domain.OrderBook, error)
|
||||
GetTradingStatus(ctx context.Context, instrumentUID string) (domain.TradingStatus, error)
|
||||
PostLimitOrder(ctx context.Context, accountID, instrumentUID string, side domain.Side, lots int64, price decimal.Decimal, clientOrderID string) (domain.Order, error)
|
||||
CancelOrder(ctx context.Context, accountID, orderID string) error
|
||||
GetOrderState(ctx context.Context, accountID, orderID string) (domain.Order, error)
|
||||
GetActiveOrders(ctx context.Context, accountID string) ([]domain.Order, error)
|
||||
GetPortfolio(ctx context.Context, accountID string) (domain.Portfolio, error)
|
||||
GetOperations(ctx context.Context, accountID string, from, to time.Time) ([]domain.Operation, error)
|
||||
GetServerTime(ctx context.Context) (time.Time, error)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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),
|
||||
Portfolio: domain.Portfolio{
|
||||
Equity: decimal.NewFromInt(100_000),
|
||||
Cash: decimal.NewFromInt(100_000),
|
||||
CheckedAt: time.Now().UTC(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *FakeGateway) GetInstrument(_ context.Context, ticker, classCode string) (domain.Instrument, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, instrument := range f.Instruments {
|
||||
if instrument.Ticker == ticker && instrument.ClassCode == classCode {
|
||||
return instrument, nil
|
||||
}
|
||||
}
|
||||
return domain.Instrument{}, ErrNotFound
|
||||
}
|
||||
|
||||
func (f *FakeGateway) GetCandles(_ context.Context, instrumentUID string, _ string, from, to time.Time) ([]domain.Candle, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []domain.Candle
|
||||
for _, candle := range f.Candles[instrumentUID] {
|
||||
if !candle.TradeDate.Before(from) && !candle.TradeDate.After(to) {
|
||||
out = append(out, candle)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *FakeGateway) GetOrderBook(_ context.Context, instrumentUID string, _ int32) (domain.OrderBook, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
book, ok := f.OrderBooks[instrumentUID]
|
||||
if !ok {
|
||||
return domain.OrderBook{}, ErrNotFound
|
||||
}
|
||||
return book, nil
|
||||
}
|
||||
|
||||
func (f *FakeGateway) GetTradingStatus(_ context.Context, instrumentUID string) (domain.TradingStatus, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
status, ok := f.Statuses[instrumentUID]
|
||||
if !ok {
|
||||
return domain.TradingStatusNormal, nil
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (f *FakeGateway) PostLimitOrder(_ context.Context, accountID, instrumentUID string, side domain.Side, lots int64, price decimal.Decimal, clientOrderID string) (domain.Order, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
order := domain.Order{
|
||||
ClientOrderID: clientOrderID,
|
||||
BrokerOrderID: "fake-" + clientOrderID,
|
||||
AccountIDHash: accountID,
|
||||
InstrumentUID: instrumentUID,
|
||||
Side: side,
|
||||
OrderType: domain.OrderTypeLimit,
|
||||
LimitPrice: price,
|
||||
QuantityLots: lots,
|
||||
Status: domain.OrderStatusSent,
|
||||
RawStateJSON: "{}",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
f.Orders[order.BrokerOrderID] = order
|
||||
return order, nil
|
||||
}
|
||||
|
||||
func (f *FakeGateway) CancelOrder(_ context.Context, _ string, orderID string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
order, ok := f.Orders[orderID]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
order.Status = domain.OrderStatusCancelled
|
||||
order.UpdatedAt = time.Now().UTC()
|
||||
f.Orders[orderID] = order
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *FakeGateway) GetOrderState(_ context.Context, _ string, orderID string) (domain.Order, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
order, ok := f.Orders[orderID]
|
||||
if !ok {
|
||||
return domain.Order{}, ErrNotFound
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
func (f *FakeGateway) GetActiveOrders(_ context.Context, _ string) ([]domain.Order, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := make([]domain.Order, 0)
|
||||
for _, order := range f.Orders {
|
||||
if order.Status == domain.OrderStatusSent || order.Status == domain.OrderStatusPartiallyFilled {
|
||||
out = append(out, order)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *FakeGateway) GetPortfolio(_ context.Context, _ string) (domain.Portfolio, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.Portfolio.CheckedAt = time.Now().UTC()
|
||||
return f.Portfolio, nil
|
||||
}
|
||||
|
||||
func (f *FakeGateway) GetOperations(_ context.Context, _ string, from, to time.Time) ([]domain.Operation, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []domain.Operation
|
||||
for _, op := range f.Operations {
|
||||
if !op.ExecutedAt.Before(from) && !op.ExecutedAt.After(to) {
|
||||
out = append(out, op)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *FakeGateway) GetServerTime(context.Context) (time.Time, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.ServerTime.IsZero() {
|
||||
return time.Now().UTC(), nil
|
||||
}
|
||||
return f.ServerTime, nil
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
package tinvest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/russianinvestments/invest-api-go-sdk/investgo"
|
||||
pb "github.com/russianinvestments/invest-api-go-sdk/proto"
|
||||
"github.com/shopspring/decimal"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"overnight-trading-bot/internal/domain"
|
||||
"overnight-trading-bot/internal/logging"
|
||||
"overnight-trading-bot/internal/money"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Token string
|
||||
AccountID string
|
||||
Endpoint string
|
||||
AppName string
|
||||
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
|
||||
}
|
||||
|
||||
func NewRealGateway(ctx context.Context, opts Options) (*RealGateway, error) {
|
||||
if opts.Token == "" {
|
||||
return nil, fmt.Errorf("tinvest token is required")
|
||||
}
|
||||
client, err := investgo.NewClient(ctx, investgo.Config{
|
||||
EndPoint: opts.Endpoint,
|
||||
Token: opts.Token,
|
||||
AppName: opts.AppName,
|
||||
AccountId: opts.AccountID,
|
||||
MaxRetries: 0,
|
||||
}, logging.SDKLogger{Logger: opts.Logger})
|
||||
if err != nil {
|
||||
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,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (g *RealGateway) Close() error {
|
||||
if g.client == nil || g.client.Conn == nil {
|
||||
return nil
|
||||
}
|
||||
return g.client.Conn.Close()
|
||||
}
|
||||
|
||||
func (g *RealGateway) GetInstrument(ctx context.Context, ticker, classCode string) (domain.Instrument, error) {
|
||||
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)
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Instrument{}, err
|
||||
}
|
||||
etf := resp.GetInstrument()
|
||||
if etf == nil {
|
||||
return domain.Instrument{}, ErrNotFound
|
||||
}
|
||||
return domain.Instrument{
|
||||
InstrumentUID: etf.GetUid(),
|
||||
Figi: etf.GetFigi(),
|
||||
Ticker: etf.GetTicker(),
|
||||
ClassCode: etf.GetClassCode(),
|
||||
Name: etf.GetName(),
|
||||
Lot: int64(etf.GetLot()),
|
||||
MinPriceIncrement: money.QuotationToDecimal(etf.GetMinPriceIncrement()),
|
||||
Currency: strings.ToUpper(etf.GetCurrency()),
|
||||
Enabled: etf.GetApiTradeAvailableFlag() && etf.GetBuyAvailableFlag() && etf.GetSellAvailableFlag(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (g *RealGateway) GetCandles(ctx context.Context, instrumentUID string, interval string, from, to time.Time) ([]domain.Candle, error) {
|
||||
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)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
candles := resp.GetCandles()
|
||||
out := make([]domain.Candle, 0, len(candles))
|
||||
for _, candle := range candles {
|
||||
out = append(out, domain.Candle{
|
||||
InstrumentUID: instrumentUID,
|
||||
TradeDate: candle.GetTime().AsTime().UTC(),
|
||||
Open: money.QuotationToDecimal(candle.GetOpen()),
|
||||
High: money.QuotationToDecimal(candle.GetHigh()),
|
||||
Low: money.QuotationToDecimal(candle.GetLow()),
|
||||
Close: money.QuotationToDecimal(candle.GetClose()),
|
||||
VolumeLots: decimal.NewFromInt(candle.GetVolume()),
|
||||
Source: "tinvest",
|
||||
LoadedAt: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (g *RealGateway) GetOrderBook(ctx context.Context, instrumentUID string, depth int32) (domain.OrderBook, error) {
|
||||
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)
|
||||
})
|
||||
if err != nil {
|
||||
return domain.OrderBook{}, err
|
||||
}
|
||||
return domain.OrderBook{
|
||||
InstrumentUID: instrumentUID,
|
||||
Bids: orderBookLevels(resp.GetBids()),
|
||||
Asks: orderBookLevels(resp.GetAsks()),
|
||||
Time: resp.GetOrderbookTs().AsTime().UTC(),
|
||||
ReceivedAt: time.Now().UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (g *RealGateway) GetTradingStatus(ctx context.Context, instrumentUID string) (domain.TradingStatus, error) {
|
||||
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)
|
||||
})
|
||||
if err != nil {
|
||||
return domain.TradingStatusUnknown, err
|
||||
}
|
||||
if resp.GetTradingStatus() == pb.SecurityTradingStatus_SECURITY_TRADING_STATUS_NORMAL_TRADING &&
|
||||
resp.GetLimitOrderAvailableFlag() &&
|
||||
resp.GetApiTradeAvailableFlag() {
|
||||
return domain.TradingStatusNormal, nil
|
||||
}
|
||||
return domain.TradingStatusClosed, nil
|
||||
}
|
||||
|
||||
func (g *RealGateway) PostLimitOrder(ctx context.Context, accountID, instrumentUID string, side domain.Side, lots int64, price decimal.Decimal, clientOrderID string) (domain.Order, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return domain.Order{}, err
|
||||
}
|
||||
direction := pb.OrderDirection_ORDER_DIRECTION_BUY
|
||||
if side == domain.SideSell {
|
||||
direction = pb.OrderDirection_ORDER_DIRECTION_SELL
|
||||
}
|
||||
resp, err := retryValue(ctx, g.retryAttempts, g.retryBackoff, func() (*investgo.PostOrderResponse, error) {
|
||||
return g.orders.PostOrder(&investgo.PostOrderRequest{
|
||||
InstrumentId: instrumentUID,
|
||||
Quantity: lots,
|
||||
Price: money.DecimalToQuotation(price),
|
||||
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 {
|
||||
return domain.Order{}, err
|
||||
}
|
||||
return orderFromPostResponse(resp.PostOrderResponse, accountID, clientOrderID, side, price), nil
|
||||
}
|
||||
|
||||
func (g *RealGateway) CancelOrder(ctx context.Context, accountID, orderID string) error {
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Order{}, err
|
||||
}
|
||||
return orderFromState(resp.OrderState, accountID), nil
|
||||
}
|
||||
|
||||
func (g *RealGateway) GetActiveOrders(ctx context.Context, accountID string) ([]domain.Order, error) {
|
||||
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)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
states := resp.GetOrders()
|
||||
out := make([]domain.Order, 0, len(states))
|
||||
for _, state := range states {
|
||||
out = append(out, orderFromState(state, accountID))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (g *RealGateway) GetPortfolio(ctx context.Context, accountID string) (domain.Portfolio, error) {
|
||||
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)
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Portfolio{}, err
|
||||
}
|
||||
positions := resp.GetPositions()
|
||||
holdings := make([]domain.Holding, 0, len(positions))
|
||||
for _, position := range positions {
|
||||
holdings = append(holdings, domain.Holding{
|
||||
InstrumentUID: position.GetInstrumentUid(),
|
||||
QuantityLots: money.QuotationToDecimal(position.GetQuantity()).IntPart(),
|
||||
AveragePrice: money.MoneyValueToDecimal(position.GetAveragePositionPrice()),
|
||||
MarketValue: money.MoneyValueToDecimal(position.GetCurrentPrice()).Mul(money.QuotationToDecimal(position.GetQuantity())),
|
||||
})
|
||||
}
|
||||
equity, err := rubMoneyValueToDecimal(resp.GetTotalAmountPortfolio())
|
||||
if err != nil {
|
||||
return domain.Portfolio{}, err
|
||||
}
|
||||
cash, err := rubMoneyValueToDecimal(resp.GetTotalAmountCurrencies())
|
||||
if err != nil {
|
||||
return domain.Portfolio{}, err
|
||||
}
|
||||
return domain.Portfolio{
|
||||
Equity: equity,
|
||||
Cash: cash,
|
||||
Holdings: holdings,
|
||||
CheckedAt: time.Now().UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
resp, err := 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 {
|
||||
return nil, err
|
||||
}
|
||||
ops := resp.GetOperations()
|
||||
out := make([]domain.Operation, 0, len(ops))
|
||||
for _, op := range ops {
|
||||
payment := money.MoneyValueToDecimal(op.GetPayment())
|
||||
out = append(out, domain.Operation{
|
||||
ID: op.GetId(),
|
||||
InstrumentUID: op.GetInstrumentUid(),
|
||||
Type: op.GetOperationType().String(),
|
||||
Payment: payment,
|
||||
Commission: operationCommission(op.GetOperationType(), payment),
|
||||
ExecutedAt: op.GetDate().AsTime().UTC(),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
if serverTime, ok := serverTimeFromHeader(resp.Header); ok {
|
||||
return serverTime, nil
|
||||
}
|
||||
return time.Time{}, errors.New("server time is unavailable in response metadata")
|
||||
}
|
||||
|
||||
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 {
|
||||
return decimal.Zero
|
||||
}
|
||||
return money.Abs(payment)
|
||||
}
|
||||
|
||||
func rubMoneyValueToDecimal(value *pb.MoneyValue) (decimal.Decimal, error) {
|
||||
if value == nil {
|
||||
return decimal.Zero, nil
|
||||
}
|
||||
if currency := strings.ToUpper(value.GetCurrency()); currency != "" && currency != "RUB" {
|
||||
return decimal.Zero, fmt.Errorf("expected RUB money value, got %s", currency)
|
||||
}
|
||||
return money.MoneyValueToDecimal(value), nil
|
||||
}
|
||||
|
||||
func serverTimeFromHeader(header map[string][]string) (time.Time, bool) {
|
||||
for _, key := range []string{"date", "Date"} {
|
||||
values := header[key]
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
parsed, err := http.ParseTime(values[0])
|
||||
if err == nil {
|
||||
return parsed.UTC(), true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func candleInterval(interval string) pb.CandleInterval {
|
||||
switch strings.ToLower(interval) {
|
||||
case "minute", "1m", "1min":
|
||||
return pb.CandleInterval_CANDLE_INTERVAL_1_MIN
|
||||
default:
|
||||
return pb.CandleInterval_CANDLE_INTERVAL_DAY
|
||||
}
|
||||
}
|
||||
|
||||
func orderBookLevels(levels []*pb.Order) []domain.OrderBookLevel {
|
||||
out := make([]domain.OrderBookLevel, 0, len(levels))
|
||||
for _, level := range levels {
|
||||
out = append(out, domain.OrderBookLevel{
|
||||
Price: money.QuotationToDecimal(level.GetPrice()),
|
||||
QuantityLots: level.GetQuantity(),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func orderFromPostResponse(resp *pb.PostOrderResponse, accountID, clientOrderID string, side domain.Side, limitPrice decimal.Decimal) domain.Order {
|
||||
if resp == nil {
|
||||
return domain.Order{}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
return domain.Order{
|
||||
ClientOrderID: clientOrderID,
|
||||
BrokerOrderID: resp.GetOrderId(),
|
||||
AccountIDHash: accountID,
|
||||
InstrumentUID: resp.GetInstrumentUid(),
|
||||
Side: side,
|
||||
OrderType: domain.OrderTypeLimit,
|
||||
LimitPrice: limitPrice,
|
||||
QuantityLots: resp.GetLotsRequested(),
|
||||
FilledLots: resp.GetLotsExecuted(),
|
||||
AvgFillPrice: limitPrice,
|
||||
Status: mapOrderStatus(resp.GetExecutionReportStatus()),
|
||||
Commission: money.MoneyValueToDecimal(resp.GetExecutedCommission()),
|
||||
RawStateJSON: marshalProto(resp),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func orderFromState(state *pb.OrderState, accountID string) domain.Order {
|
||||
if state == nil {
|
||||
return domain.Order{}
|
||||
}
|
||||
side := domain.SideBuy
|
||||
if state.GetDirection() == pb.OrderDirection_ORDER_DIRECTION_SELL {
|
||||
side = domain.SideSell
|
||||
}
|
||||
orderDate := time.Now().UTC()
|
||||
if state.GetOrderDate() != nil {
|
||||
orderDate = state.GetOrderDate().AsTime().UTC()
|
||||
}
|
||||
return domain.Order{
|
||||
ClientOrderID: state.GetOrderRequestId(),
|
||||
BrokerOrderID: state.GetOrderId(),
|
||||
AccountIDHash: accountID,
|
||||
InstrumentUID: state.GetInstrumentUid(),
|
||||
Side: side,
|
||||
OrderType: domain.OrderTypeLimit,
|
||||
LimitPrice: money.MoneyValueToDecimal(state.GetInitialSecurityPrice()),
|
||||
QuantityLots: state.GetLotsRequested(),
|
||||
FilledLots: state.GetLotsExecuted(),
|
||||
AvgFillPrice: money.MoneyValueToDecimal(state.GetAveragePositionPrice()),
|
||||
Status: mapOrderStatus(state.GetExecutionReportStatus()),
|
||||
Commission: money.MoneyValueToDecimal(state.GetExecutedCommission()),
|
||||
RawStateJSON: marshalProto(state),
|
||||
CreatedAt: orderDate,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func mapOrderStatus(status pb.OrderExecutionReportStatus) domain.OrderStatus {
|
||||
switch status {
|
||||
case pb.OrderExecutionReportStatus_EXECUTION_REPORT_STATUS_FILL:
|
||||
return domain.OrderStatusFilled
|
||||
case pb.OrderExecutionReportStatus_EXECUTION_REPORT_STATUS_PARTIALLYFILL:
|
||||
return domain.OrderStatusPartiallyFilled
|
||||
case pb.OrderExecutionReportStatus_EXECUTION_REPORT_STATUS_CANCELLED:
|
||||
return domain.OrderStatusCancelled
|
||||
case pb.OrderExecutionReportStatus_EXECUTION_REPORT_STATUS_REJECTED:
|
||||
return domain.OrderStatusRejected
|
||||
case pb.OrderExecutionReportStatus_EXECUTION_REPORT_STATUS_NEW:
|
||||
return domain.OrderStatusSent
|
||||
default:
|
||||
return domain.OrderStatusNew
|
||||
}
|
||||
}
|
||||
|
||||
func marshalProto(msg proto.Message) string {
|
||||
if msg == nil {
|
||||
return "{}"
|
||||
}
|
||||
raw, err := protojson.Marshal(msg)
|
||||
if err != nil {
|
||||
fallback, _ := json.Marshal(map[string]string{"marshal_error": err.Error()})
|
||||
return string(fallback)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package tinvest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
backofflib "github.com/cenkalti/backoff/v4"
|
||||
)
|
||||
|
||||
func withRetry(ctx context.Context, attempts int, interval time.Duration, fn func() error) error {
|
||||
if attempts <= 0 {
|
||||
attempts = 1
|
||||
}
|
||||
if interval < 0 {
|
||||
interval = 0
|
||||
}
|
||||
policy := backofflib.NewExponentialBackOff()
|
||||
policy.InitialInterval = interval
|
||||
policy.MaxInterval = interval * 8
|
||||
policy.Multiplier = 2
|
||||
policy.MaxElapsedTime = 0
|
||||
policy.Reset()
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < attempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fn(); err != nil {
|
||||
lastErr = err
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
if attempt == attempts-1 || interval <= 0 {
|
||||
continue
|
||||
}
|
||||
timer := time.NewTimer(policy.NextBackOff())
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-ctx.Done():
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func retryValue[T any](ctx context.Context, attempts int, interval time.Duration, fn func() (T, error)) (T, error) {
|
||||
var out T
|
||||
err := withRetry(ctx, attempts, interval, func() error {
|
||||
var err error
|
||||
out, err = fn()
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
var zero T
|
||||
return zero, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package tinvest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWithRetryRetriesUntilSuccess(t *testing.T) {
|
||||
attempts := 0
|
||||
err := withRetry(context.Background(), 3, 0, func() error {
|
||||
attempts++
|
||||
if attempts < 3 {
|
||||
return errors.New("temporary")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if attempts != 3 {
|
||||
t.Fatalf("attempts=%d, want 3", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithRetryStopsOnContextCancel(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
attempts := 0
|
||||
err := withRetry(ctx, 3, time.Millisecond, func() error {
|
||||
attempts++
|
||||
return errors.New("temporary")
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err=%v, want context.Canceled", err)
|
||||
}
|
||||
if attempts != 0 {
|
||||
t.Fatalf("attempts=%d, want 0", attempts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package tinvest
|
||||
|
||||
import "context"
|
||||
|
||||
const sandboxEndpoint = "sandbox-invest-public-api.tinkoff.ru:443"
|
||||
|
||||
func NewSandboxGateway(ctx context.Context, opts Options) (*RealGateway, error) {
|
||||
opts.Endpoint = sandboxEndpoint
|
||||
return NewRealGateway(ctx, opts)
|
||||
}
|
||||
Reference in New Issue
Block a user