diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 5bd85c7..91a1d95 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -21,10 +21,10 @@ jobs: name: CI Build steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Setup .NET - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v3 with: dotnet-version: 6.0.x @@ -35,8 +35,8 @@ jobs: run: dotnet build Primary.sln --configuration Release --no-restore - name: Test - run: dotnet test Primary.sln --configuration Release --no-build --verbosity normal --collect:"XPlat Code Coverage" --results-directory ./coverage - + run: dotnet test Primary.sln --configuration Release -- no-build --verbosity normal --collect:"XPlat Code Coverage" --results-directory ./coverage + - name: Copy Coverage To Predictable Location run: cp coverage/**/coverage.cobertura.xml coverage.cobertura.xml diff --git a/.gitignore b/.gitignore index c9df4fe..481acde 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ bld/ /Primary.Tests/bin /Primary.Tests/obj *.user + +coverage/ \ No newline at end of file diff --git a/Primary.Tests/AccountsTests.cs b/Primary.Tests/AccountsTests.cs index ed158b8..0a64435 100644 --- a/Primary.Tests/AccountsTests.cs +++ b/Primary.Tests/AccountsTests.cs @@ -1,4 +1,5 @@ using NUnit.Framework; +using Primary.Data.Orders; using System.Linq; using System.Threading.Tasks; @@ -25,5 +26,56 @@ public async Task AccountStatementCanBeRetrieved() Assert.That(detailedAccountReport.CurrencyBalance, Is.Not.Null.And.Not.Empty); Assert.That(detailedAccountReport.AvailableToOperate.Cash.DetailedCash, Is.Not.Empty); } + + [Test] + public async Task AccountsCanBeRetrieved() + { + var accounts = await Api.GetAccounts(); + Assert.That(accounts, Is.Not.Empty); + + var account = accounts.First(); + Assert.That(account.BrokerId, Is.Not.EqualTo(default)); + Assert.That(account.Id, Is.Not.EqualTo(default)); + Assert.That(account.Name, Is.EqualTo(Api.DemoAccount)); + Assert.That(account.Status, Is.True); + } + + [Test] + [Timeout(20000)] + public async Task PositionsCanBeRetrieved() + { + var marketData = await GetSomeMarketData(); + var symbol = marketData.InstrumentId.Symbol; + + // Take the opposite side. + var order = new Order() + { + InstrumentId = marketData.InstrumentId, + Type = Type.Market, + Side = marketData.Data.Offers?.Length > 0 ? Side.Buy : Side.Sell, + Quantity = AllInstrumentsBySymbol[symbol].MinimumTradeVolume, + }; + + var orderId = await Api.SubmitOrder(Api.DemoAccount, order); + await WaitForOrderToComplete(orderId); + + var positions = await Api.GetAccountPositions(Api.DemoAccount); + Assert.That(positions, Is.Not.Null); + + var position = positions.FirstOrDefault(p => p.Symbol == symbol); + Assert.That(position, Is.Not.EqualTo(default)); + Assert.That(position.Symbol, Is.EqualTo(symbol)); + + if (order.Side == Side.Buy) + { + Assert.That(position.BuySize, Is.GreaterThanOrEqualTo(order.Quantity)); + Assert.That(position.OriginalBuyPrice, Is.Not.EqualTo(0)); + } + else + { + Assert.That(position.SellSize, Is.GreaterThanOrEqualTo(order.Quantity)); + Assert.That(position.OriginalSellPrice, Is.Not.EqualTo(0)); + } + } } -} +} \ No newline at end of file diff --git a/Primary.Tests/ApiTests.cs b/Primary.Tests/ApiTests.cs index b0597ce..2c00342 100644 --- a/Primary.Tests/ApiTests.cs +++ b/Primary.Tests/ApiTests.cs @@ -24,6 +24,8 @@ public async Task AllAvailableInstrumentsCanBeRetrieved() Assert.That(instrument.PriceConversionFactor, Is.Not.EqualTo(default)); Assert.That(instrument.CfiCode, Is.Not.Null.And.Not.Empty); Assert.That(instrument.Type, Is.Not.EqualTo(InstrumentType.Unknown)); + Assert.That(instrument.MinimumTradeVolume, Is.GreaterThanOrEqualTo(0)); + Assert.That(instrument.MaximumTradeVolume, Is.GreaterThanOrEqualTo(0)); } Assert.That(instruments.Where(i => i.MaturityDate != default), Is.Not.Empty); diff --git a/Primary.Tests/TestWithApi.cs b/Primary.Tests/TestWithApi.cs index 8e8a953..ec0d88b 100644 --- a/Primary.Tests/TestWithApi.cs +++ b/Primary.Tests/TestWithApi.cs @@ -1,11 +1,71 @@ -using System; +using Primary.Data; +using Primary.Data.Orders; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; namespace Primary.Tests { internal class TestWithApi { - protected Api Api { get { return _lazyApi.Value; } } + protected Api Api => _lazyApi.Value; - private static readonly Lazy _lazyApi = new Lazy(() => Build.AnApi().Result); + private static readonly Lazy _lazyApi = new(() => Build.AnApi().Result); + + protected async Task GetSomeMarketData() + { + if (AllInstrumentsBySymbol == null) + { + AllInstrumentsBySymbol = (await Api.GetAllInstruments()).ToDictionary(i => i.Symbol, i => i); + } + + using var socket = Api.CreateMarketDataSocket(AllInstrumentsBySymbol.Values, new[] { Entry.Offers, Entry.Bids }, 1, 1); + + MarketData retrievedData = null; + var dataSemaphore = new SemaphoreSlim(0, 1); + socket.OnData = ((_, marketData) => + { + var instrument = AllInstrumentsBySymbol[marketData.InstrumentId.Symbol]; + + if (instrument.Type == InstrumentType.Equity && + ( + (marketData.Data.Offers != null && + marketData.Data.Offers[0].Size > instrument.MinimumTradeVolume * 2) + || + (marketData.Data.Bids != null && + marketData.Data.Bids[0].Size > instrument.MinimumTradeVolume * 2) + ) + ) + { + retrievedData = marketData; + dataSemaphore.Release(); + } + }); + + await socket.Start(); + await dataSemaphore.WaitAsync(); + + return retrievedData; + } + + protected async Task WaitForOrderToComplete(OrderId orderId) + { + var orderStatus = await Api.GetOrderStatus(orderId); + while (orderStatus.Status != Status.Rejected && orderStatus.Status != Status.Filled) + { + Thread.Sleep(200); + orderStatus = await Api.GetOrderStatus(orderId); + } + + if (orderStatus.Status == Status.Rejected) + { + throw new InvalidOperationException($"{orderStatus.StatusText} / {orderStatus.InstrumentId.Symbol} / {orderStatus.Side}"); + } + return orderStatus; + } + + protected Dictionary AllInstrumentsBySymbol; } } diff --git a/Primary.sln b/Primary.sln index 10dcaed..c846507 100644 --- a/Primary.sln +++ b/Primary.sln @@ -6,6 +6,7 @@ MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{3559615F-B6AA-46C5-882E-0DC3A0BB950B}" ProjectSection(SolutionItems) = preProject .gitignore = .gitignore + global,json.txt = global,json.txt README.md = README.md EndProjectSection EndProject diff --git a/Primary/Api.cs b/Primary/Api.cs index ed2316b..8a804ed 100644 --- a/Primary/Api.cs +++ b/Primary/Api.cs @@ -425,6 +425,35 @@ private struct GetOrderResponse #region Accounts + public async Task> GetAccounts() + { + var builder = new UriBuilder(BaseUri + "rest/accounts"); + + var jsonResponse = await HttpClient.GetStringAsync(builder.Uri); + + var response = JsonConvert.DeserializeObject(jsonResponse); + if (response.Status == Status.Error) + { + throw new Exception($"{response.Message} ({response.Description})"); + } + return response.Accounts; + } + + public class AccountsResponse + { + [JsonProperty("status")] + public string Status; + + [JsonProperty("message")] + public string Message { get; set; } + + [JsonProperty("description")] + public string Description { get; set; } + + [JsonProperty("accounts")] + public List Accounts { get; set; } + } + public async Task GetAccountStatement(string accountId) { var uri = new Uri(BaseUri, "/rest/risk/accountReport/" + accountId); @@ -457,6 +486,38 @@ private struct GetAccountStatementResponse #endregion + #region Positions + + public async Task> GetAccountPositions(string accountName) + { + var builder = new UriBuilder(BaseUri + $"rest/risk/position/getPositions/{accountName}"); + var jsonResponse = await HttpClient.GetStringAsync(builder.Uri); + + var response = JsonConvert.DeserializeObject(jsonResponse); + if (response.Status == Status.Error) + { + throw new Exception($"{response.Message} ({response.Description})"); + } + return response.Positions; + } + + public class PositionsResponse + { + [JsonProperty("status")] + public string Status; + + [JsonProperty("message")] + public string Message; + + [JsonProperty("description")] + public string Description; + + [JsonProperty("positions")] + public List Positions { get; set; } + } + + #endregion + #region Constants private static class Status diff --git a/Primary/Data/Account.cs b/Primary/Data/Account.cs new file mode 100644 index 0000000..23b0e53 --- /dev/null +++ b/Primary/Data/Account.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Primary.Data; + +public class Account +{ + [JsonProperty("id")] + public int Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("brokerId")] + public int BrokerId { get; set; } + + [JsonProperty("status")] + public bool Status { get; set; } +} diff --git a/Primary/Data/Instrument.cs b/Primary/Data/Instrument.cs index 2df1f8d..704431d 100644 --- a/Primary/Data/Instrument.cs +++ b/Primary/Data/Instrument.cs @@ -21,10 +21,10 @@ public class InstrumentId #region JSON serialization [JsonProperty("instrumentId.marketId")] - protected string NestedMarket { get { return Market; } set { Market = value; } } + protected string NestedMarket { get => Market; set => Market = value; } [JsonProperty("instrumentId.symbol")] - protected string NestedSymbol { get { return Symbol; } set { Symbol = value; } } + protected string NestedSymbol { get => Symbol; set => Symbol = value; } /// This is used for serialization purposes and should not be called. public bool ShouldSerializeNestedMarket() { return false; } @@ -61,31 +61,33 @@ public class Instrument : InstrumentId public string CfiCode { get; set; } /// Instrument type from CFI code. - public InstrumentType Type + public InstrumentType Type => CfiCode switch { - get - { - return CfiCode switch - { - "ESXXXX" => InstrumentType.Equity, - "DBXXXX" => InstrumentType.Bond, - "OCASPS" => InstrumentType.EquityCallOption, - "OPASPS" => InstrumentType.EquityPutOption, - "FXXXSX" => InstrumentType.Future, - "OPAFXS" => InstrumentType.FuturePutOption, - "OCAFXS" => InstrumentType.FutureCallOption, - "EMXXXX" => InstrumentType.Cedear, - "DBXXFR" => InstrumentType.Obligation, - "MRIXXX" => InstrumentType.Index, - "FXXXXX" => InstrumentType.Future, - "RPXXXX" => InstrumentType.Caucion, - "MXXXXX" => InstrumentType.Miscellaneous, - "LRSTXH" => InstrumentType.Miscellaneous, - "DYXTXR" => InstrumentType.TreasureNotes, - _ => InstrumentType.Unknown - }; - } - } + "ESXXXX" => InstrumentType.Equity, + "DBXXXX" => InstrumentType.Bond, + "OCASPS" => InstrumentType.EquityCallOption, + "OPASPS" => InstrumentType.EquityPutOption, + "FXXXSX" => InstrumentType.Future, + "OPAFXS" => InstrumentType.FuturePutOption, + "OCAFXS" => InstrumentType.FutureCallOption, + "EMXXXX" => InstrumentType.Cedear, + "DBXXFR" => InstrumentType.Obligation, + "MRIXXX" => InstrumentType.Index, + "FXXXXX" => InstrumentType.Future, + "RPXXXX" => InstrumentType.Caucion, + "MXXXXX" => InstrumentType.Miscellaneous, + "LRSTXH" => InstrumentType.Miscellaneous, + "DYXTXR" => InstrumentType.TreasureNotes, + _ => InstrumentType.Unknown + }; + + /// Minimum volume that can be traded for this instrument. + [JsonProperty("minTradeVol")] + public uint MinimumTradeVolume { get; set; } + + /// Maximum volume that can be traded for this instrument. + [JsonProperty("maxTradeVol")] + public uint MaximumTradeVolume { get; set; } } public enum InstrumentType diff --git a/Primary/Data/Orders/Enums.cs b/Primary/Data/Orders/Enums.cs index af1176c..379673f 100644 --- a/Primary/Data/Orders/Enums.cs +++ b/Primary/Data/Orders/Enums.cs @@ -3,6 +3,14 @@ namespace Primary.Data.Orders { + [JsonConverter(typeof(SettlementTypeJsonSerializer))] + public enum SettlementType + { + CI, + T24H, + T48H + } + [JsonConverter(typeof(TypeJsonSerializer))] public enum Type { @@ -65,6 +73,32 @@ public enum Status internal static class EnumsToApiStrings { + #region SettlementType + + public static string ToApiString(this SettlementType value) + { + return value switch + { + SettlementType.CI => "0", + SettlementType.T24H => "1", + SettlementType.T48H => "2", + _ => throw new InvalidEnumStringException(value.ToString()), + }; + } + + public static SettlementType SettlementTypeFromApiString(string value) + { + return (value.ToUpper()) switch + { + "0" => SettlementType.CI, + "1" => SettlementType.T24H, + "2" => SettlementType.T48H, + _ => throw new InvalidEnumStringException(value), + }; + } + + #endregion + #region Type public static string ToApiString(this Type value) @@ -182,6 +216,19 @@ public static Status StatusFromApiString(string value) #region JSON serialization + internal class SettlementTypeJsonSerializer : EnumJsonSerializer + { + protected override string ToString(SettlementType enumValue) + { + return enumValue.ToApiString(); + } + + protected override SettlementType FromString(string enumString) + { + return EnumsToApiStrings.SettlementTypeFromApiString(enumString); + } + } + internal class TypeJsonSerializer : EnumJsonSerializer { protected override string ToString(Type enumValue) diff --git a/Primary/Data/Position.cs b/Primary/Data/Position.cs new file mode 100644 index 0000000..d2f354a --- /dev/null +++ b/Primary/Data/Position.cs @@ -0,0 +1,50 @@ +using Newtonsoft.Json; +using Primary.Data.Orders; + +namespace Primary.Data; + +public class PositionInstrument + +{ + [JsonProperty("symbolReference")] + public string SymbolReference { get; set; } + + [JsonProperty("settlType")] + public SettlementType SettlementType { get; set; } +} + +public class Position +{ + [JsonProperty("instrument")] + public PositionInstrument Instrument { get; set; } + + [JsonProperty("symbol")] + public string Symbol { get; set; } + + [JsonProperty("buySize")] + public decimal BuySize { get; set; } + + [JsonProperty("buyPrice")] + public decimal BuyPrice { get; set; } + + [JsonProperty("sellSize")] + public decimal SellSize { get; set; } + + [JsonProperty("sellPrice")] + public decimal SellPrice { get; set; } + + [JsonProperty("totalDailyDiff")] + public decimal TotalDailyDiff { get; set; } + + [JsonProperty("totalDiff")] + public decimal TotalDiff { get; set; } + + [JsonProperty("tradingSymbol")] + public string TradingSymbol { get; set; } + + [JsonProperty("originalBuyPrice")] + public decimal OriginalBuyPrice { get; set; } + + [JsonProperty("originalSellPrice")] + public decimal OriginalSellPrice { get; set; } +} diff --git a/Primary/Primary.csproj b/Primary/Primary.csproj index 3ba7add..b058568 100644 --- a/Primary/Primary.csproj +++ b/Primary/Primary.csproj @@ -20,10 +20,6 @@ 0.10.0 - - - - diff --git a/README.md b/README.md index b8b7114..6a53c4f 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,9 @@ Documentation: https://finanzascodificadas.com/Primary.Net/ - Real-time market data. - Real-time order data. - Submit, update and cancel orders. +- Accounts: available accounts, positions and statements. # Roadmap -- Account information. - Remove dependencies. - Performance improvements.