1
This commit is contained in:
parent
583280294b
commit
c5d8e343ea
@ -110,6 +110,12 @@ def default_paper_trading_config():
|
||||
"trailing_distance_pct": _env_float("ALPHAX_PAPER_TRAILING_DISTANCE_PCT", 1.5),
|
||||
"trailing_move_push_min_interval_seconds": _env_int("ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_INTERVAL_SECONDS", 300),
|
||||
"trailing_move_push_min_step_pct": _env_float("ALPHAX_PAPER_TRAILING_MOVE_PUSH_MIN_STEP_PCT", 2.0),
|
||||
"order_gate_enabled": _env_bool("ALPHAX_PAPER_ORDER_GATE_ENABLED", True),
|
||||
"order_min_rr": _env_float("ALPHAX_PAPER_ORDER_MIN_RR", 1.2),
|
||||
"order_max_distance_to_entry_pct": _env_float("ALPHAX_PAPER_ORDER_MAX_DISTANCE_TO_ENTRY_PCT", 8.0),
|
||||
"order_require_current_trigger": _env_bool("ALPHAX_PAPER_ORDER_REQUIRE_CURRENT_TRIGGER", False),
|
||||
"order_cancel_far_from_entry_pct": _env_float("ALPHAX_PAPER_ORDER_CANCEL_FAR_FROM_ENTRY_PCT", 12.0),
|
||||
"order_expire_hours": _env_float("ALPHAX_PAPER_ORDER_EXPIRE_HOURS", 24.0),
|
||||
"trailing_tiers": [
|
||||
{"min_pnl_pct": 8.0, "distance_pct": 1.0, "label": "紧贴"},
|
||||
{"min_pnl_pct": 5.0, "distance_pct": 1.2, "label": "锁利"},
|
||||
|
||||
125
app/db/data_export.py
Normal file
125
app/db/data_export.py
Normal file
@ -0,0 +1,125 @@
|
||||
"""Admin data export bundles for offline strategy analysis."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from app.db.schema import get_conn
|
||||
|
||||
|
||||
RECENT_TABLES = {
|
||||
"recommendation": ("rec_time", 5000, "recommendations and lifecycle state"),
|
||||
"screening_log": ("scan_time", 10000, "screening funnel rows"),
|
||||
"coin_state": ("detected_at", 5000, "latest detected coin states"),
|
||||
"price_tracking": ("track_time", 10000, "recommendation tracking samples"),
|
||||
"paper_orders": ("created_at", 5000, "pending/filled/canceled order simulation"),
|
||||
"paper_trades": ("opened_at", 5000, "trade ledger"),
|
||||
"paper_trade_events": ("event_time", 10000, "trade lifecycle events"),
|
||||
"cron_run_log": ("started_at", 2000, "scheduler run logs"),
|
||||
"review_log": ("review_time", 2000, "review records"),
|
||||
"missed_explosions": ("detected_at", 5000, "missed explosion review samples"),
|
||||
"strategy_iteration_log": ("created_at", 2000, "strategy iteration history"),
|
||||
"strategy_rule_candidate": ("created_at", 5000, "candidate strategy rules"),
|
||||
"strategy_failure_pattern": ("created_at", 5000, "failure pattern records"),
|
||||
"push_log": ("pushed_at", 5000, "notification/push decisions"),
|
||||
"sentiment_events": ("detected_at", 5000, "sentiment events"),
|
||||
"llm_insights": ("created_at", 5000, "LLM analysis cache"),
|
||||
"event_news": ("detected_at", 5000, "news/event candidates"),
|
||||
"onchain_events": ("detected_at", 5000, "normalized on-chain events"),
|
||||
"latest_price_cache": ("updated_at", 2000, "latest price cache"),
|
||||
}
|
||||
|
||||
SNAPSHOT_TABLES = {
|
||||
"strategy_runtime_config": (1000, "strategy runtime config snapshot"),
|
||||
"system_config": (1000, "system runtime config snapshot"),
|
||||
"scheduler_job_config": (200, "scheduler config snapshot"),
|
||||
}
|
||||
|
||||
|
||||
def _json_default(value):
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.isoformat()
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _rows_to_dicts(rows) -> list[dict]:
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def _fetch_recent(conn, table: str, time_col: str, cutoff: str, limit: int) -> list[dict]:
|
||||
return _rows_to_dicts(
|
||||
conn.execute(
|
||||
f"""
|
||||
SELECT *
|
||||
FROM {table}
|
||||
WHERE {time_col} >= %s
|
||||
ORDER BY {time_col} DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(cutoff, limit),
|
||||
).fetchall()
|
||||
)
|
||||
|
||||
|
||||
def _fetch_snapshot(conn, table: str, limit: int) -> list[dict]:
|
||||
return _rows_to_dicts(conn.execute(f"SELECT * FROM {table} LIMIT %s", (limit,)).fetchall())
|
||||
|
||||
|
||||
def _write_json(zf: zipfile.ZipFile, path: str, payload) -> None:
|
||||
zf.writestr(path, json.dumps(payload, ensure_ascii=False, indent=2, default=_json_default))
|
||||
|
||||
|
||||
def build_data_export_bundle(hours: int = 24) -> tuple[str, bytes, dict]:
|
||||
hours = max(1, min(int(hours or 24), 24 * 90))
|
||||
generated_at = datetime.now()
|
||||
cutoff = (generated_at - timedelta(hours=hours)).isoformat()
|
||||
manifest = {
|
||||
"generated_at": generated_at.isoformat(),
|
||||
"window_hours": hours,
|
||||
"cutoff": cutoff,
|
||||
"format": "json_zip",
|
||||
"tables": {},
|
||||
}
|
||||
buffer = io.BytesIO()
|
||||
conn = get_conn()
|
||||
try:
|
||||
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for table, (time_col, limit, description) in RECENT_TABLES.items():
|
||||
try:
|
||||
rows = _fetch_recent(conn, table, time_col, cutoff, limit)
|
||||
_write_json(zf, f"tables/{table}.json", rows)
|
||||
manifest["tables"][table] = {
|
||||
"mode": "recent",
|
||||
"time_column": time_col,
|
||||
"rows": len(rows),
|
||||
"limit": limit,
|
||||
"description": description,
|
||||
}
|
||||
except Exception as exc:
|
||||
manifest["tables"][table] = {"error": str(exc), "description": description}
|
||||
for table, (limit, description) in SNAPSHOT_TABLES.items():
|
||||
try:
|
||||
rows = _fetch_snapshot(conn, table, limit)
|
||||
_write_json(zf, f"snapshots/{table}.json", rows)
|
||||
manifest["tables"][table] = {
|
||||
"mode": "snapshot",
|
||||
"rows": len(rows),
|
||||
"limit": limit,
|
||||
"description": description,
|
||||
}
|
||||
except Exception as exc:
|
||||
manifest["tables"][table] = {"error": str(exc), "description": description}
|
||||
_write_json(zf, "manifest.json", manifest)
|
||||
finally:
|
||||
conn.close()
|
||||
filename = f"alphax_export_{generated_at.strftime('%Y%m%d_%H%M%S')}_{hours}h.zip"
|
||||
return filename, buffer.getvalue(), manifest
|
||||
|
||||
|
||||
__all__ = ["build_data_export_bundle"]
|
||||
@ -512,6 +512,83 @@ def _paper_order_too_far(order: dict, current_price: float, config: dict | None
|
||||
return current_price > target * (1 + threshold_pct / 100)
|
||||
|
||||
|
||||
def _paper_order_rr(side: str, target: float, stop_loss: float, tp1: float) -> float:
|
||||
if side == "short":
|
||||
risk = stop_loss - target
|
||||
reward = target - tp1
|
||||
else:
|
||||
risk = target - stop_loss
|
||||
reward = tp1 - target
|
||||
if risk <= 0 or reward <= 0:
|
||||
return 0.0
|
||||
return reward / risk
|
||||
|
||||
|
||||
def _paper_order_distance_pct(side: str, current_price: float, target: float) -> float:
|
||||
if target <= 0 or current_price <= 0:
|
||||
return 999.0
|
||||
if side == "short":
|
||||
return max(0.0, (target / current_price - 1) * 100)
|
||||
return max(0.0, (current_price / target - 1) * 100)
|
||||
|
||||
|
||||
def _paper_order_gate(rec: dict, current_price: float, config: dict | None = None) -> tuple[bool, list[str], dict]:
|
||||
cfg = _paper_cfg(config)
|
||||
if not bool(cfg.get("order_gate_enabled", True)):
|
||||
return True, [], {"gate_enabled": False}
|
||||
|
||||
plan = _entry_plan(rec)
|
||||
side = str(plan.get("side") or rec.get("side") or "long").strip().lower() or "long"
|
||||
target = _paper_order_target_price(rec)
|
||||
stop_loss = _safe_float(rec.get("stop_loss") or plan.get("stop_loss"))
|
||||
tp1 = _safe_float(rec.get("tp1") or plan.get("tp1") or plan.get("take_profit_1"))
|
||||
rr = _safe_float(plan.get("rr1") or plan.get("rr1_live"))
|
||||
calc_rr = _paper_order_rr(side, target, stop_loss, tp1)
|
||||
effective_rr = rr if rr > 0 else calc_rr
|
||||
min_rr = max(0.0, _safe_float(cfg.get("order_min_rr"), 1.2))
|
||||
distance_pct = _paper_order_distance_pct(side, current_price, target)
|
||||
max_distance = max(0.0, _safe_float(cfg.get("order_max_distance_to_entry_pct"), 8.0))
|
||||
opportunity_level = str(plan.get("opportunity_level") or rec.get("opportunity_level") or "").strip()
|
||||
level_max_action = str(plan.get("max_action") or "").strip()
|
||||
risk_reward_ok = plan.get("risk_reward_ok")
|
||||
trigger_ok = plan.get("entry_trigger_confirmed") is True or _safe_int(rec.get("entry_triggered")) == 1
|
||||
|
||||
reasons = []
|
||||
if target <= 0:
|
||||
reasons.append("missing_target_price")
|
||||
if stop_loss <= 0:
|
||||
reasons.append("missing_stop_loss")
|
||||
if tp1 <= 0:
|
||||
reasons.append("missing_tp1")
|
||||
if target > 0 and stop_loss > 0 and tp1 > 0 and calc_rr <= 0:
|
||||
reasons.append("invalid_risk_geometry")
|
||||
if risk_reward_ok is False:
|
||||
reasons.append("risk_reward_rejected")
|
||||
if effective_rr > 0 and effective_rr < min_rr:
|
||||
reasons.append("rr_below_min")
|
||||
if effective_rr <= 0:
|
||||
reasons.append("missing_rr")
|
||||
if distance_pct > max_distance:
|
||||
reasons.append("too_far_from_entry")
|
||||
if opportunity_level in {"momentum_watch", "theme_trend"} or level_max_action == "observe":
|
||||
reasons.append("observe_only_opportunity")
|
||||
if bool(cfg.get("order_require_current_trigger", False)) and not trigger_ok:
|
||||
reasons.append("missing_current_trigger")
|
||||
|
||||
return not reasons, reasons, {
|
||||
"target_price": target,
|
||||
"stop_loss": stop_loss,
|
||||
"tp1": tp1,
|
||||
"rr1": round(effective_rr, 4) if effective_rr > 0 else 0,
|
||||
"calc_rr1": round(calc_rr, 4) if calc_rr > 0 else 0,
|
||||
"distance_to_entry_pct": round(distance_pct, 4),
|
||||
"max_distance_to_entry_pct": max_distance,
|
||||
"min_rr": min_rr,
|
||||
"opportunity_level": opportunity_level,
|
||||
"entry_trigger_confirmed": trigger_ok,
|
||||
}
|
||||
|
||||
|
||||
def _cancel_paper_order(conn, order: dict, reason: str, event_time: str) -> dict:
|
||||
conn.execute(
|
||||
"""
|
||||
@ -661,6 +738,17 @@ def _sync_wait_pullback_order(conn, rec: dict, current_price: float, event_time:
|
||||
"current_price": current_price,
|
||||
}
|
||||
|
||||
gate_ok, gate_reasons, gate_detail = _paper_order_gate(rec, current_price, cfg)
|
||||
if not gate_ok:
|
||||
return {
|
||||
"skipped": True,
|
||||
"reason": "paper_order_gate_rejected",
|
||||
"gate_reasons": gate_reasons,
|
||||
"gate_detail": gate_detail,
|
||||
"target_price": gate_detail.get("target_price"),
|
||||
"current_price": current_price,
|
||||
}
|
||||
|
||||
payload = _order_payload_from_rec(rec, current_price, event_time, cfg)
|
||||
if payload["recommendation_id"] <= 0 or not payload["symbol"] or payload["target_price"] <= 0:
|
||||
return {"skipped": True, "reason": "invalid_paper_order"}
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
from fastapi import APIRouter, Cookie, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
|
||||
from app.config.system_config import seed_runtime_system_defaults
|
||||
from app.db import auth_db
|
||||
from app.db import chat_assistant_db
|
||||
from app.db.data_export import build_data_export_bundle
|
||||
from app.db.scheduler_db import (
|
||||
enqueue_manual_trigger,
|
||||
get_job_config,
|
||||
@ -102,6 +103,16 @@ def build_router(templates):
|
||||
require_admin(altcoin_session)
|
||||
return chat_assistant_db.list_chat_admin_questions(hours=hours, intent=intent, search=search, offset=offset, limit=limit)
|
||||
|
||||
@router.get("/api/admin/data-export")
|
||||
async def api_admin_data_export(hours: int = 24, altcoin_session: str = Cookie(default="")):
|
||||
require_admin(altcoin_session)
|
||||
filename, content, _manifest = build_data_export_bundle(hours=hours)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
@router.get("/api/runtime-config")
|
||||
async def api_runtime_config(kind: str = "all", altcoin_session: str = Cookie(default="")):
|
||||
require_admin(altcoin_session)
|
||||
|
||||
@ -96,6 +96,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("system_logs.html", request, active_nav="system_logs")
|
||||
|
||||
@router.get("/data-export", response_class=HTMLResponse)
|
||||
async def data_export_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 render_page("data_export.html", request, active_nav="data_export")
|
||||
|
||||
@router.get("/chat-logs", response_class=HTMLResponse)
|
||||
async def chat_logs_page(request: Request):
|
||||
user, redirect = require_page_user(request)
|
||||
|
||||
@ -164,6 +164,7 @@ a { color: inherit; text-decoration: none; }
|
||||
<symbol id="svg-admin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M5.3 20h13.4c1.1 0 2-.9 2-2 0-3.3-2.7-6-6-6H9.3c-3.3 0-6 2.7-6 6 0 1.1.9 2 2 2z"/></symbol>
|
||||
<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-chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></symbol>
|
||||
</svg>
|
||||
|
||||
@ -188,6 +189,7 @@ a { color: inherit; text-decoration: none; }
|
||||
<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 == 'strategy' %}active{% endif %}" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略归因</a>
|
||||
<a class="sidebar-link admin-link {% if active_nav == 'iteration' %}active{% endif %}" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>策略迭代</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 == 'chat_logs' %}active{% endif %}" href="/chat-logs" style="display:none"><svg class="link-icon"><use href="#svg-chat"/></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>
|
||||
|
||||
54
static/data_export.html
Normal file
54
static/data_export.html
Normal file
@ -0,0 +1,54 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}AlphaX Agent — 数据导出{% endblock %}
|
||||
{% block extra_head_css %}
|
||||
<style>
|
||||
.shell{width:min(100% - 40px,1120px);margin:0 auto;padding:28px 0 56px}.hero{position:relative;overflow:hidden;border:1px solid var(--hairline-soft);border-radius:24px;background:linear-gradient(135deg,#0f172a 0%,#1f3a5f 52%,#0f766e 100%);padding:26px;color:white;box-shadow:0 24px 70px rgba(15,23,42,.18)}.hero:after{content:"";position:absolute;right:-80px;top:-80px;width:260px;height:260px;border-radius:999px;background:rgba(255,255,255,.14);filter:blur(4px)}.hero h1{position:relative;font-size:30px;font-weight:950;letter-spacing:-.9px;margin:0}.hero p{position:relative;margin:8px 0 0;max-width:760px;color:rgba(255,255,255,.78);line-height:1.65;font-size:13px}.grid{display:grid;grid-template-columns:1fr 360px;gap:14px;margin-top:14px}.panel{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-md);padding:16px}.panel-title{font-size:15px;font-weight:950;color:var(--ink);margin-bottom:8px}.hint{color:var(--stone);font-size:12px;line-height:1.6}.quick{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-top:14px}.choice{border:1px solid var(--hairline-soft);background:var(--surface);border-radius:16px;padding:14px;text-align:left;cursor:pointer;transition:.15s}.choice:hover,.choice.active{border-color:rgba(66,98,255,.35);background:rgba(66,98,255,.055);transform:translateY(-1px)}.choice b{display:block;font-size:18px;color:var(--ink);font-weight:950}.choice span{display:block;margin-top:4px;color:var(--stone);font-size:11px;font-weight:850}.custom{display:flex;gap:8px;align-items:center;margin-top:14px}.input{height:40px;border:1px solid var(--hairline-strong);border-radius:12px;background:var(--canvas);padding:0 12px;color:var(--ink);font-weight:850;width:110px}.btn{height:42px;border:0;border-radius:999px;background:var(--ink);color:var(--canvas);padding:0 18px;font-weight:950;cursor:pointer}.btn:disabled{opacity:.55;cursor:default}.status{margin-top:12px;border:1px solid rgba(66,98,255,.16);background:rgba(66,98,255,.05);border-radius:14px;padding:11px;color:var(--slate);font-size:12px;line-height:1.55}.list{display:grid;gap:8px;margin-top:10px}.item{display:flex;justify-content:space-between;gap:12px;border-bottom:1px solid var(--hairline-soft);padding:9px 0;font-size:12px}.item:last-child{border-bottom:0}.item b{color:var(--ink)}.item span{color:var(--stone);text-align:right}@media(max-width:900px){.grid{grid-template-columns:1fr}.quick{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:560px){.shell{width:min(100% - 24px,1120px)}.quick{grid-template-columns:1fr}.custom{align-items:stretch;flex-direction:column}.input{width:100%}}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="shell">
|
||||
<section class="hero">
|
||||
<h1>数据导出</h1>
|
||||
<p>把线上最近一段时间的链路、推荐、挂单、成交、复盘、推送和配置快照打包下载。下载后的 ZIP 可以直接拿来做本地分析、策略复盘和功能优化。</p>
|
||||
</section>
|
||||
|
||||
<div class="grid">
|
||||
<section class="panel">
|
||||
<div class="panel-title">选择导出窗口</div>
|
||||
<div class="hint">推荐先导出 24 小时用于快速排查;做策略复盘时导出 7 天或 30 天。导出包是 ZIP,里面按表拆分 JSON 文件。</div>
|
||||
<div class="quick" id="quickChoices">
|
||||
<button class="choice active" type="button" data-hours="24" onclick="chooseHours(24,this)"><b>24 小时</b><span>最近链路排查</span></button>
|
||||
<button class="choice" type="button" data-hours="72" onclick="chooseHours(72,this)"><b>3 天</b><span>短周期异常</span></button>
|
||||
<button class="choice" type="button" data-hours="168" onclick="chooseHours(168,this)"><b>7 天</b><span>策略小复盘</span></button>
|
||||
<button class="choice" type="button" data-hours="720" onclick="chooseHours(720,this)"><b>30 天</b><span>完整表现分析</span></button>
|
||||
</div>
|
||||
<div class="custom">
|
||||
<input class="input" id="customDays" type="number" min="1" max="90" value="1">
|
||||
<span class="hint">天</span>
|
||||
<button class="btn" id="downloadBtn" type="button" onclick="downloadExport()">下载导出包</button>
|
||||
</div>
|
||||
<div class="status" id="exportStatus">当前选择:最近 24 小时。</div>
|
||||
</section>
|
||||
|
||||
<aside class="panel">
|
||||
<div class="panel-title">导出内容</div>
|
||||
<div class="list">
|
||||
<div class="item"><b>推荐链路</b><span>recommendation / screening_log / cron_run_log</span></div>
|
||||
<div class="item"><b>交易账本</b><span>paper_orders / paper_trades / events</span></div>
|
||||
<div class="item"><b>复盘迭代</b><span>review / missed / strategy rules</span></div>
|
||||
<div class="item"><b>证据源</b><span>舆情 / 链上 / AI 记录</span></div>
|
||||
<div class="item"><b>运行配置</b><span>策略与系统配置快照</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_script %}
|
||||
<script>
|
||||
var selectedHours=24;
|
||||
function $(id){return document.getElementById(id)}
|
||||
function chooseHours(hours,el){selectedHours=hours;Array.prototype.forEach.call(document.querySelectorAll('.choice'),function(x){x.classList.remove('active')});if(el)el.classList.add('active');$('customDays').value=Math.max(1,Math.round(hours/24));$('exportStatus').textContent='当前选择:最近 '+labelHours(hours)+'。'}
|
||||
function labelHours(hours){if(hours===24)return'24 小时';if(hours%24===0)return (hours/24)+' 天';return hours+' 小时'}
|
||||
async function downloadExport(){var days=Number($('customDays').value||1);if(days>0)selectedHours=Math.max(1,Math.min(2160,Math.round(days*24)));var btn=$('downloadBtn');btn.disabled=true;btn.textContent='打包中...';$('exportStatus').textContent='正在生成最近 '+labelHours(selectedHours)+' 的导出包,请稍等...';try{var resp=await fetch('/api/admin/data-export?hours='+encodeURIComponent(selectedHours));if(!resp.ok){var t=await resp.text();throw new Error(t||'导出失败')}var blob=await resp.blob();var name='alphax_export_'+selectedHours+'h.zip';var cd=resp.headers.get('content-disposition')||'';var m=cd.match(/filename="([^"]+)"/);if(m)name=m[1];var url=URL.createObjectURL(blob);var a=document.createElement('a');a.href=url;a.download=name;document.body.appendChild(a);a.click();a.remove();URL.revokeObjectURL(url);$('exportStatus').textContent='导出完成:'+name+'。下载后可以把 ZIP 给我做本地分析。'}catch(e){$('exportStatus').textContent='导出失败:'+e.message}finally{btn.disabled=false;btn.textContent='下载导出包'}}
|
||||
</script>
|
||||
{% endblock %}
|
||||
67
tests/test_data_export.py
Normal file
67
tests/test_data_export.py
Normal file
@ -0,0 +1,67 @@
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.db import altcoin_db, auth_db
|
||||
from app.db.data_export import build_data_export_bundle
|
||||
from app.web import web_server
|
||||
|
||||
|
||||
def _login_admin(email: str = "admin-export@example.com") -> str:
|
||||
reg = auth_db.register_user(email, "StrongPass123")
|
||||
auth_db.verify_email(email, reg["verification_code"])
|
||||
auth_db.claim_free_trial(auth_db.get_user_by_email(email)["id"])
|
||||
auth_db.set_user_admin(email, True)
|
||||
return auth_db.login_user(email, "StrongPass123")["token"]
|
||||
|
||||
|
||||
def test_build_data_export_bundle_contains_manifest_and_recent_tables():
|
||||
altcoin_db.create_recommendation(
|
||||
symbol="EXPORT/USDT",
|
||||
rec_state="爆发",
|
||||
rec_score=30,
|
||||
entry_price=100,
|
||||
stop_loss=95,
|
||||
tp1=110,
|
||||
signals=["导出测试"],
|
||||
entry_plan={"entry_action": "可即刻买入", "entry_price": 100},
|
||||
)
|
||||
|
||||
filename, content, manifest = build_data_export_bundle(hours=24)
|
||||
|
||||
assert filename.endswith("_24h.zip")
|
||||
assert manifest["window_hours"] == 24
|
||||
with zipfile.ZipFile(io.BytesIO(content)) as zf:
|
||||
names = set(zf.namelist())
|
||||
assert "manifest.json" in names
|
||||
assert "tables/recommendation.json" in names
|
||||
assert "tables/paper_trades.json" in names
|
||||
loaded_manifest = json.loads(zf.read("manifest.json").decode("utf-8"))
|
||||
rows = json.loads(zf.read("tables/recommendation.json").decode("utf-8"))
|
||||
|
||||
assert loaded_manifest["tables"]["recommendation"]["rows"] >= 1
|
||||
assert any(row["symbol"] == "EXPORT/USDT" for row in rows)
|
||||
|
||||
|
||||
def test_admin_data_export_api_downloads_zip():
|
||||
token = _login_admin()
|
||||
client = TestClient(web_server.app)
|
||||
client.cookies.set("altcoin_session", token)
|
||||
|
||||
resp = client.get("/api/admin/data-export?hours=24")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "application/zip"
|
||||
assert "attachment" in resp.headers["content-disposition"]
|
||||
with zipfile.ZipFile(io.BytesIO(resp.content)) as zf:
|
||||
assert "manifest.json" in zf.namelist()
|
||||
|
||||
|
||||
def test_data_export_page_requires_admin():
|
||||
client = TestClient(web_server.app)
|
||||
resp = client.get("/data-export")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "登录" in resp.text or "会员" in resp.text
|
||||
@ -174,7 +174,7 @@ def test_wait_pullback_creates_pending_paper_order(monkeypatch):
|
||||
tp1=105,
|
||||
tp2=112,
|
||||
signals=["等待回踩"],
|
||||
entry_plan={"entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105},
|
||||
entry_plan={"entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "risk_reward_ok": True, "rr1": 2.0},
|
||||
)
|
||||
with altcoin_db.get_conn() as conn:
|
||||
conn.execute(
|
||||
@ -182,7 +182,7 @@ def test_wait_pullback_creates_pending_paper_order(monkeypatch):
|
||||
(rec_id,),
|
||||
)
|
||||
conn.commit()
|
||||
rec = {"id": rec_id, "symbol": "WAIT/USDT", "execution_status": "wait_pullback", "action_status": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "tp2": 112, "entry_plan": {"entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105}}
|
||||
rec = {"id": rec_id, "symbol": "WAIT/USDT", "execution_status": "wait_pullback", "action_status": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "tp2": 112, "entry_plan": {"entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "risk_reward_ok": True, "rr1": 2.0}}
|
||||
|
||||
result = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
||||
|
||||
@ -209,9 +209,9 @@ def test_wait_pullback_paper_order_pushes_created_card(monkeypatch):
|
||||
stop_loss=90,
|
||||
tp1=105,
|
||||
signals=["等待回踩"],
|
||||
entry_plan={"entry_action": "等回踩", "entry_price": 95},
|
||||
entry_plan={"entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "risk_reward_ok": True, "rr1": 2.0},
|
||||
)
|
||||
rec = {"id": rec_id, "symbol": "PUSHORD/USDT", "execution_status": "wait_pullback", "action_status": "等回踩", "entry_price": 95, "entry_plan": {"entry_action": "等回踩", "entry_price": 95}}
|
||||
rec = {"id": rec_id, "symbol": "PUSHORD/USDT", "execution_status": "wait_pullback", "action_status": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "entry_plan": {"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")
|
||||
|
||||
@ -223,6 +223,80 @@ def test_wait_pullback_paper_order_pushes_created_card(monkeypatch):
|
||||
assert card["elements"][0]["tag"] == "column_set"
|
||||
|
||||
|
||||
def test_wait_pullback_without_tradeable_plan_does_not_create_order(monkeypatch):
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
||||
altcoin_db.init_db()
|
||||
rec_id = altcoin_db.create_recommendation(
|
||||
symbol="BADPLAN/USDT",
|
||||
rec_state="蓄力",
|
||||
rec_score=20,
|
||||
entry_price=95,
|
||||
signals=["等待回踩"],
|
||||
entry_plan={"entry_action": "等回踩", "entry_price": 95},
|
||||
)
|
||||
rec = {"id": rec_id, "symbol": "BADPLAN/USDT", "execution_status": "wait_pullback", "action_status": "等回踩", "entry_price": 95, "entry_plan": {"entry_action": "等回踩", "entry_price": 95}}
|
||||
|
||||
result = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
||||
|
||||
assert result["reason"] == "paper_order_gate_rejected"
|
||||
assert "missing_stop_loss" in result["gate_reasons"]
|
||||
assert "missing_tp1" in result["gate_reasons"]
|
||||
assert list_paper_orders()["total"] == 0
|
||||
|
||||
|
||||
def test_wait_pullback_too_far_from_entry_does_not_create_order(monkeypatch):
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
||||
altcoin_db.init_db()
|
||||
rec_id = altcoin_db.create_recommendation(
|
||||
symbol="FARGATE/USDT",
|
||||
rec_state="蓄力",
|
||||
rec_score=22,
|
||||
entry_price=95,
|
||||
stop_loss=90,
|
||||
tp1=105,
|
||||
signals=["等待回踩"],
|
||||
entry_plan={"entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "risk_reward_ok": True, "rr1": 2.0},
|
||||
)
|
||||
rec = {"id": rec_id, "symbol": "FARGATE/USDT", "execution_status": "wait_pullback", "action_status": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "entry_plan": {"entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "risk_reward_ok": True, "rr1": 2.0}}
|
||||
|
||||
result = sync_recommendation(rec, 110, event_time="2026-05-16T10:00:00")
|
||||
|
||||
assert result["reason"] == "paper_order_gate_rejected"
|
||||
assert "too_far_from_entry" in result["gate_reasons"]
|
||||
assert result["gate_detail"]["distance_to_entry_pct"] > 8
|
||||
assert list_paper_orders()["total"] == 0
|
||||
|
||||
|
||||
def test_observe_only_wait_pullback_does_not_create_order(monkeypatch):
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
||||
altcoin_db.init_db()
|
||||
rec_id = altcoin_db.create_recommendation(
|
||||
symbol="OBSWAIT/USDT",
|
||||
rec_state="蓄力",
|
||||
rec_score=22,
|
||||
entry_price=95,
|
||||
stop_loss=90,
|
||||
tp1=105,
|
||||
signals=["等待回踩"],
|
||||
entry_plan={
|
||||
"entry_action": "等回踩",
|
||||
"entry_price": 95,
|
||||
"stop_loss": 90,
|
||||
"tp1": 105,
|
||||
"risk_reward_ok": True,
|
||||
"rr1": 2.0,
|
||||
"opportunity_level": "theme_trend",
|
||||
},
|
||||
)
|
||||
rec = {"id": rec_id, "symbol": "OBSWAIT/USDT", "execution_status": "wait_pullback", "action_status": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "entry_plan": {"entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "risk_reward_ok": True, "rr1": 2.0, "opportunity_level": "theme_trend"}}
|
||||
|
||||
result = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00")
|
||||
|
||||
assert result["reason"] == "paper_order_gate_rejected"
|
||||
assert "observe_only_opportunity" in result["gate_reasons"]
|
||||
assert list_paper_orders()["total"] == 0
|
||||
|
||||
|
||||
def test_wait_pullback_paper_order_fills_when_price_touches(monkeypatch):
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1")
|
||||
monkeypatch.setenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", "100")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user