first commit
This commit is contained in:
commit
d46f3e9801
17
.dockerignore
Normal file
17
.dockerignore
Normal file
@ -0,0 +1,17 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.git/
|
||||
logs/
|
||||
archive/
|
||||
backups/
|
||||
*.bak
|
||||
*.bak_*
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
data/
|
||||
/tmp/
|
||||
.DS_Store
|
||||
.env
|
||||
26
.env.example
Normal file
26
.env.example
Normal file
@ -0,0 +1,26 @@
|
||||
# AlphaX Docker 环境变量示例
|
||||
# 复制为 .env 后再按需填写:cp .env.example .env
|
||||
|
||||
# Web 服务端口由 docker-compose 映射为宿主机 8191 -> 容器 8190。
|
||||
PORT=8190
|
||||
|
||||
# 容器调度器默认 dry-run,避免首次启动就写库/推送。
|
||||
# 验证完成后再改为 0。
|
||||
ALPHAX_SCHEDULER_DRY_RUN=1
|
||||
|
||||
# SQLite DB 路径。容器内默认 /app/data/altcoin_monitor.db。
|
||||
ALPHAX_DB_PATH=/app/data/altcoin_monitor.db
|
||||
|
||||
# 飞书机器人 Webhook。不要把真实值提交到仓库。
|
||||
# ALTCOIN_FEISHU_WEBHOOK=https://open.feishu.cn/open-apis/bot/v2/hook/REDACTED
|
||||
ALTCOIN_FEISHU_WEBHOOK=
|
||||
|
||||
# 邮箱验证码 SMTP 配置。没有配置时,注册验证码只会生成,不会发邮件。
|
||||
ASTOCK_SMTP_HOST=
|
||||
ASTOCK_SMTP_PORT=465
|
||||
ASTOCK_SMTP_USERNAME=
|
||||
ASTOCK_SMTP_PASSWORD=
|
||||
ASTOCK_SMTP_SENDER=
|
||||
|
||||
# 可选:时区
|
||||
TZ=Asia/Shanghai
|
||||
66
.gitignore
vendored
Normal file
66
.gitignore
vendored
Normal file
@ -0,0 +1,66 @@
|
||||
# Python bytecode and caches
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Test, type-check, and coverage outputs
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.hypothesis/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
coverage.xml
|
||||
pytestdebug.log
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Local environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# SQLite and local runtime data
|
||||
*.db
|
||||
*.db-*
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
data/
|
||||
|
||||
# Logs and process artifacts
|
||||
logs/
|
||||
*.log
|
||||
*.out
|
||||
*.pid
|
||||
*.pid.lock
|
||||
|
||||
# Local backups and temporary patch files
|
||||
*.bak
|
||||
*.bak_*
|
||||
*.tmp
|
||||
*.tmp.*
|
||||
.tmp_*
|
||||
|
||||
# Editor and OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Build and packaging artifacts
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
|
||||
# Project-specific scratch / archive directories
|
||||
archive/
|
||||
backups/
|
||||
tmp/
|
||||
257
AGENTS.md
Normal file
257
AGENTS.md
Normal file
@ -0,0 +1,257 @@
|
||||
# AGENTS.md
|
||||
|
||||
## 1. 项目定位
|
||||
|
||||
AlphaX 是一个以 `Python + FastAPI + SQLite + 静态 HTML` 组成的加密市场机会监控系统,当前核心目标不是做完整交易执行,而是围绕“发现机会 -> 确认机会 -> 跟踪机会 -> 复盘迭代”建立一套可持续优化的研究与提示闭环。
|
||||
|
||||
当前仓库是一个 **Docker 化副本**,从 `README_DOCKER.md` 的描述看,它被设计为和线上主实例隔离运行,默认端口、数据库挂载、调度器行为都以“先验证、后放量”为原则。
|
||||
|
||||
## 2. 当前技术栈
|
||||
|
||||
- 后端:`FastAPI`, `uvicorn`, `pydantic`
|
||||
- 数据与计算:`sqlite3`, `pandas`, `numpy`
|
||||
- 交易所/行情:`ccxt`, `requests`
|
||||
- 配置:`rules.yaml` + `config_loader.py`
|
||||
- 测试:`pytest` / `unittest`
|
||||
- 部署:`Dockerfile`, `docker-compose.yml`
|
||||
- 前端:`static/*.html` 模板页,由 FastAPI/Jinja2 提供页面壳和 API
|
||||
|
||||
## 3. 代码主线
|
||||
|
||||
### 3.1 业务闭环
|
||||
|
||||
建议把系统理解为 6 个层次:
|
||||
|
||||
1. `altcoin_screener.py`
|
||||
负责粗筛,基于 Binance 行情、量价/结构等规则找候选币。
|
||||
2. `altcoin_confirm.py`
|
||||
负责确认,判断是否形成更可执行的机会,并生成入场计划、上下文和推送候选。
|
||||
3. `price_tracker.py`
|
||||
负责跟踪活跃推荐,更新盈亏、止盈止损、趋势衰减、行动状态。
|
||||
4. `review_engine.py`
|
||||
负责复盘与策略自迭代,包括信号绩效、漏选复盘、规则候选、版本演进。
|
||||
5. `event_driven_screener.py`
|
||||
负责事件/舆情驱动的快速触发检查,属于技术筛选主链路的补充入口。
|
||||
6. `web_server.py`
|
||||
负责用户端和管理端 API、页面壳、订阅与认证相关接口。
|
||||
|
||||
### 3.2 数据与状态中心
|
||||
|
||||
- `altcoin_db.py` 是交易/推荐/状态的核心数据库层,体量很大,承担了:
|
||||
- 初始化表结构
|
||||
- recommendation / screening_log / tracking / review 等主表读写
|
||||
- 推荐状态派生与展示口径整理
|
||||
- 部分状态迁移与兼容逻辑
|
||||
- `auth_db.py` 是会员、邀请码、邮箱验证、订阅、订单预留的数据库层。
|
||||
- `opportunity_lifecycle.py` 是机会生命周期和买点质量闸门的规则中心,决定:
|
||||
- 哪些机会只是观察池
|
||||
- 哪些机会可以进入“可即刻买入”
|
||||
- 哪些状态应该被视为历史/盈利管理/观察态
|
||||
|
||||
### 3.3 配置中心
|
||||
|
||||
- `rules.yaml` 是策略配置单一事实源。
|
||||
- `config_loader.py` 负责:
|
||||
- 读取/缓存配置
|
||||
- 暴露各子模块配置访问函数
|
||||
- 将部分复盘后的参数改写回 `rules.yaml`
|
||||
- 兼容旧信号名
|
||||
|
||||
如果要改筛选阈值、确认门槛、止盈止损、动态权重逻辑,优先检查 `rules.yaml` 和 `config_loader.py`,不要直接在业务脚本里硬编码新参数。
|
||||
|
||||
## 4. 目录速览
|
||||
|
||||
### 4.1 核心目录
|
||||
|
||||
- `/static`
|
||||
- 页面文件,如 `app.html`, `auth.html`, `subscription.html`, `strategy.html`
|
||||
- `/tests`
|
||||
- 针对状态机、认证订阅、复盘、事件驱动、策略版本等的回归测试
|
||||
- `/scripts`
|
||||
- 若干结构/状态机/信号时效性校验脚本
|
||||
- `/docker`
|
||||
- 容器入口与串行调度器
|
||||
- `/data`
|
||||
- SQLite 数据库存放目录
|
||||
- `/logs`
|
||||
- 运行日志目录
|
||||
- `/docs`
|
||||
- 当前只有少量专题文档,不是完整系统文档中心
|
||||
|
||||
### 4.2 根目录关键文件
|
||||
|
||||
- [web_server.py](/Users/aaron/Desktop/code/alphax-docker/web_server.py)
|
||||
- [altcoin_db.py](/Users/aaron/Desktop/code/alphax-docker/altcoin_db.py)
|
||||
- [auth_db.py](/Users/aaron/Desktop/code/alphax-docker/auth_db.py)
|
||||
- [altcoin_screener.py](/Users/aaron/Desktop/code/alphax-docker/altcoin_screener.py)
|
||||
- [altcoin_confirm.py](/Users/aaron/Desktop/code/alphax-docker/altcoin_confirm.py)
|
||||
- [price_tracker.py](/Users/aaron/Desktop/code/alphax-docker/price_tracker.py)
|
||||
- [review_engine.py](/Users/aaron/Desktop/code/alphax-docker/review_engine.py)
|
||||
- [event_driven_screener.py](/Users/aaron/Desktop/code/alphax-docker/event_driven_screener.py)
|
||||
- [opportunity_lifecycle.py](/Users/aaron/Desktop/code/alphax-docker/opportunity_lifecycle.py)
|
||||
- [rules.yaml](/Users/aaron/Desktop/code/alphax-docker/rules.yaml)
|
||||
- [config_loader.py](/Users/aaron/Desktop/code/alphax-docker/config_loader.py)
|
||||
- [docker-compose.yml](/Users/aaron/Desktop/code/alphax-docker/docker-compose.yml)
|
||||
- [README_DOCKER.md](/Users/aaron/Desktop/code/alphax-docker/README_DOCKER.md)
|
||||
|
||||
## 5. Web/API 观察
|
||||
|
||||
`web_server.py` 体量非常大,既包含:
|
||||
|
||||
- 认证接口
|
||||
- 订阅接口
|
||||
- 推荐/筛选/复盘/策略看板接口
|
||||
- 新闻/舆情接口
|
||||
- 管理端接口
|
||||
- 页面路由
|
||||
|
||||
如果要改 Web 逻辑,先确认变更属于哪一类:
|
||||
|
||||
- “数据口径问题”优先去 `altcoin_db.py` / `opportunity_lifecycle.py`
|
||||
- “参数问题”优先去 `rules.yaml`
|
||||
- “页面展示问题”再回到 `static/*.html`
|
||||
|
||||
不要第一反应直接在 `web_server.py` 里堆业务分支,否则会继续放大这个文件的复杂度。
|
||||
|
||||
## 6. 调度与运行方式
|
||||
|
||||
### 6.1 Docker 运行
|
||||
|
||||
主要服务:
|
||||
|
||||
- `alphax-web`
|
||||
- 对外提供 FastAPI + 页面
|
||||
- 宿主机默认映射 `8191 -> 8190`
|
||||
- `alphax-scheduler`
|
||||
- 串行调度任务
|
||||
- 默认 `ALPHAX_SCHEDULER_DRY_RUN=1`
|
||||
|
||||
关键原则:
|
||||
|
||||
- 调度器是 **串行** 执行任务,用来规避 SQLite 写锁冲突。
|
||||
- 副本默认不应影响线上实例。
|
||||
- 数据库挂载路径是 `./data/altcoin_monitor.db`。
|
||||
|
||||
### 6.2 入口
|
||||
|
||||
- `docker/entrypoint.sh`
|
||||
- `web` -> 启动 uvicorn
|
||||
- `scheduler` -> 启动 `docker/scheduler.py`
|
||||
- `once` -> 执行单次脚本
|
||||
- `docker/scheduler.py`
|
||||
- 统一调度 `event_driven_screener.py`
|
||||
- `price_tracker.py`
|
||||
- `altcoin_confirm.py`
|
||||
- `altcoin_screener.py`
|
||||
- `sentiment_monitor.py`
|
||||
- `review_engine.py`
|
||||
|
||||
## 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 推荐命令
|
||||
|
||||
常用回归命令:
|
||||
|
||||
```bash
|
||||
pytest -q
|
||||
python3 scripts/validate_docker_layout.py
|
||||
python3 scripts/validate_state_machine.py
|
||||
python3 scripts/validate_push_state_flow.py
|
||||
python3 scripts/validate_signal_recency.py
|
||||
```
|
||||
|
||||
如果只是小范围修改,优先跑和改动模块最相关的测试文件,不要盲目只看 `pytest` 是否全绿。
|
||||
|
||||
## 8. 开发守则
|
||||
|
||||
### 8.1 改动前先判断“应该改哪一层”
|
||||
|
||||
- 调参数:优先 `rules.yaml`
|
||||
- 配置读取/兼容:`config_loader.py`
|
||||
- 状态口径:`opportunity_lifecycle.py` 或 `altcoin_db.py`
|
||||
- DB 表结构/查询:`altcoin_db.py` / `auth_db.py`
|
||||
- API 契约:`web_server.py`
|
||||
- 页面壳和交互:`static/*.html`
|
||||
|
||||
### 8.2 SQLite 相关约束
|
||||
|
||||
这个项目高度依赖 SQLite,因此要特别注意:
|
||||
|
||||
- 避免引入并发写入路径
|
||||
- 避免在不同脚本里制造长事务
|
||||
- 任何新增 cron/task 设计,都先考虑是否会和现有调度冲突
|
||||
- 新增写操作时,优先复用已有 `get_conn()` 约定
|
||||
|
||||
### 8.3 状态机不要各写各的
|
||||
|
||||
项目已经存在比较强的“状态派生中心化”趋势:
|
||||
|
||||
- `normalize_action_status`
|
||||
- `derive_display_bucket`
|
||||
- `apply_entry_quality_gate`
|
||||
- `apply_recommendation_state_transition`
|
||||
|
||||
新增状态时,必须检查:
|
||||
|
||||
1. DB 中的原始状态字段怎么存
|
||||
2. API 输出怎么派生
|
||||
3. 前端如何解释
|
||||
4. 推送是否会误触发
|
||||
5. 相关测试是否需要补齐
|
||||
|
||||
### 8.4 面向兼容开发
|
||||
|
||||
这个仓库里有不少“迭代中兼容旧逻辑”的痕迹,例如:
|
||||
|
||||
- `config_loader.py` 中的信号别名兼容
|
||||
- `altcoin_db.py` 中大量 `ALTER TABLE` 迁移兜底
|
||||
- 多处 `entry_plan_json` / `detail_json` / `*_context_json`
|
||||
|
||||
因此改动时应优先做增量兼容,而不是假设数据库、配置、旧数据永远是干净新鲜的。
|
||||
|
||||
## 9. 当前仓库的几个事实
|
||||
|
||||
- 当前目录 **不是 git 仓库根目录**,`git status` 会失败;如果后续需要版本管理,请先确认真正的 Git 根目录或重新初始化。
|
||||
- [DESIGN.md](/Users/aaron/Desktop/code/alphax-docker/DESIGN.md) 当前内容更像一份品牌/样式 YAML,不是这个项目的系统设计文档,阅读时不要误判。
|
||||
- 根目录存在一些临时/非核心文件,例如 `.tmp_patch_tp1.py`、`.tmp_strategy_v2_marker.txt`、`=`,后续开发前建议先确认这些文件是否仍有保留价值。
|
||||
- `web_server.py` 和 `altcoin_db.py` 都已经非常大,后续新增功能应尽量避免继续把复杂度集中到这两个文件。
|
||||
|
||||
## 10. 推荐的后续重构方向
|
||||
|
||||
后续若继续开发,建议优先考虑这几个方向:
|
||||
|
||||
1. 把 `web_server.py` 按认证、推荐、策略、管理端拆分路由模块。
|
||||
2. 把 `altcoin_db.py` 拆成 schema/init、recommendation、review、analytics、admin 查询几个子模块。
|
||||
3. 为 `rules.yaml` 建立更明确的 schema 校验,避免配置漂移。
|
||||
4. 给核心脚本增加更稳定的 CLI 参数入口,而不是依赖脚本内默认行为。
|
||||
5. 梳理推送链路,把“是否推送”的判断和“推送内容生成”进一步解耦。
|
||||
|
||||
## 11. 给后续 Agent 的工作方式建议
|
||||
|
||||
接手这个仓库时,优先按下面顺序理解问题:
|
||||
|
||||
1. 先确认问题发生在“筛选 / 确认 / 跟踪 / 复盘 / Web 展示 / 认证订阅”哪一层。
|
||||
2. 再确认它属于“参数失真、状态派生错误、DB 查询口径不一致、前端展示错误、任务调度副作用”中的哪一类。
|
||||
3. 修改后至少补 1 个相关测试,最好补到最接近业务口径的那层。
|
||||
4. 如果变更影响推荐状态或展示桶,务必同时检查 API、前端、推送、历史统计四个面。
|
||||
|
||||
---
|
||||
|
||||
这份文档以当前仓库实际代码为准整理。如果后续完成模块拆分、引入新的持久层或把静态页面改造成更现代的前端工程,记得同步更新本文件,而不是让它变成另一份过时说明书。
|
||||
825
DESIGN.md
Normal file
825
DESIGN.md
Normal file
@ -0,0 +1,825 @@
|
||||
---
|
||||
version: alpha
|
||||
name: Miro
|
||||
description: Miro presents itself as the AI-powered visual workspace through a confident, almost playful brand voice — anchored by its signature canary yellow ({colors.brand-yellow}) wordmark over white canvas, broken open by colorful pastel feature tints (rose, teal, coral, orange, mint) that echo the actual sticky-note color palette used on the live whiteboard. Black-pill primary buttons dominate marketing, real Miro-board mockups serve as feature illustrations, and a 4-tier pricing grid leads into a dense comparison table. Roobert PRO carries display headlines; the system supports homepage, pricing, AI Workflows product page, agile vertical, and customer stories surfaces.
|
||||
|
||||
colors:
|
||||
primary: "#1c1c1e"
|
||||
on-primary: "#ffffff"
|
||||
brand-yellow: "#ffd02f"
|
||||
brand-yellow-deep: "#fcb900"
|
||||
yellow-light: "#fff4c4"
|
||||
yellow-dark: "#746019"
|
||||
brand-blue: "#4262ff"
|
||||
blue-450: "#5b76fe"
|
||||
blue-pressed: "#2a41b6"
|
||||
brand-coral: "#ff9999"
|
||||
coral-light: "#ffc6c6"
|
||||
coral-dark: "#600000"
|
||||
brand-rose: "#ffd8f4"
|
||||
rose-light: "#fde0f0"
|
||||
brand-pink: "#fde0f0"
|
||||
brand-teal: "#0fbcb0"
|
||||
teal-light: "#c3faf5"
|
||||
moss-dark: "#187574"
|
||||
brand-orange-light: "#ffe6cd"
|
||||
brand-red: "#fbd4d4"
|
||||
brand-red-dark: "#e3c5c5"
|
||||
success-accent: "#00b473"
|
||||
canvas: "#ffffff"
|
||||
surface: "#f7f8fa"
|
||||
surface-soft: "#fafbfc"
|
||||
surface-yellow: "#fff8e0"
|
||||
surface-pricing-featured: "#f5f3ff"
|
||||
hairline: "#e0e2e8"
|
||||
hairline-soft: "#eef0f3"
|
||||
hairline-strong: "#c7cad5"
|
||||
ink-deep: "#050038"
|
||||
ink: "#1c1c1e"
|
||||
charcoal: "#2c2c34"
|
||||
slate: "#555a6a"
|
||||
steel: "#6b6f7e"
|
||||
stone: "#8e91a0"
|
||||
muted: "#a5a8b5"
|
||||
on-dark: "#ffffff"
|
||||
on-dark-muted: "#a5a8b5"
|
||||
footer-bg: "#1c1c1e"
|
||||
|
||||
typography:
|
||||
hero-display:
|
||||
fontFamily: Roobert PRO
|
||||
fontSize: 80px
|
||||
fontWeight: 500
|
||||
lineHeight: 1.05
|
||||
letterSpacing: -2px
|
||||
display-lg:
|
||||
fontFamily: Roobert PRO
|
||||
fontSize: 60px
|
||||
fontWeight: 500
|
||||
lineHeight: 1.10
|
||||
letterSpacing: -1.5px
|
||||
heading-1:
|
||||
fontFamily: Roobert PRO
|
||||
fontSize: 48px
|
||||
fontWeight: 500
|
||||
lineHeight: 1.15
|
||||
letterSpacing: -1px
|
||||
heading-2:
|
||||
fontFamily: Roobert PRO
|
||||
fontSize: 36px
|
||||
fontWeight: 500
|
||||
lineHeight: 1.20
|
||||
letterSpacing: -0.5px
|
||||
heading-3:
|
||||
fontFamily: Roobert PRO
|
||||
fontSize: 28px
|
||||
fontWeight: 500
|
||||
lineHeight: 1.25
|
||||
heading-4:
|
||||
fontFamily: Roobert PRO
|
||||
fontSize: 22px
|
||||
fontWeight: 500
|
||||
lineHeight: 1.30
|
||||
heading-5:
|
||||
fontFamily: Roobert PRO
|
||||
fontSize: 18px
|
||||
fontWeight: 500
|
||||
lineHeight: 1.40
|
||||
subtitle:
|
||||
fontFamily: Roobert PRO
|
||||
fontSize: 18px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.50
|
||||
body-md:
|
||||
fontFamily: Roobert PRO
|
||||
fontSize: 16px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.50
|
||||
body-md-medium:
|
||||
fontFamily: Roobert PRO
|
||||
fontSize: 16px
|
||||
fontWeight: 500
|
||||
lineHeight: 1.50
|
||||
body-sm:
|
||||
fontFamily: Roobert PRO
|
||||
fontSize: 14px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.50
|
||||
body-sm-medium:
|
||||
fontFamily: Roobert PRO
|
||||
fontSize: 14px
|
||||
fontWeight: 500
|
||||
lineHeight: 1.50
|
||||
caption:
|
||||
fontFamily: Roobert PRO
|
||||
fontSize: 13px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.40
|
||||
caption-bold:
|
||||
fontFamily: Roobert PRO
|
||||
fontSize: 13px
|
||||
fontWeight: 600
|
||||
lineHeight: 1.40
|
||||
micro:
|
||||
fontFamily: Roobert PRO
|
||||
fontSize: 12px
|
||||
fontWeight: 500
|
||||
lineHeight: 1.40
|
||||
micro-uppercase:
|
||||
fontFamily: Roobert PRO
|
||||
fontSize: 11px
|
||||
fontWeight: 600
|
||||
lineHeight: 1.40
|
||||
letterSpacing: 0.5px
|
||||
button-md:
|
||||
fontFamily: Roobert PRO
|
||||
fontSize: 14px
|
||||
fontWeight: 500
|
||||
lineHeight: 1.30
|
||||
stat-display:
|
||||
fontFamily: Roobert PRO
|
||||
fontSize: 64px
|
||||
fontWeight: 500
|
||||
lineHeight: 1.10
|
||||
letterSpacing: -1.5px
|
||||
|
||||
rounded:
|
||||
xs: 4px
|
||||
sm: 6px
|
||||
md: 8px
|
||||
lg: 12px
|
||||
xl: 16px
|
||||
xxl: 20px
|
||||
xxxl: 28px
|
||||
feature: 32px
|
||||
full: 9999px
|
||||
|
||||
spacing:
|
||||
xxs: 4px
|
||||
xs: 8px
|
||||
sm: 12px
|
||||
md: 16px
|
||||
lg: 20px
|
||||
xl: 24px
|
||||
xxl: 32px
|
||||
xxxl: 40px
|
||||
section-sm: 48px
|
||||
section: 64px
|
||||
section-lg: 96px
|
||||
hero: 120px
|
||||
|
||||
components:
|
||||
button-primary:
|
||||
backgroundColor: "{colors.primary}"
|
||||
textColor: "{colors.on-primary}"
|
||||
typography: "{typography.button-md}"
|
||||
rounded: "{rounded.full}"
|
||||
padding: "12px 24px"
|
||||
button-primary-pressed:
|
||||
backgroundColor: "{colors.charcoal}"
|
||||
textColor: "{colors.on-primary}"
|
||||
button-primary-disabled:
|
||||
backgroundColor: "{colors.hairline}"
|
||||
textColor: "{colors.muted}"
|
||||
button-yellow:
|
||||
backgroundColor: "{colors.brand-yellow}"
|
||||
textColor: "{colors.primary}"
|
||||
typography: "{typography.button-md}"
|
||||
rounded: "{rounded.full}"
|
||||
padding: "12px 24px"
|
||||
button-blue:
|
||||
backgroundColor: "{colors.brand-blue}"
|
||||
textColor: "{colors.on-primary}"
|
||||
typography: "{typography.button-md}"
|
||||
rounded: "{rounded.full}"
|
||||
padding: "12px 24px"
|
||||
button-secondary:
|
||||
backgroundColor: "transparent"
|
||||
textColor: "{colors.ink}"
|
||||
typography: "{typography.button-md}"
|
||||
rounded: "{rounded.full}"
|
||||
padding: "12px 24px"
|
||||
border: "1px solid {colors.hairline-strong}"
|
||||
button-on-dark:
|
||||
backgroundColor: "{colors.on-dark}"
|
||||
textColor: "{colors.primary}"
|
||||
typography: "{typography.button-md}"
|
||||
rounded: "{rounded.full}"
|
||||
padding: "12px 24px"
|
||||
button-ghost:
|
||||
backgroundColor: "transparent"
|
||||
textColor: "{colors.ink}"
|
||||
typography: "{typography.button-md}"
|
||||
rounded: "{rounded.md}"
|
||||
padding: "8px 12px"
|
||||
button-link:
|
||||
backgroundColor: "transparent"
|
||||
textColor: "{colors.brand-blue}"
|
||||
typography: "{typography.body-sm-medium}"
|
||||
padding: "0"
|
||||
button-icon-circular:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
textColor: "{colors.ink}"
|
||||
rounded: "{rounded.full}"
|
||||
size: 36px
|
||||
border: "1px solid {colors.hairline}"
|
||||
card-base:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
rounded: "{rounded.xl}"
|
||||
padding: "{spacing.xl}"
|
||||
border: "1px solid {colors.hairline-soft}"
|
||||
card-feature:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
rounded: "{rounded.xxxl}"
|
||||
padding: "{spacing.xxl}"
|
||||
border: "1px solid {colors.hairline-soft}"
|
||||
card-feature-yellow:
|
||||
backgroundColor: "{colors.brand-yellow}"
|
||||
textColor: "{colors.primary}"
|
||||
rounded: "{rounded.xxxl}"
|
||||
padding: "{spacing.xxl}"
|
||||
card-feature-coral:
|
||||
backgroundColor: "{colors.coral-light}"
|
||||
textColor: "{colors.primary}"
|
||||
rounded: "{rounded.xxxl}"
|
||||
padding: "{spacing.xxl}"
|
||||
card-feature-teal:
|
||||
backgroundColor: "{colors.teal-light}"
|
||||
textColor: "{colors.primary}"
|
||||
rounded: "{rounded.xxxl}"
|
||||
padding: "{spacing.xxl}"
|
||||
card-feature-rose:
|
||||
backgroundColor: "{colors.rose-light}"
|
||||
textColor: "{colors.primary}"
|
||||
rounded: "{rounded.xxxl}"
|
||||
padding: "{spacing.xxl}"
|
||||
card-customer-story:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
rounded: "{rounded.xxxl}"
|
||||
padding: "0"
|
||||
border: "1px solid {colors.hairline-soft}"
|
||||
card-stat:
|
||||
backgroundColor: "transparent"
|
||||
textColor: "{colors.ink}"
|
||||
typography: "{typography.stat-display}"
|
||||
padding: "{spacing.lg}"
|
||||
pricing-card:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
rounded: "{rounded.xl}"
|
||||
padding: "{spacing.xxl}"
|
||||
border: "1px solid {colors.hairline}"
|
||||
pricing-card-featured:
|
||||
backgroundColor: "{colors.surface-pricing-featured}"
|
||||
rounded: "{rounded.xl}"
|
||||
padding: "{spacing.xxl}"
|
||||
border: "2px solid {colors.brand-blue}"
|
||||
pricing-card-enterprise:
|
||||
backgroundColor: "{colors.primary}"
|
||||
textColor: "{colors.on-primary}"
|
||||
rounded: "{rounded.xl}"
|
||||
padding: "{spacing.xxl}"
|
||||
text-input:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
textColor: "{colors.ink}"
|
||||
typography: "{typography.body-md}"
|
||||
rounded: "{rounded.md}"
|
||||
padding: "{spacing.sm} {spacing.md}"
|
||||
border: "1px solid {colors.hairline-strong}"
|
||||
height: 44px
|
||||
text-input-focused:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
textColor: "{colors.ink}"
|
||||
border: "2px solid {colors.brand-blue}"
|
||||
search-pill:
|
||||
backgroundColor: "{colors.surface}"
|
||||
textColor: "{colors.steel}"
|
||||
typography: "{typography.body-sm}"
|
||||
rounded: "{rounded.md}"
|
||||
padding: "{spacing.xs} {spacing.md}"
|
||||
height: 40px
|
||||
border: "1px solid {colors.hairline}"
|
||||
filter-dropdown:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
textColor: "{colors.ink}"
|
||||
typography: "{typography.body-sm-medium}"
|
||||
rounded: "{rounded.full}"
|
||||
padding: "{spacing.xs} {spacing.md}"
|
||||
border: "1px solid {colors.hairline-strong}"
|
||||
pill-tab:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
textColor: "{colors.steel}"
|
||||
typography: "{typography.body-sm-medium}"
|
||||
rounded: "{rounded.full}"
|
||||
padding: "{spacing.xs} {spacing.md}"
|
||||
border: "1px solid {colors.hairline}"
|
||||
pill-tab-active:
|
||||
backgroundColor: "{colors.primary}"
|
||||
textColor: "{colors.on-primary}"
|
||||
rounded: "{rounded.full}"
|
||||
border: "1px solid {colors.primary}"
|
||||
toggle-monthly-yearly:
|
||||
backgroundColor: "{colors.surface}"
|
||||
textColor: "{colors.ink}"
|
||||
rounded: "{rounded.full}"
|
||||
padding: "4px"
|
||||
badge-promo:
|
||||
backgroundColor: "{colors.brand-yellow}"
|
||||
textColor: "{colors.primary}"
|
||||
typography: "{typography.caption-bold}"
|
||||
rounded: "{rounded.full}"
|
||||
padding: "4px 10px"
|
||||
badge-tag-yellow:
|
||||
backgroundColor: "{colors.surface-yellow}"
|
||||
textColor: "{colors.yellow-dark}"
|
||||
typography: "{typography.caption-bold}"
|
||||
rounded: "{rounded.full}"
|
||||
padding: "4px 10px"
|
||||
badge-tag-purple:
|
||||
backgroundColor: "{colors.surface-pricing-featured}"
|
||||
textColor: "{colors.brand-blue}"
|
||||
typography: "{typography.caption-bold}"
|
||||
rounded: "{rounded.full}"
|
||||
padding: "4px 10px"
|
||||
badge-tag-coral:
|
||||
backgroundColor: "{colors.coral-light}"
|
||||
textColor: "{colors.coral-dark}"
|
||||
typography: "{typography.caption-bold}"
|
||||
rounded: "{rounded.full}"
|
||||
padding: "4px 10px"
|
||||
badge-success:
|
||||
backgroundColor: "{colors.success-accent}"
|
||||
textColor: "{colors.on-primary}"
|
||||
typography: "{typography.caption-bold}"
|
||||
rounded: "{rounded.full}"
|
||||
padding: "4px 10px"
|
||||
badge-discount:
|
||||
backgroundColor: "{colors.brand-yellow}"
|
||||
textColor: "{colors.primary}"
|
||||
typography: "{typography.caption-bold}"
|
||||
rounded: "{rounded.sm}"
|
||||
padding: "2px 6px"
|
||||
promo-banner:
|
||||
backgroundColor: "{colors.primary}"
|
||||
textColor: "{colors.on-primary}"
|
||||
typography: "{typography.body-sm-medium}"
|
||||
padding: "{spacing.sm} {spacing.md}"
|
||||
comparison-table:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
textColor: "{colors.ink}"
|
||||
typography: "{typography.body-sm}"
|
||||
rounded: "{rounded.md}"
|
||||
border: "1px solid {colors.hairline}"
|
||||
comparison-row:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
textColor: "{colors.ink}"
|
||||
padding: "{spacing.md} {spacing.lg}"
|
||||
border: "0 0 1px {colors.hairline-soft} solid"
|
||||
template-card:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
rounded: "{rounded.xl}"
|
||||
padding: "{spacing.md}"
|
||||
border: "1px solid {colors.hairline}"
|
||||
whiteboard-mockup:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
rounded: "{rounded.xl}"
|
||||
padding: "0"
|
||||
border: "1px solid {colors.hairline-soft}"
|
||||
shadow: "rgba(5, 0, 56, 0.08) 0px 12px 32px -4px"
|
||||
faq-accordion-item:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
rounded: "{rounded.md}"
|
||||
padding: "{spacing.xl}"
|
||||
border: "0 0 1px {colors.hairline} solid"
|
||||
logo-wall-item:
|
||||
backgroundColor: "transparent"
|
||||
textColor: "{colors.steel}"
|
||||
typography: "{typography.body-md-medium}"
|
||||
padding: "{spacing.lg}"
|
||||
hero-band-marketing:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
textColor: "{colors.ink}"
|
||||
typography: "{typography.hero-display}"
|
||||
rounded: "0"
|
||||
padding: "{spacing.hero}"
|
||||
cta-banner-dark:
|
||||
backgroundColor: "{colors.primary}"
|
||||
textColor: "{colors.on-primary}"
|
||||
rounded: "{rounded.feature}"
|
||||
padding: "{spacing.section}"
|
||||
industry-tile:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
rounded: "{rounded.xl}"
|
||||
padding: "{spacing.xl}"
|
||||
border: "1px solid {colors.hairline-soft}"
|
||||
capterra-badge:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
textColor: "{colors.ink}"
|
||||
typography: "{typography.caption}"
|
||||
rounded: "{rounded.md}"
|
||||
padding: "{spacing.sm} {spacing.md}"
|
||||
border: "1px solid {colors.hairline}"
|
||||
footer-region:
|
||||
backgroundColor: "{colors.footer-bg}"
|
||||
textColor: "{colors.on-dark}"
|
||||
typography: "{typography.body-sm}"
|
||||
padding: "{spacing.section} {spacing.xxl}"
|
||||
footer-link:
|
||||
backgroundColor: "transparent"
|
||||
textColor: "{colors.on-dark-muted}"
|
||||
typography: "{typography.body-sm}"
|
||||
padding: "{spacing.xxs} 0"
|
||||
app-store-badge:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
textColor: "{colors.primary}"
|
||||
typography: "{typography.caption-bold}"
|
||||
rounded: "{rounded.md}"
|
||||
padding: "{spacing.sm} {spacing.md}"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Miro positions itself as the AI-powered visual workspace through a confident, slightly playful brand voice. The homepage opens with a stark white canvas anchored by a small canary-yellow Miro wordmark in the top-left, a black-pill primary CTA "Get started free" and a secondary "Book a demo" outline pill — then dramatic real-Miro-board mockup imagery (sticky notes, kanban, mind maps) carries the visual weight. Across deeper surfaces, the system breaks open: pastel feature cards (rose, teal, coral, yellow) echo the actual sticky-note color palette of the live whiteboard product, and customer story cards reuse those tints to differentiate brand vignettes.
|
||||
|
||||
Roobert PRO — Miro's custom display face — anchors every typographic surface, from the 80px hero display down to 11px micro labels. The face's slightly rounded, geometric character pairs naturally with the playful product photography and the friendly product positioning. Black-pill primary buttons (`{rounded.full}`) dominate marketing CTAs; the brand color, signature canary yellow ({colors.brand-yellow}), is reserved for the wordmark, top promo banners, and "yellow tag" feature pills — never as a primary CTA. The 4-tier pricing comparison (Free / Starter / Business / Enterprise) leads into the densest surface in the system: a feature comparison table that runs ~80 rows deep across multiple section dividers.
|
||||
|
||||
**Key Characteristics:**
|
||||
- Stark white canvas + Miro wordmark in canary yellow ({colors.brand-yellow}) as the recognizable opening signature
|
||||
- Black-pill primary CTAs ({colors.primary} + `{rounded.full}`) as the dominant interactive element
|
||||
- Pastel feature cards (yellow, rose, coral, teal, mint) that echo the actual sticky-note palette
|
||||
- Roobert PRO across every UI surface; geometric, slightly rounded character
|
||||
- Real Miro-board mockup imagery used as feature illustrations
|
||||
- 4-tier pricing card grid + dense feature comparison table
|
||||
- Massive dark footer ({colors.footer-bg}) with multi-column links + app-store badges
|
||||
|
||||
## Colors
|
||||
|
||||
> Source pages: miro.com/ (homepage), /pricing/ (4-tier comparison), /products/ai-workflows/ (AI product), /agile/ (vertical landing), /customers/ (story directory). Token coverage was identical across all five pages.
|
||||
|
||||
### Brand & Accent
|
||||
- **Miro Yellow** ({colors.brand-yellow}): The brand's recognizable canary yellow — wordmark color, top promo banner, "yellow tag" pills
|
||||
- **Yellow Deep** ({colors.brand-yellow-deep}): Darker variant for hover states and emphasis
|
||||
- **Yellow Light** ({colors.yellow-light}): Pale yellow background tint for tag chips
|
||||
- **Yellow Dark** ({colors.yellow-dark}): Yellow-tag text color (dark olive) for chip foreground
|
||||
- **Brand Blue** ({colors.brand-blue}): Action blue for inline links and featured-pricing-tier border
|
||||
- **Blue Pressed** ({colors.blue-pressed}): Pressed-state blue
|
||||
- **Brand Coral** ({colors.brand-coral}): Coral accent for warm callouts
|
||||
- **Coral Light** ({colors.coral-light}): Pale coral for feature card backgrounds
|
||||
- **Coral Dark** ({colors.coral-dark}): Coral-tag text color (deep wine)
|
||||
- **Brand Rose** ({colors.brand-rose}): Soft rose-pink for feature card variants
|
||||
- **Brand Teal** ({colors.brand-teal}): Brand teal
|
||||
- **Teal Light** ({colors.teal-light}): Pale teal for feature card backgrounds
|
||||
- **Moss Dark** ({colors.moss-dark}): Deep teal-green text color
|
||||
- **Brand Pink** ({colors.brand-pink}): Pale pink for soft callouts
|
||||
- **Brand Orange Light** ({colors.brand-orange-light}): Soft orange for feature card backgrounds
|
||||
|
||||
### Surface
|
||||
- **Canvas White** ({colors.canvas}): Page background and primary card surface
|
||||
- **Surface** ({colors.surface}): Subtle section backgrounds, search-pill rest
|
||||
- **Surface Soft** ({colors.surface-soft}): Quieter section divisions
|
||||
- **Surface Yellow** ({colors.surface-yellow}): Pale yellow-tinted surface for tag chip
|
||||
- **Surface Pricing Featured** ({colors.surface-pricing-featured}): Pale lavender for featured pricing tier
|
||||
- **Hairline** ({colors.hairline}): 1px borders and primary dividers
|
||||
- **Hairline Soft** ({colors.hairline-soft}): Quieter table-row dividers
|
||||
- **Hairline Strong** ({colors.hairline-strong}): Stronger 1px border for inputs
|
||||
|
||||
### Text
|
||||
- **Ink Deep** ({colors.ink-deep}): Headlines on lighter feature cards
|
||||
- **Ink** ({colors.ink}): Primary headlines and body text
|
||||
- **Charcoal** ({colors.charcoal}): Body emphasis text
|
||||
- **Slate** ({colors.slate}): Secondary text, metadata
|
||||
- **Steel** ({colors.steel}): Tertiary text, footer links
|
||||
- **Stone** ({colors.stone}): Captions, muted labels
|
||||
- **Muted** ({colors.muted}): Disabled labels, input placeholders
|
||||
- **On Dark** ({colors.on-dark}): White text on dark surfaces
|
||||
- **On Dark Muted** ({colors.on-dark-muted}): Reduced-opacity white on dark
|
||||
|
||||
### Semantic
|
||||
- **Success Accent** ({colors.success-accent}): Confirmation/success indicator green
|
||||
- **Brand Red** ({colors.brand-red}): Soft red for error backgrounds
|
||||
- **Brand Red Dark** ({colors.brand-red-dark}): Stronger red for error borders
|
||||
|
||||
## Typography
|
||||
|
||||
### Font Family
|
||||
**Roobert PRO** (primary): Miro's custom geometric sans-serif typeface. Used across every UI surface from oversized 80px hero displays to 11px micro labels. The face has a slightly rounded, friendly character that matches the brand's playful product positioning. Fallbacks: Noto Sans, -apple-system, BlinkMacSystemFont, sans-serif.
|
||||
|
||||
### Hierarchy
|
||||
|
||||
| Token | Size | Weight | Line Height | Letter Spacing | Use |
|
||||
|---|---|---|---|---|---|
|
||||
| `{typography.hero-display}` | 80px | 500 | 1.05 | -2px | Marketing hero ("See how teams get great done with Miro") |
|
||||
| `{typography.display-lg}` | 60px | 500 | 1.10 | -1.5px | Major section openers |
|
||||
| `{typography.heading-1}` | 48px | 500 | 1.15 | -1px | Page-level headlines |
|
||||
| `{typography.heading-2}` | 36px | 500 | 1.20 | -0.5px | Subsection headlines |
|
||||
| `{typography.heading-3}` | 28px | 500 | 1.25 | 0 | Card titles |
|
||||
| `{typography.heading-4}` | 22px | 500 | 1.30 | 0 | Feature tile titles |
|
||||
| `{typography.heading-5}` | 18px | 500 | 1.40 | 0 | FAQ questions, smaller cards |
|
||||
| `{typography.subtitle}` | 18px | 400 | 1.50 | 0 | Hero subtitle |
|
||||
| `{typography.body-md}` | 16px | 400 | 1.50 | 0 | Primary body text |
|
||||
| `{typography.body-md-medium}` | 16px | 500 | 1.50 | 0 | Logo wall labels |
|
||||
| `{typography.body-sm}` | 14px | 400 | 1.50 | 0 | Secondary body, table cells |
|
||||
| `{typography.body-sm-medium}` | 14px | 500 | 1.50 | 0 | Filter dropdowns, button labels |
|
||||
| `{typography.caption}` | 13px | 400 | 1.40 | 0 | Helper text |
|
||||
| `{typography.caption-bold}` | 13px | 600 | 1.40 | 0 | Badge labels, tag chips |
|
||||
| `{typography.micro}` | 12px | 500 | 1.40 | 0 | Footer microcopy |
|
||||
| `{typography.micro-uppercase}` | 11px | 600 | 1.40 | 0.5px | Section dividers in tables |
|
||||
| `{typography.button-md}` | 14px | 500 | 1.30 | 0 | Pill button labels |
|
||||
| `{typography.stat-display}` | 64px | 500 | 1.10 | -1.5px | "100M+ users" stat callouts |
|
||||
|
||||
### Principles
|
||||
- **Tight hero leading** (1.05) creates magazine-grade display headlines on the 80px hero
|
||||
- **Negative letter-spacing progression** — display sizes use -2px to -1.5px; smaller headings relax to 0
|
||||
- **Stat-display token** (64px / 500) for marketing stat callouts
|
||||
- **Single weight scale** — 400 (body), 500 (medium emphasis + headings), 600 (badges and uppercase). Roobert PRO does not use 700 in this system.
|
||||
|
||||
## Layout
|
||||
|
||||
### Spacing System
|
||||
- **Base unit**: 4px (8px primary increment)
|
||||
- **Tokens**: `{spacing.xxs}` (4px) · `{spacing.xs}` (8px) · `{spacing.sm}` (12px) · `{spacing.md}` (16px) · `{spacing.lg}` (20px) · `{spacing.xl}` (24px) · `{spacing.xxl}` (32px) · `{spacing.xxxl}` (40px) · `{spacing.section-sm}` (48px) · `{spacing.section}` (64px) · `{spacing.section-lg}` (96px) · `{spacing.hero}` (120px)
|
||||
- **Section rhythm**: Marketing pages use `{spacing.section-lg}` (96px); pricing comparison tightens to `{spacing.section}` (64px); customer story stack uses `{spacing.xxl}` (32px)
|
||||
- **Card internal padding**: `{spacing.xl}` (24px) for compact cards; `{spacing.xxl}` (32px) for feature panels
|
||||
|
||||
### Grid & Container
|
||||
- Marketing pages use 1280px max-width with 32px gutters
|
||||
- Pricing page renders 4-tier card row at desktop (Free / Starter / Business / Enterprise)
|
||||
- Customer stories page uses 2-column grid with filter dropdowns
|
||||
- AI Workflows page uses 2-column hero, then 3-up feature grid
|
||||
|
||||
### Whitespace Philosophy
|
||||
Marketing surfaces give content generous breathing room — `{spacing.hero}` (120px) hero padding gives the small wordmark room to breathe. Pricing surfaces tighten dramatically.
|
||||
|
||||
## Elevation & Depth
|
||||
|
||||
The system runs predominantly flat with strategic depth on hero mockups.
|
||||
|
||||
| Level | Treatment | Use |
|
||||
|---|---|---|
|
||||
| 0 (flat) | No shadow; `{colors.hairline-soft}` border | Default cards, table rows, form inputs |
|
||||
| 1 (subtle) | `rgba(5, 0, 56, 0.04) 0px 1px 2px 0px` | Subtle hover-elevated tiles |
|
||||
| 2 (card) | `rgba(5, 0, 56, 0.06) 0px 4px 12px 0px` | Standard feature cards |
|
||||
| 3 (mockup) | `rgba(5, 0, 56, 0.08) 0px 12px 32px -4px` | Hero whiteboard mockup framing |
|
||||
| 4 (modal) | `rgba(5, 0, 56, 0.12) 0px 16px 48px -8px` | Modals, dropdowns |
|
||||
|
||||
### Decorative Depth
|
||||
- The atmospheric depth on Miro's hero comes from the live-product-board mockup illustrations — sticky notes layered at z-offsets, color-block tints behind whiteboard frames
|
||||
- Pastel feature cards carry their own visual weight via saturated background color
|
||||
- Customer-story cards layer dark photographic content with overlay scrims
|
||||
|
||||
## Shapes
|
||||
|
||||
### Border Radius Scale
|
||||
|
||||
| Token | Value | Use |
|
||||
|---|---|---|
|
||||
| `{rounded.xs}` | 4px | Small chips, micro-controls |
|
||||
| `{rounded.sm}` | 6px | Discount badges |
|
||||
| `{rounded.md}` | 8px | Inputs, search-pill |
|
||||
| `{rounded.lg}` | 12px | Standard cards, table containers |
|
||||
| `{rounded.xl}` | 16px | Pricing cards, feature panels |
|
||||
| `{rounded.xxl}` | 20px | Larger feature cards |
|
||||
| `{rounded.xxxl}` | 28px | Pastel feature cards (yellow, rose, coral, teal) |
|
||||
| `{rounded.feature}` | 32px | Hero CTA banner cards |
|
||||
| `{rounded.full}` | 9999px | All buttons, pill tabs, badges |
|
||||
|
||||
### Photography Geometry
|
||||
- Real Miro board mockups render with `{rounded.xl}` (16px) corners and a subtle drop shadow
|
||||
- Customer story cards use `{rounded.xxxl}` (28px) corners with full-bleed photography
|
||||
- Template card thumbnails use `{rounded.xl}` (16px) with photographic content
|
||||
- Customer logos wall presents wordmarks inline at consistent 100px height
|
||||
|
||||
## Components
|
||||
|
||||
> Per the no-hover policy, hover states are NOT documented. Default and pressed/active states only.
|
||||
|
||||
### Buttons
|
||||
|
||||
**`button-primary`** — Black pill primary CTA, the dominant action ("Get started free").
|
||||
- Background `{colors.primary}`, text `{colors.on-primary}`, typography `{typography.button-md}`, padding `12px 24px`, rounded `{rounded.full}`.
|
||||
- Pressed state `button-primary-pressed` lifts to `{colors.charcoal}`.
|
||||
- Disabled state `button-primary-disabled` uses `{colors.hairline}` background and `{colors.muted}` text.
|
||||
|
||||
**`button-yellow`** — Brand-yellow pill for moments of brand emphasis.
|
||||
- Background `{colors.brand-yellow}`, text `{colors.primary}`, typography `{typography.button-md}`, padding `12px 24px`, rounded `{rounded.full}`.
|
||||
|
||||
**`button-blue`** — Brand-blue pill for inline action callouts.
|
||||
- Background `{colors.brand-blue}`, text `{colors.on-primary}`, typography `{typography.button-md}`, padding `12px 24px`, rounded `{rounded.full}`.
|
||||
|
||||
**`button-secondary`** — Outlined pill for secondary actions ("Book a demo").
|
||||
- Background transparent, text `{colors.ink}`, border `1px solid {colors.hairline-strong}`, typography `{typography.button-md}`, padding `12px 24px`, rounded `{rounded.full}`.
|
||||
|
||||
**`button-on-dark`** — White pill for dark CTA banners.
|
||||
- Background `{colors.on-dark}`, text `{colors.primary}`, typography `{typography.button-md}`, padding `12px 24px`, rounded `{rounded.full}`.
|
||||
|
||||
**`button-ghost`** — Quieter rectangular ghost button.
|
||||
- Background transparent, text `{colors.ink}`, typography `{typography.button-md}`, padding `8px 12px`, rounded `{rounded.md}`.
|
||||
|
||||
**`button-link`** — Inline text link.
|
||||
- Background transparent, text `{colors.brand-blue}`, typography `{typography.body-sm-medium}`, padding `0`.
|
||||
|
||||
**`button-icon-circular`** — 36×36px circular utility button.
|
||||
- Background `{colors.canvas}`, text `{colors.ink}`, border `1px solid {colors.hairline}`, rounded `{rounded.full}`.
|
||||
|
||||
### Cards & Containers
|
||||
|
||||
**`card-base`** — Standard content card.
|
||||
- Background `{colors.canvas}`, rounded `{rounded.xl}`, padding `{spacing.xl}`, border `1px solid {colors.hairline-soft}`.
|
||||
|
||||
**`card-feature`** — White feature card with larger 28px corners.
|
||||
- Background `{colors.canvas}`, rounded `{rounded.xxxl}`, padding `{spacing.xxl}`, border `1px solid {colors.hairline-soft}`.
|
||||
|
||||
**`card-feature-yellow`** — Pastel-yellow feature card.
|
||||
- Background `{colors.brand-yellow}`, text `{colors.primary}`, rounded `{rounded.xxxl}`, padding `{spacing.xxl}`.
|
||||
|
||||
**`card-feature-coral`** — Pastel-coral feature card variant.
|
||||
- Background `{colors.coral-light}`, text `{colors.primary}`, rounded `{rounded.xxxl}`, padding `{spacing.xxl}`.
|
||||
|
||||
**`card-feature-teal`** — Pastel-teal feature card variant.
|
||||
- Background `{colors.teal-light}`, text `{colors.primary}`, rounded `{rounded.xxxl}`, padding `{spacing.xxl}`.
|
||||
|
||||
**`card-feature-rose`** — Pastel-rose feature card variant.
|
||||
- Background `{colors.rose-light}`, text `{colors.primary}`, rounded `{rounded.xxxl}`, padding `{spacing.xxl}`.
|
||||
|
||||
**`card-customer-story`** — Customer story card.
|
||||
- Background `{colors.canvas}`, rounded `{rounded.xxxl}`, padding `0` (image fills the card), border `1px solid {colors.hairline-soft}`.
|
||||
|
||||
**`card-stat`** — Stat-row cell for "100M+ users".
|
||||
- Background transparent, text `{colors.ink}`, typography `{typography.stat-display}`, padding `{spacing.lg}`.
|
||||
|
||||
**`pricing-card`** — Standard pricing tier card.
|
||||
- Background `{colors.canvas}`, rounded `{rounded.xl}`, padding `{spacing.xxl}`, border `1px solid {colors.hairline}`.
|
||||
|
||||
**`pricing-card-featured`** — Featured pricing tier (Business — lavender background + blue border).
|
||||
- Background `{colors.surface-pricing-featured}`, rounded `{rounded.xl}`, padding `{spacing.xxl}`, border `2px solid {colors.brand-blue}`.
|
||||
|
||||
**`pricing-card-enterprise`** — Dark-canvas enterprise tier card.
|
||||
- Background `{colors.primary}`, text `{colors.on-primary}`, rounded `{rounded.xl}`, padding `{spacing.xxl}`.
|
||||
|
||||
### Inputs & Forms
|
||||
|
||||
**`text-input`** — Standard text field.
|
||||
- Background `{colors.canvas}`, text `{colors.ink}`, border `1px solid {colors.hairline-strong}`, rounded `{rounded.md}`, padding `{spacing.sm} {spacing.md}`, height 44px.
|
||||
|
||||
**`text-input-focused`** — Activated state.
|
||||
- Border switches to `2px solid {colors.brand-blue}`.
|
||||
|
||||
**`search-pill`** — Search bar.
|
||||
- Background `{colors.surface}`, text `{colors.steel}`, typography `{typography.body-sm}`, rounded `{rounded.md}`, height 40px, border `1px solid {colors.hairline}`.
|
||||
|
||||
**`filter-dropdown`** — Pill-shaped filter dropdown ("Company use" / "Industry" / "Use case").
|
||||
- Background `{colors.canvas}`, text `{colors.ink}`, typography `{typography.body-sm-medium}`, rounded `{rounded.full}`, padding `{spacing.xs} {spacing.md}`, border `1px solid {colors.hairline-strong}`.
|
||||
|
||||
### Tabs
|
||||
|
||||
**`pill-tab`** + **`pill-tab-active`** — Pill-style tab nav.
|
||||
- Inactive: background `{colors.canvas}`, text `{colors.steel}`, border `1px solid {colors.hairline}`, padding `{spacing.xs} {spacing.md}`, rounded `{rounded.full}`.
|
||||
- Active: background `{colors.primary}`, text `{colors.on-primary}`.
|
||||
|
||||
**`toggle-monthly-yearly`** — Two-state pill toggle (Monthly / Annual on pricing).
|
||||
- Background `{colors.surface}`, rounded `{rounded.full}`, padding `4px`.
|
||||
|
||||
### Badges & Status
|
||||
|
||||
**`badge-promo`** — Yellow promo banner badge.
|
||||
- Background `{colors.brand-yellow}`, text `{colors.primary}`, typography `{typography.caption-bold}`, rounded `{rounded.full}`, padding `4px 10px`.
|
||||
|
||||
**`badge-tag-yellow`** — Soft-yellow feature tag chip ("Yellow" tag on AI Workflows page).
|
||||
- Background `{colors.surface-yellow}`, text `{colors.yellow-dark}`, typography `{typography.caption-bold}`, rounded `{rounded.full}`, padding `4px 10px`.
|
||||
|
||||
**`badge-tag-purple`** — Lavender feature tag chip ("AI agent" tag).
|
||||
- Background `{colors.surface-pricing-featured}`, text `{colors.brand-blue}`, typography `{typography.caption-bold}`, rounded `{rounded.full}`, padding `4px 10px`.
|
||||
|
||||
**`badge-tag-coral`** — Coral feature tag chip variant.
|
||||
- Background `{colors.coral-light}`, text `{colors.coral-dark}`, typography `{typography.caption-bold}`, rounded `{rounded.full}`, padding `4px 10px`.
|
||||
|
||||
**`badge-success`** — Green success indicator.
|
||||
- Background `{colors.success-accent}`, text `{colors.on-primary}`, typography `{typography.caption-bold}`, rounded `{rounded.full}`, padding `4px 10px`.
|
||||
|
||||
**`badge-discount`** — Yellow rectangular discount pill ("Save 15%").
|
||||
- Background `{colors.brand-yellow}`, text `{colors.primary}`, typography `{typography.caption-bold}`, rounded `{rounded.sm}`, padding `2px 6px`.
|
||||
|
||||
**`promo-banner`** — Sticky black promo strip ABOVE the top nav.
|
||||
- Background `{colors.primary}`, text `{colors.on-primary}`, typography `{typography.body-sm-medium}`, padding `{spacing.sm} {spacing.md}`. Carries inline yellow "GET YOUR SPOT" pill.
|
||||
|
||||
### Tables
|
||||
|
||||
**`comparison-table`** — Pricing feature comparison table.
|
||||
- Background `{colors.canvas}`, text `{colors.ink}`, typography `{typography.body-sm}`, rounded `{rounded.md}`, border `1px solid {colors.hairline}`.
|
||||
|
||||
**`comparison-row`** — Individual feature row.
|
||||
- Background `{colors.canvas}`, text `{colors.ink}`, padding `{spacing.md} {spacing.lg}`, bottom border `1px solid {colors.hairline-soft}`.
|
||||
|
||||
### Documentation Components
|
||||
|
||||
**`whiteboard-mockup`** — Real Miro-board UI rendered as feature illustration.
|
||||
- Background `{colors.canvas}`, rounded `{rounded.xl}`, border `1px solid {colors.hairline-soft}`, shadow `rgba(5, 0, 56, 0.08) 0px 12px 32px -4px`.
|
||||
|
||||
**`template-card`** — Template thumbnail card.
|
||||
- Background `{colors.canvas}`, rounded `{rounded.xl}`, padding `{spacing.md}`, border `1px solid {colors.hairline}`.
|
||||
|
||||
**`industry-tile`** — Industry-vertical tile.
|
||||
- Background `{colors.canvas}`, rounded `{rounded.xl}`, padding `{spacing.xl}`, border `1px solid {colors.hairline-soft}`.
|
||||
|
||||
**`faq-accordion-item`** — FAQ panel item.
|
||||
- Background `{colors.canvas}`, rounded `{rounded.md}`, padding `{spacing.xl}`, bottom border `1px solid {colors.hairline}`.
|
||||
|
||||
**`logo-wall-item`** — Customer logo wordmark cell.
|
||||
- Background transparent, text `{colors.steel}`, typography `{typography.body-md-medium}`, padding `{spacing.lg}`.
|
||||
|
||||
**`capterra-badge`** — Review/rating badge in the footer.
|
||||
- Background `{colors.canvas}`, text `{colors.ink}`, typography `{typography.caption}`, rounded `{rounded.md}`, padding `{spacing.sm} {spacing.md}`, border `1px solid {colors.hairline}`.
|
||||
|
||||
**`app-store-badge`** — App store / Google Play download pill.
|
||||
- Background `{colors.canvas}`, text `{colors.primary}`, typography `{typography.caption-bold}`, rounded `{rounded.md}`, padding `{spacing.sm} {spacing.md}`.
|
||||
|
||||
### Navigation
|
||||
|
||||
**Top Navigation (Marketing)** — Sticky white bar with yellow Miro wordmark + horizontal links + right-side CTAs.
|
||||
- Background `{colors.canvas}`, height ~64px.
|
||||
- Left: Yellow square Miro wordmark + horizontal link list (Product, Solutions, Resources).
|
||||
- Right: "Login / Pricing / Contact sales" links + black-pill "Get started free".
|
||||
|
||||
### Signature Components
|
||||
|
||||
**`hero-band-marketing`** — Marketing hero band.
|
||||
- Background `{colors.canvas}`, padding `{spacing.hero}`.
|
||||
- Layout: centered headline in `{typography.hero-display}`, centered subtitle, centered button row, then whiteboard mockup illustration below.
|
||||
|
||||
**`cta-banner-dark`** — Dark CTA banner at the bottom of feature pages.
|
||||
- Background `{colors.primary}`, text `{colors.on-primary}`, rounded `{rounded.feature}`, padding `{spacing.section}`. Centered headline + subtitle + `button-on-dark` "Get started free".
|
||||
|
||||
**`footer-region`** — Massive multi-column dark footer.
|
||||
- Background `{colors.footer-bg}`, padding `{spacing.section} {spacing.xxl}`.
|
||||
- 6-column link grid (Product / Solutions / Tools / Resources / Company / Plans & Pricing).
|
||||
- Section headings in `{typography.body-md-medium}` `{colors.on-dark}`.
|
||||
|
||||
**`footer-link`** — Individual link in the footer.
|
||||
- Background transparent, text `{colors.on-dark-muted}`, typography `{typography.body-sm}`, padding `{spacing.xxs} 0`.
|
||||
|
||||
## Do's and Don'ts
|
||||
|
||||
### Do
|
||||
- Reserve `{colors.brand-yellow}` for the wordmark, top promo banner, and "yellow tag" chips
|
||||
- Use `{colors.primary}` (black) as the dominant CTA on all surfaces
|
||||
- Pair pastel feature cards (yellow, rose, coral, teal) with white feature cards in the same viewport
|
||||
- Apply `{rounded.full}` to every button, every pill tab, every status badge
|
||||
- Apply `{rounded.xxxl}` (28px) to pastel feature cards
|
||||
- Use real Miro-board mockups as feature illustrations
|
||||
- Maintain Roobert PRO across every UI surface
|
||||
|
||||
### Don't
|
||||
- Don't use `{colors.brand-yellow}` on standard CTAs or large background surfaces
|
||||
- Don't introduce additional accent colors beyond yellow + brand pastels
|
||||
- Don't soften corners on buttons; the pill is a brand signature
|
||||
- Don't reduce hero leading below 1.05
|
||||
- Don't apply heavy shadows on flat documentation cards; reserve elevation for whiteboard mockups
|
||||
- Don't use stock photography — show the live product board UI
|
||||
|
||||
## Responsive Behavior
|
||||
|
||||
### Breakpoints
|
||||
| Name | Width | Key Changes |
|
||||
|---|---|---|
|
||||
| Mobile (small) | < 480px | Single column. Hero scales to 36px. Pill nav collapses to hamburger. Pricing tiers stack 1-up. |
|
||||
| Mobile (large) | 480 – 767px | Feature tiles 2-up. Hero scales to 48px. |
|
||||
| Tablet | 768 – 1023px | 2-column feature grids. Pill-tab nav returns. |
|
||||
| Desktop | 1024 – 1279px | 4-tier pricing card row. Customer story grid 2-up. Hero at 64px. |
|
||||
| Wide Desktop | ≥ 1280px | Full hero presentation, 80px hero display. |
|
||||
|
||||
### Touch Targets
|
||||
- Pill buttons render at 40–44px effective height — at WCAG AAA floor
|
||||
- Circular icon buttons: 36×36px desktop → 44×44px mobile
|
||||
- Form inputs render at 44px height
|
||||
- Filter dropdowns render at ~36px tall — bumps to 44px on mobile
|
||||
|
||||
### Collapsing Strategy
|
||||
- **Promo banner** stays full-width; truncates at < 480px
|
||||
- **Top nav** below 1024px collapses to hamburger
|
||||
- **Hero band**: 2-column hero collapses to stacked at < 1024px
|
||||
- **Pricing comparison**: 4-column tiers → 2-column tablet → 1-column mobile; comparison table becomes horizontal-scroll
|
||||
- **Customer story grid**: 2-up → 1-up at < 768px
|
||||
- **Hero typography**: 80px → 60px tablet → 48px mobile-large → 36px mobile-small
|
||||
- **Footer**: 6-column desktop → 3-column tablet → 2-column mobile → accordion at small mobile
|
||||
|
||||
### Image Behavior
|
||||
- Whiteboard mockups maintain aspect ratio; lazy-loaded below the fold
|
||||
- Customer story photography uses 16:9 ratio with full-bleed scaling
|
||||
- Logo wall presents wordmarks at consistent 100px height
|
||||
|
||||
## Iteration Guide
|
||||
|
||||
1. Focus on ONE component at a time
|
||||
2. Reference component names and tokens directly
|
||||
3. Run `npx @google/design.md lint DESIGN.md` after edits
|
||||
4. Add new variants as separate `components:` entries
|
||||
5. Default to `{typography.body-md}` for body and `{typography.subtitle}` for emphasis
|
||||
6. Keep `{colors.brand-yellow}` confined to wordmark, promo banner, and yellow-tag chips
|
||||
7. Pill-shaped buttons (`{rounded.full}`) always
|
||||
8. When showing the product, use a real Miro-board mockup with sticky-note color tints
|
||||
|
||||
## Known Gaps
|
||||
|
||||
- Specific dark-mode token values not surfaced
|
||||
- Animation/transition timings not extracted; recommend 150–200ms ease
|
||||
- Form validation success state not explicitly captured beyond defaults
|
||||
- Sticky note color tints inside the actual whiteboard product are richer than what marketing surfaces capture
|
||||
30
Dockerfile
Normal file
30
Dockerfile
Normal file
@ -0,0 +1,30 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
TZ=Asia/Shanghai
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
tzdata \
|
||||
procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt /app/requirements.txt
|
||||
RUN pip install --upgrade pip \
|
||||
&& pip install -r /app/requirements.txt
|
||||
|
||||
COPY . /app
|
||||
|
||||
RUN mkdir -p /app/data /app/logs \
|
||||
&& chmod +x /app/docker/entrypoint.sh /app/docker/scheduler.py 2>/dev/null || true
|
||||
|
||||
EXPOSE 8190
|
||||
|
||||
ENTRYPOINT ["/app/docker/entrypoint.sh"]
|
||||
CMD ["web"]
|
||||
119
README_DOCKER.md
Normal file
119
README_DOCKER.md
Normal file
@ -0,0 +1,119 @@
|
||||
# AlphaX Docker 化副本
|
||||
|
||||
这是从当前运行中的 `/home/ubuntu/quant_monitor/altcoin` 复制出来的独立 Docker 化副本,目录:
|
||||
|
||||
```text
|
||||
/home/ubuntu/quant_monitor/alphax-docker
|
||||
```
|
||||
|
||||
## 重要原则
|
||||
|
||||
- 这个目录是副本,不影响当前正在运行的 AlphaX。
|
||||
- 默认 `docker-compose.yml` 将 Web 暴露到宿主机 `8191`,避免占用当前线上 `8190`。
|
||||
- 调度器默认 `ALPHAX_SCHEDULER_DRY_RUN=1`,第一次启动不会真的跑筛选/确认/跟踪任务。
|
||||
- SQLite 数据挂载在 `./data/altcoin_monitor.db`,容器内路径为 `/app/data/altcoin_monitor.db`。
|
||||
- 镜像构建上下文通过 `.dockerignore` 排除了 `data/`、`archive/`、真实 `.env` 和所有 DB 文件,避免把数据库/密钥打进镜像。
|
||||
|
||||
## 快速启动
|
||||
|
||||
```bash
|
||||
cd /home/ubuntu/quant_monitor/alphax-docker
|
||||
cp .env.example .env
|
||||
# 如需推送,编辑 .env 填 ALTCOIN_FEISHU_WEBHOOK
|
||||
|
||||
docker compose build
|
||||
docker compose up -d alphax-web
|
||||
curl -s http://127.0.0.1:8191/api/stats
|
||||
```
|
||||
|
||||
确认 Web 正常后,如果要启动调度器:
|
||||
|
||||
```bash
|
||||
docker compose up -d alphax-scheduler
|
||||
```
|
||||
|
||||
调度器默认 dry-run,只打印计划,不写库。确认日志无误后,把 `.env` 或 compose 里的:
|
||||
|
||||
```text
|
||||
ALPHAX_SCHEDULER_DRY_RUN=0
|
||||
```
|
||||
|
||||
再重启:
|
||||
|
||||
```bash
|
||||
docker compose up -d alphax-scheduler
|
||||
```
|
||||
|
||||
## 服务说明
|
||||
|
||||
- `alphax-web`:FastAPI + 静态页面,容器内 8190,宿主机 8191。
|
||||
- `alphax-scheduler`:轻量 Python 调度器,串行执行任务,避免 SQLite 并发锁。
|
||||
|
||||
调度任务与当前线上大致对齐:
|
||||
|
||||
| 任务 | 脚本 | 间隔 |
|
||||
|---|---|---|
|
||||
| 事件舆情 | `event_driven_screener.py --once` | 60s |
|
||||
| 价格跟踪 | `price_tracker.py` | 180s |
|
||||
| 爆发确认 | `altcoin_confirm.py` | 600s |
|
||||
| 粗筛/细筛 | `altcoin_screener.py` | 900s |
|
||||
| 舆情采集 | `sentiment_monitor.py --collect` | 1800s |
|
||||
| 复盘 | `review_engine.py` | 24h |
|
||||
|
||||
## 验证命令
|
||||
|
||||
```bash
|
||||
cd /home/ubuntu/quant_monitor/alphax-docker
|
||||
python3 -m py_compile altcoin_db.py auth_db.py opportunity_lifecycle.py altcoin_screener.py altcoin_confirm.py price_tracker.py event_driven_screener.py sentiment_monitor.py review_engine.py web_server.py docker/scheduler.py scripts/validate_docker_layout.py
|
||||
python3 scripts/validate_docker_layout.py
|
||||
python3 scripts/validate_state_machine.py
|
||||
python3 scripts/validate_push_state_flow.py
|
||||
python3 scripts/validate_signal_recency.py
|
||||
```
|
||||
|
||||
Docker 配置验证:
|
||||
|
||||
```bash
|
||||
docker compose config
|
||||
```
|
||||
|
||||
> 当前机器如果没有 Docker,只能做离线文件/语法/DB 校验;到有 Docker 的机器上再执行 build/up。
|
||||
|
||||
## 数据迁移
|
||||
|
||||
当前副本是从线上目录复制来的,包含复制时刻的 `altcoin_monitor.db`。为了避免误影响线上,容器读写的是副本目录下的:
|
||||
|
||||
```text
|
||||
./data/altcoin_monitor.db
|
||||
```
|
||||
|
||||
容器内路径通过环境变量配置:
|
||||
|
||||
```text
|
||||
ALPHAX_DB_PATH=/app/data/altcoin_monitor.db
|
||||
```
|
||||
|
||||
如需重新以线上最新数据初始化副本,应停掉副本容器后手动复制 DB:
|
||||
|
||||
```bash
|
||||
cd /home/ubuntu/quant_monitor/alphax-docker
|
||||
docker compose down
|
||||
cp /home/ubuntu/quant_monitor/altcoin/altcoin_monitor.db ./data/altcoin_monitor.db
|
||||
```
|
||||
|
||||
不要反向覆盖线上 DB。
|
||||
|
||||
## 打包迁移到新服务器
|
||||
|
||||
建议只打包代码和配置骨架,不把 DB 直接打进镜像。可以用 tar 打包整个副本目录,排除本地缓存和归档备份:
|
||||
|
||||
```bash
|
||||
cd /home/ubuntu/quant_monitor
|
||||
tar --exclude='alphax-docker/.git' \
|
||||
--exclude='alphax-docker/__pycache__' \
|
||||
--exclude='alphax-docker/.pytest_cache' \
|
||||
--exclude='alphax-docker/archive' \
|
||||
-czf alphax-docker.tar.gz alphax-docker
|
||||
```
|
||||
|
||||
如果新服务器要带初始 DB,可以保留 `alphax-docker/data/altcoin_monitor.db`;如果希望空库启动,则删除该文件,容器首次启动会创建空 DB 并由 `init_db()` 补表。
|
||||
1343
altcoin_confirm.py
Normal file
1343
altcoin_confirm.py
Normal file
File diff suppressed because it is too large
Load Diff
3435
altcoin_db.py
Normal file
3435
altcoin_db.py
Normal file
File diff suppressed because it is too large
Load Diff
1349
altcoin_screener.py
Normal file
1349
altcoin_screener.py
Normal file
File diff suppressed because it is too large
Load Diff
1252
auth_db.py
Normal file
1252
auth_db.py
Normal file
File diff suppressed because it is too large
Load Diff
200
backtest.py
Normal file
200
backtest.py
Normal file
@ -0,0 +1,200 @@
|
||||
"""
|
||||
山寨币策略回测脚本
|
||||
对 DB 中所有有完整入场方案(stop_loss/tp1/tp2)的推荐做模拟跟踪。
|
||||
"""
|
||||
import sys, os, json, sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
sys.path.insert(0, '/home/ubuntu/quant_monitor/altcoin')
|
||||
|
||||
import ccxt
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
exchange = ccxt.binance({'enableRateLimit': True})
|
||||
DB = '/home/ubuntu/quant_monitor/altcoin/altcoin_monitor.db'
|
||||
|
||||
|
||||
def fetch_klines_since(symbol, timeframe, since_ms, limit=500):
|
||||
"""Fetch K-lines from a specific timestamp."""
|
||||
try:
|
||||
ohlcv = exchange.fetch_ohlcv(symbol, timeframe, since=since_ms, limit=limit)
|
||||
if not ohlcv:
|
||||
return None
|
||||
df = pd.DataFrame(ohlcv, columns=['timestamp', 'open', 'high', 'low', 'close', 'volume'])
|
||||
df['timestamp'] = pd.to_datetime(df['timestamp'], unit='ms')
|
||||
return df
|
||||
except Exception as e:
|
||||
print(f" fetch_klines error for {symbol}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def simulate_trade(rec, klines_df):
|
||||
"""Simulate one trade: walk through K-lines, check TP/stop."""
|
||||
entry_price = float(rec['entry_price'])
|
||||
stop_loss = float(rec['stop_loss'] or 0)
|
||||
tp1 = float(rec['tp1'] or 0)
|
||||
tp2 = float(rec['tp2'] or 0)
|
||||
|
||||
if stop_loss <= 0 or tp1 <= 0:
|
||||
return {'result': 'no_entry_plan', 'exit_price': 0, 'exit_time': '', 'pnl_pct': 0, 'hours': 0}
|
||||
|
||||
result = 'expired'
|
||||
exit_price = entry_price
|
||||
exit_time = ''
|
||||
max_profit_pct = 0
|
||||
max_loss_pct = 0
|
||||
|
||||
for _, row in klines_df.iterrows():
|
||||
high = float(row['high'])
|
||||
low = float(row['low'])
|
||||
close = float(row['close'])
|
||||
ts = row['timestamp']
|
||||
|
||||
current_pnl = (close / entry_price - 1) * 100
|
||||
max_profit_pct = max(max_profit_pct, (high / entry_price - 1) * 100)
|
||||
max_loss_pct = min(max_loss_pct, (low / entry_price - 1) * 100)
|
||||
|
||||
# Check TP2 first (higher target)
|
||||
if tp2 > 0 and high >= tp2:
|
||||
result = 'hit_tp2'
|
||||
exit_price = tp2
|
||||
exit_time = str(ts)
|
||||
break
|
||||
|
||||
# Check TP1
|
||||
if tp1 > 0 and high >= tp1:
|
||||
result = 'hit_tp1'
|
||||
exit_price = tp1
|
||||
exit_time = str(ts)
|
||||
break
|
||||
|
||||
# Check stop loss
|
||||
if stop_loss > 0 and low <= stop_loss:
|
||||
result = 'stopped_out'
|
||||
exit_price = stop_loss
|
||||
exit_time = str(ts)
|
||||
break
|
||||
|
||||
pnl_pct = round((exit_price / entry_price - 1) * 100, 2)
|
||||
|
||||
# Calculate holding hours
|
||||
if exit_time:
|
||||
try:
|
||||
et = datetime.fromisoformat(exit_time)
|
||||
rt = datetime.fromisoformat(rec['rec_time'])
|
||||
hours = round((et - rt).total_seconds() / 3600, 1)
|
||||
except:
|
||||
hours = 0
|
||||
else:
|
||||
hours = 0
|
||||
|
||||
return {
|
||||
'result': result,
|
||||
'exit_price': round(exit_price, 6),
|
||||
'exit_time': exit_time,
|
||||
'pnl_pct': pnl_pct,
|
||||
'max_profit_pct': round(max_profit_pct, 2),
|
||||
'max_loss_pct': round(max_loss_pct, 2),
|
||||
'hours': hours,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
conn = sqlite3.connect(DB)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
# Only backtest "爆发" with full entry plans
|
||||
rows = conn.execute("""
|
||||
SELECT id, symbol, rec_time, rec_state, rec_score, entry_price,
|
||||
stop_loss, tp1, tp2, status, entry_plan_json, signals, sector
|
||||
FROM recommendation
|
||||
WHERE stop_loss > 0 AND tp1 > 0
|
||||
ORDER BY id
|
||||
""").fetchall()
|
||||
|
||||
conn.close()
|
||||
|
||||
print(f"回测样本: {len(rows)} 条 (有完整入场方案)\n")
|
||||
|
||||
results = []
|
||||
wins = losses = expired_count = 0
|
||||
total_pnl = 0
|
||||
max_win = -999
|
||||
max_loss = 999
|
||||
|
||||
for i, rec in enumerate(rows, 1):
|
||||
symbol = rec['symbol']
|
||||
rec_time = datetime.fromisoformat(rec['rec_time'])
|
||||
since_ms = int(rec_time.timestamp() * 1000)
|
||||
|
||||
print(f"[{i}/{len(rows)}] {symbol} rec_time={rec['rec_time'][:19]} "
|
||||
f"entry={rec['entry_price']} stop={rec['stop_loss']} tp1={rec['tp1']} tp2={rec['tp2']}", end='')
|
||||
|
||||
klines = fetch_klines_since(symbol, '15m', since_ms, limit=2000)
|
||||
|
||||
if klines is None or len(klines) < 2:
|
||||
print(" → 数据不足,跳过")
|
||||
continue
|
||||
|
||||
sim = simulate_trade(rec, klines)
|
||||
results.append({**sim, 'symbol': symbol, 'rec_time': rec['rec_time'],
|
||||
'entry_price': rec['entry_price'], 'rec_state': rec['rec_state']})
|
||||
|
||||
tag = {'hit_tp1': '🟢', 'hit_tp2': '🟢🟢', 'stopped_out': '🔴', 'expired': '⏰', 'no_entry_plan': '❓'}
|
||||
print(f" → {tag.get(sim['result'], '?')} {sim['result']} pnl={sim['pnl_pct']}% "
|
||||
f"max_profit={sim['max_profit_pct']}% max_loss={sim['max_loss_pct']}% {sim['hours']}h")
|
||||
|
||||
if sim['result'] in ('hit_tp1', 'hit_tp2'):
|
||||
wins += 1
|
||||
total_pnl += sim['pnl_pct']
|
||||
max_win = max(max_win, sim['pnl_pct'])
|
||||
elif sim['result'] == 'stopped_out':
|
||||
losses += 1
|
||||
total_pnl += sim['pnl_pct']
|
||||
max_loss = min(max_loss, sim['pnl_pct'])
|
||||
else:
|
||||
expired_count += 1
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"回测汇总 (n={len(results)})")
|
||||
print(f"{'='*60}")
|
||||
print(f"止盈(TP): {wins} 笔")
|
||||
print(f"止损: {losses} 笔")
|
||||
print(f"未触达: {expired_count} 笔")
|
||||
closed = wins + losses
|
||||
if closed > 0:
|
||||
print(f"胜率: {wins}/{closed} = {round(wins/closed*100,1)}%")
|
||||
print(f"平均盈亏: {round(total_pnl/closed, 2)}%")
|
||||
print(f"最大盈利: {max_win}%")
|
||||
print(f"最大亏损: {max_loss}%")
|
||||
print(f"盈亏比: {round(abs(max_win/max_loss) if max_loss != 0 else 99, 1)}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
# Detail table
|
||||
print(f"\n{'symbol':<14} {'time':<17} {'result':<14} {'pnl':>7} {'max+':>7} {'max-':>7} {'h':>5}")
|
||||
print("-" * 70)
|
||||
for r in results:
|
||||
print(f"{r['symbol']:<14} {r['rec_time'][:16]:<17} {r['result']:<14} "
|
||||
f"{r['pnl_pct']:>6.1f}% {r['max_profit_pct']:>6.1f}% {r['max_loss_pct']:>6.1f}% {r['hours']:>5.1f}")
|
||||
|
||||
# Save to JSON for HTML report
|
||||
with open('/home/ubuntu/quant_monitor/altcoin/backtest_result.json', 'w') as f:
|
||||
json.dump({
|
||||
'generated_at': datetime.now().isoformat(),
|
||||
'total': len(results),
|
||||
'wins': wins,
|
||||
'losses': losses,
|
||||
'expired': expired_count,
|
||||
'win_rate': round(wins/closed*100, 1) if closed > 0 else 0,
|
||||
'avg_pnl': round(total_pnl/closed, 2) if closed > 0 else 0,
|
||||
'max_win': max_win,
|
||||
'max_loss': max_loss,
|
||||
'details': [{k: str(v) if isinstance(v, (datetime, pd.Timestamp)) else v
|
||||
for k, v in r.items()} for r in results],
|
||||
}, f, ensure_ascii=False, indent=2, default=str)
|
||||
print(f"\n结果已保存: backtest_result.json")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
1079
backtest_result.json
Normal file
1079
backtest_result.json
Normal file
File diff suppressed because it is too large
Load Diff
261
coin_state_tracker.py
Normal file
261
coin_state_tracker.py
Normal file
@ -0,0 +1,261 @@
|
||||
"""
|
||||
山寨币状态跟踪器 — 去重 + 状态升级管理
|
||||
|
||||
状态生命周期:蓄力 → 加速 → 爁发 → 已告警 → 过期
|
||||
只有状态升级才告警,同级别12h内不重复推送
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
DB_PATH = "/home/ubuntu/quant_monitor/altcoin/altcoin_monitor.db"
|
||||
|
||||
STATE_ORDER = {
|
||||
"蓄力": 1,
|
||||
"加速": 2,
|
||||
"爆发": 3,
|
||||
"已告警": 4,
|
||||
"过期": 5,
|
||||
}
|
||||
|
||||
ALERT_LEVELS = {
|
||||
"蓄力": "low",
|
||||
"加速": "medium",
|
||||
"爆发": "high",
|
||||
}
|
||||
|
||||
# 12h内同级别不重复告警
|
||||
ALERT_COOLDOWN_HOURS = 12
|
||||
|
||||
# 24h后状态自动过期
|
||||
EXPIRE_HOURS = 24
|
||||
|
||||
|
||||
def init_db():
|
||||
"""初始化数据库"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS coin_state (
|
||||
symbol TEXT PRIMARY KEY,
|
||||
state TEXT NOT NULL,
|
||||
score REAL DEFAULT 0,
|
||||
anomaly_type TEXT DEFAULT '',
|
||||
sector TEXT DEFAULT '',
|
||||
leader_status TEXT DEFAULT '',
|
||||
detected_at TEXT NOT NULL,
|
||||
last_alert_time TEXT DEFAULT '',
|
||||
last_alert_level TEXT DEFAULT '',
|
||||
detail_json TEXT DEFAULT '{}'
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_state(symbol):
|
||||
"""获取币种当前状态"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
row = conn.execute("SELECT * FROM coin_state WHERE symbol=?", (symbol,)).fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
return {
|
||||
"symbol": row[0],
|
||||
"state": row[1],
|
||||
"score": row[2],
|
||||
"anomaly_type": row[3],
|
||||
"sector": row[4],
|
||||
"leader_status": row[5],
|
||||
"detected_at": row[6],
|
||||
"last_alert_time": row[7],
|
||||
"last_alert_level": row[8],
|
||||
"detail": json.loads(row[9]) if row[9] else {},
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def update_state(symbol, new_state, score=0, anomaly_type="", sector="", leader_status="", detail={}):
|
||||
"""
|
||||
更新币种状态,判断是否需要告警
|
||||
返回: {"should_alert": bool, "alert_level": str, "reason": str}
|
||||
"""
|
||||
current = get_state(symbol)
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
should_alert = False
|
||||
alert_level = ""
|
||||
reason = ""
|
||||
|
||||
if current:
|
||||
current_level = STATE_ORDER.get(current["state"], 0)
|
||||
new_level = STATE_ORDER.get(new_state, 0)
|
||||
|
||||
# 状态升级 → 检查冷却时间
|
||||
if new_level > current_level:
|
||||
last_alert_time = current.get("last_alert_time", "") or ""
|
||||
last_alert_level_str = current.get("last_alert_level", "") or ""
|
||||
|
||||
if not last_alert_time:
|
||||
# 没有上次告警记录 → 状态升级肯定要告警
|
||||
should_alert = True
|
||||
alert_level = ALERT_LEVELS.get(new_state, "low")
|
||||
reason = f"状态升级(首次告警): {current['state']} → {new_state}"
|
||||
else:
|
||||
last_dt = datetime.fromisoformat(last_alert_time)
|
||||
cooldown_end = last_dt + timedelta(hours=ALERT_COOLDOWN_HOURS)
|
||||
|
||||
# 新级别比上次告警级别高 → 不管冷却期都要告警
|
||||
last_alert_num = STATE_ORDER.get(last_alert_level_str, 0)
|
||||
if new_level > last_alert_num:
|
||||
should_alert = True
|
||||
alert_level = ALERT_LEVELS.get(new_state, "low")
|
||||
reason = f"状态升级: {current['state']} → {new_state}"
|
||||
elif now > cooldown_end.isoformat():
|
||||
# 冷却期过了,可以再告警
|
||||
should_alert = True
|
||||
alert_level = ALERT_LEVELS.get(new_state, "low")
|
||||
reason = f"冷却期结束,重新告警: {new_state}"
|
||||
else:
|
||||
should_alert = False
|
||||
reason = f"冷却期内(上次告警: {last_alert_level_str} @ {last_alert_time})"
|
||||
|
||||
# 状态降级 → 标记为"信号消退"
|
||||
elif new_level < current_level:
|
||||
should_alert = False
|
||||
reason = f"信号消退: {current['state']} → {new_state}"
|
||||
|
||||
# 同级别,分数显著提升(≥3分) → 也告警
|
||||
elif new_level == current_level and score - current["score"] >= 3:
|
||||
last_alert_time = current.get("last_alert_time", "") or ""
|
||||
if not last_alert_time:
|
||||
should_alert = True
|
||||
alert_level = ALERT_LEVELS.get(new_state, "low")
|
||||
reason = f"同级别分数显著提升(首次): {current['score']} → {score}"
|
||||
else:
|
||||
last_dt = datetime.fromisoformat(last_alert_time)
|
||||
cooldown_end = last_dt + timedelta(hours=ALERT_COOLDOWN_HOURS)
|
||||
if now > cooldown_end.isoformat():
|
||||
should_alert = True
|
||||
alert_level = ALERT_LEVELS.get(new_state, "low")
|
||||
reason = f"同级别但分数显著提升: {current['score']} → {score}"
|
||||
|
||||
# 更新记录
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
alert_time = now if should_alert else current.get("last_alert_time", "")
|
||||
alert_lvl = alert_level if should_alert else current.get("last_alert_level", "")
|
||||
conn.execute("""
|
||||
UPDATE coin_state SET state=?, score=?, anomaly_type=?, sector=?,
|
||||
leader_status=?, detail_json=?, last_alert_time=?, last_alert_level=?
|
||||
WHERE symbol=?
|
||||
""", (new_state, score, anomaly_type, sector, leader_status,
|
||||
json.dumps(detail, ensure_ascii=False), alert_time, alert_lvl, symbol))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
else:
|
||||
# 新币种,首次检测
|
||||
# 蓄力级别首次不告警(太多噪音),加速和爆发才告警
|
||||
if STATE_ORDER.get(new_state, 0) >= STATE_ORDER.get("加速", 0):
|
||||
should_alert = True
|
||||
alert_level = ALERT_LEVELS.get(new_state, "low")
|
||||
reason = f"新检测: {new_state}"
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
alert_time = now if should_alert else ""
|
||||
alert_lvl = alert_level if should_alert else ""
|
||||
conn.execute("""
|
||||
INSERT INTO coin_state (symbol, state, score, anomaly_type, sector,
|
||||
leader_status, detected_at, last_alert_time, last_alert_level, detail_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (symbol, new_state, score, anomaly_type, sector, leader_status,
|
||||
now, alert_time, alert_lvl, json.dumps(detail, ensure_ascii=False)))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return {"should_alert": should_alert, "alert_level": alert_level, "reason": reason}
|
||||
|
||||
|
||||
def get_all_active():
|
||||
"""获取所有活跃状态(未过期的)"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cutoff = (datetime.now() - timedelta(hours=EXPIRE_HOURS)).isoformat()
|
||||
rows = conn.execute("""
|
||||
SELECT symbol, state, score, anomaly_type, sector, leader_status, detected_at
|
||||
FROM coin_state WHERE detected_at > ? AND state != '过期'
|
||||
ORDER BY score DESC
|
||||
""", (cutoff,)).fetchall()
|
||||
conn.close()
|
||||
|
||||
return [{
|
||||
"symbol": r[0], "state": r[1], "score": r[2],
|
||||
"anomaly_type": r[3], "sector": r[4], "leader_status": r[5],
|
||||
"detected_at": r[6]
|
||||
} for r in rows]
|
||||
|
||||
|
||||
def get_candidates_for_confirm():
|
||||
"""获取加速状态的候选(需要第三层确认的)"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cutoff = (datetime.now() - timedelta(hours=EXPIRE_HOURS)).isoformat()
|
||||
rows = conn.execute("""
|
||||
SELECT symbol, state, score, anomaly_type, sector, leader_status, detail_json
|
||||
FROM coin_state WHERE state IN ('加速', '蓄力') AND detected_at > ?
|
||||
AND score >= 6
|
||||
ORDER BY score DESC
|
||||
""", (cutoff,)).fetchall()
|
||||
conn.close()
|
||||
|
||||
return [{
|
||||
"symbol": r[0], "state": r[1], "score": r[2],
|
||||
"anomaly_type": r[3], "sector": r[4], "leader_status": r[5],
|
||||
"detail": json.loads(r[6]) if r[6] else {},
|
||||
} for r in rows]
|
||||
|
||||
|
||||
def expire_old_states():
|
||||
"""过期超过24h的状态"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cutoff = (datetime.now() - timedelta(hours=EXPIRE_HOURS)).isoformat()
|
||||
conn.execute("UPDATE coin_state SET state='过期' WHERE detected_at < ? AND state != '过期'", (cutoff,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_db()
|
||||
print("DB初始化完成")
|
||||
|
||||
# 测试状态升级
|
||||
r1 = update_state("FET/USDT", "蓄力", score=3, anomaly_type="布林收窄+量突变", sector="AI_DePIN")
|
||||
print(f"FET 蓄力: alert={r1['should_alert']}, reason={r1['reason']}")
|
||||
|
||||
r2 = update_state("FET/USDT", "加速", score=8, anomaly_type="MACD金叉+RSI拐点", sector="AI_DePIN")
|
||||
print(f"FET 加速: alert={r2['should_alert']}, reason={r2['reason']}")
|
||||
|
||||
r3 = update_state("FET/USDT", "爆发", score=12, anomaly_type="1H放量突破", sector="AI_DePIN")
|
||||
print(f"FET 爁发: alert={r3['should_alert']}, reason={r3['reason']}")
|
||||
|
||||
# 测试同级别不重复
|
||||
r4 = update_state("FET/USDT", "爆发", score=13, anomaly_type="1H放量突破+均线多头", sector="AI_DePIN")
|
||||
print(f"FET 爁发(重复): alert={r4['should_alert']}, reason={r4['reason']}")
|
||||
|
||||
# 测试分数显著提升
|
||||
r5 = update_state("FET/USDT", "爆发", score=16, anomaly_type="三级共振", sector="AI_DePIN")
|
||||
print(f"FET 爁发(分数升3+): alert={r5['should_alert']}, reason={r5['reason']}")
|
||||
|
||||
# 测试新币种首次检测
|
||||
r6 = update_state("PEPE/USDT", "蓄力", score=3, sector="MEME")
|
||||
print(f"PEPE 蓄力(首次): alert={r6['should_alert']}, reason={r6['reason']}")
|
||||
|
||||
r7 = update_state("PEPE/USDT", "加速", score=8, sector="MEME")
|
||||
print(f"PEPE 加速(首次): alert={r7['should_alert']}, reason={r7['reason']}")
|
||||
|
||||
# 查看活跃状态
|
||||
active = get_all_active()
|
||||
print(f"\n活跃状态: {len(active)}个")
|
||||
for a in active:
|
||||
print(f" {a['symbol']}: {a['state']} (score={a['score']})")
|
||||
|
||||
# 查看需要确认的候选
|
||||
candidates = get_candidates_for_confirm()
|
||||
print(f"\n需要确认的候选: {len(candidates)}个")
|
||||
430
config_loader.py
Normal file
430
config_loader.py
Normal file
@ -0,0 +1,430 @@
|
||||
"""
|
||||
策略配置加载器 — 从 rules.yaml 加载所有参数,支持热更新
|
||||
review_engine 调整权重后直接写回 yaml,下次运行自动生效
|
||||
"""
|
||||
import copy
|
||||
import datetime
|
||||
import os
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
RULES_PATH = os.path.join(os.path.dirname(__file__), "rules.yaml")
|
||||
|
||||
_cache = None
|
||||
_cache_mtime = None
|
||||
|
||||
# 兼容旧代码中的信号名写法
|
||||
_SIGNAL_NAME_ALIASES = {
|
||||
"N倍放量(≥10x)": "N倍放量",
|
||||
"连续3x放量(≥3根)": "连续3x放量",
|
||||
"静K→动K转折": "静K动K转折",
|
||||
"Q≥7供给区突破": "Q7供给区突破",
|
||||
"1H放量(量价背离)": "1H放量",
|
||||
}
|
||||
|
||||
|
||||
def load_rules(force_reload=False):
|
||||
"""加载 rules.yaml,带文件变更检测自动刷新缓存"""
|
||||
global _cache, _cache_mtime
|
||||
mtime = os.path.getmtime(RULES_PATH) if os.path.exists(RULES_PATH) else 0
|
||||
if not force_reload and _cache and _cache_mtime == mtime:
|
||||
return _cache
|
||||
|
||||
with open(RULES_PATH, "r", encoding="utf-8") as f:
|
||||
_cache = yaml.safe_load(f) or {}
|
||||
_cache_mtime = mtime
|
||||
return _cache
|
||||
|
||||
|
||||
def save_rules(rules_dict):
|
||||
"""保存修改后的 rules.yaml(review_engine 调整权重后用)"""
|
||||
global _cache, _cache_mtime
|
||||
with open(RULES_PATH, "w", encoding="utf-8") as f:
|
||||
yaml.dump(rules_dict, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||||
_cache = rules_dict
|
||||
_cache_mtime = os.path.getmtime(RULES_PATH)
|
||||
|
||||
|
||||
def _get_section(section_name, default=None):
|
||||
rules = load_rules()
|
||||
section = rules.get(section_name, default if default is not None else {})
|
||||
return copy.deepcopy(section)
|
||||
|
||||
|
||||
def _get_nested(section_name, path, default=None):
|
||||
node = load_rules().get(section_name, {})
|
||||
for key in path:
|
||||
if not isinstance(node, dict):
|
||||
return copy.deepcopy(default)
|
||||
node = node.get(key)
|
||||
if node is None:
|
||||
return copy.deepcopy(default)
|
||||
return copy.deepcopy(node)
|
||||
|
||||
|
||||
def normalize_signal_name(signal_name):
|
||||
"""统一信号名,兼容 rules.yaml 与旧 Python 代码中的不同写法"""
|
||||
return _SIGNAL_NAME_ALIASES.get(signal_name, signal_name)
|
||||
|
||||
|
||||
def get_strategy_params():
|
||||
"""返回全局策略约束"""
|
||||
return _get_section("strategy")
|
||||
|
||||
|
||||
def get_strategy_direction(default="多头启动"):
|
||||
return get_strategy_params().get("direction", default)
|
||||
|
||||
|
||||
def is_long_only():
|
||||
return get_strategy_params().get("mode", "") == "long_only"
|
||||
|
||||
|
||||
def allow_short(default=False):
|
||||
return bool(get_strategy_params().get("allow_short", default))
|
||||
|
||||
|
||||
def get_pa_params():
|
||||
"""返回 PA 引擎所有参数"""
|
||||
return _get_section("pa_engine")
|
||||
|
||||
|
||||
def get_pa_section(name=None):
|
||||
"""返回 PA 引擎某个子区块;name=None 时返回整个 pa_engine"""
|
||||
if name is None:
|
||||
return get_pa_params()
|
||||
return _get_nested("pa_engine", [name], {})
|
||||
|
||||
|
||||
def get_screener_params():
|
||||
"""返回粗筛所有参数"""
|
||||
return _get_section("screener")
|
||||
|
||||
|
||||
def get_event_driven_params():
|
||||
"""返回事件驱动舆情触发选币参数"""
|
||||
return _get_section("event_driven")
|
||||
|
||||
|
||||
def get_event_driven_section(name=None):
|
||||
"""返回 event_driven 某个子区块;name=None 时返回整个 event_driven"""
|
||||
if name is None:
|
||||
return get_event_driven_params()
|
||||
return _get_nested("event_driven", [name], {})
|
||||
|
||||
|
||||
def get_screener_section(name=None):
|
||||
"""返回 screener 某个子区块;name=None 时返回整个 screener"""
|
||||
if name is None:
|
||||
return get_screener_params()
|
||||
return _get_nested("screener", [name], {})
|
||||
|
||||
|
||||
def get_confirm_params():
|
||||
"""返回确认层所有参数"""
|
||||
return _get_section("confirm")
|
||||
|
||||
|
||||
def get_confirm_section(name=None):
|
||||
"""返回 confirm 某个子区块;name=None 时返回整个 confirm"""
|
||||
if name is None:
|
||||
return get_confirm_params()
|
||||
return _get_nested("confirm", [name], {})
|
||||
|
||||
|
||||
def get_tracker_params():
|
||||
"""返回跟踪层所有参数"""
|
||||
return _get_section("tracker")
|
||||
|
||||
|
||||
def get_signal_weights():
|
||||
"""返回信号权重 dict(优先用 DB signal_performance 的动态权重,fallback 到 yaml)
|
||||
|
||||
兼容层要求:
|
||||
- 规则侧统一存 canonical key(如 "1H放量")
|
||||
- 旧脚本仍可能用历史 key(如 "1H放量(量价背离)")直接查 weights[...]
|
||||
因此返回值同时暴露 canonical key + alias key,避免旧调用方 KeyError。
|
||||
"""
|
||||
rules = load_rules()
|
||||
yaml_weights = copy.deepcopy(rules.get("signal_weights", {}))
|
||||
|
||||
canonical = {}
|
||||
for sig, weight in yaml_weights.items():
|
||||
canonical[normalize_signal_name(sig)] = weight
|
||||
|
||||
try:
|
||||
from altcoin_db import get_signal_weights as db_get_weights
|
||||
db_weights = db_get_weights()
|
||||
for sig, data in db_weights.items():
|
||||
norm_sig = normalize_signal_name(sig)
|
||||
if data.get("total_count", 0) >= 3:
|
||||
canonical[norm_sig] = data["weight"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
merged = dict(canonical)
|
||||
for alias, target in _SIGNAL_NAME_ALIASES.items():
|
||||
if target in canonical:
|
||||
merged[alias] = canonical[target]
|
||||
return merged
|
||||
|
||||
|
||||
def get_review_params():
|
||||
"""返回复盘参数"""
|
||||
return _get_section("review")
|
||||
|
||||
|
||||
def get_reverse_params():
|
||||
"""返回逆向分析参数"""
|
||||
return _get_section("reverse_analysis")
|
||||
|
||||
|
||||
def get_learned_rules(active_only=True):
|
||||
"""返回已学习的规律列表"""
|
||||
rules = load_rules()
|
||||
learned = copy.deepcopy(rules.get("learned_rules", []))
|
||||
if active_only:
|
||||
return [r for r in learned if r.get("active", True)]
|
||||
return learned
|
||||
|
||||
|
||||
def add_learned_rule(rule_dict):
|
||||
"""添加一条新学习规律"""
|
||||
rules = load_rules(force_reload=True)
|
||||
learned = rules.get("learned_rules", [])
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M")
|
||||
rule_dict["id"] = f"rule_{ts}_{len(learned)+1:03d}"
|
||||
rule_dict["created"] = datetime.datetime.now().strftime("%Y-%m-%d")
|
||||
rule_dict["hit_count"] = 0
|
||||
rule_dict["miss_count"] = 0
|
||||
rule_dict["active"] = True
|
||||
learned.append(rule_dict)
|
||||
rules["learned_rules"] = learned
|
||||
rules.setdefault("meta", {})["total_rules_learned"] = len(learned)
|
||||
save_rules(rules)
|
||||
return rule_dict["id"]
|
||||
|
||||
|
||||
def update_learned_rule(rule_id, updates):
|
||||
"""更新一条规律(如 hit_count/miss_count/active 状态)"""
|
||||
rules = load_rules(force_reload=True)
|
||||
learned = rules.get("learned_rules", [])
|
||||
for r in learned:
|
||||
if r.get("id") == rule_id:
|
||||
for k, v in updates.items():
|
||||
r[k] = v
|
||||
break
|
||||
rules["learned_rules"] = learned
|
||||
save_rules(rules)
|
||||
|
||||
|
||||
def update_signal_weight(signal_name, new_weight):
|
||||
"""更新单个信号权重(写回 yaml + DB)"""
|
||||
canonical_name = normalize_signal_name(signal_name)
|
||||
rules = load_rules(force_reload=True)
|
||||
rules.setdefault("signal_weights", {})[canonical_name] = new_weight
|
||||
save_rules(rules)
|
||||
try:
|
||||
from altcoin_db import update_signal_performance
|
||||
update_signal_performance(canonical_name, category=None, is_hit=None, pnl=None, weight_override=new_weight)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def get_meta():
|
||||
"""返回迭代元数据"""
|
||||
meta = _get_section("meta")
|
||||
if not meta.get("strategy_version"):
|
||||
version_num = meta.get("version", 1)
|
||||
iteration = meta.get("iteration_count", 0)
|
||||
meta["strategy_version"] = f"v{version_num}.{iteration}"
|
||||
return meta
|
||||
|
||||
|
||||
def get_rules_snapshot():
|
||||
"""返回完整 rules.yaml 快照(深拷贝)"""
|
||||
return copy.deepcopy(load_rules())
|
||||
|
||||
|
||||
def diff_rule_snapshots(before, after, prefix=""):
|
||||
"""递归比较两个配置快照,输出 changed/added/removed"""
|
||||
result = {"changed": [], "added": [], "removed": []}
|
||||
|
||||
def walk(path, a, b):
|
||||
if isinstance(a, dict) and isinstance(b, dict):
|
||||
keys = set(a.keys()) | set(b.keys())
|
||||
for key in sorted(keys):
|
||||
next_path = f"{path}.{key}" if path else str(key)
|
||||
if key not in a:
|
||||
result["added"].append({"path": next_path, "new": copy.deepcopy(b[key])})
|
||||
elif key not in b:
|
||||
result["removed"].append({"path": next_path, "old": copy.deepcopy(a[key])})
|
||||
else:
|
||||
walk(next_path, a[key], b[key])
|
||||
return
|
||||
|
||||
if isinstance(a, list) and isinstance(b, list):
|
||||
if a != b:
|
||||
result["changed"].append({"path": path, "old": copy.deepcopy(a), "new": copy.deepcopy(b)})
|
||||
return
|
||||
|
||||
if a != b:
|
||||
result["changed"].append({"path": path, "old": copy.deepcopy(a), "new": copy.deepcopy(b)})
|
||||
|
||||
walk(prefix, before or {}, after or {})
|
||||
return result
|
||||
|
||||
|
||||
|
||||
def promote_candidate_rule_to_learned_rule(candidate, release_version=""):
|
||||
"""把通过发布门槛的候选规则正式写入 learned_rules。
|
||||
|
||||
候选规则来自 DB strategy_rule_candidate;只有发布闸门通过时才调用,
|
||||
避免日常研究直接污染主策略。
|
||||
"""
|
||||
desc = (candidate.get("rule_description") or "").strip()
|
||||
if not desc:
|
||||
return None
|
||||
rules = load_rules(force_reload=True)
|
||||
learned_rules = rules.setdefault("learned_rules", [])
|
||||
for existing in learned_rules:
|
||||
if existing.get("description") == desc:
|
||||
return existing.get("id") or existing.get("rule_id")
|
||||
rule_id = f"candidate_{candidate.get('id')}_{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}"
|
||||
rule = {
|
||||
"id": rule_id,
|
||||
"type": candidate.get("rule_type") or "bonus",
|
||||
"description": desc,
|
||||
"conditions": {
|
||||
"candidate_id": candidate.get("id"),
|
||||
"signal_name": candidate.get("signal_name") or "",
|
||||
"source": candidate.get("source") or "strategy_rule_candidate",
|
||||
"sample_size": candidate.get("sample_size") or 0,
|
||||
"confidence_score": candidate.get("confidence_score") or 0,
|
||||
},
|
||||
"score_adjust": 2 if (candidate.get("rule_type") or "") == "bonus" else -2,
|
||||
"source": "candidate_release_gate",
|
||||
"release_version": release_version or get_meta().get("strategy_version", ""),
|
||||
"created_at": datetime.datetime.now().isoformat(),
|
||||
}
|
||||
learned_rules.append(rule)
|
||||
save_rules(rules)
|
||||
return rule_id
|
||||
|
||||
|
||||
def bump_strategy_patch_version(note=""):
|
||||
"""正式发布时才提升 patch 版本号。"""
|
||||
import re
|
||||
meta = get_meta()
|
||||
current_ver = str(meta.get("strategy_version") or "v1.0.0").strip()
|
||||
m = re.match(r"^v(\d+)\.(\d+)\.(\d+)$", current_ver)
|
||||
if m:
|
||||
major, minor, patch = map(int, m.groups())
|
||||
new_ver = f"v{major}.{minor}.{patch + 1}"
|
||||
else:
|
||||
new_ver = current_ver + ".1"
|
||||
update_meta("strategy_version", new_ver)
|
||||
update_meta("strategy_revision_note", f"{new_ver}: {note}" if note else new_ver)
|
||||
update_meta("strategy_revision_started_at", datetime.datetime.now().isoformat())
|
||||
return current_ver, new_ver
|
||||
|
||||
def update_meta(key, value):
|
||||
"""更新迭代元数据"""
|
||||
rules = load_rules(force_reload=True)
|
||||
rules.setdefault("meta", {})[key] = value
|
||||
save_rules(rules)
|
||||
|
||||
|
||||
# === 快捷取值函数(给各模块直接 import 用)===
|
||||
|
||||
def dynamic_k_thresholds():
|
||||
p = get_pa_section("dynamic_k")
|
||||
return p["body_ratio_min"], p["atr_ratio_min"]
|
||||
|
||||
|
||||
def static_k_thresholds():
|
||||
p = get_pa_section("static_k")
|
||||
return p["body_ratio_max"], p["atr_ratio_max"]
|
||||
|
||||
|
||||
def zone_params():
|
||||
p = get_pa_section("supply_demand")
|
||||
return p["lookback"], p["min_static_count"], p["q_score_breakpoints"]
|
||||
|
||||
|
||||
def ignition_params():
|
||||
p = get_pa_section("ignition")
|
||||
return p["lookback"], p["min_static_count"], p["static_search_range"], p["confirm_search_range"]
|
||||
|
||||
|
||||
def continuous_k_params():
|
||||
return get_pa_section("continuous_k")
|
||||
|
||||
|
||||
def exhaustion_params():
|
||||
return get_pa_section("exhaustion")
|
||||
|
||||
|
||||
def entry_point_params():
|
||||
return get_pa_section("entry_point")
|
||||
|
||||
|
||||
def burst_thresholds():
|
||||
p = get_screener_section("burst_threshold")
|
||||
return p["main"], p["meme"], p["overbought_multiplier"]
|
||||
|
||||
|
||||
def volume_thresholds():
|
||||
p = get_screener_section("volume")
|
||||
return p["min_usd"], p["meme_min_usd"]
|
||||
|
||||
|
||||
def vp_fly_params():
|
||||
return get_screener_section("vp_fly")
|
||||
|
||||
|
||||
def bollinger_squeeze_params():
|
||||
return get_screener_section("bollinger_squeeze")
|
||||
|
||||
|
||||
def funding_rate_params():
|
||||
return get_screener_section("funding_rate")
|
||||
|
||||
|
||||
def top_trader_params():
|
||||
return get_screener_section("top_trader")
|
||||
|
||||
|
||||
def state_score_thresholds():
|
||||
p = get_screener_section("state_threshold")
|
||||
return p["accelerate_main"], p["accelerate_meme"], p["accumulate"]
|
||||
|
||||
|
||||
def confirm_min_score():
|
||||
return get_confirm_params().get("min_score", 6)
|
||||
|
||||
|
||||
def confirm_volume_breakout_ratio():
|
||||
return get_confirm_params().get("volume_breakout_ratio", 2.0)
|
||||
|
||||
|
||||
def confirm_state_cooldown_hours():
|
||||
return get_confirm_params().get("state_cooldown_hours", 6)
|
||||
|
||||
|
||||
def confirm_atr_multipliers():
|
||||
return get_confirm_section("atr_multiplier")
|
||||
|
||||
|
||||
def confirm_stop_loss_params():
|
||||
return get_confirm_section("stop_loss")
|
||||
|
||||
|
||||
def get_sentiment_params():
|
||||
"""返回舆情监控所有参数"""
|
||||
return _get_section("sentiment")
|
||||
|
||||
|
||||
def sentiment_max_bonus():
|
||||
return get_sentiment_params().get("max_bonus", 2)
|
||||
46
docker-compose.yml
Normal file
46
docker-compose.yml
Normal file
@ -0,0 +1,46 @@
|
||||
services:
|
||||
alphax-web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: alphax:local
|
||||
container_name: alphax-web
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
PORT: "8190"
|
||||
ALPHAX_DB_PATH: "/app/data/altcoin_monitor.db"
|
||||
command: ["web"]
|
||||
ports:
|
||||
- "8191:8190"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
- ./rules.yaml:/app/rules.yaml:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8190/api/stats >/dev/null || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
alphax-scheduler:
|
||||
image: alphax:local
|
||||
container_name: alphax-scheduler
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
alphax-web:
|
||||
condition: service_started
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# 默认 dry-run,确保第一次 docker compose up 不会直接写库/推送。
|
||||
# 验证无误后改成 0。
|
||||
ALPHAX_SCHEDULER_DRY_RUN: "1"
|
||||
ALPHAX_DB_PATH: "/app/data/altcoin_monitor.db"
|
||||
command: ["scheduler"]
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
- ./rules.yaml:/app/rules.yaml:ro
|
||||
30
docker/entrypoint.sh
Executable file
30
docker/entrypoint.sh
Executable file
@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd /app
|
||||
mkdir -p /app/data /app/logs
|
||||
|
||||
export ALPHAX_DB_PATH="${ALPHAX_DB_PATH:-/app/data/altcoin_monitor.db}"
|
||||
# 若首次启动没有 DB,则创建空文件,init_db 会补表结构。
|
||||
if [ ! -e "$ALPHAX_DB_PATH" ]; then
|
||||
touch "$ALPHAX_DB_PATH"
|
||||
fi
|
||||
|
||||
case "${1:-web}" in
|
||||
web)
|
||||
exec python -m uvicorn web_server:app --host 0.0.0.0 --port "${PORT:-8190}"
|
||||
;;
|
||||
scheduler)
|
||||
exec python /app/docker/scheduler.py
|
||||
;;
|
||||
once)
|
||||
shift
|
||||
exec python "$@"
|
||||
;;
|
||||
shell)
|
||||
exec bash
|
||||
;;
|
||||
*)
|
||||
exec "$@"
|
||||
;;
|
||||
esac
|
||||
106
docker/scheduler.py
Executable file
106
docker/scheduler.py
Executable file
@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AlphaX 容器内轻量调度器。
|
||||
|
||||
设计目标:
|
||||
- 替代宿主机 crontab;
|
||||
- 单进程串行执行,避免 SQLite 并发写锁;
|
||||
- 默认 DRY_RUN=1,不影响线上,也不会真的跑任务;
|
||||
- 部署验证通过后再把 ALPHAX_SCHEDULER_DRY_RUN=0 打开。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PYTHON = sys.executable
|
||||
DRY_RUN = os.getenv("ALPHAX_SCHEDULER_DRY_RUN", "1").strip() not in {"0", "false", "False", "no", "NO"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Job:
|
||||
name: str
|
||||
script: str
|
||||
every_seconds: int
|
||||
args: tuple[str, ...] = ()
|
||||
initial_delay: int = 0
|
||||
next_run: float = 0.0
|
||||
|
||||
|
||||
def now_str() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def env_for_child() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
env.setdefault("PYTHONUNBUFFERED", "1")
|
||||
return env
|
||||
|
||||
|
||||
def run_job(job: Job) -> None:
|
||||
cmd = [PYTHON, str(ROOT / job.script), *job.args]
|
||||
print(f"[{now_str()}] [scheduler] start {job.name}: {' '.join(cmd)}", flush=True)
|
||||
if DRY_RUN:
|
||||
print(f"[{now_str()}] [scheduler] DRY_RUN=1 skip {job.name}", flush=True)
|
||||
return
|
||||
started = time.time()
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
cwd=ROOT,
|
||||
env=env_for_child(),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
timeout=max(job.every_seconds * 2, 600),
|
||||
)
|
||||
duration = time.time() - started
|
||||
out = (proc.stdout or "").strip()
|
||||
if len(out) > 8000:
|
||||
out = out[-8000:]
|
||||
print(f"[{now_str()}] [scheduler] done {job.name} exit={proc.returncode} duration={duration:.1f}s", flush=True)
|
||||
if out:
|
||||
print(out, flush=True)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
print(f"[{now_str()}] [scheduler] timeout {job.name}: {e}", flush=True)
|
||||
except Exception as e:
|
||||
print(f"[{now_str()}] [scheduler] error {job.name}: {e}", flush=True)
|
||||
|
||||
|
||||
def build_jobs() -> list[Job]:
|
||||
# 与当前宿主机 crontab 对齐,但串行执行。
|
||||
return [
|
||||
Job("event", "event_driven_screener.py", 60, ("--once",), initial_delay=5),
|
||||
Job("tracker", "price_tracker.py", 180, initial_delay=20),
|
||||
Job("confirm", "altcoin_confirm.py", 600, initial_delay=40),
|
||||
Job("screener", "altcoin_screener.py", 900, initial_delay=80),
|
||||
Job("sentiment", "sentiment_monitor.py", 1800, ("--collect",), initial_delay=120),
|
||||
Job("review", "review_engine.py", 24 * 3600, initial_delay=300),
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
jobs = build_jobs()
|
||||
base = time.time()
|
||||
for job in jobs:
|
||||
job.next_run = base + job.initial_delay
|
||||
print(f"[{now_str()}] [scheduler] started jobs={len(jobs)} dry_run={DRY_RUN}", flush=True)
|
||||
while True:
|
||||
now = time.time()
|
||||
due = [j for j in jobs if now >= j.next_run]
|
||||
if not due:
|
||||
time.sleep(1)
|
||||
continue
|
||||
# 串行执行;一个 job 跑完才跑下一个,避免 SQLite 写锁。
|
||||
for job in sorted(due, key=lambda j: j.next_run):
|
||||
run_job(job)
|
||||
job.next_run = time.time() + job.every_seconds
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
40
docs/factor_recency_audit.md
Normal file
40
docs/factor_recency_audit.md
Normal file
@ -0,0 +1,40 @@
|
||||
# 因子时效性审计(2026-05-11)
|
||||
|
||||
目标:禁止把“过去很久发生的事件”当作“当前正在发生的触发信号”。
|
||||
|
||||
## 原则
|
||||
|
||||
- 当前触发:必须发生在最近 1 根或上一根对应周期 K 线内。
|
||||
- 历史结构:可以作为背景/风险说明,但不能加当前触发分,不能进入强门控。
|
||||
- 文案必须区分“当前信号”和“历史已过期信号”。
|
||||
|
||||
## 已修复
|
||||
|
||||
| 因子 | 旧问题 | 新规则 | 文件 |
|
||||
|---|---|---|---|
|
||||
| 1H量价齐飞 | 最近12根内任一放量阳线都可能计入当前信号 | 只承认最近/上一根1H;旧信号进入 stale_vp_fly_details | altcoin_screener.py, altcoin_confirm.py |
|
||||
| 1H起爆点 | 最近20根内旧起爆点仍加分 | 起爆点带 age_bars;只承认 age_bars<=1 | pa_engine.py, altcoin_confirm.py |
|
||||
| 4H起爆点 | 细筛/强共振可能使用旧4H起爆点 | 只承认 age_bars<=1;旧信号标记“历史起爆点已过期” | altcoin_screener.py, altcoin_confirm.py |
|
||||
| 日线起爆点 | 日线旧起爆点仍可能作为当前日线强信号 | 只承认最近1根日线内发生 | altcoin_confirm.py |
|
||||
| 强共振旁路 | 旧起爆点可绕过量价齐飞门控 | 只使用时效内起爆点 | altcoin_confirm.py |
|
||||
| 事件驱动起爆点 | 舆情触发技术确认可能用旧1H起爆点加分 | 只承认 age_bars<=1 | event_driven_screener.py |
|
||||
| 15min突破K | 最近6根内突破可能被描述成“正在发生” | 只承认最近/上一根15min突破K | pa_engine.py |
|
||||
|
||||
## 仍作为结构背景的因子
|
||||
|
||||
以下因子不是“当前触发”,允许跨较长窗口存在,但文案不应写成正在发生:
|
||||
|
||||
- 筑底 N 根
|
||||
- 需求区/供需区位置
|
||||
- 底部抬高
|
||||
- 布林收窄
|
||||
- 静K蓄力
|
||||
- 板块联动/大户偏多/资金费率
|
||||
|
||||
这些因子只能作为背景/环境加权,不应单独触发“可即刻买入”。
|
||||
|
||||
## 验证
|
||||
|
||||
- tests/test_vp_fly_recency.py
|
||||
- tests/test_pa_recency.py
|
||||
- tests/test_screener_optimizations.py
|
||||
714
event_driven_screener.py
Normal file
714
event_driven_screener.py
Normal file
@ -0,0 +1,714 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
事件驱动舆情触发选币 v1.7.3
|
||||
|
||||
目标:重大消息刚发生 → 时间窗/去重/重要性评级 → 单币快速技术检查 → 飞书推送。
|
||||
原则:消息只负责触发检查,技术形态决定是否推荐。
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import hashlib
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email.utils import parsedate_to_datetime
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
import ccxt
|
||||
import pandas as pd
|
||||
import requests
|
||||
import yaml
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from config_loader import load_rules, get_meta, get_strategy_direction
|
||||
from altcoin_db import init_db, get_conn, create_recommendation, log_screening, log_cron_run, should_push, log_push, get_recommendation_for_push
|
||||
from altcoin_screener import (
|
||||
fetch_all_tickers,
|
||||
detect_volume_price_fly,
|
||||
detect_static_accumulation,
|
||||
STABLECOINS,
|
||||
WRAPPED,
|
||||
BTC_ETH,
|
||||
GOLD_METAL,
|
||||
BNB_CHAIN,
|
||||
EXCLUDED_BASES,
|
||||
EXCLUDED_BASE_SUFFIXES,
|
||||
)
|
||||
from altcoin_confirm import fetch_derivatives_context
|
||||
from pa_engine import full_pa_analysis, calc_atr
|
||||
from feishu_push import push_recommendation_state_alert
|
||||
|
||||
DB_PATH = os.getenv("ALPHAX_DB_PATH", os.path.join(os.path.dirname(__file__), "data", "altcoin_monitor.db"))
|
||||
exchange = ccxt.binance({"enableRateLimit": True})
|
||||
|
||||
LEVEL_RANK = {"S": 4, "A": 3, "B": 2, "C": 1, "D": 0, "RISK": 5}
|
||||
|
||||
|
||||
def _level_max(a, b):
|
||||
"""返回重要性更高的级别。"""
|
||||
return a if LEVEL_RANK.get(a, 0) >= LEVEL_RANK.get(b, 0) else b
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now()
|
||||
|
||||
|
||||
def _cfg():
|
||||
return load_rules(force_reload=True).get("event_driven", {})
|
||||
|
||||
|
||||
def _parse_binance_time(ms):
|
||||
try:
|
||||
return datetime.fromtimestamp(int(ms) / 1000)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_pubdate(value):
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
dt = parsedate_to_datetime(value)
|
||||
if dt.tzinfo:
|
||||
dt = dt.astimezone().replace(tzinfo=None)
|
||||
return dt
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _is_recent(dt, max_hours=None):
|
||||
if not dt:
|
||||
return False
|
||||
hours = max_hours or _cfg().get("news_time_window_hours", 3)
|
||||
return (_now() - dt) <= timedelta(hours=hours) and dt <= _now() + timedelta(minutes=5)
|
||||
|
||||
|
||||
def _event_hash(source, title, symbol):
|
||||
raw = f"{source}|{title}|{symbol}".lower().strip()
|
||||
return hashlib.sha256(raw.encode()).hexdigest()[:20]
|
||||
|
||||
|
||||
def init_event_tables():
|
||||
conn = get_conn()
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS event_news (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_hash TEXT UNIQUE,
|
||||
source TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT DEFAULT '',
|
||||
published_at TEXT NOT NULL,
|
||||
detected_at TEXT NOT NULL,
|
||||
importance TEXT DEFAULT 'B',
|
||||
event_type TEXT DEFAULT '',
|
||||
raw_json TEXT DEFAULT '{}',
|
||||
processed INTEGER DEFAULT 0,
|
||||
decision TEXT DEFAULT '',
|
||||
tech_score INTEGER DEFAULT 0,
|
||||
rec_id INTEGER DEFAULT 0,
|
||||
pushed INTEGER DEFAULT 0
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_event_news_time ON event_news(published_at, detected_at)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_event_news_symbol ON event_news(symbol)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def _symbol_from_title(title):
|
||||
"""从标题里提取可能的币种,返回 AAA/USDT 列表。"""
|
||||
text = title or ""
|
||||
candidates = set()
|
||||
|
||||
# XXXUSDT / XXXUSDT Perpetual
|
||||
for m in re.finditer(r"\b([A-Z0-9]{2,15})USDT\b", text):
|
||||
base = m.group(1).upper()
|
||||
candidates.add(f"{base}/USDT")
|
||||
|
||||
# Binance Will List XXX (Name) / Add XXX
|
||||
patterns = [
|
||||
r"Will List\s+([A-Z0-9]{2,15})(?![A-Z0-9]*USDT)\b",
|
||||
r"Will Launch\s+([A-Z0-9]{2,15})(?![A-Z0-9]*USDT)\b",
|
||||
r"Will Add\s+([A-Z0-9]{2,15})(?![A-Z0-9]*USDT)\b",
|
||||
r"Earn\s+([A-Z0-9]{2,15})(?![A-Z0-9]*USDT)\b",
|
||||
r"Add\s+([A-Z0-9]{2,15})(?![A-Z0-9]*USDT)\b",
|
||||
]
|
||||
for pat in patterns:
|
||||
for m in re.finditer(pat, text, flags=re.I):
|
||||
base = m.group(1).upper()
|
||||
if base.endswith("USDT"):
|
||||
continue
|
||||
candidates.add(f"{base}/USDT")
|
||||
|
||||
# 括号中的 ticker
|
||||
for m in re.finditer(r"\(([A-Z0-9]{2,15})\)", text):
|
||||
base = m.group(1).upper()
|
||||
candidates.add(f"{base}/USDT")
|
||||
|
||||
return [s for s in sorted(candidates) if _tradable_symbol(s)]
|
||||
|
||||
|
||||
def _tradable_symbol(symbol):
|
||||
base = symbol.split("/")[0].upper()
|
||||
if base in STABLECOINS or base in WRAPPED or base in BTC_ETH or base in GOLD_METAL or base in BNB_CHAIN:
|
||||
return False
|
||||
if base in EXCLUDED_BASES or base.endswith(EXCLUDED_BASE_SUFFIXES):
|
||||
return False
|
||||
if not base.isascii():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _base_symbol(symbol):
|
||||
return (symbol or "").split("/")[0].upper()
|
||||
|
||||
|
||||
def _theme_cfg():
|
||||
return _cfg().get("theme_expansion", {}) or {}
|
||||
|
||||
|
||||
def _theme_definitions():
|
||||
return _theme_cfg().get("themes", {}) or {}
|
||||
|
||||
|
||||
def _matched_themes(title="", symbol=""):
|
||||
"""识别标题/命中币种所属的生态主题。"""
|
||||
if not _theme_cfg().get("enabled", False):
|
||||
return []
|
||||
low = (title or "").lower()
|
||||
base = _base_symbol(symbol)
|
||||
matched = []
|
||||
for theme_name, theme in _theme_definitions().items():
|
||||
keywords = [str(k).lower() for k in theme.get("keywords", [])]
|
||||
primary = {str(s).upper() for s in theme.get("primary_symbols", [])}
|
||||
symbols = {str(s).upper() for s in theme.get("symbols", [])}
|
||||
if any(k and k in low for k in keywords) or base in primary or base in symbols:
|
||||
matched.append((theme_name, theme))
|
||||
return matched
|
||||
|
||||
|
||||
def _event_copy_for_symbol(event, symbol, theme_name, theme, expanded=True):
|
||||
min_level = _theme_cfg().get("min_theme_importance", "A")
|
||||
importance = _level_max(event.get("importance", "B"), min_level)
|
||||
original_title = event.get("title", "")
|
||||
return {
|
||||
**event,
|
||||
"symbol": symbol,
|
||||
"importance": importance,
|
||||
"event_type": "theme_expansion" if expanded else "theme_direct",
|
||||
"title": f"[主题扩散:{theme_name}] {original_title}",
|
||||
"raw": {
|
||||
"parent_event": event.get("raw", {}),
|
||||
"parent_symbol": event.get("symbol", ""),
|
||||
"theme": theme_name,
|
||||
"theme_symbols": theme.get("symbols", []),
|
||||
"expansion_reason": theme.get("note", "生态主题消息扩散"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def expand_theme_events(events):
|
||||
"""重大生态/主题事件扩散到同生态币,解决 TON/DOGS 这类联动行情漏选。"""
|
||||
if not _theme_cfg().get("enabled", False):
|
||||
return events
|
||||
expanded = list(events)
|
||||
seen = {(e.get("source"), e.get("title"), e.get("symbol")) for e in expanded}
|
||||
for e in events:
|
||||
for theme_name, theme in _matched_themes(e.get("title", ""), e.get("symbol", "")):
|
||||
direct = _event_copy_for_symbol(e, e.get("symbol"), theme_name, theme, expanded=False)
|
||||
key = (direct.get("source"), direct.get("title"), direct.get("symbol"))
|
||||
if direct.get("symbol") and key not in seen and _tradable_symbol(direct.get("symbol")):
|
||||
expanded.append(direct)
|
||||
seen.add(key)
|
||||
|
||||
for base in theme.get("symbols", []):
|
||||
symbol = f"{str(base).upper()}/USDT"
|
||||
if symbol == e.get("symbol") or not _tradable_symbol(symbol):
|
||||
continue
|
||||
child = _event_copy_for_symbol(e, symbol, theme_name, theme, expanded=True)
|
||||
key = (child.get("source"), child.get("title"), child.get("symbol"))
|
||||
if key not in seen:
|
||||
expanded.append(child)
|
||||
seen.add(key)
|
||||
return expanded
|
||||
|
||||
|
||||
def classify_event(title, source=""):
|
||||
cfg = _cfg().get("importance", {})
|
||||
low = (title or "").lower()
|
||||
negs = [k.lower() for k in cfg.get("negative_keywords", [])]
|
||||
s_keys = [k.lower() for k in cfg.get("s_keywords", [])]
|
||||
a_keys = [k.lower() for k in cfg.get("a_keywords", [])]
|
||||
|
||||
if any(k in low for k in negs):
|
||||
return "RISK", "risk_negative"
|
||||
if any(k in low for k in s_keys):
|
||||
return "S", "major_listing_or_contract"
|
||||
if any(k in low for k in a_keys):
|
||||
return "A", "important_catalyst"
|
||||
if "trending" in source.lower() or "coingecko" in source.lower():
|
||||
return "B", "market_heat"
|
||||
return "C", "minor_or_unknown"
|
||||
|
||||
|
||||
def _passes_min_importance(level):
|
||||
min_level = _cfg().get("min_importance_level", "A")
|
||||
if level == "RISK":
|
||||
return True
|
||||
return LEVEL_RANK.get(level, 0) >= LEVEL_RANK.get(min_level, 3)
|
||||
|
||||
|
||||
def fetch_binance_events(source_key, source_cfg):
|
||||
if not source_cfg.get("enabled", True):
|
||||
return []
|
||||
url = source_cfg.get("url")
|
||||
events = []
|
||||
try:
|
||||
r = requests.get(url, timeout=10, headers={"User-Agent": "Mozilla/5.0"})
|
||||
if r.status_code != 200:
|
||||
return []
|
||||
data = r.json()
|
||||
catalogs = (data.get("data") or {}).get("catalogs") or []
|
||||
for cat in catalogs:
|
||||
for a in cat.get("articles", []) or []:
|
||||
title = a.get("title", "")
|
||||
pub = _parse_binance_time(a.get("releaseDate"))
|
||||
if not _is_recent(pub, _cfg().get("news_time_window_hours", 3)):
|
||||
continue
|
||||
symbols = _symbol_from_title(title)
|
||||
if not symbols:
|
||||
continue
|
||||
importance, event_type = classify_event(title, source_key)
|
||||
if not _passes_min_importance(importance):
|
||||
continue
|
||||
code = a.get("code") or ""
|
||||
link = f"https://www.binance.com/en/support/announcement/{code}" if code else ""
|
||||
for symbol in symbols:
|
||||
events.append({
|
||||
"source": source_key,
|
||||
"symbol": symbol,
|
||||
"title": title,
|
||||
"url": link,
|
||||
"published_at": pub,
|
||||
"importance": importance,
|
||||
"event_type": event_type,
|
||||
"raw": a,
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"[event] fetch_binance_events error {source_key}: {e}")
|
||||
return events
|
||||
|
||||
|
||||
def fetch_coingecko_trending_events():
|
||||
cfg = _cfg().get("sources", {}).get("coingecko_trending", {})
|
||||
if not cfg.get("enabled", True):
|
||||
return []
|
||||
try:
|
||||
from sentiment_monitor import fetch_trending_coins, _get_previous_trending
|
||||
trending = fetch_trending_coins()
|
||||
prev = {r["symbol"] for r in _get_previous_trending()}
|
||||
events = []
|
||||
now = _now()
|
||||
for t in trending[:10]:
|
||||
sym = (t.get("symbol") or "").upper()
|
||||
full = f"{sym}/USDT"
|
||||
if not _tradable_symbol(full):
|
||||
continue
|
||||
# Trending 只作为热度源:必须同时满足新进Top10/Top5 + 交易所可交易,且后续仍需技术确认。
|
||||
if sym not in prev or t.get("trend_rank", 99) <= 5:
|
||||
title = f"{sym}({t.get('name','')}) enters CoinGecko Trending #{t.get('trend_rank')}"
|
||||
events.append({
|
||||
"source": "coingecko_trending",
|
||||
"symbol": full,
|
||||
"title": title,
|
||||
"url": "https://www.coingecko.com/en/trending-crypto",
|
||||
"published_at": now,
|
||||
"importance": "B",
|
||||
"event_type": "market_heat",
|
||||
"raw": t,
|
||||
})
|
||||
return events
|
||||
except Exception as e:
|
||||
print(f"[event] fetch trending error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def collect_events():
|
||||
cfg = _cfg()
|
||||
sources = cfg.get("sources", {})
|
||||
events = []
|
||||
for key in ("binance_listing", "binance_latest"):
|
||||
if key in sources:
|
||||
events.extend(fetch_binance_events(key, sources[key]))
|
||||
events.extend(fetch_coingecko_trending_events())
|
||||
return expand_theme_events(events)
|
||||
|
||||
|
||||
def store_events(events):
|
||||
init_event_tables()
|
||||
conn = get_conn()
|
||||
stored = []
|
||||
now = _now().isoformat()
|
||||
for e in events:
|
||||
h = _event_hash(e["source"], e["title"], e["symbol"])
|
||||
try:
|
||||
conn.execute("""
|
||||
INSERT INTO event_news
|
||||
(event_hash, source, symbol, title, url, published_at, detected_at, importance, event_type, raw_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
h, e["source"], e["symbol"], e["title"], e.get("url", ""),
|
||||
e["published_at"].isoformat(), now, e["importance"], e["event_type"],
|
||||
json.dumps(e.get("raw", {}), ensure_ascii=False),
|
||||
))
|
||||
e["event_hash"] = h
|
||||
stored.append(e)
|
||||
except sqlite3.IntegrityError:
|
||||
continue
|
||||
except Exception as ex:
|
||||
print(f"[event] store error {e.get('title')}: {ex}")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return stored
|
||||
|
||||
|
||||
def fetch_klines(symbol, timeframe, limit=120):
|
||||
try:
|
||||
ohlcv = exchange.fetch_ohlcv(symbol, timeframe, limit=limit)
|
||||
df = pd.DataFrame(ohlcv, columns=["timestamp", "open", "high", "low", "close", "volume"])
|
||||
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms")
|
||||
return df
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _ticker_info(symbol):
|
||||
try:
|
||||
t = exchange.fetch_ticker(symbol)
|
||||
return {
|
||||
"price": float(t.get("last") or 0),
|
||||
"change_24h": float(t.get("percentage") or 0),
|
||||
"volume_24h": float(t.get("quoteVolume") or 0),
|
||||
}
|
||||
except Exception:
|
||||
return {"price": 0, "change_24h": 0, "volume_24h": 0}
|
||||
|
||||
|
||||
def quick_technical_check(event):
|
||||
symbol = event["symbol"]
|
||||
cfg = _cfg().get("technical_check", {})
|
||||
ticker = _ticker_info(symbol)
|
||||
price = ticker["price"]
|
||||
signals = [f"📢 {event['importance']}级舆情触发: {event['title']}"]
|
||||
score = 0
|
||||
decision = "ignore"
|
||||
reason = ""
|
||||
entry_plan = {}
|
||||
|
||||
if price <= 0:
|
||||
return {"decision": "ignore", "reason": "交易对不可用或无价格", "score": 0, "signals": signals, "price": price}
|
||||
|
||||
if event.get("importance") == "RISK":
|
||||
return {"decision": "risk", "reason": "负面重大消息,禁买/风控", "score": 0, "signals": signals, "price": price, "ticker": ticker}
|
||||
|
||||
reject_gain = cfg.get("reject_if_24h_gain_gt", 30)
|
||||
warn_gain = cfg.get("warn_if_24h_gain_gt", 18)
|
||||
if ticker["change_24h"] > reject_gain:
|
||||
signals.append(f"⛔ 24h已涨{ticker['change_24h']:.1f}%>{reject_gain}%,不追高")
|
||||
return {"decision": "risk", "reason": "重大消息但已过度拉升,不追高", "score": 0, "signals": signals, "price": price, "ticker": ticker}
|
||||
elif ticker["change_24h"] > warn_gain:
|
||||
signals.append(f"⚠️ 24h已涨{ticker['change_24h']:.1f}%,追高风险升高")
|
||||
score -= 1
|
||||
|
||||
h1 = fetch_klines(symbol, "1h", 100)
|
||||
h4 = fetch_klines(symbol, "4h", 100)
|
||||
if h1 is None or len(h1) < 30:
|
||||
return {"decision": "observe", "reason": "K线数据不足,仅观察", "score": 0, "signals": signals, "price": price, "ticker": ticker}
|
||||
|
||||
current_triggers = [{"type": "news", "label": event.get("event_type") or "消息触发", "source": event.get("source"), "title": event.get("title"), "published_at": event.get("published_at").isoformat() if hasattr(event.get("published_at"), "isoformat") else str(event.get("published_at", ""))}]
|
||||
stale_background = []
|
||||
|
||||
vp = detect_volume_price_fly(h1)
|
||||
if vp:
|
||||
if vp.get("vp_fly_count", 0) >= 1:
|
||||
score += 5
|
||||
signals.append(f"1H量价齐飞({vp.get('vp_fly_count')}根, 最大量比{vp.get('max_vol_ratio')}x)")
|
||||
current_triggers.append({"type": "technical", "label": "当前1H量价齐飞", "source": "binance_ohlcv_1h", "age_hours": vp.get("latest_vp_age_hours")})
|
||||
elif vp.get("relaxed_vp_fly_count", 0) >= 2:
|
||||
score += 4
|
||||
signals.append(f"1H连续放宽量价齐飞({vp.get('relaxed_vp_fly_count')}根)")
|
||||
elif vp.get("stale_vp_fly_count", 0):
|
||||
stale = vp.get("stale_vp_fly_details", [{}])[-1]
|
||||
signals.append(f"1H历史量价齐飞已过期({stale.get('age_hours')}小时前, 量{stale.get('vol_ratio')}x)")
|
||||
stale_background.append({"type": "technical", "label": "历史1H量价齐飞", "source": "binance_ohlcv_1h", "age_hours": stale.get("age_hours"), "vol_ratio": stale.get("vol_ratio")})
|
||||
elif vp.get("max_consecutive_3x", 0) >= 2:
|
||||
score += 2
|
||||
signals.append(f"1H连续{vp.get('max_consecutive_3x')}根3x放量")
|
||||
|
||||
static_acc = detect_static_accumulation(symbol, h4) if h4 is not None and len(h4) >= 30 else None
|
||||
if static_acc:
|
||||
score += 3
|
||||
signals.append(f"4H静K蓄力({static_acc['static_count']}静K,量比{static_acc['vol_ratio']}x)")
|
||||
current_triggers.append({"type": "technical", "label": "当前4H静K蓄力", "source": "pa_engine_4h"})
|
||||
|
||||
theme_bonus_cfg = _theme_cfg().get("static_accumulation_bonus", {}) or {}
|
||||
if event.get("event_type") in ("theme_expansion", "theme_direct") and theme_bonus_cfg.get("enabled", True):
|
||||
min_static = theme_bonus_cfg.get("min_static_count", 8)
|
||||
bonus = theme_bonus_cfg.get("score_bonus", 3)
|
||||
if static_acc.get("static_count", 0) >= min_static:
|
||||
score += bonus
|
||||
signals.append(f"生态主题+强静K蓄力升权(+{bonus})")
|
||||
|
||||
pa1 = full_pa_analysis(h1, "1h")
|
||||
ignitions = pa1.get("ignition_points", []) if pa1 else []
|
||||
max_ig = 0
|
||||
stale_igs = []
|
||||
for ig in ignitions[-5:]:
|
||||
if ig.get("direction") != 1:
|
||||
continue
|
||||
if ig.get("age_bars", 999) <= 1:
|
||||
max_ig = max(max_ig, ig.get("strength_ratio", 0))
|
||||
else:
|
||||
stale_igs.append(ig)
|
||||
if max_ig >= 5:
|
||||
score += 3
|
||||
signals.append(f"1H静K→阳动K起爆(强度{max_ig}×)")
|
||||
current_triggers.append({"type": "technical", "label": "当前1H起爆点", "source": "pa_engine_1h", "strength": max_ig})
|
||||
elif stale_igs:
|
||||
ig = stale_igs[-1]
|
||||
signals.append(f"1H历史起爆点已过期({ig.get('age_bars')}根前, 强度{ig.get('strength_ratio')}×)")
|
||||
stale_background.append({"type": "technical", "label": "历史1H起爆点", "source": "pa_engine_1h", "age_bars": ig.get("age_bars"), "strength": ig.get("strength_ratio")})
|
||||
|
||||
deriv = fetch_derivatives_context(symbol)
|
||||
funding = deriv.get("funding_rate", 0) or 0
|
||||
if funding > cfg.get("reject_if_funding_gt", 0.003):
|
||||
signals.append(f"⛔ Funding过热({funding*100:.3f}%)")
|
||||
return {"decision": "risk", "reason": "资金费率过热,不追", "score": score, "signals": signals, "price": price, "ticker": ticker, "derivatives": deriv}
|
||||
if deriv.get("top_trader_long_pct", 0) and deriv.get("top_trader_long_pct", 0) > 55:
|
||||
score += 1
|
||||
signals.append(f"大户偏多({deriv.get('top_trader_long_pct')}%)")
|
||||
|
||||
atr = calc_atr(h1, 14)
|
||||
if atr and atr > 0:
|
||||
stop_loss = round(max(price * 0.92, price - 2 * atr), 6)
|
||||
tp1 = round(price * 1.05, 6)
|
||||
tp2 = round(price * 1.10, 6)
|
||||
risk = price - stop_loss
|
||||
entry_plan = {
|
||||
"entry_price": round(price, 6),
|
||||
"entry_method": "事件驱动即时技术确认",
|
||||
"entry_action": "可即刻买入" if score >= cfg.get("min_tech_score_recommend", 6) else "等技术确认",
|
||||
"stop_loss": stop_loss,
|
||||
"stop_pct": round((stop_loss / price - 1) * 100, 1) if price else 0,
|
||||
"tp1": tp1,
|
||||
"tp2": tp2,
|
||||
"rr1": round((tp1 - price) / risk, 2) if risk > 0 else 0,
|
||||
"rr2": round((tp2 - price) / risk, 2) if risk > 0 else 0,
|
||||
"current_price": round(price, 6),
|
||||
"risk_reward_ok": risk > 0,
|
||||
"trigger_context": {
|
||||
"trigger_status": "news_current" if current_triggers else "background",
|
||||
"trigger_label": "消息面触发 + 技术确认" if current_triggers else "消息背景观察",
|
||||
"current_triggers": current_triggers,
|
||||
"stale_background": stale_background,
|
||||
"event_source": event.get("source"),
|
||||
"event_title": event.get("title"),
|
||||
"event_url": event.get("url"),
|
||||
"event_importance": event.get("importance"),
|
||||
"published_at": event.get("published_at").isoformat() if hasattr(event.get("published_at"), "isoformat") else str(event.get("published_at", "")),
|
||||
},
|
||||
}
|
||||
|
||||
if score >= cfg.get("min_tech_score_recommend", 6) and event.get("importance") in ("S", "A"):
|
||||
decision = "recommend"
|
||||
reason = "重大消息+技术形态确认"
|
||||
elif score >= cfg.get("min_tech_score_observe", 3):
|
||||
decision = "observe"
|
||||
reason = "消息重大但技术只到观察级"
|
||||
else:
|
||||
decision = "ignore"
|
||||
reason = "技术形态未确认"
|
||||
|
||||
return {
|
||||
"decision": decision,
|
||||
"reason": reason,
|
||||
"score": score,
|
||||
"signals": signals,
|
||||
"entry_plan": entry_plan,
|
||||
"price": round(price, 6),
|
||||
"ticker": ticker,
|
||||
"derivatives": deriv,
|
||||
"static_accumulation": static_acc,
|
||||
"trigger_context": {
|
||||
"trigger_status": "news_current" if current_triggers else "background",
|
||||
"trigger_label": "消息面触发 + 技术确认" if current_triggers else "消息背景观察",
|
||||
"current_triggers": current_triggers,
|
||||
"stale_background": stale_background,
|
||||
"event_source": event.get("source"),
|
||||
"event_title": event.get("title"),
|
||||
"event_url": event.get("url"),
|
||||
"event_importance": event.get("importance"),
|
||||
"published_at": event.get("published_at").isoformat() if hasattr(event.get("published_at"), "isoformat") else str(event.get("published_at", "")),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def process_event(event):
|
||||
result = quick_technical_check(event)
|
||||
rec_id = 0
|
||||
pushed = False
|
||||
symbol = event["symbol"]
|
||||
decision = result["decision"]
|
||||
signals = result.get("signals", [])
|
||||
price = result.get("price", 0)
|
||||
|
||||
log_screening(
|
||||
layer="舆情触发",
|
||||
symbol=symbol,
|
||||
state="爆发" if decision == "recommend" else "蓄力" if decision == "observe" else "风险" if decision == "risk" else "过期",
|
||||
score=result.get("score", 0),
|
||||
price=price or 0,
|
||||
signals=signals,
|
||||
sector="",
|
||||
leader_status=event.get("source", ""),
|
||||
is_meme=0,
|
||||
change_24h=(result.get("ticker") or {}).get("change_24h", 0),
|
||||
funding_rate=(result.get("derivatives") or {}).get("funding_rate", 0),
|
||||
)
|
||||
|
||||
if decision == "recommend":
|
||||
ep = result.get("entry_plan") or {}
|
||||
rec_id = create_recommendation(
|
||||
symbol=symbol,
|
||||
rec_state="爆发",
|
||||
rec_score=result.get("score", 0),
|
||||
entry_price=price,
|
||||
stop_loss=ep.get("stop_loss", 0),
|
||||
tp1=ep.get("tp1", 0),
|
||||
tp2=ep.get("tp2", 0),
|
||||
sector="事件驱动",
|
||||
signals=signals,
|
||||
is_meme=0,
|
||||
entry_plan=ep,
|
||||
direction=get_strategy_direction(),
|
||||
force_reason="重大舆情触发",
|
||||
base_state="舆情触发",
|
||||
sector_signal_count=0,
|
||||
market_context={"event_source": event.get("source"), "trigger_context": result.get("trigger_context", {}), **(result.get("ticker") or {})},
|
||||
derivatives_context=result.get("derivatives") or {},
|
||||
sector_context={"event_title": event.get("title"), "event_url": event.get("url"), "event_source": event.get("source"), "event_importance": event.get("importance"), "trigger_context": result.get("trigger_context", {})},
|
||||
)
|
||||
|
||||
# 飞书只是通知层:事件脚本不再直接推 observe/risk,也不允许 rec_id=0 的事件旁路进通知。
|
||||
# 只有 decision=recommend 且已创建主推荐记录后,消费主链路派生状态进行通知。
|
||||
if decision == "recommend" and rec_id and _cfg().get("push", {}).get(decision, True):
|
||||
mainline_item = get_recommendation_for_push(rec_id)
|
||||
if mainline_item and mainline_item.get("execution_status") in ("buy_now", "wait_pullback"):
|
||||
push_type = "event_entry" if mainline_item.get("execution_status") == "buy_now" else "event_watch_pool"
|
||||
action = mainline_item.get("action_status", "")
|
||||
if should_push(symbol, push_type, action):
|
||||
ok, resp = push_recommendation_state_alert(mainline_item, title_prefix="事件触发机会")
|
||||
if ok:
|
||||
pushed = True
|
||||
log_push(symbol, push_type, action, rec_id=rec_id)
|
||||
else:
|
||||
print(f"[event] push failed {symbol}: {resp}")
|
||||
else:
|
||||
status = mainline_item.get("execution_status") if mainline_item else "missing"
|
||||
print(f"[event] skip push {symbol}: mainline_status={status}")
|
||||
elif decision in ("observe", "risk"):
|
||||
print(f"[event] skip push {symbol}: decision={decision} is not a主链路推荐通知")
|
||||
|
||||
conn = get_conn()
|
||||
conn.execute("""
|
||||
UPDATE event_news SET processed=1, decision=?, tech_score=?, rec_id=?, pushed=?
|
||||
WHERE event_hash=?
|
||||
""", (decision, result.get("score", 0), rec_id, int(pushed), event.get("event_hash")))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"event": event, "result": result, "rec_id": rec_id, "pushed": pushed}
|
||||
|
||||
|
||||
def load_unprocessed_events(limit=20):
|
||||
init_event_tables()
|
||||
conn = get_conn()
|
||||
cutoff = (_now() - timedelta(hours=_cfg().get("max_event_age_hours", 6))).isoformat()
|
||||
rows = conn.execute("""
|
||||
SELECT * FROM event_news
|
||||
WHERE processed=0 AND published_at >= ?
|
||||
ORDER BY published_at DESC LIMIT ?
|
||||
""", (cutoff, limit)).fetchall()
|
||||
conn.close()
|
||||
events = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["published_at"] = datetime.fromisoformat(d["published_at"])
|
||||
d["raw"] = json.loads(d.get("raw_json") or "{}")
|
||||
events.append(d)
|
||||
return events
|
||||
|
||||
|
||||
def run_once(process_existing=True):
|
||||
started = _now()
|
||||
init_db()
|
||||
init_event_tables()
|
||||
collected = collect_events()
|
||||
stored = store_events(collected)
|
||||
to_process = stored if stored else (load_unprocessed_events() if process_existing else [])
|
||||
processed = []
|
||||
for e in to_process:
|
||||
if isinstance(e.get("published_at"), str):
|
||||
e["published_at"] = datetime.fromisoformat(e["published_at"])
|
||||
processed.append(process_event(e))
|
||||
|
||||
output = {
|
||||
"status": "processed" if processed else "no_new_events",
|
||||
"collected_count": len(collected),
|
||||
"stored_count": len(stored),
|
||||
"processed_count": len(processed),
|
||||
"decisions": {k: sum(1 for p in processed if p["result"]["decision"] == k) for k in ["recommend", "observe", "risk", "ignore"]},
|
||||
"events": [
|
||||
{
|
||||
"symbol": p["event"].get("symbol"),
|
||||
"importance": p["event"].get("importance"),
|
||||
"title": p["event"].get("title"),
|
||||
"decision": p["result"].get("decision"),
|
||||
"score": p["result"].get("score"),
|
||||
"reason": p["result"].get("reason"),
|
||||
"rec_id": p.get("rec_id"),
|
||||
"pushed": p.get("pushed"),
|
||||
}
|
||||
for p in processed
|
||||
],
|
||||
"check_time": _now().isoformat(),
|
||||
}
|
||||
log_cron_run(
|
||||
job_name="事件舆情",
|
||||
script_name="event_driven_screener.py",
|
||||
run_status="success",
|
||||
result_status=output["status"],
|
||||
started_at=started.isoformat(),
|
||||
finished_at=_now().isoformat(),
|
||||
duration_ms=int((_now() - started).total_seconds() * 1000),
|
||||
summary={"stored_count": len(stored), "processed_count": len(processed), "decisions": output["decisions"]},
|
||||
error_message="",
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="事件驱动舆情触发选币")
|
||||
parser.add_argument("--once", action="store_true")
|
||||
parser.add_argument("--no-process-existing", action="store_true")
|
||||
args = parser.parse_args()
|
||||
out = run_once(process_existing=not args.no_process_existing)
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2, default=str))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
22
extract_summary.py
Normal file
22
extract_summary.py
Normal file
@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract summary from altcoin_confirm.py output (skip non-JSON lines)."""
|
||||
import json, sys
|
||||
|
||||
lines = sys.stdin.read()
|
||||
# Find the JSON object start
|
||||
start = lines.find('{')
|
||||
if start == -1:
|
||||
print("No JSON found in output")
|
||||
sys.exit(1)
|
||||
|
||||
# Try to parse from the first { onwards
|
||||
d = json.loads(lines[start:])
|
||||
print(f"confirmed_count: {d['confirmed_count']}")
|
||||
print(f"unconfirmed_count: {d['unconfirmed_count']}")
|
||||
print(f"check_time: {d['check_time']}")
|
||||
for u in d['unconfirmed']:
|
||||
sigs = "|".join(u['signals'])
|
||||
print(f"UNCONFIRMED: {u['symbol']} | price={u['price']} | score={u['score']} | action={u['entry_action']} | reason={u['state_update']['reason']} | signals={sigs}")
|
||||
for c in d['confirmed']:
|
||||
sigs = "|".join(c['signals'])
|
||||
print(f"CONFIRMED: {c['symbol']} | price={c['price']} | score={c['score']} | action={c['entry_action']} | reason={c['state_update']['reason']} | signals={sigs}")
|
||||
402
feishu_push.py
Normal file
402
feishu_push.py
Normal file
@ -0,0 +1,402 @@
|
||||
"""
|
||||
山寨币监控飞书卡片推送模块
|
||||
|
||||
通过飞书机器人 Webhook 直发,不经过 Hermes Agent 的飞书通道。
|
||||
Webhook 支持 v2 interactive cards。
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
|
||||
# === 飞书 Webhook URL(用户指定的山寨币专用 webhook)===
|
||||
FEISHU_WEBHOOK_URL = os.getenv("ALTCOIN_FEISHU_WEBHOOK", "").strip()
|
||||
|
||||
|
||||
def push_card(card_content):
|
||||
"""通过 webhook 推送飞书交互式卡片"""
|
||||
payload = {
|
||||
"msg_type": "interactive",
|
||||
"card": card_content,
|
||||
}
|
||||
try:
|
||||
if not FEISHU_WEBHOOK_URL:
|
||||
return False, "ALTCOIN_FEISHU_WEBHOOK not configured"
|
||||
r = requests.post(FEISHU_WEBHOOK_URL, json=payload, timeout=10)
|
||||
result = r.json()
|
||||
ok = (r.status_code == 200 and result.get("StatusCode") == 0)
|
||||
return ok, result
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def push_altcoin_burst_alert(symbol, price, signals, entry_plan, sector="", leader_status="", direction="多头启动"):
|
||||
"""
|
||||
推荐确认爆发推送 — 只做做多,方向永远多头🟢
|
||||
"""
|
||||
dir_emoji = "🟢"
|
||||
dir_color = "green"
|
||||
coin_name = symbol.replace('/USDT', '')
|
||||
|
||||
entry_lines = ""
|
||||
if entry_plan:
|
||||
rr_ok = "✅" if entry_plan.get("risk_reward_ok") else "❌"
|
||||
entry_lines = f"""---
|
||||
**入场方案**:
|
||||
• 入场价: ${entry_plan['entry_price']}
|
||||
• 入场方式: {entry_plan['entry_method']}
|
||||
• 止损价: ${entry_plan['stop_loss']} ({entry_plan['stop_pct']}%)
|
||||
• 止盈1: ${entry_plan['tp1']} (RR={entry_plan['rr1']} {rr_ok})
|
||||
• 止盈2: ${entry_plan['tp2']} (RR={entry_plan['rr2']})
|
||||
• 当前价: ${entry_plan['current_price']}"""
|
||||
|
||||
sector_line = f"\n**板块**: {sector}" if sector else ""
|
||||
leader_line = f"\n**龙头状态**: {leader_status}" if leader_status else ""
|
||||
signal_lines = "\n".join([f" • {s}" for s in signals])
|
||||
|
||||
card = {
|
||||
"config": {"wide_screen_mode": True},
|
||||
"header": {
|
||||
"template": dir_color,
|
||||
"title": {"tag": "plain_text", "content": f"{dir_emoji} {direction}确认 — {coin_name}"},
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"tag": "lark_md",
|
||||
"content": f"**方向**: {dir_emoji} {direction}\n**价格**: ${price}{sector_line}{leader_line}\n\n**确认信号**:\n{signal_lines}{entry_lines}",
|
||||
},
|
||||
},
|
||||
{
|
||||
"tag": "action",
|
||||
"actions": [
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": f"🔥 {symbol.replace('/USDT','')} 爆发确认"},
|
||||
"type": "danger",
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
return push_card(card)
|
||||
|
||||
|
||||
def push_recommendation_state_alert(item, title_prefix=None):
|
||||
"""主链路推荐状态推送:只渲染 DB/API 已派生好的状态,不做推荐判断。"""
|
||||
if not item:
|
||||
return True, {"skipped": True, "reason": "empty_mainline_item"}
|
||||
symbol = item.get("symbol", "")
|
||||
coin = symbol.replace("/USDT", "")
|
||||
execution_status = item.get("execution_status", "")
|
||||
action_status = item.get("action_status", "")
|
||||
execution_label = item.get("execution_label", "") or action_status or execution_status
|
||||
if execution_status == "buy_now":
|
||||
color, title = "blue", title_prefix or "入场窗口"
|
||||
elif execution_status == "wait_pullback":
|
||||
color, title = "yellow", title_prefix or "观察池:等回踩"
|
||||
elif execution_status == "observe":
|
||||
color, title = "blue", title_prefix or "观察池更新"
|
||||
else:
|
||||
color, title = "grey", title_prefix or "状态更新"
|
||||
|
||||
entry_plan = item.get("entry_plan") or {}
|
||||
price = item.get("current_price") or item.get("entry_price") or 0
|
||||
entry_ref = entry_plan.get("entry_price") if execution_status == "wait_pullback" else item.get("entry_price")
|
||||
if not entry_ref:
|
||||
entry_ref = item.get("entry_price") or entry_plan.get("entry_price") or 0
|
||||
risk_line = entry_plan.get("stop_loss") or item.get("stop_loss") or 0
|
||||
space_ref = entry_plan.get("tp1") or item.get("tp1") or 0
|
||||
signals = item.get("signals") or []
|
||||
if isinstance(signals, str):
|
||||
try:
|
||||
signals = json.loads(signals)
|
||||
except Exception:
|
||||
signals = [signals]
|
||||
signal_lines = "\n".join([f" • {x}" for x in signals[:5]]) or " • 主链路状态更新"
|
||||
rec_id = item.get("id", "")
|
||||
reason = item.get("execution_reason", "")
|
||||
ver = item.get("strategy_version", "")
|
||||
card = {
|
||||
"config": {"wide_screen_mode": True},
|
||||
"header": {
|
||||
"template": color,
|
||||
"title": {"tag": "plain_text", "content": f"{title} — {coin}"},
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"tag": "lark_md",
|
||||
"content": (
|
||||
f"**币种**: {symbol}\n"
|
||||
f"**主链路状态**: {execution_label}\n"
|
||||
f"**当前价**: ${price}\n"
|
||||
f"**参考价**: ${entry_ref} | **风险边界**: ${risk_line} | **上方空间参考**: ${space_ref}\n"
|
||||
f"**推荐ID**: #{rec_id} | **版本**: {ver}\n"
|
||||
f"**说明**: {reason}\n\n"
|
||||
f"**信号摘要**:\n{signal_lines}"
|
||||
),
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
return push_card(card)
|
||||
|
||||
|
||||
def push_altcoin_accelerating_alert(symbol, price, signals, score, sector="", leader_status="", direction="多头启动"):
|
||||
"""
|
||||
加速信号推送 — 只做做多🟢
|
||||
⚠️ 用户要求:不再推送到飞书,此函数保留但只写日志
|
||||
"""
|
||||
coin_name = symbol.replace('/USDT', '')
|
||||
print(f"[飞书跳过] 🟠 加速信号 — {coin_name} @ ${price} 评分{score}/20 (用户要求不推送)")
|
||||
return True, {"skipped": True, "reason": "用户要求不推送加速信号"}
|
||||
|
||||
|
||||
def push_altcoin_sector_alert(hot_sectors, leaders_info):
|
||||
"""
|
||||
推送板块联动告警
|
||||
⚠️ 用户要求:不再推送到飞书,此函数保留但只写日志
|
||||
"""
|
||||
print(f"[飞书跳过] 🔵 板块联动信号 — {len(hot_sectors)}个板块 (用户要求不推送)")
|
||||
return True, {"skipped": True, "reason": "用户要求不推送板块联动"}
|
||||
|
||||
|
||||
def push_altcoin_tp_sl_alert(symbol, current_price, entry_price, pnl_pct, action_status, signals, stop_loss=0, tp1=0, tp2=0):
|
||||
"""推送交易执行告警 — 可即刻买入 + 🆕v1.7.8 跟踪止盈触发。止盈/止损/衰减只落库展示,不发飞书。"""
|
||||
if action_status not in ("可即刻买入", "跟踪止盈"):
|
||||
print(f"[飞书跳过] {symbol} {action_status} — 用户要求止盈/止损/衰减不推送,只在网站展示")
|
||||
return True, {"skipped": True, "reason": "only_buy_now_and_trailing_stop_push_enabled"}
|
||||
|
||||
# v1.7.8: 跟踪止盈用独立的醒目卡片
|
||||
if action_status == "跟踪止盈":
|
||||
coin = symbol.replace("/USDT", "")
|
||||
signal_lines = "\n".join([f" • {s}" for s in signals])
|
||||
trail_info = f"入场${entry_price:.4f} → 当前${current_price:.4f}"
|
||||
if pnl_pct > 0:
|
||||
trail_info += f"\n**累计盈利: +{pnl_pct:.2f}%** 📈"
|
||||
elif pnl_pct < 0:
|
||||
trail_info += f"\n**保本出场: {pnl_pct:.2f}%**"
|
||||
|
||||
card = {
|
||||
"config": {"wide_screen_mode": True},
|
||||
"header": {
|
||||
"template": "red",
|
||||
"title": {"tag": "plain_text", "content": f"🎯 跟踪止盈触发 — {coin}"},
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"tag": "lark_md",
|
||||
"content": f"{trail_info}\n\n**信号详情**:\n{signal_lines}\n\n💡 跟踪止盈触发,建议立即平仓锁定利润!",
|
||||
},
|
||||
},
|
||||
{
|
||||
"tag": "action",
|
||||
"actions": [
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": f"🎯 {coin} 跟踪止盈"},
|
||||
"type": "danger",
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
return push_card(card)
|
||||
|
||||
# 当前只保留入场时机到位推送
|
||||
event_config = {
|
||||
"可即刻买入": ("blue", "🟢", "入场时机到位"),
|
||||
}
|
||||
cfg = event_config.get(action_status, ("blue", "⚠️", action_status))
|
||||
color, emoji, title_prefix = cfg
|
||||
|
||||
coin = symbol.replace("/USDT", "")
|
||||
signal_lines = "\n".join([f" • {s}" for s in signals])
|
||||
|
||||
pnl_emoji = "📈" if pnl_pct > 0 else "📉" if pnl_pct < 0 else "➡️"
|
||||
price_lines = f"**入场价**: ${entry_price} → **当前价**: ${current_price} → **盈亏**: {pnl_emoji} {pnl_pct}%"
|
||||
if stop_loss > 0:
|
||||
price_lines += f"\n**止损**: ${stop_loss}"
|
||||
if tp1 > 0:
|
||||
price_lines += f"\n**止盈1**: ${tp1}"
|
||||
|
||||
card = {
|
||||
"config": {"wide_screen_mode": True},
|
||||
"header": {
|
||||
"template": color,
|
||||
"title": {"tag": "plain_text", "content": f"{emoji} {title_prefix} — {coin}"},
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"tag": "lark_md",
|
||||
"content": f"{price_lines}\n\n**操作建议**: {action_status}\n\n**信号详情**:\n{signal_lines}",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
return push_card(card)
|
||||
|
||||
|
||||
def push_altcoin_exhaustion_alert(symbol, current_price, pnl_pct, exhaustion):
|
||||
"""
|
||||
推送趋势衰减告警 — ⚠️ 橙色卡片
|
||||
"""
|
||||
coin = symbol.replace("/USDT", "")
|
||||
severity = exhaustion.get("severity", "low")
|
||||
sev_emoji = "⚠️" if severity == "medium" else "🔴"
|
||||
ex_signals = exhaustion.get("signals", [])
|
||||
signal_lines = "\n".join([f" • {s}" for s in ex_signals])
|
||||
|
||||
card = {
|
||||
"config": {"wide_screen_mode": True},
|
||||
"header": {
|
||||
"template": "orange",
|
||||
"title": {"tag": "plain_text", "content": f"{sev_emoji} 趋势衰减 — {coin}"},
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"tag": "lark_md",
|
||||
"content": f"**当前价**: ${current_price} | **盈亏**: {pnl_pct}%\n\n**衰减信号**:\n{signal_lines}\n\n💡 建议:关注止盈机会,趋势可能即将反转",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
return push_card(card)
|
||||
|
||||
|
||||
def push_sentiment_alert(alert):
|
||||
"""
|
||||
推送舆情异动卡片 — 📢 蓝色信息卡
|
||||
alert: {"type": "holding_trending"|"new_trending", "symbol", "name", "trend_rank", "alert"}
|
||||
"""
|
||||
coin = alert["symbol"].replace("/USDT", "")
|
||||
alert_type = alert["type"]
|
||||
emoji = "🔔" if alert_type == "holding_trending" else "🆕"
|
||||
color = "red" if alert_type == "holding_trending" else "blue"
|
||||
|
||||
extra = ""
|
||||
if alert_type == "holding_trending":
|
||||
extra = f"\n⚠️ 持仓币进入热搜,关注价格异动"
|
||||
|
||||
card = {
|
||||
"config": {"wide_screen_mode": True},
|
||||
"header": {
|
||||
"template": color,
|
||||
"title": {"tag": "plain_text", "content": f"{emoji} 舆情异动 — {coin}"},
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"tag": "lark_md",
|
||||
"content": (
|
||||
f"**{alert['name']}** 进入 CoinGecko Trending #{alert['trend_rank']}\n"
|
||||
f"{alert['alert']}{extra}\n\n"
|
||||
f"💡 消息面热度上升,建议结合技术面判断入场时机"
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
return push_card(card)
|
||||
|
||||
|
||||
def push_event_driven_alert(event, result, rec_id=0):
|
||||
"""事件驱动舆情触发选币推送。重大消息触发后,根据技术检查结果分为推荐/观察/风险。"""
|
||||
symbol = event.get("symbol", "")
|
||||
coin = symbol.replace("/USDT", "")
|
||||
decision = result.get("decision", "observe")
|
||||
importance = event.get("importance", "")
|
||||
title = event.get("title", "")
|
||||
source = event.get("source", "")
|
||||
url = event.get("url", "")
|
||||
published_at = event.get("published_at", "")
|
||||
price = result.get("price", 0)
|
||||
score = result.get("score", 0)
|
||||
reason = result.get("reason", "")
|
||||
signals = result.get("signals", [])
|
||||
entry_plan = result.get("entry_plan", {}) or {}
|
||||
|
||||
if decision == "recommend":
|
||||
color, emoji, headline = "red", "🚨", "重大舆情触发:可交易机会"
|
||||
elif decision == "risk":
|
||||
color, emoji, headline = "orange", "⚠️", "重大舆情风险:不建议追"
|
||||
else:
|
||||
color, emoji, headline = "blue", "👀", "重大舆情观察:等待技术确认"
|
||||
|
||||
signal_lines = "\n".join([f" • {s}" for s in signals[:8]])
|
||||
link_line = f"\n**来源链接**: [查看原文]({url})" if url else ""
|
||||
entry_lines = ""
|
||||
if entry_plan:
|
||||
entry_lines = (
|
||||
f"\n---\n**交易计划**:\n"
|
||||
f"• 动作: {entry_plan.get('entry_action', '')}\n"
|
||||
f"• 入场: ${entry_plan.get('entry_price', '')}\n"
|
||||
f"• 止损: ${entry_plan.get('stop_loss', '')} ({entry_plan.get('stop_pct', '')}%)\n"
|
||||
f"• TP1/TP2: ${entry_plan.get('tp1', '')} / ${entry_plan.get('tp2', '')}"
|
||||
)
|
||||
rec_line = f"\n**推荐ID**: #{rec_id}" if rec_id else ""
|
||||
|
||||
card = {
|
||||
"config": {"wide_screen_mode": True},
|
||||
"header": {
|
||||
"template": color,
|
||||
"title": {"tag": "plain_text", "content": f"{emoji} {headline} — {coin}"},
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"tag": "lark_md",
|
||||
"content": (
|
||||
f"**币种**: {symbol}\n"
|
||||
f"**重要性**: {importance}级 | **来源**: {source}\n"
|
||||
f"**发布时间**: {published_at}\n"
|
||||
f"**消息**: {title}{link_line}\n\n"
|
||||
f"**技术决策**: {reason}\n"
|
||||
f"**当前价**: ${price} | **技术分**: {score}\n"
|
||||
f"{rec_line}\n\n"
|
||||
f"**触发信号**:\n{signal_lines}"
|
||||
f"{entry_lines}"
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
return push_card(card)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 测试推送
|
||||
print(f"Webhook URL: {FEISHU_WEBHOOK_URL[:50]}...")
|
||||
print("\n测试爆发卡片推送...")
|
||||
ok, result = push_altcoin_burst_alert(
|
||||
"FET/USDT", 2.15,
|
||||
["1H放量突破阻力(2.3倍)", "1H MACD金叉", "1H 均线多头初成", "15min 5阳线+量递增"],
|
||||
{"entry_price": 2.10, "entry_method": "回踩确认", "stop_loss": 1.95,
|
||||
"stop_pct": 3.0, "tp1": 2.55, "tp2": 2.80, "rr1": 3.0, "rr2": 5.0,
|
||||
"risk_reward_ok": True, "current_price": 2.15, "atr_1h": 0.10},
|
||||
sector="AI_DePIN", leader_status="板块龙头(AI_DePIN)",
|
||||
)
|
||||
print(f"爆发卡片: ok={ok}, result={result}")
|
||||
|
||||
print("\n测试加速卡片推送(应被跳过)...")
|
||||
ok2, result2 = push_altcoin_accelerating_alert(
|
||||
"ARB/USDT", 0.125,
|
||||
["4H MACD金叉", "4H RSI拐点(35→52)", "板块联动: Layer2龙头启动"],
|
||||
score=10, sector="Layer2",
|
||||
)
|
||||
print(f"加速卡片: ok={ok2}, result={result2}")
|
||||
|
||||
print("\n测试板块联动推送(应被跳过)...")
|
||||
ok4, result4 = push_altcoin_sector_alert(["AI"], {"AI": {"leader": "FET/USDT", "leader_pct": 12.5, "is_leader_hot": True}})
|
||||
print(f"板块联动: ok={ok4}, result={result4}")
|
||||
298
feishu_review_push.py
Normal file
298
feishu_review_push.py
Normal file
@ -0,0 +1,298 @@
|
||||
"""
|
||||
飞书复盘报告推送模块
|
||||
|
||||
推送三类卡片:
|
||||
1. push_review_report — 策略复盘报告(蓝色主题)
|
||||
2. push_reverse_analysis_report — 逆向分析报告(紫色主题)
|
||||
3. push_rule_update_notification — 新规律通知(绿色主题)
|
||||
|
||||
复用 feishu_push.py 的认证模式(load_feishu_creds → get_token → push_card)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from feishu_push import push_card
|
||||
|
||||
CHAT_ID = "oc_2c597ad94167102922de142928e2917a"
|
||||
|
||||
|
||||
# ==================== 1. 策略复盘报告 ====================
|
||||
|
||||
def push_review_report(review_results):
|
||||
"""
|
||||
推送策略复盘报告卡片 — 📊 蓝色主题
|
||||
|
||||
Section 1: 推荐命中统计 (hit/fail/flat counts, hit rate %)
|
||||
Section 2: 信号绩效TOP5 (best performing signals)
|
||||
Section 3: 遗漏爆炸 (missed coins, why, what features)
|
||||
Section 4: 权重调整 (weight changes)
|
||||
"""
|
||||
reviews = review_results.get("review_details", [])
|
||||
weight_adj = review_results.get("weight_adjustments", [])
|
||||
missed = review_results.get("missed_explosions", [])
|
||||
|
||||
# Section 1: 命中统计
|
||||
hit_count = sum(1 for r in reviews if r.get("outcome") == "爆发")
|
||||
fail_count = sum(1 for r in reviews if r.get("outcome") == "失败")
|
||||
flat_count = sum(1 for r in reviews if r.get("outcome") == "横盘")
|
||||
total = len(reviews)
|
||||
hit_rate_pct = round(hit_count / total * 100, 1) if total > 0 else 0
|
||||
|
||||
# 命中统计文案
|
||||
hit_emoji = "🔥" if hit_rate_pct >= 50 else "⚠️" if hit_rate_pct >= 30 else "❌"
|
||||
stats_line = (
|
||||
f"本次复盘 **{total}** 条推荐:\n"
|
||||
f" • 爆发(命中): **{hit_count}** ({hit_emoji})\n"
|
||||
f" • 横盘: **{flat_count}**\n"
|
||||
f" • 失败: **{fail_count}**\n"
|
||||
f" • 命中率: **{hit_rate_pct}%**"
|
||||
)
|
||||
|
||||
# Section 2: 信号绩效TOP5
|
||||
# 从review_results中提取信号绩效信息
|
||||
from altcoin_db import get_signal_weights
|
||||
weights = get_signal_weights()
|
||||
sig_perf_list = sorted(
|
||||
[(sig, data) for sig, data in weights.items() if data.get("total_count", 0) >= 3],
|
||||
key=lambda x: x[1].get("hit_rate", 0),
|
||||
reverse=True,
|
||||
)[:5]
|
||||
|
||||
sig_lines = ""
|
||||
if sig_perf_list:
|
||||
for sig, data in sig_perf_list:
|
||||
hr = data.get("hit_rate", 0)
|
||||
w = data.get("weight", 0)
|
||||
total_n = data.get("total_count", 0)
|
||||
cat = data.get("category", "")
|
||||
emoji = "✅" if hr >= 50 else "⚠️" if hr >= 30 else "❌"
|
||||
sig_lines += f"\n • {emoji} **{sig}**({cat}): 命中率{hr}% | 权重{w} | 样本{total_n}"
|
||||
else:
|
||||
sig_lines = "\n • 样本不足,暂无绩效数据"
|
||||
|
||||
# Section 3: 遗漏爆炸
|
||||
missed_lines = ""
|
||||
if missed:
|
||||
for m in missed[:5]: # 最多展示5只
|
||||
symbol = m.get("symbol", "")
|
||||
gain = m.get("gain_pct", 0)
|
||||
reason = m.get("reason_missed", m.get("reason", ""))
|
||||
features = m.get("features_detected", [])
|
||||
if isinstance(features, str):
|
||||
try:
|
||||
features = json.loads(features)
|
||||
except:
|
||||
features = [features]
|
||||
feat_str = ", ".join(str(f) for f in features[:3]) if features else "无"
|
||||
missed_lines += f"\n • 💥 **{symbol}** 涨{gain}% | 原因: {reason} | 特征: {feat_str}"
|
||||
else:
|
||||
missed_lines = "\n • ✅ 无遗漏爆炸"
|
||||
|
||||
# Section 4: 权重调整
|
||||
adj_lines = ""
|
||||
if weight_adj:
|
||||
for adj in weight_adj:
|
||||
adj_lines += f"\n • {adj}"
|
||||
else:
|
||||
adj_lines = "\n • 无权重调整"
|
||||
|
||||
# 构建卡片
|
||||
card = {
|
||||
"config": {"wide_screen_mode": True},
|
||||
"header": {
|
||||
"template": "blue",
|
||||
"title": {"tag": "plain_text", "content": "📊 山寨币策略复盘报告"},
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"tag": "lark_md",
|
||||
"content": (
|
||||
f"**=== 推荐命中统计 ===**\n{stats_line}\n\n"
|
||||
f"**=== 信号绩效TOP5 ===**\n{sig_lines}\n\n"
|
||||
f"**=== 遗漏爆炸 ===**\n{missed_lines}\n\n"
|
||||
f"**=== 权重调整 ===**\n{adj_lines}"
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
"tag": "action",
|
||||
"actions": [
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": f"📊 命中率{hit_rate_pct}%"},
|
||||
"type": "primary" if hit_rate_pct >= 50 else "warning" if hit_rate_pct >= 30 else "danger",
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
return push_card(card)
|
||||
|
||||
|
||||
# ==================== 2. 逆向分析报告 ====================
|
||||
|
||||
def push_reverse_analysis_report(reverse_results):
|
||||
"""
|
||||
推送逆向分析报告卡片 — 🔍 紫色主题
|
||||
|
||||
Section 1: 今日涨幅榜TOP10 (symbol, gain%, sector)
|
||||
Section 2: 起爆前共性特征 (pattern summary with percentages)
|
||||
Section 3: 新发现规律 (any new rules)
|
||||
"""
|
||||
top_gainers = reverse_results.get("top_gainers", [])
|
||||
pattern_summary = reverse_results.get("pattern_summary", [])
|
||||
new_rules = reverse_results.get("new_rules", [])
|
||||
total_unrecommended = reverse_results.get("total_unrecommended", 0)
|
||||
total_analyzed = reverse_results.get("total_analyzed", 0)
|
||||
|
||||
# Section 1: 涨幅榜TOP10
|
||||
gainer_lines = ""
|
||||
for i, g in enumerate(top_gainers[:10], 1):
|
||||
symbol = g.get("symbol", "").replace("/USDT", "")
|
||||
gain = g.get("gain_pct", 0)
|
||||
sector = g.get("sector", [])
|
||||
sector_str = sector[0] if isinstance(sector, list) and sector else (sector if sector else "未知")
|
||||
volume = g.get("volume_24h", 0)
|
||||
vol_str = f"${volume / 1e6:.1f}M" if volume > 0 else ""
|
||||
gainer_lines += f"\n {i}. **{symbol}** +{gain}% | {sector_str} | {vol_str}"
|
||||
|
||||
if not gainer_lines:
|
||||
gainer_lines = "\n • 今日无明显涨幅"
|
||||
|
||||
# Section 2: 起爆前共性特征
|
||||
pattern_lines = ""
|
||||
for p in pattern_summary[:8]: # 最多展示8个特征
|
||||
label = p.get("label", p.get("feature", ""))
|
||||
pct = p.get("percentage", 0)
|
||||
count = p.get("count", 0)
|
||||
total = p.get("total", 0)
|
||||
bar = "█" * int(pct / 10) + "░" * (10 - int(pct / 10))
|
||||
emoji = "🔥" if pct >= 60 else "✅" if pct >= 40 else "⚠️" if pct >= 20 else "❌"
|
||||
pattern_lines += f"\n • {emoji} **{label}**: {pct}%({count}/{total}) {bar}"
|
||||
|
||||
if not pattern_lines:
|
||||
pattern_lines = "\n • 分析样本不足"
|
||||
|
||||
# Section 3: 新发现规律
|
||||
rule_lines = ""
|
||||
if new_rules:
|
||||
for r in new_rules:
|
||||
rule_id = r.get("rule_id", "")
|
||||
desc = r.get("description", "")
|
||||
score_adj = r.get("score_adjust", 0)
|
||||
rule_type = r.get("type", "")
|
||||
rule_lines += f"\n • 🧠 **{rule_id}**: {desc} → 评分{score_adj}({rule_type})"
|
||||
else:
|
||||
rule_lines = "\n • 暂无新规律达到显著性阈值"
|
||||
|
||||
# 分析概况
|
||||
overview = (
|
||||
f"涨幅榜共{len(top_gainers)}只 ≥10%\n"
|
||||
f"未被推荐: {total_unrecommended}只\n"
|
||||
f"已做PA分析: {total_analyzed}只"
|
||||
)
|
||||
|
||||
# 构建卡片(紫色主题用 "violet" — 飞书卡片没有purple,用indigo近似)
|
||||
card = {
|
||||
"config": {"wide_screen_mode": True},
|
||||
"header": {
|
||||
"template": "indigo",
|
||||
"title": {"tag": "plain_text", "content": "🔍 逆向分析报告 — 涨幅榜复盘"},
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"tag": "lark_md",
|
||||
"content": (
|
||||
f"**{overview}**\n\n"
|
||||
f"**=== 今日涨幅榜TOP10 ===**\n{gainer_lines}\n\n"
|
||||
f"**=== 起爆前共性特征 ===**\n{pattern_lines}\n\n"
|
||||
f"**=== 新发现规律 ===**\n{rule_lines}"
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
"tag": "action",
|
||||
"actions": [
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": f"🔍 分析{total_analyzed}只暴涨币"},
|
||||
"type": "primary",
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
return push_card(card)
|
||||
|
||||
|
||||
# ==================== 3. 新规律通知 ====================
|
||||
|
||||
def push_rule_update_notification(rule_id, description, status="候选规则,未生效"):
|
||||
"""
|
||||
推送新规律学习通知 — 🧠 绿色主题
|
||||
简洁卡片,告知策略自动迭代
|
||||
"""
|
||||
card = {
|
||||
"config": {"wide_screen_mode": True},
|
||||
"header": {
|
||||
"template": "green",
|
||||
"title": {"tag": "plain_text", "content": "🧠 策略自学习 — 候选规则发现"},
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"tag": "lark_md",
|
||||
"content": (
|
||||
f"规则ID: **{rule_id}**\n\n"
|
||||
f"状态: **{status}**\n\n"
|
||||
f"描述: {description}\n\n"
|
||||
f"说明: 该规则仅进入候选池/灰度评估,未通过发布闸门前不会写入正式规则库,也不会影响下次选币。"
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
"tag": "action",
|
||||
"actions": [
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": f"🧠 {rule_id}"},
|
||||
"type": "primary",
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
return push_card(card)
|
||||
|
||||
|
||||
# ==================== 测试 ====================
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("测试复盘报告推送...")
|
||||
|
||||
# 测试review report
|
||||
test_review = {
|
||||
"review_details": [
|
||||
{"symbol": "FET/USDT", "outcome": "爆发", "pnl_48h": 12.5},
|
||||
{"symbol": "ARB/USDT", "outcome": "横盘", "pnl_48h": 1.2},
|
||||
{"symbol": "PEPE/USDT", "outcome": "失败", "pnl_48h": -4.5},
|
||||
],
|
||||
"weight_adjustments": ["量价齐飞: 3→4.0 (命中率67%)"],
|
||||
"missed_explosions": [
|
||||
{"symbol": "INJ/USDT", "gain_pct": 25, "reason_missed": "细筛淘汰(score=4)", "features_detected": ["ignition_point", "Q7_zone"]},
|
||||
],
|
||||
}
|
||||
ok1, r1 = push_review_report(test_review)
|
||||
print(f"复盘报告: ok={ok1}")
|
||||
|
||||
# 测试rule notification
|
||||
ok2, r2 = push_rule_update_notification("rule_20260429_001", "涨幅榜60%有起爆点 → 起爆点是爆发前必现信号")
|
||||
print(f"规律通知: ok={ok2}")
|
||||
340
opportunity_lifecycle.py
Normal file
340
opportunity_lifecycle.py
Normal file
@ -0,0 +1,340 @@
|
||||
"""交易机会生命周期与买点质量闸门。
|
||||
|
||||
v1.7.5: 把“强势发现”和“可执行买点”彻底分离。
|
||||
- 低位静K蓄力 + 大户偏多/主题/板块 → 潜伏计划
|
||||
- risk_reward_ok=false / rr不足 / 离突破位过远 → 禁止可即刻买入
|
||||
- 高位爆发确认 → 强势发现/等回踩/观察,不再当新开仓
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict, Iterable, Tuple
|
||||
|
||||
DEFAULT_ENTRY_GATE = {
|
||||
"enabled": True,
|
||||
"min_rr_buy_now": 1.2,
|
||||
"min_rr_observe": 1.0,
|
||||
"breakout_distance_wait_pct": 15,
|
||||
"breakout_distance_risk_pct": 30,
|
||||
"breakout_distance_ban_pct": 60,
|
||||
"gain_24h_wait_pct": 8,
|
||||
"gain_24h_observe_pct": 12,
|
||||
"low_position_max_pct": 55,
|
||||
"low_plan_max_gain_24h_pct": 8,
|
||||
"low_plan_min_static_count": 3,
|
||||
"low_plan_min_top_long_pct": 55,
|
||||
}
|
||||
|
||||
|
||||
|
||||
# AlphaX 统一状态机:所有展示/统计/推送都应消费这些派生状态,不再各自解释 status/action_status。
|
||||
TERMINAL_STATUSES = {"hit_tp1", "hit_tp2", "stopped_out", "expired", "archived", "invalid"}
|
||||
EXIT_ACTIONS = {"止损", "衰减", "反转", "放弃"}
|
||||
PROFIT_ACTIONS = {"止盈1", "止盈2", "跟踪止盈"}
|
||||
|
||||
|
||||
def normalize_action_status(action_status: Any, status: str = "") -> str:
|
||||
action = str(action_status or "").strip()
|
||||
status = str(status or "").strip()
|
||||
terminal_map = {
|
||||
"hit_tp1": "止盈1",
|
||||
"hit_tp2": "止盈2",
|
||||
"stopped_out": "止损",
|
||||
"expired": "过期",
|
||||
"invalid": "放弃",
|
||||
"archived": "归档",
|
||||
}
|
||||
if status in terminal_map:
|
||||
return terminal_map[status]
|
||||
aliases = {
|
||||
"即刻买入": "可即刻买入",
|
||||
"🟢即刻买入": "可即刻买入",
|
||||
"🟢可即刻买入": "可即刻买入",
|
||||
"🟡等回踩": "等回踩",
|
||||
"等待回踩": "等回踩",
|
||||
"观望": "观察",
|
||||
"观察中": "观察",
|
||||
"持仓": "持有",
|
||||
}
|
||||
return aliases.get(action, action or "观察")
|
||||
|
||||
|
||||
def derive_display_bucket(status: str, action_status: str, execution_status: str = "") -> Dict[str, str]:
|
||||
"""把 DB 主状态派生成唯一展示桶。
|
||||
|
||||
这是实时/历史/迭代/飞书共同口径:
|
||||
- realtime: 当前有效机会雷达
|
||||
- watch_pool: 未触发入场的观察/等待阶段,不计推荐收益
|
||||
- position: 已入场或利润管理中的交易态
|
||||
- history: 失效/止损/过期/归档
|
||||
"""
|
||||
status = str(status or "active").strip()
|
||||
action = normalize_action_status(action_status, status)
|
||||
execution_status = str(execution_status or "").strip()
|
||||
|
||||
if status in ("stopped_out", "expired", "archived", "invalid") or action in EXIT_ACTIONS or action in ("过期", "归档"):
|
||||
return {"display_bucket": "history", "lifecycle_state": "invalidated", "execution_status": "invalid"}
|
||||
if status in ("hit_tp1", "hit_tp2") or action in PROFIT_ACTIONS:
|
||||
return {"display_bucket": "position", "lifecycle_state": "profit_management", "execution_status": "completed"}
|
||||
if action == "可即刻买入" or execution_status == "buy_now":
|
||||
return {"display_bucket": "realtime", "lifecycle_state": "buyable", "execution_status": "buy_now"}
|
||||
if action == "等回踩" or execution_status == "wait_pullback":
|
||||
return {"display_bucket": "watch_pool", "lifecycle_state": "waiting_entry", "execution_status": "wait_pullback"}
|
||||
if action == "持有":
|
||||
# “持有”在旧链路里经常只是默认值,不等于真实成交/持仓。
|
||||
# 未出现止盈/止损/明确入场窗口前,一律归观察池,避免把粗筛候选当持仓收益。
|
||||
return {"display_bucket": "watch_pool", "lifecycle_state": "watching", "execution_status": "observe"}
|
||||
return {"display_bucket": "watch_pool", "lifecycle_state": "watching", "execution_status": "observe"}
|
||||
|
||||
|
||||
def is_executed_lifecycle(status: str, action_status: str, execution_status: str = "") -> bool:
|
||||
bucket = derive_display_bucket(status, action_status, execution_status)
|
||||
return bucket["display_bucket"] == "position" or bucket["lifecycle_state"] in {"holding", "profit_management"}
|
||||
|
||||
def _cfg_value(cfg: Dict[str, Any], key: str):
|
||||
return cfg.get(key, DEFAULT_ENTRY_GATE.get(key))
|
||||
|
||||
|
||||
def normalize_json_object(payload: Any) -> Dict[str, Any]:
|
||||
if isinstance(payload, dict):
|
||||
return payload
|
||||
if not payload:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(payload)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def normalize_signals(signals: Any) -> list:
|
||||
if isinstance(signals, list):
|
||||
return signals
|
||||
if not signals:
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(signals)
|
||||
if isinstance(parsed, list):
|
||||
return parsed
|
||||
except Exception:
|
||||
pass
|
||||
if isinstance(signals, str):
|
||||
return [signals]
|
||||
return []
|
||||
|
||||
|
||||
def to_float(value: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return float(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def detect_breakout_distance_pct(signals: Iterable[Any]) -> float:
|
||||
"""从“站稳突破位 +66.7%”等信号中提取最大追高距离。"""
|
||||
max_pct = 0.0
|
||||
for sig in signals or []:
|
||||
text = str(sig)
|
||||
if "突破位" not in text:
|
||||
continue
|
||||
for m in re.finditer(r"\+\s*([0-9]+(?:\.[0-9]+)?)\s*%", text):
|
||||
max_pct = max(max_pct, to_float(m.group(1)))
|
||||
return round(max_pct, 2)
|
||||
|
||||
|
||||
def detect_static_count(signals: Iterable[Any]) -> int:
|
||||
max_count = 0
|
||||
for sig in signals or []:
|
||||
text = str(sig)
|
||||
if "静K" not in text and "蓄力" not in text:
|
||||
continue
|
||||
for pattern in (r"([0-9]+)\s*静K", r"静K[^0-9]{0,8}\(?([0-9]+)\s*静K"):
|
||||
for m in re.finditer(pattern, text):
|
||||
try:
|
||||
max_count = max(max_count, int(m.group(1)))
|
||||
except Exception:
|
||||
pass
|
||||
return max_count
|
||||
|
||||
|
||||
def _calc_position_pct(current_price: float, entry_plan: Dict[str, Any]) -> float:
|
||||
support = to_float(entry_plan.get("support") or entry_plan.get("support_price") or entry_plan.get("range_low"))
|
||||
resistance = to_float(entry_plan.get("resistance") or entry_plan.get("resistance_price") or entry_plan.get("range_high"))
|
||||
if support > 0 and resistance > support and current_price > 0:
|
||||
return round((current_price - support) / (resistance - support) * 100, 2)
|
||||
return to_float(entry_plan.get("position_pct"), 50.0)
|
||||
|
||||
|
||||
def build_low_ambush_plan(
|
||||
*,
|
||||
entry_plan: Dict[str, Any],
|
||||
signals: Iterable[Any],
|
||||
current_price: float,
|
||||
market_context: Dict[str, Any],
|
||||
derivatives_context: Dict[str, Any],
|
||||
sector_context: Dict[str, Any],
|
||||
cfg: Dict[str, Any] = None,
|
||||
) -> Tuple[Dict[str, Any], list]:
|
||||
"""识别低位潜伏机会,返回 opportunity_lifecycle + reasons。"""
|
||||
cfg = {**DEFAULT_ENTRY_GATE, **(cfg or {})}
|
||||
signal_list = normalize_signals(signals)
|
||||
static_count = detect_static_count(signal_list)
|
||||
top_long = to_float(derivatives_context.get("top_trader_long_pct"))
|
||||
change_24h = to_float(market_context.get("change_24h"))
|
||||
position_pct = _calc_position_pct(current_price, entry_plan)
|
||||
has_theme_or_sector = bool(sector_context.get("hot_sectors")) or any(
|
||||
key in str(s) for s in signal_list for key in ("主题", "生态", "舆情", "板块联动")
|
||||
)
|
||||
reasons = []
|
||||
if static_count >= _cfg_value(cfg, "low_plan_min_static_count"):
|
||||
reasons.append(f"静K蓄力{static_count}根")
|
||||
if top_long >= _cfg_value(cfg, "low_plan_min_top_long_pct"):
|
||||
reasons.append(f"大户偏多{top_long:.0f}%")
|
||||
if has_theme_or_sector:
|
||||
reasons.append("主题/板块资金线索")
|
||||
if position_pct <= _cfg_value(cfg, "low_position_max_pct"):
|
||||
reasons.append(f"结构位置偏低({position_pct:.0f}%)")
|
||||
if change_24h <= _cfg_value(cfg, "low_plan_max_gain_24h_pct"):
|
||||
reasons.append(f"24h涨幅未过热({change_24h:.1f}%)")
|
||||
|
||||
qualifies = (
|
||||
static_count >= _cfg_value(cfg, "low_plan_min_static_count")
|
||||
and change_24h <= _cfg_value(cfg, "low_plan_max_gain_24h_pct")
|
||||
and position_pct <= _cfg_value(cfg, "low_position_max_pct")
|
||||
and (top_long >= _cfg_value(cfg, "low_plan_min_top_long_pct") or has_theme_or_sector)
|
||||
)
|
||||
if not qualifies:
|
||||
return {}, reasons
|
||||
|
||||
plan_entry = to_float(entry_plan.get("entry_price")) or current_price
|
||||
stop_loss = to_float(entry_plan.get("stop_loss"))
|
||||
return {
|
||||
"stage": "低位潜伏",
|
||||
"plan_type": "ambush",
|
||||
"trigger": "静K蓄力+低位+资金/主题线索",
|
||||
"ambush_price": round(plan_entry, 8),
|
||||
"current_price": round(current_price, 8),
|
||||
"stop_loss": stop_loss,
|
||||
"static_count": static_count,
|
||||
"top_trader_long_pct": top_long,
|
||||
"change_24h": change_24h,
|
||||
"position_pct": position_pct,
|
||||
"reasons": reasons,
|
||||
}, reasons
|
||||
|
||||
|
||||
def apply_entry_quality_gate(
|
||||
*,
|
||||
action_status: str,
|
||||
entry_plan: Dict[str, Any],
|
||||
signals: Iterable[Any] = None,
|
||||
current_price: float = 0,
|
||||
market_context: Dict[str, Any] = None,
|
||||
derivatives_context: Dict[str, Any] = None,
|
||||
sector_context: Dict[str, Any] = None,
|
||||
cfg: Dict[str, Any] = None,
|
||||
) -> Tuple[str, Dict[str, Any], list]:
|
||||
"""返回修正后的 action_status、增强后的 entry_plan、拦截原因。"""
|
||||
cfg = {**DEFAULT_ENTRY_GATE, **(cfg or {})}
|
||||
if not cfg.get("enabled", True):
|
||||
return action_status, entry_plan or {}, []
|
||||
|
||||
entry_plan = dict(entry_plan or {})
|
||||
signals = normalize_signals(signals)
|
||||
market_context = market_context or {}
|
||||
derivatives_context = derivatives_context or {}
|
||||
sector_context = sector_context or {}
|
||||
reasons = []
|
||||
|
||||
current_price = to_float(current_price) or to_float(entry_plan.get("current_price")) or to_float(entry_plan.get("entry_price"))
|
||||
rr1 = to_float(entry_plan.get("rr1"), 999.0)
|
||||
risk_reward_ok = entry_plan.get("risk_reward_ok")
|
||||
stop_loss = to_float(entry_plan.get("stop_loss"))
|
||||
tp1 = to_float(entry_plan.get("tp1") or entry_plan.get("take_profit_1"))
|
||||
if current_price > 0 and stop_loss > 0 and tp1 > 0 and current_price > stop_loss:
|
||||
live_rr1 = round((tp1 - current_price) / (current_price - stop_loss), 2)
|
||||
entry_plan["rr1_live"] = live_rr1
|
||||
entry_plan["rr1_live_price"] = round(current_price, 8)
|
||||
# 当前价已经明显低于确认时价格时,旧 rr1/risk_reward_ok 会失真。
|
||||
# 买点质量闸门必须用最新现价重算 RR,否则会出现“现价低于回踩参考,却仍让等回踩”的矛盾。
|
||||
rr1 = live_rr1
|
||||
risk_reward_ok = live_rr1 >= _cfg_value(cfg, "min_rr_buy_now")
|
||||
entry_plan["risk_reward_ok_live"] = risk_reward_ok
|
||||
entry_action = str(entry_plan.get("entry_action") or "").strip()
|
||||
if entry_plan.get("entry_quality_gate"):
|
||||
entry_plan.pop("entry_quality_gate", None)
|
||||
breakout_distance = detect_breakout_distance_pct(signals)
|
||||
change_24h = to_float(market_context.get("change_24h"))
|
||||
|
||||
lifecycle, ambush_reasons = build_low_ambush_plan(
|
||||
entry_plan=entry_plan,
|
||||
signals=signals,
|
||||
current_price=current_price,
|
||||
market_context=market_context,
|
||||
derivatives_context=derivatives_context,
|
||||
sector_context=sector_context,
|
||||
cfg=cfg,
|
||||
)
|
||||
if lifecycle:
|
||||
entry_plan["opportunity_lifecycle"] = lifecycle
|
||||
elif ambush_reasons:
|
||||
entry_plan.setdefault("opportunity_lifecycle", {
|
||||
"stage": "强势发现",
|
||||
"plan_type": "watch",
|
||||
"reasons": ambush_reasons,
|
||||
})
|
||||
|
||||
if action_status in ("可即刻买入", "等回踩"):
|
||||
if risk_reward_ok is False:
|
||||
reasons.append(f"risk_reward_ok=false,盈亏比闸门禁止现价买入;实时rr1={rr1}")
|
||||
if "rr1" in entry_plan and rr1 < _cfg_value(cfg, "min_rr_buy_now"):
|
||||
reasons.append(f"rr1={rr1} < {_cfg_value(cfg, 'min_rr_buy_now')},禁止现价买入")
|
||||
|
||||
if action_status == "可即刻买入":
|
||||
if current_price > 0:
|
||||
plan_entry_price = to_float(entry_plan.get("entry_price"))
|
||||
# 价格已经回到/跌破计划参考价,且实时 RR 已达标时,应转为入场窗口,不能继续显示“等回踩”。
|
||||
if plan_entry_price > 0 and current_price <= plan_entry_price * 1.003 and risk_reward_ok is not False and rr1 >= _cfg_value(cfg, "min_rr_buy_now"):
|
||||
entry_plan["entry_trigger_confirmed"] = True
|
||||
entry_plan["entry_action"] = "可即刻买入"
|
||||
# 缺少止损/目标价时 rr1 默认 999,不能因为字段不全拦截测试/候选信号;有显式 rr1 时才按硬门槛降级。
|
||||
if entry_action == "等回踩" and not entry_plan.get("entry_trigger_confirmed") and current_price > to_float(entry_plan.get("entry_price")) * 1.003:
|
||||
reasons.append("原计划为等回踩,当前尚未严格触达计划价")
|
||||
if breakout_distance > _cfg_value(cfg, "breakout_distance_wait_pct"):
|
||||
reasons.append(f"离突破位+{breakout_distance:.1f}%,现价追高降级")
|
||||
if change_24h > _cfg_value(cfg, "gain_24h_wait_pct") and rr1 < 1.5:
|
||||
reasons.append(f"24h涨幅{change_24h:.1f}%且rr1不足,禁止追涨")
|
||||
|
||||
if breakout_distance > _cfg_value(cfg, "breakout_distance_ban_pct"):
|
||||
target_action = "观察"
|
||||
reasons.append(f"离突破位+{breakout_distance:.1f}%>{ _cfg_value(cfg, 'breakout_distance_ban_pct') }%,严禁现价追")
|
||||
elif breakout_distance > _cfg_value(cfg, "breakout_distance_risk_pct"):
|
||||
target_action = "观察"
|
||||
reasons.append(f"离突破位+{breakout_distance:.1f}%>{ _cfg_value(cfg, 'breakout_distance_risk_pct') }%,只做强势观察")
|
||||
elif change_24h > _cfg_value(cfg, "gain_24h_observe_pct") and rr1 < 1.5:
|
||||
target_action = "观察"
|
||||
elif reasons:
|
||||
# 只要买点质量闸门明确给出拦截原因,就不能继续保留“可即刻买入”。
|
||||
# 如果当前已经回到/跌破计划参考价,但实时 RR 仍不足,说明不是“等回踩”,而是“回踩到了也不值得买”,应降级为观察。
|
||||
if action_status == "等回踩" and current_price > 0 and to_float(entry_plan.get("entry_price")) > 0 and current_price <= to_float(entry_plan.get("entry_price")) * 1.003 and (risk_reward_ok is False or rr1 < _cfg_value(cfg, "min_rr_buy_now")):
|
||||
target_action = "观察"
|
||||
reasons.append("回踩参考已到,但实时盈亏比不达标,转为观察")
|
||||
else:
|
||||
# risk_reward_ok=false / rr1不足 / 追高距离过远 都代表“现价买入被禁止”;
|
||||
# 展示层必须降级为“等回踩/观察”,否则会出现“闸门禁止买入但仍显示入场窗口”的矛盾。
|
||||
target_action = "等回踩" if action_status == "可即刻买入" else action_status
|
||||
else:
|
||||
target_action = action_status
|
||||
|
||||
if target_action != action_status:
|
||||
entry_plan["entry_quality_gate"] = {
|
||||
"blocked_action": action_status,
|
||||
"final_action": target_action,
|
||||
"reasons": reasons,
|
||||
"rr1": rr1,
|
||||
"risk_reward_ok": risk_reward_ok,
|
||||
"breakout_distance_pct": breakout_distance,
|
||||
"change_24h": change_24h,
|
||||
}
|
||||
return target_action, entry_plan, reasons
|
||||
784
pa_engine.py
Normal file
784
pa_engine.py
Normal file
@ -0,0 +1,784 @@
|
||||
"""
|
||||
价格行为学(PA)引擎 — 动K/静K、供需区Q评分、连续K加速、起爆点判定
|
||||
|
||||
核心概念:
|
||||
- 动K(Dynamic Candle):实体占比>70% + 振幅>1.5×ATR → 强方向性力量
|
||||
- 静K(Static Candle):实体占比<40% + 振幅<0.8×ATR → 犹豫/平衡/蓄力
|
||||
- 供需区:由动K+静K群组成,Q评分≥7才是高质量区
|
||||
- 起爆点:静K群后出现动K = 从蓄力到爆发的转折
|
||||
- 连续K:高低点不断抬高/降低 = 趋势加速段
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
|
||||
from config_loader import (
|
||||
dynamic_k_thresholds,
|
||||
static_k_thresholds,
|
||||
zone_params,
|
||||
ignition_params,
|
||||
continuous_k_params,
|
||||
exhaustion_params,
|
||||
entry_point_params,
|
||||
)
|
||||
|
||||
|
||||
# ==================== 动K/静K识别 ====================
|
||||
|
||||
def classify_candles(df: pd.DataFrame, atr: float) -> List[Dict]:
|
||||
"""
|
||||
对每根K线做动K/静K分类
|
||||
返回: [{"index": i, "type": "dynamic"/"static"/"neutral", "direction": 1/-1/0, ...}]
|
||||
"""
|
||||
dynamic_body_ratio_min, dynamic_atr_ratio_min = dynamic_k_thresholds()
|
||||
static_body_ratio_max, static_atr_ratio_max = static_k_thresholds()
|
||||
results = []
|
||||
for i in range(len(df)):
|
||||
o = float(df["open"].iloc[i])
|
||||
c = float(df["close"].iloc[i])
|
||||
h = float(df["high"].iloc[i])
|
||||
l = float(df["low"].iloc[i])
|
||||
v = float(df["volume"].iloc[i])
|
||||
|
||||
body = abs(c - o)
|
||||
total_range = h - l
|
||||
if total_range <= 0 or l <= 0 or atr <= 0:
|
||||
results.append({"index": i, "type": "neutral", "direction": 0,
|
||||
"body_ratio": 0, "swing_ratio": 0, "volume": v})
|
||||
continue
|
||||
|
||||
body_ratio = body / total_range # 实体占比
|
||||
swing_ratio = total_range / l # 振幅/低价 ≈ 涨跌幅
|
||||
|
||||
atr_ratio = total_range / atr # 振幅/ATR
|
||||
|
||||
# 动K:实体占比>70% + 振幅>1.5×ATR
|
||||
if body_ratio > dynamic_body_ratio_min and atr_ratio > dynamic_atr_ratio_min:
|
||||
direction = 1 if c > o else -1
|
||||
results.append({"index": i, "type": "dynamic", "direction": direction,
|
||||
"body_ratio": body_ratio, "swing_ratio": swing_ratio,
|
||||
"atr_ratio": atr_ratio, "volume": v})
|
||||
# 静K:实体占比<40% + 振幅<0.8×ATR
|
||||
elif body_ratio < static_body_ratio_max and atr_ratio < static_atr_ratio_max:
|
||||
results.append({"index": i, "type": "static", "direction": 0,
|
||||
"body_ratio": body_ratio, "swing_ratio": swing_ratio,
|
||||
"atr_ratio": atr_ratio, "volume": v})
|
||||
else:
|
||||
direction = 1 if c > o else -1 if c < o else 0
|
||||
results.append({"index": i, "type": "neutral", "direction": direction,
|
||||
"body_ratio": body_ratio, "swing_ratio": swing_ratio,
|
||||
"atr_ratio": atr_ratio, "volume": v})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def calc_atr(df: pd.DataFrame, period: int = 14) -> float:
|
||||
"""计算ATR"""
|
||||
if df is None or len(df) < period + 1:
|
||||
return 0.0
|
||||
tr = pd.concat([
|
||||
df["high"] - df["low"],
|
||||
abs(df["high"] - df["close"].shift(1)),
|
||||
abs(df["low"] - df["close"].shift(1)),
|
||||
], axis=1).max(axis=1)
|
||||
atr = tr.rolling(period).mean().iloc[-1]
|
||||
return float(atr) if not pd.isna(atr) else 0.0
|
||||
|
||||
|
||||
# ==================== 供需区 + Q评分 ====================
|
||||
|
||||
def find_supply_demand_zones(df: pd.DataFrame, candles_class: List[Dict],
|
||||
atr: float, lookback: int = 50) -> List[Dict]:
|
||||
"""
|
||||
识别结构化供需区(动K+静K群+动K确认)并计算Q评分
|
||||
|
||||
供给区模式:阴动K(方向-1) → 静K群(2-5根) → 阳动K确认(方向+1)离开
|
||||
需求区模式:阳动K(方向+1) → 静K群(2-5根) → 阴动K确认(方向-1)离开
|
||||
|
||||
返回: [{"type": "supply"/"demand", "top": float, "btm": float,
|
||||
"q_score": float, "freshness": int, ...}]
|
||||
"""
|
||||
cfg_lookback, min_static_count, _ = zone_params()
|
||||
lookback = cfg_lookback if cfg_lookback else lookback
|
||||
|
||||
zones = []
|
||||
if not candles_class or atr <= 0:
|
||||
return zones
|
||||
|
||||
# 只看最近lookback根K线
|
||||
start_idx = max(0, len(candles_class) - lookback)
|
||||
|
||||
for i in range(start_idx, len(candles_class)):
|
||||
c = candles_class[i]
|
||||
if c["type"] != "dynamic":
|
||||
continue
|
||||
|
||||
# 检查后面是否有静K群+确认动K
|
||||
base_dir = c["direction"] # 起始动K方向
|
||||
|
||||
# 寻找静K群(紧随起始动K之后的2-5根静K)
|
||||
static_start = i + 1
|
||||
static_count = 0
|
||||
static_end = static_start
|
||||
for j in range(static_start, min(static_start + 5, len(candles_class))):
|
||||
if candles_class[j]["type"] == "static":
|
||||
static_count += 1
|
||||
static_end = j + 1
|
||||
else:
|
||||
break
|
||||
|
||||
if static_count < min_static_count: # 需要至少N根静K
|
||||
continue
|
||||
|
||||
# 寻找确认动K(静K群之后的动K)
|
||||
confirm_idx = None
|
||||
for j in range(static_end, min(static_end + 3, len(candles_class))):
|
||||
if candles_class[j]["type"] == "dynamic":
|
||||
confirm_idx = j
|
||||
break
|
||||
|
||||
if confirm_idx is None:
|
||||
continue
|
||||
|
||||
confirm_dir = candles_class[confirm_idx]["direction"]
|
||||
|
||||
# 判断区类型
|
||||
# 供给区:起始是阴动K,确认是阳动K离开向上
|
||||
# 需求区:起始是阳动K,确认是阴动K离开向下
|
||||
if base_dir == -1 and confirm_dir == 1:
|
||||
zone_type = "supply"
|
||||
elif base_dir == 1 and confirm_dir == -1:
|
||||
zone_type = "demand"
|
||||
else:
|
||||
continue # 方向不匹配
|
||||
|
||||
# 计算区的范围
|
||||
# 供给区:从静K群最低点到起始动K高点
|
||||
# 需求区:从起始动K低点到静K群最高点
|
||||
region_indices = [i] + list(range(static_start, static_end))
|
||||
region_highs = [float(df["high"].iloc[ri]) for ri in region_indices]
|
||||
region_lows = [float(df["low"].iloc[ri]) for ri in region_indices]
|
||||
|
||||
if zone_type == "supply":
|
||||
zone_top = max(region_highs)
|
||||
zone_btm = min(region_lows)
|
||||
else:
|
||||
zone_top = max(region_highs) # 需求区top = 阳动K高点+静K高点
|
||||
zone_btm = min(region_lows) # 需求区btm = 最低点
|
||||
|
||||
zone_height = zone_top - zone_btm
|
||||
if zone_height <= 0:
|
||||
continue
|
||||
|
||||
# 离去强度:确认动K的离开幅度 / 区高度
|
||||
confirm_price = float(df["close"].iloc[confirm_idx])
|
||||
if zone_type == "supply":
|
||||
leave_dist = confirm_price - zone_top # 离开供给区向上
|
||||
else:
|
||||
leave_dist = zone_btm - confirm_price # 离开需求区向下
|
||||
|
||||
leave_ratio = abs(leave_dist) / zone_height if zone_height > 0 else 0
|
||||
|
||||
# Q评分(0-10分)
|
||||
q_score = _calc_q_score(
|
||||
zone_type=zone_type,
|
||||
base_speed=static_count, # 静K数量(基底速度)
|
||||
freshness=_calc_freshness(df, zone_top, zone_btm, i, zone_type),
|
||||
age=len(candles_class) - i, # K线年龄
|
||||
leave_ratio=leave_ratio,
|
||||
reaction_strength=_calc_reaction_strength(df, zone_type, zone_top, zone_btm, i),
|
||||
)
|
||||
|
||||
zones.append({
|
||||
"type": zone_type,
|
||||
"top": round(zone_top, 6),
|
||||
"btm": round(zone_btm, 6),
|
||||
"q_score": q_score,
|
||||
"leave_ratio": round(leave_ratio, 2),
|
||||
"base_index": i,
|
||||
"confirm_index": confirm_idx,
|
||||
"static_count": static_count,
|
||||
"age": len(candles_class) - i,
|
||||
})
|
||||
|
||||
# 去重:同价位区域保留Q评分最高的
|
||||
deduped = {}
|
||||
for z in zones:
|
||||
key = (z["type"], round(z["btm"], 4), round(z["top"], 4))
|
||||
if key not in deduped or z["q_score"] > deduped[key]["q_score"]:
|
||||
deduped[key] = z
|
||||
|
||||
return sorted(deduped.values(), key=lambda z: z["q_score"], reverse=True)
|
||||
|
||||
|
||||
def _calc_q_score(zone_type, base_speed, freshness, age, leave_ratio, reaction_strength):
|
||||
"""
|
||||
Q质量评分(0-10分)
|
||||
维度:基底速度(0-2) + 新鲜度(0-2) + 年龄(0-1) + 离去强度(0-3) + 回踩反应(0-2)
|
||||
"""
|
||||
_, _, q_score_breakpoints = zone_params()
|
||||
base_speed_bp = q_score_breakpoints.get("base_speed", [2, 3, 5])
|
||||
freshness_bp = q_score_breakpoints.get("freshness", [0, 2])
|
||||
age_bp = q_score_breakpoints.get("age", [20, 40])
|
||||
leave_ratio_bp = q_score_breakpoints.get("leave_ratio", [1.0, 1.5, 2.5, 3.0])
|
||||
|
||||
score = 0.0
|
||||
|
||||
# 1. 基底速度(静K越少越好,说明蓄力时间短、爆发快)
|
||||
if base_speed <= base_speed_bp[0]:
|
||||
score += 2
|
||||
elif base_speed <= base_speed_bp[1]:
|
||||
score += 1.5
|
||||
elif base_speed <= base_speed_bp[2]:
|
||||
score += 1
|
||||
else:
|
||||
score += 0
|
||||
|
||||
# 2. 新鲜度(未被触碰的次数越少越好)
|
||||
if freshness <= freshness_bp[0]:
|
||||
score += 2
|
||||
elif freshness <= freshness_bp[1]:
|
||||
score += 1
|
||||
else:
|
||||
score += 0
|
||||
|
||||
# 3. 年龄(越近越好)
|
||||
if age <= age_bp[0]:
|
||||
score += 1
|
||||
elif age <= age_bp[1]:
|
||||
score += 0.5
|
||||
else:
|
||||
score += 0
|
||||
|
||||
# 4. 离去强度(离开幅度/区域高度 ≥2.5倍=最强)
|
||||
if leave_ratio >= leave_ratio_bp[3]:
|
||||
score += 3
|
||||
elif leave_ratio >= leave_ratio_bp[2]:
|
||||
score += 2.5
|
||||
elif leave_ratio >= leave_ratio_bp[1]:
|
||||
score += 2
|
||||
elif leave_ratio >= leave_ratio_bp[0]:
|
||||
score += 1
|
||||
else:
|
||||
score += 0
|
||||
|
||||
# 5. 回踩反应(回踩后快速离开=强反应)
|
||||
score += min(reaction_strength, 2)
|
||||
|
||||
return round(score, 1)
|
||||
|
||||
|
||||
def _calc_freshness(df, zone_top, zone_btm, base_index, zone_type):
|
||||
"""计算区域被触碰次数(新鲜度)"""
|
||||
touches = 0
|
||||
price = float(df["close"].iloc[-1])
|
||||
|
||||
for j in range(base_index + 1, len(df)):
|
||||
h = float(df["high"].iloc[j])
|
||||
l = float(df["low"].iloc[j])
|
||||
|
||||
if zone_type == "supply" and l <= zone_top:
|
||||
touches += 1
|
||||
elif zone_type == "demand" and h >= zone_btm:
|
||||
touches += 1
|
||||
|
||||
return touches
|
||||
|
||||
|
||||
def _calc_reaction_strength(df, zone_type, zone_top, zone_btm, base_index):
|
||||
"""计算回踩后反应强度"""
|
||||
if base_index + 6 >= len(df):
|
||||
return 0
|
||||
|
||||
# 看回踩后2根K线是否快速离开区域
|
||||
reaction = 0
|
||||
for j in range(base_index + 2, min(base_index + 8, len(df))):
|
||||
c = float(df["close"].iloc[j])
|
||||
|
||||
if zone_type == "supply":
|
||||
# 回踩供给区后是否快速远离(向下远离=强反应)
|
||||
dist = (zone_btm - c) / (zone_top - zone_btm) if (zone_top - zone_btm) > 0 else 0
|
||||
reaction = max(reaction, max(0, dist))
|
||||
else:
|
||||
# 回踩需求区后是否快速远离(向上远离=强反应)
|
||||
dist = (c - zone_top) / (zone_top - zone_btm) if (zone_top - zone_btm) > 0 else 0
|
||||
reaction = max(reaction, max(0, dist))
|
||||
|
||||
# 映射到0-2分
|
||||
if reaction >= 2.0:
|
||||
return 2
|
||||
elif reaction >= 1.0:
|
||||
return 1
|
||||
elif reaction >= 0.5:
|
||||
return 0.5
|
||||
return 0
|
||||
|
||||
|
||||
# ==================== 连续K识别(趋势加速) ====================
|
||||
|
||||
def find_continuous_k(df: pd.DataFrame) -> List[Dict]:
|
||||
"""
|
||||
识别连续K线组合(高低点不断抬高的阳线群/不断降低的阴线群)
|
||||
对应TV的is_continue_hl逻辑
|
||||
|
||||
返回: [{"type": "bullish_continue"/"bearish_continue", "length": int,
|
||||
"start_index": int, "strength": float}]
|
||||
"""
|
||||
min_count = continuous_k_params().get("min_count", 3)
|
||||
results = []
|
||||
if len(df) < min_count:
|
||||
return results
|
||||
|
||||
closes = df["close"].values
|
||||
opens = df["open"].values
|
||||
highs = df["high"].values
|
||||
lows = df["low"].values
|
||||
|
||||
# 从最新K线往回找连续模式
|
||||
i = len(df) - 1
|
||||
|
||||
# 多头连续(阳线+高点抬高+低点抬高)
|
||||
bull_count = 0
|
||||
bull_start = i
|
||||
for j in range(i, -1, -1):
|
||||
if closes[j] > opens[j]: # 阳线
|
||||
if j < i:
|
||||
if lows[j] < lows[j+1] and highs[j] < highs[j+1]:
|
||||
# 低点抬高 + 高点抬高 = 多头连续
|
||||
bull_count += 1
|
||||
bull_start = j
|
||||
else:
|
||||
break
|
||||
else:
|
||||
bull_count = 1
|
||||
bull_start = j
|
||||
else:
|
||||
break
|
||||
|
||||
if bull_count >= min_count:
|
||||
strength = sum(highs[k] - lows[k] for k in range(bull_start, i+1)) / df["close"].iloc[-1]
|
||||
results.append({
|
||||
"type": "bullish_continue",
|
||||
"length": bull_count,
|
||||
"start_index": bull_start,
|
||||
"strength": round(float(strength), 4),
|
||||
})
|
||||
|
||||
# 空头连续(阴线+高点降低+低点降低)
|
||||
bear_count = 0
|
||||
bear_start = i
|
||||
for j in range(i, -1, -1):
|
||||
if closes[j] < opens[j]: # 阴线
|
||||
if j < i:
|
||||
if lows[j] > lows[j+1] and highs[j] > highs[j+1]:
|
||||
bear_count += 1
|
||||
bear_start = j
|
||||
else:
|
||||
break
|
||||
else:
|
||||
bear_count = 1
|
||||
bear_start = j
|
||||
else:
|
||||
break
|
||||
|
||||
if bear_count >= min_count:
|
||||
strength = sum(highs[k] - lows[k] for k in range(bear_start, i+1)) / float(df["close"].iloc[-1])
|
||||
results.append({
|
||||
"type": "bearish_continue",
|
||||
"length": bear_count,
|
||||
"start_index": bear_start,
|
||||
"strength": round(float(strength), 4),
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ==================== 起爆点判定 ====================
|
||||
|
||||
def detect_ignition_point(candles_class: List[Dict], df: pd.DataFrame,
|
||||
atr: float, min_static_count: int = 2) -> List[Dict]:
|
||||
"""
|
||||
检测起爆点:静K群后出现动K = 从蓄力到爆发的转折
|
||||
|
||||
条件:
|
||||
1. 连续min_static_count根静K(蓄力段)
|
||||
2. 紧随其后出现动K(方向性爆发)
|
||||
3. 动K实体 > 静K群平均实体 × 3(爆发力量显著)
|
||||
|
||||
返回: [{"index": int, "direction": 1/-1, "strength": float,
|
||||
"static_before": int, "signal_type": str}]
|
||||
"""
|
||||
lookback, cfg_min_static_count, static_search_range, _ = ignition_params()
|
||||
min_static_count = cfg_min_static_count if cfg_min_static_count else min_static_count
|
||||
|
||||
ignitions = []
|
||||
if not candles_class or atr <= 0:
|
||||
return ignitions
|
||||
|
||||
start_idx = max(0, len(candles_class) - lookback) # 只看最近N根
|
||||
|
||||
for i in range(start_idx, len(candles_class)):
|
||||
c = candles_class[i]
|
||||
if c["type"] != "dynamic":
|
||||
continue
|
||||
|
||||
# 检查前面是否有静K群
|
||||
static_count = 0
|
||||
for j in range(i - 1, max(i - (static_search_range + 1), start_idx - 1), -1):
|
||||
if candles_class[j]["type"] == "static":
|
||||
static_count += 1
|
||||
else:
|
||||
break
|
||||
|
||||
if static_count < min_static_count:
|
||||
continue
|
||||
|
||||
# 检查爆发强度:动K实体 vs 静K群平均实体
|
||||
dynamic_body = abs(float(df["close"].iloc[i]) - float(df["open"].iloc[i]))
|
||||
|
||||
# 静K群平均实体
|
||||
static_bodies = []
|
||||
for j in range(i - static_count, i):
|
||||
if candles_class[j]["type"] == "static":
|
||||
sb = abs(float(df["close"].iloc[j]) - float(df["open"].iloc[j]))
|
||||
static_bodies.append(sb)
|
||||
avg_static_body = sum(static_bodies) / len(static_bodies) if static_bodies else 0
|
||||
|
||||
# 爆发强度 = 动K实体 / 静K平均实体
|
||||
if avg_static_body <= 0:
|
||||
strength_ratio = dynamic_body / atr if atr > 0 else 0
|
||||
else:
|
||||
strength_ratio = dynamic_body / avg_static_body
|
||||
|
||||
# 动K方向
|
||||
direction = c["direction"]
|
||||
|
||||
# 信号类型命名
|
||||
if direction == 1:
|
||||
signal_type = "起爆点↑(静K→阳动K)"
|
||||
elif direction == -1:
|
||||
signal_type = "起爆点↓(静K→阴动K)"
|
||||
else:
|
||||
continue
|
||||
|
||||
age_bars = len(candles_class) - 1 - i
|
||||
ignitions.append({
|
||||
"index": i,
|
||||
"direction": direction,
|
||||
"strength_ratio": round(strength_ratio, 2),
|
||||
"static_before": static_count,
|
||||
"dynamic_atr_ratio": round(c["atr_ratio"], 2),
|
||||
"signal_type": signal_type,
|
||||
"age_bars": age_bars,
|
||||
})
|
||||
|
||||
return ignitions
|
||||
|
||||
|
||||
# ==================== 15min入场点分析 ====================
|
||||
|
||||
def analyze_entry_point(h1_df: pd.DataFrame, m15_df: pd.DataFrame,
|
||||
atr_1h: float, zones_4h: List[Dict],
|
||||
direction: int = 1) -> Dict:
|
||||
"""
|
||||
15min级别入场点分析
|
||||
|
||||
方向=1(做多):寻找回踩到需求区+出现静K确认 = 最佳买点
|
||||
方向=-1(做空):寻找反弹到供给区+出现静K确认 = 最佳卖点
|
||||
|
||||
返回: {
|
||||
"action": "即刻买入"/"等回踩"/"放弃",
|
||||
"entry_type": str,
|
||||
"wait_price": float, # 等回踩的目标价位
|
||||
"confidence": float, # 0-1
|
||||
"reason": str,
|
||||
"breakout_k_info": dict, # 突破K线信息
|
||||
"pullback_info": dict, # 回踩信息
|
||||
"false_breakout": bool, # 是否假突破
|
||||
}
|
||||
"""
|
||||
if m15_df is None or len(m15_df) < 20 or atr_1h <= 0:
|
||||
return {"action": "放弃", "entry_type": "数据不足",
|
||||
"wait_price": 0, "confidence": 0, "reason": "15min数据不足",
|
||||
"false_breakout": False}
|
||||
|
||||
entry_cfg = entry_point_params()
|
||||
recent_15min = entry_cfg.get("recent_15min", 6)
|
||||
dy_bear_max = entry_cfg.get("dy_bear_max", 3)
|
||||
|
||||
price_1h = float(h1_df["close"].iloc[-1]) if h1_df is not None else 0
|
||||
atr_15 = calc_atr(m15_df, 14)
|
||||
m15_class = classify_candles(m15_df, atr_15)
|
||||
|
||||
# 找最近2-3根15min的突破K线
|
||||
recent_15 = m15_class[-recent_15min:] # 最近N根15min
|
||||
breakout_k = None
|
||||
pullback_k = None
|
||||
false_breakout = False
|
||||
|
||||
# 1. 找突破K线:必须是最近/上一根15min内发生,避免把1小时前突破当作“正在突破”
|
||||
max_breakout_age = entry_cfg.get("max_breakout_age_bars", 1)
|
||||
for c in reversed(recent_15):
|
||||
age_bars = len(m15_class) - 1 - c.get("index", -1)
|
||||
if age_bars > max_breakout_age:
|
||||
continue
|
||||
if c["type"] == "dynamic" and c["direction"] == direction:
|
||||
breakout_k = c
|
||||
breakout_k["age_bars"] = age_bars
|
||||
break
|
||||
|
||||
# 2. 突破后是否有回踩(静K或neutral+小实体)
|
||||
if breakout_k:
|
||||
bk_idx = breakout_k["index"]
|
||||
# 看突破K线之后的K线
|
||||
for c in m15_class[bk_idx+1:]:
|
||||
if c["type"] == "static" or (c["type"] == "neutral" and c["body_ratio"] < 0.35):
|
||||
pullback_k = c
|
||||
break
|
||||
|
||||
# 3. 假突破检测:突破后立刻回落跌破突破K线低点(做多)/突破K线高点(做空)
|
||||
if breakout_k:
|
||||
bk_low = float(m15_df["low"].iloc[breakout_k["index"]])
|
||||
bk_high = float(m15_df["high"].iloc[breakout_k["index"]])
|
||||
current_price = float(m15_df["close"].iloc[-1])
|
||||
|
||||
if direction == 1 and current_price < bk_low:
|
||||
false_breakout = True
|
||||
elif direction == -1 and current_price > bk_high:
|
||||
false_breakout = True
|
||||
|
||||
# 4. 判断入场类型
|
||||
if false_breakout:
|
||||
return {
|
||||
"action": "放弃",
|
||||
"entry_type": "假突破",
|
||||
"wait_price": 0,
|
||||
"confidence": 0,
|
||||
"reason": "突破后回落,假突破信号",
|
||||
"breakout_k_info": {"index": breakout_k["index"] if breakout_k else -1,
|
||||
"atr_ratio": breakout_k["atr_ratio"] if breakout_k else 0},
|
||||
"pullback_info": {},
|
||||
"false_breakout": True,
|
||||
}
|
||||
|
||||
# 5. 找目标回踩价位(4H需求区或1H均线支撑)
|
||||
wait_price = 0
|
||||
if direction == 1:
|
||||
# 做多:找最近的4H需求区top作为回踩目标
|
||||
demand_zones = [z for z in zones_4h if z["type"] == "demand" and z["q_score"] >= 7]
|
||||
if demand_zones:
|
||||
# 价格还在需求区上方 → 回踩到需求区top是最佳买点
|
||||
nearest_demand = min(demand_zones, key=lambda z: abs(z["top"] - price_1h))
|
||||
if nearest_demand["top"] < price_1h:
|
||||
wait_price = round(nearest_demand["top"], 6)
|
||||
else:
|
||||
# 无高质量需求区,用1H MA20作为回踩目标
|
||||
if h1_df is not None and len(h1_df) >= 20:
|
||||
ma20_1h = float(h1_df["close"].rolling(20).mean().iloc[-1])
|
||||
if ma20_1h < price_1h:
|
||||
wait_price = round(ma20_1h, 6)
|
||||
|
||||
elif direction == -1:
|
||||
# 做空:找最近的4H供给区btm作为反弹目标
|
||||
supply_zones = [z for z in zones_4h if z["type"] == "supply" and z["q_score"] >= 7]
|
||||
if supply_zones:
|
||||
nearest_supply = min(supply_zones, key=lambda z: abs(z["btm"] - price_1h))
|
||||
if nearest_supply["btm"] > price_1h:
|
||||
wait_price = round(nearest_supply["btm"], 6)
|
||||
|
||||
# 6. 综合判断(v11纯前瞻版 — 用PA行为替代RSI/布林)
|
||||
current_price_15 = float(m15_df["close"].iloc[-1])
|
||||
|
||||
# 即刻买入条件:15min动K连续 + 无连续阴动K超买反转
|
||||
if breakout_k and not pullback_k:
|
||||
# 突破正在发生,没有回踩 — PA判断:15min近6根中阴动K<3即非超买反转
|
||||
candles_15 = classify_candles(m15_df, calc_atr(m15_df, 14))
|
||||
recent_15 = candles_15[-recent_15min:] if len(candles_15) >= recent_15min else candles_15
|
||||
dy_bear_15 = sum(1 for c in recent_15 if c["type"] == "dynamic" and c["direction"] == -1)
|
||||
no_overbought_reversal = dy_bear_15 < dy_bear_max
|
||||
|
||||
if no_overbought_reversal:
|
||||
return {
|
||||
"action": "即刻买入",
|
||||
"entry_type": "突破进行中(15min动K连续)",
|
||||
"wait_price": round(current_price_15, 6),
|
||||
"confidence": 0.8,
|
||||
"reason": f"15min突破K线正在发生,近{recent_15min}根阴动K={dy_bear_15}<{dy_bear_max}(无超买反转)",
|
||||
"breakout_k_info": {"index": breakout_k["index"],
|
||||
"atr_ratio": round(breakout_k["atr_ratio"], 2),
|
||||
"direction": breakout_k["direction"],
|
||||
"age_bars": breakout_k.get("age_bars", 0)},
|
||||
"pullback_info": {},
|
||||
"false_breakout": False,
|
||||
}
|
||||
|
||||
# 等回踩条件:突破后出现静K回踩
|
||||
if breakout_k and pullback_k:
|
||||
pb_idx = pullback_k["index"]
|
||||
pb_low = float(m15_df["low"].iloc[pb_idx])
|
||||
pb_high = float(m15_df["high"].iloc[pb_idx])
|
||||
return {
|
||||
"action": "等回踩",
|
||||
"entry_type": "突破后回踩确认",
|
||||
"wait_price": wait_price if wait_price > 0 else round(pb_low, 6),
|
||||
"confidence": 0.6,
|
||||
"reason": f"突破后15min出现静K回踩,等价格回到${wait_price or pb_low:.4f}附近入场",
|
||||
"breakout_k_info": {"index": breakout_k["index"],
|
||||
"atr_ratio": round(breakout_k["atr_ratio"], 2)},
|
||||
"pullback_info": {"index": pb_idx,
|
||||
"low": round(pb_low, 6),
|
||||
"high": round(pb_high, 6)},
|
||||
"false_breakout": False,
|
||||
}
|
||||
|
||||
# 放弃条件:15min出现连续阴动K(PA超买反转信号)
|
||||
candles_15_all = classify_candles(m15_df, calc_atr(m15_df, 14))
|
||||
recent_15_all = candles_15_all[-recent_15min:] if len(candles_15_all) >= recent_15min else candles_15_all
|
||||
dy_bear_15_all = sum(1 for c in recent_15_all if c["type"] == "dynamic" and c["direction"] == -1)
|
||||
if dy_bear_15_all >= dy_bear_max:
|
||||
return {
|
||||
"action": "放弃",
|
||||
"entry_type": "超买反转(PA)",
|
||||
"wait_price": 0,
|
||||
"confidence": 0,
|
||||
"reason": f"15min近{recent_15min}根阴动K={dy_bear_15_all}≥{dy_bear_max}(超买反转信号)",
|
||||
"false_breakout": False,
|
||||
}
|
||||
|
||||
# 默认:HTF已确认多头,15min无明显突破K但也不存在反转信号
|
||||
# → 不再强制等回踩(37%失败率已被数据证实),改为参考当前价即刻入场
|
||||
# 由上层质量闸门对极端追高做最后拦截
|
||||
return {
|
||||
"action": "即刻买入",
|
||||
"entry_type": "HTF趋势延续(15min无反转)",
|
||||
"wait_price": round(current_price_15, 6),
|
||||
"confidence": 0.5,
|
||||
"reason": f"1H/4H已确认多头,15min无动K突破但阴动K={dy_bear_15_all}<{dy_bear_max}(无反转),即刻入场",
|
||||
"breakout_k_info": {},
|
||||
"pullback_info": {},
|
||||
"false_breakout": False,
|
||||
}
|
||||
|
||||
|
||||
# ==================== 趋势衰减检测 ====================
|
||||
|
||||
def detect_trend_exhaustion(h1_df: pd.DataFrame, atr_1h: float) -> Dict:
|
||||
"""
|
||||
检测1H级别趋势衰减信号(v11纯前瞻版 — 已删除MACD/RSI)
|
||||
|
||||
衰减信号(纯PA行为):
|
||||
- 连续2+根静K(方向性力量减弱)
|
||||
- 1H放量阴线×2(多头出货)
|
||||
- 1H连续K空头加速(趋势加速下行)
|
||||
- 1H连续≥3根阴动K(趋势反转)
|
||||
- 放量滞涨(量放大但价格不涨)
|
||||
|
||||
返回: {"exhausted": bool, "signals": [...], "severity": "low"/"medium"/"high"}
|
||||
"""
|
||||
if h1_df is None or len(h1_df) < 30 or atr_1h <= 0:
|
||||
return {"exhausted": False, "signals": [], "severity": "low"}
|
||||
|
||||
ex_cfg = exhaustion_params()
|
||||
recent_static_count = ex_cfg.get("recent_static_count", 3)
|
||||
recent_static_threshold = ex_cfg.get("recent_static_threshold", 2)
|
||||
high_vol_bear_threshold = ex_cfg.get("high_vol_bear_threshold", 2)
|
||||
dy_bear_threshold = ex_cfg.get("dy_bear_threshold", 3)
|
||||
vol_ratio_stagnation = ex_cfg.get("vol_ratio_stagnation", 2.0)
|
||||
price_change_stagnation_pct = ex_cfg.get("price_change_stagnation", 0.005) * 100
|
||||
min_count = continuous_k_params().get("min_count", 3)
|
||||
|
||||
signals = []
|
||||
severity = "low"
|
||||
candles = classify_candles(h1_df, atr_1h)
|
||||
|
||||
# 1. 最近2根是否是静K(方向性力量减弱)
|
||||
recent_types = [c["type"] for c in candles[-recent_static_count:]]
|
||||
static_count_recent = sum(1 for t in recent_types if t == "static")
|
||||
if static_count_recent >= recent_static_threshold:
|
||||
signals.append(f"1H连续{static_count_recent}静K(方向性减弱)")
|
||||
severity = "medium"
|
||||
|
||||
# 2. 1H放量阴线×2(多头出货) — 替代MACD柱收缩
|
||||
vol_avg = float(h1_df["volume"].rolling(20).mean().iloc[-1])
|
||||
recent_3 = h1_df.tail(3)
|
||||
high_vol_bear = 0
|
||||
for _, row in recent_3.iterrows():
|
||||
vol_r = row["volume"] / vol_avg if vol_avg > 0 else 0
|
||||
if vol_r >= vol_ratio_stagnation and row["close"] < row["open"]:
|
||||
high_vol_bear += 1
|
||||
if high_vol_bear >= high_vol_bear_threshold:
|
||||
signals.append(f"1H放量阴线×{high_vol_bear}(多头出货)")
|
||||
severity = "medium" if severity == "low" else severity
|
||||
|
||||
# 3. 1H连续K空头加速 — 替代RSI回落
|
||||
cont_k = find_continuous_k(h1_df)
|
||||
for ck in cont_k:
|
||||
if ck["type"] == "bearish_continue" and ck["length"] >= min_count:
|
||||
signals.append(f"1H连续{ck['length']}K空头加速")
|
||||
severity = "medium" if severity == "low" else severity
|
||||
|
||||
# 4. 1H连续≥3根阴动K(趋势反转) — 替代MACD死叉
|
||||
recent_candles = candles[-6:] if len(candles) >= 6 else candles
|
||||
dy_bear_count = sum(1 for c in recent_candles if c["type"] == "dynamic" and c["direction"] == -1)
|
||||
if dy_bear_count >= dy_bear_threshold:
|
||||
signals.append(f"1H连续{dy_bear_count}根阴动K(趋势反转)")
|
||||
severity = "high"
|
||||
|
||||
# 5. 放量滞涨(PA行为)
|
||||
vol_latest = float(h1_df["volume"].iloc[-1])
|
||||
vol_ratio = vol_latest / vol_avg if vol_avg > 0 else 1
|
||||
price_change_latest = (float(h1_df["close"].iloc[-1]) / float(h1_df["close"].iloc[-2]) - 1) * 100
|
||||
if vol_ratio >= vol_ratio_stagnation and abs(price_change_latest) < price_change_stagnation_pct:
|
||||
signals.append(f"1H放量滞涨(量{vol_ratio:.1f}倍但涨{price_change_latest:.2f}%)")
|
||||
severity = "high"
|
||||
|
||||
exhausted = severity != "low"
|
||||
|
||||
return {
|
||||
"exhausted": exhausted,
|
||||
"signals": signals,
|
||||
"severity": severity,
|
||||
}
|
||||
|
||||
|
||||
# ==================== 综合PA分析 ====================
|
||||
|
||||
def full_pa_analysis(df: pd.DataFrame, timeframe: str = "4h") -> Dict:
|
||||
"""
|
||||
对单币单时间框架做完整PA分析
|
||||
|
||||
返回: {
|
||||
"candles_class": [...],
|
||||
"zones": [...],
|
||||
"continuous_k": [...],
|
||||
"ignition_points": [...],
|
||||
"atr": float,
|
||||
"trend_exhaustion": {...},
|
||||
}
|
||||
"""
|
||||
atr = calc_atr(df, 14)
|
||||
if atr <= 0 or len(df) < 30:
|
||||
return {"candles_class": [], "zones": [], "continuous_k": [],
|
||||
"ignition_points": [], "atr": 0, "trend_exhaustion": {"exhausted": False, "signals": [], "severity": "low"}}
|
||||
|
||||
candles_class = classify_candles(df, atr)
|
||||
zones = find_supply_demand_zones(df, candles_class, atr)
|
||||
continuous_k = find_continuous_k(df)
|
||||
ignition_points = detect_ignition_point(candles_class, df, atr)
|
||||
|
||||
# 只在1H级别检测趋势衰减
|
||||
exhaustion = {}
|
||||
if timeframe == "1h":
|
||||
exhaustion = detect_trend_exhaustion(df, atr)
|
||||
else:
|
||||
exhaustion = {"exhausted": False, "signals": [], "severity": "low"}
|
||||
|
||||
return {
|
||||
"candles_class": candles_class,
|
||||
"zones": zones,
|
||||
"continuous_k": continuous_k,
|
||||
"ignition_points": ignition_points,
|
||||
"atr": round(atr, 6),
|
||||
"trend_exhaustion": exhaustion,
|
||||
}
|
||||
433
price_tracker.py
Normal file
433
price_tracker.py
Normal file
@ -0,0 +1,433 @@
|
||||
"""
|
||||
山寨币爆发监控系统 v11.7.9 — 价格跟踪+三级分级跟踪止盈+趋势反转(纯前瞻版)
|
||||
趋势反转检测:1H连续阴动K、量价背离、空头加速(替代MACD/RSI)
|
||||
v1.7.9: 跟踪止盈按盈利分三级 — 防震(3×ATR) → 锁利(2×ATR) → 紧贴(1.2×ATR)
|
||||
止盈止损跟踪、动态买入/卖出指引
|
||||
"""
|
||||
|
||||
import sys, os, shutil
|
||||
|
||||
# ⚠️ 安全机制:启动时强制清__pycache__,防止旧版字节码残留
|
||||
for cache_dir in [
|
||||
os.path.join(os.path.dirname(__file__), "__pycache__"),
|
||||
os.path.join(os.path.dirname(__file__), "..", "__pycache__"),
|
||||
]:
|
||||
if os.path.exists(cache_dir):
|
||||
shutil.rmtree(cache_dir, ignore_errors=True)
|
||||
|
||||
import ccxt
|
||||
import pandas as pd
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from altcoin_db import (
|
||||
init_db, get_active_recommendations, update_recommendation_tracking,
|
||||
expire_old_recommendations, get_stats, update_recommendation_action_status,
|
||||
should_push, log_push, apply_recommendation_state_transition, log_cron_run,
|
||||
update_latest_price_cache,
|
||||
)
|
||||
from pa_engine import (
|
||||
calc_atr, full_pa_analysis, detect_trend_exhaustion,
|
||||
analyze_entry_point,
|
||||
)
|
||||
from feishu_push import push_altcoin_tp_sl_alert
|
||||
from config_loader import load_rules
|
||||
from opportunity_lifecycle import apply_entry_quality_gate
|
||||
|
||||
exchange = ccxt.binance({"enableRateLimit": True})
|
||||
|
||||
|
||||
def fetch_klines(symbol, timeframe, limit=200):
|
||||
try:
|
||||
ohlcv = exchange.fetch_ohlcv(symbol, timeframe, limit=limit)
|
||||
df = pd.DataFrame(ohlcv, columns=["timestamp", "open", "high", "low", "close", "volume"])
|
||||
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms")
|
||||
return df
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def analyze_tracking_signals(symbol, rec, current_price):
|
||||
"""
|
||||
对active推荐做动态跟踪分析
|
||||
返回: {"action_status": str, "sell_signals": [...], "buy_signals": [...],
|
||||
"exhaustion": {...}, "entry_update": {...}}
|
||||
"""
|
||||
sell_signals = []
|
||||
buy_signals = []
|
||||
action_status = "持有" # 默认状态
|
||||
|
||||
entry_price = rec["entry_price"]
|
||||
stop_loss = rec.get("stop_loss", entry_price * 0.95)
|
||||
tp1 = rec.get("tp1", entry_price * 1.03)
|
||||
tp2 = rec.get("tp2", entry_price * 1.05)
|
||||
entry_plan = rec.get("entry_plan") or {}
|
||||
|
||||
# ---- 拉取1H数据做趋势分析 ----
|
||||
h1_df = fetch_klines(symbol, "1h", limit=100)
|
||||
m15_df = fetch_klines(symbol, "15m", limit=100)
|
||||
h4_df = fetch_klines(symbol, "4h", limit=100)
|
||||
|
||||
atr_1h = calc_atr(h1_df, 14) if h1_df is not None else 0
|
||||
|
||||
# ---- 趋势衰减检测 ----
|
||||
exhaustion = {}
|
||||
if h1_df is not None and atr_1h > 0:
|
||||
exhaustion = detect_trend_exhaustion(h1_df, atr_1h)
|
||||
if exhaustion.get("exhausted"):
|
||||
for es in exhaustion.get("signals", []):
|
||||
sell_signals.append(es)
|
||||
if exhaustion["severity"] == "high":
|
||||
action_status = "衰减"
|
||||
elif exhaustion["severity"] == "medium":
|
||||
# 中度衰减,还持有但需关注
|
||||
sell_signals.append("趋势中度衰减,关注止盈")
|
||||
|
||||
# ---- 止盈信号检测 ----
|
||||
pnl_pct = ((current_price / entry_price) - 1) * 100 if entry_price > 0 else 0
|
||||
|
||||
# 到达TP1(v1.7.8:TP1保留作为提醒目标)
|
||||
if tp1 > 0 and current_price >= tp1:
|
||||
sell_signals.append(f"✅ 到达TP1(${tp1:.4f}), 建议止盈50%仓位")
|
||||
action_status = "止盈1" # 无条件:TP1到了就是止盈,无论之前什么状态
|
||||
|
||||
# === v1.7.8 跟踪止盈全面升级 ===
|
||||
# 核心改动:
|
||||
# ① 激活门槛 5%→3% (抓更多利润)
|
||||
# ② ATR乘数 2.0→1.5 (更紧贴行情)
|
||||
# ③ 每次tracker运行都重新计算并只升不降 (动态跟随行情逐步抬高)
|
||||
# ④ 跟踪止盈触发时无条件覆盖所有状态 (利润保护优先级最高)
|
||||
# ⑤ TP2已废除(历史0命中),跟踪止盈是唯一动态止盈方式
|
||||
rules = load_rules()
|
||||
trail_cfg = rules.get("tracker", {}).get("trailing_stop", {})
|
||||
trailing_stop_level = entry_plan.get("trailing_stop_level", 0)
|
||||
|
||||
if trail_cfg.get("enabled", True) and atr_1h > 0 and entry_price > 0:
|
||||
activate_pct = trail_cfg.get("activate_pnl_pct", 3)
|
||||
|
||||
# === v1.7.9 三级分级乘数: 按盈利切换 ===
|
||||
tiers = trail_cfg.get("tiers", [])
|
||||
trail_atr_mult = 1.5 # 兜底
|
||||
tier_label = ""
|
||||
if tiers:
|
||||
for t in sorted(tiers, key=lambda x: x.get("min_pnl_pct", 0), reverse=True):
|
||||
if pnl_pct >= t.get("min_pnl_pct", 0):
|
||||
trail_atr_mult = t.get("atr_mult", 1.5)
|
||||
tier_label = t.get("label", "")
|
||||
break
|
||||
|
||||
# === 动态跟随:每次运行都重新计算跟踪止盈位 ===
|
||||
# 算法: trailing_stop = current_price - atr_mult × ATR_1h
|
||||
# 规则: 只升不降 (max(old_level, new_level))
|
||||
# 激活条件: pnl_pct ≥ activate_pct (3%)
|
||||
if pnl_pct >= activate_pct:
|
||||
new_trail = current_price - trail_atr_mult * atr_1h
|
||||
|
||||
if trailing_stop_level > 0:
|
||||
# 已有跟踪位 → 只上移不下移
|
||||
trailing_stop_level = max(trailing_stop_level, new_trail)
|
||||
else:
|
||||
# 首次激活
|
||||
trailing_stop_level = new_trail
|
||||
tier_info = f" [{tier_label}·{trail_atr_mult}×ATR]" if tier_label else ""
|
||||
sell_signals.append(f"🎯 跟踪止盈激活(盈+{pnl_pct:.1f}%≥{activate_pct}%{tier_info}, 回撤{trail_atr_mult}×ATR触发)")
|
||||
|
||||
# === 触发检查:当前价跌破跟踪止盈位 → 止盈 ===
|
||||
# 🔴 v1.7.8: 跟踪止盈触发时无条件覆盖(利润保护优先级最高)
|
||||
if trailing_stop_level > 0 and current_price <= trailing_stop_level:
|
||||
drop_from_trail = trailing_stop_level - current_price
|
||||
sell_signals.append(f"🎯 跟踪止盈触发! 从高位回撤${drop_from_trail:.4f}({drop_from_trail/trailing_stop_level*100:.1f}%)")
|
||||
action_status = "跟踪止盈"
|
||||
|
||||
# 定期报告(即使没触发也显示跟踪位)
|
||||
if trailing_stop_level > 0 and current_price > trailing_stop_level and pnl_pct >= activate_pct * 2:
|
||||
cushion = (current_price - trailing_stop_level) / current_price * 100
|
||||
sell_signals.append(f"📊 跟踪止盈中: 止盈位${trailing_stop_level:.4f}(距现价{cushion:.1f}%)")
|
||||
elif pnl_pct >= 1 and trailing_stop_level > 0 and current_price <= trailing_stop_level:
|
||||
# 利润回落到1%以内但跟踪位已激活且被击穿 → 保本出
|
||||
sell_signals.append(f"🔒 保本止盈: 利润缩至{pnl_pct:.1f}%,跟踪位击穿")
|
||||
action_status = "跟踪止盈"
|
||||
|
||||
# ---- 无TP保护的推荐,涨超15%自动止盈(孤儿推荐保护)----
|
||||
# 加速推荐(粗筛/细筛层)没有 TP/SL,price_tracker 无法判断出场点。
|
||||
# 涨超15%仍无结论 → 认怂落袋,避免永续浮盈不兑现。
|
||||
if tp1 == 0 and pnl_pct >= 15:
|
||||
sell_signals.append(f"✅ 无TP保护自动止盈(涨+{pnl_pct:.1f}%≥15%,落袋为安)")
|
||||
action_status = "止盈1"
|
||||
|
||||
# ---- 止损接近警告 ----
|
||||
if stop_loss > 0:
|
||||
loss_pct = ((current_price / stop_loss) - 1) * 100
|
||||
if loss_pct < 3: # 当前价离止损不到3%
|
||||
sell_signals.append(f"⚠️ 接近止损!当前${current_price:.4f}离止损${stop_loss:.4f}仅{loss_pct:.1f}%")
|
||||
if current_price <= stop_loss:
|
||||
sell_signals.append(f"🔴 已触发止损!${current_price:.4f}≤${stop_loss:.4f}")
|
||||
action_status = "止损"
|
||||
|
||||
# ---- 趋势反转信号(PA行为检测,替代MACD) ----
|
||||
if h1_df is not None and len(h1_df) >= 30 and atr_1h > 0:
|
||||
pa_1h = full_pa_analysis(h1_df, "1h")
|
||||
pa_1h_candles = pa_1h.get("candles_class", [])
|
||||
recent_candles = pa_1h_candles[-6:] if len(pa_1h_candles) >= 6 else pa_1h_candles
|
||||
|
||||
# 1H连续阴动K → 趋势反转
|
||||
dy_bear_count = sum(1 for c in recent_candles if c["type"] == "dynamic" and c["direction"] == -1)
|
||||
if dy_bear_count >= 3:
|
||||
sell_signals.append(f"🔴 1H连续{dy_bear_count}根阴动K(趋势反转)")
|
||||
if action_status == "持有":
|
||||
action_status = "反转"
|
||||
|
||||
# 1H量价背离:放量但阴线(多头出货)
|
||||
avg_vol = float(h1_df["volume"].rolling(20).mean().iloc[-1])
|
||||
recent_3 = h1_df.tail(3)
|
||||
high_vol_bear = 0
|
||||
for _, row in recent_3.iterrows():
|
||||
vol_r = row["volume"] / avg_vol if avg_vol > 0 else 0
|
||||
if vol_r >= 2 and row["close"] < row["open"]:
|
||||
high_vol_bear += 1
|
||||
if high_vol_bear >= 2:
|
||||
sell_signals.append("🔴 1H放量阴线×2(多头出货)")
|
||||
|
||||
# 1H连续K空头加速
|
||||
cont_k = pa_1h.get("continuous_k", [])
|
||||
for ck in cont_k:
|
||||
if ck["type"] == "bearish_continue" and ck["length"] >= 3:
|
||||
sell_signals.append(f"🔴 1H连续{ck['length']}K空头加速")
|
||||
if action_status == "持有":
|
||||
action_status = "反转"
|
||||
|
||||
# === 时间衰减 (v1.6.9) ===
|
||||
# 持仓>24h仍无盈利→降级衰减
|
||||
decay_cfg = rules.get("tracker", {}).get("time_decay", {})
|
||||
if decay_cfg.get("enabled", True):
|
||||
decay_hours = decay_cfg.get("decay_hours", 24)
|
||||
rec_time = rec.get("rec_time", "")
|
||||
if rec_time:
|
||||
try:
|
||||
t_rec = datetime.fromisoformat(rec_time)
|
||||
hours_held = (datetime.now() - t_rec).total_seconds() / 3600
|
||||
if hours_held > decay_hours and pnl_pct <= 0 and action_status == "持有":
|
||||
sell_signals.append(f"⏰ 持仓{hours_held:.0f}h无盈利,降级衰减")
|
||||
action_status = "衰减"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---- 动态买入指引(只允许在未触发任何止盈/止损/退出信号时执行) ----
|
||||
entry_update = {}
|
||||
current_action = entry_plan.get("entry_action", "")
|
||||
|
||||
# 如果推荐状态是"等回踩",检查是否到了回踩价位。
|
||||
# 注意:一旦本轮 action_status 已经是止盈/止损/跟踪止盈/反转/衰减,就绝不能再覆盖成“可即刻买入”。
|
||||
if action_status == "持有" and current_action in ("等回踩", "🟡等回踩") and h4_df is not None and atr_1h > 0:
|
||||
pa_4h = full_pa_analysis(h4_df, "4h")
|
||||
h4_zones = pa_4h.get("zones", [])
|
||||
direction = 1 # 做多方向
|
||||
|
||||
# 重新做15min入场点分析
|
||||
if m15_df is not None and len(m15_df) >= 20:
|
||||
entry_result = analyze_entry_point(
|
||||
h1_df=h1_df, m15_df=m15_df, atr_1h=atr_1h,
|
||||
zones_4h=h4_zones, direction=direction,
|
||||
)
|
||||
new_action = entry_result.get("action", "等回踩")
|
||||
|
||||
if new_action == "即刻买入":
|
||||
buy_signals.append(f"🟢 回踩确认完毕!可即刻入场(15min动K确认)")
|
||||
action_status = "可即刻买入"
|
||||
elif new_action == "等回踩":
|
||||
wait_price = entry_result.get("wait_price", 0)
|
||||
if wait_price > 0:
|
||||
# 检查当前价是否接近回踩目标
|
||||
dist_pct = ((current_price / wait_price) - 1) * 100
|
||||
if abs(dist_pct) < 2:
|
||||
buy_signals.append(f"🟢 当前价接近回踩目标!${current_price:.4f}≈${wait_price:.4f}")
|
||||
action_status = "可即刻买入"
|
||||
|
||||
entry_update = {
|
||||
"new_action": new_action,
|
||||
"reason": entry_result.get("reason", ""),
|
||||
"wait_price": entry_result.get("wait_price", 0),
|
||||
}
|
||||
|
||||
action_status, gated_plan, gate_reasons = apply_entry_quality_gate(
|
||||
action_status=action_status,
|
||||
entry_plan=entry_plan,
|
||||
signals=rec.get("signals"),
|
||||
current_price=current_price,
|
||||
market_context=rec.get("market_context") or {},
|
||||
derivatives_context=rec.get("derivatives_context") or {},
|
||||
sector_context=rec.get("sector_context") or {},
|
||||
)
|
||||
if gate_reasons:
|
||||
buy_signals.append("⚠️ 买点质量闸门: " + ";".join(gate_reasons[:3]))
|
||||
entry_plan.update(gated_plan)
|
||||
|
||||
return {
|
||||
"action_status": action_status,
|
||||
"sell_signals": sell_signals,
|
||||
"buy_signals": buy_signals,
|
||||
"exhaustion": exhaustion,
|
||||
"entry_update": entry_update,
|
||||
"pnl_pct": round(pnl_pct, 2),
|
||||
"trailing_stop_level": trailing_stop_level,
|
||||
}
|
||||
|
||||
|
||||
def track_prices():
|
||||
"""拉取所有active推荐币的实时价格,更新盈亏 + 动态跟踪信号"""
|
||||
recs = get_active_recommendations()
|
||||
if not recs:
|
||||
output = {
|
||||
"status": "no_active",
|
||||
"message": "无active推荐需要跟踪",
|
||||
"stats": get_stats(),
|
||||
"track_time": datetime.now().isoformat(),
|
||||
}
|
||||
print(json.dumps(output, ensure_ascii=False))
|
||||
return output
|
||||
|
||||
results = []
|
||||
failed_symbols = []
|
||||
for rec in recs:
|
||||
symbol = rec["symbol"]
|
||||
try:
|
||||
ticker = exchange.fetch_ticker(symbol)
|
||||
current_price = ticker["last"]
|
||||
|
||||
# 最新价格缓存:看板读取小表 latest_price_cache,不再依赖 price_tracking 高频流水表
|
||||
update_latest_price_cache(symbol, current_price, source="tracker")
|
||||
|
||||
# 基础盈亏跟踪
|
||||
track_result = update_recommendation_tracking(rec["id"], current_price)
|
||||
|
||||
# PA增强:动态跟踪信号分析
|
||||
tracking_signals = analyze_tracking_signals(symbol, rec, current_price)
|
||||
|
||||
# === v1.7.8 跟踪止盈DB回写 ===
|
||||
# 每次tracker运行都写DB,支持动态跟随行情逐步抬高跟踪位
|
||||
trail_level = tracking_signals.get("trailing_stop_level", 0)
|
||||
if trail_level > 0:
|
||||
entry_plan = rec.get("entry_plan") or {}
|
||||
old_trail = entry_plan.get("trailing_stop_level", 0)
|
||||
# v1.7.8: 只要跟踪位有变化就写DB(上移时必变;首次激活也写)
|
||||
if abs(trail_level - old_trail) > 0.000001:
|
||||
entry_plan["trailing_stop_level"] = trail_level
|
||||
import sqlite3 as _sq
|
||||
_c2 = _sq.connect(os.getenv("ALPHAX_DB_PATH", os.path.join(os.path.dirname(__file__), "data", "altcoin_monitor.db")))
|
||||
_c2.execute("UPDATE recommendation SET entry_plan_json=? WHERE id=?",
|
||||
(json.dumps(entry_plan, ensure_ascii=False), rec["id"]))
|
||||
_c2.commit()
|
||||
_c2.close()
|
||||
|
||||
# 主链路状态迁移:tracker 只提交“候选状态 + 当前价”,最终状态由 DB 主链路统一落库。
|
||||
# 飞书推送只能消费主链路返回的最终状态,不能再自行判断。
|
||||
terminal_action = {
|
||||
"hit_tp2": "止盈2",
|
||||
"stopped_out": "止损",
|
||||
}.get(track_result.get("status"))
|
||||
requested_action = terminal_action or tracking_signals["action_status"]
|
||||
state_decision = apply_recommendation_state_transition(
|
||||
rec["id"],
|
||||
requested_action=requested_action,
|
||||
current_price=current_price,
|
||||
event_time=datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
signals=tracking_signals.get("sell_signals", []) + tracking_signals.get("buy_signals", []),
|
||||
)
|
||||
final_action = state_decision.get("action_status", requested_action)
|
||||
|
||||
if state_decision.get("push_required"):
|
||||
if should_push(symbol, "entry", final_action):
|
||||
try:
|
||||
push_altcoin_tp_sl_alert(
|
||||
state_decision["push_symbol"],
|
||||
state_decision["push_current_price"],
|
||||
state_decision["push_entry_price"],
|
||||
state_decision["push_pnl_pct"],
|
||||
final_action,
|
||||
state_decision.get("push_signals", []),
|
||||
state_decision.get("stop_loss", 0),
|
||||
state_decision.get("tp1", 0),
|
||||
state_decision.get("tp2", 0),
|
||||
)
|
||||
log_push(symbol, "entry", final_action, rec_id=rec["id"])
|
||||
except Exception as e:
|
||||
print(f"飞书推送失败({symbol}): {e}")
|
||||
else:
|
||||
print(f"⏭ 跳过推送({symbol}): entry/{final_action} 12h冷却中")
|
||||
|
||||
results.append({
|
||||
"symbol": symbol,
|
||||
"rec_id": rec["id"],
|
||||
"entry_price": rec["entry_price"],
|
||||
"current_price": current_price,
|
||||
"pnl_pct": tracking_signals["pnl_pct"],
|
||||
"status": track_result["status"],
|
||||
"action_status": tracking_signals["action_status"],
|
||||
"sell_signals": tracking_signals["sell_signals"],
|
||||
"buy_signals": tracking_signals["buy_signals"],
|
||||
"exhaustion_severity": tracking_signals.get("exhaustion", {}).get("severity", "low"),
|
||||
})
|
||||
print(f" {symbol}: 入场${rec['entry_price']} → 现在${current_price} "
|
||||
f"盈亏{tracking_signals['pnl_pct']}% 状态={track_result['status']} "
|
||||
f"操作={tracking_signals['action_status']}")
|
||||
|
||||
except Exception as e:
|
||||
failed_symbols.append({"symbol": symbol, "error": str(e)})
|
||||
print(f" {symbol}: 获取价格失败 - {e}")
|
||||
|
||||
# 过期检查
|
||||
expire_old_recommendations()
|
||||
|
||||
output = {
|
||||
"status": "tracked",
|
||||
"tracked_count": len(results),
|
||||
"failed_count": len(failed_symbols),
|
||||
"failed_symbols": failed_symbols,
|
||||
"results": results,
|
||||
"stats": get_stats(),
|
||||
"track_time": datetime.now().isoformat(),
|
||||
}
|
||||
print(json.dumps(output, ensure_ascii=False, indent=2))
|
||||
return output
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
started_at = datetime.now()
|
||||
try:
|
||||
init_db()
|
||||
output = track_prices()
|
||||
except Exception as e:
|
||||
finished_at = datetime.now()
|
||||
log_cron_run(
|
||||
job_name="跟踪",
|
||||
script_name="price_tracker.py",
|
||||
run_status="error",
|
||||
result_status="exception",
|
||||
started_at=started_at.isoformat(),
|
||||
finished_at=finished_at.isoformat(),
|
||||
duration_ms=int((finished_at - started_at).total_seconds() * 1000),
|
||||
summary={},
|
||||
error_message=str(e),
|
||||
)
|
||||
raise
|
||||
else:
|
||||
finished_at = datetime.now()
|
||||
summary = {
|
||||
"tracked_count": output.get("tracked_count", 0),
|
||||
"failed_count": output.get("failed_count", 0),
|
||||
"active_count": output.get("stats", {}).get("active_count", 0),
|
||||
}
|
||||
log_cron_run(
|
||||
job_name="跟踪",
|
||||
script_name="price_tracker.py",
|
||||
run_status="success",
|
||||
result_status=output.get("status", "completed"),
|
||||
started_at=started_at.isoformat(),
|
||||
finished_at=finished_at.isoformat(),
|
||||
duration_ms=int((finished_at - started_at).total_seconds() * 1000),
|
||||
summary=summary,
|
||||
error_message="",
|
||||
)
|
||||
182
price_tracker_ws.py
Normal file
182
price_tracker_ws.py
Normal file
@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
山寨币实时价格监控 — ccxt REST 高频轮询版。
|
||||
|
||||
职责边界:本进程只做实时价格采集和“候选状态”提交;最终状态落库、是否推送、推送价格口径
|
||||
全部由 altcoin_db.apply_recommendation_state_transition 主链路决定。
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import ccxt
|
||||
from datetime import datetime
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from altcoin_db import (
|
||||
init_db,
|
||||
get_active_recommendations_deduped,
|
||||
update_recommendation_tracking,
|
||||
apply_recommendation_state_transition,
|
||||
should_push,
|
||||
log_push,
|
||||
)
|
||||
from feishu_push import push_altcoin_tp_sl_alert
|
||||
|
||||
POLL_INTERVAL = 5
|
||||
REFRESH_INTERVAL = 60
|
||||
BATCH_SIZE = 50
|
||||
|
||||
exchange = ccxt.binance({"enableRateLimit": True})
|
||||
last_refresh = 0
|
||||
active_map = {}
|
||||
|
||||
|
||||
def load_active():
|
||||
try:
|
||||
recs = get_active_recommendations_deduped(actionable_only=False)
|
||||
# 只监控 active 最新去重记录;结案记录由主链路/网站展示,不在实时入口反复触发。
|
||||
return {r["symbol"]: r for r in recs if r.get("symbol") and r.get("status") == "active"}
|
||||
except Exception as e:
|
||||
print(f"[{datetime.now():%H:%M:%S}] 加载推荐失败: {e}", flush=True)
|
||||
return {}
|
||||
|
||||
|
||||
def check_triggers(symbol, rec, current_price):
|
||||
"""向后兼容旧测试:结案记录不得再产生入场触发。"""
|
||||
if rec.get("status") != "active":
|
||||
return None
|
||||
action, signals = detect_candidate_action(rec, current_price, {})
|
||||
if not action:
|
||||
return None
|
||||
return {"action_status": action, "signals": signals}
|
||||
|
||||
|
||||
def detect_candidate_action(rec, current_price, track_result):
|
||||
"""仅产生候选状态;不得在这里推送或落最终状态。"""
|
||||
terminal_action = {
|
||||
"hit_tp1": "止盈1",
|
||||
"hit_tp2": "止盈2",
|
||||
"stopped_out": "止损",
|
||||
}.get((track_result or {}).get("status"))
|
||||
if terminal_action:
|
||||
return terminal_action, [f"状态机检测到{terminal_action}"]
|
||||
|
||||
ep = rec.get("entry_plan") or {}
|
||||
entry_action = ep.get("entry_action", "") or rec.get("initial_action", "")
|
||||
plan_entry_price = ep.get("entry_price", 0) or 0
|
||||
entry_price = rec.get("entry_price", 0) or 0
|
||||
cur_action = rec.get("action_status", "持有")
|
||||
|
||||
if entry_action == "等回踩" and plan_entry_price > 0 and current_price <= plan_entry_price:
|
||||
ep["entry_trigger_confirmed"] = True
|
||||
return "可即刻买入", [f"回踩到位 ${plan_entry_price:.6f}"]
|
||||
if entry_action in ("即刻买入", "可即刻买入") and current_price <= (plan_entry_price or entry_price):
|
||||
ep["entry_trigger_confirmed"] = True
|
||||
return "可即刻买入", ["入场条件仍满足"]
|
||||
if cur_action == "可即刻买入":
|
||||
return "可即刻买入", ["入场窗口延续"]
|
||||
return None, []
|
||||
|
||||
|
||||
def fetch_prices(symbols):
|
||||
all_tickers = {}
|
||||
for i in range(0, len(symbols), BATCH_SIZE):
|
||||
batch = symbols[i:i + BATCH_SIZE]
|
||||
try:
|
||||
tickers = exchange.fetch_tickers(batch)
|
||||
for s in batch:
|
||||
if s in tickers:
|
||||
all_tickers[s] = tickers[s]
|
||||
except Exception as e:
|
||||
print(f"[{datetime.now():%H:%M:%S}] 拉取价格失败(批次{i//BATCH_SIZE}): {e}", flush=True)
|
||||
time.sleep(1)
|
||||
return all_tickers
|
||||
|
||||
|
||||
def maybe_push_from_decision(symbol, decision):
|
||||
"""推送层只消费主链路 decision,不自行判断状态。"""
|
||||
action = decision.get("action_status")
|
||||
if not decision.get("push_required"):
|
||||
return
|
||||
if not should_push(symbol, "entry", action):
|
||||
print(f"[{datetime.now():%H:%M:%S}] ⏭ 跳过推送 {symbol}: entry/{action} 冷却中", flush=True)
|
||||
return
|
||||
push_altcoin_tp_sl_alert(
|
||||
decision["push_symbol"],
|
||||
decision["push_current_price"],
|
||||
decision["push_entry_price"],
|
||||
decision["push_pnl_pct"],
|
||||
action,
|
||||
decision.get("push_signals", []),
|
||||
decision.get("stop_loss", 0),
|
||||
decision.get("tp1", 0),
|
||||
decision.get("tp2", 0),
|
||||
)
|
||||
log_push(symbol, "entry", action, rec_id=decision.get("id", 0))
|
||||
print(f"[{datetime.now():%H:%M:%S}] 📲 {symbol} → {action} (盈亏{decision['push_pnl_pct']}%)", flush=True)
|
||||
|
||||
|
||||
def main_loop():
|
||||
global active_map, last_refresh
|
||||
init_db()
|
||||
active_map = load_active()
|
||||
last_refresh = time.time()
|
||||
print(f"[{datetime.now():%H:%M:%S}] 🚀 实时价格监控启动,活跃: {len(active_map)}只,轮询间隔{POLL_INTERVAL}s", flush=True)
|
||||
|
||||
while True:
|
||||
loop_start = time.time()
|
||||
if loop_start - last_refresh >= REFRESH_INTERVAL:
|
||||
active_map = load_active()
|
||||
last_refresh = loop_start
|
||||
|
||||
if not active_map:
|
||||
time.sleep(POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
tickers = fetch_prices(list(active_map.keys()))
|
||||
for symbol, rec in list(active_map.items()):
|
||||
ticker = tickers.get(symbol)
|
||||
if not ticker:
|
||||
continue
|
||||
current_price = ticker.get("last", 0)
|
||||
if current_price <= 0:
|
||||
continue
|
||||
|
||||
try:
|
||||
track_result = update_recommendation_tracking(rec["id"], current_price) or {}
|
||||
except Exception as e:
|
||||
print(f"[{datetime.now():%H:%M:%S}] 跟踪价格失败 {symbol}: {e}", flush=True)
|
||||
continue
|
||||
|
||||
action, signals = detect_candidate_action(rec, current_price, track_result)
|
||||
if not action:
|
||||
continue
|
||||
|
||||
decision = apply_recommendation_state_transition(
|
||||
rec["id"],
|
||||
requested_action=action,
|
||||
current_price=current_price,
|
||||
event_time=datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
signals=signals,
|
||||
)
|
||||
if decision.get("action_status") in ("止盈1", "止盈2", "止损"):
|
||||
active_map.pop(symbol, None)
|
||||
else:
|
||||
rec["action_status"] = decision.get("action_status", rec.get("action_status"))
|
||||
rec["entry_price"] = decision.get("entry_price", rec.get("entry_price"))
|
||||
rec["current_price"] = decision.get("current_price", current_price)
|
||||
rec["pnl_pct"] = decision.get("pnl_pct", rec.get("pnl_pct"))
|
||||
active_map[symbol] = rec
|
||||
|
||||
try:
|
||||
maybe_push_from_decision(symbol, decision)
|
||||
except Exception as e:
|
||||
print(f"[{datetime.now():%H:%M:%S}] 推送失败 {symbol}: {e}", flush=True)
|
||||
|
||||
elapsed = time.time() - loop_start
|
||||
time.sleep(max(0.5, POLL_INTERVAL - elapsed))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main_loop()
|
||||
10
requirements.txt
Normal file
10
requirements.txt
Normal file
@ -0,0 +1,10 @@
|
||||
ccxt==4.5.11
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
pandas==2.2.3
|
||||
numpy==2.1.3
|
||||
requests==2.32.3
|
||||
PyYAML==6.0.2
|
||||
pydantic==2.10.4
|
||||
python-multipart==0.0.20
|
||||
pytest==8.3.4
|
||||
579
reverse_analysis.py
Normal file
579
reverse_analysis.py
Normal file
@ -0,0 +1,579 @@
|
||||
"""
|
||||
逆向分析模块 — 从涨幅榜复盘,提取起爆前共性特征,发现新规律
|
||||
|
||||
核心逻辑:
|
||||
1. 拉Binance 24h涨幅榜Top N(configurable)
|
||||
2. 对未被推荐的暴涨币,回溯其起爆前K线
|
||||
3. 用full_pa_analysis()提取特征:连续K、起爆点、供需区(Q≥7)、静K蓄力、量价模式
|
||||
4. 检查板块联动(同板块是否有其他币也暴涨)
|
||||
5. 统计共性特征占比,达到显著性阈值则add_learned_rule()
|
||||
6. 返回结构化结果供feishu推送
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict, Counter
|
||||
|
||||
import requests
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from altcoin_db import get_conn, record_missed_explosion, upsert_strategy_rule_candidate
|
||||
from pa_engine import full_pa_analysis, classify_candles, calc_atr
|
||||
from sector_map import get_sector_for_coin, COIN_TO_SECTORS, SECTOR_MEMBERS
|
||||
import config_loader
|
||||
|
||||
BINANCE_API = "https://api.binance.com/api/v3"
|
||||
|
||||
|
||||
# ==================== 数据获取 ====================
|
||||
|
||||
def fetch_24h_tickers():
|
||||
"""获取Binance所有USDT交易对的24h行情"""
|
||||
try:
|
||||
resp = requests.get(f"{BINANCE_API}/ticker/24hr", timeout=15)
|
||||
if resp.status_code != 200:
|
||||
return []
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
print(f"[reverse_analysis] fetch_24h_tickers failed: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def fetch_klines_before(symbol, lookback_hours=72, interval="1h"):
|
||||
"""
|
||||
获取起爆前的K线数据
|
||||
取最近(lookback_hours+24)根1H K线,截掉最后24h(起爆段),只看起爆前的蓄力期
|
||||
"""
|
||||
total_limit = lookback_hours + 24 # 起爆前 + 起爆段
|
||||
try:
|
||||
resp = requests.get(f"{BINANCE_API}/klines", params={
|
||||
"symbol": symbol.replace("/", ""),
|
||||
"interval": interval,
|
||||
"limit": total_limit,
|
||||
}, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
return [], []
|
||||
raw = resp.json()
|
||||
klines = [{"time": k[0], "open": float(k[1]), "high": float(k[2]),
|
||||
"low": float(k[3]), "close": float(k[4]), "volume": float(k[5])}
|
||||
for k in raw]
|
||||
|
||||
# 分割:前lookback_hours是起爆前,后24h是起爆段
|
||||
explosion_start = max(0, len(klines) - 24)
|
||||
pre_explosion = klines[:explosion_start]
|
||||
explosion_segment = klines[explosion_start:]
|
||||
|
||||
return pre_explosion, explosion_segment
|
||||
except Exception as e:
|
||||
print(f"[reverse_analysis] fetch_klines_before({symbol}) failed: {e}")
|
||||
return [], []
|
||||
|
||||
|
||||
def get_recommended_symbols(hours=72):
|
||||
"""获取过去N小时内被推荐过的币种列表"""
|
||||
conn = get_conn()
|
||||
rows = conn.execute("""
|
||||
SELECT symbol FROM recommendation
|
||||
WHERE julianday(?) - julianday(rec_time) < ?
|
||||
""", (datetime.now().isoformat(), hours / 24.0)).fetchall()
|
||||
conn.close()
|
||||
return set(r["symbol"] for r in rows)
|
||||
|
||||
|
||||
# ==================== 特征提取 ====================
|
||||
|
||||
def extract_pre_explosion_features(symbol, pre_klines, explosion_klines, config):
|
||||
"""
|
||||
对起爆前K线做full_pa_analysis,提取特征字典
|
||||
"""
|
||||
features = {
|
||||
"has_ignition_point": False,
|
||||
"ignition_count": 0,
|
||||
"ignition_details": [],
|
||||
"has_q7_zone": False,
|
||||
"q7_zones": [],
|
||||
"q_max": 0,
|
||||
"has_continuous_k": False,
|
||||
"continuous_k_count": 0,
|
||||
"continuous_k_details": [],
|
||||
"has_static_accumulation": False,
|
||||
"static_k_count": 0,
|
||||
"static_ratio": 0.0,
|
||||
"has_volume_surge_before": False,
|
||||
"vol_surge_ratio": 0.0,
|
||||
"has_bullish_breakout_pattern": False,
|
||||
}
|
||||
|
||||
if not pre_klines or len(pre_klines) < 30:
|
||||
return features
|
||||
|
||||
# 转DataFrame做PA分析
|
||||
df = pd.DataFrame(pre_klines)
|
||||
df["time"] = pd.to_datetime(df["time"], unit="ms")
|
||||
pa_result = full_pa_analysis(df, timeframe="1h")
|
||||
|
||||
feature_config = config.get("feature_extraction", {})
|
||||
|
||||
# 1. 起爆点特征
|
||||
if feature_config.get("check_ignition", True):
|
||||
ignition_points = pa_result.get("ignition_points", [])
|
||||
bullish_ignitions = [ip for ip in ignition_points if ip["direction"] == 1]
|
||||
features["has_ignition_point"] = len(bullish_ignitions) > 0
|
||||
features["ignition_count"] = len(bullish_ignitions)
|
||||
features["ignition_details"] = bullish_ignitions
|
||||
|
||||
# 2. 供需区特征(特别是Q≥7)
|
||||
if feature_config.get("check_supply_demand", True):
|
||||
zones = pa_result.get("zones", [])
|
||||
q7_zones = [z for z in zones if z["q_score"] >= 7]
|
||||
demand_q7 = [z for z in q7_zones if z["type"] == "demand"]
|
||||
features["has_q7_zone"] = len(q7_zones) > 0
|
||||
features["q7_zones"] = q7_zones
|
||||
features["q_max"] = max((z["q_score"] for z in zones), default=0)
|
||||
# 特别标注:是否有Q≥7需求区在起爆前价格附近
|
||||
if demand_q7 and len(pre_klines) > 0:
|
||||
last_price = pre_klines[-1]["close"]
|
||||
nearby_demand = [z for z in demand_q7 if abs(z["top"] - last_price) / last_price < 0.03]
|
||||
features["has_q7_demand_nearby"] = len(nearby_demand) > 0
|
||||
|
||||
# 3. 连续K加速特征
|
||||
if feature_config.get("check_continuous_k", True):
|
||||
continuous_k = pa_result.get("continuous_k", [])
|
||||
bullish_cont = [ck for ck in continuous_k if ck["type"] == "bullish_continue"]
|
||||
features["has_continuous_k"] = len(bullish_cont) > 0
|
||||
features["continuous_k_count"] = len(bullish_cont)
|
||||
features["continuous_k_details"] = bullish_cont
|
||||
|
||||
# 4. 静K蓄力特征
|
||||
candles_class = pa_result.get("candles_class", [])
|
||||
static_ks = [c for c in candles_class if c["type"] == "static"]
|
||||
features["static_k_count"] = len(static_ks)
|
||||
features["static_ratio"] = len(static_ks) / len(candles_class) if candles_class else 0
|
||||
features["has_static_accumulation"] = features["static_ratio"] >= 0.15 # 静K占比≥15%
|
||||
|
||||
# 5. 起爆前放量特征
|
||||
if feature_config.get("check_volume_pattern", True) and len(pre_klines) >= 20:
|
||||
recent_10 = pre_klines[-10:]
|
||||
older_10 = pre_klines[-20:-10]
|
||||
avg_recent_vol = sum(k["volume"] for k in recent_10) / len(recent_10)
|
||||
avg_older_vol = sum(k["volume"] for k in older_10) / len(older_10) if older_10 else 1
|
||||
vol_surge_ratio = avg_recent_vol / avg_older_vol if avg_older_vol > 0 else 0
|
||||
features["has_volume_surge_before"] = vol_surge_ratio >= 2.0
|
||||
features["vol_surge_ratio"] = round(vol_surge_ratio, 2)
|
||||
|
||||
# 6. 多头突破模式(最后几根K线close连续抬高)
|
||||
if len(pre_klines) >= 5:
|
||||
last_5_closes = [k["close"] for k in pre_klines[-5:]]
|
||||
bullish_breakout = all(last_5_closes[i] > last_5_closes[i - 1] for i in range(1, len(last_5_closes)))
|
||||
features["has_bullish_breakout_pattern"] = bullish_breakout
|
||||
|
||||
# 7. 4H 和 1D 多周期分析(从config读取lookback_hours,默认168/720)
|
||||
lookback_4h = config.get("lookback_hours_4h", 168)
|
||||
lookback_1d = config.get("lookback_hours_1d", 720)
|
||||
features["has_4h_ignition"] = False
|
||||
features["has_daily_demand_zone"] = False
|
||||
features["daily_trend_up"] = False
|
||||
|
||||
try:
|
||||
# 4H K线分析
|
||||
pre_4h, _ = fetch_klines_before(symbol, lookback_4h, interval="4h")
|
||||
if pre_4h and len(pre_4h) >= 8:
|
||||
df_4h = pd.DataFrame(pre_4h)
|
||||
df_4h["time"] = pd.to_datetime(df_4h["time"], unit="ms")
|
||||
pa_4h = full_pa_analysis(df_4h, timeframe="4h")
|
||||
ignition_4h = pa_4h.get("ignition_points", [])
|
||||
features["has_4h_ignition"] = any(ip["direction"] == 1 for ip in ignition_4h)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# 1D K线分析
|
||||
pre_1d, _ = fetch_klines_before(symbol, lookback_1d, interval="1d")
|
||||
if pre_1d and len(pre_1d) >= 5:
|
||||
df_1d = pd.DataFrame(pre_1d)
|
||||
df_1d["time"] = pd.to_datetime(df_1d["time"], unit="ms")
|
||||
pa_1d = full_pa_analysis(df_1d, timeframe="1d")
|
||||
zones_1d = pa_1d.get("zones", [])
|
||||
demand_zones_1d = [z for z in zones_1d if z["type"] == "demand" and z["q_score"] >= 5]
|
||||
features["has_daily_demand_zone"] = len(demand_zones_1d) > 0
|
||||
|
||||
# 日线趋势:最后5根日线close是否整体向上
|
||||
closes_1d = [k["close"] for k in pre_1d[-5:]]
|
||||
features["daily_trend_up"] = closes_1d[-1] > closes_1d[0] if len(closes_1d) >= 3 else False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return features
|
||||
|
||||
|
||||
def check_sector_alignment(symbol, top_gainers, config):
|
||||
"""
|
||||
检查板块联动:同板块其他币是否也在涨幅榜
|
||||
返回: {sector_name, sector_coin_count, sector_top_gainer_count, is_sector_hot}
|
||||
"""
|
||||
if not config.get("feature_extraction", {}).get("check_sector_alignment", True):
|
||||
return {"sector": "", "sector_coin_count": 0, "sector_top_gainer_count": 0, "is_sector_hot": False}
|
||||
|
||||
sectors = get_sector_for_coin(symbol)
|
||||
if not sectors:
|
||||
return {"sector": "未知", "sector_coin_count": 0, "sector_top_gainer_count": 0, "is_sector_hot": False}
|
||||
|
||||
# 主板块取第一个
|
||||
primary_sector = sectors[0]
|
||||
sector_coins = SECTOR_MEMBERS.get(primary_sector, [])
|
||||
|
||||
# 统计同板块有多少币在涨幅榜
|
||||
sector_in_gainers = []
|
||||
for g in top_gainers:
|
||||
if g["symbol"] in sector_coins:
|
||||
sector_in_gainers.append(g)
|
||||
|
||||
# 板块联动阈值:≥3只同板块币涨幅榜 → 板块热
|
||||
is_hot = len(sector_in_gainers) >= 3
|
||||
|
||||
return {
|
||||
"sector": primary_sector,
|
||||
"sector_coin_count": len(sector_coins),
|
||||
"sector_top_gainer_count": len(sector_in_gainers),
|
||||
"sector_gainer_symbols": [g["symbol"] for g in sector_in_gainers],
|
||||
"is_sector_hot": is_hot,
|
||||
}
|
||||
|
||||
|
||||
# ==================== 共性特征统计与规律发现 ====================
|
||||
|
||||
def compute_pattern_summary(all_features, total_count):
|
||||
"""
|
||||
统计所有top gainer的共性特征占比
|
||||
返回: [{feature_name, count, percentage, description}]
|
||||
"""
|
||||
if total_count == 0:
|
||||
return []
|
||||
|
||||
feature_counts = Counter()
|
||||
feature_details = defaultdict(list)
|
||||
|
||||
for feat in all_features:
|
||||
for key, value in feat.items():
|
||||
if key.startswith("has_") and value:
|
||||
feature_counts[key] += 1
|
||||
# 收集具体描述
|
||||
detail_key = key.replace("has_", "") + "_details"
|
||||
if detail_key in feat and feat[detail_key]:
|
||||
feature_details[key].extend(feat[detail_key])
|
||||
|
||||
# 特征名 → 中文描述映射
|
||||
FEATURE_LABELS = {
|
||||
"has_ignition_point": "起爆点(静K→动K转折)",
|
||||
"has_q7_zone": "Q≥7高质量供需区",
|
||||
"has_q7_demand_nearby": "Q≥7需求区在起爆价附近(<3%)",
|
||||
"has_continuous_k": "连续K多头加速",
|
||||
"has_static_accumulation": "静K蓄力(占比≥15%)",
|
||||
"has_volume_surge_before": "起爆前2倍+放量",
|
||||
"has_bullish_breakout_pattern": "连续5K收盘价抬高",
|
||||
}
|
||||
|
||||
summary = []
|
||||
for feat_key, count in feature_counts.most_common():
|
||||
pct = round(count / total_count * 100, 1)
|
||||
label = FEATURE_LABELS.get(feat_key, feat_key)
|
||||
summary.append({
|
||||
"feature": feat_key,
|
||||
"label": label,
|
||||
"count": count,
|
||||
"percentage": pct,
|
||||
"total": total_count,
|
||||
})
|
||||
|
||||
# 补充:即使未达到阈值的特征也记录(只是不触发learned_rule)
|
||||
for feat_key, label in FEATURE_LABELS.items():
|
||||
if feat_key not in feature_counts:
|
||||
summary.append({
|
||||
"feature": feat_key,
|
||||
"label": label,
|
||||
"count": 0,
|
||||
"percentage": 0.0,
|
||||
"total": total_count,
|
||||
})
|
||||
|
||||
# 排序:percentage降序
|
||||
summary.sort(key=lambda x: x["percentage"], reverse=True)
|
||||
return summary
|
||||
|
||||
|
||||
def discover_new_rules(pattern_summary, all_features, sector_alignments, significance_pct=60.0):
|
||||
"""
|
||||
当共性特征占比≥significance_pct时,自动生成learned_rule
|
||||
返回: [{rule_id, description, conditions, score_adjust}]
|
||||
"""
|
||||
new_rules = []
|
||||
significant_features = [p for p in pattern_summary if p["percentage"] >= significance_pct]
|
||||
|
||||
for pattern in significant_features:
|
||||
feat_key = pattern["feature"]
|
||||
pct = pattern["percentage"]
|
||||
|
||||
# 根据特征类型生成不同规则
|
||||
if feat_key == "has_ignition_point":
|
||||
rule = {
|
||||
"type": "bonus",
|
||||
"description": f"涨幅榜{pct}%有起爆点(静K→动K) → 起爆点是爆发前必现信号",
|
||||
"conditions": {"has_ignition_point": True},
|
||||
"score_adjust": 2,
|
||||
"source": "reverse_analysis",
|
||||
}
|
||||
elif feat_key == "has_q7_zone":
|
||||
rule = {
|
||||
"type": "bonus",
|
||||
"description": f"涨幅榜{pct}%有Q≥7供需区 → 高质量供需区是爆发支撑",
|
||||
"conditions": {"has_q7_zone": True},
|
||||
"score_adjust": 2,
|
||||
"source": "reverse_analysis",
|
||||
}
|
||||
elif feat_key == "has_q7_demand_nearby":
|
||||
rule = {
|
||||
"type": "bonus",
|
||||
"description": f"涨幅榜{pct}%有Q≥7需求区在起爆价附近 → 需求区支撑是爆发关键",
|
||||
"conditions": {"has_q7_demand_nearby": True},
|
||||
"score_adjust": 3,
|
||||
"source": "reverse_analysis",
|
||||
}
|
||||
elif feat_key == "has_continuous_k":
|
||||
rule = {
|
||||
"type": "bonus",
|
||||
"description": f"涨幅榜{pct}%有连续K多头加速 → 趋势加速是爆发前兆",
|
||||
"conditions": {"has_continuous_k_bullish": True},
|
||||
"score_adjust": 2,
|
||||
"source": "reverse_analysis",
|
||||
}
|
||||
elif feat_key == "has_static_accumulation":
|
||||
rule = {
|
||||
"type": "bonus",
|
||||
"description": f"涨幅榜{pct}%有静K蓄力(占比≥15%) → 蓄力是爆发必要条件",
|
||||
"conditions": {"static_ratio_min": 0.15},
|
||||
"score_adjust": 1,
|
||||
"source": "reverse_analysis",
|
||||
}
|
||||
elif feat_key == "has_volume_surge_before":
|
||||
rule = {
|
||||
"type": "bonus",
|
||||
"description": f"涨幅榜{pct}%起爆前2倍+放量 → 量能先行是爆发预警",
|
||||
"conditions": {"vol_surge_ratio_min": 2.0},
|
||||
"score_adjust": 2,
|
||||
"source": "reverse_analysis",
|
||||
}
|
||||
elif feat_key == "has_bullish_breakout_pattern":
|
||||
rule = {
|
||||
"type": "bonus",
|
||||
"description": f"涨幅榜{pct}%连续5K收盘价抬高 → 价格加速是爆发前形态",
|
||||
"conditions": {"has_bullish_breakout_pattern": True},
|
||||
"score_adjust": 1,
|
||||
"source": "reverse_analysis",
|
||||
}
|
||||
else:
|
||||
continue
|
||||
|
||||
# 新体系:逆向分析只生成候选规则,不直接写 learned_rules,避免涨幅榜小样本污染主策略。
|
||||
rule["candidate_id"] = upsert_strategy_rule_candidate(
|
||||
source="reverse_analysis",
|
||||
rule_type=rule.get("type", "bonus"),
|
||||
signal_name=feat_key,
|
||||
rule_description=rule.get("description", ""),
|
||||
support_count=int(count),
|
||||
success_count=int(count),
|
||||
fail_count=0,
|
||||
confidence_score=round(min(95, pct), 1),
|
||||
sample_size=int(total_analyzed),
|
||||
status="candidate",
|
||||
notes="逆向涨幅榜规律,需等待推荐样本验证后再发布",
|
||||
source_ref=f"reverse:{feat_key}",
|
||||
)
|
||||
new_rules.append(rule)
|
||||
|
||||
# 检查板块联动规律
|
||||
hot_sectors = [sa for sa in sector_alignments if sa.get("is_sector_hot")]
|
||||
if len(hot_sectors) >= 2:
|
||||
# 多板块联动规律
|
||||
sector_names = [sa["sector"] for sa in hot_sectors]
|
||||
rule = {
|
||||
"type": "bonus",
|
||||
"description": f"多板块联动({', '.join(sector_names)}) → 市场整体情绪升温,蓄力币起爆概率高",
|
||||
"conditions": {"multi_sector_hot": True, "sectors": sector_names},
|
||||
"score_adjust": 2,
|
||||
"source": "reverse_analysis",
|
||||
}
|
||||
rule["candidate_id"] = upsert_strategy_rule_candidate(
|
||||
source="reverse_analysis",
|
||||
rule_type=rule.get("type", "bonus"),
|
||||
signal_name="multi_sector_hot",
|
||||
rule_description=rule.get("description", ""),
|
||||
support_count=len(hot_sectors),
|
||||
success_count=len(hot_sectors),
|
||||
fail_count=0,
|
||||
confidence_score=60,
|
||||
sample_size=len(sector_alignments),
|
||||
status="candidate",
|
||||
notes="板块联动候选规律,需等待推荐样本验证后再发布",
|
||||
source_ref="reverse:multi_sector_hot",
|
||||
)
|
||||
new_rules.append(rule)
|
||||
|
||||
return new_rules
|
||||
|
||||
|
||||
# ==================== 主流程 ====================
|
||||
|
||||
def run_reverse_analysis():
|
||||
"""
|
||||
执行完整逆向分析流程:
|
||||
1. 拉涨幅榜Top N
|
||||
2. 过滤掉已推荐的币
|
||||
3. 对每个暴涨币回溯起爆前K线,做PA分析
|
||||
4. 统计共性特征,发现规律
|
||||
5. 写入DB,返回结构化结果
|
||||
"""
|
||||
config = config_loader.get_reverse_params()
|
||||
top_n = config.get("top_n_gainers", 30)
|
||||
lookback_hours = config.get("lookback_hours", 72)
|
||||
min_gain_pct = config.get("min_gain_pct", 10.0)
|
||||
significance_pct = config.get("significance_threshold_pct", 60.0)
|
||||
|
||||
# 1. 拉涨幅榜
|
||||
tickers = fetch_24h_tickers()
|
||||
if not tickers:
|
||||
print("[reverse_analysis] 无法获取24h行情数据")
|
||||
return {"error": "无法获取24h行情数据", "top_gainers": [], "pattern_summary": [], "new_rules": []}
|
||||
|
||||
# 排序涨幅榜
|
||||
gainers = []
|
||||
for t in tickers:
|
||||
symbol_str = t["symbol"]
|
||||
if not symbol_str.endswith("USDT"):
|
||||
continue
|
||||
base = symbol_str.replace("USDT", "")
|
||||
if base in ("BTC", "ETH", "BNB", "USDT"):
|
||||
continue
|
||||
formatted = f"{base}/USDT"
|
||||
change_pct = float(t["priceChangePercent"])
|
||||
volume_24h = float(t["quoteVolume"])
|
||||
last_price = float(t["lastPrice"])
|
||||
|
||||
if change_pct >= min_gain_pct:
|
||||
gainers.append({
|
||||
"symbol": formatted,
|
||||
"gain_pct": round(change_pct, 2),
|
||||
"price": last_price,
|
||||
"volume_24h": volume_24h,
|
||||
"sector": get_sector_for_coin(formatted),
|
||||
})
|
||||
|
||||
# 按涨幅排序,取Top N
|
||||
gainers.sort(key=lambda x: x["gain_pct"], reverse=True)
|
||||
gainers = gainers[:top_n]
|
||||
|
||||
# 2. 过滤掉已推荐的币
|
||||
recommended = get_recommended_symbols(hours=lookback_hours)
|
||||
unrecommended_gainers = [g for g in gainers if g["symbol"] not in recommended]
|
||||
|
||||
print(f"[reverse_analysis] 涨幅榜共{len(gainers)}只>{min_gain_pct}%, 其中{len(unrecommended_gainers)}只未被推荐")
|
||||
|
||||
# 3. 对每个未被推荐的暴涨币做PA分析
|
||||
all_features = []
|
||||
sector_alignments = []
|
||||
missed_details = []
|
||||
|
||||
for gainer in unrecommended_gainers:
|
||||
symbol = gainer["symbol"]
|
||||
pre_klines, explosion_klines = fetch_klines_before(symbol, lookback_hours, "1h")
|
||||
|
||||
features = extract_pre_explosion_features(symbol, pre_klines, explosion_klines, config)
|
||||
all_features.append(features)
|
||||
|
||||
# 板块联动检测
|
||||
sector_alignment = check_sector_alignment(symbol, gainers, config)
|
||||
sector_alignments.append(sector_alignment)
|
||||
gainer["sector_info"] = sector_alignment
|
||||
|
||||
# 构建DB记录的特征列表(用于features_detected字段)
|
||||
detected_features = []
|
||||
for key, value in features.items():
|
||||
if key.startswith("has_") and value:
|
||||
detected_features.append(key.replace("has_", ""))
|
||||
elif key in ("ignition_count", "q_max", "static_ratio", "vol_surge_ratio", "static_k_count", "continuous_k_count"):
|
||||
detected_features.append(f"{key}={value}")
|
||||
|
||||
# 计算起爆前价格(涨幅反推)
|
||||
price_before = gainer["price"] / (1 + gainer["gain_pct"] / 100)
|
||||
|
||||
# 判断为什么没推荐(简化版)
|
||||
conn = get_conn()
|
||||
screened = conn.execute("""
|
||||
SELECT symbol, state, score, signals FROM screening_log
|
||||
WHERE symbol=? AND layer='细筛'
|
||||
ORDER BY scan_time DESC LIMIT 1
|
||||
""", (symbol,)).fetchone()
|
||||
conn.close()
|
||||
|
||||
if screened:
|
||||
reason = f"细筛淘汰(state={screened['state']}, score={screened['score']})"
|
||||
else:
|
||||
reason = "粗筛未通过(涨幅或量价不达标)"
|
||||
|
||||
# 写入DB
|
||||
record_missed_explosion(
|
||||
symbol=symbol,
|
||||
price_at_detect=gainer["price"],
|
||||
price_before=price_before,
|
||||
gain_pct=gainer["gain_pct"],
|
||||
reason_missed=reason,
|
||||
features_detected=detected_features,
|
||||
lesson=f"起爆前PA特征: {', '.join(detected_features[:5])}; 板块: {sector_alignment.get('sector', '未知')}",
|
||||
)
|
||||
|
||||
missed_details.append({
|
||||
"symbol": symbol,
|
||||
"gain_pct": gainer["gain_pct"],
|
||||
"sector": sector_alignment.get("sector", "未知"),
|
||||
"is_sector_hot": sector_alignment.get("is_sector_hot", False),
|
||||
"reason_missed": reason,
|
||||
"features_detected": detected_features,
|
||||
"q_max": features.get("q_max", 0),
|
||||
"ignition_count": features.get("ignition_count", 0),
|
||||
"static_ratio": features.get("static_ratio", 0),
|
||||
"vol_surge_ratio": features.get("vol_surge_ratio", 0),
|
||||
})
|
||||
|
||||
# 4. 统计共性特征
|
||||
total_analyzed = len(all_features)
|
||||
pattern_summary = compute_pattern_summary(all_features, total_analyzed)
|
||||
|
||||
# 5. 发现新规律
|
||||
new_rules = discover_new_rules(pattern_summary, all_features, sector_alignments, significance_pct)
|
||||
|
||||
# 更新meta
|
||||
config_loader.update_meta("last_reverse_analysis", datetime.now().isoformat())
|
||||
|
||||
# 6. 返回结构化结果
|
||||
results = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"total_gainers": len(gainers),
|
||||
"total_unrecommended": len(unrecommended_gainers),
|
||||
"total_analyzed": total_analyzed,
|
||||
"top_gainers": gainers[:10], # feishu推送只取TOP10
|
||||
"missed_details": missed_details,
|
||||
"pattern_summary": pattern_summary,
|
||||
"new_rules": new_rules,
|
||||
"sector_alignments": sector_alignments,
|
||||
}
|
||||
|
||||
print(f"[reverse_analysis] 完成: 分析{total_analyzed}只, 发现{len(new_rules)}条新规律")
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
results = run_reverse_analysis()
|
||||
print(json.dumps(results, ensure_ascii=False, indent=2))
|
||||
1390
review_engine.py
Normal file
1390
review_engine.py
Normal file
File diff suppressed because it is too large
Load Diff
1132
rules.yaml
Normal file
1132
rules.yaml
Normal file
File diff suppressed because it is too large
Load Diff
116
schema.py
Normal file
116
schema.py
Normal file
@ -0,0 +1,116 @@
|
||||
"""
|
||||
山寨币监控数据库Schema
|
||||
|
||||
与 altcoin_db.py 保持完全一致,4张表:
|
||||
1. coin_state — 状态去重管理
|
||||
2. screening_log — 每次筛选的历史记录
|
||||
3. recommendation — 推荐追踪(入场→止损→止盈→最终结果)
|
||||
4. price_tracking — 推荐后的价格跟踪快照
|
||||
|
||||
注意:此文件仅作为schema参考文档,实际初始化由 altcoin_db.init_db() 执行。
|
||||
此文件不应被 screener/confirm 等模块直接导入调用。
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
|
||||
DB_PATH = "/home/ubuntu/quant_monitor/altcoin/altcoin_monitor.db"
|
||||
|
||||
|
||||
def init_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
|
||||
# 1. 状态去重表
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS coin_state (
|
||||
symbol TEXT PRIMARY KEY,
|
||||
state TEXT NOT NULL DEFAULT '蓄力',
|
||||
score INTEGER DEFAULT 0,
|
||||
anomaly_type TEXT DEFAULT '',
|
||||
sector TEXT DEFAULT '',
|
||||
leader_status TEXT DEFAULT '',
|
||||
detected_at TEXT NOT NULL,
|
||||
last_alert_time TEXT DEFAULT '',
|
||||
last_alert_level TEXT DEFAULT '',
|
||||
detail_json TEXT DEFAULT '{}'
|
||||
)
|
||||
""")
|
||||
|
||||
# 2. 筛选记录表(每次筛选全量写入)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS screening_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scan_time TEXT NOT NULL,
|
||||
layer TEXT NOT NULL, -- '粗筛'/'细筛'/'确认'
|
||||
symbol TEXT NOT NULL,
|
||||
state TEXT NOT NULL, -- 蓄力/加速/爆发/过期
|
||||
score INTEGER DEFAULT 0,
|
||||
price REAL NOT NULL, -- 筛选时价格
|
||||
signals TEXT DEFAULT '', -- 信号列表(json array)
|
||||
sector TEXT DEFAULT '',
|
||||
leader_status TEXT DEFAULT '',
|
||||
is_meme INTEGER DEFAULT 0,
|
||||
change_24h REAL DEFAULT 0,
|
||||
funding_rate REAL DEFAULT 0,
|
||||
detail_json TEXT DEFAULT '{}'
|
||||
)
|
||||
""")
|
||||
|
||||
# 3. 推荐表(加速/爆发时生成推荐记录,跟踪最终盈亏)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS recommendation (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL,
|
||||
rec_time TEXT NOT NULL, -- 推荐时间
|
||||
rec_state TEXT NOT NULL, -- 加速/爆发
|
||||
rec_score INTEGER DEFAULT 0,
|
||||
entry_price REAL NOT NULL, -- 推荐时价格
|
||||
stop_loss REAL DEFAULT 0,
|
||||
tp1 REAL DEFAULT 0,
|
||||
tp2 REAL DEFAULT 0,
|
||||
sector TEXT DEFAULT '',
|
||||
signals TEXT DEFAULT '', -- 触发信号(json)
|
||||
is_meme INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'active', -- active/hit_tp1/hit_tp2/stopped_out/expired
|
||||
current_price REAL DEFAULT 0, -- 最新跟踪价格
|
||||
max_price REAL DEFAULT 0, -- 推荐后最高价
|
||||
min_price REAL DEFAULT 0, -- 推荐后最低价
|
||||
pnl_pct REAL DEFAULT 0, -- 当前盈亏%
|
||||
max_pnl_pct REAL DEFAULT 0, -- 最大盈亏%
|
||||
max_drawdown_pct REAL DEFAULT 0, -- 最大回撤%
|
||||
hit_tp1_time TEXT DEFAULT '',
|
||||
hit_tp2_time TEXT DEFAULT '',
|
||||
stopped_out_time TEXT DEFAULT '',
|
||||
expired_time TEXT DEFAULT '',
|
||||
last_track_time TEXT DEFAULT '',
|
||||
entry_plan_json TEXT DEFAULT '{}'
|
||||
)
|
||||
""")
|
||||
|
||||
# 4. 价格跟踪表(定时快照推荐币的当前价格)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS price_tracking (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rec_id INTEGER NOT NULL, -- 关联recommendation.id
|
||||
symbol TEXT NOT NULL,
|
||||
track_time TEXT NOT NULL,
|
||||
price REAL NOT NULL,
|
||||
pnl_pct REAL DEFAULT 0,
|
||||
FOREIGN KEY (rec_id) REFERENCES recommendation(id)
|
||||
)
|
||||
""")
|
||||
|
||||
# 索引
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_screening_time ON screening_log(scan_time)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_screening_symbol ON screening_log(symbol)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_rec_status ON recommendation(status)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_rec_symbol ON recommendation(symbol)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_rec_time ON recommendation(rec_time)")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_db()
|
||||
print("DB Schema初始化完成(4张表+5个索引)")
|
||||
61
scripts/validate_docker_layout.py
Normal file
61
scripts/validate_docker_layout.py
Normal file
@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AlphaX Docker 副本离线布局校验。"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
required_files = [
|
||||
"Dockerfile",
|
||||
"docker-compose.yml",
|
||||
"requirements.txt",
|
||||
".env.example",
|
||||
".dockerignore",
|
||||
"docker/entrypoint.sh",
|
||||
"docker/scheduler.py",
|
||||
"README_DOCKER.md",
|
||||
]
|
||||
for rel in required_files:
|
||||
if not (ROOT / rel).exists():
|
||||
errors.append({"rule": "missing_file", "path": rel})
|
||||
|
||||
compose = (ROOT / "docker-compose.yml").read_text(errors="ignore") if (ROOT / "docker-compose.yml").exists() else ""
|
||||
for needle in ["8191:8190", "ALPHAX_DB_PATH", "ALPHAX_SCHEDULER_DRY_RUN", "./data:/app/data"]:
|
||||
if needle not in compose:
|
||||
errors.append({"rule": "compose_missing_expected_setting", "needle": needle})
|
||||
|
||||
entrypoint = ROOT / "docker/entrypoint.sh"
|
||||
if entrypoint.exists() and not os.access(entrypoint, os.X_OK):
|
||||
errors.append({"rule": "entrypoint_not_executable", "path": str(entrypoint)})
|
||||
|
||||
ignore = (ROOT / ".dockerignore").read_text(errors="ignore") if (ROOT / ".dockerignore").exists() else ""
|
||||
for needle in ["data/", "archive/", "*.db", ".env"]:
|
||||
if needle not in ignore:
|
||||
warnings.append({"rule": "dockerignore_missing_recommended_pattern", "needle": needle})
|
||||
|
||||
# 镜像上下文不应包含真实 DB / 大备份;它们应在 data/archive 并被 .dockerignore 排除。
|
||||
root_db = list(ROOT.glob("*.db")) + list(ROOT.glob("*.db-wal")) + list(ROOT.glob("*.db-shm"))
|
||||
if root_db:
|
||||
warnings.append({"rule": "root_db_files_present", "files": [p.name for p in root_db]})
|
||||
|
||||
# 不应残留 Hermes venv 绝对路径作为运行依赖。
|
||||
hermes_refs = []
|
||||
for p in ROOT.glob("*.py"):
|
||||
txt = p.read_text(errors="ignore")
|
||||
if "/home/ubuntu/.hermes" in txt:
|
||||
hermes_refs.append(p.name)
|
||||
if hermes_refs:
|
||||
errors.append({"rule": "hermes_path_reference_in_py", "files": hermes_refs})
|
||||
|
||||
summary = {
|
||||
"root": str(ROOT),
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"has_data_db": (ROOT / "data" / "altcoin_monitor.db").exists(),
|
||||
}
|
||||
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
||||
if errors:
|
||||
raise SystemExit(1)
|
||||
97
scripts/validate_push_state_flow.py
Normal file
97
scripts/validate_push_state_flow.py
Normal file
@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AlphaX 推送与状态流转口径审计脚本。"""
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DB = Path(os.getenv("ALPHAX_DB_PATH", str(ROOT / "data" / "altcoin_monitor.db")))
|
||||
conn = sqlite3.connect(DB)
|
||||
conn.row_factory = sqlite3.Row
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
|
||||
def rows(sql, params=()):
|
||||
return [dict(r) for r in conn.execute(sql, params).fetchall()]
|
||||
|
||||
|
||||
# 1. 仍在 active 的同一 symbol 只能有一条实时/观察主记录。
|
||||
dups = rows("""
|
||||
SELECT symbol, COUNT(*) c, GROUP_CONCAT(id) ids
|
||||
FROM recommendation
|
||||
WHERE status='active' AND COALESCE(display_bucket,'watch_pool') != 'history'
|
||||
GROUP BY symbol HAVING COUNT(*) > 1
|
||||
""")
|
||||
if dups:
|
||||
errors.append({"rule": "active_symbol_unique", "rows": dups[:30]})
|
||||
|
||||
# 2. 推送必须可追溯到推荐主记录。历史旧数据可能 rec_id=0,但新推送不应继续出现。
|
||||
missing_rec_id = rows("""
|
||||
SELECT id, symbol, push_type, action_status, pushed_at
|
||||
FROM push_log
|
||||
WHERE COALESCE(rec_id,0)=0
|
||||
ORDER BY id DESC
|
||||
LIMIT 20
|
||||
""")
|
||||
if missing_rec_id:
|
||||
warnings.append({"rule": "push_log_missing_rec_id_legacy_or_bug", "rows": missing_rec_id})
|
||||
|
||||
# 3. 入场窗口 rec_time 不应被同一状态反复刷新:同一 rec_id/action 冷却期内重复推送要告警。
|
||||
duplicate_push = rows("""
|
||||
SELECT rec_id, symbol, push_type, action_status, COUNT(*) c, MIN(pushed_at) first_push, MAX(pushed_at) last_push
|
||||
FROM push_log
|
||||
WHERE COALESCE(rec_id,0) > 0
|
||||
GROUP BY rec_id, push_type, action_status
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY c DESC, last_push DESC
|
||||
LIMIT 30
|
||||
""")
|
||||
if duplicate_push:
|
||||
errors.append({"rule": "duplicate_same_rec_action_push", "rows": duplicate_push})
|
||||
|
||||
# 4. 可即刻买入必须有 rec_time/entry_price/current_price,且展示桶一致。
|
||||
bad_buy_now = rows("""
|
||||
SELECT id, symbol, rec_time, entry_price, current_price, action_status, execution_status, display_bucket
|
||||
FROM recommendation
|
||||
WHERE action_status='可即刻买入'
|
||||
AND NOT (execution_status='buy_now' AND display_bucket='realtime' AND COALESCE(entry_price,0)>0 AND COALESCE(current_price,0)>0 AND COALESCE(rec_time,'')!='')
|
||||
LIMIT 30
|
||||
""")
|
||||
if bad_buy_now:
|
||||
errors.append({"rule": "bad_buy_now_state", "rows": bad_buy_now})
|
||||
|
||||
# 5. 失效/止损/反转/衰减不能留在实时/观察池。
|
||||
bad_invalid = rows("""
|
||||
SELECT id, symbol, status, action_status, execution_status, display_bucket
|
||||
FROM recommendation
|
||||
WHERE (status IN ('expired','stopped_out','invalid','archived') OR action_status IN ('衰减','反转','止损','放弃','过期','归档'))
|
||||
AND COALESCE(display_bucket,'') IN ('realtime','watch_pool')
|
||||
LIMIT 30
|
||||
""")
|
||||
if bad_invalid:
|
||||
errors.append({"rule": "invalid_still_visible", "rows": bad_invalid})
|
||||
|
||||
rec_counts = dict(conn.execute("""
|
||||
SELECT
|
||||
COUNT(*) AS recommendation_count,
|
||||
SUM(CASE WHEN status='active' AND COALESCE(display_bucket,'watch_pool')!='history' THEN 1 ELSE 0 END) AS active_mainline_count,
|
||||
SUM(CASE WHEN action_status='可即刻买入' THEN 1 ELSE 0 END) AS buy_now_count
|
||||
FROM recommendation
|
||||
""").fetchone())
|
||||
push_counts = dict(conn.execute("""
|
||||
SELECT
|
||||
COUNT(*) AS push_count,
|
||||
SUM(CASE WHEN COALESCE(rec_id,0)=0 THEN 1 ELSE 0 END) AS push_missing_rec_id_count
|
||||
FROM push_log
|
||||
""").fetchone())
|
||||
summary = {
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"counts": {**rec_counts, **push_counts},
|
||||
}
|
||||
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
||||
conn.close()
|
||||
if errors:
|
||||
raise SystemExit(1)
|
||||
65
scripts/validate_signal_recency.py
Normal file
65
scripts/validate_signal_recency.py
Normal file
@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""审计 AlphaX 技术/消息触发时效,防止旧形态冒充当下交易机会。"""
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DB = Path(os.getenv("ALPHAX_DB_PATH", str(ROOT / "data" / "altcoin_monitor.db")))
|
||||
conn = sqlite3.connect(DB)
|
||||
conn.row_factory = sqlite3.Row
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
|
||||
def parse_json(v):
|
||||
try:
|
||||
if isinstance(v, dict):
|
||||
return v
|
||||
if v:
|
||||
return json.loads(v)
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def add(kind, rule, row):
|
||||
(errors if kind == "error" else warnings).append({"rule": rule, "row": row})
|
||||
|
||||
rows = conn.execute("""
|
||||
SELECT id,symbol,rec_state,action_status,execution_status,display_bucket,signals,market_context_json,sector_context_json,entry_plan_json,rec_time
|
||||
FROM recommendation
|
||||
WHERE status='active' AND COALESCE(display_bucket,'watch_pool')!='history'
|
||||
ORDER BY id DESC
|
||||
""").fetchall()
|
||||
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
market = parse_json(d.get("market_context_json"))
|
||||
sector = parse_json(d.get("sector_context_json"))
|
||||
ep = parse_json(d.get("entry_plan_json"))
|
||||
tc = market.get("trigger_context") or sector.get("trigger_context") or ep.get("trigger_context") or {}
|
||||
sig_text = d.get("signals") or ""
|
||||
if d.get("execution_status") == "buy_now":
|
||||
if not tc:
|
||||
add("warning", "buy_now_missing_trigger_context", {k:d.get(k) for k in ("id","symbol","rec_time","action_status")})
|
||||
elif tc.get("trigger_status") == "stale_background_only":
|
||||
add("error", "buy_now_from_stale_background", {"id": d["id"], "symbol": d["symbol"], "trigger_context": tc})
|
||||
if "历史" in sig_text and "已过期" in sig_text and d.get("execution_status") == "buy_now":
|
||||
current = (tc.get("current_triggers") or []) if isinstance(tc, dict) else []
|
||||
if not current:
|
||||
add("error", "stale_signal_buy_now_without_current_trigger", {"id": d["id"], "symbol": d["symbol"], "signals": sig_text[:300]})
|
||||
if "event_title" in sector or "event_source" in market:
|
||||
if not tc and not (sector.get("trigger_context") or market.get("trigger_context")):
|
||||
add("warning", "event_recommendation_missing_event_marker", {"id": d["id"], "symbol": d["symbol"], "sector_context": sector, "market_context": market})
|
||||
|
||||
summary = {
|
||||
"active_checked": len(rows),
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
}
|
||||
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
||||
conn.close()
|
||||
if errors:
|
||||
raise SystemExit(1)
|
||||
66
scripts/validate_state_machine.py
Normal file
66
scripts/validate_state_machine.py
Normal file
@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AlphaX 状态机口径验收脚本。"""
|
||||
import sqlite3, json, sys, os
|
||||
from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DB = Path(os.getenv('ALPHAX_DB_PATH', str(ROOT / 'data' / 'altcoin_monitor.db')))
|
||||
conn = sqlite3.connect(DB)
|
||||
conn.row_factory = sqlite3.Row
|
||||
errors = []
|
||||
|
||||
def scalar(sql, params=()):
|
||||
return conn.execute(sql, params).fetchone()[0]
|
||||
|
||||
# 1. 当前主状态唯一:同一 symbol 不能有多条非历史 active。
|
||||
dups = conn.execute("""
|
||||
SELECT symbol, COUNT(*) c, GROUP_CONCAT(id) ids
|
||||
FROM recommendation
|
||||
WHERE status='active' AND COALESCE(display_bucket,'watch_pool') != 'history'
|
||||
GROUP BY symbol HAVING COUNT(*) > 1
|
||||
""").fetchall()
|
||||
if dups:
|
||||
errors.append({'rule': 'active_symbol_unique', 'rows': [dict(r) for r in dups[:20]]})
|
||||
|
||||
# 2. 实时看板不允许失效/历史状态混入。
|
||||
bad_realtime = conn.execute("""
|
||||
SELECT id,symbol,status,action_status,display_bucket,execution_status
|
||||
FROM recommendation
|
||||
WHERE display_bucket IN ('realtime','watch_pool')
|
||||
AND (status IN ('expired','stopped_out','invalid','archived') OR action_status IN ('衰减','反转','止损','放弃','过期','归档'))
|
||||
LIMIT 50
|
||||
""").fetchall()
|
||||
if bad_realtime:
|
||||
errors.append({'rule': 'no_invalid_in_realtime_or_watch_pool', 'rows': [dict(r) for r in bad_realtime]})
|
||||
|
||||
# 3. 等回踩/观察不能被标记为已触发入场。
|
||||
bad_unexecuted = conn.execute("""
|
||||
SELECT id,symbol,status,action_status,display_bucket,execution_status,entry_triggered
|
||||
FROM recommendation
|
||||
WHERE action_status IN ('等回踩','观察') AND COALESCE(entry_triggered,0) != 0
|
||||
LIMIT 50
|
||||
""").fetchall()
|
||||
if bad_unexecuted:
|
||||
errors.append({'rule': 'watch_wait_not_executed', 'rows': [dict(r) for r in bad_unexecuted]})
|
||||
|
||||
# 4. 入场窗口必须具备 realtime/buy_now 口径。
|
||||
bad_buy = conn.execute("""
|
||||
SELECT id,symbol,status,action_status,display_bucket,execution_status
|
||||
FROM recommendation
|
||||
WHERE action_status='可即刻买入' AND NOT (display_bucket='realtime' AND execution_status='buy_now')
|
||||
LIMIT 50
|
||||
""").fetchall()
|
||||
if bad_buy:
|
||||
errors.append({'rule': 'buy_now_bucket_consistency', 'rows': [dict(r) for r in bad_buy]})
|
||||
|
||||
summary = {
|
||||
'recommendation_count': scalar('SELECT COUNT(*) FROM recommendation'),
|
||||
'realtime_count': scalar("SELECT COUNT(*) FROM recommendation WHERE display_bucket='realtime'"),
|
||||
'watch_pool_count': scalar("SELECT COUNT(*) FROM recommendation WHERE display_bucket='watch_pool'"),
|
||||
'position_count': scalar("SELECT COUNT(*) FROM recommendation WHERE display_bucket='position'"),
|
||||
'history_count': scalar("SELECT COUNT(*) FROM recommendation WHERE display_bucket='history'"),
|
||||
'errors': errors,
|
||||
}
|
||||
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
||||
conn.close()
|
||||
if errors:
|
||||
sys.exit(1)
|
||||
151
sector_map.py
Normal file
151
sector_map.py
Normal file
@ -0,0 +1,151 @@
|
||||
"""
|
||||
山寨币板块映射表 + 动态龙头识别
|
||||
|
||||
板块分类硬编码,龙头动态计算(板块内涨幅最大的就是龙头)
|
||||
"""
|
||||
|
||||
# 板块定义:币种 → 板块名
|
||||
# 只包含Binance上线的主流山寨币
|
||||
SECTOR_MEMBERS = {
|
||||
"AI_DePIN": [
|
||||
"FET/USDT", "RENDER/USDT", "TAO/USDT", "AKT/USDT", "AETH/USDT",
|
||||
"WLD/USDT", "RNDR/USDT", "OCEAN/USDT", "INJ/USDT", "ATH/USDT",
|
||||
"AI/USDT", "VANA/USDT",
|
||||
],
|
||||
"Layer2": [
|
||||
"ARB/USDT", "OP/USDT", "MATIC/USDT", "IMX/USDT", "STRK/USDT",
|
||||
"METIS/USDT", "SKL/USDT", "CELO/USDT", "LRC/USDT",
|
||||
"POL/USDT",
|
||||
],
|
||||
"DeFi": [
|
||||
"AAVE/USDT", "UNI/USDT", "COMP/USDT", "CRV/USDT", "MKR/USDT",
|
||||
"SNX/USDT", "SUSHI/USDT", "1INCH/USDT", "YFI/USDT", "LDO/USDT",
|
||||
"RPL/USDT", "PENDLE/USDT", "ENA/USDT", "EIGEN/USDT",
|
||||
"API3/USDT", "BIO/USDT", "ENSO/USDT", "LINK/USDT",
|
||||
"SPK/USDT", "TRB/USDT", "WLFI/USDT",
|
||||
],
|
||||
"MEME": [
|
||||
"PEPE/USDT", "WIF/USDT", "BONK/USDT", "FLOKI/USDT", "DOGE/USDT",
|
||||
"SHIB/USDT", "MEME/USDT", "TRUMP/USDT", "BOME/USDT",
|
||||
"BANANAS31/USDT", "KAT/USDT", "NEIRO/USDT",
|
||||
],
|
||||
"Solana_eco": [
|
||||
"SOL/USDT", "JUP/USDT", "JTO/USDT", "PYTH/USDT", "BONK/USDT",
|
||||
"WIF/USDT", "HNT/USDT", "RAY/USDT",
|
||||
],
|
||||
"BTC_eco": [
|
||||
"STX/USDT", "ORDI/USDT", "SATS/USDT", "RIF/USDT",
|
||||
],
|
||||
"Gaming_Metaverse": [
|
||||
"AXS/USDT", "MANA/USDT", "SAND/USDT", "ENJ/USDT", "GALA/USDT",
|
||||
"ALICE/USDT", "ILV/USDT", "MAGIC/USDT", "YGG/USDT",
|
||||
"APE/USDT", "CHZ/USDT",
|
||||
],
|
||||
"Storage_Computing": [
|
||||
"FIL/USDT", "AR/USDT", "CHIA/USDT", "RNDR/USDT", "AKT/USDT",
|
||||
"ETHFI/USDT",
|
||||
],
|
||||
"RWA": [
|
||||
"POLYX/USDT", "TRU/USDT", "CFG/USDT", "MPL/USDT",
|
||||
"PLUME/USDT",
|
||||
],
|
||||
"Layer1_alt": [
|
||||
"ADA/USDT", "ATOM/USDT", "DOT/USDT", "AVAX/USDT", "NEAR/USDT",
|
||||
"ALGO/USDT", "FTM/USDT", "SEI/USDT", "SUI/USDT", "APT/USDT",
|
||||
"TON/USDT", "KAS/USDT", "TIA/USDT",
|
||||
"AXL/USDT", "DASH/USDT", "HBAR/USDT", "LTC/USDT",
|
||||
"MOVR/USDT", "TRX/USDT", "XRP/USDT", "ZEN/USDT",
|
||||
],
|
||||
}
|
||||
|
||||
# MEME板块的特殊阈值(波动大,阈值不同)
|
||||
MEME_SECTORS = {"MEME", "Solana_eco"} # Solana生态也包含很多MEME
|
||||
|
||||
# 构建反向映射:币种 → 所属板块列表(一个币可能属于多个板块)
|
||||
COIN_TO_SECTORS = {}
|
||||
for sector, coins in SECTOR_MEMBERS.items():
|
||||
for coin in coins:
|
||||
if coin not in COIN_TO_SECTORS:
|
||||
COIN_TO_SECTORS[coin] = []
|
||||
COIN_TO_SECTORS[coin].append(sector)
|
||||
|
||||
# 排除列表(不监控的币种)
|
||||
EXCLUDE_SYMBOLS = set() # 稳定币、Wrapped代币在粗筛中过滤
|
||||
|
||||
# 市值Top200门槛:24h成交量最低要求
|
||||
MIN_24H_VOLUME_USD = 5_000_000 # $5M
|
||||
|
||||
# MEME板块最低成交量更低(MEME流动性更差)
|
||||
MEME_MIN_24H_VOLUME_USD = 2_000_000 # $2M
|
||||
|
||||
|
||||
def get_sector_for_coin(symbol):
|
||||
"""获取币种所属的所有板块"""
|
||||
return COIN_TO_SECTORS.get(symbol, [])
|
||||
|
||||
|
||||
def is_meme_coin(symbol):
|
||||
"""判断是否是MEME类币种(阈值不同)"""
|
||||
sectors = set(get_sector_for_coin(symbol))
|
||||
return bool(sectors & MEME_SECTORS)
|
||||
|
||||
|
||||
def get_burst_threshold(symbol):
|
||||
"""获取爆发涨幅阈值(MEME更高)"""
|
||||
if is_meme_coin(symbol):
|
||||
return 10.0 # MEME: 10%才算爆发
|
||||
return 5.0 # 主流: 5%算爆发
|
||||
|
||||
|
||||
def get_stop_loss_pct(symbol):
|
||||
"""获取止损百分比(MEME更大)"""
|
||||
if is_meme_coin(symbol):
|
||||
return 5.0 # MEME: -5%止损
|
||||
return 3.0 # 主流: -3%止损
|
||||
|
||||
|
||||
def dynamic_leader_detection(sector_perf):
|
||||
"""
|
||||
动态龙头识别
|
||||
sector_perf: {sector: {symbol: pct_change_4h}}
|
||||
返回: {sector: {"leader": symbol, "leader_pct": float, "is_leader_hot": bool}}
|
||||
"""
|
||||
results = {}
|
||||
for sector, coin_perf in sector_perf.items():
|
||||
if not coin_perf:
|
||||
results[sector] = {"leader": None, "leader_pct": 0, "is_leader_hot": False}
|
||||
continue
|
||||
|
||||
# 找涨幅最大的作为龙头
|
||||
sorted_perf = sorted(coin_perf.items(), key=lambda x: x[1], reverse=True)
|
||||
leader = sorted_perf[0][0]
|
||||
leader_pct = sorted_perf[0][1]
|
||||
|
||||
# MEME板块龙头启动阈值更高
|
||||
threshold = 8.0 if sector in MEME_SECTORS else 5.0
|
||||
|
||||
results[sector] = {
|
||||
"leader": leader,
|
||||
"leader_pct": leader_pct,
|
||||
"is_leader_hot": leader_pct >= threshold,
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 测试
|
||||
print("币种 → 板块映射示例:")
|
||||
for coin in ["FET/USDT", "JUP/USDT", "PEPE/USDT", "ARB/USDT"]:
|
||||
print(f" {coin} → {get_sector_for_coin(coin)}")
|
||||
print(f" 爆发阈值: {get_burst_threshold(coin)}%, 止损: {get_stop_loss_pct(coin)}%")
|
||||
|
||||
# 测试动态龙头
|
||||
test_perf = {
|
||||
"AI_DePIN": {"FET/USDT": 7.2, "RENDER/USDT": 3.1, "TAO/USDT": 1.5},
|
||||
"MEME": {"PEPE/USDT": 12.0, "WIF/USDT": 4.5, "DOGE/USDT": 2.1},
|
||||
}
|
||||
leaders = dynamic_leader_detection(test_perf)
|
||||
print("\n动态龙头检测结果:")
|
||||
for sector, info in leaders.items():
|
||||
print(f" {sector}: 龙头={info['leader']}, 涨幅={info['leader_pct']}%, 热度={info['is_leader_hot']}")
|
||||
338
sentiment_monitor.py
Normal file
338
sentiment_monitor.py
Normal file
@ -0,0 +1,338 @@
|
||||
"""
|
||||
山寨币舆情监控模块 v1.1
|
||||
数据源:CoinGecko Trending API + Google News RSS (均免费)
|
||||
用途:检测山寨币的消息面热度+具体新闻内容,与PA技术面共振加权
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import sqlite3
|
||||
import requests
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
COINGECKO_TRENDING_URL = "https://api.coingecko.com/api/v3/search/trending"
|
||||
GOOGLE_NEWS_RSS = "https://news.google.com/rss/search?q={query}&hl=en-US&gl=US&ceid=US:en"
|
||||
DB_PATH = os.getenv("ALPHAX_DB_PATH", os.path.join(os.path.dirname(__file__), "data", "altcoin_monitor.db"))
|
||||
|
||||
|
||||
def _get_conn():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def fetch_trending_coins():
|
||||
"""获取 CoinGecko Trending 榜单, 含价格+涨跌幅"""
|
||||
try:
|
||||
r = requests.get(COINGECKO_TRENDING_URL, timeout=15)
|
||||
if r.status_code != 200:
|
||||
return []
|
||||
data = r.json()
|
||||
coins = []
|
||||
for idx, c in enumerate(data.get("coins", [])):
|
||||
item = c.get("item", {})
|
||||
symbol = (item.get("symbol", "") or "").upper()
|
||||
price_data = item.get("data", {}) or {}
|
||||
price_usd = price_data.get("price", 0) or 0
|
||||
change_pct_dict = price_data.get("price_change_percentage_24h", {}) or {}
|
||||
change_usd = change_pct_dict.get("usd", 0) or 0
|
||||
coins.append({
|
||||
"symbol": symbol,
|
||||
"name": item.get("name", ""),
|
||||
"coingecko_id": item.get("id", ""),
|
||||
"trend_rank": idx + 1,
|
||||
"trend_score": item.get("score", 0),
|
||||
"market_cap_rank": item.get("market_cap_rank", 0) or 0,
|
||||
"price_usd": round(float(price_usd), 6),
|
||||
"change_24h_pct": round(float(change_usd), 2),
|
||||
"thumb": item.get("thumb", ""),
|
||||
})
|
||||
return coins
|
||||
except Exception as e:
|
||||
print(f"[sentiment] fetch_trending error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def fetch_news_for_coin(symbol, max_results=3):
|
||||
"""
|
||||
从 Google News RSS 抓取币种相关新闻标题
|
||||
返回: list of {title, source, published}
|
||||
"""
|
||||
try:
|
||||
query = f"{symbol}+crypto+coin"
|
||||
url = GOOGLE_NEWS_RSS.format(query=query)
|
||||
r = requests.get(url, timeout=10, headers={"User-Agent": "Mozilla/5.0"})
|
||||
if r.status_code != 200:
|
||||
return []
|
||||
root = ET.fromstring(r.text)
|
||||
items = root.findall(".//item")
|
||||
news = []
|
||||
for item in items[:max_results]:
|
||||
title_el = item.find("title")
|
||||
source_el = item.find("source")
|
||||
pubdate_el = item.find("pubDate")
|
||||
title = title_el.text if title_el is not None else ""
|
||||
source = source_el.text if source_el is not None else ""
|
||||
published = pubdate_el.text if pubdate_el is not None else ""
|
||||
# 清理标题: 去掉末尾的 " - SourceName"
|
||||
if " - " in title:
|
||||
parts = title.rsplit(" - ", 1)
|
||||
if len(parts) == 2 and len(parts[1]) < 30:
|
||||
title = parts[0]
|
||||
news.append({
|
||||
"title": title[:200],
|
||||
"source": source,
|
||||
"published": published,
|
||||
})
|
||||
return news
|
||||
except Exception as e:
|
||||
print(f"[sentiment] fetch_news error for {symbol}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def get_sentiment_scores(symbols=None, max_bonus=2):
|
||||
"""获取指定币种的舆情评分"""
|
||||
trending = fetch_trending_coins()
|
||||
if not trending:
|
||||
return {}
|
||||
|
||||
trending_map = {}
|
||||
for t in trending:
|
||||
if t["symbol"]:
|
||||
trending_map[t["symbol"]] = t
|
||||
|
||||
previous_trending = _get_previous_trending()
|
||||
prev_symbols = {r["symbol"] for r in previous_trending}
|
||||
|
||||
scores = {}
|
||||
for t in trending:
|
||||
sym = t["symbol"]
|
||||
full_symbol = f"{sym}/USDT"
|
||||
if symbols and full_symbol not in symbols:
|
||||
continue
|
||||
|
||||
bonus = 0.0
|
||||
details = []
|
||||
|
||||
if t["trend_rank"] <= 5:
|
||||
bonus += 2.0
|
||||
details.append(f"Trending Top5(#{t['trend_rank']})")
|
||||
elif t["trend_rank"] <= 10:
|
||||
bonus += 1.0
|
||||
details.append(f"Trending Top10(#{t['trend_rank']})")
|
||||
elif t["trend_rank"] <= 15:
|
||||
bonus += 0.5
|
||||
details.append(f"Trending(#{t['trend_rank']})")
|
||||
|
||||
if sym not in prev_symbols:
|
||||
bonus += 1.0
|
||||
details.append("new_entry")
|
||||
|
||||
consecutive_hours = _get_consecutive_trending_hours(sym)
|
||||
if consecutive_hours > 6:
|
||||
decay = max(0.3, 1.0 - (consecutive_hours - 6) * 0.1)
|
||||
bonus *= decay
|
||||
|
||||
bonus = min(bonus, max_bonus)
|
||||
bonus = round(bonus, 1)
|
||||
|
||||
if bonus > 0:
|
||||
scores[full_symbol] = {
|
||||
"trending": True,
|
||||
"trend_rank": t["trend_rank"],
|
||||
"bonus": bonus,
|
||||
"details": ", ".join(details),
|
||||
"market_cap_rank": t["market_cap_rank"],
|
||||
"name": t["name"],
|
||||
"price_usd": t.get("price_usd", 0),
|
||||
"change_24h_pct": t.get("change_24h_pct", 0),
|
||||
"coingecko_id": t.get("coingecko_id", ""),
|
||||
}
|
||||
return scores
|
||||
|
||||
|
||||
def get_sentiment_alert(holdings=None):
|
||||
"""检测舆情异动"""
|
||||
trending = fetch_trending_coins()
|
||||
if not trending:
|
||||
return []
|
||||
|
||||
previous_trending = _get_previous_trending()
|
||||
prev_symbols = {r["symbol"] for r in previous_trending}
|
||||
|
||||
alerts = []
|
||||
holdings_set = set(holdings) if holdings else set()
|
||||
|
||||
for t in trending[:10]:
|
||||
sym = t["symbol"]
|
||||
full_symbol = f"{sym}/USDT"
|
||||
if full_symbol in holdings_set and t["trend_rank"] <= 3:
|
||||
alerts.append({
|
||||
"type": "holding_trending",
|
||||
"symbol": full_symbol,
|
||||
"name": t["name"],
|
||||
"trend_rank": t["trend_rank"],
|
||||
"alert": f"持仓币 {sym} 进入 CoinGecko Trending Top{t['trend_rank']}",
|
||||
})
|
||||
elif sym not in prev_symbols and t["trend_rank"] <= 10:
|
||||
alerts.append({
|
||||
"type": "new_trending",
|
||||
"symbol": full_symbol,
|
||||
"name": t["name"],
|
||||
"trend_rank": t["trend_rank"],
|
||||
"alert": f"{sym}({t['name']}) 新进 Trending #{t['trend_rank']}",
|
||||
})
|
||||
return alerts
|
||||
|
||||
|
||||
def collect_and_store():
|
||||
"""采集舆情数据+新闻,写入DB"""
|
||||
trending = fetch_trending_coins()
|
||||
if not trending:
|
||||
return {"status": "error", "message": "Failed to fetch trending"}
|
||||
|
||||
conn = _get_conn()
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
# 获取活跃持仓币种,用于新闻优先级
|
||||
active_recs = conn.execute(
|
||||
"SELECT DISTINCT symbol FROM recommendation WHERE status='active'"
|
||||
).fetchall()
|
||||
active_bases = {r["symbol"].split("/")[0].upper() for r in active_recs}
|
||||
|
||||
stored = 0
|
||||
for i, t in enumerate(trending):
|
||||
# 只对 Top10 + 持仓重叠的币拉新闻 (控制请求量)
|
||||
should_fetch_news = (t["trend_rank"] <= 10 or t["symbol"] in active_bases)
|
||||
news = []
|
||||
if should_fetch_news:
|
||||
news = fetch_news_for_coin(t["symbol"], max_results=3)
|
||||
if i < 9: # 控制频率,每个请求间隔0.5秒
|
||||
time.sleep(0.5)
|
||||
|
||||
try:
|
||||
conn.execute("""
|
||||
INSERT INTO sentiment_events
|
||||
(symbol, name, source, event_type, trend_rank, trend_score,
|
||||
market_cap_rank, extra_json, detected_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
t["symbol"], t["name"], "coingecko", "trending",
|
||||
t["trend_rank"], t["trend_score"], t["market_cap_rank"],
|
||||
json.dumps({
|
||||
"price_usd": t.get("price_usd", 0),
|
||||
"change_24h_pct": t.get("change_24h_pct", 0),
|
||||
"thumb": t.get("thumb", ""),
|
||||
"news": news,
|
||||
}),
|
||||
now,
|
||||
))
|
||||
stored += 1
|
||||
except Exception as e:
|
||||
print(f"[sentiment] DB insert error for {t['symbol']}: {e}")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"[sentiment] Stored {stored} trending coins at {now}")
|
||||
return {"status": "ok", "stored": stored, "time": now}
|
||||
|
||||
|
||||
def _get_previous_trending():
|
||||
"""获取上一次采集的 trending 记录"""
|
||||
try:
|
||||
conn = _get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT MAX(detected_at) FROM sentiment_events WHERE source='coingecko'"
|
||||
).fetchone()
|
||||
if not row or not row[0]:
|
||||
conn.close()
|
||||
return []
|
||||
latest_time = row[0]
|
||||
rows = conn.execute(
|
||||
"SELECT symbol, trend_rank FROM sentiment_events WHERE detected_at = ?",
|
||||
(latest_time,)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [{"symbol": r["symbol"], "trend_rank": r["trend_rank"]} for r in rows]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _get_consecutive_trending_hours(symbol):
|
||||
"""计算连续在榜小时数"""
|
||||
try:
|
||||
conn = _get_conn()
|
||||
rows = conn.execute("""
|
||||
SELECT detected_at FROM sentiment_events
|
||||
WHERE symbol = ? AND source = 'coingecko'
|
||||
ORDER BY detected_at DESC LIMIT 20
|
||||
""", (symbol,)).fetchall()
|
||||
conn.close()
|
||||
if not rows:
|
||||
return 0
|
||||
consecutive = 1
|
||||
max_gap = 3600
|
||||
prev = datetime.fromisoformat(rows[0]["detected_at"])
|
||||
for r in rows[1:]:
|
||||
curr = datetime.fromisoformat(r["detected_at"])
|
||||
if (prev - curr).total_seconds() <= max_gap:
|
||||
consecutive += 1
|
||||
prev = curr
|
||||
else:
|
||||
break
|
||||
return consecutive * 0.5
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def get_active_holdings():
|
||||
"""获取当前活跃推荐币种"""
|
||||
try:
|
||||
conn = _get_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT symbol FROM recommendation WHERE status='active'"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [r["symbol"] for r in rows]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="山寨币舆情监控")
|
||||
parser.add_argument("--collect", action="store_true", help="采集并存储")
|
||||
parser.add_argument("--check", action="store_true", help="检测异动")
|
||||
parser.add_argument("--scores", action="store_true", help="输出评分")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.collect:
|
||||
result = collect_and_store()
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
elif args.scores:
|
||||
scores = get_sentiment_scores()
|
||||
print(json.dumps(scores, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
holdings = get_active_holdings()
|
||||
alerts = get_sentiment_alert(holdings=holdings)
|
||||
if args.check:
|
||||
print(json.dumps(alerts, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
scores = get_sentiment_scores()
|
||||
output = {
|
||||
"alerts": alerts,
|
||||
"sentiment_scores": scores,
|
||||
"holdings_count": len(holdings),
|
||||
"check_time": datetime.now().isoformat(),
|
||||
}
|
||||
print(json.dumps(output, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
292
static/admin.html
Normal file
292
static/admin.html
Normal file
@ -0,0 +1,292 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}管理看板 · AlphaX{% endblock %}
|
||||
|
||||
{% block nav_links %}
|
||||
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||
<a class="sidebar-link" href="/watchlist"><svg class="link-icon"><use href="#svg-star"/></svg>关注</a>
|
||||
<a class="sidebar-link" href="/strategy"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||
<a class="sidebar-link" href="/iteration"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||
<a class="sidebar-link active admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_head_css %}
|
||||
<style>
|
||||
main { max-width: 1280px; margin: 0 auto; width: 100%; padding: 24px; display: flex; flex-direction: column; gap: 24px; }
|
||||
.page-title { font-size: 22px; font-weight: 700; }
|
||||
.page-title .sub { font-size: 13px; color: var(--stone); font-weight: 400; margin-left: 8px; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; }
|
||||
.stat-card { background: var(--canvas); border: 1px solid var(--hairline-soft); border-radius: var(--radius-xl); padding: 18px 20px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.stat-card .label { font-size: 11px; color: var(--stone); text-transform: uppercase; letter-spacing: .5px; }
|
||||
.stat-card .value { font-size: 34px; font-weight: 700; letter-spacing: -1px; line-height: 1; color: var(--ink); }
|
||||
.stat-card .sub { font-size: 11px; color: var(--stone); margin-top: 2px; }
|
||||
.section { display: flex; flex-direction: column; gap: 12px; }
|
||||
.section-title { font-size: 13px; font-weight: 600; color: var(--stone); text-transform: uppercase; letter-spacing: .5px; }
|
||||
.card { background: var(--canvas); border: 1px solid var(--hairline-soft); border-radius: var(--radius-xl); padding: 20px; overflow: hidden; }
|
||||
.card.no-pad { padding: 0; }
|
||||
.chart-wrap { min-height: 160px; position: relative; }
|
||||
.chart-wrap svg { width: 100%; display: block; }
|
||||
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.toolbar input { flex: 1; padding: 9px 14px; background: var(--surface); border: 1px solid var(--hairline); border-radius: var(--radius-md); color: var(--ink); font-size: 13px; outline: none; }
|
||||
.toolbar input:focus { border-color: var(--blue); }
|
||||
.toolbar button { padding: 9px 18px; background: var(--blue); color: #fff; border: none; border-radius: var(--radius-md); font-size: 13px; cursor: pointer; font-weight: 500; }
|
||||
.toolbar button:hover { opacity: .9; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; table-layout: auto; }
|
||||
th { text-align: left; padding: 10px 12px; color: var(--stone); font-weight: 500; border-bottom: 1px solid var(--hairline-soft); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; white-space: nowrap; }
|
||||
td { padding: 11px 12px; border-bottom: 1px solid var(--hairline-soft); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); }
|
||||
tr:hover td { background: var(--surface); }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; line-height: 1.5; }
|
||||
.badge-green { background: rgba(0,180,115,.12); color: var(--green); }
|
||||
.badge-yellow { background: rgba(255,208,47,.15); color: var(--yellow-dark); }
|
||||
.badge-red { background: rgba(229,62,62,.12); color: var(--red); }
|
||||
.badge-gray { background: var(--hairline-soft); color: var(--stone); }
|
||||
.admin-tabs { display:flex; gap:6px; padding:4px; background:var(--surface); border:1px solid var(--hairline-soft); border-radius:var(--radius-full); width:fit-content; }
|
||||
.admin-tab-btn { border:1px solid transparent; background:var(--canvas); color:var(--steel); padding:9px 20px; border-radius:var(--radius-full); font-weight:700; font-size:14px; cursor:pointer; transition:.15s; box-shadow:0 1px 2px rgba(5,0,56,.03); }
|
||||
.admin-tab-btn.active { background:var(--primary); color:var(--on-primary); border-color:var(--primary); }
|
||||
.admin-panel { display:none; }
|
||||
.admin-panel.active { display:block; }
|
||||
.user-tabs { display: flex; gap: 4px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.tab-btn { padding: 6px 16px; border: 1px solid var(--hairline); border-radius: var(--radius-full); background: transparent; color: var(--stone); font-size: 13px; cursor: pointer; transition: .15s; font-weight: 500; white-space: nowrap; }
|
||||
.tab-btn:hover { border-color: var(--hairline-strong); color: var(--ink); }
|
||||
.tab-btn.active { background: var(--primary); color: var(--on-primary); border-color: var(--primary); }
|
||||
.pagination { display: flex; justify-content: center; align-items: center; gap: 12px; margin-top: 16px; font-size: 13px; color: var(--stone); }
|
||||
.pagination button { padding: 6px 14px; background: var(--surface); border: 1px solid var(--hairline); border-radius: var(--radius-md); color: var(--ink); font-size: 13px; cursor: pointer; }
|
||||
.pagination button:disabled { opacity: .4; cursor: default; }
|
||||
@media(max-width:600px){main{padding:16px}.page-title{font-size:18px}.stats-grid{grid-template-columns:1fr 1fr}.stat-card{padding:14px 16px}.stat-card .value{font-size:26px}}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
<div class="page-title">管理看板<div class="sub">PV · 用户 · 订阅 · 订单</div></div>
|
||||
|
||||
<div class="stats-grid" id="stats">
|
||||
<div class="stat-card"><div class="label">今日 PV</div><div class="value">--</div></div>
|
||||
<div class="stat-card"><div class="label">累计 PV</div><div class="value">--</div></div>
|
||||
<div class="stat-card"><div class="label">总用户</div><div class="value">--</div></div>
|
||||
<div class="stat-card"><div class="label">今日新增用户</div><div class="value">--</div></div>
|
||||
<div class="stat-card"><div class="label">活跃订阅</div><div class="value">--</div></div>
|
||||
<div class="stat-card"><div class="label">订单记录</div><div class="value">--</div></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">PV 趋势 · 近 30 天</div>
|
||||
<div class="card chart-wrap" id="dauChart"></div>
|
||||
</div>
|
||||
|
||||
<div class="admin-tabs" role="tablist">
|
||||
<button class="admin-tab-btn active" data-admin-tab="users" onclick="switchAdminTab('users')">用户管理</button>
|
||||
<button class="admin-tab-btn" data-admin-tab="orders" onclick="switchAdminTab('orders')">订阅订单</button>
|
||||
</div>
|
||||
|
||||
<div class="section admin-panel active" id="usersPanel">
|
||||
<div class="section-title">用户管理</div>
|
||||
<div class="card no-pad">
|
||||
<div style="padding:16px 20px 0">
|
||||
<div class="user-tabs">
|
||||
<button class="tab-btn active" data-tab="all" onclick="switchTab('all')">全部用户</button>
|
||||
<button class="tab-btn" data-tab="today_active" onclick="switchTab('today_active')">今日活跃</button>
|
||||
<button class="tab-btn" data-tab="admin" onclick="switchTab('admin')">管理员</button>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<input type="text" id="userSearch" placeholder="搜索邮箱..." onkeydown="if(event.key==='Enter')loadUsers(0)">
|
||||
<button onclick="loadUsers(0)">搜索</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x:auto">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>邮箱</th><th>注册状态</th><th>订阅状态</th><th>到期时间</th><th>角色</th><th>注册时间</th><th>最后登录</th>
|
||||
</tr></thead>
|
||||
<tbody id="userTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="pagination" style="padding-bottom:16px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section admin-panel" id="ordersPanel">
|
||||
<div class="section-title">订阅订单管理</div>
|
||||
<div class="card no-pad">
|
||||
<div style="padding:16px 20px 0">
|
||||
<div class="user-tabs">
|
||||
<button class="tab-btn order-tab active" data-status="all" onclick="switchOrderStatus('all')">全部订单</button>
|
||||
<button class="tab-btn order-tab" data-status="pending" onclick="switchOrderStatus('pending')">待支付</button>
|
||||
<button class="tab-btn order-tab" data-status="paid" onclick="switchOrderStatus('paid')">已支付</button>
|
||||
<button class="tab-btn order-tab" data-status="confirmed" onclick="switchOrderStatus('confirmed')">已确认</button>
|
||||
<button class="tab-btn order-tab" data-status="expired" onclick="switchOrderStatus('expired')">已过期</button>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<input type="text" id="orderSearch" placeholder="搜索邮箱 / TXID / 订单号..." onkeydown="if(event.key==='Enter')loadOrders(0)">
|
||||
<button onclick="loadOrders(0)">搜索</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x:auto">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>订单号</th><th>邮箱</th><th>套餐</th><th>金额</th><th>状态</th><th>链 / TXID</th><th>创建时间</th><th>订阅关联</th>
|
||||
</tr></thead>
|
||||
<tbody id="orderTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="orderPagination" style="padding-bottom:16px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
{% block password_modal %}{% endblock %}
|
||||
|
||||
{% block extra_script %}
|
||||
<script>
|
||||
var API = '';
|
||||
|
||||
var PAGE_SIZE=50,userOffset=0,userTotal=0,currentTab='all';
|
||||
var ORDER_PAGE_SIZE=50,orderOffset=0,orderTotal=0,currentOrderStatus='all';
|
||||
|
||||
async function init(){
|
||||
try{var chk=await fetch(API+'/api/admin/check');if(!chk.ok){window.location.href='/subscription';return}
|
||||
var info=await chk.json();if(!info.is_admin){window.location.href='/subscription';return}}catch(e){window.location.href='/subscription';return}
|
||||
loadStats();loadUsers(0);
|
||||
}
|
||||
|
||||
function switchAdminTab(tab){
|
||||
document.querySelectorAll('.admin-tab-btn').forEach(function(b){b.classList.toggle('active',b.dataset.adminTab===tab)});
|
||||
document.getElementById('usersPanel').classList.toggle('active',tab==='users');
|
||||
document.getElementById('ordersPanel').classList.toggle('active',tab==='orders');
|
||||
if(tab==='orders' && orderTotal===0) loadOrders(0);
|
||||
}
|
||||
|
||||
function switchTab(tab){
|
||||
currentTab=tab;userOffset=0;
|
||||
document.querySelectorAll('[data-tab]').forEach(function(b){b.classList.toggle('active',b.dataset.tab===tab)});
|
||||
loadUsers(0);
|
||||
}
|
||||
|
||||
async function loadStats(){
|
||||
try{var r=await fetch(API+'/api/admin/stats');if(!r.ok)throw new Error('Unauthorized');var d=await r.json();
|
||||
document.getElementById('stats').innerHTML=
|
||||
'<div class="stat-card"><div class="label">今日 PV</div><div class="value">'+(d.pv_today||0)+'<div class="sub">页面访问次数,不去重</div></div></div>'+
|
||||
'<div class="stat-card"><div class="label">累计 PV</div><div class="value">'+(d.pv_total||0)+'<div class="sub">全部页面访问</div></div></div>'+
|
||||
'<div class="stat-card"><div class="label">总用户</div><div class="value">'+(d.total_users||0)+'<div class="sub">DAU '+(d.dau_today||0)+' · WAU '+(d.wau_7d||0)+'</div></div></div>'+
|
||||
'<div class="stat-card"><div class="label">今日新增用户</div><div class="value">'+(d.new_users_today||0)+'<div class="sub">新增 / 总用户配对</div></div></div>'+
|
||||
'<div class="stat-card"><div class="label">活跃订阅</div><div class="value">'+(d.active_subscriptions||0)+'<div class="sub">当前有效</div></div></div>'+
|
||||
'<div class="stat-card"><div class="label">订单记录</div><div class="value">'+(d.total_orders||0)+'<div class="sub">已支付 '+(d.paid_orders||0)+'</div></div></div>';
|
||||
renderChart(d.pv_trend||[]);
|
||||
}catch(e){}
|
||||
}
|
||||
function renderChart(trend){
|
||||
var wrap=document.getElementById('dauChart');
|
||||
if(!trend.length){wrap.innerHTML='<div style="display:flex;align-items:center;justify-content:center;height:160px;color:var(--stone)">暂无数据</div>';return}
|
||||
var W=Math.max(wrap.clientWidth||600,300),H=160,pad={t:10,r:16,b:26,l:38},cw=W-pad.l-pad.r,ch=H-pad.t-pad.b;
|
||||
var maxVal=Math.max.apply(null,trend.map(function(d){return d.count}))||1;
|
||||
var svg='<svg viewBox="0 0 '+W+' '+H+'">';
|
||||
for(var i=0;i<=4;i++){var y=pad.t+ch*i/4;svg+='<line x1="'+pad.l+'" x2="'+(W-pad.r)+'" y1="'+y+'" y2="'+y+'" stroke="var(--hairline-soft)" stroke-width="1"/>'}
|
||||
var barW=Math.max(2,cw/trend.length*.7),gap=cw/trend.length;
|
||||
for(var i=0;i<trend.length;i++){var d=trend[i],x=pad.l+i*gap+(gap-barW)/2,h=d.count/maxVal*ch,by=pad.t+ch-h;
|
||||
svg+='<rect x="'+x+'" y="'+by+'" width="'+barW+'" height="'+h+'" rx="1.5" fill="var(--blue)" opacity=".65"/>';
|
||||
if(i%7===0)svg+='<text x="'+(x+barW/2)+'" y="'+(pad.t+ch+15)+'" text-anchor="middle" font-size="9" fill="var(--stone)">'+d.day.slice(5)+'</text>'}
|
||||
[0,Math.round(maxVal/2),maxVal].forEach(function(v){var y=pad.t+ch-(v/maxVal*ch);svg+='<text x="'+(pad.l-5)+'" y="'+(y+3)+'" text-anchor="end" font-size="9" fill="var(--stone)">'+v+'</text>'});
|
||||
svg+='</svg>';wrap.innerHTML=svg;
|
||||
}
|
||||
|
||||
async function loadUsers(offset){
|
||||
userOffset=offset;var q=document.getElementById('userSearch').value.trim();
|
||||
document.getElementById('userTable').innerHTML='<tr><td colspan="7" style="text-align:center;padding:32px;color:var(--stone)">加载中...</td></tr>';
|
||||
try{
|
||||
var r=await fetch(API+'/api/admin/users?search='+encodeURIComponent(q)+'&offset='+offset+'&limit='+PAGE_SIZE+'&tab='+currentTab);
|
||||
if(!r.ok)throw new Error(r.status);
|
||||
var d=await r.json();userTotal=d.total;renderUsers(d.users);renderPagination();
|
||||
}catch(e){
|
||||
document.getElementById('userTable').innerHTML='<tr><td colspan="7" style="text-align:center;padding:32px;color:var(--red)">加载失败</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderUsers(users){
|
||||
var tb=document.getElementById('userTable');
|
||||
if(!users.length){tb.innerHTML='<tr><td colspan="7" style="text-align:center;padding:32px;color:var(--stone)">无匹配用户</td></tr>';return}
|
||||
tb.innerHTML=users.map(function(u){
|
||||
return '<tr>'+
|
||||
'<td style="font-weight:500">'+esc(u.email)+'</td>'+
|
||||
'<td>'+registrationBadge(u)+'</td>'+
|
||||
'<td>'+subscriptionBadge(u)+'</td>'+
|
||||
'<td style="color:var(--stone);font-size:12px">'+(u.subscription_end_at?fmtDate(u.subscription_end_at):'—')+'</td>'+
|
||||
'<td>'+(u.is_admin?'<span class="badge badge-yellow">管理员</span>':'<span class="badge badge-gray">用户</span>')+'</td>'+
|
||||
'<td style="color:var(--stone);font-size:12px">'+fmtDate(u.created_at)+'</td>'+
|
||||
'<td style="color:var(--stone);font-size:12px">'+(u.last_login_at?fmtDate(u.last_login_at):'—')+'</td>'+
|
||||
'</tr>';
|
||||
}).join('');
|
||||
}
|
||||
function renderPagination(){
|
||||
var pg=document.getElementById('pagination'),totalPages=Math.ceil(userTotal/PAGE_SIZE),cur=Math.floor(userOffset/PAGE_SIZE)+1;
|
||||
pg.innerHTML='<button '+(userOffset===0?'disabled':'')+' onclick="loadUsers('+(userOffset-PAGE_SIZE)+')">← 上一页</button>'+
|
||||
'<span>第 '+cur+' / '+Math.max(1,totalPages)+' 页 · 共 '+userTotal+' 用户</span>'+
|
||||
'<button '+((userOffset+PAGE_SIZE>=userTotal)?'disabled':'')+' onclick="loadUsers('+(userOffset+PAGE_SIZE)+')">下一页 →</button>';
|
||||
}
|
||||
|
||||
function registrationBadge(u){
|
||||
return'<span class="badge badge-green">已注册</span>';
|
||||
}
|
||||
function subscriptionBadge(u){
|
||||
var label=u.subscription_status_label||'未开通';
|
||||
var plan=u.subscription_plan_name||'未开通';
|
||||
var cls=label==='有效'?'badge-green':(label==='已过期'?'badge-red':'badge-gray');
|
||||
return '<span class="badge '+cls+'">'+esc(plan)+' · '+esc(label)+'</span>';
|
||||
}
|
||||
function switchOrderStatus(status){
|
||||
currentOrderStatus=status;orderOffset=0;
|
||||
document.querySelectorAll('.order-tab').forEach(function(b){b.classList.toggle('active',b.dataset.status===status)});
|
||||
loadOrders(0);
|
||||
}
|
||||
|
||||
async function loadOrders(offset){
|
||||
orderOffset=offset;var q=document.getElementById('orderSearch').value.trim();
|
||||
document.getElementById('orderTable').innerHTML='<tr><td colspan="8" style="text-align:center;padding:32px;color:var(--stone)">加载中...</td></tr>';
|
||||
try{
|
||||
var r=await fetch(API+'/api/admin/orders?search='+encodeURIComponent(q)+'&offset='+offset+'&limit='+ORDER_PAGE_SIZE+'&status='+currentOrderStatus);
|
||||
if(!r.ok)throw new Error(r.status);
|
||||
var d=await r.json();orderTotal=d.total;renderOrders(d.orders);renderOrderPagination();
|
||||
}catch(e){
|
||||
document.getElementById('orderTable').innerHTML='<tr><td colspan="8" style="text-align:center;padding:32px;color:var(--red)">加载失败</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderOrders(orders){
|
||||
var tb=document.getElementById('orderTable');
|
||||
if(!orders.length){tb.innerHTML='<tr><td colspan="8" style="text-align:center;padding:32px;color:var(--stone)">无匹配订单</td></tr>';return}
|
||||
tb.innerHTML=orders.map(function(o){
|
||||
return '<tr>'+
|
||||
'<td style="font-weight:700">#'+esc(o.id)+'</td>'+
|
||||
'<td>'+esc(o.email)+'</td>'+
|
||||
'<td>'+esc(o.plan_name||o.plan_code)+'</td>'+
|
||||
'<td style="font-weight:700">'+Number(o.amount_usdt||0).toFixed(0)+' USDT</td>'+
|
||||
'<td>'+orderStatusBadge(o.status)+'</td>'+
|
||||
'<td style="color:var(--stone);font-size:12px">'+esc(o.chain||'')+(o.txid?' · '+esc(shortText(o.txid,18)):' · —')+'</td>'+
|
||||
'<td style="color:var(--stone);font-size:12px">'+fmtDate(o.created_at)+'</td>'+
|
||||
'<td>'+(o.subscription_id?'<span class="badge badge-green">订阅 #'+esc(o.subscription_id)+'</span>':'<span class="badge badge-gray">未关联</span>')+'</td>'+
|
||||
'</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderOrderPagination(){
|
||||
var pg=document.getElementById('orderPagination'),totalPages=Math.ceil(orderTotal/ORDER_PAGE_SIZE),cur=Math.floor(orderOffset/ORDER_PAGE_SIZE)+1;
|
||||
pg.innerHTML='<button '+(orderOffset===0?'disabled':'')+' onclick="loadOrders('+(orderOffset-ORDER_PAGE_SIZE)+')">← 上一页</button>'+
|
||||
'<span>第 '+cur+' / '+Math.max(1,totalPages)+' 页 · 共 '+orderTotal+' 订单</span>'+
|
||||
'<button '+((orderOffset+ORDER_PAGE_SIZE>=orderTotal)?'disabled':'')+' onclick="loadOrders('+(orderOffset+ORDER_PAGE_SIZE)+')">下一页 →</button>';
|
||||
}
|
||||
|
||||
function orderStatusBadge(status){
|
||||
var cls=(status==='paid'||status==='confirmed')?'badge-green':(status==='pending'?'badge-yellow':(status==='expired'||status==='cancelled'?'badge-red':'badge-gray'));
|
||||
var map={pending:'待支付',paid:'已支付',confirmed:'已确认',expired:'已过期',cancelled:'已取消'};
|
||||
return '<span class="badge '+cls+'">'+esc(map[status]||status||'—')+'</span>';
|
||||
}
|
||||
function shortText(s,n){s=String(s||'');return s.length>n?s.slice(0,n)+'…':s}
|
||||
|
||||
function esc(s){return String(s||'').replace(/[&<>"]/g,function(c){return{'&':'&','<':'<','>':'>','"':'"'}[c]})}
|
||||
function fmtDate(ts){if(!ts)return'—';var m=String(ts).match(/^(\d{4})-(\d{2})-(\d{2})/);return m?m[2]+'/'+m[3]:ts.slice(0,10)}
|
||||
|
||||
init();
|
||||
</script>
|
||||
{% endblock %}
|
||||
1002
static/app.html
Normal file
1002
static/app.html
Normal file
File diff suppressed because it is too large
Load Diff
308
static/auth.html
Normal file
308
static/auth.html
Normal file
@ -0,0 +1,308 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>登录 / 注册 — AlphaX</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--yellow: #ffd02f; --yellow-light: #fff4c4; --yellow-dark: #746019;
|
||||
--blue: #4262ff; --green: #00b473; --red: #e53e3e;
|
||||
--primary: #1c1c1e; --on-primary: #ffffff; --canvas: #ffffff;
|
||||
--surface: #f7f8fa; --surface-soft: #fafbfc;
|
||||
--hairline: #e0e2e8; --hairline-soft: #eef0f3; --hairline-strong: #c7cad5;
|
||||
--ink: #1c1c1e; --ink-deep: #050038;
|
||||
--charcoal: #2c2c34; --slate: #555a6a; --steel: #6b6f7e; --stone: #8e91a0; --muted: #a5a8b5;
|
||||
--radius-xs:4px; --radius-sm:6px; --radius-md:8px; --radius-lg:12px; --radius-xl:16px; --radius-full:9999px;
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
html { overflow-x: hidden; }
|
||||
body {
|
||||
min-height: 100vh; overflow-x: hidden;
|
||||
font-family: 'Noto Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: var(--ink); background: var(--canvas);
|
||||
line-height: 1.5; -webkit-font-smoothing: antialiased; text-size-adjust: 100%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding-bottom: var(--safe-bottom);
|
||||
}
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
.page { width: 100%; max-width: 420px; padding: 48px 32px; }
|
||||
|
||||
/* Brand — DESIGN.md top-nav pattern: yellow mark + wordmark */
|
||||
.brand { display: flex; align-items: center; gap: 8px; margin-bottom: 40px; }
|
||||
.brand-mark { width: 24px; height: 24px; background: var(--yellow); border-radius: 6px; display: grid; place-items: center; }
|
||||
.brand-mark::after { content: ""; width: 8px; height: 8px; border: 1.5px solid var(--primary); border-radius: 50%; box-shadow: 6px -4px 0 -2px var(--primary); }
|
||||
.brand-name { font-weight: 500; font-size: 16px; letter-spacing: -.2px; }
|
||||
|
||||
/* Tab pills — DESIGN.md pill-tab pattern */
|
||||
.tabs { display: grid; grid-template-columns: 1fr 1fr; padding: 4px; background: var(--surface); border-radius: var(--radius-full); margin-bottom: 32px; }
|
||||
.tab { border: 0; background: transparent; color: var(--steel); padding: 10px 16px; border-radius: var(--radius-full); font-weight: 500; font-size: 14px; cursor: pointer; transition: .15s; line-height: 1.3; }
|
||||
.tab.active { background: var(--primary); color: var(--on-primary); }
|
||||
|
||||
.form { display: none; flex-direction: column; gap: 16px; animation: rise .2s ease; }
|
||||
.form.active { display: flex; }
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
/* DESIGN.md text-input pattern */
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field label { font-size: 13px; font-weight: 600; color: var(--slate); }
|
||||
.input-wrap { position: relative; width: 100%; }
|
||||
.input-wrap input, .field input {
|
||||
width: 100%; height: 44px; border: 1px solid var(--hairline-strong); border-radius: var(--radius-md);
|
||||
padding: 0 14px; font-size: 16px; color: var(--ink); background: var(--canvas);
|
||||
outline: none; transition: border .15s; -webkit-appearance: none;
|
||||
}
|
||||
.input-wrap input:focus, .field input:focus { border: 2px solid var(--blue); }
|
||||
.input-wrap input::placeholder, .field input::placeholder { color: var(--muted); }
|
||||
|
||||
/* Email + send-code row */
|
||||
.field-row { display: flex; gap: 8px; align-items: flex-end; }
|
||||
.field-row .field { flex: 1; }
|
||||
.btn-send {
|
||||
flex-shrink: 0; height: 44px; padding: 0 20px;
|
||||
background: var(--primary); color: var(--on-primary);
|
||||
border: 0; border-radius: var(--radius-full);
|
||||
font-weight: 500; font-size: 14px; cursor: pointer;
|
||||
transition: background .15s, transform .15s;
|
||||
white-space: nowrap; -webkit-tap-highlight-color: transparent;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.btn-send:active { background: var(--charcoal); transform: scale(.97); }
|
||||
.btn-send:disabled { background: var(--hairline); color: var(--muted); cursor: not-allowed; }
|
||||
|
||||
/* Password toggle */
|
||||
.pwd-toggle {
|
||||
position: absolute; right: 0; top: 0; height: 44px; width: 44px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border: 0; background: transparent; cursor: pointer; color: var(--stone); padding: 0;
|
||||
transition: color .15s; -webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.pwd-toggle:active { color: var(--slate); }
|
||||
.pwd-toggle svg { width: 20px; height: 20px; }
|
||||
|
||||
/* DESIGN.md button-primary pattern */
|
||||
.btn-primary {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 100%; height: 44px;
|
||||
background: var(--primary); color: var(--on-primary);
|
||||
border: 0; border-radius: var(--radius-full);
|
||||
font-weight: 500; font-size: 14px; cursor: pointer;
|
||||
transition: background .15s, transform .15s;
|
||||
-webkit-tap-highlight-color: transparent; margin-top: 4px;
|
||||
}
|
||||
.btn-primary:active { background: var(--charcoal); transform: scale(.97); }
|
||||
|
||||
/* DESIGN.md badge-tag-yellow for notice */
|
||||
.notice {
|
||||
background: var(--yellow-light); color: var(--yellow-dark);
|
||||
border-radius: var(--radius-md); padding: 10px 14px;
|
||||
font-size: 13px; line-height: 1.5;
|
||||
}
|
||||
.resend-hint { font-size: 12px; color: var(--stone); margin-top: -10px; }
|
||||
.resend-hint a { color: var(--blue); font-weight: 500; cursor: pointer; }
|
||||
.code-sent-badge {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 13px; color: var(--green); font-weight: 600; margin-top: -8px;
|
||||
}
|
||||
.code-sent-badge svg { width: 16px; height: 16px; }
|
||||
.msg { font-size: 13px; line-height: 1.5; min-height: 20px; }
|
||||
.msg.ok { color: var(--green); } .msg.err { color: var(--red); } .msg.warn { color: var(--yellow-dark); }
|
||||
|
||||
/* DESIGN.md button-link for back */
|
||||
.back-link { display: block; text-align: center; margin-top: 36px; padding-bottom: 24px; font-size: 13px; color: var(--stone); transition: color .15s; }
|
||||
.back-link:hover { color: var(--slate); }
|
||||
|
||||
/* ====== MOBILE ====== */
|
||||
@media (max-width: 480px) {
|
||||
.page { padding: 32px 20px 48px; }
|
||||
.brand { margin-bottom: 32px; }
|
||||
.tabs { margin-bottom: 28px; }
|
||||
.field-row .field { flex: 1; min-width: 0; }
|
||||
.btn-send { flex-shrink: 0; padding: 0 16px; font-size: 13px; }
|
||||
.back-link { margin-top: 32px; padding-bottom: calc(24px + var(--safe-bottom)); }
|
||||
}
|
||||
@media (max-width: 360px) {
|
||||
.page { padding: 24px 16px 40px; }
|
||||
.brand { margin-bottom: 24px; }
|
||||
.tabs { margin-bottom: 24px; }
|
||||
.tab { padding: 8px 12px; font-size: 13px; }
|
||||
.input-wrap input, .field input { height: 44px; font-size: 15px; }
|
||||
.btn-send { height: 44px; font-size: 13px; }
|
||||
.btn-primary { height: 44px; font-size: 15px; }
|
||||
.pwd-toggle { height: 44px; width: 44px; }
|
||||
.form { gap: 14px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="page">
|
||||
<a class="brand" href="/">
|
||||
<span class="brand-mark"></span>
|
||||
<span class="brand-name">AlphaX</span>
|
||||
</a>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" id="tabRegister" onclick="setTab('register')">注册</button>
|
||||
<button class="tab" id="tabLogin" onclick="setTab('login')">登录</button>
|
||||
</div>
|
||||
|
||||
<!-- ===== 注册表单 ===== -->
|
||||
<div id="registerForm" class="form active">
|
||||
<div class="field">
|
||||
<label>邮箱</label>
|
||||
<input id="regEmail" type="email" placeholder="you@example.com" autocomplete="email" inputmode="email">
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>邮箱验证码</label>
|
||||
<input id="verifyCode" type="text" inputmode="numeric" autocomplete="one-time-code" placeholder="输入 6 位验证码" maxlength="6">
|
||||
</div>
|
||||
<button class="btn-send" id="sendCodeBtn" onclick="sendCode()">发送验证码</button>
|
||||
</div>
|
||||
|
||||
<div id="codeSentBadge" class="code-sent-badge" style="display:none">
|
||||
<svg viewBox="0 0 16 16" fill="none"><path d="M3 8l3.5 3.5L13 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
验证码已发送至邮箱
|
||||
</div>
|
||||
<div class="resend-hint" id="resendHint" style="display:none">
|
||||
没收到?<a onclick="resendCode()">重新发送</a>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>设置密码</label>
|
||||
<div class="input-wrap">
|
||||
<input id="regPassword" type="password" placeholder="至少 8 位,含字母和数字" autocomplete="new-password">
|
||||
<button class="pwd-toggle" id="regPwdToggle" onclick="togglePwd('regPassword', 'regPwdToggle')" type="button" aria-label="显示密码">
|
||||
<svg id="regPwdEye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>邀请码</label>
|
||||
<input id="regInvite" type="text" placeholder="请输入邀请码" autocomplete="off" required>
|
||||
</div>
|
||||
|
||||
<button class="btn-primary" onclick="doRegister()">注册</button>
|
||||
<div id="regMsg" class="msg"></div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 登录表单 ===== -->
|
||||
<div id="loginForm" class="form">
|
||||
<div class="field">
|
||||
<label>邮箱</label>
|
||||
<input id="loginEmail" type="email" placeholder="you@example.com" autocomplete="email" inputmode="email">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>密码</label>
|
||||
<div class="input-wrap">
|
||||
<input id="loginPassword" type="password" placeholder="输入密码" autocomplete="current-password">
|
||||
<button class="pwd-toggle" id="loginPwdToggle" onclick="togglePwd('loginPassword', 'loginPwdToggle')" type="button" aria-label="显示密码">
|
||||
<svg id="loginPwdEye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-primary" onclick="loginUser()">登录</button>
|
||||
<div id="loginMsg" class="msg"></div>
|
||||
</div>
|
||||
|
||||
<a class="back-link" href="/">← 返回首页</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function $(id){ return document.getElementById(id); }
|
||||
function setMsg(id, text, cls){ var el=$(id); el.className='msg '+(cls||''); el.textContent=text||''; }
|
||||
function setTab(tab){
|
||||
document.querySelectorAll('.tab').forEach(function(b,i){ b.classList.toggle('active', (tab==='register'&&i===0)||(tab==='login'&&i===1)); });
|
||||
$('registerForm').classList.toggle('active', tab==='register');
|
||||
$('loginForm').classList.toggle('active', tab==='login');
|
||||
}
|
||||
async function post(url, body){
|
||||
var r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})});
|
||||
var data=await r.json().catch(function(){ return {detail:'请求失败'}; });
|
||||
if(!r.ok) throw new Error(data.detail||'请求失败');
|
||||
return data;
|
||||
}
|
||||
function togglePwd(inputId, toggleId) {
|
||||
var inp = $(inputId);
|
||||
var eye = $(inputId === 'regPassword' ? 'regPwdEye' : 'loginPwdEye');
|
||||
var isPass = inp.type === 'password';
|
||||
inp.type = isPass ? 'text' : 'password';
|
||||
if (isPass) {
|
||||
eye.innerHTML = '<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>';
|
||||
} else {
|
||||
eye.innerHTML = '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>';
|
||||
}
|
||||
}
|
||||
var codeSent = false;
|
||||
(function(){
|
||||
var m = location.search.match(/[?&]invite=([^&]+)/);
|
||||
if(m){ $('regInvite').value = decodeURIComponent(m[1]); }
|
||||
})();
|
||||
async function sendCode(){
|
||||
try{
|
||||
var email = $('regEmail').value;
|
||||
if(!email){ setMsg('regMsg','请先输入邮箱','err'); return; }
|
||||
var data = await post('/api/auth/send-code',{email:email});
|
||||
if(data.dev_verification_code) $('verifyCode').value = data.dev_verification_code;
|
||||
$('codeSentBadge').style.display = 'flex';
|
||||
$('resendHint').style.display = 'block';
|
||||
codeSent = true;
|
||||
var devMsg = data.dev_verification_code ? ' 开发环境验证码:'+data.dev_verification_code : '';
|
||||
setMsg('regMsg', '验证码已发送至 '+email+devMsg,'ok');
|
||||
var btn = $('sendCodeBtn'), sec = 60;
|
||||
btn.disabled = true; btn.textContent = sec+'s';
|
||||
var timer = setInterval(function(){
|
||||
sec--; btn.textContent = sec+'s';
|
||||
if(sec<=0){ clearInterval(timer); btn.disabled = false; btn.textContent = '发送验证码'; }
|
||||
}, 1000);
|
||||
}catch(e){ setMsg('regMsg', e.message, 'err'); }
|
||||
}
|
||||
async function doRegister(){
|
||||
try{
|
||||
if(!codeSent){ setMsg('regMsg','请先点击「发送验证码」获取邮箱验证码','err'); return; }
|
||||
var pwd = $('regPassword').value;
|
||||
if(!pwd || pwd.length < 8){ setMsg('regMsg','密码至少 8 位','err'); return; }
|
||||
var code = $('verifyCode').value;
|
||||
if(!code){ setMsg('regMsg','请输入验证码','err'); return; }
|
||||
var invite = $('regInvite').value.trim();
|
||||
if(!invite){ setMsg('regMsg','请输入邀请码','err'); return; }
|
||||
var data = await post('/api/auth/complete-registration',{
|
||||
email: $('regEmail').value,
|
||||
code: code,
|
||||
password: pwd,
|
||||
invite_code: invite
|
||||
});
|
||||
setMsg('regMsg', data.message||'注册成功,正在跳转……','ok');
|
||||
$('loginEmail').value = $('regEmail').value;
|
||||
setTimeout(function(){
|
||||
setTab('login');
|
||||
setMsg('loginMsg','注册成功,请登录','ok');
|
||||
}, 600);
|
||||
}catch(e){ setMsg('regMsg', e.message, 'err'); }
|
||||
}
|
||||
async function resendCode(){
|
||||
try{
|
||||
var data = await post('/api/auth/resend-verification',{email:$('regEmail').value});
|
||||
if(data.dev_verification_code) $('verifyCode').value = data.dev_verification_code;
|
||||
setMsg('regMsg', data.dev_verification_code ? '验证码:'+data.dev_verification_code : '验证码已重新发送','ok');
|
||||
}catch(e){ setMsg('regMsg', e.message, 'err'); }
|
||||
}
|
||||
async function loginUser(){
|
||||
try{
|
||||
var data = await post('/api/auth/login',{email:$('loginEmail').value,password:$('loginPassword').value});
|
||||
var next = data.next || (data.subscription_active ? '/app' : '/subscription?welcome=1');
|
||||
setMsg('loginMsg', data.subscription_active ? '登录成功,正在进入看板…' : '登录成功,先开通免费体验套餐…','ok');
|
||||
setTimeout(function(){ window.location.href = next; }, 500);
|
||||
}catch(e){ setMsg('loginMsg', e.message, 'err'); }
|
||||
}
|
||||
(function(){
|
||||
if (location.search.indexOf('tab=login') !== -1) setTab('login');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
290
static/base.html
Normal file
290
static/base.html
Normal file
@ -0,0 +1,290 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
|
||||
<title>{% block title %}AlphaX{% endblock %}</title>
|
||||
<style>
|
||||
/* ===== DESIGN.md Miro Tokens ===== */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--yellow: #ffd02f; --yellow-deep: #fcb900; --yellow-light: #fff4c4; --yellow-dark: #746019;
|
||||
--blue: #4262ff;
|
||||
--green: #00b473; --green-light: rgba(0,180,115,.08);
|
||||
--red: #e53e3e; --red-light: rgba(229,62,62,.08);
|
||||
--primary: #1c1c1e; --on-primary: #ffffff; --canvas: #ffffff;
|
||||
--surface: #f7f8fa;
|
||||
--hairline: #e0e2e8; --hairline-soft: #eef0f3; --hairline-strong: #c7cad5;
|
||||
--ink: #1c1c1e; --ink-deep: #050038;
|
||||
--slate: #555a6a; --steel: #6b6f7e; --stone: #8e91a0; --muted: #a5a8b5;
|
||||
--shadow: rgba(5,0,56,.08) 0px 12px 32px -4px;
|
||||
--radius-sm:6px; --radius-md:8px; --radius-lg:12px; --radius-xl:16px; --radius-full:9999px;
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--sidebar-w: 220px;
|
||||
--app-vh: 100vh;
|
||||
}
|
||||
{% block theme_override %}{% endblock %}
|
||||
html { overflow-x: hidden; }
|
||||
body {
|
||||
min-height: 100vh; overflow-x: hidden;
|
||||
font-family: 'Noto Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: var(--ink); background: var(--surface);
|
||||
line-height: 1.5; -webkit-font-smoothing: antialiased; text-size-adjust: 100%;
|
||||
padding-bottom: var(--safe-bottom);
|
||||
display: block;
|
||||
}
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
/* ===== SIDEBAR ===== */
|
||||
.sidebar {
|
||||
position: fixed; left: 0; top: 0; width: var(--sidebar-w); height: var(--app-vh); max-height: var(--app-vh);
|
||||
background: var(--canvas); border-right: 1px solid var(--hairline-soft);
|
||||
display: flex; flex-direction: column; z-index: 100;
|
||||
transition: transform .25s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
.sidebar-brand {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 18px 20px; border-bottom: 1px solid var(--hairline-soft);
|
||||
}
|
||||
.brand-mark { width: 22px; height: 22px; background: var(--yellow); border-radius: 5px; display: grid; place-items: center; flex-shrink: 0; }
|
||||
.brand-mark::after { content: ""; width: 7px; height: 7px; border: 1.5px solid var(--primary); border-radius: 50%; box-shadow: 5px -3px 0 -1.5px var(--primary); }
|
||||
.brand-name { font-weight: 600; font-size: 14px; letter-spacing: -.2px; }
|
||||
.beta-badge { display:inline-flex; align-items:center; height:19px; padding:0 7px; border-radius:var(--radius-full); background:var(--surface); border:1px solid var(--hairline); color:var(--steel); font-size:10px; font-weight:700; line-height:1; }
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1 1 auto; min-height: 0; padding: 8px; display: flex; flex-direction: column; gap: 2px;
|
||||
overflow-y: auto; -webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.sidebar-link {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 14px; border-radius: var(--radius-md);
|
||||
font-size: 14px; font-weight: 500; color: var(--steel);
|
||||
transition: .15s; cursor: pointer;
|
||||
}
|
||||
.sidebar-link:hover { color: var(--ink); background: var(--surface); }
|
||||
.sidebar-link.active { color: var(--on-primary); background: var(--primary); font-weight: 600; }
|
||||
.sidebar-link .link-icon { width: 18px; height: 18px; flex-shrink: 0; opacity: .6; }
|
||||
.sidebar-link.active .link-icon { opacity: 1; }
|
||||
|
||||
.sidebar-user {
|
||||
padding: 14px 16px calc(14px + var(--safe-bottom)); border-top: 1px solid var(--hairline-soft);
|
||||
display: flex; align-items: center; gap: 8px; cursor: pointer;
|
||||
font-size: 13px; color: var(--slate); transition: .15s; flex-shrink: 0;
|
||||
}
|
||||
.sidebar-user:hover { background: var(--surface); }
|
||||
.user-avatar { width: 28px; height: 28px; border-radius: 50%; background: var(--yellow); color: var(--primary); display: grid; place-items: center; font-weight: 700; font-size: 12px; flex-shrink: 0; }
|
||||
.user-email { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.user-chevron { width: 12px; height: 12px; opacity: .4; flex-shrink: 0; }
|
||||
|
||||
/* User dropdown (attached to sidebar user) */
|
||||
.user-dropdown {
|
||||
display: none; position: absolute; left: 16px; bottom: 60px;
|
||||
background: var(--canvas); border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-lg); box-shadow: var(--shadow);
|
||||
min-width: 188px; padding: 4px; z-index: 200;
|
||||
}
|
||||
.user-dropdown.show { display: block; }
|
||||
.user-dropdown .dd-email { padding: 10px 14px; font-size: 12px; color: var(--stone); border-bottom: 1px solid var(--hairline-soft); margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dd-item { display: block; width: 100%; padding: 8px 14px; border: 0; background: transparent; font-size: 13px; color: var(--slate); text-align: left; cursor: pointer; border-radius: var(--radius-sm); transition: .1s; }
|
||||
.dd-item:hover { background: var(--surface); color: var(--ink); }
|
||||
.dd-item.danger { color: var(--red); }
|
||||
.dd-item.danger:hover { background: var(--red-light); }
|
||||
|
||||
/* ===== MAIN CONTENT ===== */
|
||||
.main-content { margin-left: var(--sidebar-w); min-width: 0; min-height: 100vh; overflow-y: auto; }
|
||||
|
||||
/* ===== MOBILE ===== */
|
||||
.hamburger {
|
||||
display: none; position: fixed; top: 12px; left: 12px; z-index: 110;
|
||||
width: 36px; height: 36px; border-radius: var(--radius-md);
|
||||
background: var(--canvas); border: 1px solid var(--hairline);
|
||||
align-items: center; justify-content: center; cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.06);
|
||||
}
|
||||
.hamburger span { display: block; width: 16px; height: 1.5px; background: var(--ink); position: relative; transition: .2s; }
|
||||
.hamburger span::before, .hamburger span::after { content: ""; display: block; width: 16px; height: 1.5px; background: var(--ink); position: absolute; transition: .2s; }
|
||||
.hamburger span::before { top: -5px; }
|
||||
.hamburger span::after { top: 5px; }
|
||||
|
||||
.sidebar-overlay {
|
||||
display: none; position: fixed; inset: 0; background: rgba(0,0,0,.35);
|
||||
z-index: 99; transition: opacity .25s;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { transform: translateX(-100%); z-index: 100; height: var(--app-vh); max-height: var(--app-vh); }
|
||||
.sidebar.open { transform: translateX(0); }
|
||||
.sidebar-overlay.open { display: block; }
|
||||
.hamburger { display: flex; }
|
||||
.main-content { margin-left: 0; padding-top: 48px; }
|
||||
}
|
||||
|
||||
/* ===== MODAL ===== */
|
||||
.modal-overlay { display: none; position: fixed; inset: 0; z-index: 300; background: rgba(0,0,0,.35); align-items: center; justify-content: center; }
|
||||
.modal-overlay.show { display: flex; }
|
||||
.modal { background: var(--canvas); border-radius: var(--radius-xl); padding: 32px; width: 100%; max-width: 380px; box-shadow: var(--shadow); }
|
||||
.modal h3 { font-size: 20px; font-weight: 600; margin-bottom: 20px; }
|
||||
.modal .field { margin-bottom: 14px; }
|
||||
.modal .field label { display: block; font-size: 13px; font-weight: 600; color: var(--slate); margin-bottom: 6px; }
|
||||
.modal .field input { width: 100%; height: 42px; border: 1px solid var(--hairline-strong); border-radius: var(--radius-md); padding: 0 14px; font-size: 14px; outline: none; }
|
||||
.modal .field input:focus { border-color: var(--blue); }
|
||||
.modal-actions { display: flex; gap: 8px; margin-top: 20px; }
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 4px; border:0; cursor:pointer; font-weight:500; font-size:14px; line-height:1.3; transition: background .15s, transform .15s; border-radius: var(--radius-full); }
|
||||
.btn:active { transform: scale(.98); }
|
||||
.btn-primary { background: var(--primary); color: var(--on-primary); padding: 10px 20px; flex: 1; }
|
||||
.btn-secondary { background: transparent; color: var(--ink); border: 1px solid var(--hairline-strong); padding: 10px 20px; flex: 1; }
|
||||
.modal-msg { font-size: 13px; min-height: 20px; margin-top: 8px; }
|
||||
.modal-msg.ok { color: var(--green); } .modal-msg.err { color: var(--red); }
|
||||
</style>
|
||||
{% block extra_head_css %}{% endblock %}
|
||||
{% block extra_style %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
{% if show_nav | default(True) %}
|
||||
<svg style="display:none" aria-hidden="true">
|
||||
<symbol id="svg-win" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6L9 17l-5-5" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="svg-lose" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18" stroke-linecap="round"/><line x1="6" y1="6" x2="18" y2="18" stroke-linecap="round"/></symbol>
|
||||
<symbol id="svg-target" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></symbol>
|
||||
<symbol id="svg-trendup" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18" stroke-linecap="round" stroke-linejoin="round"/><polyline points="17 6 23 6 23 12" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="svg-star" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></symbol>
|
||||
<symbol id="svg-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></symbol>
|
||||
<symbol id="svg-spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" stroke-dasharray="31.4 31.4" stroke-linecap="round"/></symbol>
|
||||
<symbol id="svg-dashboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></symbol>
|
||||
<symbol id="svg-iterate" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></symbol>
|
||||
<symbol id="svg-sentiment" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14z"/><path d="M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></symbol>
|
||||
<symbol id="svg-subscribe" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></symbol>
|
||||
<symbol id="svg-admin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M5.3 20h13.4c1.1 0 2-.9 2-2 0-3.3-2.7-6-6-6H9.3c-3.3 0-6 2.7-6 6 0 1.1.9 2 2 2z"/></symbol>
|
||||
<symbol id="svg-referral" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><polyline points="17 11 19 13 23 9"/></symbol>
|
||||
<symbol id="svg-chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></symbol>
|
||||
</svg>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<a class="sidebar-brand" href="/" aria-label="返回 AlphaX 首页">
|
||||
<span class="brand-mark"></span>
|
||||
<span class="brand-name">AlphaX</span>
|
||||
<span class="beta-badge">Beta</span>
|
||||
</a>
|
||||
<nav class="sidebar-nav">
|
||||
{% block nav_links %}
|
||||
<a class="sidebar-link active" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||
<a class="sidebar-link" href="/watchlist"><svg class="link-icon"><use href="#svg-star"/></svg>关注</a>
|
||||
<a class="sidebar-link" href="/strategy"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||
<a class="sidebar-link" href="/iteration"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||
{% endblock %}
|
||||
</nav>
|
||||
<div class="sidebar-user" onclick="toggleUserMenu()">
|
||||
<span class="user-avatar" id="userInitial">?</span>
|
||||
<span class="user-email" id="userEmailShort">--</span>
|
||||
<svg class="user-chevron"><use href="#svg-chevron-down"/></svg>
|
||||
</div>
|
||||
<div class="user-dropdown" id="userDropdown">
|
||||
<div class="dd-email" id="ddEmail">--</div>
|
||||
<button class="dd-item" onclick="showChangePwd()">修改密码</button>
|
||||
<button class="dd-item danger" onclick="doLogout()">退出登录</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile overlay + hamburger -->
|
||||
<div class="sidebar-overlay" id="sidebarOverlay" onclick="closeSidebar()"></div>
|
||||
<button class="hamburger" id="hamburger" onclick="toggleSidebar()"><span></span></button>
|
||||
{% endif %}
|
||||
|
||||
<div class="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
{% if show_nav | default(True) %}
|
||||
{% block password_modal %}
|
||||
<div class="modal-overlay" id="pwdModal">
|
||||
<div class="modal">
|
||||
<h3>修改密码</h3>
|
||||
<div class="field"><label>旧密码</label><input id="oldPwd" type="password" placeholder="输入当前密码"></div>
|
||||
<div class="field"><label>新密码</label><input id="newPwd" type="password" placeholder="至少 8 位"></div>
|
||||
<div class="field"><label>确认新密码</label><input id="cfmPwd" type="password" placeholder="再次输入新密码"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" onclick="closePwdModal()">取消</button>
|
||||
<button class="btn btn-primary" onclick="changePwd()">确认修改</button>
|
||||
</div>
|
||||
<div class="modal-msg" id="pwdMsg"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
<script>
|
||||
// ====== AUTH (shared) ======
|
||||
var API = '';
|
||||
var currentUser = null;
|
||||
var $ = function(id){ return document.getElementById(id); };
|
||||
function setAppViewportHeight() {
|
||||
document.documentElement.style.setProperty('--app-vh', (window.innerHeight || document.documentElement.clientHeight) + 'px');
|
||||
}
|
||||
setAppViewportHeight();
|
||||
window.addEventListener('resize', setAppViewportHeight);
|
||||
window.addEventListener('orientationchange', function(){ setTimeout(setAppViewportHeight, 250); });
|
||||
|
||||
async function loadUser() {
|
||||
try {
|
||||
var resp = await fetch(API + '/api/auth/me');
|
||||
if (!resp.ok) return;
|
||||
var data = await resp.json();
|
||||
currentUser = data.user;
|
||||
var email = currentUser.email || '--';
|
||||
$('userInitial').textContent = email.charAt(0).toUpperCase();
|
||||
$('userEmailShort').textContent = email.length > 16 ? email.slice(0,14) + '\u2026' : email;
|
||||
$('ddEmail').textContent = email;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function toggleUserMenu() { $('userDropdown').classList.toggle('show'); }
|
||||
document.addEventListener('click', function(e) { if (!e.target.closest('.sidebar-user') && !e.target.closest('.user-dropdown')) $('userDropdown').classList.remove('show'); });
|
||||
|
||||
function showChangePwd() { $('userDropdown').classList.remove('show'); $('pwdModal').classList.add('show'); }
|
||||
function closePwdModal() { $('pwdModal').classList.remove('show'); $('pwdMsg').textContent=''; $('pwdMsg').className='modal-msg'; }
|
||||
|
||||
async function changePwd() {
|
||||
var o=$('oldPwd').value,n=$('newPwd').value,c=$('cfmPwd').value;
|
||||
if(n!==c){ $('pwdMsg').textContent='两次密码不一致'; $('pwdMsg').className='modal-msg err'; return; }
|
||||
try{
|
||||
var r=await fetch(API+'/api/auth/change-password',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({old_password:o,new_password:n})});
|
||||
var d=await r.json();
|
||||
if(r.ok){ $('pwdMsg').textContent='密码已修改'; $('pwdMsg').className='modal-msg ok'; setTimeout(closePwdModal,1500); }
|
||||
else { $('pwdMsg').textContent=d.detail||'修改失败'; $('pwdMsg').className='modal-msg err'; }
|
||||
}catch(e){ $('pwdMsg').textContent='网络错误'; $('pwdMsg').className='modal-msg err'; }
|
||||
}
|
||||
|
||||
async function doLogout() {
|
||||
try{ await fetch(API+'/api/auth/logout',{method:'POST'}); }catch(e){}
|
||||
window.location.href='/auth';
|
||||
}
|
||||
|
||||
// Mobile sidebar
|
||||
function toggleSidebar() {
|
||||
$('sidebar').classList.toggle('open');
|
||||
$('sidebarOverlay').classList.toggle('open');
|
||||
}
|
||||
function closeSidebar() {
|
||||
$('sidebar').classList.remove('open');
|
||||
$('sidebarOverlay').classList.remove('open');
|
||||
}
|
||||
|
||||
// Admin check
|
||||
fetch(API+'/api/admin/check').then(function(r){return r.json()}).then(function(d){
|
||||
if(d&&d.is_admin){
|
||||
var links=document.querySelectorAll('.admin-link');
|
||||
for(var i=0;i<links.length;i++)links[i].style.display='';
|
||||
}
|
||||
}).catch(function(){});
|
||||
|
||||
loadUser();
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
{% block extra_script %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
228
static/index.html
Normal file
228
static/index.html
Normal file
@ -0,0 +1,228 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>AlphaX</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--yellow: #ffd02f; --yellow-light: #fff4c4; --yellow-dark: #746019;
|
||||
--blue: #4262ff;
|
||||
--teal: #0fbcb0; --teal-light: #c3faf5; --moss-dark: #187574;
|
||||
--coral: #ff9999; --coral-light: #ffc6c6; --coral-dark: #600000;
|
||||
--primary: #1c1c1e; --on-primary: #ffffff; --canvas: #ffffff;
|
||||
--surface: #f7f8fa; --surface-soft: #fafbfc;
|
||||
--hairline: #e0e2e8; --hairline-soft: #eef0f3; --hairline-strong: #c7cad5;
|
||||
--ink: #1c1c1e; --ink-deep: #050038;
|
||||
--charcoal: #2c2c34; --slate: #555a6a; --steel: #6b6f7e; --stone: #8e91a0; --muted: #a5a8b5;
|
||||
--shadow: rgba(5,0,56,.08) 0px 12px 32px -4px;
|
||||
--radius-xs:4px; --radius-sm:6px; --radius-md:8px; --radius-lg:12px; --radius-xl:16px; --radius-xxxl:28px; --radius-full:9999px;
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
html { overflow-x: hidden; height: 100%; }
|
||||
body {
|
||||
height: 100%; overflow-x: hidden;
|
||||
font-family: 'Noto Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: var(--ink); background: var(--canvas);
|
||||
line-height: 1.5; -webkit-font-smoothing: antialiased; text-size-adjust: 100%;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
/* User menu (from app.html nav pattern) */
|
||||
.user-menu { position: relative; }
|
||||
.user-trigger {
|
||||
display: flex; align-items: center; gap: 6px; padding: 6px 12px;
|
||||
border: 1px solid var(--hairline); border-radius: var(--radius-full);
|
||||
font-size: 13px; color: var(--slate); cursor: pointer; transition: .15s;
|
||||
background: var(--canvas); line-height: 1.4;
|
||||
}
|
||||
.user-trigger:hover { border-color: var(--hairline-strong); }
|
||||
.user-avatar {
|
||||
width: 26px; height: 26px; border-radius: 50%;
|
||||
background: var(--yellow); color: var(--primary);
|
||||
display: grid; place-items: center; font-weight: 700; font-size: 12px;
|
||||
}
|
||||
.user-dropdown {
|
||||
display: none; position: absolute; right: 0; top: 42px;
|
||||
background: var(--canvas); border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-lg); box-shadow: var(--shadow);
|
||||
min-width: 180px; padding: 4px; z-index: 200;
|
||||
}
|
||||
.user-dropdown.show { display: block; }
|
||||
.user-dropdown .dd-email {
|
||||
padding: 10px 14px; font-size: 12px; color: var(--stone);
|
||||
border-bottom: 1px solid var(--hairline-soft); margin-bottom: 4px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.dd-item {
|
||||
display: block; width: 100%; padding: 8px 14px; border: 0; background: transparent;
|
||||
font-size: 13px; color: var(--slate); text-align: left; cursor: pointer;
|
||||
border-radius: var(--radius-sm); transition: .1s;
|
||||
}
|
||||
.dd-item:hover { background: var(--surface); color: var(--ink); }
|
||||
.dd-item.danger { color: var(--red); }
|
||||
.dd-item.danger:hover { background: var(--red-light); }
|
||||
|
||||
/* Auth-dependent visibility */
|
||||
.nav-guest { display: flex; align-items: center; gap: 8px; }
|
||||
.nav-user { display: none; }
|
||||
body.auth .nav-guest { display: none; }
|
||||
body.auth .nav-user { display: flex; }
|
||||
|
||||
/* Shell */
|
||||
.shell { flex: 1; width: min(100% - 48px, 960px); margin: 0 auto; padding: 40px 0 0; display: flex; flex-direction: column; }
|
||||
|
||||
/* Nav */
|
||||
.nav { display: flex; align-items: center; justify-content: space-between; gap: 16px; height: 48px; }
|
||||
.nav-left { display: flex; align-items: center; gap: 8px; }
|
||||
.brand-mark { width: 22px; height: 22px; background: var(--yellow); border-radius: 5px; display: grid; place-items: center; }
|
||||
.brand-mark::after { content: ""; width: 7px; height: 7px; border: 1.5px solid var(--primary); border-radius: 50%; box-shadow: 5px -3px 0 -1.5px var(--primary); }
|
||||
.brand-name { font-weight: 500; font-size: 15px; letter-spacing: -.2px; }
|
||||
.beta-badge { display:inline-flex; align-items:center; height:20px; padding:0 7px; border-radius:var(--radius-full); background:var(--surface); border:1px solid var(--hairline); color:var(--steel); font-size:11px; font-weight:600; line-height:1; }
|
||||
.hero-brand .beta-badge { margin-left:2px; }
|
||||
|
||||
/* Buttons */
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; border: 0; cursor: pointer; font-weight: 500; font-size: 14px; line-height: 1.3; transition: background .15s, transform .15s; text-decoration: none; -webkit-tap-highlight-color: transparent; }
|
||||
.btn:active { transform: scale(.98); }
|
||||
.btn-primary { background: var(--primary); color: var(--on-primary); border-radius: var(--radius-full); padding: 10px 20px; }
|
||||
.btn-primary:active { background: var(--charcoal); }
|
||||
.btn-secondary { background: transparent; color: var(--ink); border: 1px solid var(--hairline-strong); border-radius: var(--radius-full); padding: 10px 20px; }
|
||||
|
||||
/* Hero */
|
||||
.hero { padding: 120px 0 0; text-align: center; flex: 1; position: relative; overflow: hidden; }
|
||||
.hero-bg {
|
||||
position: absolute; inset: 0; pointer-events: none; user-select: none;
|
||||
}
|
||||
.hero-particle {
|
||||
position: absolute; bottom: -40px;
|
||||
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
||||
font-size: 13px; color: var(--slate);
|
||||
opacity: 0; animation: floatUp var(--dur) var(--delay) infinite linear;
|
||||
}
|
||||
@keyframes floatUp {
|
||||
0% { transform: translateY(0) translateX(0); opacity: 0; }
|
||||
5% { opacity: var(--a); }
|
||||
85% { opacity: var(--a); }
|
||||
100% { transform: translateY(calc(-100vh - 80px)) translateX(var(--dx)); opacity: 0; }
|
||||
}
|
||||
.hero-brand { display: flex; align-items: center; justify-content: center; gap: 10px; margin-bottom: 32px; }
|
||||
.hero-mark { width: 36px; height: 36px; background: var(--yellow); border-radius: 8px; display: grid; place-items: center; flex-shrink: 0; }
|
||||
.hero-mark::after { content: ""; width: 12px; height: 12px; border: 2px solid var(--primary); border-radius: 50%; box-shadow: 9px -5px 0 -2.5px var(--primary); }
|
||||
.hero-word { font-weight: 450; font-size: 22px; letter-spacing: -0.3px; color: var(--ink); }
|
||||
h1 { font-size: clamp(36px, 5.5vw, 56px); font-weight: 500; line-height: 1.1; letter-spacing: -1px; color: var(--ink-deep); max-width: 600px; margin: 0 auto 20px; }
|
||||
.hero-lead { font-size: 16px; line-height: 1.6; color: var(--slate); max-width: 440px; margin: 0 auto 32px; }
|
||||
.hero-actions { display: flex; justify-content: center; gap: 10px; flex-wrap: wrap; margin-top: 32px; }
|
||||
|
||||
/* Footer */
|
||||
.footer { padding: 32px 0; display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; color: var(--stone); font-size: 13px; border-top: 1px solid var(--hairline-soft); flex-shrink: 0; }
|
||||
.footer-copy { color: var(--stone); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.shell { width: min(100% - 32px, 960px); padding: 24px 0 0; }
|
||||
.hero { padding: 0; display: flex; flex-direction: column; justify-content: center; }
|
||||
h1 { letter-spacing: -.5px; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.shell { width: min(100% - 24px, 960px); }
|
||||
.hero { padding: 0; display: flex; flex-direction: column; justify-content: center; }
|
||||
.hero-actions { flex-direction: column; align-items: center; width: 100%; }
|
||||
.hero-actions .btn { width: auto; min-width: 180px; }
|
||||
.footer { flex-direction: column; text-align: center; gap: 8px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="shell">
|
||||
<nav class="nav">
|
||||
<a class="nav-left" href="/">
|
||||
<span class="brand-mark"></span>
|
||||
<span class="brand-name">AlphaX</span>
|
||||
</a>
|
||||
<div class="nav-guest">
|
||||
<a class="btn btn-secondary" href="/auth?tab=login">登录</a>
|
||||
</div>
|
||||
<div class="nav-user">
|
||||
<div class="user-menu">
|
||||
<div class="user-trigger" onclick="toggleUserMenu()">
|
||||
<span class="user-avatar" id="userInitial">?</span>
|
||||
<span id="userEmailShort">--</span>
|
||||
<svg width="10" height="6" viewBox="0 0 10 6"><path d="M1 1l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
</div>
|
||||
<div class="user-dropdown" id="userDropdown">
|
||||
<div class="dd-email" id="ddEmail">--</div>
|
||||
<a class="dd-item" href="/app">进入看板</a>
|
||||
<a class="dd-item danger" href="#" onclick="doLogout()">退出登录</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<section class="hero">
|
||||
<div class="hero-bg">
|
||||
<span class="hero-particle" style="left:5%;--dur:18s;--delay:-2s;--a:0.18;--dx:20px">01001011</span>
|
||||
<span class="hero-particle" style="left:12%;--dur:22s;--delay:-7s;--a:0.13;--dx:-15px">0x7f3a</span>
|
||||
<span class="hero-particle" style="left:20%;--dur:20s;--delay:-12s;--a:0.15;--dx:30px">BTC</span>
|
||||
<span class="hero-particle" style="left:28%;--dur:25s;--delay:-4s;--a:0.13;--dx:-25px">$48.2K</span>
|
||||
<span class="hero-particle" style="left:35%;--dur:17s;--delay:-9s;--a:0.18;--dx:10px">10110110</span>
|
||||
<span class="hero-particle" style="left:42%;--dur:23s;--delay:-1s;--a:0.15;--dx:-20px">ETH</span>
|
||||
<span class="hero-particle" style="left:50%;--dur:19s;--delay:-14s;--a:0.13;--dx:35px">#d4f0</span>
|
||||
<span class="hero-particle" style="left:56%;--dur:26s;--delay:-6s;--a:0.15;--dx:-10px">SOL</span>
|
||||
<span class="hero-particle" style="left:63%;--dur:21s;--delay:-11s;--a:0.13;--dx:25px">0.0037</span>
|
||||
<span class="hero-particle" style="left:70%;--dur:24s;--delay:-3s;--a:0.18;--dx:-30px">11001010</span>
|
||||
<span class="hero-particle" style="left:77%;--dur:18s;--delay:-8s;--a:0.13;--dx:15px">$1.2M</span>
|
||||
<span class="hero-particle" style="left:84%;--dur:22s;--delay:-13s;--a:0.15;--dx:-20px">0xa91f</span>
|
||||
<span class="hero-particle" style="left:90%;--dur:20s;--delay:-5s;--a:0.13;--dx:40px">XRP</span>
|
||||
<span class="hero-particle" style="left:8%;--dur:27s;--delay:-16s;--a:0.10;--dx:-12px">+2.41%</span>
|
||||
<span class="hero-particle" style="left:32%;--dur:19s;--delay:-18s;--a:0.13;--dx:22px">0b0101</span>
|
||||
<span class="hero-particle" style="left:48%;--dur:24s;--delay:-10s;--a:0.10;--dx:-35px">$0.42</span>
|
||||
<span class="hero-particle" style="left:65%;--dur:21s;--delay:-20s;--a:0.13;--dx:18px">BNB</span>
|
||||
<span class="hero-particle" style="left:80%;--dur:26s;--delay:-15s;--a:0.10;--dx:-28px">7.13K</span>
|
||||
<span class="hero-particle" style="left:15%;--dur:23s;--delay:-22s;--a:0.10;--dx:12px">0b1101</span>
|
||||
<span class="hero-particle" style="left:55%;--dur:28s;--delay:-19s;--a:0.13;--dx:-18px">ADA</span>
|
||||
</div>
|
||||
<div class="hero-brand">
|
||||
<span class="hero-mark"></span>
|
||||
<span class="hero-word">AlphaX</span>
|
||||
<span class="beta-badge">Beta</span>
|
||||
</div>
|
||||
<h1>AI Market Intelligence.</h1>
|
||||
<p class="hero-lead">AI 驱动的 Crypto 市场情报系统,帮助交易者发现机会、验证信号、管理风险。</p>
|
||||
<div class="hero-actions">
|
||||
<a class="btn btn-primary hero-cta" id="heroCta" href="/auth?tab=login">立刻体验</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
<span class="footer-copy">© 2026 AlphaX</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var $ = function(id){ return document.getElementById(id); };
|
||||
function toggleUserMenu() { $('userDropdown').classList.toggle('show'); }
|
||||
document.addEventListener('click', function(e) { if (!e.target.closest('.user-menu')) $('userDropdown').classList.remove('show'); });
|
||||
async function doLogout() { await fetch('/api/auth/logout',{method:'POST'}); window.location.href='/'; }
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
var resp = await fetch('/api/auth/me');
|
||||
if (!resp.ok) return;
|
||||
var data = await resp.json();
|
||||
var email = (data.user && data.user.email) || '';
|
||||
if (!email) return;
|
||||
document.body.classList.add('auth');
|
||||
var cta = $('heroCta');
|
||||
if (cta) cta.href = '/app';
|
||||
$('userInitial').textContent = email.charAt(0).toUpperCase();
|
||||
$('userEmailShort').textContent = email.length > 14 ? email.slice(0,12) + '\u2026' : email;
|
||||
$('ddEmail').textContent = email;
|
||||
} catch(e) {}
|
||||
}
|
||||
checkAuth();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
192
static/iteration.html
Normal file
192
static/iteration.html
Normal file
@ -0,0 +1,192 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}AlphaX — 策略进化{% endblock %}
|
||||
{% block nav_links %}
|
||||
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||
<a class="sidebar-link" href="/watchlist"><svg class="link-icon"><use href="#svg-star"/></svg>关注</a>
|
||||
<a class="sidebar-link" href="/strategy"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||
<a class="sidebar-link active" href="/iteration"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_head_css %}
|
||||
<style>
|
||||
.shell { width:min(100% - 40px,1180px); margin:0 auto; padding:28px 0 48px; }
|
||||
.hero { display:flex; justify-content:space-between; align-items:flex-start; gap:20px; margin-bottom:18px; }
|
||||
h2 { font-size:26px; font-weight:900; margin:0 0 8px; color:var(--ink); }
|
||||
.subtitle { color:var(--stone); font-size:14px; line-height:1.7; max-width:780px; }
|
||||
.badge { display:inline-flex; align-items:center; gap:6px; padding:5px 10px; border-radius:999px; font-size:12px; font-weight:800; border:1px solid var(--hairline-soft); background:var(--canvas); color:var(--slate); white-space:nowrap; }
|
||||
.badge.release { background:var(--green-light); color:var(--green); border-color:rgba(29,166,122,.2); }
|
||||
.badge.gray { background:rgba(66,98,255,.08); color:var(--blue); border-color:rgba(66,98,255,.18); }
|
||||
.badge.hold { background:var(--yellow-light); color:var(--yellow-dark); border-color:rgba(245,158,11,.18); }
|
||||
.badge.reject { background:rgba(216,75,75,.08); color:var(--red); border-color:rgba(216,75,75,.18); }
|
||||
.toolbar { display:flex; gap:10px; flex-wrap:wrap; }
|
||||
.btn { border:1px solid var(--hairline-soft); background:var(--canvas); color:var(--ink); border-radius:12px; min-height:44px; padding:9px 12px; font-size:13px; font-weight:800; cursor:pointer; }
|
||||
.btn:hover { border-color:var(--hairline); box-shadow:0 4px 12px rgba(5,0,56,.05); }
|
||||
.grid { display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin:18px 0; }
|
||||
.kpi { background:var(--canvas); border:1px solid var(--hairline-soft); border-radius:18px; padding:16px; }
|
||||
.kpi .label { color:var(--muted); font-size:12px; font-weight:700; margin-bottom:8px; }
|
||||
.kpi .value { color:var(--ink); font-size:28px; font-weight:900; letter-spacing:-.04em; }
|
||||
.kpi .note { color:var(--stone); font-size:12px; margin-top:6px; line-height:1.55; }
|
||||
.gate-card { background:linear-gradient(135deg, rgba(66,98,255,.08), rgba(255,208,47,.12)); border:1px solid var(--hairline-soft); border-radius:20px; padding:16px; margin:14px 0 18px; }
|
||||
.gate-head { display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap; margin-bottom:10px; }
|
||||
.gate-title { font-size:15px; font-weight:900; color:var(--ink); }
|
||||
.gate-text { color:var(--slate); font-size:13px; line-height:1.7; }
|
||||
.gate-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:8px; margin-top:12px; }
|
||||
.gate-mini { background:rgba(255,255,255,.7); border:1px solid var(--hairline-soft); border-radius:14px; padding:10px; }
|
||||
.gate-mini span { display:block; color:var(--muted); font-size:11px; font-weight:800; margin-bottom:4px; }
|
||||
.gate-mini b { color:var(--ink); font-size:16px; }
|
||||
.tabs { display:flex; gap:8px; margin:22px 0 14px; flex-wrap:wrap; }
|
||||
.tab { padding:9px 13px; border-radius:999px; border:1px solid var(--hairline-soft); background:var(--surface); color:var(--slate); font-size:13px; font-weight:900; cursor:pointer; min-height:44px; }
|
||||
.tab.active { background:var(--primary); color:white; border-color:var(--primary); }
|
||||
.panel { display:none; }
|
||||
.panel.active { display:block; }
|
||||
.board { display:grid; grid-template-columns:1.05fr .95fr; gap:14px; }
|
||||
.card { background:var(--canvas); border:1px solid var(--hairline-soft); border-radius:20px; padding:18px; margin-bottom:14px; }
|
||||
.card-title { font-size:15px; font-weight:900; margin-bottom:12px; color:var(--ink); display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap; }
|
||||
.timeline { position:relative; padding-left:34px; }
|
||||
.timeline::before { content:""; position:absolute; left:13px; top:0; bottom:0; width:2px; background:var(--hairline); }
|
||||
.iter { position:relative; border:1px solid var(--hairline-soft); background:var(--canvas); border-radius:18px; padding:16px; margin-bottom:12px; }
|
||||
.iter::before { content:""; position:absolute; left:-26px; top:22px; width:10px; height:10px; border-radius:50%; background:var(--yellow); border:3px solid var(--canvas); box-shadow:0 0 0 1px var(--hairline); }
|
||||
.iter.release::before { background:var(--green); }
|
||||
.iter.gray::before { background:var(--blue); }
|
||||
.iter-head { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
.ver { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:13px; font-weight:900; color:var(--primary); background:var(--yellow-light); padding:3px 9px; border-radius:10px; }
|
||||
.title { font-size:14px; font-weight:900; color:var(--ink); }
|
||||
.time { margin-left:auto; color:var(--muted); font-size:12px; }
|
||||
.metrics { display:flex; gap:12px; flex-wrap:wrap; margin:12px 0; color:var(--slate); font-size:12px; }
|
||||
.metric b { color:var(--ink); }
|
||||
.summary { color:var(--slate); font-size:13px; line-height:1.7; }
|
||||
.detail { margin-top:12px; padding-top:12px; border-top:1px solid var(--hairline-soft); display:none; }
|
||||
.iter.open .detail { display:block; }
|
||||
.section { margin-top:10px; }
|
||||
.section-label { font-size:11px; font-weight:900; color:var(--muted); letter-spacing:.04em; margin-bottom:7px; }
|
||||
.item { color:var(--slate); font-size:13px; line-height:1.65; padding:6px 0 6px 13px; position:relative; }
|
||||
.item::before { content:""; position:absolute; left:0; top:15px; width:4px; height:4px; border-radius:50%; background:var(--hairline-strong); }
|
||||
.item.good { color:var(--green); } .item.warn { color:var(--red); }
|
||||
.table { width:100%; border-collapse:separate; border-spacing:0 8px; }
|
||||
.table th { color:var(--muted); font-size:11px; text-align:left; padding:0 8px; font-weight:900; }
|
||||
.table td { background:var(--surface); border-top:1px solid var(--hairline-soft); border-bottom:1px solid var(--hairline-soft); padding:10px 8px; font-size:12px; color:var(--slate); vertical-align:top; }
|
||||
.table td:first-child { border-left:1px solid var(--hairline-soft); border-radius:12px 0 0 12px; }
|
||||
.table td:last-child { border-right:1px solid var(--hairline-soft); border-radius:0 12px 12px 0; }
|
||||
.rule-name { color:var(--ink); font-weight:900; max-width:380px; white-space:normal; line-height:1.55; }
|
||||
.reason { max-width:360px; white-space:normal; line-height:1.55; color:var(--slate); }
|
||||
.score { font-weight:900; color:var(--primary); }
|
||||
.failure-chip { display:inline-flex; margin:3px; padding:6px 9px; border-radius:999px; background:rgba(216,75,75,.08); color:var(--red); font-size:12px; font-weight:800; }
|
||||
.empty,.loading { text-align:center; padding:44px 20px; color:var(--stone); font-size:14px; }
|
||||
@media(max-width:860px){ .shell{width:min(100% - 24px,1180px); padding-top:20px;} .hero{display:block;} .toolbar{margin-top:12px;} .grid,.gate-grid{grid-template-columns:repeat(2,1fr);} .board{grid-template-columns:1fr;} .time{margin-left:0;width:100%;} .table{display:block; overflow-x:auto; white-space:nowrap;} .rule-name,.reason{min-width:260px;} }
|
||||
@media(max-width:480px){ .grid,.gate-grid{grid-template-columns:1fr;} .kpi .value{font-size:24px;} .tabs{gap:6px;} .tab{padding:8px 10px;} .timeline{padding-left:26px;} .timeline::before{left:9px;} .iter::before{left:-22px;} }
|
||||
|
||||
/* ===== USER REPORT VIEW ===== */
|
||||
.summary-report { display:grid; grid-template-columns:1.1fr .9fr; gap:14px; margin:16px 0 18px; }
|
||||
.report-card { background:var(--canvas); border:1px solid var(--hairline-soft); border-radius:22px; padding:18px; }
|
||||
.report-title { font-size:15px; font-weight:900; color:var(--ink); margin-bottom:10px; display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.report-answer { font-size:28px; font-weight:900; letter-spacing:-.04em; color:var(--ink); margin:8px 0; }
|
||||
.report-answer.release { color:var(--green); } .report-answer.gray { color:var(--blue); } .report-answer.hold { color:var(--yellow-dark); } .report-answer.reject { color:var(--red); }
|
||||
.report-text { color:var(--slate); font-size:13px; line-height:1.75; }
|
||||
.report-list { display:flex; flex-direction:column; gap:8px; margin-top:12px; }
|
||||
.report-item { border:1px solid var(--hairline-soft); background:var(--surface); border-radius:14px; padding:10px 12px; }
|
||||
.report-item b { display:block; color:var(--ink); font-size:13px; margin-bottom:4px; }
|
||||
.report-item span { color:var(--stone); font-size:12px; line-height:1.55; }
|
||||
.user-tabs-note { color:var(--stone); font-size:12px; margin:-6px 0 8px; line-height:1.6; }
|
||||
.rule-quality { display:inline-flex; padding:4px 8px; border-radius:999px; font-size:11px; font-weight:900; }
|
||||
.rule-quality.good { background:var(--green-light); color:var(--green); }
|
||||
.rule-quality.wait { background:var(--yellow-light); color:var(--yellow-dark); }
|
||||
.rule-quality.bad { background:var(--red-light); color:var(--red); }
|
||||
@media(max-width:860px){ .summary-report{grid-template-columns:1fr;} }
|
||||
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="shell">
|
||||
<div class="hero">
|
||||
<div>
|
||||
<h2>策略进化</h2>
|
||||
<p class="subtitle">这里展示 AlphaX 策略是否真的在变聪明:本轮有没有发布、为什么没发布、哪些规律还在观察、哪些错误正在减少。</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button class="btn" onclick="refreshCandidates()">刷新规则表现</button>
|
||||
<button class="btn" onclick="loadAll()">刷新页面</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid" id="kpis"><div class="loading">加载中…</div></div>
|
||||
<div id="gateBox" class="gate-card"><div class="loading">加载发布闸门…</div></div>
|
||||
<div id="userReport" class="summary-report"><div class="loading">生成用户版进化报告…</div></div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="timeline" onclick="switchTab('timeline')">本轮结论</button>
|
||||
<button class="tab" data-tab="candidates" onclick="switchTab('candidates')">发现的规律</button>
|
||||
<button class="tab" data-tab="dryrun" onclick="switchTab('dryrun')">发布预演</button>
|
||||
<button class="tab" data-tab="failures" onclick="switchTab('failures')">错误复盘</button>
|
||||
<button class="tab" data-tab="versions" onclick="switchTab('versions')">版本表现</button>
|
||||
</div>
|
||||
|
||||
<div class="panel active" id="panel-timeline"><div class="timeline" id="timeline"><div class="loading">加载中…</div></div></div>
|
||||
<div class="panel" id="panel-candidates"><div class="card"><div class="card-title">发现的规律 <span class="badge hold">未达标不发布</span></div><div class="user-tabs-note">这些是系统复盘后发现的可能规律。只有样本、成功率、收益和稳定性都达标,才会进入线上策略。</div><div id="candidates"></div></div></div>
|
||||
<div class="panel" id="panel-dryrun"><div class="card"><div class="card-title">发布预演 <span class="badge hold">只读评估,不改线上策略</span></div><div id="dryrun"></div></div></div>
|
||||
<div class="panel" id="panel-failures"><div class="board"><div class="card"><div class="card-title">主要失败原因</div><div id="failureSummary"></div></div><div class="card"><div class="card-title">失败样本</div><div id="failures"></div></div></div></div>
|
||||
<div class="panel" id="panel-versions"><div class="card"><div class="card-title">版本表现</div><div id="versions"></div></div></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_script %}
|
||||
<script>
|
||||
var API = '';
|
||||
var $ = function(id){ return document.getElementById(id); };
|
||||
var state = { data:null };
|
||||
function esc(v){ return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];}); }
|
||||
function fmtTime(t){ if(!t)return '--'; var d=new Date(t); return (d.getMonth()+1)+'/'+d.getDate()+' '+('0'+d.getHours()).slice(-2)+':'+('0'+d.getMinutes()).slice(-2); }
|
||||
function badge(status){ var cls=status==='release'||status==='active'?'release':status==='gray'?'gray':status==='rejected'?'reject':'hold'; var txt={release:'正式发布',gray:'灰度观察',hold:'只研究不发布',candidate:'候选研究',active:'正式生效',rejected:'已淘汰',blocked:'发布阻断',unknown:'旧日志',dirty_history:'污染历史'}[status]||status||'研究中'; return '<span class="badge '+cls+'">'+esc(txt)+'</span>'; }
|
||||
function switchTab(tab){ document.querySelectorAll('.tab').forEach(function(x){x.classList.toggle('active',x.dataset.tab===tab);}); document.querySelectorAll('.panel').forEach(function(x){x.classList.remove('active');}); $('panel-'+tab).classList.add('active'); }
|
||||
async function loadUser(){ try{ var r=await fetch(API+'/api/auth/me'); if(!r.ok)return; var d=await r.json(); var email=(d.user&&d.user.email)||''; if(!email)return; $('userInitial').textContent=email.charAt(0).toUpperCase(); $('userEmailShort').textContent=email.length>14?email.slice(0,12)+'…':email; $('ddEmail').textContent=email; }catch(e){} }
|
||||
function toggleUserMenu(){ $('userDropdown').classList.toggle('show'); }
|
||||
document.addEventListener('click',function(e){ if(!e.target.closest('.sidebar-user')&&!e.target.closest('.user-dropdown')) $('userDropdown').classList.remove('show'); });
|
||||
async function doLogout(){ await fetch(API+'/api/auth/logout',{method:'POST'}); location.href='/auth'; }
|
||||
async function refreshCandidates(){ var old=document.querySelector('.toolbar .btn'); try{ old.textContent='刷新中…'; await fetch(API+'/api/strategy/candidates/refresh',{method:'POST'}); await loadAll(); }catch(e){ alert('刷新失败'); } finally{ old.textContent='刷新候选评分'; } }
|
||||
async function loadAll(){ try{ var r=await fetch(API+'/api/strategy/lifecycle?days=60'); var d=await r.json(); state.data=d; renderAll(d); }catch(e){ $('timeline').innerHTML='<div class="empty">加载失败</div>'; } }
|
||||
function renderAll(d){ renderKpis(d); renderGate(d); renderUserReport(d); renderTimeline(d.logs||[]); renderCandidates(d.candidates||[], d.dry_run||{}); renderDryRun(d.dry_run||{}); renderFailures(d); renderVersions(((d.summary||{}).version_stats)||[]); }
|
||||
|
||||
function decisionText(decision){
|
||||
var map={release:'正式发布新策略',gray:'进入灰度观察',hold:'只研究,不发布',blocked:'暂不发布',rejected:'暂不采纳',unknown:'等待复盘'};
|
||||
return map[decision]||map.unknown;
|
||||
}
|
||||
function decisionClass(decision){ return decision==='release'?'release':decision==='gray'?'gray':(decision==='blocked'||decision==='rejected')?'reject':'hold'; }
|
||||
function renderUserReport(d){
|
||||
var ov=d.overview||{}, dry=d.dry_run||{}, ds=ov.dry_run_summary||{};
|
||||
var decision=ov.latest_release_decision || (dry.would_bump_version?'release':'hold');
|
||||
var reason=ov.latest_release_reason || dry.release_reason || '样本仍在积累,暂不改变线上策略。';
|
||||
var candidates=(d.candidates||[]).slice(0,3);
|
||||
var failures=((ov.failure_type_counts)||[]).slice(0,3);
|
||||
var candHtml=candidates.length?candidates.map(function(c){
|
||||
var name=c.rule_description||c.signal_name||'待验证规律';
|
||||
var conf=Number(c.confidence_score||0);
|
||||
var q=conf>=70?'good':conf>=40?'wait':'bad';
|
||||
var qtxt=conf>=70?'接近可用':conf>=40?'继续观察':'证据不足';
|
||||
return '<div class="report-item"><b>'+esc(name)+'</b><span><span class="rule-quality '+q+'">'+qtxt+'</span> 样本 '+esc(c.sample_size||0)+' · 置信 '+esc(c.confidence_score||0)+' · 平均表现 '+esc(c.avg_pnl||0)+'</span></div>';
|
||||
}).join(''):'<div class="report-item"><b>暂无新规律</b><span>当前没有足够证据支持策略改动。</span></div>';
|
||||
var failHtml=failures.length?failures.map(function(f){return '<div class="report-item"><b>'+esc(f.type||'失败模式')+'</b><span>出现 '+esc(f.count||0)+' 次,后续复盘会重点观察是否重复发生。</span></div>';}).join(''):'<div class="report-item"><b>暂无集中失败模式</b><span>当前失败样本不足,先继续观察。</span></div>';
|
||||
$('userReport').innerHTML = '<div class="report-card"><div class="report-title">本轮策略结论 '+badge(decision)+'</div><div class="report-answer '+decisionClass(decision)+'">'+decisionText(decision)+'</div><div class="report-text">'+esc(reason)+'</div><div class="report-list">'+candHtml+'</div></div>' +
|
||||
'<div class="report-card"><div class="report-title">最近最该关注的错误</div><div class="report-text">系统不只看成功因子,也会记录反复导致失败的原因,避免下一轮继续犯同样的错。</div><div class="report-list">'+failHtml+'</div></div>';
|
||||
}
|
||||
|
||||
function renderKpis(d){ var ov=d.overview||{}, st=ov.candidate_status_counts||{}, rd=ov.release_decision_counts||{}, dry=ov.dry_run_summary||{}; $('kpis').innerHTML=[
|
||||
['复盘样本', dry.review_sample_count||0, '修复后干净样本数量'],
|
||||
['待验证规律', ov.candidate_count||0, '观察中 '+(st.candidate||0)+' / 灰度 '+(st.gray||0)+' / 旧样本参考 '+(dry.dirty_history_candidate_count||0)],
|
||||
['可灰度规律', dry.gray_ready_count||0, '达到门槛才会进入灰度'],
|
||||
['正式发布', (rd.release||0), '真正改变线上策略的次数']
|
||||
].map(function(k){return '<div class="kpi"><div class="label">'+k[0]+'</div><div class="value">'+k[1]+'</div><div class="note">'+k[2]+'</div></div>';}).join(''); }
|
||||
function renderGate(d){ var ov=d.overview||{}, dry=(d.dry_run||{}), ds=ov.dry_run_summary||{}; var latest=ov.latest_release_decision||'hold'; $('gateBox').innerHTML='<div class="gate-head"><div class="gate-title">本轮是否发布</div>'+badge(latest)+'</div><div class="gate-text"><b>干净样本起点:</b>'+esc(ds.clean_started_at||dry.clean_started_at||'未设置')+';样本窗口:'+esc(ds.sample_window||dry.sample_window||'all_history')+'。旧污染样本只作解释,不会直接改变线上策略。</div><div class="gate-text"><b>最近发布原因:</b>'+esc(ov.latest_release_reason||'暂无发布决策说明')+'</div><div class="gate-text"><b>预演结论:</b>'+esc(dry.release_reason||ds.release_reason||'只读评估,不写库、不升版')+'</div><div class="gate-grid"><div class="gate-mini"><span>干净复盘样本</span><b>'+esc(ds.review_sample_count||dry.review_sample_count||0)+'</b></div><div class="gate-mini"><span>污染历史候选</span><b>'+esc(ds.dirty_history_candidate_count||dry.dirty_history_candidate_count||0)+'</b></div><div class="gate-mini"><span>可灰度</span><b>'+esc(ds.gray_ready_count||dry.gray_ready_count||0)+'</b></div><div class="gate-mini"><span>是否发布</span><b>'+(dry.would_bump_version?'是':'否')+'</b></div></div>'; }
|
||||
function renderTimeline(items){ if(!items.length){$('timeline').innerHTML='<div class="empty">暂无迭代记录</div>';return;} $('timeline').innerHTML=items.map(function(it){ var decision=it.release_decision||'unknown'; var metrics=it.metrics||{}; var cls=decision==='release'?' release':decision==='gray'?' gray':''; return '<div class="iter'+cls+'" onclick="this.classList.toggle(\'open\')"><div class="iter-head"><span class="ver">'+esc(it.strategy_version||'--')+'</span><span class="title">'+esc(it.title||'复盘迭代')+'</span>'+badge(decision)+'<span class="time">'+fmtTime(it.created_at)+'</span></div><div class="metrics"><span class="metric">爆发 <b>'+(metrics.hit_count||0)+'</b></span><span class="metric">横盘 <b>'+(metrics.flat_count||0)+'</b></span><span class="metric">失败 <b>'+(metrics.fail_count||0)+'</b></span><span class="metric">候选 <b>'+((it.candidate_rules||[]).length)+'</b></span><span class="metric">置信 <b>'+esc(it.confidence_level||'--')+'</b></span></div><div class="summary">'+esc((it.release_reason||it.version_change_summary||it.summary||'').slice(0,280))+'</div><div class="detail">'+renderSection('成功因子',(it.success_analysis&&it.success_analysis.top_success_factors)||[],'good')+renderSection('失败模式',(it.failure_analysis&&it.failure_analysis.failure_types)||[],'warn')+renderCandidateMini(it.candidate_rules||[])+renderSection('动作',it.actions||[],'')+renderSection('问题',it.problems||[],'warn')+'</div></div>'; }).join(''); }
|
||||
function renderSection(label,items,cls){ if(!items||!items.length)return ''; return '<div class="section"><div class="section-label">'+label+'</div>'+items.slice(0,10).map(function(x){ var t=typeof x==='string'?x:(x.label||x.type||x.signal||x.description||JSON.stringify(x)); var c=x.count?(' · '+x.count):''; return '<div class="item '+cls+'">'+esc(t+c)+'</div>'; }).join('')+'</div>'; }
|
||||
function renderCandidateMini(items){ if(!items.length)return ''; return '<div class="section"><div class="section-label">本轮候选规则</div>'+items.slice(0,8).map(function(x){return '<div class="item">'+esc(x.description||x.signal||'候选规则')+' · 置信 '+esc(x.confidence_score||0)+' · 样本 '+esc(x.sample_size||0)+' · '+esc(x.status||'candidate')+'</div>';}).join('')+'</div>'; }
|
||||
function renderCandidates(items,dry){ if(!items.length){$('candidates').innerHTML='<div class="empty">暂无待验证规律</div>';return;} var dryMap={}; (dry.evaluated_candidates||[]).forEach(function(x){dryMap[x.id]=x;}); $('candidates').innerHTML='<table class="table"><thead><tr><th>当前阶段</th><th>预演结论</th><th>规律</th><th>样本</th><th>成功/失败</th><th>可信度</th><th>平均表现</th><th>为什么还没发布</th></tr></thead><tbody>'+items.map(function(c){var d=dryMap[c.id]||{};return '<tr><td>'+badge(c.status||'candidate')+'</td><td>'+badge(d.dry_run_status||c.status||'candidate')+'</td><td class="rule-name">'+esc(c.rule_description||c.signal_name||'--')+'</td><td>'+esc(d.sample_size!=null?d.sample_size:(c.sample_size||0))+'</td><td>'+esc(d.success_count!=null?d.success_count:(c.success_count||0))+' / '+esc(d.fail_count!=null?d.fail_count:(c.fail_count||0))+'</td><td class="score">'+esc(d.confidence_score!=null?d.confidence_score:(c.confidence_score||0))+'</td><td>'+esc(d.avg_pnl!=null?d.avg_pnl:(c.avg_pnl||0))+'</td><td class="reason">'+esc(d.gate_reason||'等待样本验证')+'</td></tr>';}).join('')+'</tbody></table>'; }
|
||||
function renderDryRun(dry){ var items=dry.evaluated_candidates||[]; if(!items.length){$('dryrun').innerHTML='<div class="empty">暂无待验证规律可评估</div>';return;} $('dryrun').innerHTML='<div class="gate-text">当前版本 '+esc(dry.current_version||'--')+';干净样本起点 '+esc(dry.clean_started_at||'未设置')+';干净复盘样本 '+esc(dry.review_sample_count||0)+';污染历史候选 '+esc(dry.dirty_history_candidate_count||0)+';可灰度 '+esc(dry.gray_ready_count||0)+';是否发布:'+(dry.would_bump_version?'是':'否')+'。</div><div class="gate-text"><b>灰度标准:</b>'+esc((dry.gate_policy&&dry.gate_policy.gray)||'--')+'</div><table class="table"><thead><tr><th>预演结论</th><th>规律</th><th>样本</th><th>成功/失败</th><th>可信度</th><th>平均表现</th><th>原因</th></tr></thead><tbody>'+items.map(function(x){return '<tr><td>'+badge(x.dry_run_status||'candidate')+'</td><td class="rule-name">'+esc(x.rule_description||x.signal_name||'--')+'</td><td>'+esc(x.sample_size||0)+'</td><td>'+esc(x.success_count||0)+' / '+esc(x.fail_count||0)+'</td><td class="score">'+esc(x.confidence_score||0)+'</td><td>'+esc(x.avg_pnl||0)+'</td><td class="reason">'+esc(x.gate_reason||'--')+'</td></tr>';}).join('')+'</tbody></table>'; }
|
||||
function renderFailures(d){ var fs=(d.overview&&d.overview.failure_type_counts)||[]; $('failureSummary').innerHTML=fs.length?fs.map(function(f){return '<span class="failure-chip">'+esc(f.type)+' · '+esc(f.count)+'</span>';}).join(''):'<div class="empty">暂无失败模式</div>'; var items=d.failures||[]; $('failures').innerHTML=items.length?items.slice(0,30).map(function(f){return '<div class="item warn"><b>'+esc(f.symbol||'--')+'</b> · '+esc(f.failure_type||'未分类')+' · '+esc((f.failure_reason||'').slice(0,90))+' · PnL '+esc(f.pnl_pct||0)+'</div>';}).join(''):'<div class="empty">暂无失败样本</div>'; }
|
||||
function renderVersions(items){ if(!items.length){$('versions').innerHTML='<div class="empty">暂无版本表现</div>';return;} $('versions').innerHTML='<table class="table"><thead><tr><th>版本</th><th>推荐数</th><th>成功</th><th>失败</th><th>待观察</th><th>成功率</th><th>均值收益</th></tr></thead><tbody>'+items.map(function(v){return '<tr><td class="rule-name">'+esc(v.strategy_version)+'</td><td>'+esc(v.recommendation_count)+'</td><td>'+esc(v.success_count)+'</td><td>'+esc(v.failed_count)+'</td><td>'+esc(v.pending_count)+'</td><td class="score">'+esc(v.success_rate_pct)+'</td><td>'+esc(v.avg_pnl_pct)+'</td></tr>';}).join('')+'</tbody></table>'; }
|
||||
loadUser(); loadAll();
|
||||
</script>
|
||||
{% endblock %}
|
||||
216
static/referral.html
Normal file
216
static/referral.html
Normal file
@ -0,0 +1,216 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}推荐好友 · AlphaX{% endblock %}
|
||||
|
||||
{% block nav_links %}
|
||||
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||
<a class="sidebar-link" href="/watchlist"><svg class="link-icon"><use href="#svg-star"/></svg>关注</a>
|
||||
<a class="sidebar-link" href="/strategy"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||
<a class="sidebar-link" href="/iteration"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||
<a class="sidebar-link active" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_head_css %}
|
||||
<style>
|
||||
main { max-width: 680px; margin: 0 auto; width: 100%; padding: 32px 20px; display: flex; flex-direction: column; gap: 28px; }
|
||||
|
||||
.page-header { text-align: center; }
|
||||
.page-header h1 { font-size: 28px; font-weight: 700; letter-spacing: -.5px; margin-bottom: 6px; }
|
||||
.page-header .sub { font-size: 14px; color: var(--stone); }
|
||||
|
||||
/* Invite card */
|
||||
.invite-card {
|
||||
background: var(--canvas); border: 1px solid var(--hairline-soft);
|
||||
border-radius: var(--radius-xl); padding: 28px 24px;
|
||||
display: flex; flex-direction: column; gap: 16px;
|
||||
}
|
||||
.invite-label { font-size: 12px; color: var(--stone); font-weight: 600; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.invite-link-box {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: var(--surface); border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-lg); padding: 10px 14px;
|
||||
font-size: 14px; color: var(--ink); font-family: monospace;
|
||||
word-break: break-all; user-select: all;
|
||||
}
|
||||
.invite-link-box .link-text { flex: 1; min-width: 0; }
|
||||
.copy-btn {
|
||||
flex-shrink: 0; padding: 7px 18px; border: 0; border-radius: var(--radius-full);
|
||||
background: var(--primary); color: var(--on-primary);
|
||||
font-size: 13px; font-weight: 600; cursor: pointer; transition: .15s;
|
||||
}
|
||||
.copy-btn:hover { opacity: .85; }
|
||||
.copy-btn.copied { background: var(--green); }
|
||||
.copy-msg { font-size: 12px; color: var(--green); min-height: 18px; text-align: center; }
|
||||
|
||||
/* Stats */
|
||||
.stats-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.stat-box {
|
||||
background: var(--canvas); border: 1px solid var(--hairline-soft);
|
||||
border-radius: var(--radius-xl); padding: 20px;
|
||||
text-align: center; display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.stat-box .num { font-size: 36px; font-weight: 700; letter-spacing: -1px; line-height: 1; }
|
||||
.stat-box .lbl { font-size: 12px; color: var(--stone); }
|
||||
|
||||
/* Referred list */
|
||||
.ref-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.ref-card {
|
||||
background: var(--canvas); border: 1px solid var(--hairline-soft);
|
||||
border-radius: var(--radius-lg); padding: 16px 18px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
.ref-avatar {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: var(--yellow); color: var(--primary);
|
||||
display: grid; place-items: center; font-weight: 700; font-size: 14px; flex-shrink: 0;
|
||||
}
|
||||
.ref-info { flex: 1; min-width: 0; }
|
||||
.ref-email { font-weight: 600; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ref-date { font-size: 11px; color: var(--stone); margin-top: 2px; }
|
||||
.ref-badge { flex-shrink: 0; font-size: 11px; font-weight: 600; padding: 3px 10px; border-radius: var(--radius-full); }
|
||||
.ref-badge.verified { background: var(--green-light); color: var(--green); }
|
||||
.ref-badge.pending { background: var(--yellow-light); color: var(--yellow-dark); }
|
||||
|
||||
.empty-state { text-align: center; padding: 48px 20px; color: var(--stone); }
|
||||
.empty-state p { font-size: 14px; margin-bottom: 8px; }
|
||||
.empty-state .empty-icon { font-size: 48px; margin-bottom: 16px; opacity: .3; }
|
||||
|
||||
/* Share section */
|
||||
.share-section { text-align: center; }
|
||||
.share-section h3 { font-size: 16px; font-weight: 600; margin-bottom: 4px; }
|
||||
.share-section p { font-size: 13px; color: var(--stone); margin-bottom: 12px; }
|
||||
.share-tips { display: flex; flex-direction: column; gap: 6px; text-align: left; font-size: 13px; color: var(--slate); line-height: 1.6; padding: 16px 20px; background: var(--surface); border-radius: var(--radius-lg); }
|
||||
|
||||
@media(max-width:480px){
|
||||
main { padding: 20px 14px; gap: 20px; }
|
||||
.invite-card { padding: 20px 16px; }
|
||||
.invite-link-box { flex-direction: column; }
|
||||
.copy-btn { width: 100%; }
|
||||
.stats-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
<div class="page-header">
|
||||
<h1>🎁 推荐好友</h1>
|
||||
<p class="sub">邀请朋友加入 AlphaX,一起跟踪市场信号</p>
|
||||
</div>
|
||||
|
||||
<div class="invite-card">
|
||||
<div class="invite-label">你的专属邀请链接</div>
|
||||
<div class="invite-link-box">
|
||||
<span class="link-text" id="inviteLink">加载中...</span>
|
||||
<button class="copy-btn" id="copyBtn" onclick="copyLink()">复制链接</button>
|
||||
</div>
|
||||
<div class="copy-msg" id="copyMsg"></div>
|
||||
</div>
|
||||
|
||||
<div class="stats-row" id="statsRow">
|
||||
<div class="stat-box"><div class="num">--</div><div class="lbl">已邀请</div></div>
|
||||
<div class="stat-box"><div class="num">--</div><div class="lbl">已注册</div></div>
|
||||
</div>
|
||||
|
||||
<div id="refSection">
|
||||
<h3 style="font-size:16px;font-weight:600;margin-bottom:12px">已邀请好友</h3>
|
||||
<div class="ref-list" id="refList">
|
||||
<div class="empty-state"><p>加载中...</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="share-section">
|
||||
<h3>如何推荐</h3>
|
||||
<p>复制邀请链接发送给朋友,他们注册时将自动绑定你的邀请码</p>
|
||||
<div class="share-tips">
|
||||
<div>📋 复制邀请链接,通过微信 / Telegram / 邮件分享</div>
|
||||
<div>🔗 好友点击链接注册,系统自动记录邀请关系</div>
|
||||
<div>📊 你可以在本页查看邀请进度</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_script %}
|
||||
<script>
|
||||
var inviteCode = '';
|
||||
var baseUrl = window.location.origin;
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
var r = await fetch('/api/auth/me');
|
||||
if (!r.ok) { window.location.href = '/auth'; return; }
|
||||
var data = await r.json();
|
||||
inviteCode = data.user.invite_code || '';
|
||||
if (!inviteCode) {
|
||||
document.getElementById('inviteLink').textContent = '暂无邀请码';
|
||||
return;
|
||||
}
|
||||
var link = baseUrl + '/auth?invite=' + inviteCode;
|
||||
document.getElementById('inviteLink').textContent = link;
|
||||
loadReferralStats();
|
||||
} catch(e) {
|
||||
document.getElementById('inviteLink').textContent = '加载失败,请刷新重试';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadReferralStats() {
|
||||
try {
|
||||
var r = await fetch('/api/referral/stats');
|
||||
if (!r.ok) return;
|
||||
var d = await r.json();
|
||||
document.getElementById('statsRow').innerHTML =
|
||||
'<div class="stat-box"><div class="num">' + (d.total_invited || 0) + '</div><div class="lbl">已邀请</div></div>' +
|
||||
'<div class="stat-box"><div class="num">' + (d.total_registered || 0) + '</div><div class="lbl">已注册</div></div>';
|
||||
renderRefList(d.invited_users || []);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function renderRefList(users) {
|
||||
var list = document.getElementById('refList');
|
||||
if (!users.length) {
|
||||
list.innerHTML = '<div class="empty-state"><div class="empty-icon">📭</div><p>还没有好友通过你的链接注册</p><p style="font-size:12px;color:var(--muted)">分享邀请链接,开始推荐吧</p></div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = users.map(function(u) {
|
||||
var initial = (u.email || '?').charAt(0).toUpperCase();
|
||||
var badge = u.email_verified
|
||||
? '<span class="ref-badge verified">已验证</span>'
|
||||
: '<span class="ref-badge pending">未验证</span>';
|
||||
var date = u.created_at ? u.created_at.slice(0, 10) : '--';
|
||||
return '<div class="ref-card">' +
|
||||
'<div class="ref-avatar">' + initial + '</div>' +
|
||||
'<div class="ref-info"><div class="ref-email">' + esc(u.email) + '</div><div class="ref-date">' + date + '</div></div>' +
|
||||
badge +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function copyLink() {
|
||||
var link = document.getElementById('inviteLink').textContent;
|
||||
if (!link || link === '加载中...' || link === '暂无邀请码') return;
|
||||
navigator.clipboard.writeText(link).then(function() {
|
||||
var btn = document.getElementById('copyBtn');
|
||||
var msg = document.getElementById('copyMsg');
|
||||
btn.textContent = '已复制 ✓';
|
||||
btn.classList.add('copied');
|
||||
msg.textContent = '邀请链接已复制到剪贴板';
|
||||
setTimeout(function() {
|
||||
btn.textContent = '复制链接';
|
||||
btn.classList.remove('copied');
|
||||
msg.textContent = '';
|
||||
}, 2000);
|
||||
}).catch(function() {
|
||||
var msg = document.getElementById('copyMsg');
|
||||
msg.textContent = '复制失败,请手动选择链接复制';
|
||||
msg.style.color = 'var(--red)';
|
||||
});
|
||||
}
|
||||
|
||||
function esc(s) { return String(s||'').replace(/[&<>"]/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"'}[c] }); }
|
||||
|
||||
init();
|
||||
</script>
|
||||
{% endblock %}
|
||||
286
static/sentiment.html
Normal file
286
static/sentiment.html
Normal file
@ -0,0 +1,286 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}AlphaX — 舆情雷达{% endblock %}
|
||||
{% block nav_links %}
|
||||
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||
<a class="sidebar-link" href="/watchlist"><svg class="link-icon"><use href="#svg-star"/></svg>关注</a>
|
||||
<a class="sidebar-link" href="/strategy"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||
<a class="sidebar-link" href="/iteration"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||
<a class="sidebar-link active" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||
{% endblock %}
|
||||
{% block extra_head_css %}
|
||||
<style>
|
||||
|
||||
/* SHELL */
|
||||
.shell { position: relative; z-index: 1; width: min(100% - 40px, 960px); margin: 0 auto; padding: 24px 0; }
|
||||
|
||||
/* Page title */
|
||||
.page-title { font-size: 24px; font-weight: 800; color: var(--ink); margin-bottom: 4px; }
|
||||
.page-sub { font-size: 13px; color: var(--stone); margin-bottom: 24px; }
|
||||
|
||||
/* === SECTION: DASHBOARD === */
|
||||
.dashboard-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr;
|
||||
gap: 14px; margin-bottom: 28px;
|
||||
}
|
||||
@media(max-width:640px) { .dashboard-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
/* Fear & Greed */
|
||||
.fg-card {
|
||||
background: var(--canvas); border: 1px solid var(--hairline-soft);
|
||||
border-radius: var(--radius-xl); padding: 24px;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 12px;
|
||||
}
|
||||
.fg-card .fg-label { font-size: 12px; color: var(--stone); font-weight: 600; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.fg-card .fg-value { font-size: 56px; font-weight: 900; line-height: 1; transition: color .3s; }
|
||||
.fg-card .fg-class { font-size: 15px; font-weight: 700; padding: 4px 16px; border-radius: var(--radius-full); }
|
||||
.fg-gauge { width: 100%; height: 8px; border-radius: 4px; background: linear-gradient(to right, #e53e3e, #f59e0b, #84cc16); position: relative; }
|
||||
.fg-gauge::after {
|
||||
content: ""; position: absolute; top: -4px;
|
||||
width: 16px; height: 16px; border-radius: 50%; background: var(--canvas);
|
||||
border: 3px solid var(--ink); transition: left .5s;
|
||||
left: 0%;
|
||||
}
|
||||
|
||||
/* Trending card */
|
||||
.trend-card {
|
||||
background: var(--canvas); border: 1px solid var(--hairline-soft);
|
||||
border-radius: var(--radius-xl); padding: 18px 20px;
|
||||
}
|
||||
.trend-card .section-label { font-size: 12px; color: var(--stone); font-weight: 600; margin-bottom: 14px; }
|
||||
.trend-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.trend-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--hairline-soft); }
|
||||
.trend-row:last-child { border-bottom: 0; }
|
||||
.trend-icon { width: 28px; height: 28px; border-radius: 50%; background: var(--surface); display: grid; place-items: center; font-weight: 800; font-size: 10px; color: var(--steel); flex-shrink: 0; }
|
||||
.trend-icon img { width: 28px; height: 28px; border-radius: 50%; }
|
||||
.trend-name { font-weight: 700; font-size: 14px; color: var(--ink); }
|
||||
.trend-symbol { font-size: 11px; color: var(--stone); margin-left: 4px; }
|
||||
.trend-rank { margin-left: auto; font-size: 11px; color: var(--muted); }
|
||||
|
||||
/* === SECTION: NEWS FEED === */
|
||||
.feed-header { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
|
||||
.feed-header h2 { font-size: 18px; font-weight: 700; }
|
||||
.feed-header .feed-count { font-size: 12px; color: var(--muted); background: var(--surface); padding: 2px 10px; border-radius: var(--radius-full); }
|
||||
|
||||
.news-feed { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.news-card {
|
||||
background: var(--canvas); border: 1px solid var(--hairline-soft);
|
||||
border-radius: var(--radius-xl); padding: 16px 18px;
|
||||
transition: .15s; cursor: pointer; display: flex; gap: 14px; align-items: flex-start;
|
||||
}
|
||||
.news-card:hover { border-color: var(--hairline); box-shadow: 0 2px 8px rgba(5,0,56,.04); }
|
||||
.news-card:active { transform: scale(.995); }
|
||||
|
||||
.news-source {
|
||||
flex-shrink: 0; min-width: 56px; font-size: 10px; font-weight: 700;
|
||||
color: var(--stone); padding: 4px 8px; background: var(--surface);
|
||||
border-radius: var(--radius-md); text-align: center; white-space: nowrap;
|
||||
overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.news-source.cn { color: var(--blue); background: rgba(66,98,255,.06); }
|
||||
.news-body { flex: 1; min-width: 0; }
|
||||
.news-title { font-size: 14px; font-weight: 600; color: var(--ink); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 6px; }
|
||||
.news-meta { display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--muted); }
|
||||
.news-meta .dot { width: 3px; height: 3px; border-radius: 50%; background: var(--hairline); }
|
||||
|
||||
/* Empty */
|
||||
.empty-state { text-align:center; padding:48px 20px; color:var(--stone); }
|
||||
.empty-state p { font-size:14px; }
|
||||
|
||||
/* Loading */
|
||||
.loading-pulse { animation: pulse 1.5s ease-in-out infinite; }
|
||||
@keyframes pulse { 0%,100%{ opacity:1 } 50%{ opacity:.3 } }
|
||||
.spin { animation: spin 1s linear infinite; }
|
||||
@keyframes spin { to{ transform:rotate(360deg) } }
|
||||
|
||||
.shell { width: min(100% - 24px, 960px); }
|
||||
.fg-card .fg-value { font-size: 42px; }
|
||||
.news-card { padding: 14px 14px; gap: 10px; }
|
||||
.news-source { min-width: 48px; font-size: 9px; padding: 3px 6px; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="shell">
|
||||
<h1 class="page-title">实时舆情</h1>
|
||||
<p class="page-sub">市场情绪 + 热门币种 + 最新加密新闻</p>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<div class="dashboard-grid">
|
||||
<div class="fg-card" id="fgCard">
|
||||
<div class="fg-label">恐惧 & 贪婪指数</div>
|
||||
<div class="fg-value loading-pulse" id="fgValue">--</div>
|
||||
<div class="fg-class" id="fgClass">加载中</div>
|
||||
<div class="fg-gauge" id="fgGauge"></div>
|
||||
</div>
|
||||
|
||||
<div class="trend-card">
|
||||
<div class="section-label">CoinGecko 热门币种</div>
|
||||
<div class="trend-list loading-pulse" id="trendList">
|
||||
<div class="trend-row"><span class="trend-icon">?</span><span class="trend-name">加载中...</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- News Feed -->
|
||||
<div class="feed-header">
|
||||
<h2>新闻信息流</h2>
|
||||
<span class="feed-count" id="feedCount">--</span>
|
||||
</div>
|
||||
<div class="news-feed" id="newsFeed">
|
||||
<div class="empty-state"><p>加载中...</p></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_script %}
|
||||
<script>
|
||||
var API = '';
|
||||
|
||||
// ====== USER ======
|
||||
var currentUser = null;
|
||||
var $ = function(id){ return document.getElementById(id); };
|
||||
|
||||
async function loadUser() {
|
||||
try {
|
||||
var resp = await fetch(API + '/api/auth/me');
|
||||
if (!resp.ok) return;
|
||||
var data = await resp.json();
|
||||
currentUser = data.user;
|
||||
var email = currentUser.email || '--';
|
||||
$('userInitial').textContent = email.charAt(0).toUpperCase();
|
||||
$('userEmailShort').textContent = email.length > 14 ? email.slice(0,12) + '…' : email;
|
||||
$('ddEmail').textContent = email;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function toggleUserMenu() {
|
||||
$('userDropdown').classList.toggle('show');
|
||||
}
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!e.target.closest('.sidebar-user') && !e.target.closest('.user-dropdown')) $('userDropdown').classList.remove('show');
|
||||
});
|
||||
|
||||
function showChangePwd() {
|
||||
$('userDropdown').classList.remove('show');
|
||||
$('pwdModal').classList.add('show');
|
||||
$('oldPwd').value = ''; $('newPwd').value = ''; $('cfmPwd').value = ''; $('pwdMsg').textContent = '';
|
||||
}
|
||||
function closePwdModal() { $('pwdModal').classList.remove('show'); }
|
||||
|
||||
async function changePwd() {
|
||||
var old = $('oldPwd').value, nw = $('newPwd').value, cf = $('cfmPwd').value;
|
||||
if (!old || !nw) { $('pwdMsg').className = 'modal-msg err'; $('pwdMsg').textContent = '请填写所有字段'; return; }
|
||||
if (nw.length < 8) { $('pwdMsg').className = 'modal-msg err'; $('pwdMsg').textContent = '新密码至少 8 位'; return; }
|
||||
if (nw !== cf) { $('pwdMsg').className = 'modal-msg err'; $('pwdMsg').textContent = '两次密码不一致'; return; }
|
||||
try {
|
||||
var r = await fetch(API + '/api/auth/change-password', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({old_password: old, new_password: nw})
|
||||
});
|
||||
var d = await r.json();
|
||||
if (!r.ok) throw new Error(d.detail || '修改失败');
|
||||
$('pwdMsg').className = 'modal-msg ok'; $('pwdMsg').textContent = d.message || '修改成功';
|
||||
setTimeout(closePwdModal, 1200);
|
||||
} catch(e) { $('pwdMsg').className = 'modal-msg err'; $('pwdMsg').textContent = e.message; }
|
||||
}
|
||||
|
||||
async function doLogout() {
|
||||
await fetch(API + '/api/auth/logout', { method: 'POST' });
|
||||
window.location.href = '/auth';
|
||||
}
|
||||
|
||||
// ====== FEED ======
|
||||
|
||||
function ageStr(h) {
|
||||
if (h == null) return '';
|
||||
if (h < 1) return Math.round(h * 60) + '分钟前';
|
||||
if (h < 24) return Math.floor(h) + '小时前';
|
||||
return Math.floor(h / 24) + '天前';
|
||||
}
|
||||
|
||||
function fgColor(v) {
|
||||
if (v <= 25) return 'var(--red)';
|
||||
if (v <= 45) return 'var(--orange)';
|
||||
if (v <= 55) return 'var(--yellow)';
|
||||
if (v <= 75) return 'var(--green)';
|
||||
return 'var(--green)';
|
||||
}
|
||||
|
||||
async function loadFeed() {
|
||||
try {
|
||||
var resp = await fetch(API + '/api/newsfeed');
|
||||
var data = await resp.json();
|
||||
|
||||
// Fear & Greed
|
||||
var fg = data.fear_greed;
|
||||
if (fg) {
|
||||
var v = fg.value, cls = fg.classification, clr = fgColor(v);
|
||||
document.getElementById('fgValue').textContent = v;
|
||||
document.getElementById('fgValue').style.color = clr;
|
||||
document.getElementById('fgValue').classList.remove('loading-pulse');
|
||||
document.getElementById('fgClass').textContent = cls;
|
||||
document.getElementById('fgClass').style.color = clr;
|
||||
document.getElementById('fgClass').style.background = clr + '15';
|
||||
document.getElementById('fgGauge').style.setProperty('--pos', v + '%');
|
||||
// Update gauge pointer position
|
||||
var g = document.getElementById('fgGauge');
|
||||
g.style.setProperty('--pos', v + '%');
|
||||
// Re-apply ::after style with JS since CSS custom properties in pseudo-elements can be tricky
|
||||
var sheet = document.createElement('style');
|
||||
sheet.textContent = '#fgGauge::after { left: calc(' + v + '% - 8px); }';
|
||||
document.head.appendChild(sheet);
|
||||
}
|
||||
|
||||
// Trending
|
||||
var trends = data.trending || [];
|
||||
document.getElementById('trendList').classList.remove('loading-pulse');
|
||||
if (trends.length) {
|
||||
document.getElementById('trendList').innerHTML = trends.map(function(t, i) {
|
||||
var icon = t.thumb
|
||||
? '<img src="' + t.thumb + '" alt="' + t.symbol + '" onerror="this.style.display=\'none\';this.nextSibling.style.display=\'block\'"><span style="display:none">' + t.symbol.charAt(0) + '</span>'
|
||||
: t.symbol.slice(0, 2).toUpperCase();
|
||||
return '<div class="trend-row">' +
|
||||
'<div class="trend-icon">' + icon + '</div>' +
|
||||
'<div><span class="trend-name">' + t.name + '</span><span class="trend-symbol">' + t.symbol + '</span></div>' +
|
||||
'<span class="trend-rank">#' + (t.market_cap_rank || '--') + '</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
} else {
|
||||
document.getElementById('trendList').innerHTML = '<div style="color:var(--muted);font-size:13px;text-align:center;padding:20px">暂无数据</div>';
|
||||
}
|
||||
|
||||
// News feed
|
||||
var news = data.news || [];
|
||||
document.getElementById('feedCount').textContent = news.length + ' 条';
|
||||
if (news.length) {
|
||||
document.getElementById('newsFeed').innerHTML = news.map(function(n) {
|
||||
var isCn = n.lang === 'cn';
|
||||
return '<a class="news-card" href="' + n.url + '" target="_blank" rel="noopener">' +
|
||||
'<span class="news-source' + (isCn ? ' cn' : '') + '">' + n.source + '</span>' +
|
||||
'<div class="news-body">' +
|
||||
'<div class="news-title">' + n.title + '</div>' +
|
||||
'<div class="news-meta">' +
|
||||
'<span>' + (isCn ? '中文' : 'EN') + '</span>' +
|
||||
'<span class="dot"></span>' +
|
||||
'<span>' + ageStr(n.age_hours) + '</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</a>';
|
||||
}).join('');
|
||||
} else {
|
||||
document.getElementById('newsFeed').innerHTML = '<div class="empty-state"><p>暂无新闻数据</p></div>';
|
||||
}
|
||||
} catch(e) {
|
||||
document.getElementById('newsFeed').innerHTML = '<div class="empty-state"><p>加载失败,请稍后重试</p></div>';
|
||||
}
|
||||
}
|
||||
|
||||
loadUser();
|
||||
loadFeed();
|
||||
// Auto-refresh every 5 minutes
|
||||
setInterval(loadFeed, 300000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
36
static/strategy.html
Normal file
36
static/strategy.html
Normal file
@ -0,0 +1,36 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}策略 — AlphaX{% endblock %}
|
||||
{% block nav_links %}
|
||||
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||
<a class="sidebar-link" href="/watchlist"><svg class="link-icon"><use href="#svg-star"/></svg>关注</a>
|
||||
<a class="sidebar-link active" href="/strategy"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||
<a class="sidebar-link" href="/iteration"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||
{% endblock %}
|
||||
{% block extra_head_css %}
|
||||
<style>
|
||||
.shell{width:min(100% - 40px,1180px);margin:0 auto;padding:24px 0 48px}.page-head{margin-bottom:20px}.page-head h1{font-size:28px;letter-spacing:-.8px}.page-head p{color:var(--stone);font-size:14px;margin-top:4px}.metrics{display:grid;grid-template-columns:repeat(6,1fr);gap:12px;margin-bottom:12px}.metric{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-xl);padding:18px;text-align:center}.metric .num{font-size:30px;font-weight:900;letter-spacing:-.8px}.metric .lbl{font-size:12px;color:var(--stone);font-weight:700;margin-top:4px}.disclaimer{font-size:12px;color:var(--stone);background:var(--surface);border:1px solid var(--hairline-soft);border-radius:var(--radius-lg);padding:10px 14px;margin-bottom:16px}.panel{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-xl);padding:18px;margin-bottom:16px}.panel h2{font-size:16px;margin-bottom:12px}.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.table{width:100%;border-collapse:collapse}.table th,.table td{padding:9px 8px;border-bottom:1px solid var(--hairline-soft);font-size:12px;text-align:left}.table th{color:var(--stone);font-weight:800}.pos{color:var(--green);font-weight:800}.neg{color:var(--red);font-weight:800}.tag{display:inline-flex;border-radius:var(--radius-full);background:var(--surface);padding:3px 8px;font-weight:800}.empty{color:var(--stone);font-size:13px;padding:16px;background:var(--surface);border-radius:var(--radius-lg)}@media(max-width:980px){.metrics{grid-template-columns:repeat(2,1fr)}}@media(max-width:820px){.grid{grid-template-columns:1fr}.shell{width:min(100% - 24px,1180px)}}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="shell">
|
||||
<div class="page-head"><h1>策略</h1><p>系统可信度、版本表现、因子归因与市场环境归因。</p></div>
|
||||
<div class="metrics" id="metrics"></div>
|
||||
<div class="disclaimer">数据基于历史信号跟踪,仅用于策略研究与模型评估,不构成收益承诺或投资建议。</div>
|
||||
<div class="grid">
|
||||
<section class="panel"><h2>版本表现</h2><div id="versionPerf"></div></section>
|
||||
<section class="panel"><h2>市场环境归因</h2><div id="envPerf"></div></section>
|
||||
</div>
|
||||
<section class="panel"><h2>因子归因</h2><div id="factorPerf"></div></section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_script %}
|
||||
<script>
|
||||
function pct(x){x=Number(x||0);return (x>=0?'+':'')+x.toFixed(2)+'%'}function cls(x){return Number(x||0)>=0?'pos':'neg'}
|
||||
function table(rows,key){if(!rows||!rows.length)return'<div class="empty">暂无数据</div>';return '<table class="table"><thead><tr><th>名称</th><th>次数</th><th>命中率</th><th>平均表现</th><th>最大表现</th><th>最大回撤</th></tr></thead><tbody>'+rows.map(r=>'<tr><td><span class="tag">'+(r[key]||'--')+'</span></td><td>'+r.total_count+'</td><td>'+Number(r.win_rate_pct||0).toFixed(1)+'%</td><td class="'+cls(r.avg_pnl_pct)+'">'+pct(r.avg_pnl_pct)+'</td><td class="pos">'+pct(r.max_gain_pct)+'</td><td class="neg">'+Number(r.max_drawdown_pct||0).toFixed(2)+'%</td></tr>').join('')+'</tbody></table>'}
|
||||
async function load(){try{var d=await (await fetch('/api/strategy/insights')).json();var o=d.overview||{};var active=await (await fetch('/api/recommendations/active?actionable_only=false&hours=12')).json();metrics.innerHTML=['总信号|'+(o.total_signals||0)+'|','已验证|'+(o.resolved_count||0)+'|','信号命中率|'+Number(o.win_rate_pct||0).toFixed(1)+'%|green','当前有效机会|'+(Array.isArray(active)?active.length:0)+'|blue','最大表现|'+pct(o.max_gain_pct)+'|green','最大回撤|'+Number(o.max_drawdown_pct||0).toFixed(1)+'%|red'].map(s=>{var a=s.split('|');return '<div class="metric"><div class="num" '+(a[2]?'style="color:var(--'+a[2]+')"':'')+'>'+a[1]+'</div><div class="lbl">'+a[0]+'</div></div>'}).join('');versionPerf.innerHTML=table(d.version_performance,'strategy_version');envPerf.innerHTML=table(d.market_environment,'environment');factorPerf.innerHTML=table(d.factor_attribution,'factor')}catch(e){metrics.innerHTML='<div class="empty">加载失败</div>'}}load();
|
||||
</script>
|
||||
{% endblock %}
|
||||
256
static/subscription.html
Normal file
256
static/subscription.html
Normal file
@ -0,0 +1,256 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}订阅中心 — AlphaX{% endblock %}
|
||||
{% block nav_links %}
|
||||
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||
<a class="sidebar-link" href="/watchlist"><svg class="link-icon"><use href="#svg-star"/></svg>关注</a>
|
||||
<a class="sidebar-link" href="/strategy"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||
<a class="sidebar-link" href="/iteration"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||
<a class="sidebar-link active" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||
{% endblock %}
|
||||
{% block extra_head_css %}
|
||||
<style>
|
||||
|
||||
.shell { position: relative; z-index: 1; width: min(100% - 64px, 900px); margin: 0 auto; padding: 48px 0 64px; }
|
||||
.shell h1 { font-size: 32px; font-weight: 500; letter-spacing: -1px; margin-bottom: 8px; }
|
||||
.shell .sub { color: var(--slate); font-size: 15px; margin-bottom: 32px; }
|
||||
|
||||
/* Status bar */
|
||||
.status-bar {
|
||||
border: 1px solid var(--hairline-soft); background: var(--canvas);
|
||||
border-radius: var(--radius-lg); padding: 16px 20px; margin-bottom: 32px;
|
||||
display: flex; align-items: center; gap: 24px; flex-wrap: wrap;
|
||||
}
|
||||
.status-bar .status-item { font-size: 13px; color: var(--slate); }
|
||||
.status-bar .status-item strong { color: var(--ink); }
|
||||
.guide-box {
|
||||
border: 1px solid rgba(66,98,255,.16);
|
||||
background: linear-gradient(135deg, rgba(66,98,255,.08), rgba(255,208,47,.16));
|
||||
border-radius: var(--radius-xl); padding: 18px 20px; margin-bottom: 22px;
|
||||
display: none;
|
||||
}
|
||||
.guide-box.show { display: block; }
|
||||
.guide-title { font-size: 16px; font-weight: 700; color: var(--ink); margin-bottom: 6px; }
|
||||
.guide-text { font-size: 14px; color: var(--slate); line-height: 1.7; }
|
||||
|
||||
/* Plans grid */
|
||||
.plans { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
.plan {
|
||||
border-radius: var(--radius-xl); padding: 26px; border: 1px solid var(--hairline);
|
||||
background: var(--canvas); display: flex; flex-direction: column; gap: 16px;
|
||||
transition: transform .15s, border-color .15s, box-shadow .15s;
|
||||
min-height: 230px;
|
||||
}
|
||||
.plan:hover { transform: translateY(-2px); border-color: var(--hairline-strong); box-shadow: 0 8px 22px rgba(5,0,56,.05); }
|
||||
.plan.featured { border: 2px solid var(--blue); }
|
||||
.plan-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
|
||||
.plan h3 { font-size: 20px; font-weight: 600; letter-spacing: -.3px; }
|
||||
.plan .price { font-size: 34px; font-weight: 700; line-height: 1.15; margin-top: 2px; }
|
||||
.plan .price unit { font-size: 14px; font-weight: 500; color: var(--slate); }
|
||||
.price-pending { display: inline-flex; align-items: center; min-height: 39px; color: var(--stone); font-size: 17px; font-weight: 600; letter-spacing: -.2px; }
|
||||
.plan .desc { font-size: 13px; color: var(--slate); line-height: 1.5; min-height: 40px; }
|
||||
.plan-spacer { flex: 1; }
|
||||
|
||||
/* Plan button */
|
||||
.btn-plan {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 100%; height: 44px; border: 0; border-radius: var(--radius-full);
|
||||
font-weight: 500; font-size: 14px; cursor: pointer;
|
||||
transition: background .15s, transform .15s;
|
||||
}
|
||||
.btn-plan.green { background: var(--green); color: #fff; }
|
||||
.btn-plan.green:hover { opacity: .9; }
|
||||
.btn-plan.disabled { background: var(--hairline-soft); color: var(--muted); cursor: not-allowed; }
|
||||
|
||||
.badge { display: inline-flex; align-items: center; font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: var(--radius-full); }
|
||||
.badge.yellow { background: var(--yellow); color: var(--primary); }
|
||||
.badge.gray { background: var(--surface); color: var(--stone); }
|
||||
.badge.blue { background: rgba(66,98,255,.1); color: var(--blue); }
|
||||
|
||||
.msg { font-size: 13px; min-height: 20px; margin-top: 8px; }
|
||||
.msg.ok { color: var(--green); } .msg.err { color: #e53e3e; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.plans { grid-template-columns: 1fr; }
|
||||
.shell { width: min(100% - 32px, 900px); padding: 24px 0 48px; }
|
||||
.status-bar { flex-direction: column; gap: 8px; align-items: flex-start; }
|
||||
.shell h1 { font-size: 24px; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="shell">
|
||||
<h1>订阅中心</h1>
|
||||
<p class="sub">新用户可免费体验 1 个月,后续按需选择方案。</p>
|
||||
|
||||
<div id="guideBox" class="guide-box">
|
||||
<div class="guide-title" id="guideTitle">先开通套餐,开始使用 AlphaX</div>
|
||||
<div class="guide-text" id="guideText">新用户可领取 30 天免费体验。开通后即可进入看板、策略、迭代和舆情页面。</div>
|
||||
</div>
|
||||
|
||||
<div class="status-bar" id="statusBar" style="display:none">
|
||||
<div class="status-item">当前方案: <strong id="mePlan">--</strong></div>
|
||||
<div class="status-item">到期时间: <strong id="meEnd">--</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="plans">
|
||||
<div class="plan featured">
|
||||
<div class="plan-top"><h3>免费体验</h3><span class="badge yellow">推荐</span></div>
|
||||
<div class="price"><unit>$</unit>0</div>
|
||||
<p class="desc">新用户可领取 30 天 Beta 体验。</p>
|
||||
<div id="trialMsg" class="msg"></div>
|
||||
<div class="plan-spacer"></div>
|
||||
<button class="btn-plan green" id="freeBtn" onclick="claimFreeTrial()">立即开通</button>
|
||||
</div>
|
||||
|
||||
<div class="plan">
|
||||
<div class="plan-top"><h3>月付</h3><span class="badge gray">即将上线</span></div>
|
||||
<div class="price"><span class="price-pending">价格开放前公布</span></div>
|
||||
<p class="desc">适合短期体验和灵活使用。</p>
|
||||
<div class="plan-spacer"></div>
|
||||
<button class="btn-plan disabled">即将上线</button>
|
||||
</div>
|
||||
|
||||
<div class="plan">
|
||||
<div class="plan-top"><h3>季付</h3><span class="badge gray">即将上线</span></div>
|
||||
<div class="price"><span class="price-pending">价格开放前公布</span></div>
|
||||
<p class="desc">适合持续跟踪市场机会。</p>
|
||||
<div class="plan-spacer"></div>
|
||||
<button class="btn-plan disabled">即将上线</button>
|
||||
</div>
|
||||
|
||||
<div class="plan">
|
||||
<div class="plan-top"><h3>年付</h3><span class="badge gray">即将上线</span></div>
|
||||
<div class="price"><span class="price-pending">价格开放前公布</span></div>
|
||||
<p class="desc">适合长期使用和策略复盘。</p>
|
||||
<div class="plan-spacer"></div>
|
||||
<button class="btn-plan disabled">即将上线</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_script %}
|
||||
<script>
|
||||
var $ = function(id){ return document.getElementById(id); };
|
||||
|
||||
// ====== USER ======
|
||||
var currentUser = null;
|
||||
function readablePlanName(code) {
|
||||
var names = {
|
||||
free_trial_1m: '免费体验',
|
||||
monthly_usdt: '月付',
|
||||
quarterly_usdt: '季付',
|
||||
yearly_usdt: '年付'
|
||||
};
|
||||
return names[code] || code || '未开通';
|
||||
}
|
||||
|
||||
async function loadUser() {
|
||||
try {
|
||||
var resp = await fetch('/api/auth/me');
|
||||
if (!resp.ok) return;
|
||||
var data = await resp.json();
|
||||
currentUser = data.user;
|
||||
var email = currentUser.email || '--';
|
||||
$('userInitial').textContent = email.charAt(0).toUpperCase();
|
||||
$('userEmailShort').textContent = email.length > 14 ? email.slice(0,12) + '…' : email;
|
||||
$('ddEmail').textContent = email;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function toggleUserMenu() {
|
||||
$('userDropdown').classList.toggle('show');
|
||||
}
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!e.target.closest('.sidebar-user') && !e.target.closest('.user-dropdown')) $('userDropdown').classList.remove('show');
|
||||
});
|
||||
|
||||
function showChangePwd() {
|
||||
$('userDropdown').classList.remove('show');
|
||||
$('pwdModal').classList.add('show');
|
||||
$('oldPwd').value = ''; $('newPwd').value = ''; $('cfmPwd').value = ''; $('pwdMsg').textContent = '';
|
||||
}
|
||||
function closePwdModal() { $('pwdModal').classList.remove('show'); }
|
||||
|
||||
async function changePwd() {
|
||||
var old = $('oldPwd').value, nw = $('newPwd').value, cf = $('cfmPwd').value;
|
||||
if (!old || !nw) { $('pwdMsg').className = 'modal-msg err'; $('pwdMsg').textContent = '请填写所有字段'; return; }
|
||||
if (nw.length < 8) { $('pwdMsg').className = 'modal-msg err'; $('pwdMsg').textContent = '新密码至少 8 位'; return; }
|
||||
if (nw !== cf) { $('pwdMsg').className = 'modal-msg err'; $('pwdMsg').textContent = '两次密码不一致'; return; }
|
||||
try {
|
||||
var r = await fetch('/api/auth/change-password', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({old_password: old, new_password: nw})
|
||||
});
|
||||
var d = await r.json();
|
||||
if (!r.ok) throw new Error(d.detail || '修改失败');
|
||||
$('pwdMsg').className = 'modal-msg ok'; $('pwdMsg').textContent = d.message || '修改成功';
|
||||
setTimeout(closePwdModal, 1200);
|
||||
} catch(e) { $('pwdMsg').className = 'modal-msg err'; $('pwdMsg').textContent = e.message; }
|
||||
}
|
||||
|
||||
async function doLogout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
window.location.href = '/auth';
|
||||
}
|
||||
|
||||
// ====== SUBSCRIPTION ======
|
||||
async function post(url, body) {
|
||||
var r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) });
|
||||
var data = await r.json().catch(function(){ return { detail: '请求失败' }; });
|
||||
if (!r.ok) throw new Error(data.detail || '请求失败');
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
var r = await fetch('/api/auth/me');
|
||||
if (!r.ok) { window.location.href = '/auth'; return; }
|
||||
var data = await r.json();
|
||||
var s = data.subscription;
|
||||
var params = new URLSearchParams(location.search);
|
||||
if (!s || params.get('welcome') === '1' || params.get('expired') === '1') {
|
||||
document.getElementById('guideBox').classList.add('show');
|
||||
if (params.get('expired') === '1') {
|
||||
document.getElementById('guideTitle').textContent = '订阅已到期,请先续订';
|
||||
document.getElementById('guideText').textContent = '当前账号没有有效订阅。续订或开通套餐后,才能继续访问看板、策略、迭代和舆情页面。';
|
||||
} else if (!s) {
|
||||
document.getElementById('guideTitle').textContent = '欢迎使用 AlphaX,请先开通套餐';
|
||||
document.getElementById('guideText').textContent = '新用户可领取 30 天免费体验。开通后即可进入完整功能页面。';
|
||||
}
|
||||
}
|
||||
document.getElementById('statusBar').style.display = 'flex';
|
||||
document.getElementById('mePlan').textContent = s ? readablePlanName(s.plan_code) : '未开通';
|
||||
document.getElementById('meEnd').textContent = s ? String(s.end_at).slice(0, 10) : '--';
|
||||
if (s) {
|
||||
document.getElementById('freeBtn').textContent = '已开通';
|
||||
document.getElementById('freeBtn').className = 'btn-plan disabled';
|
||||
document.getElementById('freeBtn').onclick = null;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function claimFreeTrial() {
|
||||
try {
|
||||
var data = await post('/api/subscriptions/free-trial');
|
||||
var el = document.getElementById('trialMsg');
|
||||
var sub = data.subscription;
|
||||
el.className = 'msg ok'; el.textContent = '开通成功!到期: ' + String(sub.end_at).slice(0, 10);
|
||||
document.getElementById('freeBtn').textContent = '已开通';
|
||||
document.getElementById('freeBtn').className = 'btn-plan disabled';
|
||||
document.getElementById('freeBtn').onclick = null;
|
||||
document.getElementById('mePlan').textContent = readablePlanName(sub.plan_code);
|
||||
document.getElementById('meEnd').textContent = String(sub.end_at).slice(0, 10);
|
||||
setTimeout(function(){ window.location.href = '/app'; }, 900);
|
||||
} catch (e) {
|
||||
var el = document.getElementById('trialMsg');
|
||||
el.className = 'msg err'; el.textContent = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
loadUser();
|
||||
loadMe();
|
||||
</script>
|
||||
{% endblock %}
|
||||
40
static/watchlist.html
Normal file
40
static/watchlist.html
Normal file
@ -0,0 +1,40 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}关注 — AlphaX{% endblock %}
|
||||
{% block nav_links %}
|
||||
<a class="sidebar-link" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>看板</a>
|
||||
<a class="sidebar-link active" href="/watchlist"><svg class="link-icon"><use href="#svg-star"/></svg>关注</a>
|
||||
<a class="sidebar-link" href="/strategy"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
|
||||
<a class="sidebar-link" href="/iteration"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
|
||||
<a class="sidebar-link" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>舆情</a>
|
||||
<a class="sidebar-link" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||
<a class="sidebar-link" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>推荐</a>
|
||||
<a class="sidebar-link admin-link" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>管理</a>
|
||||
{% endblock %}
|
||||
{% block extra_head_css %}
|
||||
<style>
|
||||
.shell{width:min(100% - 40px,1180px);margin:0 auto;padding:24px 0 48px}.page-head{margin-bottom:20px}.page-head h1{font-size:28px;letter-spacing:-.8px}.page-head p{color:var(--stone);font-size:14px;margin-top:4px}.grid{display:grid;grid-template-columns:1fr;gap:16px}.panel{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-xl);padding:18px}.panel h2{font-size:16px;margin-bottom:12px}.actions{display:flex;gap:8px;flex-wrap:wrap}.input{height:42px;border:1px solid var(--hairline-strong);border-radius:var(--radius-full);padding:0 14px;outline:none;min-width:220px}.btn{border:1px solid var(--hairline-strong);background:var(--canvas);border-radius:var(--radius-full);padding:9px 14px;font-size:13px;font-weight:700;cursor:pointer}.btn.primary{background:var(--primary);color:var(--on-primary);border-color:var(--primary)}.tokens{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}.token{display:inline-flex;align-items:center;gap:6px;background:var(--surface);border-radius:var(--radius-full);padding:6px 10px;font-size:13px;font-weight:800}.token button{border:0;background:transparent;color:var(--stone);cursor:pointer;font-weight:900}.watch-cards{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:14px}.watch-card{border:1px solid var(--hairline-soft);border-radius:var(--radius-lg);padding:14px;background:var(--surface)}.watch-card b{font-size:16px}.meta{font-size:12px;color:var(--stone);margin-top:6px}.status{display:inline-flex;margin-top:8px;border-radius:var(--radius-full);padding:3px 8px;font-size:11px;font-weight:800}.status.buy{color:var(--green);background:var(--green-light)}.status.wait{color:var(--yellow-dark);background:var(--yellow-light)}.status.obs{color:var(--blue);background:rgba(66,98,255,.06)}.empty{color:var(--stone);font-size:13px;padding:12px 0}@media(max-width:820px){.watch-cards{grid-template-columns:1fr}.shell{width:min(100% - 24px,1180px);padding-top:16px}.actions .input{flex:1;min-width:160px}.btn{min-height:44px}}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="shell">
|
||||
<div class="page-head"><h1>关注</h1><p>集中维护你关心的币种;看板里可以用“只看关注池”快速筛选。</p></div>
|
||||
<div class="grid">
|
||||
<section class="panel">
|
||||
<h2>我的关注池</h2>
|
||||
<div class="actions"><input class="input" id="symbolInput" placeholder="输入币种,如 ENS"><button class="btn primary" onclick="addSymbol()">加入关注</button><a class="btn" href="/app">回看板</a></div>
|
||||
<div class="tokens" id="tokens"></div>
|
||||
</section>
|
||||
<section class="panel"><h2>关注池实时状态</h2><div class="watch-cards" id="watchCards"></div></section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_script %}
|
||||
<script>
|
||||
var personalization={watchlist:[]};var active=[];
|
||||
function norm(s){s=String(s||'').trim().toUpperCase();return s?(s.indexOf('/')>=0?s:s+'/USDT'):''}function base(s){return String(s||'').replace('/USDT','')}function priceDecimals(p){p=Math.abs(Number(p||0));if(p<=0)return 2;if(p<0.0001)return 8;if(p<0.001)return 7;if(p<0.01)return 6;if(p<0.1)return 5;if(p<1)return 4;if(p<10)return 3;return 2}function fmtPrice(p){p=Number(p||0);return p>0?('$'+p.toFixed(priceDecimals(p))):'--'}
|
||||
async function loadAll(){try{var p=await fetch('/api/personalization');if(p.ok)personalization=await p.json()}catch(e){}try{active=await (await fetch('/api/recommendations/active?actionable_only=false&hours=12')).json()}catch(e){active=[]}render()}
|
||||
function render(){var wl=personalization.watchlist||[];tokens.innerHTML=wl.length?wl.map(s=>'<span class="token">'+base(s)+'<button onclick="removeSymbol(\''+base(s)+'\')">×</button></span>').join(''):'<div class="empty">暂无关注币</div>';renderWatchCards(wl)}
|
||||
function renderWatchCards(wl){var map={};active.forEach(x=>map[x.symbol]=x);watchCards.innerHTML=wl.length?wl.map(s=>{var x=map[s]||{};var st=x.execution_status==='buy_now'?'buy':x.execution_status==='wait_pullback'?'wait':'obs';var label=x.execution_status==='buy_now'?'入场窗口':x.execution_status==='wait_pullback'?'等回踩':x.symbol?'观察中':'暂无信号';return '<div class="watch-card"><b>'+base(s)+'</b><div class="meta">当前价 '+fmtPrice(x.current_price)+'</div><span class="status '+st+'">'+label+'</span><div class="meta">最后信号 '+(x.rec_time?fmtTime(x.rec_time):'--')+'</div></div>'}).join(''):'<div class="empty">先加入关注币,系统会自动显示它们的实时状态。</div>'}
|
||||
async function addSymbol(){var s=norm(symbolInput.value);if(!s)return;await fetch('/api/watchlist',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({symbol:s})});symbolInput.value='';loadAll()}async function removeSymbol(s){await fetch('/api/watchlist/'+encodeURIComponent(s),{method:'DELETE'});loadAll()}function fmtTime(t){if(!t)return'--';var diff=(Date.now()-new Date(t).getTime())/1000;if(diff<60)return'刚刚';if(diff<3600)return Math.round(diff/60)+'分钟前';if(diff<86400)return Math.round(diff/3600)+'小时前';var d=new Date(t);return(d.getMonth()+1)+'/'+d.getDate()}loadAll();
|
||||
</script>
|
||||
{% endblock %}
|
||||
232
stock_report_template.html
Normal file
232
stock_report_template.html
Normal file
@ -0,0 +1,232 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>股票投研报告模板</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b1020;
|
||||
--panel: #121a2b;
|
||||
--panel-2: #182235;
|
||||
--border: rgba(255,255,255,0.08);
|
||||
--text: #edf2f7;
|
||||
--muted: #94a3b8;
|
||||
--accent: #38bdf8;
|
||||
--accent-2: #818cf8;
|
||||
--good: #22c55e;
|
||||
--warn: #f59e0b;
|
||||
--bad: #ef4444;
|
||||
--shadow: 0 10px 30px rgba(0,0,0,0.35);
|
||||
--radius: 18px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
background: linear-gradient(180deg, #09101d 0%, #0b1020 100%);
|
||||
color: var(--text);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.container { max-width: 1320px; margin: 0 auto; padding: 28px 18px 56px; }
|
||||
.topbar {
|
||||
display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.back-link {
|
||||
display:inline-flex; align-items:center; gap:8px; text-decoration:none; color:#cbd5e1;
|
||||
padding:10px 14px; border:1px solid var(--border); border-radius:999px; background:rgba(255,255,255,0.04);
|
||||
}
|
||||
.back-link:hover { color:#fff; border-color:rgba(56,189,248,0.35); }
|
||||
.hint {
|
||||
color: var(--muted); font-size: 13px;
|
||||
}
|
||||
.hero {
|
||||
background: linear-gradient(135deg, rgba(56,189,248,0.16), rgba(129,140,248,0.12));
|
||||
border: 1px solid rgba(129,140,248,0.18);
|
||||
border-radius: 24px;
|
||||
padding: 28px;
|
||||
box-shadow: var(--shadow);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.hero-top {
|
||||
display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; align-items: flex-start;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; padding: 6px 12px; border-radius: 999px; font-size: 12px;
|
||||
font-weight: 700; background: rgba(56,189,248,0.14); color: #7dd3fc; border: 1px solid rgba(56,189,248,0.22); margin-bottom: 12px;
|
||||
}
|
||||
h1 { margin: 0 0 8px; font-size: 34px; line-height: 1.2; }
|
||||
.subtitle { color: var(--muted); font-size: 15px; max-width: 900px; }
|
||||
.meta { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:12px; margin-top:22px; }
|
||||
.meta-item { background: rgba(255,255,255,0.04); border:1px solid var(--border); border-radius:14px; padding:14px 16px; }
|
||||
.meta-label { color:var(--muted); font-size:12px; margin-bottom:6px; }
|
||||
.meta-value { font-size:16px; font-weight:700; }
|
||||
.summary-grid { display:grid; grid-template-columns:1.3fr 1fr; gap:18px; margin-bottom:18px; }
|
||||
.panel { background:var(--panel); border:1px solid var(--border); border-radius:var(--radius); box-shadow:var(--shadow); overflow:hidden; }
|
||||
.panel-header { padding:18px 20px 10px; font-size:18px; font-weight:800; }
|
||||
.panel-body { padding:0 20px 20px; }
|
||||
.decision-box {
|
||||
background: linear-gradient(180deg, rgba(34,197,94,0.12), rgba(34,197,94,0.04));
|
||||
border: 1px solid rgba(34,197,94,0.2); border-radius:16px; padding:16px; margin-bottom:14px;
|
||||
}
|
||||
.decision-title { font-size:22px; font-weight:800; margin-bottom:8px; }
|
||||
.decision-text { color:#dbeafe; }
|
||||
.score-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:12px; }
|
||||
.score-card { background:var(--panel-2); border:1px solid var(--border); border-radius:14px; padding:14px; }
|
||||
.score-name { color:var(--muted); font-size:12px; margin-bottom:8px; }
|
||||
.score-value { font-size:28px; font-weight:800; margin-bottom:6px; }
|
||||
.score-comment { font-size:13px; color:#cbd5e1; }
|
||||
.layout { display:grid; grid-template-columns:1.2fr 0.8fr; gap:18px; margin-bottom:18px; }
|
||||
.section-block { padding:18px 20px 20px; border-top:1px solid rgba(255,255,255,0.04); }
|
||||
.section-block:first-child { border-top:none; }
|
||||
h2 { margin:0 0 12px; font-size:20px; }
|
||||
h3 { margin:0 0 10px; font-size:16px; }
|
||||
ul { margin:0; padding-left:20px; }
|
||||
li + li { margin-top:8px; }
|
||||
.tag-row { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
|
||||
.tag { padding:6px 10px; border-radius:999px; font-size:12px; border:1px solid var(--border); background:rgba(255,255,255,0.04); color:#dbeafe; }
|
||||
.kpi-list { display:grid; gap:10px; }
|
||||
.kpi-item {
|
||||
padding:12px 14px; border-radius:12px; background:rgba(255,255,255,0.04); border:1px solid var(--border);
|
||||
display:flex; justify-content:space-between; gap:12px;
|
||||
}
|
||||
.kpi-item span:first-child { color:var(--muted); }
|
||||
.action-table { width:100%; border-collapse:collapse; overflow:hidden; border-radius:14px; margin-top:8px; }
|
||||
.action-table th, .action-table td {
|
||||
padding:12px 14px; border-bottom:1px solid rgba(255,255,255,0.06); text-align:left; vertical-align:top; font-size:14px;
|
||||
}
|
||||
.action-table th { color:var(--muted); font-weight:600; background:rgba(255,255,255,0.03); }
|
||||
.action-table tr:last-child td { border-bottom:none; }
|
||||
.risk-good { color:var(--good); font-weight:700; }
|
||||
.risk-warn { color:var(--warn); font-weight:700; }
|
||||
.risk-bad { color:var(--bad); font-weight:700; }
|
||||
.footer-note { margin-top:18px; color:var(--muted); font-size:13px; text-align:center; }
|
||||
@media (max-width: 960px) {
|
||||
.summary-grid, .layout { grid-template-columns:1fr; }
|
||||
h1 { font-size:28px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="topbar">
|
||||
<a class="back-link" href="/">← 返回山寨币监控首页</a>
|
||||
<div class="hint">这是投研报告网页模板页。后续生成个股报告时,可按同样结构直接落地为单独页面。</div>
|
||||
</div>
|
||||
|
||||
<section class="hero">
|
||||
<div class="hero-top">
|
||||
<div>
|
||||
<div class="badge">港股 · 个股投研模板</div>
|
||||
<h1>小米集团(1810.HK)投研报告</h1>
|
||||
<div class="subtitle">示例:先给结论,再拆基本面、技术面、消息面、股东减持/回购与最终交易计划。这一页是以后所有单票 HTML 报告的标准骨架。</div>
|
||||
</div>
|
||||
<div class="tag-row">
|
||||
<div class="tag">周期:🔥短线 / 🎯中线</div>
|
||||
<div class="tag">评级:可关注</div>
|
||||
<div class="tag">当前结论:分批布局,不宜梭哈</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<div class="meta-item"><div class="meta-label">报告日期</div><div class="meta-value">{{report_date}}</div></div>
|
||||
<div class="meta-item"><div class="meta-label">最新价格</div><div class="meta-value">{{latest_price}}</div></div>
|
||||
<div class="meta-item"><div class="meta-label">涨跌幅</div><div class="meta-value">{{change_pct}}</div></div>
|
||||
<div class="meta-item"><div class="meta-label">市值 / 估值</div><div class="meta-value">{{market_cap_and_valuation}}</div></div>
|
||||
<div class="meta-item"><div class="meta-label">行业 / 主题</div><div class="meta-value">{{sector_theme}}</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="summary-grid">
|
||||
<div class="panel">
|
||||
<div class="panel-header">核心结论</div>
|
||||
<div class="panel-body">
|
||||
<div class="decision-box">
|
||||
<div class="decision-title">{{core_decision}}</div>
|
||||
<div class="decision-text">{{core_decision_reason}}</div>
|
||||
</div>
|
||||
<h3>三句话看懂这只票</h3>
|
||||
<ul>
|
||||
<li>{{summary_point_1}}</li>
|
||||
<li>{{summary_point_2}}</li>
|
||||
<li>{{summary_point_3}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">综合评分</div>
|
||||
<div class="panel-body">
|
||||
<div class="score-grid">
|
||||
<div class="score-card"><div class="score-name">综合评分</div><div class="score-value">{{total_score}}</div><div class="score-comment">{{total_score_comment}}</div></div>
|
||||
<div class="score-card"><div class="score-name">技术面</div><div class="score-value">{{technical_score}}</div><div class="score-comment">{{technical_comment}}</div></div>
|
||||
<div class="score-card"><div class="score-name">催化/资金面</div><div class="score-value">{{catalyst_score}}</div><div class="score-comment">{{catalyst_comment}}</div></div>
|
||||
<div class="score-card"><div class="score-name">基本面防雷</div><div class="score-value">{{fundamental_score}}</div><div class="score-comment">{{fundamental_comment}}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="layout">
|
||||
<div class="panel">
|
||||
<div class="panel-header">投研主报告</div>
|
||||
<div class="section-block"><h2>一、投资逻辑</h2><ul><li>{{logic_1}}</li><li>{{logic_2}}</li><li>{{logic_3}}</li></ul></div>
|
||||
<div class="section-block"><h2>二、基本面拆解</h2><ul><li>{{fundamental_detail_1}}</li><li>{{fundamental_detail_2}}</li><li>{{fundamental_detail_3}}</li></ul></div>
|
||||
<div class="section-block"><h2>三、技术面判断</h2><ul><li>{{technical_detail_1}}</li><li>{{technical_detail_2}}</li><li>{{technical_detail_3}}</li></ul></div>
|
||||
<div class="section-block"><h2>四、消息面 / 催化 / 资金</h2><ul><li>{{news_detail_1}}</li><li>{{news_detail_2}}</li><li>{{news_detail_3}}</li></ul></div>
|
||||
<div class="section-block"><h2>五、股东减持 / 回购 / 解禁</h2><ul><li>{{holder_detail_1}}</li><li>{{holder_detail_2}}</li><li>{{holder_detail_3}}</li></ul></div>
|
||||
<div class="section-block"><h2>六、风险点</h2><ul><li>{{risk_1}}</li><li>{{risk_2}}</li><li>{{risk_3}}</li></ul></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">操作面板</div>
|
||||
<div class="section-block">
|
||||
<h2>当前是否可建仓</h2>
|
||||
<div class="decision-box">
|
||||
<div class="decision-title">{{entry_status}}</div>
|
||||
<div class="decision-text">{{entry_status_reason}}</div>
|
||||
</div>
|
||||
<div class="tag-row">
|
||||
<div class="tag">最佳买点:{{best_buy_point}}</div>
|
||||
<div class="tag">观察位:{{watch_price}}</div>
|
||||
<div class="tag">止损位:{{stop_loss}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-block">
|
||||
<h2>关键指标速览</h2>
|
||||
<div class="kpi-list">
|
||||
<div class="kpi-item"><span>布林位置</span><strong>{{boll_position}}</strong></div>
|
||||
<div class="kpi-item"><span>RSI</span><strong>{{rsi_value}}</strong></div>
|
||||
<div class="kpi-item"><span>MACD</span><strong>{{macd_status}}</strong></div>
|
||||
<div class="kpi-item"><span>量价关系</span><strong>{{volume_price_status}}</strong></div>
|
||||
<div class="kpi-item"><span>支撑 / 压力</span><strong>{{support_resistance}}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-block">
|
||||
<h2>交易计划</h2>
|
||||
<table class="action-table">
|
||||
<thead><tr><th>场景</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>当前策略</td><td>{{trade_plan_now}}</td></tr>
|
||||
<tr><td>回调策略</td><td>{{trade_plan_pullback}}</td></tr>
|
||||
<tr><td>突破策略</td><td>{{trade_plan_breakout}}</td></tr>
|
||||
<tr><td>止盈策略</td><td>{{trade_plan_take_profit}}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="section-block">
|
||||
<h2>最终建议</h2>
|
||||
<ul>
|
||||
<li><span class="risk-good">适合做什么:</span>{{final_do}}</li>
|
||||
<li><span class="risk-warn">现在别做什么:</span>{{final_dont}}</li>
|
||||
<li><span class="risk-bad">最大风险:</span>{{final_biggest_risk}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="footer-note">模板页地址固定为 /stock-report。后续我帮你分析具体股票时,可直接生成真实数据版 HTML 并挂到同站点。</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
14
summarize_output.py
Normal file
14
summarize_output.py
Normal file
@ -0,0 +1,14 @@
|
||||
import json, sys
|
||||
d = json.load(sys.stdin)
|
||||
print(f'status={d["status"]}')
|
||||
print(f'confirmed_count={d["confirmed_count"]}')
|
||||
print(f'unconfirmed_count={d["unconfirmed_count"]}')
|
||||
print(f'check_time={d["check_time"]}')
|
||||
for c in d['confirmed']:
|
||||
print(f' CONFIRMED: {c["symbol"]} | price={c["price"]} | score={c["score"]} | signals={c.get("signals",[])} | action={c["entry_action"]}')
|
||||
for u in d['unconfirmed']:
|
||||
print(f' UNCONFIRMED: {u["symbol"]} | price={u["price"]} | score={u["score"]} | signals={u.get("signals",[])} | action={u["entry_action"]} | alert={u["state_update"]["should_alert"]} | reason={u["state_update"]["reason"]}')
|
||||
if d['confirmed_count'] > 0:
|
||||
print('HAS_CONFIRMED=True')
|
||||
else:
|
||||
print('HAS_CONFIRMED=False')
|
||||
343
tests/test_actionable_active_recommendations.py
Normal file
343
tests/test_actionable_active_recommendations.py
Normal file
@ -0,0 +1,343 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if PROJECT_DIR not in sys.path:
|
||||
sys.path.insert(0, PROJECT_DIR)
|
||||
|
||||
import altcoin_db
|
||||
import web_server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db(monkeypatch, tmp_path):
|
||||
db_path = tmp_path / "altcoin_monitor.db"
|
||||
monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path))
|
||||
monkeypatch.setattr(web_server, "init_db", altcoin_db.init_db)
|
||||
monkeypatch.setattr(web_server, "get_stats", altcoin_db.get_stats)
|
||||
monkeypatch.setattr(web_server, "get_active_recommendations", altcoin_db.get_active_recommendations)
|
||||
monkeypatch.setattr(web_server, "get_active_recommendations_deduped", altcoin_db.get_active_recommendations_deduped)
|
||||
altcoin_db.init_db()
|
||||
return db_path
|
||||
|
||||
|
||||
def _insert_recommendation(db_path, **kwargs):
|
||||
defaults = dict(
|
||||
symbol='AAA/USDT',
|
||||
rec_time='2026-04-30T10:00:00',
|
||||
rec_state='加速',
|
||||
rec_score=8,
|
||||
entry_price=100.0,
|
||||
stop_loss=95.0,
|
||||
tp1=110.0,
|
||||
tp2=118.0,
|
||||
sector='AI',
|
||||
signals='[]',
|
||||
is_meme=0,
|
||||
status='active',
|
||||
current_price=100.0,
|
||||
max_price=104.0,
|
||||
min_price=98.0,
|
||||
pnl_pct=0.0,
|
||||
max_pnl_pct=4.0,
|
||||
max_drawdown_pct=-1.0,
|
||||
hit_tp1_time='',
|
||||
hit_tp2_time='',
|
||||
stopped_out_time='',
|
||||
expired_time='',
|
||||
last_track_time='2026-04-30T10:05:00',
|
||||
entry_plan_json='{}',
|
||||
action_status='持有',
|
||||
direction='多头启动',
|
||||
strategy_version='v1.2',
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
conn = altcoin_db.sqlite3.connect(str(db_path))
|
||||
conn.execute(
|
||||
'''
|
||||
INSERT INTO recommendation (
|
||||
symbol, rec_time, rec_state, rec_score, entry_price, stop_loss, tp1, tp2,
|
||||
sector, signals, is_meme, status, current_price, max_price, min_price,
|
||||
pnl_pct, max_pnl_pct, max_drawdown_pct, hit_tp1_time, hit_tp2_time,
|
||||
stopped_out_time, expired_time, last_track_time, entry_plan_json,
|
||||
action_status, direction, strategy_version
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''',
|
||||
(
|
||||
defaults['symbol'], defaults['rec_time'], defaults['rec_state'], defaults['rec_score'],
|
||||
defaults['entry_price'], defaults['stop_loss'], defaults['tp1'], defaults['tp2'],
|
||||
defaults['sector'], defaults['signals'], defaults['is_meme'], defaults['status'],
|
||||
defaults['current_price'], defaults['max_price'], defaults['min_price'], defaults['pnl_pct'],
|
||||
defaults['max_pnl_pct'], defaults['max_drawdown_pct'], defaults['hit_tp1_time'], defaults['hit_tp2_time'],
|
||||
defaults['stopped_out_time'], defaults['expired_time'], defaults['last_track_time'], defaults['entry_plan_json'],
|
||||
defaults['action_status'], defaults['direction'], defaults['strategy_version'],
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_active_api_only_returns_actionable_recommendations(temp_db):
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
symbol='BUY/USDT',
|
||||
action_status='可即刻买入',
|
||||
entry_plan_json='{"entry_action": "可即刻买入"}'
|
||||
)
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
symbol='WAIT/USDT',
|
||||
action_status='等回踩',
|
||||
entry_plan_json='{"entry_action": "等回踩"}'
|
||||
)
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
symbol='OBS/USDT',
|
||||
action_status='持有',
|
||||
entry_plan_json='{"entry_action": "继续观察"}'
|
||||
)
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
symbol='INV/USDT',
|
||||
action_status='衰减',
|
||||
entry_plan_json='{"entry_action": "可即刻买入"}'
|
||||
)
|
||||
|
||||
client = TestClient(web_server.app)
|
||||
resp = client.get('/api/recommendations/active')
|
||||
assert resp.status_code == 200
|
||||
|
||||
rows = resp.json()
|
||||
assert {row['symbol'] for row in rows} == {'WAIT/USDT', 'BUY/USDT'}
|
||||
assert {row['execution_status'] for row in rows} == {'buy_now', 'wait_pullback'}
|
||||
|
||||
|
||||
def test_stats_only_count_actionable_active_recommendations(temp_db):
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
symbol='BUY/USDT',
|
||||
action_status='可即刻买入',
|
||||
entry_plan_json='{"entry_action": "可即刻买入"}',
|
||||
pnl_pct=5.0,
|
||||
max_pnl_pct=5.0,
|
||||
)
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
symbol='WAIT/USDT',
|
||||
action_status='等回踩',
|
||||
entry_plan_json='{"entry_action": "等回踩"}',
|
||||
pnl_pct=1.0,
|
||||
)
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
symbol='OBS/USDT',
|
||||
action_status='持有',
|
||||
entry_plan_json='{"entry_action": "继续观察"}',
|
||||
pnl_pct=9.0,
|
||||
max_pnl_pct=9.0,
|
||||
)
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
symbol='INV/USDT',
|
||||
action_status='衰减',
|
||||
entry_plan_json='{"entry_action": "可即刻买入"}',
|
||||
pnl_pct=-6.0,
|
||||
max_drawdown_pct=-6.0,
|
||||
)
|
||||
|
||||
stats = altcoin_db.get_stats()
|
||||
assert stats['active_count'] == 2
|
||||
assert stats['raw_active_count'] == 4
|
||||
assert stats['active_pnl_sum'] == 5.0
|
||||
assert stats['active_avg_pnl'] == 5.0
|
||||
assert stats['active_success_count'] == 1
|
||||
assert stats['active_failed_count'] == 0
|
||||
assert stats['active_pending_count'] == 0
|
||||
|
||||
|
||||
def test_stats_api_exposes_separate_live_and_history_sections(temp_db):
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
symbol='BUY/USDT',
|
||||
action_status='可即刻买入',
|
||||
entry_plan_json='{"entry_action": "可即刻买入"}',
|
||||
pnl_pct=5.0,
|
||||
max_pnl_pct=5.0,
|
||||
)
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
symbol='WAIT/USDT',
|
||||
action_status='等回踩',
|
||||
entry_plan_json='{"entry_action": "等回踩"}',
|
||||
pnl_pct=1.0,
|
||||
)
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
symbol='OBS/USDT',
|
||||
action_status='持有',
|
||||
entry_plan_json='{"entry_action": "继续观察"}',
|
||||
pnl_pct=9.0,
|
||||
max_pnl_pct=9.0,
|
||||
)
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
symbol='INV/USDT',
|
||||
action_status='衰减',
|
||||
entry_plan_json='{"entry_action": "可即刻买入"}',
|
||||
pnl_pct=-6.0,
|
||||
max_drawdown_pct=-6.0,
|
||||
)
|
||||
|
||||
client = TestClient(web_server.app)
|
||||
resp = client.get('/api/stats')
|
||||
assert resp.status_code == 200
|
||||
stats = resp.json()
|
||||
|
||||
live = stats['live_overview']
|
||||
assert live['actionable_count'] == 2
|
||||
assert live['executed_trade_count'] == 1
|
||||
assert live['executed_pnl_sum'] == 5.0
|
||||
assert live['executed_avg_pnl'] == 5.0
|
||||
assert live['actionable_pnl_sum'] == 5.0
|
||||
assert live['actionable_avg_pnl'] == 5.0
|
||||
assert live['actionable_success_count'] == 1
|
||||
assert live['actionable_failed_count'] == 0
|
||||
assert live['actionable_pending_count'] == 0
|
||||
assert live['raw_active_count'] == 4
|
||||
assert stats['history_overview']['success_count'] == 0
|
||||
assert stats['history_overview']['failed_count'] == 0
|
||||
assert 'pending_count' not in stats['history_overview']
|
||||
assert stats['history_overview']['recommendation_success_rate'] == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_history_overview_only_counts_real_tp_and_stopout_results(temp_db):
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
symbol='TP1/USDT',
|
||||
status='hit_tp1',
|
||||
action_status='可即刻买入',
|
||||
entry_plan_json='{"entry_action": "可即刻买入"}',
|
||||
pnl_pct=7.0,
|
||||
max_pnl_pct=7.0,
|
||||
)
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
symbol='TP2/USDT',
|
||||
status='hit_tp2',
|
||||
action_status='等回踩',
|
||||
entry_plan_json='{"entry_action": "等回踩"}',
|
||||
pnl_pct=12.0,
|
||||
max_pnl_pct=12.0,
|
||||
)
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
symbol='STOP/USDT',
|
||||
status='stopped_out',
|
||||
action_status='可即刻买入',
|
||||
entry_plan_json='{"entry_action": "可即刻买入"}',
|
||||
pnl_pct=-4.0,
|
||||
max_drawdown_pct=-6.0,
|
||||
)
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
symbol='MAXONLY/USDT',
|
||||
status='active',
|
||||
action_status='持有',
|
||||
entry_plan_json='{"entry_action": "继续观察"}',
|
||||
pnl_pct=1.5,
|
||||
max_pnl_pct=9.0,
|
||||
)
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
symbol='DDONLY/USDT',
|
||||
status='active',
|
||||
action_status='持有',
|
||||
entry_plan_json='{"entry_action": "继续观察"}',
|
||||
pnl_pct=-1.0,
|
||||
max_drawdown_pct=-7.0,
|
||||
)
|
||||
|
||||
client = TestClient(web_server.app)
|
||||
resp = client.get('/api/stats')
|
||||
assert resp.status_code == 200
|
||||
stats = resp.json()
|
||||
|
||||
assert stats['history_overview']['success_count'] == 2
|
||||
assert stats['history_overview']['failed_count'] == 1
|
||||
assert stats['history_overview']['recommendation_success_rate'] == pytest.approx(66.7)
|
||||
assert 'pending_count' not in stats['history_overview']
|
||||
|
||||
|
||||
def test_history_avg_pnl_only_uses_real_tp_and_stopout_samples(temp_db):
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
symbol='TP1AVG/USDT',
|
||||
status='hit_tp1',
|
||||
action_status='可即刻买入',
|
||||
entry_plan_json='{"entry_action": "可即刻买入"}',
|
||||
pnl_pct=6.0,
|
||||
max_pnl_pct=6.0,
|
||||
)
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
symbol='STOPAVG/USDT',
|
||||
status='stopped_out',
|
||||
action_status='可即刻买入',
|
||||
entry_plan_json='{"entry_action": "可即刻买入"}',
|
||||
pnl_pct=-4.0,
|
||||
max_drawdown_pct=-6.0,
|
||||
)
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
symbol='EXPIREDAVG/USDT',
|
||||
status='expired',
|
||||
action_status='持有',
|
||||
entry_plan_json='{"entry_action": "继续观察"}',
|
||||
pnl_pct=30.0,
|
||||
max_pnl_pct=30.0,
|
||||
)
|
||||
|
||||
client = TestClient(web_server.app)
|
||||
resp = client.get('/api/stats')
|
||||
assert resp.status_code == 200
|
||||
stats = resp.json()
|
||||
|
||||
assert stats['history_overview']['avg_pnl_pct'] == pytest.approx(1.0)
|
||||
|
||||
def test_version_filter_labels_use_plan_not_executable_to_avoid_wait_pullback_confusion(temp_db):
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
symbol='BUY/USDT',
|
||||
action_status='可即刻买入',
|
||||
entry_plan_json='{"entry_action": "可即刻买入"}',
|
||||
strategy_version='v1.7.3',
|
||||
)
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
symbol='WAIT/USDT',
|
||||
action_status='等回踩',
|
||||
entry_plan_json='{"entry_action": "等回踩"}',
|
||||
strategy_version='v1.7.3',
|
||||
)
|
||||
|
||||
client = TestClient(web_server.app)
|
||||
versions = client.get('/api/versions?view=active').json()
|
||||
v173 = next(v for v in versions if v['version'] == 'v1.7.3')
|
||||
assert v173['count'] == 2
|
||||
|
||||
monkeypatch = pytest.MonkeyPatch()
|
||||
monkeypatch.setattr(web_server, "_require_active_subscription", lambda altcoin_session='': ({"id": 1}, {"plan_code": "test"}))
|
||||
try:
|
||||
html = client.get('/app').text
|
||||
finally:
|
||||
monkeypatch.undo()
|
||||
# v1.7.7: 新看板没有 watch tab,使用 version dropdown
|
||||
assert 'version-select' in html or '全部版本' in html
|
||||
assert '实时推荐' in html
|
||||
assert '历史推荐' in html
|
||||
# v1.7.7: 新看板版本下拉用 (${v.count}) 格式 + 默认选中最新版本
|
||||
assert 'v.count' in html
|
||||
|
||||
31
tests/test_confirm_freshness_gate.py
Normal file
31
tests/test_confirm_freshness_gate.py
Normal file
@ -0,0 +1,31 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from altcoin_confirm import _is_candidate_fresh, _event_time_from_age
|
||||
|
||||
|
||||
def test_candidate_fresh_when_state_detected_recently_without_current_trigger():
|
||||
cand = {"symbol": "TEST/USDT", "detected_at": datetime.now().isoformat()}
|
||||
ok, reason, events = _is_candidate_fresh(cand, [], max_hours=6)
|
||||
assert ok is True
|
||||
assert reason == "fresh_candidate_state"
|
||||
assert events and events[0]["age_hours"] <= 6
|
||||
|
||||
|
||||
def test_candidate_stale_when_no_recent_trigger_and_old_state():
|
||||
cand = {"symbol": "TEST/USDT", "detected_at": (datetime.now() - timedelta(hours=8)).isoformat()}
|
||||
ok, reason, events = _is_candidate_fresh(cand, [], max_hours=6)
|
||||
assert ok is True
|
||||
assert reason == "stale_structure_background_only"
|
||||
assert events and events[0]["age_hours"] > 6
|
||||
|
||||
|
||||
def test_event_time_from_age_maps_latest_and_previous_bar():
|
||||
now = pd.Timestamp(datetime.now().replace(minute=0, second=0, microsecond=0))
|
||||
df = pd.DataFrame({"timestamp": [now - pd.Timedelta(hours=2), now - pd.Timedelta(hours=1), now]})
|
||||
assert _event_time_from_age(df, 0) == now.to_pydatetime()
|
||||
assert _event_time_from_age(df, 1) == (now - pd.Timedelta(hours=1)).to_pydatetime()
|
||||
78
tests/test_cron_run_logs.py
Normal file
78
tests/test_cron_run_logs.py
Normal file
@ -0,0 +1,78 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if PROJECT_DIR not in sys.path:
|
||||
sys.path.insert(0, PROJECT_DIR)
|
||||
|
||||
import altcoin_db
|
||||
import web_server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db(monkeypatch, tmp_path):
|
||||
db_path = tmp_path / "altcoin_monitor.db"
|
||||
monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path))
|
||||
monkeypatch.setattr(web_server, "init_db", altcoin_db.init_db)
|
||||
monkeypatch.setattr(web_server, "get_cron_run_logs", altcoin_db.get_cron_run_logs)
|
||||
monkeypatch.setattr(web_server, "get_cron_run_summary", altcoin_db.get_cron_run_summary)
|
||||
altcoin_db.init_db()
|
||||
return db_path
|
||||
|
||||
|
||||
def test_cron_run_log_roundtrip_and_summary(temp_db):
|
||||
altcoin_db.log_cron_run(
|
||||
job_name="粗筛",
|
||||
script_name="altcoin_screener.py",
|
||||
run_status="success",
|
||||
result_status="screened",
|
||||
duration_ms=1234,
|
||||
summary={"total_candidates": 88, "total_qualified": 6, "alert_count": 2},
|
||||
)
|
||||
altcoin_db.log_cron_run(
|
||||
job_name="跟踪",
|
||||
script_name="price_tracker.py",
|
||||
run_status="error",
|
||||
result_status="exception",
|
||||
duration_ms=222,
|
||||
summary={"tracked_count": 0},
|
||||
error_message="boom",
|
||||
)
|
||||
|
||||
logs = altcoin_db.get_cron_run_logs(limit=10)
|
||||
assert len(logs) == 2
|
||||
assert logs[0]["job_name"] == "跟踪"
|
||||
assert logs[0]["summary_json"]["tracked_count"] == 0
|
||||
assert logs[1]["summary_json"]["total_qualified"] == 6
|
||||
|
||||
summary = altcoin_db.get_cron_run_summary(hours=24)
|
||||
assert summary["overall"]["total_runs"] == 2
|
||||
assert summary["overall"]["success_runs"] == 1
|
||||
assert summary["overall"]["error_runs"] == 1
|
||||
stats_by_job = {item["job_name"]: item for item in summary["job_stats"]}
|
||||
assert stats_by_job["粗筛"]["last_result_status"] == "screened"
|
||||
assert summary["recent_logs"][1]["summary_json"]["total_candidates"] == 88
|
||||
assert stats_by_job["跟踪"]["last_error_message"] == "boom"
|
||||
|
||||
|
||||
def test_cron_log_api_returns_summary_and_logs(temp_db):
|
||||
altcoin_db.log_cron_run(
|
||||
job_name="确认",
|
||||
script_name="altcoin_confirm.py",
|
||||
run_status="success",
|
||||
result_status="confirmed",
|
||||
duration_ms=456,
|
||||
summary={"confirmed_count": 3, "unconfirmed_count": 1},
|
||||
)
|
||||
|
||||
client = TestClient(web_server.app)
|
||||
resp = client.get("/api/cron/summary?hours=24")
|
||||
assert resp.status_code == 200
|
||||
|
||||
data = resp.json()
|
||||
assert data["overall"]["total_runs"] == 1
|
||||
assert data["job_stats"][0]["last_result_status"] == "confirmed"
|
||||
assert data["recent_logs"][0]["script_name"] == "altcoin_confirm.py"
|
||||
124
tests/test_event_driven_screener.py
Normal file
124
tests/test_event_driven_screener.py
Normal file
@ -0,0 +1,124 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
import event_driven_screener as ed
|
||||
|
||||
|
||||
def test_symbol_extraction_filters_usdt_suffix_and_pollution():
|
||||
title = "Binance Futures Will Launch ABCUSDT, XYZUSDT and USD1USDT USDⓈ-Margined Perpetual Contracts"
|
||||
symbols = ed._symbol_from_title(title)
|
||||
assert "ABC/USDT" in symbols
|
||||
assert "XYZ/USDT" in symbols
|
||||
assert "ABCUSDT/USDT" not in symbols
|
||||
assert "USD1/USDT" not in symbols
|
||||
|
||||
|
||||
def test_recent_time_window_rejects_old_news():
|
||||
assert ed._is_recent(datetime.now() - timedelta(hours=2), 3) is True
|
||||
assert ed._is_recent(datetime.now() - timedelta(hours=8), 3) is False
|
||||
assert ed._is_recent(None, 3) is False
|
||||
|
||||
|
||||
def test_classify_major_listing_as_s_level_and_negative_as_risk():
|
||||
level, event_type = ed.classify_event("Binance Futures Will Launch ABCUSDT USDⓈ-Margined Perpetual Contracts")
|
||||
assert level == "S"
|
||||
assert event_type == "major_listing_or_contract"
|
||||
|
||||
level, event_type = ed.classify_event("Binance Will Delist ABC on 2026-05-07")
|
||||
assert level == "RISK"
|
||||
assert event_type == "risk_negative"
|
||||
|
||||
|
||||
def test_store_events_deduplicates_by_hash(tmp_path):
|
||||
# 使用真实DB表,但同一事件重复插入只保留一次
|
||||
event = {
|
||||
"source": "binance_listing",
|
||||
"symbol": "ABC/USDT",
|
||||
"title": "Binance Futures Will Launch ABCUSDT USDⓈ-Margined Perpetual Contracts",
|
||||
"url": "https://example.com",
|
||||
"published_at": datetime.now(),
|
||||
"importance": "S",
|
||||
"event_type": "major_listing_or_contract",
|
||||
"raw": {"id": 1},
|
||||
}
|
||||
first = ed.store_events([event])
|
||||
second = ed.store_events([event])
|
||||
assert len(first) in (0, 1) # 若本地DB已有同事件,允许0
|
||||
assert second == []
|
||||
|
||||
|
||||
def test_quick_technical_check_rejects_old_overheated_gain():
|
||||
event = {
|
||||
"source": "binance_listing",
|
||||
"symbol": "ABC/USDT",
|
||||
"title": "Binance Futures Will Launch ABCUSDT USDⓈ-Margined Perpetual Contracts",
|
||||
"importance": "S",
|
||||
}
|
||||
with patch.object(ed, "_ticker_info", return_value={"price": 1.0, "change_24h": 35.0, "volume_24h": 10000000}):
|
||||
result = ed.quick_technical_check(event)
|
||||
assert result["decision"] == "risk"
|
||||
assert "不追高" in result["reason"]
|
||||
|
||||
|
||||
def test_theme_expansion_spreads_ton_news_to_ecosystem_symbols():
|
||||
event = {
|
||||
"source": "coingecko_trending",
|
||||
"symbol": "TON/USDT",
|
||||
"title": "Telegram becomes the main driver of the TON ecosystem and cuts TON fees",
|
||||
"url": "https://example.com/ton",
|
||||
"published_at": datetime.now(),
|
||||
"importance": "B",
|
||||
"event_type": "market_heat",
|
||||
"raw": {},
|
||||
}
|
||||
|
||||
expanded = ed.expand_theme_events([event])
|
||||
by_symbol = {e["symbol"]: e for e in expanded}
|
||||
|
||||
assert "TON/USDT" in by_symbol
|
||||
assert "NOT/USDT" in by_symbol
|
||||
assert "DOGS/USDT" in by_symbol
|
||||
assert by_symbol["DOGS/USDT"]["importance"] == "A"
|
||||
assert by_symbol["DOGS/USDT"]["event_type"] == "theme_expansion"
|
||||
assert "主题扩散:ton_ecosystem" in by_symbol["DOGS/USDT"]["title"]
|
||||
|
||||
|
||||
def _fake_ohlcv(rows=60):
|
||||
return pd.DataFrame({
|
||||
"timestamp": pd.date_range("2026-05-01", periods=rows, freq="h"),
|
||||
"open": [1.0] * rows,
|
||||
"high": [1.02] * rows,
|
||||
"low": [0.98] * rows,
|
||||
"close": [1.0] * rows,
|
||||
"volume": [1000.0] * rows,
|
||||
})
|
||||
|
||||
|
||||
def test_theme_static_accumulation_bonus_can_upgrade_to_recommend():
|
||||
event = {
|
||||
"source": "coingecko_trending",
|
||||
"symbol": "DOGS/USDT",
|
||||
"title": "[主题扩散:ton_ecosystem] Telegram becomes the main driver of the TON ecosystem",
|
||||
"importance": "A",
|
||||
"event_type": "theme_expansion",
|
||||
}
|
||||
|
||||
with patch.object(ed, "_ticker_info", return_value={"price": 0.001, "change_24h": 5.0, "volume_24h": 10000000}), \
|
||||
patch.object(ed, "fetch_klines", return_value=_fake_ohlcv()), \
|
||||
patch.object(ed, "detect_volume_price_fly", return_value=None), \
|
||||
patch.object(ed, "detect_static_accumulation", return_value={"static_count": 23, "vol_ratio": 1.4}), \
|
||||
patch.object(ed, "full_pa_analysis", return_value={"ignition_points": []}), \
|
||||
patch.object(ed, "fetch_derivatives_context", return_value={"funding_rate": 0, "top_trader_long_pct": 56}), \
|
||||
patch.object(ed, "calc_atr", return_value=0.00005):
|
||||
result = ed.quick_technical_check(event)
|
||||
|
||||
assert result["score"] >= 6
|
||||
assert result["decision"] == "recommend"
|
||||
assert any("生态主题+强静K蓄力升权" in s for s in result["signals"])
|
||||
99
tests/test_executed_trade_pnl_semantics.py
Normal file
99
tests/test_executed_trade_pnl_semantics.py
Normal file
@ -0,0 +1,99 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if PROJECT_DIR not in sys.path:
|
||||
sys.path.insert(0, PROJECT_DIR)
|
||||
|
||||
import altcoin_db
|
||||
import web_server
|
||||
from test_actionable_active_recommendations import _insert_recommendation
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db(monkeypatch, tmp_path):
|
||||
db_path = tmp_path / "altcoin_monitor.db"
|
||||
monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path))
|
||||
monkeypatch.setattr(web_server, "get_stats", altcoin_db.get_stats)
|
||||
monkeypatch.setattr(web_server, "get_active_recommendations", altcoin_db.get_active_recommendations)
|
||||
monkeypatch.setattr(web_server, "get_active_recommendations_deduped", altcoin_db.get_active_recommendations_deduped)
|
||||
monkeypatch.setattr(web_server, "get_all_recommendations", altcoin_db.get_all_recommendations)
|
||||
altcoin_db.init_db()
|
||||
return db_path
|
||||
|
||||
|
||||
def test_wait_pullback_plan_is_actionable_but_not_counted_as_executed_pnl(temp_db):
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
symbol='BUY/USDT',
|
||||
action_status='可即刻买入',
|
||||
entry_plan_json='{"entry_action": "可即刻买入"}',
|
||||
pnl_pct=5.0,
|
||||
max_pnl_pct=5.0,
|
||||
)
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
symbol='WAIT/USDT',
|
||||
action_status='等回踩',
|
||||
entry_plan_json='{"entry_action": "等回踩", "entry_price": 90}',
|
||||
pnl_pct=12.0,
|
||||
max_pnl_pct=12.0,
|
||||
)
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
symbol='OBS/USDT',
|
||||
action_status='持有',
|
||||
entry_plan_json='{"entry_action": "继续观察"}',
|
||||
pnl_pct=20.0,
|
||||
max_pnl_pct=20.0,
|
||||
)
|
||||
|
||||
stats = altcoin_db.get_stats()
|
||||
live = stats['live_overview']
|
||||
|
||||
# 现可买 + 等回踩仍属于实时可操作看板数量
|
||||
assert live['actionable_count'] == 2
|
||||
assert live['buy_now_count'] == 1
|
||||
assert live['wait_pullback_count'] == 1
|
||||
|
||||
# 但收益只算已经执行/触发入场的 BUY,不把 WAIT/OBS 的发现后涨幅算收益
|
||||
assert live['executed_trade_count'] == 1
|
||||
assert live['executed_pnl_sum'] == pytest.approx(5.0)
|
||||
assert live['executed_avg_pnl'] == pytest.approx(5.0)
|
||||
assert stats['active_pnl_sum'] == pytest.approx(5.0)
|
||||
assert stats['active_avg_pnl'] == pytest.approx(5.0)
|
||||
assert stats['active_success_count'] == 1
|
||||
assert stats['active_pending_count'] == 0
|
||||
|
||||
|
||||
def test_active_api_marks_wait_plan_as_unexecuted_not_success(temp_db):
|
||||
_insert_recommendation(
|
||||
temp_db,
|
||||
symbol='WAIT/USDT',
|
||||
action_status='等回踩',
|
||||
entry_plan_json='{"entry_action": "等回踩", "entry_price": 90}',
|
||||
pnl_pct=12.0,
|
||||
max_pnl_pct=12.0,
|
||||
)
|
||||
|
||||
client = TestClient(web_server.app)
|
||||
rows = client.get('/api/recommendations/active').json()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]['execution_status'] == 'wait_pullback'
|
||||
assert rows[0]['recommendation_result'] == 'pending'
|
||||
assert rows[0]['recommendation_result_label'] == '⏳ 未执行'
|
||||
|
||||
|
||||
def test_dashboard_kline_only_marks_executed_entry_price(temp_db, monkeypatch):
|
||||
monkeypatch.setattr(web_server, "_require_active_subscription", lambda altcoin_session='': ({"id": 1}, {"plan_code": "test"}))
|
||||
client = TestClient(web_server.app)
|
||||
html = client.get('/app').text
|
||||
# v1.7.7: 新看板使用 drawPin 函数 + pin标记
|
||||
assert 'drawPin' in html
|
||||
assert 'data-entry-price=' in html
|
||||
assert '止损 ' in html
|
||||
assert '止盈 ' in html
|
||||
assert '推荐 ${recText}' not in html
|
||||
105
tests/test_higher_lows_compression.py
Normal file
105
tests/test_higher_lows_compression.py
Normal file
@ -0,0 +1,105 @@
|
||||
"""v1.7.6 新增特征检测和信号淘汰机制测试"""
|
||||
import os
|
||||
import sys
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if PROJECT_DIR not in sys.path:
|
||||
sys.path.insert(0, PROJECT_DIR)
|
||||
|
||||
|
||||
def make_df(highs, lows, closes, opens=None, volumes=None):
|
||||
df = pd.DataFrame({
|
||||
"high": highs,
|
||||
"low": lows,
|
||||
"close": closes,
|
||||
"open": opens or closes,
|
||||
"volume": volumes or [1000] * len(closes),
|
||||
})
|
||||
return df
|
||||
|
||||
|
||||
def test_detect_higher_lows_finds_clear_pattern():
|
||||
"""验证底部抬高检测能识别清晰的逐步抬高低点"""
|
||||
from altcoin_screener import detect_higher_lows
|
||||
|
||||
lows, highs, closes, volumes = [], [], [], []
|
||||
for seg in range(6):
|
||||
for _ in range(4):
|
||||
low = seg * 5 + np.random.uniform(-0.3, 0.3)
|
||||
high = low + np.random.uniform(2, 4)
|
||||
lows.append(low)
|
||||
highs.append(high)
|
||||
closes.append((low + high) / 2)
|
||||
volumes.append(np.random.uniform(800, 1200))
|
||||
|
||||
df = make_df(highs, lows, closes, volumes=volumes)
|
||||
result = detect_higher_lows(df)
|
||||
|
||||
assert result["found"] is True
|
||||
assert result["hl_count"] >= 4
|
||||
assert "底部抬高" in result["signal"]
|
||||
|
||||
|
||||
def test_detect_higher_lows_rejects_declining_trend():
|
||||
"""验证下降趋势不会被误判为底部抬高"""
|
||||
from altcoin_screener import detect_higher_lows
|
||||
|
||||
lows, highs, closes, volumes = [], [], [], []
|
||||
for seg in range(6):
|
||||
for _ in range(4):
|
||||
low = 100 - seg * 3 + np.random.uniform(-0.3, 0.3)
|
||||
high = low + np.random.uniform(1, 3)
|
||||
lows.append(low)
|
||||
highs.append(high)
|
||||
closes.append((low + high) / 2)
|
||||
volumes.append(np.random.uniform(800, 1200))
|
||||
|
||||
df = make_df(highs, lows, closes, volumes=volumes)
|
||||
result = detect_higher_lows(df)
|
||||
|
||||
assert result["found"] is False
|
||||
|
||||
|
||||
def test_detect_compression_surge_detects_tight_then_volume():
|
||||
"""验证压缩后放量模式能被检测"""
|
||||
from altcoin_screener import detect_compression_surge
|
||||
|
||||
lows = [100 + np.random.uniform(-0.5, 0.5) for _ in range(24)]
|
||||
highs = [l + np.random.uniform(1, 2.5) for l in lows]
|
||||
closes = [(h + l) / 2 for h, l in zip(highs, lows)]
|
||||
volumes = [np.random.uniform(500, 800) for _ in range(21)] + [np.random.uniform(3000, 5000) for _ in range(3)]
|
||||
|
||||
df = make_df(highs, lows, closes, volumes=volumes)
|
||||
result = detect_compression_surge(df)
|
||||
|
||||
assert result["found"] is True
|
||||
assert result["range_pct"] < 20
|
||||
assert result["vol_ratio"] > 2.0
|
||||
assert "压缩放量" in result["signal"]
|
||||
|
||||
|
||||
def test_detect_compression_surge_rejects_wide_range():
|
||||
"""验证宽幅震荡不触发压缩检测"""
|
||||
from altcoin_screener import detect_compression_surge
|
||||
|
||||
lows = [np.random.uniform(80, 120) for _ in range(24)]
|
||||
highs = [l + np.random.uniform(5, 15) for l in lows]
|
||||
closes = [(h + l) / 2 for h, l in zip(highs, lows)]
|
||||
volumes = [np.random.uniform(500, 800) for _ in range(21)] + [np.random.uniform(3000, 5000) for _ in range(3)]
|
||||
|
||||
df = make_df(highs, lows, closes, volumes=volumes)
|
||||
result = detect_compression_surge(df)
|
||||
|
||||
assert result["found"] is False
|
||||
|
||||
|
||||
def test_signal_deprecation_config_exists():
|
||||
"""验证信号淘汰机制配置可正确读取"""
|
||||
from config_loader import get_review_params
|
||||
|
||||
dep = get_review_params().get("signal_deprecation", {})
|
||||
assert dep.get("enabled") is True
|
||||
assert dep.get("min_samples", 0) >= 5
|
||||
assert 0 < dep.get("hit_rate_deprecate_threshold", 0) < 1
|
||||
181
tests/test_history_grouping.py
Normal file
181
tests/test_history_grouping.py
Normal file
@ -0,0 +1,181 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
if PROJECT_DIR not in sys.path:
|
||||
sys.path.insert(0, PROJECT_DIR)
|
||||
|
||||
import altcoin_db
|
||||
|
||||
|
||||
class RecommendationHistoryBase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmpdir.name, 'test_altcoin.db')
|
||||
self.db_patch = patch.object(altcoin_db, 'DB_PATH', self.db_path)
|
||||
self.db_patch.start()
|
||||
altcoin_db.init_db()
|
||||
|
||||
def tearDown(self):
|
||||
self.db_patch.stop()
|
||||
self.tmpdir.cleanup()
|
||||
|
||||
def _insert_rec(self, **kwargs):
|
||||
defaults = dict(
|
||||
symbol='AAA/USDT',
|
||||
rec_time='2026-04-29T10:00:00',
|
||||
rec_state='加速',
|
||||
rec_score=8,
|
||||
entry_price=100.0,
|
||||
stop_loss=95.0,
|
||||
tp1=110.0,
|
||||
tp2=118.0,
|
||||
sector='AI',
|
||||
signals='[]',
|
||||
is_meme=0,
|
||||
status='active',
|
||||
current_price=100.0,
|
||||
max_price=104.0,
|
||||
min_price=98.0,
|
||||
pnl_pct=0.0,
|
||||
max_pnl_pct=4.0,
|
||||
max_drawdown_pct=-1.0,
|
||||
hit_tp1_time='',
|
||||
hit_tp2_time='',
|
||||
stopped_out_time='',
|
||||
expired_time='',
|
||||
last_track_time='2026-04-29T10:05:00',
|
||||
entry_plan_json=json.dumps({
|
||||
'entry_price': 100.0,
|
||||
'entry_action': '可即刻买入',
|
||||
'risk_reward_ok': True,
|
||||
'stop_loss': 95.0,
|
||||
'stop_pct': -5.0,
|
||||
'tp1': 110.0,
|
||||
'tp2': 118.0,
|
||||
'rr1': 2.0,
|
||||
'rr2': 3.6,
|
||||
}, ensure_ascii=False),
|
||||
action_status='可即刻买入',
|
||||
direction='多头启动',
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.execute(
|
||||
'''
|
||||
INSERT INTO recommendation (
|
||||
symbol, rec_time, rec_state, rec_score, entry_price, stop_loss, tp1, tp2,
|
||||
sector, signals, is_meme, status, current_price, max_price, min_price,
|
||||
pnl_pct, max_pnl_pct, max_drawdown_pct, hit_tp1_time, hit_tp2_time,
|
||||
stopped_out_time, expired_time, last_track_time, entry_plan_json,
|
||||
action_status, direction
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''',
|
||||
(
|
||||
defaults['symbol'], defaults['rec_time'], defaults['rec_state'], defaults['rec_score'],
|
||||
defaults['entry_price'], defaults['stop_loss'], defaults['tp1'], defaults['tp2'],
|
||||
defaults['sector'], defaults['signals'], defaults['is_meme'], defaults['status'],
|
||||
defaults['current_price'], defaults['max_price'], defaults['min_price'], defaults['pnl_pct'],
|
||||
defaults['max_pnl_pct'], defaults['max_drawdown_pct'], defaults['hit_tp1_time'], defaults['hit_tp2_time'],
|
||||
defaults['stopped_out_time'], defaults['expired_time'], defaults['last_track_time'], defaults['entry_plan_json'],
|
||||
defaults['action_status'], defaults['direction'],
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
class RecommendationHistoryGroupingTests(RecommendationHistoryBase):
|
||||
def test_get_all_recommendations_exposes_each_history_group(self):
|
||||
self._insert_rec(symbol='AAA/USDT', action_status='可即刻买入', status='active')
|
||||
self._insert_rec(
|
||||
symbol='BBB/USDT',
|
||||
action_status='等回踩',
|
||||
status='active',
|
||||
entry_plan_json=json.dumps({'entry_action': '等回踩', 'entry_price': 100.0}, ensure_ascii=False),
|
||||
)
|
||||
self._insert_rec(
|
||||
symbol='CCC/USDT',
|
||||
action_status='持有',
|
||||
status='active',
|
||||
entry_plan_json=json.dumps({'entry_action': '继续观察', 'entry_price': 100.0}, ensure_ascii=False),
|
||||
)
|
||||
self._insert_rec(symbol='DDD/USDT', action_status='衰减', status='active')
|
||||
self._insert_rec(symbol='EEE/USDT', action_status='止盈1', status='hit_tp1')
|
||||
|
||||
rows = altcoin_db.get_all_recommendations(limit=20)
|
||||
mapping = {row['symbol']: row['execution_status'] for row in rows}
|
||||
|
||||
self.assertEqual(mapping['AAA/USDT'], 'buy_now')
|
||||
self.assertEqual(mapping['BBB/USDT'], 'wait_pullback')
|
||||
self.assertEqual(mapping['CCC/USDT'], 'observe')
|
||||
self.assertEqual(mapping['DDD/USDT'], 'invalid')
|
||||
self.assertEqual(mapping['EEE/USDT'], 'completed')
|
||||
|
||||
def test_get_all_recommendations_can_drive_history_summary_counts(self):
|
||||
self._insert_rec(symbol='AAA/USDT', action_status='可即刻买入', status='active')
|
||||
self._insert_rec(symbol='BBB/USDT', action_status='可即刻买入', status='active')
|
||||
self._insert_rec(
|
||||
symbol='CCC/USDT',
|
||||
action_status='等回踩',
|
||||
status='active',
|
||||
entry_plan_json=json.dumps({'entry_action': '等回踩', 'entry_price': 100.0}, ensure_ascii=False),
|
||||
)
|
||||
self._insert_rec(
|
||||
symbol='DDD/USDT',
|
||||
action_status='持有',
|
||||
status='active',
|
||||
entry_plan_json=json.dumps({'entry_action': '继续观察', 'entry_price': 100.0}, ensure_ascii=False),
|
||||
)
|
||||
self._insert_rec(symbol='EEE/USDT', action_status='衰减', status='active')
|
||||
self._insert_rec(symbol='FFF/USDT', action_status='止盈2', status='hit_tp2')
|
||||
|
||||
rows = altcoin_db.get_all_recommendations(limit=20)
|
||||
counts = {}
|
||||
for row in rows:
|
||||
key = row['execution_status']
|
||||
counts[key] = counts.get(key, 0) + 1
|
||||
|
||||
self.assertEqual(counts, {
|
||||
'buy_now': 2,
|
||||
'wait_pullback': 1,
|
||||
'observe': 1,
|
||||
'invalid': 1,
|
||||
'completed': 1,
|
||||
})
|
||||
|
||||
|
||||
class DecisionModeHistoryTests(RecommendationHistoryBase):
|
||||
def test_get_all_recommendations_supports_decision_only_history_mode(self):
|
||||
self._insert_rec(symbol='BUY/USDT', action_status='可即刻买入', status='active')
|
||||
self._insert_rec(
|
||||
symbol='WAIT/USDT',
|
||||
action_status='等回踩',
|
||||
status='active',
|
||||
entry_plan_json=json.dumps({'entry_action': '等回踩', 'entry_price': 100.0}, ensure_ascii=False),
|
||||
)
|
||||
self._insert_rec(
|
||||
symbol='OBS/USDT',
|
||||
action_status='持有',
|
||||
status='active',
|
||||
entry_plan_json=json.dumps({'entry_action': '继续观察', 'entry_price': 100.0}, ensure_ascii=False),
|
||||
)
|
||||
self._insert_rec(symbol='INV/USDT', action_status='衰减', status='active')
|
||||
self._insert_rec(symbol='TP/USDT', action_status='止盈1', status='hit_tp1')
|
||||
self._insert_rec(symbol='STOP/USDT', action_status='止损', status='stopped_out')
|
||||
|
||||
rows = altcoin_db.get_all_recommendations(limit=20, decision_only=True)
|
||||
mapping = {row['symbol']: row['execution_status'] for row in rows}
|
||||
|
||||
self.assertEqual(set(mapping.keys()), {'TP/USDT', 'STOP/USDT'})
|
||||
self.assertEqual(mapping['TP/USDT'], 'completed')
|
||||
self.assertEqual(mapping['STOP/USDT'], 'invalid')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
116
tests/test_iteration_diff_effect.py
Normal file
116
tests/test_iteration_diff_effect.py
Normal file
@ -0,0 +1,116 @@
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if PROJECT_DIR not in sys.path:
|
||||
sys.path.insert(0, PROJECT_DIR)
|
||||
|
||||
import altcoin_db
|
||||
import web_server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db(monkeypatch, tmp_path):
|
||||
db_path = tmp_path / "altcoin_monitor.db"
|
||||
monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path))
|
||||
monkeypatch.setattr(web_server, "init_db", altcoin_db.init_db)
|
||||
monkeypatch.setattr(web_server, "get_review_stats", altcoin_db.get_review_stats)
|
||||
monkeypatch.setattr(web_server, "get_stats", altcoin_db.get_stats)
|
||||
altcoin_db.init_db()
|
||||
return db_path
|
||||
|
||||
|
||||
def test_iteration_log_supports_rule_diff_and_effect_summary(temp_db):
|
||||
conn = altcoin_db.get_conn()
|
||||
base_time = datetime(2026, 4, 29, 10, 0, 0)
|
||||
for idx, (outcome, pnl) in enumerate([
|
||||
("爆发", 12.5),
|
||||
("失败", -4.2),
|
||||
("横盘", 0.8),
|
||||
], start=1):
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
idx,
|
||||
f"COIN{idx}/USDT",
|
||||
(base_time + timedelta(hours=idx)).isoformat(),
|
||||
outcome,
|
||||
pnl,
|
||||
10 + idx,
|
||||
'[]',
|
||||
'[]',
|
||||
'[]',
|
||||
f"lesson-{idx}"
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
altcoin_db.log_strategy_iteration(
|
||||
run_date="2026-04-29",
|
||||
trigger_source="daily_review",
|
||||
title="增加静K蓄力优先级",
|
||||
summary="基于漏选币复盘,提高静K蓄力相关因子的优先级。",
|
||||
findings=["静K蓄力类漏选较集中"],
|
||||
problems=["旧参数对蓄力型起爆敏感度不足"],
|
||||
actions=["提高静K蓄力权重并降低滞后信号优先级"],
|
||||
changed_rules=[{"field": "signal_weights.静K蓄力", "old": 1.0, "new": 1.6}],
|
||||
metrics={"reviews_done": 3},
|
||||
related_symbols=["PNT/USDT"],
|
||||
config_diff={
|
||||
"changed": [
|
||||
{"path": "signal_weights.静K蓄力", "old": 1.0, "new": 1.6},
|
||||
{"path": "meta.iteration_count", "old": 4, "new": 5},
|
||||
],
|
||||
"added": [
|
||||
{"path": "learned_rules.rule_20260429_001", "new": {"description": "静K蓄力增强"}},
|
||||
],
|
||||
"removed": []
|
||||
},
|
||||
effect_summary={
|
||||
"review_count_window": 3,
|
||||
"hit_rate_pct": 33.3,
|
||||
"avg_pnl": 3.03,
|
||||
"fail_rate_pct": 33.3,
|
||||
"flat_rate_pct": 33.3,
|
||||
}
|
||||
)
|
||||
|
||||
logs = altcoin_db.get_strategy_iteration_logs(limit=5)
|
||||
assert logs[0]["config_diff"]["changed"][0]["path"] == "signal_weights.静K蓄力"
|
||||
assert logs[0]["effect_summary"]["avg_pnl"] == 3.03
|
||||
|
||||
summary = altcoin_db.get_strategy_iteration_summary(days=30)
|
||||
assert summary["config_change_count"] == 3
|
||||
assert summary["effect_overview"]["avg_hit_rate_pct"] == 33.3
|
||||
|
||||
|
||||
def test_review_api_returns_diff_and_effect_fields(temp_db):
|
||||
altcoin_db.log_strategy_iteration(
|
||||
run_date="2026-04-29",
|
||||
trigger_source="manual",
|
||||
title="补充diff与效果回看",
|
||||
summary="让网站能看见参数改了多少,以及改后效果。",
|
||||
findings=["用户需要参数审计"],
|
||||
problems=["之前只能看文字描述"],
|
||||
actions=["给迭代日志增加config_diff/effect_summary"],
|
||||
changed_rules=[],
|
||||
metrics={"items": 1},
|
||||
related_symbols=[],
|
||||
config_diff={"changed": [{"path": "review.min_samples", "old": 5, "new": 4}], "added": [], "removed": []},
|
||||
effect_summary={"review_count_window": 0, "hit_rate_pct": 0, "avg_pnl": 0},
|
||||
)
|
||||
|
||||
client = TestClient(web_server.app)
|
||||
resp = client.get("/api/review")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["iteration_logs"][0]["config_diff"]["changed"][0]["path"] == "review.min_samples"
|
||||
assert "effect_overview" in data["iteration_summary"]
|
||||
208
tests/test_market_context_enrichment.py
Normal file
208
tests/test_market_context_enrichment.py
Normal file
@ -0,0 +1,208 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if PROJECT_DIR not in sys.path:
|
||||
sys.path.insert(0, PROJECT_DIR)
|
||||
|
||||
import altcoin_db
|
||||
import web_server
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def temp_db(tmp_path, monkeypatch):
|
||||
db_path = tmp_path / "altcoin_monitor.db"
|
||||
monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path))
|
||||
altcoin_db.init_db()
|
||||
yield db_path
|
||||
|
||||
|
||||
def _require_columns(conn, columns):
|
||||
rows = conn.execute("PRAGMA table_info(recommendation)").fetchall()
|
||||
existing = {row[1] for row in rows}
|
||||
for column in columns:
|
||||
assert column in existing, f"missing column: {column}"
|
||||
|
||||
|
||||
def _insert_recommendation(conn, **overrides):
|
||||
row = {
|
||||
"symbol": "TEST/USDT",
|
||||
"rec_time": "2026-04-30T10:00:00",
|
||||
"rec_state": "加速",
|
||||
"rec_score": 15.5,
|
||||
"entry_price": 1.25,
|
||||
"stop_loss": 1.1,
|
||||
"tp1": 1.4,
|
||||
"tp2": 1.55,
|
||||
"sector": "AI",
|
||||
"signals": "[]",
|
||||
"is_meme": 0,
|
||||
"status": "active",
|
||||
"current_price": 1.32,
|
||||
"max_price": 1.36,
|
||||
"max_drawdown_pct": -2.5,
|
||||
"max_pnl_pct": 5.2,
|
||||
"pnl_pct": 3.1,
|
||||
"last_track_time": "2026-04-30T11:00:00",
|
||||
"action_status": "可即刻买入",
|
||||
"entry_plan_json": '{"entry_action": "可即刻买入", "entry_price": 1.25}',
|
||||
"direction": "多头启动",
|
||||
"force_reason": "",
|
||||
"base_state": "加速",
|
||||
"sector_signal_count": 0,
|
||||
"strategy_version": "v11.1",
|
||||
"market_context_json": "{}",
|
||||
"derivatives_context_json": "{}",
|
||||
"sector_context_json": "{}",
|
||||
}
|
||||
row.update(overrides)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO recommendation (
|
||||
symbol, rec_time, rec_state, rec_score, entry_price,
|
||||
stop_loss, tp1, tp2, sector, signals, is_meme,
|
||||
status, current_price, max_price, max_drawdown_pct,
|
||||
max_pnl_pct, pnl_pct, last_track_time, action_status,
|
||||
entry_plan_json, direction, force_reason, base_state,
|
||||
sector_signal_count, strategy_version,
|
||||
market_context_json, derivatives_context_json, sector_context_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
row["symbol"], row["rec_time"], row["rec_state"], row["rec_score"], row["entry_price"],
|
||||
row["stop_loss"], row["tp1"], row["tp2"], row["sector"], row["signals"], row["is_meme"],
|
||||
row["status"], row["current_price"], row["max_price"], row["max_drawdown_pct"],
|
||||
row["max_pnl_pct"], row["pnl_pct"], row["last_track_time"], row["action_status"],
|
||||
row["entry_plan_json"], row["direction"], row["force_reason"], row["base_state"],
|
||||
row["sector_signal_count"], row["strategy_version"],
|
||||
row["market_context_json"], row["derivatives_context_json"], row["sector_context_json"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_active_recommendations_expose_enriched_context(temp_db):
|
||||
conn = altcoin_db.get_conn()
|
||||
_require_columns(conn, [
|
||||
"market_context_json", "derivatives_context_json", "sector_context_json",
|
||||
])
|
||||
_insert_recommendation(
|
||||
conn,
|
||||
symbol="PNT/USDT",
|
||||
force_reason="静K蓄力旁路",
|
||||
base_state="蓄力",
|
||||
sector_signal_count=2,
|
||||
market_context_json='{"volume_24h": 12000000, "turnover_acceleration_1h": 2.8, "turnover_acceleration_4h": 1.6, "change_24h": 8.2}',
|
||||
derivatives_context_json='{"funding_rate": 0.0008, "top_trader_long_pct": 62.5, "top_trader_long_short_ratio": 1.7}',
|
||||
sector_context_json='{"sectors": ["AI", "Infra"], "hot_sectors": ["AI"], "leader_symbol": "WLD/USDT", "leader_move_pct": 9.6}',
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False)
|
||||
assert len(rows) == 1
|
||||
item = rows[0]
|
||||
|
||||
assert item["force_reason"] == "静K蓄力旁路"
|
||||
assert item["base_state"] == "蓄力"
|
||||
assert item["sector_signal_count"] == 2
|
||||
assert item["market_context"]["turnover_acceleration_1h"] == 2.8
|
||||
assert item["market_context"]["change_24h"] == 8.2
|
||||
assert item["derivatives_context"]["top_trader_long_pct"] == 62.5
|
||||
assert item["sector_context"]["leader_symbol"] == "WLD/USDT"
|
||||
assert item["sector_context"]["hot_sectors"] == ["AI"]
|
||||
|
||||
|
||||
def test_stats_exposes_market_context_summary(temp_db):
|
||||
conn = altcoin_db.get_conn()
|
||||
_require_columns(conn, [
|
||||
"market_context_json", "derivatives_context_json", "sector_context_json",
|
||||
])
|
||||
_insert_recommendation(
|
||||
conn,
|
||||
symbol="PNT/USDT",
|
||||
force_reason="静K蓄力旁路",
|
||||
base_state="蓄力",
|
||||
sector_signal_count=1,
|
||||
market_context_json='{"volume_24h": 12000000, "turnover_acceleration_1h": 2.8, "turnover_acceleration_4h": 1.6}',
|
||||
derivatives_context_json='{"funding_rate": 0.0008, "top_trader_long_pct": 62.5, "top_trader_long_short_ratio": 1.7}',
|
||||
sector_context_json='{"sectors": ["AI"], "hot_sectors": ["AI"], "leader_symbol": "WLD/USDT", "leader_move_pct": 9.6}',
|
||||
)
|
||||
_insert_recommendation(
|
||||
conn,
|
||||
symbol="AI/USDT",
|
||||
sector="AI,Gaming",
|
||||
force_reason="纯板块联动降级",
|
||||
base_state="加速",
|
||||
sector_signal_count=2,
|
||||
market_context_json='{"volume_24h": 20000000, "turnover_acceleration_1h": 3.2, "turnover_acceleration_4h": 2.1}',
|
||||
derivatives_context_json='{"funding_rate": 0.0012, "top_trader_long_pct": 66.0, "top_trader_long_short_ratio": 1.9}',
|
||||
sector_context_json='{"sectors": ["AI", "Gaming"], "hot_sectors": ["AI", "Gaming"], "leader_symbol": "TAO/USDT", "leader_move_pct": 12.4}',
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
stats = altcoin_db.get_stats()
|
||||
market = stats["market_context_overview"]
|
||||
|
||||
assert market["actionable_sample_count"] == 2
|
||||
assert market["avg_turnover_acceleration_1h"] == 3.0
|
||||
assert market["avg_funding_rate"] == 0.001
|
||||
assert market["avg_top_trader_long_pct"] == 64.2
|
||||
assert market["top_hot_sectors"][0] == {"sector": "AI", "count": 2}
|
||||
assert market["top_hot_sectors"][1] == {"sector": "Gaming", "count": 1}
|
||||
|
||||
|
||||
def test_stats_api_returns_market_context_overview(temp_db):
|
||||
conn = altcoin_db.get_conn()
|
||||
_require_columns(conn, ["market_context_json", "derivatives_context_json", "sector_context_json"])
|
||||
_insert_recommendation(
|
||||
conn,
|
||||
symbol="PNT/USDT",
|
||||
force_reason="静K蓄力旁路",
|
||||
base_state="蓄力",
|
||||
sector_signal_count=2,
|
||||
market_context_json='{"volume_24h": 12000000, "turnover_acceleration_1h": 2.8, "turnover_acceleration_4h": 1.6}',
|
||||
derivatives_context_json='{"funding_rate": 0.0008, "top_trader_long_pct": 62.5, "top_trader_long_short_ratio": 1.7}',
|
||||
sector_context_json='{"sectors": ["AI"], "hot_sectors": ["AI"], "leader_symbol": "WLD/USDT", "leader_move_pct": 9.6}',
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
client = TestClient(web_server.app)
|
||||
resp = client.get("/api/stats")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
assert "market_context_overview" in data
|
||||
overview = data["market_context_overview"]
|
||||
assert overview["actionable_sample_count"] == 1
|
||||
assert overview["top_hot_sectors"][0]["sector"] == "AI"
|
||||
|
||||
|
||||
def test_recommendations_api_and_page_expose_context_fields(temp_db):
|
||||
conn = altcoin_db.get_conn()
|
||||
_insert_recommendation(
|
||||
conn,
|
||||
symbol="PNT/USDT",
|
||||
force_reason="静K蓄力旁路",
|
||||
base_state="蓄力",
|
||||
sector_signal_count=2,
|
||||
market_context_json='{"volume_24h": 12000000, "turnover_acceleration_1h": 2.8, "turnover_acceleration_4h": 1.6, "change_24h": 8.2}',
|
||||
derivatives_context_json='{"funding_rate": 0.0008, "open_interest_change_24h": 14.5, "top_trader_long_pct": 62.5, "top_trader_long_short_ratio": 1.7}',
|
||||
sector_context_json='{"sectors": ["AI", "Infra"], "hot_sectors": ["AI"], "leader_symbol": "WLD/USDT", "leader_move_pct": 9.6}',
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
client = TestClient(web_server.app)
|
||||
|
||||
api_resp = client.get("/api/recommendations/active?actionable_only=false")
|
||||
assert api_resp.status_code == 200
|
||||
rows = api_resp.json()
|
||||
assert rows[0]["market_context"]["turnover_acceleration_1h"] == 2.8
|
||||
assert rows[0]["derivatives_context"]["open_interest_change_24h"] == 14.5
|
||||
assert rows[0]["sector_context"]["leader_symbol"] == "WLD/USDT"
|
||||
84
tests/test_opportunity_lifecycle.py
Normal file
84
tests/test_opportunity_lifecycle.py
Normal file
@ -0,0 +1,84 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from opportunity_lifecycle import apply_entry_quality_gate
|
||||
import price_tracker_ws
|
||||
|
||||
|
||||
def test_risk_reward_false_blocks_buy_now():
|
||||
action, plan, reasons = apply_entry_quality_gate(
|
||||
action_status='可即刻买入',
|
||||
entry_plan={
|
||||
'entry_action': '等回踩',
|
||||
'entry_price': 0.072,
|
||||
'current_price': 0.0758,
|
||||
'risk_reward_ok': False,
|
||||
'rr1': 0.4,
|
||||
},
|
||||
signals=['1H 起爆点↑(强度56×)', '⚠️ 等回踩降权(-3分)'],
|
||||
current_price=0.0758,
|
||||
market_context={'change_24h': 9.0},
|
||||
)
|
||||
assert action in ('等回踩', '观察')
|
||||
assert action != '可即刻买入'
|
||||
assert plan['entry_quality_gate']['blocked_action'] == '可即刻买入'
|
||||
assert any('risk_reward_ok=false' in r for r in reasons)
|
||||
|
||||
|
||||
def test_breakout_distance_over_60_forces_observe():
|
||||
action, plan, reasons = apply_entry_quality_gate(
|
||||
action_status='可即刻买入',
|
||||
entry_plan={'entry_action': '即刻买入', 'risk_reward_ok': True, 'rr1': 2.0, 'entry_price': 0.164},
|
||||
signals=['日线站稳突破位 +66.7%', '日线站稳突破位 +71.7%'],
|
||||
current_price=0.168,
|
||||
market_context={'change_24h': 5.0},
|
||||
)
|
||||
assert action == '观察'
|
||||
assert plan['entry_quality_gate']['breakout_distance_pct'] == 71.7
|
||||
assert any('严禁现价追' in r for r in reasons)
|
||||
|
||||
|
||||
def test_low_static_accumulation_builds_ambush_plan():
|
||||
action, plan, reasons = apply_entry_quality_gate(
|
||||
action_status='等回踩',
|
||||
entry_plan={'entry_action': '等回踩', 'entry_price': 2.393, 'risk_reward_ok': True, 'rr1': 1.6, 'support': 2.2, 'resistance': 3.0},
|
||||
signals=['4H静K蓄力观察(3静K,量比1.4x)', '大户偏多 62%'],
|
||||
current_price=2.393,
|
||||
market_context={'change_24h': 3.0},
|
||||
derivatives_context={'top_trader_long_pct': 62},
|
||||
)
|
||||
assert action == '等回踩'
|
||||
lifecycle = plan.get('opportunity_lifecycle')
|
||||
assert lifecycle['stage'] == '低位潜伏'
|
||||
assert lifecycle['plan_type'] == 'ambush'
|
||||
assert lifecycle['static_count'] >= 3
|
||||
|
||||
|
||||
def test_ws_tracker_does_not_push_when_gate_downgrades_buy_now():
|
||||
rec = {
|
||||
'id': 1,
|
||||
'symbol': 'WLFI/USDT',
|
||||
'status': 'active',
|
||||
'entry_price': 0.0758,
|
||||
'stop_loss': 0.070,
|
||||
'tp1': 0.080,
|
||||
'tp2': 0.085,
|
||||
'entry_plan': {
|
||||
'entry_action': '等回踩',
|
||||
'entry_price': 0.072,
|
||||
'risk_reward_ok': False,
|
||||
'rr1': 0.4,
|
||||
},
|
||||
'signals': json.dumps(['1H 起爆点↑(强度56×)', '⚠️ 等回踩降权(-3分)'], ensure_ascii=False),
|
||||
'market_context': {'change_24h': 9.0},
|
||||
'derivatives_context': {},
|
||||
'sector_context': {},
|
||||
'action_status': '持有',
|
||||
}
|
||||
trigger = price_tracker_ws.check_triggers('WLFI/USDT', rec, 0.0719)
|
||||
assert trigger is not None
|
||||
assert trigger['action_status'] != '可即刻买入'
|
||||
assert trigger['pushable'] is False
|
||||
37
tests/test_pa_recency.py
Normal file
37
tests/test_pa_recency.py
Normal file
@ -0,0 +1,37 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from pa_engine import calc_atr, classify_candles, detect_ignition_point
|
||||
|
||||
|
||||
def _ignition_df(stale_age_bars=6):
|
||||
rows = []
|
||||
base = pd.Timestamp('2026-05-10 00:00:00')
|
||||
for i in range(40):
|
||||
rows.append({
|
||||
'time': base + pd.Timedelta(hours=i),
|
||||
'open': 1.0,
|
||||
'high': 1.012,
|
||||
'low': 0.988,
|
||||
'close': 1.001,
|
||||
'volume': 100.0,
|
||||
})
|
||||
idx = len(rows) - 1 - stale_age_bars
|
||||
for j in [idx - 2, idx - 1]:
|
||||
rows[j].update({'open': 1.0, 'high': 1.003, 'low': 0.997, 'close': 1.0005})
|
||||
rows[idx].update({'open': 1.0, 'high': 1.18, 'low': 0.995, 'close': 1.16, 'volume': 1000.0})
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def test_ignition_point_has_age_bars_for_recency_gate():
|
||||
df = _ignition_df(stale_age_bars=6)
|
||||
atr = calc_atr(df, 14)
|
||||
candles = classify_candles(df, atr)
|
||||
ignitions = detect_ignition_point(candles, df, atr)
|
||||
assert ignitions
|
||||
assert any('age_bars' in ig for ig in ignitions)
|
||||
assert max(ig['age_bars'] for ig in ignitions) >= 6
|
||||
117
tests/test_personalization_strategy_stage2_3.py
Normal file
117
tests/test_personalization_strategy_stage2_3.py
Normal file
@ -0,0 +1,117 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
import auth_db
|
||||
import altcoin_db
|
||||
|
||||
|
||||
class PersonalizationAndStrategyInsightTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.TemporaryDirectory()
|
||||
self.auth_path = os.path.join(self.tmpdir.name, 'auth.db')
|
||||
self.alt_path = os.path.join(self.tmpdir.name, 'alt.db')
|
||||
self.auth_patch = patch.object(auth_db, 'DB_PATH', self.auth_path)
|
||||
self.alt_patch = patch.object(altcoin_db, 'DB_PATH', self.alt_path)
|
||||
self.auth_patch.start()
|
||||
self.alt_patch.start()
|
||||
auth_db.init_auth_db()
|
||||
altcoin_db.init_db()
|
||||
conn = sqlite3.connect(self.auth_path)
|
||||
conn.execute("""
|
||||
INSERT INTO app_user (id,email,password_hash,password_salt,email_verified,status,invite_code,created_at,updated_at)
|
||||
VALUES (1,'u@test.com','h','s',1,'active','INV','2026-05-09T00:00:00','2026-05-09T00:00:00')
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def tearDown(self):
|
||||
self.auth_patch.stop()
|
||||
self.alt_patch.stop()
|
||||
self.tmpdir.cleanup()
|
||||
|
||||
def _insert_rec(self, **kwargs):
|
||||
defaults = dict(
|
||||
symbol='ENS/USDT', rec_time='2026-05-09T20:00:00', rec_state='爆发', rec_score=72,
|
||||
entry_price=10.0, stop_loss=9.6, tp1=10.8, tp2=11.4, sector='AI',
|
||||
signals=json.dumps(['底部抬高', '放量突破'], ensure_ascii=False), is_meme=0,
|
||||
status='hit_tp1', current_price=10.8, max_price=10.9, min_price=9.9,
|
||||
pnl_pct=8.0, max_pnl_pct=9.0, max_drawdown_pct=-1.0,
|
||||
hit_tp1_time='2026-05-09T21:00:00', hit_tp2_time='', stopped_out_time='', expired_time='', last_track_time='2026-05-09T21:00:00',
|
||||
entry_plan_json=json.dumps({'entry_price':10.0,'entry_action':'可即刻买入','rr1':2.0,'stop_loss':9.6,'tp1':10.8}, ensure_ascii=False),
|
||||
action_status='止盈1', direction='多头启动', strategy_version='v-test',
|
||||
market_context_json=json.dumps({'btc_trend':'up'}, ensure_ascii=False),
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
conn = sqlite3.connect(self.alt_path)
|
||||
cols = ','.join(defaults.keys())
|
||||
qs = ','.join(['?'] * len(defaults))
|
||||
cur = conn.execute(f"INSERT INTO recommendation ({cols}) VALUES ({qs})", tuple(defaults.values()))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return cur.lastrowid
|
||||
|
||||
def test_watchlist_adds_and_filters_active_recommendations(self):
|
||||
self._insert_rec(symbol='ENS/USDT', status='active', action_status='可即刻买入')
|
||||
self._insert_rec(symbol='SOL/USDT', status='active', action_status='等回踩')
|
||||
|
||||
auth_db.add_watchlist_symbol(1, 'ens')
|
||||
rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False, watch_symbols=auth_db.get_watchlist_symbols(1))
|
||||
|
||||
self.assertEqual([r['symbol'] for r in rows], ['ENS/USDT'])
|
||||
|
||||
def test_observation_list_keeps_user_saved_opportunities(self):
|
||||
rec_id = self._insert_rec(symbol='ENS/USDT', status='active', action_status='等回踩')
|
||||
|
||||
auth_db.save_observation(1, rec_id, '重点等回踩')
|
||||
rows = auth_db.get_saved_observations(1)
|
||||
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]['rec_id'], rec_id)
|
||||
self.assertEqual(rows[0]['note'], '重点等回踩')
|
||||
|
||||
def test_push_rules_persist_user_thresholds(self):
|
||||
auth_db.update_push_rules(1, {
|
||||
'watchlist_only': True,
|
||||
'min_score': 70,
|
||||
'min_rr': 1.5,
|
||||
'push_buy_now': True,
|
||||
'push_wait_pullback': False,
|
||||
'quiet_start': '01:00',
|
||||
'quiet_end': '08:00',
|
||||
})
|
||||
|
||||
rules = auth_db.get_push_rules(1)
|
||||
|
||||
self.assertTrue(rules['watchlist_only'])
|
||||
self.assertEqual(rules['min_score'], 70)
|
||||
self.assertEqual(rules['min_rr'], 1.5)
|
||||
self.assertFalse(rules['push_wait_pullback'])
|
||||
self.assertEqual(rules['quiet_start'], '01:00')
|
||||
|
||||
def test_strategy_performance_and_factor_attribution(self):
|
||||
self._insert_rec(symbol='ENS/USDT', signals=json.dumps(['底部抬高','放量突破'], ensure_ascii=False), status='hit_tp1', pnl_pct=8.0, max_drawdown_pct=-1.0)
|
||||
self._insert_rec(symbol='SOL/USDT', signals=json.dumps(['放量突破'], ensure_ascii=False), status='stopped_out', pnl_pct=-3.0, max_drawdown_pct=-4.0)
|
||||
self._insert_rec(symbol='BTC/USDT', signals=json.dumps(['底部抬高'], ensure_ascii=False), status='active', pnl_pct=1.0, max_pnl_pct=2.0)
|
||||
|
||||
insight = altcoin_db.get_strategy_insights()
|
||||
|
||||
self.assertEqual(insight['overview']['total_signals'], 2)
|
||||
self.assertEqual(insight['overview']['resolved_count'], 2)
|
||||
self.assertEqual(insight['overview']['win_rate_pct'], 50.0)
|
||||
factor = next(x for x in insight['factor_attribution'] if x['factor'] == '底部抬高')
|
||||
self.assertEqual(factor['total_count'], 1)
|
||||
self.assertEqual(factor['success_count'], 1)
|
||||
self.assertAlmostEqual(factor['avg_pnl_pct'], 8.0)
|
||||
env = next(x for x in insight['market_environment'] if x['environment'] == 'btc_trend:up')
|
||||
self.assertEqual(env['total_count'], 2)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
248
tests/test_recommendation_execution_status.py
Normal file
248
tests/test_recommendation_execution_status.py
Normal file
@ -0,0 +1,248 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import altcoin_db
|
||||
|
||||
|
||||
class RecommendationExecutionStatusTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmpdir.name, 'test_altcoin.db')
|
||||
self.db_patch = patch.object(altcoin_db, 'DB_PATH', self.db_path)
|
||||
self.db_patch.start()
|
||||
altcoin_db.init_db()
|
||||
|
||||
def tearDown(self):
|
||||
self.db_patch.stop()
|
||||
self.tmpdir.cleanup()
|
||||
|
||||
def _insert_rec(self, **kwargs):
|
||||
defaults = dict(
|
||||
symbol='AAA/USDT',
|
||||
rec_time='2026-04-29T10:00:00',
|
||||
rec_state='加速',
|
||||
rec_score=8,
|
||||
entry_price=100.0,
|
||||
stop_loss=95.0,
|
||||
tp1=110.0,
|
||||
tp2=118.0,
|
||||
sector='AI',
|
||||
signals='[]',
|
||||
is_meme=0,
|
||||
status='active',
|
||||
current_price=100.0,
|
||||
max_price=104.0,
|
||||
min_price=98.0,
|
||||
pnl_pct=0.0,
|
||||
max_pnl_pct=4.0,
|
||||
max_drawdown_pct=-1.0,
|
||||
hit_tp1_time='',
|
||||
hit_tp2_time='',
|
||||
stopped_out_time='',
|
||||
expired_time='',
|
||||
last_track_time='2026-04-29T10:05:00',
|
||||
entry_plan_json=json.dumps({
|
||||
'entry_price': 100.0,
|
||||
'entry_action': '可即刻买入',
|
||||
'risk_reward_ok': True,
|
||||
'stop_loss': 95.0,
|
||||
'stop_pct': -5.0,
|
||||
'tp1': 110.0,
|
||||
'tp2': 118.0,
|
||||
'rr1': 2.0,
|
||||
'rr2': 3.6,
|
||||
}, ensure_ascii=False),
|
||||
action_status='可即刻买入',
|
||||
direction='多头启动',
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.execute(
|
||||
'''
|
||||
INSERT INTO recommendation (
|
||||
symbol, rec_time, rec_state, rec_score, entry_price, stop_loss, tp1, tp2,
|
||||
sector, signals, is_meme, status, current_price, max_price, min_price,
|
||||
pnl_pct, max_pnl_pct, max_drawdown_pct, hit_tp1_time, hit_tp2_time,
|
||||
stopped_out_time, expired_time, last_track_time, entry_plan_json,
|
||||
action_status, direction
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''',
|
||||
(
|
||||
defaults['symbol'], defaults['rec_time'], defaults['rec_state'], defaults['rec_score'],
|
||||
defaults['entry_price'], defaults['stop_loss'], defaults['tp1'], defaults['tp2'],
|
||||
defaults['sector'], defaults['signals'], defaults['is_meme'], defaults['status'],
|
||||
defaults['current_price'], defaults['max_price'], defaults['min_price'], defaults['pnl_pct'],
|
||||
defaults['max_pnl_pct'], defaults['max_drawdown_pct'], defaults['hit_tp1_time'], defaults['hit_tp2_time'],
|
||||
defaults['stopped_out_time'], defaults['expired_time'], defaults['last_track_time'], defaults['entry_plan_json'],
|
||||
defaults['action_status'], defaults['direction'],
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def _get_active(self):
|
||||
rows = altcoin_db.get_active_recommendations_deduped()
|
||||
self.assertGreaterEqual(len(rows), 1)
|
||||
return next(r for r in rows if r['symbol'] == 'AAA/USDT')
|
||||
|
||||
def test_buy_now_status_for_immediate_entry(self):
|
||||
self._insert_rec()
|
||||
row = self._get_active()
|
||||
self.assertEqual(row['execution_status'], 'buy_now')
|
||||
self.assertEqual(row['execution_label'], '🟢 现在可买')
|
||||
self.assertEqual(row['initial_action'], '可即刻买入')
|
||||
self.assertIn('推荐时就是可即刻买入', row['execution_reason'])
|
||||
|
||||
def test_wait_pullback_status_for_wait_action(self):
|
||||
self._insert_rec(
|
||||
symbol='BBB/USDT',
|
||||
action_status='等回踩',
|
||||
current_price=104.0,
|
||||
entry_plan_json=json.dumps({
|
||||
'entry_price': 100.0,
|
||||
'entry_action': '等回踩',
|
||||
'risk_reward_ok': True,
|
||||
}, ensure_ascii=False),
|
||||
)
|
||||
rows = altcoin_db.get_active_recommendations_deduped()
|
||||
target = next(r for r in rows if r['symbol'] == 'BBB/USDT')
|
||||
self.assertEqual(target['execution_status'], 'wait_pullback')
|
||||
self.assertEqual(target['execution_label'], '🟡 等回踩,不追高')
|
||||
self.assertIn('等待回踩', target['execution_reason'])
|
||||
|
||||
def test_invalid_status_for_decay(self):
|
||||
self._insert_rec(
|
||||
symbol='CCC/USDT',
|
||||
action_status='衰减',
|
||||
current_price=107.0,
|
||||
max_pnl_pct=8.0,
|
||||
entry_plan_json=json.dumps({
|
||||
'entry_price': 100.0,
|
||||
'entry_action': '可即刻买入',
|
||||
'risk_reward_ok': True,
|
||||
}, ensure_ascii=False),
|
||||
)
|
||||
rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False)
|
||||
target = next(r for r in rows if r['symbol'] == 'CCC/USDT')
|
||||
self.assertEqual(target['execution_status'], 'invalid')
|
||||
self.assertEqual(target['execution_label'], '🔴 已失效,勿追')
|
||||
self.assertIn('趋势衰减', target['execution_reason'])
|
||||
|
||||
def test_completed_status_for_take_profit(self):
|
||||
self._insert_rec(
|
||||
symbol='DDD/USDT',
|
||||
status='hit_tp1',
|
||||
action_status='止盈1',
|
||||
current_price=111.0,
|
||||
pnl_pct=11.0,
|
||||
max_pnl_pct=12.0,
|
||||
entry_plan_json=json.dumps({
|
||||
'entry_price': 100.0,
|
||||
'entry_action': '可即刻买入',
|
||||
'risk_reward_ok': True,
|
||||
}, ensure_ascii=False),
|
||||
)
|
||||
all_rows = altcoin_db.get_all_recommendations(limit=10)
|
||||
target = next(r for r in all_rows if r['symbol'] == 'DDD/USDT')
|
||||
self.assertEqual(target['execution_status'], 'completed')
|
||||
self.assertEqual(target['execution_label'], '✅ 已兑现,仅观察')
|
||||
self.assertIn('止盈', target['execution_reason'])
|
||||
|
||||
stats = altcoin_db.get_stats()
|
||||
self.assertEqual(stats['active_count'], 0)
|
||||
self.assertIsNone(stats['leaderboard']['top_gainer'])
|
||||
|
||||
def test_create_recommendation_skips_duplicate_symbol_state_within_window(self):
|
||||
with patch.object(altcoin_db, 'get_meta', return_value={'strategy_version': 'v1.2'}), \
|
||||
patch.object(altcoin_db, 'datetime') as mock_datetime:
|
||||
mock_datetime.now.return_value = unittest.mock.Mock(isoformat=lambda: '2026-04-30T10:00:00')
|
||||
|
||||
rec_id_1 = altcoin_db.create_recommendation(
|
||||
symbol='AAA/USDT', rec_state='加速', rec_score=8, entry_price=100.0,
|
||||
sector='AI', signals=['4H 连续4K多头加速'], is_meme=0, direction='多头启动'
|
||||
)
|
||||
rec_id_2 = altcoin_db.create_recommendation(
|
||||
symbol='AAA/USDT', rec_state='加速', rec_score=9, entry_price=101.0,
|
||||
sector='AI', signals=['4H 连续4K多头加速'], is_meme=0, direction='多头启动'
|
||||
)
|
||||
|
||||
self.assertEqual(rec_id_1, rec_id_2)
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
count = conn.execute("SELECT COUNT(*) FROM recommendation WHERE symbol='AAA/USDT'").fetchone()[0]
|
||||
conn.close()
|
||||
self.assertEqual(count, 1)
|
||||
|
||||
def test_create_recommendation_allows_new_state_transition_within_window(self):
|
||||
with patch.object(altcoin_db, 'get_meta', return_value={'strategy_version': 'v1.2'}), \
|
||||
patch.object(altcoin_db, 'datetime') as mock_datetime:
|
||||
mock_datetime.now.return_value = unittest.mock.Mock(isoformat=lambda: '2026-04-30T10:00:00')
|
||||
|
||||
rec_id_1 = altcoin_db.create_recommendation(
|
||||
symbol='AAA/USDT', rec_state='蓄力', rec_score=5, entry_price=100.0,
|
||||
sector='AI', signals=['4H 3静K蓄力'], is_meme=0, direction='多头启动'
|
||||
)
|
||||
rec_id_2 = altcoin_db.create_recommendation(
|
||||
symbol='AAA/USDT', rec_state='加速', rec_score=9, entry_price=103.0,
|
||||
sector='AI', signals=['4H 连续4K多头加速'], is_meme=0, direction='多头启动'
|
||||
)
|
||||
|
||||
self.assertNotEqual(rec_id_1, rec_id_2)
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
count = conn.execute("SELECT COUNT(*) FROM recommendation WHERE symbol='AAA/USDT'").fetchone()[0]
|
||||
conn.close()
|
||||
self.assertEqual(count, 2)
|
||||
def test_risk_reward_false_blocks_buy_now(self):
|
||||
self._insert_rec(
|
||||
symbol='EEE/USDT',
|
||||
action_status='可即刻买入',
|
||||
current_price=0.0758,
|
||||
entry_price=0.0758,
|
||||
signals=json.dumps(['1H 起爆点↑(强度56×)', '⚠️ 等回踩降权(-3分)'], ensure_ascii=False),
|
||||
entry_plan_json=json.dumps({
|
||||
'entry_price': 0.072,
|
||||
'entry_action': '等回踩',
|
||||
'risk_reward_ok': False,
|
||||
'rr1': 0.4,
|
||||
'stop_loss': 0.07,
|
||||
}, ensure_ascii=False),
|
||||
)
|
||||
rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False)
|
||||
target = next(r for r in rows if r['symbol'] == 'EEE/USDT')
|
||||
self.assertNotEqual(target['execution_status'], 'buy_now')
|
||||
self.assertIn(target['action_status'], ('等回踩', '观察'))
|
||||
self.assertIn('entry_quality_gate', target['entry_plan'])
|
||||
|
||||
def test_update_action_status_refuses_buy_now_when_rr_bad(self):
|
||||
self._insert_rec(
|
||||
symbol='FFF/USDT',
|
||||
action_status='持有',
|
||||
current_price=0.0758,
|
||||
entry_price=0.0758,
|
||||
entry_plan_json=json.dumps({
|
||||
'entry_price': 0.072,
|
||||
'entry_action': '等回踩',
|
||||
'risk_reward_ok': False,
|
||||
'rr1': 0.4,
|
||||
}, ensure_ascii=False),
|
||||
)
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
rec_id = conn.execute("SELECT id FROM recommendation WHERE symbol='FFF/USDT'").fetchone()[0]
|
||||
conn.close()
|
||||
|
||||
altcoin_db.update_recommendation_action_status(rec_id, '可即刻买入')
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
row = conn.execute("SELECT action_status, entry_plan_json FROM recommendation WHERE id=?", (rec_id,)).fetchone()
|
||||
conn.close()
|
||||
self.assertNotEqual(row[0], '可即刻买入')
|
||||
self.assertIn(row[0], ('等回踩', '观察'))
|
||||
self.assertIn('entry_quality_gate', json.loads(row[1]))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
155
tests/test_recommendation_state_mainline.py
Normal file
155
tests/test_recommendation_state_mainline.py
Normal file
@ -0,0 +1,155 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
import altcoin_db
|
||||
|
||||
|
||||
class RecommendationStateMainlineTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmpdir.name, 'test_altcoin.db')
|
||||
self.db_patch = patch.object(altcoin_db, 'DB_PATH', self.db_path)
|
||||
self.db_patch.start()
|
||||
altcoin_db.init_db()
|
||||
|
||||
def tearDown(self):
|
||||
self.db_patch.stop()
|
||||
self.tmpdir.cleanup()
|
||||
|
||||
def _insert_rec(self, **kwargs):
|
||||
defaults = dict(
|
||||
symbol='CHIP/USDT',
|
||||
rec_time='2026-05-09T20:10:21',
|
||||
rec_state='爆发',
|
||||
rec_score=27,
|
||||
entry_price=0.06557,
|
||||
stop_loss=0.061846,
|
||||
tp1=0.071156,
|
||||
tp2=0.074881,
|
||||
sector='',
|
||||
signals=json.dumps(['15min 回踩确认'], ensure_ascii=False),
|
||||
is_meme=0,
|
||||
status='active',
|
||||
current_price=0.06557,
|
||||
max_price=0.06557,
|
||||
min_price=0.06557,
|
||||
pnl_pct=0.0,
|
||||
max_pnl_pct=0.0,
|
||||
max_drawdown_pct=0.0,
|
||||
hit_tp1_time='',
|
||||
hit_tp2_time='',
|
||||
stopped_out_time='',
|
||||
expired_time='',
|
||||
last_track_time='2026-05-09T20:11:00',
|
||||
entry_plan_json=json.dumps({
|
||||
'entry_price': 0.06557,
|
||||
'entry_action': '等回踩',
|
||||
'risk_reward_ok': True,
|
||||
'rr1': 1.5,
|
||||
'stop_loss': 0.061846,
|
||||
'tp1': 0.071156,
|
||||
'entry_trigger_confirmed': True,
|
||||
}, ensure_ascii=False),
|
||||
action_status='等回踩',
|
||||
direction='多头启动',
|
||||
strategy_version='v-test',
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cols = ','.join(defaults.keys())
|
||||
placeholders = ','.join(['?'] * len(defaults))
|
||||
cur = conn.execute(
|
||||
f"INSERT INTO recommendation ({cols}) VALUES ({placeholders})",
|
||||
tuple(defaults.values()),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return cur.lastrowid
|
||||
|
||||
def _row(self, rec_id):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
row = dict(conn.execute('SELECT * FROM recommendation WHERE id=?', (rec_id,)).fetchone())
|
||||
conn.close()
|
||||
return row
|
||||
|
||||
def test_state_transition_updates_db_before_push_payload(self):
|
||||
rec_id = self._insert_rec()
|
||||
|
||||
decision = altcoin_db.apply_recommendation_state_transition(
|
||||
rec_id,
|
||||
requested_action='可即刻买入',
|
||||
current_price=0.06580,
|
||||
event_time='2026-05-09T22:21:12',
|
||||
)
|
||||
|
||||
self.assertEqual(decision['action_status'], '可即刻买入')
|
||||
self.assertEqual(decision['execution_status'], 'buy_now')
|
||||
self.assertTrue(decision['push_required'])
|
||||
self.assertEqual(decision['push_symbol'], 'CHIP/USDT')
|
||||
self.assertEqual(decision['push_entry_price'], 0.06580)
|
||||
self.assertEqual(decision['push_current_price'], 0.06580)
|
||||
self.assertEqual(decision['push_pnl_pct'], 0.0)
|
||||
|
||||
row = self._row(rec_id)
|
||||
self.assertEqual(row['action_status'], '可即刻买入')
|
||||
self.assertEqual(row['entry_price'], 0.06580)
|
||||
self.assertEqual(row['current_price'], 0.06580)
|
||||
self.assertEqual(row['pnl_pct'], 0.0)
|
||||
self.assertEqual(row['rec_time'], '2026-05-09T22:21:12')
|
||||
|
||||
def test_state_transition_blocks_push_when_gate_downgrades_action(self):
|
||||
rec_id = self._insert_rec(
|
||||
entry_plan_json=json.dumps({
|
||||
'entry_price': 0.06557,
|
||||
'entry_action': '等回踩',
|
||||
'risk_reward_ok': False,
|
||||
'rr1': 0.4,
|
||||
'stop_loss': 0.061846,
|
||||
'tp1': 0.066,
|
||||
}, ensure_ascii=False),
|
||||
current_price=0.06650,
|
||||
)
|
||||
|
||||
decision = altcoin_db.apply_recommendation_state_transition(
|
||||
rec_id,
|
||||
requested_action='可即刻买入',
|
||||
current_price=0.06650,
|
||||
event_time='2026-05-09T22:21:12',
|
||||
)
|
||||
|
||||
self.assertNotEqual(decision['action_status'], '可即刻买入')
|
||||
self.assertFalse(decision['push_required'])
|
||||
row = self._row(rec_id)
|
||||
self.assertNotEqual(row['action_status'], '可即刻买入')
|
||||
self.assertNotEqual(row['rec_time'], '2026-05-09T22:21:12')
|
||||
|
||||
def test_api_derivation_consumes_persisted_state_without_promoting_initial_action(self):
|
||||
self._insert_rec(
|
||||
symbol='AAA/USDT',
|
||||
action_status='等回踩',
|
||||
current_price=104.0,
|
||||
entry_price=100.0,
|
||||
entry_plan_json=json.dumps({
|
||||
'entry_price': 100.0,
|
||||
'entry_action': '可即刻买入',
|
||||
'risk_reward_ok': True,
|
||||
'rr1': 2.0,
|
||||
}, ensure_ascii=False),
|
||||
)
|
||||
|
||||
rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False, version='v-test')
|
||||
target = next(r for r in rows if r['symbol'] == 'AAA/USDT')
|
||||
self.assertEqual(target['action_status'], '等回踩')
|
||||
self.assertEqual(target['execution_status'], 'wait_pullback')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
25
tests/test_replay_validation.py
Normal file
25
tests/test_replay_validation.py
Normal file
@ -0,0 +1,25 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if PROJECT_DIR not in sys.path:
|
||||
sys.path.insert(0, PROJECT_DIR)
|
||||
|
||||
import altcoin_screener
|
||||
|
||||
|
||||
def test_replay_samples_cover_pnt_cream_ai():
|
||||
replay = altcoin_screener.get_replay_samples()
|
||||
|
||||
assert set(replay.keys()) >= {"PNT/USDT", "CREAM/USDT", "AI/USDT"}
|
||||
assert replay["CREAM/USDT"]["expected"] in {"coarse_candidate", "qualified_candidate"}
|
||||
assert replay["PNT/USDT"]["expected"] in {"static_bypass_candidate", "qualified_candidate"}
|
||||
assert replay["AI/USDT"]["expected"] in {"sector_downgraded_candidate", "qualified_candidate"}
|
||||
|
||||
|
||||
def test_run_replay_validation_returns_all_three_symbols():
|
||||
result = altcoin_screener.run_replay_validation()
|
||||
|
||||
assert result["sample_count"] >= 3
|
||||
assert set(result["symbols"]) >= {"PNT/USDT", "CREAM/USDT", "AI/USDT"}
|
||||
assert all(item["passed"] for item in result["results"] if item["symbol"] in {"PNT/USDT", "CREAM/USDT", "AI/USDT"})
|
||||
31
tests/test_review_page_slimming.py
Normal file
31
tests/test_review_page_slimming.py
Normal file
@ -0,0 +1,31 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if PROJECT_DIR not in sys.path:
|
||||
sys.path.insert(0, PROJECT_DIR)
|
||||
|
||||
import web_server
|
||||
|
||||
|
||||
def test_index_hides_screening_from_top_level_tabs():
|
||||
client = TestClient(web_server.app)
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
assert 'data-tab="screening"' not in html
|
||||
assert '>筛选池<' not in html
|
||||
# Miro redesign: landing page no longer references internal debugging endpoints
|
||||
assert '/api/screening' not in html
|
||||
|
||||
|
||||
def test_review_page_slimmed_top_level_modules():
|
||||
html = web_server.HTML_PAGE
|
||||
assert '📈 组合净值 + 推荐生命周期' not in html
|
||||
assert '📘 复盘口径说明' not in html
|
||||
assert '🏆 当前浮盈第一' not in html
|
||||
assert '🩸 当前浮亏第一' not in html
|
||||
assert '🚀 最大爆发币' not in html
|
||||
assert '⚠️ 最危险币' not in html
|
||||
99
tests/test_review_version_summary.py
Normal file
99
tests/test_review_version_summary.py
Normal file
@ -0,0 +1,99 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if PROJECT_DIR not in sys.path:
|
||||
sys.path.insert(0, PROJECT_DIR)
|
||||
|
||||
import altcoin_db
|
||||
|
||||
|
||||
def test_review_stats_contains_strategy_version_summary_and_changelog(monkeypatch):
|
||||
monkeypatch.setattr(altcoin_db, "get_strategy_iteration_logs", lambda limit=30: [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "第2轮复盘迭代",
|
||||
"strategy_version": "v1.2",
|
||||
"version_change_summary": "v1.2:加入静K蓄力旁路 + 板块联动降级保留",
|
||||
"changed_rules": [{"type": "learned_rule", "description": "静K旁路"}],
|
||||
"config_diff": {"changed": [{"path": "screener.vp_fly"}], "added": [], "removed": []},
|
||||
"effect_summary": {"hit_rate_pct": 50.0, "avg_pnl": 0.79},
|
||||
}
|
||||
])
|
||||
monkeypatch.setattr(altcoin_db, "get_strategy_iteration_summary", lambda days=30: {
|
||||
"total_logs": 1,
|
||||
"version_stats": [
|
||||
{"strategy_version": "v1.2", "recommendation_count": 9, "success_count": 5, "failed_count": 1, "pending_count": 3, "success_rate_pct": 55.6, "avg_pnl_pct": 3.2}
|
||||
],
|
||||
"version_changelog": [
|
||||
{"strategy_version": "v1.2", "title": "第2轮复盘迭代", "summary": "加入旁路", "version_change_summary": "v1.2:加入静K蓄力旁路 + 板块联动降级保留"}
|
||||
],
|
||||
})
|
||||
|
||||
class DummyConn:
|
||||
def execute(self, sql, params=()):
|
||||
class Rows(list):
|
||||
def fetchall(self_inner):
|
||||
return []
|
||||
return Rows()
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(altcoin_db, "get_conn", lambda: DummyConn())
|
||||
|
||||
data = altcoin_db.get_review_stats()
|
||||
|
||||
assert data["iteration_summary"]["version_stats"][0]["strategy_version"] == "v1.2"
|
||||
assert "静K蓄力旁路" in data["iteration_summary"]["version_changelog"][0]["version_change_summary"]
|
||||
assert data["iteration_logs"][0]["strategy_version"] == "v1.2"
|
||||
|
||||
|
||||
def test_strategy_iteration_summary_aggregates_version_stats(monkeypatch):
|
||||
class DummyConn:
|
||||
def execute(self, sql, params=()):
|
||||
sql_norm = " ".join(sql.split())
|
||||
if "FROM strategy_iteration_log" in sql_norm:
|
||||
class Rows(list):
|
||||
def fetchall(self_inner):
|
||||
return [
|
||||
{
|
||||
"id": 1,
|
||||
"created_at": "2026-04-30T00:32:05",
|
||||
"run_date": "2026-04-30",
|
||||
"title": "第2轮复盘迭代",
|
||||
"summary": "继续积累样本",
|
||||
"changed_rules_json": "[]",
|
||||
"metrics_json": "{}",
|
||||
"related_symbols_json": "[]",
|
||||
"findings_json": "[]",
|
||||
"problems_json": "[]",
|
||||
"actions_json": "[]",
|
||||
"config_diff_json": '{"changed": [{"path": "meta.last_review"}], "added": [], "removed": []}',
|
||||
"effect_summary_json": '{"hit_rate_pct": 50.0, "avg_pnl": 0.79}',
|
||||
"strategy_version": "v1.2",
|
||||
"version_change_summary": "v1.2:加入静K蓄力旁路 + 板块联动降级保留",
|
||||
}
|
||||
]
|
||||
return Rows()
|
||||
if "FROM recommendation" in sql_norm:
|
||||
class Rows(list):
|
||||
def fetchall(self_inner):
|
||||
return [
|
||||
{"strategy_version": "v1.2", "status": "hit_tp1", "pnl_pct": 8.0, "max_pnl_pct": 12.0, "max_drawdown_pct": -1.0},
|
||||
{"strategy_version": "v1.2", "status": "active", "pnl_pct": 2.0, "max_pnl_pct": 6.0, "max_drawdown_pct": -2.0},
|
||||
{"strategy_version": "v1.2", "status": "stopped_out", "pnl_pct": -4.0, "max_pnl_pct": 1.0, "max_drawdown_pct": -6.0},
|
||||
]
|
||||
return Rows()
|
||||
raise AssertionError(sql)
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(altcoin_db, "get_conn", lambda: DummyConn())
|
||||
|
||||
summary = altcoin_db.get_strategy_iteration_summary(days=30)
|
||||
|
||||
assert summary["version_stats"][0]["strategy_version"] == "v1.2"
|
||||
assert summary["version_stats"][0]["recommendation_count"] == 3
|
||||
assert summary["version_stats"][0]["success_count"] == 2
|
||||
assert summary["version_stats"][0]["failed_count"] == 1
|
||||
assert summary["version_changelog"][0]["strategy_version"] == "v1.2"
|
||||
241
tests/test_screener_optimizations.py
Normal file
241
tests/test_screener_optimizations.py
Normal file
@ -0,0 +1,241 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if PROJECT_DIR not in sys.path:
|
||||
sys.path.insert(0, PROJECT_DIR)
|
||||
|
||||
import altcoin_screener
|
||||
|
||||
|
||||
def test_fetch_all_tickers_filters_stable_and_fiat_suffixes(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
altcoin_screener.exchange,
|
||||
"fetch_tickers",
|
||||
lambda: {
|
||||
"BTC/USDT": {"last": 1, "percentage": 1, "quoteVolume": 100},
|
||||
"RLUSD/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100},
|
||||
"BFUSD/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100},
|
||||
"EUR/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100},
|
||||
"AI/USDT": {"last": 1, "percentage": 5, "quoteVolume": 1000},
|
||||
"USD1/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100},
|
||||
"U/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100},
|
||||
"XUSD/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100},
|
||||
"FRAX/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100},
|
||||
"LUSD/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100},
|
||||
"GUSD/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100},
|
||||
"SUSD/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100},
|
||||
"USDD/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100},
|
||||
"EURS/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100},
|
||||
"AUD/USDT": {"last": 1, "percentage": 0.1, "quoteVolume": 100},
|
||||
},
|
||||
)
|
||||
|
||||
pairs = altcoin_screener.fetch_all_tickers()
|
||||
|
||||
assert "AI/USDT" in pairs
|
||||
assert "RLUSD/USDT" not in pairs
|
||||
assert "BFUSD/USDT" not in pairs
|
||||
assert "EUR/USDT" not in pairs
|
||||
assert "USD1/USDT" not in pairs
|
||||
assert "U/USDT" not in pairs
|
||||
assert "XUSD/USDT" not in pairs
|
||||
assert "FRAX/USDT" not in pairs
|
||||
assert "LUSD/USDT" not in pairs
|
||||
assert "GUSD/USDT" not in pairs
|
||||
assert "SUSD/USDT" not in pairs
|
||||
assert "USDD/USDT" not in pairs
|
||||
assert "EURS/USDT" not in pairs
|
||||
assert "AUD/USDT" not in pairs
|
||||
assert "BTC/USDT" not in pairs
|
||||
|
||||
|
||||
def _mock_weights():
|
||||
return {
|
||||
"量价齐飞": 5,
|
||||
"N倍放量": 5,
|
||||
"连续3x放量": 4,
|
||||
"布林收窄": 3,
|
||||
"静K蓄力": 2,
|
||||
"Q≥7供给区突破": 4,
|
||||
"Q7供给区突破": 4,
|
||||
"动K(阳)+量递增": 3,
|
||||
"动K阳量递增": 3,
|
||||
"连续K加速": 3,
|
||||
"板块联动": 3,
|
||||
"大户偏多": 1,
|
||||
"静K→动K转折": 4,
|
||||
"静K动K转折": 4,
|
||||
"1H放量(量价背离)": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_volume_price_fly_accepts_two_consecutive_4x_bars(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
altcoin_screener,
|
||||
"vp_fly_params",
|
||||
lambda: {"vol_ratio_min": 5.0, "body_ratio_min": 0.70, "consecutive_relaxed_vol_ratio_min": 4.0},
|
||||
)
|
||||
|
||||
rows = []
|
||||
for i in range(20):
|
||||
rows.append({"open": 1.0, "high": 1.03, "low": 0.99, "close": 1.01, "volume": 100.0})
|
||||
rows.extend([
|
||||
{"open": 1.00, "high": 1.10, "low": 0.99, "close": 1.09, "volume": 650.0},
|
||||
{"open": 1.09, "high": 1.20, "low": 1.08, "close": 1.18, "volume": 670.0},
|
||||
])
|
||||
df = pd.DataFrame(rows)
|
||||
|
||||
vp = altcoin_screener.detect_volume_price_fly(df)
|
||||
|
||||
assert vp["vp_fly_count"] == 2
|
||||
assert len(vp["vp_fly_details"]) == 2
|
||||
|
||||
|
||||
def test_layer1_keeps_high_momentum_breakout_without_5x_vp(monkeypatch):
|
||||
monkeypatch.setattr(altcoin_screener, "fetch_all_tickers", lambda: {
|
||||
"CREAM/USDT": {"price": 2.1, "change_24h": 12.0, "volume_24h": 8000000},
|
||||
})
|
||||
monkeypatch.setattr(altcoin_screener, "fetch_funding_rates", lambda: {})
|
||||
monkeypatch.setattr(altcoin_screener, "is_meme_coin", lambda symbol: False)
|
||||
monkeypatch.setattr(altcoin_screener, "get_burst_threshold", lambda symbol: 20)
|
||||
monkeypatch.setattr(altcoin_screener, "funding_rate_params", lambda: {"long_extreme": 0.001, "short_extreme": -0.0005})
|
||||
monkeypatch.setattr(altcoin_screener, "detect_bollinger_squeeze", lambda df: None)
|
||||
monkeypatch.setattr(altcoin_screener, "vp_fly_params", lambda: {"vol_ratio_min": 5.0, "body_ratio_min": 0.70, "consecutive_relaxed_vol_ratio_min": 4.0})
|
||||
monkeypatch.setattr(altcoin_screener, "get_dynamic_weights", lambda: {
|
||||
"量价齐飞": 5,
|
||||
"N倍放量(≥10x)": 6,
|
||||
"连续3x放量(≥3根)": 4,
|
||||
"布林收窄": 3,
|
||||
"静K蓄力": 2,
|
||||
"1H放量(量价背离)": 1,
|
||||
})
|
||||
|
||||
h1_rows = []
|
||||
for _ in range(20):
|
||||
h1_rows.append([0, 1.0, 1.03, 0.99, 1.01, 100.0])
|
||||
h1_rows.extend([
|
||||
[0, 1.00, 1.10, 0.99, 1.09, 650.0],
|
||||
[0, 1.09, 1.20, 1.08, 1.18, 670.0],
|
||||
])
|
||||
h4_rows = []
|
||||
price = 1.0
|
||||
for _ in range(30):
|
||||
h4_rows.append([0, price, price * 1.01, price * 0.99, price * 1.002, 100.0])
|
||||
price *= 1.001
|
||||
|
||||
def fake_fetch_klines(symbol, timeframe, limit=100):
|
||||
data = h1_rows if timeframe == '1h' else h4_rows
|
||||
return pd.DataFrame(data, columns=['timestamp','open','high','low','close','volume'])
|
||||
|
||||
monkeypatch.setattr(altcoin_screener, "fetch_klines", fake_fetch_klines)
|
||||
|
||||
candidates = altcoin_screener.layer1_coarse_filter()
|
||||
vp = altcoin_screener.detect_volume_price_fly(fake_fetch_klines('CREAM/USDT', '1h'))
|
||||
assert vp["vp_fly_count"] == 2
|
||||
|
||||
assert "CREAM/USDT" in candidates
|
||||
assert any("量价齐飞" in s for s in candidates["CREAM/USDT"]["anomalies"])
|
||||
assert any("连续2根量价齐飞K" in s for s in candidates["CREAM/USDT"]["anomalies"])
|
||||
|
||||
|
||||
def test_static_accumulation_bypass_promotes_expired_to_accumulate(monkeypatch):
|
||||
monkeypatch.setattr(altcoin_screener, "get_dynamic_weights", _mock_weights)
|
||||
monkeypatch.setattr(altcoin_screener, "state_score_thresholds", lambda: (8, 10, 3))
|
||||
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,
|
||||
},
|
||||
}.get(name, {}),
|
||||
)
|
||||
monkeypatch.setattr(altcoin_screener, "fetch_top_trader_ratio", lambda symbol: None)
|
||||
monkeypatch.setattr(altcoin_screener, "log_screening", lambda **kwargs: None)
|
||||
monkeypatch.setattr(altcoin_screener, "create_recommendation", lambda **kwargs: 456)
|
||||
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({
|
||||
"PNT/USDT": {
|
||||
"anomaly_score": 2,
|
||||
"price": 1.0,
|
||||
"change_24h": 4.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 qualified["PNT/USDT"]["state"] == "蓄力"
|
||||
assert qualified["PNT/USDT"]["base_state"] == "过期"
|
||||
assert qualified["PNT/USDT"]["force_reason"] == "静K蓄力旁路"
|
||||
assert any("静K蓄力旁路入池" in s for s in qualified["PNT/USDT"]["signals"])
|
||||
|
||||
|
||||
def test_strong_static_accumulation_can_promote_to_accelerate(monkeypatch):
|
||||
monkeypatch.setattr(altcoin_screener, "get_dynamic_weights", _mock_weights)
|
||||
monkeypatch.setattr(altcoin_screener, "state_score_thresholds", lambda: (8, 10, 3))
|
||||
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,
|
||||
"direct_accelerate": {
|
||||
"enabled": True,
|
||||
"min_static_count": 10,
|
||||
"min_vol_ratio": 1.25,
|
||||
"min_score": 5,
|
||||
},
|
||||
},
|
||||
}.get(name, {}),
|
||||
)
|
||||
monkeypatch.setattr(altcoin_screener, "fetch_top_trader_ratio", lambda symbol: None)
|
||||
monkeypatch.setattr(altcoin_screener, "log_screening", lambda **kwargs: None)
|
||||
created = []
|
||||
monkeypatch.setattr(altcoin_screener, "create_recommendation", lambda **kwargs: created.append(kwargs) or 789)
|
||||
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({
|
||||
"PNT/USDT": {
|
||||
"anomaly_score": 5,
|
||||
"price": 1.0,
|
||||
"change_24h": 4.0,
|
||||
"funding_rate": 0.0,
|
||||
"is_meme": False,
|
||||
"vp_data": None,
|
||||
"bb_data": None,
|
||||
"static_accumulation": {"static_count": 12, "vol_ratio": 1.4},
|
||||
"h4_df": None,
|
||||
}
|
||||
})
|
||||
|
||||
assert qualified["PNT/USDT"]["state"] == "加速"
|
||||
assert qualified["PNT/USDT"]["base_state"] == "蓄力"
|
||||
assert qualified["PNT/USDT"]["force_reason"] == "强静K蓄力直升加速"
|
||||
assert any("强静K蓄力直升加速" in s for s in qualified["PNT/USDT"]["signals"])
|
||||
assert created and created[0]["force_reason"] == "强静K蓄力直升加速"
|
||||
179
tests/test_signal_trust_stage1.py
Normal file
179
tests/test_signal_trust_stage1.py
Normal file
@ -0,0 +1,179 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
import altcoin_db
|
||||
|
||||
|
||||
class RecommendationSignalTrustTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmpdir.name, 'test_altcoin.db')
|
||||
self.db_patch = patch.object(altcoin_db, 'DB_PATH', self.db_path)
|
||||
self.db_patch.start()
|
||||
altcoin_db.init_db()
|
||||
|
||||
def tearDown(self):
|
||||
self.db_patch.stop()
|
||||
self.tmpdir.cleanup()
|
||||
|
||||
def _insert_rec(self, **kwargs):
|
||||
defaults = dict(
|
||||
symbol='ENS/USDT',
|
||||
rec_time='2026-05-09T20:00:00',
|
||||
rec_state='爆发',
|
||||
rec_score=72,
|
||||
entry_price=10.0,
|
||||
stop_loss=9.6,
|
||||
tp1=10.8,
|
||||
tp2=11.4,
|
||||
sector='',
|
||||
signals=json.dumps(['15m 入场窗口信号'], ensure_ascii=False),
|
||||
is_meme=0,
|
||||
status='active',
|
||||
current_price=10.0,
|
||||
max_price=10.0,
|
||||
min_price=10.0,
|
||||
pnl_pct=0.0,
|
||||
max_pnl_pct=0.0,
|
||||
max_drawdown_pct=0.0,
|
||||
hit_tp1_time='',
|
||||
hit_tp2_time='',
|
||||
stopped_out_time='',
|
||||
expired_time='',
|
||||
last_track_time='2026-05-09T20:00:00',
|
||||
entry_plan_json=json.dumps({
|
||||
'entry_price': 10.0,
|
||||
'entry_action': '可即刻买入',
|
||||
'risk_reward_ok': True,
|
||||
'rr1': 2.0,
|
||||
'stop_loss': 9.6,
|
||||
'tp1': 10.8,
|
||||
}, ensure_ascii=False),
|
||||
action_status='可即刻买入',
|
||||
direction='多头启动',
|
||||
strategy_version='v-test',
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cols = ','.join(defaults.keys())
|
||||
placeholders = ','.join(['?'] * len(defaults))
|
||||
cur = conn.execute(
|
||||
f"INSERT INTO recommendation ({cols}) VALUES ({placeholders})",
|
||||
tuple(defaults.values()),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return cur.lastrowid
|
||||
|
||||
def _row(self, rec_id):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
row = dict(conn.execute('SELECT * FROM recommendation WHERE id=?', (rec_id,)).fetchone())
|
||||
conn.close()
|
||||
return row
|
||||
|
||||
def test_entry_window_expires_after_two_hours_and_no_longer_shows_buy_now(self):
|
||||
rec_id = self._insert_rec(rec_time='2026-05-09T20:00:00', entry_price=10.0, current_price=10.02)
|
||||
|
||||
decision = altcoin_db.apply_recommendation_state_transition(
|
||||
rec_id,
|
||||
requested_action='可即刻买入',
|
||||
current_price=10.02,
|
||||
event_time='2026-05-09T22:01:00',
|
||||
)
|
||||
|
||||
self.assertEqual(decision['action_status'], '观察')
|
||||
self.assertEqual(decision['execution_status'], 'observe')
|
||||
self.assertFalse(decision['push_required'])
|
||||
self.assertEqual(decision['entry_window']['status'], 'expired')
|
||||
self.assertIn('超过有效期', decision['entry_window']['reason'])
|
||||
row = self._row(rec_id)
|
||||
self.assertEqual(row['action_status'], '观察')
|
||||
|
||||
def test_entry_window_invalidates_when_price_moves_too_far_above_entry(self):
|
||||
rec_id = self._insert_rec(rec_time='2026-05-09T20:00:00', entry_price=10.0, current_price=10.0)
|
||||
|
||||
decision = altcoin_db.apply_recommendation_state_transition(
|
||||
rec_id,
|
||||
requested_action='可即刻买入',
|
||||
current_price=10.16,
|
||||
event_time='2026-05-09T20:30:00',
|
||||
)
|
||||
|
||||
self.assertEqual(decision['action_status'], '等回踩')
|
||||
self.assertEqual(decision['execution_status'], 'wait_pullback')
|
||||
self.assertFalse(decision['push_required'])
|
||||
self.assertEqual(decision['entry_window']['status'], 'price_left_up')
|
||||
self.assertGreater(decision['entry_window']['deviation_pct'], 1.5)
|
||||
row = self._row(rec_id)
|
||||
self.assertEqual(row['action_status'], '等回踩')
|
||||
|
||||
def test_entry_window_invalidates_when_price_breaks_below_entry_tolerance(self):
|
||||
rec_id = self._insert_rec(rec_time='2026-05-09T20:00:00', entry_price=10.0, current_price=10.0)
|
||||
|
||||
decision = altcoin_db.apply_recommendation_state_transition(
|
||||
rec_id,
|
||||
requested_action='可即刻买入',
|
||||
current_price=9.87,
|
||||
event_time='2026-05-09T20:30:00',
|
||||
)
|
||||
|
||||
self.assertEqual(decision['action_status'], '观察')
|
||||
self.assertEqual(decision['execution_status'], 'observe')
|
||||
self.assertFalse(decision['push_required'])
|
||||
self.assertEqual(decision['entry_window']['status'], 'price_left_down')
|
||||
row = self._row(rec_id)
|
||||
self.assertEqual(row['action_status'], '观察')
|
||||
|
||||
def test_risk_suggestion_converts_stop_distance_to_position_size(self):
|
||||
self._insert_rec(
|
||||
rec_time=datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
entry_price=10.0,
|
||||
current_price=10.0,
|
||||
stop_loss=9.6,
|
||||
)
|
||||
|
||||
rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False, version='v-test')
|
||||
target = rows[0]
|
||||
|
||||
self.assertEqual(target['entry_window']['status'], 'active')
|
||||
self.assertEqual(target['risk_suggestion']['risk_budget_pct'], 1.0)
|
||||
self.assertAlmostEqual(target['risk_suggestion']['stop_distance_pct'], 4.0, places=2)
|
||||
self.assertAlmostEqual(target['risk_suggestion']['suggested_position_pct'], 25.0, places=2)
|
||||
self.assertAlmostEqual(target['risk_suggestion']['max_loss_pct'], 1.0, places=2)
|
||||
self.assertAlmostEqual(target['risk_suggestion']['tp1_profit_pct'], 8.0, places=2)
|
||||
|
||||
def test_dashboard_current_price_uses_latest_price_cache_without_changing_entry_reference(self):
|
||||
rec_id = self._insert_rec(
|
||||
rec_time=datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
entry_price=10.0,
|
||||
current_price=10.0,
|
||||
stop_loss=9.6,
|
||||
)
|
||||
altcoin_db.update_latest_price_cache(
|
||||
'ENS/USDT',
|
||||
10.12,
|
||||
updated_at=datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
|
||||
source='test',
|
||||
)
|
||||
|
||||
rows = altcoin_db.get_active_recommendations_deduped(actionable_only=False, version='v-test')
|
||||
target = rows[0]
|
||||
|
||||
self.assertAlmostEqual(target['entry_price'], 10.0, places=4)
|
||||
self.assertAlmostEqual(target['current_price'], 10.12, places=4)
|
||||
self.assertAlmostEqual(target['entry_window']['entry_price'], 10.0, places=4)
|
||||
self.assertAlmostEqual(target['entry_window']['current_price'], 10.12, places=4)
|
||||
self.assertAlmostEqual(target['entry_window']['deviation_pct'], 1.2, places=2)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
121
tests/test_strategy_iteration_logs.py
Normal file
121
tests/test_strategy_iteration_logs.py
Normal file
@ -0,0 +1,121 @@
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if PROJECT_DIR not in sys.path:
|
||||
sys.path.insert(0, PROJECT_DIR)
|
||||
|
||||
import altcoin_db
|
||||
import web_server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db(monkeypatch, tmp_path):
|
||||
db_path = tmp_path / "altcoin_monitor.db"
|
||||
monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path))
|
||||
monkeypatch.setattr(web_server, "init_db", altcoin_db.init_db)
|
||||
monkeypatch.setattr(web_server, "get_review_stats", altcoin_db.get_review_stats)
|
||||
monkeypatch.setattr(web_server, "get_stats", altcoin_db.get_stats)
|
||||
altcoin_db.init_db()
|
||||
return db_path
|
||||
|
||||
|
||||
def test_iteration_log_roundtrip_and_summary(temp_db):
|
||||
altcoin_db.log_strategy_iteration(
|
||||
run_date="2026-04-29",
|
||||
trigger_source="daily_review",
|
||||
title="缩小滞后指标权重",
|
||||
summary="复盘发现 MACD/RSI 追涨假信号偏多,降低滞后指标基础权重。",
|
||||
findings=[
|
||||
"过去 5 次失败案例里,4 次同时出现滞后指标共振但没有前瞻量价确认",
|
||||
"横盘样本集中在仅有 MACD/RSI 共振的票",
|
||||
],
|
||||
problems=[
|
||||
"滞后指标在启动末端给出追涨确认,导致入场过晚",
|
||||
],
|
||||
actions=[
|
||||
"category_base_weights.滞后: 0.5 → 0.3",
|
||||
"kill_min_samples: 5 → 4",
|
||||
],
|
||||
changed_rules=[
|
||||
{"field": "category_base_weights.滞后", "old": 0.5, "new": 0.3},
|
||||
{"field": "kill_min_samples", "old": 5, "new": 4},
|
||||
],
|
||||
metrics={"reviews_done": 6, "fail_count": 4, "new_rules": 1},
|
||||
related_symbols=["DOGE/USDT", "PEPE/USDT"],
|
||||
pollution_summary={
|
||||
"window_days": 7,
|
||||
"effective_start": "2026-04-22T00:00:00",
|
||||
"contaminated_symbol_count": 2,
|
||||
"screening_hit_count": 3,
|
||||
"recommendation_hit_count": 1,
|
||||
"contaminated_symbols": ["EUR/USDT", "USD1/USDT"],
|
||||
"layer_counts": {"coarse": 2, "fine": 1},
|
||||
},
|
||||
)
|
||||
altcoin_db.log_strategy_iteration(
|
||||
run_date="2026-04-29",
|
||||
trigger_source="manual",
|
||||
title="补充等待回踩过滤",
|
||||
summary="将追高型候选延后到回踩确认。",
|
||||
findings=["爆发前 1H 放量但偏离 5EMA 过远的票,回撤概率上升"],
|
||||
problems=["原有即刻买入条件对急拉币容忍度过高"],
|
||||
actions=["新增 wait_pullback 场景说明"],
|
||||
changed_rules=[],
|
||||
metrics={"affected_candidates": 3},
|
||||
related_symbols=["FIL/USDT"],
|
||||
)
|
||||
|
||||
logs = altcoin_db.get_strategy_iteration_logs(limit=10)
|
||||
assert len(logs) == 2
|
||||
assert logs[0]["title"] == "补充等待回踩过滤"
|
||||
assert logs[1]["changed_rules"][0]["field"] == "category_base_weights.滞后"
|
||||
assert logs[1]["metrics"]["reviews_done"] == 6
|
||||
assert logs[1]["pollution_summary"]["contaminated_symbol_count"] == 2
|
||||
assert logs[1]["pollution_summary"]["contaminated_symbols"] == ["EUR/USDT", "USD1/USDT"]
|
||||
|
||||
summary = altcoin_db.get_strategy_iteration_summary(days=30)
|
||||
assert summary["total_logs"] == 2
|
||||
assert summary["unique_run_days"] == 1
|
||||
assert summary["trigger_counts"]["daily_review"] == 1
|
||||
assert summary["trigger_counts"]["manual"] == 1
|
||||
assert summary["change_rule_count"] == 2
|
||||
assert summary["recent_titles"][0] == "补充等待回踩过滤"
|
||||
|
||||
|
||||
def test_review_api_exposes_iteration_logs(temp_db):
|
||||
altcoin_db.log_strategy_iteration(
|
||||
run_date="2026-04-29",
|
||||
trigger_source="daily_review",
|
||||
title="新增失败案例日志化",
|
||||
summary="把问题和动作拆开记录到网站。",
|
||||
findings=["用户需要直接看到每天改了什么"],
|
||||
problems=["之前只有复盘结果,没有策略修改过程记录"],
|
||||
actions=["新增 strategy_iteration_log 表与 review 页面展示"],
|
||||
changed_rules=[{"field": "ui.review.iteration_log", "old": False, "new": True}],
|
||||
metrics={"items": 1},
|
||||
related_symbols=["FIL/USDT"],
|
||||
pollution_summary={
|
||||
"window_days": 7,
|
||||
"effective_start": "2026-04-22T00:00:00",
|
||||
"contaminated_symbol_count": 1,
|
||||
"screening_hit_count": 1,
|
||||
"recommendation_hit_count": 0,
|
||||
"contaminated_symbols": ["EUR/USDT"],
|
||||
"layer_counts": {"coarse": 1},
|
||||
},
|
||||
)
|
||||
|
||||
client = TestClient(web_server.app)
|
||||
resp = client.get("/api/review")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["iteration_summary"]["total_logs"] == 1
|
||||
assert data["iteration_logs"][0]["title"] == "新增失败案例日志化"
|
||||
assert data["iteration_logs"][0]["changed_rules"][0]["field"] == "ui.review.iteration_log"
|
||||
assert data["iteration_logs"][0]["pollution_summary"]["contaminated_symbol_count"] == 1
|
||||
assert data["iteration_logs"][0]["pollution_summary"]["contaminated_symbols"] == ["EUR/USDT"]
|
||||
144
tests/test_strategy_revision_marker.py
Normal file
144
tests/test_strategy_revision_marker.py
Normal file
@ -0,0 +1,144 @@
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if PROJECT_DIR not in sys.path:
|
||||
sys.path.insert(0, PROJECT_DIR)
|
||||
|
||||
import altcoin_db
|
||||
import review_engine
|
||||
import config_loader
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_and_rules(monkeypatch, tmp_path):
|
||||
db_path = tmp_path / "altcoin_monitor.db"
|
||||
rules_path = tmp_path / "rules.yaml"
|
||||
|
||||
monkeypatch.setattr(altcoin_db, "DB_PATH", str(db_path))
|
||||
monkeypatch.setattr(config_loader, "RULES_PATH", str(rules_path))
|
||||
monkeypatch.setattr(review_engine, "get_conn", altcoin_db.get_conn)
|
||||
|
||||
config_loader._cache = None
|
||||
config_loader._cache_mtime = None
|
||||
|
||||
rules_path.write_text(
|
||||
"""
|
||||
strategy:
|
||||
mode: long_only
|
||||
direction: 多头启动
|
||||
allow_short: false
|
||||
screener: {}
|
||||
confirm: {}
|
||||
tracker: {}
|
||||
signal_weights: {}
|
||||
review:
|
||||
hit_threshold_pct: 5.0
|
||||
fail_threshold_pct: -3.0
|
||||
missed_explosion_pct: 20.0
|
||||
reverse_analysis: {}
|
||||
learned_rules: []
|
||||
meta:
|
||||
version: 1
|
||||
strategy_version: v_next
|
||||
strategy_revision_started_at: '2026-04-30T10:00:00'
|
||||
strategy_revision_note: '静K蓄力改版开始'
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
altcoin_db.init_db()
|
||||
return db_path, rules_path
|
||||
|
||||
|
||||
def _insert_recommendation(conn, rec_id, symbol, rec_time):
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO recommendation (
|
||||
id, symbol, rec_time, rec_state, rec_score, entry_price,
|
||||
stop_loss, tp1, tp2, sector, signals, is_meme, status,
|
||||
current_price, max_price, min_price, pnl_pct, max_pnl_pct,
|
||||
max_drawdown_pct, hit_tp1_time, hit_tp2_time, stopped_out_time,
|
||||
expired_time, last_track_time, entry_plan_json, action_status, direction
|
||||
) VALUES (?, ?, ?, '加速', 8, 1.0, 0, 0, 0, '', '[]', 0, 'active', 1.0, 1.0, 1.0, 0, 0, 0, '', '', '', '', ?, '{}', '持有', '多头启动')
|
||||
""",
|
||||
(rec_id, symbol, rec_time, rec_time),
|
||||
)
|
||||
|
||||
|
||||
def _insert_review(conn, rec_id, symbol, review_time, outcome, pnl_48h):
|
||||
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 (?, ?, ?, ?, ?, ?, '[]', '[]', '[]', '')
|
||||
""",
|
||||
(rec_id, symbol, review_time, outcome, pnl_48h, pnl_48h),
|
||||
)
|
||||
|
||||
|
||||
def _insert_missed(conn, symbol, detect_time, gain_pct):
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO missed_explosions (
|
||||
symbol, detect_time, price_at_detect, price_before, gain_pct,
|
||||
reason_missed, features_detected, lesson
|
||||
) VALUES (?, ?, 1.0, 0.8, ?, '粗筛未过', '[]', '')
|
||||
""",
|
||||
(symbol, detect_time, gain_pct),
|
||||
)
|
||||
|
||||
|
||||
def test_revision_marker_filters_effect_summary(temp_db_and_rules):
|
||||
conn = altcoin_db.get_conn()
|
||||
|
||||
_insert_recommendation(conn, 1, 'OLD/USDT', '2026-04-29T09:00:00')
|
||||
_insert_recommendation(conn, 2, 'NEW/USDT', '2026-04-30T11:00:00')
|
||||
_insert_review(conn, 1, 'OLD/USDT', '2026-04-29T12:00:00', '失败', -6.0)
|
||||
_insert_review(conn, 2, 'NEW/USDT', '2026-04-30T12:00:00', '爆发', 12.0)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
now = datetime.fromisoformat('2026-04-30T13:00:00')
|
||||
summary = review_engine._compute_effect_summary(now, lookback_days=7)
|
||||
|
||||
assert summary['review_count_window'] == 1
|
||||
assert summary['hit_rate_pct'] == 100.0
|
||||
assert summary['fail_rate_pct'] == 0.0
|
||||
assert summary['avg_pnl'] == 12.0
|
||||
|
||||
|
||||
def test_revision_marker_filters_reviewable_recommendations(temp_db_and_rules):
|
||||
conn = altcoin_db.get_conn()
|
||||
_insert_recommendation(conn, 1, 'OLD/USDT', '2026-04-29T09:00:00')
|
||||
_insert_recommendation(conn, 2, 'NEW/USDT', '2026-04-30T11:00:00')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
reviewable = review_engine._get_reviewable_recommendations(datetime.fromisoformat('2026-05-01T13:00:00'))
|
||||
symbols = [row['symbol'] for row in reviewable]
|
||||
assert symbols == ['NEW/USDT']
|
||||
|
||||
|
||||
def test_revision_marker_filters_review_stats_and_missed_explosions(temp_db_and_rules):
|
||||
conn = altcoin_db.get_conn()
|
||||
_insert_review(conn, 1, 'OLD/USDT', '2026-04-29T12:00:00', '失败', -6.0)
|
||||
_insert_review(conn, 2, 'NEW/USDT', '2026-04-30T12:00:00', '爆发', 12.0)
|
||||
_insert_missed(conn, 'OLDMISS/USDT', '2026-04-29T15:00:00', 35.0)
|
||||
_insert_missed(conn, 'NEWMISS/USDT', '2026-04-30T15:00:00', 28.0)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
stats = altcoin_db.get_review_stats()
|
||||
review_symbols = [item['symbol'] for item in stats['reviews']]
|
||||
missed_symbols = [item['symbol'] for item in stats['missed_explosions']]
|
||||
|
||||
# get_review_stats() 不再按 revision_started_at 过滤 review_log,
|
||||
# 页面展示需要累积全部复盘数据。revision marker 的过滤只在
|
||||
# review_engine._get_reviewable_recommendations() 中生效。
|
||||
assert set(review_symbols) == {'NEW/USDT', 'OLD/USDT'}
|
||||
assert set(missed_symbols) == {'NEWMISS/USDT', 'OLDMISS/USDT'}
|
||||
86
tests/test_strategy_version.py
Normal file
86
tests/test_strategy_version.py
Normal file
@ -0,0 +1,86 @@
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if PROJECT_DIR not in sys.path:
|
||||
sys.path.insert(0, PROJECT_DIR)
|
||||
|
||||
import altcoin_db
|
||||
|
||||
|
||||
def test_strategy_version_from_meta():
|
||||
original_get_meta = altcoin_db.get_meta
|
||||
try:
|
||||
altcoin_db.get_meta = lambda: {"strategy_version": "v2026.04.30-r1"}
|
||||
assert altcoin_db.get_meta().get("strategy_version") == "v2026.04.30-r1"
|
||||
finally:
|
||||
altcoin_db.get_meta = original_get_meta
|
||||
|
||||
|
||||
def test_create_recommendation_persists_strategy_version(monkeypatch):
|
||||
original_get_conn = altcoin_db.get_conn
|
||||
original_meta = altcoin_db.get_meta
|
||||
|
||||
class FakeCursor:
|
||||
lastrowid = 321
|
||||
|
||||
class FakeConn:
|
||||
def __init__(self):
|
||||
self.sql = None
|
||||
self.params = None
|
||||
self.committed = False
|
||||
self.closed = False
|
||||
|
||||
def execute(self, sql, params=()):
|
||||
self.sql = sql
|
||||
self.params = params
|
||||
return FakeCursor()
|
||||
|
||||
def commit(self):
|
||||
self.committed = True
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
fake_conn = FakeConn()
|
||||
monkeypatch.setattr(altcoin_db, "get_conn", lambda: fake_conn)
|
||||
monkeypatch.setattr(altcoin_db, "get_meta", lambda: {"strategy_version": "v2026.04.30-r2"})
|
||||
|
||||
rec_id = altcoin_db.create_recommendation(
|
||||
symbol="AI/USDT",
|
||||
rec_state="加速",
|
||||
rec_score=7,
|
||||
entry_price=1.23,
|
||||
stop_loss=1.1,
|
||||
tp1=1.4,
|
||||
tp2=1.6,
|
||||
sector="AI",
|
||||
signals=["量价齐飞"],
|
||||
is_meme=0,
|
||||
entry_plan={"entry_action": "可即刻买入"},
|
||||
direction="多头启动",
|
||||
)
|
||||
|
||||
assert rec_id == 321
|
||||
assert "strategy_version" in fake_conn.sql
|
||||
assert fake_conn.params[-1] == "v2026.04.30-r2"
|
||||
assert fake_conn.committed is True
|
||||
assert fake_conn.closed is True
|
||||
|
||||
altcoin_db.get_conn = original_get_conn
|
||||
altcoin_db.get_meta = original_meta
|
||||
|
||||
|
||||
def test_derive_execution_fields_exposes_strategy_version():
|
||||
item = {
|
||||
"status": "active",
|
||||
"action_status": "持有",
|
||||
"entry_plan_json": "{}",
|
||||
"strategy_version": "v2026.04.30-r3",
|
||||
}
|
||||
|
||||
result = altcoin_db._derive_execution_fields(item)
|
||||
|
||||
assert result["strategy_version"] == "v2026.04.30-r3"
|
||||
assert result["strategy_version_label"] == "策略版本 v2026.04.30-r3"
|
||||
49
tests/test_tracker_terminal_action_guard.py
Normal file
49
tests/test_tracker_terminal_action_guard.py
Normal file
@ -0,0 +1,49 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if PROJECT_DIR not in sys.path:
|
||||
sys.path.insert(0, PROJECT_DIR)
|
||||
|
||||
import altcoin_db
|
||||
import price_tracker_ws
|
||||
|
||||
|
||||
def test_terminal_recommendation_action_status_cannot_be_overwritten_by_entry_signal(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="NOT/USDT",
|
||||
rec_state="爆发",
|
||||
rec_score=10,
|
||||
entry_price=0.000619,
|
||||
stop_loss=0.000521,
|
||||
tp1=0.000684,
|
||||
tp2=0.000726,
|
||||
entry_plan={"entry_action": "等回踩", "entry_price": 0.000628},
|
||||
)
|
||||
altcoin_db.update_recommendation_tracking(rec_id, 0.000685)
|
||||
|
||||
# 即使后续动态入场逻辑误传“可即刻买入”,DB 层也必须保持止盈状态。
|
||||
altcoin_db.update_recommendation_action_status(rec_id, "可即刻买入")
|
||||
|
||||
rows = altcoin_db.get_all_recommendations(limit=10)
|
||||
rec = next(r for r in rows if r["id"] == rec_id)
|
||||
assert rec["status"] == "hit_tp1"
|
||||
assert rec["action_status"] == "止盈1"
|
||||
assert rec["execution_status"] == "completed"
|
||||
|
||||
|
||||
def test_ws_tracker_does_not_emit_entry_signal_for_closed_recommendation():
|
||||
rec = {
|
||||
"status": "hit_tp1",
|
||||
"entry_price": 0.000619,
|
||||
"stop_loss": 0.000521,
|
||||
"tp1": 0.000684,
|
||||
"tp2": 0.000726,
|
||||
"entry_plan": {"entry_action": "等回踩", "entry_price": 0.000628},
|
||||
"action_status": "止盈1",
|
||||
}
|
||||
assert price_tracker_ws.check_triggers("NOT/USDT", rec, 0.000628) is None
|
||||
231
tests/test_user_subscription_auth.py
Normal file
231
tests/test_user_subscription_auth.py
Normal file
@ -0,0 +1,231 @@
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if PROJECT_DIR not in sys.path:
|
||||
sys.path.insert(0, PROJECT_DIR)
|
||||
|
||||
import auth_db
|
||||
import web_server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_auth_db(monkeypatch, tmp_path):
|
||||
db_path = tmp_path / "auth.db"
|
||||
monkeypatch.setattr(auth_db, "DB_PATH", str(db_path))
|
||||
monkeypatch.setattr(web_server.auth_db, "DB_PATH", str(db_path))
|
||||
# 默认测试环境不配置 SMTP,注册接口返回 dev_verification_code 便于验证。
|
||||
for key in [
|
||||
"ASTOCK_SMTP_HOST", "ASTOCK_SMTP_PORT", "ASTOCK_SMTP_USERNAME",
|
||||
"ASTOCK_SMTP_PASSWORD", "ASTOCK_SMTP_SENDER",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
auth_db.init_auth_db()
|
||||
return db_path
|
||||
|
||||
|
||||
def test_register_creates_unverified_user_with_invite_code_and_email_verification(temp_auth_db):
|
||||
result = auth_db.register_user("alice@example.com", "StrongPass123")
|
||||
|
||||
assert result["email"] == "alice@example.com"
|
||||
assert result["email_verified"] is False
|
||||
assert len(result["invite_code"]) >= 8
|
||||
assert result["verification_code"] and len(result["verification_code"]) == 6
|
||||
|
||||
user = auth_db.get_user_by_email("alice@example.com")
|
||||
assert user["password_hash"] != "StrongPass123"
|
||||
assert user["status"] == "pending_email_verification"
|
||||
assert user["invited_by_user_id"] is None
|
||||
|
||||
|
||||
def test_register_with_invite_code_locks_inviter_relationship(temp_auth_db):
|
||||
inviter = auth_db.register_user("inviter@example.com", "StrongPass123")
|
||||
invited = auth_db.register_user("bob@example.com", "StrongPass123", invite_code=inviter["invite_code"])
|
||||
|
||||
user = auth_db.get_user_by_email("bob@example.com")
|
||||
assert user["invited_by_user_id"] == inviter["user_id"]
|
||||
assert invited["invited_by_user_id"] == inviter["user_id"]
|
||||
|
||||
|
||||
def test_invalid_invite_code_rejects_registration(temp_auth_db):
|
||||
with pytest.raises(auth_db.AuthError) as exc:
|
||||
auth_db.register_user("bad@example.com", "StrongPass123", invite_code="NO_SUCH_CODE")
|
||||
assert "邀请码无效" in str(exc.value)
|
||||
|
||||
|
||||
def test_verify_email_activates_user_and_login_requires_verified_email(temp_auth_db):
|
||||
reg = auth_db.register_user("alice@example.com", "StrongPass123")
|
||||
|
||||
with pytest.raises(auth_db.AuthError) as exc:
|
||||
auth_db.login_user("alice@example.com", "StrongPass123")
|
||||
assert "邮箱未验证" in str(exc.value)
|
||||
|
||||
verified = auth_db.verify_email("alice@example.com", reg["verification_code"])
|
||||
assert verified["email_verified"] is True
|
||||
assert verified["status"] == "active"
|
||||
|
||||
session = auth_db.login_user("alice@example.com", "StrongPass123")
|
||||
assert session["token"]
|
||||
assert session["user"]["email"] == "alice@example.com"
|
||||
|
||||
|
||||
def test_free_trial_subscription_can_only_be_claimed_once(temp_auth_db):
|
||||
reg = auth_db.register_user("alice@example.com", "StrongPass123")
|
||||
auth_db.verify_email("alice@example.com", reg["verification_code"])
|
||||
user = auth_db.get_user_by_email("alice@example.com")
|
||||
|
||||
sub = auth_db.claim_free_trial(user["id"])
|
||||
assert sub["plan_code"] == "free_trial_1m"
|
||||
assert sub["status"] == "active"
|
||||
assert sub["source"] == "free_trial"
|
||||
assert auth_db.get_user_by_email("alice@example.com")["free_trial_claimed"] == 1
|
||||
|
||||
with pytest.raises(auth_db.AuthError) as exc:
|
||||
auth_db.claim_free_trial(user["id"])
|
||||
assert "只能领取一次" in str(exc.value)
|
||||
|
||||
|
||||
def test_subscription_tables_reserve_paid_usdt_order_schema(temp_auth_db):
|
||||
cols = auth_db.get_table_columns("payment_order")
|
||||
for required in ["user_id", "plan_code", "amount_usdt", "chain", "pay_address", "txid", "status"]:
|
||||
assert required in cols
|
||||
|
||||
|
||||
def test_auth_api_register_verify_login_and_free_trial(temp_auth_db):
|
||||
client = TestClient(web_server.app)
|
||||
|
||||
r = client.post("/api/auth/register", json={"email": "alice@example.com", "password": "StrongPass123"})
|
||||
assert r.status_code == 200
|
||||
payload = r.json()
|
||||
assert payload["ok"] is True
|
||||
assert payload["user"]["email_verified"] is False
|
||||
assert payload["dev_verification_code"] # SMTP 未配置前用于本地/测试验证;配置后隐藏
|
||||
|
||||
code = payload["dev_verification_code"]
|
||||
r = client.post("/api/auth/verify-email", json={"email": "alice@example.com", "code": code})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["user"]["email_verified"] is True
|
||||
|
||||
r = client.post("/api/auth/login", json={"email": "alice@example.com", "password": "StrongPass123"})
|
||||
assert r.status_code == 200
|
||||
token = r.cookies.get("altcoin_session")
|
||||
assert token
|
||||
|
||||
r = client.post("/api/subscriptions/free-trial", cookies={"altcoin_session": token})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["subscription"]["plan_code"] == "free_trial_1m"
|
||||
|
||||
r = client.post("/api/subscriptions/free-trial", cookies={"altcoin_session": token})
|
||||
assert r.status_code == 400
|
||||
assert "只能领取一次" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_register_sends_email_and_hides_dev_code_when_smtp_configured(temp_auth_db, monkeypatch):
|
||||
sent = []
|
||||
monkeypatch.setenv("ASTOCK_SMTP_HOST", "smtp.example.com")
|
||||
monkeypatch.setenv("ASTOCK_SMTP_PORT", "465")
|
||||
monkeypatch.setenv("ASTOCK_SMTP_USERNAME", "noreply@example.com")
|
||||
monkeypatch.setenv("ASTOCK_SMTP_PASSWORD", "secret")
|
||||
monkeypatch.setenv("ASTOCK_SMTP_SENDER", "noreply@example.com")
|
||||
|
||||
def fake_send(to_email, code):
|
||||
sent.append((to_email, code))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(auth_db, "send_verification_email", fake_send)
|
||||
client = TestClient(web_server.app)
|
||||
|
||||
r = client.post("/api/auth/register", json={"email": "mail@example.com", "password": "StrongPass123"})
|
||||
assert r.status_code == 200
|
||||
payload = r.json()
|
||||
assert payload["dev_verification_code"] is None
|
||||
assert payload["email_sent"] is True
|
||||
assert sent and sent[0][0] == "mail@example.com"
|
||||
|
||||
|
||||
def test_resend_verification_code_has_rate_limit(temp_auth_db, monkeypatch):
|
||||
sent = []
|
||||
monkeypatch.setattr(auth_db, "send_verification_email", lambda email, code: sent.append((email, code)) or True)
|
||||
reg = auth_db.register_user("alice@example.com", "StrongPass123")
|
||||
|
||||
# 注册验证码也视为一次发送;需要过冷却时间后才允许重发。
|
||||
with pytest.raises(auth_db.AuthError) as exc:
|
||||
auth_db.resend_verification_code("alice@example.com")
|
||||
assert "请稍后再试" in str(exc.value)
|
||||
|
||||
conn = auth_db.get_conn()
|
||||
old_time = (datetime.now() - timedelta(seconds=auth_db.RESEND_COOLDOWN_SECONDS + 5)).isoformat(timespec="seconds")
|
||||
conn.execute("UPDATE email_verification_code SET created_at=? WHERE email=?", (old_time, "alice@example.com"))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
resent = auth_db.resend_verification_code("alice@example.com")
|
||||
assert resent["email"] == "alice@example.com"
|
||||
assert resent["verification_code"] != reg["verification_code"]
|
||||
|
||||
|
||||
def test_auth_page_hides_internal_requirements_and_has_modern_member_copy(temp_auth_db):
|
||||
client = TestClient(web_server.app)
|
||||
r = client.get("/auth")
|
||||
assert r.status_code == 200
|
||||
html = r.text
|
||||
|
||||
for forbidden in [
|
||||
"先把会员系统搭起来",
|
||||
"第一阶段支持",
|
||||
"后续用于会员天数奖励或返佣统计",
|
||||
"USDT 订阅已预留表结构",
|
||||
"SMTP 未配置",
|
||||
"提示词",
|
||||
"领取免费体验 1 个月",
|
||||
"claimTrial()",
|
||||
"/api/subscriptions/free-trial",
|
||||
]:
|
||||
assert forbidden not in html
|
||||
|
||||
for expected in [
|
||||
"提前发现机会,别在强信号后追高",
|
||||
"登录或开启免费体验",
|
||||
"创建账号",
|
||||
"会员登录",
|
||||
"前往订阅中心",
|
||||
"AI Opportunity Radar",
|
||||
]:
|
||||
assert expected in html
|
||||
|
||||
|
||||
def test_subscription_page_owns_trial_and_plan_flow(temp_auth_db):
|
||||
client = TestClient(web_server.app)
|
||||
r = client.get("/subscription")
|
||||
assert r.status_code == 200
|
||||
html = r.text
|
||||
|
||||
for expected in [
|
||||
"订阅中心",
|
||||
"免费体验 1 个月",
|
||||
"月付",
|
||||
"/api/subscriptions/free-trial",
|
||||
]:
|
||||
assert expected in html
|
||||
|
||||
assert "先把会员系统搭起来" not in html
|
||||
assert "USDT 订阅已预留表结构" not in html
|
||||
|
||||
|
||||
def test_app_shell_returns_200_for_all_users(temp_auth_db):
|
||||
"""v2: /app 是纯壳页,不校验登录/订阅(鉴权由 JS 调用 /api/auth/me 完成)"""
|
||||
client = TestClient(web_server.app)
|
||||
# 未登录也能拿到壳页(JS自己判断跳转)
|
||||
assert client.get("/app").status_code == 200
|
||||
assert "Omnix" in client.get("/app").text
|
||||
|
||||
# 登录用户也一样
|
||||
reg = auth_db.register_user("alice@example.com", "StrongPass123")
|
||||
auth_db.verify_email("alice@example.com", reg["verification_code"])
|
||||
login = auth_db.login_user("alice@example.com", "StrongPass123")
|
||||
token = login["token"]
|
||||
assert client.get("/app", cookies={"altcoin_session": token}).status_code == 200
|
||||
50
tests/test_vp_fly_recency.py
Normal file
50
tests/test_vp_fly_recency.py
Normal file
@ -0,0 +1,50 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from altcoin_screener import detect_volume_price_fly
|
||||
from altcoin_confirm import detect_volume_price_fly_1h
|
||||
|
||||
|
||||
def _sample_df(stale_age_hours=9):
|
||||
rows = []
|
||||
base_time = pd.Timestamp('2026-05-10 00:00:00')
|
||||
# 12根 recent + 前20均量所需历史;默认低量小阳/小阴
|
||||
for i in range(32):
|
||||
rows.append({
|
||||
'time': base_time + pd.Timedelta(hours=i),
|
||||
'open': 1.0,
|
||||
'high': 1.01,
|
||||
'low': 0.99,
|
||||
'close': 1.002,
|
||||
'volume': 100.0,
|
||||
})
|
||||
# recent 中距离最新 stale_age_hours 的那根制造放量大阳线
|
||||
target_idx = len(rows) - 1 - stale_age_hours
|
||||
rows[target_idx].update({'open': 1.0, 'high': 1.3, 'low': 0.99, 'close': 1.25, 'volume': 2000.0})
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def test_stale_1h_volume_price_fly_not_counted_as_current_signal():
|
||||
df = _sample_df(stale_age_hours=9)
|
||||
result = detect_volume_price_fly(df)
|
||||
assert result['vp_fly_count'] == 0
|
||||
assert result['stale_vp_fly_count'] >= 1
|
||||
assert result['stale_vp_fly_details'][0]['age_hours'] == 9
|
||||
|
||||
|
||||
def test_confirm_layer_stale_volume_price_fly_not_confirmed():
|
||||
df = _sample_df(stale_age_hours=9)
|
||||
result = detect_volume_price_fly_1h(df)
|
||||
assert result['vp_fly_count'] == 0
|
||||
assert result['stale_vp_fly_count'] >= 1
|
||||
|
||||
|
||||
def test_recent_1h_volume_price_fly_is_counted():
|
||||
df = _sample_df(stale_age_hours=1)
|
||||
result = detect_volume_price_fly(df)
|
||||
assert result['vp_fly_count'] == 1
|
||||
assert result['vp_fly_details'][0]['age_hours'] == 1
|
||||
45
validate_params.py
Normal file
45
validate_params.py
Normal file
@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""参数变更审计:检测 rules.yaml 是否被意外修改"""
|
||||
import hashlib, yaml, sys
|
||||
from pathlib import Path
|
||||
|
||||
RULES_PATH = Path(__file__).parent / 'rules.yaml'
|
||||
|
||||
def compute_semantic_hash(rules_dict):
|
||||
"""语义哈希:忽略格式差异,只对关键参数字段做哈希"""
|
||||
import json
|
||||
# 只取影响策略行为的字段,排除meta、注释等
|
||||
critical = {
|
||||
'confirm': rules_dict.get('confirm', {}),
|
||||
'screener': rules_dict.get('screener', {}),
|
||||
'pa_engine': rules_dict.get('pa_engine', {}),
|
||||
'signal_weights': rules_dict.get('signal_weights', {}),
|
||||
'tracker': rules_dict.get('tracker', {}),
|
||||
'sentiment': rules_dict.get('sentiment', {}),
|
||||
'event_driven': rules_dict.get('event_driven', {}),
|
||||
'learned_rules': rules_dict.get('learned_rules', []),
|
||||
}
|
||||
canonical = json.dumps(critical, sort_keys=True, default=str)
|
||||
return hashlib.sha256(canonical.encode()).hexdigest()[:16]
|
||||
|
||||
def main():
|
||||
raw = RULES_PATH.read_bytes()
|
||||
rules = yaml.safe_load(raw)
|
||||
|
||||
current_hash = compute_semantic_hash(rules)
|
||||
stored_hash = rules.get('meta', {}).get('rules_checksum', '')
|
||||
|
||||
if not stored_hash:
|
||||
print("⚠️ rules.yaml 缺少校验和 — 请在meta.rules_checksum写入当前hash")
|
||||
print(f" 当前: {current_hash}")
|
||||
return 1
|
||||
|
||||
if current_hash != stored_hash:
|
||||
print(f"🔴 参数被修改! 存储校验: {stored_hash}, 当前: {current_hash}")
|
||||
return 2
|
||||
|
||||
print(f"✅ 参数校验通过 (hash={current_hash})")
|
||||
return 0
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
330
web/index.html
Normal file
330
web/index.html
Normal file
@ -0,0 +1,330 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>山寨币爆发监控</title>
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { background:#0a0a0f; color:#e0e0e0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; min-height:100vh; }
|
||||
|
||||
/* 顶部导航 */
|
||||
.nav { background:#141420; padding:12px 16px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid #2a2a3a; position:sticky; top:0; z-index:100; }
|
||||
.nav h1 { font-size:18px; color:#fff; }
|
||||
.nav .refresh-btn { background:none; border:1px solid #4a4a5a; color:#aaa; padding:6px 12px; border-radius:6px; font-size:13px; cursor:pointer; }
|
||||
.nav .refresh-btn:active { background:#2a2a3a; }
|
||||
|
||||
/* 统计卡片 */
|
||||
.stats-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:8px; padding:12px 16px; }
|
||||
.stat-card { background:#141420; border-radius:10px; padding:12px; text-align:center; }
|
||||
.stat-card .label { font-size:11px; color:#888; margin-bottom:4px; }
|
||||
.stat-card .value { font-size:20px; font-weight:bold; }
|
||||
.stat-card .value.green { color:#00d4aa; }
|
||||
.stat-card .value.red { color:#ff4444; }
|
||||
.stat-card .value.yellow { color:#ffaa00; }
|
||||
.stat-card .value.blue { color:#4488ff; }
|
||||
|
||||
/* Tab切换 */
|
||||
.tabs { display:flex; padding:0 16px; background:#141420; border-bottom:1px solid #2a2a3a; }
|
||||
.tab { padding:10px 16px; font-size:14px; color:#888; cursor:pointer; border-bottom:2px solid transparent; flex:1; text-align:center; }
|
||||
.tab.active { color:#fff; border-bottom:2px solid #4488ff; }
|
||||
|
||||
/* 内容区 */
|
||||
.content { padding:12px 16px; }
|
||||
.section-title { font-size:14px; color:#888; margin:12px 0 8px; padding-left:4px; }
|
||||
|
||||
/* 推荐卡片 */
|
||||
.rec-card { background:#1a1a28; border-radius:12px; padding:14px; margin-bottom:10px; border-left:4px solid; }
|
||||
.rec-card.burst { border-left-color:#ff4444; }
|
||||
.rec-card.accel { border-left-color:#ffaa00; }
|
||||
.rec-card.gather { border-left-color:#4488ff; }
|
||||
.rec-card.closed-profit { border-left-color:#00d4aa; }
|
||||
.rec-card.closed-loss { border-left-color:#ff4444; }
|
||||
.rec-card.closed-expired { border-left-color:#666; }
|
||||
|
||||
.rec-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; }
|
||||
.rec-symbol { font-size:16px; font-weight:bold; color:#fff; }
|
||||
.rec-state { font-size:12px; padding:3px 8px; border-radius:4px; font-weight:bold; }
|
||||
.rec-state.burst { background:#ff4444; color:#fff; }
|
||||
.rec-state.accel { background:#ffaa00; color:#000; }
|
||||
.rec-state.gather { background:#4488ff; color:#fff; }
|
||||
.rec-state.closed-profit { background:#00d4aa; color:#000; }
|
||||
.rec-state.closed-loss { background:#ff4444; color:#fff; }
|
||||
.rec-state.closed-expired { background:#666; color:#fff; }
|
||||
.rec-state.tp1_hit { background:#00d4aa; color:#000; }
|
||||
|
||||
.rec-price { font-size:14px; margin-bottom:6px; }
|
||||
.rec-price .current { color:#fff; font-weight:bold; }
|
||||
.rec-price .change { font-size:13px; }
|
||||
.rec-price .change.pos { color:#00d4aa; }
|
||||
.rec-price .change.neg { color:#ff4444; }
|
||||
|
||||
.rec-details { font-size:12px; color:#888; line-height:1.6; }
|
||||
.rec-details span { color:#aaa; }
|
||||
.rec-signals { margin-top:6px; }
|
||||
.rec-signal { display:inline-block; background:#2a2a3a; padding:2px 8px; border-radius:4px; font-size:11px; color:#ccc; margin:2px 2px; }
|
||||
|
||||
/* 入场方案卡片 */
|
||||
.entry-card { background:#1e1e2e; border-radius:8px; padding:10px; margin-top:8px; }
|
||||
.entry-title { font-size:12px; color:#4488ff; margin-bottom:4px; }
|
||||
.entry-row { display:flex; justify-content:space-between; font-size:12px; padding:2px 0; }
|
||||
.entry-row .key { color:#888; }
|
||||
.entry-row .val { color:#fff; }
|
||||
.entry-row .val.green { color:#00d4aa; }
|
||||
.entry-row .val.red { color:#ff4444; }
|
||||
|
||||
/* PnL条 */
|
||||
.pnl-bar { height:6px; border-radius:3px; background:#2a2a3a; margin-top:8px; position:relative; overflow:hidden; }
|
||||
.pnl-bar .fill { height:100%; border-radius:3px; }
|
||||
.pnl-bar .fill.green { background:#00d4aa; }
|
||||
.pnl-bar .fill.red { background:#ff4444; }
|
||||
|
||||
/* 空状态 */
|
||||
.empty { text-align:center; padding:40px; color:#666; font-size:14px; }
|
||||
|
||||
/* 最后更新时间 */
|
||||
.last-update { text-align:center; font-size:11px; color:#666; padding:8px 0 20px; }
|
||||
|
||||
/* 动画 */
|
||||
@keyframes fadeIn { from{opacity:0;transform:translateY(10px)} to{opacity:1;transform:translateY(0)} }
|
||||
.rec-card { animation: fadeIn 0.3s ease; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="nav">
|
||||
<h1>🚀 山寨币监控</h1>
|
||||
<button class="refresh-btn" onclick="loadData()">刷新</button>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid" id="stats-grid"></div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-tab="recommendations" onclick="switchTab('recommendations')">🔥 推荐</div>
|
||||
<div class="tab" data-tab="candidates" onclick="switchTab('candidates')">📋 候选</div>
|
||||
<div class="tab" data-tab="history" onclick="switchTab('history')">📜 历史</div>
|
||||
</div>
|
||||
|
||||
<div class="content" id="tab-content"></div>
|
||||
|
||||
<div class="last-update" id="last-update"></div>
|
||||
|
||||
<script>
|
||||
let currentTab = 'recommendations';
|
||||
let dashboardData = null;
|
||||
|
||||
// 自动刷新
|
||||
setInterval(loadData, 60000); // 1分钟刷新
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const resp = await fetch('/api/dashboard');
|
||||
dashboardData = await resp.json();
|
||||
renderStats();
|
||||
renderTab();
|
||||
const t = dashboardData.latest_scan_time || '--';
|
||||
document.getElementById('last-update').textContent = '最后更新: ' + formatTime(t);
|
||||
} catch(e) {
|
||||
console.error('加载失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
const s = dashboardData.stats || {};
|
||||
const total = s.total_recs || 0;
|
||||
const wins = s.wins || 0;
|
||||
const losses = s.losses || 0;
|
||||
const active = s.active || 0;
|
||||
const avgPnl = s.avg_pnl || 0;
|
||||
const winRate = total > 0 ? Math.round(wins/(wins+losses)*100) : 0;
|
||||
|
||||
document.getElementById('stats-grid').innerHTML = `
|
||||
<div class="stat-card"><div class="label">活跃推荐</div><div class="value blue">${active}</div></div>
|
||||
<div class="stat-card"><div class="label">胜率</div><div class="value ${winRate>=50?'green':'red'}">${wins+losses>0?winRate+'%':'--'}</div></div>
|
||||
<div class="stat-card"><div class="label">平均盈亏</div><div class="value ${avgPnl>=0?'green':'red'}">${avgPnl>0?'+'+avgPnl:avgPnl}%</div></div>
|
||||
<div class="stat-card"><div class="label">总推荐数</div><div class="value yellow">${total}</div></div>
|
||||
`;
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
currentTab = tab;
|
||||
document.querySelectorAll('.tab').forEach(t => {
|
||||
t.classList.toggle('active', t.dataset.tab === tab);
|
||||
});
|
||||
renderTab();
|
||||
}
|
||||
|
||||
function renderTab() {
|
||||
const container = document.getElementById('tab-content');
|
||||
if (!dashboardData) { container.innerHTML = '<div class="empty">加载中...</div>'; return; }
|
||||
|
||||
if (currentTab === 'recommendations') {
|
||||
renderRecommendations(container);
|
||||
} else if (currentTab === 'candidates') {
|
||||
renderCandidates(container);
|
||||
} else if (currentTab === 'history') {
|
||||
renderHistory(container);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRecommendations(container) {
|
||||
const active = dashboardData.active_recommendations || [];
|
||||
const closed = dashboardData.closed_recommendations || [];
|
||||
|
||||
if (active.length === 0 && closed.length === 0) {
|
||||
container.innerHTML = '<div class="empty">暂无推荐 — 等待爆发信号确认</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
if (active.length > 0) {
|
||||
html += '<div class="section-title">🔥 活跃推荐</div>';
|
||||
active.forEach(r => { html += renderRecCard(r); });
|
||||
}
|
||||
if (closed.length > 0) {
|
||||
html += '<div class="section-title">📜 已关闭</div>';
|
||||
closed.forEach(r => { html += renderRecCard(r); });
|
||||
}
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderCandidates(container) {
|
||||
const cands = dashboardData.latest_candidates || [];
|
||||
if (cands.length === 0) {
|
||||
container.innerHTML = '<div class="empty">暂无候选 — 等待下次筛选</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="section-title">最近筛选结果 (' + formatTime(dashboardData.latest_scan_time) + ')</div>';
|
||||
cands.forEach(c => {
|
||||
const stateClass = c.state === '加速' ? 'accel' : 'gather';
|
||||
const stateTag = c.state === '加速' ? '🔥🔥加速' : '🔥蓄力';
|
||||
const tagClass = c.state === '加速' ? 'accel' : 'gather';
|
||||
const signals = (c.signals||'').split(',').filter(Boolean);
|
||||
const isMeme = c.is_meme ? ' 🎮MEME' : '';
|
||||
const change24h = c.change_24h || 0;
|
||||
const changeClass = change24h >= 0 ? 'pos' : 'neg';
|
||||
const changeSign = change24h >= 0 ? '+' : '';
|
||||
|
||||
html += `<div class="rec-card ${stateClass}">
|
||||
<div class="rec-header">
|
||||
<div class="rec-symbol">${c.symbol.replace('/USDT','')}${isMeme}</div>
|
||||
<div class="rec-state ${tagClass}">${stateTag} 评分${c.score}</div>
|
||||
</div>
|
||||
<div class="rec-price">
|
||||
<span class="current">$${(c.price_at_scan||0).toFixed(4)}</span>
|
||||
<span class="change ${changeClass}">${changeSign}${change24h.toFixed(1)}%/24h</span>
|
||||
</div>
|
||||
<div class="rec-details">
|
||||
${c.sector?'<span>板块:'+c.sector+'</span>':''}
|
||||
${c.leader_status?'<span>'+c.leader_status+'</span>':''}
|
||||
</div>
|
||||
<div class="rec-signals">${signals.map(s=>'<span class="rec-signal">'+s+'</span>').join('')}</div>
|
||||
</div>`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderHistory(container) {
|
||||
const scans = dashboardData.scan_stats || [];
|
||||
if (scans.length === 0) {
|
||||
container.innerHTML = '<div class="empty">暂无筛选历史</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="section-title">筛选历史 (最近24h)</div>';
|
||||
scans.forEach(s => {
|
||||
html += `<div class="rec-card gather" style="border-left-color:#4488ff">
|
||||
<div class="rec-header">
|
||||
<div class="rec-symbol" style="font-size:14px">${formatTime(s.scan_time)}</div>
|
||||
<div class="rec-state gather">${s.count}个候选</div>
|
||||
</div>
|
||||
<div class="rec-details">
|
||||
<span>加速:${s.accelerating||0}</span> | <span>蓄力:${s.gathering||0}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderRecCard(r) {
|
||||
const status = r.status || 'active';
|
||||
let cardClass = 'gather';
|
||||
let stateTag = '🔥蓄力';
|
||||
let tagClass = 'gather';
|
||||
|
||||
if (status === 'active') { cardClass = 'accel'; stateTag = '🔥🔥加速'; tagClass = 'accel'; }
|
||||
else if (status === 'tp1_hit') { cardClass = 'burst'; stateTag = '✅TP1触发'; tagClass = 'tp1_hit'; }
|
||||
else if (status === 'closed_profit') { cardClass = 'closed-profit'; stateTag = '✅盈利'; tagClass = 'closed-profit'; }
|
||||
else if (status === 'closed_loss') { cardClass = 'closed-loss'; stateTag = '❌止损'; tagClass = 'closed-loss'; }
|
||||
else if (status === 'closed_expired') { cardClass = 'closed-expired'; stateTag = '⏰过期'; tagClass = 'closed-expired'; }
|
||||
|
||||
const pnl = r.pnl_pct || 0;
|
||||
const pnlClass = pnl >= 0 ? 'green' : 'red';
|
||||
const pnlSign = pnl >= 0 ? '+' : '';
|
||||
const currentPrice = r.current_price || r.recommended_price || 0;
|
||||
const maxProfit = r.max_profit_pct || 0;
|
||||
const maxLoss = r.max_loss_pct || 0;
|
||||
|
||||
const signals = (r.signals||'').split(',').filter(Boolean);
|
||||
|
||||
let entryHtml = '';
|
||||
if (r.entry_price > 0 && (status === 'active' || status === 'tp1_hit')) {
|
||||
const rr1 = r.rr1 || 0;
|
||||
const rr2 = r.rr2 || 0;
|
||||
entryHtml = `<div class="entry-card">
|
||||
<div class="entry-title">📊 入场方案</div>
|
||||
<div class="entry-row"><span class="key">入场</span><span class="val">$${r.entry_price.toFixed(4)}</span></div>
|
||||
<div class="entry-row"><span class="key">止损</span><span class="val red">$${r.stop_loss.toFixed(4)} (${r.stop_pct}%)</span></div>
|
||||
<div class="entry-row"><span class="key">TP1</span><span class="val green">$${r.tp1.toFixed(4)} (RR=${rr1})</span></div>
|
||||
<div class="entry-row"><span class="key">TP2</span><span class="val green">$${r.tp2.toFixed(4)} (RR=${rr2})</span></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// PnL条
|
||||
let pnlBarHtml = '';
|
||||
if (r.recommended_price > 0) {
|
||||
const pnlAbs = Math.abs(pnl);
|
||||
const barWidth = Math.min(pnlAbs * 5, 100); // 1% = 5px宽,max 100%
|
||||
pnlBarHtml = `<div class="pnl-bar"><div class="fill ${pnlClass}" style="width:${barWidth}%"></div></div>`;
|
||||
}
|
||||
|
||||
const closeInfo = r.close_reason ? `<div class="rec-details" style="margin-top:4px"><span>${r.close_reason}</span></div>` : '';
|
||||
|
||||
return `<div class="rec-card ${cardClass}">
|
||||
<div class="rec-header">
|
||||
<div class="rec-symbol">${r.symbol.replace('/USDT','')}</div>
|
||||
<div class="rec-state ${tagClass}">${stateTag}</div>
|
||||
</div>
|
||||
<div class="rec-price">
|
||||
<span class="current">$${currentPrice.toFixed(4)}</span>
|
||||
<span class="change ${pnlClass}">${pnlSign}${pnl.toFixed(2)}%</span>
|
||||
</div>
|
||||
<div class="rec-details">
|
||||
推荐价: $${(r.recommended_price||0).toFixed(4)} | 最高: +${maxProfit.toFixed(2)}% | 最低: ${maxLoss.toFixed(2)}%
|
||||
${r.sector?' | 板块:'+r.sector:''}
|
||||
</div>
|
||||
${closeInfo}
|
||||
<div class="rec-signals">${signals.map(s=>'<span class="rec-signal">'+s+'</span>').join('')}</div>
|
||||
${entryHtml}
|
||||
${pnlBarHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function formatTime(t) {
|
||||
if (!t || t === '--') return '--';
|
||||
try {
|
||||
const d = new Date(t);
|
||||
const month = (d.getMonth()+1).toString().padStart(2,'0');
|
||||
const day = d.getDate().toString().padStart(2,'0');
|
||||
const hour = d.getHours().toString().padStart(2,'0');
|
||||
const min = d.getMinutes().toString().padStart(2,'0');
|
||||
return `${month}-${day} ${hour}:${min}`;
|
||||
} catch(e) { return t; }
|
||||
}
|
||||
|
||||
// 首次加载
|
||||
loadData();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
4250
web_server.py
Normal file
4250
web_server.py
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user