From fd7fb3cc6c7387cdba1d28eb628f07a6420b24f8 Mon Sep 17 00:00:00 2001 From: yuqei Date: Wed, 29 Apr 2026 22:19:25 +0800 Subject: [PATCH] Add autonomous workflow and offline quality gates --- CLAUDE.md | 23 + Dockerfile | 1 + README.md | 143 ++++- ...AI_ICT_Training_Case_Engine_开发方案_v1.md | 522 ++++++++++++++++++ docs/AI_ICT_当前工作断点_2026-04-24.md | 71 +++ docs/AI_ICT_执行检查清单_v1.md | 52 +- docs/AI_ICT_报告样本_2026-04-24.md | 151 +++++ docs/AI_ICT_离线自动化推进记录_2026-04-29.md | 431 +++++++++++++++ scripts/apply_migrations.py | 30 +- scripts/check_db.py | 37 +- scripts/check_db_connection.py | 35 +- scripts/deploy_server.exp | 8 + scripts/fix_pg_hba.exp | 5 +- scripts/run_api_server.py | 10 +- scripts/run_autonomous_cycle.py | 480 ++++++++++++++++ scripts/run_backtest.py | 133 ++++- scripts/run_database_readiness.py | 304 ++++++++++ scripts/run_dev.py | 9 +- scripts/run_displacement.py | 42 +- scripts/run_execution.py | 133 ++++- scripts/run_execution_settlement.py | 81 +++ scripts/run_fvg.py | 42 +- scripts/run_liquidity.py | 42 +- scripts/run_migration_static_checks.py | 143 +++++ scripts/run_migrations.py | 107 +++- scripts/run_mss.py | 42 +- scripts/run_offline_checks.py | 97 ++++ scripts/run_okx_backfill.py | 118 +++- scripts/run_pipeline.py | 62 ++- scripts/run_ranges.py | 42 +- scripts/run_replay_file.py | 209 +++++++ scripts/run_reports.py | 29 +- scripts/run_script_static_checks.py | 144 +++++ scripts/run_session_generator.py | 40 +- scripts/run_signals.py | 66 ++- scripts/run_sweeps.py | 42 +- scripts/run_swings.py | 42 +- scripts/show_models.py | 25 +- src/api/backtest.py | 10 +- src/api/server.py | 47 ++ src/config/constants.py | 2 + src/config/settings.py | 1 + src/db/engine.py | 12 +- src/main.py | 57 +- src/repositories/dealing_range_repository.py | 20 + src/repositories/execution_repository.py | 100 +++- src/repositories/fvg_repository.py | 40 ++ src/repositories/liquidity_pool_repository.py | 39 ++ src/repositories/structure_repository.py | 21 + src/repositories/sweep_repository.py | 20 + src/services/backtest/__init__.py | 42 +- src/services/backtest/backtest_service.py | 264 ++++++++- src/services/backtest/replay.py | 229 ++++++++ src/services/execution/__init__.py | 6 + src/services/execution/execution_service.py | 236 +++++++- src/services/fvg/__init__.py | 16 +- src/services/fvg/fvg_service.py | 44 +- src/services/liquidity/__init__.py | 9 +- .../liquidity/liquidity_pool_service.py | 40 +- src/services/market_data/okx_client.py | 9 +- src/services/ranges/__init__.py | 4 +- src/services/ranges/dealing_range_service.py | 14 + .../reports/backtest_report_service.py | 10 +- .../reports/post_trade_review_service.py | 24 +- src/services/signals/__init__.py | 8 + src/services/signals/signal_service.py | 86 ++- src/services/structure/__init__.py | 4 +- src/services/structure/mss_service.py | 23 + src/services/sweeps/__init__.py | 4 +- src/services/sweeps/sweep_service.py | 25 +- src/web/app.js | 417 ++++++++++++++ src/web/index.html | 90 +++ src/web/styles.css | 186 +++++++ tests/fixtures/offline_replay_sample.jsonl | 5 + tests/unit/test_api_and_main.py | 202 ++++++- tests/unit/test_api_server.py | 391 +++++++++++++ tests/unit/test_api_static_server.py | 55 ++ tests/unit/test_autonomous_cycle.py | 410 ++++++++++++++ tests/unit/test_backtest_replay.py | 304 ++++++++++ tests/unit/test_backtest_report_service.py | 32 +- tests/unit/test_backtest_script.py | 198 +++++++ tests/unit/test_backtest_service.py | 254 ++++++++- tests/unit/test_database_readiness.py | 129 +++++ tests/unit/test_db_engine.py | 21 + tests/unit/test_documentation_consistency.py | 22 + tests/unit/test_execution_service.py | 338 +++++++++++- tests/unit/test_fvg_service.py | 58 +- tests/unit/test_liquidity_pool_service.py | 25 +- tests/unit/test_migration_runner.py | 85 +++ tests/unit/test_migration_static_checks.py | 82 +++ tests/unit/test_mss_service.py | 62 ++- tests/unit/test_okx_backfill_script.py | 156 ++++++ tests/unit/test_range_service.py | 66 +++ tests/unit/test_report_services.py | 31 ++ tests/unit/test_runtime_script_json.py | 109 ++++ tests/unit/test_script_static_checks.py | 96 ++++ tests/unit/test_signal_service.py | 160 +++++- 97 files changed, 9207 insertions(+), 236 deletions(-) create mode 100644 CLAUDE.md create mode 100644 docs/AI_ICT_Training_Case_Engine_开发方案_v1.md create mode 100644 docs/AI_ICT_当前工作断点_2026-04-24.md create mode 100644 docs/AI_ICT_报告样本_2026-04-24.md create mode 100644 docs/AI_ICT_离线自动化推进记录_2026-04-29.md create mode 100644 scripts/run_autonomous_cycle.py create mode 100644 scripts/run_database_readiness.py create mode 100644 scripts/run_execution_settlement.py create mode 100644 scripts/run_migration_static_checks.py create mode 100644 scripts/run_offline_checks.py create mode 100644 scripts/run_replay_file.py create mode 100644 scripts/run_script_static_checks.py create mode 100644 src/services/backtest/replay.py create mode 100644 src/web/app.js create mode 100644 src/web/index.html create mode 100644 src/web/styles.css create mode 100644 tests/fixtures/offline_replay_sample.jsonl create mode 100644 tests/unit/test_api_static_server.py create mode 100644 tests/unit/test_autonomous_cycle.py create mode 100644 tests/unit/test_backtest_replay.py create mode 100644 tests/unit/test_backtest_script.py create mode 100644 tests/unit/test_database_readiness.py create mode 100644 tests/unit/test_db_engine.py create mode 100644 tests/unit/test_documentation_consistency.py create mode 100644 tests/unit/test_migration_runner.py create mode 100644 tests/unit/test_migration_static_checks.py create mode 100644 tests/unit/test_okx_backfill_script.py create mode 100644 tests/unit/test_range_service.py create mode 100644 tests/unit/test_runtime_script_json.py create mode 100644 tests/unit/test_script_static_checks.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..02e1444 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,23 @@ +## gstack (REQUIRED — global install) + +**Before doing ANY work, verify gstack is installed:** + +```bash +test -d ~/.claude/skills/gstack/bin && echo "GSTACK_OK" || echo "GSTACK_MISSING" +``` + +If GSTACK_MISSING: STOP. Do not proceed. Tell the user: + +> gstack is required for all AI-assisted work in this repo. +> Install it: +> ```bash +> git clone --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack +> cd ~/.claude/skills/gstack && ./setup --team +> ``` +> Then restart your AI coding tool. + +Do not skip skills, ignore gstack errors, or work around missing gstack. + +Using gstack skills: After install, skills like /qa, /ship, /review, /investigate, +and /browse are available. Use /browse for all web browsing. +Use ~/.claude/skills/gstack/... for gstack file paths (the global path). diff --git a/Dockerfile b/Dockerfile index fff21ca..1b222e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,7 @@ RUN pip install --no-cache-dir --upgrade pip && \ COPY src /app/src COPY scripts /app/scripts COPY migrations /app/migrations +COPY src/web /app/src/web COPY .env.example /app/.env.example COPY README.md /app/README.md diff --git a/README.md b/README.md index 8cfc962..33d21b3 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ AI ICT 交易辅助系统第一阶段工程实现。 See `docs/` for architecture, database, rule, and migration references. +Database connectivity defaults to a 5 second PostgreSQL connect timeout. Override with `DATABASE_CONNECT_TIMEOUT` when needed. +Set `MIGRATIONS_OUTPUT_JSON=1` when running `run_migrations.py` for a machine-readable migration summary. + ## Current closed loop 当前主线可运行链路: @@ -28,12 +31,145 @@ See `docs/` for architecture, database, rule, and migration references. - `python3 scripts/run_mss.py` - rebuild MSS structure events - `python3 scripts/run_fvg.py` - rebuild FVG zones - `python3 scripts/run_signals.py` - generate bias and signals -- `python3 scripts/run_execution.py` - run paper execution gating and create an execution record +- `python3 scripts/run_execution.py` - run paper execution gating, create an execution record, then auto-settle the latest execution for that signal - `python3 scripts/run_backtest.py` - run backtest and write results +- `python3 scripts/run_autonomous_cycle.py` - run the unattended cycle: offline checks, database probe, readiness checks, then guarded pipeline when the database is available; prints `Next actions` for the next unattended step +- `python3 scripts/run_database_readiness.py` - run read-only database readiness checks for connection, schema, seed data, and candle availability; prints `next_actions` and `action_plan` in JSON mode +- `python3 scripts/run_offline_checks.py` - run database-free automated checks +- `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 - `python3 scripts/run_reports.py` - build sample reports from current services -- `python3 scripts/run_api_server.py` - start a lightweight HTTP API server on `127.0.0.1:8000` +- `python3 scripts/run_api_server.py` - start a lightweight HTTP API server on `127.0.0.1:8000` and open `/` for the minimal dashboard - `python3 scripts/run_pipeline.py` - run the current end-to-end closed loop and print a structured summary +Optional report environment variables used by `run_reports.py`: + +- `REPORTS_OUTPUT_JSON` - set to `1`, `true`, `yes`, or `on` for machine-readable sample report output + +Optional utility JSON output variables: + +- `CHECK_DB_OUTPUT_JSON` +- `CHECK_DB_CONNECTION_OUTPUT_JSON` +- `MIGRATION_CHECKS_OUTPUT_JSON` +- `SCRIPT_CHECKS_OUTPUT_JSON` +- `SHOW_MODELS_OUTPUT_JSON` + +Optional structure script JSON output variables: + +- `SESSION_GENERATOR_OUTPUT_JSON` +- `SWINGS_OUTPUT_JSON` +- `RANGES_OUTPUT_JSON` +- `LIQUIDITY_OUTPUT_JSON` +- `SWEEPS_OUTPUT_JSON` +- `DISPLACEMENT_OUTPUT_JSON` +- `MSS_OUTPUT_JSON` +- `FVG_OUTPUT_JSON` + +Optional backtest cost environment variables used by `run_backtest.py` and `run_pipeline.py`: + +- `BACKTEST_FEE_RATE` +- `BACKTEST_SLIPPAGE_RATE` +- `BACKTEST_FIXED_COST_R` + +Optional backtest automation gate used by `run_backtest.py` and `run_pipeline.py`: + +- `BACKTEST_OUTPUT_JSON` - set to `1`, `true`, `yes`, or `on` for machine-readable `run_backtest.py` output +- `BACKTEST_REQUIRE_QUALITY_GATE` - set to `1`, `true`, `yes`, or `on` to exit with code `2` when the backtest quality gate fails +- `BACKTEST_MIN_TOTAL_SIGNALS` +- `BACKTEST_MIN_EXECUTED_SIGNALS` +- `BACKTEST_MIN_EXPECTANCY_R` +- `BACKTEST_MIN_PROFIT_FACTOR_R` +- `BACKTEST_MAX_DRAWDOWN_R` +- `BACKTEST_MAX_CONSECUTIVE_LOSSES` + +Optional pipeline environment variables used by `run_pipeline.py`: + +- `PIPELINE_OUTPUT_JSON` - set to `1`, `true`, `yes`, or `on` for machine-readable pipeline error output + +Optional replay environment variables used by `run_replay_file.py`: + +- `OFFLINE_REPLAY_FILE` - optional candle file path used by `run_offline_checks.py` to include replay validation +- `OFFLINE_CHECKS_OUTPUT_JSON` - set to `1`, `true`, `yes`, or `on` for machine-readable offline check summary output +- `REPLAY_LEFT_BARS` +- `REPLAY_RIGHT_BARS` +- `REPLAY_MIN_CANDLES` +- `REPLAY_MIN_SWINGS` +- `REPLAY_MAX_GAP_MINUTES` +- `REPLAY_OUTPUT_JSON` - set to `1`, `true`, `yes`, or `on` for machine-readable replay summary output + +Example offline replay gate: + +```bash +OFFLINE_REPLAY_FILE=tests/fixtures/offline_replay_sample.jsonl \ +REPLAY_LEFT_BARS=1 REPLAY_RIGHT_BARS=1 \ +REPLAY_MIN_CANDLES=5 REPLAY_MIN_SWINGS=2 \ +python3 scripts/run_offline_checks.py +``` + +Optional OKX backfill environment variables used by `run_okx_backfill.py`: + +- `OKX_BACKFILL_SYMBOL` - defaults to `BTC-USDT` +- `OKX_BACKFILL_INST_ID` - defaults to `-SWAP` +- `OKX_BACKFILL_PAGES` - defaults to `20` +- `OKX_BACKFILL_LIMIT` - defaults to `100` +- `OKX_BACKFILL_TIMEFRAMES` - defaults to `1m,5m,15m` +- `OKX_BACKFILL_OUTPUT_JSON` - emit a machine-readable backfill summary + +Optional autonomous cycle environment variables used by `run_autonomous_cycle.py`: + +- `AUTONOMOUS_OUTPUT_JSON` - emit a machine-readable cycle summary +- `AUTONOMOUS_REQUIRE_DB` - return code `2` when the database is unavailable +- `AUTONOMOUS_RUN_PIPELINE` - set to `0` to stop after a successful database probe +- `AUTONOMOUS_MAX_DB_PROBES` - number of database probe attempts before stopping +- `AUTONOMOUS_PROBE_INTERVAL_SECONDS` - sleep interval between database probe attempts +- `AUTONOMOUS_REQUIRE_SCHEMA` - enabled by default; set to `0` to skip core table readiness checks before the guarded pipeline +- `AUTONOMOUS_APPLY_MIGRATIONS` - enabled by default; when schema is not ready, run migrations, recheck schema, then continue +- `AUTONOMOUS_REQUIRE_DATA_READINESS` - enabled by default; set to `0` to skip read-only seed/candle readiness before the guarded pipeline +- `AUTONOMOUS_BACKFILL_CANDLES` - enabled by default; when readiness only lacks candles, run OKX backfill, recheck readiness, then continue +- The guarded pipeline step defaults `BACKTEST_REQUIRE_QUALITY_GATE=1` and `PIPELINE_OUTPUT_JSON=1` unless explicitly overridden + +Current autonomous modes emit machine-readable `next_actions` and `action_plan`, including: + +- `waiting_for_database` -> `wait_for_database_recovery` +- `database_schema_not_ready` -> `run_migrations_before_pipeline` +- `migration_failed` -> `inspect_migration_output_and_schema` +- `database_readiness_failed` -> the readiness script's own next action, such as `run_okx_backfill_or_load_replay_sample` +- `backfill_failed` -> `inspect_backfill_output_and_data_readiness` +- `pipeline_failed` -> `inspect_pipeline_output_and_quality_gates` + +Each `action_plan` item includes the action name, suggested command, description, and `requires_database`. + +Optional database readiness environment variables used by `run_database_readiness.py`: + +- `READINESS_OUTPUT_JSON` +- `READINESS_REQUIRED_VENUES` +- `READINESS_REQUIRED_INSTRUMENTS` +- `READINESS_CANDLE_INSTRUMENT_ID` +- `READINESS_CANDLE_TIMEFRAME` +- `READINESS_MIN_CANDLES` - defaults to `50` +- `READINESS_MIN_CANDLE_SPAN_MINUTES` - defaults to `60` +- `READINESS_MAX_CANDLE_AGE_MINUTES` - defaults to `240` + +Optional account risk environment variables used by `run_execution.py` and `run_pipeline.py`: + +- `EXECUTION_OUTPUT_JSON` - set to `1`, `true`, `yes`, or `on` for machine-readable `run_execution.py` output +- `EXECUTION_SETTLEMENT_OUTPUT_JSON` - set to `1`, `true`, `yes`, or `on` for machine-readable `run_execution_settlement.py` output +- `EXECUTION_KILL_SWITCH` +- `EXECUTION_DAILY_PNL_PCT` +- `EXECUTION_CONSECUTIVE_LOSSES` +- `EXECUTION_OPEN_POSITIONS` +- `EXECUTION_SAME_SIDE_OPEN_POSITIONS` +- `EXECUTION_MAX_DAILY_LOSS_PCT` +- `EXECUTION_MAX_CONSECUTIVE_LOSSES` +- `EXECUTION_MAX_OPEN_POSITIONS` +- `EXECUTION_MAX_SAME_SIDE_POSITIONS` + +Optional signal quality environment variables used by `run_signals.py` and `run_pipeline.py`: + +- `SIGNALS_OUTPUT_JSON` - set to `1`, `true`, `yes`, or `on` for machine-readable `run_signals.py` output +- `SIGNAL_EVIDENCE_MODE` - `minimal` by default, or `strict` to require sweep/displacement/liquidity evidence before building a signal + ## Lightweight API helpers The `src/api/` package exposes internal query helpers and HTTP routes for: @@ -42,8 +178,11 @@ The `src/api/` package exposes internal query helpers and HTTP routes for: - `GET /candles?instrument_id=1&timeframe=1m&limit=100` - `GET /sessions?instrument_id=1&limit=20` - `GET /signals?instrument_id=1&limit=20` +- `GET /executions?instrument_id=1&limit=20` - `GET /backtests?instrument_id=1&limit=10` +Open `/` in the browser for the minimal dashboard, which now includes health, candles, signals, sessions, executions, and backtests. + ## Validation Unit tests: diff --git a/docs/AI_ICT_Training_Case_Engine_开发方案_v1.md b/docs/AI_ICT_Training_Case_Engine_开发方案_v1.md new file mode 100644 index 0000000..564d1f0 --- /dev/null +++ b/docs/AI_ICT_Training_Case_Engine_开发方案_v1.md @@ -0,0 +1,522 @@ +# AI ICT Training Case Engine 开发方案 v1 + +## 1. 方案结论 + +在 `/Users/yuqei/AI Exchange` 内新增独立 `Training` 模块,复用现有 OKX 数据、ICT 规则识别、Replay、Backtest,但训练数据与实盘、信号、执行严格隔离。 + +开发目标不是先做大而全,而是先做一个能真实跑通的 MVP: + +```text +真实 K 线 -> 自动生成训练案例 -> 隐藏未来回放 -> 用户标注/答题 -> 系统评分 -> 形成薄弱点 +``` + +核心产品定位: + +```text +AI Exchange = 真实市场数据与规则识别引擎 +Training Case Engine = 学习、标注、评分、复盘、成长系统 +``` + +## 2. 总体架构 + +新增模块边界: + +```text +AI Exchange +├─ market/rule engine 现有:OKX、candles、sessions、swings、ranges、liquidity、sweep、MSS、FVG +├─ trading engine 现有:bias、signals、backtest、execution、reports +└─ training engine 新增:case generation、replay、annotation、grading、progress +``` + +核心依赖规则: + +```text +现有交易数据/规则 -> Training 只读消费 +Training 用户行为 -> 不反向影响 signals / executions / backtest +``` + +训练系统可以读取 `candles`、`sweep_events`、`fvg_zones`、`structure_events`、`backtest_results` 等数据,但不能污染交易主链路。 + +## 3. 第一阶段 MVP 范围 + +第一版只做 6 类训练题,覆盖书中最重要的基础能力: + +1. `Dealing Range` 标注题 +2. `Premium / Discount / Equilibrium` 位置判断题 +3. `Liquidity Pool` 主次目标判断题 +4. `Sweep` 真假识别题 +5. `MSS + Displacement` 结构接管题 +6. `FVG` 入场区域与失效判断题 + +第一阶段暂不做: + +1. 复杂账户系统 +2. 多人课堂 +3. 真实下单训练 +4. 付费体系 +5. 大型用户权限系统 + +## 4. 数据库新增表 + +建议新增 migration,表名: + +```text +training_cases +training_case_tasks +training_attempts +training_annotations +training_scores +training_progress +``` + +### 4.1 training_cases + +核心字段: + +```text +id +instrument_id +timeframe +case_type +chapter_ref +skill_tags +source_event_type +source_event_id +window_start_ts +decision_ts +window_end_ts +difficulty +answer_key jsonb +status +meta jsonb +``` + +说明: + +- `case_type` 用于区分 `range`、`premium_discount`、`liquidity_pool`、`sweep`、`mss_displacement`、`fvg`。 +- `chapter_ref` 用于绑定书中章节,例如 `chapter_02`、`chapter_10`。 +- `decision_ts` 是隐藏未来的关键时间点。 +- `answer_key` 保存标准答案或规则答案。 +- `source_event_type/source_event_id` 追溯案例来自哪个真实市场事件。 + +### 4.2 training_case_tasks + +核心字段: + +```text +id +case_id +task_type +prompt +required_annotation_types +max_score +sort_order +meta jsonb +``` + +说明: + +- 一个案例可以包含多个任务。 +- 例如 Sweep 案例可以包含:识别流动性池、判断是否 close back inside、写失效条件。 + +### 4.3 training_attempts + +核心字段: + +```text +id +case_id +user_id +status +current_frame_index +started_ts +submitted_ts +total_score +meta jsonb +``` + +第一版 `user_id` 可先使用 `"local"`,以后再接正式用户系统。 + +### 4.4 training_annotations + +核心字段: + +```text +id +attempt_id +annotation_type +payload jsonb +created_ts +``` + +常见 `annotation_type`: + +```text +dealing_range +equilibrium +draw +liquidity_pool +fvg +mss +invalidation +tp_sl +multiple_choice +free_text +``` + +### 4.5 training_scores + +核心字段: + +```text +id +attempt_id +category +score +max_score +feedback jsonb +``` + +### 4.6 training_progress + +核心字段: + +```text +id +user_id +skill_tag +attempt_count +avg_score +last_attempt_ts +weakness_tags jsonb +meta jsonb +``` + +## 5. 后端服务新增 + +新增目录: + +```text +src/services/training/ + __init__.py + case_generator.py + case_repository.py + replay_case_service.py + grading_service.py + annotation_service.py + progress_service.py +``` + +### 5.1 case_generator.py + +职责: + +1. 从真实事件生成案例。 +2. 从 `sweep_events` 生成 Sweep 训练题。 +3. 从 `fvg_zones` 生成 FVG 训练题。 +4. 从 `dealing_ranges` 生成 Range 训练题。 +5. 自动绑定 `chapter_ref` 与 `skill_tags`。 + +第一版案例来源: + +```text +sweep_events -> sweep 案例 +fvg_zones -> fvg 案例 +dealing_ranges -> range / premium_discount 案例 +structure_events + displacement_events -> mss_displacement 案例 +liquidity_pools -> liquidity_pool 案例 +``` + +### 5.2 replay_case_service.py + +职责: + +1. 基于已有 `src/services/backtest/replay.py`。 +2. 只返回 `decision_ts` 之前或逐根推进后的 candles。 +3. 防止未来函数。 +4. 支持 `advance 1 candle`、`advance 5 candles`、`reset`。 + +### 5.3 grading_service.py + +职责: + +1. 对用户标注打分。 +2. 第一版采用规则评分,不让 AI 主判。 +3. AI 后续只负责解释反馈。 +4. 评分结果写入 `training_scores`。 + +### 5.4 annotation_service.py + +职责: + +1. 保存用户画的 Range、FVG、SL、TP、Draw、Invalidation 等。 +2. 校验 annotation payload 的基本结构。 +3. 为评分服务提供用户提交内容。 + +### 5.5 progress_service.py + +职责: + +汇总用户能力雷达: + +1. Range 准确率 +2. Draw 判断率 +3. Sweep 误判率 +4. 无效点完整率 +5. 风险违规率 +6. FVG 识别准确率 +7. 结构接管判断准确率 + +## 6. API 设计 + +新增 `src/api/training.py`,路由接入 `src/api/server.py`。 + +建议接口: + +```text +GET /training/cases +GET /training/cases/{id} +POST /training/cases/generate + +POST /training/attempts +GET /training/attempts/{id} +POST /training/attempts/{id}/advance +POST /training/attempts/{id}/annotations +POST /training/attempts/{id}/submit + +GET /training/progress +``` + +典型流程: + +```text +1. GET /training/cases?case_type=sweep +2. POST /training/attempts { case_id } +3. POST /training/attempts/{id}/advance +4. POST /training/attempts/{id}/annotations +5. POST /training/attempts/{id}/submit +6. 返回 score + feedback + answer_key +``` + +## 7. 前端 Training Lab + +新增: + +```text +src/web/training/ + index.html + training.js + training.css +``` + +第一版页面结构: + +1. 左侧:章节/能力选择 +2. 中间:K 线回放图 +3. 右侧:任务卡、答题区、评分反馈 +4. 底部:推进按钮、提交按钮、显示答案按钮 + +第一版交互控件: + +1. 下一根 K 线 +2. 推进 5 根 +3. 重置 +4. 标注 Range +5. 标注 Draw +6. 标注 FVG +7. 标注 Invalidation +8. 选择题 / 判断题 +9. 提交评分 + +图表层第一版可以先沿用 Canvas,但要封装成训练图表组件。第二版再考虑升级专业图表库。 + +## 8. 评分规则 MVP + +每个案例总分 100: + +```text +Range / 位置判断:20 +Draw / 目标判断:20 +结构事件识别:20 +Invalidation:20 +执行纪律:20 +``` + +### 8.1 Sweep 题评分 + +```text +是否识别正确流动性池:20 +是否判断 close back inside:20 +是否等待确认而非立刻反手:20 +是否写出失效条件:20 +是否符合会话/时间过滤:20 +``` + +### 8.2 FVG 题评分 + +```text +是否识别正确 FVG 区间:20 +是否判断方向正确:20 +是否区分 open / touched / mitigated / invalidated:20 +是否写出有效失效条件:20 +是否符合当前 Bias / MSS 上下文:20 +``` + +### 8.3 Range 题评分 + +```text +是否选择合理 Key High:20 +是否选择合理 Key Low:20 +是否标注 Equilibrium:20 +是否判断 Premium / Discount:20 +是否能说明当前位置是否适合执行:20 +``` + +## 9. 开发里程碑 + +建议按 7 个小里程碑推进。 + +### M1:Training 数据模型与 migration + +完成: + +1. 新增训练表 migration。 +2. 新增 repository。 +3. 新增基础单测。 + +验收: + +1. training tables 可创建。 +2. case / attempt / annotation / score 可写入与读取。 +3. 不影响现有 migrations 与主链路测试。 + +### M2:案例生成器 MVP + +完成: + +1. 支持 `sweep` 案例生成。 +2. 支持 `fvg` 案例生成。 +3. 支持 `range` 案例生成。 + +验收: + +1. 能从真实事件生成至少 30 个训练案例。 +2. 每个案例有 `window_start_ts`、`decision_ts`、`window_end_ts`。 +3. 每个案例有 `answer_key`。 + +### M3:Replay API + +完成: + +1. 用户只能看到当时已知 K 线。 +2. 支持逐根推进。 +3. 支持重置。 + +验收: + +1. `decision_ts` 之后的 candle 不会提前暴露。 +2. replay frame 与当前进度一致。 +3. 输入 candles 通过合法性校验。 + +### M4:Annotation + Submit + +完成: + +1. 前端可提交用户标注。 +2. 后端保存 annotation。 +3. attempt 可从 started 进入 submitted。 + +验收: + +1. 至少支持 Range、Draw、Invalidation 三种标注。 +2. 标注 payload 可被评分服务读取。 + +### M5:Grading MVP + +完成: + +1. 规则评分。 +2. 返回分项得分。 +3. 返回反馈和 answer_key。 + +验收: + +1. Sweep / FVG / Range 至少三类题可评分。 +2. 分数写入 `training_scores`。 +3. attempt 总分更新。 + +### M6:Training Lab 前端 + +完成: + +1. 训练案例列表。 +2. K 线回放图。 +3. 任务卡。 +4. 标注提交。 +5. 评分反馈。 + +验收: + +1. 一个用户可以完整跑通训练闭环。 +2. UI 能清楚区分未提交、已提交、已评分状态。 +3. 不与现有 dashboard 互相干扰。 + +### M7:Progress 面板 + +完成: + +1. 汇总用户能力弱点。 +2. 输出初版 `Precision Maturity`。 +3. 展示按 skill_tag 的得分趋势。 + +验收: + +1. 能看到每类训练题尝试次数与平均分。 +2. 能看到主要薄弱项。 +3. 能基于弱点推荐下一组训练案例。 + +## 10. 第一版验收标准 + +第一版完成时必须满足: + +1. 能从真实 OKX 数据生成至少 30 个训练案例。 +2. 每个案例能隐藏未来并逐根推进。 +3. 用户能完成至少 3 种标注。 +4. 提交后能得到分项评分。 +5. Training 数据不影响 `trade_signals` 和 `trade_executions`。 +6. 单元测试覆盖 case generation、replay、grading。 +7. 前端能完整跑通一个训练闭环。 + +## 11. 推荐立即开始的第一步 + +先做 M1 + M2: + +```text +新增 training_cases +新增 training_case_tasks +实现 TrainingCaseGenerator +先从 sweep_events / fvg_zones / dealing_ranges 各生成一类案例 +``` + +这一步完成后,就能把书中的章节真实挂到市场样本上,后面的回放、标注、评分会自然长出来。 + +## 12. 长期演进方向 + +第一阶段完成后,可继续扩展: + +1. 精选教学案例 + 自动行情案例混合案例库。 +2. AI 教练解释反馈,但不作为主评分裁判。 +3. 章节闯关系统。 +4. 个人框架自动生成器。 +5. 多人训练营与老师批改。 +6. 高阶 replay:隐藏成交量、显示/隐藏指标、按会话切片。 +7. 真实交易日志与训练弱点联动。 + +## 13. 核心原则 + +1. 训练模块只读消费交易主链路数据。 +2. 训练用户行为不反向影响信号、执行、回测。 +3. 第一版以规则评分为主,AI 只做解释增强。 +4. 防未来函数是训练系统的生命线。 +5. 先跑通真实案例闭环,再扩展复杂教学玩法。 + diff --git a/docs/AI_ICT_当前工作断点_2026-04-24.md b/docs/AI_ICT_当前工作断点_2026-04-24.md new file mode 100644 index 0000000..d4b23f6 --- /dev/null +++ b/docs/AI_ICT_当前工作断点_2026-04-24.md @@ -0,0 +1,71 @@ +# AI ICT 当前工作断点(2026-04-24) + +## 已完成 +- 代码侧 idempotency 主线补丁已落地并通过本地回归。 +- API 服务端异常兜底已补齐:`src/api/server.py` + - 业务查询异常不再直接断连接 + - 会返回 JSON 500(`internal_server_error`)便于排查 + - 已新增 `/executions` 聚合接口,供 dashboard 展示 execution timeline / blotter +- `/signals` 路由回归测试已补:`tests/unit/test_api_and_main.py` +- 执行层已进一步对齐需求:`src/services/execution/execution_service.py` + - `semi_auto` 必须显式确认后才会真正创建执行记录 + - 风控拒绝原因继续保留在 `meta.risk_checks` + - 已新增 `execution_audit`、lifecycle 分类、settlement 持久化回写 + - 已新增 `settle_execution_by_id()`,可按 execution_id 回查并回写 settlement + - 已新增 `settle_execution_by_id_from_latest_backtest()`,可自动抓取最新 backtest result 后完成 settlement + - 已新增 `settle_latest_execution_for_signal()`,可按 signal 自动找到最新 execution 并完成 settlement +- 主运行链路已接入 execution settlement:`src/main.py` + - 已从 `signal -> latest execution -> latest backtest -> settlement` 打通到主闭环 + - 闭环摘要新增 `writes.execution_status` + - 闭环摘要新增 `writes.execution_lifecycle_state` +- 报告层已接入 execution audit:`src/services/reports/post_trade_review_service.py` +- 最小前端 dashboard 已落地:`src/web/` + - 当前可视化 overview / health / candles / signals / sessions / executions / backtests + - 已新增 auto refresh、last updated、状态彩色标签 + - 已新增 overview 指标卡 + - 已新增 details 面板,可点击行/卡片查看结构化明细 + - 已新增 execution status filter、文本搜索、选中高亮 +- 运行脚本已补数据库不可用时的优雅降级: + - `scripts/run_execution.py` + - `scripts/run_pipeline.py` + - `scripts/run_execution_settlement.py` +- `scripts/run_execution.py` 已升级为:创建 execution 后自动执行该 signal 的 latest execution settlement +- `scripts/run_execution_settlement.py` 已升级为:按 `signal_id` 自动找到 latest execution 后执行 settlement +- 报告样本、错误样本、脚本 fallback 样本已落盘:`docs/AI_ICT_报告样本_2026-04-24.md` +- 本地验证已通过: + - `test_api_and_main.py` -> 7 tests OK + - `test_api_server.py` -> 8 tests OK + - `test_execution_service.py` -> 22 tests OK + - `test_report_services.py` -> 3 tests OK +- 远端 PostgreSQL 已修复 `pg_hba.conf` 网段放行问题: + - 新增 `172.22.0.0/16` + - 新增 `172.23.0.0/16` +- 远端服务已重部署并完成 smoke: + - `GET /health` -> `200 OK` + - `GET /signals?instrument_id=1&limit=3` -> `200 OK` + - `GET /backtests?instrument_id=1&limit=3` -> `200 OK` + - `GET /sessions?instrument_id=1&limit=3` -> `200 OK` + - `GET /candles?instrument_id=1&timeframe=1m&limit=3` -> `200 OK` +- 部署脚本已修正:`scripts/deploy_server.exp` + - 不再依赖外部遗留的 `/tmp/ai-ict-deploy.tgz` + - 脚本会从项目根目录自动打包再上传部署 + +## 本次根因 +- HTTP 层此前只捕获 `ValueError`,业务异常会导致连接直接断开,表现为 `curl: (52) Empty reply from server` +- 远端数据库同时拒绝了 Docker bridge 网段 `172.23.0.0/16` 的容器连接,导致 `/signals` 查询实际触发 `psycopg.OperationalError` + +## 当前未闭环点 +- 部署链路已用新版脚本完成一次完整验证:打包、上传、解包、重启、smoke 均已通过。 +- 需求对齐后续主线待推进: + - 执行层:继续把 settlement 从当前回测/规则结算链推进到真实成交/持仓回写链 + - 运行链路:下一步可考虑把 settlement 再推进到真实成交/持仓更新与 execution audit 联动 + - 检查清单:继续把已完成项从文档状态同步到真实验证状态 + +## 建议下次直接续跑步骤 +1. 继续补执行层最小闭环,优先做 execution audit 与持仓生命周期管理。 +2. 为 pre-market / intraday / post-trade 报告补样本落盘与错误样本。 +3. 完成后再更新检查清单与主线检查点文档。 + +## 本次保留说明 +- 按你的要求:**不写入持久记忆系统**。 +- 本断点仅保存于项目文档,避免增加 memory 负担。 diff --git a/docs/AI_ICT_执行检查清单_v1.md b/docs/AI_ICT_执行检查清单_v1.md index 0a77e03..5533a38 100644 --- a/docs/AI_ICT_执行检查清单_v1.md +++ b/docs/AI_ICT_执行检查清单_v1.md @@ -227,24 +227,36 @@ ### 检查项 -- [ ] risk engine 已实现 -- [ ] paper trading 已实现 -- [ ] semi-auto order confirm 流程已实现 +- [x] risk engine 已实现 +- [x] paper trading 已实现 +- [x] semi-auto order confirm 流程已实现 - [ ] stop / tp1 / tp2 管理已实现 -- [ ] execution audit 已实现 +- [x] execution audit 已实现 ### 完成标准 -- [ ] 无 stop 信号无法执行 -- [ ] 风控拒绝可追溯 +- [x] 无 stop 信号无法执行 +- [x] 风控拒绝可追溯 - [ ] 模拟订单可完整开平 -- [ ] 半自动流程需要显式确认 +- [x] 半自动流程需要显式确认 ### 验证记录 -- [ ] 拒单样本已记录 -- [ ] 正常成交样本已记录 -- [ ] 失效平仓样本已记录 +- [x] 拒单样本已记录 +- [x] 正常成交样本已记录 +- [x] 失效平仓样本已记录 + +### 当前说明 + +- `ExecutionService.evaluate_execution_decision()` 已实现风险判定与拒绝原因收集。 +- `ExecutionService.create_execution()` 已支持 `paper` / `semi_auto` 两种模式。 +- `semi_auto` 现在必须显式传入确认参数后才会真正创建执行记录。 +- 当前仍未覆盖真实撮合/成交回执驱动的持仓管理,因此 `stop / tp1 / tp2 管理` 仍保持未完成。 +- `ExecutionService.audit_execution()`、`classify_execution_lifecycle()`、`persist_execution_settlement()`、`settle_execution_by_id()`、`settle_execution_by_id_from_latest_backtest()`、`settle_latest_execution_for_signal()` 已落地,post-trade review、主运行链路、按 execution_id / signal_id 的回写入口都已接入 execution settlement 输出。 +- 主闭环 `run_closed_loop()` 现已直接使用 `signal_id -> latest execution -> latest backtest -> settlement` 路径。 +- `scripts/run_execution.py`、`scripts/run_pipeline.py`、`scripts/run_execution_settlement.py` 已补数据库不可用时的优雅降级输出,减少样本脚本在无本地 PG 环境下的阻塞。 +- `scripts/run_execution.py` 与 `scripts/run_execution_settlement.py` 都已升级到更完整的自动 settlement 样本链路。 +- 本地验证:`test_execution_service.py` -> 22 tests OK;`test_report_services.py` -> 3 tests OK;`test_api_and_main.py` -> 7 tests OK;`test_api_server.py` -> 8 tests OK --- @@ -252,22 +264,22 @@ ### 检查项 -- [ ] pre-market 模板已实现 -- [ ] intraday explanation 模板已实现 -- [ ] post-trade review 模板已实现 -- [ ] AI 输入只依赖结构化数据 -- [ ] AI 输出不绕过规则层 +- [x] pre-market 模板已实现 +- [x] intraday explanation 模板已实现 +- [x] post-trade review 模板已实现 +- [x] AI 输入只依赖结构化数据 +- [x] AI 输出不绕过规则层 ### 完成标准 -- [ ] 同一输入得到稳定报告 -- [ ] 报告能解释关键 evidence -- [ ] 报告含 invalidation / risk note +- [x] 同一输入得到稳定报告 +- [x] 报告能解释关键 evidence +- [x] 报告含 invalidation / risk note ### 验证记录 -- [ ] 报告样本已保存 -- [ ] 错误示例已记录 +- [x] 报告样本已保存 +- [x] 错误示例已记录 --- diff --git a/docs/AI_ICT_报告样本_2026-04-24.md b/docs/AI_ICT_报告样本_2026-04-24.md new file mode 100644 index 0000000..0f88e83 --- /dev/null +++ b/docs/AI_ICT_报告样本_2026-04-24.md @@ -0,0 +1,151 @@ +# AI ICT 报告样本(2026-04-24) + +## 1. pre-market sample + +```json +{ + "report_type": "pre_market_plan", + "daily_bias": "bullish", + "draw_side": "buy_side", + "execution_intensity": "high", + "reason_codes": ["latest_mss", "active_range_available"], + "session_code": "LONDON", + "execution_permission": true, + "working_range": { + "range_id": 1, + "high": 110.0, + "low": 90.0, + "equilibrium": 100.0, + "location": "discount" + } +} +``` + +## 2. intraday sample + +```json +{ + "report_type": "intraday_explanation", + "sweep": { + "present": true, + "side_swept": "buy_side", + "close_back_inside": true + }, + "structure": { + "event_type": "MSS", + "direction": "bullish", + "accepted": true + }, + "fvg": { + "direction": "bullish", + "status": "open", + "fill_pct": 0.0 + }, + "signal": { + "present": true, + "status": "awaiting_confirmation", + "side": "buy", + "session_code": "LONDON", + "is_valid": true + }, + "invalidations": [] +} +``` + +## 3. post-trade review sample + +```json +{ + "report_type": "post_trade_review", + "rule_aligned": false, + "execution_status": "rejected", + "signal_status": "awaiting_context", + "backtest_outcome": "loss", + "execution_audit": { + "audit_status": "review", + "is_actionable": false, + "error_tags": ["execution_rejected", "session_not_allowed"], + "lifecycle_state": "rejected", + "risk_checks": ["session_not_allowed"], + "tp_hits": {"tp1": false, "tp2": false}, + "stop_loss_hit": true + }, + "error_types": ["execution_rejected", "session_not_allowed"], + "improvement_notes": ["inspect_stop_and_structure_context", "prefer_confirmed_execution_window", "review_same_candle_ambiguity_cases", "review_signal_invalidations"] +} +``` + +## 4. error sample + +```json +{ + "report_type": "post_trade_review", + "rule_aligned": false, + "execution_status": "closed", + "signal_status": "awaiting_confirmation", + "backtest_outcome": "loss", + "execution_audit": { + "audit_status": "review", + "is_actionable": false, + "error_tags": ["signal_invalidated_before_entry"], + "lifecycle_state": "expired", + "risk_checks": [], + "tp_hits": {"tp1": false, "tp2": false}, + "stop_loss_hit": false + }, + "error_types": ["signal_invalidated_before_entry"], + "improvement_notes": ["avoid_expired_or_invalidated_signals", "review_signal_invalidations"] +} +``` + +## 5. current note + +- 以上样本已对应当前代码输出结构。 +- post-trade review 已包含 `execution_audit`,可用于 execution audit 样本留档。 +- 当前 lifecycle 已接入主运行链路摘要,并可持久化回写 execution 状态。 +- 当前已支持按 execution_id 回查 execution 后执行 settlement 回写。 +- 当前主闭环也已直接接入 `signal_id -> latest execution -> latest backtest -> settlement` 路径。 +- `scripts/run_execution.py` 与 `scripts/run_pipeline.py` 在数据库不可用时都已改为优雅输出错误,而不是抛长异常。 +- 当前 dashboard 已增强为带 overview 指标卡、auto refresh、last updated、状态彩色标签的最小可视化页面。 +- 当前仍未完成真实撮合/成交回执驱动的持仓管理,因此样本中的 lifecycle 仍主要来自回测/规则结算链。 + +## 6. script fallback sample + +```text +Database unavailable for execution sample: OperationalError +Database unavailable for pipeline sample: OperationalError +Database unavailable for execution settlement sample: OperationalError +``` + +当前 `run_execution.py` 已升级为:创建 execution 后自动结算该 signal 的 latest execution。 +当前 `run_execution_settlement.py` 已升级为:按 `signal_id` 自动找到 latest execution 并完成 settlement。 + +该样本用于记录当前运行脚本的无库环境行为。 + +## 7. execution settlement sample + +脚本入口:`scripts/run_execution_settlement.py` + +用途: +- 按 `signal_id` 自动找到 latest execution +- 自动抓取该 signal 最新 backtest result +- 根据最新 backtest 结果执行 settlement +- 回写 lifecycle / status / meta + +当前无数据库环境下的样本输出: + +```text +Database unavailable for execution settlement sample: OperationalError +``` + +有数据库且 execution 存在时,预期输出结构: + +```text +Execution settlement updated: id=; status=closed; lifecycle_state=tp2_closed +``` + +该入口用于后续继续收口“真实 execution 回写链”。 + +另外,当前运行脚本的优雅降级行为已由 `tests/unit/test_api_server.py` 覆盖。 + +该样本用于记录当前运行脚本的无库环境行为。 diff --git a/docs/AI_ICT_离线自动化推进记录_2026-04-29.md b/docs/AI_ICT_离线自动化推进记录_2026-04-29.md new file mode 100644 index 0000000..0f6e0a0 --- /dev/null +++ b/docs/AI_ICT_离线自动化推进记录_2026-04-29.md @@ -0,0 +1,431 @@ +# AI ICT 离线自动化推进记录(2026-04-29) + +## 背景 + +服务器 PostgreSQL 暂时不可用,本轮只推进不依赖数据库的代码、纯函数、单元测试与文档记录。 + +目标不是扩展概念,而是提高系统后续验证交易有效性的可信度。 + +## 已完成离线能力 + +### 1. Signal RR 硬过滤 + +- 新增 `MIN_TP1_RR = 1.0` +- 新增 `MIN_TP2_RR = 2.0` +- `SignalService.build_signal()` 现在会拒绝 RR 不达标的候选信号 +- 信号 meta 写入 `rr_filter` + +### 2. 回测 R 倍数与成本模型 + +- 新增 `BacktestCostConfig` +- 支持 `fee_rate` +- 支持 `slippage_rate` +- 支持 `fixed_cost_r` +- 每笔结果写入: + - `cost_r` + - `r_multiple` + - `cost_model` +- 汇总新增: + - `total_r` + - `expectancy_r` + - `profit_factor_r` + - `total_cost_r` + - `avg_cost_r` + +### 3. 回测风险统计 + +- 新增 R 曲线: + - `cumulative_r_curve` +- 新增最大回撤: + - `max_drawdown_r` +- 新增最大连续亏损: + - `max_consecutive_losses` +- 新增 MFE / MAE 的 R 倍数: + - `avg_mfe_r` + - `avg_mae_r` + +### 4. 回测质量门槛 + +- 新增 `BacktestQualityGate` +- 新增 `evaluate_backtest_quality()` +- 默认检查: + - 最小样本量 + - 最小实际触发交易样本量 + - 最低期望 R + - 最低 profit factor + - 最大回撤 + - 最大连续亏损 +- `BacktestReportService.build_summary()` 现在输出 `quality_gate` +- `BacktestService.run_backtest()` 写入 backtest run summary 时同步固化 `quality_gate` +- backtest run config 同步记录当次使用的 `quality_gate` 阈值,便于追溯自动化门禁标准 +- `run_backtest.py` 与 `run_pipeline.py` 支持通过环境变量注入成本模型: + - `BACKTEST_FEE_RATE` + - `BACKTEST_SLIPPAGE_RATE` + - `BACKTEST_FIXED_COST_R` +- `run_backtest.py` 与 `run_pipeline.py` 支持无人流程质量门禁: + - `BACKTEST_REQUIRE_QUALITY_GATE=1` + - 门禁失败时返回 exit code `2` +- `run_backtest.py` 支持 `BACKTEST_OUTPUT_JSON=1`: + - 输出脚本级 `passed` + - 输出 `backtest_run_id` + - 输出 `result_count` + - 输出 `summary` + - 输出 `quality_gate` + - 数据库不可用时输出结构化 `error_type` +- 质量门禁阈值可通过环境变量调整: + - `BACKTEST_MIN_TOTAL_SIGNALS` + - `BACKTEST_MIN_EXECUTED_SIGNALS` + - `BACKTEST_MIN_EXPECTANCY_R` + - `BACKTEST_MIN_PROFIT_FACTOR_R` + - `BACKTEST_MAX_DRAWDOWN_R` + - `BACKTEST_MAX_CONSECUTIVE_LOSSES` +- 回测 summary 新增: + - `executed_signals` + - `executed_rate` + - `executed_expectancy_r` + - `executed_profit_factor_r` +- `run_closed_loop()` 返回 `backtest_quality_gate`,便于自动编排系统直接判断是否继续推进 + +### 5. 历史 replay 骨架 + +- 新增纯内存历史回放模块:`src/services/backtest/replay.py` +- `iter_historical_replay()` 逐根 candle 推进 +- 每个 frame 只暴露当时已知 candles +- `detect_confirmed_swings_for_replay()` 只在右侧确认窗口完成后释放 confirmed swing +- 这是后续无未来函数回测的基础 + +### 6. Replay 输入数据校验 + +- 新增 `validate_replay_candles()` +- 回放前检查: + - 必要字段缺失 + - `ts_open` 重复 + - `ts_open` 非递增 + - `high < low` + - `open` 不在 high/low 内 + - `close` 不在 high/low 内 + - OHLC 非数字、非有限值与非正价格 +- 新增 `load_replay_candles()`,支持: + - JSON + - JSONL / NDJSON + - CSV +- 新增纯离线 replay 文件脚本: + - `python3 scripts/run_replay_file.py ` + - `REPLAY_LEFT_BARS` + - `REPLAY_RIGHT_BARS` + - `REPLAY_MIN_CANDLES` + - `REPLAY_MIN_SWINGS` + - `REPLAY_OUTPUT_JSON` +- Replay 样本门禁失败时返回 exit code `2` +- Replay JSON 输出包含: + - candle/frame/swing 统计 + - 样本窗口:起止 `ts_open`、首尾 close、最高/最低价、区间 close return + - `max_gap_minutes`,用于发现历史 K 线断档 + - 最新 confirmed swing + - quality gate 状态与原因 +- Replay 文件门禁新增: + - `REPLAY_MAX_GAP_MINUTES` + - 超过门槛时返回 `candle_gap_too_large` +- 新增仓库内 replay 样本: + - `tests/fixtures/offline_replay_sample.jsonl` + +### 7. 账户级风控纯函数 + +- 新增 `AccountRiskState` +- 新增 `evaluate_account_risk_limits()` +- 执行决策已支持账户状态输入 +- `run_execution.py` 与 `run_pipeline.py` 支持通过环境变量注入账户状态 +- 当前可拒绝: + - 全局执行熔断开启 + - 日亏损达到上限 + - 连续亏损达到上限 + - 最大持仓数达到上限 + - 同方向暴露达到上限 +- 当前执行风控环境变量: + - `EXECUTION_KILL_SWITCH` + - `EXECUTION_DAILY_PNL_PCT` + - `EXECUTION_CONSECUTIVE_LOSSES` + - `EXECUTION_OPEN_POSITIONS` + - `EXECUTION_SAME_SIDE_OPEN_POSITIONS` + - `EXECUTION_MAX_DAILY_LOSS_PCT` + - `EXECUTION_MAX_CONSECUTIVE_LOSSES` + - `EXECUTION_MAX_OPEN_POSITIONS` + - `EXECUTION_MAX_SAME_SIDE_POSITIONS` + +### 8. Signal evidence 校验 + +- 新增 `validate_signal_evidence()` +- 当前最小证据链: + - `bias_snapshot_id` + - `fvg_id` + - `mss_id` +- 预留严格证据链: + - `bias_snapshot_id` + - `sweep_id` + - `displacement_id` + - `mss_id` + - `fvg_id` + - `liquidity_target_id` +- `SignalService.build_signal()` meta 写入 `evidence_quality` +- `SignalService` 支持注入 required evidence keys,不完整时直接拒绝候选信号 +- `run_signals.py` 与 `run_pipeline.py` 支持: + - `SIGNAL_EVIDENCE_MODE=minimal` + - `SIGNAL_EVIDENCE_MODE=strict` + +### 9. Dashboard 回测质量展示与静态资源检查 + +- Dashboard 回测卡片展示: + - `expectancy_r` + - `executed_expectancy_r` + - `max_drawdown_r` + - `executed_signals / executed_rate` + - `quality_gate` +- Overview 展示最新回测的 quality gate 状态与实际触发交易样本量 +- 清理 `src/web/app.js` 中重复定义导致的脚本语法错误 +- 静态资源测试新增: + - `index.html` 引用资源存在性检查 + - `node --check src/web/app.js` 语法检查 +- 新增离线总闸脚本: + - `python3 scripts/run_offline_checks.py` + - 当前串联单元测试、migration 静态检查与 dashboard JS 语法检查 + - 设置 `OFFLINE_REPLAY_FILE` 时自动追加 replay 文件检查 + - 设置 `OFFLINE_CHECKS_OUTPUT_JSON` 时输出机器可读 JSON summary +- 新增 migration 静态检查脚本: + - `python3 scripts/run_migration_static_checks.py` + - 检查 migration 编号连续 + - 检查核心表与关键列是否由 migration 创建 + - 检查 readiness 依赖的 seed 值: + - `venues.code=OKX` + - `instruments.symbol=BTC-USDT` +- 新增 script 静态检查脚本: + - `python3 scripts/run_script_static_checks.py` + - 检查无人脚本中会让空 env 误回退到真实环境变量的写法 + - AST 解析 automation 脚本,提前拦截 Python 语法错误 + - 检查关键自动化入口必须 `raise SystemExit(main())`,避免返回码被吞 + - 检查脚本中 Python 3.9 不兼容的 PEP604 Optional 注解 + - 已接入 `run_offline_checks.py` +- `run_migrations.py` 改为按 SQL statement 分拆执行 migration 文件,避免多语句整文件 execute 的驱动兼容风险 +- `run_migrations.py` 数据库异常时返回 exit code `2`,并纳入脚本入口返回码静态检查 +- `run_migrations.py` 支持 `MIGRATIONS_OUTPUT_JSON=1` 输出机器可读迁移摘要 +- 新增无人流程循环入口: + - `python3 scripts/run_autonomous_cycle.py` + - 顺序执行离线总闸、数据库探测、数据库可用后的 guarded pipeline + - 数据库不可用时进入 `waiting_for_database` + - human 输出与 JSON 输出都包含 `next_actions`,便于下一轮无人流程直接接续 + - 输出 `action_plan`,包含建议命令、动作说明与 `requires_database` 标记 + - `AUTONOMOUS_OUTPUT_JSON` + - `AUTONOMOUS_REQUIRE_DB` + - `AUTONOMOUS_RUN_PIPELINE` + - `AUTONOMOUS_MAX_DB_PROBES` + - `AUTONOMOUS_PROBE_INTERVAL_SECONDS` + - `AUTONOMOUS_REQUIRE_SCHEMA` + - `AUTONOMOUS_APPLY_MIGRATIONS` + - DB 可用但 schema 不完整时,默认执行 `run_migrations.py`,复查 schema,通过后继续 readiness/pipeline;migration 失败时进入 `migration_failed` + - 数据库可连通后先检查核心表与关键列;缺表或缺列时进入 `database_schema_not_ready` 并返回 exit code `2` + - `AUTONOMOUS_REQUIRE_DATA_READINESS` + - `AUTONOMOUS_BACKFILL_CANDLES` + - readiness 仅因 candles 数量不足失败时,默认执行 `run_okx_backfill.py`,复查 readiness,通过后继续 guarded pipeline;backfill 失败时进入 `backfill_failed` + - 调用 `run_pipeline.py` 时默认设置 `BACKTEST_REQUIRE_QUALITY_GATE=1` 与 `PIPELINE_OUTPUT_JSON=1`,确保无人流程不会把质量门槛失败误判为成功,并能解析 pipeline 失败原因 +- `run_okx_backfill.py` 拆出可测试核心函数,自动化异常返回 exit code `2` +- 修复 OKX backfill 同批次重复 `ts_open` confirmed candle 去重问题 +- OKX backfill 支持使用上一页最旧 `ts` 作为 `after` 游标继续拉更早历史,减少重复拉取最新页 +- OKX backfill 支持无人流程环境变量: + - `OKX_BACKFILL_SYMBOL` + - `OKX_BACKFILL_INST_ID` + - `OKX_BACKFILL_PAGES` + - `OKX_BACKFILL_LIMIT` + - `OKX_BACKFILL_TIMEFRAMES` + - `OKX_BACKFILL_OUTPUT_JSON` +- autonomous cycle 自动 backfill 时强制 `OKX_BACKFILL_OUTPUT_JSON=1` +- `run_pipeline.py`、`run_execution.py`、`run_execution_settlement.py` 在数据库异常时返回 exit code `2`,避免无人流程把数据库中途掉线误判为成功 +- `run_pipeline.py` 支持 `PIPELINE_OUTPUT_JSON=1`,数据库中途异常时输出结构化 `passed:false` 与 `error_type` +- `run_execution.py` 支持 `EXECUTION_OUTPUT_JSON=1`,输出 `execution_mode:paper`、`passed` 与数据库错误类型 +- `run_execution_settlement.py` 支持 `EXECUTION_SETTLEMENT_OUTPUT_JSON=1`,输出 `execution_mode:paper`、`passed` 与数据库错误类型 +- `run_reports.py` 支持 `REPORTS_OUTPUT_JSON=1`,输出 pre-market、intraday、post-trade 与 backtest summary 的机器可读报告样本 +- `run_signals.py` 支持 `SIGNALS_OUTPUT_JSON=1`,数据库异常时返回 exit code `2` 并输出结构化错误;入口改为 `raise SystemExit(main())` +- 以下结构处理入口已支持 JSON 输出、数据库异常 exit code `2`,并纳入入口返回码静态检查: + - `run_session_generator.py` + - `run_swings.py` + - `run_ranges.py` + - `run_liquidity.py` + - `run_sweeps.py` + - `run_displacement.py` + - `run_mss.py` + - `run_fvg.py` +- `check_db.py` 与 `check_db_connection.py` 支持 JSON 输出与数据库异常 exit code `2` +- `apply_migrations.py` 已委托给 `run_migrations.py` 的安全迁移入口,避免旧脚本绕过 SQL 拆分和结构化错误输出 +- `show_models.py` 支持 `SHOW_MODELS_OUTPUT_JSON=1`,并纳入入口返回码静态检查 +- README 已补齐 `MIGRATION_CHECKS_OUTPUT_JSON` 与 `SCRIPT_CHECKS_OUTPUT_JSON`,确保公开环境变量清单覆盖当前脚本能力 +- `run_dev.py` 与 `run_api_server.py` 也改为 `raise SystemExit(main())`,保留原行为同时让入口返回码静态检查覆盖全部 Python 脚本 +- `run_script_static_checks.py` 现在对 `scripts/` 中任意定义了 `main()` 的脚本强制检查 `raise SystemExit(main())`,不再依赖手工维护白名单 +- `run_offline_checks.run_checks(env=...)` 现在会把传入 env 与当前系统环境合并后传给子检查进程,保证函数调用模式与命令行模式一致 +- `src/db/engine.py` 默认对 PostgreSQL 设置 `connect_timeout=5`,可用 `DATABASE_CONNECT_TIMEOUT` 覆盖,避免数据库不可用时无人脚本长时间挂起 +- 新增只读数据库 readiness 脚本: + - `python3 scripts/run_database_readiness.py` + - 检查数据库连通 + - 检查 schema readiness + - 检查必要 seed data + - 检查可选最小 candles 数量 + - 检查可选 candles 时间跨度与最新 candle 新鲜度 + - `READINESS_OUTPUT_JSON` + - `READINESS_REQUIRED_VENUES` + - `READINESS_REQUIRED_INSTRUMENTS` + - `READINESS_CANDLE_INSTRUMENT_ID` + - `READINESS_CANDLE_TIMEFRAME` + - `READINESS_MIN_CANDLES`,默认 `50` + - `READINESS_MIN_CANDLE_SPAN_MINUTES`,默认 `60` + - `READINESS_MAX_CANDLE_AGE_MINUTES`,默认 `240` + - 输出 `next_actions` 与结构化 `action_plan`: + - `wait_for_database_recovery` + - `run_migrations_before_pipeline` + - `verify_seed_migrations_or_seed_data` + - `run_okx_backfill_or_load_replay_sample` + - `run_guarded_pipeline` + +## 当前验证 + +本轮离线推进后的本地单元测试: + +```text +python3 -m unittest discover -s tests/unit -p "test_*.py" +Ran 216 tests +OK +``` + +前端脚本语法检查: + +```text +node --check src/web/app.js +exit code 0 +``` + +离线总闸: + +```text +python3 scripts/run_offline_checks.py +Ran 216 tests +OK +Migration static checks passed: True +Script static checks passed: True +dashboard_js_syntax passed +``` + +回测脚本 JSON 输出(数据库不可用时): + +```text +BACKTEST_OUTPUT_JSON=1 python3 scripts/run_backtest.py +{"backtest_run_id": null, "error_type": "OperationalError", "passed": false, "quality_gate": null, "result_count": 0, "summary": null} +``` + +Pipeline JSON 输出(数据库不可用时): + +```text +PIPELINE_OUTPUT_JSON=1 python3 scripts/run_pipeline.py +{"error_type": "OperationalError", "passed": false} +``` + +执行脚本 JSON 输出(数据库不可用时,仍保持 paper 模式): + +```text +EXECUTION_OUTPUT_JSON=1 python3 scripts/run_execution.py +{"error_type": "OperationalError", "execution_mode": "paper", "passed": false, "signal_id": 1} + +EXECUTION_SETTLEMENT_OUTPUT_JSON=1 python3 scripts/run_execution_settlement.py +{"error_type": "OperationalError", "execution_mode": "paper", "passed": false, "signal_id": 1} +``` + +报告脚本 JSON 输出: + +```text +REPORTS_OUTPUT_JSON=1 python3 scripts/run_reports.py +{"passed": true, "backtest_summary": {"quality_gate": {"passed": false}}} +``` + +信号脚本 JSON 输出(数据库不可用时): + +```text +SIGNALS_OUTPUT_JSON=1 python3 scripts/run_signals.py +{"error_type": "OperationalError", "instrument_id": 1, "passed": false, "timeframe": "1m"} +``` + +工具脚本 JSON 输出: + +```text +SHOW_MODELS_OUTPUT_JSON=1 python3 scripts/show_models.py +{"model_count": 16, "passed": true} + +CHECK_DB_OUTPUT_JSON=1 python3 scripts/check_db.py +{"available": false, "error_type": "OperationalError", "passed": false} +``` + +无人流程循环入口当前状态: + +```text +python3 scripts/run_autonomous_cycle.py +Autonomous mode: waiting_for_database +Next actions: wait_for_database_recovery +Action plan [no-db]: wait_for_database_recovery -> /Library/Developer/CommandLineTools/usr/bin/python3 scripts/run_autonomous_cycle.py +Step offline_checks: passed exit_code=0 +Database available: False +Database probe error: OperationalError +``` + +无人流程 JSON 入口当前状态: + +```text +AUTONOMOUS_OUTPUT_JSON=1 python3 scripts/run_autonomous_cycle.py +{"mode": "waiting_for_database", "next_actions": ["wait_for_database_recovery"], "passed": true} +``` + +数据库 readiness 当前状态: + +```text +python3 scripts/run_database_readiness.py +Database readiness passed: False +Database available: False +Database readiness reasons: database_unavailable +Database readiness next actions: wait_for_database_recovery +Database readiness action plan [no-db]: wait_for_database_recovery -> /Library/Developer/CommandLineTools/usr/bin/python3 scripts/run_autonomous_cycle.py +``` + +带 replay 样本门禁的离线总闸: + +```text +OFFLINE_REPLAY_FILE=tests/fixtures/offline_replay_sample.jsonl REPLAY_LEFT_BARS=1 REPLAY_RIGHT_BARS=1 REPLAY_MIN_CANDLES=5 REPLAY_MIN_SWINGS=2 python3 scripts/run_offline_checks.py +Replay sample: first_ts=1 last_ts=5 close_return_pct=0.02040816326530612 +Replay confirmed swings: 2 +Replay quality gate passed: True +``` + +## 数据库恢复后的优先接续点 + +1. 用真实 candles 跑 replay 校验,确认历史数据顺序与 OHLC 合法。 +2. 将 replay confirmed swings 与当前 `SwingService.detect_swings()` 结果抽样比对。 +3. 补齐严格 evidence 链路中的: + - `sweep_id` + - `displacement_id` + - `liquidity_target_id` +4. 在真实回测结果上观察: + - `expectancy_r` + - `profit_factor_r` + - `max_drawdown_r` + - `max_consecutive_losses` + - `avg_mfe_r` + - `avg_mae_r` + +## 当前原则 + +数据库恢复前继续只推进: + +- 纯函数 +- 单元测试 +- 离线 replay +- 风控与质量门槛 +- 文档同步 + +不推进: + +- migration +- 远程部署 +- 真实数据库写入 +- 真实下单 diff --git a/scripts/apply_migrations.py b/scripts/apply_migrations.py index c563253..529aee1 100644 --- a/scripts/apply_migrations.py +++ b/scripts/apply_migrations.py @@ -1,36 +1,16 @@ -from pathlib import Path import sys - -from sqlalchemy import text +from pathlib import Path 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 scripts.run_migrations import main as run_migrations_main -MIGRATIONS_DIR = Path(__file__).resolve().parents[1] / "migrations" - - -def iter_sql_files() -> list[Path]: - return sorted(p for p in MIGRATIONS_DIR.glob("*.sql") if p.is_file()) - - -def main() -> None: - files = iter_sql_files() - if not files: - print("No migration files found") - return - - with engine.begin() as conn: - for path in files: - sql = path.read_text(encoding="utf-8").strip() - if not sql: - continue - conn.execute(text(sql)) - print(f"Applied {path.name}") +def main(env=None) -> int: + return run_migrations_main(env=env) if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/scripts/check_db.py b/scripts/check_db.py index 884079f..afdac03 100644 --- a/scripts/check_db.py +++ b/scripts/check_db.py @@ -1,4 +1,6 @@ from pathlib import Path +import json +import os import sys ROOT = Path(__file__).resolve().parents[1] @@ -6,16 +8,39 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError from src.db.engine import engine -def main() -> None: - with engine.connect() as connection: - result = connection.execute(text("select 1")) - value = result.scalar_one() - print(f"database connection ok: {value}") +def main(env=None) -> int: + env = os.environ if env is None else env + output_json = _get_bool(env, "CHECK_DB_OUTPUT_JSON", False) + try: + with engine.connect() as connection: + result = connection.execute(text("select 1")) + value = result.scalar_one() + except SQLAlchemyError as exc: + payload = {"passed": False, "available": False, "error_type": exc.__class__.__name__, "error": str(exc)} + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + else: + print(f"database connection failed: {exc.__class__.__name__}") + return 2 + payload = {"passed": True, "available": True, "value": value} + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + else: + print(f"database connection ok: {value}") + return 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__": - main() + raise SystemExit(main()) diff --git a/scripts/check_db_connection.py b/scripts/check_db_connection.py index d5ed7ce..e13dfb0 100644 --- a/scripts/check_db_connection.py +++ b/scripts/check_db_connection.py @@ -1,7 +1,10 @@ from pathlib import Path +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: @@ -10,12 +13,34 @@ if str(ROOT) not in sys.path: from src.db.engine import engine -def main() -> None: - with engine.connect() as conn: - result = conn.execute(text("select 1")) - value = result.scalar_one() +def main(env=None) -> int: + env = os.environ if env is None else env + output_json = _get_bool(env, "CHECK_DB_CONNECTION_OUTPUT_JSON", False) + try: + with engine.connect() as conn: + result = conn.execute(text("select 1")) + value = result.scalar_one() + except SQLAlchemyError as exc: + payload = {"passed": False, "available": False, "error_type": exc.__class__.__name__, "error": str(exc)} + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + else: + print(f"DB connection failed: {exc.__class__.__name__}") + return 2 + payload = {"passed": value == 1, "available": value == 1, "value": value} + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + else: print("DB connection OK" if value == 1 else "DB connection unexpected result") + return 0 if value == 1 else 2 + + +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__": - main() + raise SystemExit(main()) diff --git a/scripts/deploy_server.exp b/scripts/deploy_server.exp index d78afae..ca5c07b 100755 --- a/scripts/deploy_server.exp +++ b/scripts/deploy_server.exp @@ -3,6 +3,8 @@ set timeout 300 set host "192.168.3.90" set user "root" set password "816301" +set script_dir [file dirname [file normalize [info script]]] +set project_root [file normalize [file join $script_dir ".."]] set archive "/tmp/ai-ict-deploy.tgz" set remote_archive "/root/ai-ict-deploy.tgz" set remote_path "/root/deploy/ai-ict" @@ -15,6 +17,8 @@ proc auth {} { } } +exec /bin/sh -c "cd '$project_root' && tar -czf '$archive' Dockerfile docker-compose.yml pyproject.toml README.md .env.example src scripts migrations" + spawn ssh -o StrictHostKeyChecking=accept-new $user@$host "hostname && docker --version && ((docker compose version) || (docker-compose --version)) && mkdir -p $remote_path" auth @@ -23,3 +27,7 @@ auth spawn ssh -o StrictHostKeyChecking=accept-new $user@$host "rm -rf $remote_path/* && tar -xzf $remote_archive -C $remote_path && cd $remote_path && ((docker compose down || docker-compose down) >/dev/null 2>&1 || true) && ((docker compose up -d --build) || (docker-compose up -d --build)) && ((docker compose ps) || (docker-compose ps))" auth + +file delete -force $archive + +puts "deploy_complete" diff --git a/scripts/fix_pg_hba.exp b/scripts/fix_pg_hba.exp index 9bfc9b0..8b217ef 100755 --- a/scripts/fix_pg_hba.exp +++ b/scripts/fix_pg_hba.exp @@ -6,6 +6,9 @@ hba = Path('/var/lib/pgsql/16/data/pg_hba.conf') lines = [ 'host ai_ict yuqei 172.18.0.0/16 scram-sha-256\n', 'host ai_ict yuqei 172.20.0.0/16 scram-sha-256\n', + 'host ai_ict yuqei 172.21.0.0/16 scram-sha-256\n', + 'host ai_ict yuqei 172.22.0.0/16 scram-sha-256\n', + 'host ai_ict yuqei 172.23.0.0/16 scram-sha-256\n', ] text = hba.read_text() added = [] @@ -19,6 +22,6 @@ print('TAIL') for row in hba.read_text().splitlines()[-12:]: print(row) PY -su - postgres -c "pg_ctl reload -D /var/lib/pgsql/16/data" && echo RELOADED} +su - postgres -c "psql -d postgres -c 'select pg_reload_conf();'" && echo RELOADED} expect "*assword:*" { send "816301\r" } expect eof diff --git a/scripts/run_api_server.py b/scripts/run_api_server.py index b4d6390..792c91f 100644 --- a/scripts/run_api_server.py +++ b/scripts/run_api_server.py @@ -9,11 +9,13 @@ if str(ROOT) not in sys.path: from src.api.server import serve_api -def main() -> None: - host = os.getenv("AI_ICT_API_HOST", "0.0.0.0") - port = int(os.getenv("AI_ICT_API_PORT", "8000")) +def main(env=None) -> int: + env = os.environ if env is None else env + host = env.get("AI_ICT_API_HOST", "0.0.0.0") + port = int(env.get("AI_ICT_API_PORT", "8000")) serve_api(host=host, port=port) + return 0 if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/scripts/run_autonomous_cycle.py b/scripts/run_autonomous_cycle.py new file mode 100644 index 0000000..458d8d2 --- /dev/null +++ b/scripts/run_autonomous_cycle.py @@ -0,0 +1,480 @@ +from pathlib import Path +import json +import os +import subprocess +import sys +import time + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +REQUIRED_PIPELINE_TABLES = ( + "venues", + "instruments", + "candles", + "market_sessions", + "swings", + "dealing_ranges", + "liquidity_pools", + "sweep_events", + "displacement_events", + "structure_events", + "fvg_zones", + "bias_snapshots", + "trade_signals", + "trade_executions", + "backtest_runs", + "backtest_results", +) + +REQUIRED_PIPELINE_SCHEMA = { + "venues": ("id", "code"), + "instruments": ("id", "symbol", "venue_id"), + "candles": ("id", "instrument_id", "timeframe", "ts_open", "ts_close", "open", "high", "low", "close"), + "market_sessions": ("id", "instrument_id", "session_code", "session_date", "start_ts", "end_ts", "high", "low", "midpoint", "status", "meta"), + "swings": ("id", "instrument_id", "timeframe", "kind", "strength", "price", "ts", "left_bars", "right_bars", "confirmed", "meta"), + "dealing_ranges": ("id", "instrument_id", "timeframe", "high", "low", "equilibrium", "state", "ts_start", "meta"), + "liquidity_pools": ("id", "instrument_id", "timeframe", "pool_type", "side", "price_low", "price_high", "reference_ids", "status", "created_ts", "updated_ts", "meta"), + "sweep_events": ("id", "instrument_id", "timeframe", "side_swept", "pool_id", "sweep_high", "sweep_low", "sweep_ts", "close_back_inside", "validation_level", "status", "meta"), + "displacement_events": ("id", "instrument_id", "timeframe", "direction", "start_ts", "end_ts", "strength_score", "body_ratio", "has_fvg", "is_valid", "meta"), + "structure_events": ("id", "instrument_id", "timeframe", "event_type", "direction", "broken_level", "reference_swing_id", "displacement_event_id", "accepted", "signal_relevant", "ts", "meta"), + "fvg_zones": ("id", "instrument_id", "timeframe", "direction", "lower", "upper", "start_ts", "end_ts", "status", "fill_pct", "related_displacement_id", "meta"), + "bias_snapshots": ("id", "instrument_id", "bias_tf", "setup_tf", "trigger_tf", "snapshot_ts", "daily_bias", "execution_intensity", "draw_side", "reason_codes", "meta"), + "trade_signals": ("id", "instrument_id", "model_code", "side", "status", "bias_snapshot_id", "entry_type", "entry_low", "entry_high", "stop_loss", "tp1", "tp2", "rr_tp1", "rr_tp2", "created_ts", "expires_ts", "invalidations", "evidence", "meta"), + "trade_executions": ("id", "signal_id", "execution_mode", "side", "entry_price", "stop_loss", "take_profit_1", "take_profit_2", "size", "risk_pct", "status", "opened_ts", "meta"), + "backtest_runs": ("id", "model_code", "instrument_id", "timeframe_set", "started_ts", "config", "summary"), + "backtest_results": ("id", "backtest_run_id", "signal_id", "outcome", "mfe", "mae", "tp1_hit", "tp2_hit", "invalidated_before_entry", "error_tags", "meta"), +} + +ACTION_CATALOG = { + "fix_offline_checks_before_database_work": { + "command": [sys.executable, "scripts/run_offline_checks.py"], + "requires_database": False, + "description": "Fix failing database-free checks before any database-dependent work.", + }, + "wait_for_database_recovery": { + "command": [sys.executable, "scripts/run_autonomous_cycle.py"], + "requires_database": False, + "description": "Retry the autonomous cycle after PostgreSQL recovery or on the next heartbeat.", + }, + "run_migrations_before_pipeline": { + "command": [sys.executable, "scripts/run_migrations.py"], + "requires_database": True, + "description": "Apply pending migrations before data validation or pipeline execution.", + }, + "inspect_migration_output_and_schema": { + "command": [sys.executable, "scripts/run_migrations.py"], + "requires_database": True, + "description": "Inspect failed migration output and schema readiness before retrying.", + }, + "verify_seed_migrations_or_seed_data": { + "command": [sys.executable, "scripts/run_database_readiness.py"], + "requires_database": True, + "description": "Verify required venues and instruments before the guarded pipeline.", + }, + "run_okx_backfill_or_load_replay_sample": { + "command": [sys.executable, "scripts/run_okx_backfill.py"], + "requires_database": True, + "description": "Load enough candles for data readiness before the guarded pipeline.", + }, + "inspect_backfill_output_and_data_readiness": { + "command": [sys.executable, "scripts/run_database_readiness.py"], + "requires_database": True, + "description": "Inspect failed candle backfill output and rerun database readiness.", + }, + "run_guarded_pipeline_when_ready": { + "command": [sys.executable, "scripts/run_pipeline.py"], + "requires_database": True, + "description": "Run the guarded closed loop after readiness passes.", + }, + "inspect_pipeline_output_and_quality_gates": { + "command": [sys.executable, "scripts/run_pipeline.py"], + "requires_database": True, + "description": "Inspect pipeline output and backtest quality gates before advancing.", + }, + "review_pipeline_summary_and_reports": { + "command": [sys.executable, "scripts/run_reports.py"], + "requires_database": True, + "description": "Review reports after a completed guarded pipeline.", + }, + "continue_offline_automation": { + "command": [sys.executable, "scripts/run_offline_checks.py"], + "requires_database": False, + "description": "Continue database-free automation and verification.", + }, +} + + +def run_autonomous_cycle(env=None, runner=subprocess.run, db_probe=None, schema_probe=None, cwd=ROOT, sleeper=time.sleep) -> int: + env = dict(os.environ if env is None else env) + db_probe = db_probe or probe_database + schema_probe = schema_probe or probe_database_schema + output_json = _get_bool(env, "AUTONOMOUS_OUTPUT_JSON", False) + require_db = _get_bool(env, "AUTONOMOUS_REQUIRE_DB", False) + require_schema = _get_bool(env, "AUTONOMOUS_REQUIRE_SCHEMA", True) + apply_migrations = _get_bool(env, "AUTONOMOUS_APPLY_MIGRATIONS", True) + require_data_readiness = _get_bool(env, "AUTONOMOUS_REQUIRE_DATA_READINESS", True) + backfill_candles = _get_bool(env, "AUTONOMOUS_BACKFILL_CANDLES", True) + run_pipeline = _get_bool(env, "AUTONOMOUS_RUN_PIPELINE", True) + max_db_probes = max(_get_int(env, "AUTONOMOUS_MAX_DB_PROBES", 1), 1) + probe_interval_seconds = max(_get_float(env, "AUTONOMOUS_PROBE_INTERVAL_SECONDS", 0.0), 0.0) + summary = { + "mode": "starting", + "passed": False, + "steps": [], + "database": None, + "database_attempts": [], + "schema": None, + "next_actions": [], + "action_plan": [], + } + + offline_env = dict(env) + offline_env["OFFLINE_CHECKS_OUTPUT_JSON"] = "1" + offline = _run_step( + name="offline_checks", + command=[sys.executable, "scripts/run_offline_checks.py"], + runner=runner, + cwd=cwd, + env=offline_env, + ) + summary["steps"].append(offline) + if offline["exit_code"] != 0: + summary["mode"] = "offline_checks_failed" + return _finish(summary=summary, exit_code=offline["exit_code"], output_json=output_json) + + database = _probe_database_with_retries( + db_probe=db_probe, + max_db_probes=max_db_probes, + probe_interval_seconds=probe_interval_seconds, + sleeper=sleeper, + ) + summary["database"] = database + summary["database_attempts"] = database.get("attempts", []) + if not database["available"]: + summary["mode"] = "waiting_for_database" + summary["passed"] = not require_db + return _finish(summary=summary, exit_code=2 if require_db else 0, output_json=output_json) + + schema = schema_probe() if require_schema else {"ready": True, "skipped": True, "missing_tables": []} + summary["schema"] = schema + if not schema["ready"]: + if apply_migrations: + migration_env = dict(env) + migration_env["MIGRATIONS_OUTPUT_JSON"] = "1" + migrations = _run_step( + name="migrations", + command=[sys.executable, "scripts/run_migrations.py"], + runner=runner, + cwd=cwd, + env=migration_env, + ) + summary["steps"].append(migrations) + if migrations["exit_code"] != 0: + summary["mode"] = "migration_failed" + summary["passed"] = False + return _finish(summary=summary, exit_code=migrations["exit_code"], output_json=output_json) + schema = schema_probe() + summary["schema"] = schema + if schema["ready"]: + summary["migrations_applied"] = True + else: + summary["migrations_applied"] = False + summary["mode"] = "database_schema_not_ready" + summary["passed"] = False + if not schema["ready"]: + return _finish(summary=summary, exit_code=2, output_json=output_json) + + if require_data_readiness: + readiness_env = dict(env) + readiness_env["READINESS_OUTPUT_JSON"] = "1" + readiness = _run_database_readiness_step(runner=runner, cwd=cwd, env=readiness_env) + summary["steps"].append(readiness) + if readiness["exit_code"] != 0 and backfill_candles and readiness_needs_candle_backfill(readiness.get("summary") or {}): + backfill_env = dict(env) + backfill_env["OKX_BACKFILL_OUTPUT_JSON"] = "1" + backfill = _run_step( + name="okx_backfill", + command=[sys.executable, "scripts/run_okx_backfill.py"], + runner=runner, + cwd=cwd, + env=backfill_env, + ) + summary["steps"].append(backfill) + if backfill["exit_code"] != 0: + summary["mode"] = "backfill_failed" + summary["passed"] = False + return _finish(summary=summary, exit_code=backfill["exit_code"], output_json=output_json) + readiness = _run_database_readiness_step(runner=runner, cwd=cwd, env=readiness_env) + summary["steps"].append(readiness) + if readiness["exit_code"] != 0: + summary["mode"] = "database_readiness_failed" + summary["passed"] = False + return _finish(summary=summary, exit_code=readiness["exit_code"], output_json=output_json) + + if not run_pipeline: + summary["mode"] = "database_ready_pipeline_skipped" + summary["passed"] = True + return _finish(summary=summary, exit_code=0, output_json=output_json) + + pipeline_env = dict(env) + pipeline_env.setdefault("BACKTEST_REQUIRE_QUALITY_GATE", "1") + pipeline_env.setdefault("PIPELINE_OUTPUT_JSON", "1") + pipeline = _run_step( + name="pipeline", + command=[sys.executable, "scripts/run_pipeline.py"], + runner=runner, + cwd=cwd, + env=pipeline_env, + ) + summary["steps"].append(pipeline) + summary["mode"] = "pipeline_completed" if pipeline["exit_code"] == 0 else "pipeline_failed" + summary["passed"] = pipeline["exit_code"] == 0 + return _finish(summary=summary, exit_code=pipeline["exit_code"], output_json=output_json) + + +def _run_database_readiness_step(runner, cwd: Path, env: dict) -> dict: + return _run_step( + name="database_readiness", + command=[sys.executable, "scripts/run_database_readiness.py"], + runner=runner, + cwd=cwd, + env=env, + ) + + +def readiness_needs_candle_backfill(readiness_summary: dict) -> bool: + reasons = set(readiness_summary.get("reasons") or []) + blocking_reasons = {"database_unavailable", "schema_not_ready", "missing_required_venues", "missing_required_instruments"} + if reasons & blocking_reasons: + return False + next_actions = set(readiness_summary.get("next_actions") or []) + candle_reasons = {"not_enough_candles", "not_enough_candle_span", "candles_too_stale"} + return bool(reasons & candle_reasons) or "run_okx_backfill_or_load_replay_sample" in next_actions + + +def probe_database() -> dict: + try: + from src.db.engine import engine + + with engine.connect() as connection: + value = connection.execute(text("select 1")).scalar_one() + return {"available": True, "value": value} + except Exception as exc: + return {"available": False, "error_type": exc.__class__.__name__, "error": str(exc)} + + +def probe_database_schema(required_tables: tuple[str, ...] = REQUIRED_PIPELINE_TABLES) -> dict: + try: + from src.db.engine import engine + + required_schema = build_required_schema(required_tables=required_tables) + existing_tables = set() + existing_columns: dict[str, set[str]] = {} + missing_tables = [] + with engine.connect() as connection: + for table in required_tables: + exists = connection.execute(text("select to_regclass(:table_name)"), {"table_name": f"public.{table}"}).scalar() + if exists is None: + missing_tables.append(table) + continue + existing_tables.add(table) + columns = connection.execute( + text("select column_name from information_schema.columns where table_schema = 'public' and table_name = :table_name"), + {"table_name": table}, + ).scalars().all() + existing_columns[table] = set(columns) + readiness = evaluate_schema_readiness( + required_schema=required_schema, + existing_tables=existing_tables, + existing_columns=existing_columns, + ) + readiness["missing_tables"] = missing_tables or readiness["missing_tables"] + readiness["ready"] = len(readiness["missing_tables"]) == 0 and len(readiness["missing_columns"]) == 0 + return readiness + except Exception as exc: + return { + "ready": False, + "required_tables": list(required_tables), + "missing_tables": list(required_tables), + "missing_columns": {}, + "error_type": exc.__class__.__name__, + "error": str(exc), + } + + +def build_required_schema(required_tables: tuple[str, ...] = REQUIRED_PIPELINE_TABLES) -> dict[str, tuple[str, ...]]: + return {table: REQUIRED_PIPELINE_SCHEMA.get(table, ()) for table in required_tables} + + +def evaluate_schema_readiness(required_schema: dict[str, tuple[str, ...]], existing_tables: set[str], existing_columns: dict[str, set[str]]) -> dict: + missing_tables = [table for table in required_schema if table not in existing_tables] + missing_columns = {} + for table, required_columns in required_schema.items(): + if table in missing_tables: + continue + missing = [column for column in required_columns if column not in existing_columns.get(table, set())] + if missing: + missing_columns[table] = missing + return { + "ready": len(missing_tables) == 0 and len(missing_columns) == 0, + "required_tables": list(required_schema), + "missing_tables": missing_tables, + "missing_columns": missing_columns, + } + + +def _probe_database_with_retries(db_probe, max_db_probes: int, probe_interval_seconds: float, sleeper) -> dict: + attempts = [] + last_result = None + for attempt in range(1, max_db_probes + 1): + last_result = dict(db_probe()) + last_result["attempt"] = attempt + attempts.append(last_result) + if last_result["available"]: + break + if attempt < max_db_probes and probe_interval_seconds > 0: + sleeper(probe_interval_seconds) + result = dict(last_result or {"available": False, "error_type": "NoProbeResult", "error": "no database probe executed"}) + result["attempts"] = attempts + return result + + +def _run_step(name: str, command: list[str], runner, cwd: Path, env: dict) -> dict: + result = runner(command, cwd=cwd, capture_output=True, text=True, check=False, env=env) + parsed_stdout = _parse_json(result.stdout) + return { + "name": name, + "command": command, + "status": "passed" if result.returncode == 0 else "failed", + "exit_code": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + "summary": parsed_stdout, + } + + +def _parse_json(value: str): + if not value: + return None + try: + return json.loads(value) + except json.JSONDecodeError: + return None + + +def _finish(summary: dict, exit_code: int, output_json: bool) -> int: + summary["next_actions"] = build_cycle_next_actions(summary) + summary["action_plan"] = build_cycle_action_plan(summary["next_actions"]) + if output_json: + print(json.dumps(summary, ensure_ascii=False, sort_keys=True)) + return exit_code + + print(f"Autonomous mode: {summary['mode']}") + print(f"Next actions: {', '.join(summary['next_actions'])}") + for action in summary["action_plan"]: + command = " ".join(action["command"]) + db_hint = "db" if action["requires_database"] else "no-db" + print(f"Action plan [{db_hint}]: {action['action']} -> {command}") + for step in summary["steps"]: + print(f"Step {step['name']}: {step['status']} exit_code={step['exit_code']}") + if step["exit_code"] != 0: + if step["stdout"]: + print(step["stdout"].rstrip()) + if step["stderr"]: + print(step["stderr"].rstrip()) + database = summary.get("database") + if database: + print(f"Database available: {database['available']}") + if not database["available"]: + print(f"Database probe error: {database.get('error_type')}") + schema = summary.get("schema") + if schema: + print(f"Database schema ready: {schema['ready']}") + if schema.get("missing_tables"): + print(f"Missing tables: {', '.join(schema['missing_tables'])}") + if schema.get("missing_columns"): + missing_columns = [ + f"{table}.{column}" + for table, columns in schema["missing_columns"].items() + for column in columns + ] + print(f"Missing columns: {', '.join(missing_columns)}") + return exit_code + + +def build_cycle_next_actions(summary: dict) -> list[str]: + mode = summary.get("mode") + if mode == "offline_checks_failed": + return ["fix_offline_checks_before_database_work"] + if mode == "waiting_for_database": + return ["wait_for_database_recovery"] + if mode == "database_schema_not_ready": + return ["run_migrations_before_pipeline"] + if mode == "migration_failed": + return ["inspect_migration_output_and_schema"] + if mode == "backfill_failed": + return ["inspect_backfill_output_and_data_readiness"] + if mode == "database_readiness_failed": + for step in summary.get("steps", []): + if step.get("name") == "database_readiness": + step_summary = step.get("summary") or {} + next_actions = step_summary.get("next_actions") + if next_actions: + return next_actions + return ["inspect_database_readiness_reasons"] + if mode == "database_ready_pipeline_skipped": + return ["run_guarded_pipeline_when_ready"] + if mode == "pipeline_failed": + return ["inspect_pipeline_output_and_quality_gates"] + if mode == "pipeline_completed": + return ["review_pipeline_summary_and_reports"] + return ["continue_offline_automation"] + + +def build_cycle_action_plan(next_actions: list[str]) -> list[dict]: + plan = [] + for action in next_actions: + catalog_item = ACTION_CATALOG.get(action, ACTION_CATALOG["continue_offline_automation"]) + plan.append( + { + "action": action, + "command": list(catalog_item["command"]), + "requires_database": catalog_item["requires_database"], + "description": catalog_item["description"], + } + ) + return plan + + +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"} + + +def _get_int(env, key: str, default: int) -> int: + value = env.get(key) + if value is None or value == "": + return default + return int(value) + + +def _get_float(env, key: str, default: float) -> float: + value = env.get(key) + if value is None or value == "": + return default + return float(value) + + +def main() -> int: + return run_autonomous_cycle() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_backtest.py b/scripts/run_backtest.py index faa3e50..672b014 100644 --- a/scripts/run_backtest.py +++ b/scripts/run_backtest.py @@ -1,18 +1,139 @@ from pathlib import Path +import json +import os import sys +from typing import Optional + +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.services.backtest import BacktestService +from src.services.backtest import ( + BacktestCostConfig, + BacktestQualityGate, + BacktestService, + evaluate_backtest_quality, + summarize_backtest_outcomes, +) -def main() -> None: - service = BacktestService() - run_id, outcomes = service.run_backtest(instrument_id=1, limit=100) - print(f"Backtest run id: {run_id}; results written: {len(outcomes)}") +def build_cost_config_from_env(env=None) -> BacktestCostConfig: + env = os.environ if env is None else env + return BacktestCostConfig( + fee_rate=_get_float(env, "BACKTEST_FEE_RATE", 0.0), + slippage_rate=_get_float(env, "BACKTEST_SLIPPAGE_RATE", 0.0), + fixed_cost_r=_get_float(env, "BACKTEST_FIXED_COST_R", 0.0), + ) + + +def build_require_quality_gate_from_env(env=None) -> bool: + env = os.environ if env is None else env + value = str(env.get("BACKTEST_REQUIRE_QUALITY_GATE") or "").strip().lower() + return value in {"1", "true", "yes", "on"} + + +def build_quality_gate_from_env(env=None) -> BacktestQualityGate: + env = os.environ if env is None else env + defaults = BacktestQualityGate() + return BacktestQualityGate( + min_total_signals=_get_int(env, "BACKTEST_MIN_TOTAL_SIGNALS", defaults.min_total_signals), + min_executed_signals=_get_int(env, "BACKTEST_MIN_EXECUTED_SIGNALS", defaults.min_executed_signals), + min_expectancy_r=_get_float(env, "BACKTEST_MIN_EXPECTANCY_R", defaults.min_expectancy_r), + min_profit_factor_r=_get_float(env, "BACKTEST_MIN_PROFIT_FACTOR_R", defaults.min_profit_factor_r), + max_drawdown_r=_get_float(env, "BACKTEST_MAX_DRAWDOWN_R", defaults.max_drawdown_r), + max_consecutive_losses=_get_int(env, "BACKTEST_MAX_CONSECUTIVE_LOSSES", defaults.max_consecutive_losses), + ) + + +def run_backtest_once( + service: BacktestService, + instrument_id: int = 1, + limit: int = 100, + cost_config: Optional[BacktestCostConfig] = None, + quality_gate: Optional[BacktestQualityGate] = None, + require_quality_gate: bool = False, + output_json: bool = False, +) -> int: + run_id, outcomes = service.run_backtest( + instrument_id=instrument_id, + limit=limit, + cost_config=cost_config, + quality_gate=quality_gate, + ) + summary = summarize_backtest_outcomes(outcomes) + quality_gate_result = evaluate_backtest_quality(summary, gate=quality_gate) + payload = { + "passed": not require_quality_gate or quality_gate_result["passed"], + "backtest_run_id": run_id, + "result_count": len(outcomes), + "summary": summary, + "quality_gate": quality_gate_result, + "quality_gate_required": require_quality_gate, + } + exit_code = 2 if require_quality_gate and not quality_gate_result["passed"] else 0 + + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + else: + print(f"Backtest run id: {run_id}; results written: {len(outcomes)}") + print(f"Backtest quality gate passed: {quality_gate_result['passed']}") + if quality_gate_result["reasons"]: + print(f"Backtest quality gate reasons: {', '.join(quality_gate_result['reasons'])}") + return exit_code + + +def main(env=None) -> int: + env = os.environ if env is None else env + output_json = _get_bool(env, "BACKTEST_OUTPUT_JSON", False) + try: + service = BacktestService() + cost_config = build_cost_config_from_env(env=env) + return run_backtest_once( + service=service, + cost_config=cost_config, + quality_gate=build_quality_gate_from_env(env=env), + require_quality_gate=build_require_quality_gate_from_env(env=env), + output_json=output_json, + ) + except SQLAlchemyError as exc: + summary = { + "passed": False, + "backtest_run_id": None, + "result_count": 0, + "summary": None, + "quality_gate": None, + "error_type": exc.__class__.__name__, + "error": str(exc), + } + if output_json: + print(json.dumps(summary, default=str, ensure_ascii=False, sort_keys=True)) + else: + print(f"Database unavailable for backtest sample: {exc.__class__.__name__}") + return 2 + + +def _get_float(env, key: str, default: float) -> float: + value = env.get(key) + if value is None or value == "": + return default + return float(value) + + +def _get_int(env, key: str, default: int) -> int: + value = env.get(key) + if value is None or value == "": + return default + return int(value) + + +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__': - main() + raise SystemExit(main()) diff --git a/scripts/run_database_readiness.py b/scripts/run_database_readiness.py new file mode 100644 index 0000000..65be23b --- /dev/null +++ b/scripts/run_database_readiness.py @@ -0,0 +1,304 @@ +from pathlib import Path +from datetime import datetime, timezone +import json +import os +import sys +from typing import Optional + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +DEFAULT_REQUIRED_VENUES = ("OKX",) +DEFAULT_REQUIRED_INSTRUMENTS = ("BTC-USDT",) +DEFAULT_MIN_CANDLES = 50 +DEFAULT_MIN_CANDLE_SPAN_MINUTES = 60 +DEFAULT_MAX_CANDLE_AGE_MINUTES = 240 + + +def run_database_readiness(env=None, db_probe=None, schema_probe=None, seed_probe=None, candle_probe=None) -> dict: + from scripts.run_autonomous_cycle import probe_database, probe_database_schema + + env = os.environ if env is None else env + db_probe = db_probe or probe_database + schema_probe = schema_probe or probe_database_schema + seed_probe = seed_probe or probe_seed_data + candle_probe = candle_probe or probe_candle_count + required_venues = _get_csv(env, "READINESS_REQUIRED_VENUES", DEFAULT_REQUIRED_VENUES) + required_instruments = _get_csv(env, "READINESS_REQUIRED_INSTRUMENTS", DEFAULT_REQUIRED_INSTRUMENTS) + candle_instrument_id = _get_int(env, "READINESS_CANDLE_INSTRUMENT_ID", 1) + candle_timeframe = env.get("READINESS_CANDLE_TIMEFRAME") or "1m" + min_candles = _get_int(env, "READINESS_MIN_CANDLES", DEFAULT_MIN_CANDLES) + min_candle_span_minutes = _get_int(env, "READINESS_MIN_CANDLE_SPAN_MINUTES", DEFAULT_MIN_CANDLE_SPAN_MINUTES) + max_candle_age_minutes = _get_int(env, "READINESS_MAX_CANDLE_AGE_MINUTES", DEFAULT_MAX_CANDLE_AGE_MINUTES) + now = datetime.now(timezone.utc) + + database = db_probe() + if not database.get("available"): + next_actions = build_next_actions(["database_unavailable"]) + return { + "passed": False, + "database": database, + "schema": None, + "seed_data": None, + "candles": None, + "reasons": ["database_unavailable"], + "next_actions": next_actions, + "action_plan": build_action_plan(next_actions), + } + + schema = schema_probe() + seed_data = seed_probe(required_venues=required_venues, required_instruments=required_instruments) + candles = candle_probe( + instrument_id=candle_instrument_id, + timeframe=candle_timeframe, + min_candles=min_candles, + min_span_minutes=min_candle_span_minutes, + max_age_minutes=max_candle_age_minutes, + now=now, + ) + reasons = [] + if not schema.get("ready"): + reasons.append("schema_not_ready") + if not seed_data.get("ready"): + reasons.extend(seed_data.get("reasons") or ["seed_data_not_ready"]) + if not candles.get("ready"): + reasons.extend(candles.get("reasons") or ["candles_not_ready"]) + + next_actions = build_next_actions(reasons) + return { + "passed": len(reasons) == 0, + "database": database, + "schema": schema, + "seed_data": seed_data, + "candles": candles, + "reasons": reasons, + "next_actions": next_actions, + "action_plan": build_action_plan(next_actions), + } + + +def probe_seed_data(required_venues: tuple[str, ...] = DEFAULT_REQUIRED_VENUES, required_instruments: tuple[str, ...] = DEFAULT_REQUIRED_INSTRUMENTS) -> dict: + from src.db.engine import engine + + with engine.connect() as connection: + venue_codes = { + code + for code in required_venues + if connection.execute(text("select code from venues where code = :code"), {"code": code}).scalar() is not None + } + instrument_symbols = { + symbol + for symbol in required_instruments + if connection.execute(text("select symbol from instruments where symbol = :symbol"), {"symbol": symbol}).scalar() is not None + } + return evaluate_seed_readiness( + found_venues=venue_codes, + found_instruments=instrument_symbols, + required_venues=required_venues, + required_instruments=required_instruments, + ) + + +def probe_candle_count( + instrument_id: int = 1, + timeframe: str = "1m", + min_candles: int = 0, + min_span_minutes: int = 0, + max_age_minutes: int = 0, + now: Optional[datetime] = None, +) -> dict: + from src.db.engine import engine + + with engine.connect() as connection: + row = connection.execute( + text( + """ + select count(*) as candle_count, min(ts_open) as first_ts_open, max(ts_open) as last_ts_open + from candles + where instrument_id = :instrument_id and timeframe = :timeframe + """ + ), + {"instrument_id": instrument_id, "timeframe": timeframe}, + ).mappings().one() + return evaluate_candle_readiness( + candle_count=int(row["candle_count"] or 0), + min_candles=min_candles, + instrument_id=instrument_id, + timeframe=timeframe, + first_ts_open=row["first_ts_open"], + last_ts_open=row["last_ts_open"], + min_span_minutes=min_span_minutes, + max_age_minutes=max_age_minutes, + now=now, + ) + + +def evaluate_seed_readiness(found_venues: set[str], found_instruments: set[str], required_venues: tuple[str, ...], required_instruments: tuple[str, ...]) -> dict: + missing_venues = [code for code in required_venues if code not in found_venues] + missing_instruments = [symbol for symbol in required_instruments if symbol not in found_instruments] + reasons = [] + if missing_venues: + reasons.append("missing_required_venues") + if missing_instruments: + reasons.append("missing_required_instruments") + return { + "ready": len(reasons) == 0, + "required_venues": list(required_venues), + "required_instruments": list(required_instruments), + "missing_venues": missing_venues, + "missing_instruments": missing_instruments, + "reasons": reasons, + } + + +def evaluate_candle_readiness( + candle_count: int, + min_candles: int = 0, + instrument_id: int = 1, + timeframe: str = "1m", + first_ts_open=None, + last_ts_open=None, + min_span_minutes: int = 0, + max_age_minutes: int = 0, + now: Optional[datetime] = None, +) -> dict: + reasons = [] + if min_candles and candle_count < min_candles: + reasons.append("not_enough_candles") + span_minutes = compute_span_minutes(first_ts_open, last_ts_open) + age_minutes = compute_age_minutes(last_ts_open, now=now) + if min_span_minutes and (span_minutes is None or span_minutes < min_span_minutes): + reasons.append("not_enough_candle_span") + if max_age_minutes and (age_minutes is None or age_minutes > max_age_minutes): + reasons.append("candles_too_stale") + return { + "ready": len(reasons) == 0, + "instrument_id": instrument_id, + "timeframe": timeframe, + "candle_count": candle_count, + "min_candles": min_candles, + "first_ts_open": format_timestamp(first_ts_open), + "last_ts_open": format_timestamp(last_ts_open), + "span_minutes": span_minutes, + "min_span_minutes": min_span_minutes, + "age_minutes": age_minutes, + "max_age_minutes": max_age_minutes, + "reasons": reasons, + } + + +def build_next_actions(reasons: list[str]) -> list[str]: + actions = [] + if "database_unavailable" in reasons: + actions.append("wait_for_database_recovery") + if "schema_not_ready" in reasons: + actions.append("run_migrations_before_pipeline") + if "missing_required_venues" in reasons or "missing_required_instruments" in reasons: + actions.append("verify_seed_migrations_or_seed_data") + if "not_enough_candles" in reasons or "not_enough_candle_span" in reasons or "candles_too_stale" in reasons: + actions.append("run_okx_backfill_or_load_replay_sample") + if not actions: + actions.append("run_guarded_pipeline") + return actions + + +def build_action_plan(next_actions: list[str]) -> list[dict]: + from scripts.run_autonomous_cycle import build_cycle_action_plan + + return build_cycle_action_plan(next_actions) + + +def compute_span_minutes(first_ts_open, last_ts_open) -> Optional[float]: + first_ts = parse_timestamp(first_ts_open) + last_ts = parse_timestamp(last_ts_open) + if first_ts is None or last_ts is None: + return None + return max((last_ts - first_ts).total_seconds() / 60, 0.0) + + +def compute_age_minutes(last_ts_open, now: Optional[datetime] = None) -> Optional[float]: + last_ts = parse_timestamp(last_ts_open) + if last_ts is None: + return None + current = parse_timestamp(now or datetime.now(timezone.utc)) + return max((current - last_ts).total_seconds() / 60, 0.0) + + +def parse_timestamp(value) -> Optional[datetime]: + if value is None: + return None + if isinstance(value, datetime): + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + if isinstance(value, (int, float)): + seconds = value / 1000 if value > 10_000_000_000 else value + return datetime.fromtimestamp(seconds, tz=timezone.utc) + if isinstance(value, str): + normalized = value.strip().replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(normalized) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + return None + + +def format_timestamp(value): + parsed = parse_timestamp(value) + return parsed.isoformat() if parsed else None + + +def main(env=None) -> int: + env = os.environ if env is None else env + result = run_database_readiness(env=env) + if _get_bool(env, "READINESS_OUTPUT_JSON", False): + print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + else: + print(f"Database readiness passed: {result['passed']}") + print(f"Database available: {result['database'].get('available')}") + if result.get("schema") is not None: + print(f"Schema ready: {result['schema'].get('ready')}") + if result.get("seed_data") is not None: + print(f"Seed data ready: {result['seed_data'].get('ready')}") + if result.get("candles") is not None: + print(f"Candles checked: {result['candles'].get('candle_count')}") + if result["reasons"]: + print(f"Database readiness reasons: {', '.join(result['reasons'])}") + print(f"Database readiness next actions: {', '.join(result['next_actions'])}") + for action in result.get("action_plan", []): + command = " ".join(action["command"]) + db_hint = "db" if action["requires_database"] else "no-db" + print(f"Database readiness action plan [{db_hint}]: {action['action']} -> {command}") + return 0 if result["passed"] else 2 + + +def _get_csv(env, key: str, default: tuple[str, ...]) -> tuple[str, ...]: + value = env.get(key) + if value is None or value.strip() == "": + return default + return tuple(item.strip() for item in value.split(",") if item.strip()) + + +def _get_int(env, key: str, default: int) -> int: + value = env.get(key) + if value is None or value == "": + return default + return int(value) + + +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_dev.py b/scripts/run_dev.py index 3170bba..1cca49f 100644 --- a/scripts/run_dev.py +++ b/scripts/run_dev.py @@ -5,8 +5,13 @@ ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from src.main import main +from src.main import main as app_main + + +def main() -> int: + app_main() + return 0 if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/scripts/run_displacement.py b/scripts/run_displacement.py index 1170e13..fc4ae34 100644 --- a/scripts/run_displacement.py +++ b/scripts/run_displacement.py @@ -1,6 +1,10 @@ from pathlib import Path +import json +import os import sys +from sqlalchemy.exc import SQLAlchemyError + ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) @@ -8,12 +12,40 @@ if str(ROOT) not in sys.path: from src.services.displacement import DisplacementService -def main() -> None: +def main(env=None) -> int: + env = os.environ if env is None else env + output_json = _get_bool(env, "DISPLACEMENT_OUTPUT_JSON", False) service = DisplacementService() - candidates = service.detect_displacement(instrument_id=1, timeframe="1m", limit=200) - written = service.persist_displacement(instrument_id=1, timeframe="1m", candidates=candidates) - print(f"Detected {len(candidates)} displacement events; wrote {written} rows") + try: + candidates = service.detect_displacement(instrument_id=1, timeframe="1m", limit=200) + written = service.persist_displacement(instrument_id=1, timeframe="1m", candidates=candidates) + except SQLAlchemyError as exc: + payload = { + "passed": False, + "instrument_id": 1, + "timeframe": "1m", + "error_type": exc.__class__.__name__, + "error": str(exc), + } + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + else: + print(f"Database unavailable for displacement sample: {exc.__class__.__name__}") + return 2 + payload = {"passed": True, "instrument_id": 1, "timeframe": "1m", "candidates": len(candidates), "rows_written": written} + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + else: + print(f"Detected {len(candidates)} displacement events; wrote {written} rows") + return 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__": - main() + raise SystemExit(main()) diff --git a/scripts/run_execution.py b/scripts/run_execution.py index b6749f0..e0fc65e 100644 --- a/scripts/run_execution.py +++ b/scripts/run_execution.py @@ -1,26 +1,133 @@ from pathlib import Path +import json +import os import sys +from sqlalchemy.exc import SQLAlchemyError + ROOT = Path(__file__).resolve().parents[1] +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from src.services.execution import ExecutionService +from src.services.execution import AccountRiskState, ExecutionService -def main() -> None: - service = ExecutionService() - signal_id = 1 - decision = service.prepare_execution(signal_id=signal_id, execution_mode="paper") - if decision is None: - print("Signal not found") - return - execution_id = service.create_execution(signal_id=signal_id, execution_mode="paper") - print( - f"Execution allowed: {decision.allowed}; status: {decision.status}; " - f"reasons: {decision.reasons}; execution_id: {execution_id}" +def build_account_risk_state_from_env(env=None) -> AccountRiskState: + env = os.environ if env is None else env + return AccountRiskState( + kill_switch=_get_bool(env, "EXECUTION_KILL_SWITCH", False), + daily_pnl_pct=_get_float(env, "EXECUTION_DAILY_PNL_PCT", 0.0), + consecutive_losses=_get_int(env, "EXECUTION_CONSECUTIVE_LOSSES", 0), + open_positions=_get_int(env, "EXECUTION_OPEN_POSITIONS", 0), + same_side_open_positions=_get_int(env, "EXECUTION_SAME_SIDE_OPEN_POSITIONS", 0), + max_daily_loss_pct=_get_float(env, "EXECUTION_MAX_DAILY_LOSS_PCT", 0.02), + max_consecutive_losses=_get_int(env, "EXECUTION_MAX_CONSECUTIVE_LOSSES", 3), + max_open_positions=_get_int(env, "EXECUTION_MAX_OPEN_POSITIONS", 1), + max_same_side_positions=_get_int(env, "EXECUTION_MAX_SAME_SIDE_POSITIONS", 1), ) +def main(env=None) -> int: + env = os.environ if env is None else env + output_json = _get_bool(env, "EXECUTION_OUTPUT_JSON", False) + service = ExecutionService() + signal_id = 1 + execution_mode = "paper" + account_state = build_account_risk_state_from_env(env=env) + try: + decision = service.prepare_execution(signal_id=signal_id, execution_mode=execution_mode, account_state=account_state) + if decision is None: + payload = { + "passed": True, + "signal_id": signal_id, + "execution_mode": execution_mode, + "status": "signal_not_found", + "decision": None, + "execution_id": None, + "settlement_state": None, + } + _print_summary(payload=payload, output_json=output_json) + return 0 + execution_id = service.create_execution(signal_id=signal_id, execution_mode=execution_mode, account_state=account_state) + settled = None + if execution_id is not None: + settled = service.settle_latest_execution_for_signal(signal_id=signal_id, execution_mode=execution_mode) + except SQLAlchemyError as exc: + payload = { + "passed": False, + "signal_id": signal_id, + "execution_mode": execution_mode, + "error_type": exc.__class__.__name__, + "error": str(exc), + } + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + else: + print(f"Database unavailable for execution sample: {exc.__class__.__name__}") + return 2 + payload = { + "passed": True, + "signal_id": signal_id, + "execution_mode": execution_mode, + "status": decision.status, + "decision": _decision_to_dict(decision), + "execution_id": execution_id, + "settlement_state": ((settled or {}).get("meta") or {}).get("lifecycle_state"), + } + _print_summary(payload=payload, output_json=output_json) + return 0 + + +def _print_summary(payload: dict, output_json: bool) -> None: + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + return + if payload["decision"] is None: + print("Signal not found") + return + decision = payload["decision"] + print( + f"Execution allowed: {decision['allowed']}; status: {decision['status']}; " + f"reasons: {decision['reasons']}; execution_id: {payload['execution_id']}; " + f"settlement_state: {payload['settlement_state']}" + ) + + +def _decision_to_dict(decision) -> dict: + return { + "allowed": bool(decision.allowed), + "status": decision.status, + "reasons": list(decision.reasons), + "execution_mode": decision.execution_mode, + "requires_confirmation": bool(decision.requires_confirmation), + "risk_pct": decision.risk_pct, + "size": decision.size, + "entry_price": decision.entry_price, + "meta": dict(decision.meta or {}), + } + + +def _get_float(env, key: str, default: float) -> float: + value = env.get(key) + if value is None or value == "": + return default + return float(value) + + +def _get_int(env, key: str, default: int) -> int: + value = env.get(key) + if value is None or value == "": + return default + return int(value) + + +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__": - main() + raise SystemExit(main()) diff --git a/scripts/run_execution_settlement.py b/scripts/run_execution_settlement.py new file mode 100644 index 0000000..90386f4 --- /dev/null +++ b/scripts/run_execution_settlement.py @@ -0,0 +1,81 @@ +from pathlib import Path +import json +import os +import sys + +from sqlalchemy.exc import SQLAlchemyError + +ROOT = Path(__file__).resolve().parents[1] +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.services.execution import ExecutionService + + +def main(env=None) -> int: + env = os.environ if env is None else env + output_json = _get_bool(env, "EXECUTION_SETTLEMENT_OUTPUT_JSON", False) + service = ExecutionService() + signal_id = 1 + execution_mode = "paper" + try: + settled = service.settle_latest_execution_for_signal(signal_id=signal_id, execution_mode=execution_mode) + except SQLAlchemyError as exc: + payload = { + "passed": False, + "signal_id": signal_id, + "execution_mode": execution_mode, + "error_type": exc.__class__.__name__, + "error": str(exc), + } + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + else: + print(f"Database unavailable for execution settlement sample: {exc.__class__.__name__}") + return 2 + if settled is None: + payload = { + "passed": True, + "signal_id": signal_id, + "execution_mode": execution_mode, + "status": "not_found", + "execution_id": None, + "settlement_state": None, + } + _print_summary(payload=payload, output_json=output_json) + return 0 + payload = { + "passed": True, + "signal_id": signal_id, + "execution_mode": execution_mode, + "status": settled["status"], + "execution_id": settled["id"], + "settlement_state": ((settled.get("meta") or {}).get("lifecycle_state")), + } + _print_summary(payload=payload, output_json=output_json) + return 0 + + +def _print_summary(payload: dict, output_json: bool) -> None: + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + return + if payload["execution_id"] is None: + print("Execution or latest backtest result not found") + return + print( + f"Execution settlement updated: id={payload['execution_id']}; status={payload['status']}; " + f"lifecycle_state={payload['settlement_state']}" + ) + + +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_fvg.py b/scripts/run_fvg.py index 7c55e41..76fc1cd 100644 --- a/scripts/run_fvg.py +++ b/scripts/run_fvg.py @@ -1,6 +1,10 @@ from pathlib import Path +import json +import os import sys +from sqlalchemy.exc import SQLAlchemyError + ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) @@ -8,12 +12,40 @@ if str(ROOT) not in sys.path: from src.services.fvg import FVGService -def main() -> None: +def main(env=None) -> int: + env = os.environ if env is None else env + output_json = _get_bool(env, "FVG_OUTPUT_JSON", False) service = FVGService() - candidates = service.detect_fvg(instrument_id=1, timeframe="1m", limit=200) - written = service.persist_fvg(instrument_id=1, timeframe="1m", candidates=candidates) - print(f"Detected {len(candidates)} FVG zones; wrote {written} rows") + try: + candidates = service.detect_fvg(instrument_id=1, timeframe="1m", limit=200) + written = service.persist_fvg(instrument_id=1, timeframe="1m", candidates=candidates) + except SQLAlchemyError as exc: + payload = { + "passed": False, + "instrument_id": 1, + "timeframe": "1m", + "error_type": exc.__class__.__name__, + "error": str(exc), + } + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + else: + print(f"Database unavailable for FVG sample: {exc.__class__.__name__}") + return 2 + payload = {"passed": True, "instrument_id": 1, "timeframe": "1m", "candidates": len(candidates), "rows_written": written} + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + else: + print(f"Detected {len(candidates)} FVG zones; wrote {written} rows") + return 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__": - main() + raise SystemExit(main()) diff --git a/scripts/run_liquidity.py b/scripts/run_liquidity.py index 0487b53..1bfa260 100644 --- a/scripts/run_liquidity.py +++ b/scripts/run_liquidity.py @@ -1,6 +1,10 @@ from pathlib import Path +import json +import os import sys +from sqlalchemy.exc import SQLAlchemyError + ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) @@ -8,12 +12,40 @@ if str(ROOT) not in sys.path: from src.services.liquidity import LiquidityPoolService -def main() -> None: +def main(env=None) -> int: + env = os.environ if env is None else env + output_json = _get_bool(env, "LIQUIDITY_OUTPUT_JSON", False) service = LiquidityPoolService() - candidates = service.build_candidates(instrument_id=1, timeframe="1m") - written = service.persist_candidates(instrument_id=1, timeframe="1m", candidates=candidates) - print(f"Detected {len(candidates)} liquidity pools; wrote {written} rows") + try: + candidates = service.build_candidates(instrument_id=1, timeframe="1m") + written = service.persist_candidates(instrument_id=1, timeframe="1m", candidates=candidates) + except SQLAlchemyError as exc: + payload = { + "passed": False, + "instrument_id": 1, + "timeframe": "1m", + "error_type": exc.__class__.__name__, + "error": str(exc), + } + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + else: + print(f"Database unavailable for liquidity sample: {exc.__class__.__name__}") + return 2 + payload = {"passed": True, "instrument_id": 1, "timeframe": "1m", "candidates": len(candidates), "rows_written": written} + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + else: + print(f"Detected {len(candidates)} liquidity pools; wrote {written} rows") + return 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__": - main() + raise SystemExit(main()) diff --git a/scripts/run_migration_static_checks.py b/scripts/run_migration_static_checks.py new file mode 100644 index 0000000..f3cf0f5 --- /dev/null +++ b/scripts/run_migration_static_checks.py @@ -0,0 +1,143 @@ +from pathlib import Path +import json +import os +import re +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from scripts.run_autonomous_cycle import REQUIRED_PIPELINE_SCHEMA + +MIGRATIONS_DIR = ROOT / "migrations" +REQUIRED_SEED_VALUES = { + "venues.code": ("OKX",), + "instruments.symbol": ("BTC-USDT",), +} + + +def run_migration_static_checks(migrations_dir: Path = MIGRATIONS_DIR, required_schema=None, required_seed_values=None) -> dict: + using_default_schema = required_schema is None + required_schema = required_schema or REQUIRED_PIPELINE_SCHEMA + if required_seed_values is None: + required_seed_values = REQUIRED_SEED_VALUES if using_default_schema else {} + files = iter_migration_files(migrations_dir) + errors = [] + errors.extend(validate_migration_sequence(files)) + created_schema = extract_created_schema(files) + + for table, required_columns in required_schema.items(): + if table not in created_schema: + errors.append(f"missing_table:{table}") + continue + missing_columns = [column for column in required_columns if column not in created_schema[table]] + for column in missing_columns: + errors.append(f"missing_column:{table}.{column}") + errors.extend(validate_required_seed_values(files=files, required_seed_values=required_seed_values)) + + return { + "passed": len(errors) == 0, + "errors": errors, + "migration_count": len(files), + "tables_checked": sorted(required_schema), + "seed_values_checked": { + key: list(values) + for key, values in required_seed_values.items() + }, + } + + +def iter_migration_files(migrations_dir: Path = MIGRATIONS_DIR) -> list[Path]: + return sorted(path for path in migrations_dir.glob("*.sql") if path.is_file()) + + +def validate_migration_sequence(files: list[Path]) -> list[str]: + errors = [] + numbers = [] + for path in files: + match = re.match(r"^(\d{3})_", path.name) + if not match: + errors.append(f"invalid_migration_name:{path.name}") + continue + numbers.append(int(match.group(1))) + expected = list(range(1, len(numbers) + 1)) + if numbers != expected: + errors.append(f"non_contiguous_migration_sequence:{numbers}") + return errors + + +def extract_created_schema(files: list[Path]) -> dict[str, set[str]]: + schema: dict[str, set[str]] = {} + for path in files: + sql = path.read_text(encoding="utf-8") + schema.update(extract_created_table_columns(sql)) + return schema + + +def validate_required_seed_values(files: list[Path], required_seed_values: dict[str, tuple[str, ...]]) -> list[str]: + sql = "\n".join(path.read_text(encoding="utf-8") for path in files) + errors = [] + for seed_key, values in required_seed_values.items(): + for value in values: + if f"'{value}'" not in sql and f'"{value}"' not in sql: + errors.append(f"missing_seed_value:{seed_key}={value}") + return errors + + +def extract_created_table_columns(sql: str) -> dict[str, set[str]]: + tables: dict[str, set[str]] = {} + for match in re.finditer(r"create\s+table\s+if\s+not\s+exists\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\((.*?)\)\s*(?:partition\s+by\s+range\s*\([^)]+\))?\s*;", sql, flags=re.IGNORECASE | re.DOTALL): + table_name = match.group(1) + body = match.group(2) + columns = set() + for item in _split_sql_columns(body): + token = item.strip().split(None, 1)[0].strip('"').lower() if item.strip() else "" + if not token or token in {"primary", "unique", "constraint", "foreign", "check"}: + continue + columns.add(token) + tables[table_name] = columns + return tables + + +def _split_sql_columns(body: str) -> list[str]: + items = [] + current = [] + depth = 0 + for char in body: + if char == "(": + depth += 1 + elif char == ")": + depth = max(depth - 1, 0) + if char == "," and depth == 0: + items.append("".join(current)) + current = [] + continue + current.append(char) + if current: + items.append("".join(current)) + return items + + +def main(env=None) -> int: + env = os.environ if env is None else env + result = run_migration_static_checks() + if _get_bool(env, "MIGRATION_CHECKS_OUTPUT_JSON", False): + print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + else: + print(f"Migration static checks passed: {result['passed']}") + print(f"Migration files checked: {result['migration_count']}") + if result["errors"]: + print(f"Migration static check errors: {', '.join(result['errors'])}") + return 0 if result["passed"] else 2 + + +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_migrations.py b/scripts/run_migrations.py index 7a51c0f..9ee29df 100644 --- a/scripts/run_migrations.py +++ b/scripts/run_migrations.py @@ -1,4 +1,6 @@ from pathlib import Path +import json +import os import sys ROOT = Path(__file__).resolve().parents[1] @@ -6,6 +8,7 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError from src.db.engine import engine @@ -16,20 +19,102 @@ def iter_migration_files() -> list[Path]: return sorted(path for path in MIGRATIONS_DIR.glob("*.sql") if path.is_file()) -def main() -> None: +def split_sql_statements(sql: str) -> list[str]: + statements = [] + current = [] + in_single_quote = False + in_double_quote = False + index = 0 + while index < len(sql): + char = sql[index] + next_char = sql[index + 1] if index + 1 < len(sql) else "" + + if not in_single_quote and not in_double_quote and char == "-" and next_char == "-": + while index < len(sql) and sql[index] != "\n": + index += 1 + continue + if char == "'" and not in_double_quote: + in_single_quote = not in_single_quote + elif char == '"' and not in_single_quote: + in_double_quote = not in_double_quote + if char == ";" and not in_single_quote and not in_double_quote: + statement = "".join(current).strip() + if statement: + statements.append(statement) + current = [] + index += 1 + continue + current.append(char) + index += 1 + + statement = "".join(current).strip() + if statement: + statements.append(statement) + return statements + + +def apply_migration_file(connection, path: Path) -> int: + sql = path.read_text(encoding="utf-8").strip() + statements = split_sql_statements(sql) + for statement in statements: + connection.execute(text(statement)) + return len(statements) + + +def run_migrations(connection, files: list[Path]) -> list[dict]: + results = [] + for path in files: + statement_count = apply_migration_file(connection, path) + results.append({"file": path.name, "statements": statement_count}) + return results + + +def main(env=None) -> int: + env = os.environ if env is None else env + json_output = _get_bool(env, "MIGRATIONS_OUTPUT_JSON", False) files = iter_migration_files() if not files: - print("no migration files found") - return + summary = {"passed": True, "migration_count": 0, "results": []} + _print_summary(summary, json_output=json_output) + return 0 - with engine.begin() as connection: - for path in files: - sql = path.read_text(encoding="utf-8").strip() - if not sql: - continue - connection.execute(text(sql)) - print(f"applied {path.name}") + try: + with engine.begin() as connection: + results = run_migrations(connection=connection, files=files) + except SQLAlchemyError as exc: + summary = { + "passed": False, + "migration_count": len(files), + "results": [], + "error_type": exc.__class__.__name__, + "error": str(exc), + } + _print_summary(summary, json_output=json_output) + return 2 + summary = {"passed": True, "migration_count": len(files), "results": results} + _print_summary(summary, json_output=json_output) + return 0 + + +def _print_summary(summary: dict, json_output: bool) -> None: + if json_output: + print(json.dumps(summary, ensure_ascii=False, sort_keys=True)) + return + if not summary["results"] and summary["passed"]: + print("no migration files found") + for item in summary["results"]: + if item["statements"]: + print(f"applied {item['file']}; statements: {item['statements']}") + if not summary["passed"]: + print(f"Database unavailable for migrations: {summary.get('error_type')}") + + +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__": - main() + raise SystemExit(main()) diff --git a/scripts/run_mss.py b/scripts/run_mss.py index 6d0ed57..04130d9 100644 --- a/scripts/run_mss.py +++ b/scripts/run_mss.py @@ -1,6 +1,10 @@ from pathlib import Path +import json +import os import sys +from sqlalchemy.exc import SQLAlchemyError + ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) @@ -8,12 +12,40 @@ if str(ROOT) not in sys.path: from src.services.structure import MSSService -def main() -> None: +def main(env=None) -> int: + env = os.environ if env is None else env + output_json = _get_bool(env, "MSS_OUTPUT_JSON", False) service = MSSService() - candidates = service.detect_mss(instrument_id=1, timeframe="1m") - written = service.persist_mss(instrument_id=1, timeframe="1m", candidates=candidates) - print(f"Detected {len(candidates)} MSS events; wrote {written} rows") + try: + candidates = service.detect_mss(instrument_id=1, timeframe="1m") + written = service.persist_mss(instrument_id=1, timeframe="1m", candidates=candidates) + except SQLAlchemyError as exc: + payload = { + "passed": False, + "instrument_id": 1, + "timeframe": "1m", + "error_type": exc.__class__.__name__, + "error": str(exc), + } + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + else: + print(f"Database unavailable for MSS sample: {exc.__class__.__name__}") + return 2 + payload = {"passed": True, "instrument_id": 1, "timeframe": "1m", "candidates": len(candidates), "rows_written": written} + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + else: + print(f"Detected {len(candidates)} MSS events; wrote {written} rows") + return 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__": - main() + raise SystemExit(main()) diff --git a/scripts/run_offline_checks.py b/scripts/run_offline_checks.py new file mode 100644 index 0000000..9498a27 --- /dev/null +++ b/scripts/run_offline_checks.py @@ -0,0 +1,97 @@ +from pathlib import Path +import json +import os +import shutil +import subprocess +import sys + +ROOT = Path(__file__).resolve().parents[1] + +DEFAULT_CHECKS = [ + ("unit_tests", [sys.executable, "-m", "unittest", "discover", "-s", "tests/unit", "-p", "test_*.py"]), + ("migration_static_checks", [sys.executable, "scripts/run_migration_static_checks.py"]), + ("script_static_checks", [sys.executable, "scripts/run_script_static_checks.py"]), + ("dashboard_js_syntax", ["node", "--check", "src/web/app.js"]), +] + + +def build_check_commands(env=None) -> list[tuple[str, list[str]]]: + env = os.environ if env is None else env + commands = list(DEFAULT_CHECKS) + replay_file = env.get("OFFLINE_REPLAY_FILE") + if replay_file: + commands.append(("replay_file", [sys.executable, "scripts/run_replay_file.py", replay_file])) + return commands + + +def run_checks(commands=None, runner=subprocess.run, cwd=ROOT, node_available=None, env=None) -> int: + commands = commands or build_check_commands(env=env) + if node_available is None: + node_available = shutil.which("node") is not None + env = os.environ if env is None else env + subprocess_env = dict(os.environ) + subprocess_env.update(env) + json_output = _get_bool(env, "OFFLINE_CHECKS_OUTPUT_JSON", False) + results = [] + + for name, command in commands: + if command[0] == "node" and not node_available: + results.append({"name": name, "status": "skipped", "exit_code": 0, "reason": "node_not_available"}) + if not json_output: + print(f"Offline check skipped: {name} (node not available)") + continue + if not json_output: + print(f"Offline check started: {name}") + result = runner(command, cwd=cwd, capture_output=True, text=True, check=False, env=subprocess_env) + results.append( + { + "name": name, + "status": "passed" if result.returncode == 0 else "failed", + "exit_code": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + } + ) + if result.stdout and not json_output: + print(result.stdout.rstrip()) + if result.stderr and not json_output: + print(result.stderr.rstrip()) + if result.returncode != 0: + if json_output: + _print_json_summary(results) + else: + print(f"Offline check failed: {name} exit_code={result.returncode}") + return result.returncode + if not json_output: + print(f"Offline check passed: {name}") + if json_output: + _print_json_summary(results) + return 0 + + +def main() -> int: + return run_checks() + + +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"} + + +def _print_json_summary(results: list[dict]) -> None: + print( + json.dumps( + { + "passed": all(item["status"] in {"passed", "skipped"} for item in results), + "checks": results, + }, + ensure_ascii=False, + sort_keys=True, + ) + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_okx_backfill.py b/scripts/run_okx_backfill.py index ee220a9..99f5fae 100644 --- a/scripts/run_okx_backfill.py +++ b/scripts/run_okx_backfill.py @@ -1,5 +1,11 @@ from pathlib import Path +import json +import os import sys +from http.client import IncompleteRead +from urllib.error import HTTPError, URLError + +from sqlalchemy.exc import SQLAlchemyError ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: @@ -18,37 +24,121 @@ INSTRUMENT_ID_MAP = { TIMEFRAMES = ["1m", "5m", "15m"] -def main() -> None: - symbol = "BTC-USDT" - inst_id = "BTC-USDT-SWAP" - pages = 20 - limit = 100 +def build_backfill_config_from_env(env=None) -> dict: + env = os.environ if env is None else env + symbol = env.get("OKX_BACKFILL_SYMBOL") or "BTC-USDT" + inst_id = env.get("OKX_BACKFILL_INST_ID") or f"{symbol}-SWAP" + timeframes = _get_csv(env, "OKX_BACKFILL_TIMEFRAMES", tuple(TIMEFRAMES)) + return { + "symbol": symbol, + "inst_id": inst_id, + "pages": _get_int(env, "OKX_BACKFILL_PAGES", 20), + "limit": _get_int(env, "OKX_BACKFILL_LIMIT", 100), + "timeframes": list(timeframes), + } - client = OKXClient() - repository = CandleRepository() +def run_okx_backfill_once( + client=None, + repository=None, + symbol: str = "BTC-USDT", + inst_id: str = "BTC-USDT-SWAP", + pages: int = 20, + limit: int = 100, + timeframes: list[str] = TIMEFRAMES, +) -> dict: + if symbol not in INSTRUMENT_ID_MAP: + raise ValueError(f"unsupported_okx_backfill_symbol:{symbol}") + client = client or OKXClient() + repository = repository or CandleRepository() grand_raw = 0 grand_written = 0 + timeframe_results = [] - for timeframe in TIMEFRAMES: + for timeframe in timeframes: total_raw = 0 total_written = 0 seen_ts = set() + after = None for _ in range(pages): - raw = client.get_candles(inst_id=inst_id, bar=timeframe, limit=limit) + raw = client.get_candles(inst_id=inst_id, bar=timeframe, limit=limit, after=after) + if not raw: + break normalized = normalize_okx_candles(symbol=symbol, venue="OKX", timeframe=timeframe, candles=raw) - confirmed = [c for c in normalized if c.confirm and c.ts_open_ms not in seen_ts] - for candle in confirmed: + confirmed = [] + for candle in normalized: + if not candle.confirm or candle.ts_open_ms in seen_ts: + continue seen_ts.add(candle.ts_open_ms) + confirmed.append(candle) written = repository.upsert_many(confirmed, instrument_id=INSTRUMENT_ID_MAP[symbol]) total_raw += len(raw) total_written += written + next_after = str(min(int(candle.ts) for candle in raw)) + if next_after == after: + break + after = next_after grand_raw += total_raw grand_written += total_written - print(f"timeframe={timeframe} fetched={total_raw} wrote={total_written}") + timeframe_results.append({"timeframe": timeframe, "fetched": total_raw, "wrote": total_written}) - print(f"total fetched={grand_raw} total wrote={grand_written}") + return { + "symbol": symbol, + "inst_id": inst_id, + "pages": pages, + "limit": limit, + "timeframes": timeframe_results, + "total_fetched": grand_raw, + "total_wrote": grand_written, + } + + +def main(env=None) -> int: + env = os.environ if env is None else env + json_output = _get_bool(env, "OKX_BACKFILL_OUTPUT_JSON", False) + try: + summary = run_okx_backfill_once(**build_backfill_config_from_env(env=env)) + except (SQLAlchemyError, URLError, HTTPError, TimeoutError, RuntimeError, OSError, IncompleteRead, ValueError) as exc: + summary = { + "passed": False, + "error_type": exc.__class__.__name__, + "error": str(exc), + } + if json_output: + print(json.dumps(summary, ensure_ascii=False, sort_keys=True)) + else: + print(f"OKX backfill failed: {exc.__class__.__name__}") + return 2 + summary["passed"] = True + if json_output: + print(json.dumps(summary, ensure_ascii=False, sort_keys=True)) + return 0 + for item in summary["timeframes"]: + print(f"timeframe={item['timeframe']} fetched={item['fetched']} wrote={item['wrote']}") + print(f"total fetched={summary['total_fetched']} total wrote={summary['total_wrote']}") + return 0 + + +def _get_csv(env, key: str, default: tuple[str, ...]) -> tuple[str, ...]: + value = env.get(key) + if value is None or value.strip() == "": + return default + return tuple(item.strip() for item in value.split(",") if item.strip()) + + +def _get_int(env, key: str, default: int) -> int: + value = env.get(key) + if value is None or value == "": + return default + return int(value) + + +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__": - main() + raise SystemExit(main()) diff --git a/scripts/run_pipeline.py b/scripts/run_pipeline.py index 82cf5fc..2d77316 100644 --- a/scripts/run_pipeline.py +++ b/scripts/run_pipeline.py @@ -1,18 +1,74 @@ from pathlib import Path import json +import os import sys +from typing import Optional + +from sqlalchemy.exc import SQLAlchemyError ROOT = Path(__file__).resolve().parents[1] +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) +from scripts.run_backtest import ( + build_cost_config_from_env, + build_quality_gate_from_env, + build_require_quality_gate_from_env, +) +from scripts.run_execution import build_account_risk_state_from_env +from scripts.run_signals import build_signal_evidence_keys_from_env from src.main import run_closed_loop -def main() -> None: - result = run_closed_loop(instrument_id=1, timeframe="1m", backtest_limit=100, run_execution=True) +def main(env=None) -> int: + env = os.environ if env is None else env + output_json = _get_bool(env, "PIPELINE_OUTPUT_JSON", False) + try: + result = run_closed_loop( + instrument_id=1, + timeframe="1m", + backtest_limit=100, + run_execution=True, + backtest_cost_config=build_cost_config_from_env(env=env), + backtest_quality_gate=build_quality_gate_from_env(env=env), + signal_required_evidence_keys=build_signal_evidence_keys_from_env(env=env), + execution_account_state=build_account_risk_state_from_env(env=env), + ) + except SQLAlchemyError as exc: + if output_json: + print( + json.dumps( + {"passed": False, "error_type": exc.__class__.__name__, "error": str(exc)}, + default=str, + ensure_ascii=False, + sort_keys=True, + ) + ) + else: + print(f"Database unavailable for pipeline sample: {exc.__class__.__name__}") + return 2 print(json.dumps(result, default=str, ensure_ascii=False, indent=2)) + quality_gate = _extract_quality_gate(result) + if build_require_quality_gate_from_env(env=env) and (not quality_gate or quality_gate.get("passed") is not True): + return 2 + return 0 + + +def _extract_quality_gate(result: dict) -> Optional[dict]: + if result.get("backtest_quality_gate"): + return result["backtest_quality_gate"] + latest_backtest = result.get("latest_backtest") or {} + summary = latest_backtest.get("summary") or {} + return summary.get("quality_gate") + + +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__": - main() + raise SystemExit(main()) diff --git a/scripts/run_ranges.py b/scripts/run_ranges.py index a28a50d..d36e8bc 100644 --- a/scripts/run_ranges.py +++ b/scripts/run_ranges.py @@ -1,6 +1,10 @@ from pathlib import Path +import json +import os import sys +from sqlalchemy.exc import SQLAlchemyError + ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) @@ -8,12 +12,40 @@ if str(ROOT) not in sys.path: from src.services.ranges import DealingRangeService -def main() -> None: +def main(env=None) -> int: + env = os.environ if env is None else env + output_json = _get_bool(env, "RANGES_OUTPUT_JSON", False) service = DealingRangeService() - candidate = service.build_active_range(instrument_id=1, timeframe="1m") - written = service.persist_active_range(instrument_id=1, timeframe="1m", candidate=candidate) - print(f"Persisted {written} dealing range rows") + try: + candidate = service.build_active_range(instrument_id=1, timeframe="1m") + written = service.persist_active_range(instrument_id=1, timeframe="1m", candidate=candidate) + except SQLAlchemyError as exc: + payload = { + "passed": False, + "instrument_id": 1, + "timeframe": "1m", + "error_type": exc.__class__.__name__, + "error": str(exc), + } + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + else: + print(f"Database unavailable for ranges sample: {exc.__class__.__name__}") + return 2 + payload = {"passed": True, "instrument_id": 1, "timeframe": "1m", "has_candidate": candidate is not None, "rows_written": written} + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + else: + print(f"Persisted {written} dealing range rows") + return 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__": - main() + raise SystemExit(main()) diff --git a/scripts/run_replay_file.py b/scripts/run_replay_file.py new file mode 100644 index 0000000..5165ad1 --- /dev/null +++ b/scripts/run_replay_file.py @@ -0,0 +1,209 @@ +from pathlib import Path +from datetime import datetime, timezone +import json +import os +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.backtest import detect_confirmed_swings_for_replay, iter_historical_replay, load_replay_candles + + +def main(argv=None, env=None) -> int: + argv = argv if argv is not None else sys.argv[1:] + env = os.environ if env is None else env + if not argv: + print("Usage: python3 scripts/run_replay_file.py ") + return 2 + + left_bars = _get_int(env, "REPLAY_LEFT_BARS", 2) + right_bars = _get_int(env, "REPLAY_RIGHT_BARS", 2) + min_candles = _get_int(env, "REPLAY_MIN_CANDLES", 0) + min_swings = _get_int(env, "REPLAY_MIN_SWINGS", 0) + max_gap_minutes = _get_float(env, "REPLAY_MAX_GAP_MINUTES", 0.0) + json_output = _get_bool(env, "REPLAY_OUTPUT_JSON", False) + try: + candles = load_replay_candles(argv[0]) + frames = list(iter_historical_replay(candles, left_bars=left_bars, right_bars=right_bars)) + swings = detect_confirmed_swings_for_replay(candles, left_bars=left_bars, right_bars=right_bars) + except (OSError, ValueError) as exc: + print(f"Replay file invalid: {exc}") + return 2 + + gate_reasons = _evaluate_replay_gate( + candle_count=len(candles), + swing_count=len(swings), + min_candles=min_candles, + min_swings=min_swings, + max_gap_minutes=max_gap_minutes, + observed_max_gap_minutes=compute_max_gap_minutes(candles), + ) + summary = _build_replay_summary( + candles=candles, + frame_count=len(frames), + swings=swings, + gate_reasons=gate_reasons, + gate_config={"min_candles": min_candles, "min_swings": min_swings, "max_gap_minutes": max_gap_minutes}, + ) + if json_output: + print(json.dumps(summary, ensure_ascii=False, sort_keys=True)) + else: + _print_replay_summary(summary) + return 0 if summary["quality_gate"]["passed"] else 2 + + +def _get_int(env, key: str, default: int) -> int: + value = env.get(key) + if value is None or value == "": + return default + return int(value) + + +def _get_float(env, key: str, default: float) -> float: + value = env.get(key) + if value is None or value == "": + return default + return float(value) + + +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"} + + +def _evaluate_replay_gate( + candle_count: int, + swing_count: int, + min_candles: int = 0, + min_swings: int = 0, + max_gap_minutes: float = 0.0, + observed_max_gap_minutes: Optional[float] = None, +) -> list[str]: + reasons = [] + if min_candles and candle_count < min_candles: + reasons.append("candle_sample_too_small") + if min_swings and swing_count < min_swings: + reasons.append("swing_sample_too_small") + if max_gap_minutes and (observed_max_gap_minutes is None or observed_max_gap_minutes > max_gap_minutes): + reasons.append("candle_gap_too_large") + return reasons + + +def _build_replay_summary(candles: list[dict], frame_count: int, swings: list, gate_reasons: list[str], gate_config: dict) -> dict: + latest = swings[-1] if swings else None + return { + "candle_count": len(candles), + "frame_count": frame_count, + "sample": build_replay_sample_summary(candles), + "swing_count": len(swings), + "swing_counts": { + "swing_high": sum(1 for swing in swings if swing.kind == "swing_high"), + "swing_low": sum(1 for swing in swings if swing.kind == "swing_low"), + }, + "latest_swing": { + "kind": latest.kind, + "price": latest.price, + "source_index": latest.source_index, + "confirmed_at_index": latest.confirmed_at_index, + } if latest else None, + "quality_gate": { + "passed": len(gate_reasons) == 0, + "reasons": gate_reasons, + "gate": gate_config, + }, + } + + +def build_replay_sample_summary(candles: list[dict]) -> dict: + if not candles: + return { + "first_ts_open": None, + "last_ts_open": None, + "first_close": None, + "last_close": None, + "highest_high": None, + "lowest_low": None, + "close_return_pct": None, + "max_gap_minutes": None, + } + first = candles[0] + last = candles[-1] + first_close = float(first["close"]) + last_close = float(last["close"]) + close_return_pct = ((last_close - first_close) / first_close) if first_close else None + return { + "first_ts_open": first.get("ts_open"), + "last_ts_open": last.get("ts_open"), + "first_close": first_close, + "last_close": last_close, + "highest_high": max(float(candle["high"]) for candle in candles), + "lowest_low": min(float(candle["low"]) for candle in candles), + "close_return_pct": close_return_pct, + "max_gap_minutes": compute_max_gap_minutes(candles), + } + + +def compute_max_gap_minutes(candles: list[dict]) -> Optional[float]: + gaps = [] + previous = None + for candle in candles: + current = parse_timestamp(candle.get("ts_open")) + if current is None: + return None + if previous is not None: + gaps.append(max((current - previous).total_seconds() / 60, 0.0)) + previous = current + return max(gaps) if gaps else 0.0 + + +def parse_timestamp(value) -> Optional[datetime]: + if value is None: + return None + if isinstance(value, datetime): + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + if isinstance(value, (int, float)): + seconds = value / 1000 if value > 10_000_000_000 else value + return datetime.fromtimestamp(seconds, tz=timezone.utc) + if isinstance(value, str): + normalized = value.strip().replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(normalized) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + return None + + +def _print_replay_summary(summary: dict) -> None: + print(f"Replay candles: {summary['candle_count']}") + print(f"Replay frames: {summary['frame_count']}") + sample = summary["sample"] + if sample["first_ts_open"] is not None: + print( + f"Replay sample: first_ts={sample['first_ts_open']} last_ts={sample['last_ts_open']} " + f"close_return_pct={sample['close_return_pct']}" + ) + print(f"Replay max gap minutes: {sample['max_gap_minutes']}") + print(f"Replay confirmed swings: {summary['swing_count']}") + latest = summary["latest_swing"] + if latest: + print( + f"Latest swing: {latest['kind']} price={latest['price']} " + f"source_index={latest['source_index']} confirmed_at_index={latest['confirmed_at_index']}" + ) + print(f"Replay quality gate passed: {summary['quality_gate']['passed']}") + if summary["quality_gate"]["reasons"]: + print(f"Replay quality gate reasons: {', '.join(summary['quality_gate']['reasons'])}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_reports.py b/scripts/run_reports.py index 832e009..0e209e4 100644 --- a/scripts/run_reports.py +++ b/scripts/run_reports.py @@ -1,7 +1,10 @@ from pathlib import Path +import json +import os import sys ROOT = Path(__file__).resolve().parents[1] +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) @@ -13,7 +16,9 @@ from src.services.reports import ( ) -def main() -> None: +def main(env=None) -> int: + env = os.environ if env is None else env + output_json = _get_bool(env, "REPORTS_OUTPUT_JSON", False) pre_market = PreMarketReportService().build_report( bias_snapshot={ "daily_bias": "bullish", @@ -38,11 +43,31 @@ def main() -> None: ) backtest_summary = BacktestReportService().build_summary([]) + payload = { + "passed": True, + "pre_market": pre_market, + "intraday": intraday, + "post_trade": review, + "backtest_summary": backtest_summary, + } + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + return 0 + print(f"Pre-market permission: {pre_market['execution_permission']}") print(f"Intraday valid: {intraday['signal']['is_valid']}") print(f"Post-trade aligned: {review['rule_aligned']}") print(f"Backtest total signals: {backtest_summary['total_signals']}") + print(f"Backtest quality gate passed: {backtest_summary['quality_gate']['passed']}") + return 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__": - main() + raise SystemExit(main()) diff --git a/scripts/run_script_static_checks.py b/scripts/run_script_static_checks.py new file mode 100644 index 0000000..b069927 --- /dev/null +++ b/scripts/run_script_static_checks.py @@ -0,0 +1,144 @@ +from pathlib import Path +import ast +import json +import os + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT_DIR = ROOT / "scripts" + +FORBIDDEN_PATTERNS = ( + "env " + "= env or " + "os.environ", + "env or " + "os.environ", +) + + +def run_script_static_checks(env=None, script_dir: Path = SCRIPT_DIR) -> dict: + env = os.environ if env is None else env + findings = [] + for path in sorted(script_dir.glob("*.py")): + findings.extend(check_script_file(path)) + return { + "passed": len(findings) == 0, + "script_count": len(list(script_dir.glob("*.py"))), + "findings": findings, + } + + +def check_script_file(path: Path) -> list[dict]: + findings = [] + source = path.read_text(encoding="utf-8") + tree = None + try: + tree = ast.parse(source, filename=str(path)) + except SyntaxError as exc: + findings.append( + { + "file": display_path(path), + "line": exc.lineno or 1, + "pattern": "python_syntax", + "reason": "script_python_syntax_error", + } + ) + if tree is not None: + findings.extend(find_python39_incompatible_annotations(tree, path)) + for line_number, line in enumerate(source.splitlines(), start=1): + for pattern in FORBIDDEN_PATTERNS: + if pattern in line: + findings.append( + { + "file": display_path(path), + "line": line_number, + "pattern": pattern, + "reason": forbidden_pattern_reason(pattern), + } + ) + if tree is not None and has_main_function(tree) and "raise SystemExit(main(" not in source: + findings.append( + { + "file": display_path(path), + "line": 1, + "pattern": "raise SystemExit(main())", + "reason": "automation_entrypoint_must_preserve_exit_code", + } + ) + return findings + + +def forbidden_pattern_reason(pattern: str) -> str: + return "empty_env_must_not_fall_back_to_ambient_environment" + + +def has_main_function(tree: ast.AST) -> bool: + return any(isinstance(node, ast.FunctionDef) and node.name == "main" for node in ast.walk(tree)) + + +def find_python39_incompatible_annotations(tree: ast.AST, path: Path) -> list[dict]: + findings = [] + + def inspect_annotation(annotation) -> None: + if annotation is not None and has_pep604_optional_annotation(annotation): + findings.append( + { + "file": display_path(path), + "line": getattr(annotation, "lineno", 1), + "pattern": "PEP604 Optional", + "reason": "python39_incompatible_pep604_optional_annotation", + } + ) + + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + inspect_annotation(node.returns) + for arg in list(node.args.args) + list(node.args.kwonlyargs) + list(node.args.posonlyargs): + inspect_annotation(arg.annotation) + if node.args.vararg: + inspect_annotation(node.args.vararg.annotation) + if node.args.kwarg: + inspect_annotation(node.args.kwarg.annotation) + elif isinstance(node, ast.AnnAssign): + inspect_annotation(node.annotation) + return findings + + +def has_pep604_optional_annotation(annotation) -> bool: + if isinstance(annotation, ast.BinOp) and isinstance(annotation.op, ast.BitOr): + return is_none_annotation(annotation.left) or is_none_annotation(annotation.right) + return any(has_pep604_optional_annotation(child) for child in ast.iter_child_nodes(annotation)) + + +def is_none_annotation(node) -> bool: + return isinstance(node, ast.Constant) and node.value is None + + +def display_path(path: Path) -> str: + try: + return str(path.relative_to(ROOT)) + except ValueError: + return path.name + + +def main(env=None) -> int: + env = os.environ if env is None else env + result = run_script_static_checks(env=env) + if _get_bool(env, "SCRIPT_CHECKS_OUTPUT_JSON", False): + print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + else: + print(f"Script static checks passed: {result['passed']}") + print(f"Script files checked: {result['script_count']}") + for finding in result["findings"]: + print( + f"{finding['file']}:{finding['line']} " + f"{finding['reason']} pattern={finding['pattern']}" + ) + return 0 if result["passed"] else 2 + + +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_session_generator.py b/scripts/run_session_generator.py index 3722deb..b03231d 100644 --- a/scripts/run_session_generator.py +++ b/scripts/run_session_generator.py @@ -1,7 +1,11 @@ from pathlib import Path +import json +import os import sys from datetime import datetime +from sqlalchemy.exc import SQLAlchemyError + ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) @@ -9,12 +13,40 @@ if str(ROOT) not in sys.path: from src.services.sessions import SessionGenerator -def main() -> None: +def main(env=None) -> int: + env = os.environ if env is None else env + output_json = _get_bool(env, "SESSION_GENERATOR_OUTPUT_JSON", False) generator = SessionGenerator() today = datetime.now(generator.timezone) - written = generator.generate_for_date(instrument_id=1, timeframe="1m", session_date=today) - print(f"Generated {written} session rows") + try: + written = generator.generate_for_date(instrument_id=1, timeframe="1m", session_date=today) + except SQLAlchemyError as exc: + payload = { + "passed": False, + "instrument_id": 1, + "timeframe": "1m", + "error_type": exc.__class__.__name__, + "error": str(exc), + } + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + else: + print(f"Database unavailable for session generator sample: {exc.__class__.__name__}") + return 2 + payload = {"passed": True, "instrument_id": 1, "timeframe": "1m", "session_rows_written": written} + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + else: + print(f"Generated {written} session rows") + return 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__": - main() + raise SystemExit(main()) diff --git a/scripts/run_signals.py b/scripts/run_signals.py index 913256f..bb57ceb 100644 --- a/scripts/run_signals.py +++ b/scripts/run_signals.py @@ -1,25 +1,73 @@ from pathlib import Path +import json +import os import sys +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.services.bias import BiasService -from src.services.signals import SignalService +from src.services.signals import MINIMAL_SIGNAL_EVIDENCE_KEYS, STRICT_SIGNAL_EVIDENCE_KEYS, SignalService -def main() -> None: +def build_signal_evidence_keys_from_env(env=None) -> tuple[str, ...]: + env = os.environ if env is None else env + mode = str(env.get("SIGNAL_EVIDENCE_MODE") or "minimal").strip().lower() + if mode == "minimal": + return MINIMAL_SIGNAL_EVIDENCE_KEYS + if mode == "strict": + return STRICT_SIGNAL_EVIDENCE_KEYS + raise ValueError(f"unsupported_signal_evidence_mode:{mode}") + + +def main(env=None) -> int: + env = os.environ if env is None else env + output_json = _get_bool(env, "SIGNALS_OUTPUT_JSON", False) instrument_id = 1 bias_service = BiasService() - signal_service = SignalService() + signal_service = SignalService(required_evidence_keys=build_signal_evidence_keys_from_env(env=env)) - bias = bias_service.build_bias(instrument_id=instrument_id, bias_tf='1m', setup_tf='1m', trigger_tf='1m') - bias_id = bias_service.persist_bias(instrument_id=instrument_id, bias_tf='1m', setup_tf='1m', trigger_tf='1m', candidate=bias) - signal = signal_service.build_signal(instrument_id=instrument_id, setup_tf='1m', trigger_tf='1m') - written = signal_service.persist_signal(instrument_id=instrument_id, setup_tf='1m', trigger_tf='1m', candidate=signal) - print(f'Bias snapshot id: {bias_id}; signal rows written: {written}') + try: + bias = bias_service.build_bias(instrument_id=instrument_id, bias_tf='1m', setup_tf='1m', trigger_tf='1m') + bias_id = bias_service.persist_bias(instrument_id=instrument_id, bias_tf='1m', setup_tf='1m', trigger_tf='1m', candidate=bias) + signal = signal_service.build_signal(instrument_id=instrument_id, setup_tf='1m', trigger_tf='1m') + written = signal_service.persist_signal(instrument_id=instrument_id, setup_tf='1m', trigger_tf='1m', candidate=signal) + except SQLAlchemyError as exc: + payload = { + "passed": False, + "instrument_id": instrument_id, + "timeframe": "1m", + "error_type": exc.__class__.__name__, + "error": str(exc), + } + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + else: + print(f"Database unavailable for signal sample: {exc.__class__.__name__}") + return 2 + payload = { + "passed": True, + "instrument_id": instrument_id, + "timeframe": "1m", + "bias_snapshot_id": bias_id, + "signal_rows_written": written, + } + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + else: + print(f'Bias snapshot id: {bias_id}; signal rows written: {written}') + return 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__': - main() + raise SystemExit(main()) diff --git a/scripts/run_sweeps.py b/scripts/run_sweeps.py index beff801..1fa37d7 100644 --- a/scripts/run_sweeps.py +++ b/scripts/run_sweeps.py @@ -1,6 +1,10 @@ from pathlib import Path +import json +import os import sys +from sqlalchemy.exc import SQLAlchemyError + ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) @@ -8,12 +12,40 @@ if str(ROOT) not in sys.path: from src.services.sweeps import SweepService -def main() -> None: +def main(env=None) -> int: + env = os.environ if env is None else env + output_json = _get_bool(env, "SWEEPS_OUTPUT_JSON", False) service = SweepService() - candidates = service.detect_sweeps(instrument_id=1, timeframe="1m") - written = service.persist_sweeps(instrument_id=1, timeframe="1m", candidates=candidates) - print(f"Detected {len(candidates)} sweeps; wrote {written} events") + try: + candidates = service.detect_sweeps(instrument_id=1, timeframe="1m") + written = service.persist_sweeps(instrument_id=1, timeframe="1m", candidates=candidates) + except SQLAlchemyError as exc: + payload = { + "passed": False, + "instrument_id": 1, + "timeframe": "1m", + "error_type": exc.__class__.__name__, + "error": str(exc), + } + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + else: + print(f"Database unavailable for sweeps sample: {exc.__class__.__name__}") + return 2 + payload = {"passed": True, "instrument_id": 1, "timeframe": "1m", "candidates": len(candidates), "events_written": written} + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + else: + print(f"Detected {len(candidates)} sweeps; wrote {written} events") + return 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__": - main() + raise SystemExit(main()) diff --git a/scripts/run_swings.py b/scripts/run_swings.py index 00d66b2..a839c7f 100644 --- a/scripts/run_swings.py +++ b/scripts/run_swings.py @@ -1,6 +1,10 @@ from pathlib import Path +import json +import os import sys +from sqlalchemy.exc import SQLAlchemyError + ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) @@ -8,12 +12,40 @@ if str(ROOT) not in sys.path: from src.services.swings import SwingService -def main() -> None: +def main(env=None) -> int: + env = os.environ if env is None else env + output_json = _get_bool(env, "SWINGS_OUTPUT_JSON", False) service = SwingService() - candidates = service.detect_swings(instrument_id=1, timeframe="1m", left_bars=2, right_bars=2, limit=500) - written = service.persist_swings(instrument_id=1, timeframe="1m", candidates=candidates) - print(f"Detected {len(candidates)} swings; wrote {written} rows") + try: + candidates = service.detect_swings(instrument_id=1, timeframe="1m", left_bars=2, right_bars=2, limit=500) + written = service.persist_swings(instrument_id=1, timeframe="1m", candidates=candidates) + except SQLAlchemyError as exc: + payload = { + "passed": False, + "instrument_id": 1, + "timeframe": "1m", + "error_type": exc.__class__.__name__, + "error": str(exc), + } + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + else: + print(f"Database unavailable for swings sample: {exc.__class__.__name__}") + return 2 + payload = {"passed": True, "instrument_id": 1, "timeframe": "1m", "candidates": len(candidates), "rows_written": written} + if output_json: + print(json.dumps(payload, default=str, ensure_ascii=False, sort_keys=True)) + else: + print(f"Detected {len(candidates)} swings; wrote {written} rows") + return 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__": - main() + raise SystemExit(main()) diff --git a/scripts/show_models.py b/scripts/show_models.py index f9e1d0d..441c0cc 100644 --- a/scripts/show_models.py +++ b/scripts/show_models.py @@ -1,4 +1,6 @@ from pathlib import Path +import json +import os import sys ROOT = Path(__file__).resolve().parents[1] @@ -8,16 +10,35 @@ if str(ROOT) not in sys.path: MODELS_DIR = ROOT / "src" / "models" -def main() -> None: +def main(env=None) -> int: + env = os.environ if env is None else env + output_json = _get_bool(env, "SHOW_MODELS_OUTPUT_JSON", False) model_files = sorted( path.stem for path in MODELS_DIR.glob("*.py") if path.is_file() and path.stem != "__init__" ) + if output_json: + print( + json.dumps( + {"passed": True, "model_count": len(model_files), "models": model_files}, + ensure_ascii=False, + sort_keys=True, + ) + ) + return 0 print(f"model exports: {len(model_files)}") for name in model_files: print(name) + return 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__": - main() + raise SystemExit(main()) diff --git a/src/api/backtest.py b/src/api/backtest.py index fed033f..4f23ec3 100644 --- a/src/api/backtest.py +++ b/src/api/backtest.py @@ -3,6 +3,7 @@ from typing import Optional from sqlalchemy import text from src.db.session import SessionLocal +from src.services.backtest import evaluate_backtest_quality def get_recent_backtest_summaries(instrument_id: Optional[int] = None, limit: int = 10) -> list[dict]: @@ -21,4 +22,11 @@ def get_recent_backtest_summaries(instrument_id: Optional[int] = None, limit: in params["instrument_id"] = instrument_id with SessionLocal() as session: rows = session.execute(sql, params).mappings().all() - return [dict(row) for row in rows] + return [normalize_backtest_summary_row(dict(row)) for row in rows] + + +def normalize_backtest_summary_row(row: dict) -> dict: + summary = dict(row.get("summary") or {}) + if "quality_gate" not in summary: + summary["quality_gate"] = evaluate_backtest_quality(summary) + return {**row, "summary": summary} diff --git a/src/api/server.py b/src/api/server.py index 8db172e..51cfc65 100644 --- a/src/api/server.py +++ b/src/api/server.py @@ -1,4 +1,6 @@ import json +import mimetypes +from pathlib import Path from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Optional from urllib.parse import parse_qs, urlparse @@ -8,18 +10,24 @@ from src.api.candles import get_recent_candles from src.api.health import get_health from src.api.sessions import get_recent_sessions from src.api.signals import get_recent_signals +from src.services.execution import ExecutionService def build_api_dependencies() -> dict: + execution_service = ExecutionService() return { "health": get_health, "candles": get_recent_candles, "sessions": get_recent_sessions, "signals": get_recent_signals, "backtests": get_recent_backtest_summaries, + "executions": execution_service.build_execution_dashboard_items, } +STATIC_DIR = Path(__file__).resolve().parents[1] / "web" + + def route_request(path: str, query: dict, dependencies: Optional[dict] = None) -> tuple[int, dict]: deps = dependencies or build_api_dependencies() @@ -42,6 +50,10 @@ def route_request(path: str, query: dict, dependencies: Optional[dict] = None) - instrument_id = _get_optional_int(query, "instrument_id") limit = _get_int(query, "limit", 10) return 200, {"items": deps["backtests"](instrument_id=instrument_id, limit=limit)} + if path == "/executions": + instrument_id = _require_int(query, "instrument_id") + limit = _get_int(query, "limit", 20) + return 200, {"items": deps["executions"](instrument_id=instrument_id, limit=limit)} return 404, {"error": "not_found"} @@ -50,10 +62,15 @@ class APIRequestHandler(BaseHTTPRequestHandler): def do_GET(self) -> None: parsed = urlparse(self.path) + if _should_serve_static(parsed.path): + self._serve_static(parsed.path) + return try: status, payload = route_request(parsed.path, parse_qs(parsed.query), dependencies=self.dependencies) except ValueError as exc: status, payload = 400, {"error": str(exc)} + except Exception as exc: + status, payload = 500, {"error": "internal_server_error", "detail": str(exc)} body = json.dumps(payload, default=str, ensure_ascii=False).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") @@ -61,6 +78,20 @@ class APIRequestHandler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(body) + def _serve_static(self, path: str) -> None: + file_path = _resolve_static_file(path) + if file_path is None or not file_path.exists() or not file_path.is_file(): + self.send_response(404) + self.end_headers() + return + body = file_path.read_bytes() + mime_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream" + self.send_response(200) + self.send_header("Content-Type", f"{mime_type}; charset=utf-8" if mime_type.startswith("text/") or mime_type == "application/javascript" else mime_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + def log_message(self, format: str, *args) -> None: return @@ -106,3 +137,19 @@ def _get_single(query: dict, key: str, default: Optional[str] = None) -> Optiona if not values: return default return values[0] + + +def _should_serve_static(path: str) -> bool: + return path == "/" or path.startswith("/static/") + + +def _resolve_static_file(path: str) -> Optional[Path]: + if path == "/": + return STATIC_DIR / "index.html" + relative = path.removeprefix("/static/") + resolved = (STATIC_DIR / relative).resolve() + try: + resolved.relative_to(STATIC_DIR.resolve()) + except ValueError: + return None + return resolved diff --git a/src/config/constants.py b/src/config/constants.py index 6f3d82a..0671eab 100644 --- a/src/config/constants.py +++ b/src/config/constants.py @@ -7,3 +7,5 @@ APP_VERSION = "0.1.0" DEFAULT_EQUAL_POOL_THRESHOLD_PCT = "0.0005" DEFAULT_MSS_ACCEPTANCE_WINDOW_CANDLES = 5 DEFAULT_FVG_MITIGATION_THRESHOLD_PCT = 1.0 +MIN_TP1_RR = 1.0 +MIN_TP2_RR = 2.0 diff --git a/src/config/settings.py b/src/config/settings.py index 3c39072..8024c8d 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -3,6 +3,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): database_url: str + database_connect_timeout: int = 5 okx_base_url: str = "https://www.okx.com" okx_timeout: int = 30 default_timezone: str = "UTC" diff --git a/src/db/engine.py b/src/db/engine.py index 573ebc2..0be174d 100644 --- a/src/db/engine.py +++ b/src/db/engine.py @@ -3,4 +3,14 @@ from sqlalchemy import create_engine from src.config.settings import settings -engine = create_engine(settings.database_url, pool_pre_ping=True) +def build_connect_args(database_url: str, connect_timeout: int) -> dict: + if database_url.startswith("postgresql"): + return {"connect_timeout": connect_timeout} + return {} + + +engine = create_engine( + settings.database_url, + pool_pre_ping=True, + connect_args=build_connect_args(settings.database_url, settings.database_connect_timeout), +) diff --git a/src/main.py b/src/main.py index 9142d5c..7e1d65f 100644 --- a/src/main.py +++ b/src/main.py @@ -3,22 +3,28 @@ from datetime import datetime from typing import Optional from src.api import get_health, get_recent_backtest_summaries, get_recent_signals -from src.services.backtest import BacktestService +from src.services.backtest import ( + BacktestCostConfig, + BacktestQualityGate, + BacktestService, + evaluate_backtest_quality, + summarize_backtest_outcomes, +) from src.services.bias import BiasService from src.services.displacement import DisplacementService -from src.services.execution import ExecutionService +from src.services.execution import AccountRiskState, ExecutionService from src.services.fvg import FVGService from src.services.liquidity import LiquidityPoolService from src.services.ranges import DealingRangeService from src.services.reports import IntradayReportService, PostTradeReviewService, PreMarketReportService from src.services.sessions import SessionGenerator -from src.services.signals import SignalService +from src.services.signals import MINIMAL_SIGNAL_EVIDENCE_KEYS, SignalService from src.services.structure import MSSService from src.services.sweeps import SweepService from src.services.swings import SwingService -def build_default_runtime_dependencies() -> dict: +def build_default_runtime_dependencies(signal_required_evidence_keys: tuple[str, ...] = MINIMAL_SIGNAL_EVIDENCE_KEYS) -> dict: return { "session_generator": SessionGenerator(), "swing_service": SwingService(), @@ -29,7 +35,7 @@ def build_default_runtime_dependencies() -> dict: "mss_service": MSSService(), "fvg_service": FVGService(), "bias_service": BiasService(), - "signal_service": SignalService(), + "signal_service": SignalService(required_evidence_keys=signal_required_evidence_keys), "execution_service": ExecutionService(), "backtest_service": BacktestService(), "pre_market_report_service": PreMarketReportService(), @@ -45,9 +51,13 @@ def run_closed_loop( timeframe: str = "1m", backtest_limit: int = 100, run_execution: bool = True, + backtest_cost_config: Optional[BacktestCostConfig] = None, + backtest_quality_gate: Optional[BacktestQualityGate] = None, + signal_required_evidence_keys: tuple[str, ...] = MINIMAL_SIGNAL_EVIDENCE_KEYS, + execution_account_state: Optional[AccountRiskState] = None, dependencies: Optional[dict] = None, ) -> dict: - deps = dependencies or build_default_runtime_dependencies() + deps = dependencies or build_default_runtime_dependencies(signal_required_evidence_keys=signal_required_evidence_keys) session_generator = deps["session_generator"] now = datetime.now(session_generator.timezone) @@ -97,14 +107,40 @@ def run_closed_loop( execution_record = None if run_execution and latest_signal is not None: execution_service = deps["execution_service"] - execution_id = execution_service.create_execution(signal_id=latest_signal["id"], execution_mode="paper") + execution_id = execution_service.create_execution( + signal_id=latest_signal["id"], + execution_mode="paper", + account_state=execution_account_state, + ) execution_record = { "status": "open" if execution_id is not None else "rejected", "meta": {"risk_checks": [] if execution_id is not None else ["execution_creation_failed"]}, } backtest_service = deps["backtest_service"] - backtest_run_id, outcomes = backtest_service.run_backtest(instrument_id=instrument_id, limit=backtest_limit) + backtest_run_id, outcomes = backtest_service.run_backtest( + instrument_id=instrument_id, + limit=backtest_limit, + cost_config=backtest_cost_config, + quality_gate=backtest_quality_gate, + ) + backtest_summary = summarize_backtest_outcomes(outcomes) + backtest_quality_gate_result = evaluate_backtest_quality(backtest_summary, gate=backtest_quality_gate) + latest_outcome = _to_payload(outcomes[-1]) if outcomes else None + if execution_record is not None and latest_outcome is not None: + if execution_id is not None and hasattr(execution_service, "settle_latest_execution_for_signal"): + execution_record = execution_service.settle_latest_execution_for_signal(signal_id=latest_signal["id"], execution_mode="paper") + else: + execution_record = execution_service.persist_execution_settlement( + trade_execution={"id": execution_id, **execution_record}, + backtest_result=latest_outcome, + ) + if execution_record is None: + execution_record = { + "id": execution_id, + "status": "open" if execution_id is not None else "rejected", + "meta": {"lifecycle_state": None, "risk_checks": [] if execution_id is not None else ["execution_creation_failed"]}, + } recent_backtests = deps["get_recent_backtest_summaries"](instrument_id=instrument_id, limit=1) latest_backtest = recent_backtests[0] if recent_backtests else None @@ -123,7 +159,7 @@ def run_closed_loop( post_trade_report = deps["post_trade_review_service"].build_report( trade_execution=execution_record, trade_signal=latest_signal, - backtest_result=_to_payload(outcomes[-1]) if outcomes else None, + backtest_result=latest_outcome, ) return { @@ -143,6 +179,8 @@ def run_closed_loop( "bias_snapshot_id": bias_id, "signals": signals_written, "execution_id": execution_id, + "execution_status": (execution_record or {}).get("status"), + "execution_lifecycle_state": ((execution_record or {}).get("meta") or {}).get("lifecycle_state"), "backtest_run_id": backtest_run_id, }, "reports": { @@ -152,6 +190,7 @@ def run_closed_loop( }, "latest_signal": latest_signal, "latest_backtest": latest_backtest, + "backtest_quality_gate": backtest_quality_gate_result, } diff --git a/src/repositories/dealing_range_repository.py b/src/repositories/dealing_range_repository.py index 0e90c36..27a5f6e 100644 --- a/src/repositories/dealing_range_repository.py +++ b/src/repositories/dealing_range_repository.py @@ -1,6 +1,7 @@ from decimal import Decimal import json from datetime import datetime +from typing import Optional from sqlalchemy import text @@ -27,6 +28,25 @@ class DealingRangeRepository: ).mappings().all() return [dict(row) for row in rows] + def fetch_latest_valid_range(self, instrument_id: int, timeframe: str) -> Optional[dict]: + sql = text( + """ + select id, high, low, equilibrium, high_swing_id, low_swing_id, ts_start, meta + from dealing_ranges + where instrument_id = :instrument_id + and timeframe = :timeframe + and state = 'valid' + order by ts_start desc, id desc + limit 1 + """ + ) + with SessionLocal() as session: + row = session.execute( + sql, + {"instrument_id": instrument_id, "timeframe": timeframe}, + ).mappings().first() + return dict(row) if row else None + def upsert_range( self, instrument_id: int, diff --git a/src/repositories/execution_repository.py b/src/repositories/execution_repository.py index bd6c2dd..914f5df 100644 --- a/src/repositories/execution_repository.py +++ b/src/repositories/execution_repository.py @@ -48,7 +48,7 @@ class ExecutionRepository: sql = text( """ select id, signal_id, execution_mode, side, entry_price, stop_loss, take_profit_1, take_profit_2, - size, risk_pct, status, meta + size, risk_pct, status, opened_ts, closed_ts, meta from trade_executions where signal_id = :signal_id and execution_mode = :execution_mode @@ -63,6 +63,104 @@ class ExecutionRepository: ).mappings().first() return dict(row) if row else None + def fetch_execution_by_id(self, execution_id: int) -> Optional[dict]: + sql = text( + """ + select id, signal_id, execution_mode, side, entry_price, stop_loss, take_profit_1, take_profit_2, + size, risk_pct, status, opened_ts, closed_ts, meta + from trade_executions + where id = :execution_id + limit 1 + """ + ) + with SessionLocal() as session: + row = session.execute(sql, {"execution_id": execution_id}).mappings().first() + return dict(row) if row else None + + def fetch_latest_backtest_result_for_signal(self, signal_id: int) -> Optional[dict]: + sql = text( + """ + select signal_id, outcome, mfe, mae, tp1_hit, tp2_hit, invalidated_before_entry, error_tags, meta + from backtest_results + where signal_id = :signal_id + order by id desc + limit 1 + """ + ) + with SessionLocal() as session: + row = session.execute(sql, {"signal_id": signal_id}).mappings().first() + return dict(row) if row else None + + def fetch_recent_execution_rows(self, instrument_id: int, limit: int = 20) -> list[dict]: + sql = text( + """ + select + te.id as execution_id, + te.signal_id, + ts.instrument_id, + te.execution_mode, + te.status as execution_status, + te.side, + te.entry_price, + te.stop_loss, + te.take_profit_1, + te.take_profit_2, + te.opened_ts, + te.closed_ts, + te.meta as execution_meta, + ts.status as signal_status, + ts.session_code, + br.outcome as backtest_outcome, + br.tp1_hit, + br.tp2_hit, + br.invalidated_before_entry, + br.error_tags as backtest_error_tags, + br.meta as backtest_meta + from trade_executions te + join trade_signals ts on ts.id = te.signal_id + left join lateral ( + select outcome, tp1_hit, tp2_hit, invalidated_before_entry, error_tags, meta + from backtest_results + where signal_id = te.signal_id + order by id desc + limit 1 + ) br on true + where ts.instrument_id = :instrument_id + order by te.opened_ts desc nulls last, te.id desc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute(sql, {"instrument_id": instrument_id, "limit": limit}).mappings().all() + return [dict(row) for row in rows] + + def update_trade_execution_lifecycle( + self, + execution_id: int, + status: str, + closed_ts, + meta: dict, + ) -> None: + sql = text( + """ + update trade_executions + set status = :status, + closed_ts = :closed_ts, + meta = cast(:meta as jsonb) + where id = :execution_id + """ + ) + with SessionLocal.begin() as session: + session.execute( + sql, + { + "execution_id": execution_id, + "status": status, + "closed_ts": closed_ts, + "meta": json.dumps(meta, default=str), + }, + ) + def insert_trade_execution( self, signal_id: int, diff --git a/src/repositories/fvg_repository.py b/src/repositories/fvg_repository.py index a6a5150..d048975 100644 --- a/src/repositories/fvg_repository.py +++ b/src/repositories/fvg_repository.py @@ -62,6 +62,46 @@ class FVGRepository: ).mappings().all() return [dict(row) for row in rows] + def fetch_latest_fvg( + self, + instrument_id: int, + timeframe: str, + direction: str, + upper: float, + lower: float, + start_ts, + end_ts, + ) -> Optional[dict]: + sql = text( + """ + select id, direction, upper, lower, start_ts, end_ts, fill_pct, status, related_displacement_id, meta + from fvg_zones + where instrument_id = :instrument_id + and timeframe = :timeframe + and direction = :direction + and upper = :upper + and lower = :lower + and start_ts = :start_ts + and end_ts = :end_ts + order by id desc + limit 1 + """ + ) + with SessionLocal() as session: + row = session.execute( + sql, + { + "instrument_id": instrument_id, + "timeframe": timeframe, + "direction": direction, + "upper": upper, + "lower": lower, + "start_ts": start_ts, + "end_ts": end_ts, + }, + ).mappings().first() + return dict(row) if row else None + def fetch_candles_after_ts(self, instrument_id: int, timeframe: str, start_ts, limit: int = 500) -> list[dict]: sql = text( """ diff --git a/src/repositories/liquidity_pool_repository.py b/src/repositories/liquidity_pool_repository.py index cf03826..a7dab1a 100644 --- a/src/repositories/liquidity_pool_repository.py +++ b/src/repositories/liquidity_pool_repository.py @@ -1,6 +1,7 @@ from datetime import datetime from decimal import Decimal import json +from typing import Optional from sqlalchemy import text @@ -41,6 +42,44 @@ class LiquidityPoolRepository: rows = session.execute(sql, {"instrument_id": instrument_id, "limit": limit}).mappings().all() return [dict(row) for row in rows] + def fetch_latest_pool( + self, + instrument_id: int, + timeframe: str, + side: str, + pool_type: str, + price_low: Decimal, + price_high: Decimal, + ) -> Optional[dict]: + sql = text( + """ + select id, side, pool_type, price_low, price_high, reference_ids, created_ts, meta + from liquidity_pools + where instrument_id = :instrument_id + and timeframe = :timeframe + and status = 'untouched' + and side = :side + and pool_type = :pool_type + and price_low = :price_low + and price_high = :price_high + order by created_ts desc, id desc + limit 1 + """ + ) + with SessionLocal() as session: + row = session.execute( + sql, + { + "instrument_id": instrument_id, + "timeframe": timeframe, + "side": side, + "pool_type": pool_type, + "price_low": price_low, + "price_high": price_high, + }, + ).mappings().first() + return dict(row) if row else None + def insert_pool( self, instrument_id: int, diff --git a/src/repositories/structure_repository.py b/src/repositories/structure_repository.py index 2961001..78b6ea2 100644 --- a/src/repositories/structure_repository.py +++ b/src/repositories/structure_repository.py @@ -1,5 +1,6 @@ from sqlalchemy import text import json +from typing import Optional from src.db.session import SessionLocal @@ -61,6 +62,26 @@ class StructureRepository: ).mappings().all() return [dict(row) for row in rows] + def fetch_latest_mss_event(self, instrument_id: int, timeframe: str) -> Optional[dict]: + sql = text( + """ + select id, direction, broken_level, reference_swing_id, displacement_event_id, accepted, ts, meta + from structure_events + where instrument_id = :instrument_id + and timeframe = :timeframe + and event_type = 'MSS' + and accepted = true + order by ts desc, id desc + limit 1 + """ + ) + with SessionLocal() as session: + row = session.execute( + sql, + {"instrument_id": instrument_id, "timeframe": timeframe}, + ).mappings().first() + return dict(row) if row else None + def insert_mss_event( self, instrument_id: int, diff --git a/src/repositories/sweep_repository.py b/src/repositories/sweep_repository.py index 42e5aeb..9288857 100644 --- a/src/repositories/sweep_repository.py +++ b/src/repositories/sweep_repository.py @@ -1,5 +1,6 @@ from datetime import datetime import json +from typing import Optional from sqlalchemy import text @@ -56,6 +57,25 @@ class SweepRepository: ).mappings().all() return [dict(row) for row in rows] + def fetch_latest_sweep_event(self, instrument_id: int, timeframe: str, pool_id: int) -> Optional[dict]: + sql = text( + """ + select id, side_swept, pool_id, sweep_high, sweep_low, sweep_ts, close_back_inside, validation_level, status, meta + from sweep_events + where instrument_id = :instrument_id + and timeframe = :timeframe + and pool_id = :pool_id + order by sweep_ts desc, id desc + limit 1 + """ + ) + with SessionLocal() as session: + row = session.execute( + sql, + {"instrument_id": instrument_id, "timeframe": timeframe, "pool_id": pool_id}, + ).mappings().first() + return dict(row) if row else None + def upsert_sweep( self, instrument_id: int, diff --git a/src/services/backtest/__init__.py b/src/services/backtest/__init__.py index c8e13f6..1eb979b 100644 --- a/src/services/backtest/__init__.py +++ b/src/services/backtest/__init__.py @@ -2,10 +2,50 @@ from src.services.backtest.backtest_service import ( BacktestOutcome, + BacktestCostConfig, BacktestService, + BacktestQualityGate, BacktestSignal, + build_cumulative_r_curve, + compute_max_consecutive_losses, + compute_max_drawdown_r, + compute_outcome_r_multiple, + estimate_trade_cost_r, + evaluate_backtest_quality, evaluate_signal_outcome, summarize_backtest_outcomes, ) +from src.services.backtest.replay import ( + REPLAY_REQUIRED_CANDLE_FIELDS, + ReplayFrame, + ReplaySwing, + ReplayValidationResult, + detect_confirmed_swings_for_replay, + iter_historical_replay, + load_replay_candles, + validate_replay_candles, +) -__all__ = ["BacktestOutcome", "BacktestService", "BacktestSignal", "evaluate_signal_outcome", "summarize_backtest_outcomes"] +__all__ = [ + "BacktestOutcome", + "BacktestCostConfig", + "BacktestService", + "BacktestQualityGate", + "BacktestSignal", + "build_cumulative_r_curve", + "compute_max_consecutive_losses", + "compute_max_drawdown_r", + "compute_outcome_r_multiple", + "estimate_trade_cost_r", + "evaluate_backtest_quality", + "evaluate_signal_outcome", + "REPLAY_REQUIRED_CANDLE_FIELDS", + "ReplayFrame", + "ReplaySwing", + "ReplayValidationResult", + "detect_confirmed_swings_for_replay", + "iter_historical_replay", + "load_replay_candles", + "validate_replay_candles", + "summarize_backtest_outcomes", +] diff --git a/src/services/backtest/backtest_service.py b/src/services/backtest/backtest_service.py index 65be61c..875ff96 100644 --- a/src/services/backtest/backtest_service.py +++ b/src/services/backtest/backtest_service.py @@ -30,19 +30,53 @@ class BacktestSignal: stop_loss: float tp1: float tp2: float + rr_tp1: float = 0.0 + rr_tp2: float = 0.0 + + +@dataclass(frozen=True) +class BacktestCostConfig: + fee_rate: float = 0.0 + slippage_rate: float = 0.0 + fixed_cost_r: float = 0.0 + + +@dataclass(frozen=True) +class BacktestQualityGate: + min_total_signals: int = 100 + min_executed_signals: int = 30 + min_expectancy_r: float = 0.0 + min_profit_factor_r: float = 1.2 + max_drawdown_r: float = 10.0 + max_consecutive_losses: int = 5 class BacktestService: def __init__(self, repository: Optional[BacktestRepository] = None) -> None: self.repository = repository or BacktestRepository() - def run_backtest(self, instrument_id: int, limit: int = 100) -> tuple[int, list[BacktestOutcome]]: + def run_backtest( + self, + instrument_id: int, + limit: int = 100, + cost_config: Optional[BacktestCostConfig] = None, + quality_gate: Optional[BacktestQualityGate] = None, + ) -> tuple[int, list[BacktestOutcome]]: + cost_config = cost_config or BacktestCostConfig() + quality_gate = quality_gate or BacktestQualityGate() signals = self.repository.fetch_candidate_signals(instrument_id=instrument_id, limit=limit) run_id = self.repository.insert_backtest_run( model_code='SWEEP_MSS_FVG', instrument_id=instrument_id, timeframe_set={'bias_tf': '1m', 'setup_tf': '1m', 'trigger_tf': '1m'}, - config={'limit': limit, 'mode': 'entry_then_outcome', 'same_candle_priority': 'stop_first', 'session_filter': 'mark_outside'}, + config={ + 'limit': limit, + 'mode': 'entry_then_outcome', + 'same_candle_priority': 'stop_first', + 'session_filter': 'mark_outside', + 'cost_model': cost_config.__dict__, + 'quality_gate': quality_gate.__dict__, + }, ) outcomes: list[BacktestOutcome] = [] for signal in signals: @@ -76,6 +110,11 @@ class BacktestService: 'session_codes': [row['session_code'] for row in sessions], 'signal_side': signal['side'], '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), + 'cost_r': 0.0, + 'cost_model': cost_config.__dict__, + 'r_multiple': 0.0, 'weekday': _extract_weekday(signal['created_ts']), }, ) @@ -104,8 +143,11 @@ class BacktestService: stop_loss=float(signal['stop_loss'] or 0), tp1=float(signal['tp1'] or 0), tp2=float(signal['tp2'] or 0), + rr_tp1=float(signal.get('rr_tp1') or 0), + rr_tp2=float(signal.get('rr_tp2') or 0), ), future, + cost_config=cost_config, ) if session_filtered: record.error_tags.append('outside_session_window') @@ -116,6 +158,8 @@ class BacktestService: 'session_filter': 'outside_window' if session_filtered else 'covered', 'signal_side': signal['side'], '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), 'weekday': _extract_weekday(signal['created_ts']), } ) @@ -133,11 +177,17 @@ class BacktestService: meta=record.meta, ) summary = summarize_backtest_outcomes(outcomes) + summary['quality_gate'] = evaluate_backtest_quality(summary, gate=quality_gate) self.repository.update_backtest_run_summary(run_id, summary) return run_id, outcomes -def evaluate_signal_outcome(signal: BacktestSignal, future_candles: list[dict]) -> BacktestOutcome: +def evaluate_signal_outcome( + signal: BacktestSignal, + future_candles: list[dict], + cost_config: Optional[BacktestCostConfig] = None, +) -> BacktestOutcome: + cost_config = cost_config or BacktestCostConfig() entry_mid = (signal.entry_low + signal.entry_high) / 2 entry_triggered = False tp1_hit = False @@ -204,6 +254,22 @@ def evaluate_signal_outcome(signal: BacktestSignal, future_candles: list[dict]) else: outcome = 'missed' error_tags.append('no_target_hit') + risk_distance = abs(entry_mid - signal.stop_loss) + mfe_r = mfe / risk_distance if risk_distance else 0.0 + mae_r = mae / risk_distance if risk_distance else 0.0 + cost_r = estimate_trade_cost_r( + signal=signal, + outcome=outcome, + entry_price=entry_mid, + entry_triggered=entry_triggered, + cost_config=cost_config, + ) + r_multiple = compute_outcome_r_multiple( + outcome=outcome, + rr_tp1=signal.rr_tp1, + rr_tp2=signal.rr_tp2, + cost_r=cost_r, + ) return BacktestOutcome( signal_id=signal.signal_id, @@ -219,23 +285,86 @@ def evaluate_signal_outcome(signal: BacktestSignal, future_candles: list[dict]) 'same_candle_priority': SAME_CANDLE_PRIORITY, 'same_candle_ambiguous': same_candle_ambiguous, 'signal_side': signal.side, + 'rr_tp1': signal.rr_tp1, + 'rr_tp2': signal.rr_tp2, + 'mfe_r': mfe_r, + 'mae_r': mae_r, + 'cost_r': cost_r, + 'cost_model': cost_config.__dict__, + 'r_multiple': r_multiple, }, ) +def estimate_trade_cost_r( + signal: BacktestSignal, + outcome: str, + entry_price: float, + entry_triggered: bool, + cost_config: BacktestCostConfig, +) -> float: + if not entry_triggered: + return 0.0 + if outcome not in {'win', 'loss', 'breakeven'}: + return 0.0 + + risk_distance = abs(entry_price - signal.stop_loss) + if risk_distance <= 0: + return 0.0 + + exit_price = estimate_exit_price(signal=signal, outcome=outcome, entry_price=entry_price) + rate_cost = (abs(entry_price) + abs(exit_price)) * (cost_config.fee_rate + cost_config.slippage_rate) + return cost_config.fixed_cost_r + (rate_cost / risk_distance) + + +def estimate_exit_price(signal: BacktestSignal, outcome: str, entry_price: float) -> float: + if outcome == 'win' and signal.tp2: + return signal.tp2 + if outcome == 'loss' and signal.stop_loss: + return signal.stop_loss + return entry_price + + +def compute_outcome_r_multiple(outcome: str, rr_tp1: float = 0.0, rr_tp2: float = 0.0, cost_r: float = 0.0) -> float: + """Convert the current simplified outcome labels into conservative R multiples.""" + if outcome == 'win': + gross_r = rr_tp2 + elif outcome == 'loss': + gross_r = -1.0 + else: + gross_r = 0.0 + + if outcome in {'win', 'loss', 'breakeven'}: + return gross_r - cost_r + return gross_r + + def summarize_backtest_outcomes(outcomes: list[BacktestOutcome]) -> dict: total = len(outcomes) grouped_by_outcome: dict[str, int] = {} grouped_by_side: dict[str, dict] = {} grouped_by_session: dict[str, dict] = {} grouped_by_weekday: dict[str, dict] = {} + r_multiples: list[float] = [] + cost_values: list[float] = [] + mfe_r_values: list[float] = [] + mae_r_values: list[float] = [] + executed_r_multiples: list[float] = [] + executed_signals = 0 for outcome in outcomes: grouped_by_outcome[outcome.outcome] = grouped_by_outcome.get(outcome.outcome, 0) + 1 + if is_executed_outcome(outcome): + executed_signals += 1 + executed_r_multiples.append(float(outcome.meta.get('r_multiple') or 0.0)) 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') + 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)) + mae_r_values.append(float(outcome.meta.get('mae_r') or 0.0)) _accumulate_bucket(grouped_by_side, side, outcome) _accumulate_bucket(grouped_by_weekday, weekday, outcome) @@ -249,14 +378,67 @@ def summarize_backtest_outcomes(outcomes: list[BacktestOutcome]) -> dict: 'loss_rate': _ratio(grouped_by_outcome.get('loss', 0), total), 'breakeven_rate': _ratio(grouped_by_outcome.get('breakeven', 0), total), 'miss_rate': _ratio(grouped_by_outcome.get('missed', 0), total), + 'executed_signals': executed_signals, + 'executed_rate': _ratio(executed_signals, total), + 'executed_expectancy_r': _average(executed_r_multiples), + 'executed_profit_factor_r': _profit_factor(executed_r_multiples), 'avg_mfe': _average([outcome.mfe for outcome in outcomes]), 'avg_mae': _average([outcome.mae for outcome in outcomes]), + 'avg_mfe_r': _average(mfe_r_values), + 'avg_mae_r': _average(mae_r_values), + 'total_r': sum(r_multiples), + 'expectancy_r': _average(r_multiples), + 'profit_factor_r': _profit_factor(r_multiples), + 'max_drawdown_r': compute_max_drawdown_r(r_multiples), + 'max_consecutive_losses': compute_max_consecutive_losses(outcomes), + 'cumulative_r_curve': build_cumulative_r_curve(r_multiples), + 'total_cost_r': sum(cost_values), + 'avg_cost_r': _average(cost_values), 'by_side': grouped_by_side, 'by_session': grouped_by_session, 'by_weekday': grouped_by_weekday, } +def evaluate_backtest_quality(summary: dict, gate: Optional[BacktestQualityGate] = None) -> dict[str, object]: + gate = gate or BacktestQualityGate() + reasons: list[str] = [] + + total_signals = int(summary.get('total_signals') or 0) + expectancy_r = float(summary.get('expectancy_r') or 0.0) + profit_factor_r = summary.get('profit_factor_r') + max_drawdown_r = float(summary.get('max_drawdown_r') or 0.0) + max_consecutive_losses = int(summary.get('max_consecutive_losses') or 0) + executed_signals = int(summary.get('executed_signals') or 0) + + if total_signals < gate.min_total_signals: + reasons.append('sample_size_too_small') + if executed_signals < gate.min_executed_signals: + reasons.append('executed_sample_too_small') + if expectancy_r <= gate.min_expectancy_r: + reasons.append('expectancy_below_threshold') + if profit_factor_r is None or float(profit_factor_r) < gate.min_profit_factor_r: + reasons.append('profit_factor_below_threshold') + if max_drawdown_r > gate.max_drawdown_r: + reasons.append('drawdown_above_threshold') + if max_consecutive_losses > gate.max_consecutive_losses: + reasons.append('consecutive_losses_above_threshold') + + return { + 'passed': len(reasons) == 0, + 'reasons': reasons, + 'gate': gate.__dict__, + 'metrics': { + 'total_signals': total_signals, + 'executed_signals': executed_signals, + 'expectancy_r': expectancy_r, + 'profit_factor_r': profit_factor_r, + 'max_drawdown_r': max_drawdown_r, + 'max_consecutive_losses': max_consecutive_losses, + }, + } + + def _accumulate_bucket(container: dict, key: str, outcome: BacktestOutcome) -> None: bucket = container.setdefault( key, @@ -266,24 +448,58 @@ def _accumulate_bucket(container: dict, key: str, outcome: BacktestOutcome) -> N 'losses': 0, 'breakevens': 0, 'misses': 0, + 'executed': 0, + 'executed_total_r': 0.0, 'avg_mfe_sum': 0.0, 'avg_mae_sum': 0.0, + 'avg_mfe_r_sum': 0.0, + 'avg_mae_r_sum': 0.0, + 'current_loss_streak': 0, + 'max_consecutive_losses': 0, }, ) bucket['count'] += 1 + if is_executed_outcome(outcome): + bucket['executed'] += 1 if outcome.outcome == 'win': bucket['wins'] += 1 + bucket['current_loss_streak'] = 0 elif outcome.outcome == 'loss': bucket['losses'] += 1 + bucket['current_loss_streak'] += 1 elif outcome.outcome == 'breakeven': bucket['breakevens'] += 1 + bucket['current_loss_streak'] = 0 else: bucket['misses'] += 1 + bucket['current_loss_streak'] = 0 + bucket['max_consecutive_losses'] = max(bucket['max_consecutive_losses'], bucket['current_loss_streak']) bucket['avg_mfe_sum'] += outcome.mfe bucket['avg_mae_sum'] += outcome.mae + bucket['avg_mfe_r_sum'] += float(outcome.meta.get('mfe_r') or 0.0) + bucket['avg_mae_r_sum'] += float(outcome.meta.get('mae_r') or 0.0) + r_multiple = float(outcome.meta.get('r_multiple') or 0.0) + bucket['total_r'] = bucket.get('total_r', 0.0) + r_multiple + if is_executed_outcome(outcome): + bucket['executed_total_r'] += r_multiple + cost_r = float(outcome.meta.get('cost_r') or 0.0) + bucket['total_cost_r'] = bucket.get('total_cost_r', 0.0) + cost_r bucket['win_rate'] = _ratio(bucket['wins'], bucket['count']) + bucket['executed_rate'] = _ratio(bucket['executed'], bucket['count']) + bucket['executed_expectancy_r'] = bucket['executed_total_r'] / bucket['executed'] if bucket['executed'] else 0.0 bucket['avg_mfe'] = bucket['avg_mfe_sum'] / bucket['count'] if bucket['count'] else 0.0 bucket['avg_mae'] = bucket['avg_mae_sum'] / bucket['count'] if bucket['count'] else 0.0 + bucket['avg_mfe_r'] = bucket['avg_mfe_r_sum'] / bucket['count'] if bucket['count'] else 0.0 + bucket['avg_mae_r'] = bucket['avg_mae_r_sum'] / bucket['count'] if bucket['count'] else 0.0 + bucket['expectancy_r'] = bucket['total_r'] / bucket['count'] if bucket['count'] else 0.0 + bucket['avg_cost_r'] = bucket['total_cost_r'] / bucket['count'] if bucket['count'] else 0.0 + curve = bucket.setdefault('cumulative_r_curve', []) + curve.append(bucket['total_r']) + bucket['max_drawdown_r'] = compute_drawdown_from_curve(curve) + + +def is_executed_outcome(outcome: BacktestOutcome) -> bool: + return outcome.outcome in {'win', 'loss', 'breakeven'} or bool(outcome.meta.get('entry_triggered')) def _ratio(numerator: int, denominator: int) -> float: @@ -294,6 +510,48 @@ def _average(values: list[float]) -> float: return sum(values) / len(values) if values else 0.0 +def build_cumulative_r_curve(values: list[float]) -> list[float]: + curve: list[float] = [] + running = 0.0 + for value in values: + running += value + curve.append(running) + return curve + + +def compute_max_drawdown_r(values: list[float]) -> float: + return compute_drawdown_from_curve(build_cumulative_r_curve(values)) + + +def compute_max_consecutive_losses(outcomes: list[BacktestOutcome]) -> int: + current = 0 + longest = 0 + for outcome in outcomes: + if outcome.outcome == 'loss': + current += 1 + longest = max(longest, current) + else: + current = 0 + return longest + + +def compute_drawdown_from_curve(curve: list[float]) -> float: + peak = 0.0 + max_drawdown = 0.0 + for value in curve: + peak = max(peak, value) + max_drawdown = max(max_drawdown, peak - value) + return max_drawdown + + +def _profit_factor(values: list[float]) -> Optional[float]: + gross_profit = sum(value for value in values if value > 0) + gross_loss = abs(sum(value for value in values if value < 0)) + if gross_loss == 0: + return None if gross_profit == 0 else gross_profit + return gross_profit / gross_loss + + def _extract_weekday(ts) -> str: if isinstance(ts, datetime): return ts.strftime('%A') diff --git a/src/services/backtest/replay.py b/src/services/backtest/replay.py new file mode 100644 index 0000000..95663e4 --- /dev/null +++ b/src/services/backtest/replay.py @@ -0,0 +1,229 @@ +import csv +import json +import math +from dataclasses import dataclass +from pathlib import Path +from typing import Optional, Union + + +REPLAY_REQUIRED_CANDLE_FIELDS = ("ts_open", "open", "high", "low", "close") + + +@dataclass(frozen=True) +class ReplayValidationResult: + is_valid: bool + errors: list[str] + + +@dataclass(frozen=True) +class ReplaySwing: + kind: str + price: float + ts: object + source_index: int + confirmed_at_index: int + left_bars: int + right_bars: int + confirmed: bool = True + + +@dataclass(frozen=True) +class ReplayFrame: + index: int + candle: dict + known_candles: tuple[dict, ...] + confirmable_index: Optional[int] + new_swings: tuple[ReplaySwing, ...] + + +def iter_historical_replay(candles: list[dict], left_bars: int = 2, right_bars: int = 2): + validation = validate_replay_candles(candles) + if not validation.is_valid: + raise ValueError(f"invalid_replay_candles:{','.join(validation.errors)}") + for index, candle in enumerate(candles): + known_candles = tuple(candles[: index + 1]) + confirmable_index = index - right_bars + new_swings = () + if confirmable_index >= left_bars: + new_swings = tuple( + detect_confirmed_swings_at_index( + candles=known_candles, + source_index=confirmable_index, + confirmed_at_index=index, + left_bars=left_bars, + right_bars=right_bars, + ) + ) + yield ReplayFrame( + index=index, + candle=candle, + known_candles=known_candles, + confirmable_index=confirmable_index if confirmable_index >= 0 else None, + new_swings=new_swings, + ) + + +def detect_confirmed_swings_for_replay(candles: list[dict], left_bars: int = 2, right_bars: int = 2) -> list[ReplaySwing]: + swings: list[ReplaySwing] = [] + for frame in iter_historical_replay(candles=candles, left_bars=left_bars, right_bars=right_bars): + swings.extend(frame.new_swings) + return swings + + +def load_replay_candles(path: Union[str, Path]) -> list[dict]: + file_path = Path(path) + suffix = file_path.suffix.lower() + if suffix == ".json": + payload = json.loads(file_path.read_text(encoding="utf-8")) + rows = _extract_candle_rows_from_json(payload) + elif suffix in {".jsonl", ".ndjson"}: + rows = [json.loads(line) for line in file_path.read_text(encoding="utf-8").splitlines() if line.strip()] + elif suffix == ".csv": + with file_path.open("r", encoding="utf-8", newline="") as handle: + rows = list(csv.DictReader(handle)) + else: + raise ValueError(f"unsupported_replay_candle_file:{suffix or file_path.name}") + + candles = [_coerce_replay_candle(row) for row in rows] + validation = validate_replay_candles(candles) + if not validation.is_valid: + raise ValueError(f"invalid_replay_candles:{','.join(validation.errors)}") + return candles + + +def validate_replay_candles(candles: list[dict]) -> ReplayValidationResult: + errors: list[str] = [] + seen_ts: set[object] = set() + previous_ts = None + + for index, candle in enumerate(candles): + for field in REPLAY_REQUIRED_CANDLE_FIELDS: + if field not in candle or candle.get(field) is None: + errors.append(f"missing_{field}:{index}") + + if any(field not in candle or candle.get(field) is None for field in REPLAY_REQUIRED_CANDLE_FIELDS): + continue + + ts_open = candle["ts_open"] + if ts_open in seen_ts: + errors.append(f"duplicate_ts_open:{index}") + seen_ts.add(ts_open) + if previous_ts is not None and ts_open <= previous_ts: + errors.append(f"non_increasing_ts_open:{index}") + previous_ts = ts_open + + prices = {} + has_invalid_price = False + for field in ("open", "high", "low", "close"): + try: + price = float(candle[field]) + except (TypeError, ValueError): + errors.append(f"non_numeric_{field}:{index}") + has_invalid_price = True + continue + if not math.isfinite(price): + errors.append(f"non_finite_{field}:{index}") + has_invalid_price = True + if price <= 0: + errors.append(f"non_positive_{field}:{index}") + has_invalid_price = True + prices[field] = price + if has_invalid_price: + continue + + open_price = prices["open"] + high_price = prices["high"] + low_price = prices["low"] + close_price = prices["close"] + if high_price < low_price: + errors.append(f"high_below_low:{index}") + if not (low_price <= open_price <= high_price): + errors.append(f"open_outside_range:{index}") + if not (low_price <= close_price <= high_price): + errors.append(f"close_outside_range:{index}") + + return ReplayValidationResult(is_valid=len(errors) == 0, errors=errors) + + +def _extract_candle_rows_from_json(payload) -> list[dict]: + if isinstance(payload, list): + return payload + if isinstance(payload, dict): + for key in ("candles", "items", "data"): + rows = payload.get(key) + if isinstance(rows, list): + return rows + raise ValueError("invalid_replay_candle_json") + + +def _coerce_replay_candle(row: dict) -> dict: + candle = dict(row) + for field in ("open", "high", "low", "close", "volume"): + if field in candle and candle[field] not in (None, ""): + try: + candle[field] = float(candle[field]) + except (TypeError, ValueError): + pass + for field in ("ts_open", "ts_close"): + if field in candle and candle[field] not in (None, ""): + candle[field] = _coerce_timestamp(candle[field]) + return candle + + +def _coerce_timestamp(value): + if isinstance(value, str): + stripped = value.strip() + if stripped.isdigit(): + return int(stripped) + return stripped + return value + + +def detect_confirmed_swings_at_index( + candles: tuple[dict, ...], + source_index: int, + confirmed_at_index: int, + left_bars: int, + right_bars: int, +) -> list[ReplaySwing]: + if source_index < left_bars: + return [] + if confirmed_at_index < source_index + right_bars: + return [] + if len(candles) <= confirmed_at_index: + return [] + + current = candles[source_index] + left = candles[source_index - left_bars : source_index] + right = candles[source_index + 1 : source_index + 1 + right_bars] + if len(left) < left_bars or len(right) < right_bars: + return [] + + swings: list[ReplaySwing] = [] + current_high = float(current["high"]) + current_low = float(current["low"]) + if all(current_high > float(item["high"]) for item in left) and all(current_high > float(item["high"]) for item in right): + swings.append( + ReplaySwing( + kind="swing_high", + price=current_high, + ts=current.get("ts_open") or current.get("ts"), + source_index=source_index, + confirmed_at_index=confirmed_at_index, + left_bars=left_bars, + right_bars=right_bars, + ) + ) + if all(current_low < float(item["low"]) for item in left) and all(current_low < float(item["low"]) for item in right): + swings.append( + ReplaySwing( + kind="swing_low", + price=current_low, + ts=current.get("ts_open") or current.get("ts"), + source_index=source_index, + confirmed_at_index=confirmed_at_index, + left_bars=left_bars, + right_bars=right_bars, + ) + ) + return swings diff --git a/src/services/execution/__init__.py b/src/services/execution/__init__.py index 4cf4090..bbcc650 100644 --- a/src/services/execution/__init__.py +++ b/src/services/execution/__init__.py @@ -1,17 +1,23 @@ """Execution service package.""" from src.services.execution.execution_service import ( + AccountRiskState, ExecutionDecision, ExecutionService, + classify_execution_lifecycle, compute_entry_price, + evaluate_account_risk_limits, evaluate_execution_decision, trade_execution_matches_decision, ) __all__ = [ + "AccountRiskState", "ExecutionDecision", "ExecutionService", + "classify_execution_lifecycle", "compute_entry_price", + "evaluate_account_risk_limits", "evaluate_execution_decision", "trade_execution_matches_decision", ] diff --git a/src/services/execution/execution_service.py b/src/services/execution/execution_service.py index be2d652..5b57a63 100644 --- a/src/services/execution/execution_service.py +++ b/src/services/execution/execution_service.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from datetime import datetime, timezone +import math from typing import Optional from src.repositories.execution_repository import ExecutionRepository @@ -15,22 +16,188 @@ class ExecutionDecision: status: str reasons: list[str] execution_mode: str + requires_confirmation: bool risk_pct: float size: float entry_price: float meta: dict +@dataclass(frozen=True) +class AccountRiskState: + kill_switch: bool = False + daily_pnl_pct: float = 0.0 + consecutive_losses: int = 0 + open_positions: int = 0 + same_side_open_positions: int = 0 + max_daily_loss_pct: float = 0.02 + max_consecutive_losses: int = 3 + max_open_positions: int = 1 + max_same_side_positions: int = 1 + + class ExecutionService: def __init__(self, repository: Optional[ExecutionRepository] = None) -> None: self.repository = repository or ExecutionRepository() + def audit_execution(self, trade_execution: Optional[dict], backtest_result: Optional[dict] = None) -> dict: + if not trade_execution: + return { + "audit_status": "missing", + "is_actionable": False, + "error_tags": ["missing_execution_record"], + "lifecycle_state": None, + "risk_checks": [], + "tp_hits": {"tp1": False, "tp2": False}, + "stop_loss_hit": False, + } + + meta = dict(trade_execution.get("meta") or {}) + risk_checks = list(meta.get("risk_checks") or []) + 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") + tp1_hit = bool(backtest_result and backtest_result.get("tp1_hit")) + tp2_hit = bool(backtest_result and backtest_result.get("tp2_hit")) + + if status == "rejected": + error_tags.append("execution_rejected") + if backtest_result and backtest_result.get("invalidated_before_entry"): + error_tags.append("signal_invalidated_before_entry") + + return { + "audit_status": "ok" if not error_tags else "review", + "is_actionable": len(error_tags) == 0, + "error_tags": sorted(set(error_tags)), + "lifecycle_state": lifecycle_state, + "risk_checks": risk_checks, + "tp_hits": {"tp1": tp1_hit, "tp2": tp2_hit}, + "stop_loss_hit": stop_loss_hit, + } + + def settle_execution_from_backtest(self, trade_execution: Optional[dict], backtest_result: Optional[dict]) -> Optional[dict]: + if not trade_execution: + return None + meta = dict(trade_execution.get("meta") or {}) + lifecycle_state = classify_execution_lifecycle( + status=trade_execution.get("status"), + backtest_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") + invalidated_before_entry = bool(backtest_result and backtest_result.get("invalidated_before_entry")) + if lifecycle_state == "tp2_closed": + status = "closed" + elif lifecycle_state in {"stopped_out", "expired"}: + status = "closed" + else: + status = trade_execution.get("status") + meta["lifecycle_state"] = lifecycle_state + meta["tp1_hit"] = tp1_hit + meta["tp2_hit"] = tp2_hit + meta["stop_loss_hit"] = stop_loss_hit + meta["invalidated_before_entry"] = invalidated_before_entry + return { + **trade_execution, + "status": status, + "meta": meta, + "closed_ts": datetime.now(timezone.utc) if status == "closed" else trade_execution.get("closed_ts"), + } + + def persist_execution_settlement(self, trade_execution: Optional[dict], backtest_result: Optional[dict]) -> Optional[dict]: + settled = self.settle_execution_from_backtest(trade_execution=trade_execution, backtest_result=backtest_result) + if not settled or not settled.get("id"): + return settled + self.repository.update_trade_execution_lifecycle( + execution_id=int(settled["id"]), + status=str(settled["status"]), + closed_ts=settled.get("closed_ts"), + meta=dict(settled.get("meta") or {}), + ) + return settled + + def settle_execution_by_id(self, execution_id: int, backtest_result: Optional[dict]) -> Optional[dict]: + trade_execution = self.repository.fetch_execution_by_id(execution_id) + if not trade_execution: + return None + return self.persist_execution_settlement(trade_execution=trade_execution, backtest_result=backtest_result) + + def settle_execution_by_id_from_latest_backtest(self, execution_id: int) -> Optional[dict]: + trade_execution = self.repository.fetch_execution_by_id(execution_id) + if not trade_execution: + return None + signal_id = trade_execution.get("signal_id") + if signal_id is None: + return None + backtest_result = self.repository.fetch_latest_backtest_result_for_signal(int(signal_id)) + if not backtest_result: + return None + return self.persist_execution_settlement(trade_execution=trade_execution, backtest_result=backtest_result) + + def settle_latest_execution_for_signal(self, signal_id: int, execution_mode: str = "paper") -> Optional[dict]: + trade_execution = self.repository.fetch_latest_execution(signal_id=signal_id, execution_mode=execution_mode) + if not trade_execution: + return None + backtest_result = self.repository.fetch_latest_backtest_result_for_signal(signal_id) + if not backtest_result: + return None + return self.persist_execution_settlement(trade_execution=trade_execution, backtest_result=backtest_result) + + def build_execution_dashboard_items(self, instrument_id: int, limit: int = 20) -> list[dict]: + rows = self.repository.fetch_recent_execution_rows(instrument_id=instrument_id, limit=limit) + items: list[dict] = [] + for row in rows: + execution_meta = dict(row.get("execution_meta") or {}) + backtest_result = { + "outcome": row.get("backtest_outcome"), + "tp1_hit": row.get("tp1_hit"), + "tp2_hit": row.get("tp2_hit"), + "invalidated_before_entry": row.get("invalidated_before_entry"), + "error_tags": list(row.get("backtest_error_tags") or []), + "meta": dict(row.get("backtest_meta") or {}), + } if row.get("backtest_outcome") is not None else None + trade_execution = { + "status": row.get("execution_status"), + "meta": execution_meta, + } + audit = self.audit_execution(trade_execution=trade_execution, backtest_result=backtest_result) + items.append( + { + "execution_id": row.get("execution_id"), + "signal_id": row.get("signal_id"), + "instrument_id": row.get("instrument_id"), + "execution_mode": row.get("execution_mode"), + "execution_status": row.get("execution_status"), + "lifecycle_state": audit["lifecycle_state"], + "side": row.get("side"), + "session_code": row.get("session_code"), + "opened_ts": row.get("opened_ts"), + "closed_ts": row.get("closed_ts"), + "entry_price": row.get("entry_price"), + "stop_loss": row.get("stop_loss"), + "tp1": row.get("take_profit_1"), + "tp2": row.get("take_profit_2"), + "risk_checks": audit["risk_checks"], + "audit_status": audit["audit_status"], + "error_tags": audit["error_tags"], + "backtest_outcome": row.get("backtest_outcome"), + "tp1_hit": row.get("tp1_hit"), + "tp2_hit": row.get("tp2_hit"), + "signal_status": row.get("signal_status"), + "meta": execution_meta, + } + ) + return items + def prepare_execution( self, signal_id: int, execution_mode: str = "paper", account_equity: float = 10000.0, risk_pct: float = 0.01, + account_state: Optional[AccountRiskState] = None, ) -> Optional[ExecutionDecision]: signal = self.repository.fetch_signal_for_execution(signal_id) if not signal: @@ -40,6 +207,7 @@ class ExecutionService: execution_mode=execution_mode, account_equity=account_equity, risk_pct=risk_pct, + account_state=account_state, ) def create_execution( @@ -48,15 +216,20 @@ class ExecutionService: execution_mode: str = "paper", account_equity: float = 10000.0, risk_pct: float = 0.01, + confirmed: bool = False, + account_state: Optional[AccountRiskState] = None, ) -> Optional[int]: decision = self.prepare_execution( signal_id=signal_id, execution_mode=execution_mode, account_equity=account_equity, risk_pct=risk_pct, + account_state=account_state, ) if decision is None or not decision.allowed: return None + if decision.requires_confirmation and not confirmed: + return None latest = self.repository.fetch_latest_execution(signal_id=signal_id, execution_mode=execution_mode) if latest and trade_execution_matches_decision(latest, decision): return latest['id'] @@ -83,22 +256,27 @@ def trade_execution_matches_decision(execution: dict, decision: ExecutionDecisio execution.get('execution_mode') == decision.execution_mode and execution.get('status') == decision.status and execution.get('side') == decision.meta['signal_side'] - and float(execution.get('entry_price') or 0.0) == float(decision.entry_price) - and float(execution.get('stop_loss') or 0.0) == float(decision.meta['stop_loss']) - and float(execution.get('take_profit_1') or 0.0) == float(decision.meta['tp1']) - and float(execution.get('take_profit_2') or 0.0) == float(decision.meta['tp2']) - and float(execution.get('size') or 0.0) == float(decision.size) - and float(execution.get('risk_pct') or 0.0) == float(decision.risk_pct) + and _same_number(execution.get('entry_price'), decision.entry_price) + and _same_number(execution.get('stop_loss'), decision.meta['stop_loss']) + and _same_number(execution.get('take_profit_1'), decision.meta['tp1']) + and _same_number(execution.get('take_profit_2'), decision.meta['tp2']) + and _same_number(execution.get('size'), decision.size) + and _same_number(execution.get('risk_pct'), decision.risk_pct) and dict(execution.get('meta') or {}) == dict(decision.meta) and execution.get('status') in {'open', 'pending'} ) +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 evaluate_execution_decision( signal: dict, execution_mode: str, account_equity: float, risk_pct: float, + account_state: Optional[AccountRiskState] = None, ) -> ExecutionDecision: reasons: list[str] = [] meta = { @@ -128,6 +306,8 @@ def evaluate_execution_decision( reasons.append("bias_execution_intensity_low") if risk_pct <= 0 or risk_pct > 0.02: reasons.append("risk_pct_out_of_bounds") + account_risk_reasons = evaluate_account_risk_limits(account_state) + reasons.extend(account_risk_reasons) entry_price = compute_entry_price(signal) stop_loss = float(signal.get("stop_loss") or 0.0) @@ -145,18 +325,23 @@ def evaluate_execution_decision( meta["entry_high"] = float(signal.get("entry_high") or 0.0) meta["risk_distance"] = risk_distance meta["account_equity"] = account_equity + meta["account_risk"] = account_state.__dict__ if account_state else None allowed = len(reasons) == 0 + requires_confirmation = execution_mode == "semi_auto" if execution_mode == "paper": status = "open" if allowed else "rejected" else: status = "pending" if allowed else "rejected" + meta["requires_confirmation"] = requires_confirmation + return ExecutionDecision( allowed=allowed, status=status, reasons=reasons, execution_mode=execution_mode, + requires_confirmation=requires_confirmation, risk_pct=risk_pct, size=size, entry_price=entry_price, @@ -164,6 +349,45 @@ def evaluate_execution_decision( ) +def evaluate_account_risk_limits(account_state: Optional[AccountRiskState]) -> list[str]: + if account_state is None: + return [] + reasons: list[str] = [] + if account_state.kill_switch: + reasons.append("execution_kill_switch_enabled") + if account_state.daily_pnl_pct <= -abs(account_state.max_daily_loss_pct): + reasons.append("daily_loss_limit_reached") + if account_state.consecutive_losses >= account_state.max_consecutive_losses: + reasons.append("consecutive_loss_limit_reached") + if account_state.open_positions >= account_state.max_open_positions: + reasons.append("max_open_positions_reached") + if account_state.same_side_open_positions >= account_state.max_same_side_positions: + reasons.append("same_side_exposure_limit_reached") + return reasons + + +def classify_execution_lifecycle(status: Optional[str], backtest_result: Optional[dict]) -> Optional[str]: + if status is None: + return None + if status == "rejected": + 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" + return "open" + + def compute_entry_price(signal: dict) -> float: entry_low = float(signal.get("entry_low") or 0.0) entry_high = float(signal.get("entry_high") or 0.0) diff --git a/src/services/fvg/__init__.py b/src/services/fvg/__init__.py index 550ad35..fa92a2f 100644 --- a/src/services/fvg/__init__.py +++ b/src/services/fvg/__init__.py @@ -1,5 +1,17 @@ """FVG service package.""" -from src.services.fvg.fvg_service import FVGCandidate, FVGService, compute_fvg_fill_state +from src.services.fvg.fvg_service import ( + FVGCandidate, + FVGService, + compute_fvg_fill_state, + fvg_matches_candidate, + fvg_state_matches_update, +) -__all__ = ["FVGCandidate", "FVGService", "compute_fvg_fill_state"] +__all__ = [ + "FVGCandidate", + "FVGService", + "compute_fvg_fill_state", + "fvg_matches_candidate", + "fvg_state_matches_update", +] diff --git a/src/services/fvg/fvg_service.py b/src/services/fvg/fvg_service.py index 55e3a06..4711b93 100644 --- a/src/services/fvg/fvg_service.py +++ b/src/services/fvg/fvg_service.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +import math from typing import Optional from src.config.constants import DEFAULT_FVG_MITIGATION_THRESHOLD_PCT @@ -77,7 +78,19 @@ class FVGService: return candidates def persist_fvg(self, instrument_id: int, timeframe: str, candidates: list[FVGCandidate]) -> int: + persisted = 0 for candidate in candidates: + latest = self.repository.fetch_latest_fvg( + instrument_id=instrument_id, + timeframe=timeframe, + direction=candidate.direction, + upper=candidate.upper, + lower=candidate.lower, + start_ts=candidate.start_ts, + end_ts=candidate.end_ts, + ) + if latest and fvg_matches_candidate(latest, candidate): + continue self.repository.insert_fvg( instrument_id=instrument_id, timeframe=timeframe, @@ -91,7 +104,8 @@ class FVGService: related_displacement_id=candidate.related_displacement_id, meta=candidate.meta, ) - return len(candidates) + persisted += 1 + return persisted def update_existing_fvgs(self, instrument_id: int, timeframe: str, limit: int = 500) -> int: fvgs = self.repository.fetch_open_fvgs(instrument_id=instrument_id, timeframe=timeframe, limit=limit) @@ -112,6 +126,8 @@ class FVGService: candles=candles, mitigation_threshold=DEFAULT_FVG_MITIGATION_THRESHOLD_PCT, ) + if fvg_state_matches_update(fvg, fill_pct, status, meta): + continue self.repository.update_fvg( fvg_id=fvg['id'], fill_pct=fill_pct, @@ -122,6 +138,27 @@ class FVGService: return updated +def fvg_matches_candidate(row: dict, candidate: FVGCandidate) -> bool: + return ( + row.get('direction') == candidate.direction + and _same_number(row.get('upper'), candidate.upper) + and _same_number(row.get('lower'), candidate.lower) + and row.get('start_ts') == candidate.start_ts + and row.get('end_ts') == candidate.end_ts + and row.get('related_displacement_id') == candidate.related_displacement_id + ) + + +def fvg_state_matches_update(row: dict, fill_pct: float, status: str, meta: dict) -> bool: + row_meta = dict(row.get('meta') or {}) + merged_meta = {**row_meta, **meta} + return ( + _same_number(row.get('fill_pct'), fill_pct) + and row.get('status') == status + and row_meta == merged_meta + ) + + def compute_fvg_fill_state( direction: str, upper: float, @@ -176,7 +213,12 @@ def compute_fvg_fill_state( } + def _normalize_meta_ts(value): if hasattr(value, 'isoformat'): return value.isoformat() return value + + +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) diff --git a/src/services/liquidity/__init__.py b/src/services/liquidity/__init__.py index 0907114..9f8704a 100644 --- a/src/services/liquidity/__init__.py +++ b/src/services/liquidity/__init__.py @@ -5,6 +5,13 @@ from src.services.liquidity.liquidity_pool_service import ( LiquidityPoolService, build_equal_pool_candidates, dedupe_candidates, + pool_matches_candidate, ) -__all__ = ["LiquidityPoolCandidate", "LiquidityPoolService", "build_equal_pool_candidates", "dedupe_candidates"] +__all__ = [ + "LiquidityPoolCandidate", + "LiquidityPoolService", + "build_equal_pool_candidates", + "dedupe_candidates", + "pool_matches_candidate", +] diff --git a/src/services/liquidity/liquidity_pool_service.py b/src/services/liquidity/liquidity_pool_service.py index fc9f28e..7e74103 100644 --- a/src/services/liquidity/liquidity_pool_service.py +++ b/src/services/liquidity/liquidity_pool_service.py @@ -104,7 +104,18 @@ class LiquidityPoolService: return dedupe_candidates(candidates) def persist_candidates(self, instrument_id: int, timeframe: str, candidates: list[LiquidityPoolCandidate]) -> int: + persisted = 0 for candidate in candidates: + latest = self.repository.fetch_latest_pool( + instrument_id=instrument_id, + timeframe=timeframe, + side=candidate.side, + pool_type=candidate.pool_type, + price_low=candidate.price_low, + price_high=candidate.price_high, + ) + if latest and pool_matches_candidate(latest, candidate): + continue self.repository.insert_pool( instrument_id=instrument_id, timeframe=timeframe, @@ -116,7 +127,20 @@ class LiquidityPoolService: created_ts=candidate.created_ts, meta=candidate.meta, ) - return len(candidates) + persisted += 1 + return persisted + + +def pool_matches_candidate(pool: dict, candidate: LiquidityPoolCandidate) -> bool: + pool_meta = dict(pool.get("meta") or {}) + candidate_meta = dict(candidate.meta or {}) + return ( + pool.get("side") == candidate.side + and _same_pool_type(pool.get("pool_type"), candidate.pool_type, pool_meta, candidate_meta) + and Decimal(pool.get("price_low")) == Decimal(candidate.price_low) + and Decimal(pool.get("price_high")) == Decimal(candidate.price_high) + and sorted(list(pool.get("reference_ids") or [])) == sorted(list(candidate.reference_ids)) + ) def build_equal_pool_candidates( @@ -264,8 +288,22 @@ def _pool_types(candidate: LiquidityPoolCandidate) -> list[str]: return [candidate.pool_type] + + def _source_types(candidate: LiquidityPoolCandidate) -> list[str]: source = candidate.meta.get("source_pool_types") if candidate.meta else None if source: return list(source) return [candidate.pool_type] + + +def _same_pool_type(pool_type: Optional[str], candidate_type: str, pool_meta: dict, candidate_meta: dict) -> bool: + if pool_type == candidate_type: + return True + pool_merged_types = set(pool_meta.get("merged_pool_types") or []) + candidate_merged_types = set(candidate_meta.get("merged_pool_types") or []) + if candidate_type in pool_merged_types: + return True + if pool_type in candidate_merged_types: + return True + return bool(pool_merged_types and candidate_merged_types and pool_merged_types == candidate_merged_types) diff --git a/src/services/market_data/okx_client.py b/src/services/market_data/okx_client.py index 61fe6d9..bcc35d3 100644 --- a/src/services/market_data/okx_client.py +++ b/src/services/market_data/okx_client.py @@ -48,10 +48,15 @@ class OKXClient: assert last_error is not None raise last_error - def get_candles(self, inst_id: str, bar: str, limit: int = 100) -> list[OKXCandle]: + def get_candles(self, inst_id: str, bar: str, limit: int = 100, after: Optional[str] = None, before: Optional[str] = None) -> list[OKXCandle]: + params = {"instId": inst_id, "bar": bar, "limit": str(limit)} + if after: + params["after"] = after + if before: + params["before"] = before payload = self._get( "/api/v5/market/history-candles", - {"instId": inst_id, "bar": bar, "limit": str(limit)}, + params, ) rows = payload.get("data", []) candles: list[OKXCandle] = [] diff --git a/src/services/ranges/__init__.py b/src/services/ranges/__init__.py index 89e1540..5959940 100644 --- a/src/services/ranges/__init__.py +++ b/src/services/ranges/__init__.py @@ -1,5 +1,5 @@ """Dealing range service package.""" -from src.services.ranges.dealing_range_service import DealingRangeService, RangeCandidate +from src.services.ranges.dealing_range_service import DealingRangeService, RangeCandidate, range_matches_candidate -__all__ = ["DealingRangeService", "RangeCandidate"] +__all__ = ["DealingRangeService", "RangeCandidate", "range_matches_candidate"] diff --git a/src/services/ranges/dealing_range_service.py b/src/services/ranges/dealing_range_service.py index d109360..72507c6 100644 --- a/src/services/ranges/dealing_range_service.py +++ b/src/services/ranges/dealing_range_service.py @@ -46,6 +46,9 @@ class DealingRangeService: def persist_active_range(self, instrument_id: int, timeframe: str, candidate: Optional[RangeCandidate]) -> int: if candidate is None: return 0 + latest = self.repository.fetch_latest_valid_range(instrument_id=instrument_id, timeframe=timeframe) + if latest and range_matches_candidate(latest, candidate): + return 0 self.repository.upsert_range( instrument_id=instrument_id, timeframe=timeframe, @@ -58,3 +61,14 @@ class DealingRangeService: meta={}, ) return 1 + + +def range_matches_candidate(row: dict, candidate: RangeCandidate) -> bool: + return ( + float(row.get("high") or 0.0) == float(candidate.high) + and float(row.get("low") or 0.0) == float(candidate.low) + and float(row.get("equilibrium") or 0.0) == float(candidate.equilibrium) + and int(row.get("high_swing_id") or 0) == int(candidate.high_swing_id) + and int(row.get("low_swing_id") or 0) == int(candidate.low_swing_id) + and row.get("ts_start") == candidate.ts_start + ) diff --git a/src/services/reports/backtest_report_service.py b/src/services/reports/backtest_report_service.py index a717731..e6265bc 100644 --- a/src/services/reports/backtest_report_service.py +++ b/src/services/reports/backtest_report_service.py @@ -1,6 +1,10 @@ -from src.services.backtest import summarize_backtest_outcomes +from typing import Optional + +from src.services.backtest import BacktestQualityGate, evaluate_backtest_quality, summarize_backtest_outcomes class BacktestReportService: - def build_summary(self, outcomes): - return summarize_backtest_outcomes(outcomes) + def build_summary(self, outcomes, quality_gate: Optional[BacktestQualityGate] = None): + summary = summarize_backtest_outcomes(outcomes) + summary["quality_gate"] = evaluate_backtest_quality(summary, gate=quality_gate) + return summary diff --git a/src/services/reports/post_trade_review_service.py b/src/services/reports/post_trade_review_service.py index 785eb49..de035fc 100644 --- a/src/services/reports/post_trade_review_service.py +++ b/src/services/reports/post_trade_review_service.py @@ -1,7 +1,12 @@ from typing import Optional +from src.services.execution import ExecutionService + class PostTradeReviewService: + def __init__(self, execution_service: Optional[ExecutionService] = None) -> None: + self.execution_service = execution_service or ExecutionService() + def build_report( self, trade_execution: Optional[dict], @@ -12,17 +17,15 @@ class PostTradeReviewService: error_types: list[str] = [] improvement_notes: list[str] = [] - if not trade_execution: + execution_audit = self.execution_service.audit_execution( + trade_execution=trade_execution, + backtest_result=backtest_result, + ) + if execution_audit["audit_status"] == "missing": rule_alignment = False - error_types.append("missing_execution_record") - else: - if trade_execution.get("status") == "rejected": - rule_alignment = False - error_types.append("execution_rejected") - risk_checks = (trade_execution.get("meta") or {}).get("risk_checks") or [] - if risk_checks: - rule_alignment = False - error_types.extend(risk_checks) + if execution_audit["error_tags"]: + rule_alignment = False + error_types.extend(execution_audit["error_tags"]) if trade_signal: signal_invalidations = trade_signal.get("invalidations") or [] @@ -45,6 +48,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, + "execution_audit": execution_audit, "error_types": sorted(set(error_types)), "improvement_notes": sorted(set(improvement_notes)), } diff --git a/src/services/signals/__init__.py b/src/services/signals/__init__.py index 413bbb1..1e8df88 100644 --- a/src/services/signals/__init__.py +++ b/src/services/signals/__init__.py @@ -1,17 +1,25 @@ """Signal service package.""" from src.services.signals.signal_service import ( + MINIMAL_SIGNAL_EVIDENCE_KEYS, + STRICT_SIGNAL_EVIDENCE_KEYS, SignalCandidate, SignalService, classify_session_for_signal, trade_signal_matches_candidate, + validate_rr_thresholds, + validate_signal_evidence, validate_signal_context, ) __all__ = [ + "MINIMAL_SIGNAL_EVIDENCE_KEYS", + "STRICT_SIGNAL_EVIDENCE_KEYS", "SignalCandidate", "SignalService", "classify_session_for_signal", "trade_signal_matches_candidate", + "validate_rr_thresholds", + "validate_signal_evidence", "validate_signal_context", ] diff --git a/src/services/signals/signal_service.py b/src/services/signals/signal_service.py index 9890960..d8dd248 100644 --- a/src/services/signals/signal_service.py +++ b/src/services/signals/signal_service.py @@ -1,10 +1,23 @@ from dataclasses import dataclass from datetime import datetime, timedelta, timezone +import math from typing import Optional +from src.config.constants import MIN_TP1_RR, MIN_TP2_RR from src.repositories.signal_repository import SignalRepository +MINIMAL_SIGNAL_EVIDENCE_KEYS = ("bias_snapshot_id", "fvg_id", "mss_id") +STRICT_SIGNAL_EVIDENCE_KEYS = ( + "bias_snapshot_id", + "sweep_id", + "displacement_id", + "mss_id", + "fvg_id", + "liquidity_target_id", +) + + @dataclass(frozen=True) class SignalCandidate: side: str @@ -25,8 +38,13 @@ class SignalCandidate: class SignalService: - def __init__(self, repository: Optional[SignalRepository] = None) -> None: + def __init__( + self, + repository: Optional[SignalRepository] = None, + required_evidence_keys: tuple[str, ...] = MINIMAL_SIGNAL_EVIDENCE_KEYS, + ) -> None: self.repository = repository or SignalRepository() + self.required_evidence_keys = required_evidence_keys 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) @@ -53,6 +71,10 @@ class SignalService: risk = abs(((entry_low + entry_high) / 2) - stop_loss) rr_tp1 = abs(tp1 - ((entry_low + entry_high) / 2)) / risk if risk else 0.0 rr_tp2 = abs(tp2 - ((entry_low + entry_high) / 2)) / risk if risk else 0.0 + rr_decision = validate_rr_thresholds(rr_tp1=rr_tp1, rr_tp2=rr_tp2) + if not rr_decision["is_actionable"]: + return None + created_ts = datetime.now(timezone.utc) expires_ts = created_ts + timedelta(hours=4) session = self.repository.fetch_session_at(instrument_id=instrument_id, ts=created_ts) @@ -61,6 +83,14 @@ class SignalService: invalidations = ['mss invalidated', 'fvg fully mitigated'] if not session_decision['execution_permission']: invalidations.append('session_mismatch') + evidence = { + 'bias_snapshot_id': bias['id'], + 'fvg_id': fvg['id'], + 'mss_id': mss['id'], + } + evidence_quality = validate_signal_evidence(evidence, required_keys=self.required_evidence_keys) + if not evidence_quality['is_complete']: + return None return SignalCandidate( side=side, status=session_decision['status'], @@ -75,18 +105,16 @@ class SignalService: created_ts=created_ts, expires_ts=expires_ts, invalidations=invalidations, - evidence={ - 'bias_snapshot_id': bias['id'], - 'fvg_id': fvg['id'], - 'mss_id': mss['id'], - }, + evidence=evidence, meta={ 'session_filter': session_decision['reason'], 'execution_permission': session_decision['execution_permission'], 'structure_quality': structure_alignment['reason'], + 'evidence_quality': evidence_quality, 'fvg_status': fvg['status'], 'fvg_fill_pct': float(fvg.get('fill_pct') or 0.0), 'mss_displacement_event_id': mss.get('displacement_event_id'), + 'rr_filter': rr_decision, }, ) @@ -129,13 +157,13 @@ def trade_signal_matches_candidate(signal: dict, candidate: SignalCandidate, bia and signal.get('status') == candidate.status and signal.get('bias_snapshot_id') == bias_snapshot_id and signal.get('entry_type') == 'limit' - and float(signal.get('entry_low') or 0.0) == float(candidate.entry_low) - and float(signal.get('entry_high') or 0.0) == float(candidate.entry_high) - and float(signal.get('stop_loss') or 0.0) == float(candidate.stop_loss) - and float(signal.get('tp1') or 0.0) == float(candidate.tp1) - and float(signal.get('tp2') or 0.0) == float(candidate.tp2) - and float(signal.get('rr_tp1') or 0.0) == float(candidate.rr_tp1) - and float(signal.get('rr_tp2') or 0.0) == float(candidate.rr_tp2) + and _same_number(signal.get('entry_low'), candidate.entry_low) + and _same_number(signal.get('entry_high'), candidate.entry_high) + and _same_number(signal.get('stop_loss'), candidate.stop_loss) + and _same_number(signal.get('tp1'), candidate.tp1) + and _same_number(signal.get('tp2'), candidate.tp2) + 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 dict(signal.get('evidence') or {}) == dict(candidate.evidence) @@ -143,6 +171,10 @@ def trade_signal_matches_candidate(signal: dict, candidate: SignalCandidate, bia ) +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 validate_signal_context(bias: dict, fvg: dict, mss: dict) -> dict[str, object]: if not mss.get('accepted'): return {'is_actionable': False, 'reason': 'mss_not_accepted'} @@ -168,6 +200,34 @@ def validate_signal_context(bias: dict, fvg: dict, mss: dict) -> dict[str, objec return {'is_actionable': True, 'reason': 'aligned', 'side': expected_side} +def validate_rr_thresholds(rr_tp1: float, rr_tp2: float) -> dict[str, object]: + reasons: list[str] = [] + if rr_tp1 < MIN_TP1_RR: + reasons.append('tp1_rr_too_low') + if rr_tp2 < MIN_TP2_RR: + reasons.append('tp2_rr_too_low') + return { + 'is_actionable': len(reasons) == 0, + 'reason': 'rr_ok' if not reasons else ','.join(reasons), + 'reasons': reasons, + 'rr_tp1': rr_tp1, + 'rr_tp2': rr_tp2, + 'min_tp1_rr': MIN_TP1_RR, + 'min_tp2_rr': MIN_TP2_RR, + } + + +def validate_signal_evidence(evidence: dict, required_keys: tuple[str, ...] = MINIMAL_SIGNAL_EVIDENCE_KEYS) -> dict[str, object]: + present_keys = [key for key in required_keys if evidence.get(key) is not None] + missing_keys = [key for key in required_keys if evidence.get(key) is None] + return { + 'is_complete': len(missing_keys) == 0, + 'present_keys': present_keys, + 'missing_keys': missing_keys, + 'required_keys': list(required_keys), + } + + def classify_session_for_signal(session_code: Optional[str]) -> dict[str, object]: if session_code in {'LONDON', 'NY_AM', 'NY_PM'}: return { diff --git a/src/services/structure/__init__.py b/src/services/structure/__init__.py index 1d55a7a..adfe21b 100644 --- a/src/services/structure/__init__.py +++ b/src/services/structure/__init__.py @@ -1,5 +1,5 @@ """Structure service package.""" -from src.services.structure.mss_service import MSSCandidate, MSSService, detect_mss_candidates, evaluate_mss_acceptance +from src.services.structure.mss_service import MSSCandidate, MSSService, detect_mss_candidates, evaluate_mss_acceptance, mss_event_matches_candidate -__all__ = ["MSSCandidate", "MSSService", "detect_mss_candidates", "evaluate_mss_acceptance"] +__all__ = ["MSSCandidate", "MSSService", "detect_mss_candidates", "evaluate_mss_acceptance", "mss_event_matches_candidate"] diff --git a/src/services/structure/mss_service.py b/src/services/structure/mss_service.py index eb046ff..bc8f8bf 100644 --- a/src/services/structure/mss_service.py +++ b/src/services/structure/mss_service.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +import math from typing import Optional from src.config.constants import DEFAULT_MSS_ACCEPTANCE_WINDOW_CANDLES @@ -33,9 +34,12 @@ class MSSService: def persist_mss(self, instrument_id: int, timeframe: str, candidates: list[MSSCandidate]) -> int: persisted = 0 + latest = self.repository.fetch_latest_mss_event(instrument_id=instrument_id, timeframe=timeframe) for candidate in candidates: if not candidate.accepted: continue + if latest and mss_event_matches_candidate(latest, candidate): + continue self.repository.insert_mss_event( instrument_id=instrument_id, timeframe=timeframe, @@ -47,6 +51,14 @@ class MSSService: ts=candidate.ts, meta=candidate.meta, ) + latest = { + 'direction': candidate.direction, + 'broken_level': candidate.broken_level, + 'reference_swing_id': candidate.reference_swing_id, + 'displacement_event_id': candidate.displacement_event_id, + 'accepted': candidate.accepted, + 'meta': candidate.meta, + } persisted += 1 return persisted @@ -184,6 +196,17 @@ def evaluate_mss_acceptance(direction: str, broken_level: float, candles: list[d } +def mss_event_matches_candidate(event: dict, candidate: MSSCandidate) -> bool: + return ( + event.get('direction') == candidate.direction + and event.get('accepted') == candidate.accepted + and int(event.get('reference_swing_id') or 0) == int(candidate.reference_swing_id) + and int(event.get('displacement_event_id') or 0) == int(candidate.displacement_event_id) + and math.isclose(float(event.get('broken_level') or 0.0), float(candidate.broken_level), rel_tol=1e-9, abs_tol=1e-9) + and dict(event.get('meta') or {}) == dict(candidate.meta) + ) + + def _latest_swing_before_ts(swings: list[dict], ts) -> Optional[dict]: eligible = [swing for swing in swings if swing['ts'] <= ts] if not eligible: diff --git a/src/services/sweeps/__init__.py b/src/services/sweeps/__init__.py index c8cf4b0..abce745 100644 --- a/src/services/sweeps/__init__.py +++ b/src/services/sweeps/__init__.py @@ -1,5 +1,5 @@ """Sweep service package.""" -from src.services.sweeps.sweep_service import SweepCandidate, SweepService +from src.services.sweeps.sweep_service import SweepCandidate, SweepService, sweep_event_matches_candidate -__all__ = ["SweepCandidate", "SweepService"] +__all__ = ["SweepCandidate", "SweepService", "sweep_event_matches_candidate"] diff --git a/src/services/sweeps/sweep_service.py b/src/services/sweeps/sweep_service.py index 0075ec2..2fb9497 100644 --- a/src/services/sweeps/sweep_service.py +++ b/src/services/sweeps/sweep_service.py @@ -47,7 +47,15 @@ class SweepService: return candidates def persist_sweeps(self, instrument_id: int, timeframe: str, candidates: list[SweepCandidate]) -> int: + persisted = 0 for candidate in candidates: + latest = self.repository.fetch_latest_sweep_event( + instrument_id=instrument_id, + timeframe=timeframe, + pool_id=candidate.pool_id, + ) + if latest and sweep_event_matches_candidate(latest, candidate): + continue self.repository.upsert_sweep( instrument_id=instrument_id, timeframe=timeframe, @@ -63,7 +71,8 @@ class SweepService: ) if candidate.status == "confirmed": self.repository.mark_pool_swept(candidate.pool_id) - return len(candidates) + persisted += 1 + return persisted def _build_candidate(self, pool: dict, candles: list[dict]) -> Optional[SweepCandidate]: for index, candle in enumerate(candles): @@ -107,3 +116,17 @@ class SweepService: if isinstance(value, datetime): return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc) return datetime.combine(value, time.min, tzinfo=timezone.utc) + + +def sweep_event_matches_candidate(event: dict, candidate: SweepCandidate) -> bool: + return ( + event.get("side_swept") == candidate.side_swept + and int(event.get("pool_id") or 0) == int(candidate.pool_id) + and Decimal(event.get("sweep_high")) == Decimal(candidate.sweep_high) + and Decimal(event.get("sweep_low")) == Decimal(candidate.sweep_low) + and event.get("sweep_ts") == candidate.sweep_ts + and bool(event.get("close_back_inside")) == bool(candidate.close_back_inside) + and event.get("validation_level") == candidate.validation_level + and event.get("status") == candidate.status + and dict(event.get("meta") or {}) == dict(candidate.meta) + ) diff --git a/src/web/app.js b/src/web/app.js new file mode 100644 index 0000000..733b6b8 --- /dev/null +++ b/src/web/app.js @@ -0,0 +1,417 @@ +const healthStatus = document.getElementById('healthStatus'); +const refreshBtn = document.getElementById('refreshBtn'); +const instrumentIdInput = document.getElementById('instrumentId'); +const timeframeInput = document.getElementById('timeframe'); +const limitInput = document.getElementById('limit'); +const autoRefreshSecondsInput = document.getElementById('autoRefreshSeconds'); +const executionStatusFilterInput = document.getElementById('executionStatusFilter'); +const searchTextInput = document.getElementById('searchText'); +const lastUpdated = document.getElementById('lastUpdated'); +const activeFilters = document.getElementById('activeFilters'); +const signalsTable = document.getElementById('signalsTable'); +const sessionsTable = document.getElementById('sessionsTable'); +const executionsTable = document.getElementById('executionsTable'); +const backtestsCards = document.getElementById('backtestsCards'); +const overviewCards = document.getElementById('overviewCards'); +const detailMeta = document.getElementById('detailMeta'); +const detailPanel = document.getElementById('detailPanel'); +const canvas = document.getElementById('candlesChart'); +const ctx = canvas.getContext('2d'); + +async function fetchJson(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`${response.status} ${url}`); + } + return response.json(); +} + +function setText(element, value) { + element.textContent = value == null ? '' : String(value); +} + +function formatQualityGate(summary) { + const passed = summary?.quality_gate?.passed; + if (passed === true) { + return 'passed'; + } + if (passed === false) { + return 'blocked'; + } + return 'unknown'; +} + +function clearNode(node) { + while (node.firstChild) { + node.removeChild(node.firstChild); + } +} + +let selectedDetailKey = null; + +function renderTable(table, columns, rows, onRowClick, rowKey) { + clearNode(table); + + const thead = document.createElement('thead'); + const headRow = document.createElement('tr'); + columns.forEach((column) => { + const th = document.createElement('th'); + setText(th, column.label); + headRow.appendChild(th); + }); + thead.appendChild(headRow); + table.appendChild(thead); + + const tbody = document.createElement('tbody'); + rows.forEach((row) => { + const tr = document.createElement('tr'); + const currentKey = rowKey ? row[rowKey] : null; + if (onRowClick) { + tr.className = 'clickable-row'; + if (selectedDetailKey != null && currentKey === selectedDetailKey) { + tr.classList.add('selected-row'); + } + tr.addEventListener('click', () => onRowClick(row)); + } + columns.forEach((column) => { + const td = document.createElement('td'); + const value = column.render ? column.render(row) : row[column.key]; + if (value instanceof Node) { + td.appendChild(value); + } else { + setText(td, value); + } + tr.appendChild(td); + }); + tbody.appendChild(tr); + }); + table.appendChild(tbody); +} + +function applyExecutionFilters(items) { + const statusFilter = executionStatusFilterInput.value || 'all'; + const search = String(searchTextInput.value || '').trim().toLowerCase(); + const filtered = items.filter((item) => { + const statusOk = statusFilter === 'all' || item.execution_status === statusFilter; + const haystack = [ + item.execution_id, + item.signal_id, + item.session_code, + item.side, + item.execution_status, + item.lifecycle_state, + item.backtest_outcome, + item.audit_status, + ].join(' ').toLowerCase(); + 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(); + if (!search) return items; + return items.filter((item) => keys.some((key) => String(item[key] ?? '').toLowerCase().includes(search))); +} + +function setSelectedDetail(title, payload, key) { + selectedDetailKey = key; + showDetail(title, payload); +} + +function rowDetailHandler(titleBuilder, keyName) { + return (row) => { + setSelectedDetail(titleBuilder(row), row, row[keyName]); + refreshDashboard(); + }; +} + +function cardDetailHandler(titleBuilder, keyValue, payload) { + return () => { + setSelectedDetail(titleBuilder(payload), payload, keyValue); + refreshDashboard(); + }; +} + +function renderDashboardData(health, candles, signals, sessions, executions, backtests) { + const signalItems = applyTextFilters(signals.items || [], ['id', 'side', 'status', 'session_code']); + const sessionItems = applyTextFilters(sessions.items || [], ['id', 'session_code', 'status', 'session_date']); + const executionItems = applyExecutionFilters(executions.items || []); + const backtestItems = applyTextFilters(backtests.items || [], ['id']); + + renderHealth(health); + renderOverview(signalItems, sessionItems, executionItems, backtestItems); + renderTable(signalsTable, [ + tableColumn('id', 'ID'), + tableColumn('side', 'Side'), + statusColumn('status', 'Status'), + 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'); + renderTable(sessionsTable, [ + tableColumn('id', 'ID'), + tableColumn('session_code', 'Session'), + tableColumn('session_date', 'Date'), + statusColumn('status', 'Status'), + tableColumn('high', 'High'), + tableColumn('low', 'Low'), + tableColumn('midpoint', 'Mid'), + ], sessionItems, rowDetailHandler((row) => `Session #${row.id}`, 'id'), 'id'); + renderTable(executionsTable, [ + tableColumn('execution_id', 'Execution ID'), + tableColumn('signal_id', 'Signal ID'), + statusColumn('execution_status', 'Status'), + statusColumn('lifecycle_state', 'Lifecycle'), + tableColumn('side', 'Side'), + tableColumn('session_code', 'Session'), + statusColumn('backtest_outcome', 'Outcome'), + statusColumn('audit_status', 'Audit'), + tableColumn('opened_ts', 'Opened'), + tableColumn('closed_ts', 'Closed'), + ], executionItems, rowDetailHandler((row) => `Execution #${row.execution_id}`, 'execution_id'), 'execution_id'); + renderBacktests(backtestItems); + drawCandles(candles.items || []); +} + +function renderBacktests(items) { + clearNode(backtestsCards); + + items.forEach((item) => { + const summary = item.summary || {}; + const card = document.createElement('div'); + card.className = 'card clickable-card'; + card.addEventListener('click', cardDetailHandler((row) => `Backtest #${row.id}`, item.id, item)); + + const run = document.createElement('div'); + run.className = 'metric'; + setText(run, `Run #${item.id}`); + + const winRate = document.createElement('div'); + winRate.className = 'value'; + setText(winRate, `${((summary.win_rate ?? 0) * 100).toFixed(1)}%`); + + const label = document.createElement('div'); + label.className = 'metric'; + setText(label, 'win rate'); + + const signals = document.createElement('div'); + signals.className = 'metric'; + setText(signals, `signals: ${summary.total_signals ?? 0}`); + + const executed = document.createElement('div'); + executed.className = 'metric'; + setText(executed, `executed: ${summary.executed_signals ?? 0} (${((summary.executed_rate ?? 0) * 100).toFixed(1)}%)`); + + const mfe = document.createElement('div'); + mfe.className = 'metric'; + setText(mfe, `avg mfe: ${Number(summary.avg_mfe ?? 0).toFixed(2)}`); + + const mae = document.createElement('div'); + mae.className = 'metric'; + setText(mae, `avg mae: ${Number(summary.avg_mae ?? 0).toFixed(2)}`); + + const expectancy = document.createElement('div'); + expectancy.className = 'metric'; + setText(expectancy, `expectancy R: ${Number(summary.expectancy_r ?? 0).toFixed(2)}`); + + const executedExpectancy = document.createElement('div'); + executedExpectancy.className = 'metric'; + setText(executedExpectancy, `executed exp R: ${Number(summary.executed_expectancy_r ?? 0).toFixed(2)}`); + + const drawdown = document.createElement('div'); + drawdown.className = 'metric'; + setText(drawdown, `max DD R: ${Number(summary.max_drawdown_r ?? 0).toFixed(2)}`); + + 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); + backtestsCards.appendChild(card); + }); +} + +function renderHealth(health) { + clearNode(healthStatus); + + const app = document.createElement('div'); + const strong = document.createElement('strong'); + setText(strong, health.app); + app.appendChild(strong); + + const db = document.createElement('div'); + setText(db, `DB configured: ${health.database_configured}`); + + const tz = document.createElement('div'); + setText(tz, `TZ: ${health.session_timezone}`); + + healthStatus.append(app, db, tz); +} + +function renderOverview(signals, sessions, executions, backtests) { + clearNode(overviewCards); + + const signalCount = signals.length; + const openSignals = signals.filter((item) => item.status === 'awaiting_confirmation').length; + const sessionCount = sessions.length; + const openExecutions = executions.filter((item) => item.execution_status === 'open').length; + const closedExecutions = executions.filter((item) => item.execution_status === 'closed').length; + const latestBacktest = backtests[0]?.summary || {}; + const cards = [ + { label: 'Signals', value: signalCount }, + { label: 'Actionable Signals', value: openSignals }, + { label: 'Sessions', value: sessionCount }, + { label: 'Open Executions', value: openExecutions }, + { label: 'Closed Executions', value: closedExecutions }, + { label: 'Latest Win Rate', value: `${((latestBacktest.win_rate ?? 0) * 100).toFixed(1)}%` }, + { label: 'Latest Executed', value: latestBacktest.executed_signals ?? 0 }, + { label: 'Quality Gate', value: formatQualityGate(latestBacktest) }, + ]; + + cards.forEach((item) => { + const card = document.createElement('div'); + card.className = 'card'; + + const label = document.createElement('div'); + label.className = 'metric'; + setText(label, item.label); + + const value = document.createElement('div'); + value.className = 'value-small'; + setText(value, item.value); + + card.append(label, value); + overviewCards.appendChild(card); + }); +} + +function showDetail(title, payload) { + setText(detailMeta, title); + setText(detailPanel, JSON.stringify(payload, null, 2)); +} + +function drawCandles(items) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + if (!items.length) { + ctx.fillStyle = '#9db0d3'; + ctx.fillText('No candle data', 24, 40); + return; + } + + const candles = [...items].reverse(); + const highs = candles.map((candle) => Number(candle.high)); + const lows = candles.map((candle) => Number(candle.low)); + const min = Math.min(...lows); + const max = Math.max(...highs); + const padX = 24; + const padY = 20; + const width = (canvas.width - padX * 2) / candles.length; + const scaleY = (value) => { + const ratio = (value - min) / Math.max(max - min, 1e-9); + return canvas.height - padY - ratio * (canvas.height - padY * 2); + }; + + candles.forEach((candle, index) => { + const x = padX + index * width + width / 2; + const open = Number(candle.open); + const close = Number(candle.close); + const high = Number(candle.high); + const low = Number(candle.low); + const color = close >= open ? '#22c55e' : '#ef4444'; + + ctx.strokeStyle = color; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.moveTo(x, scaleY(high)); + ctx.lineTo(x, scaleY(low)); + ctx.stroke(); + + const bodyTop = scaleY(Math.max(open, close)); + const bodyBottom = scaleY(Math.min(open, close)); + const bodyHeight = Math.max(bodyBottom - bodyTop, 2); + ctx.fillRect(x - width * 0.3, bodyTop, width * 0.6, bodyHeight); + }); +} + +function tableColumn(key, label) { + return { key, label, render: (row) => row[key] }; +} + +function normalizeStatusClass(value) { + return String(value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '_'); +} + +function statusColumn(key, label) { + return { + key, + label, + render: (row) => { + const span = document.createElement('span'); + span.className = `status-pill status-${normalizeStatusClass(row[key])}`; + setText(span, row[key]); + return span; + }, + }; +} + +function renderLoadError(error) { + clearNode(healthStatus); + setText(healthStatus, `Load failed: ${error.message}`); +} + +function updateLastUpdated() { + const now = new Date(); + setText(lastUpdated, `Last updated: ${now.toLocaleTimeString()}`); +} + +async function refreshDashboard() { + const instrumentId = instrumentIdInput.value || '1'; + const timeframe = timeframeInput.value || '1m'; + const limit = limitInput.value || '20'; + + try { + const [health, candles, signals, sessions, executions, backtests] = await Promise.all([ + fetchJson('/health'), + fetchJson(`/candles?instrument_id=${instrumentId}&timeframe=${encodeURIComponent(timeframe)}&limit=${limit}`), + fetchJson(`/signals?instrument_id=${instrumentId}&limit=${limit}`), + fetchJson(`/sessions?instrument_id=${instrumentId}&limit=${limit}`), + fetchJson(`/executions?instrument_id=${instrumentId}&limit=${limit}`), + fetchJson(`/backtests?instrument_id=${instrumentId}&limit=${Math.min(Number(limit), 10)}`), + ]); + + renderDashboardData(health, candles, signals, sessions, executions, backtests); + updateLastUpdated(); + } catch (error) { + renderLoadError(error); + } +} + +let autoRefreshTimer = null; + +function configureAutoRefresh() { + if (autoRefreshTimer) { + clearInterval(autoRefreshTimer); + autoRefreshTimer = null; + } + const seconds = Number(autoRefreshSecondsInput.value || 0); + if (seconds > 0) { + autoRefreshTimer = setInterval(refreshDashboard, seconds * 1000); + } +} + +refreshBtn.addEventListener('click', refreshDashboard); +executionStatusFilterInput.addEventListener('change', refreshDashboard); +searchTextInput.addEventListener('input', refreshDashboard); +autoRefreshSecondsInput.addEventListener('change', configureAutoRefresh); +configureAutoRefresh(); +refreshDashboard(); diff --git a/src/web/index.html b/src/web/index.html new file mode 100644 index 0000000..d4ea6a4 --- /dev/null +++ b/src/web/index.html @@ -0,0 +1,90 @@ + + + + + + AI ICT Dashboard + + + +
+
+

AI ICT Dashboard

+

最小可视化看板

+
+
Loading...
+
+ +
+ + + + + + + +
尚未刷新
+
+ +
+
Filters: none
+
+ +
+
+

Overview

+
+
+ +
+

Candles

+ +
+ +
+

Signals

+
+
+ +
+

Sessions

+
+
+ +
+

Executions

+
+
+ +
+

Backtests

+
+
+ +
+

Details

+
点击任一表格行或回测卡片查看详情
+
暂无数据
+
+
+ + + + diff --git a/src/web/styles.css b/src/web/styles.css new file mode 100644 index 0000000..92c9381 --- /dev/null +++ b/src/web/styles.css @@ -0,0 +1,186 @@ +* { box-sizing: border-box; } +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #0b1020; + color: #e5e7eb; +} +.topbar, .controls, .panel { + background: #121933; + border: 1px solid #24304f; + border-radius: 14px; +} +.topbar { + margin: 16px; + padding: 20px; + display: flex; + justify-content: space-between; + align-items: center; +} +.topbar h1 { margin: 0 0 6px; font-size: 24px; } +.topbar p { margin: 0; color: #93a4c3; } +.health-card { + padding: 12px 16px; + min-width: 220px; + text-align: right; + background: #0f1530; + border-radius: 10px; +} +.controls { + margin: 0 16px 12px; + padding: 16px; + display: flex; + gap: 12px; + flex-wrap: wrap; + align-items: center; +} +.filters-bar { + margin: 0 16px 16px; + padding: 10px 14px; + background: #121933; + border: 1px solid #24304f; + border-radius: 14px; +} +.controls label { + display: flex; + flex-direction: column; + gap: 6px; + color: #9db0d3; + font-size: 13px; +} +.controls input, .controls select { + min-width: 110px; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid #31436c; + background: #0d1430; + color: #fff; +} +.controls button { + margin-top: 20px; + padding: 10px 16px; + border: 0; + border-radius: 8px; + background: #4f7cff; + color: white; + cursor: pointer; +} +.last-updated { + margin-top: 20px; + color: #9db0d3; + font-size: 13px; +} +.status-pill { + display: inline-block; + padding: 4px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + background: #27324f; +} +.status-open, .status-awaiting_confirmation, .status-tp1_reached, .status-ok, .status-passed { + background: rgba(34, 197, 94, 0.18); + color: #86efac; +} +.status-closed, .status-tp2_closed { + background: rgba(79, 124, 255, 0.18); + color: #93c5fd; +} +.status-review, .status-stopped_out, .status-expired, .status-rejected, .status-loss, .status-missed, .status-blocked { + background: rgba(239, 68, 68, 0.18); + color: #fca5a5; +} +.status-unknown { + background: rgba(148, 163, 184, 0.18); + color: #cbd5e1; +} +.grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + padding: 0 16px 16px; +} +.panel { + padding: 16px; + overflow: hidden; +} +.panel-wide { grid-column: span 2; } +.panel h2 { margin: 0 0 12px; font-size: 18px; } +.table-wrap { overflow: auto; } +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +th, td { + padding: 10px 8px; + border-bottom: 1px solid #24304f; + text-align: left; + vertical-align: top; +} +thead th { + position: sticky; + top: 0; + background: #121933; + z-index: 1; +} +th { color: #9db0d3; } +.cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} +.overview-cards { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} +.card { + padding: 14px; + border: 1px solid #2a3657; + border-radius: 12px; + background: #0f1530; +} +.metric { + font-size: 12px; + color: #91a2c2; +} +.value { + font-size: 20px; + font-weight: 700; + margin-top: 4px; +} +.value-small { + font-size: 16px; + font-weight: 700; + margin-top: 4px; +} +.detail-panel { + margin: 12px 0 0; + padding: 14px; + min-height: 180px; + overflow: auto; + border: 1px solid #2a3657; + border-radius: 12px; + background: #0f1530; + color: #dbe6ff; + font-size: 12px; + line-height: 1.5; +} +.clickable-row { + cursor: pointer; +} +.clickable-row:hover { + background: rgba(79, 124, 255, 0.08); +} +.clickable-row.selected-row { + background: rgba(79, 124, 255, 0.16); +} +.card.clickable-card { + cursor: pointer; +} +.card.clickable-card:hover { + border-color: #4f7cff; +} +@media (max-width: 960px) { + .grid { grid-template-columns: 1fr; } + .panel-wide { grid-column: span 1; } +} diff --git a/tests/fixtures/offline_replay_sample.jsonl b/tests/fixtures/offline_replay_sample.jsonl new file mode 100644 index 0000000..2955d09 --- /dev/null +++ b/tests/fixtures/offline_replay_sample.jsonl @@ -0,0 +1,5 @@ +{"ts_open":1,"ts_close":2,"open":97,"high":100,"low":95,"close":98} +{"ts_open":2,"ts_close":3,"open":100,"high":105,"low":96,"close":102} +{"ts_open":3,"ts_close":4,"open":99,"high":101,"low":94,"close":96} +{"ts_open":4,"ts_close":5,"open":96,"high":102,"low":90,"close":93} +{"ts_open":5,"ts_close":6,"open":95,"high":103,"low":93,"close":100} diff --git a/tests/unit/test_api_and_main.py b/tests/unit/test_api_and_main.py index 9c0a88e..2cda870 100644 --- a/tests/unit/test_api_and_main.py +++ b/tests/unit/test_api_and_main.py @@ -5,8 +5,11 @@ from unittest.mock import patch os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") from src.api import get_health -from src.api.backtest import get_recent_backtest_summaries +from src.api.backtest import get_recent_backtest_summaries, normalize_backtest_summary_row +from src.api.server import route_request from src.api.signals import get_recent_signals +from src.services.backtest import BacktestCostConfig, BacktestQualityGate +from src.services.execution import AccountRiskState from src.main import run_closed_loop @@ -55,7 +58,33 @@ class ApiTests(unittest.TestCase): with patch("src.api.backtest.SessionLocal", return_value=FakeSession(rows)): result = get_recent_backtest_summaries(instrument_id=1, limit=1) - self.assertEqual(result, rows) + self.assertEqual(result[0]["id"], 10) + 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}}} + + result = normalize_backtest_summary_row(row) + + self.assertEqual(result["summary"]["quality_gate"], {"passed": True}) + + def test_route_request_signals_returns_items_payload(self) -> None: + status, payload = route_request( + "/signals", + {"instrument_id": ["1"], "limit": ["1"]}, + dependencies={"signals": lambda instrument_id, limit: [{"id": 1}], "health": lambda: {}, "candles": lambda **kwargs: [], "sessions": lambda **kwargs: [], "backtests": lambda **kwargs: []}, + ) + + self.assertEqual(status, 200) + self.assertEqual(payload, {"items": [{"id": 1}]}) + + def test_route_request_signals_requires_instrument_id(self) -> None: + with self.assertRaisesRegex(ValueError, "missing_required_query_param:instrument_id"): + route_request( + "/signals", + {}, + dependencies={"signals": lambda instrument_id, limit: [], "health": lambda: {}, "candles": lambda **kwargs: [], "sessions": lambda **kwargs: [], "backtests": lambda **kwargs: []}, + ) class FakeSessionGenerator: @@ -125,28 +154,55 @@ class FakeFVGService: class FakeBiasService: + def __init__(self): + self.persist_calls = 0 + def build_bias(self, **kwargs): return type("Bias", (), {"daily_bias": "bullish", "confidence": 0.7, "execution_intensity": "high", "narrative_code": "bullish_structure_shift", "draw_side": "buy_side", "range_id": 1, "reason_codes": ["latest_mss"], "snapshot_ts": "t"})() def persist_bias(self, **kwargs): + self.persist_calls += 1 return 11 class FakeSignalService: + def __init__(self): + self.persist_calls = 0 + def build_signal(self, **kwargs): return type("Signal", (), {"side": "buy", "status": "awaiting_confirmation", "entry_low": 101.0, "entry_high": 103.0, "stop_loss": 99.0, "tp1": 104.0, "tp2": 106.0, "rr_tp1": 1.0, "rr_tp2": 2.0, "session_code": "LONDON", "created_ts": "t", "expires_ts": "t2", "invalidations": [], "evidence": {}, "meta": {"execution_permission": True}})() def persist_signal(self, **kwargs): + self.persist_calls += 1 return 1 class FakeExecutionService: + def __init__(self): + self.calls = 0 + self.settlement_calls = 0 + self.last_create_kwargs = None + def create_execution(self, **kwargs): + self.calls += 1 + self.last_create_kwargs = kwargs return 99 + def settle_latest_execution_for_signal(self, **kwargs): + self.settlement_calls += 1 + return {"id": 99, "status": "closed", "meta": {"lifecycle_state": "tp2_closed", "risk_checks": []}} + + def persist_execution_settlement(self, **kwargs): + self.settlement_calls += 1 + return {"id": 99, "status": "closed", "meta": {"lifecycle_state": "tp2_closed", "risk_checks": []}} + class FakeBacktestService: + def __init__(self): + self.last_kwargs = None + def run_backtest(self, **kwargs): + self.last_kwargs = kwargs outcome = type("Outcome", (), {"signal_id": 1, "outcome": "win", "mfe": 3.0, "mae": 1.0, "tp1_hit": True, "tp2_hit": True, "invalidated_before_entry": False, "error_tags": [], "meta": {"signal_side": "buy", "session_codes": ["LONDON"], "weekday": "Monday"}})() return 55, [outcome] @@ -200,6 +256,148 @@ class ClosedLoopTests(unittest.TestCase): self.assertEqual(result["reports"]["pre_market"]["report_type"], "pre_market_plan") self.assertEqual(result["reports"]["intraday"]["report_type"], "intraday_explanation") self.assertEqual(result["reports"]["post_trade"]["report_type"], "post_trade_review") + self.assertEqual(result["writes"]["execution_status"], "closed") + self.assertEqual(result["writes"]["execution_lifecycle_state"], "tp2_closed") + self.assertIn("backtest_quality_gate", result) + self.assertFalse(result["backtest_quality_gate"]["passed"]) + + def test_run_closed_loop_repeat_uses_service_idempotency_contracts(self) -> None: + bias_service = FakeBiasService() + signal_service = FakeSignalService() + execution_service = FakeExecutionService() + recent_signals = [{"id": 1, "session_code": "LONDON", "status": "awaiting_confirmation", "side": "buy", "invalidations": []}] + + deps = { + "session_generator": FakeSessionGenerator(), + "swing_service": FakeSwingService(), + "range_service": FakeRangeService(), + "liquidity_service": FakeLiquidityService(), + "sweep_service": FakeSweepService(), + "displacement_service": FakeDisplacementService(), + "mss_service": FakeMSSService(), + "fvg_service": FakeFVGService(), + "bias_service": bias_service, + "signal_service": signal_service, + "execution_service": execution_service, + "backtest_service": FakeBacktestService(), + "pre_market_report_service": FakePreMarketReportService(), + "intraday_report_service": FakeIntradayReportService(), + "post_trade_review_service": FakePostTradeReviewService(), + "get_recent_signals": lambda instrument_id, limit: recent_signals, + "get_recent_backtest_summaries": lambda instrument_id, limit: [{"id": 55, "summary": {"total_signals": 1}}], + } + + first = run_closed_loop(instrument_id=1, timeframe="1m", backtest_limit=10, run_execution=True, dependencies=deps) + second = run_closed_loop(instrument_id=1, timeframe="1m", backtest_limit=10, run_execution=True, dependencies=deps) + + self.assertEqual(first["writes"]["bias_snapshot_id"], 11) + self.assertEqual(second["writes"]["bias_snapshot_id"], 11) + self.assertEqual(bias_service.persist_calls, 2) + self.assertEqual(signal_service.persist_calls, 2) + self.assertEqual(execution_service.calls, 2) + self.assertEqual(execution_service.settlement_calls, 2) + + def test_run_closed_loop_passes_backtest_cost_config(self) -> None: + backtest_service = FakeBacktestService() + cost_config = BacktestCostConfig(fee_rate=0.0005, slippage_rate=0.0005, fixed_cost_r=0.05) + deps = { + "session_generator": FakeSessionGenerator(), + "swing_service": FakeSwingService(), + "range_service": FakeRangeService(), + "liquidity_service": FakeLiquidityService(), + "sweep_service": FakeSweepService(), + "displacement_service": FakeDisplacementService(), + "mss_service": FakeMSSService(), + "fvg_service": FakeFVGService(), + "bias_service": FakeBiasService(), + "signal_service": FakeSignalService(), + "execution_service": FakeExecutionService(), + "backtest_service": backtest_service, + "pre_market_report_service": FakePreMarketReportService(), + "intraday_report_service": FakeIntradayReportService(), + "post_trade_review_service": FakePostTradeReviewService(), + "get_recent_signals": lambda instrument_id, limit: [{"id": 1, "session_code": "LONDON", "status": "awaiting_confirmation", "side": "buy", "invalidations": []}], + "get_recent_backtest_summaries": lambda instrument_id, limit: [{"id": 55, "summary": {"total_signals": 1}}], + } + + run_closed_loop( + instrument_id=1, + timeframe="1m", + backtest_limit=10, + run_execution=True, + backtest_cost_config=cost_config, + dependencies=deps, + ) + + self.assertEqual(backtest_service.last_kwargs["cost_config"], cost_config) + + def test_run_closed_loop_passes_backtest_quality_gate(self) -> None: + backtest_service = FakeBacktestService() + quality_gate = BacktestQualityGate(min_total_signals=1, min_profit_factor_r=1.0) + deps = { + "session_generator": FakeSessionGenerator(), + "swing_service": FakeSwingService(), + "range_service": FakeRangeService(), + "liquidity_service": FakeLiquidityService(), + "sweep_service": FakeSweepService(), + "displacement_service": FakeDisplacementService(), + "mss_service": FakeMSSService(), + "fvg_service": FakeFVGService(), + "bias_service": FakeBiasService(), + "signal_service": FakeSignalService(), + "execution_service": FakeExecutionService(), + "backtest_service": backtest_service, + "pre_market_report_service": FakePreMarketReportService(), + "intraday_report_service": FakeIntradayReportService(), + "post_trade_review_service": FakePostTradeReviewService(), + "get_recent_signals": lambda instrument_id, limit: [{"id": 1, "session_code": "LONDON", "status": "awaiting_confirmation", "side": "buy", "invalidations": []}], + "get_recent_backtest_summaries": lambda instrument_id, limit: [{"id": 55, "summary": {"total_signals": 1}}], + } + + run_closed_loop( + instrument_id=1, + timeframe="1m", + backtest_limit=10, + run_execution=True, + backtest_quality_gate=quality_gate, + dependencies=deps, + ) + + self.assertEqual(backtest_service.last_kwargs["quality_gate"], quality_gate) + + def test_run_closed_loop_passes_execution_account_state(self) -> None: + execution_service = FakeExecutionService() + account_state = AccountRiskState(daily_pnl_pct=-0.01, consecutive_losses=1) + deps = { + "session_generator": FakeSessionGenerator(), + "swing_service": FakeSwingService(), + "range_service": FakeRangeService(), + "liquidity_service": FakeLiquidityService(), + "sweep_service": FakeSweepService(), + "displacement_service": FakeDisplacementService(), + "mss_service": FakeMSSService(), + "fvg_service": FakeFVGService(), + "bias_service": FakeBiasService(), + "signal_service": FakeSignalService(), + "execution_service": execution_service, + "backtest_service": FakeBacktestService(), + "pre_market_report_service": FakePreMarketReportService(), + "intraday_report_service": FakeIntradayReportService(), + "post_trade_review_service": FakePostTradeReviewService(), + "get_recent_signals": lambda instrument_id, limit: [{"id": 1, "session_code": "LONDON", "status": "awaiting_confirmation", "side": "buy", "invalidations": []}], + "get_recent_backtest_summaries": lambda instrument_id, limit: [{"id": 55, "summary": {"total_signals": 1}}], + } + + run_closed_loop( + instrument_id=1, + timeframe="1m", + backtest_limit=10, + run_execution=True, + execution_account_state=account_state, + dependencies=deps, + ) + + self.assertEqual(execution_service.last_create_kwargs["account_state"], account_state) if __name__ == "__main__": diff --git a/tests/unit/test_api_server.py b/tests/unit/test_api_server.py index d566a7c..d70e63c 100644 --- a/tests/unit/test_api_server.py +++ b/tests/unit/test_api_server.py @@ -1,9 +1,17 @@ +import contextlib +import io +import json import os import unittest +from types import SimpleNamespace +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 src.api import route_request +from scripts import run_execution, run_execution_settlement, run_offline_checks, run_pipeline class ApiServerRoutingTests(unittest.TestCase): @@ -49,12 +57,99 @@ class ApiServerRoutingTests(unittest.TestCase): "sessions": lambda **kwargs: [], "signals": lambda **kwargs: [], "backtests": lambda **kwargs: [{"id": 9}], + "executions": lambda **kwargs: [], }, ) self.assertEqual(status, 200) self.assertEqual(payload["items"], [{"id": 9}]) + def test_routes_executions_query(self) -> None: + status, payload = route_request( + "/executions", + {"instrument_id": ["1"], "limit": ["2"]}, + dependencies={ + "health": lambda: {"ok": True}, + "candles": lambda **kwargs: [], + "sessions": lambda **kwargs: [], + "signals": lambda **kwargs: [], + "backtests": lambda **kwargs: [], + "executions": lambda **kwargs: [{"execution_id": 1, "audit_status": "ok"}], + }, + ) + + self.assertEqual(status, 200) + self.assertEqual(payload["items"], [{"execution_id": 1, "audit_status": "ok"}]) + + def test_routes_health(self) -> None: + status, payload = route_request( + "/health", + {}, + dependencies={ + "health": lambda: {"ok": True}, + "candles": lambda **kwargs: [], + "sessions": lambda **kwargs: [], + "signals": lambda **kwargs: [], + "backtests": lambda **kwargs: [], + "executions": lambda **kwargs: [], + }, + ) + + self.assertEqual(status, 200) + self.assertEqual(payload, {"ok": True}) + + def test_routes_signals_query(self) -> None: + status, payload = route_request( + "/signals", + {"instrument_id": ["1"], "limit": ["2"]}, + dependencies={ + "health": lambda: {"ok": True}, + "candles": lambda **kwargs: [], + "sessions": lambda **kwargs: [], + "signals": lambda **kwargs: [{"id": 1}, {"id": 2}], + "backtests": lambda **kwargs: [], + "executions": lambda **kwargs: [], + }, + ) + + self.assertEqual(status, 200) + self.assertEqual(payload["items"], [{"id": 1}, {"id": 2}]) + + def test_rejects_missing_required_query_param(self) -> None: + with self.assertRaises(ValueError): + route_request( + "/signals", + {}, + dependencies={ + "health": lambda: {"ok": True}, + "candles": lambda **kwargs: [], + "sessions": lambda **kwargs: [], + "signals": lambda **kwargs: [], + "backtests": lambda **kwargs: [], + "executions": lambda **kwargs: [], + }, + ) + + def test_returns_not_found_for_unknown_route(self) -> None: + status, payload = route_request( + "/unknown", + {}, + dependencies={ + "health": lambda: {"ok": True}, + "candles": lambda **kwargs: [], + "sessions": lambda **kwargs: [], + "signals": lambda **kwargs: [], + "backtests": lambda **kwargs: [], + "executions": lambda **kwargs: [], + }, + ) + + self.assertEqual(status, 404) + self.assertEqual(payload["error"], "not_found") + + +class ScriptFallbackTests(unittest.TestCase): + def test_rejects_missing_required_query_param(self) -> None: with self.assertRaises(ValueError): route_request( @@ -86,5 +181,301 @@ class ApiServerRoutingTests(unittest.TestCase): self.assertEqual(payload["error"], "not_found") +class ScriptFallbackTests(unittest.TestCase): + def test_run_execution_reports_database_unavailable(self) -> None: + output = io.StringIO() + with patch("scripts.run_execution.ExecutionService.prepare_execution", side_effect=OperationalError("stmt", {}, Exception())), contextlib.redirect_stdout(output): + exit_code = run_execution.main() + + self.assertEqual(exit_code, 2) + self.assertIn("Database unavailable for execution sample: OperationalError", output.getvalue()) + + def test_run_execution_reports_database_unavailable_json(self) -> None: + output = io.StringIO() + with patch("scripts.run_execution.ExecutionService.prepare_execution", side_effect=OperationalError("stmt", {}, Exception())), contextlib.redirect_stdout(output): + exit_code = run_execution.main(env={"EXECUTION_OUTPUT_JSON": "1"}) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 2) + self.assertFalse(payload["passed"]) + self.assertEqual(payload["error_type"], "OperationalError") + self.assertEqual(payload["execution_mode"], "paper") + + def test_run_execution_builds_account_risk_state_from_env(self) -> None: + state = run_execution.build_account_risk_state_from_env( + { + "EXECUTION_KILL_SWITCH": "true", + "EXECUTION_DAILY_PNL_PCT": "-0.01", + "EXECUTION_CONSECUTIVE_LOSSES": "2", + "EXECUTION_OPEN_POSITIONS": "1", + "EXECUTION_SAME_SIDE_OPEN_POSITIONS": "1", + "EXECUTION_MAX_DAILY_LOSS_PCT": "0.015", + "EXECUTION_MAX_CONSECUTIVE_LOSSES": "4", + "EXECUTION_MAX_OPEN_POSITIONS": "2", + "EXECUTION_MAX_SAME_SIDE_POSITIONS": "2", + } + ) + + self.assertTrue(state.kill_switch) + self.assertEqual(state.daily_pnl_pct, -0.01) + self.assertEqual(state.consecutive_losses, 2) + self.assertEqual(state.max_daily_loss_pct, 0.015) + self.assertEqual(state.max_consecutive_losses, 4) + + def test_run_execution_empty_env_ignores_ambient_risk_state(self) -> None: + with patch.dict( + os.environ, + { + "EXECUTION_KILL_SWITCH": "true", + "EXECUTION_CONSECUTIVE_LOSSES": "9", + "EXECUTION_OPEN_POSITIONS": "7", + }, + clear=False, + ): + state = run_execution.build_account_risk_state_from_env({}) + + self.assertFalse(state.kill_switch) + self.assertEqual(state.consecutive_losses, 0) + self.assertEqual(state.open_positions, 0) + + def test_run_execution_settlement_reports_database_unavailable(self) -> None: + output = io.StringIO() + with patch("scripts.run_execution_settlement.ExecutionService.settle_latest_execution_for_signal", side_effect=OperationalError("stmt", {}, Exception())), contextlib.redirect_stdout(output): + exit_code = run_execution_settlement.main() + + self.assertEqual(exit_code, 2) + self.assertIn("Database unavailable for execution settlement sample: OperationalError", output.getvalue()) + + def test_run_execution_settlement_reports_database_unavailable_json(self) -> None: + output = io.StringIO() + with patch("scripts.run_execution_settlement.ExecutionService.settle_latest_execution_for_signal", side_effect=OperationalError("stmt", {}, Exception())), contextlib.redirect_stdout(output): + exit_code = run_execution_settlement.main(env={"EXECUTION_SETTLEMENT_OUTPUT_JSON": "1"}) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 2) + self.assertFalse(payload["passed"]) + self.assertEqual(payload["error_type"], "OperationalError") + self.assertEqual(payload["execution_mode"], "paper") + + def test_run_pipeline_reports_database_unavailable(self) -> None: + output = io.StringIO() + with patch("scripts.run_pipeline.run_closed_loop", side_effect=OperationalError("stmt", {}, Exception())), contextlib.redirect_stdout(output): + exit_code = run_pipeline.main() + + self.assertEqual(exit_code, 2) + self.assertIn("Database unavailable for pipeline sample: OperationalError", output.getvalue()) + + def test_run_pipeline_reports_database_unavailable_json(self) -> None: + output = io.StringIO() + with patch("scripts.run_pipeline.run_closed_loop", side_effect=OperationalError("stmt", {}, Exception())), contextlib.redirect_stdout(output): + exit_code = run_pipeline.main(env={"PIPELINE_OUTPUT_JSON": "1"}) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 2) + self.assertFalse(payload["passed"]) + self.assertEqual(payload["error_type"], "OperationalError") + + def test_run_pipeline_passes_backtest_cost_config(self) -> None: + captured = {} + + def fake_run_closed_loop(**kwargs): + captured.update(kwargs) + return {"ok": True} + + output = io.StringIO() + env = { + "BACKTEST_FEE_RATE": "0.0005", + "BACKTEST_SLIPPAGE_RATE": "0.0007", + "BACKTEST_FIXED_COST_R": "0.05", + } + with patch("scripts.run_pipeline.run_closed_loop", side_effect=fake_run_closed_loop), patch.dict(os.environ, env, clear=False), contextlib.redirect_stdout(output): + run_pipeline.main() + + cost_config = captured["backtest_cost_config"] + self.assertEqual(cost_config.fee_rate, 0.0005) + self.assertEqual(cost_config.slippage_rate, 0.0007) + self.assertEqual(cost_config.fixed_cost_r, 0.05) + + def test_run_pipeline_passes_backtest_quality_gate_config(self) -> None: + captured = {} + + def fake_run_closed_loop(**kwargs): + captured.update(kwargs) + return {"ok": True, "backtest_quality_gate": {"passed": True}} + + output = io.StringIO() + env = { + "BACKTEST_MIN_TOTAL_SIGNALS": "50", + "BACKTEST_MIN_EXECUTED_SIGNALS": "20", + "BACKTEST_MIN_EXPECTANCY_R": "0.1", + "BACKTEST_MIN_PROFIT_FACTOR_R": "1.5", + "BACKTEST_MAX_DRAWDOWN_R": "6.0", + "BACKTEST_MAX_CONSECUTIVE_LOSSES": "3", + } + with patch("scripts.run_pipeline.run_closed_loop", side_effect=fake_run_closed_loop), patch.dict(os.environ, env, clear=False), contextlib.redirect_stdout(output): + run_pipeline.main() + + quality_gate = captured["backtest_quality_gate"] + self.assertEqual(quality_gate.min_total_signals, 50) + self.assertEqual(quality_gate.min_executed_signals, 20) + self.assertEqual(quality_gate.min_expectancy_r, 0.1) + self.assertEqual(quality_gate.min_profit_factor_r, 1.5) + self.assertEqual(quality_gate.max_drawdown_r, 6.0) + self.assertEqual(quality_gate.max_consecutive_losses, 3) + + def test_run_pipeline_passes_signal_evidence_mode(self) -> None: + captured = {} + + def fake_run_closed_loop(**kwargs): + captured.update(kwargs) + return {"ok": True, "backtest_quality_gate": {"passed": True}} + + output = io.StringIO() + with patch("scripts.run_pipeline.run_closed_loop", side_effect=fake_run_closed_loop), patch.dict(os.environ, {"SIGNAL_EVIDENCE_MODE": "strict"}, clear=False), contextlib.redirect_stdout(output): + run_pipeline.main() + + self.assertIn("sweep_id", captured["signal_required_evidence_keys"]) + self.assertIn("liquidity_target_id", captured["signal_required_evidence_keys"]) + + def test_run_pipeline_passes_execution_account_state(self) -> None: + captured = {} + + def fake_run_closed_loop(**kwargs): + captured.update(kwargs) + return {"ok": True} + + output = io.StringIO() + env = { + "EXECUTION_DAILY_PNL_PCT": "-0.01", + "EXECUTION_CONSECUTIVE_LOSSES": "2", + } + with patch("scripts.run_pipeline.run_closed_loop", side_effect=fake_run_closed_loop), patch.dict(os.environ, env, clear=False), contextlib.redirect_stdout(output): + run_pipeline.main() + + account_state = captured["execution_account_state"] + self.assertEqual(account_state.daily_pnl_pct, -0.01) + self.assertEqual(account_state.consecutive_losses, 2) + + def test_run_pipeline_blocks_when_required_quality_gate_fails(self) -> None: + output = io.StringIO() + result = {"backtest_quality_gate": {"passed": False, "reasons": ["sample_size_too_small"]}} + + with patch("scripts.run_pipeline.run_closed_loop", return_value=result), patch.dict(os.environ, {"BACKTEST_REQUIRE_QUALITY_GATE": "1"}, clear=False), contextlib.redirect_stdout(output): + exit_code = run_pipeline.main() + + self.assertEqual(exit_code, 2) + self.assertIn('"backtest_quality_gate"', output.getvalue()) + + +class OfflineChecksScriptTests(unittest.TestCase): + def test_build_offline_check_commands_adds_replay_file_when_configured(self) -> None: + commands = run_offline_checks.build_check_commands(env={"OFFLINE_REPLAY_FILE": "sample.jsonl"}) + + self.assertEqual(commands[-1][0], "replay_file") + self.assertEqual(commands[-1][1][-1], "sample.jsonl") + + def test_run_offline_checks_returns_zero_when_all_checks_pass(self) -> None: + calls = [] + + def fake_runner(command, **kwargs): + calls.append(command) + return SimpleNamespace(returncode=0, stdout="ok\n", stderr="") + + output = io.StringIO() + commands = [("unit_tests", ["python", "-m", "unittest"]), ("dashboard_js_syntax", ["node", "--check", "src/web/app.js"])] + + with contextlib.redirect_stdout(output): + exit_code = run_offline_checks.run_checks(commands=commands, runner=fake_runner, node_available=True, env={}) + + self.assertEqual(exit_code, 0) + self.assertEqual(len(calls), 2) + self.assertIn("Offline check passed: dashboard_js_syntax", output.getvalue()) + + def test_run_offline_checks_can_include_optional_replay_file(self) -> None: + calls = [] + + def fake_runner(command, **kwargs): + calls.append(command) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_offline_checks.run_checks(runner=fake_runner, node_available=True, env={"OFFLINE_REPLAY_FILE": "sample.csv"}) + + self.assertEqual(exit_code, 0) + self.assertEqual(calls[-1][-1], "sample.csv") + self.assertIn("Offline check passed: replay_file", output.getvalue()) + + def test_run_offline_checks_stops_on_failure(self) -> None: + calls = [] + + def fake_runner(command, **kwargs): + calls.append(command) + return SimpleNamespace(returncode=7, stdout="", stderr="failed\n") + + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_offline_checks.run_checks(commands=[("unit_tests", ["python", "-m", "unittest"])], runner=fake_runner, env={}) + + self.assertEqual(exit_code, 7) + self.assertEqual(len(calls), 1) + self.assertIn("Offline check failed: unit_tests exit_code=7", output.getvalue()) + + def test_run_offline_checks_skips_node_when_unavailable(self) -> None: + calls = [] + + def fake_runner(command, **kwargs): + calls.append(command) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_offline_checks.run_checks(commands=[("dashboard_js_syntax", ["node", "--check", "src/web/app.js"])], runner=fake_runner, node_available=False, env={}) + + self.assertEqual(exit_code, 0) + self.assertEqual(calls, []) + self.assertIn("Offline check skipped: dashboard_js_syntax", output.getvalue()) + + def test_run_offline_checks_can_emit_json_summary(self) -> None: + def fake_runner(command, **kwargs): + return SimpleNamespace(returncode=0, stdout="ok\n", stderr="") + + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_offline_checks.run_checks( + commands=[("unit_tests", ["python", "-m", "unittest"])], + runner=fake_runner, + env={"OFFLINE_CHECKS_OUTPUT_JSON": "1"}, + ) + + summary = json.loads(output.getvalue()) + self.assertEqual(exit_code, 0) + self.assertTrue(summary["passed"]) + self.assertEqual(summary["checks"][0]["name"], "unit_tests") + self.assertEqual(summary["checks"][0]["status"], "passed") + + def test_run_offline_checks_passes_merged_env_to_subprocesses(self) -> None: + seen_env = {} + + def fake_runner(command, **kwargs): + seen_env.update(kwargs["env"]) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + with contextlib.redirect_stdout(io.StringIO()): + exit_code = run_offline_checks.run_checks( + commands=[("unit_tests", ["python", "-m", "unittest"])], + runner=fake_runner, + env={"CUSTOM_OFFLINE_FLAG": "1"}, + ) + + self.assertEqual(exit_code, 0) + self.assertEqual(seen_env["CUSTOM_OFFLINE_FLAG"], "1") + self.assertIn("PATH", seen_env) + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_api_static_server.py b/tests/unit/test_api_static_server.py new file mode 100644 index 0000000..f67e0f9 --- /dev/null +++ b/tests/unit/test_api_static_server.py @@ -0,0 +1,55 @@ +import os +import shutil +import subprocess +import unittest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") + +from src.api.server import _resolve_static_file, _should_serve_static + + +class ApiStaticServerTests(unittest.TestCase): + def test_root_is_treated_as_static(self) -> None: + self.assertTrue(_should_serve_static("/")) + self.assertTrue(str(_resolve_static_file("/")).endswith("src/web/index.html")) + + def test_static_asset_path_is_resolved(self) -> None: + self.assertTrue(_should_serve_static("/static/app.js")) + self.assertTrue(str(_resolve_static_file("/static/app.js")).endswith("src/web/app.js")) + + def test_static_path_rejects_escape(self) -> None: + self.assertIsNone(_resolve_static_file("/static/../../README.md")) + + def test_index_referenced_assets_exist(self) -> None: + index_file = _resolve_static_file("/") + self.assertIsNotNone(index_file) + index_html = index_file.read_text(encoding="utf-8") + + for asset in ("/static/styles.css", "/static/app.js"): + with self.subTest(asset=asset): + self.assertIn(asset, index_html) + asset_file = _resolve_static_file(asset) + self.assertIsNotNone(asset_file) + self.assertTrue(asset_file.exists()) + + @unittest.skipIf(shutil.which("node") is None, "node is required for JS syntax checks") + def test_dashboard_javascript_syntax_is_valid(self) -> None: + app_file = _resolve_static_file("/static/app.js") + self.assertIsNotNone(app_file) + + result = subprocess.run(["node", "--check", str(app_file)], capture_output=True, text=True, check=False) + + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + + def test_dashboard_surfaces_executed_backtest_sample_size(self) -> None: + app_file = _resolve_static_file("/static/app.js") + self.assertIsNotNone(app_file) + app_js = app_file.read_text(encoding="utf-8") + + self.assertIn("executed_signals", app_js) + self.assertIn("executed_expectancy_r", app_js) + self.assertIn("Latest Executed", app_js) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_autonomous_cycle.py b/tests/unit/test_autonomous_cycle.py new file mode 100644 index 0000000..b5d7b1d --- /dev/null +++ b/tests/unit/test_autonomous_cycle.py @@ -0,0 +1,410 @@ +import contextlib +import io +import json +import os +import unittest +from types import SimpleNamespace + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") + +from scripts import run_autonomous_cycle + + +class AutonomousCycleTests(unittest.TestCase): + def test_schema_readiness_reports_missing_columns(self) -> None: + result = run_autonomous_cycle.evaluate_schema_readiness( + required_schema={"candles": ("id", "ts_open", "close"), "trade_signals": ("id",)}, + existing_tables={"candles"}, + existing_columns={"candles": {"id", "ts_open"}}, + ) + + self.assertFalse(result["ready"]) + self.assertEqual(result["missing_tables"], ["trade_signals"]) + self.assertEqual(result["missing_columns"], {"candles": ["close"]}) + + def test_build_required_schema_reads_model_columns(self) -> None: + schema = run_autonomous_cycle.build_required_schema(required_tables=("candles", "trade_signals")) + + self.assertIn("ts_open", schema["candles"]) + self.assertIn("evidence", schema["trade_signals"]) + + def test_waits_for_database_after_offline_checks_pass(self) -> None: + calls = [] + + def fake_runner(command, **kwargs): + calls.append(command) + return SimpleNamespace(returncode=0, stdout='{"passed": true, "checks": []}', stderr="") + + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_autonomous_cycle.run_autonomous_cycle( + env={}, + runner=fake_runner, + db_probe=lambda: {"available": False, "error_type": "OperationalError", "error": "down"}, + ) + + self.assertEqual(exit_code, 0) + self.assertEqual(len(calls), 1) + self.assertIn("Autonomous mode: waiting_for_database", output.getvalue()) + self.assertIn("Next actions: wait_for_database_recovery", output.getvalue()) + self.assertIn("Action plan [no-db]: wait_for_database_recovery", output.getvalue()) + self.assertIn("Database available: False", output.getvalue()) + + def test_require_db_returns_failure_when_database_is_unavailable(self) -> None: + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_autonomous_cycle.run_autonomous_cycle( + env={"AUTONOMOUS_REQUIRE_DB": "1"}, + runner=lambda command, **kwargs: SimpleNamespace(returncode=0, stdout='{"passed": true}', stderr=""), + db_probe=lambda: {"available": False, "error_type": "OperationalError", "error": "down"}, + ) + + self.assertEqual(exit_code, 2) + + def test_runs_pipeline_when_database_is_available(self) -> None: + calls = [] + pipeline_env = {} + + def fake_runner(command, **kwargs): + calls.append(command) + if command[-1] == "scripts/run_pipeline.py": + pipeline_env.update(kwargs["env"]) + return SimpleNamespace(returncode=0, stdout='{"ok": true}', stderr="") + if command[-1] == "scripts/run_database_readiness.py": + return SimpleNamespace(returncode=0, stdout='{"passed": true}', stderr="") + return SimpleNamespace(returncode=0, stdout='{"passed": true, "checks": []}', stderr="") + + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_autonomous_cycle.run_autonomous_cycle( + env={}, + runner=fake_runner, + db_probe=lambda: {"available": True, "value": 1}, + schema_probe=lambda: {"ready": True, "missing_tables": []}, + ) + + self.assertEqual(exit_code, 0) + self.assertEqual(calls[-1][-1], "scripts/run_pipeline.py") + self.assertEqual(pipeline_env["BACKTEST_REQUIRE_QUALITY_GATE"], "1") + self.assertEqual(pipeline_env["PIPELINE_OUTPUT_JSON"], "1") + self.assertIn("Autonomous mode: pipeline_completed", output.getvalue()) + + def test_preserves_explicit_pipeline_quality_gate_override(self) -> None: + pipeline_env = {} + + def fake_runner(command, **kwargs): + if command[-1] == "scripts/run_pipeline.py": + pipeline_env.update(kwargs["env"]) + return SimpleNamespace(returncode=0, stdout='{"ok": true}', stderr="") + if command[-1] == "scripts/run_database_readiness.py": + return SimpleNamespace(returncode=0, stdout='{"passed": true}', stderr="") + return SimpleNamespace(returncode=0, stdout='{"passed": true, "checks": []}', stderr="") + + with contextlib.redirect_stdout(io.StringIO()): + exit_code = run_autonomous_cycle.run_autonomous_cycle( + env={"BACKTEST_REQUIRE_QUALITY_GATE": "0"}, + runner=fake_runner, + db_probe=lambda: {"available": True, "value": 1}, + schema_probe=lambda: {"ready": True, "missing_tables": [], "missing_columns": {}}, + ) + + self.assertEqual(exit_code, 0) + self.assertEqual(pipeline_env["BACKTEST_REQUIRE_QUALITY_GATE"], "0") + + def test_reports_pipeline_failure_when_guarded_pipeline_fails(self) -> None: + def fake_runner(command, **kwargs): + if command[-1] == "scripts/run_pipeline.py": + return SimpleNamespace(returncode=2, stdout="Database unavailable for pipeline sample: OperationalError\n", stderr="") + if command[-1] == "scripts/run_database_readiness.py": + return SimpleNamespace(returncode=0, stdout='{"passed": true}', stderr="") + return SimpleNamespace(returncode=0, stdout='{"passed": true, "checks": []}', stderr="") + + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_autonomous_cycle.run_autonomous_cycle( + env={}, + runner=fake_runner, + db_probe=lambda: {"available": True, "value": 1}, + schema_probe=lambda: {"ready": True, "missing_tables": [], "missing_columns": {}}, + ) + + self.assertEqual(exit_code, 2) + self.assertIn("Autonomous mode: pipeline_failed", output.getvalue()) + self.assertIn("inspect_pipeline_output_and_quality_gates", output.getvalue()) + + def test_retries_database_probe_until_available(self) -> None: + probe_results = [ + {"available": False, "error_type": "OperationalError", "error": "down"}, + {"available": True, "value": 1}, + ] + sleeps = [] + + def fake_probe(): + return probe_results.pop(0) + + calls = [] + + def fake_runner(command, **kwargs): + calls.append(command) + if command[-1] == "scripts/run_pipeline.py": + return SimpleNamespace(returncode=0, stdout='{"ok": true}', stderr="") + if command[-1] == "scripts/run_database_readiness.py": + return SimpleNamespace(returncode=0, stdout='{"passed": true}', stderr="") + return SimpleNamespace(returncode=0, stdout='{"passed": true, "checks": []}', stderr="") + + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_autonomous_cycle.run_autonomous_cycle( + env={"AUTONOMOUS_MAX_DB_PROBES": "2", "AUTONOMOUS_PROBE_INTERVAL_SECONDS": "0.5"}, + runner=fake_runner, + db_probe=fake_probe, + schema_probe=lambda: {"ready": True, "missing_tables": []}, + sleeper=sleeps.append, + ) + + self.assertEqual(exit_code, 0) + self.assertEqual(sleeps, [0.5]) + self.assertEqual(calls[-1][-1], "scripts/run_pipeline.py") + self.assertIn("Autonomous mode: pipeline_completed", output.getvalue()) + + def test_stops_when_database_schema_is_not_ready(self) -> None: + calls = [] + + def fake_runner(command, **kwargs): + calls.append(command) + return SimpleNamespace(returncode=0, stdout='{"passed": true, "checks": []}', stderr="") + + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_autonomous_cycle.run_autonomous_cycle( + env={"AUTONOMOUS_APPLY_MIGRATIONS": "0"}, + runner=fake_runner, + db_probe=lambda: {"available": True, "value": 1}, + schema_probe=lambda: {"ready": False, "missing_tables": ["candles"]}, + ) + + self.assertEqual(exit_code, 2) + self.assertEqual(len(calls), 1) + self.assertIn("Autonomous mode: database_schema_not_ready", output.getvalue()) + self.assertIn("Missing tables: candles", output.getvalue()) + + def test_runs_migrations_and_continues_when_schema_becomes_ready(self) -> None: + calls = [] + schema_results = [ + {"ready": False, "missing_tables": ["candles"], "missing_columns": {}}, + {"ready": True, "missing_tables": [], "missing_columns": {}}, + ] + + def fake_runner(command, **kwargs): + calls.append(command) + if command[-1] == "scripts/run_database_readiness.py": + return SimpleNamespace(returncode=0, stdout='{"passed": true}', stderr="") + if command[-1] == "scripts/run_pipeline.py": + return SimpleNamespace(returncode=0, stdout='{"ok": true}', stderr="") + return SimpleNamespace(returncode=0, stdout='{"passed": true, "checks": []}', stderr="") + + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_autonomous_cycle.run_autonomous_cycle( + env={}, + runner=fake_runner, + db_probe=lambda: {"available": True, "value": 1}, + schema_probe=lambda: schema_results.pop(0), + ) + + self.assertEqual(exit_code, 0) + self.assertIn("scripts/run_migrations.py", [command[-1] for command in calls]) + self.assertEqual(calls[-1][-1], "scripts/run_pipeline.py") + self.assertIn("Autonomous mode: pipeline_completed", output.getvalue()) + + def test_migration_failure_stops_before_pipeline(self) -> None: + calls = [] + + def fake_runner(command, **kwargs): + calls.append(command) + if command[-1] == "scripts/run_migrations.py": + return SimpleNamespace(returncode=3, stdout="", stderr="migration failed") + return SimpleNamespace(returncode=0, stdout='{"passed": true, "checks": []}', stderr="") + + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_autonomous_cycle.run_autonomous_cycle( + env={}, + runner=fake_runner, + db_probe=lambda: {"available": True, "value": 1}, + schema_probe=lambda: {"ready": False, "missing_tables": ["candles"], "missing_columns": {}}, + ) + + self.assertEqual(exit_code, 3) + self.assertIn("Autonomous mode: migration_failed", output.getvalue()) + self.assertIn("inspect_migration_output_and_schema", output.getvalue()) + self.assertEqual(calls[-1][-1], "scripts/run_migrations.py") + + def test_stops_when_database_readiness_fails(self) -> None: + calls = [] + + def fake_runner(command, **kwargs): + calls.append(command) + if command[-1] == "scripts/run_database_readiness.py": + return SimpleNamespace(returncode=2, stdout='{"passed": false, "reasons": ["not_enough_candles"]}', stderr="") + return SimpleNamespace(returncode=0, stdout='{"passed": true, "checks": []}', stderr="") + + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_autonomous_cycle.run_autonomous_cycle( + env={}, + runner=fake_runner, + db_probe=lambda: {"available": True, "value": 1}, + schema_probe=lambda: {"ready": True, "missing_tables": [], "missing_columns": {}}, + ) + + self.assertEqual(exit_code, 2) + self.assertEqual(calls[-1][-1], "scripts/run_database_readiness.py") + self.assertIn("Autonomous mode: database_readiness_failed", output.getvalue()) + + def test_runs_backfill_and_continues_when_only_candles_are_missing(self) -> None: + calls = [] + backfill_env = {} + readiness_results = [ + SimpleNamespace( + returncode=2, + stdout='{"passed": false, "reasons": ["not_enough_candles"], "next_actions": ["run_okx_backfill_or_load_replay_sample"]}', + stderr="", + ), + SimpleNamespace(returncode=0, stdout='{"passed": true, "reasons": []}', stderr=""), + ] + + def fake_runner(command, **kwargs): + calls.append(command) + if command[-1] == "scripts/run_database_readiness.py": + return readiness_results.pop(0) + if command[-1] == "scripts/run_okx_backfill.py": + backfill_env.update(kwargs["env"]) + return SimpleNamespace(returncode=0, stdout='{"passed": true, "total_fetched": 100, "total_wrote": 100}', stderr="") + if command[-1] == "scripts/run_pipeline.py": + return SimpleNamespace(returncode=0, stdout='{"ok": true}', stderr="") + return SimpleNamespace(returncode=0, stdout='{"passed": true, "checks": []}', stderr="") + + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_autonomous_cycle.run_autonomous_cycle( + env={}, + runner=fake_runner, + db_probe=lambda: {"available": True, "value": 1}, + schema_probe=lambda: {"ready": True, "missing_tables": [], "missing_columns": {}}, + ) + + self.assertEqual(exit_code, 0) + self.assertEqual([command[-1] for command in calls].count("scripts/run_database_readiness.py"), 2) + self.assertIn("scripts/run_okx_backfill.py", [command[-1] for command in calls]) + self.assertEqual(backfill_env["OKX_BACKFILL_OUTPUT_JSON"], "1") + self.assertEqual(calls[-1][-1], "scripts/run_pipeline.py") + self.assertIn("Autonomous mode: pipeline_completed", output.getvalue()) + + def test_backfill_failure_stops_before_pipeline(self) -> None: + calls = [] + + def fake_runner(command, **kwargs): + calls.append(command) + if command[-1] == "scripts/run_database_readiness.py": + return SimpleNamespace( + returncode=2, + stdout='{"passed": false, "reasons": ["not_enough_candles"], "next_actions": ["run_okx_backfill_or_load_replay_sample"]}', + stderr="", + ) + if command[-1] == "scripts/run_okx_backfill.py": + return SimpleNamespace(returncode=7, stdout="", stderr="network failed") + return SimpleNamespace(returncode=0, stdout='{"passed": true, "checks": []}', stderr="") + + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_autonomous_cycle.run_autonomous_cycle( + env={}, + runner=fake_runner, + db_probe=lambda: {"available": True, "value": 1}, + schema_probe=lambda: {"ready": True, "missing_tables": [], "missing_columns": {}}, + ) + + self.assertEqual(exit_code, 7) + self.assertIn("Autonomous mode: backfill_failed", output.getvalue()) + self.assertIn("inspect_backfill_output_and_data_readiness", output.getvalue()) + self.assertNotIn("scripts/run_pipeline.py", [command[-1] for command in calls]) + + def test_backfill_is_not_used_for_blocked_readiness(self) -> None: + self.assertFalse(run_autonomous_cycle.readiness_needs_candle_backfill({"reasons": ["schema_not_ready", "not_enough_candles"]})) + self.assertTrue(run_autonomous_cycle.readiness_needs_candle_backfill({"reasons": ["not_enough_candles"]})) + self.assertTrue(run_autonomous_cycle.readiness_needs_candle_backfill({"reasons": ["candles_too_stale"]})) + + def test_json_output_is_machine_readable(self) -> None: + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_autonomous_cycle.run_autonomous_cycle( + env={"AUTONOMOUS_OUTPUT_JSON": "1"}, + runner=lambda command, **kwargs: SimpleNamespace(returncode=0, stdout='{"passed": true, "checks": []}', stderr=""), + db_probe=lambda: {"available": False, "error_type": "OperationalError", "error": "down"}, + ) + + summary = json.loads(output.getvalue()) + self.assertEqual(exit_code, 0) + self.assertEqual(summary["mode"], "waiting_for_database") + self.assertTrue(summary["passed"]) + self.assertEqual(summary["next_actions"], ["wait_for_database_recovery"]) + self.assertEqual(summary["action_plan"][0]["action"], "wait_for_database_recovery") + self.assertFalse(summary["action_plan"][0]["requires_database"]) + self.assertEqual(summary["action_plan"][0]["command"][-1], "scripts/run_autonomous_cycle.py") + self.assertEqual(summary["steps"][0]["name"], "offline_checks") + + def test_offline_failure_stops_cycle(self) -> None: + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_autonomous_cycle.run_autonomous_cycle( + env={}, + runner=lambda command, **kwargs: SimpleNamespace(returncode=5, stdout="", stderr="failed"), + db_probe=lambda: {"available": True, "value": 1}, + ) + + self.assertEqual(exit_code, 5) + self.assertIn("Autonomous mode: offline_checks_failed", output.getvalue()) + + def test_cycle_next_actions_use_readiness_summary_when_available(self) -> None: + summary = { + "mode": "database_readiness_failed", + "steps": [ + { + "name": "database_readiness", + "summary": {"next_actions": ["run_okx_backfill_or_load_replay_sample"]}, + } + ], + } + + self.assertEqual( + run_autonomous_cycle.build_cycle_next_actions(summary), + ["run_okx_backfill_or_load_replay_sample"], + ) + + def test_cycle_action_plan_maps_database_actions(self) -> None: + plan = run_autonomous_cycle.build_cycle_action_plan( + ["run_migrations_before_pipeline", "run_guarded_pipeline_when_ready"] + ) + + self.assertEqual(plan[0]["command"][-1], "scripts/run_migrations.py") + self.assertTrue(plan[0]["requires_database"]) + self.assertEqual(plan[1]["command"][-1], "scripts/run_pipeline.py") + self.assertTrue(plan[1]["requires_database"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_backtest_replay.py b/tests/unit/test_backtest_replay.py new file mode 100644 index 0000000..2d049ab --- /dev/null +++ b/tests/unit/test_backtest_replay.py @@ -0,0 +1,304 @@ +import os +import contextlib +import io +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") + +from src.services.backtest import detect_confirmed_swings_for_replay, iter_historical_replay, load_replay_candles, validate_replay_candles +from scripts import run_replay_file + + +class HistoricalReplayTests(unittest.TestCase): + def test_frames_only_expose_known_candles(self) -> None: + candles = [_candle(idx, high=100 + idx, low=90 - idx) for idx in range(4)] + + frames = list(iter_historical_replay(candles, left_bars=1, right_bars=1)) + + self.assertEqual(len(frames), 4) + for frame in frames: + self.assertEqual(len(frame.known_candles), frame.index + 1) + self.assertEqual(frame.known_candles[-1], frame.candle) + + def test_swing_confirms_only_after_right_window_exists(self) -> None: + candles = [ + _candle(0, high=100, low=90), + _candle(1, high=105, low=91), + _candle(2, high=101, low=92), + ] + + frames = list(iter_historical_replay(candles, left_bars=1, right_bars=1)) + + self.assertEqual(frames[1].new_swings, ()) + self.assertEqual(len(frames[2].new_swings), 1) + swing = frames[2].new_swings[0] + self.assertEqual(swing.kind, "swing_high") + self.assertEqual(swing.source_index, 1) + self.assertEqual(swing.confirmed_at_index, 2) + + def test_detects_confirmed_highs_and_lows_without_future_leakage(self) -> None: + candles = [ + _candle(0, high=100, low=95), + _candle(1, high=105, low=96), + _candle(2, high=101, low=94), + _candle(3, high=102, low=90), + _candle(4, high=103, low=93), + ] + + swings = detect_confirmed_swings_for_replay(candles, left_bars=1, right_bars=1) + + self.assertEqual([(s.kind, s.source_index, s.confirmed_at_index) for s in swings], [("swing_high", 1, 2), ("swing_low", 3, 4)]) + + def test_validates_replay_candle_integrity(self) -> None: + candles = [ + _candle(0, high=100, low=95), + _candle(0, high=101, low=96), + {"ts_open": 2, "open": 110, "high": 105, "low": 100, "close": 104}, + {"ts_open": 3, "open": 100, "high": 100, "low": 101, "close": 106}, + {"ts_open": 4, "open": None, "high": 105, "low": 101, "close": 103}, + ] + + result = validate_replay_candles(candles) + + self.assertFalse(result.is_valid) + self.assertIn("duplicate_ts_open:1", result.errors) + self.assertIn("non_increasing_ts_open:1", result.errors) + self.assertIn("open_outside_range:2", result.errors) + self.assertIn("high_below_low:3", result.errors) + self.assertIn("close_outside_range:3", result.errors) + self.assertIn("missing_open:4", result.errors) + + def test_validates_replay_price_quality(self) -> None: + candles = [ + {"ts_open": 1, "open": "bad", "high": 101, "low": 99, "close": 100}, + {"ts_open": 2, "open": 100, "high": float("inf"), "low": 99, "close": 100}, + {"ts_open": 3, "open": 100, "high": 101, "low": -1, "close": 100}, + ] + + result = validate_replay_candles(candles) + + self.assertFalse(result.is_valid) + self.assertIn("non_numeric_open:0", result.errors) + self.assertIn("non_finite_high:1", result.errors) + self.assertIn("non_positive_low:2", result.errors) + + def test_load_replay_candles_reports_non_numeric_prices(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "candles.jsonl" + path.write_text('{"ts_open":1,"open":"bad","high":101,"low":99,"close":100}\n', encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "non_numeric_open:0"): + load_replay_candles(path) + + def test_replay_rejects_invalid_candles_before_iterating(self) -> None: + candles = [{"ts_open": 1, "open": 100, "high": 99, "low": 101, "close": 100}] + + with self.assertRaisesRegex(ValueError, "invalid_replay_candles"): + list(iter_historical_replay(candles)) + + def test_loads_replay_candles_from_json_items_payload(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "candles.json" + path.write_text('{"items":[{"ts_open":"1","open":"100","high":"101","low":"99","close":"100.5"}]}', encoding="utf-8") + + candles = load_replay_candles(path) + + self.assertEqual(candles[0]["ts_open"], 1) + self.assertEqual(candles[0]["open"], 100.0) + + def test_loads_replay_candles_from_jsonl(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "candles.jsonl" + path.write_text( + '{"ts_open":1,"open":100,"high":101,"low":99,"close":100}\n' + '{"ts_open":2,"open":101,"high":102,"low":100,"close":101}\n', + encoding="utf-8", + ) + + candles = load_replay_candles(path) + + self.assertEqual(len(candles), 2) + self.assertEqual(candles[1]["high"], 102.0) + + def test_loads_replay_candles_from_csv(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "candles.csv" + path.write_text("ts_open,open,high,low,close\n1,100,101,99,100\n2,101,102,100,101\n", encoding="utf-8") + + candles = load_replay_candles(path) + + self.assertEqual(len(candles), 2) + self.assertEqual(candles[0]["low"], 99.0) + + def test_fixture_replay_sample_has_two_confirmed_swings(self) -> None: + fixture = Path(__file__).resolve().parents[1] / "fixtures" / "offline_replay_sample.jsonl" + + candles = load_replay_candles(fixture) + swings = detect_confirmed_swings_for_replay(candles, left_bars=1, right_bars=1) + + self.assertEqual(len(candles), 5) + self.assertEqual([(s.kind, s.source_index, s.confirmed_at_index) for s in swings], [("swing_high", 1, 2), ("swing_low", 3, 4)]) + + def test_load_replay_candles_rejects_unsupported_format(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "candles.txt" + path.write_text("", encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "unsupported_replay_candle_file"): + load_replay_candles(path) + + def test_run_replay_file_reports_summary(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "candles.jsonl" + path.write_text( + '{"ts_open":1,"open":97,"high":100,"low":95,"close":98}\n' + '{"ts_open":2,"open":100,"high":105,"low":96,"close":102}\n' + '{"ts_open":3,"open":99,"high":101,"low":94,"close":96}\n', + encoding="utf-8", + ) + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_replay_file.main([str(path)], env={"REPLAY_LEFT_BARS": "1", "REPLAY_RIGHT_BARS": "1"}) + + self.assertEqual(exit_code, 0) + self.assertIn("Replay candles: 3", output.getvalue()) + self.assertIn("Replay sample: first_ts=1 last_ts=3", output.getvalue()) + self.assertIn("Replay confirmed swings: 1", output.getvalue()) + self.assertIn("Replay quality gate passed: True", output.getvalue()) + + def test_run_replay_file_can_emit_json_summary(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "candles.jsonl" + path.write_text( + '{"ts_open":1,"open":97,"high":100,"low":95,"close":98}\n' + '{"ts_open":2,"open":100,"high":105,"low":96,"close":102}\n' + '{"ts_open":3,"open":99,"high":101,"low":94,"close":96}\n', + encoding="utf-8", + ) + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_replay_file.main( + [str(path)], + env={ + "REPLAY_LEFT_BARS": "1", + "REPLAY_RIGHT_BARS": "1", + "REPLAY_OUTPUT_JSON": "1", + }, + ) + + summary = json.loads(output.getvalue()) + self.assertEqual(exit_code, 0) + self.assertEqual(summary["candle_count"], 3) + self.assertEqual(summary["sample"]["first_ts_open"], 1) + self.assertEqual(summary["sample"]["last_ts_open"], 3) + self.assertEqual(summary["sample"]["highest_high"], 105.0) + self.assertEqual(summary["sample"]["lowest_low"], 94.0) + self.assertAlmostEqual(summary["sample"]["max_gap_minutes"], 1 / 60) + self.assertEqual(summary["swing_counts"]["swing_high"], 1) + self.assertTrue(summary["quality_gate"]["passed"]) + + def test_replay_sample_summary_handles_empty_samples(self) -> None: + summary = run_replay_file.build_replay_sample_summary([]) + + self.assertIsNone(summary["first_ts_open"]) + self.assertIsNone(summary["close_return_pct"]) + self.assertIsNone(summary["max_gap_minutes"]) + + def test_run_replay_file_blocks_when_gap_gate_fails(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "candles.jsonl" + path.write_text( + '{"ts_open":"2026-04-29T08:00:00+00:00","open":97,"high":100,"low":95,"close":98}\n' + '{"ts_open":"2026-04-29T08:01:00+00:00","open":100,"high":105,"low":96,"close":102}\n' + '{"ts_open":"2026-04-29T08:10:00+00:00","open":99,"high":101,"low":94,"close":96}\n', + encoding="utf-8", + ) + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_replay_file.main( + [str(path)], + env={ + "REPLAY_LEFT_BARS": "1", + "REPLAY_RIGHT_BARS": "1", + "REPLAY_MAX_GAP_MINUTES": "2", + }, + ) + + self.assertEqual(exit_code, 2) + self.assertIn("Replay max gap minutes: 9.0", output.getvalue()) + self.assertIn("candle_gap_too_large", output.getvalue()) + + def test_run_replay_file_blocks_when_replay_gate_fails(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "candles.jsonl" + path.write_text( + '{"ts_open":1,"open":97,"high":100,"low":95,"close":98}\n' + '{"ts_open":2,"open":100,"high":105,"low":96,"close":102}\n' + '{"ts_open":3,"open":99,"high":101,"low":94,"close":96}\n', + encoding="utf-8", + ) + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_replay_file.main( + [str(path)], + env={ + "REPLAY_LEFT_BARS": "1", + "REPLAY_RIGHT_BARS": "1", + "REPLAY_MIN_CANDLES": "10", + "REPLAY_MIN_SWINGS": "2", + }, + ) + + self.assertEqual(exit_code, 2) + self.assertIn("candle_sample_too_small", output.getvalue()) + self.assertIn("swing_sample_too_small", output.getvalue()) + + def test_run_replay_file_rejects_missing_path(self) -> None: + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_replay_file.main([]) + + self.assertEqual(exit_code, 2) + self.assertIn("Usage:", output.getvalue()) + + def test_run_replay_file_empty_env_ignores_ambient_json_setting(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "candles.jsonl" + path.write_text( + '{"ts_open":1,"open":97,"high":100,"low":95,"close":98}\n' + '{"ts_open":2,"open":100,"high":105,"low":96,"close":102}\n' + '{"ts_open":3,"open":99,"high":101,"low":94,"close":96}\n', + encoding="utf-8", + ) + output = io.StringIO() + + with patch.dict(os.environ, {"REPLAY_OUTPUT_JSON": "1"}, clear=False), contextlib.redirect_stdout(output): + exit_code = run_replay_file.main([str(path)], env={}) + + self.assertEqual(exit_code, 0) + self.assertIn("Replay candles: 3", output.getvalue()) + self.assertNotIn('"candle_count"', output.getvalue()) + + +def _candle(idx: int, high: float, low: float) -> dict: + return { + "ts_open": idx, + "ts_close": idx + 1, + "open": (high + low) / 2, + "high": high, + "low": low, + "close": (high + low) / 2, + } + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_backtest_report_service.py b/tests/unit/test_backtest_report_service.py index 80adef1..b93fcf5 100644 --- a/tests/unit/test_backtest_report_service.py +++ b/tests/unit/test_backtest_report_service.py @@ -4,7 +4,7 @@ from datetime import datetime, timezone os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") -from src.services.backtest import BacktestOutcome, summarize_backtest_outcomes +from src.services.backtest import BacktestOutcome, BacktestQualityGate, summarize_backtest_outcomes from src.services.reports import BacktestReportService @@ -92,6 +92,36 @@ class BacktestSummaryTests(unittest.TestCase): self.assertEqual(summary["by_session"]["NY_PM"]["breakevens"], 1) self.assertEqual(summary["by_side"]["buy"]["count"], 1) + self.assertIn("quality_gate", summary) + self.assertFalse(summary["quality_gate"]["passed"]) + + def test_report_service_accepts_custom_quality_gate(self) -> None: + outcomes = [ + BacktestOutcome( + signal_id=10, + outcome="win", + mfe=2.0, + mae=0.5, + tp1_hit=True, + tp2_hit=True, + invalidated_before_entry=False, + error_tags=[], + meta={"signal_side": "buy", "session_codes": ["LONDON"], "weekday": "Friday", "r_multiple": 2.0}, + ) + ] + + service = BacktestReportService() + summary = service.build_summary( + outcomes, + quality_gate=BacktestQualityGate( + min_total_signals=1, + min_executed_signals=1, + min_profit_factor_r=1.0, + max_drawdown_r=1.0, + ), + ) + + self.assertTrue(summary["quality_gate"]["passed"]) if __name__ == "__main__": diff --git a/tests/unit/test_backtest_script.py b/tests/unit/test_backtest_script.py new file mode 100644 index 0000000..3ec39d6 --- /dev/null +++ b/tests/unit/test_backtest_script.py @@ -0,0 +1,198 @@ +import os +import contextlib +import io +import json +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_backtest +from scripts.run_backtest import build_cost_config_from_env, build_quality_gate_from_env, build_require_quality_gate_from_env, run_backtest_once +from src.services.backtest import BacktestOutcome + + +class RunBacktestScriptTests(unittest.TestCase): + def test_build_cost_config_from_env_uses_zero_defaults(self) -> None: + config = build_cost_config_from_env({}) + + self.assertEqual(config.fee_rate, 0.0) + self.assertEqual(config.slippage_rate, 0.0) + self.assertEqual(config.fixed_cost_r, 0.0) + + def test_empty_env_overrides_ambient_backtest_settings(self) -> None: + with patch.dict( + os.environ, + { + "BACKTEST_FEE_RATE": "0.02", + "BACKTEST_REQUIRE_QUALITY_GATE": "1", + "BACKTEST_MIN_TOTAL_SIGNALS": "999", + "BACKTEST_MIN_EXECUTED_SIGNALS": "888", + }, + clear=False, + ): + config = build_cost_config_from_env({}) + require_gate = build_require_quality_gate_from_env({}) + gate = build_quality_gate_from_env({}) + + self.assertEqual(config.fee_rate, 0.0) + self.assertFalse(require_gate) + self.assertNotEqual(gate.min_total_signals, 999) + self.assertNotEqual(gate.min_executed_signals, 888) + + def test_build_cost_config_from_env_reads_cost_values(self) -> None: + config = build_cost_config_from_env( + { + "BACKTEST_FEE_RATE": "0.0005", + "BACKTEST_SLIPPAGE_RATE": "0.0007", + "BACKTEST_FIXED_COST_R": "0.05", + } + ) + + self.assertEqual(config.fee_rate, 0.0005) + self.assertEqual(config.slippage_rate, 0.0007) + self.assertEqual(config.fixed_cost_r, 0.05) + + def test_build_require_quality_gate_from_env(self) -> None: + self.assertFalse(build_require_quality_gate_from_env({})) + self.assertFalse(build_require_quality_gate_from_env({"BACKTEST_REQUIRE_QUALITY_GATE": "0"})) + self.assertTrue(build_require_quality_gate_from_env({"BACKTEST_REQUIRE_QUALITY_GATE": "true"})) + self.assertTrue(build_require_quality_gate_from_env({"BACKTEST_REQUIRE_QUALITY_GATE": "1"})) + + def test_build_quality_gate_from_env_reads_thresholds(self) -> None: + gate = build_quality_gate_from_env( + { + "BACKTEST_MIN_TOTAL_SIGNALS": "50", + "BACKTEST_MIN_EXECUTED_SIGNALS": "20", + "BACKTEST_MIN_EXPECTANCY_R": "0.1", + "BACKTEST_MIN_PROFIT_FACTOR_R": "1.5", + "BACKTEST_MAX_DRAWDOWN_R": "6.0", + "BACKTEST_MAX_CONSECUTIVE_LOSSES": "3", + } + ) + + self.assertEqual(gate.min_total_signals, 50) + self.assertEqual(gate.min_executed_signals, 20) + self.assertEqual(gate.min_expectancy_r, 0.1) + self.assertEqual(gate.min_profit_factor_r, 1.5) + self.assertEqual(gate.max_drawdown_r, 6.0) + self.assertEqual(gate.max_consecutive_losses, 3) + + def test_run_backtest_once_allows_failed_quality_gate_when_not_required(self) -> None: + service = FakeBacktestService([]) + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_backtest_once(service=service, instrument_id=3, limit=5, require_quality_gate=False) + + self.assertEqual(exit_code, 0) + self.assertEqual(service.calls[0]["instrument_id"], 3) + self.assertEqual(service.calls[0]["limit"], 5) + self.assertIsNone(service.calls[0]["quality_gate"]) + self.assertIn("Backtest quality gate passed: False", output.getvalue()) + + def test_run_backtest_once_blocks_failed_quality_gate_when_required(self) -> None: + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_backtest_once(service=FakeBacktestService([]), require_quality_gate=True) + + self.assertEqual(exit_code, 2) + self.assertIn("sample_size_too_small", output.getvalue()) + + def test_run_backtest_once_passes_when_required_quality_gate_is_healthy(self) -> None: + outcomes = [_build_win_outcome(signal_id=index) for index in range(100)] + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_backtest_once(service=FakeBacktestService(outcomes), require_quality_gate=True) + + self.assertEqual(exit_code, 0) + self.assertIn("Backtest quality gate passed: True", output.getvalue()) + + def test_run_backtest_once_outputs_json_success_summary(self) -> None: + outcomes = [_build_win_outcome(signal_id=index) for index in range(100)] + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_backtest_once(service=FakeBacktestService(outcomes), require_quality_gate=True, output_json=True) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 0) + self.assertTrue(payload["passed"]) + self.assertTrue(payload["quality_gate_required"]) + self.assertEqual(payload["backtest_run_id"], 99) + self.assertEqual(payload["result_count"], 100) + self.assertTrue(payload["quality_gate"]["passed"]) + self.assertEqual(payload["summary"]["executed_signals"], 100) + + def test_run_backtest_once_outputs_json_quality_failure(self) -> None: + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_backtest_once(service=FakeBacktestService([]), require_quality_gate=True, output_json=True) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 2) + self.assertFalse(payload["passed"]) + self.assertFalse(payload["quality_gate"]["passed"]) + self.assertIn("sample_size_too_small", payload["quality_gate"]["reasons"]) + + def test_main_reports_database_unavailable(self) -> None: + output = io.StringIO() + + with patch("scripts.run_backtest.BacktestService.run_backtest", side_effect=OperationalError("stmt", {}, Exception())), contextlib.redirect_stdout(output): + exit_code = run_backtest.main() + + self.assertEqual(exit_code, 2) + self.assertIn("Database unavailable for backtest sample: OperationalError", output.getvalue()) + + def test_main_reports_database_unavailable_as_json(self) -> None: + output = io.StringIO() + + with patch("scripts.run_backtest.BacktestService.run_backtest", side_effect=OperationalError("stmt", {}, Exception())), contextlib.redirect_stdout(output): + exit_code = run_backtest.main(env={"BACKTEST_OUTPUT_JSON": "1"}) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 2) + self.assertFalse(payload["passed"]) + self.assertEqual(payload["error_type"], "OperationalError") + self.assertIsNone(payload["quality_gate"]) + + +class FakeBacktestService: + def __init__(self, outcomes: list[BacktestOutcome]) -> None: + self.outcomes = outcomes + self.calls = [] + + def run_backtest(self, **kwargs): + self.calls.append(kwargs) + return 99, self.outcomes + + +def _build_win_outcome(signal_id: int) -> BacktestOutcome: + return BacktestOutcome( + signal_id=signal_id, + outcome="win", + mfe=2.0, + mae=0.0, + tp1_hit=True, + tp2_hit=True, + invalidated_before_entry=False, + error_tags=[], + meta={ + "signal_side": "buy", + "session_codes": ["LONDON"], + "weekday": "Wednesday", + "mfe_r": 2.0, + "mae_r": 0.0, + "cost_r": 0.0, + "r_multiple": 2.0, + }, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_backtest_service.py b/tests/unit/test_backtest_service.py index aea523c..27e16fc 100644 --- a/tests/unit/test_backtest_service.py +++ b/tests/unit/test_backtest_service.py @@ -4,7 +4,21 @@ from datetime import datetime, timezone os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") -from src.services.backtest import BacktestSignal, evaluate_signal_outcome +from src.services.backtest import ( + BacktestCostConfig, + BacktestOutcome, + BacktestQualityGate, + BacktestService, + BacktestSignal, + build_cumulative_r_curve, + compute_max_consecutive_losses, + compute_max_drawdown_r, + compute_outcome_r_multiple, + estimate_trade_cost_r, + evaluate_backtest_quality, + evaluate_signal_outcome, + summarize_backtest_outcomes, +) from src.repositories.backtest_repository import BacktestRepository @@ -60,6 +74,8 @@ class BacktestOutcomeTests(unittest.TestCase): stop_loss=103.0, tp1=99.0, tp2=97.0, + rr_tp1=0.75, + rr_tp2=2.5, ) outcome = evaluate_signal_outcome( signal, @@ -72,8 +88,244 @@ class BacktestOutcomeTests(unittest.TestCase): self.assertEqual(outcome.outcome, "win") self.assertTrue(outcome.tp1_hit) self.assertTrue(outcome.tp2_hit) + self.assertAlmostEqual(outcome.meta["mfe_r"], 1.6) + self.assertAlmostEqual(outcome.meta["mae_r"], 0.0) + self.assertEqual(outcome.meta["r_multiple"], 2.5) self.assertEqual(outcome.error_tags, []) + def test_outcome_r_multiple_applies_cost_to_executed_results(self) -> None: + self.assertAlmostEqual(compute_outcome_r_multiple("win", rr_tp2=2.5, cost_r=0.1), 2.4) + self.assertAlmostEqual(compute_outcome_r_multiple("loss", cost_r=0.1), -1.1) + self.assertEqual(compute_outcome_r_multiple("missed", cost_r=0.1), 0.0) + + def test_cost_model_reduces_executed_r_multiple(self) -> None: + signal = BacktestSignal( + signal_id=4, + side="sell", + entry_low=100.0, + entry_high=101.0, + stop_loss=103.0, + tp1=99.0, + tp2=97.0, + rr_tp1=0.75, + rr_tp2=2.5, + ) + cost_config = BacktestCostConfig(fee_rate=0.0005, slippage_rate=0.0005, fixed_cost_r=0.05) + expected_cost_r = estimate_trade_cost_r( + signal=signal, + outcome="win", + entry_price=100.5, + entry_triggered=True, + cost_config=cost_config, + ) + + outcome = evaluate_signal_outcome( + signal, + [ + {"high": 100.5, "low": 99.5}, + {"high": 100.0, "low": 96.5}, + ], + cost_config=cost_config, + ) + + self.assertAlmostEqual(expected_cost_r, 0.129) + self.assertAlmostEqual(outcome.meta["cost_r"], expected_cost_r) + self.assertAlmostEqual(outcome.meta["r_multiple"], 2.5 - expected_cost_r) + self.assertEqual(outcome.meta["cost_model"]["fee_rate"], 0.0005) + + def test_summary_includes_expectancy_and_profit_factor_r(self) -> None: + outcomes = [ + BacktestOutcome( + signal_id=1, + outcome="win", + mfe=3.0, + mae=0.5, + tp1_hit=True, + 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}, + ), + BacktestOutcome( + signal_id=2, + outcome="loss", + mfe=0.2, + mae=1.0, + tp1_hit=False, + 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}, + ), + BacktestOutcome( + signal_id=3, + outcome="loss", + mfe=0.0, + mae=0.0, + tp1_hit=False, + 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}, + ), + BacktestOutcome( + signal_id=4, + outcome="missed", + mfe=0.0, + mae=0.0, + tp1_hit=False, + 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}, + ), + ] + + summary = summarize_backtest_outcomes(outcomes) + + self.assertAlmostEqual(summary["total_r"], 0.2) + self.assertAlmostEqual(summary["expectancy_r"], 0.05) + self.assertAlmostEqual(summary["profit_factor_r"], 2.4 / 2.2) + self.assertEqual(summary["executed_signals"], 3) + self.assertAlmostEqual(summary["executed_rate"], 0.75) + self.assertAlmostEqual(summary["executed_expectancy_r"], 0.2 / 3) + self.assertAlmostEqual(summary["executed_profit_factor_r"], 2.4 / 2.2) + self.assertAlmostEqual(summary["avg_mfe_r"], 0.725) + self.assertAlmostEqual(summary["avg_mae_r"], 0.675) + self.assertAlmostEqual(summary["max_drawdown_r"], 2.2) + self.assertEqual(summary["max_consecutive_losses"], 2) + self.assertEqual(summary["cumulative_r_curve"], [2.4, 1.2999999999999998, 0.19999999999999973, 0.19999999999999973]) + self.assertAlmostEqual(summary["total_cost_r"], 0.3) + self.assertAlmostEqual(summary["avg_cost_r"], 0.075) + self.assertAlmostEqual(summary["by_session"]["LONDON"]["expectancy_r"], 0.2 / 3) + self.assertAlmostEqual(summary["by_session"]["LONDON"]["avg_mfe_r"], 2.9 / 3) + self.assertAlmostEqual(summary["by_session"]["LONDON"]["avg_mae_r"], 2.7 / 3) + self.assertAlmostEqual(summary["by_session"]["LONDON"]["avg_cost_r"], 0.1) + self.assertAlmostEqual(summary["by_session"]["LONDON"]["max_drawdown_r"], 2.2) + self.assertEqual(summary["by_session"]["LONDON"]["max_consecutive_losses"], 2) + self.assertEqual(summary["by_session"]["LONDON"]["executed"], 3) + self.assertAlmostEqual(summary["by_session"]["LONDON"]["executed_expectancy_r"], 0.2 / 3) + self.assertAlmostEqual(summary["by_side"]["buy"]["total_r"], 0.2) + + def test_drawdown_helpers_track_cumulative_r_peak_to_trough(self) -> None: + values = [1.0, -0.5, 2.0, -3.0, 0.5] + + self.assertEqual(build_cumulative_r_curve(values), [1.0, 0.5, 2.5, -0.5, 0.0]) + self.assertEqual(compute_max_drawdown_r(values), 3.0) + + def test_counts_max_consecutive_losses(self) -> None: + outcomes = [ + BacktestOutcome(1, "loss", 0.0, 1.0, False, False, False, [], {}), + BacktestOutcome(2, "loss", 0.0, 1.0, False, False, False, [], {}), + BacktestOutcome(3, "breakeven", 0.0, 0.0, True, False, False, [], {}), + BacktestOutcome(4, "loss", 0.0, 1.0, False, False, False, [], {}), + ] + + self.assertEqual(compute_max_consecutive_losses(outcomes), 2) + + def test_backtest_quality_gate_passes_when_metrics_are_healthy(self) -> None: + result = evaluate_backtest_quality( + { + "total_signals": 120, + "executed_signals": 80, + "expectancy_r": 0.15, + "profit_factor_r": 1.4, + "max_drawdown_r": 4.5, + "max_consecutive_losses": 3, + } + ) + + self.assertTrue(result["passed"]) + self.assertEqual(result["reasons"], []) + + def test_backtest_quality_gate_rejects_weak_metrics(self) -> None: + result = evaluate_backtest_quality( + { + "total_signals": 12, + "executed_signals": 3, + "expectancy_r": -0.05, + "profit_factor_r": 0.9, + "max_drawdown_r": 12.0, + "max_consecutive_losses": 8, + }, + gate=BacktestQualityGate(min_total_signals=50, max_drawdown_r=6.0, max_consecutive_losses=4), + ) + + self.assertFalse(result["passed"]) + self.assertEqual( + result["reasons"], + [ + "sample_size_too_small", + "executed_sample_too_small", + "expectancy_below_threshold", + "profit_factor_below_threshold", + "drawdown_above_threshold", + "consecutive_losses_above_threshold", + ], + ) + + +class BacktestServiceRunTests(unittest.TestCase): + def test_run_backtest_persists_quality_gate_in_summary(self) -> None: + case = self + + class FakeBacktestRepository: + def __init__(self) -> None: + self.summary = {} + self.results = [] + self.run_config = {} + + def fetch_candidate_signals(self, instrument_id: int, limit: int): + case.assertEqual(instrument_id, 1) + case.assertEqual(limit, 10) + return [ + { + "id": 7, + "created_ts": datetime(2026, 4, 29, 9, 0, tzinfo=timezone.utc), + "expires_ts": datetime(2026, 4, 29, 10, 0, tzinfo=timezone.utc), + "side": "buy", + "entry_low": 100.0, + "entry_high": 101.0, + "stop_loss": 99.0, + "tp1": 102.0, + "tp2": 104.0, + "rr_tp1": 1.0, + "rr_tp2": 2.0, + } + ] + + def insert_backtest_run(self, **kwargs): + self.run_config = kwargs["config"] + return 42 + + def fetch_sessions_covering_window(self, **kwargs): + return [{"session_code": "LONDON"}] + + def fetch_future_candles(self, **kwargs): + return [{"high": 104.5, "low": 100.0}] + + def insert_backtest_result(self, **kwargs): + self.results.append(kwargs) + + def update_backtest_run_summary(self, backtest_run_id: int, summary: dict) -> None: + case.assertEqual(backtest_run_id, 42) + self.summary = summary + + repository = FakeBacktestRepository() + service = BacktestService(repository=repository) + + run_id, outcomes = service.run_backtest(instrument_id=1, limit=10) + + self.assertEqual(run_id, 42) + self.assertEqual(len(outcomes), 1) + self.assertEqual(len(repository.results), 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"]) + self.assertIn("executed_sample_too_small", repository.summary["quality_gate"]["reasons"]) + self.assertEqual(repository.run_config["quality_gate"]["min_total_signals"], 100) + self.assertEqual(repository.run_config["quality_gate"]["min_executed_signals"], 30) + class BacktestRepositorySerializationTests(unittest.TestCase): def test_insert_backtest_result_serializes_datetime_in_meta(self) -> None: diff --git a/tests/unit/test_database_readiness.py b/tests/unit/test_database_readiness.py new file mode 100644 index 0000000..480f10b --- /dev/null +++ b/tests/unit/test_database_readiness.py @@ -0,0 +1,129 @@ +import contextlib +import io +import json +import os +import unittest +from datetime import datetime, timezone + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") + +from scripts import run_database_readiness + + +class DatabaseReadinessTests(unittest.TestCase): + def test_seed_readiness_reports_missing_seed_data(self) -> None: + result = run_database_readiness.evaluate_seed_readiness( + found_venues={"OKX"}, + found_instruments=set(), + required_venues=("OKX",), + required_instruments=("BTC-USDT",), + ) + + self.assertFalse(result["ready"]) + self.assertEqual(result["missing_instruments"], ["BTC-USDT"]) + self.assertIn("missing_required_instruments", result["reasons"]) + + def test_candle_readiness_can_require_minimum_count(self) -> None: + result = run_database_readiness.evaluate_candle_readiness(candle_count=10, min_candles=20) + + self.assertFalse(result["ready"]) + self.assertIn("not_enough_candles", result["reasons"]) + + def test_candle_readiness_can_require_span_and_freshness(self) -> None: + result = run_database_readiness.evaluate_candle_readiness( + candle_count=100, + first_ts_open=datetime(2026, 4, 29, 8, 0, tzinfo=timezone.utc), + last_ts_open=datetime(2026, 4, 29, 8, 30, tzinfo=timezone.utc), + min_span_minutes=60, + max_age_minutes=10, + now=datetime(2026, 4, 29, 9, 0, tzinfo=timezone.utc), + ) + + self.assertFalse(result["ready"]) + self.assertIn("not_enough_candle_span", result["reasons"]) + self.assertIn("candles_too_stale", result["reasons"]) + self.assertEqual(result["span_minutes"], 30.0) + self.assertEqual(result["age_minutes"], 30.0) + self.assertEqual(result["first_ts_open"], "2026-04-29T08:00:00+00:00") + + def test_run_database_readiness_passes_when_all_gates_are_ready(self) -> None: + captured = {} + + def fake_candle_probe(**kwargs): + captured.update(kwargs) + return {"ready": True, "candle_count": 3, "reasons": []} + + result = run_database_readiness.run_database_readiness( + env={"READINESS_MIN_CANDLES": "2"}, + db_probe=lambda: {"available": True, "value": 1}, + schema_probe=lambda: {"ready": True, "missing_tables": [], "missing_columns": {}}, + seed_probe=lambda **kwargs: {"ready": True, "reasons": []}, + candle_probe=fake_candle_probe, + ) + + self.assertTrue(result["passed"]) + self.assertEqual(result["reasons"], []) + self.assertEqual(result["action_plan"][0]["action"], "run_guarded_pipeline") + self.assertEqual(captured["min_candles"], 2) + self.assertEqual(captured["min_span_minutes"], run_database_readiness.DEFAULT_MIN_CANDLE_SPAN_MINUTES) + self.assertEqual(captured["max_age_minutes"], run_database_readiness.DEFAULT_MAX_CANDLE_AGE_MINUTES) + + def test_run_database_readiness_collects_reasons(self) -> None: + result = run_database_readiness.run_database_readiness( + env={}, + db_probe=lambda: {"available": True, "value": 1}, + schema_probe=lambda: {"ready": False, "missing_tables": ["candles"], "missing_columns": {}}, + seed_probe=lambda **kwargs: {"ready": False, "reasons": ["missing_required_instruments"]}, + candle_probe=lambda **kwargs: {"ready": False, "reasons": ["not_enough_candles"]}, + ) + + self.assertFalse(result["passed"]) + self.assertEqual(result["reasons"], ["schema_not_ready", "missing_required_instruments", "not_enough_candles"]) + self.assertEqual( + result["next_actions"], + [ + "run_migrations_before_pipeline", + "verify_seed_migrations_or_seed_data", + "run_okx_backfill_or_load_replay_sample", + ], + ) + self.assertEqual(result["action_plan"][0]["command"][-1], "scripts/run_migrations.py") + self.assertTrue(result["action_plan"][0]["requires_database"]) + + def test_next_actions_default_to_guarded_pipeline_when_ready(self) -> None: + self.assertEqual(run_database_readiness.build_next_actions([]), ["run_guarded_pipeline"]) + + def test_next_actions_backfill_for_span_or_staleness(self) -> None: + self.assertEqual( + run_database_readiness.build_next_actions(["not_enough_candle_span", "candles_too_stale"]), + ["run_okx_backfill_or_load_replay_sample"], + ) + + def test_main_can_emit_json(self) -> None: + output = io.StringIO() + + original = run_database_readiness.run_database_readiness + try: + run_database_readiness.run_database_readiness = lambda env=None: { + "passed": True, + "database": {"available": True}, + "schema": {"ready": True}, + "seed_data": {"ready": True}, + "candles": {"candle_count": 1}, + "reasons": [], + "next_actions": ["run_guarded_pipeline"], + "action_plan": run_database_readiness.build_action_plan(["run_guarded_pipeline"]), + } + with contextlib.redirect_stdout(output): + exit_code = run_database_readiness.main(env={"READINESS_OUTPUT_JSON": "1"}) + finally: + run_database_readiness.run_database_readiness = original + + summary = json.loads(output.getvalue()) + self.assertEqual(exit_code, 0) + self.assertTrue(summary["passed"]) + self.assertEqual(summary["action_plan"][0]["action"], "run_guarded_pipeline") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_db_engine.py b/tests/unit/test_db_engine.py new file mode 100644 index 0000000..b63fc96 --- /dev/null +++ b/tests/unit/test_db_engine.py @@ -0,0 +1,21 @@ +import os +import unittest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") + +from src.db.engine import build_connect_args + + +class DbEngineTests(unittest.TestCase): + def test_postgresql_connect_args_include_timeout(self) -> None: + self.assertEqual( + build_connect_args("postgresql+psycopg://user:pass@localhost/db", 5), + {"connect_timeout": 5}, + ) + + def test_non_postgresql_connect_args_are_empty(self) -> None: + self.assertEqual(build_connect_args("sqlite:///:memory:", 5), {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_documentation_consistency.py b/tests/unit/test_documentation_consistency.py new file mode 100644 index 0000000..9da053e --- /dev/null +++ b/tests/unit/test_documentation_consistency.py @@ -0,0 +1,22 @@ +import re +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +class DocumentationConsistencyTests(unittest.TestCase): + def test_readme_lists_script_output_json_environment_variables(self) -> None: + script_env_vars = set() + for path in (ROOT / "scripts").glob("*.py"): + script_env_vars.update(re.findall(r'"([A-Z0-9_]+OUTPUT_JSON)"', path.read_text(encoding="utf-8"))) + readme = (ROOT / "README.md").read_text(encoding="utf-8") + + missing = sorted(name for name in script_env_vars if name not in readme) + + self.assertEqual(missing, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_execution_service.py b/tests/unit/test_execution_service.py index 0f1bb57..0f3986b 100644 --- a/tests/unit/test_execution_service.py +++ b/tests/unit/test_execution_service.py @@ -1,9 +1,18 @@ import os import unittest +from unittest.mock import Mock os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") -from src.services.execution import compute_entry_price, evaluate_execution_decision, trade_execution_matches_decision +from src.services.execution import ( + AccountRiskState, + ExecutionService, + classify_execution_lifecycle, + compute_entry_price, + evaluate_account_risk_limits, + evaluate_execution_decision, + trade_execution_matches_decision, +) class ExecutionDecisionTests(unittest.TestCase): @@ -56,6 +65,7 @@ class ExecutionDecisionTests(unittest.TestCase): self.assertTrue(decision.allowed) self.assertEqual(decision.status, "pending") self.assertEqual(decision.execution_mode, "semi_auto") + self.assertTrue(decision.requires_confirmation) def test_rejects_signal_without_stop_or_allowed_session(self) -> None: decision = evaluate_execution_decision( @@ -86,6 +96,296 @@ class ExecutionDecisionTests(unittest.TestCase): self.assertIn("bias_execution_intensity_low", decision.reasons) self.assertIn("risk_pct_out_of_bounds", decision.reasons) + def test_account_risk_limits_reject_execution(self) -> None: + account_state = AccountRiskState( + daily_pnl_pct=-0.02, + consecutive_losses=3, + open_positions=1, + same_side_open_positions=1, + ) + decision = evaluate_execution_decision( + signal={ + "side": "buy", + "status": "awaiting_confirmation", + "entry_type": "limit", + "entry_low": 100.0, + "entry_high": 101.0, + "stop_loss": 99.0, + "tp1": 102.0, + "tp2": 104.0, + "session_code": "LONDON", + "execution_intensity": "medium", + "meta": {"execution_permission": True}, + }, + execution_mode="paper", + account_equity=10000.0, + risk_pct=0.01, + account_state=account_state, + ) + + self.assertFalse(decision.allowed) + self.assertEqual(decision.status, "rejected") + self.assertEqual( + decision.reasons, + [ + "daily_loss_limit_reached", + "consecutive_loss_limit_reached", + "max_open_positions_reached", + "same_side_exposure_limit_reached", + ], + ) + self.assertEqual(decision.meta["account_risk"]["consecutive_losses"], 3) + + def test_account_risk_limits_allow_clean_state(self) -> None: + self.assertEqual( + evaluate_account_risk_limits( + AccountRiskState( + daily_pnl_pct=-0.005, + consecutive_losses=1, + open_positions=0, + same_side_open_positions=0, + ) + ), + [], + ) + + def test_account_risk_kill_switch_rejects_execution(self) -> None: + decision = evaluate_execution_decision( + signal={ + "side": "buy", + "status": "awaiting_confirmation", + "entry_type": "limit", + "entry_low": 100.0, + "entry_high": 101.0, + "stop_loss": 99.0, + "tp1": 102.0, + "tp2": 104.0, + "session_code": "LONDON", + "execution_intensity": "medium", + "meta": {"execution_permission": True}, + }, + execution_mode="paper", + account_equity=10000.0, + risk_pct=0.01, + account_state=AccountRiskState(kill_switch=True), + ) + + self.assertFalse(decision.allowed) + self.assertEqual(decision.status, "rejected") + self.assertIn("execution_kill_switch_enabled", decision.reasons) + self.assertTrue(decision.meta["account_risk"]["kill_switch"]) + + +class ExecutionServiceTests(unittest.TestCase): + def test_semi_auto_creation_requires_explicit_confirmation(self) -> None: + repository = Mock() + repository.fetch_signal_for_execution.return_value = { + "id": 1, + "side": "buy", + "status": "awaiting_confirmation", + "entry_type": "limit", + "entry_low": 100.0, + "entry_high": 101.0, + "stop_loss": 99.0, + "tp1": 102.0, + "tp2": 104.0, + "session_code": "LONDON", + "execution_intensity": "high", + "meta": {"execution_permission": True}, + } + repository.fetch_latest_execution.return_value = None + repository.insert_trade_execution.return_value = 88 + service = ExecutionService(repository=repository) + + execution_id = service.create_execution(signal_id=1, execution_mode="semi_auto") + + self.assertIsNone(execution_id) + repository.insert_trade_execution.assert_not_called() + + def test_semi_auto_creation_allows_confirmed_execution(self) -> None: + repository = Mock() + repository.fetch_signal_for_execution.return_value = { + "id": 1, + "side": "buy", + "status": "awaiting_confirmation", + "entry_type": "limit", + "entry_low": 100.0, + "entry_high": 101.0, + "stop_loss": 99.0, + "tp1": 102.0, + "tp2": 104.0, + "session_code": "LONDON", + "execution_intensity": "high", + "meta": {"execution_permission": True}, + } + repository.fetch_latest_execution.return_value = None + repository.insert_trade_execution.return_value = 89 + service = ExecutionService(repository=repository) + + execution_id = service.create_execution(signal_id=1, execution_mode="semi_auto", confirmed=True) + + self.assertEqual(execution_id, 89) + repository.insert_trade_execution.assert_called_once() + + +class ExecutionAuditTests(unittest.TestCase): + def test_classifies_lifecycle_states(self) -> None: + self.assertEqual(classify_execution_lifecycle("pending", None), "awaiting_confirmation") + 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") + + def test_audit_execution_reports_review_state(self) -> None: + audit = ExecutionService().audit_execution( + trade_execution={"status": "rejected", "meta": {"risk_checks": ["session_not_allowed"]}}, + backtest_result={"outcome": "loss", "tp1_hit": False, "tp2_hit": False, "invalidated_before_entry": False}, + ) + + self.assertEqual(audit["audit_status"], "review") + self.assertFalse(audit["is_actionable"]) + self.assertIn("execution_rejected", audit["error_tags"]) + self.assertIn("session_not_allowed", audit["error_tags"]) + self.assertEqual(audit["lifecycle_state"], "rejected") + self.assertTrue(audit["stop_loss_hit"]) + + def test_settle_execution_from_backtest_closes_tp2_hit(self) -> None: + settled = ExecutionService().settle_execution_from_backtest( + trade_execution={"status": "open", "meta": {}, "closed_ts": None}, + backtest_result={"outcome": "win", "tp1_hit": True, "tp2_hit": True, "invalidated_before_entry": False}, + ) + + self.assertEqual(settled["status"], "closed") + self.assertEqual(settled["meta"]["lifecycle_state"], "tp2_closed") + self.assertTrue(settled["meta"]["tp1_hit"]) + self.assertTrue(settled["meta"]["tp2_hit"]) + self.assertIsNotNone(settled["closed_ts"]) + + def test_settle_execution_from_backtest_marks_stopped_out(self) -> None: + settled = ExecutionService().settle_execution_from_backtest( + trade_execution={"status": "open", "meta": {}, "closed_ts": None}, + backtest_result={"outcome": "loss", "tp1_hit": False, "tp2_hit": False, "invalidated_before_entry": False}, + ) + + self.assertEqual(settled["status"], "closed") + self.assertEqual(settled["meta"]["lifecycle_state"], "stopped_out") + self.assertTrue(settled["meta"]["stop_loss_hit"]) + + def test_settle_execution_from_backtest_keeps_tp1_progress_open(self) -> None: + settled = ExecutionService().settle_execution_from_backtest( + trade_execution={"status": "open", "meta": {}, "closed_ts": None}, + backtest_result={"outcome": "breakeven", "tp1_hit": True, "tp2_hit": False, "invalidated_before_entry": False}, + ) + + self.assertEqual(settled["status"], "open") + self.assertEqual(settled["meta"]["lifecycle_state"], "tp1_reached") + self.assertTrue(settled["meta"]["tp1_hit"]) + self.assertFalse(settled["meta"]["tp2_hit"]) + self.assertFalse(settled["meta"]["stop_loss_hit"]) + + def test_persist_execution_settlement_updates_repository_when_execution_has_id(self) -> None: + repository = Mock() + service = ExecutionService(repository=repository) + + settled = service.persist_execution_settlement( + trade_execution={"id": 7, "status": "open", "meta": {}, "closed_ts": None}, + backtest_result={"outcome": "win", "tp1_hit": True, "tp2_hit": True, "invalidated_before_entry": False}, + ) + + self.assertEqual(settled["status"], "closed") + repository.update_trade_execution_lifecycle.assert_called_once() + kwargs = repository.update_trade_execution_lifecycle.call_args.kwargs + self.assertEqual(kwargs["execution_id"], 7) + self.assertEqual(kwargs["status"], "closed") + self.assertEqual(kwargs["meta"]["lifecycle_state"], "tp2_closed") + + def test_settle_execution_by_id_fetches_and_persists(self) -> None: + repository = Mock() + repository.fetch_execution_by_id.return_value = {"id": 8, "status": "open", "meta": {}, "closed_ts": None} + service = ExecutionService(repository=repository) + + settled = service.settle_execution_by_id( + execution_id=8, + backtest_result={"outcome": "loss", "tp1_hit": False, "tp2_hit": False, "invalidated_before_entry": False}, + ) + + self.assertEqual(settled["status"], "closed") + self.assertEqual(settled["meta"]["lifecycle_state"], "stopped_out") + repository.fetch_execution_by_id.assert_called_once_with(8) + repository.update_trade_execution_lifecycle.assert_called_once() + + def test_settle_execution_by_id_returns_none_when_missing(self) -> None: + repository = Mock() + repository.fetch_execution_by_id.return_value = None + service = ExecutionService(repository=repository) + + settled = service.settle_execution_by_id(execution_id=404, backtest_result={"outcome": "missed", "tp1_hit": False, "tp2_hit": False, "invalidated_before_entry": True}) + + self.assertIsNone(settled) + repository.update_trade_execution_lifecycle.assert_not_called() + + def test_settle_execution_by_id_returns_closed_tp2_sample(self) -> None: + repository = Mock() + repository.fetch_execution_by_id.return_value = {"id": 9, "status": "open", "meta": {}, "closed_ts": None} + service = ExecutionService(repository=repository) + + settled = service.settle_execution_by_id( + execution_id=9, + backtest_result={"outcome": "win", "tp1_hit": True, "tp2_hit": True, "invalidated_before_entry": False}, + ) + + self.assertEqual(settled["status"], "closed") + self.assertEqual(settled["meta"]["lifecycle_state"], "tp2_closed") + + def test_settle_execution_by_id_from_latest_backtest_fetches_result(self) -> None: + repository = Mock() + repository.fetch_execution_by_id.return_value = {"id": 10, "signal_id": 3, "status": "open", "meta": {}, "closed_ts": None} + repository.fetch_latest_backtest_result_for_signal.return_value = {"outcome": "loss", "tp1_hit": False, "tp2_hit": False, "invalidated_before_entry": False} + service = ExecutionService(repository=repository) + + settled = service.settle_execution_by_id_from_latest_backtest(execution_id=10) + + self.assertEqual(settled["status"], "closed") + self.assertEqual(settled["meta"]["lifecycle_state"], "stopped_out") + repository.fetch_execution_by_id.assert_called_once_with(10) + repository.fetch_latest_backtest_result_for_signal.assert_called_once_with(3) + repository.update_trade_execution_lifecycle.assert_called_once() + + def test_settle_execution_by_id_from_latest_backtest_returns_none_without_result(self) -> None: + repository = Mock() + repository.fetch_execution_by_id.return_value = {"id": 11, "signal_id": 4, "status": "open", "meta": {}, "closed_ts": None} + repository.fetch_latest_backtest_result_for_signal.return_value = None + service = ExecutionService(repository=repository) + + settled = service.settle_execution_by_id_from_latest_backtest(execution_id=11) + + self.assertIsNone(settled) + repository.update_trade_execution_lifecycle.assert_not_called() + + def test_settle_latest_execution_for_signal_uses_latest_execution(self) -> None: + repository = Mock() + repository.fetch_latest_execution.return_value = {"id": 12, "signal_id": 5, "status": "open", "meta": {}, "closed_ts": None} + repository.fetch_latest_backtest_result_for_signal.return_value = {"outcome": "win", "tp1_hit": True, "tp2_hit": True, "invalidated_before_entry": False} + service = ExecutionService(repository=repository) + + settled = service.settle_latest_execution_for_signal(signal_id=5, execution_mode="paper") + + self.assertEqual(settled["status"], "closed") + self.assertEqual(settled["meta"]["lifecycle_state"], "tp2_closed") + repository.fetch_latest_execution.assert_called_once_with(signal_id=5, execution_mode="paper") + repository.fetch_latest_backtest_result_for_signal.assert_called_once_with(5) + repository.update_trade_execution_lifecycle.assert_called_once() + + def test_settle_latest_execution_for_signal_returns_none_without_execution(self) -> None: + repository = Mock() + repository.fetch_latest_execution.return_value = None + service = ExecutionService(repository=repository) + + settled = service.settle_latest_execution_for_signal(signal_id=6) + + self.assertIsNone(settled) + repository.fetch_latest_backtest_result_for_signal.assert_not_called() + repository.update_trade_execution_lifecycle.assert_not_called() + class ExecutionPricingTests(unittest.TestCase): def test_entry_price_uses_midpoint_when_range_exists(self) -> None: @@ -135,6 +435,42 @@ class ExecutionIdempotencyTests(unittest.TestCase): self.assertTrue(trade_execution_matches_decision(existing, decision)) + def test_detects_equivalent_open_execution_with_rounding_differences(self) -> None: + decision = evaluate_execution_decision( + signal={ + "side": "buy", + "status": "awaiting_confirmation", + "entry_type": "limit", + "entry_low": 77200.0, + "entry_high": 77223.3, + "stop_loss": 77122.8, + "tp1": 77300.5233, + "tp2": 77454.9699, + "session_code": "LONDON", + "execution_intensity": "medium", + "daily_bias": "bullish", + "draw_side": "buy_side", + "meta": {"execution_permission": True}, + }, + execution_mode="paper", + account_equity=10000.0, + risk_pct=0.01, + ) + existing = { + "execution_mode": "paper", + "status": "open", + "side": "buy", + "entry_price": "77211.6500000000", + "stop_loss": "77122.8000000000", + "take_profit_1": "77300.5233000000", + "take_profit_2": "77454.9699000000", + "size": "1.1254924029", + "risk_pct": "0.0100", + "meta": decision.meta, + } + + self.assertTrue(trade_execution_matches_decision(existing, decision)) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_fvg_service.py b/tests/unit/test_fvg_service.py index df3244a..b7a52b7 100644 --- a/tests/unit/test_fvg_service.py +++ b/tests/unit/test_fvg_service.py @@ -4,7 +4,8 @@ import unittest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") -from src.services.fvg import compute_fvg_fill_state +from src.services.fvg import compute_fvg_fill_state, fvg_matches_candidate, fvg_state_matches_update +from src.services.fvg.fvg_service import FVGCandidate class FVGStateTests(unittest.TestCase): @@ -58,5 +59,60 @@ class FVGStateTests(unittest.TestCase): self.assertTrue(meta["invalidated"]) +class FVGIdempotencyTests(unittest.TestCase): + def test_matches_candidate_without_requiring_same_runtime_status(self) -> None: + candidate = FVGCandidate( + direction="bearish", + upper=78158.0, + lower=78148.5, + start_ts="s", + end_ts="e", + fill_pct=0.0, + status="open", + related_displacement_id=None, + meta={"width": 9.5, "has_displacement_context": False}, + ) + latest = { + "direction": "bearish", + "upper": "78158.0000000000", + "lower": "78148.5000000000", + "start_ts": "s", + "end_ts": "e", + "fill_pct": "1.0000", + "status": "invalidated", + "related_displacement_id": None, + "meta": { + "width": 9.5, + "invalidated": True, + "first_touch_ts": "2026-04-23T03:03:00-04:00", + "mitigation_threshold": 1.0, + "has_displacement_context": False, + }, + } + + self.assertTrue(fvg_matches_candidate(latest, candidate)) + + def test_state_update_match_tolerates_fill_rounding(self) -> None: + row = { + "fill_pct": "0.9327", + "status": "touched", + "meta": { + "width": 22.29999999998836, + "invalidated": False, + "first_touch_ts": "2026-04-23T04:16:00-04:00", + "mitigation_threshold": 1.0, + "has_displacement_context": False, + }, + } + meta = { + "first_touch_ts": "2026-04-23T04:16:00-04:00", + "mitigation_threshold": 1.0, + "invalidated": False, + "width": 22.29999999998836, + } + + self.assertTrue(fvg_state_matches_update(row, 0.9327354260089334, "touched", meta)) + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_liquidity_pool_service.py b/tests/unit/test_liquidity_pool_service.py index dec7db2..3d1780f 100644 --- a/tests/unit/test_liquidity_pool_service.py +++ b/tests/unit/test_liquidity_pool_service.py @@ -4,7 +4,7 @@ import unittest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") -from src.services.liquidity import LiquidityPoolCandidate, build_equal_pool_candidates, dedupe_candidates +from src.services.liquidity import LiquidityPoolCandidate, build_equal_pool_candidates, dedupe_candidates, pool_matches_candidate class EqualLiquidityPoolTests(unittest.TestCase): @@ -122,5 +122,28 @@ class LiquidityMergeTests(unittest.TestCase): self.assertEqual(len(deduped), 1) +class LiquidityIdempotencyTests(unittest.TestCase): + def test_pool_matches_candidate_when_db_pool_type_in_merged_meta(self) -> None: + candidate = LiquidityPoolCandidate( + side="buy_side", + pool_type="equal_highs", + price_low=Decimal("100.00"), + price_high=Decimal("100.20"), + reference_ids=[1, 2], + created_ts="2026-04-21T00:00:00Z", + meta={"merged_pool_types": ["equal_highs", "prior_high"], "source_pool_types": ["equal_highs", "prior_high"]}, + ) + pool = { + "side": "buy_side", + "pool_type": "prior_high", + "price_low": Decimal("100.00"), + "price_high": Decimal("100.20"), + "reference_ids": [1, 2], + "meta": {"merged_pool_types": ["equal_highs", "prior_high"], "source_pool_types": ["equal_highs", "prior_high"]}, + } + + self.assertTrue(pool_matches_candidate(pool, candidate)) + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_migration_runner.py b/tests/unit/test_migration_runner.py new file mode 100644 index 0000000..83c6820 --- /dev/null +++ b/tests/unit/test_migration_runner.py @@ -0,0 +1,85 @@ +import contextlib +import io +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from sqlalchemy.sql.elements import TextClause +from sqlalchemy.exc import OperationalError + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") + +from scripts import run_migrations as run_migrations_module +from scripts.run_migrations import apply_migration_file, run_migrations as run_migration_files, split_sql_statements + + +class MigrationRunnerTests(unittest.TestCase): + def test_split_sql_statements_keeps_semicolon_inside_quotes(self) -> None: + sql = """ + -- comment with ; should be ignored + create table sample (id bigint); + insert into sample (note) values ('semi;colon'); + """ + + statements = split_sql_statements(sql) + + self.assertEqual(len(statements), 2) + self.assertIn("create table sample", statements[0]) + self.assertIn("'semi;colon'", statements[1]) + + def test_apply_migration_file_executes_each_statement(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "001_sample.sql" + path.write_text("create table a (id bigint); create table b (id bigint);", encoding="utf-8") + connection = FakeConnection() + + count = apply_migration_file(connection, path) + + self.assertEqual(count, 2) + self.assertEqual(len(connection.statements), 2) + self.assertTrue(all(isinstance(statement, TextClause) for statement in connection.statements)) + + def test_run_migrations_reports_each_file(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + first = Path(tmpdir) / "001_sample.sql" + second = Path(tmpdir) / "002_sample.sql" + first.write_text("create table a (id bigint);", encoding="utf-8") + second.write_text("create table b (id bigint); create table c (id bigint);", encoding="utf-8") + connection = FakeConnection() + + results = run_migration_files(connection=connection, files=[first, second]) + + self.assertEqual(results, [{"file": "001_sample.sql", "statements": 1}, {"file": "002_sample.sql", "statements": 2}]) + + def test_main_reports_database_unavailable(self) -> None: + output = io.StringIO() + + with patch("scripts.run_migrations.engine.begin", side_effect=OperationalError("stmt", {}, Exception())), contextlib.redirect_stdout(output): + exit_code = run_migrations_module.main() + + self.assertEqual(exit_code, 2) + self.assertIn("Database unavailable for migrations: OperationalError", output.getvalue()) + + def test_main_can_emit_json_failure(self) -> None: + output = io.StringIO() + + with patch("scripts.run_migrations.engine.begin", side_effect=OperationalError("stmt", {}, Exception())), contextlib.redirect_stdout(output): + exit_code = run_migrations_module.main(env={"MIGRATIONS_OUTPUT_JSON": "1"}) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 2) + self.assertFalse(payload["passed"]) + self.assertEqual(payload["error_type"], "OperationalError") + + +class FakeConnection: + def __init__(self) -> None: + self.statements = [] + + def execute(self, statement): + self.statements.append(statement) +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_migration_static_checks.py b/tests/unit/test_migration_static_checks.py new file mode 100644 index 0000000..2d29ca6 --- /dev/null +++ b/tests/unit/test_migration_static_checks.py @@ -0,0 +1,82 @@ +import contextlib +import io +import json +import os +import tempfile +import unittest +from pathlib import Path + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") + +from scripts import run_migration_static_checks + + +class MigrationStaticCheckTests(unittest.TestCase): + def test_extract_created_table_columns_handles_constraints_and_commas(self) -> None: + sql = """ + create table if not exists sample_table ( + id bigserial primary key, + amount numeric(20,10) not null, + payload jsonb not null default '{}'::jsonb, + unique (id, amount) + ); + """ + + result = run_migration_static_checks.extract_created_table_columns(sql) + + self.assertEqual(result["sample_table"], {"id", "amount", "payload"}) + + def test_validate_migration_sequence_rejects_gaps(self) -> None: + files = [Path("001_create_a.sql"), Path("003_create_b.sql")] + + errors = run_migration_static_checks.validate_migration_sequence(files) + + self.assertEqual(errors, ["non_contiguous_migration_sequence:[1, 3]"]) + + def test_static_checks_pass_current_migrations(self) -> None: + result = run_migration_static_checks.run_migration_static_checks() + + self.assertTrue(result["passed"], result["errors"]) + self.assertGreater(result["migration_count"], 0) + self.assertEqual(result["seed_values_checked"]["venues.code"], ["OKX"]) + + def test_static_checks_report_missing_column(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "001_create_sample.sql" + path.write_text("create table if not exists sample (id bigserial primary key);", encoding="utf-8") + + result = run_migration_static_checks.run_migration_static_checks( + migrations_dir=Path(tmpdir), + required_schema={"sample": ("id", "missing_column")}, + ) + + self.assertFalse(result["passed"]) + self.assertIn("missing_column:sample.missing_column", result["errors"]) + + def test_static_checks_report_missing_required_seed_value(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "001_create_venues.sql" + path.write_text("create table if not exists venues (id bigserial primary key, code text not null);", encoding="utf-8") + + result = run_migration_static_checks.run_migration_static_checks( + migrations_dir=Path(tmpdir), + required_schema={"venues": ("id", "code")}, + required_seed_values={"venues.code": ("OKX",)}, + ) + + self.assertFalse(result["passed"]) + self.assertIn("missing_seed_value:venues.code=OKX", result["errors"]) + + def test_main_can_emit_json(self) -> None: + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_migration_static_checks.main(env={"MIGRATION_CHECKS_OUTPUT_JSON": "1"}) + + summary = json.loads(output.getvalue()) + self.assertEqual(exit_code, 0) + self.assertTrue(summary["passed"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_mss_service.py b/tests/unit/test_mss_service.py index 7e98247..cf4ae7a 100644 --- a/tests/unit/test_mss_service.py +++ b/tests/unit/test_mss_service.py @@ -3,7 +3,8 @@ import unittest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") -from src.services.structure import detect_mss_candidates, evaluate_mss_acceptance +from src.services.structure import MSSCandidate, MSSService, detect_mss_candidates, evaluate_mss_acceptance +from src.services.structure.mss_service import mss_event_matches_candidate class MSSAcceptanceTests(unittest.TestCase): @@ -62,5 +63,64 @@ class MSSAcceptanceTests(unittest.TestCase): self.assertEqual(candidates[0].meta["retest_count"], 1) +class MSSIdempotencyTests(unittest.TestCase): + def test_mss_event_matches_candidate_when_same_structure(self) -> None: + candidate = MSSCandidate( + direction="bullish", + broken_level=100.0, + reference_swing_id=10, + displacement_event_id=20, + accepted=True, + ts="2026-04-24T00:00:00+00:00", + meta={"has_retest": True, "retest_count": 1}, + ) + latest = { + "direction": "bullish", + "broken_level": 100.0, + "reference_swing_id": 10, + "displacement_event_id": 20, + "accepted": True, + "meta": {"has_retest": True, "retest_count": 1}, + } + + self.assertTrue(mss_event_matches_candidate(latest, candidate)) + + def test_persist_mss_skips_equivalent_latest_event(self) -> None: + candidate = MSSCandidate( + direction="bearish", + broken_level=99.5, + reference_swing_id=11, + displacement_event_id=21, + accepted=True, + ts="t", + meta={"has_retest": True, "retest_count": 2}, + ) + + class StubRepo: + def __init__(self): + self.insert_calls = 0 + + def fetch_latest_mss_event(self, instrument_id, timeframe): + return { + "direction": "bearish", + "broken_level": 99.5, + "reference_swing_id": 11, + "displacement_event_id": 21, + "accepted": True, + "meta": {"has_retest": True, "retest_count": 2}, + } + + def insert_mss_event(self, **kwargs): + self.insert_calls += 1 + + repo = StubRepo() + service = MSSService(repository=repo) + + persisted = service.persist_mss(instrument_id=1, timeframe="1m", candidates=[candidate]) + + self.assertEqual(persisted, 0) + self.assertEqual(repo.insert_calls, 0) + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_okx_backfill_script.py b/tests/unit/test_okx_backfill_script.py new file mode 100644 index 0000000..f48ea2c --- /dev/null +++ b/tests/unit/test_okx_backfill_script.py @@ -0,0 +1,156 @@ +import contextlib +import io +import json +import os +import unittest +from urllib.error import URLError +from unittest.mock import patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") + +from scripts import run_okx_backfill +from src.services.market_data import OKXCandle + + +class OKXBackfillScriptTests(unittest.TestCase): + def test_build_backfill_config_from_env_uses_defaults(self) -> None: + config = run_okx_backfill.build_backfill_config_from_env({}) + + self.assertEqual(config["symbol"], "BTC-USDT") + self.assertEqual(config["inst_id"], "BTC-USDT-SWAP") + self.assertEqual(config["pages"], 20) + self.assertEqual(config["limit"], 100) + self.assertEqual(config["timeframes"], ["1m", "5m", "15m"]) + + def test_build_backfill_config_from_env_reads_overrides(self) -> None: + config = run_okx_backfill.build_backfill_config_from_env( + { + "OKX_BACKFILL_SYMBOL": "ETH-USDT", + "OKX_BACKFILL_INST_ID": "ETH-USDT-SWAP", + "OKX_BACKFILL_PAGES": "3", + "OKX_BACKFILL_LIMIT": "50", + "OKX_BACKFILL_TIMEFRAMES": "1m,15m", + } + ) + + self.assertEqual(config["symbol"], "ETH-USDT") + self.assertEqual(config["inst_id"], "ETH-USDT-SWAP") + self.assertEqual(config["pages"], 3) + self.assertEqual(config["limit"], 50) + self.assertEqual(config["timeframes"], ["1m", "15m"]) + + def test_run_okx_backfill_once_deduplicates_confirmed_candles(self) -> None: + client = FakeOKXClient( + [ + OKXCandle("BTC-USDT-SWAP", "1m", "1000", "1", "2", "0.5", "1.5", "10", "1"), + OKXCandle("BTC-USDT-SWAP", "1m", "1000", "1", "2", "0.5", "1.5", "10", "1"), + OKXCandle("BTC-USDT-SWAP", "1m", "2000", "2", "3", "1.5", "2.5", "11", "0"), + ] + ) + repository = FakeCandleRepository() + + summary = run_okx_backfill.run_okx_backfill_once( + client=client, + repository=repository, + pages=1, + limit=3, + timeframes=["1m"], + ) + + self.assertEqual(summary["total_fetched"], 3) + self.assertEqual(summary["total_wrote"], 1) + self.assertEqual(repository.batch_sizes, [1]) + + def test_run_okx_backfill_once_uses_after_cursor_for_older_pages(self) -> None: + client = CursorOKXClient() + repository = FakeCandleRepository() + + summary = run_okx_backfill.run_okx_backfill_once( + client=client, + repository=repository, + pages=2, + limit=2, + timeframes=["1m"], + ) + + self.assertEqual(client.after_values, [None, "2000"]) + self.assertEqual(summary["total_fetched"], 3) + self.assertEqual(summary["total_wrote"], 3) + + def test_main_returns_failure_when_backfill_fails(self) -> None: + output = io.StringIO() + + with patch("scripts.run_okx_backfill.run_okx_backfill_once", side_effect=URLError("offline")), contextlib.redirect_stdout(output): + exit_code = run_okx_backfill.main() + + self.assertEqual(exit_code, 2) + self.assertIn("OKX backfill failed: URLError", output.getvalue()) + + def test_main_can_emit_json_summary(self) -> None: + output = io.StringIO() + summary = { + "symbol": "BTC-USDT", + "inst_id": "BTC-USDT-SWAP", + "pages": 1, + "limit": 2, + "timeframes": [{"timeframe": "1m", "fetched": 2, "wrote": 2}], + "total_fetched": 2, + "total_wrote": 2, + } + + with patch("scripts.run_okx_backfill.run_okx_backfill_once", return_value=summary), contextlib.redirect_stdout(output): + exit_code = run_okx_backfill.main(env={"OKX_BACKFILL_OUTPUT_JSON": "1"}) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 0) + self.assertTrue(payload["passed"]) + self.assertEqual(payload["total_wrote"], 2) + + def test_main_can_emit_json_failure(self) -> None: + output = io.StringIO() + + with patch("scripts.run_okx_backfill.run_okx_backfill_once", side_effect=ValueError("bad")), contextlib.redirect_stdout(output): + exit_code = run_okx_backfill.main(env={"OKX_BACKFILL_OUTPUT_JSON": "1"}) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 2) + self.assertFalse(payload["passed"]) + self.assertEqual(payload["error_type"], "ValueError") + + +class FakeOKXClient: + def __init__(self, candles: list[OKXCandle]) -> None: + self.candles = candles + + def get_candles(self, **kwargs) -> list[OKXCandle]: + return self.candles + + +class CursorOKXClient: + def __init__(self) -> None: + self.after_values = [] + + def get_candles(self, **kwargs) -> list[OKXCandle]: + after = kwargs.get("after") + self.after_values.append(after) + if after is None: + return [ + OKXCandle("BTC-USDT-SWAP", "1m", "3000", "1", "2", "0.5", "1.5", "10", "1"), + OKXCandle("BTC-USDT-SWAP", "1m", "2000", "1", "2", "0.5", "1.5", "10", "1"), + ] + return [ + OKXCandle("BTC-USDT-SWAP", "1m", "1000", "1", "2", "0.5", "1.5", "10", "1"), + ] + + +class FakeCandleRepository: + def __init__(self) -> None: + self.batch_sizes = [] + + def upsert_many(self, candles, instrument_id: int) -> int: + self.batch_sizes.append(len(candles)) + return len(candles) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_range_service.py b/tests/unit/test_range_service.py new file mode 100644 index 0000000..7d3bf32 --- /dev/null +++ b/tests/unit/test_range_service.py @@ -0,0 +1,66 @@ +import os +import unittest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") + +from src.services.ranges import DealingRangeService, RangeCandidate, range_matches_candidate + + +class RangeIdempotencyTests(unittest.TestCase): + def test_range_matches_candidate_when_same_structure(self) -> None: + candidate = RangeCandidate( + high=110.0, + low=90.0, + equilibrium=100.0, + high_swing_id=10, + low_swing_id=11, + ts_start="t", + ) + latest = { + "high": 110.0, + "low": 90.0, + "equilibrium": 100.0, + "high_swing_id": 10, + "low_swing_id": 11, + "ts_start": "t", + } + + self.assertTrue(range_matches_candidate(latest, candidate)) + + def test_persist_active_range_skips_equivalent_latest_row(self) -> None: + class StubRepo: + def __init__(self): + self.upsert_calls = 0 + + def fetch_latest_valid_range(self, instrument_id, timeframe): + return { + "high": 110.0, + "low": 90.0, + "equilibrium": 100.0, + "high_swing_id": 10, + "low_swing_id": 11, + "ts_start": "t", + } + + def upsert_range(self, **kwargs): + self.upsert_calls += 1 + + repo = StubRepo() + service = DealingRangeService(repository=repo) + candidate = RangeCandidate( + high=110.0, + low=90.0, + equilibrium=100.0, + high_swing_id=10, + low_swing_id=11, + ts_start="t", + ) + + persisted = service.persist_active_range(instrument_id=1, timeframe="1m", candidate=candidate) + + self.assertEqual(persisted, 0) + self.assertEqual(repo.upsert_calls, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_report_services.py b/tests/unit/test_report_services.py index 76d4920..8aa5a4c 100644 --- a/tests/unit/test_report_services.py +++ b/tests/unit/test_report_services.py @@ -1,9 +1,13 @@ +import contextlib +import io +import json import os import unittest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") from src.services.reports import IntradayReportService, PostTradeReviewService, PreMarketReportService +from scripts import run_reports class PreMarketReportTests(unittest.TestCase): @@ -53,9 +57,36 @@ 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["execution_audit"]["audit_status"], "review") + self.assertEqual(report["execution_audit"]["lifecycle_state"], "rejected") self.assertIn("inspect_stop_and_structure_context", report["improvement_notes"]) self.assertIn("review_same_candle_ambiguity_cases", report["improvement_notes"]) +class ReportScriptTests(unittest.TestCase): + def test_run_reports_prints_backtest_quality_gate(self) -> None: + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_reports.main() + + self.assertEqual(exit_code, 0) + self.assertIn("Backtest quality gate passed: False", output.getvalue()) + + def test_run_reports_outputs_json_summary(self) -> None: + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_reports.main(env={"REPORTS_OUTPUT_JSON": "1"}) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 0) + self.assertTrue(payload["passed"]) + self.assertEqual(payload["pre_market"]["report_type"], "pre_market_plan") + self.assertEqual(payload["intraday"]["report_type"], "intraday_explanation") + self.assertEqual(payload["post_trade"]["report_type"], "post_trade_review") + self.assertIn("quality_gate", payload["backtest_summary"]) + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_runtime_script_json.py b/tests/unit/test_runtime_script_json.py new file mode 100644 index 0000000..9d06227 --- /dev/null +++ b/tests/unit/test_runtime_script_json.py @@ -0,0 +1,109 @@ +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 ( + apply_migrations, + check_db, + check_db_connection, + run_api_server, + run_dev, + run_displacement, + run_fvg, + run_liquidity, + run_mss, + run_ranges, + run_session_generator, + run_sweeps, + run_swings, + show_models, +) + + +class RuntimeScriptJsonTests(unittest.TestCase): + def test_structure_scripts_report_database_unavailable_json(self) -> None: + cases = [ + (run_session_generator, "SESSION_GENERATOR_OUTPUT_JSON", "scripts.run_session_generator.SessionGenerator.generate_for_date"), + (run_swings, "SWINGS_OUTPUT_JSON", "scripts.run_swings.SwingService.detect_swings"), + (run_ranges, "RANGES_OUTPUT_JSON", "scripts.run_ranges.DealingRangeService.build_active_range"), + (run_liquidity, "LIQUIDITY_OUTPUT_JSON", "scripts.run_liquidity.LiquidityPoolService.build_candidates"), + (run_sweeps, "SWEEPS_OUTPUT_JSON", "scripts.run_sweeps.SweepService.detect_sweeps"), + (run_displacement, "DISPLACEMENT_OUTPUT_JSON", "scripts.run_displacement.DisplacementService.detect_displacement"), + (run_mss, "MSS_OUTPUT_JSON", "scripts.run_mss.MSSService.detect_mss"), + (run_fvg, "FVG_OUTPUT_JSON", "scripts.run_fvg.FVGService.detect_fvg"), + ] + + 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"}) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 2) + self.assertFalse(payload["passed"]) + self.assertEqual(payload["error_type"], "OperationalError") + self.assertEqual(payload["instrument_id"], 1) + self.assertEqual(payload["timeframe"], "1m") + + def test_database_check_scripts_report_unavailable_json(self) -> None: + 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"), + ] + + 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"}) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 2) + self.assertFalse(payload["passed"]) + self.assertFalse(payload["available"]) + self.assertEqual(payload["error_type"], "OperationalError") + + def test_show_models_outputs_json(self) -> None: + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = show_models.main(env={"SHOW_MODELS_OUTPUT_JSON": "1"}) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 0) + self.assertTrue(payload["passed"]) + self.assertGreater(payload["model_count"], 0) + self.assertIn("candle", payload["models"]) + + def test_apply_migrations_delegates_to_safe_runner(self) -> None: + with patch("scripts.apply_migrations.run_migrations_main", return_value=2) as run_migrations: + exit_code = apply_migrations.main(env={"MIGRATIONS_OUTPUT_JSON": "1"}) + + self.assertEqual(exit_code, 2) + run_migrations.assert_called_once_with(env={"MIGRATIONS_OUTPUT_JSON": "1"}) + + def test_run_dev_preserves_zero_exit_code(self) -> None: + with patch("scripts.run_dev.app_main") as app_main: + exit_code = run_dev.main() + + self.assertEqual(exit_code, 0) + app_main.assert_called_once_with() + + def test_run_api_server_uses_env_and_returns_when_server_stops(self) -> None: + with patch("scripts.run_api_server.serve_api") as serve_api: + exit_code = run_api_server.main(env={"AI_ICT_API_HOST": "127.0.0.1", "AI_ICT_API_PORT": "8123"}) + + self.assertEqual(exit_code, 0) + serve_api.assert_called_once_with(host="127.0.0.1", port=8123) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_script_static_checks.py b/tests/unit/test_script_static_checks.py new file mode 100644 index 0000000..13175f6 --- /dev/null +++ b/tests/unit/test_script_static_checks.py @@ -0,0 +1,96 @@ +import contextlib +import io +import json +import os +import tempfile +import unittest +from pathlib import Path + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") + +from scripts import run_script_static_checks + + +class ScriptStaticChecksTests(unittest.TestCase): + def test_detects_ambient_env_fallback(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + script_dir = Path(tmpdir) + (script_dir / "bad.py").write_text("env = env or os.environ\n", encoding="utf-8") + + result = run_script_static_checks.run_script_static_checks(script_dir=script_dir) + + self.assertFalse(result["passed"]) + self.assertEqual(result["findings"][0]["file"], "bad.py") + self.assertEqual(result["findings"][0]["reason"], "empty_env_must_not_fall_back_to_ambient_environment") + + def test_passes_clean_scripts(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + script_dir = Path(tmpdir) + (script_dir / "ok.py").write_text("env = os.environ if env is None else env\n", encoding="utf-8") + + result = run_script_static_checks.run_script_static_checks(script_dir=script_dir) + + self.assertTrue(result["passed"]) + self.assertEqual(result["script_count"], 1) + + def test_detects_python_syntax_errors(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + script_dir = Path(tmpdir) + (script_dir / "broken.py").write_text("def nope(:\n", encoding="utf-8") + + result = run_script_static_checks.run_script_static_checks(script_dir=script_dir) + + self.assertFalse(result["passed"]) + self.assertEqual(result["findings"][0]["reason"], "script_python_syntax_error") + + def test_detects_missing_system_exit_for_automation_entrypoints(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + script_dir = Path(tmpdir) + (script_dir / "run_pipeline.py").write_text( + "def main() -> int:\n return 2\n\nif __name__ == '__main__':\n main()\n", + encoding="utf-8", + ) + + result = run_script_static_checks.run_script_static_checks(script_dir=script_dir) + + self.assertFalse(result["passed"]) + self.assertEqual(result["findings"][0]["reason"], "automation_entrypoint_must_preserve_exit_code") + + def test_detects_missing_system_exit_for_any_script_with_main(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + script_dir = Path(tmpdir) + (script_dir / "ad_hoc.py").write_text( + "def main() -> int:\n return 0\n\nif __name__ == '__main__':\n main()\n", + encoding="utf-8", + ) + + result = run_script_static_checks.run_script_static_checks(script_dir=script_dir) + + self.assertFalse(result["passed"]) + self.assertEqual(result["findings"][0]["file"], "ad_hoc.py") + self.assertEqual(result["findings"][0]["reason"], "automation_entrypoint_must_preserve_exit_code") + + def test_detects_python39_incompatible_optional_annotation(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + script_dir = Path(tmpdir) + (script_dir / "bad.py").write_text("def f(value: str | None = None):\n return value\n", encoding="utf-8") + + result = run_script_static_checks.run_script_static_checks(script_dir=script_dir) + + self.assertFalse(result["passed"]) + self.assertEqual(result["findings"][0]["reason"], "python39_incompatible_pep604_optional_annotation") + + def test_json_output_is_machine_readable(self) -> None: + output = io.StringIO() + + with contextlib.redirect_stdout(output): + exit_code = run_script_static_checks.main(env={"SCRIPT_CHECKS_OUTPUT_JSON": "1"}) + + summary = json.loads(output.getvalue()) + self.assertEqual(exit_code, 0) + self.assertTrue(summary["passed"]) + self.assertGreaterEqual(summary["script_count"], 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_signal_service.py b/tests/unit/test_signal_service.py index 996c6c4..7d6810b 100644 --- a/tests/unit/test_signal_service.py +++ b/tests/unit/test_signal_service.py @@ -1,10 +1,25 @@ +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 src.services.signals import classify_session_for_signal, trade_signal_matches_candidate, validate_signal_context -from src.services.signals.signal_service import SignalCandidate +from src.services.signals import ( + STRICT_SIGNAL_EVIDENCE_KEYS, + classify_session_for_signal, + trade_signal_matches_candidate, + validate_rr_thresholds, + validate_signal_evidence, + validate_signal_context, +) +from src.services.signals.signal_service import SignalCandidate, SignalService +from scripts import run_signals +from scripts.run_signals import build_signal_evidence_keys_from_env class SignalSessionFilterTests(unittest.TestCase): @@ -68,6 +83,109 @@ class SignalStructureAlignmentTests(unittest.TestCase): self.assertEqual(result["reason"], "aligned") +class SignalRRFilterTests(unittest.TestCase): + def test_rejects_rr_below_mvp_thresholds(self) -> None: + result = validate_rr_thresholds(rr_tp1=0.8, rr_tp2=1.9) + + self.assertFalse(result["is_actionable"]) + self.assertEqual(result["reasons"], ["tp1_rr_too_low", "tp2_rr_too_low"]) + + def test_build_signal_drops_low_rr_candidate_without_database(self) -> None: + service = SignalService( + repository=_SignalStubRepository( + fvg={"id": 2, "direction": "bullish", "lower": 100.0, "upper": 101.0, "status": "open", "meta": {}}, + ) + ) + + candidate = service.build_signal(instrument_id=1, setup_tf="1m", trigger_tf="1m") + + self.assertIsNone(candidate) + + def test_build_signal_keeps_candidate_when_rr_passes_without_database(self) -> None: + service = SignalService( + repository=_SignalStubRepository( + fvg={"id": 2, "direction": "bullish", "lower": 100.0, "upper": 100.1, "status": "open", "meta": {}}, + ) + ) + + candidate = service.build_signal(instrument_id=1, setup_tf="1m", trigger_tf="1m") + + self.assertIsNotNone(candidate) + assert candidate is not None + self.assertGreaterEqual(candidate.rr_tp1, 1.0) + self.assertGreaterEqual(candidate.rr_tp2, 2.0) + self.assertTrue(candidate.meta["evidence_quality"]["is_complete"]) + self.assertEqual(candidate.meta["rr_filter"]["reason"], "rr_ok") + + def test_build_signal_rejects_candidate_when_required_evidence_is_missing(self) -> None: + service = SignalService( + repository=_SignalStubRepository( + fvg={"id": 2, "direction": "bullish", "lower": 100.0, "upper": 100.1, "status": "open", "meta": {}}, + ), + required_evidence_keys=STRICT_SIGNAL_EVIDENCE_KEYS, + ) + + candidate = service.build_signal(instrument_id=1, setup_tf="1m", trigger_tf="1m") + + self.assertIsNone(candidate) + + +class SignalEvidenceTests(unittest.TestCase): + 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}) + + self.assertTrue(result["is_complete"]) + self.assertEqual(result["missing_keys"], []) + + def test_strict_evidence_reports_missing_future_chain_links(self) -> None: + result = validate_signal_evidence( + {"bias_snapshot_id": 1, "fvg_id": 2, "mss_id": 3}, + required_keys=STRICT_SIGNAL_EVIDENCE_KEYS, + ) + + self.assertFalse(result["is_complete"]) + self.assertEqual(result["missing_keys"], ["sweep_id", "displacement_id", "liquidity_target_id"]) + + def test_signal_evidence_mode_env_selects_required_keys(self) -> None: + self.assertEqual(build_signal_evidence_keys_from_env({}), ("bias_snapshot_id", "fvg_id", "mss_id")) + self.assertEqual(build_signal_evidence_keys_from_env({"SIGNAL_EVIDENCE_MODE": "strict"}), STRICT_SIGNAL_EVIDENCE_KEYS) + + def test_signal_evidence_mode_env_rejects_unknown_mode(self) -> None: + with self.assertRaisesRegex(ValueError, "unsupported_signal_evidence_mode"): + build_signal_evidence_keys_from_env({"SIGNAL_EVIDENCE_MODE": "loose"}) + + +class SignalScriptTests(unittest.TestCase): + def test_run_signals_reports_database_unavailable_json(self) -> None: + output = io.StringIO() + + with patch("scripts.run_signals.BiasService.build_bias", side_effect=OperationalError("stmt", {}, Exception())), contextlib.redirect_stdout(output): + exit_code = run_signals.main(env={"SIGNALS_OUTPUT_JSON": "1"}) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 2) + self.assertFalse(payload["passed"]) + self.assertEqual(payload["error_type"], "OperationalError") + self.assertEqual(payload["timeframe"], "1m") + + +class _SignalStubRepository: + def __init__(self, fvg: dict) -> None: + self.fvg = fvg + + def fetch_latest_bias_snapshot(self, instrument_id: int, setup_tf: str, trigger_tf: str): + return {"id": 1, "draw_side": "buy_side"} + + def fetch_latest_fvg(self, instrument_id: int, timeframe: str): + return self.fvg + + def fetch_latest_mss(self, instrument_id: int, timeframe: str): + return {"id": 3, "direction": "bullish", "accepted": True, "broken_level": 100.0, "displacement_event_id": 7} + + def fetch_session_at(self, instrument_id: int, ts): + return {"session_code": "LONDON"} + + class SignalIdempotencyTests(unittest.TestCase): def test_detects_equivalent_trade_signal(self) -> None: candidate = SignalCandidate( @@ -107,6 +225,44 @@ class SignalIdempotencyTests(unittest.TestCase): self.assertTrue(trade_signal_matches_candidate(latest, candidate, bias_snapshot_id=9)) + def test_detects_equivalent_trade_signal_with_rounding_differences(self) -> None: + candidate = SignalCandidate( + side="buy", + status="awaiting_confirmation", + entry_low=77200.0, + entry_high=77223.3, + stop_loss=77122.8, + tp1=77300.5233, + tp2=77454.9699, + rr_tp1=1.00033764772, + rr_tp2=2.73854755205, + session_code="LONDON", + created_ts="now", + expires_ts="later", + invalidations=["mss invalidated", "fvg fully mitigated"], + evidence={"bias_snapshot_id": 8, "fvg_id": 100, "mss_id": 3}, + meta={"execution_permission": True, "fvg_fill_pct": 0.0}, + ) + latest = { + "side": "buy", + "status": "awaiting_confirmation", + "bias_snapshot_id": 8, + "entry_type": "limit", + "entry_low": "77200.0000000000", + "entry_high": "77223.3000000000", + "stop_loss": "77122.8000000000", + "tp1": "77300.5233000000", + "tp2": "77454.9699000000", + "rr_tp1": "1.0003", + "rr_tp2": "2.7385", + "session_code": "LONDON", + "invalidations": ["mss invalidated", "fvg fully mitigated"], + "evidence": {"bias_snapshot_id": 8, "fvg_id": 100, "mss_id": 3}, + "meta": {"execution_permission": True, "fvg_fill_pct": 0.0}, + } + + self.assertTrue(trade_signal_matches_candidate(latest, candidate, bias_snapshot_id=8)) + if __name__ == "__main__": unittest.main()