From e7f7f8a03fe42cbe48f43e13a3c1b59d4e7af177 Mon Sep 17 00:00:00 2001 From: abhi byte-62 Date: Thu, 1 Oct 2026 12:19:01 +0530 Subject: [PATCH] Fix FutureSettlementModel cash adjustment for non-USD account currency In FutureSettlementModel.Scan and ApplyFunds, daily settled profit is computed in the account currency (via TotalCloseProfit and SettledProfit). When applying this settled profit to the contract's quote-currency cash balance in CashBook, convert the amount to quote currency using QuoteCurrency.ConversionRate to prevent cashbook and portfolio valuation drift in non-USD account currency setups. Fixes #9837 --- .../Future/FutureSettlementModel.cs | 10 +- .../Futures/FutureSettlementModelTests.cs | 129 ++++++++++++++++++ 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/Common/Securities/Future/FutureSettlementModel.cs b/Common/Securities/Future/FutureSettlementModel.cs index 587fded62edd..d1170ed1d23f 100644 --- a/Common/Securities/Future/FutureSettlementModel.cs +++ b/Common/Securities/Future/FutureSettlementModel.cs @@ -52,7 +52,10 @@ public override void ApplyFunds(ApplyFundsSettlementModelParameters applyFundsPa var removedSettledProfit = factor * futureHolding.SettledProfit; futureHolding.SettledProfit -= removedSettledProfit; - applyFundsParameters.CashAmount = new CashAmount(applyFundsParameters.CashAmount.Amount - removedSettledProfit, applyFundsParameters.CashAmount.Currency); + var conversionRate = security.QuoteCurrency.ConversionRate; + var removedSettledProfitInQuoteCurrency = conversionRate != 0 ? removedSettledProfit / conversionRate : removedSettledProfit; + + applyFundsParameters.CashAmount = new CashAmount(applyFundsParameters.CashAmount.Amount - removedSettledProfitInQuoteCurrency, applyFundsParameters.CashAmount.Currency); } base.ApplyFunds(applyFundsParameters); @@ -82,7 +85,10 @@ public override void Scan(ScanSettlementModelParameters settlementParameters) { futureHolding.SettledProfit += dailyProfitLoss; - settlementParameters.Portfolio.CashBook[security.QuoteCurrency.Symbol].AddAmount(dailyProfitLoss); + var conversionRate = security.QuoteCurrency.ConversionRate; + var dailyProfitLossInQuoteCurrency = conversionRate != 0 ? dailyProfitLoss / conversionRate : dailyProfitLoss; + + settlementParameters.Portfolio.CashBook[security.QuoteCurrency.Symbol].AddAmount(dailyProfitLossInQuoteCurrency); Log.Trace($"FutureSettlementModel.Scan({security.Symbol}): {security.LocalTime} Daily P&L: {dailyProfitLoss} " + $"Quantity: {_settledFutureQuantity} Settlement: {_settlementPrice} UnrealizedProfit: {futureHolding.UnrealizedProfit}"); } diff --git a/Tests/Common/Securities/Futures/FutureSettlementModelTests.cs b/Tests/Common/Securities/Futures/FutureSettlementModelTests.cs index 9c3c7d8ddfad..8f4627b10216 100644 --- a/Tests/Common/Securities/Futures/FutureSettlementModelTests.cs +++ b/Tests/Common/Securities/Futures/FutureSettlementModelTests.cs @@ -245,5 +245,134 @@ private static void SetPrice(Security security, decimal price) { security.SetMarketPrice(new Tick(Noon, security.Symbol, string.Empty, Exchange.UNKNOWN, quantity: 1, price)); } + + [TestCase(1400, 10, 1300, 1200, 1.35)] + [TestCase(1400, -10, 1300, 1200, 1.35)] + [TestCase(1300, 10, 1400, 1500, 1.35)] + [TestCase(1300, -10, 1400, 1500, 1.35)] + [TestCase(1400, 10, 1300, 1500, 1.35)] + [TestCase(1400, -10, 1300, 1500, 1.35)] + [TestCase(1300, 10, 1400, 1200, 1.35)] + [TestCase(1300, -10, 1400, 1200, 1.35)] + public void DailySettlementNonAccountCurrency(decimal averagePrice, decimal quantity, decimal futurePriceStep1, decimal futurePriceStep2, decimal conversionRate) + { + _portfolio.CashBook[Currencies.USD].ConversionRate = conversionRate; + + _future.Holdings.SetHoldings(averagePrice, quantity); + SetPrice(_future, futurePriceStep1); + _portfolio.InvalidateTotalPortfolioValue(); + + var expectedTpv = _portfolio.TotalPortfolioValue; + var startCash = _portfolio.CashBook[Currencies.USD].Amount; + Assert.AreEqual(0, _futureHoldings.SettledProfit); + + // advance time + _timeKeeper.UpdateTime(_timeKeeper.LocalTime.AddDays(1)); + _model.Scan(new ScanSettlementModelParameters(_portfolio, _future, _timeKeeper.LocalTime)); + _portfolio.InvalidateTotalPortfolioValue(); + + Assert.AreEqual(_portfolio.TotalPortfolioValue, expectedTpv); + var expectedCash = startCash + (_future.Holdings.UnrealizedProfit / conversionRate); + Assert.AreEqual(expectedCash, _portfolio.CashBook[Currencies.USD].Amount); + Assert.AreEqual(_future.Holdings.UnrealizedProfit, _futureHoldings.SettledProfit); + Assert.AreEqual(0, _futureHoldings.UnsettledProfit); + + // we call it again, nothing should change + SetPrice(_future, futurePriceStep2); + _portfolio.InvalidateTotalPortfolioValue(); + _model.Scan(new ScanSettlementModelParameters(_portfolio, _future, _timeKeeper.LocalTime)); + + // price movement does affect TPV not cash + expectedTpv = expectedTpv + (futurePriceStep2 - futurePriceStep1) * quantity * conversionRate; + Assert.AreEqual(expectedTpv, _portfolio.TotalPortfolioValue); + Assert.AreEqual(expectedCash, _portfolio.CashBook[Currencies.USD].Amount); + Assert.AreNotEqual(0, _futureHoldings.UnsettledProfit); + + // advance time + _timeKeeper.UpdateTime(_timeKeeper.LocalTime.AddDays(1)); + _model.Scan(new ScanSettlementModelParameters(_portfolio, _future, _timeKeeper.LocalTime)); + _portfolio.InvalidateTotalPortfolioValue(); + + Assert.AreEqual(expectedTpv, _portfolio.TotalPortfolioValue); + Assert.AreEqual(startCash + (_future.Holdings.UnrealizedProfit / conversionRate), _portfolio.CashBook[Currencies.USD].Amount); + Assert.AreEqual(_future.Holdings.UnrealizedProfit, _futureHoldings.SettledProfit); + Assert.AreEqual(0, _futureHoldings.UnsettledProfit); + } + + [TestCase(1400, 10, 1300, 0, 1.35)] + [TestCase(1400, -10, 1300, 0, 1.35)] + [TestCase(1300, 10, 1400, 0, 1.35)] + [TestCase(1300, -10, 1400, 0, 1.35)] + [TestCase(1400, 10, 1300, 1, 1.35)] + [TestCase(1400, -10, 1300, 1, 1.35)] + [TestCase(1300, 10, 1400, 1, 1.35)] + [TestCase(1300, -10, 1400, 1, 1.35)] + [TestCase(1400, 10, 1300, -1, 1.35)] + [TestCase(1400, -10, 1300, -1, 1.35)] + [TestCase(1300, 10, 1400, -1, 1.35)] + [TestCase(1300, -10, 1400, -1, 1.35)] + [TestCase(1400, 10, 1300, -20, 1.35)] + [TestCase(1300, 10, 1400, -20, 1.35)] + [TestCase(1400, -10, 1300, 20, 1.35)] + [TestCase(1300, -10, 1400, 20, 1.35)] + public void HoldingsQuantityChangeNonAccountCurrency(decimal averagePrice, decimal quantity, decimal futurePrice, decimal newQuantity, decimal conversionRate) + { + _portfolio.CashBook[Currencies.USD].ConversionRate = conversionRate; + + _future.Holdings.SetHoldings(averagePrice, quantity); + SetPrice(_future, futurePrice); + _portfolio.InvalidateTotalPortfolioValue(); + + var expectedTpv = _portfolio.TotalPortfolioValue; + var startCash = _portfolio.CashBook[Currencies.USD].Amount; + // advance time + _timeKeeper.UpdateTime(_timeKeeper.LocalTime.AddDays(1)); + _model.Scan(new ScanSettlementModelParameters(_portfolio, _future, _timeKeeper.LocalTime)); + _portfolio.InvalidateTotalPortfolioValue(); + + var expectedSettledProfitInAccountCurrency = _future.Holdings.UnrealizedProfit; + var expectedSettledCashInQuoteCurrency = expectedSettledProfitInAccountCurrency / conversionRate; + var expectedCash = startCash + expectedSettledCashInQuoteCurrency; + Assert.AreEqual(_portfolio.TotalPortfolioValue, expectedTpv); + Assert.AreEqual(expectedCash, _portfolio.CashBook[Currencies.USD].Amount); + + // we change the holdings quantity + var fillPrice = futurePrice * 0.9m; + var fillQuantity = -(quantity - newQuantity); + var absoluteQuantityClosed = Math.Min(Math.Abs(fillQuantity), _future.Holdings.AbsoluteQuantity); + var closedQuantity = Math.Sign(-fillQuantity) * absoluteQuantityClosed; + + Assert.AreEqual(Math.Sign(closedQuantity), Math.Sign(quantity)); + var fundsInQuoteCurrency = (_future.Holdings.TotalCloseProfit(includeFees: false, exitPrice: fillPrice, _future.Holdings.AveragePrice, closedQuantity)) / conversionRate; + var funds = new CashAmount(fundsInQuoteCurrency, Currencies.USD); + var fill = new OrderEvent(1, _future.Symbol, _timeKeeper.LocalTime, OrderStatus.Filled, Extensions.GetOrderDirection(fillQuantity), fillPrice, fillQuantity, OrderFee.Zero); + _future.SettlementModel.ApplyFunds(new ApplyFundsSettlementModelParameters(_portfolio, _future, _timeKeeper.LocalTime.ConvertToUtc(_timeKeeper.TimeZone), funds, fill)); + + // if we change side the cash adjustment will go to 0, until we scan again + var settledProfit = 0m; + expectedCash = startCash + funds.Amount; + if (Math.Sign(newQuantity) == Math.Sign(quantity)) + { + // if we increase the position the cash adjustment will remain the same, until we scan again + if (newQuantity < 0 && newQuantity < quantity) + { + settledProfit = expectedSettledProfitInAccountCurrency; + } + else if (newQuantity > 0 && newQuantity > quantity) + { + settledProfit = expectedSettledProfitInAccountCurrency; + } + else + { + // we reduced the position + settledProfit = expectedSettledProfitInAccountCurrency * (newQuantity / quantity); + expectedCash = startCash + funds.Amount + (settledProfit / conversionRate); + } + } + + var futureHoldings = (FutureHolding)_future.Holdings; + Assert.AreEqual(settledProfit, futureHoldings.SettledProfit); + Assert.AreEqual(expectedCash, _portfolio.CashBook[Currencies.USD].Amount); + } } }