alphax/web_server.py
2026-05-13 22:32:50 +08:00

4250 lines
183 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
山寨币监控网站 — FastAPI后端 v11纯前瞻信号版
新增复盘tab + 信号权重API
"""
import sys
import os
import json
import sqlite3
from datetime import datetime, timezone
from contextvars import ContextVar
from fastapi import FastAPI, HTTPException, Cookie, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
import auth_db
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from altcoin_db import (
init_db, get_active_recommendations, get_active_recommendations_deduped, get_all_recommendations,
get_screening_history, get_stats, get_review_stats, get_cron_run_logs, get_cron_run_summary,
get_conn, _derive_execution_fields, get_strategy_insights, get_strategy_rule_candidates,
get_strategy_failure_patterns, get_strategy_iteration_dashboard, refresh_strategy_candidate_performance,
dry_run_strategy_candidate_performance, backfill_strategy_failure_patterns,
generate_candidates_from_review_history,
)
from config_loader import get_signal_weights, get_meta
app = FastAPI(title="山寨币爆发监控 v11")
templates = Jinja2Templates(directory="static")
_current_request = ContextVar("current_request", default=None)
@app.middleware("http")
async def bind_current_request(request: Request, call_next):
token = _current_request.set(request)
try:
return await call_next(request)
finally:
_current_request.reset(token)
def _is_local_request(request: Request = None) -> bool:
"""仅本机调试访问免登录;公网/域名访问仍走完整认证与订阅闸门。"""
request = request or _current_request.get()
if not request or not request.client:
return False
host = (request.client.host or "").split(":")[0]
return host in ("127.0.0.1", "localhost", "::1")
def _local_debug_user():
return {"id": 0, "email": "local@alphax.dev", "is_admin": True, "local_debug": True}
class RegisterRequest(BaseModel):
email: str
password: str
invite_code: str = ""
class VerifyEmailRequest(BaseModel):
email: str
code: str
class LoginRequest(BaseModel):
email: str
password: str
class ResendVerificationRequest(BaseModel):
email: str
class SendCodeRequest(BaseModel):
email: str
class CompleteRegistrationRequest(BaseModel):
email: str
code: str
password: str
invite_code: str = ""
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
class WatchlistRequest(BaseModel):
symbol: str
class ObservationRequest(BaseModel):
rec_id: int
note: str = ""
class PushRulesRequest(BaseModel):
watchlist_only: bool = False
min_score: int = 0
min_rr: float = 0
push_buy_now: bool = True
push_wait_pullback: bool = True
push_observe: bool = False
quiet_start: str = ""
quiet_end: str = ""
def _auth_error(exc: Exception, status_code: int = 400):
raise HTTPException(status_code=status_code, detail=str(exc))
def _require_user(altcoin_session: str = ""):
if _is_local_request():
return _local_debug_user()
user = auth_db.get_user_by_session_token(altcoin_session)
if not user:
raise HTTPException(status_code=401, detail="请先登录")
return user
def _require_active_subscription(altcoin_session: str = ""):
if _is_local_request():
return _local_debug_user(), {"plan_name": "本地调试", "local_debug": True}
user = _require_user(altcoin_session)
sub = auth_db.get_current_subscription(user["id"])
if not sub:
raise HTTPException(status_code=402, detail="订阅已过期或未开通,请先开通订阅")
return user, sub
def _require_admin(altcoin_session: str = ""):
"""v1.7.8: 管理员权限校验"""
if _is_local_request():
return _local_debug_user()
user = _require_user(altcoin_session)
if not auth_db.is_user_admin(user["id"]):
raise HTTPException(status_code=403, detail="需要管理员权限")
return user
def _login_redirect():
return RedirectResponse(url="/auth?tab=login", status_code=302)
def _subscription_redirect():
return RedirectResponse(url="/subscription?expired=1", status_code=302)
def _has_active_subscription(user) -> bool:
"""页面访问用订阅校验:管理员放行;普通用户必须有未过期订阅。"""
if _is_local_request():
return True
if not user:
return False
try:
if auth_db.is_user_admin(user["id"]):
return True
except Exception:
pass
return bool(auth_db.get_current_subscription(user["id"]))
def _require_page_user(request: Request, require_subscription: bool = True):
if _is_local_request(request):
return _local_debug_user(), None
user = auth_db.get_user_by_session_token(request.cookies.get("altcoin_session", ""))
if not user:
return None, _login_redirect()
if require_subscription and not _has_active_subscription(user):
return user, _subscription_redirect()
return user, None
def _require_api_user_with_subscription(altcoin_session: str = ""):
if _is_local_request():
return _local_debug_user()
user = _require_user(altcoin_session)
if not _has_active_subscription(user):
raise HTTPException(status_code=402, detail="订阅已过期或未开通,请先开通订阅")
return user
@app.post("/api/auth/register")
async def api_auth_register(req: RegisterRequest):
try:
result = auth_db.register_user(req.email, req.password, req.invite_code)
# SMTP 已配置时验证码只通过邮件发送;未配置时返回 dev_verification_code 便于本地测试。
smtp_ready = auth_db.is_smtp_configured()
return {
"ok": True,
"user": {k: v for k, v in result.items() if k not in ("verification_code", "user_id")},
"dev_verification_code": None if smtp_ready else result.get("verification_code"),
"email_sent": bool(result.get("email_sent")),
"message": "注册成功,请查收邮箱验证码" if smtp_ready else "注册成功,请完成邮箱验证码验证",
}
except auth_db.AuthError as exc:
_auth_error(exc)
@app.post("/api/auth/send-code")
async def api_auth_send_code(req: SendCodeRequest):
"""注册步骤1仅发送邮箱验证码不要求密码。"""
try:
result = auth_db.send_registration_code(req.email)
smtp_ready = auth_db.is_smtp_configured()
return {
"ok": True,
"email": result["email"],
"dev_verification_code": None if smtp_ready else result.get("verification_code"),
"email_sent": bool(result.get("email_sent")),
"message": "验证码已发送,请查收邮箱" if smtp_ready else "验证码已生成",
}
except auth_db.AuthError as exc:
_auth_error(exc)
@app.post("/api/auth/complete-registration")
async def api_auth_complete_registration(req: CompleteRegistrationRequest):
"""注册步骤2验证验证码 + 设置密码 + 完成注册。"""
try:
user = auth_db.complete_registration(req.email, req.code, req.password, req.invite_code)
return {"ok": True, "user": user, "message": "注册成功,请登录"}
except auth_db.AuthError as exc:
_auth_error(exc)
@app.post("/api/auth/verify-email")
async def api_auth_verify_email(req: VerifyEmailRequest):
try:
user = auth_db.verify_email(req.email, req.code)
return {"ok": True, "user": user, "message": "邮箱验证成功"}
except auth_db.AuthError as exc:
_auth_error(exc)
@app.post("/api/auth/resend-verification")
async def api_auth_resend_verification(req: ResendVerificationRequest):
try:
result = auth_db.resend_verification_code(req.email)
smtp_ready = auth_db.is_smtp_configured()
return {
"ok": True,
"email": result["email"],
"dev_verification_code": None if smtp_ready else result.get("verification_code"),
"email_sent": bool(result.get("email_sent")),
"message": "验证码已重新发送,请查收邮箱" if smtp_ready else "验证码已重新生成",
}
except auth_db.AuthError as exc:
_auth_error(exc)
@app.post("/api/auth/login")
async def api_auth_login(req: LoginRequest, request: Request = None):
try:
session = auth_db.login_user(req.email, req.password)
auth_db.log_user_activity(session["user"]["id"], "login", "auth",
ip=request.client.host if request.client else "")
sub = auth_db.get_current_subscription(session["user"]["id"])
next_path = "/app" if sub else "/subscription?welcome=1"
resp = JSONResponse({
"ok": True,
"user": session["user"],
"expires_at": session["expires_at"],
"subscription": sub,
"subscription_active": bool(sub),
"next": next_path,
})
resp.set_cookie(
"altcoin_session",
session["token"],
httponly=True,
samesite="lax",
max_age=30 * 24 * 3600,
)
return resp
except auth_db.AuthError as exc:
_auth_error(exc, status_code=400)
@app.get("/api/auth/me")
async def api_auth_me(altcoin_session: str = Cookie(default="")):
user = _require_user(altcoin_session)
sub = auth_db.get_current_subscription(user["id"])
return {"ok": True, "user": user, "subscription": sub, "subscription_active": bool(sub)}
@app.post("/api/auth/change-password")
async def api_auth_change_password(req: ChangePasswordRequest, altcoin_session: str = Cookie(default="")):
user = _require_user(altcoin_session)
try:
result = auth_db.change_password(user["id"], req.old_password, req.new_password)
return result
except auth_db.AuthError as exc:
_auth_error(exc)
@app.post("/api/auth/logout")
async def api_auth_logout(altcoin_session: str = Cookie(default="")):
auth_db.logout_user(altcoin_session)
resp = JSONResponse({"ok": True, "message": "已退出登录"})
resp.delete_cookie("altcoin_session")
return resp
@app.post("/api/subscriptions/free-trial")
async def api_subscription_free_trial(altcoin_session: str = Cookie(default="")):
user = _require_user(altcoin_session)
try:
sub = auth_db.claim_free_trial(user["id"])
return {"ok": True, "subscription": sub, "message": "已开通新用户免费体验1个月"}
except auth_db.AuthError as exc:
_auth_error(exc)
@app.get("/api/subscription/plans")
async def api_subscription_plans():
"""订阅套餐列表当前只开放免费体验USDT 月/季/年为预留态。"""
auth_db.init_auth_db()
conn = auth_db.get_conn()
rows = conn.execute("SELECT * FROM subscription_plan ORDER BY sort_order ASC").fetchall()
conn.close()
return [dict(r) for r in rows]
@app.get("/api/referral/stats")
async def api_referral_stats(altcoin_session: str = Cookie(default="")):
"""当前用户的推荐统计"""
user = auth_db.get_user_by_session_token(altcoin_session)
if not user:
raise HTTPException(status_code=401, detail="请先登录")
return auth_db.get_referral_stats(user["id"])
@app.get("/api/stats")
async def api_stats(altcoin_session: str = Cookie(default="")):
_require_api_user_with_subscription(altcoin_session)
return get_stats()
@app.get("/api/recommendations")
async def api_recommendations(
limit: int = 50,
offset: int = 0,
decision_only: bool = False,
version: str = "",
paged: bool = False,
compact: bool = False,
altcoin_session: str = Cookie(default=""),
):
_require_api_user_with_subscription(altcoin_session)
# 兼容旧前端默认仍返回数组paged/compact 任一开启时返回分页对象。
return get_all_recommendations(
limit,
decision_only=decision_only,
version=version,
offset=offset,
with_meta=(paged or compact),
)
@app.get("/api/recommendations/active")
async def api_recommendations_active(
dedup: bool = True,
actionable_only: bool = True,
version: str = "",
hours: float = 0,
limit: int = 0,
offset: int = 0,
paged: bool = False,
compact: bool = False,
altcoin_session: str = Cookie(default=""),
):
_require_api_user_with_subscription(altcoin_session)
if dedup:
return get_active_recommendations_deduped(
actionable_only=actionable_only,
version=version,
hours=hours,
limit=limit,
offset=offset,
with_meta=(paged or compact),
)
return get_active_recommendations(actionable_only=actionable_only)
@app.get("/api/versions")
async def api_versions(view: str = "active", altcoin_session: str = Cookie(default="")):
_require_api_user_with_subscription(altcoin_session)
"""返回策略版本及当前视图数量。
view=active只统计实时推荐入场计划buy_now/wait_pullback
view=watch只统计观察池observe
其他:统计全量 active 去重样本
v1.7.8 修复: 不再调用 get_active_recommendations_deduped()(该函数默认只取最新版本)。
改用直接 SQL 查询全量 active 去重样本,确保所有版本都出现在列表中。
"""
# 直接 SQL: 按 (symbol, strategy_version) 去重取最新 active
# v1.7.8 fix: 同币种在不同版本都有活跃时,各版本独立计数(不在全局合并)
conn = get_conn()
rows = conn.execute("""
SELECT r.* FROM recommendation r
JOIN (
SELECT symbol, strategy_version, MAX(id) AS max_id
FROM recommendation
WHERE status='active' AND strategy_version IS NOT NULL AND strategy_version != ''
GROUP BY symbol, strategy_version
) latest ON latest.max_id = r.id
ORDER BY r.strategy_version DESC, r.rec_time DESC
""").fetchall()
conn.close()
counts = {}
for row in rows:
item = dict(row) # sqlite3.Row → dict
_derive_execution_fields(item) # 派生 execution_status (原来在 get_active_recommendations_deduped 内部)
version = str(item.get("strategy_version") or "").strip()
if not version:
continue
status = item.get("execution_status") or "observe"
if view == "active" and status not in ("buy_now", "wait_pullback"):
continue
if view == "watch" and status != "observe":
continue
counts[version] = counts.get(version, 0) + 1
versions = [{"version": version, "count": count} for version, count in counts.items()]
# 确保当前 rules.yaml 的版本始终在列表中哪怕当前视图数量为0
current_version = str(get_meta().get("strategy_version") or "").strip()
if current_version and current_version not in counts:
versions.append({"version": current_version, "count": 0})
def _version_key(v):
try:
parts = v["version"].lstrip("v").split(".")
return tuple(int(p) for p in parts)
except Exception:
return (0,)
versions.sort(key=_version_key, reverse=True)
return versions
@app.get("/api/strategy/insights")
async def api_strategy_insights(altcoin_session: str = Cookie(default="")):
_require_api_user_with_subscription(altcoin_session)
return get_strategy_insights()
@app.get("/api/strategy/lifecycle")
async def api_strategy_lifecycle(days: int = 30, altcoin_session: str = Cookie(default="")):
"""策略发布生命周期总览:研究 → 候选 → 灰度 → 正式发布。"""
_require_api_user_with_subscription(altcoin_session)
return get_strategy_iteration_dashboard(days=days)
@app.get("/api/personalization")
async def api_personalization(altcoin_session: str = Cookie(default="")):
user = _require_api_user_with_subscription(altcoin_session)
return {
"watchlist": auth_db.get_watchlist_symbols(user["id"]),
"observations": auth_db.get_saved_observations(user["id"]),
"push_rules": auth_db.get_push_rules(user["id"]),
}
@app.post("/api/watchlist")
async def api_add_watchlist(req: WatchlistRequest, altcoin_session: str = Cookie(default="")):
user = _require_api_user_with_subscription(altcoin_session)
auth_db.add_watchlist_symbol(user["id"], req.symbol)
return {"ok": True, "watchlist": auth_db.get_watchlist_symbols(user["id"])}
@app.delete("/api/watchlist/{symbol}")
async def api_remove_watchlist(symbol: str, altcoin_session: str = Cookie(default="")):
user = _require_api_user_with_subscription(altcoin_session)
auth_db.remove_watchlist_symbol(user["id"], symbol)
return {"ok": True, "watchlist": auth_db.get_watchlist_symbols(user["id"])}
@app.post("/api/observations")
async def api_save_observation(req: ObservationRequest, altcoin_session: str = Cookie(default="")):
user = _require_api_user_with_subscription(altcoin_session)
auth_db.save_observation(user["id"], req.rec_id, req.note)
return {"ok": True, "observations": auth_db.get_saved_observations(user["id"])}
@app.delete("/api/observations/{rec_id}")
async def api_remove_observation(rec_id: int, altcoin_session: str = Cookie(default="")):
user = _require_api_user_with_subscription(altcoin_session)
auth_db.remove_observation(user["id"], rec_id)
return {"ok": True, "observations": auth_db.get_saved_observations(user["id"])}
@app.post("/api/push-rules")
async def api_update_push_rules(req: PushRulesRequest, altcoin_session: str = Cookie(default="")):
user = _require_api_user_with_subscription(altcoin_session)
rules = auth_db.update_push_rules(user["id"], req.dict())
return {"ok": True, "push_rules": rules}
@app.get("/api/screening")
async def api_screening(hours: int = 24, limit: int = 100, altcoin_session: str = Cookie(default="")):
_require_api_user_with_subscription(altcoin_session)
return get_screening_history(hours, limit)
@app.get("/api/review")
async def api_review(altcoin_session: str = Cookie(default="")):
"""复盘数据 + 信号权重"""
_require_api_user_with_subscription(altcoin_session)
return get_review_stats()
@app.get("/api/weights")
async def api_weights(altcoin_session: str = Cookie(default="")):
"""当前动态权重"""
_require_api_user_with_subscription(altcoin_session)
return get_signal_weights()
@app.get("/api/cron")
async def api_cron(limit: int = 50, job_name: str = "", altcoin_session: str = Cookie(default="")):
_require_api_user_with_subscription(altcoin_session)
return get_cron_run_logs(limit=limit, job_name=job_name or None)
@app.get("/api/cron/summary")
async def api_cron_summary(hours: int = 24, altcoin_session: str = Cookie(default="")):
_require_api_user_with_subscription(altcoin_session)
return get_cron_run_summary(hours=hours)
@app.get("/api/sentiment")
async def api_sentiment(hours: int = 6, altcoin_session: str = Cookie(default="")):
"""返回舆情监控数据:以消息/事件为主,币种作为关联信息。"""
_require_api_user_with_subscription(altcoin_session)
db = os.getenv("ALPHAX_DB_PATH", os.path.join(os.path.dirname(__file__), "data", "altcoin_monitor.db"))
conn = sqlite3.connect(db)
conn.row_factory = sqlite3.Row
active_recs = conn.execute(
"SELECT DISTINCT symbol FROM recommendation WHERE status='active'"
).fetchall()
active_symbols = {r["symbol"].split("/")[0].upper() for r in active_recs}
recent_screened = conn.execute("""
SELECT DISTINCT symbol FROM screening_log
WHERE scan_time >= datetime('now', '-' || ? || ' hours')
""", (hours,)).fetchall()
screened_bases = {r["symbol"].split("/")[0].upper() for r in recent_screened}
events = []
now_utc = datetime.now(timezone.utc)
def _parse_event_time(value):
"""解析 RSS/ISO 时间;失败返回 None避免旧闻/脏时间混进短线舆情。"""
if not value:
return None
text = str(value).strip()
for fmt in ("%a, %d %b %Y %H:%M:%S %Z", "%a, %d %b %Y %H:%M:%S GMT"):
try:
return datetime.strptime(text, fmt).replace(tzinfo=timezone.utc)
except Exception:
pass
try:
dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
except Exception:
return None
def _is_fresh_news(value, max_hours):
dt = _parse_event_time(value)
if not dt:
return False
age_hours = (now_utc - dt).total_seconds() / 3600
return 0 <= age_hours <= max_hours
valuable_news_keywords = [
"listing", "listed", "launch", "launchpool", "megadrop", "airdrop",
"mainnet", "upgrade", "partnership", "integrat", "acquisition", "merge",
"buyback", "burn", "token burn", "funding", "raises", "investment",
"sec", "etf", "approval", "lawsuit", "settlement", "hack", "exploit",
"delist", "suspend", "roadmap", "migration",
"上线", "上币", "合约", "空投", "主网", "升级", "合作", "收购",
"回购", "销毁", "融资", "获投", "监管", "批准", "黑客", "漏洞", "下架", "暂停",
]
low_value_news_keywords = [
"price prediction", "price today", "live price", "marketcap and chart",
"what is", "how to buy", "good investment", "forecast", "prediction 2026",
"prediction 2027", "prediction 2030", "technical analysis", "is it time to buy",
"价格预测", "今日价格", "实时价格", "怎么买", "是什么币",
]
def _is_valuable_news_title(title):
text = (title or "").lower()
if not text:
return False
if any(k in text for k in low_value_news_keywords):
return False
return any(k in text for k in valuable_news_keywords)
# 1) v1.7.3 事件驱动舆情:交易所公告 / 高时效消息 / Trending触发天然是“消息优先”。
try:
event_rows = conn.execute("""
SELECT source, symbol, title, url, published_at, detected_at, importance,
event_type, decision, tech_score, rec_id, pushed
FROM event_news
WHERE detected_at >= datetime('now', '-' || ? || ' hours')
ORDER BY datetime(published_at) DESC, id DESC
LIMIT 80
""", (hours,)).fetchall()
for r in event_rows:
base = (r["symbol"] or "").split("/")[0].upper()
source = r["source"] or "event"
title = r["title"] or ""
event_type = r["event_type"] or "event"
importance = r["importance"] or "B"
decision = r["decision"] or ""
# 纯 CoinGecko Trending 只说明“热度变化”,不是高价值舆情;舆情页不展示。
# Trending 仍可在策略内部作为热度源参与技术确认,但不占用“有价值舆情”列表。
if event_type == "market_heat":
continue
events.append({
"source": source,
"source_label": "Binance公告" if "binance" in source else "CoinGecko热度" if "coingecko" in source else source,
"event_type": event_type,
"importance": importance,
"title": title,
"url": r["url"] or "",
"published_at": r["published_at"],
"detected_at": r["detected_at"],
"related_symbol": r["symbol"],
"related_base": base,
"related_name": "",
"decision": r["decision"] or "",
"tech_score": r["tech_score"] or 0,
"rec_id": r["rec_id"] or 0,
"pushed": bool(r["pushed"]),
"in_active": base in active_symbols,
"in_screened": base in screened_bases,
"price_usd": 0,
"change_24h_pct": 0,
"market_cap_rank": 0,
"trend_rank": None,
})
except Exception:
pass
# 2) 旧 sentiment_events把 CoinGecko Trending 也转成“舆情消息”,不再以币种排名卡为主。
rows = conn.execute("""
SELECT symbol, name, trend_rank, trend_score, market_cap_rank,
detected_at, extra_json
FROM sentiment_events
WHERE detected_at = (SELECT MAX(detected_at) FROM sentiment_events WHERE source='coingecko')
ORDER BY trend_rank
""").fetchall()
for r in rows:
raw_extra = r["extra_json"]
if not raw_extra or not isinstance(raw_extra, str) or not raw_extra.strip():
extra = {}
else:
try:
extra = json.loads(raw_extra)
except Exception:
extra = {}
base = (r["symbol"] or "").upper()
name = r["name"] or base
trend_rank = r["trend_rank"] or 0
price_usd = extra.get("price_usd", 0) or 0
change_24h_pct = extra.get("change_24h_pct", 0) or 0
news_items = extra.get("news", []) or []
if news_items:
for n in news_items[:3]:
published = n.get("published") or ""
# 旧闻不能出现在短线舆情页:必须按新闻发布时间过滤,而不是按热度采集时间过滤。
if not _is_fresh_news(published, hours):
continue
title = n.get("title") or f"{name} 相关新闻"
if not _is_valuable_news_title(title):
continue
events.append({
"source": n.get("source") or "news",
"source_label": n.get("source") or "新闻",
"event_type": "news",
"importance": "B",
"title": title,
"url": n.get("url") or "",
"published_at": published,
"detected_at": r["detected_at"],
"related_symbol": f"{base}/USDT",
"related_base": base,
"related_name": name,
"decision": "",
"tech_score": 0,
"rec_id": 0,
"pushed": False,
"in_active": base in active_symbols,
"in_screened": base in screened_bases,
"price_usd": price_usd,
"change_24h_pct": change_24h_pct,
"market_cap_rank": r["market_cap_rank"],
"trend_rank": trend_rank,
})
# 没有高价值相关新闻时,不再把“进入 Trending”本身当舆情消息展示。
# Trending 可继续在策略内部作为热度信号参与技术确认,但舆情页只保留可解释的事件/新闻。
conn.close()
# 去重:同一标题+币种只保留最新一条
deduped = []
seen = set()
for e in events:
key = ((e.get("title") or "").strip().lower(), e.get("related_base"), e.get("source"))
if key in seen:
continue
seen.add(key)
if e.get("in_active"):
e["relation_tag"] = "持仓/活跃推荐"
elif e.get("in_screened"):
e["relation_tag"] = "系统筛选中"
else:
e["relation_tag"] = "关联币种"
deduped.append(e)
def _sort_key(item):
ts = item.get("published_at") or item.get("detected_at") or ""
imp = {"RISK": 5, "S": 4, "A": 3, "B": 2, "C": 1}.get(item.get("importance"), 0)
return (ts, imp)
deduped.sort(key=_sort_key, reverse=True)
check_time = deduped[0]["detected_at"] if deduped else None
return {
"check_time": check_time,
"total_events": len(deduped),
"overlap_active": sum(1 for e in deduped if e["in_active"]),
"overlap_screened": sum(1 for e in deduped if e["in_screened"]),
"events": deduped[:80],
# 兼容旧前端/调试字段
"trending": [],
"total_trending": 0,
}
@app.get("/api/kline")
async def api_kline(symbol: str, interval: str = "1d", limit: int = 60, altcoin_session: str = Cookie(default="")):
"""返回 Binance K线数据日线默认60根前端渲染蜡烛图用"""
_require_api_user_with_subscription(altcoin_session)
import requests as req
try:
clean = symbol.replace("/", "")
r = req.get(
"https://api.binance.com/api/v3/klines",
params={"symbol": clean, "interval": interval, "limit": limit},
timeout=10,
)
if r.status_code != 200:
return JSONResponse({"error": f"Binance {r.status_code}"}, status_code=502)
data = r.json()
candles = [
{
"time": d[0], "open": float(d[1]), "high": float(d[2]),
"low": float(d[3]), "close": float(d[4]), "volume": float(d[5]),
}
for d in data
]
return {"symbol": symbol, "interval": interval, "candles": candles}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.get("/", response_class=HTMLResponse)
async def index():
"""落地页 — 原始 HTML,无 Jinja2"""
landing_path = os.path.join(os.path.dirname(__file__), "static", "index.html")
with open(landing_path, "r", encoding="utf-8") as f:
return HTMLResponse(content=f.read())
@app.get("/auth", response_class=HTMLResponse)
async def auth_page():
"""登录/注册页 — 原始 HTML,无 Jinja2"""
auth_path = os.path.join(os.path.dirname(__file__), "static", "auth.html")
with open(auth_path, "r", encoding="utf-8") as f:
return HTMLResponse(content=f.read())
@app.get("/watchlist", response_class=HTMLResponse)
async def watchlist_page(request: Request):
user, redirect = _require_page_user(request)
if redirect:
return redirect
return render_page("watchlist.html", request)
@app.get("/strategy", response_class=HTMLResponse)
async def strategy_page(request: Request):
user, redirect = _require_page_user(request)
if redirect:
return redirect
return render_page("strategy.html", request)
@app.get("/subscription", response_class=HTMLResponse)
async def subscription_page(request: Request):
user, redirect = _require_page_user(request, require_subscription=False)
if redirect:
return redirect
return render_page("subscription.html", request)
@app.get("/referral", response_class=HTMLResponse)
async def referral_page(request: Request, altcoin_session: str = Cookie(default="")):
user, redirect = _require_page_user(request)
if redirect:
return redirect
return render_page("referral.html", request)
@app.get("/app", response_class=HTMLResponse)
async def app_page(altcoin_session: str = Cookie(default=""), request: Request = None):
user, redirect = _require_page_user(request)
if redirect:
return redirect
try:
auth_db.log_user_activity(user["id"], "page_view", "app",
ip=request.client.host if request and request.client else "")
except Exception:
pass
resp = templates.TemplateResponse(request=request, name="app.html", context={"show_nav": True})
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
resp.headers["Pragma"] = "no-cache"
resp.headers["Expires"] = "0"
return resp
def render_page(template_name: str, request: Request, **kwargs):
"""统一的模板渲染,自动注入活动日志"""
try:
user = auth_db.get_user_by_session_token(
request.cookies.get("altcoin_session", "")
)
if user:
auth_db.log_user_activity(user["id"], "page_view", template_name.replace(".html", ""),
ip=request.client.host if request.client else "")
except Exception:
pass
return templates.TemplateResponse(request=request, name=template_name,
context={"show_nav": True, **kwargs})
@app.get("/api/newsfeed")
async def api_newsfeed(altcoin_session: str = Cookie(default="")):
"""聚合外部新闻源Fear & Greed Index + Google News RSS(EN+CN) + CoinGecko Trending"""
_require_api_user_with_subscription(altcoin_session)
import requests as req
import xml.etree.ElementTree as ET
from email.utils import parsedate_to_datetime
from datetime import datetime, timezone
result = {"fear_greed": None, "trending": [], "news": []}
now = datetime.now(timezone.utc)
# 1) Fear & Greed Index
try:
r = req.get("https://api.alternative.me/fng/?limit=1", timeout=8)
if r.status_code == 200:
fg = r.json()
d0 = fg.get("data", [{}])[0]
result["fear_greed"] = {
"value": int(d0.get("value", 50)),
"classification": d0.get("value_classification", ""),
}
except Exception:
pass
# 2) CoinGecko trending
try:
r = req.get("https://api.coingecko.com/api/v3/search/trending", timeout=10)
if r.status_code == 200:
coins = r.json().get("coins", [])[:7]
for c in coins:
item = c.get("item", {})
result["trending"].append({
"name": item.get("name", ""),
"symbol": item.get("symbol", ""),
"market_cap_rank": item.get("market_cap_rank"),
"thumb": item.get("thumb", ""),
})
except Exception:
pass
# 3) Google News RSS
def fetch_google_news(query, hl, gl, ceid, label):
items = []
try:
url = f"https://news.google.com/rss/search?q={req.utils.quote(query)}&hl={hl}&gl={gl}&ceid={ceid}"
r = req.get(url, timeout=12, headers={"User-Agent": "Mozilla/5.0"})
if r.status_code != 200:
return items
root = ET.fromstring(r.text)
for el in root.findall(".//item")[:15]:
pub_str = el.findtext("pubDate", "")
dt = parsedate_to_datetime(pub_str) if pub_str else None
age_h = round((now - dt).total_seconds() / 3600, 1) if dt else None
if age_h is not None and age_h > 48:
continue
items.append({
"title": (el.findtext("title", "") or "")[:120],
"url": el.findtext("link", "") or "",
"source": (el.findtext("source", "") or "")[:30],
"age_hours": age_h,
"lang": label,
})
except Exception:
pass
return items
en_news = fetch_google_news(
"cryptocurrency OR bitcoin OR ethereum OR defi OR altcoin when:24h",
"en-US", "US", "US:en", "en"
)
cn_news = fetch_google_news(
"加密货币 OR 比特币 OR 以太坊 OR DeFi OR Web3 when:24h",
"zh-CN", "CN", "CN:zh-Hans", "cn"
)
result["news"] = sorted(en_news + cn_news, key=lambda x: x.get("age_hours") or 999)[:30]
return result
@app.get("/sentiment", response_class=HTMLResponse)
async def sentiment_page(request: Request):
user, redirect = _require_page_user(request)
if redirect:
return redirect
return render_page("sentiment.html", request)
async def stock_report_page():
return HTMLResponse(content=STOCK_REPORT_TEMPLATE)
@app.get("/iteration", response_class=HTMLResponse)
async def iteration_page(request: Request):
user, redirect = _require_page_user(request)
if redirect:
return redirect
return render_page("iteration.html", request)
@app.get("/api/iterations")
async def api_iterations(limit: int = 30, altcoin_session: str = Cookie(default="")):
"""返回策略迭代历史"""
_require_api_user_with_subscription(altcoin_session)
conn = get_conn()
rows = conn.execute(
"SELECT * FROM strategy_iteration_log ORDER BY id DESC LIMIT ?",
(limit,)
).fetchall()
conn.close()
return [dict(r) for r in rows]
@app.get("/api/strategy/candidates")
async def api_strategy_candidates(limit: int = 50, status: str = "", altcoin_session: str = Cookie(default="")):
"""候选/灰度规则池:研究结论先入池,达到样本门槛后再发布。"""
_require_api_user_with_subscription(altcoin_session)
return get_strategy_rule_candidates(limit=limit, status=status or None)
@app.get("/api/strategy/failures")
async def api_strategy_failures(limit: int = 50, altcoin_session: str = Cookie(default="")):
"""失败模式归因明细。"""
_require_api_user_with_subscription(altcoin_session)
return get_strategy_failure_patterns(limit=limit)
@app.post("/api/strategy/candidates/refresh")
async def api_strategy_candidates_refresh(altcoin_session: str = Cookie(default="")):
"""手动刷新候选规则表现与生命周期状态。"""
_require_api_user_with_subscription(altcoin_session)
return {"updated": refresh_strategy_candidate_performance()}
@app.get("/api/strategy/candidates/dry-run")
async def api_strategy_candidates_dry_run(altcoin_session: str = Cookie(default="")):
"""候选规则表现 dry-run只读评估不写库、不升版。"""
_require_api_user_with_subscription(altcoin_session)
return dry_run_strategy_candidate_performance()
@app.post("/api/strategy/failures/backfill")
async def api_strategy_failures_backfill(dry_run: bool = False):
"""历史失败模式回填默认写库dry_run=true时只预览。"""
return backfill_strategy_failure_patterns(dry_run=dry_run)
@app.post("/api/strategy/candidates/generate-history")
async def api_strategy_candidates_generate_history(dry_run: bool = False):
"""从历史review_log自动生成候选规则池默认写库dry_run=true时只预览。"""
result = generate_candidates_from_review_history(dry_run=dry_run)
if not dry_run:
result["refreshed"] = refresh_strategy_candidate_performance()
return result
STOCK_REPORT_TEMPLATE = open(os.path.join(os.path.dirname(__file__), "stock_report_template.html"), "r", encoding="utf-8").read()
HTML_PAGE = r'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>AlphaX</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg-primary: #06080d;
--bg-secondary: #0d1117;
--bg-card: #111820;
--bg-card-hover: #161d27;
--bg-glass: rgba(17, 24, 32, 0.7);
--border-subtle: rgba(255,255,255,0.06);
--border-accent: rgba(255,107,53,0.3);
--text-primary: #f0f2f5;
--text-secondary: #8b95a5;
--text-muted: #525c6b;
--accent: #ff6b35;
--accent-glow: rgba(255,107,53,0.15);
--green: #00d68f;
--green-dim: rgba(0,214,143,0.12);
--red: #ff3d71;
--red-dim: rgba(255,61,113,0.12);
--yellow: #ffb020;
--yellow-dim: rgba(255,176,32,0.12);
--blue: #339af0;
--blue-dim: rgba(51,154,240,0.12);
--purple: #7c3aed;
--purple-dim: rgba(124,58,237,0.12);
--radius: 16px;
--radius-sm: 10px;
--shadow: 0 2px 8px rgba(0,0,0,0.3);
--transition: all 0.2s cubic-bezier(0.4,0,0.2,1);
}
* { margin:0; padding:0; box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
body {
font-family: 'Inter', -apple-system, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
}
.app-shell {
max-width: 1400px;
margin: 0 auto;
}
/* === HEADER === */
.header {
position: sticky; top:0; z-index:100;
background: rgba(6,8,13,0.85);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border-bottom: 1px solid var(--border-subtle);
padding: 14px 20px;
display: flex; align-items: center; justify-content: space-between;
}
@media (min-width:768px) { .header { padding: 14px 24px; } }
@media (min-width:1200px) { .header { padding: 14px 32px; } }
.header-left { display:flex; align-items:center; gap:10px; min-width:0; flex-wrap:wrap; }
.logo { font-size:20px; font-weight:800; letter-spacing:-0.5px; display:flex; align-items:center; gap:6px; min-width:0; }
.logo span { color: var(--accent); }
.v11-badge {
display:inline-flex; align-items:center; justify-content:center;
padding:4px 10px; border-radius:999px;
font-size:11px; font-weight:700; letter-spacing:0.3px;
color:#ffd8c9; background:linear-gradient(135deg, rgba(255,107,53,0.22), rgba(124,58,237,0.22));
border:1px solid rgba(255,107,53,0.35); box-shadow:0 6px 18px rgba(255,107,53,0.12);
white-space:nowrap;
}
.header-badge {
font-size:11px; font-weight:500; padding:4px 10px;
border-radius:20px; background:var(--accent-glow);
color:var(--accent); border:1px solid var(--border-accent);
}
.live-dot {
width:6px; height:6px; border-radius:50%; background:var(--green);
display:inline-block; margin-right:4px;
animation: pulse-dot 2s infinite;
}
@keyframes pulse-dot {
0%,100% { opacity:1; box-shadow:0 0 0 0 rgba(0,214,143,0.4); }
50% { opacity:0.6; box-shadow:0 0 0 4px rgba(0,214,143,0); }
}
/* === HEADER RIGHT === */
.header-right { display:flex; align-items:center; gap:10px; flex-shrink:0; }
.header-refresh-btn {
width:32px; height:32px; border-radius:8px;
background:rgba(255,255,255,0.06); border:1px solid var(--border-subtle);
color:var(--text-secondary); cursor:pointer;
display:flex; align-items:center; justify-content:center;
transition: all 0.2s;
}
.header-refresh-btn:hover { background:rgba(255,255,255,0.12); color:var(--text-primary); }
.header-refresh-btn:active { transform:scale(0.9); }
.header-refresh-btn.spinning svg { animation:spin 0.8s linear infinite; }
@keyframes spin { to { transform:rotate(360deg); } }
/* Toggle switch */
.auto-toggle { display:flex; align-items:center; gap:6px; cursor:pointer; user-select:none; }
.auto-toggle input { display:none; }
.toggle-track {
width:34px; height:20px; border-radius:10px;
background:rgba(255,255,255,0.12);
position:relative; transition:background 0.25s;
}
.toggle-track::after {
content:''; position:absolute; top:2px; left:2px;
width:16px; height:16px; border-radius:50%;
background:var(--text-muted);
transition:all 0.25s;
}
.auto-toggle input:checked + .toggle-track { background:var(--accent); }
.auto-toggle input:checked + .toggle-track::after { left:16px; background:#fff; }
.toggle-label { font-size:11px; font-weight:500; color:var(--text-muted); }
/* === STATS === */
.stats-row {
display: flex; gap:6px; padding:14px 16px;
overflow-x: auto; -webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
@media (min-width:768px) { .stats-row { padding:14px 24px; flex-wrap:wrap; } }
.stats-row::-webkit-scrollbar { display:none; }
.stat-chip {
flex-shrink:0; padding:10px 14px; border-radius:var(--radius-sm);
background: var(--bg-card); border:1px solid var(--border-subtle);
display:flex; flex-direction:column; gap:2px; min-width:72px;
}
@media (min-width:768px) { .stat-chip { flex:1; min-width:0; } }
.stat-chip .s-label { font-size:10px; font-weight:500; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.5px; }
.stat-chip .s-value { font-size:18px; font-weight:700; font-family:'JetBrains Mono',monospace; }
.s-value.green { color:var(--green); }
.s-value.red { color:var(--red); }
.s-value.yellow { color:var(--yellow); }
.s-value.blue { color:var(--blue); }
.s-value.purple { color:var(--purple); }
/* === TABS === */
.tabs {
display:flex; gap:0; padding:0 16px; margin-top:4px;
border-bottom:1px solid var(--border-subtle);
position:sticky; top:55px; z-index:99;
background:rgba(6,8,13,0.85);
backdrop-filter:blur(20px);
}
@media (min-width:768px) { .tabs { padding:0 24px; } }
@media (min-width:1200px) { .tabs { padding:0 32px; } }
.tab-btn {
padding:12px 14px; font-size:13px; font-weight:600;
color:var(--text-muted); cursor:pointer;
border-bottom:2px solid transparent;
transition:var(--transition); background:none; border-top:none; border-left:none; border-right:none;
}
.tab-btn.active { color:var(--accent); border-bottom-color:var(--accent); }
.tab-btn:hover:not(.active) { color:var(--text-secondary); }
/* === VERSION FILTER === */
.version-filter-bar {
display:flex; align-items:center; gap:10px; padding:8px 16px;
background:rgba(6,8,13,0.6); border-bottom:1px solid var(--border-subtle);
}
@media (min-width:768px) { .version-filter-bar { padding:8px 24px; } }
@media (min-width:1200px) { .version-filter-bar { padding:8px 32px; } }
.version-filter-label { font-size:12px; color:var(--text-muted); white-space:nowrap; }
.version-filter-bar select {
padding:4px 8px; font-size:12px; font-family:'JetBrains Mono',monospace;
background:var(--bg-card); color:var(--text-primary);
border:1px solid var(--border-subtle); border-radius:4px;
cursor:pointer; min-width:100px;
}
.version-filter-bar select:focus { outline:none; border-color:var(--accent); }
.version-count { font-size:11px; color:var(--text-muted); }
/* === CARDS === */
.cards { padding:8px 16px 80px; }
@media (min-width:768px) { .cards { padding:8px 24px 80px; } }
@media (min-width:1200px) { .cards { padding:12px 32px 80px; } }
.card-grid {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
@media (min-width:768px) { .card-grid { grid-template-columns: repeat(2, 1fr); } }
@media (min-width:1200px) { .card-grid { grid-template-columns: repeat(3, 1fr); } }
.card {
background: var(--bg-card);
border:1px solid var(--border-subtle);
border-radius: var(--radius);
padding:16px; margin-bottom:10px;
transition: var(--transition);
position:relative; overflow:hidden;
}
@media (min-width:768px) {
.card { margin-bottom: 0; transition: all 0.3s ease; }
.card:hover { border-color: rgba(255,255,255,0.12); transform: translateY(-2px); box-shadow: 0 8px 30px rgba(0,0,0,0.3); }
}
.card::before { content:''; position:absolute; left:0; top:0; bottom:0; width:3px; border-radius:0 2px 2px 0; }
.card.accent-yellow::before { background:var(--yellow); }
.card.accent-red::before { background:var(--red); }
.card.accent-green::before { background:var(--green); }
.card.accent-blue::before { background:var(--blue); }
.card.accent-purple::before { background:var(--purple); }
.card.accent-gray::before { background:var(--text-muted); }
.card:active { transform:scale(0.985); }
/* Card top */
.card-top { display:flex; justify-content:space-between; align-items:center; margin-bottom:10px; }
.coin-info { display:flex; align-items:center; gap:8px; }
.coin-icon {
width:32px; height:32px; border-radius:50%;
background:linear-gradient(135deg,var(--accent),#ff9a56);
display:flex; align-items:center; justify-content:center;
font-size:13px; font-weight:700; color:#fff; flex-shrink:0;
}
.coin-name { font-size:15px; font-weight:700; letter-spacing:-0.3px; }
.coin-sector { font-size:11px; color:var(--text-muted); margin-left:4px; }
.badge {
font-size:10px; font-weight:600; padding:4px 8px;
border-radius:6px; letter-spacing:0.3px; text-transform:uppercase;
}
.badge-accel { background:var(--yellow-dim); color:var(--yellow); }
.badge-burst { background:var(--red-dim); color:var(--red); }
.badge-蓄力 { background:var(--blue-dim); color:var(--blue); }
.badge-active { background:var(--green-dim); color:var(--green); }
.badge-hit_tp1 { background:var(--green-dim); color:var(--green); }
.badge-hit_tp2 { background:var(--green-dim); color:var(--green); }
.badge-stopped_out { background:var(--red-dim); color:var(--red); }
.badge-expired { background:rgba(82,92,107,0.15); color:var(--text-muted); }
.badge-可即刻买入 { background:var(--green-dim); color:var(--green); }
.badge-等回踩 { background:var(--yellow-dim); color:var(--yellow); }
.badge-衰减 { background:rgba(255,176,32,0.15); color:var(--yellow); }
.badge-止损 { background:var(--red-dim); color:var(--red); }
.badge-止盈1 { background:var(--green-dim); color:var(--green); }
.badge-止盈2 { background:var(--green-dim); color:var(--green); }
.badge-跟踪止盈 { background:var(--green-dim); color:var(--green); }
.badge-反转 { background:var(--red-dim); color:var(--red); }
.badge-持有 { background:rgba(82,92,107,0.15); color:var(--text-muted); }
/* Action status indicator */
.action-indicator {
font-size:11px; font-weight:600; padding:5px 10px;
border-radius:999px; display:inline-flex; align-items:center; gap:4px;
}
.action-🟢 { background:var(--green-dim); color:var(--green); border:1px solid rgba(0,214,143,0.3); }
.action-🟡 { background:var(--yellow-dim); color:var(--yellow); border:1px solid rgba(255,176,32,0.3); }
.action-🔴 { background:var(--red-dim); color:var(--red); border:1px solid rgba(255,61,113,0.3); }
.action-⚠️ { background:rgba(255,176,32,0.15); color:var(--yellow); border:1px solid rgba(255,176,32,0.2); }
.action-✅ { background:var(--green-dim); color:var(--green); border:1px solid rgba(0,214,143,0.3); }
.execution-panel {
display:flex;
flex-direction:column;
gap:8px;
margin:8px 0 10px;
padding:10px 12px;
border-radius:12px;
background:linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.015));
border:1px solid rgba(255,255,255,0.05);
}
.execution-row {
display:flex;
flex-wrap:wrap;
gap:8px;
align-items:center;
}
.execution-topline {
display:flex;
align-items:center;
gap:8px;
flex-wrap:wrap;
}
.execution-summary-text {
font-size:11px;
color:var(--text-secondary);
flex:1;
min-width:140px;
line-height:1.4;
}
.execution-summary {
display:flex;
flex-direction:column;
gap:8px;
min-width:0;
}
.execution-side {
display:flex;
flex-direction:column;
gap:8px;
align-items:flex-end;
}
.execution-actions {
display:flex;
flex-wrap:wrap;
gap:8px;
align-items:center;
justify-content:flex-end;
}
.execution-primary {
display:flex;
align-items:center;
gap:8px;
flex-wrap:wrap;
}
.execution-caption {
font-size:10px;
font-weight:700;
letter-spacing:0.6px;
text-transform:uppercase;
color:var(--text-muted);
}
.execution-title {
font-size:15px;
font-weight:800;
color:var(--text-primary);
letter-spacing:-0.2px;
}
.execution-subtitle {
font-size:11px;
color:var(--text-secondary);
line-height:1.5;
}
@media (max-width: 720px) {
.execution-topline {
grid-template-columns:1fr;
}
.execution-side {
align-items:flex-start;
}
.execution-actions {
justify-content:flex-start;
}
}
.execution-chip {
display:inline-flex;
align-items:center;
gap:6px;
font-size:11px;
font-weight:600;
padding:5px 10px;
border-radius:999px;
border:1px solid var(--border-subtle);
background:rgba(255,255,255,0.03);
color:var(--text-secondary);
}
.execution-chip.execution-buy_now { background:var(--green-dim); color:var(--green); border-color:rgba(0,214,143,0.24); }
.execution-chip.execution-wait_pullback { background:var(--yellow-dim); color:var(--yellow); border-color:rgba(255,176,32,0.24); }
.execution-chip.execution-invalid { background:var(--red-dim); color:var(--red); border-color:rgba(255,61,113,0.24); }
.execution-chip.execution-completed { background:rgba(51,154,240,0.12); color:var(--blue); border-color:rgba(51,154,240,0.24); }
.execution-chip.execution-observe { background:rgba(124,58,237,0.12); color:#b89cff; border-color:rgba(124,58,237,0.24); }
.execution-status-chip {
font-size:12px;
padding:6px 11px;
}
.execution-plan-chip { background:rgba(255,255,255,0.04); color:var(--text-primary); }
.execution-meta {
display:flex;
flex-wrap:wrap;
gap:8px;
}
.execution-meta-chip {
display:inline-flex;
align-items:center;
gap:6px;
font-size:11px;
font-weight:600;
padding:5px 10px;
border-radius:999px;
border:1px solid rgba(255,255,255,0.06);
background:rgba(255,255,255,0.03);
color:var(--text-secondary);
}
.execution-meta-chip.bypass { background:rgba(124,58,237,0.12); color:#c4b5fd; border-color:rgba(124,58,237,0.28); }
.execution-meta-chip.degrade { background:rgba(255,176,32,0.12); color:var(--yellow); border-color:rgba(255,176,32,0.28); }
.execution-meta-chip.neutral { background:rgba(51,154,240,0.12); color:var(--blue); border-color:rgba(51,154,240,0.24); }
.execution-meta-chip.version { background:rgba(0,214,143,0.12); color:var(--green); border-color:rgba(0,214,143,0.24); }
.execution-reason {
font-size:12px;
line-height:1.5;
color:var(--text-secondary);
padding:10px 12px;
border-radius:10px;
background:rgba(255,255,255,0.025);
border:1px solid rgba(255,255,255,0.05);
}
.execution-reason b { color:var(--text-primary); }
.rec-groups {
display:flex;
flex-direction:column;
gap:18px;
}
.rec-group-block { display:flex; flex-direction:column; gap:10px; }
.rec-group-head {
display:flex;
align-items:center;
justify-content:space-between;
gap:12px;
padding:0 2px;
}
.rec-group-title {
font-size:14px;
font-weight:800;
color:var(--text-primary);
}
.rec-group-desc {
font-size:11px;
color:var(--text-muted);
margin-top:2px;
}
.rec-group-count {
flex-shrink:0;
font-size:11px;
font-weight:700;
padding:5px 9px;
border-radius:999px;
background:rgba(255,255,255,0.04);
color:var(--text-secondary);
border:1px solid var(--border-subtle);
}
/* Sell/Buy signals */
.sell-signal { font-size:10px; font-weight:600; padding:3px 7px; border-radius:6px; background:var(--red-dim); color:var(--red); border:1px solid rgba(255,61,113,0.2); }
.buy-signal { font-size:10px; font-weight:600; padding:3px 7px; border-radius:6px; background:var(--green-dim); color:var(--green); border:1px solid rgba(0,214,143,0.2); }
/* Price area */
.price-area { margin-bottom:10px; }
.price-main { display:flex; align-items:baseline; gap:10px; }
.price-val { font-size:22px; font-weight:700; font-family:'JetBrains Mono',monospace; letter-spacing:-0.5px; }
.price-row-two { display:flex; gap:6px; justify-content:space-between; align-items:stretch; }
.price-item { flex:1; display:flex; flex-direction:column; gap:2px; background:rgba(255,255,255,0.03); border-radius:8px; padding:6px 10px; }
.price-label { font-size:10px; color:var(--text-muted); font-weight:500; }
.price-val-sm { font-size:16px; font-weight:700; font-family:'JetBrains Mono',monospace; letter-spacing:-0.3px; }
.price-val-sm.muted { font-family:inherit; font-size:12px; color:var(--text-muted); font-weight:700; }
.pnl-note { margin-top:6px; font-size:11px; color:var(--text-muted); line-height:1.5; }
.pnl-pill { font-size:13px; font-weight:600; font-family:'JetBrains Mono',monospace; padding:3px 8px; border-radius:6px; }
.pnl-pill.up { background:var(--green-dim); color:var(--green); }
.pnl-pill.down { background:var(--red-dim); color:var(--red); }
.pnl-pill.flat { background:rgba(82,92,107,0.15); color:var(--text-muted); }
.dir-tag { font-size:11px; font-weight:600; padding:2px 6px; border-radius:6px; margin-left:6px; }
.dir-bullish { background:var(--green-dim); color:var(--green); }
/* PnL bar */
.pnl-bar { margin-top:6px; height:4px; border-radius:2px; background:rgba(255,255,255,0.05); overflow:hidden; }
.pnl-bar-fill { height:100%; border-radius:2px; transition:width 0.6s ease; }
.pnl-bar-fill.up { background:linear-gradient(90deg,var(--green),#33e6a0); }
.pnl-bar-fill.down { background:linear-gradient(90deg,var(--red),#ff6b8a); }
/* K线图区 */
.kline-section { margin:10px 0 4px; padding:6px 4px; min-height:110px; background:rgba(0,0,0,0.15); border-radius:var(--radius-sm); border:1px solid var(--border-subtle); width:100%; box-sizing:border-box; }
.kline-title { font-size:11px; font-weight:600; color:var(--text-secondary); margin-bottom:6px; display:flex; align-items:center; justify-content:space-between; gap:4px; }
.kline-interval-selector { display:flex; gap:2px; }
.kline-int-btn {
font-size:10px; font-weight:500; padding:2px 7px;
border-radius:4px; cursor:pointer; background:transparent;
color:var(--text-muted); border:1px solid transparent;
transition:var(--transition); font-family:inherit;
}
.kline-int-btn:hover { color:var(--text-secondary); border-color:rgba(255,255,255,0.15); }
.kline-int-btn.active { color:var(--accent); border-color:var(--accent); background:var(--accent-glow); }
.kline-chart { display:block; }
.kline-legend { display:flex; gap:10px; margin-top:4px; font-size:9px; color:var(--text-muted); }
.kline-legend-up { color:var(--green); }
.kline-legend-down { color:var(--red); }
.kline-legend-vol { color:rgba(255,255,255,0.2); }
.kline-container { width:100%; }
.kline-container.loading { min-height:120px; display:flex; align-items:center; justify-content:center; }
.chart-loading { color:var(--text-muted); font-size:12px; }
.chart-placeholder { color:var(--text-muted); font-size:12px; padding:20px; text-align:center; }
/* Meta row */
.meta-row { display:flex; gap:12px; font-size:11px; color:var(--text-muted); margin-bottom:8px; flex-wrap:wrap; font-family:'JetBrains Mono',monospace; }
.meta-row span { white-space:nowrap; }
.signals { display:flex; flex-wrap:wrap; gap:4px; margin-top:6px; }
.sig { display:inline-block; font-size:10px; font-weight:600; padding:3px 7px; border-radius:6px;
background:rgba(255,255,255,0.04); color:var(--text-muted);
border:1px solid rgba(255,255,255,0.05); white-space:nowrap; }
.sig.highlight { background:var(--accent-glow); color:var(--accent); border-color:var(--border-accent); }
.sig.forward { background:var(--green-dim); color:var(--green); border-color:rgba(0,214,143,0.25); }
.sig.pa { background:var(--blue-dim); color:var(--blue); border-color:rgba(51,154,240,0.25); }
.sig.strong { background:rgba(255,107,53,0.18); color:#ff8a50; border-color:rgba(255,107,53,0.35); font-weight:600; }
.sig.strong-pa { background:rgba(124,58,237,0.18); color:#a78bfa; border-color:rgba(124,58,237,0.35); font-weight:600; }
.sig.info { background:rgba(82,92,107,0.12); color:#8b95a5; border-color:rgba(82,92,107,0.2); font-size:10px; }
.sig.warn { background:var(--red-dim); color:var(--red); border-color:rgba(255,61,113,0.3); }
.context-strip {
display:grid;
grid-template-columns:1fr;
gap:8px;
margin-top:10px;
}
@media (min-width:768px) {
.context-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
.context-card {
background:rgba(255,255,255,0.03);
border:1px solid rgba(255,255,255,0.06);
border-radius:12px;
padding:10px 12px;
min-width:0;
}
.context-card.market { border-color:rgba(0,214,143,0.18); }
.context-card.derivatives { border-color:rgba(255,176,32,0.18); }
.context-card.sector { border-color:rgba(51,154,240,0.18); }
.context-card-header {
display:flex;
align-items:center;
justify-content:space-between;
gap:8px;
margin-bottom:8px;
}
.context-card-title {
font-size:11px;
font-weight:700;
letter-spacing:0.3px;
text-transform:uppercase;
color:var(--text-secondary);
}
.context-card-badge {
font-size:10px;
font-weight:700;
padding:3px 8px;
border-radius:999px;
background:rgba(255,255,255,0.04);
border:1px solid rgba(255,255,255,0.06);
color:var(--text-muted);
}
.context-grid {
display:grid;
grid-template-columns:repeat(2, minmax(0, 1fr));
gap:6px;
}
.context-item {
background:rgba(255,255,255,0.025);
border-radius:8px;
padding:7px 8px;
min-width:0;
}
.context-item.full { grid-column:1 / -1; }
.context-item-label {
display:block;
font-size:10px;
color:var(--text-muted);
margin-bottom:3px;
}
.context-item-value {
display:block;
font-size:12px;
font-weight:600;
color:var(--text-primary);
line-height:1.35;
word-break:break-word;
}
.context-item-value.green { color:var(--green); }
.context-item-value.red { color:var(--red); }
.context-item-value.yellow { color:var(--yellow); }
.context-item-value.blue { color:var(--blue); }
.context-tags { display:flex; flex-wrap:wrap; gap:5px; }
.context-tag {
font-size:10px;
font-weight:600;
padding:3px 7px;
border-radius:999px;
background:rgba(51,154,240,0.12);
border:1px solid rgba(51,154,240,0.18);
color:var(--blue);
}
.review-context-strip {
display:grid;
grid-template-columns:1fr;
gap:10px;
margin-top:14px;
}
@media (min-width:1200px) {
.review-context-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.review-context-card {
background:rgba(255,255,255,0.02);
border:1px solid rgba(255,255,255,0.06);
border-radius:14px;
padding:14px;
}
.review-context-card .sub-title { margin-bottom:10px; }
.review-context-grid {
display:grid;
grid-template-columns:repeat(2, minmax(0, 1fr));
gap:8px;
}
.review-context-metric {
background:rgba(255,255,255,0.025);
border-radius:10px;
padding:10px;
}
.review-context-metric .k {
font-size:11px;
color:var(--text-muted);
margin-bottom:4px;
}
.review-context-metric .v {
font-size:16px;
font-weight:700;
font-family:'JetBrains Mono',monospace;
color:var(--text-primary);
}
.review-context-metric .v.green { color:var(--green); }
.review-context-metric .v.yellow { color:var(--yellow); }
.review-context-metric .v.blue { color:var(--blue); }
.review-context-metric .v.red { color:var(--red); }
.review-context-hint {
margin-top:10px;
font-size:12px;
color:var(--text-secondary);
line-height:1.55;
}
.review-context-tags { display:flex; flex-wrap:wrap; gap:6px; margin-top:10px; }
.review-context-tags .context-tag { background:rgba(124,58,237,0.12); border-color:rgba(124,58,237,0.18); color:#c4b5fd; }
/* Entry plan */
.entry-section { margin-top:10px; padding-top:10px; border-top:1px dashed rgba(255,255,255,0.06); }
.entry-toggle { font-size:11px; font-weight:600; color:var(--accent); cursor:pointer; display:flex; align-items:center; gap:4px; background:none; border:none; padding:0; }
.entry-toggle .arrow { transition:transform 0.2s; font-size:10px; }
.entry-toggle.open .arrow { transform:rotate(90deg); }
.entry-body { display:none; margin-top:8px; }
.entry-body.show { display:block; }
.entry-grid { display:grid; grid-template-columns:1fr 1fr; gap:6px; }
.entry-item { background:rgba(255,255,255,0.02); border-radius:8px; padding:8px 10px; }
.entry-item .ei-label { font-size:10px; color:var(--text-muted); margin-bottom:2px; }
.entry-item .ei-value { font-size:13px; font-weight:600; font-family:'JetBrains Mono',monospace; }
.ei-value.green { color:var(--green); }
.ei-value.red { color:var(--red); }
.ei-value.accent { color:var(--accent); }
/* Stats inline */
.entry-stats { display:flex; gap:8px; margin-top:6px; }
.es-item { font-size:10px; color:var(--text-muted); }
.es-item b { font-weight:600; font-family:'JetBrains Mono',monospace; }
.es-item b.green { color:var(--green); }
.es-item b.red { color:var(--red); }
/* Change pill */
.change-pill { font-size:11px; font-weight:600; font-family:'JetBrains Mono',monospace; padding:2px 6px; border-radius:4px; }
.change-pill.up { background:var(--green-dim); color:var(--green); }
.change-pill.down { background:var(--red-dim); color:var(--red); }
/* Empty / Loading */
.empty-state { text-align:center; padding:60px 20px; color:var(--text-muted); }
.empty-state .icon { font-size:40px; margin-bottom:12px; opacity:0.3; }
.empty-state .title { font-size:15px; font-weight:600; margin-bottom:4px; color:var(--text-secondary); }
.empty-state .desc { font-size:12px; }
.skeleton {
background:linear-gradient(90deg,var(--bg-card) 25%,var(--bg-card-hover) 50%,var(--bg-card) 75%);
background-size:200% 100%;
animation:shimmer 1.5s infinite;
border-radius:8px; height:120px;
}
@keyframes shimmer { 0%{background-position:200% 0} 100%{background-position:-200% 0} }
/* FAB */
/* Score ring */
.score-ring { width:36px; height:36px; position:relative; display:flex; align-items:center; justify-content:center; }
.score-ring svg { position:absolute; top:0; left:0; transform:rotate(-90deg); }
.score-ring .score-text { font-size:12px; font-weight:700; font-family:'JetBrains Mono',monospace; z-index:1; color:var(--text-primary); }
/* === 复盘专区样式 === */
.review-section, .cron-section { padding:0 16px; }
@media (min-width:768px) { .review-section, .cron-section { padding:0 24px; } }
@media (min-width:1200px) { .review-section, .cron-section { padding:0 32px; } }
.review-block {
margin-bottom:20px;
padding:16px;
border-radius:16px;
border:1px solid var(--border-subtle);
background:linear-gradient(180deg, rgba(255,255,255,0.035), rgba(255,255,255,0.02));
box-shadow:0 10px 28px rgba(0,0,0,0.16);
min-width:0;
}
.review-block > * {
min-width:0;
}
.review-block-head {
display:flex;
flex-direction:column;
gap:6px;
margin:0 0 12px;
}
.review-overview-stack {
display:grid;
grid-template-columns:minmax(0, 1fr);
gap:12px;
width:100%;
}
.review-overview-stack > * {
width:100%;
min-width:0;
margin:0;
}
.review-overview-stack .definition-box {
margin:0;
}
.review-block-kicker {
font-size:10px;
font-weight:800;
letter-spacing:0.8px;
text-transform:uppercase;
color:var(--accent);
}
.review-block-title {
font-size:20px;
font-weight:800;
letter-spacing:-0.4px;
color:var(--text-primary);
}
.decision-overview {
margin:0 0 18px;
padding:16px;
border-radius:var(--radius);
background:linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.015));
border:1px solid var(--border-subtle);
}
.decision-overview-head {
display:flex;
flex-direction:column;
gap:6px;
margin-bottom:14px;
}
.decision-overview-kicker {
font-size:10px;
font-weight:800;
letter-spacing:0.8px;
text-transform:uppercase;
color:var(--accent);
}
.decision-overview-title {
font-size:22px;
font-weight:800;
letter-spacing:-0.4px;
color:var(--text-primary);
}
.decision-overview-desc {
font-size:12px;
line-height:1.6;
color:var(--text-secondary);
max-width:820px;
}
.decision-chip-grid {
display:grid;
grid-template-columns:repeat(2, minmax(0, 1fr));
gap:10px;
}
@media (min-width:768px) { .decision-chip-grid { grid-template-columns:repeat(3, minmax(0, 1fr)); } }
@media (min-width:1200px) { .decision-chip-grid { grid-template-columns:repeat(5, minmax(0, 1fr)); } }
.decision-chip {
display:flex;
flex-direction:column;
gap:6px;
padding:12px 14px;
border-radius:12px;
background:rgba(255,255,255,0.03);
border:1px solid var(--border-subtle);
}
.decision-chip .label { font-size:11px; font-weight:700; color:var(--text-secondary); }
.decision-chip .value { font-size:24px; font-weight:800; line-height:1; font-family:'JetBrains Mono',monospace; color:var(--text-primary); }
.decision-chip .sub { font-size:11px; color:var(--text-secondary); line-height:1.5; }
.decision-chip.k-buy_now .value, .decision-chip.k-buy_now .label { color:var(--green); }
.decision-chip.k-wait_pullback .value, .decision-chip.k-wait_pullback .label { color:var(--yellow); }
.decision-chip.k-observe .value, .decision-chip.k-observe .label { color:var(--blue); }
.decision-chip.k-invalid .value, .decision-chip.k-invalid .label { color:var(--red); }
.decision-chip.k-completed .value, .decision-chip.k-completed .label { color:#9ae6b4; }
.decision-chip-nav {
margin-top:12px;
display:flex;
flex-wrap:wrap;
gap:8px;
}
.decision-nav-pill {
display:inline-flex;
align-items:center;
gap:6px;
padding:7px 12px;
border-radius:999px;
text-decoration:none;
color:var(--text-secondary);
background:rgba(255,255,255,0.03);
border:1px solid var(--border-subtle);
font-size:11px;
font-weight:700;
}
.decision-nav-pill .count { color:var(--accent); font-family:'JetBrains Mono',monospace; }
.decision-archive-note {
margin:0 0 18px;
padding:14px 16px;
border-radius:14px;
border:1px solid var(--border-subtle);
background:rgba(255,255,255,0.025);
}
.decision-archive-note .title { font-size:14px; font-weight:800; color:var(--text-primary); margin-bottom:6px; }
.decision-archive-note .desc { font-size:12px; line-height:1.7; color:var(--text-secondary); }
.review-top-grid {
display:grid;
grid-template-columns:1fr;
gap:14px;
margin-bottom:18px;
}
@media (min-width:1200px) { .review-top-grid { grid-template-columns:1.2fr 0.8fr; } }
.review-mid-grid {
display:grid;
grid-template-columns:1fr;
gap:14px;
margin-bottom:18px;
}
@media (min-width:1200px) { .review-mid-grid { grid-template-columns:1fr 1fr; } }
.review-bottom-grid {
display:grid;
grid-template-columns:1fr;
gap:14px;
}
@media (min-width:1200px) { .review-bottom-grid { grid-template-columns:1.05fr 0.95fr; } }
.review-compact-block .review-block-head { margin-bottom:0; }
.review-compact-block .review-divider { margin:12px 0 0; }
.iteration-hero {
display:grid;
grid-template-columns:1fr;
gap:12px;
margin-bottom:14px;
}
@media (min-width:1200px) { .iteration-hero { grid-template-columns:1.15fr 0.85fr; } }
.iteration-hero-card, .iteration-summary-panel {
border:1px solid var(--border-subtle);
border-radius:14px;
background:rgba(255,255,255,0.03);
padding:14px;
}
.iteration-hero-card .kicker, .iteration-summary-panel .kicker { font-size:10px; text-transform:uppercase; letter-spacing:0.8px; color:var(--accent); font-weight:800; margin-bottom:8px; }
.iteration-hero-card .title { font-size:22px; font-weight:800; color:var(--text-primary); margin-bottom:8px; }
.iteration-hero-card .desc, .iteration-summary-panel .desc { font-size:12px; line-height:1.7; color:var(--text-secondary); }
.iteration-key-points { display:flex; flex-wrap:wrap; gap:8px; margin-top:12px; }
.iteration-key-points span {
padding:6px 10px; border-radius:999px; font-size:11px; font-weight:700;
border:1px solid var(--border-subtle); background:rgba(255,255,255,0.03); color:var(--text-primary);
}
.review-card-grid {
display:grid;
grid-template-columns:1fr;
gap:12px;
}
@media (min-width:1200px) { .review-card-grid { grid-template-columns:repeat(2, minmax(0, 1fr)); } }
.iteration-summary-grid {
display:grid;
grid-template-columns:repeat(2, minmax(0, 1fr));
gap:10px;
margin-bottom:14px;
}
@media (min-width:1200px) { .iteration-summary-grid { grid-template-columns:repeat(4, minmax(0, 1fr)); } }
.iteration-stat {
background:rgba(255,255,255,0.03);
border:1px solid var(--border-subtle);
border-radius:12px;
padding:12px 14px;
}
.iteration-stat .label { font-size:11px; color:var(--text-secondary); margin-bottom:6px; }
.iteration-stat .value { font-size:22px; font-weight:800; font-family:'JetBrains Mono',monospace; }
.iteration-strip {
display:flex;
flex-wrap:wrap;
gap:8px;
margin-bottom:14px;
}
.iteration-pill {
padding:6px 10px;
border-radius:999px;
font-size:11px;
font-weight:700;
color:var(--text-secondary);
border:1px solid var(--border-subtle);
background:rgba(255,255,255,0.03);
}
.iteration-problem-list {
display:grid;
grid-template-columns:1fr;
gap:8px;
margin-bottom:14px;
}
.iteration-problem-item {
background:rgba(255,61,113,0.08);
border:1px solid rgba(255,61,113,0.16);
border-radius:10px;
padding:10px 12px;
}
.iteration-problem-item .k { font-size:11px; color:var(--red); font-weight:700; margin-bottom:4px; }
.iteration-problem-item .v { font-size:12px; line-height:1.6; color:var(--text-secondary); }
.iteration-log-list {
display:flex;
flex-direction:column;
gap:12px;
}
.iteration-log-card {
background:rgba(255,255,255,0.03);
border:1px solid var(--border-subtle);
border-radius:14px;
padding:14px;
}
.iteration-log-top {
display:flex;
justify-content:space-between;
align-items:flex-start;
gap:10px;
margin-bottom:10px;
}
.iteration-log-title { font-size:15px; font-weight:800; color:var(--text-primary); }
.iteration-log-meta { font-size:11px; color:var(--text-secondary); margin-top:4px; display:flex; flex-wrap:wrap; gap:8px; }
.iteration-badge {
padding:5px 10px;
border-radius:999px;
font-size:11px;
font-weight:700;
color:var(--accent);
border:1px solid var(--border-accent);
background:var(--accent-glow);
white-space:nowrap;
}
.iteration-version-grid {
display:grid;
grid-template-columns:1fr;
gap:12px;
margin-bottom:14px;
}
@media (min-width:1200px) { .iteration-version-grid { grid-template-columns:repeat(2, minmax(0, 1fr)); } }
.version-stats-card, .version-changelog-card {
border:1px solid var(--border-subtle);
border-radius:14px;
background:rgba(255,255,255,0.03);
padding:14px;
min-width:0;
}
.version-stats-grid, .version-changelog-list {
display:grid;
grid-template-columns:1fr;
gap:10px;
}
.version-stat-item, .version-log-item {
border:1px solid var(--border-subtle);
border-radius:12px;
background:rgba(255,255,255,0.02);
padding:12px;
min-width:0;
}
.version-stat-top, .version-log-top {
display:flex;
justify-content:space-between;
align-items:center;
gap:8px;
flex-wrap:wrap;
margin-bottom:8px;
}
.version-chip {
display:inline-flex;
align-items:center;
padding:5px 10px;
border-radius:999px;
font-size:11px;
font-weight:800;
color:var(--accent);
border:1px solid var(--border-accent);
background:var(--accent-glow);
white-space:nowrap;
}
.version-count, .version-log-time, .version-log-meta {
font-size:11px;
color:var(--text-secondary);
}
.version-stat-metrics {
display:grid;
grid-template-columns:repeat(3, minmax(0, 1fr));
gap:8px;
margin-bottom:10px;
}
.version-stat-metrics div {
border:1px solid var(--border-subtle);
border-radius:10px;
background:rgba(255,255,255,0.02);
padding:8px 10px;
display:flex;
flex-direction:column;
gap:4px;
}
.version-stat-metrics span {
font-size:10px;
color:var(--text-secondary);
}
.version-stat-metrics strong {
font-size:16px;
color:var(--text-primary);
}
.version-stat-footer, .version-log-meta {
display:flex;
flex-wrap:wrap;
gap:10px;
}
.version-stat-footer span {
font-size:11px;
font-weight:700;
}
.version-log-title {
font-size:14px;
font-weight:800;
color:var(--text-primary);
margin-bottom:6px;
}
.version-log-summary, .iteration-version-summary {
font-size:12px;
line-height:1.7;
color:var(--text-secondary);
}
.iteration-version-summary {
margin-bottom:10px;
padding:10px 12px;
border:1px solid rgba(80, 200, 120, 0.18);
border-radius:10px;
background:rgba(80, 200, 120, 0.06);
}
.iteration-log-summary {
font-size:12px;
line-height:1.7;
color:var(--text-secondary);
margin-bottom:10px;
}
.iteration-section-grid {
display:grid;
grid-template-columns:1fr;
gap:10px;
}
@media (min-width:1200px) { .iteration-section-grid { grid-template-columns:repeat(2, minmax(0, 1fr)); } }
.iteration-subcard {
border:1px solid var(--border-subtle);
border-radius:12px;
background:rgba(255,255,255,0.02);
padding:12px;
}
.iteration-subcard .sub-title { font-size:12px; font-weight:800; margin-bottom:8px; }
.iteration-subcard.findings .sub-title { color:var(--blue); }
.iteration-subcard.problems .sub-title { color:var(--red); }
.iteration-subcard.actions .sub-title { color:var(--green); }
.iteration-subcard.rules .sub-title { color:var(--yellow); }
.iteration-list { display:flex; flex-direction:column; gap:6px; }
.iteration-list-item { font-size:12px; line-height:1.6; color:var(--text-secondary); }
.iteration-tags { display:flex; flex-wrap:wrap; gap:6px; margin-top:10px; }
.iteration-tag {
font-size:10px; padding:4px 8px; border-radius:999px;
border:1px solid var(--border-subtle); background:rgba(255,255,255,0.04); color:var(--text-secondary);
}
.iteration-metrics { display:flex; flex-wrap:wrap; gap:6px; margin-top:10px; }
.history-summary {
margin:0 0 18px;
padding:16px;
border-radius:var(--radius);
background:linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.015));
border:1px solid var(--border-subtle);
}
.history-summary-head {
display:flex;
flex-direction:column;
gap:6px;
margin-bottom:14px;
}
.history-summary-kicker {
font-size:10px;
font-weight:800;
letter-spacing:0.8px;
text-transform:uppercase;
color:var(--accent);
}
.history-summary-title {
font-size:22px;
font-weight:800;
letter-spacing:-0.4px;
color:var(--text-primary);
}
.history-summary-desc {
font-size:12px;
line-height:1.6;
color:var(--text-secondary);
max-width:820px;
}
.history-chip-grid {
display:grid;
grid-template-columns:repeat(2, minmax(0, 1fr));
gap:10px;
}
@media (min-width:768px) { .history-chip-grid { grid-template-columns:repeat(3, minmax(0, 1fr)); } }
@media (min-width:1200px) { .history-chip-grid { grid-template-columns:repeat(6, minmax(0, 1fr)); } }
.history-chip {
display:flex;
flex-direction:column;
gap:6px;
padding:12px 14px;
border-radius:12px;
background:rgba(255,255,255,0.03);
border:1px solid var(--border-subtle);
color:inherit;
text-decoration:none;
transition:var(--transition);
}
.history-chip:hover {
transform:translateY(-1px);
border-color:rgba(255,255,255,0.12);
background:rgba(255,255,255,0.05);
}
.history-chip.k-total .label, .history-chip.k-total .value { color:var(--text-primary); }
.history-chip.k-buy_now .label, .history-chip.k-buy_now .value { color:var(--green); }
.history-chip.k-wait_pullback .label, .history-chip.k-wait_pullback .value { color:var(--yellow); }
.history-chip.k-observe .label, .history-chip.k-observe .value { color:var(--blue); }
.history-chip.k-invalid .label, .history-chip.k-invalid .value { color:var(--red); }
.history-chip.k-completed .label, .history-chip.k-completed .value { color:#9ae6b4; }
.history-chip .label { font-size:11px; font-weight:700; }
.history-chip .value { font-size:24px; font-weight:800; line-height:1; font-family:'JetBrains Mono',monospace; }
.history-chip .sub { font-size:11px; color:var(--text-secondary); }
.history-chip-nav {
margin-top:12px;
display:flex;
flex-wrap:wrap;
gap:8px;
}
.history-nav-pill {
display:inline-flex;
align-items:center;
gap:6px;
padding:7px 12px;
border-radius:999px;
text-decoration:none;
color:var(--text-secondary);
background:rgba(255,255,255,0.03);
border:1px solid var(--border-subtle);
font-size:11px;
font-weight:700;
}
.history-nav-pill:hover {
color:var(--text-primary);
border-color:rgba(255,255,255,0.14);
}
.history-nav-pill .count {
color:var(--accent);
font-family:'JetBrains Mono',monospace;
}
.history-groups {
display:flex;
flex-direction:column;
gap:16px;
}
.rec-group-block {
scroll-margin-top:120px;
}
.iteration-metrics { display:flex; flex-wrap:wrap; gap:6px; margin-top:10px; }
.iteration-metric {
font-size:10px; padding:4px 8px; border-radius:999px;
border:1px solid var(--border-subtle); background:rgba(255,255,255,0.04); color:var(--text-primary);
}
.iteration-diff-grid {
display:grid;
grid-template-columns:1fr;
gap:10px;
margin-top:10px;
}
@media (min-width:1200px) { .iteration-diff-grid { grid-template-columns:repeat(3, minmax(0, 1fr)); } }
.iteration-diff-card {
border:1px solid var(--border-subtle);
border-radius:12px;
background:rgba(255,255,255,0.02);
padding:12px;
}
.iteration-diff-card .sub-title { font-size:12px; font-weight:800; margin-bottom:8px; }
.iteration-diff-card.changed .sub-title { color:var(--yellow); }
.iteration-diff-card.added .sub-title { color:var(--green); }
.iteration-diff-card.removed .sub-title { color:var(--red); }
.iteration-diff-item { font-size:12px; line-height:1.6; color:var(--text-secondary); margin-bottom:6px; }
.iteration-diff-path { color:var(--text-primary); font-weight:700; }
.iteration-effect-grid {
display:grid;
grid-template-columns:repeat(2, minmax(0, 1fr));
gap:10px;
margin-top:10px;
}
@media (min-width:1200px) { .iteration-effect-grid { grid-template-columns:repeat(5, minmax(0, 1fr)); } }
.iteration-effect-stat {
border:1px solid var(--border-subtle);
border-radius:12px;
padding:12px;
background:rgba(255,255,255,0.02);
}
.iteration-effect-stat .label { font-size:11px; color:var(--text-secondary); margin-bottom:6px; }
.iteration-effect-stat .value { font-size:18px; font-weight:800; color:var(--text-primary); }
.weight-table { width:100%; border-collapse:collapse; font-size:12px; }
.weight-table th { font-size:10px; color:var(--text-muted); text-align:left; padding:6px 8px; border-bottom:1px solid var(--border-subtle); font-weight:600; letter-spacing:0.5px; }
.weight-table td { padding:8px; border-bottom:1px solid rgba(255,255,255,0.03); }
.weight-table .w-name { font-weight:600; }
.weight-table .w-val { font-family:'JetBrains Mono',monospace; font-weight:600; }
.weight-table .w-hit { font-family:'JetBrains Mono',monospace; }
.w-bar { height:6px; border-radius:3px; background:rgba(255,255,255,0.05); overflow:hidden; max-width:80px; }
.w-bar-fill { height:100%; border-radius:3px; transition:width 0.6s ease; }
.w-bar-fill.green { background:var(--green); }
.w-bar-fill.red { background:var(--red); }
.w-bar-fill.yellow { background:var(--yellow); }
.missed-card {
background:var(--bg-card); border:1px solid var(--border-subtle);
border-radius:var(--radius); padding:14px; margin-bottom:8px;
}
.missed-card .mc-symbol { font-size:14px; font-weight:700; }
.missed-card .mc-gain { font-family:'JetBrains Mono',monospace; font-size:13px; font-weight:600; color:var(--red); }
.missed-card .mc-reason { font-size:11px; color:var(--text-secondary); margin-top:4px; }
.missed-card .mc-lesson { font-size:11px; color:var(--yellow); margin-top:4px; padding:4px 8px; background:var(--yellow-dim); border-radius:6px; }
.review-card {
background:var(--bg-card); border:1px solid var(--border-subtle);
border-radius:var(--radius); padding:14px; margin-bottom:8px;
}
.review-card .rc-symbol { font-size:14px; font-weight:700; }
.review-card .rc-result { font-family:'JetBrains Mono',monospace; font-size:13px; font-weight:600; }
.review-card .rc-result.hit { color:var(--green); }
.review-card .rc-result.miss { color:var(--red); }
.review-card .rc-attribution { font-size:11px; color:var(--text-secondary); margin-top:4px; }
.cron-summary-grid {
display:grid;
grid-template-columns:repeat(2, 1fr);
gap:10px;
margin-bottom:16px;
}
@media (min-width:768px) { .cron-summary-grid { grid-template-columns:repeat(4, 1fr); } }
.cron-stat {
background:var(--bg-card); border:1px solid var(--border-subtle);
border-radius:var(--radius); padding:14px;
}
.cron-stat .label { font-size:11px; color:var(--text-muted); margin-bottom:6px; }
.cron-stat .value { font-size:22px; font-weight:700; font-family:'JetBrains Mono',monospace; }
.cron-stat .value.green { color:var(--green); }
.cron-stat .value.red { color:var(--red); }
.cron-stat .value.blue { color:var(--blue); }
.cron-stat .value.yellow { color:var(--yellow); }
.cron-group { margin-bottom:20px; }
.cron-group-title {
font-size:12px; font-weight:700; color:var(--text-secondary);
margin:8px 0 10px; display:flex; align-items:center; gap:8px;
}
.cron-run-card {
background:var(--bg-card); border:1px solid var(--border-subtle);
border-radius:var(--radius); padding:14px; margin-bottom:8px;
}
.cron-run-top { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; }
.cron-run-title { font-size:14px; font-weight:700; }
.cron-run-meta { font-size:11px; color:var(--text-muted); margin-top:6px; display:flex; gap:10px; flex-wrap:wrap; font-family:'JetBrains Mono',monospace; }
.cron-badge {
font-size:10px; font-weight:600; padding:4px 8px; border-radius:6px;
text-transform:uppercase; letter-spacing:0.3px;
}
.cron-badge.success { background:var(--green-dim); color:var(--green); }
.cron-badge.error { background:var(--red-dim); color:var(--red); }
.cron-badge.partial { background:var(--yellow-dim); color:var(--yellow); }
.cron-summary-tags, .cron-detail-tags { display:flex; flex-wrap:wrap; gap:6px; margin-top:10px; }
.cron-tag {
font-size:10px; padding:4px 8px; border-radius:999px;
background:rgba(255,255,255,0.04); color:var(--text-secondary);
border:1px solid var(--border-subtle); font-family:'JetBrains Mono',monospace;
}
.cron-error {
margin-top:10px; padding:10px 12px; border-radius:10px;
background:var(--red-dim); color:var(--red); font-size:11px;
border:1px solid rgba(255,61,113,0.2); white-space:pre-wrap;
}
.leaderboard-section {
margin:0;
padding:14px;
width:100%;
box-sizing:border-box;
border-radius:var(--radius);
background:var(--bg-card);
border:1px solid var(--border-subtle);
}
.leaderboard-grid {
display:grid; grid-template-columns:1fr; gap:10px;
}
@media (min-width:768px) { .leaderboard-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } }
@media (min-width:1200px) { .leaderboard-grid { grid-template-columns:repeat(4,minmax(0,1fr)); } }
.leaderboard-card {
background:rgba(255,255,255,0.03); border:1px solid var(--border-subtle);
border-radius:var(--radius); padding:14px; min-width:0;
}
.leaderboard-card .lb-label { font-size:11px; color:var(--text-muted); margin-bottom:8px; }
.leaderboard-card .lb-symbol { font-size:16px; font-weight:800; margin-bottom:4px; }
.leaderboard-card .lb-metric { font-size:20px; font-weight:800; font-family:'JetBrains Mono',monospace; }
.leaderboard-card .lb-metric.green { color:var(--green); }
.leaderboard-card .lb-metric.red { color:var(--red); }
.leaderboard-card .lb-metric.yellow { color:var(--yellow); }
.leaderboard-card .lb-meta { margin-top:8px; font-size:11px; color:var(--text-secondary); display:flex; gap:8px; flex-wrap:wrap; }
.tier-summary {
margin:0; padding:14px; border-radius:var(--radius);
background: var(--bg-card); border:1px solid var(--border-subtle);
width:100%; box-sizing:border-box;
}
.tier-title { font-size:13px; font-weight:700; color:var(--accent); margin-bottom:8px; }
.tier-grid { display:grid; grid-template-columns:1fr; gap:8px; }
@media (min-width:768px) { .tier-grid { grid-template-columns:repeat(3,minmax(0,1fr)); } }
.tier-item { background:rgba(255,255,255,0.03); border-radius:10px; padding:10px 12px; border:1px solid var(--border-subtle); min-width:0; }
.tier-item .tt-k { font-size:11px; font-weight:700; margin-bottom:4px; }
.tier-item .tt-v { font-size:18px; font-weight:800; font-family:'JetBrains Mono',monospace; }
.tier-item .tt-d { font-size:11px; color:var(--text-secondary); margin-top:4px; }
.tier-item.small .tt-k, .tier-item.small .tt-v { color:var(--green); }
.tier-item.medium .tt-k, .tier-item.medium .tt-v { color:var(--yellow); }
.tier-item.big .tt-k, .tier-item.big .tt-v { color:var(--red); }
.result-indicator {
margin-top:8px; font-size:11px; font-weight:700; padding:5px 9px;
border-radius:8px; display:inline-flex; align-items:center; gap:4px;
border:1px solid transparent;
}
.result-success { background:var(--green-dim); color:var(--green); border-color:rgba(0,214,143,0.28); }
.result-failed { background:var(--red-dim); color:var(--red); border-color:rgba(255,61,113,0.28); }
.result-pending { background:rgba(255,255,255,0.05); color:var(--text-secondary); border-color:var(--border-subtle); }
.definition-box {
margin:0; padding:14px; border-radius: var(--radius);
background: var(--bg-card); border:1px solid var(--border-subtle);
width:100%; box-sizing:border-box;
}
.definition-title { font-size:13px; font-weight:700; color:var(--accent); margin-bottom:8px; }
.definition-list { display:grid; grid-template-columns:1fr; gap:8px; }
@media (min-width:768px) { .definition-list { grid-template-columns:repeat(3,minmax(0,1fr)); } }
.definition-item { background:rgba(255,255,255,0.03); border-radius:10px; padding:10px 12px; border:1px solid var(--border-subtle); min-width:0; }
.definition-item .k { font-size:11px; font-weight:700; margin-bottom:4px; }
.definition-item .v { font-size:11px; color:var(--text-secondary); line-height:1.5; }
.definition-item.success .k { color:var(--green); }
.definition-item.failed .k { color:var(--red); }
.definition-item.pending .k { color:var(--yellow); }
/* === 舆情监控样式:消息优先,币种为关联信息 === */
.sentiment-list {
display:flex;
flex-direction:column;
gap:12px;
padding:0 16px;
}
@media (min-width:768px) { .sentiment-list { padding:0 24px; } }
@media (min-width:1200px) { .sentiment-list { padding:0 32px; } }
.sentiment-event-card {
background:linear-gradient(135deg, rgba(255,255,255,0.04), rgba(255,255,255,0.018));
border:1px solid var(--border-subtle);
border-radius:var(--radius);
padding:16px;
display:grid;
grid-template-columns:1fr;
gap:12px;
transition:var(--transition);
position:relative;
overflow:hidden;
}
@media (min-width:900px) { .sentiment-event-card { grid-template-columns:minmax(0,1fr) 260px; align-items:start; } }
.sentiment-event-card:hover { border-color:var(--border-accent); box-shadow:var(--shadow); transform:translateY(-1px); }
.sentiment-event-card.importance-S { border-left:4px solid var(--red); }
.sentiment-event-card.importance-A { border-left:4px solid var(--accent); }
.sentiment-event-card.importance-B { border-left:4px solid var(--blue); }
.sentiment-event-card.importance-RISK { border-left:4px solid var(--yellow); }
.sentiment-event-main { min-width:0; }
.sentiment-event-top { display:flex; flex-wrap:wrap; align-items:center; gap:8px; margin-bottom:10px; }
.sentiment-source-chip, .sentiment-importance-chip, .sentiment-decision-chip {
display:inline-flex; align-items:center; gap:4px;
font-size:10px; font-weight:800; padding:4px 8px; border-radius:999px;
border:1px solid var(--border-subtle); background:rgba(255,255,255,0.04); color:var(--text-secondary);
white-space:nowrap;
}
.sentiment-importance-chip.level-S { color:var(--red); background:var(--red-dim); border-color:rgba(255,61,113,0.25); }
.sentiment-importance-chip.level-A { color:var(--accent); background:var(--accent-glow); border-color:var(--border-accent); }
.sentiment-importance-chip.level-B { color:var(--blue); background:rgba(51,154,240,0.10); border-color:rgba(51,154,240,0.22); }
.sentiment-importance-chip.level-RISK { color:var(--yellow); background:var(--yellow-dim); border-color:rgba(255,176,32,0.25); }
.sentiment-event-time { font-size:11px; color:var(--text-muted); margin-left:auto; }
.sentiment-event-title {
font-size:15px; font-weight:800; line-height:1.55; color:var(--text-primary);
text-decoration:none; display:block;
}
.sentiment-event-title:hover { color:var(--accent); text-decoration:underline; }
.sentiment-event-meta { margin-top:10px; display:flex; flex-wrap:wrap; gap:8px; color:var(--text-muted); font-size:11px; }
.sentiment-related-box {
border:1px solid var(--border-subtle);
border-radius:12px;
background:rgba(0,0,0,0.16);
padding:12px;
min-width:0;
}
.sentiment-related-label { font-size:10px; color:var(--text-muted); font-weight:800; letter-spacing:0.5px; text-transform:uppercase; margin-bottom:8px; }
.sentiment-related-symbol { display:flex; align-items:baseline; justify-content:space-between; gap:10px; margin-bottom:8px; }
.sentiment-related-symbol a { font-size:18px; font-weight:900; color:var(--text-primary); text-decoration:none; }
.sentiment-related-symbol a:hover { color:var(--accent); }
.sentiment-related-name { font-size:11px; color:var(--text-muted); margin-top:2px; }
.sentiment-related-tag { font-size:10px; font-weight:800; color:var(--accent); background:var(--accent-glow); border:1px solid var(--border-accent); padding:4px 8px; border-radius:999px; white-space:nowrap; }
.sentiment-related-metrics { display:flex; flex-wrap:wrap; gap:6px; }
.sentiment-related-metric { font-size:11px; color:var(--text-secondary); padding:5px 8px; border-radius:8px; background:rgba(255,255,255,0.03); border:1px solid var(--border-subtle); }
.sentiment-change.change-strong-up { color:#00ff8c; }
.sentiment-change.change-up { color:var(--green); }
.sentiment-change.change-down { color:var(--red); }
.sentiment-change.change-flat { color:var(--text-muted); }
.sentiment-footer {
display:flex; flex-wrap:wrap; gap:12px; padding:16px 20px; margin:20px 16px;
border-radius:var(--radius-sm); background:rgba(255,255,255,0.02); border:1px solid var(--border-subtle);
font-size:11px; color:var(--text-muted); justify-content:center;
}
@media (min-width:768px) { .sentiment-footer { margin:20px 24px; } }
@media (min-width:1200px) { .sentiment-footer { margin:20px 32px; } }
.sentiment-empty { text-align:center; padding:40px 16px; color:var(--text-muted); }
.sentiment-empty .icon { font-size:40px; margin-bottom:12px; }
.sentiment-empty .title { font-size:16px; font-weight:600; margin-bottom:6px; }
.sentiment-empty .desc { font-size:12px; }
</style>
</head>
<body>
<div class="app-shell">
<div class="header">
<div class="header-left">
<div class="logo"><span>⚡</span> AI Radar</div>
<span class="v11-badge">__STRATEGY_VERSION__</span>
</div>
<div class="header-right">
<label class="auto-toggle" title="开启自动刷新60秒">
<input type="checkbox" id="autoRefreshToggle" onchange="toggleAutoRefresh()">
<span class="toggle-track"></span>
<span class="toggle-label">自动</span>
</label>
<button class="header-refresh-btn" onclick="doRefresh()" title="手动刷新">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/></svg>
</button>
</div>
</div>
<div class="stats-row" id="statsRow"></div>
<div class="tabs">
<button class="tab-btn active" data-tab="active" onclick="switchTab('active',this)">实时推荐</button>
<button class="tab-btn" data-tab="watch" onclick="switchTab('watch',this)">观察池</button>
<button class="tab-btn" data-tab="all" onclick="switchTab('all',this)">历史推荐</button>
<button class="tab-btn" data-tab="review" onclick="switchTab('review',this)">复盘/迭代</button>
<button class="tab-btn" data-tab="cron" onclick="switchTab('cron',this)">Cron日志</button>
<button class="tab-btn" data-tab="sentiment" onclick="switchTab('sentiment',this)">📢 舆情</button>
</div>
<!-- 筛选池入口已从顶部交易导航移除;内部调试仍保留 /api/screening 接口。 -->
<div class="version-filter-bar" id="versionFilterBar">
<span class="version-filter-label">📦 策略版本:</span>
<select id="versionSelect" onchange="onVersionChange()">
<option value="">全部版本</option>
</select>
<span id="versionCount" class="version-count"></span>
</div>
<div class="cards" id="cardContainer">
<div class="card-grid">
<div class="skeleton"></div>
<div class="skeleton"></div>
</div>
</div>
<script>
const API = '';
let curTab = 'active';
let curVersion = '';
let refreshTimer = null;
// === Version Filter ===
async function loadVersions() {
try {
const view = curTab === 'watch' ? 'watch' : 'active';
const resp = await fetch(API+'/api/versions?view='+encodeURIComponent(view));
const versions = await resp.json();
const sel = $('#versionSelect');
const allLabel = curTab === 'watch' ? '全部观察' : '全部计划';
sel.innerHTML = `<option value="">${allLabel}</option>`;
versions.forEach(v => {
const suffix = curTab === 'watch' ? '观察' : '计划';
sel.innerHTML += `<option value="${v.version}">${v.version} (${v.count}${suffix})</option>`;
});
// 默认选中最新策略版本;升级后页面自动聚焦当前最新版本
if (!curVersion && versions.length > 0) curVersion = versions[0].version;
sel.value = curVersion || '';
} catch(e) { console.error('loadVersions', e); }
}
function onVersionChange() {
curVersion = $('#versionSelect').value;
loadContent();
}
function updateVersionCount(data) {
const cnt = data ? data.length : 0;
const suffix = curTab === 'watch' ? '条观察' : '条计划';
$('#versionCount').textContent = curVersion ? `${cnt}${suffix}` : '';
}
// === Helpers ===
const $ = s => document.querySelector(s);
const $$ = s => document.querySelectorAll(s);
const fmtP = p => p>=100?'$'+p.toFixed(2):p>=1?'$'+p.toFixed(3):p>=0.01?'$'+p.toFixed(4):'$'+p.toFixed(6);
const fmtPct = p => (p>=0?'+':'')+p.toFixed(2)+'%';
const fmtTime = t => {
if(!t) return '--';
const d=new Date(t), now=new Date(), ms=now-d, m=Math.floor(ms/6e4);
if(m<1) return '刚刚';
if(m<60) return m+'分钟前';
const h=Math.floor(m/60);
if(h<24) return h+'小时前';
return (d.getMonth()+1)+'/'+d.getDate()+' '+String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0');
};
function scoreRing(score, max=20) {
const pct = Math.min(score/max, 1);
const r = 14, c = 2*Math.PI*r;
const offset = c * (1 - pct);
const color = pct>=0.6?'var(--green)':pct>=0.35?'var(--yellow)':'var(--text-muted)';
return `<div class="score-ring" title="综合评分 ${score}/${max}">
<svg width="36" height="36"><circle cx="18" cy="18" r="${r}" fill="none" stroke="rgba(255,255,255,0.06)" stroke-width="3"/>
<circle cx="18" cy="18" r="${r}" fill="none" stroke="${color}" stroke-width="3" stroke-linecap="round"
stroke-dasharray="${c}" stroke-dashoffset="${offset}"/></svg>
<span class="score-text">${score}</span></div>`;
}
function parseSignals(s) {
try { return typeof s==='string'?JSON.parse(s||'[]'):(s||[]); }
catch(e){ return []; }
}
function sigClass(sig) {
// v11前瞻信号分类 + 强度前缀
const s = sig;
// 🔥 强确认信号
if (/量价齐飞.*≥2|根量价齐飞K|最强确认/.test(s)) return 'strong';
if (/量价齐飞/.test(s)) return 'forward';
if (/突破放量|突破量能/.test(s)) return 'strong';
if (/起爆点/.test(s)) return 'pa strong-pa';
// ⚡ 中等信号
if (/N倍放量|连续.*放量/.test(s)) return 'forward';
if (/筑底|底部形成/.test(s)) return 'forward';
if (/静K|蓄力|动K/.test(s)) return 'pa';
if (/布林收窄/.test(s)) return 'forward';
if (/站稳突破/.test(s)) return 'forward';
if (/回踩/.test(s)) return 'forward';
// 💡 参考信号
if (/大户/.test(s)) return 'info';
if (/板块联动/.test(s)) return 'highlight';
// ⚠️ 警示信号
if (/空头|衰减|背离|⚠️/.test(s)) return 'warn';
if (/MACD|RSI|均线/.test(s)) return '';
return '';
}
function sigEmoji(sig) {
if (/量价齐飞.*≥2|根量价齐飞|最强确认/.test(sig)) return '🔥';
if (/量价齐飞/.test(sig)) return '';
if (/突破放量|突破量能/.test(sig)) return '🔥';
if (/起爆点/.test(sig)) return '🔥';
if (/筑底|底部形成/.test(sig)) return '';
if (/静K|蓄力|动K/.test(sig)) return '';
if (/N倍放量|连续.*放量/.test(sig)) return '';
if (/布林收窄/.test(sig)) return '';
if (/站稳突破/.test(sig)) return '';
if (/回踩/.test(sig)) return '';
if (/大户/.test(sig)) return '💡';
if (/板块联动/.test(sig)) return '💡';
if (/空头|衰减|背离|⚠️/.test(sig)) return '⚠️';
return '';
}
function accentClass(state) {
const m = {'加速':'yellow','爆发':'red','蓄力':'blue','active':'green','hit_tp1':'green','hit_tp2':'green','stopped_out':'red','expired':'gray'};
return m[state]||'gray';
}
function badgeClass(state) {
const m = {'加速':'accel','爆发':'burst','蓄力':'蓄力','active':'active','hit_tp1':'hit_tp1','hit_tp2':'hit_tp2','stopped_out':'stopped_out','expired':'expired',
'可即刻买入':'可即刻买入','等回踩':'等回踩','衰减':'衰减','止损':'止损','止盈1':'止盈1','止盈2':'止盈2','跟踪止盈':'跟踪止盈','反转':'反转','持有':'持有'};
return m[state]||'expired';
}
function badgeLabel(state) {
const m = {'加速':'⚡加速中','爆发':'🔥已爆发','蓄力':'💤蓄力中','active':'📊跟踪中','hit_tp1':'✅ TP1','hit_tp2':'✅ TP2','stopped_out':'✗ 止损','expired':'⏰ 已过期',
'可即刻买入':'🟢即刻买入','等回踩':'🟡等回踩','衰减':'⚠️衰减','止损':'🔴止损','止盈1':'✅止盈1','止盈2':'✅止盈2','跟踪止盈':'✅跟踪止盈','反转':'🔴反转','持有':'持有'};
return m[state]||state;
}
function actionEmoji(action) {
if(!action) return '⚠️';
if(action.includes('即刻')||action.includes('可即刻')||action==='🟢即刻买入') return '🟢';
if(action.includes('等回踩')||action==='🟡等回踩') return '🟡';
if(action.includes('放弃')||action==='🔴放弃') return '🔴';
if(action.includes('衰减')) return '⚠️';
if(action.includes('止盈')||action.includes('')) return '';
if(action.includes('止损')||action.includes('反转')||action==='🔴') return '🔴';
return '⚠️';
}
function coinInitials(symbol) {
return symbol.replace('/USDT','').substring(0,2).toUpperCase();
}
// === Render Stats ===
function renderLeaderboard(stats) {
return '';
}
function renderTierSummary(stats) {
const counts = stats.success_tier_counts || {};
const defs = stats.success_tier_definition || {};
return `<div class="tier-summary">
<div class="tier-title">成功分级统计</div>
<div class="tier-grid">
<div class="tier-item small"><div class="tt-k">🟢 小成功</div><div class="tt-v">${counts.small || 0}</div><div class="tt-d">${defs.small || '--'}</div></div>
<div class="tier-item medium"><div class="tt-k">🟠 中成功</div><div class="tt-v">${counts.medium || 0}</div><div class="tt-d">${defs.medium || '--'}</div></div>
<div class="tier-item big"><div class="tt-k">🔴 大成功</div><div class="tt-v">${counts.big || 0}</div><div class="tt-d">${defs.big || '--'}</div></div>
</div>
</div>`;
}
function fmtHours(v) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return '--';
return Number(v).toFixed(1) + 'h';
}
function fmtCompactNumber(v) {
const num = Number(v || 0);
const abs = Math.abs(num);
if (abs >= 1e8) return (num / 1e8).toFixed(2) + '亿';
if (abs >= 1e4) return (num / 1e4).toFixed(1) + '';
if (abs >= 1e3) return (num / 1e3).toFixed(1) + 'k';
return String(Math.round(num));
}
function fmtSignedMaybePct(v, digits=1) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return '--';
const num = Number(v);
return `${num >= 0 ? '+' : ''}${num.toFixed(digits)}%`;
}
function fmtRatio(v, digits=2) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return '--';
return Number(v).toFixed(digits) + 'x';
}
function valueTone(v) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return '';
const num = Number(v);
if (num > 0) return 'green';
if (num < 0) return 'red';
return 'yellow';
}
function parseContextMaybe(obj) {
if (!obj) return {};
if (typeof obj === 'string') {
try { return JSON.parse(obj || '{}'); }
catch (e) { return {}; }
}
return obj;
}
function renderContextMetric(label, value, tone='') {
return `<div class="context-item"><span class="context-item-label">${label}</span><span class="context-item-value ${tone}">${value}</span></div>`;
}
function renderContextStrip(rec) {
const market = parseContextMaybe(rec.market_context);
const derivatives = parseContextMaybe(rec.derivatives_context);
const sector = parseContextMaybe(rec.sector_context);
const sectorTags = (sector.hot_sectors || sector.sectors || []).slice(0, 4);
const marketHtml = [
renderContextMetric('24h成交额', market.volume_24h ? fmtCompactNumber(market.volume_24h) : '--'),
renderContextMetric('1h量能加速', fmtRatio(market.turnover_acceleration_1h), valueTone(market.turnover_acceleration_1h)),
renderContextMetric('4h量能加速', fmtRatio(market.turnover_acceleration_4h), valueTone(market.turnover_acceleration_4h)),
renderContextMetric('24h涨跌', fmtSignedMaybePct(market.change_24h, 1), valueTone(market.change_24h)),
].join('');
const derivativesHtml = [
renderContextMetric('Funding', fmtSignedMaybePct((derivatives.funding_rate || 0) * 100, 3), valueTone(derivatives.funding_rate)),
renderContextMetric('OI变化24h', fmtSignedMaybePct(derivatives.open_interest_change_24h, 1), valueTone(derivatives.open_interest_change_24h)),
renderContextMetric('大户多头占比', fmtSignedMaybePct(derivatives.top_trader_long_pct, 1), valueTone((derivatives.top_trader_long_pct || 0) - 50)),
renderContextMetric('多空比', fmtRatio(derivatives.top_trader_long_short_ratio), valueTone((derivatives.top_trader_long_short_ratio || 0) - 1)),
].join('');
const sectorInfoHtml = [
renderContextMetric('热点板块数', String((sector.hot_sectors || []).length || (sector.sectors || []).length || 0), 'blue'),
renderContextMetric('龙头币', sector.leader_symbol || '--', 'blue'),
renderContextMetric('龙头涨幅', fmtSignedMaybePct(sector.leader_move_pct, 1), valueTone(sector.leader_move_pct)),
`<div class="context-item full"><span class="context-item-label">热点板块</span><div class="context-tags">${sectorTags.length ? sectorTags.map(tag => `<span class="context-tag">${tag}</span>`).join('') : '<span class="context-item-value">--</span>'}</div></div>`
].join('');
return `<div class="context-strip">
<div class="context-card market">
<div class="context-card-header"><span class="context-card-title">市场热度</span><span class="context-card-badge">MARKET</span></div>
<div class="context-grid">${marketHtml}</div>
</div>
<div class="context-card derivatives">
<div class="context-card-header"><span class="context-card-title">衍生品情绪</span><span class="context-card-badge">DERIV</span></div>
<div class="context-grid">${derivativesHtml}</div>
</div>
<div class="context-card sector">
<div class="context-card-header"><span class="context-card-title">板块联动</span><span class="context-card-badge">SECTOR</span></div>
<div class="context-grid">${sectorInfoHtml}</div>
</div>
</div>`;
}
// ===== 日线K线图 SVG 渲染 =====
function renderDailyKlineChart(symbol, candles, entryPrice, stopLoss, tp1) {
if (!candles || candles.length < 5) return '<div class="chart-placeholder">K线数据不足</div>';
const base = (symbol||'').replace('/USDT','');
const recPrice = Number(entryPrice || 0);
const slPrice = Number(stopLoss || 0);
const tpPrice = Number(tp1 || 0);
const W = 360, H = 180, volH = 26;
const padL = 36, padR = 3, padT = 6, padB = 16;
const chartW = W - padL - padR;
const chartH = H - padT - padB;
// 取最近60根用满宽度
const data = candles.slice(-60);
const n = data.length;
const candleW = Math.max(1.5, Math.floor(chartW / n * 0.78));
const gap = Math.max(0.8, Math.floor(chartW / n * 0.22));
let minPrice = Infinity, maxPrice = -Infinity, maxVol = 0;
data.forEach(c => {
minPrice = Math.min(minPrice, c.low);
maxPrice = Math.max(maxPrice, c.high);
maxVol = Math.max(maxVol, c.volume);
});
if (recPrice > 0) { minPrice = Math.min(minPrice, recPrice); maxPrice = Math.max(maxPrice, recPrice); }
if (slPrice > 0) { minPrice = Math.min(minPrice, slPrice); maxPrice = Math.max(maxPrice, slPrice); }
if (tpPrice > 0) { minPrice = Math.min(minPrice, tpPrice); maxPrice = Math.max(maxPrice, tpPrice); }
const priceRange = maxPrice - minPrice || 1;
const priceY = (p) => padT + chartH - ((p - minPrice) / priceRange * chartH);
let svg = `<svg viewBox="0 0 ${W} ${H + volH}" class="kline-chart" style="width:100%;height:auto">`;
// 水平网格线 + 价格标签
for (let i = 0; i <= 4; i++) {
const y = padT + (chartH / 4) * i;
const p = maxPrice - (priceRange / 4) * i;
svg += `<line x1="${padL}" y1="${y}" x2="${W - padR}" y2="${y}" stroke="rgba(255,255,255,0.05)"/>`;
const priceStr = p < 0.01 ? p.toFixed(6) : p < 1 ? p.toFixed(4) : p < 10 ? p.toFixed(3) : p.toFixed(2);
svg += `<text x="${padL - 4}" y="${y + 4}" text-anchor="end" class="chart-label" fill="#525c6b" font-size="9" font-family="JetBrains Mono,monospace">${priceStr}</text>`;
}
// 蜡烛 + 成交量
data.forEach((c, i) => {
const x = padL + i * (candleW + gap);
const cx = x + candleW / 2;
const isUp = c.close >= c.open;
const color = isUp ? '#00d68f' : '#ff3d71';
const yOpen = priceY(c.open), yClose = priceY(c.close);
const yHigh = priceY(c.high), yLow = priceY(c.low);
// 影线
svg += `<line x1="${cx}" y1="${yHigh}" x2="${cx}" y2="${yLow}" stroke="${color}" stroke-width="0.8"/>`;
// 实体
const bodyH = Math.max(1, Math.abs(yClose - yOpen));
const bodyY = Math.min(yOpen, yClose);
svg += `<rect x="${x}" y="${bodyY}" width="${Math.max(1.5, candleW)}" height="${bodyH}" fill="${color}" rx="0.5"/>`;
// 成交量柱
const volY = H + volH;
const volBarH = Math.max(0.5, (c.volume / maxVol) * volH * 0.7);
const volColor = isUp ? 'rgba(0,214,143,0.25)' : 'rgba(255,61,113,0.25)';
svg += `<rect x="${x}" y="${volY - volBarH}" width="${Math.max(1.5, candleW)}" height="${volBarH}" fill="${volColor}" rx="0.5"/>`;
});
// 绘制K线右侧pin标记不入场线用pinpoint标记价格位
function drawPricePin(price, label, color, shape) {
const y = priceY(price);
const text = price < 0.01 ? price.toFixed(6) : price < 1 ? price.toFixed(4) : price < 10 ? price.toFixed(3) : price.toFixed(2);
const pinX = W - padR - 6;
const labelW = 56;
const labelX = pinX - labelW - 4;
// 小标记图形
if (shape === 'entry') {
svg += `<circle cx="${pinX}" cy="${y}" r="3.5" fill="${color}" stroke="#fff" stroke-width="0.8"/><line x1="${pinX-3.5}" y1="${y}" x2="${padL}" y2="${y}" stroke="${color}" stroke-width="0.6" stroke-dasharray="2 4" opacity="0.4"/>`;
} else if (shape === 'tp') {
svg += `<polygon points="${pinX+4},${y-4.5} ${pinX-3.5},${y} ${pinX+4},${y+4.5}" fill="${color}" opacity="0.9"/>`;
} else if (shape === 'sl') {
svg += `<polygon points="${pinX+4},${y-4.5} ${pinX-3.5},${y} ${pinX+4},${y+4.5}" fill="${color}" opacity="0.9"/>`;
}
// 价格标签
svg += `<rect x="${labelX}" y="${Math.max(1, y - 10)}" width="${labelW}" height="16" rx="8" fill="${color}22" stroke="${color}88" stroke-width="0.6"/>`;
svg += `<text x="${labelX + labelW/2}" y="${Math.max(12, y + 4)}" text-anchor="middle" fill="${color}" font-size="9" font-family="JetBrains Mono,monospace">${label}${text}</text>`;
}
if (recPrice > 0) drawPricePin(recPrice, '入场 ', '#ffd166', 'entry');
if (slPrice > 0) drawPricePin(slPrice, '止损 ', '#ff5575', 'sl');
if (tpPrice > 0) drawPricePin(tpPrice, '止盈 ', '#28e29d', 'tp');
svg += '</svg>';
return `<div class="kline-section" data-entry-price="${recPrice || ''}" data-stop-loss="${slPrice || ''}" data-tp1="${tpPrice || ''}">
<div class="kline-title">
<span>📊 ${base}</span>
<span class="kline-interval-selector">
<button class="kline-int-btn" data-int="1d" data-symbol="${symbol}">日</button>
<button class="kline-int-btn" data-int="4h" data-symbol="${symbol}">4h</button>
<button class="kline-int-btn active" data-int="1h" data-symbol="${symbol}">1h</button>
<button class="kline-int-btn" data-int="15m" data-symbol="${symbol}">15m</button>
</span>
</div>
${svg}
</div>`;
}
// ===== 异步加载所有推荐卡片的1H K线图默认周期=====
async function loadKlineCharts() {
const containers = document.querySelectorAll('.kline-container');
for (const c of containers) {
const symbol = c.dataset.symbol;
const entryPrice = parseFloat(c.dataset.entryPrice || '0');
const stopLoss = parseFloat(c.dataset.stopLoss || '0');
const tp1 = parseFloat(c.dataset.tp1 || '0');
if (!symbol) continue;
try {
const resp = await fetch(`/api/kline?symbol=${encodeURIComponent(symbol)}&interval=1h&limit=60`);
const data = await resp.json();
if (data.candles && data.candles.length) {
klineCache[`${symbol}|1h`] = data.candles; // 缓存1h
c.innerHTML = renderDailyKlineChart(symbol, data.candles, entryPrice, stopLoss, tp1);
} else {
c.innerHTML = '<div class="chart-placeholder">K线暂无数据</div>';
}
} catch(e) {
c.innerHTML = '<div class="chart-placeholder">K线加载失败</div>';
}
}
}
// ===== K线周期切换 =====
const klineCache = {};
async function switchKlineInterval(btn) {
const interval = btn.dataset.int;
const symbol = btn.dataset.symbol;
const cacheKey = symbol + '|' + interval;
const section = btn.closest('.kline-section');
const svgEl = section.querySelector('.kline-chart');
section.querySelectorAll('.kline-int-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
if (svgEl) svgEl.style.opacity = '0.4';
let candles = klineCache[cacheKey];
if (!candles) {
try {
const resp = await fetch('/api/kline?symbol=' + encodeURIComponent(symbol) + '&interval=' + interval + '&limit=60');
const data = await resp.json();
if (data.candles && data.candles.length) { candles = data.candles; klineCache[cacheKey] = candles; }
} catch(e) {}
}
const entryPrice = parseFloat(section.dataset.entryPrice || '0');
const stopLoss = parseFloat(section.dataset.stopLoss || '0');
const tp1 = parseFloat(section.dataset.tp1 || '0');
if (candles && candles.length) {
const newSvg = renderKlineSvg(candles, entryPrice, stopLoss, tp1);
const svgEl2 = section.querySelector('.kline-chart');
if (svgEl2) {
svgEl2.outerHTML = newSvg;
} else {
section.insertAdjacentHTML('beforeend', newSvg);
}
section.querySelectorAll('.kline-int-btn').forEach(b => {
if (b.dataset.int === interval) b.classList.add('active');
});
} else if (svgEl) { svgEl.style.opacity = '1'; }
}
function renderKlineSvg(data, entryPrice, stopLoss, tp1) {
// 与 renderDailyKlineChart 完全相同的尺寸参数,确保切换不闪变
var W=360, H=180, volH=26, padL=36, padR=3, padT=6, padB=16;
var recP=Number(entryPrice||0), slP=Number(stopLoss||0), tpP=Number(tp1||0);
var chartH=H-padT-padB, n=data.length, gap=1;
var cw=Math.max(1.5,(W-padL-padR-gap*(n-1))/n);
var prices=data.flatMap(function(c){return[c.high,c.low];});
var maxP=Math.max.apply(null,prices), minP=Math.min.apply(null,prices);
if(recP>0){maxP=Math.max(maxP,recP);minP=Math.min(minP,recP);}
if(slP>0){maxP=Math.max(maxP,slP);minP=Math.min(minP,slP);}
if(tpP>0){maxP=Math.max(maxP,tpP);minP=Math.min(minP,tpP);}
var maxV=Math.max.apply(null,data.map(function(c){return c.volume;}));
var range=maxP-minP||1;
var py=function(p){return padT+chartH-((p-minP)/range*chartH);};
var svg='<svg viewBox="0 0 '+W+' '+(H+volH)+'" class="kline-chart" style="width:100%;height:auto">';
for(var i=0;i<=4;i++){var y=padT+chartH/4*i, p=maxP-range/4*i, ps=p<0.01?p.toFixed(6):p<1?p.toFixed(4):p<10?p.toFixed(3):p.toFixed(2);
svg+='<line x1="'+padL+'" y1="'+y+'" x2="'+(W-padR)+'" y2="'+y+'" stroke="rgba(255,255,255,0.05)"/>';
svg+='<text x="'+(padL-4)+'" y="'+(y+4)+'" text-anchor="end" fill="#525c6b" font-size="9" font-family="JetBrains Mono,monospace">'+ps+'</text>';}
data.forEach(function(c,i){var x=padL+i*(cw+gap), cx=x+cw/2, up=c.close>=c.open, col=up?'#00d68f':'#ff3d71';
svg+='<line x1="'+cx+'" y1="'+py(c.high)+'" x2="'+cx+'" y2="'+py(c.low)+'" stroke="'+col+'" stroke-width="0.8"/>';
var bh=Math.max(1,Math.abs(py(c.close)-py(c.open))), by=Math.min(py(c.open),py(c.close));
svg+='<rect x="'+x+'" y="'+by+'" width="'+Math.max(1.5,cw)+'" height="'+bh+'" fill="'+col+'" rx="0.5"/>';
var vy=H+volH, vbh=Math.max(0.5,c.volume/maxV*volH*0.7), vc=up?'rgba(0,214,143,0.25)':'rgba(255,61,113,0.25)';
svg+='<rect x="'+x+'" y="'+(vy-vbh)+'" width="'+Math.max(1.5,cw)+'" height="'+vbh+'" fill="'+vc+'" rx="0.5"/>';});
function dp(p, label, color, shape){
var y=py(p), t=p<0.01?p.toFixed(6):p<1?p.toFixed(4):p<10?p.toFixed(3):p.toFixed(2), px=W-padR-6, lw=56, lx=px-lw-4;
if(shape==='entry'){svg+='<circle cx="'+px+'" cy="'+y+'" r="3.5" fill="'+color+'" stroke="#fff" stroke-width="0.8"/><line x1="'+(px-3.5)+'" y1="'+y+'" x2="'+padL+'" y2="'+y+'" stroke="'+color+'" stroke-width="0.6" stroke-dasharray="2 4" opacity="0.4"/>';}
else{svg+='<polygon points="'+(px+4)+','+(y-4.5)+' '+(px-3.5)+','+y+' '+(px+4)+','+(y+4.5)+'" fill="'+color+'" opacity="0.9"/>';}
svg+='<rect x="'+lx+'" y="'+Math.max(1,y-10)+'" width="'+lw+'" height="16" rx="8" fill="'+color+'22" stroke="'+color+'88" stroke-width="0.6"/>';
svg+='<text x="'+(lx+lw/2)+'" y="'+Math.max(12,y+4)+'" text-anchor="middle" fill="'+color+'" font-size="9" font-family="JetBrains Mono,monospace">'+label+t+'</text>';}
if(recP>0) dp(recP,'入场 ','#ffd166','entry');
if(slP>0) dp(slP,'止损 ','#ff5575','sl');
if(tpP>0) dp(tpP,'止盈 ','#28e29d','tp');
svg+='</svg>'; return svg;
}
document.addEventListener('click', function(e) {
if (e.target.classList.contains('kline-int-btn')) { e.preventDefault(); switchKlineInterval(e.target); }
});
function lifecycleStageClass(stage) {
if (stage === '等待入场') return 'wait';
if (stage === '进入衰减') return 'decay';
if (stage === '已验证成功') return 'success';
if (stage === '已验证失败') return 'failed';
return 'hold';
}
function renderLifecycleCard(title, item) {
if (!item) {
return `<div class="lifecycle-card"><div class="lc-title">${title}</div><div class="lc-symbol">--</div><div class="lifecycle-meta"><span>暂无数据</span></div></div>`;
}
const symbol = (item.symbol || '--').replace('/USDT', '');
const stage = item.lifecycle_stage || '--';
const stageCls = lifecycleStageClass(stage);
return `<div class="lifecycle-card">
<div class="lc-title">${title}</div>
<div class="lc-symbol">${symbol}</div>
<div class="lc-stage ${stageCls}">${stage}</div>
<div class="lifecycle-meta">
<span>持有时长 ${fmtHours(item.hold_hours)}</span>
<span>最新收益 ${fmtPct(item.pnl_pct || 0)}</span>
<span>最大涨幅 ${fmtPct(item.max_pnl_pct || 0)}</span>
<span>最大回撤 ${fmtPct(item.max_drawdown_pct || 0)}</span>
<span>动作 ${item.action_status || '--'}</span>
<span>跟踪延迟 ${fmtHours(item.track_delay_hours)}</span>
</div>
</div>`;
}
function renderEquityChart(points, chartId) {
const series = Array.isArray(points) ? points : [];
if (!series.length) return `<div class="chart-empty">暂无可绘制的净值轨迹</div>`;
const width = 720;
const height = 220;
const padX = 16;
const padTop = 18;
const padBottom = 28;
const vals = series.map(p => Number(p.avg_pnl || 0));
const maxVal = Math.max(...vals, 0);
const minVal = Math.min(...vals, 0);
const span = Math.max(maxVal - minVal, 1);
const baseY = padTop + ((maxVal - 0) / span) * (height - padTop - padBottom);
const coords = series.map((p, i) => {
const x = padX + (series.length === 1 ? (width - padX * 2) / 2 : i * (width - padX * 2) / (series.length - 1));
const y = padTop + ((maxVal - Number(p.avg_pnl || 0)) / span) * (height - padTop - padBottom);
return { ...p, x, y };
});
const line = coords.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' ');
const area = `${line} L ${coords[coords.length - 1].x.toFixed(1)} ${(height - padBottom).toFixed(1)} L ${coords[0].x.toFixed(1)} ${(height - padBottom).toFixed(1)} Z`;
const latest = vals[vals.length - 1] || 0;
const best = Math.max(...vals);
const worst = Math.min(...vals);
return `<div class="chart-canvas-wrap">
<svg class="chart-svg" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">
<defs>
<linearGradient id="equityGradient-${chartId}" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="rgba(255,107,53,0.65)" />
<stop offset="100%" stop-color="rgba(255,107,53,0.02)" />
</linearGradient>
</defs>
<line class="chart-axis" x1="${padX}" y1="${baseY.toFixed(1)}" x2="${width - padX}" y2="${baseY.toFixed(1)}"></line>
<path d="${area}" fill="url(#equityGradient-${chartId})" class="chart-area"></path>
<path d="${line}" class="chart-line"></path>
${coords.map(p => `<circle class="chart-dot" cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="2.5"></circle>`).join('')}
</svg>
<div class="chart-labels">
<span>${series[0].time || '--'}</span>
<span>${series[series.length - 1].time || '--'}</span>
</div>
<div class="chart-summary-grid">
<div class="chart-summary-item"><div class="k">最新均收益</div><div class="v ${latest >= 0 ? 'green' : 'red'}">${fmtPct(latest)}</div></div>
<div class="chart-summary-item"><div class="k">区间最高</div><div class="v green">${fmtPct(best)}</div></div>
<div class="chart-summary-item"><div class="k">区间最低</div><div class="v ${worst >= 0 ? 'yellow' : 'red'}">${fmtPct(worst)}</div></div>
</div>
</div>`;
}
function renderAnalytics(stats) {
const curve = stats.equity_curve || {};
const lifecycle = stats.lifecycle_summary || {};
const defs = stats.lifecycle_definition || {};
const context = stats.market_context_overview || {};
const points24h = curve.last_24h || [];
const points7d = curve.last_7d || [];
const activePoints = points24h.length ? points24h : points7d;
const activeId = points24h.length ? '24h' : '7d';
const hotSectorTags = (context.top_hot_sectors || []).map(item => `<span class="context-tag">${item.sector} × ${item.count}</span>`).join('');
return `<div class="analytics-section">
<div class="analytics-title">📈 市场上下文</div>
<div class="analytics-subtitle">复盘页只保留对下一轮交易有直接帮助的环境摘要;组合净值和生命周期详情已移出顶部模块</div>
<div class="review-context-strip">
<div class="review-context-card">
<div class="sub-title">市场/衍生品/板块上下文</div>
<div class="review-context-grid">
<div class="review-context-metric"><div class="k">可操作样本</div><div class="v blue">${context.actionable_sample_count || 0}</div></div>
<div class="review-context-metric"><div class="k">1h量能均值</div><div class="v ${valueTone(context.avg_turnover_acceleration_1h)}">${fmtRatio(context.avg_turnover_acceleration_1h)}</div></div>
<div class="review-context-metric"><div class="k">4h量能均值</div><div class="v ${valueTone(context.avg_turnover_acceleration_4h)}">${fmtRatio(context.avg_turnover_acceleration_4h)}</div></div>
<div class="review-context-metric"><div class="k">平均Funding</div><div class="v ${valueTone(context.avg_funding_rate)}">${fmtSignedMaybePct((context.avg_funding_rate || 0) * 100, 3)}</div></div>
<div class="review-context-metric"><div class="k">大户多头均值</div><div class="v ${valueTone((context.avg_top_trader_long_pct || 0) - 50)}">${fmtSignedMaybePct(context.avg_top_trader_long_pct, 1)}</div></div>
<div class="review-context-metric"><div class="k">多空比均值</div><div class="v ${valueTone((context.avg_top_trader_long_short_ratio || 0) - 1)}">${fmtRatio(context.avg_top_trader_long_short_ratio)}</div></div>
</div>
<div class="review-context-hint">这块回答的是:当前推荐池所处的整体交易环境偏不偏热、杠杆情绪有没有抬升、热点是否集中在少数板块。</div>
</div>
<div class="review-context-card" id="reviewContextStrip">
<div class="sub-title">热点板块分布</div>
<div class="review-context-grid">
<div class="review-context-metric"><div class="k">热点板块数</div><div class="v yellow">${context.hot_sector_count || 0}</div></div>
<div class="review-context-metric"><div class="k">平均24h成交额</div><div class="v green">${context.avg_volume_24h ? fmtCompactNumber(context.avg_volume_24h) : '--'}</div></div>
</div>
<div class="review-context-tags">${hotSectorTags || '<span class="context-tag">暂无热点板块聚集</span>'}</div>
<div class="review-context-hint">如果热点板块高度集中,说明最近推荐更容易受单一叙事驱动;如果分散,说明市场更偏轮动,执行上要更重视入场位置和量能确认。</div>
</div>
</div>
</div>`;
}
function toggleEquityRange(range) {
const wrap = document.getElementById('equityChartWrap');
if (!wrap) return;
const points24 = JSON.parse(wrap.dataset.chart24 || '[]');
const points7 = JSON.parse(wrap.dataset.chart7 || '[]');
const target = range === '7d' ? points7 : points24;
wrap.dataset.active = range;
wrap.innerHTML = renderEquityChart(target, range);
const btns = document.querySelectorAll('.chart-switcher button');
btns.forEach((btn, idx) => {
const isActive = (range === '24h' && idx === 0) || (range === '7d' && idx === 1);
btn.classList.toggle('active', isActive);
if (idx === 0) {
btn.textContent = range === '24h' ? '近24小时' : '切换近24小时';
btn.setAttribute('onclick', "toggleEquityRange('24h')");
}
if (idx === 1) {
btn.textContent = range === '7d' ? '近7天' : '切换近7天';
btn.setAttribute('onclick', "toggleEquityRange('7d')");
}
});
}
function renderStats(s) {
const live = s.live_overview || {
actionable_count: s.active_count ?? 0,
actionable_pnl_sum: s.active_pnl_sum ?? 0,
actionable_avg_pnl: s.active_avg_pnl ?? 0,
actionable_success_count: s.active_success_count ?? 0,
actionable_failed_count: s.active_failed_count ?? 0,
actionable_pending_count: s.active_pending_count ?? 0,
raw_active_count: s.raw_active_count ?? 0,
};
const history = s.history_overview || {
success_count: s.success_count ?? 0,
failed_count: s.failed_count ?? 0,
recommendation_success_rate: s.recommendation_success_rate ?? 0,
avg_pnl_pct: s.avg_pnl_pct ?? 0,
};
const context = s.market_context_overview || {};
const topHot = (context.top_hot_sectors || [])[0];
const executedCount = live.executed_trade_count ?? live.held_count ?? 0;
const executedAvg = live.executed_avg_pnl ?? live.held_pnl_avg ?? 0;
const items = [
{label:'🟢 现可买', value:live.buy_now_count ?? 0, cls:'green'},
{label:'🟡 入场计划', value:live.wait_pullback_count ?? 0, cls:'yellow'},
{label:'👀 观察信号', value:live.observe_count ?? 0, cls:'blue'},
{label:'📊 全量跟踪', value:live.raw_active_count ?? 0, cls:'purple'},
{label:'📈 已执行交易', value:executedCount, cls:'green'},
{label:'📊 已执行均收益', value:fmtPct(executedAvg || 0), cls:(executedAvg||0)>=0?'green':'red'},
{label:'历史·止盈成功', value:history.success_count ?? 0, cls:'green'},
{label:'历史·止损失败', value:history.failed_count ?? 0, cls:'red'},
{label:'历史·成功率', value:(history.recommendation_success_rate ?? 0)+'%', cls:(history.recommendation_success_rate??0)>=50?'green':'red'},
{label:'历史·兑现均盈亏', value:fmtPct(history.avg_pnl_pct || 0), cls:(history.avg_pnl_pct||0)>=0?'green':'red'},
];
$('#statsRow').innerHTML = items.map(i =>
`<div class="stat-chip"><span class="s-label">${i.label}</span><span class="s-value ${i.cls}">${i.value}</span></div>`
).join('');
}
// === Render Recommendation Card ===
function renderRec(r) {
const ac = accentClass(r.status||r.rec_state);
const pnl = r.pnl_pct||0;
const maxPnl = r.max_pnl_pct||0;
const maxDD = r.max_drawdown_pct||0;
const current = r.current_price||r.entry_price;
const isExecutedTrade = ['buy_now','completed'].includes(r.execution_status || '') || ['可即刻买入','止盈1','止盈2','跟踪止盈','止损'].includes(r.action_status || '') || ['hit_tp1','hit_tp2','stopped_out'].includes(r.status || '');
const pnlCls = pnl>0.5?'up':pnl<-0.5?'down':'flat';
const base = r.symbol.replace('/USDT','');
const sigs = parseSignals(r.signals);
const ep = r.entry_plan || (typeof r.entry_plan_json==='string'?JSON.parse(r.entry_plan_json||'{}'):(r.entry_plan_json||{}));
const actionStatus = r.action_status || (ep.entry_action || '持有');
const initialAction = r.initial_action || ep.entry_action || actionStatus;
const ae = actionEmoji(actionStatus);
const resultKey = r.recommendation_result || 'pending';
const resultLabel = r.recommendation_result_label || '⏳ 观察中';
const executionStatus = r.execution_status || 'observe';
const executionLabel = r.execution_label || '👀 继续观察';
const executionReason = r.execution_reason || '当前暂无明确入场或失效结论,继续观察价格行为确认';
const showActionIndicator = actionStatus && !['持有','观察中','继续观察'].includes(actionStatus) && actionStatus !== executionLabel;
const forceReason = (r.force_reason || '').trim();
const baseState = (r.base_state || '').trim();
const sectorSignalCount = Number(r.sector_signal_count || 0);
const metaChips = [];
if (r.strategy_version_label) {
metaChips.push(`<span class="execution-meta-chip version">🏷️ ${r.strategy_version_label}</span>`);
}
if (forceReason === '静K蓄力旁路') {
metaChips.push('<span class="execution-meta-chip bypass">🟣 静K旁路入池</span>');
} else if (forceReason === '纯板块联动降级') {
metaChips.push('<span class="execution-meta-chip degrade">🟠 纯板块联动降级</span>');
}
if (baseState && forceReason) {
metaChips.push(`<span class="execution-meta-chip neutral">初判 ${baseState} → 当前 ${r.rec_state || r.status || executionStatus}</span>`);
}
if (sectorSignalCount > 0) {
metaChips.push(`<span class="execution-meta-chip neutral">板块信号 ${sectorSignalCount} 条</span>`);
}
const metaHtml = metaChips.length ? `<div class="execution-meta">${metaChips.join('')}</div>` : '';
const executionTitleMap = {
buy_now: '现在可执行',
wait_pullback: '等待更优回踩',
observe: '继续观察确认',
invalid: '本次机会已失效',
completed: '本轮策略已兑现',
};
const executionSubtitleMap = {
buy_now: '逻辑仍成立,当前价位允许按计划执行。',
wait_pullback: '方向没坏,但不建议在当前位置追价。',
observe: '暂未出现明确入场或失效信号,先等待新确认。',
invalid: '原始逻辑已被破坏,当前不建议继续追踪执行。',
completed: '止盈或目标已兑现,后续只做复盘参考。',
};
const executionTitle = executionTitleMap[executionStatus] || executionLabel;
const executionSubtitle = executionSubtitleMap[executionStatus] || executionReason;
// PnL bar
const barPct = Math.min(Math.abs(pnl),30)/30*100;
const barDir = pnl>=0?'up':'down';
// Action status indicator
const actionLabelMap = {
'可即刻买入':'🟢 即刻入场','等回踩':'🟡 等回踩确认','放弃':'🔴 暂不入场',
'衰减':'⚠️ 趋势衰减','止损':'🔴 已止损','止盈1':'✅ 止盈1达标',
'止盈2':'✅ 止盈2达标','跟踪止盈':'✅ 超额止盈','反转':'🔴 趋势反转',
'持有':'持有中','🟢即刻买入':'🟢 即刻入场','🟡等回踩':'🟡 等回踩确认',
};
const actionLabel = actionLabelMap[actionStatus] || actionStatus;
// 执行结论已覆盖时不再重复显示 action/result 标签
const hideExtra = executionStatus === 'buy_now' || executionStatus === 'invalid' || executionStatus === 'completed';
const actionHtml = (!hideExtra && showActionIndicator) ? `<span class="action-indicator action-${ae}">${actionLabel}</span>` : '';
const resultHtml = (!hideExtra && !(executionStatus === 'observe' && resultKey === 'pending'))
? `<span class="result-indicator result-${resultKey}">${resultLabel}</span>`
: '';
// 首次建议与当前决策不同时才显示,且当前不是“可即刻买入”(避免与“现在可买”重复混淆)
const showInitial = initialAction && initialAction !== actionStatus && initialAction !== '持有' && actionStatus !== '可即刻买入';
const initialTag = showInitial ? `<span class="execution-chip execution-plan-chip">首次建议:${initialAction}</span>` : '';
const executionHtml = `
<div class="execution-panel">
<div class="execution-topline">
<span class="execution-chip execution-status-chip execution-${executionStatus}">${executionLabel}</span>
<span class="execution-summary-text">${executionReason}</span>
${initialTag}
${(actionHtml || resultHtml) ? `${actionHtml}${resultHtml}` : ''}
</div>
${metaHtml}
</div>`;
// Sell/Buy signals (v11: 过滤滞后指标)
const sellSigs = parseSignals(r.sell_signals || []).filter(s => !/MACD|RSI/.test(s));
const buySigs = parseSignals(r.buy_signals || []).filter(s => !/MACD|RSI/.test(s));
const sellHtml = sellSigs.map(s => `<span class="sell-signal">${s}</span>`).join('');
const buyHtml = buySigs.map(s => `<span class="buy-signal">${s}</span>`).join('');
const priceLabelMap = {
buy_now: '执行入场价',
wait_pullback: '计划生成价',
observe: '发现价',
invalid: '发现价',
completed: '执行入场价',
};
const primaryPriceLabel = priceLabelMap[executionStatus] || '发现价';
const pnlBlock = isExecutedTrade
? `<div class="price-item">
<span class="price-label">已执行收益</span>
<span class="pnl-pill ${pnlCls}">${fmtPct(pnl)}</span>
</div>`
: `<div class="price-item">
<span class="price-label">收益口径</span>
<span class="price-val-sm muted">未执行不计算</span>
</div>`;
let priceHtml = `
<div class="price-row-two">
<div class="price-item">
<span class="price-label">${primaryPriceLabel}</span>
<span class="price-val-sm">${fmtP(r.entry_price)}</span>
</div>
<div class="price-item">
<span class="price-label">当前价</span>
<span class="price-val-sm">${fmtP(current)}</span>
</div>
${pnlBlock}
</div>
${isExecutedTrade ? `<div class="pnl-bar"><div class="pnl-bar-fill ${barDir}" style="width:${barPct}%"></div></div>` : `<div class="pnl-note">计划/观察未触发入场前,不按发现价计算推荐收益。</div>`}`;
let maxInfo = '';
if(isExecutedTrade && (maxPnl!==0||maxDD!==0)) {
maxInfo = `<div class="entry-stats">
<span class="es-item">最大浮盈 <b class="green">${fmtPct(maxPnl)}</b></span>
<span class="es-item">最大回撤 <b class="red">${fmtPct(maxDD)}</b></span>
</div>`;
}
let entryHtml = '';
if(ep && ep.entry_price) {
const rrOk = ep.risk_reward_ok ? '' : '';
entryHtml = `
<div class="entry-section">
<button class="entry-toggle" onclick="toggleEntry(this)">
<span class="arrow">▸</span> 入场方案
</button>
<div class="entry-body">
<div class="entry-grid">
<div class="entry-item"><div class="ei-label">入场</div><div class="ei-value accent">${fmtP(ep.entry_price)}</div></div>
<div class="entry-item"><div class="ei-label">方式</div><div class="ei-value">${ep.entry_method||'回踩确认'}</div></div>
<div class="entry-item"><div class="ei-label">止损</div><div class="ei-value red">${fmtP(ep.stop_loss)} <small>(${ep.stop_pct}%)</small></div></div>
<div class="entry-item"><div class="ei-label">止盈1</div><div class="ei-value green">${fmtP(ep.tp1)} <small>RR=${ep.rr1}</small></div></div>
<div class="entry-item"><div class="ei-label">止盈2</div><div class="ei-value green">${fmtP(ep.tp2)} <small>RR=${ep.rr2}</small></div></div>
<div class="entry-item"><div class="ei-label">RR达标</div><div class="ei-value">${rrOk}</div></div>
</div>
</div>
</div>`;
}
const sigHtml = sigs.map((s,i) => {
const cls = sigClass(s);
const emoji = sigEmoji(s);
if (!cls && /MACD|RSI|均线/.test(s)) return '';
return `<span class="sig ${cls||''} ${i===0?'highlight':''}">${emoji} ${s}</span>`;
}).filter(Boolean).join('');
const sectorTag = r.sector ? `<span class="coin-sector">${r.sector}</span>` : '';
const chartEntryPrice = isExecutedTrade ? (r.entry_price || '') : '';
const chartStopLoss = (r.stop_loss && r.stop_loss > 0) ? r.stop_loss : '';
const chartTp1 = (r.tp1 && r.tp1 > 0) ? r.tp1 : '';
const klineHtml = `<div class="kline-container loading" data-symbol="${r.symbol}" data-entry-price="${chartEntryPrice}" data-stop-loss="${chartStopLoss}" data-tp1="${chartTp1}"><div class="chart-loading">⏳ 加载K线...</div></div>`;
return `
<div class="card accent-${ac}">
<div class="card-top">
<div class="coin-info">
<div class="coin-icon">${coinInitials(r.symbol)}</div>
<div>
<span class="coin-name">${base}</span>${sectorTag}
</div>
</div>
<div style="display:flex;align-items:center;gap:8px">
${scoreRing(r.rec_score)}
<span class="badge badge-${badgeClass(r.status||r.rec_state)}">${badgeLabel(r.status||r.rec_state)}</span>
</div>
</div>
${klineHtml}
${executionHtml}
<div class="price-area">
${priceHtml}
</div>
<div class="meta-row">
<span>推荐 ${fmtTime(r.rec_time)}</span>
<span>跟踪 ${fmtTime(r.last_track_time)}</span>
</div>
${maxInfo}
${sigHtml?`<div class="signals">${sigHtml}</div>`:''}
${sellHtml?`<div class="signals" style="margin-top:4px">${sellHtml}</div>`:''}
${buyHtml?`<div class="signals" style="margin-top:4px">${buyHtml}</div>`:''}
${entryHtml}
</div>`;
}
// === Render Screening Card ===
function renderScreen(item) {
const ac = accentClass(item.state);
const base = item.symbol.replace('/USDT','');
const sigs = parseSignals(item.signals);
const sigHtml = sigs.map((s,i) => {
const cls = sigClass(s);
const emoji = sigEmoji(s);
if (!cls && /MACD|RSI|均线/.test(s)) return '';
return `<span class="sig ${cls||''} ${i===0?'highlight':''}">${emoji} ${s}</span>`;
}).filter(Boolean).join('');
const changeCls = (item.change_24h||0)>=0?'up':'down';
const sectorTag = item.sector ? `<span class="coin-sector">${item.sector}</span>` : '';
const pseudoRec = {
market_context: {
volume_24h: item.detail_json?.volume_24h || item.volume_24h || 0,
turnover_acceleration_1h: item.detail_json?.turnover_acceleration_1h || item.turnover_acceleration_1h || 0,
turnover_acceleration_4h: item.detail_json?.turnover_acceleration_4h || item.turnover_acceleration_4h || 0,
change_24h: item.change_24h || 0,
},
derivatives_context: {
funding_rate: item.funding_rate || 0,
open_interest_change_24h: item.detail_json?.open_interest_change_24h || item.open_interest_change_24h || 0,
top_trader_long_pct: item.detail_json?.top_trader_long_pct || item.top_trader_long_pct || 0,
top_trader_long_short_ratio: item.detail_json?.top_trader_long_short_ratio || item.top_trader_long_short_ratio || 0,
},
sector_context: {
sectors: item.sector ? String(item.sector).split(/[,/]/).map(x => x.trim()).filter(Boolean) : [],
hot_sectors: item.detail_json?.hot_sectors || [],
leader_symbol: item.detail_json?.leader_symbol || '',
leader_move_pct: item.detail_json?.leader_move_pct || 0,
}
};
const contextHtml = renderContextStrip(pseudoRec);
return `
<div class="card accent-${ac}">
<div class="card-top">
<div class="coin-info">
<div class="coin-icon">${coinInitials(item.symbol)}</div>
<div>
<span class="coin-name">${base}</span>${sectorTag}
</div>
</div>
<div style="display:flex;align-items:center;gap:8px">
${scoreRing(item.score)}
<span class="badge badge-${badgeClass(item.state)}">${badgeLabel(item.state)}</span>
</div>
</div>
<div class="price-area">
<div class="price-main">
<span class="price-val">${fmtP(item.price)}</span>
<span class="change-pill ${changeCls}">${fmtPct(item.change_24h)}</span>
</div>
</div>
<div class="meta-row">
<span>筛选 ${fmtTime(item.scan_time)}</span>
${item.funding_rate?`<span>Funding ${item.funding_rate.toFixed(4)}%</span>`:''}
</div>
${contextHtml}
${sigHtml?`<div class="signals">${sigHtml}</div>`:''}
</div>`;
}
// === Render Review Tab ===
function reviewBlock(kicker, title, desc, innerHtml) {
return `<div class="review-block">
<div class="review-block-head">
<div class="review-block-kicker">${kicker}</div>
<div class="review-block-title">${title}</div>
<div class="review-block-desc">${desc}</div>
</div>
<div class="review-divider"></div>
${innerHtml}
</div>`;
}
function renderIterationSection(title, cls, items) {
if (!items || !items.length) {
return `<div class="iteration-subcard ${cls}"><div class="sub-title">${title}</div><div class="iteration-list-item">暂无记录</div></div>`;
}
return `<div class="iteration-subcard ${cls}">
<div class="sub-title">${title}</div>
<div class="iteration-list">${items.map(item => `<div class="iteration-list-item">• ${item}</div>`).join('')}</div>
</div>`;
}
function renderChangedRulesSection(changedRules) {
if (!changedRules || !changedRules.length) {
return `<div class="iteration-subcard rules"><div class="sub-title">本轮改动规则</div><div class="iteration-list-item">本轮暂无显式规则改动,先记录结论继续积累样本。</div></div>`;
}
return `<div class="iteration-subcard rules">
<div class="sub-title">本轮改动规则</div>
<div class="iteration-list">${changedRules.map(rule => `<div class="iteration-list-item">• ${rule.field ? `${rule.field}: ` : ''}${rule.detail || rule.description || JSON.stringify(rule)}</div>`).join('')}</div>
</div>`;
}
function renderVersionStats(versionStats) {
if (!versionStats || !versionStats.length) {
return `<div class="iteration-subcard version-empty"><div class="sub-title">按策略版本效果对比</div><div class="iteration-list-item">暂无带策略版本号的推荐样本,后续有新版本推荐后这里会自动出现版本对比。</div></div>`;
}
return `<div class="version-stats-card">
<div class="sub-title">按策略版本效果对比</div>
<div class="version-stats-grid">${versionStats.map(item => {
const successRate = Number(item.success_rate_pct || 0).toFixed(1);
const avgPnl = Number(item.avg_pnl_pct || 0).toFixed(2);
return `<div class="version-stat-item">
<div class="version-stat-top">
<span class="version-chip">${item.strategy_version || '--'}</span>
<span class="version-count">样本 ${item.recommendation_count || 0}</span>
</div>
<div class="version-stat-metrics">
<div><span>成功</span><strong>${item.success_count || 0}</strong></div>
<div><span>失败</span><strong>${item.failed_count || 0}</strong></div>
<div><span>观察中</span><strong>${item.pending_count || 0}</strong></div>
</div>
<div class="version-stat-footer">
<span class="success-rate">成功率 ${successRate}%</span>
<span class="avg-pnl">平均PnL ${avgPnl}%</span>
</div>
</div>`;
}).join('')}</div>
</div>`;
}
function renderVersionChangelog(changelog) {
if (!changelog || !changelog.length) {
return `<div class="iteration-subcard version-empty"><div class="sub-title">版本升级说明</div><div class="iteration-list-item">暂无版本升级说明,后续复盘写入 strategy_version / version_change_summary 后这里会自动展示。</div></div>`;
}
return `<div class="version-changelog-card">
<div class="sub-title">版本升级说明</div>
<div class="version-changelog-list">${changelog.map(item => `
<div class="version-log-item">
<div class="version-log-top">
<span class="version-chip">${item.strategy_version || '--'}</span>
<span class="version-log-time">${fmtTime(item.created_at || item.run_date || '')}</span>
</div>
<div class="version-log-title">${item.title || '未命名迭代'}</div>
<div class="version-log-summary">${item.version_change_summary || item.summary || '本轮暂无显式版本改动说明。'}</div>
<div class="version-log-meta">
<span>规则改动 ${item.changed_rules_count || 0}</span>
<span>配置变更 ${item.config_change_count || 0}</span>
</div>
</div>
`).join('')}</div>
</div>`;
}
function renderIterationLogs(iterationLogs, iterationSummary) {
const effectOverview = iterationSummary.effect_overview || {};
const triggerPills = Object.entries(iterationSummary.trigger_counts || {}).map(([k, v]) => `<span class="iteration-pill">${k} × ${v}</span>`).join('');
const topProblems = iterationSummary.top_problems || [];
const versionStats = iterationSummary.version_stats || [];
const versionChangelog = iterationSummary.version_changelog || [];
let html = `<div class="review-header" style="margin-top:0">🧠 每日复盘与策略迭代日志</div>`;
html += `<div class="iteration-hero">
<div class="iteration-hero-card">
<div class="kicker">ITERATION STORY</div>
<div class="title">先看最近到底改了什么,再决定要不要深挖单条日志</div>
<div class="desc">这一块不是给你看流水账,而是让你快速判断:最近系统主要在修什么问题、有没有持续在迭代、改完以后效果有没有改善。</div>
${triggerPills ? `<div class="iteration-key-points">${triggerPills}</div>` : ''}
</div>
<div class="iteration-summary-panel">
<div class="kicker">IMPACT SNAPSHOT</div>
<div class="iteration-summary-grid">
<div class="iteration-stat"><div class="label">近30天日志数</div><div class="value blue">${iterationSummary.total_logs || 0}</div></div>
<div class="iteration-stat"><div class="label">发生迭代天数</div><div class="value green">${iterationSummary.unique_run_days || 0}</div></div>
<div class="iteration-stat"><div class="label">累计规则改动</div><div class="value yellow">${iterationSummary.change_rule_count || 0}</div></div>
<div class="iteration-stat"><div class="label">累计配置变更</div><div class="value purple">${iterationSummary.config_change_count || 0}</div></div>
</div>
<div class="desc">平均命中率 ${effectOverview.avg_hit_rate_pct || 0}% · 平均PnL ${effectOverview.avg_pnl || 0} · 效果样本日志 ${effectOverview.samples || 0}</div>
</div>
</div>`;
html += `<div class="iteration-version-grid">
${renderVersionStats(versionStats)}
${renderVersionChangelog(versionChangelog)}
</div>`;
if (topProblems.length) {
html += `<div class="iteration-problem-list">${topProblems.map(item => `
<div class="iteration-problem-item">
<div class="k">高频问题 × ${item.count}</div>
<div class="v">${item.problem}</div>
</div>
`).join('')}</div>`;
}
if (!iterationLogs.length) {
html += `<div class="empty-state"><div class="icon">🧠</div><div class="title">暂无迭代日志</div><div class="desc">等每日复盘 cron 跑完后,这里会自动显示“发现了什么问题、改了什么策略、影响了哪些币”。</div></div>`;
return html;
}
html += `<div class="iteration-log-list">`;
for (const log of iterationLogs) {
const metrics = log.metrics || {};
const relatedSymbols = log.related_symbols || [];
const metricHtml = Object.entries(metrics).map(([k, v]) => `<span class="iteration-metric">${k}: ${v}</span>`).join('');
const tagsHtml = relatedSymbols.map(symbol => `<span class="iteration-tag">${symbol}</span>`).join('');
html += `<div class="iteration-log-card">
<div class="iteration-log-top">
<div>
<div class="iteration-log-title">${log.title || '未命名迭代'}</div>
<div class="iteration-log-meta">
<span>版本 ${(log.strategy_version || '--')}</span>
<span>日期 ${log.run_date || '--'}</span>
<span>记录 ${fmtTime(log.created_at)}</span>
<span>来源 ${log.trigger_source || '--'}</span>
</div>
</div>
<span class="iteration-badge">迭代日志</span>
</div>
<div class="iteration-log-summary">${log.summary || '本轮未写总结。'}</div>
<div class="iteration-version-summary">${log.version_change_summary || '本轮暂无显式版本升级说明。'}</div>
<div class="iteration-section-grid">
${renderIterationSection('发现了什么', 'findings', log.findings || [])}
${renderIterationSection('发现的问题', 'problems', log.problems || [])}
${renderIterationSection('采取了什么动作', 'actions', log.actions || [])}
${renderChangedRulesSection(log.changed_rules || [])}
</div>
${metricHtml ? `<div class="iteration-metrics">${metricHtml}</div>` : ''}
<div class="iteration-subcard rules" style="margin-top:10px;">
<div class="sub-title">参数Diff审计</div>
${renderConfigDiffSection(log.config_diff || {})}
</div>
<div class="iteration-subcard findings" style="margin-top:10px;">
<div class="sub-title">迭代后效果回看</div>
${renderEffectSummary(log.effect_summary || {})}
</div>
<div class="iteration-subcard findings" style="margin-top:10px;">
<div class="sub-title">稳定币/法币污染巡检</div>
${renderPollutionSummary(log.pollution_summary || {})}
</div>
${tagsHtml ? `<div class="iteration-tags">${tagsHtml}</div>` : ''}
</div>`;
}
html += `</div>`;
return html;
}
function renderConfigDiffSection(configDiff) {
const groups = [
{ key: 'changed', title: '参数改动', cls: 'changed' },
{ key: 'added', title: '新增规则/字段', cls: 'added' },
{ key: 'removed', title: '移除项', cls: 'removed' },
];
return `<div class="iteration-diff-grid">${groups.map(group => {
const items = (configDiff && configDiff[group.key]) || [];
const body = items.length
? items.map(item => `<div class="iteration-diff-item"><span class="iteration-diff-path">${item.path}</span>${item.old !== undefined || item.new !== undefined ? `${item.old !== undefined ? JSON.stringify(item.old) : ''} → ${item.new !== undefined ? JSON.stringify(item.new) : ''}` : ''}</div>`).join('')
: '<div class="iteration-list-item">暂无</div>';
return `<div class="iteration-diff-card ${group.cls}"><div class="sub-title">${group.title}</div>${body}</div>`;
}).join('')}</div>`;
}
function renderEffectSummary(effect) {
if (!effect || !Object.keys(effect).length) {
return '<div class="iteration-list-item" style="margin-top:10px;">暂无迭代后效果回看数据</div>';
}
const stats = [
['观察窗口', `${effect.window_days || 0}天`],
['样本数', effect.review_count_window ?? 0],
['命中率', `${effect.hit_rate_pct ?? 0}%`],
['失败率', `${effect.fail_rate_pct ?? 0}%`],
['平均PnL', effect.avg_pnl ?? 0],
];
return `<div class="iteration-effect-grid">${stats.map(([label, value]) => `<div class="iteration-effect-stat"><div class="label">${label}</div><div class="value">${value}</div></div>`).join('')}</div>`;
}
function renderPollutionSummary(pollution) {
if (!pollution || !Object.keys(pollution).length) {
return '<div class="iteration-list-item" style="margin-top:10px;">暂无稳定币/法币污染巡检数据</div>';
}
const contaminatedCount = pollution.contaminated_symbol_count || 0;
const screeningCount = pollution.screening_hit_count || 0;
const recommendationCount = pollution.recommendation_hit_count || 0;
const statusLabel = contaminatedCount > 0 ? '⚠️ 发现污染' : '✅ 巡检通过';
const symbolList = (pollution.contaminated_symbols || []).slice(0, 8);
const layerList = Object.entries(pollution.layer_counts || {});
return `<div class="iteration-pollution-card">
<div class="iteration-effect-grid">
<div class="iteration-effect-stat"><div class="label">巡检状态</div><div class="value">${statusLabel}</div></div>
<div class="iteration-effect-stat"><div class="label">污染币对数</div><div class="value">${contaminatedCount}</div></div>
<div class="iteration-effect-stat"><div class="label">筛选命中</div><div class="value">${screeningCount}</div></div>
<div class="iteration-effect-stat"><div class="label">推荐命中</div><div class="value">${recommendationCount}</div></div>
</div>
<div class="iteration-list-item" style="margin-top:10px;">观察窗口 ${pollution.window_days || 0} 天 · 起算 ${fmtTime(pollution.effective_start || '')}</div>
${layerList.length ? `<div class="iteration-tags" style="margin-top:10px;">${layerList.map(([k, v]) => `<span class="iteration-tag">${k}: ${v}</span>`).join('')}</div>` : ''}
${symbolList.length ? `<div class="iteration-tags" style="margin-top:10px;">${symbolList.map(symbol => `<span class="iteration-tag">${symbol}</span>`).join('')}</div>` : '<div class="iteration-list-item" style="margin-top:10px;">窗口内未发现稳定币/法币污染币对。</div>'}
</div>`;
}
function renderReview(data, stats={}) {
const reviews = data.reviews || [];
const signalPerf = data.signal_performance || [];
const missed = data.missed_explosions || [];
const iterationLogs = data.iteration_logs || [];
const iterationSummary = data.iteration_summary || {};
const reviewStats = stats || {};
const reviewContext = reviewStats.market_context_overview || {};
let html = '<div class="review-section">';
const overviewHtml = `<div class="review-overview-stack">${renderLeaderboard(reviewStats)}${renderTierSummary(reviewStats)}<div class="definition-box">
<div class="definition-title">推荐成功/失败定义</div>
<div class="definition-list">
<div class="definition-item success"><div class="k">✅ 推荐成功</div><div class="v">${reviewStats.result_definition?.success || '--'}</div></div>
<div class="definition-item failed"><div class="k">❌ 推荐失败</div><div class="v">${reviewStats.result_definition?.failed || '--'}</div></div>
<div class="definition-item pending"><div class="k">⏳ 观察中</div><div class="v">${reviewStats.result_definition?.pending || '--'}</div></div>
</div>
</div><div class="review-context-strip" id="reviewContextStrip">
<div class="review-context-card">
<div class="sub-title">市场/衍生品/板块上下文</div>
<div class="review-context-grid">
<div class="review-context-metric"><div class="k">可操作样本</div><div class="v blue">${reviewContext.actionable_sample_count || 0}</div></div>
<div class="review-context-metric"><div class="k">1h量能均值</div><div class="v ${valueTone(reviewContext.avg_turnover_acceleration_1h)}">${fmtRatio(reviewContext.avg_turnover_acceleration_1h)}</div></div>
<div class="review-context-metric"><div class="k">4h量能均值</div><div class="v ${valueTone(reviewContext.avg_turnover_acceleration_4h)}">${fmtRatio(reviewContext.avg_turnover_acceleration_4h)}</div></div>
<div class="review-context-metric"><div class="k">平均Funding</div><div class="v ${valueTone(reviewContext.avg_funding_rate)}">${fmtSignedMaybePct((reviewContext.avg_funding_rate || 0) * 100, 3)}</div></div>
</div>
<div class="review-context-hint">复盘时先看这组环境指标,能快速判断最近命中率变化,究竟是策略本身的问题,还是市场热度/杠杆情绪/板块集中度发生了切换。</div>
</div>
<div class="review-context-card">
<div class="sub-title">热点板块分布</div>
<div class="review-context-tags">${(reviewContext.top_hot_sectors || []).map(item => `<span class="context-tag">${item.sector} × ${item.count}</span>`).join('') || '<span class="context-tag">暂无热点板块聚集</span>'}</div>
<div class="review-context-hint">如果一段时间内失败样本集中出现在“板块分散+Funding偏冷”的环境后续就要减少追逐广撒网式加速信号更多等回踩或等龙头确认。</div>
</div>
</div></div>`;
let resultSectionHtml = `<div class="review-header" style="margin-top:0">🎯 推荐质量复盘 (${reviews.length})</div>`;
if (reviews.length === 0) {
resultSectionHtml += `<div class="empty-state"><div class="icon">📋</div><div class="title">暂无复盘记录</div><div class="desc">推荐24h后自动复盘归因</div></div>`;
} else {
resultSectionHtml += `<div class="review-card-grid">`;
for (const rv of reviews.slice(0, 20)) {
const resultCls = rv.outcome==='爆发'?'hit':'miss';
const resultLabel = rv.outcome==='爆发'?'✅ 爆发命中':rv.outcome==='横盘'?'⏸️ 横盘':'❌ 失败';
const pnlVal = rv.pnl_48h !== undefined ? rv.pnl_48h : (rv.pnl_pct !== undefined ? rv.pnl_pct : 0);
const attribution = (rv.hit_signals||'') + (rv.lesson ? ' | 教训: '+rv.lesson : '');
resultSectionHtml += `<div class="review-card">
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap;">
<span class="rc-symbol">${rv.symbol}</span>
<span class="rc-result ${resultCls}">${resultLabel} ${fmtPct(pnlVal)}</span>
</div>
<div class="rc-attribution">${attribution}</div>
<div class="meta-row"><span>复盘 ${fmtTime(rv.review_time)}</span></div>
</div>`;
}
resultSectionHtml += `</div>`;
}
let optimizationHtml = `<div class="review-header" style="margin-top:0">💥 漏选爆发 (${missed.length})</div>`;
if (missed.length === 0) {
optimizationHtml += `<div class="empty-state"><div class="icon">🚀</div><div class="title">暂无漏选</div><div class="desc">扫描没选但后来爆发的币</div></div>`;
} else {
for (const ms of missed) {
optimizationHtml += `<div class="missed-card">
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap;">
<span class="mc-symbol">${ms.symbol}</span>
<span class="mc-gain">+${ms.gain_pct}%</span>
</div>
<div class="mc-reason">漏选原因: ${ms.reason_missed||'--'}</div>
${ms.lesson?`<div class="mc-lesson">💡 ${ms.lesson}</div>`:''}
<div class="meta-row"><span>发现 ${fmtTime(ms.detect_time)}</span></div>
</div>`;
}
}
optimizationHtml += `<div class="review-header" style="margin-top:20px">📊 信号权重 & 命中率</div>`;
if (signalPerf.length === 0) {
optimizationHtml += `<div class="empty-state"><div class="icon">📈</div><div class="title">尚未统计</div><div class="desc">复盘引擎运行后才会生成命中率数据</div></div>`;
} else {
optimizationHtml += `<table class="weight-table">
<tr><th>信号类型</th><th>分类</th><th>权重</th><th>命中率</th><th>样本</th><th>命中率可视化</th></tr>`;
for (const sp of signalPerf) {
const hitRate = sp.hit_rate || 0;
const barW = Math.min(hitRate, 100);
const barCls = hitRate>=50?'green':hitRate>=25?'yellow':'red';
optimizationHtml += `<tr>
<td class="w-name">${sp.signal_type}</td>
<td>${sp.category||'--'}</td>
<td class="w-val">${sp.weight}</td>
<td class="w-hit">${hitRate.toFixed(1)}%</td>
<td>${sp.total_count||0}</td>
<td><div class="w-bar"><div class="w-bar-fill ${barCls}" style="width:${barW}%"></div></div></td>
</tr>`;
}
optimizationHtml += '</table>';
}
html += `<div class="review-top-grid">
${reviewBlock('OVERVIEW','盘面结果总览','先看组合层面的强弱结构与结果定义,快速判断最近推荐池整体是强、弱、还是分化。', overviewHtml)}
${reviewBlock('RESULTS',`推荐质量复盘 (${reviews.length})`,'这一块只看结果:哪些推荐真正爆发,哪些横盘,哪些失败,用来回答“推荐质量到底行不行”。', resultSectionHtml)}
</div>`;
html += `<div class="review-mid-grid">
${reviewBlock('ITERATION','策略迭代轨迹','这里专门回答“最近改了什么”。先看最近发现的问题、采取的动作、参数改动和效果回看,再决定是否继续深挖单条迭代日志。', renderIterationLogs(iterationLogs, iterationSummary))}
${reviewBlock('OPTIMIZE','下一轮优化素材','这里专门回答“下一轮该怎么改”。左边看漏选爆发找盲区,右边看信号权重与命中率,决定哪些条件该增强、削弱或淘汰。', optimizationHtml)}
</div>`;
html += `<div class="review-bottom-grid">
${reviewBlock('GUIDE','复盘阅读顺序','建议按“结果总览 → 策略迭代 → 优化素材”的顺序阅读。先确认最近推荐表现,再看系统怎么改,最后看下一轮应该修哪里。', `<div class="definition-list" style="margin-bottom:0;">
<div class="definition-item pending"><div class="k">1. 结果总览</div><div class="v">先看组合层面的强弱、成功失败定义,以及最近推荐整体质量。</div></div>
<div class="definition-item pending"><div class="k">2. 策略迭代</div><div class="v">再看最近到底改了哪些规则、参数和过滤逻辑,避免只看结果不看过程。</div></div>
<div class="definition-item pending"><div class="k">3. 优化素材</div><div class="v">最后看漏选爆发和信号命中率,用于决定下一轮增强或淘汰哪些条件。</div></div>
</div>`)}
<div class="review-compact-block">${reviewBlock('WHY','为什么这样重排','复盘页不再把所有东西平铺在一起,而是按“先结果、再过程、后优化”的顺序组织。手机端单列阅读,电脑端双列并排,提高信息密度同时不打乱阅读顺序。','')}</div>
</div>`;
html += '</div>';
return html;
}
function renderCron(data) {
const summary = data.summary || {};
const logs = data.logs || [];
const jobs = summary.jobs || [];
let html = '<div class="cron-section">';
html += `<div class="cron-header">🧭 Cron 运行总览(近 ${summary.hours || 24} 小时)</div>`;
html += `<div class="cron-summary-grid">
<div class="cron-stat"><div class="label">总运行次数</div><div class="value blue">${summary.total_runs || 0}</div></div>
<div class="cron-stat"><div class="label">成功次数</div><div class="value green">${summary.success_runs || 0}</div></div>
<div class="cron-stat"><div class="label">失败次数</div><div class="value red">${summary.error_runs || 0}</div></div>
<div class="cron-stat"><div class="label">成功率</div><div class="value yellow">${(summary.success_rate || 0).toFixed(1)}%</div></div>
</div>`;
if (jobs.length) {
html += `<div class="cron-header">📦 分任务汇总</div>`;
html += `<div class="card-grid">${jobs.map(job => `
<div class="cron-stat">
<div class="label">${job.job_name || '--'}</div>
<div class="value ${job.error_runs ? 'yellow' : 'green'}">${job.total_runs || 0}</div>
<div class="cron-summary-tags">
<span class="cron-tag">成功 ${job.success_runs || 0}</span>
<span class="cron-tag">失败 ${job.error_runs || 0}</span>
<span class="cron-tag">均耗时 ${job.avg_duration_ms || 0}ms</span>
<span class="cron-tag">最近 ${fmtTime(job.last_run_at)}</span>
</div>
</div>
`).join('')}</div>`;
}
html += `<div class="cron-header" style="margin-top:20px">📝 最近运行日志 (${logs.length})</div>`;
if (!logs.length) {
html += `<div class="empty-state"><div class="icon">🕒</div><div class="title">暂无Cron日志</div><div class="desc">等下一次粗筛/确认/跟踪脚本运行后会自动出现</div></div>`;
} else {
const grouped = {};
for (const log of logs) {
const key = log.job_name || '未分类';
if (!grouped[key]) grouped[key] = [];
grouped[key].push(log);
}
for (const [jobName, items] of Object.entries(grouped)) {
html += `<div class="cron-group"><div class="cron-group-title">${jobName}</div>`;
for (const item of items) {
const badgeCls = item.run_status === 'success' ? 'success' : (item.run_status === 'error' ? 'error' : 'partial');
const summaryObj = typeof item.summary_json === 'string' ? JSON.parse(item.summary_json || '{}') : (item.summary_json || {});
const summaryTags = Object.entries(summaryObj).map(([k,v]) => `<span class="cron-tag">${k}: ${v}</span>`).join('');
html += `<div class="cron-run-card">
<div class="cron-run-top">
<div>
<div class="cron-run-title">${item.script_name || '--'} · ${item.result_status || '--'}</div>
<div class="cron-run-meta">
<span>开始 ${fmtTime(item.started_at)}</span>
<span>结束 ${fmtTime(item.finished_at)}</span>
<span>耗时 ${item.duration_ms || 0}ms</span>
</div>
</div>
<span class="cron-badge ${badgeCls}">${item.run_status || '--'}</span>
</div>
${summaryTags ? `<div class="cron-detail-tags">${summaryTags}</div>` : ''}
${item.error_message ? `<div class="cron-error">${item.error_message}</div>` : ''}
</div>`;
}
html += `</div>`;
}
}
html += '</div>';
return html;
}
function renderDecisionOverview(items, opts = {}) {
const groups = opts.groups || [];
const counts = {};
for (const item of items) {
const key = item.execution_status || 'observe';
counts[key] = (counts[key] || 0) + 1;
}
const chips = groups.map(group => ({
key: group.key,
label: group.title,
value: counts[group.key] || 0,
sub: group.desc,
anchor: opts.anchorPrefix ? `#${opts.anchorPrefix}-${group.key}` : ''
}));
const chipHtml = chips.map(chip => `
<div class="decision-chip k-${chip.key}">
<span class="label">${chip.label}</span>
<span class="value">${chip.value}</span>
<span class="sub">${chip.sub}</span>
</div>`).join('');
const navHtml = chips.map(chip => chip.anchor ? `
<a class="decision-nav-pill" href="${chip.anchor}">
<span>${chip.label}</span>
<span class="count">${chip.value}</span>
</a>` : '').join('');
return `<div class="decision-overview">
<div class="decision-overview-head">
<div class="decision-overview-kicker">${opts.kicker || 'DECISION'}</div>
<div class="decision-overview-title">${opts.title || '按执行状态读推荐池'}</div>
<div class="decision-overview-desc">${opts.desc || '先看当前到底有哪些币可以立刻做、哪些只适合等回踩、哪些还只能观察,再决定要不要往下看具体卡片。'}</div>
</div>
<div class="decision-chip-grid">${chipHtml}</div>
${navHtml ? `<div class="decision-chip-nav">${navHtml}</div>` : ''}
</div>`;
}
function renderHistorySummary(items, groups) {
const counts = {};
for (const item of items) {
const key = item.execution_status || 'observe';
counts[key] = (counts[key] || 0) + 1;
}
const summaryHtml = renderDecisionOverview(items, {
groups,
kicker: 'ARCHIVE',
title: '历史交易结果库',
desc: '历史页只保留真实兑现结果:哪些推荐最终止盈成功、哪些最终止损失败。这里不再混入筛选池、等待回踩或观察中样本。',
anchorPrefix: 'history-group'
});
const chips = [
{ key:'total', label:'历史总数', value:items.length, sub:'近50条推荐的整体样本' },
...groups.map(group => ({
key:group.key,
label:group.title,
value:counts[group.key] || 0,
sub:group.desc,
anchor:`#history-group-${group.key}`
}))
];
const chipHtml = chips.map(chip => {
const tagClass = `history-chip k-${chip.key}`;
const inner = `<span class="label">${chip.label}</span><span class="value">${chip.value}</span><span class="sub">${chip.sub}</span>`;
return chip.anchor
? `<a class="${tagClass}" href="${chip.anchor}">${inner}</a>`
: `<div class="${tagClass}">${inner}</div>`;
}).join('');
const navHtml = groups.map(group => `
<a class="history-nav-pill" href="#history-group-${group.key}">
<span>${group.title}</span>
<span class="count">${counts[group.key] || 0}</span>
</a>`).join('');
return `${summaryHtml}<div class="history-summary">
<div class="history-summary-head">
<div class="history-summary-kicker">History Overview</div>
<div class="history-summary-title">历史交易结果导航</div>
<div class="history-summary-desc">这里只保留两类真实交易结果:止盈成功与止损失败。点击下方导航可直接跳转查看案例。 </div>
</div>
<div class="history-chip-grid">${chipHtml}</div>
<div class="history-chip-nav">${navHtml}</div>
</div>`;
}
function renderRecommendationGroups(items, opts = {}) {
const sectionClass = opts.sectionClass || 'rec-groups';
const emptyHtml = opts.emptyHtml || '';
const showSummary = !!opts.showSummary;
const showDecisionOverview = !!opts.showDecisionOverview;
const groupIdPrefix = opts.groupIdPrefix || 'history-group';
const overviewHtml = opts.overviewHtml || '';
const groups = [
...(opts.groups || [
{ key:'buy_now', title:'🟢 现在可买', desc:'首次建议可执行,且当前仍未失效', empty:'' },
{ key:'wait_pullback', title:'🟡 等回踩', desc:'逻辑还在,但不建议追高', empty:'' },
{ key:'observe', title:'👀 继续观察', desc:'尚未到明确执行点,继续等确认', empty:'' },
{ key:'invalid', title:'🔴 止损失败', desc:'真实触发止损的失败样本,只做复盘与避坑参考', empty:'' },
{ key:'completed', title:'✅ 止盈成功', desc:'真实达到止盈目标的成功样本,只做复盘参考', empty:'' },
]),
];
const blocks = groups.map(group => {
const rows = items.filter(x => (x.execution_status || 'observe') === group.key);
if (!rows.length) return '';
return `<div class="rec-group-block" id="${groupIdPrefix}-${group.key}">
<div class="rec-group-head">
<div>
<div class="rec-group-title">${group.title}</div>
<div class="rec-group-desc">${group.desc}</div>
</div>
<div class="rec-group-count">${rows.length} 条</div>
</div>
<div class="card-grid">${rows.map(renderRec).join('')}</div>
</div>`;
}).filter(Boolean).join('');
const summaryHtml = showSummary ? renderHistorySummary(items, groups) : '';
const decisionHtml = showDecisionOverview ? renderDecisionOverview(items, {
groups,
kicker: 'LIVE DECISIONS',
title: '实时推荐:先看现在能不能做',
desc: '实时页只回答一个问题:此刻哪些币可以马上执行,哪些只适合等回踩,哪些先别动。先定动作,再展开单卡。',
anchorPrefix: groupIdPrefix
}) : '';
return blocks ? `${overviewHtml}${decisionHtml}${summaryHtml}<div class="${sectionClass}">${blocks}</div>` : emptyHtml;
}
// === Toggle Entry Plan ===
function toggleEntry(btn) {
btn.classList.toggle('open');
const body = btn.nextElementSibling;
body.classList.toggle('show');
}
// === Tab Switch ===
function switchTab(tab, el) {
curTab = tab;
$('#versionFilterBar').style.display = (tab==='active' || tab==='watch') ? 'flex' : 'none';
$$('.tab-btn').forEach(b=>b.classList.remove('active'));
if(el) el.classList.add('active');
// 切到 active/watch tab 时重新加载版本列表(确保拿到最新版本)
if (tab === 'active' || tab === 'watch') {
loadVersions().then(() => loadContent());
} else {
loadContent();
}
}
// === Load Content ===
async function loadContent() {
const c = $('#cardContainer');
c.innerHTML = '<div class="card-grid"><div class="skeleton"></div><div class="skeleton"></div></div>';
try {
let url, renderer;
if(curTab==='active') {
const verParam = curVersion ? `?version=${encodeURIComponent(curVersion)}` : '';
const [statsResp, activeResp] = await Promise.all([
fetch(API+'/api/stats'),
fetch(API+'/api/recommendations/active'+verParam)
]);
const stats = await statsResp.json();
const data = await activeResp.json();
if (Array.isArray(data)) updateVersionCount(data);
const rawActive = stats.raw_active_count ?? (stats.live_overview?.raw_active_count ?? 0);
const observeCount = stats.live_overview?.observe_count ?? 0;
const headHtml = `<div class="decision-archive-note"><div class="title">实时推荐页现在只做“可执行决策”</div><div class="desc">默认只展示“现在可买 / 等回踩”。继续观察候选已移到“观察池”,不再污染实时推荐;当前全量跟踪 ${rawActive} 只,其中观察池 ${observeCount} 只。</div></div>`;
if(!data.length) {
c.innerHTML = `${headHtml}<div class="empty-state"><div class="icon">🔍</div><div class="title">当前没有可执行机会</div><div class="desc">没有“现在可买/等回踩”的币。观察池里的候选仅用于跟踪,不构成入场建议。</div></div>`;
setTimeout(loadKlineCharts, 300);
return;
}
c.innerHTML = renderRecommendationGroups(data, {
overviewHtml: headHtml,
showDecisionOverview: true,
groupIdPrefix: 'active-group',
groups: [
{ key:'buy_now', title:'🟢 现在可买', desc:'当前已到可执行入场点,仍需按卡片止损计划控制仓位', empty:'' },
{ key:'wait_pullback', title:'🟡 等回踩', desc:'逻辑还在,但不建议追高,等价格回到计划区间', empty:'' },
]
});
setTimeout(loadKlineCharts, 300);
return;
}
else if(curTab==='watch') {
const verParam = curVersion ? `&version=${encodeURIComponent(curVersion)}` : '';
const [statsResp, activeResp] = await Promise.all([
fetch(API+'/api/stats'),
fetch(API+'/api/recommendations/active?actionable_only=false'+verParam)
]);
const stats = await statsResp.json();
const allData = await activeResp.json();
const data = Array.isArray(allData) ? allData.filter(x => (x.execution_status || 'observe') === 'observe') : [];
if (Array.isArray(allData)) updateVersionCount(data);
const rawActive = stats.raw_active_count ?? (stats.live_overview?.raw_active_count ?? 0);
const headHtml = `<div class="decision-archive-note"><div class="title">观察池:只跟踪,不下单</div><div class="desc">这里存放“继续观察”的候选,便于后续确认是否升级为可执行信号。它们不会进入实时推荐,也不会触发飞书买入提醒;当前全量跟踪 ${rawActive} 只,观察池 ${data.length} 只。</div></div>`;
if(!data.length) {
c.innerHTML = `${headHtml}<div class="empty-state"><div class="icon">👀</div><div class="title">观察池为空</div><div class="desc">当前没有仅观察候选;实时页只保留可执行机会。</div></div>`;
setTimeout(loadKlineCharts, 300);
return;
}
c.innerHTML = renderRecommendationGroups(data, {
overviewHtml: headHtml,
showDecisionOverview: false,
groupIdPrefix: 'watch-group',
groups: [
{ key:'observe', title:'👀 继续观察', desc:'尚未到明确执行点,等待确认/回踩/放量,不构成入场建议', empty:'' },
]
});
setTimeout(loadKlineCharts, 300);
return;
}
else if(curTab==='all') {
const resp = await fetch(API+'/api/recommendations?limit=50&decision_only=true');
const data = await resp.json();
if(!data.length) {
c.innerHTML = `<div class="empty-state"><div class="icon">🔍</div><div class="title">空空如也</div><div class="desc">暂无历史推荐记录</div></div>`;
return;
}
c.innerHTML = renderRecommendationGroups(data, {
sectionClass: 'history-groups',
showSummary: true,
groupIdPrefix: 'history-group',
groups: [
{ key:'completed', title:'✅ 止盈成功', desc:'真实达到止盈目标的成功样本,只做复盘参考' },
{ key:'invalid', title:'🔴 止损失败', desc:'真实触发止损的失败样本,只做复盘与避坑参考' },
],
emptyHtml: `<div class="empty-state"><div class="icon">🔍</div><div class="title">空空如也</div><div class="desc">暂无历史推荐记录</div></div>`
});
setTimeout(loadKlineCharts, 300);
return;
}
else if(curTab==='screening') { url=API+'/api/screening?hours=24&limit=50'; renderer=renderScreen; }
else if(curTab==='review') {
const [statsResp, reviewResp] = await Promise.all([
fetch(API+'/api/stats'),
fetch(API+'/api/review')
]);
const stats = await statsResp.json();
const data = await reviewResp.json();
c.innerHTML = renderReview(data, stats);
return;
} else if(curTab==='cron') {
const [summaryResp, logsResp] = await Promise.all([
fetch(API+'/api/cron/summary?hours=24'),
fetch(API+'/api/cron?limit=60')
]);
const summary = await summaryResp.json();
const logs = await logsResp.json();
c.innerHTML = renderCron({summary, logs});
return;
} else if(curTab==='sentiment') {
const resp = await fetch(API+'/api/sentiment?hours=6');
const data = await resp.json();
c.innerHTML = renderSentiment(data);
return;
}
const resp = await fetch(url);
const data = await resp.json();
if(!data.length) {
const hints = {active:'暂无进行中的推荐',all:'暂无历史推荐记录',screening:'暂无扫描记录',review:'暂无复盘数据',cron:'暂无Cron日志'};
c.innerHTML = `<div class="empty-state"><div class="icon">🔍</div><div class="title">空空如也</div><div class="desc">${hints[curTab]}</div></div>`;
return;
}
c.innerHTML = `<div class="card-grid">${data.map(renderer).join('')}</div>`;
} catch(e) {
c.innerHTML = `<div class="empty-state"><div class="icon">⚠️</div><div class="title">加载失败</div><div class="desc">${e.message}</div></div>`;
}
// 异步加载日K线图仅当页面有 .kline-container 时生效)
setTimeout(loadKlineCharts, 200);
}
// === Sentiment Render ===
function renderSentiment(data) {
const events = data.events || [];
const check_time = data.check_time;
const totalEvents = data.total_events ?? events.length;
const overlap_active = data.overlap_active || 0;
const overlap_screened = data.overlap_screened || 0;
if (!events.length) {
return `<div class="empty-state"><div class="icon">📭</div><div class="title">暂无舆情数据</div><div class="desc">等待事件驱动舆情或 sentiment_monitor 采集第一轮数据</div></div>`;
}
const timeStr = check_time ? new Date(check_time).toLocaleString('zh-CN') : '--';
const decisionLabel = d => ({recommend:'🚨 可交易机会', observe:'👀 等待确认', risk:'⚠️ 风险/不追', ignore:'忽略'}[d] || '');
const sourceIcon = s => /binance/i.test(s||'') ? '🟡' : /coingecko/i.test(s||'') ? '🔥' : '📰';
const eventCards = events.map(e => {
const level = e.importance || 'B';
const title = e.title || '未命名舆情';
const eventUrl = e.url || '';
const titleHtml = eventUrl
? `<a class="sentiment-event-title" href="${eventUrl}" target="_blank" rel="noopener">${title}</a>`
: `<div class="sentiment-event-title">${title}</div>`;
const published = e.published_at || e.detected_at;
const changePct = Number(e.change_24h_pct || 0);
const changeCls = changePct > 5 ? 'change-strong-up' : changePct > 0 ? 'change-up' : changePct < 0 ? 'change-down' : 'change-flat';
const changeText = changePct ? `${changePct >= 0 ? '+' : ''}${changePct.toFixed(1)}%` : '--';
const price = Number(e.price_usd || 0);
const priceText = price ? (price >= 1 ? '$'+price.toFixed(2) : '$'+price.toFixed(6)) : '--';
const mcap = e.market_cap_rank ? `MCap #${e.market_cap_rank}` : '';
const trend = e.trend_rank ? `热度 #${e.trend_rank}` : '';
const decision = decisionLabel(e.decision);
const related = e.related_symbol || (e.related_base ? `${e.related_base}/USDT` : '--');
const base = e.related_base || related.replace('/USDT','');
const cgLink = base && base !== '--' ? `https://www.coingecko.com/en/coins/${base.toLowerCase()}` : '#';
const metaBits = [
published ? `发布时间 ${fmtTime(published)}` : '',
e.event_type ? `类型 ${e.event_type}` : '',
e.tech_score ? `技术分 ${e.tech_score}` : '',
e.rec_id ? `推荐ID #${e.rec_id}` : '',
e.pushed ? '已推送飞书' : ''
].filter(Boolean).map(x => `<span>${x}</span>`).join('');
const metricBits = [
`<span class="sentiment-related-metric">价格 ${priceText}</span>`,
`<span class="sentiment-related-metric sentiment-change ${changeCls}">24h ${changeText}</span>`,
mcap ? `<span class="sentiment-related-metric">${mcap}</span>` : '',
trend ? `<span class="sentiment-related-metric">${trend}</span>` : ''
].filter(Boolean).join('');
return `<div class="sentiment-event-card importance-${level}">
<div class="sentiment-event-main">
<div class="sentiment-event-top">
<span class="sentiment-source-chip">${sourceIcon(e.source)} ${e.source_label || e.source || '消息源'}</span>
<span class="sentiment-importance-chip level-${level}">${level}级</span>
${decision ? `<span class="sentiment-decision-chip">${decision}</span>` : ''}
<span class="sentiment-event-time">${published ? fmtTime(published) : '--'}</span>
</div>
${titleHtml}
<div class="sentiment-event-meta">${metaBits}</div>
</div>
<div class="sentiment-related-box">
<div class="sentiment-related-label">关联币种</div>
<div class="sentiment-related-symbol">
<div>
<a href="${cgLink}" target="_blank" rel="noopener">${related}</a>
${e.related_name ? `<div class="sentiment-related-name">${e.related_name}</div>` : ''}
</div>
<span class="sentiment-related-tag">${e.relation_tag || '关联币种'}</span>
</div>
<div class="sentiment-related-metrics">${metricBits}</div>
</div>
</div>`;
}).join('');
const statsHTML = `
<div class="decision-archive-note">
<div class="title">📢 舆情监控 — 消息优先视图</div>
<div class="desc">
数据时间:${timeStr} | 舆情消息 ${totalEvents} 条 | 🔥 活跃推荐关联 ${overlap_active} 条 | 👀 筛选关联 ${overlap_screened}
<br>💡 页面现在按“消息/事件”展示,币种只是右侧关联信息;交易动作仍由技术确认决定。
</div>
</div>
<div class="sentiment-list">`;
const footerHTML = `
</div>
<div class="sentiment-footer">
<span>📊 来源: 事件驱动舆情 + CoinGecko Trending + 相关新闻</span>
<span>🔄 事件驱动每1分钟检查热度源每30分钟更新</span>
<span>🧭 消息优先展示,不再按币种排名做主视觉</span>
</div>`;
return statsHTML + eventCards + footerHTML;
}
// === Refresh ===
async function doRefresh() {
const btn = document.querySelector('.header-refresh-btn');
if (btn) btn.classList.add('spinning');
try {
const stats = await fetch(API+'/api/stats').then(r=>r.json());
renderStats(stats);
await loadContent();
} catch(e) {}
setTimeout(()=>{ if (btn) btn.classList.remove('spinning'); }, 600);
}
function toggleAutoRefresh() {
const checked = $('#autoRefreshToggle').checked;
if (checked) {
refreshTimer = setInterval(doRefresh, 60000);
} else {
clearInterval(refreshTimer);
refreshTimer = null;
}
}
// === Init ===
(async()=>{
try {
const stats = await fetch(API+'/api/stats').then(r=>r.json());
renderStats(stats);
} catch(e){}
await loadVersions();
$('#versionFilterBar').style.display = 'flex';
await loadContent();
// 默认不自动刷新,用户手动开启
})();
</script>
</div><!-- app-shell -->
</body>
</html>'''
@app.get("/admin.html", response_class=HTMLResponse)
async def admin_page(request: Request, altcoin_session: str = Cookie(default="")):
if not auth_db.get_user_by_session_token(altcoin_session):
return _login_redirect()
try:
_require_admin(altcoin_session)
except HTTPException as e:
return HTMLResponse(content=f"<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})
# ====== ADMIN API (v1.7.8) ======
@app.get("/api/admin/check")
async def api_admin_check(altcoin_session: str = Cookie(default="")):
"""前端检查当前用户是否为管理员"""
try:
user = _require_admin(altcoin_session)
return {"is_admin": True, "email": user.get("email", "")}
except HTTPException:
return {"is_admin": False}
@app.get("/api/admin/stats")
async def api_admin_stats(altcoin_session: str = Cookie(default="")):
_require_admin(altcoin_session)
return auth_db.get_admin_stats()
@app.get("/api/admin/users")
async def api_admin_users(search: str = "", offset: int = 0, limit: int = 50, tab: str = "all",
altcoin_session: str = Cookie(default="")):
_require_admin(altcoin_session)
return auth_db.get_admin_users(search=search, offset=offset, limit=limit, tab=tab)
@app.get("/api/admin/orders")
async def api_admin_orders(search: str = "", offset: int = 0, limit: int = 50, status: str = "all",
altcoin_session: str = Cookie(default="")):
_require_admin(altcoin_session)
return auth_db.get_admin_orders(search=search, offset=offset, limit=limit, status=status)