292 lines
11 KiB
C#
292 lines
11 KiB
C#
using KLHZ.Trader.Core.Contracts.Messaging.Dtos;
|
|
using KLHZ.Trader.Core.Contracts.Messaging.Dtos.Enums;
|
|
using KLHZ.Trader.Core.Contracts.Messaging.Interfaces;
|
|
using KLHZ.Trader.Core.DataLayer;
|
|
using KLHZ.Trader.Core.Exchange.Extentions;
|
|
using KLHZ.Trader.Core.Exchange.Models;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using System.Collections.Concurrent;
|
|
using System.Threading.Channels;
|
|
using Tinkoff.InvestApi;
|
|
using Tinkoff.InvestApi.V1;
|
|
using PositionType = KLHZ.Trader.Core.Exchange.Models.PositionType;
|
|
|
|
namespace KLHZ.Trader.Core.Exchange.Services
|
|
{
|
|
public class ManagedAccount
|
|
{
|
|
public string AccountId { get; private set; } = string.Empty;
|
|
private readonly Channel<TradeCommand> _channel = Channel.CreateUnbounded<TradeCommand>();
|
|
|
|
#region Поля, собираемые из контейнера DI
|
|
private readonly InvestApiClient _investApiClient;
|
|
private readonly IDbContextFactory<TraderDbContext> _dbContextFactory;
|
|
private readonly ILogger<ManagedAccount> _logger;
|
|
private readonly IDataBus _dataBus;
|
|
|
|
#endregion
|
|
|
|
#region Кеш рабочих данных
|
|
private readonly object _locker = new();
|
|
private decimal _balance = 0;
|
|
private decimal _total = 0;
|
|
internal decimal Balance
|
|
{
|
|
get
|
|
{
|
|
lock (_locker)
|
|
return _balance;
|
|
}
|
|
set
|
|
{
|
|
lock (_locker)
|
|
_balance = value;
|
|
}
|
|
}
|
|
internal decimal Total
|
|
{
|
|
get
|
|
{
|
|
lock (_locker)
|
|
return _total;
|
|
}
|
|
set
|
|
{
|
|
lock (_locker)
|
|
_total = value;
|
|
}
|
|
}
|
|
|
|
internal readonly ConcurrentDictionary<string, Models.Asset> Assets = new();
|
|
|
|
#endregion
|
|
|
|
public ManagedAccount(InvestApiClient investApiClient, IDataBus dataBus, IDbContextFactory<TraderDbContext> dbContextFactory, ILogger<ManagedAccount> logger)
|
|
{
|
|
_dataBus = dataBus;
|
|
_investApiClient = investApiClient;
|
|
_dbContextFactory = dbContextFactory;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task Init(string accountId)
|
|
{
|
|
AccountId = accountId;
|
|
await SyncPortfolio();
|
|
_dataBus.AddChannel(accountId, _channel);
|
|
_ = ProcessCommands();
|
|
}
|
|
|
|
private async Task ProcessCommands()
|
|
{
|
|
while (await _channel.Reader.WaitToReadAsync())
|
|
{
|
|
var command = await _channel.Reader.ReadAsync();
|
|
try
|
|
{
|
|
await ProcessMarketCommand(command);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Ошибка при обработке команды.");
|
|
}
|
|
}
|
|
}
|
|
|
|
internal async Task SyncPortfolio()
|
|
{
|
|
try
|
|
{
|
|
//await _semaphoreSlim.WaitAsync();
|
|
var portfolio = await _investApiClient.Operations.GetPortfolioAsync(new PortfolioRequest()
|
|
{
|
|
AccountId = AccountId,
|
|
});
|
|
|
|
var oldAssets = Assets.Keys.ToHashSet();
|
|
using var context = await _dbContextFactory.CreateDbContextAsync();
|
|
context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
|
|
|
|
var trades = await context.Trades
|
|
.Where(t => t.AccountId == AccountId && t.ArchiveStatus == 0)
|
|
.ToListAsync();
|
|
foreach (var position in portfolio.Positions)
|
|
{
|
|
decimal price = 0;
|
|
var trade = trades.FirstOrDefault(t => t.Figi == position.Figi);
|
|
|
|
if (trade != null)
|
|
{
|
|
trades.Remove(trade);
|
|
price = trade.Price;
|
|
}
|
|
else
|
|
{
|
|
price = position.AveragePositionPrice;
|
|
}
|
|
#pragma warning disable CS0612 // Тип или член устарел
|
|
var asset = new Models.Asset()
|
|
{
|
|
AccountId = AccountId,
|
|
Figi = position.Figi,
|
|
Ticker = position.Ticker,
|
|
BoughtAt = trade?.BoughtAt ?? DateTime.UtcNow,
|
|
BoughtPrice = price,
|
|
Type = position.InstrumentType.ParseInstrumentType(),
|
|
Position = position.Quantity > 0 ? PositionType.Long : PositionType.Short,
|
|
BlockedItems = position.BlockedLots,
|
|
Count = position.Quantity,
|
|
CountLots = position.QuantityLots,
|
|
};
|
|
#pragma warning restore CS0612 // Тип или член устарел
|
|
Assets.AddOrUpdate(asset.Figi, asset, (k, v) => asset);
|
|
oldAssets.Remove(asset.Figi);
|
|
}
|
|
|
|
Total = portfolio.TotalAmountPortfolio;
|
|
Balance = portfolio.TotalAmountCurrencies;
|
|
|
|
foreach (var asset in oldAssets)
|
|
{
|
|
Assets.TryRemove(asset, out _);
|
|
}
|
|
|
|
var ids = trades.Select(t => t.Id).ToArray();
|
|
await context.Trades
|
|
.Where(t => ids.Contains(t.Id))
|
|
.ExecuteUpdateAsync(t => t.SetProperty(tr => tr.ArchiveStatus, 1));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Ошибка при синхранизации портфеля счёта {accountId}", AccountId);
|
|
}
|
|
finally
|
|
{
|
|
//_semaphoreSlim.Release();
|
|
}
|
|
|
|
}
|
|
|
|
internal async Task<DealResult> ClosePosition(string figi)
|
|
{
|
|
if (!string.IsNullOrEmpty(figi) && Assets.TryGetValue(figi, out var asset))
|
|
{
|
|
try
|
|
{
|
|
var req = new PostOrderRequest()
|
|
{
|
|
AccountId = AccountId,
|
|
InstrumentId = figi,
|
|
};
|
|
if (asset != null)
|
|
{
|
|
req.Direction = OrderDirection.Sell;
|
|
req.OrderType = OrderType.Market;
|
|
req.Quantity = (long)asset.Count;
|
|
|
|
var res = await _investApiClient.Orders.PostOrderAsync(req);
|
|
return new DealResult
|
|
{
|
|
Count = res.LotsExecuted,
|
|
Price = res.ExecutedOrderPrice,
|
|
Success = true,
|
|
};
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Ошибка при закрытии позиции по счёту {acc}. figi: {figi}", AccountId, figi);
|
|
}
|
|
}
|
|
return new DealResult
|
|
{
|
|
Count = 0,
|
|
Price = 0,
|
|
Success = false,
|
|
};
|
|
}
|
|
|
|
internal async Task<DealResult> BuyAsset(string figi, decimal count, string? ticker = null, decimal? recommendedPrice = null)
|
|
{
|
|
try
|
|
{
|
|
var req = new PostOrderRequest()
|
|
{
|
|
AccountId = AccountId,
|
|
InstrumentId = figi,
|
|
Direction = OrderDirection.Buy,
|
|
OrderType = OrderType.Market,
|
|
Quantity = (long)count,
|
|
};
|
|
|
|
var res = await _investApiClient.Orders.PostOrderAsync(req);
|
|
|
|
using var context = await _dbContextFactory.CreateDbContextAsync();
|
|
context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
|
|
|
|
var trade = await context.Trades.FirstOrDefaultAsync(t => t.ArchiveStatus == 0 && t.Figi == figi);
|
|
if (trade == null)
|
|
{
|
|
var newTrade = new DataLayer.Entities.Trades.Trade()
|
|
{
|
|
AccountId = AccountId,
|
|
Figi = figi,
|
|
Ticker = ticker ?? string.Empty,
|
|
BoughtAt = DateTime.UtcNow,
|
|
Count = res.LotsExecuted,
|
|
Price = res.ExecutedOrderPrice,
|
|
Position = DataLayer.Entities.Trades.PositionType.Long,
|
|
Direction = DataLayer.Entities.Trades.TradeDirection.Buy,
|
|
Asset = DataLayer.Entities.Trades.AssetType.Common,
|
|
};
|
|
|
|
await context.Trades.AddAsync(newTrade);
|
|
}
|
|
else
|
|
{
|
|
var oldAmount = trade.Price * trade.Count;
|
|
var newAmount = res.ExecutedOrderPrice * res.LotsExecuted;
|
|
trade.Count = res.LotsExecuted + trade.Count;
|
|
trade.Price = (oldAmount + newAmount) / trade.Count;
|
|
context.Trades.Update(trade);
|
|
}
|
|
|
|
await context.SaveChangesAsync();
|
|
return new DealResult
|
|
{
|
|
Count = res.LotsExecuted,
|
|
Price = res.ExecutedOrderPrice,
|
|
Success = true,
|
|
};
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Ошибка при покупке актива на счёт {acc}. figi: {figi}", AccountId, figi);
|
|
}
|
|
return new DealResult
|
|
{
|
|
Count = 0,
|
|
Price = 0,
|
|
Success = false,
|
|
};
|
|
}
|
|
|
|
private async Task ProcessMarketCommand(TradeCommand command)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(command.Figi)) return;
|
|
if (command.CommandType == TradeCommandType.MarketBuy)
|
|
{
|
|
await BuyAsset(command.Figi, command.Count ?? 1, command.Ticker, command.RecomendPrice);
|
|
}
|
|
else if (command.CommandType == TradeCommandType.ForceClosePosition)
|
|
{
|
|
await ClosePosition(command.Figi);
|
|
}
|
|
else return;
|
|
|
|
await SyncPortfolio();
|
|
}
|
|
}
|
|
}
|