4250 lines
183 KiB
Python
4250 lines
183 KiB
Python
"""
|
||
山寨币监控网站 — 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) |