This commit is contained in:
aaron 2026-05-13 22:49:47 +08:00
parent d46f3e9801
commit c9f9d1b2a0
67 changed files with 370 additions and 1262 deletions

2
.gitignore vendored
View File

@ -64,3 +64,5 @@ dist/
archive/ archive/
backups/ backups/
tmp/ tmp/
legacy/scratch/
reports/*.json

0
=
View File

105
AGENTS.md
View File

@ -11,7 +11,7 @@ AlphaX 是一个以 `Python + FastAPI + SQLite + 静态 HTML` 组成的加密市
- 后端:`FastAPI`, `uvicorn`, `pydantic` - 后端:`FastAPI`, `uvicorn`, `pydantic`
- 数据与计算:`sqlite3`, `pandas`, `numpy` - 数据与计算:`sqlite3`, `pandas`, `numpy`
- 交易所/行情:`ccxt`, `requests` - 交易所/行情:`ccxt`, `requests`
- 配置:`rules.yaml` + `config_loader.py` - 配置:`rules.yaml` + `app/config/config_loader.py`
- 测试:`pytest` / `unittest` - 测试:`pytest` / `unittest`
- 部署:`Dockerfile`, `docker-compose.yml` - 部署:`Dockerfile`, `docker-compose.yml`
- 前端:`static/*.html` 模板页,由 FastAPI/Jinja2 提供页面壳和 API - 前端:`static/*.html` 模板页,由 FastAPI/Jinja2 提供页面壳和 API
@ -22,28 +22,28 @@ AlphaX 是一个以 `Python + FastAPI + SQLite + 静态 HTML` 组成的加密市
建议把系统理解为 6 个层次: 建议把系统理解为 6 个层次:
1. `altcoin_screener.py` 1. `app/services/altcoin_screener.py`
负责粗筛,基于 Binance 行情、量价/结构等规则找候选币。 负责粗筛,基于 Binance 行情、量价/结构等规则找候选币。
2. `altcoin_confirm.py` 2. `app/services/altcoin_confirm.py`
负责确认,判断是否形成更可执行的机会,并生成入场计划、上下文和推送候选。 负责确认,判断是否形成更可执行的机会,并生成入场计划、上下文和推送候选。
3. `price_tracker.py` 3. `app/services/price_tracker.py`
负责跟踪活跃推荐,更新盈亏、止盈止损、趋势衰减、行动状态。 负责跟踪活跃推荐,更新盈亏、止盈止损、趋势衰减、行动状态。
4. `review_engine.py` 4. `app/services/review_engine.py`
负责复盘与策略自迭代,包括信号绩效、漏选复盘、规则候选、版本演进。 负责复盘与策略自迭代,包括信号绩效、漏选复盘、规则候选、版本演进。
5. `event_driven_screener.py` 5. `app/services/event_driven_screener.py`
负责事件/舆情驱动的快速触发检查,属于技术筛选主链路的补充入口。 负责事件/舆情驱动的快速触发检查,属于技术筛选主链路的补充入口。
6. `web_server.py` 6. `app/web/web_server.py`
负责用户端和管理端 API、页面壳、订阅与认证相关接口。 负责用户端和管理端 API、页面壳、订阅与认证相关接口。
### 3.2 数据与状态中心 ### 3.2 数据与状态中心
- `altcoin_db.py` 是交易/推荐/状态的核心数据库层,体量很大,承担了: - `app/db/altcoin_db.py` 是交易/推荐/状态的核心数据库层,体量很大,承担了:
- 初始化表结构 - 初始化表结构
- recommendation / screening_log / tracking / review 等主表读写 - recommendation / screening_log / tracking / review 等主表读写
- 推荐状态派生与展示口径整理 - 推荐状态派生与展示口径整理
- 部分状态迁移与兼容逻辑 - 部分状态迁移与兼容逻辑
- `auth_db.py` 是会员、邀请码、邮箱验证、订阅、订单预留的数据库层。 - `app/db/auth_db.py` 是会员、邀请码、邮箱验证、订阅、订单预留的数据库层。
- `opportunity_lifecycle.py` 是机会生命周期和买点质量闸门的规则中心,决定: - `app/core/opportunity_lifecycle.py` 是机会生命周期和买点质量闸门的规则中心,决定:
- 哪些机会只是观察池 - 哪些机会只是观察池
- 哪些机会可以进入“可即刻买入” - 哪些机会可以进入“可即刻买入”
- 哪些状态应该被视为历史/盈利管理/观察态 - 哪些状态应该被视为历史/盈利管理/观察态
@ -51,18 +51,20 @@ AlphaX 是一个以 `Python + FastAPI + SQLite + 静态 HTML` 组成的加密市
### 3.3 配置中心 ### 3.3 配置中心
- `rules.yaml` 是策略配置单一事实源。 - `rules.yaml` 是策略配置单一事实源。
- `config_loader.py` 负责: - `app/config/config_loader.py` 负责:
- 读取/缓存配置 - 读取/缓存配置
- 暴露各子模块配置访问函数 - 暴露各子模块配置访问函数
- 将部分复盘后的参数改写回 `rules.yaml` - 将部分复盘后的参数改写回 `rules.yaml`
- 兼容旧信号名 - 兼容旧信号名
如果要改筛选阈值、确认门槛、止盈止损、动态权重逻辑,优先检查 `rules.yaml``config_loader.py`,不要直接在业务脚本里硬编码新参数。 如果要改筛选阈值、确认门槛、止盈止损、动态权重逻辑,优先检查 `rules.yaml``app/config/config_loader.py`,不要直接在业务脚本里硬编码新参数。
## 4. 目录速览 ## 4. 目录速览
### 4.1 核心目录 ### 4.1 核心目录
- `/app`
- 当前真实实现层,按职责拆成 `services`, `db`, `core`, `config`, `integrations`, `analysis`, `web`
- `/static` - `/static`
- 页面文件,如 `app.html`, `auth.html`, `subscription.html`, `strategy.html` - 页面文件,如 `app.html`, `auth.html`, `subscription.html`, `strategy.html`
- `/tests` - `/tests`
@ -71,6 +73,14 @@ AlphaX 是一个以 `Python + FastAPI + SQLite + 静态 HTML` 组成的加密市
- 若干结构/状态机/信号时效性校验脚本 - 若干结构/状态机/信号时效性校验脚本
- `/docker` - `/docker`
- 容器入口与串行调度器 - 容器入口与串行调度器
- `/tools`
- 非主链路工具脚本,如回测和输出摘要脚本
- `/templates`
- 被后端读取的 HTML 模板资源
- `/reports`
- 本地分析/回测结果产物
- `/legacy`
- 历史实验脚本、旧页面备份、临时整理归档
- `/data` - `/data`
- SQLite 数据库存放目录 - SQLite 数据库存放目录
- `/logs` - `/logs`
@ -80,23 +90,36 @@ AlphaX 是一个以 `Python + FastAPI + SQLite + 静态 HTML` 组成的加密市
### 4.2 根目录关键文件 ### 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) - [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) - [docker-compose.yml](/Users/aaron/Desktop/code/alphax-docker/docker-compose.yml)
- [README_DOCKER.md](/Users/aaron/Desktop/code/alphax-docker/README_DOCKER.md) - [README_DOCKER.md](/Users/aaron/Desktop/code/alphax-docker/README_DOCKER.md)
### 4.3 根目录保留原则
根目录应尽量只保留这些类型:
- 顶层配置
- Docker 入口与部署文件
- 项目说明文档
- 明确约定的非代码资产目录
Python 业务实现不应再直接留在根目录。
像下面这些内容应放在分层目录里:
- 服务流程:`/app/services`
- 数据访问:`/app/db`
- 领域与规则:`/app/core`
- 配置访问:`/app/config`
- Web 层:`/app/web`
- 第三方集成:`/app/integrations`
- 顶层配置
- Docker 入口
- 关键说明文档
## 5. Web/API 观察 ## 5. Web/API 观察
`web_server.py` 体量非常大,既包含: `app/web/web_server.py` 体量非常大,既包含:
- 认证接口 - 认证接口
- 订阅接口 - 订阅接口
@ -107,11 +130,11 @@ AlphaX 是一个以 `Python + FastAPI + SQLite + 静态 HTML` 组成的加密市
如果要改 Web 逻辑,先确认变更属于哪一类: 如果要改 Web 逻辑,先确认变更属于哪一类:
- “数据口径问题”优先去 `altcoin_db.py` / `opportunity_lifecycle.py` - “数据口径问题”优先去 `app/db/altcoin_db.py` / `app/core/opportunity_lifecycle.py`
- “参数问题”优先去 `rules.yaml` - “参数问题”优先去 `rules.yaml`
- “页面展示问题”再回到 `static/*.html` - “页面展示问题”再回到 `static/*.html`
不要第一反应直接在 `web_server.py` 里堆业务分支,否则会继续放大这个文件的复杂度。 不要第一反应直接在 `app/web/web_server.py` 里堆业务分支,否则会继续放大这个文件的复杂度。
## 6. 调度与运行方式 ## 6. 调度与运行方式
@ -135,16 +158,16 @@ AlphaX 是一个以 `Python + FastAPI + SQLite + 静态 HTML` 组成的加密市
### 6.2 入口 ### 6.2 入口
- `docker/entrypoint.sh` - `docker/entrypoint.sh`
- `web` -> 启动 uvicorn - `web` -> 启动 uvicorn (`app.web.web_server:app`)
- `scheduler` -> 启动 `docker/scheduler.py` - `scheduler` -> 启动 `docker/scheduler.py`
- `once` -> 执行单次脚本 - `once` -> 执行单次脚本
- `docker/scheduler.py` - `docker/scheduler.py`
- 统一调度 `event_driven_screener.py` - 统一调度 `app.services.event_driven_screener`
- `price_tracker.py` - `app.services.price_tracker`
- `altcoin_confirm.py` - `app.services.altcoin_confirm`
- `altcoin_screener.py` - `app.services.altcoin_screener`
- `sentiment_monitor.py` - `app.services.sentiment_monitor`
- `review_engine.py` - `app.services.review_engine`
## 7. 测试与验证建议 ## 7. 测试与验证建议
@ -184,10 +207,10 @@ python3 scripts/validate_signal_recency.py
### 8.1 改动前先判断“应该改哪一层” ### 8.1 改动前先判断“应该改哪一层”
- 调参数:优先 `rules.yaml` - 调参数:优先 `rules.yaml`
- 配置读取/兼容:`config_loader.py` - 配置读取/兼容:`app/config/config_loader.py`
- 状态口径:`opportunity_lifecycle.py` 或 `altcoin_db.py` - 状态口径:`app/core/opportunity_lifecycle.py` 或 `app/db/altcoin_db.py`
- DB 表结构/查询:`altcoin_db.py` / `auth_db.py` - DB 表结构/查询:`app/db/altcoin_db.py` / `app/db/auth_db.py`
- API 契约:`web_server.py` - API 契约:`app/web/web_server.py`
- 页面壳和交互:`static/*.html` - 页面壳和交互:`static/*.html`
### 8.2 SQLite 相关约束 ### 8.2 SQLite 相关约束
@ -220,8 +243,8 @@ python3 scripts/validate_signal_recency.py
这个仓库里有不少“迭代中兼容旧逻辑”的痕迹,例如: 这个仓库里有不少“迭代中兼容旧逻辑”的痕迹,例如:
- `config_loader.py` 中的信号别名兼容 - `app/config/config_loader.py` 中的信号别名兼容
- `altcoin_db.py` 中大量 `ALTER TABLE` 迁移兜底 - `app/db/altcoin_db.py` 中大量 `ALTER TABLE` 迁移兜底
- 多处 `entry_plan_json` / `detail_json` / `*_context_json` - 多处 `entry_plan_json` / `detail_json` / `*_context_json`
因此改动时应优先做增量兼容,而不是假设数据库、配置、旧数据永远是干净新鲜的。 因此改动时应优先做增量兼容,而不是假设数据库、配置、旧数据永远是干净新鲜的。
@ -231,14 +254,14 @@ python3 scripts/validate_signal_recency.py
- 当前目录 **不是 git 仓库根目录**`git status` 会失败;如果后续需要版本管理,请先确认真正的 Git 根目录或重新初始化。 - 当前目录 **不是 git 仓库根目录**`git status` 会失败;如果后续需要版本管理,请先确认真正的 Git 根目录或重新初始化。
- [DESIGN.md](/Users/aaron/Desktop/code/alphax-docker/DESIGN.md) 当前内容更像一份品牌/样式 YAML不是这个项目的系统设计文档阅读时不要误判。 - [DESIGN.md](/Users/aaron/Desktop/code/alphax-docker/DESIGN.md) 当前内容更像一份品牌/样式 YAML不是这个项目的系统设计文档阅读时不要误判。
- 根目录存在一些临时/非核心文件,例如 `.tmp_patch_tp1.py`、`.tmp_strategy_v2_marker.txt`、`=`,后续开发前建议先确认这些文件是否仍有保留价值。 - 根目录存在一些临时/非核心文件,例如 `.tmp_patch_tp1.py`、`.tmp_strategy_v2_marker.txt`、`=`,后续开发前建议先确认这些文件是否仍有保留价值。
- `web_server.py` 和 `altcoin_db.py` 都已经非常大,后续新增功能应尽量避免继续把复杂度集中到这两个文件。 - `app/web/web_server.py` 和 `app/db/altcoin_db.py` 都已经非常大,后续新增功能应尽量避免继续把复杂度集中到这两个文件。
## 10. 推荐的后续重构方向 ## 10. 推荐的后续重构方向
后续若继续开发,建议优先考虑这几个方向: 后续若继续开发,建议优先考虑这几个方向:
1. 把 `web_server.py` 按认证、推荐、策略、管理端拆分路由模块。 1. 把 `app/web/web_server.py` 按认证、推荐、策略、管理端拆分路由模块。
2. 把 `altcoin_db.py` 拆成 schema/init、recommendation、review、analytics、admin 查询几个子模块。 2. 把 `app/db/altcoin_db.py` 拆成 schema/init、recommendation、review、analytics、admin 查询几个子模块。
3. 为 `rules.yaml` 建立更明确的 schema 校验,避免配置漂移。 3. 为 `rules.yaml` 建立更明确的 schema 校验,避免配置漂移。
4. 给核心脚本增加更稳定的 CLI 参数入口,而不是依赖脚本内默认行为。 4. 给核心脚本增加更稳定的 CLI 参数入口,而不是依赖脚本内默认行为。
5. 梳理推送链路,把“是否推送”的判断和“推送内容生成”进一步解耦。 5. 梳理推送链路,把“是否推送”的判断和“推送内容生成”进一步解耦。

View File

@ -53,18 +53,18 @@ docker compose up -d alphax-scheduler
| 任务 | 脚本 | 间隔 | | 任务 | 脚本 | 间隔 |
|---|---|---| |---|---|---|
| 事件舆情 | `event_driven_screener.py --once` | 60s | | 事件舆情 | `python -m app.services.event_driven_screener --once` | 60s |
| 价格跟踪 | `price_tracker.py` | 180s | | 价格跟踪 | `python -m app.services.price_tracker` | 180s |
| 爆发确认 | `altcoin_confirm.py` | 600s | | 爆发确认 | `python -m app.services.altcoin_confirm` | 600s |
| 粗筛/细筛 | `altcoin_screener.py` | 900s | | 粗筛/细筛 | `python -m app.services.altcoin_screener` | 900s |
| 舆情采集 | `sentiment_monitor.py --collect` | 1800s | | 舆情采集 | `python -m app.services.sentiment_monitor --collect` | 1800s |
| 复盘 | `review_engine.py` | 24h | | 复盘 | `python -m app.services.review_engine` | 24h |
## 验证命令 ## 验证命令
```bash ```bash
cd /home/ubuntu/quant_monitor/alphax-docker 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 -m py_compile app/db/altcoin_db.py app/db/auth_db.py app/core/opportunity_lifecycle.py app/services/altcoin_screener.py app/services/altcoin_confirm.py app/services/price_tracker.py app/services/event_driven_screener.py app/services/sentiment_monitor.py app/services/review_engine.py app/web/web_server.py docker/scheduler.py scripts/validate_docker_layout.py
python3 scripts/validate_docker_layout.py python3 scripts/validate_docker_layout.py
python3 scripts/validate_state_machine.py python3 scripts/validate_state_machine.py
python3 scripts/validate_push_state_flow.py python3 scripts/validate_push_state_flow.py

1
app/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Application package for AlphaX."""

1
app/analysis/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Analysis modules."""

View File

@ -21,10 +21,10 @@ import requests
import pandas as pd import pandas as pd
sys.path.insert(0, os.path.dirname(__file__)) sys.path.insert(0, os.path.dirname(__file__))
from altcoin_db import get_conn, record_missed_explosion, upsert_strategy_rule_candidate from app.db.altcoin_db import get_conn, record_missed_explosion, upsert_strategy_rule_candidate
from pa_engine import full_pa_analysis, classify_candles, calc_atr from app.core.pa_engine import full_pa_analysis, classify_candles, calc_atr
from sector_map import get_sector_for_coin, COIN_TO_SECTORS, SECTOR_MEMBERS from app.core.sector_map import get_sector_for_coin, COIN_TO_SECTORS, SECTOR_MEMBERS
import config_loader from app.config import config_loader
BINANCE_API = "https://api.binance.com/api/v3" BINANCE_API = "https://api.binance.com/api/v3"
@ -576,4 +576,4 @@ def run_reverse_analysis():
if __name__ == "__main__": if __name__ == "__main__":
results = run_reverse_analysis() results = run_reverse_analysis()
print(json.dumps(results, ensure_ascii=False, indent=2)) print(json.dumps(results, ensure_ascii=False, indent=2))

1
app/config/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Configuration modules."""

View File

@ -5,11 +5,13 @@ review_engine 调整权重后直接写回 yaml下次运行自动生效
import copy import copy
import datetime import datetime
import os import os
from pathlib import Path
import yaml import yaml
RULES_PATH = os.path.join(os.path.dirname(__file__), "rules.yaml") REPO_ROOT = Path(__file__).resolve().parents[2]
RULES_PATH = str(REPO_ROOT / "rules.yaml")
_cache = None _cache = None
_cache_mtime = None _cache_mtime = None
@ -154,7 +156,7 @@ def get_signal_weights():
canonical[normalize_signal_name(sig)] = weight canonical[normalize_signal_name(sig)] = weight
try: try:
from altcoin_db import get_signal_weights as db_get_weights from app.db.altcoin_db import get_signal_weights as db_get_weights
db_weights = db_get_weights() db_weights = db_get_weights()
for sig, data in db_weights.items(): for sig, data in db_weights.items():
norm_sig = normalize_signal_name(sig) norm_sig = normalize_signal_name(sig)
@ -226,7 +228,7 @@ def update_signal_weight(signal_name, new_weight):
rules.setdefault("signal_weights", {})[canonical_name] = new_weight rules.setdefault("signal_weights", {})[canonical_name] = new_weight
save_rules(rules) save_rules(rules)
try: try:
from altcoin_db import update_signal_performance from app.db.altcoin_db import update_signal_performance
update_signal_performance(canonical_name, category=None, is_hit=None, pnl=None, weight_override=new_weight) update_signal_performance(canonical_name, category=None, is_hit=None, pnl=None, weight_override=new_weight)
except Exception: except Exception:
pass pass

1
app/core/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Core strategy and domain modules."""

View File

@ -13,7 +13,7 @@ import pandas as pd
import numpy as np import numpy as np
from typing import List, Dict, Optional, Tuple from typing import List, Dict, Optional, Tuple
from config_loader import ( from app.config.config_loader import (
dynamic_k_thresholds, dynamic_k_thresholds,
static_k_thresholds, static_k_thresholds,
zone_params, zone_params,
@ -781,4 +781,4 @@ def full_pa_analysis(df: pd.DataFrame, timeframe: str = "4h") -> Dict:
"ignition_points": ignition_points, "ignition_points": ignition_points,
"atr": round(atr, 6), "atr": round(atr, 6),
"trend_exhaustion": exhaustion, "trend_exhaustion": exhaustion,
} }

1
app/db/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Database access modules."""

View File

@ -8,9 +8,10 @@ import json
import os import os
import re import re
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path
from config_loader import get_meta, get_screener_section, confirm_state_cooldown_hours from app.config.config_loader import get_meta, get_screener_section, confirm_state_cooldown_hours
from opportunity_lifecycle import ( from app.core.opportunity_lifecycle import (
apply_entry_quality_gate, apply_entry_quality_gate,
normalize_json_object, normalize_json_object,
derive_display_bucket, derive_display_bucket,
@ -18,7 +19,8 @@ from opportunity_lifecycle import (
is_executed_lifecycle, is_executed_lifecycle,
) )
DB_PATH = os.getenv("ALPHAX_DB_PATH", os.path.join(os.path.dirname(__file__), "data", "altcoin_monitor.db")) REPO_ROOT = Path(__file__).resolve().parents[2]
DB_PATH = os.getenv("ALPHAX_DB_PATH", str(REPO_ROOT / "data" / "altcoin_monitor.db"))
def get_conn(): def get_conn():
@ -2177,7 +2179,7 @@ def get_review_stats():
conn = get_conn() conn = get_conn()
revision_started_at = "" revision_started_at = ""
try: try:
from config_loader import get_meta from app.config.config_loader import get_meta
meta = get_meta() or {} meta = get_meta() or {}
revision_started_at = (meta.get("strategy_revision_started_at") or "").strip() revision_started_at = (meta.get("strategy_revision_started_at") or "").strip()
except Exception: except Exception:

View File

@ -18,9 +18,11 @@ import smtplib
import sqlite3 import sqlite3
from datetime import datetime, timedelta from datetime import datetime, timedelta
from email.message import EmailMessage from email.message import EmailMessage
from pathlib import Path
from typing import Optional from typing import Optional
DB_PATH = os.getenv("ALPHAX_DB_PATH", os.path.join(os.path.dirname(__file__), "data", "altcoin_monitor.db")) REPO_ROOT = Path(__file__).resolve().parents[2]
DB_PATH = os.getenv("ALPHAX_DB_PATH", str(REPO_ROOT / "data" / "altcoin_monitor.db"))
SESSION_DAYS = 30 SESSION_DAYS = 30
VERIFY_CODE_MINUTES = 15 VERIFY_CODE_MINUTES = 15
FREE_TRIAL_DAYS = 30 FREE_TRIAL_DAYS = 30

View File

@ -0,0 +1 @@
"""External integration modules."""

View File

@ -14,7 +14,7 @@ import sys
import json import json
sys.path.insert(0, os.path.dirname(__file__)) sys.path.insert(0, os.path.dirname(__file__))
from feishu_push import push_card from app.integrations.feishu_push import push_card
CHAT_ID = "oc_2c597ad94167102922de142928e2917a" CHAT_ID = "oc_2c597ad94167102922de142928e2917a"
@ -53,7 +53,7 @@ def push_review_report(review_results):
# Section 2: 信号绩效TOP5 # Section 2: 信号绩效TOP5
# 从review_results中提取信号绩效信息 # 从review_results中提取信号绩效信息
from altcoin_db import get_signal_weights from app.db.altcoin_db import get_signal_weights
weights = get_signal_weights() weights = get_signal_weights()
sig_perf_list = sorted( sig_perf_list = sorted(
[(sig, data) for sig, data in weights.items() if data.get("total_count", 0) >= 3], [(sig, data) for sig, data in weights.items() if data.get("total_count", 0) >= 3],
@ -295,4 +295,4 @@ if __name__ == "__main__":
# 测试rule notification # 测试rule notification
ok2, r2 = push_rule_update_notification("rule_20260429_001", "涨幅榜60%有起爆点 → 起爆点是爆发前必现信号") ok2, r2 = push_rule_update_notification("rule_20260429_001", "涨幅榜60%有起爆点 → 起爆点是爆发前必现信号")
print(f"规律通知: ok={ok2}") print(f"规律通知: ok={ok2}")

1
app/services/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Service and workflow modules."""

View File

@ -22,16 +22,17 @@ import os
import time import time
import requests import requests
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path
sys.path.insert(0, os.path.dirname(__file__)) sys.path.insert(0, os.path.dirname(__file__))
from sector_map import get_burst_threshold, is_meme_coin, get_sector_for_coin, COIN_TO_SECTORS from app.core.sector_map import get_burst_threshold, is_meme_coin, get_sector_for_coin, COIN_TO_SECTORS
from altcoin_db import ( from app.db.altcoin_db import (
init_db, expire_old_states, expire_old_recommendations, init_db, expire_old_states, expire_old_recommendations,
get_candidates_for_confirm, update_state, get_conn, create_recommendation, log_screening, get_candidates_for_confirm, update_state, get_conn, create_recommendation, log_screening,
log_cron_run, should_push, log_push, update_latest_price_cache, get_recommendation_for_push, log_cron_run, should_push, log_push, update_latest_price_cache, get_recommendation_for_push,
) )
from feishu_push import push_recommendation_state_alert from app.integrations.feishu_push import push_recommendation_state_alert
from config_loader import ( from app.config.config_loader import (
get_strategy_direction, get_strategy_direction,
vp_fly_params, vp_fly_params,
confirm_min_score, confirm_min_score,
@ -40,15 +41,16 @@ from config_loader import (
confirm_stop_loss_params, confirm_stop_loss_params,
get_strategy_params, get_strategy_params,
) )
from opportunity_lifecycle import apply_entry_quality_gate from app.core.opportunity_lifecycle import apply_entry_quality_gate
from config_loader import _get_section as _get_cfg_section from app.config.config_loader import _get_section as _get_cfg_section
from pa_engine import ( from app.core.pa_engine import (
classify_candles, calc_atr, find_supply_demand_zones, classify_candles, calc_atr, find_supply_demand_zones,
find_continuous_k, detect_ignition_point, full_pa_analysis, find_continuous_k, detect_ignition_point, full_pa_analysis,
analyze_entry_point, detect_trend_exhaustion, analyze_entry_point, detect_trend_exhaustion,
) )
exchange = ccxt.binance({"enableRateLimit": True}) exchange = ccxt.binance({"enableRateLimit": True})
REPO_ROOT = Path(__file__).resolve().parents[2]
def fetch_klines(symbol, timeframe, limit=200): def fetch_klines(symbol, timeframe, limit=200):
@ -66,7 +68,7 @@ def symbol_recently_closed(symbol: str, hours: int = 8) -> bool:
用于冷却期刚止盈的币不宜立即追入""" 用于冷却期刚止盈的币不宜立即追入"""
import sqlite3, os import sqlite3, os
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
db = os.getenv("ALPHAX_DB_PATH", os.path.join(os.path.dirname(__file__), "data", "altcoin_monitor.db")) db = os.getenv("ALPHAX_DB_PATH", str(REPO_ROOT / "data" / "altcoin_monitor.db"))
conn = sqlite3.connect(db) conn = sqlite3.connect(db)
cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat() cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
row = conn.execute(""" row = conn.execute("""
@ -1340,4 +1342,4 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -23,20 +23,21 @@ import os
import time import time
import requests import requests
from datetime import datetime from datetime import datetime
from pathlib import Path
sys.path.insert(0, os.path.dirname(__file__)) sys.path.insert(0, os.path.dirname(__file__))
from sector_map import ( from app.core.sector_map import (
SECTOR_MEMBERS, COIN_TO_SECTORS, MEME_SECTORS, SECTOR_MEMBERS, COIN_TO_SECTORS, MEME_SECTORS,
MIN_24H_VOLUME_USD, MEME_MIN_24H_VOLUME_USD, MIN_24H_VOLUME_USD, MEME_MIN_24H_VOLUME_USD,
get_sector_for_coin, is_meme_coin, get_burst_threshold, get_sector_for_coin, is_meme_coin, get_burst_threshold,
dynamic_leader_detection, dynamic_leader_detection,
) )
from altcoin_db import ( from app.db.altcoin_db import (
init_db, expire_old_states, update_state, get_candidates_for_confirm, init_db, expire_old_states, update_state, get_candidates_for_confirm,
log_screening, create_recommendation, expire_old_recommendations, log_screening, create_recommendation, expire_old_recommendations,
log_cron_run, log_cron_run,
) )
from config_loader import ( from app.config.config_loader import (
get_signal_weights, get_signal_weights,
get_strategy_direction, get_strategy_direction,
get_meta, get_meta,
@ -48,12 +49,13 @@ from config_loader import (
get_screener_section, get_screener_section,
sentiment_max_bonus, sentiment_max_bonus,
) )
from pa_engine import ( from app.core.pa_engine import (
classify_candles, calc_atr, find_supply_demand_zones, classify_candles, calc_atr, find_supply_demand_zones,
find_continuous_k, detect_ignition_point, full_pa_analysis, find_continuous_k, detect_ignition_point, full_pa_analysis,
) )
exchange = ccxt.binance({"enableRateLimit": True}) exchange = ccxt.binance({"enableRateLimit": True})
REPO_ROOT = Path(__file__).resolve().parents[2]
# ==================== 排除列表 ==================== # ==================== 排除列表 ====================
STABLECOINS = { STABLECOINS = {
@ -574,7 +576,7 @@ def layer1_coarse_filter():
# 过去24h内在screening_log出现过的币不受"涨太多"过滤限制 # 过去24h内在screening_log出现过的币不受"涨太多"过滤限制
# 防止ICP/SUI类系统早已盯上但被burst_threshold×1.5误挡 # 防止ICP/SUI类系统早已盯上但被burst_threshold×1.5误挡
import sqlite3 as _sq import sqlite3 as _sq
_c = _sq.connect(os.getenv("ALPHAX_DB_PATH", os.path.join(os.path.dirname(__file__), "data", "altcoin_monitor.db"))) _c = _sq.connect(os.getenv("ALPHAX_DB_PATH", str(REPO_ROOT / "data" / "altcoin_monitor.db")))
_recent = _c.execute(""" _recent = _c.execute("""
SELECT DISTINCT symbol FROM screening_log SELECT DISTINCT symbol FROM screening_log
WHERE scan_time >= datetime('now', '-24 hours') WHERE scan_time >= datetime('now', '-24 hours')
@ -859,7 +861,7 @@ def layer1_coarse_filter():
# === 舆情共振加权 === # === 舆情共振加权 ===
try: try:
from sentiment_monitor import get_sentiment_scores from app.services.sentiment_monitor import get_sentiment_scores
sentiment_cfg = get_screener_section("sentiment") or {} sentiment_cfg = get_screener_section("sentiment") or {}
if sentiment_cfg.get("enabled", True): if sentiment_cfg.get("enabled", True):
sentiment_scores = get_sentiment_scores() sentiment_scores = get_sentiment_scores()

View File

@ -14,6 +14,7 @@ import hashlib
import sqlite3 import sqlite3
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from email.utils import parsedate_to_datetime from email.utils import parsedate_to_datetime
from pathlib import Path
from urllib.parse import quote_plus from urllib.parse import quote_plus
import ccxt import ccxt
@ -23,9 +24,9 @@ import yaml
sys.path.insert(0, os.path.dirname(__file__)) sys.path.insert(0, os.path.dirname(__file__))
from config_loader import load_rules, get_meta, get_strategy_direction from app.config.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 app.db.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 ( from app.services.altcoin_screener import (
fetch_all_tickers, fetch_all_tickers,
detect_volume_price_fly, detect_volume_price_fly,
detect_static_accumulation, detect_static_accumulation,
@ -37,11 +38,12 @@ from altcoin_screener import (
EXCLUDED_BASES, EXCLUDED_BASES,
EXCLUDED_BASE_SUFFIXES, EXCLUDED_BASE_SUFFIXES,
) )
from altcoin_confirm import fetch_derivatives_context from app.services.altcoin_confirm import fetch_derivatives_context
from pa_engine import full_pa_analysis, calc_atr from app.core.pa_engine import full_pa_analysis, calc_atr
from feishu_push import push_recommendation_state_alert from app.integrations.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")) REPO_ROOT = Path(__file__).resolve().parents[2]
DB_PATH = os.getenv("ALPHAX_DB_PATH", str(REPO_ROOT / "data" / "altcoin_monitor.db"))
exchange = ccxt.binance({"enableRateLimit": True}) exchange = ccxt.binance({"enableRateLimit": True})
LEVEL_RANK = {"S": 4, "A": 3, "B": 2, "C": 1, "D": 0, "RISK": 5} LEVEL_RANK = {"S": 4, "A": 3, "B": 2, "C": 1, "D": 0, "RISK": 5}
@ -308,7 +310,7 @@ def fetch_coingecko_trending_events():
if not cfg.get("enabled", True): if not cfg.get("enabled", True):
return [] return []
try: try:
from sentiment_monitor import fetch_trending_coins, _get_previous_trending from app.services.sentiment_monitor import fetch_trending_coins, _get_previous_trending
trending = fetch_trending_coins() trending = fetch_trending_coins()
prev = {r["symbol"] for r in _get_previous_trending()} prev = {r["symbol"] for r in _get_previous_trending()}
events = [] events = []

View File

@ -21,23 +21,25 @@ import json
import sys import sys
import os import os
from datetime import datetime from datetime import datetime
from pathlib import Path
sys.path.insert(0, os.path.dirname(__file__)) sys.path.insert(0, os.path.dirname(__file__))
from altcoin_db import ( from app.db.altcoin_db import (
init_db, get_active_recommendations, update_recommendation_tracking, init_db, get_active_recommendations, update_recommendation_tracking,
expire_old_recommendations, get_stats, update_recommendation_action_status, expire_old_recommendations, get_stats, update_recommendation_action_status,
should_push, log_push, apply_recommendation_state_transition, log_cron_run, should_push, log_push, apply_recommendation_state_transition, log_cron_run,
update_latest_price_cache, update_latest_price_cache,
) )
from pa_engine import ( from app.core.pa_engine import (
calc_atr, full_pa_analysis, detect_trend_exhaustion, calc_atr, full_pa_analysis, detect_trend_exhaustion,
analyze_entry_point, analyze_entry_point,
) )
from feishu_push import push_altcoin_tp_sl_alert from app.integrations.feishu_push import push_altcoin_tp_sl_alert
from config_loader import load_rules from app.config.config_loader import load_rules
from opportunity_lifecycle import apply_entry_quality_gate from app.core.opportunity_lifecycle import apply_entry_quality_gate
exchange = ccxt.binance({"enableRateLimit": True}) exchange = ccxt.binance({"enableRateLimit": True})
REPO_ROOT = Path(__file__).resolve().parents[2]
def fetch_klines(symbol, timeframe, limit=200): def fetch_klines(symbol, timeframe, limit=200):
@ -316,7 +318,7 @@ def track_prices():
if abs(trail_level - old_trail) > 0.000001: if abs(trail_level - old_trail) > 0.000001:
entry_plan["trailing_stop_level"] = trail_level entry_plan["trailing_stop_level"] = trail_level
import sqlite3 as _sq import sqlite3 as _sq
_c2 = _sq.connect(os.getenv("ALPHAX_DB_PATH", os.path.join(os.path.dirname(__file__), "data", "altcoin_monitor.db"))) _c2 = _sq.connect(os.getenv("ALPHAX_DB_PATH", str(REPO_ROOT / "data" / "altcoin_monitor.db")))
_c2.execute("UPDATE recommendation SET entry_plan_json=? WHERE id=?", _c2.execute("UPDATE recommendation SET entry_plan_json=? WHERE id=?",
(json.dumps(entry_plan, ensure_ascii=False), rec["id"])) (json.dumps(entry_plan, ensure_ascii=False), rec["id"]))
_c2.commit() _c2.commit()

View File

@ -16,21 +16,21 @@ from datetime import datetime, timedelta
from collections import defaultdict, Counter from collections import defaultdict, Counter
sys.path.insert(0, os.path.dirname(__file__)) sys.path.insert(0, os.path.dirname(__file__))
from altcoin_db import ( from app.db.altcoin_db import (
get_conn, record_review, update_signal_performance, get_conn, record_review, update_signal_performance,
get_signal_weights, record_missed_explosion, get_review_stats, log_strategy_iteration, get_signal_weights, record_missed_explosion, get_review_stats, log_strategy_iteration,
upsert_strategy_rule_candidate, record_strategy_failure_pattern, upsert_strategy_rule_candidate, record_strategy_failure_pattern,
get_strategy_rule_candidates, update_strategy_rule_candidate_status, get_strategy_rule_candidates, update_strategy_rule_candidate_status,
refresh_strategy_candidate_performance, refresh_strategy_candidate_performance,
) )
from pa_engine import classify_candles, calc_atr, full_pa_analysis from app.core.pa_engine import classify_candles, calc_atr, full_pa_analysis
from config_loader import ( from app.config.config_loader import (
get_review_params, update_meta, get_learned_rules, add_learned_rule, get_review_params, update_meta, get_learned_rules, add_learned_rule,
get_rules_snapshot, diff_rule_snapshots, get_meta, update_signal_weight, get_rules_snapshot, diff_rule_snapshots, get_meta, update_signal_weight,
promote_candidate_rule_to_learned_rule, bump_strategy_patch_version, promote_candidate_rule_to_learned_rule, bump_strategy_patch_version,
) )
import reverse_analysis from app.analysis import reverse_analysis
import feishu_review_push from app.integrations import feishu_review_push
import requests import requests
BINANCE_API = "https://api.binance.com/api/v3" BINANCE_API = "https://api.binance.com/api/v3"
@ -1340,7 +1340,7 @@ def run_review():
# 通过config_loader更新迭代计数 # 通过config_loader更新迭代计数
current_meta = {} current_meta = {}
try: try:
from config_loader import get_meta from app.config.config_loader import get_meta
current_meta = get_meta() current_meta = get_meta()
except: except:
pass pass
@ -1387,4 +1387,4 @@ def run_review():
if __name__ == "__main__": if __name__ == "__main__":
run_review() run_review()

View File

@ -13,12 +13,14 @@ import requests
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from datetime import datetime, timedelta from datetime import datetime, timedelta
from collections import defaultdict from collections import defaultdict
from pathlib import Path
sys.path.insert(0, os.path.dirname(__file__)) sys.path.insert(0, os.path.dirname(__file__))
COINGECKO_TRENDING_URL = "https://api.coingecko.com/api/v3/search/trending" 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" 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")) REPO_ROOT = Path(__file__).resolve().parents[2]
DB_PATH = os.getenv("ALPHAX_DB_PATH", str(REPO_ROOT / "data" / "altcoin_monitor.db"))
def _get_conn(): def _get_conn():

1
app/web/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Web application modules."""

View File

@ -9,15 +9,14 @@ import json
import sqlite3 import sqlite3
from datetime import datetime, timezone from datetime import datetime, timezone
from contextvars import ContextVar from contextvars import ContextVar
from pathlib import Path
from fastapi import FastAPI, HTTPException, Cookie, Request from fastapi import FastAPI, HTTPException, Cookie, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pydantic import BaseModel from pydantic import BaseModel
import auth_db from app.db import auth_db
from app.db.altcoin_db import (
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from altcoin_db import (
init_db, get_active_recommendations, get_active_recommendations_deduped, get_all_recommendations, init_db, get_active_recommendations, get_active_recommendations_deduped, get_all_recommendations,
get_screening_history, get_stats, get_review_stats, get_cron_run_logs, get_cron_run_summary, get_screening_history, get_stats, get_review_stats, get_cron_run_logs, get_cron_run_summary,
get_conn, _derive_execution_fields, get_strategy_insights, get_strategy_rule_candidates, get_conn, _derive_execution_fields, get_strategy_insights, get_strategy_rule_candidates,
@ -25,10 +24,12 @@ from altcoin_db import (
dry_run_strategy_candidate_performance, backfill_strategy_failure_patterns, dry_run_strategy_candidate_performance, backfill_strategy_failure_patterns,
generate_candidates_from_review_history, generate_candidates_from_review_history,
) )
from config_loader import get_signal_weights, get_meta from app.config.config_loader import get_signal_weights, get_meta
REPO_ROOT = Path(__file__).resolve().parents[2]
app = FastAPI(title="山寨币爆发监控 v11") app = FastAPI(title="山寨币爆发监控 v11")
templates = Jinja2Templates(directory="static") templates = Jinja2Templates(directory=str(REPO_ROOT / "static"))
_current_request = ContextVar("current_request", default=None) _current_request = ContextVar("current_request", default=None)
@ -537,7 +538,7 @@ async def api_cron_summary(hours: int = 24, altcoin_session: str = Cookie(defaul
async def api_sentiment(hours: int = 6, altcoin_session: str = Cookie(default="")): async def api_sentiment(hours: int = 6, altcoin_session: str = Cookie(default="")):
"""返回舆情监控数据:以消息/事件为主,币种作为关联信息。""" """返回舆情监控数据:以消息/事件为主,币种作为关联信息。"""
_require_api_user_with_subscription(altcoin_session) _require_api_user_with_subscription(altcoin_session)
db = os.getenv("ALPHAX_DB_PATH", os.path.join(os.path.dirname(__file__), "data", "altcoin_monitor.db")) db = os.getenv("ALPHAX_DB_PATH", str(REPO_ROOT / "data" / "altcoin_monitor.db"))
conn = sqlite3.connect(db) conn = sqlite3.connect(db)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
@ -777,7 +778,7 @@ async def api_kline(symbol: str, interval: str = "1d", limit: int = 60, altcoin_
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def index(): async def index():
"""落地页 — 原始 HTML,无 Jinja2""" """落地页 — 原始 HTML,无 Jinja2"""
landing_path = os.path.join(os.path.dirname(__file__), "static", "index.html") landing_path = str(REPO_ROOT / "static" / "index.html")
with open(landing_path, "r", encoding="utf-8") as f: with open(landing_path, "r", encoding="utf-8") as f:
return HTMLResponse(content=f.read()) return HTMLResponse(content=f.read())
@ -785,7 +786,7 @@ async def index():
@app.get("/auth", response_class=HTMLResponse) @app.get("/auth", response_class=HTMLResponse)
async def auth_page(): async def auth_page():
"""登录/注册页 — 原始 HTML,无 Jinja2""" """登录/注册页 — 原始 HTML,无 Jinja2"""
auth_path = os.path.join(os.path.dirname(__file__), "static", "auth.html") auth_path = str(REPO_ROOT / "static" / "auth.html")
with open(auth_path, "r", encoding="utf-8") as f: with open(auth_path, "r", encoding="utf-8") as f:
return HTMLResponse(content=f.read()) return HTMLResponse(content=f.read())
@ -1008,7 +1009,11 @@ async def api_strategy_candidates_generate_history(dry_run: bool = False):
return result return result
STOCK_REPORT_TEMPLATE = open(os.path.join(os.path.dirname(__file__), "stock_report_template.html"), "r", encoding="utf-8").read() STOCK_REPORT_TEMPLATE = open(
REPO_ROOT / "templates" / "stock_report_template.html",
"r",
encoding="utf-8",
).read()
HTML_PAGE = r'''<!DOCTYPE html> HTML_PAGE = r'''<!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
@ -4247,4 +4252,4 @@ async def api_admin_users(search: str = "", offset: int = 0, limit: int = 50, ta
async def api_admin_orders(search: str = "", offset: int = 0, limit: int = 50, status: str = "all", async def api_admin_orders(search: str = "", offset: int = 0, limit: int = 50, status: str = "all",
altcoin_session: str = Cookie(default="")): altcoin_session: str = Cookie(default="")):
_require_admin(altcoin_session) _require_admin(altcoin_session)
return auth_db.get_admin_orders(search=search, offset=offset, limit=limit, status=status) return auth_db.get_admin_orders(search=search, offset=offset, limit=limit, status=status)

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,7 @@ fi
case "${1:-web}" in case "${1:-web}" in
web) web)
exec python -m uvicorn web_server:app --host 0.0.0.0 --port "${PORT:-8190}" exec python -m uvicorn app.web.web_server:app --host 0.0.0.0 --port "${PORT:-8190}"
;; ;;
scheduler) scheduler)
exec python /app/docker/scheduler.py exec python /app/docker/scheduler.py

View File

@ -25,7 +25,7 @@ DRY_RUN = os.getenv("ALPHAX_SCHEDULER_DRY_RUN", "1").strip() not in {"0", "false
@dataclass @dataclass
class Job: class Job:
name: str name: str
script: str module: str
every_seconds: int every_seconds: int
args: tuple[str, ...] = () args: tuple[str, ...] = ()
initial_delay: int = 0 initial_delay: int = 0
@ -43,7 +43,7 @@ def env_for_child() -> dict[str, str]:
def run_job(job: Job) -> None: def run_job(job: Job) -> None:
cmd = [PYTHON, str(ROOT / job.script), *job.args] cmd = [PYTHON, "-m", job.module, *job.args]
print(f"[{now_str()}] [scheduler] start {job.name}: {' '.join(cmd)}", flush=True) print(f"[{now_str()}] [scheduler] start {job.name}: {' '.join(cmd)}", flush=True)
if DRY_RUN: if DRY_RUN:
print(f"[{now_str()}] [scheduler] DRY_RUN=1 skip {job.name}", flush=True) print(f"[{now_str()}] [scheduler] DRY_RUN=1 skip {job.name}", flush=True)
@ -75,12 +75,12 @@ def run_job(job: Job) -> None:
def build_jobs() -> list[Job]: def build_jobs() -> list[Job]:
# 与当前宿主机 crontab 对齐,但串行执行。 # 与当前宿主机 crontab 对齐,但串行执行。
return [ return [
Job("event", "event_driven_screener.py", 60, ("--once",), initial_delay=5), Job("event", "app.services.event_driven_screener", 60, ("--once",), initial_delay=5),
Job("tracker", "price_tracker.py", 180, initial_delay=20), Job("tracker", "app.services.price_tracker", 180, initial_delay=20),
Job("confirm", "altcoin_confirm.py", 600, initial_delay=40), Job("confirm", "app.services.altcoin_confirm", 600, initial_delay=40),
Job("screener", "altcoin_screener.py", 900, initial_delay=80), Job("screener", "app.services.altcoin_screener", 900, initial_delay=80),
Job("sentiment", "sentiment_monitor.py", 1800, ("--collect",), initial_delay=120), Job("sentiment", "app.services.sentiment_monitor", 1800, ("--collect",), initial_delay=120),
Job("review", "review_engine.py", 24 * 3600, initial_delay=300), Job("review", "app.services.review_engine", 24 * 3600, initial_delay=300),
] ]

122
docs/PROJECT_STRUCTURE.md Normal file
View File

@ -0,0 +1,122 @@
# Project Structure
## 目标
这次整理的目标不是把项目“重写成新架构”,而是先把目录语义拉清楚:
- 根目录只放主运行链路和顶层配置
- 工具脚本集中到 `tools/`
- 校验脚本集中到 `scripts/`
- 模板资源集中到 `templates/`
- 运行/分析产物集中到 `reports/`
- 历史遗留与备份集中到 `legacy/`
## 当前建议心智模型
### 1. 真实实现层
真实实现现在集中在 `app/` 下:
- `app/services/`
- `app/db/`
- `app/core/`
- `app/config/`
- `app/integrations/`
- `app/analysis/`
- `app/web/`
### 2. 运行主链路
- `app/services/altcoin_screener.py`
- `app/services/altcoin_confirm.py`
- `app/services/price_tracker.py`
- `app/services/event_driven_screener.py`
- `app/services/review_engine.py`
- `app/web/web_server.py`
### 3. 核心共享层
- `app/db/altcoin_db.py`
- `app/db/auth_db.py`
- `app/config/config_loader.py`
- `app/core/opportunity_lifecycle.py`
- `app/core/pa_engine.py`
- `app/core/sector_map.py`
- `app/integrations/feishu_push.py`
- `app/integrations/feishu_review_push.py`
### 4. 配置与部署
- `rules.yaml`
- `.env.example`
- `Dockerfile`
- `docker-compose.yml`
- `docker/`
### 5. 前端与模板
- `static/`
- `templates/`
### 6. 开发辅助
- `tests/`
- `scripts/`
- `tools/`
- `docs/`
### 7. 非主路径归档
- `legacy/`
- `reports/`
## 已整理的文件
### 移入 `tools/`
- `backtest.py` -> `tools/backtest.py`
- `extract_summary.py` -> `tools/extract_summary.py`
- `summarize_output.py` -> `tools/summarize_output.py`
### 移入 `scripts/`
- `validate_params.py` -> `scripts/validate_params.py`
### 移入 `templates/`
- `stock_report_template.html` -> `templates/stock_report_template.html`
### 移入 `reports/`
- `backtest_result.json` -> `reports/backtest_result.json`
### 移入 `docs/reference/`
- `schema.py` -> `docs/reference/schema_reference.py`
### 移入 `legacy/`
- `coin_state_tracker.py`
- `price_tracker_ws.py`
- `legacy/web/index.html`
- `legacy/static/app.html.bak`
- `legacy/scratch/*`
## 后续建议
如果继续整理,建议下一步做实现层拆分,而不是再搬目录:
1. 拆 `web_server.py`
2. 拆 `altcoin_db.py`
3. 为 `rules.yaml` 增加 schema 校验
4. 为主脚本建立统一 CLI 入口
## 当前结论
这次整理后:
- `app/` 承载真实实现
- 根目录不再堆放业务实现脚本
- 旧工具/旧资产/产物不再继续污染主目录
这不是最终态,但已经从“所有实现都摊在根目录”进到了“实现按职责分层”的可维护阶段。

View File

@ -11,9 +11,12 @@
此文件不应被 screener/confirm 等模块直接导入调用 此文件不应被 screener/confirm 等模块直接导入调用
""" """
import os
import sqlite3 import sqlite3
from pathlib import Path
DB_PATH = "/home/ubuntu/quant_monitor/altcoin/altcoin_monitor.db" REPO_ROOT = Path(__file__).resolve().parents[2]
DB_PATH = os.getenv("ALPHAX_DB_PATH", str(REPO_ROOT / "data" / "altcoin_monitor.db"))
def init_db(): def init_db():
@ -113,4 +116,4 @@ def init_db():
if __name__ == "__main__": if __name__ == "__main__":
init_db() init_db()
print("DB Schema初始化完成4张表+5个索引") print("DB Schema初始化完成4张表+5个索引")

View File

@ -13,7 +13,7 @@ from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from altcoin_db import ( from app.db.altcoin_db import (
init_db, init_db,
get_active_recommendations_deduped, get_active_recommendations_deduped,
update_recommendation_tracking, update_recommendation_tracking,
@ -21,7 +21,7 @@ from altcoin_db import (
should_push, should_push,
log_push, log_push,
) )
from feishu_push import push_altcoin_tp_sl_alert from app.integrations.feishu_push import push_altcoin_tp_sl_alert
POLL_INTERVAL = 5 POLL_INTERVAL = 5
REFRESH_INTERVAL = 60 REFRESH_INTERVAL = 60

View File

@ -986,8 +986,8 @@ monitoring:
min_score_max: 6 min_score_max: 6
require_human_if_exceeded: true require_human_if_exceeded: true
param_audit: param_audit:
description: 参数变更审计:每次复盘运行validate_params.py验证关键参数未被意外修改 description: 参数变更审计:每次复盘运行scripts/validate_params.py验证关键参数未被意外修改
validate_script: validate_params.py validate_script: scripts/validate_params.py
hash_algorithm: semantic_sha256 hash_algorithm: semantic_sha256
critical_sections: critical_sections:
- confirm - confirm

View File

@ -1,9 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""参数变更审计:检测 rules.yaml 是否被意外修改""" """参数变更审计:检测 rules.yaml 是否被意外修改"""
import hashlib, yaml, sys import hashlib
import sys
from pathlib import Path from pathlib import Path
RULES_PATH = Path(__file__).parent / 'rules.yaml' import yaml
REPO_ROOT = Path(__file__).resolve().parents[1]
RULES_PATH = REPO_ROOT / "rules.yaml"
def compute_semantic_hash(rules_dict): def compute_semantic_hash(rules_dict):
"""语义哈希:忽略格式差异,只对关键参数字段做哈希""" """语义哈希:忽略格式差异,只对关键参数字段做哈希"""

View File

@ -8,8 +8,8 @@ PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if PROJECT_DIR not in sys.path: if PROJECT_DIR not in sys.path:
sys.path.insert(0, PROJECT_DIR) sys.path.insert(0, PROJECT_DIR)
import altcoin_db from app.db import altcoin_db
import web_server from app.web import web_server
@pytest.fixture @pytest.fixture
@ -340,4 +340,3 @@ def test_version_filter_labels_use_plan_not_executable_to_avoid_wait_pullback_co
assert '历史推荐' in html assert '历史推荐' in html
# v1.7.7: 新看板版本下拉用 (${v.count}) 格式 + 默认选中最新版本 # v1.7.7: 新看板版本下拉用 (${v.count}) 格式 + 默认选中最新版本
assert 'v.count' in html assert 'v.count' in html

View File

@ -5,7 +5,7 @@ from datetime import datetime, timedelta
import pandas as pd import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from altcoin_confirm import _is_candidate_fresh, _event_time_from_age from app.services.altcoin_confirm import _is_candidate_fresh, _event_time_from_age
def test_candidate_fresh_when_state_detected_recently_without_current_trigger(): def test_candidate_fresh_when_state_detected_recently_without_current_trigger():

View File

@ -8,8 +8,8 @@ PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if PROJECT_DIR not in sys.path: if PROJECT_DIR not in sys.path:
sys.path.insert(0, PROJECT_DIR) sys.path.insert(0, PROJECT_DIR)
import altcoin_db from app.db import altcoin_db
import web_server from app.web import web_server
@pytest.fixture @pytest.fixture

View File

@ -8,7 +8,7 @@ import pandas as pd
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
import event_driven_screener as ed from app.services import event_driven_screener as ed
def test_symbol_extraction_filters_usdt_suffix_and_pollution(): def test_symbol_extraction_filters_usdt_suffix_and_pollution():

View File

@ -8,8 +8,8 @@ PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if PROJECT_DIR not in sys.path: if PROJECT_DIR not in sys.path:
sys.path.insert(0, PROJECT_DIR) sys.path.insert(0, PROJECT_DIR)
import altcoin_db from app.db import altcoin_db
import web_server from app.web import web_server
from test_actionable_active_recommendations import _insert_recommendation from test_actionable_active_recommendations import _insert_recommendation

View File

@ -22,7 +22,7 @@ def make_df(highs, lows, closes, opens=None, volumes=None):
def test_detect_higher_lows_finds_clear_pattern(): def test_detect_higher_lows_finds_clear_pattern():
"""验证底部抬高检测能识别清晰的逐步抬高低点""" """验证底部抬高检测能识别清晰的逐步抬高低点"""
from altcoin_screener import detect_higher_lows from app.services.altcoin_screener import detect_higher_lows
lows, highs, closes, volumes = [], [], [], [] lows, highs, closes, volumes = [], [], [], []
for seg in range(6): for seg in range(6):
@ -44,7 +44,7 @@ def test_detect_higher_lows_finds_clear_pattern():
def test_detect_higher_lows_rejects_declining_trend(): def test_detect_higher_lows_rejects_declining_trend():
"""验证下降趋势不会被误判为底部抬高""" """验证下降趋势不会被误判为底部抬高"""
from altcoin_screener import detect_higher_lows from app.services.altcoin_screener import detect_higher_lows
lows, highs, closes, volumes = [], [], [], [] lows, highs, closes, volumes = [], [], [], []
for seg in range(6): for seg in range(6):
@ -64,7 +64,7 @@ def test_detect_higher_lows_rejects_declining_trend():
def test_detect_compression_surge_detects_tight_then_volume(): def test_detect_compression_surge_detects_tight_then_volume():
"""验证压缩后放量模式能被检测""" """验证压缩后放量模式能被检测"""
from altcoin_screener import detect_compression_surge from app.services.altcoin_screener import detect_compression_surge
lows = [100 + np.random.uniform(-0.5, 0.5) for _ in range(24)] 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] highs = [l + np.random.uniform(1, 2.5) for l in lows]
@ -82,7 +82,7 @@ def test_detect_compression_surge_detects_tight_then_volume():
def test_detect_compression_surge_rejects_wide_range(): def test_detect_compression_surge_rejects_wide_range():
"""验证宽幅震荡不触发压缩检测""" """验证宽幅震荡不触发压缩检测"""
from altcoin_screener import detect_compression_surge from app.services.altcoin_screener import detect_compression_surge
lows = [np.random.uniform(80, 120) for _ in range(24)] lows = [np.random.uniform(80, 120) for _ in range(24)]
highs = [l + np.random.uniform(5, 15) for l in lows] highs = [l + np.random.uniform(5, 15) for l in lows]
@ -97,7 +97,7 @@ def test_detect_compression_surge_rejects_wide_range():
def test_signal_deprecation_config_exists(): def test_signal_deprecation_config_exists():
"""验证信号淘汰机制配置可正确读取""" """验证信号淘汰机制配置可正确读取"""
from config_loader import get_review_params from app.config.config_loader import get_review_params
dep = get_review_params().get("signal_deprecation", {}) dep = get_review_params().get("signal_deprecation", {})
assert dep.get("enabled") is True assert dep.get("enabled") is True

View File

@ -10,7 +10,7 @@ PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
if PROJECT_DIR not in sys.path: if PROJECT_DIR not in sys.path:
sys.path.insert(0, PROJECT_DIR) sys.path.insert(0, PROJECT_DIR)
import altcoin_db from app.db import altcoin_db
class RecommendationHistoryBase(unittest.TestCase): class RecommendationHistoryBase(unittest.TestCase):

View File

@ -9,8 +9,8 @@ PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if PROJECT_DIR not in sys.path: if PROJECT_DIR not in sys.path:
sys.path.insert(0, PROJECT_DIR) sys.path.insert(0, PROJECT_DIR)
import altcoin_db from app.db import altcoin_db
import web_server from app.web import web_server
@pytest.fixture @pytest.fixture

View File

@ -9,8 +9,8 @@ PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if PROJECT_DIR not in sys.path: if PROJECT_DIR not in sys.path:
sys.path.insert(0, PROJECT_DIR) sys.path.insert(0, PROJECT_DIR)
import altcoin_db from app.db import altcoin_db
import web_server from app.web import web_server
@pytest.fixture() @pytest.fixture()

View File

@ -4,8 +4,8 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from opportunity_lifecycle import apply_entry_quality_gate from app.core.opportunity_lifecycle import apply_entry_quality_gate
import price_tracker_ws from legacy import price_tracker_ws
def test_risk_reward_false_blocks_buy_now(): def test_risk_reward_false_blocks_buy_now():

View File

@ -5,7 +5,7 @@ import pandas as pd
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from pa_engine import calc_atr, classify_candles, detect_ignition_point from app.core.pa_engine import calc_atr, classify_candles, detect_ignition_point
def _ignition_df(stale_age_bars=6): def _ignition_df(stale_age_bars=6):

View File

@ -8,8 +8,8 @@ from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import auth_db from app.db import auth_db
import altcoin_db from app.db import altcoin_db
class PersonalizationAndStrategyInsightTests(unittest.TestCase): class PersonalizationAndStrategyInsightTests(unittest.TestCase):

View File

@ -5,7 +5,7 @@ import tempfile
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
import altcoin_db from app.db import altcoin_db
class RecommendationExecutionStatusTests(unittest.TestCase): class RecommendationExecutionStatusTests(unittest.TestCase):

View File

@ -8,7 +8,7 @@ from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import altcoin_db from app.db import altcoin_db
class RecommendationStateMainlineTests(unittest.TestCase): class RecommendationStateMainlineTests(unittest.TestCase):

View File

@ -5,7 +5,7 @@ PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if PROJECT_DIR not in sys.path: if PROJECT_DIR not in sys.path:
sys.path.insert(0, PROJECT_DIR) sys.path.insert(0, PROJECT_DIR)
import altcoin_screener from app.services import altcoin_screener
def test_replay_samples_cover_pnt_cream_ai(): def test_replay_samples_cover_pnt_cream_ai():

View File

@ -7,7 +7,7 @@ PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if PROJECT_DIR not in sys.path: if PROJECT_DIR not in sys.path:
sys.path.insert(0, PROJECT_DIR) sys.path.insert(0, PROJECT_DIR)
import web_server from app.web import web_server
def test_index_hides_screening_from_top_level_tabs(): def test_index_hides_screening_from_top_level_tabs():

View File

@ -5,7 +5,7 @@ PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if PROJECT_DIR not in sys.path: if PROJECT_DIR not in sys.path:
sys.path.insert(0, PROJECT_DIR) sys.path.insert(0, PROJECT_DIR)
import altcoin_db from app.db import altcoin_db
def test_review_stats_contains_strategy_version_summary_and_changelog(monkeypatch): def test_review_stats_contains_strategy_version_summary_and_changelog(monkeypatch):

View File

@ -7,7 +7,7 @@ PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if PROJECT_DIR not in sys.path: if PROJECT_DIR not in sys.path:
sys.path.insert(0, PROJECT_DIR) sys.path.insert(0, PROJECT_DIR)
import altcoin_screener from app.services import altcoin_screener
def test_fetch_all_tickers_filters_stable_and_fiat_suffixes(monkeypatch): def test_fetch_all_tickers_filters_stable_and_fiat_suffixes(monkeypatch):

View File

@ -9,7 +9,7 @@ from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import altcoin_db from app.db import altcoin_db
class RecommendationSignalTrustTests(unittest.TestCase): class RecommendationSignalTrustTests(unittest.TestCase):

View File

@ -9,8 +9,8 @@ PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if PROJECT_DIR not in sys.path: if PROJECT_DIR not in sys.path:
sys.path.insert(0, PROJECT_DIR) sys.path.insert(0, PROJECT_DIR)
import altcoin_db from app.db import altcoin_db
import web_server from app.web import web_server
@pytest.fixture @pytest.fixture

View File

@ -8,9 +8,9 @@ PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if PROJECT_DIR not in sys.path: if PROJECT_DIR not in sys.path:
sys.path.insert(0, PROJECT_DIR) sys.path.insert(0, PROJECT_DIR)
import altcoin_db from app.db import altcoin_db
import review_engine from app.services import review_engine
import config_loader from app.config import config_loader
@pytest.fixture @pytest.fixture

View File

@ -6,7 +6,7 @@ PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if PROJECT_DIR not in sys.path: if PROJECT_DIR not in sys.path:
sys.path.insert(0, PROJECT_DIR) sys.path.insert(0, PROJECT_DIR)
import altcoin_db from app.db import altcoin_db
def test_strategy_version_from_meta(): def test_strategy_version_from_meta():

View File

@ -5,8 +5,8 @@ PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if PROJECT_DIR not in sys.path: if PROJECT_DIR not in sys.path:
sys.path.insert(0, PROJECT_DIR) sys.path.insert(0, PROJECT_DIR)
import altcoin_db from app.db import altcoin_db
import price_tracker_ws from legacy import price_tracker_ws
def test_terminal_recommendation_action_status_cannot_be_overwritten_by_entry_signal(monkeypatch, tmp_path): def test_terminal_recommendation_action_status_cannot_be_overwritten_by_entry_signal(monkeypatch, tmp_path):

View File

@ -9,8 +9,8 @@ PROJECT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if PROJECT_DIR not in sys.path: if PROJECT_DIR not in sys.path:
sys.path.insert(0, PROJECT_DIR) sys.path.insert(0, PROJECT_DIR)
import auth_db from app.db import auth_db
import web_server from app.web import web_server
@pytest.fixture @pytest.fixture

View File

@ -5,8 +5,8 @@ import pandas as pd
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from altcoin_screener import detect_volume_price_fly from app.services.altcoin_screener import detect_volume_price_fly
from altcoin_confirm import detect_volume_price_fly_1h from app.services.altcoin_confirm import detect_volume_price_fly_1h
def _sample_df(stale_age_hours=9): def _sample_df(stale_age_hours=9):

View File

@ -2,17 +2,22 @@
山寨币策略回测脚本 山寨币策略回测脚本
DB 中所有有完整入场方案stop_loss/tp1/tp2的推荐做模拟跟踪 DB 中所有有完整入场方案stop_loss/tp1/tp2的推荐做模拟跟踪
""" """
import sys, os, json, sqlite3 import json
from datetime import datetime, timedelta import os
import sqlite3
sys.path.insert(0, '/home/ubuntu/quant_monitor/altcoin') import sys
from datetime import datetime
from pathlib import Path
import ccxt import ccxt
import pandas as pd import pandas as pd
import numpy as np
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT))
exchange = ccxt.binance({'enableRateLimit': True}) exchange = ccxt.binance({'enableRateLimit': True})
DB = '/home/ubuntu/quant_monitor/altcoin/altcoin_monitor.db' DB = os.getenv("ALPHAX_DB_PATH", str(REPO_ROOT / "data" / "altcoin_monitor.db"))
OUTPUT_PATH = REPO_ROOT / "reports" / "backtest_result.json"
def fetch_klines_since(symbol, timeframe, since_ms, limit=500): def fetch_klines_since(symbol, timeframe, since_ms, limit=500):
@ -51,7 +56,6 @@ def simulate_trade(rec, klines_df):
close = float(row['close']) close = float(row['close'])
ts = row['timestamp'] ts = row['timestamp']
current_pnl = (close / entry_price - 1) * 100
max_profit_pct = max(max_profit_pct, (high / 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) max_loss_pct = min(max_loss_pct, (low / entry_price - 1) * 100)
@ -179,7 +183,8 @@ def main():
f"{r['pnl_pct']:>6.1f}% {r['max_profit_pct']:>6.1f}% {r['max_loss_pct']:>6.1f}% {r['hours']:>5.1f}") 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 # Save to JSON for HTML report
with open('/home/ubuntu/quant_monitor/altcoin/backtest_result.json', 'w') as f: OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(OUTPUT_PATH, 'w', encoding='utf-8') as f:
json.dump({ json.dump({
'generated_at': datetime.now().isoformat(), 'generated_at': datetime.now().isoformat(),
'total': len(results), 'total': len(results),
@ -193,7 +198,7 @@ def main():
'details': [{k: str(v) if isinstance(v, (datetime, pd.Timestamp)) else v 'details': [{k: str(v) if isinstance(v, (datetime, pd.Timestamp)) else v
for k, v in r.items()} for r in results], for k, v in r.items()} for r in results],
}, f, ensure_ascii=False, indent=2, default=str) }, f, ensure_ascii=False, indent=2, default=str)
print(f"\n结果已保存: backtest_result.json") print(f"\n结果已保存: {OUTPUT_PATH}")
if __name__ == '__main__': if __name__ == '__main__':