Files
crypto_okx_hub/app.py
T
2026-10-03 02:23:07 +08:00

424 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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/<int:iid>/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/<int:iid>/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())
opt_type = (data.get("opt_type") or "P").strip().upper()
if opt_type not in ("P", "C"):
opt_type = "P"
# 左侧单:看跌上穿、看涨下穿
trigger_side = "gte" if opt_type == "P" else "lte"
payload = {
"hub_order_id": hub_order_id,
"underlying": (data.get("underlying") or "ETH").strip().upper(),
"opt_type": opt_type,
"trigger_side": trigger_side,
"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/<hub_order_id>/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/<hub_order_id>/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)