first commit

main
vlad zverzhkhovskiy 2025-08-27 09:20:37 +03:00
commit 20920c6832
65 changed files with 4106 additions and 0 deletions

30
.dockerignore Normal file
View File

@ -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/**

279
.gitignore vendored Normal file
View File

@ -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

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit.Analyzers" Version="3.9.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\KLHZ.Trader.Core\KLHZ.Trader.Core.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="NUnit.Framework" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,4 @@
using System.Runtime.CompilerServices;
//Тесты
[assembly: InternalsVisibleTo("KLHZ.Trader.Core.Tests")]

View File

@ -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;
}
}
}

View File

@ -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 = "Включить покупки";
}
}
}

View File

@ -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<INewPriceMessage> channel);
public bool AddChannel(string key, Channel<TradeCommand> channel);
public bool AddChannel(string key, Channel<INewCandle> channel);
public bool AddChannel(Channel<MessageForAdmin> channel);
public Task BroadcastNewPrice(INewPriceMessage newPriceMessage);
public Task BroadcastCommand(TradeCommand command);
public Task BroadcastCommand(MessageForAdmin command);
public Task BroadcastNewCandle(INewCandle command);
}
}

View File

@ -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,
}
}

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -0,0 +1,7 @@
namespace KLHZ.Trader.Core.Common.Messaging.Contracts.Messages
{
public class MessageForAdmin
{
public required string Text { get; set; }
}
}

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -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<string, Channel<INewCandle>> _candlesChannels = new();
private readonly ConcurrentDictionary<string, Channel<INewPriceMessage>> _priceChannels = new();
private readonly ConcurrentDictionary<string, Channel<TradeCommand>> _commandChannels = new();
private readonly ConcurrentDictionary<string, Channel<MessageForAdmin>> _chatMessages = new();
public bool AddChannel(Channel<MessageForAdmin> channel)
{
return _chatMessages.TryAdd(Guid.NewGuid().ToString(), channel);
}
public bool AddChannel(string key, Channel<INewPriceMessage> channel)
{
return _priceChannels.TryAdd(key, channel);
}
public bool AddChannel(string key, Channel<INewCandle> channel)
{
return _candlesChannels.TryAdd(key, channel);
}
public bool AddChannel(string key, Channel<TradeCommand> 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);
}
}
}
}

View File

@ -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; }
}
}

View File

@ -0,0 +1,11 @@
namespace KLHZ.Trader.Core.DataLayer.Entities.Declisions
{
public enum DeclisionTradeAction
{
Unknown = 0,
OpenLong = 100,
CloseLong = 200,
OpenShort = 300,
CloseShort = 400,
}
}

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -0,0 +1,9 @@
namespace KLHZ.Trader.Core.DataLayer.Entities.Trades
{
public enum AssetType
{
Unknown = 0,
Common = 1,
Future = 2
}
}

View File

@ -0,0 +1,9 @@
namespace KLHZ.Trader.Core.DataLayer.Entities.Trades
{
public enum PositionType
{
Unknown = 0,
Long = 1,
Short = 2
}
}

View File

@ -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; }
}
}

View File

@ -0,0 +1,9 @@
namespace KLHZ.Trader.Core.DataLayer.Entities.Trades
{
public enum TradeDirection
{
Unknown = 0,
Income = 1,
Outcome = 2
}
}

View File

@ -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<Trade> Trades { get; set; }
public DbSet<Declision> Declisions { get; set; }
public DbSet<PriceChange> PriceChanges { get; set; }
public DbSet<Candle> Candles { get; set; }
public TraderDbContext(DbContextOptions<TraderDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.UseSerialColumns();
modelBuilder.Entity<Trade>(entity =>
{
entity.HasKey(e1 => e1.Id);
entity.Property(e => e.BoughtAt)
.HasConversion(
v => v.ToUniversalTime(),
v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
});
modelBuilder.Entity<Declision>(entity =>
{
entity.HasKey(e1 => e1.Id);
entity.Property(e => e.Time)
.HasConversion(
v => v.ToUniversalTime(),
v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
});
modelBuilder.Entity<PriceChange>(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<Candle>(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));
});
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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<TraderDbContext> _dbContextFactory;
private readonly ConcurrentDictionary<string, ManagedAccount> Accounts = new();
private readonly ConcurrentDictionary<string, PriceHistoryCacheUnit> _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<INewPriceMessage> _pricesChannel = Channel.CreateUnbounded<INewPriceMessage>();
public Trader(
BotModeSwitcher botModeSwitcher,
IServiceProvider provider,
IOptions<ExchangeConfig> options,
IDataBus dataBus,
IDbContextFactory<TraderDbContext> 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<ManagedAccount>();
int i = 0;
foreach (var accountId in accounts)
{
var acc = _provider.GetKeyedService<ManagedAccount>(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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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; } = [];
}
}

View File

@ -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<string[]> GetAccounts(this InvestApiClient client, params string[] managedAccountNamePatterns)
{
var accounts = await client.Users.GetAccountsAsync();
var accsIds = new HashSet<string>();
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();
}
}
}

View File

@ -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;
}
}
}
}

View File

@ -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; }
}
}

View File

@ -0,0 +1,10 @@
namespace KLHZ.Trader.Core.Exchange.Models
{
public enum AssetType
{
Unknown = 0,
Currency = 1,
Common = 2,
Futures = 3,
}
}

View File

@ -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; }
}
}

View File

@ -0,0 +1,9 @@
namespace KLHZ.Trader.Core.Exchange.Models
{
public enum PositionType
{
Unknown = 0,
Long = 1,
Short = 2
}
}

View File

@ -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<ExchangeDataReader> _logger;
private readonly ConcurrentDictionary<string, string> _tickersCache = new();
private readonly IDbContextFactory<TraderDbContext> _dbContextFactory;
private readonly CancellationTokenSource _cts = new();
private readonly IDataBus _eventBus;
public ExchangeDataReader(InvestApiClient investApiClient, IDataBus eventBus,
IOptions<ExchangeConfig> options, IDbContextFactory<TraderDbContext> dbContextFactory,
ILogger<ExchangeDataReader> 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;
}
}
}

View File

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

View File

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.8" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.8" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Telegram.Bot" Version="22.6.0" />
<PackageReference Include="Tinkoff.InvestApi" Version="0.6.17" />
</ItemGroup>
</Project>

View File

@ -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<long> _admins = [];
private readonly BotModeSwitcher _botModeSwitcher;
private readonly IDataBus _eventBus;
private readonly ILogger<BotMessagesHandler> _logger;
public BotMessagesHandler(BotModeSwitcher botModeSwitcher, IDataBus eventBus, IOptions<TgBotConfig> options, ILogger<BotMessagesHandler> 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, "Ошибка при обработке сообщения из телеграма.");
}
}
}
}

View File

@ -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<MessageForAdmin> _messages = Channel.CreateUnbounded<MessageForAdmin>();
private readonly ImmutableArray<long> _admins = [];
public BotStarter(IOptions<TgBotConfig> cfg, IUpdateHandler updateHandler, IDataBus dataBus, IOptions<TgBotConfig> 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();
}
}
}

View File

@ -0,0 +1,9 @@
namespace KLHZ.Trader.Core.TG
{
public class TgBotConfig
{
public required string Token { get; set; }
public required long[] Admins = [];
}
}

View File

@ -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<TraderDbContext> _dbContextFactory;
public LoaderController(InvestApiClient client, IDbContextFactory<TraderDbContext> 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<Candle>();
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)
{
}
}
}
}
}

View File

@ -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"]

View File

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerComposeProjectPath>..\docker-compose.dcproj</DockerComposeProjectPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\KLHZ.Trader.Core\KLHZ.Trader.Core.csproj" />
</ItemGroup>
</Project>

View File

@ -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<TraderDbContext>(options =>
{
options.UseNpgsql(builder.Configuration.GetConnectionString("PostgresConnection"));
});
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
app.MapControllers();
app.Run();

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@ -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
}

View File

@ -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

View File

@ -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);

View File

@ -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']

View File

@ -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<TraderDbContext> _dbContextFactory;
public PlayController(IDataBus dataBus, IDbContextFactory<TraderDbContext> 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);
}
}
}
}

View File

@ -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"]

View File

@ -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<string> DefaultLabels = new List<string>()
{
"action",
}.ToImmutableArray();
/// <summary>
/// Добавить логирование в Loki
/// </summary>
/// <param name="hostBuilder"></param>
/// <param name="lokiUrl">url loki, куда писать логи.</param>
/// <param name="serviceName">Название сервиса, от имени которого пишутся логи. Если передан null - берется название домена. </param>
/// <param name="writeToConsole">Флаг, включающий дублирование логов в консоль.</param>
/// <param name="minLevel">Минимальный уровень логирования приложения.</param>
/// <param name="EFMinLogLevel">Минимальный уровень логирования для EF.</param>
/// <param name="excludeEFLogs">Удалять из логов записи, сгенерированные EF.</param>
/// <param name="excludeMetricsScrapingLogs">Удалять из логов записи, возникшие в результате скреппинга метрик прометеусом..</param>
/// <param name="additionalFiltratonLabels">Набор тегов, который будет извлекаться при отправке в Loki
/// и помечаться как Label для ускорения поиска по логам.</param>
/// <returns></returns>
/// <exception cref="ArgumentException"></exception>
public static IHostBuilder ConfigureSerilog(this IHostBuilder hostBuilder, string? lokiUrl,
string? serviceName = null,
bool writeToConsole = false,
List<string>? 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<string>();
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<LokiLabel>
{
new ()
{
Key = "service",
Value = serviceName ?? AppDomain.CurrentDomain.FriendlyName
}
},
restrictedToMinimumLevel: minLevel,
propertiesAsLabels: labels,
textFormatter: new LokiJsonTextFormatter()
);
if (writeToConsole)
lc.WriteTo.Console();
});
return hostBuilder;
}
}
}

View File

@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerComposeProjectPath>..\docker-compose.dcproj</DockerComposeProjectPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.Grafana.Loki" Version="8.3.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\KLHZ.Trader.Core\KLHZ.Trader.Core.csproj" />
</ItemGroup>
</Project>

View File

@ -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<TraderDbContext>(options =>
{
options.UseNpgsql(builder.Configuration.GetConnectionString("PostgresConnection"));
});
builder.Services.AddHostedService<BotStarter>();
builder.Services.AddHostedService<ExchangeDataReader>();
builder.Services.AddHostedService<Trader>();
builder.Services.AddSingleton<IUpdateHandler, BotMessagesHandler>();
builder.Services.AddSingleton<BotModeSwitcher>();
builder.Services.AddSingleton<IDataBus, DataBus>();
for (int i = 0; i < 10; i++)
{
builder.Services.AddKeyedSingleton<ManagedAccount>(i);
}
builder.Services.Configure<TgBotConfig>(builder.Configuration.GetSection(nameof(TgBotConfig)));
builder.Services.Configure<ExchangeConfig>(builder.Configuration.GetSection(nameof(ExchangeConfig)));
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
app.MapMetrics();
app.MapControllers();
app.Run();

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -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": "*"
}

77
KLHZ.Trader.sln Normal file
View File

@ -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

15
build-docker-compose.yml Normal file
View File

@ -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}"

20
docker-compose.dcproj Normal file
View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" Sdk="Microsoft.Docker.Sdk">
<PropertyGroup Label="Globals">
<ProjectVersion>2.1</ProjectVersion>
<DockerTargetOS>Linux</DockerTargetOS>
<DockerPublishLocally>False</DockerPublishLocally>
<ProjectGuid>81dded9d-158b-e303-5f62-77a2896d2a5a</ProjectGuid>
<DockerLaunchAction>LaunchBrowser</DockerLaunchAction>
<DockerServiceUrl>{Scheme}://localhost:{ServicePort}/swagger</DockerServiceUrl>
<DockerServiceName>klhz.trader.api</DockerServiceName>
</PropertyGroup>
<ItemGroup>
<None Include=".env" />
<None Include="docker-compose.override.yml">
<DependentUpon>docker-compose.yml</DependentUpon>
</None>
<None Include="docker-compose.yml" />
<None Include=".dockerignore" />
</ItemGroup>
</Project>

View File

90
docker-compose.yml Normal file
View File

@ -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:

13
launchSettings.json Normal file
View File

@ -0,0 +1,13 @@
{
"profiles": {
"Docker Compose": {
"commandName": "DockerCompose",
"commandVersion": "1.0",
"launchBrowser": false,
"serviceActions": {
"klhz.trader.service": "StartDebugging",
"klhz.trader.historyloader": "StartDebugging"
}
}
}
}