diff --git a/SESSION.md b/SESSION.md index 48d63b4..7828198 100644 --- a/SESSION.md +++ b/SESSION.md @@ -849,10 +849,11 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. - 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. +- D5 Time / Session Kernel is complete, deployed, and remote-verified: added `DOCTRINE_SESSION_PHASE=D5_TIME_SESSION_KERNEL`, `TimeSessionRule`, `GET /trading/time-session-kernel`, Strategy Backtest `time_session_backtest_features` / `time_session_kernel_status` / `time_session_kernel_summary`, and `docs/AI_ICT_DoctrineKernel_TimeSessionKernel_D5_2026-05-22_v1.md`. Asia / London / NY AM / NY PM, killzone, Judas/manipulation window, opening range, weekly profile, and news/no-trade time are now measurable session-quality gates; `missing_session_window`, `session_window_unverified`, `outside_session_window`, `session_not_allowed`, `missing_killzone`, `killzone_not_active`, `judas_window_wait`, `opening_range_unresolved`, `weekly_profile_missing`, `news_time_no_trade`, and `session_invalidation_missing` block promotion until timing is explicit. Validation passed focused D5/API/backtest/docs tests, full unit discovery (`846` tests), script static checks (`79` scripts), secret static checks, JS syntax checks, deploy smoke over 11 endpoints, remote ops smoke, read-only live analysis smoke, `/trading/time-session-kernel` field smoke, `/trading/backtests` D5 field smoke, and Playwright screenshot evidence at `output/playwright/d5-time-session-strategy-backtest-1366x768.png`. Remote latest backtest `#513` is correctly blocked by D5 as `study_only / session_window_unverified` instead of treating `ASIA` plus `mark_outside` as a verified session window. 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/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. +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/AI_ICT_DoctrineKernel_TimeSessionKernel_D5_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 D6 Model Playbook Kernel: convert the book-derived models into full setup playbooks with required Range / Draw / Liquidity / PD Array / Session / Confirmation / Invalidation / Target fields and batch-backtest cohorts. 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 11e3ad5..b5743ba 100644 --- a/TASKS.md +++ b/TASKS.md @@ -956,7 +956,8 @@ - [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 - [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] D5 Time / Session Kernel: rebuilt Asia / London / NY AM / NY PM / killzone / Judas window / opening range / weekly profile / news time into measurable session-quality gates instead of loose time labels; Strategy Backtest now exposes `time_session_backtest_features`, `time_session_kernel_status`, `time_session_kernel_summary`, and `/trading/time-session-kernel` without changing execution/readiness gates +- [ ] D6 next product gate: rebuild Model Playbook Kernel so book-derived models become full setup playbooks with required Range / Draw / Liquidity / PD Array / Session / Confirmation / Invalidation / Target fields and batch-backtest cohorts - [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_TimeSessionKernel_D5_2026-05-22_v1.md b/docs/AI_ICT_DoctrineKernel_TimeSessionKernel_D5_2026-05-22_v1.md new file mode 100644 index 0000000..cd8c159 --- /dev/null +++ b/docs/AI_ICT_DoctrineKernel_TimeSessionKernel_D5_2026-05-22_v1.md @@ -0,0 +1,97 @@ +# AI_ICT Doctrine Kernel Time / Session Kernel D5 2026-05-22 v1 + +## Scope + +D5 rebuilds time and session from loose labels into measurable session-quality gates. This slice follows `docs/ICT交易全书_出版终校版.md` 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 + +Asia, London, NY AM, NY PM, killzone, Judas/manipulation window, opening range, weekly profile, and news/no-trade time are not buy/sell signals. They can only qualify or block a setup after the system can state: + +- the exact session code and whether the candidate occurred inside a verifiable session window, +- whether the active Trading Plan allows that session, +- the killzone name and state, +- whether Judas/manipulation is still in a wait-only phase, +- whether the opening range reference is resolved, +- the weekly/day profile, +- whether a news or low-liquidity no-trade window is active, +- the session-specific invalidation. + +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_SESSION_PHASE = D5_TIME_SESSION_KERNEL` + +New registry/API: + +- `GET /trading/time-session-kernel` +- `build_time_session_kernel_registry` +- `list_time_session_rules` + +New backtest surface fields: + +- `time_session_backtest_features` +- `time_session_kernel_status` +- `time_session_kernel_summary` +- `time_session_kernel_status_counts` +- `time_session_kernel_reason_counts` +- `by_time_session_code` + +## D5 Rules + +1. `session_window_verifiable` + Session names are context windows. A setup must prove it occurred inside the allowed session window. + +2. `killzone_reaction_window` + Killzone is a timing filter only. It cannot create direction or override missing Range / Draw / Location. + +3. `judas_manipulation_sequence` + Judas/manipulation is wait-only until post-manipulation confirmation exists. + +4. `opening_range_delivery_state` + Opening range defines early reference and delivery state. Early motion is not enough. + +5. `weekly_profile_alignment` + Weekly/day profile must be declared before an intraday setup can be promoted. + +6. `news_time_no_trade_guard` + High-impact news and thin-liquidity windows are explicit no-trade guards. + +## Promotion Gate + +The D2 playbook now requires D5 Time / Session fields before promotion: + +- `session_code` +- `session_window_state` +- `allowed_sessions` +- `killzone_name` +- `killzone_state` +- `judas_window_state` +- `opening_range_state` +- `weekly_profile` +- `news_window_state` +- `session_invalidation` + +The Doctrine Backtest Cohort now blocks promotion unless: + +- D2 playbook schema is complete, +- D3 Liquidity Kernel passes, +- D4 PD Array / Reaction Kernel passes, +- D5 Time / Session Kernel passes, +- Strategy Backtest quality gate passes, +- active Trading Plan gate passes. + +## Read-Only Boundary + +D5 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 + +D6 should rebuild the Model Playbook Kernel: convert the book-derived models into full setup playbooks with required Range / Draw / Liquidity / PD Array / Session / Confirmation / Invalidation / Target fields and batch-backtest cohorts. diff --git a/output/playwright/d5-time-session-strategy-backtest-1366x768.png b/output/playwright/d5-time-session-strategy-backtest-1366x768.png new file mode 100644 index 0000000..f0dfb8d Binary files /dev/null and b/output/playwright/d5-time-session-strategy-backtest-1366x768.png differ diff --git a/src/api/backtest.py b/src/api/backtest.py index 737c373..de81e4f 100644 --- a/src/api/backtest.py +++ b/src/api/backtest.py @@ -9,15 +9,18 @@ from src.services.ict_mainline import ( build_doctrine_backtest_cohort, build_liquidity_backtest_features, build_pd_array_backtest_features, + build_time_session_backtest_features, build_backtest_quality_gate, build_ict_rulebook_mapping, evaluate_doctrine_research_status, evaluate_liquidity_kernel_status, evaluate_pd_array_reaction_status, + evaluate_time_session_kernel_status, summarize_doctrine_backtest_cohorts, summarize_doctrine_rule_gaps, summarize_liquidity_kernel_statuses, summarize_pd_array_reaction_statuses, + summarize_time_session_kernel_statuses, ) @@ -41,6 +44,7 @@ def get_strategy_backtest_surface(instrument_id: Optional[int] = None, limit: in 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) + time_session_kernel_summary = summarize_time_session_kernel_statuses(items) return { "instrument_id": instrument_id, "item_count": len(items), @@ -52,6 +56,7 @@ def get_strategy_backtest_surface(instrument_id: Optional[int] = None, limit: in "doctrine_cohort_summary": doctrine_cohort_summary, "liquidity_kernel_summary": liquidity_kernel_summary, "pd_array_reaction_summary": pd_array_reaction_summary, + "time_session_kernel_summary": time_session_kernel_summary, "ict_rulebook_mapping": ict_rulebook_mapping, "items": items, } @@ -157,6 +162,27 @@ def normalize_backtest_summary_row(row: dict) -> dict: "pd_array_backtest_features": pd_array_backtest_features, } ) + time_session_backtest_features = build_time_session_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, + } + ) + time_session_kernel_status = evaluate_time_session_kernel_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, + "time_session_backtest_features": time_session_backtest_features, + } + ) doctrine_backtest_cohort = build_doctrine_backtest_cohort( { "subject_type": row.get("subject_type"), @@ -167,6 +193,7 @@ def normalize_backtest_summary_row(row: dict) -> dict: "summary": summary, "liquidity_backtest_features": liquidity_backtest_features, "pd_array_backtest_features": pd_array_backtest_features, + "time_session_backtest_features": time_session_backtest_features, }, quality_gate=dict(summary.get("quality_gate") or {}), ) @@ -181,6 +208,8 @@ def normalize_backtest_summary_row(row: dict) -> dict: "liquidity_kernel_status": liquidity_kernel_status, "pd_array_backtest_features": pd_array_backtest_features, "pd_array_reaction_status": pd_array_reaction_status, + "time_session_backtest_features": time_session_backtest_features, + "time_session_kernel_status": time_session_kernel_status, "doctrine_backtest_cohort": doctrine_backtest_cohort, } @@ -225,6 +254,13 @@ def _build_operator_summary(latest: Optional[dict]) -> dict: status = "pd_array_reaction_blocked" decision = "do_not_promote_strategy" reason = str(pd_array_reasons[0]) + time_session_status = dict(latest.get("time_session_kernel_status") or {}) + if not time_session_status.get("passed"): + time_session_reasons = list(time_session_status.get("reasons") or []) + if time_session_reasons: + status = "time_session_kernel_blocked" + decision = "do_not_promote_strategy" + reason = str(time_session_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 95228d4..70c1f26 100644 --- a/src/api/server.py +++ b/src/api/server.py @@ -127,6 +127,7 @@ from src.services.ict_mainline import ( build_doctrine_rule_matrix, build_liquidity_kernel_registry, build_pd_array_reaction_registry, + build_time_session_kernel_registry, ) from src.services.execution import ExecutionService from src.services.market_state import start_market_truth_runtime @@ -231,6 +232,7 @@ def build_api_dependencies() -> dict: "trading_doctrine_playbooks": build_doctrine_playbook_registry, "trading_liquidity_kernel": build_liquidity_kernel_registry, "trading_pd_array_kernel": build_pd_array_reaction_registry, + "trading_time_session_kernel": build_time_session_kernel_registry, "training_cases": get_training_cases, "training_case": get_training_case, "training_export_cases": export_training_case_package, @@ -860,6 +862,8 @@ def route_request( 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 path == "/trading/time-session-kernel": + return 200, deps["trading_time_session_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 8585e32..faf4f11 100644 --- a/src/services/backtest/backtest_service.py +++ b/src/services/backtest/backtest_service.py @@ -8,8 +8,10 @@ from src.services.ict_mainline import ( build_doctrine_backtest_cohort, build_liquidity_backtest_features, build_pd_array_backtest_features, + build_time_session_backtest_features, evaluate_liquidity_kernel_status, evaluate_pd_array_reaction_status, + evaluate_time_session_kernel_status, extract_setup_playbook_id, ) @@ -154,6 +156,17 @@ class BacktestService: 'active_trading_plan': active_trading_plan, } ) + time_session_backtest_features = build_time_session_backtest_features( + { + 'subject_type': subject_type, + 'model_code': 'SWEEP_MSS_FVG', + 'candidate_model_code': candidate_model_code, + 'timeframe_set': timeframe_set, + 'session_codes': list(session_codes or []), + 'run_metadata': run_metadata, + 'active_trading_plan': active_trading_plan, + } + ) doctrine_backtest_cohort = build_doctrine_backtest_cohort( { 'subject_type': subject_type, @@ -163,6 +176,7 @@ class BacktestService: 'run_metadata': run_metadata, 'liquidity_backtest_features': liquidity_backtest_features, 'pd_array_backtest_features': pd_array_backtest_features, + 'time_session_backtest_features': time_session_backtest_features, } ) run_id = self.repository.insert_backtest_run( @@ -189,6 +203,8 @@ class BacktestService: '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), + 'time_session_backtest_features': time_session_backtest_features, + 'time_session_kernel_status': evaluate_time_session_kernel_status(time_session_backtest_features), 'doctrine_backtest_cohort': doctrine_backtest_cohort, 'run_metadata': run_metadata, }, @@ -210,10 +226,13 @@ class BacktestService: 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 + outcome_time_session_features = _first_outcome_meta_value(outcomes, 'time_session_backtest_features') or time_session_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['time_session_backtest_features'] = dict(outcome_time_session_features or {}) + summary['time_session_kernel_status'] = evaluate_time_session_kernel_status(summary['time_session_backtest_features']) summary['doctrine_backtest_cohort'] = build_doctrine_backtest_cohort( { 'subject_type': subject_type, @@ -224,6 +243,7 @@ class BacktestService: 'summary': summary, 'liquidity_backtest_features': summary['liquidity_backtest_features'], 'pd_array_backtest_features': summary['pd_array_backtest_features'], + 'time_session_backtest_features': summary['time_session_backtest_features'], }, quality_gate=summary['quality_gate'], ) @@ -268,6 +288,15 @@ class BacktestService: 'run_metadata': run_metadata, } ) + signal_time_session_features = build_time_session_backtest_features( + { + 'signal': signal, + 'target_plan': target_plan, + 'run_metadata': run_metadata, + 'session_codes': session_codes, + 'session_filter': 'outside_window' if len(sessions) == 0 else 'covered', + } + ) error_tags = ['no_future_candles'] if len(sessions) == 0: error_tags.append('outside_session_window') @@ -308,6 +337,8 @@ class BacktestService: '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), + 'time_session_backtest_features': signal_time_session_features, + 'time_session_kernel_status': evaluate_time_session_kernel_status(signal_time_session_features), 'cost_r': 0.0, 'cost_model': cost_config.__dict__, 'backtest_mode': mode, @@ -398,11 +429,21 @@ class BacktestService: 'run_metadata': run_metadata, } ), + 'time_session_backtest_features': build_time_session_backtest_features( + { + 'signal': signal, + 'target_plan': extract_target_plan_from_signal_row(signal), + 'run_metadata': run_metadata, + 'session_codes': session_codes, + 'session_filter': 'outside_window' if session_filtered else 'covered', + } + ), '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']) + record.meta['time_session_kernel_status'] = evaluate_time_session_kernel_status(record.meta['time_session_backtest_features']) outcomes.append(record) self.repository.insert_backtest_result( backtest_run_id=run_id, @@ -423,10 +464,13 @@ class BacktestService: 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 + outcome_time_session_features = _first_outcome_meta_value(outcomes, 'time_session_backtest_features') or time_session_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['time_session_backtest_features'] = dict(outcome_time_session_features or {}) + summary['time_session_kernel_status'] = evaluate_time_session_kernel_status(summary['time_session_backtest_features']) summary['doctrine_backtest_cohort'] = build_doctrine_backtest_cohort( { 'subject_type': subject_type, @@ -437,6 +481,7 @@ class BacktestService: 'summary': summary, 'liquidity_backtest_features': summary['liquidity_backtest_features'], 'pd_array_backtest_features': summary['pd_array_backtest_features'], + 'time_session_backtest_features': summary['time_session_backtest_features'], }, quality_gate=summary['quality_gate'], ) @@ -1014,6 +1059,19 @@ def build_plan_replay_units(candidate_rows: list[dict]) -> list[dict]: } ) pd_array_reaction_status = evaluate_pd_array_reaction_status(pd_array_backtest_features) + time_session_backtest_features = build_time_session_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, + 'session_code': first.get('session_code'), + 'allowed_sessions': list(first.get('allowed_sessions') or []), + } + ) + time_session_kernel_status = evaluate_time_session_kernel_status(time_session_backtest_features) doctrine_backtest_cohort = build_doctrine_backtest_cohort( { 'setup_playbook_id': setup_playbook_id, @@ -1024,6 +1082,7 @@ def build_plan_replay_units(candidate_rows: list[dict]) -> list[dict]: }, 'liquidity_backtest_features': liquidity_backtest_features, 'pd_array_backtest_features': pd_array_backtest_features, + 'time_session_backtest_features': time_session_backtest_features, } ) units.append( @@ -1036,6 +1095,8 @@ def build_plan_replay_units(candidate_rows: list[dict]) -> list[dict]: 'liquidity_kernel_status': liquidity_kernel_status, 'pd_array_backtest_features': pd_array_backtest_features, 'pd_array_reaction_status': pd_array_reaction_status, + 'time_session_backtest_features': time_session_backtest_features, + 'time_session_kernel_status': time_session_kernel_status, 'candidate_model_code': first.get('candidate_model_code'), 'side': first.get('side'), 'session_code': first.get('session_code'), @@ -1120,6 +1181,15 @@ def build_plan_replay_meta( mode: str, ) -> dict: session_codes = [row.get('session_code') for row in sessions if row.get('session_code')] or ['OFF_HOURS'] + time_session_backtest_features = build_time_session_backtest_features( + { + 'unit': unit, + 'time_session_backtest_features': dict(unit.get('time_session_backtest_features') or {}), + 'session_codes': session_codes, + 'session_filter': 'outside_window' if len(sessions) == 0 else 'covered', + } + ) + time_session_kernel_status = evaluate_time_session_kernel_status(time_session_backtest_features) return { 'subject_type': BACKTEST_SUBJECT_PLAN_REPLAY, 'trading_plan_id': unit.get('trading_plan_id'), @@ -1131,6 +1201,8 @@ def build_plan_replay_meta( '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 {}), + 'time_session_backtest_features': time_session_backtest_features, + 'time_session_kernel_status': time_session_kernel_status, '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'), @@ -1319,6 +1391,7 @@ def summarize_backtest_outcomes(outcomes: list[BacktestOutcome], subject_type: s grouped_by_doctrine_cohort: dict[str, dict] = {} grouped_by_liquidity_target_side: dict[str, dict] = {} grouped_by_pd_array_type: dict[str, dict] = {} + grouped_by_time_session_code: dict[str, dict] = {} r_multiples: list[float] = [] cost_values: list[float] = [] mfe_r_values: list[float] = [] @@ -1333,6 +1406,8 @@ def summarize_backtest_outcomes(outcomes: list[BacktestOutcome], subject_type: s liquidity_kernel_status_counts: dict[str, int] = {} pd_array_reaction_reason_counts: dict[str, int] = {} pd_array_reaction_status_counts: dict[str, int] = {} + time_session_kernel_reason_counts: dict[str, int] = {} + time_session_kernel_status_counts: dict[str, int] = {} plan_total_r: dict[int, float] = {} plan_seen: set[int] = set() @@ -1382,6 +1457,13 @@ def summarize_backtest_outcomes(outcomes: list[BacktestOutcome], subject_type: s 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 + time_session_features = dict(outcome.meta.get('time_session_backtest_features') or {}) + time_session_code = str(time_session_features.get('session_code') or (session_codes[0] if session_codes else '') or 'missing_session_window') + time_session_status = dict(outcome.meta.get('time_session_kernel_status') or evaluate_time_session_kernel_status(time_session_features)) + time_session_status_key = str(time_session_status.get('status') or 'unknown') + time_session_kernel_status_counts[time_session_status_key] = time_session_kernel_status_counts.get(time_session_status_key, 0) + 1 + for reason in list(time_session_status.get('reasons') or []): + time_session_kernel_reason_counts[str(reason)] = time_session_kernel_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)) @@ -1394,6 +1476,7 @@ def summarize_backtest_outcomes(outcomes: list[BacktestOutcome], subject_type: s _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) + _accumulate_bucket(grouped_by_time_session_code, time_session_code, outcome) for session_code in session_codes: _accumulate_bucket(grouped_by_session, str(session_code), outcome) @@ -1428,6 +1511,8 @@ def summarize_backtest_outcomes(outcomes: list[BacktestOutcome], subject_type: s '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, + 'time_session_kernel_status_counts': time_session_kernel_status_counts, + 'time_session_kernel_reason_counts': time_session_kernel_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), @@ -1448,6 +1533,7 @@ def summarize_backtest_outcomes(outcomes: list[BacktestOutcome], subject_type: s '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, + 'by_time_session_code': grouped_by_time_session_code, } 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 e76ffcd..dc534ef 100644 --- a/src/services/ict_mainline/__init__.py +++ b/src/services/ict_mainline/__init__.py @@ -22,27 +22,33 @@ from src.services.ict_mainline.doctrine import ( DOCTRINE_LIQUIDITY_PHASE, DOCTRINE_PLAYBOOK_PHASE, DOCTRINE_REACTION_PHASE, + DOCTRINE_SESSION_PHASE, build_doctrine_backtest_cohort, build_liquidity_backtest_features, build_pd_array_backtest_features, + build_time_session_backtest_features, build_doctrine_rule_matrix, build_doctrine_playbook_gate, build_doctrine_playbook_registry, build_liquidity_kernel_registry, build_pd_array_reaction_registry, + build_time_session_kernel_registry, evaluate_doctrine_research_status, evaluate_liquidity_kernel_status, evaluate_pd_array_reaction_status, + evaluate_time_session_kernel_status, extract_setup_playbook_id, get_doctrine_setup_playbook, list_doctrine_setup_playbooks, list_doctrine_rules, list_liquidity_rules, list_pd_array_rules, + list_time_session_rules, summarize_doctrine_backtest_cohorts, summarize_doctrine_rule_gaps, summarize_liquidity_kernel_statuses, summarize_pd_array_reaction_statuses, + summarize_time_session_kernel_statuses, ) __all__ = [ @@ -50,15 +56,18 @@ __all__ = [ "DOCTRINE_LIQUIDITY_PHASE", "DOCTRINE_PLAYBOOK_PHASE", "DOCTRINE_REACTION_PHASE", + "DOCTRINE_SESSION_PHASE", "ICT_MAINLINE_CHAIN", "build_doctrine_backtest_cohort", "build_liquidity_backtest_features", "build_pd_array_backtest_features", + "build_time_session_backtest_features", "build_doctrine_rule_matrix", "build_doctrine_playbook_gate", "build_doctrine_playbook_registry", "build_liquidity_kernel_registry", "build_pd_array_reaction_registry", + "build_time_session_kernel_registry", "build_ict_rulebook_mapping", "build_ict_rulebook_summary", "build_backtest_quality_gate", @@ -71,6 +80,7 @@ __all__ = [ "evaluate_doctrine_research_status", "evaluate_liquidity_kernel_status", "evaluate_pd_array_reaction_status", + "evaluate_time_session_kernel_status", "extract_setup_playbook_id", "get_ict_concept", "get_doctrine_setup_playbook", @@ -78,10 +88,12 @@ __all__ = [ "list_doctrine_rules", "list_liquidity_rules", "list_pd_array_rules", + "list_time_session_rules", "list_ict_concepts", "summarize_doctrine_backtest_cohorts", "summarize_doctrine_rule_gaps", "summarize_liquidity_kernel_statuses", "summarize_pd_array_reaction_statuses", + "summarize_time_session_kernel_statuses", "summarize_book_rule_errors", ] diff --git a/src/services/ict_mainline/doctrine.py b/src/services/ict_mainline/doctrine.py index 9d766d5..9f5b659 100644 --- a/src/services/ict_mainline/doctrine.py +++ b/src/services/ict_mainline/doctrine.py @@ -10,6 +10,7 @@ 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" +DOCTRINE_SESSION_PHASE = "D5_TIME_SESSION_KERNEL" @dataclass(frozen=True) @@ -134,6 +135,34 @@ class PdArrayReactionRule: return payload +@dataclass(frozen=True) +class TimeSessionRule: + rule_id: str + stage: str + book_anchor: str + session_scope: 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", @@ -307,8 +336,25 @@ D2_DOCTRINE_SETUP_PLAYBOOKS: tuple[DoctrineSetupPlaybook, ...] = ( ), }, session_timing_rule={ - "required_fields": ("session_code", "allowed_sessions", "path_context", "sequence_validity"), - "operator_rule": "Only London/NY timing defined by the active plan can produce a review candidate.", + "required_fields": ( + "session_code", + "session_window_state", + "allowed_sessions", + "killzone_name", + "killzone_state", + "judas_window_state", + "opening_range_state", + "weekly_profile", + "news_window_state", + "session_invalidation", + "path_context", + "sequence_validity", + ), + "ranking_rule_id": "session_window_verifiable", + "operator_rule": ( + "Only a verifiable London/NY session window can produce review; killzone, Judas/manipulation, " + "opening range, weekly profile, and news/no-trade time must be explicit." + ), }, confirmation_rule={ "required_fields": ("confirmation_state", "missing_confirmations", "reason_codes"), @@ -336,6 +382,17 @@ D2_DOCTRINE_SETUP_PLAYBOOKS: tuple[DoctrineSetupPlaybook, ...] = ( "pd_array_not_direction_source", "fvg_is_not_setup", "doctrine_missing_time_path", + "missing_session_window", + "session_window_unverified", + "outside_session_window", + "session_not_allowed", + "missing_killzone", + "killzone_not_active", + "judas_window_wait", + "opening_range_unresolved", + "weekly_profile_missing", + "news_time_no_trade", + "session_invalidation_missing", "doctrine_missing_invalidation", "doctrine_missing_exit_architecture", "backtest_quality_gate_failed", @@ -375,6 +432,10 @@ D2_DOCTRINE_SETUP_PLAYBOOKS: tuple[DoctrineSetupPlaybook, ...] = ( "traded_without_pd_array_context", "traded_unvalidated_order_block", "traded_without_time_or_path", + "traded_outside_session_window", + "traded_judas_as_signal", + "traded_without_weekly_profile", + "traded_through_news_window", "traded_without_invalidation", "no_exit_architecture", ), @@ -647,6 +708,94 @@ D4_PD_ARRAY_REACTION_RULES: tuple[PdArrayReactionRule, ...] = ( ) +D5_TIME_SESSION_RULES: tuple[TimeSessionRule, ...] = ( + TimeSessionRule( + rule_id="session_window_verifiable", + stage="Session Window", + book_anchor="ICT trading book Time / Session model: Asia, London, NY AM, and NY PM are context windows, not signals", + session_scope="ASIA/LONDON/NY_AM/NY_PM", + ranking_weight=100, + required_inputs=("session_code", "session_window_state", "allowed_sessions", "session_invalidation"), + measurable_fields=("session_code", "session_window_state", "session_start_ts", "session_end_ts", "allowed_sessions", "session_invalidation"), + no_trade_when=("missing_session_window", "session_window_unverified", "outside_session_window", "session_not_allowed"), + wait_when=(), + backtest_observable="setup_without_verifiable_session_window", + operator_rule="A setup must name the active session and prove that the candidate occurred inside the allowed session window.", + aliases=("session_code", "session_window_state", "session_filter", "session_start_ts", "session_end_ts", "allowed_sessions"), + ), + TimeSessionRule( + rule_id="killzone_reaction_window", + stage="Killzone", + book_anchor="ICT trading book Time / Session model: killzone is a timing filter after context is known", + session_scope="London/NY killzone", + ranking_weight=90, + required_inputs=("killzone_name", "killzone_state", "killzone_start_ts", "killzone_end_ts"), + measurable_fields=("killzone_name", "killzone_state", "killzone_start_ts", "killzone_end_ts"), + no_trade_when=("missing_killzone",), + wait_when=("killzone_not_active",), + backtest_observable="setup_without_active_killzone_window", + operator_rule="Killzone can only qualify timing; it cannot create direction or override missing Range/Draw/Location.", + aliases=("killzone", "kill_zone", "killzone_name", "killzone_state"), + ), + TimeSessionRule( + rule_id="judas_manipulation_sequence", + stage="Judas / Manipulation", + book_anchor="ICT trading book Time / Session model: Judas/manipulation is inducement first and confirmation later", + session_scope="London/NY manipulation window", + ranking_weight=85, + required_inputs=("judas_window_state", "manipulation_window_state", "inducement_side", "post_manipulation_confirmation"), + measurable_fields=("judas_window_state", "manipulation_window_state", "inducement_side", "post_manipulation_confirmation"), + no_trade_when=("judas_traded_as_signal",), + wait_when=("judas_window_wait",), + backtest_observable="trade_attempt_during_manipulation_window", + operator_rule="Judas/manipulation is a wait condition until sweep, structure, and session transition evidence are present.", + aliases=("judas_window_state", "manipulation_window_state", "inducement_state", "post_manipulation_confirmation"), + ), + TimeSessionRule( + rule_id="opening_range_delivery_state", + stage="Opening Range", + book_anchor="ICT trading book Time / Session model: opening range defines early reference and delivery state", + session_scope="session opening range", + ranking_weight=75, + required_inputs=("opening_range_state", "opening_range_id", "opening_range_high", "opening_range_low"), + measurable_fields=("opening_range_state", "opening_range_id", "opening_range_high", "opening_range_low", "opening_range_reaction"), + no_trade_when=("opening_range_invalid",), + wait_when=("opening_range_unresolved",), + backtest_observable="setup_before_opening_range_resolved", + operator_rule="Do not treat early-session motion as a setup until the opening range reference and reaction state are explicit.", + aliases=("opening_range_state", "opening_range_id", "opening_range_high", "opening_range_low", "or_high", "or_low"), + ), + TimeSessionRule( + rule_id="weekly_profile_alignment", + stage="Weekly Profile", + book_anchor="ICT trading book Time / Session model: day/week profile conditions the intraday model", + session_scope="weekly profile / day model", + ranking_weight=70, + required_inputs=("weekly_profile", "weekly_bias", "day_of_week", "weekly_range_state"), + measurable_fields=("weekly_profile", "weekly_bias", "day_of_week", "weekly_range_state"), + no_trade_when=("weekly_profile_missing",), + wait_when=("weekly_profile_unresolved",), + backtest_observable="setup_without_weekly_profile_alignment", + operator_rule="A session setup must declare the weekly/day profile or stay study-only.", + aliases=("weekly_profile", "weekly_bias", "day_of_week", "weekly_range_state", "day_model", "week_model"), + ), + TimeSessionRule( + rule_id="news_time_no_trade_guard", + stage="News / No-Trade Time", + book_anchor="ICT trading book Time / Session model: high-impact news and thin-liquidity windows are explicit no-trade guards", + session_scope="news / low-liquidity window", + ranking_weight=95, + required_inputs=("news_window_state", "high_impact_news", "low_liquidity_window"), + measurable_fields=("news_window_state", "high_impact_news", "low_liquidity_window", "news_event_id"), + no_trade_when=("news_time_no_trade",), + wait_when=("news_window_clearing_wait",), + backtest_observable="setup_attempted_inside_news_no_trade_window", + operator_rule="When news/no-trade time is active, freeze promotion and wait for the window to clear.", + aliases=("news_window_state", "high_impact_news", "low_liquidity_window", "news_event_id", "red_folder_news"), + ), +) + + def list_doctrine_rules() -> list[dict]: return [rule.to_public_dict() for rule in D1_DOCTRINE_RULES] @@ -693,6 +842,10 @@ def list_pd_array_rules() -> list[dict]: return [rule.to_public_dict() for rule in D4_PD_ARRAY_REACTION_RULES] +def list_time_session_rules() -> list[dict]: + return [rule.to_public_dict() for rule in D5_TIME_SESSION_RULES] + + def build_liquidity_kernel_registry() -> dict: return { "phase": DOCTRINE_LIQUIDITY_PHASE, @@ -739,6 +892,29 @@ def build_pd_array_reaction_registry() -> dict: } +def build_time_session_kernel_registry() -> dict: + return { + "phase": DOCTRINE_SESSION_PHASE, + "source": "docs/ICT交易全书_出版终校版.md Time / Session model chapters", + "status": "active", + "rule_count": len(D5_TIME_SESSION_RULES), + "hard_rules": [ + "Asia / London / NY AM / NY PM are context windows, not entry signals.", + "Killzone is a timing filter only; it cannot override missing Range/Draw/Location/Liquidity.", + "Judas / manipulation window is wait-only until post-manipulation confirmation exists.", + "Opening range must be defined before early-session motion is used as evidence.", + "Weekly profile and news/no-trade windows must be explicit before promotion.", + ], + "required_backtest_feature_fields": _dedupe( + field + for rule in D5_TIME_SESSION_RULES + for field in rule.measurable_fields + ), + "rules": list_time_session_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: @@ -824,6 +1000,7 @@ def build_doctrine_backtest_cohort(payload: object, *, quality_gate: dict | None playbook_gate = build_doctrine_playbook_gate(payload) liquidity_status = evaluate_liquidity_kernel_status(payload) pd_array_status = evaluate_pd_array_reaction_status(payload) + time_session_status = evaluate_time_session_kernel_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")) @@ -838,6 +1015,7 @@ def build_doctrine_backtest_cohort(payload: object, *, quality_gate: dict | None *[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 time_session_status.get("passed") else [str(item) for item in list(time_session_status.get("reasons") or []) if item]), *([] if quality_passed else quality_reasons), ] promotion_reasons = _dedupe(promotion_reasons) @@ -855,12 +1033,15 @@ def build_doctrine_backtest_cohort(payload: object, *, quality_gate: dict | None "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 {}), + "time_session_kernel_status": time_session_status, + "time_session_backtest_features": dict(time_session_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_time_session_kernel_complete": True, "requires_backtest_quality_gate": True, "requires_active_trading_plan": True, "execution_boundary": "read_only_analysis_no_order_submission", @@ -1282,6 +1463,194 @@ def summarize_pd_array_reaction_statuses(items: Iterable[dict]) -> dict: } +def build_time_session_backtest_features(payload: object) -> dict: + session_codes = _as_string_list( + _extract_first_present_value(payload, keys=("session_codes", "allowed_session_codes")) + ) + allowed_sessions = _as_string_list( + _extract_first_present_value(payload, keys=("allowed_sessions", "plan_allowed_sessions")) + ) + session_code = _string_or_empty(_extract_first_present_value(payload, keys=("session_code",))) + if not session_code: + session_value = _extract_first_present_value(payload, keys=("session",)) + if not isinstance(session_value, (dict, list, tuple)): + session_code = _string_or_empty(session_value) + if not session_code and session_codes: + session_code = session_codes[0] + raw_window_state = _string_or_empty( + _extract_first_present_value( + payload, + keys=("session_window_state", "session_filter", "session_status", "window_state"), + ) + ) + session_filter = _string_or_empty(_extract_first_present_value(payload, keys=("session_filter",))) + session_window_state = _normalize_session_window_state( + raw_window_state or session_filter, + session_code=session_code, + ) + features = { + "session_code": session_code, + "session_codes": session_codes, + "allowed_sessions": allowed_sessions, + "session_window_state": session_window_state, + "session_filter": session_filter, + "session_start_ts": _extract_first_present_value(payload, keys=("session_start_ts", "window_start_ts", "session_open_ts")), + "session_end_ts": _extract_first_present_value(payload, keys=("session_end_ts", "window_end_ts", "session_close_ts")), + "killzone_name": _string_or_empty( + _extract_first_present_value(payload, keys=("killzone_name", "killzone", "kill_zone", "killzone_code")) + ), + "killzone_state": _string_or_empty( + _extract_first_present_value(payload, keys=("killzone_state", "kill_zone_state", "killzone_status")) + ), + "killzone_start_ts": _extract_first_present_value(payload, keys=("killzone_start_ts", "kill_zone_start_ts")), + "killzone_end_ts": _extract_first_present_value(payload, keys=("killzone_end_ts", "kill_zone_end_ts")), + "judas_window_state": _string_or_empty( + _extract_first_present_value( + payload, + keys=("judas_window_state", "judas_state", "manipulation_window_state", "inducement_state"), + ) + ), + "inducement_side": _string_or_empty(_extract_first_present_value(payload, keys=("inducement_side", "used_side"))), + "post_manipulation_confirmation": _extract_first_present_value( + payload, + keys=("post_manipulation_confirmation", "post_judas_confirmation", "post_take_reaction", "structure_response"), + ), + "opening_range_state": _string_or_empty( + _extract_first_present_value(payload, keys=("opening_range_state", "opening_range_status", "or_state")) + ), + "opening_range_id": _string_or_empty(_extract_first_present_value(payload, keys=("opening_range_id", "or_id"))), + "opening_range_high": _extract_first_present_value(payload, keys=("opening_range_high", "or_high")), + "opening_range_low": _extract_first_present_value(payload, keys=("opening_range_low", "or_low")), + "opening_range_reaction": _string_or_empty( + _extract_first_present_value(payload, keys=("opening_range_reaction", "or_reaction", "opening_range_response")) + ), + "weekly_profile": _string_or_empty(_extract_first_present_value(payload, keys=("weekly_profile", "week_model", "day_model"))), + "weekly_bias": _string_or_empty(_extract_first_present_value(payload, keys=("weekly_bias", "week_bias", "day_bias"))), + "day_of_week": _string_or_empty(_extract_first_present_value(payload, keys=("day_of_week", "weekday"))), + "weekly_range_state": _string_or_empty( + _extract_first_present_value(payload, keys=("weekly_range_state", "week_range_state")) + ), + "news_window_state": _string_or_empty( + _extract_first_present_value(payload, keys=("news_window_state", "news_state", "event_window_state")) + ), + "high_impact_news": _extract_first_present_value(payload, keys=("high_impact_news", "red_folder_news")), + "low_liquidity_window": _extract_first_present_value(payload, keys=("low_liquidity_window", "thin_liquidity_window")), + "news_event_id": _string_or_empty(_extract_first_present_value(payload, keys=("news_event_id", "event_id"))), + "session_invalidation": _string_or_empty( + _extract_first_present_value( + payload, + keys=("session_invalidation", "session_invalidation_rule", "session_invalidates_at", "invalidation"), + ) + ), + "created_ts": _extract_first_present_value(payload, keys=("created_ts", "signal_created_ts", "candidate_created_ts")), + "expires_ts": _extract_first_present_value(payload, keys=("expires_ts", "expiry_ts", "session_expires_ts")), + } + features["has_session_window"] = bool(features["session_code"] and features["session_window_state"]) + features["session_allowed"] = _session_value_allowed(features["session_code"], allowed_sessions) + features["killzone_active"] = _session_state_is_active(features["killzone_state"]) + features["opening_range_resolved"] = _opening_range_resolved(features["opening_range_state"]) + features["news_no_trade_active"] = _news_window_active(features) + return features + + +def evaluate_time_session_kernel_status(payload: object) -> dict: + features = build_time_session_backtest_features(payload) + no_trade_reasons: list[str] = [] + wait_reasons: list[str] = [] + session_code = str(features.get("session_code") or "").strip() + + if not session_code or _session_state_is_missing(session_code): + no_trade_reasons.append("missing_session_window") + elif not features.get("session_window_state"): + no_trade_reasons.append("session_window_unverified") + elif _session_state_is_outside(session_code) or _session_state_is_outside(str(features.get("session_window_state") or "")): + no_trade_reasons.append("outside_session_window") + if not features["session_allowed"]: + no_trade_reasons.append("session_not_allowed") + + if not features["killzone_name"]: + wait_reasons.append("missing_killzone") + elif not features["killzone_active"]: + wait_reasons.append("killzone_not_active") + + judas_state = str(features["judas_window_state"] or "").lower() + if judas_state in {"active", "judas", "manipulation", "inducement", "conditioning", "pre_confirmation", "building"}: + wait_reasons.append("judas_window_wait") + + if not features["opening_range_resolved"]: + wait_reasons.append("opening_range_unresolved") + + if not features["weekly_profile"] and not features["weekly_bias"]: + no_trade_reasons.append("weekly_profile_missing") + + if features["news_no_trade_active"]: + no_trade_reasons.append("news_time_no_trade") + + if not features["session_invalidation"]: + no_trade_reasons.append("session_invalidation_missing") + + reasons = _dedupe([*no_trade_reasons, *wait_reasons]) + passed = not reasons + if passed: + status = "time_session_mapped" + elif no_trade_reasons: + status = "study_only" + else: + status = "wait" + return { + "phase": DOCTRINE_SESSION_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": ( + "Session, killzone, opening range, weekly profile, news guard, and session invalidation are mapped." + if passed + else "Treat timing as study-only/wait until the session window, killzone, opening range, weekly profile, news guard, and session invalidation are explicit." + ), + "execution_boundary": "read_only_analysis_no_order_submission", + } + + +def summarize_time_session_kernel_statuses(items: Iterable[dict]) -> dict: + reason_counts: dict[str, int] = {} + status_counts: dict[str, int] = {} + session_code_counts: dict[str, int] = {} + total = 0 + passed_count = 0 + for item in items: + total += 1 + status = dict(item.get("time_session_kernel_status") or evaluate_time_session_kernel_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 {}) + session_code = str(features.get("session_code") or "missing_session_window") + session_code_counts[session_code] = int(session_code_counts.get(session_code) 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_SESSION_PHASE, + "item_count": total, + "passed_count": passed_count, + "blocked_count": max(0, total - passed_count), + "status_counts": status_counts, + "reason_counts": reason_counts, + "session_code_counts": session_code_counts, + "operator_summary": ( + "No backtest items available for Time / Session Kernel review." + if not total + else "Review Time / Session blockers before treating a session label as usable setup evidence." + ), + } + + def evaluate_doctrine_research_status( payload: object, *, @@ -1486,6 +1855,89 @@ def _dedupe(values: Iterable[str]) -> list[str]: return result +def _as_string_list(value: object) -> list[str]: + if value is None: + return [] + if isinstance(value, (list, tuple, set)): + raw_items = value + else: + raw_items = [value] + result: list[str] = [] + for item in raw_items: + if item is None: + continue + text = str(item).strip() + if not text: + continue + result.append(text) + return _dedupe(result) + + +def _normalize_session_window_state(value: object, *, session_code: str = "") -> str: + text = str(value or "").strip().lower() + code = str(session_code or "").strip().lower() + if text in {"covered", "inside", "active", "open", "in_session", "in_session_window", "session_window_mapped"}: + return "inside" + if text in {"mark_outside", "outside_marking_policy"}: + return "" + if text in {"outside", "outside_window", "off_hours", "closed", "session_mismatch"}: + return "outside" + if code in {"off_hours", "outside", "n/a", "na", "none", "missing"}: + return "outside" + return str(value or "").strip() + + +def _session_value_allowed(session_code: str, allowed_sessions: list[str]) -> bool: + if not allowed_sessions: + return True + code = str(session_code or "").strip().lower() + allowed = {str(item or "").strip().lower() for item in allowed_sessions if str(item or "").strip()} + return "all" in allowed or code in allowed + + +def _session_state_is_missing(value: str) -> bool: + return str(value or "").strip().lower() in {"", "n/a", "na", "none", "null", "missing", "unknown"} + + +def _session_state_is_outside(value: str) -> bool: + return str(value or "").strip().lower() in {"off_hours", "outside", "outside_window", "closed", "session_mismatch"} + + +def _session_state_is_active(value: str) -> bool: + return str(value or "").strip().lower() in { + "active", + "inside", + "open", + "confirmed", + "mapped", + "in_session", + "in_killzone", + "killzone_active", + } + + +def _opening_range_resolved(value: str) -> bool: + return str(value or "").strip().lower() in { + "defined", + "resolved", + "complete", + "completed", + "confirmed", + "after_opening_range", + "post_opening_range", + "range_set", + } + + +def _news_window_active(features: dict) -> bool: + text = str(features.get("news_window_state") or "").strip().lower() + if text in {"active", "blocked", "high_impact", "red_folder", "pre_news", "post_news", "low_liquidity", "thin_liquidity"}: + return True + if _truthy_feature(features.get("high_impact_news")): + return True + return _truthy_feature(features.get("low_liquidity_window")) + + def _string_or_empty(value: object) -> str: if value is None: return "" diff --git a/src/web/app.js b/src/web/app.js index c77a0cd..d9c239f 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -5550,6 +5550,17 @@ function renderStrategyBacktests(payload) { doctrineCohort.pd_array_backtest_features || pdArrayStatus.features || {}; + const timeSessionStatus = + latest.time_session_kernel_status || + summary.time_session_kernel_status || + doctrineCohort.time_session_kernel_status || + {}; + const timeSessionFeatures = + latest.time_session_backtest_features || + summary.time_session_backtest_features || + doctrineCohort.time_session_backtest_features || + timeSessionStatus.features || + {}; strategyBacktestPanel.appendChild( buildInfoGrid([ @@ -5568,6 +5579,10 @@ function renderStrategyBacktests(payload) { ['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'], + ['Time / Session Kernel', formatBacktestDecision(timeSessionStatus.status || 'study_only')], + ['Time / Session Reason', formatBacktestDecision(timeSessionStatus.reason || 'missing_session_window')], + ['Session', timeSessionFeatures.session_code || 'missing_session_window'], + ['Killzone', timeSessionFeatures.killzone_name || 'missing_killzone'], ['Promotion Gate', doctrinePromotionGate.passed ? 'Passed' : 'Blocked'], ['Instrument ID', latest.instrument_id ?? 'n/a'], ['Timeframe', formatJsonInline(latest.timeframe_set || {})], @@ -5610,6 +5625,13 @@ function renderStrategyBacktests(payload) { formatList((pdArrayStatus.reasons || []).map(formatBacktestDecision), 'PD Array chain mapped') ) ); + cards.appendChild( + buildMetricCard( + 'Time / Session Kernel', + timeSessionStatus.passed ? 'Mapped' : 'Blocked', + formatList((timeSessionStatus.reasons || []).map(formatBacktestDecision), 'Time / Session chain mapped') + ) + ); strategyBacktestPanel.appendChild(cards); strategyBacktestPanel.appendChild(buildBacktestCurve(summary.cumulative_r_curve || [])); @@ -5660,6 +5682,15 @@ function renderStrategyBacktests(payload) { .map((item) => [formatBacktestDecision(item.key), String(item.value)]) ) ); + detailGrid.appendChild( + buildItemCard( + 'Time / Session Kernel', + `${payload?.time_session_kernel_summary?.passed_count ?? 0} mapped / ${payload?.time_session_kernel_summary?.blocked_count ?? 0} blocked`, + topCountEntries(payload?.time_session_kernel_summary?.reason_counts || summary.time_session_kernel_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 e48650f..487ef4c 100644 --- a/src/web/i18n.js +++ b/src/web/i18n.js @@ -87,6 +87,10 @@ 'PD Array': 'PD Array 位置', 'PD Array Role': 'PD Array 角色', 'PD Array chain mapped': 'PD Array 反应链路已映射', + 'Time / Session Kernel': '时间 / 时段内核', + 'Time / Session Reason': '时间 / 时段原因', + 'Time / Session chain mapped': '时间 / 时段链路已映射', + 'Killzone': 'Killzone 时段', 'Mapped': '已映射', 'mapped': '已映射', 'Eligible For Trading Plan Review': '可进入交易计划复核', @@ -790,6 +794,19 @@ ['breaker_without_failure_precondition', 'Breaker 缺少失败前提'], ['mitigation_without_inventory_context', 'Mitigation 缺少库存上下文'], ['awaiting_breaker_retest_confirmation', '等待 Breaker 回踩确认'], + ['time_session_mapped', '时间 / 时段链路已映射'], + ['time_session_kernel_blocked', '时间 / 时段内核阻塞'], + ['missing_session_window', '缺少可验证交易时段窗口'], + ['session_window_unverified', '交易时段窗口未验证'], + ['outside_session_window', '不在允许交易时段'], + ['session_not_allowed', '当前时段不在计划允许范围'], + ['missing_killzone', '缺少 Killzone 时段'], + ['killzone_not_active', 'Killzone 未激活'], + ['judas_window_wait', 'Judas / 操纵窗口等待中'], + ['opening_range_unresolved', '开盘区间未确认'], + ['weekly_profile_missing', '缺少周模型 / 日模型'], + ['news_time_no_trade', '新闻 / 禁交易时间'], + ['session_invalidation_missing', '缺少时段失效条件'], ['missing_playbook', '缺少 playbook'], ['Doctrine Cohort', 'Doctrine 回测分组'], ['Doctrine Cohorts', 'Doctrine 回测分组'], @@ -800,6 +817,10 @@ ['Liquidity Target', '流动性目标'], ['Liquidity Pool', '流动性池'], ['Liquidity chain mapped', '流动性链路已映射'], + ['Time / Session Kernel', '时间 / 时段内核'], + ['Time / Session Reason', '时间 / 时段原因'], + ['Time / Session chain mapped', '时间 / 时段链路已映射'], + ['Killzone', 'Killzone 时段'], ['Promotion Gate', '晋级门'], ['complete', '完整'], ['incomplete', '不完整'], diff --git a/tests/unit/test_api_and_main.py b/tests/unit/test_api_and_main.py index 4ea1a5e..15f9757 100644 --- a/tests/unit/test_api_and_main.py +++ b/tests/unit/test_api_and_main.py @@ -97,6 +97,8 @@ class ApiTests(unittest.TestCase): 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"]) + self.assertEqual(result[0]["time_session_kernel_status"]["status"], "study_only") + self.assertIn("missing_session_window", result[0]["time_session_kernel_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}}}} @@ -130,6 +132,8 @@ class ApiTests(unittest.TestCase): 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"]) + self.assertEqual(result["time_session_kernel_summary"]["item_count"], 1) + self.assertIn("missing_session_window", result["time_session_kernel_summary"]["reason_counts"]) def test_route_request_trading_doctrine_rules_returns_d1_matrix(self) -> None: status, payload = route_request( @@ -190,6 +194,22 @@ class ApiTests(unittest.TestCase): 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_time_session_kernel_returns_d5_registry(self) -> None: + status, payload = route_request( + "/trading/time-session-kernel", + {}, + dependencies={ + "trading_time_session_kernel": lambda: { + "phase": "D5_TIME_SESSION_KERNEL", + "rules": [{"rule_id": "session_window_verifiable"}], + } + }, + ) + + self.assertEqual(status, 200) + self.assertEqual(payload["phase"], "D5_TIME_SESSION_KERNEL") + self.assertEqual(payload["rules"][0]["rule_id"], "session_window_verifiable") + 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 a95983b..b0039c5 100644 --- a/tests/unit/test_api_server.py +++ b/tests/unit/test_api_server.py @@ -150,6 +150,22 @@ class ApiServerRoutingTests(unittest.TestCase): self.assertEqual(payload["phase"], "D4_PD_ARRAY_REACTION_KERNEL") self.assertEqual(payload["rule_count"], 5) + def test_routes_trading_time_session_kernel(self) -> None: + status, payload = route_request( + "/trading/time-session-kernel", + {}, + dependencies={ + "trading_time_session_kernel": lambda: { + "phase": "D5_TIME_SESSION_KERNEL", + "rule_count": 6, + } + }, + ) + + self.assertEqual(status, 200) + self.assertEqual(payload["phase"], "D5_TIME_SESSION_KERNEL") + self.assertEqual(payload["rule_count"], 6) + 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 48df6e2..2d05921 100644 --- a/tests/unit/test_backtest_service.py +++ b/tests/unit/test_backtest_service.py @@ -321,6 +321,9 @@ class BacktestOutcomeTests(unittest.TestCase): 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["time_session_kernel_status_counts"]["study_only"], 4) + self.assertEqual(summary["time_session_kernel_reason_counts"]["missing_session_window"], 4) + self.assertEqual(summary["by_time_session_code"]["LONDON"]["count"], 3) 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) @@ -418,6 +421,8 @@ class BacktestOutcomeTests(unittest.TestCase): "entry": {}, "candidate_meta": {}, "candidate_status": "awaiting_confirmation", + "session_code": "LONDON", + "allowed_sessions": ["LONDON"], }, { "candidate_id": 2, @@ -472,6 +477,22 @@ class BacktestOutcomeTests(unittest.TestCase): "management_rules": [{"code": "tp1_partial", "fraction": 0.5}], }, "candidate_status": "executable", + "session_code": "LONDON", + "session_window_state": "inside", + "allowed_sessions": ["LONDON"], + "killzone_name": "LONDON_KILLZONE", + "killzone_state": "active", + "judas_window_state": "cleared", + "opening_range_state": "defined", + "opening_range_id": "or-london", + "opening_range_high": 101.0, + "opening_range_low": 99.0, + "weekly_profile": "midweek_expansion", + "weekly_bias": "bullish_draw_to_bsl", + "news_window_state": "clear", + "high_impact_news": False, + "low_liquidity_window": False, + "session_invalidation": "london_close_or_reclaim", }, ] @@ -487,6 +508,8 @@ class BacktestOutcomeTests(unittest.TestCase): 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") + self.assertEqual(units[0]["time_session_backtest_features"]["session_code"], "LONDON") + self.assertEqual(units[0]["time_session_kernel_status"]["status"], "time_session_mapped") class BacktestServiceRunTests(unittest.TestCase): @@ -739,6 +762,7 @@ class BacktestServiceRunTests(unittest.TestCase): "entry": {}, "invalidations": [], "candidate_meta": {}, + "session_code": "LONDON", "allowed_sessions": ["LONDON"], "primary_draw": {"side": "buy_side"}, }, @@ -786,6 +810,8 @@ class BacktestServiceRunTests(unittest.TestCase): ], }, "invalidations": [], + "session_code": "LONDON", + "session_window_state": "inside", "candidate_meta": { "management_rules": [ {"code": "tp1_partial", "fraction": 0.5}, @@ -793,6 +819,19 @@ class BacktestServiceRunTests(unittest.TestCase): ] }, "allowed_sessions": ["LONDON"], + "killzone_name": "LONDON_KILLZONE", + "killzone_state": "active", + "judas_window_state": "cleared", + "opening_range_state": "defined", + "opening_range_id": "or-london", + "opening_range_high": 101.0, + "opening_range_low": 99.0, + "weekly_profile": "midweek_expansion", + "weekly_bias": "bullish_draw_to_bsl", + "news_window_state": "clear", + "high_impact_news": False, + "low_liquidity_window": False, + "session_invalidation": "london_close_or_reclaim", "primary_draw": {"side": "buy_side"}, }, ] @@ -842,6 +881,8 @@ class BacktestServiceRunTests(unittest.TestCase): ) self.assertEqual(repository.summary["pd_array_reaction_status"]["status"], "pd_array_mapped") self.assertEqual(repository.summary["by_pd_array_type"]["fvg"]["count"], 1) + self.assertEqual(repository.summary["time_session_kernel_status"]["status"], "time_session_mapped") + self.assertEqual(repository.summary["by_time_session_code"]["LONDON"]["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 ffe1924..9971688 100644 --- a/tests/unit/test_documentation_consistency.py +++ b/tests/unit/test_documentation_consistency.py @@ -22,6 +22,7 @@ ACTIVE_TOP_LEVEL_DOCS = { "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_DoctrineKernel_TimeSessionKernel_D5_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 bdced11..8a87f35 100644 --- a/tests/unit/test_ict_doctrine.py +++ b/tests/unit/test_ict_doctrine.py @@ -5,19 +5,24 @@ from src.services.ict_mainline import ( DOCTRINE_LIQUIDITY_PHASE, DOCTRINE_PLAYBOOK_PHASE, DOCTRINE_REACTION_PHASE, + DOCTRINE_SESSION_PHASE, build_doctrine_backtest_cohort, build_liquidity_backtest_features, build_pd_array_backtest_features, + build_time_session_backtest_features, build_doctrine_playbook_gate, build_doctrine_playbook_registry, build_doctrine_rule_matrix, build_liquidity_kernel_registry, build_pd_array_reaction_registry, + build_time_session_kernel_registry, evaluate_doctrine_research_status, evaluate_liquidity_kernel_status, evaluate_pd_array_reaction_status, + evaluate_time_session_kernel_status, summarize_liquidity_kernel_statuses, summarize_pd_array_reaction_statuses, + summarize_time_session_kernel_statuses, summarize_doctrine_rule_gaps, ) @@ -48,6 +53,8 @@ class IctDoctrineKernelTests(unittest.TestCase): 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.assertIn("killzone_state", playbook["session_timing_rule"]["required_fields"]) + self.assertIn("weekly_profile", playbook["session_timing_rule"]["required_fields"]) self.assertTrue(playbook["promotion_gate"]["requires_backtest_quality_gate"]) def test_d3_liquidity_kernel_registry_exposes_target_ranking_rules(self) -> None: @@ -163,6 +170,50 @@ class IctDoctrineKernelTests(unittest.TestCase): self.assertGreater(summary["reason_counts"]["fvg_is_not_setup"], 0) self.assertEqual(summary["array_type_counts"]["fvg"], 2) + def test_d5_time_session_kernel_registry_exposes_session_quality_rules(self) -> None: + registry = build_time_session_kernel_registry() + + self.assertEqual(registry["phase"], DOCTRINE_SESSION_PHASE) + self.assertEqual(registry["status"], "active") + self.assertGreaterEqual(registry["rule_count"], 6) + self.assertIn("session_window_state", registry["required_backtest_feature_fields"]) + self.assertIn("Asia / London / NY AM / NY PM are context windows, not entry signals.", registry["hard_rules"]) + + def test_outside_session_stays_study_only(self) -> None: + status = evaluate_time_session_kernel_status( + { + "session_code": "OFF_HOURS", + "session_window_state": "outside_window", + "allowed_sessions": ["LONDON"], + } + ) + + self.assertFalse(status["passed"]) + self.assertEqual(status["phase"], DOCTRINE_SESSION_PHASE) + self.assertEqual(status["status"], "study_only") + self.assertIn("outside_session_window", status["reasons"]) + self.assertIn("session_not_allowed", status["reasons"]) + + def test_complete_time_session_payload_maps_without_execution_permission(self) -> None: + status = evaluate_time_session_kernel_status(_complete_time_session_payload()) + + self.assertTrue(status["passed"]) + self.assertEqual(status["status"], "time_session_mapped") + self.assertEqual(status["execution_boundary"], "read_only_analysis_no_order_submission") + + def test_time_session_summary_counts_blockers(self) -> None: + summary = summarize_time_session_kernel_statuses( + [ + {"session_code": "OFF_HOURS", "session_window_state": "outside_window"}, + _complete_time_session_payload(), + ] + ) + + self.assertEqual(summary["item_count"], 2) + self.assertEqual(summary["passed_count"], 1) + self.assertGreater(summary["reason_counts"]["outside_session_window"], 0) + self.assertEqual(summary["session_code_counts"]["LONDON"], 1) + def test_playbook_gate_blocks_missing_and_passes_registered_schema(self) -> None: missing = build_doctrine_playbook_gate({"model_code": "SWEEP_MSS_FVG"}) @@ -251,6 +302,7 @@ class IctDoctrineKernelTests(unittest.TestCase): "sweep_confirmed": True, "post_take_reaction": "reclaim_displacement", **_complete_pd_array_payload(), + **_complete_time_session_payload(), "summary": {"quality_gate": {"passed": True, "reasons": []}}, } ) @@ -260,6 +312,7 @@ class IctDoctrineKernelTests(unittest.TestCase): 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") + self.assertEqual(cohort["time_session_kernel_status"]["status"], "time_session_mapped") missing = build_doctrine_backtest_cohort({"model_code": "SWEEP_MSS_FVG"}) @@ -267,6 +320,7 @@ class IctDoctrineKernelTests(unittest.TestCase): 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"]) + self.assertIn("missing_session_window", missing["promotion_gate"]["reasons"]) def test_liquidity_backtest_features_extract_nested_candidate_fields(self) -> None: features = build_liquidity_backtest_features( @@ -310,6 +364,27 @@ class IctDoctrineKernelTests(unittest.TestCase): self.assertEqual(features["pd_array_rank"], "primary") self.assertEqual(features["array_location"], "discount") + def test_time_session_backtest_features_extract_nested_candidate_fields(self) -> None: + features = build_time_session_backtest_features( + { + "candidate": { + "session": { + "session_code": "NY_AM", + "killzone_name": "NY_AM_KILLZONE", + "opening_range_state": "defined", + }, + "confirmation_state": {"session_invalidation": "ny_lunch_cutoff"}, + }, + "session_filter": "covered", + "weekly_profile": "tuesday_expansion", + } + ) + + self.assertEqual(features["session_code"], "NY_AM") + self.assertEqual(features["session_window_state"], "inside") + self.assertEqual(features["killzone_name"], "NY_AM_KILLZONE") + self.assertEqual(features["opening_range_state"], "defined") + def _complete_pd_array_payload() -> dict: return { @@ -330,5 +405,35 @@ def _complete_pd_array_payload() -> dict: } +def _complete_time_session_payload() -> dict: + return { + "session_code": "LONDON", + "session_window_state": "inside", + "allowed_sessions": ["LONDON", "NY_AM"], + "session_start_ts": "2026-05-22T06:00:00Z", + "session_end_ts": "2026-05-22T09:00:00Z", + "killzone_name": "LONDON_KILLZONE", + "killzone_state": "active", + "killzone_start_ts": "2026-05-22T07:00:00Z", + "killzone_end_ts": "2026-05-22T09:00:00Z", + "judas_window_state": "cleared", + "inducement_side": "sell_side", + "post_manipulation_confirmation": "mss_after_sweep", + "opening_range_state": "defined", + "opening_range_id": "or-london-20260522", + "opening_range_high": 101.0, + "opening_range_low": 99.0, + "opening_range_reaction": "rejected_low", + "weekly_profile": "midweek_expansion", + "weekly_bias": "bearish_draw_to_ssl", + "day_of_week": "Friday", + "weekly_range_state": "inside_weekly_range", + "news_window_state": "clear", + "high_impact_news": False, + "low_liquidity_window": False, + "session_invalidation": "london_close_or_reclaim", + } + + if __name__ == "__main__": unittest.main()