From b33db93bf9c3c201e80c7e57d42f93d70d0ecd6f Mon Sep 17 00:00:00 2001 From: Vincent <30174292+VibeNL@users.noreply.github.com> Date: Wed, 21 Jan 2026 09:49:41 +0100 Subject: [PATCH 1/7] Remove old primary currency --- Configuration/Settings.cs | 6 +- Database/DatabaseContext.cs | 6 +- .../HoldingAggregatedTypeConfiguration.cs | 41 +--- .../CovertToPrimaryCurrencyTaskTests.cs | 62 ------ .../CovertToPrimaryCurrencyTask.cs | 202 ------------------ GhostfolioSidekick/TaskPriority.cs | 4 +- Model.UnitTests/CustomFixture.cs | 5 +- Model/Accounts/Account.cs | 5 +- Model/Performance/BalancePrimaryCurrency.cs | 25 --- .../CalculatedSnapshotPrimaryCurrency.cs | 25 --- Model/Performance/HoldingAggregated.cs | 2 - Parsers.UnitTests/DefaultFixture.cs | 4 +- 12 files changed, 9 insertions(+), 378 deletions(-) delete mode 100644 GhostfolioSidekick.UnitTests/Performance/CovertToPrimaryCurrencyTaskTests.cs delete mode 100644 GhostfolioSidekick/Performance/CovertToPrimaryCurrencyTask.cs delete mode 100644 Model/Performance/BalancePrimaryCurrency.cs delete mode 100644 Model/Performance/CalculatedSnapshotPrimaryCurrency.cs diff --git a/Configuration/Settings.cs b/Configuration/Settings.cs index 108d25370..862b8395f 100644 --- a/Configuration/Settings.cs +++ b/Configuration/Settings.cs @@ -1,4 +1,4 @@ -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; namespace GhostfolioSidekick.Configuration @@ -13,7 +13,7 @@ public class Settings() [JsonPropertyName("dataprovider.preference.order")] public required string DataProviderPreference { get; set; } = "YAHOO;COINGECKO"; - [JsonPropertyName("performance.primarycurrency")] - public required string PrimaryCurrency { get; set; } = "EUR"; + [JsonPropertyName("performance.currencies")] + public required string Currencies { get; set; } = "EUR;USD"; } } \ No newline at end of file diff --git a/Database/DatabaseContext.cs b/Database/DatabaseContext.cs index d4413f1b4..99d694c0f 100644 --- a/Database/DatabaseContext.cs +++ b/Database/DatabaseContext.cs @@ -1,4 +1,4 @@ -using GhostfolioSidekick.Model; +using GhostfolioSidekick.Model; using GhostfolioSidekick.Model.Accounts; using GhostfolioSidekick.Model.Activities; using GhostfolioSidekick.Model.Market; @@ -37,10 +37,6 @@ public class DatabaseContext : DbContext public virtual DbSet CalculatedSnapshots { get; set; } - public virtual DbSet CalculatedSnapshotPrimaryCurrencies { get; set; } - - public virtual DbSet BalancePrimaryCurrencies { get; set; } - public virtual DbSet Tasks { get; set; } public virtual DbSet TaskRunLogs { get; set; } diff --git a/Database/TypeConfigurations/HoldingAggregatedTypeConfiguration.cs b/Database/TypeConfigurations/HoldingAggregatedTypeConfiguration.cs index db32291dc..1957339de 100644 --- a/Database/TypeConfigurations/HoldingAggregatedTypeConfiguration.cs +++ b/Database/TypeConfigurations/HoldingAggregatedTypeConfiguration.cs @@ -11,9 +11,7 @@ namespace GhostfolioSidekick.Database.TypeConfigurations { internal class HoldingAggregatedTypeConfiguration : IEntityTypeConfiguration, - IEntityTypeConfiguration, - IEntityTypeConfiguration, - IEntityTypeConfiguration + IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { @@ -79,43 +77,6 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(x => new { x.Date }); } - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("CalculatedSnapshotPrimaryCurrencies"); - - // Add a shadow property for the primary key since class doesn't have one - builder.Property("Id") - .HasColumnType("integer") - .ValueGeneratedOnAdd() - .HasAnnotation("Key", 0); - builder.HasKey("Id"); - - // Configure simple properties - builder.Property(x => x.Date).IsRequired(); - builder.Property(x => x.Quantity).IsRequired(); - - // Indexes - builder.HasIndex(x => new { x.Date }); - builder.HasIndex(x => new { x.AccountId, x.Date }); - builder.HasIndex(x => new { x.HoldingAggregatedId, x.AccountId, x.Date }).IsUnique(); - builder.HasIndex(x => new { x.HoldingAggregatedId, x.Date }); - builder.HasIndex(x => new { x.Date }); - } - - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("BalancePrimaryCurrencies"); - builder.HasKey(x => x.Id); - - // Configure simple properties - builder.Property(x => x.Date).IsRequired(); - builder.Property(x => x.Money).IsRequired(); - - // Indexes - builder.HasIndex(x => new { x.AccountId, x.Date }).IsUnique(); - builder.HasIndex(x => new { x.Date }); - } - private static void MapMoney(EntityTypeBuilder builder, Expression> navigationExpression, string name) where TEntity : class { // Cast to nullable Money to satisfy EF Core's ComplexProperty method diff --git a/GhostfolioSidekick.UnitTests/Performance/CovertToPrimaryCurrencyTaskTests.cs b/GhostfolioSidekick.UnitTests/Performance/CovertToPrimaryCurrencyTaskTests.cs deleted file mode 100644 index af6d6b1be..000000000 --- a/GhostfolioSidekick.UnitTests/Performance/CovertToPrimaryCurrencyTaskTests.cs +++ /dev/null @@ -1,62 +0,0 @@ -using GhostfolioSidekick.Model; -using GhostfolioSidekick.Configuration; -using GhostfolioSidekick.Database.Repository; -using GhostfolioSidekick.Model.Accounts; -using Microsoft.Data.Sqlite; - -namespace GhostfolioSidekick.UnitTests.Performance -{ - public class CovertToPrimaryCurrencyTaskTests - { - [Fact] - public async Task DoWork_UsesRealSqliteDatabase() - { - var connection = new SqliteConnection("Filename=:memory:"); - await connection.OpenAsync(); - - var options = new DbContextOptionsBuilder() - .UseSqlite(connection) - .Options; - - // Use a context for seeding - var seedContext = new DatabaseContext(options); - await seedContext.Database.EnsureCreatedAsync(); - - // Seed Account entity to satisfy foreign key constraint - seedContext.Accounts.Add(new Account { Id = 1, Name = "Test Account" }); - await seedContext.SaveChangesAsync(); - - // Seed data as needed for your test - seedContext.Balances.Add(new Balance(DateOnly.FromDateTime(DateTime.Today), Money.Zero(Currency.EUR)) { AccountId = 1 }); - await seedContext.SaveChangesAsync(); - await seedContext.DisposeAsync(); - - var currencyExchangeMock = new Mock(); - currencyExchangeMock.Setup(e => e.PreloadAllExchangeRates()).Returns(Task.CompletedTask); - currencyExchangeMock.Setup(e => e.ConvertMoney(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(Money.Zero(Currency.EUR)); - - var appSettingsMock = new Mock(); - var configInstance = new ConfigurationInstance { Settings = new Settings { PrimaryCurrency = "EUR" } }; - appSettingsMock.Setup(a => a.ConfigurationInstance).Returns(configInstance); - - var dbContextFactoryMock = new Mock>(); - dbContextFactoryMock.Setup(f => f.CreateDbContextAsync()).ReturnsAsync(() => new DatabaseContext(options)); - - var loggerMock = new Mock(); - - var task = new CovertToPrimaryCurrencyTask( - currencyExchangeMock.Object, - dbContextFactoryMock.Object, - appSettingsMock.Object - ); - - await task.DoWork(loggerMock.Object); - - // Use a new context to assert results - await using var assertContext = new DatabaseContext(options); - Assert.True(await assertContext.Balances.AnyAsync()); - - await connection.DisposeAsync(); - } - } -} diff --git a/GhostfolioSidekick/Performance/CovertToPrimaryCurrencyTask.cs b/GhostfolioSidekick/Performance/CovertToPrimaryCurrencyTask.cs deleted file mode 100644 index 6602aa53b..000000000 --- a/GhostfolioSidekick/Performance/CovertToPrimaryCurrencyTask.cs +++ /dev/null @@ -1,202 +0,0 @@ -using GhostfolioSidekick.Configuration; -using GhostfolioSidekick.Database; -using GhostfolioSidekick.Database.Repository; -using GhostfolioSidekick.Model; -using GhostfolioSidekick.Model.Performance; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; - -namespace GhostfolioSidekick.Performance -{ - internal class CovertToPrimaryCurrencyTask( - ICurrencyExchange currencyExchange, - IDbContextFactory dbContextFactory, - IApplicationSettings applicationSettings - ) : IScheduledWork - { - const int batchSize = 1000; - - public TaskPriority Priority => TaskPriority.CovertToPrimaryCurrency; - - public TimeSpan ExecutionFrequency => TimeSpan.FromHours(1); - - public bool ExceptionsAreFatal => false; - - public string Name => "Convert to Primary Currency"; - - public async Task DoWork(ILogger logger) - { - var primaryCurrencySymbol = applicationSettings.ConfigurationInstance.Settings.PrimaryCurrency; - var currency = Currency.GetCurrency(primaryCurrencySymbol); - - await currencyExchange.PreloadAllExchangeRates(); - - logger.LogDebug("Converting all snapshots and balances to primary currency {Currency}", primaryCurrencySymbol); - - await ConvertSnapshotsToPrimaryCurrency(currency, primaryCurrencySymbol, logger); - await ConvertBalancesToPrimaryCurrency(currency, primaryCurrencySymbol, logger); - - logger.LogDebug("Cleanup unmatched primary currency records"); - await CleanupUnmatchedItems(); - - logger.LogDebug("Completed conversion to primary currency {Currency}", primaryCurrencySymbol); - } - - private async Task ConvertSnapshotsToPrimaryCurrency(Currency currency, string primaryCurrencySymbol, ILogger logger) - { - var totalSnapshots = 0; - using (var dbContext = await dbContextFactory.CreateDbContextAsync()) - { - totalSnapshots = await dbContext.CalculatedSnapshots.CountAsync(); - } - - int processed = 0; - - for (int i = 0; i < totalSnapshots; i += batchSize) - { - using var dbContext = await dbContextFactory.CreateDbContextAsync(); - - var snapshots = await dbContext.CalculatedSnapshots - .AsNoTracking() - .OrderBy(s => s.Id) - .Skip(i) - .Take(batchSize) - .ToListAsync(); - - foreach (var snapshot in snapshots) - { - var primarySnapshot = await dbContext.CalculatedSnapshotPrimaryCurrencies - .FirstOrDefaultAsync(s => s.HoldingAggregatedId == snapshot.HoldingAggregatedId && s.AccountId == snapshot.AccountId && s.Date == snapshot.Date); - - if (primarySnapshot == null) - { - primarySnapshot = new CalculatedSnapshotPrimaryCurrency - { - HoldingAggregatedId = snapshot.HoldingAggregatedId, - Date = snapshot.Date - }; - dbContext.CalculatedSnapshotPrimaryCurrencies.Add(primarySnapshot); - } - - primarySnapshot.Quantity = snapshot.Quantity; - primarySnapshot.TotalValue = (await currencyExchange.ConvertMoney(snapshot.TotalValue, currency, snapshot.Date)).Amount; - primarySnapshot.TotalInvested = (await currencyExchange.ConvertMoney(snapshot.TotalInvested, currency, snapshot.Date)).Amount; - primarySnapshot.AverageCostPrice = primarySnapshot.Quantity != 0 ? primarySnapshot.TotalInvested / primarySnapshot.Quantity : 0; - primarySnapshot.CurrentUnitPrice = (await currencyExchange.ConvertMoney(snapshot.CurrentUnitPrice, currency, snapshot.Date)).Amount; - primarySnapshot.AccountId = snapshot.AccountId; - } - - await dbContext.SaveChangesAsync(); - - processed += snapshots.Count; - logger.LogDebug("Processed {Processed}/{Total} snapshots to primary currency {Currency}", processed, totalSnapshots, primaryCurrencySymbol); - } - - logger.LogDebug("Converted {Count} snapshots to primary currency {Currency}", totalSnapshots, primaryCurrencySymbol); - } - - private async Task ConvertBalancesToPrimaryCurrency(Currency currency, string primaryCurrencySymbol, ILogger logger) - { - using var queryContext = await dbContextFactory.CreateDbContextAsync(); - var accountIds = await queryContext.Balances - .AsNoTracking() - .Select(b => b.AccountId) - .Distinct() - .ToListAsync(); - - var today = DateOnly.FromDateTime(DateTime.Today); - - foreach (var accountId in accountIds) - { - using var dbContext = await dbContextFactory.CreateDbContextAsync(); - - var balances = await dbContext.Balances - .Where(b => b.AccountId == accountId) - .OrderBy(b => b.Date) - .AsNoTracking() - .ToListAsync(); - - if (balances.Count == 0) - { - continue; - } - - var startDate = balances[0].Date; - var balanceByDate = balances.ToDictionary(b => b.Date); - var existingPrimary = await dbContext.BalancePrimaryCurrencies - .Where(b => b.AccountId == accountId) - .ToDictionaryAsync(b => b.Date); - - decimal lastKnownAmount = 0; - for (var date = startDate; date <= today; date = date.AddDays(1)) - { - if (balanceByDate.TryGetValue(date, out var balance)) - { - var converted = await currencyExchange.ConvertMoney(balance.Money, currency, date); - lastKnownAmount = converted.Amount; - } - - if (!existingPrimary.TryGetValue(date, out BalancePrimaryCurrency? value)) - { - dbContext.BalancePrimaryCurrencies.Add(new BalancePrimaryCurrency - { - AccountId = accountId, - Date = date, - Money = lastKnownAmount - }); - } - else - { - value.Money = lastKnownAmount; - } - } - - await dbContext.SaveChangesAsync(); - } - - logger.LogDebug("Converted and filled balances to primary currency {Currency}", primaryCurrencySymbol); - } - - private async Task CleanupUnmatchedItems() - { - using var dbContext = await dbContextFactory.CreateDbContextAsync(); - var orphanedPrimarySnapshots = await dbContext.CalculatedSnapshotPrimaryCurrencies - .Where(ps => !dbContext.CalculatedSnapshots - .Any(s => s.HoldingAggregatedId == ps.HoldingAggregatedId && - s.AccountId == ps.AccountId && - s.Date == ps.Date)) - .ToListAsync(); - - if (orphanedPrimarySnapshots.Count > 0) - { - dbContext.CalculatedSnapshotPrimaryCurrencies.RemoveRange(orphanedPrimarySnapshots); - } - - var firstActualBalanceDates = await dbContext.Balances - .GroupBy(b => b.AccountId) - .Select(g => new { AccountId = g.Key, FirstDate = g.Min(b => b.Date) }) - .ToListAsync(); - - var firstActualBalanceDateDict = firstActualBalanceDates.ToDictionary(x => x.AccountId, x => x.FirstDate); - - var orphanedPrimaryBalances = await dbContext.BalancePrimaryCurrencies - .Where(pb => !dbContext.Balances - .Any(b => b.AccountId == pb.AccountId && b.Date == pb.Date)) - .ToListAsync(); - - var balancesToRemove = orphanedPrimaryBalances - .Where(pb => firstActualBalanceDateDict.TryGetValue(pb.AccountId, out var firstActualDate) && pb.Date < firstActualDate) - .ToList(); - - if (balancesToRemove.Count > 0) - { - dbContext.BalancePrimaryCurrencies.RemoveRange(balancesToRemove); - } - - if (orphanedPrimarySnapshots.Count > 0 || balancesToRemove.Count > 0) - { - await dbContext.SaveChangesAsync(); - } - } - } -} diff --git a/GhostfolioSidekick/TaskPriority.cs b/GhostfolioSidekick/TaskPriority.cs index e9f732cbd..8633a59ea 100644 --- a/GhostfolioSidekick/TaskPriority.cs +++ b/GhostfolioSidekick/TaskPriority.cs @@ -1,4 +1,4 @@ -namespace GhostfolioSidekick +namespace GhostfolioSidekick { public enum TaskPriority { @@ -28,8 +28,6 @@ public enum TaskPriority PerformanceCalculations, - CovertToPrimaryCurrency, - CleanupDatabase, SyncAccountsWithGhostfolio, diff --git a/Model.UnitTests/CustomFixture.cs b/Model.UnitTests/CustomFixture.cs index 5f69411ed..3858f2e04 100644 --- a/Model.UnitTests/CustomFixture.cs +++ b/Model.UnitTests/CustomFixture.cs @@ -1,7 +1,6 @@ -using AutoFixture; +using AutoFixture; using AutoFixture.Kernel; using GhostfolioSidekick.Model.Activities; -using GhostfolioSidekick.Model.Performance; namespace GhostfolioSidekick.Model.UnitTests { @@ -17,8 +16,6 @@ public static Fixture New() fixture.Behaviors.Add(new OmitOnRecursionBehavior()); fixture.Customizations.Add(new ExcludeTypeSpecimenBuilder(typeof(Activity))); fixture.Customizations.Add(new ExcludeTypeSpecimenBuilder(typeof(Holding))); - fixture.Customizations.Add(new ExcludeTypeSpecimenBuilder(typeof(BalancePrimaryCurrency))); - fixture.Customizations.Add(new ExcludeTypeSpecimenBuilder(typeof(CalculatedSnapshotPrimaryCurrency))); return fixture; } diff --git a/Model/Accounts/Account.cs b/Model/Accounts/Account.cs index b9a3e5d11..c7b4772ec 100644 --- a/Model/Accounts/Account.cs +++ b/Model/Accounts/Account.cs @@ -1,5 +1,4 @@ -using GhostfolioSidekick.Model.Activities; -using GhostfolioSidekick.Model.Performance; +using GhostfolioSidekick.Model.Activities; namespace GhostfolioSidekick.Model.Accounts { @@ -21,8 +20,6 @@ public Account(string name) public virtual List Balance { get; set; } = []; - public virtual List BalancePrimaryCurrency { get; set; } = []; - public int Id { get; set; } public string? Comment { get; set; } diff --git a/Model/Performance/BalancePrimaryCurrency.cs b/Model/Performance/BalancePrimaryCurrency.cs deleted file mode 100644 index bd4e67230..000000000 --- a/Model/Performance/BalancePrimaryCurrency.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace GhostfolioSidekick.Model.Performance -{ - public class BalancePrimaryCurrency - { - public BalancePrimaryCurrency() - { - // EF Core - Date = default!; - Money = default!; - } - - public int Id { get; set; } - - public DateOnly Date { get; set; } - - public decimal Money { get; set; } - - public int AccountId { get; set; } - - public override string ToString() - { - return Date.ToShortDateString() + " " + Money.ToString(); - } - } -} \ No newline at end of file diff --git a/Model/Performance/CalculatedSnapshotPrimaryCurrency.cs b/Model/Performance/CalculatedSnapshotPrimaryCurrency.cs deleted file mode 100644 index e251195d3..000000000 --- a/Model/Performance/CalculatedSnapshotPrimaryCurrency.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace GhostfolioSidekick.Model.Performance -{ - public class CalculatedSnapshotPrimaryCurrency - { - public long Id { get; set; } // EF Core key - public int AccountId { get; set; } // Foreign key to Account, if needed - public long HoldingAggregatedId { get; set; } // Foreign key to Holding, if needed - public DateOnly Date { get; set; } - public decimal Quantity { get; set; } - public decimal AverageCostPrice { get; set; } = 0; - public decimal CurrentUnitPrice { get; set; } = 0; - public decimal TotalInvested { get; set; } = 0; - public decimal TotalValue { get; set; } = 0; - - // Parameterless constructor for EF Core - public CalculatedSnapshotPrimaryCurrency() - { - } - - public override string ToString() - { - return $"CalculatedSnapshotPrimaryCurrency(Id={Id}, AccountId={AccountId}, Date={Date}, Quantity={Quantity}, AverageCostPrice={AverageCostPrice}, CurrentUnitPrice={CurrentUnitPrice}, TotalInvested={TotalInvested}, TotalValue={TotalValue})"; - } - } -} \ No newline at end of file diff --git a/Model/Performance/HoldingAggregated.cs b/Model/Performance/HoldingAggregated.cs index 24e7d9692..a14dcca7f 100644 --- a/Model/Performance/HoldingAggregated.cs +++ b/Model/Performance/HoldingAggregated.cs @@ -25,8 +25,6 @@ public class HoldingAggregated public virtual ICollection CalculatedSnapshots { get; set; } = []; - public virtual ICollection CalculatedSnapshotsPrimaryCurrency { get; set; } = []; - public override string ToString() { return $"{Symbol} ({DataSource}) - {Name ?? "No Name"}"; diff --git a/Parsers.UnitTests/DefaultFixture.cs b/Parsers.UnitTests/DefaultFixture.cs index 4b4c59ba4..2bcb5c2ae 100644 --- a/Parsers.UnitTests/DefaultFixture.cs +++ b/Parsers.UnitTests/DefaultFixture.cs @@ -1,4 +1,4 @@ -using AutoFixture; +using AutoFixture; using AutoFixture.Kernel; using GhostfolioSidekick.Model; using GhostfolioSidekick.Model.Activities; @@ -28,8 +28,6 @@ public static Fixture New() fixture.Behaviors.Add(new OmitOnRecursionBehavior()); fixture.Customizations.Add(new ExcludeTypeSpecimenBuilder(typeof(Activity))); fixture.Customizations.Add(new ExcludeTypeSpecimenBuilder(typeof(Holding))); - fixture.Customizations.Add(new ExcludeTypeSpecimenBuilder(typeof(BalancePrimaryCurrency))); - fixture.Customizations.Add(new ExcludeTypeSpecimenBuilder(typeof(CalculatedSnapshotPrimaryCurrency))); return fixture; } From 084f347a02ae06521d3d30bfc95e9ddceca73c05 Mon Sep 17 00:00:00 2001 From: Vincent <30174292+VibeNL@users.noreply.github.com> Date: Wed, 21 Jan 2026 10:11:54 +0100 Subject: [PATCH 2/7] 1 --- .../ApplicationSettingsTests.cs | 4 ++-- Configuration/Settings.cs | 11 ++++++++-- .../GhostfolioSymbolMatcherTests.cs | 4 ++-- GhostfolioAPI/GhostfolioSymbolMatcher.cs | 2 +- .../BalanceMaintainerTaskTests.cs | 13 +++++++++++- .../DisplayInformationTaskTests.cs | 4 ++-- .../AccountMaintainer/BalanceCalculator.cs | 17 ++++++++++++++- .../BalanceMaintainerTask.cs | 21 ++++++++++++++----- 8 files changed, 60 insertions(+), 16 deletions(-) diff --git a/Configuration.UnitTests/ApplicationSettingsTests.cs b/Configuration.UnitTests/ApplicationSettingsTests.cs index 3331dd6ea..347195222 100644 --- a/Configuration.UnitTests/ApplicationSettingsTests.cs +++ b/Configuration.UnitTests/ApplicationSettingsTests.cs @@ -1,4 +1,4 @@ -using AwesomeAssertions; +using AwesomeAssertions; using Microsoft.Extensions.Logging; using Moq; @@ -25,7 +25,7 @@ public void Constructor_ParsesConfigurationFile() // Assert settings.ConfigurationInstance.Should().NotBeNull(); - settings.ConfigurationInstance.Settings.DataProviderPreference.Should().Be("COINGECKO,YAHOO"); + settings.ConfigurationInstance.Settings.RawDataProviderPreference.Should().Be("COINGECKO,YAHOO"); } [Fact] diff --git a/Configuration/Settings.cs b/Configuration/Settings.cs index 862b8395f..1cc3f2b61 100644 --- a/Configuration/Settings.cs +++ b/Configuration/Settings.cs @@ -11,9 +11,16 @@ public class Settings() public bool DeleteUnusedSymbols { get; set; } = true; [JsonPropertyName("dataprovider.preference.order")] - public required string DataProviderPreference { get; set; } = "YAHOO;COINGECKO"; + public required string RawDataProviderPreference { get; set; } = "YAHOO;COINGECKO"; [JsonPropertyName("performance.currencies")] - public required string Currencies { get; set; } = "EUR;USD"; + public required string RawCurrencies { get; set; } = "EUR;USD"; + + public string[] DataProviderPreference => SplitConfigurationValue(RawDataProviderPreference); + + public string[] Currencies => SplitConfigurationValue(RawCurrencies); + + private static string[] SplitConfigurationValue(string value) => + [.. value.Split([';', ','], System.StringSplitOptions.RemoveEmptyEntries).Select(x => x.ToUpperInvariant())]; } } \ No newline at end of file diff --git a/GhostfolioAPI.UnitTests/GhostfolioSymbolMatcherTests.cs b/GhostfolioAPI.UnitTests/GhostfolioSymbolMatcherTests.cs index 9bebdb888..1be4ac485 100644 --- a/GhostfolioAPI.UnitTests/GhostfolioSymbolMatcherTests.cs +++ b/GhostfolioAPI.UnitTests/GhostfolioSymbolMatcherTests.cs @@ -27,7 +27,7 @@ public GhostfolioSymbolMatcherTests() // Create real configuration objects instead of mocking _configInstance = new ConfigurationInstance { - Settings = new Settings { DataProviderPreference = "YAHOO,COINGECKO" } + Settings = new Settings { RawDataProviderPreference = "YAHOO,COINGECKO" } }; _settingsMock.Setup(x => x.ConfigurationInstance).Returns(_configInstance); @@ -400,7 +400,7 @@ public async Task MatchSymbol_ShouldPreferDataSourceByConfiguration() // Arrange - Create a new symbol matcher with different preferences var configInstance = new ConfigurationInstance { - Settings = new Settings { DataProviderPreference = "COINGECKO,YAHOO" } + Settings = new Settings { RawDataProviderPreference = "COINGECKO,YAHOO" } }; var settingsMock = new Mock(); settingsMock.Setup(x => x.ConfigurationInstance).Returns(configInstance); diff --git a/GhostfolioAPI/GhostfolioSymbolMatcher.cs b/GhostfolioAPI/GhostfolioSymbolMatcher.cs index bf35cb336..5241deee8 100644 --- a/GhostfolioAPI/GhostfolioSymbolMatcher.cs +++ b/GhostfolioAPI/GhostfolioSymbolMatcher.cs @@ -20,7 +20,7 @@ public GhostfolioSymbolMatcher(IApplicationSettings settings, IApiWrapper apiWra this.apiWrapper = apiWrapper ?? throw new ArgumentNullException(nameof(apiWrapper)); this.memoryCache = memoryCache; - SortorderDataSources = [.. settings.ConfigurationInstance.Settings.DataProviderPreference.Split(',').Select(x => x.ToUpperInvariant())]; + SortorderDataSources = [.. settings.ConfigurationInstance.Settings.DataProviderPreference]; } public string DataSource => Datasource.GHOSTFOLIO; diff --git a/GhostfolioSidekick.UnitTests/AccountMaintainer/BalanceMaintainerTaskTests.cs b/GhostfolioSidekick.UnitTests/AccountMaintainer/BalanceMaintainerTaskTests.cs index 9b3bfc1f9..551241d66 100644 --- a/GhostfolioSidekick.UnitTests/AccountMaintainer/BalanceMaintainerTaskTests.cs +++ b/GhostfolioSidekick.UnitTests/AccountMaintainer/BalanceMaintainerTaskTests.cs @@ -1,4 +1,5 @@ using GhostfolioSidekick.AccountMaintainer; +using GhostfolioSidekick.Configuration; using GhostfolioSidekick.Database.Repository; using GhostfolioSidekick.Model; using Moq.EntityFrameworkCore; @@ -9,13 +10,23 @@ public class BalanceMaintainerTaskTests { private readonly Mock> _mockDbContextFactory; private readonly Mock _mockExchangeRateService; + private readonly Mock _mockApplicationSettings; private readonly BalanceMaintainerTask _balanceMaintainerTask; public BalanceMaintainerTaskTests() { _mockDbContextFactory = new Mock>(); _mockExchangeRateService = new Mock(); - _balanceMaintainerTask = new BalanceMaintainerTask(_mockDbContextFactory.Object, _mockExchangeRateService.Object); + _mockApplicationSettings = new Mock(); + + // Setup default configuration + var mockConfigurationInstance = new ConfigurationInstance + { + Settings = new Settings { RawCurrencies = "EUR;USD" } + }; + _mockApplicationSettings.Setup(x => x.ConfigurationInstance).Returns(mockConfigurationInstance); + + _balanceMaintainerTask = new BalanceMaintainerTask(_mockDbContextFactory.Object, _mockExchangeRateService.Object, _mockApplicationSettings.Object); } [Fact] diff --git a/GhostfolioSidekick.UnitTests/DisplayInformationTaskTests.cs b/GhostfolioSidekick.UnitTests/DisplayInformationTaskTests.cs index 67cdc1a96..159f28701 100644 --- a/GhostfolioSidekick.UnitTests/DisplayInformationTaskTests.cs +++ b/GhostfolioSidekick.UnitTests/DisplayInformationTaskTests.cs @@ -23,7 +23,7 @@ public void DoWork_ShouldPrintUsedSettings_WithMappings() { Settings = new Settings { - DataProviderPreference = "provider1", + RawDataProviderPreference = "provider1", DeleteUnusedSymbols = true }, Mappings = [new Mapping { MappingType = MappingType.Symbol, Source = "source1", Target = "target1" }] @@ -59,7 +59,7 @@ public void DoWork_ShouldPrintUsedSettings_WithoutMappings() { Settings = new Settings { - DataProviderPreference = "provider1", + RawDataProviderPreference = "provider1", DeleteUnusedSymbols = true }, }; diff --git a/GhostfolioSidekick/AccountMaintainer/BalanceCalculator.cs b/GhostfolioSidekick/AccountMaintainer/BalanceCalculator.cs index 953e7c743..44311e1e6 100644 --- a/GhostfolioSidekick/AccountMaintainer/BalanceCalculator.cs +++ b/GhostfolioSidekick/AccountMaintainer/BalanceCalculator.cs @@ -1,4 +1,4 @@ -using GhostfolioSidekick.Database.Repository; +using GhostfolioSidekick.Database.Repository; using GhostfolioSidekick.Model; using GhostfolioSidekick.Model.Accounts; using GhostfolioSidekick.Model.Activities; @@ -86,5 +86,20 @@ public async Task> Calculate( return balances; } + + public async Task> CalculateMultipleCurrencies( + List targetCurrencies, + IEnumerable activities) + { + var allBalances = new List(); + + foreach (var currency in targetCurrencies) + { + var balancesForCurrency = await Calculate(currency, activities); + allBalances.AddRange(balancesForCurrency); + } + + return allBalances; + } } } diff --git a/GhostfolioSidekick/AccountMaintainer/BalanceMaintainerTask.cs b/GhostfolioSidekick/AccountMaintainer/BalanceMaintainerTask.cs index 46e11e131..70a4c6573 100644 --- a/GhostfolioSidekick/AccountMaintainer/BalanceMaintainerTask.cs +++ b/GhostfolioSidekick/AccountMaintainer/BalanceMaintainerTask.cs @@ -1,4 +1,5 @@ -using GhostfolioSidekick.Database; +using GhostfolioSidekick.Configuration; +using GhostfolioSidekick.Database; using GhostfolioSidekick.Database.Repository; using GhostfolioSidekick.Model; using KellermanSoftware.CompareNetObjects; @@ -9,7 +10,8 @@ namespace GhostfolioSidekick.AccountMaintainer { internal class BalanceMaintainerTask( IDbContextFactory databaseContextFactory, - ICurrencyExchange exchangeRateService) : IScheduledWork + ICurrencyExchange exchangeRateService, + IApplicationSettings applicationSettings) : IScheduledWork { public TaskPriority Priority => TaskPriority.BalanceMaintainer; @@ -21,6 +23,15 @@ internal class BalanceMaintainerTask( public async Task DoWork(ILogger logger) { + // Parse currencies from application settings + var currencies = applicationSettings.ConfigurationInstance.Settings.Currencies.Select(Currency.GetCurrency).ToList(); + + if (currencies.Count == 0) + { + logger.LogWarning("No currencies configured in settings. Using EUR as default."); + currencies.Add(Currency.EUR); + } + List accountKeys; using (var databaseContext = await databaseContextFactory.CreateDbContextAsync()) { @@ -41,20 +52,20 @@ public async Task DoWork(ILogger logger) var orderedActivities = (activities ?? []).OrderBy(x => x.Date); var balanceCalculator = new BalanceCalculator(exchangeRateService); - var balances = await balanceCalculator.Calculate(Currency.EUR, orderedActivities); + var allBalances = await balanceCalculator.CalculateMultipleCurrencies(currencies, orderedActivities); var account = await databaseContext.Accounts.SingleAsync(x => x.Id == accountKey)!; var existingBalances = account!.Balance ?? []; var compareLogic = new CompareLogic() { Config = new ComparisonConfig { MaxDifferences = int.MaxValue, IgnoreObjectTypes = true, MembersToIgnore = ["Id"] } }; - ComparisonResult result = compareLogic.Compare(existingBalances.OrderBy(x => x.Date), balances.OrderBy(x => x.Date)); + ComparisonResult result = compareLogic.Compare(existingBalances.OrderBy(x => x.Date).ThenBy(x => x.Money.Currency.Symbol), allBalances.OrderBy(x => x.Date).ThenBy(x => x.Money.Currency.Symbol)); if (!result.AreEqual) { if (account.Balance != null) { account.Balance.Clear(); - account.Balance.AddRange(balances); + account.Balance.AddRange(allBalances); } await databaseContext.SaveChangesAsync(); } From 7b7be95aedb4b7f866d8b7e5f865cd63dd047578 Mon Sep 17 00:00:00 2001 From: Vincent <30174292+VibeNL@users.noreply.github.com> Date: Wed, 21 Jan 2026 10:41:00 +0100 Subject: [PATCH 3/7] Update Balance Calc to multiple currencies --- .../AccountMaintainer/BalanceCalculator.cs | 15 --------------- .../AccountMaintainer/BalanceMaintainerTask.cs | 10 ++++++++-- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/GhostfolioSidekick/AccountMaintainer/BalanceCalculator.cs b/GhostfolioSidekick/AccountMaintainer/BalanceCalculator.cs index 44311e1e6..1b1a4d2a0 100644 --- a/GhostfolioSidekick/AccountMaintainer/BalanceCalculator.cs +++ b/GhostfolioSidekick/AccountMaintainer/BalanceCalculator.cs @@ -86,20 +86,5 @@ public async Task> Calculate( return balances; } - - public async Task> CalculateMultipleCurrencies( - List targetCurrencies, - IEnumerable activities) - { - var allBalances = new List(); - - foreach (var currency in targetCurrencies) - { - var balancesForCurrency = await Calculate(currency, activities); - allBalances.AddRange(balancesForCurrency); - } - - return allBalances; - } } } diff --git a/GhostfolioSidekick/AccountMaintainer/BalanceMaintainerTask.cs b/GhostfolioSidekick/AccountMaintainer/BalanceMaintainerTask.cs index 70a4c6573..c0f292e7d 100644 --- a/GhostfolioSidekick/AccountMaintainer/BalanceMaintainerTask.cs +++ b/GhostfolioSidekick/AccountMaintainer/BalanceMaintainerTask.cs @@ -2,6 +2,7 @@ using GhostfolioSidekick.Database; using GhostfolioSidekick.Database.Repository; using GhostfolioSidekick.Model; +using GhostfolioSidekick.Model.Accounts; using KellermanSoftware.CompareNetObjects; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -25,7 +26,7 @@ public async Task DoWork(ILogger logger) { // Parse currencies from application settings var currencies = applicationSettings.ConfigurationInstance.Settings.Currencies.Select(Currency.GetCurrency).ToList(); - + if (currencies.Count == 0) { logger.LogWarning("No currencies configured in settings. Using EUR as default."); @@ -52,7 +53,12 @@ public async Task DoWork(ILogger logger) var orderedActivities = (activities ?? []).OrderBy(x => x.Date); var balanceCalculator = new BalanceCalculator(exchangeRateService); - var allBalances = await balanceCalculator.CalculateMultipleCurrencies(currencies, orderedActivities); + + List allBalances = []; + foreach (var currency in currencies) + { + allBalances.AddRange(await balanceCalculator.Calculate(currency, orderedActivities)); + } var account = await databaseContext.Accounts.SingleAsync(x => x.Id == accountKey)!; var existingBalances = account!.Balance ?? []; From 73e710cda7488dce969567349403c3364fcbe709 Mon Sep 17 00:00:00 2001 From: Vincent <30174292+VibeNL@users.noreply.github.com> Date: Wed, 21 Jan 2026 10:49:21 +0100 Subject: [PATCH 4/7] 1 --- Database.UnitTests/DatabaseContextTests.cs | 18 +++++++------- .../HoldingAggregatedTypeConfiguration.cs | 20 ++++------------ Model/Performance/CalculatedSnapshot.cs | 24 ++++++++++--------- 3 files changed, 28 insertions(+), 34 deletions(-) diff --git a/Database.UnitTests/DatabaseContextTests.cs b/Database.UnitTests/DatabaseContextTests.cs index af9d1e280..aac56a282 100644 --- a/Database.UnitTests/DatabaseContextTests.cs +++ b/Database.UnitTests/DatabaseContextTests.cs @@ -59,10 +59,11 @@ public async Task CanCreateDbContextWithCalculatedSnapshotConfiguration() 0, 0, DateOnly.FromDateTime(DateTime.Today), 100m, - new Money(Currency.USD, 50m), - new Money(Currency.USD, 55m), - new Money(Currency.USD, 5000m), - new Money(Currency.USD, 5500m) + Currency.USD, + 50m, + 55m, + 5000m, + 5500m ) } }; @@ -81,10 +82,11 @@ public async Task CanCreateDbContextWithCalculatedSnapshotConfiguration() var snapshot = retrieved.CalculatedSnapshots.First(); snapshot.Date.Should().Be(DateOnly.FromDateTime(DateTime.Today)); snapshot.Quantity.Should().Be(100m); - snapshot.AverageCostPrice.Amount.Should().Be(50m); - snapshot.CurrentUnitPrice.Amount.Should().Be(55m); - snapshot.TotalInvested.Amount.Should().Be(5000m); - snapshot.TotalValue.Amount.Should().Be(5500m); + snapshot.Currency.Should().Be(Currency.USD); + snapshot.AverageCostPrice.Should().Be(50m); + snapshot.CurrentUnitPrice.Should().Be(55m); + snapshot.TotalInvested.Should().Be(5000m); + snapshot.TotalValue.Should().Be(5500m); } } } diff --git a/Database/TypeConfigurations/HoldingAggregatedTypeConfiguration.cs b/Database/TypeConfigurations/HoldingAggregatedTypeConfiguration.cs index 1957339de..d1db2b901 100644 --- a/Database/TypeConfigurations/HoldingAggregatedTypeConfiguration.cs +++ b/Database/TypeConfigurations/HoldingAggregatedTypeConfiguration.cs @@ -64,10 +64,11 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.Quantity).IsRequired(); // Configure Money complex properties - MapMoney(builder, x => x.AverageCostPrice, nameof(CalculatedSnapshot.AverageCostPrice)); - MapMoney(builder, x => x.CurrentUnitPrice, nameof(CalculatedSnapshot.CurrentUnitPrice)); - MapMoney(builder, x => x.TotalInvested, nameof(CalculatedSnapshot.TotalInvested)); - MapMoney(builder, x => x.TotalValue, nameof(CalculatedSnapshot.TotalValue)); + builder.Property(x => x.Currency).IsRequired(); + builder.Property(x => x.AverageCostPrice).IsRequired(); + builder.Property(x => x.CurrentUnitPrice).IsRequired(); + builder.Property(x => x.TotalInvested).IsRequired(); + builder.Property(x => x.TotalValue).IsRequired(); // Indexes builder.HasIndex(x => new { x.Date }); @@ -76,16 +77,5 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(x => new { x.HoldingAggregatedId, x.Date }); builder.HasIndex(x => new { x.Date }); } - - private static void MapMoney(EntityTypeBuilder builder, Expression> navigationExpression, string name) where TEntity : class - { - // Cast to nullable Money to satisfy EF Core's ComplexProperty method - var nullableExpression = Expression.Lambda>( - navigationExpression.Body, - navigationExpression.Parameters); - - builder.ComplexProperty(nullableExpression).IsRequired().Property(x => x!.Amount).HasColumnName(name); - builder.ComplexProperty(nullableExpression).IsRequired().ComplexProperty(x => x!.Currency).Property(x => x.Symbol).HasColumnName("Currency" + name); - } } } \ No newline at end of file diff --git a/Model/Performance/CalculatedSnapshot.cs b/Model/Performance/CalculatedSnapshot.cs index 849e2a078..6522a5767 100644 --- a/Model/Performance/CalculatedSnapshot.cs +++ b/Model/Performance/CalculatedSnapshot.cs @@ -7,10 +7,11 @@ public class CalculatedSnapshot public long HoldingAggregatedId { get; set; } // Foreign key to Holding, if needed public DateOnly Date { get; set; } public decimal Quantity { get; set; } - public Money AverageCostPrice { get; set; } = Money.Zero(Currency.USD); - public Money CurrentUnitPrice { get; set; } = Money.Zero(Currency.USD); - public Money TotalInvested { get; set; } = Money.Zero(Currency.USD); - public Money TotalValue { get; set; } = Money.Zero(Currency.USD); + public Currency Currency { get; set; } = Currency.USD; + public decimal AverageCostPrice { get; set; } + public decimal CurrentUnitPrice { get; set; } + public decimal TotalInvested { get; set; } + public decimal TotalValue { get; set; } // Parameterless constructor for EF Core public CalculatedSnapshot() @@ -18,14 +19,15 @@ public CalculatedSnapshot() } public CalculatedSnapshot( - long id, + long id, int accountId, - DateOnly date, + DateOnly date, decimal quantity, - Money averageCostPrice, - Money currentUnitPrice, - Money totalInvested, - Money totalValue) + Currency currency, + decimal averageCostPrice, + decimal currentUnitPrice, + decimal totalInvested, + decimal totalValue) { Id = id; AccountId = accountId; @@ -49,7 +51,7 @@ public CalculatedSnapshot(CalculatedSnapshot original) AccountId = original.AccountId; } - public static CalculatedSnapshot Empty(Currency currency, int accountId) => new(0, accountId, DateOnly.MinValue, 0, Money.Zero(currency), Money.Zero(currency), Money.Zero(currency), Money.Zero(currency)); + public static CalculatedSnapshot Empty(Currency currency, int accountId) => new(0, accountId, DateOnly.MinValue, 0, Currency.EUR, 0, 0, 0, 0); public override string ToString() { From 930b4f55ffde6038a9fb1a4b8e0b6c0e12af51f2 Mon Sep 17 00:00:00 2001 From: Vincent <30174292+VibeNL@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:49:38 +0100 Subject: [PATCH 5/7] 1 --- ...pportMultiplePrimaryCurrencies.Designer.cs | 1570 +++++++++++++++++ ...134022_SupportMultiplePrimaryCurrencies.cs | 147 ++ .../DatabaseContextModelSnapshot.cs | 172 +- .../HoldingAggregatedTypeConfiguration.cs | 3 +- .../Performance/PerformanceTaskTests.cs | 36 +- .../HoldingPerformanceCalculatorTests.cs | 21 +- .../HoldingPerformanceCalculator.cs | 63 +- 7 files changed, 1803 insertions(+), 209 deletions(-) create mode 100644 Database/Migrations/20260121134022_SupportMultiplePrimaryCurrencies.Designer.cs create mode 100644 Database/Migrations/20260121134022_SupportMultiplePrimaryCurrencies.cs diff --git a/Database/Migrations/20260121134022_SupportMultiplePrimaryCurrencies.Designer.cs b/Database/Migrations/20260121134022_SupportMultiplePrimaryCurrencies.Designer.cs new file mode 100644 index 000000000..fb9fbcda0 --- /dev/null +++ b/Database/Migrations/20260121134022_SupportMultiplePrimaryCurrencies.Designer.cs @@ -0,0 +1,1570 @@ +// +using System; +using System.Collections.Generic; +using GhostfolioSidekick.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace GhostfolioSidekick.Database.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20260121134022_SupportMultiplePrimaryCurrencies")] + partial class SupportMultiplePrimaryCurrencies + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true); + + modelBuilder.Entity("GhostfolioSidekick.Database.TypeConfigurations.PartialSymbolIdentifierActivity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivityId") + .HasColumnType("INTEGER"); + + b.Property("PartialSymbolIdentifierId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ActivityId"); + + b.HasIndex("PartialSymbolIdentifierId"); + + b.ToTable("PartialSymbolIdentifierActivity"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlatformId") + .HasColumnType("INTEGER"); + + b.Property("SyncActivities") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("SyncBalance") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("PlatformId"); + + b.ToTable("Accounts", (string)null); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Accounts.Balance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountId") + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.ComplexProperty(typeof(Dictionary), "Money", "GhostfolioSidekick.Model.Accounts.Balance.Money#Money", b1 => + { + b1.IsRequired(); + + b1.Property("Amount") + .HasColumnType("TEXT") + .HasColumnName("Amount"); + + b1.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Accounts.Balance.Money#Money.Currency#Currency", b2 => + { + b2.IsRequired(); + + b2.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Currency"); + }); + }); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "Date") + .IsUnique(); + + b.ToTable("Balances", (string)null); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Accounts.Platform", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Platforms"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Activity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountId") + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("TEXT"); + + b.Property("HoldingId") + .HasColumnType("INTEGER"); + + b.Property("SortingPriority") + .HasColumnType("INTEGER"); + + b.Property("TransactionId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Date"); + + b.HasIndex("HoldingId"); + + b.ToTable("Activities", (string)null); + + b.HasDiscriminator("Discriminator").HasValue("Activity"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.CalculatedPriceTrace", b => + { + b.Property("ID") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasAnnotation("Key", 0); + + b.Property("ActivityId") + .HasColumnType("INTEGER"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("TEXT"); + + b.ComplexProperty(typeof(Dictionary), "NewPrice", "GhostfolioSidekick.Model.Activities.CalculatedPriceTrace.NewPrice#Money", b1 => + { + b1.IsRequired(); + + b1.Property("Amount") + .HasColumnType("TEXT") + .HasColumnName("NewPrice"); + + b1.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Activities.CalculatedPriceTrace.NewPrice#Money.Currency#Currency", b2 => + { + b2.IsRequired(); + + b2.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("CurrencyNewPrice"); + }); + }); + + b.HasKey("ID"); + + b.HasIndex("ActivityId"); + + b.ToTable("CalculatedPriceTrace", (string)null); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.PartialSymbolIdentifier", b => + { + b.Property("ID") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasAnnotation("Key", 0); + + b.Property("AllowedAssetClasses") + .HasColumnType("TEXT"); + + b.Property("AllowedAssetSubClasses") + .HasColumnType("TEXT"); + + b.Property("Identifier") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ID"); + + b.HasIndex("Identifier", "AllowedAssetClasses", "AllowedAssetSubClasses") + .IsUnique() + .HasDatabaseName("IX_PartialSymbolIdentifiers_Identifier_AllowedAssetClass_AllowedAssetSubClass"); + + b.ToTable("PartialSymbolIdentifiers", (string)null); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.MoneyLists.BuyActivityFee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivityId") + .HasColumnType("INTEGER"); + + b.ComplexProperty(typeof(Dictionary), "Money", "GhostfolioSidekick.Model.Activities.Types.MoneyLists.BuyActivityFee.Money#Money", b1 => + { + b1.IsRequired(); + + b1.Property("Amount") + .HasColumnType("TEXT") + .HasColumnName("Money"); + + b1.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Activities.Types.MoneyLists.BuyActivityFee.Money#Money.Currency#Currency", b2 => + { + b2.IsRequired(); + + b2.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("CurrencyMoney"); + }); + }); + + b.HasKey("Id"); + + b.HasIndex("ActivityId"); + + b.ToTable("BuyActivityFees", (string)null); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.MoneyLists.BuyActivityTax", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivityId") + .HasColumnType("INTEGER"); + + b.ComplexProperty(typeof(Dictionary), "Money", "GhostfolioSidekick.Model.Activities.Types.MoneyLists.BuyActivityTax.Money#Money", b1 => + { + b1.IsRequired(); + + b1.Property("Amount") + .HasColumnType("TEXT") + .HasColumnName("Money"); + + b1.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Activities.Types.MoneyLists.BuyActivityTax.Money#Money.Currency#Currency", b2 => + { + b2.IsRequired(); + + b2.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("CurrencyMoney"); + }); + }); + + b.HasKey("Id"); + + b.HasIndex("ActivityId"); + + b.ToTable("BuyActivityTaxes", (string)null); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.MoneyLists.DividendActivityFee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivityId") + .HasColumnType("INTEGER"); + + b.ComplexProperty(typeof(Dictionary), "Money", "GhostfolioSidekick.Model.Activities.Types.MoneyLists.DividendActivityFee.Money#Money", b1 => + { + b1.IsRequired(); + + b1.Property("Amount") + .HasColumnType("TEXT") + .HasColumnName("Money"); + + b1.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Activities.Types.MoneyLists.DividendActivityFee.Money#Money.Currency#Currency", b2 => + { + b2.IsRequired(); + + b2.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("CurrencyMoney"); + }); + }); + + b.HasKey("Id"); + + b.HasIndex("ActivityId"); + + b.ToTable("DividendActivityFees", (string)null); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.MoneyLists.DividendActivityTax", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivityId") + .HasColumnType("INTEGER"); + + b.ComplexProperty(typeof(Dictionary), "Money", "GhostfolioSidekick.Model.Activities.Types.MoneyLists.DividendActivityTax.Money#Money", b1 => + { + b1.IsRequired(); + + b1.Property("Amount") + .HasColumnType("TEXT") + .HasColumnName("Money"); + + b1.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Activities.Types.MoneyLists.DividendActivityTax.Money#Money.Currency#Currency", b2 => + { + b2.IsRequired(); + + b2.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("CurrencyMoney"); + }); + }); + + b.HasKey("Id"); + + b.HasIndex("ActivityId"); + + b.ToTable("DividendActivityTaxes", (string)null); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.MoneyLists.ReceiveActivityFee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivityId") + .HasColumnType("INTEGER"); + + b.ComplexProperty(typeof(Dictionary), "Money", "GhostfolioSidekick.Model.Activities.Types.MoneyLists.ReceiveActivityFee.Money#Money", b1 => + { + b1.IsRequired(); + + b1.Property("Amount") + .HasColumnType("TEXT") + .HasColumnName("Money"); + + b1.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Activities.Types.MoneyLists.ReceiveActivityFee.Money#Money.Currency#Currency", b2 => + { + b2.IsRequired(); + + b2.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("CurrencyMoney"); + }); + }); + + b.HasKey("Id"); + + b.HasIndex("ActivityId"); + + b.ToTable("ReceiveActivityFees", (string)null); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.MoneyLists.SellActivityFee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivityId") + .HasColumnType("INTEGER"); + + b.ComplexProperty(typeof(Dictionary), "Money", "GhostfolioSidekick.Model.Activities.Types.MoneyLists.SellActivityFee.Money#Money", b1 => + { + b1.IsRequired(); + + b1.Property("Amount") + .HasColumnType("TEXT") + .HasColumnName("Money"); + + b1.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Activities.Types.MoneyLists.SellActivityFee.Money#Money.Currency#Currency", b2 => + { + b2.IsRequired(); + + b2.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("CurrencyMoney"); + }); + }); + + b.HasKey("Id"); + + b.HasIndex("ActivityId"); + + b.ToTable("SellActivityFees", (string)null); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.MoneyLists.SellActivityTax", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivityId") + .HasColumnType("INTEGER"); + + b.ComplexProperty(typeof(Dictionary), "Money", "GhostfolioSidekick.Model.Activities.Types.MoneyLists.SellActivityTax.Money#Money", b1 => + { + b1.IsRequired(); + + b1.Property("Amount") + .HasColumnType("TEXT") + .HasColumnName("Money"); + + b1.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Activities.Types.MoneyLists.SellActivityTax.Money#Money.Currency#Currency", b2 => + { + b2.IsRequired(); + + b2.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("CurrencyMoney"); + }); + }); + + b.HasKey("Id"); + + b.HasIndex("ActivityId"); + + b.ToTable("SellActivityTaxes", (string)null); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.MoneyLists.SendActivityFee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivityId") + .HasColumnType("INTEGER"); + + b.ComplexProperty(typeof(Dictionary), "Money", "GhostfolioSidekick.Model.Activities.Types.MoneyLists.SendActivityFee.Money#Money", b1 => + { + b1.IsRequired(); + + b1.Property("Amount") + .HasColumnType("TEXT") + .HasColumnName("Money"); + + b1.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Activities.Types.MoneyLists.SendActivityFee.Money#Money.Currency#Currency", b2 => + { + b2.IsRequired(); + + b2.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("CurrencyMoney"); + }); + }); + + b.HasKey("Id"); + + b.HasIndex("ActivityId"); + + b.ToTable("SendActivityFees", (string)null); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Holding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PartialSymbolIdentifiers") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("PartialSymbolIdentifiers"); + + b.HasKey("Id"); + + b.ToTable("Holdings", (string)null); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Market.CurrencyExchangeRate", b => + { + b.Property("ID") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasAnnotation("Key", 0); + + b.Property("CurrencyExchangeProfileID") + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("Date"); + + b.Property("TradingVolume") + .HasColumnType("TEXT") + .HasColumnName("TradingVolume"); + + b.ComplexProperty(typeof(Dictionary), "Close", "GhostfolioSidekick.Model.Market.CurrencyExchangeRate.Close#Money", b1 => + { + b1.IsRequired(); + + b1.Property("Amount") + .HasColumnType("TEXT") + .HasColumnName("Close"); + + b1.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Market.CurrencyExchangeRate.Close#Money.Currency#Currency", b2 => + { + b2.IsRequired(); + + b2.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("CurrencyClose"); + }); + }); + + b.ComplexProperty(typeof(Dictionary), "High", "GhostfolioSidekick.Model.Market.CurrencyExchangeRate.High#Money", b1 => + { + b1.IsRequired(); + + b1.Property("Amount") + .HasColumnType("TEXT") + .HasColumnName("High"); + + b1.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Market.CurrencyExchangeRate.High#Money.Currency#Currency", b2 => + { + b2.IsRequired(); + + b2.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("CurrencyHigh"); + }); + }); + + b.ComplexProperty(typeof(Dictionary), "Low", "GhostfolioSidekick.Model.Market.CurrencyExchangeRate.Low#Money", b1 => + { + b1.IsRequired(); + + b1.Property("Amount") + .HasColumnType("TEXT") + .HasColumnName("Low"); + + b1.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Market.CurrencyExchangeRate.Low#Money.Currency#Currency", b2 => + { + b2.IsRequired(); + + b2.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("CurrencyLow"); + }); + }); + + b.ComplexProperty(typeof(Dictionary), "Open", "GhostfolioSidekick.Model.Market.CurrencyExchangeRate.Open#Money", b1 => + { + b1.IsRequired(); + + b1.Property("Amount") + .HasColumnType("TEXT") + .HasColumnName("Open"); + + b1.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Market.CurrencyExchangeRate.Open#Money.Currency#Currency", b2 => + { + b2.IsRequired(); + + b2.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("CurrencyOpen"); + }); + }); + + b.HasKey("ID"); + + b.HasIndex("CurrencyExchangeProfileID", "Date") + .IsUnique() + .HasDatabaseName("IX_CurrencyExchangeRate_CurrencyExchangeProfileID_Date"); + + b.ToTable("CurrencyExchangeRate", (string)null); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Market.Dividend", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DividendState") + .HasColumnType("INTEGER"); + + b.Property("DividendType") + .HasColumnType("INTEGER"); + + b.Property("ExDividendDate") + .HasColumnType("TEXT"); + + b.Property("PaymentDate") + .HasColumnType("TEXT"); + + b.Property("SymbolProfileDataSource") + .HasColumnType("TEXT"); + + b.Property("SymbolProfileSymbol") + .HasColumnType("TEXT"); + + b.ComplexProperty(typeof(Dictionary), "Amount", "GhostfolioSidekick.Model.Market.Dividend.Amount#Money", b1 => + { + b1.IsRequired(); + + b1.Property("Amount") + .HasColumnType("TEXT") + .HasColumnName("Amount"); + + b1.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Market.Dividend.Amount#Money.Currency#Currency", b2 => + { + b2.IsRequired(); + + b2.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("CurrencyAmount"); + }); + }); + + b.HasKey("Id"); + + b.HasIndex("SymbolProfileSymbol", "SymbolProfileDataSource"); + + b.ToTable("Dividends"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Market.MarketData", b => + { + b.Property("ID") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasAnnotation("Key", 0); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("Date"); + + b.Property("IsGenerated") + .HasColumnType("INTEGER"); + + b.Property("SymbolProfileDataSource") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SymbolProfileSymbol") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TradingVolume") + .HasColumnType("TEXT") + .HasColumnName("TradingVolume"); + + b.ComplexProperty(typeof(Dictionary), "Close", "GhostfolioSidekick.Model.Market.MarketData.Close#Money", b1 => + { + b1.IsRequired(); + + b1.Property("Amount") + .HasColumnType("TEXT") + .HasColumnName("Close"); + + b1.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Market.MarketData.Close#Money.Currency#Currency", b2 => + { + b2.IsRequired(); + + b2.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("CurrencyClose"); + }); + }); + + b.ComplexProperty(typeof(Dictionary), "High", "GhostfolioSidekick.Model.Market.MarketData.High#Money", b1 => + { + b1.IsRequired(); + + b1.Property("Amount") + .HasColumnType("TEXT") + .HasColumnName("High"); + + b1.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Market.MarketData.High#Money.Currency#Currency", b2 => + { + b2.IsRequired(); + + b2.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("CurrencyHigh"); + }); + }); + + b.ComplexProperty(typeof(Dictionary), "Low", "GhostfolioSidekick.Model.Market.MarketData.Low#Money", b1 => + { + b1.IsRequired(); + + b1.Property("Amount") + .HasColumnType("TEXT") + .HasColumnName("Low"); + + b1.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Market.MarketData.Low#Money.Currency#Currency", b2 => + { + b2.IsRequired(); + + b2.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("CurrencyLow"); + }); + }); + + b.ComplexProperty(typeof(Dictionary), "Open", "GhostfolioSidekick.Model.Market.MarketData.Open#Money", b1 => + { + b1.IsRequired(); + + b1.Property("Amount") + .HasColumnType("TEXT") + .HasColumnName("Open"); + + b1.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Market.MarketData.Open#Money.Currency#Currency", b2 => + { + b2.IsRequired(); + + b2.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("CurrencyOpen"); + }); + }); + + b.HasKey("ID"); + + b.HasIndex("SymbolProfileSymbol", "SymbolProfileDataSource"); + + b.HasIndex("SymbolProfileDataSource", "SymbolProfileSymbol", "Date") + .IsUnique() + .HasDatabaseName("IX_MarketData_SymbolProfileDataSource_SymbolProfileSymbol_Date"); + + b.ToTable("MarketData", (string)null); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Market.StockSplit", b => + { + b.Property("ID") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasAnnotation("Key", 0); + + b.Property("AfterSplit") + .HasColumnType("TEXT"); + + b.Property("BeforeSplit") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("SymbolProfileDataSource") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SymbolProfileSymbol") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ID"); + + b.HasIndex("SymbolProfileSymbol", "SymbolProfileDataSource"); + + b.ToTable("StockSplits", (string)null); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Performance.CalculatedSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasAnnotation("Key", 0); + + b.Property("AccountId") + .HasColumnType("INTEGER"); + + b.Property("AverageCostPrice") + .HasColumnType("TEXT"); + + b.Property("CurrentUnitPrice") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("HoldingAggregatedId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("TEXT"); + + b.Property("TotalInvested") + .HasColumnType("TEXT"); + + b.Property("TotalValue") + .HasColumnType("TEXT"); + + b.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Performance.CalculatedSnapshot.Currency#Currency", b1 => + { + b1.IsRequired(); + + b1.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Currency"); + }); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("AccountId", "Date"); + + b.HasIndex("HoldingAggregatedId", "Date"); + + b.HasIndex("HoldingAggregatedId", "AccountId", "Date") + .IsUnique(); + + b.ToTable("CalculatedSnapshots", (string)null); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Performance.HoldingAggregated", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivityCount") + .HasColumnType("INTEGER"); + + b.Property("AssetClass") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AssetSubClass") + .HasColumnType("TEXT"); + + b.Property("CountryWeight") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DataSource") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("SectorWeights") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("HoldingAggregateds", (string)null); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Symbols.CurrencyExchangeProfile", b => + { + b.Property("ID") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("SourceCurrency") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("SourceCurrency"); + + b.Property("TargetCurrency") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("TargetCurrency"); + + b.HasKey("ID"); + + b.HasIndex("SourceCurrency", "TargetCurrency") + .IsUnique(); + + b.ToTable("CurrencyExchangeProfile", (string)null); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Symbols.SymbolProfile", b => + { + b.Property("Symbol") + .HasColumnType("TEXT"); + + b.Property("DataSource") + .HasColumnType("TEXT"); + + b.Property("AssetClass") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AssetSubClass") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("CountryWeight") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("HoldingId") + .HasColumnType("INTEGER"); + + b.Property("ISIN") + .HasColumnType("TEXT"); + + b.Property("Identifiers") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("SectorWeights") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WebsiteUrl") + .HasColumnType("TEXT"); + + b.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Symbols.SymbolProfile.Currency#Currency", b1 => + { + b1.IsRequired(); + + b1.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Currency"); + }); + + b.HasKey("Symbol", "DataSource"); + + b.HasIndex("HoldingId"); + + b.ToTable("SymbolProfiles", (string)null); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Tasks.TaskRun", b => + { + b.Property("Type") + .HasColumnType("TEXT"); + + b.Property("InProgress") + .HasColumnType("INTEGER"); + + b.Property("LastException") + .HasColumnType("TEXT"); + + b.Property("LastUpdate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NextSchedule") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("Scheduled") + .HasColumnType("INTEGER"); + + b.HasKey("Type"); + + b.ToTable("TaskRuns", (string)null); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Tasks.TaskRunLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TaskRunType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TaskRunType"); + + b.ToTable("TaskRunLogs", (string)null); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.ActivityWithAmount", b => + { + b.HasBaseType("GhostfolioSidekick.Model.Activities.Activity"); + + b.ComplexProperty(typeof(Dictionary), "Amount", "GhostfolioSidekick.Model.Activities.ActivityWithAmount.Amount#Money", b1 => + { + b1.IsRequired(); + + b1.Property("Amount") + .HasColumnType("TEXT") + .HasColumnName("Amount"); + + b1.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Activities.ActivityWithAmount.Amount#Money.Currency#Currency", b2 => + { + b2.IsRequired(); + + b2.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("CurrencyAmount"); + }); + }); + + b.HasDiscriminator().HasValue("ActivityWithAmount"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.ActivityWithQuantityAndUnitPrice", b => + { + b.HasBaseType("GhostfolioSidekick.Model.Activities.Activity"); + + b.Property("AdjustedQuantity") + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("TEXT"); + + b.ComplexProperty(typeof(Dictionary), "AdjustedUnitPrice", "GhostfolioSidekick.Model.Activities.ActivityWithQuantityAndUnitPrice.AdjustedUnitPrice#Money", b1 => + { + b1.IsRequired(); + + b1.Property("Amount") + .HasColumnType("TEXT") + .HasColumnName("AdjustedUnitPrice"); + + b1.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Activities.ActivityWithQuantityAndUnitPrice.AdjustedUnitPrice#Money.Currency#Currency", b2 => + { + b2.IsRequired(); + + b2.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("CurrencyAdjustedUnitPrice"); + }); + }); + + b.ComplexProperty(typeof(Dictionary), "TotalTransactionAmount", "GhostfolioSidekick.Model.Activities.ActivityWithQuantityAndUnitPrice.TotalTransactionAmount#Money", b1 => + { + b1.IsRequired(); + + b1.Property("Amount") + .HasColumnType("TEXT") + .HasColumnName("TotalTransactionAmount"); + + b1.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Activities.ActivityWithQuantityAndUnitPrice.TotalTransactionAmount#Money.Currency#Currency", b2 => + { + b2.IsRequired(); + + b2.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("CurrencyTotalTransactionAmount"); + }); + }); + + b.ComplexProperty(typeof(Dictionary), "UnitPrice", "GhostfolioSidekick.Model.Activities.ActivityWithQuantityAndUnitPrice.UnitPrice#Money", b1 => + { + b1.IsRequired(); + + b1.Property("Amount") + .HasColumnType("TEXT") + .HasColumnName("UnitPrice"); + + b1.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Activities.ActivityWithQuantityAndUnitPrice.UnitPrice#Money.Currency#Currency", b2 => + { + b2.IsRequired(); + + b2.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("CurrencyUnitPrice"); + }); + }); + + b.HasDiscriminator().HasValue("ActivityWithQuantityAndUnitPrice"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.CashDepositActivity", b => + { + b.HasBaseType("GhostfolioSidekick.Model.Activities.ActivityWithAmount"); + + b.HasDiscriminator().HasValue("CashDeposit"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.CashWithdrawalActivity", b => + { + b.HasBaseType("GhostfolioSidekick.Model.Activities.ActivityWithAmount"); + + b.HasDiscriminator().HasValue("CashWithdrawal"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.DividendActivity", b => + { + b.HasBaseType("GhostfolioSidekick.Model.Activities.ActivityWithAmount"); + + b.HasDiscriminator().HasValue("Dividend"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.FeeActivity", b => + { + b.HasBaseType("GhostfolioSidekick.Model.Activities.ActivityWithAmount"); + + b.HasDiscriminator().HasValue("Fee"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.GiftFiatActivity", b => + { + b.HasBaseType("GhostfolioSidekick.Model.Activities.ActivityWithAmount"); + + b.HasDiscriminator().HasValue("GiftFiat"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.InterestActivity", b => + { + b.HasBaseType("GhostfolioSidekick.Model.Activities.ActivityWithAmount"); + + b.HasDiscriminator().HasValue("Interest"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.KnownBalanceActivity", b => + { + b.HasBaseType("GhostfolioSidekick.Model.Activities.ActivityWithAmount"); + + b.HasDiscriminator().HasValue("KnownBalance"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.LiabilityActivity", b => + { + b.HasBaseType("GhostfolioSidekick.Model.Activities.ActivityWithAmount"); + + b.HasDiscriminator().HasValue("Liability"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.RepayBondActivity", b => + { + b.HasBaseType("GhostfolioSidekick.Model.Activities.ActivityWithAmount"); + + b.HasDiscriminator().HasValue("RepayBond"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.ValuableActivity", b => + { + b.HasBaseType("GhostfolioSidekick.Model.Activities.ActivityWithAmount"); + + b.HasDiscriminator().HasValue("Valuable"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.BuyActivity", b => + { + b.HasBaseType("GhostfolioSidekick.Model.Activities.ActivityWithQuantityAndUnitPrice"); + + b.HasDiscriminator().HasValue("Buy"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.GiftAssetActivity", b => + { + b.HasBaseType("GhostfolioSidekick.Model.Activities.ActivityWithQuantityAndUnitPrice"); + + b.HasDiscriminator().HasValue("GiftAsset"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.ReceiveActivity", b => + { + b.HasBaseType("GhostfolioSidekick.Model.Activities.ActivityWithQuantityAndUnitPrice"); + + b.HasDiscriminator().HasValue("Receive"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.SellActivity", b => + { + b.HasBaseType("GhostfolioSidekick.Model.Activities.ActivityWithQuantityAndUnitPrice"); + + b.HasDiscriminator().HasValue("Sell"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.SendActivity", b => + { + b.HasBaseType("GhostfolioSidekick.Model.Activities.ActivityWithQuantityAndUnitPrice"); + + b.HasDiscriminator().HasValue("Send"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.StakingRewardActivity", b => + { + b.HasBaseType("GhostfolioSidekick.Model.Activities.ActivityWithQuantityAndUnitPrice"); + + b.HasDiscriminator().HasValue("StakingReward"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Database.TypeConfigurations.PartialSymbolIdentifierActivity", b => + { + b.HasOne("GhostfolioSidekick.Model.Activities.ActivityWithQuantityAndUnitPrice", null) + .WithMany() + .HasForeignKey("ActivityId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("GhostfolioSidekick.Model.Activities.Types.DividendActivity", null) + .WithMany() + .HasForeignKey("ActivityId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("GhostfolioSidekick.Model.Activities.Types.LiabilityActivity", null) + .WithMany() + .HasForeignKey("ActivityId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("GhostfolioSidekick.Model.Activities.Types.RepayBondActivity", null) + .WithMany() + .HasForeignKey("ActivityId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("GhostfolioSidekick.Model.Activities.Types.ValuableActivity", null) + .WithMany() + .HasForeignKey("ActivityId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("GhostfolioSidekick.Model.Activities.PartialSymbolIdentifier", null) + .WithMany() + .HasForeignKey("PartialSymbolIdentifierId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Accounts.Account", b => + { + b.HasOne("GhostfolioSidekick.Model.Accounts.Platform", "Platform") + .WithMany() + .HasForeignKey("PlatformId"); + + b.Navigation("Platform"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Accounts.Balance", b => + { + b.HasOne("GhostfolioSidekick.Model.Accounts.Account", null) + .WithMany("Balance") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Activity", b => + { + b.HasOne("GhostfolioSidekick.Model.Accounts.Account", "Account") + .WithMany("Activities") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GhostfolioSidekick.Model.Holding", "Holding") + .WithMany("Activities") + .HasForeignKey("HoldingId"); + + b.Navigation("Account"); + + b.Navigation("Holding"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.CalculatedPriceTrace", b => + { + b.HasOne("GhostfolioSidekick.Model.Activities.ActivityWithQuantityAndUnitPrice", null) + .WithMany("AdjustedUnitPriceSource") + .HasForeignKey("ActivityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.MoneyLists.BuyActivityFee", b => + { + b.HasOne("GhostfolioSidekick.Model.Activities.Types.BuyActivity", null) + .WithMany("Fees") + .HasForeignKey("ActivityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.MoneyLists.BuyActivityTax", b => + { + b.HasOne("GhostfolioSidekick.Model.Activities.Types.BuyActivity", null) + .WithMany("Taxes") + .HasForeignKey("ActivityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.MoneyLists.DividendActivityFee", b => + { + b.HasOne("GhostfolioSidekick.Model.Activities.Types.DividendActivity", null) + .WithMany("Fees") + .HasForeignKey("ActivityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.MoneyLists.DividendActivityTax", b => + { + b.HasOne("GhostfolioSidekick.Model.Activities.Types.DividendActivity", null) + .WithMany("Taxes") + .HasForeignKey("ActivityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.MoneyLists.ReceiveActivityFee", b => + { + b.HasOne("GhostfolioSidekick.Model.Activities.Types.ReceiveActivity", null) + .WithMany("Fees") + .HasForeignKey("ActivityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.MoneyLists.SellActivityFee", b => + { + b.HasOne("GhostfolioSidekick.Model.Activities.Types.SellActivity", null) + .WithMany("Fees") + .HasForeignKey("ActivityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.MoneyLists.SellActivityTax", b => + { + b.HasOne("GhostfolioSidekick.Model.Activities.Types.SellActivity", null) + .WithMany("Taxes") + .HasForeignKey("ActivityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.MoneyLists.SendActivityFee", b => + { + b.HasOne("GhostfolioSidekick.Model.Activities.Types.SendActivity", null) + .WithMany("Fees") + .HasForeignKey("ActivityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Market.CurrencyExchangeRate", b => + { + b.HasOne("GhostfolioSidekick.Model.Symbols.CurrencyExchangeProfile", null) + .WithMany("Rates") + .HasForeignKey("CurrencyExchangeProfileID") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Market.Dividend", b => + { + b.HasOne("GhostfolioSidekick.Model.Symbols.SymbolProfile", null) + .WithMany("Dividends") + .HasForeignKey("SymbolProfileSymbol", "SymbolProfileDataSource"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Market.MarketData", b => + { + b.HasOne("GhostfolioSidekick.Model.Symbols.SymbolProfile", null) + .WithMany("MarketData") + .HasForeignKey("SymbolProfileSymbol", "SymbolProfileDataSource") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Market.StockSplit", b => + { + b.HasOne("GhostfolioSidekick.Model.Symbols.SymbolProfile", null) + .WithMany("StockSplits") + .HasForeignKey("SymbolProfileSymbol", "SymbolProfileDataSource") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Performance.CalculatedSnapshot", b => + { + b.HasOne("GhostfolioSidekick.Model.Performance.HoldingAggregated", null) + .WithMany("CalculatedSnapshots") + .HasForeignKey("HoldingAggregatedId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Symbols.SymbolProfile", b => + { + b.HasOne("GhostfolioSidekick.Model.Holding", null) + .WithMany("SymbolProfiles") + .HasForeignKey("HoldingId"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Tasks.TaskRunLog", b => + { + b.HasOne("GhostfolioSidekick.Model.Tasks.TaskRun", "TaskRun") + .WithMany("Logs") + .HasForeignKey("TaskRunType") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TaskRun"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Accounts.Account", b => + { + b.Navigation("Activities"); + + b.Navigation("Balance"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Holding", b => + { + b.Navigation("Activities"); + + b.Navigation("SymbolProfiles"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Performance.HoldingAggregated", b => + { + b.Navigation("CalculatedSnapshots"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Symbols.CurrencyExchangeProfile", b => + { + b.Navigation("Rates"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Symbols.SymbolProfile", b => + { + b.Navigation("Dividends"); + + b.Navigation("MarketData"); + + b.Navigation("StockSplits"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Tasks.TaskRun", b => + { + b.Navigation("Logs"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.ActivityWithQuantityAndUnitPrice", b => + { + b.Navigation("AdjustedUnitPriceSource"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.DividendActivity", b => + { + b.Navigation("Fees"); + + b.Navigation("Taxes"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.BuyActivity", b => + { + b.Navigation("Fees"); + + b.Navigation("Taxes"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.ReceiveActivity", b => + { + b.Navigation("Fees"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.SellActivity", b => + { + b.Navigation("Fees"); + + b.Navigation("Taxes"); + }); + + modelBuilder.Entity("GhostfolioSidekick.Model.Activities.Types.SendActivity", b => + { + b.Navigation("Fees"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Database/Migrations/20260121134022_SupportMultiplePrimaryCurrencies.cs b/Database/Migrations/20260121134022_SupportMultiplePrimaryCurrencies.cs new file mode 100644 index 000000000..c7ffbc5a6 --- /dev/null +++ b/Database/Migrations/20260121134022_SupportMultiplePrimaryCurrencies.cs @@ -0,0 +1,147 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GhostfolioSidekick.Database.Migrations +{ + /// + public partial class SupportMultiplePrimaryCurrencies : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BalancePrimaryCurrencies"); + + migrationBuilder.DropTable( + name: "CalculatedSnapshotPrimaryCurrencies"); + + migrationBuilder.DropColumn( + name: "CurrencyAverageCostPrice", + table: "CalculatedSnapshots"); + + migrationBuilder.DropColumn( + name: "CurrencyCurrentUnitPrice", + table: "CalculatedSnapshots"); + + migrationBuilder.DropColumn( + name: "CurrencyTotalInvested", + table: "CalculatedSnapshots"); + + migrationBuilder.RenameColumn( + name: "CurrencyTotalValue", + table: "CalculatedSnapshots", + newName: "Currency"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "Currency", + table: "CalculatedSnapshots", + newName: "CurrencyTotalValue"); + + migrationBuilder.AddColumn( + name: "CurrencyAverageCostPrice", + table: "CalculatedSnapshots", + type: "TEXT", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "CurrencyCurrentUnitPrice", + table: "CalculatedSnapshots", + type: "TEXT", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "CurrencyTotalInvested", + table: "CalculatedSnapshots", + type: "TEXT", + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateTable( + name: "BalancePrimaryCurrencies", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + AccountId = table.Column(type: "INTEGER", nullable: false), + Date = table.Column(type: "TEXT", nullable: false), + Money = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BalancePrimaryCurrencies", x => x.Id); + table.ForeignKey( + name: "FK_BalancePrimaryCurrencies_Accounts_AccountId", + column: x => x.AccountId, + principalTable: "Accounts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "CalculatedSnapshotPrimaryCurrencies", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + AccountId = table.Column(type: "INTEGER", nullable: false), + AverageCostPrice = table.Column(type: "TEXT", nullable: false), + CurrentUnitPrice = table.Column(type: "TEXT", nullable: false), + Date = table.Column(type: "TEXT", nullable: false), + HoldingAggregatedId = table.Column(type: "INTEGER", nullable: false), + Quantity = table.Column(type: "TEXT", nullable: false), + TotalInvested = table.Column(type: "TEXT", nullable: false), + TotalValue = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CalculatedSnapshotPrimaryCurrencies", x => x.Id); + table.ForeignKey( + name: "FK_CalculatedSnapshotPrimaryCurrencies_HoldingAggregateds_HoldingAggregatedId", + column: x => x.HoldingAggregatedId, + principalTable: "HoldingAggregateds", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_BalancePrimaryCurrencies_AccountId_Date", + table: "BalancePrimaryCurrencies", + columns: new[] { "AccountId", "Date" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_BalancePrimaryCurrencies_Date", + table: "BalancePrimaryCurrencies", + column: "Date"); + + migrationBuilder.CreateIndex( + name: "IX_CalculatedSnapshotPrimaryCurrencies_AccountId_Date", + table: "CalculatedSnapshotPrimaryCurrencies", + columns: new[] { "AccountId", "Date" }); + + migrationBuilder.CreateIndex( + name: "IX_CalculatedSnapshotPrimaryCurrencies_Date", + table: "CalculatedSnapshotPrimaryCurrencies", + column: "Date"); + + migrationBuilder.CreateIndex( + name: "IX_CalculatedSnapshotPrimaryCurrencies_HoldingAggregatedId_AccountId_Date", + table: "CalculatedSnapshotPrimaryCurrencies", + columns: new[] { "HoldingAggregatedId", "AccountId", "Date" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_CalculatedSnapshotPrimaryCurrencies_HoldingAggregatedId_Date", + table: "CalculatedSnapshotPrimaryCurrencies", + columns: new[] { "HoldingAggregatedId", "Date" }); + } + } +} diff --git a/Database/Migrations/DatabaseContextModelSnapshot.cs b/Database/Migrations/DatabaseContextModelSnapshot.cs index 1791c9e91..a0b01e2d5 100644 --- a/Database/Migrations/DatabaseContextModelSnapshot.cs +++ b/Database/Migrations/DatabaseContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("ProductVersion", "10.0.2") .HasAnnotation("Proxies:ChangeTracking", false) .HasAnnotation("Proxies:CheckEquality", false) .HasAnnotation("Proxies:LazyLoading", true); @@ -173,7 +173,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Activities", (string)null); - b.HasDiscriminator().HasValue("Activity"); + b.HasDiscriminator("Discriminator").HasValue("Activity"); b.UseTphMappingStrategy(); }); @@ -837,141 +837,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("StockSplits", (string)null); }); - modelBuilder.Entity("GhostfolioSidekick.Model.Performance.BalancePrimaryCurrency", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("AccountId") - .HasColumnType("INTEGER"); - - b.Property("Date") - .HasColumnType("TEXT"); - - b.Property("Money") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Date"); - - b.HasIndex("AccountId", "Date") - .IsUnique(); - - b.ToTable("BalancePrimaryCurrencies", (string)null); - }); - modelBuilder.Entity("GhostfolioSidekick.Model.Performance.CalculatedSnapshot", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasAnnotation("Key", 0); - - b.Property("AccountId") - .HasColumnType("INTEGER"); - - b.Property("Date") - .HasColumnType("TEXT"); - - b.Property("HoldingAggregatedId") - .HasColumnType("INTEGER"); - - b.Property("Quantity") - .HasColumnType("TEXT"); - - b.ComplexProperty(typeof(Dictionary), "AverageCostPrice", "GhostfolioSidekick.Model.Performance.CalculatedSnapshot.AverageCostPrice#Money", b1 => - { - b1.IsRequired(); - - b1.Property("Amount") - .HasColumnType("TEXT") - .HasColumnName("AverageCostPrice"); - - b1.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Performance.CalculatedSnapshot.AverageCostPrice#Money.Currency#Currency", b2 => - { - b2.IsRequired(); - - b2.Property("Symbol") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("CurrencyAverageCostPrice"); - }); - }); - - b.ComplexProperty(typeof(Dictionary), "CurrentUnitPrice", "GhostfolioSidekick.Model.Performance.CalculatedSnapshot.CurrentUnitPrice#Money", b1 => - { - b1.IsRequired(); - - b1.Property("Amount") - .HasColumnType("TEXT") - .HasColumnName("CurrentUnitPrice"); - - b1.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Performance.CalculatedSnapshot.CurrentUnitPrice#Money.Currency#Currency", b2 => - { - b2.IsRequired(); - - b2.Property("Symbol") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("CurrencyCurrentUnitPrice"); - }); - }); - - b.ComplexProperty(typeof(Dictionary), "TotalInvested", "GhostfolioSidekick.Model.Performance.CalculatedSnapshot.TotalInvested#Money", b1 => - { - b1.IsRequired(); - - b1.Property("Amount") - .HasColumnType("TEXT") - .HasColumnName("TotalInvested"); - - b1.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Performance.CalculatedSnapshot.TotalInvested#Money.Currency#Currency", b2 => - { - b2.IsRequired(); - - b2.Property("Symbol") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("CurrencyTotalInvested"); - }); - }); - - b.ComplexProperty(typeof(Dictionary), "TotalValue", "GhostfolioSidekick.Model.Performance.CalculatedSnapshot.TotalValue#Money", b1 => - { - b1.IsRequired(); - - b1.Property("Amount") - .HasColumnType("TEXT") - .HasColumnName("TotalValue"); - - b1.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Performance.CalculatedSnapshot.TotalValue#Money.Currency#Currency", b2 => - { - b2.IsRequired(); - - b2.Property("Symbol") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("CurrencyTotalValue"); - }); - }); - - b.HasKey("Id"); - - b.HasIndex("Date"); - - b.HasIndex("AccountId", "Date"); - - b.HasIndex("HoldingAggregatedId", "Date"); - - b.HasIndex("HoldingAggregatedId", "AccountId", "Date") - .IsUnique(); - - b.ToTable("CalculatedSnapshots", (string)null); - }); - - modelBuilder.Entity("GhostfolioSidekick.Model.Performance.CalculatedSnapshotPrimaryCurrency", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -1002,6 +868,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("TotalValue") .HasColumnType("TEXT"); + b.ComplexProperty(typeof(Dictionary), "Currency", "GhostfolioSidekick.Model.Performance.CalculatedSnapshot.Currency#Currency", b1 => + { + b1.IsRequired(); + + b1.Property("Symbol") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Currency"); + }); + b.HasKey("Id"); b.HasIndex("Date"); @@ -1013,7 +889,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("HoldingAggregatedId", "AccountId", "Date") .IsUnique(); - b.ToTable("CalculatedSnapshotPrimaryCurrencies", (string)null); + b.ToTable("CalculatedSnapshots", (string)null); }); modelBuilder.Entity("GhostfolioSidekick.Model.Performance.HoldingAggregated", b => @@ -1585,15 +1461,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); - modelBuilder.Entity("GhostfolioSidekick.Model.Performance.BalancePrimaryCurrency", b => - { - b.HasOne("GhostfolioSidekick.Model.Accounts.Account", null) - .WithMany("BalancePrimaryCurrency") - .HasForeignKey("AccountId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - modelBuilder.Entity("GhostfolioSidekick.Model.Performance.CalculatedSnapshot", b => { b.HasOne("GhostfolioSidekick.Model.Performance.HoldingAggregated", null) @@ -1603,15 +1470,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); - modelBuilder.Entity("GhostfolioSidekick.Model.Performance.CalculatedSnapshotPrimaryCurrency", b => - { - b.HasOne("GhostfolioSidekick.Model.Performance.HoldingAggregated", null) - .WithMany("CalculatedSnapshotsPrimaryCurrency") - .HasForeignKey("HoldingAggregatedId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - modelBuilder.Entity("GhostfolioSidekick.Model.Symbols.SymbolProfile", b => { b.HasOne("GhostfolioSidekick.Model.Holding", null) @@ -1635,8 +1493,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Activities"); b.Navigation("Balance"); - - b.Navigation("BalancePrimaryCurrency"); }); modelBuilder.Entity("GhostfolioSidekick.Model.Holding", b => @@ -1649,8 +1505,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("GhostfolioSidekick.Model.Performance.HoldingAggregated", b => { b.Navigation("CalculatedSnapshots"); - - b.Navigation("CalculatedSnapshotsPrimaryCurrency"); }); modelBuilder.Entity("GhostfolioSidekick.Model.Symbols.CurrencyExchangeProfile", b => diff --git a/Database/TypeConfigurations/HoldingAggregatedTypeConfiguration.cs b/Database/TypeConfigurations/HoldingAggregatedTypeConfiguration.cs index d1db2b901..ed56ffb01 100644 --- a/Database/TypeConfigurations/HoldingAggregatedTypeConfiguration.cs +++ b/Database/TypeConfigurations/HoldingAggregatedTypeConfiguration.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; using System.Linq.Expressions; using System.Text.Json; +using System.Xml.Linq; namespace GhostfolioSidekick.Database.TypeConfigurations { @@ -64,7 +65,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.Quantity).IsRequired(); // Configure Money complex properties - builder.Property(x => x.Currency).IsRequired(); + builder.ComplexProperty(x => x.Currency).Property(x => x.Symbol).HasColumnName("Currency"); builder.Property(x => x.AverageCostPrice).IsRequired(); builder.Property(x => x.CurrentUnitPrice).IsRequired(); builder.Property(x => x.TotalInvested).IsRequired(); diff --git a/GhostfolioSidekick.UnitTests/Performance/PerformanceTaskTests.cs b/GhostfolioSidekick.UnitTests/Performance/PerformanceTaskTests.cs index 0607fb55b..0bed71d6c 100644 --- a/GhostfolioSidekick.UnitTests/Performance/PerformanceTaskTests.cs +++ b/GhostfolioSidekick.UnitTests/Performance/PerformanceTaskTests.cs @@ -28,10 +28,11 @@ public async Task DoWork_RemovesObsoleteHoldings() AccountId = 1, Date = new DateOnly(2024, 1, 1), Quantity = 1, - AverageCostPrice = new Money(Currency.USD, 0), - CurrentUnitPrice = new Money(Currency.USD, 0), - TotalInvested = new Money(Currency.USD, 0), - TotalValue = new Money(Currency.USD, 0) + Currency = Currency.USD, + AverageCostPrice = 0, + CurrentUnitPrice = 0, + TotalInvested = 0, + TotalValue = 0 } ] }); @@ -76,10 +77,11 @@ public async Task DoWork_UpdatesExistingHoldingSnapshots() AccountId = 1, Date = new DateOnly(2024, 1, 1), Quantity = 1, - AverageCostPrice = new Money(Currency.USD, 0), - CurrentUnitPrice = new Money(Currency.USD, 0), - TotalInvested = new Money(Currency.USD, 0), - TotalValue = new Money(Currency.USD, 0) + Currency = Currency.USD, + AverageCostPrice = 0, + CurrentUnitPrice = 0, + TotalInvested = 0, + TotalValue = 0 } ] }; @@ -94,10 +96,11 @@ public async Task DoWork_UpdatesExistingHoldingSnapshots() AccountId = 1, Date = new DateOnly(2024, 1, 1), Quantity = 2, - AverageCostPrice = new Money(Currency.USD, 0), - CurrentUnitPrice = new Money(Currency.USD, 0), - TotalInvested = new Money(Currency.USD, 0), - TotalValue = new Money(Currency.USD, 0) + Currency = Currency.USD, + AverageCostPrice = 0, + CurrentUnitPrice = 0, + TotalInvested = 0, + TotalValue = 0 }; var holding = new HoldingAggregated { Symbol = "A", AssetClass = AssetClass.Equity, CalculatedSnapshots = [newSnapshot] }; var calculatorMock = new Mock(); @@ -135,10 +138,11 @@ public async Task DoWork_DeletesAllHoldings_WhenNoNewHoldings() AccountId = 1, Date = new DateOnly(2024, 1, 1), Quantity = 1, - AverageCostPrice = new Money(Currency.USD, 0), - CurrentUnitPrice = new Money(Currency.USD, 0), - TotalInvested = new Money(Currency.USD, 0), - TotalValue = new Money(Currency.USD, 0) + Currency = Currency.USD, + AverageCostPrice = 0, + CurrentUnitPrice = 0, + TotalInvested = 0, + TotalValue = 0 } ] }); diff --git a/PerformanceCalculations.UnitTests/Calculator/HoldingPerformanceCalculatorTests.cs b/PerformanceCalculations.UnitTests/Calculator/HoldingPerformanceCalculatorTests.cs index f9f93a17b..d0bfe5672 100644 --- a/PerformanceCalculations.UnitTests/Calculator/HoldingPerformanceCalculatorTests.cs +++ b/PerformanceCalculations.UnitTests/Calculator/HoldingPerformanceCalculatorTests.cs @@ -1,4 +1,5 @@ using AwesomeAssertions; +using GhostfolioSidekick.Configuration; using GhostfolioSidekick.Database; using GhostfolioSidekick.Database.Repository; using GhostfolioSidekick.Model; @@ -182,8 +183,8 @@ public async Task GetCalculatedHoldings_ShouldCalculateCorrectSnapshots_WithSing var firstSnapshot = holdingAggregated.CalculatedSnapshots.First(); firstSnapshot.Date.Should().Be(DateOnly.FromDateTime(buyDate)); firstSnapshot.Quantity.Should().Be(100); - firstSnapshot.TotalValue.Amount.Should().Be(155 * 100); // Market price * quantity - firstSnapshot.TotalInvested.Amount.Should().Be(150 * 100); // Unit price * quantity (TotalTransactionAmount) + firstSnapshot.TotalValue.Should().Be(155 * 100); // Market price * quantity + firstSnapshot.TotalInvested.Should().Be(150 * 100); // Unit price * quantity (TotalTransactionAmount) } [Fact] @@ -253,8 +254,8 @@ public async Task GetCalculatedHoldings_ShouldHandleMultipleBuyActivities_Calcul var finalSnapshot = holdingAggregated.CalculatedSnapshots.Last(); finalSnapshot.Quantity.Should().Be(150); // Total quantity - finalSnapshot.TotalValue.Amount.Should().Be(155 * 150); // Today's market price * total quantity - finalSnapshot.TotalInvested.Amount.Should().Be((100 * 150) + (50 * 160)); // First buy: 100*150 + Second buy: 50*160 = 23000 + finalSnapshot.TotalValue.Should().Be(155 * 150); // Today's market price * total quantity + finalSnapshot.TotalInvested.Should().Be((100 * 150) + (50 * 160)); // First buy: 100*150 + Second buy: 50*160 = 23000 } [Fact] @@ -310,7 +311,7 @@ public async Task GetCalculatedHoldings_ShouldHandleSellActivities_ReduceQuantit var finalSnapshot = holdingAggregated.CalculatedSnapshots.Last(); finalSnapshot.Quantity.Should().Be(70); // 100 - 30 // TotalInvested: buy 100*150 = 15000, sell reduces by cost basis of 30*150 = 4500, total = 10500 - finalSnapshot.TotalInvested.Amount.Should().Be(10500); + finalSnapshot.TotalInvested.Should().Be(10500); } [Fact] @@ -351,7 +352,7 @@ public async Task GetCalculatedHoldings_ShouldUseLastKnownMarketPrice_WhenMarket // Assert var holdingAggregated = result.First(); var firstSnapshot = holdingAggregated.CalculatedSnapshots.First(); - firstSnapshot.TotalValue.Amount.Should().Be(145 * 100); // Should use last known price + firstSnapshot.TotalValue.Should().Be(145 * 100); // Should use last known price } [Fact] @@ -562,7 +563,7 @@ private DatabaseContext CreateDatabaseContext() private HoldingPerformanceCalculator CreateCalculator(DatabaseContext context) { - return new HoldingPerformanceCalculator(context, _mockCurrencyExchange.Object); + return new HoldingPerformanceCalculator(context, _mockCurrencyExchange.Object, Mock.Of()); } private static Holding CreateHolding(IList symbolProfiles, ICollection activities) @@ -722,19 +723,19 @@ public async Task GetCalculatedHoldings_ShouldCalculateTotalInvested_WithBuyAndS var firstSnapshot = holdingAggregated.CalculatedSnapshots.First(); firstSnapshot.Date.Should().Be(DateOnly.FromDateTime(buyDate)); firstSnapshot.Quantity.Should().Be(100); - firstSnapshot.TotalInvested.Amount.Should().Be(15000); // 100 * 150 + firstSnapshot.TotalInvested.Should().Be(15000); // 100 * 150 // Check sell day snapshot var sellSnapshot = holdingAggregated.CalculatedSnapshots .FirstOrDefault(s => s.Date == DateOnly.FromDateTime(sellDate)); sellSnapshot.Should().NotBeNull(); sellSnapshot!.Quantity.Should().Be(70); // 100 - 30 - sellSnapshot.TotalInvested.Amount.Should().Be(10500); // 15000 - (30 * 150) = 10500 (cost basis reduction using average cost) + sellSnapshot.TotalInvested.Should().Be(10500); // 15000 - (30 * 150) = 10500 (cost basis reduction using average cost) // Check final snapshot var finalSnapshot = holdingAggregated.CalculatedSnapshots.Last(); finalSnapshot.Quantity.Should().Be(70); // 100 - 30 - finalSnapshot.TotalInvested.Amount.Should().Be(10500); // Should remain the same + finalSnapshot.TotalInvested.Should().Be(10500); // Should remain the same } } } \ No newline at end of file diff --git a/PerformanceCalculations/Calculator/HoldingPerformanceCalculator.cs b/PerformanceCalculations/Calculator/HoldingPerformanceCalculator.cs index 4778d5291..dc23bc203 100644 --- a/PerformanceCalculations/Calculator/HoldingPerformanceCalculator.cs +++ b/PerformanceCalculations/Calculator/HoldingPerformanceCalculator.cs @@ -1,4 +1,5 @@ -using GhostfolioSidekick.Database; +using GhostfolioSidekick.Configuration; +using GhostfolioSidekick.Database; using GhostfolioSidekick.Database.Repository; using GhostfolioSidekick.Model; using GhostfolioSidekick.Model.Activities; @@ -9,7 +10,10 @@ namespace GhostfolioSidekick.PerformanceCalculations.Calculator { - public class HoldingPerformanceCalculator(DatabaseContext databaseContext, ICurrencyExchange currencyExchange) : IHoldingPerformanceCalculator + public class HoldingPerformanceCalculator( + DatabaseContext databaseContext, + ICurrencyExchange currencyExchange, + IApplicationSettings applicationSettings) : IHoldingPerformanceCalculator { public async Task> GetCalculatedHoldings() { @@ -140,13 +144,20 @@ public async Task> GetCalculatedHoldings() foreach (var accountId in accountIds) { ICollection activitiesForAccount = [.. activities.Where(x => x.AccountId == accountId).Select(x => x.Activity)]; - var lst = await CalculateSnapShots( - defaultSymbolProfile.Currency, - accountId, - symbolProfiles, - activitiesForAccount, - allMarketData).ConfigureAwait(false); - snapshots.AddRange(lst); + + var currencies = applicationSettings.ConfigurationInstance.Settings.Currencies?.Select(c => Currency.GetCurrency(c)).Where(c => c != Currency.NONE).Distinct().ToList() ?? []; + currencies.Add(defaultSymbolProfile.Currency); + + foreach (var currency in currencies.Distinct()) + { + var lst = await CalculateSnapShots( + currency, + accountId, + symbolProfiles, + activitiesForAccount, + allMarketData).ConfigureAwait(false); + snapshots.AddRange(lst); + } } returnList.Add(new HoldingAggregated @@ -189,7 +200,7 @@ private async Task> CalculateSnapShots( .GroupBy(x => DateOnly.FromDateTime(x.Date)) .ToDictionary(g => g.Key, g => g.OrderBy(x => x.Date).ToList()); - var previousSnapshot = new CalculatedSnapshot(0, accountId, minDate.AddDays(-1), 0, Money.Zero(targetCurrency), Money.Zero(targetCurrency), Money.Zero(targetCurrency), Money.Zero(targetCurrency)); + var previousSnapshot = new CalculatedSnapshot(0, accountId, minDate.AddDays(-1), 0, targetCurrency, 0, 0, 0, 0); // Use pre-loaded market data instead of querying database Dictionary marketData = new(dayCount); @@ -231,8 +242,8 @@ await ApplyActivitiesForDateAsync( marketPrice, targetCurrency, date).ConfigureAwait(false); - snapshot.CurrentUnitPrice = marketPriceConverted; - snapshot.TotalValue = marketPriceConverted.Times(snapshot.Quantity); + snapshot.CurrentUnitPrice = marketPriceConverted.Amount; + snapshot.TotalValue = marketPriceConverted.Times(snapshot.Quantity).Amount; snapshots.Add(snapshot); previousSnapshot = snapshot; @@ -241,10 +252,10 @@ await ApplyActivitiesForDateAsync( // Round the values to avoid floating point issues foreach (var snapshot in snapshots) { - snapshot.AverageCostPrice = new Money(targetCurrency, Math.Round(snapshot.AverageCostPrice.Amount, Constants.NumberOfDecimals)); - snapshot.CurrentUnitPrice = new Money(targetCurrency, Math.Round(snapshot.CurrentUnitPrice.Amount, Constants.NumberOfDecimals)); - snapshot.TotalInvested = new Money(targetCurrency, Math.Round(snapshot.TotalInvested.Amount, Constants.NumberOfDecimals)); - snapshot.TotalValue = new Money(targetCurrency, Math.Round(snapshot.TotalValue.Amount, Constants.NumberOfDecimals)); + snapshot.AverageCostPrice = Math.Round(snapshot.AverageCostPrice, Constants.NumberOfDecimals); + snapshot.CurrentUnitPrice = Math.Round(snapshot.CurrentUnitPrice, Constants.NumberOfDecimals); + snapshot.TotalInvested = Math.Round(snapshot.TotalInvested, Constants.NumberOfDecimals); + snapshot.TotalValue = Math.Round(snapshot.TotalValue, Constants.NumberOfDecimals); snapshot.Quantity = Math.Round(snapshot.Quantity, Constants.NumberOfDecimals); } @@ -282,34 +293,40 @@ private static async Task ApplyActivitiesForDateAsync( if (sign == 1) { // For buy/receive/gift/staking, add the invested amount and update average cost price - snapshot.TotalInvested = snapshot.TotalInvested.Add(convertedTotal); + snapshot.TotalInvested = snapshot.TotalInvested + convertedTotal.Amount; snapshot.Quantity += activity.AdjustedQuantity; snapshot.AverageCostPrice = CalculateAverageCostPrice(snapshot); // quantity already added above } else { // For sell/send, first calculate cost basis reduction using current average cost price - var costBasisReduction = snapshot.AverageCostPrice.Times(activity.AdjustedQuantity); - snapshot.TotalInvested = snapshot.TotalInvested.Subtract(costBasisReduction); + var costBasisReduction = snapshot.AverageCostPrice * activity.AdjustedQuantity; + snapshot.TotalInvested = snapshot.TotalInvested - costBasisReduction; snapshot.Quantity -= activity.AdjustedQuantity; // Average cost price remains the same after a sell (unless quantity becomes zero) if (snapshot.Quantity <= 0) { - snapshot.AverageCostPrice = Money.Zero(targetCurrency); + snapshot.AverageCostPrice = 0; } } } } - private static Money CalculateAverageCostPrice(CalculatedSnapshot snapshot) + private static decimal CalculateAverageCostPrice(CalculatedSnapshot snapshot) { if (snapshot.Quantity == 0) { - return Money.Zero(snapshot.TotalInvested.Currency); + return 0; + } + + if (snapshot.Quantity < Constants.Epsilon) + { + // Avoid division by very small number + return 0; } - return snapshot.TotalInvested.SafeDivide(snapshot.Quantity); + return snapshot.TotalInvested / snapshot.Quantity; } } } From f7ac92f1c4fef0cafc9f8a7bb685794879518c58 Mon Sep 17 00:00:00 2001 From: Vincent <30174292+VibeNL@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:53:41 +0100 Subject: [PATCH 6/7] 1 --- .../BalanceMaintainerTaskTests.cs | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/GhostfolioSidekick.UnitTests/AccountMaintainer/BalanceMaintainerTaskTests.cs b/GhostfolioSidekick.UnitTests/AccountMaintainer/BalanceMaintainerTaskTests.cs index 551241d66..55c552b28 100644 --- a/GhostfolioSidekick.UnitTests/AccountMaintainer/BalanceMaintainerTaskTests.cs +++ b/GhostfolioSidekick.UnitTests/AccountMaintainer/BalanceMaintainerTaskTests.cs @@ -19,11 +19,11 @@ public BalanceMaintainerTaskTests() _mockExchangeRateService = new Mock(); _mockApplicationSettings = new Mock(); - // Setup default configuration - var mockConfigurationInstance = new ConfigurationInstance - { - Settings = new Settings { RawCurrencies = "EUR;USD" } - }; + // Setup default configuration + var mockConfigurationInstance = new ConfigurationInstance + { + Settings = new Settings { RawCurrencies = "USD" } + }; _mockApplicationSettings.Setup(x => x.ConfigurationInstance).Returns(mockConfigurationInstance); _balanceMaintainerTask = new BalanceMaintainerTask(_mockDbContextFactory.Object, _mockExchangeRateService.Object, _mockApplicationSettings.Object); @@ -79,9 +79,10 @@ public async Task DoWork_ShouldUpdateBalances_WhenBalancesAreDifferent() public async Task DoWork_ShouldNotUpdateBalances_WhenBalancesAreSame() { // Arrange + var testDate = DateTime.Today; var existingBalances = new List { - new(DateOnly.FromDateTime(DateTime.Now), new Money(Currency.USD,100)) + new(DateOnly.FromDateTime(testDate), new Money(Currency.USD,100)) }; var mockDbContext = new Mock(); @@ -93,15 +94,13 @@ public async Task DoWork_ShouldNotUpdateBalances_WhenBalancesAreSame() var activities = new List { - new Model.Activities.Types.KnownBalanceActivity { Date = DateTime.Now, Account = account, Amount = new Money(Currency.USD,100) } + new Model.Activities.Types.KnownBalanceActivity { Date = testDate, Account = account, Amount = new Money(Currency.USD,100) } }; mockDbContext.Setup(db => db.Activities).ReturnsDbSet(activities); _mockDbContextFactory.Setup(factory => factory.CreateDbContextAsync(It.IsAny())).ReturnsAsync(mockDbContext.Object); - var balanceCalculator = new BalanceCalculator(_mockExchangeRateService.Object); - var loggerMock = new Mock>(); // Act From 1005c9856aa22e518c3c062ba211012ff96c7a91 Mon Sep 17 00:00:00 2001 From: Vincent <30174292+VibeNL@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:57:29 +0100 Subject: [PATCH 7/7] 1 --- .../HoldingPerformanceCalculatorTests.cs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/PerformanceCalculations.UnitTests/Calculator/HoldingPerformanceCalculatorTests.cs b/PerformanceCalculations.UnitTests/Calculator/HoldingPerformanceCalculatorTests.cs index d0bfe5672..243ad5fc7 100644 --- a/PerformanceCalculations.UnitTests/Calculator/HoldingPerformanceCalculatorTests.cs +++ b/PerformanceCalculations.UnitTests/Calculator/HoldingPerformanceCalculatorTests.cs @@ -18,6 +18,7 @@ public class HoldingPerformanceCalculatorTests : IDisposable { private readonly DbContextOptions _dbContextOptions; private readonly Mock _mockCurrencyExchange; + private readonly Mock _mockApplicationSettings; private readonly string _databaseFilePath; public HoldingPerformanceCalculatorTests() @@ -31,6 +32,21 @@ public HoldingPerformanceCalculatorTests() _mockCurrencyExchange .Setup(x => x.ConvertMoney(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((Money money, Currency currency, DateOnly date) => new Money(currency, money.Amount)); + + // Setup proper mock for IApplicationSettings + var settings = new Settings + { + RawCurrencies = "USD", // Use only USD to match symbol profiles in tests + RawDataProviderPreference = "YAHOO;COINGECKO" + }; + + var configurationInstance = new ConfigurationInstance + { + Settings = settings + }; + + _mockApplicationSettings = new Mock(); + _mockApplicationSettings.Setup(x => x.ConfigurationInstance).Returns(configurationInstance); } [Fact] @@ -563,7 +579,7 @@ private DatabaseContext CreateDatabaseContext() private HoldingPerformanceCalculator CreateCalculator(DatabaseContext context) { - return new HoldingPerformanceCalculator(context, _mockCurrencyExchange.Object, Mock.Of()); + return new HoldingPerformanceCalculator(context, _mockCurrencyExchange.Object, _mockApplicationSettings.Object); } private static Holding CreateHolding(IList symbolProfiles, ICollection activities)