From a7e1bfee3b9a76b877653e30a64279263e6e5d48 Mon Sep 17 00:00:00 2001 From: AIVensk <194205757+AIVensk@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:35:43 -0400 Subject: [PATCH] feat: plan portfolio allocations with Deep --- Bot.sln | 6 + csharp/Bot.sln | 6 + .../TraderBot.Tests/PortfolioBalanceTests.cs | 202 ++++++++++++++++++ .../TraderBot.Tests/PortfolioEdgeCaseTests.cs | 38 ++++ csharp/TraderBot.Tests/TraderBot.Tests.csproj | 17 ++ csharp/TraderBot/PORTFOLIO_BALANCE.md | 98 +++++++++ .../TraderBot/PortfolioAllocationStorage.cs | 71 ++++++ csharp/TraderBot/PortfolioBalanceAlgorithm.cs | 73 +++++++ csharp/TraderBot/PortfolioBalanceCommand.cs | 38 ++++ csharp/TraderBot/Program.cs | 6 + csharp/TraderBot/README.md | 6 + .../TraderBot/portfolio-balance.example.json | 5 + 12 files changed, 566 insertions(+) create mode 100644 csharp/TraderBot.Tests/PortfolioBalanceTests.cs create mode 100644 csharp/TraderBot.Tests/PortfolioEdgeCaseTests.cs create mode 100644 csharp/TraderBot.Tests/TraderBot.Tests.csproj create mode 100644 csharp/TraderBot/PORTFOLIO_BALANCE.md create mode 100644 csharp/TraderBot/PortfolioAllocationStorage.cs create mode 100644 csharp/TraderBot/PortfolioBalanceAlgorithm.cs create mode 100644 csharp/TraderBot/PortfolioBalanceCommand.cs create mode 100644 csharp/TraderBot/portfolio-balance.example.json diff --git a/Bot.sln b/Bot.sln index 47ddcb13..4df53b18 100644 --- a/Bot.sln +++ b/Bot.sln @@ -10,6 +10,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Storage", "csharp\Storage\S EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TraderBot", "csharp\TraderBot\TraderBot.csproj", "{FAE89FE2-17C5-4AD6-98EC-84002CC4C672}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TraderBot.Tests", "csharp\TraderBot.Tests\TraderBot.Tests.csproj", "{773551FE-5A86-4D2A-BF41-48406F8043D0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -36,5 +38,9 @@ Global {FAE89FE2-17C5-4AD6-98EC-84002CC4C672}.Debug|Any CPU.Build.0 = Debug|Any CPU {FAE89FE2-17C5-4AD6-98EC-84002CC4C672}.Release|Any CPU.ActiveCfg = Release|Any CPU {FAE89FE2-17C5-4AD6-98EC-84002CC4C672}.Release|Any CPU.Build.0 = Release|Any CPU + {773551FE-5A86-4D2A-BF41-48406F8043D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {773551FE-5A86-4D2A-BF41-48406F8043D0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {773551FE-5A86-4D2A-BF41-48406F8043D0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {773551FE-5A86-4D2A-BF41-48406F8043D0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/csharp/Bot.sln b/csharp/Bot.sln index 612998b2..045532db 100755 --- a/csharp/Bot.sln +++ b/csharp/Bot.sln @@ -10,6 +10,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Storage", "Storage\Storage. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TraderBot", "TraderBot\TraderBot.csproj", "{FAE89FE2-17C5-4AD6-98EC-84002CC4C672}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TraderBot.Tests", "TraderBot.Tests\TraderBot.Tests.csproj", "{351A4B17-752D-49BC-9B40-4E5469D799A7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -36,5 +38,9 @@ Global {FAE89FE2-17C5-4AD6-98EC-84002CC4C672}.Debug|Any CPU.Build.0 = Debug|Any CPU {FAE89FE2-17C5-4AD6-98EC-84002CC4C672}.Release|Any CPU.ActiveCfg = Release|Any CPU {FAE89FE2-17C5-4AD6-98EC-84002CC4C672}.Release|Any CPU.Build.0 = Release|Any CPU + {351A4B17-752D-49BC-9B40-4E5469D799A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {351A4B17-752D-49BC-9B40-4E5469D799A7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {351A4B17-752D-49BC-9B40-4E5469D799A7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {351A4B17-752D-49BC-9B40-4E5469D799A7}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/csharp/TraderBot.Tests/PortfolioBalanceTests.cs b/csharp/TraderBot.Tests/PortfolioBalanceTests.cs new file mode 100644 index 00000000..075f4a91 --- /dev/null +++ b/csharp/TraderBot.Tests/PortfolioBalanceTests.cs @@ -0,0 +1,202 @@ +using Platform.Data.Doublets; +using TraderBot; +using Xunit; + +public class PortfolioBalanceTests +{ + private static Dictionary Targets() => new() + { ["Gold"] = 25m, ["USD"] = 25m, ["TCS Group stocks"] = 50m }; + private static Dictionary Values() => new() + { ["Gold"] = 10000m, ["USD"] = 30000m, ["TCS Group stocks"] = 60000m }; + + [Fact] + public void CalculatesTheRequestedPortfolioAndConservesValue() + { + var plan = PortfolioBalanceAlgorithm.Calculate(Values(), Targets()).ToDictionary(action => action.Asset); + Assert.Equal(25000m, plan["Gold"].TargetValue); + Assert.Equal(15000m, plan["Gold"].ValueChange); + Assert.Equal("Buy", plan["Gold"].Action); + Assert.Equal(-5000m, plan["USD"].ValueChange); + Assert.Equal(-10000m, plan["TCS Group stocks"].ValueChange); + Assert.Equal("Sell", plan["USD"].Action); + Assert.Equal(0m, plan.Values.Sum(action => action.ValueChange)); + } + + [Fact] + public void BalancedPortfolioHoldsAllAssets() + { + var plan = PortfolioBalanceAlgorithm.Calculate(new Dictionary + { ["Gold"] = 25m, ["USD"] = 25m, ["TCS Group stocks"] = 50m }, Targets()); + Assert.All(plan, action => Assert.Equal("Hold", action.Action)); + } + + [Fact] + public void AbsentTargetAssetIsBoughtAndUnallocatedHoldingIsSold() + { + var plan = PortfolioBalanceAlgorithm.Calculate(new Dictionary { ["Old"] = 10m }, + new Dictionary { ["New"] = 100m }).ToDictionary(action => action.Asset); + Assert.Equal(10m, plan["New"].ValueChange); + Assert.Equal(-10m, plan["Old"].ValueChange); + Assert.Equal(0m, plan["Old"].TargetPercent); + } + + [Fact] + public void EmptyPortfolioReturnsZeroValuedTargets() + { + var plan = PortfolioBalanceAlgorithm.Calculate(new Dictionary(), Targets()); + Assert.Equal(3, plan.Count); + Assert.All(plan, action => Assert.Equal(0m, action.ValueChange)); + } + + [Fact] + public void FractionalPercentagesKeepAllValueAndDoNotProduceNegativeTargets() + { + var targets = new Dictionary + { ["A"] = 33.33333333333333333333333333m, ["B"] = 66.66666666666666666666666666m, + ["C"] = 0.00000000000000000000000001m }; + var plan = PortfolioBalanceAlgorithm.Calculate(new Dictionary { ["A"] = 1m }, targets); + Assert.Equal(1m, plan.Sum(action => action.TargetValue)); + Assert.Equal(0m, plan.Sum(action => action.ValueChange)); + Assert.All(plan, action => Assert.True(action.TargetValue >= 0m)); + } + + [Fact] + public void LargePortfolioDoesNotOverflowDuringPercentageMultiplication() + { + var plan = PortfolioBalanceAlgorithm.Calculate(new Dictionary { ["A"] = decimal.MaxValue }, + new Dictionary { ["A"] = 50m, ["B"] = 50m }); + Assert.Equal(decimal.MaxValue, plan.Sum(action => action.TargetValue)); + } + + [Fact] + public void UsesOrdinalAssetIdentityRegardlessOfInputComparer() + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["USD"] = 10m }; + var plan = PortfolioBalanceAlgorithm.Calculate(values, + new Dictionary { ["usd"] = 100m }); + Assert.Equal(2, plan.Count); + Assert.Equal(10m, plan.Sum(action => action.CurrentValue)); + Assert.Equal(0m, plan.Sum(action => action.ValueChange)); + } + + [Theory] + [InlineData(-1, 101)] + [InlineData(101, -1)] + [InlineData(20, 20)] + [InlineData(100, 1)] + public void RejectsInvalidPercentages(int first, int second) + => Assert.Throws(() => PortfolioBalanceAlgorithm.Calculate(Values(), + new Dictionary { ["A"] = first, ["B"] = second })); + + [Fact] + public void RejectsEmptyTargets() + => Assert.Throws(() => PortfolioBalanceAlgorithm.Calculate(Values(), new Dictionary())); + + [Fact] + public void RejectsNegativeHoldings() + => Assert.Throws(() => PortfolioBalanceAlgorithm.Calculate( + new Dictionary { ["Gold"] = -1m }, Targets())); + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void RejectsBlankAssetNames(string asset) + { + Assert.Throws(() => PortfolioBalanceAlgorithm.Calculate( + new Dictionary { [asset] = 1m }, Targets())); + Assert.Throws(() => PortfolioBalanceAlgorithm.Calculate(Values(), + new Dictionary { [asset] = 100m })); + } + + [Fact] + public void DeepStorageReadbackWorksFromAnotherWrapper() + { + var financial = new FinancialStorage(); + var first = new PortfolioAllocationStorage(financial); + var snapshot = first.Save("Example", Targets()); + var second = new PortfolioAllocationStorage(financial); + Assert.Equal(Targets().OrderBy(pair => pair.Key), second.Read(snapshot).OrderBy(pair => pair.Key)); + Assert.Equal(PortfolioBalanceAlgorithm.Calculate(Values(), Targets()), second.Calculate(snapshot, Values())); + var memberships = new List(); + financial.Storage.Each(new Link(financial.Storage.Constants.Any, snapshot, financial.Storage.Constants.Any), link => + { + memberships.Add(financial.Storage.GetTarget(link)); + return financial.Storage.Constants.Continue; + }); + Assert.Equal(3, memberships.Count); + } + + [Fact] + public void AllocationSnapshotsStayIndependentAndReadOnly() + { + var store = new PortfolioAllocationStorage(new FinancialStorage()); + var oldSnapshot = store.Save("Same portfolio", Targets()); + var newSnapshot = store.Save("Same portfolio", new Dictionary { ["USD"] = 100m }); + Assert.NotEqual(oldSnapshot, newSnapshot); + Assert.Equal(3, store.Read(oldSnapshot).Count); + Assert.Equal(100m, store.Read(newSnapshot)["USD"]); + Assert.Throws(() => ((IDictionary)store.Read(oldSnapshot)).Add("Fake", 1m)); + } + + [Fact] + public void DeepStorageSupportsZeroAndFractionalPercentages() + { + var store = new PortfolioAllocationStorage(new FinancialStorage()); + var input = new Dictionary { ["Gold"] = 12.5m, ["Cash"] = 87.5m, ["Other"] = 0m }; + Assert.Equal(input.OrderBy(pair => pair.Key), store.Read(store.Save("Fractional", input)).OrderBy(pair => pair.Key)); + } + + [Fact] + public void RejectsInvalidOrUnrelatedSnapshot() + { + var financial = new FinancialStorage(); + var store = new PortfolioAllocationStorage(financial); + Assert.Throws(() => store.Read(0)); + Assert.Throws(() => store.Read(financial.Type)); + Assert.Throws(() => store.Save(" ", Targets())); + } + + [Fact] + public void InvalidAllocationDoesNotWritePartialDeepSnapshot() + { + var financial = new FinancialStorage(); + var store = new PortfolioAllocationStorage(financial); + var before = financial.Storage.Count(new Link(financial.Storage.Constants.Any, financial.Storage.Constants.Any, financial.Storage.Constants.Any)); + Assert.Throws(() => store.Save("Invalid", new Dictionary { ["Gold"] = 1m })); + Assert.Equal(before, financial.Storage.Count(new Link(financial.Storage.Constants.Any, financial.Storage.Constants.Any, financial.Storage.Constants.Any))); + } + + [Fact] + public void OfflineCommandReadsFixtureWithoutBrokerConfiguration() + { + var path = Path.GetTempFileName(); + try + { + File.WriteAllText(path, """ + {"PortfolioName":"Demo","TargetPercentages":{"Gold":25,"USD":25,"TCS Group stocks":50}, + "CurrentValues":{"Gold":10000,"USD":30000,"TCS Group stocks":60000}} + """); + var output = new StringWriter(); + var error = new StringWriter(); + Assert.Equal(0, PortfolioBalanceCommand.Run(new[] { "--balance-portfolio", path }, output, error)); + Assert.Contains("15000", output.ToString()); + Assert.Equal("", error.ToString()); + } + finally { File.Delete(path); } + } + + [Fact] + public void OfflineCommandRejectsMissingPathAndMalformedInput() + { + var output = new StringWriter(); + var error = new StringWriter(); + Assert.Equal(1, PortfolioBalanceCommand.Run(new[] { "--balance-portfolio" }, output, error)); + var path = Path.GetTempFileName(); + try + { + File.WriteAllText(path, "{}"); + Assert.Equal(1, PortfolioBalanceCommand.Run(new[] { "--balance-portfolio", path }, output, error)); + } + finally { File.Delete(path); } + } +} diff --git a/csharp/TraderBot.Tests/PortfolioEdgeCaseTests.cs b/csharp/TraderBot.Tests/PortfolioEdgeCaseTests.cs new file mode 100644 index 00000000..b560a2d2 --- /dev/null +++ b/csharp/TraderBot.Tests/PortfolioEdgeCaseTests.cs @@ -0,0 +1,38 @@ +using System.Globalization; +using TraderBot; +using Xunit; + +public class PortfolioEdgeCaseTests +{ + [Fact] + public void TinyValidPercentageRetainsRepresentableTargetValue() + { + var plan = PortfolioBalanceAlgorithm.Calculate( + new Dictionary { ["B"] = 10000000000000000000000000000m }, + new Dictionary + { + ["A"] = 0.000000000000000000000000001m, + ["B"] = 50m, + ["C"] = 49.999999999999999999999999999m + }).ToDictionary(x => x.Asset); + Assert.Equal(0.1m, plan["A"].TargetValue); + Assert.Equal(10000000000000000000000000000m, plan.Values.Sum(x => x.TargetValue)); + } + + [Fact] + public void UnicodeAllocationsRoundTripUnderNonEnglishCulture() + { + var previous = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR"); + var store = new PortfolioAllocationStorage(new FinancialStorage()); + var targets = new Dictionary { ["Or 🔆"] = 12.5m, ["現金"] = 87.5m }; + var snapshot = store.Save("Portefeuille 📈", targets); + var saved = store.Read(snapshot); + Assert.Equal(12.5m, saved["Or 🔆"]); + Assert.Equal(87.5m, saved["現金"]); + } + finally { CultureInfo.CurrentCulture = previous; } + } +} diff --git a/csharp/TraderBot.Tests/TraderBot.Tests.csproj b/csharp/TraderBot.Tests/TraderBot.Tests.csproj new file mode 100644 index 00000000..81479d6d --- /dev/null +++ b/csharp/TraderBot.Tests/TraderBot.Tests.csproj @@ -0,0 +1,17 @@ + + + net8 + enable + enable + false + true + + + + + + all + + + + diff --git a/csharp/TraderBot/PORTFOLIO_BALANCE.md b/csharp/TraderBot/PORTFOLIO_BALANCE.md new file mode 100644 index 00000000..d8a58a3f --- /dev/null +++ b/csharp/TraderBot/PORTFOLIO_BALANCE.md @@ -0,0 +1,98 @@ +# Portfolio allocation and balance planning + +Define a target percentage for each asset and supply its current market value in +one common valuation currency. For example, issue #138's 25% Gold, 25% USD and +50% TCS Group stocks allocation can be evaluated with the included fixture: + +```sh +dotnet run --project csharp/TraderBot -- --balance-portfolio csharp/TraderBot/portfolio-balance.example.json +``` + +This explicit command runs before the trading host is constructed. It requires +no broker account, access token or market-data connection and places no orders. +It saves target allocations into the existing Deep associative store, reads them +back, and prints the calculated plan as JSON. The example returns: + +| Asset | Current value | Target value | Value change | +|---|---:|---:|---:| +| Gold | 10000 | 25000 | +15000 | +| USD | 30000 | 25000 | -5000 | +| TCS Group stocks | 60000 | 50000 | -10000 | + +`ValueChange` is an amount in the supplied valuation currency, **not a quantity +of shares**. Positive values are labelled `Buy`, negative values `Sell`, and zero +values `Hold`. Names are opaque, case-sensitive asset identifiers. No price or +exchange-rate assumptions are made: value all assets, including cash, consistently +before supplying the input. Output contains each current or target asset once, +sorted by its ordinal identifier. + +## C# API + +```csharp +var financial = new FinancialStorage(); +var allocations = new PortfolioAllocationStorage(financial); +var snapshot = allocations.Save("Example", new Dictionary +{ + ["Gold"] = 25m, + ["USD"] = 25m, + ["TCS Group stocks"] = 50m +}); +var plan = allocations.Calculate(snapshot, new Dictionary +{ + ["Gold"] = 10000m, + ["USD"] = 30000m, + ["TCS Group stocks"] = 60000m +}); +``` + +Targets must sum to exactly 100%, and each must be between 0 and 100. Negative +current values and blank identifiers are rejected. Missing target assets have +zero current value; holdings absent from the target set receive a zero target +and a sell value. An empty/zero-value portfolio produces zero-value actions. +Calculations use `decimal`; the final positive target receives any rounding +remainder so total value is conserved. Amounts are not rounded to a broker's +currency precision or lot size. Inputs whose total exceeds `decimal.MaxValue` +are rejected by overflow rather than producing an invalid plan. + +Each `Save` creates an immutable snapshot. Changing a portfolio's targets means +saving a new snapshot; earlier snapshots remain readable. A new +`PortfolioAllocationStorage` wrapper around the same `FinancialStorage` can +read the saved targets and calculate a plan without relying on a dictionary +cached by the first wrapper. + +## Deep relationships + +The graph uses the following doublet relationships: + +```text +portfolio = (PortfolioBalancePortfolio type, portfolio-name sequence) +snapshot = (PortfolioBalanceAllocations type, unique point) +membership = (portfolio, snapshot) +percentage = (PortfolioBalancePercentage type, invariant-decimal sequence) +asset target = (asset-name sequence, percentage) +allocation = (snapshot, asset target) +``` + +Percentages are encoded losslessly as typed invariant decimal text. This includes +zero and fractional values and is independent of the machine's current culture. +Both the asset identifier and its percentage are reachable from the snapshot. + +`FinancialStorage` currently uses heap memory. These graph snapshots persist +within that store's lifetime; they are **not written to disk** and link IDs must +not be reused in a different store or process. Use one thread per store, matching +the existing storage's access model. + +## Verification + +```sh +dotnet test csharp/TraderBot.Tests/TraderBot.Tests.csproj +``` + +The tests use only synthetic portfolio data and in-memory Deep storage. They +exercise the calculation, validation, exact example, rounding conservation, +snapshot associations/readback, immutable updates, and offline command. The test +project is included in both solution files. + +Broker execution, live market valuation, fees, currency conversion, lot sizing +and order reconciliation are outside this planning algorithm. The existing +scalping host is not connected to these plans and its behavior is unchanged. diff --git a/csharp/TraderBot/PortfolioAllocationStorage.cs b/csharp/TraderBot/PortfolioAllocationStorage.cs new file mode 100644 index 00000000..56b9e045 --- /dev/null +++ b/csharp/TraderBot/PortfolioAllocationStorage.cs @@ -0,0 +1,71 @@ +using System.Globalization; +using Platform.Data; +using Platform.Data.Doublets; + +namespace TraderBot; + +/// Stores immutable target-allocation snapshots in the existing Deep link store. +public sealed class PortfolioAllocationStorage +{ + private readonly FinancialStorage _financial; + private readonly ulong _portfolioType; + private readonly ulong _allocationSetType; + private readonly ulong _percentageType; + + public PortfolioAllocationStorage(FinancialStorage financial) + { + ArgumentNullException.ThrowIfNull(financial); + _financial = financial; + _portfolioType = financial.GetOrCreateType(financial.Type, "PortfolioBalancePortfolio"); + _allocationSetType = financial.GetOrCreateType(financial.Type, "PortfolioBalanceAllocations"); + _percentageType = financial.GetOrCreateType(financial.Type, "PortfolioBalancePercentage"); + } + + public ulong Save(string portfolioName, IReadOnlyDictionary targetPercentages) + { + ArgumentException.ThrowIfNullOrWhiteSpace(portfolioName); + PortfolioBalanceAlgorithm.ValidateAllocations(targetPercentages); + var links = _financial.Storage; + var portfolio = links.GetOrCreate(_portfolioType, + _financial.StringToUnicodeSequenceConverter.Convert(portfolioName)); + var snapshot = links.GetOrCreate(_allocationSetType, links.CreatePoint()); + links.GetOrCreate(portfolio, snapshot); + foreach (var allocation in targetPercentages.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + var asset = _financial.StringToUnicodeSequenceConverter.Convert(allocation.Key); + // Typed invariant decimal text is lossless, including fractional percentages. + var percent = links.GetOrCreate(_percentageType, + _financial.StringToUnicodeSequenceConverter.Convert(allocation.Value.ToString("G29", CultureInfo.InvariantCulture))); + var assetAllocation = links.GetOrCreate(asset, percent); + links.GetOrCreate(snapshot, assetAllocation); + } + return snapshot; + } + + public IReadOnlyDictionary Read(ulong snapshot) + { + var links = _financial.Storage; + if (!links.Exists(snapshot) || links.GetSource(snapshot) != _allocationSetType) + throw new ArgumentException("Not a portfolio allocation snapshot.", nameof(snapshot)); + var allocations = new Dictionary(StringComparer.Ordinal); + links.Each(new Link(links.Constants.Any, snapshot, links.Constants.Any), link => + { + var allocation = links.GetTarget(link); + var asset = _financial.UnicodeSequenceToStringConverter.Convert(links.GetSource(allocation)); + var percentage = links.GetTarget(allocation); + if (links.GetSource(percentage) != _percentageType) + throw new InvalidOperationException("Invalid percentage link in allocation snapshot."); + var percent = decimal.Parse( + _financial.UnicodeSequenceToStringConverter.Convert(links.GetTarget(percentage)), + NumberStyles.Float, CultureInfo.InvariantCulture); + allocations.Add(asset, percent); + return links.Constants.Continue; + }); + PortfolioBalanceAlgorithm.ValidateAllocations(allocations); + return new System.Collections.ObjectModel.ReadOnlyDictionary(allocations); + } + + public IReadOnlyList Calculate( + ulong snapshot, IReadOnlyDictionary currentValues) + => PortfolioBalanceAlgorithm.Calculate(currentValues, Read(snapshot)); +} diff --git a/csharp/TraderBot/PortfolioBalanceAlgorithm.cs b/csharp/TraderBot/PortfolioBalanceAlgorithm.cs new file mode 100644 index 00000000..bea23c68 --- /dev/null +++ b/csharp/TraderBot/PortfolioBalanceAlgorithm.cs @@ -0,0 +1,73 @@ +namespace TraderBot; + +/// Plans value transfers in a single valuation currency; never places orders. +public static class PortfolioBalanceAlgorithm +{ + public static IReadOnlyList Calculate( + IReadOnlyDictionary currentValues, + IReadOnlyDictionary targetPercentages) + { + ArgumentNullException.ThrowIfNull(currentValues); + ValidateAllocations(targetPercentages); + ValidateAssets(currentValues, nameof(currentValues)); + if (currentValues.Values.Any(value => value < 0)) + throw new ArgumentException("Asset values cannot be negative.", nameof(currentValues)); + + // Treat identifiers consistently even when the caller uses a custom dictionary comparer. + currentValues = new Dictionary(currentValues, StringComparer.Ordinal); + targetPercentages = new Dictionary(targetPercentages, StringComparer.Ordinal); + var total = currentValues.Values.Sum(); + var assets = currentValues.Keys.Union(targetPercentages.Keys, StringComparer.Ordinal) + .OrderBy(asset => asset, StringComparer.Ordinal).ToArray(); + var targetValues = new Dictionary(StringComparer.Ordinal); + var positiveTargets = assets.Where(asset => targetPercentages.GetValueOrDefault(asset) > 0).ToArray(); + var remaining = total; + for (var i = 0; i < positiveTargets.Length; i++) + { + var asset = positiveTargets[i]; + // Allocate from the remaining share to conserve value without negative rounding dust. + var target = i == positiveTargets.Length - 1 + ? remaining : Math.Min(remaining, PercentageOf(total, targetPercentages[asset])); + targetValues[asset] = target; + remaining -= target; + } + + return Array.AsReadOnly(assets.Select(asset => new PortfolioBalanceAction( + asset, + currentValues.GetValueOrDefault(asset), + targetPercentages.GetValueOrDefault(asset), + targetValues.GetValueOrDefault(asset))).ToArray()); + } + + private static decimal PercentageOf(decimal total, decimal percentage) + { + // Preserve tiny percentages by multiplying first whenever the product fits. + // Scaling the total first is safe when only that product would overflow. + if (percentage <= 1m || total <= decimal.MaxValue / percentage) + return total * percentage / 100m; + return (total / 100m) * percentage; + } + + internal static void ValidateAllocations(IReadOnlyDictionary allocations) + { + ArgumentNullException.ThrowIfNull(allocations); + ValidateAssets(allocations, nameof(allocations)); + if (allocations.Count == 0 || allocations.Values.Any(value => value < 0 || value > 100) || + allocations.Values.Sum() != 100m) + throw new ArgumentException("Target percentages must be between 0 and 100 and sum to 100.", nameof(allocations)); + } + + private static void ValidateAssets(IReadOnlyDictionary assets, string parameter) + { + if (assets.Keys.Any(string.IsNullOrWhiteSpace)) + throw new ArgumentException("Asset identifiers cannot be empty or whitespace.", parameter); + } +} + +public sealed record PortfolioBalanceAction( + string Asset, decimal CurrentValue, decimal TargetPercent, decimal TargetValue) +{ + /// Positive means buy/add value; negative means sell/remove value. + public decimal ValueChange => TargetValue - CurrentValue; + public string Action => ValueChange > 0 ? "Buy" : ValueChange < 0 ? "Sell" : "Hold"; +} diff --git a/csharp/TraderBot/PortfolioBalanceCommand.cs b/csharp/TraderBot/PortfolioBalanceCommand.cs new file mode 100644 index 00000000..c2891558 --- /dev/null +++ b/csharp/TraderBot/PortfolioBalanceCommand.cs @@ -0,0 +1,38 @@ +using System.Text.Json; + +namespace TraderBot; + +/// Explicit offline entry point. It does not construct the broker client or trading host. +public static class PortfolioBalanceCommand +{ + public static int Run(string[] args, TextWriter output, TextWriter error) + { + if (args.Length != 2) + { + error.WriteLine("Usage: --balance-portfolio "); + return 1; + } + try + { + var request = JsonSerializer.Deserialize(File.ReadAllText(args[1])) + ?? throw new ArgumentException("A portfolio request is required."); + var store = new PortfolioAllocationStorage(new FinancialStorage()); + var snapshot = store.Save(request.PortfolioName, request.TargetPercentages); + var plan = store.Calculate(snapshot, request.CurrentValues); + output.WriteLine(JsonSerializer.Serialize(plan, new JsonSerializerOptions { WriteIndented = true })); + return 0; + } + catch (Exception exception) when (exception is ArgumentException or JsonException or IOException or UnauthorizedAccessException or OverflowException) + { + error.WriteLine($"Cannot balance portfolio: {exception.Message}"); + return 1; + } + } + + public sealed class PortfolioBalanceRequest + { + public string PortfolioName { get; set; } = ""; + public Dictionary TargetPercentages { get; set; } = new(StringComparer.Ordinal); + public Dictionary CurrentValues { get; set; } = new(StringComparer.Ordinal); + } +} diff --git a/csharp/TraderBot/Program.cs b/csharp/TraderBot/Program.cs index e5ac64bb..34ed0f1e 100644 --- a/csharp/TraderBot/Program.cs +++ b/csharp/TraderBot/Program.cs @@ -5,6 +5,12 @@ using Tinkoff.InvestApi; using TraderBot; +if (args.Length > 0 && args[0] == "--balance-portfolio") +{ + Environment.ExitCode = PortfolioBalanceCommand.Run(args, Console.Out, Console.Error); + return; +} + var builder = Host.CreateDefaultBuilder(args); var host = builder .ConfigureServices((context, services) => diff --git a/csharp/TraderBot/README.md b/csharp/TraderBot/README.md index 352b8b97..9601280b 100644 --- a/csharp/TraderBot/README.md +++ b/csharp/TraderBot/README.md @@ -4,6 +4,12 @@ This bot was participating in the [Tinkoff Invest Robot Contest](https://github. [![Trading with bot](trading.png)](trading.png) +## Portfolio allocation planning + +[Calculate target allocations and balance changes offline](PORTFOLIO_BALANCE.md), +including the 25% Gold / 25% USD / 50% stocks example. This mode stores target +relationships in Deep and requires no broker credentials. + ## Prerequisites * Linux, macOS, Windows * [Git](https://git-scm.com/downloads) diff --git a/csharp/TraderBot/portfolio-balance.example.json b/csharp/TraderBot/portfolio-balance.example.json new file mode 100644 index 00000000..9bac138a --- /dev/null +++ b/csharp/TraderBot/portfolio-balance.example.json @@ -0,0 +1,5 @@ +{ + "PortfolioName": "Example", + "TargetPercentages": { "Gold": 25, "USD": 25, "TCS Group stocks": 50 }, + "CurrentValues": { "Gold": 10000, "USD": 30000, "TCS Group stocks": 60000 } +}