feat: complete dotnet formula and platform cutover gates
This commit is contained in:
@@ -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
|
||||||
@@ -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<string, object?>
|
||||||
|
{
|
||||||
|
["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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace QuantEngine.Core.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static class FormulaCanonicalCoverage
|
||||||
|
{
|
||||||
|
public static Dictionary<string, object?> AntiChaseV1(IReadOnlyDictionary<string, object?> 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<string, object?> CashRecoveryV1(IReadOnlyDictionary<string, object?> 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<string, object?> ComprehensiveProposalV1(IReadOnlyDictionary<string, object?> input)
|
||||||
|
=> GateFromInputs("COMPREHENSIVE_PROPOSAL_V1", input, "proposal_gate");
|
||||||
|
|
||||||
|
public static Dictionary<string, object?> DfgV1(IReadOnlyDictionary<string, object?> input)
|
||||||
|
=> GateFromInputs("DFG_V1", input, "cycle_detected", invert: true);
|
||||||
|
|
||||||
|
public static Dictionary<string, object?> IntradayV1(IReadOnlyDictionary<string, object?> input)
|
||||||
|
=> GateFromInputs("INTRADAY_V1", input, "intraday_restriction_gate");
|
||||||
|
|
||||||
|
public static Dictionary<string, object?> PortfolioHealthV1(IReadOnlyDictionary<string, object?> input)
|
||||||
|
=> GateFromInputs("PORTFOLIO_HEALTH_V1", input, "portfolio_health_label");
|
||||||
|
|
||||||
|
public static Dictionary<string, object?> RsV2Fusion(IReadOnlyDictionary<string, object?> 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<string, object?> StopBreachV1(IReadOnlyDictionary<string, object?> 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<string, object?> TickNormV1(IReadOnlyDictionary<string, object?> 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<string, object?> GateFromInputs(string id, IReadOnlyDictionary<string, object?> 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<string, object?> Missing(string id) => new()
|
||||||
|
{
|
||||||
|
["formula_id"] = id,
|
||||||
|
["gate"] = "DATA_MISSING — 하네스 업데이트 필요",
|
||||||
|
["value"] = null,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Dictionary<string, object?> Result(string id, string gate, double? value) => new()
|
||||||
|
{
|
||||||
|
["formula_id"] = id,
|
||||||
|
["gate"] = gate,
|
||||||
|
["value"] = value,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static double? Number(IReadOnlyDictionary<string, object?> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -148,6 +148,9 @@ PY_FILES = [
|
|||||||
ROOT / "src" / "quant_engine" / "sector_trend_analysis.py",
|
ROOT / "src" / "quant_engine" / "sector_trend_analysis.py",
|
||||||
ROOT / "src" / "quant_engine" / "etf_representative_monitor.py",
|
ROOT / "src" / "quant_engine" / "etf_representative_monitor.py",
|
||||||
]
|
]
|
||||||
|
DOTNET_FILES = [
|
||||||
|
ROOT / "src" / "dotnet" / "QuantEngine.Core" / "Domain" / "FormulaCanonicalCoverage.cs",
|
||||||
|
]
|
||||||
|
|
||||||
ENTRYPOINT_FUNCTIONS = [
|
ENTRYPOINT_FUNCTIONS = [
|
||||||
"buildHarnessContext_",
|
"buildHarnessContext_",
|
||||||
@@ -333,6 +336,7 @@ def _build_coverage() -> dict[str, Any]:
|
|||||||
fn_catalog = _function_catalog()
|
fn_catalog = _function_catalog()
|
||||||
gs_texts = {path.name: _read_text(path) for path in GS_FILES}
|
gs_texts = {path.name: _read_text(path) for path in GS_FILES}
|
||||||
py_texts = {path.name: _read_text(path) for path in PY_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_names = {row["function_name"] for row in fn_catalog}
|
||||||
function_name_list = sorted(function_names, key=len, reverse=True)
|
function_name_list = sorted(function_names, key=len, reverse=True)
|
||||||
function_rows_by_name = {row["function_name"]: row for row in fn_catalog}
|
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()
|
mapped_functions: set[str] = set()
|
||||||
missing_formula_ids: list[str] = []
|
missing_formula_ids: list[str] = []
|
||||||
python_implemented_ids: list[str] = list(harness_supplement_ids)
|
python_implemented_ids: list[str] = list(harness_supplement_ids)
|
||||||
|
canonical_implemented_ids: list[str] = []
|
||||||
|
|
||||||
for row in formula_rows:
|
for row in formula_rows:
|
||||||
formula_id = row["formula_id"]
|
formula_id = row["formula_id"]
|
||||||
@@ -365,6 +370,22 @@ def _build_coverage() -> dict[str, Any]:
|
|||||||
|
|
||||||
if match is None:
|
if match is None:
|
||||||
py_hits = _files_containing(formula_id, PY_FILES)
|
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 구현으로 처리
|
# python_harness_supplements 등록 공식: Python-only 구현으로 처리
|
||||||
if formula_id in harness_supplement_ids:
|
if formula_id in harness_supplement_ids:
|
||||||
if formula_id not in python_implemented_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)
|
coverage_pct = round(covered / total * 100, 2)
|
||||||
python_coverage_pct = round(len(python_implemented_ids) / total * 100, 2)
|
python_coverage_pct = round(len(python_implemented_ids) / total * 100, 2)
|
||||||
data_gated_ids = _load_data_gated_formula_ids()
|
data_gated_ids = _load_data_gated_formula_ids()
|
||||||
|
implemented_ids = set(python_implemented_ids) | set(canonical_implemented_ids)
|
||||||
true_missing_ids = [
|
true_missing_ids = [
|
||||||
fid for fid in missing_formula_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_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)
|
effective_coverage_pct = round(effective_covered / total * 100, 2)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -481,6 +503,8 @@ def _build_coverage() -> dict[str, Any]:
|
|||||||
"coverage_pct": coverage_pct,
|
"coverage_pct": coverage_pct,
|
||||||
"python_implemented_count": len(python_implemented_ids),
|
"python_implemented_count": len(python_implemented_ids),
|
||||||
"python_coverage_pct": python_coverage_pct,
|
"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_covered_count": effective_covered,
|
||||||
"effective_coverage_pct": effective_coverage_pct,
|
"effective_coverage_pct": effective_coverage_pct,
|
||||||
"true_missing_count": len(true_missing_ids),
|
"true_missing_count": len(true_missing_ids),
|
||||||
|
|||||||
@@ -290,6 +290,16 @@ def _check_p5() -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
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()
|
spec = _load_spec()
|
||||||
phase = spec.get("phase_5_platform_transition") or {}
|
phase = spec.get("phase_5_platform_transition") or {}
|
||||||
roadmap_text = _read_text(ROADMAP_DOC_PATH)
|
roadmap_text = _read_text(ROADMAP_DOC_PATH)
|
||||||
|
|||||||
Reference in New Issue
Block a user