From b5bb786ad948281397608d8e39b63f572dccc94d Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 11 Sep 2025 11:22:45 +0300 Subject: [PATCH 1/3] Initial commit with task details for issue #230 Adding CLAUDE.md with task information for AI processing. This file will be removed when the task is complete. Issue: https://github.com/linksplatform/Bot/issues/230 --- CLAUDE.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..e1e59658 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +Issue to solve: https://github.com/linksplatform/Bot/issues/230 +Your prepared branch: issue-230-405bff73 +Your prepared working directory: /tmp/gh-issue-solver-1757578962739 + +Proceed. \ No newline at end of file From 96b0023d31e9bba4b3249975d63fb4e48f42c27e Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 11 Sep 2025 11:23:02 +0300 Subject: [PATCH 2/3] Remove CLAUDE.md - PR created successfully --- CLAUDE.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index e1e59658..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,5 +0,0 @@ -Issue to solve: https://github.com/linksplatform/Bot/issues/230 -Your prepared branch: issue-230-405bff73 -Your prepared working directory: /tmp/gh-issue-solver-1757578962739 - -Proceed. \ No newline at end of file From 6b8aa3817c139759c61c20a290a5c8142b821a11 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 11 Sep 2025 11:34:44 +0300 Subject: [PATCH 3/3] Implement API abstraction layer to make bot independent of Tinkoff API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This implementation introduces a comprehensive abstraction layer that decouples the trading bot from the Tinkoff API, addressing the issue of API instability. Key Changes: - Created ITradingApiProvider interface to abstract all trading operations - Implemented TinkoffApiProvider as a wrapper around Tinkoff InvestApi - Added MockApiProvider for testing without real API calls - Introduced configuration-based provider selection - Created interfaces for all trading data structures (IAccount, IInstrument, IOrder, etc.) - Used custom enums to avoid namespace conflicts with provider APIs - Refactored TradingService to use the abstraction layer Benefits: - Reduced dependency risk - no longer tied to single API provider - Easy testing with mock provider - Provider flexibility via configuration - Future-proof architecture for adding new trading APIs - API instability isolation to specific provider implementations Files added: - Interfaces/ - Complete abstraction layer interfaces - Providers/TinkoffApiProvider.cs - Tinkoff implementation - Providers/Tinkoff/ - Tinkoff-specific wrapper classes - Providers/MockApiProvider.cs - Mock implementation for testing - AbstractTradingService.cs - Refactored service using abstraction - Configuration files and examples 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- csharp/TraderBot/AbstractTradingService.cs | 812 ++++++++++++++++++ csharp/TraderBot/ApiProviderSettings.cs | 12 + csharp/TraderBot/Interfaces/IAccount.cs | 8 + csharp/TraderBot/Interfaces/IInstrument.cs | 16 + csharp/TraderBot/Interfaces/IOperation.cs | 19 + csharp/TraderBot/Interfaces/IOrder.cs | 48 ++ csharp/TraderBot/Interfaces/IOrderBook.cs | 18 + csharp/TraderBot/Interfaces/IPosition.cs | 26 + csharp/TraderBot/Interfaces/ITrades.cs | 31 + .../Interfaces/ITradingApiProvider.cs | 16 + csharp/TraderBot/Program.cs | 48 +- csharp/TraderBot/Program.cs.bak | 29 + csharp/TraderBot/Providers/MockApiProvider.cs | 273 ++++++ .../Providers/Tinkoff/TinkoffAccount.cs | 18 + .../Providers/Tinkoff/TinkoffInstrument.cs | 33 + .../Providers/Tinkoff/TinkoffOperation.cs | 44 + .../Providers/Tinkoff/TinkoffOrder.cs | 64 ++ .../Providers/Tinkoff/TinkoffOrderBook.cs | 57 ++ .../Providers/Tinkoff/TinkoffPosition.cs | 59 ++ .../Providers/Tinkoff/TinkoffTrades.cs | 76 ++ .../TraderBot/Providers/TinkoffApiProvider.cs | 144 ++++ csharp/TraderBot/appsettings.example.json | 32 + csharp/TraderBot/appsettings.mock.json | 32 + examples/README.md | 98 +++ 24 files changed, 2008 insertions(+), 5 deletions(-) create mode 100644 csharp/TraderBot/AbstractTradingService.cs create mode 100644 csharp/TraderBot/ApiProviderSettings.cs create mode 100644 csharp/TraderBot/Interfaces/IAccount.cs create mode 100644 csharp/TraderBot/Interfaces/IInstrument.cs create mode 100644 csharp/TraderBot/Interfaces/IOperation.cs create mode 100644 csharp/TraderBot/Interfaces/IOrder.cs create mode 100644 csharp/TraderBot/Interfaces/IOrderBook.cs create mode 100644 csharp/TraderBot/Interfaces/IPosition.cs create mode 100644 csharp/TraderBot/Interfaces/ITrades.cs create mode 100644 csharp/TraderBot/Interfaces/ITradingApiProvider.cs create mode 100644 csharp/TraderBot/Program.cs.bak create mode 100644 csharp/TraderBot/Providers/MockApiProvider.cs create mode 100644 csharp/TraderBot/Providers/Tinkoff/TinkoffAccount.cs create mode 100644 csharp/TraderBot/Providers/Tinkoff/TinkoffInstrument.cs create mode 100644 csharp/TraderBot/Providers/Tinkoff/TinkoffOperation.cs create mode 100644 csharp/TraderBot/Providers/Tinkoff/TinkoffOrder.cs create mode 100644 csharp/TraderBot/Providers/Tinkoff/TinkoffOrderBook.cs create mode 100644 csharp/TraderBot/Providers/Tinkoff/TinkoffPosition.cs create mode 100644 csharp/TraderBot/Providers/Tinkoff/TinkoffTrades.cs create mode 100644 csharp/TraderBot/Providers/TinkoffApiProvider.cs create mode 100644 csharp/TraderBot/appsettings.example.json create mode 100644 csharp/TraderBot/appsettings.mock.json create mode 100644 examples/README.md diff --git a/csharp/TraderBot/AbstractTradingService.cs b/csharp/TraderBot/AbstractTradingService.cs new file mode 100644 index 00000000..27c39d20 --- /dev/null +++ b/csharp/TraderBot/AbstractTradingService.cs @@ -0,0 +1,812 @@ +using System.Collections.Concurrent; +using System.Globalization; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using TraderBot.Interfaces; +using TraderBot.Providers.Tinkoff; + +namespace TraderBot; + +using OperationsList = List<(OperationType Type, DateTime Date, long Quantity, decimal Price)>; + +public class AbstractTradingService : BackgroundService +{ + protected const bool PreferLocalCashBalance = true; + protected static readonly TimeSpan RecoveryInterval = TimeSpan.FromSeconds(20); + protected static readonly TimeSpan FailedCancelOrderInterval = TimeSpan.FromSeconds(10); + protected static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(10); + protected static readonly TimeSpan SyncInterval = TimeSpan.FromSeconds(20); + protected static readonly TimeSpan WaitOutputInterval = TimeSpan.FromSeconds(20); + protected readonly ITradingApiProvider ApiProvider; + protected readonly ILogger Logger; + protected readonly IHostApplicationLifetime Lifetime; + protected readonly TradingSettings Settings; + protected readonly IAccount CurrentAccount; + protected readonly string Figi; + protected readonly int LotSize; + protected readonly decimal PriceStep; + protected decimal CashBalanceFree; + protected decimal CashBalanceLocked; + protected DateTime LastOperationsCheckpoint; + protected long LastRefreshTicks; + protected long LastSyncTicks; + protected long LastWaitOutputTicks; + protected TimeSpan MinimumTimeToBuy; + protected TimeSpan MaximumTimeToBuy; + protected readonly ConcurrentDictionary ActiveBuyOrders; + protected readonly ConcurrentDictionary ActiveSellOrders; + protected readonly ConcurrentDictionary LotsSets; + protected readonly ConcurrentDictionary ActiveSellOrderSourcePrice; + + public AbstractTradingService(ILogger logger, ITradingApiProvider apiProvider, IHostApplicationLifetime lifetime, TradingSettings settings) + { + Logger = logger; + ApiProvider = apiProvider; + Lifetime = lifetime; + Settings = settings; + Logger.LogInformation($"Instrument: {settings.Instrument}"); + Logger.LogInformation($"Ticker: {settings.Ticker}"); + Logger.LogInformation($"CashCurrency: {settings.CashCurrency}"); + Logger.LogInformation($"AccountIndex: {settings.AccountIndex}"); + Logger.LogInformation($"MinimumProfitSteps: {settings.MinimumProfitSteps}"); + Logger.LogInformation($"MarketOrderBookDepth: {settings.MarketOrderBookDepth}"); + Logger.LogInformation($"MinimumMarketOrderSizeToChangeBuyPrice: {settings.MinimumMarketOrderSizeToChangeBuyPrice}"); + Logger.LogInformation($"MinimumMarketOrderSizeToChangeSellPrice: {settings.MinimumMarketOrderSizeToChangeSellPrice}"); + Logger.LogInformation($"MinimumMarketOrderSizeToBuy: {settings.MinimumMarketOrderSizeToBuy}"); + Logger.LogInformation($"MinimumMarketOrderSizeToSell: {settings.MinimumMarketOrderSizeToSell}"); + MinimumTimeToBuy = TimeSpan.Parse(settings.MinimumTimeToBuy ?? "00:00:00", CultureInfo.InvariantCulture); + Logger.LogInformation($"MinimumTimeToBuy: {MinimumTimeToBuy}"); + MaximumTimeToBuy = TimeSpan.Parse(settings.MaximumTimeToBuy ?? "23:59:59", CultureInfo.InvariantCulture); + Logger.LogInformation($"MaximumTimeToBuy: {MaximumTimeToBuy}"); + Logger.LogInformation($"EarlySellOwnedLotsDelta: {settings.EarlySellOwnedLotsDelta}"); + Logger.LogInformation($"EarlySellOwnedLotsMultiplier: {settings.EarlySellOwnedLotsMultiplier}"); + Logger.LogInformation($"LoadOperationsFrom: {settings.LoadOperationsFrom}"); + + var currentTime = DateTime.UtcNow.TimeOfDay; + Logger.LogInformation($"Current time: {currentTime}"); + + // Initialize API provider + Task.Run(async () => + { + await ApiProvider.InitializeAsync(); + + var accounts = await ApiProvider.GetAccountsAsync(); + var accountList = accounts.ToList(); + Logger.LogInformation("Accounts:"); + for (int i = 0; i < accountList.Count; i++) + { + Logger.LogInformation($"[{i}]: {accountList[i]}"); + } + if (settings.AccountIndex < 0 || settings.AccountIndex >= accountList.Count) + { + throw new ArgumentException($"Account index {settings.AccountIndex} is out of range. Please select a valid account index ({0}-{accountList.Count - 1})."); + } + CurrentAccount = accountList[settings.AccountIndex]; + Logger.LogInformation($"CurrentAccount (with {settings.AccountIndex} index): {CurrentAccount}"); + + var instrumentType = settings.Instrument == Instrument.Etf ? InstrumentType.Etf : InstrumentType.Shares; + var currentInstrument = await ApiProvider.GetInstrumentAsync(settings.Ticker!, instrumentType); + + Logger.LogInformation($"CurrentInstrument: {currentInstrument}"); + Figi = currentInstrument.Figi; + Logger.LogInformation($"Figi: {Figi}"); + PriceStep = currentInstrument.MinPriceIncrement; + Logger.LogInformation($"PriceStep: {PriceStep}"); + LotSize = currentInstrument.Lot; + Logger.LogInformation($"LotSize: {LotSize}"); + }).Wait(); + + ActiveBuyOrders = new ConcurrentDictionary(); + ActiveSellOrders = new ConcurrentDictionary(); + LotsSets = new ConcurrentDictionary(); + ActiveSellOrderSourcePrice = new ConcurrentDictionary(); + LastOperationsCheckpoint = settings.LoadOperationsFrom; + } + + protected async Task ReceiveTrades(CancellationToken cancellationToken) + { + var tradesStream = await ApiProvider.SubscribeToTradesAsync(CurrentAccount.Id); + await foreach (var data in tradesStream) + { + Logger.LogInformation($"Trade: {data}"); + if (data.DataType == TradeDataType.OrderTrades && data.OrderTrades != null) + { + var orderTrades = data.OrderTrades; + UpdateCashBalance(orderTrades); + TryUpdateLots(orderTrades); + TrySubtractTradesFromOrder(ActiveBuyOrders, orderTrades); + TrySubtractTradesFromOrder(ActiveSellOrders, orderTrades); + } + else if (data.DataType == TradeDataType.Ping) + { + SyncActiveOrders(); + SyncLots(); + } + + if (cancellationToken.IsCancellationRequested) + break; + } + } + + protected void UpdateCashBalance(IOrderTrades orderTrades) + { + foreach (var trade in orderTrades.Trades) + { + var cashBalanceDelta = trade.Quantity * trade.Price; + if (orderTrades.Direction == OrderDirection.Buy) + { + SetCashBalance(CashBalanceFree, CashBalanceLocked - cashBalanceDelta); + } + else if (orderTrades.Direction == OrderDirection.Sell) + { + SetCashBalance(CashBalanceFree + cashBalanceDelta, CashBalanceLocked); + } + } + } + + protected void LogActiveOrders() + { + foreach (var order in ActiveBuyOrders) + { + Logger.LogInformation($"Active buy order: {order.Value}"); + } + foreach (var order in ActiveSellOrders) + { + Logger.LogInformation($"Active sell order: {order.Value}"); + } + } + + protected async void SyncActiveOrders(bool forceReset = false) + { + if (forceReset) + { + ActiveBuyOrders.Clear(); + ActiveSellOrders.Clear(); + ActiveSellOrderSourcePrice.Clear(); + } + var orders = await ApiProvider.GetOrdersAsync(CurrentAccount.Id); + var ordersList = orders.ToList(); + + var deletedBuyOrders = new List(); + foreach (var order in ActiveBuyOrders) + { + if (ordersList.All(o => o.OrderId != order.Key)) + { + deletedBuyOrders.Add(order.Key); + } + } + var deletedSellOrders = new List(); + foreach (var order in ActiveSellOrders) + { + if (ordersList.All(o => o.OrderId != order.Key)) + { + deletedSellOrders.Add(order.Key); + } + } + foreach (var orderState in ordersList) + { + if (orderState.Figi == Figi) + { + if (orderState.Direction == OrderDirection.Buy) + { + ActiveBuyOrders.TryAdd(orderState.OrderId, orderState); + } + else if (orderState.Direction == OrderDirection.Sell) + { + ActiveSellOrders.TryAdd(orderState.OrderId, orderState); + } + } + } + foreach (var orderId in deletedBuyOrders) + { + ActiveBuyOrders.TryRemove(orderId, out IOrder? orderState); + } + foreach (var orderId in deletedSellOrders) + { + ActiveSellOrders.TryRemove(orderId, out IOrder? orderState); + ActiveSellOrderSourcePrice.TryRemove(orderId, out decimal sourcePrice); + } + if (ActiveBuyOrders.Count == 0 && CashBalanceLocked > 0) + { + Logger.LogInformation("No active buy orders, locked cash balance will be reset."); + SetCashBalance(CashBalanceFree + CashBalanceLocked, 0); + } + if (LotsSets.Count == 1 && ActiveSellOrders.Count == 1) + { + ActiveSellOrderSourcePrice[ActiveSellOrders.Single().Value.OrderId] = LotsSets.Single().Key; + } + } + + protected async void SyncLots(bool forceReset = false) + { + if (forceReset) + { + LotsSets.Clear(); + } + // Get positions + var positions = await ApiProvider.GetPositionsAsync(CurrentAccount.Id); + var currentInstrumentPosition = positions.Where(p => p.Figi == Figi).FirstOrDefault(); + if (currentInstrumentPosition == null) + { + Logger.LogInformation($"Current instrument not found in positions."); + } + else + { + Logger.LogInformation($"Current instrument found in positions: {currentInstrumentPosition}"); + } + // Get portfolio + var portfolio = await ApiProvider.GetPortfolioAsync(CurrentAccount.Id); + var currentInstrumentPortfolio = portfolio.Positions.Where(p => p.Figi == Figi).FirstOrDefault(); + if (currentInstrumentPortfolio == null) + { + Logger.LogInformation($"Current instrument not found in portfolio."); + } + else + { + Logger.LogInformation($"Current instrument found in portfolio: {currentInstrumentPortfolio}"); + } + + var openOperations = await GetOpenOperationsAsync(); + // Logger.LogInformation($"Open operations count: {openOperations.Count}"); + var openOperationsGroupedByPrice = openOperations.GroupBy(operation => operation.Price).ToList(); + + var deletedLotsSets = new List(); + foreach (var lotsSet in LotsSets) + { + if (openOperationsGroupedByPrice.All(openOperation => openOperation.Key != lotsSet.Key)) + { + deletedLotsSets.Add(lotsSet.Key); + } + } + foreach (var group in openOperationsGroupedByPrice) + { + LotsSets.TryAdd(group.Key, group.Sum(o => o.Quantity)); + } + foreach (var lotsSet in deletedLotsSets) + { + LotsSets.TryRemove(lotsSet, out long lot); + } + } + + protected void TrySubtractTradesFromOrder(ConcurrentDictionary orders, IOrderTrades orderTrades) + { + Logger.LogInformation($"TrySubtractTradesFromOrder.orderTrades: {orderTrades}"); + if (orders.TryGetValue(orderTrades.OrderId, out var activeOrder)) + { + foreach (var trade in orderTrades.Trades) + { + activeOrder.LotsRequested -= trade.Quantity; + } + Logger.LogInformation($"Active order: {activeOrder}"); + if (activeOrder.LotsRequested == 0) + { + orders.TryRemove(orderTrades.OrderId, out activeOrder); + ActiveSellOrderSourcePrice.TryRemove(orderTrades.OrderId, out decimal sourcePrice); + Logger.LogInformation($"Active order removed: {activeOrder}"); + } + } + } + + protected void LogLots() + { + foreach (var lot in LotsSets) + { + Logger.LogInformation($"{lot.Value} lots with {lot.Key} price"); + } + } + + protected void TryUpdateLots(IOrderTrades orderTrades) + { + Logger.LogInformation($"TryUpdateLots.orderTrades: {orderTrades}"); + foreach (var trade in orderTrades.Trades) + { + Logger.LogInformation($"orderTrades.Direction: {orderTrades.Direction}"); + Logger.LogInformation($"trade.Price: {trade.Price}"); + Logger.LogInformation($"trade.Quantity: {trade.Quantity}"); + if (orderTrades.Direction == OrderDirection.Buy) + { + LotsSets.AddOrUpdate(trade.Price, trade.Quantity, (key, value) => { + Logger.LogInformation($"Previous value: {value}"); + Logger.LogInformation($"New value: {value + trade.Quantity}"); + return value + trade.Quantity; + }); + } + else if (orderTrades.Direction == OrderDirection.Sell) + { + Logger.LogInformation($"orderTrades.OrderId: {orderTrades.OrderId}"); + if (ActiveSellOrderSourcePrice.TryGetValue(orderTrades.OrderId, out decimal sourcePrice)) + { + // Logger.LogInformation($"LotsSets.Count before TryUpdateOrRemove: {LotsSets.Count}"); + Logger.LogInformation($"sourcePrice: {sourcePrice}"); + var result = LotsSets.TryUpdateOrRemove(sourcePrice, (key, value) => { + Logger.LogInformation($"Previous value: {value}"); + Logger.LogInformation($"New value: {value - trade.Quantity}"); + return value - trade.Quantity; + }, (key, value) => { + Logger.LogInformation($"Remove condition: {value <= 0}"); + return value <= 0; + }); + Logger.LogInformation($"TryUpdateOrRemove.result: {result}"); + // Logger.LogInformation($"LotsSets.Count after TryUpdateOrRemove: {LotsSets.Count}"); + } + } + } + } + + protected async Task SendOrdersLoop(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + await Refresh(forceReset: true); + await SendOrders(cancellationToken); + } + catch (Exception ex) + { + if (!cancellationToken.IsCancellationRequested) + { + Logger.LogError(ex, "SendOrders exception."); + await Task.Delay(RecoveryInterval); + } + } + } + } + + protected async Task ReceiveTradesLoop(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + await Refresh(forceReset: true); + await ReceiveTrades(cancellationToken); + } + catch (Exception ex) + { + if (!cancellationToken.IsCancellationRequested) + { + Logger.LogError(ex, "ReceiveTrades exception."); + await Task.Delay(RecoveryInterval); + } + } + } + } + + protected async Task SendOrders(CancellationToken cancellationToken) + { + var orderBookStream = await ApiProvider.SubscribeToOrderBookAsync(Figi, Settings.MarketOrderBookDepth); + await foreach (var orderBook in orderBookStream) + { + if (cancellationToken.IsCancellationRequested) + break; + + var topBidOrder = orderBook.Bids.FirstOrDefault(); + if (topBidOrder == null) + { + Logger.LogInformation("No top bid order, skipping."); + continue; + } + var topBid = topBidOrder.Price; + var bestBidOrder = orderBook.Bids.FirstOrDefault(x => x.Quantity > Settings.MinimumMarketOrderSizeToBuy); + if (bestBidOrder == null) + { + Logger.LogInformation($"No best bid order, skipping."); + continue; + } + var bestBid = bestBidOrder.Price; + var bestAskOrder = orderBook.Asks.FirstOrDefault(x => x.Quantity > Settings.MinimumMarketOrderSizeToSell); + if (bestAskOrder == null) + { + Logger.LogInformation($"No best ask order, skipping."); + continue; + } + var bestAsk = bestAskOrder.Price; + + // Logger.LogInformation($"bid: {bestBid}, ask: {bestAsk}."); + + // Logger.LogInformation($"Time: {DateTime.Now}"); + // Logger.LogInformation($"ActiveBuyOrders.Count: {ActiveBuyOrders.Count}"); + // Logger.LogInformation($"ActiveSellOrders.Count: {ActiveSellOrders.Count}"); + + if (ActiveBuyOrders.Count == 0 && ActiveSellOrders.Count == 0) + { + var areOrdersPlaced = false; + // Process potential sell order + if (LotsSets.Count > 0) + { + Logger.LogInformation($"sell activated"); + Logger.LogInformation($"bid: {bestBid}, ask: {bestAsk}."); + var maxPrice = LotsSets.Keys.Max(); + Logger.LogInformation($"maxPrice: {maxPrice}"); + var totalAmount = LotsSets.Values.Sum(); + Logger.LogInformation($"totalAmount: {totalAmount}"); + var minimumSellPrice = GetMinimumSellPrice(maxPrice); + var targetSellPrice = GetTargetSellPrice(minimumSellPrice, bestAsk); + var marketLotsAtTargetPrice = orderBook.Asks.FirstOrDefault(o => o.Price == targetSellPrice)?.Quantity ?? 0; + Logger.LogInformation($"marketLotsAtTargetPrice: {marketLotsAtTargetPrice}"); + var response = await PlaceSellOrder(totalAmount, targetSellPrice); + ActiveSellOrderSourcePrice[response.OrderId] = maxPrice; + Logger.LogInformation($"sell complete"); + areOrdersPlaced = true; + } + if (!areOrdersPlaced) + { + if (IsTimeToBuy()) + { + // Process potential buy order + var (cashBalance, _) = await GetCashBalance(); + var lotPrice = bestBid * LotSize; + if (cashBalance > lotPrice) + { + Logger.LogInformation($"buy activated"); + Logger.LogInformation($"bid: {bestBid}, ask: {bestAsk}."); + var lots = (long)(cashBalance / lotPrice); + var marketLotsAtTargetPrice = orderBook.Bids.FirstOrDefault(o => o.Price == bestBid)?.Quantity ?? 0; + Logger.LogInformation($"marketLotsAtTargetPrice: {marketLotsAtTargetPrice}"); + var response = await PlaceBuyOrder(lots, bestBid); + Logger.LogInformation($"buy complete"); + areOrdersPlaced = true; + } + } + else + { + var currentTime = DateTime.UtcNow.TimeOfDay; + var nowTicks = DateTime.UtcNow.Ticks; + var originalValue = Interlocked.Read(ref LastWaitOutputTicks); + if (nowTicks - originalValue > WaitOutputInterval.Ticks) + { + Interlocked.Exchange(ref LastWaitOutputTicks, nowTicks); + Logger.LogInformation($"Buy order will be placed from {Settings.MinimumTimeToBuy} to {Settings.MaximumTimeToBuy}. Now it is {currentTime:hh\\:mm\\:ss}."); + } + continue; + } + } + if (areOrdersPlaced) + { + SyncActiveOrders(); + } + else + { + var nowTicks = DateTime.UtcNow.Ticks; + var originalValue = Interlocked.Read(ref LastSyncTicks); + if (nowTicks - originalValue > SyncInterval.Ticks) + { + Interlocked.Exchange(ref LastSyncTicks, nowTicks); + SyncLots(); + } + } + } + else if (ActiveBuyOrders.Count == 1) + { + var activeBuyOrder = ActiveBuyOrders.Single().Value; + if (IsTimeToBuy()) + { + var initialOrderPrice = activeBuyOrder.InitialSecurityPrice; + if (LotsSets.TryGetValue(initialOrderPrice, out var boughtLots) || LotsSets.Count == 0) + { + if (initialOrderPrice != bestBid && bestBidOrder.Quantity > Settings.MinimumMarketOrderSizeToChangeBuyPrice) + { + if (boughtLots > 0) + { + Logger.LogInformation($"buy trades are in progress"); + continue; + } + Logger.LogInformation($"bid: {bestBid}, ask: {bestAsk}."); + Logger.LogInformation($"initial buy order price: {initialOrderPrice}"); + Logger.LogInformation($"buy order price change activated"); + // Cancel order + if (!await TryCancelOrder(activeBuyOrder.OrderId)) + { + ActiveBuyOrders.Clear(); + Logger.LogInformation($"failed to cancel buy order."); + continue; + } + SetCashBalance(CashBalanceFree + CashBalanceLocked, 0); + // Place new order + var (cashBalance, _) = await GetCashBalance(); + var lotPrice = bestBid * LotSize; + if (cashBalance > lotPrice) + { + var lots = (long)(cashBalance / lotPrice); + var marketLotsAtTargetPrice = orderBook.Bids.FirstOrDefault(o => o.Price == bestBid)?.Quantity ?? 0; + Logger.LogInformation($"marketLotsAtTargetPrice: {marketLotsAtTargetPrice}"); + var response = await PlaceBuyOrder(lots, bestBid); + } + SyncActiveOrders(); + Logger.LogInformation($"buy order price change is complete"); + } + } + else + { + Logger.LogInformation($"bought lots with other prices found, cancelling buy order"); + // Cancel order + if (!await TryCancelOrder(activeBuyOrder.OrderId)) + { + ActiveBuyOrders.Clear(); + Logger.LogInformation($"failed to cancel buy order."); + continue; + } + SyncActiveOrders(); + Logger.LogInformation($"buy order cancelled"); + } + } + else + { + Logger.LogInformation($"It is not time to buy, cancelling buy order"); + // Cancel order + if (!await TryCancelOrder(activeBuyOrder.OrderId)) + { + ActiveBuyOrders.Clear(); + Logger.LogInformation($"failed to cancel buy order."); + continue; + } + SyncActiveOrders(); + Logger.LogInformation($"buy order cancelled"); + } + } + else if (ActiveSellOrders.Count == 1) + { + var activeSellOrder = ActiveSellOrders.Single().Value; + if (ActiveSellOrderSourcePrice.TryGetValue(activeSellOrder.OrderId, out var sourcePrice)) + { + var initialLots = activeSellOrder.InitialOrderPrice / activeSellOrder.InitialSecurityPrice; + var minimumSellPrice = GetMinimumSellPrice(sourcePrice); + if (topBid <= sourcePrice && topBid >= minimumSellPrice && topBidOrder.Quantity < (Settings.EarlySellOwnedLotsDelta + activeSellOrder.LotsRequested * Settings.EarlySellOwnedLotsMultiplier)) + { + if (activeSellOrder.LotsRequested < initialLots) + { + Logger.LogInformation($"sell trades are in progress"); + continue; + } + Logger.LogInformation($"early sell is activated"); + Logger.LogInformation($"topBid: {topBid}, bestBid: {bestBid}, bestAsk: {bestAsk}."); + Logger.LogInformation($"topBidOrder.Quantity: {topBidOrder.Quantity}"); + Logger.LogInformation($"EarlySellOwnedLotsDelta: {Settings.EarlySellOwnedLotsDelta}"); + Logger.LogInformation($"EarlySellOwnedLotsMultiplier: {Settings.EarlySellOwnedLotsMultiplier}"); + Logger.LogInformation($"LotsRequested: {activeSellOrder.LotsRequested}"); + Logger.LogInformation($"Threshold: {(Settings.EarlySellOwnedLotsDelta + activeSellOrder.LotsRequested * Settings.EarlySellOwnedLotsMultiplier)}"); + Logger.LogInformation($"initial sell order price: {sourcePrice}"); + // Cancel order + if (!await TryCancelOrder(activeSellOrder.OrderId)) + { + ActiveSellOrders.Clear(); + Logger.LogInformation($"failed to cancel sell order."); + continue; + } + // Place new order at top bid price + var response = await PlaceSellOrder(activeSellOrder.LotsRequested, topBid); + SyncActiveOrders(); + Logger.LogInformation($"early sell is complete"); + } + else + { + var initialOrderPrice = activeSellOrder.InitialSecurityPrice; + if (bestAsk >= minimumSellPrice && bestAsk != initialOrderPrice && bestAskOrder.Quantity > Settings.MinimumMarketOrderSizeToChangeSellPrice) + { + Logger.LogInformation($"sell order price change activated"); + Logger.LogInformation($"bid: {bestBid}, ask: {bestAsk}."); + Logger.LogInformation($"initial sell order price: {initialOrderPrice}"); + Logger.LogInformation($"initial sell order source price: {sourcePrice}"); + Logger.LogInformation($"minimumSellPrice: {minimumSellPrice}"); + // Cancel order + if (!await TryCancelOrder(activeSellOrder.OrderId)) + { + ActiveSellOrders.Clear(); + Logger.LogInformation($"failed to cancel sell order."); + continue; + } + // Place new order + var targetSellPrice = GetTargetSellPrice(minimumSellPrice, bestAsk); + var marketLotsAtTargetPrice = orderBook.Asks.FirstOrDefault(o => o.Price == targetSellPrice)?.Quantity ?? 0; + Logger.LogInformation($"marketLotsAtTargetPrice: {marketLotsAtTargetPrice}"); + var response = await PlaceSellOrder(activeSellOrder.LotsRequested, targetSellPrice); + ActiveSellOrderSourcePrice[response.OrderId] = sourcePrice; + SyncActiveOrders(); + Logger.LogInformation($"sell order price change is complete"); + } + } + } + } + } + } + + private bool IsTimeToBuy() + { + var currentTime = DateTime.UtcNow.TimeOfDay; + return currentTime > MinimumTimeToBuy && currentTime < MaximumTimeToBuy; + } + + private async Task<(decimal, decimal)> GetCashBalance(bool forceRemote = false) + { + var portfolio = await ApiProvider.GetPortfolioAsync(CurrentAccount.Id); + var balanceFree = portfolio.Money.Any() ? portfolio.Money.First(m => m.Currency == Settings.CashCurrency).Value : 0; + var balanceLocked = portfolio.Blocked.Any() ? portfolio.Blocked.First(m => m.Currency == Settings.CashCurrency).Value : 0; + Logger.LogInformation($"Local cash balance, {Settings.CashCurrency}: {CashBalanceFree} ({CashBalanceLocked} locked)"); + Logger.LogInformation($"Remote cash balance, {Settings.CashCurrency}: {balanceFree} ({balanceLocked} locked)"); + // If remote balance is greater than local balance, update local balance + if (balanceFree > CashBalanceFree) + { + SetCashBalance(balanceFree, balanceLocked); + return (CashBalanceFree, CashBalanceLocked); + } + return (!forceRemote && PreferLocalCashBalance) ? (CashBalanceFree, CashBalanceLocked) : (balanceFree, balanceLocked); + } + + private void SetCashBalance(decimal free, decimal locked) + { + CashBalanceFree = free; + CashBalanceLocked = locked; + Logger.LogInformation($"New local cash balance, {Settings.CashCurrency}: {CashBalanceFree} ({CashBalanceLocked} locked)"); + } + + private decimal GetMinimumSellPrice(decimal sourcePrice) + { + var minimumSellPrice = sourcePrice + Settings.MinimumProfitSteps * PriceStep; + // Logger.LogInformation($"minimumSellPrice: {minimumSellPrice}"); + return minimumSellPrice; + } + + private decimal GetTargetSellPrice(decimal minimumSellPrice, decimal bestAsk) + { + var targetSellPrice = Math.Max(minimumSellPrice, bestAsk); + Logger.LogInformation($"targetSellPrice: {targetSellPrice}"); + return targetSellPrice; + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + var tasks = new[] + { + ReceiveTradesLoop(cancellationToken), + SendOrdersLoop(cancellationToken) + }; + await Task.WhenAll(tasks); + } + + protected async Task Refresh(bool forceReset = false) + { + var nowTicks = DateTime.UtcNow.Ticks; + var originalValue = Interlocked.Exchange(ref LastRefreshTicks, nowTicks); + if (nowTicks - originalValue < RefreshInterval.Ticks) + { + return; + } + SyncActiveOrders(forceReset); + LogActiveOrders(); + SyncLots(forceReset); + LogLots(); + if (forceReset) + { + var cashBalance = await GetCashBalance(forceRemote: true); + SetCashBalance(cashBalance.Item1, cashBalance.Item2); + } + } + + private async Task GetOpenOperationsAsync() + { + DateTime accountOpenDate = DateTime.SpecifyKind(CurrentAccount.OpenedDate, DateTimeKind.Utc).AddHours(-3); + DateTime lastCheckpoint = DateTime.SpecifyKind(LastOperationsCheckpoint, DateTimeKind.Utc).AddHours(-3); + DateTime from = new[] { accountOpenDate, lastCheckpoint }.Max(); + + var operations = await ApiProvider.GetOperationsAsync(CurrentAccount.Id, Figi, from, DateTime.UtcNow.AddDays(4)); + var operationsList = operations.Select(o => (o.OperationType, o.Date, o.Quantity, o.Price)).OrderBy(x => x.Date).ToList(); + + // Log operations + foreach (var operation in operationsList) + { + Logger.LogInformation($"{operation.OperationType} operation with {operation.Quantity} lots at {operation.Price} price on {operation.Date.ToString("o", System.Globalization.CultureInfo.InvariantCulture)}."); + } + + if (operationsList.Any() && operationsList.First().OperationType == OperationType.Sell) + { + throw new InvalidOperationException("Sell operation is first in list. It will not possible to correctly identify open operations."); + } + + var totalSoldQuantity = operationsList.Where(o => o.OperationType == OperationType.Sell).Sum(o => o.Quantity); + Logger.LogInformation($"Total sell operations quantity {totalSoldQuantity}"); + + var openOperations = operationsList.Where(o => o.OperationType == OperationType.Buy).ToList(); + + var totalBoughtQuantity = openOperations.Sum(o => o.Quantity); + Logger.LogInformation($"Total buy operations quantity {totalBoughtQuantity}"); + + if (totalSoldQuantity > 0 && totalSoldQuantity == totalBoughtQuantity) + { + var baseDate = operationsList.Last().Date.AddMilliseconds(1); + LastOperationsCheckpoint = baseDate.AddHours(3); + Logger.LogInformation($"New last operations checkpoint: {baseDate.ToString("o", System.Globalization.CultureInfo.InvariantCulture)}"); + } + + for (var i = 0; totalSoldQuantity > 0 && i < openOperations.Count; i++) + { + var openOperation = openOperations[i]; + var actualQuantity = openOperation.Quantity; + if (totalSoldQuantity < actualQuantity) + { + Logger.LogInformation($"final totalSoldQuantity: \t{totalSoldQuantity}"); + Logger.LogInformation($"final actualQuantity: \t{actualQuantity}"); + openOperations[i] = (openOperation.OperationType, openOperation.Date, actualQuantity - totalSoldQuantity, openOperation.Price); + Logger.LogInformation($"openOperation.Quantity: \t{openOperations[i].Quantity}"); + totalSoldQuantity = 0; + continue; + } + totalSoldQuantity -= actualQuantity; + openOperations.RemoveAt(i); + --i; + } + + // log operations + foreach (var openOperation in openOperations) + { + Logger.LogInformation($"Open operation \t{openOperation}"); + } + + if (openOperations.Any(o => o.Price == 0m)) + { + throw new InvalidOperationException("Open operation with price 0 is found."); + } + + return openOperations; + } + + private async Task PlaceSellOrder(long amount, decimal price) + { + var sellOrderRequest = new TinkoffOrderRequest + { + OrderId = Guid.NewGuid().ToString(), + AccountId = CurrentAccount.Id, + Direction = OrderDirection.Sell, + OrderType = OrderType.Limit, + Figi = Figi, + Quantity = amount, + Price = price + }; + + var response = await ApiProvider.PlaceOrderAsync(sellOrderRequest); + Logger.LogInformation($"Sell order placed: {response}"); + return response; + } + + private async Task PlaceBuyOrder(long amount, decimal price) + { + var buyOrderRequest = new TinkoffOrderRequest + { + OrderId = Guid.NewGuid().ToString(), + AccountId = CurrentAccount.Id, + Direction = OrderDirection.Buy, + OrderType = OrderType.Limit, + Figi = Figi, + Quantity = amount, + Price = price, + }; + + var response = await ApiProvider.PlaceOrderAsync(buyOrderRequest); + var total = amount * price; + SetCashBalance(CashBalanceFree - total, CashBalanceLocked + total); + Logger.LogInformation($"Buy order placed: {response}"); + return response; + } + + private async Task CancelOrder(string orderId) + { + var response = await ApiProvider.CancelOrderAsync(CurrentAccount.Id, orderId); + Logger.LogInformation($"Order cancelled: {response}"); + return response; + } + + private async Task TryCancelOrder(string orderId) + { + try + { + await CancelOrder(orderId); + return true; + } + catch (Exception ex) + { + await Task.Delay(FailedCancelOrderInterval); + Logger.LogError(ex, "Error while cancelling order"); + return false; + } + } +} \ No newline at end of file diff --git a/csharp/TraderBot/ApiProviderSettings.cs b/csharp/TraderBot/ApiProviderSettings.cs new file mode 100644 index 00000000..3cb150c0 --- /dev/null +++ b/csharp/TraderBot/ApiProviderSettings.cs @@ -0,0 +1,12 @@ +namespace TraderBot; + +public class ApiProviderSettings +{ + public string Provider { get; set; } = "Tinkoff"; +} + +public class InvestApiSettings +{ + public string? AccessToken { get; set; } + public string? AppName { get; set; } +} \ No newline at end of file diff --git a/csharp/TraderBot/Interfaces/IAccount.cs b/csharp/TraderBot/Interfaces/IAccount.cs new file mode 100644 index 00000000..7a374ba6 --- /dev/null +++ b/csharp/TraderBot/Interfaces/IAccount.cs @@ -0,0 +1,8 @@ +namespace TraderBot.Interfaces; + +public interface IAccount +{ + string Id { get; } + string Name { get; } + DateTime OpenedDate { get; } +} \ No newline at end of file diff --git a/csharp/TraderBot/Interfaces/IInstrument.cs b/csharp/TraderBot/Interfaces/IInstrument.cs new file mode 100644 index 00000000..679b37a9 --- /dev/null +++ b/csharp/TraderBot/Interfaces/IInstrument.cs @@ -0,0 +1,16 @@ +namespace TraderBot.Interfaces; + +public interface IInstrument +{ + string Figi { get; } + string Ticker { get; } + int Lot { get; } + decimal MinPriceIncrement { get; } + TradingInstrumentType InstrumentType { get; } +} + +public enum TradingInstrumentType +{ + Etf, + Shares +} \ No newline at end of file diff --git a/csharp/TraderBot/Interfaces/IOperation.cs b/csharp/TraderBot/Interfaces/IOperation.cs new file mode 100644 index 00000000..37b4fa6e --- /dev/null +++ b/csharp/TraderBot/Interfaces/IOperation.cs @@ -0,0 +1,19 @@ +namespace TraderBot.Interfaces; + +public interface IOperation +{ + string Id { get; } + TradingOperationType OperationType { get; } + DateTime Date { get; } + long Quantity { get; } + decimal Price { get; } + long QuantityRest { get; } + IEnumerable? Trades { get; } + long GetActualQuantity(); +} + +public enum TradingOperationType +{ + Buy, + Sell +} \ No newline at end of file diff --git a/csharp/TraderBot/Interfaces/IOrder.cs b/csharp/TraderBot/Interfaces/IOrder.cs new file mode 100644 index 00000000..d64dedf9 --- /dev/null +++ b/csharp/TraderBot/Interfaces/IOrder.cs @@ -0,0 +1,48 @@ +namespace TraderBot.Interfaces; + +public interface IOrder +{ + string OrderId { get; } + string Figi { get; } + TradingOrderDirection Direction { get; } + long LotsRequested { get; set; } + decimal InitialSecurityPrice { get; } + decimal InitialOrderPrice { get; } +} + +public interface IOrderRequest +{ + string OrderId { get; } + string AccountId { get; } + TradingOrderDirection Direction { get; } + TradingOrderType OrderType { get; } + string Figi { get; } + long Quantity { get; } + decimal Price { get; } +} + +public interface IOrderResponse +{ + string OrderId { get; } + string Figi { get; } + TradingOrderDirection Direction { get; } + long LotsRequested { get; } + decimal InitialSecurityPrice { get; } +} + +public interface ICancelOrderResponse +{ + string OrderId { get; } +} + +public enum TradingOrderDirection +{ + Buy, + Sell +} + +public enum TradingOrderType +{ + Limit, + Market +} \ No newline at end of file diff --git a/csharp/TraderBot/Interfaces/IOrderBook.cs b/csharp/TraderBot/Interfaces/IOrderBook.cs new file mode 100644 index 00000000..20e503a7 --- /dev/null +++ b/csharp/TraderBot/Interfaces/IOrderBook.cs @@ -0,0 +1,18 @@ +namespace TraderBot.Interfaces; + +public interface IOrderBookStream : IAsyncEnumerable +{ +} + +public interface IOrderBook +{ + string Figi { get; } + IEnumerable Bids { get; } + IEnumerable Asks { get; } +} + +public interface IOrderBookEntry +{ + decimal Price { get; } + long Quantity { get; } +} \ No newline at end of file diff --git a/csharp/TraderBot/Interfaces/IPosition.cs b/csharp/TraderBot/Interfaces/IPosition.cs new file mode 100644 index 00000000..e2697901 --- /dev/null +++ b/csharp/TraderBot/Interfaces/IPosition.cs @@ -0,0 +1,26 @@ +namespace TraderBot.Interfaces; + +public interface IPosition +{ + string Figi { get; } + decimal Balance { get; } +} + +public interface IPortfolio +{ + IEnumerable Positions { get; } + IEnumerable Money { get; } + IEnumerable Blocked { get; } +} + +public interface IPortfolioPosition +{ + string Figi { get; } + decimal Quantity { get; } +} + +public interface IMoneyValue +{ + string Currency { get; } + decimal Value { get; } +} \ No newline at end of file diff --git a/csharp/TraderBot/Interfaces/ITrades.cs b/csharp/TraderBot/Interfaces/ITrades.cs new file mode 100644 index 00000000..1568e32b --- /dev/null +++ b/csharp/TraderBot/Interfaces/ITrades.cs @@ -0,0 +1,31 @@ +namespace TraderBot.Interfaces; + +public interface ITradesStream : IAsyncEnumerable +{ +} + +public interface ITradeData +{ + TradeDataType DataType { get; } + IOrderTrades? OrderTrades { get; } +} + +public interface IOrderTrades +{ + string OrderId { get; } + TradingOrderDirection Direction { get; } + IEnumerable Trades { get; } +} + +public interface ITrade +{ + long Quantity { get; } + decimal Price { get; } + DateTime DateTime { get; } +} + +public enum TradeDataType +{ + OrderTrades, + Ping +} \ No newline at end of file diff --git a/csharp/TraderBot/Interfaces/ITradingApiProvider.cs b/csharp/TraderBot/Interfaces/ITradingApiProvider.cs new file mode 100644 index 00000000..6da22602 --- /dev/null +++ b/csharp/TraderBot/Interfaces/ITradingApiProvider.cs @@ -0,0 +1,16 @@ +namespace TraderBot.Interfaces; + +public interface ITradingApiProvider +{ + Task InitializeAsync(); + Task> GetAccountsAsync(); + Task GetInstrumentAsync(string ticker, TradingInstrumentType instrumentType); + Task SubscribeToOrderBookAsync(string figi, int depth); + Task SubscribeToTradesAsync(string accountId); + Task> GetOrdersAsync(string accountId); + Task> GetPositionsAsync(string accountId); + Task GetPortfolioAsync(string accountId); + Task> GetOperationsAsync(string accountId, string figi, DateTime from, DateTime to); + Task PlaceOrderAsync(IOrderRequest orderRequest); + Task CancelOrderAsync(string accountId, string orderId); +} \ No newline at end of file diff --git a/csharp/TraderBot/Program.cs b/csharp/TraderBot/Program.cs index e5ac64bb..8e60d582 100644 --- a/csharp/TraderBot/Program.cs +++ b/csharp/TraderBot/Program.cs @@ -1,29 +1,67 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Configuration.UserSecrets; +using Microsoft.Extensions.Logging; using Tinkoff.InvestApi; using TraderBot; +using TraderBot.Interfaces; +using TraderBot.Providers; var builder = Host.CreateDefaultBuilder(args); var host = builder .ConfigureServices((context, services) => { + // Register settings services.AddSingleton(_ => { var section = context.Configuration.GetSection(nameof(TradingSettings)); - return section.Get(); + return section.Get()!; }); - services.AddHostedService(); + + services.AddSingleton(_ => + { + var section = context.Configuration.GetSection(nameof(ApiProviderSettings)); + return section.Get() ?? new ApiProviderSettings(); + }); + + services.AddSingleton(_ => + { + var section = context.Configuration.GetSection(nameof(InvestApiSettings)); + return section.Get()!; + }); + + // Register API providers services.AddInvestApiClient((_, settings) => { var section = context.Configuration.GetSection(nameof(InvestApiSettings)); var loadedSettings = section.Get(); - settings.AccessToken = loadedSettings.AccessToken; + settings.AccessToken = loadedSettings!.AccessToken; settings.AppName = loadedSettings.AppName; context.Configuration.Bind(settings); }); + + // Register the trading API provider based on configuration + services.AddSingleton(provider => + { + var apiProviderSettings = provider.GetRequiredService(); + + switch (apiProviderSettings.Provider.ToLowerInvariant()) + { + case "tinkoff": + var investApi = provider.GetRequiredService(); + var tinkoffLogger = provider.GetRequiredService>(); + return new TinkoffApiProvider(investApi, tinkoffLogger); + case "mock": + var mockLogger = provider.GetRequiredService>(); + return new MockApiProvider(mockLogger); + default: + throw new InvalidOperationException($"Unsupported API provider: {apiProviderSettings.Provider}"); + } + }); + + // Register the trading service + services.AddHostedService(); }) .Build(); -await host.RunAsync(); +await host.RunAsync(); \ No newline at end of file diff --git a/csharp/TraderBot/Program.cs.bak b/csharp/TraderBot/Program.cs.bak new file mode 100644 index 00000000..e5ac64bb --- /dev/null +++ b/csharp/TraderBot/Program.cs.bak @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.UserSecrets; +using Tinkoff.InvestApi; +using TraderBot; + +var builder = Host.CreateDefaultBuilder(args); +var host = builder + .ConfigureServices((context, services) => + { + services.AddSingleton(_ => + { + var section = context.Configuration.GetSection(nameof(TradingSettings)); + return section.Get(); + }); + services.AddHostedService(); + services.AddInvestApiClient((_, settings) => + { + var section = context.Configuration.GetSection(nameof(InvestApiSettings)); + var loadedSettings = section.Get(); + settings.AccessToken = loadedSettings.AccessToken; + settings.AppName = loadedSettings.AppName; + context.Configuration.Bind(settings); + }); + }) + .Build(); + +await host.RunAsync(); diff --git a/csharp/TraderBot/Providers/MockApiProvider.cs b/csharp/TraderBot/Providers/MockApiProvider.cs new file mode 100644 index 00000000..422f6df3 --- /dev/null +++ b/csharp/TraderBot/Providers/MockApiProvider.cs @@ -0,0 +1,273 @@ +using Microsoft.Extensions.Logging; +using TraderBot.Interfaces; + +namespace TraderBot.Providers; + +public class MockApiProvider : ITradingApiProvider +{ + private readonly ILogger _logger; + private readonly List _accounts; + private readonly Dictionary _instruments; + private readonly Random _random = new Random(); + + public MockApiProvider(ILogger logger) + { + _logger = logger; + + // Initialize mock data + _accounts = new List + { + new MockAccount("mock-account-1", "Mock Trading Account", DateTime.UtcNow.AddYears(-1)) + }; + + _instruments = new Dictionary + { + ["MOCK"] = new MockInstrument("MOCK-FIGI", "MOCK", 1, 0.01m, InstrumentType.Etf), + ["MOCKSHARE"] = new MockInstrument("MOCK-SHARE-FIGI", "MOCKSHARE", 1, 0.01m, InstrumentType.Shares) + }; + } + + public Task InitializeAsync() + { + _logger.LogInformation("Mock API Provider initialized"); + return Task.CompletedTask; + } + + public Task> GetAccountsAsync() + { + _logger.LogInformation("Getting mock accounts"); + return Task.FromResult>(_accounts); + } + + public Task GetInstrumentAsync(string ticker, InstrumentType instrumentType) + { + _logger.LogInformation($"Getting mock instrument: {ticker}"); + if (_instruments.TryGetValue(ticker, out var instrument)) + { + return Task.FromResult(instrument); + } + throw new InvalidOperationException($"Instrument {ticker} not found in mock provider"); + } + + public Task SubscribeToOrderBookAsync(string figi, int depth) + { + _logger.LogInformation($"Subscribing to mock order book: {figi}"); + return Task.FromResult(new MockOrderBookStream(_logger)); + } + + public Task SubscribeToTradesAsync(string accountId) + { + _logger.LogInformation($"Subscribing to mock trades: {accountId}"); + return Task.FromResult(new MockTradesStream(_logger)); + } + + public Task> GetOrdersAsync(string accountId) + { + _logger.LogInformation($"Getting mock orders for account: {accountId}"); + return Task.FromResult>(new List()); + } + + public Task> GetPositionsAsync(string accountId) + { + _logger.LogInformation($"Getting mock positions for account: {accountId}"); + return Task.FromResult>(new List()); + } + + public Task GetPortfolioAsync(string accountId) + { + _logger.LogInformation($"Getting mock portfolio for account: {accountId}"); + return Task.FromResult(new MockPortfolio()); + } + + public Task> GetOperationsAsync(string accountId, string figi, DateTime from, DateTime to) + { + _logger.LogInformation($"Getting mock operations for account: {accountId}, figi: {figi}"); + return Task.FromResult>(new List()); + } + + public Task PlaceOrderAsync(IOrderRequest orderRequest) + { + _logger.LogInformation($"Placing mock order: {orderRequest.Direction} {orderRequest.Quantity} @ {orderRequest.Price}"); + return Task.FromResult(new MockOrderResponse(orderRequest.OrderId, orderRequest.Figi, orderRequest.Direction, orderRequest.Quantity, orderRequest.Price)); + } + + public Task CancelOrderAsync(string accountId, string orderId) + { + _logger.LogInformation($"Canceling mock order: {orderId}"); + return Task.FromResult(new MockCancelOrderResponse(orderId)); + } +} + +// Mock implementations of interfaces +public class MockAccount : IAccount +{ + public MockAccount(string id, string name, DateTime openedDate) + { + Id = id; + Name = name; + OpenedDate = openedDate; + } + + public string Id { get; } + public string Name { get; } + public DateTime OpenedDate { get; } + + public override string ToString() => $"MockAccount(Id={Id}, Name={Name})"; +} + +public class MockInstrument : IInstrument +{ + public MockInstrument(string figi, string ticker, int lot, decimal minPriceIncrement, InstrumentType instrumentType) + { + Figi = figi; + Ticker = ticker; + Lot = lot; + MinPriceIncrement = minPriceIncrement; + InstrumentType = instrumentType; + } + + public string Figi { get; } + public string Ticker { get; } + public int Lot { get; } + public decimal MinPriceIncrement { get; } + public InstrumentType InstrumentType { get; } + + public override string ToString() => $"MockInstrument(Ticker={Ticker}, Figi={Figi})"; +} + +public class MockOrderBookStream : IOrderBookStream +{ + private readonly ILogger _logger; + + public MockOrderBookStream(ILogger logger) + { + _logger = logger; + } + + public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + var random = new Random(); + while (!cancellationToken.IsCancellationRequested) + { + var basePrice = 100m + random.Next(-10, 10); + var orderBook = new MockOrderBook("MOCK-FIGI", basePrice); + yield return orderBook; + await Task.Delay(1000, cancellationToken); + } + } +} + +public class MockOrderBook : IOrderBook +{ + public MockOrderBook(string figi, decimal basePrice) + { + Figi = figi; + var random = new Random(); + + Bids = new List + { + new MockOrderBookEntry(basePrice - 0.01m, 1000 + random.Next(0, 500)), + new MockOrderBookEntry(basePrice - 0.02m, 500 + random.Next(0, 300)), + }; + + Asks = new List + { + new MockOrderBookEntry(basePrice + 0.01m, 1000 + random.Next(0, 500)), + new MockOrderBookEntry(basePrice + 0.02m, 500 + random.Next(0, 300)), + }; + } + + public string Figi { get; } + public IEnumerable Bids { get; } + public IEnumerable Asks { get; } +} + +public class MockOrderBookEntry : IOrderBookEntry +{ + public MockOrderBookEntry(decimal price, long quantity) + { + Price = price; + Quantity = quantity; + } + + public decimal Price { get; } + public long Quantity { get; } +} + +public class MockTradesStream : ITradesStream +{ + private readonly ILogger _logger; + + public MockTradesStream(ILogger logger) + { + _logger = logger; + } + + public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + while (!cancellationToken.IsCancellationRequested) + { + // Send ping periodically + yield return new MockTradeData(); + await Task.Delay(30000, cancellationToken); // 30 second ping interval + } + } +} + +public class MockTradeData : ITradeData +{ + public TradeDataType DataType => TradeDataType.Ping; + public IOrderTrades? OrderTrades => null; +} + +public class MockPortfolio : IPortfolio +{ + public IEnumerable Positions => new List(); + public IEnumerable Money => new List + { + new MockMoneyValue("rub", 10000m) + }; + public IEnumerable Blocked => new List(); +} + +public class MockMoneyValue : IMoneyValue +{ + public MockMoneyValue(string currency, decimal value) + { + Currency = currency; + Value = value; + } + + public string Currency { get; } + public decimal Value { get; } +} + +public class MockOrderResponse : IOrderResponse +{ + public MockOrderResponse(string orderId, string figi, OrderDirection direction, long lotsRequested, decimal initialSecurityPrice) + { + OrderId = orderId; + Figi = figi; + Direction = direction; + LotsRequested = lotsRequested; + InitialSecurityPrice = initialSecurityPrice; + } + + public string OrderId { get; } + public string Figi { get; } + public OrderDirection Direction { get; } + public long LotsRequested { get; } + public decimal InitialSecurityPrice { get; } + + public override string ToString() => $"MockOrder(Id={OrderId}, Direction={Direction}, Quantity={LotsRequested}, Price={InitialSecurityPrice})"; +} + +public class MockCancelOrderResponse : ICancelOrderResponse +{ + public MockCancelOrderResponse(string orderId) + { + OrderId = orderId; + } + + public string OrderId { get; } +} \ No newline at end of file diff --git a/csharp/TraderBot/Providers/Tinkoff/TinkoffAccount.cs b/csharp/TraderBot/Providers/Tinkoff/TinkoffAccount.cs new file mode 100644 index 00000000..baca1c10 --- /dev/null +++ b/csharp/TraderBot/Providers/Tinkoff/TinkoffAccount.cs @@ -0,0 +1,18 @@ +using Tinkoff.InvestApi.V1; +using TraderBot.Interfaces; + +namespace TraderBot.Providers.Tinkoff; + +public class TinkoffAccount : IAccount +{ + private readonly Account _account; + + public TinkoffAccount(Account account) + { + _account = account; + } + + public string Id => _account.Id; + public string Name => _account.Name; + public DateTime OpenedDate => _account.OpenedDate.ToDateTime(); +} \ No newline at end of file diff --git a/csharp/TraderBot/Providers/Tinkoff/TinkoffInstrument.cs b/csharp/TraderBot/Providers/Tinkoff/TinkoffInstrument.cs new file mode 100644 index 00000000..c1439d35 --- /dev/null +++ b/csharp/TraderBot/Providers/Tinkoff/TinkoffInstrument.cs @@ -0,0 +1,33 @@ +using Tinkoff.InvestApi.V1; +using TraderBot.Interfaces; + +namespace TraderBot.Providers.Tinkoff; + +public class TinkoffInstrument : IInstrument +{ + public TinkoffInstrument(Etf etf, TradingInstrumentType instrumentType) + { + Figi = etf.Figi; + Ticker = etf.Ticker; + Lot = etf.Lot; + MinPriceIncrement = QuotationToDecimal(etf.MinPriceIncrement); + InstrumentType = instrumentType; + } + + public TinkoffInstrument(Share share, TradingInstrumentType instrumentType) + { + Figi = share.Figi; + Ticker = share.Ticker; + Lot = share.Lot; + MinPriceIncrement = QuotationToDecimal(share.MinPriceIncrement); + InstrumentType = instrumentType; + } + + public string Figi { get; } + public string Ticker { get; } + public int Lot { get; } + public decimal MinPriceIncrement { get; } + public TradingInstrumentType InstrumentType { get; } + + private static decimal QuotationToDecimal(Quotation value) => value.Units + value.Nano / 1000000000m; +} \ No newline at end of file diff --git a/csharp/TraderBot/Providers/Tinkoff/TinkoffOperation.cs b/csharp/TraderBot/Providers/Tinkoff/TinkoffOperation.cs new file mode 100644 index 00000000..632e7771 --- /dev/null +++ b/csharp/TraderBot/Providers/Tinkoff/TinkoffOperation.cs @@ -0,0 +1,44 @@ +using Tinkoff.InvestApi.V1; +using TraderBot.Interfaces; + +namespace TraderBot.Providers.Tinkoff; + +public class TinkoffOperation : IOperation +{ + private readonly Operation _operation; + + public TinkoffOperation(Operation operation) + { + _operation = operation; + } + + public string Id => _operation.Id; + public OperationType OperationType => (OperationType)_operation.OperationType; + public DateTime Date => _operation.Date.ToDateTime(); + public long Quantity => _operation.Quantity; + public decimal Price => MoneyValueToDecimal(_operation.Price); + public long QuantityRest => _operation.QuantityRest; + public IEnumerable? Trades => _operation.Trades?.Select(trade => new TinkoffOperationTrade(trade)); + + public long GetActualQuantity() => (_operation.Trades == null || _operation.Trades.Count <= 0) + ? _operation.Quantity - _operation.QuantityRest + : _operation.Trades.Sum(trade => trade.Quantity); + + private static decimal MoneyValueToDecimal(MoneyValue value) => value.Units + value.Nano / 1000000000m; +} + +public class TinkoffOperationTrade : ITrade +{ + private readonly OperationTrade _trade; + + public TinkoffOperationTrade(OperationTrade trade) + { + _trade = trade; + } + + public long Quantity => _trade.Quantity; + public decimal Price => MoneyValueToDecimal(_trade.Price); + public DateTime DateTime => _trade.DateTime.ToDateTime(); + + private static decimal MoneyValueToDecimal(MoneyValue value) => value.Units + value.Nano / 1000000000m; +} \ No newline at end of file diff --git a/csharp/TraderBot/Providers/Tinkoff/TinkoffOrder.cs b/csharp/TraderBot/Providers/Tinkoff/TinkoffOrder.cs new file mode 100644 index 00000000..df51db55 --- /dev/null +++ b/csharp/TraderBot/Providers/Tinkoff/TinkoffOrder.cs @@ -0,0 +1,64 @@ +using Tinkoff.InvestApi.V1; +using TraderBot.Interfaces; + +namespace TraderBot.Providers.Tinkoff; + +public class TinkoffOrder : IOrder +{ + private readonly OrderState _orderState; + + public TinkoffOrder(OrderState orderState) + { + _orderState = orderState; + } + + public string OrderId => _orderState.OrderId; + public string Figi => _orderState.Figi; + public TradingOrderDirection Direction => (TradingOrderDirection)_orderState.Direction; + public long LotsRequested { get => _orderState.LotsRequested; set => _orderState.LotsRequested = value; } + public decimal InitialSecurityPrice => MoneyValueToDecimal(_orderState.InitialSecurityPrice); + public decimal InitialOrderPrice => MoneyValueToDecimal(_orderState.InitialOrderPrice); + + private static decimal MoneyValueToDecimal(MoneyValue value) => value.Units + value.Nano / 1000000000m; +} + +public class TinkoffOrderResponse : IOrderResponse +{ + private readonly PostOrderResponse _response; + + public TinkoffOrderResponse(PostOrderResponse response) + { + _response = response; + } + + public string OrderId => _response.OrderId; + public string Figi => _response.Figi; + public TradingOrderDirection Direction => (TradingOrderDirection)_response.Direction; + public long LotsRequested => _response.LotsRequested; + public decimal InitialSecurityPrice => MoneyValueToDecimal(_response.InitialSecurityPrice); + + private static decimal MoneyValueToDecimal(MoneyValue value) => value.Units + value.Nano / 1000000000m; +} + +public class TinkoffCancelOrderResponse : ICancelOrderResponse +{ + private readonly CancelOrderResponse _response; + + public TinkoffCancelOrderResponse(CancelOrderResponse response) + { + _response = response; + } + + public string OrderId => _response.OrderId; +} + +public class TinkoffOrderRequest : IOrderRequest +{ + public string OrderId { get; set; } = string.Empty; + public string AccountId { get; set; } = string.Empty; + public TradingOrderDirection Direction { get; set; } + public TradingOrderType OrderType { get; set; } + public string Figi { get; set; } = string.Empty; + public long Quantity { get; set; } + public decimal Price { get; set; } +} \ No newline at end of file diff --git a/csharp/TraderBot/Providers/Tinkoff/TinkoffOrderBook.cs b/csharp/TraderBot/Providers/Tinkoff/TinkoffOrderBook.cs new file mode 100644 index 00000000..ddb60e2d --- /dev/null +++ b/csharp/TraderBot/Providers/Tinkoff/TinkoffOrderBook.cs @@ -0,0 +1,57 @@ +using Microsoft.Extensions.Logging; +using Tinkoff.InvestApi.V1; +using TraderBot.Interfaces; + +namespace TraderBot.Providers.Tinkoff; + +public class TinkoffOrderBookStream : IOrderBookStream +{ + private readonly MarketDataStreamService.MarketDataStreamClient.MarketDataStream _stream; + private readonly ILogger _logger; + + public TinkoffOrderBookStream(MarketDataStreamService.MarketDataStreamClient.MarketDataStream stream, ILogger logger) + { + _stream = stream; + _logger = logger; + } + + public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + await foreach (var data in _stream.ResponseStream.ReadAllAsync(cancellationToken)) + { + if (data.PayloadCase == MarketDataResponse.PayloadOneofCase.Orderbook) + { + yield return new TinkoffOrderBook(data.Orderbook); + } + } + } +} + +public class TinkoffOrderBook : IOrderBook +{ + private readonly OrderBook _orderBook; + + public TinkoffOrderBook(OrderBook orderBook) + { + _orderBook = orderBook; + } + + public string Figi => _orderBook.Figi; + public IEnumerable Bids => _orderBook.Bids.Select(bid => new TinkoffOrderBookEntry(bid)); + public IEnumerable Asks => _orderBook.Asks.Select(ask => new TinkoffOrderBookEntry(ask)); +} + +public class TinkoffOrderBookEntry : IOrderBookEntry +{ + private readonly Order _order; + + public TinkoffOrderBookEntry(Order order) + { + _order = order; + } + + public decimal Price => QuotationToDecimal(_order.Price); + public long Quantity => _order.Quantity; + + private static decimal QuotationToDecimal(Quotation value) => value.Units + value.Nano / 1000000000m; +} \ No newline at end of file diff --git a/csharp/TraderBot/Providers/Tinkoff/TinkoffPosition.cs b/csharp/TraderBot/Providers/Tinkoff/TinkoffPosition.cs new file mode 100644 index 00000000..94bc3f4a --- /dev/null +++ b/csharp/TraderBot/Providers/Tinkoff/TinkoffPosition.cs @@ -0,0 +1,59 @@ +using Tinkoff.InvestApi.V1; +using TraderBot.Interfaces; + +namespace TraderBot.Providers.Tinkoff; + +public class TinkoffPosition : IPosition +{ + private readonly PositionsSecurities _position; + + public TinkoffPosition(PositionsSecurities position) + { + _position = position; + } + + public string Figi => _position.Figi; + public decimal Balance => _position.Balance; +} + +public class TinkoffPortfolio : IPortfolio +{ + private readonly PortfolioResponse _portfolio; + + public TinkoffPortfolio(PortfolioResponse portfolio) + { + _portfolio = portfolio; + } + + public IEnumerable Positions => _portfolio.Positions.Select(p => new TinkoffPortfolioPosition(p)); + public IEnumerable Money => _portfolio.Money.Select(m => new TinkoffMoneyValue(m)); + public IEnumerable Blocked => _portfolio.Blocked.Select(b => new TinkoffMoneyValue(b)); +} + +public class TinkoffPortfolioPosition : IPortfolioPosition +{ + private readonly PortfolioPosition _position; + + public TinkoffPortfolioPosition(PortfolioPosition position) + { + _position = position; + } + + public string Figi => _position.Figi; + public decimal Quantity => MoneyValueToDecimal(_position.Quantity); + + private static decimal MoneyValueToDecimal(MoneyValue value) => value.Units + value.Nano / 1000000000m; +} + +public class TinkoffMoneyValue : IMoneyValue +{ + private readonly MoneyValue _moneyValue; + + public TinkoffMoneyValue(MoneyValue moneyValue) + { + _moneyValue = moneyValue; + } + + public string Currency => _moneyValue.Currency; + public decimal Value => _moneyValue.Units + _moneyValue.Nano / 1000000000m; +} \ No newline at end of file diff --git a/csharp/TraderBot/Providers/Tinkoff/TinkoffTrades.cs b/csharp/TraderBot/Providers/Tinkoff/TinkoffTrades.cs new file mode 100644 index 00000000..722bf5b6 --- /dev/null +++ b/csharp/TraderBot/Providers/Tinkoff/TinkoffTrades.cs @@ -0,0 +1,76 @@ +using Microsoft.Extensions.Logging; +using Tinkoff.InvestApi.V1; +using TraderBot.Interfaces; + +namespace TraderBot.Providers.Tinkoff; + +public class TinkoffTradesStream : ITradesStream +{ + private readonly OrdersStreamService.OrdersStreamClient.TradesStream _stream; + private readonly ILogger _logger; + + public TinkoffTradesStream(OrdersStreamService.OrdersStreamClient.TradesStream stream, ILogger logger) + { + _stream = stream; + _logger = logger; + } + + public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + await foreach (var data in _stream.ResponseStream.ReadAllAsync(cancellationToken)) + { + yield return new TinkoffTradeData(data); + } + } +} + +public class TinkoffTradeData : ITradeData +{ + private readonly TradesStreamResponse _data; + + public TinkoffTradeData(TradesStreamResponse data) + { + _data = data; + } + + public TradeDataType DataType => _data.PayloadCase switch + { + TradesStreamResponse.PayloadOneofCase.OrderTrades => TradeDataType.OrderTrades, + TradesStreamResponse.PayloadOneofCase.Ping => TradeDataType.Ping, + _ => throw new InvalidOperationException($"Unknown trade data type: {_data.PayloadCase}") + }; + + public IOrderTrades? OrderTrades => _data.PayloadCase == TradesStreamResponse.PayloadOneofCase.OrderTrades + ? new TinkoffOrderTrades(_data.OrderTrades) + : null; +} + +public class TinkoffOrderTrades : IOrderTrades +{ + private readonly OrderTrades _orderTrades; + + public TinkoffOrderTrades(OrderTrades orderTrades) + { + _orderTrades = orderTrades; + } + + public string OrderId => _orderTrades.OrderId; + public OrderDirection Direction => (OrderDirection)_orderTrades.Direction; + public IEnumerable Trades => _orderTrades.Trades.Select(trade => new TinkoffTrade(trade)); +} + +public class TinkoffTrade : ITrade +{ + private readonly OrderTrade _trade; + + public TinkoffTrade(OrderTrade trade) + { + _trade = trade; + } + + public long Quantity => _trade.Quantity; + public decimal Price => QuotationToDecimal(_trade.Price); + public DateTime DateTime => _trade.DateTime.ToDateTime(); + + private static decimal QuotationToDecimal(Quotation value) => value.Units + value.Nano / 1000000000m; +} \ No newline at end of file diff --git a/csharp/TraderBot/Providers/TinkoffApiProvider.cs b/csharp/TraderBot/Providers/TinkoffApiProvider.cs new file mode 100644 index 00000000..be9cded4 --- /dev/null +++ b/csharp/TraderBot/Providers/TinkoffApiProvider.cs @@ -0,0 +1,144 @@ +using Grpc.Core; +using Microsoft.Extensions.Logging; +using Tinkoff.InvestApi; +using Tinkoff.InvestApi.V1; +using TraderBot.Interfaces; +using TraderBot.Providers.Tinkoff; +using Google.Protobuf.WellKnownTypes; + +namespace TraderBot.Providers; + +public class TinkoffApiProvider : ITradingApiProvider +{ + private readonly InvestApiClient _investApi; + private readonly ILogger _logger; + + public TinkoffApiProvider(InvestApiClient investApi, ILogger logger) + { + _investApi = investApi; + _logger = logger; + } + + public Task InitializeAsync() + { + // Tinkoff API is initialized during construction + return Task.CompletedTask; + } + + public async Task> GetAccountsAsync() + { + var response = await _investApi.Users.GetAccountsAsync(); + return response.Accounts.Select(account => new TinkoffAccount(account)); + } + + public async Task GetInstrumentAsync(string ticker, InstrumentType instrumentType) + { + if (instrumentType == InstrumentType.Etf) + { + var response = await _investApi.Instruments.EtfsAsync(); + var instrument = response.Instruments.First(etf => etf.Ticker == ticker); + return new TinkoffInstrument(instrument, InstrumentType.Etf); + } + else if (instrumentType == InstrumentType.Shares) + { + var response = await _investApi.Instruments.SharesAsync(); + var instrument = response.Instruments.First(share => share.Ticker == ticker); + return new TinkoffInstrument(instrument, InstrumentType.Shares); + } + else + { + throw new InvalidOperationException("Not supported instrument type."); + } + } + + public async Task SubscribeToOrderBookAsync(string figi, int depth) + { + var marketDataStream = _investApi.MarketDataStream.MarketDataStream(); + await marketDataStream.RequestStream.WriteAsync(new MarketDataRequest + { + SubscribeOrderBookRequest = new SubscribeOrderBookRequest + { + Instruments = { new OrderBookInstrument { Figi = figi, Depth = depth } }, + SubscriptionAction = SubscriptionAction.Subscribe + }, + }); + + return new TinkoffOrderBookStream(marketDataStream, _logger); + } + + public async Task SubscribeToTradesAsync(string accountId) + { + var tradesStream = _investApi.OrdersStream.TradesStream(new TradesStreamRequest + { + Accounts = { accountId } + }); + + return new TinkoffTradesStream(tradesStream, _logger); + } + + public async Task> GetOrdersAsync(string accountId) + { + var response = await _investApi.Orders.GetOrdersAsync(new GetOrdersRequest { AccountId = accountId }); + return response.Orders.Select(order => new TinkoffOrder(order)); + } + + public async Task> GetPositionsAsync(string accountId) + { + var response = await _investApi.Operations.GetPositionsAsync(new PositionsRequest { AccountId = accountId }); + return response.Securities.Select(position => new TinkoffPosition(position)); + } + + public async Task GetPortfolioAsync(string accountId) + { + var response = await _investApi.Operations.GetPortfolioAsync(new PortfolioRequest { AccountId = accountId }); + return new TinkoffPortfolio(response); + } + + public async Task> GetOperationsAsync(string accountId, string figi, DateTime from, DateTime to) + { + var response = await _investApi.Operations.GetOperationsAsync(new OperationsRequest + { + AccountId = accountId, + State = OperationState.Executed, + Figi = figi, + From = Timestamp.FromDateTime(from), + To = Timestamp.FromDateTime(to) + }); + + return response.Operations.Select(operation => new TinkoffOperation(operation)); + } + + public async Task PlaceOrderAsync(IOrderRequest orderRequest) + { + var tinkoffRequest = new PostOrderRequest + { + OrderId = orderRequest.OrderId, + AccountId = orderRequest.AccountId, + Direction = (Tinkoff.InvestApi.V1.OrderDirection)orderRequest.Direction, + OrderType = (Tinkoff.InvestApi.V1.OrderType)orderRequest.OrderType, + Figi = orderRequest.Figi, + Quantity = orderRequest.Quantity, + Price = DecimalToQuotation(orderRequest.Price) + }; + + var response = await _investApi.Orders.PostOrderAsync(tinkoffRequest); + return new TinkoffOrderResponse(response); + } + + public async Task CancelOrderAsync(string accountId, string orderId) + { + var response = await _investApi.Orders.CancelOrderAsync(new CancelOrderRequest + { + AccountId = accountId, + OrderId = orderId, + }); + return new TinkoffCancelOrderResponse(response); + } + + private static Quotation DecimalToQuotation(decimal value) + { + var units = (long)Math.Truncate(value); + var nano = (int)Math.Truncate((value - units) * 1000000000m); + return new Quotation { Units = units, Nano = nano }; + } +} \ No newline at end of file diff --git a/csharp/TraderBot/appsettings.example.json b/csharp/TraderBot/appsettings.example.json new file mode 100644 index 00000000..8665f099 --- /dev/null +++ b/csharp/TraderBot/appsettings.example.json @@ -0,0 +1,32 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "ApiProviderSettings": { + "Provider": "Tinkoff" + }, + "InvestApiSettings": { + "AccessToken": "your_tinkoff_token_here", + "AppName": "LinksPlatformScalper" + }, + "TradingSettings": { + "Instrument": "Etf", + "Ticker": "TMON@", + "CashCurrency": "rub", + "AccountIndex": -1, + "MinimumProfitSteps": -1, + "MarketOrderBookDepth": 10, + "MinimumMarketOrderSizeToChangeBuyPrice": 300000, + "MinimumMarketOrderSizeToChangeSellPrice": 0, + "MinimumMarketOrderSizeToBuy": 300000, + "MinimumMarketOrderSizeToSell": 0, + "MinimumTimeToBuy": "00:00:01", + "MaximumTimeToBuy": "23:59:59", + "EarlySellOwnedLotsDelta": 300000, + "EarlySellOwnedLotsMultiplier": 0, + "LoadOperationsFrom": "2025-03-01T00:00:01.3389860Z" + } +} \ No newline at end of file diff --git a/csharp/TraderBot/appsettings.mock.json b/csharp/TraderBot/appsettings.mock.json new file mode 100644 index 00000000..96220f8e --- /dev/null +++ b/csharp/TraderBot/appsettings.mock.json @@ -0,0 +1,32 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "ApiProviderSettings": { + "Provider": "Mock" + }, + "InvestApiSettings": { + "AccessToken": "not_required_for_mock", + "AppName": "LinksPlatformScalper" + }, + "TradingSettings": { + "Instrument": "Etf", + "Ticker": "MOCK", + "CashCurrency": "rub", + "AccountIndex": 0, + "MinimumProfitSteps": 1, + "MarketOrderBookDepth": 10, + "MinimumMarketOrderSizeToChangeBuyPrice": 100, + "MinimumMarketOrderSizeToChangeSellPrice": 0, + "MinimumMarketOrderSizeToBuy": 100, + "MinimumMarketOrderSizeToSell": 0, + "MinimumTimeToBuy": "00:00:01", + "MaximumTimeToBuy": "23:59:59", + "EarlySellOwnedLotsDelta": 100, + "EarlySellOwnedLotsMultiplier": 0, + "LoadOperationsFrom": "2025-03-01T00:00:01.3389860Z" + } +} \ No newline at end of file diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..4d4bb5e2 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,98 @@ +# Trader Bot API Abstraction + +This implementation demonstrates how to make the Trader Bot independent of the Tinkoff API by introducing an abstraction layer. + +## Key Changes + +### 1. API Abstraction Layer +- Created `ITradingApiProvider` interface to abstract trading operations +- Defined interfaces for all trading data structures (`IAccount`, `IInstrument`, `IOrder`, etc.) +- Used custom enums to avoid namespace conflicts with provider-specific APIs + +### 2. Provider Implementations +- `TinkoffApiProvider`: Wrapper around the Tinkoff InvestApi +- `MockApiProvider`: Example implementation for testing/demonstration + +### 3. Configuration-Based Provider Selection +```json +{ + "ApiProviderSettings": { + "Provider": "Tinkoff" + } +} +``` + +## Usage Examples + +### Using Tinkoff Provider +```json +{ + "ApiProviderSettings": { + "Provider": "Tinkoff" + }, + "InvestApiSettings": { + "AccessToken": "your_token_here", + "AppName": "YourApp" + } +} +``` + +### Using Mock Provider (for testing) +```json +{ + "ApiProviderSettings": { + "Provider": "Mock" + } +} +``` + +## Adding New Providers + +To add support for a new trading API (e.g., Interactive Brokers, Alpaca): + +1. Create a new provider class implementing `ITradingApiProvider` +2. Implement all required wrapper classes for data structures +3. Add the provider to the dependency injection configuration +4. Add any provider-specific settings classes + +Example: +```csharp +public class AlpacaApiProvider : ITradingApiProvider +{ + // Implementation here +} + +// In Program.cs: +case "alpaca": + var alpacaSettings = provider.GetRequiredService(); + var alpacaLogger = provider.GetRequiredService>(); + return new AlpacaApiProvider(alpacaSettings, alpacaLogger); +``` + +## Benefits + +1. **Reduced Dependency Risk**: No longer tied to a single API provider +2. **Easy Testing**: Mock provider allows testing without real API calls +3. **Provider Flexibility**: Can switch providers via configuration +4. **Future-Proof**: Easy to add new trading APIs as needed +5. **Isolation**: API instability only affects the specific provider implementation + +## Files Created + +### Interfaces +- `ITradingApiProvider.cs` - Main provider interface +- `IAccount.cs`, `IInstrument.cs`, `IOrder.cs`, etc. - Data structure interfaces + +### Tinkoff Implementation +- `TinkoffApiProvider.cs` - Main Tinkoff provider +- `Tinkoff/TinkoffAccount.cs`, `Tinkoff/TinkoffOrder.cs`, etc. - Tinkoff-specific wrappers + +### Mock Implementation +- `MockApiProvider.cs` - Mock provider with all necessary implementations + +### Configuration +- `ApiProviderSettings.cs` - Provider selection configuration +- `appsettings.example.json` - Configuration example +- `appsettings.mock.json` - Mock provider configuration + +This abstraction makes the bot truly independent of any single API provider, addressing the core issue of Tinkoff API instability. \ No newline at end of file