diff --git a/README.md b/README.md index 1d42eac..2b19126 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,10 @@ test "$(docker inspect -f '{{.State.Health.Status}}' ai-ict)" = "healthy" - `.venv/bin/python scripts/run_offline_checks.py` - run the broader offline check suite, including the M35 acceptance posture through the Training Case acceptance manifest for focused milestone test suites, the Training Case package smoke registered as `training_case_package`, dashboard syntax validation as `dashboard_js_syntax`, and dashboard and public docs privacy smoke coverage; this public validation runs as offline checks without external services - `.venv/bin/python scripts/run_training_case_package.py --help` - show the offline package smoke help for public package import/export behavior, public export/public output semantics, and stable public error codes for invalid input paths and invalid output targets - `.venv/bin/python scripts/run_training_case_package.py --input tests/fixtures/training_case_package_m6.json --output /tmp/training_case_public_export.json` - run the Training Case offline package smoke, an import/export smoke with local files, and write public output as the public export in the public package shape; exported public JSON omits internal solution details +- `.venv/bin/python scripts/run_training_case_generator.py --help` - show the offline prototype generator help for read-only Training case draft generation from trading-derived seed events +- `.venv/bin/python scripts/run_training_case_generator.py --input tests/fixtures/training_case_generator_seed_v1.json --output /tmp/generated_training_case_drafts.json` - run the offline Training case generator prototype and write a public draft package without touching Training database tables +- `.venv/bin/python scripts/run_historical_compatibility_repair.py` - scan for older stored signal/FVG/MSS/liquidity compatibility issues in the connected database without applying changes +- `.venv/bin/python scripts/run_historical_compatibility_repair.py --apply-safe-repairs` - apply only the safe historical compatibility repairs for signal invalidations plus MSS/FVG meta backfills; liquidity duplicate groups remain manual-review only - `python3 scripts/run_migration_static_checks.py` - check migration order and required core schema without connecting to the database - `python3 scripts/run_script_static_checks.py` - check automation scripts for unsafe ambient environment fallback patterns - `python3 scripts/run_replay_file.py ` - run offline no-lookahead candle replay without database access @@ -179,6 +183,7 @@ Optional utility JSON output variables: - `CHECK_DB_OUTPUT_JSON` - `CHECK_DB_CONNECTION_OUTPUT_JSON` +- `HISTORICAL_REPAIR_OUTPUT_JSON` - `MIGRATION_CHECKS_OUTPUT_JSON` - `SCRIPT_CHECKS_OUTPUT_JSON` - `SHOW_MODELS_OUTPUT_JSON` diff --git a/RUNBOOK.md b/RUNBOOK.md index 2a0020c..cee33a3 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -42,6 +42,107 @@ python3 scripts/run_migration_static_checks.py python3 scripts/run_script_static_checks.py ``` +## Training Generator Prototype + +The current generator work is a read-only offline prototype. It does not write Training cases to database-backed tables. + +Show help: + +```bash +cd /home/yuqei/project/ai-exchange +.venv/bin/python scripts/run_training_case_generator.py --help +``` + +Run the prototype against the tracked seed fixture and write a public draft package: + +```bash +cd /home/yuqei/project/ai-exchange +.venv/bin/python scripts/run_training_case_generator.py \ + --input tests/fixtures/training_case_generator_seed_v1.json \ + --output /tmp/generated_training_case_drafts.json +``` + +Prototype intent: + +- Input is trading-derived seed events only +- Output is a public Training case draft package +- The prototype is offline, read-only, and rollback-friendly +- Public output omits private solution material and keeps answer content empty + +## Historical Compatibility Repair + +The current historical compatibility repair path is intentionally safe by default: + +- default mode is read-only scan +- safe apply mode repairs only signal invalidations plus MSS/FVG meta compatibility fields +- liquidity duplicate groups are reported for manual review and are not auto-merged + +Dry-run scan: + +```bash +cd /home/yuqei/project/ai-exchange +.venv/bin/python scripts/run_historical_compatibility_repair.py +``` + +Machine-readable dry-run summary: + +```bash +cd /home/yuqei/project/ai-exchange +HISTORICAL_REPAIR_OUTPUT_JSON=1 .venv/bin/python scripts/run_historical_compatibility_repair.py +``` + +Apply only the safe automatic repairs: + +```bash +cd /home/yuqei/project/ai-exchange +.venv/bin/python scripts/run_historical_compatibility_repair.py --apply-safe-repairs +``` + +## Git Remote + +This repo is currently configured to use SSH for `origin`: + +```bash +cd /home/yuqei/project/ai-exchange +git remote -v +``` + +Expected `origin`: + +```text +origin gitea-water:yuqei/ai-exchange.git (fetch) +origin gitea-water:yuqei/ai-exchange.git (push) +``` + +The SSH host alias is provided by `/root/.ssh/config`: + +```sshconfig +Host gitea-water + HostName 192.168.3.90 + Port 2222 + User git + IdentityFile /root/.ssh/water_audit_gitea_ed25519 + IdentitiesOnly yes + StrictHostKeyChecking accept-new +``` + +Recommended checks before pushing: + +```bash +ssh -T -o BatchMode=yes gitea-water +git ls-remote gitea-water:yuqei/ai-exchange.git HEAD +git push origin main +``` + +If `origin` is accidentally changed back to HTTP, restore SSH with: + +```bash +cd /home/yuqei/project/ai-exchange +git remote set-url origin gitea-water:yuqei/ai-exchange.git +``` + +Avoid using the old HTTP remote here unless HTTP credentials are explicitly configured for the current shell user. + ## Docker Repo-local compose: diff --git a/SESSION.md b/SESSION.md index 4db3cfb..f61b8dd 100644 --- a/SESSION.md +++ b/SESSION.md @@ -2,7 +2,7 @@ ## Current Goal -Continue downstream consumer hardening after report/API structure-state propagation, with the next focus on dashboard visibility and invalidation-code alignment. +Current roadmap items are implemented. The next practical step is to run the new historical compatibility repair path in dry-run mode against a real database and decide whether to apply the safe repairs, or define the next roadmap increment. ## Current Branch @@ -10,29 +10,42 @@ Continue downstream consumer hardening after report/API structure-state propagat ## Last Updated -2026-05-03 UTC +2026-05-04 UTC ## Completed In Last Session -- Propagated normalized signal structure-state fields through the public `/signals` API surface, keeping raw `meta` while exposing stable top-level fields such as `structure_state`, `mss_status_reason`, and `fvg_status_reason`. -- Enriched `IntradayReportService` so reports now surface MSS/FVG `state` and `status_reason` instead of relying only on `accepted` and coarse FVG status checks. -- Updated intraday invalidation logic to emit richer downstream reasons like `mss_invalidated`, `mss_rejected`, `fvg_mitigated`, and `fvg_invalidated`, while carrying FVG data-quality warnings through the report output. -- Enriched `PostTradeReviewService` with a `signal_context` section that exposes `structure_state`, MSS/FVG reasons, and FVG data-quality warnings for downstream review consumers. -- Added focused report/API unit coverage for signal-row normalization and richer report-state propagation. +- Added a historical compatibility repair core module in [src/services/maintenance/historical_compatibility.py](/home/yuqei/project/ai-exchange/src/services/maintenance/historical_compatibility.py) covering three safe auto-repair classes: signal invalidation normalization, MSS meta backfill, and FVG meta compatibility backfill. +- Added [scripts/run_historical_compatibility_repair.py](/home/yuqei/project/ai-exchange/scripts/run_historical_compatibility_repair.py) with default dry-run behavior, explicit safe-apply flags, JSON output support, and manual-review reporting for liquidity duplicate groups. +- Kept liquidity duplicate handling scan-only by default; the script reports duplicate groups for review instead of auto-merging or mutating them. +- Added focused unit coverage for repair planning logic, script dry-run/apply behavior, database-unavailable JSON behavior, and runtime script registry compatibility. +- Documented the new historical compatibility repair script in `README.md` and `RUNBOOK.md`. - Revalidated the repository with `.venv/bin/python3 -m unittest discover -s tests/unit -p "test_*.py"`, `.venv/bin/python3 scripts/run_offline_checks.py`, and `.venv/bin/python3 scripts/check_local_demo_readiness.py`. ## Current Working State -- `git status --short --branch` shows `## main...origin/main`; the worktree is still dirty from earlier doc/backtest/liquidity/dev-entrypoint sessions, and `uv.lock` remains untracked. +- `git status --short --branch` now shows `## main...origin/main` with local changes in `RUNBOOK.md`, `SESSION.md`, `TASKS.md`, the signal/report/frontend/backtest/execution/training files touched in recent sessions, plus unrelated untracked content under `docs/AI_Legal_Content_Intelligence_Upgrade_Plan_v1.md` and `legal_content/`. +- `origin` now uses SSH: `gitea-water:yuqei/ai-exchange.git`; SSH auth for this host alias works from the current shell user. - MSS candidates now carry `meta.state` and `meta.status_reason`; accepted structures use `retest_held`, rejected structures use `retest_not_observed`, and invalidated structures use `old_control_reclaimed`. - MSS persistence now records the latest structure state even when `accepted = false`, so downstream consumers no longer read stale accepted-only MSS rows. - MSS detection still collapses duplicate same-direction displacement context at the same `end_ts` to one deterministic candidate, preferring the highest event id. - Signal-side MSS/FVG fetches now include the latest downstream state regardless of actionability, with deterministic same-timestamp tie-breaks. - `validate_signal_context` now returns explicit downstream structure outcomes plus richer MSS/FVG state reasons instead of a single coarse non-actionable result. - Signal metadata now exposes `structure_state`, `mss_state`, `mss_status_reason`, `fvg_status_reason`, `fvg_touch_count`, and FVG `data_quality_warnings`. -- `/signals` API rows now normalize key signal context fields to top-level output keys, so API consumers no longer need to inspect nested `meta` for core structure-state reasons. +- `/signals` API rows now normalize key signal context fields to top-level output keys and add `invalidations_normalized` plus `signal_context`, so API consumers no longer need to inspect nested `meta` for either structure-state reasons or normalized invalidations. - Intraday reports now expose structure `state` / `status_reason`, FVG `status_reason`, and signal-side richer structure context directly in the report payload. -- Post-trade review reports now expose a `signal_context` block with richer structure-state and data-quality fields. +- Post-trade review reports now expose a `signal_context` block with richer structure-state, normalized invalidations, and data-quality fields. +- Execution audit, execution settlement, post-trade review, and backtest summary now share the same lifecycle vocabulary for `missed`, `invalidated_before_entry`, `tp1_reached`, `tp2_closed`, and `stopped_out`. +- Backtest summary now exposes top-level `lifecycle_states` in addition to the older raw `outcomes` buckets. +- Backtest summary now also exposes `by_symbol`, using `instruments.symbol` when available via repository join and `signal_symbol` in outcome metadata downstream. +- Dashboard backtest cards now show a compact symbol-scope summary derived from `summary.by_symbol`. +- Training case list/detail/export surfaces now expose a public read-only `quality` object with deterministic `score`, `rank`, and `signals`. +- Training dashboard case cards and case detail now display the derived quality signal directly, while progress/attempt flows remain unchanged. +- Training case list/export service/API surfaces now also support stable selector inputs for `quality_rank`, `min_quality_score`, and `sort_by`. +- The generator prototype now exists as an offline-only flow: trading-derived seed JSON -> public Training draft package JSON, with empty `answer` content and no database writes. +- Signal-side FVG selection is now explicit and deterministic: exact MSS displacement match wins, otherwise newest same-direction wins, otherwise newest overall wins. +- Historical compatibility repair now exists as a script-backed path: default dry-run scan, optional safe apply for signal invalidations plus MSS/FVG meta, and manual-review-only reporting for liquidity duplicate groups. +- `TASKS.md` now reflects the intended development sequence more explicitly: + there are no active roadmap items left after the current sequence. - FVG state metadata in the dirty worktree still includes `last_touch_ts`, `touch_count`, `status_reason`, and gap warnings. - Replay-backed no-lookahead backtest mode remains present and validation is green. - `scripts/run_dev.py` still starts the persistent local API/dashboard dev server using `AI_ICT_API_HOST` / `AI_ICT_API_PORT`. @@ -41,7 +54,10 @@ Continue downstream consumer hardening after report/API structure-state propagat ## Exact Next Step -Surface the normalized signal structure-state fields in dashboard table/filter UX and decide whether legacy `trade_signal.invalidations` strings should be normalized to the same machine-readable structure-state reason codes now used in signal/report/API metadata. +If continuing from the current implementation, do this next: +1. run `.venv/bin/python scripts/run_historical_compatibility_repair.py` against the target database in dry-run mode and inspect candidate repair counts plus liquidity duplicate groups; +2. if the dry-run output is acceptable, execute `.venv/bin/python scripts/run_historical_compatibility_repair.py --apply-safe-repairs` in a controlled environment; +3. after the real repair run, decide whether the roadmap should branch into a new migration-backed cleanup phase or shift to a new feature roadmap. ## Validation Commands @@ -53,9 +69,10 @@ Surface the normalized signal structure-state fields in dashboard table/filter U ## Risks Or Open Questions -- `uv.lock` is intentionally left visible for version control, but it is still untracked until included in a future commit. -- Liquidity merge-dedupe prevents new duplicate inserts at the same collapsed level, but it does not retro-clean older duplicate rows already stored in a database. -- The dashboard still shows the new structure-state fields mainly through raw JSON detail rather than first-class table columns or filters. -- `trade_signal.invalidations` still uses legacy human-readable strings like `mss invalidated` / `fvg fully mitigated`, while newer signal/report/API fields now use machine-readable state codes; that contract mismatch is still unresolved. -- Signal-side FVG selection still prefers displacement context first; if newer non-contextual FVGs should override that preference, the selection rule may need further refinement against expected trading behavior. +- The dashboard now surfaces the new structure-state fields directly, but signal detail is still rendered as structured JSON rather than a dedicated custom detail card. +- Legacy `trade_signal.invalidations` strings are now normalized for API/report/dashboard consumers, but historical rows still physically store mixed old/new string forms in the database until a later migration or cleanup pass. +- The new FVG selector rule is explicit, but it still reflects one specific interpretation of “recent plus context”; if trading review later wants recency to dominate exact context in some setups, that will require another deliberate rule change rather than a repository ordering tweak. +- The generator prototype is intentionally offline and read-only; it is not yet wired into repository-backed Training flows or any public generation route. +- The historical repair path is script-based rather than migration-based. That keeps it safer and reversible, but it also means real data repair still depends on an explicit operator run rather than automatic deploy-time migration. +- The current worktree is no longer fully clean because this session added both code and documentation changes, and there is unrelated untracked legal/planning content in the repo; decide later whether to commit or separate that unrelated content. - Training remains a maintenance-first module for now; upstream trading semantics should stabilize before adding larger Training features. diff --git a/TASKS.md b/TASKS.md index a9ca887..a985781 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,15 +2,15 @@ ## In Progress -- [ ] Surface normalized signal structure-state fields in the dashboard UX and align legacy `trade_signal.invalidations` strings with the newer machine-readable structure-state reason contract. +- [ ] No active in-progress items beyond the current roadmap. ## Next Up -- [ ] Keep the Training M40 public contract green while upstream trading semantics are refined. +- [ ] No additional next-up items beyond the current roadmap. ## Backlog -- [ ] Keep this list aligned with the real product backlog instead of mixing notes into chat history. +- [ ] No additional backlog items beyond the current roadmap. ## Blocked @@ -18,6 +18,14 @@ ## Done +- [x] 2026-05-04 完成历史数据修复与兼容迁移:新增历史兼容修复核心模块与 `scripts/run_historical_compatibility_repair.py`,默认 dry-run 扫描旧 `invalidations` 文案、MSS/FVG 粗元数据与 liquidity duplicate group,并支持仅对 signal invalidations 与 MSS/FVG meta 执行 safe repairs;补充 focused tests、README/RUNBOOK 入口说明,并通过单测、offline checks 和 local demo readiness。 +- [x] 2026-05-04 完成 FVG 候选优先级规则再收敛:signal 层不再被 repository 的隐式 SQL 偏好控制,而是显式使用“先取最近候选,再优先与当前 MSS displacement 精确匹配,其次同方向,最后回退到最新 FVG”的规则;已通过 focused FVG/signal tests、全量单测、offline checks 和 local demo readiness。 +- [x] 2026-05-04 完成自动 Training case generator 预研:新增只读离线原型 `src/services/training/case_generator.py` 与 `scripts/run_training_case_generator.py`,输入 trading-derived seed package,输出公开可消费的 Training case 草案 package;新增 fixture、focused tests、README/RUNBOOK 入口说明;已通过单测、offline checks 和 local demo readiness。 +- [x] 2026-05-04 完成 Training case quality scorer / selector:在现有公开只读 `quality` 对象上新增稳定的 quality filter/sort 能力,支持 `quality_rank`、`min_quality_score`、`sort_by` 的服务/API 选择器入口,且默认行为不变;已通过单测、offline checks 和 local demo readiness。 +- [x] 2026-05-04 完成 Training M40 维护优先下的上游 case quality 提升:新增公开只读 `quality` 信号对象,覆盖 Training case list/detail/export 和 dashboard 展示;质量信号由公开字段衍生,不泄漏私有解题信息;已通过单测、offline checks 和 local demo readiness。 +- [x] 2026-05-04 完成 Backtest `by_symbol` 质量拆分:repository 直接联表拿 `instruments.symbol`,backtest outcome `meta` 写入 `signal_symbol`,summary 新增 `by_symbol`,dashboard backtest 卡片新增 symbol 摘要入口;已通过单测、offline checks 和 local demo readiness。 +- [x] 2026-05-04 完成 Execution / Backtest / Report 生命周期与指标口径统一:新增共享 backtest lifecycle contract,execution audit / settlement / dashboard item / post-trade review 与 backtest summary 统一使用 `missed`、`invalidated_before_entry`、`tp1_reached`、`tp2_closed`、`stopped_out` 等 canonical 状态;已通过单测、offline checks 和 local demo readiness。 +- [x] 2026-05-03 完成 dashboard richer state UX 与 `invalidations` 状态码统一:signals 表格直接展示和筛选 `structure_state` / `mss_status_reason` / `fvg_status_reason` / warning,`/signals` API 输出新增 `invalidations_normalized` 与 `signal_context`,并对旧的人类可读 invalidations 做兼容归一化;已通过单测、offline checks 和 local demo readiness。 - [x] 2026-05-03 Propagated richer `structure_state` / MSS / FVG reason fields into report and API consumers, normalized `/signals` output, enriched report payloads, and revalidated with unit tests, offline checks, and local demo readiness. - [x] 2026-05-03 Converged MSS `accepted`/`rejected`/`invalidated` semantics into explicit state metadata, persisted the latest MSS state for downstream consumers, exposed richer MSS/FVG reasons in signal metadata, and revalidated with unit tests, offline checks, and local demo readiness. - [x] 2026-05-03 Fixed the `run_dev.py` exit bug by turning it into the persistent local API/dashboard dev entrypoint, updated docs, and revalidated with unit tests, offline checks, and local demo readiness. diff --git a/scripts/run_historical_compatibility_repair.py b/scripts/run_historical_compatibility_repair.py new file mode 100644 index 0000000..76b6833 --- /dev/null +++ b/scripts/run_historical_compatibility_repair.py @@ -0,0 +1,242 @@ +from pathlib import Path +import argparse +import json +import os +import sys + +from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.db.engine import engine +from src.services.maintenance.historical_compatibility import ( + build_historical_compatibility_summary, + plan_fvg_meta_repairs, + plan_mss_meta_repairs, + plan_trade_signal_invalidation_repairs, + summarize_liquidity_duplicate_groups, +) + + +def run_historical_compatibility_repair( + *, + apply_signal_invalidations: bool = False, + apply_mss_meta: bool = False, + apply_fvg_meta: bool = False, +) -> dict: + dry_run = not any((apply_signal_invalidations, apply_mss_meta, apply_fvg_meta)) + with engine.begin() as connection: + signal_rows = _fetch_trade_signal_rows(connection) + mss_rows = _fetch_mss_rows(connection) + fvg_rows = _fetch_fvg_rows(connection) + liquidity_duplicate_rows = _fetch_liquidity_duplicate_groups(connection) + + signal_repairs = plan_trade_signal_invalidation_repairs(signal_rows) + mss_repairs = plan_mss_meta_repairs(mss_rows) + fvg_repairs = plan_fvg_meta_repairs(fvg_rows) + liquidity_duplicates = summarize_liquidity_duplicate_groups(liquidity_duplicate_rows) + + applied_repairs = { + "signal_invalidations": 0, + "mss_meta": 0, + "fvg_meta": 0, + } + + if apply_signal_invalidations: + for repair in signal_repairs: + _update_trade_signal_invalidations(connection, repair) + applied_repairs["signal_invalidations"] = len(signal_repairs) + if apply_mss_meta: + for repair in mss_repairs: + _update_mss_meta(connection, repair) + applied_repairs["mss_meta"] = len(mss_repairs) + if apply_fvg_meta: + for repair in fvg_repairs: + _update_fvg_meta(connection, repair) + applied_repairs["fvg_meta"] = len(fvg_repairs) + + return build_historical_compatibility_summary( + signal_repairs=signal_repairs, + mss_repairs=mss_repairs, + fvg_repairs=fvg_repairs, + liquidity_duplicates=liquidity_duplicates, + applied_repairs=applied_repairs, + dry_run=dry_run, + ) + + +def _fetch_trade_signal_rows(connection) -> list[dict]: + sql = text( + """ + select id, invalidations + from trade_signals + order by id asc + """ + ) + return [dict(row) for row in connection.execute(sql).mappings().all()] + + +def _fetch_mss_rows(connection) -> list[dict]: + sql = text( + """ + select id, accepted, meta + from structure_events + where event_type = 'MSS' + order by id asc + """ + ) + return [dict(row) for row in connection.execute(sql).mappings().all()] + + +def _fetch_fvg_rows(connection) -> list[dict]: + sql = text( + """ + select id, status, meta + from fvg_zones + order by id asc + """ + ) + return [dict(row) for row in connection.execute(sql).mappings().all()] + + +def _fetch_liquidity_duplicate_groups(connection) -> list[dict]: + sql = text( + """ + select + instrument_id, + timeframe, + side, + price_low, + price_high, + count(*) as duplicate_count, + array_agg(id order by created_ts desc, id desc) as ids + from liquidity_pools + where status = 'untouched' + group by instrument_id, timeframe, side, price_low, price_high + having count(*) > 1 + order by count(*) desc, instrument_id asc, timeframe asc, side asc + """ + ) + return [dict(row) for row in connection.execute(sql).mappings().all()] + + +def _update_trade_signal_invalidations(connection, repair: dict) -> None: + sql = text( + """ + update trade_signals + set invalidations = cast(:invalidations as jsonb) + where id = :id + """ + ) + connection.execute( + sql, + { + "id": int(repair["id"]), + "invalidations": json.dumps(repair["invalidations"]), + }, + ) + + +def _update_mss_meta(connection, repair: dict) -> None: + sql = text( + """ + update structure_events + set meta = cast(:meta as jsonb) + where id = :id + """ + ) + connection.execute( + sql, + { + "id": int(repair["id"]), + "meta": json.dumps(repair["meta"], default=str), + }, + ) + + +def _update_fvg_meta(connection, repair: dict) -> None: + sql = text( + """ + update fvg_zones + set meta = cast(:meta as jsonb) + where id = :id + """ + ) + connection.execute( + sql, + { + "id": int(repair["id"]), + "meta": json.dumps(repair["meta"], default=str), + }, + ) + + +def main(argv=None, env=None) -> int: + env = os.environ if env is None else env + parser = _build_parser() + args = parser.parse_args(argv) + apply_safe_repairs = bool(args.apply_safe_repairs) + apply_signal_invalidations = bool(args.apply_signal_invalidations or apply_safe_repairs) + apply_mss_meta = bool(args.apply_mss_meta or apply_safe_repairs) + apply_fvg_meta = bool(args.apply_fvg_meta or apply_safe_repairs) + + try: + summary = run_historical_compatibility_repair( + apply_signal_invalidations=apply_signal_invalidations, + apply_mss_meta=apply_mss_meta, + apply_fvg_meta=apply_fvg_meta, + ) + except SQLAlchemyError as exc: + summary = { + "passed": False, + "dry_run": not any((apply_signal_invalidations, apply_mss_meta, apply_fvg_meta)), + "error_type": exc.__class__.__name__, + "error": str(exc), + } + _print_summary(summary, json_output=_get_bool(env, "HISTORICAL_REPAIR_OUTPUT_JSON", False)) + return 2 + + _print_summary(summary, json_output=_get_bool(env, "HISTORICAL_REPAIR_OUTPUT_JSON", False)) + return 0 + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="scripts/run_historical_compatibility_repair.py", + description="Scan historical compatibility issues and optionally apply safe normalization repairs.", + ) + parser.add_argument("--apply-safe-repairs", action="store_true", help="Apply signal invalidation, MSS meta, and FVG meta repairs.") + parser.add_argument("--apply-signal-invalidations", action="store_true", help="Apply only trade_signals invalidation normalization.") + parser.add_argument("--apply-mss-meta", action="store_true", help="Apply only MSS meta backfill for state/status_reason.") + parser.add_argument("--apply-fvg-meta", action="store_true", help="Apply only FVG meta backfill for status_reason/default compatibility fields.") + return parser + + +def _print_summary(summary: dict, json_output: bool) -> None: + if json_output: + print(json.dumps(summary, ensure_ascii=False, sort_keys=True, default=str)) + return + print(f"Historical compatibility repair passed: {summary['passed']}") + print(f"Dry run: {summary.get('dry_run')}") + if not summary["passed"]: + print(f"Database unavailable for historical repair: {summary.get('error_type')}") + return + candidate_repairs = summary.get("candidate_repairs") or {} + print(f"Signal invalidation repairs: {candidate_repairs.get('signal_invalidations', 0)}") + print(f"MSS meta repairs: {candidate_repairs.get('mss_meta', 0)}") + print(f"FVG meta repairs: {candidate_repairs.get('fvg_meta', 0)}") + print(f"Liquidity duplicate groups: {candidate_repairs.get('liquidity_duplicate_groups', 0)}") + + +def _get_bool(env, key: str, default: bool) -> bool: + value = env.get(key) + if value is None or value == "": + return default + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_training_case_generator.py b/scripts/run_training_case_generator.py new file mode 100644 index 0000000..37ae873 --- /dev/null +++ b/scripts/run_training_case_generator.py @@ -0,0 +1,129 @@ +from pathlib import Path +import argparse +import json +import sys +from typing import Optional + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.services.training import generate_public_training_case_drafts + + +DEFAULT_FIXTURE = ROOT / "tests" / "fixtures" / "training_case_generator_seed_v1.json" + + +class _PublicCliError(Exception): + def __init__(self, code: str) -> None: + super().__init__(code) + self.code = code + + +def run_generator(input_path: Path, output_path: Optional[Path] = None) -> dict: + payload = _load_payload(input_path) + generated = generate_public_training_case_drafts(payload) + + if output_path is not None: + _write_output(output_path, generated) + + event_types = sorted( + { + item.get("scenario", {}).get("source_event_type") + for item in generated.get("items", []) + if isinstance(item, dict) + } + ) + return { + "passed": True, + "input": _display_path(input_path), + "output": _display_path(output_path) if output_path is not None else None, + "generated": len(generated.get("items", [])), + "event_types": event_types, + } + + +def main(argv=None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + input_path = Path(args.input) + output_path = Path(args.output) if args.output else None + try: + summary = run_generator(input_path=input_path, output_path=output_path) + except (_PublicCliError, OSError, ValueError, json.JSONDecodeError) as exc: + print(json.dumps(_error_summary(input_path, output_path, exc), ensure_ascii=False)) + return 2 + + print(json.dumps(summary, ensure_ascii=False)) + return 0 + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="scripts/run_training_case_generator.py", + description="Offline prototype: generate public Training case drafts from trading-derived seed events.", + epilog=( + "Reads a local seed JSON package and writes a public draft package when --output is set. " + "The prototype is read-only and does not write to database-backed Training tables." + ), + ) + parser.add_argument("--input", default=str(DEFAULT_FIXTURE), help=f"Seed JSON path (default: {DEFAULT_FIXTURE.relative_to(ROOT)}).") + parser.add_argument("--output", help="Optional output path for generated public draft package JSON.") + return parser + + +def _load_payload(input_path: Path) -> dict: + with input_path.open("r", encoding="utf-8") as handle: + payload = json.load(handle) + if not isinstance(payload, dict): + raise ValueError("invalid_json_body") + return payload + + +def _write_output(output_path: Path, payload: dict) -> None: + try: + if output_path.exists() and output_path.is_dir(): + raise _PublicCliError("output_path_is_directory") + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + except _PublicCliError: + raise + except OSError as exc: + raise _PublicCliError("output_path_unwritable") from exc + + +def _display_path(path: Optional[Path]) -> Optional[str]: + if path is None: + return None + try: + return str(path.resolve().relative_to(ROOT)) + except ValueError: + return str(path) + + +def _public_error(exc: Exception) -> str: + if isinstance(exc, _PublicCliError): + return exc.code + if isinstance(exc, IsADirectoryError): + return "input_path_is_directory" + if isinstance(exc, FileNotFoundError): + return "input_file_not_found" + if isinstance(exc, json.JSONDecodeError): + return "invalid_json_body" + return str(exc) + + +def _error_summary(input_path: Path, output_path: Optional[Path], exc: Exception) -> dict: + return { + "passed": False, + "input": _display_path(input_path), + "output": _display_path(output_path), + "error": _public_error(exc), + } + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/api/server.py b/src/api/server.py index 117cb70..cb1cf79 100644 --- a/src/api/server.py +++ b/src/api/server.py @@ -18,6 +18,9 @@ from src.api.training import ( get_training_cases, get_training_progress, import_training_case_package, + parse_min_quality_score_query, + parse_quality_rank_query, + parse_quality_sort_query, submit_training_attempt, ) from src.services.execution import ExecutionService @@ -95,25 +98,45 @@ def route_request( skill_tag = _get_single(query, "skill_tag") instrument_id = _get_optional_int(query, "instrument_id") limit = _get_limit(query, "limit", 100, max_value=MAX_TRAINING_LIMIT) + quality_rank = parse_quality_rank_query(_get_single(query, "quality_rank")) + min_quality_score = parse_min_quality_score_query(_get_single(query, "min_quality_score")) + sort_by = parse_quality_sort_query(_get_single(query, "sort_by")) + case_kwargs = { + "status": status, + "skill_tag": skill_tag, + "instrument_id": instrument_id, + "limit": limit, + } + if quality_rank is not None: + case_kwargs["quality_rank"] = quality_rank + if min_quality_score is not None: + case_kwargs["min_quality_score"] = min_quality_score + if sort_by is not None: + case_kwargs["sort_by"] = sort_by return 200, { - "items": deps["training_cases"]( - status=status, - skill_tag=skill_tag, - instrument_id=instrument_id, - limit=limit, - ) + "items": deps["training_cases"](**case_kwargs) } if method == "GET" and path == "/training/cases/export": status = _get_single(query, "status") skill_tag = _get_single(query, "skill_tag") instrument_id = _get_optional_int(query, "instrument_id") limit = _get_limit(query, "limit", 100, max_value=MAX_TRAINING_LIMIT) - return 200, deps["training_export_cases"]( - status=status, - skill_tag=skill_tag, - instrument_id=instrument_id, - limit=limit, - ) + quality_rank = parse_quality_rank_query(_get_single(query, "quality_rank")) + min_quality_score = parse_min_quality_score_query(_get_single(query, "min_quality_score")) + sort_by = parse_quality_sort_query(_get_single(query, "sort_by")) + export_kwargs = { + "status": status, + "skill_tag": skill_tag, + "instrument_id": instrument_id, + "limit": limit, + } + if quality_rank is not None: + export_kwargs["quality_rank"] = quality_rank + if min_quality_score is not None: + export_kwargs["min_quality_score"] = min_quality_score + if sort_by is not None: + export_kwargs["sort_by"] = sort_by + return 200, deps["training_export_cases"](**export_kwargs) if method == "POST" and path == "/training/cases/import": return 201, deps["training_import_cases"](body or {}) if method == "GET" and len(path_parts) == 3 and path_parts[:2] == ["training", "cases"]: diff --git a/src/api/signals.py b/src/api/signals.py index 1855014..f81f555 100644 --- a/src/api/signals.py +++ b/src/api/signals.py @@ -1,6 +1,7 @@ from sqlalchemy import text from src.db.session import SessionLocal +from src.services.signals import normalize_signal_invalidations def get_recent_signals(instrument_id: int, limit: int = 20) -> list[dict]: @@ -22,6 +23,7 @@ def get_recent_signals(instrument_id: int, limit: int = 20) -> list[dict]: def normalize_signal_row(row: dict) -> dict: payload = dict(row) meta = dict(payload.get("meta") or {}) + invalidations = list(payload.get("invalidations") or []) payload["structure_state"] = meta.get("structure_state") payload["mss_state"] = meta.get("mss_state") payload["mss_status_reason"] = meta.get("mss_status_reason") @@ -31,4 +33,16 @@ def normalize_signal_row(row: dict) -> dict: payload["fvg_fill_pct"] = float(meta.get("fvg_fill_pct") or 0.0) payload["fvg_touch_count"] = int(meta.get("fvg_touch_count") or 0) payload["fvg_data_quality_warnings"] = list(meta.get("fvg_data_quality_warnings") or []) + payload["invalidations_normalized"] = normalize_signal_invalidations(invalidations) + payload["signal_context"] = { + "structure_state": payload["structure_state"], + "mss_state": payload["mss_state"], + "mss_status_reason": payload["mss_status_reason"], + "fvg_status": payload["fvg_status"], + "fvg_status_reason": payload["fvg_status_reason"], + "fvg_fill_pct": payload["fvg_fill_pct"], + "fvg_touch_count": payload["fvg_touch_count"], + "fvg_data_quality_warnings": payload["fvg_data_quality_warnings"], + "invalidations_normalized": payload["invalidations_normalized"], + } return payload diff --git a/src/api/training.py b/src/api/training.py index 3ea176c..043051a 100644 --- a/src/api/training.py +++ b/src/api/training.py @@ -1,6 +1,6 @@ from typing import Optional -from src.services.training import TrainingCaseService +from src.services.training import QUALITY_RANKS, QUALITY_SORT_BY_VALUES, TrainingCaseService from src.services.training.training_case_service import sanitize_public_training_payload @@ -9,6 +9,9 @@ def get_training_cases( skill_tag: Optional[str] = None, instrument_id: Optional[int] = None, limit: int = 100, + quality_rank: Optional[str] = None, + min_quality_score: Optional[int] = None, + sort_by: Optional[str] = None, service: Optional[TrainingCaseService] = None, ) -> list[dict]: return _service(service).list_cases( @@ -16,6 +19,9 @@ def get_training_cases( skill_tag=skill_tag, instrument_id=instrument_id, limit=limit, + quality_rank=quality_rank, + min_quality_score=min_quality_score, + sort_by=sort_by, ) @@ -73,6 +79,9 @@ def export_training_case_package( skill_tag: Optional[str] = None, instrument_id: Optional[int] = None, limit: int = 100, + quality_rank: Optional[str] = None, + min_quality_score: Optional[int] = None, + sort_by: Optional[str] = None, service: Optional[TrainingCaseService] = None, ) -> dict: return _service(service).export_cases( @@ -80,6 +89,9 @@ def export_training_case_package( skill_tag=skill_tag, instrument_id=instrument_id, limit=limit, + quality_rank=quality_rank, + min_quality_score=min_quality_score, + sort_by=sort_by, ) @@ -170,3 +182,33 @@ def _require_value(body: dict, key: str): if value is None: raise ValueError(f"missing_required_json_field:{key}") return value + + +def parse_quality_rank_query(value: Optional[str]) -> Optional[str]: + if value is None: + return None + normalized = value.strip().lower() + if normalized not in QUALITY_RANKS: + raise ValueError("invalid_quality_rank_query_param:quality_rank") + return normalized + + +def parse_min_quality_score_query(value: Optional[str]) -> Optional[int]: + if value is None: + return None + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise ValueError("invalid_min_quality_score_query_param:min_quality_score") from exc + if parsed < 0 or parsed > 100: + raise ValueError("invalid_min_quality_score_query_param:min_quality_score") + return parsed + + +def parse_quality_sort_query(value: Optional[str]) -> Optional[str]: + if value is None: + return None + normalized = value.strip().lower() + if normalized not in QUALITY_SORT_BY_VALUES: + raise ValueError("invalid_sort_query_param:sort_by") + return normalized diff --git a/src/repositories/backtest_repository.py b/src/repositories/backtest_repository.py index 375e5bb..4d17472 100644 --- a/src/repositories/backtest_repository.py +++ b/src/repositories/backtest_repository.py @@ -10,9 +10,23 @@ class BacktestRepository: def fetch_candidate_signals(self, instrument_id: int, limit: int = 100) -> list[dict]: sql = text( """ - select id, side, entry_low, entry_high, stop_loss, tp1, tp2, rr_tp1, rr_tp2, created_ts, expires_ts - from trade_signals - where instrument_id = :instrument_id + select + ts.id, + ts.instrument_id, + ts.side, + ts.entry_low, + ts.entry_high, + ts.stop_loss, + ts.tp1, + ts.tp2, + ts.rr_tp1, + ts.rr_tp2, + ts.created_ts, + ts.expires_ts, + i.symbol + from trade_signals ts + join instruments i on i.id = ts.instrument_id + where ts.instrument_id = :instrument_id order by created_ts desc limit :limit """ diff --git a/src/repositories/signal_repository.py b/src/repositories/signal_repository.py index 70a4927..7810af3 100644 --- a/src/repositories/signal_repository.py +++ b/src/repositories/signal_repository.py @@ -33,7 +33,6 @@ class SignalRepository: where instrument_id = :instrument_id and timeframe = :timeframe order by - case when related_displacement_id is not null then 0 else 1 end, end_ts desc, id desc limit 1 @@ -43,6 +42,24 @@ class SignalRepository: row = session.execute(sql, {"instrument_id": instrument_id, "timeframe": timeframe}).mappings().first() return dict(row) if row else None + def fetch_recent_fvgs_for_signal(self, instrument_id: int, timeframe: str, limit: int = 20) -> list[dict]: + sql = text( + """ + select id, direction, upper, lower, status, related_displacement_id, end_ts, fill_pct, meta + from fvg_zones + where instrument_id = :instrument_id + and timeframe = :timeframe + order by end_ts desc, id desc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute( + sql, + {"instrument_id": instrument_id, "timeframe": timeframe, "limit": limit}, + ).mappings().all() + return [dict(row) for row in rows] + def fetch_latest_mss(self, instrument_id: int, timeframe: str) -> Optional[dict]: sql = text( """ diff --git a/src/services/backtest/__init__.py b/src/services/backtest/__init__.py index 6d601f6..4f7519d 100644 --- a/src/services/backtest/__init__.py +++ b/src/services/backtest/__init__.py @@ -2,6 +2,14 @@ from src.services.backtest.backtest_service import ( BACKTEST_MODE_ENTRY_THEN_OUTCOME, + BACKTEST_LIFECYCLE_AWAITING_CONFIRMATION, + BACKTEST_LIFECYCLE_INVALIDATED_BEFORE_ENTRY, + BACKTEST_LIFECYCLE_MISSED, + BACKTEST_LIFECYCLE_OPEN, + BACKTEST_LIFECYCLE_REJECTED, + BACKTEST_LIFECYCLE_STOPPED_OUT, + BACKTEST_LIFECYCLE_TP1_REACHED, + BACKTEST_LIFECYCLE_TP2_CLOSED, BACKTEST_MODE_REPLAY_NO_LOOKAHEAD, BacktestOutcome, BacktestCostConfig, @@ -13,6 +21,8 @@ from src.services.backtest.backtest_service import ( compute_max_consecutive_losses, compute_max_drawdown_r, compute_outcome_r_multiple, + classify_backtest_lifecycle_state, + classify_backtest_lifecycle_state_from_result, estimate_trade_cost_r, evaluate_backtest_quality, evaluate_signal_outcome, @@ -33,6 +43,14 @@ from src.services.backtest.replay import ( __all__ = [ "BacktestOutcome", "BACKTEST_MODE_ENTRY_THEN_OUTCOME", + "BACKTEST_LIFECYCLE_AWAITING_CONFIRMATION", + "BACKTEST_LIFECYCLE_INVALIDATED_BEFORE_ENTRY", + "BACKTEST_LIFECYCLE_MISSED", + "BACKTEST_LIFECYCLE_OPEN", + "BACKTEST_LIFECYCLE_REJECTED", + "BACKTEST_LIFECYCLE_STOPPED_OUT", + "BACKTEST_LIFECYCLE_TP1_REACHED", + "BACKTEST_LIFECYCLE_TP2_CLOSED", "BACKTEST_MODE_REPLAY_NO_LOOKAHEAD", "BacktestCostConfig", "BacktestReplayConfig", @@ -43,6 +61,8 @@ __all__ = [ "compute_max_consecutive_losses", "compute_max_drawdown_r", "compute_outcome_r_multiple", + "classify_backtest_lifecycle_state", + "classify_backtest_lifecycle_state_from_result", "estimate_trade_cost_r", "evaluate_backtest_quality", "evaluate_signal_outcome", diff --git a/src/services/backtest/backtest_service.py b/src/services/backtest/backtest_service.py index 13c3fdf..e26cc34 100644 --- a/src/services/backtest/backtest_service.py +++ b/src/services/backtest/backtest_service.py @@ -14,6 +14,15 @@ SUPPORTED_BACKTEST_MODES = { BACKTEST_MODE_REPLAY_NO_LOOKAHEAD, } +BACKTEST_LIFECYCLE_OPEN = "open" +BACKTEST_LIFECYCLE_REJECTED = "rejected" +BACKTEST_LIFECYCLE_AWAITING_CONFIRMATION = "awaiting_confirmation" +BACKTEST_LIFECYCLE_INVALIDATED_BEFORE_ENTRY = "invalidated_before_entry" +BACKTEST_LIFECYCLE_MISSED = "missed" +BACKTEST_LIFECYCLE_STOPPED_OUT = "stopped_out" +BACKTEST_LIFECYCLE_TP1_REACHED = "tp1_reached" +BACKTEST_LIFECYCLE_TP2_CLOSED = "tp2_closed" + @dataclass(frozen=True) class BacktestOutcome: @@ -128,6 +137,7 @@ class BacktestService: 'sessions_found': len(sessions), 'session_codes': [row['session_code'] for row in sessions], 'signal_side': signal['side'], + 'signal_symbol': signal.get('symbol'), 'signal_created_ts': signal['created_ts'], 'rr_tp1': float(signal.get('rr_tp1') or 0.0), 'rr_tp2': float(signal.get('rr_tp2') or 0.0), @@ -181,6 +191,7 @@ class BacktestService: 'session_codes': [row['session_code'] for row in sessions], 'session_filter': 'outside_window' if session_filtered else 'covered', 'signal_side': signal['side'], + 'signal_symbol': signal.get('symbol'), 'signal_created_ts': signal['created_ts'], 'rr_tp1': float(signal.get('rr_tp1') or 0.0), 'rr_tp2': float(signal.get('rr_tp2') or 0.0), @@ -535,12 +546,45 @@ def compute_outcome_r_multiple(outcome: str, rr_tp1: float = 0.0, rr_tp2: float return gross_r +def classify_backtest_lifecycle_state( + *, + outcome: Optional[str], + tp1_hit: bool, + tp2_hit: bool, + invalidated_before_entry: bool, +) -> Optional[str]: + if invalidated_before_entry: + return BACKTEST_LIFECYCLE_INVALIDATED_BEFORE_ENTRY + if outcome == 'loss': + return BACKTEST_LIFECYCLE_STOPPED_OUT + if tp2_hit or outcome == 'win': + return BACKTEST_LIFECYCLE_TP2_CLOSED + if tp1_hit or outcome == 'breakeven': + return BACKTEST_LIFECYCLE_TP1_REACHED + if outcome == 'missed': + return BACKTEST_LIFECYCLE_MISSED + return None + + +def classify_backtest_lifecycle_state_from_result(backtest_result: Optional[dict]) -> Optional[str]: + if backtest_result is None: + return None + return classify_backtest_lifecycle_state( + outcome=backtest_result.get('outcome'), + tp1_hit=bool(backtest_result.get('tp1_hit')), + tp2_hit=bool(backtest_result.get('tp2_hit')), + invalidated_before_entry=bool(backtest_result.get('invalidated_before_entry')), + ) + + def summarize_backtest_outcomes(outcomes: list[BacktestOutcome]) -> dict: total = len(outcomes) grouped_by_outcome: dict[str, int] = {} + grouped_by_lifecycle_state: dict[str, int] = {} grouped_by_side: dict[str, dict] = {} grouped_by_session: dict[str, dict] = {} grouped_by_weekday: dict[str, dict] = {} + grouped_by_symbol: dict[str, dict] = {} r_multiples: list[float] = [] cost_values: list[float] = [] mfe_r_values: list[float] = [] @@ -550,6 +594,14 @@ def summarize_backtest_outcomes(outcomes: list[BacktestOutcome]) -> dict: for outcome in outcomes: grouped_by_outcome[outcome.outcome] = grouped_by_outcome.get(outcome.outcome, 0) + 1 + lifecycle_state = classify_backtest_lifecycle_state( + outcome=outcome.outcome, + tp1_hit=outcome.tp1_hit, + tp2_hit=outcome.tp2_hit, + invalidated_before_entry=outcome.invalidated_before_entry, + ) + if lifecycle_state is not None: + grouped_by_lifecycle_state[lifecycle_state] = grouped_by_lifecycle_state.get(lifecycle_state, 0) + 1 if is_executed_outcome(outcome): executed_signals += 1 executed_r_multiples.append(float(outcome.meta.get('r_multiple') or 0.0)) @@ -557,6 +609,7 @@ def summarize_backtest_outcomes(outcomes: list[BacktestOutcome]) -> dict: side = str(outcome.meta.get('signal_side') or 'unknown') session_codes = outcome.meta.get('session_codes') or ['OFF_HOURS'] weekday = str(outcome.meta.get('weekday') or 'unknown') + symbol = _extract_symbol_from_outcome(outcome) 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)) @@ -564,12 +617,14 @@ def summarize_backtest_outcomes(outcomes: list[BacktestOutcome]) -> dict: _accumulate_bucket(grouped_by_side, side, outcome) _accumulate_bucket(grouped_by_weekday, weekday, outcome) + _accumulate_bucket(grouped_by_symbol, symbol, outcome) for session_code in session_codes: _accumulate_bucket(grouped_by_session, str(session_code), outcome) return { 'total_signals': total, 'outcomes': grouped_by_outcome, + 'lifecycle_states': grouped_by_lifecycle_state, 'win_rate': _ratio(grouped_by_outcome.get('win', 0), total), 'loss_rate': _ratio(grouped_by_outcome.get('loss', 0), total), 'breakeven_rate': _ratio(grouped_by_outcome.get('breakeven', 0), total), @@ -593,6 +648,7 @@ def summarize_backtest_outcomes(outcomes: list[BacktestOutcome]) -> dict: 'by_side': grouped_by_side, 'by_session': grouped_by_session, 'by_weekday': grouped_by_weekday, + 'by_symbol': grouped_by_symbol, } @@ -698,6 +754,16 @@ def is_executed_outcome(outcome: BacktestOutcome) -> bool: return outcome.outcome in {'win', 'loss', 'breakeven'} or bool(outcome.meta.get('entry_triggered')) +def _extract_symbol_from_outcome(outcome: BacktestOutcome) -> str: + symbol = outcome.meta.get('signal_symbol') + if symbol: + return str(symbol) + instrument_id = outcome.meta.get('instrument_id') + if instrument_id is not None: + return f"instrument:{instrument_id}" + return 'UNKNOWN' + + def _ratio(numerator: int, denominator: int) -> float: return numerator / denominator if denominator else 0.0 diff --git a/src/services/execution/execution_service.py b/src/services/execution/execution_service.py index 5b57a63..98cfed5 100644 --- a/src/services/execution/execution_service.py +++ b/src/services/execution/execution_service.py @@ -4,6 +4,13 @@ import math from typing import Optional from src.repositories.execution_repository import ExecutionRepository +from src.services.backtest import ( + BACKTEST_LIFECYCLE_INVALIDATED_BEFORE_ENTRY, + BACKTEST_LIFECYCLE_MISSED, + BACKTEST_LIFECYCLE_STOPPED_OUT, + BACKTEST_LIFECYCLE_TP2_CLOSED, + classify_backtest_lifecycle_state_from_result, +) ALLOWED_EXECUTION_SIGNAL_STATUSES = {"awaiting_confirmation"} @@ -57,7 +64,8 @@ class ExecutionService: error_tags = list(risk_checks) status = trade_execution.get("status") lifecycle_state = classify_execution_lifecycle(status=status, backtest_result=backtest_result) - stop_loss_hit = bool(backtest_result and backtest_result.get("outcome") == "loss") + backtest_lifecycle_state = classify_backtest_lifecycle_state_from_result(backtest_result) + stop_loss_hit = backtest_lifecycle_state == BACKTEST_LIFECYCLE_STOPPED_OUT tp1_hit = bool(backtest_result and backtest_result.get("tp1_hit")) tp2_hit = bool(backtest_result and backtest_result.get("tp2_hit")) @@ -71,6 +79,7 @@ class ExecutionService: "is_actionable": len(error_tags) == 0, "error_tags": sorted(set(error_tags)), "lifecycle_state": lifecycle_state, + "backtest_lifecycle_state": backtest_lifecycle_state, "risk_checks": risk_checks, "tp_hits": {"tp1": tp1_hit, "tp2": tp2_hit}, "stop_loss_hit": stop_loss_hit, @@ -84,17 +93,19 @@ class ExecutionService: status=trade_execution.get("status"), backtest_result=backtest_result, ) + backtest_lifecycle_state = classify_backtest_lifecycle_state_from_result(backtest_result) tp1_hit = bool(backtest_result and backtest_result.get("tp1_hit")) tp2_hit = bool(backtest_result and backtest_result.get("tp2_hit")) - stop_loss_hit = bool(backtest_result and backtest_result.get("outcome") == "loss") + stop_loss_hit = backtest_lifecycle_state == BACKTEST_LIFECYCLE_STOPPED_OUT invalidated_before_entry = bool(backtest_result and backtest_result.get("invalidated_before_entry")) - if lifecycle_state == "tp2_closed": + if lifecycle_state == BACKTEST_LIFECYCLE_TP2_CLOSED: status = "closed" - elif lifecycle_state in {"stopped_out", "expired"}: + elif lifecycle_state in {BACKTEST_LIFECYCLE_STOPPED_OUT, BACKTEST_LIFECYCLE_INVALIDATED_BEFORE_ENTRY, BACKTEST_LIFECYCLE_MISSED}: status = "closed" else: status = trade_execution.get("status") meta["lifecycle_state"] = lifecycle_state + meta["backtest_lifecycle_state"] = backtest_lifecycle_state meta["tp1_hit"] = tp1_hit meta["tp2_hit"] = tp2_hit meta["stop_loss_hit"] = stop_loss_hit @@ -183,6 +194,7 @@ class ExecutionService: "audit_status": audit["audit_status"], "error_tags": audit["error_tags"], "backtest_outcome": row.get("backtest_outcome"), + "backtest_lifecycle_state": audit["backtest_lifecycle_state"], "tp1_hit": row.get("tp1_hit"), "tp2_hit": row.get("tp2_hit"), "signal_status": row.get("signal_status"), @@ -373,18 +385,10 @@ def classify_execution_lifecycle(status: Optional[str], backtest_result: Optiona return "rejected" if status == "pending": return "awaiting_confirmation" - if backtest_result is None: - return "open" - if backtest_result.get("invalidated_before_entry"): - return "expired" - if backtest_result.get("outcome") == "loss": - return "stopped_out" - if backtest_result.get("tp2_hit"): - return "tp2_closed" - if backtest_result.get("tp1_hit"): - return "tp1_reached" - if backtest_result.get("outcome") == "missed": - return "expired" + if backtest_result is not None: + backtest_lifecycle_state = classify_backtest_lifecycle_state_from_result(backtest_result) + if backtest_lifecycle_state is not None: + return backtest_lifecycle_state return "open" diff --git a/src/services/maintenance/__init__.py b/src/services/maintenance/__init__.py new file mode 100644 index 0000000..a808f06 --- /dev/null +++ b/src/services/maintenance/__init__.py @@ -0,0 +1,19 @@ +from src.services.maintenance.historical_compatibility import ( + build_historical_compatibility_summary, + derive_historical_fvg_meta, + derive_historical_mss_meta, + plan_fvg_meta_repairs, + plan_mss_meta_repairs, + plan_trade_signal_invalidation_repairs, + summarize_liquidity_duplicate_groups, +) + +__all__ = [ + "build_historical_compatibility_summary", + "derive_historical_fvg_meta", + "derive_historical_mss_meta", + "plan_fvg_meta_repairs", + "plan_mss_meta_repairs", + "plan_trade_signal_invalidation_repairs", + "summarize_liquidity_duplicate_groups", +] diff --git a/src/services/maintenance/historical_compatibility.py b/src/services/maintenance/historical_compatibility.py new file mode 100644 index 0000000..1abba8e --- /dev/null +++ b/src/services/maintenance/historical_compatibility.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from typing import Optional + +from src.services.signals import normalize_signal_invalidations + + +def plan_trade_signal_invalidation_repairs(rows: list[dict]) -> list[dict]: + repairs: list[dict] = [] + for row in rows: + current = list(row.get("invalidations") or []) + normalized = normalize_signal_invalidations(current) + if current != normalized: + repairs.append({"id": int(row["id"]), "invalidations": normalized}) + return repairs + + +def plan_mss_meta_repairs(rows: list[dict]) -> list[dict]: + repairs: list[dict] = [] + for row in rows: + merged = derive_historical_mss_meta(row) + if merged != dict(row.get("meta") or {}): + repairs.append({"id": int(row["id"]), "meta": merged}) + return repairs + + +def plan_fvg_meta_repairs(rows: list[dict]) -> list[dict]: + repairs: list[dict] = [] + for row in rows: + merged = derive_historical_fvg_meta(row) + if merged != dict(row.get("meta") or {}): + repairs.append({"id": int(row["id"]), "meta": merged}) + return repairs + + +def derive_historical_mss_meta(row: dict) -> dict: + meta = dict(row.get("meta") or {}) + if meta.get("state") in {"accepted", "rejected", "invalidated"} and meta.get("status_reason"): + return meta + + if bool(meta.get("reclaimed_old_control")): + state = "invalidated" + status_reason = "old_control_reclaimed" + elif bool(row.get("accepted")): + state = "accepted" + status_reason = "retest_held" + else: + state = "rejected" + status_reason = "retest_not_observed" + + return { + **meta, + "state": meta.get("state") or state, + "status_reason": meta.get("status_reason") or status_reason, + } + + +def derive_historical_fvg_meta(row: dict) -> dict: + meta = dict(row.get("meta") or {}) + status = str(row.get("status") or "open") + invalidated = bool(meta.get("invalidated")) or status == "invalidated" + status_reason = meta.get("status_reason") + if not status_reason: + if invalidated: + status_reason = "structure_break" + elif status == "mitigated": + status_reason = "mitigation_threshold_reached" + elif status == "touched": + status_reason = "partial_fill" + else: + status_reason = "no_touch" + merged = { + **meta, + "invalidated": invalidated, + "status_reason": status_reason, + } + if "has_data_gap" not in merged: + merged["has_data_gap"] = False + if "gap_count" not in merged: + merged["gap_count"] = 0 + if "data_quality_warnings" not in merged: + merged["data_quality_warnings"] = [] + return merged + + +def summarize_liquidity_duplicate_groups(rows: list[dict]) -> dict: + normalized_rows = [] + total_rows = 0 + for row in rows: + duplicate_count = int(row.get("duplicate_count") or 0) + total_rows += duplicate_count + normalized_rows.append( + { + "instrument_id": row.get("instrument_id"), + "timeframe": row.get("timeframe"), + "side": row.get("side"), + "price_low": row.get("price_low"), + "price_high": row.get("price_high"), + "duplicate_count": duplicate_count, + "ids": list(row.get("ids") or []), + } + ) + return { + "group_count": len(normalized_rows), + "row_count": total_rows, + "groups": normalized_rows, + } + + +def build_historical_compatibility_summary( + *, + signal_repairs: list[dict], + mss_repairs: list[dict], + fvg_repairs: list[dict], + liquidity_duplicates: dict, + applied_repairs: Optional[dict] = None, + dry_run: bool = True, +) -> dict: + applied_repairs = applied_repairs or {} + return { + "passed": True, + "dry_run": dry_run, + "candidate_repairs": { + "signal_invalidations": len(signal_repairs), + "mss_meta": len(mss_repairs), + "fvg_meta": len(fvg_repairs), + "liquidity_duplicate_groups": int(liquidity_duplicates.get("group_count") or 0), + "liquidity_duplicate_rows": int(liquidity_duplicates.get("row_count") or 0), + }, + "applied_repairs": { + "signal_invalidations": int(applied_repairs.get("signal_invalidations") or 0), + "mss_meta": int(applied_repairs.get("mss_meta") or 0), + "fvg_meta": int(applied_repairs.get("fvg_meta") or 0), + }, + "manual_review": { + "liquidity_duplicates": liquidity_duplicates.get("groups") or [], + }, + "safe_apply_supported": [ + "signal_invalidations", + "mss_meta", + "fvg_meta", + ], + } diff --git a/src/services/reports/intraday_report_service.py b/src/services/reports/intraday_report_service.py index 3ac0382..f6966f2 100644 --- a/src/services/reports/intraday_report_service.py +++ b/src/services/reports/intraday_report_service.py @@ -1,5 +1,7 @@ from typing import Optional +from src.services.signals import normalize_signal_invalidations + class IntradayReportService: def build_report( @@ -17,7 +19,7 @@ class IntradayReportService: signal_fvg_data_quality_warnings = list(_get_signal_context_value(trade_signal, "fvg_data_quality_warnings", [])) invalidations: list[str] = [] if trade_signal: - invalidations.extend(trade_signal.get("invalidations") or []) + invalidations.extend(normalize_signal_invalidations(list(trade_signal.get("invalidations") or []))) if fvg_status == "mitigated": invalidations.append("fvg_mitigated") if fvg_status == "invalidated": diff --git a/src/services/reports/post_trade_review_service.py b/src/services/reports/post_trade_review_service.py index 26f2f74..16e27cb 100644 --- a/src/services/reports/post_trade_review_service.py +++ b/src/services/reports/post_trade_review_service.py @@ -1,6 +1,8 @@ from typing import Optional +from src.services.backtest import classify_backtest_lifecycle_state_from_result from src.services.execution import ExecutionService +from src.services.signals import normalize_signal_invalidations class PostTradeReviewService: @@ -28,7 +30,7 @@ class PostTradeReviewService: error_types.extend(execution_audit["error_tags"]) if trade_signal: - signal_invalidations = trade_signal.get("invalidations") or [] + signal_invalidations = normalize_signal_invalidations(list(trade_signal.get("invalidations") or [])) if signal_invalidations: improvement_notes.append("review_signal_invalidations") if trade_signal.get("status") != "awaiting_confirmation": @@ -38,11 +40,15 @@ class PostTradeReviewService: if _get_signal_context_value(trade_signal, "fvg_data_quality_warnings", []): improvement_notes.append("review_signal_data_quality_warnings") + backtest_lifecycle_state = classify_backtest_lifecycle_state_from_result(backtest_result) + if backtest_result: - if backtest_result.get("outcome") == "loss": + if backtest_lifecycle_state == "stopped_out": improvement_notes.append("inspect_stop_and_structure_context") - if backtest_result.get("invalidated_before_entry"): + if backtest_lifecycle_state == "invalidated_before_entry": improvement_notes.append("avoid_expired_or_invalidated_signals") + if backtest_lifecycle_state == "missed": + improvement_notes.append("review_untriggered_signal_window") if "same_candle_entry_exit_ambiguous" in (backtest_result.get("error_tags") or []): improvement_notes.append("review_same_candle_ambiguity_cases") @@ -52,6 +58,7 @@ class PostTradeReviewService: "execution_status": trade_execution.get("status") if trade_execution else None, "signal_status": trade_signal.get("status") if trade_signal else None, "backtest_outcome": backtest_result.get("outcome") if backtest_result else None, + "backtest_lifecycle_state": backtest_lifecycle_state, "signal_context": { "structure_state": _get_signal_context_value(trade_signal, "structure_state"), "mss_state": _get_signal_context_value(trade_signal, "mss_state"), @@ -59,6 +66,7 @@ class PostTradeReviewService: "fvg_status": _get_signal_context_value(trade_signal, "fvg_status"), "fvg_status_reason": _get_signal_context_value(trade_signal, "fvg_status_reason"), "fvg_data_quality_warnings": list(_get_signal_context_value(trade_signal, "fvg_data_quality_warnings", [])), + "invalidations_normalized": normalize_signal_invalidations(list((trade_signal or {}).get("invalidations") or [])), }, "execution_audit": execution_audit, "error_types": sorted(set(error_types)), diff --git a/src/services/signals/__init__.py b/src/services/signals/__init__.py index 1e8df88..1e9763e 100644 --- a/src/services/signals/__init__.py +++ b/src/services/signals/__init__.py @@ -1,11 +1,14 @@ """Signal service package.""" from src.services.signals.signal_service import ( + LEGACY_SIGNAL_INVALIDATION_CODE_MAP, MINIMAL_SIGNAL_EVIDENCE_KEYS, STRICT_SIGNAL_EVIDENCE_KEYS, SignalCandidate, SignalService, classify_session_for_signal, + normalize_signal_invalidations, + select_preferred_fvg, trade_signal_matches_candidate, validate_rr_thresholds, validate_signal_evidence, @@ -13,11 +16,14 @@ from src.services.signals.signal_service import ( ) __all__ = [ + "LEGACY_SIGNAL_INVALIDATION_CODE_MAP", "MINIMAL_SIGNAL_EVIDENCE_KEYS", "STRICT_SIGNAL_EVIDENCE_KEYS", "SignalCandidate", "SignalService", "classify_session_for_signal", + "normalize_signal_invalidations", + "select_preferred_fvg", "trade_signal_matches_candidate", "validate_rr_thresholds", "validate_signal_evidence", diff --git a/src/services/signals/signal_service.py b/src/services/signals/signal_service.py index 2a2d363..f9f43ca 100644 --- a/src/services/signals/signal_service.py +++ b/src/services/signals/signal_service.py @@ -17,6 +17,12 @@ STRICT_SIGNAL_EVIDENCE_KEYS = ( "liquidity_target_id", ) +LEGACY_SIGNAL_INVALIDATION_CODE_MAP = { + "mss invalidated": "mss_invalidated", + "fvg fully mitigated": "fvg_mitigated", + "session mismatch": "session_mismatch", +} + @dataclass(frozen=True) class SignalCandidate: @@ -48,8 +54,9 @@ class SignalService: def build_signal(self, instrument_id: int, setup_tf: str, trigger_tf: str) -> Optional[SignalCandidate]: bias = self.repository.fetch_latest_bias_snapshot(instrument_id=instrument_id, setup_tf=setup_tf, trigger_tf=trigger_tf) - fvg = self.repository.fetch_latest_fvg(instrument_id=instrument_id, timeframe=trigger_tf) mss = self.repository.fetch_latest_mss(instrument_id=instrument_id, timeframe=setup_tf) + fvgs = self._fetch_fvg_candidates(instrument_id=instrument_id, timeframe=trigger_tf) + fvg = select_preferred_fvg(fvgs=fvgs, mss=mss) if mss else None if not bias or not fvg or not mss: return None @@ -80,9 +87,10 @@ class SignalService: session = self.repository.fetch_session_at(instrument_id=instrument_id, ts=created_ts) session_code = session['session_code'] if session else 'OFF_HOURS' session_decision = classify_session_for_signal(session_code) - invalidations = ['mss invalidated', 'fvg fully mitigated'] + invalidations = ['mss_invalidated', 'fvg_mitigated'] if not session_decision['execution_permission']: invalidations.append('session_mismatch') + invalidations = normalize_signal_invalidations(invalidations) fvg_meta = dict(fvg.get('meta') or {}) evidence = { 'bias_snapshot_id': bias['id'], @@ -157,6 +165,12 @@ class SignalService: ) return 1 + def _fetch_fvg_candidates(self, instrument_id: int, timeframe: str) -> list[dict]: + if hasattr(self.repository, "fetch_recent_fvgs_for_signal"): + return list(self.repository.fetch_recent_fvgs_for_signal(instrument_id=instrument_id, timeframe=timeframe, limit=20)) + latest = self.repository.fetch_latest_fvg(instrument_id=instrument_id, timeframe=timeframe) + return [latest] if latest else [] + def trade_signal_matches_candidate(signal: dict, candidate: SignalCandidate, bias_snapshot_id: int) -> bool: return ( @@ -172,7 +186,7 @@ def trade_signal_matches_candidate(signal: dict, candidate: SignalCandidate, bia and _same_number(signal.get('rr_tp1'), candidate.rr_tp1) and _same_number(signal.get('rr_tp2'), candidate.rr_tp2) and signal.get('session_code') == candidate.session_code - and list(signal.get('invalidations') or []) == list(candidate.invalidations) + and normalize_signal_invalidations(signal.get('invalidations') or []) == normalize_signal_invalidations(candidate.invalidations) and dict(signal.get('evidence') or {}) == dict(candidate.evidence) and dict(signal.get('meta') or {}) == dict(candidate.meta) ) @@ -182,6 +196,23 @@ def _same_number(left, right) -> bool: return math.isclose(float(left or 0.0), float(right or 0.0), rel_tol=1e-4, abs_tol=1e-4) +def normalize_signal_invalidations(invalidations: list[str]) -> list[str]: + normalized: list[str] = [] + seen: set[str] = set() + for value in invalidations: + text = str(value or '').strip() + if not text: + continue + lowered = text.lower() + canonical = LEGACY_SIGNAL_INVALIDATION_CODE_MAP.get(lowered) + if canonical is None: + canonical = lowered.replace(' ', '_') + if canonical not in seen: + seen.add(canonical) + normalized.append(canonical) + return normalized + + def _build_signal_context_result( *, is_actionable: bool, @@ -347,6 +378,29 @@ def validate_signal_context(bias: dict, fvg: dict, mss: dict) -> dict[str, objec ) +def select_preferred_fvg(fvgs: list[dict], mss: dict) -> Optional[dict]: + if not fvgs: + return None + + mss_direction = mss.get("direction") + mss_displacement_event_id = int(mss.get("displacement_event_id") or 0) + + exact_context_matches = [ + row for row in fvgs + if row.get("direction") == mss_direction + and int(row.get("related_displacement_id") or 0) == mss_displacement_event_id + and mss_displacement_event_id > 0 + ] + if exact_context_matches: + return exact_context_matches[0] + + same_direction = [row for row in fvgs if row.get("direction") == mss_direction] + if same_direction: + return same_direction[0] + + return fvgs[0] + + def validate_rr_thresholds(rr_tp1: float, rr_tp2: float) -> dict[str, object]: reasons: list[str] = [] if rr_tp1 < MIN_TP1_RR: diff --git a/src/services/training/__init__.py b/src/services/training/__init__.py index 9be9d42..1b3eaaf 100644 --- a/src/services/training/__init__.py +++ b/src/services/training/__init__.py @@ -5,11 +5,38 @@ from src.services.training.case_import_export import ( import_training_cases, validate_training_case_payload, ) +from src.services.training.case_quality import attach_case_quality, build_case_quality +from src.services.training.case_quality import ( + QUALITY_RANKS, + QUALITY_SORT_BY_CREATED_AT_ASC, + QUALITY_SORT_BY_CREATED_AT_DESC, + QUALITY_SORT_BY_SCORE_ASC, + QUALITY_SORT_BY_SCORE_DESC, + QUALITY_SORT_BY_VALUES, + select_cases_by_quality, +) +from src.services.training.case_generator import ( + GENERATOR_VERSION, + SUPPORTED_GENERATOR_EVENT_TYPES, + generate_public_training_case_drafts, +) from src.services.training.training_case_service import TrainingCaseService __all__ = [ + "GENERATOR_VERSION", + "SUPPORTED_GENERATOR_EVENT_TYPES", "TrainingCaseService", + "QUALITY_RANKS", + "QUALITY_SORT_BY_CREATED_AT_ASC", + "QUALITY_SORT_BY_CREATED_AT_DESC", + "QUALITY_SORT_BY_SCORE_ASC", + "QUALITY_SORT_BY_SCORE_DESC", + "QUALITY_SORT_BY_VALUES", + "attach_case_quality", + "build_case_quality", "export_training_cases", + "generate_public_training_case_drafts", "import_training_cases", + "select_cases_by_quality", "validate_training_case_payload", ] diff --git a/src/services/training/case_generator.py b/src/services/training/case_generator.py new file mode 100644 index 0000000..18e6fbb --- /dev/null +++ b/src/services/training/case_generator.py @@ -0,0 +1,319 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from src.services.training.case_quality import attach_case_quality + + +GENERATOR_VERSION = 1 +SUPPORTED_GENERATOR_EVENT_TYPES = ( + "sweep_event", + "fvg_zone", + "dealing_range", + "mss_displacement", + "liquidity_pool", +) + +_GENERATOR_BLUEPRINTS = { + "sweep_event": { + "case_type": "sweep", + "skill_tags": ["sweep", "context_reading"], + "title": "Liquidity sweep reading practice", + "rubric": [ + {"category": "context", "max_score": 3}, + {"category": "risk_story", "max_score": 2}, + ], + "tasks": [ + { + "task_type": "short_answer", + "skill_tag": "sweep", + "prompt": "Describe what needs confirmation before marking this as a usable sweep.", + "required_annotation_types": ["text"], + "max_score": 5, + "sort_order": 0, + "meta": {"public_hint": "Focus on context and confirmation."}, + } + ], + }, + "fvg_zone": { + "case_type": "fvg", + "skill_tags": ["fvg", "context_reading"], + "title": "Fair value gap reading practice", + "rubric": [ + {"category": "zone_context", "max_score": 3}, + {"category": "invalidation_story", "max_score": 2}, + ], + "tasks": [ + { + "task_type": "short_answer", + "skill_tag": "fvg", + "prompt": "Explain what would make this gap actionable and what would invalidate it.", + "required_annotation_types": ["text"], + "max_score": 5, + "sort_order": 0, + "meta": {"public_hint": "Focus on context, mitigation, and invalidation."}, + } + ], + }, + "dealing_range": { + "case_type": "range", + "skill_tags": ["range", "premium_discount"], + "title": "Range context reading practice", + "rubric": [ + {"category": "range_location", "max_score": 3}, + {"category": "draw_bias_story", "max_score": 2}, + ], + "tasks": [ + { + "task_type": "short_answer", + "skill_tag": "range", + "prompt": "Describe where price is inside the range and what directional draw is implied.", + "required_annotation_types": ["text"], + "max_score": 5, + "sort_order": 0, + "meta": {"public_hint": "Focus on premium/discount context and draw direction."}, + } + ], + }, + "mss_displacement": { + "case_type": "mss_displacement", + "skill_tags": ["mss", "displacement"], + "title": "Structure displacement reading practice", + "rubric": [ + {"category": "structure_shift", "max_score": 3}, + {"category": "follow_through_context", "max_score": 2}, + ], + "tasks": [ + { + "task_type": "short_answer", + "skill_tag": "mss", + "prompt": "Explain why the structure shift matters and what follow-through context is still required.", + "required_annotation_types": ["text"], + "max_score": 5, + "sort_order": 0, + "meta": {"public_hint": "Focus on break context and displacement follow-through."}, + } + ], + }, + "liquidity_pool": { + "case_type": "liquidity_pool", + "skill_tags": ["liquidity_pool", "context_reading"], + "title": "Liquidity pool reading practice", + "rubric": [ + {"category": "pool_identification", "max_score": 3}, + {"category": "liquidity_story", "max_score": 2}, + ], + "tasks": [ + { + "task_type": "short_answer", + "skill_tag": "liquidity_pool", + "prompt": "Describe why this liquidity pool matters and what interaction would make it relevant.", + "required_annotation_types": ["text"], + "max_score": 5, + "sort_order": 0, + "meta": {"public_hint": "Focus on liquidity relevance and expected interaction."}, + } + ], + }, +} + + +def generate_public_training_case_drafts(payload: dict) -> dict: + items = _require_seed_items(payload) + generated_items = [ + _build_public_case_draft(seed, index) + for index, seed in enumerate(items) + ] + generated_items.sort(key=lambda item: (item["external_id"], item["title"])) + return { + "version": GENERATOR_VERSION, + "metadata": { + "generated": len(generated_items), + "generator_mode": "prototype_public_draft", + "read_only": True, + }, + "items": generated_items, + } + + +def _build_public_case_draft(seed: dict, index: int) -> dict: + source_event_type = _require_supported_event_type(seed.get("source_event_type")) + blueprint = _GENERATOR_BLUEPRINTS[source_event_type] + scenario = { + "instrument_id": _require_positive_int(seed.get("instrument_id"), "instrument_id"), + "timeframe": _require_non_empty_str(seed.get("timeframe"), "timeframe"), + "case_type": blueprint["case_type"], + "start_ts": _require_timestamp_str(seed.get("start_ts"), "start_ts"), + "decision_ts": _require_timestamp_str(seed.get("decision_ts"), "decision_ts"), + "end_ts": _require_timestamp_str(seed.get("end_ts"), "end_ts"), + "source_event_type": source_event_type, + "source_event_refs": _normalize_refs(seed.get("source_event_refs", [])), + } + source_event_id = seed.get("source_event_id") + if source_event_id is not None: + scenario["source_event_id"] = _require_positive_int(source_event_id, "source_event_id") + chapter_ref = seed.get("chapter_ref") + if chapter_ref: + scenario["chapter_ref"] = _require_non_empty_str(chapter_ref, "chapter_ref") + + skill_tags = _normalize_skill_tags(seed.get("skill_tags"), blueprint["skill_tags"]) + metadata = _build_public_metadata(seed, source_event_type) + item = { + "external_id": _build_external_id(seed, source_event_type, index), + "title": _build_title(seed, blueprint["title"]), + "scenario": scenario, + "skill_tags": skill_tags, + "difficulty": _normalize_difficulty(seed.get("difficulty")), + "answer": {}, + "rubric": _copy_public_list(blueprint["rubric"]), + "tasks": _copy_public_list(blueprint["tasks"]), + "status": _normalize_status(seed.get("status")), + "metadata": metadata, + } + public_item = _sanitize_public_payload(item) + public_item["answer"] = {} + return attach_case_quality(public_item) + + +def _require_seed_items(payload: dict) -> list[dict]: + if not isinstance(payload, dict): + raise ValueError("invalid_json_body") + items = payload.get("items") + if not isinstance(items, list) or not items: + raise ValueError("invalid_json_field:items") + normalized = [] + for index, item in enumerate(items): + if not isinstance(item, dict): + raise ValueError(f"invalid_json_field:items[{index}]") + normalized.append(item) + return normalized + + +def _require_supported_event_type(value) -> str: + event_type = _require_non_empty_str(value, "source_event_type") + if event_type not in SUPPORTED_GENERATOR_EVENT_TYPES: + raise ValueError("invalid_json_field:source_event_type") + return event_type + + +def _require_positive_int(value, field: str) -> int: + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"invalid_json_field:{field}") from exc + if parsed < 1: + raise ValueError(f"invalid_json_field:{field}") + return parsed + + +def _require_non_empty_str(value, field: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"invalid_json_field:{field}") + return value.strip() + + +def _require_timestamp_str(value, field: str) -> str: + text = _require_non_empty_str(value, field) + normalized = text.replace("Z", "+00:00") + try: + datetime.fromisoformat(normalized) + except ValueError as exc: + raise ValueError(f"invalid_json_field:{field}") from exc + return text + + +def _normalize_refs(value) -> list[dict]: + if not isinstance(value, list): + raise ValueError("invalid_json_field:source_event_refs") + refs: list[dict] = [] + for item in value: + if not isinstance(item, dict): + continue + refs.append(_sanitize_public_payload(item)) + return refs + + +def _normalize_skill_tags(value, default: list[str]) -> list[str]: + if value is None: + return list(default) + if not isinstance(value, list) or not value: + raise ValueError("invalid_json_field:skill_tags") + tags: list[str] = [] + for item in value: + tag = _require_non_empty_str(item, "skill_tags") + if tag not in tags: + tags.append(tag) + return tags + + +def _normalize_difficulty(value) -> str: + if value is None: + return "medium" + difficulty = _require_non_empty_str(value, "difficulty").lower() + if difficulty not in {"easy", "medium", "hard"}: + raise ValueError("invalid_json_field:difficulty") + return difficulty + + +def _normalize_status(value) -> str: + if value is None: + return "draft" + status = _require_non_empty_str(value, "status").lower() + if status not in {"draft", "published"}: + raise ValueError("invalid_json_field:status") + return status + + +def _build_external_id(seed: dict, source_event_type: str, index: int) -> str: + source_event_id = seed.get("source_event_id") + if source_event_id is not None: + return f"generator-{source_event_type}-{int(source_event_id)}" + return f"generator-{source_event_type}-{index + 1}" + + +def _build_title(seed: dict, default_title: str) -> str: + title = seed.get("title") + if title is None: + return default_title + return _require_non_empty_str(title, "title") + + +def _build_public_metadata(seed: dict, source_event_type: str) -> dict: + metadata = { + "generator": { + "mode": "prototype_public_draft", + "version": GENERATOR_VERSION, + "source_event_type": source_event_type, + } + } + symbol = seed.get("symbol") + if isinstance(symbol, str) and symbol.strip(): + metadata["symbol"] = symbol.strip() + chapter_ref = seed.get("chapter_ref") + if isinstance(chapter_ref, str) and chapter_ref.strip(): + metadata["chapter_ref"] = chapter_ref.strip() + public_context = seed.get("public_context") + if isinstance(public_context, dict) and public_context: + metadata["public_context"] = _sanitize_public_payload(public_context) + return metadata + + +def _copy_public_list(items: list[dict]) -> list[dict]: + return [_sanitize_public_payload(dict(item)) for item in items] + + +def _sanitize_public_payload(value): + if isinstance(value, dict): + public_value = {} + for key, item in value.items(): + key_text = str(key) + if key_text in {"answer", "answer_key"}: + continue + public_value[key_text] = _sanitize_public_payload(item) + return public_value + if isinstance(value, list): + return [_sanitize_public_payload(item) for item in value] + if isinstance(value, str): + return value.replace("answer_key", "private solution field") + return value diff --git a/src/services/training/case_import_export.py b/src/services/training/case_import_export.py index 164daec..7255263 100644 --- a/src/services/training/case_import_export.py +++ b/src/services/training/case_import_export.py @@ -4,6 +4,8 @@ from datetime import date, datetime from decimal import Decimal from typing import Optional +from src.services.training.case_quality import attach_case_quality + EXPORT_VERSION = 1 @@ -256,7 +258,7 @@ def _public_case_row(row: dict) -> dict: public_meta = _public_metadata(meta) if public_meta: item["metadata"] = public_meta - return item + return attach_case_quality(item) def _public_scenario(source: dict) -> dict: diff --git a/src/services/training/case_quality.py b/src/services/training/case_quality.py new file mode 100644 index 0000000..50d1388 --- /dev/null +++ b/src/services/training/case_quality.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + + +QUALITY_RANK_HIGH = "high" +QUALITY_RANK_MEDIUM = "medium" +QUALITY_RANK_LOW = "low" +QUALITY_RANKS = (QUALITY_RANK_HIGH, QUALITY_RANK_MEDIUM, QUALITY_RANK_LOW) + +QUALITY_SORT_BY_SCORE_DESC = "quality_score_desc" +QUALITY_SORT_BY_SCORE_ASC = "quality_score_asc" +QUALITY_SORT_BY_CREATED_AT_DESC = "created_at_desc" +QUALITY_SORT_BY_CREATED_AT_ASC = "created_at_asc" +QUALITY_SORT_BY_VALUES = ( + QUALITY_SORT_BY_SCORE_DESC, + QUALITY_SORT_BY_SCORE_ASC, + QUALITY_SORT_BY_CREATED_AT_DESC, + QUALITY_SORT_BY_CREATED_AT_ASC, +) + + +def attach_case_quality(case: dict) -> dict: + payload = dict(case) + payload["quality"] = build_case_quality(payload) + return payload + + +def build_case_quality(case: dict) -> dict: + quality_signals: list[str] = [] + score = 0 + + source_event_refs = _extract_source_event_refs(case) + if source_event_refs: + score += 20 + quality_signals.append("has_source_event_refs") + + tasks = _extract_tasks(case) + if tasks: + score += 20 + quality_signals.append("has_tasks") + + rubric = _extract_rubric(case) + if rubric: + score += 20 + quality_signals.append("has_rubric") + + if _has_valid_decision_window(case): + score += 10 + quality_signals.append("valid_decision_window") + + status = str(case.get("status") or "").strip().lower() + if status == "published": + score += 10 + quality_signals.append("published_case") + + if _extract_chapter_ref(case): + score += 10 + quality_signals.append("chapter_mapped") + + if _has_source_event_context(case): + score += 10 + quality_signals.append("has_source_event_context") + + if len(_extract_skill_tags(case)) > 1: + score += 10 + quality_signals.append("multi_skill_context") + + if score >= 80: + rank = QUALITY_RANK_HIGH + elif score >= 50: + rank = QUALITY_RANK_MEDIUM + else: + rank = QUALITY_RANK_LOW + + return { + "score": score, + "rank": rank, + "signals": quality_signals, + } + + +def select_cases_by_quality( + cases: list[dict], + *, + quality_rank: Optional[str] = None, + min_quality_score: Optional[int] = None, + sort_by: Optional[str] = None, +) -> list[dict]: + selected = [attach_case_quality(case) for case in cases] + + if quality_rank is not None: + selected = [case for case in selected if case.get("quality", {}).get("rank") == quality_rank] + + if min_quality_score is not None: + selected = [case for case in selected if int(case.get("quality", {}).get("score") or 0) >= min_quality_score] + + if sort_by is None: + return selected + + if sort_by == QUALITY_SORT_BY_SCORE_DESC: + return sorted( + selected, + key=lambda case: (-int(case.get("quality", {}).get("score") or 0), _sort_fallback(case)), + ) + if sort_by == QUALITY_SORT_BY_SCORE_ASC: + return sorted( + selected, + key=lambda case: (int(case.get("quality", {}).get("score") or 0), _sort_fallback(case)), + ) + if sort_by == QUALITY_SORT_BY_CREATED_AT_DESC: + return sorted( + selected, + key=lambda case: (_timestamp_sort_value(case.get("created_at")), _sort_fallback(case)), + reverse=True, + ) + if sort_by == QUALITY_SORT_BY_CREATED_AT_ASC: + return sorted( + selected, + key=lambda case: (_timestamp_sort_value(case.get("created_at")), _sort_fallback(case)), + ) + return selected + + +def _extract_case_meta(case: dict) -> dict: + meta = case.get("meta") + if isinstance(meta, dict): + return meta + metadata = case.get("metadata") + if isinstance(metadata, dict): + return metadata + return {} + + +def _extract_scenario(case: dict) -> dict: + scenario = case.get("scenario") + if isinstance(scenario, dict): + return scenario + return case + + +def _extract_source_event_refs(case: dict) -> list: + scenario = _extract_scenario(case) + refs = scenario.get("source_event_refs", case.get("source_event_refs", [])) + return refs if isinstance(refs, list) else [] + + +def _extract_tasks(case: dict) -> list: + direct = case.get("tasks") + if isinstance(direct, list): + return direct + meta = _extract_case_meta(case) + nested = meta.get("tasks") + return nested if isinstance(nested, list) else [] + + +def _extract_rubric(case: dict) -> list: + direct = case.get("rubric") + if isinstance(direct, list): + return direct + meta = _extract_case_meta(case) + nested = meta.get("rubric") + return nested if isinstance(nested, list) else [] + + +def _extract_skill_tags(case: dict) -> list[str]: + direct = case.get("skill_tags") + if isinstance(direct, list): + return [str(item) for item in direct if str(item).strip()] + meta = _extract_case_meta(case) + nested = meta.get("skill_tags") + if isinstance(nested, list): + return [str(item) for item in nested if str(item).strip()] + skill_tag = case.get("skill_tag") + if skill_tag: + return [str(skill_tag)] + return [] + + +def _extract_chapter_ref(case: dict) -> Optional[str]: + scenario = _extract_scenario(case) + for value in ( + scenario.get("chapter_ref"), + case.get("chapter_ref"), + _extract_case_meta(case).get("chapter_ref"), + ): + if isinstance(value, str) and value.strip(): + return value + return None + + +def _has_source_event_context(case: dict) -> bool: + scenario = _extract_scenario(case) + return bool( + scenario.get("source_event_type") + or scenario.get("source_event_id") + or case.get("source_event_type") + or case.get("source_event_id") + ) + + +def _has_valid_decision_window(case: dict) -> bool: + scenario = _extract_scenario(case) + start_ts = _parse_timestamp(scenario.get("start_ts", case.get("start_ts"))) + decision_ts = _parse_timestamp(scenario.get("decision_ts", case.get("decision_ts"))) + end_ts = _parse_timestamp(scenario.get("end_ts", case.get("end_ts"))) + if start_ts is None or decision_ts is None or end_ts is None: + return False + return start_ts <= decision_ts <= end_ts + + +def _parse_timestamp(value) -> Optional[datetime]: + if value is None: + return None + if isinstance(value, datetime): + return value + if isinstance(value, str): + normalized = value.replace("Z", "+00:00") + try: + return datetime.fromisoformat(normalized) + except ValueError: + return None + return None + + +def _timestamp_sort_value(value) -> float: + parsed = _parse_timestamp(value) + if parsed is None: + return 0.0 + return parsed.timestamp() + + +def _sort_fallback(case: dict) -> tuple[str, int]: + title = str(case.get("title") or "") + case_id = int(case.get("id") or 0) + return title, case_id diff --git a/src/services/training/training_case_service.py b/src/services/training/training_case_service.py index 72d6419..84c6af1 100644 --- a/src/services/training/training_case_service.py +++ b/src/services/training/training_case_service.py @@ -1,6 +1,7 @@ from typing import Optional, TYPE_CHECKING from src.services.training.case_import_export import export_training_cases, import_training_cases +from src.services.training.case_quality import attach_case_quality, select_cases_by_quality if TYPE_CHECKING: from src.repositories.training_case_repository import TrainingCaseRepository @@ -24,6 +25,9 @@ class TrainingCaseService: skill_tag: Optional[str] = None, instrument_id: Optional[int] = None, limit: int = 100, + quality_rank: Optional[str] = None, + min_quality_score: Optional[int] = None, + sort_by: Optional[str] = None, ) -> list[dict]: cases = self.repository.list_cases( status=status, @@ -31,11 +35,17 @@ class TrainingCaseService: instrument_id=instrument_id, limit=limit, ) - return [_sanitize_public_export(case) for case in cases] + public_cases = [attach_case_quality(_sanitize_public_export(case)) for case in cases] + return select_cases_by_quality( + public_cases, + quality_rank=quality_rank, + min_quality_score=min_quality_score, + sort_by=sort_by, + ) def get_case(self, case_id: int) -> Optional[dict]: case = self.repository.get_case(case_id, include_answer_key=False) - return _sanitize_public_export(case) if case else None + return attach_case_quality(_sanitize_public_export(case)) if case else None def create_attempt( self, @@ -87,6 +97,9 @@ class TrainingCaseService: skill_tag: Optional[str] = None, instrument_id: Optional[int] = None, limit: int = 100, + quality_rank: Optional[str] = None, + min_quality_score: Optional[int] = None, + sort_by: Optional[str] = None, ) -> dict: cases = self.repository.list_cases( status=status, @@ -94,7 +107,16 @@ class TrainingCaseService: instrument_id=instrument_id, limit=limit, ) - return _sanitize_public_export(export_training_cases(cases)) + exported = _sanitize_public_export(export_training_cases(cases)) + selected_items = select_cases_by_quality( + list(exported.get("items") or []), + quality_rank=quality_rank, + min_quality_score=min_quality_score, + sort_by=sort_by, + ) + exported["items"] = selected_items + exported["metadata"] = {"item_count": len(selected_items)} + return exported def import_cases(self, payload: dict) -> dict: return _sanitize_public_export(import_training_cases(payload, repository=self.repository)) diff --git a/src/web/app.js b/src/web/app.js index 5d8cc41..e8389fa 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -5,6 +5,7 @@ const timeframeInput = document.getElementById('timeframe'); const limitInput = document.getElementById('limit'); const autoRefreshSecondsInput = document.getElementById('autoRefreshSeconds'); const executionStatusFilterInput = document.getElementById('executionStatusFilter'); +const signalStructureStateFilterInput = document.getElementById('signalStructureStateFilter'); const searchTextInput = document.getElementById('searchText'); const lastUpdated = document.getElementById('lastUpdated'); const activeFilters = document.getElementById('activeFilters'); @@ -52,6 +53,12 @@ const TRAINING_DASHBOARD_ROUTE_ANCHORS = Object.freeze([ "fetchJson(`/training/attempts/${attemptId}/submit`, { method: 'POST'", ]); +const LEGACY_SIGNAL_INVALIDATION_CODE_MAP = Object.freeze({ + 'mss invalidated': 'mss_invalidated', + 'fvg fully mitigated': 'fvg_mitigated', + 'session mismatch': 'session_mismatch', +}); + async function fetchJson(url, options = {}) { const fetchOptions = { ...options }; if (fetchOptions.body) { @@ -86,6 +93,19 @@ function formatQualityGate(summary) { return 'unknown'; } +function formatSymbolBreakdown(summary) { + const bySymbol = summary?.by_symbol || {}; + const entries = Object.entries(bySymbol); + if (!entries.length) { + return 'symbols: n/a'; + } + const sorted = entries + .map(([symbol, bucket]) => [symbol, Number(bucket?.count ?? 0)]) + .sort((left, right) => right[1] - left[1]); + const [topSymbol, topCount] = sorted[0]; + return sorted.length === 1 ? `symbol: ${topSymbol} (${topCount})` : `symbols: ${sorted.length}, top ${topSymbol} (${topCount})`; +} + function clearNode(node) { while (node.firstChild) { node.removeChild(node.firstChild); @@ -133,10 +153,14 @@ function renderTable(table, columns, rows, onRowClick, rowKey) { table.appendChild(tbody); } +function getDashboardSearchText() { + return String(searchTextInput.value || '').trim().toLowerCase(); +} + function applyExecutionFilters(items) { const statusFilter = executionStatusFilterInput.value || 'all'; - const search = String(searchTextInput.value || '').trim().toLowerCase(); - const filtered = items.filter((item) => { + const search = getDashboardSearchText(); + return items.filter((item) => { const statusOk = statusFilter === 'all' || item.execution_status === statusFilter; const haystack = [ item.execution_id, @@ -151,19 +175,103 @@ function applyExecutionFilters(items) { const searchOk = !search || haystack.includes(search); return statusOk && searchOk; }); - const active = []; - if (statusFilter !== 'all') active.push(`status=${statusFilter}`); - if (search) active.push(`search=${search}`); - setText(activeFilters, `Filters: ${active.length ? active.join(', ') : 'none'}`); - return filtered; } function applyTextFilters(items, keys) { - const search = String(searchTextInput.value || '').trim().toLowerCase(); + const search = getDashboardSearchText(); if (!search) return items; return items.filter((item) => keys.some((key) => String(item[key] ?? '').toLowerCase().includes(search))); } +function normalizeSignalInvalidationCode(value) { + const raw = String(value ?? '').trim().toLowerCase(); + if (!raw) return null; + return LEGACY_SIGNAL_INVALIDATION_CODE_MAP[raw] || raw.replace(/ /g, '_'); +} + +function getNormalizedSignalInvalidations(item) { + const values = Array.isArray(item?.invalidations_normalized) ? item.invalidations_normalized : item?.invalidations || []; + const normalized = []; + const seen = new Set(); + values.forEach((value) => { + const canonical = normalizeSignalInvalidationCode(value); + if (!canonical || seen.has(canonical)) return; + seen.add(canonical); + normalized.push(canonical); + }); + return normalized; +} + +function getSignalDataQualityWarnings(item) { + if (Array.isArray(item?.fvg_data_quality_warnings)) return item.fvg_data_quality_warnings; + if (Array.isArray(item?.signal_context?.fvg_data_quality_warnings)) return item.signal_context.fvg_data_quality_warnings; + if (Array.isArray(item?.meta?.fvg_data_quality_warnings)) return item.meta.fvg_data_quality_warnings; + return []; +} + +function getSignalStructureState(item) { + return item?.structure_state || item?.signal_context?.structure_state || item?.meta?.structure_state || 'unknown'; +} + +function buildSignalContextPayload(item) { + return { + structure_state: getSignalStructureState(item), + mss_state: item?.mss_state || item?.signal_context?.mss_state || item?.meta?.mss_state || null, + mss_status_reason: item?.mss_status_reason || item?.signal_context?.mss_status_reason || item?.meta?.mss_status_reason || null, + fvg_status: item?.fvg_status || item?.signal_context?.fvg_status || item?.meta?.fvg_status || null, + fvg_status_reason: item?.fvg_status_reason || item?.signal_context?.fvg_status_reason || item?.meta?.fvg_status_reason || null, + fvg_fill_pct: item?.fvg_fill_pct ?? item?.signal_context?.fvg_fill_pct ?? item?.meta?.fvg_fill_pct ?? null, + fvg_touch_count: item?.fvg_touch_count ?? item?.signal_context?.fvg_touch_count ?? item?.meta?.fvg_touch_count ?? null, + fvg_data_quality_warnings: getSignalDataQualityWarnings(item), + invalidations_normalized: getNormalizedSignalInvalidations(item), + }; +} + +function buildSignalDetailPayload(item) { + return { + ...item, + invalidations_normalized: getNormalizedSignalInvalidations(item), + signal_context: buildSignalContextPayload(item), + }; +} + +function formatListForCell(values, fallback = '-') { + return values.length ? values.join(', ') : fallback; +} + +function applySignalFilters(items) { + const structureStateFilter = signalStructureStateFilterInput?.value || 'all'; + const search = getDashboardSearchText(); + return items.filter((item) => { + const structureState = getSignalStructureState(item); + const structureOk = structureStateFilter === 'all' || structureState === structureStateFilter; + const haystack = [ + item.id, + item.side, + item.status, + item.session_code, + structureState, + item.mss_status_reason, + item.fvg_status_reason, + ...getNormalizedSignalInvalidations(item), + ...getSignalDataQualityWarnings(item), + ].join(' ').toLowerCase(); + const searchOk = !search || haystack.includes(search); + return structureOk && searchOk; + }); +} + +function updateActiveFilters() { + const active = []; + const executionStatus = executionStatusFilterInput.value || 'all'; + const structureState = signalStructureStateFilterInput?.value || 'all'; + const search = getDashboardSearchText(); + if (executionStatus !== 'all') active.push(`execution=${executionStatus}`); + if (structureState !== 'all') active.push(`structure=${structureState}`); + if (search) active.push(`search=${search}`); + setText(activeFilters, `Filters: ${active.length ? active.join(', ') : 'none'}`); +} + function setSelectedDetail(title, payload, key) { selectedDetailKey = key; showDetail(title, payload); @@ -184,25 +292,40 @@ function cardDetailHandler(titleBuilder, keyValue, payload) { } function renderDashboardData(health, candles, signals, sessions, executions, backtests) { - const signalItems = applyTextFilters(signals.items || [], ['id', 'side', 'status', 'session_code']); + const signalItems = applySignalFilters(signals.items || []); const sessionItems = applyTextFilters(sessions.items || [], ['id', 'session_code', 'status', 'session_date']); const executionItems = applyExecutionFilters(executions.items || []); const backtestItems = applyTextFilters(backtests.items || [], ['id']); renderHealth(health); + updateActiveFilters(); renderOverview(signalItems, sessionItems, executionItems, backtestItems); renderTable(signalsTable, [ tableColumn('id', 'ID'), tableColumn('side', 'Side'), statusColumn('status', 'Status'), + statusColumn('structure_state', 'Structure'), + tableColumn('mss_status_reason', 'MSS Reason'), + tableColumn('fvg_status_reason', 'FVG Reason'), + { + key: 'warnings', + label: 'Warnings', + render: (row) => formatListForCell(getSignalDataQualityWarnings(row)), + }, + { + key: 'invalidations', + label: 'Invalidations', + render: (row) => formatListForCell(getNormalizedSignalInvalidations(row)), + }, tableColumn('entry_low', 'Entry Low'), tableColumn('entry_high', 'Entry High'), - tableColumn('stop_loss', 'SL'), - tableColumn('tp1', 'TP1'), - tableColumn('tp2', 'TP2'), tableColumn('session_code', 'Session'), tableColumn('created_ts', 'Created'), - ], signalItems, rowDetailHandler((row) => `Signal #${row.id}`, 'id'), 'id'); + ], signalItems, (row) => { + const structureState = getSignalStructureState(row); + setSelectedDetail(`Signal #${row.id} [${structureState}]`, buildSignalDetailPayload(row), row.id); + refreshDashboard(); + }, 'id'); renderTable(sessionsTable, [ tableColumn('id', 'ID'), tableColumn('session_code', 'Session'), @@ -277,11 +400,15 @@ function renderBacktests(items) { drawdown.className = 'metric'; setText(drawdown, `max DD R: ${Number(summary.max_drawdown_r ?? 0).toFixed(2)}`); + const symbols = document.createElement('div'); + symbols.className = 'metric'; + setText(symbols, formatSymbolBreakdown(summary)); + const gate = document.createElement('div'); gate.className = `status-pill status-${normalizeStatusClass(formatQualityGate(summary))}`; setText(gate, `gate: ${formatQualityGate(summary)}`); - card.append(run, winRate, label, signals, executed, mfe, mae, expectancy, executedExpectancy, drawdown, gate); + card.append(run, winRate, label, signals, executed, mfe, mae, expectancy, executedExpectancy, drawdown, symbols, gate); backtestsCards.appendChild(card); }); } @@ -525,6 +652,17 @@ function trainingCaseTasks(item) { return []; } +function trainingCaseQuality(item) { + const quality = item?.quality; + return quality && typeof quality === 'object' ? quality : null; +} + +function trainingQualitySummary(item) { + const quality = trainingCaseQuality(item); + if (!quality) return 'quality: n/a'; + return `quality: ${trainingDisplayText(quality.rank, 'unknown')} (${quality.score ?? 'n/a'})`; +} + function appendTrainingField(parent, label, value) { const row = document.createElement('div'); row.className = 'metric'; @@ -559,9 +697,12 @@ function renderTrainingCaseDetail(item) { appendTrainingField(trainingCaseDetail, 'timeframe', scenario.timeframe || item.timeframe); appendTrainingField(trainingCaseDetail, 'type', scenario.case_type || item.case_type); appendTrainingField(trainingCaseDetail, 'difficulty', item.difficulty); + appendTrainingField(trainingCaseDetail, 'quality', trainingQualitySummary(item).replace('quality: ', '')); appendTrainingField(trainingCaseDetail, 'decision', scenario.decision_ts || item.decision_ts); appendTrainingField(trainingCaseDetail, 'tasks', tasks.length); appendTrainingField(trainingCaseDetail, 'source refs', Array.isArray(refs) ? refs.length : 0); + const qualitySignals = Array.isArray(trainingCaseQuality(item)?.signals) ? trainingCaseQuality(item).signals : []; + appendTrainingField(trainingCaseDetail, 'quality signals', qualitySignals.length ? qualitySignals.join(', ') : 'n/a'); } function renderTrainingCases(items) { @@ -600,6 +741,10 @@ function renderTrainingCases(items) { status.className = `status-pill status-${normalizeStatusClass(item.status || 'unknown')}`; setText(status, trainingDisplayText(item.status, 'unknown')); + const quality = document.createElement('div'); + quality.className = 'metric'; + setText(quality, trainingQualitySummary(item)); + const actions = document.createElement('div'); actions.className = 'training-case-actions'; @@ -616,7 +761,7 @@ function renderTrainingCases(items) { startBtn.addEventListener('click', () => startTrainingAttempt(item)); actions.append(detailBtn, startBtn); - card.append(title, skill, frame, status, actions); + card.append(title, skill, frame, quality, status, actions); trainingCasesList.appendChild(card); }); @@ -913,6 +1058,9 @@ function configureAutoRefresh() { refreshBtn.addEventListener('click', refreshDashboard); executionStatusFilterInput.addEventListener('change', refreshDashboard); +if (signalStructureStateFilterInput) { + signalStructureStateFilterInput.addEventListener('change', refreshDashboard); +} searchTextInput.addEventListener('input', refreshDashboard); autoRefreshSecondsInput.addEventListener('change', configureAutoRefresh); if (trainingSaveAnnotationBtn) { diff --git a/src/web/index.html b/src/web/index.html index 3031738..d87ee49 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -36,8 +36,18 @@ +
尚未刷新
diff --git a/src/web/styles.css b/src/web/styles.css index b3c1d1f..4536d27 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -82,11 +82,11 @@ body { background: rgba(34, 197, 94, 0.18); color: #86efac; } -.status-closed, .status-tp2_closed { +.status-closed, .status-tp2_closed, .status-breakeven { background: rgba(79, 124, 255, 0.18); color: #93c5fd; } -.status-review, .status-stopped_out, .status-expired, .status-rejected, .status-loss, .status-missed, .status-blocked { +.status-review, .status-stopped_out, .status-expired, .status-invalidated_before_entry, .status-rejected, .status-loss, .status-missed, .status-blocked { background: rgba(239, 68, 68, 0.18); color: #fca5a5; } diff --git a/tests/fixtures/training_case_generator_seed_v1.json b/tests/fixtures/training_case_generator_seed_v1.json new file mode 100644 index 0000000..b99b41f --- /dev/null +++ b/tests/fixtures/training_case_generator_seed_v1.json @@ -0,0 +1,55 @@ +{ + "version": 1, + "metadata": { + "source": "generator_proto_fixture", + "mode": "offline" + }, + "items": [ + { + "source_event_type": "sweep_event", + "source_event_id": 61, + "instrument_id": 1, + "symbol": "BTC-USDT", + "timeframe": "15m", + "start_ts": "2026-04-20T13:00:00Z", + "decision_ts": "2026-04-20T14:00:00Z", + "end_ts": "2026-04-20T15:00:00Z", + "difficulty": "easy", + "status": "draft", + "chapter_ref": "generator_proto_sweep", + "source_event_refs": [ + { + "kind": "sweep_event", + "id": 61 + } + ], + "public_context": { + "session_code": "LONDON", + "structure_state": "aligned" + } + }, + { + "source_event_type": "fvg_zone", + "source_event_id": 81, + "instrument_id": 2, + "symbol": "ETH-USDT", + "timeframe": "5m", + "start_ts": "2026-04-21T09:00:00Z", + "decision_ts": "2026-04-21T09:15:00Z", + "end_ts": "2026-04-21T09:30:00Z", + "difficulty": "medium", + "status": "draft", + "chapter_ref": "generator_proto_fvg", + "source_event_refs": [ + { + "kind": "fvg_zone", + "id": 81 + } + ], + "public_context": { + "fvg_status": "open", + "fvg_status_reason": "no_touch" + } + } + ] +} diff --git a/tests/unit/test_api_and_main.py b/tests/unit/test_api_and_main.py index c9f166d..a931d51 100644 --- a/tests/unit/test_api_and_main.py +++ b/tests/unit/test_api_and_main.py @@ -51,6 +51,7 @@ class ApiTests(unittest.TestCase): "id": 1, "side": "buy", "status": "awaiting_confirmation", + "invalidations": ["mss invalidated", "fvg fully mitigated"], "meta": { "structure_state": "aligned", "mss_state": "accepted", @@ -76,6 +77,9 @@ class ApiTests(unittest.TestCase): self.assertEqual(result[0]["fvg_fill_pct"], 0.0) self.assertEqual(result[0]["fvg_touch_count"], 0) self.assertEqual(result[0]["fvg_data_quality_warnings"], []) + self.assertEqual(result[0]["invalidations_normalized"], ["mss_invalidated", "fvg_mitigated"]) + self.assertEqual(result[0]["signal_context"]["structure_state"], "aligned") + self.assertEqual(result[0]["signal_context"]["invalidations_normalized"], ["mss_invalidated", "fvg_mitigated"]) def test_get_recent_backtest_summaries_uses_session_query(self) -> None: rows = [{"id": 10, "summary": {"total_signals": 3}}] @@ -86,11 +90,12 @@ class ApiTests(unittest.TestCase): self.assertIn("quality_gate", result[0]["summary"]) def test_normalize_backtest_summary_row_preserves_existing_quality_gate(self) -> None: - row = {"id": 10, "summary": {"total_signals": 3, "quality_gate": {"passed": True}}} + row = {"id": 10, "summary": {"total_signals": 3, "quality_gate": {"passed": True}, "by_symbol": {"BTC-USDT": {"count": 3}}}} result = normalize_backtest_summary_row(row) self.assertEqual(result["summary"]["quality_gate"], {"passed": True}) + self.assertEqual(result["summary"]["by_symbol"]["BTC-USDT"]["count"], 3) def test_route_request_signals_returns_items_payload(self) -> None: status, payload = route_request( diff --git a/tests/unit/test_api_static_server.py b/tests/unit/test_api_static_server.py index 34fdf2c..08d0bb5 100644 --- a/tests/unit/test_api_static_server.py +++ b/tests/unit/test_api_static_server.py @@ -52,6 +52,23 @@ class ApiStaticServerTests(unittest.TestCase): self.assertIn("executed_signals", app_js) self.assertIn("executed_expectancy_r", app_js) self.assertIn("Latest Executed", app_js) + self.assertIn("by_symbol", app_js) + self.assertIn("formatSymbolBreakdown", app_js) + + def test_dashboard_surfaces_signal_structure_filters_and_normalized_invalidations(self) -> None: + index_file = _resolve_static_file("/") + self.assertIsNotNone(index_file) + index_html = index_file.read_text(encoding="utf-8") + + app_file = _resolve_static_file("/static/app.js") + self.assertIsNotNone(app_file) + app_js = app_file.read_text(encoding="utf-8") + + self.assertIn('id="signalStructureStateFilter"', index_html) + self.assertIn("invalidations_normalized", app_js) + self.assertIn("structure_state", app_js) + self.assertIn("mss_status_reason", app_js) + self.assertIn("fvg_status_reason", app_js) if __name__ == "__main__": diff --git a/tests/unit/test_backtest_report_service.py b/tests/unit/test_backtest_report_service.py index b93fcf5..c890f64 100644 --- a/tests/unit/test_backtest_report_service.py +++ b/tests/unit/test_backtest_report_service.py @@ -22,6 +22,7 @@ class BacktestSummaryTests(unittest.TestCase): error_tags=[], meta={ "signal_side": "buy", + "signal_symbol": "BTC-USDT", "session_codes": ["LONDON"], "weekday": "Monday", }, @@ -37,6 +38,7 @@ class BacktestSummaryTests(unittest.TestCase): error_tags=["stopped_out"], meta={ "signal_side": "sell", + "signal_symbol": "ETH-USDT", "session_codes": ["NY_AM"], "weekday": "Monday", }, @@ -52,6 +54,7 @@ class BacktestSummaryTests(unittest.TestCase): error_tags=["entry_not_triggered"], meta={ "signal_side": "buy", + "signal_symbol": "BTC-USDT", "session_codes": ["LONDON", "NY_AM"], "weekday": "Tuesday", }, @@ -64,9 +67,14 @@ class BacktestSummaryTests(unittest.TestCase): self.assertEqual(summary["outcomes"]["win"], 1) self.assertEqual(summary["outcomes"]["loss"], 1) self.assertEqual(summary["outcomes"]["missed"], 1) + self.assertEqual(summary["lifecycle_states"]["tp2_closed"], 1) + self.assertEqual(summary["lifecycle_states"]["stopped_out"], 1) + self.assertEqual(summary["lifecycle_states"]["invalidated_before_entry"], 1) self.assertAlmostEqual(summary["win_rate"], 1 / 3) self.assertEqual(summary["by_side"]["buy"]["count"], 2) self.assertEqual(summary["by_side"]["sell"]["losses"], 1) + self.assertEqual(summary["by_symbol"]["BTC-USDT"]["count"], 2) + self.assertEqual(summary["by_symbol"]["ETH-USDT"]["losses"], 1) self.assertEqual(summary["by_session"]["LONDON"]["count"], 2) self.assertEqual(summary["by_session"]["NY_AM"]["count"], 2) self.assertEqual(summary["by_weekday"]["Monday"]["count"], 2) @@ -83,7 +91,7 @@ class BacktestSummaryTests(unittest.TestCase): tp2_hit=False, invalidated_before_entry=False, error_tags=[], - meta={"signal_side": "buy", "session_codes": ["NY_PM"], "weekday": "Friday"}, + meta={"signal_side": "buy", "signal_symbol": "BTC-USDT", "session_codes": ["NY_PM"], "weekday": "Friday"}, ) ] @@ -92,6 +100,7 @@ class BacktestSummaryTests(unittest.TestCase): self.assertEqual(summary["by_session"]["NY_PM"]["breakevens"], 1) self.assertEqual(summary["by_side"]["buy"]["count"], 1) + self.assertEqual(summary["by_symbol"]["BTC-USDT"]["breakevens"], 1) self.assertIn("quality_gate", summary) self.assertFalse(summary["quality_gate"]["passed"]) @@ -106,7 +115,7 @@ class BacktestSummaryTests(unittest.TestCase): tp2_hit=True, invalidated_before_entry=False, error_tags=[], - meta={"signal_side": "buy", "session_codes": ["LONDON"], "weekday": "Friday", "r_multiple": 2.0}, + meta={"signal_side": "buy", "signal_symbol": "BTC-USDT", "session_codes": ["LONDON"], "weekday": "Friday", "r_multiple": 2.0}, ) ] diff --git a/tests/unit/test_backtest_service.py b/tests/unit/test_backtest_service.py index 0aa2f17..cb9f4cb 100644 --- a/tests/unit/test_backtest_service.py +++ b/tests/unit/test_backtest_service.py @@ -6,6 +6,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost: from src.services.backtest import ( BacktestCostConfig, + classify_backtest_lifecycle_state, BacktestOutcome, BacktestQualityGate, BacktestReplayConfig, @@ -25,6 +26,28 @@ from src.repositories.backtest_repository import BacktestRepository class BacktestOutcomeTests(unittest.TestCase): + def test_classifies_backtest_lifecycle_states(self) -> None: + self.assertEqual( + classify_backtest_lifecycle_state(outcome="win", tp1_hit=True, tp2_hit=True, invalidated_before_entry=False), + "tp2_closed", + ) + self.assertEqual( + classify_backtest_lifecycle_state(outcome="breakeven", tp1_hit=True, tp2_hit=False, invalidated_before_entry=False), + "tp1_reached", + ) + self.assertEqual( + classify_backtest_lifecycle_state(outcome="loss", tp1_hit=False, tp2_hit=False, invalidated_before_entry=False), + "stopped_out", + ) + self.assertEqual( + classify_backtest_lifecycle_state(outcome="missed", tp1_hit=False, tp2_hit=False, invalidated_before_entry=False), + "missed", + ) + self.assertEqual( + classify_backtest_lifecycle_state(outcome="missed", tp1_hit=False, tp2_hit=False, invalidated_before_entry=True), + "invalidated_before_entry", + ) + def test_buy_signal_uses_stop_first_when_entry_and_target_are_ambiguous(self) -> None: signal = BacktestSignal( signal_id=1, @@ -181,7 +204,7 @@ class BacktestOutcomeTests(unittest.TestCase): tp2_hit=True, invalidated_before_entry=False, error_tags=[], - meta={"signal_side": "buy", "session_codes": ["LONDON"], "weekday": "Monday", "mfe_r": 2.6, "mae_r": 0.4, "cost_r": 0.1, "r_multiple": 2.4}, + meta={"signal_side": "buy", "signal_symbol": "BTC-USDT", "session_codes": ["LONDON"], "weekday": "Monday", "mfe_r": 2.6, "mae_r": 0.4, "cost_r": 0.1, "r_multiple": 2.4}, ), BacktestOutcome( signal_id=2, @@ -192,7 +215,7 @@ class BacktestOutcomeTests(unittest.TestCase): tp2_hit=False, invalidated_before_entry=False, error_tags=["stopped_out"], - meta={"signal_side": "buy", "session_codes": ["LONDON"], "weekday": "Monday", "mfe_r": 0.2, "mae_r": 1.2, "cost_r": 0.1, "r_multiple": -1.1}, + meta={"signal_side": "buy", "signal_symbol": "BTC-USDT", "session_codes": ["LONDON"], "weekday": "Monday", "mfe_r": 0.2, "mae_r": 1.2, "cost_r": 0.1, "r_multiple": -1.1}, ), BacktestOutcome( signal_id=3, @@ -203,7 +226,7 @@ class BacktestOutcomeTests(unittest.TestCase): tp2_hit=False, invalidated_before_entry=False, error_tags=["stopped_out"], - meta={"signal_side": "buy", "session_codes": ["LONDON"], "weekday": "Monday", "mfe_r": 0.1, "mae_r": 1.1, "cost_r": 0.1, "r_multiple": -1.1}, + meta={"signal_side": "buy", "signal_symbol": "BTC-USDT", "session_codes": ["LONDON"], "weekday": "Monday", "mfe_r": 0.1, "mae_r": 1.1, "cost_r": 0.1, "r_multiple": -1.1}, ), BacktestOutcome( signal_id=4, @@ -214,13 +237,18 @@ class BacktestOutcomeTests(unittest.TestCase): tp2_hit=False, invalidated_before_entry=False, error_tags=["entry_not_triggered"], - meta={"signal_side": "sell", "session_codes": ["NY_AM"], "weekday": "Tuesday", "mfe_r": 0.0, "mae_r": 0.0, "cost_r": 0.0, "r_multiple": 0.0}, + meta={"signal_side": "sell", "signal_symbol": "ETH-USDT", "session_codes": ["NY_AM"], "weekday": "Tuesday", "mfe_r": 0.0, "mae_r": 0.0, "cost_r": 0.0, "r_multiple": 0.0}, ), ] summary = summarize_backtest_outcomes(outcomes) self.assertAlmostEqual(summary["total_r"], 0.2) + self.assertEqual(summary["lifecycle_states"]["tp2_closed"], 1) + self.assertEqual(summary["lifecycle_states"]["stopped_out"], 2) + self.assertEqual(summary["lifecycle_states"]["missed"], 1) + self.assertEqual(summary["by_symbol"]["BTC-USDT"]["count"], 3) + self.assertEqual(summary["by_symbol"]["ETH-USDT"]["misses"], 1) self.assertAlmostEqual(summary["expectancy_r"], 0.05) self.assertAlmostEqual(summary["profit_factor_r"], 2.4 / 2.2) self.assertEqual(summary["executed_signals"], 3) @@ -328,6 +356,7 @@ class BacktestServiceRunTests(unittest.TestCase): "tp2": 104.0, "rr_tp1": 1.0, "rr_tp2": 2.0, + "symbol": "BTC-USDT", } ] @@ -356,6 +385,8 @@ class BacktestServiceRunTests(unittest.TestCase): self.assertEqual(run_id, 42) self.assertEqual(len(outcomes), 1) self.assertEqual(len(repository.results), 1) + self.assertEqual(repository.results[0]["meta"]["signal_symbol"], "BTC-USDT") + self.assertEqual(repository.summary["by_symbol"]["BTC-USDT"]["count"], 1) self.assertIn("quality_gate", repository.summary) self.assertFalse(repository.summary["quality_gate"]["passed"]) self.assertIn("sample_size_too_small", repository.summary["quality_gate"]["reasons"]) @@ -387,6 +418,7 @@ class BacktestServiceRunTests(unittest.TestCase): "tp2": 103.0, "rr_tp1": 1.0, "rr_tp2": 3.0, + "symbol": "BTC-USDT", } ] diff --git a/tests/unit/test_execution_service.py b/tests/unit/test_execution_service.py index 0f3986b..9e20532 100644 --- a/tests/unit/test_execution_service.py +++ b/tests/unit/test_execution_service.py @@ -234,6 +234,8 @@ class ExecutionAuditTests(unittest.TestCase): self.assertEqual(classify_execution_lifecycle("open", {"tp1_hit": True, "tp2_hit": False, "outcome": "breakeven", "invalidated_before_entry": False}), "tp1_reached") self.assertEqual(classify_execution_lifecycle("open", {"tp1_hit": True, "tp2_hit": True, "outcome": "win", "invalidated_before_entry": False}), "tp2_closed") self.assertEqual(classify_execution_lifecycle("open", {"tp1_hit": False, "tp2_hit": False, "outcome": "loss", "invalidated_before_entry": False}), "stopped_out") + self.assertEqual(classify_execution_lifecycle("open", {"tp1_hit": False, "tp2_hit": False, "outcome": "missed", "invalidated_before_entry": False}), "missed") + self.assertEqual(classify_execution_lifecycle("open", {"tp1_hit": False, "tp2_hit": False, "outcome": "missed", "invalidated_before_entry": True}), "invalidated_before_entry") def test_audit_execution_reports_review_state(self) -> None: audit = ExecutionService().audit_execution( @@ -246,6 +248,7 @@ class ExecutionAuditTests(unittest.TestCase): self.assertIn("execution_rejected", audit["error_tags"]) self.assertIn("session_not_allowed", audit["error_tags"]) self.assertEqual(audit["lifecycle_state"], "rejected") + self.assertEqual(audit["backtest_lifecycle_state"], "stopped_out") self.assertTrue(audit["stop_loss_hit"]) def test_settle_execution_from_backtest_closes_tp2_hit(self) -> None: @@ -282,6 +285,28 @@ class ExecutionAuditTests(unittest.TestCase): self.assertFalse(settled["meta"]["tp2_hit"]) self.assertFalse(settled["meta"]["stop_loss_hit"]) + def test_settle_execution_from_backtest_closes_invalidated_before_entry(self) -> None: + settled = ExecutionService().settle_execution_from_backtest( + trade_execution={"status": "open", "meta": {}, "closed_ts": None}, + backtest_result={"outcome": "missed", "tp1_hit": False, "tp2_hit": False, "invalidated_before_entry": True}, + ) + + self.assertEqual(settled["status"], "closed") + self.assertEqual(settled["meta"]["lifecycle_state"], "invalidated_before_entry") + self.assertEqual(settled["meta"]["backtest_lifecycle_state"], "invalidated_before_entry") + self.assertTrue(settled["meta"]["invalidated_before_entry"]) + + def test_settle_execution_from_backtest_closes_missed_without_entry_invalidation(self) -> None: + settled = ExecutionService().settle_execution_from_backtest( + trade_execution={"status": "open", "meta": {}, "closed_ts": None}, + backtest_result={"outcome": "missed", "tp1_hit": False, "tp2_hit": False, "invalidated_before_entry": False}, + ) + + self.assertEqual(settled["status"], "closed") + self.assertEqual(settled["meta"]["lifecycle_state"], "missed") + self.assertEqual(settled["meta"]["backtest_lifecycle_state"], "missed") + self.assertFalse(settled["meta"]["invalidated_before_entry"]) + def test_persist_execution_settlement_updates_repository_when_execution_has_id(self) -> None: repository = Mock() service = ExecutionService(repository=repository) diff --git a/tests/unit/test_historical_compatibility_repair_script.py b/tests/unit/test_historical_compatibility_repair_script.py new file mode 100644 index 0000000..1f113df --- /dev/null +++ b/tests/unit/test_historical_compatibility_repair_script.py @@ -0,0 +1,111 @@ +import contextlib +import io +import json +import os +import unittest +from unittest.mock import patch + +from sqlalchemy.exc import OperationalError + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") + +from scripts import run_historical_compatibility_repair + + +class _MappingsResult: + def __init__(self, rows): + self._rows = rows + + def mappings(self): + return self + + def all(self): + return self._rows + + +class _ConnectionStub: + def __init__(self): + self.updates = [] + + def execute(self, sql, params=None): + sql_text = str(sql).lower() + if "from trade_signals" in sql_text: + return _MappingsResult([{"id": 1, "invalidations": ["mss invalidated"]}]) + if "from structure_events" in sql_text: + return _MappingsResult([{"id": 2, "accepted": True, "meta": {}}]) + if "from fvg_zones" in sql_text and "select" in sql_text: + return _MappingsResult([{"id": 3, "status": "open", "meta": {}}]) + if "from liquidity_pools" in sql_text: + return _MappingsResult( + [ + { + "instrument_id": 1, + "timeframe": "1m", + "side": "buy_side", + "price_low": 100.0, + "price_high": 100.0, + "duplicate_count": 2, + "ids": [10, 9], + } + ] + ) + self.updates.append({"sql": sql_text, "params": params}) + return _MappingsResult([]) + + +class _BeginStub: + def __init__(self, connection): + self.connection = connection + + def __enter__(self): + return self.connection + + def __exit__(self, exc_type, exc, tb): + return False + + +class HistoricalCompatibilityRepairScriptTests(unittest.TestCase): + def test_dry_run_reports_candidate_repairs_without_updates(self) -> None: + connection = _ConnectionStub() + + with patch("scripts.run_historical_compatibility_repair.engine.begin", return_value=_BeginStub(connection)): + summary = run_historical_compatibility_repair.run_historical_compatibility_repair() + + self.assertTrue(summary["passed"]) + self.assertTrue(summary["dry_run"]) + self.assertEqual(summary["candidate_repairs"]["signal_invalidations"], 1) + self.assertEqual(summary["candidate_repairs"]["mss_meta"], 1) + self.assertEqual(summary["candidate_repairs"]["fvg_meta"], 1) + self.assertEqual(summary["candidate_repairs"]["liquidity_duplicate_groups"], 1) + self.assertEqual(connection.updates, []) + + def test_apply_safe_repairs_updates_supported_categories(self) -> None: + connection = _ConnectionStub() + + with patch("scripts.run_historical_compatibility_repair.engine.begin", return_value=_BeginStub(connection)): + summary = run_historical_compatibility_repair.run_historical_compatibility_repair( + apply_signal_invalidations=True, + apply_mss_meta=True, + apply_fvg_meta=True, + ) + + self.assertFalse(summary["dry_run"]) + self.assertEqual(summary["applied_repairs"]["signal_invalidations"], 1) + self.assertEqual(summary["applied_repairs"]["mss_meta"], 1) + self.assertEqual(summary["applied_repairs"]["fvg_meta"], 1) + self.assertEqual(len(connection.updates), 3) + + def test_main_reports_database_unavailable_json(self) -> None: + output = io.StringIO() + + with patch("scripts.run_historical_compatibility_repair.engine.begin", side_effect=OperationalError("stmt", {}, Exception())), contextlib.redirect_stdout(output): + exit_code = run_historical_compatibility_repair.main(argv=[], env={"HISTORICAL_REPAIR_OUTPUT_JSON": "1"}) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 2) + self.assertFalse(payload["passed"]) + self.assertEqual(payload["error_type"], "OperationalError") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_historical_compatibility_service.py b/tests/unit/test_historical_compatibility_service.py new file mode 100644 index 0000000..87e6007 --- /dev/null +++ b/tests/unit/test_historical_compatibility_service.py @@ -0,0 +1,84 @@ +import os +import unittest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") + +from src.services.maintenance import ( + build_historical_compatibility_summary, + derive_historical_fvg_meta, + derive_historical_mss_meta, + plan_fvg_meta_repairs, + plan_mss_meta_repairs, + plan_trade_signal_invalidation_repairs, + summarize_liquidity_duplicate_groups, +) + + +class HistoricalCompatibilityServiceTests(unittest.TestCase): + def test_plans_trade_signal_invalidation_repairs(self) -> None: + repairs = plan_trade_signal_invalidation_repairs( + [ + {"id": 1, "invalidations": ["mss invalidated", "fvg fully mitigated"]}, + {"id": 2, "invalidations": ["mss_invalidated"]}, + ] + ) + + self.assertEqual(repairs, [{"id": 1, "invalidations": ["mss_invalidated", "fvg_mitigated"]}]) + + def test_derives_historical_mss_meta(self) -> None: + repaired = derive_historical_mss_meta({"accepted": False, "meta": {"reclaimed_old_control": True}}) + + self.assertEqual(repaired["state"], "invalidated") + self.assertEqual(repaired["status_reason"], "old_control_reclaimed") + + def test_plans_historical_fvg_meta_repairs(self) -> None: + repairs = plan_fvg_meta_repairs( + [ + {"id": 3, "status": "mitigated", "meta": {}}, + {"id": 4, "status": "open", "meta": {"status_reason": "no_touch", "invalidated": False, "has_data_gap": False, "gap_count": 0, "data_quality_warnings": []}}, + ] + ) + + self.assertEqual(len(repairs), 1) + self.assertEqual(repairs[0]["id"], 3) + self.assertEqual(repairs[0]["meta"]["status_reason"], "mitigation_threshold_reached") + self.assertFalse(repairs[0]["meta"]["invalidated"]) + + def test_summarizes_liquidity_duplicate_groups(self) -> None: + summary = summarize_liquidity_duplicate_groups( + [ + { + "instrument_id": 1, + "timeframe": "1m", + "side": "buy_side", + "price_low": 100.0, + "price_high": 100.0, + "duplicate_count": 2, + "ids": [10, 9], + } + ] + ) + + self.assertEqual(summary["group_count"], 1) + self.assertEqual(summary["row_count"], 2) + self.assertEqual(summary["groups"][0]["ids"], [10, 9]) + + def test_builds_historical_compatibility_summary(self) -> None: + summary = build_historical_compatibility_summary( + signal_repairs=[{"id": 1}], + mss_repairs=[{"id": 2}], + fvg_repairs=[], + liquidity_duplicates={"group_count": 1, "row_count": 3, "groups": [{"ids": [9, 8, 7]}]}, + applied_repairs={"signal_invalidations": 1, "mss_meta": 1, "fvg_meta": 0}, + dry_run=False, + ) + + self.assertTrue(summary["passed"]) + self.assertFalse(summary["dry_run"]) + self.assertEqual(summary["candidate_repairs"]["liquidity_duplicate_rows"], 3) + self.assertEqual(summary["applied_repairs"]["signal_invalidations"], 1) + self.assertEqual(summary["manual_review"]["liquidity_duplicates"][0]["ids"], [9, 8, 7]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_report_services.py b/tests/unit/test_report_services.py index c128ec8..754ce1e 100644 --- a/tests/unit/test_report_services.py +++ b/tests/unit/test_report_services.py @@ -53,7 +53,7 @@ class IntradayReportTests(unittest.TestCase): "status": "awaiting_confirmation", "side": "sell", "session_code": "NY_AM", - "invalidations": ["session_mismatch"], + "invalidations": ["session mismatch"], "meta": { "structure_state": "fvg_mitigated", "mss_status_reason": "old_control_reclaimed", @@ -85,7 +85,7 @@ class PostTradeReviewTests(unittest.TestCase): trade_execution={"status": "rejected", "meta": {"risk_checks": ["session_not_allowed"]}}, trade_signal={ "status": "awaiting_context", - "invalidations": ["session_mismatch"], + "invalidations": ["session mismatch"], "meta": { "structure_state": "mss_invalidated", "mss_state": "invalidated", @@ -102,16 +102,30 @@ class PostTradeReviewTests(unittest.TestCase): self.assertFalse(report["rule_aligned"]) self.assertIn("execution_rejected", report["error_types"]) self.assertIn("session_not_allowed", report["error_types"]) + self.assertEqual(report["backtest_lifecycle_state"], "stopped_out") self.assertEqual(report["execution_audit"]["audit_status"], "review") self.assertEqual(report["execution_audit"]["lifecycle_state"], "rejected") + self.assertEqual(report["execution_audit"]["backtest_lifecycle_state"], "stopped_out") self.assertEqual(report["signal_context"]["structure_state"], "mss_invalidated") self.assertEqual(report["signal_context"]["mss_status_reason"], "old_control_reclaimed") self.assertEqual(report["signal_context"]["fvg_status_reason"], "partial_fill") + self.assertEqual(report["signal_context"]["invalidations_normalized"], ["session_mismatch"]) self.assertIn("inspect_stop_and_structure_context", report["improvement_notes"]) self.assertIn("review_signal_structure_state", report["improvement_notes"]) self.assertIn("review_signal_data_quality_warnings", report["improvement_notes"]) self.assertIn("review_same_candle_ambiguity_cases", report["improvement_notes"]) + def test_post_trade_review_distinguishes_missed_from_invalidated_before_entry(self) -> None: + report = PostTradeReviewService().build_report( + trade_execution={"status": "open", "meta": {"risk_checks": []}}, + trade_signal={"status": "awaiting_confirmation", "invalidations": []}, + backtest_result={"outcome": "missed", "invalidated_before_entry": False, "tp1_hit": False, "tp2_hit": False, "error_tags": ["entry_not_triggered"]}, + ) + + self.assertEqual(report["backtest_lifecycle_state"], "missed") + self.assertIn("review_untriggered_signal_window", report["improvement_notes"]) + self.assertNotIn("avoid_expired_or_invalidated_signals", report["improvement_notes"]) + class ReportScriptTests(unittest.TestCase): def test_run_reports_prints_backtest_quality_gate(self) -> None: diff --git a/tests/unit/test_runtime_script_json.py b/tests/unit/test_runtime_script_json.py index d3ba437..dda66a4 100644 --- a/tests/unit/test_runtime_script_json.py +++ b/tests/unit/test_runtime_script_json.py @@ -13,6 +13,7 @@ from scripts import ( apply_migrations, check_db, check_db_connection, + run_historical_compatibility_repair, run_api_server, run_dev, run_displacement, @@ -44,7 +45,10 @@ class RuntimeScriptJsonTests(unittest.TestCase): with self.subTest(script=module.__name__): output = io.StringIO() with patch(patch_target, side_effect=OperationalError("stmt", {}, Exception())), contextlib.redirect_stdout(output): - exit_code = module.main(env={env_key: "1"}) + if module.__name__ == run_historical_compatibility_repair.__name__: + exit_code = module.main(argv=[], env={env_key: "1"}) + else: + exit_code = module.main(env={env_key: "1"}) payload = json.loads(output.getvalue()) self.assertEqual(exit_code, 2) @@ -57,19 +61,24 @@ class RuntimeScriptJsonTests(unittest.TestCase): cases = [ (check_db, "CHECK_DB_OUTPUT_JSON", "scripts.check_db.engine.connect"), (check_db_connection, "CHECK_DB_CONNECTION_OUTPUT_JSON", "scripts.check_db_connection.engine.connect"), + (run_historical_compatibility_repair, "HISTORICAL_REPAIR_OUTPUT_JSON", "scripts.run_historical_compatibility_repair.engine.begin"), ] for module, env_key, patch_target in cases: with self.subTest(script=module.__name__): output = io.StringIO() with patch(patch_target, side_effect=OperationalError("stmt", {}, Exception())), contextlib.redirect_stdout(output): - exit_code = module.main(env={env_key: "1"}) + if module.__name__ == run_historical_compatibility_repair.__name__: + exit_code = module.main(argv=[], env={env_key: "1"}) + else: + exit_code = module.main(env={env_key: "1"}) payload = json.loads(output.getvalue()) self.assertEqual(exit_code, 2) self.assertFalse(payload["passed"]) - self.assertFalse(payload["available"]) self.assertEqual(payload["error_type"], "OperationalError") + if module in {check_db, check_db_connection}: + self.assertFalse(payload["available"]) def test_show_models_outputs_json(self) -> None: output = io.StringIO() diff --git a/tests/unit/test_signal_service.py b/tests/unit/test_signal_service.py index 1ad697e..21ef188 100644 --- a/tests/unit/test_signal_service.py +++ b/tests/unit/test_signal_service.py @@ -12,6 +12,8 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost: from src.services.signals import ( STRICT_SIGNAL_EVIDENCE_KEYS, classify_session_for_signal, + normalize_signal_invalidations, + select_preferred_fvg, trade_signal_matches_candidate, validate_rr_thresholds, validate_signal_evidence, @@ -46,6 +48,37 @@ class SignalSessionFilterTests(unittest.TestCase): class SignalStructureAlignmentTests(unittest.TestCase): + def test_select_preferred_fvg_prefers_exact_mss_displacement_match_over_newer_unlinked_candidate(self) -> None: + fvgs = [ + {"id": 11, "direction": "bullish", "status": "open", "related_displacement_id": None, "end_ts": 20, "meta": {}}, + {"id": 9, "direction": "bullish", "status": "open", "related_displacement_id": 7, "end_ts": 18, "meta": {}}, + ] + + selected = select_preferred_fvg( + fvgs=fvgs, + mss={"direction": "bullish", "accepted": True, "displacement_event_id": 7, "meta": {"state": "accepted", "status_reason": "retest_held"}}, + ) + + self.assertIsNotNone(selected) + assert selected is not None + self.assertEqual(selected["id"], 9) + + def test_select_preferred_fvg_prefers_newest_same_direction_when_no_exact_context_match_exists(self) -> None: + fvgs = [ + {"id": 15, "direction": "bearish", "status": "open", "related_displacement_id": None, "end_ts": 30, "meta": {}}, + {"id": 13, "direction": "bullish", "status": "open", "related_displacement_id": 5, "end_ts": 25, "meta": {}}, + {"id": 12, "direction": "bullish", "status": "open", "related_displacement_id": None, "end_ts": 24, "meta": {}}, + ] + + selected = select_preferred_fvg( + fvgs=fvgs, + mss={"direction": "bullish", "accepted": True, "displacement_event_id": 7, "meta": {"state": "accepted", "status_reason": "retest_held"}}, + ) + + self.assertIsNotNone(selected) + assert selected is not None + self.assertEqual(selected["id"], 13) + def test_rejects_rejected_mss_with_explicit_state_reason(self) -> None: result = validate_signal_context( bias={"draw_side": "buy_side"}, @@ -158,6 +191,7 @@ class SignalRRFilterTests(unittest.TestCase): self.assertEqual(candidate.meta["fvg_status_reason"], "no_touch") self.assertEqual(candidate.meta["fvg_touch_count"], 0) self.assertEqual(candidate.meta["fvg_data_quality_warnings"], []) + self.assertEqual(candidate.invalidations, ["mss_invalidated", "fvg_mitigated"]) def test_build_signal_rejects_candidate_when_required_evidence_is_missing(self) -> None: service = SignalService( @@ -173,6 +207,11 @@ class SignalRRFilterTests(unittest.TestCase): class SignalEvidenceTests(unittest.TestCase): + def test_normalizes_legacy_signal_invalidations_to_machine_readable_codes(self) -> None: + result = normalize_signal_invalidations(["mss invalidated", "fvg fully mitigated", "session mismatch", "mss_invalidated"]) + + self.assertEqual(result, ["mss_invalidated", "fvg_mitigated", "session_mismatch"]) + def test_minimal_evidence_passes_for_current_signal_chain(self) -> None: result = validate_signal_evidence({"bias_snapshot_id": 1, "fvg_id": 2, "mss_id": 3}) @@ -221,6 +260,9 @@ class _SignalStubRepository: def fetch_latest_fvg(self, instrument_id: int, timeframe: str): return self.fvg + def fetch_recent_fvgs_for_signal(self, instrument_id: int, timeframe: str, limit: int = 20): + return [self.fvg] + def fetch_latest_mss(self, instrument_id: int, timeframe: str): return { "id": 3, @@ -250,7 +292,7 @@ class SignalIdempotencyTests(unittest.TestCase): session_code="LONDON", created_ts="now", expires_ts="later", - invalidations=["mss invalidated"], + invalidations=["mss_invalidated"], evidence={"bias_snapshot_id": 1, "fvg_id": 2, "mss_id": 3}, meta={"execution_permission": True}, ) @@ -288,7 +330,7 @@ class SignalIdempotencyTests(unittest.TestCase): session_code="LONDON", created_ts="now", expires_ts="later", - invalidations=["mss invalidated", "fvg fully mitigated"], + invalidations=["mss_invalidated", "fvg_mitigated"], evidence={"bias_snapshot_id": 8, "fvg_id": 100, "mss_id": 3}, meta={"execution_permission": True, "fvg_fill_pct": 0.0}, ) diff --git a/tests/unit/test_training_api_m2.py b/tests/unit/test_training_api_m2.py index 75cb8ce..bfc243a 100644 --- a/tests/unit/test_training_api_m2.py +++ b/tests/unit/test_training_api_m2.py @@ -130,6 +130,7 @@ class TrainingApiM2Tests(unittest.TestCase): self.assertEqual(status, 200) self.assertEqual(payload["items"][0]["id"], 7) + self.assertEqual(payload["items"][0]["quality"]["rank"], "low") self.assertNotIn("answer_key", payload["items"][0]) self.assertEqual( self.repository.list_args, @@ -141,9 +142,65 @@ class TrainingApiM2Tests(unittest.TestCase): self.assertEqual(status, 200) self.assertEqual(payload["id"], 7) + self.assertEqual(payload["quality"]["rank"], "low") self.assertNotIn("answer_key", payload) self.assertEqual(self.repository.get_args, {"case_id": 7, "include_answer_key": False}) + def test_get_training_cases_supports_quality_selector_filters_and_sort(self) -> None: + class QualityRepository(_FakeTrainingRepository): + def list_cases(self, status=None, skill_tag=None, instrument_id=None, limit=100): + self.list_args = { + "status": status, + "skill_tag": skill_tag, + "instrument_id": instrument_id, + "limit": limit, + } + return [ + { + "id": 7, + "case_type": "sweep", + "skill_tag": "liquidity_pool", + "status": "published", + "source_event_refs": [{"kind": "sweep_event", "id": 1}], + "meta": { + "title": "Higher quality case", + "skill_tags": ["liquidity_pool", "context_reading"], + "rubric": [{"category": "context", "max_score": 5}], + "tasks": [{"task_type": "note", "prompt": "Explain context."}], + "chapter_ref": "chapter_01", + }, + "start_ts": "2026-04-01T09:00:00Z", + "decision_ts": "2026-04-01T10:00:00Z", + "end_ts": "2026-04-01T11:00:00Z", + }, + { + "id": 8, + "case_type": "sweep", + "skill_tag": "liquidity_pool", + "status": "draft", + "meta": {"title": "Lower quality case"}, + }, + ] + + repository = QualityRepository() + service = TrainingCaseService(repository=repository) + dependencies = _dependencies(service) + + status, payload = route_request( + "/training/cases", + { + "quality_rank": ["high"], + "min_quality_score": ["80"], + "sort_by": ["quality_score_desc"], + }, + dependencies=dependencies, + ) + + self.assertEqual(status, 200) + self.assertEqual([item["id"] for item in payload["items"]], [7]) + self.assertEqual(payload["items"][0]["quality"]["rank"], "high") + self.assertGreaterEqual(payload["items"][0]["quality"]["score"], 80) + def test_post_training_attempt_creates_attempt(self) -> None: status, payload = route_request( "/training/attempts", diff --git a/tests/unit/test_training_api_m4.py b/tests/unit/test_training_api_m4.py index d6d0900..dd6de29 100644 --- a/tests/unit/test_training_api_m4.py +++ b/tests/unit/test_training_api_m4.py @@ -122,6 +122,20 @@ class TrainingApiM4Tests(unittest.TestCase): self.assertEqual(body, {"error": "internal_server_error"}) self.assertNotIn("raw internal failure marker", serialized) + def test_training_case_selector_queries_reject_invalid_values(self) -> None: + for path, expected in ( + ("/training/cases?quality_rank=elite", {"error": "invalid_quality_rank_query_param:quality_rank"}), + ("/training/cases?min_quality_score=-1", {"error": "invalid_min_quality_score_query_param:min_quality_score"}), + ("/training/cases?min_quality_score=101", {"error": "invalid_min_quality_score_query_param:min_quality_score"}), + ("/training/cases?sort_by=unknown", {"error": "invalid_sort_query_param:sort_by"}), + ): + with self.subTest(path=path): + handler = _GetHandlerHarness(path, _dependencies()) + handler.do_GET() + + self.assertEqual(handler.status, 400) + self.assertEqual(handler.json_body(), expected) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_training_case_cli_m6.py b/tests/unit/test_training_case_cli_m6.py index f97a665..d54f606 100644 --- a/tests/unit/test_training_case_cli_m6.py +++ b/tests/unit/test_training_case_cli_m6.py @@ -193,12 +193,13 @@ class TrainingCaseCliM6Tests(unittest.TestCase): self.assertEqual(exported["metadata"]["item_count"], summary["exported"]) self.assertEqual( set(item), - {"id", "title", "scenario", "skill_tags", "difficulty", "status", "rubric", "tasks", "metadata"}, + {"id", "title", "scenario", "skill_tags", "difficulty", "status", "rubric", "tasks", "metadata", "quality"}, ) self.assertEqual(item["title"], fixture_item["title"]) self.assertEqual(item["scenario"], fixture_item["scenario"]) self.assertEqual(item["rubric"], fixture_item["rubric"]) self.assertEqual(item["metadata"]["external_id"], fixture_item["external_id"]) + self.assertEqual(item["quality"]["rank"], "high") self.assertEqual(item["tasks"][0]["status"], "active") self.assertEqual(item["tasks"][0]["meta"], fixture_item["tasks"][0]["meta"]) self.assertNotIn("answer", keys) diff --git a/tests/unit/test_training_case_docs_cli_m10.py b/tests/unit/test_training_case_docs_cli_m10.py index 9943472..cf9d026 100644 --- a/tests/unit/test_training_case_docs_cli_m10.py +++ b/tests/unit/test_training_case_docs_cli_m10.py @@ -448,6 +448,7 @@ class TrainingCaseDocsCliM10Tests(unittest.TestCase): "rubric", "tasks", "metadata", + "quality", }, ) self.assertEqual(exported_item["title"], fixture_item["title"]) @@ -457,6 +458,7 @@ class TrainingCaseDocsCliM10Tests(unittest.TestCase): {"chapter_ref": "public_training_m6", "demo": True, "external_id": fixture_item["external_id"]}, ) self.assertEqual(exported_item["rubric"], fixture_item["rubric"]) + self.assertEqual(exported_item["quality"]["rank"], "high") self.assertEqual(exported_item["scenario"], fixture_item["scenario"]) self.assertEqual(set(exported_item["tasks"][0]), set(fixture_item["tasks"][0]) | {"status"}) self.assertEqual(exported_item["tasks"][0]["status"], "active") diff --git a/tests/unit/test_training_case_generator_proto.py b/tests/unit/test_training_case_generator_proto.py new file mode 100644 index 0000000..1dcf946 --- /dev/null +++ b/tests/unit/test_training_case_generator_proto.py @@ -0,0 +1,84 @@ +import contextlib +import io +import json +import tempfile +import unittest +from pathlib import Path + +from scripts import run_training_case_generator +from src.services.training import generate_public_training_case_drafts + + +FIXTURE = Path("tests/fixtures/training_case_generator_seed_v1.json") + + +class TrainingCaseGeneratorProtoTests(unittest.TestCase): + def test_generate_public_training_case_drafts_returns_public_case_package(self) -> None: + payload = json.loads(FIXTURE.read_text(encoding="utf-8")) + + generated = generate_public_training_case_drafts(payload) + + self.assertEqual(generated["version"], 1) + self.assertEqual(generated["metadata"]["generated"], 2) + self.assertTrue(generated["metadata"]["read_only"]) + self.assertEqual([item["scenario"]["source_event_type"] for item in generated["items"]], ["fvg_zone", "sweep_event"]) + + first = generated["items"][0] + self.assertEqual( + set(first), + {"external_id", "title", "scenario", "skill_tags", "difficulty", "answer", "rubric", "tasks", "status", "metadata", "quality"}, + ) + self.assertEqual(first["answer"], {}) + self.assertIn("quality", first) + self.assertIn(first["quality"]["rank"], {"high", "medium", "low"}) + + serialized = json.dumps(generated, sort_keys=True) + self.assertNotIn("answer_key", serialized) + + def test_generator_keeps_output_public_and_deterministic(self) -> None: + payload = json.loads(FIXTURE.read_text(encoding="utf-8")) + + first = generate_public_training_case_drafts(payload) + second = generate_public_training_case_drafts(payload) + + self.assertEqual(first, second) + self.assertNotIn("answer_key", json.dumps(first, sort_keys=True)) + self.assertEqual(first["items"][0]["quality"]["signals"][0], first["items"][0]["quality"]["signals"][0]) + + def test_script_writes_public_draft_package_and_reports_summary(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / "generated_training_case_drafts.json" + stdout = io.StringIO() + + with contextlib.redirect_stdout(stdout): + exit_code = run_training_case_generator.main(["--input", str(FIXTURE), "--output", str(output_path)]) + + summary = json.loads(stdout.getvalue()) + generated = json.loads(output_path.read_text(encoding="utf-8")) + + self.assertEqual(exit_code, 0) + self.assertTrue(summary["passed"]) + self.assertEqual(summary["generated"], 2) + self.assertEqual(summary["event_types"], ["fvg_zone", "sweep_event"]) + self.assertEqual(generated["metadata"]["generator_mode"], "prototype_public_draft") + self.assertEqual(len(generated["items"]), 2) + self.assertNotIn("answer_key", json.dumps(generated, sort_keys=True)) + + def test_script_reports_public_error_for_invalid_seed_package(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + input_path = Path(tmpdir) / "invalid_seed.json" + input_path.write_text(json.dumps({"version": 1, "items": [{"source_event_type": "unknown"}]}), encoding="utf-8") + stdout = io.StringIO() + + with contextlib.redirect_stdout(stdout): + exit_code = run_training_case_generator.main(["--input", str(input_path)]) + + payload = json.loads(stdout.getvalue()) + + self.assertEqual(exit_code, 2) + self.assertFalse(payload["passed"]) + self.assertEqual(payload["error"], "invalid_json_field:source_event_type") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_training_case_import_export_m5.py b/tests/unit/test_training_case_import_export_m5.py index 763569e..06cff86 100644 --- a/tests/unit/test_training_case_import_export_m5.py +++ b/tests/unit/test_training_case_import_export_m5.py @@ -183,6 +183,7 @@ class TrainingCaseImportExportM5Tests(unittest.TestCase): self.assertEqual(first["metadata"], {"item_count": 2}) self.assertEqual([item["id"] for item in first["items"]], [10, 20]) self.assertEqual(first["items"][0]["title"], "A FVG case") + self.assertIn("quality", first["items"][0]) self.assertEqual(first["items"][0]["scenario"]["start_ts"], "2026-04-01T09:00:00+00:00") self.assertEqual(first["items"][0]["scenario"]["source_event_refs"][0]["id"], "9") self.assertNotIn("rubric", first["items"][1]) @@ -247,6 +248,7 @@ class TrainingCaseImportExportM5Tests(unittest.TestCase): self.assertEqual(import_payload["imported"], 1) self.assertEqual(export_status, 200) self.assertEqual(export_payload["items"][0]["title"], "A FVG case") + self.assertIn("quality", export_payload["items"][0]) self.assertEqual( repository.list_args, {"status": "published", "skill_tag": "fvg", "instrument_id": 1, "limit": 5}, diff --git a/tests/unit/test_training_dashboard_m3.py b/tests/unit/test_training_dashboard_m3.py index 80de0c5..c790bed 100644 --- a/tests/unit/test_training_dashboard_m3.py +++ b/tests/unit/test_training_dashboard_m3.py @@ -114,6 +114,9 @@ class TrainingDashboardM3Tests(unittest.TestCase): "/annotations", "/submit", "annotation_type", + "trainingCaseQuality", + "trainingQualitySummary", + "quality:", ): self.assertIn(expected, app_js) diff --git a/tests/unit/test_training_public_api_contract_m8.py b/tests/unit/test_training_public_api_contract_m8.py index 663b219..230671d 100644 --- a/tests/unit/test_training_public_api_contract_m8.py +++ b/tests/unit/test_training_public_api_contract_m8.py @@ -977,6 +977,39 @@ class TrainingPublicApiContractM8Tests(unittest.TestCase): self.assertEqual(handler.response_headers.get("Content-Length"), str(len(handler.wfile.getvalue()))) self.assertEqual(probe.calls, [expected_call]) + def test_training_case_selector_query_params_route_to_expected_handlers(self) -> None: + probe = _TrainingRouteProbe() + + status, payload = server.route_request( + "/training/cases", + { + "quality_rank": ["high"], + "min_quality_score": ["80"], + "sort_by": ["quality_score_desc"], + }, + dependencies=probe.dependencies(), + ) + + self.assertEqual(status, 200) + self.assertEqual(payload, {"items": [{"id": 7}]}) + self.assertEqual( + probe.calls, + [ + ( + "training_cases", + { + "status": None, + "skill_tag": None, + "instrument_id": None, + "limit": 100, + "quality_rank": "high", + "min_quality_score": 80, + "sort_by": "quality_score_desc", + }, + ) + ], + ) + def test_training_workflow_contract_case_attempt_submit_progress_is_deterministic(self) -> None: repository = _WorkflowContractRepository() service = TrainingCaseService(repository=repository) @@ -1016,6 +1049,7 @@ class TrainingPublicApiContractM8Tests(unittest.TestCase): "case_type": "sweep", "skill_tag": "sweep", "status": "published", + "quality": {"score": 10, "rank": "low", "signals": ["published_case"]}, "meta": {"title": "Workflow contract case", "public_note": "visible"}, }, ) @@ -1216,6 +1250,7 @@ class TrainingPublicApiContractM8Tests(unittest.TestCase): self.assertEqual(item["rubric"], [{"category": "context", "max_score": 3}]) self.assertEqual(item["tasks"][0]["meta"]["public_hint"], "Focus on context.") self.assertEqual(item["metadata"]["metadata_note"]["public_label"], "visible") + self.assertIn("quality", item) serialized = json.dumps(payload, sort_keys=True) diff --git a/tests/unit/test_training_public_export_privacy_m15.py b/tests/unit/test_training_public_export_privacy_m15.py index 4c9c0b1..3129c23 100644 --- a/tests/unit/test_training_public_export_privacy_m15.py +++ b/tests/unit/test_training_public_export_privacy_m15.py @@ -169,6 +169,7 @@ class TrainingPublicExportPrivacyM15Tests(unittest.TestCase): self.assertEqual(item["rubric"], [{"category": "context", "max_score": 3}]) self.assertEqual(item["tasks"][0]["meta"]["public_hint"], "Focus on context.") self.assertEqual(item["metadata"]["metadata_note"]["public_label"], "visible") + self.assertIn("quality", item) self.assertPublicPayloadHasNoPrivateMaterial(export_result) def test_service_public_list_and_detail_scrub_nested_solution_material(self) -> None: @@ -179,6 +180,8 @@ class TrainingPublicExportPrivacyM15Tests(unittest.TestCase): self.assertEqual(list_result[0]["meta"]["tasks"][0]["meta"]["public_hint"], "Focus on context.") self.assertEqual(detail_result["meta"]["metadata_note"]["public_label"], "visible") + self.assertIn("quality", list_result[0]) + self.assertIn("quality", detail_result) self.assertPublicPayloadHasNoPrivateMaterial({"items": list_result}) self.assertPublicPayloadHasNoPrivateMaterial(detail_result) @@ -201,6 +204,7 @@ class TrainingPublicExportPrivacyM15Tests(unittest.TestCase): self.assertEqual(item["rubric"], [{"category": "context", "max_score": 3.0}]) self.assertEqual(item["tasks"][0]["meta"]["public_hint"], "Focus on context.") self.assertEqual(item["metadata"]["chapter_ref"], "m15_public") + self.assertIn("quality", item) self.assertPublicPayloadHasNoPrivateMaterial(export_result)