This commit is contained in:
aaron 2026-05-20 00:57:46 +08:00
parent 01e38675fe
commit ea7d9eab63
29 changed files with 3352 additions and 2601 deletions

495
AGENTS.md
View File

@ -2,195 +2,312 @@
## 1. 项目定位
AlphaX 是一个以 `Python + FastAPI + SQLite + 静态 HTML` 组成的加密市场机会监控系统,当前核心目标不是做完整交易执行,而是围绕“发现机会 -> 确认机会 -> 跟踪机会 -> 复盘迭代”建立一套可持续优化的研究与提示闭环。
AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组成的加密市场机会监控系统。当前核心目标不是完整自动交易执行,而是围绕“发现机会 -> 确认机会 -> 跟踪机会 -> 复盘迭代”建立一套可持续优化的研究、提示、模拟交易和复盘闭环。
当前仓库是一个 **Docker 化副本**,从 `README_DOCKER.md` 的描述看,它被设计为和线上主实例隔离运行,默认端口、数据库挂载、调度器行为都以“先验证、后放量”为原则
当前仓库是一个 Docker 化运行目录。运行时数据库已经切换为 PostgreSQLSQLite 只作为历史数据导入来源,不再作为应用运行时数据库。后续开发和排查都应以 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 再被过时信息带偏

View File

@ -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}

View File

@ -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}

View File

@ -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"):

File diff suppressed because it is too large Load Diff

View File

@ -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),
}

View File

@ -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()

141
app/db/cron_queries.py Normal file
View File

@ -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

79
app/db/push_queries.py Normal file
View File

@ -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)

View File

@ -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()

View File

@ -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":

View File

@ -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", "⏳ 未执行"

View File

@ -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()

View File

@ -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 = {

View File

@ -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(

View File

@ -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]

234
app/db/strategy_insights.py Normal file
View File

@ -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)

View File

@ -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>0penalty规则可不要求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,
}

160
app/db/tracking_queries.py Normal file
View File

@ -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()

View File

@ -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})

View File

@ -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),
},

View File

@ -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),
}

View File

@ -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

View File

@ -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):

View File

@ -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)"],

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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"