"""crypto_okx_hub — 期权多机中控.""" from __future__ import annotations import os import sys from functools import wraps from pathlib import Path from dotenv import load_dotenv from flask import ( Flask, flash, jsonify, redirect, render_template, request, session, url_for, ) ROOT = Path(__file__).resolve().parent if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) ENV_PATH = ROOT / ".env" load_dotenv(ENV_PATH) from lib.bridge_client_lib import ( dispatch_cond_order_to_ids, fetch_cond_orders, post_close_full, post_cond_cancel, post_cond_exit, pull_all_enabled, ) from lib.db_lib import init_db from lib.env_lib import apply_env_updates from lib.instances_lib import ( create_instance, delete_instance, get_instance, list_instances, update_instance, ) from lib.sso_lib import build_embed_open_url APP_HOST = (os.getenv("APP_HOST") or "0.0.0.0").strip() or "0.0.0.0" APP_PORT = int(os.getenv("APP_PORT") or 5100) SECRET = (os.getenv("FLASK_SECRET_KEY") or "hub-dev-secret").strip() # 可变登录态:系统设置页可改,并立刻生效 _auth = { "username": (os.getenv("HUB_USERNAME") or "admin").strip() or "admin", "password": (os.getenv("HUB_PASSWORD") or "admin123").strip() or "admin123", } app = Flask(__name__) app.secret_key = SECRET app.config["TEMPLATES_AUTO_RELOAD"] = True init_db() def hub_username() -> str: return _auth["username"] def hub_password() -> str: return _auth["password"] def set_hub_credentials(username: str, password: str) -> None: u = (username or "").strip() or "admin" p = password if password is not None else "" _auth["username"] = u _auth["password"] = p os.environ["HUB_USERNAME"] = u os.environ["HUB_PASSWORD"] = p def login_required(f): @wraps(f) def wrapped(*args, **kwargs): if session.get("logged_in"): return f(*args, **kwargs) if request.path.startswith("/api/"): return jsonify({"ok": False, "msg": "未登录"}), 401 return redirect(url_for("login", next=request.path)) return wrapped @app.route("/login", methods=["GET", "POST"]) def login(): if request.method == "POST": u = (request.form.get("username") or "").strip() p = request.form.get("password") or "" if u == hub_username() and p == hub_password(): session["logged_in"] = True session.permanent = True nxt = (request.args.get("next") or request.form.get("next") or "/desk").strip() or "/desk" if not nxt.startswith("/"): nxt = "/desk" return redirect(nxt) flash("用户名或密码错误") if session.get("logged_in"): return redirect("/desk") return render_template("login.html") @app.route("/logout") def logout(): session.clear() return redirect(url_for("login")) @app.route("/") @login_required def dashboard(): return render_template("dashboard.html") @app.route("/instances", methods=["GET", "POST"]) @login_required def instances_page(): if request.method == "POST": action = (request.form.get("action") or "create").strip() if action == "create": _, err = create_instance( name=request.form.get("name") or "", base_url=request.form.get("base_url") or "", bridge_token=request.form.get("bridge_token") or "", embed_path=request.form.get("embed_path") or "/embed", tags=request.form.get("tags") or "", enabled=bool(request.form.get("enabled")), ) flash(err or "已添加") elif action == "update": iid = int(request.form.get("id") or 0) _, err = update_instance( iid, name=request.form.get("name") or "", base_url=request.form.get("base_url") or "", bridge_token=request.form.get("bridge_token") or "", embed_path=request.form.get("embed_path") or "/embed", tags=request.form.get("tags") or "", enabled=bool(request.form.get("enabled")), ) flash(err or "已保存") elif action == "delete": iid = int(request.form.get("id") or 0) ok = delete_instance(iid) flash("已删除" if ok else "删除失败") return redirect(url_for("instances_page")) return render_template("instances.html", instances=list_instances()) @app.route("/settings", methods=["GET", "POST"]) @login_required def settings_page(): if request.method == "POST": current_pw = request.form.get("current_password") or "" new_username = (request.form.get("new_username") or "").strip() new_password = request.form.get("new_password") or "" confirm = request.form.get("confirm_password") or "" if current_pw != hub_password(): flash("当前密码错误") return redirect(url_for("settings_page")) if len(new_username) < 2 or len(new_username) > 64: flash("用户名长度需为 2–64") return redirect(url_for("settings_page")) if any(ch.isspace() for ch in new_username): flash("用户名不能包含空格") return redirect(url_for("settings_page")) updates: dict[str, str] = {"HUB_USERNAME": new_username} final_password = hub_password() if new_password or confirm: if len(new_password) < 6: flash("新密码至少 6 位") return redirect(url_for("settings_page")) if new_password != confirm: flash("两次输入的新密码不一致") return redirect(url_for("settings_page")) updates["HUB_PASSWORD"] = new_password final_password = new_password try: apply_env_updates(ENV_PATH, updates) except OSError as exc: flash(f"写入 .env 失败: {exc}") return redirect(url_for("settings_page")) set_hub_credentials(new_username, final_password) flash("账号已更新,立即生效") return redirect(url_for("settings_page")) return render_template("settings.html", current_username=hub_username()) @app.route("/desk") @login_required def desk(): instances = list_instances(enabled_only=True) iid = request.args.get("id") # 未指定客户端时默认打开第一个 if (not iid or not str(iid).isdigit()) and instances: return redirect(url_for("desk", id=instances[0]["id"])) inst = None open_url = "" if iid and str(iid).isdigit(): inst = get_instance(int(iid)) if inst: open_url = build_embed_open_url( inst["base_url"], bridge_token=inst["bridge_token"], embed_path=inst.get("embed_path") or "/embed", ttl_sec=3600, ) return render_template( "desk.html", instances=instances, current=inst, open_url=open_url, ) @app.route("/api/overview") @login_required def api_overview(): rows = pull_all_enabled() return jsonify({"ok": True, "instances": rows}) @app.route("/api/instances//open_url") @login_required def api_open_url(iid: int): inst = get_instance(iid) if not inst: return jsonify({"ok": False, "msg": "不存在"}), 404 url = build_embed_open_url( inst["base_url"], bridge_token=inst["bridge_token"], embed_path=inst.get("embed_path") or "/embed", ttl_sec=3600, ) return jsonify({"ok": True, "url": url, "base_url": inst["base_url"]}) @app.route("/api/instances//options/close", methods=["POST"]) @login_required def api_instance_options_close(iid: int): """总览下发全平:中控只转发,客户端本地买一限价执行.""" inst = get_instance(iid) if not inst: return jsonify({"ok": False, "msg": "实例不存在"}), 404 if not inst.get("enabled"): return jsonify({"ok": False, "msg": "实例未启用"}), 400 data = request.get_json(silent=True) or {} opt_inst = (data.get("inst_id") or "").strip() if not opt_inst: return jsonify({"ok": False, "msg": "缺少 inst_id"}) result = post_close_full(inst, inst_id=opt_inst) status = 200 if not result.get("ok"): # 客户端业务拒绝(残档等)仍 200 带 ok:false;仅传输层失败用 502 msg = str(result.get("msg") or "") if msg.startswith("HTTP ") or "超时" in msg or "下发失败" in msg or "缺少 URL" in msg: status = 502 return jsonify(result), status @app.route("/cond_orders") @login_required def cond_orders_page(): return render_template("cond_orders.html") @app.route("/api/cond_orders/candidates") @login_required def api_cond_order_candidates(): """可选客户端:在线且无持仓、未被条件单锁定.""" rows = pull_all_enabled() out = [] for row in rows: snap = row.get("snapshot") if isinstance(row.get("snapshot"), dict) else {} brief = snap.get("positions_brief") or snap.get("positions") or [] has_pos = isinstance(brief, list) and len(brief) > 0 locked = bool(snap.get("cond_order_locked")) selectable = bool(row.get("online")) and not has_pos and not locked out.append( { "id": row.get("id"), "name": row.get("name"), "online": bool(row.get("online")), "has_position": has_pos, "cond_order_locked": locked, "selectable": selectable, "error": row.get("error") or "", } ) return jsonify({"ok": True, "instances": out}) @app.route("/api/cond_orders", methods=["GET"]) @login_required def api_cond_orders_list(): instances = list_instances(enabled_only=True) all_orders = [] for inst in instances: data = fetch_cond_orders(inst) for o in data.get("orders") or []: if not isinstance(o, dict): continue item = dict(o) item["instance_id"] = inst.get("id") item["instance_name"] = inst.get("name") all_orders.append(item) return jsonify({"ok": True, "orders": all_orders}) @app.route("/api/cond_orders", methods=["POST"]) @login_required def api_cond_orders_create(): import uuid data = request.get_json(silent=True) or {} ids_raw = data.get("instance_ids") or [] try: instance_ids = [int(x) for x in ids_raw] except (TypeError, ValueError): return jsonify({"ok": False, "msg": "instance_ids 无效"}) if not instance_ids: return jsonify({"ok": False, "msg": "请选择客户端"}) # 再次按快照过滤有仓/锁定 rows = {r.get("id"): r for r in pull_all_enabled()} allowed = [] rejected = [] for iid in instance_ids: row = rows.get(iid) if not row or not row.get("online"): rejected.append({"instance_id": iid, "msg": "离线或不可用"}) continue snap = row.get("snapshot") if isinstance(row.get("snapshot"), dict) else {} brief = snap.get("positions_brief") or [] if brief: rejected.append({"instance_id": iid, "msg": "已有持仓不可选"}) continue if snap.get("cond_order_locked"): rejected.append({"instance_id": iid, "msg": "已有条件单占用"}) continue allowed.append(iid) if not allowed: return jsonify({"ok": False, "msg": "没有可下发的客户端", "rejected": rejected}) hub_order_id = (data.get("hub_order_id") or "").strip() or str(uuid.uuid4()) payload = { "hub_order_id": hub_order_id, "underlying": (data.get("underlying") or "ETH").strip().upper(), "opt_type": (data.get("opt_type") or "P").strip().upper(), "trigger_side": (data.get("trigger_side") or "gte").strip().lower(), "trigger_px": data.get("trigger_px"), "deviation_pct": data.get("deviation_pct"), "min_expiry_hours": data.get("min_expiry_hours"), "exit": { "target_index": data.get("target_index"), "profit_exit_enabled": bool(data.get("profit_exit_enabled")), "profit_exit_mult": data.get("profit_exit_mult"), }, } results = dispatch_cond_order_to_ids(allowed, payload) ok_n = sum(1 for r in results if r.get("ok")) return jsonify( { "ok": ok_n > 0, "msg": f"成功 {ok_n}/{len(results)}", "hub_order_id": hub_order_id, "results": results, "rejected": rejected, } ) @app.route("/api/cond_orders//cancel", methods=["POST"]) @login_required def api_cond_orders_cancel(hub_order_id: str): data = request.get_json(silent=True) or {} iid = data.get("instance_id") if iid is None: return jsonify({"ok": False, "msg": "缺少 instance_id"}) inst = get_instance(int(iid)) if not inst: return jsonify({"ok": False, "msg": "实例不存在"}), 404 return jsonify(post_cond_cancel(inst, hub_order_id)) @app.route("/api/cond_orders//exit", methods=["POST"]) @login_required def api_cond_orders_exit(hub_order_id: str): data = request.get_json(silent=True) or {} iid = data.get("instance_id") if iid is None: return jsonify({"ok": False, "msg": "缺少 instance_id"}) inst = get_instance(int(iid)) if not inst: return jsonify({"ok": False, "msg": "实例不存在"}), 404 payload = { "target_index": data.get("target_index"), "profit_exit_enabled": data.get("profit_exit_enabled"), "profit_exit_mult": data.get("profit_exit_mult"), } return jsonify(post_cond_exit(inst, hub_order_id, payload)) if __name__ == "__main__": app.run(host=APP_HOST, port=APP_PORT, debug=True, threaded=True)