This commit is contained in:
aaron 2026-05-13 23:50:02 +08:00
parent c9f9d1b2a0
commit 0fe1b4878e
33 changed files with 2775 additions and 5132 deletions

View File

@ -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
View 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()

View File

@ -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

View 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
View 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",
]

View File

@ -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
View 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",
]

View File

@ -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("""

View 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
View 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
View 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"]

View File

@ -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):

View 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

View File

@ -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)

View File

@ -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)

View File

@ -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主链路推荐通知")

View File

@ -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()

View File

@ -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
View 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
View 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
View 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
View 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

View 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
View 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
View 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

View File

@ -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),
]

View File

@ -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`
## 当前结论

View File

@ -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, []

View File

@ -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

View File

@ -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 = '';

View File

@ -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>

View File

@ -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