Commit Latest

This commit is contained in:
2026-02-23 21:25:49 -05:00
parent b793ddc3b2
commit 753e6975ce
6 changed files with 1252 additions and 341 deletions

View File

@@ -0,0 +1,241 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using MarketData.Cache;
using MarketData.MarketDataModel;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Moq;
using MarketData.Utils;
using System.Reflection;
namespace MarketDataUnitTests;
[TestClass]
public class GBPriceCacheTests
{
private GBPriceCache cache = default!;
private Mock<IPricingDataAccess> pricingMock = default!;
private readonly string symbol = "AAPL";
private readonly DateTime today = DateTime.Today;
// ------------------------
// Helper to fully reset singleton
// ------------------------
private void ResetCacheSingleton()
{
if (null != cache) cache.Dispose();
FieldInfo field = typeof(GBPriceCache)
.GetField("priceCacheInstance", BindingFlags.Static | BindingFlags.NonPublic);
if (null != field) field.SetValue(null, null);
cache = GBPriceCache.GetInstance();
cache.Clear();
}
[TestInitialize]
public void Setup()
{
ResetCacheSingleton();
pricingMock = new Mock<IPricingDataAccess>();
cache.PricingDataAccess = pricingMock.Object;
}
[TestCleanup]
public void Cleanup()
{
cache.Dispose();
}
// ------------------------
// Singleton Behavior
// ------------------------
[TestMethod]
public void GetInstance_ReturnsSingleton()
{
GBPriceCache instance1 = GBPriceCache.GetInstance();
GBPriceCache instance2 = GBPriceCache.GetInstance();
Assert.AreSame(instance1, instance2);
}
// ------------------------
// Basic GetPrice
// ------------------------
[TestMethod]
public void GetPrice_ReturnsNull_WhenNoData()
{
Price nullPrice = default;
pricingMock.Setup(x => x.GetPrice(symbol, today)).Returns(nullPrice);
Price result = cache.GetPrice(symbol, today);
Assert.IsNull(result);
}
[TestMethod]
public void GetPrice_CachesResult_WhenDataExists()
{
Price price = new Price { Symbol = symbol, Date = today };
pricingMock.Setup(x => x.GetPrice(symbol, today)).Returns(price);
Price first = cache.GetPrice(symbol, today);
Price second = cache.GetPrice(symbol, today);
Assert.AreSame(first, second);
Assert.AreEqual(symbol, first.Symbol);
}
// ------------------------
// ContainsPrice
// ------------------------
[TestMethod]
public void ContainsPrice_BehavesCorrectly()
{
Price price = new Price { Symbol = symbol, Date = today };
pricingMock.Setup(x => x.GetPrice(symbol, today)).Returns(price);
Assert.IsFalse(cache.ContainsPrice(symbol, today));
cache.GetPrice(symbol, today);
Assert.IsTrue(cache.ContainsPrice(symbol, today));
}
// ------------------------
// GetPriceOrLatestAvailable
// ------------------------
[TestMethod]
public void GetPriceOrLatestAvailable_ReturnsLatestPrice()
{
DateTime earlier = today.AddDays(-1);
Price latestPrice = new Price { Symbol = symbol, Date = today };
pricingMock.Setup(x => x.GetLatestDateOnOrBefore(symbol, earlier)).Returns(today);
pricingMock.Setup(x => x.GetPrice(symbol, today)).Returns(latestPrice);
Price result = cache.GetPriceOrLatestAvailable(symbol, earlier);
Assert.IsNotNull(result);
Assert.AreEqual(today, result.Date);
}
// ------------------------
// GetPrices (DateTime overload)
// ------------------------
[TestMethod]
public void GetPrices_DateTimeOverload_ReturnsList()
{
DateGenerator generator = new DateGenerator();
DateTime later = generator.GetPrevBusinessDay(DateTime.Today);
DateTime earlier = later;
for (int i = 0; i < 2; i++) earlier = generator.GetPrevBusinessDay(earlier.AddDays(-1));
List<DateTime> allDates = generator.GenerateHistoricalDates(later, 1000);
List<Price> allPrices = allDates.Select(d => new Price { Symbol = symbol, Date = d }).ToList();
pricingMock.Setup(x => x.GetPrices(It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<DateTime>()))
.Returns((string s, DateTime max, DateTime min) =>
{
List<Price> matched = allPrices.Where(p => p.Date >= min && p.Date <= max).ToList();
return new Prices(matched);
});
int dayCount = generator.GenerateHistoricalDates(later, 100)
.Where(d => d <= later && d >= earlier)
.Count();
Prices prices = cache.GetPrices(symbol, earlier, later);
Assert.IsNotNull(prices);
Assert.AreEqual(dayCount, prices.Count);
Assert.IsTrue(prices.SequenceEqual(prices.OrderByDescending(p => p.Date)));
}
// ------------------------
// GetPrices (startDate + dayCount)
// ------------------------
[TestMethod]
public void GetPrices_StartDateDayCount_Works()
{
int dayCount = 5;
List<Price> allPrices = new List<Price>();
for (int i = 0; i < 100; i++) allPrices.Add(new Price { Symbol = symbol, Date = today.AddDays(-i) });
pricingMock.Setup(x => x.GetPrices(It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<DateTime>()))
.Returns((string s, DateTime max, DateTime min) =>
{
List<Price> matched = allPrices.Where(p => p.Date >= min && p.Date <= max).ToList();
return new Prices(matched);
});
Prices prices = cache.GetPrices(symbol, today, dayCount);
Assert.AreEqual(dayCount, prices.Count);
Assert.IsTrue(prices.SequenceEqual(prices.OrderByDescending(p => p.Date)));
}
// ------------------------
// Clear
// ------------------------
[TestMethod]
public void Clear_RemovesAllData()
{
Price price = new Price { Symbol = symbol, Date = today };
pricingMock.Setup(x => x.GetPrice(symbol, today)).Returns(price);
cache.GetPrice(symbol, today);
cache.Clear();
Assert.IsFalse(cache.ContainsPrice(symbol, today));
}
// ------------------------
// ClearCacheOnOrBefore
// ------------------------
[TestMethod]
public void ClearCacheOnOrBefore_FiltersOldPrices()
{
Price oldPrice = new Price { Symbol = symbol, Date = today.AddDays(-5) };
Price recentPrice = new Price { Symbol = symbol, Date = today };
pricingMock.Setup(x => x.GetPrice(symbol, oldPrice.Date)).Returns(oldPrice);
pricingMock.Setup(x => x.GetPrice(symbol, recentPrice.Date)).Returns(recentPrice);
cache.GetPrice(symbol, oldPrice.Date);
cache.GetPrice(symbol, recentPrice.Date);
cache.ClearCacheOnOrBefore(today.AddDays(-1));
Assert.IsFalse(cache.ContainsPrice(symbol, oldPrice.Date));
Assert.IsTrue(cache.ContainsPrice(symbol, recentPrice.Date));
}
// ------------------------
// Concurrency Tests
// ------------------------
[TestMethod]
public void Concurrent_ReadWrite_DoesNotThrow()
{
Price price = new Price { Symbol = symbol, Date = today };
pricingMock.Setup(x => x.GetPrice(symbol, It.IsAny<DateTime>())).Returns(price);
Parallel.For(0, 100, i =>
{
cache.GetPrice(symbol, today.AddDays(-i));
cache.ContainsPrice(symbol, today);
});
Assert.IsTrue(true);
}
[TestMethod]
public void Concurrent_MultipleSymbols_DoesNotThrow()
{
List<string> symbols = new List<string> { "AAPL", "MSFT", "GOOG" };
Price price = new Price { Symbol = "TEST", Date = today };
pricingMock.Setup(x => x.GetPrice(It.IsAny<string>(), It.IsAny<DateTime>())).Returns(price);
Parallel.ForEach(symbols, s =>
{
for (int i = 0; i < 50; i++)
{
cache.GetPrice(s, today.AddDays(-i));
}
});
Assert.IsTrue(true);
}
// ------------------------
// Dispose
// ------------------------
[TestMethod]
public void Dispose_CanBeCalledMultipleTimes()
{
cache.Dispose();
cache.Dispose();
Assert.IsTrue(true);
}
}

View File

@@ -0,0 +1,471 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using MarketData.Cache;
using MarketData.MarketDataModel;
using System;
using System.Collections.Generic;
using MarketData.Utils;
namespace MarketDataUnitTests;
[TestClass]
public class LocalPriceCacheTests
{
private LocalPriceCache cache = default!;
[TestInitialize]
public void Setup()
{
cache = LocalPriceCache.GetInstance();
cache.Clear();
}
[TestCleanup]
public void Cleanup()
{
if (cache != null)
cache.Dispose();
}
private Price CreatePrice(string symbol, DateTime date, double close)
{
return new Price
{
Symbol = symbol,
Date = date,
Close = close,
Open = close - 1,
High = close + 1,
Low = close - 2,
AdjClose = close,
Volume = 1000,
Source = Price.PriceSource.Other
};
}
#region Basic Add/Get Tests
[TestMethod]
public void AddAndGetPrice_SinglePrice_ShouldReturnPrice()
{
string symbol = "MSFT";
DateTime date = new DateTime(2026, 2, 22);
Price price = CreatePrice(symbol, date, 250);
cache.Add(price);
Price cachedPrice = cache.GetPrice(symbol, date);
Assert.IsNotNull(cachedPrice);
Assert.AreEqual(250, cachedPrice.Close);
}
[TestMethod]
public void AddAndGetPrices_List_ShouldReturnAllPrices()
{
DateTime date = new DateTime(2026, 2, 22);
List<string> symbols = new List<string> { "MSFT", "AAPL", "GOOG" };
Prices prices = new Prices();
foreach (string symbol in symbols)
{
prices.Add(CreatePrice(symbol, date, 100 + symbols.IndexOf(symbol) * 10));
}
cache.Add(prices);
foreach (string symbol in symbols)
{
Price cachedPrice = cache.GetPrice(symbol, date);
Assert.IsNotNull(cachedPrice);
}
}
#endregion
#region Cache State Tests
[TestMethod]
public void ContainsPrice_ShouldReturnTrueForExistingPrice()
{
string symbol = "MSFT";
DateTime date = new DateTime(2026, 2, 22);
Price price = CreatePrice(symbol, date, 250);
cache.Add(price);
Assert.IsTrue(cache.ContainsPrice(symbol, date));
}
[TestMethod]
public void ContainsPrice_ShouldReturnFalseForNonExistingPrice()
{
string symbol = "MSFT";
DateTime date = new DateTime(2026, 2, 22);
Assert.IsFalse(cache.ContainsPrice(symbol, date));
}
[TestMethod]
public void GetMinCacheDate_ShouldReturnEpochIfNotPresent()
{
DateTime minDate = cache.GetMinCacheDate("UNKNOWN");
Assert.AreEqual(DateTime.MinValue, minDate); // Utility.Epoch assumed to be DateTime.MinValue
}
[TestMethod]
public void GetMinCacheDate_ShouldReturnCorrectMinDateForExistingSymbol()
{
string symbol = "MSFT";
DateTime date1 = new DateTime(2026, 2, 22);
DateTime date2 = new DateTime(2026, 2, 23);
cache.Add(CreatePrice(symbol, date1, 250));
cache.Add(CreatePrice(symbol, date2, 260));
DateTime minDate = cache.GetMinCacheDate(symbol);
Assert.AreEqual(date1, minDate);
}
#endregion
#region Concurrency & Thread Safety Tests
[TestMethod]
public void AddPricesConcurrently_ShouldNotThrowException()
{
string symbol = "MSFT";
DateTime date = new DateTime(2026, 2, 22);
List<Price> prices = new List<Price>
{
CreatePrice(symbol, date, 250),
CreatePrice(symbol, date.AddDays(1), 260)
};
List<System.Threading.Tasks.Task> tasks = new List<System.Threading.Tasks.Task>();
foreach (Price price in prices)
{
tasks.Add(System.Threading.Tasks.Task.Run(() => cache.Add(price)));
}
System.Threading.Tasks.Task.WhenAll(tasks).Wait();
Price cachedPrice = cache.GetPrice(symbol, date);
Assert.IsNotNull(cachedPrice);
}
[TestMethod]
public void ThreadSafety_WithMultipleAdds_ShouldNotCorruptCache()
{
string symbol = "MSFT";
DateTime date = new DateTime(2026, 2, 22);
List<Price> prices = new List<Price>();
for (int i = 0; i < 1000; i++)
{
prices.Add(CreatePrice(symbol, date.AddDays(i), 250 + i));
}
List<System.Threading.Tasks.Task> tasks = new List<System.Threading.Tasks.Task>();
foreach (Price price in prices)
{
tasks.Add(System.Threading.Tasks.Task.Run(() => cache.Add(price)));
}
System.Threading.Tasks.Task.WhenAll(tasks).Wait();
Price cachedPrice = cache.GetPrice(symbol, date.AddDays(500));
Assert.IsNotNull(cachedPrice);
}
#endregion
#region Remove/Clear Tests
[TestMethod]
public void RemoveDate_ShouldRemovePrice()
{
string symbol = "MSFT";
DateTime date = new DateTime(2026, 2, 22);
cache.Add(CreatePrice(symbol, date, 250));
cache.RemoveDate(date);
Assert.IsFalse(cache.ContainsPrice(symbol, date));
}
[TestMethod]
public void Clear_ShouldRemoveAllPrices()
{
string symbol = "MSFT";
DateTime date = new DateTime(2026, 2, 22);
cache.Add(CreatePrice(symbol, date, 250));
cache.Clear();
Assert.IsFalse(cache.ContainsPrice(symbol, date));
}
#endregion
#region Date & Range Tests
[TestMethod]
public void GetPrices_ShouldReturnCorrectPricesInRange()
{
string symbol = "MSFT";
DateTime endDate = new DateTime(2026, 2, 25);
int dayCount = 3;
DateGenerator dateGenerator = new DateGenerator();
List<DateTime> historicalDates = dateGenerator.GenerateHistoricalDates(endDate, dayCount);
Prices prices = new Prices();
int priceValue = 250;
foreach (DateTime date in historicalDates)
{
prices.Add(CreatePrice(symbol, date, priceValue));
priceValue++;
}
cache.Add(prices);
Prices rangePrices = cache.GetPrices(symbol, endDate, dayCount);
Assert.AreEqual(historicalDates.Count, rangePrices.Count);
for (int i = 0; i < historicalDates.Count; i++)
{
Assert.AreEqual(historicalDates[i], rangePrices[i].Date);
}
}
[TestMethod]
public void GetPrices_ShouldReturnEmptyForNoPricesInRange()
{
string symbol = "MSFT";
DateTime startDate = new DateTime(2026, 2, 22);
Prices rangePrices = cache.GetPrices(symbol, startDate, 3);
Assert.AreEqual(0, rangePrices.Count);
}
#endregion
#region Duplicate & Edge Case Tests
[TestMethod]
public void AddDuplicatePrice_ShouldNotDuplicateInCache()
{
string symbol = "MSFT";
DateTime date = new DateTime(2026, 2, 22);
Price price1 = CreatePrice(symbol, date, 250);
Price price2 = CreatePrice(symbol, date, 250);
cache.Add(price1);
cache.Add(price2);
Price cachedPrice = cache.GetPrice(symbol, date);
Assert.IsNotNull(cachedPrice);
Assert.AreEqual(250, cachedPrice.Close);
}
[TestMethod]
public void RemoveNonExistentPrice_ShouldNotThrowException()
{
DateTime date = new DateTime(2026, 2, 22);
cache.RemoveDate(date);
Assert.IsTrue(true);
}
[TestMethod]
public void AddPortfolioTrades_WithNoNewPrices_ShouldNotFetch()
{
DateGenerator dateGenerator = new DateGenerator();
string symbol = "MSFT";
DateTime tradeDate = new DateTime(2026, 2, 22);
tradeDate = dateGenerator.GetPrevBusinessDay(tradeDate);
// Add a price to the cache
cache.Add(CreatePrice(symbol, tradeDate, 250));
// Create PortfolioTrades manually
PortfolioTrades trades = new PortfolioTrades();
trades.Add(new PortfolioTrade
{
Symbol = symbol,
TradeDate = tradeDate,
Shares = 1,
Price = 250,
Status = "OPEN"
});
// Add portfolio trades to the cache — should not fetch additional prices
cache.Add(trades);
// Retrieve prices for the generated historical dates
Prices cachedPrices = cache.GetPrices(symbol, tradeDate, 1);
Assert.AreEqual(1, cachedPrices.Count);
Assert.AreEqual(tradeDate, cachedPrices[0].Date);
Assert.AreEqual(250, cachedPrices[0].Close);
}
[TestMethod]
public void AddListOfSymbols_EmptyList_ShouldNotThrow()
{
cache.Add(new List<string>(), DateTime.Today);
Assert.IsTrue(true);
}
[TestMethod]
public void AddListOfSymbols_PriceIsNull_ShouldNotThrow()
{
List<string> symbols = new List<string> { "MSFT" };
cache.Add(symbols, new DateTime(1900, 1, 1));
Assert.IsTrue(true);
}
[TestMethod]
public void ContainsPrice_ListSymbols_NullOrEmpty_ShouldReturnFalse()
{
Assert.IsFalse(cache.ContainsPrice((List<string>)null, DateTime.Today));
Assert.IsFalse(cache.ContainsPrice(new List<string>(), DateTime.Today));
}
[TestMethod]
public void GetPrice_SymbolExistsButDateMissing_ShouldReturnNull()
{
string symbol = "MSFT";
cache.Add(CreatePrice(symbol, new DateTime(2026, 2, 22), 250));
Price result = cache.GetPrice(symbol, new DateTime(2026, 2, 23));
Assert.IsNull(result);
}
[TestMethod]
public void RemoveDate_EmptyCache_ShouldNotThrow()
{
cache.RemoveDate(DateTime.Today);
Assert.IsTrue(true);
}
[TestMethod]
public void Refresh_EmptyCache_ShouldNotThrow()
{
cache.Clear();
cache.Refresh();
Assert.IsTrue(true);
}
[TestMethod]
public void Dispose_WhenAlreadyDisposed_ShouldNotThrow()
{
cache.Dispose();
cache.Dispose();
Assert.IsTrue(true);
}
[TestMethod]
public void Refresh_WithExistingPrices_ShouldNotThrow()
{
DateGenerator dateGenerator = new DateGenerator();
String symbol = "MSFT";
DateTime date1 = new DateTime(2026, 2, 22);
DateTime date2 = new DateTime(2026, 2, 23);
date1=dateGenerator.FindPrevBusinessDay(date1);
date2=dateGenerator.FindNextBusinessDay(date2);
Price price1 = CreatePrice(symbol, date1, 250);
Price price2 = CreatePrice(symbol, date2, 260);
cache.Add(price1);
cache.Add(price2);
// Refresh should process existing prices without throwing
cache.Refresh();
Prices cachedPrices1 = cache.GetPrices(symbol, date1, 1);
Prices cachedPrices2 = cache.GetPrices(symbol, date2, 1);
Assert.AreEqual(1, cachedPrices1.Count);
Assert.AreEqual(1, cachedPrices2.Count);
}
[TestMethod]
public void AddPrices_NullOrEmpty_ShouldNotThrow()
{
Prices emptyPrices = new Prices();
cache.Add(emptyPrices); // Should not throw
Prices emptyList = new Prices();
cache.Add(emptyList); // Should not throw
cache.Add((Price)null!); // Should not throw
Assert.IsTrue(true);
}
[TestMethod]
public void Dispose_WithPopulatedCache_ShouldNotThrow()
{
String symbol = "MSFT";
DateTime date = new DateTime(2026, 2, 22);
Price price = CreatePrice(symbol, date, 250);
cache.Add(price);
// Dispose should handle non-empty cache gracefully
cache.Dispose();
// Subsequent calls to Dispose should still be safe
cache.Dispose();
Assert.IsTrue(true);
}
[TestMethod]
public void ThreadSafety_WithMultipleAdds_ShouldNotCorruptCache_BusinessDays()
{
string symbol = "MSFT";
DateGenerator dateGenerator = new DateGenerator();
DateTime baseDate = new DateTime(2026, 2, 22);
int numPrices = 1000;
List<Price> prices = new List<Price>();
HashSet<DateTime> usedDates = new HashSet<DateTime>();
DateTime currentDate = baseDate;
int priceCounter = 0;
while (prices.Count < numPrices)
{
// Advance one day
currentDate = currentDate.AddDays(1);
// Adjust to previous business day
DateTime businessDate = dateGenerator.GetPrevBusinessDay(currentDate);
// Only add if unique
if (!usedDates.Contains(businessDate))
{
usedDates.Add(businessDate);
prices.Add(CreatePrice(symbol, businessDate, 250 + priceCounter));
priceCounter++;
currentDate = businessDate; // continue from this business date
}
}
// Multi-threaded addition
List<System.Threading.Tasks.Task> tasks = new List<System.Threading.Tasks.Task>();
foreach (Price price in prices)
{
tasks.Add(System.Threading.Tasks.Task.Run(() => cache.Add(price)));
}
System.Threading.Tasks.Task.WhenAll(tasks).Wait();
// Pick a middle sample
DateTime sampleDate = prices[numPrices / 2].Date;
Price cachedPrice = cache.GetPrice(symbol, sampleDate);
Assert.IsNotNull(cachedPrice);
Assert.AreEqual(250 + (numPrices / 2), cachedPrice.Close);
// Verify total count
Prices allCachedPrices = cache.GetPrices(symbol, prices[numPrices - 1].Date, numPrices);
Assert.AreEqual(numPrices, allCachedPrices.Count);
}
#endregion
}

View File

@@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Nullable>disable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
@@ -12,6 +12,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="Moq" Version="4.20.2" />
<PackageReference Include="MSTest.TestAdapter" Version="3.1.1" />
<PackageReference Include="MSTest.TestFramework" Version="3.1.1" />
</ItemGroup>