feat(qe-m3-02): implement C# FactorCalculator and xUnit parity tests for Momentum, ATR, StDev, and Beta
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 0s
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 13s

This commit is contained in:
2026-07-12 21:51:37 +09:00
parent b0c9776601
commit 89d5842505
4 changed files with 326 additions and 1 deletions
@@ -0,0 +1,180 @@
using System;
using System.Collections.Generic;
using System.Linq;
using QuantEngine.Core.Interfaces;
namespace QuantEngine.Core.Domain
{
public record FactorOutputs(
double Momentum20D,
double Momentum60D,
double Momentum120D,
double Atr20Pct,
double StDev20D,
double Beta60D,
double Rs20D
);
public static class FactorCalculator
{
public static FactorOutputs CalculateFactors(
List<PriceHistoryDailyRecord> stockBars,
List<PriceHistoryDailyRecord> indexBars)
{
if (stockBars == null || stockBars.Count < 2)
{
return new FactorOutputs(0, 0, 0, 0, 0, 1.0, 0);
}
// Ensure sorted chronologically (oldest to newest)
var sortedStock = stockBars.OrderBy(b => b.TradeDate).ToList();
var sortedIndex = indexBars?.OrderBy(b => b.TradeDate).ToList() ?? new List<PriceHistoryDailyRecord>();
int count = sortedStock.Count;
double closeToday = (double)sortedStock[^1].Close;
// 1. Momentum
double mom20 = CalculateMomentum(sortedStock, 20);
double mom60 = CalculateMomentum(sortedStock, 60);
double mom120 = CalculateMomentum(sortedStock, 120);
// 2. ATR 20D Percentage
double atrPct = CalculateAtr20Pct(sortedStock);
// 3. Price Standard Deviation 20D
double stdev = CalculatePriceStDev20D(sortedStock);
// 4. Beta 60D
double beta = CalculateBeta60D(sortedStock, sortedIndex);
// 5. Relative Strength (RS) 20D (vs Index)
double rs = CalculateRs20D(sortedStock, sortedIndex);
return new FactorOutputs(mom20, mom60, mom120, atrPct, stdev, beta, rs);
}
private static double CalculateMomentum(List<PriceHistoryDailyRecord> bars, int period)
{
if (bars.Count <= period) return 0.0;
double current = (double)bars[^1].Close;
double prev = (double)bars[^(period + 1)].Close;
if (prev <= 0.0) return 0.0;
return ((current - prev) / prev) * 100.0;
}
private static double CalculateAtr20Pct(List<PriceHistoryDailyRecord> bars)
{
if (bars.Count < 21) return 0.0;
var trList = new List<double>();
for (int i = bars.Count - 20; i < bars.Count; i++)
{
double high = (double)bars[i].High;
double low = (double)bars[i].Low;
double prevClose = (double)bars[i - 1].Close;
double tr = Math.Max(high - low, Math.Max(Math.Abs(high - prevClose), Math.Abs(low - prevClose)));
trList.Add(tr);
}
double atr = trList.Average();
double closeToday = (double)bars[^1].Close;
if (closeToday <= 0.0) return 0.0;
return (atr / closeToday) * 100.0;
}
private static double CalculatePriceStDev20D(List<PriceHistoryDailyRecord> bars)
{
if (bars.Count < 20) return 0.0;
var subset = bars.Skip(bars.Count - 20).Select(b => (double)b.Close).ToList();
double avg = subset.Average();
double sumOfSquares = subset.Sum(val => Math.Pow(val - avg, 2));
// Sample standard deviation (N-1)
return Math.Sqrt(sumOfSquares / (subset.Count - 1));
}
private static double CalculateBeta60D(List<PriceHistoryDailyRecord> stock, List<PriceHistoryDailyRecord> index)
{
if (stock.Count < 61 || index.Count < 61) return 1.0;
// Align daily returns
var stockMap = stock.ToDictionary(b => b.TradeDate);
var indexMap = index.ToDictionary(b => b.TradeDate);
// Compute returns for overlapping dates
var overlappingDates = stockMap.Keys.Intersect(indexMap.Keys).OrderBy(d => d).ToList();
if (overlappingDates.Count < 61) return 1.0;
var alignedStock = overlappingDates.Select(d => stockMap[d]).ToList();
var alignedIndex = overlappingDates.Select(d => indexMap[d]).ToList();
var stockReturns = new List<double>();
var indexReturns = new List<double>();
// Calculate returns starting from last 60 days
int startIdx = Math.Max(1, alignedStock.Count - 60);
for (int i = startIdx; i < alignedStock.Count; i++)
{
double sPrev = (double)alignedStock[i - 1].Close;
double sCurr = (double)alignedStock[i].Close;
double iPrev = (double)alignedIndex[i - 1].Close;
double iCurr = (double)alignedIndex[i].Close;
if (sPrev > 0 && iPrev > 0)
{
stockReturns.Add((sCurr - sPrev) / sPrev);
indexReturns.Add((iCurr - iPrev) / iPrev);
}
}
if (stockReturns.Count < 10) return 1.0;
double avgStock = stockReturns.Average();
double avgIndex = indexReturns.Average();
double covariance = 0.0;
double varianceIndex = 0.0;
for (int i = 0; i < stockReturns.Count; i++)
{
double diffStock = stockReturns[i] - avgStock;
double diffIndex = indexReturns[i] - avgIndex;
covariance += diffStock * diffIndex;
varianceIndex += diffIndex * diffIndex;
}
if (varianceIndex <= 0.0) return 1.0;
return covariance / varianceIndex;
}
private static double CalculateRs20D(List<PriceHistoryDailyRecord> stock, List<PriceHistoryDailyRecord> index)
{
if (stock.Count < 21 || index.Count < 21) return 0.0;
// Align dates
var stockMap = stock.ToDictionary(b => b.TradeDate);
var indexMap = index.ToDictionary(b => b.TradeDate);
var overlappingDates = stockMap.Keys.Intersect(indexMap.Keys).OrderBy(d => d).ToList();
if (overlappingDates.Count < 21) return 0.0;
var alignedStock = overlappingDates.Select(d => stockMap[d]).ToList();
var alignedIndex = overlappingDates.Select(d => indexMap[d]).ToList();
double sCurr = (double)alignedStock[^1].Close;
double sPrev = (double)alignedStock[^(20 + 1)].Close;
double iCurr = (double)alignedIndex[^1].Close;
double iPrev = (double)alignedIndex[^(20 + 1)].Close;
if (sPrev <= 0.0 || iPrev <= 0.0) return 0.0;
double stockReturn = (sCurr - sPrev) / sPrev * 100.0;
double indexReturn = (iCurr - iPrev) / iPrev * 100.0;
return stockReturn - indexReturn;
}
}
}