From 20920c68324644f4e32f075be08bd7254634d468 Mon Sep 17 00:00:00 2001 From: vlad zverzhkhovskiy Date: Wed, 27 Aug 2025 09:20:37 +0300 Subject: [PATCH] first commit --- .dockerignore | 30 + .gitignore | 279 +++++++ .../HistoryCacheUnitTests.cs | 207 ++++++ .../HistoryProcessingInstrumentsTests.cs | 276 +++++++ .../KLHZ.Trader.Core.Tests.csproj | 28 + KLHZ.Trader.Core/AssemblySettings.cs | 4 + KLHZ.Trader.Core/Common/BotModeSwitcher.cs | 45 ++ KLHZ.Trader.Core/Common/Constants.cs | 19 + .../Common/Messaging/Contracts/IDataBus.cs | 22 + .../Messages/Enums/TradeCommandType.cs | 16 + .../Contracts/Messages/INewCandle.cs | 15 + .../Contracts/Messages/INewPriceMessage.cs | 11 + .../Contracts/Messages/MessageForAdmin.cs | 7 + .../Contracts/Messages/NewPriceMessage.cs | 11 + .../Contracts/Messages/TradeCommand.cs | 16 + .../Common/Messaging/Services/DataBus.cs | 67 ++ .../Entities/Declisions/Declision.cs | 28 + .../Declisions/DeclisionTradeAction.cs | 11 + .../DataLayer/Entities/Prices/Candle.cs | 38 + .../DataLayer/Entities/Prices/PriceChange.cs | 27 + .../DataLayer/Entities/Trades/AssetType.cs | 9 + .../DataLayer/Entities/Trades/PositionType.cs | 9 + .../DataLayer/Entities/Trades/Trade.cs | 45 ++ .../Entities/Trades/TradeDirection.cs | 9 + KLHZ.Trader.Core/DataLayer/TraderDbContext.cs | 63 ++ .../Declisions/Models/PeriodPricesInfo.cs | 26 + .../Models/PriceHistoryCacheUnit.cs | 73 ++ .../Declisions/Services/Trader.cs | 261 +++++++ .../Utils/HistoryProcessingInstruments.cs | 169 +++++ KLHZ.Trader.Core/Exchange/ExchangeConfig.cs | 13 + .../Extentions/InvestApiClientExtentions.cs | 25 + .../Exchange/Extentions/StringExtensions.cs | 22 + KLHZ.Trader.Core/Exchange/Models/Asset.cs | 16 + KLHZ.Trader.Core/Exchange/Models/AssetType.cs | 10 + .../Exchange/Models/DealResult.cs | 9 + .../Exchange/Models/PositionType.cs | 9 + .../Exchange/Services/ExchangeDataReader.cs | 189 +++++ .../Exchange/Services/ManagedAccount.cs | 291 ++++++++ KLHZ.Trader.Core/KLHZ.Trader.Core.csproj | 17 + .../TG/Services/BotMessagesHandler.cs | 123 ++++ KLHZ.Trader.Core/TG/Services/BotStarter.cs | 51 ++ KLHZ.Trader.Core/TG/TgBotConfig.cs | 9 + .../Controllers/LoaderController.cs | 84 +++ KLHZ.Trader.HistoryLoader/Dockerfile | 30 + .../KLHZ.Trader.HistoryLoader.csproj | 20 + KLHZ.Trader.HistoryLoader/Program.cs | 30 + .../appsettings.Development.json | 8 + KLHZ.Trader.HistoryLoader/appsettings.json | 9 + .../graphana/dashboard.json | 696 ++++++++++++++++++ .../loki/loki-config.yaml | 63 ++ KLHZ.Trader.Infrastructure/postgres/init.sql | 49 ++ .../prometheus/prometheus.yml | 7 + .../Controllers/PlayController.cs | 52 ++ KLHZ.Trader.Service/Dockerfile | 31 + .../Infrastructure/IHostBuilderExtensions.cs | 82 +++ .../KLHZ.Trader.Service.csproj | 24 + KLHZ.Trader.Service/Program.cs | 66 ++ .../appsettings.Development.json | 8 + KLHZ.Trader.Service/appsettings.json | 27 + KLHZ.Trader.sln | 77 ++ build-docker-compose.yml | 15 + docker-compose.dcproj | 20 + docker-compose.override.yml | 0 docker-compose.yml | 90 +++ launchSettings.json | 13 + 65 files changed, 4106 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 KLHZ.Trader.Core.Tests/HistoryCacheUnitTests.cs create mode 100644 KLHZ.Trader.Core.Tests/HistoryProcessingInstrumentsTests.cs create mode 100644 KLHZ.Trader.Core.Tests/KLHZ.Trader.Core.Tests.csproj create mode 100644 KLHZ.Trader.Core/AssemblySettings.cs create mode 100644 KLHZ.Trader.Core/Common/BotModeSwitcher.cs create mode 100644 KLHZ.Trader.Core/Common/Constants.cs create mode 100644 KLHZ.Trader.Core/Common/Messaging/Contracts/IDataBus.cs create mode 100644 KLHZ.Trader.Core/Common/Messaging/Contracts/Messages/Enums/TradeCommandType.cs create mode 100644 KLHZ.Trader.Core/Common/Messaging/Contracts/Messages/INewCandle.cs create mode 100644 KLHZ.Trader.Core/Common/Messaging/Contracts/Messages/INewPriceMessage.cs create mode 100644 KLHZ.Trader.Core/Common/Messaging/Contracts/Messages/MessageForAdmin.cs create mode 100644 KLHZ.Trader.Core/Common/Messaging/Contracts/Messages/NewPriceMessage.cs create mode 100644 KLHZ.Trader.Core/Common/Messaging/Contracts/Messages/TradeCommand.cs create mode 100644 KLHZ.Trader.Core/Common/Messaging/Services/DataBus.cs create mode 100644 KLHZ.Trader.Core/DataLayer/Entities/Declisions/Declision.cs create mode 100644 KLHZ.Trader.Core/DataLayer/Entities/Declisions/DeclisionTradeAction.cs create mode 100644 KLHZ.Trader.Core/DataLayer/Entities/Prices/Candle.cs create mode 100644 KLHZ.Trader.Core/DataLayer/Entities/Prices/PriceChange.cs create mode 100644 KLHZ.Trader.Core/DataLayer/Entities/Trades/AssetType.cs create mode 100644 KLHZ.Trader.Core/DataLayer/Entities/Trades/PositionType.cs create mode 100644 KLHZ.Trader.Core/DataLayer/Entities/Trades/Trade.cs create mode 100644 KLHZ.Trader.Core/DataLayer/Entities/Trades/TradeDirection.cs create mode 100644 KLHZ.Trader.Core/DataLayer/TraderDbContext.cs create mode 100644 KLHZ.Trader.Core/Declisions/Models/PeriodPricesInfo.cs create mode 100644 KLHZ.Trader.Core/Declisions/Models/PriceHistoryCacheUnit.cs create mode 100644 KLHZ.Trader.Core/Declisions/Services/Trader.cs create mode 100644 KLHZ.Trader.Core/Declisions/Utils/HistoryProcessingInstruments.cs create mode 100644 KLHZ.Trader.Core/Exchange/ExchangeConfig.cs create mode 100644 KLHZ.Trader.Core/Exchange/Extentions/InvestApiClientExtentions.cs create mode 100644 KLHZ.Trader.Core/Exchange/Extentions/StringExtensions.cs create mode 100644 KLHZ.Trader.Core/Exchange/Models/Asset.cs create mode 100644 KLHZ.Trader.Core/Exchange/Models/AssetType.cs create mode 100644 KLHZ.Trader.Core/Exchange/Models/DealResult.cs create mode 100644 KLHZ.Trader.Core/Exchange/Models/PositionType.cs create mode 100644 KLHZ.Trader.Core/Exchange/Services/ExchangeDataReader.cs create mode 100644 KLHZ.Trader.Core/Exchange/Services/ManagedAccount.cs create mode 100644 KLHZ.Trader.Core/KLHZ.Trader.Core.csproj create mode 100644 KLHZ.Trader.Core/TG/Services/BotMessagesHandler.cs create mode 100644 KLHZ.Trader.Core/TG/Services/BotStarter.cs create mode 100644 KLHZ.Trader.Core/TG/TgBotConfig.cs create mode 100644 KLHZ.Trader.HistoryLoader/Controllers/LoaderController.cs create mode 100644 KLHZ.Trader.HistoryLoader/Dockerfile create mode 100644 KLHZ.Trader.HistoryLoader/KLHZ.Trader.HistoryLoader.csproj create mode 100644 KLHZ.Trader.HistoryLoader/Program.cs create mode 100644 KLHZ.Trader.HistoryLoader/appsettings.Development.json create mode 100644 KLHZ.Trader.HistoryLoader/appsettings.json create mode 100644 KLHZ.Trader.Infrastructure/graphana/dashboard.json create mode 100644 KLHZ.Trader.Infrastructure/loki/loki-config.yaml create mode 100644 KLHZ.Trader.Infrastructure/postgres/init.sql create mode 100644 KLHZ.Trader.Infrastructure/prometheus/prometheus.yml create mode 100644 KLHZ.Trader.Service/Controllers/PlayController.cs create mode 100644 KLHZ.Trader.Service/Dockerfile create mode 100644 KLHZ.Trader.Service/Infrastructure/IHostBuilderExtensions.cs create mode 100644 KLHZ.Trader.Service/KLHZ.Trader.Service.csproj create mode 100644 KLHZ.Trader.Service/Program.cs create mode 100644 KLHZ.Trader.Service/appsettings.Development.json create mode 100644 KLHZ.Trader.Service/appsettings.json create mode 100644 KLHZ.Trader.sln create mode 100644 build-docker-compose.yml create mode 100644 docker-compose.dcproj create mode 100644 docker-compose.override.yml create mode 100644 docker-compose.yml create mode 100644 launchSettings.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4d72b4f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md +!**/.gitignore +!.git/HEAD +!.git/config +!.git/packed-refs +!.git/refs/heads/** \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8710793 --- /dev/null +++ b/.gitignore @@ -0,0 +1,279 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +env/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +output/ +dbvolume/ +[Pp]roperties/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +project.fragment.lock.json +artifacts/ + +[Log]log* +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +#*.pubxml +*.publishproj +*.pwd +*.env + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignoreable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + + +#deploy +deploy*.bat +.github + +docker-compose-env/ +*.env + +.vs +.vscode \ No newline at end of file diff --git a/KLHZ.Trader.Core.Tests/HistoryCacheUnitTests.cs b/KLHZ.Trader.Core.Tests/HistoryCacheUnitTests.cs new file mode 100644 index 0000000..3a0e581 --- /dev/null +++ b/KLHZ.Trader.Core.Tests/HistoryCacheUnitTests.cs @@ -0,0 +1,207 @@ +using KLHZ.Trader.Core.DataLayer.Entities.Prices; +using KLHZ.Trader.Core.Declisions.Models; + +namespace KLHZ.Trader.Core.Tests +{ + public class Tests + { + private static PriceChange[] GetHistory(int count, string figi) + { + var res = new PriceChange[count]; + if (count != 0) + { + var startDt = DateTime.UtcNow.AddSeconds(-count); + for (int i = 0; i < count; i++) + { + startDt = startDt.AddSeconds(i); + res[i] = new PriceChange() + { + Figi = figi, + Ticker = figi + "_ticker", + Id = i, + Time = startDt, + Value = (decimal)(i + 0.5) + }; + } + } + return res; + } + + [Test] + public void Test1() + { + var count = 0; + var figi = "figi"; + var hist = GetHistory(count, figi); + var cacheUnit = new PriceHistoryCacheUnit("", hist); + var data = cacheUnit.GetData(); + + Assert.That(data.prices.Length == count); + Assert.That(data.timestamps.Length == count); + } + + [Test] + public void Test2() + { + var count = 1; + var figi = "figi"; + var hist = GetHistory(count, figi); + var cacheUnit = new PriceHistoryCacheUnit("", hist); + var data = cacheUnit.GetData(); + + Assert.That(data.prices.Length == count); + Assert.That(data.timestamps.Length == count); + for (var i = 0; i < count; i++) + { + Assert.That((float)hist[i].Value, Is.EqualTo(data.prices[i])); + Assert.That(hist[i].Time, Is.EqualTo(data.timestamps[i])); + } + } + + [Test] + public void Test3() + { + var count = 20; + var figi = "figi"; + var hist = GetHistory(count, figi); + var cacheUnit = new PriceHistoryCacheUnit("", hist); + var data = cacheUnit.GetData(); + + Assert.That(data.prices.Length == count); + Assert.That(data.timestamps.Length == count); + + for (var i = 0; i < count; i++) + { + Assert.That((float)hist[i].Value, Is.EqualTo(data.prices[i])); + Assert.That(hist[i].Time, Is.EqualTo(data.timestamps[i])); + } + } + + [Test] + public void Test4() + { + var count = PriceHistoryCacheUnit.ArrayMaxLength; + var figi = "figi"; + var hist = GetHistory(count, figi); + var cacheUnit = new PriceHistoryCacheUnit("", hist); + var data = cacheUnit.GetData(); + + Assert.That(data.prices.Length == count); + Assert.That(data.timestamps.Length == count); + + for (var i = 0; i < count; i++) + { + Assert.That((float)hist[i].Value, Is.EqualTo(data.prices[i])); + Assert.That(hist[i].Time, Is.EqualTo(data.timestamps[i])); + } + } + + [Test] + public void Test5() + { + var shift = 7; + var count = PriceHistoryCacheUnit.ArrayMaxLength + shift; + var figi = "figi"; + var hist = GetHistory(count, figi); + var cacheUnit = new PriceHistoryCacheUnit("", hist); + var data = cacheUnit.GetData(); + + Assert.That(data.prices.Length == count - shift); + Assert.That(data.timestamps.Length == count - shift); + + for (var i = 0; i < count; i++) + { + var k = i + shift; + if (k < hist.Length) + { + Assert.That((float)hist[k].Value, Is.EqualTo(data.prices[i])); + Assert.That(hist[k].Time, Is.EqualTo(data.timestamps[i])); + } + } + } + + [Test] + public void Test6() + { + var shift = 10; + var count = PriceHistoryCacheUnit.ArrayMaxLength + shift; + var figi = "figi"; + var hist = GetHistory(count, figi); + var cacheUnit = new PriceHistoryCacheUnit("", hist); + var data = cacheUnit.GetData(); + + Assert.That(data.prices.Length == count - shift); + Assert.That(data.timestamps.Length == count - shift); + + for (var i = 0; i < count; i++) + { + var k = i + shift; + if (k < hist.Length) + { + Assert.That((float)hist[k].Value, Is.EqualTo(data.prices[i])); + Assert.That(hist[k].Time, Is.EqualTo(data.timestamps[i])); + } + } + } + + [Test] + public void Test7() + { + var shift = 334; + var count = PriceHistoryCacheUnit.ArrayMaxLength + shift; + var figi = "figi"; + var hist = GetHistory(count, figi); + var cacheUnit = new PriceHistoryCacheUnit("", hist); + var data = cacheUnit.GetData(); + + Assert.That(data.prices.Length == count - shift); + Assert.That(data.timestamps.Length == count - shift); + + for (var i = 0; i < count; i++) + { + var k = i + shift; + if (k < hist.Length) + { + Assert.That((float)hist[k].Value, Is.EqualTo(data.prices[i])); + Assert.That(hist[k].Time, Is.EqualTo(data.timestamps[i])); + } + } + } + + + [Test] + public void Test8() + { + var count = PriceHistoryCacheUnit.ArrayMaxLength; + var figi = "figi"; + var hist = GetHistory(count, figi); + var cacheUnit = new PriceHistoryCacheUnit("", hist); + var data = cacheUnit.GetData(); + + Assert.That(data.prices.Length == count); + Assert.That(data.timestamps.Length == count); + + for (var i = 0; i < count; i++) + { + Assert.That((float)hist[i].Value, Is.EqualTo(data.prices[i])); + Assert.That(hist[i].Time, Is.EqualTo(data.timestamps[i])); + } + + var newData1 = new PriceChange() { Figi = figi, Ticker = figi, Value = 100500, Time = DateTime.UtcNow }; + + cacheUnit.AddData(newData1); + + var data2 = cacheUnit.GetData(); + Assert.IsTrue(data2.prices[data2.prices.Length - 1] == (float)newData1.Value); + Assert.IsTrue(data2.timestamps[data2.timestamps.Length - 1] == newData1.Time); + + var newData2 = new PriceChange() { Figi = figi, Ticker = figi, Value = 100501, Time = DateTime.UtcNow }; + + cacheUnit.AddData(newData2); + + var data3 = cacheUnit.GetData(); + Assert.IsTrue(data3.prices[data3.prices.Length - 1] == (float)newData2.Value); + Assert.IsTrue(data3.timestamps[data3.timestamps.Length - 1] == newData2.Time); + } + } +} \ No newline at end of file diff --git a/KLHZ.Trader.Core.Tests/HistoryProcessingInstrumentsTests.cs b/KLHZ.Trader.Core.Tests/HistoryProcessingInstrumentsTests.cs new file mode 100644 index 0000000..88c7650 --- /dev/null +++ b/KLHZ.Trader.Core.Tests/HistoryProcessingInstrumentsTests.cs @@ -0,0 +1,276 @@ +using KLHZ.Trader.Core.DataLayer.Entities.Prices; +using KLHZ.Trader.Core.Declisions.Models; +using KLHZ.Trader.Core.Declisions.Utils; +using Tinkoff.InvestApi.V1; + +namespace KLHZ.Trader.Core.Tests +{ + public class HistoryProcessingInstrumentsTests + { + private static PriceChange[] GetHistory(int count, string figi, DateTime startDt, float startValue, float step) + { + var res = new PriceChange[count]; + if (count != 0) + { + for (int i = 0; i < count; i++) + { + startValue += step; + startDt = startDt.AddSeconds(1); + res[i] = new PriceChange() + { + Figi = figi, + Ticker = figi + "_ticker", + Id = i, + Time = startDt, + Value = (decimal)startValue, + }; + } + } + return res; + } + + [Test] + public void Test0() + { + var figi = "figi"; + var startDate = new DateTime(2020, 1, 1, 1, 0, 0, DateTimeKind.Utc); + var count = 100; + var step = 0; + var startValue = 10; + var unit = new PriceHistoryCacheUnit(figi, GetHistory(count, figi, startDate, startValue, step)); + + var data = unit.GetData(); + var endDate = startDate.AddSeconds(count); + Assert.IsTrue(data.timestamps.Last() == endDate); + Assert.IsTrue(data.prices.Last() == startValue + step * count); + + var periodLength = 4; + var shift = 0; + var result = HistoryProcessingInstruments.GetPriceDiffForTimeSpan(unit, TimeSpan.FromSeconds(shift), TimeSpan.FromSeconds(periodLength)); + + var maxValue = startValue + (step > 0 ? (step * count) - step * shift : (step * count) - (step * (shift + periodLength))); + var minValue = startValue + (step > 0 ? (step * count) - (step * (shift + periodLength)) : (step * count) - step * shift); + var firstPrice = startValue + (step * count) - (step * (shift + periodLength)); + var lastPrice = startValue + (step * count) - step * shift; + + var diff = firstPrice - lastPrice; + + + Assert.IsTrue(result.LastPrice == lastPrice); + Assert.IsTrue(result.FirstPrice == firstPrice); + Assert.IsTrue(result.PeriodMax == maxValue); + Assert.IsTrue(result.PeriodMin == minValue); + Assert.IsTrue(result.PeriodDiff == diff); + } + + [Test] + public void Test1() + { + var figi = "figi"; + var startDate = new DateTime(2020, 1, 1, 1, 0, 0, DateTimeKind.Utc); + var count = 100; + var step = 0.5f; + var startValue = 10; + var unit = new PriceHistoryCacheUnit(figi, GetHistory(count, figi, startDate, startValue, step)); + + var data = unit.GetData(); + var endDate = startDate.AddSeconds(count); + Assert.IsTrue(data.timestamps.Last() == endDate); + Assert.IsTrue(data.prices.Last() == startValue + step * count); + + var periodLength = 4; + var shift = 0; + var result = HistoryProcessingInstruments.GetPriceDiffForTimeSpan(unit, TimeSpan.FromSeconds(shift), TimeSpan.FromSeconds(periodLength)); + + var maxValue = startValue + (step > 0 ? (step * count) - step * shift : (step * count) - (step * (shift + periodLength))); + var minValue = startValue + (step > 0 ? (step * count) - (step * (shift + periodLength)) : (step * count) - step * shift); + var firstPrice = startValue + (step * count) - (step * (shift + periodLength)); + var lastPrice = startValue + (step * count) - step * shift; + + var diff = lastPrice - firstPrice; + + + Assert.IsTrue(result.LastPrice == lastPrice); + Assert.IsTrue(result.FirstPrice == firstPrice); + Assert.IsTrue(result.PeriodMax == maxValue); + Assert.IsTrue(result.PeriodMin == minValue); + Assert.IsTrue(result.PeriodDiff == diff); + } + + [Test] + public void Test2() + { + var figi = "figi"; + var startDate = new DateTime(2020, 1, 1, 1, 0, 0, DateTimeKind.Utc); + var count = 100; + var step = 0.5f; + var startValue = 10; + + var unit = new PriceHistoryCacheUnit(figi, GetHistory(count, figi, startDate, startValue, step)); + + var data = unit.GetData(); + var endDate = startDate.AddSeconds(count); + Assert.IsTrue(data.timestamps.Last() == endDate); + Assert.IsTrue(data.prices.Last() == startValue + step * count); + + var periodLength = 4; + var shift = 1; + var result = HistoryProcessingInstruments.GetPriceDiffForTimeSpan(unit, TimeSpan.FromSeconds(shift), TimeSpan.FromSeconds(periodLength)); + + var maxValue = startValue + (step > 0 ? (step * count) - step * shift : (step * count) - (step * (shift + periodLength))); + var minValue = startValue + (step > 0 ? (step * count) - (step * (shift + periodLength)) : (step * count) - step * shift); + var firstPrice = startValue + (step * count) - (step * (shift + periodLength)); + var lastPrice = startValue + (step * count) - step * shift; + + var diff = lastPrice - firstPrice; + + + Assert.IsTrue(result.LastPrice == lastPrice); + Assert.IsTrue(result.FirstPrice == firstPrice); + Assert.IsTrue(result.PeriodMax == maxValue); + Assert.IsTrue(result.PeriodMin == minValue); + Assert.IsTrue(result.PeriodDiff == diff); + } + + [Test] + public void Test3() + { + var figi = "figi"; + var startDate = new DateTime(2020, 1, 1, 1, 0, 0, DateTimeKind.Utc); + var count = 100; + var step = -0.5f; + var startValue = 10; + + var unit = new PriceHistoryCacheUnit(figi, GetHistory(count, figi, startDate, startValue, step)); + + var data = unit.GetData(); + var endDate = startDate.AddSeconds(count); + Assert.IsTrue(data.timestamps.Last() == endDate); + Assert.IsTrue(data.prices.Last() == startValue + step * count); + + var periodLength = 4; + var shift = 0; + var result = HistoryProcessingInstruments.GetPriceDiffForTimeSpan(unit, TimeSpan.FromSeconds(shift), TimeSpan.FromSeconds(periodLength)); + + var maxValue = startValue + (step > 0 ? (step * count) - step * shift : (step * count) - (step * (shift + periodLength))); + var minValue = startValue + (step > 0 ? (step * count) - (step * (shift + periodLength)) : (step * count) - step * shift); + var firstPrice = startValue + (step * count) - (step * (shift + periodLength)); + var lastPrice = startValue + (step * count) - step * shift; + + var diff = lastPrice - firstPrice; + + + Assert.IsTrue(result.LastPrice == lastPrice); + Assert.IsTrue(result.FirstPrice == firstPrice); + Assert.IsTrue(result.PeriodMax == maxValue); + Assert.IsTrue(result.PeriodMin == minValue); + Assert.IsTrue(result.PeriodDiff == diff); + } + + [Test] + public void Test4() + { + var figi = "figi"; + var startDate = new DateTime(2020, 1, 1, 1, 0, 0, DateTimeKind.Utc); + var count = 100; + var step = -0.5f; + var startValue = 10; + + var unit = new PriceHistoryCacheUnit(figi, GetHistory(count, figi, startDate, startValue, step)); + + var data = unit.GetData(); + var endDate = startDate.AddSeconds(count); + Assert.IsTrue(data.timestamps.Last() == endDate); + Assert.IsTrue(data.prices.Last() == startValue + step * count); + + var periodLength = 4; + var shift = 3; + var result = HistoryProcessingInstruments.GetPriceDiffForTimeSpan(unit, TimeSpan.FromSeconds(shift), TimeSpan.FromSeconds(periodLength)); + + var maxValue = startValue + (step > 0 ? (step * count) - step * shift : (step * count) - (step * (shift + periodLength))); + var minValue = startValue + (step > 0 ? (step * count) - (step * (shift + periodLength)) : (step * count) - step * shift); + var firstPrice = startValue + (step * count) - (step * (shift + periodLength)); + var lastPrice = startValue + (step * count) - step * shift; + + var diff = lastPrice - firstPrice; + + + Assert.IsTrue(result.LastPrice == lastPrice); + Assert.IsTrue(result.FirstPrice == firstPrice); + Assert.IsTrue(result.PeriodMax == maxValue); + Assert.IsTrue(result.PeriodMin == minValue); + Assert.IsTrue(result.PeriodDiff == diff); + } + + [Test] + public void Test5() + { + var figi = "figi"; + var startDate = new DateTime(2020, 1, 1, 1, 0, 0, DateTimeKind.Utc); + var count = 100; + var step = -0.5f; + var startValue = 10; + + var unit = new PriceHistoryCacheUnit(figi, GetHistory(count, figi, startDate, startValue, step)); + + var data = unit.GetData(); + var endDate = startDate.AddSeconds(count); + Assert.IsTrue(data.timestamps.Last() == endDate); + Assert.IsTrue(data.prices.Last() == startValue + step * count); + + var periodLength = 4; + var shift = 3; + var result = HistoryProcessingInstruments.GetPriceDiffForTimeSpan(unit, TimeSpan.FromSeconds(shift), TimeSpan.FromSeconds(periodLength)); + + var maxValue1 = startValue + (step > 0 ? (step * count) - step * shift : (step * count) - (step * (shift + periodLength))); + var minValue1 = startValue + (step > 0 ? (step * count) - (step * (shift + periodLength)) : (step * count) - step * shift); + var firstPrice1 = startValue + (step * count) - (step * (shift + periodLength)); + var lastPrice1 = startValue + (step * count) - step * shift; + + var diff1 = lastPrice1 - firstPrice1; + + + Assert.IsTrue(result.LastPrice == lastPrice1); + Assert.IsTrue(result.FirstPrice == firstPrice1); + Assert.IsTrue(result.PeriodMax == maxValue1); + Assert.IsTrue(result.PeriodMin == minValue1); + Assert.IsTrue(result.PeriodDiff == diff1); + + + + var unit2 = new PriceHistoryCacheUnit(figi); + var data2 = unit.GetData(); + for (int i = 0; i < data2.prices.Length; i++) + { + var value = (decimal)data2.prices[i]; + if (i == data2.prices.Length - 5) + { + value = 100; + } + else if (i == data2.prices.Length - 6) + { + value = -100; + } + unit2.AddData(new PriceChange() + { + Figi = figi, + Ticker = figi, + Time = data2.timestamps[i], + Value = value + }); + + } + + var result2 = HistoryProcessingInstruments.GetPriceDiffForTimeSpan(unit2, TimeSpan.FromSeconds(shift), TimeSpan.FromSeconds(periodLength)); + + var maxValue2 = 100; + var minValue2 = -100; + + Assert.IsTrue(result2.LastPrice == result.LastPrice); + Assert.IsTrue(result2.FirstPrice == result.FirstPrice); + Assert.IsTrue(result2.PeriodMax == maxValue2); + Assert.IsTrue(result2.PeriodMin == minValue2); + Assert.IsTrue(result2.PeriodDiff == result.PeriodDiff); + } + } +} diff --git a/KLHZ.Trader.Core.Tests/KLHZ.Trader.Core.Tests.csproj b/KLHZ.Trader.Core.Tests/KLHZ.Trader.Core.Tests.csproj new file mode 100644 index 0000000..498f3ad --- /dev/null +++ b/KLHZ.Trader.Core.Tests/KLHZ.Trader.Core.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/KLHZ.Trader.Core/AssemblySettings.cs b/KLHZ.Trader.Core/AssemblySettings.cs new file mode 100644 index 0000000..8e82cac --- /dev/null +++ b/KLHZ.Trader.Core/AssemblySettings.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +//Тесты +[assembly: InternalsVisibleTo("KLHZ.Trader.Core.Tests")] \ No newline at end of file diff --git a/KLHZ.Trader.Core/Common/BotModeSwitcher.cs b/KLHZ.Trader.Core/Common/BotModeSwitcher.cs new file mode 100644 index 0000000..6d0f373 --- /dev/null +++ b/KLHZ.Trader.Core/Common/BotModeSwitcher.cs @@ -0,0 +1,45 @@ +namespace KLHZ.Trader.Core.Common +{ + public class BotModeSwitcher + { + private readonly object _locker = new(); + private bool _canSell = true; + private bool _canPurchase = true; + + public bool CanSell() + { + lock (_locker) + return _canSell; + } + + public bool CanPurchase() + { + lock (_locker) + return _canPurchase; + } + + public void StopSelling() + { + lock (_locker) + _canSell = false; + } + + public void StopPurchase() + { + lock (_locker) + _canPurchase = false; + } + + public void StartSelling() + { + lock (_locker) + _canSell = true; + } + + public void StartPurchase() + { + lock (_locker) + _canPurchase = true; + } + } +} diff --git a/KLHZ.Trader.Core/Common/Constants.cs b/KLHZ.Trader.Core/Common/Constants.cs new file mode 100644 index 0000000..e5ec482 --- /dev/null +++ b/KLHZ.Trader.Core/Common/Constants.cs @@ -0,0 +1,19 @@ +namespace KLHZ.Trader.Core.Common +{ + internal static class Constants + { + public const string RubFigi = "RUB000UTSTOM"; + + public static class BotCommandsButtons + { + public const string DisableTrading = "Стоп торги"; + public const string EnableTrading = "Старт торги"; + + public const string DisableSelling = "Выключить продажи"; + public const string EnableSelling = "Включить продажи"; + + public const string DisablePurchases = "Выключить покупки"; + public const string EnablePurchases = "Включить покупки"; + } + } +} diff --git a/KLHZ.Trader.Core/Common/Messaging/Contracts/IDataBus.cs b/KLHZ.Trader.Core/Common/Messaging/Contracts/IDataBus.cs new file mode 100644 index 0000000..62af518 --- /dev/null +++ b/KLHZ.Trader.Core/Common/Messaging/Contracts/IDataBus.cs @@ -0,0 +1,22 @@ +using KLHZ.Trader.Core.Common.Messaging.Contracts.Messages; +using System.Threading.Channels; + +namespace KLHZ.Trader.Core.Common.Messaging.Contracts +{ + public interface IDataBus + { + public bool AddChannel(string key, Channel channel); + + public bool AddChannel(string key, Channel channel); + public bool AddChannel(string key, Channel channel); + + public bool AddChannel(Channel channel); + + public Task BroadcastNewPrice(INewPriceMessage newPriceMessage); + + public Task BroadcastCommand(TradeCommand command); + + public Task BroadcastCommand(MessageForAdmin command); + public Task BroadcastNewCandle(INewCandle command); + } +} diff --git a/KLHZ.Trader.Core/Common/Messaging/Contracts/Messages/Enums/TradeCommandType.cs b/KLHZ.Trader.Core/Common/Messaging/Contracts/Messages/Enums/TradeCommandType.cs new file mode 100644 index 0000000..f3243a7 --- /dev/null +++ b/KLHZ.Trader.Core/Common/Messaging/Contracts/Messages/Enums/TradeCommandType.cs @@ -0,0 +1,16 @@ +namespace KLHZ.Trader.Core.Common.Messaging.Contracts.Messages.Enums +{ + public enum TradeCommandType + { + Unknown = 0, + MarketBuy = 1, + + + MarketSell = 101, + SoftClosePosition = 110, + + ForceClosePosition = 201, + + UpdatePortfolio = 10000, + } +} diff --git a/KLHZ.Trader.Core/Common/Messaging/Contracts/Messages/INewCandle.cs b/KLHZ.Trader.Core/Common/Messaging/Contracts/Messages/INewCandle.cs new file mode 100644 index 0000000..4fe8e5e --- /dev/null +++ b/KLHZ.Trader.Core/Common/Messaging/Contracts/Messages/INewCandle.cs @@ -0,0 +1,15 @@ +namespace KLHZ.Trader.Core.Common.Messaging.Contracts.Messages +{ + public interface INewCandle + { + public bool IsHistoricalData { get; set; } + public decimal Open { get; set; } + public decimal Close { get; set; } + public decimal High { get; set; } + public decimal Low { get; set; } + public decimal Volume { get; set; } + public string Figi { get; set; } + public string Ticker { get; set; } + public DateTime Time { get; set; } + } +} diff --git a/KLHZ.Trader.Core/Common/Messaging/Contracts/Messages/INewPriceMessage.cs b/KLHZ.Trader.Core/Common/Messaging/Contracts/Messages/INewPriceMessage.cs new file mode 100644 index 0000000..780b9a6 --- /dev/null +++ b/KLHZ.Trader.Core/Common/Messaging/Contracts/Messages/INewPriceMessage.cs @@ -0,0 +1,11 @@ +namespace KLHZ.Trader.Core.Common.Messaging.Contracts.Messages +{ + public interface INewPriceMessage + { + public bool IsHistoricalData { get; set; } + public decimal Value { get; set; } + public string Figi { get; set; } + public string Ticker { get; set; } + public DateTime Time { get; set; } + } +} diff --git a/KLHZ.Trader.Core/Common/Messaging/Contracts/Messages/MessageForAdmin.cs b/KLHZ.Trader.Core/Common/Messaging/Contracts/Messages/MessageForAdmin.cs new file mode 100644 index 0000000..2985d9b --- /dev/null +++ b/KLHZ.Trader.Core/Common/Messaging/Contracts/Messages/MessageForAdmin.cs @@ -0,0 +1,7 @@ +namespace KLHZ.Trader.Core.Common.Messaging.Contracts.Messages +{ + public class MessageForAdmin + { + public required string Text { get; set; } + } +} diff --git a/KLHZ.Trader.Core/Common/Messaging/Contracts/Messages/NewPriceMessage.cs b/KLHZ.Trader.Core/Common/Messaging/Contracts/Messages/NewPriceMessage.cs new file mode 100644 index 0000000..66813a7 --- /dev/null +++ b/KLHZ.Trader.Core/Common/Messaging/Contracts/Messages/NewPriceMessage.cs @@ -0,0 +1,11 @@ +namespace KLHZ.Trader.Core.Common.Messaging.Contracts.Messages +{ + public class NewPriceMessage : INewPriceMessage + { + public decimal Value { get; set; } + public required string Figi { get; set; } + public required string Ticker { get; set; } + public DateTime Time { get; set; } + public bool IsHistoricalData { get; set; } + } +} diff --git a/KLHZ.Trader.Core/Common/Messaging/Contracts/Messages/TradeCommand.cs b/KLHZ.Trader.Core/Common/Messaging/Contracts/Messages/TradeCommand.cs new file mode 100644 index 0000000..d6c5bc4 --- /dev/null +++ b/KLHZ.Trader.Core/Common/Messaging/Contracts/Messages/TradeCommand.cs @@ -0,0 +1,16 @@ +using KLHZ.Trader.Core.Common.Messaging.Contracts.Messages.Enums; + +namespace KLHZ.Trader.Core.Common.Messaging.Contracts.Messages +{ + public class TradeCommand + { + public TradeCommandType CommandType { get; init; } + public string? Figi { get; init; } + public string? Ticker { get; init; } + public decimal? RecomendPrice { get; init; } + public decimal? Count { get; init; } + public decimal? LotsCount { get; init; } + public string? AccountId { get; init; } + public bool IsNeedBigCashOnAccount { get; init; } + } +} diff --git a/KLHZ.Trader.Core/Common/Messaging/Services/DataBus.cs b/KLHZ.Trader.Core/Common/Messaging/Services/DataBus.cs new file mode 100644 index 0000000..dd60c1c --- /dev/null +++ b/KLHZ.Trader.Core/Common/Messaging/Services/DataBus.cs @@ -0,0 +1,67 @@ +using KLHZ.Trader.Core.Common.Messaging.Contracts; +using KLHZ.Trader.Core.Common.Messaging.Contracts.Messages; +using System.Collections.Concurrent; +using System.Threading.Channels; + +namespace KLHZ.Trader.Core.Common.Messaging.Services +{ + public class DataBus : IDataBus + { + private readonly ConcurrentDictionary> _candlesChannels = new(); + private readonly ConcurrentDictionary> _priceChannels = new(); + private readonly ConcurrentDictionary> _commandChannels = new(); + private readonly ConcurrentDictionary> _chatMessages = new(); + + public bool AddChannel(Channel channel) + { + return _chatMessages.TryAdd(Guid.NewGuid().ToString(), channel); + } + + public bool AddChannel(string key, Channel channel) + { + return _priceChannels.TryAdd(key, channel); + } + + public bool AddChannel(string key, Channel channel) + { + return _candlesChannels.TryAdd(key, channel); + } + + public bool AddChannel(string key, Channel channel) + { + return _commandChannels.TryAdd(key, channel); + } + + public async Task BroadcastNewPrice(INewPriceMessage newPriceMessage) + { + foreach (var channel in _priceChannels.Values) + { + await channel.Writer.WriteAsync(newPriceMessage); + } + } + + public async Task BroadcastNewCandle(INewCandle newPriceMessage) + { + foreach (var channel in _candlesChannels.Values) + { + await channel.Writer.WriteAsync(newPriceMessage); + } + } + + public async Task BroadcastCommand(TradeCommand command) + { + foreach (var channel in _commandChannels.Values) + { + await channel.Writer.WriteAsync(command); + } + } + + public async Task BroadcastCommand(MessageForAdmin message) + { + foreach (var channel in _chatMessages.Values) + { + await channel.Writer.WriteAsync(message); + } + } + } +} diff --git a/KLHZ.Trader.Core/DataLayer/Entities/Declisions/Declision.cs b/KLHZ.Trader.Core/DataLayer/Entities/Declisions/Declision.cs new file mode 100644 index 0000000..18d0531 --- /dev/null +++ b/KLHZ.Trader.Core/DataLayer/Entities/Declisions/Declision.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace KLHZ.Trader.Core.DataLayer.Entities.Declisions +{ + [Table("declisions")] + public class Declision + { + [Column("id")] + public long Id { get; set; } + + [Column("time")] + public DateTime Time { get; set; } + + [Column("account_id")] + public required string AccountId { get; set; } + + [Column("figi")] + public required string Figi { get; set; } + + [Column("ticker")] + public required string Ticker { get; set; } + + [Column("price")] + public decimal Price { get; set; } + [Column("action")] + public DeclisionTradeAction Action { get; set; } + } +} diff --git a/KLHZ.Trader.Core/DataLayer/Entities/Declisions/DeclisionTradeAction.cs b/KLHZ.Trader.Core/DataLayer/Entities/Declisions/DeclisionTradeAction.cs new file mode 100644 index 0000000..64350cf --- /dev/null +++ b/KLHZ.Trader.Core/DataLayer/Entities/Declisions/DeclisionTradeAction.cs @@ -0,0 +1,11 @@ +namespace KLHZ.Trader.Core.DataLayer.Entities.Declisions +{ + public enum DeclisionTradeAction + { + Unknown = 0, + OpenLong = 100, + CloseLong = 200, + OpenShort = 300, + CloseShort = 400, + } +} diff --git a/KLHZ.Trader.Core/DataLayer/Entities/Prices/Candle.cs b/KLHZ.Trader.Core/DataLayer/Entities/Prices/Candle.cs new file mode 100644 index 0000000..d6a2861 --- /dev/null +++ b/KLHZ.Trader.Core/DataLayer/Entities/Prices/Candle.cs @@ -0,0 +1,38 @@ +using KLHZ.Trader.Core.Common.Messaging.Contracts.Messages; +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace KLHZ.Trader.Core.DataLayer.Entities.Prices +{ + [Table("candles")] + public class Candle : INewCandle + { + [Column("time")] + public DateTime Time { get; set; } + + [Column("figi")] + public required string Figi { get; set; } + + [Column("open")] + public decimal Open { get; set; } + [Column("close")] + public decimal Close { get; set; } + [Column("volume")] + public decimal Volume { get; set; } + + [Column("high")] + public decimal High { get; set; } + [Column("low")] + public decimal Low { get; set; } + + [Column("ticker")] + public required string Ticker { get; set; } + + [NotMapped] + public bool IsHistoricalData { get; set; } + } +} diff --git a/KLHZ.Trader.Core/DataLayer/Entities/Prices/PriceChange.cs b/KLHZ.Trader.Core/DataLayer/Entities/Prices/PriceChange.cs new file mode 100644 index 0000000..eb8bc51 --- /dev/null +++ b/KLHZ.Trader.Core/DataLayer/Entities/Prices/PriceChange.cs @@ -0,0 +1,27 @@ +using KLHZ.Trader.Core.Common.Messaging.Contracts.Messages; +using System.ComponentModel.DataAnnotations.Schema; + +namespace KLHZ.Trader.Core.DataLayer.Entities.Prices +{ + [Table("price_changes")] + public class PriceChange : INewPriceMessage + { + [Column("id")] + public long Id { get; set; } + + [Column("time")] + public DateTime Time { get; set; } + + [Column("value")] + public decimal Value { get; set; } + + [Column("figi")] + public required string Figi { get; set; } + + [Column("ticker")] + public required string Ticker { get; set; } + + [NotMapped] + public bool IsHistoricalData { get; set; } + } +} diff --git a/KLHZ.Trader.Core/DataLayer/Entities/Trades/AssetType.cs b/KLHZ.Trader.Core/DataLayer/Entities/Trades/AssetType.cs new file mode 100644 index 0000000..59a30f1 --- /dev/null +++ b/KLHZ.Trader.Core/DataLayer/Entities/Trades/AssetType.cs @@ -0,0 +1,9 @@ +namespace KLHZ.Trader.Core.DataLayer.Entities.Trades +{ + public enum AssetType + { + Unknown = 0, + Common = 1, + Future = 2 + } +} diff --git a/KLHZ.Trader.Core/DataLayer/Entities/Trades/PositionType.cs b/KLHZ.Trader.Core/DataLayer/Entities/Trades/PositionType.cs new file mode 100644 index 0000000..27c75c6 --- /dev/null +++ b/KLHZ.Trader.Core/DataLayer/Entities/Trades/PositionType.cs @@ -0,0 +1,9 @@ +namespace KLHZ.Trader.Core.DataLayer.Entities.Trades +{ + public enum PositionType + { + Unknown = 0, + Long = 1, + Short = 2 + } +} diff --git a/KLHZ.Trader.Core/DataLayer/Entities/Trades/Trade.cs b/KLHZ.Trader.Core/DataLayer/Entities/Trades/Trade.cs new file mode 100644 index 0000000..e7c764d --- /dev/null +++ b/KLHZ.Trader.Core/DataLayer/Entities/Trades/Trade.cs @@ -0,0 +1,45 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace KLHZ.Trader.Core.DataLayer.Entities.Trades +{ + [Table("trades")] + public class Trade + { + [Column("trade_id")] + public long Id { get; set; } + + [Column("bought_at")] + public DateTime BoughtAt { get; set; } + + [Column("account_id")] + public required string AccountId { get; set; } + + [Column("figi")] + public required string Figi { get; set; } + + [Column("ticker")] + public required string Ticker { get; set; } + + [Column("price")] + + public decimal Price { get; set; } + + [Column("count")] + public decimal Count { get; set; } + + [Column("count_lots")] + public decimal CountLots { get; set; } + + [Column("archive_status")] + public int ArchiveStatus { get; set; } + + [Column("direction")] + public TradeDirection Direction { get; set; } + + [Column("position_type")] + public PositionType Position { get; set; } + + [Column("asset_type")] + public AssetType Asset { get; set; } + } +} diff --git a/KLHZ.Trader.Core/DataLayer/Entities/Trades/TradeDirection.cs b/KLHZ.Trader.Core/DataLayer/Entities/Trades/TradeDirection.cs new file mode 100644 index 0000000..63e87b8 --- /dev/null +++ b/KLHZ.Trader.Core/DataLayer/Entities/Trades/TradeDirection.cs @@ -0,0 +1,9 @@ +namespace KLHZ.Trader.Core.DataLayer.Entities.Trades +{ + public enum TradeDirection + { + Unknown = 0, + Income = 1, + Outcome = 2 + } +} diff --git a/KLHZ.Trader.Core/DataLayer/TraderDbContext.cs b/KLHZ.Trader.Core/DataLayer/TraderDbContext.cs new file mode 100644 index 0000000..df5d66b --- /dev/null +++ b/KLHZ.Trader.Core/DataLayer/TraderDbContext.cs @@ -0,0 +1,63 @@ +using KLHZ.Trader.Core.DataLayer.Entities.Declisions; +using KLHZ.Trader.Core.DataLayer.Entities.Prices; +using KLHZ.Trader.Core.DataLayer.Entities.Trades; +using Microsoft.EntityFrameworkCore; + + +namespace KLHZ.Trader.Core.DataLayer +{ + public class TraderDbContext : DbContext + { + public DbSet Trades { get; set; } + public DbSet Declisions { get; set; } + public DbSet PriceChanges { get; set; } + public DbSet Candles { get; set; } + public TraderDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.UseSerialColumns(); + + modelBuilder.Entity(entity => + { + entity.HasKey(e1 => e1.Id); + entity.Property(e => e.BoughtAt) + .HasConversion( + v => v.ToUniversalTime(), + v => DateTime.SpecifyKind(v, DateTimeKind.Utc)); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e1 => e1.Id); + entity.Property(e => e.Time) + .HasConversion( + v => v.ToUniversalTime(), + v => DateTime.SpecifyKind(v, DateTimeKind.Utc)); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e1 => e1.Id); + entity.Ignore(e1 => e1.IsHistoricalData); + entity.Property(e => e.Time) + .HasConversion( + v => v.ToUniversalTime(), + v => DateTime.SpecifyKind(v, DateTimeKind.Utc)); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e1 => new { e1.Figi, e1.Time }); + entity.Ignore(e1 => e1.IsHistoricalData); + entity.Property(e => e.Time) + .HasConversion( + v => v.ToUniversalTime(), + v => DateTime.SpecifyKind(v, DateTimeKind.Utc)); + }); + } + } +} diff --git a/KLHZ.Trader.Core/Declisions/Models/PeriodPricesInfo.cs b/KLHZ.Trader.Core/Declisions/Models/PeriodPricesInfo.cs new file mode 100644 index 0000000..46fcf80 --- /dev/null +++ b/KLHZ.Trader.Core/Declisions/Models/PeriodPricesInfo.cs @@ -0,0 +1,26 @@ +namespace KLHZ.Trader.Core.Declisions.Models +{ + public readonly struct PeriodPricesInfo + { + public readonly int Count; + public readonly float LastPrice; + public readonly float FirstPrice; + public readonly float PeriodDiff; + public readonly float PeriodMax; + public readonly float PeriodMin; + public readonly bool Success; + public readonly TimeSpan Period; + + public PeriodPricesInfo(bool success, float firstPrice, float lastPrice, float periodDiff, float periodMin, float periodMax, TimeSpan period, int count) + { + Success = success; + LastPrice = lastPrice; + FirstPrice = firstPrice; + PeriodDiff = periodDiff; + PeriodMax = periodMax; + PeriodMin = periodMin; + Period = period; + Count = count; + } + } +} diff --git a/KLHZ.Trader.Core/Declisions/Models/PriceHistoryCacheUnit.cs b/KLHZ.Trader.Core/Declisions/Models/PriceHistoryCacheUnit.cs new file mode 100644 index 0000000..2280a67 --- /dev/null +++ b/KLHZ.Trader.Core/Declisions/Models/PriceHistoryCacheUnit.cs @@ -0,0 +1,73 @@ +using KLHZ.Trader.Core.Common.Messaging.Contracts.Messages; + +namespace KLHZ.Trader.Core.Declisions.Models +{ + public class PriceHistoryCacheUnit + { + public const int ArrayMaxLength = 500; + + public readonly string Figi; + + private readonly object _locker = new(); + private readonly float[] Prices = new float[ArrayMaxLength]; + private readonly DateTime[] Timestamps = new DateTime[ArrayMaxLength]; + + private int Length = 0; + + public void AddData(INewPriceMessage priceChange) + { + lock (_locker) + { + Array.Copy(Prices, 1, Prices, 0, Prices.Length - 1); + Array.Copy(Timestamps, 1, Timestamps, 0, Timestamps.Length - 1); + + Prices[Prices.Length - 1] = (float)priceChange.Value; + Timestamps[Timestamps.Length - 1] = priceChange.Time; + + if (Length < ArrayMaxLength) + { + Length++; + } + } + } + + public (DateTime[] timestamps, float[] prices) GetData() + { + var prices = new float[Length]; + var timestamps = new DateTime[Length]; + lock (_locker) + { + Array.Copy(Prices, Prices.Length - Length, prices, 0, prices.Length); + Array.Copy(Timestamps, Prices.Length - Length, timestamps, 0, timestamps.Length); + return (timestamps, prices); + } + } + + public PriceHistoryCacheUnit(string figi, params INewPriceMessage[] priceChanges) + { + Figi = figi; + + + if (priceChanges.Length == 0) + { + return; + } + + var selectedPriceChanges = priceChanges + .OrderBy(pc => pc.Time) + .Skip(priceChanges.Length - ArrayMaxLength) + .ToArray(); + var prices = selectedPriceChanges + .Select(pc => (float)pc.Value) + .ToArray(); + var times = selectedPriceChanges + .Select(pc => pc.Time) + .ToArray(); + + Array.Copy(prices, 0, Prices, Prices.Length - prices.Length, prices.Length); + Array.Copy(times, 0, Timestamps, Timestamps.Length - times.Length, times.Length); + + Length = times.Length > ArrayMaxLength ? ArrayMaxLength : times.Length; + } + } +} diff --git a/KLHZ.Trader.Core/Declisions/Services/Trader.cs b/KLHZ.Trader.Core/Declisions/Services/Trader.cs new file mode 100644 index 0000000..f26746c --- /dev/null +++ b/KLHZ.Trader.Core/Declisions/Services/Trader.cs @@ -0,0 +1,261 @@ +using KLHZ.Trader.Core.Common; +using KLHZ.Trader.Core.Common.Messaging.Contracts; +using KLHZ.Trader.Core.Common.Messaging.Contracts.Messages; +using KLHZ.Trader.Core.Common.Messaging.Contracts.Messages.Enums; +using KLHZ.Trader.Core.DataLayer; +using KLHZ.Trader.Core.DataLayer.Entities.Declisions; +using KLHZ.Trader.Core.Declisions.Models; +using KLHZ.Trader.Core.Declisions.Utils; +using KLHZ.Trader.Core.Exchange; +using KLHZ.Trader.Core.Exchange.Extentions; +using KLHZ.Trader.Core.Exchange.Models; +using KLHZ.Trader.Core.Exchange.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using System.Collections.Concurrent; +using System.Threading.Channels; +using Tinkoff.InvestApi; +using AssetType = KLHZ.Trader.Core.Exchange.Models.AssetType; + +namespace KLHZ.Trader.Core.Declisions.Services +{ + public class Trader : IHostedService + { + private readonly InvestApiClient _investApiClient; + private readonly IServiceProvider _provider; + private readonly IDataBus _dataBus; + private readonly BotModeSwitcher _botModeSwitcher; + private readonly IDbContextFactory _dbContextFactory; + private readonly ConcurrentDictionary Accounts = new(); + private readonly ConcurrentDictionary _historyCash = new(); + + private readonly decimal _futureComission; + private readonly decimal _shareComission; + private readonly decimal _accountCashPart; + private readonly decimal _accountCashPartFutures; + private readonly decimal _defaultBuyPartOfAccount; + private readonly string[] _managedAccountsNamePatterns = []; + + private readonly Channel _pricesChannel = Channel.CreateUnbounded(); + + public Trader( + BotModeSwitcher botModeSwitcher, + IServiceProvider provider, + IOptions options, + IDataBus dataBus, + IDbContextFactory dbContextFactory, + InvestApiClient investApiClient) + { + _botModeSwitcher = botModeSwitcher; + _dataBus = dataBus; + _provider = provider; + _investApiClient = investApiClient; + _managedAccountsNamePatterns = options.Value.ManagingAccountNamePatterns.ToArray(); + _dbContextFactory = dbContextFactory; + _futureComission = options.Value.FutureComission; + _shareComission = options.Value.ShareComission; + _accountCashPart = options.Value.AccountCashPart; + _accountCashPartFutures = options.Value.AccountCashPartFutures; + _defaultBuyPartOfAccount = options.Value.DefaultBuyPartOfAccount; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + var accounts = await _investApiClient.GetAccounts(_managedAccountsNamePatterns); + var accountsList = new List(); + int i = 0; + foreach (var accountId in accounts) + { + var acc = _provider.GetKeyedService(i); + if (acc != null) + { + await acc.Init(accountId); + Accounts[accountId] = acc; + i++; + } + else + { + break; + } + } + + _dataBus.AddChannel(nameof(Trader), _pricesChannel); + _ = ProcessMessages(); + } + + + private async Task ProcessMessages() + { + while (await _pricesChannel.Reader.WaitToReadAsync()) + { + var message = await _pricesChannel.Reader.ReadAsync(); + if (_historyCash.TryGetValue(message.Figi, out var data)) + { + data.AddData(message); + } + else + { + data = new PriceHistoryCacheUnit(message.Figi, message); + _historyCash.TryAdd(message.Figi, data); + } + + if (message.IsHistoricalData) + { + float meanfullDiff; + if (message.Figi == "BBG004730N88") + { + meanfullDiff = 0.16f; + } + else if (message.Figi == "FUTIMOEXF000") + { + meanfullDiff = 1.5f; + } + else + { + continue; + } + + try + { + var downtrendStarts = data.CheckDowntrendStarting(TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(7), meanfullDiff); + var uptrendStarts = data.CheckUptrendStarting(TimeSpan.FromSeconds(45), TimeSpan.FromSeconds(10), meanfullDiff); + + var downtrendEnds = data.CheckDowntrendEnding(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(15), meanfullDiff); + var uptrendEnds = data.CheckUptrendEnding(TimeSpan.FromSeconds(25), TimeSpan.FromSeconds(11), meanfullDiff); + //var uptrendEnds2 = data.CheckUptrendEnding(TimeSpan.FromSeconds(20), TimeSpan.FromSeconds(20), meanfullDiff); + + //var uptrendEnds = uptrendEnds1 || uptrendEnds2; + + var declisionAction = DeclisionTradeAction.Unknown; + + if (downtrendStarts) + { + //declisionAction = DeclisionTradeAction.OpenShort; + } + else if (uptrendStarts) + { + declisionAction = DeclisionTradeAction.OpenLong; + } + else if (downtrendEnds) + { + //declisionAction = DeclisionTradeAction.CloseShort; + } + else if(uptrendEnds) + { + declisionAction = DeclisionTradeAction.CloseLong; + } + + if (declisionAction != DeclisionTradeAction.Unknown) + { + using var context = await _dbContextFactory.CreateDbContextAsync(); + context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + await context.Declisions.AddAsync(new Declision() + { + AccountId = string.Empty, + Figi = message.Figi, + Ticker = message.Ticker, + Price = message.Value, + Time = message.IsHistoricalData? message.Time: DateTime.UtcNow, + Action = declisionAction, + }); + await context.SaveChangesAsync(); + } + } + catch (Exception ex) + { + + } + + } + } + } + + public async Task Preprocess(string figi) + { + if (_historyCash.TryGetValue(figi, out var unit)) + { + var periodData1 = unit.GetPriceDiffForTimeSpan(TimeSpan.Zero, TimeSpan.FromSeconds(10)); + var periodData2 = unit.GetPriceDiffForTimeSpan(TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)); + if (Math.Abs(periodData1.PeriodDiff) <= 1 && periodData2.PeriodDiff > 2) + { + //можно покупать. + } + + if (Math.Abs(periodData1.PeriodDiff) <= 1 && periodData2.PeriodDiff < -2) + { + //можно продавать. + } + + } + } + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + private decimal GetComission(AssetType assetType) + { + if (assetType == AssetType.Common) + { + return _shareComission; + } + else if (assetType == AssetType.Futures) + { + return _futureComission; + } + else + { + return 0; + } + } + + private decimal GetCount(string accountId, decimal boutPrice) + { + var balance = Accounts[accountId].Balance; + return System.Math.Floor(balance * _defaultBuyPartOfAccount / boutPrice); + } + + private bool IsBuyAllowed(string accountId, decimal boutPrice, decimal count, bool needBigCash) + { + if (!_botModeSwitcher.CanPurchase()) return false; + + var balance = Accounts[accountId].Balance; + var total = Accounts[accountId].Total; + + var futures = Accounts[accountId].Assets.Values.FirstOrDefault(v => v.Type == AssetType.Futures); + if (futures != null || needBigCash) + { + if ((balance - boutPrice * count) / total < _accountCashPartFutures) return false; + } + else + { + if ((balance - boutPrice * count) / total < _accountCashPart) return false; + } + + return true; + } + + private bool IsSellAllowed(AssetType assetType, PositionType positionType, decimal boutPrice, decimal? requiredPrice, TradeCommandType commandType) + { + if (commandType >= TradeCommandType.MarketSell && commandType < TradeCommandType.ForceClosePosition && requiredPrice.HasValue) + { + var comission = GetComission(assetType); + if (positionType == PositionType.Long) + { + return requiredPrice.Value * (1 - comission) > boutPrice * (1 + comission); + } + else if (positionType == PositionType.Short) + { + return requiredPrice.Value * (1 + comission) < boutPrice * (1 - comission); + } + } + + if (commandType == TradeCommandType.ForceClosePosition) return true; + + return false; + } + + } +} diff --git a/KLHZ.Trader.Core/Declisions/Utils/HistoryProcessingInstruments.cs b/KLHZ.Trader.Core/Declisions/Utils/HistoryProcessingInstruments.cs new file mode 100644 index 0000000..28992cd --- /dev/null +++ b/KLHZ.Trader.Core/Declisions/Utils/HistoryProcessingInstruments.cs @@ -0,0 +1,169 @@ +using KLHZ.Trader.Core.Declisions.Models; + +namespace KLHZ.Trader.Core.Declisions.Utils +{ + internal static class HistoryProcessingInstruments + { + internal static PeriodPricesInfo GetPriceDiffForTimeSpan(this PriceHistoryCacheUnit unit, TimeSpan timeShift, TimeSpan timeSpan) + { + var res = new PeriodPricesInfo(false, 0, 0, 0, 0, 0, timeSpan, 0); + var data = unit.GetData(); + var times = data.timestamps; + var prices = data.prices; + if (times.Length < 2) return res; + var lastPriceTime = times[times.Length - 1]; + var intervalEnd = lastPriceTime - timeShift; + var intervalStart = intervalEnd - timeSpan; + var max = float.MinValue; + var min = float.MaxValue; + + var intervaEndIndex = -1; + var intervaStartIndex = -1; + for (int i = times.Length - 1; i > -1; i--) + { + if (times[i] <= intervalEnd && intervaEndIndex < 0) + { + intervaEndIndex = i; + } + + if (prices[i] > max && intervaEndIndex >= 0) + { + max = prices[i]; + } + if (prices[i] < min && intervaEndIndex >= 0) + { + min = prices[i]; + } + if (times[i] <= intervalStart && intervaStartIndex < 0) + { + intervaStartIndex = i; + if (intervaStartIndex != intervaEndIndex && intervaEndIndex >= 0) + break; + } + } + + if (intervaStartIndex >= 0 && intervaEndIndex >= 0) + { + res = new PeriodPricesInfo( + true, + prices[intervaStartIndex], + prices[intervaEndIndex], + prices[intervaEndIndex] - prices[intervaStartIndex], + min, + max, + timeSpan, intervaEndIndex - intervaStartIndex); + } + + return res; + } + + internal static bool CheckStable(this PeriodPricesInfo data, float meanfullDiff) + { + meanfullDiff = Math.Abs(meanfullDiff); + return data.Success && Math.Abs(data.PeriodDiff) < 1.5 * meanfullDiff && Math.Abs(data.PeriodMax - data.PeriodMin) < 2 * meanfullDiff; + } + + internal static bool CheckGrowing(this PeriodPricesInfo data, float meanfullDiff) + { + return meanfullDiff > 0 && data.Success && data.PeriodDiff > meanfullDiff && Math.Abs(data.PeriodMax - data.PeriodMin) < 3 * Math.Abs(data.PeriodDiff); + } + + internal static bool CheckFalling(this PeriodPricesInfo data, float meanfullDiff) + { + meanfullDiff = -meanfullDiff; + return meanfullDiff < 0 && data.Success && data.PeriodDiff < meanfullDiff && Math.Abs(data.PeriodMax - data.PeriodMin) < 3 * Math.Abs(data.PeriodDiff); + } + + internal static float CalcTrendRelationAbs(PeriodPricesInfo first, PeriodPricesInfo second) + { + var k1 = Math.Abs(first.PeriodDiff) / Math.Abs(first.Period.TotalSeconds); + var k2 = Math.Abs(second.PeriodDiff) / Math.Abs(second.Period.TotalSeconds); + if (k2 == 0 && k1 != 0) return 1000; + return (float)(k1 / k2); + } + + internal static bool CheckDowntrendEnding(this PriceHistoryCacheUnit unit, TimeSpan firstPeriod, TimeSpan secondPeriod, float meanfullDiff) + { + var totalDiff = unit.GetPriceDiffForTimeSpan(TimeSpan.Zero, firstPeriod + secondPeriod); + var startDiff = unit.GetPriceDiffForTimeSpan(secondPeriod, firstPeriod); + var endDiff = unit.GetPriceDiffForTimeSpan(TimeSpan.Zero, secondPeriod); + var isEndStable = endDiff.CheckStable(meanfullDiff); + var isEndGrown = endDiff.CheckGrowing(meanfullDiff); + var isStartFalls = startDiff.CheckFalling(meanfullDiff); + var isTotalFalls = totalDiff.CheckFalling(meanfullDiff); + + var trendRelation = CalcTrendRelationAbs(startDiff, endDiff); + var res = totalDiff.Success && isStartFalls && (isEndStable || isEndGrown) && trendRelation >= 2; + + if (startDiff.Success) + { + + } + return res; + } + + internal static bool CheckUptrendEnding(this PriceHistoryCacheUnit unit, TimeSpan firstPeriod, TimeSpan secondPeriod, float meanfullDiff) + { + var totalDiff = unit.GetPriceDiffForTimeSpan(TimeSpan.Zero, firstPeriod + secondPeriod); + var startDiff = unit.GetPriceDiffForTimeSpan(secondPeriod, firstPeriod); + var endDiff = unit.GetPriceDiffForTimeSpan(TimeSpan.Zero, secondPeriod); + var isEndStable = endDiff.CheckStable(meanfullDiff); + var isEndFalls = endDiff.CheckFalling(meanfullDiff); + var isStartGrows = startDiff.CheckGrowing(meanfullDiff); + + var trendRelation = CalcTrendRelationAbs(startDiff, endDiff); + var isEndLocal = endDiff.PeriodDiff == 0 && endDiff.Count == 2; + var res = totalDiff.Success && isStartGrows && (isEndStable || isEndFalls) && (trendRelation >= 2 && !isEndLocal); + + if (res) + { + + } + return res; + } + + internal static bool CheckDowntrendStarting(this PriceHistoryCacheUnit unit, TimeSpan firstPeriod, TimeSpan secondPeriod, float meanfullDiff) + { + var totalDiff = unit.GetPriceDiffForTimeSpan(TimeSpan.Zero, firstPeriod + secondPeriod); + var startDiff = unit.GetPriceDiffForTimeSpan(secondPeriod, firstPeriod); + var endDiff = unit.GetPriceDiffForTimeSpan(TimeSpan.Zero, secondPeriod); + + + var isEndFalls = endDiff.CheckFalling(meanfullDiff); + var isStartStable = startDiff.CheckStable(meanfullDiff); + var isStartGrows = startDiff.CheckGrowing(meanfullDiff); + + var trendRelation = CalcTrendRelationAbs(endDiff, startDiff); + return totalDiff.Success && (isStartStable || isStartGrows) && isEndFalls && trendRelation >= 2; + } + + internal static bool CheckUptrendStarting(this PriceHistoryCacheUnit unit, TimeSpan firstPeriod, TimeSpan secondPeriod, float meanfullDiff) + { + var totalDiff = unit.GetPriceDiffForTimeSpan(TimeSpan.Zero, firstPeriod + secondPeriod); + var startDiff = unit.GetPriceDiffForTimeSpan(secondPeriod, firstPeriod); + var endDiff = unit.GetPriceDiffForTimeSpan(TimeSpan.Zero, secondPeriod); + + + var isEndGrows = endDiff.CheckGrowing(meanfullDiff); + var isStartStable = startDiff.CheckStable(meanfullDiff); + var isStartFalls = startDiff.CheckStable(meanfullDiff); + + var trendRelation = CalcTrendRelationAbs(endDiff, startDiff); + var res = totalDiff.Success && (isStartStable || isStartFalls) && isEndGrows && endDiff.PeriodDiff > meanfullDiff; + + if (isStartStable) + { + res &= trendRelation >= 2; + } + else + { + + } + if (res) + { + + } + return res; + } + } +} diff --git a/KLHZ.Trader.Core/Exchange/ExchangeConfig.cs b/KLHZ.Trader.Core/Exchange/ExchangeConfig.cs new file mode 100644 index 0000000..5aeae41 --- /dev/null +++ b/KLHZ.Trader.Core/Exchange/ExchangeConfig.cs @@ -0,0 +1,13 @@ +namespace KLHZ.Trader.Core.Exchange +{ + public class ExchangeConfig + { + public decimal FutureComission { get; set; } + public decimal ShareComission { get; set; } + public decimal AccountCashPart { get; set; } + public decimal AccountCashPartFutures { get; set; } + public decimal DefaultBuyPartOfAccount { get; set; } + public string[] AllowedInstrumentsFigis { get; set; } = []; + public string[] ManagingAccountNamePatterns { get; set; } = []; + } +} diff --git a/KLHZ.Trader.Core/Exchange/Extentions/InvestApiClientExtentions.cs b/KLHZ.Trader.Core/Exchange/Extentions/InvestApiClientExtentions.cs new file mode 100644 index 0000000..7ea622a --- /dev/null +++ b/KLHZ.Trader.Core/Exchange/Extentions/InvestApiClientExtentions.cs @@ -0,0 +1,25 @@ +using Tinkoff.InvestApi; +using Tinkoff.InvestApi.V1; + +namespace KLHZ.Trader.Core.Exchange.Extentions +{ + internal static class InvestApiClientExtentions + { + public static async Task GetAccounts(this InvestApiClient client, params string[] managedAccountNamePatterns) + { + var accounts = await client.Users.GetAccountsAsync(); + var accsIds = new HashSet(); + foreach (var pattern in managedAccountNamePatterns) + { + var aids = accounts.Accounts + .Where(a => a.Name.ToLower().Contains(pattern) && a.AccessLevel == AccessLevel.AccountAccessLevelFullAccess) + .Select(a => a.Id); + foreach (var a in aids) + { + accsIds.Add(a); + } + } + return accsIds.ToArray(); + } + } +} diff --git a/KLHZ.Trader.Core/Exchange/Extentions/StringExtensions.cs b/KLHZ.Trader.Core/Exchange/Extentions/StringExtensions.cs new file mode 100644 index 0000000..a0bcda0 --- /dev/null +++ b/KLHZ.Trader.Core/Exchange/Extentions/StringExtensions.cs @@ -0,0 +1,22 @@ +using KLHZ.Trader.Core.Exchange.Models; + +namespace KLHZ.Trader.Core.Exchange.Extentions +{ + internal static class StringExtensions + { + internal static AssetType ParseInstrumentType(this string instrumentType) + { + switch (instrumentType) + { + case "futures": + return AssetType.Futures; + case "currency": + return AssetType.Currency; + case "share": + return AssetType.Common; + default: + return AssetType.Unknown; + } + } + } +} diff --git a/KLHZ.Trader.Core/Exchange/Models/Asset.cs b/KLHZ.Trader.Core/Exchange/Models/Asset.cs new file mode 100644 index 0000000..fec382d --- /dev/null +++ b/KLHZ.Trader.Core/Exchange/Models/Asset.cs @@ -0,0 +1,16 @@ +namespace KLHZ.Trader.Core.Exchange.Models +{ + public class Asset + { + public decimal BlockedItems { get; init; } + public AssetType Type { get; init; } + public PositionType Position { get; init; } + public DateTime BoughtAt { get; init; } + public required string AccountId { get; init; } + public required string Figi { get; init; } + public required string Ticker { get; init; } + public decimal BoughtPrice { get; init; } + public decimal Count { get; init; } + public decimal CountLots { get; init; } + } +} \ No newline at end of file diff --git a/KLHZ.Trader.Core/Exchange/Models/AssetType.cs b/KLHZ.Trader.Core/Exchange/Models/AssetType.cs new file mode 100644 index 0000000..1a58b9d --- /dev/null +++ b/KLHZ.Trader.Core/Exchange/Models/AssetType.cs @@ -0,0 +1,10 @@ +namespace KLHZ.Trader.Core.Exchange.Models +{ + public enum AssetType + { + Unknown = 0, + Currency = 1, + Common = 2, + Futures = 3, + } +} diff --git a/KLHZ.Trader.Core/Exchange/Models/DealResult.cs b/KLHZ.Trader.Core/Exchange/Models/DealResult.cs new file mode 100644 index 0000000..4b09c1d --- /dev/null +++ b/KLHZ.Trader.Core/Exchange/Models/DealResult.cs @@ -0,0 +1,9 @@ +namespace KLHZ.Trader.Core.Exchange.Models +{ + public class DealResult + { + public decimal Price { get; set; } + public decimal Count { get; set; } + public bool Success { get; set; } + } +} diff --git a/KLHZ.Trader.Core/Exchange/Models/PositionType.cs b/KLHZ.Trader.Core/Exchange/Models/PositionType.cs new file mode 100644 index 0000000..1f68c6f --- /dev/null +++ b/KLHZ.Trader.Core/Exchange/Models/PositionType.cs @@ -0,0 +1,9 @@ +namespace KLHZ.Trader.Core.Exchange.Models +{ + public enum PositionType + { + Unknown = 0, + Long = 1, + Short = 2 + } +} diff --git a/KLHZ.Trader.Core/Exchange/Services/ExchangeDataReader.cs b/KLHZ.Trader.Core/Exchange/Services/ExchangeDataReader.cs new file mode 100644 index 0000000..f654cc5 --- /dev/null +++ b/KLHZ.Trader.Core/Exchange/Services/ExchangeDataReader.cs @@ -0,0 +1,189 @@ +using Grpc.Core; +using KLHZ.Trader.Core.Common.Messaging.Contracts; +using KLHZ.Trader.Core.DataLayer; +using KLHZ.Trader.Core.DataLayer.Entities.Prices; +using KLHZ.Trader.Core.Exchange.Extentions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Collections.Concurrent; +using Tinkoff.InvestApi; +using Tinkoff.InvestApi.V1; +using Candle = KLHZ.Trader.Core.DataLayer.Entities.Prices.Candle; + +namespace KLHZ.Trader.Core.Exchange.Services +{ + public class ExchangeDataReader : IHostedService + { + private readonly InvestApiClient _investApiClient; + private readonly string[] _instrumentsFigis = []; + private readonly string[] _managedAccountNamePatterns; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _tickersCache = new(); + private readonly IDbContextFactory _dbContextFactory; + private readonly CancellationTokenSource _cts = new(); + private readonly IDataBus _eventBus; + + public ExchangeDataReader(InvestApiClient investApiClient, IDataBus eventBus, + IOptions options, IDbContextFactory dbContextFactory, + ILogger logger) + { + _eventBus = eventBus; + _dbContextFactory = dbContextFactory; + _investApiClient = investApiClient; + _instrumentsFigis = options.Value.AllowedInstrumentsFigis.ToArray(); + _logger = logger; + _managedAccountNamePatterns = options.Value.ManagingAccountNamePatterns.ToArray(); + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Инициализация приемника данных с биржи"); + var accounts = await _investApiClient.GetAccounts(_managedAccountNamePatterns); + await InitCache(); + _ = CycleSubscribtion(accounts); + } + + private async Task InitCache() + { + var shares = await _investApiClient.Instruments.SharesAsync(); + foreach (var share in shares.Instruments) + { + //if (_instrumentsFigis.Contains(share.Figi)) + { + _tickersCache.TryAdd(share.Figi, share.Ticker); + } + } + var futures = await _investApiClient.Instruments.FuturesAsync(); + foreach (var future in futures.Instruments) + { + //if (_instrumentsFigis.Contains(future.Figi)) + { + _tickersCache.TryAdd(future.Figi, future.Ticker); + } + } + } + + private async Task CycleSubscribtion(string[] accounts) + { + while (true) + { + try + { + //await SubscribePrices(); + await Task.Delay(1000); + //await SubscribeCandles(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Ошибка в одном из стримов получения данных от биржи."); + } + } + } + + private async Task SubscribePrices() + { + using var stream = _investApiClient.MarketDataStream.MarketDataStream(); + + var request = new SubscribeLastPriceRequest + { + SubscriptionAction = SubscriptionAction.Subscribe + }; + + foreach (var f in _instrumentsFigis) + { + request.Instruments.Add( + new LastPriceInstrument() + { + InstrumentId = f + }); + } + + await stream.RequestStream.WriteAsync(new MarketDataRequest + { + SubscribeLastPriceRequest = request, + }); + + await foreach (var response in stream.ResponseStream.ReadAllAsync()) + { + if (response.LastPrice != null) + { + var message = new PriceChange() + { + Figi = response.LastPrice.Figi, + Ticker = GetTickerByFigi(response.LastPrice.Figi), + Time = response.LastPrice.Time.ToDateTime().ToUniversalTime(), + Value = response.LastPrice.Price, + IsHistoricalData = false, + }; + await _eventBus.BroadcastNewPrice(message); + using var context = await _dbContextFactory.CreateDbContextAsync(); + context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + await context.PriceChanges.AddAsync(message); + await context.SaveChangesAsync(); + } + } + } + + private async Task SubscribeCandles() + { + using var stream = _investApiClient.MarketDataStream.MarketDataStream(); + + var request = new SubscribeCandlesRequest + { + SubscriptionAction = SubscriptionAction.Subscribe, + CandleSourceType = GetCandlesRequest.Types.CandleSource.Exchange + }; + + foreach (var f in _instrumentsFigis) + { + request.Instruments.Add( + new CandleInstrument() + { + InstrumentId = f, + Interval = SubscriptionInterval.OneMinute + }); + } + + await stream.RequestStream.WriteAsync(new MarketDataRequest + { + SubscribeCandlesRequest = request, + }); + + await foreach (var response in stream.ResponseStream.ReadAllAsync()) + { + if (response.Candle != null) + { + var message = new Candle() + { + Figi = response.Candle.Figi, + Ticker = GetTickerByFigi(response.LastPrice.Figi), + Time = response.Candle.Time.ToDateTime().ToUniversalTime(), + Close = response.Candle.Close, + Open = response.Candle.Open, + Low = response.Candle.Low, + High = response.Candle.High, + Volume = response.Candle.Volume, + IsHistoricalData = false, + }; + await _eventBus.BroadcastNewCandle(message); + using var context = await _dbContextFactory.CreateDbContextAsync(); + context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + await context.Candles.AddAsync(message); + await context.SaveChangesAsync(); + } + } + } + private string GetTickerByFigi(string figi) + { + return _tickersCache.TryGetValue(figi, out var ticker) ? ticker : string.Empty; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _cts.Cancel(); + return Task.CompletedTask; + } + } +} diff --git a/KLHZ.Trader.Core/Exchange/Services/ManagedAccount.cs b/KLHZ.Trader.Core/Exchange/Services/ManagedAccount.cs new file mode 100644 index 0000000..5ba3df7 --- /dev/null +++ b/KLHZ.Trader.Core/Exchange/Services/ManagedAccount.cs @@ -0,0 +1,291 @@ +using KLHZ.Trader.Core.Common.Messaging.Contracts; +using KLHZ.Trader.Core.Common.Messaging.Contracts.Messages; +using KLHZ.Trader.Core.Common.Messaging.Contracts.Messages.Enums; +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 _channel = Channel.CreateUnbounded(); + + #region Поля, собираемые из контейнера DI + private readonly InvestApiClient _investApiClient; + private readonly IDbContextFactory _dbContextFactory; + private readonly ILogger _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 Assets = new(); + + #endregion + + public ManagedAccount(InvestApiClient investApiClient, IDataBus dataBus, IDbContextFactory dbContextFactory, ILogger 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 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 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.Income, + 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(); + } + } +} diff --git a/KLHZ.Trader.Core/KLHZ.Trader.Core.csproj b/KLHZ.Trader.Core/KLHZ.Trader.Core.csproj new file mode 100644 index 0000000..63b403a --- /dev/null +++ b/KLHZ.Trader.Core/KLHZ.Trader.Core.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + diff --git a/KLHZ.Trader.Core/TG/Services/BotMessagesHandler.cs b/KLHZ.Trader.Core/TG/Services/BotMessagesHandler.cs new file mode 100644 index 0000000..e5dbdaf --- /dev/null +++ b/KLHZ.Trader.Core/TG/Services/BotMessagesHandler.cs @@ -0,0 +1,123 @@ +using KLHZ.Trader.Core.Common; +using KLHZ.Trader.Core.Common.Messaging.Contracts; +using KLHZ.Trader.Core.Common.Messaging.Contracts.Messages; +using KLHZ.Trader.Core.Common.Messaging.Contracts.Messages.Enums; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Collections.Immutable; +using Telegram.Bot; +using Telegram.Bot.Polling; +using Telegram.Bot.Types; +using Telegram.Bot.Types.ReplyMarkups; + +namespace KLHZ.Trader.Core.TG.Services +{ + public class BotMessagesHandler : IUpdateHandler + { + private readonly ImmutableArray _admins = []; + private readonly BotModeSwitcher _botModeSwitcher; + private readonly IDataBus _eventBus; + private readonly ILogger _logger; + public BotMessagesHandler(BotModeSwitcher botModeSwitcher, IDataBus eventBus, IOptions options, ILogger logger) + { + _logger = logger; + _botModeSwitcher = botModeSwitcher; + _eventBus = eventBus; + _admins = ImmutableArray.CreateRange(options.Value.Admins); + } + + public Task HandleErrorAsync(ITelegramBotClient botClient, Exception exception, HandleErrorSource source, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public async Task HandleUpdateAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken) + { + try + { + if (update.Message != null && update.Message?.From != null && _admins.Contains(update.Message.From.Id)) + { + switch (update.Message.Text) + { + + case "/start": + { + var replyKeyboardMarkup = new ReplyKeyboardMarkup(new[] { + new KeyboardButton[] { Constants.BotCommandsButtons.EnableSelling, Constants.BotCommandsButtons.DisableSelling}, + new KeyboardButton[] { Constants.BotCommandsButtons.EnablePurchases, Constants.BotCommandsButtons.DisablePurchases}}); + + + await botClient.SendMessage(update.Message.Chat, "Принято!", replyMarkup: replyKeyboardMarkup); + break; + } + case Constants.BotCommandsButtons.EnableSelling: + { + _botModeSwitcher.StartSelling(); + await botClient.SendMessage(update.Message.Chat, "Продажи начаты!"); + break; + } + case Constants.BotCommandsButtons.DisableSelling: + { + _botModeSwitcher.StopSelling(); + await botClient.SendMessage(update.Message.Chat, "Продажи остановлены!"); + break; + } + case Constants.BotCommandsButtons.EnablePurchases: + { + _botModeSwitcher.StartPurchase(); + await botClient.SendMessage(update.Message.Chat, "Покупки начаты!"); + break; + } + case Constants.BotCommandsButtons.DisablePurchases: + { + _botModeSwitcher.StopPurchase(); + await botClient.SendMessage(update.Message.Chat, "Покупки остановлены!"); + break; + } + case "сбросить сбер": + { + var command = new TradeCommand() + { + CommandType = TradeCommandType.ForceClosePosition, + RecomendPrice = null, + Figi = "BBG004730N88", + }; + await _eventBus.BroadcastCommand(command); + break; + } + case "продать сбер": + { + var command = new TradeCommand() + { + CommandType = TradeCommandType.MarketSell, + RecomendPrice = null, + Figi = "BBG004730N88", + Count = 1, + LotsCount = 1, + }; + await _eventBus.BroadcastCommand(command); + break; + } + case "купить сбер": + { + var command = new TradeCommand() + { + CommandType = TradeCommandType.MarketBuy, + RecomendPrice = null, + Figi = "BBG004730N88", + Count = 1 + }; + await _eventBus.BroadcastCommand(command); + break; + } + } + await botClient.SendMessage(update.Message.Chat, "Принято!"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Ошибка при обработке сообщения из телеграма."); + } + } + } +} diff --git a/KLHZ.Trader.Core/TG/Services/BotStarter.cs b/KLHZ.Trader.Core/TG/Services/BotStarter.cs new file mode 100644 index 0000000..cf50731 --- /dev/null +++ b/KLHZ.Trader.Core/TG/Services/BotStarter.cs @@ -0,0 +1,51 @@ +using KLHZ.Trader.Core.Common.Messaging.Contracts; +using KLHZ.Trader.Core.Common.Messaging.Contracts.Messages; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using System.Collections.Immutable; +using System.Threading.Channels; +using Telegram.Bot; +using Telegram.Bot.Polling; + +namespace KLHZ.Trader.Core.TG.Services +{ + public class BotStarter : IHostedService + { + private readonly TelegramBotClient _botClient; + private readonly IUpdateHandler _updateHandler; + private readonly Channel _messages = Channel.CreateUnbounded(); + private readonly ImmutableArray _admins = []; + + public BotStarter(IOptions cfg, IUpdateHandler updateHandler, IDataBus dataBus, IOptions options) + { + _botClient = new TelegramBotClient(cfg.Value.Token); + _updateHandler = updateHandler; + dataBus.AddChannel(_messages); + _admins = ImmutableArray.CreateRange(options.Value.Admins); + _ = ProcessMessages(); + } + + private async Task ProcessMessages() + { + while (await _messages.Reader.WaitToReadAsync()) + { + var message = await _messages.Reader.ReadAsync(); + foreach (var admin in _admins) + { + await _botClient.SendMessage(admin, message.Text); + } + } + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _botClient.StartReceiving(_updateHandler); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return _botClient.Close(); + } + } +} diff --git a/KLHZ.Trader.Core/TG/TgBotConfig.cs b/KLHZ.Trader.Core/TG/TgBotConfig.cs new file mode 100644 index 0000000..3c253fc --- /dev/null +++ b/KLHZ.Trader.Core/TG/TgBotConfig.cs @@ -0,0 +1,9 @@ +namespace KLHZ.Trader.Core.TG +{ + public class TgBotConfig + { + public required string Token { get; set; } + + public required long[] Admins = []; + } +} diff --git a/KLHZ.Trader.HistoryLoader/Controllers/LoaderController.cs b/KLHZ.Trader.HistoryLoader/Controllers/LoaderController.cs new file mode 100644 index 0000000..a1f3837 --- /dev/null +++ b/KLHZ.Trader.HistoryLoader/Controllers/LoaderController.cs @@ -0,0 +1,84 @@ +using Google.Protobuf.WellKnownTypes; +using KLHZ.Trader.Core.Common.Messaging.Contracts; +using KLHZ.Trader.Core.Common.Messaging.Contracts.Messages; +using KLHZ.Trader.Core.DataLayer; +using KLHZ.Trader.Core.DataLayer.Entities.Prices; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Internal; +using Tinkoff.InvestApi; +using Tinkoff.InvestApi.V1; +using Candle = KLHZ.Trader.Core.DataLayer.Entities.Prices.Candle; + +namespace KLHZ.Trader.HistoryLoader.Controllers +{ + [ApiController] + [Route("[controller]/[action]")] + public class LoaderController : ControllerBase + { + private readonly InvestApiClient _investApiClient; + private readonly IDbContextFactory _dbContextFactory; + + public LoaderController(InvestApiClient client, IDbContextFactory dbContextFactory) + { + _dbContextFactory = dbContextFactory; + _investApiClient = client; + } + [HttpGet] + public async Task Load(string figi) + { + using var context1 = await _dbContextFactory.CreateDbContextAsync(); + context1.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + var existed = (await context1.Candles.Where(c => c.Figi == figi).Select(c => c.Time) + .ToArrayAsync()).Select(t => t.ToUniversalTime()) + .ToHashSet(); + var dt = DateTime.UtcNow; + var i = -400; + while (i < 0) + { + try + { + + var req = new GetCandlesRequest() + { + Interval = CandleInterval._5Sec, + From = Timestamp.FromDateTime(dt.AddHours(i)), + To = Timestamp.FromDateTime(dt.AddHours(i + 1)), + InstrumentId = figi, + }; + var candles = await _investApiClient.MarketData.GetCandlesAsync(req); + i++; + using var context = await _dbContextFactory.CreateDbContextAsync(); + context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + var forAdd = new List(); + foreach (var c in candles.Candles) + { + var dt1 = c.Time.ToDateTime().ToUniversalTime(); + if (!existed.Contains(dt1)) + { + var ca = new Candle + { + Figi = figi, + Ticker = string.Empty, + Time = dt1, + Open = c.Open, + Close = c.Close, + Volume = c.Volume, + High = c.High, + Low = c.Low, + }; + forAdd.Add(ca); + existed.Add(dt1); + } + } + await context.Candles.AddRangeAsync(forAdd); + await context.SaveChangesAsync(); + } + catch (Exception ex) + { + + } + } + } + } +} diff --git a/KLHZ.Trader.HistoryLoader/Dockerfile b/KLHZ.Trader.HistoryLoader/Dockerfile new file mode 100644 index 0000000..2703170 --- /dev/null +++ b/KLHZ.Trader.HistoryLoader/Dockerfile @@ -0,0 +1,30 @@ +# См. статью по ссылке https://aka.ms/customizecontainer, чтобы узнать как настроить контейнер отладки и как Visual Studio использует этот Dockerfile для создания образов для ускорения отладки. + +# Этот этап используется при запуске из VS в быстром режиме (по умолчанию для конфигурации отладки) +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 + + +# Этот этап используется для сборки проекта службы +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["KLHZ.Trader.HistoryLoader/KLHZ.Trader.HistoryLoader.csproj", "KLHZ.Trader.HistoryLoader/"] +COPY ["KLHZ.Trader.Core/KLHZ.Trader.Core.csproj", "KLHZ.Trader.Core/"] +RUN dotnet restore "./KLHZ.Trader.HistoryLoader/KLHZ.Trader.HistoryLoader.csproj" +COPY . . +WORKDIR "/src/KLHZ.Trader.HistoryLoader" +RUN dotnet build "./KLHZ.Trader.HistoryLoader.csproj" -c $BUILD_CONFIGURATION -o /app/build + +# Этот этап используется для публикации проекта службы, который будет скопирован на последний этап +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./KLHZ.Trader.HistoryLoader.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +# Этот этап используется в рабочей среде или при запуске из VS в обычном режиме (по умолчанию, когда конфигурация отладки не используется) +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "KLHZ.Trader.HistoryLoader.dll"] \ No newline at end of file diff --git a/KLHZ.Trader.HistoryLoader/KLHZ.Trader.HistoryLoader.csproj b/KLHZ.Trader.HistoryLoader/KLHZ.Trader.HistoryLoader.csproj new file mode 100644 index 0000000..dc14fd5 --- /dev/null +++ b/KLHZ.Trader.HistoryLoader/KLHZ.Trader.HistoryLoader.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + Linux + ..\docker-compose.dcproj + + + + + + + + + + + + diff --git a/KLHZ.Trader.HistoryLoader/Program.cs b/KLHZ.Trader.HistoryLoader/Program.cs new file mode 100644 index 0000000..c9871b8 --- /dev/null +++ b/KLHZ.Trader.HistoryLoader/Program.cs @@ -0,0 +1,30 @@ +using KLHZ.Trader.Core.DataLayer; +using KLHZ.Trader.Core.Exchange; +using Microsoft.EntityFrameworkCore; + + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddInvestApiClient((_, settings) => +{ + settings.AccessToken = builder.Configuration.GetSection(nameof(ExchangeConfig))["Token"]; +}); + +builder.Services.AddDbContextFactory(options => +{ + options.UseNpgsql(builder.Configuration.GetConnectionString("PostgresConnection")); +}); + + +var app = builder.Build(); + +app.UseSwagger(); +app.UseSwaggerUI(); + +app.MapControllers(); + +app.Run(); diff --git a/KLHZ.Trader.HistoryLoader/appsettings.Development.json b/KLHZ.Trader.HistoryLoader/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/KLHZ.Trader.HistoryLoader/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/KLHZ.Trader.HistoryLoader/appsettings.json b/KLHZ.Trader.HistoryLoader/appsettings.json new file mode 100644 index 0000000..4d56694 --- /dev/null +++ b/KLHZ.Trader.HistoryLoader/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/KLHZ.Trader.Infrastructure/graphana/dashboard.json b/KLHZ.Trader.Infrastructure/graphana/dashboard.json new file mode 100644 index 0000000..40c86d5 --- /dev/null +++ b/KLHZ.Trader.Infrastructure/graphana/dashboard.json @@ -0,0 +1,696 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Торговля сбером", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 1, + "links": [], + "panels": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "bew2oc42looowc" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "points", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineStyle": { + "fill": "solid" + }, + "lineWidth": 1, + "pointSize": 9, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "fieldMinMax": false, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "value" + }, + "properties": [ + { + "id": "custom.drawStyle", + "value": "line" + }, + { + "id": "color", + "value": { + "fixedColor": "light-green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "OpenLong" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "super-light-yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "CloseLong" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "dark-yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "OpenShort" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "super-light-red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "CloseShort" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "dark-red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 13, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "maxDataPoints": 500, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.1.1", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "bew2oc42looowc" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT time, value FROM price_changes WHERE figi ='BBG004730N88' ORDER BY time desc LIMIT 500", + "refId": "A", + "sql": { + "columns": [ + { + "parameters": [ + { + "name": "value", + "type": "functionParameter" + } + ], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "table": "price_changes" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "bew2oc42looowc" + }, + "editorMode": "code", + "format": "table", + "hide": false, + "rawQuery": true, + "rawSql": "SELECT time, price as \"OpenLong\" FROM declisions WHERE figi ='BBG004730N88' AND action =100 ORDER BY time desc LIMIT 500", + "refId": "B", + "sql": { + "columns": [ + { + "parameters": [ + { + "name": "price", + "type": "functionParameter" + } + ], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "table": "declisions" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "bew2oc42looowc" + }, + "editorMode": "code", + "format": "table", + "hide": false, + "rawQuery": true, + "rawSql": "SELECT time, price as \"CloseLong\" FROM declisions WHERE figi ='BBG004730N88' AND action =200 ORDER BY time desc LIMIT 500", + "refId": "C", + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + } + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "bew2oc42looowc" + }, + "editorMode": "code", + "format": "table", + "hide": false, + "rawQuery": true, + "rawSql": "SELECT time, price as \"OpenShort\" FROM declisions WHERE figi ='BBG004730N88' AND action =300 ORDER BY time desc LIMIT 500", + "refId": "D", + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + } + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "bew2oc42looowc" + }, + "editorMode": "code", + "format": "table", + "hide": false, + "rawQuery": true, + "rawSql": "SELECT time, price as \"CloseShort\" FROM declisions WHERE figi ='BBG004730N88' AND action =400 ORDER BY time desc LIMIT 500", + "refId": "E", + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + } + } + ], + "title": "SBER", + "type": "timeseries" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "bew2oc42looowc" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "points", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineStyle": { + "fill": "solid" + }, + "lineWidth": 1, + "pointSize": 9, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "fieldMinMax": false, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "value" + }, + "properties": [ + { + "id": "custom.drawStyle", + "value": "line" + }, + { + "id": "color", + "value": { + "fixedColor": "light-green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "OpenLong" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "super-light-yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "CloseLong" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "dark-yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "OpenShort" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "super-light-red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "CloseShort" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "dark-red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 13, + "w": 24, + "x": 0, + "y": 13 + }, + "id": 2, + "maxDataPoints": 500, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.1.1", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "bew2oc42looowc" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT time, value FROM price_changes WHERE figi ='FUTIMOEXF000' ORDER BY time desc LIMIT 500", + "refId": "A", + "sql": { + "columns": [ + { + "parameters": [ + { + "name": "value", + "type": "functionParameter" + } + ], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "table": "price_changes" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "bew2oc42looowc" + }, + "editorMode": "code", + "format": "table", + "hide": false, + "rawQuery": true, + "rawSql": "SELECT time, price as \"OpenLong\" FROM declisions WHERE figi ='FUTIMOEXF000' AND action =100 ORDER BY time desc LIMIT 500", + "refId": "B", + "sql": { + "columns": [ + { + "parameters": [ + { + "name": "price", + "type": "functionParameter" + } + ], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + }, + "table": "declisions" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "bew2oc42looowc" + }, + "editorMode": "code", + "format": "table", + "hide": false, + "rawQuery": true, + "rawSql": "SELECT time, price as \"CloseLong\" FROM declisions WHERE figi ='FUTIMOEXF000' AND action =200 ORDER BY time desc LIMIT 500", + "refId": "C", + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + } + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "bew2oc42looowc" + }, + "editorMode": "code", + "format": "table", + "hide": false, + "rawQuery": true, + "rawSql": "SELECT time, price as \"OpenShort\" FROM declisions WHERE figi ='FUTIMOEXF000' AND action =300 ORDER BY time desc LIMIT 500", + "refId": "D", + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + } + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "bew2oc42looowc" + }, + "editorMode": "code", + "format": "table", + "hide": false, + "rawQuery": true, + "rawSql": "SELECT time, price as \"CloseShort\" FROM declisions WHERE figi ='FUTIMOEXF000' AND action =400 ORDER BY time desc LIMIT 500", + "refId": "E", + "sql": { + "columns": [ + { + "parameters": [], + "type": "function" + } + ], + "groupBy": [ + { + "property": { + "type": "string" + }, + "type": "groupBy" + } + ], + "limit": 50 + } + } + ], + "title": "IMOEXF", + "type": "timeseries" + } + ], + "preload": false, + "refresh": "5s", + "schemaVersion": 41, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-30m", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "SBER", + "uid": "25359e73-1645-466b-9e1e-b9dbc4742a41", + "version": 12 +} \ No newline at end of file diff --git a/KLHZ.Trader.Infrastructure/loki/loki-config.yaml b/KLHZ.Trader.Infrastructure/loki/loki-config.yaml new file mode 100644 index 0000000..28f16d7 --- /dev/null +++ b/KLHZ.Trader.Infrastructure/loki/loki-config.yaml @@ -0,0 +1,63 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 9096 + log_level: info + grpc_server_max_concurrent_streams: 1000 + +common: + instance_addr: 127.0.0.1 + path_prefix: /tmp/loki + storage: + filesystem: + chunks_directory: /tmp/loki/chunks + rules_directory: /tmp/loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +query_range: + results_cache: + cache: + embedded_cache: + enabled: true + max_size_mb: 100 + +limits_config: + metric_aggregation_enabled: true + allow_structured_metadata: true + volume_enabled: true + retention_period: 24h # 24h + +schema_config: + configs: + - from: 2020-10-24 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +pattern_ingester: + enabled: true + metric_aggregation: + loki_address: localhost:3100 + +ruler: + enable_alertmanager_discovery: true + enable_api: true + + + + +frontend: + encoding: protobuf + + +compactor: + working_directory: /tmp/loki/retention + delete_request_store: filesystem + retention_enabled: true \ No newline at end of file diff --git a/KLHZ.Trader.Infrastructure/postgres/init.sql b/KLHZ.Trader.Infrastructure/postgres/init.sql new file mode 100644 index 0000000..b72d449 --- /dev/null +++ b/KLHZ.Trader.Infrastructure/postgres/init.sql @@ -0,0 +1,49 @@ +drop table if exists trades; +create table trades +( + trade_id bigserial, + account_id text not null, + bought_at timestamp default current_timestamp, + figi text not null, + ticker text not null, + price decimal not null, + count decimal not null, + count_lots decimal not null, + archive_status int not null default 0, + direction int not null default 1, + position_type int not null default 1, + asset_type int not null default 1, + primary key (trade_id,archive_status) +) partition by list (archive_status); + +create table assets_active partition of trades for values in (0); +create table assets_archive partition of trades for values in (1); + +CREATE INDEX assets_account_id_index ON trades USING btree(account_id); +CREATE INDEX assets_figi_index ON trades USING btree(figi); +drop table if exists price_changes; +create table price_changes +( + id bigserial, + time timestamp default current_timestamp, + figi text not null, + ticker text not null, + value decimal not null, + primary key (id) +); + +CREATE INDEX price_changes_figi_index ON price_changes USING btree(figi, time); +drop table if exists declisions; +create table declisions +( + id bigserial, + time timestamp default current_timestamp, + figi text not null, + price decimal not null, + ticker text not null, + account_id text not null, + action int not null default 0, + primary key (id) +); + +CREATE INDEX declisions_index ON declisions USING btree(figi, time); diff --git a/KLHZ.Trader.Infrastructure/prometheus/prometheus.yml b/KLHZ.Trader.Infrastructure/prometheus/prometheus.yml new file mode 100644 index 0000000..93068f3 --- /dev/null +++ b/KLHZ.Trader.Infrastructure/prometheus/prometheus.yml @@ -0,0 +1,7 @@ +scrape_configs: + - job_name: 'trader' + metrics_path: /metrics + scrape_interval: 5s + static_configs: + - targets: ['klhz_trader:8080','gateway.docker.internal:9100'] + \ No newline at end of file diff --git a/KLHZ.Trader.Service/Controllers/PlayController.cs b/KLHZ.Trader.Service/Controllers/PlayController.cs new file mode 100644 index 0000000..a9bad06 --- /dev/null +++ b/KLHZ.Trader.Service/Controllers/PlayController.cs @@ -0,0 +1,52 @@ +using Google.Protobuf.WellKnownTypes; +using KLHZ.Trader.Core.Common.Messaging.Contracts; +using KLHZ.Trader.Core.Common.Messaging.Contracts.Messages; +using KLHZ.Trader.Core.DataLayer; +using KLHZ.Trader.Core.DataLayer.Entities.Prices; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Internal; +using Tinkoff.InvestApi; +using Tinkoff.InvestApi.V1; +using Candle = KLHZ.Trader.Core.DataLayer.Entities.Prices.Candle; + +namespace KLHZ.Trader.Service.Controllers +{ + [ApiController] + [Route("[controller]/[action]")] + public class PlayController : ControllerBase + { + private readonly IDataBus _dataBus; + private readonly IDbContextFactory _dbContextFactory; + + public PlayController(IDataBus dataBus, IDbContextFactory dbContextFactory) + { + _dbContextFactory = dbContextFactory; + _dataBus = dataBus; + } + + [HttpGet] + public async Task Run(string figi) + { + using var context1 = await _dbContextFactory.CreateDbContextAsync(); + context1.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + var data = await context1.Candles + .Where(c => c.Figi == figi) + .OrderBy(c=>c.Time) + .Select(c => new NewPriceMessage() + { + Figi = figi, + Ticker = c.Ticker, + Time = c.Time, + Value = c.Close, + IsHistoricalData = true + }) + .ToArrayAsync(); + + foreach (var mess in data) + { + await _dataBus.BroadcastNewPrice(mess); + } + } + } +} diff --git a/KLHZ.Trader.Service/Dockerfile b/KLHZ.Trader.Service/Dockerfile new file mode 100644 index 0000000..670d058 --- /dev/null +++ b/KLHZ.Trader.Service/Dockerfile @@ -0,0 +1,31 @@ +# См. статью по ссылке https://aka.ms/customizecontainer, чтобы узнать как настроить контейнер отладки и как Visual Studio использует этот Dockerfile для создания образов для ускорения отладки. + +# Этот этап используется при запуске из VS в быстром режиме (по умолчанию для конфигурации отладки) +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 + + +# Этот этап используется для сборки проекта службы +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["KLHZ.Trader.Service/KLHZ.Trader.Service.csproj", "KLHZ.Trader.Service/"] +COPY ["KLHZ.Trader.Core/KLHZ.Trader.Core.csproj", "KLHZ.Trader.Core/"] + +RUN dotnet restore "./KLHZ.Trader.Service/KLHZ.Trader.Service.csproj" +COPY . . +WORKDIR "/src/KLHZ.Trader.Service" +RUN dotnet build "./KLHZ.Trader.Service.csproj" -c $BUILD_CONFIGURATION -o /app/build + +# Этот этап используется для публикации проекта службы, который будет скопирован на последний этап +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./KLHZ.Trader.Service.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +# Этот этап используется в рабочей среде или при запуске из VS в обычном режиме (по умолчанию, когда конфигурация отладки не используется) +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "KLHZ.Trader.Service.dll"] \ No newline at end of file diff --git a/KLHZ.Trader.Service/Infrastructure/IHostBuilderExtensions.cs b/KLHZ.Trader.Service/Infrastructure/IHostBuilderExtensions.cs new file mode 100644 index 0000000..b621746 --- /dev/null +++ b/KLHZ.Trader.Service/Infrastructure/IHostBuilderExtensions.cs @@ -0,0 +1,82 @@ +using Serilog; +using Serilog.Events; +using Serilog.Filters; +using Serilog.Sinks.Grafana.Loki; +using System.Collections.Immutable; +using System.Text.RegularExpressions; + +namespace KLHZ.Trader.Service.Infrastructure +{ + internal static class IHostBuilderExtensions + { + private readonly static Regex urlCheckRegex = new("^http://|https://.+"); + private readonly static ImmutableArray DefaultLabels = new List() + { + "action", + }.ToImmutableArray(); + + /// + /// Добавить логирование в Loki + /// + /// + /// url loki, куда писать логи. + /// Название сервиса, от имени которого пишутся логи. Если передан null - берется название домена. + /// Флаг, включающий дублирование логов в консоль. + /// Минимальный уровень логирования приложения. + /// Минимальный уровень логирования для EF. + /// Удалять из логов записи, сгенерированные EF. + /// Удалять из логов записи, возникшие в результате скреппинга метрик прометеусом.. + /// Набор тегов, который будет извлекаться при отправке в Loki + /// и помечаться как Label для ускорения поиска по логам. + /// + /// + public static IHostBuilder ConfigureSerilog(this IHostBuilder hostBuilder, string? lokiUrl, + string? serviceName = null, + bool writeToConsole = false, + List? additionalFiltratonLabels = null, + LogEventLevel minLevel = LogEventLevel.Information, + LogEventLevel EFMinLogLevel = LogEventLevel.Information, + bool excludeEFLogs = true, + bool excludeMetricsScrapingLogs = true + ) + { + if (string.IsNullOrEmpty(lokiUrl) || !urlCheckRegex.IsMatch(lokiUrl)) throw new ArgumentException("Bad lokiUrl!"); + var labels = new List(); + + if (additionalFiltratonLabels != null && additionalFiltratonLabels.Count > 0) + { + labels.AddRange(additionalFiltratonLabels); + } + labels.AddRange(DefaultLabels); + hostBuilder.UseSerilog((ctx, lc) => + { + if (excludeEFLogs) + lc.Filter.ByExcluding(Matching.WithProperty("SourceContext", "Microsoft.EntityFrameworkCore.Database.Command")); + if (excludeMetricsScrapingLogs) + lc.Filter.ByExcluding(Matching.WithProperty("RequestPath", "/metrics")); + lc.MinimumLevel.Is(minLevel); + lc.MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command", EFMinLogLevel); + lc.WriteTo.GrafanaLoki(lokiUrl, + queueLimit: 1000000, + labels: new List + { + new () + { + Key = "service", + Value = serviceName ?? AppDomain.CurrentDomain.FriendlyName + } + }, + restrictedToMinimumLevel: minLevel, + propertiesAsLabels: labels, + textFormatter: new LokiJsonTextFormatter() + + ); + + if (writeToConsole) + lc.WriteTo.Console(); + }); + + return hostBuilder; + } + } +} \ No newline at end of file diff --git a/KLHZ.Trader.Service/KLHZ.Trader.Service.csproj b/KLHZ.Trader.Service/KLHZ.Trader.Service.csproj new file mode 100644 index 0000000..1ffab53 --- /dev/null +++ b/KLHZ.Trader.Service/KLHZ.Trader.Service.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + enable + enable + Linux + ..\docker-compose.dcproj + + + + + + + + + + + + + + + + diff --git a/KLHZ.Trader.Service/Program.cs b/KLHZ.Trader.Service/Program.cs new file mode 100644 index 0000000..9ac15ab --- /dev/null +++ b/KLHZ.Trader.Service/Program.cs @@ -0,0 +1,66 @@ +using KLHZ.Trader.Core.Common; +using KLHZ.Trader.Core.Common.Messaging.Contracts; +using KLHZ.Trader.Core.Common.Messaging.Services; +using KLHZ.Trader.Core.DataLayer; +using KLHZ.Trader.Core.Declisions.Services; +using KLHZ.Trader.Core.Exchange; +using KLHZ.Trader.Core.Exchange.Services; +using KLHZ.Trader.Core.TG; +using KLHZ.Trader.Core.TG.Services; +using KLHZ.Trader.Service.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Prometheus; +using Serilog; +using Telegram.Bot.Polling; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddControllers(); + + +builder.Services.AddMetrics(); + +builder.Logging.ClearProviders(); +builder.Logging.AddSerilog(); +builder.Host.ConfigureSerilog( + builder.Configuration.GetSection("LokiUrl").Value, + serviceName: "klhz.trader", + excludeEFLogs: false, + excludeMetricsScrapingLogs: true, + EFMinLogLevel: Serilog.Events.LogEventLevel.Warning + ); + +builder.Services.AddInvestApiClient((_, settings) => +{ + settings.AccessToken = builder.Configuration.GetSection(nameof(ExchangeConfig))["Token"]; +}); + +builder.Services.AddDbContextFactory(options => +{ + options.UseNpgsql(builder.Configuration.GetConnectionString("PostgresConnection")); +}); + +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +for (int i = 0; i < 10; i++) +{ + builder.Services.AddKeyedSingleton(i); +} + +builder.Services.Configure(builder.Configuration.GetSection(nameof(TgBotConfig))); +builder.Services.Configure(builder.Configuration.GetSection(nameof(ExchangeConfig))); + +var app = builder.Build(); +app.UseSwagger(); +app.UseSwaggerUI(); +app.MapMetrics(); +app.MapControllers(); +app.Run(); diff --git a/KLHZ.Trader.Service/appsettings.Development.json b/KLHZ.Trader.Service/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/KLHZ.Trader.Service/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/KLHZ.Trader.Service/appsettings.json b/KLHZ.Trader.Service/appsettings.json new file mode 100644 index 0000000..52789be --- /dev/null +++ b/KLHZ.Trader.Service/appsettings.json @@ -0,0 +1,27 @@ +{ + "TgBotConfig": { + "Token": "", + "Admins": [ 227272610 ] + }, + "ConnectionStrings": { + "PostgresConnection": "" + }, + "LokiUrl": "", + "ExchangeConfig": { + "Token": "", + "ManagingAccountNamePatterns": [ "автотрейд 1" ], + "AllowedInstrumentsFigis": [ "BBG004730N88", "FUTIMOEXF000" ], + "FutureComission": 0.0025, + "ShareComission": 0.0004, + "AccountCashPart": 0.05, + "AccountCashPartFutures": 0.5, + "DefaultBuyPartOfAccount": 0.3333 + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/KLHZ.Trader.sln b/KLHZ.Trader.sln new file mode 100644 index 0000000..6e06ef3 --- /dev/null +++ b/KLHZ.Trader.sln @@ -0,0 +1,77 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36301.6 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KLHZ.Trader.Core", "KLHZ.Trader.Core\KLHZ.Trader.Core.csproj", "{D0978553-9459-48E7-B090-BD553C55E799}" +EndProject +Project("{E53339B2-1760-4266-BCC7-CA923CBCF16C}") = "docker-compose", "docker-compose.dcproj", "{81DDED9D-158B-E303-5F62-77A2896D2A5A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KLHZ.Trader.Core.Tests", "KLHZ.Trader.Core.Tests\KLHZ.Trader.Core.Tests.csproj", "{A8FFAD9B-0CC8-4A44-95FC-7AB3068226DE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KLHZ.Trader.Service", "KLHZ.Trader.Service\KLHZ.Trader.Service.csproj", "{8AB0053E-6F6D-4AC6-A908-E0F404FF69C5}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "infrastructure", "infrastructure", "{36D591C7-65C7-A0D1-1CBC-10CDE441BDC8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "prometheus", "prometheus", "{03E36D37-202B-4F4F-9EF3-09CF10BC1056}" + ProjectSection(SolutionItems) = preProject + KLHZ.Trader.Infrastructure\prometheus\prometheus.yml = KLHZ.Trader.Infrastructure\prometheus\prometheus.yml + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "graphana", "graphana", "{4A2C2DE7-39A9-43D2-AC17-0F0AE2E10276}" + ProjectSection(SolutionItems) = preProject + KLHZ.Trader.Infrastructure\graphana\dashboard.json = KLHZ.Trader.Infrastructure\graphana\dashboard.json + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "postrgres", "postrgres", "{174A800A-6040-40CF-B331-8603E097CBAC}" + ProjectSection(SolutionItems) = preProject + KLHZ.Trader.Infrastructure\postgres\init.sql = KLHZ.Trader.Infrastructure\postgres\init.sql + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "loki", "loki", "{63D21DAF-FDF0-4F2D-A671-E9E59BB0CA5B}" + ProjectSection(SolutionItems) = preProject + KLHZ.Trader.Infrastructure\loki\loki-config.yaml = KLHZ.Trader.Infrastructure\loki\loki-config.yaml + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KLHZ.Trader.HistoryLoader", "KLHZ.Trader.HistoryLoader\KLHZ.Trader.HistoryLoader.csproj", "{9BF1E4ED-CCD5-401B-9F1C-3B7625258F7E}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D0978553-9459-48E7-B090-BD553C55E799}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D0978553-9459-48E7-B090-BD553C55E799}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D0978553-9459-48E7-B090-BD553C55E799}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D0978553-9459-48E7-B090-BD553C55E799}.Release|Any CPU.Build.0 = Release|Any CPU + {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Release|Any CPU.Build.0 = Release|Any CPU + {A8FFAD9B-0CC8-4A44-95FC-7AB3068226DE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A8FFAD9B-0CC8-4A44-95FC-7AB3068226DE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A8FFAD9B-0CC8-4A44-95FC-7AB3068226DE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A8FFAD9B-0CC8-4A44-95FC-7AB3068226DE}.Release|Any CPU.Build.0 = Release|Any CPU + {8AB0053E-6F6D-4AC6-A908-E0F404FF69C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8AB0053E-6F6D-4AC6-A908-E0F404FF69C5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8AB0053E-6F6D-4AC6-A908-E0F404FF69C5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8AB0053E-6F6D-4AC6-A908-E0F404FF69C5}.Release|Any CPU.Build.0 = Release|Any CPU + {9BF1E4ED-CCD5-401B-9F1C-3B7625258F7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9BF1E4ED-CCD5-401B-9F1C-3B7625258F7E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9BF1E4ED-CCD5-401B-9F1C-3B7625258F7E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9BF1E4ED-CCD5-401B-9F1C-3B7625258F7E}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {03E36D37-202B-4F4F-9EF3-09CF10BC1056} = {36D591C7-65C7-A0D1-1CBC-10CDE441BDC8} + {4A2C2DE7-39A9-43D2-AC17-0F0AE2E10276} = {36D591C7-65C7-A0D1-1CBC-10CDE441BDC8} + {174A800A-6040-40CF-B331-8603E097CBAC} = {36D591C7-65C7-A0D1-1CBC-10CDE441BDC8} + {63D21DAF-FDF0-4F2D-A671-E9E59BB0CA5B} = {36D591C7-65C7-A0D1-1CBC-10CDE441BDC8} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {25E56E6E-B6FE-4B25-BDAA-CC88076B23A4} + EndGlobalSection +EndGlobal diff --git a/build-docker-compose.yml b/build-docker-compose.yml new file mode 100644 index 0000000..4c2025e --- /dev/null +++ b/build-docker-compose.yml @@ -0,0 +1,15 @@ +services: + klhz.trader.service: + restart: always + image: klhz_trader_service + hostname: klhz_trader + ports: + - 8080:8080 + build: + context: . + dockerfile: KLHZ.Trader.Service/Dockerfile + environment: + LokiUrl: "loki:3100" + TgBotConfig__Token: "${TG_BOT_TOKEN}" + ExchangeConfig__Token: "${EXCHANGE_API_TOKEN}" + ConnectionStrings__PostgresConnection: "${PG_CONNECTION_STRING}" \ No newline at end of file diff --git a/docker-compose.dcproj b/docker-compose.dcproj new file mode 100644 index 0000000..fb3d976 --- /dev/null +++ b/docker-compose.dcproj @@ -0,0 +1,20 @@ + + + + 2.1 + Linux + False + 81dded9d-158b-e303-5f62-77a2896d2a5a + LaunchBrowser + {Scheme}://localhost:{ServicePort}/swagger + klhz.trader.api + + + + + docker-compose.yml + + + + + \ No newline at end of file diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ee13fb1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,90 @@ +services: + klhz.trader.service: + restart: always + image: klhz_trader_service + hostname: klhz_trader + ports: + - 8080:8080 + build: + context: . + dockerfile: KLHZ.Trader.Service/Dockerfile + environment: + LokiUrl: "http://loki:3100" + TgBotConfig__Token: "${TG_BOT_TOKEN}" + ExchangeConfig__Token: "${EXCHANGE_API_TOKEN}" + ConnectionStrings__PostgresConnection: "${PG_CONNECTION_STRING}" + + postgresql: + ports: + - 15433:5432 + container_name: debug_postgresql_16 + hostname: debug_postgresql_16 + image: postgres:16 + restart: always + command: + - "postgres" + - "-c" + - "max_connections=100" + - "-c" + - "shared_buffers=512MB" + - "-c" + - "temp_buffers=64MB" + - "-c" + - "log_statement=all" + environment: + POSTGRES_PASSWORD: QW12cv9001 + POSTGRES_DB: trading + volumes: + - traderdata:/var/lib/postgresql/data + + prometheus: + image: prom/prometheus + container_name: prometheus + ports: + - 9191:9090 + restart: always + volumes: + - ./KLHZ.Trader.Infrastructure/prometheus/:/etc/prometheus/ + - prom_data:/prometheus + + grafana: + image: grafana/grafana + container_name: grafana + ports: + - 1300:3000 + restart: always + environment: + GF_SECURITY_ADMIN_USER: "${GF_SECURITY_ADMIN_USER}" + GF_SECURITY_ADMIN_PASSWORD: "${GF_SECURITY_ADMIN_PASSWORD}" + volumes: + - graphana:/etc/grafana/provisioning/datasources + + loki: + hostname: loki + image: grafana/loki:latest + container_name: loki + ports: + - "2300:3100" + volumes: + - ./KLHZ.Trader.Infrastructure/loki/loki-config.yaml:/etc/loki/local-config.yaml + - loki_data:/loki + command: -config.file=/etc/loki/local-config.yaml + + klhz.trader.historyloader: + image: klhztraderhistoryloader + ports: + - "5004:8080" + build: + context: . + dockerfile: KLHZ.Trader.HistoryLoader/Dockerfile + environment: + ExchangeConfig__Token: "${EXCHANGE_API_TOKEN}" + ConnectionStrings__PostgresConnection: "${PG_CONNECTION_STRING}" + +volumes: + traderdata: + prom_data: + graphana: + loki_data: + + diff --git a/launchSettings.json b/launchSettings.json new file mode 100644 index 0000000..8372594 --- /dev/null +++ b/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "Docker Compose": { + "commandName": "DockerCompose", + "commandVersion": "1.0", + "launchBrowser": false, + "serviceActions": { + "klhz.trader.service": "StartDebugging", + "klhz.trader.historyloader": "StartDebugging" + } + } + } +} \ No newline at end of file