1
This commit is contained in:
parent
48e27bacf3
commit
cb7eb2f202
@ -10,6 +10,8 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.core.trade_math import normalize_side
|
||||
|
||||
|
||||
def _safe_float(value, default: float = 0.0) -> float:
|
||||
try:
|
||||
@ -46,7 +48,7 @@ def order_expires_at(event_time: str, expire_hours: float = 24.0) -> str:
|
||||
|
||||
|
||||
def order_touched(order: dict, current_price: float) -> bool:
|
||||
side = str(order.get("side") or "long").lower()
|
||||
side = normalize_side(order.get("side"))
|
||||
target = _safe_float(order.get("target_price"))
|
||||
price = _safe_float(current_price)
|
||||
if target <= 0 or price <= 0:
|
||||
@ -60,7 +62,7 @@ def order_too_far(order: dict, current_price: float, threshold_pct: float = 12.0
|
||||
threshold = max(0.0, _safe_float(threshold_pct, 12.0))
|
||||
if threshold <= 0:
|
||||
return False
|
||||
side = str(order.get("side") or "long").lower()
|
||||
side = normalize_side(order.get("side"))
|
||||
target = _safe_float(order.get("target_price"))
|
||||
price = _safe_float(current_price)
|
||||
if target <= 0 or price <= 0:
|
||||
@ -71,7 +73,7 @@ def order_too_far(order: dict, current_price: float, threshold_pct: float = 12.0
|
||||
|
||||
|
||||
def order_rr(side: str, target: float, stop_loss: float, tp1: float) -> float:
|
||||
if str(side or "long").lower() == "short":
|
||||
if normalize_side(side) == "short":
|
||||
risk = _safe_float(stop_loss) - _safe_float(target)
|
||||
reward = _safe_float(target) - _safe_float(tp1)
|
||||
else:
|
||||
@ -87,7 +89,7 @@ def order_distance_pct(side: str, current_price: float, target: float) -> float:
|
||||
target_price = _safe_float(target)
|
||||
if target_price <= 0 or price <= 0:
|
||||
return 999.0
|
||||
if str(side or "long").lower() == "short":
|
||||
if normalize_side(side) == "short":
|
||||
return max(0.0, (target_price / price - 1) * 100)
|
||||
return max(0.0, (price / target_price - 1) * 100)
|
||||
|
||||
|
||||
@ -13,7 +13,10 @@ def safe_float(value, default: float = 0.0) -> float:
|
||||
|
||||
|
||||
def normalize_side(side: str | None) -> str:
|
||||
return "short" if str(side or "").strip().lower() == "short" else "long"
|
||||
text = str(side or "").strip().lower()
|
||||
if text in {"short", "sell", "空", "空头", "做空", "开空"} or "空" in text:
|
||||
return "short"
|
||||
return "long"
|
||||
|
||||
|
||||
def open_price(side: str, current_price: float, slippage_pct: float = 0.0) -> float:
|
||||
|
||||
438
app/db/operations_dashboard.py
Normal file
438
app/db/operations_dashboard.py
Normal file
@ -0,0 +1,438 @@
|
||||
"""Operations cockpit read model for system-wide health monitoring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.db.schema import get_conn
|
||||
from app.db.scheduler_db import get_scheduler_overview
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now()
|
||||
|
||||
|
||||
def _safe_int(value, default: int = 0) -> int:
|
||||
try:
|
||||
return int(value or 0)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _safe_float(value, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(value or 0)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _parse_dt(value) -> datetime | None:
|
||||
if isinstance(value, datetime):
|
||||
return value.replace(tzinfo=None)
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(str(value).replace("Z", "+00:00")).replace(tzinfo=None)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _iso(value) -> str:
|
||||
dt = _parse_dt(value)
|
||||
if dt:
|
||||
return dt.isoformat(timespec="seconds")
|
||||
return str(value or "")
|
||||
|
||||
|
||||
def _age_seconds(value) -> int | None:
|
||||
dt = _parse_dt(value)
|
||||
if not dt:
|
||||
return None
|
||||
return max(0, int((_now() - dt).total_seconds()))
|
||||
|
||||
|
||||
def _status_by_age(value, warn_seconds: int, danger_seconds: int, missing: str = "danger") -> str:
|
||||
age = _age_seconds(value)
|
||||
if age is None:
|
||||
return missing
|
||||
if age >= danger_seconds:
|
||||
return "danger"
|
||||
if age >= warn_seconds:
|
||||
return "warn"
|
||||
return "ok"
|
||||
|
||||
|
||||
def _count(conn, sql: str, params=()) -> int:
|
||||
try:
|
||||
row = conn.execute(sql, params).fetchone()
|
||||
return _safe_int(row[0] if row else 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _fetchone(conn, sql: str, params=()) -> dict:
|
||||
try:
|
||||
row = conn.execute(sql, params).fetchone()
|
||||
return dict(row) if row else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _fetchall(conn, sql: str, params=()) -> list[dict]:
|
||||
try:
|
||||
return [dict(r) for r in conn.execute(sql, params).fetchall()]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _severity_rank(status: str) -> int:
|
||||
return {"ok": 0, "warn": 1, "danger": 2}.get(str(status or ""), 0)
|
||||
|
||||
|
||||
def _display_error_summary(message: str, source: str = "", error_type: str = "") -> str:
|
||||
text = str(message or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
lowered = text.lower()
|
||||
source_text = f"{source} " if source else ""
|
||||
if "alchemy" in lowered and (
|
||||
"ssl" in lowered
|
||||
or "httpsconnectionpool" in lowered
|
||||
or "max retries" in lowered
|
||||
or "ssleoferror" in lowered
|
||||
or "nameresolution" in lowered
|
||||
or "name resolution" in lowered
|
||||
):
|
||||
symbol = text.split(":", 1)[0] if "/USDT" in text.split(":", 1)[0] else ""
|
||||
prefix = f"{symbol} · " if symbol else ""
|
||||
return f"{prefix}Alchemy 链上数据源连接异常"
|
||||
if "nodereal" in lowered and (
|
||||
"ssl" in lowered
|
||||
or "httpsconnectionpool" in lowered
|
||||
or "max retries" in lowered
|
||||
or "timeout" in lowered
|
||||
or "nameresolution" in lowered
|
||||
or "name resolution" in lowered
|
||||
):
|
||||
symbol = text.split(":", 1)[0] if "/USDT" in text.split(":", 1)[0] else ""
|
||||
prefix = f"{symbol} · " if symbol else ""
|
||||
return f"{prefix}NodeReal 链上数据源连接异常"
|
||||
if "feishu" in lowered or "lark" in lowered or "webhook" in lowered:
|
||||
return "飞书通知配置或发送异常"
|
||||
if "timeout" in lowered or "timed out" in lowered:
|
||||
return f"{source_text}请求超时".strip()
|
||||
if "connectionpool" in lowered or "max retries" in lowered:
|
||||
return f"{source_text}外部服务连接异常".strip()
|
||||
if "ssl" in lowered or "ssleoferror" in lowered:
|
||||
return f"{source_text}外部服务 SSL 连接异常".strip()
|
||||
if len(text) > 72:
|
||||
return text[:72].rstrip() + "..."
|
||||
return text
|
||||
|
||||
|
||||
def _overall_status(cards: list[dict], errors_24h: int) -> dict:
|
||||
worst = "ok"
|
||||
if errors_24h > 0:
|
||||
worst = "warn"
|
||||
for item in cards:
|
||||
if _severity_rank(item.get("status")) > _severity_rank(worst):
|
||||
worst = item.get("status")
|
||||
label = {"ok": "运行正常", "warn": "需要关注", "danger": "存在异常"}.get(worst, "运行正常")
|
||||
return {
|
||||
"status": worst,
|
||||
"label": label,
|
||||
"summary": "所有关键链路近期有心跳" if worst == "ok" else "部分链路延迟、失败或数据源不新鲜",
|
||||
}
|
||||
|
||||
|
||||
def _job_display_name(job_name: str) -> str:
|
||||
return {
|
||||
"event": "事件检查",
|
||||
"tracker": "价格跟踪",
|
||||
"paper-trader": "策略交易",
|
||||
"market": "市场快照",
|
||||
"confirm": "交易确认",
|
||||
"screener": "异动筛选",
|
||||
"sentiment": "舆情采集",
|
||||
"onchain": "链上追踪",
|
||||
"llm-sentiment": "AI 舆情",
|
||||
"review": "复盘迭代",
|
||||
}.get(job_name, job_name)
|
||||
|
||||
|
||||
def _build_scheduler_cards() -> tuple[list[dict], list[dict]]:
|
||||
overview = get_scheduler_overview()
|
||||
cards = []
|
||||
timeline = []
|
||||
for job in overview.get("jobs") or []:
|
||||
runtime = job.get("runtime") or {}
|
||||
latest = job.get("latest_cron") or {}
|
||||
enabled = bool(job.get("enabled"))
|
||||
last_time = runtime.get("last_finished_at") or latest.get("finished_at") or runtime.get("updated_at")
|
||||
interval = max(30, _safe_int(job.get("every_seconds"), 300))
|
||||
status = "off"
|
||||
if enabled:
|
||||
status = _status_by_age(last_time, interval * 2, interval * 5, missing="warn")
|
||||
if str(runtime.get("status") or "") in {"running", "pending"}:
|
||||
status = "running"
|
||||
if latest.get("run_status") == "error" or latest.get("error_message"):
|
||||
status = "danger"
|
||||
card = {
|
||||
"job_name": job.get("job_name"),
|
||||
"name": _job_display_name(job.get("job_name")),
|
||||
"enabled": enabled,
|
||||
"status": status,
|
||||
"runtime_status": runtime.get("status") or ("disabled" if not enabled else "idle"),
|
||||
"last_time": _iso(last_time),
|
||||
"age_seconds": _age_seconds(last_time),
|
||||
"next_run_at": _iso(runtime.get("next_run_at")),
|
||||
"interval_seconds": interval,
|
||||
"last_result": latest.get("run_status") or "",
|
||||
"result_status": latest.get("result_status") or "",
|
||||
"duration_ms": _safe_int(latest.get("duration_ms") or runtime.get("last_duration_ms")),
|
||||
"error": _display_error_summary(latest.get("error_message") or runtime.get("last_error") or "", source=job.get("job_name") or ""),
|
||||
}
|
||||
cards.append(card)
|
||||
if last_time:
|
||||
timeline.append({
|
||||
"time": _iso(last_time),
|
||||
"type": "scheduler",
|
||||
"title": f"{card['name']}完成",
|
||||
"status": card["status"],
|
||||
"detail": card["result_status"] or card["last_result"] or card["runtime_status"],
|
||||
})
|
||||
return cards, timeline
|
||||
|
||||
|
||||
def _build_data_sources(conn) -> tuple[list[dict], list[dict]]:
|
||||
now = _now()
|
||||
sources = []
|
||||
timeline = []
|
||||
specs = [
|
||||
("market", "市场快照", "market_snapshots", "snapshot_time", 600, 1800),
|
||||
("prices", "实时价格", "latest_price_cache", "updated_at", 360, 900),
|
||||
("sentiment", "新闻舆情", "event_news", "detected_at", 3600, 10800),
|
||||
("onchain", "链上事件", "onchain_raw_events", "detected_at", 7200, 21600),
|
||||
("llm", "AI 解读", "llm_insights", "updated_at", 7200, 21600),
|
||||
]
|
||||
for code, name, table, time_col, warn, danger in specs:
|
||||
row = _fetchone(conn, f"SELECT MAX({time_col}) AS last_time, COUNT(*) AS total FROM {table}")
|
||||
count_24h = _count(conn, f"SELECT COUNT(*) FROM {table} WHERE {time_col} >= %s", ((now - timedelta(hours=24)).isoformat(),))
|
||||
last_time = row.get("last_time")
|
||||
status = _status_by_age(last_time, warn, danger, missing="warn")
|
||||
sources.append({
|
||||
"code": code,
|
||||
"name": name,
|
||||
"status": status,
|
||||
"last_time": _iso(last_time),
|
||||
"age_seconds": _age_seconds(last_time),
|
||||
"count_24h": count_24h,
|
||||
"total": _safe_int(row.get("total")),
|
||||
})
|
||||
if last_time:
|
||||
timeline.append({
|
||||
"time": _iso(last_time),
|
||||
"type": "data",
|
||||
"title": f"{name}更新",
|
||||
"status": status,
|
||||
"detail": f"近 24h {count_24h} 条",
|
||||
})
|
||||
return sources, timeline
|
||||
|
||||
|
||||
def _build_funnel(conn, since: str) -> list[dict]:
|
||||
coverage = _fetchone(
|
||||
conn,
|
||||
"""
|
||||
SELECT *
|
||||
FROM screening_coverage_audit
|
||||
ORDER BY scan_started_at DESC, id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
)
|
||||
confirm_count = _count(conn, "SELECT COUNT(*) FROM recommendation WHERE rec_time >= %s", (since,))
|
||||
buy_now = _count(conn, "SELECT COUNT(*) FROM recommendation WHERE rec_time >= %s AND execution_status='buy_now'", (since,))
|
||||
wait_pullback = _count(conn, "SELECT COUNT(*) FROM recommendation WHERE rec_time >= %s AND execution_status='wait_pullback'", (since,))
|
||||
observe = _count(conn, "SELECT COUNT(*) FROM recommendation WHERE rec_time >= %s AND execution_status='observe'", (since,))
|
||||
orders = _count(conn, "SELECT COUNT(*) FROM paper_orders WHERE created_at >= %s", (since,))
|
||||
filled_orders = _count(conn, "SELECT COUNT(*) FROM paper_orders WHERE filled_at >= %s AND status='filled'", (since,))
|
||||
trades = _count(conn, "SELECT COUNT(*) FROM paper_trades WHERE opened_at >= %s", (since,))
|
||||
closed = _count(conn, "SELECT COUNT(*) FROM paper_trades WHERE closed_at >= %s AND status='closed'", (since,))
|
||||
reviews = _count(conn, "SELECT COUNT(*) FROM review_log WHERE review_time >= %s", (since,))
|
||||
return [
|
||||
{
|
||||
"code": "universe",
|
||||
"label": "交易宇宙",
|
||||
"value": _safe_int(coverage.get("tradable_universe_count")),
|
||||
"sub": f"原始 {_safe_int(coverage.get('usdt_pair_count'))} / 过滤 {_safe_int(coverage.get('universe_gate_count'))}",
|
||||
"status": _status_by_age(coverage.get("scan_finished_at"), 1800, 7200, missing="warn"),
|
||||
},
|
||||
{
|
||||
"code": "discovery",
|
||||
"label": "异动发现",
|
||||
"value": _safe_int(coverage.get("coarse_candidate_count")),
|
||||
"sub": f"强势榜 {_safe_int(coverage.get('top_gainer_discovery_count'))}",
|
||||
"status": _status_by_age(coverage.get("scan_finished_at"), 1800, 7200, missing="warn"),
|
||||
},
|
||||
{
|
||||
"code": "quality",
|
||||
"label": "质量验证",
|
||||
"value": _safe_int(coverage.get("fine_qualified_count")),
|
||||
"sub": f"拒绝 {_safe_int(coverage.get('quality_rejected_count'))}",
|
||||
"status": _status_by_age(coverage.get("scan_finished_at"), 1800, 7200, missing="warn"),
|
||||
},
|
||||
{
|
||||
"code": "confirm",
|
||||
"label": "交易确认",
|
||||
"value": confirm_count,
|
||||
"sub": f"可买 {buy_now} / 挂单 {wait_pullback} / 观察 {observe}",
|
||||
"status": "ok" if confirm_count or observe or wait_pullback else "warn",
|
||||
},
|
||||
{
|
||||
"code": "execution",
|
||||
"label": "策略交易",
|
||||
"value": orders + trades,
|
||||
"sub": f"挂单 {orders} / 成交 {filled_orders} / 开仓 {trades} / 平仓 {closed}",
|
||||
"status": "ok",
|
||||
},
|
||||
{
|
||||
"code": "review",
|
||||
"label": "复盘迭代",
|
||||
"value": reviews,
|
||||
"sub": "只统计窗口内复盘样本",
|
||||
"status": "ok" if reviews else "warn",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _build_trading(conn, since: str) -> dict:
|
||||
open_count = _count(conn, "SELECT COUNT(*) FROM paper_trades WHERE status='open'")
|
||||
pending_count = _count(conn, "SELECT COUNT(*) FROM paper_orders WHERE status='pending'")
|
||||
filled_24h = _count(conn, "SELECT COUNT(*) FROM paper_orders WHERE status='filled' AND filled_at >= %s", (since,))
|
||||
canceled_24h = _count(conn, "SELECT COUNT(*) FROM paper_orders WHERE status IN ('canceled','expired','rejected') AND COALESCE(canceled_at, updated_at, created_at) >= %s", (since,))
|
||||
closed_24h = _count(conn, "SELECT COUNT(*) FROM paper_trades WHERE status='closed' AND closed_at >= %s", (since,))
|
||||
pnl = _safe_float(_fetchone(conn, "SELECT COALESCE(SUM(realized_pnl_usdt),0) AS v FROM paper_trades WHERE status='closed' AND closed_at >= %s", (since,)).get("v"))
|
||||
recent_orders = _fetchall(
|
||||
conn,
|
||||
"""
|
||||
SELECT symbol, side, status, target_price, fill_price, cancel_reason,
|
||||
COALESCE(filled_at, canceled_at, updated_at, created_at) AS event_time
|
||||
FROM paper_orders
|
||||
ORDER BY COALESCE(filled_at, canceled_at, updated_at, created_at) DESC, id DESC
|
||||
LIMIT 8
|
||||
""",
|
||||
)
|
||||
return {
|
||||
"open_positions": open_count,
|
||||
"pending_orders": pending_count,
|
||||
"filled_orders_24h": filled_24h,
|
||||
"canceled_orders_24h": canceled_24h,
|
||||
"closed_trades_24h": closed_24h,
|
||||
"realized_pnl_24h": round(pnl, 4),
|
||||
"recent_orders": recent_orders,
|
||||
"status": "warn" if canceled_24h > max(3, filled_24h * 2) else "ok",
|
||||
}
|
||||
|
||||
|
||||
def _build_timeline(conn, base_events: list[dict], since: str) -> list[dict]:
|
||||
timeline = list(base_events)
|
||||
for row in _fetchall(
|
||||
conn,
|
||||
"""
|
||||
SELECT event_time AS time, event_type, symbol, message
|
||||
FROM paper_trade_events
|
||||
WHERE event_time >= %s
|
||||
ORDER BY event_time DESC, id DESC
|
||||
LIMIT 12
|
||||
""",
|
||||
(since,),
|
||||
):
|
||||
timeline.append({
|
||||
"time": _iso(row.get("time")),
|
||||
"type": "trade",
|
||||
"title": f"{row.get('symbol') or '--'} · {row.get('event_type') or '交易事件'}",
|
||||
"status": "ok",
|
||||
"detail": _display_error_summary(row.get("message") or "", source="paper_trade"),
|
||||
})
|
||||
for row in _fetchall(
|
||||
conn,
|
||||
"""
|
||||
SELECT started_at AS time, job_name, run_status, result_status, error_message
|
||||
FROM cron_run_log
|
||||
WHERE started_at >= %s
|
||||
ORDER BY started_at DESC, id DESC
|
||||
LIMIT 12
|
||||
""",
|
||||
(since,),
|
||||
):
|
||||
status = "danger" if row.get("run_status") == "error" or row.get("error_message") else "ok"
|
||||
timeline.append({
|
||||
"time": _iso(row.get("time")),
|
||||
"type": "job",
|
||||
"title": f"{row.get('job_name') or '--'} 运行",
|
||||
"status": status,
|
||||
"detail": _display_error_summary(row.get("error_message") or "", source=row.get("job_name") or "")
|
||||
or row.get("result_status")
|
||||
or row.get("run_status")
|
||||
or "",
|
||||
})
|
||||
timeline.sort(key=lambda x: _parse_dt(x.get("time")) or datetime.min, reverse=True)
|
||||
return timeline[:28]
|
||||
|
||||
|
||||
def get_operations_dashboard(hours: int = 24) -> dict:
|
||||
hours = max(1, min(_safe_int(hours, 24), 168))
|
||||
since = (_now() - timedelta(hours=hours)).isoformat()
|
||||
scheduler, scheduler_events = _build_scheduler_cards()
|
||||
conn = get_conn()
|
||||
try:
|
||||
data_sources, source_events = _build_data_sources(conn)
|
||||
funnel = _build_funnel(conn, since)
|
||||
trading = _build_trading(conn, since)
|
||||
errors_24h = _count(conn, "SELECT COUNT(*) FROM system_error_log WHERE created_at >= %s AND resolved_at=''", (since,))
|
||||
latest_error = _fetchone(
|
||||
conn,
|
||||
"""
|
||||
SELECT created_at, source, error_type, message
|
||||
FROM system_error_log
|
||||
WHERE created_at >= %s AND resolved_at=''
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(since,),
|
||||
)
|
||||
if latest_error:
|
||||
latest_error["display_message"] = _display_error_summary(
|
||||
latest_error.get("message") or "",
|
||||
source=latest_error.get("source") or "",
|
||||
error_type=latest_error.get("error_type") or "",
|
||||
)
|
||||
timeline = _build_timeline(conn, scheduler_events + source_events, since)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
health_inputs = [x for x in scheduler if x.get("enabled")] + data_sources + [trading]
|
||||
overall = _overall_status(health_inputs, errors_24h)
|
||||
return {
|
||||
"hours": hours,
|
||||
"generated_at": _now().isoformat(timespec="seconds"),
|
||||
"overall": overall,
|
||||
"summary": {
|
||||
"enabled_jobs": sum(1 for x in scheduler if x.get("enabled")),
|
||||
"running_jobs": sum(1 for x in scheduler if x.get("runtime_status") == "running"),
|
||||
"data_sources_ok": sum(1 for x in data_sources if x.get("status") == "ok"),
|
||||
"active_opportunities": _safe_int(next((x.get("value") for x in funnel if x.get("code") == "confirm"), 0)),
|
||||
"pending_orders": trading.get("pending_orders", 0),
|
||||
"open_positions": trading.get("open_positions", 0),
|
||||
"system_errors": errors_24h,
|
||||
"latest_error": latest_error,
|
||||
},
|
||||
"scheduler": scheduler,
|
||||
"data_sources": data_sources,
|
||||
"funnel": funnel,
|
||||
"trading": trading,
|
||||
"timeline": timeline,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["get_operations_dashboard"]
|
||||
@ -754,14 +754,16 @@ def _open_trade(conn, rec: dict, current_price: float, event_time: str, config:
|
||||
"leverage_risk": leverage_risk,
|
||||
},
|
||||
}
|
||||
global_ok, global_detail = _global_risk_entry_check(conn, rec, notional, cfg)
|
||||
if not global_ok:
|
||||
return {
|
||||
"opened": False,
|
||||
"skipped": True,
|
||||
"reason": "global_risk_rejected",
|
||||
"risk_detail": global_detail,
|
||||
}
|
||||
global_detail = rec.get("_prefilled_global_risk") if isinstance(rec.get("_prefilled_global_risk"), dict) else None
|
||||
if global_detail is None:
|
||||
global_ok, global_detail = _global_risk_entry_check(conn, rec, notional, cfg)
|
||||
if not global_ok:
|
||||
return {
|
||||
"opened": False,
|
||||
"skipped": True,
|
||||
"reason": "global_risk_rejected",
|
||||
"risk_detail": global_detail,
|
||||
}
|
||||
adjusted_notional = _market_risk_adjusted_notional(notional, global_detail, cfg)
|
||||
if adjusted_notional != notional:
|
||||
plan["market_position_sizing"] = {
|
||||
@ -1014,6 +1016,24 @@ def _cancel_paper_order(conn, order: dict, reason: str, event_time: str) -> dict
|
||||
return {"skipped": True, "reason": f"paper_order_{reason}", "paper_order_id": order["id"]}
|
||||
|
||||
|
||||
def _touch_global_risk_cancel_reason(global_detail: dict | None) -> str:
|
||||
"""Only hard account/portfolio gates should cancel an already-touched order."""
|
||||
detail = global_detail if isinstance(global_detail, dict) else {}
|
||||
decision = str(detail.get("decision") or "").strip()
|
||||
position_multiplier = _safe_float(detail.get("position_multiplier"), 1.0)
|
||||
if position_multiplier <= 0:
|
||||
return "touch_position_multiplier_zero"
|
||||
if decision == "block_critical":
|
||||
return "touch_critical_risk"
|
||||
if decision == "block_max_open_positions":
|
||||
return "touch_max_open_positions"
|
||||
if decision == "block_same_direction_concentration":
|
||||
return "touch_same_direction_concentration"
|
||||
if decision == "block_same_sector_concentration":
|
||||
return "touch_same_sector_concentration"
|
||||
return ""
|
||||
|
||||
|
||||
def _order_recommendation_cancel_reason(conn, rec: dict, order: dict) -> str:
|
||||
rec_id = _safe_int(rec.get("id") or order.get("recommendation_id"))
|
||||
if rec_id <= 0:
|
||||
@ -1083,16 +1103,22 @@ def _fill_paper_order(conn, order: dict, rec: dict, current_price: float, event_
|
||||
base_notional = _safe_float(order.get("notional_usdt"), default_notional_usdt(cfg))
|
||||
global_ok, global_detail = _global_risk_entry_check(conn, trade_rec, base_notional, cfg)
|
||||
if not global_ok:
|
||||
# 触价后的限价单已经完成“等待成交”阶段。若此刻风控不允许开仓,
|
||||
# 这张挂单必须结束,不能继续 pending 等待下一次风控放行,否则会在
|
||||
# 页面上出现“做多价格已低于目标价仍挂单”的错误状态。
|
||||
result = _cancel_paper_order(conn, order, "risk_paused_at_touch", event_time)
|
||||
result.update({
|
||||
"target_price": order.get("target_price"),
|
||||
"current_price": current_price,
|
||||
"risk_detail": global_detail,
|
||||
})
|
||||
return result
|
||||
cancel_reason = _touch_global_risk_cancel_reason(global_detail)
|
||||
if cancel_reason:
|
||||
result = _cancel_paper_order(conn, order, cancel_reason, event_time)
|
||||
result.update({
|
||||
"target_price": order.get("target_price"),
|
||||
"current_price": current_price,
|
||||
"risk_detail": global_detail,
|
||||
})
|
||||
return result
|
||||
global_detail = {
|
||||
**(global_detail or {}),
|
||||
"allow_new_entries": True,
|
||||
"touch_soft_gate_overridden": True,
|
||||
"touch_soft_gate_reason": (global_detail or {}).get("decision") or "soft_global_risk_gate",
|
||||
}
|
||||
trade_rec["_prefilled_global_risk"] = global_detail
|
||||
adjusted_notional = _market_risk_adjusted_notional(base_notional, global_detail, cfg)
|
||||
pause_ok, pause_reason, pause_detail = _portfolio_entry_pause_check(conn, adjusted_notional, event_time, cfg)
|
||||
if not pause_ok:
|
||||
|
||||
@ -48,6 +48,32 @@ ERC20_SYMBOL_SELECTOR = "0x95d89b41"
|
||||
ERC20_NAME_SELECTOR = "0x06fdde03"
|
||||
ERC20_DECIMALS_SELECTOR = "0x313ce567"
|
||||
|
||||
|
||||
def _provider_error_summary(provider: str, chain: str = "", scope: str = "", symbol: str = "", exc: Exception | str = "") -> str:
|
||||
provider_label = {"alchemy": "Alchemy", "nodereal": "NodeReal"}.get(str(provider or "").lower(), provider or "链上数据源")
|
||||
chain_label = {"ethereum": "Ethereum", "bsc": "BSC"}.get(str(chain or "").lower(), chain or "")
|
||||
scope_label = {
|
||||
"logs": "映射代币日志",
|
||||
"raw_logs": "原始转账流",
|
||||
"metadata": "Token 资料",
|
||||
}.get(str(scope or ""), scope or "链上数据")
|
||||
text = str(exc or "")
|
||||
reason = "采集失败"
|
||||
lowered = text.lower()
|
||||
if "name resolution" in lowered or "nameresolution" in lowered or "temporary failure in name resolution" in lowered:
|
||||
reason = "DNS 解析异常"
|
||||
elif "ssl" in lowered or "connectionpool" in lowered or "max retries" in lowered or "eof" in lowered:
|
||||
reason = "连接异常"
|
||||
elif "timeout" in lowered or "timed out" in lowered:
|
||||
reason = "请求超时"
|
||||
elif "rate" in lowered or "429" in lowered:
|
||||
reason = "额度或限流"
|
||||
elif "403" in lowered or "401" in lowered or "api_key" in lowered:
|
||||
reason = "鉴权异常"
|
||||
prefix = f"{symbol}:" if symbol else ""
|
||||
chain_part = f"{chain_label} " if chain_label else ""
|
||||
return f"{prefix}{provider_label} {chain_part}{scope_label} {reason}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Known CEX hot/deposit wallet addresses (lowercase).
|
||||
# Sources: Etherscan/BscScan labeled addresses, Arkham, Nansen public tags.
|
||||
@ -733,7 +759,7 @@ def fetch_nodereal_events(limit=60):
|
||||
if holder_event and insert_onchain_event(holder_event):
|
||||
events.append(holder_event)
|
||||
except Exception as exc:
|
||||
errors.append(f"{mapping.get('symbol')}:nodereal_holder:{str(exc)[:160]}")
|
||||
errors.append(_provider_error_summary("nodereal", chain=chain, scope="metadata", symbol=mapping.get("symbol"), exc=exc))
|
||||
try:
|
||||
latest = client.block_number(chain)
|
||||
if latest <= 0:
|
||||
@ -756,7 +782,7 @@ def fetch_nodereal_events(limit=60):
|
||||
if insert_onchain_event(event):
|
||||
events.append(event)
|
||||
except Exception as exc:
|
||||
errors.append(f"{mapping.get('symbol')}:nodereal_logs:{str(exc)[:160]}")
|
||||
errors.append(_provider_error_summary("nodereal", chain=chain, scope="logs", symbol=mapping.get("symbol"), exc=exc))
|
||||
if not all_mappings:
|
||||
diagnostics["mapping_note"] = "no_strategy_mappings_raw_events_only"
|
||||
elif not chain_mappings:
|
||||
@ -831,7 +857,7 @@ def fetch_alchemy_events(limit=60):
|
||||
if insert_onchain_event(event):
|
||||
events.append(event)
|
||||
except Exception as exc:
|
||||
errors.append(f"{mapping.get('symbol')}:alchemy_logs:{str(exc)[:160]}")
|
||||
errors.append(_provider_error_summary("alchemy", chain=chain, scope="logs", symbol=mapping.get("symbol"), exc=exc))
|
||||
if not all_mappings:
|
||||
diagnostics["mapping_note"] = "no_strategy_mappings_raw_events_only"
|
||||
elif not chain_mappings:
|
||||
@ -883,7 +909,7 @@ def fetch_nodereal_raw_events(client=None, cfg=None, limit=60):
|
||||
if insert_onchain_raw_event(item):
|
||||
inserted.append(item)
|
||||
except Exception as exc:
|
||||
errors.append(f"{chain}:nodereal_raw_logs:{str(exc)[:160]}")
|
||||
errors.append(_provider_error_summary("nodereal", chain=chain, scope="raw_logs", exc=exc))
|
||||
return {"raw_events": inserted, "errors": errors}
|
||||
|
||||
|
||||
@ -923,7 +949,7 @@ def fetch_alchemy_raw_events(client=None, cfg=None, limit=60):
|
||||
if insert_onchain_raw_event(item):
|
||||
inserted.append(item)
|
||||
except Exception as exc:
|
||||
errors.append(f"{chain}:alchemy_raw_logs:{str(exc)[:160]}")
|
||||
errors.append(_provider_error_summary("alchemy", chain=chain, scope="raw_logs", exc=exc))
|
||||
return {"raw_events": inserted, "errors": errors}
|
||||
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ from app.db import auth_db
|
||||
from app.db import chat_assistant_db
|
||||
from app.db.analytics import get_cron_run_logs, get_cron_run_summary, get_pipeline_runs
|
||||
from app.db.data_export import build_data_export_bundle
|
||||
from app.db.operations_dashboard import get_operations_dashboard
|
||||
from app.db.scheduler_db import (
|
||||
enqueue_manual_trigger,
|
||||
get_job_config,
|
||||
@ -51,6 +52,11 @@ def build_router(templates):
|
||||
require_admin(altcoin_session)
|
||||
return auth_db.get_admin_stats()
|
||||
|
||||
@router.get("/api/admin/operations-dashboard")
|
||||
async def api_admin_operations_dashboard(hours: int = 24, altcoin_session: str = Cookie(default="")):
|
||||
require_admin(altcoin_session)
|
||||
return get_operations_dashboard(hours=hours)
|
||||
|
||||
@router.get("/api/admin/users")
|
||||
async def api_admin_users(search: str = "", offset: int = 0, limit: int = 50, tab: str = "all", altcoin_session: str = Cookie(default="")):
|
||||
require_admin(altcoin_session)
|
||||
|
||||
@ -47,6 +47,10 @@ def build_router(templates, repo_root: Path, stock_report_template: str):
|
||||
user, redirect = require_page_user(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
try:
|
||||
require_admin(request.cookies.get("altcoin_session", ""))
|
||||
except HTTPException as exc:
|
||||
return HTMLResponse(content=f"<meta charset=utf-8><h2>需要管理员权限</h2><p>{exc.detail}</p><a href=/app>返回机会中心</a>", status_code=exc.status_code)
|
||||
return render_page("pipeline.html", request, active_nav="pipeline")
|
||||
|
||||
@router.get("/chat", response_class=HTMLResponse)
|
||||
@ -61,6 +65,10 @@ def build_router(templates, repo_root: Path, stock_report_template: str):
|
||||
user, redirect = require_page_user(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
try:
|
||||
require_admin(request.cookies.get("altcoin_session", ""))
|
||||
except HTTPException as exc:
|
||||
return HTMLResponse(content=f"<meta charset=utf-8><h2>需要管理员权限</h2><p>{exc.detail}</p><a href=/app>返回机会中心</a>", status_code=exc.status_code)
|
||||
return render_page("llm_insights.html", request, active_nav="llm_insights")
|
||||
|
||||
@router.get("/cron", response_class=HTMLResponse)
|
||||
@ -74,6 +82,17 @@ def build_router(templates, repo_root: Path, stock_report_template: str):
|
||||
return HTMLResponse(content=f"<meta charset=utf-8><h2>需要管理员权限</h2><p>{exc.detail}</p><a href=/app>返回看板</a>", status_code=exc.status_code)
|
||||
return render_page("cron.html", request, active_nav="cron")
|
||||
|
||||
@router.get("/operations", response_class=HTMLResponse)
|
||||
async def operations_page(request: Request):
|
||||
user, redirect = require_page_user(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
try:
|
||||
require_admin(request.cookies.get("altcoin_session", ""))
|
||||
except HTTPException as exc:
|
||||
return HTMLResponse(content=f"<meta charset=utf-8><h2>需要管理员权限</h2><p>{exc.detail}</p><a href=/app>返回看板</a>", status_code=exc.status_code)
|
||||
return templates.TemplateResponse(request=request, name="operations.html", context={"show_nav": False, "active_nav": "operations"})
|
||||
|
||||
@router.get("/config", response_class=HTMLResponse)
|
||||
async def config_page(request: Request):
|
||||
user, redirect = require_page_user(request)
|
||||
@ -167,6 +186,10 @@ def build_router(templates, repo_root: Path, stock_report_template: str):
|
||||
user, redirect = require_page_user(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
try:
|
||||
require_admin(request.cookies.get("altcoin_session", ""))
|
||||
except HTTPException as exc:
|
||||
return HTMLResponse(content=f"<meta charset=utf-8><h2>需要管理员权限</h2><p>{exc.detail}</p><a href=/app>返回机会中心</a>", status_code=exc.status_code)
|
||||
return render_page("strategy.html", request, active_nav="strategy")
|
||||
|
||||
@router.get("/subscription", response_class=HTMLResponse)
|
||||
@ -231,6 +254,10 @@ def build_router(templates, repo_root: Path, stock_report_template: str):
|
||||
user, redirect = require_page_user(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
try:
|
||||
require_admin(request.cookies.get("altcoin_session", ""))
|
||||
except HTTPException as exc:
|
||||
return HTMLResponse(content=f"<meta charset=utf-8><h2>需要管理员权限</h2><p>{exc.detail}</p><a href=/app>返回机会中心</a>", status_code=exc.status_code)
|
||||
return render_page("iteration.html", request, active_nav="iteration")
|
||||
|
||||
@router.get("/stock-report", response_class=HTMLResponse)
|
||||
|
||||
37
docs/PRODUCT_INFORMATION_ARCHITECTURE.md
Normal file
37
docs/PRODUCT_INFORMATION_ARCHITECTURE.md
Normal file
@ -0,0 +1,37 @@
|
||||
# AlphaX Product Information Architecture
|
||||
|
||||
AlphaX 的页面入口按“普通用户能直接理解”和“管理员/研发排障”分层。新增页面前先判断它属于哪一层,不要把工程日志、调度、provider、JSON 调用记录直接放进普通用户菜单。
|
||||
|
||||
## 用户菜单
|
||||
|
||||
普通用户只看到能帮助判断市场和机会的页面:
|
||||
|
||||
- 机会中心:当前机会、机会归档、机会状态。
|
||||
- 市场总览:全市场环境、强势榜、成交额、资金费率、链上/舆情摘要。
|
||||
- 消息面:新闻源和 AI 舆情分析。
|
||||
- 链上观察:重要资金流、风险线索、相关币种。
|
||||
- AI 助手:对话式加密研究助手。
|
||||
- 订阅、邀请:账号商业功能。
|
||||
|
||||
用户页面应该避免这些词作为第一层展示:`cron`、`scheduler`、`provider`、`raw logs`、`pipeline`、`JSON`、`runtime config`、`strategy version`。如果必须保留,应转成用户能理解的说法,例如“系统自动刷新中”“链上监控正常”“进入机会检查”。
|
||||
|
||||
## 管理员菜单
|
||||
|
||||
管理员菜单保留少数高价值入口:
|
||||
|
||||
- 运行大屏:投屏式系统运行状态。
|
||||
- 策略交易:paper/live 执行账本和操作日志。
|
||||
- 实盘控制台:交易所账号与真实执行控制。
|
||||
- 复盘中心:策略表现、证据贡献、迭代摘要。
|
||||
- 诊断中心:系统错误、链路批次、AI 调用、问答日志、数据导出等排障入口。
|
||||
- 配置中心、调度中心、用户管理。
|
||||
|
||||
深层工程页面可以保留路由和 API,但不应直接出现在侧边栏。优先从“诊断中心”进入。
|
||||
|
||||
## 设计原则
|
||||
|
||||
- 先给结论,再给证据。
|
||||
- 首页和普通用户页面只显示最重要状态,不展示完整工程流水。
|
||||
- 收益只来自策略交易账本,不把观察样本当收益。
|
||||
- 链上和舆情是机会发现与风险上下文,不直接表达买入指令。
|
||||
- 管理员页面可以保留工程细节,但需要聚合入口,避免侧边栏变成功能清单。
|
||||
@ -249,6 +249,7 @@ a { color: inherit; text-decoration: none; }
|
||||
<symbol id="svg-referral" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><polyline points="17 11 19 13 23 9"/></symbol>
|
||||
<symbol id="svg-config" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h10"/><path d="M4 17h16"/><circle cx="17" cy="7" r="3"/><circle cx="7" cy="17" r="3"/></symbol>
|
||||
<symbol id="svg-export" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/><path d="M5 17v4"/><path d="M19 17v4"/></symbol>
|
||||
<symbol id="svg-operations" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 3v3"/><path d="M12 18v3"/><path d="M3 12h3"/><path d="M18 12h3"/><path d="m12 12 4-5"/><circle cx="12" cy="12" r="2"/></symbol>
|
||||
<symbol id="svg-chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></symbol>
|
||||
</svg>
|
||||
|
||||
@ -259,20 +260,19 @@ a { color: inherit; text-decoration: none; }
|
||||
<span class="brand-name">AlphaX Agent</span>
|
||||
</a>
|
||||
<nav class="sidebar-nav">
|
||||
<a class="sidebar-link {% if active_nav | default('app') == 'app' %}active{% endif %}" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>机会总览</a>
|
||||
<a class="sidebar-link {% if active_nav | default('app') == 'app' %}active{% endif %}" href="/app"><svg class="link-icon"><use href="#svg-dashboard"/></svg>机会中心</a>
|
||||
<a class="sidebar-link {% if active_nav == 'market' %}active{% endif %}" href="/market"><svg class="link-icon"><use href="#svg-target"/></svg>市场总览</a>
|
||||
<a class="sidebar-link {% if active_nav == 'sentiment' %}active{% endif %}" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>实时舆情</a>
|
||||
<a class="sidebar-link {% if active_nav == 'onchain' %}active{% endif %}" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></svg>链上异动</a>
|
||||
<a class="sidebar-link {% if active_nav == 'chat' %}active{% endif %}" href="/chat"><svg class="link-icon"><use href="#svg-chat"/></svg>智能问答</a>
|
||||
<a class="sidebar-link {% if active_nav == 'sentiment' %}active{% endif %}" href="/sentiment"><svg class="link-icon"><use href="#svg-sentiment"/></svg>消息面</a>
|
||||
<a class="sidebar-link {% if active_nav == 'onchain' %}active{% endif %}" href="/onchain"><svg class="link-icon"><use href="#svg-onchain"/></svg>链上观察</a>
|
||||
<a class="sidebar-link {% if active_nav == 'chat' %}active{% endif %}" href="/chat"><svg class="link-icon"><use href="#svg-chat"/></svg>AI 助手</a>
|
||||
<a class="sidebar-link {% if active_nav == 'subscription' %}active{% endif %}" href="/subscription"><svg class="link-icon"><use href="#svg-subscribe"/></svg>订阅</a>
|
||||
<a class="sidebar-link {% if active_nav == 'referral' %}active{% endif %}" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>邀请</a>
|
||||
<div class="sidebar-section-label admin-link" style="display:none">管理员菜单</div>
|
||||
<a class="sidebar-link admin-link {% if active_nav == 'operations' %}active{% endif %}" href="/operations" target="_blank" rel="noopener" style="display:none"><svg class="link-icon"><use href="#svg-operations"/></svg>运行大屏</a>
|
||||
<a class="sidebar-link admin-link {% if active_nav == 'paper_trading' %}active{% endif %}" href="/paper-trading" style="display:none"><svg class="link-icon"><use href="#svg-paper"/></svg>策略交易</a>
|
||||
<a class="sidebar-link admin-link {% if active_nav == 'live_trading' %}active{% endif %}" href="/live-trading" style="display:none"><svg class="link-icon"><use href="#svg-shield"/></svg>实盘控制台</a>
|
||||
<a class="sidebar-link admin-link {% if active_nav == 'review_center' %}active{% endif %}" href="/review-center" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>复盘中心</a>
|
||||
<a class="sidebar-link admin-link {% if active_nav in ['logs','pipeline','system_logs','chat_logs'] %}active{% endif %}" href="/logs" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>日志中心</a>
|
||||
<a class="sidebar-link admin-link {% if active_nav == 'llm_insights' %}active{% endif %}" href="/llm-insights" style="display:none"><svg class="link-icon"><use href="#svg-ai"/></svg>AI 记录</a>
|
||||
<a class="sidebar-link admin-link {% if active_nav == 'data_export' %}active{% endif %}" href="/data-export" style="display:none"><svg class="link-icon"><use href="#svg-export"/></svg>数据导出</a>
|
||||
<a class="sidebar-link admin-link {% if active_nav in ['logs','pipeline','system_logs','chat_logs','llm_insights','data_export','strategy','iteration'] %}active{% endif %}" href="/logs" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>诊断中心</a>
|
||||
<a class="sidebar-link admin-link {% if active_nav == 'config' %}active{% endif %}" href="/config" style="display:none"><svg class="link-icon"><use href="#svg-config"/></svg>配置中心</a>
|
||||
<a class="sidebar-link admin-link {% if active_nav == 'cron' %}active{% endif %}" href="/cron" style="display:none"><svg class="link-icon"><use href="#svg-cron"/></svg>调度中心</a>
|
||||
<a class="sidebar-link admin-link {% if active_nav == 'admin' %}active{% endif %}" href="/admin.html" style="display:none"><svg class="link-icon"><use href="#svg-admin"/></svg>用户管理</a>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}日志中心 · AlphaX Agent{% endblock %}
|
||||
{% block title %}诊断中心 · AlphaX Agent{% endblock %}
|
||||
|
||||
{% block extra_head_css %}
|
||||
<style>
|
||||
@ -54,8 +54,8 @@ tr:hover td{background:var(--surface)}
|
||||
<main>
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<div class="page-title">日志中心</div>
|
||||
<div class="page-sub">把系统错误、链上运行、调度任务、推荐链路和问答记录收在一个运维视图里,排障时不用在多个页面之间来回跳。</div>
|
||||
<div class="page-title">诊断中心</div>
|
||||
<div class="page-sub">管理员查看系统是否正常运转、哪里卡住、哪些页面需要继续排查。这里保留工程细节,但不会出现在普通用户菜单里。</div>
|
||||
</div>
|
||||
<div class="tabs" id="tabs">
|
||||
<button class="tab-btn active" data-tab="system" onclick="switchTab('system')">系统错误</button>
|
||||
@ -66,6 +66,15 @@ tr:hover td{background:var(--surface)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar" style="border:1px solid var(--hairline-soft);border-radius:var(--radius-lg);background:var(--canvas)">
|
||||
<a class="badge" href="/operations" target="_blank" rel="noopener">运行大屏</a>
|
||||
<a class="badge" href="/pipeline">链路批次详情</a>
|
||||
<a class="badge" href="/llm-insights">AI 调用记录</a>
|
||||
<a class="badge" href="/strategy">策略归因</a>
|
||||
<a class="badge" href="/iteration">策略迭代</a>
|
||||
<a class="badge" href="/data-export">数据导出</a>
|
||||
</div>
|
||||
|
||||
<div class="ops-strip" id="opsStrip"><div class="ops-card"><span>状态</span><b>加载中</b><small>正在读取运行日志</small></div></div>
|
||||
|
||||
<section class="panel active" id="panel-system">
|
||||
|
||||
@ -62,7 +62,7 @@
|
||||
<script>
|
||||
var API='';function $(id){return document.getElementById(id)}function esc(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]})}function fmtUsd(v){v=Number(v||0);if(Math.abs(v)>=1e9)return '$'+(v/1e9).toFixed(2)+'B';if(Math.abs(v)>=1e6)return '$'+(v/1e6).toFixed(2)+'M';if(Math.abs(v)>=1e3)return '$'+(v/1e3).toFixed(1)+'K';return '$'+v.toFixed(0)}function fmt(v,d){return Number(v||0).toFixed(d==null?1:d)}function pct(v,d){return fmt(v,d==null?2:d)+'%'}function chip(t,c){return '<span class="chip '+(c||'')+'">'+esc(t)+'</span>'}function compact(t){return esc(String(t||'').replace(/\s+/g,' ').trim())}
|
||||
function rankList(items,mode){items=(items||[]).slice(0,8);if(!items.length)return '<div class="empty">暂无数据</div>';return '<div class="rank-list">'+items.map(function(x,i){var change=Number(x.change_24h||0),val=mode==='volume'?fmtUsd(x.volume_24h):pct(change,2),cls=mode==='volume'?'':(change>=0?'up':'down');return '<div class="rank"><span class="idx">'+(i+1)+'</span><div class="sym"><b>'+esc(x.symbol||'--')+'</b><span>'+esc(fmtUsd(x.volume_24h||0))+' · '+esc(fmt(x.price||0,6))+'</span></div><div class="val '+cls+'">'+esc(val)+'</div></div>'}).join('')+'</div>'}
|
||||
function renderDecision(m,on,news,ai){var st=m.state||{},btc=(m.benchmarks||{})['BTC/USDT']||{},eth=(m.benchmarks||{})['ETH/USDT']||{},delay=m.snapshot_stale?'<div class="note" style="margin-top:10px">市场快照已延迟 '+esc(m.snapshot_age_seconds||'--')+' 秒,请等待定时任务刷新或到调度中心手动触发 market。</div>':'';$('decisionPanel').innerHTML='<div class="decision-top"><div><div class="eyebrow">当前市场判断</div><h2>'+esc(st.label||'暂无全市场数据')+'</h2></div><span class="stance '+esc(st.tone||'neutral')+'">'+esc(st.label||'等待数据')+'</span></div><p>'+esc(st.summary||'全市场行情暂时不可用,请稍后刷新。')+'</p><div class="evidence"><div class="ev"><span>BTC / ETH 24h</span><b>'+pct(btc.change_24h,2)+' / '+pct(eth.change_24h,2)+'</b></div><div class="ev"><span>山寨涨跌比</span><b>'+fmt(m.advance_decline_ratio,2)+'</b></div><div class="ev"><span>强势 / 大跌币</span><b>'+esc((m.hot_count_5pct||0)+' / '+(m.crash_count_5pct||0))+'</b></div></div>'+delay}
|
||||
function renderDecision(m,on,news,ai){var st=m.state||{},btc=(m.benchmarks||{})['BTC/USDT']||{},eth=(m.benchmarks||{})['ETH/USDT']||{},delay=m.snapshot_stale?'<div class="note" style="margin-top:10px">市场数据更新有延迟,系统会自动刷新。当前判断请适当降低权重。</div>':'';$('decisionPanel').innerHTML='<div class="decision-top"><div><div class="eyebrow">当前市场判断</div><h2>'+esc(st.label||'暂无全市场数据')+'</h2></div><span class="stance '+esc(st.tone||'neutral')+'">'+esc(st.label||'等待数据')+'</span></div><p>'+esc(st.summary||'全市场行情暂时不可用,请稍后刷新。')+'</p><div class="evidence"><div class="ev"><span>BTC / ETH 24h</span><b>'+pct(btc.change_24h,2)+' / '+pct(eth.change_24h,2)+'</b></div><div class="ev"><span>山寨涨跌比</span><b>'+fmt(m.advance_decline_ratio,2)+'</b></div><div class="ev"><span>强势 / 大跌币</span><b>'+esc((m.hot_count_5pct||0)+' / '+(m.crash_count_5pct||0))+'</b></div></div>'+delay}
|
||||
function renderScore(m,on,ai){var f=m.funding||{},k=on.kpi||{};$('scorePanel').innerHTML=[['覆盖币种',m.sample_count||0],['上涨 / 下跌',(m.up_count||0)+' / '+(m.down_count||0)],['24h 总成交额',fmtUsd(m.total_quote_volume_24h||0)],['平均涨跌幅',pct(m.avg_change_24h,2)],['平均资金费率',f.sample_count?pct((f.avg_funding_rate||0)*100,4):'暂无'],['链上高价值信号',k.event_count||0],['AI 状态',(ai&&ai.status)||'暂无']].map(function(x){return '<div class="score-row"><span>'+esc(x[0])+'</span><b>'+esc(x[1])+'</b></div>'}).join('')}
|
||||
function renderBreadth(m){$('breadthPanel').innerHTML='<div class="metric-list"><div class="metric"><span>山寨覆盖范围</span><b>'+esc(m.sample_count||0)+' 个</b></div><div class="metric"><span>上涨 / 下跌 / 横盘</span><b>'+esc((m.up_count||0)+' / '+(m.down_count||0)+' / '+(m.flat_count||0))+'</b></div><div class="metric"><span>平均 / 中位涨跌</span><b>'+pct(m.avg_change_24h,2)+' / '+pct(m.median_change_24h,2)+'</b></div><div class="metric"><span>25% / 75% 分位</span><b>'+pct(m.p25_change_24h,2)+' / '+pct(m.p75_change_24h,2)+'</b></div></div><div class="note" style="margin-top:10px">'+esc(m.universe||'全市场口径')+'</div>'}
|
||||
function renderFunding(m){var f=m.funding||{};$('fundingPanel').innerHTML='<div class="metric-list"><div class="metric"><span>样本数</span><b>'+esc(f.sample_count||0)+'</b></div><div class="metric"><span>平均资金费率</span><b>'+esc(f.sample_count?pct((f.avg_funding_rate||0)*100,4):'暂无')+'</b></div><div class="metric"><span>正费率 / 负费率</span><b>'+esc((f.positive_count||0)+' / '+(f.negative_count||0))+'</b></div><div class="metric"><span>极端多头 / 极端空头</span><b>'+esc((f.extreme_positive_count||0)+' / '+(f.extreme_negative_count||0))+'</b></div></div><div class="note" style="margin-top:10px">资金费率用于判断合约拥挤度。极端正费率越多,追高风险越需要被压低。</div>'}
|
||||
|
||||
File diff suppressed because one or more lines are too long
79
static/operations.html
Normal file
79
static/operations.html
Normal file
File diff suppressed because one or more lines are too long
@ -263,7 +263,7 @@ function renderCanceledOrders(items){if(!items.length){$('canceledOrderRows').in
|
||||
'<td><button class="row-action" type="button" onclick="deleteOrder('+Number(x.id)+',\''+esc(String(x.symbol||'')).replace(/'/g,''')+'\')">删除</button></td>'+
|
||||
'</tr>'}).join('')}
|
||||
function orderStatus(x){return {filled:'已成交',expired:'已过期',canceled:'已取消',rejected:'已拒绝'}[x.status]||x.status||'--'}
|
||||
function cancelReasonLabel(r){return {global_risk_rejected:'方向风控拒绝:市场方向、账户风险或仓位集中度不允许开仓',risk_paused_at_touch:'触价时方向风控暂停:目标价已到但该方向暂不允许开仓',stop_loss_leverage_risk_exceeded:'止损杠杆风险超限:按当前止损和杠杆亏损过大',portfolio_drawdown_pause:'账户回撤保护:暂停新增仓位',weak_entries_pause:'近期同类弱入场过多:暂停新增仓位',recommendation_invalid:'原机会已失效:机会过期/归档后撤单',too_far_from_entry:'价格远离计划价:继续等待意义不大',expired:'挂单超时:超过有效期未成交',upgraded_to_buy_now:'信号升级为入场窗口:旧挂单已撤销',canceled:'已取消',filled:'已成交',rejected:'已拒绝'}[r]||r||'--'}
|
||||
function cancelReasonLabel(r){return {global_risk_rejected:'方向风控拒绝:市场方向、账户风险或仓位集中度不允许开仓',risk_paused_at_touch:'触价时风控暂停:目标价已到但硬风控不允许开仓',touch_critical_risk:'触价时 critical 硬风控:暂停新增仓位',touch_position_multiplier_zero:'触价时仓位系数为 0:暂停新增仓位',touch_max_open_positions:'触价时持仓数量超限:暂停新增仓位',touch_same_direction_concentration:'触价时同方向仓位超限:暂停新增同方向仓位',touch_same_sector_concentration:'触价时同板块仓位超限:暂停新增同板块仓位',stop_loss_leverage_risk_exceeded:'止损杠杆风险超限:按当前止损和杠杆亏损过大',portfolio_drawdown_pause:'账户回撤保护:暂停新增仓位',weak_entries_pause:'近期同类弱入场过多:暂停新增仓位',recommendation_invalid:'原机会已失效:机会过期/归档后撤单',too_far_from_entry:'价格远离计划价:继续等待意义不大',expired:'挂单超时:超过有效期未成交',upgraded_to_buy_now:'信号升级为入场窗口:旧挂单已撤销',canceled:'已取消',filled:'已成交',rejected:'已拒绝'}[r]||r||'--'}
|
||||
function renderOpenPager(){var page=Math.floor(openOffset/LIMIT)+1,totalPages=Math.max(1,Math.ceil(openTotal/LIMIT));$('openPageInfo').textContent='第 '+page+' / '+totalPages+' 页 · 共 '+openTotal+' 条';$('openPager').innerHTML='<button '+(openOffset===0?'disabled':'')+' onclick="loadOpenTrades('+(openOffset-LIMIT)+')">上一页</button><span>第 '+page+' / '+totalPages+' 页</span><button '+((openOffset+LIMIT>=openTotal)?'disabled':'')+' onclick="loadOpenTrades('+(openOffset+LIMIT)+')">下一页</button>'}
|
||||
async function loadEvents(nextOffset){eventOffset=Math.max(0,nextOffset||0);$('eventRows').innerHTML='<div class="loading">加载中...</div>';try{var sym=$('eventSymbol').value||'';var typ=$('eventType').value||'';var d=await api('/api/paper-trading/events?limit='+EVENT_LIMIT+'&offset='+eventOffset+'&symbol='+encodeURIComponent(sym)+'&event_type='+encodeURIComponent(typ)+tradeQuery());eventTotal=d.total||0;renderEvents(d.items||[]);renderEventPager()}catch(e){$('eventRows').innerHTML='<div class="empty">'+esc(e.message)+'</div>'}}
|
||||
function eventLabel(t){return {open:'开仓',close:'平仓',trailing_activate:'移动止盈激活',trailing_move:'移动止盈上移'}[t]||t||'动作'}
|
||||
|
||||
@ -211,7 +211,7 @@ def test_onchain_api_and_page(monkeypatch, tmp_path):
|
||||
|
||||
page = client.get("/onchain")
|
||||
assert page.status_code == 200
|
||||
assert "链上异动" in page.text
|
||||
assert "链上观察" in page.text
|
||||
|
||||
overview = client.get("/api/onchain/overview")
|
||||
assert overview.status_code == 200
|
||||
|
||||
69
tests/test_operations_dashboard.py
Normal file
69
tests/test_operations_dashboard.py
Normal file
@ -0,0 +1,69 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.db import auth_db
|
||||
from app.db.operations_dashboard import _display_error_summary, get_operations_dashboard
|
||||
from app.web import web_server
|
||||
|
||||
|
||||
def _login_user(email: str, admin: bool = False) -> str:
|
||||
reg = auth_db.register_user(email, "StrongPass123")
|
||||
auth_db.verify_email(email, reg["verification_code"])
|
||||
user = auth_db.get_user_by_email(email)
|
||||
auth_db.claim_free_trial(user["id"])
|
||||
if admin:
|
||||
auth_db.set_user_admin(email, True)
|
||||
return auth_db.login_user(email, "StrongPass123")["token"]
|
||||
|
||||
|
||||
def test_operations_page_and_api_require_admin():
|
||||
token = _login_user("normal-ops@example.com")
|
||||
client = TestClient(web_server.app)
|
||||
client.cookies.set("altcoin_session", token)
|
||||
|
||||
page = client.get("/operations")
|
||||
api = client.get("/api/admin/operations-dashboard")
|
||||
|
||||
assert page.status_code == 403
|
||||
assert api.status_code == 403
|
||||
|
||||
|
||||
def test_operations_admin_can_access_dashboard():
|
||||
token = _login_user("admin-ops@example.com", admin=True)
|
||||
client = TestClient(web_server.app)
|
||||
client.cookies.set("altcoin_session", token)
|
||||
|
||||
page = client.get("/operations")
|
||||
api = client.get("/api/admin/operations-dashboard?hours=24")
|
||||
|
||||
assert page.status_code == 200
|
||||
assert "运行大屏" in page.text
|
||||
assert api.status_code == 200
|
||||
data = api.json()
|
||||
assert "overall" in data
|
||||
assert "scheduler" in data
|
||||
assert "data_sources" in data
|
||||
assert "funnel" in data
|
||||
assert "trading" in data
|
||||
assert "timeline" in data
|
||||
|
||||
|
||||
def test_operations_dashboard_read_model_shape(pg_conn):
|
||||
data = get_operations_dashboard(hours=24)
|
||||
|
||||
assert data["hours"] == 24
|
||||
assert data["overall"]["status"] in {"ok", "warn", "danger"}
|
||||
assert isinstance(data["scheduler"], list)
|
||||
assert isinstance(data["data_sources"], list)
|
||||
assert isinstance(data["funnel"], list)
|
||||
assert isinstance(data["trading"], dict)
|
||||
|
||||
|
||||
def test_operations_dashboard_sanitizes_provider_errors():
|
||||
summary = _display_error_summary(
|
||||
"ethereum:nodereal_raw_logs:HTTPSConnectionPool(host='eth-mainnet.nodereal.io', port=443): "
|
||||
"Max retries exceeded with url: /v1/secret (Caused by NameResolutionError())"
|
||||
)
|
||||
|
||||
assert summary == "NodeReal 链上数据源连接异常"
|
||||
assert "HTTPSConnectionPool" not in summary
|
||||
assert "/v1/" not in summary
|
||||
@ -15,6 +15,9 @@ def test_limit_order_touched_for_long_and_short():
|
||||
assert order_touched({"side": "long", "target_price": 95}, 96) is False
|
||||
assert order_touched({"side": "short", "target_price": 105}, 105.1) is True
|
||||
assert order_touched({"side": "short", "target_price": 105}, 104.9) is False
|
||||
assert order_touched({"side": "空", "target_price": 105}, 105.1) is True
|
||||
assert order_touched({"side": "做空", "target_price": 105}, 105.1) is True
|
||||
assert order_touched({"side": "sell", "target_price": 105}, 105.1) is True
|
||||
|
||||
|
||||
def test_limit_order_cancel_when_expired():
|
||||
@ -49,6 +52,7 @@ def test_order_rr_and_distance_are_side_aware():
|
||||
assert order_rr("short", 105, 110, 95) == pytest.approx(2.0)
|
||||
assert order_distance_pct("long", 100, 95) == pytest.approx(5.2631578947)
|
||||
assert order_distance_pct("short", 100, 105) == pytest.approx(5.0)
|
||||
assert order_distance_pct("空", 100, 105) == pytest.approx(5.0)
|
||||
|
||||
|
||||
def test_order_expires_at_uses_minimum_quarter_hour():
|
||||
|
||||
@ -823,8 +823,9 @@ def test_touched_wait_pullback_order_cancels_when_global_risk_pauses(monkeypatch
|
||||
"app.db.paper_trading.evaluate_global_risk",
|
||||
lambda **kwargs: {
|
||||
"allow_new_entries": False,
|
||||
"decision": "block_critical_weak_score",
|
||||
"decision": "block_critical",
|
||||
"risk_level": "critical",
|
||||
"position_multiplier": 0,
|
||||
"reasons": ["critical 市场环境下推荐分不足"],
|
||||
},
|
||||
)
|
||||
@ -856,12 +857,59 @@ def test_touched_wait_pullback_order_cancels_when_global_risk_pauses(monkeypatch
|
||||
paused = sync_recommendation(rec, 94.9, event_time="2026-05-16T10:05:00")
|
||||
|
||||
assert created["reason"] == "paper_order_created"
|
||||
assert paused["reason"] == "paper_order_risk_paused_at_touch"
|
||||
assert paused["reason"] == "paper_order_touch_position_multiplier_zero"
|
||||
assert list_paper_trades()["total"] == 0
|
||||
assert list_paper_orders(status="pending")["total"] == 0
|
||||
canceled = list_paper_orders(status="canceled")["items"][0]
|
||||
assert canceled["symbol"] == "RISKPAUSE/USDT"
|
||||
assert canceled["cancel_reason"] == "risk_paused_at_touch"
|
||||
assert canceled["cancel_reason"] == "touch_position_multiplier_zero"
|
||||
|
||||
|
||||
def test_touched_order_soft_global_risk_gate_fills_instead_of_canceling(monkeypatch):
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", "1")
|
||||
monkeypatch.setattr(
|
||||
"app.db.paper_trading.evaluate_global_risk",
|
||||
lambda **kwargs: {
|
||||
"allow_new_entries": False,
|
||||
"decision": "block_high_weak_score",
|
||||
"risk_level": "high",
|
||||
"position_multiplier": 0.5,
|
||||
"reasons": ["高风险环境下推荐分不足"],
|
||||
},
|
||||
)
|
||||
altcoin_db.init_db()
|
||||
rec_id = altcoin_db.create_recommendation(
|
||||
symbol="SOFTOUCH/USDT",
|
||||
rec_state="蓄力",
|
||||
rec_score=60,
|
||||
entry_price=95,
|
||||
stop_loss=90,
|
||||
tp1=105,
|
||||
tp2=112,
|
||||
signals=["等待回踩"],
|
||||
entry_plan={"side": "long", "entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "risk_reward_ok": True, "rr1": 2.0},
|
||||
)
|
||||
rec = {
|
||||
"id": rec_id,
|
||||
"symbol": "SOFTOUCH/USDT",
|
||||
"rec_score": 60,
|
||||
"execution_status": "wait_pullback",
|
||||
"action_status": "等回踩",
|
||||
"entry_price": 95,
|
||||
"stop_loss": 90,
|
||||
"tp1": 105,
|
||||
"tp2": 112,
|
||||
"entry_plan": {"side": "long", "entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "risk_reward_ok": True, "rr1": 2.0},
|
||||
}
|
||||
|
||||
sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
||||
result = sync_recommendation(rec, 94.9, event_time="2026-05-16T10:05:00")
|
||||
|
||||
assert result.get("opened") is True
|
||||
assert result["global_risk"]["touch_soft_gate_overridden"] is True
|
||||
assert list_paper_orders(status="canceled")["total"] == 0
|
||||
assert list_paper_trades()["total"] == 1
|
||||
|
||||
|
||||
def test_touched_order_uses_order_snapshot_side_for_global_risk(monkeypatch, pg_conn):
|
||||
|
||||
@ -334,6 +334,20 @@ def test_pipeline_page_nav_hides_watchlist_entry_and_watchlist_route_survives(te
|
||||
assert watch_resp.status_code == 200
|
||||
|
||||
|
||||
def test_sidebar_keeps_engineering_pages_in_admin_menu(temp_db):
|
||||
client = TestClient(web_server.app)
|
||||
|
||||
resp = client.get("/app")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
assert "机会中心" in html
|
||||
assert "诊断中心" in html
|
||||
assert 'href="/llm-insights"' not in html
|
||||
assert 'href="/data-export"' not in html
|
||||
assert 'href="/strategy"' not in html
|
||||
assert 'href="/iteration"' not in html
|
||||
|
||||
|
||||
def test_pipeline_page_filters_missed_rows_as_missed(temp_db):
|
||||
client = TestClient(web_server.app)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user