Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Bot.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
6 changes: 6 additions & 0 deletions csharp/Bot.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
202 changes: 202 additions & 0 deletions csharp/TraderBot.Tests/PortfolioBalanceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
using Platform.Data.Doublets;
using TraderBot;
using Xunit;

public class PortfolioBalanceTests
{
private static Dictionary<string, decimal> Targets() => new()
{ ["Gold"] = 25m, ["USD"] = 25m, ["TCS Group stocks"] = 50m };
private static Dictionary<string, decimal> 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<string, decimal>
{ ["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<string, decimal> { ["Old"] = 10m },
new Dictionary<string, decimal> { ["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<string, decimal>(), Targets());
Assert.Equal(3, plan.Count);
Assert.All(plan, action => Assert.Equal(0m, action.ValueChange));
}

[Fact]
public void FractionalPercentagesKeepAllValueAndDoNotProduceNegativeTargets()
{
var targets = new Dictionary<string, decimal>
{ ["A"] = 33.33333333333333333333333333m, ["B"] = 66.66666666666666666666666666m,
["C"] = 0.00000000000000000000000001m };
var plan = PortfolioBalanceAlgorithm.Calculate(new Dictionary<string, decimal> { ["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<string, decimal> { ["A"] = decimal.MaxValue },
new Dictionary<string, decimal> { ["A"] = 50m, ["B"] = 50m });
Assert.Equal(decimal.MaxValue, plan.Sum(action => action.TargetValue));
}

[Fact]
public void UsesOrdinalAssetIdentityRegardlessOfInputComparer()
{
var values = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase) { ["USD"] = 10m };
var plan = PortfolioBalanceAlgorithm.Calculate(values,
new Dictionary<string, decimal> { ["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<ArgumentException>(() => PortfolioBalanceAlgorithm.Calculate(Values(),
new Dictionary<string, decimal> { ["A"] = first, ["B"] = second }));

[Fact]
public void RejectsEmptyTargets()
=> Assert.Throws<ArgumentException>(() => PortfolioBalanceAlgorithm.Calculate(Values(), new Dictionary<string, decimal>()));

[Fact]
public void RejectsNegativeHoldings()
=> Assert.Throws<ArgumentException>(() => PortfolioBalanceAlgorithm.Calculate(
new Dictionary<string, decimal> { ["Gold"] = -1m }, Targets()));

[Theory]
[InlineData("")]
[InlineData(" ")]
public void RejectsBlankAssetNames(string asset)
{
Assert.Throws<ArgumentException>(() => PortfolioBalanceAlgorithm.Calculate(
new Dictionary<string, decimal> { [asset] = 1m }, Targets()));
Assert.Throws<ArgumentException>(() => PortfolioBalanceAlgorithm.Calculate(Values(),
new Dictionary<string, decimal> { [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<ulong>();
financial.Storage.Each(new Link<ulong>(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<string, decimal> { ["USD"] = 100m });
Assert.NotEqual(oldSnapshot, newSnapshot);
Assert.Equal(3, store.Read(oldSnapshot).Count);
Assert.Equal(100m, store.Read(newSnapshot)["USD"]);
Assert.Throws<NotSupportedException>(() => ((IDictionary<string, decimal>)store.Read(oldSnapshot)).Add("Fake", 1m));
}

[Fact]
public void DeepStorageSupportsZeroAndFractionalPercentages()
{
var store = new PortfolioAllocationStorage(new FinancialStorage());
var input = new Dictionary<string, decimal> { ["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<ArgumentException>(() => store.Read(0));
Assert.Throws<ArgumentException>(() => store.Read(financial.Type));
Assert.Throws<ArgumentException>(() => store.Save(" ", Targets()));
}

[Fact]
public void InvalidAllocationDoesNotWritePartialDeepSnapshot()
{
var financial = new FinancialStorage();
var store = new PortfolioAllocationStorage(financial);
var before = financial.Storage.Count(new Link<ulong>(financial.Storage.Constants.Any, financial.Storage.Constants.Any, financial.Storage.Constants.Any));
Assert.Throws<ArgumentException>(() => store.Save("Invalid", new Dictionary<string, decimal> { ["Gold"] = 1m }));
Assert.Equal(before, financial.Storage.Count(new Link<ulong>(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); }
}
}
38 changes: 38 additions & 0 deletions csharp/TraderBot.Tests/PortfolioEdgeCaseTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using System.Globalization;
using TraderBot;
using Xunit;

public class PortfolioEdgeCaseTests
{
[Fact]
public void TinyValidPercentageRetainsRepresentableTargetValue()
{
var plan = PortfolioBalanceAlgorithm.Calculate(
new Dictionary<string, decimal> { ["B"] = 10000000000000000000000000000m },
new Dictionary<string, decimal>
{
["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<string, decimal> { ["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; }
}
}
17 changes: 17 additions & 0 deletions csharp/TraderBot.Tests/TraderBot.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<ProjectReference Include="../TraderBot/TraderBot.csproj" />
</ItemGroup>
</Project>
98 changes: 98 additions & 0 deletions csharp/TraderBot/PORTFOLIO_BALANCE.md
Original file line number Diff line number Diff line change
@@ -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<string, decimal>
{
["Gold"] = 25m,
["USD"] = 25m,
["TCS Group stocks"] = 50m
});
var plan = allocations.Calculate(snapshot, new Dictionary<string, decimal>
{
["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.
Loading
Loading