diff --git a/app/config/system_config.py b/app/config/system_config.py index 6d34486..7705400 100644 --- a/app/config/system_config.py +++ b/app/config/system_config.py @@ -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": "锁利"}, diff --git a/app/db/data_export.py b/app/db/data_export.py new file mode 100644 index 0000000..d1c5d8f --- /dev/null +++ b/app/db/data_export.py @@ -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"] diff --git a/app/db/paper_trading.py b/app/db/paper_trading.py index 1463bc0..3d63ac7 100644 --- a/app/db/paper_trading.py +++ b/app/db/paper_trading.py @@ -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"} diff --git a/app/web/routes_admin.py b/app/web/routes_admin.py index 301b86c..bed770a 100644 --- a/app/web/routes_admin.py +++ b/app/web/routes_admin.py @@ -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) diff --git a/app/web/routes_pages.py b/app/web/routes_pages.py index caadcd0..1012869 100644 --- a/app/web/routes_pages.py +++ b/app/web/routes_pages.py @@ -96,6 +96,17 @@ def build_router(templates, repo_root: Path, stock_report_template: str): return HTMLResponse(content=f"

需要管理员权限

{exc.detail}

返回看板", 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"

需要管理员权限

{exc.detail}

返回看板", 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) diff --git a/static/base.html b/static/base.html index a374b85..9145ad8 100644 --- a/static/base.html +++ b/static/base.html @@ -164,6 +164,7 @@ a { color: inherit; text-decoration: none; } + @@ -188,6 +189,7 @@ a { color: inherit; text-decoration: none; } + diff --git a/static/data_export.html b/static/data_export.html new file mode 100644 index 0000000..0fc87c8 --- /dev/null +++ b/static/data_export.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% block title %}AlphaX Agent — 数据导出{% endblock %} +{% block extra_head_css %} + +{% endblock %} +{% block content %} +
+
+

数据导出

+

把线上最近一段时间的链路、推荐、挂单、成交、复盘、推送和配置快照打包下载。下载后的 ZIP 可以直接拿来做本地分析、策略复盘和功能优化。

+
+ +
+
+
选择导出窗口
+
推荐先导出 24 小时用于快速排查;做策略复盘时导出 7 天或 30 天。导出包是 ZIP,里面按表拆分 JSON 文件。
+
+ + + + +
+
+ + + +
+
当前选择:最近 24 小时。
+
+ + +
+
+{% endblock %} +{% block extra_script %} + +{% endblock %} diff --git a/tests/test_data_export.py b/tests/test_data_export.py new file mode 100644 index 0000000..6730260 --- /dev/null +++ b/tests/test_data_export.py @@ -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 diff --git a/tests/test_paper_trading.py b/tests/test_paper_trading.py index 67374c9..868c3ff 100644 --- a/tests/test_paper_trading.py +++ b/tests/test_paper_trading.py @@ -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")