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(