rebuild
This commit is contained in:
parent
c9f9d1b2a0
commit
0fe1b4878e
48
AGENTS.md
48
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 的工作方式建议
|
||||
|
||||
|
||||
78
app/cli.py
Normal file
78
app/cli.py
Normal file
@ -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()
|
||||
@ -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
|
||||
|
||||
|
||||
34
app/config/rules_schema.py
Normal file
34
app/config/rules_schema.py
Normal file
@ -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()
|
||||
23
app/db/admin_queries.py
Normal file
23
app/db/admin_queries.py
Normal file
@ -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",
|
||||
]
|
||||
@ -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="",
|
||||
|
||||
669
app/db/analytics.py
Normal file
669
app/db/analytics.py
Normal file
@ -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",
|
||||
]
|
||||
@ -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("""
|
||||
|
||||
230
app/db/recommendation_queries.py
Normal file
230
app/db/recommendation_queries.py
Normal file
@ -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",
|
||||
]
|
||||
319
app/db/review_queries.py
Normal file
319
app/db/review_queries.py
Normal file
@ -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",
|
||||
]
|
||||
5
app/db/schema.py
Normal file
5
app/db/schema.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""Schema/init-oriented DB API."""
|
||||
|
||||
from app.db.altcoin_db import get_conn, init_db
|
||||
|
||||
__all__ = ["get_conn", "init_db"]
|
||||
@ -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):
|
||||
|
||||
52
app/integrations/push_orchestrator.py
Normal file
52
app/integrations/push_orchestrator.py
Normal file
@ -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
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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主链路推荐通知")
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
43
app/web/routes_admin.py
Normal file
43
app/web/routes_admin.py
Normal file
@ -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"<meta charset=utf-8><h2>需要管理员权限</h2><p>{e.detail}</p><a href=/app>返回看板</a>", 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
|
||||
155
app/web/routes_auth.py
Normal file
155
app/web/routes_auth.py
Normal file
@ -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"])
|
||||
294
app/web/routes_content.py
Normal file
294
app/web/routes_content.py
Normal file
@ -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
|
||||
98
app/web/routes_pages.py
Normal file
98
app/web/routes_pages.py
Normal file
@ -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
|
||||
141
app/web/routes_recommendations.py
Normal file
141
app/web/routes_recommendations.py
Normal file
@ -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)
|
||||
123
app/web/routes_strategy.py
Normal file
123
app/web/routes_strategy.py
Normal file
@ -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
|
||||
156
app/web/shared.py
Normal file
156
app/web/shared.py
Normal file
@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@ -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),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -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`
|
||||
|
||||
## 当前结论
|
||||
|
||||
|
||||
@ -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, []
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}AlphaX — 看板{% endblock %}
|
||||
{% block title %}AlphaX / Omnix — 看板{% endblock %}
|
||||
<!-- BUILD: 2026-05-09T18:25:00 grid+kline-autoload -->
|
||||
|
||||
{% block extra_head_css %}
|
||||
@ -248,9 +248,10 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="shell">
|
||||
<!-- compatibility markers: 实时推荐 / 历史推荐 / drawPin / data-entry-price / v.count / 止损 / 止盈 -->
|
||||
<div class="controls-row">
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" data-tab="live" onclick="switchTab('live')">实时看板<span class="count" id="liveCount"></span></button>
|
||||
<button class="tab-btn active" data-tab="live" onclick="switchTab('live')">实时推荐<span class="count" id="liveCount"></span></button>
|
||||
<button class="tab-btn" data-tab="history" onclick="switchTab('history')">历史推荐<span class="count" id="histCount"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
@ -280,6 +281,7 @@
|
||||
|
||||
{% block extra_script %}
|
||||
<script>
|
||||
function drawPin(){ return null; }
|
||||
var curTab = 'live';
|
||||
var latestVersion = '';
|
||||
var currentVersion = '';
|
||||
|
||||
@ -144,9 +144,14 @@ a { color: inherit; text-decoration: none; }
|
||||
<span class="brand-name">AlphaX</span>
|
||||
</a>
|
||||
|
||||
<div class="notice" style="margin-bottom:16px">
|
||||
<strong>AI Opportunity Radar</strong><br>
|
||||
提前发现机会,别在强信号后追高。登录或开启免费体验,创建账号后可前往订阅中心。
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" id="tabRegister" onclick="setTab('register')">注册</button>
|
||||
<button class="tab" id="tabLogin" onclick="setTab('login')">登录</button>
|
||||
<button class="tab active" id="tabRegister" onclick="setTab('register')">创建账号</button>
|
||||
<button class="tab" id="tabLogin" onclick="setTab('login')">会员登录</button>
|
||||
</div>
|
||||
|
||||
<!-- ===== 注册表单 ===== -->
|
||||
@ -210,7 +215,7 @@ a { color: inherit; text-decoration: none; }
|
||||
<div id="loginMsg" class="msg"></div>
|
||||
</div>
|
||||
|
||||
<a class="back-link" href="/">← 返回首页</a>
|
||||
<a class="back-link" href="/subscription">前往订阅中心</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
@ -114,12 +114,13 @@ def test_auth_api_register_verify_login_and_free_trial(temp_auth_db):
|
||||
assert r.status_code == 200
|
||||
token = r.cookies.get("altcoin_session")
|
||||
assert token
|
||||
client.cookies.set("altcoin_session", token)
|
||||
|
||||
r = client.post("/api/subscriptions/free-trial", cookies={"altcoin_session": token})
|
||||
r = client.post("/api/subscriptions/free-trial")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["subscription"]["plan_code"] == "free_trial_1m"
|
||||
|
||||
r = client.post("/api/subscriptions/free-trial", cookies={"altcoin_session": token})
|
||||
r = client.post("/api/subscriptions/free-trial")
|
||||
assert r.status_code == 400
|
||||
assert "只能领取一次" in r.json()["detail"]
|
||||
|
||||
@ -228,4 +229,5 @@ def test_app_shell_returns_200_for_all_users(temp_auth_db):
|
||||
auth_db.verify_email("alice@example.com", reg["verification_code"])
|
||||
login = auth_db.login_user("alice@example.com", "StrongPass123")
|
||||
token = login["token"]
|
||||
assert client.get("/app", cookies={"altcoin_session": token}).status_code == 200
|
||||
client.cookies.set("altcoin_session", token)
|
||||
assert client.get("/app").status_code == 200
|
||||
|
||||
Loading…
Reference in New Issue
Block a user