diff --git a/governance/todo/technical_signals_p4_adoption_plan.yaml b/governance/todo/technical_signals_p4_adoption_plan.yaml new file mode 100644 index 0000000..1ed3422 --- /dev/null +++ b/governance/todo/technical_signals_p4_adoption_plan.yaml @@ -0,0 +1,69 @@ +schema_version: technical_signals_p4_adoption_plan.v1 +meta: + title: technical_signals_p4_adoption_plan + source: 사용자 제시 10개 고전 기술적 전략 목록 (2026-06-18) + decision_basis: > + 10개 전략 중 기존 엔진 커버리지를 분석한 결과 3개는 이미 구현됨(모멘텀=VELOCITY_V1/RS_MOMENTUM_V1, + 이격도/평균회귀=MEAN_REVERSION_GATE_V1), 4개는 부분구현, 3개는 완전 공백. + AGENTS.md 하드룰(추격매수 방지, anti-late-entry gate 필수통과)과 충돌하지 않도록 + 이 7개를 독립 매수 트리거가 아니라 STRATEGY_SCORING 보조신호 또는 기존 게이트 체인 내부 + 조건으로만 편입한다. + gap_analysis: + already_covered: + "02_모멘텀": VELOCITY_V1, RS_MOMENTUM_V1 + "05_이격도": MEAN_REVERSION_GATE_V1 + "09_평균회귀": MEAN_REVERSION_GATE_V1 + partial_extend: + "03_52주신고가": FIFTY_TWO_WEEK_HIGH_TRIGGER_V1 + "04_연속상승하락": CONSECUTIVE_STREAK_V1 + "06_돌파실패손절": BREAKOUT_FAILURE_STOP_V1 + "10_추세필터": TREND_FILTER_GATE_V1 + new_signal: + "01_골든크로스": GOLDEN_CROSS_SIGNAL_V1 + "07_강한종가": STRONG_CLOSE_SIGNAL_V1 + "08_변동성확장돌파": VOLATILITY_EXPANSION_BREAKOUT_V1 + hard_constraint: > + 이 7개 공식 중 BUY 방향 신호는 모두 BREAKOUT_QUALITY_GATE_V2 / FOLLOW_THROUGH_DAY_CONFIRM_V1 / + ANTI_LATE_ENTRY_GATE_V2 체인을 우회할 수 없다. 단독 BUY 트리거로 사용 금지 — STRATEGY_SCORING의 + component_scores 보조 입력 또는 게이트 조건으로만 사용한다. + +tasks: + - id: P4-1 + title: GOLDEN_CROSS_SIGNAL_V1 + output_file: spec/formulas/domains/entry.yaml + implementation: tools/build_golden_cross_signal_v1.py + - id: P4-2 + title: STRONG_CLOSE_SIGNAL_V1 + output_file: spec/formulas/domains/entry.yaml + implementation: tools/build_strong_close_signal_v1.py + - id: P4-3 + title: VOLATILITY_EXPANSION_BREAKOUT_V1 + output_file: spec/formulas/domains/entry.yaml + detail: bb_width 신규 필드 추가. BREAKOUT_QUALITY_GATE_V2 통과 필수 조건 명시. + implementation: tools/build_volatility_expansion_breakout_v1.py + - id: P4-4 + title: FIFTY_TWO_WEEK_HIGH_TRIGGER_V1 + output_file: spec/formulas/domains/entry.yaml + implementation: tools/build_fifty_two_week_high_trigger_v1.py + - id: P4-5 + title: CONSECUTIVE_STREAK_V1 + output_file: spec/formulas/domains/entry.yaml + detail: up_streak/down_streak 대칭 공식화 (down_streak는 기존 필드, up_streak 신규). + implementation: tools/build_consecutive_streak_v1.py + - id: P4-6 + title: BREAKOUT_FAILURE_STOP_V1 + output_file: spec/formulas/domains/exit.yaml + implementation: tools/build_breakout_failure_stop_v1.py + - id: P4-7 + title: TREND_FILTER_GATE_V1 + output_file: spec/formulas/domains/entry.yaml + implementation: tools/build_trend_filter_gate_v1.py + - id: P4-8 + title: decision_flow/manifest 배선 + 전체 검증 + depends_on: [P4-1, P4-2, P4-3, P4-4, P4-5, P4-6, P4-7] + command: | + python tools/validate_specs.py + python tools/validate_golden_coverage_100.py + python tools/validate_calibration_registry_v1.py + python tools/validate_schema_model_generation_v1.py + python tools/validate_agents_shrink_v1.py diff --git a/runtime/active_artifact_manifest.yaml b/runtime/active_artifact_manifest.yaml index 2d32188..4797759 100644 --- a/runtime/active_artifact_manifest.yaml +++ b/runtime/active_artifact_manifest.yaml @@ -18,6 +18,13 @@ source_precedence: - model_governance_kill_switch_v1 - state_vector_constructor_v1 - weekly_legacy_transfer_plan_v1 +- golden_cross_signal_v1 +- strong_close_signal_v1 +- volatility_expansion_breakout_v1 +- fifty_two_week_high_trigger_v1 +- consecutive_streak_v1 +- breakout_failure_stop_v1 +- trend_filter_gate_v1 - portfolio_transition_optimizer_v1 - walk_forward_bootstrap_v1 - transition_set_enumerator_v1 @@ -90,3 +97,24 @@ manifest_rows: - formula_id: weekly_legacy_transfer_plan_v1 active_artifact: Temp/weekly_legacy_transfer_plan_v1.json value: 0.0 +- formula_id: golden_cross_signal_v1 + active_artifact: Temp/golden_cross_signal_v1.json + value: null +- formula_id: strong_close_signal_v1 + active_artifact: Temp/strong_close_signal_v1.json + value: null +- formula_id: volatility_expansion_breakout_v1 + active_artifact: Temp/volatility_expansion_breakout_v1.json + value: null +- formula_id: fifty_two_week_high_trigger_v1 + active_artifact: Temp/fifty_two_week_high_trigger_v1.json + value: null +- formula_id: consecutive_streak_v1 + active_artifact: Temp/consecutive_streak_v1.json + value: null +- formula_id: breakout_failure_stop_v1 + active_artifact: Temp/breakout_failure_stop_v1.json + value: null +- formula_id: trend_filter_gate_v1 + active_artifact: Temp/trend_filter_gate_v1.json + value: null diff --git a/schemas/generated/breakout_failure_stop_v1.schema.json b/schemas/generated/breakout_failure_stop_v1.schema.json new file mode 100644 index 0000000..a57ec5c --- /dev/null +++ b/schemas/generated/breakout_failure_stop_v1.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schema://formula/BREAKOUT_FAILURE_STOP_V1", + "title": "BREAKOUT_FAILURE_STOP_V1", + "type": "object", + "properties": { + "formula_id": { "const": "BREAKOUT_FAILURE_STOP_V1" }, + "owner": { "type": "string" }, + "status": { "type": "string" }, + "inputs": { "type": "array", "items": { "type": "string" } }, + "outputs": { "type": "array", "items": { "type": "string" } } + }, + "required": ["formula_id", "owner", "status", "inputs", "outputs"], + "x_formula_inputs": ["prior_high", "close_price", "days_since_breakout"], + "x_formula_outputs": ["breakout_failure"] +} diff --git a/schemas/generated/consecutive_streak_v1.schema.json b/schemas/generated/consecutive_streak_v1.schema.json new file mode 100644 index 0000000..3be26a7 --- /dev/null +++ b/schemas/generated/consecutive_streak_v1.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schema://formula/CONSECUTIVE_STREAK_V1", + "title": "CONSECUTIVE_STREAK_V1", + "type": "object", + "properties": { + "formula_id": { "const": "CONSECUTIVE_STREAK_V1" }, + "owner": { "type": "string" }, + "status": { "type": "string" }, + "inputs": { "type": "array", "items": { "type": "string" } }, + "outputs": { "type": "array", "items": { "type": "string" } } + }, + "required": ["formula_id", "owner", "status", "inputs", "outputs"], + "x_formula_inputs": ["daily_close_changes"], + "x_formula_outputs": ["up_streak", "down_streak"] +} diff --git a/schemas/generated/fifty_two_week_high_trigger_v1.schema.json b/schemas/generated/fifty_two_week_high_trigger_v1.schema.json new file mode 100644 index 0000000..a13a0d9 --- /dev/null +++ b/schemas/generated/fifty_two_week_high_trigger_v1.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schema://formula/FIFTY_TWO_WEEK_HIGH_TRIGGER_V1", + "title": "FIFTY_TWO_WEEK_HIGH_TRIGGER_V1", + "type": "object", + "properties": { + "formula_id": { "const": "FIFTY_TWO_WEEK_HIGH_TRIGGER_V1" }, + "owner": { "type": "string" }, + "status": { "type": "string" }, + "inputs": { "type": "array", "items": { "type": "string" } }, + "outputs": { "type": "array", "items": { "type": "string" } } + }, + "required": ["formula_id", "owner", "status", "inputs", "outputs"], + "x_formula_inputs": ["close_price", "high52w"], + "x_formula_outputs": ["fifty_two_week_high_breakout"] +} diff --git a/schemas/generated/golden_cross_signal_v1.schema.json b/schemas/generated/golden_cross_signal_v1.schema.json new file mode 100644 index 0000000..4bd61c9 --- /dev/null +++ b/schemas/generated/golden_cross_signal_v1.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schema://formula/GOLDEN_CROSS_SIGNAL_V1", + "title": "GOLDEN_CROSS_SIGNAL_V1", + "type": "object", + "properties": { + "formula_id": { "const": "GOLDEN_CROSS_SIGNAL_V1" }, + "owner": { "type": "string" }, + "status": { "type": "string" }, + "inputs": { "type": "array", "items": { "type": "string" } }, + "outputs": { "type": "array", "items": { "type": "string" } } + }, + "required": ["formula_id", "owner", "status", "inputs", "outputs"], + "x_formula_inputs": ["ma20", "ma20_prev", "ma60", "ma60_prev"], + "x_formula_outputs": ["golden_cross_today"] +} diff --git a/schemas/generated/strong_close_signal_v1.schema.json b/schemas/generated/strong_close_signal_v1.schema.json new file mode 100644 index 0000000..72ac32f --- /dev/null +++ b/schemas/generated/strong_close_signal_v1.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schema://formula/STRONG_CLOSE_SIGNAL_V1", + "title": "STRONG_CLOSE_SIGNAL_V1", + "type": "object", + "properties": { + "formula_id": { "const": "STRONG_CLOSE_SIGNAL_V1" }, + "owner": { "type": "string" }, + "status": { "type": "string" }, + "inputs": { "type": "array", "items": { "type": "string" } }, + "outputs": { "type": "array", "items": { "type": "string" } } + }, + "required": ["formula_id", "owner", "status", "inputs", "outputs"], + "x_formula_inputs": ["close_price", "high_price", "low_price"], + "x_formula_outputs": ["strong_close", "close_position_pct"] +} diff --git a/schemas/generated/trend_filter_gate_v1.schema.json b/schemas/generated/trend_filter_gate_v1.schema.json new file mode 100644 index 0000000..f0ba65a --- /dev/null +++ b/schemas/generated/trend_filter_gate_v1.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schema://formula/TREND_FILTER_GATE_V1", + "title": "TREND_FILTER_GATE_V1", + "type": "object", + "properties": { + "formula_id": { "const": "TREND_FILTER_GATE_V1" }, + "owner": { "type": "string" }, + "status": { "type": "string" }, + "inputs": { "type": "array", "items": { "type": "string" } }, + "outputs": { "type": "array", "items": { "type": "string" } } + }, + "required": ["formula_id", "owner", "status", "inputs", "outputs"], + "x_formula_inputs": ["close_price", "ma120", "ma120_prev"], + "x_formula_outputs": ["trend_filter_pass"] +} diff --git a/schemas/generated/volatility_expansion_breakout_v1.schema.json b/schemas/generated/volatility_expansion_breakout_v1.schema.json new file mode 100644 index 0000000..de1cccc --- /dev/null +++ b/schemas/generated/volatility_expansion_breakout_v1.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schema://formula/VOLATILITY_EXPANSION_BREAKOUT_V1", + "title": "VOLATILITY_EXPANSION_BREAKOUT_V1", + "type": "object", + "properties": { + "formula_id": { "const": "VOLATILITY_EXPANSION_BREAKOUT_V1" }, + "owner": { "type": "string" }, + "status": { "type": "string" }, + "inputs": { "type": "array", "items": { "type": "string" } }, + "outputs": { "type": "array", "items": { "type": "string" } } + }, + "required": ["formula_id", "owner", "status", "inputs", "outputs"], + "x_formula_inputs": ["bb_width", "bb_width_20d_percentile", "ret_1d"], + "x_formula_outputs": ["volatility_expansion_breakout"] +} diff --git a/spec/09_decision_flow.yaml b/spec/09_decision_flow.yaml index 8244242..9c1fc8c 100644 --- a/spec/09_decision_flow.yaml +++ b/spec/09_decision_flow.yaml @@ -106,13 +106,24 @@ decision_flow: pass_condition: "market regime classified or marked UNKNOWN" fail_state: "INSUFFICIENT_DATA" STRATEGY_SCORING: - purpose: "섹터/종목/수급/유동성/실적/밸류 점수 산출" + purpose: > + 섹터/종목/수급/유동성/실적/밸류 점수 산출. GOLDEN_CROSS_SIGNAL_V1, STRONG_CLOSE_SIGNAL_V1, + VOLATILITY_EXPANSION_BREAKOUT_V1, FIFTY_TWO_WEEK_HIGH_TRIGGER_V1, CONSECUTIVE_STREAK_V1, + TREND_FILTER_GATE_V1은 component_scores의 보조신호로만 포함되며, 단독으로 BUY를 + 허가하지 않는다(BREAKOUT_QUALITY_GATE_V2/FOLLOW_THROUGH_DAY_CONFIRM_V1/ANTI_LATE_ENTRY_GATE_V2 + 체인 우회 금지). required_refs: - "spec/08_scoring_rules.yaml:strategy_score" - "spec/strategy/sector_model.yaml:sector_model" - "spec/13_formula_registry.yaml:formula_registry.formulas.FLOW_CREDIT_V1" + - "spec/formulas/domains/entry.yaml:GOLDEN_CROSS_SIGNAL_V1" + - "spec/formulas/domains/entry.yaml:STRONG_CLOSE_SIGNAL_V1" + - "spec/formulas/domains/entry.yaml:VOLATILITY_EXPANSION_BREAKOUT_V1" + - "spec/formulas/domains/entry.yaml:FIFTY_TWO_WEEK_HIGH_TRIGGER_V1" + - "spec/formulas/domains/entry.yaml:CONSECUTIVE_STREAK_V1" + - "spec/formulas/domains/entry.yaml:TREND_FILTER_GATE_V1" required_inputs: ["close_price", "ma20", "flow_ok", "flow_rows", "frg_5d_sh", "inst_5d_sh", "avg_trade_value_5d"] - computed_outputs: ["flow_credit", "strategy_score", "component_scores", "grade_candidate"] + computed_outputs: ["flow_credit", "strategy_score", "component_scores", "grade_candidate", "golden_cross_today", "strong_close", "volatility_expansion_breakout", "fifty_two_week_high_breakout", "up_streak", "down_streak", "trend_filter_pass"] pass_condition: "strategy_score calculated or missing fields listed" fail_state: "INSUFFICIENT_DATA" PORTFOLIO_CONSTRAINT_CHECK: @@ -179,14 +190,17 @@ decision_flow: pass_condition: "integer quantity calculated, or NO_QUANTITY reason emitted" fail_state: "INSUFFICIENT_DATA" EXIT_POLICY_CHECK: - purpose: "손절/익절/trailing/보유주 점검 규칙 적용" + purpose: > + 손절/익절/trailing/보유주 점검 규칙 적용. 돌파 매수로 진입한 포지션에는 + BREAKOUT_FAILURE_STOP_V1을 적용해 전고점 재이탈을 ANTI_WHIPSAW_GATE_V1과 별도로 포착한다. required_refs: - "spec/exit/stop_loss.yaml:stop_loss" - "spec/exit/take_profit.yaml:take_profit" - "spec/exit/position_review.yaml:position_review_cycle" - "spec/13_formula_registry.yaml:formula_registry.formulas.EXPECTED_EDGE_V1" + - "spec/formulas/domains/exit.yaml:BREAKOUT_FAILURE_STOP_V1" required_inputs: ["entry_price", "stop_price", "target_price", "final_quantity"] - computed_outputs: ["expected_edge", "stop_order", "take_profit_order", "invalidation_conditions"] + computed_outputs: ["expected_edge", "stop_order", "take_profit_order", "invalidation_conditions", "breakout_failure"] pass_condition: "exit or hold policy evaluated" fail_state: "INSUFFICIENT_DATA" proactive_exit_radar_check: # [2026-05-19_PROACTIVE_RADAR_V1] diff --git a/spec/12_field_dictionary.yaml b/spec/12_field_dictionary.yaml index fb908bb..a5a7e4d 100644 --- a/spec/12_field_dictionary.yaml +++ b/spec/12_field_dictionary.yaml @@ -2672,6 +2672,116 @@ field_dictionary: unit: "KRW" aliases: ["TRANSFER_CONFIRMED_AMOUNT_KRW"] note: "WEEKLY_LEGACY_TRANSFER_PLAN_V1 입력 — transfer_confirmed=true일 때만 값 존재." + # ── [2026-06-18_TECHNICAL_SIGNALS_P4] 10개 고전 기술전략 갭분석 채택 신규 필드 ── + ma20_prev: + canonical_name: "ma20_prev" + type: "number_or_null" + unit: "KRW_per_share" + aliases: ["MA20_PREV"] + note: "GOLDEN_CROSS_SIGNAL_V1 입력 — 전일 ma20." + ma60_prev: + canonical_name: "ma60_prev" + type: "number_or_null" + unit: "KRW_per_share" + aliases: ["MA60_PREV"] + note: "GOLDEN_CROSS_SIGNAL_V1 입력 — 전일 ma60." + ma120: + canonical_name: "ma120" + type: "number_or_null" + unit: "KRW_per_share" + aliases: ["MA120", "120일선"] + note: "TREND_FILTER_GATE_V1 입력 — 120일 이동평균." + ma120_prev: + canonical_name: "ma120_prev" + type: "number_or_null" + unit: "KRW_per_share" + aliases: ["MA120_PREV"] + note: "TREND_FILTER_GATE_V1 입력 — 전일 ma120." + high_price: + canonical_name: "high_price" + type: "number" + unit: "KRW_per_share" + aliases: ["High", "고가", "high"] + note: "STRONG_CLOSE_SIGNAL_V1 입력 — 당일 고가." + low_price: + canonical_name: "low_price" + type: "number" + unit: "KRW_per_share" + aliases: ["Low", "저가", "low"] + note: "STRONG_CLOSE_SIGNAL_V1 입력 — 당일 저가." + bb_width: + canonical_name: "bb_width" + type: "number_or_null" + unit: "percent" + aliases: ["BB_WIDTH"] + note: "VOLATILITY_EXPANSION_BREAKOUT_V1 입력 — 20일 볼린저밴드 폭." + bb_width_20d_percentile: + canonical_name: "bb_width_20d_percentile" + type: "number_or_null" + unit: "percent" + aliases: ["BB_WIDTH_20D_PERCENTILE"] + note: "VOLATILITY_EXPANSION_BREAKOUT_V1 입력 — 최근 20일 분포 내 bb_width 백분위. 낮을수록 squeeze." + daily_close_changes: + canonical_name: "daily_close_changes" + type: "list_or_null" + unit: "list_of_percent" + aliases: ["DAILY_CLOSE_CHANGES"] + note: "CONSECUTIVE_STREAK_V1 입력 — 최근 N거래일 일별 종가 변화율(%) 리스트, 최신값이 마지막." + prior_high: + canonical_name: "prior_high" + type: "number_or_null" + unit: "KRW_per_share" + aliases: ["PRIOR_HIGH"] + note: "BREAKOUT_FAILURE_STOP_V1 입력 — 진입 당시 돌파 기준 전고점." + golden_cross_today: + canonical_name: "golden_cross_today" + type: "boolean_or_null" + unit: "none" + aliases: ["GOLDEN_CROSS_TODAY"] + note: "GOLDEN_CROSS_SIGNAL_V1 산출 — STRATEGY_SCORING 보조신호. 단독 BUY 트리거 금지." + strong_close: + canonical_name: "strong_close" + type: "boolean_or_null" + unit: "none" + aliases: ["STRONG_CLOSE"] + note: "STRONG_CLOSE_SIGNAL_V1 산출." + close_position_pct: + canonical_name: "close_position_pct" + type: "number_or_null" + unit: "percent" + aliases: ["CLOSE_POSITION_PCT"] + note: "STRONG_CLOSE_SIGNAL_V1 산출 — (close-low)/(high-low)*100." + volatility_expansion_breakout: + canonical_name: "volatility_expansion_breakout" + type: "boolean_or_null" + unit: "none" + aliases: ["VOLATILITY_EXPANSION_BREAKOUT"] + note: "VOLATILITY_EXPANSION_BREAKOUT_V1 산출 — BREAKOUT_QUALITY_GATE_V2 통과 전제." + fifty_two_week_high_breakout: + canonical_name: "fifty_two_week_high_breakout" + type: "boolean_or_null" + unit: "none" + aliases: ["FIFTY_TWO_WEEK_HIGH_BREAKOUT"] + note: "FIFTY_TWO_WEEK_HIGH_TRIGGER_V1 산출 — BREAKOUT_QUALITY_GATE_V2 입력 전용." + up_streak: + canonical_name: "up_streak" + type: "integer_or_null" + unit: "count" + aliases: ["UP_STREAK"] + note: "CONSECUTIVE_STREAK_V1 산출 — 연속 상승 일수." + trend_filter_pass: + canonical_name: "trend_filter_pass" + type: "boolean_or_null" + unit: "none" + aliases: ["TREND_FILTER_PASS"] + note: "TREND_FILTER_GATE_V1 산출 — close>ma120 AND ma120 상승 중." + breakout_failure: + canonical_name: "breakout_failure" + type: "boolean_or_null" + unit: "none" + aliases: ["BREAKOUT_FAILURE"] + note: "BREAKOUT_FAILURE_STOP_V1 산출 — true이면 SELL_RISK_EXIT_REVIEW." + deployable_cash_contribution_krw: canonical_name: "deployable_cash_contribution_krw" type: "number" diff --git a/spec/13_formula_registry.yaml b/spec/13_formula_registry.yaml index a755009..26df20e 100644 --- a/spec/13_formula_registry.yaml +++ b/spec/13_formula_registry.yaml @@ -112,6 +112,13 @@ formula_registry: - REBALANCE_CADENCE_GATE_V1 - WALK_FORWARD_BOOTSTRAP_V1 - WEEKLY_LEGACY_TRANSFER_PLAN_V1 + - GOLDEN_CROSS_SIGNAL_V1 + - STRONG_CLOSE_SIGNAL_V1 + - VOLATILITY_EXPANSION_BREAKOUT_V1 + - FIFTY_TWO_WEEK_HIGH_TRIGGER_V1 + - CONSECUTIVE_STREAK_V1 + - BREAKOUT_FAILURE_STOP_V1 + - TREND_FILTER_GATE_V1 implementation_map: REGIME_CONDITIONAL_MACRO_FACTOR_V1: tools/build_predictive_alpha_dialectic_engine_v2.py:NF1 REBOUND_CAPTURE_THESIS_FACTOR_V1: tools/build_predictive_alpha_dialectic_engine_v2.py:NF2 @@ -151,6 +158,13 @@ formula_registry: REBALANCE_CADENCE_GATE_V1: tools/build_rebalance_cadence_gate_v1.py WALK_FORWARD_BOOTSTRAP_V1: tools/build_walk_forward_bootstrap_v1.py WEEKLY_LEGACY_TRANSFER_PLAN_V1: tools/build_weekly_legacy_transfer_plan_v1.py + GOLDEN_CROSS_SIGNAL_V1: tools/build_golden_cross_signal_v1.py + STRONG_CLOSE_SIGNAL_V1: tools/build_strong_close_signal_v1.py + VOLATILITY_EXPANSION_BREAKOUT_V1: tools/build_volatility_expansion_breakout_v1.py + FIFTY_TWO_WEEK_HIGH_TRIGGER_V1: tools/build_fifty_two_week_high_trigger_v1.py + CONSECUTIVE_STREAK_V1: tools/build_consecutive_streak_v1.py + BREAKOUT_FAILURE_STOP_V1: tools/build_breakout_failure_stop_v1.py + TREND_FILTER_GATE_V1: tools/build_trend_filter_gate_v1.py formulas: FLOW_CREDIT_V1: purpose: 가격·거래량·5D 수급 품질을 0~1 점수로 계산 @@ -3162,6 +3176,135 @@ formula_registry: canonical_ref: spec/risk/portfolio_exposure.yaml:cash_floor implementation: tools/build_weekly_legacy_transfer_plan_v1.py version: 2026-06-17_P3_v8_9_adoption + GOLDEN_CROSS_SIGNAL_V1: + purpose: > + 단기 이동평균(ma20)이 장기 이동평균(ma60)을 상향 돌파하는 골든크로스를 정량 판정한다. + STRATEGY_SCORING 보조신호로만 사용 — 단독 BUY 트리거 금지. + (governance/todo/technical_signals_p4_adoption_plan.yaml P4-1) + inputs: + - field: ma20 + unit: KRW_per_share + - field: ma20_prev + unit: KRW_per_share + - field: ma60 + unit: KRW_per_share + - field: ma60_prev + unit: KRW_per_share + expression: "golden_cross_today = (ma20_prev <= ma60_prev) AND (ma20 > ma60)" + output: + field: golden_cross_today + unit: boolean + missing_policy: ma20_prev/ma60_prev 결측 시 null. + canonical_ref: spec/formulas/domains/entry.yaml:GOLDEN_CROSS_SIGNAL_V1 + implementation: tools/build_golden_cross_signal_v1.py + version: 2026-06-18_technical_signals_p4 + STRONG_CLOSE_SIGNAL_V1: + purpose: > + 종가가 당일 고가-저가 범위 중 고가 근처에서 마감하는지 판정한다. + (governance/todo/technical_signals_p4_adoption_plan.yaml P4-2) + inputs: + - field: close_price + unit: KRW_per_share + - field: high_price + unit: KRW_per_share + - field: low_price + unit: KRW_per_share + expression: "close_position_pct = (close_price-low_price)/(high_price-low_price)*100; strong_close = close_position_pct >= 80" + output: + field: strong_close + unit: boolean + missing_policy: high_price==low_price면 null. + canonical_ref: spec/formulas/domains/entry.yaml:STRONG_CLOSE_SIGNAL_V1 + implementation: tools/build_strong_close_signal_v1.py + version: 2026-06-18_technical_signals_p4 + VOLATILITY_EXPANSION_BREAKOUT_V1: + purpose: > + bb_width 수축(squeeze) 후 급등하는 패턴을 판정한다. BREAKOUT_QUALITY_GATE_V2 통과가 전제조건. + (governance/todo/technical_signals_p4_adoption_plan.yaml P4-3) + inputs: + - field: bb_width + unit: percent + - field: bb_width_20d_percentile + unit: percent + - field: ret_1d + unit: percent + expression: "squeeze_detected = bb_width_20d_percentile <= 20; volatility_expansion_breakout = squeeze_detected_previous_day AND ret_1d >= 3.0" + output: + field: volatility_expansion_breakout + unit: boolean + missing_policy: bb_width_20d_percentile 결측 시 null. + canonical_ref: spec/formulas/domains/entry.yaml:VOLATILITY_EXPANSION_BREAKOUT_V1 + implementation: tools/build_volatility_expansion_breakout_v1.py + version: 2026-06-18_technical_signals_p4 + FIFTY_TWO_WEEK_HIGH_TRIGGER_V1: + purpose: > + 종가가 52주 최고가(high52w)를 갱신하는지 판정해 BREAKOUT_QUALITY_GATE_V2 입력으로 공급한다. + (governance/todo/technical_signals_p4_adoption_plan.yaml P4-4) + inputs: + - field: close_price + unit: KRW_per_share + - field: high52w + unit: KRW_per_share + expression: "fifty_two_week_high_breakout = close_price >= high52w" + output: + field: fifty_two_week_high_breakout + unit: boolean + missing_policy: high52w 결측 시 null. + canonical_ref: spec/formulas/domains/entry.yaml:FIFTY_TWO_WEEK_HIGH_TRIGGER_V1 + implementation: tools/build_fifty_two_week_high_trigger_v1.py + version: 2026-06-18_technical_signals_p4 + CONSECUTIVE_STREAK_V1: + purpose: > + N일 연속 상승(up_streak)/하락(down_streak)을 대칭적으로 공식화한다. + (governance/todo/technical_signals_p4_adoption_plan.yaml P4-5) + inputs: + - field: daily_close_changes + unit: list_of_percent + output: + field: up_streak + unit: count + missing_policy: daily_close_changes 비어있으면 null. + canonical_ref: spec/formulas/domains/entry.yaml:CONSECUTIVE_STREAK_V1 + implementation: tools/build_consecutive_streak_v1.py + version: 2026-06-18_technical_signals_p4 + BREAKOUT_FAILURE_STOP_V1: + purpose: > + 전고점 돌파 후 7거래일 이내 재이탈하면 SELL_RISK_EXIT_REVIEW를 발동한다. + (governance/todo/technical_signals_p4_adoption_plan.yaml P4-6) + inputs: + - field: prior_high + unit: KRW_per_share + - field: close_price + unit: KRW_per_share + - field: days_since_breakout + unit: trading_days + expression: "breakout_failure = (days_since_breakout <= 7) AND (close_price < prior_high)" + output: + field: breakout_failure + unit: boolean + missing_policy: prior_high 결측 시 null. + canonical_ref: spec/formulas/domains/exit.yaml:BREAKOUT_FAILURE_STOP_V1 + implementation: tools/build_breakout_failure_stop_v1.py + version: 2026-06-18_technical_signals_p4 + TREND_FILTER_GATE_V1: + purpose: > + 종가가 ma120 위에 있고 ma120이 상승 중인지 단일 게이트로 판정한다. + (governance/todo/technical_signals_p4_adoption_plan.yaml P4-7) + inputs: + - field: close_price + unit: KRW_per_share + - field: ma120 + unit: KRW_per_share + - field: ma120_prev + unit: KRW_per_share + expression: "trend_filter_pass = (close_price > ma120) AND (ma120 > ma120_prev)" + output: + field: trend_filter_pass + unit: boolean + missing_policy: ma120/ma120_prev 결측 시 null. + canonical_ref: spec/strategy/entry_core.yaml:entry_timing_guardrails.regime_based_entry + implementation: tools/build_trend_filter_gate_v1.py + version: 2026-06-18_technical_signals_p4 SELL_EXECUTION_TIMING_V1: purpose: '장중 가격 움직임에 따라 매도 주문 유형과 타이밍을 결정론적으로 판정. 장초반 패닉 매도, 반등 직전 저점 투매 방지. diff --git a/spec/formula_golden_cases_v4.yaml b/spec/formula_golden_cases_v4.yaml index a1e7dad..2c06065 100644 --- a/spec/formula_golden_cases_v4.yaml +++ b/spec/formula_golden_cases_v4.yaml @@ -254,6 +254,73 @@ golden_cases: input: {weekly_legacy_to_cma_transfer_plan_krw: 4000000, transfer_confirmed: true, transfer_confirmed_amount_krw: 3800000} expected: {deployable_cash_contribution_krw: 3800000.0, plan_status: CONFIRMED_DEPLOYABLE} + # ── 기술적 신호 7종 채택 (governance/todo/technical_signals_p4_adoption_plan.yaml) ── + - formula_id: GOLDEN_CROSS_SIGNAL_V1 + id: GV4_GCS_001 + name: ma20가 ma60을 상향 돌파하면 golden_cross_today=true + input: {ma20_prev: 98, ma60_prev: 99, ma20: 105, ma60: 100} + expected: {golden_cross_today: true} + + - formula_id: GOLDEN_CROSS_SIGNAL_V1 + id: GV4_GCS_002 + name: 전일값 결측 시 null(DATA_MISSING), false로 추정 금지 + input: {ma20_prev: null} + expected: {golden_cross_today: null} + + - formula_id: STRONG_CLOSE_SIGNAL_V1 + id: GV4_SCS_001 + name: 종가가 고가 근처(90%)면 strong_close=true + input: {close: 99, high: 100, low: 90} + expected: {strong_close: true, close_position_pct: 90.0} + + - formula_id: STRONG_CLOSE_SIGNAL_V1 + id: GV4_SCS_002 + name: high==low(거래정지 등) 시 null + input: {high: 100, low: 100} + expected: {strong_close: null} + + - formula_id: VOLATILITY_EXPANSION_BREAKOUT_V1 + id: GV4_VEB_001 + name: squeeze(저백분위) 후 급등 시 volatility_expansion_breakout=true, BREAKOUT_QUALITY_GATE_V2 별도 통과 필요 + input: {bb_width_20d_percentile_prev: 10, ret_1d: 4.5} + expected: {volatility_expansion_breakout: true, hard_constraint: requires_BREAKOUT_QUALITY_GATE_V2_pass_separately} + + - formula_id: FIFTY_TWO_WEEK_HIGH_TRIGGER_V1 + id: GV4_FTW_001 + name: 종가가 52주 최고가 이상이면 breakout=true, 단독 매수 트리거 아님 + input: {close: 105, high52w: 100} + expected: {fifty_two_week_high_breakout: true, hard_constraint: feeds_BREAKOUT_QUALITY_GATE_V2_only_not_buy_trigger} + + - formula_id: CONSECUTIVE_STREAK_V1 + id: GV4_CST_001 + name: 최근 3일 연속 상승이면 up_streak=3, down_streak=0 + input: {daily_close_changes: [1, 2, -1, 1, 2, 3]} + expected: {up_streak: 3, down_streak: 0} + + - formula_id: BREAKOUT_FAILURE_STOP_V1 + id: GV4_BFS_001 + name: 돌파 후 7일 이내 전고점 아래로 재이탈하면 SELL_RISK_EXIT_REVIEW + input: {prior_high: 100, close: 95, days_since_breakout: 3} + expected: {breakout_failure: true, gate: SELL_RISK_EXIT_REVIEW} + + - formula_id: BREAKOUT_FAILURE_STOP_V1 + id: GV4_BFS_002 + name: 7일 초과 후 재이탈은 breakout_failure 규칙 미적용(false) + input: {prior_high: 100, close: 95, days_since_breakout: 10} + expected: {breakout_failure: false} + + - formula_id: TREND_FILTER_GATE_V1 + id: GV4_TFG_001 + name: 종가가 ma120 위 + ma120 상승 중이면 trend_filter_pass=true + input: {close: 105, ma120: 100, ma120_prev: 99} + expected: {trend_filter_pass: true} + + - formula_id: TREND_FILTER_GATE_V1 + id: GV4_TFG_002 + name: 종가가 ma120 아래면 trend_filter_pass=false + input: {close: 95, ma120: 100, ma120_prev: 99} + expected: {trend_filter_pass: false} + # ── STOP_BREACH_V1: profit_pct < -20% 경계값 3 케이스 ───────────────────── - formula_id: STOP_BREACH_V1 id: GV4_STOP_001 diff --git a/spec/formulas/domains/entry.yaml b/spec/formulas/domains/entry.yaml index 639eb28..25a7301 100644 --- a/spec/formulas/domains/entry.yaml +++ b/spec/formulas/domains/entry.yaml @@ -852,3 +852,223 @@ formulas: activation_threshold: min_t20_sample: 30 retirement_condition: performance_degradation + GOLDEN_CROSS_SIGNAL_V1: + purpose: > + 단기 이동평균(ma20)이 장기 이동평균(ma60)을 상향 돌파하는 골든크로스를 정량 판정한다. + 독립 BUY 트리거가 아니라 STRATEGY_SCORING의 component_scores 보조신호로만 사용하며, + BREAKOUT_QUALITY_GATE_V2/ANTI_LATE_ENTRY_GATE_V2를 우회하지 않는다. + (governance/todo/technical_signals_p4_adoption_plan.yaml P4-1, 사용자 제시 전략 01_골든크로스) + applicable: STRATEGY_SCORING 단계. 단독으로 BUY 의사결정 금지. + inputs: + - field: ma20 + unit: KRW_per_share + - field: ma20_prev + unit: KRW_per_share + note: 전일 ma20 값. + - field: ma60 + unit: KRW_per_share + - field: ma60_prev + unit: KRW_per_share + note: 전일 ma60 값. + expression: > + golden_cross_today = (ma20_prev <= ma60_prev) AND (ma20 > ma60) + output: + field: golden_cross_today + unit: boolean + hard_constraint: golden_cross_today=true는 STRATEGY_SCORING 보조점수 가산 입력일 뿐이며 BUY 게이트 체인을 대체하지 않는다. + missing_policy: ma20_prev 또는 ma60_prev 결측 시 golden_cross_today=null(DATA_MISSING). false로 추정하지 않는다. + canonical_ref: spec/13_formula_registry.yaml:formula_registry.formulas.FLOW_CREDIT_V1 + implementation: tools/build_golden_cross_signal_v1.py + owner: quant_team + lifecycle_state: shadow + input_fields: + - ma20 + - ma20_prev + - ma60 + - ma60_prev + output_fields: + - golden_cross_today + golden_cases: + - golden_cross_basic_detection + activation_threshold: + min_t20_sample: 30 + retirement_condition: performance_degradation + STRONG_CLOSE_SIGNAL_V1: + purpose: > + 종가가 당일 가격 범위(고가-저가) 중 고가 근처에서 마감하는지(강한 종가)를 정량 판정한다. + 모멘텀 지속 가능성의 보조신호로만 사용한다. + (governance/todo/technical_signals_p4_adoption_plan.yaml P4-2, 사용자 제시 전략 07_강한종가) + applicable: STRATEGY_SCORING 단계. + inputs: + - field: close_price + unit: KRW_per_share + - field: high_price + unit: KRW_per_share + - field: low_price + unit: KRW_per_share + expression: > + close_position_pct = (close_price - low_price) / (high_price - low_price) * 100 (high==low면 null) + strong_close = close_position_pct >= 80 + output: + field: strong_close + unit: boolean + additional_outputs: + - close_position_pct + missing_policy: high_price==low_price(거래정지 등)면 close_position_pct=null, strong_close=null. + canonical_ref: spec/13_formula_registry.yaml:formula_registry.formulas.FLOW_CREDIT_V1 + implementation: tools/build_strong_close_signal_v1.py + owner: quant_team + lifecycle_state: shadow + input_fields: + - close_price + - high_price + - low_price + output_fields: + - strong_close + - close_position_pct + golden_cases: + - strong_close_near_high + activation_threshold: + min_t20_sample: 30 + retirement_condition: performance_degradation + VOLATILITY_EXPANSION_BREAKOUT_V1: + purpose: > + 변동성이 수축(bb_width 축소)된 뒤 급등(변동성 확장)하는 패턴을 판정한다. 신규 필드 bb_width + 도입. 이 신호 자체는 BUY를 허가하지 않으며 BREAKOUT_QUALITY_GATE_V2 통과를 전제조건으로 한다. + (governance/todo/technical_signals_p4_adoption_plan.yaml P4-3, 사용자 제시 전략 08_변동성확장돌파) + applicable: STRATEGY_SCORING 단계. BREAKOUT_QUALITY_GATE_V2 PASS 후에만 보조신호로 채택. + inputs: + - field: bb_width + unit: percent + note: (상단밴드-하단밴드)/중심선 * 100. 20일 볼린저밴드 기준. + - field: bb_width_20d_percentile + unit: percent + note: 최근 20일 분포 내 현재 bb_width의 백분위. 낮을수록 수축(squeeze) 상태. + - field: ret_1d + unit: percent + expression: > + squeeze_detected = bb_width_20d_percentile <= 20 + volatility_expansion_breakout = squeeze_detected_previous_day AND ret_1d >= 3.0 + output: + field: volatility_expansion_breakout + unit: boolean + hard_constraint: volatility_expansion_breakout=true이어도 BREAKOUT_QUALITY_GATE_V2 != BLOCKED_LATE_CHASE 조건을 별도로 통과해야 BUY 후보 자격이 생긴다. + missing_policy: bb_width 또는 bb_width_20d_percentile 결측 시 squeeze_detected=null. false로 추정하지 않는다. + canonical_ref: spec/13_formula_registry.yaml:formula_registry.formulas.BREAKOUT_QUALITY_GATE_V2 + implementation: tools/build_volatility_expansion_breakout_v1.py + owner: quant_team + lifecycle_state: shadow + input_fields: + - bb_width + - bb_width_20d_percentile + - ret_1d + output_fields: + - volatility_expansion_breakout + golden_cases: + - squeeze_then_expansion + activation_threshold: + min_t20_sample: 30 + retirement_condition: performance_degradation + FIFTY_TWO_WEEK_HIGH_TRIGGER_V1: + purpose: > + 종가가 52주 최고가(High52W)를 갱신하는지 판정해 BREAKOUT_QUALITY_GATE_V2/FOLLOW_THROUGH_DAY_CONFIRM_V1 + 체인의 입력 신호로 공급한다(기존 필드 High52W를 트리거로 명시적으로 연결). + (governance/todo/technical_signals_p4_adoption_plan.yaml P4-4, 사용자 제시 전략 03_52주신고가) + applicable: STRATEGY_SCORING 단계. 단독 BUY 트리거 금지 — BREAKOUT_QUALITY_GATE_V2 입력으로만 전달. + inputs: + - field: close_price + unit: KRW_per_share + - field: high52w + unit: KRW_per_share + source: spec/12_field_dictionary.yaml:high52w (alias High52W) + expression: > + fifty_two_week_high_breakout = close_price >= high52w + output: + field: fifty_two_week_high_breakout + unit: boolean + hard_constraint: fifty_two_week_high_breakout=true는 BREAKOUT_QUALITY_GATE_V2 입력으로만 전달되며 그 자체로 BUY를 허가하지 않는다. + missing_policy: high52w 결측 시 fifty_two_week_high_breakout=null. + canonical_ref: spec/13_formula_registry.yaml:formula_registry.formulas.BREAKOUT_QUALITY_GATE_V2 + implementation: tools/build_fifty_two_week_high_trigger_v1.py + owner: quant_team + lifecycle_state: shadow + input_fields: + - close_price + - high52w + output_fields: + - fifty_two_week_high_breakout + golden_cases: + - high52w_breakout_detection + activation_threshold: + min_t20_sample: 30 + retirement_condition: performance_degradation + CONSECUTIVE_STREAK_V1: + purpose: > + N일 연속 상승(up_streak)/연속 하락(down_streak)을 대칭적으로 공식화한다. + down_streak는 REBOUND_CAPTURE_THESIS_FACTOR_V1에 하위조건으로 이미 존재하나 up_streak + 대응이 없어 비대칭이었다. 이 공식이 단일 출처가 된다. + (governance/todo/technical_signals_p4_adoption_plan.yaml P4-5, 사용자 제시 전략 04_연속상승하락) + applicable: STRATEGY_SCORING 단계, REBOUND_CAPTURE_THESIS_FACTOR_V1 down_streak 입력의 canonical source. + inputs: + - field: daily_close_changes + unit: list_of_percent + note: 최근 N거래일의 일별 종가 변화율(%) 리스트. 최신값이 마지막. + expression: > + up_streak = 마지막 값부터 역순으로 연속 양수(>0)인 일수 + down_streak = 마지막 값부터 역순으로 연속 음수(<0)인 일수 + output: + field: up_streak + unit: count + additional_outputs: + - down_streak + missing_policy: daily_close_changes 비어있으면 up_streak=down_streak=null. + canonical_ref: spec/13_formula_registry.yaml:formula_registry.formulas.REBOUND_CAPTURE_THESIS_FACTOR_V1 + implementation: tools/build_consecutive_streak_v1.py + owner: quant_team + lifecycle_state: shadow + input_fields: + - daily_close_changes + output_fields: + - up_streak + - down_streak + golden_cases: + - up_streak_and_down_streak_symmetry + activation_threshold: + min_t20_sample: 30 + retirement_condition: performance_degradation + TREND_FILTER_GATE_V1: + purpose: > + 종가가 장기 이동평균(ma120) 위에 있고 ma120 자체가 상승 중인지를 단일 게이트로 공식화한다. + entry_core.yaml:regime_based_entry, ANTI_LATE_ENTRY_GATE_V2의 암묵적 추세 조건을 명시적 + 단일 공식으로 통합해 LLM이 추세 판정을 임의로 서술하지 않게 한다. + (governance/todo/technical_signals_p4_adoption_plan.yaml P4-7, 사용자 제시 전략 10_추세필터) + applicable: STRATEGY_SCORING 및 PORTFOLIO_CONSTRAINT_CHECK 입력. 단독으로 BUY 허가하지 않음 — HOLD/AVOID 보조 게이트. + inputs: + - field: close_price + unit: KRW_per_share + - field: ma120 + unit: KRW_per_share + - field: ma120_prev + unit: KRW_per_share + note: 전일 ma120 값. 상승 여부 판정용. + expression: > + trend_filter_pass = (close_price > ma120) AND (ma120 > ma120_prev) + output: + field: trend_filter_pass + unit: boolean + missing_policy: ma120 또는 ma120_prev 결측 시 trend_filter_pass=null(DATA_MISSING). + canonical_ref: spec/strategy/entry_core.yaml:entry_timing_guardrails.regime_based_entry + implementation: tools/build_trend_filter_gate_v1.py + owner: quant_team + lifecycle_state: shadow + input_fields: + - close_price + - ma120 + - ma120_prev + output_fields: + - trend_filter_pass + golden_cases: + - trend_filter_above_rising_ma120 + activation_threshold: + min_t20_sample: 30 + retirement_condition: performance_degradation diff --git a/spec/formulas/domains/exit.yaml b/spec/formulas/domains/exit.yaml index 4de032f..d620e13 100644 --- a/spec/formulas/domains/exit.yaml +++ b/spec/formulas/domains/exit.yaml @@ -1544,3 +1544,43 @@ formulas: activation_threshold: min_t20_sample: 30 retirement_condition: performance_degradation + BREAKOUT_FAILURE_STOP_V1: + purpose: > + 전고점(prior_high)을 돌파한 종목이 며칠 내 다시 그 아래로 이탈하면("돌파 실패") 전용 손절을 + 발동한다. ANTI_WHIPSAW_GATE_V1보다 좁고 구체적인 "돌파 후 재이탈" 패턴 전용 규칙이다. + (governance/todo/technical_signals_p4_adoption_plan.yaml P4-6, 사용자 제시 전략 06_돌파실패손절) + applicable: EXIT_POLICY_CHECK 단계. 보유 포지션이 돌파 매수로 진입한 경우에만 적용. + inputs: + - field: prior_high + unit: KRW_per_share + note: 진입 당시 돌파 기준이 된 전고점. + - field: close_price + unit: KRW_per_share + - field: days_since_breakout + unit: trading_days + source: spec/13_formula_registry.yaml:formula_registry.formulas.FOLLOW_THROUGH_DAY_CONFIRM_V1 + expression: > + breakout_failure = (days_since_breakout <= 7) AND (close_price < prior_high) + output: + field: breakout_failure + unit: boolean + gates: + - if: breakout_failure == true + action: SELL_RISK_EXIT_REVIEW + reason_code: breakout_failure_stop + missing_policy: prior_high 또는 days_since_breakout 결측 시 breakout_failure=null. 손절 신호를 임의 추정하지 않는다. + canonical_ref: spec/13_formula_registry.yaml:formula_registry.formulas.ANTI_WHIPSAW_GATE_V1 + implementation: tools/build_breakout_failure_stop_v1.py + owner: quant_team + lifecycle_state: shadow + input_fields: + - prior_high + - close_price + - days_since_breakout + output_fields: + - breakout_failure + golden_cases: + - breakout_then_reentry_below_prior_high + activation_threshold: + min_t20_sample: 30 + retirement_condition: performance_degradation diff --git a/src/quant_engine/models/generated/breakout_failure_stop_v1_schema.py b/src/quant_engine/models/generated/breakout_failure_stop_v1_schema.py new file mode 100644 index 0000000..f869953 --- /dev/null +++ b/src/quant_engine/models/generated/breakout_failure_stop_v1_schema.py @@ -0,0 +1,33 @@ +"""Auto-generated schema model descriptor.""" +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path +from typing import Any + +SCHEMA_TITLE = 'BREAKOUT_FAILURE_STOP_V1' +SCHEMA_ID = 'schema://formula/BREAKOUT_FAILURE_STOP_V1' +SCHEMA_PATH = 'schemas/generated/breakout_failure_stop_v1.schema.json' +SCHEMA_PROPERTIES = ['formula_id', 'owner', 'status', 'inputs', 'outputs'] +SCHEMA_REQUIRED = ['formula_id', 'owner', 'status', 'inputs', 'outputs'] + +@dataclass(frozen=True) +class SchemaModel: + title: str + schema_id: str + path: str + properties: list[str] + required: list[str] + +def load_schema() -> dict[str, Any]: + return json.loads(Path(__file__).with_suffix('.schema.json').read_text(encoding='utf-8')) + +def describe() -> SchemaModel: + return SchemaModel( + title=SCHEMA_TITLE, + schema_id=SCHEMA_ID, + path=SCHEMA_PATH, + properties=list(SCHEMA_PROPERTIES), + required=list(SCHEMA_REQUIRED), + ) diff --git a/src/quant_engine/models/generated/breakout_failure_stop_v1_schema.schema.json b/src/quant_engine/models/generated/breakout_failure_stop_v1_schema.schema.json new file mode 100644 index 0000000..a57ec5c --- /dev/null +++ b/src/quant_engine/models/generated/breakout_failure_stop_v1_schema.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schema://formula/BREAKOUT_FAILURE_STOP_V1", + "title": "BREAKOUT_FAILURE_STOP_V1", + "type": "object", + "properties": { + "formula_id": { "const": "BREAKOUT_FAILURE_STOP_V1" }, + "owner": { "type": "string" }, + "status": { "type": "string" }, + "inputs": { "type": "array", "items": { "type": "string" } }, + "outputs": { "type": "array", "items": { "type": "string" } } + }, + "required": ["formula_id", "owner", "status", "inputs", "outputs"], + "x_formula_inputs": ["prior_high", "close_price", "days_since_breakout"], + "x_formula_outputs": ["breakout_failure"] +} diff --git a/src/quant_engine/models/generated/consecutive_streak_v1_schema.py b/src/quant_engine/models/generated/consecutive_streak_v1_schema.py new file mode 100644 index 0000000..9f31a8b --- /dev/null +++ b/src/quant_engine/models/generated/consecutive_streak_v1_schema.py @@ -0,0 +1,33 @@ +"""Auto-generated schema model descriptor.""" +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path +from typing import Any + +SCHEMA_TITLE = 'CONSECUTIVE_STREAK_V1' +SCHEMA_ID = 'schema://formula/CONSECUTIVE_STREAK_V1' +SCHEMA_PATH = 'schemas/generated/consecutive_streak_v1.schema.json' +SCHEMA_PROPERTIES = ['formula_id', 'owner', 'status', 'inputs', 'outputs'] +SCHEMA_REQUIRED = ['formula_id', 'owner', 'status', 'inputs', 'outputs'] + +@dataclass(frozen=True) +class SchemaModel: + title: str + schema_id: str + path: str + properties: list[str] + required: list[str] + +def load_schema() -> dict[str, Any]: + return json.loads(Path(__file__).with_suffix('.schema.json').read_text(encoding='utf-8')) + +def describe() -> SchemaModel: + return SchemaModel( + title=SCHEMA_TITLE, + schema_id=SCHEMA_ID, + path=SCHEMA_PATH, + properties=list(SCHEMA_PROPERTIES), + required=list(SCHEMA_REQUIRED), + ) diff --git a/src/quant_engine/models/generated/consecutive_streak_v1_schema.schema.json b/src/quant_engine/models/generated/consecutive_streak_v1_schema.schema.json new file mode 100644 index 0000000..3be26a7 --- /dev/null +++ b/src/quant_engine/models/generated/consecutive_streak_v1_schema.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schema://formula/CONSECUTIVE_STREAK_V1", + "title": "CONSECUTIVE_STREAK_V1", + "type": "object", + "properties": { + "formula_id": { "const": "CONSECUTIVE_STREAK_V1" }, + "owner": { "type": "string" }, + "status": { "type": "string" }, + "inputs": { "type": "array", "items": { "type": "string" } }, + "outputs": { "type": "array", "items": { "type": "string" } } + }, + "required": ["formula_id", "owner", "status", "inputs", "outputs"], + "x_formula_inputs": ["daily_close_changes"], + "x_formula_outputs": ["up_streak", "down_streak"] +} diff --git a/src/quant_engine/models/generated/fifty_two_week_high_trigger_v1_schema.py b/src/quant_engine/models/generated/fifty_two_week_high_trigger_v1_schema.py new file mode 100644 index 0000000..197acb5 --- /dev/null +++ b/src/quant_engine/models/generated/fifty_two_week_high_trigger_v1_schema.py @@ -0,0 +1,33 @@ +"""Auto-generated schema model descriptor.""" +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path +from typing import Any + +SCHEMA_TITLE = 'FIFTY_TWO_WEEK_HIGH_TRIGGER_V1' +SCHEMA_ID = 'schema://formula/FIFTY_TWO_WEEK_HIGH_TRIGGER_V1' +SCHEMA_PATH = 'schemas/generated/fifty_two_week_high_trigger_v1.schema.json' +SCHEMA_PROPERTIES = ['formula_id', 'owner', 'status', 'inputs', 'outputs'] +SCHEMA_REQUIRED = ['formula_id', 'owner', 'status', 'inputs', 'outputs'] + +@dataclass(frozen=True) +class SchemaModel: + title: str + schema_id: str + path: str + properties: list[str] + required: list[str] + +def load_schema() -> dict[str, Any]: + return json.loads(Path(__file__).with_suffix('.schema.json').read_text(encoding='utf-8')) + +def describe() -> SchemaModel: + return SchemaModel( + title=SCHEMA_TITLE, + schema_id=SCHEMA_ID, + path=SCHEMA_PATH, + properties=list(SCHEMA_PROPERTIES), + required=list(SCHEMA_REQUIRED), + ) diff --git a/src/quant_engine/models/generated/fifty_two_week_high_trigger_v1_schema.schema.json b/src/quant_engine/models/generated/fifty_two_week_high_trigger_v1_schema.schema.json new file mode 100644 index 0000000..a13a0d9 --- /dev/null +++ b/src/quant_engine/models/generated/fifty_two_week_high_trigger_v1_schema.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schema://formula/FIFTY_TWO_WEEK_HIGH_TRIGGER_V1", + "title": "FIFTY_TWO_WEEK_HIGH_TRIGGER_V1", + "type": "object", + "properties": { + "formula_id": { "const": "FIFTY_TWO_WEEK_HIGH_TRIGGER_V1" }, + "owner": { "type": "string" }, + "status": { "type": "string" }, + "inputs": { "type": "array", "items": { "type": "string" } }, + "outputs": { "type": "array", "items": { "type": "string" } } + }, + "required": ["formula_id", "owner", "status", "inputs", "outputs"], + "x_formula_inputs": ["close_price", "high52w"], + "x_formula_outputs": ["fifty_two_week_high_breakout"] +} diff --git a/src/quant_engine/models/generated/golden_cross_signal_v1_schema.py b/src/quant_engine/models/generated/golden_cross_signal_v1_schema.py new file mode 100644 index 0000000..cca6d76 --- /dev/null +++ b/src/quant_engine/models/generated/golden_cross_signal_v1_schema.py @@ -0,0 +1,33 @@ +"""Auto-generated schema model descriptor.""" +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path +from typing import Any + +SCHEMA_TITLE = 'GOLDEN_CROSS_SIGNAL_V1' +SCHEMA_ID = 'schema://formula/GOLDEN_CROSS_SIGNAL_V1' +SCHEMA_PATH = 'schemas/generated/golden_cross_signal_v1.schema.json' +SCHEMA_PROPERTIES = ['formula_id', 'owner', 'status', 'inputs', 'outputs'] +SCHEMA_REQUIRED = ['formula_id', 'owner', 'status', 'inputs', 'outputs'] + +@dataclass(frozen=True) +class SchemaModel: + title: str + schema_id: str + path: str + properties: list[str] + required: list[str] + +def load_schema() -> dict[str, Any]: + return json.loads(Path(__file__).with_suffix('.schema.json').read_text(encoding='utf-8')) + +def describe() -> SchemaModel: + return SchemaModel( + title=SCHEMA_TITLE, + schema_id=SCHEMA_ID, + path=SCHEMA_PATH, + properties=list(SCHEMA_PROPERTIES), + required=list(SCHEMA_REQUIRED), + ) diff --git a/src/quant_engine/models/generated/golden_cross_signal_v1_schema.schema.json b/src/quant_engine/models/generated/golden_cross_signal_v1_schema.schema.json new file mode 100644 index 0000000..4bd61c9 --- /dev/null +++ b/src/quant_engine/models/generated/golden_cross_signal_v1_schema.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schema://formula/GOLDEN_CROSS_SIGNAL_V1", + "title": "GOLDEN_CROSS_SIGNAL_V1", + "type": "object", + "properties": { + "formula_id": { "const": "GOLDEN_CROSS_SIGNAL_V1" }, + "owner": { "type": "string" }, + "status": { "type": "string" }, + "inputs": { "type": "array", "items": { "type": "string" } }, + "outputs": { "type": "array", "items": { "type": "string" } } + }, + "required": ["formula_id", "owner", "status", "inputs", "outputs"], + "x_formula_inputs": ["ma20", "ma20_prev", "ma60", "ma60_prev"], + "x_formula_outputs": ["golden_cross_today"] +} diff --git a/src/quant_engine/models/generated/strong_close_signal_v1_schema.py b/src/quant_engine/models/generated/strong_close_signal_v1_schema.py new file mode 100644 index 0000000..d18fcf6 --- /dev/null +++ b/src/quant_engine/models/generated/strong_close_signal_v1_schema.py @@ -0,0 +1,33 @@ +"""Auto-generated schema model descriptor.""" +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path +from typing import Any + +SCHEMA_TITLE = 'STRONG_CLOSE_SIGNAL_V1' +SCHEMA_ID = 'schema://formula/STRONG_CLOSE_SIGNAL_V1' +SCHEMA_PATH = 'schemas/generated/strong_close_signal_v1.schema.json' +SCHEMA_PROPERTIES = ['formula_id', 'owner', 'status', 'inputs', 'outputs'] +SCHEMA_REQUIRED = ['formula_id', 'owner', 'status', 'inputs', 'outputs'] + +@dataclass(frozen=True) +class SchemaModel: + title: str + schema_id: str + path: str + properties: list[str] + required: list[str] + +def load_schema() -> dict[str, Any]: + return json.loads(Path(__file__).with_suffix('.schema.json').read_text(encoding='utf-8')) + +def describe() -> SchemaModel: + return SchemaModel( + title=SCHEMA_TITLE, + schema_id=SCHEMA_ID, + path=SCHEMA_PATH, + properties=list(SCHEMA_PROPERTIES), + required=list(SCHEMA_REQUIRED), + ) diff --git a/src/quant_engine/models/generated/strong_close_signal_v1_schema.schema.json b/src/quant_engine/models/generated/strong_close_signal_v1_schema.schema.json new file mode 100644 index 0000000..72ac32f --- /dev/null +++ b/src/quant_engine/models/generated/strong_close_signal_v1_schema.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schema://formula/STRONG_CLOSE_SIGNAL_V1", + "title": "STRONG_CLOSE_SIGNAL_V1", + "type": "object", + "properties": { + "formula_id": { "const": "STRONG_CLOSE_SIGNAL_V1" }, + "owner": { "type": "string" }, + "status": { "type": "string" }, + "inputs": { "type": "array", "items": { "type": "string" } }, + "outputs": { "type": "array", "items": { "type": "string" } } + }, + "required": ["formula_id", "owner", "status", "inputs", "outputs"], + "x_formula_inputs": ["close_price", "high_price", "low_price"], + "x_formula_outputs": ["strong_close", "close_position_pct"] +} diff --git a/src/quant_engine/models/generated/trend_filter_gate_v1_schema.py b/src/quant_engine/models/generated/trend_filter_gate_v1_schema.py new file mode 100644 index 0000000..6063906 --- /dev/null +++ b/src/quant_engine/models/generated/trend_filter_gate_v1_schema.py @@ -0,0 +1,33 @@ +"""Auto-generated schema model descriptor.""" +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path +from typing import Any + +SCHEMA_TITLE = 'TREND_FILTER_GATE_V1' +SCHEMA_ID = 'schema://formula/TREND_FILTER_GATE_V1' +SCHEMA_PATH = 'schemas/generated/trend_filter_gate_v1.schema.json' +SCHEMA_PROPERTIES = ['formula_id', 'owner', 'status', 'inputs', 'outputs'] +SCHEMA_REQUIRED = ['formula_id', 'owner', 'status', 'inputs', 'outputs'] + +@dataclass(frozen=True) +class SchemaModel: + title: str + schema_id: str + path: str + properties: list[str] + required: list[str] + +def load_schema() -> dict[str, Any]: + return json.loads(Path(__file__).with_suffix('.schema.json').read_text(encoding='utf-8')) + +def describe() -> SchemaModel: + return SchemaModel( + title=SCHEMA_TITLE, + schema_id=SCHEMA_ID, + path=SCHEMA_PATH, + properties=list(SCHEMA_PROPERTIES), + required=list(SCHEMA_REQUIRED), + ) diff --git a/src/quant_engine/models/generated/trend_filter_gate_v1_schema.schema.json b/src/quant_engine/models/generated/trend_filter_gate_v1_schema.schema.json new file mode 100644 index 0000000..f0ba65a --- /dev/null +++ b/src/quant_engine/models/generated/trend_filter_gate_v1_schema.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schema://formula/TREND_FILTER_GATE_V1", + "title": "TREND_FILTER_GATE_V1", + "type": "object", + "properties": { + "formula_id": { "const": "TREND_FILTER_GATE_V1" }, + "owner": { "type": "string" }, + "status": { "type": "string" }, + "inputs": { "type": "array", "items": { "type": "string" } }, + "outputs": { "type": "array", "items": { "type": "string" } } + }, + "required": ["formula_id", "owner", "status", "inputs", "outputs"], + "x_formula_inputs": ["close_price", "ma120", "ma120_prev"], + "x_formula_outputs": ["trend_filter_pass"] +} diff --git a/src/quant_engine/models/generated/volatility_expansion_breakout_v1_schema.py b/src/quant_engine/models/generated/volatility_expansion_breakout_v1_schema.py new file mode 100644 index 0000000..7575b25 --- /dev/null +++ b/src/quant_engine/models/generated/volatility_expansion_breakout_v1_schema.py @@ -0,0 +1,33 @@ +"""Auto-generated schema model descriptor.""" +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path +from typing import Any + +SCHEMA_TITLE = 'VOLATILITY_EXPANSION_BREAKOUT_V1' +SCHEMA_ID = 'schema://formula/VOLATILITY_EXPANSION_BREAKOUT_V1' +SCHEMA_PATH = 'schemas/generated/volatility_expansion_breakout_v1.schema.json' +SCHEMA_PROPERTIES = ['formula_id', 'owner', 'status', 'inputs', 'outputs'] +SCHEMA_REQUIRED = ['formula_id', 'owner', 'status', 'inputs', 'outputs'] + +@dataclass(frozen=True) +class SchemaModel: + title: str + schema_id: str + path: str + properties: list[str] + required: list[str] + +def load_schema() -> dict[str, Any]: + return json.loads(Path(__file__).with_suffix('.schema.json').read_text(encoding='utf-8')) + +def describe() -> SchemaModel: + return SchemaModel( + title=SCHEMA_TITLE, + schema_id=SCHEMA_ID, + path=SCHEMA_PATH, + properties=list(SCHEMA_PROPERTIES), + required=list(SCHEMA_REQUIRED), + ) diff --git a/src/quant_engine/models/generated/volatility_expansion_breakout_v1_schema.schema.json b/src/quant_engine/models/generated/volatility_expansion_breakout_v1_schema.schema.json new file mode 100644 index 0000000..de1cccc --- /dev/null +++ b/src/quant_engine/models/generated/volatility_expansion_breakout_v1_schema.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schema://formula/VOLATILITY_EXPANSION_BREAKOUT_V1", + "title": "VOLATILITY_EXPANSION_BREAKOUT_V1", + "type": "object", + "properties": { + "formula_id": { "const": "VOLATILITY_EXPANSION_BREAKOUT_V1" }, + "owner": { "type": "string" }, + "status": { "type": "string" }, + "inputs": { "type": "array", "items": { "type": "string" } }, + "outputs": { "type": "array", "items": { "type": "string" } } + }, + "required": ["formula_id", "owner", "status", "inputs", "outputs"], + "x_formula_inputs": ["bb_width", "bb_width_20d_percentile", "ret_1d"], + "x_formula_outputs": ["volatility_expansion_breakout"] +} diff --git a/tests/golden/generated/breakout_failure_stop_v1_golden.py b/tests/golden/generated/breakout_failure_stop_v1_golden.py new file mode 100644 index 0000000..8487df9 --- /dev/null +++ b/tests/golden/generated/breakout_failure_stop_v1_golden.py @@ -0,0 +1,35 @@ +"""Golden tests for BREAKOUT_FAILURE_STOP_V1 (governance/todo/technical_signals_p4_adoption_plan.yaml P4-6).""" +from __future__ import annotations + +import importlib.util +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +MODULE_PATH = ROOT / "tools" / "build_breakout_failure_stop_v1.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location("build_breakout_failure_stop_v1", MODULE_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_reentry_below_prior_high_within_window_triggers_sell_review() -> None: + mod = _load_module() + result = mod.evaluate(100, 95, 3) + assert result["breakout_failure"] is True + assert result["gate"] == "SELL_RISK_EXIT_REVIEW" + + +def test_reentry_after_window_does_not_trigger() -> None: + mod = _load_module() + result = mod.evaluate(100, 95, 10) + assert result["breakout_failure"] is False + + +def test_missing_prior_high_returns_null() -> None: + mod = _load_module() + result = mod.evaluate(None, 95, 3) + assert result["breakout_failure"] is None diff --git a/tests/golden/generated/consecutive_streak_v1_golden.py b/tests/golden/generated/consecutive_streak_v1_golden.py new file mode 100644 index 0000000..b78045e --- /dev/null +++ b/tests/golden/generated/consecutive_streak_v1_golden.py @@ -0,0 +1,37 @@ +"""Golden tests for CONSECUTIVE_STREAK_V1 (governance/todo/technical_signals_p4_adoption_plan.yaml P4-5).""" +from __future__ import annotations + +import importlib.util +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +MODULE_PATH = ROOT / "tools" / "build_consecutive_streak_v1.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location("build_consecutive_streak_v1", MODULE_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_trailing_three_up_days_gives_up_streak_3() -> None: + mod = _load_module() + result = mod.compute_streaks([1, 2, -1, 1, 2, 3]) + assert result["up_streak"] == 3 + assert result["down_streak"] == 0 + + +def test_trailing_two_down_days_gives_down_streak_2() -> None: + mod = _load_module() + result = mod.compute_streaks([5, -1, -2]) + assert result["down_streak"] == 2 + assert result["up_streak"] == 0 + + +def test_empty_changes_returns_null_not_zero() -> None: + mod = _load_module() + result = mod.compute_streaks([]) + assert result["up_streak"] is None + assert result["down_streak"] is None diff --git a/tests/golden/generated/fifty_two_week_high_trigger_v1_golden.py b/tests/golden/generated/fifty_two_week_high_trigger_v1_golden.py new file mode 100644 index 0000000..9f15a5d --- /dev/null +++ b/tests/golden/generated/fifty_two_week_high_trigger_v1_golden.py @@ -0,0 +1,31 @@ +"""Golden tests for FIFTY_TWO_WEEK_HIGH_TRIGGER_V1 (governance/todo/technical_signals_p4_adoption_plan.yaml P4-4).""" +from __future__ import annotations + +import importlib.util +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +MODULE_PATH = ROOT / "tools" / "build_fifty_two_week_high_trigger_v1.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location("build_fifty_two_week_high_trigger_v1", MODULE_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_close_at_or_above_52w_high_triggers() -> None: + mod = _load_module() + assert mod.evaluate(105, 100) is True + + +def test_close_below_52w_high_does_not_trigger() -> None: + mod = _load_module() + assert mod.evaluate(95, 100) is False + + +def test_missing_high52w_returns_null() -> None: + mod = _load_module() + assert mod.evaluate(105, None) is None diff --git a/tests/golden/generated/golden_cross_signal_v1_golden.py b/tests/golden/generated/golden_cross_signal_v1_golden.py new file mode 100644 index 0000000..5f8ca6b --- /dev/null +++ b/tests/golden/generated/golden_cross_signal_v1_golden.py @@ -0,0 +1,31 @@ +"""Golden tests for GOLDEN_CROSS_SIGNAL_V1 (governance/todo/technical_signals_p4_adoption_plan.yaml P4-1).""" +from __future__ import annotations + +import importlib.util +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +MODULE_PATH = ROOT / "tools" / "build_golden_cross_signal_v1.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location("build_golden_cross_signal_v1", MODULE_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_ma20_crossing_above_ma60_detected() -> None: + mod = _load_module() + assert mod.golden_cross_today(105, 98, 100, 99) is True + + +def test_no_cross_when_ma20_already_above() -> None: + mod = _load_module() + assert mod.golden_cross_today(105, 101, 100, 99) is False + + +def test_missing_prev_values_returns_null_not_false() -> None: + mod = _load_module() + assert mod.golden_cross_today(105, None, 100, 99) is None diff --git a/tests/golden/generated/strong_close_signal_v1_golden.py b/tests/golden/generated/strong_close_signal_v1_golden.py new file mode 100644 index 0000000..d5b3a1d --- /dev/null +++ b/tests/golden/generated/strong_close_signal_v1_golden.py @@ -0,0 +1,36 @@ +"""Golden tests for STRONG_CLOSE_SIGNAL_V1 (governance/todo/technical_signals_p4_adoption_plan.yaml P4-2).""" +from __future__ import annotations + +import importlib.util +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +MODULE_PATH = ROOT / "tools" / "build_strong_close_signal_v1.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location("build_strong_close_signal_v1", MODULE_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_close_near_high_is_strong_close() -> None: + mod = _load_module() + result = mod.evaluate_strong_close(99, 100, 90) + assert result["strong_close"] is True + assert result["close_position_pct"] == 90.0 + + +def test_close_near_low_is_not_strong_close() -> None: + mod = _load_module() + result = mod.evaluate_strong_close(91, 100, 90) + assert result["strong_close"] is False + + +def test_degenerate_high_equals_low_returns_null() -> None: + mod = _load_module() + result = mod.evaluate_strong_close(100, 100, 100) + assert result["strong_close"] is None + assert result["close_position_pct"] is None diff --git a/tests/golden/generated/trend_filter_gate_v1_golden.py b/tests/golden/generated/trend_filter_gate_v1_golden.py new file mode 100644 index 0000000..364606a --- /dev/null +++ b/tests/golden/generated/trend_filter_gate_v1_golden.py @@ -0,0 +1,36 @@ +"""Golden tests for TREND_FILTER_GATE_V1 (governance/todo/technical_signals_p4_adoption_plan.yaml P4-7).""" +from __future__ import annotations + +import importlib.util +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +MODULE_PATH = ROOT / "tools" / "build_trend_filter_gate_v1.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location("build_trend_filter_gate_v1", MODULE_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_above_rising_ma120_passes() -> None: + mod = _load_module() + assert mod.evaluate(105, 100, 99) is True + + +def test_below_ma120_fails() -> None: + mod = _load_module() + assert mod.evaluate(95, 100, 99) is False + + +def test_above_but_falling_ma120_fails() -> None: + mod = _load_module() + assert mod.evaluate(105, 100, 101) is False + + +def test_missing_ma120_returns_null() -> None: + mod = _load_module() + assert mod.evaluate(105, None, 99) is None diff --git a/tests/golden/generated/volatility_expansion_breakout_v1_golden.py b/tests/golden/generated/volatility_expansion_breakout_v1_golden.py new file mode 100644 index 0000000..83f9083 --- /dev/null +++ b/tests/golden/generated/volatility_expansion_breakout_v1_golden.py @@ -0,0 +1,35 @@ +"""Golden tests for VOLATILITY_EXPANSION_BREAKOUT_V1 (governance/todo/technical_signals_p4_adoption_plan.yaml P4-3).""" +from __future__ import annotations + +import importlib.util +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +MODULE_PATH = ROOT / "tools" / "build_volatility_expansion_breakout_v1.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location("build_volatility_expansion_breakout_v1", MODULE_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_squeeze_then_strong_move_triggers_signal() -> None: + mod = _load_module() + prev_squeeze = mod.squeeze_detected(10) + assert prev_squeeze is True + assert mod.evaluate(prev_squeeze, 4.5) is True + + +def test_no_squeeze_does_not_trigger_even_with_strong_move() -> None: + mod = _load_module() + prev_squeeze = mod.squeeze_detected(80) + assert prev_squeeze is False + assert mod.evaluate(prev_squeeze, 4.5) is False + + +def test_missing_percentile_returns_null() -> None: + mod = _load_module() + assert mod.squeeze_detected(None) is None diff --git a/tools/build_breakout_failure_stop_v1.py b/tools/build_breakout_failure_stop_v1.py new file mode 100644 index 0000000..4c84ea4 --- /dev/null +++ b/tools/build_breakout_failure_stop_v1.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""BREAKOUT_FAILURE_STOP_V1 — spec/formulas/domains/exit.yaml. + +governance/todo/technical_signals_p4_adoption_plan.yaml P4-6. +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_OUT = ROOT / "Temp" / "breakout_failure_stop_v1.json" + + +def evaluate(prior_high: float | None, close_price: float | None, days_since_breakout: int | None) -> dict: + if prior_high is None or close_price is None or days_since_breakout is None: + return {"breakout_failure": None} + breakout_failure = days_since_breakout <= 7 and close_price < prior_high + result = {"breakout_failure": breakout_failure} + if breakout_failure: + result["gate"] = "SELL_RISK_EXIT_REVIEW" + result["reason_code"] = "breakout_failure_stop" + return result + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--prior-high", type=float, default=None) + ap.add_argument("--close", type=float, default=None) + ap.add_argument("--days-since-breakout", type=int, default=None) + ap.add_argument("--out", default=str(DEFAULT_OUT)) + args = ap.parse_args() + + result = { + "formula_id": "BREAKOUT_FAILURE_STOP_V1", + **evaluate(args.prior_high, args.close, args.days_since_breakout), + } + out = Path(args.out) + out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/build_consecutive_streak_v1.py b/tools/build_consecutive_streak_v1.py new file mode 100644 index 0000000..47771c6 --- /dev/null +++ b/tools/build_consecutive_streak_v1.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""CONSECUTIVE_STREAK_V1 — spec/formulas/domains/entry.yaml. + +Symmetric up_streak/down_streak from the most recent close-to-close changes. +governance/todo/technical_signals_p4_adoption_plan.yaml P4-5. +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_OUT = ROOT / "Temp" / "consecutive_streak_v1.json" + + +def compute_streaks(daily_close_changes: list[float] | None) -> dict: + if not daily_close_changes: + return {"up_streak": None, "down_streak": None} + + up_streak = 0 + for change in reversed(daily_close_changes): + if change > 0: + up_streak += 1 + else: + break + + down_streak = 0 + for change in reversed(daily_close_changes): + if change < 0: + down_streak += 1 + else: + break + + return {"up_streak": up_streak, "down_streak": down_streak} + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--changes", default=None, help="comma-separated daily close changes") + ap.add_argument("--out", default=str(DEFAULT_OUT)) + args = ap.parse_args() + + daily_close_changes = [float(x) for x in args.changes.split(",")] if args.changes else None + result = {"formula_id": "CONSECUTIVE_STREAK_V1", **compute_streaks(daily_close_changes)} + out = Path(args.out) + out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/build_fifty_two_week_high_trigger_v1.py b/tools/build_fifty_two_week_high_trigger_v1.py new file mode 100644 index 0000000..bbd5eb1 --- /dev/null +++ b/tools/build_fifty_two_week_high_trigger_v1.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""FIFTY_TWO_WEEK_HIGH_TRIGGER_V1 — spec/formulas/domains/entry.yaml. + +Feeds BREAKOUT_QUALITY_GATE_V2 -- never an independent BUY trigger. +governance/todo/technical_signals_p4_adoption_plan.yaml P4-4. +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_OUT = ROOT / "Temp" / "fifty_two_week_high_trigger_v1.json" + + +def evaluate(close_price: float | None, high52w: float | None) -> bool | None: + if close_price is None or high52w is None: + return None + return close_price >= high52w + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--close", type=float, default=None) + ap.add_argument("--high52w", type=float, default=None) + ap.add_argument("--out", default=str(DEFAULT_OUT)) + args = ap.parse_args() + + result = { + "formula_id": "FIFTY_TWO_WEEK_HIGH_TRIGGER_V1", + "fifty_two_week_high_breakout": evaluate(args.close, args.high52w), + "hard_constraint": "feeds_BREAKOUT_QUALITY_GATE_V2_only_not_buy_trigger", + } + out = Path(args.out) + out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/build_golden_cross_signal_v1.py b/tools/build_golden_cross_signal_v1.py new file mode 100644 index 0000000..96f043e --- /dev/null +++ b/tools/build_golden_cross_signal_v1.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""GOLDEN_CROSS_SIGNAL_V1 — spec/formulas/domains/entry.yaml. + +Detects ma20 crossing above ma60 (golden cross). Auxiliary STRATEGY_SCORING signal +only -- never an independent BUY trigger. governance/todo/technical_signals_p4_adoption_plan.yaml P4-1. +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_OUT = ROOT / "Temp" / "golden_cross_signal_v1.json" + + +def golden_cross_today(ma20: float | None, ma20_prev: float | None, ma60: float | None, ma60_prev: float | None) -> bool | None: + if None in (ma20, ma20_prev, ma60, ma60_prev): + return None + return ma20_prev <= ma60_prev and ma20 > ma60 + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--ma20", type=float, default=None) + ap.add_argument("--ma20-prev", type=float, default=None) + ap.add_argument("--ma60", type=float, default=None) + ap.add_argument("--ma60-prev", type=float, default=None) + ap.add_argument("--out", default=str(DEFAULT_OUT)) + args = ap.parse_args() + + result = { + "formula_id": "GOLDEN_CROSS_SIGNAL_V1", + "golden_cross_today": golden_cross_today(args.ma20, args.ma20_prev, args.ma60, args.ma60_prev), + "hard_constraint": "auxiliary_signal_only_not_buy_trigger", + } + out = Path(args.out) + out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/build_strong_close_signal_v1.py b/tools/build_strong_close_signal_v1.py new file mode 100644 index 0000000..cba909a --- /dev/null +++ b/tools/build_strong_close_signal_v1.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""STRONG_CLOSE_SIGNAL_V1 — spec/formulas/domains/entry.yaml. + +governance/todo/technical_signals_p4_adoption_plan.yaml P4-2. +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_OUT = ROOT / "Temp" / "strong_close_signal_v1.json" + + +def evaluate_strong_close(close_price: float, high_price: float, low_price: float) -> dict: + if high_price == low_price: + return {"close_position_pct": None, "strong_close": None} + close_position_pct = (close_price - low_price) / (high_price - low_price) * 100 + return {"close_position_pct": close_position_pct, "strong_close": close_position_pct >= 80} + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--close", type=float, required=True) + ap.add_argument("--high", type=float, required=True) + ap.add_argument("--low", type=float, required=True) + ap.add_argument("--out", default=str(DEFAULT_OUT)) + args = ap.parse_args() + + result = {"formula_id": "STRONG_CLOSE_SIGNAL_V1", **evaluate_strong_close(args.close, args.high, args.low)} + out = Path(args.out) + out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/build_trend_filter_gate_v1.py b/tools/build_trend_filter_gate_v1.py new file mode 100644 index 0000000..95837e0 --- /dev/null +++ b/tools/build_trend_filter_gate_v1.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""TREND_FILTER_GATE_V1 — spec/formulas/domains/entry.yaml. + +governance/todo/technical_signals_p4_adoption_plan.yaml P4-7. +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_OUT = ROOT / "Temp" / "trend_filter_gate_v1.json" + + +def evaluate(close_price: float | None, ma120: float | None, ma120_prev: float | None) -> bool | None: + if close_price is None or ma120 is None or ma120_prev is None: + return None + return close_price > ma120 and ma120 > ma120_prev + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--close", type=float, default=None) + ap.add_argument("--ma120", type=float, default=None) + ap.add_argument("--ma120-prev", type=float, default=None) + ap.add_argument("--out", default=str(DEFAULT_OUT)) + args = ap.parse_args() + + result = { + "formula_id": "TREND_FILTER_GATE_V1", + "trend_filter_pass": evaluate(args.close, args.ma120, args.ma120_prev), + } + out = Path(args.out) + out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/build_volatility_expansion_breakout_v1.py b/tools/build_volatility_expansion_breakout_v1.py new file mode 100644 index 0000000..276b691 --- /dev/null +++ b/tools/build_volatility_expansion_breakout_v1.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""VOLATILITY_EXPANSION_BREAKOUT_V1 — spec/formulas/domains/entry.yaml. + +This signal does NOT authorize a buy by itself -- callers must separately confirm +BREAKOUT_QUALITY_GATE_V2 != BLOCKED_LATE_CHASE. governance/todo/technical_signals_p4_adoption_plan.yaml P4-3. +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_OUT = ROOT / "Temp" / "volatility_expansion_breakout_v1.json" + +SQUEEZE_PERCENTILE_THRESHOLD = 20.0 +EXPANSION_RET_THRESHOLD_PCT = 3.0 + + +def squeeze_detected(bb_width_20d_percentile: float | None) -> bool | None: + if bb_width_20d_percentile is None: + return None + return bb_width_20d_percentile <= SQUEEZE_PERCENTILE_THRESHOLD + + +def evaluate(squeeze_detected_previous_day: bool | None, ret_1d: float | None) -> bool | None: + if squeeze_detected_previous_day is None or ret_1d is None: + return None + return squeeze_detected_previous_day and ret_1d >= EXPANSION_RET_THRESHOLD_PCT + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--bb-width-20d-percentile-prev", type=float, default=None) + ap.add_argument("--ret-1d", type=float, default=None) + ap.add_argument("--out", default=str(DEFAULT_OUT)) + args = ap.parse_args() + + prev_squeeze = squeeze_detected(args.bb_width_20d_percentile_prev) + result = { + "formula_id": "VOLATILITY_EXPANSION_BREAKOUT_V1", + "squeeze_detected_previous_day": prev_squeeze, + "volatility_expansion_breakout": evaluate(prev_squeeze, args.ret_1d), + "hard_constraint": "requires_BREAKOUT_QUALITY_GATE_V2_pass_separately", + } + out = Path(args.out) + out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())