From 4f96904a2fa6606411a5067c1e6b88597992a4b4 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 22 May 2026 07:33:33 +0800 Subject: [PATCH] Add doctrine PD array reaction kernel --- SESSION.md | 5 +- TASKS.md | 3 +- ..._PDArrayReactionKernel_D4_2026-05-22_v1.md | 95 ++++ ...d4-pd-array-strategy-backtest-1366x768.png | Bin 0 -> 55760 bytes src/api/backtest.py | 36 ++ src/api/server.py | 10 +- src/services/backtest/backtest_service.py | 70 +++ src/services/ict_mainline/__init__.py | 12 + src/services/ict_mainline/doctrine.py | 453 +++++++++++++++++- src/web/app.js | 31 ++ src/web/i18n.js | 28 ++ tests/unit/test_api_and_main.py | 20 + tests/unit/test_api_server.py | 16 + tests/unit/test_backtest_service.py | 35 ++ tests/unit/test_documentation_consistency.py | 1 + tests/unit/test_ict_doctrine.py | 95 ++++ 16 files changed, 904 insertions(+), 6 deletions(-) create mode 100644 docs/AI_ICT_DoctrineKernel_PDArrayReactionKernel_D4_2026-05-22_v1.md create mode 100644 output/playwright/d4-pd-array-strategy-backtest-1366x768.png diff --git a/SESSION.md b/SESSION.md index 84e202d..48d63b4 100644 --- a/SESSION.md +++ b/SESSION.md @@ -19,7 +19,7 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. ## Last Updated -2026-05-21 UTC +2026-05-22 UTC ## Automation Status @@ -848,10 +848,11 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. - D1 Doctrine Market Language Kernel is complete locally: added `src/services/ict_mainline/doctrine.py`, `GET /trading/doctrine-rules`, Strategy Backtest `doctrine_research_status` / `doctrine_rule_gap_summary`, and Trader Cockpit watchlist/readout `research_only` gating. Old sweep/MSS/FVG/retrace candidates now expose `missing_doctrine_setup_playbook` or the specific missing Doctrine rule reason unless mapped to a full setup playbook. No execution/readiness/risk/trust/operator-confirmation gate was changed. - D2 Doctrine Playbook / Backtest Cohort is complete locally: added the first registered setup playbook `ICT-CH1-6-LONDON-DISCOUNT-REVERSAL`, `GET /trading/doctrine-playbooks`, `doctrine_backtest_cohort` / `doctrine_cohort_summary`, `BACKTEST_SETUP_PLAYBOOK_ID`, `BACKTEST_BATCH_SETUP_PLAYBOOK_IDS`, and a Review Now gate that requires Doctrine playbook, active Trading Plan, and Strategy Backtest quality evidence to all pass. No execution/readiness/risk/trust/operator-confirmation gate was changed. - D3 Liquidity Kernel is complete locally: added `DOCTRINE_LIQUIDITY_PHASE=D3_LIQUIDITY_KERNEL`, `LiquidityTargetRule`, `GET /trading/liquidity-kernel`, Strategy Backtest `liquidity_backtest_features` / `liquidity_kernel_status` / `liquidity_kernel_summary`, and `docs/AI_ICT_DoctrineKernel_LiquidityKernel_D3_2026-05-21_v1.md`. BSL / SSL / liquidity pool / stop run / sweep / raid are now target-ranking and no-trade/wait rules; `sweep_is_not_setup`, `missing_liquidity_target`, `missing_liquidity_pool`, `missing_working_draw`, and `manipulation_window_wait` block promotion until the Working Draw, ranked pool, post-take reaction, and objective reorder evidence are present. No execution/readiness/risk/trust/operator-confirmation gate was changed. +- D4 PD Array / Reaction Kernel is complete, deployed, and remote-verified: added `DOCTRINE_REACTION_PHASE=D4_PD_ARRAY_REACTION_KERNEL`, `PdArrayReactionRule`, `GET /trading/pd-array-kernel`, Strategy Backtest `pd_array_backtest_features` / `pd_array_reaction_status` / `pd_array_reaction_summary`, and `docs/AI_ICT_DoctrineKernel_PDArrayReactionKernel_D4_2026-05-22_v1.md`. FVG / OB / Breaker / Mitigation / BPR/Rebalancing now stay reaction-location quality rankings rather than direction tags; `missing_pd_array`, `missing_pd_array_role`, `missing_array_location`, `fvg_is_not_setup`, `order_block_not_authorized`, and `breaker_without_failure_precondition` block promotion until role, rank, location, freshness, origin, reaction/retest, and invalidation are explicit. Validation passed focused D4/API/backtest/docs tests, full unit discovery (`839` tests), script static checks (`79` scripts), JS syntax checks, deploy smoke over 11 endpoints, remote ops smoke, read-only live analysis smoke, `/trading/pd-array-kernel` field smoke, `/trading/backtests` D4 field smoke, and Playwright screenshot evidence at `output/playwright/d4-pd-array-strategy-backtest-1366x768.png`. The read-only smoke initially exposed the expected external proxy blocker; starting the local v2rayN/sing-box proxy restored `192.168.3.73:10808` and the gate passed. No execution/readiness/risk/trust/operator-confirmation gate was changed. ## Exact Next Step -Use `docs/AI_ICT_DoctrineKernel全书规则内核重建_D0_2026-05-21_v1.md`, `docs/AI_ICT_DoctrineKernel市场语言规则内核_D1_2026-05-21_v1.md`, `docs/AI_ICT_DoctrineKernel_PlaybookBacktestCohort_D2_2026-05-21_v1.md`, `docs/AI_ICT_DoctrineKernel_LiquidityKernel_D3_2026-05-21_v1.md`, `docs/ICT交易全书_出版终校版.md`, `docs/AI_ICT_实盘辅助分析决策助手ICT主线开发方案_2026-05-21_v1.md`, `docs/AI_ICT_批量历史回测验证_S16_2026-05-21_v1.md`, `SESSION.md`, and `TASKS.md` as the active anchors. Start D4 PD Array / Reaction Kernel: extract FVG / Order Block / Breaker / Mitigation / Rebalance / Void into reaction-location quality ranking rules; keep PD Array as a reaction/entry-location layer, not a direction source; wire the playbook `pd_array_rule` toward measurable backtest fields. Do not restart external-context/archive/digest long-tail work, do not add order-submission features, do not physically delete S1 deprecated surfaces yet, and do not fabricate R2 paper samples from blocked candidates. Keep `execution_allowed`, `decision_support_ready`, `real_live_ready`, risk governor, trust bucket, and operator confirmation protocol unchanged. +Use `docs/AI_ICT_DoctrineKernel全书规则内核重建_D0_2026-05-21_v1.md`, `docs/AI_ICT_DoctrineKernel市场语言规则内核_D1_2026-05-21_v1.md`, `docs/AI_ICT_DoctrineKernel_PlaybookBacktestCohort_D2_2026-05-21_v1.md`, `docs/AI_ICT_DoctrineKernel_LiquidityKernel_D3_2026-05-21_v1.md`, `docs/AI_ICT_DoctrineKernel_PDArrayReactionKernel_D4_2026-05-22_v1.md`, `docs/ICT交易全书_出版终校版.md`, `docs/AI_ICT_实盘辅助分析决策助手ICT主线开发方案_2026-05-21_v1.md`, `docs/AI_ICT_批量历史回测验证_S16_2026-05-21_v1.md`, `SESSION.md`, and `TASKS.md` as the active anchors. Start D5 Time / Session Kernel: rebuild Asia / London / NY AM / NY PM, killzone, Judas/manipulation window, opening range, weekly profile, and news/no-trade time into measurable session-quality rules; a setup must bind to a verifiable session window and outside-session evidence stays study-only/wait. Do not restart external-context/archive/digest long-tail work, do not add order-submission features, do not physically delete S1 deprecated surfaces yet, and do not fabricate R2 paper samples from blocked candidates. Keep `execution_allowed`, `decision_support_ready`, `real_live_ready`, risk governor, trust bucket, and operator confirmation protocol unchanged. ## Validation Commands Used Recently diff --git a/TASKS.md b/TASKS.md index 1768aac..11e3ad5 100644 --- a/TASKS.md +++ b/TASKS.md @@ -955,7 +955,8 @@ - [x] D1 Doctrine Market Language Kernel: built the first `Range / Draw / Location / Context / Time / Invalidation / IRL-ERL` registry, added `/trading/doctrine-rules`, Strategy Backtest Doctrine gap aggregation, and Trader Cockpit `research_only_reason` gating for old surface-shape candidates; no live order submission and no fabricated samples - [x] D2 Doctrine Playbook / Backtest Cohort: defined first registered setup playbook `ICT-CH1-6-LONDON-DISCOUNT-REVERSAL`, added `/trading/doctrine-playbooks`, `doctrine_backtest_cohort` / `doctrine_cohort_summary`, setup-playbook batch envs, Strategy Backtest UI cohort display, and Review Now gating that requires playbook mapping + historical quality gate + active Trading Plan - [x] D3 Liquidity Kernel: rebuilt BSL / SSL / liquidity pool / stop run / sweep / raid from shape labels into target-ranking and no-trade/wait rules; sweep remains evidence component only, Strategy Backtest exposes liquidity features/status/summary, and `/trading/liquidity-kernel` exposes the D3 registry -- [ ] D4 next product gate: rebuild PD Array / Reaction Kernel so FVG / OB / Breaker / Mitigation / Rebalance / Void become reaction-location quality rankings instead of standalone direction tags +- [x] D4 PD Array / Reaction Kernel: rebuilt FVG / OB / Breaker / Mitigation / Rebalance / Void into reaction-location quality ranking and no-trade/wait rules; PD Array remains location/reaction evidence only, Strategy Backtest exposes PD Array features/status/summary, `/trading/pd-array-kernel` exposes the D4 registry, and remote deploy/ops/read-only smoke passed after the local proxy was restarted +- [ ] D5 next product gate: rebuild Time / Session Kernel so Asia / London / NY AM / NY PM / killzone / Judas window / opening range / weekly profile / news time become measurable session-quality gates instead of loose time labels - [x] R3 OKX sandbox isolated smoke tooling: guarded script and service can call OKX demo submit/query/cancel/query/fills only when `OKX_SANDBOX_SMOKE_EXECUTE=1`; local validation, deploy smoke, and remote ops smoke passed - [ ] R3 sandbox credential permission gate deferred: current phase is assisted analysis / decision support only, so do not request trade-permission credentials or make submit/cancel smoke the next gate - [ ] Long-term target: complete `Real-time Assisted Trading V1` without enabling default live auto-trading diff --git a/docs/AI_ICT_DoctrineKernel_PDArrayReactionKernel_D4_2026-05-22_v1.md b/docs/AI_ICT_DoctrineKernel_PDArrayReactionKernel_D4_2026-05-22_v1.md new file mode 100644 index 0000000..7daa7d9 --- /dev/null +++ b/docs/AI_ICT_DoctrineKernel_PDArrayReactionKernel_D4_2026-05-22_v1.md @@ -0,0 +1,95 @@ +# AI_ICT Doctrine Kernel PD Array / Reaction Kernel D4 2026-05-22 v1 + +## Scope + +D4 rebuilds PD Arrays from visible shape labels into a reaction-location quality layer. This slice follows `docs/ICT交易全书_出版终校版.md` chapters 13-17 and keeps the hard mainline chain: + +`Range / Draw / Location -> Liquidity / Sweep / Inducement / Path -> Displacement / MSS / FVG / Retest / Session -> backtest evidence -> pre-market plan -> intraday unique focus or no-trade reason -> journal / study-notes feedback`. + +## Product Rule + +PD Array is not a direction source. FVG, Order Block, Breaker, Mitigation, BPR/Rebalancing, and Void can only support a setup after the system can state: + +- what reaction location it is, +- whether it is target, entry, repair, expansion, or observation, +- where it sits relative to the active Range / Draw / Location / Liquidity chain, +- how fresh and high-rank it is, +- what displacement or failed-order-flow premise created it, +- what reaction/retest behavior occurred after price arrived, +- where the array-specific invalidation is. + +If those items are missing, Strategy Backtest and Trader Cockpit must keep the setup as `study_only` or `wait`. + +## Implemented Kernel + +Code anchor: `src/services/ict_mainline/doctrine.py` + +New phase: + +- `DOCTRINE_REACTION_PHASE = D4_PD_ARRAY_REACTION_KERNEL` + +New registry/API: + +- `GET /trading/pd-array-kernel` +- `build_pd_array_reaction_registry` +- `list_pd_array_rules` + +New backtest surface fields: + +- `pd_array_backtest_features` +- `pd_array_reaction_status` +- `pd_array_reaction_summary` +- `pd_array_reaction_status_counts` +- `pd_array_reaction_reason_counts` +- `by_pd_array_type` + +## D4 Rules + +1. `pd_array_relevance_matrix` + PD Array must be ranked as reaction-location quality. It is not a buy/sell or bias signal. + +2. `fvg_behavior_after_rebalance` + FVG is not a trade. It requires role, displacement origin, retrace/retest, and reaction quality. + +3. `bpr_rebalancing_classifier` + BPR/Rebalancing is path state. It must classify internal repair versus external expansion and require structure response. + +4. `order_block_authorization` + Order Block needs displacement origin, validation, structure context, draw relationship, and parent/subordinate alignment. + +5. `breaker_mitigation_failure_reuse` + Breaker/Mitigation requires objective failed order flow plus revisit reaction before review. + +## Promotion Gate + +The D2 playbook now requires D4 PD Array fields before promotion: + +- `pd_array_type` +- `pd_array_id` +- `pd_array_role` +- `pd_array_rank` +- `array_location` +- `array_timeframe` +- `array_freshness` +- `formation_event` +- `displacement_origin` +- `fvg_role` +- `fvg_retrace_confirmed` +- `reaction_quality` +- `invalidation` + +The Doctrine Backtest Cohort now blocks promotion unless: + +- D2 playbook schema is complete, +- D3 Liquidity Kernel passes, +- D4 PD Array / Reaction Kernel passes, +- Strategy Backtest quality gate passes, +- active Trading Plan gate passes. + +## Read-Only Boundary + +D4 does not add order submission, does not change `execution_allowed`, does not change `decision_support_ready`, does not change `real_live_ready`, and does not loosen risk governor / trust bucket / operator confirmation protocol. + +## Next Slice + +D5 should rebuild the Time / Session Kernel: Asia, London, NY AM, NY PM, killzones, Judas/manipulation windows, opening range, weekly profile, news/time-based no-trade rules, and measurable session fields in Strategy Backtest. diff --git a/output/playwright/d4-pd-array-strategy-backtest-1366x768.png b/output/playwright/d4-pd-array-strategy-backtest-1366x768.png new file mode 100644 index 0000000000000000000000000000000000000000..f0dfb8d637dc41554137aafbc19a73ca4bf2e213 GIT binary patch literal 55760 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 Q1Bx(sy85}Sb4q9e0I5U7TL1t6 literal 0 HcmV?d00001 diff --git a/src/api/backtest.py b/src/api/backtest.py index 336edb5..737c373 100644 --- a/src/api/backtest.py +++ b/src/api/backtest.py @@ -8,13 +8,16 @@ from src.services.backtest import evaluate_backtest_quality from src.services.ict_mainline import ( build_doctrine_backtest_cohort, build_liquidity_backtest_features, + build_pd_array_backtest_features, build_backtest_quality_gate, build_ict_rulebook_mapping, evaluate_doctrine_research_status, evaluate_liquidity_kernel_status, + evaluate_pd_array_reaction_status, summarize_doctrine_backtest_cohorts, summarize_doctrine_rule_gaps, summarize_liquidity_kernel_statuses, + summarize_pd_array_reaction_statuses, ) @@ -37,6 +40,7 @@ def get_strategy_backtest_surface(instrument_id: Optional[int] = None, limit: in doctrine_rule_gap_summary = summarize_doctrine_rule_gaps(items) doctrine_cohort_summary = summarize_doctrine_backtest_cohorts(items) liquidity_kernel_summary = summarize_liquidity_kernel_statuses(items) + pd_array_reaction_summary = summarize_pd_array_reaction_statuses(items) return { "instrument_id": instrument_id, "item_count": len(items), @@ -47,6 +51,7 @@ def get_strategy_backtest_surface(instrument_id: Optional[int] = None, limit: in "doctrine_rule_gap_summary": doctrine_rule_gap_summary, "doctrine_cohort_summary": doctrine_cohort_summary, "liquidity_kernel_summary": liquidity_kernel_summary, + "pd_array_reaction_summary": pd_array_reaction_summary, "ict_rulebook_mapping": ict_rulebook_mapping, "items": items, } @@ -131,6 +136,27 @@ def normalize_backtest_summary_row(row: dict) -> dict: "liquidity_backtest_features": liquidity_backtest_features, } ) + pd_array_backtest_features = build_pd_array_backtest_features( + { + "subject_type": row.get("subject_type"), + "model_code": row.get("model_code"), + "candidate_model_code": row.get("candidate_model_code"), + "timeframe_set": timeframe_set, + "config": config, + "summary": summary, + } + ) + pd_array_reaction_status = evaluate_pd_array_reaction_status( + { + "subject_type": row.get("subject_type"), + "model_code": row.get("model_code"), + "candidate_model_code": row.get("candidate_model_code"), + "timeframe_set": timeframe_set, + "config": config, + "summary": summary, + "pd_array_backtest_features": pd_array_backtest_features, + } + ) doctrine_backtest_cohort = build_doctrine_backtest_cohort( { "subject_type": row.get("subject_type"), @@ -140,6 +166,7 @@ def normalize_backtest_summary_row(row: dict) -> dict: "config": config, "summary": summary, "liquidity_backtest_features": liquidity_backtest_features, + "pd_array_backtest_features": pd_array_backtest_features, }, quality_gate=dict(summary.get("quality_gate") or {}), ) @@ -152,6 +179,8 @@ def normalize_backtest_summary_row(row: dict) -> dict: "doctrine_research_status": doctrine_research_status, "liquidity_backtest_features": liquidity_backtest_features, "liquidity_kernel_status": liquidity_kernel_status, + "pd_array_backtest_features": pd_array_backtest_features, + "pd_array_reaction_status": pd_array_reaction_status, "doctrine_backtest_cohort": doctrine_backtest_cohort, } @@ -189,6 +218,13 @@ def _build_operator_summary(latest: Optional[dict]) -> dict: status = "liquidity_kernel_blocked" decision = "do_not_promote_strategy" reason = str(liquidity_reasons[0]) + pd_array_status = dict(latest.get("pd_array_reaction_status") or {}) + if not pd_array_status.get("passed"): + pd_array_reasons = list(pd_array_status.get("reasons") or []) + if pd_array_reasons: + status = "pd_array_reaction_blocked" + decision = "do_not_promote_strategy" + reason = str(pd_array_reasons[0]) if not (doctrine_cohort.get("promotion_gate") or {}).get("passed"): cohort_reasons = list((doctrine_cohort.get("promotion_gate") or {}).get("reasons") or []) if cohort_reasons: diff --git a/src/api/server.py b/src/api/server.py index 5e9fa8d..95228d4 100644 --- a/src/api/server.py +++ b/src/api/server.py @@ -122,7 +122,12 @@ from src.api.training import ( parse_quality_sort_query, submit_training_attempt, ) -from src.services.ict_mainline import build_doctrine_playbook_registry, build_doctrine_rule_matrix, build_liquidity_kernel_registry +from src.services.ict_mainline import ( + build_doctrine_playbook_registry, + build_doctrine_rule_matrix, + build_liquidity_kernel_registry, + build_pd_array_reaction_registry, +) from src.services.execution import ExecutionService from src.services.market_state import start_market_truth_runtime from src.services.runtime import start_trading_runtime_service @@ -225,6 +230,7 @@ def build_api_dependencies() -> dict: "trading_doctrine_rules": build_doctrine_rule_matrix, "trading_doctrine_playbooks": build_doctrine_playbook_registry, "trading_liquidity_kernel": build_liquidity_kernel_registry, + "trading_pd_array_kernel": build_pd_array_reaction_registry, "training_cases": get_training_cases, "training_case": get_training_case, "training_export_cases": export_training_case_package, @@ -852,6 +858,8 @@ def route_request( return 200, deps["trading_doctrine_playbooks"]() if method == "GET" and path == "/trading/liquidity-kernel": return 200, deps["trading_liquidity_kernel"]() + if method == "GET" and path == "/trading/pd-array-kernel": + return 200, deps["trading_pd_array_kernel"]() if method == "GET" and len(path_parts) == 3 and path_parts[:2] == ["trading", "candidates"]: candidate = deps["trading_candidate_by_id"](_parse_path_int(path_parts[2], "candidate_id")) if candidate is None: diff --git a/src/services/backtest/backtest_service.py b/src/services/backtest/backtest_service.py index 1ed26d6..8585e32 100644 --- a/src/services/backtest/backtest_service.py +++ b/src/services/backtest/backtest_service.py @@ -7,7 +7,9 @@ from src.services.backtest.replay import ReplaySwing, iter_historical_replay from src.services.ict_mainline import ( build_doctrine_backtest_cohort, build_liquidity_backtest_features, + build_pd_array_backtest_features, evaluate_liquidity_kernel_status, + evaluate_pd_array_reaction_status, extract_setup_playbook_id, ) @@ -142,6 +144,16 @@ class BacktestService: 'active_trading_plan': active_trading_plan, } ) + pd_array_backtest_features = build_pd_array_backtest_features( + { + 'subject_type': subject_type, + 'model_code': 'SWEEP_MSS_FVG', + 'candidate_model_code': candidate_model_code, + 'timeframe_set': timeframe_set, + 'run_metadata': run_metadata, + 'active_trading_plan': active_trading_plan, + } + ) doctrine_backtest_cohort = build_doctrine_backtest_cohort( { 'subject_type': subject_type, @@ -150,6 +162,7 @@ class BacktestService: 'timeframe_set': timeframe_set, 'run_metadata': run_metadata, 'liquidity_backtest_features': liquidity_backtest_features, + 'pd_array_backtest_features': pd_array_backtest_features, } ) run_id = self.repository.insert_backtest_run( @@ -174,6 +187,8 @@ class BacktestService: 'setup_playbook_id': setup_playbook_id, 'liquidity_backtest_features': liquidity_backtest_features, 'liquidity_kernel_status': evaluate_liquidity_kernel_status(liquidity_backtest_features), + 'pd_array_backtest_features': pd_array_backtest_features, + 'pd_array_reaction_status': evaluate_pd_array_reaction_status(pd_array_backtest_features), 'doctrine_backtest_cohort': doctrine_backtest_cohort, 'run_metadata': run_metadata, }, @@ -194,8 +209,11 @@ class BacktestService: summary['quality_gate'] = evaluate_backtest_quality(summary, gate=quality_gate, sample_basis='executed') outcome_setup_playbook_id = setup_playbook_id or _first_outcome_meta_value(outcomes, 'setup_playbook_id') outcome_liquidity_features = _first_outcome_meta_value(outcomes, 'liquidity_backtest_features') or liquidity_backtest_features + outcome_pd_array_features = _first_outcome_meta_value(outcomes, 'pd_array_backtest_features') or pd_array_backtest_features summary['liquidity_backtest_features'] = dict(outcome_liquidity_features or {}) summary['liquidity_kernel_status'] = evaluate_liquidity_kernel_status(summary['liquidity_backtest_features']) + summary['pd_array_backtest_features'] = dict(outcome_pd_array_features or {}) + summary['pd_array_reaction_status'] = evaluate_pd_array_reaction_status(summary['pd_array_backtest_features']) summary['doctrine_backtest_cohort'] = build_doctrine_backtest_cohort( { 'subject_type': subject_type, @@ -205,6 +223,7 @@ class BacktestService: 'run_metadata': {**run_metadata, 'setup_playbook_id': outcome_setup_playbook_id}, 'summary': summary, 'liquidity_backtest_features': summary['liquidity_backtest_features'], + 'pd_array_backtest_features': summary['pd_array_backtest_features'], }, quality_gate=summary['quality_gate'], ) @@ -242,6 +261,13 @@ class BacktestService: 'run_metadata': run_metadata, } ) + signal_pd_array_features = build_pd_array_backtest_features( + { + 'signal': signal, + 'target_plan': target_plan, + 'run_metadata': run_metadata, + } + ) error_tags = ['no_future_candles'] if len(sessions) == 0: error_tags.append('outside_session_window') @@ -280,6 +306,8 @@ class BacktestService: 'management_rules': management_rules, 'liquidity_backtest_features': signal_liquidity_features, 'liquidity_kernel_status': evaluate_liquidity_kernel_status(signal_liquidity_features), + 'pd_array_backtest_features': signal_pd_array_features, + 'pd_array_reaction_status': evaluate_pd_array_reaction_status(signal_pd_array_features), 'cost_r': 0.0, 'cost_model': cost_config.__dict__, 'backtest_mode': mode, @@ -363,10 +391,18 @@ class BacktestService: 'run_metadata': run_metadata, } ), + 'pd_array_backtest_features': build_pd_array_backtest_features( + { + 'signal': signal, + 'target_plan': extract_target_plan_from_signal_row(signal), + 'run_metadata': run_metadata, + } + ), 'weekday': _extract_weekday(signal['created_ts']), } ) record.meta['liquidity_kernel_status'] = evaluate_liquidity_kernel_status(record.meta['liquidity_backtest_features']) + record.meta['pd_array_reaction_status'] = evaluate_pd_array_reaction_status(record.meta['pd_array_backtest_features']) outcomes.append(record) self.repository.insert_backtest_result( backtest_run_id=run_id, @@ -386,8 +422,11 @@ class BacktestService: summary = summarize_backtest_outcomes(outcomes, subject_type=subject_type) summary['quality_gate'] = evaluate_backtest_quality(summary, gate=quality_gate) outcome_liquidity_features = _first_outcome_meta_value(outcomes, 'liquidity_backtest_features') or liquidity_backtest_features + outcome_pd_array_features = _first_outcome_meta_value(outcomes, 'pd_array_backtest_features') or pd_array_backtest_features summary['liquidity_backtest_features'] = dict(outcome_liquidity_features or {}) summary['liquidity_kernel_status'] = evaluate_liquidity_kernel_status(summary['liquidity_backtest_features']) + summary['pd_array_backtest_features'] = dict(outcome_pd_array_features or {}) + summary['pd_array_reaction_status'] = evaluate_pd_array_reaction_status(summary['pd_array_backtest_features']) summary['doctrine_backtest_cohort'] = build_doctrine_backtest_cohort( { 'subject_type': subject_type, @@ -397,6 +436,7 @@ class BacktestService: 'run_metadata': run_metadata, 'summary': summary, 'liquidity_backtest_features': summary['liquidity_backtest_features'], + 'pd_array_backtest_features': summary['pd_array_backtest_features'], }, quality_gate=summary['quality_gate'], ) @@ -963,6 +1003,17 @@ def build_plan_replay_units(candidate_rows: list[dict]) -> list[dict]: } ) liquidity_kernel_status = evaluate_liquidity_kernel_status(liquidity_backtest_features) + pd_array_backtest_features = build_pd_array_backtest_features( + { + 'first_candidate': first, + 'latest_candidate': latest, + 'confirmed_candidate': confirmed_row or {}, + 'entry_payload': entry_payload, + 'target_plan': target_plan, + 'setup_playbook_id': setup_playbook_id, + } + ) + pd_array_reaction_status = evaluate_pd_array_reaction_status(pd_array_backtest_features) doctrine_backtest_cohort = build_doctrine_backtest_cohort( { 'setup_playbook_id': setup_playbook_id, @@ -972,6 +1023,7 @@ def build_plan_replay_units(candidate_rows: list[dict]) -> list[dict]: 'trigger_tf': first.get('trigger_timeframe'), }, 'liquidity_backtest_features': liquidity_backtest_features, + 'pd_array_backtest_features': pd_array_backtest_features, } ) units.append( @@ -982,6 +1034,8 @@ def build_plan_replay_units(candidate_rows: list[dict]) -> list[dict]: 'doctrine_backtest_cohort': doctrine_backtest_cohort, 'liquidity_backtest_features': liquidity_backtest_features, 'liquidity_kernel_status': liquidity_kernel_status, + 'pd_array_backtest_features': pd_array_backtest_features, + 'pd_array_reaction_status': pd_array_reaction_status, 'candidate_model_code': first.get('candidate_model_code'), 'side': first.get('side'), 'session_code': first.get('session_code'), @@ -1075,6 +1129,8 @@ def build_plan_replay_meta( 'doctrine_completeness': (unit.get('doctrine_backtest_cohort') or {}).get('doctrine_completeness'), 'liquidity_backtest_features': dict(unit.get('liquidity_backtest_features') or {}), 'liquidity_kernel_status': dict(unit.get('liquidity_kernel_status') or {}), + 'pd_array_backtest_features': dict(unit.get('pd_array_backtest_features') or {}), + 'pd_array_reaction_status': dict(unit.get('pd_array_reaction_status') or {}), 'plan_activated_ts': _normalize_meta_ts(unit.get('plan_activated_ts')), 'plan_created_ts': _normalize_meta_ts(unit.get('plan_created_ts')), 'candidate_id': unit.get('candidate_id'), @@ -1262,6 +1318,7 @@ def summarize_backtest_outcomes(outcomes: list[BacktestOutcome], subject_type: s grouped_by_timeframe: dict[str, dict] = {} grouped_by_doctrine_cohort: dict[str, dict] = {} grouped_by_liquidity_target_side: dict[str, dict] = {} + grouped_by_pd_array_type: dict[str, dict] = {} r_multiples: list[float] = [] cost_values: list[float] = [] mfe_r_values: list[float] = [] @@ -1274,6 +1331,8 @@ def summarize_backtest_outcomes(outcomes: list[BacktestOutcome], subject_type: s failure_reason_counts: dict[str, int] = {} liquidity_kernel_reason_counts: dict[str, int] = {} liquidity_kernel_status_counts: dict[str, int] = {} + pd_array_reaction_reason_counts: dict[str, int] = {} + pd_array_reaction_status_counts: dict[str, int] = {} plan_total_r: dict[int, float] = {} plan_seen: set[int] = set() @@ -1316,6 +1375,13 @@ def summarize_backtest_outcomes(outcomes: list[BacktestOutcome], subject_type: s liquidity_kernel_status_counts[liquidity_status_key] = liquidity_kernel_status_counts.get(liquidity_status_key, 0) + 1 for reason in list(liquidity_status.get('reasons') or []): liquidity_kernel_reason_counts[str(reason)] = liquidity_kernel_reason_counts.get(str(reason), 0) + 1 + pd_array_features = dict(outcome.meta.get('pd_array_backtest_features') or {}) + pd_array_type = str(pd_array_features.get('pd_array_type') or 'missing_pd_array') + pd_array_status = dict(outcome.meta.get('pd_array_reaction_status') or evaluate_pd_array_reaction_status(pd_array_features)) + pd_array_status_key = str(pd_array_status.get('status') or 'unknown') + pd_array_reaction_status_counts[pd_array_status_key] = pd_array_reaction_status_counts.get(pd_array_status_key, 0) + 1 + for reason in list(pd_array_status.get('reasons') or []): + pd_array_reaction_reason_counts[str(reason)] = pd_array_reaction_reason_counts.get(str(reason), 0) + 1 r_multiples.append(float(outcome.meta.get('r_multiple') or 0.0)) cost_values.append(float(outcome.meta.get('cost_r') or 0.0)) mfe_r_values.append(float(outcome.meta.get('mfe_r') or 0.0)) @@ -1327,6 +1393,7 @@ def summarize_backtest_outcomes(outcomes: list[BacktestOutcome], subject_type: s _accumulate_bucket(grouped_by_timeframe, timeframe_bucket, outcome) _accumulate_bucket(grouped_by_doctrine_cohort, doctrine_cohort_key, outcome) _accumulate_bucket(grouped_by_liquidity_target_side, liquidity_target_side, outcome) + _accumulate_bucket(grouped_by_pd_array_type, pd_array_type, outcome) for session_code in session_codes: _accumulate_bucket(grouped_by_session, str(session_code), outcome) @@ -1359,6 +1426,8 @@ def summarize_backtest_outcomes(outcomes: list[BacktestOutcome], subject_type: s 'failure_reasons': failure_reason_counts, 'liquidity_kernel_status_counts': liquidity_kernel_status_counts, 'liquidity_kernel_reason_counts': liquidity_kernel_reason_counts, + 'pd_array_reaction_status_counts': pd_array_reaction_status_counts, + 'pd_array_reaction_reason_counts': pd_array_reaction_reason_counts, 'avg_mfe': _average([outcome.mfe for outcome in outcomes]), 'avg_mae': _average([outcome.mae for outcome in outcomes]), 'avg_mfe_r': _average(mfe_r_values), @@ -1378,6 +1447,7 @@ def summarize_backtest_outcomes(outcomes: list[BacktestOutcome], subject_type: s 'by_timeframe': grouped_by_timeframe, 'by_doctrine_cohort': grouped_by_doctrine_cohort, 'by_liquidity_target_side': grouped_by_liquidity_target_side, + 'by_pd_array_type': grouped_by_pd_array_type, } result['session_performance'] = result['by_session'] return result diff --git a/src/services/ict_mainline/__init__.py b/src/services/ict_mainline/__init__.py index 162b33e..e76ffcd 100644 --- a/src/services/ict_mainline/__init__.py +++ b/src/services/ict_mainline/__init__.py @@ -21,35 +21,44 @@ from src.services.ict_mainline.doctrine import ( DOCTRINE_KERNEL_PHASE, DOCTRINE_LIQUIDITY_PHASE, DOCTRINE_PLAYBOOK_PHASE, + DOCTRINE_REACTION_PHASE, build_doctrine_backtest_cohort, build_liquidity_backtest_features, + build_pd_array_backtest_features, build_doctrine_rule_matrix, build_doctrine_playbook_gate, build_doctrine_playbook_registry, build_liquidity_kernel_registry, + build_pd_array_reaction_registry, evaluate_doctrine_research_status, evaluate_liquidity_kernel_status, + evaluate_pd_array_reaction_status, extract_setup_playbook_id, get_doctrine_setup_playbook, list_doctrine_setup_playbooks, list_doctrine_rules, list_liquidity_rules, + list_pd_array_rules, summarize_doctrine_backtest_cohorts, summarize_doctrine_rule_gaps, summarize_liquidity_kernel_statuses, + summarize_pd_array_reaction_statuses, ) __all__ = [ "DOCTRINE_KERNEL_PHASE", "DOCTRINE_LIQUIDITY_PHASE", "DOCTRINE_PLAYBOOK_PHASE", + "DOCTRINE_REACTION_PHASE", "ICT_MAINLINE_CHAIN", "build_doctrine_backtest_cohort", "build_liquidity_backtest_features", + "build_pd_array_backtest_features", "build_doctrine_rule_matrix", "build_doctrine_playbook_gate", "build_doctrine_playbook_registry", "build_liquidity_kernel_registry", + "build_pd_array_reaction_registry", "build_ict_rulebook_mapping", "build_ict_rulebook_summary", "build_backtest_quality_gate", @@ -61,15 +70,18 @@ __all__ = [ "classify_ict_concepts", "evaluate_doctrine_research_status", "evaluate_liquidity_kernel_status", + "evaluate_pd_array_reaction_status", "extract_setup_playbook_id", "get_ict_concept", "get_doctrine_setup_playbook", "list_doctrine_setup_playbooks", "list_doctrine_rules", "list_liquidity_rules", + "list_pd_array_rules", "list_ict_concepts", "summarize_doctrine_backtest_cohorts", "summarize_doctrine_rule_gaps", "summarize_liquidity_kernel_statuses", + "summarize_pd_array_reaction_statuses", "summarize_book_rule_errors", ] diff --git a/src/services/ict_mainline/doctrine.py b/src/services/ict_mainline/doctrine.py index e813023..9d766d5 100644 --- a/src/services/ict_mainline/doctrine.py +++ b/src/services/ict_mainline/doctrine.py @@ -9,6 +9,7 @@ from typing import Iterable DOCTRINE_KERNEL_PHASE = "D1_MARKET_LANGUAGE_KERNEL" DOCTRINE_PLAYBOOK_PHASE = "D2_PLAYBOOK_BACKTEST_COHORT" DOCTRINE_LIQUIDITY_PHASE = "D3_LIQUIDITY_KERNEL" +DOCTRINE_REACTION_PHASE = "D4_PD_ARRAY_REACTION_KERNEL" @dataclass(frozen=True) @@ -105,6 +106,34 @@ class LiquidityTargetRule: return payload +@dataclass(frozen=True) +class PdArrayReactionRule: + rule_id: str + stage: str + book_anchor: str + array_type: str + ranking_weight: int + required_inputs: tuple[str, ...] + measurable_fields: tuple[str, ...] + no_trade_when: tuple[str, ...] + wait_when: tuple[str, ...] + backtest_observable: str + operator_rule: str + aliases: tuple[str, ...] + + def to_public_dict(self) -> dict: + payload = asdict(self) + for key in ( + "required_inputs", + "measurable_fields", + "no_trade_when", + "wait_when", + "aliases", + ): + payload[key] = list(payload[key]) + return payload + + D1_DOCTRINE_RULES: tuple[DoctrineRule, ...] = ( DoctrineRule( rule_id="range_defined", @@ -256,8 +285,26 @@ D2_DOCTRINE_SETUP_PLAYBOOKS: tuple[DoctrineSetupPlaybook, ...] = ( "operator_rule": "Displacement and MSS must confirm the post-sweep direction before review.", }, pd_array_rule={ - "required_fields": ("pd_array_type", "pd_array_quality", "fvg_retrace_confirmed"), - "operator_rule": "FVG is usable only when it serves the range/draw/location chain.", + "required_fields": ( + "pd_array_type", + "pd_array_id", + "pd_array_role", + "pd_array_rank", + "array_location", + "array_timeframe", + "array_freshness", + "formation_event", + "displacement_origin", + "fvg_role", + "fvg_retrace_confirmed", + "reaction_quality", + "invalidation", + ), + "ranking_rule_id": "pd_array_relevance_matrix", + "operator_rule": ( + "FVG/OB/Breaker/Mitigation is a reaction-location layer only; it must serve the " + "Range/Draw/Location/Liquidity chain and show post-arrival reaction before review." + ), }, session_timing_rule={ "required_fields": ("session_code", "allowed_sessions", "path_context", "sequence_validity"), @@ -284,6 +331,10 @@ D2_DOCTRINE_SETUP_PLAYBOOKS: tuple[DoctrineSetupPlaybook, ...] = ( "missing_liquidity_pool", "sweep_is_not_setup", "manipulation_window_wait", + "missing_pd_array", + "missing_pd_array_role", + "pd_array_not_direction_source", + "fvg_is_not_setup", "doctrine_missing_time_path", "doctrine_missing_invalidation", "doctrine_missing_exit_architecture", @@ -320,6 +371,9 @@ D2_DOCTRINE_SETUP_PLAYBOOKS: tuple[DoctrineSetupPlaybook, ...] = ( "traded_sweep_as_setup", "traded_without_liquidity_target_rank", "traded_during_manipulation_phase", + "traded_fvg_as_direction", + "traded_without_pd_array_context", + "traded_unvalidated_order_block", "traded_without_time_or_path", "traded_without_invalidation", "no_exit_architecture", @@ -477,6 +531,122 @@ D3_LIQUIDITY_TARGET_RULES: tuple[LiquidityTargetRule, ...] = ( ) +D4_PD_ARRAY_REACTION_RULES: tuple[PdArrayReactionRule, ...] = ( + PdArrayReactionRule( + rule_id="pd_array_relevance_matrix", + stage="Array Relevance", + book_anchor="ICT交易全书 chapter 13: PD Array relevance depends on narrative, layer, time, and structure", + array_type="matrix", + ranking_weight=110, + required_inputs=("pd_array_type", "pd_array_role", "pd_array_rank", "array_location", "array_timeframe"), + measurable_fields=("pd_array_type", "pd_array_id", "pd_array_role", "pd_array_rank", "array_location", "array_timeframe", "array_freshness"), + no_trade_when=("missing_pd_array", "missing_pd_array_role", "pd_array_not_direction_source"), + wait_when=("pd_array_overload_wait", "array_quality_conflict_unresolved"), + backtest_observable="setup_without_ranked_relevant_pd_array", + operator_rule="Rank only a few relevant arrays; PD Array is a reaction location, not a direction source.", + aliases=( + "pd_array", + "pd_array_type", + "pd_array_role", + "pd_array_rank", + "array_location", + "array_timeframe", + "array_freshness", + ), + ), + PdArrayReactionRule( + rule_id="fvg_behavior_after_rebalance", + stage="FVG / Imbalance", + book_anchor="ICT交易全书 chapter 14: trade the behavior after imbalance, not the FVG label", + array_type="fvg", + ranking_weight=100, + required_inputs=("fvg_role", "fvg_retrace_confirmed", "fvg_fill_depth", "displacement_origin", "reaction_quality"), + measurable_fields=("fvg_role", "fvg_state", "fvg_retrace_confirmed", "fvg_fill_depth", "fvg_ce_reached", "displacement_origin", "reaction_quality"), + no_trade_when=("fvg_is_not_setup", "missing_fvg_role", "fvg_without_displacement_origin"), + wait_when=("awaiting_fvg_retrace_confirmation", "awaiting_reaction_quality"), + backtest_observable="fvg_label_without_retrace_reaction_context", + operator_rule="Define whether FVG is target or entry; first touch without reaction stays study-only.", + aliases=( + "fvg", + "fair_value_gap", + "imbalance", + "fair_valuation", + "fvg_role", + "fvg_retrace_confirmed", + "fvg_fill_depth", + "fvg_ce_reached", + ), + ), + PdArrayReactionRule( + rule_id="bpr_rebalancing_classifier", + stage="BPR / Rebalancing", + book_anchor="ICT交易全书 chapter 15: rebalancing is path state, not reversal promise", + array_type="bpr", + ranking_weight=80, + required_inputs=("bpr_id", "rebalancing_state", "rebalancing_depth", "trade_classification", "structure_response"), + measurable_fields=("bpr_id", "rebalancing_state", "rebalancing_depth", "trade_classification", "structure_response", "irl_erl_priority"), + no_trade_when=("rebalancing_without_structure_response", "bpr_without_imbalance_origin"), + wait_when=("awaiting_rebalance_response", "trade_classification_unresolved"), + backtest_observable="rebalancing_used_as_reversal_without_structure_response", + operator_rule="Classify internal repair versus external expansion before using BPR/Rebalancing.", + aliases=( + "bpr", + "balanced_price_range", + "rebalancing", + "return_to_fair_value", + "rebalancing_depth", + "trade_classification", + "structure_response", + ), + ), + PdArrayReactionRule( + rule_id="order_block_authorization", + stage="Order Block", + book_anchor="ICT交易全书 chapter 16: Order Block needs displacement, validation, structure, time, and draw", + array_type="order_block", + ranking_weight=90, + required_inputs=("order_block_role", "order_block_validated", "parent_ob_id", "displacement_origin", "fvg_ob_pairing"), + measurable_fields=("order_block_role", "order_block_validated", "parent_ob_id", "subordinate_ob_id", "fvg_ob_pairing", "ob_invalidation"), + no_trade_when=("order_block_not_authorized", "ob_without_displacement", "ob_without_draw"), + wait_when=("awaiting_ob_return_confirmation", "awaiting_parent_subordinate_alignment"), + backtest_observable="order_block_label_without_validation", + operator_rule="OB is authorized only when it serves current draw, has displacement/validation, and respects parent/subordinate alignment.", + aliases=( + "order_block", + "ob", + "parent_ob", + "subordinate_ob", + "order_block_role", + "order_block_validated", + "fvg_ob_pairing", + ), + ), + PdArrayReactionRule( + rule_id="breaker_mitigation_failure_reuse", + stage="Breaker / Mitigation", + book_anchor="ICT交易全书 chapter 17: Breaker/Mitigation reuses failed order flow only after objective failure", + array_type="breaker_mitigation", + ranking_weight=85, + required_inputs=("breaker_mitigation_state", "failure_precondition", "trapped_side", "mitigation_inventory_context", "reaction_quality"), + measurable_fields=("breaker_mitigation_state", "failure_precondition", "trapped_side", "mitigation_inventory_context", "breaker_retest_confirmed"), + no_trade_when=("breaker_without_failure_precondition", "mitigation_without_inventory_context"), + wait_when=("awaiting_breaker_retest_confirmation", "awaiting_mitigation_reaction"), + backtest_observable="breaker_or_mitigation_without_failure_precondition", + operator_rule="No objective failure, no Breaker/Mitigation semantics; wait for revisit reaction before review.", + aliases=( + "breaker", + "breaker_block", + "mitigation", + "mitigation_block", + "breaker_mitigation_state", + "failure_precondition", + "trapped_side", + "mitigation_inventory_context", + ), + ), +) + + def list_doctrine_rules() -> list[dict]: return [rule.to_public_dict() for rule in D1_DOCTRINE_RULES] @@ -519,6 +689,10 @@ def list_liquidity_rules() -> list[dict]: return [rule.to_public_dict() for rule in D3_LIQUIDITY_TARGET_RULES] +def list_pd_array_rules() -> list[dict]: + return [rule.to_public_dict() for rule in D4_PD_ARRAY_REACTION_RULES] + + def build_liquidity_kernel_registry() -> dict: return { "phase": DOCTRINE_LIQUIDITY_PHASE, @@ -542,6 +716,29 @@ def build_liquidity_kernel_registry() -> dict: } +def build_pd_array_reaction_registry() -> dict: + return { + "phase": DOCTRINE_REACTION_PHASE, + "source": "docs/ICT浜ゆ槗鍏ㄤ功_鍑虹増缁堟牎鐗?md chapters 13-17", + "status": "active", + "rule_count": len(D4_PD_ARRAY_REACTION_RULES), + "hard_rules": [ + "PD Array is reaction-location quality, not a direction source.", + "FVG is not a trade; only behavior after imbalance can become evidence.", + "BPR / Rebalancing is path state, not a reversal promise.", + "Order Block requires displacement, validation, structure, time, and draw context.", + "Breaker / Mitigation requires an objective failure premise before reuse.", + ], + "required_backtest_feature_fields": _dedupe( + field + for rule in D4_PD_ARRAY_REACTION_RULES + for field in rule.measurable_fields + ), + "rules": list_pd_array_rules(), + "execution_boundary": "read_only_analysis_no_order_submission", + } + + def get_doctrine_setup_playbook(setup_playbook_id: str | None) -> dict | None: key = str(setup_playbook_id or "").strip() if not key: @@ -626,6 +823,7 @@ def build_doctrine_backtest_cohort(payload: object, *, quality_gate: dict | None setup_playbook_id = extract_setup_playbook_id(payload) playbook_gate = build_doctrine_playbook_gate(payload) liquidity_status = evaluate_liquidity_kernel_status(payload) + pd_array_status = evaluate_pd_array_reaction_status(payload) quality = dict(quality_gate or _extract_quality_gate(payload) or {}) quality_reasons = [str(item) for item in list(quality.get("reasons") or []) if item] quality_passed = bool(quality.get("passed")) @@ -639,6 +837,7 @@ def build_doctrine_backtest_cohort(payload: object, *, quality_gate: dict | None promotion_reasons = [ *[str(item) for item in list(playbook_gate.get("reasons") or []) if item], *([] if liquidity_status.get("passed") else [str(item) for item in list(liquidity_status.get("reasons") or []) if item]), + *([] if pd_array_status.get("passed") else [str(item) for item in list(pd_array_status.get("reasons") or []) if item]), *([] if quality_passed else quality_reasons), ] promotion_reasons = _dedupe(promotion_reasons) @@ -654,11 +853,14 @@ def build_doctrine_backtest_cohort(payload: object, *, quality_gate: dict | None "playbook_gate": playbook_gate, "liquidity_kernel_status": liquidity_status, "liquidity_backtest_features": dict(liquidity_status.get("features") or {}), + "pd_array_reaction_status": pd_array_status, + "pd_array_backtest_features": dict(pd_array_status.get("features") or {}), "promotion_gate": { "passed": not promotion_reasons, "reasons": promotion_reasons, "requires_playbook_schema_complete": True, "requires_liquidity_kernel_complete": True, + "requires_pd_array_reaction_complete": True, "requires_backtest_quality_gate": True, "requires_active_trading_plan": True, "execution_boundary": "read_only_analysis_no_order_submission", @@ -688,6 +890,7 @@ def summarize_doctrine_backtest_cohorts(items: Iterable[dict]) -> dict: "promotable_count": 0, "promotion_blocker_counts": {}, "liquidity_status_counts": {}, + "pd_array_status_counts": {}, }, ) bucket["run_count"] += 1 @@ -697,6 +900,8 @@ def summarize_doctrine_backtest_cohorts(items: Iterable[dict]) -> dict: bucket["promotable_count"] += 1 liquidity_status = str((cohort.get("liquidity_kernel_status") or {}).get("status") or "unknown") bucket["liquidity_status_counts"][liquidity_status] = int(bucket["liquidity_status_counts"].get(liquidity_status) or 0) + 1 + pd_array_status = str((cohort.get("pd_array_reaction_status") or {}).get("status") or "unknown") + bucket["pd_array_status_counts"][pd_array_status] = int(bucket["pd_array_status_counts"].get(pd_array_status) or 0) + 1 for reason in list((cohort.get("promotion_gate") or {}).get("reasons") or []): reason_key = str(reason) bucket["promotion_blocker_counts"][reason_key] = int(bucket["promotion_blocker_counts"].get(reason_key) or 0) + 1 @@ -852,6 +1057,231 @@ def summarize_liquidity_kernel_statuses(items: Iterable[dict]) -> dict: } +def build_pd_array_backtest_features(payload: object) -> dict: + features = { + "pd_array_type": _string_or_empty( + _extract_first_present_value(payload, keys=("pd_array_type", "array_type", "pd_array_kind", "imbalance_type")) + ), + "pd_array_id": _string_or_empty( + _extract_first_present_value(payload, keys=("pd_array_id", "array_id", "fvg_id", "order_block_id", "breaker_id", "mitigation_id", "bpr_id")) + ), + "pd_array_role": _string_or_empty( + _extract_first_present_value(payload, keys=("pd_array_role", "array_role", "reaction_role", "entry_role")) + ), + "pd_array_rank": _string_or_empty( + _extract_first_present_value(payload, keys=("pd_array_rank", "array_rank", "reaction_rank", "pd_array_quality_rank")) + ), + "pd_array_quality_score": _extract_first_present_value(payload, keys=("pd_array_quality_score", "pd_array_quality", "array_quality_score")), + "array_location": _string_or_empty( + _extract_first_present_value(payload, keys=("array_location", "pd_array_location", "premium_discount_zone", "location")) + ), + "array_timeframe": _string_or_empty( + _extract_first_present_value(payload, keys=("array_timeframe", "pd_array_timeframe", "setup_tf", "setup_timeframe")) + ), + "array_freshness": _string_or_empty( + _extract_first_present_value(payload, keys=("array_freshness", "pd_array_freshness", "fvg_freshness", "ob_freshness")) + ), + "formation_event": _string_or_empty( + _extract_first_present_value(payload, keys=("formation_event", "array_formation_event", "origin_event")) + ), + "displacement_origin": _string_or_empty( + _extract_first_present_value(payload, keys=("displacement_origin", "related_displacement_id", "displacement_event_id", "imbalance_origin")) + ), + "reaction_quality": _string_or_empty( + _extract_first_present_value(payload, keys=("reaction_quality", "post_arrival_reaction", "post_retest_reaction", "rejection_quality")) + ), + "retest_confirmed": _extract_first_present_value(payload, keys=("retest_confirmed", "array_retest_confirmed", "breaker_retest_confirmed")), + "fvg_role": _string_or_empty(_extract_first_present_value(payload, keys=("fvg_role", "fvg_usage", "imbalance_role"))), + "fvg_state": _string_or_empty(_extract_first_present_value(payload, keys=("fvg_state", "fvg_status", "imbalance_state"))), + "fvg_retrace_confirmed": _extract_first_present_value(payload, keys=("fvg_retrace_confirmed", "fvg_retest_confirmed")), + "fvg_fill_depth": _extract_first_present_value(payload, keys=("fvg_fill_depth", "fvg_fill_pct", "fvg_fill_percent")), + "fvg_ce_reached": _extract_first_present_value(payload, keys=("fvg_ce_reached", "ce_reached")), + "bpr_id": _string_or_empty(_extract_first_present_value(payload, keys=("bpr_id", "balanced_price_range_id", "rebalancing_id"))), + "rebalancing_state": _string_or_empty(_extract_first_present_value(payload, keys=("rebalancing_state", "rebalance_state", "bpr_state"))), + "rebalancing_depth": _extract_first_present_value(payload, keys=("rebalancing_depth", "return_depth", "bpr_depth")), + "structure_response": _string_or_empty(_extract_first_present_value(payload, keys=("structure_response", "mss_state", "market_structure_response"))), + "trade_classification": _string_or_empty( + _extract_first_present_value(payload, keys=("trade_classification", "repair_or_expansion", "internal_external_classification")) + ), + "order_block_role": _string_or_empty(_extract_first_present_value(payload, keys=("order_block_role", "ob_role", "order_block_type"))), + "order_block_validated": _extract_first_present_value(payload, keys=("order_block_validated", "ob_validated", "ob_validation_state")), + "parent_ob_id": _string_or_empty(_extract_first_present_value(payload, keys=("parent_ob_id", "parent_order_block_id"))), + "subordinate_ob_id": _string_or_empty(_extract_first_present_value(payload, keys=("subordinate_ob_id", "child_ob_id"))), + "fvg_ob_pairing": _string_or_empty(_extract_first_present_value(payload, keys=("fvg_ob_pairing", "paired_fvg_id", "ob_fvg_pairing"))), + "breaker_mitigation_state": _string_or_empty( + _extract_first_present_value(payload, keys=("breaker_mitigation_state", "breaker_state", "mitigation_state")) + ), + "failure_precondition": _string_or_empty(_extract_first_present_value(payload, keys=("failure_precondition", "failed_order_flow", "objective_failure"))), + "trapped_side": _string_or_empty(_extract_first_present_value(payload, keys=("trapped_side", "trapped_traders_side"))), + "mitigation_inventory_context": _string_or_empty( + _extract_first_present_value(payload, keys=("mitigation_inventory_context", "inventory_context")) + ), + "pd_array_invalidation": _string_or_empty( + _extract_first_present_value(payload, keys=("pd_array_invalidation", "array_invalidation", "ob_invalidation", "invalidation")) + ), + } + lower_type = str(features["pd_array_type"] or "").lower() + features["has_pd_array"] = bool( + features["pd_array_id"] + or features["pd_array_type"] + or features["fvg_role"] + or features["fvg_state"] + or features["fvg_retrace_confirmed"] is not None + or features["bpr_id"] + or features["rebalancing_state"] + or features["order_block_role"] + or features["parent_ob_id"] + or features["breaker_mitigation_state"] + or features["failure_precondition"] + or features["mitigation_inventory_context"] + ) + features["is_fvg_like"] = bool( + any(token in lower_type for token in ("fvg", "fair_value_gap", "imbalance")) + or features["fvg_role"] + or features["fvg_state"] + or features["fvg_retrace_confirmed"] is not None + ) + features["is_bpr_like"] = bool( + any(token in lower_type for token in ("bpr", "rebalancing", "balanced_price_range")) + or features["bpr_id"] + or features["rebalancing_state"] + ) + features["is_order_block_like"] = bool( + any(token in lower_type for token in ("order_block", "ob")) + or features["order_block_role"] + or features["parent_ob_id"] + ) + features["is_breaker_mitigation_like"] = bool( + any(token in lower_type for token in ("breaker", "mitigation")) + or features["breaker_mitigation_state"] + or features["failure_precondition"] + or features["mitigation_inventory_context"] + ) + return features + + +def evaluate_pd_array_reaction_status(payload: object) -> dict: + features = build_pd_array_backtest_features(payload) + no_trade_reasons: list[str] = [] + wait_reasons: list[str] = [] + if not features["has_pd_array"]: + no_trade_reasons.append("missing_pd_array") + if features["has_pd_array"] and not features["pd_array_role"]: + no_trade_reasons.append("missing_pd_array_role") + if features["has_pd_array"] and not features["array_location"]: + no_trade_reasons.append("missing_array_location") + if features["has_pd_array"] and not features["pd_array_rank"]: + wait_reasons.append("unranked_pd_array") + if features["has_pd_array"] and not features["array_timeframe"]: + wait_reasons.append("array_timeframe_unresolved") + if features["has_pd_array"] and not features["array_freshness"]: + wait_reasons.append("array_freshness_unresolved") + if _looks_like_direction_source(features): + no_trade_reasons.append("pd_array_not_direction_source") + + if features["is_fvg_like"]: + if not features["fvg_role"]: + no_trade_reasons.append("missing_fvg_role") + if not features["displacement_origin"]: + no_trade_reasons.append("fvg_without_displacement_origin") + if not _truthy_feature(features["fvg_retrace_confirmed"]): + wait_reasons.append("awaiting_fvg_retrace_confirmation") + if not features["reaction_quality"] and not _truthy_feature(features["retest_confirmed"]): + wait_reasons.append("awaiting_reaction_quality") + if any(reason in no_trade_reasons or reason in wait_reasons for reason in ( + "missing_fvg_role", + "fvg_without_displacement_origin", + "awaiting_fvg_retrace_confirmation", + "awaiting_reaction_quality", + )): + no_trade_reasons.append("fvg_is_not_setup") + + if features["is_bpr_like"]: + if not features["structure_response"]: + no_trade_reasons.append("rebalancing_without_structure_response") + if not features["displacement_origin"]: + no_trade_reasons.append("bpr_without_imbalance_origin") + if not features["trade_classification"]: + wait_reasons.append("trade_classification_unresolved") + + if features["is_order_block_like"]: + if not _truthy_feature(features["order_block_validated"]): + no_trade_reasons.append("order_block_not_authorized") + if not features["displacement_origin"]: + no_trade_reasons.append("ob_without_displacement") + if not features["parent_ob_id"] and not features["subordinate_ob_id"]: + wait_reasons.append("awaiting_parent_subordinate_alignment") + + if features["is_breaker_mitigation_like"]: + if not features["failure_precondition"]: + no_trade_reasons.append("breaker_without_failure_precondition") + if "mitigation" in str(features["breaker_mitigation_state"] or "").lower() and not features["mitigation_inventory_context"]: + no_trade_reasons.append("mitigation_without_inventory_context") + if not _truthy_feature(features["retest_confirmed"]) and not features["reaction_quality"]: + wait_reasons.append("awaiting_breaker_retest_confirmation") + + reasons = _dedupe([*no_trade_reasons, *wait_reasons]) + passed = not reasons + if passed: + status = "pd_array_mapped" + elif no_trade_reasons: + status = "study_only" + else: + status = "wait" + return { + "phase": DOCTRINE_REACTION_PHASE, + "status": status, + "passed": passed, + "reason": "" if passed else reasons[0], + "reasons": reasons, + "no_trade_reasons": _dedupe(no_trade_reasons), + "wait_reasons": _dedupe(wait_reasons), + "features": features, + "can_promote_to_playbook": passed, + "operator_summary": ( + "PD Array reaction location, role, rank, freshness, reaction, and invalidation are mapped." + if passed + else "Treat FVG/OB/Breaker/Mitigation as study-only/wait until the reaction location is ranked and behavior after arrival is explicit." + ), + "execution_boundary": "read_only_analysis_no_order_submission", + } + + +def summarize_pd_array_reaction_statuses(items: Iterable[dict]) -> dict: + reason_counts: dict[str, int] = {} + status_counts: dict[str, int] = {} + array_type_counts: dict[str, int] = {} + total = 0 + passed_count = 0 + for item in items: + total += 1 + status = dict(item.get("pd_array_reaction_status") or evaluate_pd_array_reaction_status(item)) + status_key = str(status.get("status") or "unknown") + status_counts[status_key] = int(status_counts.get(status_key) or 0) + 1 + if status.get("passed"): + passed_count += 1 + features = dict(status.get("features") or {}) + array_type = str(features.get("pd_array_type") or "missing_pd_array") + array_type_counts[array_type] = int(array_type_counts.get(array_type) or 0) + 1 + for reason in list(status.get("reasons") or []): + key = str(reason) + reason_counts[key] = int(reason_counts.get(key) or 0) + 1 + return { + "phase": DOCTRINE_REACTION_PHASE, + "item_count": total, + "passed_count": passed_count, + "blocked_count": max(0, total - passed_count), + "status_counts": status_counts, + "reason_counts": reason_counts, + "array_type_counts": array_type_counts, + "operator_summary": ( + "No backtest items available for PD Array Reaction review." + if not total + else "Review PD Array reaction blockers before treating any FVG/OB/Breaker/Mitigation as usable evidence." + ), + } + + def evaluate_doctrine_research_status( payload: object, *, @@ -1082,3 +1512,22 @@ def _has_liquidity_action(features: dict) -> bool: def _looks_like_equal_priority(features: dict) -> bool: text = " ".join(str(features.get(key) or "").lower() for key in ("working_draw", "liquidity_pool_rank", "reorder_trigger_type")) return any(token in text for token in ("equal_priority", "two_sided_equal", "tied", "both_sides_equal")) + + +def _truthy_feature(value: object) -> bool: + if isinstance(value, bool): + return value + if value is None: + return False + text = str(value).strip().lower() + if text in {"", "0", "false", "no", "none", "null", "missing", "unconfirmed", "not_confirmed"}: + return False + return text in {"1", "true", "yes", "confirmed", "valid", "validated", "accepted", "retest_confirmed", "reaction_confirmed"} + + +def _looks_like_direction_source(features: dict) -> bool: + role_text = str(features.get("pd_array_role") or "").lower() + type_text = str(features.get("pd_array_type") or "").lower() + return any(token in role_text for token in ("direction", "bias", "signal", "buy_sell")) or any( + token in type_text for token in ("direction_source", "signal_source") + ) diff --git a/src/web/app.js b/src/web/app.js index 6f7269e..c77a0cd 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -5539,6 +5539,17 @@ function renderStrategyBacktests(payload) { doctrineCohort.liquidity_backtest_features || liquidityStatus.features || {}; + const pdArrayStatus = + latest.pd_array_reaction_status || + summary.pd_array_reaction_status || + doctrineCohort.pd_array_reaction_status || + {}; + const pdArrayFeatures = + latest.pd_array_backtest_features || + summary.pd_array_backtest_features || + doctrineCohort.pd_array_backtest_features || + pdArrayStatus.features || + {}; strategyBacktestPanel.appendChild( buildInfoGrid([ @@ -5553,6 +5564,10 @@ function renderStrategyBacktests(payload) { ['Liquidity Reason', formatBacktestDecision(liquidityStatus.reason || 'missing_liquidity_target')], ['Liquidity Target', liquidityFeatures.liquidity_target_side || 'missing_liquidity_target'], ['Liquidity Pool', liquidityFeatures.liquidity_pool_id || 'missing_liquidity_pool'], + ['PD Array Kernel', formatBacktestDecision(pdArrayStatus.status || 'study_only')], + ['PD Array Reason', formatBacktestDecision(pdArrayStatus.reason || 'missing_pd_array')], + ['PD Array', pdArrayFeatures.pd_array_type || 'missing_pd_array'], + ['PD Array Role', pdArrayFeatures.pd_array_role || 'missing_pd_array_role'], ['Promotion Gate', doctrinePromotionGate.passed ? 'Passed' : 'Blocked'], ['Instrument ID', latest.instrument_id ?? 'n/a'], ['Timeframe', formatJsonInline(latest.timeframe_set || {})], @@ -5588,6 +5603,13 @@ function renderStrategyBacktests(payload) { formatList((liquidityStatus.reasons || []).map(formatBacktestDecision), 'Liquidity chain mapped') ) ); + cards.appendChild( + buildMetricCard( + 'PD Array Kernel', + pdArrayStatus.passed ? 'Mapped' : 'Blocked', + formatList((pdArrayStatus.reasons || []).map(formatBacktestDecision), 'PD Array chain mapped') + ) + ); strategyBacktestPanel.appendChild(cards); strategyBacktestPanel.appendChild(buildBacktestCurve(summary.cumulative_r_curve || [])); @@ -5629,6 +5651,15 @@ function renderStrategyBacktests(payload) { .map((item) => [formatBacktestDecision(item.key), String(item.value)]) ) ); + detailGrid.appendChild( + buildItemCard( + 'PD Array Kernel', + `${payload?.pd_array_reaction_summary?.passed_count ?? 0} mapped / ${payload?.pd_array_reaction_summary?.blocked_count ?? 0} blocked`, + topCountEntries(payload?.pd_array_reaction_summary?.reason_counts || summary.pd_array_reaction_reason_counts || {}) + .slice(0, 5) + .map((item) => [formatBacktestDecision(item.key), String(item.value)]) + ) + ); detailGrid.appendChild( buildItemCard( 'Recent Backtest Runs', diff --git a/src/web/i18n.js b/src/web/i18n.js index 7e8ca8e..e48650f 100644 --- a/src/web/i18n.js +++ b/src/web/i18n.js @@ -82,6 +82,11 @@ 'Liquidity Target': '流动性目标', 'Liquidity Pool': '流动性池', 'Liquidity chain mapped': '流动性链路已映射', + 'PD Array Kernel': 'PD Array 反应内核', + 'PD Array Reason': 'PD Array 原因', + 'PD Array': 'PD Array 位置', + 'PD Array Role': 'PD Array 角色', + 'PD Array chain mapped': 'PD Array 反应链路已映射', 'Mapped': '已映射', 'mapped': '已映射', 'Eligible For Trading Plan Review': '可进入交易计划复核', @@ -762,6 +767,29 @@ ['liquidity_targets_tied_wait', '两侧流动性同权等待'], ['void_without_trigger_pool', 'Void 缺少触发池'], ['reorder_trigger_pending', '等待目标重排证据'], + ['pd_array_mapped', 'PD Array 反应位已映射'], + ['pd_array_reaction_blocked', 'PD Array 反应内核阻塞'], + ['missing_pd_array', '缺少 PD Array 反应位'], + ['missing_pd_array_role', '缺少 PD Array 角色'], + ['missing_array_location', '缺少 PD Array 所在位置'], + ['unranked_pd_array', 'PD Array 未排序'], + ['array_timeframe_unresolved', 'PD Array 周期未明确'], + ['array_freshness_unresolved', 'PD Array 新鲜度未明确'], + ['pd_array_not_direction_source', 'PD Array 不能当方向来源'], + ['missing_fvg_role', '缺少 FVG 角色'], + ['fvg_without_displacement_origin', 'FVG 缺少位移来源'], + ['fvg_is_not_setup', 'FVG 标签不是完整 setup'], + ['awaiting_fvg_retrace_confirmation', '等待 FVG 回撤确认'], + ['awaiting_reaction_quality', '等待到位后的反应质量'], + ['rebalancing_without_structure_response', 'Rebalancing 缺少结构响应'], + ['bpr_without_imbalance_origin', 'BPR 缺少 imbalance 来源'], + ['trade_classification_unresolved', '内部修复/外部扩展未分类'], + ['order_block_not_authorized', 'Order Block 未被授权'], + ['ob_without_displacement', 'Order Block 缺少位移来源'], + ['awaiting_parent_subordinate_alignment', '等待父/子 OB 对齐'], + ['breaker_without_failure_precondition', 'Breaker 缺少失败前提'], + ['mitigation_without_inventory_context', 'Mitigation 缺少库存上下文'], + ['awaiting_breaker_retest_confirmation', '等待 Breaker 回踩确认'], ['missing_playbook', '缺少 playbook'], ['Doctrine Cohort', 'Doctrine 回测分组'], ['Doctrine Cohorts', 'Doctrine 回测分组'], diff --git a/tests/unit/test_api_and_main.py b/tests/unit/test_api_and_main.py index d5522d9..4ea1a5e 100644 --- a/tests/unit/test_api_and_main.py +++ b/tests/unit/test_api_and_main.py @@ -95,6 +95,8 @@ class ApiTests(unittest.TestCase): self.assertEqual(result[0]["doctrine_research_status"]["research_only_reason"], "missing_doctrine_setup_playbook") self.assertEqual(result[0]["liquidity_kernel_status"]["status"], "study_only") self.assertIn("missing_liquidity_target", result[0]["liquidity_kernel_status"]["reasons"]) + self.assertEqual(result[0]["pd_array_reaction_status"]["status"], "study_only") + self.assertIn("missing_pd_array", result[0]["pd_array_reaction_status"]["reasons"]) def test_normalize_backtest_summary_row_preserves_existing_quality_gate(self) -> None: row = {"id": 10, "summary": {"total_signals": 3, "quality_gate": {"passed": True}, "by_symbol": {"BTC-USDT": {"count": 3}}}} @@ -126,6 +128,8 @@ class ApiTests(unittest.TestCase): self.assertEqual(result["doctrine_rule_gap_summary"]["research_only_count"], 1) self.assertEqual(result["liquidity_kernel_summary"]["item_count"], 1) self.assertIn("missing_liquidity_target", result["liquidity_kernel_summary"]["reason_counts"]) + self.assertEqual(result["pd_array_reaction_summary"]["item_count"], 1) + self.assertIn("missing_pd_array", result["pd_array_reaction_summary"]["reason_counts"]) def test_route_request_trading_doctrine_rules_returns_d1_matrix(self) -> None: status, payload = route_request( @@ -170,6 +174,22 @@ class ApiTests(unittest.TestCase): self.assertEqual(payload["phase"], "D3_LIQUIDITY_KERNEL") self.assertEqual(payload["rules"][0]["rule_id"], "stop_run_sweep_raid_validated") + def test_route_request_trading_pd_array_kernel_returns_d4_registry(self) -> None: + status, payload = route_request( + "/trading/pd-array-kernel", + {}, + dependencies={ + "trading_pd_array_kernel": lambda: { + "phase": "D4_PD_ARRAY_REACTION_KERNEL", + "rules": [{"rule_id": "pd_array_relevance_matrix"}], + } + }, + ) + + self.assertEqual(status, 200) + self.assertEqual(payload["phase"], "D4_PD_ARRAY_REACTION_KERNEL") + self.assertEqual(payload["rules"][0]["rule_id"], "pd_array_relevance_matrix") + def test_route_request_trading_candidates_returns_items_payload(self) -> None: status, payload = route_request( "/trading/candidates", diff --git a/tests/unit/test_api_server.py b/tests/unit/test_api_server.py index 6de4b44..a95983b 100644 --- a/tests/unit/test_api_server.py +++ b/tests/unit/test_api_server.py @@ -134,6 +134,22 @@ class ApiServerRoutingTests(unittest.TestCase): self.assertEqual(payload["phase"], "D3_LIQUIDITY_KERNEL") self.assertEqual(payload["rule_count"], 6) + def test_routes_trading_pd_array_kernel(self) -> None: + status, payload = route_request( + "/trading/pd-array-kernel", + {}, + dependencies={ + "trading_pd_array_kernel": lambda: { + "phase": "D4_PD_ARRAY_REACTION_KERNEL", + "rule_count": 5, + } + }, + ) + + self.assertEqual(status, 200) + self.assertEqual(payload["phase"], "D4_PD_ARRAY_REACTION_KERNEL") + self.assertEqual(payload["rule_count"], 5) + def test_routes_trading_alert_quality_summary(self) -> None: status, payload = route_request( "/trading/alerts/quality-summary", diff --git a/tests/unit/test_backtest_service.py b/tests/unit/test_backtest_service.py index 508f589..48df6e2 100644 --- a/tests/unit/test_backtest_service.py +++ b/tests/unit/test_backtest_service.py @@ -318,6 +318,9 @@ class BacktestOutcomeTests(unittest.TestCase): self.assertAlmostEqual(summary["by_session"]["LONDON"]["avg_mae_r"], 2.7 / 3) self.assertAlmostEqual(summary["by_session"]["LONDON"]["avg_cost_r"], 0.1) self.assertAlmostEqual(summary["by_session"]["LONDON"]["max_drawdown_r"], 2.2) + self.assertEqual(summary["pd_array_reaction_status_counts"]["study_only"], 4) + self.assertEqual(summary["pd_array_reaction_reason_counts"]["missing_pd_array"], 4) + self.assertEqual(summary["by_pd_array_type"]["missing_pd_array"]["count"], 4) self.assertEqual(summary["by_session"]["LONDON"]["max_consecutive_losses"], 2) self.assertEqual(summary["by_session"]["LONDON"]["executed"], 3) self.assertAlmostEqual(summary["by_session"]["LONDON"]["executed_expectancy_r"], 0.2 / 3) @@ -435,6 +438,20 @@ class BacktestOutcomeTests(unittest.TestCase): "swept_level": 103.0, "sweep_confirmed": True, "post_take_reaction": "acceptance_displacement", + "pd_array_type": "fvg", + "pd_array_id": "fvg-1", + "pd_array_role": "entry", + "pd_array_rank": "primary", + "array_location": "discount", + "array_timeframe": "5m", + "array_freshness": "fresh", + "formation_event": "post_sweep_displacement", + "displacement_origin": "post_sweep_displacement", + "fvg_role": "entry", + "fvg_retrace_confirmed": True, + "reaction_quality": "rejection_confirmed", + "retest_confirmed": True, + "pd_array_invalidation": "fvg_accepted_other_side", "entry_low": 100.0, "entry_high": 100.2, "stop_loss": 99.0, @@ -468,6 +485,8 @@ class BacktestOutcomeTests(unittest.TestCase): self.assertEqual(units[0]["target_plan"]["tp2"]["objective_layer"], "ERL") self.assertEqual(units[0]["liquidity_backtest_features"]["liquidity_target_side"], "buy_side_liquidity") self.assertEqual(units[0]["liquidity_kernel_status"]["status"], "liquidity_mapped") + self.assertEqual(units[0]["pd_array_backtest_features"]["pd_array_type"], "fvg") + self.assertEqual(units[0]["pd_array_reaction_status"]["status"], "pd_array_mapped") class BacktestServiceRunTests(unittest.TestCase): @@ -736,6 +755,20 @@ class BacktestServiceRunTests(unittest.TestCase): "expires_ts": datetime(2026, 5, 6, 4, 0, tzinfo=timezone.utc), "confirmation_state": {"executable": True}, "missing_confirmations": [], + "pd_array_type": "fvg", + "pd_array_id": "fvg-12", + "pd_array_role": "entry", + "pd_array_rank": "primary", + "array_location": "discount", + "array_timeframe": "5m", + "array_freshness": "fresh", + "formation_event": "post_sweep_displacement", + "displacement_origin": "post_sweep_displacement", + "fvg_role": "entry", + "fvg_retrace_confirmed": True, + "reaction_quality": "rejection_confirmed", + "retest_confirmed": True, + "pd_array_invalidation": "fvg_accepted_other_side", "entry": { "entry_low": 100.0, "entry_high": 100.2, @@ -807,6 +840,8 @@ class BacktestServiceRunTests(unittest.TestCase): repository.summary["by_doctrine_cohort"]["ICT-CH1-6-LONDON-DISCOUNT-REVERSAL::complete"]["count"], 1, ) + self.assertEqual(repository.summary["pd_array_reaction_status"]["status"], "pd_array_mapped") + self.assertEqual(repository.summary["by_pd_array_type"]["fvg"]["count"], 1) def test_normalize_timeframe_set_and_bucket_helpers(self) -> None: normalized = normalize_timeframe_set({"bias_tf": "1h", "setup_tf": "15m"}) diff --git a/tests/unit/test_documentation_consistency.py b/tests/unit/test_documentation_consistency.py index 5547b59..ffe1924 100644 --- a/tests/unit/test_documentation_consistency.py +++ b/tests/unit/test_documentation_consistency.py @@ -21,6 +21,7 @@ ACTIVE_TOP_LEVEL_DOCS = { "AI_ICT_DoctrineKernel市场语言规则内核_D1_2026-05-21_v1.md", "AI_ICT_DoctrineKernel_PlaybookBacktestCohort_D2_2026-05-21_v1.md", "AI_ICT_DoctrineKernel_LiquidityKernel_D3_2026-05-21_v1.md", + "AI_ICT_DoctrineKernel_PDArrayReactionKernel_D4_2026-05-22_v1.md", "AI_ICT_硬性主线约束_必须遵循ICT交易全书_2026-05-21_v1.md", "AI_ICT_实盘辅助分析决策助手ICT主线开发方案_2026-05-21_v1.md", "AI_ICT_主线裁剪审计_S1_2026-05-21_v1.md", diff --git a/tests/unit/test_ict_doctrine.py b/tests/unit/test_ict_doctrine.py index 8bb4fbc..bdced11 100644 --- a/tests/unit/test_ict_doctrine.py +++ b/tests/unit/test_ict_doctrine.py @@ -4,15 +4,20 @@ from src.services.ict_mainline import ( DOCTRINE_KERNEL_PHASE, DOCTRINE_LIQUIDITY_PHASE, DOCTRINE_PLAYBOOK_PHASE, + DOCTRINE_REACTION_PHASE, build_doctrine_backtest_cohort, build_liquidity_backtest_features, + build_pd_array_backtest_features, build_doctrine_playbook_gate, build_doctrine_playbook_registry, build_doctrine_rule_matrix, build_liquidity_kernel_registry, + build_pd_array_reaction_registry, evaluate_doctrine_research_status, evaluate_liquidity_kernel_status, + evaluate_pd_array_reaction_status, summarize_liquidity_kernel_statuses, + summarize_pd_array_reaction_statuses, summarize_doctrine_rule_gaps, ) @@ -41,6 +46,8 @@ class IctDoctrineKernelTests(unittest.TestCase): self.assertIn("target_rule", playbook) self.assertIn("liquidity_target_side", playbook["liquidity_event_rule"]["required_fields"]) self.assertIn("post_take_reaction", playbook["liquidity_event_rule"]["required_fields"]) + self.assertIn("pd_array_role", playbook["pd_array_rule"]["required_fields"]) + self.assertIn("reaction_quality", playbook["pd_array_rule"]["required_fields"]) self.assertTrue(playbook["promotion_gate"]["requires_backtest_quality_gate"]) def test_d3_liquidity_kernel_registry_exposes_target_ranking_rules(self) -> None: @@ -112,6 +119,50 @@ class IctDoctrineKernelTests(unittest.TestCase): self.assertGreater(summary["reason_counts"]["sweep_is_not_setup"], 0) self.assertEqual(summary["target_side_counts"]["buy_side_liquidity"], 1) + def test_d4_pd_array_reaction_registry_exposes_location_quality_rules(self) -> None: + registry = build_pd_array_reaction_registry() + + self.assertEqual(registry["phase"], DOCTRINE_REACTION_PHASE) + self.assertEqual(registry["status"], "active") + self.assertGreaterEqual(registry["rule_count"], 5) + self.assertIn("pd_array_role", registry["required_backtest_feature_fields"]) + self.assertIn("PD Array is reaction-location quality, not a direction source.", registry["hard_rules"]) + + def test_fvg_label_without_reaction_context_stays_study_only(self) -> None: + status = evaluate_pd_array_reaction_status( + { + "pd_array_type": "fvg", + "fvg_retrace_confirmed": True, + } + ) + + self.assertFalse(status["passed"]) + self.assertEqual(status["phase"], DOCTRINE_REACTION_PHASE) + self.assertEqual(status["status"], "study_only") + self.assertIn("missing_pd_array_role", status["reasons"]) + self.assertIn("missing_array_location", status["reasons"]) + self.assertIn("fvg_is_not_setup", status["reasons"]) + + def test_complete_pd_array_reaction_payload_maps_without_execution_permission(self) -> None: + status = evaluate_pd_array_reaction_status(_complete_pd_array_payload()) + + self.assertTrue(status["passed"]) + self.assertEqual(status["status"], "pd_array_mapped") + self.assertEqual(status["execution_boundary"], "read_only_analysis_no_order_submission") + + def test_pd_array_summary_counts_blockers(self) -> None: + summary = summarize_pd_array_reaction_statuses( + [ + {"pd_array_type": "fvg", "fvg_retrace_confirmed": True}, + _complete_pd_array_payload(), + ] + ) + + self.assertEqual(summary["item_count"], 2) + self.assertEqual(summary["passed_count"], 1) + self.assertGreater(summary["reason_counts"]["fvg_is_not_setup"], 0) + self.assertEqual(summary["array_type_counts"]["fvg"], 2) + def test_playbook_gate_blocks_missing_and_passes_registered_schema(self) -> None: missing = build_doctrine_playbook_gate({"model_code": "SWEEP_MSS_FVG"}) @@ -199,6 +250,7 @@ class IctDoctrineKernelTests(unittest.TestCase): "swept_level": 99.5, "sweep_confirmed": True, "post_take_reaction": "reclaim_displacement", + **_complete_pd_array_payload(), "summary": {"quality_gate": {"passed": True, "reasons": []}}, } ) @@ -207,12 +259,14 @@ class IctDoctrineKernelTests(unittest.TestCase): self.assertEqual(cohort["doctrine_completeness"], "complete") self.assertTrue(cohort["promotion_gate"]["passed"]) self.assertEqual(cohort["liquidity_kernel_status"]["status"], "liquidity_mapped") + self.assertEqual(cohort["pd_array_reaction_status"]["status"], "pd_array_mapped") missing = build_doctrine_backtest_cohort({"model_code": "SWEEP_MSS_FVG"}) self.assertEqual(missing["cohort_key"], "missing_playbook::incomplete") self.assertIn("missing_doctrine_setup_playbook", missing["promotion_gate"]["reasons"]) self.assertIn("missing_liquidity_target", missing["promotion_gate"]["reasons"]) + self.assertIn("missing_pd_array", missing["promotion_gate"]["reasons"]) def test_liquidity_backtest_features_extract_nested_candidate_fields(self) -> None: features = build_liquidity_backtest_features( @@ -234,6 +288,47 @@ class IctDoctrineKernelTests(unittest.TestCase): self.assertEqual(features["liquidity_pool_rank"], "primary") self.assertFalse(features["sweep_confirmed"]) + def test_pd_array_backtest_features_extract_nested_candidate_fields(self) -> None: + features = build_pd_array_backtest_features( + { + "candidate": { + "entry": { + "pd_array_type": "fvg", + "pd_array_id": "fvg-1", + "pd_array_role": "entry", + }, + "confirmation_state": {"reaction_quality": "rejection_confirmed"}, + }, + "pd_array_rank": "primary", + "array_location": "discount", + } + ) + + self.assertEqual(features["pd_array_type"], "fvg") + self.assertEqual(features["pd_array_id"], "fvg-1") + self.assertEqual(features["pd_array_role"], "entry") + self.assertEqual(features["pd_array_rank"], "primary") + self.assertEqual(features["array_location"], "discount") + + +def _complete_pd_array_payload() -> dict: + return { + "pd_array_type": "fvg", + "pd_array_id": "fvg-1", + "pd_array_role": "entry", + "pd_array_rank": "primary", + "array_location": "discount", + "array_timeframe": "5m", + "array_freshness": "fresh", + "formation_event": "post_sweep_displacement", + "displacement_origin": "post_sweep_displacement", + "fvg_role": "entry", + "fvg_retrace_confirmed": True, + "reaction_quality": "rejection_confirmed", + "retest_confirmed": True, + "pd_array_invalidation": "fvg_accepted_other_side", + } + if __name__ == "__main__": unittest.main()