using System; using System.Collections.Generic; using Xunit; using QuantEngine.Core.Domain; using QuantEngine.Core.Interfaces; namespace QuantEngine.Core.Tests { public class FactorCalculatorTests { private List CreateMockBars(string ticker, double startPrice, double trend, int count) { var list = new List(); var startDate = new DateOnly(2026, 1, 1); for (int i = 0; i < count; i++) { double price = startPrice + (i * trend); list.Add(new PriceHistoryDailyRecord( ticker, startDate.AddDays(i), (decimal)price, (decimal)(price + 2.0), (decimal)(price - 2.0), (decimal)price, 100000, "TEST_SOURCE" )); } return list; } [Fact] public void CalculateFactors_EmptyStockBars_ReturnsAllZeros() { var stock = new List(); var index = new List(); var outputs = FactorCalculator.CalculateFactors(stock, index); Assert.Equal(0, outputs.Momentum20D); Assert.Equal(0, outputs.Momentum60D); Assert.Equal(0, outputs.Momentum120D); Assert.Equal(0, outputs.Atr20Pct); Assert.Equal(0, outputs.StDev20D); Assert.Equal(1.0, outputs.Beta60D); // Beta defaults to 1.0 on short data Assert.Equal(0, outputs.Rs20D); } [Fact] public void CalculateFactors_ConstantTrend_CalculatesCorrectMomentum() { // Stock starting at 100.0, rising 1.0 every day for 130 days. // On day 130 (index 129), price = 100 + 129 = 229. // Close[129] = 229. // Close[129-20] = Close[109] = 100 + 109 = 209. // Momentum 20D = ((229 - 209) / 209) * 100 = (20 / 209) * 100 = 9.5693% var stock = CreateMockBars("005930", 100.0, 1.0, 130); var index = CreateMockBars("KOSPI", 2000.0, 0.0, 130); // Constant index var outputs = FactorCalculator.CalculateFactors(stock, index); double expectedMom20 = (20.0 / 209.0) * 100.0; Assert.Equal(expectedMom20, outputs.Momentum20D, 5); // 5 decimals precision double expectedMom60 = (60.0 / 169.0) * 100.0; Assert.Equal(expectedMom60, outputs.Momentum60D, 5); double expectedMom120 = (120.0 / 109.0) * 100.0; Assert.Equal(expectedMom120, outputs.Momentum120D, 5); } [Fact] public void CalculateAtr20Pct_ConstantHighLowDifference_CalculatesCorrectAtrPct() { // Create stock where High - Low = 4.0 consistently, and close doesn't gap. // TR = High - Low = 4.0. // ATR 20D = Average TR over last 20 days = 4.0. // Final close price = 100 + 129 = 229. // ATR% = (4.0 / 229.0) * 100 = 1.7467% var stock = CreateMockBars("005930", 100.0, 1.0, 130); var index = CreateMockBars("KOSPI", 2000.0, 0.0, 130); var outputs = FactorCalculator.CalculateFactors(stock, index); double expectedAtrPct = (4.0 / 229.0) * 100.0; Assert.Equal(expectedAtrPct, outputs.Atr20Pct, 5); } [Fact] public void CalculatePriceStDev20D_CalculatesCorrectStDev() { // Close values over last 20 days: 210, 211, ..., 229. // Average = 219.5 // Variance = Sum(x_i - Avg)^2 / 19 var stock = CreateMockBars("005930", 100.0, 1.0, 130); var index = CreateMockBars("KOSPI", 2000.0, 0.0, 130); var outputs = FactorCalculator.CalculateFactors(stock, index); // Manual stdev calculation for sequential 20 numbers: stdev = sqrt( (20^2 - 1) * d^2 / 12 * N / (N-1) )? // stdev of 20 numbers with step 1: sqrt(35) * sqrt(20/19) ≈ 5.91608 * 1.02598 ≈ 6.0697 // Actual check using double math double sum = 0; for (int i = 110; i < 130; i++) sum += (100.0 + i); double avg = sum / 20.0; double sumSquares = 0; for (int i = 110; i < 130; i++) sumSquares += Math.Pow((100.0 + i) - avg, 2); double expectedStDev = Math.Sqrt(sumSquares / 19.0); Assert.Equal(expectedStDev, outputs.StDev20D, 5); } [Fact] public void CalculateFactors_UnsortedInput_ProducesSameOutputs() { var stock = CreateMockBars("005930", 100.0, 1.0, 130); var index = CreateMockBars("KOSPI", 2000.0, 0.0, 130); stock.Reverse(); index.Reverse(); var outputs = FactorCalculator.CalculateFactors(stock, index); double expectedMom20 = (20.0 / 209.0) * 100.0; Assert.Equal(expectedMom20, outputs.Momentum20D, 5); Assert.Equal(1.0, outputs.Beta60D); } [Fact] public void CalculateFactors_DuplicateDates_UsesLastOccurrencePerDate() { var stock = CreateMockBars("005930", 100.0, 1.0, 130); var index = CreateMockBars("KOSPI", 2000.0, 0.0, 130); var duplicateDate = stock[129].TradeDate; stock.Add(new PriceHistoryDailyRecord( "005930", duplicateDate, 1000m, 1002m, 998m, 1001m, 100000, "TEST_SOURCE")); var outputs = FactorCalculator.CalculateFactors(stock, index); Assert.True(outputs.Momentum20D > 0); Assert.True(outputs.Atr20Pct > 0); } } }