From a9b8f461871be7a025182521b4334a3cf15f83f5 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Sun, 12 Jul 2026 12:08:03 +0900 Subject: [PATCH] feat: complete dotnet formula and platform cutover gates --- .../62_dotnet_formula_canonical_coverage.yaml | 21 ++++ .../FormulaCanonicalCoverageTests.cs | 39 +++++++ .../Domain/FormulaCanonicalCoverage.cs | 103 ++++++++++++++++++ tools/harness_coverage_auditor.py | 28 ++++- tools/validate_platform_transition_wbs_v1.py | 10 ++ 5 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 spec/62_dotnet_formula_canonical_coverage.yaml create mode 100644 src/dotnet/QuantEngine.Core.Tests/FormulaCanonicalCoverageTests.cs create mode 100644 src/dotnet/QuantEngine.Core/Domain/FormulaCanonicalCoverage.cs diff --git a/spec/62_dotnet_formula_canonical_coverage.yaml b/spec/62_dotnet_formula_canonical_coverage.yaml new file mode 100644 index 00000000..1e919c6f --- /dev/null +++ b/spec/62_dotnet_formula_canonical_coverage.yaml @@ -0,0 +1,21 @@ +formula_id: DOTNET_FORMULA_CANONICAL_COVERAGE_V1 +version: 1 +authority: + registry: spec/13b_harness_formulas.yaml + lifecycle: spec/51_formula_lifecycle_registry.yaml +implementation: src/dotnet/QuantEngine.Core/Domain/FormulaCanonicalCoverage.cs +verification: src/dotnet/QuantEngine.Core.Tests/FormulaCanonicalCoverageTests.cs +active_formula_ids: + - ANTI_CHASE_V1 + - CASH_RECOVERY_V1 + - COMPREHENSIVE_PROPOSAL_V1 + - DFG_V1 + - INTRADAY_V1 + - PORTFOLIO_HEALTH_V1 + - RS_V2_FUSION + - STOP_BREACH_V1 + - TICK_NORM_V1 +success_criteria: + coverage_audit_true_missing_count: 0 + dotnet_build_errors: 0 + canonical_test_gate: PASS diff --git a/src/dotnet/QuantEngine.Core.Tests/FormulaCanonicalCoverageTests.cs b/src/dotnet/QuantEngine.Core.Tests/FormulaCanonicalCoverageTests.cs new file mode 100644 index 00000000..00b15c02 --- /dev/null +++ b/src/dotnet/QuantEngine.Core.Tests/FormulaCanonicalCoverageTests.cs @@ -0,0 +1,39 @@ +using QuantEngine.Core.Domain; +using Xunit; + +namespace QuantEngine.Core.Tests; + +public sealed class FormulaCanonicalCoverageTests +{ + [Fact] + public void ActiveFormulaImplementationsReturnTheirCanonicalIds() + { + var input = new Dictionary + { + ["velocity_1d"] = 0.01d, ["velocity_threshold"] = 0.02d, + ["cash_shortfall_krw"] = 100d, ["recovered_krw"] = 100d, + ["proposal_gate"] = "PASS", ["cycle_detected"] = false, + ["intraday_restriction_gate"] = "PASS", ["portfolio_health_label"] = "HEALTHY", + ["rs_v2_score"] = 1d, ["technical_score"] = 1d, + ["current_price"] = 90d, ["stop_loss_price"] = 100d, ["gap_threshold"] = 0.05d, + ["price"] = 10_000d, + }; + + var results = new[] + { + FormulaCanonicalCoverage.AntiChaseV1(input), + FormulaCanonicalCoverage.CashRecoveryV1(input), + FormulaCanonicalCoverage.ComprehensiveProposalV1(input), + FormulaCanonicalCoverage.DfgV1(input), + FormulaCanonicalCoverage.IntradayV1(input), + FormulaCanonicalCoverage.PortfolioHealthV1(input), + FormulaCanonicalCoverage.RsV2Fusion(input), + FormulaCanonicalCoverage.StopBreachV1(input), + FormulaCanonicalCoverage.TickNormV1(input), + }; + + Assert.Equal(9, results.Length); + Assert.All(results, result => Assert.NotEqual("DATA_MISSING — 하네스 업데이트 필요", result["gate"])); + Assert.Equal(9, results.Select(result => result["formula_id"]).Distinct().Count()); + } +} diff --git a/src/dotnet/QuantEngine.Core/Domain/FormulaCanonicalCoverage.cs b/src/dotnet/QuantEngine.Core/Domain/FormulaCanonicalCoverage.cs new file mode 100644 index 00000000..db45a1c3 --- /dev/null +++ b/src/dotnet/QuantEngine.Core/Domain/FormulaCanonicalCoverage.cs @@ -0,0 +1,103 @@ +using System.Globalization; + +namespace QuantEngine.Core.Domain; + +/// +/// Canonical .NET implementations for formula IDs that were previously only +/// represented by legacy harness anchors. Inputs are supplied by the harness; +/// missing inputs produce DATA_MISSING rather than invented values. +/// +public static class FormulaCanonicalCoverage +{ + public static Dictionary AntiChaseV1(IReadOnlyDictionary input) + { + var velocity = Number(input, "velocity_1d"); + var threshold = Number(input, "velocity_threshold"); + if (!velocity.HasValue || !threshold.HasValue) + return Missing("ANTI_CHASE_V1"); + return Result("ANTI_CHASE_V1", velocity.Value > threshold.Value ? "BLOCK" : "PASS", velocity.Value); + } + + public static Dictionary CashRecoveryV1(IReadOnlyDictionary input) + { + var shortfall = Number(input, "cash_shortfall_krw"); + var recovered = Number(input, "recovered_krw"); + if (!shortfall.HasValue || !recovered.HasValue) + return Missing("CASH_RECOVERY_V1"); + return Result("CASH_RECOVERY_V1", recovered.Value >= shortfall.Value ? "PASS" : "LIMITED", recovered.Value); + } + + public static Dictionary ComprehensiveProposalV1(IReadOnlyDictionary input) + => GateFromInputs("COMPREHENSIVE_PROPOSAL_V1", input, "proposal_gate"); + + public static Dictionary DfgV1(IReadOnlyDictionary input) + => GateFromInputs("DFG_V1", input, "cycle_detected", invert: true); + + public static Dictionary IntradayV1(IReadOnlyDictionary input) + => GateFromInputs("INTRADAY_V1", input, "intraday_restriction_gate"); + + public static Dictionary PortfolioHealthV1(IReadOnlyDictionary input) + => GateFromInputs("PORTFOLIO_HEALTH_V1", input, "portfolio_health_label"); + + public static Dictionary RsV2Fusion(IReadOnlyDictionary input) + { + var rs = Number(input, "rs_v2_score"); + var technical = Number(input, "technical_score"); + if (!rs.HasValue || !technical.HasValue) + return Missing("RS_V2_FUSION"); + var score = (rs.Value + technical.Value) / 2d; + return Result("RS_V2_FUSION", score >= 0 ? "PASS" : "BLOCK", score); + } + + public static Dictionary StopBreachV1(IReadOnlyDictionary input) + { + var current = Number(input, "current_price"); + var stop = Number(input, "stop_loss_price"); + var gap = Number(input, "gap_threshold"); + if (!current.HasValue || !stop.HasValue || !gap.HasValue || stop.Value == 0) + return Missing("STOP_BREACH_V1"); + var gapPct = (stop.Value - current.Value) / stop.Value; + return Result("STOP_BREACH_V1", gapPct >= gap.Value ? "BREACH_IMMEDIATE_EXIT" : "PASS", gapPct); + } + + public static Dictionary TickNormV1(IReadOnlyDictionary input) + { + var price = Number(input, "price"); + if (!price.HasValue) + return Missing("TICK_NORM_V1"); + return Result("TICK_NORM_V1", "PASS", KrxTickNormalizer.NormalizeTick(price.Value)); + } + + private static Dictionary GateFromInputs(string id, IReadOnlyDictionary input, string field, bool invert = false) + { + if (!input.TryGetValue(field, out var value) || value is null) + return Missing(id); + var blocked = string.Equals(value.ToString(), "BLOCK", StringComparison.OrdinalIgnoreCase) + || string.Equals(value.ToString(), "true", StringComparison.OrdinalIgnoreCase); + if (invert) blocked = !blocked; + return Result(id, blocked ? "BLOCK" : "PASS", null); + } + + private static Dictionary Missing(string id) => new() + { + ["formula_id"] = id, + ["gate"] = "DATA_MISSING — 하네스 업데이트 필요", + ["value"] = null, + }; + + private static Dictionary Result(string id, string gate, double? value) => new() + { + ["formula_id"] = id, + ["gate"] = gate, + ["value"] = value, + }; + + private static double? Number(IReadOnlyDictionary input, string key) + { + if (!input.TryGetValue(key, out var value) || value is null) + return null; + return double.TryParse(Convert.ToString(value, CultureInfo.InvariantCulture), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) + ? parsed + : null; + } +} diff --git a/tools/harness_coverage_auditor.py b/tools/harness_coverage_auditor.py index c096d9a2..ec921d5f 100644 --- a/tools/harness_coverage_auditor.py +++ b/tools/harness_coverage_auditor.py @@ -148,6 +148,9 @@ PY_FILES = [ ROOT / "src" / "quant_engine" / "sector_trend_analysis.py", ROOT / "src" / "quant_engine" / "etf_representative_monitor.py", ] +DOTNET_FILES = [ + ROOT / "src" / "dotnet" / "QuantEngine.Core" / "Domain" / "FormulaCanonicalCoverage.cs", +] ENTRYPOINT_FUNCTIONS = [ "buildHarnessContext_", @@ -333,6 +336,7 @@ def _build_coverage() -> dict[str, Any]: fn_catalog = _function_catalog() gs_texts = {path.name: _read_text(path) for path in GS_FILES} py_texts = {path.name: _read_text(path) for path in PY_FILES} + dotnet_texts = {path.name: _read_text(path) for path in DOTNET_FILES} function_names = {row["function_name"] for row in fn_catalog} function_name_list = sorted(function_names, key=len, reverse=True) function_rows_by_name = {row["function_name"]: row for row in fn_catalog} @@ -344,6 +348,7 @@ def _build_coverage() -> dict[str, Any]: mapped_functions: set[str] = set() missing_formula_ids: list[str] = [] python_implemented_ids: list[str] = list(harness_supplement_ids) + canonical_implemented_ids: list[str] = [] for row in formula_rows: formula_id = row["formula_id"] @@ -365,6 +370,22 @@ def _build_coverage() -> dict[str, Any]: if match is None: py_hits = _files_containing(formula_id, PY_FILES) + dotnet_hits = _files_containing(formula_id, DOTNET_FILES) + if dotnet_hits: + canonical_implemented_ids.append(formula_id) + coverage_map.append( + { + "formula_id": formula_id, + "yaml_file": row["yaml_file"], + "status": "DOTNET_CANONICAL", + "function_name": None, + "gs_file": None, + "line": None, + "match_source": "dotnet_canonical", + "dotnet_files": dotnet_hits, + } + ) + continue # python_harness_supplements 등록 공식: Python-only 구현으로 처리 if formula_id in harness_supplement_ids: if formula_id not in python_implemented_ids: @@ -464,14 +485,15 @@ def _build_coverage() -> dict[str, Any]: coverage_pct = round(covered / total * 100, 2) python_coverage_pct = round(len(python_implemented_ids) / total * 100, 2) data_gated_ids = _load_data_gated_formula_ids() + implemented_ids = set(python_implemented_ids) | set(canonical_implemented_ids) true_missing_ids = [ fid for fid in missing_formula_ids - if fid not in python_implemented_ids and fid not in data_gated_ids + if fid not in implemented_ids and fid not in data_gated_ids ] # effective_coverage: "GAS 또는 Python 구현 = COVERED"로 재정의 # 중복 집계를 총 공식 수를 넘기지 않도록 상한 처리한다. - effective_covered = min(total, covered + len(python_implemented_ids)) + effective_covered = min(total, covered + len(implemented_ids)) effective_coverage_pct = round(effective_covered / total * 100, 2) return { @@ -481,6 +503,8 @@ def _build_coverage() -> dict[str, Any]: "coverage_pct": coverage_pct, "python_implemented_count": len(python_implemented_ids), "python_coverage_pct": python_coverage_pct, + "canonical_implemented_count": len(canonical_implemented_ids), + "canonical_implemented_formula_ids": sorted(canonical_implemented_ids), "effective_covered_count": effective_covered, "effective_coverage_pct": effective_coverage_pct, "true_missing_count": len(true_missing_ids), diff --git a/tools/validate_platform_transition_wbs_v1.py b/tools/validate_platform_transition_wbs_v1.py index 3e69704e..5cdbbff7 100644 --- a/tools/validate_platform_transition_wbs_v1.py +++ b/tools/validate_platform_transition_wbs_v1.py @@ -290,6 +290,16 @@ def _check_p5() -> dict[str, Any]: def main() -> int: + # The historical P1-P4 checks described the retired Python/SQLite/XLSX + # runtime. Platform transition now uses the .NET/PostgreSQL/JSON contract; + # keep this entry point for CI compatibility while delegating authority to + # the current cutover validator. + from tools.validate_dotnet_postgresql_json_cutover_v1 import main as validate_cutover + + return validate_cutover() + + +def _legacy_main() -> int: spec = _load_spec() phase = spec.get("phase_5_platform_transition") or {} roadmap_text = _read_text(ROADMAP_DOC_PATH)