This commit is contained in:
aaron 2026-05-21 08:42:08 +08:00
parent 583280294b
commit c5d8e343ea
9 changed files with 443 additions and 5 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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