From ea7d9eab63a246e9d00a1dd2cdf6bb08bca049ae Mon Sep 17 00:00:00 2001 From: aaron <> Date: Wed, 20 May 2026 00:57:46 +0800 Subject: [PATCH] update --- AGENTS.md | 495 ++-- app/core/opportunity_funnel.py | 9 + app/core/opportunity_level.py | 22 +- app/core/opportunity_lifecycle.py | 8 +- app/db/altcoin_db.py | 2390 +---------------- app/db/analytics.py | 23 +- app/db/coin_state_queries.py | 100 + app/db/cron_queries.py | 141 + app/db/push_queries.py | 79 + app/db/recommendation_commands.py | 474 ++++ app/db/recommendation_queries.py | 103 +- app/db/recommendation_state.py | 496 ++++ app/db/review_basic_queries.py | 83 + app/db/review_center.py | 20 +- app/db/review_queries.py | 10 +- app/db/screening_queries.py | 63 + app/db/strategy_insights.py | 234 ++ app/db/strategy_rule_queries.py | 636 +++++ app/db/tracking_queries.py | 160 ++ app/services/altcoin_confirm.py | 93 +- app/services/altcoin_screener.py | 48 +- app/services/price_tracker.py | 39 +- rules.yaml | 7 + .../test_actionable_active_recommendations.py | 12 + tests/test_opportunity_level.py | 44 + tests/test_pipeline_runs_api.py | 6 +- tests/test_price_tracker_watch_only_guard.py | 22 +- tests/test_screener_optimizations.py | 94 + tests/test_tracker_terminal_action_guard.py | 42 +- 29 files changed, 3352 insertions(+), 2601 deletions(-) create mode 100644 app/db/coin_state_queries.py create mode 100644 app/db/cron_queries.py create mode 100644 app/db/push_queries.py create mode 100644 app/db/recommendation_commands.py create mode 100644 app/db/recommendation_state.py create mode 100644 app/db/review_basic_queries.py create mode 100644 app/db/screening_queries.py create mode 100644 app/db/strategy_insights.py create mode 100644 app/db/strategy_rule_queries.py create mode 100644 app/db/tracking_queries.py diff --git a/AGENTS.md b/AGENTS.md index 1986af3..d3e8103 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,195 +2,312 @@ ## 1. 项目定位 -AlphaX 是一个以 `Python + FastAPI + SQLite + 静态 HTML` 组成的加密市场机会监控系统,当前核心目标不是做完整交易执行,而是围绕“发现机会 -> 确认机会 -> 跟踪机会 -> 复盘迭代”建立一套可持续优化的研究与提示闭环。 +AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组成的加密市场机会监控系统。当前核心目标不是完整自动交易执行,而是围绕“发现机会 -> 确认机会 -> 跟踪机会 -> 复盘迭代”建立一套可持续优化的研究、提示、模拟交易和复盘闭环。 -当前仓库是一个 **Docker 化副本**,从 `README_DOCKER.md` 的描述看,它被设计为和线上主实例隔离运行,默认端口、数据库挂载、调度器行为都以“先验证、后放量”为原则。 +当前仓库是一个 Docker 化运行目录。运行时数据库已经切换为 PostgreSQL;SQLite 只作为历史数据导入来源,不再作为应用运行时数据库。后续开发和排查都应以 PostgreSQL、`DATABASE_URL`、Docker 服务和当前 migration 为准。 ## 2. 当前技术栈 - 后端:`FastAPI`, `uvicorn`, `pydantic` -- 数据与计算:`sqlite3`, `pandas`, `numpy` +- 数据库:PostgreSQL 16,连接入口为 `DATABASE_URL` +- 数据访问:`psycopg` + 兼容旧 row 读取方式的 `DbRow` +- 数据与计算:`pandas`, `numpy` - 交易所/行情:`ccxt`, `requests` -- 配置:`rules.yaml` + `app/config/config_loader.py` +- 配置:`rules.yaml` + `app/config/config_loader.py` + `system_config`/runtime DB 配置 - 测试:`pytest` / `unittest` - 部署:`Dockerfile`, `docker-compose.yml` -- 前端:`static/*.html` 模板页,由 FastAPI/Jinja2 提供页面壳和 API +- 前端:`static/*.html` 页面,由 FastAPI/Jinja2 提供页面壳和 API -## 3. 代码主线 +## 3. 真实运行架构 -### 3.1 业务闭环 +### 3.1 Docker 服务 -建议把系统理解为 6 个层次: +当前 `docker-compose.yml` 定义的核心服务: -1. `app/services/altcoin_screener.py` - 负责粗筛,基于 Binance 行情、量价/结构等规则找候选币。 -2. `app/services/altcoin_confirm.py` - 负责确认,判断是否形成更可执行的机会,并生成入场计划、上下文和推送候选。 -3. `app/services/price_tracker.py` - 负责跟踪活跃推荐,更新盈亏、止盈止损、趋势衰减、行动状态。 -4. `app/services/review_engine.py` +- `postgres` + - PostgreSQL 16 + - 默认宿主机端口 `5433 -> 5432` + - 数据保存在 compose volume `postgres_data` +- `alphax-web` + - FastAPI + 页面/API + - 默认宿主机端口 `8191 -> 8190` + - 启动入口:`app.web.web_server:app` +- `alphax-scheduler` + - 后台调度器 + - 从 PostgreSQL 中读取任务配置和运行状态 + - 以并发子进程运行,通过 lock group 避免关键写路径冲突 +- `alphax-price-streamer` + - 实时价格流服务 + - 更新最新价格缓存,供页面和部分链路读取 + +关键原则: + +- PostgreSQL 是唯一运行时数据库。 +- `DATABASE_URL` 是唯一运行时数据库连接入口。 +- SQLite 只用于历史导入脚本,不参与应用运行。 +- `.env`、`data/`、数据库文件、日志等不应被打进镜像。 + +### 3.2 数据库入口 + +- `app/db/schema.py` + - PostgreSQL schema/init 门面。 + - `init_db()` 实际调用 `apply_migrations()`。 + - `get_conn` 来自 `app.db.postgres_connection.connect`。 +- `app/db/postgres_connection.py` + - PostgreSQL 连接、migration、row factory 的底层实现。 + - `DbRow` 用于兼容旧代码里按下标读取 row 的过渡逻辑。 +- `app/db/migrations/*.sql` + - PostgreSQL migration 单一事实源。 + - 新表/字段优先新增 migration,不要在业务函数里临时补 schema。 + +### 3.3 调度入口 + +- `docker/entrypoint.sh` + - `web` -> 启动 uvicorn + - `scheduler` -> 启动 `docker/scheduler.py` + - `price-streamer` -> 启动实时价格流 + - `once` -> 执行单次命令 +- `docker/scheduler.py` + - 从 `scheduler_job_config` 加载任务。 + - 写入 `scheduler_runtime_status`。 + - 支持 `scheduler_manual_trigger` 手动触发。 + - 使用 lock group 控制并发冲突。 +- `app/cli.py` + - 统一命令入口:`screener`, `confirm`, `tracker`, `paper-trader`, `price-streamer`, `market`, `review`, `event`, `sentiment`, `onchain`, `llm-insights`。 + +## 4. 代码主线 + +### 4.1 推荐系统业务闭环 + +建议把系统理解为 8 个层次: + +1. `app/services/market_overview.py` + 采集全市场快照,为行情环境、涨幅榜和市场温度提供数据。 +2. `app/services/altcoin_screener.py` + 负责粗筛/细筛,基于 Binance 行情、量价/结构等规则找候选币。 +3. `app/services/altcoin_confirm.py` + 负责确认,判断候选是否形成更可执行的机会,并生成入场计划、上下文和推送候选。 +4. `app/services/event_driven_screener.py` + 负责事件/舆情驱动的快速触发检查,是技术筛选主链路的补充入口。 +5. `app/services/price_streamer.py` + 负责实时价格缓存,不等同于完整推荐状态跟踪。 +6. `app/services/price_tracker.py` + 负责可执行推荐的价格跟踪、状态迁移和动态风险提示。 +7. `app/services/paper_trader.py` + 负责模拟交易账本同步,真实 TP/SL、移动止盈、杠杆和资金口径在 paper trading 层管理。 +8. `app/services/review_engine.py` 负责复盘与策略自迭代,包括信号绩效、漏选复盘、规则候选、版本演进。 -5. `app/services/event_driven_screener.py` - 负责事件/舆情驱动的快速触发检查,属于技术筛选主链路的补充入口。 -6. `app/web/web_server.py` - 现在主要负责 FastAPI 应用装配、模板装配和中间件绑定。 -### 3.2 数据与状态中心 +### 4.2 Web/API -- `app/db/altcoin_db.py` 仍是交易/推荐/状态的核心数据库层,体量很大,当前主要承担: - - 初始化表结构 - - recommendation / screening_log / tracking / review 等主表读写 - - 推荐状态派生与展示口径整理 - - 部分状态迁移与兼容逻辑 +`app/web/web_server.py` 只应负责 FastAPI 应用装配、模板装配、中间件、全局异常处理和 router include。新增业务 API 优先放到对应 route 模块: + +- `app/web/routes_auth.py` +- `app/web/routes_chat.py` +- `app/web/routes_recommendations.py` +- `app/web/routes_review_center.py` +- `app/web/routes_strategy.py` +- `app/web/routes_onchain.py` +- `app/web/routes_paper_trading.py` +- `app/web/routes_market.py` +- `app/web/routes_admin.py` +- `app/web/routes_pages.py` +- `app/web/routes_content.py` +- `app/web/shared.py` + +如果要改 Web 逻辑,先判断问题属于哪一层: + +- 数据口径问题:优先检查 `app/db/*`、`app/core/opportunity_lifecycle.py`、`app/core/opportunity_funnel.py` +- 参数问题:优先检查 `rules.yaml`、`app/config/config_loader.py` +- 页面展示问题:再检查 `static/*.html` +- 管理端/运行态问题:检查 `app/db/scheduler_db.py`、`app/db/runtime_config_db.py`、`app/config/system_config.py` + +### 4.3 状态与漏斗中心 + +- `app/core/opportunity_lifecycle.py` + - 推荐生命周期、展示桶、执行状态、买点质量闸门的中心。 + - 新增状态时必须同步 API、前端、推送、统计和测试。 +- `app/core/opportunity_funnel.py` + - 漏斗阶段词表与筛选层映射中心。 + - 筛选、确认、pipeline 页面和分析口径应优先复用这里的 vocabulary。 +- `app/core/opportunity_level.py` + - 机会级别、持有周期、入场/止损/止盈模型等结构化口径。 +- `app/core/signal_taxonomy.py` + - 信号分类与信号语义口径。 + +## 5. 数据与状态中心 + +### 5.1 DB 模块分工 + +- `app/db/altcoin_db.py` + - 现在主要是历史兼容门面和少量读接口;推荐写入、推送、状态派生、跟踪、筛选、cron、复盘基础写入等都应走细分模块。 + - 后续新增查询不要默认继续塞回这里,优先放到更细分模块。 +- `app/db/recommendation_commands.py` + - 推荐创建、旧推荐过期、推荐 action_status 状态迁移、派生字段重算、操作状态更新。 +- `app/db/coin_state_queries.py` + - 旧 `coin_state` 兼容状态写入、active 状态读取、过期状态清理。 +- `app/db/screening_queries.py` + - 筛选日志写入、细筛历史读取、确认层候选读取。 +- `app/db/recommendation_state.py` + - 推荐状态派生、展示桶、发现层/交易层字段、entry_plan 解析、观察池分层。 - `app/db/recommendation_queries.py` - - 已开始承接推荐热路径查询和推送冷却判断,如 active 推荐查询、push 去重、推送消费口径 + - 推荐热路径查询、active/deduped 查询;不应反向依赖 `altcoin_db.py`。 +- `app/db/push_queries.py` + - 推送冷却去重、推送日志、推送前单条推荐读取;推送层只能消费这里派生后的主链路口径。 +- `app/db/tracking_queries.py` + - 最新价格缓存、推荐跟踪价格/PnL 写入、入场时点更新。 +- `app/db/cron_queries.py` + - cron/调度任务运行日志写入、列表与汇总查询。 +- `app/db/review_basic_queries.py` + - 基础复盘写入、漏选记录写入、信号绩效和动态权重读取。 +- `app/db/strategy_rule_queries.py` + - 策略规则候选、失败模式、候选状态判定、历史回填、候选生成、dry-run/refresh、迭代 dashboard 等复盘迭代基础能力。 +- `app/db/strategy_insights.py` + - 策略归因读模型,基于 opportunity/recommendation 与 paper_trades 转化统计,不把 recommendation.pnl_pct 当交易收益。 - `app/db/analytics.py` - - 已开始承接筛选历史、复盘概览、cron 汇总等读多写少的查询 -- `app/db/auth_db.py` 是会员、邀请码、邮箱验证、订阅、订单预留的数据库层。 -- `app/core/opportunity_lifecycle.py` 是机会生命周期和买点质量闸门的规则中心,决定: - - 哪些机会只是观察池 - - 哪些机会可以进入“可即刻买入” - - 哪些状态应该被视为历史/盈利管理/观察态 + - 筛选历史、复盘概览、cron 汇总等读多写少查询。 +- `app/db/review_queries.py` + - 策略迭代日志和汇总查询;不应再直接顶层依赖 `altcoin_db.py`。 +- `app/db/admin_queries.py` + - 管理端数据查询。 +- `app/db/scheduler_db.py` + - 调度任务配置、运行态、手动触发。 +- `app/db/runtime_config_db.py` + - 运行时系统配置。 +- `app/db/paper_trading.py` + - 模拟交易账本、仓位、成交事件和资金口径。 +- `app/db/market_db.py` + - 市场快照。 +- `app/db/system_logs.py` + - 系统错误与异常日志。 +- `app/db/auth_db.py` + - 用户、会员、邀请码、邮箱验证、订阅、订单预留。 -### 3.3 配置中心 +### 5.2 核心表 -- `rules.yaml` 是策略配置单一事实源。 -- `app/config/config_loader.py` 负责: - - 读取/缓存配置 - - 暴露各子模块配置访问函数 - - 将部分复盘后的参数改写回 `rules.yaml` - - 兼容旧信号名 +当前 PostgreSQL 运行时重要表包括: -如果要改筛选阈值、确认门槛、止盈止损、动态权重逻辑,优先检查 `rules.yaml` 和 `app/config/config_loader.py`,不要直接在业务脚本里硬编码新参数。 +- `recommendation` +- `screening_log` +- `price_tracking` +- `latest_price_cache` +- `review_log` +- `missed_explosions` +- `cron_run_log` +- `scheduler_job_config` +- `scheduler_runtime_status` +- `scheduler_manual_trigger` +- `paper_trades` +- `paper_trade_events` +- `market_snapshots` +- `sentiment_events` +- `onchain_*` +- `llm_insights` +- `system_config` +- `system_error_log` +- `app_user` / `user_*` / `subscription_plan` -## 4. 目录速览 +不要把 SQLite 文件或 `data/altcoin_monitor.db` 当作当前状态来源。排查最近链路数据时,优先查询 PostgreSQL。 -### 4.1 核心目录 +## 6. 配置中心 + +- `rules.yaml` 是策略参数单一事实源。 +- `app/config/config_loader.py` 负责读取、缓存、兼容旧信号名,以及部分复盘参数写回。 +- `app/config/rules_schema.py` 负责规则结构校验。 +- `app/config/system_config.py` 负责运行时系统配置,如 scheduler dry run、poll interval 等。 +- `system_config` / `strategy_runtime_config` 等 PostgreSQL 表承载运行态配置。 + +如果要改筛选阈值、确认门槛、止盈止损、动态权重逻辑,优先检查 `rules.yaml` 和 `app/config/config_loader.py`。如果要改调度行为或系统开关,优先检查 runtime config,而不是只看环境变量。 + +## 7. 目录速览 - `/app` - - 当前真实实现层,按职责拆成 `services`, `db`, `core`, `config`, `integrations`, `analysis`, `web` + - 真实实现层,按职责拆成 `services`, `db`, `core`, `config`, `integrations`, `analysis`, `web` - `/static` - - 页面文件,如 `app.html`, `auth.html`, `subscription.html`, `strategy.html` + - 页面文件,如 `app.html`, `pipeline.html`, `paper_trading.html`, `review_center.html`, `market.html`, `onchain.html`, `chat.html` - `/tests` - - 针对状态机、认证订阅、复盘、事件驱动、策略版本等的回归测试 + - 状态机、认证订阅、推荐链路、调度、模拟交易、行情、复盘、前端页面约束等回归测试 - `/scripts` - - 若干结构/状态机/信号时效性校验脚本 + - 校验脚本和 PostgreSQL 导入/备份/恢复脚本 +- `/scripts/postgres` + - PostgreSQL migration、SQLite 历史导入、导入校验、备份恢复 - `/docker` - - 容器入口与串行调度器 + - 容器入口与调度器 - `/tools` - 非主链路工具脚本,如回测和输出摘要脚本 - `/templates` - - 被后端读取的 HTML 模板资源 -- `/reports` - - 本地分析/回测结果产物 -- `/legacy` - - 历史实验脚本、旧页面备份、临时整理归档 + - 后端读取的 HTML 模板资源 +- `/docs` + - 项目结构、迁移、专题审计、参考 schema 等文档 - `/data` - - SQLite 数据库存放目录 + - 本地挂载数据目录,主要用于历史导入源或运行产物,不是 PostgreSQL 主存储 - `/logs` - 运行日志目录 -- `/docs` - - 当前只有少量专题文档,不是完整系统文档中心 -### 4.2 根目录关键文件 - -- [rules.yaml](/Users/aaron/Desktop/code/alphax-docker/rules.yaml) -- [docker-compose.yml](/Users/aaron/Desktop/code/alphax-docker/docker-compose.yml) -- [README_DOCKER.md](/Users/aaron/Desktop/code/alphax-docker/README_DOCKER.md) - -### 4.3 根目录保留原则 - -根目录应尽量只保留这些类型: +根目录应尽量只保留: - 顶层配置 - Docker 入口与部署文件 - 项目说明文档 - 明确约定的非代码资产目录 -Python 业务实现不应再直接留在根目录。 +Python 业务实现不应直接留在根目录。 -像下面这些内容应放在分层目录里: +## 8. 运行与验证 -- 服务流程:`/app/services` -- 数据访问:`/app/db` -- 领域与规则:`/app/core` -- 配置访问:`/app/config` -- Web 层:`/app/web` -- 第三方集成:`/app/integrations` -- 顶层配置 -- Docker 入口 -- 关键说明文档 +### 8.1 Docker 启动 -## 5. Web/API 观察 +常规启动: -Web 层已经开始拆分,当前建议优先在这些文件上继续演进: +```bash +cp .env.example .env +docker compose build +docker compose up -d postgres alphax-web alphax-scheduler alphax-price-streamer +``` -- `app/web/routes_auth.py` -- `app/web/routes_recommendations.py` -- `app/web/routes_strategy.py` -- `app/web/routes_admin.py` -- `app/web/routes_pages.py` -- `app/web/routes_content.py` -- `app/web/shared.py` +访问: -如果要改 Web 逻辑,先确认变更属于哪一类: +```text +http://127.0.0.1:8191 +``` -- “数据口径问题”优先去 `app/db/altcoin_db.py` / `app/core/opportunity_lifecycle.py` -- “参数问题”优先去 `rules.yaml` -- “页面展示问题”再回到 `static/*.html` +常用状态检查: -`app/web/web_server.py` 不应该再继续承载业务路由实现;新增接口优先落到对应 `routes_*` 模块。 +```bash +docker compose ps +docker compose logs --tail=100 alphax-web +docker compose logs --tail=100 alphax-scheduler +docker compose logs --tail=100 alphax-price-streamer +``` -## 6. 调度与运行方式 +容器内 API smoke: -### 6.1 Docker 运行 +```bash +docker compose exec alphax-web curl -fsS http://127.0.0.1:8190/api/stats +docker compose exec alphax-web curl -fsS 'http://127.0.0.1:8190/api/pipeline/runs?page=1&page_size=5' +docker compose exec alphax-web curl -fsS http://127.0.0.1:8190/api/onchain/overview +``` -主要服务: +### 8.2 CLI -- `alphax-web` - - 对外提供 FastAPI + 页面 - - 宿主机默认映射 `8191 -> 8190` -- `alphax-scheduler` - - 串行调度任务 - - 默认 `ALPHAX_SCHEDULER_DRY_RUN=1` +统一入口: -关键原则: +```bash +python -m app.cli screener +python -m app.cli confirm +python -m app.cli tracker +python -m app.cli paper-trader +python -m app.cli market +python -m app.cli review +python -m app.cli event +python -m app.cli sentiment --collect +python -m app.cli onchain +python -m app.cli llm-insights --scope sentiment --limit 40 +``` -- 调度器是 **串行** 执行任务,用来规避 SQLite 写锁冲突。 -- 副本默认不应影响线上实例。 -- 数据库挂载路径是 `./data/altcoin_monitor.db`。 +Docker 内建议通过 `docker compose exec alphax-web python -m app.cli ...` 执行,确保使用容器内 `DATABASE_URL` 和依赖环境。 -### 6.2 入口 - -- `docker/entrypoint.sh` - - `web` -> 启动 uvicorn (`app.web.web_server:app`) - - `scheduler` -> 启动 `docker/scheduler.py` - - `once` -> 执行单次脚本 -- `app/cli.py` - - 统一命令入口:`screener / confirm / tracker / review / event / sentiment` -- `docker/scheduler.py` - - 统一通过 `python -m app.cli ...` 串行调度任务 - -## 7. 测试与验证建议 - -### 7.1 优先跑的测试 - -每次涉及以下模块改动时,至少补跑相关测试: - -- 状态机 / 展示口径 / 推送条件 - - `tests/test_recommendation_state_mainline.py` - - `tests/test_recommendation_execution_status.py` - - `tests/test_opportunity_lifecycle.py` -- 认证订阅 - - `tests/test_user_subscription_auth.py` -- 舆情事件 - - `tests/test_event_driven_screener.py` -- 时效性与门控 - - `tests/test_confirm_freshness_gate.py` - - `tests/test_pa_recency.py` - - `tests/test_vp_fly_recency.py` - -### 7.2 推荐命令 +### 8.3 测试与校验 常用回归命令: @@ -202,81 +319,117 @@ python3 scripts/validate_push_state_flow.py python3 scripts/validate_signal_recency.py ``` +涉及 PostgreSQL migration/import 时: + +```bash +docker compose run --rm alphax-web python scripts/postgres/run_migrations.py +docker compose run --rm alphax-web python scripts/postgres/validate_import.py --all-tables +``` + 如果只是小范围修改,优先跑和改动模块最相关的测试文件,不要盲目只看 `pytest` 是否全绿。 -## 8. 开发守则 +## 9. 开发守则 -### 8.1 改动前先判断“应该改哪一层” +### 9.1 改动前先判断“应该改哪一层” - 调参数:优先 `rules.yaml` -- 配置读取/兼容:`app/config/config_loader.py` -- 状态口径:`app/core/opportunity_lifecycle.py` 或 `app/db/altcoin_db.py` -- DB 表结构/查询:`app/db/altcoin_db.py` / `app/db/auth_db.py` +- 策略配置读取/兼容:`app/config/config_loader.py` +- 系统运行态配置:`app/config/system_config.py` / `app/db/runtime_config_db.py` +- 状态口径:`app/core/opportunity_lifecycle.py` +- 漏斗口径:`app/core/opportunity_funnel.py` +- DB 表结构:新增 PostgreSQL migration +- DB 查询:优先对应 `app/db/*_queries.py` 或细分 DB 模块 - API 契约:优先对应 `app/web/routes_*.py` - 页面壳和交互:`static/*.html` +- 调度任务:`app/cli.py` + `app/db/scheduler_db.py` + `docker/scheduler.py` -### 8.2 SQLite 相关约束 +### 9.2 PostgreSQL 约束 -这个项目高度依赖 SQLite,因此要特别注意: +这个项目当前运行时依赖 PostgreSQL,因此要特别注意: -- 避免引入并发写入路径 -- 避免在不同脚本里制造长事务 -- 任何新增 cron/task 设计,都先考虑是否会和现有调度冲突 -- 新增写操作时,优先复用已有 `get_conn()` 约定 +- 不要新增 SQLite 运行时分支。 +- 不要把 `data/*.db` 当作线上或当前状态来源。 +- schema 变化必须通过 `app/db/migrations/*.sql`。 +- 查询最新运行状态优先看 PostgreSQL 表,而不是历史文件。 +- Docker 容器内运行和宿主机运行可能使用不同连接地址,排查时先确认 `DATABASE_URL`。 +- 调度器并发运行时要检查 lock group,避免多个任务同时写推荐主链路。 -### 8.3 状态机不要各写各的 +### 9.3 状态机不要各写各的 -项目已经存在比较强的“状态派生中心化”趋势: +项目已经存在比较强的状态派生中心化趋势: - `normalize_action_status` - `derive_display_bucket` - `apply_entry_quality_gate` - `apply_recommendation_state_transition` +- `screening_stage_meta` +- `build_screening_detail` 新增状态时,必须检查: -1. DB 中的原始状态字段怎么存 -2. API 输出怎么派生 -3. 前端如何解释 -4. 推送是否会误触发 -5. 相关测试是否需要补齐 +1. DB 中原始状态字段怎么存。 +2. API 输出怎么派生。 +3. 前端如何解释。 +4. 推送是否会误触发。 +5. paper trading 是否会误开仓或误平仓。 +6. 复盘统计是否会被污染。 +7. 相关测试是否需要补齐。 -### 8.4 面向兼容开发 +### 9.4 推荐链路当前特别注意点 -这个仓库里有不少“迭代中兼容旧逻辑”的痕迹,例如: +当前主链路已经能持续产生筛选和确认样本,但后半段仍需要重点盯住: -- `app/config/config_loader.py` 中的信号别名兼容 -- `app/db/altcoin_db.py` 中大量 `ALTER TABLE` 迁移兜底 -- 多处 `entry_plan_json` / `detail_json` / `*_context_json` +- `latest_price_cache` 可能是实时的,但不代表 `recommendation.pnl_pct` 已更新。 +- `price_tracking` 是跟踪流水,不应和 `latest_price_cache` 混为一谈。 +- `price_tracker.py` 会为 active 观察池样本更新观察价/PnL,但未触发入场的 watch_pool/wait_pullback 不能触发止盈止损、不能进入 paper trading 收益账本。 +- `rec_state` 是发现层状态(如“爆发/加速”),`execution_status`/`trade_stage` 才是交易执行阶段(如 `buy_now`/`wait_pullback`/`observe`),不要把“发现爆发”直接解读成“现在可买”。 +- 静K蓄力旁路已要求配置化共振(见 `rules.yaml` 的 `screener.static_accumulation_bypass.require_resonance`),避免单一静K样本淹没确认层;无追高风险的强势榜异动仍可作为发现入口。 +- `paper_trader.py` 只应处理可执行推荐,不能把观察池样本当成已成交。 +- `review_engine.py` 的可信度依赖跟踪数据质量;如果 PnL 没更新,复盘结论也会失真。 +- `missed_explosions` 历史数据可能存在同一 symbol 多次记录,读模型/KPI 需要保持去重口径,写入侧后续仍建议加唯一性或冷却约束。 + +### 9.5 面向兼容开发 + +这个仓库里仍有不少“迭代中兼容旧逻辑”的痕迹,例如: + +- `DbRow` 兼容旧的 row 下标读取。 +- `app/config/config_loader.py` 中的信号别名兼容。 +- 多处 `entry_plan_json` / `detail_json` / `*_context_json`。 +- `app/db/altcoin_db.py` 仍承载较多历史兼容逻辑。 因此改动时应优先做增量兼容,而不是假设数据库、配置、旧数据永远是干净新鲜的。 -## 9. 当前仓库的几个事实 +## 10. 当前仓库事实 -- 当前目录 **不是 git 仓库根目录**,`git status` 会失败;如果后续需要版本管理,请先确认真正的 Git 根目录或重新初始化。 -- [DESIGN.md](/Users/aaron/Desktop/code/alphax-docker/DESIGN.md) 当前内容更像一份品牌/样式 YAML,不是这个项目的系统设计文档,阅读时不要误判。 -- 根目录存在一些临时/非核心文件,例如 `.tmp_patch_tp1.py`、`.tmp_strategy_v2_marker.txt`、`=`,后续开发前建议先确认这些文件是否仍有保留价值。 -- `app/db/altcoin_db.py` 仍然很大,后续新增 DB 查询应优先放到 `recommendation_queries.py`、`analytics.py`、`review_queries.py` 等分组模块。 +- 当前运行态是 PostgreSQL,不是 SQLite。 +- `README_DOCKER.md` 是 Docker/PostgreSQL 运行说明的重要事实源。 +- `docs/PROJECT_STRUCTURE.md` 记录了目录整理背景,但具体运行状态仍以代码、compose 和 PostgreSQL 为准。 +- `DESIGN.md` 当前更像品牌/样式 YAML,不是系统架构设计文档。 +- `app/db/altcoin_db.py` 仍保留兼容门面,但推荐写入/状态迁移已迁到 `recommendation_commands.py`,推送去重已迁到 `push_queries.py`,状态派生、价格/跟踪写入、筛选日志、旧 coin_state、cron 日志、基础复盘写入、策略规则候选、策略归因也已迁出;后续新增 DB 查询应优先放到 `recommendation_queries.py`、`screening_queries.py`、`tracking_queries.py`、`cron_queries.py`、`review_basic_queries.py`、`strategy_rule_queries.py`、`strategy_insights.py`、`analytics.py`、`review_queries.py`、`admin_queries.py`、`paper_trading.py` 等分组模块。 -## 10. 推荐的后续重构方向 +## 11. 推荐的后续重构方向 -后续若继续开发,建议优先考虑这几个方向: +后续若继续开发,建议优先考虑: -1. 继续把剩余页面/内容能力从 `app/web` 拆成更细的 service 或 data provider,而不是继续把查询塞回路由层。 -2. 继续把 `app/db/altcoin_db.py` 的真实实现迁出到 schema/init、recommendation、review、analytics、admin 分组模块。 +1. 继续把 `app/db/altcoin_db.py` 剩余读接口迁到 `recommendation_queries.py` / `analytics.py`,最终让它只保留极薄兼容导出或逐步废弃。 +2. 为 watch_pool / wait_pullback 建立更完整的观察绩效报表,继续避免和已执行仓位 PnL 混在一起。 3. 把 `rules.yaml` 的 schema 校验从“顶层结构校验”推进到“关键子字段校验”。 4. 让 Docker、文档、测试样例全面收敛到 `python -m app.cli ...` 入口。 5. 继续梳理推送链路,把“是否推送”的判断、推送内容组装、通道发送彻底分层。 +6. 对 `missed_explosions` 写入侧建立唯一性或冷却约束,避免重复样本继续进入历史表。 +7. 梳理 price-streamer、tracker、paper-trader 三者边界,确保实时价格、推荐跟踪、模拟成交各自语义清晰。 -## 11. 给后续 Agent 的工作方式建议 +## 12. 给后续 Agent 的工作方式建议 接手这个仓库时,优先按下面顺序理解问题: -1. 先确认问题发生在“筛选 / 确认 / 跟踪 / 复盘 / Web 展示 / 认证订阅”哪一层。 -2. 再确认它属于“参数失真、状态派生错误、DB 查询口径不一致、前端展示错误、任务调度副作用”中的哪一类。 -3. 修改后至少补 1 个相关测试,最好补到最接近业务口径的那层。 -4. 如果变更影响推荐状态或展示桶,务必同时检查 API、前端、推送、历史统计四个面。 +1. 先确认问题发生在“筛选 / 确认 / 跟踪 / 模拟交易 / 复盘 / Web 展示 / 认证订阅 / 调度运行态”哪一层。 +2. 再确认它属于“参数失真、状态派生错误、DB 查询口径不一致、前端展示错误、任务调度副作用、数据水位滞后、样本去重缺失”中的哪一类。 +3. 排查数据时直接查 PostgreSQL,不要使用 SQLite 文件作为当前状态来源。 +4. 修改后至少补 1 个相关测试,最好补到最接近业务口径的那层。 +5. 如果变更影响推荐状态或展示桶,务必同时检查 API、前端、推送、paper trading、历史统计五个面。 +6. 如果变更影响调度任务,务必检查 `scheduler_job_config`、`scheduler_runtime_status` 和最近 `cron_run_log`。 --- -这份文档以当前仓库实际代码为准整理。如果后续完成模块拆分、引入新的持久层或把静态页面改造成更现代的前端工程,记得同步更新本文件,而不是让它变成另一份过时说明书。 +这份文档以当前仓库实际代码、Docker compose 和 PostgreSQL 运行态为准整理。后续如果引入新的服务、迁移表结构、改变调度模式或调整推荐状态机,必须同步更新本文件,避免后续 Agent 再被过时信息带偏。 diff --git a/app/core/opportunity_funnel.py b/app/core/opportunity_funnel.py index 9c6361b..3dca5d5 100644 --- a/app/core/opportunity_funnel.py +++ b/app/core/opportunity_funnel.py @@ -134,6 +134,8 @@ def quality_filter_reasons(candidate: Dict[str, Any], score: int, threshold: int signals = list(signals or candidate.get("signals") or candidate.get("anomalies") or []) text = " ".join(str(s) for s in signals) codes: List[str] = [] + has_top_gainer = bool(candidate.get("top_gainer_24h") or "24h强势榜" in text or "涨幅榜" in text) + chase_risk = bool(candidate.get("top_gainer_chase_risk") or any(keyword in text for keyword in ("追高", "站稳突破位+", "离突破位+"))) if score < threshold: codes.append("low_score") @@ -148,6 +150,13 @@ def quality_filter_reasons(candidate: Dict[str, Any], score: int, threshold: int if any(keyword in text for keyword in ("板块联动", "舆情共振", "链上", "DEX", "鲸鱼", "聪明钱")): codes.append("multi_source_resonance") + # 24h 强势榜异动属于“发现层”信号,不能因为低分或旧背景被直接打成纯拒绝。 + # 只要没有明显追高/出货风险,就保留为可继续验证的候选。 + if has_top_gainer and not chase_risk and "stale_signal" in codes and "multi_source_resonance" not in codes: + codes = [code for code in codes if code != "stale_signal"] + if has_top_gainer and not chase_risk and score < threshold and "low_score" in codes and "stale_signal" not in codes: + codes = [code for code in codes if code != "low_score"] + labels = [QUALITY_REASON_LABELS.get(code, code) for code in dict.fromkeys(codes)] return {"codes": list(dict.fromkeys(codes)), "labels": labels} diff --git a/app/core/opportunity_level.py b/app/core/opportunity_level.py index 2d89a48..e9aca5c 100644 --- a/app/core/opportunity_level.py +++ b/app/core/opportunity_level.py @@ -28,6 +28,14 @@ OPPORTUNITY_LEVELS: Dict[str, Dict[str, str]] = { "tp_model": "4H压力位 / 前高 / 移动止盈", "max_action": "buy_now", }, + "momentum_watch": { + "label": "强势观察", + "holding_horizon": "数小时-2天", + "entry_model": "涨幅榜强势 / 等二次结构", + "stop_model": "等待回踩低点 / 新结构失效位", + "tp_model": "不追首段 / 只跟踪二次买点", + "max_action": "observe", + }, "structure_watch": { "label": "结构观察", "holding_horizon": "3-7天", @@ -47,7 +55,7 @@ OPPORTUNITY_LEVELS: Dict[str, Dict[str, str]] = { } -LEVEL_ORDER = ("intraday_breakout", "short_swing", "structure_watch", "theme_trend") +LEVEL_ORDER = ("intraday_breakout", "short_swing", "momentum_watch", "structure_watch", "theme_trend") def _text(signals: Iterable[Any]) -> str: @@ -111,6 +119,8 @@ def classify_opportunity_level( has_1h_momentum = _has_any(text, ("1H 量价齐飞", "1H放量突破", "1H极放量", "1H 起爆", "1H 动K", "1H 1根量价齐飞")) has_30m_bridge = bool(m30_aligned) or _has_any(text, ("30min", "30m")) has_4h_or_daily = _has_any(text, ("4H", "日线", "周线", "需求区", "突破回踩", "底部", "静K", "蓄力")) + has_top_gainer = _has_any(text, ("24h强势榜", "强势榜异动", "涨幅榜")) + has_chase_risk = _has_any(text, ("追高", "离突破位+", "站稳突破位+")) has_theme = bool(sector_context.get("hot_sectors")) or _has_any( text, ("主题", "生态", "舆情", "板块", "listing", "公告", "催化"), @@ -126,15 +136,21 @@ def classify_opportunity_level( basis.append("30m结构桥接") if has_4h_or_daily: basis.append("高周期结构背景") + if has_top_gainer: + basis.append("24h强势榜异动") + if has_chase_risk: + basis.append("追高/首段已启动风险") if has_theme: basis.append("主题/板块线索") if stale_only: basis.append("旧信号仅作背景") - if has_15m_trigger and has_1h_momentum and not stale_only: + if has_15m_trigger and has_1h_momentum and not stale_only and not has_chase_risk: level = "intraday_breakout" elif (has_1h_momentum and (has_30m_bridge or has_4h_or_daily)) and not stale_only: level = "short_swing" + elif has_top_gainer and (has_15m_trigger or has_4h_or_daily or has_1h_momentum): + level = "momentum_watch" elif has_theme and not has_15m_trigger and not has_1h_momentum: level = "theme_trend" elif has_4h_or_daily or stale_only: @@ -202,6 +218,8 @@ def level_tp_parameters(level: str) -> Dict[str, float]: return {"tp1_atr": 2.0, "tp1_floor": 0.03, "tp2_atr": 3.5, "tp2_floor": 0.05} if level == "short_swing": return {"tp1_atr": 3.0, "tp1_floor": 0.05, "tp2_atr": 5.0, "tp2_floor": 0.08} + if level == "momentum_watch": + return {"tp1_atr": 3.0, "tp1_floor": 0.06, "tp2_atr": 5.0, "tp2_floor": 0.10} if level == "theme_trend": return {"tp1_atr": 6.0, "tp1_floor": 0.12, "tp2_atr": 10.0, "tp2_floor": 0.20} return {"tp1_atr": 4.0, "tp1_floor": 0.08, "tp2_atr": 7.0, "tp2_floor": 0.14} diff --git a/app/core/opportunity_lifecycle.py b/app/core/opportunity_lifecycle.py index f01688a..b0a9992 100644 --- a/app/core/opportunity_lifecycle.py +++ b/app/core/opportunity_lifecycle.py @@ -281,6 +281,8 @@ def apply_entry_quality_gate( current_price = to_float(current_price) or to_float(entry_plan.get("current_price")) or to_float(entry_plan.get("entry_price")) rr1 = to_float(entry_plan.get("rr1"), 999.0) risk_reward_ok = entry_plan.get("risk_reward_ok") + original_rr1 = rr1 + original_risk_reward_ok = risk_reward_ok stop_loss = to_float(entry_plan.get("stop_loss")) tp1 = to_float(entry_plan.get("tp1") or entry_plan.get("take_profit_1")) plan_entry_price = to_float(entry_plan.get("entry_price")) @@ -329,10 +331,10 @@ def apply_entry_quality_gate( }) if action_status in ("可即刻买入", "等回踩"): - if risk_reward_ok is False: + if original_risk_reward_ok is False: reasons.append(f"risk_reward_ok=false,盈亏比闸门禁止现价买入;实时rr1={rr1}") - if "rr1" in entry_plan and rr1 < _cfg_value(cfg, "min_rr_buy_now"): - reasons.append(f"rr1={rr1} < {_cfg_value(cfg, 'min_rr_buy_now')},禁止现价买入") + if "rr1" in entry_plan and original_rr1 < _cfg_value(cfg, "min_rr_buy_now"): + reasons.append(f"rr1={original_rr1} < {_cfg_value(cfg, 'min_rr_buy_now')},禁止现价买入") if action_status == "可即刻买入": if level_max_action in ("observe", "wait_pullback"): diff --git a/app/db/altcoin_db.py b/app/db/altcoin_db.py index e560862..4922bf2 100644 --- a/app/db/altcoin_db.py +++ b/app/db/altcoin_db.py @@ -4,19 +4,46 @@ """ import json -import re from datetime import datetime, timedelta -from app.config.config_loader import get_meta, get_screener_section, confirm_state_cooldown_hours -from app.core.opportunity_lifecycle import ( - apply_entry_quality_gate, - normalize_json_object, - derive_display_bucket, - normalize_action_status, - is_executed_lifecycle, +from app.config.config_loader import get_meta +from app.db import recommendation_commands as _recommendation_commands +from app.db.coin_state_queries import expire_old_states, get_all_active, update_state +from app.db.cron_queries import get_cron_run_logs, get_cron_run_summary, log_cron_run +from app.db.postgres_connection import apply_migrations, connect as pg_connect +from app.db.recommendation_state import ( + classify_recommendation_result as _classify_recommendation_result, + derive_execution_fields as _derive_execution_fields, + is_actionable_execution_status as _is_actionable_execution_status, + is_executed_trade as _is_executed_trade, +) +from app.db.push_queries import PUSH_COOLDOWN_HOURS, get_recommendation_for_push, log_push, should_push +from app.db.review_basic_queries import ( + get_signal_weights, + record_missed_explosion, + record_review, + update_signal_performance, +) +from app.db.screening_queries import get_candidates_for_confirm, get_screening_history, log_screening +from app.db.strategy_rule_queries import ( + backfill_strategy_failure_patterns, + dry_run_strategy_candidate_performance, + generate_candidates_from_review_history, + get_strategy_failure_patterns, + get_strategy_iteration_dashboard, + get_strategy_rule_candidates, + record_strategy_failure_pattern, + refresh_strategy_candidate_performance, + update_strategy_rule_candidate_status, + upsert_strategy_rule_candidate, +) +from app.db.strategy_insights import get_strategy_insights +from app.db.tracking_queries import ( + get_latest_price_cache, + update_entry_timing, + update_latest_price_cache, + update_recommendation_tracking, ) -from app.core.signal_taxonomy import signal_codes as build_signal_codes, signal_labels as build_signal_labels -from app.db.postgres_connection import apply_migrations, connect as pg_connect, table_columns def get_conn(): return pg_connect() @@ -27,1099 +54,35 @@ def init_db(): print("PostgreSQL schema migrations checked") -# === 推送去重 === -PUSH_COOLDOWN_HOURS = 12 +def _sync_command_compat_hooks(): + """Keep legacy altcoin_db monkeypatch hooks effective after command extraction.""" + _recommendation_commands.get_meta = get_meta + _recommendation_commands.datetime = datetime -def should_push(symbol: str, push_type: str, action_status: str = "") -> bool: - """检查是否可以推送: - - 如果提供了 action_status:同状态+同type冷却期内已推过 → False,状态变了无条件放行 - - 如果未提供 action_status(如burst):同type冷却期内任何推过 → False - """ - conn = get_conn() - cutoff = (datetime.now() - timedelta(hours=PUSH_COOLDOWN_HOURS)).isoformat() - if action_status: - # 状态感知:只拦同状态重复推,状态变了永远放行 - row = conn.execute( - "SELECT action_status FROM push_log WHERE symbol=%s AND push_type=%s AND pushed_at > %s ORDER BY id DESC LIMIT 1", - (symbol, push_type, cutoff), - ).fetchone() - conn.close() - if row is None: - return True # 冷却期内没推过,放行 - last_status = row[0] - return last_status != action_status # 状态变了放行,相同则冷却 - else: - # 无状态(burst):同type任何推过就冷却 - row = conn.execute( - "SELECT id FROM push_log WHERE symbol=%s AND push_type=%s AND pushed_at > %s ORDER BY id DESC LIMIT 1", - (symbol, push_type, cutoff), - ).fetchone() - conn.close() - return row is None +def create_recommendation(*args, **kwargs): + _sync_command_compat_hooks() + return _recommendation_commands.create_recommendation(*args, **kwargs) -def log_push(symbol: str, push_type: str, action_status: str = "", rec_id: int = 0): - """记录一次推送。rec_id 可选,作为主链路推荐记录的可追溯来源。""" - conn = get_conn() - try: - conn.execute( - "INSERT INTO push_log (symbol, push_type, action_status, rec_id, pushed_at) VALUES (%s,%s,%s,%s,%s)", - (symbol, push_type, action_status, int(rec_id or 0), datetime.now().isoformat()), - ) - conn.commit() - finally: - conn.close() +def expire_old_recommendations(*args, **kwargs): + _sync_command_compat_hooks() + return _recommendation_commands.expire_old_recommendations(*args, **kwargs) -def get_recommendation_for_push(rec_id: int): - """读取单条推荐并派生网站同口径展示状态,供推送层消费。 +def apply_recommendation_state_transition(*args, **kwargs): + _sync_command_compat_hooks() + return _recommendation_commands.apply_recommendation_state_transition(*args, **kwargs) - 飞书/其他通知渠道只能消费这个主链路派生结果,不能自行基于事件或 entry_plan 做推荐判断。 - """ - try: - rec_id = int(rec_id or 0) - except Exception: - rec_id = 0 - if rec_id <= 0: - return None - conn = get_conn() - row = conn.execute(""" - SELECT r.*, - lpc.price AS latest_cache_price, - lpc.updated_at AS latest_cache_updated_at - FROM recommendation r - LEFT JOIN latest_price_cache lpc ON lpc.symbol = r.symbol - WHERE r.id=%s - """, (rec_id,)).fetchone() - conn.close() - if not row: - return None - item = dict(row) - rec_result, rec_result_label = _classify_recommendation_result(item) - item["recommendation_result"] = rec_result - item["recommendation_result_label"] = rec_result_label - return _derive_execution_fields(item) +def recompute_all_recommendation_state_fields(*args, **kwargs): + _sync_command_compat_hooks() + return _recommendation_commands.recompute_all_recommendation_state_fields(*args, **kwargs) -# ==================== 筛选记录 ==================== -def log_screening(layer, symbol, state, score, price, signals, - sector="", leader_status="", is_meme=0, - change_24h=0, funding_rate=0, detail=None): - """记录一次筛选结果""" - conn = get_conn() - conn.execute(""" - INSERT INTO screening_log (scan_time, layer, symbol, state, score, price, signals, - sector, leader_status, is_meme, change_24h, funding_rate, detail_json) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, ( - datetime.now().isoformat(), layer, symbol, state, score, price, - json.dumps(signals, ensure_ascii=False) if isinstance(signals, list) else signals, - sector, leader_status, is_meme, change_24h, funding_rate, - json.dumps(detail, ensure_ascii=False) if detail else "{}", - )) - conn.commit() - conn.close() - - -# ==================== 推荐记录 ==================== - -def _state_fields_for_storage(status, action_status, execution_status="", reason=""): - bucket = derive_display_bucket(status or "active", action_status, execution_status) - return ( - bucket.get("execution_status", execution_status or "observe"), - bucket.get("display_bucket", "watch_pool"), - bucket.get("lifecycle_state", "watching"), - 1 if is_executed_lifecycle(status or "active", action_status, bucket.get("execution_status")) else 0, - reason or "", - ) - -def _derive_minimal_state_fields(status, action_status, entry_plan=None): - action = normalize_action_status(action_status, status) - if action == "可即刻买入": - execution_status = "buy_now" - reason = "主链路确认当前入场窗口" - elif action == "等回踩": - execution_status = "wait_pullback" - reason = "等待回踩触发,未触发前不计推荐收益" - elif action == "持有": - execution_status = "holding" - reason = "已进入持仓跟踪" - elif action in ("止盈1", "止盈2", "跟踪止盈"): - execution_status = "completed" - reason = "利润管理/阶段兑现" - elif action in ("止损", "衰减", "反转", "放弃", "过期", "归档") or status in ("stopped_out", "expired", "invalid", "archived"): - execution_status = "invalid" - reason = "机会失效,归入历史复盘" - else: - execution_status = "observe" - reason = "观察池,未触发入场" - return _state_fields_for_storage(status, action, execution_status, reason) - - -def _serialized_signal_payload(signals): - labels = build_signal_labels(signals if isinstance(signals, list) else _normalize_signals(signals)) - codes = build_signal_codes(labels) - stored_signals = json.dumps(labels, ensure_ascii=False) if isinstance(signals, list) else signals - return stored_signals, json.dumps(codes, ensure_ascii=False), json.dumps(labels, ensure_ascii=False) - - -def _opportunity_fields_from_plan(entry_plan): - plan = entry_plan if isinstance(entry_plan, dict) else {} - return { - "opportunity_level": str(plan.get("opportunity_level") or ""), - "opportunity_level_label": str(plan.get("opportunity_level_label") or ""), - "holding_horizon": str(plan.get("holding_horizon") or ""), - "entry_model": str(plan.get("entry_model") or ""), - "stop_model": str(plan.get("stop_model") or plan.get("stop_basis") or ""), - "tp_model": str(plan.get("tp_model") or plan.get("tp_basis") or ""), - } - - -def create_recommendation(symbol, rec_state, rec_score, entry_price, - stop_loss=0, tp1=0, tp2=0, sector="", - signals="", is_meme=0, entry_plan=None, direction="中性", - force_reason="", base_state="", sector_signal_count=0, - market_context=None, derivatives_context=None, sector_context=None): - """创建推荐记录(加速/爆发时调用) - direction: 多头启动/空头启动/中性 — 推荐的方向标签 - 注意:rec_score 入参为原始分(0~30范围),落库时转为百分制(0~100),分母 30。 - """ - # 原始分 → 百分制(分母 30,天花板 100) - raw_pct = round(rec_score * 100.0 / 30) if rec_score else 0 - rec_score_pct = min(raw_pct, 100) - strategy_version = str(get_meta().get("strategy_version") or "").strip() - now = datetime.now().isoformat() - conn = get_conn() - - incoming_action = normalize_action_status((entry_plan or {}).get("entry_action", "观察") if entry_plan else "观察", "active") - incoming_exec, incoming_bucket, incoming_lifecycle, incoming_triggered, incoming_reason = _derive_minimal_state_fields( - "active", incoming_action, entry_plan or {} - ) - stored_signals, signal_codes_json, signal_labels_json = _serialized_signal_payload(signals) - opportunity_fields = _opportunity_fields_from_plan(entry_plan or {}) - # 当前状态唯一:同一 symbol 同一时间只允许一条可执行/观察主记录; - # 但兼容粗筛蓄力→加速/爆发的状态迁移测试:无 entry_plan 的旧粗筛记录仍可新建演化轨迹。 - duplicate_cursor = conn.execute( - """ - SELECT * FROM recommendation - WHERE symbol=%s AND status='active' AND COALESCE(display_bucket,'watch_pool') != 'history' - ORDER BY id DESC LIMIT 1 - """, - (symbol,), - ) - duplicate_row = duplicate_cursor.fetchone() if hasattr(duplicate_cursor, "fetchone") else None - if duplicate_row and (entry_plan or duplicate_row["rec_state"] == rec_state): - # 同一币种已有当前主记录时更新该记录,不再制造多个 active。 - # 无 entry_plan 的粗筛状态迁移仍允许保留演化轨迹。 - existing_id = duplicate_row["id"] if hasattr(duplicate_row, "keys") else duplicate_row[0] - existing_score = duplicate_row["rec_score"] or 0 - merged_state = rec_state - merged_score = max(existing_score, rec_score_pct) - conn.execute(""" - UPDATE recommendation - SET rec_state=%s, rec_score=%s, sector=COALESCE(NULLIF(%s, ''), sector), - signals=%s, signal_codes_json=%s, signal_labels_json=%s, is_meme=%s, direction=%s, strategy_version=%s, - force_reason=COALESCE(NULLIF(%s, ''), force_reason), - base_state=COALESCE(NULLIF(%s, ''), base_state), - sector_signal_count=GREATEST(COALESCE(sector_signal_count,0), %s), - entry_plan_json=CASE WHEN %s != '{}' THEN %s ELSE entry_plan_json END, - market_context_json=%s, derivatives_context_json=%s, sector_context_json=%s, - opportunity_level=COALESCE(NULLIF(%s, ''), opportunity_level), - opportunity_level_label=COALESCE(NULLIF(%s, ''), opportunity_level_label), - holding_horizon=COALESCE(NULLIF(%s, ''), holding_horizon), - entry_model=COALESCE(NULLIF(%s, ''), entry_model), - stop_model=COALESCE(NULLIF(%s, ''), stop_model), - tp_model=COALESCE(NULLIF(%s, ''), tp_model), - action_status=CASE - WHEN action_status IN ('止盈1','止盈2','止损','跟踪止盈','衰减','反转') THEN action_status - ELSE COALESCE(NULLIF(%s, ''), action_status) - END, - execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s - WHERE id=%s - """, ( - merged_state, merged_score, sector, - stored_signals, signal_codes_json, signal_labels_json, - is_meme, direction, strategy_version, - force_reason or "", base_state or "", int(sector_signal_count or 0), - json.dumps(entry_plan or {}, ensure_ascii=False), - json.dumps(entry_plan or {}, ensure_ascii=False), - json.dumps(market_context or {}, ensure_ascii=False), - json.dumps(derivatives_context or {}, ensure_ascii=False), - json.dumps(sector_context or {}, ensure_ascii=False), - opportunity_fields["opportunity_level"], - opportunity_fields["opportunity_level_label"], - opportunity_fields["holding_horizon"], - opportunity_fields["entry_model"], - opportunity_fields["stop_model"], - opportunity_fields["tp_model"], - incoming_action if entry_plan else "", - incoming_exec, incoming_bucket, incoming_lifecycle, incoming_triggered, incoming_reason, - existing_id, - )) - conn.commit() - conn.close() - return existing_id - - cursor = conn.execute(""" - INSERT INTO recommendation (symbol, rec_time, rec_state, rec_score, entry_price, - stop_loss, tp1, tp2, sector, signals, signal_codes_json, signal_labels_json, is_meme, direction, - current_price, max_price, min_price, last_track_time, entry_plan_json, - force_reason, base_state, sector_signal_count, - market_context_json, derivatives_context_json, sector_context_json, - opportunity_level, opportunity_level_label, holding_horizon, entry_model, stop_model, tp_model, - action_status, execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, - strategy_version) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - RETURNING id - """, ( - symbol, now, rec_state, rec_score_pct, entry_price, - stop_loss, tp1, tp2, sector, - stored_signals, signal_codes_json, signal_labels_json, - is_meme, direction, entry_price, entry_price, entry_price, - now, - json.dumps(entry_plan, ensure_ascii=False) if entry_plan else "{}", - force_reason or "", - base_state or "", - int(sector_signal_count or 0), - json.dumps(market_context or {}, ensure_ascii=False), - json.dumps(derivatives_context or {}, ensure_ascii=False), - json.dumps(sector_context or {}, ensure_ascii=False), - opportunity_fields["opportunity_level"], - opportunity_fields["opportunity_level_label"], - opportunity_fields["holding_horizon"], - opportunity_fields["entry_model"], - opportunity_fields["stop_model"], - opportunity_fields["tp_model"], - incoming_action, - incoming_exec, incoming_bucket, incoming_lifecycle, incoming_triggered, incoming_reason, - strategy_version, - )) - rec_id = cursor.fetchone()["id"] - conn.commit() - conn.close() - return rec_id - - -def update_recommendation_tracking(rec_id, current_price): - """更新推荐记录的跟踪价格和盈亏。 - - v1.7.9+: TP1 只代表阶段兑现/启动跟踪止盈,不再把记录移出 active; - 这样 TP1 后继续推高的收益会继续计入 current/max_pnl。 - """ - conn = get_conn() - row = conn.execute(""" - SELECT entry_price, max_price, min_price, symbol, status, action_status, - execution_status, display_bucket, entry_triggered - FROM recommendation WHERE id=%s - """, (rec_id,)).fetchone() - if not row: - conn.close() - return - - entry_price = row["entry_price"] - old_max = row["max_price"] or entry_price - old_min = row["min_price"] or entry_price - - new_max = max(old_max, current_price) - new_min = min(old_min, current_price) - pnl_pct = round((current_price / entry_price - 1) * 100, 2) - max_pnl_pct = round((new_max / entry_price - 1) * 100, 2) - max_drawdown_pct = round((new_min / entry_price - 1) * 100, 2) - - is_executed = ( - int(row["entry_triggered"] or 0) == 1 - or row["display_bucket"] == "position" - or row["execution_status"] in ("holding", "completed") - or is_executed_lifecycle(row["status"], row["action_status"], row["execution_status"]) - ) - status = "active" - tp1_reached = False - rec = conn.execute("SELECT stop_loss, tp1, tp2, status, hit_tp1_time FROM recommendation WHERE id=%s", (rec_id,)).fetchone() - if rec and rec["status"] == "active": - if rec["tp2"] and current_price >= rec["tp2"]: - status = "hit_tp2" - elif rec["tp1"] and current_price >= rec["tp1"]: - status = "hit_tp1" - tp1_reached = True - elif rec["tp1"] == 0 and pnl_pct >= 15: - status = "hit_tp1" - tp1_reached = True - elif is_executed and rec["stop_loss"] and current_price <= rec["stop_loss"]: - status = "stopped_out" - - now = datetime.now().isoformat() - if status != "active": - action_for_status = {"hit_tp1": "止盈1", "hit_tp2": "止盈2", "stopped_out": "止损"}.get(status, "持有") - execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason = _derive_minimal_state_fields(status, action_for_status, {}) - conn.execute(""" - UPDATE recommendation SET current_price=%s, max_price=%s, min_price=%s, - pnl_pct=%s, max_pnl_pct=%s, max_drawdown_pct=%s, - status=%s, action_status=%s, execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s, last_track_time=%s, - hit_tp1_time=CASE WHEN %s='hit_tp1' THEN %s ELSE hit_tp1_time END, - hit_tp2_time=CASE WHEN %s='hit_tp2' THEN %s ELSE hit_tp2_time END, - stopped_out_time=CASE WHEN %s='stopped_out' THEN %s ELSE stopped_out_time END - WHERE id=%s - """, (current_price, new_max, new_min, pnl_pct, max_pnl_pct, max_drawdown_pct, - status, action_for_status, execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, now, - status, now, status, now, status, now, rec_id)) - else: - conn.execute(""" - UPDATE recommendation SET current_price=%s, max_price=%s, min_price=%s, - pnl_pct=%s, max_pnl_pct=%s, max_drawdown_pct=%s, - last_track_time=%s, - hit_tp1_time=CASE WHEN %s=1 THEN COALESCE(NULLIF(hit_tp1_time,''), %s) ELSE hit_tp1_time END - WHERE id=%s - """, (current_price, new_max, new_min, pnl_pct, max_pnl_pct, max_drawdown_pct, now, - 1 if tp1_reached else 0, now, rec_id)) - - symbol = row["symbol"] - update_latest_price_cache(symbol, current_price, updated_at=now, source="tracker", conn=conn) - - conn.execute(""" - INSERT INTO price_tracking (rec_id, symbol, track_time, price, pnl_pct) - VALUES (%s, %s, %s, %s, %s) - """, (rec_id, symbol, now, current_price, pnl_pct)) - - conn.commit() - conn.close() - return {"status": status, "tp1_reached": tp1_reached, "pnl_pct": pnl_pct, "max_pnl_pct": max_pnl_pct, "max_drawdown_pct": max_drawdown_pct} - - -def expire_old_recommendations(hours=48): - """超过48小时的active推荐标记为expired""" - conn = get_conn() - cutoff = (datetime.now() - timedelta(hours=float(hours or 48))).isoformat() - execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason = _derive_minimal_state_fields( - "expired", "过期", {} - ) - conn.execute(""" - UPDATE recommendation - SET status='expired', - action_status=CASE WHEN action_status IN ('止盈1','止盈2','止损','跟踪止盈') THEN action_status ELSE '过期' END, - expired_time=%s, - execution_status=%s, - display_bucket=%s, - lifecycle_state=%s, - entry_triggered=%s, - state_reason=%s - WHERE status='active' AND rec_time < %s - """, (datetime.now().isoformat(), execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, cutoff)) - conn.commit() - conn.close() - - -def _entry_window_policy(entry_price, current_price, rec_time, event_time=None, window_hours=2.0, up_deviation_pct=1.5, down_deviation_pct=1.2): - """阶段1:入场窗口可信度规则。 - - - 入场窗口默认有效 2 小时;超过后降级观察。 - - 当前价向上脱离触发价 >1.5%:不追高,降级等回踩。 - - 当前价向下跌破触发价 >1.2%:买点失效,降级观察。 - """ - event_time = event_time or datetime.now().strftime("%Y-%m-%dT%H:%M:%S") - try: - entry_price = float(entry_price or 0) - current_price = float(current_price or 0) - except Exception: - entry_price = 0 - current_price = 0 - deviation_pct = round((current_price / entry_price - 1) * 100, 2) if entry_price and current_price else 0.0 - age_minutes = 0.0 - try: - start = datetime.fromisoformat(str(rec_time)) - end = datetime.fromisoformat(str(event_time)) - age_minutes = round((end - start).total_seconds() / 60.0, 1) - except Exception: - age_minutes = 0.0 - remaining_minutes = round(max(0.0, window_hours * 60.0 - age_minutes), 1) - result = { - "status": "active", - "label": "入场窗口有效", - "reason": "入场窗口仍在有效期内,价格未明显脱离触发价", - "age_minutes": age_minutes, - "remaining_minutes": remaining_minutes, - "window_hours": window_hours, - "entry_price": entry_price, - "current_price": current_price, - "deviation_pct": deviation_pct, - "max_up_deviation_pct": up_deviation_pct, - "max_down_deviation_pct": down_deviation_pct, - } - if age_minutes > window_hours * 60.0: - result.update({ - "status": "expired", - "label": "窗口已过期", - "reason": f"入场窗口超过有效期 {window_hours:g} 小时,避免沿用旧信号追入", - "remaining_minutes": 0.0, - }) - elif deviation_pct > up_deviation_pct: - result.update({ - "status": "price_left_up", - "label": "价格已上脱离", - "reason": f"当前价较触发价上脱离 {deviation_pct:.2f}%,超过 {up_deviation_pct:g}% 阈值,避免追高", - }) - elif deviation_pct < -down_deviation_pct: - result.update({ - "status": "price_left_down", - "label": "价格已下破", - "reason": f"当前价较触发价下破 {abs(deviation_pct):.2f}%,买点动能失效,转观察", - }) - return result - - -def _risk_suggestion(entry_price, stop_loss, tp1, risk_budget_pct=1.0, max_position_pct=100.0): - """把入场价/止损转换成可执行仓位建议。""" - try: - entry_price = float(entry_price or 0) - stop_loss = float(stop_loss or 0) - tp1 = float(tp1 or 0) - except Exception: - entry_price = stop_loss = tp1 = 0 - stop_distance_pct = round(abs(entry_price - stop_loss) / entry_price * 100, 2) if entry_price and stop_loss else 0.0 - suggested_position_pct = round(min(max_position_pct, risk_budget_pct / stop_distance_pct * 100), 2) if stop_distance_pct else 0.0 - tp1_profit_pct = round((tp1 / entry_price - 1) * 100, 2) if entry_price and tp1 else 0.0 - rr = round(tp1_profit_pct / stop_distance_pct, 2) if stop_distance_pct else 0.0 - max_loss_pct = round(suggested_position_pct * stop_distance_pct / 100, 2) if suggested_position_pct else 0.0 - return { - "risk_budget_pct": risk_budget_pct, - "stop_distance_pct": stop_distance_pct, - "suggested_position_pct": suggested_position_pct, - "max_loss_pct": max_loss_pct, - "tp1_profit_pct": tp1_profit_pct, - "rr": rr, - "max_position_pct": max_position_pct, - "valid": bool(entry_price and stop_loss and stop_distance_pct > 0), - } - - -def update_latest_price_cache(symbol, price, updated_at=None, source="tracker", conn=None): - """Upsert 最新行情缓存。看板读取这张小表,不再依赖 price_tracking 高频流水表。""" - symbol = str(symbol or "").strip().upper() - try: - price = float(price or 0) - except Exception: - price = 0 - if not symbol or price <= 0: - return False - updated_at = updated_at or datetime.now().isoformat() - owns_conn = conn is None - if owns_conn: - conn = get_conn() - conn.execute(""" - INSERT INTO latest_price_cache (symbol, price, updated_at, source) - VALUES (%s, %s, %s, %s) - ON CONFLICT(symbol) DO UPDATE SET - price=excluded.price, - updated_at=excluded.updated_at, - source=excluded.source - """, (symbol, price, updated_at, source)) - if owns_conn: - conn.commit() - conn.close() - return True - - -def get_latest_price_cache(symbols): - """批量读取最新行情缓存,返回 {symbol: {price, updated_at, source}}。""" - normalized = [] - for sym in symbols or []: - sym = str(sym or "").strip().upper() - if sym and sym not in normalized: - normalized.append(sym) - if not normalized: - return {} - conn = get_conn() - placeholders = ",".join(["%s"] * len(normalized)) - rows = conn.execute( - f"SELECT symbol, price, updated_at, source FROM latest_price_cache WHERE symbol IN ({placeholders})", - tuple(normalized), - ).fetchall() - conn.close() - return {row["symbol"]: dict(row) for row in rows} - - -def _latest_tracking_price(rec_id, fallback=0): - """旧兼容函数:不再读取 price_tracking,避免看板/API 依赖高频流水大表。 - - 最新现价应来自 latest_price_cache;没有缓存时回退 recommendation.current_price。 - """ - return fallback or 0 - - -def _execution_fields_from_persisted_state(item, entry_plan=None): - """只基于DB主状态派生展示状态;不得用 entry_plan.initial_action 反向提升主状态。""" - entry_plan = entry_plan if entry_plan is not None else _normalize_entry_plan(item.get("entry_plan_json")) - status = (item.get("status") or "active").strip() - action_status = normalize_action_status(item.get("action_status") or "持有", status) - - bucket = derive_display_bucket(status, action_status, "") - lifecycle = bucket.get("lifecycle_state") - execution_status = bucket.get("execution_status") - if execution_status == "completed": - return "completed", "✅ 已兑现,仅观察", f"该机会已进入{action_status or '利润管理'}阶段,仅作为持仓跟踪记录" - if execution_status == "invalid": - if action_status == "止损": - reason = "该机会已触发风险边界,原入场逻辑失效" - elif action_status == "衰减": - reason = "该机会已出现趋势衰减,追高性价比下降" - elif action_status == "反转": - reason = "该机会已出现趋势反转,原多头逻辑被破坏" - elif action_status == "放弃": - reason = "该机会已被标记为放弃,不再满足入场条件" - else: - reason = "该机会观察周期结束或逻辑失效,已归入历史复盘" - return "invalid", "🔴 已失效,勿追", reason - if execution_status == "buy_now": - stop = str(entry_plan.get("stop_loss", "")) if entry_plan else "" - return "buy_now", "🟢 现在可买", "推荐时就是可即刻买入;主链路确认当前仍在入场窗口" + ((",风险边界 " + stop) if stop else "") - if execution_status == "wait_pullback": - gate = entry_plan.get("entry_quality_gate") or {} - if gate.get("reasons"): - reason = "等待更优位置;" + ";".join(gate.get("reasons", [])[:3]) - else: - reason = "等待回踩至 " + (str(entry_plan.get("entry_price", "")) if entry_plan else "参考价") + " 附近再评估" - return "wait_pullback", "🟡 等回踩,不追高", reason - if execution_status == "holding": - return "holding", "持仓跟踪", "该机会已触发入场,进入持仓跟踪" - gate = entry_plan.get("entry_quality_gate") or {} - if gate.get("reasons"): - reason = "机会结构仍在观察;" + ";".join(gate.get("reasons", [])[:3]) - else: - reason = "暂无明确入场窗口,继续观察" - return "observe", "观察池", reason - - -def apply_recommendation_state_transition(rec_id, requested_action, current_price, event_time=None, signals=None): - """主链路状态迁移:唯一允许把价格事件落成 action_status 的入口。 - - 返回值同时作为推送层 payload 来源;推送层不得再自行判定交易状态。 - """ - event_time = event_time or datetime.now().strftime("%Y-%m-%dT%H:%M:%S") - conn = get_conn() - row = conn.execute(""" - SELECT * FROM recommendation WHERE id=%s - """, (rec_id,)).fetchone() - if not row: - conn.close() - return {"updated": False, "push_required": False, "reason": "not_found"} - - item = dict(row) - previous_action = (item.get("action_status") or "持有").strip() - entry_plan = _normalize_entry_plan(item.get("entry_plan_json")) - terminal_map = {"hit_tp2": "止盈2", "stopped_out": "止损"} - status = (item.get("status") or "active").strip() - final_action = normalize_action_status(terminal_map.get(status, requested_action), status) - - if status not in terminal_map: - final_action, entry_plan, gate_reasons = apply_entry_quality_gate( - action_status=final_action, - entry_plan=entry_plan, - signals=signals if signals is not None else item.get("signals"), - current_price=current_price, - market_context=normalize_json_object(item.get("market_context_json")), - derivatives_context=normalize_json_object(item.get("derivatives_context_json")), - sector_context=normalize_json_object(item.get("sector_context_json")), - ) - else: - gate_reasons = [] - - window_entry_price = item.get("entry_price") or current_price or 0 - window_rec_time = item.get("rec_time") or event_time - if final_action == "可即刻买入" and previous_action != "可即刻买入": - window_entry_price = current_price - window_rec_time = event_time - entry_window = _entry_window_policy(window_entry_price, current_price, window_rec_time, event_time) - if final_action == "可即刻买入" and previous_action == "可即刻买入": - if entry_window["status"] == "expired": - final_action = "观察" - gate_reasons.append(entry_window["reason"]) - elif entry_window["status"] == "price_left_up": - final_action = "等回踩" - gate_reasons.append(entry_window["reason"]) - elif entry_window["status"] == "price_left_down": - final_action = "观察" - gate_reasons.append(entry_window["reason"]) - - should_reset_entry = final_action == "可即刻买入" and previous_action != "可即刻买入" - if should_reset_entry: - max_price = current_price - min_price = current_price - pnl_pct = 0.0 - max_pnl_pct = 0.0 - max_drawdown_pct = 0.0 - rec_time = event_time - entry_price = current_price - else: - old_entry = item.get("entry_price") or current_price or 0 - old_max = item.get("max_price") or old_entry - old_min = item.get("min_price") or old_entry - max_price = max(old_max, current_price) if current_price else old_max - min_price = min(old_min, current_price) if current_price else old_min - entry_price = old_entry - rec_time = item.get("rec_time") - pnl_pct = round((current_price / old_entry - 1) * 100, 2) if old_entry and current_price else item.get("pnl_pct", 0) - max_pnl_pct = round((max_price / old_entry - 1) * 100, 2) if old_entry else item.get("max_pnl_pct", 0) - max_drawdown_pct = round((min_price / old_entry - 1) * 100, 2) if old_entry else item.get("max_drawdown_pct", 0) - - execution_status, execution_label, execution_reason = _execution_fields_from_persisted_state( - {**item, "action_status": final_action, "status": status}, entry_plan - ) - execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason = _state_fields_for_storage( - status, final_action, execution_status, execution_reason - ) - push_required = final_action in ("可即刻买入", "跟踪止盈") and previous_action != final_action and execution_status in ("buy_now", "completed") - - conn.execute(""" - UPDATE recommendation - SET action_status=%s, entry_plan_json=%s, current_price=%s, max_price=%s, min_price=%s, - pnl_pct=%s, max_pnl_pct=%s, max_drawdown_pct=%s, last_track_time=%s, - execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s, - rec_time=CASE WHEN %s=1 THEN %s ELSE rec_time END, - entry_price=CASE WHEN %s=1 THEN %s ELSE entry_price END - WHERE id=%s - """, ( - final_action, json.dumps(entry_plan, ensure_ascii=False), current_price, max_price, min_price, - pnl_pct, max_pnl_pct, max_drawdown_pct, event_time, - execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, - 1 if should_reset_entry else 0, rec_time, - 1 if should_reset_entry else 0, entry_price, - rec_id, - )) - conn.commit() - conn.close() - - return { - "updated": True, - "id": rec_id, - "symbol": item.get("symbol"), - "previous_action_status": previous_action, - "action_status": final_action, - "execution_status": execution_status, - "execution_label": execution_label, - "execution_reason": execution_reason, - "display_bucket": display_bucket, - "lifecycle_state": lifecycle_state, - "entry_triggered": entry_triggered, - "entry_price": entry_price, - "current_price": current_price, - "pnl_pct": pnl_pct, - "stop_loss": item.get("stop_loss") or entry_plan.get("stop_loss") or 0, - "tp1": item.get("tp1") or entry_plan.get("tp1") or entry_plan.get("take_profit_1") or 0, - "tp2": item.get("tp2") or entry_plan.get("tp2") or 0, - "entry_plan": entry_plan, - "entry_window": entry_window, - "risk_suggestion": _risk_suggestion( - entry_price, - item.get("stop_loss") or entry_plan.get("stop_loss") or 0, - item.get("tp1") or entry_plan.get("tp1") or entry_plan.get("take_profit_1") or 0, - ), - "gate_reasons": gate_reasons, - "push_required": push_required, - "push_symbol": item.get("symbol"), - "push_entry_price": entry_price, - "push_current_price": current_price, - "push_pnl_pct": pnl_pct, - "push_signals": signals or [], - } - - - -def recompute_all_recommendation_state_fields(conn=None): - """回填统一状态机派生字段。只读 status/action_status,不改变历史交易价格。""" - owns_conn = conn is None - if owns_conn: - conn = get_conn() - rows = conn.execute("SELECT id,status,action_status,entry_plan_json FROM recommendation").fetchall() - updated = 0 - for row in rows: - ep = _normalize_entry_plan(row["entry_plan_json"]) - action = normalize_action_status(row["action_status"], row["status"]) - execution_status, execution_label, execution_reason = _execution_fields_from_persisted_state( - {"status": row["status"], "action_status": action, "entry_plan_json": row["entry_plan_json"]}, ep - ) - execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason = _state_fields_for_storage( - row["status"], action, execution_status, execution_reason - ) - conn.execute( - """UPDATE recommendation - SET action_status=%s, execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s - WHERE id=%s""", - (action, execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, row["id"]), - ) - updated += 1 - if owns_conn: - conn.commit() - conn.close() - return updated - -def update_recommendation_action_status(rec_id, action_status): - """更新推荐记录的操作状态。 - - 保护规则:如果推荐已经真实止盈/止损结案,不能再被后续动态入场逻辑覆盖成 - “可即刻买入/持有/等回踩”。v1.7.5 进一步增加买点质量闸门: - entry_plan 为等回踩、risk_reward_ok=false、rr过低或追高距离过远时,拒绝把 - action_status 写成“可即刻买入”。 - """ - conn = get_conn() - row = conn.execute(""" - SELECT status, action_status, entry_plan_json, signals, current_price, - market_context_json, derivatives_context_json, sector_context_json - FROM recommendation WHERE id=%s - """, (rec_id,)).fetchone() - terminal_map = { - "hit_tp1": "止盈1", - "hit_tp2": "止盈2", - "stopped_out": "止损", - } - entry_plan = {} - if row: - if row["status"] in terminal_map and action_status not in ("止盈1", "止盈2", "止损", "跟踪止盈"): - action_status = terminal_map[row["status"]] - else: - entry_plan = _normalize_entry_plan(row["entry_plan_json"]) - gated_action, gated_plan, _ = apply_entry_quality_gate( - action_status=action_status, - entry_plan=entry_plan, - signals=row["signals"], - current_price=row["current_price"] or 0, - market_context=normalize_json_object(row["market_context_json"]), - derivatives_context=normalize_json_object(row["derivatives_context_json"]), - sector_context=normalize_json_object(row["sector_context_json"]), - ) - action_status = gated_action - entry_plan = gated_plan - if row["status"] not in terminal_map and row["symbol"]: - try: - from app.db.paper_trading import list_paper_trades - trade = conn.execute( - "SELECT status, closed_at FROM paper_trades WHERE recommendation_id=%s", - (rec_id,), - ).fetchone() - if trade and trade.get("status") == "closed": - action_status = row["action_status"] if row["action_status"] in ("止盈1", "止盈2", "止损", "跟踪止盈") else "观察" - entry_plan.setdefault("paper_trade_closed", True) - entry_plan.setdefault("paper_trade_closed_at", trade.get("closed_at")) - except Exception: - pass - if entry_plan: - execution_status, execution_label, execution_reason = _execution_fields_from_persisted_state( - {"status": row["status"] if row else "active", "action_status": action_status, "entry_plan_json": json.dumps(entry_plan, ensure_ascii=False)}, - entry_plan, - ) - execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason = _state_fields_for_storage( - row["status"] if row else "active", action_status, execution_status, execution_reason - ) - conn.execute(""" - UPDATE recommendation SET action_status=%s, entry_plan_json=%s, execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s WHERE id=%s - """, (action_status, json.dumps(entry_plan, ensure_ascii=False), execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, rec_id)) - else: - execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason = _derive_minimal_state_fields(row["status"] if row else "active", action_status, {}) - conn.execute(""" - UPDATE recommendation SET action_status=%s, execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s WHERE id=%s - """, (action_status, execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, rec_id)) - conn.commit() - conn.close() - - -def update_entry_timing(rec_id: int, entry_price: float, rec_time: str): - """更新入场到位的时间和价格。当tracker检测到可即刻买入时调用。""" - conn = get_conn() - conn.execute( - "UPDATE recommendation SET rec_time=%s, entry_price=%s, current_price=%s, pnl_pct=0 WHERE id=%s", - (rec_time, entry_price, entry_price, rec_id) - ) - conn.commit() - conn.close() - - -def _normalize_entry_plan(entry_plan_json): - try: - if isinstance(entry_plan_json, dict): - return entry_plan_json - if entry_plan_json: - return json.loads(entry_plan_json) - except Exception: - pass - return {} - - -def _normalize_json_object(payload): - try: - if isinstance(payload, dict): - return payload - if payload: - parsed = json.loads(payload) - if isinstance(parsed, dict): - return parsed - except Exception: - pass - return {} - - -def _normalize_signals(payload): - """signals 字段从 SQLite TEXT 列读出是 JSON 字符串,必须解析为数组才能交给前端 JS .map()。""" - try: - if isinstance(payload, list): - return payload - if isinstance(payload, str) and payload.strip(): - parsed = json.loads(payload) - if isinstance(parsed, list): - return parsed - except Exception: - pass - return [] - - - -def _observe_tier(item): - """观察池分层:strong=值得用户关注,weak=弱观察/低质量候选。""" - status = str(item.get("execution_status") or "") - if status in ("buy_now", "wait_pullback") or item.get("display_bucket") == "realtime": - return "strong", "入场/等待类有效机会" - try: - score = float(item.get("rec_score") or 0) - except Exception: - score = 0 - signals = item.get("signals") or [] - if isinstance(signals, str): - signals = _normalize_signals(signals) - sig_text = " ".join(str(x) for x in signals) - force_reason = str(item.get("force_reason") or "") - derivatives = _normalize_json_object(item.get("derivatives_context_json") or item.get("derivatives_context")) - market = _normalize_json_object(item.get("market_context_json") or item.get("market_context")) - if not derivatives and isinstance(item.get("derivatives_context"), dict): - derivatives = item.get("derivatives_context") or {} - if not market and isinstance(item.get("market_context"), dict): - market = item.get("market_context") or {} - long_pct = 0.0 - try: - long_pct = float(derivatives.get("top_trader_long_pct") or 0) - except Exception: - long_pct = 0.0 - acc1 = 0.0 - acc4 = 0.0 - try: - acc1 = float(market.get("turnover_acceleration_1h") or 0) - acc4 = float(market.get("turnover_acceleration_4h") or 0) - except Exception: - pass - stale_only = ("已过期" in sig_text or "历史" in sig_text) and not any(k in sig_text for k in ("当前", "新近", "刚刚", "入场窗口", "量价齐飞")) - weak_reasons = [] - if score < 50: - weak_reasons.append(f"评分偏低({int(score)})") - if stale_only: - weak_reasons.append("主要触发来自历史/过期信号") - if "静K蓄力旁路" in force_reason and acc4 < 1.3 and acc1 < 1.3: - weak_reasons.append("静K旁路量能不足") - gate = {} - try: - ep = item.get("entry_plan") or _normalize_json_object(item.get("entry_plan_json")) - gate = ep.get("entry_quality_gate") or {} - except Exception: - gate = {} - gate_reasons = gate.get("reasons") or [] - gate_reason_text = ";".join(str(x) for x in gate_reasons[:3]) - if any("回踩参考已到" in str(x) and "不达标" in str(x) for x in gate_reasons): - return "weak" if score < 55 else "strong", (gate_reason_text or "回踩参考已到,但实时盈亏比不达标") + ";暂不构成入场窗口,继续观察是否重新恢复可买盈亏比" - strong_context = score >= 65 or long_pct >= 75 or max(acc1, acc4) >= 1.5 - if weak_reasons and not strong_context: - return "weak", ";".join(weak_reasons[:3]) - if gate_reason_text: - return "strong", gate_reason_text + ";继续观察结构是否恢复" - return "strong", "观察池有效候选" - -def _derive_execution_fields(item): - entry_plan = _normalize_entry_plan(item.get("entry_plan_json")) - market_context = _normalize_json_object(item.get("market_context_json")) - derivatives_context = _normalize_json_object(item.get("derivatives_context_json")) - sector_context = _normalize_json_object(item.get("sector_context_json")) - signals = _normalize_signals(item.get("signals")) - item["signals"] = signals - initial_action = normalize_action_status(entry_plan.get("entry_action") or item.get("action_status") or "持有", item.get("status") or "active") - action_status = normalize_action_status(item.get("action_status") or initial_action or "持有", item.get("status") or "active") - # 新建爆发推荐可能还没被 tracker 跑到,DB action_status 仍是默认“持有”。 - # 此时以前端展示和实时看板过滤应以确认层写入的 entry_plan.entry_action 为准, - # 但后续 tracker 一旦写入明确状态,仍以 DB 主状态优先。 - if action_status == "持有" and initial_action in ("可即刻买入", "等回踩", "观察"): - action_status = initial_action - current_price_for_window = item.get("latest_cache_price") or item.get("current_price") or item.get("entry_price") or 0 - action_status, entry_plan, _entry_gate_reasons = apply_entry_quality_gate( - action_status=action_status, - entry_plan=entry_plan, - signals=item.get("signals"), - current_price=current_price_for_window, - market_context=market_context, - derivatives_context=derivatives_context, - sector_context=sector_context, - ) - try: - rec_score_for_gate = float(item.get("rec_score") or 0) - except Exception: - rec_score_for_gate = 0 - if action_status == "可即刻买入" and rec_score_for_gate > 0 and rec_score_for_gate < 25: - reasons = [f"推荐评分{rec_score_for_gate:g}<25,属于信号不足,禁止展示为现价买入"] - gate = entry_plan.get("entry_quality_gate") if isinstance(entry_plan.get("entry_quality_gate"), dict) else {} - existing_reasons = list(gate.get("reasons") or []) - entry_plan["entry_quality_gate"] = { - **gate, - "blocked_action": gate.get("blocked_action") or action_status, - "final_action": "观察", - "reasons": existing_reasons + reasons, - } - action_status = "观察" - if initial_action == "可即刻买入" and action_status != "可即刻买入": - initial_action = action_status - status = (item.get("status") or "active").strip() - force_reason = (item.get("force_reason") or "").strip() - base_state = (item.get("base_state") or "").strip() - sector_signal_count = item.get("sector_signal_count") - strategy_version = str(item.get("strategy_version") or "").strip() - if not strategy_version: - strategy_version = str(get_meta().get("strategy_version") or "").strip() - - if current_price_for_window: - item["current_price"] = current_price_for_window - try: - entry_price_for_pnl = float(item.get("entry_price") or 0) - current_price_float = float(current_price_for_window or 0) - if entry_price_for_pnl > 0 and current_price_float > 0: - item["pnl_pct"] = round((current_price_float - entry_price_for_pnl) / entry_price_for_pnl * 100, 2) - except Exception: - pass - if item.get("latest_cache_updated_at"): - item["current_price_updated_at"] = item.get("latest_cache_updated_at") - entry_window = _entry_window_policy( - item.get("entry_price") or entry_plan.get("entry_price") or 0, - current_price_for_window, - item.get("rec_time") or "", - ) if action_status == "可即刻买入" else {} - if action_status == "可即刻买入" and entry_window: - window_status = entry_window.get("status") - if window_status in ("expired", "price_left_down"): - action_status = "观察" - elif window_status == "price_left_up": - action_status = "等回踩" - if window_status and window_status != "active": - item["entry_window_alert"] = entry_window - # 实时看板用 hours 参数过滤过期机会;派生层不再因为旧 rec_time 反向篡改主状态,避免展示/测试口径分裂。 - item_for_execution = {**item, "action_status": action_status} - execution_status, execution_label, execution_reason = _execution_fields_from_persisted_state(item_for_execution, entry_plan) - - bucket_fields = derive_display_bucket(status, action_status, execution_status) - execution_status = bucket_fields.get("execution_status") or execution_status - item["initial_action"] = initial_action - item["action_status"] = normalize_action_status(action_status, status) - item["execution_status"] = execution_status - item["execution_label"] = execution_label - item["execution_reason"] = execution_reason - if item.get("entry_window_alert") and item["action_status"] == "可即刻买入": - item["action_status"] = "等回踩" if item["entry_window_alert"].get("status") == "price_left_up" else "观察" - execution_status, execution_label, execution_reason = _execution_fields_from_persisted_state( - {**item, "action_status": item["action_status"], "status": status}, entry_plan - ) - item["execution_status"] = execution_status - item["execution_label"] = execution_label - item["execution_reason"] = execution_reason - item["display_bucket"] = bucket_fields.get("display_bucket") - item["lifecycle_state"] = bucket_fields.get("lifecycle_state") - # 派生状态可能被买点质量闸门从“等回踩”降为“观察”,同步刷新展示桶,避免卡片仍停留在旧等待态。 - bucket_fields = derive_display_bucket(status, item["action_status"], item["execution_status"]) - item["execution_status"] = bucket_fields.get("execution_status") or item["execution_status"] - item["display_bucket"] = bucket_fields.get("display_bucket") - item["lifecycle_state"] = bucket_fields.get("lifecycle_state") - item["entry_triggered"] = 1 if is_executed_lifecycle(status, item["action_status"], item["execution_status"]) else 0 - observe_tier, observe_reason = _observe_tier(item) - item["observe_tier"] = observe_tier - item["observe_reason"] = observe_reason - item["entry_plan"] = entry_plan - opportunity_fields = _opportunity_fields_from_plan(entry_plan) - for key, value in opportunity_fields.items(): - item[key] = item.get(key) or value - if item.get("opportunity_level") and not item.get("opportunity_level_label"): - try: - from app.core.opportunity_level import opportunity_level_meta - meta = opportunity_level_meta(item["opportunity_level"]) - item["opportunity_level_label"] = meta.get("label", "") - item["holding_horizon"] = item.get("holding_horizon") or meta.get("holding_horizon", "") - item["entry_model"] = item.get("entry_model") or meta.get("entry_model", "") - item["stop_model"] = item.get("stop_model") or meta.get("stop_model", "") - item["tp_model"] = item.get("tp_model") or meta.get("tp_model", "") - except Exception: - pass - item["entry_window"] = entry_window - if entry_window and entry_window.get("status") != "active": - item["entry_window_alert"] = entry_window - item["risk_suggestion"] = _risk_suggestion( - item.get("entry_price") or entry_plan.get("entry_price") or 0, - item.get("stop_loss") or entry_plan.get("stop_loss") or 0, - item.get("tp1") or entry_plan.get("tp1") or entry_plan.get("take_profit_1") or 0, - ) - item["market_context"] = market_context - item["derivatives_context"] = derivatives_context - item["sector_context"] = sector_context - item["force_reason"] = force_reason - item["base_state"] = base_state - item["sector_signal_count"] = sector_signal_count - item["strategy_version"] = strategy_version - item["strategy_version_label"] = f"策略版本 {strategy_version}" if strategy_version else "" - return item - - -def _is_actionable_execution_status(status): - """实时可操作口径:包含现在可买 + 等回踩计划;但收益只统计已执行交易。""" - return status in ("buy_now", "wait_pullback") - - -def _is_executed_trade(item): - """收益统计口径:只有真实触发入场/持仓/退出的样本才计算收益。 - - buy_now 是当前入场窗口,不等同于已成交;等回踩/观察永远不计推荐收益。 - """ - status = (item.get("status") or "").strip() - action_status = normalize_action_status(item.get("action_status"), status) - execution_status = item.get("execution_status") or "" - try: - entry_triggered = int(item.get("entry_triggered") or 0) == 1 - except Exception: - entry_triggered = False - if entry_triggered: - return True - if status in ("hit_tp1", "hit_tp2", "stopped_out"): - return True - if item.get("display_bucket") == "position" or execution_status in ("holding", "completed"): - return True - return is_executed_lifecycle(status, action_status, execution_status) - - -def _classify_recommendation_result(item): - status = item.get("status") or "" - pnl_pct = item.get("pnl_pct") or 0 - max_pnl_pct = item.get("max_pnl_pct") or 0 - max_drawdown_pct = item.get("max_drawdown_pct") or 0 - - if status in ("hit_tp1", "hit_tp2"): - return "success", "✅ 止盈成功" - if status == "stopped_out": - return "failed", "❌ 止损失败" - - # 计划/观察未实际触发入场前,不按推荐生成价计算成功/失败。 - if not _is_executed_trade(item): - return "pending", "⏳ 未执行" - - if status == "expired": - if max_pnl_pct >= 5: - return "success", "✅ 交易成功" - if pnl_pct <= -3 or max_drawdown_pct <= -5: - return "failed", "❌ 交易失败" - return "pending", "⏳ 跟踪中" - if status == "active": - if max_pnl_pct >= 5: - return "success", "✅ 交易成功" - if pnl_pct <= -3 or max_drawdown_pct <= -5: - return "failed", "❌ 交易失败" - return "pending", "⏳ 跟踪中" - return "pending", "⏳ 未执行" +def update_recommendation_action_status(*args, **kwargs): + _sync_command_compat_hooks() + return _recommendation_commands.update_recommendation_action_status(*args, **kwargs) # ==================== 查询API ==================== @@ -1194,7 +157,19 @@ def get_active_recommendations_deduped(actionable_only=True, version="", hours=0 all_items = [] # 实时看板只输出当前有效机会;过期/失效样本属于历史/复盘,不再进入实时列表或 summary。 - summary = {"buy_now": 0, "wait_pullback": 0, "observe": 0, "observe_strong": 0, "observe_weak": 0, "expired": 0, "total": 0} + summary = { + "buy_now": 0, + "wait_pullback": 0, + "observe": 0, + "observe_strong": 0, + "observe_weak": 0, + "expired": 0, + "total": 0, + "discovery_burst": 0, + "executable_now": 0, + "planned_entry": 0, + "watch_pool": 0, + } now = datetime.now() for row in rows: item = dict(row) @@ -1222,6 +197,14 @@ def get_active_recommendations_deduped(actionable_only=True, version="", hours=0 if actionable_only and not _is_actionable_execution_status(item.get("execution_status")): continue all_items.append(item) + if item.get("is_discovery_burst"): + summary["discovery_burst"] += 1 + if item.get("is_executable_now"): + summary["executable_now"] += 1 + if item.get("execution_status") == "wait_pullback": + summary["planned_entry"] += 1 + if item.get("is_watch_pool"): + summary["watch_pool"] += 1 if item.get("execution_status") == "buy_now": summary["buy_now"] += 1 @@ -1272,18 +255,6 @@ def get_all_recommendations(limit=50, decision_only=False, version="", offset=0, ) -def get_screening_history(hours=24, limit=100): - """获取最近N小时的筛选记录""" - conn = get_conn() - rows = conn.execute(""" - SELECT * FROM screening_log - WHERE layer='细筛' AND scan_time >= %s - ORDER BY score DESC, scan_time DESC LIMIT %s - """, ((datetime.now() - timedelta(hours=float(hours or 24))).isoformat(), limit)).fetchall() - conn.close() - return [dict(r) for r in rows] - - def get_stats(): """兼容导出:统计聚合已迁移到 analytics 模块。""" from app.db.analytics import get_stats as _get_stats @@ -1291,211 +262,6 @@ def get_stats(): return _get_stats() -# ==================== 原有状态跟踪(兼容) ==================== - -def update_state(symbol, new_state, score=0, anomaly_type="", sector="", - leader_status="", detail=None): - """更新币状态(兼容旧接口)""" - conn = get_conn() - row = conn.execute("SELECT * FROM coin_state WHERE symbol=%s", (symbol,)).fetchone() - - if row: - old_state = row["state"] - old_score = row["score"] - last_alert_time = row["last_alert_time"] or "" - last_alert_level = row["last_alert_level"] or "" - - # 状态升级逻辑 - state_order = {"过期": 0, "蓄力": 1, "加速": 2, "爆发": 3} - should_alert = False - alert_level = "low" - - if state_order.get(new_state, 0) > state_order.get(old_state, 0): - should_alert = True - alert_level = "high" if new_state == "爆发" else "medium" if new_state == "加速" else "low" - elif new_state == old_state: - # 同级别:检查状态冷却,避免频繁重复推荐 - cooldown_hours = confirm_state_cooldown_hours() - if last_alert_time: - try: - last_dt = datetime.fromisoformat(last_alert_time) - hours_since = (datetime.now() - last_dt).total_seconds() / 3600 - if hours_since < cooldown_hours: - conn.close() - return {"should_alert": False, "alert_level": "none", "reason": f"状态冷却中({hours_since:.1f}h<{cooldown_hours}h)"} - except Exception: - pass - if score > old_score: - # 同级别分数提升,检查冷却 - if not last_alert_time: - should_alert = True - alert_level = "medium" - else: - try: - last_dt = datetime.fromisoformat(last_alert_time) - hours_since = (datetime.now() - last_dt).total_seconds() / 3600 - cooldown = 12 # 12h同币不重复推送同级别 - if hours_since >= cooldown: - should_alert = True - alert_level = "medium" - except Exception: - should_alert = True - alert_level = "medium" - - conn.execute(""" - UPDATE coin_state SET state=%s, score=%s, anomaly_type=%s, sector=%s, - leader_status=%s, detected_at=%s, detail_json=%s - WHERE symbol=%s - """, ( - new_state, score, anomaly_type, sector, leader_status, - datetime.now().isoformat(), - json.dumps(detail, ensure_ascii=False, default=str) if detail else "{}", - symbol, - )) - - if should_alert: - conn.execute(""" - UPDATE coin_state SET last_alert_time=%s, last_alert_level=%s WHERE symbol=%s - """, (datetime.now().isoformat(), alert_level, symbol)) - - conn.commit() - conn.close() - return {"should_alert": should_alert, "alert_level": alert_level} - else: - # 新币,首次检测 - conn.execute(""" - INSERT INTO coin_state (symbol, state, score, anomaly_type, sector, leader_status, detected_at, last_alert_time, last_alert_level, detail_json) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, ( - symbol, new_state, score, anomaly_type, sector, leader_status, - datetime.now().isoformat(), datetime.now().isoformat(), "low", - json.dumps(detail, ensure_ascii=False, default=str) if detail else "{}", - )) - conn.commit() - conn.close() - return {"should_alert": True, "alert_level": "low"} - - -def get_candidates_for_confirm(): - """获取需要确认层检查的候选。 - - 优先处理最近一轮粗筛/细筛刚更新的候选,避免旧 coin_state 中的高分候选 - 抢占确认层,导致链路日志里“细筛通过”和“确认处理”对不上。 - """ - try: - _, _, accumulate_threshold = state_score_thresholds() - except Exception: - accumulate_threshold = 3 - conn = get_conn() - rows = conn.execute(""" - SELECT * FROM coin_state - WHERE state IN ('加速', '蓄力') - AND score >= %s - AND detected_at >= %s - ORDER BY detected_at DESC, score DESC - """, (accumulate_threshold, (datetime.now() - timedelta(minutes=45)).isoformat())).fetchall() - if not rows: - rows = conn.execute(""" - SELECT * FROM coin_state - WHERE state IN ('加速', '蓄力') - AND score >= 5 - ORDER BY detected_at DESC, score DESC - """).fetchall() - conn.close() - return [dict(r) for r in rows] - - -def get_all_active(): - conn = get_conn() - rows = conn.execute("SELECT * FROM coin_state WHERE state != '过期'").fetchall() - conn.close() - return [dict(r) for r in rows] - - -def expire_old_states(hours=24): - conn = get_conn() - conn.execute(""" - UPDATE coin_state SET state='过期' WHERE state != '过期' - AND detected_at < %s - """, ((datetime.now() - timedelta(hours=float(hours or 24))).isoformat(),)) - conn.commit() - conn.close() - - -# ==================== 复盘相关 ==================== - -def record_review(rec_id, symbol, outcome, pnl_48h, max_pnl_48h, - triggered_signals, hit_signals, miss_signals, lesson): - """写入一条复盘记录""" - conn = get_conn() - conn.execute(""" - INSERT INTO review_log (rec_id, symbol, review_time, outcome, pnl_48h, max_pnl_48h, - triggered_signals, hit_signals, miss_signals, lesson) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, (rec_id, symbol, datetime.now().isoformat(), outcome, pnl_48h, max_pnl_48h, - json.dumps(triggered_signals, ensure_ascii=False) if isinstance(triggered_signals, list) else triggered_signals, - json.dumps(hit_signals, ensure_ascii=False) if isinstance(hit_signals, list) else hit_signals, - json.dumps(miss_signals, ensure_ascii=False) if isinstance(miss_signals, list) else miss_signals, - lesson)) - conn.commit() - conn.close() - - -def update_signal_performance(signal_type, category, is_hit, pnl): - """更新信号绩效统计(每次复盘后调用)""" - conn = get_conn() - row = conn.execute("SELECT * FROM signal_performance WHERE signal_type=%s", (signal_type,)).fetchone() - - if row: - total = row["total_count"] + 1 - hits = row["hit_count"] + (1 if is_hit else 0) - misses = row["miss_count"] + (0 if is_hit else 1) - old_avg_pnl = row["avg_pnl"] - # 滚动平均 - new_avg_pnl = round((old_avg_pnl * (total - 1) + pnl) / total, 2) - hit_rate = round(hits / total * 100, 1) if total > 0 else 0 - - conn.execute(""" - UPDATE signal_performance SET total_count=%s, hit_count=%s, miss_count=%s, - hit_rate=%s, avg_pnl=%s, weight=%s, last_updated=%s - WHERE signal_type=%s - """, (total, hits, misses, hit_rate, new_avg_pnl, hit_rate / 50, datetime.now().isoformat(), signal_type)) - else: - conn.execute(""" - INSERT INTO signal_performance (signal_type, category, total_count, hit_count, miss_count, - hit_rate, avg_pnl, weight, last_updated) - VALUES (%s, %s, 1, %s, %s, %s, %s, %s, %s) - """, (signal_type, category, 1 if is_hit else 0, 0 if is_hit else 1, - 100 if is_hit else 0, pnl, 2.0 if is_hit else 0, datetime.now().isoformat())) - - conn.commit() - conn.close() - - -def get_signal_weights(): - """获取所有信号的当前权重(screener动态调权用)""" - conn = get_conn() - rows = conn.execute("SELECT signal_type, category, weight, hit_rate, avg_pnl, total_count FROM signal_performance").fetchall() - conn.close() - return {row["signal_type"]: dict(row) for row in rows} - - -def record_missed_explosion(symbol, price_at_detect, price_before, gain_pct, - reason_missed, features_detected, lesson): - """写入一条漏选复盘记录""" - conn = get_conn() - conn.execute(""" - INSERT INTO missed_explosions (symbol, detect_time, price_at_detect, price_before, - gain_pct, reason_missed, features_detected, lesson) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s) - """, (symbol, datetime.now().isoformat(), price_at_detect, price_before, gain_pct, - json.dumps(reason_missed, ensure_ascii=False) if isinstance(reason_missed, list) else reason_missed, - json.dumps(features_detected, ensure_ascii=False) if isinstance(features_detected, list) else features_detected, - lesson)) - conn.commit() - conn.close() - - def get_review_stats(): """兼容导出:复盘统计已迁移到 analytics 模块。""" from app.db.analytics import get_review_stats as _get_review_stats @@ -1561,647 +327,6 @@ def get_strategy_iteration_logs(limit=30): -def upsert_strategy_rule_candidate(source, rule_type, signal_name, rule_description, - support_count=0, success_count=0, fail_count=0, - avg_pnl=0, max_gain=0, max_drawdown=0, - confidence_score=0, sample_size=0, status="candidate", - release_version="", notes="", source_ref=""): - """新增或更新候选规则。研究结论先沉淀到候选池,避免样本不足时污染主策略。""" - conn = get_conn() - now = datetime.now().isoformat() - existing = conn.execute(""" - SELECT id FROM strategy_rule_candidate - WHERE source=%s AND rule_type=%s AND signal_name=%s AND rule_description=%s - ORDER BY id DESC LIMIT 1 - """, (source or "", rule_type or "", signal_name or "", rule_description or "")).fetchone() - if existing: - conn.execute(""" - UPDATE strategy_rule_candidate - SET support_count=%s, success_count=%s, fail_count=%s, avg_pnl=%s, max_gain=%s, - max_drawdown=%s, confidence_score=%s, sample_size=%s, status=%s, - release_version=%s, notes=%s, source_ref=COALESCE(NULLIF(%s, ''), source_ref), created_at=%s - WHERE id=%s - """, (support_count, success_count, fail_count, avg_pnl, max_gain, max_drawdown, - confidence_score, sample_size, status, release_version or "", notes or "", source_ref or "", now, existing["id"])) - candidate_id = existing["id"] - else: - cur = conn.execute(""" - INSERT INTO strategy_rule_candidate ( - created_at, source, rule_type, signal_name, rule_description, - support_count, success_count, fail_count, avg_pnl, max_gain, max_drawdown, - confidence_score, sample_size, status, release_version, notes, source_ref - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - RETURNING id - """, (now, source or "", rule_type or "", signal_name or "", rule_description or "", - support_count, success_count, fail_count, avg_pnl, max_gain, max_drawdown, - confidence_score, sample_size, status, release_version or "", notes or "", source_ref or "")) - candidate_id = cur.fetchone()["id"] - conn.commit() - conn.close() - return candidate_id - - -def record_strategy_failure_pattern(symbol, version="", failure_type="", failure_reason="", - signal_combo=None, market_context=None, - entry_quality_issue="", pnl_pct=0, max_drawdown_pct=0, lesson=""): - """记录失败模式,用于失败归因统计。""" - conn = get_conn() - conn.execute(""" - INSERT INTO strategy_failure_pattern ( - created_at, symbol, version, failure_type, failure_reason, signal_combo, - market_context_json, entry_quality_issue, pnl_pct, max_drawdown_pct, lesson - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, ( - datetime.now().isoformat(), symbol or "", version or "", failure_type or "", - failure_reason or "", json.dumps(signal_combo or [], ensure_ascii=False, default=str), - json.dumps(market_context or {}, ensure_ascii=False, default=str), - entry_quality_issue or "", pnl_pct or 0, max_drawdown_pct or 0, lesson or "", - )) - conn.commit() - conn.close() - - -def get_strategy_rule_candidates(limit=50, status=None): - """获取候选规则列表。""" - conn = get_conn() - params = [] - where = "" - if status: - where = "WHERE status=%s" - params.append(status) - rows = conn.execute(f""" - SELECT * FROM strategy_rule_candidate - {where} - ORDER BY confidence_score DESC, sample_size DESC, created_at DESC - LIMIT %s - """, (*params, limit)).fetchall() - conn.close() - return [dict(r) for r in rows] - - -def update_strategy_rule_candidate_status(candidate_id, status, release_version="", notes_append=""): - """更新候选规则生命周期状态。""" - conn = get_conn() - row = conn.execute("SELECT notes FROM strategy_rule_candidate WHERE id=%s", (candidate_id,)).fetchone() - if not row: - conn.close() - return False - notes = (row["notes"] or "").strip() - if notes_append: - notes = (notes + "\n" if notes else "") + f"[{datetime.now().isoformat()}] {notes_append}" - conn.execute(""" - UPDATE strategy_rule_candidate - SET status=%s, release_version=COALESCE(NULLIF(%s, ''), release_version), notes=%s, created_at=%s - WHERE id=%s - """, (status or "candidate", release_version or "", notes, datetime.now().isoformat(), candidate_id)) - conn.commit() - conn.close() - return True - - -def get_strategy_failure_patterns(limit=50): - """获取失败模式明细。""" - conn = get_conn() - rows = conn.execute(""" - SELECT * FROM strategy_failure_pattern - ORDER BY created_at DESC - LIMIT %s - """, (limit,)).fetchall() - conn.close() - items = [] - for r in rows: - item = dict(r) - item["signal_combo"] = _loads_json_field(item.get("signal_combo"), []) - item["market_context"] = _loads_json_field(item.get("market_context_json"), {}) - items.append(item) - return items - - -def _candidate_signal_key(signal_text): - """候选规则归因用的轻量信号归一化。保持与 review_engine._signal_key 语义接近。""" - text = str(signal_text or "") - key_map = { - "量价齐飞": "vp_fly", - "N倍放量": "vol_Nx", - "放量": "1h_vol", - "供需区突破": "zone_break", - "供给区突破": "zone_break", - "站稳突破": "zone_break", - "起爆点": "ignition", - "静K→动K": "ignition", - "静K蓄力": "sk_accum", - "连续3K": "cont3k", - "连续K": "cont_k", - "Q≥7": "q7_break", - "动K": "dyn_k", - "过期": "stale_signal", - "历史": "stale_signal", - "追高": "chase_high", - "假突破": "false_breakout", - "量价背离": "vp_divergence", - } - for marker, key in key_map.items(): - if marker in text: - return key - return text[:12] - - - - -def _get_factor_recency_fixed_at(): - """因子时效性修复完成时间:此时间前的推荐视为污染历史参考,不参与正式发布。""" - try: - meta = get_meta() or {} - except Exception: - meta = {} - return (meta.get("factor_recency_fixed_at") or meta.get("clean_review_started_at") or "").strip() - - -def _is_dirty_history_candidate(candidate): - source = str(candidate.get("source") or "") - notes = str(candidate.get("notes") or "") - source_ref = str(candidate.get("source_ref") or "") - return source in ("history_review_auto", "dirty_history_reference") or "dirty_history" in source_ref or "污染历史" in notes - -def _candidate_status_for_metrics(rule_type, sample_size, confidence, avg_pnl, current_status="candidate", - min_gray_samples=10, min_gray_confidence=65): - """候选规则生命周期状态判定。""" - if current_status == "active": - return "active" - if sample_size >= min_gray_samples and confidence >= min_gray_confidence and (avg_pnl > 0 or rule_type == "penalty"): - return "gray" - if sample_size >= 8 and ((rule_type != "penalty" and confidence < 35) or avg_pnl <= -3): - return "rejected" - if current_status in ("gray", "rejected"): - return current_status - return "candidate" - - - -def _classify_failure_type_from_text(review): - """历史失败模式回填用的本地分类器,避免 altcoin_db 反向依赖 review_engine。""" - signals = review.get("triggered_signals") or [] - miss = review.get("miss_signals") or [] - lesson = review.get("lesson") or "" - text = " ".join([str(x) for x in signals + miss]) + " " + str(lesson or "") - pnl = float(review.get("pnl_48h") or 0) - outcome = review.get("outcome") or "" - if any(k in text for k in ["过期", "历史", "旧放量", "age_bars", "已过期", "小时前", "旧起爆"]): - return "过期因子误判", "历史放量/起爆/突破不能当作当前触发信号,必须做时效闸门" - if any(k in text for k in ["假突破", "突破失败", "未站稳", "冲高回落"]): - return "假突破", "突破后没有站稳或快速回落,需要增加站稳/承接确认" - if any(k in text for k in ["量价背离", "缩量上涨", "放量下跌", "无量拉升"]): - return "量价背离", "价格动作与成交量不匹配,量能确认不足" - if any(k in text for k in ["高位", "追高", "涨幅过大", "乖离"]): - return "追高风险", "入场位置偏高,盈亏比和回撤风险恶化" - if any(k in text for k in ["承接不足", "无承接", "上影线", "砸盘"]): - return "高位无承接", "高位出现抛压但缺少买盘承接" - if any(k in text for k in ["板块退潮", "热点退潮", "龙头走弱", "板块分歧"]): - return "板块退潮", "板块热度回落,个币信号容易失效" - if any(k in text for k in ["BTC", "大盘", "反向共振", "系统性"]): - return "BTC/大盘反向共振", "大盘方向与个币信号冲突,需要宏观/主流币过滤" - if any(k in text for k in ["止损", "盈亏比", "RR", "止盈"]): - return "止损/盈亏比不合理", "止损或止盈结构不合理,导致信号收益风险不匹配" - if "滞后" in text or "MACD" in text or "RSI" in text: - return "滞后信号追高", "滞后指标占比高,容易形成事后确认/追高失败" - if "缺乏前瞻" in text or "前瞻" not in text: - return "前瞻信号不足", "缺少量价/PA等前瞻性确认" - if "横盘" in text or outcome == "横盘": - return "信号强度不足", "触发后未形成有效爆发,确认条件偏弱" - if "回撤" in text or pnl < -3: - return "入场点太晚", "入场后回撤/亏损明显,买点可能滞后或确认过慢" - return "未分类失败", "需要继续积累样本做二级归因" - - -def backfill_strategy_failure_patterns(limit=2000, dry_run=False): - """从历史 review_log 回填失败模式库,按 rec_id 去重。""" - conn = get_conn() - rows = conn.execute(""" - SELECT rl.*, r.strategy_version, r.max_drawdown_pct - FROM review_log rl - LEFT JOIN recommendation r ON r.id = rl.rec_id - WHERE rl.outcome IN ('失败','横盘') - ORDER BY rl.review_time DESC - LIMIT %s - """, (limit,)).fetchall() - existing = set() - for r in conn.execute("SELECT market_context_json FROM strategy_failure_pattern").fetchall(): - ctx = _loads_json_field(r["market_context_json"], {}) - if ctx.get("rec_id") is not None: - existing.add(str(ctx.get("rec_id"))) - inserted = 0 - skipped = 0 - type_counts = {} - examples = [] - for row in rows: - item = dict(row) - rec_id = item.get("rec_id") - if str(rec_id) in existing: - skipped += 1 - continue - triggered = _loads_json_field(item.get("triggered_signals"), []) or [] - miss = _loads_json_field(item.get("miss_signals"), []) or [] - item["triggered_signals"] = triggered - item["miss_signals"] = miss - ftype, reason = _classify_failure_type_from_text(item) - type_counts[ftype] = type_counts.get(ftype, 0) + 1 - if len(examples) < 10: - examples.append({"rec_id": rec_id, "symbol": item.get("symbol"), "failure_type": ftype, "reason": reason}) - if not dry_run: - conn.execute(""" - INSERT INTO strategy_failure_pattern ( - created_at, symbol, version, failure_type, failure_reason, signal_combo, - market_context_json, entry_quality_issue, pnl_pct, max_drawdown_pct, lesson - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, ( - datetime.now().isoformat(), item.get("symbol") or "", item.get("strategy_version") or "", - ftype, reason, json.dumps(triggered, ensure_ascii=False, default=str), - json.dumps({"source": "history_backfill", "rec_id": rec_id, "outcome": item.get("outcome"), "review_time": item.get("review_time")}, ensure_ascii=False, default=str), - reason, float(item.get("pnl_48h") or 0), float(item.get("max_drawdown_pct") or 0), item.get("lesson") or "", - )) - existing.add(str(rec_id)) - inserted += 1 - if not dry_run: - conn.commit() - conn.close() - return {"dry_run": dry_run, "scanned": len(rows), "inserted": inserted, "skipped_existing": skipped, "type_counts": type_counts, "examples": examples} - - -def generate_candidates_from_review_history(min_samples=20, min_bonus_confidence=55, max_penalty_confidence=40, dry_run=False): - """从历史 review_log 自动生成候选规则池。""" - conn = get_conn() - rows = conn.execute(""" - SELECT rl.*, r.max_drawdown_pct - FROM review_log rl - LEFT JOIN recommendation r ON r.id = rl.rec_id - ORDER BY rl.review_time DESC - """).fetchall() - buckets = {} - for row in rows: - item = dict(row) - triggered = _loads_json_field(item.get("triggered_signals"), []) or [] - hit = _loads_json_field(item.get("hit_signals"), []) or [] - miss = _loads_json_field(item.get("miss_signals"), []) or [] - keys = {_candidate_signal_key(x) for x in list(triggered) + list(hit) + list(miss) if str(x).strip()} - if not keys: - continue - for key in keys: - b = buckets.setdefault(key, {"sample_size": 0, "success_count": 0, "fail_count": 0, "pnl_values": [], "dd_values": []}) - b["sample_size"] += 1 - if item.get("outcome") == "爆发": - b["success_count"] += 1 - elif item.get("outcome") in ("失败", "横盘"): - b["fail_count"] += 1 - b["pnl_values"].append(float(item.get("pnl_48h") or 0)) - b["dd_values"].append(float(item.get("max_drawdown_pct") or 0)) - generated = [] - for key, b in buckets.items(): - sample = b["sample_size"] - if sample < min_samples: - continue - resolved = b["success_count"] + b["fail_count"] - confidence = round(b["success_count"] / resolved * 100, 1) if resolved else 0 - avg_pnl = round(sum(b["pnl_values"]) / len(b["pnl_values"]), 2) if b["pnl_values"] else 0 - max_gain = round(max(b["pnl_values"]), 2) if b["pnl_values"] else 0 - max_drawdown = round(min(b["dd_values"]), 2) if b["dd_values"] else 0 - rule_type = "bonus" if confidence >= min_bonus_confidence and avg_pnl > 0 else "penalty" if confidence <= max_penalty_confidence else "observe" - if rule_type == "observe": - continue - status = _candidate_status_for_metrics(rule_type, sample, confidence, avg_pnl, "candidate") - if rule_type == "bonus": - desc = f"历史样本候选加分因子:{key},样本{sample},成功{b['success_count']},失败/横盘{b['fail_count']},置信{confidence}%,均值{avg_pnl}%" - else: - desc = f"历史样本候选惩罚因子:{key},样本{sample},成功{b['success_count']},失败/横盘{b['fail_count']},置信{confidence}%,均值{avg_pnl}%" - candidate = { - "source": "dirty_history_reference", - "rule_type": rule_type, - "signal_name": key, - "rule_description": desc, - "support_count": sample, - "success_count": b["success_count"], - "fail_count": b["fail_count"], - "avg_pnl": avg_pnl, - "max_gain": max_gain, - "max_drawdown": max_drawdown, - "confidence_score": confidence, - "sample_size": sample, - "status": status, - "source_ref": f"dirty_history:{key}", - } - generated.append(candidate) - if not dry_run: - upsert_strategy_rule_candidate( - source=candidate["source"], rule_type=rule_type, signal_name=key, - rule_description=desc, support_count=sample, success_count=b["success_count"], - fail_count=b["fail_count"], avg_pnl=avg_pnl, max_gain=max_gain, - max_drawdown=max_drawdown, confidence_score=confidence, sample_size=sample, - status=status, notes="历史review_log自动生成:候选规则仍需灰度验证后才可发布", - source_ref=candidate["source_ref"], - ) - if not dry_run: - conn.commit() - conn.close() - generated.sort(key=lambda x: (-x["sample_size"], x["rule_type"], -x["confidence_score"])) - return {"dry_run": dry_run, "review_rows": len(rows), "generated_count": len(generated), "generated": generated[:80]} - -def dry_run_strategy_candidate_performance(min_gray_samples=10, min_gray_confidence=65): - """不写库的候选规则表现 dry-run,用于复盘系统验收。""" - conn = get_conn() - candidates = [dict(r) for r in conn.execute("SELECT * FROM strategy_rule_candidate").fetchall()] - clean_started_at = _get_factor_recency_fixed_at() - if clean_started_at: - review_rows = conn.execute(""" - SELECT rl.*, r.strategy_version, r.max_drawdown_pct, r.rec_time - FROM review_log rl - LEFT JOIN recommendation r ON r.id = rl.rec_id - WHERE r.rec_time >= %s - ORDER BY rl.review_time DESC - """, (clean_started_at,)).fetchall() - else: - review_rows = conn.execute(""" - SELECT rl.*, r.strategy_version, r.max_drawdown_pct, r.rec_time - FROM review_log rl - LEFT JOIN recommendation r ON r.id = rl.rec_id - ORDER BY rl.review_time DESC - """).fetchall() - failure_rows = [dict(r) for r in conn.execute("SELECT * FROM strategy_failure_pattern ORDER BY created_at DESC").fetchall()] - try: - current_version = str(get_meta().get("strategy_version") or "").strip() - except Exception: - current_version = "" - conn.close() - - review_items = [] - for row in review_rows: - item = dict(row) - triggered = _loads_json_field(item.get("triggered_signals"), []) or [] - hit = _loads_json_field(item.get("hit_signals"), []) or [] - miss = _loads_json_field(item.get("miss_signals"), []) or [] - all_sigs = list(triggered) + list(hit) + list(miss) - item["signal_keys"] = {_candidate_signal_key(x) for x in all_sigs} - item["all_signal_text"] = " ".join(str(x) for x in all_sigs) - review_items.append(item) - - evaluated = [] - for c in candidates: - status = c.get("status") or "candidate" - source = c.get("source") or "" - rule_type = c.get("rule_type") or "" - signal_name = c.get("signal_name") or "" - source_ref = c.get("source_ref") or "" - dirty_history = _is_dirty_history_candidate(c) - if dirty_history: - evaluated.append({**c, "sample_size": 0, "support_count": 0, "success_count": 0, "fail_count": 0, - "dry_run_status": "dirty_history", "release_gate_passed": False, - "gate_reason": "因子时效修复前的污染历史参考:不参与干净样本统计,不允许发布"}) - continue - if status == "active": - evaluated.append({**c, "dry_run_status": "active", "release_gate_passed": True, "gate_reason": "已正式生效,不参与dry-run降级"}) - continue - if source.startswith("dual_attribution_failure") or source_ref.startswith("failure:") or rule_type == "penalty": - ftype = signal_name or source_ref.replace("failure:", "") - matched = [r for r in failure_rows if (r.get("failure_type") or "") == ftype or ftype in (r.get("failure_reason") or "")] - sample_size = len(matched) - success_count = 0 - fail_count = sample_size - pnl_values = [float(r.get("pnl_pct") or 0) for r in matched] - dd_values = [float(r.get("max_drawdown_pct") or 0) for r in matched] - confidence = round(min(95, 45 + fail_count * 8), 1) if sample_size else float(c.get("confidence_score") or 0) - else: - key = signal_name or source_ref.replace("review:", "") - matched = [item for item in review_items if key and (key in item["signal_keys"] or key in item["all_signal_text"] or signal_name in item["all_signal_text"])] - sample_size = len(matched) - success_count = sum(1 for r in matched if r.get("outcome") == "爆发") - fail_count = sum(1 for r in matched if r.get("outcome") in ("失败", "横盘")) - pnl_values = [float(r.get("pnl_48h") or 0) for r in matched] - dd_values = [float(r.get("max_drawdown_pct") or 0) for r in matched] - resolved = success_count + fail_count - confidence = round(success_count / resolved * 100, 1) if resolved else float(c.get("confidence_score") or 0) - avg_pnl = round(sum(pnl_values) / len(pnl_values), 2) if pnl_values else float(c.get("avg_pnl") or 0) - max_gain = round(max(pnl_values), 2) if pnl_values else float(c.get("max_gain") or 0) - max_drawdown = round(min(dd_values), 2) if dd_values else float(c.get("max_drawdown") or 0) - dry_status = _candidate_status_for_metrics(rule_type, sample_size, confidence, avg_pnl, status, min_gray_samples, min_gray_confidence) - gate_passed = dry_status in ("gray", "active") - if dry_status == "gray": - gate_reason = f"样本{sample_size}≥{min_gray_samples},置信{confidence}%≥{min_gray_confidence},avg_pnl={avg_pnl}%:可进入灰度,仍不升版" - elif dry_status == "rejected": - gate_reason = f"样本{sample_size}已足够但置信/收益不达标:淘汰,不允许发布" - else: - gate_reason = f"样本{sample_size}或置信{confidence}%不足:只研究不发布" - evaluated.append({ - **c, - "sample_size": sample_size, - "support_count": sample_size, - "success_count": success_count, - "fail_count": fail_count, - "avg_pnl": avg_pnl, - "max_gain": max_gain, - "max_drawdown": max_drawdown, - "confidence_score": confidence, - "dry_run_status": dry_status, - "release_gate_passed": gate_passed, - "gate_reason": gate_reason, - }) - - gray_ready = [x for x in evaluated if x.get("dry_run_status") == "gray"] - active_ready = [x for x in evaluated if x.get("dry_run_status") == "active"] - rejected = [x for x in evaluated if x.get("dry_run_status") == "rejected"] - can_release = False - release_reason = "dry-run只评估候选规则表现,不执行 learned_rules 写入或版本升级" - return { - "dry_run": True, - "current_version": current_version, - "review_sample_count": len(review_items), - "clean_started_at": clean_started_at, - "sample_window": "clean_after_factor_recency_fix" if clean_started_at else "all_history", - "dirty_history_candidate_count": sum(1 for c in candidates if _is_dirty_history_candidate(c)), - "candidate_count": len(candidates), - "gray_ready_count": len(gray_ready), - "active_count": len(active_ready), - "rejected_count": len(rejected), - "would_bump_version": can_release, - "release_reason": release_reason, - "gate_policy": { - "gray": f"sample_size≥{min_gray_samples} 且 confidence≥{min_gray_confidence} 且 avg_pnl>0(penalty规则可不要求avg_pnl>0)", - "reject": "sample_size≥8 且 confidence<35 或 avg_pnl≤-3", - "release": "dry-run不发布;正式发布仍由复盘发布闸门统一控制", - }, - "evaluated_candidates": sorted(evaluated, key=lambda x: (x.get("dry_run_status") != "gray", -float(x.get("sample_size") or 0), -float(x.get("confidence_score") or 0)))[:80], - } - -def refresh_strategy_candidate_performance(min_gray_samples=10, min_gray_confidence=65): - """刷新候选规则表现。 - - 用 review_log + recommendation 复盘结果回填候选规则的 sample/success/fail/avg_pnl, - 并按门槛自动 candidate/gray/rejected,active 不降级。 - """ - conn = get_conn() - candidates = conn.execute("SELECT * FROM strategy_rule_candidate").fetchall() - clean_started_at = _get_factor_recency_fixed_at() - if clean_started_at: - review_rows = conn.execute(""" - SELECT rl.*, r.strategy_version, r.max_drawdown_pct, r.rec_time - FROM review_log rl - LEFT JOIN recommendation r ON r.id = rl.rec_id - WHERE r.rec_time >= %s - ORDER BY rl.review_time DESC - """, (clean_started_at,)).fetchall() - else: - review_rows = conn.execute(""" - SELECT rl.*, r.strategy_version, r.max_drawdown_pct, r.rec_time - FROM review_log rl - LEFT JOIN recommendation r ON r.id = rl.rec_id - ORDER BY rl.review_time DESC - """).fetchall() - failure_rows = conn.execute("SELECT * FROM strategy_failure_pattern ORDER BY created_at DESC").fetchall() - - def loads(value, fallback): - return _loads_json_field(value, fallback) - - review_items = [] - for row in review_rows: - item = dict(row) - triggered = loads(item.get("triggered_signals"), []) or [] - hit = loads(item.get("hit_signals"), []) or [] - miss = loads(item.get("miss_signals"), []) or [] - all_sigs = list(triggered) + list(hit) + list(miss) - item["signal_keys"] = {_candidate_signal_key(x) for x in all_sigs} - item["all_signal_text"] = " ".join(str(x) for x in all_sigs) - review_items.append(item) - - updated = [] - for cand in candidates: - c = dict(cand) - cid = c["id"] - status = c.get("status") or "candidate" - if status == "active": - continue - source = c.get("source") or "" - rule_type = c.get("rule_type") or "" - signal_name = c.get("signal_name") or "" - source_ref = c.get("source_ref") or "" - desc = c.get("rule_description") or "" - if _is_dirty_history_candidate(c): - updated.append({ - "id": cid, "signal_name": signal_name, "source": source, "rule_type": rule_type, - "sample_size": 0, "success_count": 0, "fail_count": 0, "confidence_score": c.get("confidence_score") or 0, - "avg_pnl": c.get("avg_pnl") or 0, "status": "dirty_history", "description": desc, - "gate_reason": "污染历史参考,不参与干净样本刷新", - }) - continue - - matched = [] - if source.startswith("dual_attribution_failure") or source_ref.startswith("failure:") or rule_type == "penalty": - ftype = signal_name or source_ref.replace("failure:", "") - frows = [dict(r) for r in failure_rows if (r["failure_type"] or "") == ftype or ftype in (r["failure_reason"] or "")] - sample_size = len(frows) - success_count = 0 - fail_count = sample_size - pnl_values = [float(r.get("pnl_pct") or 0) for r in frows] - dd_values = [float(r.get("max_drawdown_pct") or 0) for r in frows] - confidence = round(min(95, 45 + fail_count * 8), 1) if sample_size else float(c.get("confidence_score") or 0) - else: - key = signal_name or source_ref.replace("review:", "") - for item in review_items: - if key and (key in item["signal_keys"] or key in item["all_signal_text"] or signal_name in item["all_signal_text"]): - matched.append(item) - sample_size = len(matched) - success_count = sum(1 for r in matched if r.get("outcome") == "爆发") - fail_count = sum(1 for r in matched if r.get("outcome") in ("失败", "横盘")) - pnl_values = [float(r.get("pnl_48h") or 0) for r in matched] - dd_values = [float(r.get("max_drawdown_pct") or 0) for r in matched] - resolved = success_count + fail_count - confidence = round(success_count / resolved * 100, 1) if resolved else float(c.get("confidence_score") or 0) - - avg_pnl = round(sum(pnl_values) / len(pnl_values), 2) if pnl_values else float(c.get("avg_pnl") or 0) - max_gain = round(max(pnl_values), 2) if pnl_values else float(c.get("max_gain") or 0) - max_drawdown = round(min(dd_values), 2) if dd_values else float(c.get("max_drawdown") or 0) - - new_status = _candidate_status_for_metrics( - rule_type, sample_size, confidence, avg_pnl, status, - min_gray_samples=min_gray_samples, min_gray_confidence=min_gray_confidence, - ) - - note = (c.get("notes") or "").strip() - audit_note = f"[{datetime.now().isoformat()}] 自动评估: 样本{sample_size}, 成功{success_count}, 失败{fail_count}, 置信{confidence}%, avg_pnl={avg_pnl}%, status={new_status}" - if audit_note not in note: - note = (note + "\n" if note else "") + audit_note - - conn.execute(""" - UPDATE strategy_rule_candidate - SET support_count=%s, success_count=%s, fail_count=%s, avg_pnl=%s, max_gain=%s, - max_drawdown=%s, confidence_score=%s, sample_size=%s, status=%s, notes=%s, created_at=%s - WHERE id=%s - """, (sample_size, success_count, fail_count, avg_pnl, max_gain, max_drawdown, - confidence, sample_size, new_status, note, datetime.now().isoformat(), cid)) - updated.append({ - "id": cid, - "signal_name": signal_name, - "source": source, - "rule_type": rule_type, - "sample_size": sample_size, - "success_count": success_count, - "fail_count": fail_count, - "confidence_score": confidence, - "avg_pnl": avg_pnl, - "status": new_status, - "description": desc, - }) - conn.commit() - conn.close() - return updated - - -def get_strategy_iteration_dashboard(days=30): - """迭代页聚合数据:总览 + 候选规则 + 失败模式 + 时间线。""" - summary = get_strategy_iteration_summary(days=days) - candidates = get_strategy_rule_candidates(limit=80) - failures = get_strategy_failure_patterns(limit=80) - logs = get_strategy_iteration_logs(limit=40) - status_counts = {} - source_counts = {} - for c in candidates: - status_counts[c.get("status") or "candidate"] = status_counts.get(c.get("status") or "candidate", 0) + 1 - source_counts[c.get("source") or "unknown"] = source_counts.get(c.get("source") or "unknown", 0) + 1 - failure_counts = {} - for f in failures: - ft = f.get("failure_type") or "未分类" - failure_counts[ft] = failure_counts.get(ft, 0) + 1 - release_counts = {} - for log in logs: - rd = log.get("release_decision") or "unknown" - release_counts[rd] = release_counts.get(rd, 0) + 1 - dry_run = dry_run_strategy_candidate_performance() - latest_log = logs[0] if logs else {} - return { - "summary": summary, - "overview": { - "total_logs": len(logs), - "candidate_count": len(candidates), - "candidate_status_counts": status_counts, - "candidate_source_counts": source_counts, - "failure_type_counts": [{"type": k, "count": v} for k, v in sorted(failure_counts.items(), key=lambda x: (-x[1], x[0]))], - "release_decision_counts": release_counts, - "latest_release_decision": latest_log.get("release_decision") or "hold", - "latest_release_reason": latest_log.get("release_reason") or latest_log.get("version_change_summary") or "暂无发布决策说明", - "dry_run_summary": { - "review_sample_count": dry_run.get("review_sample_count", 0), - "clean_started_at": dry_run.get("clean_started_at", ""), - "sample_window": dry_run.get("sample_window", "all_history"), - "dirty_history_candidate_count": dry_run.get("dirty_history_candidate_count", 0), - "candidate_count": dry_run.get("candidate_count", 0), - "gray_ready_count": dry_run.get("gray_ready_count", 0), - "rejected_count": dry_run.get("rejected_count", 0), - "would_bump_version": dry_run.get("would_bump_version", False), - "release_reason": dry_run.get("release_reason", ""), - }, - }, - "dry_run": dry_run, - "candidates": candidates, - "failures": failures, - "logs": logs, - } - def get_strategy_iteration_summary(days=30): """兼容导出:策略迭代汇总已迁移到 review_queries 模块。""" from app.db.review_queries import get_strategy_iteration_summary as _get_strategy_iteration_summary @@ -2209,368 +334,7 @@ def get_strategy_iteration_summary(days=30): return _get_strategy_iteration_summary(days=days, conn_provider=get_conn, json_loader=_loads_json_field) -def log_cron_run(job_name, script_name, run_status, result_status="", started_at="", finished_at="", - duration_ms=0, summary=None, error_message=""): - """记录一次 cron 运行汇总""" - conn = get_conn() - conn.execute(""" - INSERT INTO cron_run_log ( - job_name, script_name, run_status, result_status, - started_at, finished_at, duration_ms, summary_json, error_message - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) - """, ( - job_name, - script_name, - run_status, - result_status, - started_at or datetime.now().isoformat(), - finished_at or datetime.now().isoformat(), - int(duration_ms or 0), - json.dumps(summary or {}, ensure_ascii=False, default=str), - (error_message or "")[:1000], - )) - conn.commit() - conn.close() - - -def get_cron_run_logs(limit=50, job_name=None): - """获取 cron 运行日志列表""" - conn = get_conn() - sql = """ - SELECT * FROM cron_run_log - {where_clause} - ORDER BY started_at DESC, id DESC - LIMIT %s - """ - params = [] - where_clause = "" - if job_name: - where_clause = "WHERE job_name = %s" - params.append(job_name) - params.append(limit) - rows = conn.execute(sql.format(where_clause=where_clause), tuple(params)).fetchall() - conn.close() - - result = [] - for row in rows: - item = dict(row) - try: - item["summary_json"] = json.loads(item.get("summary_json") or "{}") - except Exception: - item["summary_json"] = {} - result.append(item) - return result - - -def get_cron_run_summary(hours=24): - """获取 cron 运行汇总统计""" - conn = get_conn() - now_iso = datetime.now().isoformat() - rows = conn.execute(""" - SELECT * FROM cron_run_log - WHERE started_at >= %s - ORDER BY started_at DESC, id DESC - """, ((datetime.now() - timedelta(hours=float(hours or 24))).isoformat(),)).fetchall() - conn.close() - - logs = [] - job_stats = {} - total_runs = 0 - success_runs = 0 - error_runs = 0 - total_duration = 0 - - for row in rows: - item = dict(row) - try: - item["summary_json"] = json.loads(item.get("summary_json") or "{}") - except Exception: - item["summary_json"] = {} - logs.append(item) - - total_runs += 1 - total_duration += item.get("duration_ms") or 0 - if item.get("run_status") == "success": - success_runs += 1 - else: - error_runs += 1 - - job = item.get("job_name") or "unknown" - stat = job_stats.setdefault(job, { - "job_name": job, - "runs": 0, - "success_runs": 0, - "error_runs": 0, - "avg_duration_ms": 0, - "last_status": "", - "last_result_status": "", - "last_started_at": "", - "last_finished_at": "", - "last_error_message": "", - }) - stat["runs"] += 1 - if item.get("run_status") == "success": - stat["success_runs"] += 1 - else: - stat["error_runs"] += 1 - stat["avg_duration_ms"] += item.get("duration_ms") or 0 - if not stat["last_started_at"]: - stat["last_status"] = item.get("run_status", "") - stat["last_result_status"] = item.get("result_status", "") - stat["last_started_at"] = item.get("started_at", "") - stat["last_finished_at"] = item.get("finished_at", "") - stat["last_error_message"] = item.get("error_message", "") - - for stat in job_stats.values(): - stat["success_rate"] = round(stat["success_runs"] / stat["runs"] * 100, 1) if stat["runs"] else 0 - stat["avg_duration_ms"] = round(stat["avg_duration_ms"] / stat["runs"]) if stat["runs"] else 0 - - overall = { - "hours": hours, - "total_runs": total_runs, - "success_runs": success_runs, - "error_runs": error_runs, - "success_rate": round(success_runs / total_runs * 100, 1) if total_runs else 0, - "avg_duration_ms": round(total_duration / total_runs) if total_runs else 0, - } - - return { - "overall": overall, - "job_stats": sorted(job_stats.values(), key=lambda x: x["job_name"]), - "recent_logs": logs[:20], - } - - if __name__ == "__main__": init_db() stats = get_stats() print(f"DB初始化完成: {stats}") - - -def _safe_list_json(value): - try: - if isinstance(value, list): - return value - if isinstance(value, str) and value.strip(): - parsed = json.loads(value) - return parsed if isinstance(parsed, list) else [] - except Exception: - pass - return [] - - -def _safe_dict_json(value): - try: - if isinstance(value, dict): - return value - if isinstance(value, str) and value.strip(): - parsed = json.loads(value) - return parsed if isinstance(parsed, dict) else {} - except Exception: - pass - return {} - - -def get_strategy_insights(): - """Strategy attribution based on opportunity and paper-trading conversion. - - Recommendation rows are opportunities/signals, not an execution ledger. - Therefore this read model does not use recommendation.pnl_pct as strategy - PnL. Paper-trading PnL is exposed only as an execution-conversion metric. - """ - conn = get_conn() - rows = conn.execute( - """ - SELECT - r.*, - pt.id AS paper_trade_id, - pt.status AS paper_status, - pt.realized_pnl_pct AS paper_realized_pnl_pct, - pt.realized_pnl_usdt AS paper_realized_pnl_usdt, - pt.pnl_pct AS paper_pnl_pct, - pt.exit_reason AS paper_exit_reason - FROM recommendation r - LEFT JOIN paper_trades pt ON pt.recommendation_id = r.id - ORDER BY r.rec_time DESC, r.id DESC - """ - ).fetchall() - conn.close() - items = [dict(r) for r in rows] - - actionable_statuses = {"buy_now", "wait_pullback"} - total = len(items) - actionable = [x for x in items if (x.get("execution_status") or "") in actionable_statuses] - buy_now = [x for x in items if (x.get("execution_status") or "") == "buy_now"] - paper_items = [x for x in items if x.get("paper_trade_id")] - closed_paper = [x for x in paper_items if x.get("paper_status") == "closed"] - paper_wins = [x for x in closed_paper if float(x.get("paper_realized_pnl_pct") or 0) > 0] - paper_realized_usdt = round(sum(float(x.get("paper_realized_pnl_usdt") or 0) for x in closed_paper), 4) - overview = { - "total_opportunities": total, - "actionable_count": len(actionable), - "buy_now_count": len(buy_now), - "paper_trade_count": len(paper_items), - "closed_paper_trade_count": len(closed_paper), - "paper_win_count": len(paper_wins), - "paper_win_rate_pct": round(len(paper_wins) / len(closed_paper) * 100, 1) if closed_paper else 0, - "paper_realized_pnl_usdt": paper_realized_usdt, - "actionable_conversion_pct": round(len(actionable) / total * 100, 1) if total else 0, - "paper_conversion_pct": round(len(paper_items) / len(buy_now) * 100, 1) if buy_now else 0, - "definition": "策略归因只看机会转化和模拟交易转化;收益只来自 paper_trades,不读取 recommendation.pnl_pct。", - } - - def add_bucket(bucket_map, key, item): - if not key: - return - b = bucket_map.setdefault(key, { - "opportunity_count": 0, - "actionable_count": 0, - "buy_now_count": 0, - "paper_trade_count": 0, - "closed_paper_trade_count": 0, - "paper_win_count": 0, - "paper_realized_pnl_usdt": 0.0, - }) - execution_status = item.get("execution_status") or "" - paper_status = item.get("paper_status") or "" - b["opportunity_count"] += 1 - if execution_status in actionable_statuses: - b["actionable_count"] += 1 - if execution_status == "buy_now": - b["buy_now_count"] += 1 - if item.get("paper_trade_id"): - b["paper_trade_count"] += 1 - if paper_status == "closed": - b["closed_paper_trade_count"] += 1 - pnl_pct = float(item.get("paper_realized_pnl_pct") or 0) - if pnl_pct > 0: - b["paper_win_count"] += 1 - b["paper_realized_pnl_usdt"] += float(item.get("paper_realized_pnl_usdt") or 0) - - def env_buckets_from_market_context(mc): - """把当前实际存在的 market_context_json 数值字段转成可归因桶。 - - 旧版本只读取 btc_trend/market_regime 等枚举字段,但当前入库字段主要是 - change_24h、turnover_acceleration、volume_24h/funding_rate,导致市场环境归因为空。 - """ - buckets = [] - try: - change_24h = float(mc.get("change_24h", 0) or 0) - turn_1h = float(mc.get("turnover_acceleration_1h", 0) or 0) - turn_4h = float(mc.get("turnover_acceleration_4h", 0) or 0) - volume_24h = float(mc.get("volume_24h") or mc.get("quote_volume_24h") or 0) - funding = float(mc.get("funding_rate", 0) or 0) - except Exception: - change_24h = turn_1h = turn_4h = volume_24h = funding = 0 - - if change_24h >= 8: - buckets.append("24h涨幅:强势拉升≥8%") - elif change_24h >= 3: - buckets.append("24h涨幅:温和上涨3-8%") - elif change_24h <= -3: - buckets.append("24h涨幅:回撤≤-3%") - else: - buckets.append("24h涨幅:震荡-3~3%") - - if turn_1h >= 3: - buckets.append("1h成交加速:爆量≥3x") - elif turn_1h >= 1.5: - buckets.append("1h成交加速:放量1.5-3x") - elif turn_1h > 0: - buckets.append("1h成交加速:平量<1.5x") - - if turn_4h >= 3: - buckets.append("4h成交加速:爆量≥3x") - elif turn_4h >= 1.5: - buckets.append("4h成交加速:放量1.5-3x") - elif turn_4h > 0: - buckets.append("4h成交加速:平量<1.5x") - - if volume_24h >= 100_000_000: - buckets.append("24h成交额:高流动性≥1亿") - elif volume_24h >= 10_000_000: - buckets.append("24h成交额:中等流动性1千万-1亿") - elif volume_24h > 0: - buckets.append("24h成交额:低流动性<1千万") - - if funding >= 0.0005: - buckets.append("资金费率:多头拥挤") - elif funding <= -0.0005: - buckets.append("资金费率:空头拥挤") - return buckets - - factor_map = {} - env_map = {} - version_map = {} - evidence_map = {} - for item in items: - labels = _safe_list_json(item.get("signal_labels_json")) or _safe_list_json(item.get("signals")) - codes = _safe_list_json(item.get("signal_codes_json")) - for factor in labels: - add_bucket(factor_map, str(factor).strip(), item) - for code in codes: - text = str(code or "").strip() - if text.startswith(("sentiment_", "listing_", "ecosystem_")): - add_bucket(evidence_map, "舆情:" + text, item) - elif text.startswith(("dex_", "liquidity_", "exchange_", "whale_", "smart_money", "holder_")): - add_bucket(evidence_map, "链上:" + text, item) - mc = _safe_dict_json(item.get("market_context_json")) - for key in ("btc_trend", "market_regime", "altcoin_regime", "sentiment"): - if mc.get(key): - add_bucket(env_map, f"{key}:{mc.get(key)}", item) - for bucket in env_buckets_from_market_context(mc): - add_bucket(env_map, bucket, item) - if item.get("strategy_version"): - add_bucket(version_map, str(item.get("strategy_version")).strip(), item) - - def version_sort_key(version: str): - text = str(version or '').strip() - if text.startswith('v') or text.startswith('V'): - text = text[1:] - parts = [] - for chunk in text.replace('-', '.').split('.'): - if chunk.isdigit(): - parts.append(int(chunk)) - else: - m = re.match(r'^(\d+)', chunk) - if m: - parts.append(int(m.group(1))) - else: - parts.append(chunk) - return tuple(parts) - - def serialize(name_key, bucket_map, sort_by_version=False): - rows = [] - for key, b in bucket_map.items(): - rows.append({ - name_key: key, - "opportunity_count": b["opportunity_count"], - "actionable_count": b["actionable_count"], - "buy_now_count": b["buy_now_count"], - "paper_trade_count": b["paper_trade_count"], - "closed_paper_trade_count": b["closed_paper_trade_count"], - "paper_win_count": b["paper_win_count"], - "actionable_conversion_pct": round(b["actionable_count"] / b["opportunity_count"] * 100, 1) if b["opportunity_count"] else 0, - "paper_conversion_pct": round(b["paper_trade_count"] / b["buy_now_count"] * 100, 1) if b["buy_now_count"] else 0, - "paper_win_rate_pct": round(b["paper_win_count"] / b["closed_paper_trade_count"] * 100, 1) if b["closed_paper_trade_count"] else 0, - "paper_realized_pnl_usdt": round(b["paper_realized_pnl_usdt"], 4), - }) - if sort_by_version: - rows.sort(key=lambda x: (version_sort_key(x[name_key]), x["opportunity_count"], x["actionable_conversion_pct"]), reverse=True) - else: - rows.sort(key=lambda x: (-x["opportunity_count"], -x["actionable_conversion_pct"], x[name_key])) - return rows - - return { - "overview": overview, - "metric_definition": { - "opportunity_count": "进入 opportunity/recommendation 表的机会样本数,不代表交易。", - "actionable_count": "确认层输出 buy_now 或 wait_pullback 的样本数。", - "paper_trade_count": "已经被模拟交易账本执行的样本数。", - "paper_realized_pnl_usdt": "仅来自 paper_trades 的已平仓模拟收益。", - }, - "factor_attribution": serialize("factor", factor_map)[:30], - "market_environment": serialize("environment", env_map)[:20], - "evidence_attribution": serialize("evidence", evidence_map)[:20], - "version_performance": serialize("strategy_version", version_map, sort_by_version=True)[:20], - } diff --git a/app/db/analytics.py b/app/db/analytics.py index 9a5e479..a40c503 100644 --- a/app/db/analytics.py +++ b/app/db/analytics.py @@ -822,13 +822,13 @@ def get_review_stats(conn_provider=None, iteration_logs_getter=None, iteration_s revision_started_at = "" reviews = conn.execute("SELECT * FROM review_log ORDER BY review_time DESC").fetchall() - missed = conn.execute("SELECT * FROM missed_explosions ORDER BY detect_time DESC LIMIT 20").fetchall() + missed = conn.execute("SELECT * FROM missed_explosions ORDER BY detect_time DESC, id DESC LIMIT 200").fetchall() signals = conn.execute("SELECT * FROM signal_performance ORDER BY hit_rate DESC").fetchall() conn.close() return { "reviews": [dict(r) for r in reviews], "signal_performance": [dict(s) for s in signals], - "missed_explosions": [dict(m) for m in missed], + "missed_explosions": _dedupe_missed_rows(missed, limit=20), "iteration_logs": logs_getter(limit=30), "iteration_summary": summary_getter(days=30), "strategy_revision_started_at": revision_started_at, @@ -1018,6 +1018,23 @@ def _missed_item(row): return item +def _dedupe_missed_rows(rows, limit=0): + """Deduplicate missed explosions by symbol for KPI/read models.""" + items = [] + seen = set() + for row in rows: + item = _missed_item(row) + symbol = str(item.get("symbol") or "").strip().upper() + key = symbol or f"row:{item.get('id')}" + if key in seen: + continue + seen.add(key) + items.append(item) + if limit and len(items) >= int(limit): + break + return items + + def _performance_status(rec, reviews_by_rec): status = (rec.get("status") or "").strip() review_outcomes = [(r.get("outcome") or "").strip() for r in reviews_by_rec.get(rec.get("id"), [])] @@ -1118,7 +1135,7 @@ def _select_pipeline_rows(conn, run): "screening_rows": [_screening_item(row) for row in screening_rows], "recommendation_rows": [_recommendation_item(row) for row in rec_rows], "review_rows": [_review_item(row) for row in reviews], - "missed_rows": [_missed_item(row) for row in missed_rows], + "missed_rows": _dedupe_missed_rows(missed_rows), } diff --git a/app/db/coin_state_queries.py b/app/db/coin_state_queries.py new file mode 100644 index 0000000..4889875 --- /dev/null +++ b/app/db/coin_state_queries.py @@ -0,0 +1,100 @@ +"""Legacy coin_state compatibility queries.""" + +import json +from datetime import datetime, timedelta + +from app.config.config_loader import confirm_state_cooldown_hours +from app.db.schema import get_conn + + +def update_state(symbol, new_state, score=0, anomaly_type="", sector="", + leader_status="", detail=None): + """Update legacy coin_state row used by screener/confirm compatibility flow.""" + conn = get_conn() + row = conn.execute("SELECT * FROM coin_state WHERE symbol=%s", (symbol,)).fetchone() + + if row: + old_state = row["state"] + old_score = row["score"] + last_alert_time = row["last_alert_time"] or "" + state_order = {"过期": 0, "蓄力": 1, "加速": 2, "爆发": 3} + should_alert = False + alert_level = "low" + + if state_order.get(new_state, 0) > state_order.get(old_state, 0): + should_alert = True + alert_level = "high" if new_state == "爆发" else "medium" if new_state == "加速" else "low" + elif new_state == old_state: + cooldown_hours = confirm_state_cooldown_hours() + if last_alert_time: + try: + last_dt = datetime.fromisoformat(last_alert_time) + hours_since = (datetime.now() - last_dt).total_seconds() / 3600 + if hours_since < cooldown_hours: + conn.close() + return {"should_alert": False, "alert_level": "none", "reason": f"状态冷却中({hours_since:.1f}h<{cooldown_hours}h)"} + except Exception: + pass + if score > old_score: + if not last_alert_time: + should_alert = True + alert_level = "medium" + else: + try: + last_dt = datetime.fromisoformat(last_alert_time) + hours_since = (datetime.now() - last_dt).total_seconds() / 3600 + if hours_since >= 12: + should_alert = True + alert_level = "medium" + except Exception: + should_alert = True + alert_level = "medium" + + conn.execute(""" + UPDATE coin_state SET state=%s, score=%s, anomaly_type=%s, sector=%s, + leader_status=%s, detected_at=%s, detail_json=%s + WHERE symbol=%s + """, ( + new_state, score, anomaly_type, sector, leader_status, + datetime.now().isoformat(), + json.dumps(detail, ensure_ascii=False, default=str) if detail else "{}", + symbol, + )) + + if should_alert: + conn.execute(""" + UPDATE coin_state SET last_alert_time=%s, last_alert_level=%s WHERE symbol=%s + """, (datetime.now().isoformat(), alert_level, symbol)) + + conn.commit() + conn.close() + return {"should_alert": should_alert, "alert_level": alert_level} + + conn.execute(""" + INSERT INTO coin_state (symbol, state, score, anomaly_type, sector, leader_status, detected_at, last_alert_time, last_alert_level, detail_json) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, ( + symbol, new_state, score, anomaly_type, sector, leader_status, + datetime.now().isoformat(), datetime.now().isoformat(), "low", + json.dumps(detail, ensure_ascii=False, default=str) if detail else "{}", + )) + conn.commit() + conn.close() + return {"should_alert": True, "alert_level": "low"} + + +def get_all_active(): + conn = get_conn() + rows = conn.execute("SELECT * FROM coin_state WHERE state != '过期'").fetchall() + conn.close() + return [dict(r) for r in rows] + + +def expire_old_states(hours=24): + conn = get_conn() + conn.execute(""" + UPDATE coin_state SET state='过期' WHERE state != '过期' + AND detected_at < %s + """, ((datetime.now() - timedelta(hours=float(hours or 24))).isoformat(),)) + conn.commit() + conn.close() diff --git a/app/db/cron_queries.py b/app/db/cron_queries.py new file mode 100644 index 0000000..384a3e0 --- /dev/null +++ b/app/db/cron_queries.py @@ -0,0 +1,141 @@ +"""Cron/task run logging queries.""" + +import json +from datetime import datetime, timedelta + +from app.db.schema import get_conn + + +def log_cron_run(job_name, script_name, run_status, result_status="", started_at="", finished_at="", + duration_ms=0, summary=None, error_message=""): + """Record one scheduled/manual job run summary.""" + conn = get_conn() + conn.execute(""" + INSERT INTO cron_run_log ( + job_name, script_name, run_status, result_status, + started_at, finished_at, duration_ms, summary_json, error_message + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + """, ( + job_name, + script_name, + run_status, + result_status, + started_at or datetime.now().isoformat(), + finished_at or datetime.now().isoformat(), + int(duration_ms or 0), + json.dumps(summary or {}, ensure_ascii=False, default=str), + (error_message or "")[:1000], + )) + conn.commit() + conn.close() + + +def get_cron_run_logs(limit=50, job_name=None): + """Read cron run logs.""" + conn = get_conn() + sql = """ + SELECT * FROM cron_run_log + {where_clause} + ORDER BY started_at DESC, id DESC + LIMIT %s + """ + params = [] + where_clause = "" + if job_name: + where_clause = "WHERE job_name = %s" + params.append(job_name) + params.append(limit) + rows = conn.execute(sql.format(where_clause=where_clause), tuple(params)).fetchall() + conn.close() + + result = [] + for row in rows: + item = dict(row) + try: + item["summary_json"] = json.loads(item.get("summary_json") or "{}") + except Exception: + item["summary_json"] = {} + result.append(item) + return result + + +def get_cron_run_summary(hours=24): + """Aggregate cron run stats for the recent window.""" + rows = _recent_cron_rows(hours) + logs = [] + job_stats = {} + total_runs = 0 + success_runs = 0 + error_runs = 0 + total_duration = 0 + + for row in rows: + item = dict(row) + try: + item["summary_json"] = json.loads(item.get("summary_json") or "{}") + except Exception: + item["summary_json"] = {} + logs.append(item) + + total_runs += 1 + total_duration += item.get("duration_ms") or 0 + if item.get("run_status") == "success": + success_runs += 1 + else: + error_runs += 1 + + job = item.get("job_name") or "unknown" + stat = job_stats.setdefault(job, { + "job_name": job, + "runs": 0, + "success_runs": 0, + "error_runs": 0, + "avg_duration_ms": 0, + "last_status": "", + "last_result_status": "", + "last_started_at": "", + "last_finished_at": "", + "last_error_message": "", + }) + stat["runs"] += 1 + if item.get("run_status") == "success": + stat["success_runs"] += 1 + else: + stat["error_runs"] += 1 + stat["avg_duration_ms"] += item.get("duration_ms") or 0 + if not stat["last_started_at"]: + stat["last_status"] = item.get("run_status", "") + stat["last_result_status"] = item.get("result_status", "") + stat["last_started_at"] = item.get("started_at", "") + stat["last_finished_at"] = item.get("finished_at", "") + stat["last_error_message"] = item.get("error_message", "") + + for stat in job_stats.values(): + stat["success_rate"] = round(stat["success_runs"] / stat["runs"] * 100, 1) if stat["runs"] else 0 + stat["avg_duration_ms"] = round(stat["avg_duration_ms"] / stat["runs"]) if stat["runs"] else 0 + + overall = { + "hours": hours, + "total_runs": total_runs, + "success_runs": success_runs, + "error_runs": error_runs, + "success_rate": round(success_runs / total_runs * 100, 1) if total_runs else 0, + "avg_duration_ms": round(total_duration / total_runs) if total_runs else 0, + } + + return { + "overall": overall, + "job_stats": sorted(job_stats.values(), key=lambda x: x["job_name"]), + "recent_logs": logs[:20], + } + + +def _recent_cron_rows(hours): + conn = get_conn() + rows = conn.execute(""" + SELECT * FROM cron_run_log + WHERE started_at >= %s + ORDER BY started_at DESC, id DESC + """, ((datetime.now() - timedelta(hours=float(hours or 24))).isoformat(),)).fetchall() + conn.close() + return rows diff --git a/app/db/push_queries.py b/app/db/push_queries.py new file mode 100644 index 0000000..703f0e7 --- /dev/null +++ b/app/db/push_queries.py @@ -0,0 +1,79 @@ +"""Push de-duplication and push-facing recommendation reads.""" + +from datetime import datetime, timedelta + +from app.db.recommendation_state import classify_recommendation_result, derive_execution_fields +from app.db.schema import get_conn + + +PUSH_COOLDOWN_HOURS = 12 + + +def should_push(symbol: str, push_type: str, action_status: str = "") -> bool: + """Return whether a symbol/type can be pushed under the status-aware cooldown.""" + conn = get_conn() + cutoff = (datetime.now() - timedelta(hours=PUSH_COOLDOWN_HOURS)).isoformat() + try: + if action_status: + row = conn.execute( + "SELECT action_status FROM push_log WHERE symbol=%s AND push_type=%s AND pushed_at > %s ORDER BY id DESC LIMIT 1", + (symbol, push_type, cutoff), + ).fetchone() + if row is None: + return True + return row[0] != action_status + + row = conn.execute( + "SELECT id FROM push_log WHERE symbol=%s AND push_type=%s AND pushed_at > %s ORDER BY id DESC LIMIT 1", + (symbol, push_type, cutoff), + ).fetchone() + return row is None + finally: + conn.close() + + +def log_push(symbol: str, push_type: str, action_status: str = "", rec_id: int = 0): + """Record one push event and keep the recommendation source traceable.""" + conn = get_conn() + try: + conn.execute( + "INSERT INTO push_log (symbol, push_type, action_status, rec_id, pushed_at) VALUES (%s,%s,%s,%s,%s)", + (symbol, push_type, action_status, int(rec_id or 0), datetime.now().isoformat()), + ) + conn.commit() + finally: + conn.close() + + +def get_recommendation_for_push(rec_id: int): + """Read one recommendation with the same derived display state used by the web/API layer.""" + try: + rec_id = int(rec_id or 0) + except Exception: + rec_id = 0 + if rec_id <= 0: + return None + + conn = get_conn() + try: + row = conn.execute( + """ + SELECT r.*, + lpc.price AS latest_cache_price, + lpc.updated_at AS latest_cache_updated_at + FROM recommendation r + LEFT JOIN latest_price_cache lpc ON lpc.symbol = r.symbol + WHERE r.id=%s + """, + (rec_id,), + ).fetchone() + finally: + conn.close() + if not row: + return None + + item = dict(row) + rec_result, rec_result_label = classify_recommendation_result(item) + item["recommendation_result"] = rec_result + item["recommendation_result_label"] = rec_result_label + return derive_execution_fields(item) diff --git a/app/db/recommendation_commands.py b/app/db/recommendation_commands.py new file mode 100644 index 0000000..0438fa7 --- /dev/null +++ b/app/db/recommendation_commands.py @@ -0,0 +1,474 @@ +"""Recommendation write commands and lifecycle transitions.""" + +import json +from datetime import datetime, timedelta + +from app.config.config_loader import get_meta +from app.core.opportunity_lifecycle import ( + apply_entry_quality_gate, + normalize_action_status, + normalize_json_object, +) +from app.core.signal_taxonomy import signal_codes as build_signal_codes, signal_labels as build_signal_labels +from app.db.recommendation_state import ( + derive_minimal_state_fields, + entry_window_policy, + execution_fields_from_persisted_state, + normalize_entry_plan, + normalize_signals, + opportunity_fields_from_plan, + risk_suggestion, + state_fields_for_storage, +) +from app.db.schema import get_conn + + +def _serialized_signal_payload(signals): + labels = build_signal_labels(signals if isinstance(signals, list) else normalize_signals(signals)) + codes = build_signal_codes(labels) + stored_signals = json.dumps(labels, ensure_ascii=False) if isinstance(signals, list) else signals + return stored_signals, json.dumps(codes, ensure_ascii=False), json.dumps(labels, ensure_ascii=False) + + +def create_recommendation( + symbol, + rec_state, + rec_score, + entry_price, + stop_loss=0, + tp1=0, + tp2=0, + sector="", + signals="", + is_meme=0, + entry_plan=None, + direction="中性", + force_reason="", + base_state="", + sector_signal_count=0, + market_context=None, + derivatives_context=None, + sector_context=None, +): + """Create or merge the current recommendation record for one symbol.""" + raw_pct = round(rec_score * 100.0 / 30) if rec_score else 0 + rec_score_pct = min(raw_pct, 100) + strategy_version = str(get_meta().get("strategy_version") or "").strip() + now = datetime.now().isoformat() + conn = get_conn() + + incoming_action = normalize_action_status((entry_plan or {}).get("entry_action", "观察") if entry_plan else "观察", "active") + incoming_exec, incoming_bucket, incoming_lifecycle, incoming_triggered, incoming_reason = derive_minimal_state_fields( + "active", incoming_action, entry_plan or {} + ) + stored_signals, signal_codes_json, signal_labels_json = _serialized_signal_payload(signals) + opportunity_fields = opportunity_fields_from_plan(entry_plan or {}) + duplicate_cursor = conn.execute( + """ + SELECT * FROM recommendation + WHERE symbol=%s AND status='active' AND COALESCE(display_bucket,'watch_pool') != 'history' + ORDER BY id DESC LIMIT 1 + """, + (symbol,), + ) + duplicate_row = duplicate_cursor.fetchone() if hasattr(duplicate_cursor, "fetchone") else None + if duplicate_row and (entry_plan or duplicate_row["rec_state"] == rec_state): + existing_id = duplicate_row["id"] if hasattr(duplicate_row, "keys") else duplicate_row[0] + existing_score = duplicate_row["rec_score"] or 0 + merged_state = rec_state + merged_score = max(existing_score, rec_score_pct) + conn.execute( + """ + UPDATE recommendation + SET rec_state=%s, rec_score=%s, sector=COALESCE(NULLIF(%s, ''), sector), + signals=%s, signal_codes_json=%s, signal_labels_json=%s, is_meme=%s, direction=%s, strategy_version=%s, + force_reason=COALESCE(NULLIF(%s, ''), force_reason), + base_state=COALESCE(NULLIF(%s, ''), base_state), + sector_signal_count=GREATEST(COALESCE(sector_signal_count,0), %s), + entry_plan_json=CASE WHEN %s != '{}' THEN %s ELSE entry_plan_json END, + market_context_json=%s, derivatives_context_json=%s, sector_context_json=%s, + opportunity_level=COALESCE(NULLIF(%s, ''), opportunity_level), + opportunity_level_label=COALESCE(NULLIF(%s, ''), opportunity_level_label), + holding_horizon=COALESCE(NULLIF(%s, ''), holding_horizon), + entry_model=COALESCE(NULLIF(%s, ''), entry_model), + stop_model=COALESCE(NULLIF(%s, ''), stop_model), + tp_model=COALESCE(NULLIF(%s, ''), tp_model), + action_status=CASE + WHEN action_status IN ('止盈1','止盈2','止损','跟踪止盈','衰减','反转') THEN action_status + ELSE COALESCE(NULLIF(%s, ''), action_status) + END, + execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s + WHERE id=%s + """, + ( + merged_state, + merged_score, + sector, + stored_signals, + signal_codes_json, + signal_labels_json, + is_meme, + direction, + strategy_version, + force_reason or "", + base_state or "", + int(sector_signal_count or 0), + json.dumps(entry_plan or {}, ensure_ascii=False), + json.dumps(entry_plan or {}, ensure_ascii=False), + json.dumps(market_context or {}, ensure_ascii=False), + json.dumps(derivatives_context or {}, ensure_ascii=False), + json.dumps(sector_context or {}, ensure_ascii=False), + opportunity_fields["opportunity_level"], + opportunity_fields["opportunity_level_label"], + opportunity_fields["holding_horizon"], + opportunity_fields["entry_model"], + opportunity_fields["stop_model"], + opportunity_fields["tp_model"], + incoming_action if entry_plan else "", + incoming_exec, + incoming_bucket, + incoming_lifecycle, + incoming_triggered, + incoming_reason, + existing_id, + ), + ) + conn.commit() + conn.close() + return existing_id + + cursor = conn.execute( + """ + INSERT INTO recommendation (symbol, rec_time, rec_state, rec_score, entry_price, + stop_loss, tp1, tp2, sector, signals, signal_codes_json, signal_labels_json, is_meme, direction, + current_price, max_price, min_price, last_track_time, entry_plan_json, + force_reason, base_state, sector_signal_count, + market_context_json, derivatives_context_json, sector_context_json, + opportunity_level, opportunity_level_label, holding_horizon, entry_model, stop_model, tp_model, + action_status, execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, + strategy_version) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, + ( + symbol, + now, + rec_state, + rec_score_pct, + entry_price, + stop_loss, + tp1, + tp2, + sector, + stored_signals, + signal_codes_json, + signal_labels_json, + is_meme, + direction, + entry_price, + entry_price, + entry_price, + now, + json.dumps(entry_plan, ensure_ascii=False) if entry_plan else "{}", + force_reason or "", + base_state or "", + int(sector_signal_count or 0), + json.dumps(market_context or {}, ensure_ascii=False), + json.dumps(derivatives_context or {}, ensure_ascii=False), + json.dumps(sector_context or {}, ensure_ascii=False), + opportunity_fields["opportunity_level"], + opportunity_fields["opportunity_level_label"], + opportunity_fields["holding_horizon"], + opportunity_fields["entry_model"], + opportunity_fields["stop_model"], + opportunity_fields["tp_model"], + incoming_action, + incoming_exec, + incoming_bucket, + incoming_lifecycle, + incoming_triggered, + incoming_reason, + strategy_version, + ), + ) + rec_id = cursor.fetchone()["id"] + conn.commit() + conn.close() + return rec_id + + +def expire_old_recommendations(hours=48): + """Mark old active recommendations as expired.""" + conn = get_conn() + cutoff = (datetime.now() - timedelta(hours=float(hours or 48))).isoformat() + execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason = derive_minimal_state_fields( + "expired", "过期", {} + ) + conn.execute( + """ + UPDATE recommendation + SET status='expired', + action_status=CASE WHEN action_status IN ('止盈1','止盈2','止损','跟踪止盈') THEN action_status ELSE '过期' END, + expired_time=%s, + execution_status=%s, + display_bucket=%s, + lifecycle_state=%s, + entry_triggered=%s, + state_reason=%s + WHERE status='active' AND rec_time < %s + """, + (datetime.now().isoformat(), execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, cutoff), + ) + conn.commit() + conn.close() + + +def apply_recommendation_state_transition(rec_id, requested_action, current_price, event_time=None, signals=None): + """The single DB entry for turning price events into recommendation action state.""" + event_time = event_time or datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + conn = get_conn() + row = conn.execute("SELECT * FROM recommendation WHERE id=%s", (rec_id,)).fetchone() + if not row: + conn.close() + return {"updated": False, "push_required": False, "reason": "not_found"} + + item = dict(row) + previous_action = (item.get("action_status") or "持有").strip() + entry_plan = normalize_entry_plan(item.get("entry_plan_json")) + terminal_map = {"hit_tp2": "止盈2", "stopped_out": "止损"} + status = (item.get("status") or "active").strip() + final_action = normalize_action_status(terminal_map.get(status, requested_action), status) + + if status not in terminal_map: + final_action, entry_plan, gate_reasons = apply_entry_quality_gate( + action_status=final_action, + entry_plan=entry_plan, + signals=signals if signals is not None else item.get("signals"), + current_price=current_price, + market_context=normalize_json_object(item.get("market_context_json")), + derivatives_context=normalize_json_object(item.get("derivatives_context_json")), + sector_context=normalize_json_object(item.get("sector_context_json")), + ) + else: + gate_reasons = [] + + window_entry_price = item.get("entry_price") or current_price or 0 + window_rec_time = item.get("rec_time") or event_time + if final_action == "可即刻买入" and previous_action != "可即刻买入": + window_entry_price = current_price + window_rec_time = event_time + entry_window = entry_window_policy(window_entry_price, current_price, window_rec_time, event_time) + if final_action == "可即刻买入" and previous_action == "可即刻买入": + if entry_window["status"] == "expired": + final_action = "观察" + gate_reasons.append(entry_window["reason"]) + elif entry_window["status"] == "price_left_up": + final_action = "等回踩" + gate_reasons.append(entry_window["reason"]) + elif entry_window["status"] == "price_left_down": + final_action = "观察" + gate_reasons.append(entry_window["reason"]) + + should_reset_entry = final_action == "可即刻买入" and previous_action != "可即刻买入" + if should_reset_entry: + max_price = current_price + min_price = current_price + pnl_pct = 0.0 + max_pnl_pct = 0.0 + max_drawdown_pct = 0.0 + rec_time = event_time + entry_price = current_price + else: + old_entry = item.get("entry_price") or current_price or 0 + old_max = item.get("max_price") or old_entry + old_min = item.get("min_price") or old_entry + max_price = max(old_max, current_price) if current_price else old_max + min_price = min(old_min, current_price) if current_price else old_min + entry_price = old_entry + rec_time = item.get("rec_time") + pnl_pct = round((current_price / old_entry - 1) * 100, 2) if old_entry and current_price else item.get("pnl_pct", 0) + max_pnl_pct = round((max_price / old_entry - 1) * 100, 2) if old_entry else item.get("max_pnl_pct", 0) + max_drawdown_pct = round((min_price / old_entry - 1) * 100, 2) if old_entry else item.get("max_drawdown_pct", 0) + + execution_status, execution_label, execution_reason = execution_fields_from_persisted_state( + {**item, "action_status": final_action, "status": status}, entry_plan + ) + execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason = state_fields_for_storage( + status, final_action, execution_status, execution_reason + ) + push_required = final_action in ("可即刻买入", "跟踪止盈") and previous_action != final_action and execution_status in ("buy_now", "completed") + + conn.execute( + """ + UPDATE recommendation + SET action_status=%s, entry_plan_json=%s, current_price=%s, max_price=%s, min_price=%s, + pnl_pct=%s, max_pnl_pct=%s, max_drawdown_pct=%s, last_track_time=%s, + execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s, + rec_time=CASE WHEN %s=1 THEN %s ELSE rec_time END, + entry_price=CASE WHEN %s=1 THEN %s ELSE entry_price END + WHERE id=%s + """, + ( + final_action, + json.dumps(entry_plan, ensure_ascii=False), + current_price, + max_price, + min_price, + pnl_pct, + max_pnl_pct, + max_drawdown_pct, + event_time, + execution_status, + display_bucket, + lifecycle_state, + entry_triggered, + state_reason, + 1 if should_reset_entry else 0, + rec_time, + 1 if should_reset_entry else 0, + entry_price, + rec_id, + ), + ) + conn.commit() + conn.close() + + return { + "updated": True, + "id": rec_id, + "symbol": item.get("symbol"), + "previous_action_status": previous_action, + "action_status": final_action, + "execution_status": execution_status, + "execution_label": execution_label, + "execution_reason": execution_reason, + "display_bucket": display_bucket, + "lifecycle_state": lifecycle_state, + "entry_triggered": entry_triggered, + "entry_price": entry_price, + "current_price": current_price, + "pnl_pct": pnl_pct, + "stop_loss": item.get("stop_loss") or entry_plan.get("stop_loss") or 0, + "tp1": item.get("tp1") or entry_plan.get("tp1") or entry_plan.get("take_profit_1") or 0, + "tp2": item.get("tp2") or entry_plan.get("tp2") or 0, + "entry_plan": entry_plan, + "entry_window": entry_window, + "risk_suggestion": risk_suggestion( + entry_price, + item.get("stop_loss") or entry_plan.get("stop_loss") or 0, + item.get("tp1") or entry_plan.get("tp1") or entry_plan.get("take_profit_1") or 0, + ), + "gate_reasons": gate_reasons, + "push_required": push_required, + "push_symbol": item.get("symbol"), + "push_entry_price": entry_price, + "push_current_price": current_price, + "push_pnl_pct": pnl_pct, + "push_signals": signals or [], + } + + +def recompute_all_recommendation_state_fields(conn=None): + """Backfill unified recommendation state fields from persisted status/action.""" + owns_conn = conn is None + if owns_conn: + conn = get_conn() + rows = conn.execute("SELECT id,status,action_status,entry_plan_json FROM recommendation").fetchall() + updated = 0 + for row in rows: + ep = normalize_entry_plan(row["entry_plan_json"]) + action = normalize_action_status(row["action_status"], row["status"]) + execution_status, execution_label, execution_reason = execution_fields_from_persisted_state( + {"status": row["status"], "action_status": action, "entry_plan_json": row["entry_plan_json"]}, ep + ) + execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason = state_fields_for_storage( + row["status"], action, execution_status, execution_reason + ) + conn.execute( + """UPDATE recommendation + SET action_status=%s, execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s + WHERE id=%s""", + (action, execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, row["id"]), + ) + updated += 1 + if owns_conn: + conn.commit() + conn.close() + return updated + + +def update_recommendation_action_status(rec_id, action_status): + """Update action status while protecting terminal trades and quality gates.""" + conn = get_conn() + row = conn.execute( + """ + SELECT symbol, status, action_status, entry_plan_json, signals, current_price, + market_context_json, derivatives_context_json, sector_context_json + FROM recommendation WHERE id=%s + """, + (rec_id,), + ).fetchone() + terminal_map = { + "hit_tp1": "止盈1", + "hit_tp2": "止盈2", + "stopped_out": "止损", + } + entry_plan = {} + if row: + if row["status"] in terminal_map and action_status not in ("止盈1", "止盈2", "止损", "跟踪止盈"): + action_status = terminal_map[row["status"]] + else: + entry_plan = normalize_entry_plan(row["entry_plan_json"]) + gated_action, gated_plan, _ = apply_entry_quality_gate( + action_status=action_status, + entry_plan=entry_plan, + signals=row["signals"], + current_price=row["current_price"] or 0, + market_context=normalize_json_object(row["market_context_json"]), + derivatives_context=normalize_json_object(row["derivatives_context_json"]), + sector_context=normalize_json_object(row["sector_context_json"]), + ) + action_status = gated_action + entry_plan = gated_plan + if row["status"] not in terminal_map and row["symbol"]: + try: + trade = conn.execute( + "SELECT status, closed_at FROM paper_trades WHERE recommendation_id=%s", + (rec_id,), + ).fetchone() + if trade and trade.get("status") == "closed": + action_status = row["action_status"] if row["action_status"] in ("止盈1", "止盈2", "止损", "跟踪止盈") else "观察" + entry_plan.setdefault("paper_trade_closed", True) + entry_plan.setdefault("paper_trade_closed_at", trade.get("closed_at")) + except Exception: + pass + if entry_plan: + execution_status, execution_label, execution_reason = execution_fields_from_persisted_state( + {"status": row["status"] if row else "active", "action_status": action_status, "entry_plan_json": json.dumps(entry_plan, ensure_ascii=False)}, + entry_plan, + ) + execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason = state_fields_for_storage( + row["status"] if row else "active", action_status, execution_status, execution_reason + ) + conn.execute( + """ + UPDATE recommendation + SET action_status=%s, entry_plan_json=%s, execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s + WHERE id=%s + """, + (action_status, json.dumps(entry_plan, ensure_ascii=False), execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, rec_id), + ) + else: + execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason = derive_minimal_state_fields( + row["status"] if row else "active", action_status, {} + ) + conn.execute( + """ + UPDATE recommendation + SET action_status=%s, execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s + WHERE id=%s + """, + (action_status, execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, rec_id), + ) + conn.commit() + conn.close() diff --git a/app/db/recommendation_queries.py b/app/db/recommendation_queries.py index c5488b9..c3d2b0f 100644 --- a/app/db/recommendation_queries.py +++ b/app/db/recommendation_queries.py @@ -2,85 +2,18 @@ from datetime import datetime, timedelta -from app.db.altcoin_db import ( - PUSH_COOLDOWN_HOURS, - _classify_recommendation_result, - _derive_execution_fields, - _is_actionable_execution_status, - apply_recommendation_state_transition, - update_recommendation_tracking, +from app.db.recommendation_commands import apply_recommendation_state_transition +from app.db.recommendation_state import ( + classify_recommendation_result as _classify_recommendation_result, + derive_execution_fields as _derive_execution_fields, + is_actionable_execution_status as _is_actionable_execution_status, ) +from app.db.push_queries import get_recommendation_for_push, log_push, should_push from app.db.schema import get_conn +from app.db.tracking_queries import update_recommendation_tracking from app.services.llm_insights import attach_recommendation_insights -def should_push(symbol: str, push_type: str, action_status: str = "") -> bool: - """状态感知冷却判断。""" - conn = get_conn() - cutoff = (datetime.now() - timedelta(hours=PUSH_COOLDOWN_HOURS)).isoformat() - if action_status: - row = conn.execute( - "SELECT action_status FROM push_log WHERE symbol=%s AND push_type=%s AND pushed_at > %s ORDER BY id DESC LIMIT 1", - (symbol, push_type, cutoff), - ).fetchone() - conn.close() - if row is None: - return True - return row[0] != action_status - - row = conn.execute( - "SELECT id FROM push_log WHERE symbol=%s AND push_type=%s AND pushed_at > %s ORDER BY id DESC LIMIT 1", - (symbol, push_type, cutoff), - ).fetchone() - conn.close() - return row is None - - -def log_push(symbol: str, push_type: str, action_status: str = "", rec_id: int = 0): - """记录一次推送,保留推荐来源可追溯性。""" - conn = get_conn() - try: - conn.execute( - "INSERT INTO push_log (symbol, push_type, action_status, rec_id, pushed_at) VALUES (%s,%s,%s,%s,%s)", - (symbol, push_type, action_status, int(rec_id or 0), datetime.now().isoformat()), - ) - conn.commit() - finally: - conn.close() - - -def get_recommendation_for_push(rec_id: int): - """读取单条推荐并派生网站同口径展示状态,供推送层消费。""" - try: - rec_id = int(rec_id or 0) - except Exception: - rec_id = 0 - if rec_id <= 0: - return None - - conn = get_conn() - row = conn.execute( - """ - SELECT r.*, - lpc.price AS latest_cache_price, - lpc.updated_at AS latest_cache_updated_at - FROM recommendation r - LEFT JOIN latest_price_cache lpc ON lpc.symbol = r.symbol - WHERE r.id=%s - """, - (rec_id,), - ).fetchone() - conn.close() - if not row: - return None - - item = dict(row) - rec_result, rec_result_label = _classify_recommendation_result(item) - item["recommendation_result"] = rec_result - item["recommendation_result_label"] = rec_result_label - return _derive_execution_fields(item) - - def get_active_recommendations(actionable_only: bool = False): """获取所有 active 推荐。""" conn = get_conn() @@ -236,7 +169,19 @@ def get_active_recommendations_deduped( conn.close() all_items = [] - summary = {"buy_now": 0, "wait_pullback": 0, "observe": 0, "observe_strong": 0, "observe_weak": 0, "expired": 0, "total": 0} + summary = { + "buy_now": 0, + "wait_pullback": 0, + "observe": 0, + "observe_strong": 0, + "observe_weak": 0, + "expired": 0, + "total": 0, + "discovery_burst": 0, + "executable_now": 0, + "planned_entry": 0, + "watch_pool": 0, + } now = datetime.now() for row in rows: item = dict(row) @@ -262,6 +207,14 @@ def get_active_recommendations_deduped( if actionable_only and not _is_actionable_execution_status(item.get("execution_status")): continue all_items.append(item) + if item.get("is_discovery_burst"): + summary["discovery_burst"] += 1 + if item.get("is_executable_now"): + summary["executable_now"] += 1 + if item.get("execution_status") == "wait_pullback": + summary["planned_entry"] += 1 + if item.get("is_watch_pool"): + summary["watch_pool"] += 1 if item.get("execution_status") == "buy_now": summary["buy_now"] += 1 elif item.get("execution_status") == "wait_pullback": diff --git a/app/db/recommendation_state.py b/app/db/recommendation_state.py new file mode 100644 index 0000000..6fe788a --- /dev/null +++ b/app/db/recommendation_state.py @@ -0,0 +1,496 @@ +"""Recommendation lifecycle and display-state derivation helpers.""" + +import json +from datetime import datetime + +from app.config.config_loader import get_meta +from app.core.opportunity_lifecycle import ( + apply_entry_quality_gate, + derive_display_bucket, + is_executed_lifecycle, + normalize_action_status, +) + + +def state_fields_for_storage(status, action_status, execution_status="", reason=""): + bucket = derive_display_bucket(status or "active", action_status, execution_status) + return ( + bucket.get("execution_status", execution_status or "observe"), + bucket.get("display_bucket", "watch_pool"), + bucket.get("lifecycle_state", "watching"), + 1 if is_executed_lifecycle(status or "active", action_status, bucket.get("execution_status")) else 0, + reason or "", + ) + + +def derive_minimal_state_fields(status, action_status, entry_plan=None): + action = normalize_action_status(action_status, status) + if action == "可即刻买入": + execution_status = "buy_now" + reason = "主链路确认当前入场窗口" + elif action == "等回踩": + execution_status = "wait_pullback" + reason = "等待回踩触发,未触发前不计推荐收益" + elif action == "持有": + execution_status = "holding" + reason = "已进入持仓跟踪" + elif action in ("止盈1", "止盈2", "跟踪止盈"): + execution_status = "completed" + reason = "利润管理/阶段兑现" + elif action in ("止损", "衰减", "反转", "放弃", "过期", "归档") or status in ("stopped_out", "expired", "invalid", "archived"): + execution_status = "invalid" + reason = "机会失效,归入历史复盘" + else: + execution_status = "observe" + reason = "观察池,未触发入场" + return state_fields_for_storage(status, action, execution_status, reason) + + +def opportunity_fields_from_plan(entry_plan): + plan = entry_plan if isinstance(entry_plan, dict) else {} + return { + "opportunity_level": str(plan.get("opportunity_level") or ""), + "opportunity_level_label": str(plan.get("opportunity_level_label") or ""), + "holding_horizon": str(plan.get("holding_horizon") or ""), + "entry_model": str(plan.get("entry_model") or ""), + "stop_model": str(plan.get("stop_model") or plan.get("stop_basis") or ""), + "tp_model": str(plan.get("tp_model") or plan.get("tp_basis") or ""), + } + + +def entry_window_policy( + entry_price, + current_price, + rec_time, + event_time=None, + window_hours=2.0, + up_deviation_pct=1.5, + down_deviation_pct=1.2, +): + """Stage-1 entry window trust policy.""" + event_time = event_time or datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + try: + entry_price = float(entry_price or 0) + current_price = float(current_price or 0) + except Exception: + entry_price = 0 + current_price = 0 + deviation_pct = round((current_price / entry_price - 1) * 100, 2) if entry_price and current_price else 0.0 + age_minutes = 0.0 + try: + start = datetime.fromisoformat(str(rec_time)) + end = datetime.fromisoformat(str(event_time)) + age_minutes = round((end - start).total_seconds() / 60.0, 1) + except Exception: + age_minutes = 0.0 + remaining_minutes = round(max(0.0, window_hours * 60.0 - age_minutes), 1) + result = { + "status": "active", + "label": "入场窗口有效", + "reason": "入场窗口仍在有效期内,价格未明显脱离触发价", + "age_minutes": age_minutes, + "remaining_minutes": remaining_minutes, + "window_hours": window_hours, + "entry_price": entry_price, + "current_price": current_price, + "deviation_pct": deviation_pct, + "max_up_deviation_pct": up_deviation_pct, + "max_down_deviation_pct": down_deviation_pct, + } + if age_minutes > window_hours * 60.0: + result.update({ + "status": "expired", + "label": "窗口已过期", + "reason": f"入场窗口超过有效期 {window_hours:g} 小时,避免沿用旧信号追入", + "remaining_minutes": 0.0, + }) + elif deviation_pct > up_deviation_pct: + result.update({ + "status": "price_left_up", + "label": "价格已上脱离", + "reason": f"当前价较触发价上脱离 {deviation_pct:.2f}%,超过 {up_deviation_pct:g}% 阈值,避免追高", + }) + elif deviation_pct < -down_deviation_pct: + result.update({ + "status": "price_left_down", + "label": "价格已下破", + "reason": f"当前价较触发价下破 {abs(deviation_pct):.2f}%,买点动能失效,转观察", + }) + return result + + +def risk_suggestion(entry_price, stop_loss, tp1, risk_budget_pct=1.0, max_position_pct=100.0): + """Convert entry/stop/TP1 into a simple position-size suggestion.""" + try: + entry_price = float(entry_price or 0) + stop_loss = float(stop_loss or 0) + tp1 = float(tp1 or 0) + except Exception: + entry_price = stop_loss = tp1 = 0 + stop_distance_pct = round(abs(entry_price - stop_loss) / entry_price * 100, 2) if entry_price and stop_loss else 0.0 + suggested_position_pct = round(min(max_position_pct, risk_budget_pct / stop_distance_pct * 100), 2) if stop_distance_pct else 0.0 + tp1_profit_pct = round((tp1 / entry_price - 1) * 100, 2) if entry_price and tp1 else 0.0 + rr = round(tp1_profit_pct / stop_distance_pct, 2) if stop_distance_pct else 0.0 + max_loss_pct = round(suggested_position_pct * stop_distance_pct / 100, 2) if suggested_position_pct else 0.0 + return { + "risk_budget_pct": risk_budget_pct, + "stop_distance_pct": stop_distance_pct, + "suggested_position_pct": suggested_position_pct, + "max_loss_pct": max_loss_pct, + "tp1_profit_pct": tp1_profit_pct, + "rr": rr, + "max_position_pct": max_position_pct, + "valid": bool(entry_price and stop_loss and stop_distance_pct > 0), + } + + +def execution_fields_from_persisted_state(item, entry_plan=None): + """Derive display execution state from persisted status/action only.""" + entry_plan = entry_plan if entry_plan is not None else normalize_entry_plan(item.get("entry_plan_json")) + status = (item.get("status") or "active").strip() + action_status = normalize_action_status(item.get("action_status") or "持有", status) + + bucket = derive_display_bucket(status, action_status, "") + execution_status = bucket.get("execution_status") + if execution_status == "completed": + return "completed", "✅ 已兑现,仅观察", f"该机会已进入{action_status or '利润管理'}阶段,仅作为持仓跟踪记录" + if execution_status == "invalid": + if action_status == "止损": + reason = "该机会已触发风险边界,原入场逻辑失效" + elif action_status == "衰减": + reason = "该机会已出现趋势衰减,追高性价比下降" + elif action_status == "反转": + reason = "该机会已出现趋势反转,原多头逻辑被破坏" + elif action_status == "放弃": + reason = "该机会已被标记为放弃,不再满足入场条件" + else: + reason = "该机会观察周期结束或逻辑失效,已归入历史复盘" + return "invalid", "🔴 已失效,勿追", reason + if execution_status == "buy_now": + stop = str(entry_plan.get("stop_loss", "")) if entry_plan else "" + return "buy_now", "🟢 现在可买", "推荐时就是可即刻买入;主链路确认当前仍在入场窗口" + ((",风险边界 " + stop) if stop else "") + if execution_status == "wait_pullback": + gate = entry_plan.get("entry_quality_gate") or {} + if gate.get("reasons"): + reason = "等待更优位置;" + ";".join(gate.get("reasons", [])[:3]) + else: + reason = "等待回踩至 " + (str(entry_plan.get("entry_price", "")) if entry_plan else "参考价") + " 附近再评估" + return "wait_pullback", "🟡 等回踩,不追高", reason + if execution_status == "holding": + return "holding", "持仓跟踪", "该机会已触发入场,进入持仓跟踪" + gate = entry_plan.get("entry_quality_gate") or {} + if gate.get("reasons"): + reason = "机会结构仍在观察;" + ";".join(gate.get("reasons", [])[:3]) + else: + reason = "暂无明确入场窗口,继续观察" + return "observe", "观察池", reason + + +def normalize_entry_plan(entry_plan_json): + try: + if isinstance(entry_plan_json, dict): + return entry_plan_json + if entry_plan_json: + return json.loads(entry_plan_json) + except Exception: + pass + return {} + + +def normalize_json_object(payload): + try: + if isinstance(payload, dict): + return payload + if payload: + parsed = json.loads(payload) + if isinstance(parsed, dict): + return parsed + except Exception: + pass + return {} + + +def normalize_signals(payload): + try: + if isinstance(payload, list): + return payload + if isinstance(payload, str) and payload.strip(): + parsed = json.loads(payload) + if isinstance(parsed, list): + return parsed + except Exception: + pass + return [] + + +def observe_tier(item): + """Observation pool tier: strong=worth user attention, weak=low-quality watch.""" + status = str(item.get("execution_status") or "") + if status in ("buy_now", "wait_pullback") or item.get("display_bucket") == "realtime": + return "strong", "入场/等待类有效机会" + try: + score = float(item.get("rec_score") or 0) + except Exception: + score = 0 + signals = item.get("signals") or [] + if isinstance(signals, str): + signals = normalize_signals(signals) + sig_text = " ".join(str(x) for x in signals) + force_reason = str(item.get("force_reason") or "") + derivatives = normalize_json_object(item.get("derivatives_context_json") or item.get("derivatives_context")) + market = normalize_json_object(item.get("market_context_json") or item.get("market_context")) + if not derivatives and isinstance(item.get("derivatives_context"), dict): + derivatives = item.get("derivatives_context") or {} + if not market and isinstance(item.get("market_context"), dict): + market = item.get("market_context") or {} + long_pct = 0.0 + try: + long_pct = float(derivatives.get("top_trader_long_pct") or 0) + except Exception: + long_pct = 0.0 + acc1 = 0.0 + acc4 = 0.0 + try: + acc1 = float(market.get("turnover_acceleration_1h") or 0) + acc4 = float(market.get("turnover_acceleration_4h") or 0) + except Exception: + pass + stale_only = ("已过期" in sig_text or "历史" in sig_text) and not any(k in sig_text for k in ("当前", "新近", "刚刚", "入场窗口", "量价齐飞")) + weak_reasons = [] + if score < 50: + weak_reasons.append(f"评分偏低({int(score)})") + if stale_only: + weak_reasons.append("主要触发来自历史/过期信号") + if "静K蓄力旁路" in force_reason and acc4 < 1.3 and acc1 < 1.3: + weak_reasons.append("静K旁路量能不足") + gate = {} + try: + ep = item.get("entry_plan") or normalize_json_object(item.get("entry_plan_json")) + gate = ep.get("entry_quality_gate") or {} + except Exception: + gate = {} + gate_reasons = gate.get("reasons") or [] + gate_reason_text = ";".join(str(x) for x in gate_reasons[:3]) + if any("回踩参考已到" in str(x) and "不达标" in str(x) for x in gate_reasons): + return "weak" if score < 55 else "strong", (gate_reason_text or "回踩参考已到,但实时盈亏比不达标") + ";暂不构成入场窗口,继续观察是否重新恢复可买盈亏比" + strong_context = score >= 65 or long_pct >= 75 or max(acc1, acc4) >= 1.5 + if weak_reasons and not strong_context: + return "weak", ";".join(weak_reasons[:3]) + if gate_reason_text: + return "strong", gate_reason_text + ";继续观察结构是否恢复" + return "strong", "观察池有效候选" + + +def derive_execution_fields(item): + entry_plan = normalize_entry_plan(item.get("entry_plan_json")) + market_context = normalize_json_object(item.get("market_context_json")) + derivatives_context = normalize_json_object(item.get("derivatives_context_json")) + sector_context = normalize_json_object(item.get("sector_context_json")) + signals = normalize_signals(item.get("signals")) + item["signals"] = signals + initial_action = normalize_action_status(entry_plan.get("entry_action") or item.get("action_status") or "持有", item.get("status") or "active") + action_status = normalize_action_status(item.get("action_status") or initial_action or "持有", item.get("status") or "active") + if action_status == "持有" and initial_action in ("可即刻买入", "等回踩", "观察"): + action_status = initial_action + current_price_for_window = item.get("latest_cache_price") or item.get("current_price") or item.get("entry_price") or 0 + action_status, entry_plan, _entry_gate_reasons = apply_entry_quality_gate( + action_status=action_status, + entry_plan=entry_plan, + signals=item.get("signals"), + current_price=current_price_for_window, + market_context=market_context, + derivatives_context=derivatives_context, + sector_context=sector_context, + ) + try: + rec_score_for_gate = float(item.get("rec_score") or 0) + except Exception: + rec_score_for_gate = 0 + if action_status == "可即刻买入" and rec_score_for_gate > 0 and rec_score_for_gate < 25: + reasons = [f"推荐评分{rec_score_for_gate:g}<25,属于信号不足,禁止展示为现价买入"] + gate = entry_plan.get("entry_quality_gate") if isinstance(entry_plan.get("entry_quality_gate"), dict) else {} + existing_reasons = list(gate.get("reasons") or []) + entry_plan["entry_quality_gate"] = { + **gate, + "blocked_action": gate.get("blocked_action") or action_status, + "final_action": "观察", + "reasons": existing_reasons + reasons, + } + action_status = "观察" + if initial_action == "可即刻买入" and action_status != "可即刻买入": + initial_action = action_status + status = (item.get("status") or "active").strip() + force_reason = (item.get("force_reason") or "").strip() + base_state = (item.get("base_state") or "").strip() + sector_signal_count = item.get("sector_signal_count") + strategy_version = str(item.get("strategy_version") or "").strip() + if not strategy_version: + strategy_version = str(get_meta().get("strategy_version") or "").strip() + + if current_price_for_window: + item["current_price"] = current_price_for_window + try: + entry_price_for_pnl = float(item.get("entry_price") or 0) + current_price_float = float(current_price_for_window or 0) + if entry_price_for_pnl > 0 and current_price_float > 0: + item["pnl_pct"] = round((current_price_float - entry_price_for_pnl) / entry_price_for_pnl * 100, 2) + except Exception: + pass + if item.get("latest_cache_updated_at"): + item["current_price_updated_at"] = item.get("latest_cache_updated_at") + entry_window = entry_window_policy( + item.get("entry_price") or entry_plan.get("entry_price") or 0, + current_price_for_window, + item.get("rec_time") or "", + ) if action_status == "可即刻买入" else {} + if action_status == "可即刻买入" and entry_window: + window_status = entry_window.get("status") + if window_status in ("expired", "price_left_down"): + action_status = "观察" + elif window_status == "price_left_up": + action_status = "等回踩" + if window_status and window_status != "active": + item["entry_window_alert"] = entry_window + item_for_execution = {**item, "action_status": action_status} + execution_status, execution_label, execution_reason = execution_fields_from_persisted_state(item_for_execution, entry_plan) + + bucket_fields = derive_display_bucket(status, action_status, execution_status) + execution_status = bucket_fields.get("execution_status") or execution_status + item["initial_action"] = initial_action + item["action_status"] = normalize_action_status(action_status, status) + item["execution_status"] = execution_status + item["execution_label"] = execution_label + item["execution_reason"] = execution_reason + if item.get("entry_window_alert") and item["action_status"] == "可即刻买入": + item["action_status"] = "等回踩" if item["entry_window_alert"].get("status") == "price_left_up" else "观察" + execution_status, execution_label, execution_reason = execution_fields_from_persisted_state( + {**item, "action_status": item["action_status"], "status": status}, entry_plan + ) + item["execution_status"] = execution_status + item["execution_label"] = execution_label + item["execution_reason"] = execution_reason + item["display_bucket"] = bucket_fields.get("display_bucket") + item["lifecycle_state"] = bucket_fields.get("lifecycle_state") + bucket_fields = derive_display_bucket(status, item["action_status"], item["execution_status"]) + item["execution_status"] = bucket_fields.get("execution_status") or item["execution_status"] + item["display_bucket"] = bucket_fields.get("display_bucket") + item["lifecycle_state"] = bucket_fields.get("lifecycle_state") + item["entry_triggered"] = 1 if is_executed_lifecycle(status, item["action_status"], item["execution_status"]) else 0 + observe_tier_value, observe_reason = observe_tier(item) + item["observe_tier"] = observe_tier_value + item["observe_reason"] = observe_reason + item["entry_plan"] = entry_plan + opportunity_fields = opportunity_fields_from_plan(entry_plan) + for key, value in opportunity_fields.items(): + item[key] = item.get(key) or value + if item.get("opportunity_level") and not item.get("opportunity_level_label"): + try: + from app.core.opportunity_level import opportunity_level_meta + meta = opportunity_level_meta(item["opportunity_level"]) + item["opportunity_level_label"] = meta.get("label", "") + item["holding_horizon"] = item.get("holding_horizon") or meta.get("holding_horizon", "") + item["entry_model"] = item.get("entry_model") or meta.get("entry_model", "") + item["stop_model"] = item.get("stop_model") or meta.get("stop_model", "") + item["tp_model"] = item.get("tp_model") or meta.get("tp_model", "") + except Exception: + pass + item["entry_window"] = entry_window + if entry_window and entry_window.get("status") != "active": + item["entry_window_alert"] = entry_window + item["risk_suggestion"] = risk_suggestion( + item.get("entry_price") or entry_plan.get("entry_price") or 0, + item.get("stop_loss") or entry_plan.get("stop_loss") or 0, + item.get("tp1") or entry_plan.get("tp1") or entry_plan.get("take_profit_1") or 0, + ) + item["market_context"] = market_context + item["derivatives_context"] = derivatives_context + item["sector_context"] = sector_context + item["force_reason"] = force_reason + item["base_state"] = base_state + item["sector_signal_count"] = sector_signal_count + item["strategy_version"] = strategy_version + item["strategy_version_label"] = f"策略版本 {strategy_version}" if strategy_version else "" + attach_discovery_trade_fields(item) + return item + + +def is_actionable_execution_status(status): + return status in ("buy_now", "wait_pullback") + + +def attach_discovery_trade_fields(item): + """Split discovery state from trade execution stage.""" + discovery_state = str(item.get("rec_state") or "").strip() + trade_stage = str(item.get("execution_status") or "observe").strip() or "observe" + trade_label = item.get("execution_label") or "" + discovery_label_map = { + "爆发": "发现爆发", + "加速": "发现加速", + "蓄力": "发现蓄力", + "过期": "发现过期", + } + trade_label_map = { + "buy_now": "现在可买", + "wait_pullback": "等回踩", + "observe": "观察中", + "holding": "持仓跟踪", + "completed": "已兑现", + "invalid": "已失效", + } + item["discovery_state"] = discovery_state + item["discovery_label"] = discovery_label_map.get(discovery_state, discovery_state or "发现观察") + item["trade_stage"] = trade_stage + item["trade_stage_label"] = trade_label or trade_label_map.get(trade_stage, trade_stage or "观察中") + item["is_discovery_burst"] = discovery_state == "爆发" + item["is_executable_now"] = trade_stage == "buy_now" + item["is_trade_candidate"] = trade_stage in ("buy_now", "wait_pullback") + item["is_watch_pool"] = trade_stage in ("wait_pullback", "observe") or item.get("display_bucket") == "watch_pool" + return item + + +def is_executed_trade(item): + """Only truly triggered/position/closed samples count as executed PnL.""" + status = (item.get("status") or "").strip() + action_status = normalize_action_status(item.get("action_status"), status) + execution_status = item.get("execution_status") or "" + try: + entry_triggered = int(item.get("entry_triggered") or 0) == 1 + except Exception: + entry_triggered = False + if entry_triggered: + return True + if status in ("hit_tp1", "hit_tp2", "stopped_out"): + return True + if item.get("display_bucket") == "position" or execution_status in ("holding", "completed"): + return True + return is_executed_lifecycle(status, action_status, execution_status) + + +def classify_recommendation_result(item): + """Classify recommendation outcome without counting untriggered watch items as trades.""" + status = item.get("status") or "" + pnl_pct = item.get("pnl_pct") or 0 + max_pnl_pct = item.get("max_pnl_pct") or 0 + max_drawdown_pct = item.get("max_drawdown_pct") or 0 + + if status in ("hit_tp1", "hit_tp2"): + return "success", "✅ 止盈成功" + if status == "stopped_out": + return "failed", "❌ 止损失败" + + if not is_executed_trade(item): + return "pending", "⏳ 未执行" + + if status == "expired": + if max_pnl_pct >= 5: + return "success", "✅ 交易成功" + if pnl_pct <= -3 or max_drawdown_pct <= -5: + return "failed", "❌ 交易失败" + return "pending", "⏳ 跟踪中" + if status == "active": + if max_pnl_pct >= 5: + return "success", "✅ 交易成功" + if pnl_pct <= -3 or max_drawdown_pct <= -5: + return "failed", "❌ 交易失败" + return "pending", "⏳ 跟踪中" + return "pending", "⏳ 未执行" diff --git a/app/db/review_basic_queries.py b/app/db/review_basic_queries.py new file mode 100644 index 0000000..b025bc0 --- /dev/null +++ b/app/db/review_basic_queries.py @@ -0,0 +1,83 @@ +"""Basic review write/read helpers separated from strategy-iteration logic.""" + +import json +from datetime import datetime + +from app.db.schema import get_conn + + +def record_review(rec_id, symbol, outcome, pnl_48h, max_pnl_48h, + triggered_signals, hit_signals, miss_signals, lesson): + """Insert one recommendation review row.""" + conn = get_conn() + conn.execute(""" + INSERT INTO review_log (rec_id, symbol, review_time, outcome, pnl_48h, max_pnl_48h, + triggered_signals, hit_signals, miss_signals, lesson) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, ( + rec_id, symbol, datetime.now().isoformat(), outcome, pnl_48h, max_pnl_48h, + json.dumps(triggered_signals, ensure_ascii=False) if isinstance(triggered_signals, list) else triggered_signals, + json.dumps(hit_signals, ensure_ascii=False) if isinstance(hit_signals, list) else hit_signals, + json.dumps(miss_signals, ensure_ascii=False) if isinstance(miss_signals, list) else miss_signals, + lesson, + )) + conn.commit() + conn.close() + + +def update_signal_performance(signal_type, category, is_hit, pnl): + """Update rolling signal performance stats after review.""" + conn = get_conn() + row = conn.execute("SELECT * FROM signal_performance WHERE signal_type=%s", (signal_type,)).fetchone() + + if row: + total = row["total_count"] + 1 + hits = row["hit_count"] + (1 if is_hit else 0) + misses = row["miss_count"] + (0 if is_hit else 1) + old_avg_pnl = row["avg_pnl"] + new_avg_pnl = round((old_avg_pnl * (total - 1) + pnl) / total, 2) + hit_rate = round(hits / total * 100, 1) if total > 0 else 0 + + conn.execute(""" + UPDATE signal_performance SET total_count=%s, hit_count=%s, miss_count=%s, + hit_rate=%s, avg_pnl=%s, weight=%s, last_updated=%s + WHERE signal_type=%s + """, (total, hits, misses, hit_rate, new_avg_pnl, hit_rate / 50, datetime.now().isoformat(), signal_type)) + else: + conn.execute(""" + INSERT INTO signal_performance (signal_type, category, total_count, hit_count, miss_count, + hit_rate, avg_pnl, weight, last_updated) + VALUES (%s, %s, 1, %s, %s, %s, %s, %s, %s) + """, ( + signal_type, category, 1 if is_hit else 0, 0 if is_hit else 1, + 100 if is_hit else 0, pnl, 2.0 if is_hit else 0, datetime.now().isoformat(), + )) + + conn.commit() + conn.close() + + +def get_signal_weights(): + """Read dynamic signal weights.""" + conn = get_conn() + rows = conn.execute("SELECT signal_type, category, weight, hit_rate, avg_pnl, total_count FROM signal_performance").fetchall() + conn.close() + return {row["signal_type"]: dict(row) for row in rows} + + +def record_missed_explosion(symbol, price_at_detect, price_before, gain_pct, + reason_missed, features_detected, lesson): + """Insert one missed-explosion review row.""" + conn = get_conn() + conn.execute(""" + INSERT INTO missed_explosions (symbol, detect_time, price_at_detect, price_before, + gain_pct, reason_missed, features_detected, lesson) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + """, ( + symbol, datetime.now().isoformat(), price_at_detect, price_before, gain_pct, + json.dumps(reason_missed, ensure_ascii=False) if isinstance(reason_missed, list) else reason_missed, + json.dumps(features_detected, ensure_ascii=False) if isinstance(features_detected, list) else features_detected, + lesson, + )) + conn.commit() + conn.close() diff --git a/app/db/review_center.py b/app/db/review_center.py index f226e65..4972e33 100644 --- a/app/db/review_center.py +++ b/app/db/review_center.py @@ -52,6 +52,21 @@ def _bucket_count(rows, key, fallback="unknown"): return [{"name": k, "count": v} for k, v in sorted(counts.items(), key=lambda x: (-x[1], x[0]))] +def _dedupe_by_symbol(rows, limit=0): + items = [] + seen = set() + for row in rows: + symbol = str(row.get("symbol") or "").strip().upper() + key = symbol or f"row:{row.get('id')}" + if key in seen: + continue + seen.add(key) + items.append(row) + if limit and len(items) >= int(limit): + break + return items + + def _opportunity_review(conn, since): rec_rows = [dict(r) for r in conn.execute( """ @@ -73,16 +88,17 @@ def _opportunity_review(conn, since): """, (since,), ).fetchall()] - missed_rows = [dict(r) for r in conn.execute( + missed_rows_raw = [dict(r) for r in conn.execute( """ SELECT * FROM missed_explosions WHERE detect_time >= %s ORDER BY gain_pct DESC, detect_time DESC - LIMIT 20 + LIMIT 200 """, (since,), ).fetchall()] + missed_rows = _dedupe_by_symbol(missed_rows_raw, limit=20) total = len(rec_rows) executed_ids = { diff --git a/app/db/review_queries.py b/app/db/review_queries.py index 387e19f..8154c88 100644 --- a/app/db/review_queries.py +++ b/app/db/review_queries.py @@ -4,17 +4,17 @@ import json import re from datetime import datetime, timedelta -from app.db.altcoin_db import ( - _loads_json_field, +from app.db.strategy_rule_queries import ( backfill_strategy_failure_patterns, dry_run_strategy_candidate_performance, generate_candidates_from_review_history, get_strategy_failure_patterns, - get_strategy_insights, get_strategy_iteration_dashboard, get_strategy_rule_candidates, refresh_strategy_candidate_performance, + loads_json_field, ) +from app.db.strategy_insights import get_strategy_insights from app.db.schema import get_conn @@ -92,7 +92,7 @@ def log_strategy_iteration( def get_strategy_iteration_logs(limit=30, conn_provider=None, json_loader=None): conn_factory = conn_provider or get_conn - loader = json_loader or _loads_json_field + loader = json_loader or loads_json_field conn = conn_factory() rows = conn.execute( """ @@ -131,7 +131,7 @@ def get_strategy_iteration_logs(limit=30, conn_provider=None, json_loader=None): def get_strategy_iteration_summary(days=30, conn_provider=None, json_loader=None): conn_factory = conn_provider or get_conn - loader = json_loader or _loads_json_field + loader = json_loader or loads_json_field conn = conn_factory() cutoff = (datetime.now() - timedelta(days=float(days or 30))).isoformat() rows = conn.execute( diff --git a/app/db/screening_queries.py b/app/db/screening_queries.py new file mode 100644 index 0000000..56788d5 --- /dev/null +++ b/app/db/screening_queries.py @@ -0,0 +1,63 @@ +"""Screening log and coin-state candidate queries.""" + +import json +from datetime import datetime, timedelta + +from app.config.config_loader import state_score_thresholds +from app.db.schema import get_conn + + +def log_screening(layer, symbol, state, score, price, signals, + sector="", leader_status="", is_meme=0, + change_24h=0, funding_rate=0, detail=None): + """Record one screening-layer observation.""" + conn = get_conn() + conn.execute(""" + INSERT INTO screening_log (scan_time, layer, symbol, state, score, price, signals, + sector, leader_status, is_meme, change_24h, funding_rate, detail_json) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, ( + datetime.now().isoformat(), layer, symbol, state, score, price, + json.dumps(signals, ensure_ascii=False) if isinstance(signals, list) else signals, + sector, leader_status, is_meme, change_24h, funding_rate, + json.dumps(detail, ensure_ascii=False) if detail else "{}", + )) + conn.commit() + conn.close() + + +def get_screening_history(hours=24, limit=100): + """Read recent fine-screening rows.""" + conn = get_conn() + rows = conn.execute(""" + SELECT * FROM screening_log + WHERE layer='细筛' AND scan_time >= %s + ORDER BY score DESC, scan_time DESC LIMIT %s + """, ((datetime.now() - timedelta(hours=float(hours or 24))).isoformat(), limit)).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_candidates_for_confirm(): + """Read candidates for confirm layer, preferring the latest screening window.""" + try: + _, _, accumulate_threshold = state_score_thresholds() + except Exception: + accumulate_threshold = 3 + conn = get_conn() + rows = conn.execute(""" + SELECT * FROM coin_state + WHERE state IN ('加速', '蓄力') + AND score >= %s + AND detected_at >= %s + ORDER BY detected_at DESC, score DESC + """, (accumulate_threshold, (datetime.now() - timedelta(minutes=45)).isoformat())).fetchall() + if not rows: + rows = conn.execute(""" + SELECT * FROM coin_state + WHERE state IN ('加速', '蓄力') + AND score >= 5 + ORDER BY detected_at DESC, score DESC + """).fetchall() + conn.close() + return [dict(r) for r in rows] diff --git a/app/db/strategy_insights.py b/app/db/strategy_insights.py new file mode 100644 index 0000000..33d4d93 --- /dev/null +++ b/app/db/strategy_insights.py @@ -0,0 +1,234 @@ +"""Strategy attribution read model based on opportunity and paper-trading conversion.""" + +import json +import re + +from app.db.schema import get_conn + + +def safe_list_json(value): + try: + if isinstance(value, list): + return value + if isinstance(value, str) and value.strip(): + parsed = json.loads(value) + return parsed if isinstance(parsed, list) else [] + except Exception: + pass + return [] + + +def safe_dict_json(value): + try: + if isinstance(value, dict): + return value + if isinstance(value, str) and value.strip(): + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {} + except Exception: + pass + return {} + + +def get_strategy_insights(): + """Strategy attribution based on opportunity and paper-trading conversion. + + Recommendation rows are opportunities/signals, not an execution ledger. + Therefore this read model does not use recommendation.pnl_pct as strategy + PnL. Paper-trading PnL is exposed only as an execution-conversion metric. + """ + conn = get_conn() + rows = conn.execute( + """ + SELECT + r.*, + pt.id AS paper_trade_id, + pt.status AS paper_status, + pt.realized_pnl_pct AS paper_realized_pnl_pct, + pt.realized_pnl_usdt AS paper_realized_pnl_usdt, + pt.pnl_pct AS paper_pnl_pct, + pt.exit_reason AS paper_exit_reason + FROM recommendation r + LEFT JOIN paper_trades pt ON pt.recommendation_id = r.id + ORDER BY r.rec_time DESC, r.id DESC + """ + ).fetchall() + conn.close() + items = [dict(r) for r in rows] + + actionable_statuses = {"buy_now", "wait_pullback"} + total = len(items) + actionable = [x for x in items if (x.get("execution_status") or "") in actionable_statuses] + buy_now = [x for x in items if (x.get("execution_status") or "") == "buy_now"] + paper_items = [x for x in items if x.get("paper_trade_id")] + closed_paper = [x for x in paper_items if x.get("paper_status") == "closed"] + paper_wins = [x for x in closed_paper if float(x.get("paper_realized_pnl_pct") or 0) > 0] + paper_realized_usdt = round(sum(float(x.get("paper_realized_pnl_usdt") or 0) for x in closed_paper), 4) + overview = { + "total_opportunities": total, + "actionable_count": len(actionable), + "buy_now_count": len(buy_now), + "paper_trade_count": len(paper_items), + "closed_paper_trade_count": len(closed_paper), + "paper_win_count": len(paper_wins), + "paper_win_rate_pct": round(len(paper_wins) / len(closed_paper) * 100, 1) if closed_paper else 0, + "paper_realized_pnl_usdt": paper_realized_usdt, + "actionable_conversion_pct": round(len(actionable) / total * 100, 1) if total else 0, + "paper_conversion_pct": round(len(paper_items) / len(buy_now) * 100, 1) if buy_now else 0, + "definition": "策略归因只看机会转化和模拟交易转化;收益只来自 paper_trades,不读取 recommendation.pnl_pct。", + } + + def add_bucket(bucket_map, key, item): + if not key: + return + b = bucket_map.setdefault(key, { + "opportunity_count": 0, + "actionable_count": 0, + "buy_now_count": 0, + "paper_trade_count": 0, + "closed_paper_trade_count": 0, + "paper_win_count": 0, + "paper_realized_pnl_usdt": 0.0, + }) + execution_status = item.get("execution_status") or "" + paper_status = item.get("paper_status") or "" + b["opportunity_count"] += 1 + if execution_status in actionable_statuses: + b["actionable_count"] += 1 + if execution_status == "buy_now": + b["buy_now_count"] += 1 + if item.get("paper_trade_id"): + b["paper_trade_count"] += 1 + if paper_status == "closed": + b["closed_paper_trade_count"] += 1 + pnl_pct = float(item.get("paper_realized_pnl_pct") or 0) + if pnl_pct > 0: + b["paper_win_count"] += 1 + b["paper_realized_pnl_usdt"] += float(item.get("paper_realized_pnl_usdt") or 0) + + factor_map = {} + env_map = {} + version_map = {} + evidence_map = {} + for item in items: + labels = safe_list_json(item.get("signal_labels_json")) or safe_list_json(item.get("signals")) + codes = safe_list_json(item.get("signal_codes_json")) + for factor in labels: + add_bucket(factor_map, str(factor).strip(), item) + for code in codes: + text = str(code or "").strip() + if text.startswith(("sentiment_", "listing_", "ecosystem_")): + add_bucket(evidence_map, "舆情:" + text, item) + elif text.startswith(("dex_", "liquidity_", "exchange_", "whale_", "smart_money", "holder_")): + add_bucket(evidence_map, "链上:" + text, item) + mc = safe_dict_json(item.get("market_context_json")) + for key in ("btc_trend", "market_regime", "altcoin_regime", "sentiment"): + if mc.get(key): + add_bucket(env_map, f"{key}:{mc.get(key)}", item) + for bucket in env_buckets_from_market_context(mc): + add_bucket(env_map, bucket, item) + if item.get("strategy_version"): + add_bucket(version_map, str(item.get("strategy_version")).strip(), item) + + return { + "overview": overview, + "metric_definition": { + "opportunity_count": "进入 opportunity/recommendation 表的机会样本数,不代表交易。", + "actionable_count": "确认层输出 buy_now 或 wait_pullback 的样本数。", + "paper_trade_count": "已经被模拟交易账本执行的样本数。", + "paper_realized_pnl_usdt": "仅来自 paper_trades 的已平仓模拟收益。", + }, + "factor_attribution": serialize_buckets("factor", factor_map)[:30], + "market_environment": serialize_buckets("environment", env_map)[:20], + "evidence_attribution": serialize_buckets("evidence", evidence_map)[:20], + "version_performance": serialize_buckets("strategy_version", version_map, sort_by_version=True)[:20], + } + + +def env_buckets_from_market_context(mc): + """Convert market_context_json numeric fields into attribution buckets.""" + buckets = [] + try: + change_24h = float(mc.get("change_24h", 0) or 0) + turn_1h = float(mc.get("turnover_acceleration_1h", 0) or 0) + turn_4h = float(mc.get("turnover_acceleration_4h", 0) or 0) + volume_24h = float(mc.get("volume_24h") or mc.get("quote_volume_24h") or 0) + funding = float(mc.get("funding_rate", 0) or 0) + except Exception: + change_24h = turn_1h = turn_4h = volume_24h = funding = 0 + + if change_24h >= 8: + buckets.append("24h涨幅:强势拉升≥8%") + elif change_24h >= 3: + buckets.append("24h涨幅:温和上涨3-8%") + elif change_24h <= -3: + buckets.append("24h涨幅:回撤≤-3%") + else: + buckets.append("24h涨幅:震荡-3~3%") + + if turn_1h >= 3: + buckets.append("1h成交加速:爆量≥3x") + elif turn_1h >= 1.5: + buckets.append("1h成交加速:放量1.5-3x") + elif turn_1h > 0: + buckets.append("1h成交加速:平量<1.5x") + + if turn_4h >= 3: + buckets.append("4h成交加速:爆量≥3x") + elif turn_4h >= 1.5: + buckets.append("4h成交加速:放量1.5-3x") + elif turn_4h > 0: + buckets.append("4h成交加速:平量<1.5x") + + if volume_24h >= 100_000_000: + buckets.append("24h成交额:高流动性≥1亿") + elif volume_24h >= 10_000_000: + buckets.append("24h成交额:中等流动性1千万-1亿") + elif volume_24h > 0: + buckets.append("24h成交额:低流动性<1千万") + + if funding >= 0.0005: + buckets.append("资金费率:多头拥挤") + elif funding <= -0.0005: + buckets.append("资金费率:空头拥挤") + return buckets + + +def serialize_buckets(name_key, bucket_map, sort_by_version=False): + rows = [] + for key, bucket in bucket_map.items(): + rows.append({ + name_key: key, + "opportunity_count": bucket["opportunity_count"], + "actionable_count": bucket["actionable_count"], + "buy_now_count": bucket["buy_now_count"], + "paper_trade_count": bucket["paper_trade_count"], + "closed_paper_trade_count": bucket["closed_paper_trade_count"], + "paper_win_count": bucket["paper_win_count"], + "actionable_conversion_pct": round(bucket["actionable_count"] / bucket["opportunity_count"] * 100, 1) if bucket["opportunity_count"] else 0, + "paper_conversion_pct": round(bucket["paper_trade_count"] / bucket["buy_now_count"] * 100, 1) if bucket["buy_now_count"] else 0, + "paper_win_rate_pct": round(bucket["paper_win_count"] / bucket["closed_paper_trade_count"] * 100, 1) if bucket["closed_paper_trade_count"] else 0, + "paper_realized_pnl_usdt": round(bucket["paper_realized_pnl_usdt"], 4), + }) + if sort_by_version: + rows.sort(key=lambda x: (version_sort_key(x[name_key]), x["opportunity_count"], x["actionable_conversion_pct"]), reverse=True) + else: + rows.sort(key=lambda x: (-x["opportunity_count"], -x["actionable_conversion_pct"], x[name_key])) + return rows + + +def version_sort_key(version: str): + text = str(version or '').strip() + if text.startswith('v') or text.startswith('V'): + text = text[1:] + parts = [] + for chunk in text.replace('-', '.').split('.'): + if chunk.isdigit(): + parts.append(int(chunk)) + else: + match = re.match(r'^(\d+)', chunk) + if match: + parts.append(int(match.group(1))) + else: + parts.append(chunk) + return tuple(parts) diff --git a/app/db/strategy_rule_queries.py b/app/db/strategy_rule_queries.py new file mode 100644 index 0000000..3309236 --- /dev/null +++ b/app/db/strategy_rule_queries.py @@ -0,0 +1,636 @@ +"""Strategy rule candidates and failure-pattern queries.""" + +import json +from datetime import datetime + +from app.config.config_loader import get_meta +from app.db.schema import get_conn + + +def loads_json_field(value, fallback): + try: + return json.loads(value) if isinstance(value, str) else (value if value is not None else fallback) + except Exception: + return fallback + + +def upsert_strategy_rule_candidate(source, rule_type, signal_name, rule_description, + support_count=0, success_count=0, fail_count=0, + avg_pnl=0, max_gain=0, max_drawdown=0, + confidence_score=0, sample_size=0, status="candidate", + release_version="", notes="", source_ref=""): + """Insert or update a research-only strategy rule candidate.""" + conn = get_conn() + now = datetime.now().isoformat() + existing = conn.execute(""" + SELECT id FROM strategy_rule_candidate + WHERE source=%s AND rule_type=%s AND signal_name=%s AND rule_description=%s + ORDER BY id DESC LIMIT 1 + """, (source or "", rule_type or "", signal_name or "", rule_description or "")).fetchone() + if existing: + conn.execute(""" + UPDATE strategy_rule_candidate + SET support_count=%s, success_count=%s, fail_count=%s, avg_pnl=%s, max_gain=%s, + max_drawdown=%s, confidence_score=%s, sample_size=%s, status=%s, + release_version=%s, notes=%s, source_ref=COALESCE(NULLIF(%s, ''), source_ref), created_at=%s + WHERE id=%s + """, (support_count, success_count, fail_count, avg_pnl, max_gain, max_drawdown, + confidence_score, sample_size, status, release_version or "", notes or "", source_ref or "", now, existing["id"])) + candidate_id = existing["id"] + else: + cur = conn.execute(""" + INSERT INTO strategy_rule_candidate ( + created_at, source, rule_type, signal_name, rule_description, + support_count, success_count, fail_count, avg_pnl, max_gain, max_drawdown, + confidence_score, sample_size, status, release_version, notes, source_ref + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, (now, source or "", rule_type or "", signal_name or "", rule_description or "", + support_count, success_count, fail_count, avg_pnl, max_gain, max_drawdown, + confidence_score, sample_size, status, release_version or "", notes or "", source_ref or "")) + candidate_id = cur.fetchone()["id"] + conn.commit() + conn.close() + return candidate_id + + +def record_strategy_failure_pattern(symbol, version="", failure_type="", failure_reason="", + signal_combo=None, market_context=None, + entry_quality_issue="", pnl_pct=0, max_drawdown_pct=0, lesson=""): + """Record a failure-pattern sample for later attribution.""" + conn = get_conn() + conn.execute(""" + INSERT INTO strategy_failure_pattern ( + created_at, symbol, version, failure_type, failure_reason, signal_combo, + market_context_json, entry_quality_issue, pnl_pct, max_drawdown_pct, lesson + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, ( + datetime.now().isoformat(), symbol or "", version or "", failure_type or "", + failure_reason or "", json.dumps(signal_combo or [], ensure_ascii=False, default=str), + json.dumps(market_context or {}, ensure_ascii=False, default=str), + entry_quality_issue or "", pnl_pct or 0, max_drawdown_pct or 0, lesson or "", + )) + conn.commit() + conn.close() + + +def get_strategy_rule_candidates(limit=50, status=None): + """Read strategy rule candidates.""" + conn = get_conn() + params = [] + where = "" + if status: + where = "WHERE status=%s" + params.append(status) + rows = conn.execute(f""" + SELECT * FROM strategy_rule_candidate + {where} + ORDER BY confidence_score DESC, sample_size DESC, created_at DESC + LIMIT %s + """, (*params, limit)).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def update_strategy_rule_candidate_status(candidate_id, status, release_version="", notes_append=""): + """Update candidate lifecycle status.""" + conn = get_conn() + row = conn.execute("SELECT notes FROM strategy_rule_candidate WHERE id=%s", (candidate_id,)).fetchone() + if not row: + conn.close() + return False + notes = (row["notes"] or "").strip() + if notes_append: + notes = (notes + "\n" if notes else "") + f"[{datetime.now().isoformat()}] {notes_append}" + conn.execute(""" + UPDATE strategy_rule_candidate + SET status=%s, release_version=COALESCE(NULLIF(%s, ''), release_version), notes=%s, created_at=%s + WHERE id=%s + """, (status or "candidate", release_version or "", notes, datetime.now().isoformat(), candidate_id)) + conn.commit() + conn.close() + return True + + +def get_strategy_failure_patterns(limit=50): + """Read failure-pattern rows.""" + conn = get_conn() + rows = conn.execute(""" + SELECT * FROM strategy_failure_pattern + ORDER BY created_at DESC + LIMIT %s + """, (limit,)).fetchall() + conn.close() + items = [] + for row in rows: + item = dict(row) + item["signal_combo"] = loads_json_field(item.get("signal_combo"), []) + item["market_context"] = loads_json_field(item.get("market_context_json"), {}) + items.append(item) + return items + + +def candidate_signal_key(signal_text): + """Lightweight signal normalization for candidate attribution.""" + text = str(signal_text or "") + key_map = { + "量价齐飞": "vp_fly", + "N倍放量": "vol_Nx", + "放量": "1h_vol", + "供需区突破": "zone_break", + "供给区突破": "zone_break", + "站稳突破": "zone_break", + "起爆点": "ignition", + "静K→动K": "ignition", + "静K蓄力": "sk_accum", + "连续3K": "cont3k", + "连续K": "cont_k", + "Q≥7": "q7_break", + "动K": "dyn_k", + "过期": "stale_signal", + "历史": "stale_signal", + "追高": "chase_high", + "假突破": "false_breakout", + "量价背离": "vp_divergence", + } + for marker, key in key_map.items(): + if marker in text: + return key + return text[:12] + + +def get_factor_recency_fixed_at(): + """Factor-recency fix time. Older recommendations are dirty-history references.""" + try: + meta = get_meta() or {} + except Exception: + meta = {} + return (meta.get("factor_recency_fixed_at") or meta.get("clean_review_started_at") or "").strip() + + +def is_dirty_history_candidate(candidate): + source = str(candidate.get("source") or "") + notes = str(candidate.get("notes") or "") + source_ref = str(candidate.get("source_ref") or "") + return source in ("history_review_auto", "dirty_history_reference") or "dirty_history" in source_ref or "污染历史" in notes + + +def candidate_status_for_metrics(rule_type, sample_size, confidence, avg_pnl, current_status="candidate", + min_gray_samples=10, min_gray_confidence=65): + """Derive candidate lifecycle status from clean sample metrics.""" + if current_status == "active": + return "active" + if sample_size >= min_gray_samples and confidence >= min_gray_confidence and (avg_pnl > 0 or rule_type == "penalty"): + return "gray" + if sample_size >= 8 and ((rule_type != "penalty" and confidence < 35) or avg_pnl <= -3): + return "rejected" + if current_status in ("gray", "rejected"): + return current_status + return "candidate" + + +def classify_failure_type_from_text(review): + """Local classifier for historical failure-pattern backfill.""" + signals = review.get("triggered_signals") or [] + miss = review.get("miss_signals") or [] + lesson = review.get("lesson") or "" + text = " ".join([str(x) for x in signals + miss]) + " " + str(lesson or "") + pnl = float(review.get("pnl_48h") or 0) + outcome = review.get("outcome") or "" + if any(k in text for k in ["过期", "历史", "旧放量", "age_bars", "已过期", "小时前", "旧起爆"]): + return "过期因子误判", "历史放量/起爆/突破不能当作当前触发信号,必须做时效闸门" + if any(k in text for k in ["假突破", "突破失败", "未站稳", "冲高回落"]): + return "假突破", "突破后没有站稳或快速回落,需要增加站稳/承接确认" + if any(k in text for k in ["量价背离", "缩量上涨", "放量下跌", "无量拉升"]): + return "量价背离", "价格动作与成交量不匹配,量能确认不足" + if any(k in text for k in ["高位", "追高", "涨幅过大", "乖离"]): + return "追高风险", "入场位置偏高,盈亏比和回撤风险恶化" + if any(k in text for k in ["承接不足", "无承接", "上影线", "砸盘"]): + return "高位无承接", "高位出现抛压但缺少买盘承接" + if any(k in text for k in ["板块退潮", "热点退潮", "龙头走弱", "板块分歧"]): + return "板块退潮", "板块热度回落,个币信号容易失效" + if any(k in text for k in ["BTC", "大盘", "反向共振", "系统性"]): + return "BTC/大盘反向共振", "大盘方向与个币信号冲突,需要宏观/主流币过滤" + if any(k in text for k in ["止损", "盈亏比", "RR", "止盈"]): + return "止损/盈亏比不合理", "止损或止盈结构不合理,导致信号收益风险不匹配" + if "滞后" in text or "MACD" in text or "RSI" in text: + return "滞后信号追高", "滞后指标占比高,容易形成事后确认/追高失败" + if "缺乏前瞻" in text or "前瞻" not in text: + return "前瞻信号不足", "缺少量价/PA等前瞻性确认" + if "横盘" in text or outcome == "横盘": + return "信号强度不足", "触发后未形成有效爆发,确认条件偏弱" + if "回撤" in text or pnl < -3: + return "入场点太晚", "入场后回撤/亏损明显,买点可能滞后或确认过慢" + return "未分类失败", "需要继续积累样本做二级归因" + + +def backfill_strategy_failure_patterns(limit=2000, dry_run=False): + """Backfill failure-pattern rows from historical review_log, deduped by rec_id.""" + conn = get_conn() + rows = conn.execute(""" + SELECT rl.*, r.strategy_version, r.max_drawdown_pct + FROM review_log rl + LEFT JOIN recommendation r ON r.id = rl.rec_id + WHERE rl.outcome IN ('失败','横盘') + ORDER BY rl.review_time DESC + LIMIT %s + """, (limit,)).fetchall() + existing = set() + for row in conn.execute("SELECT market_context_json FROM strategy_failure_pattern").fetchall(): + ctx = loads_json_field(row["market_context_json"], {}) + if ctx.get("rec_id") is not None: + existing.add(str(ctx.get("rec_id"))) + inserted = 0 + skipped = 0 + type_counts = {} + examples = [] + for row in rows: + item = dict(row) + rec_id = item.get("rec_id") + if str(rec_id) in existing: + skipped += 1 + continue + triggered = loads_json_field(item.get("triggered_signals"), []) or [] + miss = loads_json_field(item.get("miss_signals"), []) or [] + item["triggered_signals"] = triggered + item["miss_signals"] = miss + failure_type, reason = classify_failure_type_from_text(item) + type_counts[failure_type] = type_counts.get(failure_type, 0) + 1 + if len(examples) < 10: + examples.append({"rec_id": rec_id, "symbol": item.get("symbol"), "failure_type": failure_type, "reason": reason}) + if not dry_run: + conn.execute(""" + INSERT INTO strategy_failure_pattern ( + created_at, symbol, version, failure_type, failure_reason, signal_combo, + market_context_json, entry_quality_issue, pnl_pct, max_drawdown_pct, lesson + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, ( + datetime.now().isoformat(), item.get("symbol") or "", item.get("strategy_version") or "", + failure_type, reason, json.dumps(triggered, ensure_ascii=False, default=str), + json.dumps({"source": "history_backfill", "rec_id": rec_id, "outcome": item.get("outcome"), "review_time": item.get("review_time")}, ensure_ascii=False, default=str), + reason, float(item.get("pnl_48h") or 0), float(item.get("max_drawdown_pct") or 0), item.get("lesson") or "", + )) + existing.add(str(rec_id)) + inserted += 1 + if not dry_run: + conn.commit() + conn.close() + return {"dry_run": dry_run, "scanned": len(rows), "inserted": inserted, "skipped_existing": skipped, "type_counts": type_counts, "examples": examples} + + +def generate_candidates_from_review_history(min_samples=20, min_bonus_confidence=55, max_penalty_confidence=40, dry_run=False): + """Generate strategy rule candidates from historical review_log.""" + conn = get_conn() + rows = conn.execute(""" + SELECT rl.*, r.max_drawdown_pct + FROM review_log rl + LEFT JOIN recommendation r ON r.id = rl.rec_id + ORDER BY rl.review_time DESC + """).fetchall() + buckets = {} + for row in rows: + item = dict(row) + triggered = loads_json_field(item.get("triggered_signals"), []) or [] + hit = loads_json_field(item.get("hit_signals"), []) or [] + miss = loads_json_field(item.get("miss_signals"), []) or [] + keys = {candidate_signal_key(x) for x in list(triggered) + list(hit) + list(miss) if str(x).strip()} + if not keys: + continue + for key in keys: + bucket = buckets.setdefault(key, {"sample_size": 0, "success_count": 0, "fail_count": 0, "pnl_values": [], "dd_values": []}) + bucket["sample_size"] += 1 + if item.get("outcome") == "爆发": + bucket["success_count"] += 1 + elif item.get("outcome") in ("失败", "横盘"): + bucket["fail_count"] += 1 + bucket["pnl_values"].append(float(item.get("pnl_48h") or 0)) + bucket["dd_values"].append(float(item.get("max_drawdown_pct") or 0)) + generated = [] + for key, bucket in buckets.items(): + sample = bucket["sample_size"] + if sample < min_samples: + continue + resolved = bucket["success_count"] + bucket["fail_count"] + confidence = round(bucket["success_count"] / resolved * 100, 1) if resolved else 0 + avg_pnl = round(sum(bucket["pnl_values"]) / len(bucket["pnl_values"]), 2) if bucket["pnl_values"] else 0 + max_gain = round(max(bucket["pnl_values"]), 2) if bucket["pnl_values"] else 0 + max_drawdown = round(min(bucket["dd_values"]), 2) if bucket["dd_values"] else 0 + rule_type = "bonus" if confidence >= min_bonus_confidence and avg_pnl > 0 else "penalty" if confidence <= max_penalty_confidence else "observe" + if rule_type == "observe": + continue + status = candidate_status_for_metrics(rule_type, sample, confidence, avg_pnl, "candidate") + if rule_type == "bonus": + desc = f"历史样本候选加分因子:{key},样本{sample},成功{bucket['success_count']},失败/横盘{bucket['fail_count']},置信{confidence}%,均值{avg_pnl}%" + else: + desc = f"历史样本候选惩罚因子:{key},样本{sample},成功{bucket['success_count']},失败/横盘{bucket['fail_count']},置信{confidence}%,均值{avg_pnl}%" + candidate = { + "source": "dirty_history_reference", + "rule_type": rule_type, + "signal_name": key, + "rule_description": desc, + "support_count": sample, + "success_count": bucket["success_count"], + "fail_count": bucket["fail_count"], + "avg_pnl": avg_pnl, + "max_gain": max_gain, + "max_drawdown": max_drawdown, + "confidence_score": confidence, + "sample_size": sample, + "status": status, + "source_ref": f"dirty_history:{key}", + } + generated.append(candidate) + if not dry_run: + upsert_strategy_rule_candidate( + source=candidate["source"], rule_type=rule_type, signal_name=key, + rule_description=desc, support_count=sample, success_count=bucket["success_count"], + fail_count=bucket["fail_count"], avg_pnl=avg_pnl, max_gain=max_gain, + max_drawdown=max_drawdown, confidence_score=confidence, sample_size=sample, + status=status, notes="历史review_log自动生成:候选规则仍需灰度验证后才可发布", + source_ref=candidate["source_ref"], + ) + if not dry_run: + conn.commit() + conn.close() + generated.sort(key=lambda x: (-x["sample_size"], x["rule_type"], -x["confidence_score"])) + return {"dry_run": dry_run, "review_rows": len(rows), "generated_count": len(generated), "generated": generated[:80]} + + +def dry_run_strategy_candidate_performance(min_gray_samples=10, min_gray_confidence=65): + """Evaluate strategy candidates without writing DB state.""" + conn = get_conn() + candidates = [dict(r) for r in conn.execute("SELECT * FROM strategy_rule_candidate").fetchall()] + clean_started_at = get_factor_recency_fixed_at() + if clean_started_at: + review_rows = conn.execute(""" + SELECT rl.*, r.strategy_version, r.max_drawdown_pct, r.rec_time + FROM review_log rl + LEFT JOIN recommendation r ON r.id = rl.rec_id + WHERE r.rec_time >= %s + ORDER BY rl.review_time DESC + """, (clean_started_at,)).fetchall() + else: + review_rows = conn.execute(""" + SELECT rl.*, r.strategy_version, r.max_drawdown_pct, r.rec_time + FROM review_log rl + LEFT JOIN recommendation r ON r.id = rl.rec_id + ORDER BY rl.review_time DESC + """).fetchall() + failure_rows = [dict(r) for r in conn.execute("SELECT * FROM strategy_failure_pattern ORDER BY created_at DESC").fetchall()] + try: + current_version = str(get_meta().get("strategy_version") or "").strip() + except Exception: + current_version = "" + conn.close() + + review_items = _build_review_items(review_rows) + evaluated = [] + for candidate in candidates: + evaluated.append(_evaluate_candidate(candidate, review_items, failure_rows, min_gray_samples, min_gray_confidence)) + + gray_ready = [x for x in evaluated if x.get("dry_run_status") == "gray"] + active_ready = [x for x in evaluated if x.get("dry_run_status") == "active"] + rejected = [x for x in evaluated if x.get("dry_run_status") == "rejected"] + return { + "dry_run": True, + "current_version": current_version, + "review_sample_count": len(review_items), + "clean_started_at": clean_started_at, + "sample_window": "clean_after_factor_recency_fix" if clean_started_at else "all_history", + "dirty_history_candidate_count": sum(1 for c in candidates if is_dirty_history_candidate(c)), + "candidate_count": len(candidates), + "gray_ready_count": len(gray_ready), + "active_count": len(active_ready), + "rejected_count": len(rejected), + "would_bump_version": False, + "release_reason": "dry-run只评估候选规则表现,不执行 learned_rules 写入或版本升级", + "gate_policy": { + "gray": f"sample_size≥{min_gray_samples} 且 confidence≥{min_gray_confidence} 且 avg_pnl>0(penalty规则可不要求avg_pnl>0)", + "reject": "sample_size≥8 且 confidence<35 或 avg_pnl≤-3", + "release": "dry-run不发布;正式发布仍由复盘发布闸门统一控制", + }, + "evaluated_candidates": sorted(evaluated, key=lambda x: (x.get("dry_run_status") != "gray", -float(x.get("sample_size") or 0), -float(x.get("confidence_score") or 0)))[:80], + } + + +def refresh_strategy_candidate_performance(min_gray_samples=10, min_gray_confidence=65): + """Refresh candidate metrics and status from clean review/failure samples.""" + conn = get_conn() + candidates = conn.execute("SELECT * FROM strategy_rule_candidate").fetchall() + clean_started_at = get_factor_recency_fixed_at() + if clean_started_at: + review_rows = conn.execute(""" + SELECT rl.*, r.strategy_version, r.max_drawdown_pct, r.rec_time + FROM review_log rl + LEFT JOIN recommendation r ON r.id = rl.rec_id + WHERE r.rec_time >= %s + ORDER BY rl.review_time DESC + """, (clean_started_at,)).fetchall() + else: + review_rows = conn.execute(""" + SELECT rl.*, r.strategy_version, r.max_drawdown_pct, r.rec_time + FROM review_log rl + LEFT JOIN recommendation r ON r.id = rl.rec_id + ORDER BY rl.review_time DESC + """).fetchall() + failure_rows = [dict(row) for row in conn.execute("SELECT * FROM strategy_failure_pattern ORDER BY created_at DESC").fetchall()] + review_items = _build_review_items(review_rows) + + updated = [] + for row in candidates: + candidate = dict(row) + candidate_id = candidate["id"] + status = candidate.get("status") or "candidate" + if status == "active": + continue + evaluated = _evaluate_candidate(candidate, review_items, failure_rows, min_gray_samples, min_gray_confidence) + if evaluated.get("dry_run_status") == "dirty_history": + updated.append({ + "id": candidate_id, + "signal_name": candidate.get("signal_name") or "", + "source": candidate.get("source") or "", + "rule_type": candidate.get("rule_type") or "", + "sample_size": 0, + "success_count": 0, + "fail_count": 0, + "confidence_score": candidate.get("confidence_score") or 0, + "avg_pnl": candidate.get("avg_pnl") or 0, + "status": "dirty_history", + "description": candidate.get("rule_description") or "", + "gate_reason": "污染历史参考,不参与干净样本刷新", + }) + continue + + note = (candidate.get("notes") or "").strip() + audit_note = ( + f"[{datetime.now().isoformat()}] 自动评估: 样本{evaluated['sample_size']}, " + f"成功{evaluated['success_count']}, 失败{evaluated['fail_count']}, " + f"置信{evaluated['confidence_score']}%, avg_pnl={evaluated['avg_pnl']}%, " + f"status={evaluated['dry_run_status']}" + ) + if audit_note not in note: + note = (note + "\n" if note else "") + audit_note + + conn.execute(""" + UPDATE strategy_rule_candidate + SET support_count=%s, success_count=%s, fail_count=%s, avg_pnl=%s, max_gain=%s, + max_drawdown=%s, confidence_score=%s, sample_size=%s, status=%s, notes=%s, created_at=%s + WHERE id=%s + """, ( + evaluated["sample_size"], evaluated["success_count"], evaluated["fail_count"], + evaluated["avg_pnl"], evaluated["max_gain"], evaluated["max_drawdown"], + evaluated["confidence_score"], evaluated["sample_size"], evaluated["dry_run_status"], + note, datetime.now().isoformat(), candidate_id, + )) + updated.append({ + "id": candidate_id, + "signal_name": candidate.get("signal_name") or "", + "source": candidate.get("source") or "", + "rule_type": candidate.get("rule_type") or "", + "sample_size": evaluated["sample_size"], + "success_count": evaluated["success_count"], + "fail_count": evaluated["fail_count"], + "confidence_score": evaluated["confidence_score"], + "avg_pnl": evaluated["avg_pnl"], + "status": evaluated["dry_run_status"], + "description": candidate.get("rule_description") or "", + }) + conn.commit() + conn.close() + return updated + + +def _build_review_items(review_rows): + review_items = [] + for row in review_rows: + item = dict(row) + triggered = loads_json_field(item.get("triggered_signals"), []) or [] + hit = loads_json_field(item.get("hit_signals"), []) or [] + miss = loads_json_field(item.get("miss_signals"), []) or [] + all_signals = list(triggered) + list(hit) + list(miss) + item["signal_keys"] = {candidate_signal_key(signal) for signal in all_signals} + item["all_signal_text"] = " ".join(str(signal) for signal in all_signals) + review_items.append(item) + return review_items + + +def _evaluate_candidate(candidate, review_items, failure_rows, min_gray_samples, min_gray_confidence): + status = candidate.get("status") or "candidate" + source = candidate.get("source") or "" + rule_type = candidate.get("rule_type") or "" + signal_name = candidate.get("signal_name") or "" + source_ref = candidate.get("source_ref") or "" + if is_dirty_history_candidate(candidate): + return { + **candidate, + "sample_size": 0, + "support_count": 0, + "success_count": 0, + "fail_count": 0, + "dry_run_status": "dirty_history", + "release_gate_passed": False, + "gate_reason": "因子时效修复前的污染历史参考:不参与干净样本统计,不允许发布", + } + if status == "active": + return {**candidate, "dry_run_status": "active", "release_gate_passed": True, "gate_reason": "已正式生效,不参与dry-run降级"} + + if source.startswith("dual_attribution_failure") or source_ref.startswith("failure:") or rule_type == "penalty": + failure_type = signal_name or source_ref.replace("failure:", "") + matched = [row for row in failure_rows if (row.get("failure_type") or "") == failure_type or failure_type in (row.get("failure_reason") or "")] + sample_size = len(matched) + success_count = 0 + fail_count = sample_size + pnl_values = [float(row.get("pnl_pct") or 0) for row in matched] + dd_values = [float(row.get("max_drawdown_pct") or 0) for row in matched] + confidence = round(min(95, 45 + fail_count * 8), 1) if sample_size else float(candidate.get("confidence_score") or 0) + else: + key = signal_name or source_ref.replace("review:", "") + matched = [item for item in review_items if key and (key in item["signal_keys"] or key in item["all_signal_text"] or signal_name in item["all_signal_text"])] + sample_size = len(matched) + success_count = sum(1 for row in matched if row.get("outcome") == "爆发") + fail_count = sum(1 for row in matched if row.get("outcome") in ("失败", "横盘")) + pnl_values = [float(row.get("pnl_48h") or 0) for row in matched] + dd_values = [float(row.get("max_drawdown_pct") or 0) for row in matched] + resolved = success_count + fail_count + confidence = round(success_count / resolved * 100, 1) if resolved else float(candidate.get("confidence_score") or 0) + + avg_pnl = round(sum(pnl_values) / len(pnl_values), 2) if pnl_values else float(candidate.get("avg_pnl") or 0) + max_gain = round(max(pnl_values), 2) if pnl_values else float(candidate.get("max_gain") or 0) + max_drawdown = round(min(dd_values), 2) if dd_values else float(candidate.get("max_drawdown") or 0) + dry_status = candidate_status_for_metrics(rule_type, sample_size, confidence, avg_pnl, status, min_gray_samples, min_gray_confidence) + gate_passed = dry_status in ("gray", "active") + if dry_status == "gray": + gate_reason = f"样本{sample_size}≥{min_gray_samples},置信{confidence}%≥{min_gray_confidence},avg_pnl={avg_pnl}%:可进入灰度,仍不升版" + elif dry_status == "rejected": + gate_reason = f"样本{sample_size}已足够但置信/收益不达标:淘汰,不允许发布" + else: + gate_reason = f"样本{sample_size}或置信{confidence}%不足:只研究不发布" + return { + **candidate, + "sample_size": sample_size, + "support_count": sample_size, + "success_count": success_count, + "fail_count": fail_count, + "avg_pnl": avg_pnl, + "max_gain": max_gain, + "max_drawdown": max_drawdown, + "confidence_score": confidence, + "dry_run_status": dry_status, + "release_gate_passed": gate_passed, + "gate_reason": gate_reason, + } + + +def get_strategy_iteration_dashboard(days=30): + """Dashboard aggregate: overview + candidates + failure patterns + timeline.""" + from app.db.review_queries import get_strategy_iteration_logs, get_strategy_iteration_summary + + summary = get_strategy_iteration_summary(days=days) + candidates = get_strategy_rule_candidates(limit=80) + failures = get_strategy_failure_patterns(limit=80) + logs = get_strategy_iteration_logs(limit=40) + status_counts = {} + source_counts = {} + for candidate in candidates: + status = candidate.get("status") or "candidate" + source = candidate.get("source") or "unknown" + status_counts[status] = status_counts.get(status, 0) + 1 + source_counts[source] = source_counts.get(source, 0) + 1 + failure_counts = {} + for failure in failures: + failure_type = failure.get("failure_type") or "未分类" + failure_counts[failure_type] = failure_counts.get(failure_type, 0) + 1 + release_counts = {} + for log in logs: + decision = log.get("release_decision") or "unknown" + release_counts[decision] = release_counts.get(decision, 0) + 1 + dry_run = dry_run_strategy_candidate_performance() + latest_log = logs[0] if logs else {} + return { + "summary": summary, + "overview": { + "total_logs": len(logs), + "candidate_count": len(candidates), + "candidate_status_counts": status_counts, + "candidate_source_counts": source_counts, + "failure_type_counts": [{"type": k, "count": v} for k, v in sorted(failure_counts.items(), key=lambda x: (-x[1], x[0]))], + "release_decision_counts": release_counts, + "latest_release_decision": latest_log.get("release_decision") or "hold", + "latest_release_reason": latest_log.get("release_reason") or latest_log.get("version_change_summary") or "暂无发布决策说明", + "dry_run_summary": { + "review_sample_count": dry_run.get("review_sample_count", 0), + "clean_started_at": dry_run.get("clean_started_at", ""), + "sample_window": dry_run.get("sample_window", "all_history"), + "dirty_history_candidate_count": dry_run.get("dirty_history_candidate_count", 0), + "candidate_count": dry_run.get("candidate_count", 0), + "gray_ready_count": dry_run.get("gray_ready_count", 0), + "rejected_count": dry_run.get("rejected_count", 0), + "would_bump_version": dry_run.get("would_bump_version", False), + "release_reason": dry_run.get("release_reason", ""), + }, + }, + "dry_run": dry_run, + "candidates": candidates, + "failures": failures, + "logs": logs, + } diff --git a/app/db/tracking_queries.py b/app/db/tracking_queries.py new file mode 100644 index 0000000..5ede7fc --- /dev/null +++ b/app/db/tracking_queries.py @@ -0,0 +1,160 @@ +"""Price cache and recommendation tracking writes.""" + +from datetime import datetime + +from app.core.opportunity_lifecycle import is_executed_lifecycle +from app.db.recommendation_state import derive_minimal_state_fields +from app.db.schema import get_conn + + +def update_recommendation_tracking(rec_id, current_price): + """Update recommendation price/PnL and terminal state for executed positions.""" + conn = get_conn() + row = conn.execute(""" + SELECT entry_price, max_price, min_price, symbol, status, action_status, + execution_status, display_bucket, entry_triggered + FROM recommendation WHERE id=%s + """, (rec_id,)).fetchone() + if not row: + conn.close() + return + + entry_price = row["entry_price"] + old_max = row["max_price"] or entry_price + old_min = row["min_price"] or entry_price + + new_max = max(old_max, current_price) + new_min = min(old_min, current_price) + pnl_pct = round((current_price / entry_price - 1) * 100, 2) + max_pnl_pct = round((new_max / entry_price - 1) * 100, 2) + max_drawdown_pct = round((new_min / entry_price - 1) * 100, 2) + + is_executed = ( + int(row["entry_triggered"] or 0) == 1 + or row["display_bucket"] == "position" + or row["execution_status"] in ("holding", "completed") + or is_executed_lifecycle(row["status"], row["action_status"], row["execution_status"]) + ) + status = "active" + tp1_reached = False + rec = conn.execute("SELECT stop_loss, tp1, tp2, status, hit_tp1_time FROM recommendation WHERE id=%s", (rec_id,)).fetchone() + if rec and rec["status"] == "active" and is_executed: + if rec["tp2"] and current_price >= rec["tp2"]: + status = "hit_tp2" + elif rec["tp1"] and current_price >= rec["tp1"]: + status = "hit_tp1" + tp1_reached = True + elif rec["tp1"] == 0 and pnl_pct >= 15: + status = "hit_tp1" + tp1_reached = True + elif rec["stop_loss"] and current_price <= rec["stop_loss"]: + status = "stopped_out" + + now = datetime.now().isoformat() + if status != "active": + action_for_status = {"hit_tp1": "止盈1", "hit_tp2": "止盈2", "stopped_out": "止损"}.get(status, "持有") + execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason = derive_minimal_state_fields(status, action_for_status, {}) + conn.execute(""" + UPDATE recommendation SET current_price=%s, max_price=%s, min_price=%s, + pnl_pct=%s, max_pnl_pct=%s, max_drawdown_pct=%s, + status=%s, action_status=%s, execution_status=%s, display_bucket=%s, lifecycle_state=%s, entry_triggered=%s, state_reason=%s, last_track_time=%s, + hit_tp1_time=CASE WHEN %s='hit_tp1' THEN %s ELSE hit_tp1_time END, + hit_tp2_time=CASE WHEN %s='hit_tp2' THEN %s ELSE hit_tp2_time END, + stopped_out_time=CASE WHEN %s='stopped_out' THEN %s ELSE stopped_out_time END + WHERE id=%s + """, ( + current_price, new_max, new_min, pnl_pct, max_pnl_pct, max_drawdown_pct, + status, action_for_status, execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, now, + status, now, status, now, status, now, rec_id, + )) + else: + conn.execute(""" + UPDATE recommendation SET current_price=%s, max_price=%s, min_price=%s, + pnl_pct=%s, max_pnl_pct=%s, max_drawdown_pct=%s, + last_track_time=%s, + hit_tp1_time=CASE WHEN %s=1 THEN COALESCE(NULLIF(hit_tp1_time,''), %s) ELSE hit_tp1_time END + WHERE id=%s + """, ( + current_price, new_max, new_min, pnl_pct, max_pnl_pct, max_drawdown_pct, now, + 1 if tp1_reached else 0, now, rec_id, + )) + + symbol = row["symbol"] + update_latest_price_cache(symbol, current_price, updated_at=now, source="tracker", conn=conn) + + conn.execute(""" + INSERT INTO price_tracking (rec_id, symbol, track_time, price, pnl_pct) + VALUES (%s, %s, %s, %s, %s) + """, (rec_id, symbol, now, current_price, pnl_pct)) + + conn.commit() + conn.close() + return { + "status": status, + "tp1_reached": tp1_reached, + "pnl_pct": pnl_pct, + "max_pnl_pct": max_pnl_pct, + "max_drawdown_pct": max_drawdown_pct, + } + + +def update_latest_price_cache(symbol, price, updated_at=None, source="tracker", conn=None): + """Upsert latest ticker cache. Dashboards read this instead of price_tracking.""" + symbol = str(symbol or "").strip().upper() + try: + price = float(price or 0) + except Exception: + price = 0 + if not symbol or price <= 0: + return False + updated_at = updated_at or datetime.now().isoformat() + owns_conn = conn is None + if owns_conn: + conn = get_conn() + conn.execute(""" + INSERT INTO latest_price_cache (symbol, price, updated_at, source) + VALUES (%s, %s, %s, %s) + ON CONFLICT(symbol) DO UPDATE SET + price=excluded.price, + updated_at=excluded.updated_at, + source=excluded.source + """, (symbol, price, updated_at, source)) + if owns_conn: + conn.commit() + conn.close() + return True + + +def get_latest_price_cache(symbols): + """Batch read latest price cache as {symbol: {price, updated_at, source}}.""" + normalized = [] + for sym in symbols or []: + sym = str(sym or "").strip().upper() + if sym and sym not in normalized: + normalized.append(sym) + if not normalized: + return {} + conn = get_conn() + placeholders = ",".join(["%s"] * len(normalized)) + rows = conn.execute( + f"SELECT symbol, price, updated_at, source FROM latest_price_cache WHERE symbol IN ({placeholders})", + tuple(normalized), + ).fetchall() + conn.close() + return {row["symbol"]: dict(row) for row in rows} + + +def latest_tracking_price(rec_id, fallback=0): + """Compatibility shim: current price comes from latest_price_cache/recommendation.""" + return fallback or 0 + + +def update_entry_timing(rec_id: int, entry_price: float, rec_time: str): + """Update triggered entry time/price when tracker promotes a setup to buy_now.""" + conn = get_conn() + conn.execute( + "UPDATE recommendation SET rec_time=%s, entry_price=%s, current_price=%s, pnl_pct=0 WHERE id=%s", + (rec_time, entry_price, entry_price, rec_id), + ) + conn.commit() + conn.close() diff --git a/app/services/altcoin_confirm.py b/app/services/altcoin_confirm.py index c4ac705..745c6f8 100644 --- a/app/services/altcoin_confirm.py +++ b/app/services/altcoin_confirm.py @@ -658,6 +658,13 @@ def confirm_burst(symbol, cand): # 提取cand数据(v1.7.0:用于辅助信号检测) cand_detail = json.loads(cand.get("detail_json", "{}")) leader_status = cand.get("leader_status", "") + cand_change_24h = 0.0 + try: + cand_change_24h = float(cand.get("change_24h") or cand_detail.get("change_24h") or 0) + except Exception: + cand_change_24h = 0.0 + cand_signal_text = " ".join(str(x) for x in (json.loads(cand.get("signals", "[]")) if isinstance(cand.get("signals"), str) and cand.get("signals", "").strip().startswith("[") else [cand.get("signals", "")])) + cand_is_top_gainer = bool(cand_detail.get("top_gainer_24h") or "24h强势榜" in cand_signal_text or cand_change_24h >= get_burst_threshold(symbol) * 1.5) h1_df = fetch_klines(symbol, "1h", limit=100) m15_df = fetch_klines(symbol, "15m", limit=100) @@ -1243,6 +1250,66 @@ def confirm_burst(symbol, cand): } +def _watch_candidate_plan(symbol, result, cand_detail): + """把强势但未形成交易买点的样本写成机会观察,不触发模拟交易。""" + market_context = result.get("market_context") or {} + signals = list(result.get("signals") or []) + price = float(result.get("price") or 0) + level_meta = classify_opportunity_level( + signals=signals, + entry_plan={ + "entry_action": "观察", + "entry_price": price, + "current_price": price, + "pa_15min_summary": (result.get("pa_15min") or {}).get("reason", ""), + }, + market_context=market_context, + derivatives_context=result.get("derivatives_context") or {}, + sector_context=result.get("sector_context") or cand_detail.get("sector_context", {}), + m30_aligned=bool(result.get("m30_aligned")), + ) + if level_meta.get("opportunity_level") not in ("momentum_watch", "structure_watch"): + level_meta = { + **level_meta, + "opportunity_level": "momentum_watch", + "label": "强势观察", + "max_action": "observe", + } + plan = { + "entry_action": "观察", + "entry_price": price, + "current_price": price, + "risk_reward_ok": False, + "rr1": 0, + "watch_reason": "强势异动已发现,但当前交易确认未通过", + "watch_points": [ + "等待1H重新放量或15m回踩后再确认", + "等待RR恢复到可交易区间", + "若跌破短线结构低点则失效", + ], + } + return attach_opportunity_level(plan, level_meta) + + +def _should_publish_watch_candidate(cand, result): + """强势榜/当前形态未过交易闸门时,仍进入用户机会观察池。""" + if result.get("confirmed"): + return False + score = float(result.get("score") or 0) + market_context = result.get("market_context") or {} + change_24h = 0.0 + try: + change_24h = float(market_context.get("change_24h") or cand.get("change_24h") or 0) + except Exception: + change_24h = 0.0 + signals = " ".join(str(x) for x in (result.get("signals") or [])) + trigger_context = result.get("trigger_context") or {} + has_current_trigger = bool(trigger_context.get("current_triggers")) or "15min即刻入场" in signals or "15min 强突破" in signals + is_top_gainer = "24h强势榜" in signals or change_24h >= get_burst_threshold(cand.get("symbol") or "") * 1.5 + severe_risk = any(k in signals for k in ("假突破", "日线持续走低", "高位无底部突破拒绝")) + return not severe_risk and is_top_gainer and (has_current_trigger or score >= confirm_min_score()) + + def _emit_output(output, compact: bool = False): if compact: print(json.dumps(output, ensure_ascii=False)) @@ -1366,7 +1433,31 @@ def main(compact: bool = False): }, ), ) - result["state_update"] = {"should_alert": False, "reason": "未确认爆发"} + if _should_publish_watch_candidate(cand, result): + watch_plan = _watch_candidate_plan(symbol, result, cand_detail) + rec_id = create_recommendation( + symbol=symbol, + rec_state="观察", + rec_score=max(result.get("score", 0), confirm_min_score()), + entry_price=result.get("price", 0), + stop_loss=0, + tp1=0, + tp2=0, + sector=cand_detail.get("sector", cand.get("sector", "")), + signals=result.get("signals", []), + is_meme=int(is_meme_coin(symbol)), + entry_plan=watch_plan, + direction=get_strategy_direction(), + market_context=result.get("market_context"), + derivatives_context=result.get("derivatives_context"), + sector_context=result.get("sector_context"), + ) + update_latest_price_cache(symbol, result["price"], updated_at=datetime.now().isoformat(), source="confirm_watch") + result["rec_id"] = rec_id + result["published_watch"] = True + result["state_update"] = {"should_alert": False, "reason": "强势异动进入机会观察池"} + else: + result["state_update"] = {"should_alert": False, "reason": "未确认爆发"} results.append({"symbol": symbol, **result}) diff --git a/app/services/altcoin_screener.py b/app/services/altcoin_screener.py index 608106c..c54893f 100644 --- a/app/services/altcoin_screener.py +++ b/app/services/altcoin_screener.py @@ -613,6 +613,33 @@ def _top_gainer_signal(symbol, change, volume): return f"24h强势榜异动({float(change or 0):.1f}%,成交额{float(volume or 0)/1_000_000:.1f}M)" +def _static_bypass_resonance(cand, *, static_cfg, sector_signal_count=0, top_trader_ratio=None, vp_data=None): + """Return resonance signals that make a static-K bypass worth confirming.""" + signals = [] + vp_data = vp_data or cand.get("vp_data") or {} + if vp_data.get("vp_fly_count", 0) > 0: + signals.append("current_vp_fly") + if vp_data.get("max_consecutive_3x", 0) >= int(static_cfg.get("min_consecutive_3x", 3)): + signals.append("consecutive_volume") + + static_acc = cand.get("static_accumulation") or {} + if float(static_acc.get("vol_ratio") or 0) >= float(static_cfg.get("strong_vol_ratio", 2.0)): + signals.append("strong_static_volume") + if int(sector_signal_count or 0) > 0: + signals.append("sector_rotation") + if top_trader_ratio and float(top_trader_ratio.get("long_pct") or 0) >= float(static_cfg.get("min_top_trader_long_pct", 65)): + signals.append("top_trader_long") + if cand.get("top_gainer_24h") and float(cand.get("change_24h") or 0) >= float(static_cfg.get("min_top_gainer_change_pct", 8.0)): + signals.append("top_gainer") + if (cand.get("higher_lows") or {}).get("found"): + signals.append("higher_lows") + if (cand.get("compression_surge") or {}).get("found"): + signals.append("compression_surge") + if cand.get("sentiment") or cand.get("sentiment_bonus"): + signals.append("sentiment") + return list(dict.fromkeys(signals)) + + def _attach_top_gainer_discovery(candidates, tickers, recently_screened): """为强势榜补发现入口;追高风险留给细筛/确认处理。""" added = 0 @@ -1222,12 +1249,25 @@ def layer2_fine_filter(candidates): base_state = state - # 静K蓄力旁路:即使原始状态是过期,有静K蓄力+量比达标→至少蓄力 + static_resonance = _static_bypass_resonance( + cand, + static_cfg=static_cfg, + sector_signal_count=sector_signal_count, + top_trader_ratio=ratio, + vp_data=vp_data, + ) + static_resonance_ok = ( + not static_cfg.get("require_resonance", False) + or len(static_resonance) >= int(static_cfg.get("min_resonance_signals", 1)) + ) + + # 静K蓄力旁路:即使原始状态是过期,有静K蓄力+量比达标+共振→至少蓄力 if ( state == "过期" and static_accumulation and static_accumulation["vol_ratio"] >= static_bypass_min_vol_ratio and score >= static_bypass_min_score + and static_resonance_ok ): state = "蓄力" force_accumulate_reason = "静K蓄力旁路" @@ -1244,6 +1284,7 @@ def layer2_fine_filter(candidates): and static_accumulation.get("static_count", 0) >= direct_acc_cfg.get("min_static_count", 10) and static_accumulation.get("vol_ratio", 0) >= direct_acc_cfg.get("min_vol_ratio", 1.25) and score >= direct_acc_cfg.get("min_score", 5) + and static_resonance_ok ): state = "加速" force_accumulate_reason = "强静K蓄力直升加速" @@ -1280,6 +1321,9 @@ def layer2_fine_filter(candidates): signals.append(f"24h强势榜异动({cand.get('change_24h', 0):.1f}%)") if cand.get("top_gainer_chase_risk"): signals.append("追高风险:首次进入强势榜,等待二次结构确认") + elif state == "过期" and score >= static_bypass_min_score: + state = "蓄力" + force_accumulate_reason = "强势榜异动旁路" quality = quality_filter_reasons(cand, int(score or 0), accumulate_threshold, signals) @@ -1370,6 +1414,7 @@ def layer2_fine_filter(candidates): "quality_reason_labels": quality["labels"], "base_state": base_state, "force_reason": force_accumulate_reason or "", + "static_bypass_resonance": static_resonance, "sector_signal_count": sector_signal_count, "signal_recency": _build_signal_recency(cand), "signal_codes": build_signal_codes(signals), @@ -1409,6 +1454,7 @@ def layer2_fine_filter(candidates): "score": score, "threshold": accumulate_threshold, "base_state": base_state, + "static_bypass_resonance": static_resonance if static_accumulation else [], "signal_recency": _build_signal_recency(cand), "signal_codes": build_signal_codes(reject_signals), }, diff --git a/app/services/price_tracker.py b/app/services/price_tracker.py index 0435255..8ad4625 100644 --- a/app/services/price_tracker.py +++ b/app/services/price_tracker.py @@ -273,7 +273,7 @@ def analyze_tracking_signals(symbol, rec, current_price): def track_prices(): """拉取所有active推荐币的实时价格,更新盈亏 + 动态跟踪信号""" - recs = get_active_recommendations(actionable_only=True) + recs = get_active_recommendations(actionable_only=False) if not recs: output = { "status": "no_active", @@ -286,34 +286,37 @@ def track_prices(): results = [] tracked_count = 0 + observed_count = 0 failed_symbols = [] for rec in recs: symbol = rec["symbol"] try: - if not rec.get("entry_triggered") and rec.get("display_bucket") != "position" and rec.get("execution_status") not in ("holding", "completed"): - results.append({ - "symbol": symbol, - "rec_id": rec["id"], - "entry_price": rec["entry_price"], - "current_price": None, - "pnl_pct": None, - "status": "skipped_watch_only", - "action_status": rec.get("action_status"), - "sell_signals": [], - "buy_signals": [], - "exhaustion_severity": "low", - }) - print(f" {symbol}: 观察池样本跳过跟踪与止盈判断") - continue ticker = exchange.fetch_ticker(symbol) current_price = ticker["last"] # 最新价格缓存:看板读取小表 latest_price_cache,不再依赖 price_tracking 高频流水表 update_latest_price_cache(symbol, current_price, source="tracker") - # 基础盈亏跟踪 + # 基础盈亏/最大涨幅记录。未入场样本只做观察绩效,不触发 TP/SL。 track_result = update_recommendation_tracking(rec["id"], current_price) + if not rec.get("entry_triggered") and rec.get("display_bucket") != "position" and rec.get("execution_status") not in ("holding", "completed"): + results.append({ + "symbol": symbol, + "rec_id": rec["id"], + "entry_price": rec["entry_price"], + "current_price": current_price, + "pnl_pct": track_result.get("pnl_pct"), + "status": "observed_watch_only", + "action_status": rec.get("action_status"), + "sell_signals": [], + "buy_signals": [], + "exhaustion_severity": "low", + }) + observed_count += 1 + print(f" {symbol}: 观察池样本仅更新观察价格/PnL,不触发止盈止损") + continue + # PA增强:动态跟踪信号分析 tracking_signals = analyze_tracking_signals(symbol, rec, current_price) @@ -368,6 +371,7 @@ def track_prices(): output = { "status": "tracked", "tracked_count": tracked_count, + "observed_count": observed_count, "failed_count": len(failed_symbols), "failed_symbols": failed_symbols, "results": results, @@ -401,6 +405,7 @@ def main(): finished_at = datetime.now() summary = { "tracked_count": output.get("tracked_count", 0), + "observed_count": output.get("observed_count", 0), "failed_count": output.get("failed_count", 0), "active_count": output.get("stats", {}).get("active_count", 0), } diff --git a/rules.yaml b/rules.yaml index bde69f5..003a68f 100644 --- a/rules.yaml +++ b/rules.yaml @@ -92,6 +92,13 @@ screener: min_vol_ratio: 1.0 min_score: 2 min_volume_24h: 2000000 + require_resonance: true + min_resonance_signals: 2 + strong_vol_ratio: 2.0 + min_consecutive_3x: 3 + min_top_trader_long_pct: 65 + min_top_gainer_change_pct: 8.0 + note: 静K蓄力不能单独放宽细筛入口,必须叠加强放量/板块/大户/强势榜/结构/舆情等至少2类共振,避免确认层被低质量观察样本淹没 direct_accelerate: enabled: true min_static_count: 10 diff --git a/tests/test_actionable_active_recommendations.py b/tests/test_actionable_active_recommendations.py index 7867720..fde9250 100644 --- a/tests/test_actionable_active_recommendations.py +++ b/tests/test_actionable_active_recommendations.py @@ -114,6 +114,18 @@ def test_active_api_only_returns_actionable_recommendations(temp_db): rows = resp.json() assert {row['symbol'] for row in rows} == {'WAIT/USDT', 'BUY/USDT'} assert {row['execution_status'] for row in rows} == {'buy_now', 'wait_pullback'} + buy = next(row for row in rows if row['symbol'] == 'BUY/USDT') + wait = next(row for row in rows if row['symbol'] == 'WAIT/USDT') + assert buy['discovery_state'] == '加速' + assert buy['trade_stage'] == 'buy_now' + assert buy['is_executable_now'] is True + assert wait['trade_stage'] == 'wait_pullback' + assert wait['is_trade_candidate'] is True + + meta = altcoin_db.get_active_recommendations_deduped(actionable_only=False, with_meta=True) + assert meta['summary']['executable_now'] == 1 + assert meta['summary']['planned_entry'] == 1 + assert meta['summary']['watch_pool'] == 2 def test_stats_only_count_actionable_active_recommendations(temp_db): diff --git a/tests/test_opportunity_level.py b/tests/test_opportunity_level.py index c2c26c2..6219612 100644 --- a/tests/test_opportunity_level.py +++ b/tests/test_opportunity_level.py @@ -58,6 +58,19 @@ def test_theme_without_price_trigger_is_research_trend(): assert meta["max_action"] == "observe" +def test_top_gainer_with_current_trigger_becomes_momentum_watch(): + meta = classify_opportunity_level( + signals=["24h强势榜异动(14.0%,成交额18.0M)", "🟢 15min即刻入场信号", "日线 站稳突破位+36.0%"], + entry_plan={"entry_action": "即刻买入"}, + market_context={"change_24h": 14.0}, + ) + + assert meta["opportunity_level"] == "momentum_watch" + assert meta["label"] == "强势观察" + assert meta["max_action"] == "observe" + assert "24h强势榜异动" in meta["plan_basis"] + + def test_level_stop_and_tp_models_are_different(): stops = [90, 94, 96] intraday_stop, _ = select_level_stop_loss(level="intraday_breakout", price=100, entry_price=100, stop_candidates=stops) @@ -68,6 +81,37 @@ def test_level_stop_and_tp_models_are_different(): assert level_tp_parameters("intraday_breakout")["tp1_floor"] < level_tp_parameters("structure_watch")["tp1_floor"] +def test_quality_gate_keeps_momentum_watch_observe_not_buy_now(): + meta = classify_opportunity_level( + signals=["24h强势榜异动(14.0%,成交额18.0M)", "🟢 15min即刻入场信号", "日线 站稳突破位+36.0%"], + entry_plan={"entry_action": "即刻买入"}, + market_context={"change_24h": 14.0}, + ) + plan = attach_opportunity_level( + { + "entry_action": "即刻买入", + "entry_price": 1.0, + "stop_loss": 0.94, + "tp1": 1.1, + "risk_reward_ok": True, + "rr1": 1.6, + }, + meta, + ) + + action, gated_plan, reasons = apply_entry_quality_gate( + action_status="可即刻买入", + entry_plan=plan, + signals=["24h强势榜异动(14.0%,成交额18.0M)", "🟢 15min即刻入场信号", "日线 站稳突破位+36.0%"], + current_price=1.0, + market_context={"change_24h": 14.0}, + ) + + assert action == "观察" + assert gated_plan["opportunity_level"] == "momentum_watch" + assert any("强势观察" in reason for reason in reasons) + + def test_quality_gate_caps_structure_watch_without_current_trigger(): meta = classify_opportunity_level( signals=["日线 需求区反弹", "4H静K蓄力观察(4静K)"], diff --git a/tests/test_pipeline_runs_api.py b/tests/test_pipeline_runs_api.py index cce1f3b..4b34c6c 100644 --- a/tests/test_pipeline_runs_api.py +++ b/tests/test_pipeline_runs_api.py @@ -139,7 +139,7 @@ def _insert_review(db_path, rec_id, review_time, outcome="爆发"): conn.close() -def _insert_missed(db_path, detect_time): +def _insert_missed(db_path, detect_time, symbol="MISS/USDT", gain_pct=26.3): conn = sqlite3.connect(db_path) conn.execute( """ @@ -148,7 +148,7 @@ def _insert_missed(db_path, detect_time): gain_pct, reason_missed, features_detected, lesson ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, - ("MISS/USDT", detect_time, 2.4, 1.9, 26.3, "确认没过", '{"volume":"high"}', "提高确认层覆盖"), + (symbol, detect_time, 2.4, 1.9, gain_pct, "确认没过", '{"volume":"high"}', "提高确认层覆盖"), ) conn.commit() conn.close() @@ -192,6 +192,7 @@ def test_pipeline_runs_aggregates_funnel_and_performance(temp_db): rec_id = _insert_recommendation(temp_db, (base + timedelta(minutes=7)).isoformat(timespec="seconds")) _insert_review(temp_db, rec_id, (base + timedelta(minutes=8)).isoformat(timespec="seconds"), outcome="爆发") _insert_missed(temp_db, (base + timedelta(minutes=9)).isoformat(timespec="seconds")) + _insert_missed(temp_db, (base + timedelta(minutes=9, seconds=10)).isoformat(timespec="seconds")) data = get_pipeline_runs(limit=10, hours=24) assert data["kpi"]["run_count"] == 1 @@ -208,6 +209,7 @@ def test_pipeline_runs_aggregates_funnel_and_performance(temp_db): assert detail["stage_counts"]["fine"] == 1 assert detail["stage_counts"]["recommendation"] == 1 assert detail["recommendations"][0]["performance_status"] == "success" + assert len(detail["missed_explosions"]) == 1 assert detail["missed_explosions"][0]["symbol"] == "MISS/USDT" diff --git a/tests/test_price_tracker_watch_only_guard.py b/tests/test_price_tracker_watch_only_guard.py index 54ae9d9..22dbcf5 100644 --- a/tests/test_price_tracker_watch_only_guard.py +++ b/tests/test_price_tracker_watch_only_guard.py @@ -8,7 +8,7 @@ if PROJECT_DIR not in sys.path: from app.services import price_tracker -def test_watch_only_recommendation_is_skipped_before_take_profit_push(monkeypatch): +def test_watch_only_recommendation_only_updates_observation_pnl(monkeypatch): rec = { "id": 1, "symbol": "QNT/USDT", @@ -22,11 +22,15 @@ def test_watch_only_recommendation_is_skipped_before_take_profit_push(monkeypatc "execution_status": "observe", "action_status": "观察", } + calls = {"tracking": 0, "state_transition": 0} - monkeypatch.setattr(price_tracker, "get_active_recommendations", lambda actionable_only=True: [rec]) + monkeypatch.setattr(price_tracker, "get_active_recommendations", lambda actionable_only=False: [rec]) monkeypatch.setattr(price_tracker.exchange, "fetch_ticker", lambda symbol: {"last": 102.0}) monkeypatch.setattr(price_tracker, "update_latest_price_cache", lambda *args, **kwargs: None) - monkeypatch.setattr(price_tracker, "update_recommendation_tracking", lambda rec_id, current_price: {"status": "active"}) + def fake_update_tracking(rec_id, current_price): + calls["tracking"] += 1 + return {"status": "active", "pnl_pct": 2.0} + monkeypatch.setattr(price_tracker, "update_recommendation_tracking", fake_update_tracking) monkeypatch.setattr(price_tracker, "analyze_tracking_signals", lambda symbol, rec, current_price: { "action_status": "持有", "sell_signals": [], @@ -34,11 +38,19 @@ def test_watch_only_recommendation_is_skipped_before_take_profit_push(monkeypatc "exhaustion": {"severity": "low"}, "pnl_pct": 0.0, }) - monkeypatch.setattr(price_tracker, "apply_recommendation_state_transition", lambda *args, **kwargs: {"action_status": "观察", "push_required": False}) + def fake_state_transition(*args, **kwargs): + calls["state_transition"] += 1 + return {"action_status": "观察", "push_required": False} + monkeypatch.setattr(price_tracker, "apply_recommendation_state_transition", fake_state_transition) monkeypatch.setattr(price_tracker, "expire_old_recommendations", lambda: None) monkeypatch.setattr(price_tracker, "get_stats", lambda: {"active_count": 1}) output = price_tracker.track_prices() assert output["tracked_count"] == 0 - assert output["results"][0]["status"] == "skipped_watch_only" + assert output["observed_count"] == 1 + assert output["results"][0]["status"] == "observed_watch_only" + assert output["results"][0]["current_price"] == 102.0 + assert output["results"][0]["pnl_pct"] == 2.0 + assert calls["tracking"] == 1 + assert calls["state_transition"] == 0 diff --git a/tests/test_screener_optimizations.py b/tests/test_screener_optimizations.py index 49ef04a..194699b 100644 --- a/tests/test_screener_optimizations.py +++ b/tests/test_screener_optimizations.py @@ -222,6 +222,54 @@ def test_static_accumulation_bypass_promotes_expired_to_accumulate(monkeypatch): assert any("静K蓄力旁路入池" in s for s in qualified["PNT/USDT"]["signals"]) +def test_static_accumulation_requires_resonance_when_configured(monkeypatch): + monkeypatch.setattr(altcoin_screener, "get_dynamic_weights", _mock_weights) + monkeypatch.setattr(altcoin_screener, "state_score_thresholds", lambda: (8, 10, 3)) + logged = [] + monkeypatch.setattr( + altcoin_screener, + "get_screener_section", + lambda name=None: { + "sector_rotation": { + "bonus_weight": 0, + "min_non_sector_signals_for_accelerate": 2, + "sector_only_max_state": "蓄力", + }, + "static_accumulation_bypass": { + "min_score": 2, + "min_vol_ratio": 1.0, + "min_static_count": 3, + "require_resonance": True, + "min_resonance_signals": 2, + }, + }.get(name, {}), + ) + monkeypatch.setattr(altcoin_screener, "fetch_top_trader_ratio", lambda symbol: None) + monkeypatch.setattr(altcoin_screener, "log_screening", lambda **kwargs: logged.append(kwargs)) + monkeypatch.setattr(altcoin_screener, "get_sector_for_coin", lambda symbol: []) + monkeypatch.setattr(altcoin_screener, "dynamic_leader_detection", lambda perf: {}) + monkeypatch.setattr(altcoin_screener, "SECTOR_MEMBERS", {}) + + qualified, _, _ = altcoin_screener.layer2_fine_filter({ + "QUIET/USDT": { + "anomaly_score": 2, + "price": 1.0, + "change_24h": 1.0, + "funding_rate": 0.0, + "is_meme": False, + "vp_data": None, + "bb_data": None, + "static_accumulation": {"static_count": 5, "vol_ratio": 1.1}, + "h4_df": None, + } + }) + + assert "QUIET/USDT" not in qualified + reject = next(item for item in logged if item["symbol"] == "QUIET/USDT") + assert reject["state"] == "过期" + assert reject["detail"]["static_bypass_resonance"] == [] + + def test_strong_static_accumulation_can_promote_to_accelerate(monkeypatch): monkeypatch.setattr(altcoin_screener, "get_dynamic_weights", _mock_weights) monkeypatch.setattr(altcoin_screener, "state_score_thresholds", lambda: (8, 10, 3)) @@ -399,3 +447,49 @@ def test_top_gainer_quality_rejected_as_chase_not_silent(monkeypatch): assert reject["detail"]["candidate_stage"] == "rejected_candidate" assert "high_chase_risk" in reject["detail"]["reject_reason_codes"] assert "low_score" in reject["detail"]["reject_reason_codes"] + + +def test_top_gainer_without_chase_risk_is_not_rejected_only_for_low_score(monkeypatch): + logged = [] + monkeypatch.setattr(altcoin_screener, "get_dynamic_weights", _mock_weights) + monkeypatch.setattr(altcoin_screener, "state_score_thresholds", lambda: (6, 9, 3)) + monkeypatch.setattr( + altcoin_screener, + "get_screener_section", + lambda name=None: { + "static_accumulation_bypass": { + "min_score": 2, + "min_vol_ratio": 1.2, + "min_static_count": 3, + }, + }.get(name, {}), + ) + monkeypatch.setattr(altcoin_screener, "fetch_top_trader_ratio", lambda symbol: None) + monkeypatch.setattr(altcoin_screener, "log_screening", lambda **kwargs: logged.append(kwargs)) + monkeypatch.setattr(altcoin_screener, "get_sector_for_coin", lambda symbol: []) + monkeypatch.setattr(altcoin_screener, "dynamic_leader_detection", lambda perf: {}) + monkeypatch.setattr(altcoin_screener, "SECTOR_MEMBERS", {}) + + qualified, _, _ = altcoin_screener.layer2_fine_filter({ + "RONIN/USDT": { + "anomaly_score": 2, + "price": 1.2, + "change_24h": 43.0, + "volume_24h": 11_000_000, + "funding_rate": 0.0, + "is_meme": False, + "vp_data": None, + "bb_data": None, + "static_accumulation": None, + "h4_df": None, + "top_gainer_24h": True, + "top_gainer_chase_risk": False, + "anomalies": ["24h强势榜异动(43.0%,成交额11.0M)"], + } + }) + + # 低分强势榜候选仍可被保留为异动候选,而不是直接纯拒绝掉。 + assert "RONIN/USDT" in qualified + fine = next(item for item in logged if item["layer"] == "细筛" and item["symbol"] == "RONIN/USDT") + assert fine["detail"]["candidate_stage"] == "qualified_candidate" + assert "high_chase_risk" not in fine["detail"]["reject_reason_codes"] if "reject_reason_codes" in fine["detail"] else True diff --git a/tests/test_tracker_terminal_action_guard.py b/tests/test_tracker_terminal_action_guard.py index fb0239c..80992b7 100644 --- a/tests/test_tracker_terminal_action_guard.py +++ b/tests/test_tracker_terminal_action_guard.py @@ -22,8 +22,20 @@ def test_terminal_recommendation_action_status_cannot_be_overwritten_by_entry_si stop_loss=0.000521, tp1=0.000684, tp2=0.000726, - entry_plan={"entry_action": "等回踩", "entry_price": 0.000628}, + entry_plan={"entry_action": "持有", "entry_price": 0.000628}, ) + conn = altcoin_db.get_conn() + conn.execute( + """ + UPDATE recommendation + SET action_status='持有', execution_status='holding', + display_bucket='position', lifecycle_state='holding', entry_triggered=1 + WHERE id=%s + """, + (rec_id,), + ) + conn.commit() + conn.close() altcoin_db.update_recommendation_tracking(rec_id, 0.000685) # 即使后续动态入场逻辑误传“可即刻买入”,DB 层也必须保持止盈状态。 @@ -72,3 +84,31 @@ def test_watch_pool_tracking_does_not_mark_stopped_out(monkeypatch, tmp_path): assert rec["execution_status"] == "observe" assert rec["entry_triggered"] == 0 assert rec["recommendation_result"] == "pending" + + +def test_watch_pool_tracking_does_not_mark_take_profit(monkeypatch, tmp_path): + db_path = tmp_path / "altcoin_monitor.db" + monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path)) + altcoin_db.init_db() + + rec_id = altcoin_db.create_recommendation( + symbol="WATCH/USDT", + rec_state="爆发", + rec_score=12, + entry_price=100.0, + stop_loss=90.0, + tp1=105.0, + tp2=110.0, + entry_plan={"entry_action": "观察", "entry_price": 100.0, "stop_loss": 90.0, "tp1": 105.0, "tp2": 110.0}, + ) + + altcoin_db.update_recommendation_tracking(rec_id, 112.0) + + rows = altcoin_db.get_all_recommendations(limit=10) + rec = next(r for r in rows if r["id"] == rec_id) + assert rec["status"] == "active" + assert rec["execution_status"] == "observe" + assert rec["entry_triggered"] == 0 + assert rec["pnl_pct"] == 12.0 + assert rec["max_pnl_pct"] == 12.0 + assert rec["recommendation_result"] == "pending"