1b3f661bad
Set order monitor and trade record source to trend pullback after handoff; unify hub and instance display; add migration script for legacy rows. Co-authored-by: Cursor <cursoragent@cursor.com>
494 lines
15 KiB
Python
494 lines
15 KiB
Python
"""
|
||
各 crypto_monitor_* 注册 /api/hub/* JSON 接口,供 manual_trading_hub 调用。
|
||
实例末尾:app.config["HUB_CTX"] = {...}; register_hub_routes(app)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import time
|
||
from functools import wraps
|
||
|
||
from flask import (
|
||
current_app,
|
||
flash,
|
||
get_flashed_messages,
|
||
jsonify,
|
||
redirect,
|
||
request,
|
||
session,
|
||
)
|
||
|
||
from hub_auth import request_allowed
|
||
from hub_sso import (
|
||
mint_hub_embed_bootstrap,
|
||
safe_next_path,
|
||
verify_hub_embed_bootstrap,
|
||
verify_hub_sso_token,
|
||
)
|
||
|
||
|
||
def _hub_auth_required(f):
|
||
@wraps(f)
|
||
def wrapped(*args, **kwargs):
|
||
from flask import current_app as cap
|
||
|
||
auth_disabled = bool(cap.config.get("HUB_AUTH_DISABLED"))
|
||
if not request_allowed(bool(session.get("logged_in")), auth_disabled):
|
||
return jsonify({"ok": False, "msg": "未授权(登录或 HUB_BRIDGE_TOKEN)"}), 401
|
||
return f(*args, **kwargs)
|
||
|
||
return wrapped
|
||
|
||
|
||
def _ctx():
|
||
return current_app.config.get("HUB_CTX") or {}
|
||
|
||
|
||
def _row_to_dict(row):
|
||
fn = _ctx().get("row_to_dict")
|
||
if fn and row is not None:
|
||
return fn(row)
|
||
return dict(row) if row is not None else {}
|
||
|
||
|
||
def build_hub_monitor_payload(
|
||
*,
|
||
keys,
|
||
orders,
|
||
trends,
|
||
rolls,
|
||
enrich=None,
|
||
) -> dict:
|
||
"""合并 enrich 增量字段;enrich 只返回 trends 等局部时不得丢掉 keys/orders。"""
|
||
payload = {
|
||
"ok": True,
|
||
"keys": keys,
|
||
"orders": orders,
|
||
"trends": trends,
|
||
"rolls": rolls,
|
||
"key_prices": [],
|
||
}
|
||
if callable(enrich):
|
||
extra = enrich(keys=keys, orders=orders, trends=trends, rolls=rolls)
|
||
if isinstance(extra, dict):
|
||
payload.update(extra)
|
||
return payload
|
||
|
||
|
||
_FAIL_HINTS = (
|
||
"失败",
|
||
"错误",
|
||
"拒绝",
|
||
"无效",
|
||
"缺少",
|
||
"无法",
|
||
"过期",
|
||
"未达",
|
||
"不能为空",
|
||
"已有",
|
||
"不允许",
|
||
"异常",
|
||
)
|
||
|
||
|
||
def _invoke_view(view_name: str, path: str, form=None) -> dict:
|
||
views = _ctx().get("views") or {}
|
||
view = views.get(view_name)
|
||
if not view:
|
||
return {"ok": False, "messages": [f"未配置视图 {view_name}"]}
|
||
data = form if form is not None else request.form
|
||
if hasattr(data, "items") and not isinstance(data, dict):
|
||
data = {k: v for k, v in data.items()}
|
||
with current_app.test_request_context(path, method="POST", data=data):
|
||
session["logged_in"] = True
|
||
try:
|
||
view()
|
||
except Exception as e:
|
||
return {"ok": False, "messages": [str(e)]}
|
||
try:
|
||
msgs = [str(x) for x in get_flashed_messages()]
|
||
except Exception as e:
|
||
return {"ok": False, "messages": [f"读取提示信息失败: {e}"]}
|
||
ok = True
|
||
for m in msgs:
|
||
if any(k in m for k in _FAIL_HINTS):
|
||
ok = False
|
||
break
|
||
return {"ok": ok, "messages": msgs}
|
||
|
||
|
||
def _hub_json(view_name: str, path: str, form=None):
|
||
try:
|
||
return jsonify(_invoke_view(view_name, path, form=form))
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "messages": [str(e)]})
|
||
|
||
|
||
def install_on_app(
|
||
app,
|
||
*,
|
||
exchange: str,
|
||
capabilities: list,
|
||
has_trend: bool,
|
||
get_db,
|
||
row_to_dict,
|
||
meta_fn,
|
||
views: dict,
|
||
ohlcv_fn=None,
|
||
):
|
||
app.config["HUB_CTX"] = {
|
||
"exchange": exchange,
|
||
"capabilities": list(capabilities),
|
||
"has_trend": bool(has_trend),
|
||
"get_db": get_db,
|
||
"row_to_dict": row_to_dict,
|
||
"meta_fn": meta_fn,
|
||
"views": views,
|
||
"ohlcv_fn": ohlcv_fn,
|
||
}
|
||
install_hub_embed_headers(app)
|
||
configure_hub_embed_session(app)
|
||
register_hub_routes(app)
|
||
|
||
|
||
def configure_hub_embed_session(app):
|
||
"""HTTPS iframe 内嵌须 SameSite=None + Secure;hub-sso / hub-embed-auth 自动启用。"""
|
||
import os
|
||
|
||
allowed = (os.getenv("APP_ALLOW_HUB_EMBED") or "true").strip().lower() in (
|
||
"1",
|
||
"true",
|
||
"yes",
|
||
"on",
|
||
)
|
||
if not allowed:
|
||
return
|
||
|
||
secure_env = (os.getenv("APP_COOKIE_SECURE") or "auto").strip().lower()
|
||
if secure_env in ("1", "true", "yes", "on"):
|
||
app.config.update(
|
||
SESSION_COOKIE_SECURE=True,
|
||
SESSION_COOKIE_SAMESITE="None",
|
||
SESSION_COOKIE_HTTPONLY=True,
|
||
)
|
||
return
|
||
|
||
@app.before_request
|
||
def _hub_embed_session_cookie():
|
||
if request.path not in ("/hub-sso", "/hub-embed-auth"):
|
||
return
|
||
embed = (request.args.get("embed") or "").strip().lower() in (
|
||
"1",
|
||
"true",
|
||
"yes",
|
||
"on",
|
||
)
|
||
in_iframe = (request.headers.get("Sec-Fetch-Dest") or "").lower() == "iframe"
|
||
if not embed and not in_iframe:
|
||
return
|
||
if not request.is_secure:
|
||
return
|
||
app.config["SESSION_COOKIE_SECURE"] = True
|
||
app.config["SESSION_COOKIE_SAMESITE"] = "None"
|
||
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
||
|
||
|
||
def _sso_wants_embed_auth() -> bool:
|
||
embed = (request.args.get("embed") or "").strip().lower() in (
|
||
"1",
|
||
"true",
|
||
"yes",
|
||
"on",
|
||
)
|
||
in_iframe = (request.headers.get("Sec-Fetch-Dest") or "").lower() == "iframe"
|
||
return bool(embed or in_iframe)
|
||
|
||
|
||
def install_hub_embed_headers(app):
|
||
"""允许复盘中控 iframe 内嵌打开本实例(须与 hub 的 HUB_EMBED_ORIGINS 或域名一致)。"""
|
||
import os
|
||
|
||
allowed = (os.getenv("APP_ALLOW_HUB_EMBED") or "true").strip().lower() in (
|
||
"1",
|
||
"true",
|
||
"yes",
|
||
"on",
|
||
)
|
||
if not allowed:
|
||
return
|
||
origins = (
|
||
(os.getenv("HUB_EMBED_PARENT_ORIGINS") or os.getenv("HUB_EMBED_ORIGINS") or "*")
|
||
.strip()
|
||
)
|
||
|
||
@app.after_request
|
||
def _hub_embed_frame_headers(response):
|
||
if origins == "*":
|
||
response.headers["Content-Security-Policy"] = "frame-ancestors *"
|
||
else:
|
||
response.headers["Content-Security-Policy"] = (
|
||
f"frame-ancestors 'self' {origins}"
|
||
)
|
||
return response
|
||
|
||
|
||
def register_hub_routes(app):
|
||
auth_disabled = False
|
||
try:
|
||
import os
|
||
|
||
auth_disabled = os.getenv("APP_AUTH_DISABLED", "false").lower() in (
|
||
"1",
|
||
"true",
|
||
"yes",
|
||
"on",
|
||
)
|
||
except Exception:
|
||
pass
|
||
app.config.setdefault("HUB_AUTH_DISABLED", auth_disabled)
|
||
|
||
@app.route("/api/hub/ping")
|
||
@_hub_auth_required
|
||
def api_hub_ping():
|
||
c = _ctx()
|
||
return jsonify(
|
||
{
|
||
"ok": True,
|
||
"exchange": c.get("exchange"),
|
||
"capabilities": c.get("capabilities") or [],
|
||
}
|
||
)
|
||
|
||
@app.route("/api/hub/meta")
|
||
@_hub_auth_required
|
||
def api_hub_meta():
|
||
c = _ctx()
|
||
meta_fn = c.get("meta_fn")
|
||
meta = meta_fn() if callable(meta_fn) else {}
|
||
return jsonify({"ok": True, "meta": meta})
|
||
|
||
@app.route("/api/hub/monitor")
|
||
@_hub_auth_required
|
||
def api_hub_monitor():
|
||
c = _ctx()
|
||
get_db = c.get("get_db")
|
||
if not get_db:
|
||
return jsonify({"ok": False, "msg": "HUB_CTX 缺少 get_db"}), 500
|
||
conn = get_db()
|
||
keys = []
|
||
for row in conn.execute("SELECT * FROM key_monitors ORDER BY id DESC").fetchall():
|
||
keys.append(_row_to_dict(row))
|
||
orders = []
|
||
for row in conn.execute(
|
||
"SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC"
|
||
).fetchall():
|
||
od = _row_to_dict(row)
|
||
try:
|
||
from strategy_trade_labels import apply_order_monitor_source_labels
|
||
|
||
od = apply_order_monitor_source_labels(od)
|
||
except Exception:
|
||
pass
|
||
orders.append(od)
|
||
trends = []
|
||
if c.get("has_trend"):
|
||
for row in conn.execute(
|
||
"SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC"
|
||
).fetchall():
|
||
trends.append(_row_to_dict(row))
|
||
rolls = []
|
||
try:
|
||
for row in conn.execute(
|
||
"""SELECT g.* FROM roll_groups g
|
||
INNER JOIN order_monitors m ON m.id = g.order_monitor_id AND m.status='active'
|
||
WHERE g.status='active' ORDER BY g.id DESC"""
|
||
).fetchall():
|
||
rolls.append(_row_to_dict(row))
|
||
except Exception:
|
||
pass
|
||
conn.close()
|
||
enrich = c.get("enrich_monitor")
|
||
if callable(enrich):
|
||
try:
|
||
return jsonify(
|
||
build_hub_monitor_payload(
|
||
keys=keys,
|
||
orders=orders,
|
||
trends=trends,
|
||
rolls=rolls,
|
||
enrich=enrich,
|
||
)
|
||
)
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "msg": str(e)}), 500
|
||
return jsonify(
|
||
build_hub_monitor_payload(
|
||
keys=keys,
|
||
orders=orders,
|
||
trends=trends,
|
||
rolls=rolls,
|
||
)
|
||
)
|
||
|
||
@app.route("/api/hub/ohlcv")
|
||
@_hub_auth_required
|
||
def api_hub_ohlcv():
|
||
fn = _ctx().get("ohlcv_fn")
|
||
if not callable(fn):
|
||
return jsonify({"ok": False, "msg": "该实例未配置 OHLCV 接口"}), 501
|
||
symbol = (request.args.get("symbol") or "").strip()
|
||
timeframe = (request.args.get("timeframe") or "5m").strip()
|
||
since_raw = (request.args.get("since_ms") or "").strip()
|
||
limit_raw = (request.args.get("limit") or "").strip()
|
||
since_ms = None
|
||
if since_raw.isdigit():
|
||
since_ms = int(since_raw)
|
||
limit = 500
|
||
if limit_raw.isdigit():
|
||
limit = int(limit_raw)
|
||
try:
|
||
result = fn(symbol=symbol, timeframe=timeframe, since_ms=since_ms, limit=limit)
|
||
if isinstance(result, dict):
|
||
return jsonify(result)
|
||
return jsonify({"ok": False, "msg": "OHLCV 返回格式无效"}), 500
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "msg": str(e)}), 500
|
||
|
||
@app.route("/api/hub/add_order", methods=["POST"])
|
||
@_hub_auth_required
|
||
def api_hub_add_order():
|
||
return _hub_json("add_order", "/add_order")
|
||
|
||
@app.route("/api/hub/add_key", methods=["POST"])
|
||
@_hub_auth_required
|
||
def api_hub_add_key():
|
||
return _hub_json("add_key", "/add_key")
|
||
|
||
@app.route("/api/hub/trend/preview", methods=["POST"])
|
||
@_hub_auth_required
|
||
def api_hub_trend_preview():
|
||
if not _ctx().get("has_trend"):
|
||
return jsonify({"ok": False, "msg": "该实例无趋势回调"}), 400
|
||
data = _invoke_view("preview_trend_pullback", "/trade")
|
||
pid = _latest_preview_id()
|
||
preview = _fetch_preview(pid) if pid else None
|
||
return jsonify(
|
||
{
|
||
"ok": bool(data.get("ok")),
|
||
"messages": data.get("messages") or [],
|
||
"preview_id": pid,
|
||
"preview": preview,
|
||
}
|
||
)
|
||
|
||
@app.route("/api/hub/trend/execute", methods=["POST"])
|
||
@_hub_auth_required
|
||
def api_hub_trend_execute():
|
||
if not _ctx().get("has_trend"):
|
||
return jsonify({"ok": False, "msg": "该实例无趋势回调"}), 400
|
||
pid = (request.form.get("preview_id") or "").strip()
|
||
if not pid:
|
||
body = request.get_json(silent=True) or {}
|
||
pid = str(body.get("preview_id") or "").strip()
|
||
form = {"preview_id": pid} if pid else {}
|
||
return jsonify(_invoke_view("execute_trend_pullback", "/trade", form=form))
|
||
|
||
@app.route("/api/hub/trend/preview/<pid>")
|
||
@_hub_auth_required
|
||
def api_hub_trend_preview_get(pid):
|
||
if not _ctx().get("has_trend"):
|
||
return jsonify({"ok": False, "msg": "该实例无趋势回调"}), 400
|
||
preview = _fetch_preview(pid)
|
||
if not preview:
|
||
return jsonify({"ok": False, "msg": "预览不存在或已过期"}), 404
|
||
return jsonify({"ok": True, "preview": preview})
|
||
|
||
@app.route("/hub-sso")
|
||
def hub_sso_login():
|
||
"""中控签发的临时链接:写入 session 后跳转,直链访问仍走 /login。"""
|
||
from urllib.parse import urlencode
|
||
|
||
auth_disabled = bool(current_app.config.get("HUB_AUTH_DISABLED"))
|
||
next_arg = request.args.get("next")
|
||
if auth_disabled:
|
||
session["logged_in"] = True
|
||
return redirect(safe_next_path(next_arg))
|
||
ex = str((_ctx().get("exchange") or "")).strip().lower()
|
||
token = (request.args.get("token") or "").strip()
|
||
ok, next_path, err = verify_hub_sso_token(token, ex)
|
||
if ok:
|
||
if _sso_wants_embed_auth() and request.is_secure:
|
||
boot = mint_hub_embed_bootstrap(ex, next_path)
|
||
if boot:
|
||
q = urlencode({"t": boot, "next": next_path, "embed": "1"})
|
||
return redirect(f"/hub-embed-auth?{q}")
|
||
session["logged_in"] = True
|
||
session.modified = True
|
||
return redirect(next_path)
|
||
hint = err or "校验失败"
|
||
flash(
|
||
f"中控 SSO 未生效({hint})。"
|
||
"请确认中控与实例 .env 中 HUB_BRIDGE_TOKEN 一致,"
|
||
f"且中控设置里该账户 key 为「{ex}」。"
|
||
"经本地导航 iframe 打开时,实例须 HTTPS 且可设 APP_COOKIE_SECURE=true。"
|
||
)
|
||
return redirect("/login")
|
||
|
||
@app.route("/hub-embed-auth")
|
||
def hub_embed_auth_login():
|
||
"""LocalNav 等 iframe 内嵌:单独写入 SameSite=None 会话后跳转。"""
|
||
auth_disabled = bool(current_app.config.get("HUB_AUTH_DISABLED"))
|
||
next_arg = request.args.get("next")
|
||
if auth_disabled:
|
||
session["logged_in"] = True
|
||
return redirect(safe_next_path(next_arg))
|
||
ex = str((_ctx().get("exchange") or "")).strip().lower()
|
||
boot = (request.args.get("t") or "").strip()
|
||
ok, next_path, err = verify_hub_embed_bootstrap(boot, ex)
|
||
if ok:
|
||
session["logged_in"] = True
|
||
session.modified = True
|
||
return redirect(next_path)
|
||
hint = err or "校验失败"
|
||
flash(f"iframe 登录未生效({hint})。可点本地导航工具栏「实例免密」重试。")
|
||
return redirect("/login")
|
||
|
||
|
||
def _latest_preview_id():
|
||
get_db = _ctx().get("get_db")
|
||
if not get_db:
|
||
return None
|
||
conn = get_db()
|
||
row = conn.execute(
|
||
"SELECT id FROM trend_pullback_previews ORDER BY created_at DESC LIMIT 1"
|
||
).fetchone()
|
||
conn.close()
|
||
return row["id"] if row else None
|
||
|
||
|
||
def _fetch_preview(pid):
|
||
get_db = _ctx().get("get_db")
|
||
if not get_db or not pid:
|
||
return None
|
||
conn = get_db()
|
||
row = conn.execute(
|
||
"SELECT * FROM trend_pullback_previews WHERE id=?", (pid,)
|
||
).fetchone()
|
||
conn.close()
|
||
if not row:
|
||
return None
|
||
d = _row_to_dict(row)
|
||
now_ms = int(time.time() * 1000)
|
||
d["expires_in_sec"] = max(0, int((int(d.get("expires_at_ms") or 0) - now_ms) / 1000))
|
||
try:
|
||
grid = json.loads(d.get("grid_prices_json") or "[]")
|
||
legs = json.loads(d.get("leg_amounts_json") or "[]")
|
||
d["grid_levels"] = [
|
||
{"i": i + 1, "price": grid[i], "contracts": legs[i] if i < len(legs) else None}
|
||
for i in range(len(grid))
|
||
]
|
||
except Exception:
|
||
d["grid_levels"] = []
|
||
return d
|