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