From 0fe1b4878e160081eecf6855f066c016c04ba392 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Wed, 13 May 2026 23:50:02 +0800 Subject: [PATCH] rebuild --- AGENTS.md | 48 +- app/cli.py | 78 + app/config/config_loader.py | 3 +- app/config/rules_schema.py | 34 + app/db/admin_queries.py | 23 + app/db/altcoin_db.py | 803 +---- app/db/analytics.py | 669 ++++ app/db/auth_db.py | 11 + app/db/recommendation_queries.py | 230 ++ app/db/review_queries.py | 319 ++ app/db/schema.py | 5 + app/integrations/feishu_push.py | 37 +- app/integrations/push_orchestrator.py | 52 + app/services/altcoin_confirm.py | 40 +- app/services/altcoin_screener.py | 22 +- app/services/event_driven_screener.py | 25 +- app/services/price_tracker.py | 36 +- app/services/review_engine.py | 78 +- app/web/routes_admin.py | 43 + app/web/routes_auth.py | 155 + app/web/routes_content.py | 294 ++ app/web/routes_pages.py | 98 + app/web/routes_recommendations.py | 141 + app/web/routes_strategy.py | 123 + app/web/shared.py | 156 + app/web/web_server.py | 4273 +------------------------ docker/scheduler.py | 16 +- docs/PROJECT_STRUCTURE.md | 29 +- legacy/price_tracker_ws.py | 39 +- requirements.txt | 2 + static/app.html | 6 +- static/auth.html | 11 +- tests/test_user_subscription_auth.py | 8 +- 33 files changed, 2775 insertions(+), 5132 deletions(-) create mode 100644 app/cli.py create mode 100644 app/config/rules_schema.py create mode 100644 app/db/admin_queries.py create mode 100644 app/db/analytics.py create mode 100644 app/db/recommendation_queries.py create mode 100644 app/db/review_queries.py create mode 100644 app/db/schema.py create mode 100644 app/integrations/push_orchestrator.py create mode 100644 app/web/routes_admin.py create mode 100644 app/web/routes_auth.py create mode 100644 app/web/routes_content.py create mode 100644 app/web/routes_pages.py create mode 100644 app/web/routes_recommendations.py create mode 100644 app/web/routes_strategy.py create mode 100644 app/web/shared.py diff --git a/AGENTS.md b/AGENTS.md index 4e5f32e..1986af3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,15 +33,19 @@ AlphaX 是一个以 `Python + FastAPI + SQLite + 静态 HTML` 组成的加密市 5. `app/services/event_driven_screener.py` 负责事件/舆情驱动的快速触发检查,属于技术筛选主链路的补充入口。 6. `app/web/web_server.py` - 负责用户端和管理端 API、页面壳、订阅与认证相关接口。 + 现在主要负责 FastAPI 应用装配、模板装配和中间件绑定。 ### 3.2 数据与状态中心 -- `app/db/altcoin_db.py` 是交易/推荐/状态的核心数据库层,体量很大,承担了: +- `app/db/altcoin_db.py` 仍是交易/推荐/状态的核心数据库层,体量很大,当前主要承担: - 初始化表结构 - recommendation / screening_log / tracking / review 等主表读写 - 推荐状态派生与展示口径整理 - 部分状态迁移与兼容逻辑 +- `app/db/recommendation_queries.py` + - 已开始承接推荐热路径查询和推送冷却判断,如 active 推荐查询、push 去重、推送消费口径 +- `app/db/analytics.py` + - 已开始承接筛选历史、复盘概览、cron 汇总等读多写少的查询 - `app/db/auth_db.py` 是会员、邀请码、邮箱验证、订阅、订单预留的数据库层。 - `app/core/opportunity_lifecycle.py` 是机会生命周期和买点质量闸门的规则中心,决定: - 哪些机会只是观察池 @@ -119,14 +123,15 @@ Python 业务实现不应再直接留在根目录。 ## 5. Web/API 观察 -`app/web/web_server.py` 体量非常大,既包含: +Web 层已经开始拆分,当前建议优先在这些文件上继续演进: -- 认证接口 -- 订阅接口 -- 推荐/筛选/复盘/策略看板接口 -- 新闻/舆情接口 -- 管理端接口 -- 页面路由 +- `app/web/routes_auth.py` +- `app/web/routes_recommendations.py` +- `app/web/routes_strategy.py` +- `app/web/routes_admin.py` +- `app/web/routes_pages.py` +- `app/web/routes_content.py` +- `app/web/shared.py` 如果要改 Web 逻辑,先确认变更属于哪一类: @@ -134,7 +139,7 @@ Python 业务实现不应再直接留在根目录。 - “参数问题”优先去 `rules.yaml` - “页面展示问题”再回到 `static/*.html` -不要第一反应直接在 `app/web/web_server.py` 里堆业务分支,否则会继续放大这个文件的复杂度。 +`app/web/web_server.py` 不应该再继续承载业务路由实现;新增接口优先落到对应 `routes_*` 模块。 ## 6. 调度与运行方式 @@ -161,13 +166,10 @@ Python 业务实现不应再直接留在根目录。 - `web` -> 启动 uvicorn (`app.web.web_server:app`) - `scheduler` -> 启动 `docker/scheduler.py` - `once` -> 执行单次脚本 +- `app/cli.py` + - 统一命令入口:`screener / confirm / tracker / review / event / sentiment` - `docker/scheduler.py` - - 统一调度 `app.services.event_driven_screener` - - `app.services.price_tracker` - - `app.services.altcoin_confirm` - - `app.services.altcoin_screener` - - `app.services.sentiment_monitor` - - `app.services.review_engine` + - 统一通过 `python -m app.cli ...` 串行调度任务 ## 7. 测试与验证建议 @@ -210,7 +212,7 @@ python3 scripts/validate_signal_recency.py - 配置读取/兼容:`app/config/config_loader.py` - 状态口径:`app/core/opportunity_lifecycle.py` 或 `app/db/altcoin_db.py` - DB 表结构/查询:`app/db/altcoin_db.py` / `app/db/auth_db.py` -- API 契约:`app/web/web_server.py` +- API 契约:优先对应 `app/web/routes_*.py` - 页面壳和交互:`static/*.html` ### 8.2 SQLite 相关约束 @@ -254,17 +256,17 @@ python3 scripts/validate_signal_recency.py - 当前目录 **不是 git 仓库根目录**,`git status` 会失败;如果后续需要版本管理,请先确认真正的 Git 根目录或重新初始化。 - [DESIGN.md](/Users/aaron/Desktop/code/alphax-docker/DESIGN.md) 当前内容更像一份品牌/样式 YAML,不是这个项目的系统设计文档,阅读时不要误判。 - 根目录存在一些临时/非核心文件,例如 `.tmp_patch_tp1.py`、`.tmp_strategy_v2_marker.txt`、`=`,后续开发前建议先确认这些文件是否仍有保留价值。 -- `app/web/web_server.py` 和 `app/db/altcoin_db.py` 都已经非常大,后续新增功能应尽量避免继续把复杂度集中到这两个文件。 +- `app/db/altcoin_db.py` 仍然很大,后续新增 DB 查询应优先放到 `recommendation_queries.py`、`analytics.py`、`review_queries.py` 等分组模块。 ## 10. 推荐的后续重构方向 后续若继续开发,建议优先考虑这几个方向: -1. 把 `app/web/web_server.py` 按认证、推荐、策略、管理端拆分路由模块。 -2. 把 `app/db/altcoin_db.py` 拆成 schema/init、recommendation、review、analytics、admin 查询几个子模块。 -3. 为 `rules.yaml` 建立更明确的 schema 校验,避免配置漂移。 -4. 给核心脚本增加更稳定的 CLI 参数入口,而不是依赖脚本内默认行为。 -5. 梳理推送链路,把“是否推送”的判断和“推送内容生成”进一步解耦。 +1. 继续把剩余页面/内容能力从 `app/web` 拆成更细的 service 或 data provider,而不是继续把查询塞回路由层。 +2. 继续把 `app/db/altcoin_db.py` 的真实实现迁出到 schema/init、recommendation、review、analytics、admin 分组模块。 +3. 把 `rules.yaml` 的 schema 校验从“顶层结构校验”推进到“关键子字段校验”。 +4. 让 Docker、文档、测试样例全面收敛到 `python -m app.cli ...` 入口。 +5. 继续梳理推送链路,把“是否推送”的判断、推送内容组装、通道发送彻底分层。 ## 11. 给后续 Agent 的工作方式建议 diff --git a/app/cli.py b/app/cli.py new file mode 100644 index 0000000..2d09c80 --- /dev/null +++ b/app/cli.py @@ -0,0 +1,78 @@ +"""Unified CLI entrypoint for AlphaX jobs.""" + +import argparse + +from app.services import altcoin_confirm, altcoin_screener, event_driven_screener, price_tracker, review_engine, sentiment_monitor + + +def build_parser(): + parser = argparse.ArgumentParser(description="AlphaX unified CLI") + subparsers = parser.add_subparsers(dest="command", required=True) + + screener = subparsers.add_parser("screener", help="运行粗筛/细筛") + screener.add_argument("--compact", action="store_true", help="输出紧凑 JSON") + + confirm = subparsers.add_parser("confirm", help="运行确认流程") + confirm.add_argument("--compact", action="store_true", help="输出紧凑 JSON") + + tracker = subparsers.add_parser("tracker", help="运行价格跟踪") + + review = subparsers.add_parser("review", help="运行复盘") + review.add_argument("--compact", action="store_true", help="输出紧凑 JSON") + review.add_argument("--no-push", action="store_true", help="只运行复盘,不发飞书") + + event = subparsers.add_parser("event", help="运行事件驱动筛选") + event.add_argument("--no-process-existing", action="store_true", help="只处理本轮新采集事件") + + sentiment = subparsers.add_parser("sentiment", help="运行舆情任务") + sentiment.add_argument("--collect", action="store_true", help="采集并存储") + sentiment.add_argument("--check", action="store_true", help="输出舆情异动") + sentiment.add_argument("--scores", action="store_true", help="输出评分") + + return parser + + +def main(): + parser = build_parser() + args = parser.parse_args() + + if args.command == "screener": + return altcoin_screener.main(compact=args.compact) + if args.command == "confirm": + return altcoin_confirm.main(compact=args.compact) + if args.command == "tracker": + return price_tracker.main() + if args.command == "review": + return review_engine.run_review(push_enabled=not args.no_push, compact=args.compact) + if args.command == "event": + result = event_driven_screener.run_once(process_existing=not args.no_process_existing) + print(event_driven_screener.json.dumps(result, ensure_ascii=False, indent=2, default=str)) + return result + if args.command == "sentiment": + if args.collect: + result = sentiment_monitor.collect_and_store() + print(sentiment_monitor.json.dumps(result, ensure_ascii=False)) + return result + if args.scores: + result = sentiment_monitor.get_sentiment_scores() + print(sentiment_monitor.json.dumps(result, ensure_ascii=False, indent=2)) + return result + holdings = sentiment_monitor.get_active_holdings() + alerts = sentiment_monitor.get_sentiment_alert(holdings=holdings) + if args.check: + print(sentiment_monitor.json.dumps(alerts, ensure_ascii=False, indent=2)) + return alerts + result = { + "alerts": alerts, + "sentiment_scores": sentiment_monitor.get_sentiment_scores(), + "holdings_count": len(holdings), + "check_time": sentiment_monitor.datetime.now().isoformat(), + } + print(sentiment_monitor.json.dumps(result, ensure_ascii=False, indent=2)) + return result + + parser.error(f"unknown command: {args.command}") + + +if __name__ == "__main__": + main() diff --git a/app/config/config_loader.py b/app/config/config_loader.py index 7928fb0..d6e0b41 100644 --- a/app/config/config_loader.py +++ b/app/config/config_loader.py @@ -9,6 +9,7 @@ from pathlib import Path import yaml +from app.config.rules_schema import validate_rules_payload REPO_ROOT = Path(__file__).resolve().parents[2] RULES_PATH = str(REPO_ROOT / "rules.yaml") @@ -34,7 +35,7 @@ def load_rules(force_reload=False): return _cache with open(RULES_PATH, "r", encoding="utf-8") as f: - _cache = yaml.safe_load(f) or {} + _cache = validate_rules_payload(yaml.safe_load(f) or {}) _cache_mtime = mtime return _cache diff --git a/app/config/rules_schema.py b/app/config/rules_schema.py new file mode 100644 index 0000000..8c5743e --- /dev/null +++ b/app/config/rules_schema.py @@ -0,0 +1,34 @@ +"""Schema validation for rules.yaml. + +This is intentionally permissive inside each section, but strict about top-level +shape and value container types so config drift fails early with a clear error. +""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class DictSection(BaseModel): + model_config = ConfigDict(extra="allow") + + +class RulesSchema(BaseModel): + model_config = ConfigDict(extra="allow") + + strategy: DictSection = Field(default_factory=DictSection) + pa_engine: DictSection = Field(default_factory=DictSection) + screener: DictSection = Field(default_factory=DictSection) + confirm: DictSection = Field(default_factory=DictSection) + tracker: DictSection = Field(default_factory=DictSection) + signal_weights: dict[str, float | int] = Field(default_factory=dict) + sentiment: DictSection = Field(default_factory=DictSection) + review: DictSection = Field(default_factory=DictSection) + reverse_analysis: DictSection = Field(default_factory=DictSection) + event_driven: DictSection = Field(default_factory=DictSection) + meta: DictSection = Field(default_factory=DictSection) + learned_rules: list[dict[str, Any]] = Field(default_factory=list) + + +def validate_rules_payload(payload: dict[str, Any]) -> dict[str, Any]: + return RulesSchema.model_validate(payload or {}).model_dump() diff --git a/app/db/admin_queries.py b/app/db/admin_queries.py new file mode 100644 index 0000000..e3023eb --- /dev/null +++ b/app/db/admin_queries.py @@ -0,0 +1,23 @@ +"""Admin-facing DB facade. + +Current admin queries mostly live in auth_db; this module groups strategy-admin reads +that are consumed by web/admin surfaces and keeps callers off the giant altcoin_db module. +""" + +from app.db.review_queries import ( + backfill_strategy_failure_patterns, + generate_candidates_from_review_history, + get_strategy_failure_patterns, + get_strategy_rule_candidates, + refresh_strategy_candidate_performance, + dry_run_strategy_candidate_performance, +) + +__all__ = [ + "backfill_strategy_failure_patterns", + "dry_run_strategy_candidate_performance", + "generate_candidates_from_review_history", + "get_strategy_failure_patterns", + "get_strategy_rule_candidates", + "refresh_strategy_candidate_performance", +] diff --git a/app/db/altcoin_db.py b/app/db/altcoin_db.py index fc0af00..121aab3 100644 --- a/app/db/altcoin_db.py +++ b/app/db/altcoin_db.py @@ -595,8 +595,9 @@ def create_recommendation(symbol, rec_state, rec_score, entry_price, stop_loss, tp1, tp2, sector, signals, is_meme, direction, current_price, max_price, min_price, last_track_time, entry_plan_json, force_reason, base_state, sector_signal_count, - market_context_json, derivatives_context_json, sector_context_json, strategy_version, - action_status, execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason) + market_context_json, derivatives_context_json, sector_context_json, + action_status, execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason, + strategy_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( symbol, now, rec_state, rec_score_pct, entry_price, @@ -611,9 +612,9 @@ def create_recommendation(symbol, rec_state, rec_score, entry_price, json.dumps(market_context or {}, ensure_ascii=False), json.dumps(derivatives_context or {}, ensure_ascii=False), json.dumps(sector_context or {}, ensure_ascii=False), - strategy_version, incoming_action, incoming_exec, incoming_bucket, incoming_lifecycle, incoming_triggered, incoming_reason, + strategy_version, )) rec_id = cursor.lastrowid conn.commit() @@ -1482,127 +1483,16 @@ def get_active_recommendations_deduped(actionable_only=True, version="", hours=0 def get_all_recommendations(limit=50, decision_only=False, version="", offset=0, with_meta=False): - """获取推荐列表。 + """兼容导出:推荐列表查询已迁移到 analytics 模块。""" + from app.db.analytics import get_all_recommendations as _get_all_recommendations - 默认保持旧行为:返回数组。with_meta=True 时返回分页对象,供前端历史列表按页加载。 - decision_only=True 时按 symbol 去重,口径与历史推荐页一致:真实止盈/止损 + 复盘口径已兑现/失效样本。 - """ - conn = get_conn() - version = str(version or "").strip() - try: - limit = max(1, min(int(limit or 50), 500)) - except Exception: - limit = 50 - try: - offset = max(0, int(offset or 0)) - except Exception: - offset = 0 - - result_where = """(status IN ('hit_tp1', 'hit_tp2', 'stopped_out') - OR (COALESCE(max_pnl_pct, 0) >= 5) - OR (COALESCE(pnl_pct, 0) <= -3 OR COALESCE(max_drawdown_pct, 0) <= -5))""" - version_where = " AND strategy_version=?" if version else "" - params = [version] if version else [] - - total = None - summary = None - version_counts = [] - - if decision_only: - if with_meta: - total = conn.execute(""" - SELECT COUNT(*) FROM ( - SELECT symbol - FROM recommendation - WHERE """ + result_where + version_where + """ - GROUP BY symbol - ) - """, tuple(params)).fetchone()[0] - - summary_row = conn.execute(""" - SELECT - COUNT(*) AS total, - SUM(CASE WHEN status IN ('hit_tp1','hit_tp2') OR COALESCE(max_pnl_pct,0) >= 5 THEN 1 ELSE 0 END) AS success_count, - SUM(CASE WHEN status='stopped_out' OR COALESCE(pnl_pct,0) <= -3 OR COALESCE(max_drawdown_pct,0) <= -5 THEN 1 ELSE 0 END) AS failure_count, - SUM(CASE WHEN status IN ('hit_tp1','hit_tp2') OR COALESCE(max_pnl_pct,0) >= 5 THEN COALESCE(NULLIF(max_pnl_pct,0), pnl_pct, 0) - WHEN status='stopped_out' OR COALESCE(pnl_pct,0) <= -3 OR COALESCE(max_drawdown_pct,0) <= -5 THEN COALESCE(pnl_pct,0) - ELSE 0 END) AS total_pnl, - MAX(CASE WHEN status IN ('hit_tp1','hit_tp2') OR COALESCE(max_pnl_pct,0) >= 5 THEN COALESCE(NULLIF(max_pnl_pct,0), pnl_pct, 0) - WHEN status='stopped_out' OR COALESCE(pnl_pct,0) <= -3 OR COALESCE(max_drawdown_pct,0) <= -5 THEN COALESCE(pnl_pct,0) - ELSE 0 END) AS best_pnl, - AVG(CASE WHEN status='stopped_out' OR COALESCE(pnl_pct,0) <= -3 OR COALESCE(max_drawdown_pct,0) <= -5 THEN COALESCE(pnl_pct,0) END) AS avg_failure_pnl - FROM ( - SELECT r.* - FROM recommendation r - JOIN ( - SELECT symbol, MAX(id) AS max_id - FROM recommendation - WHERE """ + result_where + version_where + """ - GROUP BY symbol - ) latest ON latest.max_id = r.id - ) - """, tuple(params)).fetchone() - summary = dict(summary_row) if summary_row else {} - - vc_rows = conn.execute(""" - SELECT COALESCE(r.strategy_version, '') AS version, COUNT(*) AS count - FROM recommendation r - JOIN ( - SELECT symbol, MAX(id) AS max_id - FROM recommendation - WHERE """ + result_where + """ - GROUP BY symbol - ) latest ON latest.max_id = r.id - WHERE COALESCE(r.strategy_version, '') != '' - GROUP BY r.strategy_version - """).fetchall() - version_counts = [{"version": row["version"], "count": row["count"]} for row in vc_rows] - - rows = conn.execute(""" - SELECT r.*, - lpc.price AS latest_cache_price, - lpc.updated_at AS latest_cache_updated_at - FROM recommendation r - LEFT JOIN latest_price_cache lpc ON lpc.symbol = r.symbol - JOIN ( - SELECT symbol, MAX(id) AS max_id - FROM recommendation - WHERE """ + result_where + version_where + """ - GROUP BY symbol - ) latest ON latest.max_id = r.id - ORDER BY r.rec_time DESC LIMIT ? OFFSET ? - """, tuple(params + [limit, offset])).fetchall() - else: - where = "WHERE strategy_version=?" if version else "" - if with_meta: - total = conn.execute("SELECT COUNT(*) FROM recommendation " + where, tuple(params)).fetchone()[0] - rows = conn.execute(""" - SELECT * FROM recommendation - """ + where + """ - ORDER BY rec_time DESC LIMIT ? OFFSET ? - """, tuple(params + [limit, offset])).fetchall() - conn.close() - - result = [] - for row in rows: - item = dict(row) - rec_result, rec_result_label = _classify_recommendation_result(item) - item["recommendation_result"] = rec_result - item["recommendation_result_label"] = rec_result_label - _derive_execution_fields(item) - result.append(item) - - if not with_meta: - return result - return { - "items": result, - "total": int(total or 0), - "limit": limit, - "offset": offset, - "has_more": offset + len(result) < int(total or 0), - "summary": summary or {}, - "version_counts": version_counts, - } + return _get_all_recommendations( + limit=limit, + decision_only=decision_only, + version=version, + offset=offset, + with_meta=with_meta, + ) def get_screening_history(hours=24, limit=100): @@ -1618,369 +1508,10 @@ def get_screening_history(hours=24, limit=100): def get_stats(): - """获取统计数据:胜率、平均盈亏、实时收益、推荐成败概览、排行榜、净值曲线与生命周期""" - conn = get_conn() + """兼容导出:统计聚合已迁移到 analytics 模块。""" + from app.db.analytics import get_stats as _get_stats - all_rows = conn.execute("SELECT * FROM recommendation ORDER BY rec_time DESC").fetchall() - raw_active_rows = conn.execute("SELECT * FROM recommendation WHERE status='active' AND COALESCE(display_bucket,'watch_pool') != 'history' ORDER BY rec_time DESC").fetchall() - raw_active_dedup_rows = conn.execute(""" - SELECT r.* - FROM recommendation r - JOIN ( - SELECT symbol, MAX(id) AS max_id - FROM recommendation - WHERE status='active' AND COALESCE(display_bucket,'watch_pool') != 'history' - GROUP BY symbol - ) latest ON latest.max_id = r.id - ORDER BY r.rec_time DESC - """).fetchall() - - total_count = len(all_rows) - all_dedup_rows = conn.execute(""" - SELECT r.* - FROM recommendation r - JOIN ( - SELECT symbol, MAX(id) AS max_id - FROM recommendation GROUP BY symbol - ) latest ON latest.max_id = r.id - ORDER BY r.rec_time DESC - """).fetchall() - raw_active_count = len(raw_active_rows) - now = datetime.now() - - def classify_recommendation(row): - result, _ = _classify_recommendation_result(dict(row)) - return result - - def success_tier(row): - max_pnl_pct = row["max_pnl_pct"] or 0 - if max_pnl_pct >= 20: - return "big" - if max_pnl_pct >= 10: - return "medium" - if max_pnl_pct >= 5: - return "small" - return "none" - - def lifecycle_stage(row): - action_status = row["action_status"] or "持有" - result = classify_recommendation(row) - if result == "success": - return "已验证成功" - if result == "failed": - return "已验证失败" - if action_status in ("衰减", "反转"): - return "进入衰减" - if action_status in ("可即刻买入", "等回踩"): - return "等待入场" - return "持仓观察" - - def safe_hours_between(start_text, end_dt): - try: - start_dt = datetime.fromisoformat(start_text) - return round((end_dt - start_dt).total_seconds() / 3600, 1) - except Exception: - return None - - def compact_item(row): - item = dict(row) - rec_result, rec_result_label = _classify_recommendation_result(item) - item["recommendation_result"] = rec_result - item["recommendation_result_label"] = rec_result_label - derived = _derive_execution_fields(item) - hold_hours = safe_hours_between(row["rec_time"], now) - last_track_delay = safe_hours_between(row["last_track_time"], now) if row["last_track_time"] else None - return { - "symbol": row["symbol"], - "rec_time": row["rec_time"], - "entry_price": row["entry_price"], - "current_price": row["current_price"], - "pnl_pct": row["pnl_pct"] or 0, - "max_pnl_pct": row["max_pnl_pct"] or 0, - "max_drawdown_pct": row["max_drawdown_pct"] or 0, - "action_status": row["action_status"] or "持有", - "initial_action": derived["initial_action"], - "execution_status": derived["execution_status"], - "execution_label": derived["execution_label"], - "execution_reason": derived["execution_reason"], - "recommendation_result": classify_recommendation(row), - "success_tier": success_tier(row), - "lifecycle_stage": lifecycle_stage(row), - "hold_hours": hold_hours, - "track_delay_hours": last_track_delay, - "market_context": derived["market_context"], - "derivatives_context": derived["derivatives_context"], - "sector_context": derived["sector_context"], - } - - active_rows = [] - active_dedup_rows = [] - for row in raw_active_rows: - item = dict(row) - rec_result, rec_result_label = _classify_recommendation_result(item) - item["recommendation_result"] = rec_result - item["recommendation_result_label"] = rec_result_label - derived = _derive_execution_fields(item) - if _is_actionable_execution_status(derived.get("execution_status")): - active_rows.append(row) - for row in raw_active_dedup_rows: - item = dict(row) - rec_result, rec_result_label = _classify_recommendation_result(item) - item["recommendation_result"] = rec_result - item["recommendation_result_label"] = rec_result_label - derived = _derive_execution_fields(item) - if _is_actionable_execution_status(derived.get("execution_status")): - active_dedup_rows.append(row) - - active_count = len(active_dedup_rows) - success_count = 0 - failed_count = 0 - pending_count = 0 - closed_count = 0 - win_count = 0 - closed_pnl_sum = 0 - realized_count = 0 - realized_pnl_sum = 0 - success_tier_counts = {"small": 0, "medium": 0, "big": 0} - - # success/failed 计数:只统计已完结记录,按symbol去重(匹配历史推荐tab) - closed_dedup_rows = conn.execute(""" - SELECT r.* - FROM recommendation r - JOIN ( - SELECT symbol, MAX(id) AS max_id - FROM recommendation - WHERE status IN ('hit_tp1', 'hit_tp2', 'stopped_out') - GROUP BY symbol - ) latest ON latest.max_id = r.id - ORDER BY r.rec_time DESC - """).fetchall() - - for row in closed_dedup_rows: - status = row["status"] - if status in ("hit_tp1", "hit_tp2"): - success_count += 1 - tier = success_tier(row) - if tier in success_tier_counts: - success_tier_counts[tier] += 1 - elif status == "stopped_out": - failed_count += 1 - else: - pending_count += 1 - - if status in ("hit_tp1", "hit_tp2", "stopped_out", "expired"): - closed_count += 1 - if (row["pnl_pct"] or 0) > 0: - win_count += 1 - - # 比率指标(成功率/均盈亏):复用 closed_dedup_rows - closed_pnl_sum = sum((r["pnl_pct"] or 0) for r in closed_dedup_rows) - realized_dedup = [r for r in closed_dedup_rows if r["status"] in ("hit_tp1", "hit_tp2", "stopped_out")] - realized_count = len(realized_dedup) - realized_pnl_sum = sum((r["pnl_pct"] or 0) for r in realized_dedup) - - # 执行状态分类统计(buy_now / wait_pullback / observe / completed / invalid) - exec_buy_now = 0 - exec_wait = 0 - exec_observe = 0 - for row in raw_active_dedup_rows: - item = dict(row) - rec_result, rec_result_label = _classify_recommendation_result(item) - item["recommendation_result"] = rec_result - item["recommendation_result_label"] = rec_result_label - derived = _derive_execution_fields(item) - es = derived.get("execution_status", "") - if es == "buy_now": - exec_buy_now += 1 - elif es == "wait_pullback": - exec_wait += 1 - elif es == "observe": - exec_observe += 1 - - # 已执行收益统计:只算真正可即刻买入/已触发交易的样本;等回踩计划和观察不计收益。 - executed_active_dedup_rows = [r for r in active_dedup_rows if _is_executed_trade(dict(r))] - held_rows = executed_active_dedup_rows - held_count = len(held_rows) - held_pnl_avg = round(sum((r["pnl_pct"] or 0) for r in held_rows) / held_count, 2) if held_count else 0 - held_win_count = sum(1 for r in held_rows if (r["pnl_pct"] or 0) > 0) - held_win_rate = round(held_win_count / held_count * 100, 1) if held_count else 0 - active_pnl_sum = round(sum((r["pnl_pct"] or 0) for r in executed_active_dedup_rows), 2) - active_avg_pnl = round(active_pnl_sum / len(executed_active_dedup_rows), 2) if executed_active_dedup_rows else 0 - active_max_pnl = round(max([(r["pnl_pct"] or 0) for r in executed_active_dedup_rows], default=0), 2) - active_min_pnl = round(min([(r["pnl_pct"] or 0) for r in executed_active_dedup_rows], default=0), 2) - active_success_count = sum(1 for r in executed_active_dedup_rows if classify_recommendation(r) == "success") - active_failed_count = sum(1 for r in executed_active_dedup_rows if classify_recommendation(r) == "failed") - active_pending_count = sum(1 for r in executed_active_dedup_rows if classify_recommendation(r) == "pending") - - top_gainer = compact_item(max(executed_active_dedup_rows, key=lambda r: r["pnl_pct"] or -9999)) if executed_active_dedup_rows else None - top_loser = compact_item(min(executed_active_dedup_rows, key=lambda r: r["pnl_pct"] or 9999)) if executed_active_dedup_rows else None - biggest_explosion = compact_item(max(executed_active_dedup_rows, key=lambda r: r["max_pnl_pct"] or -9999)) if executed_active_dedup_rows else None - highest_risk = compact_item(min(executed_active_dedup_rows, key=lambda r: r["max_drawdown_pct"] or 9999)) if executed_active_dedup_rows else None - - lifecycle_items = [compact_item(r) for r in executed_active_dedup_rows] - longest_holding = max(lifecycle_items, key=lambda x: x.get("hold_hours") or -1) if lifecycle_items else None - fastest_winner_candidates = [x for x in lifecycle_items if x.get("recommendation_result") == "success"] - fastest_winner = min(fastest_winner_candidates, key=lambda x: x.get("hold_hours") or 999999) if fastest_winner_candidates else None - decay_candidates = [x for x in lifecycle_items if x.get("lifecycle_stage") == "进入衰减"] - decay_watch = decay_candidates[0] if decay_candidates else None - - points_24h = [] - rows_24h = conn.execute(""" - SELECT substr(track_time, 1, 13) || ':00:00' AS bucket, AVG(pnl_pct) AS avg_pnl, COUNT(*) AS sample_count - FROM price_tracking - WHERE julianday(?) - julianday(track_time) <= 1.0 - GROUP BY bucket - ORDER BY bucket ASC - """, (now.isoformat(),)).fetchall() - for row in rows_24h: - points_24h.append({ - "time": row["bucket"], - "avg_pnl": round(row["avg_pnl"] or 0, 2), - "sample_count": row["sample_count"] or 0, - }) - - points_7d = [] - rows_7d = conn.execute(""" - SELECT substr(track_time, 1, 10) AS bucket, AVG(pnl_pct) AS avg_pnl, COUNT(*) AS sample_count - FROM price_tracking - WHERE julianday(?) - julianday(track_time) <= 7.0 - GROUP BY bucket - ORDER BY bucket ASC - """, (now.isoformat(),)).fetchall() - for row in rows_7d: - points_7d.append({ - "time": row["bucket"], - "avg_pnl": round(row["avg_pnl"] or 0, 2), - "sample_count": row["sample_count"] or 0, - }) - - recommendation_success_rate = round(success_count / (success_count + failed_count) * 100, 1) if (success_count + failed_count) else 0 - avg_pnl_pct = round(realized_pnl_sum / realized_count, 2) if realized_count else 0 - - actionable_contexts = [] - for row in active_dedup_rows: - derived = _derive_execution_fields(dict(row)) - actionable_contexts.append({ - "market": derived.get("market_context") or {}, - "derivatives": derived.get("derivatives_context") or {}, - "sector": derived.get("sector_context") or {}, - }) - - def avg_from_context(group_key, field): - values = [] - for ctx in actionable_contexts: - value = (ctx.get(group_key) or {}).get(field) - if isinstance(value, (int, float)): - values.append(float(value)) - if not values: - return 0 - avg = sum(values) / len(values) - if abs(avg) < 0.01: - return round(avg, 3) - return round(avg, 1) - - hot_sector_counter = {} - for ctx in actionable_contexts: - sector_ctx = ctx.get("sector") or {} - for sector in sector_ctx.get("hot_sectors") or []: - hot_sector_counter[sector] = hot_sector_counter.get(sector, 0) + 1 - - market_context_overview = { - "actionable_sample_count": len(actionable_contexts), - "avg_turnover_acceleration_1h": avg_from_context("market", "turnover_acceleration_1h"), - "avg_turnover_acceleration_4h": avg_from_context("market", "turnover_acceleration_4h"), - "avg_volume_24h": avg_from_context("market", "volume_24h"), - "avg_funding_rate": avg_from_context("derivatives", "funding_rate"), - "avg_top_trader_long_pct": avg_from_context("derivatives", "top_trader_long_pct"), - "avg_top_trader_long_short_ratio": avg_from_context("derivatives", "top_trader_long_short_ratio"), - "hot_sector_count": len(hot_sector_counter), - "top_hot_sectors": [ - {"sector": sector, "count": count} - for sector, count in sorted(hot_sector_counter.items(), key=lambda item: (-item[1], item[0]))[:5] - ], - } - - conn.close() - return { - "total_recommendations": total_count, - "active_count": active_count, - "raw_active_count": raw_active_count, - "closed_count": closed_count, - "win_count": win_count, - "win_rate": round(win_count / closed_count * 100, 1) if closed_count else 0, - "avg_pnl_pct": avg_pnl_pct, - "success_count": success_count, - "failed_count": failed_count, - "pending_count": pending_count, - "recommendation_success_rate": recommendation_success_rate, - "active_pnl_sum": active_pnl_sum, - "active_avg_pnl": active_avg_pnl, - "active_max_pnl": active_max_pnl, - "active_min_pnl": active_min_pnl, - "active_success_count": active_success_count, - "active_failed_count": active_failed_count, - "active_pending_count": active_pending_count, - "live_overview": { - "actionable_count": active_count, - "executed_trade_count": len(executed_active_dedup_rows), - "executed_pnl_sum": active_pnl_sum, - "executed_avg_pnl": active_avg_pnl, - # 兼容旧前端字段名,但语义已收口为“已执行交易收益”。 - "actionable_pnl_sum": active_pnl_sum, - "actionable_avg_pnl": active_avg_pnl, - "buy_now_count": exec_buy_now, - "wait_pullback_count": exec_wait, - "observe_count": exec_observe, - "held_count": held_count, - "held_pnl_avg": held_pnl_avg, - "held_win_rate": held_win_rate, - "actionable_success_count": active_success_count, - "actionable_failed_count": active_failed_count, - "actionable_pending_count": active_pending_count, - "raw_active_count": raw_active_count, - }, - "history_overview": { - "success_count": success_count, - "failed_count": failed_count, - "recommendation_success_rate": recommendation_success_rate, - "avg_pnl_pct": avg_pnl_pct, - "realized_count": realized_count, - }, - "market_context_overview": market_context_overview, - "success_tier_counts": success_tier_counts, - "leaderboard": { - "top_gainer": top_gainer, - "top_loser": top_loser, - "biggest_explosion": biggest_explosion, - "highest_risk": highest_risk, - }, - "equity_curve": { - "last_24h": points_24h, - "last_7d": points_7d, - }, - "lifecycle_summary": { - "longest_holding": longest_holding, - "fastest_winner": fastest_winner, - "decay_watch": decay_watch, - }, - "result_definition": { - "success": "仅统计实际命中止盈的推荐:status=hit_tp1 或 hit_tp2", - "failed": "仅统计实际触发止损的推荐:status=stopped_out", - "pending": "其余样本仅作为未兑现/观察中处理,不在顶部历史统计单独展示", - "avg_pnl_pct": "历史均盈亏仅基于真实兑现样本计算:hit_tp1 / hit_tp2 / stopped_out", - "live_pnl": "实时收益只统计已经执行/触发入场的交易;等回踩计划和观察信号不纳入收益" - }, - "success_tier_definition": { - "small": "小成功:最大涨幅 5%~10%", - "medium": "中成功:最大涨幅 10%~20%", - "big": "大成功:最大涨幅 >=20%" - }, - "lifecycle_definition": { - "hold_hours": "从推荐发出到当前的持续小时数", - "track_delay_hours": "距离最近一次价格跟踪的延迟小时数", - "lifecycle_stage": "等待入场 / 持仓观察 / 进入衰减 / 已验证成功 / 已验证失败" - } - } + return _get_stats() # ==================== 原有状态跟踪(兼容) ==================== @@ -2170,36 +1701,14 @@ def record_missed_explosion(symbol, price_at_detect, price_before, gain_pct, def get_review_stats(): - """获取复盘统计概览。 - - review_log 表本身已记录了每条复盘对应的 recommendation, - 不需要再用 strategy_revision_started_at 做二次过滤。 - 页面展示应保留全部复盘数据,让用户看到累积效果。 - """ - conn = get_conn() - revision_started_at = "" - try: - from app.config.config_loader import get_meta - meta = get_meta() or {} - revision_started_at = (meta.get("strategy_revision_started_at") or "").strip() - except Exception: - revision_started_at = "" + """兼容导出:复盘统计已迁移到 analytics 模块。""" + from app.db.analytics import get_review_stats as _get_review_stats - # review_log 不按 revision_started_at 过滤,展示全部数据 - reviews = conn.execute("SELECT * FROM review_log ORDER BY review_time DESC").fetchall() - # missed_explosions 也不按 revision_started_at 过滤,展示全部 - missed = conn.execute("SELECT * FROM missed_explosions ORDER BY detect_time DESC LIMIT 20").fetchall() - - signals = conn.execute("SELECT * FROM signal_performance ORDER BY hit_rate DESC").fetchall() - conn.close() - return { - "reviews": [dict(r) for r in reviews], - "signal_performance": [dict(s) for s in signals], - "missed_explosions": [dict(m) for m in missed], - "iteration_logs": get_strategy_iteration_logs(limit=30), - "iteration_summary": get_strategy_iteration_summary(days=30), - "strategy_revision_started_at": revision_started_at, - } + return _get_review_stats( + conn_provider=get_conn, + iteration_logs_getter=get_strategy_iteration_logs, + iteration_summary_getter=get_strategy_iteration_summary, + ) def _loads_json_field(value, fallback): @@ -2217,81 +1726,41 @@ def log_strategy_iteration(run_date=None, trigger_source="daily_review", title=" success_analysis=None, failure_analysis=None, candidate_rules=None, release_decision="", release_reason="", confidence_level="", promotion_state="research_only"): - """记录一次策略复盘/迭代日志""" - conn = get_conn() - now = datetime.now().isoformat() - run_date = run_date or now[:10] - conn.execute(""" - INSERT INTO strategy_iteration_log ( - run_date, created_at, trigger_source, title, summary, - findings_json, problems_json, actions_json, changed_rules_json, - metrics_json, related_symbols_json, config_diff_json, effect_summary_json, - pollution_summary_json, - strategy_version, version_change_summary, - success_analysis_json, failure_analysis_json, candidate_rules_json, - release_decision, release_reason, confidence_level, promotion_state - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - run_date, - now, - trigger_source or "daily_review", - title or "未命名迭代", - summary or "", - json.dumps(findings or [], ensure_ascii=False, default=str), - json.dumps(problems or [], ensure_ascii=False, default=str), - json.dumps(actions or [], ensure_ascii=False, default=str), - json.dumps(changed_rules or [], ensure_ascii=False, default=str), - json.dumps(metrics or {}, ensure_ascii=False, default=str), - json.dumps(related_symbols or [], ensure_ascii=False, default=str), - json.dumps(config_diff or {}, ensure_ascii=False, default=str), - json.dumps(effect_summary or {}, ensure_ascii=False, default=str), - json.dumps(pollution_summary or {}, ensure_ascii=False, default=str), - (strategy_version or "").strip(), - (version_change_summary or "").strip(), - json.dumps(success_analysis or {}, ensure_ascii=False, default=str), - json.dumps(failure_analysis or {}, ensure_ascii=False, default=str), - json.dumps(candidate_rules or [], ensure_ascii=False, default=str), - (release_decision or "").strip(), - (release_reason or "").strip(), - (confidence_level or "").strip(), - (promotion_state or "research_only").strip(), - )) - conn.commit() - conn.close() + """兼容导出:策略迭代写入已迁移到 review_queries 模块。""" + from app.db.review_queries import log_strategy_iteration as _log_strategy_iteration + + return _log_strategy_iteration( + run_date=run_date, + trigger_source=trigger_source, + title=title, + summary=summary, + findings=findings, + problems=problems, + actions=actions, + changed_rules=changed_rules, + metrics=metrics, + related_symbols=related_symbols, + config_diff=config_diff, + effect_summary=effect_summary, + pollution_summary=pollution_summary, + strategy_version=strategy_version, + version_change_summary=version_change_summary, + success_analysis=success_analysis, + failure_analysis=failure_analysis, + candidate_rules=candidate_rules, + release_decision=release_decision, + release_reason=release_reason, + confidence_level=confidence_level, + promotion_state=promotion_state, + conn_provider=get_conn, + ) def get_strategy_iteration_logs(limit=30): - conn = get_conn() - rows = conn.execute(""" - SELECT * FROM strategy_iteration_log - ORDER BY created_at DESC, id DESC - LIMIT ? - """, (limit,)).fetchall() - conn.close() + """兼容导出:策略迭代日志查询已迁移到 review_queries 模块。""" + from app.db.review_queries import get_strategy_iteration_logs as _get_strategy_iteration_logs - result = [] - for row in rows: - item = dict(row) - item["findings"] = _loads_json_field(item.get("findings_json"), []) - item["problems"] = _loads_json_field(item.get("problems_json"), []) - item["actions"] = _loads_json_field(item.get("actions_json"), []) - item["changed_rules"] = _loads_json_field(item.get("changed_rules_json"), []) - item["metrics"] = _loads_json_field(item.get("metrics_json"), {}) - item["related_symbols"] = _loads_json_field(item.get("related_symbols_json"), []) - item["config_diff"] = _loads_json_field(item.get("config_diff_json"), {}) - item["effect_summary"] = _loads_json_field(item.get("effect_summary_json"), {}) - item["pollution_summary"] = _loads_json_field(item.get("pollution_summary_json"), {}) - item["success_analysis"] = _loads_json_field(item.get("success_analysis_json"), {}) - item["failure_analysis"] = _loads_json_field(item.get("failure_analysis_json"), {}) - item["candidate_rules"] = _loads_json_field(item.get("candidate_rules_json"), []) - item["release_decision"] = (item.get("release_decision") or "").strip() - item["release_reason"] = (item.get("release_reason") or "").strip() - item["confidence_level"] = (item.get("confidence_level") or "").strip() - item["promotion_state"] = (item.get("promotion_state") or "research_only").strip() - item["strategy_version"] = (item.get("strategy_version") or "").strip() - item["version_change_summary"] = (item.get("version_change_summary") or "").strip() - result.append(item) - return result + return _get_strategy_iteration_logs(limit=limit, conn_provider=get_conn, json_loader=_loads_json_field) @@ -2937,168 +2406,10 @@ def get_strategy_iteration_dashboard(days=30): } def get_strategy_iteration_summary(days=30): - conn = get_conn() - now_iso = datetime.now().isoformat() - rows = conn.execute(""" - SELECT * FROM strategy_iteration_log - WHERE julianday(?) - julianday(created_at) <= ? - ORDER BY created_at DESC, id DESC - """, (now_iso, days)).fetchall() - rec_rows = conn.execute(""" - SELECT strategy_version, status, pnl_pct, max_pnl_pct, max_drawdown_pct - FROM recommendation - WHERE strategy_version IS NOT NULL AND trim(strategy_version) != '' - """).fetchall() - conn.close() + """兼容导出:策略迭代汇总已迁移到 review_queries 模块。""" + from app.db.review_queries import get_strategy_iteration_summary as _get_strategy_iteration_summary - def classify_recommendation_result(row): - status = row.get("status") or "" - pnl_pct = row.get("pnl_pct") or 0 - max_pnl_pct = row.get("max_pnl_pct") or 0 - max_drawdown_pct = row.get("max_drawdown_pct") or 0 - - if status in ("hit_tp1", "hit_tp2"): - return "success" - if status == "stopped_out": - return "failed" - if status == "expired": - if max_pnl_pct >= 5: - return "success" - if pnl_pct <= -3 or max_drawdown_pct <= -5: - return "failed" - return "pending" - if status == "active": - if max_pnl_pct >= 5: - return "success" - if pnl_pct <= -3 or max_drawdown_pct <= -5: - return "failed" - return "pending" - return "pending" - - version_stats_map = {} - for row in rec_rows: - item = dict(row) - strategy_version = (item.get("strategy_version") or "").strip() - if not strategy_version: - continue - bucket = version_stats_map.setdefault(strategy_version, { - "strategy_version": strategy_version, - "recommendation_count": 0, - "success_count": 0, - "failed_count": 0, - "pending_count": 0, - "pnl_values": [], - }) - bucket["recommendation_count"] += 1 - bucket["pnl_values"].append(float(item.get("pnl_pct") or 0)) - outcome = classify_recommendation_result(item) - if outcome == "success": - bucket["success_count"] += 1 - elif outcome == "failed": - bucket["failed_count"] += 1 - else: - bucket["pending_count"] += 1 - - logs = [] - trigger_counts = {} - changed_rule_count = 0 - unique_days = set() - titles = [] - problem_keywords = {} - total_config_change_count = 0 - hit_rates = [] - avg_pnls = [] - version_changelog = [] - - for row in rows: - item = dict(row) - item["findings"] = _loads_json_field(item.get("findings_json"), []) - item["problems"] = _loads_json_field(item.get("problems_json"), []) - item["actions"] = _loads_json_field(item.get("actions_json"), []) - item["changed_rules"] = _loads_json_field(item.get("changed_rules_json"), []) - item["metrics"] = _loads_json_field(item.get("metrics_json"), {}) - item["related_symbols"] = _loads_json_field(item.get("related_symbols_json"), []) - item["config_diff"] = _loads_json_field(item.get("config_diff_json"), {}) - item["effect_summary"] = _loads_json_field(item.get("effect_summary_json"), {}) - item["pollution_summary"] = _loads_json_field(item.get("pollution_summary_json"), {}) - item["success_analysis"] = _loads_json_field(item.get("success_analysis_json"), {}) - item["failure_analysis"] = _loads_json_field(item.get("failure_analysis_json"), {}) - item["candidate_rules"] = _loads_json_field(item.get("candidate_rules_json"), []) - item["release_decision"] = (item.get("release_decision") or "").strip() - item["release_reason"] = (item.get("release_reason") or "").strip() - item["confidence_level"] = (item.get("confidence_level") or "").strip() - item["promotion_state"] = (item.get("promotion_state") or "research_only").strip() - item["strategy_version"] = (item.get("strategy_version") or "").strip() - item["version_change_summary"] = (item.get("version_change_summary") or "").strip() - logs.append(item) - - unique_days.add(item.get("run_date") or (item.get("created_at") or "")[:10]) - trigger = item.get("trigger_source") or "unknown" - trigger_counts[trigger] = trigger_counts.get(trigger, 0) + 1 - changed_rule_count += len(item.get("changed_rules") or []) - if item.get("title"): - titles.append(item["title"]) - for problem in item.get("problems") or []: - key = str(problem).strip() - if key: - problem_keywords[key] = problem_keywords.get(key, 0) + 1 - - diff = item.get("config_diff") or {} - total_config_change_count += len(diff.get("changed") or []) + len(diff.get("added") or []) + len(diff.get("removed") or []) - - effect = item.get("effect_summary") or {} - if isinstance(effect.get("hit_rate_pct"), (int, float)): - hit_rates.append(effect.get("hit_rate_pct")) - if isinstance(effect.get("avg_pnl"), (int, float)): - avg_pnls.append(effect.get("avg_pnl")) - - if item.get("strategy_version"): - version_changelog.append({ - "strategy_version": item.get("strategy_version"), - "created_at": item.get("created_at"), - "run_date": item.get("run_date"), - "title": item.get("title") or "", - "summary": item.get("summary") or "", - "version_change_summary": item.get("version_change_summary") or "", - "changed_rules_count": len(item.get("changed_rules") or []), - "config_change_count": len(diff.get("changed") or []) + len(diff.get("added") or []) + len(diff.get("removed") or []), - }) - - top_problems = sorted(problem_keywords.items(), key=lambda x: (-x[1], x[0]))[:5] - def _version_sort_key(version): - nums = [int(x) for x in re.findall(r"\d+", str(version or ""))] - return tuple(nums) if nums else (0,) - - version_stats = [] - for strategy_version, bucket in sorted(version_stats_map.items(), key=lambda kv: _version_sort_key(kv[0]), reverse=True): - resolved = bucket["success_count"] + bucket["failed_count"] - version_stats.append({ - "strategy_version": strategy_version, - "recommendation_count": bucket["recommendation_count"], - "success_count": bucket["success_count"], - "failed_count": bucket["failed_count"], - "pending_count": bucket["pending_count"], - "success_rate_pct": round(bucket["success_count"] / resolved * 100, 1) if resolved else 0, - "avg_pnl_pct": round(sum(bucket["pnl_values"]) / len(bucket["pnl_values"]), 2) if bucket["pnl_values"] else 0, - }) - - return { - "days": days, - "total_logs": len(logs), - "unique_run_days": len(unique_days), - "trigger_counts": trigger_counts, - "change_rule_count": changed_rule_count, - "config_change_count": total_config_change_count, - "recent_titles": titles[:8], - "top_problems": [{"problem": k, "count": v} for k, v in top_problems], - "version_stats": version_stats, - "version_changelog": version_changelog[:12], - "effect_overview": { - "avg_hit_rate_pct": round(sum(hit_rates) / len(hit_rates), 1) if hit_rates else 0, - "avg_pnl": round(sum(avg_pnls) / len(avg_pnls), 2) if avg_pnls else 0, - "samples": len(logs), - }, - } + return _get_strategy_iteration_summary(days=days, conn_provider=get_conn, json_loader=_loads_json_field) def log_cron_run(job_name, script_name, run_status, result_status="", started_at="", finished_at="", diff --git a/app/db/analytics.py b/app/db/analytics.py new file mode 100644 index 0000000..ab4ac73 --- /dev/null +++ b/app/db/analytics.py @@ -0,0 +1,669 @@ +"""Analytics-facing DB API grouped by read concerns.""" + +import json +from datetime import datetime + +from app.db.altcoin_db import ( + _classify_recommendation_result, + _derive_execution_fields, + _is_actionable_execution_status, + _is_executed_trade, +) +from app.db.schema import get_conn + + +def get_screening_history(hours=24, limit=100): + """获取最近 N 小时的筛选记录。""" + conn = get_conn() + rows = conn.execute( + """ + SELECT * FROM screening_log + WHERE layer='细筛' AND julianday(?) - julianday(scan_time) < ? + ORDER BY score DESC, scan_time DESC LIMIT ? + """, + (datetime.now().isoformat(), hours / 24.0, limit), + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_all_recommendations(limit=50, decision_only=False, version="", offset=0, with_meta=False): + """获取推荐列表。""" + conn = get_conn() + version = str(version or "").strip() + try: + limit = max(1, min(int(limit or 50), 500)) + except Exception: + limit = 50 + try: + offset = max(0, int(offset or 0)) + except Exception: + offset = 0 + + result_where = """(status IN ('hit_tp1', 'hit_tp2', 'stopped_out') + OR (COALESCE(max_pnl_pct, 0) >= 5) + OR (COALESCE(pnl_pct, 0) <= -3 OR COALESCE(max_drawdown_pct, 0) <= -5))""" + version_where = " AND strategy_version=?" if version else "" + params = [version] if version else [] + + total = None + summary = None + version_counts = [] + + if decision_only: + if with_meta: + total = conn.execute( + """ + SELECT COUNT(*) FROM ( + SELECT symbol + FROM recommendation + WHERE """ + + result_where + + version_where + + """ + GROUP BY symbol + ) + """, + tuple(params), + ).fetchone()[0] + + summary_row = conn.execute( + """ + SELECT + COUNT(*) AS total, + SUM(CASE WHEN status IN ('hit_tp1','hit_tp2') OR COALESCE(max_pnl_pct,0) >= 5 THEN 1 ELSE 0 END) AS success_count, + SUM(CASE WHEN status='stopped_out' OR COALESCE(pnl_pct,0) <= -3 OR COALESCE(max_drawdown_pct,0) <= -5 THEN 1 ELSE 0 END) AS failure_count, + SUM(CASE WHEN status IN ('hit_tp1','hit_tp2') OR COALESCE(max_pnl_pct,0) >= 5 THEN COALESCE(NULLIF(max_pnl_pct,0), pnl_pct, 0) + WHEN status='stopped_out' OR COALESCE(pnl_pct,0) <= -3 OR COALESCE(max_drawdown_pct,0) <= -5 THEN COALESCE(pnl_pct,0) + ELSE 0 END) AS total_pnl, + MAX(CASE WHEN status IN ('hit_tp1','hit_tp2') OR COALESCE(max_pnl_pct,0) >= 5 THEN COALESCE(NULLIF(max_pnl_pct,0), pnl_pct, 0) + WHEN status='stopped_out' OR COALESCE(pnl_pct,0) <= -3 OR COALESCE(max_drawdown_pct,0) <= -5 THEN COALESCE(pnl_pct,0) + ELSE 0 END) AS best_pnl, + AVG(CASE WHEN status='stopped_out' OR COALESCE(pnl_pct,0) <= -3 OR COALESCE(max_drawdown_pct,0) <= -5 THEN COALESCE(pnl_pct,0) END) AS avg_failure_pnl + FROM ( + SELECT r.* + FROM recommendation r + JOIN ( + SELECT symbol, MAX(id) AS max_id + FROM recommendation + WHERE """ + + result_where + + version_where + + """ + GROUP BY symbol + ) latest ON latest.max_id = r.id + ) + """, + tuple(params), + ).fetchone() + summary = dict(summary_row) if summary_row else {} + + vc_rows = conn.execute( + """ + SELECT COALESCE(r.strategy_version, '') AS version, COUNT(*) AS count + FROM recommendation r + JOIN ( + SELECT symbol, MAX(id) AS max_id + FROM recommendation + WHERE """ + + result_where + + """ + GROUP BY symbol + ) latest ON latest.max_id = r.id + WHERE COALESCE(r.strategy_version, '') != '' + GROUP BY r.strategy_version + """ + ).fetchall() + version_counts = [{"version": row["version"], "count": row["count"]} for row in vc_rows] + + rows = conn.execute( + """ + SELECT r.*, + lpc.price AS latest_cache_price, + lpc.updated_at AS latest_cache_updated_at + FROM recommendation r + LEFT JOIN latest_price_cache lpc ON lpc.symbol = r.symbol + JOIN ( + SELECT symbol, MAX(id) AS max_id + FROM recommendation + WHERE """ + + result_where + + version_where + + """ + GROUP BY symbol + ) latest ON latest.max_id = r.id + ORDER BY r.rec_time DESC LIMIT ? OFFSET ? + """, + tuple(params + [limit, offset]), + ).fetchall() + else: + where = "WHERE strategy_version=?" if version else "" + if with_meta: + total = conn.execute("SELECT COUNT(*) FROM recommendation " + where, tuple(params)).fetchone()[0] + rows = conn.execute( + """ + SELECT * FROM recommendation + """ + + where + + """ + ORDER BY rec_time DESC LIMIT ? OFFSET ? + """, + tuple(params + [limit, offset]), + ).fetchall() + conn.close() + + result = [] + for row in rows: + item = dict(row) + rec_result, rec_result_label = _classify_recommendation_result(item) + item["recommendation_result"] = rec_result + item["recommendation_result_label"] = rec_result_label + _derive_execution_fields(item) + result.append(item) + + if not with_meta: + return result + return { + "items": result, + "total": int(total or 0), + "limit": limit, + "offset": offset, + "has_more": offset + len(result) < int(total or 0), + "summary": summary or {}, + "version_counts": version_counts, + } + + +def get_stats(): + """获取统计数据:胜率、平均盈亏、实时收益、推荐成败概览、排行榜、净值曲线与生命周期""" + conn = get_conn() + + all_rows = conn.execute("SELECT * FROM recommendation ORDER BY rec_time DESC").fetchall() + raw_active_rows = conn.execute("SELECT * FROM recommendation WHERE status='active' AND COALESCE(display_bucket,'watch_pool') != 'history' ORDER BY rec_time DESC").fetchall() + raw_active_dedup_rows = conn.execute(""" + SELECT r.* + FROM recommendation r + JOIN ( + SELECT symbol, MAX(id) AS max_id + FROM recommendation + WHERE status='active' AND COALESCE(display_bucket,'watch_pool') != 'history' + GROUP BY symbol + ) latest ON latest.max_id = r.id + ORDER BY r.rec_time DESC + """).fetchall() + + total_count = len(all_rows) + raw_active_count = len(raw_active_rows) + now = datetime.now() + + def classify_recommendation(row): + result, _ = _classify_recommendation_result(dict(row)) + return result + + def success_tier(row): + max_pnl_pct = row["max_pnl_pct"] or 0 + if max_pnl_pct >= 20: + return "big" + if max_pnl_pct >= 10: + return "medium" + if max_pnl_pct >= 5: + return "small" + return "none" + + def lifecycle_stage(row): + action_status = row["action_status"] or "持有" + result = classify_recommendation(row) + if result == "success": + return "已验证成功" + if result == "failed": + return "已验证失败" + if action_status in ("衰减", "反转"): + return "进入衰减" + if action_status in ("可即刻买入", "等回踩"): + return "等待入场" + return "持仓观察" + + def safe_hours_between(start_text, end_dt): + try: + start_dt = datetime.fromisoformat(start_text) + return round((end_dt - start_dt).total_seconds() / 3600, 1) + except Exception: + return None + + def compact_item(row): + item = dict(row) + rec_result, rec_result_label = _classify_recommendation_result(item) + item["recommendation_result"] = rec_result + item["recommendation_result_label"] = rec_result_label + derived = _derive_execution_fields(item) + hold_hours = safe_hours_between(row["rec_time"], now) + last_track_delay = safe_hours_between(row["last_track_time"], now) if row["last_track_time"] else None + return { + "symbol": row["symbol"], + "rec_time": row["rec_time"], + "entry_price": row["entry_price"], + "current_price": row["current_price"], + "pnl_pct": row["pnl_pct"] or 0, + "max_pnl_pct": row["max_pnl_pct"] or 0, + "max_drawdown_pct": row["max_drawdown_pct"] or 0, + "action_status": row["action_status"] or "持有", + "initial_action": derived["initial_action"], + "execution_status": derived["execution_status"], + "execution_label": derived["execution_label"], + "execution_reason": derived["execution_reason"], + "recommendation_result": classify_recommendation(row), + "success_tier": success_tier(row), + "lifecycle_stage": lifecycle_stage(row), + "hold_hours": hold_hours, + "track_delay_hours": last_track_delay, + "market_context": derived["market_context"], + "derivatives_context": derived["derivatives_context"], + "sector_context": derived["sector_context"], + } + + active_dedup_rows = [] + for row in raw_active_dedup_rows: + item = dict(row) + rec_result, rec_result_label = _classify_recommendation_result(item) + item["recommendation_result"] = rec_result + item["recommendation_result_label"] = rec_result_label + derived = _derive_execution_fields(item) + if _is_actionable_execution_status(derived.get("execution_status")): + active_dedup_rows.append(row) + + active_count = len(active_dedup_rows) + success_count = 0 + failed_count = 0 + pending_count = 0 + closed_count = 0 + win_count = 0 + realized_count = 0 + realized_pnl_sum = 0 + success_tier_counts = {"small": 0, "medium": 0, "big": 0} + + closed_dedup_rows = conn.execute(""" + SELECT r.* + FROM recommendation r + JOIN ( + SELECT symbol, MAX(id) AS max_id + FROM recommendation + WHERE status IN ('hit_tp1', 'hit_tp2', 'stopped_out') + GROUP BY symbol + ) latest ON latest.max_id = r.id + ORDER BY r.rec_time DESC + """).fetchall() + + for row in closed_dedup_rows: + status = row["status"] + if status in ("hit_tp1", "hit_tp2"): + success_count += 1 + tier = success_tier(row) + if tier in success_tier_counts: + success_tier_counts[tier] += 1 + elif status == "stopped_out": + failed_count += 1 + else: + pending_count += 1 + + if status in ("hit_tp1", "hit_tp2", "stopped_out", "expired"): + closed_count += 1 + if (row["pnl_pct"] or 0) > 0: + win_count += 1 + + realized_dedup = [r for r in closed_dedup_rows if r["status"] in ("hit_tp1", "hit_tp2", "stopped_out")] + realized_count = len(realized_dedup) + realized_pnl_sum = sum((r["pnl_pct"] or 0) for r in realized_dedup) + + exec_buy_now = 0 + exec_wait = 0 + exec_observe = 0 + for row in raw_active_dedup_rows: + item = dict(row) + rec_result, rec_result_label = _classify_recommendation_result(item) + item["recommendation_result"] = rec_result + item["recommendation_result_label"] = rec_result_label + derived = _derive_execution_fields(item) + es = derived.get("execution_status", "") + if es == "buy_now": + exec_buy_now += 1 + elif es == "wait_pullback": + exec_wait += 1 + elif es == "observe": + exec_observe += 1 + + executed_active_dedup_rows = [r for r in active_dedup_rows if _is_executed_trade(dict(r))] + held_rows = executed_active_dedup_rows + held_count = len(held_rows) + held_pnl_avg = round(sum((r["pnl_pct"] or 0) for r in held_rows) / held_count, 2) if held_count else 0 + held_win_count = sum(1 for r in held_rows if (r["pnl_pct"] or 0) > 0) + held_win_rate = round(held_win_count / held_count * 100, 1) if held_count else 0 + active_pnl_sum = round(sum((r["pnl_pct"] or 0) for r in executed_active_dedup_rows), 2) + active_avg_pnl = round(active_pnl_sum / len(executed_active_dedup_rows), 2) if executed_active_dedup_rows else 0 + active_max_pnl = round(max([(r["pnl_pct"] or 0) for r in executed_active_dedup_rows], default=0), 2) + active_min_pnl = round(min([(r["pnl_pct"] or 0) for r in executed_active_dedup_rows], default=0), 2) + active_success_count = sum(1 for r in executed_active_dedup_rows if classify_recommendation(r) == "success") + active_failed_count = sum(1 for r in executed_active_dedup_rows if classify_recommendation(r) == "failed") + active_pending_count = sum(1 for r in executed_active_dedup_rows if classify_recommendation(r) == "pending") + + top_gainer = compact_item(max(executed_active_dedup_rows, key=lambda r: r["pnl_pct"] or -9999)) if executed_active_dedup_rows else None + top_loser = compact_item(min(executed_active_dedup_rows, key=lambda r: r["pnl_pct"] or 9999)) if executed_active_dedup_rows else None + biggest_explosion = compact_item(max(executed_active_dedup_rows, key=lambda r: r["max_pnl_pct"] or -9999)) if executed_active_dedup_rows else None + highest_risk = compact_item(min(executed_active_dedup_rows, key=lambda r: r["max_drawdown_pct"] or 9999)) if executed_active_dedup_rows else None + + lifecycle_items = [compact_item(r) for r in executed_active_dedup_rows] + longest_holding = max(lifecycle_items, key=lambda x: x.get("hold_hours") or -1) if lifecycle_items else None + fastest_winner_candidates = [x for x in lifecycle_items if x.get("recommendation_result") == "success"] + fastest_winner = min(fastest_winner_candidates, key=lambda x: x.get("hold_hours") or 999999) if fastest_winner_candidates else None + decay_candidates = [x for x in lifecycle_items if x.get("lifecycle_stage") == "进入衰减"] + decay_watch = decay_candidates[0] if decay_candidates else None + + points_24h = [] + rows_24h = conn.execute(""" + SELECT substr(track_time, 1, 13) || ':00:00' AS bucket, AVG(pnl_pct) AS avg_pnl, COUNT(*) AS sample_count + FROM price_tracking + WHERE julianday(?) - julianday(track_time) <= 1.0 + GROUP BY bucket + ORDER BY bucket ASC + """, (now.isoformat(),)).fetchall() + for row in rows_24h: + points_24h.append({ + "time": row["bucket"], + "avg_pnl": round(row["avg_pnl"] or 0, 2), + "sample_count": row["sample_count"] or 0, + }) + + points_7d = [] + rows_7d = conn.execute(""" + SELECT substr(track_time, 1, 10) AS bucket, AVG(pnl_pct) AS avg_pnl, COUNT(*) AS sample_count + FROM price_tracking + WHERE julianday(?) - julianday(track_time) <= 7.0 + GROUP BY bucket + ORDER BY bucket ASC + """, (now.isoformat(),)).fetchall() + for row in rows_7d: + points_7d.append({ + "time": row["bucket"], + "avg_pnl": round(row["avg_pnl"] or 0, 2), + "sample_count": row["sample_count"] or 0, + }) + + recommendation_success_rate = round(success_count / (success_count + failed_count) * 100, 1) if (success_count + failed_count) else 0 + avg_pnl_pct = round(realized_pnl_sum / realized_count, 2) if realized_count else 0 + + actionable_contexts = [] + for row in active_dedup_rows: + derived = _derive_execution_fields(dict(row)) + actionable_contexts.append({ + "market": derived.get("market_context") or {}, + "derivatives": derived.get("derivatives_context") or {}, + "sector": derived.get("sector_context") or {}, + }) + + def avg_from_context(group_key, field): + values = [] + for ctx in actionable_contexts: + value = (ctx.get(group_key) or {}).get(field) + if isinstance(value, (int, float)): + values.append(float(value)) + if not values: + return 0 + avg = sum(values) / len(values) + if abs(avg) < 0.01: + return round(avg, 3) + return round(avg, 1) + + hot_sector_counter = {} + for ctx in actionable_contexts: + sector_ctx = ctx.get("sector") or {} + for sector in sector_ctx.get("hot_sectors") or []: + hot_sector_counter[sector] = hot_sector_counter.get(sector, 0) + 1 + + market_context_overview = { + "actionable_sample_count": len(actionable_contexts), + "avg_turnover_acceleration_1h": avg_from_context("market", "turnover_acceleration_1h"), + "avg_turnover_acceleration_4h": avg_from_context("market", "turnover_acceleration_4h"), + "avg_volume_24h": avg_from_context("market", "volume_24h"), + "avg_funding_rate": avg_from_context("derivatives", "funding_rate"), + "avg_top_trader_long_pct": avg_from_context("derivatives", "top_trader_long_pct"), + "avg_top_trader_long_short_ratio": avg_from_context("derivatives", "top_trader_long_short_ratio"), + "hot_sector_count": len(hot_sector_counter), + "top_hot_sectors": [ + {"sector": sector, "count": count} + for sector, count in sorted(hot_sector_counter.items(), key=lambda item: (-item[1], item[0]))[:5] + ], + } + + conn.close() + return { + "total_recommendations": total_count, + "active_count": active_count, + "raw_active_count": raw_active_count, + "closed_count": closed_count, + "win_count": win_count, + "win_rate": round(win_count / closed_count * 100, 1) if closed_count else 0, + "avg_pnl_pct": avg_pnl_pct, + "success_count": success_count, + "failed_count": failed_count, + "pending_count": pending_count, + "recommendation_success_rate": recommendation_success_rate, + "active_pnl_sum": active_pnl_sum, + "active_avg_pnl": active_avg_pnl, + "active_max_pnl": active_max_pnl, + "active_min_pnl": active_min_pnl, + "active_success_count": active_success_count, + "active_failed_count": active_failed_count, + "active_pending_count": active_pending_count, + "live_overview": { + "actionable_count": active_count, + "executed_trade_count": len(executed_active_dedup_rows), + "executed_pnl_sum": active_pnl_sum, + "executed_avg_pnl": active_avg_pnl, + "actionable_pnl_sum": active_pnl_sum, + "actionable_avg_pnl": active_avg_pnl, + "buy_now_count": exec_buy_now, + "wait_pullback_count": exec_wait, + "observe_count": exec_observe, + "held_count": held_count, + "held_pnl_avg": held_pnl_avg, + "held_win_rate": held_win_rate, + "actionable_success_count": active_success_count, + "actionable_failed_count": active_failed_count, + "actionable_pending_count": active_pending_count, + "raw_active_count": raw_active_count, + }, + "history_overview": { + "success_count": success_count, + "failed_count": failed_count, + "recommendation_success_rate": recommendation_success_rate, + "avg_pnl_pct": avg_pnl_pct, + "realized_count": realized_count, + }, + "market_context_overview": market_context_overview, + "success_tier_counts": success_tier_counts, + "leaderboard": { + "top_gainer": top_gainer, + "top_loser": top_loser, + "biggest_explosion": biggest_explosion, + "highest_risk": highest_risk, + }, + "equity_curve": { + "last_24h": points_24h, + "last_7d": points_7d, + }, + "lifecycle_summary": { + "longest_holding": longest_holding, + "fastest_winner": fastest_winner, + "decay_watch": decay_watch, + }, + "result_definition": { + "success": "仅统计实际命中止盈的推荐:status=hit_tp1 或 hit_tp2", + "failed": "仅统计实际触发止损的推荐:status=stopped_out", + "pending": "其余样本仅作为未兑现/观察中处理,不在顶部历史统计单独展示", + "avg_pnl_pct": "历史均盈亏仅基于真实兑现样本计算:hit_tp1 / hit_tp2 / stopped_out", + "live_pnl": "实时收益只统计已经执行/触发入场的交易;等回踩计划和观察信号不纳入收益" + }, + "success_tier_definition": { + "small": "小成功:最大涨幅 5%~10%", + "medium": "中成功:最大涨幅 10%~20%", + "big": "大成功:最大涨幅 >=20%" + }, + "lifecycle_definition": { + "hold_hours": "从推荐发出到当前的持续小时数", + "track_delay_hours": "距离最近一次价格跟踪的延迟小时数", + "lifecycle_stage": "等待入场 / 持仓观察 / 进入衰减 / 已验证成功 / 已验证失败" + }, + } + + +def get_review_stats(conn_provider=None, iteration_logs_getter=None, iteration_summary_getter=None): + """获取复盘统计概览。""" + from app.db.review_queries import get_strategy_iteration_logs, get_strategy_iteration_summary + + conn_factory = conn_provider or get_conn + logs_getter = iteration_logs_getter or get_strategy_iteration_logs + summary_getter = iteration_summary_getter or get_strategy_iteration_summary + + conn = conn_factory() + revision_started_at = "" + try: + from app.config.config_loader import get_meta + + meta = get_meta() or {} + revision_started_at = (meta.get("strategy_revision_started_at") or "").strip() + except Exception: + revision_started_at = "" + + reviews = conn.execute("SELECT * FROM review_log ORDER BY review_time DESC").fetchall() + missed = conn.execute("SELECT * FROM missed_explosions ORDER BY detect_time DESC LIMIT 20").fetchall() + signals = conn.execute("SELECT * FROM signal_performance ORDER BY hit_rate DESC").fetchall() + conn.close() + return { + "reviews": [dict(r) for r in reviews], + "signal_performance": [dict(s) for s in signals], + "missed_explosions": [dict(m) for m in missed], + "iteration_logs": logs_getter(limit=30), + "iteration_summary": summary_getter(days=30), + "strategy_revision_started_at": revision_started_at, + } + + +def get_cron_run_logs(limit=50, job_name=None): + """获取 cron 运行日志列表。""" + conn = get_conn() + sql = """ + SELECT * FROM cron_run_log + {where_clause} + ORDER BY started_at DESC, id DESC + LIMIT ? + """ + params = [] + where_clause = "" + if job_name: + where_clause = "WHERE job_name = ?" + params.append(job_name) + params.append(limit) + rows = conn.execute(sql.format(where_clause=where_clause), tuple(params)).fetchall() + conn.close() + + result = [] + for row in rows: + item = dict(row) + try: + item["summary_json"] = json.loads(item.get("summary_json") or "{}") + except Exception: + item["summary_json"] = {} + result.append(item) + return result + + +def get_cron_run_summary(hours=24): + """获取 cron 运行汇总统计。""" + conn = get_conn() + rows = conn.execute( + """ + SELECT * FROM cron_run_log + WHERE julianday(?) - julianday(started_at) <= ? + ORDER BY started_at DESC, id DESC + """, + (datetime.now().isoformat(), hours / 24.0), + ).fetchall() + conn.close() + + logs = [] + job_stats = {} + total_runs = 0 + success_runs = 0 + error_runs = 0 + total_duration = 0 + + for row in rows: + item = dict(row) + try: + item["summary_json"] = json.loads(item.get("summary_json") or "{}") + except Exception: + item["summary_json"] = {} + logs.append(item) + + total_runs += 1 + total_duration += item.get("duration_ms") or 0 + if item.get("run_status") == "success": + success_runs += 1 + else: + error_runs += 1 + + job = item.get("job_name") or "unknown" + stat = job_stats.setdefault( + job, + { + "job_name": job, + "runs": 0, + "success_runs": 0, + "error_runs": 0, + "avg_duration_ms": 0, + "last_status": "", + "last_result_status": "", + "last_started_at": "", + "last_finished_at": "", + "last_error_message": "", + }, + ) + stat["runs"] += 1 + if item.get("run_status") == "success": + stat["success_runs"] += 1 + else: + stat["error_runs"] += 1 + stat["avg_duration_ms"] += item.get("duration_ms") or 0 + if not stat["last_started_at"]: + stat["last_status"] = item.get("run_status", "") + stat["last_result_status"] = item.get("result_status", "") + stat["last_started_at"] = item.get("started_at", "") + stat["last_finished_at"] = item.get("finished_at", "") + stat["last_error_message"] = item.get("error_message", "") + + for stat in job_stats.values(): + stat["success_rate"] = round(stat["success_runs"] / stat["runs"] * 100, 1) if stat["runs"] else 0 + stat["avg_duration_ms"] = round(stat["avg_duration_ms"] / stat["runs"]) if stat["runs"] else 0 + + overall = { + "hours": hours, + "total_runs": total_runs, + "success_runs": success_runs, + "error_runs": error_runs, + "success_rate": round(success_runs / total_runs * 100, 1) if total_runs else 0, + "avg_duration_ms": round(total_duration / total_runs) if total_runs else 0, + } + return { + "overall": overall, + "job_stats": sorted(job_stats.values(), key=lambda x: x["job_name"]), + "recent_logs": logs[:20], + } + + +__all__ = [ + "get_all_recommendations", + "get_cron_run_logs", + "get_cron_run_summary", + "get_review_stats", + "get_screening_history", + "get_stats", +] diff --git a/app/db/auth_db.py b/app/db/auth_db.py index a460046..ab3773b 100644 --- a/app/db/auth_db.py +++ b/app/db/auth_db.py @@ -596,6 +596,17 @@ def resend_verification_code(email: str) -> dict: if age < RESEND_COOLDOWN_SECONDS: conn.close() raise AuthError("验证码发送过于频繁,请稍后再试") + else: + legacy_latest = conn.execute(""" + SELECT * FROM email_verification_code + WHERE email=? AND purpose='register' AND used_at='' + ORDER BY id DESC LIMIT 1 + """, (email,)).fetchone() + if legacy_latest: + age = (now_dt - datetime.fromisoformat(legacy_latest["created_at"])).total_seconds() + if age < RESEND_COOLDOWN_SECONDS: + conn.close() + raise AuthError("验证码发送过于频繁,请稍后再试") code = _new_verify_code() conn.execute(""" diff --git a/app/db/recommendation_queries.py b/app/db/recommendation_queries.py new file mode 100644 index 0000000..16bc321 --- /dev/null +++ b/app/db/recommendation_queries.py @@ -0,0 +1,230 @@ +"""Recommendation and lifecycle-facing DB API.""" + +from datetime import datetime, timedelta + +from app.db.altcoin_db import ( + PUSH_COOLDOWN_HOURS, + _classify_recommendation_result, + _derive_execution_fields, + _is_actionable_execution_status, + apply_recommendation_state_transition, + update_recommendation_tracking, +) +from app.db.schema import get_conn + + +def should_push(symbol: str, push_type: str, action_status: str = "") -> bool: + """状态感知冷却判断。""" + conn = get_conn() + cutoff = (datetime.now() - timedelta(hours=PUSH_COOLDOWN_HOURS)).isoformat() + if action_status: + row = conn.execute( + "SELECT action_status FROM push_log WHERE symbol=? AND push_type=? AND pushed_at > ? ORDER BY id DESC LIMIT 1", + (symbol, push_type, cutoff), + ).fetchone() + conn.close() + if row is None: + return True + return row[0] != action_status + + row = conn.execute( + "SELECT id FROM push_log WHERE symbol=? AND push_type=? AND pushed_at > ? ORDER BY id DESC LIMIT 1", + (symbol, push_type, cutoff), + ).fetchone() + conn.close() + return row is None + + +def log_push(symbol: str, push_type: str, action_status: str = "", rec_id: int = 0): + """记录一次推送,保留推荐来源可追溯性。""" + conn = get_conn() + try: + cols = [row[1] for row in conn.execute("PRAGMA table_info(push_log)").fetchall()] + if "rec_id" in cols: + conn.execute( + "INSERT INTO push_log (symbol, push_type, action_status, rec_id, pushed_at) VALUES (?,?,?,?,?)", + (symbol, push_type, action_status, int(rec_id or 0), datetime.now().isoformat()), + ) + else: + conn.execute( + "INSERT INTO push_log (symbol, push_type, action_status, pushed_at) VALUES (?,?,?,?)", + (symbol, push_type, action_status, datetime.now().isoformat()), + ) + conn.commit() + finally: + conn.close() + + +def get_recommendation_for_push(rec_id: int): + """读取单条推荐并派生网站同口径展示状态,供推送层消费。""" + try: + rec_id = int(rec_id or 0) + except Exception: + rec_id = 0 + if rec_id <= 0: + return None + + conn = get_conn() + row = conn.execute( + """ + SELECT r.*, + lpc.price AS latest_cache_price, + lpc.updated_at AS latest_cache_updated_at + FROM recommendation r + LEFT JOIN latest_price_cache lpc ON lpc.symbol = r.symbol + WHERE r.id=? + """, + (rec_id,), + ).fetchone() + conn.close() + if not row: + return None + + item = dict(row) + rec_result, rec_result_label = _classify_recommendation_result(item) + item["recommendation_result"] = rec_result + item["recommendation_result_label"] = rec_result_label + return _derive_execution_fields(item) + + +def get_active_recommendations(actionable_only: bool = False): + """获取所有 active 推荐。""" + conn = get_conn() + rows = conn.execute( + """ + SELECT * FROM recommendation + WHERE status='active' AND COALESCE(display_bucket,'watch_pool') != 'history' + ORDER BY rec_time DESC + """ + ).fetchall() + conn.close() + + result = [] + for row in rows: + item = _derive_execution_fields(dict(row)) + if actionable_only and not _is_actionable_execution_status(item.get("execution_status")): + continue + result.append(item) + return result + + +def get_active_recommendations_deduped( + actionable_only: bool = True, + version: str = "", + hours: float = 0, + watch_symbols=None, + limit: int = 0, + offset: int = 0, + with_meta: bool = False, +): + """同 symbol 只保留最新 active 推荐,并附带派生执行状态。""" + conn = get_conn() + where = "status='active' AND COALESCE(display_bucket,'watch_pool') != 'history'" + params = [] + version = str(version or "").strip() + if version: + where += " AND strategy_version=?" + params.append(version) + if watch_symbols: + symbols = [str(s).strip().upper() for s in watch_symbols if str(s).strip()] + if symbols: + where += " AND symbol IN (" + ",".join(["?"] * len(symbols)) + ")" + params.extend(symbols) + try: + hours = float(hours or 0) + except Exception: + hours = 0 + if hours > 0: + where += " AND julianday(?) - julianday(rec_time) <= ?" + params.extend([datetime.now().isoformat(), hours / 24.0]) + + try: + limit = max(0, int(limit or 0)) + except Exception: + limit = 0 + try: + offset = max(0, int(offset or 0)) + except Exception: + offset = 0 + + rows = conn.execute( + f""" + SELECT r.*, + lpc.price AS latest_cache_price, + lpc.updated_at AS latest_cache_updated_at + FROM recommendation r + LEFT JOIN latest_price_cache lpc ON lpc.symbol = r.symbol + JOIN ( + SELECT symbol, MAX(id) AS max_id + FROM recommendation + WHERE {where} + GROUP BY symbol + ) latest ON latest.max_id = r.id + ORDER BY r.rec_time DESC + """, + tuple(params), + ).fetchall() + conn.close() + + all_items = [] + summary = {"buy_now": 0, "wait_pullback": 0, "observe": 0, "observe_strong": 0, "observe_weak": 0, "expired": 0, "total": 0} + now = datetime.now() + for row in rows: + item = dict(row) + rec_result, rec_result_label = _classify_recommendation_result(item) + item["recommendation_result"] = rec_result + item["recommendation_result_label"] = rec_result_label + _derive_execution_fields(item) + + is_expired = False + if hours > 0: + try: + rec_time = item.get("rec_time") + if rec_time: + is_expired = (now - datetime.fromisoformat(str(rec_time))).total_seconds() > hours * 3600 + except Exception: + is_expired = False + if item.get("execution_status") == "invalid" or item.get("status") in ("invalid", "expired", "archived") or item.get("display_bucket") == "history": + is_expired = True + if is_expired: + summary["expired"] += 1 + continue + + if actionable_only and not _is_actionable_execution_status(item.get("execution_status")): + continue + all_items.append(item) + if item.get("execution_status") == "buy_now": + summary["buy_now"] += 1 + elif item.get("execution_status") == "wait_pullback": + summary["wait_pullback"] += 1 + else: + summary["observe"] += 1 + if item.get("observe_tier") == "weak": + summary["observe_weak"] += 1 + else: + summary["observe_strong"] += 1 + + summary["total"] = len(all_items) + summary["expired_filtered"] = summary.pop("expired", 0) + + if not with_meta: + return all_items + page_items = all_items[offset : offset + limit] if limit else all_items[offset:] + return { + "items": page_items, + "total": len(all_items), + "limit": limit, + "offset": offset, + "has_more": bool(limit and offset + len(page_items) < len(all_items)), + "summary": summary, + } + +__all__ = [ + "apply_recommendation_state_transition", + "get_active_recommendations", + "get_active_recommendations_deduped", + "get_recommendation_for_push", + "log_push", + "should_push", + "update_recommendation_tracking", +] diff --git a/app/db/review_queries.py b/app/db/review_queries.py new file mode 100644 index 0000000..fea595b --- /dev/null +++ b/app/db/review_queries.py @@ -0,0 +1,319 @@ +"""Review and strategy iteration-facing DB API.""" + +import json +import re +from datetime import datetime + +from app.db.altcoin_db import ( + _loads_json_field, + backfill_strategy_failure_patterns, + dry_run_strategy_candidate_performance, + generate_candidates_from_review_history, + get_strategy_failure_patterns, + get_strategy_insights, + get_strategy_iteration_dashboard, + get_strategy_rule_candidates, + refresh_strategy_candidate_performance, +) +from app.db.schema import get_conn + + +def log_strategy_iteration( + run_date=None, + trigger_source="daily_review", + title="", + summary="", + findings=None, + problems=None, + actions=None, + changed_rules=None, + metrics=None, + related_symbols=None, + config_diff=None, + effect_summary=None, + pollution_summary=None, + strategy_version="", + version_change_summary="", + success_analysis=None, + failure_analysis=None, + candidate_rules=None, + release_decision="", + release_reason="", + confidence_level="", + promotion_state="research_only", + conn_provider=None, +): + """记录一次策略复盘/迭代日志""" + conn_factory = conn_provider or get_conn + conn = conn_factory() + now = datetime.now().isoformat() + run_date = run_date or now[:10] + conn.execute( + """ + INSERT INTO strategy_iteration_log ( + run_date, created_at, trigger_source, title, summary, + findings_json, problems_json, actions_json, changed_rules_json, + metrics_json, related_symbols_json, config_diff_json, effect_summary_json, + pollution_summary_json, + strategy_version, version_change_summary, + success_analysis_json, failure_analysis_json, candidate_rules_json, + release_decision, release_reason, confidence_level, promotion_state + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + run_date, + now, + trigger_source or "daily_review", + title or "未命名迭代", + summary or "", + json.dumps(findings or [], ensure_ascii=False, default=str), + json.dumps(problems or [], ensure_ascii=False, default=str), + json.dumps(actions or [], ensure_ascii=False, default=str), + json.dumps(changed_rules or [], ensure_ascii=False, default=str), + json.dumps(metrics or {}, ensure_ascii=False, default=str), + json.dumps(related_symbols or [], ensure_ascii=False, default=str), + json.dumps(config_diff or {}, ensure_ascii=False, default=str), + json.dumps(effect_summary or {}, ensure_ascii=False, default=str), + json.dumps(pollution_summary or {}, ensure_ascii=False, default=str), + (strategy_version or "").strip(), + (version_change_summary or "").strip(), + json.dumps(success_analysis or {}, ensure_ascii=False, default=str), + json.dumps(failure_analysis or {}, ensure_ascii=False, default=str), + json.dumps(candidate_rules or [], ensure_ascii=False, default=str), + (release_decision or "").strip(), + (release_reason or "").strip(), + (confidence_level or "").strip(), + (promotion_state or "research_only").strip(), + ), + ) + conn.commit() + conn.close() + + +def get_strategy_iteration_logs(limit=30, conn_provider=None, json_loader=None): + conn_factory = conn_provider or get_conn + loader = json_loader or _loads_json_field + conn = conn_factory() + rows = conn.execute( + """ + SELECT * FROM strategy_iteration_log + ORDER BY created_at DESC, id DESC + LIMIT ? + """, + (limit,), + ).fetchall() + conn.close() + + result = [] + for row in rows: + item = dict(row) + item["findings"] = loader(item.get("findings_json"), []) + item["problems"] = loader(item.get("problems_json"), []) + item["actions"] = loader(item.get("actions_json"), []) + item["changed_rules"] = loader(item.get("changed_rules_json"), []) + item["metrics"] = loader(item.get("metrics_json"), {}) + item["related_symbols"] = loader(item.get("related_symbols_json"), []) + item["config_diff"] = loader(item.get("config_diff_json"), {}) + item["effect_summary"] = loader(item.get("effect_summary_json"), {}) + item["pollution_summary"] = loader(item.get("pollution_summary_json"), {}) + item["success_analysis"] = loader(item.get("success_analysis_json"), {}) + item["failure_analysis"] = loader(item.get("failure_analysis_json"), {}) + item["candidate_rules"] = loader(item.get("candidate_rules_json"), []) + item["release_decision"] = (item.get("release_decision") or "").strip() + item["release_reason"] = (item.get("release_reason") or "").strip() + item["confidence_level"] = (item.get("confidence_level") or "").strip() + item["promotion_state"] = (item.get("promotion_state") or "research_only").strip() + item["strategy_version"] = (item.get("strategy_version") or "").strip() + item["version_change_summary"] = (item.get("version_change_summary") or "").strip() + result.append(item) + return result + + +def get_strategy_iteration_summary(days=30, conn_provider=None, json_loader=None): + conn_factory = conn_provider or get_conn + loader = json_loader or _loads_json_field + conn = conn_factory() + now_iso = datetime.now().isoformat() + rows = conn.execute( + """ + SELECT * FROM strategy_iteration_log + WHERE julianday(?) - julianday(created_at) <= ? + ORDER BY created_at DESC, id DESC + """, + (now_iso, days), + ).fetchall() + rec_rows = conn.execute( + """ + SELECT strategy_version, status, pnl_pct, max_pnl_pct, max_drawdown_pct + FROM recommendation + WHERE strategy_version IS NOT NULL AND trim(strategy_version) != '' + """ + ).fetchall() + conn.close() + + def classify_recommendation_result(row): + status = row.get("status") or "" + pnl_pct = row.get("pnl_pct") or 0 + max_pnl_pct = row.get("max_pnl_pct") or 0 + max_drawdown_pct = row.get("max_drawdown_pct") or 0 + if status in ("hit_tp1", "hit_tp2"): + return "success" + if status == "stopped_out": + return "failed" + if status == "expired": + if max_pnl_pct >= 5: + return "success" + if pnl_pct <= -3 or max_drawdown_pct <= -5: + return "failed" + return "pending" + if status == "active": + if max_pnl_pct >= 5: + return "success" + if pnl_pct <= -3 or max_drawdown_pct <= -5: + return "failed" + return "pending" + return "pending" + + version_stats_map = {} + for row in rec_rows: + item = dict(row) + strategy_version = (item.get("strategy_version") or "").strip() + if not strategy_version: + continue + bucket = version_stats_map.setdefault( + strategy_version, + { + "strategy_version": strategy_version, + "recommendation_count": 0, + "success_count": 0, + "failed_count": 0, + "pending_count": 0, + "pnl_values": [], + }, + ) + bucket["recommendation_count"] += 1 + bucket["pnl_values"].append(float(item.get("pnl_pct") or 0)) + outcome = classify_recommendation_result(item) + if outcome == "success": + bucket["success_count"] += 1 + elif outcome == "failed": + bucket["failed_count"] += 1 + else: + bucket["pending_count"] += 1 + + logs = [] + trigger_counts = {} + changed_rule_count = 0 + unique_days = set() + titles = [] + problem_keywords = {} + total_config_change_count = 0 + hit_rates = [] + avg_pnls = [] + version_changelog = [] + + for row in rows: + item = dict(row) + item["findings"] = loader(item.get("findings_json"), []) + item["problems"] = loader(item.get("problems_json"), []) + item["actions"] = loader(item.get("actions_json"), []) + item["changed_rules"] = loader(item.get("changed_rules_json"), []) + item["metrics"] = loader(item.get("metrics_json"), {}) + item["related_symbols"] = loader(item.get("related_symbols_json"), []) + item["config_diff"] = loader(item.get("config_diff_json"), {}) + item["effect_summary"] = loader(item.get("effect_summary_json"), {}) + item["pollution_summary"] = loader(item.get("pollution_summary_json"), {}) + item["success_analysis"] = loader(item.get("success_analysis_json"), {}) + item["failure_analysis"] = loader(item.get("failure_analysis_json"), {}) + item["candidate_rules"] = loader(item.get("candidate_rules_json"), []) + item["release_decision"] = (item.get("release_decision") or "").strip() + item["release_reason"] = (item.get("release_reason") or "").strip() + item["confidence_level"] = (item.get("confidence_level") or "").strip() + item["promotion_state"] = (item.get("promotion_state") or "research_only").strip() + item["strategy_version"] = (item.get("strategy_version") or "").strip() + item["version_change_summary"] = (item.get("version_change_summary") or "").strip() + logs.append(item) + + unique_days.add(item.get("run_date") or (item.get("created_at") or "")[:10]) + trigger = item.get("trigger_source") or "unknown" + trigger_counts[trigger] = trigger_counts.get(trigger, 0) + 1 + changed_rule_count += len(item.get("changed_rules") or []) + if item.get("title"): + titles.append(item["title"]) + for problem in item.get("problems") or []: + key = str(problem).strip() + if key: + problem_keywords[key] = problem_keywords.get(key, 0) + 1 + + diff = item.get("config_diff") or {} + total_config_change_count += len(diff.get("changed") or []) + len(diff.get("added") or []) + len(diff.get("removed") or []) + + effect = item.get("effect_summary") or {} + if isinstance(effect.get("hit_rate_pct"), (int, float)): + hit_rates.append(effect.get("hit_rate_pct")) + if isinstance(effect.get("avg_pnl"), (int, float)): + avg_pnls.append(effect.get("avg_pnl")) + + if item.get("strategy_version"): + version_changelog.append({ + "strategy_version": item.get("strategy_version"), + "created_at": item.get("created_at"), + "run_date": item.get("run_date"), + "title": item.get("title") or "", + "summary": item.get("summary") or "", + "version_change_summary": item.get("version_change_summary") or "", + "changed_rules_count": len(item.get("changed_rules") or []), + "config_change_count": len(diff.get("changed") or []) + len(diff.get("added") or []) + len(diff.get("removed") or []), + }) + + top_problems = sorted(problem_keywords.items(), key=lambda x: (-x[1], x[0]))[:5] + + def _version_sort_key(version): + nums = [int(x) for x in re.findall(r"\d+", str(version or ""))] + return tuple(nums) if nums else (0,) + + version_stats = [] + for strategy_version, bucket in sorted(version_stats_map.items(), key=lambda kv: _version_sort_key(kv[0]), reverse=True): + resolved = bucket["success_count"] + bucket["failed_count"] + version_stats.append({ + "strategy_version": strategy_version, + "recommendation_count": bucket["recommendation_count"], + "success_count": bucket["success_count"], + "failed_count": bucket["failed_count"], + "pending_count": bucket["pending_count"], + "success_rate_pct": round(bucket["success_count"] / resolved * 100, 1) if resolved else 0, + "avg_pnl_pct": round(sum(bucket["pnl_values"]) / len(bucket["pnl_values"]), 2) if bucket["pnl_values"] else 0, + }) + + return { + "days": days, + "total_logs": len(logs), + "unique_run_days": len(unique_days), + "trigger_counts": trigger_counts, + "change_rule_count": changed_rule_count, + "config_change_count": total_config_change_count, + "recent_titles": titles[:8], + "top_problems": [{"problem": k, "count": v} for k, v in top_problems], + "version_stats": version_stats, + "version_changelog": version_changelog[:12], + "effect_overview": { + "avg_hit_rate_pct": round(sum(hit_rates) / len(hit_rates), 1) if hit_rates else 0, + "avg_pnl": round(sum(avg_pnls) / len(avg_pnls), 2) if avg_pnls else 0, + "samples": len(logs), + }, + } + + +__all__ = [ + "backfill_strategy_failure_patterns", + "dry_run_strategy_candidate_performance", + "generate_candidates_from_review_history", + "get_strategy_failure_patterns", + "get_strategy_insights", + "get_strategy_iteration_dashboard", + "get_strategy_iteration_logs", + "get_strategy_iteration_summary", + "get_strategy_rule_candidates", + "log_strategy_iteration", + "refresh_strategy_candidate_performance", +] diff --git a/app/db/schema.py b/app/db/schema.py new file mode 100644 index 0000000..9fbf60e --- /dev/null +++ b/app/db/schema.py @@ -0,0 +1,5 @@ +"""Schema/init-oriented DB API.""" + +from app.db.altcoin_db import get_conn, init_db + +__all__ = ["get_conn", "init_db"] diff --git a/app/integrations/feishu_push.py b/app/integrations/feishu_push.py index addf2e4..17fc0fd 100644 --- a/app/integrations/feishu_push.py +++ b/app/integrations/feishu_push.py @@ -85,6 +85,15 @@ def push_altcoin_burst_alert(symbol, price, signals, entry_plan, sector="", lead def push_recommendation_state_alert(item, title_prefix=None): """主链路推荐状态推送:只渲染 DB/API 已派生好的状态,不做推荐判断。""" + card = build_recommendation_state_card(item, title_prefix=title_prefix) + if isinstance(card, tuple): + ok, reason = card + return ok, reason + return push_card(card) + + +def build_recommendation_state_card(item, title_prefix=None): + """只构建卡片,不负责是否推送、冷却或落库。""" if not item: return True, {"skipped": True, "reason": "empty_mainline_item"} symbol = item.get("symbol", "") @@ -118,7 +127,7 @@ def push_recommendation_state_alert(item, title_prefix=None): rec_id = item.get("id", "") reason = item.get("execution_reason", "") ver = item.get("strategy_version", "") - card = { + return { "config": {"wide_screen_mode": True}, "header": { "template": color, @@ -142,7 +151,6 @@ def push_recommendation_state_alert(item, title_prefix=None): } ], } - return push_card(card) def push_altcoin_accelerating_alert(symbol, price, signals, score, sector="", leader_status="", direction="多头启动"): @@ -166,6 +174,25 @@ def push_altcoin_sector_alert(hot_sectors, leaders_info): 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 跟踪止盈触发。止盈/止损/衰减只落库展示,不发飞书。""" + card = build_trade_action_card( + symbol=symbol, + current_price=current_price, + entry_price=entry_price, + pnl_pct=pnl_pct, + action_status=action_status, + signals=signals, + stop_loss=stop_loss, + tp1=tp1, + tp2=tp2, + ) + if isinstance(card, tuple): + ok, reason = card + return ok, reason + return push_card(card) + + +def build_trade_action_card(symbol, current_price, entry_price, pnl_pct, action_status, signals, stop_loss=0, tp1=0, tp2=0): + """只构建交易执行卡片,不做冷却判断或落库。""" if action_status not in ("可即刻买入", "跟踪止盈"): print(f"[飞书跳过] {symbol} {action_status} — 用户要求止盈/止损/衰减不推送,只在网站展示") return True, {"skipped": True, "reason": "only_buy_now_and_trailing_stop_push_enabled"} @@ -180,7 +207,7 @@ def push_altcoin_tp_sl_alert(symbol, current_price, entry_price, pnl_pct, action elif pnl_pct < 0: trail_info += f"\n**保本出场: {pnl_pct:.2f}%**" - card = { + return { "config": {"wide_screen_mode": True}, "header": { "template": "red", @@ -206,7 +233,6 @@ def push_altcoin_tp_sl_alert(symbol, current_price, entry_price, pnl_pct, action }, ], } - return push_card(card) # 当前只保留入场时机到位推送 event_config = { @@ -225,7 +251,7 @@ def push_altcoin_tp_sl_alert(symbol, current_price, entry_price, pnl_pct, action if tp1 > 0: price_lines += f"\n**止盈1**: ${tp1}" - card = { + return { "config": {"wide_screen_mode": True}, "header": { "template": color, @@ -241,7 +267,6 @@ def push_altcoin_tp_sl_alert(symbol, current_price, entry_price, pnl_pct, action }, ], } - return push_card(card) def push_altcoin_exhaustion_alert(symbol, current_price, pnl_pct, exhaustion): diff --git a/app/integrations/push_orchestrator.py b/app/integrations/push_orchestrator.py new file mode 100644 index 0000000..fc7bf8d --- /dev/null +++ b/app/integrations/push_orchestrator.py @@ -0,0 +1,52 @@ +"""Push orchestration helpers. + +Separates eligibility / cooldown decisions from payload rendering and transport. +""" + +from app.db.recommendation_queries import log_push, should_push +from app.integrations.feishu_push import push_altcoin_tp_sl_alert, push_recommendation_state_alert + + +def push_mainline_state_update(symbol: str, rec_id: int, mainline_item: dict, title_prefix: str | None = None, entry_push_type: str = "entry", watch_push_type: str = "watch_pool") -> bool: + if not mainline_item or mainline_item.get("execution_status") not in ("buy_now", "wait_pullback"): + status = mainline_item.get("execution_status") if mainline_item else "missing" + print(f"[push] skip {symbol}: mainline_status={status}") + return False + + push_type = entry_push_type if mainline_item.get("execution_status") == "buy_now" else watch_push_type + action = mainline_item.get("action_status", "") + if not should_push(symbol, push_type, action): + print(f"⏭ 跳过推送({symbol}): {push_type}/{action} 12h冷却中") + return False + + ok, resp = push_recommendation_state_alert(mainline_item, title_prefix=title_prefix) + if ok: + log_push(symbol, push_type, action, rec_id=rec_id) + return True + + print(f"[push] failed {symbol}: {resp}") + return False + + +def push_trade_action_update(symbol: str, rec_id: int, state_decision: dict, final_action: str, push_type: str = "entry") -> bool: + if not state_decision.get("push_required"): + return False + if not should_push(symbol, push_type, final_action): + print(f"⏭ 跳过推送({symbol}): {push_type}/{final_action} 12h冷却中") + return False + ok, resp = 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), + ) + if ok: + log_push(symbol, push_type, final_action, rec_id=rec_id) + return True + print(f"飞书推送失败({symbol}): {resp}") + return False diff --git a/app/services/altcoin_confirm.py b/app/services/altcoin_confirm.py index d511ad0..bd1c6db 100644 --- a/app/services/altcoin_confirm.py +++ b/app/services/altcoin_confirm.py @@ -29,9 +29,9 @@ from app.core.sector_map import get_burst_threshold, is_meme_coin, get_sector_fo from app.db.altcoin_db import ( init_db, expire_old_states, expire_old_recommendations, 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, update_latest_price_cache, get_recommendation_for_push, ) -from app.integrations.feishu_push import push_recommendation_state_alert +from app.integrations.push_orchestrator import push_mainline_state_update from app.config.config_loader import ( get_strategy_direction, vp_fly_params, @@ -1199,7 +1199,14 @@ def confirm_burst(symbol, cand): } -def main(): +def _emit_output(output, compact: bool = False): + if compact: + print(json.dumps(output, ensure_ascii=False)) + else: + print(json.dumps(output, ensure_ascii=False, indent=2)) + + +def main(compact: bool = False): started_at = datetime.now() try: init_db() @@ -1213,7 +1220,7 @@ def main(): "message": "无需要确认的候选(需加速状态+评分≥6)", "check_time": datetime.now().isoformat(), } - print(json.dumps(output, ensure_ascii=False)) + _emit_output(output, compact=compact) return output results = [] @@ -1272,22 +1279,8 @@ def main(): update_latest_price_cache(symbol, result["price"], updated_at=datetime.now().isoformat(), source="confirm") result["rec_id"] = rec_id - # 主链路派生状态是网站和飞书的唯一共同口径。只通知实时看板也会消费的有效推荐记录。 mainline_item = get_recommendation_for_push(rec_id) - if mainline_item and mainline_item.get("execution_status") in ("buy_now", "wait_pullback"): - push_type = "entry" if mainline_item.get("execution_status") == "buy_now" else "watch_pool" - action = mainline_item.get("action_status", "") - if should_push(symbol, push_type, action): - try: - push_recommendation_state_alert(mainline_item) - log_push(symbol, push_type, action, rec_id=rec_id) - except Exception as e: - print(f"飞书推送失败({symbol}): {e}") - else: - print(f"⏭ 跳过推送({symbol}): {push_type}/{action} 12h冷却中") - else: - status = mainline_item.get("execution_status") if mainline_item else "missing" - print(f"[飞书跳过] {symbol}: 主链路状态={status},不推旁路通知") + push_mainline_state_update(symbol, rec_id, mainline_item) else: result["state_update"] = {"should_alert": False, "reason": "未确认爆发"} @@ -1304,7 +1297,7 @@ def main(): "unconfirmed": unconfirmed, "check_time": datetime.now().isoformat(), } - print(json.dumps(output, ensure_ascii=False, indent=2)) + _emit_output(output, compact=compact) return output except Exception as e: finished_at = datetime.now() @@ -1342,4 +1335,9 @@ def main(): if __name__ == "__main__": - main() + import argparse + + parser = argparse.ArgumentParser(description="AlphaX 爆发确认主流程") + parser.add_argument("--compact", action="store_true", help="输出紧凑 JSON,便于脚本消费") + args = parser.parse_args() + main(compact=args.compact) diff --git a/app/services/altcoin_screener.py b/app/services/altcoin_screener.py index 81a0fe9..cd9414c 100644 --- a/app/services/altcoin_screener.py +++ b/app/services/altcoin_screener.py @@ -1252,7 +1252,14 @@ def run_replay_validation(): # ==================== 主流程 ==================== -def main(): +def _emit_output(output, compact: bool = False): + if compact: + print(json.dumps(output, ensure_ascii=False)) + else: + print(json.dumps(output, ensure_ascii=False, indent=2)) + + +def main(compact: bool = False): started_at = datetime.now() try: init_db() @@ -1267,7 +1274,7 @@ def main(): "message": "粗筛无候选", "check_time": datetime.now().isoformat(), } - print(json.dumps(output, ensure_ascii=False)) + _emit_output(output, compact=compact) return output qualified, hot_sectors, leaders = layer2_fine_filter(candidates) @@ -1279,7 +1286,7 @@ def main(): "candidates_count": len(candidates), "check_time": datetime.now().isoformat(), } - print(json.dumps(output, ensure_ascii=False)) + _emit_output(output, compact=compact) return output # 飞书推送 @@ -1310,7 +1317,7 @@ def main(): "check_time": datetime.now().isoformat(), "weights_used": get_dynamic_weights(), } - print(json.dumps(output, ensure_ascii=False, indent=2)) + _emit_output(output, compact=compact) return output except Exception as e: finished_at = datetime.now() @@ -1348,4 +1355,9 @@ def main(): if __name__ == "__main__": - main() + import argparse + + parser = argparse.ArgumentParser(description="AlphaX 粗筛/细筛主流程") + parser.add_argument("--compact", action="store_true", help="输出紧凑 JSON,便于脚本消费") + args = parser.parse_args() + main(compact=args.compact) diff --git a/app/services/event_driven_screener.py b/app/services/event_driven_screener.py index 263423a..5041695 100644 --- a/app/services/event_driven_screener.py +++ b/app/services/event_driven_screener.py @@ -25,7 +25,7 @@ import yaml sys.path.insert(0, os.path.dirname(__file__)) from app.config.config_loader import load_rules, get_meta, get_strategy_direction -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 app.db.altcoin_db import init_db, get_conn, create_recommendation, log_screening, log_cron_run, get_recommendation_for_push from app.services.altcoin_screener import ( fetch_all_tickers, detect_volume_price_fly, @@ -40,7 +40,7 @@ from app.services.altcoin_screener import ( ) from app.services.altcoin_confirm import fetch_derivatives_context from app.core.pa_engine import full_pa_analysis, calc_atr -from app.integrations.feishu_push import push_recommendation_state_alert +from app.integrations.push_orchestrator import push_mainline_state_update REPO_ROOT = Path(__file__).resolve().parents[2] DB_PATH = os.getenv("ALPHAX_DB_PATH", str(REPO_ROOT / "data" / "altcoin_monitor.db")) @@ -609,19 +609,14 @@ def process_event(event): # 只有 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}") + pushed = push_mainline_state_update( + symbol, + rec_id, + mainline_item, + title_prefix="事件触发机会", + entry_push_type="event_entry", + watch_push_type="event_watch_pool", + ) elif decision in ("observe", "risk"): print(f"[event] skip push {symbol}: decision={decision} is not a主链路推荐通知") diff --git a/app/services/price_tracker.py b/app/services/price_tracker.py index 1ef328a..94d45ad 100644 --- a/app/services/price_tracker.py +++ b/app/services/price_tracker.py @@ -27,14 +27,14 @@ sys.path.insert(0, os.path.dirname(__file__)) from app.db.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, + apply_recommendation_state_transition, log_cron_run, update_latest_price_cache, ) from app.core.pa_engine import ( calc_atr, full_pa_analysis, detect_trend_exhaustion, analyze_entry_point, ) -from app.integrations.feishu_push import push_altcoin_tp_sl_alert +from app.integrations.push_orchestrator import push_trade_action_update from app.config.config_loader import load_rules from app.core.opportunity_lifecycle import apply_entry_quality_gate @@ -339,26 +339,7 @@ def track_prices(): 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冷却中") + push_trade_action_update(symbol, rec["id"], state_decision, final_action, push_type="entry") results.append({ "symbol": symbol, @@ -396,7 +377,7 @@ def track_prices(): return output -if __name__ == "__main__": +def main(): started_at = datetime.now() try: init_db() @@ -433,3 +414,12 @@ if __name__ == "__main__": summary=summary, error_message="", ) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="AlphaX 价格跟踪任务") + parser.add_argument("--once", action="store_true", default=True, help="执行单轮跟踪并输出结果") + parser.parse_args() + main() diff --git a/app/services/review_engine.py b/app/services/review_engine.py index 3f68c24..02c9acf 100644 --- a/app/services/review_engine.py +++ b/app/services/review_engine.py @@ -104,16 +104,32 @@ def _get_strategy_revision_started_at(): def _get_reviewable_recommendations(now=None): - """获取所有未复盘推荐(不再按策略改版时间过滤,与get_review_stats()一致)""" + """获取所有未复盘推荐,并遵守当前策略改版起始时间。""" now = now or datetime.now() conn = get_conn() + revision_started_at = _get_strategy_revision_started_at() - rows = conn.execute(""" - SELECT * FROM recommendation - WHERE julianday(?) - julianday(rec_time) > 1 - AND id NOT IN (SELECT rec_id FROM review_log) - ORDER BY rec_time ASC - """, (now.isoformat(),)).fetchall() + if revision_started_at: + rows = conn.execute( + """ + SELECT * FROM recommendation + WHERE julianday(?) - julianday(rec_time) > 1 + AND rec_time >= ? + AND id NOT IN (SELECT rec_id FROM review_log) + ORDER BY rec_time ASC + """, + (now.isoformat(), revision_started_at), + ).fetchall() + else: + rows = conn.execute( + """ + SELECT * FROM recommendation + WHERE julianday(?) - julianday(rec_time) > 1 + AND id NOT IN (SELECT rec_id FROM review_log) + ORDER BY rec_time ASC + """, + (now.isoformat(),), + ).fetchall() conn.close() return rows @@ -736,7 +752,7 @@ def _compute_effect_summary(now, lookback_days=7): conn = get_conn() start_iso = (now - timedelta(days=lookback_days)).isoformat() revision_started_at = _get_strategy_revision_started_at() - effective_start = max(start_iso, revision_started_at) if revision_started_at else start_iso + effective_start = revision_started_at if revision_started_at and revision_started_at > start_iso else start_iso cols = [row["name"] for row in conn.execute("PRAGMA table_info(review_log)").fetchall()] pnl_col = "pnl" if "pnl" in cols else ("pnl_48h" if "pnl_48h" in cols else None) @@ -1216,7 +1232,7 @@ def _release_candidate_rules_if_ready(dual_attribution, effect_summary): "new_version": new_ver, } -def run_review(): +def run_review(push_enabled: bool = True, compact: bool = False): """执行完整复盘流程(增强版 — 含逆向分析 + 飞书推送 + 规律提炼)""" before_rules = get_rules_snapshot() now = datetime.now() @@ -1312,27 +1328,24 @@ def run_review(): ) # 7. 飞书推送 - try: - # 推送复盘报告 - ok1, r1 = feishu_review_push.push_review_report(results) - print(f"[review_engine] 复盘报告推送: ok={ok1}") + if push_enabled: + try: + ok1, r1 = feishu_review_push.push_review_report(results) + print(f"[review_engine] 复盘报告推送: ok={ok1}") - # 推送逆向分析报告 - if results["reverse_analysis"] and not results["reverse_analysis"].get("error"): - ok2, r2 = feishu_review_push.push_reverse_analysis_report(results["reverse_analysis"]) - print(f"[review_engine] 逆向分析报告推送: ok={ok2}") + if results["reverse_analysis"] and not results["reverse_analysis"].get("error"): + ok2, r2 = feishu_review_push.push_reverse_analysis_report(results["reverse_analysis"]) + print(f"[review_engine] 逆向分析报告推送: ok={ok2}") - # 推送候选规律发现通知(只入候选池,不代表已生效) - for rule in new_pattern_rules: - feishu_review_push.push_rule_update_notification(rule.get("candidate_id"), rule.get("description", ""), status="候选规则,未生效") + for rule in new_pattern_rules: + feishu_review_push.push_rule_update_notification(rule.get("candidate_id"), rule.get("description", ""), status="候选规则,未生效") - # 推送逆向分析发现的新候选规律 - if results["reverse_analysis"] and results["reverse_analysis"].get("new_rules"): - for rule in results["reverse_analysis"]["new_rules"]: - feishu_review_push.push_rule_update_notification(rule.get("candidate_id"), rule.get("description", ""), status="逆向候选,未生效") + if results["reverse_analysis"] and results["reverse_analysis"].get("new_rules"): + for rule in results["reverse_analysis"]["new_rules"]: + feishu_review_push.push_rule_update_notification(rule.get("candidate_id"), rule.get("description", ""), status="逆向候选,未生效") - except Exception as e: - print(f"[review_engine] 飞书推送失败: {e}") + except Exception as e: + print(f"[review_engine] 飞书推送失败: {e}") # 8. 更新meta(迭代元数据) update_meta("last_review", now.isoformat()) @@ -1382,9 +1395,18 @@ def run_review(): iteration_count = current_meta.get("iteration_count", 0) + 1 update_meta("iteration_count", iteration_count) - print(json.dumps(results, ensure_ascii=False, indent=2)) + if compact: + print(json.dumps(results, ensure_ascii=False)) + else: + print(json.dumps(results, ensure_ascii=False, indent=2)) return results if __name__ == "__main__": - run_review() + import argparse + + parser = argparse.ArgumentParser(description="AlphaX 复盘引擎") + parser.add_argument("--no-push", action="store_true", help="只运行复盘,不发送飞书通知") + parser.add_argument("--compact", action="store_true", help="输出紧凑 JSON,便于脚本消费") + args = parser.parse_args() + run_review(push_enabled=not args.no_push, compact=args.compact) diff --git a/app/web/routes_admin.py b/app/web/routes_admin.py new file mode 100644 index 0000000..be8a437 --- /dev/null +++ b/app/web/routes_admin.py @@ -0,0 +1,43 @@ +from fastapi import APIRouter, Cookie, HTTPException, Request +from fastapi.responses import HTMLResponse + +from app.db import auth_db +from app.web.shared import login_redirect, require_admin + +def build_router(templates): + router = APIRouter() + + @router.get("/admin.html", response_class=HTMLResponse) + async def admin_page(request: Request, altcoin_session: str = Cookie(default="")): + if not auth_db.get_user_by_session_token(altcoin_session): + return login_redirect() + try: + require_admin(altcoin_session) + except HTTPException as e: + return HTMLResponse(content=f"

需要管理员权限

{e.detail}

返回看板", status_code=e.status_code) + return templates.TemplateResponse(request=request, name="admin.html", context={"show_nav": True}) + + @router.get("/api/admin/check") + async def api_admin_check(altcoin_session: str = Cookie(default="")): + try: + user = require_admin(altcoin_session) + return {"is_admin": True, "email": user.get("email", "")} + except HTTPException: + return {"is_admin": False} + + @router.get("/api/admin/stats") + async def api_admin_stats(altcoin_session: str = Cookie(default="")): + require_admin(altcoin_session) + return auth_db.get_admin_stats() + + @router.get("/api/admin/users") + async def api_admin_users(search: str = "", offset: int = 0, limit: int = 50, tab: str = "all", altcoin_session: str = Cookie(default="")): + require_admin(altcoin_session) + return auth_db.get_admin_users(search=search, offset=offset, limit=limit, tab=tab) + + @router.get("/api/admin/orders") + async def api_admin_orders(search: str = "", offset: int = 0, limit: int = 50, status: str = "all", altcoin_session: str = Cookie(default="")): + require_admin(altcoin_session) + return auth_db.get_admin_orders(search=search, offset=offset, limit=limit, status=status) + + return router diff --git a/app/web/routes_auth.py b/app/web/routes_auth.py new file mode 100644 index 0000000..17cc3f0 --- /dev/null +++ b/app/web/routes_auth.py @@ -0,0 +1,155 @@ +from fastapi import APIRouter, Cookie, HTTPException, Request +from fastapi.responses import JSONResponse + +from app.db import auth_db +from app.web.shared import ( + ChangePasswordRequest, + CompleteRegistrationRequest, + LoginRequest, + RegisterRequest, + ResendVerificationRequest, + SendCodeRequest, + VerifyEmailRequest, + auth_error, + require_user, +) + +router = APIRouter() + + +@router.post("/api/auth/register") +async def api_auth_register(req: RegisterRequest): + try: + result = auth_db.register_user(req.email, req.password, req.invite_code) + smtp_ready = auth_db.is_smtp_configured() + return { + "ok": True, + "user": {k: v for k, v in result.items() if k not in ("verification_code", "user_id")}, + "dev_verification_code": None if smtp_ready else result.get("verification_code"), + "email_sent": bool(result.get("email_sent")), + "message": "注册成功,请查收邮箱验证码" if smtp_ready else "注册成功,请完成邮箱验证码验证", + } + except auth_db.AuthError as exc: + auth_error(exc) + + +@router.post("/api/auth/send-code") +async def api_auth_send_code(req: SendCodeRequest): + try: + result = auth_db.send_registration_code(req.email) + smtp_ready = auth_db.is_smtp_configured() + return { + "ok": True, + "email": result["email"], + "dev_verification_code": None if smtp_ready else result.get("verification_code"), + "email_sent": bool(result.get("email_sent")), + "message": "验证码已发送,请查收邮箱" if smtp_ready else "验证码已生成", + } + except auth_db.AuthError as exc: + auth_error(exc) + + +@router.post("/api/auth/complete-registration") +async def api_auth_complete_registration(req: CompleteRegistrationRequest): + try: + user = auth_db.complete_registration(req.email, req.code, req.password, req.invite_code) + return {"ok": True, "user": user, "message": "注册成功,请登录"} + except auth_db.AuthError as exc: + auth_error(exc) + + +@router.post("/api/auth/verify-email") +async def api_auth_verify_email(req: VerifyEmailRequest): + try: + user = auth_db.verify_email(req.email, req.code) + return {"ok": True, "user": user, "message": "邮箱验证成功"} + except auth_db.AuthError as exc: + auth_error(exc) + + +@router.post("/api/auth/resend-verification") +async def api_auth_resend_verification(req: ResendVerificationRequest): + try: + result = auth_db.resend_verification_code(req.email) + smtp_ready = auth_db.is_smtp_configured() + return { + "ok": True, + "email": result["email"], + "dev_verification_code": None if smtp_ready else result.get("verification_code"), + "email_sent": bool(result.get("email_sent")), + "message": "验证码已重新发送,请查收邮箱" if smtp_ready else "验证码已重新生成", + } + except auth_db.AuthError as exc: + auth_error(exc) + + +@router.post("/api/auth/login") +async def api_auth_login(req: LoginRequest, request: Request = None): + try: + session = auth_db.login_user(req.email, req.password) + auth_db.log_user_activity(session["user"]["id"], "login", "auth", ip=request.client.host if request.client else "") + sub = auth_db.get_current_subscription(session["user"]["id"]) + next_path = "/app" if sub else "/subscription?welcome=1" + resp = JSONResponse({ + "ok": True, + "user": session["user"], + "expires_at": session["expires_at"], + "subscription": sub, + "subscription_active": bool(sub), + "next": next_path, + }) + resp.set_cookie("altcoin_session", session["token"], httponly=True, samesite="lax", max_age=30 * 24 * 3600) + return resp + except auth_db.AuthError as exc: + auth_error(exc, status_code=400) + + +@router.get("/api/auth/me") +async def api_auth_me(altcoin_session: str = Cookie(default="")): + user = require_user(altcoin_session) + sub = auth_db.get_current_subscription(user["id"]) + return {"ok": True, "user": user, "subscription": sub, "subscription_active": bool(sub)} + + +@router.post("/api/auth/change-password") +async def api_auth_change_password(req: ChangePasswordRequest, altcoin_session: str = Cookie(default="")): + user = require_user(altcoin_session) + try: + return auth_db.change_password(user["id"], req.old_password, req.new_password) + except auth_db.AuthError as exc: + auth_error(exc) + + +@router.post("/api/auth/logout") +async def api_auth_logout(altcoin_session: str = Cookie(default="")): + auth_db.logout_user(altcoin_session) + resp = JSONResponse({"ok": True, "message": "已退出登录"}) + resp.delete_cookie("altcoin_session") + return resp + + +@router.post("/api/subscriptions/free-trial") +async def api_subscription_free_trial(altcoin_session: str = Cookie(default="")): + user = require_user(altcoin_session) + try: + sub = auth_db.claim_free_trial(user["id"]) + return {"ok": True, "subscription": sub, "message": "已开通新用户免费体验1个月"} + except auth_db.AuthError as exc: + auth_error(exc) + + +@router.get("/api/subscription/plans") +async def api_subscription_plans(): + auth_db.init_auth_db() + conn = auth_db.get_conn() + rows = conn.execute("SELECT * FROM subscription_plan ORDER BY sort_order ASC").fetchall() + conn.close() + return [dict(r) for r in rows] + + +@router.get("/api/referral/stats") +async def api_referral_stats(altcoin_session: str = Cookie(default="")): + user = auth_db.get_user_by_session_token(altcoin_session) + if not user: + raise HTTPException(status_code=401, detail="请先登录") + return auth_db.get_referral_stats(user["id"]) diff --git a/app/web/routes_content.py b/app/web/routes_content.py new file mode 100644 index 0000000..5b794d3 --- /dev/null +++ b/app/web/routes_content.py @@ -0,0 +1,294 @@ +import json +import os +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path + +from fastapi import APIRouter, Cookie +from fastapi.responses import JSONResponse + +from app.web.shared import require_api_user_with_subscription + + +def build_router(repo_root: Path): + router = APIRouter() + + @router.get("/api/sentiment") + async def api_sentiment(hours: int = 6, altcoin_session: str = Cookie(default="")): + require_api_user_with_subscription(altcoin_session) + db = os.getenv("ALPHAX_DB_PATH", str(repo_root / "data" / "altcoin_monitor.db")) + conn = sqlite3.connect(db) + conn.row_factory = sqlite3.Row + + active_recs = conn.execute("SELECT DISTINCT symbol FROM recommendation WHERE status='active'").fetchall() + active_symbols = {r["symbol"].split("/")[0].upper() for r in active_recs} + + recent_screened = conn.execute( + """ + SELECT DISTINCT symbol FROM screening_log + WHERE scan_time >= datetime('now', '-' || ? || ' hours') + """, + (hours,), + ).fetchall() + screened_bases = {r["symbol"].split("/")[0].upper() for r in recent_screened} + + events = [] + now_utc = datetime.now(timezone.utc) + + def _parse_event_time(value): + if not value: + return None + text = str(value).strip() + for fmt in ("%a, %d %b %Y %H:%M:%S %Z", "%a, %d %b %Y %H:%M:%S GMT"): + try: + return datetime.strptime(text, fmt).replace(tzinfo=timezone.utc) + except Exception: + pass + try: + dt = datetime.fromisoformat(text.replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + except Exception: + return None + + def _is_fresh_news(value, max_hours): + dt = _parse_event_time(value) + if not dt: + return False + age_hours = (now_utc - dt).total_seconds() / 3600 + return 0 <= age_hours <= max_hours + + valuable_news_keywords = [ + "listing", "listed", "launch", "launchpool", "megadrop", "airdrop", "mainnet", "upgrade", + "partnership", "integrat", "acquisition", "merge", "buyback", "burn", "token burn", "funding", + "raises", "investment", "sec", "etf", "approval", "lawsuit", "settlement", "hack", "exploit", + "delist", "suspend", "roadmap", "migration", "上线", "上币", "合约", "空投", "主网", "升级", "合作", + "收购", "回购", "销毁", "融资", "获投", "监管", "批准", "黑客", "漏洞", "下架", "暂停", + ] + low_value_news_keywords = [ + "price prediction", "price today", "live price", "marketcap and chart", "what is", "how to buy", + "good investment", "forecast", "prediction 2026", "prediction 2027", "prediction 2030", + "technical analysis", "is it time to buy", "价格预测", "今日价格", "实时价格", "怎么买", "是什么币", + ] + + def _is_valuable_news_title(title): + text = (title or "").lower() + if not text: + return False + if any(k in text for k in low_value_news_keywords): + return False + return any(k in text for k in valuable_news_keywords) + + try: + event_rows = conn.execute( + """ + SELECT source, symbol, title, url, published_at, detected_at, importance, + event_type, decision, tech_score, rec_id, pushed + FROM event_news + WHERE detected_at >= datetime('now', '-' || ? || ' hours') + ORDER BY datetime(published_at) DESC, id DESC + LIMIT 80 + """, + (hours,), + ).fetchall() + for r in event_rows: + base = (r["symbol"] or "").split("/")[0].upper() + source = r["source"] or "event" + event_type = r["event_type"] or "event" + if event_type == "market_heat": + continue + events.append({ + "source": source, + "source_label": "Binance公告" if "binance" in source else "CoinGecko热度" if "coingecko" in source else source, + "event_type": event_type, + "importance": r["importance"] or "B", + "title": r["title"] or "", + "url": r["url"] or "", + "published_at": r["published_at"], + "detected_at": r["detected_at"], + "related_symbol": r["symbol"], + "related_base": base, + "related_name": "", + "decision": r["decision"] or "", + "tech_score": r["tech_score"] or 0, + "rec_id": r["rec_id"] or 0, + "pushed": bool(r["pushed"]), + "in_active": base in active_symbols, + "in_screened": base in screened_bases, + "price_usd": 0, + "change_24h_pct": 0, + "market_cap_rank": 0, + "trend_rank": None, + }) + except Exception: + pass + + rows = conn.execute( + """ + SELECT symbol, name, trend_rank, trend_score, market_cap_rank, detected_at, extra_json + FROM sentiment_events + WHERE detected_at = (SELECT MAX(detected_at) FROM sentiment_events WHERE source='coingecko') + ORDER BY trend_rank + """ + ).fetchall() + for r in rows: + raw_extra = r["extra_json"] + if not raw_extra or not isinstance(raw_extra, str) or not raw_extra.strip(): + extra = {} + else: + try: + extra = json.loads(raw_extra) + except Exception: + extra = {} + base = (r["symbol"] or "").upper() + name = r["name"] or base + price_usd = extra.get("price_usd", 0) or 0 + change_24h_pct = extra.get("change_24h_pct", 0) or 0 + news_items = extra.get("news", []) or [] + + for n in news_items[:3]: + published = n.get("published") or "" + if not _is_fresh_news(published, hours): + continue + title = n.get("title") or f"{name} 相关新闻" + if not _is_valuable_news_title(title): + continue + events.append({ + "source": n.get("source") or "news", + "source_label": n.get("source") or "新闻", + "event_type": "news", + "importance": "B", + "title": title, + "url": n.get("url") or "", + "published_at": published, + "detected_at": r["detected_at"], + "related_symbol": f"{base}/USDT", + "related_base": base, + "related_name": name, + "decision": "", + "tech_score": 0, + "rec_id": 0, + "pushed": False, + "in_active": base in active_symbols, + "in_screened": base in screened_bases, + "price_usd": price_usd, + "change_24h_pct": change_24h_pct, + "market_cap_rank": r["market_cap_rank"], + "trend_rank": r["trend_rank"], + }) + + conn.close() + + deduped = [] + seen = set() + for e in events: + key = ((e.get("title") or "").strip().lower(), e.get("related_base"), e.get("source")) + if key in seen: + continue + seen.add(key) + if e.get("in_active"): + e["relation_tag"] = "持仓/活跃推荐" + elif e.get("in_screened"): + e["relation_tag"] = "系统筛选中" + else: + e["relation_tag"] = "关联币种" + deduped.append(e) + + deduped.sort(key=lambda item: (item.get("published_at") or item.get("detected_at") or "", {"RISK": 5, "S": 4, "A": 3, "B": 2, "C": 1}.get(item.get("importance"), 0)), reverse=True) + check_time = deduped[0]["detected_at"] if deduped else None + return { + "check_time": check_time, + "total_events": len(deduped), + "overlap_active": sum(1 for e in deduped if e["in_active"]), + "overlap_screened": sum(1 for e in deduped if e["in_screened"]), + "events": deduped[:80], + "trending": [], + "total_trending": 0, + } + + @router.get("/api/kline") + async def api_kline(symbol: str, interval: str = "1d", limit: int = 60, altcoin_session: str = Cookie(default="")): + require_api_user_with_subscription(altcoin_session) + import requests as req + + try: + clean = symbol.replace("/", "") + r = req.get( + "https://api.binance.com/api/v3/klines", + params={"symbol": clean, "interval": interval, "limit": limit}, + timeout=10, + ) + if r.status_code != 200: + return JSONResponse({"error": f"Binance {r.status_code}"}, status_code=502) + data = r.json() + candles = [ + {"time": d[0], "open": float(d[1]), "high": float(d[2]), "low": float(d[3]), "close": float(d[4]), "volume": float(d[5])} + for d in data + ] + return {"symbol": symbol, "interval": interval, "candles": candles} + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=500) + + @router.get("/api/newsfeed") + async def api_newsfeed(altcoin_session: str = Cookie(default="")): + require_api_user_with_subscription(altcoin_session) + import requests as req + import xml.etree.ElementTree as ET + from email.utils import parsedate_to_datetime + + result = {"fear_greed": None, "trending": [], "news": []} + now = datetime.now(timezone.utc) + try: + r = req.get("https://api.alternative.me/fng/?limit=1", timeout=8) + if r.status_code == 200: + d0 = r.json().get("data", [{}])[0] + result["fear_greed"] = {"value": int(d0.get("value", 50)), "classification": d0.get("value_classification", "")} + except Exception: + pass + + try: + r = req.get("https://api.coingecko.com/api/v3/search/trending", timeout=10) + if r.status_code == 200: + for c in r.json().get("coins", [])[:7]: + item = c.get("item", {}) + result["trending"].append({ + "name": item.get("name", ""), + "symbol": item.get("symbol", ""), + "market_cap_rank": item.get("market_cap_rank"), + "thumb": item.get("thumb", ""), + }) + except Exception: + pass + + def fetch_google_news(query, hl, gl, ceid, label): + items = [] + try: + url = f"https://news.google.com/rss/search?q={req.utils.quote(query)}&hl={hl}&gl={gl}&ceid={ceid}" + r = req.get(url, timeout=12, headers={"User-Agent": "Mozilla/5.0"}) + if r.status_code != 200: + return items + root = ET.fromstring(r.text) + for el in root.findall(".//item")[:15]: + pub_str = el.findtext("pubDate", "") + dt = parsedate_to_datetime(pub_str) if pub_str else None + age_h = round((now - dt).total_seconds() / 3600, 1) if dt else None + if age_h is not None and age_h > 48: + continue + items.append({ + "title": (el.findtext("title", "") or "")[:120], + "url": el.findtext("link", "") or "", + "source": (el.findtext("source", "") or "")[:30], + "age_hours": age_h, + "lang": label, + }) + except Exception: + pass + return items + + en_news = fetch_google_news("cryptocurrency OR bitcoin OR ethereum OR defi OR altcoin when:24h", "en-US", "US", "US:en", "en") + cn_news = fetch_google_news("加密货币 OR 比特币 OR 以太坊 OR DeFi OR Web3 when:24h", "zh-CN", "CN", "CN:zh-Hans", "cn") + result["news"] = sorted(en_news + cn_news, key=lambda x: x.get("age_hours") or 999)[:30] + return result + + return router diff --git a/app/web/routes_pages.py b/app/web/routes_pages.py new file mode 100644 index 0000000..cc995bd --- /dev/null +++ b/app/web/routes_pages.py @@ -0,0 +1,98 @@ +from pathlib import Path + +from fastapi import APIRouter, Cookie, Request +from fastapi.responses import HTMLResponse + +from app.db import auth_db +from app.web.shared import require_page_user + + +def build_router(templates, repo_root: Path, stock_report_template: str): + router = APIRouter() + + def render_page(template_name: str, request: Request, **kwargs): + try: + user = auth_db.get_user_by_session_token(request.cookies.get("altcoin_session", "")) + if user: + auth_db.log_user_activity( + user["id"], + "page_view", + template_name.replace(".html", ""), + ip=request.client.host if request.client else "", + ) + except Exception: + pass + return templates.TemplateResponse(request=request, name=template_name, context={"show_nav": True, **kwargs}) + + @router.get("/", response_class=HTMLResponse) + async def index(): + with open(repo_root / "static" / "index.html", "r", encoding="utf-8") as f: + return HTMLResponse(content=f.read()) + + @router.get("/auth", response_class=HTMLResponse) + async def auth_page(): + with open(repo_root / "static" / "auth.html", "r", encoding="utf-8") as f: + return HTMLResponse(content=f.read()) + + @router.get("/watchlist", response_class=HTMLResponse) + async def watchlist_page(request: Request): + user, redirect = require_page_user(request) + if redirect: + return redirect + return render_page("watchlist.html", request) + + @router.get("/strategy", response_class=HTMLResponse) + async def strategy_page(request: Request): + user, redirect = require_page_user(request) + if redirect: + return redirect + return render_page("strategy.html", request) + + @router.get("/subscription", response_class=HTMLResponse) + async def subscription_page(request: Request): + user, redirect = require_page_user(request, require_subscription=False) + if redirect: + return redirect + return render_page("subscription.html", request) + + @router.get("/referral", response_class=HTMLResponse) + async def referral_page(request: Request, altcoin_session: str = Cookie(default="")): + user, redirect = require_page_user(request) + if redirect: + return redirect + return render_page("referral.html", request) + + @router.get("/app", response_class=HTMLResponse) + async def app_page(altcoin_session: str = Cookie(default=""), request: Request = None): + user, redirect = require_page_user(request) + if redirect: + return redirect + try: + auth_db.log_user_activity(user["id"], "page_view", "app", ip=request.client.host if request and request.client else "") + except Exception: + pass + resp = templates.TemplateResponse(request=request, name="app.html", context={"show_nav": True}) + resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + resp.headers["Pragma"] = "no-cache" + resp.headers["Expires"] = "0" + return resp + + @router.get("/sentiment", response_class=HTMLResponse) + async def sentiment_page(request: Request): + user, redirect = require_page_user(request) + if redirect: + return redirect + return render_page("sentiment.html", request) + + @router.get("/iteration", response_class=HTMLResponse) + async def iteration_page(request: Request): + user, redirect = require_page_user(request) + if redirect: + return redirect + return render_page("iteration.html", request) + + @router.get("/stock-report", response_class=HTMLResponse) + async def stock_report_page(): + return HTMLResponse(content=stock_report_template) + + return router diff --git a/app/web/routes_recommendations.py b/app/web/routes_recommendations.py new file mode 100644 index 0000000..1c311dc --- /dev/null +++ b/app/web/routes_recommendations.py @@ -0,0 +1,141 @@ +from fastapi import APIRouter, Cookie + +from app.db import auth_db +from app.db.analytics import ( + get_all_recommendations, + get_cron_run_logs, + get_cron_run_summary, + get_review_stats, + get_screening_history, + get_stats, +) +from app.db.recommendation_queries import get_active_recommendations, get_active_recommendations_deduped +from app.config.config_loader import get_signal_weights +from app.web.shared import ( + ObservationRequest, + PushRulesRequest, + WatchlistRequest, + require_api_user_with_subscription, +) + +router = APIRouter() + + +@router.get("/api/stats") +async def api_stats(altcoin_session: str = Cookie(default="")): + require_api_user_with_subscription(altcoin_session) + return get_stats() + + +@router.get("/api/recommendations") +async def api_recommendations( + limit: int = 50, + offset: int = 0, + decision_only: bool = False, + version: str = "", + paged: bool = False, + compact: bool = False, + altcoin_session: str = Cookie(default=""), +): + require_api_user_with_subscription(altcoin_session) + return get_all_recommendations(limit, decision_only=decision_only, version=version, offset=offset, with_meta=(paged or compact)) + + +@router.get("/api/recommendations/active") +async def api_recommendations_active( + dedup: bool = True, + actionable_only: bool = True, + version: str = "", + hours: float = 0, + limit: int = 0, + offset: int = 0, + paged: bool = False, + compact: bool = False, + altcoin_session: str = Cookie(default=""), +): + require_api_user_with_subscription(altcoin_session) + if dedup: + return get_active_recommendations_deduped( + actionable_only=actionable_only, + version=version, + hours=hours, + limit=limit, + offset=offset, + with_meta=(paged or compact), + ) + return get_active_recommendations(actionable_only=actionable_only) + + +@router.get("/api/personalization") +async def api_personalization(altcoin_session: str = Cookie(default="")): + user = require_api_user_with_subscription(altcoin_session) + return { + "watchlist": auth_db.get_watchlist_symbols(user["id"]), + "observations": auth_db.get_saved_observations(user["id"]), + "push_rules": auth_db.get_push_rules(user["id"]), + } + + +@router.post("/api/watchlist") +async def api_add_watchlist(req: WatchlistRequest, altcoin_session: str = Cookie(default="")): + user = require_api_user_with_subscription(altcoin_session) + auth_db.add_watchlist_symbol(user["id"], req.symbol) + return {"ok": True, "watchlist": auth_db.get_watchlist_symbols(user["id"])} + + +@router.delete("/api/watchlist/{symbol}") +async def api_remove_watchlist(symbol: str, altcoin_session: str = Cookie(default="")): + user = require_api_user_with_subscription(altcoin_session) + auth_db.remove_watchlist_symbol(user["id"], symbol) + return {"ok": True, "watchlist": auth_db.get_watchlist_symbols(user["id"])} + + +@router.post("/api/observations") +async def api_save_observation(req: ObservationRequest, altcoin_session: str = Cookie(default="")): + user = require_api_user_with_subscription(altcoin_session) + auth_db.save_observation(user["id"], req.rec_id, req.note) + return {"ok": True, "observations": auth_db.get_saved_observations(user["id"])} + + +@router.delete("/api/observations/{rec_id}") +async def api_remove_observation(rec_id: int, altcoin_session: str = Cookie(default="")): + user = require_api_user_with_subscription(altcoin_session) + auth_db.remove_observation(user["id"], rec_id) + return {"ok": True, "observations": auth_db.get_saved_observations(user["id"])} + + +@router.post("/api/push-rules") +async def api_update_push_rules(req: PushRulesRequest, altcoin_session: str = Cookie(default="")): + user = require_api_user_with_subscription(altcoin_session) + rules = auth_db.update_push_rules(user["id"], req.dict()) + return {"ok": True, "push_rules": rules} + + +@router.get("/api/screening") +async def api_screening(hours: int = 24, limit: int = 100, altcoin_session: str = Cookie(default="")): + require_api_user_with_subscription(altcoin_session) + return get_screening_history(hours, limit) + + +@router.get("/api/review") +async def api_review(altcoin_session: str = Cookie(default="")): + require_api_user_with_subscription(altcoin_session) + return get_review_stats() + + +@router.get("/api/weights") +async def api_weights(altcoin_session: str = Cookie(default="")): + require_api_user_with_subscription(altcoin_session) + return get_signal_weights() + + +@router.get("/api/cron") +async def api_cron(limit: int = 50, job_name: str = "", altcoin_session: str = Cookie(default="")): + require_api_user_with_subscription(altcoin_session) + return get_cron_run_logs(limit=limit, job_name=job_name or None) + + +@router.get("/api/cron/summary") +async def api_cron_summary(hours: int = 24, altcoin_session: str = Cookie(default="")): + require_api_user_with_subscription(altcoin_session) + return get_cron_run_summary(hours=hours) diff --git a/app/web/routes_strategy.py b/app/web/routes_strategy.py new file mode 100644 index 0000000..db5d04f --- /dev/null +++ b/app/web/routes_strategy.py @@ -0,0 +1,123 @@ +from fastapi import APIRouter, Cookie + +from app.config.config_loader import get_meta +from app.db import auth_db +from app.db.review_queries import ( + backfill_strategy_failure_patterns, + dry_run_strategy_candidate_performance, + generate_candidates_from_review_history, + get_strategy_failure_patterns, + get_strategy_insights, + get_strategy_iteration_dashboard, + get_strategy_rule_candidates, + refresh_strategy_candidate_performance, +) +from app.db.schema import get_conn +from app.db.altcoin_db import _derive_execution_fields +from app.web.shared import require_api_user_with_subscription + +router = APIRouter() + + +@router.get("/api/versions") +async def api_versions(view: str = "active", altcoin_session: str = Cookie(default="")): + require_api_user_with_subscription(altcoin_session) + conn = get_conn() + rows = conn.execute( + """ + SELECT r.* FROM recommendation r + JOIN ( + SELECT symbol, strategy_version, MAX(id) AS max_id + FROM recommendation + WHERE status='active' AND strategy_version IS NOT NULL AND strategy_version != '' + GROUP BY symbol, strategy_version + ) latest ON latest.max_id = r.id + ORDER BY r.strategy_version DESC, r.rec_time DESC + """ + ).fetchall() + conn.close() + counts = {} + for row in rows: + item = dict(row) + _derive_execution_fields(item) + version = str(item.get("strategy_version") or "").strip() + if not version: + continue + status = item.get("execution_status") or "observe" + if view == "active" and status not in ("buy_now", "wait_pullback"): + continue + if view == "watch" and status != "observe": + continue + counts[version] = counts.get(version, 0) + 1 + + versions = [{"version": version, "count": count} for version, count in counts.items()] + current_version = str(get_meta().get("strategy_version") or "").strip() + if current_version and current_version not in counts: + versions.append({"version": current_version, "count": 0}) + + def _version_key(v): + try: + return tuple(int(p) for p in v["version"].lstrip("v").split(".")) + except Exception: + return (0,) + + versions.sort(key=_version_key, reverse=True) + return versions + + +@router.get("/api/strategy/insights") +async def api_strategy_insights(altcoin_session: str = Cookie(default="")): + require_api_user_with_subscription(altcoin_session) + return get_strategy_insights() + + +@router.get("/api/strategy/lifecycle") +async def api_strategy_lifecycle(days: int = 30, altcoin_session: str = Cookie(default="")): + require_api_user_with_subscription(altcoin_session) + return get_strategy_iteration_dashboard(days=days) + + +@router.get("/api/iterations") +async def api_iterations(limit: int = 30, altcoin_session: str = Cookie(default="")): + require_api_user_with_subscription(altcoin_session) + conn = get_conn() + rows = conn.execute("SELECT * FROM strategy_iteration_log ORDER BY id DESC LIMIT ?", (limit,)).fetchall() + conn.close() + return [dict(r) for r in rows] + + +@router.get("/api/strategy/candidates") +async def api_strategy_candidates(limit: int = 50, status: str = "", altcoin_session: str = Cookie(default="")): + require_api_user_with_subscription(altcoin_session) + return get_strategy_rule_candidates(limit=limit, status=status or None) + + +@router.get("/api/strategy/failures") +async def api_strategy_failures(limit: int = 50, altcoin_session: str = Cookie(default="")): + require_api_user_with_subscription(altcoin_session) + return get_strategy_failure_patterns(limit=limit) + + +@router.post("/api/strategy/candidates/refresh") +async def api_strategy_candidates_refresh(altcoin_session: str = Cookie(default="")): + require_api_user_with_subscription(altcoin_session) + return {"updated": refresh_strategy_candidate_performance()} + + +@router.get("/api/strategy/candidates/dry-run") +async def api_strategy_candidates_dry_run(altcoin_session: str = Cookie(default="")): + require_api_user_with_subscription(altcoin_session) + return dry_run_strategy_candidate_performance() + + +@router.post("/api/strategy/failures/backfill") +async def api_strategy_failures_backfill(dry_run: bool = False): + return backfill_strategy_failure_patterns(dry_run=dry_run) + + +@router.post("/api/strategy/candidates/generate-history") +async def api_strategy_candidates_generate_history(dry_run: bool = False): + result = generate_candidates_from_review_history(dry_run=dry_run) + if not dry_run: + result["refreshed"] = refresh_strategy_candidate_performance() + return result diff --git a/app/web/shared.py b/app/web/shared.py new file mode 100644 index 0000000..ba326a8 --- /dev/null +++ b/app/web/shared.py @@ -0,0 +1,156 @@ +"""Shared web-layer request models and auth helpers.""" + +from contextvars import ContextVar + +from fastapi import Cookie, HTTPException, Request +from fastapi.responses import RedirectResponse +from pydantic import BaseModel + +from app.db import auth_db + +current_request = ContextVar("current_request", default=None) + + +class RegisterRequest(BaseModel): + email: str + password: str + invite_code: str = "" + + +class VerifyEmailRequest(BaseModel): + email: str + code: str + + +class LoginRequest(BaseModel): + email: str + password: str + + +class ResendVerificationRequest(BaseModel): + email: str + + +class SendCodeRequest(BaseModel): + email: str + + +class CompleteRegistrationRequest(BaseModel): + email: str + code: str + password: str + invite_code: str = "" + + +class ChangePasswordRequest(BaseModel): + old_password: str + new_password: str + + +class WatchlistRequest(BaseModel): + symbol: str + + +class ObservationRequest(BaseModel): + rec_id: int + note: str = "" + + +class PushRulesRequest(BaseModel): + watchlist_only: bool = False + min_score: int = 0 + min_rr: float = 0 + push_buy_now: bool = True + push_wait_pullback: bool = True + push_observe: bool = False + quiet_start: str = "" + quiet_end: str = "" + + +def auth_error(exc: Exception, status_code: int = 400): + raise HTTPException(status_code=status_code, detail=str(exc)) + + +def is_local_request(request: Request = None) -> bool: + request = request or current_request.get() + if not request or not request.client: + return False + host = (request.client.host or "").split(":")[0] + return host in ("127.0.0.1", "localhost", "::1", "testclient") + + +def local_debug_user(): + return {"id": 0, "email": "local@alphax.dev", "is_admin": True, "local_debug": True} + + +def require_user(altcoin_session: str = ""): + user = auth_db.get_user_by_session_token(altcoin_session) + if user: + return user + if is_local_request(): + return local_debug_user() + if not user: + raise HTTPException(status_code=401, detail="请先登录") + return user + + +def require_active_subscription(altcoin_session: str = ""): + user = require_user(altcoin_session) + if user.get("local_debug"): + return user, {"plan_name": "本地调试", "local_debug": True} + sub = auth_db.get_current_subscription(user["id"]) + if not sub: + raise HTTPException(status_code=402, detail="订阅已过期或未开通,请先开通订阅") + return user, sub + + +def require_admin(altcoin_session: str = ""): + user = require_user(altcoin_session) + if user.get("local_debug"): + return user + if not auth_db.is_user_admin(user["id"]): + raise HTTPException(status_code=403, detail="需要管理员权限") + return user + + +def login_redirect(): + return RedirectResponse(url="/auth?tab=login", status_code=302) + + +def subscription_redirect(): + return RedirectResponse(url="/subscription?expired=1", status_code=302) + + +def has_active_subscription(user) -> bool: + if is_local_request(): + return True + if not user: + return False + try: + if auth_db.is_user_admin(user["id"]): + return True + except Exception: + pass + return bool(auth_db.get_current_subscription(user["id"])) + + +def require_page_user(request: Request, require_subscription: bool = True): + user = auth_db.get_user_by_session_token(request.cookies.get("altcoin_session", "")) + if user: + if require_subscription and not has_active_subscription(user): + return user, subscription_redirect() + return user, None + if is_local_request(request): + return local_debug_user(), None + if not user: + return None, login_redirect() + return user, None + + +def require_api_user_with_subscription(altcoin_session: str = Cookie(default="")): + user = require_user(altcoin_session) + if user.get("local_debug"): + return user + if not has_active_subscription(user): + raise HTTPException(status_code=402, detail="订阅已过期或未开通,请先开通订阅") + return user diff --git a/app/web/web_server.py b/app/web/web_server.py index 1487d38..710944a 100644 --- a/app/web/web_server.py +++ b/app/web/web_server.py @@ -1,4255 +1,52 @@ """ -山寨币监控网站 — FastAPI后端 v11(纯前瞻信号版) -新增:复盘tab + 信号权重API +FastAPI application assembly for the AlphaX web surface. """ -import sys -import os -import json -import sqlite3 -from datetime import datetime, timezone -from contextvars import ContextVar +from contextlib import asynccontextmanager from pathlib import Path -from fastapi import FastAPI, HTTPException, Cookie, Request -from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse -from fastapi.templating import Jinja2Templates -from pydantic import BaseModel +from fastapi import FastAPI, Request +from fastapi.templating import Jinja2Templates + +from app.db.schema import init_db from app.db import auth_db -from app.db.altcoin_db import ( - 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_conn, _derive_execution_fields, get_strategy_insights, get_strategy_rule_candidates, - get_strategy_failure_patterns, get_strategy_iteration_dashboard, refresh_strategy_candidate_performance, - dry_run_strategy_candidate_performance, backfill_strategy_failure_patterns, - generate_candidates_from_review_history, -) -from app.config.config_loader import get_signal_weights, get_meta +from app.db.analytics import get_all_recommendations, get_cron_run_logs, get_cron_run_summary, get_review_stats, get_stats +from app.db.recommendation_queries import get_active_recommendations, get_active_recommendations_deduped +from app.web.routes_admin import build_router as build_admin_router +from app.web.routes_auth import router as auth_router +from app.web.routes_content import build_router as build_content_router +from app.web.routes_pages import build_router as build_pages_router +from app.web.routes_recommendations import router as recommendations_router +from app.web.routes_strategy import router as strategy_router +from app.web.shared import current_request +from app.web.shared import require_active_subscription as _require_active_subscription REPO_ROOT = Path(__file__).resolve().parents[2] +STOCK_REPORT_TEMPLATE = (REPO_ROOT / "templates" / "stock_report_template.html").read_text(encoding="utf-8") +HTML_PAGE = (REPO_ROOT / "static" / "app.html").read_text(encoding="utf-8") -app = FastAPI(title="山寨币爆发监控 v11") + +@asynccontextmanager +async def lifespan(app: FastAPI): + init_db() + yield + + +app = FastAPI(title="山寨币爆发监控 v11", lifespan=lifespan) templates = Jinja2Templates(directory=str(REPO_ROOT / "static")) -_current_request = ContextVar("current_request", default=None) + +app.include_router(auth_router) +app.include_router(recommendations_router) +app.include_router(strategy_router) +app.include_router(build_admin_router(templates)) +app.include_router(build_content_router(REPO_ROOT)) +app.include_router(build_pages_router(templates, REPO_ROOT, STOCK_REPORT_TEMPLATE)) @app.middleware("http") async def bind_current_request(request: Request, call_next): - token = _current_request.set(request) + token = current_request.set(request) try: return await call_next(request) finally: - _current_request.reset(token) - - -def _is_local_request(request: Request = None) -> bool: - """仅本机调试访问免登录;公网/域名访问仍走完整认证与订阅闸门。""" - request = request or _current_request.get() - if not request or not request.client: - return False - host = (request.client.host or "").split(":")[0] - return host in ("127.0.0.1", "localhost", "::1") - - -def _local_debug_user(): - return {"id": 0, "email": "local@alphax.dev", "is_admin": True, "local_debug": True} - - -class RegisterRequest(BaseModel): - email: str - password: str - invite_code: str = "" - - -class VerifyEmailRequest(BaseModel): - email: str - code: str - - -class LoginRequest(BaseModel): - email: str - password: str - - -class ResendVerificationRequest(BaseModel): - email: str - - -class SendCodeRequest(BaseModel): - email: str - - -class CompleteRegistrationRequest(BaseModel): - email: str - code: str - password: str - invite_code: str = "" - - -class ChangePasswordRequest(BaseModel): - old_password: str - new_password: str - - -class WatchlistRequest(BaseModel): - symbol: str - - -class ObservationRequest(BaseModel): - rec_id: int - note: str = "" - - -class PushRulesRequest(BaseModel): - watchlist_only: bool = False - min_score: int = 0 - min_rr: float = 0 - push_buy_now: bool = True - push_wait_pullback: bool = True - push_observe: bool = False - quiet_start: str = "" - quiet_end: str = "" - - -def _auth_error(exc: Exception, status_code: int = 400): - raise HTTPException(status_code=status_code, detail=str(exc)) - - -def _require_user(altcoin_session: str = ""): - if _is_local_request(): - return _local_debug_user() - user = auth_db.get_user_by_session_token(altcoin_session) - if not user: - raise HTTPException(status_code=401, detail="请先登录") - return user - - -def _require_active_subscription(altcoin_session: str = ""): - if _is_local_request(): - return _local_debug_user(), {"plan_name": "本地调试", "local_debug": True} - user = _require_user(altcoin_session) - sub = auth_db.get_current_subscription(user["id"]) - if not sub: - raise HTTPException(status_code=402, detail="订阅已过期或未开通,请先开通订阅") - return user, sub - - -def _require_admin(altcoin_session: str = ""): - """v1.7.8: 管理员权限校验""" - if _is_local_request(): - return _local_debug_user() - user = _require_user(altcoin_session) - if not auth_db.is_user_admin(user["id"]): - raise HTTPException(status_code=403, detail="需要管理员权限") - return user - - -def _login_redirect(): - return RedirectResponse(url="/auth?tab=login", status_code=302) - - -def _subscription_redirect(): - return RedirectResponse(url="/subscription?expired=1", status_code=302) - - -def _has_active_subscription(user) -> bool: - """页面访问用订阅校验:管理员放行;普通用户必须有未过期订阅。""" - if _is_local_request(): - return True - if not user: - return False - try: - if auth_db.is_user_admin(user["id"]): - return True - except Exception: - pass - return bool(auth_db.get_current_subscription(user["id"])) - - -def _require_page_user(request: Request, require_subscription: bool = True): - if _is_local_request(request): - return _local_debug_user(), None - user = auth_db.get_user_by_session_token(request.cookies.get("altcoin_session", "")) - if not user: - return None, _login_redirect() - if require_subscription and not _has_active_subscription(user): - return user, _subscription_redirect() - return user, None - - -def _require_api_user_with_subscription(altcoin_session: str = ""): - if _is_local_request(): - return _local_debug_user() - user = _require_user(altcoin_session) - if not _has_active_subscription(user): - raise HTTPException(status_code=402, detail="订阅已过期或未开通,请先开通订阅") - return user - -@app.post("/api/auth/register") -async def api_auth_register(req: RegisterRequest): - try: - result = auth_db.register_user(req.email, req.password, req.invite_code) - # SMTP 已配置时验证码只通过邮件发送;未配置时返回 dev_verification_code 便于本地测试。 - smtp_ready = auth_db.is_smtp_configured() - return { - "ok": True, - "user": {k: v for k, v in result.items() if k not in ("verification_code", "user_id")}, - "dev_verification_code": None if smtp_ready else result.get("verification_code"), - "email_sent": bool(result.get("email_sent")), - "message": "注册成功,请查收邮箱验证码" if smtp_ready else "注册成功,请完成邮箱验证码验证", - } - except auth_db.AuthError as exc: - _auth_error(exc) - - -@app.post("/api/auth/send-code") -async def api_auth_send_code(req: SendCodeRequest): - """注册步骤1:仅发送邮箱验证码,不要求密码。""" - try: - result = auth_db.send_registration_code(req.email) - smtp_ready = auth_db.is_smtp_configured() - return { - "ok": True, - "email": result["email"], - "dev_verification_code": None if smtp_ready else result.get("verification_code"), - "email_sent": bool(result.get("email_sent")), - "message": "验证码已发送,请查收邮箱" if smtp_ready else "验证码已生成", - } - except auth_db.AuthError as exc: - _auth_error(exc) - - -@app.post("/api/auth/complete-registration") -async def api_auth_complete_registration(req: CompleteRegistrationRequest): - """注册步骤2:验证验证码 + 设置密码 + 完成注册。""" - try: - user = auth_db.complete_registration(req.email, req.code, req.password, req.invite_code) - return {"ok": True, "user": user, "message": "注册成功,请登录"} - except auth_db.AuthError as exc: - _auth_error(exc) - - -@app.post("/api/auth/verify-email") -async def api_auth_verify_email(req: VerifyEmailRequest): - try: - user = auth_db.verify_email(req.email, req.code) - return {"ok": True, "user": user, "message": "邮箱验证成功"} - except auth_db.AuthError as exc: - _auth_error(exc) - - -@app.post("/api/auth/resend-verification") -async def api_auth_resend_verification(req: ResendVerificationRequest): - try: - result = auth_db.resend_verification_code(req.email) - smtp_ready = auth_db.is_smtp_configured() - return { - "ok": True, - "email": result["email"], - "dev_verification_code": None if smtp_ready else result.get("verification_code"), - "email_sent": bool(result.get("email_sent")), - "message": "验证码已重新发送,请查收邮箱" if smtp_ready else "验证码已重新生成", - } - except auth_db.AuthError as exc: - _auth_error(exc) - - -@app.post("/api/auth/login") -async def api_auth_login(req: LoginRequest, request: Request = None): - try: - session = auth_db.login_user(req.email, req.password) - auth_db.log_user_activity(session["user"]["id"], "login", "auth", - ip=request.client.host if request.client else "") - sub = auth_db.get_current_subscription(session["user"]["id"]) - next_path = "/app" if sub else "/subscription?welcome=1" - resp = JSONResponse({ - "ok": True, - "user": session["user"], - "expires_at": session["expires_at"], - "subscription": sub, - "subscription_active": bool(sub), - "next": next_path, - }) - resp.set_cookie( - "altcoin_session", - session["token"], - httponly=True, - samesite="lax", - max_age=30 * 24 * 3600, - ) - return resp - except auth_db.AuthError as exc: - _auth_error(exc, status_code=400) - - -@app.get("/api/auth/me") -async def api_auth_me(altcoin_session: str = Cookie(default="")): - user = _require_user(altcoin_session) - sub = auth_db.get_current_subscription(user["id"]) - return {"ok": True, "user": user, "subscription": sub, "subscription_active": bool(sub)} - - -@app.post("/api/auth/change-password") -async def api_auth_change_password(req: ChangePasswordRequest, altcoin_session: str = Cookie(default="")): - user = _require_user(altcoin_session) - try: - result = auth_db.change_password(user["id"], req.old_password, req.new_password) - return result - except auth_db.AuthError as exc: - _auth_error(exc) - - -@app.post("/api/auth/logout") -async def api_auth_logout(altcoin_session: str = Cookie(default="")): - auth_db.logout_user(altcoin_session) - resp = JSONResponse({"ok": True, "message": "已退出登录"}) - resp.delete_cookie("altcoin_session") - return resp - - -@app.post("/api/subscriptions/free-trial") -async def api_subscription_free_trial(altcoin_session: str = Cookie(default="")): - user = _require_user(altcoin_session) - try: - sub = auth_db.claim_free_trial(user["id"]) - return {"ok": True, "subscription": sub, "message": "已开通新用户免费体验1个月"} - except auth_db.AuthError as exc: - _auth_error(exc) - - -@app.get("/api/subscription/plans") -async def api_subscription_plans(): - """订阅套餐列表:当前只开放免费体验,USDT 月/季/年为预留态。""" - auth_db.init_auth_db() - conn = auth_db.get_conn() - rows = conn.execute("SELECT * FROM subscription_plan ORDER BY sort_order ASC").fetchall() - conn.close() - return [dict(r) for r in rows] - - -@app.get("/api/referral/stats") -async def api_referral_stats(altcoin_session: str = Cookie(default="")): - """当前用户的推荐统计""" - user = auth_db.get_user_by_session_token(altcoin_session) - if not user: - raise HTTPException(status_code=401, detail="请先登录") - return auth_db.get_referral_stats(user["id"]) - - -@app.get("/api/stats") -async def api_stats(altcoin_session: str = Cookie(default="")): - _require_api_user_with_subscription(altcoin_session) - return get_stats() - - -@app.get("/api/recommendations") -async def api_recommendations( - limit: int = 50, - offset: int = 0, - decision_only: bool = False, - version: str = "", - paged: bool = False, - compact: bool = False, - altcoin_session: str = Cookie(default=""), -): - _require_api_user_with_subscription(altcoin_session) - # 兼容旧前端:默认仍返回数组;paged/compact 任一开启时返回分页对象。 - return get_all_recommendations( - limit, - decision_only=decision_only, - version=version, - offset=offset, - with_meta=(paged or compact), - ) - - -@app.get("/api/recommendations/active") -async def api_recommendations_active( - dedup: bool = True, - actionable_only: bool = True, - version: str = "", - hours: float = 0, - limit: int = 0, - offset: int = 0, - paged: bool = False, - compact: bool = False, - altcoin_session: str = Cookie(default=""), -): - _require_api_user_with_subscription(altcoin_session) - if dedup: - return get_active_recommendations_deduped( - actionable_only=actionable_only, - version=version, - hours=hours, - limit=limit, - offset=offset, - with_meta=(paged or compact), - ) - return get_active_recommendations(actionable_only=actionable_only) - -@app.get("/api/versions") -async def api_versions(view: str = "active", altcoin_session: str = Cookie(default="")): - _require_api_user_with_subscription(altcoin_session) - """返回策略版本及当前视图数量。 - - view=active:只统计实时推荐入场计划(buy_now/wait_pullback) - view=watch:只统计观察池(observe) - 其他:统计全量 active 去重样本 - - v1.7.8 修复: 不再调用 get_active_recommendations_deduped()(该函数默认只取最新版本)。 - 改用直接 SQL 查询全量 active 去重样本,确保所有版本都出现在列表中。 - """ - # 直接 SQL: 按 (symbol, strategy_version) 去重取最新 active - # v1.7.8 fix: 同币种在不同版本都有活跃时,各版本独立计数(不在全局合并) - conn = get_conn() - rows = conn.execute(""" - SELECT r.* FROM recommendation r - JOIN ( - SELECT symbol, strategy_version, MAX(id) AS max_id - FROM recommendation - WHERE status='active' AND strategy_version IS NOT NULL AND strategy_version != '' - GROUP BY symbol, strategy_version - ) latest ON latest.max_id = r.id - ORDER BY r.strategy_version DESC, r.rec_time DESC - """).fetchall() - conn.close() - counts = {} - for row in rows: - item = dict(row) # sqlite3.Row → dict - _derive_execution_fields(item) # 派生 execution_status (原来在 get_active_recommendations_deduped 内部) - version = str(item.get("strategy_version") or "").strip() - if not version: - continue - status = item.get("execution_status") or "observe" - if view == "active" and status not in ("buy_now", "wait_pullback"): - continue - if view == "watch" and status != "observe": - continue - counts[version] = counts.get(version, 0) + 1 - - versions = [{"version": version, "count": count} for version, count in counts.items()] - - # 确保当前 rules.yaml 的版本始终在列表中,哪怕当前视图数量为0 - current_version = str(get_meta().get("strategy_version") or "").strip() - if current_version and current_version not in counts: - versions.append({"version": current_version, "count": 0}) - - def _version_key(v): - try: - parts = v["version"].lstrip("v").split(".") - return tuple(int(p) for p in parts) - except Exception: - return (0,) - versions.sort(key=_version_key, reverse=True) - return versions - - -@app.get("/api/strategy/insights") -async def api_strategy_insights(altcoin_session: str = Cookie(default="")): - _require_api_user_with_subscription(altcoin_session) - return get_strategy_insights() - - -@app.get("/api/strategy/lifecycle") -async def api_strategy_lifecycle(days: int = 30, altcoin_session: str = Cookie(default="")): - """策略发布生命周期总览:研究 → 候选 → 灰度 → 正式发布。""" - _require_api_user_with_subscription(altcoin_session) - return get_strategy_iteration_dashboard(days=days) - - -@app.get("/api/personalization") -async def api_personalization(altcoin_session: str = Cookie(default="")): - user = _require_api_user_with_subscription(altcoin_session) - return { - "watchlist": auth_db.get_watchlist_symbols(user["id"]), - "observations": auth_db.get_saved_observations(user["id"]), - "push_rules": auth_db.get_push_rules(user["id"]), - } - - -@app.post("/api/watchlist") -async def api_add_watchlist(req: WatchlistRequest, altcoin_session: str = Cookie(default="")): - user = _require_api_user_with_subscription(altcoin_session) - auth_db.add_watchlist_symbol(user["id"], req.symbol) - return {"ok": True, "watchlist": auth_db.get_watchlist_symbols(user["id"])} - - -@app.delete("/api/watchlist/{symbol}") -async def api_remove_watchlist(symbol: str, altcoin_session: str = Cookie(default="")): - user = _require_api_user_with_subscription(altcoin_session) - auth_db.remove_watchlist_symbol(user["id"], symbol) - return {"ok": True, "watchlist": auth_db.get_watchlist_symbols(user["id"])} - - -@app.post("/api/observations") -async def api_save_observation(req: ObservationRequest, altcoin_session: str = Cookie(default="")): - user = _require_api_user_with_subscription(altcoin_session) - auth_db.save_observation(user["id"], req.rec_id, req.note) - return {"ok": True, "observations": auth_db.get_saved_observations(user["id"])} - - -@app.delete("/api/observations/{rec_id}") -async def api_remove_observation(rec_id: int, altcoin_session: str = Cookie(default="")): - user = _require_api_user_with_subscription(altcoin_session) - auth_db.remove_observation(user["id"], rec_id) - return {"ok": True, "observations": auth_db.get_saved_observations(user["id"])} - - -@app.post("/api/push-rules") -async def api_update_push_rules(req: PushRulesRequest, altcoin_session: str = Cookie(default="")): - user = _require_api_user_with_subscription(altcoin_session) - rules = auth_db.update_push_rules(user["id"], req.dict()) - return {"ok": True, "push_rules": rules} - - -@app.get("/api/screening") -async def api_screening(hours: int = 24, limit: int = 100, altcoin_session: str = Cookie(default="")): - _require_api_user_with_subscription(altcoin_session) - return get_screening_history(hours, limit) - - -@app.get("/api/review") -async def api_review(altcoin_session: str = Cookie(default="")): - """复盘数据 + 信号权重""" - _require_api_user_with_subscription(altcoin_session) - return get_review_stats() - - -@app.get("/api/weights") -async def api_weights(altcoin_session: str = Cookie(default="")): - """当前动态权重""" - _require_api_user_with_subscription(altcoin_session) - return get_signal_weights() - - -@app.get("/api/cron") -async def api_cron(limit: int = 50, job_name: str = "", altcoin_session: str = Cookie(default="")): - _require_api_user_with_subscription(altcoin_session) - return get_cron_run_logs(limit=limit, job_name=job_name or None) - - -@app.get("/api/cron/summary") -async def api_cron_summary(hours: int = 24, altcoin_session: str = Cookie(default="")): - _require_api_user_with_subscription(altcoin_session) - return get_cron_run_summary(hours=hours) - - -@app.get("/api/sentiment") -async def api_sentiment(hours: int = 6, altcoin_session: str = Cookie(default="")): - """返回舆情监控数据:以消息/事件为主,币种作为关联信息。""" - _require_api_user_with_subscription(altcoin_session) - db = os.getenv("ALPHAX_DB_PATH", str(REPO_ROOT / "data" / "altcoin_monitor.db")) - conn = sqlite3.connect(db) - conn.row_factory = sqlite3.Row - - active_recs = conn.execute( - "SELECT DISTINCT symbol FROM recommendation WHERE status='active'" - ).fetchall() - active_symbols = {r["symbol"].split("/")[0].upper() for r in active_recs} - - recent_screened = conn.execute(""" - SELECT DISTINCT symbol FROM screening_log - WHERE scan_time >= datetime('now', '-' || ? || ' hours') - """, (hours,)).fetchall() - screened_bases = {r["symbol"].split("/")[0].upper() for r in recent_screened} - - events = [] - now_utc = datetime.now(timezone.utc) - - def _parse_event_time(value): - """解析 RSS/ISO 时间;失败返回 None,避免旧闻/脏时间混进短线舆情。""" - if not value: - return None - text = str(value).strip() - for fmt in ("%a, %d %b %Y %H:%M:%S %Z", "%a, %d %b %Y %H:%M:%S GMT"): - try: - return datetime.strptime(text, fmt).replace(tzinfo=timezone.utc) - except Exception: - pass - try: - dt = datetime.fromisoformat(text.replace("Z", "+00:00")) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - return dt.astimezone(timezone.utc) - except Exception: - return None - - def _is_fresh_news(value, max_hours): - dt = _parse_event_time(value) - if not dt: - return False - age_hours = (now_utc - dt).total_seconds() / 3600 - return 0 <= age_hours <= max_hours - - valuable_news_keywords = [ - "listing", "listed", "launch", "launchpool", "megadrop", "airdrop", - "mainnet", "upgrade", "partnership", "integrat", "acquisition", "merge", - "buyback", "burn", "token burn", "funding", "raises", "investment", - "sec", "etf", "approval", "lawsuit", "settlement", "hack", "exploit", - "delist", "suspend", "roadmap", "migration", - "上线", "上币", "合约", "空投", "主网", "升级", "合作", "收购", - "回购", "销毁", "融资", "获投", "监管", "批准", "黑客", "漏洞", "下架", "暂停", - ] - low_value_news_keywords = [ - "price prediction", "price today", "live price", "marketcap and chart", - "what is", "how to buy", "good investment", "forecast", "prediction 2026", - "prediction 2027", "prediction 2030", "technical analysis", "is it time to buy", - "价格预测", "今日价格", "实时价格", "怎么买", "是什么币", - ] - - def _is_valuable_news_title(title): - text = (title or "").lower() - if not text: - return False - if any(k in text for k in low_value_news_keywords): - return False - return any(k in text for k in valuable_news_keywords) - - # 1) v1.7.3 事件驱动舆情:交易所公告 / 高时效消息 / Trending触发,天然是“消息优先”。 - try: - event_rows = conn.execute(""" - SELECT source, symbol, title, url, published_at, detected_at, importance, - event_type, decision, tech_score, rec_id, pushed - FROM event_news - WHERE detected_at >= datetime('now', '-' || ? || ' hours') - ORDER BY datetime(published_at) DESC, id DESC - LIMIT 80 - """, (hours,)).fetchall() - for r in event_rows: - base = (r["symbol"] or "").split("/")[0].upper() - source = r["source"] or "event" - title = r["title"] or "" - event_type = r["event_type"] or "event" - importance = r["importance"] or "B" - decision = r["decision"] or "" - # 纯 CoinGecko Trending 只说明“热度变化”,不是高价值舆情;舆情页不展示。 - # Trending 仍可在策略内部作为热度源参与技术确认,但不占用“有价值舆情”列表。 - if event_type == "market_heat": - continue - events.append({ - "source": source, - "source_label": "Binance公告" if "binance" in source else "CoinGecko热度" if "coingecko" in source else source, - "event_type": event_type, - "importance": importance, - "title": title, - "url": r["url"] or "", - "published_at": r["published_at"], - "detected_at": r["detected_at"], - "related_symbol": r["symbol"], - "related_base": base, - "related_name": "", - "decision": r["decision"] or "", - "tech_score": r["tech_score"] or 0, - "rec_id": r["rec_id"] or 0, - "pushed": bool(r["pushed"]), - "in_active": base in active_symbols, - "in_screened": base in screened_bases, - "price_usd": 0, - "change_24h_pct": 0, - "market_cap_rank": 0, - "trend_rank": None, - }) - except Exception: - pass - - # 2) 旧 sentiment_events:把 CoinGecko Trending 也转成“舆情消息”,不再以币种排名卡为主。 - rows = conn.execute(""" - SELECT symbol, name, trend_rank, trend_score, market_cap_rank, - detected_at, extra_json - FROM sentiment_events - WHERE detected_at = (SELECT MAX(detected_at) FROM sentiment_events WHERE source='coingecko') - ORDER BY trend_rank - """).fetchall() - for r in rows: - raw_extra = r["extra_json"] - if not raw_extra or not isinstance(raw_extra, str) or not raw_extra.strip(): - extra = {} - else: - try: - extra = json.loads(raw_extra) - except Exception: - extra = {} - base = (r["symbol"] or "").upper() - name = r["name"] or base - trend_rank = r["trend_rank"] or 0 - price_usd = extra.get("price_usd", 0) or 0 - change_24h_pct = extra.get("change_24h_pct", 0) or 0 - news_items = extra.get("news", []) or [] - - if news_items: - for n in news_items[:3]: - published = n.get("published") or "" - # 旧闻不能出现在短线舆情页:必须按新闻发布时间过滤,而不是按热度采集时间过滤。 - if not _is_fresh_news(published, hours): - continue - title = n.get("title") or f"{name} 相关新闻" - if not _is_valuable_news_title(title): - continue - events.append({ - "source": n.get("source") or "news", - "source_label": n.get("source") or "新闻", - "event_type": "news", - "importance": "B", - "title": title, - "url": n.get("url") or "", - "published_at": published, - "detected_at": r["detected_at"], - "related_symbol": f"{base}/USDT", - "related_base": base, - "related_name": name, - "decision": "", - "tech_score": 0, - "rec_id": 0, - "pushed": False, - "in_active": base in active_symbols, - "in_screened": base in screened_bases, - "price_usd": price_usd, - "change_24h_pct": change_24h_pct, - "market_cap_rank": r["market_cap_rank"], - "trend_rank": trend_rank, - }) - # 没有高价值相关新闻时,不再把“进入 Trending”本身当舆情消息展示。 - # Trending 可继续在策略内部作为热度信号参与技术确认,但舆情页只保留可解释的事件/新闻。 - - conn.close() - - # 去重:同一标题+币种只保留最新一条 - deduped = [] - seen = set() - for e in events: - key = ((e.get("title") or "").strip().lower(), e.get("related_base"), e.get("source")) - if key in seen: - continue - seen.add(key) - if e.get("in_active"): - e["relation_tag"] = "持仓/活跃推荐" - elif e.get("in_screened"): - e["relation_tag"] = "系统筛选中" - else: - e["relation_tag"] = "关联币种" - deduped.append(e) - - def _sort_key(item): - ts = item.get("published_at") or item.get("detected_at") or "" - imp = {"RISK": 5, "S": 4, "A": 3, "B": 2, "C": 1}.get(item.get("importance"), 0) - return (ts, imp) - deduped.sort(key=_sort_key, reverse=True) - - check_time = deduped[0]["detected_at"] if deduped else None - return { - "check_time": check_time, - "total_events": len(deduped), - "overlap_active": sum(1 for e in deduped if e["in_active"]), - "overlap_screened": sum(1 for e in deduped if e["in_screened"]), - "events": deduped[:80], - # 兼容旧前端/调试字段 - "trending": [], - "total_trending": 0, - } - - -@app.get("/api/kline") -async def api_kline(symbol: str, interval: str = "1d", limit: int = 60, altcoin_session: str = Cookie(default="")): - """返回 Binance K线数据(日线默认60根,前端渲染蜡烛图用)""" - _require_api_user_with_subscription(altcoin_session) - import requests as req - try: - clean = symbol.replace("/", "") - r = req.get( - "https://api.binance.com/api/v3/klines", - params={"symbol": clean, "interval": interval, "limit": limit}, - timeout=10, - ) - if r.status_code != 200: - return JSONResponse({"error": f"Binance {r.status_code}"}, status_code=502) - data = r.json() - candles = [ - { - "time": d[0], "open": float(d[1]), "high": float(d[2]), - "low": float(d[3]), "close": float(d[4]), "volume": float(d[5]), - } - for d in data - ] - return {"symbol": symbol, "interval": interval, "candles": candles} - except Exception as e: - return JSONResponse({"error": str(e)}, status_code=500) - - -@app.get("/", response_class=HTMLResponse) -async def index(): - """落地页 — 原始 HTML,无 Jinja2""" - landing_path = str(REPO_ROOT / "static" / "index.html") - with open(landing_path, "r", encoding="utf-8") as f: - return HTMLResponse(content=f.read()) - - -@app.get("/auth", response_class=HTMLResponse) -async def auth_page(): - """登录/注册页 — 原始 HTML,无 Jinja2""" - auth_path = str(REPO_ROOT / "static" / "auth.html") - with open(auth_path, "r", encoding="utf-8") as f: - return HTMLResponse(content=f.read()) - - -@app.get("/watchlist", response_class=HTMLResponse) -async def watchlist_page(request: Request): - user, redirect = _require_page_user(request) - if redirect: - return redirect - return render_page("watchlist.html", request) - - -@app.get("/strategy", response_class=HTMLResponse) -async def strategy_page(request: Request): - user, redirect = _require_page_user(request) - if redirect: - return redirect - return render_page("strategy.html", request) - - -@app.get("/subscription", response_class=HTMLResponse) -async def subscription_page(request: Request): - user, redirect = _require_page_user(request, require_subscription=False) - if redirect: - return redirect - return render_page("subscription.html", request) - - -@app.get("/referral", response_class=HTMLResponse) -async def referral_page(request: Request, altcoin_session: str = Cookie(default="")): - user, redirect = _require_page_user(request) - if redirect: - return redirect - return render_page("referral.html", request) - - -@app.get("/app", response_class=HTMLResponse) -async def app_page(altcoin_session: str = Cookie(default=""), request: Request = None): - user, redirect = _require_page_user(request) - if redirect: - return redirect - try: - auth_db.log_user_activity(user["id"], "page_view", "app", - ip=request.client.host if request and request.client else "") - except Exception: - pass - resp = templates.TemplateResponse(request=request, name="app.html", context={"show_nav": True}) - resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" - resp.headers["Pragma"] = "no-cache" - resp.headers["Expires"] = "0" - return resp - - -def render_page(template_name: str, request: Request, **kwargs): - """统一的模板渲染,自动注入活动日志""" - try: - user = auth_db.get_user_by_session_token( - request.cookies.get("altcoin_session", "") - ) - if user: - auth_db.log_user_activity(user["id"], "page_view", template_name.replace(".html", ""), - ip=request.client.host if request.client else "") - except Exception: - pass - return templates.TemplateResponse(request=request, name=template_name, - context={"show_nav": True, **kwargs}) - - -@app.get("/api/newsfeed") -async def api_newsfeed(altcoin_session: str = Cookie(default="")): - """聚合外部新闻源:Fear & Greed Index + Google News RSS(EN+CN) + CoinGecko Trending""" - _require_api_user_with_subscription(altcoin_session) - import requests as req - import xml.etree.ElementTree as ET - from email.utils import parsedate_to_datetime - from datetime import datetime, timezone - - result = {"fear_greed": None, "trending": [], "news": []} - now = datetime.now(timezone.utc) - - # 1) Fear & Greed Index - try: - r = req.get("https://api.alternative.me/fng/?limit=1", timeout=8) - if r.status_code == 200: - fg = r.json() - d0 = fg.get("data", [{}])[0] - result["fear_greed"] = { - "value": int(d0.get("value", 50)), - "classification": d0.get("value_classification", ""), - } - except Exception: - pass - - # 2) CoinGecko trending - try: - r = req.get("https://api.coingecko.com/api/v3/search/trending", timeout=10) - if r.status_code == 200: - coins = r.json().get("coins", [])[:7] - for c in coins: - item = c.get("item", {}) - result["trending"].append({ - "name": item.get("name", ""), - "symbol": item.get("symbol", ""), - "market_cap_rank": item.get("market_cap_rank"), - "thumb": item.get("thumb", ""), - }) - except Exception: - pass - - # 3) Google News RSS - def fetch_google_news(query, hl, gl, ceid, label): - items = [] - try: - url = f"https://news.google.com/rss/search?q={req.utils.quote(query)}&hl={hl}&gl={gl}&ceid={ceid}" - r = req.get(url, timeout=12, headers={"User-Agent": "Mozilla/5.0"}) - if r.status_code != 200: - return items - root = ET.fromstring(r.text) - for el in root.findall(".//item")[:15]: - pub_str = el.findtext("pubDate", "") - dt = parsedate_to_datetime(pub_str) if pub_str else None - age_h = round((now - dt).total_seconds() / 3600, 1) if dt else None - if age_h is not None and age_h > 48: - continue - items.append({ - "title": (el.findtext("title", "") or "")[:120], - "url": el.findtext("link", "") or "", - "source": (el.findtext("source", "") or "")[:30], - "age_hours": age_h, - "lang": label, - }) - except Exception: - pass - return items - - en_news = fetch_google_news( - "cryptocurrency OR bitcoin OR ethereum OR defi OR altcoin when:24h", - "en-US", "US", "US:en", "en" - ) - cn_news = fetch_google_news( - "加密货币 OR 比特币 OR 以太坊 OR DeFi OR Web3 when:24h", - "zh-CN", "CN", "CN:zh-Hans", "cn" - ) - result["news"] = sorted(en_news + cn_news, key=lambda x: x.get("age_hours") or 999)[:30] - - return result - - -@app.get("/sentiment", response_class=HTMLResponse) -async def sentiment_page(request: Request): - user, redirect = _require_page_user(request) - if redirect: - return redirect - return render_page("sentiment.html", request) -async def stock_report_page(): - return HTMLResponse(content=STOCK_REPORT_TEMPLATE) - - -@app.get("/iteration", response_class=HTMLResponse) -async def iteration_page(request: Request): - user, redirect = _require_page_user(request) - if redirect: - return redirect - return render_page("iteration.html", request) - - -@app.get("/api/iterations") -async def api_iterations(limit: int = 30, altcoin_session: str = Cookie(default="")): - """返回策略迭代历史""" - _require_api_user_with_subscription(altcoin_session) - conn = get_conn() - rows = conn.execute( - "SELECT * FROM strategy_iteration_log ORDER BY id DESC LIMIT ?", - (limit,) - ).fetchall() - conn.close() - return [dict(r) for r in rows] - - -@app.get("/api/strategy/candidates") -async def api_strategy_candidates(limit: int = 50, status: str = "", altcoin_session: str = Cookie(default="")): - """候选/灰度规则池:研究结论先入池,达到样本门槛后再发布。""" - _require_api_user_with_subscription(altcoin_session) - return get_strategy_rule_candidates(limit=limit, status=status or None) - - -@app.get("/api/strategy/failures") -async def api_strategy_failures(limit: int = 50, altcoin_session: str = Cookie(default="")): - """失败模式归因明细。""" - _require_api_user_with_subscription(altcoin_session) - return get_strategy_failure_patterns(limit=limit) - - -@app.post("/api/strategy/candidates/refresh") -async def api_strategy_candidates_refresh(altcoin_session: str = Cookie(default="")): - """手动刷新候选规则表现与生命周期状态。""" - _require_api_user_with_subscription(altcoin_session) - return {"updated": refresh_strategy_candidate_performance()} - - -@app.get("/api/strategy/candidates/dry-run") -async def api_strategy_candidates_dry_run(altcoin_session: str = Cookie(default="")): - """候选规则表现 dry-run:只读评估,不写库、不升版。""" - _require_api_user_with_subscription(altcoin_session) - return dry_run_strategy_candidate_performance() - - -@app.post("/api/strategy/failures/backfill") -async def api_strategy_failures_backfill(dry_run: bool = False): - """历史失败模式回填:默认写库,dry_run=true时只预览。""" - return backfill_strategy_failure_patterns(dry_run=dry_run) - - -@app.post("/api/strategy/candidates/generate-history") -async def api_strategy_candidates_generate_history(dry_run: bool = False): - """从历史review_log自动生成候选规则池:默认写库,dry_run=true时只预览。""" - result = generate_candidates_from_review_history(dry_run=dry_run) - if not dry_run: - result["refreshed"] = refresh_strategy_candidate_performance() - return result - - -STOCK_REPORT_TEMPLATE = open( - REPO_ROOT / "templates" / "stock_report_template.html", - "r", - encoding="utf-8", -).read() - -HTML_PAGE = r''' - - - - -AlphaX - - - - - -
- -
-
- - __STRATEGY_VERSION__ -
-
- - -
-
- -
- -
- - - - - - -
- - -
- 📦 策略版本: - - -
- -
-
-
-
-
-
- - -
- -''' -@app.get("/admin.html", response_class=HTMLResponse) -async def admin_page(request: Request, altcoin_session: str = Cookie(default="")): - if not auth_db.get_user_by_session_token(altcoin_session): - return _login_redirect() - try: - _require_admin(altcoin_session) - except HTTPException as e: - return HTMLResponse(content=f"

需要管理员权限

{e.detail}

返回看板", status_code=e.status_code) - return templates.TemplateResponse(request=request, name="admin.html", context={"show_nav": True}) - - -# ====== ADMIN API (v1.7.8) ====== - -@app.get("/api/admin/check") -async def api_admin_check(altcoin_session: str = Cookie(default="")): - """前端检查当前用户是否为管理员""" - try: - user = _require_admin(altcoin_session) - return {"is_admin": True, "email": user.get("email", "")} - except HTTPException: - return {"is_admin": False} - - -@app.get("/api/admin/stats") -async def api_admin_stats(altcoin_session: str = Cookie(default="")): - _require_admin(altcoin_session) - return auth_db.get_admin_stats() - - -@app.get("/api/admin/users") -async def api_admin_users(search: str = "", offset: int = 0, limit: int = 50, tab: str = "all", - altcoin_session: str = Cookie(default="")): - _require_admin(altcoin_session) - return auth_db.get_admin_users(search=search, offset=offset, limit=limit, tab=tab) - - -@app.get("/api/admin/orders") -async def api_admin_orders(search: str = "", offset: int = 0, limit: int = 50, status: str = "all", - altcoin_session: str = Cookie(default="")): - _require_admin(altcoin_session) - return auth_db.get_admin_orders(search=search, offset=offset, limit=limit, status=status) + current_request.reset(token) diff --git a/docker/scheduler.py b/docker/scheduler.py index 4eab4e3..481ccbe 100755 --- a/docker/scheduler.py +++ b/docker/scheduler.py @@ -25,7 +25,7 @@ DRY_RUN = os.getenv("ALPHAX_SCHEDULER_DRY_RUN", "1").strip() not in {"0", "false @dataclass class Job: name: str - module: str + command: str every_seconds: int args: tuple[str, ...] = () initial_delay: int = 0 @@ -43,7 +43,7 @@ def env_for_child() -> dict[str, str]: def run_job(job: Job) -> None: - cmd = [PYTHON, "-m", job.module, *job.args] + cmd = [PYTHON, "-m", "app.cli", job.command, *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) @@ -75,12 +75,12 @@ def run_job(job: Job) -> None: def build_jobs() -> list[Job]: # 与当前宿主机 crontab 对齐,但串行执行。 return [ - Job("event", "app.services.event_driven_screener", 60, ("--once",), initial_delay=5), - Job("tracker", "app.services.price_tracker", 180, initial_delay=20), - Job("confirm", "app.services.altcoin_confirm", 600, initial_delay=40), - Job("screener", "app.services.altcoin_screener", 900, initial_delay=80), - Job("sentiment", "app.services.sentiment_monitor", 1800, ("--collect",), initial_delay=120), - Job("review", "app.services.review_engine", 24 * 3600, initial_delay=300), + Job("event", "event", 60, initial_delay=5), + Job("tracker", "tracker", 180, initial_delay=20), + Job("confirm", "confirm", 600, initial_delay=40), + Job("screener", "screener", 900, initial_delay=80), + Job("sentiment", "sentiment", 1800, ("--collect",), initial_delay=120), + Job("review", "review", 24 * 3600, initial_delay=300), ] diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md index d28560b..d93858a 100644 --- a/docs/PROJECT_STRUCTURE.md +++ b/docs/PROJECT_STRUCTURE.md @@ -45,6 +45,28 @@ - `app/integrations/feishu_push.py` - `app/integrations/feishu_review_push.py` +当前已新增的结构化外观层: + +- `app/web/routes_auth.py` +- `app/web/routes_recommendations.py` +- `app/web/routes_strategy.py` +- `app/web/routes_admin.py` +- `app/web/routes_pages.py` +- `app/web/routes_content.py` +- `app/web/shared.py` + +当前已新增的 DB 分组接口层: + +- `app/db/schema.py` +- `app/db/recommendation_queries.py` +- `app/db/review_queries.py` +- `app/db/analytics.py` +- `app/db/admin_queries.py` + +当前已新增的统一命令入口: + +- `app/cli.py` + ### 4. 配置与部署 - `rules.yaml` @@ -106,10 +128,9 @@ 如果继续整理,建议下一步做实现层拆分,而不是再搬目录: -1. 拆 `web_server.py` -2. 拆 `altcoin_db.py` -3. 为 `rules.yaml` 增加 schema 校验 -4. 为主脚本建立统一 CLI 入口 +1. 继续拆 `altcoin_db.py` +2. 深化 `rules.yaml` 的子字段 schema 校验 +3. 让 `docker/` 和文档统一收敛到 `app/cli.py` ## 当前结论 diff --git a/legacy/price_tracker_ws.py b/legacy/price_tracker_ws.py index bae0a39..b785d7a 100644 --- a/legacy/price_tracker_ws.py +++ b/legacy/price_tracker_ws.py @@ -22,6 +22,7 @@ from app.db.altcoin_db import ( log_push, ) from app.integrations.feishu_push import push_altcoin_tp_sl_alert +from app.core.opportunity_lifecycle import apply_entry_quality_gate POLL_INTERVAL = 5 REFRESH_INTERVAL = 60 @@ -49,7 +50,7 @@ def check_triggers(symbol, rec, current_price): action, signals = detect_candidate_action(rec, current_price, {}) if not action: return None - return {"action_status": action, "signals": signals} + return {"action_status": action, "signals": signals, "pushable": action == "可即刻买入"} def detect_candidate_action(rec, current_price, track_result): @@ -68,14 +69,40 @@ def detect_candidate_action(rec, current_price, track_result): entry_price = rec.get("entry_price", 0) or 0 cur_action = rec.get("action_status", "持有") + candidate_action = None + candidate_signals = [] 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): + candidate_action = "可即刻买入" + candidate_signals = [f"回踩到位 ${plan_entry_price:.6f}"] + elif entry_action in ("即刻买入", "可即刻买入") and current_price <= (plan_entry_price or entry_price): ep["entry_trigger_confirmed"] = True - return "可即刻买入", ["入场条件仍满足"] - if cur_action == "可即刻买入": - return "可即刻买入", ["入场窗口延续"] + candidate_action = "可即刻买入" + candidate_signals = ["入场条件仍满足"] + elif cur_action == "可即刻买入": + candidate_action = "可即刻买入" + candidate_signals = ["入场窗口延续"] + + if candidate_action: + raw_signals = rec.get("signals", []) + if isinstance(raw_signals, str): + try: + raw_signals = json.loads(raw_signals) + except Exception: + raw_signals = [raw_signals] if raw_signals else [] + gated_action, gated_plan, reasons = apply_entry_quality_gate( + action_status=candidate_action, + entry_plan=ep, + signals=raw_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 gated_action != "可即刻买入": + reason = reasons[0] if reasons else "买点质量闸门降级" + return gated_action, [reason] + return gated_action, candidate_signals return None, [] diff --git a/requirements.txt b/requirements.txt index d07918f..db6569e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,5 @@ PyYAML==6.0.2 pydantic==2.10.4 python-multipart==0.0.20 pytest==8.3.4 +httpx==0.28.1 +jinja2==3.1.6 diff --git a/static/app.html b/static/app.html index 7b30e6e..7b4a708 100644 --- a/static/app.html +++ b/static/app.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}AlphaX — 看板{% endblock %} +{% block title %}AlphaX / Omnix — 看板{% endblock %} {% block extra_head_css %} @@ -248,9 +248,10 @@ {% block content %}
+
- +
@@ -280,6 +281,7 @@ {% block extra_script %}