diff --git a/src/dotnet/QuantEngine.Core/QuantEngine/FactorEngine.cs b/src/dotnet/QuantEngine.Core/QuantEngine/FactorEngine.cs deleted file mode 100644 index 5a7308e4..00000000 --- a/src/dotnet/QuantEngine.Core/QuantEngine/FactorEngine.cs +++ /dev/null @@ -1,302 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using QuantEngine.Core.Repositories; - -namespace QuantEngine.Core.QuantEngine; - -/// -/// Factor Engine: Compute quantitative factors for decision-making. -/// -/// All investment decisions are based on DATA, not intuition (홀루시네이션 방지). -/// Each factor is mathematically verifiable and reproducible. -/// -/// Factors Computed: -/// 1. SharpeRatio: Risk-adjusted return (Excess Return / Volatility) -/// 2. Volatility: Price fluctuation (Standard Deviation) -/// 3. Correlation: Co-movement with other assets -/// 4. Momentum: Price trend strength (recent return acceleration) -/// 5. MeanReversion: Tendency to revert to average -/// 6. Liquidity: Ease of trading (volume, bid-ask spread) -/// -/// SOLID Applied: -/// - Single Responsibility: Compute factors only -/// - Dependency Inversion: Depends on ISnapshotRepository abstraction -/// - Testable: All calculations are deterministic and verifiable -/// -public interface IFactorEngine { - Task ComputeAsync(string ticker, DateRange period); - Task> ComputeCorrelationMatrixAsync(IEnumerable tickers, DateRange period); -} - -public class FactorEngine : IFactorEngine { - private readonly ISnapshotRepository _repository; - private readonly const double RiskFreeRate = 0.02; // 2% annual (conservative estimate) - - public FactorEngine(ISnapshotRepository repository) { - _repository = repository ?? throw new ArgumentNullException(nameof(repository)); - } - - /// - /// Compute all factors for a given ticker and period. - /// Throws if insufficient data (< 20 samples). - /// - public async Task ComputeAsync(string ticker, DateRange period) { - var snapshots = await _repository.GetByTickerAsync(ticker, period.Start, period.End); - - if (snapshots.Count < 20) { - throw new InsufficientDataException($"Only {snapshots.Count} samples for {ticker}, need 20+"); - } - - // Verify data continuity (no gaps > 5 days) - var gaps = DetectDataGaps(snapshots); - if (gaps > 5) { - throw new DataGapException($"Detected {gaps} gaps in time series for {ticker}"); - } - - var prices = snapshots.OrderBy(s => s.CollectedAt).Select(s => s.Price).ToList(); - var returns = ComputeReturns(prices); - - return new FactorMetrics { - Ticker = ticker, - SharpeRatio = ComputeSharpeRatio(returns), - Volatility = ComputeVolatility(returns), - Momentum = ComputeMomentum(returns), - MeanReversion = ComputeMeanReversion(returns), - Liquidity = ComputeLiquidity(snapshots), - DataPoints = snapshots.Count, - PeriodStart = snapshots.First().CollectedAt, - PeriodEnd = snapshots.Last().CollectedAt, - ComputedAt = DateTime.UtcNow, - }; - } - - /// - /// Compute correlation matrix for portfolio optimization. - /// Used by GameTheoreticPortfolio for Nash equilibrium calculation. - /// - public async Task> ComputeCorrelationMatrixAsync( - IEnumerable tickers, DateRange period) { - - var results = new Dictionary(); - var tickerList = tickers.ToList(); - - for (int i = 0; i < tickerList.Count; i++) { - for (int j = i; j < tickerList.Count; j++) { - var key = $"{tickerList[i]}-{tickerList[j]}"; - - if (i == j) { - // Correlation with self = 1.0 - results[key] = 1.0; - } else { - var correlation = await ComputeCorrelationAsync(tickerList[i], tickerList[j], period); - results[key] = correlation; - results[$"{tickerList[j]}-{tickerList[i]}"] = correlation; // Symmetric - } - } - } - - return results; - } - - // ========================================================================= - // Private Calculation Methods (All Deterministic & Verifiable) - // ========================================================================= - - /// - /// Sharpe Ratio = (Mean Return - Risk Free Rate) / Volatility - /// Higher is better. Measures excess return per unit of risk. - /// - private double ComputeSharpeRatio(List returns) { - if (returns.Count < 2) return 0; - - var meanReturn = returns.Average(); - var volatility = ComputeVolatility(returns); - - if (volatility == 0) return 0; // Avoid division by zero - - return (meanReturn - RiskFreeRate) / volatility; - } - - /// - /// Volatility = Standard Deviation of returns - /// Higher volatility = higher risk. - /// - private double ComputeVolatility(List returns) { - if (returns.Count < 2) return 0; - - var mean = returns.Average(); - var variance = returns.Sum(r => Math.Pow(r - mean, 2)) / (returns.Count - 1); // Sample variance - - return Math.Sqrt(variance); - } - - /// - /// Momentum = Recent return acceleration - /// Compares recent 20-day return vs overall period return. - /// Positive: trending up. Negative: trending down. - /// - private double ComputeMomentum(List returns) { - if (returns.Count < 20) return 0; - - var recent = returns.TakeLast(20).Average(); - var overall = returns.Average(); - - return recent - overall; - } - - /// - /// Mean Reversion = Deviation from mean - /// High deviation suggests future correction (reversion to mean). - /// - private double ComputeMeanReversion(List returns) { - if (returns.Count < 10) return 0; - - var mean = returns.Average(); - var recent = returns.Last(); - var volatility = ComputeVolatility(returns); - - if (volatility == 0) return 0; - - // Z-score: how many std devs away from mean? - return Math.Abs((recent - mean) / volatility); - } - - /// - /// Liquidity = Average daily volume relative to bid-ask spread - /// Higher volume, tighter spread = better liquidity. - /// - private double ComputeLiquidity(List snapshots) { - if (snapshots.Count < 10) return 0; - - var recentSnapshots = snapshots.TakeLast(10).ToList(); - var avgVolume = recentSnapshots.Average(s => s.Volume ?? 0); - var avgSpread = recentSnapshots - .Where(s => s.Bid.HasValue && s.Ask.HasValue) - .Average(s => (s.Ask!.Value - s.Bid!.Value) / s.Price); - - if (avgSpread == 0) return 1.0; // Perfect liquidity - - return avgVolume / (1 + avgSpread * 100); // Penalize spreads - } - - /// - /// Correlation = Pearson correlation coefficient between two return series - /// Range: -1 (perfect inverse) to +1 (perfect positive) - /// - private async Task ComputeCorrelationAsync(string ticker1, string ticker2, DateRange period) { - var snapshots1 = await _repository.GetByTickerAsync(ticker1, period.Start, period.End); - var snapshots2 = await _repository.GetByTickerAsync(ticker2, period.Start, period.End); - - if (snapshots1.Count < 20 || snapshots2.Count < 20) return 0; - - var prices1 = snapshots1.OrderBy(s => s.CollectedAt).Select(s => s.Price).ToList(); - var prices2 = snapshots2.OrderBy(s => s.CollectedAt).Select(s => s.Price).ToList(); - - var returns1 = ComputeReturns(prices1); - var returns2 = ComputeReturns(prices2); - - if (returns1.Count != returns2.Count) return 0; // Misaligned data - - var mean1 = returns1.Average(); - var mean2 = returns2.Average(); - - var covariance = 0.0; - var variance1 = 0.0; - var variance2 = 0.0; - - for (int i = 0; i < returns1.Count; i++) { - var dev1 = returns1[i] - mean1; - var dev2 = returns2[i] - mean2; - - covariance += dev1 * dev2; - variance1 += dev1 * dev1; - variance2 += dev2 * dev2; - } - - covariance /= returns1.Count - 1; - variance1 = Math.Sqrt(variance1 / (returns1.Count - 1)); - variance2 = Math.Sqrt(variance2 / (returns2.Count - 1)); - - if (variance1 == 0 || variance2 == 0) return 0; - - return covariance / (variance1 * variance2); - } - - /// - /// Compute daily returns from price series - /// - private List ComputeReturns(List prices) { - var returns = new List(); - - for (int i = 1; i < prices.Count; i++) { - var dailyReturn = (double)((prices[i] - prices[i - 1]) / prices[i - 1]); - returns.Add(dailyReturn); - } - - return returns; - } - - /// - /// Detect gaps in time series (> 5 days without data) - /// - private int DetectDataGaps(List snapshots) { - if (snapshots.Count < 2) return 0; - - var gaps = 0; - var sorted = snapshots.OrderBy(s => s.CollectedAt).ToList(); - - for (int i = 1; i < sorted.Count; i++) { - var daysDiff = (sorted[i].CollectedAt - sorted[i - 1].CollectedAt).TotalDays; - if (daysDiff > 5) gaps++; - } - - return gaps; - } -} - -/// -/// All computed factors for a ticker and period. -/// This is the input data for GameTheoreticPortfolio. -/// -public class FactorMetrics { - public string Ticker { get; set; } = string.Empty; - public double SharpeRatio { get; set; } - public double Volatility { get; set; } - public double Momentum { get; set; } - public double MeanReversion { get; set; } - public double Liquidity { get; set; } - public int DataPoints { get; set; } - public DateTime PeriodStart { get; set; } - public DateTime PeriodEnd { get; set; } - public DateTime ComputedAt { get; set; } - public DateTime ValidUntil => ComputedAt.AddHours(1); // Factors expire after 1 hour -} - -/// -/// Date range for factor computation. -/// -public class DateRange { - public DateTime Start { get; set; } - public DateTime End { get; set; } - - public static DateRange Last30Days => new() { - Start = DateTime.UtcNow.AddDays(-30), - End = DateTime.UtcNow, - }; - - public static DateRange Last90Days => new() { - Start = DateTime.UtcNow.AddDays(-90), - End = DateTime.UtcNow, - }; -} - -// Exceptions -public class InsufficientDataException : Exception { - public InsufficientDataException(string message) : base(message) { } -} - -public class DataGapException : Exception { - public DataGapException(string message) : base(message) { } -} diff --git a/src/dotnet/QuantEngine.Core/QuantEngine/GameTheoreticPortfolio.cs b/src/dotnet/QuantEngine.Core/QuantEngine/GameTheoreticPortfolio.cs deleted file mode 100644 index 547781e4..00000000 --- a/src/dotnet/QuantEngine.Core/QuantEngine/GameTheoreticPortfolio.cs +++ /dev/null @@ -1,343 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; - -namespace QuantEngine.Core.QuantEngine; - -/// -/// Game Theoretic Portfolio Optimization via Nash Equilibrium. -/// -/// GAME THEORY PRINCIPLES: -/// ───────────────────────── -/// Game: Asset allocation problem -/// Players: Portfolio manager (single player, but competing against market) -/// Strategy: Weight allocation w = [w1, w2, ..., wn], sum(w) = 1 -/// Payoff: Risk-adjusted return (Sharpe ratio) -/// -/// NASH EQUILIBRIUM: -/// ───────────────── -/// "A solution where no player can improve by unilaterally changing strategy" -/// -/// For portfolio: -/// "A weight allocation where changing any wi (reducing by 1%) results in lower return" -/// -/// MATHEMATICAL FORMULATION: -/// ────────────────────────── -/// Minimize: w^T * Σ * w (Portfolio variance) -/// Subject to: -/// sum(w) = 1 (Weights sum to 100%) -/// w_min ≤ w_i ≤ w_max (Position limits) -/// Correlation penalty applied (Avoid concentration) -/// -/// EQUILIBRIUM CHECK: -/// ────────────────── -/// For each position i: -/// 1. Compute current utility U(w) -/// 2. Create w' where w'_i = w_i - 1% -/// 3. Rebalance other weights: w'_j *= (sum - 1%) / sum -/// 4. Compute utility U(w') -/// 5. Nash check: U(w') must be ≤ U(w) for all i -/// (Cannot improve by moving away from current allocation) -/// -/// If all checks pass → weights are in Nash equilibrium -/// If any check fails → solution is not optimal -/// -public interface IGameTheoreticPortfolio { - Task ComputeNashEquilibriumAsync( - IEnumerable candidates, - PortfolioConstraints constraints, - Dictionary factorMetrics - ); -} - -public class GameTheoreticPortfolio : IGameTheoreticPortfolio { - private readonly ILogger _logger; - private readonly const double EquilibriumThreshold = 0.01; // 1% tolerance - private readonly const double ConcentrationPenalty = 0.05; // Penalize high concentration - - public GameTheoreticPortfolio(ILogger logger) { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// - /// Compute optimal portfolio weights that form a Nash equilibrium. - /// Raises exception if solution is not equilibrium. - /// - public async Task ComputeNashEquilibriumAsync( - IEnumerable candidates, - PortfolioConstraints constraints, - Dictionary factorMetrics) { - - var tickerList = candidates.ToList(); - - _logger.LogInformation( - "[GameTheoreticPortfolio] Computing Nash equilibrium for {Count} candidates", - tickerList.Count - ); - - // 1. COMPUTE COVARIANCE MATRIX - var covarianceMatrix = await ComputeCovarianceMatrixAsync(tickerList, factorMetrics); - - // 2. OPTIMIZE: Minimum Variance Portfolio (MVP) - var optimalWeights = SolveMinimumVariancePortfolio(tickerList, covarianceMatrix, constraints); - - // 3. VERIFY: Nash Equilibrium - var isNash = VerifyNashEquilibrium(optimalWeights, factorMetrics); - if (!isNash) { - throw new NonEquilibriumSolutionException( - "Optimization failed to converge to Nash equilibrium. Solution is sub-optimal." - ); - } - - _logger.LogInformation( - "[GameTheoreticPortfolio] Nash equilibrium verified | Weights: {Weights}", - string.Join(", ", optimalWeights.Select(x => $"{x.Key}={x.Value:P2}")) - ); - - // 4. COMPUTE PORTFOLIO METRICS - var expectedReturn = ComputeExpectedReturn(optimalWeights, factorMetrics); - var riskLevel = ComputePortfolioRisk(optimalWeights, covarianceMatrix); - var diversificationRatio = ComputeDiversificationRatio(optimalWeights, covarianceMatrix); - - return new PortfolioAllocation { - Weights = optimalWeights, - ExpectedReturn = expectedReturn, - RiskLevel = riskLevel, - DiversificationRatio = diversificationRatio, - NashEquilibrium = true, - ComputedAt = DateTime.UtcNow, - ValidUntil = DateTime.UtcNow.AddHours(1), // Rebalance hourly - Rationale = "Nash equilibrium: No single position can be reduced without worsening portfolio risk-adjusted return", - }; - } - - // ========================================================================= - // PRIVATE IMPLEMENTATION - // ========================================================================= - - /// - /// Compute covariance matrix from factor metrics. - /// - private async Task> ComputeCovarianceMatrixAsync( - List tickers, - Dictionary factorMetrics) { - - var matrix = new Dictionary<(string, string), double>(); - - for (int i = 0; i < tickers.Count; i++) { - for (int j = i; j < tickers.Count; j++) { - var t1 = tickers[i]; - var t2 = tickers[j]; - - double covariance; - if (i == j) { - // Variance (self-covariance) - covariance = Math.Pow(factorMetrics[t1].Volatility, 2); - } else { - // Simplified: assume correlation based on similar momentum/reversion - var correlation = EstimateCorrelation(factorMetrics[t1], factorMetrics[t2]); - covariance = correlation * factorMetrics[t1].Volatility * factorMetrics[t2].Volatility; - } - - matrix[(t1, t2)] = covariance; - if (i != j) matrix[(t2, t1)] = covariance; // Symmetric - } - } - - return matrix; - } - - /// - /// Estimate correlation between two stocks based on factor similarity. - /// Simplified approximation (real version would use historical correlation). - /// - private double EstimateCorrelation(FactorMetrics f1, FactorMetrics f2) { - // Similar momentum → higher correlation (move together) - var momentumDiff = Math.Abs(f1.Momentum - f2.Momentum); - var momentumCorr = Math.Max(0, 1.0 - momentumDiff); - - // Similar volatility → potential risk cluster - var volDiff = Math.Abs(f1.Volatility - f2.Volatility); - var volCorr = Math.Max(0, 1.0 - volDiff); - - return (momentumCorr + volCorr) / 2.0; // Average of two factors - } - - /// - /// Solve minimum variance portfolio (MVP) subject to constraints. - /// Simplified: Equal-weight as starting point, optimize by Sharpe ratio. - /// Real version: Use quadratic programming (cvxpy, scipy.optimize). - /// - private Dictionary SolveMinimumVariancePortfolio( - List tickers, - Dictionary<(string, string), double> covarianceMatrix, - PortfolioConstraints constraints) { - - // Simplified optimization: weight by inverse volatility + Sharpe ratio - var weights = new Dictionary(); - var scores = new Dictionary(); - - foreach (var ticker in tickers) { - // Score = Sharpe ratio / volatility (risk-adjusted efficiency) - // Higher score = better risk-adjusted return - var score = 1.0 / Math.Max(0.01, covarianceMatrix[(ticker, ticker)]); - scores[ticker] = score; - } - - var totalScore = scores.Values.Sum(); - foreach (var ticker in tickers) { - var weight = scores[ticker] / totalScore; - weights[ticker] = Math.Min(constraints.MaxWeight, Math.Max(constraints.MinWeight, weight)); - } - - // Normalize to sum = 1 - var totalWeight = weights.Values.Sum(); - foreach (var ticker in tickers) { - weights[ticker] /= totalWeight; - } - - return weights; - } - - /// - /// CRITICAL: Verify that the proposed allocation is a Nash equilibrium. - /// If any position can be improved by changing weights, fail validation. - /// - private bool VerifyNashEquilibrium( - Dictionary weights, - Dictionary factorMetrics) { - - var currentUtility = ComputePortfolioUtility(weights, factorMetrics); - - foreach (var (ticker, weight) in weights) { - if (weight < EquilibriumThreshold) continue; // Skip tiny positions - - // Test: reduce this position by 1% - var altWeights = new Dictionary(weights); - altWeights[ticker] -= EquilibriumThreshold; - - if (altWeights[ticker] < 0) altWeights[ticker] = 0; - - // Rebalance other weights proportionally - var remainingWeight = altWeights.Values.Sum(); - if (remainingWeight > 0) { - foreach (var key in altWeights.Keys.ToList()) { - altWeights[key] /= remainingWeight; - } - } - - var altUtility = ComputePortfolioUtility(altWeights, factorMetrics); - - // Nash check: alternative utility must be WORSE (or equal) than current - if (altUtility > currentUtility + double.Epsilon) { - _logger.LogWarning( - "[GameTheoreticPortfolio] Nash check failed for {Ticker}: " + - "Reducing by 1% improves utility from {Current} to {Alt}", - ticker, currentUtility, altUtility - ); - return false; // Can improve by reducing this position → not Nash - } - } - - return true; // No position can be improved → Nash equilibrium verified - } - - /// - /// Compute portfolio utility = Sharpe ratio (risk-adjusted return) - /// - private double ComputePortfolioUtility( - Dictionary weights, - Dictionary factorMetrics) { - - var expectedReturn = weights - .Sum(x => x.Value * factorMetrics[x.Key].SharpeRatio); - - // Penalize concentration (lack of diversification) - var herfindahl = weights.Values.Sum(w => w * w); // Herfindahl index - var concentrationPenalty = herfindahl * ConcentrationPenalty; - - return expectedReturn - concentrationPenalty; - } - - /// - /// Compute expected return of portfolio - /// - private double ComputeExpectedReturn( - Dictionary weights, - Dictionary factorMetrics) { - - return weights - .Where(x => factorMetrics.ContainsKey(x.Key)) - .Sum(x => x.Value * factorMetrics[x.Key].SharpeRatio); - } - - /// - /// Compute portfolio risk (standard deviation) - /// - private double ComputePortfolioRisk( - Dictionary weights, - Dictionary<(string, string), double> covarianceMatrix) { - - var variance = 0.0; - - foreach (var (t1, w1) in weights) { - foreach (var (t2, w2) in weights) { - if (covarianceMatrix.TryGetValue((t1, t2), out var covariance)) { - variance += w1 * w2 * covariance; - } - } - } - - return Math.Sqrt(Math.Max(0, variance)); - } - - /// - /// Compute diversification ratio = Average single-asset volatility / Portfolio volatility - /// Higher = better diversified - /// - private double ComputeDiversificationRatio( - Dictionary weights, - Dictionary<(string, string), double> covarianceMatrix) { - - var avgVolatility = weights - .Average(x => Math.Sqrt(Math.Max(0, covarianceMatrix[(x.Key, x.Key)]))); - - var portfolioVolatility = ComputePortfolioRisk(weights, covarianceMatrix); - - if (portfolioVolatility == 0) return 1.0; - - return avgVolatility / portfolioVolatility; - } -} - -/// -/// Portfolio allocation result with Nash equilibrium validation. -/// -public class PortfolioAllocation { - public Dictionary Weights { get; set; } = new(); - public double ExpectedReturn { get; set; } - public double RiskLevel { get; set; } - public double DiversificationRatio { get; set; } - public bool NashEquilibrium { get; set; } - public DateTime ComputedAt { get; set; } - public DateTime ValidUntil { get; set; } - public string Rationale { get; set; } = string.Empty; -} - -/// -/// Constraints for portfolio optimization. -/// -public class PortfolioConstraints { - public double MinWeight { get; set; } = 0.01; // Minimum 1% per position - public double MaxWeight { get; set; } = 0.30; // Maximum 30% per position - public double MinDiversification { get; set; } = 1.1; // Min diversification ratio -} - -/// -/// Exception: Solution is not a Nash equilibrium. -/// -public class NonEquilibriumSolutionException : Exception { - public NonEquilibriumSolutionException(string message) : base(message) { } -} diff --git a/src/dotnet/QuantEngine.Core/Scheduling/IDataValidator.cs b/src/dotnet/QuantEngine.Core/Scheduling/IDataValidator.cs new file mode 100644 index 00000000..412090ef --- /dev/null +++ b/src/dotnet/QuantEngine.Core/Scheduling/IDataValidator.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; + +namespace QuantEngine.Core.Scheduling +{ + /// + /// Data validation interface for quality gates. + /// Part of Phase 0: 5-point validation (Completeness, Freshness, Consistency, Outliers, Duplicates). + /// + public interface IDataValidator + { + /// + /// Validate a single snapshot record. + /// Returns result with status and failed checks. + /// + ValidationResult Validate(Dictionary data); + } + + /// + /// Validation result with detailed failure information. + /// + public class ValidationResult + { + public bool IsValid { get; set; } + public List FailedChecks { get; set; } = new(); + public string Status { get; set; } = "PASS"; + } +} diff --git a/src/dotnet/QuantEngine.Core/Scheduling/ISnapshotRepository.cs b/src/dotnet/QuantEngine.Core/Scheduling/ISnapshotRepository.cs new file mode 100644 index 00000000..bbad60f1 --- /dev/null +++ b/src/dotnet/QuantEngine.Core/Scheduling/ISnapshotRepository.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace QuantEngine.Core.Scheduling +{ + /// + /// Repository interface for snapshot data access. + /// Abstraction layer for data persistence (Phase 1: SOLID + Repository pattern). + /// + public interface ISnapshotRepository + { + Task SaveAsync(Dictionary snapshot); + Task>> GetByTickerAsync(string ticker, DateTime start, DateTime end); + } +} diff --git a/src/dotnet/QuantEngine.Core/Scheduling/Jobs/KisDataCollectionJob.cs b/src/dotnet/QuantEngine.Core/Scheduling/Jobs/KisDataCollectionJob.cs deleted file mode 100644 index af71a5c0..00000000 --- a/src/dotnet/QuantEngine.Core/Scheduling/Jobs/KisDataCollectionJob.cs +++ /dev/null @@ -1,163 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using QuantEngine.Core.KIS; -using QuantEngine.Core.Repositories; -using QuantEngine.Core.Validation; - -namespace QuantEngine.Core.Scheduling.Jobs; - -/// -/// KIS Data Collection Job: Fetch quotation data from KIS API and store to database. -/// -/// Responsibilities: -/// 1. Fetch data from KIS API (via IKisApiClient) -/// 2. Validate data quality (via IDataValidator) -/// 3. Store to database (via ISnapshotRepository) -/// 4. Record metrics and audit trail -/// -/// SOLID Applied: -/// - Single Responsibility: Only data collection orchestration -/// - Dependency Injection: IKisApiClient, ISnapshotRepository, IDataValidator -/// - Failure Handling: Continue on individual ticker errors, log all failures -/// -public class KisDataCollectionJob : SchedulerJob { - private readonly IKisApiClient _kisClient; - private readonly ISnapshotRepository _snapshotRepository; - private readonly IDataValidator _dataValidator; - private readonly IEnumerable _tickers; - - public KisDataCollectionJob( - IKisApiClient kisClient, - ISnapshotRepository snapshotRepository, - IDataValidator dataValidator, - ILogger logger, - IMetricsRecorder metrics, - IEnumerable tickers) : base(logger, metrics) { - - _kisClient = kisClient ?? throw new ArgumentNullException(nameof(kisClient)); - _snapshotRepository = snapshotRepository ?? throw new ArgumentNullException(nameof(snapshotRepository)); - _dataValidator = dataValidator ?? throw new ArgumentNullException(nameof(dataValidator)); - _tickers = tickers ?? throw new ArgumentNullException(nameof(tickers)); - - JobId = "kis-data-collection"; - Description = "Collect quotation data from KIS API (stock prices, bid/ask, volume)"; - CronExpression = "30 0 * * 1-5"; // 00:30 KST, weekdays only - } - - protected override async Task RunAsync() { - var runId = Guid.NewGuid(); - var results = new List(); - - foreach (var ticker in _tickers) { - try { - var snapshots = await _kisClient.FetchCurrentPriceAsync(ticker); - - foreach (var snapshot in snapshots) { - // Validation: 5-point gate - var validation = _dataValidator.Validate(snapshot); - if (!validation.IsValid) { - Logger.LogWarning( - "[{JobId}] Ticker {Ticker}: Validation failed | Issues: {Issues}", - JobId, ticker, string.Join(", ", validation.FailedChecks) - ); - - results.Add(new SnapshotCollectionResult { - Ticker = ticker, - Status = CollectionStatus.ValidationFailed, - ErrorMessage = string.Join("; ", validation.FailedChecks), - }); - continue; - } - - // Save to database - snapshot.RunId = runId; - await _snapshotRepository.SaveAsync(snapshot); - - results.Add(new SnapshotCollectionResult { - Ticker = ticker, - Status = CollectionStatus.Success, - SnapshotId = snapshot.Id, - }); - } - } - catch (KisApiException ex) { - Logger.LogError( - ex, - "[{JobId}] Ticker {Ticker}: KIS API Error | Error: {Error}", - JobId, ticker, ex.Message - ); - - results.Add(new SnapshotCollectionResult { - Ticker = ticker, - Status = CollectionStatus.ApiError, - ErrorMessage = ex.Message, - }); - } - catch (Exception ex) { - Logger.LogError( - ex, - "[{JobId}] Ticker {Ticker}: Unexpected error | Error: {Error}", - JobId, ticker, ex.Message - ); - - results.Add(new SnapshotCollectionResult { - Ticker = ticker, - Status = CollectionStatus.Failed, - ErrorMessage = ex.Message, - }); - } - } - - var summary = new JobResult { - Summary = $"Collected {results.Count} snapshots from {_tickers.Count()} tickers", - TotalRuns = results.Count, - Succeeded = results.Count(r => r.Status == CollectionStatus.Success), - Failed = results.Count(r => r.Status != CollectionStatus.Success), - }; - - Logger.LogInformation( - "[{JobId}] Collection Summary: Total={Total}, Succeeded={Succeeded}, Failed={Failed}, SuccessRate={SuccessRate:P}", - JobId, summary.TotalRuns, summary.Succeeded, summary.Failed, summary.SuccessRate - ); - - return summary; - } - - protected override async Task RecordMetricsAsync(JobExecutionContext context) { - await base.RecordMetricsAsync(context); - - if (context.Result is JobResult result) { - var tags = new Dictionary { - { "job_id", JobId }, - { "ticker_count", _tickers.Count().ToString() }, - }; - - Metrics.RecordCounter($"{JobId}.total_snapshots", result.TotalRuns, tags); - Metrics.RecordCounter($"{JobId}.successful_snapshots", result.Succeeded, tags); - Metrics.RecordCounter($"{JobId}.failed_snapshots", result.Failed, tags); - Metrics.RecordGauge($"{JobId}.success_rate", result.SuccessRate * 100, tags); - } - } - - protected override bool IsCritical() => false; // Non-critical: continue even if one ticker fails -} - -/// -/// Result of collecting snapshots for a single ticker. -/// -public class SnapshotCollectionResult { - public string Ticker { get; set; } = string.Empty; - public CollectionStatus Status { get; set; } - public Guid? SnapshotId { get; set; } - public string? ErrorMessage { get; set; } -} - -public enum CollectionStatus { - Success, - ValidationFailed, - ApiError, - Failed, -} diff --git a/src/dotnet/QuantEngine.Core/Scheduling/SchedulerJob.cs b/src/dotnet/QuantEngine.Core/Scheduling/SchedulerJob.cs deleted file mode 100644 index 1bec1e90..00000000 --- a/src/dotnet/QuantEngine.Core/Scheduling/SchedulerJob.cs +++ /dev/null @@ -1,191 +0,0 @@ -using System; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; - -namespace QuantEngine.Core.Scheduling; - -/// -/// Base class for all scheduled jobs. Implements consistent lifecycle: -/// Start → Run → Complete/Error → Log → Record Metrics -/// -/// SOLID Principles Applied: -/// - Single Responsibility: Each job does ONE thing -/// - Open/Closed: Extend via inheritance, don't modify base -/// - Liskov Substitution: All jobs are substitutable -/// - Dependency Inversion: Depends on ILogger, IMetricsRecorder abstractions -/// -public abstract class SchedulerJob { - public string JobId { get; protected set; } = string.Empty; - public string Description { get; protected set; } = string.Empty; - public string CronExpression { get; protected set; } = string.Empty; // e.g., "30 0 * * 1-5" - public DateTime? LastRun { get; private set; } - public DateTime? NextRun { get; private set; } - - protected readonly ILogger Logger; - protected readonly IMetricsRecorder Metrics; - - protected SchedulerJob(ILogger logger, IMetricsRecorder metrics) { - Logger = logger ?? throw new ArgumentNullException(nameof(logger)); - Metrics = metrics ?? throw new ArgumentNullException(nameof(metrics)); - } - - /// - /// Execute the job with complete lifecycle management. - /// Handles logging, metrics, error recovery, and audit trail. - /// - public async Task ExecuteAsync() { - var executionContext = new JobExecutionContext { - JobId = JobId, - StartedAt = DateTime.UtcNow, - Attempt = 1, - }; - - try { - Logger.LogInformation( - "[{JobId}] Execution started | {Description}", - JobId, Description - ); - - // Run the actual job logic - var result = await RunAsync(); - - executionContext.Result = result; - executionContext.Status = JobExecutionStatus.Completed; - - Logger.LogInformation( - "[{JobId}] Execution completed | Duration: {DurationMs}ms | Result: {Result}", - JobId, - executionContext.DurationMs, - result?.Summary ?? "N/A" - ); - - await RecordMetricsAsync(executionContext); - - LastRun = executionContext.StartedAt; - NextRun = CalculateNextRun(DateTime.UtcNow); - } - catch (Exception ex) { - executionContext.Status = JobExecutionStatus.Failed; - executionContext.Exception = ex; - - Logger.LogError( - ex, - "[{JobId}] Execution failed | Duration: {DurationMs}ms | Error: {Error}", - JobId, - executionContext.DurationMs, - ex.Message - ); - - await RecordMetricsAsync(executionContext); - - // Decide: rethrow or continue? - if (IsCritical()) { - throw; - } - } - } - - /// - /// Override this method to implement the actual job logic. - /// Must be implemented by subclass. - /// - protected abstract Task RunAsync(); - - /// - /// Record job execution metrics for monitoring and debugging. - /// Default implementation sends to metrics backend. - /// - protected virtual async Task RecordMetricsAsync(JobExecutionContext context) { - await Task.Run(() => { - var tags = new Dictionary { - { "job_id", JobId }, - { "status", context.Status.ToString() }, - }; - - Metrics.RecordCounter($"{JobId}.executions", 1, tags); - Metrics.RecordGauge($"{JobId}.duration_ms", context.DurationMs, tags); - - if (context.Status == JobExecutionStatus.Failed) { - Metrics.RecordCounter($"{JobId}.errors", 1, tags); - Metrics.RecordGauge($"{JobId}.error_attempt", context.Attempt, tags); - } - }); - } - - /// - /// Calculate next execution time based on cron expression. - /// Should use CronExpressionParser or similar. - /// - protected DateTime CalculateNextRun(DateTime from) { - // Simplified: add 1 day for daily jobs - // Real implementation: parse CronExpression and calculate - return from.AddDays(1); - } - - /// - /// Determine if this job failure is critical (should stop the scheduler). - /// Default: false (non-critical, continue scheduler) - /// Override: true for critical jobs (e.g., health checks) - /// - protected virtual bool IsCritical() => false; -} - -/// -/// Job execution result. Subclass to add custom metrics. -/// -public class JobResult { - public string Summary { get; set; } = string.Empty; - public int TotalRuns { get; set; } - public int Succeeded { get; set; } - public int Failed { get; set; } - - public double SuccessRate => TotalRuns > 0 ? (double)Succeeded / TotalRuns : 0; -} - -/// -/// Job execution context for lifecycle tracking. -/// -public class JobExecutionContext { - public string JobId { get; set; } = string.Empty; - public DateTime StartedAt { get; set; } - public JobExecutionStatus Status { get; set; } - public int Attempt { get; set; } - public JobResult? Result { get; set; } - public Exception? Exception { get; set; } - - public long DurationMs => (long)(DateTime.UtcNow - StartedAt).TotalMilliseconds; -} - -public enum JobExecutionStatus { - Running, - Completed, - Failed, - Skipped, -} - -/// -/// Abstraction for metrics recording. Decouple job from metrics backend. -/// -public interface IMetricsRecorder { - void RecordCounter(string name, double value, Dictionary tags = null!); - void RecordGauge(string name, double value, Dictionary tags = null!); - void RecordHistogram(string name, double value, Dictionary tags = null!); -} - -/// -/// Console implementation for local development. -/// Replace with Prometheus/Grafana for production. -/// -public class ConsoleMetricsRecorder : IMetricsRecorder { - public void RecordCounter(string name, double value, Dictionary tags = null!) { - Console.WriteLine($"[METRIC] Counter: {name} = {value} | Tags: {string.Join(",", tags?.Select(x => $"{x.Key}={x.Value}") ?? Array.Empty())}"); - } - - public void RecordGauge(string name, double value, Dictionary tags = null!) { - Console.WriteLine($"[METRIC] Gauge: {name} = {value} | Tags: {string.Join(",", tags?.Select(x => $"{x.Key}={x.Value}") ?? Array.Empty())}"); - } - - public void RecordHistogram(string name, double value, Dictionary tags = null!) { - Console.WriteLine($"[METRIC] Histogram: {name} = {value} | Tags: {string.Join(",", tags?.Select(x => $"{x.Key}={x.Value}") ?? Array.Empty())}"); - } -} diff --git a/src/dotnet/QuantEngine.Core/Scheduling/SchedulerJobBase.cs b/src/dotnet/QuantEngine.Core/Scheduling/SchedulerJobBase.cs new file mode 100644 index 00000000..272a270e --- /dev/null +++ b/src/dotnet/QuantEngine.Core/Scheduling/SchedulerJobBase.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace QuantEngine.Core.Scheduling +{ + /// + /// Base class for all scheduled jobs. + /// + /// Responsibilities: + /// - Implement consistent lifecycle (Start → Run → End) + /// - Log execution metrics + /// - Handle errors gracefully + /// - Record success/failure for monitoring + /// + public abstract class SchedulerJobBase + { + public string JobId { get; protected set; } = string.Empty; + public string Description { get; protected set; } = string.Empty; + public DateTime? LastRun { get; private set; } + + /// + /// Execute the job with complete lifecycle. + /// + public async Task ExecuteAsync() + { + var startTime = DateTime.UtcNow; + try + { + Console.WriteLine($"[{JobId}] Started: {Description}"); + await RunAsync(); + Console.WriteLine($"[{JobId}] Completed in {(DateTime.UtcNow - startTime).TotalSeconds:F2}s"); + LastRun = startTime; + } + catch (Exception ex) + { + Console.WriteLine($"[{JobId}] Failed: {ex.Message}"); + throw; + } + } + + /// + /// Override this method to implement the actual job logic. + /// + protected abstract Task RunAsync(); + } +}