diff --git a/Common/Securities/Security.cs b/Common/Securities/Security.cs index 808acff852a8..57239d73e910 100644 --- a/Common/Securities/Security.cs +++ b/Common/Securities/Security.cs @@ -535,6 +535,12 @@ public virtual DateTime LocalTime /// public virtual decimal Price => Cache.Price; + /// + /// Gets the last price observed during regular market hours. + /// Remains frozen when the exchange is closed + /// + public decimal LastMarketPrice { get; private set; } + /// /// Leverage for this Security. /// @@ -1130,6 +1136,10 @@ protected virtual void UpdateConsumersMarketPrice(BaseData data) if (data is OpenInterest || data.Price == 0m) return; Holdings.UpdateMarketPrice(Price); VolatilityModel.Update(this, data); + if (_localTimeKeeper != null && Exchange.ExchangeOpen) + { + LastMarketPrice = Price; + } } /// diff --git a/Common/Securities/SecurityHolding.cs b/Common/Securities/SecurityHolding.cs index ef441fb27a92..760a5e2cbbbe 100644 --- a/Common/Securities/SecurityHolding.cs +++ b/Common/Securities/SecurityHolding.cs @@ -179,7 +179,7 @@ public virtual decimal HoldingsCost /// public virtual decimal UnleveredHoldingsCost { - get { return HoldingsCost/Leverage; } + get { return HoldingsCost / Leverage; } } /// @@ -358,7 +358,7 @@ public virtual decimal UnrealizedProfitPercent get { if (AbsoluteHoldingsCost == 0) return 0m; - return UnrealizedProfit/AbsoluteHoldingsCost; + return UnrealizedProfit / AbsoluteHoldingsCost; } } @@ -420,7 +420,7 @@ public void SetLastTradeProfit(decimal lastTradeProfit) /// public virtual void SetHoldings(decimal averagePrice, int quantity) { - SetHoldings(averagePrice, (decimal) quantity); + SetHoldings(averagePrice, (decimal)quantity); } /// @@ -496,11 +496,12 @@ public virtual decimal TotalCloseProfit(bool includeFees = true, decimal? exitPr // if we are long, we would need to sell against the bid var price = IsLong ? _security.BidPrice : _security.AskPrice; - if (price == 0) + if (price == 0 || (!_security.Exchange.ExchangeOpen && _security.LastMarketPrice != 0 && _security.Type != SecurityType.Future)) { // Bid/Ask prices can both be equal to 0. This usually happens when we request our holdings from - // the brokerage, but only the last trade price was provided. - price = _security.Price; + // the brokerage, but only the last trade price was provided. Outside market hours, bid/ask spreads + // widen significantly, so we prefer the last price observed during regular market hours + price = _security.LastMarketPrice != 0 ? _security.LastMarketPrice : _security.Price; } var entryValue = GetQuantityValue(quantityToUse, entryPrice ?? AveragePrice).InAccountCurrency; diff --git a/Tests/Common/Securities/SecurityHoldingTests.cs b/Tests/Common/Securities/SecurityHoldingTests.cs index 24bc02f3d495..bb0b344e8687 100644 --- a/Tests/Common/Securities/SecurityHoldingTests.cs +++ b/Tests/Common/Securities/SecurityHoldingTests.cs @@ -82,6 +82,63 @@ public void Raises_QuantityChanged_WhenSetHoldingsCalled() Assert.AreEqual(firstPrice, second.PreviousAveragePrice); } + [Test] + public void TotalCloseProfitUsesLastMarketPriceWhenExchangeCloses() + { + // Exchange open 09:30-16:00 New York every day (standard equity hours) + var segment = new MarketHoursSegment(MarketHoursState.Market, new TimeSpan(9, 30, 0), TimeSpan.FromHours(16)); + var days = Enum.GetValues().Cast(); + var exchangeHours = new SecurityExchangeHours( + TimeZones.NewYork, + Enumerable.Empty(), + days.ToDictionary(d => d, d => new LocalMarketHours(d, segment)), + new Dictionary(), + new Dictionary()); + + var security = GetSecurityWithExchangeHours(exchangeHours); + + // 9:30 -> market open, set price 100 → LastMarketPrice = 100 + var openTime = new DateTime(2024, 1, 15, 9, 30, 0); + var timeKeeper = new TimeKeeper(openTime.ConvertToUtc(TimeZones.NewYork)); + security.SetLocalTimeKeeper(timeKeeper.GetLocalTimeKeeper(TimeZones.NewYork)); + security.SetMarketPrice(new Tick { Time = openTime, Symbol = security.Symbol, TickType = TickType.Trade, Value = 100m }); + + Assert.AreEqual(100m, security.LastMarketPrice); + + // 16:00:01 -> market closed, tick arrives with inflated bid/ask spread + var afterClose = new DateTime(2024, 1, 15, 16, 0, 1); + timeKeeper.SetUtcDateTime(afterClose.ConvertToUtc(TimeZones.NewYork)); + security.SetMarketPrice(new Tick(afterClose, security.Symbol, 0m, 80m, 120m)); + + // LastMarketPrice must be frozen at 100 + Assert.AreEqual(100m, security.LastMarketPrice); + + // TotalCloseProfit must use 100 (LastMarketPrice), not bid=80 + var holding = new SecurityHolding(security, new IdentityCurrencyConverter(Currencies.USD)); + holding.SetHoldings(100m, 100m); + Assert.AreEqual(0m, holding.TotalCloseProfit(includeFees: false)); + } + + private Security GetSecurityWithExchangeHours(SecurityExchangeHours exchangeHours) + { + var config = new SubscriptionDataConfig( + typeof(QuantConnect.Securities.Equity.Equity), + Symbols.SPY, + Resolution.Daily, + TimeZones.NewYork, + TimeZones.NewYork, + true, true, false); + + return new Security( + exchangeHours, + config, + new Cash(Currencies.USD, 0, 1m), + SymbolProperties.GetDefault(Currencies.USD), + ErrorCurrencyConverter.Instance, + RegisteredSecurityDataTypesProvider.Null, + new SecurityCache()); + } + private Security GetSecurity(Symbol symbol, Resolution resolution) { var subscriptionDataConfig = new SubscriptionDataConfig(