diff --git a/README.md b/README.md index 6b340dd..186483c 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ SDK предназначен для упрощения работы с API Ти * Перейдите в [настройки](https://www.tinkoff.ru/invest/settings/) * Проверьте, что функция “Подтверждение сделок кодом” отключена * Выпустите токен (если не хотите через API выдавать торговые поручения, то надо выпустить токен "только для чтения") -* Скопируйте токен и сохраните, токен отображается только один раз, просмотреть его позже не получится, тем не менее вы можете выпускать неограниченное количество токенов. Токен передается в библиотеку в методе инициализации SDKInit() +* Скопируйте токен и сохраните, токен отображается только один раз, просмотреть его позже не получится, тем не менее вы можете выпускать неограниченное количество токенов. ## Документация @@ -24,18 +24,40 @@ SDK предназначен для упрощения работы с API Ти ### Быстрый старт Для непосредственного взаимодействия с INVEST API нужно создать клиента. -Примеры использования SDK находятся в директории examples: - * `md_stream.go`, orders_stream.go, operations_stream.go - примеры работы со стримами - * `instruments.go` - примеры работы с сервисом инструментов - * `marketdata.go` - примеры работы с сервисом котировок - * `operations.go` - примеры работы с сервисом операций - * `orders.go` - примеры работы с сервисом торговых поручений - * `stop_orders` - примеры работы с сервисом стоп-заявок - * `users.go` - примеры работы с сервисом счетов - * `sandbox.go` - пример работы с песочницей - * `order_book_download/order_book.go` - пример сохранения стаканов из стрима маркетдаты в sqlite или json - -#### Конфигурация SDK +Примеры использования SDK находятся в директории examples. + +### Запуск примеров + +#### 1. Клонирование репозитория + + $ git clone https://github.com/tinkoff/invest-api-go-sdk + +#### 2. Конфигурация SDK +Перейдите в директорию с примерами + + $ cd invest-api-go-sdk/examples + +Создайте файл `config.yaml` + + $ touch "config.yaml" + +И заполните его по примеру `example.yaml` + +```yaml +AccountId: "" +APIToken: +EndPoint: sandbox-invest-public-api.tinkoff.ru:443 +AppName: invest-api-go-sdk +DisableResourceExhaustedRetry: false +DisableAllRetry: false +MaxRetries: 3 +``` + +*Для быстрого старта на песочнице достаточно указать только токен, остальное заполнится по умолчанию.* + +Так же вы можете не использовать `.yaml` файлы, а в main функции вместо `investgo.LoadConfig()` +явно создать `investgo.Config`, и заполнить его по описанию: + ```go type Config struct { // EndPoint - Для работы с реальным контуром и контуром песочницы нужны разные эндпоинты. @@ -47,7 +69,7 @@ Token string `yaml:"APIToken"` // AppName - Название вашего приложения, по умолчанию = tinkoff-api-go-sdk AppName string `yaml:"AppName"` // AccountId - Если уже есть аккаунт для апи можно указать напрямую, -// для песочницы создастся и запишется автоматически +// по умолчанию откроется новый счет в песочнице AccountId string `yaml:"AccountId"` // DisableResourceExhaustedRetry - Если true, то сдк не пытается ретраить, после получения ошибки об исчерпывании // лимита запросов, если false, то сдк ждет нужное время и пытается выполнить запрос снова. По умолчанию = false @@ -59,7 +81,14 @@ DisableAllRetry bool `yaml:"DisableAllRetry"` MaxRetries uint `yaml:"MaxRetries"` } ``` -Для проверки достаточно указать токен и запустить пример `sandbox.go` + +#### 3. Запуск +Пример использования `MarketDataStreamService`: + + $ go run md_stream.go +Загрузка стаканов из стрима: + + $ go run order_book_download/order_book.go ### Дополнительные возможности * **Загрузка исторических данных.** В рамках сервиса `Marketdata`, метод `GetHistoricCandles` возвращает список @@ -72,7 +101,9 @@ MaxRetries uint `yaml:"MaxRetries"` отключить ретраер для ошибки `ResourceExhausted`, по умолчанию он включен и в случае превышения лимитов Unary - запросов, ретраер ждет нужное время и продолжает выполнение, *при этом никакого сообщения об ошибке для клиента нет*. -#### Пример использования MarketDataStreamService +
+ Пример использования MarketDataStreamService + ```go package main @@ -172,6 +203,9 @@ func main() { } ``` + +
+ ### У меня есть вопрос [Основной репозиторий с документацией](https://github.com/Tinkoff/investAPI/) — в нем вы можете задать вопрос в Issues и получать информацию о релизах в Releases. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..de2bbd0 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,14 @@ +## Examples + +В примерах рассмотрены основные сценарии использования investAPI через пакет investgo. + +* `md_stream.go`, `orders_stream.go`, `operations_stream.go` - примеры работы со стримами +* `instruments.go` - примеры работы с сервисом инструментов +* `marketdata.go` - примеры работы с сервисом котировок +* `operations.go` - примеры работы с сервисом операций +* `orders.go` - примеры работы с сервисом торговых поручений +* `stop_orders` - примеры работы с сервисом стоп-заявок +* `users.go` - примеры работы с сервисом счетов +* `sandbox.go` - пример работы с песочницей +* `order_book_download/order_book.go` - пример сохранения стаканов из стрима маркетдаты в sqlite или json +* `ob_bot` - пример простейшего бота на стакане \ No newline at end of file diff --git a/examples/ob_bot/README.md b/examples/ob_bot/README.md new file mode 100644 index 0000000..f96e7dc --- /dev/null +++ b/examples/ob_bot/README.md @@ -0,0 +1,76 @@ +## Робот на стакане + +### Стратегия +Робот отслеживает "стакан". Если лотов в заявках на покупку больше, чем в лотах на продажу в `BuyRatio` раз, +то поступает сигнал на покупку, в противном случае, если лотов в заявках на продажу больше, чем в лотах на покупку +в `SellRatio` раз - поступает сигнал на продажу + +#### Конфигурация +```go +type OrderBookStrategyConfig struct { + // Instruments - слайс идентификаторов инструментов + Instruments []string + // Depth - Глубина стакана + Depth int32 + // Если кол-во бид/аск больше чем BuyRatio - покупаем + BuyRatio float64 + // Если кол-во аск/бид больше чем SellRatio - продаем + SellRatio float64 + // MinProfit - Минимальный процент выгоды, с которым можно совершать сделки + MinProfit float64 + // SellOut - Если true, то по достижению дедлайна бот выходит из всех активных позиций + SellOut bool +} +``` + +### Исполнитель +Под стратегию написан простейший исполнитель, который выставляет рыночные поручения. +Пока реализована возможность открывать только long позиции. + +**Покупка** + +Заявка на покупку *не* выставляется если: +* Позиция уже открыта +* На счету недостаточно денежных средств + +**Продажа** + +Заявка на продажу *не* выставляется если: +* Позиция не открыта +* Цена открытия позиции меньше цены последней сделки по этому инструменту + +### Режим работы +Данный пример ориентирован на торговлю внутри одного дня. За расписанием торгов следит `investgo.Timer`, +он сигнализирует о начале и завершении основной торговй сессии на сегодня. +При запуске main `investgo.Timer` возвращает канал с событиями, START/STOP - сигналы к запуску и остановке бота, +если выставлен флаг `SellOut` в конфигурации стратеги и время `cancelAhead` при создании таймера, то бот завершит работу и закроет все +позиции за `cancelAhead` до конца торгов текущего дня. + +### Запуск + + $ git clone https://github.com/tinkoff/invest-api-go-sdk + + $ cd invest-api-go-sdk/examples/ob_bot + +Создайте файл `config.yaml` + + $ touch "config.yaml" + +И заполните его по примеру `example.yaml` + +```yaml +AccountId: "" +APIToken: +EndPoint: sandbox-invest-public-api.tinkoff.ru:443 +AppName: invest-api-go-sdk +DisableResourceExhaustedRetry: false +DisableAllRetry: false +MaxRetries: 3 +``` + +*Для быстрого старта на песочнице достаточно указать только токен, остальное заполнится по умолчанию.* + + $ go run cmd/main.go + +Обратите внимание, что в одной функции main есть возможность создать несколько клиентов для investAPI c разными +токенами и счетами, а с разными клиентами можно создавать разных ботов и запускать их одновременно. \ No newline at end of file diff --git a/examples/ob_bot/cmd/main.go b/examples/ob_bot/cmd/main.go new file mode 100644 index 0000000..f29138f --- /dev/null +++ b/examples/ob_bot/cmd/main.go @@ -0,0 +1,160 @@ +package main + +import ( + "context" + "github.com/tinkoff/invest-api-go-sdk/examples/ob_bot/internal/bot" + "github.com/tinkoff/invest-api-go-sdk/investgo" + pb "github.com/tinkoff/invest-api-go-sdk/proto" + "go.uber.org/zap" + "log" + "os" + "os/signal" + "sync" + "syscall" + "time" +) + +const ( + // SHARES_NUM - Количество акций для торгов + SHARES_NUM = 30 + // EXCHANGE - Биржа на которой будет работать бот + EXCHANGE = "MOEX" +) + +func main() { + // загружаем конфигурацию для сдк из .yaml файла + sdkConfig, err := investgo.LoadConfig("config.yaml") + if err != nil { + log.Fatalf("config loading error %v", err.Error()) + } + + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // сдк использует для внутреннего логирования investgo.Logger + // для примера передадим uber.zap + prod := zap.NewExample() + defer func() { + err := prod.Sync() + if err != nil { + log.Printf("Prod.Sync %v", err.Error()) + } + }() + if err != nil { + log.Fatalf("logger creating error %v", err) + } + logger := prod.Sugar() + // создаем клиента для investAPI, он позволяет создавать нужные сервисы и уже + // через них вызывать нужные методы + client, err := investgo.NewClient(ctx, sdkConfig, logger) + if err != nil { + logger.Fatalf("client creating error %v", err.Error()) + } + defer func() { + logger.Infof("closing client connection") + err := client.Stop() + if err != nil { + logger.Errorf("client shutdown error %v", err.Error()) + } + }() + + // для создания стратеги нужно ее сконфигурировать, для этого получим список идентификаторов инструментов, + // которыми предстоит торговать + insrtumentsService := client.NewInstrumentsServiceClient() + // получаем список акций доступных для торговли через investAPI + instrumentsResp, err := insrtumentsService.Shares(pb.InstrumentStatus_INSTRUMENT_STATUS_BASE) + if err != nil { + logger.Errorf(err.Error()) + } + // слайс идентификаторов торговых инструментов instrument_uid + // акции с московской биржи + instrumentIds := make([]string, 0, 300) + shares := instrumentsResp.GetInstruments() + for _, share := range shares { + if len(instrumentIds) > SHARES_NUM-1 { + break + } + if share.GetExchange() == EXCHANGE { + instrumentIds = append(instrumentIds, share.GetUid()) + } + } + logger.Infof("got %v instruments\n", len(instrumentIds)) + + instruments := instrumentIds + // instruments := []string{"6afa6f80-03a7-4d83-9cf0-c19d7d021f76", "e6123145-9665-43e0-8413-cd61b8aa9b13"} + + // конфиг стратегии бота на стакане + orderBookConfig := bot.OrderBookStrategyConfig{ + Instruments: instruments, + Depth: 20, + BuyRatio: 2, + SellRatio: 2, + MinProfit: 0.5, + SellOut: true, + } + + // создание бота на стакане + botOnOrderBook, err := bot.NewBot(ctx, client, orderBookConfig) + if err != nil { + logger.Fatalf("bot on order book creating fail %v", err.Error()) + } + + wg := &sync.WaitGroup{} + // Таймер для Московской биржи, отслеживает расписание и дает сигналы, на остановку/запуск бота + // cancelAhead - Событие STOP будет отправлено в канал за cancelAhead до конца торгов + cancelAhead := time.Minute * 5 + t := investgo.NewTimer(client, "MOEX", cancelAhead) + + // запуск таймера + wg.Add(1) + go func(ctx context.Context) { + defer wg.Done() + err := t.Start(ctx) + if err != nil { + logger.Errorf(err.Error()) + } + }(ctx) + + // по сигналам останавливаем таймер + go func() { + <-sigs + t.Stop() + }() + + // чтение событий от таймера и управление ботом + events := t.Events() + wg.Add(1) + go func(ctx context.Context) { + defer wg.Done() + for { + select { + case <-ctx.Done(): + return + case ev, ok := <-events: + if !ok { + return + } + logger.Infof("got event = %v", ev) + switch ev { + case investgo.START: + // запуск бота + wg.Add(1) + go func() { + defer wg.Done() + err = botOnOrderBook.Run() + if err != nil { + logger.Errorf(err.Error()) + } + }() + case investgo.STOP: + // остановка бота + botOnOrderBook.Stop() + } + } + } + }(ctx) + + wg.Wait() +} diff --git a/examples/ob_bot/internal/bot/bot.go b/examples/ob_bot/internal/bot/bot.go new file mode 100644 index 0000000..040c8ba --- /dev/null +++ b/examples/ob_bot/internal/bot/bot.go @@ -0,0 +1,278 @@ +package bot + +import ( + "context" + "errors" + "github.com/tinkoff/invest-api-go-sdk/investgo" + pb "github.com/tinkoff/invest-api-go-sdk/proto" + "strings" + "sync" +) + +// QUANTITY - Кол-во лотов инструментов, которыми торгует бот +const QUANTITY = 1 + +// OrderBookStrategyConfig - Конфигурация стратегии на стакане +type OrderBookStrategyConfig struct { + // Instruments - слайс идентификаторов инструментов + Instruments []string + // Depth - Глубина стакана + Depth int32 + // Если кол-во бид/аск больше чем BuyRatio - покупаем + BuyRatio float64 + // Если кол-во аск/бид больше чем SellRatio - продаем + SellRatio float64 + // MinProfit - Минимальный процент выгоды, с которым можно совершать сделки + MinProfit float64 + // SellOut - Если true, то по достижению дедлайна бот выходит из всех активных позиций + SellOut bool +} + +type Bot struct { + StrategyConfig OrderBookStrategyConfig + Client *investgo.Client + + ctx context.Context + cancelBot context.CancelFunc + + executor *Executor +} + +// NewBot - Создание экземпляра бота на стакане +func NewBot(ctx context.Context, c *investgo.Client, config OrderBookStrategyConfig) (*Bot, error) { + botCtx, cancelBot := context.WithCancel(ctx) + + // по конфигу стратегии заполняем map для executor + instrumentService := c.NewInstrumentsServiceClient() + instruments := make(map[string]Instrument, len(config.Instruments)) + + for _, instrument := range config.Instruments { + // в данном случае ключ это uid, поэтому используем LotByUid() + resp, err := instrumentService.InstrumentByUid(instrument) + if err != nil { + cancelBot() + return nil, err + } + instruments[instrument] = Instrument{ + quantity: QUANTITY, + inStock: false, + entryPrice: 0, + lot: resp.GetInstrument().GetLot(), + currency: resp.GetInstrument().GetCurrency(), + } + } + return &Bot{ + Client: c, + StrategyConfig: config, + ctx: botCtx, + cancelBot: cancelBot, + executor: NewExecutor(ctx, c, instruments, config.MinProfit), + }, nil +} + +// Run - Запуск бота +func (b *Bot) Run() error { + wg := &sync.WaitGroup{} + + err := b.checkMoneyBalance("RUB", 200000) + if err != nil { + b.Client.Logger.Fatalf(err.Error()) + } + + // инфраструктура для работы стратегии: запрос, получение, преобразование рыночных данных + MarketDataStreamService := b.Client.NewMarketDataStreamClient() + stream, err := MarketDataStreamService.MarketDataStream() + if err != nil { + return err + } + pbOrderBooks, err := stream.SubscribeOrderBook(b.StrategyConfig.Instruments, b.StrategyConfig.Depth) + if err != nil { + return err + } + + wg.Add(1) + go func() { + defer wg.Done() + err := stream.Listen() + if err != nil { + b.Client.Logger.Errorf(err.Error()) + } + }() + + orderBooks := make(chan OrderBook) + defer close(orderBooks) + + // чтение из стрима + wg.Add(1) + go func(ctx context.Context) { + defer wg.Done() + for { + select { + case <-ctx.Done(): + return + case ob, ok := <-pbOrderBooks: + if !ok { + return + } + orderBooks <- transformOrderBook(ob) + } + } + }(b.ctx) + + // данные готовы, далее идет принятие решения и возможное выставление торгового поручения + wg.Add(1) + go func(ctx context.Context) { + defer wg.Done() + profit, err := b.HandleOrderBooks(ctx, orderBooks) + if err != nil { + b.Client.Logger.Errorf(err.Error()) + } + b.Client.Logger.Infof("profit by strategy = %.9f", profit) + }(b.ctx) + + // Завершение работы бота по его контексту: вызов Stop() или отмена по дедлайну + <-b.ctx.Done() + b.Client.Logger.Infof("stop bot on order book...") + + // стримы работают на контексте клиента, завершать их нужно явно + stream.Stop() + + // если нужно, то в конце торговой сессии выходим из всех, открытых ботом, позиций + if b.StrategyConfig.SellOut { + b.Client.Logger.Infof("start positions sell out...") + err := b.executor.SellOut() + if err != nil { + return err + } + } + + // так как исполнитель тоже слушает стримы, его нужно явно остановить + b.executor.Stop() + + wg.Wait() + return nil +} + +// Stop - Принудительное завершение работы бота, если SellOut = true, то бот выходит из всех активных позиций, которые он открыл +func (b *Bot) Stop() { + b.cancelBot() +} + +// HandleOrderBooks - нужно вызвать асинхронно, будет писать в канал id инструментов, которые нужно купить или продать +func (b *Bot) HandleOrderBooks(ctx context.Context, orderBooks chan OrderBook) (float64, error) { + var totalProfit float64 + for { + select { + case <-ctx.Done(): + return totalProfit, nil + case ob, ok := <-orderBooks: + if !ok { + return totalProfit, nil + } + ratio := b.checkRatio(ob) + if ratio > b.StrategyConfig.BuyRatio { + err := b.executor.Buy(ob.InstrumentUid) + if err != nil { + return totalProfit, err + } + } else if 1/ratio > b.StrategyConfig.SellRatio { + profit, err := b.executor.Sell(ob.InstrumentUid) + if err != nil { + return totalProfit, err + } + if profit > 0 { + b.Client.Logger.Infof("profit = %.9f", profit) + totalProfit += profit + } + } + } + } +} + +// checkRate - возвращает значения коэффициента count(ask) / count(bid) +func (b *Bot) checkRatio(ob OrderBook) float64 { + sell := ordersCount(ob.Asks) + buy := ordersCount(ob.Bids) + return float64(buy) / float64(sell) +} + +// ordersCount - возвращает кол-во заявок из слайса ордеров +func ordersCount(o []Order) int64 { + var count int64 + for _, order := range o { + count += order.Quantity + } + return count +} + +// checkMoneyBalance - проверка доступного баланса денежных средств +func (b *Bot) checkMoneyBalance(currency string, required float64) error { + operationsService := b.Client.NewOperationsServiceClient() + + resp, err := operationsService.GetPositions(b.Client.Config.AccountId) + if err != nil { + return err + } + var balance float64 + money := resp.GetMoney() + for _, m := range money { + b.Client.Logger.Infof("money balance = %v %v", m.ToFloat(), m.GetCurrency()) + if strings.EqualFold(m.GetCurrency(), currency) { + balance = m.ToFloat() + } + } + + if diff := balance - required; diff < 0 { + if strings.HasPrefix(b.Client.Config.EndPoint, "sandbox") { + sandbox := b.Client.NewSandboxServiceClient() + resp, err := sandbox.SandboxPayIn(&investgo.SandboxPayInRequest{ + AccountId: b.Client.Config.AccountId, + Currency: currency, + Unit: int64(-diff), + Nano: 0, + }) + if err != nil { + return err + } + b.Client.Logger.Infof("sandbox auto pay in, balance = %v", resp.GetBalance().ToFloat()) + err = b.executor.updatePositionsUnary() + if err != nil { + return err + } + } else { + return errors.New("not enough money on balance") + } + } + + return nil +} + +// transformOrderBook - Преобразование стакана в нужный формат +func transformOrderBook(input *pb.OrderBook) OrderBook { + depth := input.GetDepth() + bids := make([]Order, 0, depth) + asks := make([]Order, 0, depth) + for _, o := range input.GetBids() { + bids = append(bids, Order{ + Price: o.GetPrice().ToFloat(), + Quantity: o.GetQuantity(), + }) + } + for _, o := range input.GetAsks() { + asks = append(asks, Order{ + Price: o.GetPrice().ToFloat(), + Quantity: o.GetQuantity(), + }) + } + return OrderBook{ + Figi: input.GetFigi(), + InstrumentUid: input.GetInstrumentUid(), + Depth: depth, + IsConsistent: input.GetIsConsistent(), + TimeUnix: input.GetTime().AsTime().Unix(), + LimitUp: input.GetLimitUp().ToFloat(), + LimitDown: input.GetLimitDown().ToFloat(), + Bids: bids, + Asks: asks, + } +} diff --git a/examples/ob_bot/internal/bot/executor.go b/examples/ob_bot/internal/bot/executor.go new file mode 100644 index 0000000..7cf62c0 --- /dev/null +++ b/examples/ob_bot/internal/bot/executor.go @@ -0,0 +1,434 @@ +package bot + +import ( + "context" + "github.com/tinkoff/invest-api-go-sdk/investgo" + pb "github.com/tinkoff/invest-api-go-sdk/proto" + "sync" + "time" +) + +type Instrument struct { + // quantity - Количество лотов, которое покупает/продает исполнитель за 1 поручение + quantity int64 + // lot - Лотность инструмента + lot int32 + // currency - Код валюты инструмента + currency string + // inStock - Флаг открытой позиции по инструменту, если true - позиция открыта + inStock bool + // entryPrice - После открытия позиции, сохраняется цена этой сделки + entryPrice float64 +} + +// LastPrices - Последние цены инструментов +type LastPrices struct { + mx sync.Mutex + lp map[string]float64 +} + +func NewLastPrices() *LastPrices { + return &LastPrices{ + lp: make(map[string]float64, 0), + } +} + +// Update - обновление последних цен +func (l *LastPrices) Update(id string, price float64) { + l.mx.Lock() + l.lp[id] = price + l.mx.Unlock() +} + +// Get - получение последней цены +func (l *LastPrices) Get(id string) (float64, bool) { + l.mx.Lock() + defer l.mx.Unlock() + p, ok := l.lp[id] + return p, ok +} + +// Positions - Данные о позициях счета +type Positions struct { + mx sync.Mutex + pd *pb.PositionData +} + +func NewPositions() *Positions { + return &Positions{ + pd: &pb.PositionData{}, + } +} + +// Update - Обновление позиций +func (p *Positions) Update(data *pb.PositionData) { + p.mx.Lock() + p.pd = data + p.mx.Unlock() +} + +// Get - получение позиций +func (p *Positions) Get() *pb.PositionData { + p.mx.Lock() + defer p.mx.Unlock() + return p.pd +} + +// Executor - Вызывается ботом и исполняет торговые поручения +type Executor struct { + // instruments - Инструменты, которыми торгует исполнитель + instruments map[string]Instrument + // minProfit - Процент минимального профита, после которого выставляются рыночные заявки + minProfit float64 + + // lastPrices - Последние цены по инструментам, обновляются через стрим маркетдаты + lastPrices *LastPrices + // lastPrices - Текущие позиции на счете, обновляются через стрим сервиса операций + positions *Positions + + wg *sync.WaitGroup + cancel context.CancelFunc + + client *investgo.Client + ordersService *investgo.OrdersServiceClient + operationsService *investgo.OperationsServiceClient +} + +// NewExecutor - Создание экземпляра исполнителя +func NewExecutor(ctx context.Context, c *investgo.Client, ids map[string]Instrument, minProfit float64) *Executor { + ctxExecutor, cancel := context.WithCancel(ctx) + wg := &sync.WaitGroup{} + + e := &Executor{ + instruments: ids, + minProfit: minProfit, + lastPrices: NewLastPrices(), + positions: NewPositions(), + wg: wg, + cancel: cancel, + client: c, + ordersService: c.NewOrdersServiceClient(), + operationsService: c.NewOperationsServiceClient(), + } + // Сразу запускаем исполнителя из его же конструктора + e.start(ctxExecutor) + return e +} + +// Stop - Завершение работы +func (e *Executor) Stop() { + e.cancel() + e.wg.Wait() + e.client.Logger.Infof("executor stopped") +} + +// start - Запуск чтения стримов позиций и последних цен +func (e *Executor) start(ctx context.Context) { + e.wg.Add(1) + go func(ctx context.Context) { + defer e.wg.Done() + err := e.listenPositions(ctx) + if err != nil { + e.client.Logger.Errorf(err.Error()) + } + }(ctx) + + e.wg.Add(1) + go func(ctx context.Context) { + defer e.wg.Done() + err := e.listenLastPrices(ctx) + if err != nil { + e.client.Logger.Errorf(err.Error()) + } + }(ctx) +} + +// listenPositions - Метод слушает стрим позиций и обновляет их +func (e *Executor) listenPositions(ctx context.Context) error { + err := e.updatePositionsUnary() + if err != nil { + return err + } + operationsStreamService := e.client.NewOperationsStreamClient() + stream, err := operationsStreamService.PositionsStream([]string{e.client.Config.AccountId}) + if err != nil { + return err + } + positionsChan := stream.Positions() + + e.wg.Add(1) + go func() { + defer e.wg.Done() + err := stream.Listen() + if err != nil { + e.client.Logger.Errorf(err.Error()) + } + }() + + e.wg.Add(1) + go func(ctx context.Context) { + defer e.wg.Done() + for { + select { + case <-ctx.Done(): + return + case p, ok := <-positionsChan: + if !ok { + return + } + // e.client.Logger.Infof("update from positions stream %v\n", p.GetMoney()) + e.positions.Update(p) + } + } + }(ctx) + + <-ctx.Done() + e.client.Logger.Infof("stop updating positions in executor") + stream.Stop() + return nil +} + +// listenLastPrices - Метод слушает стрим последних цен и обновляет их +func (e *Executor) listenLastPrices(ctx context.Context) error { + MarketDataStreamService := e.client.NewMarketDataStreamClient() + stream, err := MarketDataStreamService.MarketDataStream() + if err != nil { + return err + } + + ids := make([]string, 0, len(e.instruments)) + for id := range e.instruments { + ids = append(ids, id) + } + lastPricesChan, err := stream.SubscribeLastPrice(ids) + if err != nil { + return err + } + + e.wg.Add(1) + go func() { + defer e.wg.Done() + err := stream.Listen() + if err != nil { + e.client.Logger.Errorf(err.Error()) + } + }() + + // чтение из стрима + e.wg.Add(1) + go func(ctx context.Context) { + defer e.wg.Done() + for { + select { + case <-ctx.Done(): + return + case lp, ok := <-lastPricesChan: + if !ok { + return + } + e.lastPrices.Update(lp.GetInstrumentUid(), lp.GetPrice().ToFloat()) + } + } + }(ctx) + + <-ctx.Done() + e.client.Logger.Infof("stop updating last prices in executor") + stream.Stop() + return nil +} + +// updatePositionsUnary - Unary метод обновления позиций +func (e *Executor) updatePositionsUnary() error { + resp, err := e.operationsService.GetPositions(e.client.Config.AccountId) + if err != nil { + return err + } + // два слайса *MoneyValue + available := resp.GetMoney() + blocked := resp.GetBlocked() + + // слайс *PositionMoney + positionMoney := make([]*pb.PositionsMoney, 0) + // ключ - код валюты, значение - *PositionMoney + moneyByCurrency := make(map[string]*pb.PositionsMoney, 0) + + for _, avail := range available { + moneyByCurrency[avail.GetCurrency()] = &pb.PositionsMoney{ + AvailableValue: avail, + BlockedValue: nil, + } + } + + for _, block := range blocked { + m := moneyByCurrency[block.GetCurrency()] + moneyByCurrency[block.GetCurrency()] = &pb.PositionsMoney{ + AvailableValue: m.GetAvailableValue(), + BlockedValue: block, + } + } + + for _, money := range moneyByCurrency { + positionMoney = append(positionMoney, money) + } + + // обновляем позиции для исполнителя + e.positions.Update(&pb.PositionData{ + AccountId: e.client.Config.AccountId, + Money: positionMoney, + Securities: resp.GetSecurities(), + Futures: resp.GetFutures(), + Options: resp.GetOptions(), + Date: investgo.TimeToTimestamp(time.Now()), + }) + + return nil +} + +// Buy - Метод покупки инструмента с идентификатором id +func (e *Executor) Buy(id string) error { + currentInstrument := e.instruments[id] + // если этот инструмент уже куплен ботом + if currentInstrument.inStock { + return nil + } + // если не хватает средств для покупки + if !e.possibleToBuy(id) { + return nil + } + resp, err := e.ordersService.Buy(&investgo.PostOrderRequestShort{ + InstrumentId: id, + Quantity: currentInstrument.quantity, + Price: nil, + AccountId: e.client.Config.AccountId, + OrderType: pb.OrderType_ORDER_TYPE_MARKET, + OrderId: investgo.CreateUid(), + }) + if err != nil { + return err + } + if resp.GetExecutionReportStatus() == pb.OrderExecutionReportStatus_EXECUTION_REPORT_STATUS_FILL { + currentInstrument.inStock = true + currentInstrument.entryPrice = resp.GetExecutedOrderPrice().ToFloat() + } + e.instruments[id] = currentInstrument + e.client.Logger.Infof("Buy with %v, price %v", resp.GetFigi(), resp.GetExecutedOrderPrice().ToFloat()) + return nil +} + +// Sell - Метод покупки инструмента с идентификатором id +func (e *Executor) Sell(id string) (float64, error) { + currentInstrument := e.instruments[id] + if !currentInstrument.inStock { + return 0, nil + } + if profitable := e.isProfitable(id); !profitable { + return 0, nil + } + + resp, err := e.ordersService.Sell(&investgo.PostOrderRequestShort{ + InstrumentId: id, + Quantity: currentInstrument.quantity, + Price: nil, + AccountId: e.client.Config.AccountId, + OrderType: pb.OrderType_ORDER_TYPE_MARKET, + OrderId: investgo.CreateUid(), + }) + if err != nil { + return 0, err + } + var profit float64 + if resp.GetExecutionReportStatus() == pb.OrderExecutionReportStatus_EXECUTION_REPORT_STATUS_FILL { + currentInstrument.inStock = false + // разница в цене инструмента * лотность * кол-во лотов + profit = (resp.GetExecutedOrderPrice().ToFloat() - currentInstrument.entryPrice) * float64(currentInstrument.lot) * float64(currentInstrument.quantity) + } + e.client.Logger.Infof("Sell with %v, price %v", resp.GetFigi(), resp.GetExecutedOrderPrice().ToFloat()) + e.instruments[id] = currentInstrument + return profit, nil +} + +// isProfitable - Верно если процент выгоды возможной сделки, рассчитанный по цене последней сделки, больше чем minProfit +func (e *Executor) isProfitable(id string) bool { + lp, ok := e.lastPrices.Get(id) + if !ok { + return false + } + return ((lp-e.instruments[id].entryPrice)/e.instruments[id].entryPrice)*100 > e.minProfit +} + +// possibleToBuy - Проверка возможности купить инструмент +func (e *Executor) possibleToBuy(id string) bool { + // требуемая сумма для покупки + // кол-во лотов * лотность * стоимость 1 инструмента + //return true + lp, ok := e.lastPrices.Get(id) + if !ok { + return false + } + required := float64(e.instruments[id].quantity) * float64(e.instruments[id].lot) * lp + positionMoney := e.positions.Get().GetMoney() + var moneyInFloat float64 + for _, pm := range positionMoney { + m := pm.GetAvailableValue() + if m.GetCurrency() == e.instruments[id].currency { + moneyInFloat = m.ToFloat() + } + } + + // TODO сравнение дробных чисел, реакция на недостаток баланса + if moneyInFloat < required { + e.client.Logger.Infof("executor: not enough money to buy order with id = %v", id) + } + return moneyInFloat > required +} + +// SellOut - Метод выхода из всех текущих позиций +func (e *Executor) SellOut() error { + // TODO for futures and options + resp, err := e.operationsService.GetPositions(e.client.Config.AccountId) + if err != nil { + return err + } + + securities := resp.GetSecurities() + for _, security := range securities { + var lot int64 + instrument, ok := e.instruments[security.GetInstrumentUid()] + if !ok { + // если бот не открывал эту позицию, он не будет ее закрывать + e.client.Logger.Infof("%v not found in executor instruments map", security.GetInstrumentUid()) + continue + } else { + lot = int64(instrument.lot) + } + balanceInLots := security.GetBalance() / lot + if balanceInLots < 0 { + resp, err := e.ordersService.Buy(&investgo.PostOrderRequestShort{ + InstrumentId: security.GetInstrumentUid(), + Quantity: -balanceInLots, + Price: nil, + AccountId: e.client.Config.AccountId, + OrderType: pb.OrderType_ORDER_TYPE_MARKET, + OrderId: investgo.CreateUid(), + }) + if err != nil { + e.client.Logger.Errorf(investgo.MessageFromHeader(resp.GetHeader())) + return err + } + } else { + resp, err := e.ordersService.Sell(&investgo.PostOrderRequestShort{ + InstrumentId: security.GetInstrumentUid(), + Quantity: balanceInLots, + Price: nil, + AccountId: e.client.Config.AccountId, + OrderType: pb.OrderType_ORDER_TYPE_MARKET, + OrderId: investgo.CreateUid(), + }) + if err != nil { + e.client.Logger.Errorf(investgo.MessageFromHeader(resp.GetHeader())) + return err + } + } + } + return nil +} diff --git a/examples/ob_bot/internal/bot/model.go b/examples/ob_bot/internal/bot/model.go new file mode 100644 index 0000000..f2a448d --- /dev/null +++ b/examples/ob_bot/internal/bot/model.go @@ -0,0 +1,18 @@ +package bot + +type Order struct { + Price float64 `json:"Price"` + Quantity int64 `json:"Quantity"` +} + +type OrderBook struct { + Figi string `json:"Figi"` + InstrumentUid string `json:"InstrumentUid"` + Depth int32 `json:"Depth"` + IsConsistent bool `json:"IsConsistent"` + TimeUnix int64 `json:"TimeUnix"` + LimitUp float64 `json:"LimitUp"` + LimitDown float64 `json:"LimitDown"` + Bids []Order `json:"Bids"` + Asks []Order `json:"Asks"` +} diff --git a/examples/order_book_download/order_book.go b/examples/order_book_download/order_book.go index 8d51b4d..bd29d09 100644 --- a/examples/order_book_download/order_book.go +++ b/examples/order_book_download/order_book.go @@ -68,7 +68,7 @@ create table if not exists asks ( func main() { // создаем базу данных sqlite - db, err := initDB("examples/order_book_download/order_books.db") + db, err := initDB("order_book_download/order_books.db") if err != nil { log.Fatalf(err.Error()) } @@ -296,7 +296,7 @@ func main() { wg.Wait() } -// преобразование стакана в нужный формат +// transformOrderBook - Преобразование стакана в нужный формат func transformOrderBook(input *pb.OrderBook) *OrderBook { depth := input.GetDepth() bids := make([]Order, 0, depth) @@ -326,7 +326,7 @@ func transformOrderBook(input *pb.OrderBook) *OrderBook { } } -// сохранение стаканов в json +// storeOrderBooksInFile - Сохранение стаканов в json func storeOrderBooksInFile(orderBooks []*OrderBook) error { file, err := os.Create("order_books.json") if err != nil { @@ -346,7 +346,7 @@ func storeOrderBooksInFile(orderBooks []*OrderBook) error { return err } -// инициализация бд +// initDB - Инициализация бд func initDB(path string) (*sqlx.DB, error) { db, err := sqlx.Open("sqlite3", path) if err != nil { @@ -363,7 +363,7 @@ func initDB(path string) (*sqlx.DB, error) { return db, nil } -// сохранение партии стаканов в бд +// storeOrderBooksInDB - Сохранение партии стаканов в бд func storeOrderBooksInDB(db *sqlx.DB, obooks []*OrderBook) error { tx, err := db.Begin() if err != nil { diff --git a/examples/sandbox.go b/examples/sandbox.go index 66d2789..7302f2f 100644 --- a/examples/sandbox.go +++ b/examples/sandbox.go @@ -8,7 +8,6 @@ import ( "go.uber.org/zap" "log" "os/signal" - "strings" "syscall" ) @@ -98,7 +97,7 @@ func main() { } else { instruments := instrumentResp.GetInstruments() for _, instrument := range instruments { - if strings.Compare(instrument.GetTicker(), "TCSG") == 0 { + if instrument.GetTicker() == "TCSG" { id = instrument.GetUid() } } diff --git a/investgo/client.go b/investgo/client.go index a988cef..f458c98 100644 --- a/investgo/client.go +++ b/investgo/client.go @@ -12,7 +12,6 @@ import ( "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/oauth" "google.golang.org/grpc/metadata" - "strings" "time" ) @@ -92,10 +91,10 @@ func NewClient(ctx context.Context, conf Config, l Logger) (*Client, error) { } func setDefaultConfig(conf *Config) { - if strings.Compare(conf.AppName, "") == 0 { + if conf.AppName == "" { conf.AppName = "invest-api-go-sdk" } - if strings.Compare(conf.EndPoint, "") == 0 { + if conf.EndPoint == "" { conf.EndPoint = "sandbox-invest-public-api.tinkoff.ru:443" } if conf.DisableAllRetry { diff --git a/investgo/doc.go b/investgo/doc.go new file mode 100644 index 0000000..9553fa3 --- /dev/null +++ b/investgo/doc.go @@ -0,0 +1,13 @@ +/* +Package investgo предоставляет инструменты для работы с Tinkoff InvestAPI. + +# Client + +Сначала нужно заполнить investgo.Config, затем с помощью функции investgo.NewClient() создать клиента. У каждого клиента +есть свой конфиг, который привязывает его к определенному счету и токену. Если есть потребность использовать разные счета и токены, нужно +создавать разных клиентов. investgo.Client предоставляет функции-конcтрукторы для всех сервисов Tinkoff InvestAPI. + +Подробнее смотрите в директории examples. +*/ + +package investgo diff --git a/investgo/instruments.go b/investgo/instruments.go index f6bc7ae..f061c7c 100644 --- a/investgo/instruments.go +++ b/investgo/instruments.go @@ -286,6 +286,8 @@ func (is *InstrumentsServiceClient) optionBy(id string, idType pb.InstrumentIdTy } // Options - Метод получения списка опционов +// +// Deprecated: Do not use func (is *InstrumentsServiceClient) Options(status pb.InstrumentStatus) (*OptionsResponse, error) { var header, trailer metadata.MD resp, err := is.pbClient.Options(is.ctx, &pb.InstrumentsRequest{ diff --git a/investgo/marketdata.go b/investgo/marketdata.go index d65d9c8..1e2cf40 100644 --- a/investgo/marketdata.go +++ b/investgo/marketdata.go @@ -7,7 +7,6 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/metadata" "os" - "strings" "time" ) @@ -251,7 +250,7 @@ func selectDuration(interval pb.CandleInterval) time.Duration { // Метод записи в .csv файл исторических свечей в формате instrumentId;time;open;close;high;low;volume func (md *MarketDataServiceClient) writeCandlesToFile(candles []*pb.HistoricCandle, id string, filename string) error { h, m, s := time.Now().Clock() - if strings.Compare(filename, "") == 0 { + if filename == "" { filename = fmt.Sprintf("candles %v:%v:%v", h, m, s) } diff --git a/investgo/md_stream.go b/investgo/md_stream.go index 849e6a3..aead667 100644 --- a/investgo/md_stream.go +++ b/investgo/md_stream.go @@ -254,7 +254,7 @@ func (mds *MarketDataStream) Listen() error { for { select { case <-mds.ctx.Done(): - mds.mdsClient.logger.Infof("stop listening") + mds.mdsClient.logger.Infof("stop listening market data stream") return nil default: resp, err := mds.stream.Recv() @@ -262,7 +262,7 @@ func (mds *MarketDataStream) Listen() error { // если ошибка связана с завершением контекста, обрабатываем ее switch { case status.Code(err) == codes.Canceled: - mds.mdsClient.logger.Infof("stop listening") + mds.mdsClient.logger.Infof("stop listening market data stream") return nil default: return err diff --git a/investgo/timer.go b/investgo/timer.go new file mode 100644 index 0000000..6ab07a0 --- /dev/null +++ b/investgo/timer.go @@ -0,0 +1,162 @@ +package investgo + +import ( + "context" + "errors" + pb "github.com/tinkoff/invest-api-go-sdk/proto" + "strings" + "time" +) + +// Event - события, START - сигнал к запуску, STOP - сигнал к остановке +type Event int + +const ( + START Event = iota + STOP +) + +type Timer struct { + client *Client + instrumentsService *InstrumentsServiceClient + exchange string + // cancelAhead - Событие STOP будет отправлено в канал за cancelAhead до конца торгов + cancelAhead time.Duration + cancel context.CancelFunc + events chan Event +} + +// NewTimer - Таймер сигнализирует о начале/завершении основной торговой сессии на конкретной бирже +func NewTimer(c *Client, exchange string, cancelAhead time.Duration) *Timer { + return &Timer{ + client: c, + instrumentsService: c.NewInstrumentsServiceClient(), + exchange: exchange, + cancelAhead: cancelAhead, + events: make(chan Event, 1), + } +} + +// Events - Канал событий об открытии/закрытии торгов +func (t *Timer) Events() chan Event { + return t.events +} + +// Start - Запуск таймера +func (t *Timer) Start(ctx context.Context) error { + defer t.shutdown() + ctxTimer, cancel := context.WithCancel(ctx) + t.cancel = cancel + for { + select { + case <-ctxTimer.Done(): + return nil + default: + // получаем текущее время + from := time.Now() + to := from.Add(time.Hour * 24) + + // получаем ближайшие два торговых дня + resp, err := t.instrumentsService.TradingSchedules(t.exchange, from, to) + if err != nil { + return err + } + + exchanges := resp.GetExchanges() + days := make([]*pb.TradingDay, 0) + for _, ex := range exchanges { + if strings.EqualFold(ex.GetExchange(), t.exchange) { + days = ex.GetDays() + } + } + + var today *pb.TradingDay + if len(days) > 1 { + today = days[0] + } + + // если этот день оказался неторговым, то находим ближайший торговый и ждем до старта торгов в этот день + if !today.GetIsTradingDay() { + today, err = t.findTradingDay(from) + if err != nil { + return err + } + + } + + switch { + // если торги еще не начались + case time.Now().Before(today.GetStartTime().AsTime()): + t.client.Logger.Infof("%v is closed yet, wait for start %v", t.exchange, time.Until(today.GetStartTime().AsTime().Local())) + if stop := t.wait(ctxTimer, time.Until(today.GetStartTime().AsTime().Local())); stop { + return nil + } + t.events <- START + t.client.Logger.Infof("start trading session, remaining time = %v", time.Until(today.GetEndTime().AsTime().Local())) + if stop := t.wait(ctxTimer, time.Until(today.GetEndTime().AsTime().Local())-t.cancelAhead); stop { + return nil + } + t.events <- STOP + // если сегодня торги уже идут + case time.Now().After(today.GetStartTime().AsTime()) && time.Now().Before(today.GetEndTime().AsTime().Local()): + t.client.Logger.Infof("start trading session, remaining time = %v", time.Until(today.GetEndTime().AsTime().Local())) + t.events <- START + if stop := t.wait(ctxTimer, time.Until(today.GetEndTime().AsTime().Local())-t.cancelAhead); stop { + return nil + } + t.events <- STOP + // если на сегодня торги уже окончены + case time.Now().After(today.GetEndTime().AsTime().Local()): + // спать час, пока не дождемся следующего дня + t.client.Logger.Infof("%v is already closed, wait next day for 1 hour", t.exchange) + if stop := t.wait(ctxTimer, time.Hour); stop { + return nil + } + } + } + } +} + +// Stop - Завершение работы таймера +func (t *Timer) Stop() { + t.events <- STOP + t.cancel() +} + +func (t *Timer) shutdown() { + t.client.Logger.Infof("stop %v timer", t.exchange) + close(t.events) +} + +// wait - Ожидание, с возможностью отмены по контексту +func (t *Timer) wait(ctx context.Context, dur time.Duration) bool { + tim := time.NewTimer(dur) + for { + select { + case <-ctx.Done(): + return true + case <-tim.C: + return false + } + } +} + +// findTradingDay - Поиск ближайшего торгового дня +func (t *Timer) findTradingDay(start time.Time) (*pb.TradingDay, error) { + resp, err := t.instrumentsService.TradingSchedules(t.exchange, start, start.Add(time.Hour*24*7)) + if err != nil { + return nil, err + } + for _, ex := range resp.GetExchanges() { + if strings.EqualFold(ex.GetExchange(), t.exchange) { + for _, day := range ex.GetDays() { + if day.GetIsTradingDay() { + return day, nil + } + } + // если не нашлось дня, запросим еще на неделю расписание + return t.findTradingDay(start.Add(time.Hour * 24 * 7)) + } + } + return nil, errors.New("trading day not found") +}