update
This commit is contained in:
parent
01e38675fe
commit
ea7d9eab63
495
AGENTS.md
495
AGENTS.md
@ -2,195 +2,312 @@
|
|||||||
|
|
||||||
## 1. 项目定位
|
## 1. 项目定位
|
||||||
|
|
||||||
AlphaX 是一个以 `Python + FastAPI + SQLite + 静态 HTML` 组成的加密市场机会监控系统,当前核心目标不是做完整交易执行,而是围绕“发现机会 -> 确认机会 -> 跟踪机会 -> 复盘迭代”建立一套可持续优化的研究与提示闭环。
|
AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组成的加密市场机会监控系统。当前核心目标不是完整自动交易执行,而是围绕“发现机会 -> 确认机会 -> 跟踪机会 -> 复盘迭代”建立一套可持续优化的研究、提示、模拟交易和复盘闭环。
|
||||||
|
|
||||||
当前仓库是一个 **Docker 化副本**,从 `README_DOCKER.md` 的描述看,它被设计为和线上主实例隔离运行,默认端口、数据库挂载、调度器行为都以“先验证、后放量”为原则。
|
当前仓库是一个 Docker 化运行目录。运行时数据库已经切换为 PostgreSQL;SQLite 只作为历史数据导入来源,不再作为应用运行时数据库。后续开发和排查都应以 PostgreSQL、`DATABASE_URL`、Docker 服务和当前 migration 为准。
|
||||||
|
|
||||||
## 2. 当前技术栈
|
## 2. 当前技术栈
|
||||||
|
|
||||||
- 后端:`FastAPI`, `uvicorn`, `pydantic`
|
- 后端:`FastAPI`, `uvicorn`, `pydantic`
|
||||||
- 数据与计算:`sqlite3`, `pandas`, `numpy`
|
- 数据库:PostgreSQL 16,连接入口为 `DATABASE_URL`
|
||||||
|
- 数据访问:`psycopg` + 兼容旧 row 读取方式的 `DbRow`
|
||||||
|
- 数据与计算:`pandas`, `numpy`
|
||||||
- 交易所/行情:`ccxt`, `requests`
|
- 交易所/行情:`ccxt`, `requests`
|
||||||
- 配置:`rules.yaml` + `app/config/config_loader.py`
|
- 配置:`rules.yaml` + `app/config/config_loader.py` + `system_config`/runtime DB 配置
|
||||||
- 测试:`pytest` / `unittest`
|
- 测试:`pytest` / `unittest`
|
||||||
- 部署:`Dockerfile`, `docker-compose.yml`
|
- 部署:`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`
|
- `postgres`
|
||||||
负责粗筛,基于 Binance 行情、量价/结构等规则找候选币。
|
- PostgreSQL 16
|
||||||
2. `app/services/altcoin_confirm.py`
|
- 默认宿主机端口 `5433 -> 5432`
|
||||||
负责确认,判断是否形成更可执行的机会,并生成入场计划、上下文和推送候选。
|
- 数据保存在 compose volume `postgres_data`
|
||||||
3. `app/services/price_tracker.py`
|
- `alphax-web`
|
||||||
负责跟踪活跃推荐,更新盈亏、止盈止损、趋势衰减、行动状态。
|
- FastAPI + 页面/API
|
||||||
4. `app/services/review_engine.py`
|
- 默认宿主机端口 `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` 仍是交易/推荐/状态的核心数据库层,体量很大,当前主要承担:
|
`app/web/web_server.py` 只应负责 FastAPI 应用装配、模板装配、中间件、全局异常处理和 router include。新增业务 API 优先放到对应 route 模块:
|
||||||
- 初始化表结构
|
|
||||||
- recommendation / screening_log / tracking / review 等主表读写
|
- `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`
|
- `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`
|
- `app/db/analytics.py`
|
||||||
- 已开始承接筛选历史、复盘概览、cron 汇总等读多写少的查询
|
- 筛选历史、复盘概览、cron 汇总等读多写少查询。
|
||||||
- `app/db/auth_db.py` 是会员、邀请码、邮箱验证、订阅、订单预留的数据库层。
|
- `app/db/review_queries.py`
|
||||||
- `app/core/opportunity_lifecycle.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` 是策略配置单一事实源。
|
当前 PostgreSQL 运行时重要表包括:
|
||||||
- `app/config/config_loader.py` 负责:
|
|
||||||
- 读取/缓存配置
|
|
||||||
- 暴露各子模块配置访问函数
|
|
||||||
- 将部分复盘后的参数改写回 `rules.yaml`
|
|
||||||
- 兼容旧信号名
|
|
||||||
|
|
||||||
如果要改筛选阈值、确认门槛、止盈止损、动态权重逻辑,优先检查 `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`
|
- `/app`
|
||||||
- 当前真实实现层,按职责拆成 `services`, `db`, `core`, `config`, `integrations`, `analysis`, `web`
|
- 真实实现层,按职责拆成 `services`, `db`, `core`, `config`, `integrations`, `analysis`, `web`
|
||||||
- `/static`
|
- `/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`
|
- `/tests`
|
||||||
- 针对状态机、认证订阅、复盘、事件驱动、策略版本等的回归测试
|
- 状态机、认证订阅、推荐链路、调度、模拟交易、行情、复盘、前端页面约束等回归测试
|
||||||
- `/scripts`
|
- `/scripts`
|
||||||
- 若干结构/状态机/信号时效性校验脚本
|
- 校验脚本和 PostgreSQL 导入/备份/恢复脚本
|
||||||
|
- `/scripts/postgres`
|
||||||
|
- PostgreSQL migration、SQLite 历史导入、导入校验、备份恢复
|
||||||
- `/docker`
|
- `/docker`
|
||||||
- 容器入口与串行调度器
|
- 容器入口与调度器
|
||||||
- `/tools`
|
- `/tools`
|
||||||
- 非主链路工具脚本,如回测和输出摘要脚本
|
- 非主链路工具脚本,如回测和输出摘要脚本
|
||||||
- `/templates`
|
- `/templates`
|
||||||
- 被后端读取的 HTML 模板资源
|
- 后端读取的 HTML 模板资源
|
||||||
- `/reports`
|
- `/docs`
|
||||||
- 本地分析/回测结果产物
|
- 项目结构、迁移、专题审计、参考 schema 等文档
|
||||||
- `/legacy`
|
|
||||||
- 历史实验脚本、旧页面备份、临时整理归档
|
|
||||||
- `/data`
|
- `/data`
|
||||||
- SQLite 数据库存放目录
|
- 本地挂载数据目录,主要用于历史导入源或运行产物,不是 PostgreSQL 主存储
|
||||||
- `/logs`
|
- `/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 入口与部署文件
|
- Docker 入口与部署文件
|
||||||
- 项目说明文档
|
- 项目说明文档
|
||||||
- 明确约定的非代码资产目录
|
- 明确约定的非代码资产目录
|
||||||
|
|
||||||
Python 业务实现不应再直接留在根目录。
|
Python 业务实现不应直接留在根目录。
|
||||||
|
|
||||||
像下面这些内容应放在分层目录里:
|
## 8. 运行与验证
|
||||||
|
|
||||||
- 服务流程:`/app/services`
|
### 8.1 Docker 启动
|
||||||
- 数据访问:`/app/db`
|
|
||||||
- 领域与规则:`/app/core`
|
|
||||||
- 配置访问:`/app/config`
|
|
||||||
- Web 层:`/app/web`
|
|
||||||
- 第三方集成:`/app/integrations`
|
|
||||||
- 顶层配置
|
|
||||||
- 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 写锁冲突。
|
Docker 内建议通过 `docker compose exec alphax-web python -m app.cli ...` 执行,确保使用容器内 `DATABASE_URL` 和依赖环境。
|
||||||
- 副本默认不应影响线上实例。
|
|
||||||
- 数据库挂载路径是 `./data/altcoin_monitor.db`。
|
|
||||||
|
|
||||||
### 6.2 入口
|
### 8.3 测试与校验
|
||||||
|
|
||||||
- `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 推荐命令
|
|
||||||
|
|
||||||
常用回归命令:
|
常用回归命令:
|
||||||
|
|
||||||
@ -202,81 +319,117 @@ python3 scripts/validate_push_state_flow.py
|
|||||||
python3 scripts/validate_signal_recency.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` 是否全绿。
|
如果只是小范围修改,优先跑和改动模块最相关的测试文件,不要盲目只看 `pytest` 是否全绿。
|
||||||
|
|
||||||
## 8. 开发守则
|
## 9. 开发守则
|
||||||
|
|
||||||
### 8.1 改动前先判断“应该改哪一层”
|
### 9.1 改动前先判断“应该改哪一层”
|
||||||
|
|
||||||
- 调参数:优先 `rules.yaml`
|
- 调参数:优先 `rules.yaml`
|
||||||
- 配置读取/兼容:`app/config/config_loader.py`
|
- 策略配置读取/兼容:`app/config/config_loader.py`
|
||||||
- 状态口径:`app/core/opportunity_lifecycle.py` 或 `app/db/altcoin_db.py`
|
- 系统运行态配置:`app/config/system_config.py` / `app/db/runtime_config_db.py`
|
||||||
- DB 表结构/查询:`app/db/altcoin_db.py` / `app/db/auth_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`
|
- API 契约:优先对应 `app/web/routes_*.py`
|
||||||
- 页面壳和交互:`static/*.html`
|
- 页面壳和交互:`static/*.html`
|
||||||
|
- 调度任务:`app/cli.py` + `app/db/scheduler_db.py` + `docker/scheduler.py`
|
||||||
|
|
||||||
### 8.2 SQLite 相关约束
|
### 9.2 PostgreSQL 约束
|
||||||
|
|
||||||
这个项目高度依赖 SQLite,因此要特别注意:
|
这个项目当前运行时依赖 PostgreSQL,因此要特别注意:
|
||||||
|
|
||||||
- 避免引入并发写入路径
|
- 不要新增 SQLite 运行时分支。
|
||||||
- 避免在不同脚本里制造长事务
|
- 不要把 `data/*.db` 当作线上或当前状态来源。
|
||||||
- 任何新增 cron/task 设计,都先考虑是否会和现有调度冲突
|
- schema 变化必须通过 `app/db/migrations/*.sql`。
|
||||||
- 新增写操作时,优先复用已有 `get_conn()` 约定
|
- 查询最新运行状态优先看 PostgreSQL 表,而不是历史文件。
|
||||||
|
- Docker 容器内运行和宿主机运行可能使用不同连接地址,排查时先确认 `DATABASE_URL`。
|
||||||
|
- 调度器并发运行时要检查 lock group,避免多个任务同时写推荐主链路。
|
||||||
|
|
||||||
### 8.3 状态机不要各写各的
|
### 9.3 状态机不要各写各的
|
||||||
|
|
||||||
项目已经存在比较强的“状态派生中心化”趋势:
|
项目已经存在比较强的状态派生中心化趋势:
|
||||||
|
|
||||||
- `normalize_action_status`
|
- `normalize_action_status`
|
||||||
- `derive_display_bucket`
|
- `derive_display_bucket`
|
||||||
- `apply_entry_quality_gate`
|
- `apply_entry_quality_gate`
|
||||||
- `apply_recommendation_state_transition`
|
- `apply_recommendation_state_transition`
|
||||||
|
- `screening_stage_meta`
|
||||||
|
- `build_screening_detail`
|
||||||
|
|
||||||
新增状态时,必须检查:
|
新增状态时,必须检查:
|
||||||
|
|
||||||
1. DB 中的原始状态字段怎么存
|
1. DB 中原始状态字段怎么存。
|
||||||
2. API 输出怎么派生
|
2. API 输出怎么派生。
|
||||||
3. 前端如何解释
|
3. 前端如何解释。
|
||||||
4. 推送是否会误触发
|
4. 推送是否会误触发。
|
||||||
5. 相关测试是否需要补齐
|
5. paper trading 是否会误开仓或误平仓。
|
||||||
|
6. 复盘统计是否会被污染。
|
||||||
|
7. 相关测试是否需要补齐。
|
||||||
|
|
||||||
### 8.4 面向兼容开发
|
### 9.4 推荐链路当前特别注意点
|
||||||
|
|
||||||
这个仓库里有不少“迭代中兼容旧逻辑”的痕迹,例如:
|
当前主链路已经能持续产生筛选和确认样本,但后半段仍需要重点盯住:
|
||||||
|
|
||||||
- `app/config/config_loader.py` 中的信号别名兼容
|
- `latest_price_cache` 可能是实时的,但不代表 `recommendation.pnl_pct` 已更新。
|
||||||
- `app/db/altcoin_db.py` 中大量 `ALTER TABLE` 迁移兜底
|
- `price_tracking` 是跟踪流水,不应和 `latest_price_cache` 混为一谈。
|
||||||
- 多处 `entry_plan_json` / `detail_json` / `*_context_json`
|
- `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 根目录或重新初始化。
|
- 当前运行态是 PostgreSQL,不是 SQLite。
|
||||||
- [DESIGN.md](/Users/aaron/Desktop/code/alphax-docker/DESIGN.md) 当前内容更像一份品牌/样式 YAML,不是这个项目的系统设计文档,阅读时不要误判。
|
- `README_DOCKER.md` 是 Docker/PostgreSQL 运行说明的重要事实源。
|
||||||
- 根目录存在一些临时/非核心文件,例如 `.tmp_patch_tp1.py`、`.tmp_strategy_v2_marker.txt`、`=`,后续开发前建议先确认这些文件是否仍有保留价值。
|
- `docs/PROJECT_STRUCTURE.md` 记录了目录整理背景,但具体运行状态仍以代码、compose 和 PostgreSQL 为准。
|
||||||
- `app/db/altcoin_db.py` 仍然很大,后续新增 DB 查询应优先放到 `recommendation_queries.py`、`analytics.py`、`review_queries.py` 等分组模块。
|
- `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,而不是继续把查询塞回路由层。
|
1. 继续把 `app/db/altcoin_db.py` 剩余读接口迁到 `recommendation_queries.py` / `analytics.py`,最终让它只保留极薄兼容导出或逐步废弃。
|
||||||
2. 继续把 `app/db/altcoin_db.py` 的真实实现迁出到 schema/init、recommendation、review、analytics、admin 分组模块。
|
2. 为 watch_pool / wait_pullback 建立更完整的观察绩效报表,继续避免和已执行仓位 PnL 混在一起。
|
||||||
3. 把 `rules.yaml` 的 schema 校验从“顶层结构校验”推进到“关键子字段校验”。
|
3. 把 `rules.yaml` 的 schema 校验从“顶层结构校验”推进到“关键子字段校验”。
|
||||||
4. 让 Docker、文档、测试样例全面收敛到 `python -m app.cli ...` 入口。
|
4. 让 Docker、文档、测试样例全面收敛到 `python -m app.cli ...` 入口。
|
||||||
5. 继续梳理推送链路,把“是否推送”的判断、推送内容组装、通道发送彻底分层。
|
5. 继续梳理推送链路,把“是否推送”的判断、推送内容组装、通道发送彻底分层。
|
||||||
|
6. 对 `missed_explosions` 写入侧建立唯一性或冷却约束,避免重复样本继续进入历史表。
|
||||||
|
7. 梳理 price-streamer、tracker、paper-trader 三者边界,确保实时价格、推荐跟踪、模拟成交各自语义清晰。
|
||||||
|
|
||||||
## 11. 给后续 Agent 的工作方式建议
|
## 12. 给后续 Agent 的工作方式建议
|
||||||
|
|
||||||
接手这个仓库时,优先按下面顺序理解问题:
|
接手这个仓库时,优先按下面顺序理解问题:
|
||||||
|
|
||||||
1. 先确认问题发生在“筛选 / 确认 / 跟踪 / 复盘 / Web 展示 / 认证订阅”哪一层。
|
1. 先确认问题发生在“筛选 / 确认 / 跟踪 / 模拟交易 / 复盘 / Web 展示 / 认证订阅 / 调度运行态”哪一层。
|
||||||
2. 再确认它属于“参数失真、状态派生错误、DB 查询口径不一致、前端展示错误、任务调度副作用”中的哪一类。
|
2. 再确认它属于“参数失真、状态派生错误、DB 查询口径不一致、前端展示错误、任务调度副作用、数据水位滞后、样本去重缺失”中的哪一类。
|
||||||
3. 修改后至少补 1 个相关测试,最好补到最接近业务口径的那层。
|
3. 排查数据时直接查 PostgreSQL,不要使用 SQLite 文件作为当前状态来源。
|
||||||
4. 如果变更影响推荐状态或展示桶,务必同时检查 API、前端、推送、历史统计四个面。
|
4. 修改后至少补 1 个相关测试,最好补到最接近业务口径的那层。
|
||||||
|
5. 如果变更影响推荐状态或展示桶,务必同时检查 API、前端、推送、paper trading、历史统计五个面。
|
||||||
|
6. 如果变更影响调度任务,务必检查 `scheduler_job_config`、`scheduler_runtime_status` 和最近 `cron_run_log`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
这份文档以当前仓库实际代码为准整理。如果后续完成模块拆分、引入新的持久层或把静态页面改造成更现代的前端工程,记得同步更新本文件,而不是让它变成另一份过时说明书。
|
这份文档以当前仓库实际代码、Docker compose 和 PostgreSQL 运行态为准整理。后续如果引入新的服务、迁移表结构、改变调度模式或调整推荐状态机,必须同步更新本文件,避免后续 Agent 再被过时信息带偏。
|
||||||
|
|||||||
@ -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 [])
|
signals = list(signals or candidate.get("signals") or candidate.get("anomalies") or [])
|
||||||
text = " ".join(str(s) for s in signals)
|
text = " ".join(str(s) for s in signals)
|
||||||
codes: List[str] = []
|
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:
|
if score < threshold:
|
||||||
codes.append("low_score")
|
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", "鲸鱼", "聪明钱")):
|
if any(keyword in text for keyword in ("板块联动", "舆情共振", "链上", "DEX", "鲸鱼", "聪明钱")):
|
||||||
codes.append("multi_source_resonance")
|
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)]
|
labels = [QUALITY_REASON_LABELS.get(code, code) for code in dict.fromkeys(codes)]
|
||||||
return {"codes": list(dict.fromkeys(codes)), "labels": labels}
|
return {"codes": list(dict.fromkeys(codes)), "labels": labels}
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,14 @@ OPPORTUNITY_LEVELS: Dict[str, Dict[str, str]] = {
|
|||||||
"tp_model": "4H压力位 / 前高 / 移动止盈",
|
"tp_model": "4H压力位 / 前高 / 移动止盈",
|
||||||
"max_action": "buy_now",
|
"max_action": "buy_now",
|
||||||
},
|
},
|
||||||
|
"momentum_watch": {
|
||||||
|
"label": "强势观察",
|
||||||
|
"holding_horizon": "数小时-2天",
|
||||||
|
"entry_model": "涨幅榜强势 / 等二次结构",
|
||||||
|
"stop_model": "等待回踩低点 / 新结构失效位",
|
||||||
|
"tp_model": "不追首段 / 只跟踪二次买点",
|
||||||
|
"max_action": "observe",
|
||||||
|
},
|
||||||
"structure_watch": {
|
"structure_watch": {
|
||||||
"label": "结构观察",
|
"label": "结构观察",
|
||||||
"holding_horizon": "3-7天",
|
"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:
|
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_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_30m_bridge = bool(m30_aligned) or _has_any(text, ("30min", "30m"))
|
||||||
has_4h_or_daily = _has_any(text, ("4H", "日线", "周线", "需求区", "突破回踩", "底部", "静K", "蓄力"))
|
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(
|
has_theme = bool(sector_context.get("hot_sectors")) or _has_any(
|
||||||
text,
|
text,
|
||||||
("主题", "生态", "舆情", "板块", "listing", "公告", "催化"),
|
("主题", "生态", "舆情", "板块", "listing", "公告", "催化"),
|
||||||
@ -126,15 +136,21 @@ def classify_opportunity_level(
|
|||||||
basis.append("30m结构桥接")
|
basis.append("30m结构桥接")
|
||||||
if has_4h_or_daily:
|
if has_4h_or_daily:
|
||||||
basis.append("高周期结构背景")
|
basis.append("高周期结构背景")
|
||||||
|
if has_top_gainer:
|
||||||
|
basis.append("24h强势榜异动")
|
||||||
|
if has_chase_risk:
|
||||||
|
basis.append("追高/首段已启动风险")
|
||||||
if has_theme:
|
if has_theme:
|
||||||
basis.append("主题/板块线索")
|
basis.append("主题/板块线索")
|
||||||
if stale_only:
|
if stale_only:
|
||||||
basis.append("旧信号仅作背景")
|
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"
|
level = "intraday_breakout"
|
||||||
elif (has_1h_momentum and (has_30m_bridge or has_4h_or_daily)) and not stale_only:
|
elif (has_1h_momentum and (has_30m_bridge or has_4h_or_daily)) and not stale_only:
|
||||||
level = "short_swing"
|
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:
|
elif has_theme and not has_15m_trigger and not has_1h_momentum:
|
||||||
level = "theme_trend"
|
level = "theme_trend"
|
||||||
elif has_4h_or_daily or stale_only:
|
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}
|
return {"tp1_atr": 2.0, "tp1_floor": 0.03, "tp2_atr": 3.5, "tp2_floor": 0.05}
|
||||||
if level == "short_swing":
|
if level == "short_swing":
|
||||||
return {"tp1_atr": 3.0, "tp1_floor": 0.05, "tp2_atr": 5.0, "tp2_floor": 0.08}
|
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":
|
if level == "theme_trend":
|
||||||
return {"tp1_atr": 6.0, "tp1_floor": 0.12, "tp2_atr": 10.0, "tp2_floor": 0.20}
|
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}
|
return {"tp1_atr": 4.0, "tp1_floor": 0.08, "tp2_atr": 7.0, "tp2_floor": 0.14}
|
||||||
|
|||||||
@ -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"))
|
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)
|
rr1 = to_float(entry_plan.get("rr1"), 999.0)
|
||||||
risk_reward_ok = entry_plan.get("risk_reward_ok")
|
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"))
|
stop_loss = to_float(entry_plan.get("stop_loss"))
|
||||||
tp1 = to_float(entry_plan.get("tp1") or entry_plan.get("take_profit_1"))
|
tp1 = to_float(entry_plan.get("tp1") or entry_plan.get("take_profit_1"))
|
||||||
plan_entry_price = to_float(entry_plan.get("entry_price"))
|
plan_entry_price = to_float(entry_plan.get("entry_price"))
|
||||||
@ -329,10 +331,10 @@ def apply_entry_quality_gate(
|
|||||||
})
|
})
|
||||||
|
|
||||||
if action_status in ("可即刻买入", "等回踩"):
|
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}")
|
reasons.append(f"risk_reward_ok=false,盈亏比闸门禁止现价买入;实时rr1={rr1}")
|
||||||
if "rr1" in entry_plan and 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={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 action_status == "可即刻买入":
|
||||||
if level_max_action in ("observe", "wait_pullback"):
|
if level_max_action in ("observe", "wait_pullback"):
|
||||||
|
|||||||
2390
app/db/altcoin_db.py
2390
app/db/altcoin_db.py
File diff suppressed because it is too large
Load Diff
@ -822,13 +822,13 @@ def get_review_stats(conn_provider=None, iteration_logs_getter=None, iteration_s
|
|||||||
revision_started_at = ""
|
revision_started_at = ""
|
||||||
|
|
||||||
reviews = conn.execute("SELECT * FROM review_log ORDER BY review_time DESC").fetchall()
|
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()
|
signals = conn.execute("SELECT * FROM signal_performance ORDER BY hit_rate DESC").fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
return {
|
return {
|
||||||
"reviews": [dict(r) for r in reviews],
|
"reviews": [dict(r) for r in reviews],
|
||||||
"signal_performance": [dict(s) for s in signals],
|
"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_logs": logs_getter(limit=30),
|
||||||
"iteration_summary": summary_getter(days=30),
|
"iteration_summary": summary_getter(days=30),
|
||||||
"strategy_revision_started_at": revision_started_at,
|
"strategy_revision_started_at": revision_started_at,
|
||||||
@ -1018,6 +1018,23 @@ def _missed_item(row):
|
|||||||
return item
|
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):
|
def _performance_status(rec, reviews_by_rec):
|
||||||
status = (rec.get("status") or "").strip()
|
status = (rec.get("status") or "").strip()
|
||||||
review_outcomes = [(r.get("outcome") or "").strip() for r in reviews_by_rec.get(rec.get("id"), [])]
|
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],
|
"screening_rows": [_screening_item(row) for row in screening_rows],
|
||||||
"recommendation_rows": [_recommendation_item(row) for row in rec_rows],
|
"recommendation_rows": [_recommendation_item(row) for row in rec_rows],
|
||||||
"review_rows": [_review_item(row) for row in reviews],
|
"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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
100
app/db/coin_state_queries.py
Normal file
100
app/db/coin_state_queries.py
Normal 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
141
app/db/cron_queries.py
Normal 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
79
app/db/push_queries.py
Normal 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)
|
||||||
474
app/db/recommendation_commands.py
Normal file
474
app/db/recommendation_commands.py
Normal 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()
|
||||||
@ -2,85 +2,18 @@
|
|||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from app.db.altcoin_db import (
|
from app.db.recommendation_commands import apply_recommendation_state_transition
|
||||||
PUSH_COOLDOWN_HOURS,
|
from app.db.recommendation_state import (
|
||||||
_classify_recommendation_result,
|
classify_recommendation_result as _classify_recommendation_result,
|
||||||
_derive_execution_fields,
|
derive_execution_fields as _derive_execution_fields,
|
||||||
_is_actionable_execution_status,
|
is_actionable_execution_status as _is_actionable_execution_status,
|
||||||
apply_recommendation_state_transition,
|
|
||||||
update_recommendation_tracking,
|
|
||||||
)
|
)
|
||||||
|
from app.db.push_queries import get_recommendation_for_push, log_push, should_push
|
||||||
from app.db.schema import get_conn
|
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
|
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):
|
def get_active_recommendations(actionable_only: bool = False):
|
||||||
"""获取所有 active 推荐。"""
|
"""获取所有 active 推荐。"""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
@ -236,7 +169,19 @@ def get_active_recommendations_deduped(
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
all_items = []
|
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()
|
now = datetime.now()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
item = dict(row)
|
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")):
|
if actionable_only and not _is_actionable_execution_status(item.get("execution_status")):
|
||||||
continue
|
continue
|
||||||
all_items.append(item)
|
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":
|
if item.get("execution_status") == "buy_now":
|
||||||
summary["buy_now"] += 1
|
summary["buy_now"] += 1
|
||||||
elif item.get("execution_status") == "wait_pullback":
|
elif item.get("execution_status") == "wait_pullback":
|
||||||
|
|||||||
496
app/db/recommendation_state.py
Normal file
496
app/db/recommendation_state.py
Normal 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", "⏳ 未执行"
|
||||||
83
app/db/review_basic_queries.py
Normal file
83
app/db/review_basic_queries.py
Normal 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()
|
||||||
@ -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]))]
|
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):
|
def _opportunity_review(conn, since):
|
||||||
rec_rows = [dict(r) for r in conn.execute(
|
rec_rows = [dict(r) for r in conn.execute(
|
||||||
"""
|
"""
|
||||||
@ -73,16 +88,17 @@ def _opportunity_review(conn, since):
|
|||||||
""",
|
""",
|
||||||
(since,),
|
(since,),
|
||||||
).fetchall()]
|
).fetchall()]
|
||||||
missed_rows = [dict(r) for r in conn.execute(
|
missed_rows_raw = [dict(r) for r in conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM missed_explosions
|
FROM missed_explosions
|
||||||
WHERE detect_time >= %s
|
WHERE detect_time >= %s
|
||||||
ORDER BY gain_pct DESC, detect_time DESC
|
ORDER BY gain_pct DESC, detect_time DESC
|
||||||
LIMIT 20
|
LIMIT 200
|
||||||
""",
|
""",
|
||||||
(since,),
|
(since,),
|
||||||
).fetchall()]
|
).fetchall()]
|
||||||
|
missed_rows = _dedupe_by_symbol(missed_rows_raw, limit=20)
|
||||||
|
|
||||||
total = len(rec_rows)
|
total = len(rec_rows)
|
||||||
executed_ids = {
|
executed_ids = {
|
||||||
|
|||||||
@ -4,17 +4,17 @@ import json
|
|||||||
import re
|
import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from app.db.altcoin_db import (
|
from app.db.strategy_rule_queries import (
|
||||||
_loads_json_field,
|
|
||||||
backfill_strategy_failure_patterns,
|
backfill_strategy_failure_patterns,
|
||||||
dry_run_strategy_candidate_performance,
|
dry_run_strategy_candidate_performance,
|
||||||
generate_candidates_from_review_history,
|
generate_candidates_from_review_history,
|
||||||
get_strategy_failure_patterns,
|
get_strategy_failure_patterns,
|
||||||
get_strategy_insights,
|
|
||||||
get_strategy_iteration_dashboard,
|
get_strategy_iteration_dashboard,
|
||||||
get_strategy_rule_candidates,
|
get_strategy_rule_candidates,
|
||||||
refresh_strategy_candidate_performance,
|
refresh_strategy_candidate_performance,
|
||||||
|
loads_json_field,
|
||||||
)
|
)
|
||||||
|
from app.db.strategy_insights import get_strategy_insights
|
||||||
from app.db.schema import get_conn
|
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):
|
def get_strategy_iteration_logs(limit=30, conn_provider=None, json_loader=None):
|
||||||
conn_factory = conn_provider or get_conn
|
conn_factory = conn_provider or get_conn
|
||||||
loader = json_loader or _loads_json_field
|
loader = json_loader or loads_json_field
|
||||||
conn = conn_factory()
|
conn = conn_factory()
|
||||||
rows = conn.execute(
|
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):
|
def get_strategy_iteration_summary(days=30, conn_provider=None, json_loader=None):
|
||||||
conn_factory = conn_provider or get_conn
|
conn_factory = conn_provider or get_conn
|
||||||
loader = json_loader or _loads_json_field
|
loader = json_loader or loads_json_field
|
||||||
conn = conn_factory()
|
conn = conn_factory()
|
||||||
cutoff = (datetime.now() - timedelta(days=float(days or 30))).isoformat()
|
cutoff = (datetime.now() - timedelta(days=float(days or 30))).isoformat()
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
|
|||||||
63
app/db/screening_queries.py
Normal file
63
app/db/screening_queries.py
Normal 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
234
app/db/strategy_insights.py
Normal 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)
|
||||||
636
app/db/strategy_rule_queries.py
Normal file
636
app/db/strategy_rule_queries.py
Normal 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>0(penalty规则可不要求avg_pnl>0)",
|
||||||
|
"reject": "sample_size≥8 且 confidence<35 或 avg_pnl≤-3",
|
||||||
|
"release": "dry-run不发布;正式发布仍由复盘发布闸门统一控制",
|
||||||
|
},
|
||||||
|
"evaluated_candidates": sorted(evaluated, key=lambda x: (x.get("dry_run_status") != "gray", -float(x.get("sample_size") or 0), -float(x.get("confidence_score") or 0)))[:80],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_strategy_candidate_performance(min_gray_samples=10, min_gray_confidence=65):
|
||||||
|
"""Refresh candidate metrics and status from clean review/failure samples."""
|
||||||
|
conn = get_conn()
|
||||||
|
candidates = conn.execute("SELECT * FROM strategy_rule_candidate").fetchall()
|
||||||
|
clean_started_at = get_factor_recency_fixed_at()
|
||||||
|
if clean_started_at:
|
||||||
|
review_rows = conn.execute("""
|
||||||
|
SELECT rl.*, r.strategy_version, r.max_drawdown_pct, r.rec_time
|
||||||
|
FROM review_log rl
|
||||||
|
LEFT JOIN recommendation r ON r.id = rl.rec_id
|
||||||
|
WHERE r.rec_time >= %s
|
||||||
|
ORDER BY rl.review_time DESC
|
||||||
|
""", (clean_started_at,)).fetchall()
|
||||||
|
else:
|
||||||
|
review_rows = conn.execute("""
|
||||||
|
SELECT rl.*, r.strategy_version, r.max_drawdown_pct, r.rec_time
|
||||||
|
FROM review_log rl
|
||||||
|
LEFT JOIN recommendation r ON r.id = rl.rec_id
|
||||||
|
ORDER BY rl.review_time DESC
|
||||||
|
""").fetchall()
|
||||||
|
failure_rows = [dict(row) for row in conn.execute("SELECT * FROM strategy_failure_pattern ORDER BY created_at DESC").fetchall()]
|
||||||
|
review_items = _build_review_items(review_rows)
|
||||||
|
|
||||||
|
updated = []
|
||||||
|
for row in candidates:
|
||||||
|
candidate = dict(row)
|
||||||
|
candidate_id = candidate["id"]
|
||||||
|
status = candidate.get("status") or "candidate"
|
||||||
|
if status == "active":
|
||||||
|
continue
|
||||||
|
evaluated = _evaluate_candidate(candidate, review_items, failure_rows, min_gray_samples, min_gray_confidence)
|
||||||
|
if evaluated.get("dry_run_status") == "dirty_history":
|
||||||
|
updated.append({
|
||||||
|
"id": candidate_id,
|
||||||
|
"signal_name": candidate.get("signal_name") or "",
|
||||||
|
"source": candidate.get("source") or "",
|
||||||
|
"rule_type": candidate.get("rule_type") or "",
|
||||||
|
"sample_size": 0,
|
||||||
|
"success_count": 0,
|
||||||
|
"fail_count": 0,
|
||||||
|
"confidence_score": candidate.get("confidence_score") or 0,
|
||||||
|
"avg_pnl": candidate.get("avg_pnl") or 0,
|
||||||
|
"status": "dirty_history",
|
||||||
|
"description": candidate.get("rule_description") or "",
|
||||||
|
"gate_reason": "污染历史参考,不参与干净样本刷新",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
note = (candidate.get("notes") or "").strip()
|
||||||
|
audit_note = (
|
||||||
|
f"[{datetime.now().isoformat()}] 自动评估: 样本{evaluated['sample_size']}, "
|
||||||
|
f"成功{evaluated['success_count']}, 失败{evaluated['fail_count']}, "
|
||||||
|
f"置信{evaluated['confidence_score']}%, avg_pnl={evaluated['avg_pnl']}%, "
|
||||||
|
f"status={evaluated['dry_run_status']}"
|
||||||
|
)
|
||||||
|
if audit_note not in note:
|
||||||
|
note = (note + "\n" if note else "") + audit_note
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
UPDATE strategy_rule_candidate
|
||||||
|
SET support_count=%s, success_count=%s, fail_count=%s, avg_pnl=%s, max_gain=%s,
|
||||||
|
max_drawdown=%s, confidence_score=%s, sample_size=%s, status=%s, notes=%s, created_at=%s
|
||||||
|
WHERE id=%s
|
||||||
|
""", (
|
||||||
|
evaluated["sample_size"], evaluated["success_count"], evaluated["fail_count"],
|
||||||
|
evaluated["avg_pnl"], evaluated["max_gain"], evaluated["max_drawdown"],
|
||||||
|
evaluated["confidence_score"], evaluated["sample_size"], evaluated["dry_run_status"],
|
||||||
|
note, datetime.now().isoformat(), candidate_id,
|
||||||
|
))
|
||||||
|
updated.append({
|
||||||
|
"id": candidate_id,
|
||||||
|
"signal_name": candidate.get("signal_name") or "",
|
||||||
|
"source": candidate.get("source") or "",
|
||||||
|
"rule_type": candidate.get("rule_type") or "",
|
||||||
|
"sample_size": evaluated["sample_size"],
|
||||||
|
"success_count": evaluated["success_count"],
|
||||||
|
"fail_count": evaluated["fail_count"],
|
||||||
|
"confidence_score": evaluated["confidence_score"],
|
||||||
|
"avg_pnl": evaluated["avg_pnl"],
|
||||||
|
"status": evaluated["dry_run_status"],
|
||||||
|
"description": candidate.get("rule_description") or "",
|
||||||
|
})
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def _build_review_items(review_rows):
|
||||||
|
review_items = []
|
||||||
|
for row in review_rows:
|
||||||
|
item = dict(row)
|
||||||
|
triggered = loads_json_field(item.get("triggered_signals"), []) or []
|
||||||
|
hit = loads_json_field(item.get("hit_signals"), []) or []
|
||||||
|
miss = loads_json_field(item.get("miss_signals"), []) or []
|
||||||
|
all_signals = list(triggered) + list(hit) + list(miss)
|
||||||
|
item["signal_keys"] = {candidate_signal_key(signal) for signal in all_signals}
|
||||||
|
item["all_signal_text"] = " ".join(str(signal) for signal in all_signals)
|
||||||
|
review_items.append(item)
|
||||||
|
return review_items
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_candidate(candidate, review_items, failure_rows, min_gray_samples, min_gray_confidence):
|
||||||
|
status = candidate.get("status") or "candidate"
|
||||||
|
source = candidate.get("source") or ""
|
||||||
|
rule_type = candidate.get("rule_type") or ""
|
||||||
|
signal_name = candidate.get("signal_name") or ""
|
||||||
|
source_ref = candidate.get("source_ref") or ""
|
||||||
|
if is_dirty_history_candidate(candidate):
|
||||||
|
return {
|
||||||
|
**candidate,
|
||||||
|
"sample_size": 0,
|
||||||
|
"support_count": 0,
|
||||||
|
"success_count": 0,
|
||||||
|
"fail_count": 0,
|
||||||
|
"dry_run_status": "dirty_history",
|
||||||
|
"release_gate_passed": False,
|
||||||
|
"gate_reason": "因子时效修复前的污染历史参考:不参与干净样本统计,不允许发布",
|
||||||
|
}
|
||||||
|
if status == "active":
|
||||||
|
return {**candidate, "dry_run_status": "active", "release_gate_passed": True, "gate_reason": "已正式生效,不参与dry-run降级"}
|
||||||
|
|
||||||
|
if source.startswith("dual_attribution_failure") or source_ref.startswith("failure:") or rule_type == "penalty":
|
||||||
|
failure_type = signal_name or source_ref.replace("failure:", "")
|
||||||
|
matched = [row for row in failure_rows if (row.get("failure_type") or "") == failure_type or failure_type in (row.get("failure_reason") or "")]
|
||||||
|
sample_size = len(matched)
|
||||||
|
success_count = 0
|
||||||
|
fail_count = sample_size
|
||||||
|
pnl_values = [float(row.get("pnl_pct") or 0) for row in matched]
|
||||||
|
dd_values = [float(row.get("max_drawdown_pct") or 0) for row in matched]
|
||||||
|
confidence = round(min(95, 45 + fail_count * 8), 1) if sample_size else float(candidate.get("confidence_score") or 0)
|
||||||
|
else:
|
||||||
|
key = signal_name or source_ref.replace("review:", "")
|
||||||
|
matched = [item for item in review_items if key and (key in item["signal_keys"] or key in item["all_signal_text"] or signal_name in item["all_signal_text"])]
|
||||||
|
sample_size = len(matched)
|
||||||
|
success_count = sum(1 for row in matched if row.get("outcome") == "爆发")
|
||||||
|
fail_count = sum(1 for row in matched if row.get("outcome") in ("失败", "横盘"))
|
||||||
|
pnl_values = [float(row.get("pnl_48h") or 0) for row in matched]
|
||||||
|
dd_values = [float(row.get("max_drawdown_pct") or 0) for row in matched]
|
||||||
|
resolved = success_count + fail_count
|
||||||
|
confidence = round(success_count / resolved * 100, 1) if resolved else float(candidate.get("confidence_score") or 0)
|
||||||
|
|
||||||
|
avg_pnl = round(sum(pnl_values) / len(pnl_values), 2) if pnl_values else float(candidate.get("avg_pnl") or 0)
|
||||||
|
max_gain = round(max(pnl_values), 2) if pnl_values else float(candidate.get("max_gain") or 0)
|
||||||
|
max_drawdown = round(min(dd_values), 2) if dd_values else float(candidate.get("max_drawdown") or 0)
|
||||||
|
dry_status = candidate_status_for_metrics(rule_type, sample_size, confidence, avg_pnl, status, min_gray_samples, min_gray_confidence)
|
||||||
|
gate_passed = dry_status in ("gray", "active")
|
||||||
|
if dry_status == "gray":
|
||||||
|
gate_reason = f"样本{sample_size}≥{min_gray_samples},置信{confidence}%≥{min_gray_confidence},avg_pnl={avg_pnl}%:可进入灰度,仍不升版"
|
||||||
|
elif dry_status == "rejected":
|
||||||
|
gate_reason = f"样本{sample_size}已足够但置信/收益不达标:淘汰,不允许发布"
|
||||||
|
else:
|
||||||
|
gate_reason = f"样本{sample_size}或置信{confidence}%不足:只研究不发布"
|
||||||
|
return {
|
||||||
|
**candidate,
|
||||||
|
"sample_size": sample_size,
|
||||||
|
"support_count": sample_size,
|
||||||
|
"success_count": success_count,
|
||||||
|
"fail_count": fail_count,
|
||||||
|
"avg_pnl": avg_pnl,
|
||||||
|
"max_gain": max_gain,
|
||||||
|
"max_drawdown": max_drawdown,
|
||||||
|
"confidence_score": confidence,
|
||||||
|
"dry_run_status": dry_status,
|
||||||
|
"release_gate_passed": gate_passed,
|
||||||
|
"gate_reason": gate_reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_strategy_iteration_dashboard(days=30):
|
||||||
|
"""Dashboard aggregate: overview + candidates + failure patterns + timeline."""
|
||||||
|
from app.db.review_queries import get_strategy_iteration_logs, get_strategy_iteration_summary
|
||||||
|
|
||||||
|
summary = get_strategy_iteration_summary(days=days)
|
||||||
|
candidates = get_strategy_rule_candidates(limit=80)
|
||||||
|
failures = get_strategy_failure_patterns(limit=80)
|
||||||
|
logs = get_strategy_iteration_logs(limit=40)
|
||||||
|
status_counts = {}
|
||||||
|
source_counts = {}
|
||||||
|
for candidate in candidates:
|
||||||
|
status = candidate.get("status") or "candidate"
|
||||||
|
source = candidate.get("source") or "unknown"
|
||||||
|
status_counts[status] = status_counts.get(status, 0) + 1
|
||||||
|
source_counts[source] = source_counts.get(source, 0) + 1
|
||||||
|
failure_counts = {}
|
||||||
|
for failure in failures:
|
||||||
|
failure_type = failure.get("failure_type") or "未分类"
|
||||||
|
failure_counts[failure_type] = failure_counts.get(failure_type, 0) + 1
|
||||||
|
release_counts = {}
|
||||||
|
for log in logs:
|
||||||
|
decision = log.get("release_decision") or "unknown"
|
||||||
|
release_counts[decision] = release_counts.get(decision, 0) + 1
|
||||||
|
dry_run = dry_run_strategy_candidate_performance()
|
||||||
|
latest_log = logs[0] if logs else {}
|
||||||
|
return {
|
||||||
|
"summary": summary,
|
||||||
|
"overview": {
|
||||||
|
"total_logs": len(logs),
|
||||||
|
"candidate_count": len(candidates),
|
||||||
|
"candidate_status_counts": status_counts,
|
||||||
|
"candidate_source_counts": source_counts,
|
||||||
|
"failure_type_counts": [{"type": k, "count": v} for k, v in sorted(failure_counts.items(), key=lambda x: (-x[1], x[0]))],
|
||||||
|
"release_decision_counts": release_counts,
|
||||||
|
"latest_release_decision": latest_log.get("release_decision") or "hold",
|
||||||
|
"latest_release_reason": latest_log.get("release_reason") or latest_log.get("version_change_summary") or "暂无发布决策说明",
|
||||||
|
"dry_run_summary": {
|
||||||
|
"review_sample_count": dry_run.get("review_sample_count", 0),
|
||||||
|
"clean_started_at": dry_run.get("clean_started_at", ""),
|
||||||
|
"sample_window": dry_run.get("sample_window", "all_history"),
|
||||||
|
"dirty_history_candidate_count": dry_run.get("dirty_history_candidate_count", 0),
|
||||||
|
"candidate_count": dry_run.get("candidate_count", 0),
|
||||||
|
"gray_ready_count": dry_run.get("gray_ready_count", 0),
|
||||||
|
"rejected_count": dry_run.get("rejected_count", 0),
|
||||||
|
"would_bump_version": dry_run.get("would_bump_version", False),
|
||||||
|
"release_reason": dry_run.get("release_reason", ""),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"dry_run": dry_run,
|
||||||
|
"candidates": candidates,
|
||||||
|
"failures": failures,
|
||||||
|
"logs": logs,
|
||||||
|
}
|
||||||
160
app/db/tracking_queries.py
Normal file
160
app/db/tracking_queries.py
Normal 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()
|
||||||
@ -658,6 +658,13 @@ def confirm_burst(symbol, cand):
|
|||||||
# 提取cand数据(v1.7.0:用于辅助信号检测)
|
# 提取cand数据(v1.7.0:用于辅助信号检测)
|
||||||
cand_detail = json.loads(cand.get("detail_json", "{}"))
|
cand_detail = json.loads(cand.get("detail_json", "{}"))
|
||||||
leader_status = cand.get("leader_status", "")
|
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)
|
h1_df = fetch_klines(symbol, "1h", limit=100)
|
||||||
m15_df = fetch_klines(symbol, "15m", 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):
|
def _emit_output(output, compact: bool = False):
|
||||||
if compact:
|
if compact:
|
||||||
print(json.dumps(output, ensure_ascii=False))
|
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})
|
results.append({"symbol": symbol, **result})
|
||||||
|
|
||||||
|
|||||||
@ -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)"
|
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):
|
def _attach_top_gainer_discovery(candidates, tickers, recently_screened):
|
||||||
"""为强势榜补发现入口;追高风险留给细筛/确认处理。"""
|
"""为强势榜补发现入口;追高风险留给细筛/确认处理。"""
|
||||||
added = 0
|
added = 0
|
||||||
@ -1222,12 +1249,25 @@ def layer2_fine_filter(candidates):
|
|||||||
|
|
||||||
base_state = state
|
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 (
|
if (
|
||||||
state == "过期"
|
state == "过期"
|
||||||
and static_accumulation
|
and static_accumulation
|
||||||
and static_accumulation["vol_ratio"] >= static_bypass_min_vol_ratio
|
and static_accumulation["vol_ratio"] >= static_bypass_min_vol_ratio
|
||||||
and score >= static_bypass_min_score
|
and score >= static_bypass_min_score
|
||||||
|
and static_resonance_ok
|
||||||
):
|
):
|
||||||
state = "蓄力"
|
state = "蓄力"
|
||||||
force_accumulate_reason = "静K蓄力旁路"
|
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("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 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 score >= direct_acc_cfg.get("min_score", 5)
|
||||||
|
and static_resonance_ok
|
||||||
):
|
):
|
||||||
state = "加速"
|
state = "加速"
|
||||||
force_accumulate_reason = "强静K蓄力直升加速"
|
force_accumulate_reason = "强静K蓄力直升加速"
|
||||||
@ -1280,6 +1321,9 @@ def layer2_fine_filter(candidates):
|
|||||||
signals.append(f"24h强势榜异动({cand.get('change_24h', 0):.1f}%)")
|
signals.append(f"24h强势榜异动({cand.get('change_24h', 0):.1f}%)")
|
||||||
if cand.get("top_gainer_chase_risk"):
|
if cand.get("top_gainer_chase_risk"):
|
||||||
signals.append("追高风险:首次进入强势榜,等待二次结构确认")
|
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)
|
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"],
|
"quality_reason_labels": quality["labels"],
|
||||||
"base_state": base_state,
|
"base_state": base_state,
|
||||||
"force_reason": force_accumulate_reason or "",
|
"force_reason": force_accumulate_reason or "",
|
||||||
|
"static_bypass_resonance": static_resonance,
|
||||||
"sector_signal_count": sector_signal_count,
|
"sector_signal_count": sector_signal_count,
|
||||||
"signal_recency": _build_signal_recency(cand),
|
"signal_recency": _build_signal_recency(cand),
|
||||||
"signal_codes": build_signal_codes(signals),
|
"signal_codes": build_signal_codes(signals),
|
||||||
@ -1409,6 +1454,7 @@ def layer2_fine_filter(candidates):
|
|||||||
"score": score,
|
"score": score,
|
||||||
"threshold": accumulate_threshold,
|
"threshold": accumulate_threshold,
|
||||||
"base_state": base_state,
|
"base_state": base_state,
|
||||||
|
"static_bypass_resonance": static_resonance if static_accumulation else [],
|
||||||
"signal_recency": _build_signal_recency(cand),
|
"signal_recency": _build_signal_recency(cand),
|
||||||
"signal_codes": build_signal_codes(reject_signals),
|
"signal_codes": build_signal_codes(reject_signals),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -273,7 +273,7 @@ def analyze_tracking_signals(symbol, rec, current_price):
|
|||||||
|
|
||||||
def track_prices():
|
def track_prices():
|
||||||
"""拉取所有active推荐币的实时价格,更新盈亏 + 动态跟踪信号"""
|
"""拉取所有active推荐币的实时价格,更新盈亏 + 动态跟踪信号"""
|
||||||
recs = get_active_recommendations(actionable_only=True)
|
recs = get_active_recommendations(actionable_only=False)
|
||||||
if not recs:
|
if not recs:
|
||||||
output = {
|
output = {
|
||||||
"status": "no_active",
|
"status": "no_active",
|
||||||
@ -286,34 +286,37 @@ def track_prices():
|
|||||||
|
|
||||||
results = []
|
results = []
|
||||||
tracked_count = 0
|
tracked_count = 0
|
||||||
|
observed_count = 0
|
||||||
failed_symbols = []
|
failed_symbols = []
|
||||||
for rec in recs:
|
for rec in recs:
|
||||||
symbol = rec["symbol"]
|
symbol = rec["symbol"]
|
||||||
try:
|
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)
|
ticker = exchange.fetch_ticker(symbol)
|
||||||
current_price = ticker["last"]
|
current_price = ticker["last"]
|
||||||
|
|
||||||
# 最新价格缓存:看板读取小表 latest_price_cache,不再依赖 price_tracking 高频流水表
|
# 最新价格缓存:看板读取小表 latest_price_cache,不再依赖 price_tracking 高频流水表
|
||||||
update_latest_price_cache(symbol, current_price, source="tracker")
|
update_latest_price_cache(symbol, current_price, source="tracker")
|
||||||
|
|
||||||
# 基础盈亏跟踪
|
# 基础盈亏/最大涨幅记录。未入场样本只做观察绩效,不触发 TP/SL。
|
||||||
track_result = update_recommendation_tracking(rec["id"], current_price)
|
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增强:动态跟踪信号分析
|
# PA增强:动态跟踪信号分析
|
||||||
tracking_signals = analyze_tracking_signals(symbol, rec, current_price)
|
tracking_signals = analyze_tracking_signals(symbol, rec, current_price)
|
||||||
|
|
||||||
@ -368,6 +371,7 @@ def track_prices():
|
|||||||
output = {
|
output = {
|
||||||
"status": "tracked",
|
"status": "tracked",
|
||||||
"tracked_count": tracked_count,
|
"tracked_count": tracked_count,
|
||||||
|
"observed_count": observed_count,
|
||||||
"failed_count": len(failed_symbols),
|
"failed_count": len(failed_symbols),
|
||||||
"failed_symbols": failed_symbols,
|
"failed_symbols": failed_symbols,
|
||||||
"results": results,
|
"results": results,
|
||||||
@ -401,6 +405,7 @@ def main():
|
|||||||
finished_at = datetime.now()
|
finished_at = datetime.now()
|
||||||
summary = {
|
summary = {
|
||||||
"tracked_count": output.get("tracked_count", 0),
|
"tracked_count": output.get("tracked_count", 0),
|
||||||
|
"observed_count": output.get("observed_count", 0),
|
||||||
"failed_count": output.get("failed_count", 0),
|
"failed_count": output.get("failed_count", 0),
|
||||||
"active_count": output.get("stats", {}).get("active_count", 0),
|
"active_count": output.get("stats", {}).get("active_count", 0),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -92,6 +92,13 @@ screener:
|
|||||||
min_vol_ratio: 1.0
|
min_vol_ratio: 1.0
|
||||||
min_score: 2
|
min_score: 2
|
||||||
min_volume_24h: 2000000
|
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:
|
direct_accelerate:
|
||||||
enabled: true
|
enabled: true
|
||||||
min_static_count: 10
|
min_static_count: 10
|
||||||
|
|||||||
@ -114,6 +114,18 @@ def test_active_api_only_returns_actionable_recommendations(temp_db):
|
|||||||
rows = resp.json()
|
rows = resp.json()
|
||||||
assert {row['symbol'] for row in rows} == {'WAIT/USDT', 'BUY/USDT'}
|
assert {row['symbol'] for row in rows} == {'WAIT/USDT', 'BUY/USDT'}
|
||||||
assert {row['execution_status'] for row in rows} == {'buy_now', 'wait_pullback'}
|
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):
|
def test_stats_only_count_actionable_active_recommendations(temp_db):
|
||||||
|
|||||||
@ -58,6 +58,19 @@ def test_theme_without_price_trigger_is_research_trend():
|
|||||||
assert meta["max_action"] == "observe"
|
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():
|
def test_level_stop_and_tp_models_are_different():
|
||||||
stops = [90, 94, 96]
|
stops = [90, 94, 96]
|
||||||
intraday_stop, _ = select_level_stop_loss(level="intraday_breakout", price=100, entry_price=100, stop_candidates=stops)
|
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"]
|
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():
|
def test_quality_gate_caps_structure_watch_without_current_trigger():
|
||||||
meta = classify_opportunity_level(
|
meta = classify_opportunity_level(
|
||||||
signals=["日线 需求区反弹", "4H静K蓄力观察(4静K)"],
|
signals=["日线 需求区反弹", "4H静K蓄力观察(4静K)"],
|
||||||
|
|||||||
@ -139,7 +139,7 @@ def _insert_review(db_path, rec_id, review_time, outcome="爆发"):
|
|||||||
conn.close()
|
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 = sqlite3.connect(db_path)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
@ -148,7 +148,7 @@ def _insert_missed(db_path, detect_time):
|
|||||||
gain_pct, reason_missed, features_detected, lesson
|
gain_pct, reason_missed, features_detected, lesson
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
) 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.commit()
|
||||||
conn.close()
|
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"))
|
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_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)).isoformat(timespec="seconds"))
|
||||||
|
_insert_missed(temp_db, (base + timedelta(minutes=9, seconds=10)).isoformat(timespec="seconds"))
|
||||||
|
|
||||||
data = get_pipeline_runs(limit=10, hours=24)
|
data = get_pipeline_runs(limit=10, hours=24)
|
||||||
assert data["kpi"]["run_count"] == 1
|
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"]["fine"] == 1
|
||||||
assert detail["stage_counts"]["recommendation"] == 1
|
assert detail["stage_counts"]["recommendation"] == 1
|
||||||
assert detail["recommendations"][0]["performance_status"] == "success"
|
assert detail["recommendations"][0]["performance_status"] == "success"
|
||||||
|
assert len(detail["missed_explosions"]) == 1
|
||||||
assert detail["missed_explosions"][0]["symbol"] == "MISS/USDT"
|
assert detail["missed_explosions"][0]["symbol"] == "MISS/USDT"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@ if PROJECT_DIR not in sys.path:
|
|||||||
from app.services import price_tracker
|
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 = {
|
rec = {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"symbol": "QNT/USDT",
|
"symbol": "QNT/USDT",
|
||||||
@ -22,11 +22,15 @@ def test_watch_only_recommendation_is_skipped_before_take_profit_push(monkeypatc
|
|||||||
"execution_status": "observe",
|
"execution_status": "observe",
|
||||||
"action_status": "观察",
|
"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.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_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: {
|
monkeypatch.setattr(price_tracker, "analyze_tracking_signals", lambda symbol, rec, current_price: {
|
||||||
"action_status": "持有",
|
"action_status": "持有",
|
||||||
"sell_signals": [],
|
"sell_signals": [],
|
||||||
@ -34,11 +38,19 @@ def test_watch_only_recommendation_is_skipped_before_take_profit_push(monkeypatc
|
|||||||
"exhaustion": {"severity": "low"},
|
"exhaustion": {"severity": "low"},
|
||||||
"pnl_pct": 0.0,
|
"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, "expire_old_recommendations", lambda: None)
|
||||||
monkeypatch.setattr(price_tracker, "get_stats", lambda: {"active_count": 1})
|
monkeypatch.setattr(price_tracker, "get_stats", lambda: {"active_count": 1})
|
||||||
|
|
||||||
output = price_tracker.track_prices()
|
output = price_tracker.track_prices()
|
||||||
|
|
||||||
assert output["tracked_count"] == 0
|
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
|
||||||
|
|||||||
@ -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"])
|
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):
|
def test_strong_static_accumulation_can_promote_to_accelerate(monkeypatch):
|
||||||
monkeypatch.setattr(altcoin_screener, "get_dynamic_weights", _mock_weights)
|
monkeypatch.setattr(altcoin_screener, "get_dynamic_weights", _mock_weights)
|
||||||
monkeypatch.setattr(altcoin_screener, "state_score_thresholds", lambda: (8, 10, 3))
|
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 reject["detail"]["candidate_stage"] == "rejected_candidate"
|
||||||
assert "high_chase_risk" in reject["detail"]["reject_reason_codes"]
|
assert "high_chase_risk" in reject["detail"]["reject_reason_codes"]
|
||||||
assert "low_score" 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
|
||||||
|
|||||||
@ -22,8 +22,20 @@ def test_terminal_recommendation_action_status_cannot_be_overwritten_by_entry_si
|
|||||||
stop_loss=0.000521,
|
stop_loss=0.000521,
|
||||||
tp1=0.000684,
|
tp1=0.000684,
|
||||||
tp2=0.000726,
|
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)
|
altcoin_db.update_recommendation_tracking(rec_id, 0.000685)
|
||||||
|
|
||||||
# 即使后续动态入场逻辑误传“可即刻买入”,DB 层也必须保持止盈状态。
|
# 即使后续动态入场逻辑误传“可即刻买入”,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["execution_status"] == "observe"
|
||||||
assert rec["entry_triggered"] == 0
|
assert rec["entry_triggered"] == 0
|
||||||
assert rec["recommendation_result"] == "pending"
|
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"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user