首次上传

This commit is contained in:
dekun
2026-05-16 22:25:48 +08:00
commit 2b8f902548
88 changed files with 16386 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
.venv/
__pycache__/
*.pyc
.pytest_cache/
config.yaml
runtime/
.env
*.log
+60
View File
@@ -0,0 +1,60 @@
# gate_order_executor
> 仓库总览与 Git 克隆见上级目录:[../README.md](../README.md)、[../CLONE.md](../CLONE.md)。
`onchain_scout_gate`MATRIX 扫描)**并列**的独立服务:接收结构化信号;在 **`gate.dry_run: false`** 且配置 API 密钥时,向 Gate USDT 永续发 **市价开仓 + 计划止盈/止损**(详见 [使用说明.md](docs/使用说明.md) §3.4.1)。扫描进程继续只用公共 API;本服务持有 API Key(仅本机 `config.yaml`)。
## 文档索引
| 文档 | 内容 |
|------|------|
| [docs/使用说明.md](docs/使用说明.md) | 职责、配置项、面板、接口、与扫描协作 |
| [docs/部署说明.md](docs/部署说明.md) | Ubuntu 安装、PM2、systemd、防火墙、同机部署 |
| [deploy/README.md](deploy/README.md) | `deploy/` 下各脚本与 service 文件说明 |
## 目录位置(示例)
- `山寨币扫描/onchain_scout_gate/` — 监控 + 企业微信
- `山寨币扫描/gate_order_executor/` — 本下单执行器
## 本地快速运行
```bash
cd gate_order_executor
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp config.example.yaml config.yaml
# 编辑 config.yaml
python run.py
```
- 健康检查:`GET http://127.0.0.1:8090/health`
- 面板:`http://127.0.0.1:8090/dashboard`**勿**用资源管理器直接打开 `templates/*.html`,否则为白底无样式;须先 `python run.py`)。详见 [使用说明.md](docs/使用说明.md)。
## 生产部署(PM2
```bash
chmod +x deploy/*.sh
bash deploy/bootstrap.sh /root/gate_order_executor
# 编辑 config.yaml 后:
bash deploy/pm2-start.sh
```
详见 **[docs/部署说明.md](docs/部署说明.md)**。
## 信号接口(摘要)
`POST /v1/signal`,请求头 `X-Webhook-Secret``config.yaml``security.webhook_secret` 一致。JSON 字段:`signal_id``contract`(如 `BTC_USDT`)、`side``long`/`short`)、`take_profit``stop_loss`(方案 A),可选 `reference_price`
**`gate.dry_run: true`** 时只记日志、不下单;**`false`** 且填写 `api_key` / `api_secret` 时走实盘(务必子账户、IP 白名单、先在小额上验证)。完整说明见 [使用说明.md](docs/使用说明.md)。
```bash
curl -sS -X POST http://127.0.0.1:8090/v1/signal \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: your-secret" \
-d '{"signal_id":"demo-1","contract":"BTC_USDT","side":"long","take_profit":99000,"stop_loss":97000}'
```
## 代理与 SOCKS
`proxy` 配置与扫描项目一致;SOCKS 需安装 `socksio`。详见 [使用说明.md](docs/使用说明.md) §3.6。
+1
View File
@@ -0,0 +1 @@
# Gate order executor (separate from onchain_scout_gate scanner).
@@ -0,0 +1,147 @@
"""移动保本运行态:登记 entry/initial_sl/sl_order_id,平仓后清除。"""
from __future__ import annotations
import json
import logging
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
_ROOT = Path(__file__).resolve().parent.parent
_ACTIVE_PATH = _ROOT / "runtime" / "breakeven_active.json"
_lock = threading.Lock()
def _now_iso() -> str:
return datetime.now(timezone.utc).astimezone().isoformat()
def _read_all_unlocked() -> dict[str, Any]:
if not _ACTIVE_PATH.is_file():
return {}
try:
raw = _ACTIVE_PATH.read_text(encoding="utf-8").strip()
if not raw:
return {}
data = json.loads(raw)
if not isinstance(data, dict):
return {}
return {str(k).strip().upper(): v for k, v in data.items() if isinstance(v, dict)}
except (OSError, json.JSONDecodeError) as exc:
logger.warning("breakeven_active_read_failed: %s", exc)
return {}
def _write_all_unlocked(rows: dict[str, Any]) -> None:
_ACTIVE_PATH.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps(rows, indent=2, ensure_ascii=False) + "\n"
tmp = _ACTIVE_PATH.with_suffix(".json.tmp")
tmp.write_text(payload, encoding="utf-8")
tmp.replace(_ACTIVE_PATH)
def read_all_active() -> dict[str, dict[str, Any]]:
with _lock:
return dict(_read_all_unlocked())
def get_active(contract: str) -> dict[str, Any] | None:
ct = contract.strip().upper()
with _lock:
row = _read_all_unlocked().get(ct)
return dict(row) if isinstance(row, dict) else None
def upsert_active(
contract: str,
*,
side: str,
entry: float,
initial_sl: float,
sl_order_id: str,
moved: bool = False,
status: str = "waiting_1r",
) -> dict[str, Any]:
ct = contract.strip().upper()
row: dict[str, Any] = {
"side": str(side).lower(),
"entry": float(entry),
"initial_sl": float(initial_sl),
"sl_order_id": str(sl_order_id).strip(),
"moved": bool(moved),
"status": status,
"registered_at": _now_iso(),
}
with _lock:
all_rows = _read_all_unlocked()
prev = all_rows.get(ct)
if isinstance(prev, dict) and prev.get("registered_at"):
row["registered_at"] = prev["registered_at"]
if isinstance(prev, dict) and prev.get("moved"):
row["moved"] = bool(prev["moved"])
row["status"] = prev.get("status") or row["status"]
all_rows[ct] = row
_write_all_unlocked(all_rows)
return row
def mark_unregistrable(contract: str) -> None:
ct = contract.strip().upper()
with _lock:
all_rows = _read_all_unlocked()
all_rows[ct] = {
"status": "cannot_register",
"moved": False,
"registered_at": _now_iso(),
}
_write_all_unlocked(all_rows)
def mark_moved(contract: str, *, new_sl_order_id: str, breakeven_sl: str) -> None:
ct = contract.strip().upper()
with _lock:
all_rows = _read_all_unlocked()
row = all_rows.get(ct)
if not isinstance(row, dict):
return
row["moved"] = True
row["status"] = "moved"
row["sl_order_id"] = str(new_sl_order_id).strip()
row["breakeven_sl"] = str(breakeven_sl)
row["moved_at"] = _now_iso()
all_rows[ct] = row
_write_all_unlocked(all_rows)
def update_sl_order_id(contract: str, sl_order_id: str) -> None:
ct = contract.strip().upper()
with _lock:
all_rows = _read_all_unlocked()
row = all_rows.get(ct)
if not isinstance(row, dict):
return
row["sl_order_id"] = str(sl_order_id).strip()
all_rows[ct] = row
_write_all_unlocked(all_rows)
def remove_active(contract: str) -> None:
ct = contract.strip().upper()
with _lock:
all_rows = _read_all_unlocked()
if ct in all_rows:
del all_rows[ct]
_write_all_unlocked(all_rows)
def remove_all_except(contracts: set[str]) -> None:
keep = {c.strip().upper() for c in contracts if c}
with _lock:
all_rows = _read_all_unlocked()
filtered = {k: v for k, v in all_rows.items() if k in keep}
if filtered != all_rows:
_write_all_unlocked(filtered)
+287
View File
@@ -0,0 +1,287 @@
"""移动保本:1R 判断、保本价、从计划单/信号登记。"""
from __future__ import annotations
import logging
from typing import Any
from .breakeven_active_store import get_active, mark_unregistrable, upsert_active
from .breakeven_prefs_store import read_effective_enabled
from .config import Settings
from .gate_futures_live import GateFuturesLive, _float, post_stop_loss_price_order
from .gate_price_rounding import _format_trigger_price, _trigger_price_tick
from .models_signal import TradeSignal
logger = logging.getLogger(__name__)
def sl_trigger_rule_for_side(side: str) -> int:
return 2 if side == "long" else 1
def risk_distance(side: str, entry: float, initial_sl: float) -> float | None:
if entry <= 0 or initial_sl <= 0:
return None
if side == "long":
dist = entry - initial_sl
elif side == "short":
dist = initial_sl - entry
else:
return None
return dist if dist > 0 else None
def is_1r_reached(
side: str,
mark: float,
entry: float,
initial_sl: float,
*,
trigger_r: float,
) -> bool:
dist = risk_distance(side, entry, initial_sl)
if dist is None or mark <= 0:
return False
target = trigger_r * dist
if side == "long":
return mark >= entry + target
if side == "short":
return mark <= entry - target
return False
def breakeven_sl_price(side: str, entry: float, buffer_pct: float) -> float | None:
if entry <= 0 or buffer_pct < 0:
return None
if side == "long":
return entry * (1.0 + buffer_pct)
if side == "short":
return entry * (1.0 - buffer_pct)
return None
def sl_already_at_or_better(side: str, current_sl: float, target_sl: float) -> bool:
if current_sl <= 0 or target_sl <= 0:
return False
if side == "long":
return current_sl >= target_sl
if side == "short":
return current_sl <= target_sl
return False
def _order_id_from_plan(plan: dict[str, Any]) -> str | None:
oid = plan.get("order_id")
if oid is None:
return None
s = str(oid).strip()
return s or None
def find_sl_plan(
side: str,
contract: str,
open_plans: list[dict[str, Any]],
) -> tuple[str | None, float | None]:
"""从 open 计划单中识别止损腿,返回 (order_id, trigger_price)。"""
ct = contract.strip().upper()
want_rule = sl_trigger_rule_for_side(side)
candidates: list[tuple[str, float]] = []
for p in open_plans:
if str(p.get("contract") or "").strip().upper() != ct:
continue
try:
rule = int(p.get("rule"))
except (TypeError, ValueError):
continue
if rule != want_rule:
continue
try:
px = float(str(p.get("trigger_price") or "").strip())
except ValueError:
continue
if px <= 0:
continue
oid = _order_id_from_plan(p)
if oid:
candidates.append((oid, px))
if not candidates:
return None, None
# 多仓 SL 在 entry 下方取最高触发价;空仓 SL 在 entry 上方取最低
if side == "long":
oid, px = max(candidates, key=lambda x: x[1])
else:
oid, px = min(candidates, key=lambda x: x[1])
return oid, px
def _gate_order_id(obj: Any) -> str | None:
if not isinstance(obj, dict):
return None
oid = obj.get("id")
if oid is None:
oid = obj.get("id_string")
if oid is None:
return None
s = str(oid).strip()
return s or None
def register_from_execution_result(settings: Settings, sig: TradeSignal, result: dict[str, Any]) -> None:
if result.get("status") != "accepted":
return
contract = sig.contract.strip().upper()
if not read_effective_enabled(settings, contract):
return
entry = _float(result.get("reference_entry"))
sl_sent = result.get("stop_loss_price_sent")
try:
initial_sl = float(sl_sent) if sl_sent is not None else float(sig.stop_loss)
except (TypeError, ValueError):
mark_unregistrable(contract)
return
sl_order = result.get("stop_loss_order")
sl_id = _gate_order_id(sl_order)
if entry <= 0 or initial_sl <= 0 or not sl_id:
mark_unregistrable(contract)
return
upsert_active(
contract,
side=str(sig.side).lower(),
entry=entry,
initial_sl=initial_sl,
sl_order_id=sl_id,
moved=False,
status="waiting_1r",
)
logger.info("breakeven_registered contract=%s entry=%s initial_sl=%s", contract, entry, initial_sl)
def register_from_signal_db_row(row: dict[str, Any], sl_order_id: str) -> dict[str, Any] | None:
res = row.get("result") if isinstance(row.get("result"), dict) else {}
sig = row.get("signal") if isinstance(row.get("signal"), dict) else {}
if res.get("status") != "accepted":
return None
entry = _float(res.get("reference_entry"))
sl_sent = res.get("stop_loss_price_sent")
try:
initial_sl = float(sl_sent) if sl_sent is not None else float(sig.get("stop_loss") or 0)
except (TypeError, ValueError):
return None
side = str(sig.get("side") or res.get("side") or "").lower()
if entry <= 0 or initial_sl <= 0 or side not in ("long", "short") or not sl_order_id:
return None
return {
"side": side,
"entry": entry,
"initial_sl": initial_sl,
"sl_order_id": sl_order_id,
}
async def try_register_existing_position(
settings: Settings,
*,
contract: str,
side: str,
open_plans: list[dict[str, Any]],
signal_repo: Any | None,
) -> bool:
"""有持仓但 active 无记录时尝试登记;失败则 cannot_register。返回是否已登记。"""
ct = contract.strip().upper()
existing = get_active(ct)
if existing:
return existing.get("status") != "cannot_register"
sl_id, sl_px = find_sl_plan(side, ct, open_plans)
if not sl_id or sl_px is None:
mark_unregistrable(ct)
return False
entry: float | None = None
initial_sl: float | None = sl_px
if signal_repo is not None:
try:
db_row = signal_repo.find_latest_accepted_for_contract(ct)
except Exception: # noqa: BLE001
logger.exception("breakeven_signal_db_lookup_failed contract=%s", ct)
db_row = None
if db_row:
reg = register_from_signal_db_row(db_row, sl_id)
if reg:
entry = reg["entry"]
initial_sl = reg["initial_sl"]
side = reg["side"]
if entry is None or entry <= 0:
mark_unregistrable(ct)
return False
upsert_active(
ct,
side=side,
entry=entry,
initial_sl=float(initial_sl),
sl_order_id=sl_id,
moved=False,
status="waiting_1r",
)
logger.info("breakeven_registered_existing contract=%s", ct)
return True
async def move_sl_to_breakeven(
settings: Settings,
*,
contract: str,
side: str,
entry: float,
initial_sl: float,
sl_order_id: str,
mark_price: float,
open_plans: list[dict[str, Any]],
) -> tuple[bool, str | None, str | None]:
"""撤旧 SL 并挂保本+缓冲止损。成功返回 (True, breakeven_sl_str, new_sl_order_id)。"""
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
return False, "missing_api_keys", None
if settings.gate.dry_run:
return False, "dry_run_enabled", None
be = settings.risk.breakeven_stop
target = breakeven_sl_price(side, entry, float(be.buffer_pct))
if target is None:
return False, "invalid_breakeven_price", None
client = GateFuturesLive(settings)
cdata = await client._public_get(f"{client._prefix}/contracts/{contract.strip().upper()}")
if not isinstance(cdata, dict):
return False, "contract_not_found", None
tick = _trigger_price_tick(cdata)
target_s = _format_trigger_price(target, tick)
sl_id_plan, current_sl_px = find_sl_plan(side, contract, open_plans)
use_id = sl_order_id or sl_id_plan or ""
if current_sl_px is not None and sl_already_at_or_better(side, current_sl_px, float(target_s)):
keep_id = use_id or sl_id_plan or ""
return True, target_s, keep_id or None
from .gate_futures_live import cancel_price_triggered_order
from .oco_watcher import update_oco_sl_order_id
if use_id:
try:
await cancel_price_triggered_order(client, use_id)
except Exception as exc: # noqa: BLE001
return False, f"cancel_sl_failed:{exc}", None
try:
resp = await post_stop_loss_price_order(client, contract=contract, side=side, sl_price=target_s)
except Exception as exc: # noqa: BLE001
return False, f"post_sl_failed:{exc}", None
new_id = _gate_order_id(resp)
if not new_id:
return False, "post_sl_no_id", None
await update_oco_sl_order_id(settings, contract=contract, new_sl_id=new_id)
return True, target_s, new_id
@@ -0,0 +1,96 @@
"""移动保本偏好:全局与单合约开关,持久化 runtime/breakeven_prefs.json。"""
from __future__ import annotations
import json
import logging
import threading
from pathlib import Path
from typing import Any
from .config import Settings
logger = logging.getLogger(__name__)
_ROOT = Path(__file__).resolve().parent.parent
_PREFS_PATH = _ROOT / "runtime" / "breakeven_prefs.json"
_lock = threading.Lock()
def _read_file() -> dict[str, Any]:
if not _PREFS_PATH.is_file():
return {}
try:
raw = _PREFS_PATH.read_text(encoding="utf-8").strip()
if not raw:
return {}
data = json.loads(raw)
return data if isinstance(data, dict) else {}
except (OSError, json.JSONDecodeError) as exc:
logger.warning("breakeven_prefs_read_failed: %s", exc)
return {}
def _write_file(data: dict[str, Any]) -> None:
_PREFS_PATH.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
tmp = _PREFS_PATH.with_suffix(".json.tmp")
tmp.write_text(payload, encoding="utf-8")
tmp.replace(_PREFS_PATH)
def read_prefs_snapshot() -> dict[str, Any]:
with _lock:
return dict(_read_file())
def read_effective_global_enabled(settings: Settings) -> bool:
base = bool(settings.risk.breakeven_stop.enabled)
with _lock:
data = _read_file()
if "global_enabled" not in data:
return base
return bool(data.get("global_enabled"))
def read_contract_override(contract: str) -> bool | None:
ct = contract.strip().upper()
with _lock:
data = _read_file()
contracts = data.get("contracts")
if not isinstance(contracts, dict):
return None
row = contracts.get(ct)
if not isinstance(row, dict) or "enabled" not in row:
return None
return bool(row.get("enabled"))
def read_effective_enabled(settings: Settings, contract: str) -> bool:
ov = read_contract_override(contract)
if ov is not None:
return ov
return read_effective_global_enabled(settings)
def write_global_enabled(value: bool) -> bool:
with _lock:
data = _read_file()
data["global_enabled"] = bool(value)
_write_file(data)
return bool(value)
def write_contract_enabled(contract: str, value: bool) -> tuple[str, bool]:
ct = contract.strip().upper()
if not ct:
raise ValueError("empty_contract")
with _lock:
data = _read_file()
contracts = data.get("contracts")
if not isinstance(contracts, dict):
contracts = {}
contracts[ct] = {"enabled": bool(value)}
data["contracts"] = contracts
_write_file(data)
return ct, bool(value)
@@ -0,0 +1,243 @@
"""移动保本后台轮询:达 1R 后撤旧 SL、挂保本+缓冲(仅一次)。"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from .breakeven_active_store import (
get_active,
mark_moved,
mark_unregistrable,
read_all_active,
remove_active,
remove_all_except,
)
from .breakeven_logic import (
is_1r_reached,
move_sl_to_breakeven,
try_register_existing_position,
)
from .breakeven_prefs_store import read_effective_enabled
from .config import Settings
from .gate_futures_live import _float
from .gate_operations import list_futures_positions, list_open_price_orders
from .wecom_notify import notify_breakeven_failed
logger = logging.getLogger(__name__)
_task: asyncio.Task[None] | None = None
_settings: Settings | None = None
_signal_repo: Any | None = None
def _live_ok(settings: Settings) -> bool:
g = settings.gate
return (not g.dry_run) and bool(g.api_key.strip() and g.api_secret.strip())
def start_breakeven_watcher(settings: Settings, signal_repo: Any | None = None) -> None:
global _task, _settings, _signal_repo
_settings = settings
_signal_repo = signal_repo
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
if _task is not None and not _task.done():
return
_task = loop.create_task(_poll_loop(), name="breakeven_stop_watcher")
logger.info(
"breakeven_watcher_started poll=%ss enabled_default=%s",
settings.risk.breakeven_stop.poll_interval_sec,
settings.risk.breakeven_stop.enabled,
)
async def stop_breakeven_watcher() -> None:
global _task
if _task is None:
return
_task.cancel()
try:
await _task
except asyncio.CancelledError:
pass
_task = None
async def _poll_loop() -> None:
assert _settings is not None
interval = float(_settings.risk.breakeven_stop.poll_interval_sec)
while True:
await asyncio.sleep(interval)
try:
await _tick(_settings, _signal_repo)
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001
logger.exception("breakeven_watcher_tick_failed")
def _position_side(size: float) -> str | None:
if size > 1e-12:
return "long"
if size < -1e-12:
return "short"
return None
async def _tick(settings: Settings, signal_repo: Any | None) -> None:
if not _live_ok(settings):
return
positions, pos_err = await list_futures_positions(settings)
if pos_err or not isinstance(positions, list):
return
open_contracts: set[str] = set()
pos_by_contract: dict[str, dict[str, Any]] = {}
for row in positions:
ct = str(row.get("contract") or "").strip().upper()
sz = _float(row.get("size"))
if not ct or abs(sz) <= 1e-12:
continue
open_contracts.add(ct)
pos_by_contract[ct] = row
remove_all_except(open_contracts)
plans, _ = await list_open_price_orders(settings)
plan_list = plans if isinstance(plans, list) else []
be_cfg = settings.risk.breakeven_stop
trigger_r = float(be_cfg.trigger_r)
for ct, prow in pos_by_contract.items():
if not read_effective_enabled(settings, ct):
continue
sz = _float(prow.get("size"))
side = _position_side(sz)
if not side:
continue
mark = _float(prow.get("mark_price"))
active = get_active(ct)
if not active or active.get("status") == "cannot_register":
if not active or active.get("status") != "cannot_register":
await try_register_existing_position(
settings,
contract=ct,
side=side,
open_plans=plan_list,
signal_repo=signal_repo,
)
active = get_active(ct)
if not active or active.get("status") == "cannot_register":
continue
if active.get("moved") or active.get("status") == "moved":
continue
entry = _float(active.get("entry"))
initial_sl = _float(active.get("initial_sl"))
sl_order_id = str(active.get("sl_order_id") or "").strip()
reg_side = str(active.get("side") or side).lower()
if entry <= 0 or initial_sl <= 0 or not sl_order_id:
mark_unregistrable(ct)
continue
if not is_1r_reached(reg_side, mark, entry, initial_sl, trigger_r=trigger_r):
continue
ok, be_px, new_sl_id = await move_sl_to_breakeven(
settings,
contract=ct,
side=reg_side,
entry=entry,
initial_sl=initial_sl,
sl_order_id=sl_order_id,
mark_price=mark,
open_plans=plan_list,
)
if ok:
mark_moved(
ct,
new_sl_order_id=str(new_sl_id or sl_order_id),
breakeven_sl=str(be_px or ""),
)
logger.info("breakeven_moved contract=%s sl=%s", ct, be_px)
else:
logger.warning("breakeven_move_failed contract=%s detail=%s", ct, be_px)
try:
await notify_breakeven_failed(
settings,
contract=ct,
detail=str(be_px or "unknown"),
)
except Exception: # noqa: BLE001
logger.exception("wecom_notify_breakeven_failed")
# 清除已无持仓的 activeremove_all_except 已处理;显式删 cannot_register 残留)
for ct in list(read_all_active().keys()):
if ct not in open_contracts:
remove_active(ct)
async def build_breakeven_state_for_api(
settings: Settings,
*,
exchange_positions: list[dict[str, Any]] | None,
) -> dict[str, Any]:
from .breakeven_prefs_store import read_effective_global_enabled, read_prefs_snapshot
be = settings.risk.breakeven_stop
prefs = read_prefs_snapshot()
active = read_all_active()
per_pos: list[dict[str, Any]] = []
for row in exchange_positions or []:
ct = str(row.get("contract") or "").strip().upper()
if not ct:
continue
sz = _float(row.get("size"))
if abs(sz) <= 1e-12:
continue
enabled = read_effective_enabled(settings, ct)
act = active.get(ct) or {}
st = "disabled"
if not enabled:
st = "disabled"
elif act.get("status") == "cannot_register":
st = "cannot_register"
elif act.get("moved") or act.get("status") == "moved":
st = "moved"
elif act.get("entry"):
st = "waiting_1r"
else:
st = "pending_register"
per_pos.append(
{
"contract": ct,
"effective_enabled": enabled,
"status": st,
"breakeven_sl": act.get("breakeven_sl"),
}
)
return {
"config": {
"enabled_default": bool(be.enabled),
"trigger_r": float(be.trigger_r),
"buffer_pct": float(be.buffer_pct),
"poll_interval_sec": float(be.poll_interval_sec),
},
"global_enabled": read_effective_global_enabled(settings),
"global_enabled_config_default": bool(be.enabled),
"contracts": prefs.get("contracts") if isinstance(prefs.get("contracts"), dict) else {},
"active": active,
"positions": per_pos,
}
+137
View File
@@ -0,0 +1,137 @@
from __future__ import annotations
from pathlib import Path
import yaml
from pydantic import BaseModel, Field, field_validator
class AppConfig(BaseModel):
host: str = "127.0.0.1"
port: int = 8090
log_file: str = "./runtime/executor.log"
session_secret: str = "please-change-session-secret"
class AuthConfig(BaseModel):
"""与扫描端一致:enabled=false 时仅建议局域网使用。"""
enabled: bool = False
username: str = "admin"
password: str = "changeme"
class SecurityConfig(BaseModel):
webhook_secret: str = ""
class GateConfig(BaseModel):
api_base: str = "https://api.gateio.ws/api/v4"
settle: str = "usdt"
api_key: str = ""
api_secret: str = ""
dry_run: bool = True
# 仅人工测试:为 true 时允许 micro_market 真实 IOC 市价(仍受 test_max_contracts 限制);通过 POST /api/test、/v1/test 联调,见 docs/使用说明 §4.1
test_orders_enabled: bool = False
test_max_contracts: int = Field(1, ge=1, le=30)
class BreakevenStopConfig(BaseModel):
"""移动保本:1R 相对初始止损触发后,止损拉至开仓价 ± buffer_pct(仅一次)。"""
enabled: bool = True
trigger_r: float = Field(1.0, ge=0.1, le=10.0, description="相对初始 SL 的 R 倍数")
buffer_pct: float = Field(0.002, ge=0.0, le=0.05, description="保本缓冲,价格的百分比(0.002=0.2%")
poll_interval_sec: float = Field(8.0, ge=3.0, le=120.0)
class RiskConfig(BaseModel):
risk_per_trade_frac: float = Field(0.005, ge=0.0001, le=0.05)
max_open_positions: int = Field(5, ge=1, le=50)
scheme: str = "A"
# Gate 永续 v4 无官方「单笔原生 OCO」双计划互撤时:为 true 则在净持仓为 0 后轮询 DELETE 本次挂出的另一腿计划单
oco_cleanup_enabled: bool = True
# 最低盈亏比(毛利/风险)门槛的 config 默认值;面板保存会写入 runtime/risk_prefs.json 覆盖
min_reward_risk_ratio: float = Field(1.3, ge=0.1, le=50.0)
breakeven_stop: BreakevenStopConfig = Field(default_factory=BreakevenStopConfig)
class StatsConfig(BaseModel):
"""面板「正式统计」:时区与起始时刻(仅统计该时刻之后平仓的 Gate 历史仓位记录)。"""
timezone: str = "Asia/Shanghai"
official_start: str = Field(
default="2026-05-13T02:00:00+08:00",
description="ISO8601,建议带 +08:00;仅统计平仓 time 不早于此的历史平仓记录",
)
max_trade_rows: int = Field(
20000,
ge=500,
le=100000,
description="从 Gate 分页拉 position_close 历史平仓的上限(防爆内存;沿用键名 max_trade_rows",
)
class DatabaseConfig(BaseModel):
"""信号流与执行结果默认写入 SQLite;面板与导出读同一库,进程重启后记录仍在。"""
enabled: bool = Field(
default=True,
description="已废弃,保留兼容旧配置;是否落库由 sqlite_path 决定(空串会自动回退为默认路径)",
)
sqlite_path: str = "./runtime/signals.sqlite"
@field_validator("sqlite_path", mode="before")
@classmethod
def _sqlite_path_default_if_blank(cls, v: object) -> str:
if v is None:
return "./runtime/signals.sqlite"
s = str(v).strip()
return s if s else "./runtime/signals.sqlite"
class ProxyConfig(BaseModel):
"""
出站 HTTPhttpx)代理,与 onchain_scout_gate 的 ``proxy:`` 块写法一致。
访问 Gate 私有 API 时使用此处;企业微信「策略类」仍由扫描端处理,执行结果见 ``wecom`` 配置。
"""
enabled: bool = False
url: str = "socks5h://127.0.0.1:1080"
class WecomNotifyConfig(BaseModel):
"""企业微信群机器人:仅推送执行器侧执行结果(成交/拒单/异常/平仓等);策略发现类仍由扫描端。"""
enabled: bool = False
webhook_url: str = Field(
default="",
description="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...",
)
@field_validator("webhook_url", mode="before")
@classmethod
def _strip_webhook(cls, v: object) -> str:
if v is None:
return ""
return str(v).strip()
class Settings(BaseModel):
app: AppConfig = Field(default_factory=AppConfig)
auth: AuthConfig = Field(default_factory=AuthConfig)
security: SecurityConfig = Field(default_factory=SecurityConfig)
gate: GateConfig = Field(default_factory=GateConfig)
risk: RiskConfig = Field(default_factory=RiskConfig)
stats: StatsConfig = Field(default_factory=StatsConfig)
database: DatabaseConfig = Field(default_factory=DatabaseConfig)
proxy: ProxyConfig = Field(default_factory=ProxyConfig)
wecom: WecomNotifyConfig = Field(default_factory=WecomNotifyConfig)
def load_settings(path: str | Path | None = None) -> Settings:
cfg_path = Path(path or Path(__file__).resolve().parents[1] / "config.yaml")
if not cfg_path.is_file():
return Settings()
raw = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}
return Settings.model_validate(raw)
+89
View File
@@ -0,0 +1,89 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .config import Settings
from .models_signal import TradeSignal
from .positions import PositionBook
logger = logging.getLogger(__name__)
def _live_enabled(settings: "Settings") -> bool:
g = settings.gate
return (not g.dry_run) and bool(g.api_key.strip() and g.api_secret.strip())
async def handle_signal(settings: "Settings", book: "PositionBook", sig: "TradeSignal") -> dict:
"""
校验仓位上限与重复合约。
dry_run:只打日志并释放占位槽。
实盘:同步交易所持仓 → 市价开仓 → 计划委托止盈/止损;成功则保留占位槽。
"""
from .gate_futures_live import GateFuturesLive, execute_signal_live, fetch_open_contracts
contract = sig.contract.strip().upper()
open_on_ex: set[str] = set()
if _live_enabled(settings):
try:
gc = GateFuturesLive(settings)
open_on_ex = await fetch_open_contracts(gc)
book.sync_from_exchange(open_on_ex)
except Exception as exc: # noqa: BLE001
logger.warning("exchange_sync_failed: %s", exc)
if len(open_on_ex) >= settings.risk.max_open_positions:
return {
"status": "skipped",
"reason": "max_positions_exchange",
"max": settings.risk.max_open_positions,
}
if contract in open_on_ex:
return {"status": "skipped", "reason": "already_open_on_exchange", "contract": contract}
if book.has_contract(contract):
return {"status": "skipped", "reason": "already_open_for_contract", "contract": contract}
if not book.try_reserve(contract, sig.signal_id):
return {
"status": "skipped",
"reason": "max_positions_or_race",
"max": settings.risk.max_open_positions,
}
if not _live_enabled(settings):
logger.info(
"dry_run signal accepted contract=%s side=%s tp=%s sl=%s signal_id=%s",
contract,
sig.side,
sig.take_profit,
sig.stop_loss,
sig.signal_id,
)
book.release(contract)
return {
"status": "accepted",
"mode": "dry_run",
"contract": contract,
"side": sig.side,
"take_profit": sig.take_profit,
"stop_loss": sig.stop_loss,
"signal_id": sig.signal_id,
}
try:
out = await execute_signal_live(settings, sig)
except Exception as exc: # noqa: BLE001
logger.exception("live_execute_exception contract=%s", contract)
book.release(contract)
return {"status": "error", "reason": "exception", "detail": str(exc)}
if out.get("status") != "accepted":
book.release(contract)
return out
return out
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
import hashlib
import hmac
import time
from urllib.parse import urlparse
def gate_sign_path(api_base: str, path_rel: str) -> str:
"""签名用路径:/api/v4 + /futures/usdt/...(不含 host)。"""
root = urlparse(api_base).path.rstrip("/") or "/api/v4"
rel = path_rel if path_rel.startswith("/") else "/" + path_rel
return root + rel
def gate_sign_v4_headers(
*,
api_key: str,
api_secret: str,
method: str,
sign_path: str,
query_string: str,
body: str,
) -> dict[str, str]:
ts = str(int(time.time()))
m = hashlib.sha512()
m.update((body or "").encode("utf-8"))
hashed = m.hexdigest()
payload = f"{method.upper()}\n{sign_path}\n{query_string}\n{hashed}\n{ts}"
sign = hmac.new(api_secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha512).hexdigest()
return {
"KEY": api_key,
"Timestamp": ts,
"SIGN": sign,
"Accept": "application/json",
"Content-Type": "application/json",
}
@@ -0,0 +1,439 @@
from __future__ import annotations
import json
import logging
import math
import re
from decimal import ROUND_DOWN, Decimal
from typing import Any
import httpx
from .config import Settings
from .gate_auth import gate_sign_path, gate_sign_v4_headers
from .gate_price_rounding import _format_trigger_price, _trigger_price_tick
from .models_signal import TradeSignal
from .proxy_util import httpx_client_kwargs
logger = logging.getLogger(__name__)
PRICE_ORDER_EXPIRATION_SEC = 604800 # 7 天
def _safe_order_text(signal_id: str) -> str:
s = re.sub(r"[^0-9A-Za-z._-]", "_", (signal_id or "x").strip())[:22]
return "t-e" + s if s else "t-e"
def _json_compact(obj: Any) -> str:
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
class GateFuturesLive:
"""Gate USDT 永续私有 REST(市价开仓 + 计划委托止盈止损)。"""
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._base = settings.gate.api_base.rstrip("/")
self._settle = settings.gate.settle.strip().lower()
self._prefix = f"/futures/{self._settle}"
self._key = settings.gate.api_key.strip()
self._secret = settings.gate.api_secret.strip()
self._kw = httpx_client_kwargs(settings.proxy.enabled, settings.proxy.url)
def _sign_path(self, rel: str) -> str:
return gate_sign_path(self._settings.gate.api_base, rel)
async def _public_get(self, rel: str, *, params: dict[str, str] | None = None) -> Any:
url = f"{self._base}{rel}"
async with httpx.AsyncClient(**self._kw) as client:
r = await client.get(url, params=params)
r.raise_for_status()
return r.json()
async def _signed(
self,
method: str,
rel: str,
*,
query_string: str = "",
body_obj: dict[str, Any] | None = None,
) -> Any:
body_str = _json_compact(body_obj) if body_obj is not None else ""
sp = self._sign_path(rel)
headers = gate_sign_v4_headers(
api_key=self._key,
api_secret=self._secret,
method=method,
sign_path=sp,
query_string=query_string,
body=body_str,
)
url = f"{self._base}{rel}"
async with httpx.AsyncClient(**self._kw) as client:
if method.upper() == "GET":
r = await client.get(url, headers=headers, params=params_from_qs(query_string))
elif method.upper() == "POST":
r = await client.post(url, headers=headers, content=body_str.encode("utf-8"))
elif method.upper() == "DELETE":
r = await client.delete(url, headers=headers)
else:
raise ValueError(f"unsupported {method}")
try:
r.raise_for_status()
except httpx.HTTPStatusError as exc:
try:
detail = exc.response.json()
except Exception:
detail = exc.response.text if exc.response else ""
raise RuntimeError(f"gate_http_{exc.response.status_code}: {detail}") from exc
if not r.content.strip():
return None
return r.json()
async def fetch_net_position_size(client: GateFuturesLive, contract: str) -> float:
"""该合约净持仓张数(单向模式 size 正负表示方向)。"""
ct = contract.strip().upper()
data = await client._signed("GET", f"{client._prefix}/positions")
if not isinstance(data, list):
return 0.0
for p in data:
if str(p.get("contract") or "").strip().upper() != ct:
continue
return _float(p.get("size"))
return 0.0
async def post_stop_loss_price_order(
client: GateFuturesLive,
*,
contract: str,
side: str,
sl_price: str,
) -> dict[str, Any]:
"""POST 一条 reduce_only 全平止损计划单(与信号开仓 SL 同形态)。"""
ct = contract.strip().upper()
sd = str(side).lower()
if sd not in ("long", "short"):
raise ValueError("invalid_side")
cdata = await client._public_get(f"{client._prefix}/contracts/{ct}")
if not isinstance(cdata, dict):
raise ValueError("contract_not_found")
price_tick = _trigger_price_tick(cdata)
sl_s = _format_trigger_price(float(sl_price), price_tick)
_, sl_tr = _tp_sl_triggers(sd, sl_s, sl_s)
import time as _time
text = ("t-besl" + str(int(_time.time())))[-28:]
body: dict[str, Any] = {
"initial": {
"contract": ct,
"size": 0,
"price": "0",
"tif": "ioc",
"text": text,
"reduce_only": True,
"close": True,
},
"trigger": sl_tr,
}
resp = await client._signed("POST", f"{client._prefix}/price_orders", body_obj=body)
if not isinstance(resp, dict):
raise RuntimeError("price_order_response_invalid")
return resp
async def cancel_price_triggered_order(client: GateFuturesLive, order_id: str | int) -> bool:
"""DELETE /price_orders/{id}。成功删除返回 True。
单已不存在(404,或 Gate 400 + 1034 AUTO_ORDER_NOT_FOUND)视为目标已达成,返回 True,不抛错。
"""
oid = str(order_id).strip()
if not oid:
return False
rel = f"{client._prefix}/price_orders/{oid}"
try:
await client._signed("DELETE", rel)
return True
except RuntimeError as exc:
msg = str(exc)
if "gate_http_404" in msg:
return True
# OCO 一腿触发后另一腿常被交易所联动撤掉;再 DELETE 会得到 400+1034 而非 404。
if "gate_http_400" in msg and (
"AUTO_ORDER_NOT_FOUND" in msg or "'1034'" in msg or '"1034"' in msg
):
return True
raise
def params_from_qs(qs: str) -> dict[str, str]:
if not qs.strip():
return {}
out: dict[str, str] = {}
for part in qs.split("&"):
if "=" in part:
k, v = part.split("=", 1)
out[k] = v
return out
def _float(x: Any, default: float = 0.0) -> float:
try:
if x is None:
return default
return float(x)
except (TypeError, ValueError):
return default
async def fetch_open_contracts(client: GateFuturesLive) -> set[str]:
rel = f"{client._prefix}/positions"
data = await client._signed("GET", rel)
if not isinstance(data, list):
return set()
out: set[str] = set()
for p in data:
if not isinstance(p, dict):
continue
c = str(p.get("contract") or "").strip().upper()
if not c:
continue
if abs(_float(p.get("size"))) > 1e-12:
out.add(c)
return out
def _round_contract_size(raw: float, *, enable_decimal: bool, order_size_min: float) -> str | None:
if raw <= 0 or not math.isfinite(raw):
return None
if enable_decimal:
d = Decimal(str(raw)).quantize(Decimal("0.1"), rounding=ROUND_DOWN)
m = Decimal(str(order_size_min))
if d < m:
return None
s = format(d, "f").rstrip("0").rstrip(".")
return s or None
n = int(math.floor(raw))
if n < int(math.ceil(order_size_min)):
return None
return str(n)
def _tp_sl_triggers(side: str, tp_price: str, sl_price: str) -> tuple[dict[str, Any], dict[str, Any]]:
"""返回 (tp_trigger, sl_trigger) 的 trigger 字段 dictprice 已为合约 tick 对齐后的字符串。"""
if side == "long":
tp_tr = {
"strategy_type": 0,
"price_type": 0,
"price": tp_price,
"rule": 1,
"expiration": PRICE_ORDER_EXPIRATION_SEC,
}
sl_tr = {
"strategy_type": 0,
"price_type": 0,
"price": sl_price,
"rule": 2,
"expiration": PRICE_ORDER_EXPIRATION_SEC,
}
else:
tp_tr = {
"strategy_type": 0,
"price_type": 0,
"price": tp_price,
"rule": 2,
"expiration": PRICE_ORDER_EXPIRATION_SEC,
}
sl_tr = {
"strategy_type": 0,
"price_type": 0,
"price": sl_price,
"rule": 1,
"expiration": PRICE_ORDER_EXPIRATION_SEC,
}
return tp_tr, sl_tr
async def execute_signal_live(settings: Settings, sig: TradeSignal) -> dict:
"""
市价开仓 + 计划委托止盈/止损(reduce_only 市价 IOC)。
以损订仓:用 futures 账户 total × risk_per_trade_frac / (|entry-sl|×quanto_multiplier) 估算张数。
"""
client = GateFuturesLive(settings)
contract = sig.contract.strip().upper()
ot = _safe_order_text(sig.signal_id)
try:
ticker = await client._public_get(f"{client._prefix}/tickers", params={"contract": contract})
last = 0.0
if isinstance(ticker, list) and ticker:
last = _float(ticker[0].get("last"))
elif isinstance(ticker, dict):
last = _float(ticker.get("last"))
entry = float(sig.reference_price) if sig.reference_price else last
if entry <= 0:
return {"status": "error", "reason": "no_entry_price", "detail": "缺少 reference_price 且无法从 ticker 取 last"}
cdata = await client._public_get(f"{client._prefix}/contracts/{contract}")
if not isinstance(cdata, dict):
return {"status": "error", "reason": "contract_not_found", "contract": contract}
mult = _float(cdata.get("quanto_multiplier"))
if mult <= 0:
return {"status": "error", "reason": "invalid_quanto_multiplier", "contract": contract}
order_size_min = _float(cdata.get("order_size_min"), 1.0)
enable_decimal = bool(cdata.get("enable_decimal"))
price_tick = _trigger_price_tick(cdata)
if price_tick is None:
logger.warning("contract %s: missing order_price_round/mark_price_round; TP/SL may be rejected", contract)
accounts = await client._signed("GET", f"{client._prefix}/accounts")
if not isinstance(accounts, dict):
return {"status": "error", "reason": "accounts_unexpected", "detail": str(type(accounts))}
equity = _float(accounts.get("total"))
if equity <= 0:
return {"status": "error", "reason": "zero_equity", "detail": "futures accounts total 为 0"}
risk_usdt = equity * float(settings.risk.risk_per_trade_frac)
sl_dist = abs(entry - float(sig.stop_loss))
if sl_dist <= 0:
return {"status": "error", "reason": "invalid_stop_distance"}
raw_contracts = risk_usdt / (sl_dist * mult)
size_s = _round_contract_size(raw_contracts, enable_decimal=enable_decimal, order_size_min=order_size_min)
if not size_s:
return {
"status": "error",
"reason": "size_too_small",
"detail": f"以损订仓张数不足 order_size_min={order_size_min} raw={raw_contracts:.6f}",
}
if sig.side == "long":
open_size = size_s if not size_s.startswith("-") else size_s.lstrip("-")
else:
open_size = "-" + size_s.lstrip("-")
market_body: dict[str, Any] = {
"contract": contract,
"size": open_size,
"price": "0",
"tif": "ioc",
"text": ot,
"reduce_only": False,
}
order = await client._signed("POST", f"{client._prefix}/orders", body_obj=market_body)
if not isinstance(order, dict):
return {"status": "error", "reason": "order_response_invalid"}
st = str(order.get("status") or "")
finish = str(order.get("finish_as") or "")
left_abs = abs(_float(order.get("left")))
if st != "finished" or left_abs > 1e-12:
return {"status": "error", "reason": "market_not_filled", "order": order}
if finish and finish not in {"filled", "ioc"}:
return {"status": "error", "reason": "market_not_filled", "order": order}
tp_s = _format_trigger_price(float(sig.take_profit), price_tick)
sl_s = _format_trigger_price(float(sig.stop_loss), price_tick)
tp_tr, sl_tr = _tp_sl_triggers(sig.side, tp_s, sl_s)
def _price_order(trigger: dict[str, Any], text_val: str) -> dict[str, Any]:
# Gate:单向全平时 close=true 则 initial.size 必须为 0(否则会报 AUTO_INVALID_PARAM_INITIAL_SIZE
return {
"initial": {
"contract": contract,
"size": 0,
"price": "0",
"tif": "ioc",
"text": text_val,
"reduce_only": True,
"close": True,
},
"trigger": trigger,
}
tp_po = _price_order(tp_tr, "api")
sl_po = _price_order(sl_tr, "app")
# 市价已成交后单独捕获计划委托失败,便于返回 market_order(及已挂上的 TP)供落库、对账
partial_base: dict[str, Any] = {
"status": "error",
"mode": "live",
"contract": contract,
"side": sig.side,
"signal_id": sig.signal_id,
"market_order": order,
"sized_contracts": open_size,
"risk_budget_usdt": round(risk_usdt, 6),
"reference_entry": entry,
"trigger_price_tick": str(price_tick) if price_tick is not None else None,
"take_profit_price_sent": tp_s,
"stop_loss_price_sent": sl_s,
}
try:
tp_resp = await client._signed("POST", f"{client._prefix}/price_orders", body_obj=tp_po)
except RuntimeError as exc:
logger.exception("price_orders_take_profit_failed contract=%s", contract)
return {
**partial_base,
"reason": "gate_api",
"detail": str(exc),
"stage": "take_profit",
}
try:
sl_resp = await client._signed("POST", f"{client._prefix}/price_orders", body_obj=sl_po)
except RuntimeError as exc:
logger.exception("price_orders_stop_loss_failed contract=%s", contract)
out_partial: dict[str, Any] = {
**partial_base,
"reason": "gate_api",
"detail": str(exc),
"stage": "stop_loss",
"take_profit_order": tp_resp,
}
return out_partial
from .breakeven_logic import register_from_execution_result
from .oco_watcher import register_tp_sl_oco_cleanup
if isinstance(tp_resp, dict) and isinstance(sl_resp, dict):
await register_tp_sl_oco_cleanup(
settings,
contract=contract,
tp_order=tp_resp,
sl_order=sl_resp,
)
accepted_out = {
"status": "accepted",
"mode": "live",
"contract": contract,
"side": sig.side,
"signal_id": sig.signal_id,
"market_order": order,
"take_profit_order": tp_resp,
"stop_loss_order": sl_resp,
"sized_contracts": open_size,
"risk_budget_usdt": round(risk_usdt, 6),
"reference_entry": entry,
"trigger_price_tick": str(price_tick) if price_tick is not None else None,
"take_profit_price_sent": tp_s,
"stop_loss_price_sent": sl_s,
}
register_from_execution_result(settings, sig, accepted_out)
return accepted_out
except httpx.HTTPStatusError as exc:
body = exc.response.text if exc.response else ""
logger.exception("gate_http_error %s", body[:500])
return {"status": "error", "reason": "http_error", "detail": str(exc)}
except RuntimeError as exc:
return {"status": "error", "reason": "gate_api", "detail": str(exc)}
except Exception as exc: # noqa: BLE001
logger.exception("execute_signal_live failed")
return {"status": "error", "reason": "exception", "detail": str(exc)}
+319
View File
@@ -0,0 +1,319 @@
from __future__ import annotations
import csv
import io
import time
from typing import Any
from urllib.parse import urlencode
from .config import Settings
from .gate_futures_live import GateFuturesLive
def _keys_ok(settings: Settings) -> bool:
return bool(settings.gate.api_key.strip() and settings.gate.api_secret.strip())
def _default_range_ts() -> tuple[int, int]:
now = int(time.time())
return now - 86400 * 7, now
async def fetch_position_close_timerange(
settings: Settings,
*,
contract: str | None,
from_ts: int,
to_ts: int,
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict[str, Any]] | None, str | None]:
"""GET /futures/{{settle}}/position_close — 历史平仓(与 App「历史仓位」同源类数据)。"""
if not _keys_ok(settings):
return None, None
lim = max(1, min(int(limit), 500))
off = max(0, int(offset))
q: dict[str, Any] = {
"from": int(from_ts),
"to": int(to_ts),
"limit": lim,
"offset": off,
}
if contract and str(contract).strip():
q["contract"] = str(contract).strip().upper()
qs = urlencode(q)
try:
c = GateFuturesLive(settings)
data = await c._signed("GET", f"{c._prefix}/position_close", query_string=qs)
if not isinstance(data, list):
return None, f"unexpected_response:{type(data).__name__}"
return [x for x in data if isinstance(x, dict)], None
except Exception as exc: # noqa: BLE001
return None, str(exc)
async def fetch_my_trades_timerange(
settings: Settings,
*,
contract: str | None,
from_ts: int,
to_ts: int,
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict[str, Any]] | None, str | None]:
"""GET /futures/{{settle}}/my_trades_timerange — 成交记录(Gate 为准)。"""
if not _keys_ok(settings):
return None, None
lim = max(1, min(int(limit), 500))
off = max(0, int(offset))
q: dict[str, Any] = {"from": int(from_ts), "to": int(to_ts), "limit": lim, "offset": off}
if contract and str(contract).strip():
q["contract"] = str(contract).strip().upper()
qs = urlencode(q)
try:
c = GateFuturesLive(settings)
data = await c._signed("GET", f"{c._prefix}/my_trades_timerange", query_string=qs)
if not isinstance(data, list):
return None, f"unexpected_response:{type(data).__name__}"
return [x for x in data if isinstance(x, dict)], None
except Exception as exc: # noqa: BLE001
return None, str(exc)
async def fetch_orders_list(
settings: Settings,
*,
status: str,
contract: str | None,
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict[str, Any]] | None, str | None]:
"""GET /futures/{{settle}}/orders?status=… — 委托列表(Gate 为准)。"""
if not _keys_ok(settings):
return None, None
lim = max(1, min(int(limit), 500))
off = max(0, int(offset))
st = (status or "finished").strip().lower()
if st not in ("open", "finished"):
st = "finished"
q: dict[str, Any] = {"status": st, "limit": lim, "offset": off}
if contract and str(contract).strip():
q["contract"] = str(contract).strip().upper()
qs = urlencode(q)
try:
c = GateFuturesLive(settings)
data = await c._signed("GET", f"{c._prefix}/orders", query_string=qs)
if not isinstance(data, list):
return None, f"unexpected_response:{type(data).__name__}"
return [x for x in data if isinstance(x, dict)], None
except Exception as exc: # noqa: BLE001
return None, str(exc)
def trades_rows_to_csv(rows: list[dict[str, Any]]) -> str:
"""成交记录 → CSV 文本(UTF-8 BOM 便于 Excel 打开)。"""
buf = io.StringIO()
buf.write("\ufeff")
w = csv.writer(buf)
w.writerow(
[
"trade_id",
"create_time",
"contract",
"order_id",
"size",
"price",
"fee",
"point_fee",
"role",
"text",
"close_size",
"pnl",
]
)
for r in rows:
w.writerow(
[
r.get("trade_id") or r.get("id") or "",
r.get("create_time"),
r.get("contract") or "",
r.get("order_id") or "",
r.get("size") or "",
r.get("price") or "",
r.get("fee") or "",
r.get("point_fee") or "",
r.get("role") or "",
r.get("text") or "",
r.get("close_size") or "",
r.get("pnl") if r.get("pnl") is not None else "",
]
)
return buf.getvalue()
def orders_rows_to_csv(rows: list[dict[str, Any]]) -> str:
buf = io.StringIO()
buf.write("\ufeff")
w = csv.writer(buf)
w.writerow(
[
"id",
"create_time",
"finish_time",
"contract",
"size",
"left",
"price",
"fill_price",
"status",
"finish_as",
"tif",
"text",
"is_reduce_only",
]
)
for r in rows:
w.writerow(
[
r.get("id") or "",
r.get("create_time"),
r.get("finish_time"),
r.get("contract") or "",
r.get("size") or "",
r.get("left") or "",
r.get("price") or "",
r.get("fill_price") or "",
r.get("status") or "",
r.get("finish_as") or "",
r.get("tif") or "",
r.get("text") or "",
r.get("is_reduce_only"),
]
)
return buf.getvalue()
async def collect_trades_rows(
settings: Settings,
*,
contract: str | None,
from_ts: int,
to_ts: int,
max_rows: int = 2000,
) -> tuple[list[dict[str, Any]] | None, str | None]:
"""分页拉取成交原始行(上限 max_rows,单页最多 500)。"""
if not _keys_ok(settings):
return None, None
cap = max(1, min(int(max_rows), 100_000))
page = 500
all_rows: list[dict[str, Any]] = []
offset = 0
while len(all_rows) < cap:
lim = min(page, cap - len(all_rows))
chunk, err = await fetch_my_trades_timerange(
settings,
contract=contract,
from_ts=from_ts,
to_ts=to_ts,
limit=lim,
offset=offset,
)
if err:
return None, err
if not chunk:
break
all_rows.extend(chunk)
offset += len(chunk)
if len(chunk) < lim:
break
return all_rows[:cap], None
async def collect_position_close_rows(
settings: Settings,
*,
contract: str | None,
from_ts: int,
to_ts: int,
max_rows: int = 2000,
) -> tuple[list[dict[str, Any]] | None, str | None]:
"""分页拉取历史平仓原始行(上限 max_rows,单页最多 500)。"""
if not _keys_ok(settings):
return None, None
cap = max(1, min(int(max_rows), 100_000))
page = 500
all_rows: list[dict[str, Any]] = []
offset = 0
while len(all_rows) < cap:
lim = min(page, cap - len(all_rows))
chunk, err = await fetch_position_close_timerange(
settings,
contract=contract,
from_ts=from_ts,
to_ts=to_ts,
limit=lim,
offset=offset,
)
if err:
return None, err
if not chunk:
break
all_rows.extend(chunk)
offset += len(chunk)
if len(chunk) < lim:
break
return all_rows[:cap], None
async def collect_trades_csv(
settings: Settings,
*,
contract: str | None,
from_ts: int,
to_ts: int,
max_rows: int = 2000,
) -> tuple[str | None, str | None]:
"""分页拉取成交并拼成 CSV(上限 max_rows)。"""
all_rows, err = await collect_trades_rows(
settings,
contract=contract,
from_ts=from_ts,
to_ts=to_ts,
max_rows=max_rows,
)
if err:
return None, err
if all_rows is None:
return None, None
return trades_rows_to_csv(all_rows), None
async def collect_orders_csv(
settings: Settings,
*,
status: str,
contract: str | None,
max_rows: int = 2000,
) -> tuple[str | None, str | None]:
cap = max(1, min(int(max_rows), 5000))
page = 100
all_rows: list[dict[str, Any]] = []
offset = 0
while len(all_rows) < cap:
lim = min(page, cap - len(all_rows))
chunk, err = await fetch_orders_list(
settings,
status=status,
contract=contract,
limit=lim,
offset=offset,
)
if err:
return None, err
if not chunk:
break
all_rows.extend(chunk)
offset += len(chunk)
if len(chunk) < lim:
break
return orders_rows_to_csv(all_rows[:cap]), None
+263
View File
@@ -0,0 +1,263 @@
from __future__ import annotations
import math
import time
from typing import Any
from .config import Settings
from .gate_futures_live import (
PRICE_ORDER_EXPIRATION_SEC,
GateFuturesLive,
cancel_price_triggered_order,
fetch_net_position_size,
)
def _float_field(x: Any, default: float = 0.0) -> float:
try:
if x is None:
return default
return float(x)
except (TypeError, ValueError):
return default
def _slim_futures_position(row: dict[str, Any]) -> dict[str, Any]:
"""GET /positions 单行摘要(面板用)。"""
return {
"contract": str(row.get("contract") or "").strip().upper(),
"size": row.get("size"),
"entry_price": row.get("entry_price") if row.get("entry_price") is not None else row.get("avg_entry_price"),
"mark_price": row.get("mark_price"),
"unrealised_pnl": row.get("unrealised_pnl"),
"leverage": row.get("leverage"),
"value": row.get("value"),
"liq_price": row.get("liq_price"),
"margin": row.get("margin"),
"mode": row.get("mode"),
}
async def list_futures_positions(
settings: Settings, *, limit: int = 80
) -> tuple[list[dict[str, Any]] | None, str | None]:
"""GET /futures/{{settle}}/positions,仅返回 |size|>0 的合约(最多 limit 条)。"""
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
return None, None
cap = max(1, min(int(limit), 200))
try:
c = GateFuturesLive(settings)
data = await c._signed("GET", f"{c._prefix}/positions")
if not isinstance(data, list):
return None, f"unexpected_response:{type(data).__name__}"
out: list[dict[str, Any]] = []
for row in data:
if not isinstance(row, dict):
continue
if abs(_float_field(row.get("size"))) <= 1e-12:
continue
out.append(_slim_futures_position(row))
if len(out) >= cap:
break
return out, None
except Exception as exc: # noqa: BLE001
return None, str(exc)
async def read_futures_balance(settings: Settings) -> tuple[dict[str, Any] | None, str | None]:
"""GET /futures/{{settle}}/accounts,返回 (payload, error_message)。"""
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
return None, None
try:
c = GateFuturesLive(settings)
data = await c._signed("GET", f"{c._prefix}/accounts")
if isinstance(data, dict):
return data, None
return None, f"unexpected_response:{type(data).__name__}"
except Exception as exc: # noqa: BLE001
return None, str(exc)
def _slim_price_order(row: dict[str, Any]) -> dict[str, Any]:
"""面板展示用字段(避免整对象过大)。"""
ini = row.get("initial") if isinstance(row.get("initial"), dict) else {}
tr = row.get("trigger") if isinstance(row.get("trigger"), dict) else {}
oid = row.get("id_string")
if oid is None and row.get("id") is not None:
oid = str(row.get("id"))
return {
"order_id": str(oid or "").strip(),
"contract": str(ini.get("contract") or "").strip().upper(),
"status": str(row.get("status") or ""),
"order_type": str(row.get("order_type") or ""),
"trigger_price": str(tr.get("price") or ""),
"rule": tr.get("rule"),
"size": ini.get("size"),
"reduce_only": ini.get("reduce_only"),
"create_time": row.get("create_time"),
}
async def list_open_price_orders(
settings: Settings, *, limit: int = 50
) -> tuple[list[dict[str, Any]] | None, str | None]:
"""GET /futures/{{settle}}/price_orders?status=open,返回精简列表。"""
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
return None, None
lim = max(1, min(int(limit), 100))
qs = f"status=open&limit={lim}"
try:
c = GateFuturesLive(settings)
data = await c._signed("GET", f"{c._prefix}/price_orders", query_string=qs)
if not isinstance(data, list):
return None, f"unexpected_response:{type(data).__name__}"
out: list[dict[str, Any]] = []
for row in data:
if isinstance(row, dict):
out.append(_slim_price_order(row))
return out, None
except Exception as exc: # noqa: BLE001
return None, str(exc)
async def cancel_plan_price_order(settings: Settings, order_id: str) -> tuple[bool, str | None]:
"""撤销一条计划委托(price_orders)。"""
oid = (order_id or "").strip()
if not oid:
return False, "empty_order_id"
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
return False, "missing_api_keys"
try:
c = GateFuturesLive(settings)
await cancel_price_triggered_order(c, oid)
return True, None
except Exception as exc: # noqa: BLE001
return False, str(exc)
async def post_test_market_order(settings: Settings, *, contract: str, side: str, size_qty: int) -> dict[str, Any]:
"""
极小市价 IOC 测试单。需 config gate.test_orders_enabled=true。
size_qty 会被限制在 [1, test_max_contracts]。
"""
if not settings.gate.test_orders_enabled:
return {"ok": False, "error": "test_orders_disabled", "hint": "请在 config.yaml 设置 gate.test_orders_enabled: true"}
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
return {"ok": False, "error": "missing_api_keys"}
cap = int(settings.gate.test_max_contracts)
n = max(1, min(int(size_qty), cap))
c = GateFuturesLive(settings)
ct = contract.strip().upper()
if "_" not in ct or not ct.endswith("_USDT"):
return {"ok": False, "error": "invalid_contract", "contract": ct}
sz = str(n) if side == "long" else f"-{n}"
text = "t-tst" + str(int(time.time()))[-12:]
body: dict[str, Any] = {
"contract": ct,
"size": sz,
"price": "0",
"tif": "ioc",
"text": text[:28],
"reduce_only": False,
}
order = await c._signed("POST", f"{c._prefix}/orders", body_obj=body)
return {"ok": True, "order": order, "request": body}
def _format_order_size_signed(value: float) -> str | None:
if value == 0 or not math.isfinite(value):
return None
if abs(value - round(value)) < 1e-8:
return str(int(round(value)))
s = f"{value:.10f}".rstrip("0").rstrip(".")
return s if s else None
async def market_close_futures_position(settings: Settings, *, contract: str) -> tuple[dict[str, Any] | None, str | None]:
"""市价 IOC + reduce_only 平掉该合约全部净持仓(单向 size 正负)。"""
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
return None, "missing_api_keys"
if settings.gate.dry_run:
return None, "dry_run_enabled"
c = GateFuturesLive(settings)
ct = contract.strip().upper()
try:
net = await fetch_net_position_size(c, ct)
except Exception as exc: # noqa: BLE001
return None, str(exc)
if abs(net) < 1e-12:
return None, "no_position"
flip = -net
sz_s = _format_order_size_signed(flip)
if not sz_s:
return None, "invalid_close_size"
text = ("t-mcls" + str(int(time.time())))[-28:]
body: dict[str, Any] = {
"contract": ct,
"size": sz_s,
"price": "0",
"tif": "ioc",
"text": text,
"reduce_only": True,
}
try:
order = await c._signed("POST", f"{c._prefix}/orders", body_obj=body)
if not isinstance(order, dict):
return {"response": order}, None
return order, None
except Exception as exc: # noqa: BLE001
return None, str(exc)
async def post_reduce_close_price_order(
settings: Settings,
*,
contract: str,
trigger_price: str,
rule: int,
) -> tuple[dict[str, Any] | None, str | None]:
"""POST price_orders:全平 reduce_only 条件单(与信号挂 TP/SL 同一 initial 形态)。"""
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
return None, "missing_api_keys"
if settings.gate.dry_run:
return None, "dry_run_enabled"
ct = contract.strip().upper()
try:
fp = float(str(trigger_price).strip())
except ValueError:
return None, "invalid_trigger_price"
if fp <= 0 or not math.isfinite(fp):
return None, "invalid_trigger_price"
r = int(rule)
if r not in (1, 2):
return None, "invalid_rule"
trig: dict[str, Any] = {
"strategy_type": 0,
"price_type": 0,
"price": str(fp),
"rule": r,
"expiration": PRICE_ORDER_EXPIRATION_SEC,
}
text = ("t-padd" + str(int(time.time())))[-28:]
body: dict[str, Any] = {
"initial": {
"contract": ct,
"size": 0,
"price": "0",
"tif": "ioc",
"text": text,
"reduce_only": True,
"close": True,
},
"trigger": trig,
}
c = GateFuturesLive(settings)
try:
resp = await c._signed("POST", f"{c._prefix}/price_orders", body_obj=body)
if not isinstance(resp, dict):
return {"response": resp}, None
return resp, None
except Exception as exc: # noqa: BLE001
return None, str(exc)
@@ -0,0 +1,49 @@
"""Gate 计划委托触发价:按合约 tick 对齐(仅标准库,可被离线测试直接导入)。"""
from __future__ import annotations
from decimal import ROUND_HALF_UP, Decimal
from typing import Any
def _parse_positive_decimal(raw: Any) -> Decimal | None:
if raw is None:
return None
s = str(raw).strip()
if not s:
return None
try:
t = Decimal(s)
except Exception:
return None
if t <= 0 or not t.is_finite():
return None
return t
def _trigger_price_tick(cdata: dict[str, Any]) -> Decimal | None:
"""Gate 合约最小价格跳动;优先 order_price_round,其次 mark_price_round(小币种字段齐全)。"""
for key in ("order_price_round", "mark_price_round"):
t = _parse_positive_decimal(cdata.get(key))
if t is not None:
return t
return None
def _decimal_plain_str(d: Decimal) -> str:
s = format(d, "f")
if "." in s:
s = s.rstrip("0").rstrip(".")
return s or "0"
def _format_trigger_price(price: float, tick: Decimal | None) -> str:
"""将信号里的浮点止盈/止损价对齐到合约 tick,避免 4752.700000000001 这类导致 price_orders 400。"""
p = Decimal(str(price))
if not p.is_finite():
raise ValueError("invalid trigger price")
if tick is not None and tick > 0:
q = (p / tick).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
snapped = q * tick
return _decimal_plain_str(snapped)
coarse = p.quantize(Decimal("1e-12"), rounding=ROUND_HALF_UP)
return _decimal_plain_str(coarse)
+708
View File
@@ -0,0 +1,708 @@
from __future__ import annotations
import csv
import hashlib
import io
import logging
import time
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import FastAPI, Header, HTTPException, Query, Request, status
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from starlette.middleware.sessions import SessionMiddleware
from .config import load_settings
from .executor import handle_signal
from .gate_history import (
_default_range_ts,
collect_orders_csv,
collect_trades_csv,
fetch_my_trades_timerange,
fetch_orders_list,
)
from .gate_operations import (
cancel_plan_price_order,
list_futures_positions,
list_open_price_orders,
market_close_futures_position,
post_reduce_close_price_order,
post_test_market_order,
read_futures_balance,
)
from .models_signal import TradeSignal
from .models_test import GateTestRequest
from .positions import PositionBook
from .proxy_util import effective_proxy_url
from .breakeven_active_store import remove_active
from .breakeven_prefs_store import (
read_effective_global_enabled,
read_prefs_snapshot,
write_contract_enabled,
write_global_enabled,
)
from .breakeven_watcher import build_breakeven_state_for_api, start_breakeven_watcher, stop_breakeven_watcher
from .risk_prefs_store import read_effective_min_reward_risk_ratio, write_min_reward_risk_ratio
from .signal_history import SignalHistory
from .signal_metrics import augment_signal_result, compute_signal_stream_metrics
from .signal_repository import SignalRepository
from .stats import build_dashboard_stats
from .wecom_notify import notify_manual_close, notify_signal_db_insert_failed, notify_signal_execution
settings = load_settings()
book = PositionBook(settings.risk.max_open_positions)
signal_history = SignalHistory()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
logger = logging.getLogger(__name__)
root_dir = Path(__file__).resolve().parent.parent
templates = Jinja2Templates(directory=str(root_dir / "templates"))
# 信号流:每条 POST /v1/signal 写入 SQLite;配置里 path 留空时会在 DatabaseConfig 中回退为 ./runtime/signals.sqlite
signal_repo: SignalRepository | None = SignalRepository.from_settings(
settings.database.sqlite_path,
root_dir,
)
if signal_repo:
try:
signal_repo.init_schema()
except Exception: # noqa: BLE001
logger.exception("signal_db_init_failed")
signal_repo = None
def _hash_password(plain: str) -> str:
return hashlib.sha256(plain.encode("utf-8")).hexdigest()
def _asset_version() -> str:
mt = 0
for name in ("exec.js", "style.css", "theme-matrix-terminal.css"):
try:
mt = max(mt, int((root_dir / "static" / name).stat().st_mtime))
except OSError:
continue
return str(mt or 1)
def _password_hash() -> str:
return _hash_password(settings.auth.password)
class LoginBody(BaseModel):
username: str = Field(..., min_length=1)
password: str = Field(..., min_length=1)
class CancelPlanOrderBody(BaseModel):
order_id: str = Field(..., min_length=1, description="price_orders 的 id 或 id_string")
class ClosePositionBody(BaseModel):
contract: str = Field(..., min_length=3, max_length=64, description="如 BTC_USDT")
class ManualPriceOrderBody(BaseModel):
contract: str = Field(..., min_length=3, max_length=64)
trigger_price: str = Field(..., min_length=1, max_length=32)
rule: int = Field(1, ge=1, le=2, description="Gate:1 为价格>=触发价,2 为价格<=触发价")
class RiskPrefsBody(BaseModel):
min_reward_risk_ratio: float = Field(..., ge=0.1, le=50.0, description="面板保存的最低盈亏比")
class BreakevenPrefsBody(BaseModel):
global_enabled: bool | None = Field(None, description="全局移动保本开关")
contract: str | None = Field(None, min_length=3, max_length=64)
enabled: bool | None = Field(None, description="单合约覆盖;需同时传 contract")
@asynccontextmanager
async def _lifespan(_app: FastAPI):
log_path = Path(settings.app.log_file)
log_path.parent.mkdir(parents=True, exist_ok=True)
p = effective_proxy_url(settings.proxy.enabled, settings.proxy.url)
logger.info(
"executor %s:%s dry_run=%s max_positions=%s proxy=%s wecom=%s",
settings.app.host,
settings.app.port,
settings.gate.dry_run,
settings.risk.max_open_positions,
"on" if p else "off",
"on" if (settings.wecom.enabled and (settings.wecom.webhook_url or "").strip()) else "off",
)
start_breakeven_watcher(settings, signal_repo)
try:
yield
finally:
await stop_breakeven_watcher()
app = FastAPI(title="Gate Order Executor", version="0.2.0", lifespan=_lifespan)
app.add_middleware(
SessionMiddleware,
secret_key=settings.app.session_secret,
max_age=60 * 60 * 24 * 7,
same_site="lax",
https_only=False,
)
app.mount("/static", StaticFiles(directory=str(root_dir / "static")), name="static")
def _session_ok(request: Request) -> bool:
if not settings.auth.enabled:
return True
return request.session.get("logged_in") is True
def _require_ui_session(request: Request) -> None:
if not _session_ok(request):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="login required")
def _norm_contract_hist(contract: str | None) -> str | None:
if not contract:
return None
c = str(contract).strip()
return c.upper() if c else None
def _resolve_timerange(from_ts: int | None, to_ts: int | None) -> tuple[int, int]:
"""未传 from/to 时用最近 7 天;只传其一则补齐另一端。"""
now = int(time.time())
f, t = from_ts, to_ts
if f is None and t is None:
return _default_range_ts()
if f is None:
tt = int(t or now)
return tt - 86400 * 7, tt
if t is None:
ff = int(f)
return ff, now
return int(f), int(t)
@app.get("/health")
async def health() -> dict:
p = effective_proxy_url(settings.proxy.enabled, settings.proxy.url)
return {
"ok": True,
"dry_run": settings.gate.dry_run,
"open_slots": book.count(),
"proxy_on": bool(p),
"signal_db": bool(signal_repo),
"signals_persisted": bool(signal_repo),
"signals_sqlite_path": (settings.database.sqlite_path or "").strip(),
}
@app.get("/", response_model=None)
async def root(request: Request) -> RedirectResponse:
if settings.auth.enabled and not _session_ok(request):
return RedirectResponse("/login", status_code=302)
return RedirectResponse("/dashboard", status_code=302)
@app.get("/login", response_model=None)
async def login_page(request: Request) -> HTMLResponse | RedirectResponse:
if not settings.auth.enabled:
return RedirectResponse("/dashboard", status_code=302)
if _session_ok(request):
return RedirectResponse("/dashboard", status_code=302)
return templates.TemplateResponse(
request,
"login.html",
{"asset_version": _asset_version()},
)
@app.post("/login", response_model=None)
async def login_post(request: Request, body: LoginBody) -> JSONResponse | RedirectResponse:
if not settings.auth.enabled:
return JSONResponse({"ok": True, "redirect": "/dashboard"})
if body.username.strip() != settings.auth.username.strip() or _hash_password(body.password) != _password_hash():
return JSONResponse({"ok": False, "detail": "账号或密码错误"}, status_code=401)
request.session["logged_in"] = True
return JSONResponse({"ok": True, "redirect": "/dashboard"})
@app.get("/logout", response_model=None)
async def logout(request: Request) -> RedirectResponse:
request.session.clear()
return RedirectResponse("/login" if settings.auth.enabled else "/", status_code=302)
@app.get("/dashboard", response_model=None)
async def dashboard(request: Request) -> HTMLResponse | RedirectResponse:
if settings.auth.enabled and not _session_ok(request):
return RedirectResponse("/login", status_code=302)
return templates.TemplateResponse(
request,
"dashboard.html",
{
"username": settings.auth.username if settings.auth.enabled else "local",
"asset_version": _asset_version(),
},
)
@app.get("/api/state")
async def api_state(request: Request) -> dict:
_require_ui_session(request)
p = effective_proxy_url(settings.proxy.enabled, settings.proxy.url)
fa: dict | None = None
fa_err: str | None = None
po: list[dict] | None = None
po_err: str | None = None
ex_pos: list[dict] | None = None
ex_pos_err: str | None = None
if settings.gate.api_key.strip() and settings.gate.api_secret.strip():
fa, fa_err = await read_futures_balance(settings)
po, po_err = await list_open_price_orders(settings)
ex_pos, ex_pos_err = await list_futures_positions(settings)
return {
"dry_run": settings.gate.dry_run,
"live_trading_enabled": (not settings.gate.dry_run)
and bool(settings.gate.api_key.strip() and settings.gate.api_secret.strip()),
"gate_api_configured": bool(settings.gate.api_key.strip() and settings.gate.api_secret.strip()),
"test_orders_enabled": bool(settings.gate.test_orders_enabled),
"test_max_contracts": int(settings.gate.test_max_contracts),
"futures_account": fa,
"futures_account_error": fa_err,
"open_price_orders": po,
"open_price_orders_error": po_err,
"proxy": {
"enabled": settings.proxy.enabled,
"effective": bool(p),
"url": settings.proxy.url if settings.proxy.enabled else "",
},
"risk": {
"risk_per_trade_frac": settings.risk.risk_per_trade_frac,
"max_open_positions": settings.risk.max_open_positions,
"scheme": settings.risk.scheme,
"min_reward_risk_ratio": read_effective_min_reward_risk_ratio(settings),
"min_reward_risk_ratio_default": float(settings.risk.min_reward_risk_ratio),
},
"breakeven": await build_breakeven_state_for_api(
settings,
exchange_positions=ex_pos if isinstance(ex_pos, list) else None,
),
"positions": {
"open_slot_count": book.count(),
"exchange": ex_pos,
"exchange_error": ex_pos_err,
},
"recent_signals": _recent_signals_for_state(),
"signals_persisted": bool(signal_repo),
"signals_sqlite_path": (settings.database.sqlite_path or "").strip(),
}
def _recent_signals_for_state() -> list[dict]:
if not signal_repo:
return signal_history.list_recent()
try:
return signal_repo.list_recent(100)
except Exception: # noqa: BLE001
logger.exception("signal_db_list_failed")
return signal_history.list_recent()
def _signals_export_rows(limit: int = 500) -> list[dict]:
if signal_repo:
try:
return signal_repo.list_recent(limit)
except Exception: # noqa: BLE001
logger.exception("signal_export_list_failed")
return signal_history.list_recent()
def _test_http_status(body: GateTestRequest, out: dict) -> int:
if body.action == "balance":
return 502 if out.get("error") else 200
return 400 if out.get("ok") is False else 200
async def _run_gate_test(body: GateTestRequest) -> dict:
if body.action == "balance":
data, err = await read_futures_balance(settings)
return {"action": "balance", "balance": data, "error": err}
if body.action != "micro_market":
return {"ok": False, "error": "unsupported_action"}
if not body.contract.strip():
return {"ok": False, "error": "contract_required"}
return await post_test_market_order(
settings,
contract=body.contract.strip(),
side=body.side,
size_qty=body.size,
)
@app.post("/api/test")
async def api_test(request: Request, body: GateTestRequest) -> JSONResponse:
_require_ui_session(request)
out = await _run_gate_test(body)
return JSONResponse(out, status_code=_test_http_status(body, out))
@app.post("/api/risk-prefs")
async def api_risk_prefs(request: Request, body: RiskPrefsBody) -> JSONResponse:
"""面板保存最低盈亏比到 runtime/risk_prefs.json(需登录会话)。"""
_require_ui_session(request)
try:
v = write_min_reward_risk_ratio(body.min_reward_risk_ratio)
except ValueError as exc:
return JSONResponse({"ok": False, "detail": str(exc)}, status_code=400)
return JSONResponse({"ok": True, "min_reward_risk_ratio": v})
@app.post("/api/breakeven-prefs")
async def api_breakeven_prefs(request: Request, body: BreakevenPrefsBody) -> JSONResponse:
"""保存移动保本全局/单合约开关到 runtime/breakeven_prefs.json。"""
_require_ui_session(request)
out: dict[str, Any] = {"ok": True}
if body.global_enabled is not None:
out["global_enabled"] = write_global_enabled(bool(body.global_enabled))
if body.contract is not None and body.enabled is not None:
try:
ct, en = write_contract_enabled(body.contract, bool(body.enabled))
except ValueError as exc:
return JSONResponse({"ok": False, "detail": str(exc)}, status_code=400)
remove_active(ct)
out["contract"] = ct
out["enabled"] = en
if body.global_enabled is None and (body.contract is None or body.enabled is None):
return JSONResponse({"ok": False, "detail": "nothing_to_save"}, status_code=400)
out["global_enabled"] = read_effective_global_enabled(settings)
out["prefs"] = read_prefs_snapshot()
return JSONResponse(out)
@app.post("/api/positions/market_close")
async def api_positions_market_close(request: Request, body: ClosePositionBody) -> JSONResponse:
_require_ui_session(request)
ct = body.contract.strip().upper()
order, err = await market_close_futures_position(settings, contract=ct)
if err:
bad_req = {
"missing_api_keys",
"dry_run_enabled",
"no_position",
"invalid_close_size",
}
code = 400 if err in bad_req else 502
try:
await notify_manual_close(settings, contract=ct, ok=False, detail=err, order=None)
except Exception: # noqa: BLE001
logger.exception("wecom_notify_manual_close_failed")
return JSONResponse({"ok": False, "detail": err}, status_code=code)
book.release(ct)
remove_active(ct)
try:
await notify_manual_close(settings, contract=ct, ok=True, detail=None, order=order if isinstance(order, dict) else None)
except Exception: # noqa: BLE001
logger.exception("wecom_notify_manual_close_failed")
return JSONResponse({"ok": True, "order": order})
@app.post("/api/price_orders/manual")
async def api_price_orders_manual(request: Request, body: ManualPriceOrderBody) -> JSONResponse:
_require_ui_session(request)
resp, err = await post_reduce_close_price_order(
settings,
contract=body.contract.strip().upper(),
trigger_price=body.trigger_price.strip(),
rule=body.rule,
)
if err:
bad_req = {
"missing_api_keys",
"dry_run_enabled",
"invalid_trigger_price",
"invalid_rule",
}
code = 400 if err in bad_req else 502
return JSONResponse({"ok": False, "detail": err}, status_code=code)
return JSONResponse({"ok": True, "price_order": resp})
@app.post("/api/price_orders/cancel")
async def api_cancel_plan_order(request: Request, body: CancelPlanOrderBody) -> JSONResponse:
_require_ui_session(request)
ok, err = await cancel_plan_price_order(settings, body.order_id)
if ok:
return JSONResponse({"ok": True})
return JSONResponse({"ok": False, "detail": err or "cancel_failed"}, status_code=400)
@app.get("/api/stats/summary")
async def api_stats_summary(
request: Request,
contract: str | None = None,
) -> dict:
"""正式统计:日/周/月(上海 08:00 统计日)基于 Gate 历史平仓 position_close 的 pnl 聚合。"""
_require_ui_session(request)
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "api keys not configured")
c = _norm_contract_hist(contract)
return await build_dashboard_stats(settings, contract=c)
@app.get("/api/gate/trades")
async def api_gate_trades(
request: Request,
contract: str | None = None,
from_ts: int | None = Query(default=None, alias="from"),
to_ts: int | None = Query(default=None, alias="to"),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
) -> dict:
"""成交:Gate `GET /futures/{{settle}}/my_trades_timerange`(与面板「下载」同源)。"""
_require_ui_session(request)
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "api keys not configured")
f, t = _resolve_timerange(from_ts, to_ts)
c = _norm_contract_hist(contract)
rows, err = await fetch_my_trades_timerange(
settings, contract=c, from_ts=f, to_ts=t, limit=limit, offset=offset
)
return {
"source": "gate",
"endpoint": "futures/my_trades_timerange",
"contract": c,
"from": f,
"to": t,
"limit": limit,
"offset": offset,
"rows": rows,
"error": err,
}
@app.get("/api/gate/orders_history")
async def api_gate_orders_history(
request: Request,
status: str = Query("finished"),
contract: str | None = None,
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
) -> dict:
"""委托列表:Gate `GET /futures/{{settle}}/orders`status=open|finished)。"""
_require_ui_session(request)
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "api keys not configured")
st = status.strip().lower()
if st not in ("open", "finished"):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "status must be open or finished")
c = _norm_contract_hist(contract)
rows, err = await fetch_orders_list(
settings, status=st, contract=c, limit=limit, offset=offset
)
return {
"source": "gate",
"endpoint": "futures/orders",
"status": st,
"contract": c,
"limit": limit,
"offset": offset,
"rows": rows,
"error": err,
}
@app.get("/api/gate/trades.csv", response_model=None)
async def api_gate_trades_csv(
request: Request,
contract: str | None = None,
from_ts: int | None = Query(default=None, alias="from"),
to_ts: int | None = Query(default=None, alias="to"),
max_rows: int = Query(2000, ge=1, le=5000, alias="max"),
) -> Response:
_require_ui_session(request)
f, t = _resolve_timerange(from_ts, to_ts)
c = _norm_contract_hist(contract)
csv_text, err = await collect_trades_csv(
settings, contract=c, from_ts=f, to_ts=t, max_rows=max_rows
)
if err:
return JSONResponse({"detail": err}, status_code=502)
if csv_text is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "api keys not configured")
return Response(
content=csv_text.encode("utf-8"),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="gate_futures_trades.csv"'},
)
@app.get("/api/gate/orders_history.csv", response_model=None)
async def api_gate_orders_history_csv(
request: Request,
status: str = Query("finished"),
contract: str | None = None,
max_rows: int = Query(2000, ge=1, le=5000, alias="max"),
) -> Response:
_require_ui_session(request)
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "api keys not configured")
st = status.strip().lower()
if st not in ("open", "finished"):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "status must be open or finished")
c = _norm_contract_hist(contract)
csv_text, err = await collect_orders_csv(
settings, status=st, contract=c, max_rows=max_rows
)
if err:
return JSONResponse({"detail": err}, status_code=502)
if csv_text is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "api keys not configured")
fn = "gate_futures_orders_open.csv" if st == "open" else "gate_futures_orders_finished.csv"
return Response(
content=csv_text.encode("utf-8"),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{fn}"'},
)
@app.get("/api/signals/export.csv", response_model=None)
async def api_signals_export_csv(request: Request) -> Response:
"""面板「信号流」导出;需登录会话。"""
_require_ui_session(request)
rows = _signals_export_rows(500)
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(
[
"ts_unix",
"time_utc",
"signal_id",
"contract",
"side",
"reference_price_used",
"take_profit_display",
"stop_loss_display",
"reward_risk_ratio",
"result_status",
"result_reason",
]
)
for row in rows:
s = row.get("signal") or {}
r = row.get("result") or {}
ts = row.get("ts")
try:
ts_f = float(ts) if ts is not None else 0.0
except (TypeError, ValueError):
ts_f = 0.0
tstr = datetime.fromtimestamp(ts_f, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
rp = r.get("reference_price_used")
if rp is None:
rp = r.get("reference_entry")
tp = r.get("take_profit_display")
if tp is None:
tp = r.get("take_profit_price_sent")
if tp is None:
tp = s.get("take_profit")
sl = r.get("stop_loss_display")
if sl is None:
sl = r.get("stop_loss_price_sent")
if sl is None:
sl = s.get("stop_loss")
rr = r.get("reward_risk_ratio")
w.writerow(
[
f"{ts_f:.6f}",
tstr,
s.get("signal_id") or "",
s.get("contract") or "",
s.get("side") or "",
"" if rp is None else rp,
"" if tp is None else tp,
"" if sl is None else sl,
"" if rr is None else rr,
r.get("status") or "",
r.get("reason") or "",
]
)
return Response(
content=buf.getvalue().encode("utf-8-sig"),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="signal_stream.csv"'},
)
@app.post("/v1/test")
async def v1_test(
body: GateTestRequest,
x_webhook_secret: str | None = Header(default=None, alias="X-Webhook-Secret"),
) -> JSONResponse:
expected = (settings.security.webhook_secret or "").strip()
if not expected or (x_webhook_secret or "").strip() != expected:
raise HTTPException(status_code=401, detail="invalid webhook secret")
out = await _run_gate_test(body)
return JSONResponse(out, status_code=_test_http_status(body, out))
@app.post("/v1/signal")
async def post_signal(
body: TradeSignal,
x_webhook_secret: str | None = Header(default=None, alias="X-Webhook-Secret"),
) -> JSONResponse:
expected = (settings.security.webhook_secret or "").strip()
if not expected or (x_webhook_secret or "").strip() != expected:
raise HTTPException(status_code=401, detail="invalid webhook secret")
min_rr = read_effective_min_reward_risk_ratio(settings)
try:
m = await compute_signal_stream_metrics(settings, body, prior=None)
except Exception as exc: # noqa: BLE001
logger.warning("signal_metrics_pre_gate failed signal_id=%s: %s", body.signal_id, exc)
out = {
"status": "skipped",
"reason": "reward_risk_missing",
"min_reward_risk_ratio": min_rr,
"reward_risk_reason": "metrics_failed",
"metrics_error": str(exc),
}
else:
rr = m.get("reward_risk_ratio")
if rr is None:
out = {"status": "skipped", "reason": "reward_risk_missing", "min_reward_risk_ratio": min_rr}
out.update(m)
elif float(rr) < min_rr:
out = {"status": "skipped", "reason": "reward_risk_below_min", "min_reward_risk_ratio": min_rr}
out.update(m)
else:
out = await handle_signal(settings, book, body)
out = await augment_signal_result(settings, body, out)
code = 200 if out.get("status") in {"accepted", "skipped"} else 500
signal_history.push({"signal": body.model_dump(), "result": out})
if signal_repo:
try:
signal_repo.insert_run(body.model_dump(), out, code)
except Exception as exc: # noqa: BLE001
logger.exception("signal_db_insert_failed")
try:
await notify_signal_db_insert_failed(
settings,
signal_id=str(body.signal_id or ""),
detail=str(exc),
)
except Exception: # noqa: BLE001
logger.exception("wecom_notify_signal_db_failed")
try:
await notify_signal_execution(settings, signal=body.model_dump(), result=out, http_status=code)
except Exception: # noqa: BLE001
logger.exception("wecom_notify_signal_failed")
return JSONResponse(out, status_code=code)
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
Side = Literal["long", "short"]
class TradeSignal(BaseModel):
"""
扫描端在 TRIGGER(且你允许自动执行)时 POST 的载荷。
TP/SL 对应推送里「方案 A」已算好的价格;执行器永远按 scheme A 使用本字段。
"""
signal_id: str = Field(..., description="幂等键,建议 uuid 或 交易对+确认K时间戳")
contract: str = Field(..., description="Gate 永续合约名,如 BTC_USDT、XAU_USDT")
side: Side
take_profit: float = Field(..., gt=0, description="方案 A 止盈价")
stop_loss: float = Field(..., gt=0, description="方案 A 止损价")
# 可选:扫描端带的确认收盘价,用于日志与复核;市价单以成交为准
reference_price: float | None = Field(None, gt=0, description="如确认K收盘价")
+14
View File
@@ -0,0 +1,14 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
class GateTestRequest(BaseModel):
"""面板 / Webhook 测试请求体。"""
action: Literal["balance", "micro_market"] = "balance"
contract: str = Field("", description="如 BTC_USDTmicro_market 时必填")
side: Literal["long", "short"] = "long"
size: int = Field(1, ge=1, le=30, description="张数绝对值,服务端再与 test_max_contracts 取小")
+143
View File
@@ -0,0 +1,143 @@
from __future__ import annotations
import asyncio
import logging
import time
from typing import Any
from .config import Settings
from .gate_futures_live import (
GateFuturesLive,
cancel_price_triggered_order,
fetch_net_position_size,
)
from .wecom_notify import notify_oco_cancel_failed
logger = logging.getLogger(__name__)
_POLL_SEC = 18.0
_MAX_AGE_SEC = 604800.0 # 与计划单 expiration 同量级,超时丢弃避免列表泄漏
_pending: list[dict[str, Any]] = []
_lock = asyncio.Lock()
_task: asyncio.Task[None] | None = None
def _live_ok(settings: Settings) -> bool:
g = settings.gate
return (not g.dry_run) and bool(g.api_key.strip() and g.api_secret.strip())
async def update_oco_sl_order_id(settings: Settings, *, contract: str, new_sl_id: str | int) -> None:
"""移动保本改挂新 SL 后,同步 OCO 清理队列中的 sl_id。"""
ct = contract.strip().upper()
nid = new_sl_id
async with _lock:
for row in _pending:
if str(row.get("contract") or "").strip().upper() != ct:
continue
row["sl_id"] = nid
logger.info("oco_sl_id_updated contract=%s new_sl_id=%s", ct, nid)
async def register_tp_sl_oco_cleanup(
settings: Settings,
*,
contract: str,
tp_order: dict[str, Any],
sl_order: dict[str, Any],
) -> None:
"""
登记一笔开仓挂出的止盈/止损计划单。当该合约净持仓为 0 时,尝试撤销两条计划单(另一腿未触发则清掉)。
"""
if not settings.risk.oco_cleanup_enabled:
return
if not _live_ok(settings):
return
tp_id = tp_order.get("id") if tp_order.get("id") is not None else tp_order.get("id_string")
sl_id = sl_order.get("id") if sl_order.get("id") is not None else sl_order.get("id_string")
if tp_id is None or sl_id is None:
return
row = {
"settings": settings,
"contract": contract.strip().upper(),
"tp_id": tp_id,
"sl_id": sl_id,
"t": time.monotonic(),
}
async with _lock:
_pending.append(row)
_ensure_loop()
def _ensure_loop() -> None:
global _task
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
if _task is not None and not _task.done():
return
_task = loop.create_task(_poll_loop(), name="oco_price_order_cleanup")
async def _poll_loop() -> None:
while True:
await asyncio.sleep(_POLL_SEC)
try:
await _tick()
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001
logger.exception("oco_watcher_tick_failed")
async def _tick() -> None:
async with _lock:
rows = list(_pending)
if not rows:
return
now = time.monotonic()
keep: list[dict[str, Any]] = []
for row in rows:
settings: Settings = row["settings"]
if not _live_ok(settings):
continue
if now - float(row["t"]) > _MAX_AGE_SEC:
logger.info("oco_watch_expired contract=%s", row["contract"])
continue
client = GateFuturesLive(settings)
contract = row["contract"]
try:
net = await fetch_net_position_size(client, contract)
except Exception as exc: # noqa: BLE001
logger.warning("oco_fetch_position_failed contract=%s: %s", contract, exc)
keep.append(row)
continue
if abs(net) > 1e-12:
keep.append(row)
continue
tp_id, sl_id = row["tp_id"], row["sl_id"]
cleanup_failed = False
for name, oid in (("tp", tp_id), ("sl", sl_id)):
try:
ok = await cancel_price_triggered_order(client, oid)
if ok:
logger.info("oco_cancelled contract=%s leg=%s order_id=%s", contract, name, oid)
except Exception as exc: # noqa: BLE001
logger.warning("oco_cancel_failed contract=%s leg=%s id=%s: %s", contract, name, oid, exc)
cleanup_failed = True
try:
await notify_oco_cancel_failed(
settings,
contract=contract,
leg=name,
order_id=str(oid),
detail=str(exc),
)
except Exception: # noqa: BLE001
logger.exception("wecom_notify_oco_failed")
if cleanup_failed:
keep.append(row)
async with _lock:
_pending[:] = keep + [r for r in _pending if r not in rows]
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
import threading
import time
from dataclasses import dataclass, field
@dataclass
class OpenSlot:
contract: str
signal_id: str
opened_at: float = field(default_factory=time.time)
class PositionBook:
"""进程内占位:实盘应对接交易所持仓或本地持久化。"""
def __init__(self, max_positions: int) -> None:
self._max = max_positions
self._lock = threading.Lock()
self._slots: dict[str, OpenSlot] = {}
def count(self) -> int:
with self._lock:
return len(self._slots)
def has_contract(self, contract: str) -> bool:
c = contract.strip().upper()
with self._lock:
return c in self._slots
def try_reserve(self, contract: str, signal_id: str) -> bool:
c = contract.strip().upper()
with self._lock:
if c in self._slots:
return False
if len(self._slots) >= self._max:
return False
self._slots[c] = OpenSlot(contract=c, signal_id=signal_id)
return True
def release(self, contract: str) -> None:
c = contract.strip().upper()
with self._lock:
self._slots.pop(c, None)
def sync_from_exchange(self, open_contracts: set[str]) -> None:
"""移除本地有占位但交易所已无持仓的合约(避免槽位永久占满)。"""
with self._lock:
for c in list(self._slots.keys()):
if c not in open_contracts:
self._slots.pop(c, None)
+39
View File
@@ -0,0 +1,39 @@
"""与 onchain_scout_gate 相同的代理 URL 处理,供 httpx 出站(Gate 私有 API 等)。"""
from __future__ import annotations
import httpx
def httpx_proxy_url(proxy_url: str | None) -> str | None:
"""
将配置中的代理地址转为 httpx 可用形式。
``socks5h://`` 在部分环境下会报 Unknown scheme,退化为 ``socks5://``。
"""
if not proxy_url or not str(proxy_url).strip():
return None
u = str(proxy_url).strip()
if u.startswith("socks5h://"):
return "socks5://" + u[len("socks5h://") :]
return u
def effective_proxy_url(proxy_enabled: bool, proxy_url: str | None) -> str | None:
if not proxy_enabled:
return None
return httpx_proxy_url(proxy_url.strip() if proxy_url else None)
def httpx_client_kwargs(
proxy_enabled: bool,
proxy_url: str | None,
*,
timeout_connect: float = 10.0,
timeout_read: float = 16.0,
) -> dict:
"""与扫描端 Gate 客户端一致的出站策略:有代理则 trust_env=False。"""
timeout = httpx.Timeout(timeout_connect, read=timeout_read)
p = effective_proxy_url(proxy_enabled, proxy_url)
if p:
return {"timeout": timeout, "proxy": p, "trust_env": False}
return {"timeout": timeout, "trust_env": True}
@@ -0,0 +1,63 @@
"""面板可写的风险偏好:持久化到 runtime/risk_prefs.json。"""
from __future__ import annotations
import json
import logging
import threading
from pathlib import Path
from typing import Any
from .config import Settings
logger = logging.getLogger(__name__)
_ROOT = Path(__file__).resolve().parent.parent
_PREFS_PATH = _ROOT / "runtime" / "risk_prefs.json"
_lock = threading.Lock()
def _read_json_file(path: Path) -> dict[str, Any] | None:
if not path.is_file():
return None
try:
raw = path.read_text(encoding="utf-8").strip()
if not raw:
return None
data = json.loads(raw)
return data if isinstance(data, dict) else None
except (OSError, json.JSONDecodeError) as exc:
logger.warning("risk_prefs_read_failed: %s", exc)
return None
def read_effective_min_reward_risk_ratio(settings: Settings) -> float:
"""优先 runtime 文件,否则 risk.min_reward_risk_ratioconfig 默认)。"""
base = float(settings.risk.min_reward_risk_ratio)
with _lock:
data = _read_json_file(_PREFS_PATH)
if not data:
return base
try:
v = float(data.get("min_reward_risk_ratio"))
except (TypeError, ValueError):
return base
lo, hi = 0.1, 50.0
if not (lo <= v <= hi):
return base
return v
def write_min_reward_risk_ratio(value: float) -> float:
"""写入并返回规范化后的值(与读侧范围一致)。"""
lo, hi = 0.1, 50.0
v = float(value)
if not (lo <= v <= hi):
raise ValueError(f"min_reward_risk_ratio must be in [{lo}, {hi}]")
payload = json.dumps({"min_reward_risk_ratio": v}, indent=2, ensure_ascii=False) + "\n"
with _lock:
_PREFS_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = _PREFS_PATH.with_suffix(".json.tmp")
tmp.write_text(payload, encoding="utf-8")
tmp.replace(_PREFS_PATH)
return v
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
import threading
import time
from collections import deque
class SignalHistory:
"""最近信号与执行结果(内存,进程重启清空)。"""
def __init__(self, maxlen: int = 100) -> None:
self._q: deque[dict] = deque(maxlen=maxlen)
self._lock = threading.Lock()
def push(self, item: dict) -> None:
with self._lock:
self._q.appendleft({**item, "ts": time.time()})
def list_recent(self) -> list[dict]:
with self._lock:
return list(self._q)
+100
View File
@@ -0,0 +1,100 @@
"""信号流展示:现价、按合约 tick 对齐的 TP/SL 字符串、盈亏比(相对现价)。"""
from __future__ import annotations
import logging
from typing import Any
from .config import Settings
from .gate_futures_live import GateFuturesLive, _float
from .gate_price_rounding import _format_trigger_price, _trigger_price_tick
from .models_signal import TradeSignal
logger = logging.getLogger(__name__)
def _reward_risk_ratio(side: str, p: float, tp: float, sl: float) -> tuple[float | None, str | None]:
if not (p > 0 and tp > 0 and sl > 0):
return None, "invalid_prices"
if side == "long":
reward = tp - p
risk = p - sl
elif side == "short":
reward = p - tp
risk = sl - p
else:
return None, "invalid_side"
if risk <= 0:
return None, "non_positive_risk"
if reward <= 0:
return None, "non_positive_reward"
return reward / risk, None
async def compute_signal_stream_metrics(
settings: Settings, sig: TradeSignal, prior: dict[str, Any] | None = None
) -> dict[str, Any]:
"""
现价:优先信号 reference_price;否则 ticker last;再否则 prior.reference_entry(实盘已算 entry)。
止盈/止损展示:与下单相同的 tick 对齐字符串。
"""
client = GateFuturesLive(settings)
contract = sig.contract.strip().upper()
ticker = await client._public_get(f"{client._prefix}/tickers", params={"contract": contract})
last = 0.0
if isinstance(ticker, list) and ticker:
last = _float(ticker[0].get("last"))
elif isinstance(ticker, dict):
last = _float(ticker.get("last"))
p: float | None = None
if sig.reference_price is not None and float(sig.reference_price) > 0:
p = float(sig.reference_price)
elif last > 0:
p = last
elif prior:
ref_e = prior.get("reference_entry")
if ref_e is not None:
try:
pe = float(ref_e)
except (TypeError, ValueError):
pe = 0.0
if pe > 0:
p = pe
cdata = await client._public_get(f"{client._prefix}/contracts/{contract}")
if not isinstance(cdata, dict):
raise ValueError("contract_not_found")
tick = _trigger_price_tick(cdata)
tp_s = _format_trigger_price(float(sig.take_profit), tick)
sl_s = _format_trigger_price(float(sig.stop_loss), tick)
rr: float | None = None
rr_reason: str | None = None
if p is not None and p > 0:
try:
tp_f = float(tp_s)
sl_f = float(sl_s)
except ValueError:
rr_reason = "invalid_trigger_float"
else:
rr, rr_reason = _reward_risk_ratio(str(sig.side), p, tp_f, sl_f)
return {
"reference_price_used": float(p) if p is not None and p > 0 else None,
"take_profit_display": tp_s,
"stop_loss_display": sl_s,
"reward_risk_ratio": round(rr, 6) if rr is not None else None,
"reward_risk_reason": rr_reason,
}
async def augment_signal_result(settings: Settings, sig: TradeSignal, result: dict[str, Any]) -> dict[str, Any]:
out = dict(result)
try:
m = await compute_signal_stream_metrics(settings, sig, prior=out)
out.update(m)
except Exception as exc: # noqa: BLE001
logger.warning("signal_metrics failed signal_id=%s: %s", sig.signal_id, exc)
return out
@@ -0,0 +1,204 @@
"""信号与执行结果 SQLite 落库(标准库 sqlite3)。"""
from __future__ import annotations
import json
import logging
import sqlite3
import threading
import time
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
def _gate_order_id(obj: Any) -> str | None:
if not isinstance(obj, dict):
return None
oid = obj.get("id")
if oid is None:
oid = obj.get("order_id")
if oid is None:
return None
s = str(oid).strip()
return s or None
def _resolve_sqlite_path(raw: str, root: Path) -> Path:
p = Path(raw.strip())
if not p.is_absolute():
p = (root / p).resolve()
return p
class SignalRepository:
"""线程安全;每条 POST /v1/signal 处理完成后写入一行。"""
def __init__(self, sqlite_path: Path) -> None:
self._path = sqlite_path
self._lock = threading.Lock()
@classmethod
def from_settings(cls, sqlite_path_cfg: str, root: Path) -> SignalRepository | None:
"""配置了非空 ``sqlite_path`` 即落库;空字符串则仅内存环形表(与旧版一致)。"""
raw = (sqlite_path_cfg or "").strip()
if not raw:
return None
return cls(_resolve_sqlite_path(raw, root))
def init_schema(self) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
with self._lock:
con = sqlite3.connect(self._path, check_same_thread=False)
try:
con.execute(
"""
CREATE TABLE IF NOT EXISTS signal_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at REAL NOT NULL,
http_status INTEGER NOT NULL,
signal_id TEXT NOT NULL,
contract TEXT NOT NULL,
side TEXT NOT NULL,
take_profit REAL,
stop_loss REAL,
reference_price REAL,
signal_json TEXT NOT NULL,
result_status TEXT NOT NULL,
result_mode TEXT,
result_reason TEXT,
result_detail TEXT,
stage TEXT,
market_order_id TEXT,
take_profit_order_id TEXT,
stop_loss_order_id TEXT,
result_json TEXT NOT NULL
)
"""
)
con.execute(
"CREATE INDEX IF NOT EXISTS idx_signal_runs_signal_id ON signal_runs(signal_id)"
)
con.execute(
"CREATE INDEX IF NOT EXISTS idx_signal_runs_created_at ON signal_runs(created_at)"
)
con.commit()
finally:
con.close()
def insert_run(self, signal: dict[str, Any], result: dict[str, Any], http_status: int) -> None:
created = time.time()
sig_json = json.dumps(signal, ensure_ascii=False, separators=(",", ":"))
res_json = json.dumps(result, ensure_ascii=False, separators=(",", ":"))
market_obj = result.get("market_order") or result.get("order")
detail_raw = result.get("detail")
if detail_raw is None:
detail_s: str | None = None
elif isinstance(detail_raw, str):
detail_s = detail_raw
else:
try:
detail_s = json.dumps(detail_raw, ensure_ascii=False)
except Exception:
detail_s = str(detail_raw)
row = (
created,
int(http_status),
str(signal.get("signal_id") or ""),
str(signal.get("contract") or "").strip().upper(),
str(signal.get("side") or ""),
signal.get("take_profit"),
signal.get("stop_loss"),
signal.get("reference_price"),
sig_json,
str(result.get("status") or ""),
result.get("mode"),
result.get("reason"),
detail_s,
result.get("stage") if isinstance(result.get("stage"), str) else None,
_gate_order_id(market_obj),
_gate_order_id(result.get("take_profit_order")),
_gate_order_id(result.get("stop_loss_order")),
res_json,
)
with self._lock:
con = sqlite3.connect(self._path, check_same_thread=False)
try:
con.execute(
"""
INSERT INTO signal_runs (
created_at, http_status, signal_id, contract, side,
take_profit, stop_loss, reference_price, signal_json,
result_status, result_mode, result_reason, result_detail, stage,
market_order_id, take_profit_order_id, stop_loss_order_id, result_json
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""",
row,
)
con.commit()
finally:
con.close()
def find_latest_accepted_for_contract(self, contract: str) -> dict[str, Any] | None:
ct = str(contract or "").strip().upper()
if not ct:
return None
with self._lock:
con = sqlite3.connect(self._path, check_same_thread=False)
try:
cur = con.execute(
"""
SELECT created_at, signal_json, result_json
FROM signal_runs
WHERE contract = ? AND result_status = 'accepted'
ORDER BY id DESC
LIMIT 1
""",
(ct,),
)
row = cur.fetchone()
finally:
con.close()
if not row:
return None
created_at, sig_j, res_j = row
try:
sig = json.loads(sig_j)
except Exception:
sig = {}
try:
res = json.loads(res_j)
except Exception:
res = {}
return {"ts": float(created_at), "signal": sig, "result": res}
def list_recent(self, limit: int = 100) -> list[dict[str, Any]]:
lim = max(1, min(int(limit), 500))
with self._lock:
con = sqlite3.connect(self._path, check_same_thread=False)
try:
cur = con.execute(
"""
SELECT created_at, signal_json, result_json
FROM signal_runs
ORDER BY id DESC
LIMIT ?
""",
(lim,),
)
rows = cur.fetchall()
finally:
con.close()
out: list[dict[str, Any]] = []
for created_at, sig_j, res_j in rows:
try:
sig = json.loads(sig_j)
except Exception:
sig = {}
try:
res = json.loads(res_j)
except Exception:
res = {}
out.append({"ts": float(created_at), "signal": sig, "result": res})
return out
+315
View File
@@ -0,0 +1,315 @@
from __future__ import annotations
import time
from datetime import date, datetime, timedelta
from typing import Any
from zoneinfo import ZoneInfo
from .config import Settings
from .gate_history import collect_position_close_rows
def _parse_official_start_iso(s: str) -> float:
raw = (s or "").strip()
if not raw:
raise ValueError("stats.official_start is empty")
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
dt = datetime.fromisoformat(raw)
if dt.tzinfo is None:
raise ValueError("stats.official_start must include a timezone offset (e.g. +08:00)")
return dt.timestamp()
def _position_close_ts(row: dict[str, Any]) -> float | None:
"""Gate `GET .../position_close` 行的平仓时间戳(字段 `time`)。"""
t = row.get("time")
if t is None:
return None
try:
return float(t)
except (TypeError, ValueError):
return None
def _float_field(row: dict[str, Any], *keys: str) -> float | None:
for k in keys:
v = row.get(k)
if v is None or v == "":
continue
try:
return float(v)
except (TypeError, ValueError):
return None
return None
def _row_close_pnl(row: dict[str, Any]) -> float | None:
"""历史平仓记录上的净盈亏(字符串数值)。"""
return _float_field(row, "pnl", "realised_pnl", "realized_pnl")
def stat_day_date(ts: float, tz: ZoneInfo) -> date:
dt = datetime.fromtimestamp(ts, tz=tz)
shifted = dt - timedelta(hours=8)
return shifted.date()
def stat_month_key(ts: float, tz: ZoneInfo) -> tuple[int, int]:
dt = datetime.fromtimestamp(ts, tz=tz)
shifted = dt - timedelta(hours=8)
return shifted.year, shifted.month
def stat_day_window(d: date, tz: ZoneInfo) -> tuple[float, float]:
start = datetime(d.year, d.month, d.day, 8, 0, 0, tzinfo=tz)
end = start + timedelta(days=1)
return start.timestamp(), end.timestamp()
def month_window(y: int, m: int, tz: ZoneInfo) -> tuple[float, float]:
start = datetime(y, m, 1, 8, 0, 0, tzinfo=tz)
if m == 12:
end = datetime(y + 1, 1, 1, 8, 0, 0, tzinfo=tz)
else:
end = datetime(y, m + 1, 1, 8, 0, 0, tzinfo=tz)
return start.timestamp(), end.timestamp()
def monday_of_week(d: date) -> date:
return d - timedelta(days=d.weekday())
def aggregate_pnls(pnls_ordered: list[float]) -> dict[str, Any]:
if not pnls_ordered:
return {
"trade_count": 0,
"wins": 0,
"losses": 0,
"breakeven": 0,
"win_rate": None,
"profit_factor": None,
"gross_profit": 0.0,
"gross_loss": 0.0,
"net_pnl": 0.0,
"max_single_loss": None,
"max_drawdown": 0.0,
"max_consecutive_losses": 0,
}
wins = losses = be = 0
gp = 0.0
gl = 0.0
max_consec = 0
streak = 0
for p in pnls_ordered:
if p > 0:
wins += 1
gp += p
streak = 0
elif p < 0:
losses += 1
gl += p
streak += 1
max_consec = max(max_consec, streak)
else:
be += 1
streak = 0
total = len(pnls_ordered)
win_rate = wins / total if total else None
gross_loss_abs = abs(gl)
if gross_loss_abs > 1e-12:
profit_factor: float | None = gp / gross_loss_abs
else:
profit_factor = None
min_p = min(pnls_ordered)
max_single_loss = min_p if min_p < -1e-12 else None
eq = 0.0
peak = 0.0
mdd = 0.0
for p in pnls_ordered:
eq += p
peak = max(peak, eq)
mdd = max(mdd, peak - eq)
return {
"trade_count": total,
"wins": wins,
"losses": losses,
"breakeven": be,
"win_rate": win_rate,
"profit_factor": profit_factor,
"gross_profit": gp,
"gross_loss": gl,
"net_pnl": sum(pnls_ordered),
"max_single_loss": max_single_loss,
"max_drawdown": mdd,
"max_consecutive_losses": max_consec,
}
def _detect_pnl_field(rows: list[dict[str, Any]]) -> str:
for r in rows:
if _float_field(r, "pnl") is not None:
return "pnl"
if _float_field(r, "realised_pnl", "realized_pnl") is not None:
return "realised_pnl"
return "missing"
def _pnls_for_stat_day(events: list[tuple[float, float]], d: date, tz: ZoneInfo, official_ts: float) -> list[float]:
out: list[tuple[float, float]] = []
for ts, pnl in events:
if ts < official_ts:
continue
if stat_day_date(ts, tz) != d:
continue
out.append((ts, pnl))
out.sort(key=lambda x: x[0])
return [p for _, p in out]
def _pnls_for_week(
events: list[tuple[float, float]], mon: date, sun: date, tz: ZoneInfo, official_ts: float
) -> list[float]:
out: list[tuple[float, float]] = []
for ts, pnl in events:
if ts < official_ts:
continue
dd = stat_day_date(ts, tz)
if dd < mon or dd > sun:
continue
out.append((ts, pnl))
out.sort(key=lambda x: x[0])
return [p for _, p in out]
def _pnls_for_month(
events: list[tuple[float, float]], y: int, m: int, tz: ZoneInfo, official_ts: float
) -> list[float]:
out: list[tuple[float, float]] = []
for ts, pnl in events:
if ts < official_ts:
continue
if stat_month_key(ts, tz) != (y, m):
continue
out.append((ts, pnl))
out.sort(key=lambda x: x[0])
return [p for _, p in out]
async def build_dashboard_stats(
settings: Settings,
*,
contract: str | None,
) -> dict[str, Any]:
try:
tz = ZoneInfo(settings.stats.timezone)
except Exception as exc: # noqa: BLE001
return {"ok": False, "error": f"invalid stats.timezone: {exc}"}
try:
official_ts = _parse_official_start_iso(settings.stats.official_start)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
now = time.time()
cap = int(settings.stats.max_trade_rows)
rows, err = await collect_position_close_rows(
settings,
contract=contract,
from_ts=int(official_ts),
to_ts=int(now),
max_rows=cap,
)
if err:
return {"ok": False, "error": err}
if rows is None:
return {"ok": False, "error": "api keys not configured"}
missing_pnl = 0
events: list[tuple[float, float]] = []
for row in rows:
ts = _position_close_ts(row)
if ts is None or ts < official_ts:
continue
pnl = _row_close_pnl(row)
if pnl is None:
missing_pnl += 1
continue
events.append((ts, pnl))
events.sort(key=lambda x: x[0])
pnl_field = _detect_pnl_field(rows)
truncated = len(rows) >= cap
d_cur = stat_day_date(now, tz)
day_start, day_end = stat_day_window(d_cur, tz)
mon, sun = monday_of_week(d_cur), monday_of_week(d_cur) + timedelta(days=6)
my, mm = stat_month_key(now, tz)
m_start, m_end = month_window(my, mm, tz)
def pack_period(
*,
bucket: str,
label: str,
start_ts: float,
end_ts: float,
pnls: list[float],
) -> dict[str, Any]:
partial = now < end_ts
metrics = aggregate_pnls(pnls)
return {
"bucket": bucket,
"label": label,
"start_ts": start_ts,
"end_ts": end_ts,
"partial": partial,
"metrics": metrics,
}
day_pnls = _pnls_for_stat_day(events, d_cur, tz, official_ts)
week_pnls = _pnls_for_week(events, mon, sun, tz, official_ts)
month_pnls = _pnls_for_month(events, my, mm, tz, official_ts)
return {
"ok": True,
"timezone": settings.stats.timezone,
"official_start": settings.stats.official_start,
"official_start_ts": official_ts,
"now_ts": now,
"contract": contract,
"pnl_field": pnl_field,
"closing_rows_missing_pnl": missing_pnl,
"fetched_position_close_rows": len(rows),
"fetched_trade_rows": len(rows),
"truncated": truncated,
"definitions": {
"unit": "每条 Gate 历史平仓(GET /futures/{settle}/position_close)且能解析到 pnl 的记录,按平仓 time 排序后做序列指标。",
"win_rate": "盈利笔数 / 总笔数(含盈亏为 0)。",
"profit_factor": "毛利和 / |毛亏和|;若无亏损则 null。",
"max_single_loss": "单笔最小 pnl(最负的一笔),无亏损为 null。",
"max_drawdown": "按时间累加 pnl 的权益曲线,相对历史峰值的 max(peakequity)。",
"max_consecutive_losses": "连续 pnl<0 的最大笔数。",
"day": "统计日 [D 08:00, D+1 08:00) 上海;D=(本地时刻−8h) 的日历日。",
"week": "自然周周一至周日(上海日历),聚合落在该周内的平仓记录。",
"month": "自然月 [当月1日08:00, 次月1日08:00) 上海,与统计日对齐。",
},
"day": pack_period(
bucket="day",
label=d_cur.isoformat(),
start_ts=day_start,
end_ts=day_end,
pnls=day_pnls,
),
"week": pack_period(
bucket="week",
label=f"{mon.isoformat()}_{sun.isoformat()}",
start_ts=stat_day_window(mon, tz)[0],
end_ts=stat_day_window(sun, tz)[1],
pnls=week_pnls,
),
"month": pack_period(
bucket="month",
label=f"{my:04d}-{mm:02d}",
start_ts=m_start,
end_ts=m_end,
pnls=month_pnls,
),
}
+181
View File
@@ -0,0 +1,181 @@
"""企业微信群机器人:仅推送执行器侧执行结果(策略/发现类仍由扫描端)。"""
from __future__ import annotations
import logging
import time
from typing import Any
import httpx
from .config import Settings
from .proxy_util import httpx_client_kwargs
logger = logging.getLogger(__name__)
_MAX_MD_LEN = 3500
_oco_last_sent: dict[str, float] = {}
_OCO_COOLDOWN_SEC = 600.0
def _wecom_ready(settings: Settings) -> str | None:
w = settings.wecom
if not w.enabled:
return None
url = (w.webhook_url or "").strip()
return url or None
def _clip(s: str, n: int = 800) -> str:
t = str(s).replace("\r\n", "\n").strip()
if len(t) > n:
return t[: n - 1] + ""
return t
async def _post_markdown(settings: Settings, title: str, body_lines: list[str]) -> None:
url = _wecom_ready(settings)
if not url:
return
text = "\n".join([f"## {_clip(title, 120)}", ""] + body_lines)
if len(text) > _MAX_MD_LEN:
text = text[: _MAX_MD_LEN - 20] + "\n…(truncated)"
payload = {"msgtype": "markdown", "markdown": {"content": text}}
kw = httpx_client_kwargs(settings.proxy.enabled, settings.proxy.url, timeout_connect=6.0, timeout_read=12.0)
try:
async with httpx.AsyncClient(**kw) as client:
r = await client.post(url, json=payload)
r.raise_for_status()
data = r.json()
if isinstance(data, dict) and int(data.get("errcode") or 0) != 0:
logger.warning("wecom_api_err: %s", data)
except Exception: # noqa: BLE001
logger.exception("wecom_post_failed")
async def notify_signal_execution(
settings: Settings,
*,
signal: dict[str, Any],
result: dict[str, Any],
http_status: int,
) -> None:
"""每条 POST /v1/signal 处理结束后推送摘要。"""
if not _wecom_ready(settings):
return
st = str(result.get("status") or "")
title = "执行器 · 信号结果"
if st == "accepted":
title += " · accepted"
elif st == "skipped":
title += " · skipped"
else:
title += " · error"
lines = [
f">signal_id: `{_clip(str(signal.get('signal_id') or ''), 80)}`",
f">contract: **{_clip(str(signal.get('contract') or ''), 32)}** side: `{_clip(str(signal.get('side') or ''), 8)}`",
f">http: **{http_status}** mode: `{_clip(str(result.get('mode') or ''), 20)}`",
f">status: **{st}**",
]
if result.get("reason"):
lines.append(f">reason: `{_clip(str(result.get('reason')), 200)}`")
if result.get("stage"):
lines.append(f">stage: `{_clip(str(result.get('stage')), 40)}`")
if result.get("detail"):
lines.append(f">detail: `{_clip(str(result.get('detail')), 500)}`")
if result.get("sized_contracts") is not None:
lines.append(f">size: `{_clip(str(result.get('sized_contracts')), 40)}`")
if result.get("market_order") and isinstance(result.get("market_order"), dict):
mo = result["market_order"]
oid = mo.get("id") or mo.get("order_id")
if oid is not None:
lines.append(f">market_order_id: `{oid}`")
try:
await _post_markdown(settings, title, lines)
except Exception: # noqa: BLE001
logger.exception("notify_signal_execution_failed")
async def notify_manual_close(
settings: Settings,
*,
contract: str,
ok: bool,
detail: str | None,
order: dict[str, Any] | None,
) -> None:
"""面板一键市价全平结果。"""
if not _wecom_ready(settings):
return
title = "执行器 · 一键平仓 · 成功" if ok else "执行器 · 一键平仓 · 失败"
lines = [f">contract: **{_clip(contract, 32)}**"]
if detail:
lines.append(f">detail: `{_clip(detail, 400)}`")
if ok and isinstance(order, dict):
oid = order.get("id") or order.get("order_id")
if oid is not None:
lines.append(f">order_id: `{oid}`")
try:
await _post_markdown(settings, title, lines)
except Exception: # noqa: BLE001
logger.exception("notify_manual_close_failed")
async def notify_oco_cancel_failed(
settings: Settings,
*,
contract: str,
leg: str,
order_id: str,
detail: str,
) -> None:
"""OCO 清理撤另一腿失败:带冷却,避免 18s 轮询刷屏。"""
if not _wecom_ready(settings):
return
key = f"{contract}:{leg}:{order_id}"
now = time.time()
if now - _oco_last_sent.get(key, 0.0) < _OCO_COOLDOWN_SEC:
return
_oco_last_sent[key] = now
if len(_oco_last_sent) > 500:
_oco_last_sent.clear()
title = "执行器 · OCO 撤单异常"
lines = [
f">contract: **{_clip(contract, 32)}**",
f">leg: `{_clip(leg, 8)}` price_order_id: `{_clip(order_id, 40)}`",
f">detail: `{_clip(detail, 500)}`",
]
try:
await _post_markdown(settings, title, lines)
except Exception: # noqa: BLE001
logger.exception("notify_oco_cancel_failed_post")
async def notify_breakeven_failed(settings: Settings, *, contract: str, detail: str) -> None:
"""移动保本改挂止损失败(仅失败推送)。"""
if not _wecom_ready(settings):
return
title = "执行器 · 移动保本失败"
lines = [
f">contract: **{_clip(contract, 32)}**",
f">detail: `{_clip(detail, 500)}`",
]
try:
await _post_markdown(settings, title, lines)
except Exception: # noqa: BLE001
logger.exception("notify_breakeven_failed_post")
async def notify_signal_db_insert_failed(settings: Settings, *, signal_id: str, detail: str) -> None:
"""SQLite 落库失败(HTTP 仍返回信号结果时单独告警)。"""
if not _wecom_ready(settings):
return
title = "执行器 · 信号落库失败"
lines = [
f">signal_id: `{_clip(signal_id, 80)}`",
f">detail: `{_clip(detail, 600)}`",
]
try:
await _post_markdown(settings, title, lines)
except Exception: # noqa: BLE001
logger.exception("notify_signal_db_insert_failed_post")
+63
View File
@@ -0,0 +1,63 @@
# 复制为 config.yaml 后修改;勿将含密钥的 config.yaml 提交到 git。
app:
host: "127.0.0.1"
port: 8090
log_file: "./runtime/executor.log"
session_secret: "change-me-to-long-random-string"
auth:
enabled: true
username: "admin"
password: "ChangeThisPassword!"
security:
webhook_secret: "change-me-to-long-random-string"
# 企业微信群机器人:仅推送执行器「执行结果」(信号处理结果、一键平仓、OCO 撤单异常、SQLite 落库失败)。
# 策略/发现类通知仍由 onchain_scout_gate 扫描端发送。需在企业微信建群机器人并复制 Webhook URL。
wecom:
enabled: false
webhook_url: ""
gate:
api_base: "https://api.gateio.ws/api/v4"
settle: "usdt"
api_key: ""
api_secret: ""
# true:只校验与日志,不下单。实盘:改为 false,并填写 api_key / api_secret(子账户 + IP 白名单)
dry_run: true
# 仅人工测试:为 true 时允许 POST /api/test 与 /v1/test 的 micro_market 发真实 IOC 市价(张数受 test_max_contracts 限制;联调见 docs/使用说明 §4.1)
test_orders_enabled: false
test_max_contracts: 1
risk:
risk_per_trade_frac: 0.005
max_open_positions: 5
scheme: "A"
# Gate 官方未提供「单笔双触发 OCO」时,为 true 表示净持仓为 0 后由本进程撤掉另一腿计划单(推荐保持 true)
oco_cleanup_enabled: true
# 最低盈亏比(config 默认);信号流面板可保存到 runtime/risk_prefs.json 覆盖,无需改 yaml
min_reward_risk_ratio: 1.3
# 移动保本:浮盈达 1R(相对初始止损)后,止损拉至开仓价 ± buffer_pct;面板可写 runtime/breakeven_prefs.json
breakeven_stop:
enabled: true
trigger_r: 1.0
buffer_pct: 0.002
poll_interval_sec: 8
# 面板「统计」:正式起始时刻与统计日边界(见 docs/使用说明.md)
stats:
timezone: "Asia/Shanghai"
official_start: "2026-05-13T02:00:00+08:00"
max_trade_rows: 20000
# 信号流与执行结果:写入 SQLite(默认 ./runtime/signals.sqlite);留空会自动回退为该路径,保证重启后仍可读
database:
enabled: true # 兼容旧字段
sqlite_path: "./runtime/signals.sqlite"
# 与 onchain_scout_gate 的 proxy 块相同写法;enabled=true 时访问 Gate 走此代理
proxy:
enabled: true
url: "socks5h://127.0.0.1:1080"
+17
View File
@@ -0,0 +1,17 @@
# deploy 脚本说明
所有脚本默认项目路径为 **`/root/gate_order_executor`**;可通过第一个参数传入你的绝对路径(`bootstrap.sh``start.sh`)。
| 文件 | 作用 |
|------|------|
| `ecosystem.config.cjs` | PM2 配置:单实例、`PYTHONUNBUFFERED=1`、日志在 `runtime/` |
| `bootstrap.sh` | 首次:`venv` + `pip install -r requirements.txt` + 生成 `config.yaml` 模板 |
| `start.sh` | 前台运行 `python run.py`(调试用) |
| `pm2-start.sh` | PM2 启动;若应用已存在则 `restart` |
| `pm2-restart.sh` | `pm2 restart gate-order-executor` |
| `pm2-stop.sh` | `pm2 stop gate-order-executor` |
| `pm2-delete.sh` | `pm2 delete gate-order-executor` |
| `gate-order-executor.service` | systemd 示例(需改 `WorkingDirectory``pm2-runtime` 路径) |
使用说明与接口文档:`../docs/使用说明.md`
部署步骤:`../docs/部署说明.md`
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# 首次部署:创建 venv、安装依赖、生成 config 模板、创建 runtime 目录。
# 用法:bash deploy/bootstrap.sh [项目绝对路径]
# 默认:/root/gate_order_executor
set -euo pipefail
PROJECT_DIR="${1:-/root/gate_order_executor}"
cd "$PROJECT_DIR"
if ! command -v python3 >/dev/null 2>&1; then
echo "请先安装 python3" >&2
exit 1
fi
python3 -m venv .venv
# shellcheck source=/dev/null
source .venv/bin/activate
python -m pip install -U pip
python -m pip install -r requirements.txt
# SOCKS 代理与扫描一致时建议安装(与 httpx[socks] 一致)
python -m pip install "socksio>=1.0,<2" || true
if [ ! -f config.yaml ]; then
cp -n config.example.yaml config.yaml
echo "已从 config.example.yaml 创建 config.yaml,请编辑:auth、security.webhook_secret、proxy、gate 等。"
fi
mkdir -p runtime
echo "Bootstrap 完成:$PROJECT_DIR"
@@ -0,0 +1,38 @@
/**
* PM2 守护 gate_order_executorGate 下单执行器)
*
* 在项目根目录执行:
* ./deploy/pm2-start.sh
* 或:
* pm2 start deploy/ecosystem.config.cjs
*
* 监听 host/port 来自项目根目录 config.yaml → app.host / app.port(由 run.py 内 uvicorn 读取)。
*/
const path = require("path");
const ROOT = path.resolve(__dirname, "..");
const isWin = process.platform === "win32";
const py = path.join(ROOT, isWin ? path.join(".venv", "Scripts", "python.exe") : path.join(".venv", "bin", "python"));
module.exports = {
apps: [
{
name: "gate-order-executor",
cwd: ROOT,
script: py,
args: ["run.py"],
interpreter: "none",
autorestart: true,
watch: false,
max_restarts: 20,
min_uptime: "10s",
exp_backoff_restart_delay: 2000,
error_file: path.join(ROOT, "runtime", "pm2-executor-error.log"),
out_file: path.join(ROOT, "runtime", "pm2-executor-out.log"),
merge_logs: true,
time: true,
env: {
PYTHONUNBUFFERED: "1",
},
},
],
};
@@ -0,0 +1,18 @@
[Unit]
Description=Gate Order Executor (Python) via PM2
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/root/gate_order_executor
Environment=NODE_ENV=production
Environment=PYTHONUNBUFFERED=1
# 需全局安装:npm install -g pm2
# 若 pm2-runtime 不在 PATH,请改为绝对路径:which pm2-runtime
ExecStart=/usr/bin/pm2-runtime start deploy/ecosystem.config.cjs
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
if ! command -v pm2 >/dev/null 2>&1; then
echo "未找到 pm2" >&2
exit 1
fi
pm2 delete gate-order-executor || true
pm2 save || true
echo "已从 PM2 删除 gate-order-executor"
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
if ! command -v pm2 >/dev/null 2>&1; then
echo "未找到 pm2" >&2
exit 1
fi
pm2 restart gate-order-executor --update-env
echo "已重启 gate-order-executor"
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# 使用 PM2 启动 gate-order-executor(需已 bootstrap 且已配置 config.yaml
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
if ! command -v pm2 >/dev/null 2>&1; then
echo "未找到 pm2,请先执行:npm install -g pm2" >&2
exit 1
fi
if [ ! -x ".venv/bin/python" ]; then
echo "未找到 .venv/bin/python,请先执行:bash deploy/bootstrap.sh \"$ROOT\"" >&2
exit 1
fi
mkdir -p runtime
if pm2 describe gate-order-executor >/dev/null 2>&1; then
echo "进程已存在,改为重启:pm2 restart gate-order-executor"
pm2 restart gate-order-executor --update-env
else
pm2 start "$ROOT/deploy/ecosystem.config.cjs"
fi
pm2 save || true
echo "已启动。日志:pm2 logs gate-order-executor"
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
if ! command -v pm2 >/dev/null 2>&1; then
echo "未找到 pm2" >&2
exit 1
fi
pm2 stop gate-order-executor || true
echo "已停止 gate-order-executor(进程仍在列表中可用 pm2 delete 删除)"
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# 前台调试(无 PM2)。生产请用:./deploy/pm2-start.sh
# 用法:bash deploy/start.sh [项目绝对路径]
set -euo pipefail
PROJECT_DIR="${1:-/root/gate_order_executor}"
cd "$PROJECT_DIR"
# shellcheck source=/dev/null
source .venv/bin/activate
exec python run.py
+218
View File
@@ -0,0 +1,218 @@
# Gate 下单执行器 · 使用说明
本文说明 `gate_order_executor` 的职责、配置项、Web 面板与 HTTP 接口。与 **部署安装** 相关的步骤见 [部署说明.md](./部署说明.md)。
## 1. 系统定位
-`onchain_scout_gate`MATRIX 扫描)**分进程**运行:扫描只用 Gate **公共**行情;本服务接收信号并可在 **`gate.dry_run: false`** 且配置密钥时 **向 Gate 发真实委托**
- **`gate.dry_run: true`(默认)**:只校验占位与日志,不下单。
- 不建议把 Gate API Key 放在扫描服务的配置里;密钥仅配置在本项目的 `config.yaml`(本机、权限收紧)。
## 2. 目录结构(常用)
| 路径 | 说明 |
|------|------|
| `config.yaml` | 本地配置(勿提交 git;由 `config.example.yaml` 复制) |
| `run.py` | 启动入口(uvicorn 读取 `app.host` / `app.port` |
| `app/main.py` | FastAPI:健康检查、登录、面板、`POST /v1/signal``GET /api/state``GET /api/stats/summary``POST /api/test`、**Gate 成交/委托查询与 CSV**`GET /api/gate/*` |
| `app/gate_history.py` | 拉取 Gate **`my_trades_timerange`** / **`orders`**,拼 CSVUTF-8 BOM);分页原始行 `collect_trades_rows` |
| `app/stats.py` | 面板统计:正式起点后历史平仓(`position_close`)的日/周/月聚合 |
| `app/gate_operations.py` | 拉取账户余额、**测试市价接口**(`post_test_market_order`)、列出/撤销计划委托、**非零持仓摘要**(`GET /futures/{settle}/positions` |
| `app/gate_futures_live.py` | 实盘:签名请求、市价 IOC 开仓、计划委托止盈/止损、以损订仓 |
| `app/oco_watcher.py` | 净持仓为 0 后撤掉本次止盈/止损计划单另一腿(见 §3.4.2) |
| `app/gate_auth.py` | Gate APIv4 请求签名 |
| `app/proxy_util.py` | 与扫描相同的代理处理;`httpx_client_kwargs` 供访问 Gate 时使用 |
| `deploy/ecosystem.config.cjs` | PM2 配置 |
| `deploy/bootstrap.sh` | 首次创建 venv 与依赖 |
| `deploy/pm2-start.sh` | PM2 启动/已存在则重启 |
## 3. 配置说明(`config.yaml`
### 3.1 `app`
| 字段 | 说明 |
|------|------|
| `host` | 监听地址。仅本机访问面板/接口用 `127.0.0.1`;局域网或 SSH 隧道外访问用 `0.0.0.0`(请配合防火墙与 `auth`)。 |
| `port` | 默认 `8090`,避免与扫描默认 `8088` 冲突。 |
| `log_file` | 应用日志路径(目录会自动创建)。 |
| `session_secret` | Cookie 会话密钥,请改为长随机串。 |
### 3.2 `auth`
| 字段 | 说明 |
|------|------|
| `enabled` | `false` 时跳过登录(仅建议纯局域网)。公网或不可信网络务必 `true`。 |
| `username` / `password` | 面板登录;密码以 SHA256 摘要校验(与扫描面板思路一致)。 |
### 3.3 `security`
| 字段 | 说明 |
|------|------|
| `webhook_secret` | 调用 `POST /v1/signal``POST /v1/test` 时请求头 `X-Webhook-Secret` 必须与此一致。 |
### 3.4 `gate`
| 字段 | 说明 |
|------|------|
| `api_base` / `settle` | 与扫描端 Gate 公共 API 一致即可。 |
| `api_key` / `api_secret` | 实盘必填(建议子账户、只开合约、IP 白名单)。 |
| `dry_run` | `true` 不下单;**实盘改为 `false`** 且密钥有效时,收到信号将 **市价开仓** 并挂 **计划委托** 止盈/止损。 |
| `test_orders_enabled` | 默认 `false`。为 `true` 时允许通过 `POST /api/test``POST /v1/test``action: micro_market` 发送 **真实** 极小 IOC 市价单(**仅接口联调**,见 §4.1);务必使用子账户、小额、`test_max_contracts` 限制张数。 |
| `test_max_contracts` | 与测试请求里的张数取 **最小值**,上限 30,默认 1。 |
### 3.4.1 实盘逻辑摘要(`app/gate_futures_live.py`
- **开仓**`POST /futures/usdt/orders``price=0``tif=ioc`,做多 `size` 为正、做空为负;`text``t-e` + 清洗后的 `signal_id`
- **止盈 / 止损**:各 `POST /futures/usdt/price_orders``initial``reduce_only` + 市价 IOC + **`close: true`** + **`size: 0`**Gate 要求:单向全平时 `close=true``initial.size` 必须为 0,否则会报 `AUTO_INVALID_PARAM_INITIAL_SIZE`),`trigger` 规则与方向匹配(多:TP 用 rule≥、SL 用 rule≤;空相反)。
- **一腿成交后撤另一腿**:见 **§3.4.2**;默认 **`risk.oco_cleanup_enabled: true`** 时由 **`oco_watcher`**(约每 18s)在净持仓为 0 后 `DELETE` 本次两条 `price_orders` 中仍挂着的一腿。
- **以损订仓**:用 `GET /futures/usdt/accounts``total`USDT)× `risk.risk_per_trade_frac`,再除以 `|参考价−止损|×quanto_multiplier``quanto_multiplier` 来自合约详情)。若低于 `order_size_min` 则拒绝该笔信号。
- **参考价**:优先 `reference_price`;否则用合约 ticker `last`
- **持仓上限**:每次信号前拉取 `GET /futures/usdt/positions`,与本地占位同步;交易所已有 **≥ max_open_positions** 个非零持仓则跳过;**该合约在交易所已有仓**也跳过(避免重复加仓)。
- **假设**:经典 **单向持仓** 模式。若为双向对冲模式,Gate 对平仓字段要求不同,本实现可能需调整(`auto_size` 等);请在子账户上先以单向模式验证。
### 3.4.2 Gate 与「交易所原生 OCO」
- **官方能力**USDT 永续 v4 的 `POST /futures/{settle}/price_orders` 在公开文档中是 **「一条请求 = 一条计划委托」**;**没有**与「网页止盈+止损一体、撮合层保证互撤」等价的、**写进 OpenAPI 且稳定**的单请求 OCO。文档里部分 `order_type`(如 `close-long-order`)存在 **read-only、不可在请求体传入** 的说明,**不能**当作已支持的双绑参数来用。
- **现货**侧 `POST /spot/orders``take_profit`/`stop_loss` 等字段,与 **永续计划委托** 不是同一套接口,不能照搬。
- **本仓库策略**:默认开启 **`oco_cleanup`**(应用侧在持仓清空后撤另一腿),**效果上接近 OCO**,代价是 **秒级延迟**、以及进程/网络异常时的理论竞态;这是在不依赖非官方 `text` 编码技巧的前提下较稳妥的做法。若你关闭 `oco_cleanup_enabled`,则完全依赖交易所对 `reduce_only` 计划单的处理,**常见现象是另一腿仍以 `open` 挂在计划列表**,需自行在网页撤单。
### 3.5 `risk`
| 字段 | 说明 |
|------|------|
| `risk_per_trade_frac` | 以损订仓目标比例(如 `0.005` = 0.5%),用于按止损距离换算张数。 |
| `max_open_positions` | 同时占位/持仓品种上限(默认 5)。 |
| `scheme` | 固定为方案 **A**(与推送文案「入场区间 A」一致)。 |
| `oco_cleanup_enabled` | 默认 `true`:净持仓为 0 后由 **`oco_watcher`** 尝试 `DELETE` 本次挂出的两条计划单中仍有效的一腿(见 §3.4.2)。`false` 则不做应用侧撤单。 |
### 3.6 `stats`(面板「统计」正式口径)
| 字段 | 说明 |
|------|------|
| `timezone` | 统计用 IANA 时区,默认 `Asia/Shanghai`。 |
| `official_start` | **正式统计起点**ISO8601**必须带时区**,如 `2026-05-13T02:00:00+08:00`)。仅统计 Gate `position_close` 返回里 **`time`(平仓时间)不早于此** 的记录。 |
| `max_trade_rows` | 从 `position_close` 分页拉取的最大条数(默认 20000,上限 100000;配置键名沿用 `max_trade_rows`);超过则可能 `truncated: true`,序列型指标(如回撤)在截断下不完整。 |
**时间桶(均按 `timezone` 本地理解)**
- **统计日**`[日历 D 日 08:00, D+1 日 08:00)`。标签 **D**`(本地时刻 8h)` 的日历日(与 08:00 换日对齐)。
- **自然周**:周一至周日(上海日历);聚合 **统计日标签**落在该周内的历史平仓记录。
- **自然月**`[当月 1 日 08:00, 次月 1 日 08:00)`,与统计日对齐。
**统计单元**`GET /futures/{settle}/position_close` 每条历史平仓记录(与 App「历史仓位」同类);**`pnl`(或 `realised_pnl`)可解析**的才计入笔数与盈亏序列,按 **`time`(平仓时间)** 排序。**胜率** = 盈利笔数 / 总笔数(含盈亏为 0);**盈亏比** = 毛利和 / \|毛亏和\|(无亏损时为 `null`);**最大单笔亏损** = 最小 `pnl`**最大回撤** = 按时间累加 `pnl` 的权益曲线相对历史峰值的 `max(peak equity)`**最大连续亏损次数** = 连续 `pnl < 0` 的最大长度。
面板 **「统计」** 分区调用 `GET /api/stats/summary`(需登录),**不会**随 `/api/state` 每 2 秒刷新,需手动点「刷新统计」。
### 3.7 `proxy`
`onchain_scout_gate``proxy` **块写法一致**
- `enabled: true` 且填写 `url`(如 `socks5h://127.0.0.1:1080`)时,使用 `httpx_client_kwargs` 的出站逻辑与扫描端 Gate 客户端相同(`socks5h` 会转为 `socks5` 以兼容 httpx)。
- 使用 **SOCKS** 时需安装 `socksio``pip install socksio``bootstrap.sh` 会尝试安装)。
### 3.8 `wecom`(企业微信群机器人 · 执行结果)
- **定位**:仅推送本执行器侧 **执行结果**(每条 `POST /v1/signal` 处理摘要、面板一键平仓成败、OCO 撤另一腿失败、SQLite 落库失败)。**策略/发现类**仍由 `onchain_scout_gate` 扫描端发企业微信。
- **配置**`enabled: true` 且填写 `webhook_url`(群机器人 Webhook 完整 URL)。走与 Gate 相同的 **proxy** 出站策略(若启用)。
- **关闭**`enabled: false` 或留空 `webhook_url` 即不发送。
## 4. Web 面板
- **必须用运行中的服务访问**,在浏览器地址栏输入 `http://<host>:<port>/dashboard`(例如本机 `http://127.0.0.1:8090/dashboard`)。不要双击或用「打开文件」方式直接打开 `templates/dashboard.html`:那样是 `file://` 协议,**不会**经过 FastAPI、**不会**加载 `/static/style.css`,页面会变成未样式的白底裸 HTML,模板里的 `{{ username }}` 等也不会被渲染。
- 开启 `auth.enabled` 时先访问 `/login`,使用 JSON 方式提交账号密码。
- 面板为顶部分区:**概览**、**持仓与计划**、**成交与委托**、**统计**、**信号流**(`POST /v1/signal` 写入 SQLite,默认 `database.sqlite_path`,配置留空时亦回退为该路径;面板与 `GET /api/signals/export.csv` 读库,**进程重启后记录仍在**`/api/state` 返回 `signals_persisted` / `signals_sqlite_path` 供前端展示持久化状态)。
- 前端约 **每 2 秒** 轮询 `GET /api/state`,已配置密钥时会附带拉取账户、**持仓**、计划委托。
- **联调(拉取余额 / 极小测试市价)不再放在面板**,请在服务器用 **`curl`** 或自写脚本调用 `POST /api/test``POST /v1/test`,见下文 **§4.1** 与 [部署说明.md](./部署说明.md) **§11**。
### 4.1 用 curl 联调 `POST /api/test` 与 `POST /v1/test`
以下端口、账号、密钥请替换为你的 `config.yaml` 实际值;路径以本机 `127.0.0.1:8090` 为例。
**鉴权说明**
- `POST /api/test`:需 **面板登录 Cookie**`auth.enabled: true` 时先登录再带 `-b` cookie 文件)。
- `POST /v1/test`:仅需请求头 **`X-Webhook-Secret`**,与 `security.webhook_secret` 一致,**无需 Cookie**(适合 SSH 在服务器上一条命令联调)。
**1)若开启 `auth.enabled`,先登录保存 Cookie**
```bash
curl -s -c /tmp/gate_exec_cookies.txt -X POST "http://127.0.0.1:8090/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"你的面板密码"}'
```
`auth.enabled: false`,可跳过登录,下面 `/api/test``-b` 可省略。
**2)仅读合约账户(balance**
```bash
curl -s -b /tmp/gate_exec_cookies.txt -X POST "http://127.0.0.1:8090/api/test" \
-H "Content-Type: application/json" \
-d '{"action":"balance"}'
```
**3)极小真实 IOC 市价(micro_market**
须同时满足:`gate.test_orders_enabled: true`,且已配置 `api_key` / `api_secret`。张数与 `test_max_contracts` 取小。
```bash
curl -s -b /tmp/gate_exec_cookies.txt -X POST "http://127.0.0.1:8090/api/test" \
-H "Content-Type: application/json" \
-d '{"action":"micro_market","contract":"BTC_USDT","side":"long","size":1}'
```
**4)无 Cookie:用 Webhook 密钥调同一套逻辑(`/v1/test`)**
```bash
curl -s -X POST "http://127.0.0.1:8090/v1/test" \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: 与_config.yaml_security.webhook_secret_一致" \
-d '{"action":"balance"}'
```
`micro_market` 同理,把 `body` 换成与上一步相同的 JSON 即可。
**HTTP 状态**`balance` 且 Gate 账户接口失败时一般为 **502**`micro_market``{"ok":false}` 时一般为 **400**
**Pythonhttpx)示例(`/v1/test`**
```python
import httpx
r = httpx.post(
"http://127.0.0.1:8090/v1/test",
headers={"X-Webhook-Secret": "your-secret"},
json={"action": "balance"},
timeout=30.0,
)
print(r.status_code, r.text)
```
## 5. HTTP 接口摘要
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/health` | 健康检查;无需登录。 |
| GET | `/dashboard` | 面板页面。 |
| GET | `/api/state` | JSON 状态;未登录且开启 auth 时返回 401。已配密钥时含 `futures_account` / `futures_account_error``open_price_orders`(最多 50 条 open 计划委托摘要)/ `open_price_orders_error``positions.open_slot_count`(本地占位)、`positions.exchange`(非零持仓摘要列表)、`positions.exchange_error`。 |
| GET | `/api/stats/summary` | 需登录且已配密钥。Query`contract`(可选,大写合约如 `BTC_USDT`)。返回当前 **统计日 / 自然周 / 自然月**(见 §3.6)内正式起点后的历史平仓 `pnl` 聚合;`ok: false` 时见 `error`。拉取可能较慢,勿高频轮询。 |
| GET | `/api/gate/trades` | 需登录。查询成交:Gate `GET /futures/{settle}/my_trades_timerange`。Query`contract`(可选)、`from` / `to`(Unix 秒,均可省略则默认最近 7 天至此刻)、`limit`1500)、`offset`。返回 `rows``error`(接口异常时非空)。 |
| GET | `/api/gate/trades.csv` | 需登录。同上时间范围,分页拉取至最多 `max` 行(默认 2000,上限 5000),返回 CSV 附件 `gate_futures_trades.csv`。 |
| GET | `/api/gate/orders_history` | 需登录。Gate `GET /futures/{settle}/orders`。Query`status=open|finished`(默认 `finished`)、`contract`(可选)、`limit``offset`。 |
| GET | `/api/gate/orders_history.csv` | 需登录。委托导出 CSV`status``contract` 同上,`max` 同成交导出。 |
| POST | `/api/positions/market_close` | 需登录。JSON`{"contract":"BTC_USDT"}`。实盘且非 `dry_run` 时向 Gate 发 **市价 IOC + reduce_only** 平掉该合约净持仓;成功后释放本地占位槽。`dry_run` / 无仓 / 无密钥 时 400Gate 错误 502。 |
| POST | `/api/price_orders/manual` | 需登录。JSON`contract``trigger_price`(字符串数字)、`rule`1 或 2,与 Gate `price_orders` 触发规则一致)。挂一条 **全平** 条件计划单(`close`+`size`0 形态)。`dry_run` 时 400。 |
| POST | `/api/price_orders/cancel` | 需登录 Cookie。JSON`{"order_id":"2054233581303107584"}`(与面板列表一致)。调用 Gate `DELETE .../price_orders/{id}`;失败时 400。 |
| POST | `/api/test` | 需登录 Cookie。JSON`{"action":"balance"}` 仅读余额;`{"action":"micro_market","contract":"BTC_USDT","side":"long"|"short","size":1}` 发极小 IOC 市价(需 `test_orders_enabled`)。HTTP`balance` 且 API 错误时 502`micro_market``ok:false` 时 400。 |
| POST | `/v1/test` | 与 `/api/test` 相同 JSON;鉴权为请求头 `X-Webhook-Secret`(与 `security.webhook_secret` 一致),无需 Cookie。 |
| POST | `/v1/signal` | 扫描端推送信号;请求头 `X-Webhook-Secret`JSON 体见项目根目录 `README.md` 表格。 |
## 6. 与扫描服务协作
`onchain_scout_gate` 判定需要自动执行时,由扫描机 **内网** `httpx` 请求本服务 `POST /v1/signal`(例如 `http://127.0.0.1:8090/v1/signal`)。**不要**解析企业微信文本做下单。
## 7. 日志与排错
- PM2 标准输出/错误:`runtime/pm2-executor-out.log``runtime/pm2-executor-error.log`
-`config.yaml` 后需 **重启进程**`bash deploy/pm2-restart.sh``pm2 restart gate-order-executor`
- 若面板无法访问:检查 `app.host`/`app.port`、本机防火墙、以及是否与扫描端口冲突。
+189
View File
@@ -0,0 +1,189 @@
# Gate 下单执行器 · 部署说明
本文面向 **Ubuntu / Debian** 等 Linux 服务器,说明从零安装、PM2 守护、可选 systemd 开机自启,以及与 `onchain_scout_gate` 同机部署时的注意点。**功能与配置项含义**见 [使用说明.md](./使用说明.md)。
## 1. 环境要求
- Python 3.10+(推荐 3.11
- 可访问公网拉取 PyPI(或自备镜像)
- 使用 PM2`Node.js` + `npm install -g pm2`
- 若使用 **SOCKS** 代理访问 Gate`pip install socksio``bootstrap.sh` 会尝试安装)
## 2. 上传代码
将仓库目录放到服务器,例如:
```text
/root/gate_order_executor/
```
下文以该路径为例;若你的目录不同,请替换所有命令中的路径,并修改 `deploy/gate-order-executor.service` 里的 `WorkingDirectory`
## 3. 首次安装(bootstrap
```bash
cd /root/gate_order_executor
chmod +x deploy/*.sh
bash deploy/bootstrap.sh /root/gate_order_executor
```
脚本会:
- 创建 `.venv` 并安装 `requirements.txt`
- 尝试安装 `socksio`(失败可忽略,按需手动安装)
- 若不存在 `config.yaml`,从 `config.example.yaml` 复制一份
- 创建 `runtime/` 目录
然后 **必须** 编辑 `config.yaml`
- `app.session_secret``security.webhook_secret`
- `auth`(若对外暴露建议 `enabled: true` 并改密码)
- `proxy`:本机走 SOCKS 时 `enabled: true`**云服务器能直连 `api.gateio.ws` 时设为 `false`**(见下文 §6.1
- 需要远端访问面板时,将 `app.host` 设为 `0.0.0.0`,并限制防火墙来源 IP
## 4. PM2 启动与维护
### 4.1 启动(推荐脚本)
```bash
cd /root/gate_order_executor
bash deploy/pm2-start.sh
```
若进程已在 PM2 列表中,脚本会执行 **`pm2 restart`** 而非重复 `start`
### 4.2 常用命令
```bash
pm2 logs gate-order-executor # 实时日志
pm2 status # 状态列表
bash deploy/pm2-restart.sh # 改配置后重启
bash deploy/pm2-stop.sh # 停止
bash deploy/pm2-delete.sh # 从 PM2 删除该应用
pm2 save # 保存进程列表(配合开机自启)
```
### 4.3 等价手动命令
```bash
cd /root/gate_order_executor
mkdir -p runtime
pm2 start deploy/ecosystem.config.cjs
pm2 save
```
`ecosystem.config.cjs` 使用项目内 `.venv/bin/python` 执行 `run.py`,日志写入 `runtime/pm2-executor-*.log`,并设置 `PYTHONUNBUFFERED=1`
## 5. 前台调试(无 PM2
```bash
cd /root/gate_order_executor
bash deploy/start.sh /root/gate_order_executor
```
用于排查问题;生产环境请用 PM2。
## 6. 与扫描服务同机部署
典型端口:
- 扫描:`8088`(以 `onchain_scout_gate/config.yaml` 为准)
- 执行器:`8090`(以本仓库 `config.yaml` 为准);多账户可再起 `8091`
两者使用 **不同 PM2 应用名**`onchain-scout``gate-order-executor`),互不影响。
**多执行器:** 由扫描端 Web 面板维护转发列表(`runtime/order_executors.json`),同一信号广播到多个 URL;本仓库 **不** 提供向扫描端「反向注册」。详见 `onchain_scout_gate/docs/多执行器与信号转发归档.md`
扫描端向本机执行器发信号示例:
```text
POST http://127.0.0.1:8090/v1/signal
Header: X-Webhook-Secret: <与扫描端面板及本机 security.webhook_secret 一致>
```
### 6.1 云服务器关闭代理
境外云主机通常 **无需** SOCKS。在 **本仓库** `config.yaml` 中:
```yaml
proxy:
enabled: false
```
保存后 `pm2 restart gate-order-executor`。自检:`curl -I --max-time 15 https://api.gateio.ws`
扫描端 `onchain_scout_gate``proxy` 也需同样关闭(仅影响其拉行情,不影响 POST 信号)。见 `onchain_scout_gate/交易系统部署说明.md` §8。
## 7. 可选:systemd + pm2-runtime 开机自启
适合希望 **不依赖 `pm2 startup` 脚本**、由 systemd 直接拉起 PM2 托管进程的场景。
1. 编辑 `deploy/gate-order-executor.service`:将 `WorkingDirectory=` 改为你的项目绝对路径;确认 `ExecStart``pm2-runtime` 路径(`which pm2-runtime`)。
2. 安装单元:
```bash
sudo cp /root/gate_order_executor/deploy/gate-order-executor.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable gate-order-executor
sudo systemctl start gate-order-executor
sudo systemctl status gate-order-executor
```
注意:若同时使用 **交互式 `pm2 start`****systemd pm2-runtime**,容易重复启动;二选一即可。
## 8. 防火墙与安全
- 仅本机扫描调用信号时:可将 `app.host` 保持 `127.0.0.1`,则外网无法直连 HTTP。
- 若需浏览器远程看面板:使用 `0.0.0.0` + 防火墙白名单 + **务必开启 `auth`**,并配合 HTTPS 反向代理(Nginx/Caddy)更佳。
- `config.yaml` 含 API Key 与 Webhook 密钥,权限建议:`chmod 600 config.yaml`
## 9. 升级代码后
```bash
cd /root/gate_order_executor
git pull # 或上传新文件
source .venv/bin/activate
pip install -r requirements.txt
bash deploy/pm2-restart.sh
```
## 10. 故障速查
| 现象 | 可能原因 |
|------|----------|
| `TypeError: unhashable type: 'dict'`Jinja 加载模板) | Starlette ≥0.29 起 `TemplateResponse` 须为 `TemplateResponse(request, "x.html", {...})`,勿把 `request` 放在第一个位置。请拉取最新 `app/main.py` 后重启。 |
| PM2 反复重启 | `config.yaml` 校验失败、端口被占用、依赖缺失;看 `pm2 logs``runtime/pm2-executor-error.log` |
| 面板打不开 | `host``127.0.0.1` 却从外网访问;或防火墙未放行 `port` |
| SOCKS 代理失败 | 未安装 `socksio`;或代理地址/协议错误 |
| 401 on `/v1/signal` | `X-Webhook-Secret` 与配置不一致 |
## 11. 联调测试(curl / 无面板)
面板 **不提供**「拉取余额 / 测试市价」按钮;联调请在本机或 SSH 到服务器后用 **`curl`**(或脚本调用 `httpx`)请求 **`POST /api/test`**、**`POST /v1/test`**。详细参数、Cookie 登录、`micro_market` 条件见 [使用说明.md](./使用说明.md) **§4.1**。
**快速示例(已关闭 `auth` 或已另行登录拿到 Cookie 时省略 `-b`**
```bash
# 仅读合约账户(需 Cookie 时加:-b /tmp/gate_exec_cookies.txt
curl -s -X POST "http://127.0.0.1:8090/api/test" \
-H "Content-Type: application/json" \
-d '{"action":"balance"}'
```
```bash
# 无 Cookie,用 Webhook 密钥(与 security.webhook_secret 一致)
curl -s -X POST "http://127.0.0.1:8090/v1/test" \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: YOUR_WEBHOOK_SECRET" \
-d '{"action":"balance"}'
```
```bash
# 极小真实 IOC(须 gate.test_orders_enabled: true;建议仍用子账户)
curl -s -X POST "http://127.0.0.1:8090/v1/test" \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: YOUR_WEBHOOK_SECRET" \
-d '{"action":"micro_market","contract":"BTC_USDT","side":"long","size":1}'
```
`config.yaml` 后执行 **`bash deploy/pm2-restart.sh`** 再测。
+8
View File
@@ -0,0 +1,8 @@
fastapi>=0.110,<1
uvicorn[standard]>=0.27,<1
httpx>=0.27,<1
# 使用 SOCKS 代理时再装:pip install socksio(与扫描端 httpx[socks] 一致)
pydantic>=2.6,<3
PyYAML>=6.0,<7
jinja2>=3.1,<4
itsdangerous>=2.2,<3
+16
View File
@@ -0,0 +1,16 @@
"""本地启动:python run.py(需先 config.yaml + venv)。"""
from __future__ import annotations
import uvicorn
from app.config import load_settings
if __name__ == "__main__":
s = load_settings()
uvicorn.run(
"app.main:app",
host=s.app.host,
port=s.app.port,
reload=False,
)
File diff suppressed because it is too large Load Diff
+874
View File
@@ -0,0 +1,874 @@
/* Gate Order Executor — refined dark console */
:root {
--exec-bg-deep: #070708;
--exec-bg-raised: #0c0c10;
--exec-bg-card: #101118;
--exec-bg-card-hover: #16161f;
--exec-border: rgba(255, 255, 255, 0.055);
--exec-border-strong: rgba(255, 255, 255, 0.12);
--exec-text: #f7f7fb;
--exec-text-muted: #a8a8b8;
--exec-text-dim: #6d6d7c;
--exec-accent: #ececf4;
--exec-accent-soft: rgba(236, 236, 244, 0.09);
--exec-line: rgba(255, 255, 255, 0.038);
--exec-glow: rgba(120, 160, 255, 0.08);
--exec-radius: 17px;
--exec-radius-sm: 12px;
--exec-font: "Plus Jakarta Sans", "SF Pro Text", "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
--exec-mono: "JetBrains Mono", "SF Mono", ui-monospace, "Cascadia Code", monospace;
--exec-shadow:
0 0 0 1px rgba(255, 255, 255, 0.045),
0 2px 4px rgba(0, 0, 0, 0.35),
0 28px 56px rgba(0, 0, 0, 0.5),
0 64px 128px rgba(0, 0, 0, 0.28);
--exec-shadow-hover:
0 0 0 1px rgba(255, 255, 255, 0.07),
0 36px 72px rgba(0, 0, 0, 0.55);
--exec-header-h: 78px;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
/* —— Shell(登录 + 控制台共用底层) —— */
.exec-shell {
margin: 0;
min-height: 100vh;
font-family: var(--exec-font);
background: var(--exec-bg-deep);
color: var(--exec-text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
color-scheme: dark;
}
.exec-ambient {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background:
radial-gradient(ellipse 110% 85% at 50% -35%, rgba(115, 155, 255, 0.11), transparent 52%),
radial-gradient(ellipse 55% 42% at 100% 8%, rgba(255, 255, 255, 0.045), transparent 48%),
radial-gradient(ellipse 50% 38% at 0% 88%, rgba(72, 200, 190, 0.05), transparent 46%),
linear-gradient(180deg, #0b0b0f 0%, var(--exec-bg-deep) 38%, #050506 100%);
}
.exec-noise {
position: fixed;
inset: 0;
z-index: 0;
opacity: 0.035;
pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
.exec-grid-faint {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background-size: 48px 48px;
background-image:
linear-gradient(var(--exec-line) 1px, transparent 1px),
linear-gradient(90deg, var(--exec-line) 1px, transparent 1px);
mask-image: radial-gradient(ellipse 70% 60% at 50% 40%, black 20%, transparent 100%);
opacity: 0.5;
}
/* —— 顶栏模块 —— */
.exec-header {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
min-height: var(--exec-header-h);
padding: 0 clamp(22px, 4.5vw, 44px);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
background: linear-gradient(180deg, rgba(14, 14, 18, 0.92) 0%, rgba(8, 8, 11, 0.82) 100%);
backdrop-filter: blur(22px) saturate(1.45);
-webkit-backdrop-filter: blur(22px) saturate(1.45);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.05) inset,
0 20px 48px rgba(0, 0, 0, 0.45);
}
.exec-header__brand {
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
}
.exec-header__text {
min-width: 0;
}
.exec-mark {
flex-shrink: 0;
width: 46px;
height: 46px;
border-radius: 14px;
display: grid;
place-items: center;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--exec-text);
background: linear-gradient(152deg, #222632 0%, #12141d 48%, #161a24 100%);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.1),
0 0 0 1px rgba(0, 0, 0, 0.4),
0 10px 28px rgba(0, 0, 0, 0.45);
}
.exec-title {
margin: 0;
font-size: clamp(1.08rem, 2.1vw, 1.32rem);
font-weight: 600;
letter-spacing: -0.028em;
color: var(--exec-text);
line-height: 1.2;
}
.exec-title__sep {
margin: 0 0.12em;
font-weight: 500;
color: var(--exec-text-dim);
}
.exec-subtitle {
margin: 6px 0 0;
font-size: 0.8rem;
color: var(--exec-text-dim);
letter-spacing: 0.03em;
font-weight: 500;
}
.exec-header__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.exec-clock {
font-family: var(--exec-mono);
font-size: 0.78rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--exec-text-muted);
padding: 9px 16px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.02) 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.exec-user {
font-size: 0.78rem;
color: var(--exec-text-muted);
padding: 9px 16px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.07) 0%, rgba(255, 255, 255, 0.02) 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.exec-user strong {
color: var(--exec-text);
font-weight: 600;
}
/* —— 主内容区:模块化栅格 —— */
.exec-main {
position: relative;
z-index: 1;
max-width: 1360px;
margin: 0 auto;
padding: clamp(20px, 3vw, 32px) clamp(20px, 4vw, 40px) 48px;
}
/* 标签页布局:顶部分区切换,内容区各自滚动(高度 ≈ 视口 − 顶栏) */
.exec-main--tabbed {
min-height: calc(100vh - var(--exec-header-h));
display: flex;
flex-direction: column;
width: 100%;
max-width: 1360px;
margin: 0 auto;
padding: clamp(14px, 2.2vw, 22px) clamp(22px, 4.5vw, 44px) 0;
padding-bottom: 0;
}
.exec-tabs {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-bottom: 22px;
flex-shrink: 0;
padding: 6px 7px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.025);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.exec-tab {
appearance: none;
margin: 0;
cursor: pointer;
font: inherit;
font-size: 0.83rem;
font-weight: 500;
letter-spacing: 0.01em;
color: var(--exec-text-muted);
background: transparent;
border: 1px solid transparent;
border-radius: 11px;
padding: 10px 17px;
transition:
color 0.18s ease,
background 0.18s ease,
border-color 0.18s ease,
box-shadow 0.18s ease;
}
.exec-tab:hover {
color: var(--exec-text);
background: rgba(255, 255, 255, 0.055);
}
.exec-tab:focus-visible {
outline: 2px solid rgba(130, 165, 255, 0.35);
outline-offset: 2px;
}
.exec-tab--active {
color: var(--exec-text);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.035) 100%);
border-color: rgba(255, 255, 255, 0.1);
box-shadow:
0 0 28px rgba(100, 140, 255, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.exec-tab-panels {
flex: 1;
min-height: 0;
position: relative;
margin-bottom: clamp(16px, 2vw, 28px);
}
.exec-tab-panel {
display: none;
position: absolute;
inset: 0;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
padding-bottom: 32px;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.14) transparent;
}
.exec-tab-panel::-webkit-scrollbar {
width: 9px;
}
.exec-tab-panel::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.12);
border-radius: 99px;
border: 2px solid transparent;
background-clip: padding-box;
}
.exec-tab-panel::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.18);
background-clip: padding-box;
}
.exec-tab-panel--active {
display: block;
}
.exec-modal {
position: fixed;
inset: 0;
z-index: 100;
display: none;
align-items: center;
justify-content: center;
padding: 20px;
}
.exec-modal.exec-modal--open {
display: flex;
}
.exec-modal__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.68);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
cursor: pointer;
}
.exec-modal__card {
position: relative;
z-index: 1;
width: 100%;
max-width: 440px;
padding: 24px 24px 22px;
border-radius: calc(var(--exec-radius) + 2px);
border: 1px solid rgba(255, 255, 255, 0.1);
background: linear-gradient(180deg, rgba(24, 24, 32, 0.98) 0%, rgba(14, 14, 18, 0.99) 100%);
box-shadow: var(--exec-shadow), 0 0 80px rgba(0, 0, 0, 0.5);
}
.exec-modal__title {
margin: 0 0 10px;
font-size: 1rem;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--exec-text);
}
.exec-btn--sm {
font-size: 0.72rem;
padding: 5px 10px;
border-radius: 8px;
}
.exec-pos-actions {
white-space: nowrap;
}
.exec-section-label {
font-size: 0.64rem;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--exec-text-dim);
margin: 0 0 14px 4px;
opacity: 0.92;
}
.exec-module-row {
display: grid;
gap: 16px;
margin-bottom: 16px;
}
.exec-module-row--3 {
grid-template-columns: repeat(3, 1fr);
}
@media (max-width: 1020px) {
.exec-module-row--3 {
grid-template-columns: 1fr;
}
}
/* 单模块卡片 */
.exec-module {
background: linear-gradient(165deg, rgba(22, 22, 30, 0.92) 0%, rgba(12, 12, 16, 0.96) 55%, rgba(10, 10, 14, 0.98) 100%);
border: 1px solid rgba(255, 255, 255, 0.065);
border-radius: var(--exec-radius);
box-shadow: var(--exec-shadow);
overflow: hidden;
transition:
border-color 0.22s ease,
box-shadow 0.22s ease,
transform 0.22s ease;
}
.exec-module:hover {
border-color: rgba(255, 255, 255, 0.1);
box-shadow: var(--exec-shadow-hover);
transform: translateY(-1px);
}
.exec-module--wide {
grid-column: 1 / -1;
}
.exec-module-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 17px 22px;
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.055) 0%, rgba(255, 255, 255, 0) 100%);
}
.exec-module-title {
margin: 0;
font-size: 0.94rem;
font-weight: 600;
letter-spacing: -0.015em;
color: var(--exec-text);
}
.exec-module-meta {
font-size: 0.7rem;
color: var(--exec-text-dim);
letter-spacing: 0.06em;
font-weight: 500;
text-align: right;
max-width: 52%;
line-height: 1.45;
}
.exec-module-body {
padding: 20px 22px 22px;
}
/* 指标栅格(子模块) */
.exec-metric-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.exec-metric {
padding: 15px 15px 13px;
border-radius: var(--exec-radius-sm);
border: 1px solid rgba(255, 255, 255, 0.055);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.045) 0%, rgba(255, 255, 255, 0.015) 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.exec-metric-label {
display: block;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--exec-text-dim);
margin-bottom: 9px;
}
.exec-metric-value {
font-size: 0.96rem;
font-weight: 500;
color: var(--exec-text);
line-height: 1.35;
}
.exec-metric-value--mono {
font-family: var(--exec-mono);
font-size: 0.83rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
word-break: break-all;
color: var(--exec-text-muted);
}
/* 状态芯片 */
.exec-chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 5px 11px;
border-radius: 8px;
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.exec-chip::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
opacity: 0.85;
}
.exec-chip--neutral {
color: var(--exec-text-muted);
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--exec-border);
}
.exec-chip--ok {
color: #86efac;
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.22);
}
.exec-chip--warn {
color: #fcd34d;
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.22);
}
.exec-chip--live {
color: #fca5a5;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.22);
}
/* 持仓:交易所张数符号 = 方向;未实现盈亏着色 */
.exec-dir {
display: inline-block;
margin-left: 8px;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.72rem;
font-weight: 600;
text-transform: lowercase;
vertical-align: middle;
}
.exec-dir--long {
color: #86efac;
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(34, 197, 94, 0.25);
}
.exec-dir--short {
color: #fca5a5;
background: rgba(239, 68, 68, 0.12);
border: 1px solid rgba(239, 68, 68, 0.25);
}
.exec-pnl--profit {
color: #86efac;
font-weight: 600;
}
.exec-pnl--loss {
color: #fca5a5;
font-weight: 600;
}
.exec-pnl--flat {
color: var(--exec-text-muted);
}
.exec-prose {
margin: 16px 0 0;
font-size: 0.78rem;
line-height: 1.65;
color: var(--exec-text-dim);
}
.exec-prose code {
font-family: var(--exec-mono);
font-size: 0.73rem;
color: var(--exec-text-muted);
padding: 3px 7px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
}
/* 表格模块 */
.exec-table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 14px;
}
.exec-table-hint {
margin: 0;
font-size: 0.75rem;
color: var(--exec-text-dim);
flex: 1 1 280px;
min-width: 0;
}
.exec-sig-export {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.exec-sig-export-label {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.78rem;
color: var(--exec-text-muted);
cursor: pointer;
user-select: none;
}
.exec-sig-export-label input {
accent-color: #22c55e;
}
.exec-sig-persist {
margin: 0 0 14px;
padding: 9px 14px;
font-size: 0.74rem;
line-height: 1.5;
border-radius: var(--exec-radius-sm);
border: 1px solid var(--exec-border);
background: rgba(255, 255, 255, 0.03);
color: var(--exec-text-muted);
}
.exec-sig-persist--ok {
border-color: rgba(34, 197, 94, 0.35);
background: rgba(34, 197, 94, 0.08);
color: #86efac;
}
.exec-sig-persist--warn {
border-color: rgba(245, 158, 11, 0.45);
background: rgba(245, 158, 11, 0.1);
color: #fcd34d;
}
.exec-table-scroll {
overflow-x: auto;
margin: 0 -2px;
padding: 0;
border-radius: calc(var(--exec-radius-sm) + 2px);
border: 1px solid rgba(255, 255, 255, 0.07);
background: linear-gradient(180deg, rgba(8, 8, 11, 0.95) 0%, rgba(5, 5, 8, 0.98) 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.exec-table {
width: 100%;
border-collapse: collapse;
font-size: 0.83rem;
}
.exec-table th {
text-align: left;
padding: 13px 16px;
font-size: 0.64rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--exec-text-dim);
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%);
white-space: nowrap;
}
.exec-table td {
padding: 13px 16px;
border-bottom: 1px solid var(--exec-line);
color: var(--exec-text-muted);
}
.exec-table tbody tr {
transition: background 0.16s ease;
}
.exec-table tbody tr:hover {
background: rgba(120, 155, 255, 0.045);
}
.exec-table tbody tr:last-child td {
border-bottom: none;
}
.exec-table .exec-mono {
font-family: var(--exec-mono);
font-size: 0.8rem;
color: var(--exec-text);
}
.exec-cell-status {
font-weight: 500;
}
.exec-cell-status--ok {
color: #86efac;
}
.exec-cell-status--skip {
color: var(--exec-text-muted);
}
.exec-cell-status--err {
color: #fca5a5;
}
.exec-muted {
color: var(--exec-text-dim);
text-align: center;
padding: 28px 16px !important;
}
/* 按钮 */
.exec-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 19px;
border-radius: 11px;
font-weight: 600;
font-size: 0.8rem;
letter-spacing: 0.02em;
text-decoration: none;
cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.12);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.03) 100%);
color: var(--exec-text);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
transition:
background 0.18s ease,
border-color 0.18s ease,
box-shadow 0.18s ease,
transform 0.15s ease;
}
.exec-btn:hover {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.05) 100%);
border-color: rgba(255, 255, 255, 0.18);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.1),
0 8px 24px rgba(0, 0, 0, 0.35);
transform: translateY(-0.5px);
}
.exec-btn--primary {
border: 1px solid rgba(255, 255, 255, 0.28);
background: linear-gradient(185deg, #ffffff 0%, #e4e4ea 48%, #c8c8d4 100%);
color: #0a0a0f;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.65),
0 10px 28px rgba(0, 0, 0, 0.35);
}
.exec-btn--primary:hover {
background: linear-gradient(185deg, #ffffff 0%, #f0f0f6 50%, #dcdce6 100%);
border-color: rgba(255, 255, 255, 0.38);
}
/* —— 登录页(模块化单卡) —— */
.exec-login-body.exec-shell {
display: grid;
place-items: center;
padding: 24px;
}
.exec-login-stage {
position: relative;
z-index: 1;
width: 100%;
max-width: 420px;
}
.exec-login-card {
border-radius: calc(var(--exec-radius) + 2px);
border: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(165deg, rgba(22, 22, 30, 0.95) 0%, rgba(12, 12, 16, 0.98) 100%);
box-shadow: var(--exec-shadow);
overflow: hidden;
}
.exec-login-card::before {
content: "";
display: block;
height: 3px;
background: linear-gradient(
90deg,
transparent,
rgba(130, 165, 255, 0.45),
rgba(255, 255, 255, 0.35),
rgba(130, 165, 255, 0.45),
transparent
);
}
.exec-login-inner {
padding: 36px 32px 32px;
}
.exec-login-kicker {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.2em;
color: var(--exec-text-dim);
margin-bottom: 12px;
}
.exec-login-title {
margin: 0 0 10px;
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.03em;
color: var(--exec-text);
}
.exec-login-desc {
margin: 0 0 28px;
font-size: 0.85rem;
line-height: 1.55;
color: var(--exec-text-muted);
}
.exec-field {
margin-bottom: 18px;
}
.exec-label {
display: block;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--exec-text-dim);
margin-bottom: 8px;
}
.exec-input {
width: 100%;
padding: 12px 14px;
border-radius: var(--exec-radius-sm);
border: 1px solid var(--exec-border);
background: var(--exec-bg-deep);
color: var(--exec-text);
font-size: 0.95rem;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.exec-input:focus {
outline: none;
border-color: rgba(255, 255, 255, 0.22);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.05);
}
.exec-login-form .exec-btn--primary {
width: 100%;
margin-top: 8px;
padding: 12px;
}
.exec-error {
min-height: 22px;
margin-top: 16px;
font-size: 0.82rem;
color: #fca5a5;
}
.exec-shell ::selection {
background: rgba(130, 165, 255, 0.28);
color: var(--exec-text);
}
@media (prefers-reduced-motion: reduce) {
.exec-module,
.exec-btn,
.exec-tab {
transition: none !important;
}
.exec-module:hover,
.exec-btn:hover {
transform: none !important;
}
}
@@ -0,0 +1,664 @@
/**
* MATRIX 式交易终端黑底 · 霓虹青 · 洋红点缀 · 等宽信息密度
* 仅当 body .exec-theme-matrix 时由 dashboard / login 引入
*/
body.exec-theme-matrix.exec-shell {
font-family: "JetBrains Mono", ui-monospace, "Cascadia Code", monospace;
background: #000;
color: rgba(126, 232, 234, 0.92);
letter-spacing: 0.02em;
}
body.exec-theme-matrix .exec-ambient {
background:
radial-gradient(ellipse 90% 55% at 50% -10%, rgba(0, 255, 234, 0.12), transparent 52%),
radial-gradient(ellipse 45% 35% at 100% 80%, rgba(255, 46, 166, 0.06), transparent 50%),
linear-gradient(180deg, #030308 0%, #000 45%, #020204 100%);
}
body.exec-theme-matrix .exec-noise {
opacity: 0.045;
}
body.exec-theme-matrix .exec-grid-faint {
background-size: 28px 28px;
background-image:
linear-gradient(rgba(0, 255, 234, 0.07) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 255, 234, 0.07) 1px, transparent 1px);
mask-image: radial-gradient(ellipse 75% 65% at 50% 35%, black 15%, transparent 75%);
opacity: 0.4;
}
/* —— 顶栏:状态条 + 主标题行 —— */
body.exec-theme-matrix .exec-header {
flex-direction: column;
align-items: stretch;
gap: 0;
min-height: auto;
padding: 0;
border-bottom: 1px solid rgba(0, 255, 234, 0.45);
background: linear-gradient(180deg, rgba(0, 8, 10, 0.97) 0%, rgba(0, 0, 0, 0.92) 100%);
box-shadow:
0 0 32px rgba(0, 255, 234, 0.12),
0 1px 0 rgba(255, 46, 166, 0.15) inset;
backdrop-filter: blur(12px);
}
body.exec-theme-matrix .exec-terminal-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
padding: 8px clamp(14px, 3vw, 28px);
border-bottom: 1px solid rgba(0, 255, 234, 0.2);
background: rgba(0, 0, 0, 0.55);
}
body.exec-theme-matrix .exec-tx-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(126, 232, 234, 0.75);
border: 1px solid rgba(0, 255, 234, 0.35);
border-radius: 2px;
background: rgba(0, 20, 22, 0.9);
box-shadow: 0 0 12px rgba(0, 255, 234, 0.08);
}
body.exec-theme-matrix .exec-tx-chip strong,
body.exec-theme-matrix .exec-tx-chip .exec-tx-mono {
color: #bfffff;
font-weight: 600;
letter-spacing: 0.06em;
}
body.exec-theme-matrix .exec-tx-chip--live {
border-color: rgba(0, 255, 234, 0.55);
color: #7ee8ea;
text-shadow: 0 0 12px rgba(0, 255, 234, 0.45);
}
body.exec-theme-matrix .exec-tx-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #00ffe0;
box-shadow: 0 0 10px #00ffe0;
animation: mtx-pulse 1.8s ease-in-out infinite;
}
@keyframes mtx-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
body.exec-theme-matrix .exec-tx-chip--time .exec-tx-mono {
font-variant-numeric: tabular-nums;
}
body.exec-theme-matrix .exec-terminal-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 14px 20px;
padding: 14px clamp(14px, 3vw, 28px) 16px;
}
body.exec-theme-matrix .exec-terminal-logo {
flex-shrink: 0;
width: 44px;
height: 44px;
display: grid;
place-items: center;
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.1em;
color: #000;
background: linear-gradient(145deg, #00ffe0 0%, #00b8a8 100%);
border: 1px solid rgba(0, 255, 234, 0.8);
border-radius: 2px;
box-shadow: 0 0 20px rgba(0, 255, 234, 0.35);
}
body.exec-theme-matrix .exec-terminal-center {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 10px;
}
body.exec-theme-matrix .exec-title-terminal {
margin: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 10px 14px;
font-family: "Share Tech Mono", "JetBrains Mono", ui-monospace, monospace;
font-size: clamp(1.35rem, 4.2vw, 2.15rem);
font-weight: 400;
letter-spacing: 0.14em;
text-transform: uppercase;
line-height: 1.1;
color: #9ff;
text-shadow:
0 0 20px rgba(0, 255, 234, 0.55),
0 0 40px rgba(0, 255, 234, 0.2);
}
body.exec-theme-matrix .exec-tt-part {
white-space: nowrap;
}
body.exec-theme-matrix .exec-title-slash {
margin: 0 0.02em;
opacity: 0.55;
font-weight: 400;
letter-spacing: 0.2em;
}
/* 雷达装饰 */
body.exec-theme-matrix .exec-radar {
position: relative;
width: 46px;
height: 46px;
flex-shrink: 0;
}
body.exec-theme-matrix .exec-radar__ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 1px solid rgba(0, 255, 234, 0.5);
box-shadow:
0 0 14px rgba(0, 255, 234, 0.25),
inset 0 0 18px rgba(0, 255, 234, 0.06);
}
body.exec-theme-matrix .exec-radar__sweep {
position: absolute;
inset: 3px;
border-radius: 50%;
background: conic-gradient(from -36deg, transparent 0deg, rgba(0, 255, 234, 0.22) 52deg, transparent 52deg);
animation: mtx-radar 2.8s linear infinite;
}
@keyframes mtx-radar {
to {
transform: rotate(360deg);
}
}
body.exec-theme-matrix .exec-radar__blip {
position: absolute;
width: 7px;
height: 7px;
top: 32%;
right: 18%;
border-radius: 50%;
background: #ff2ea6;
box-shadow: 0 0 12px #ff2ea6;
}
body.exec-theme-matrix .exec-tagline-terminal {
margin: 0;
display: inline-block;
max-width: min(100%, 720px);
padding: 6px 14px;
font-size: 0.62rem;
font-weight: 500;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(255, 180, 220, 0.95);
border: 1px solid rgba(255, 46, 166, 0.55);
border-radius: 2px;
background: rgba(40, 0, 28, 0.45);
box-shadow: 0 0 18px rgba(255, 46, 166, 0.12);
}
body.exec-theme-matrix .exec-terminal-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
/* —— Tab —— */
body.exec-theme-matrix .exec-tabs {
gap: 4px;
padding: 5px 6px;
margin-bottom: 18px;
border-radius: 2px;
border: 1px solid rgba(0, 255, 234, 0.28);
background: rgba(0, 12, 14, 0.75);
box-shadow: inset 0 0 24px rgba(0, 255, 234, 0.04);
}
body.exec-theme-matrix .exec-tab {
border-radius: 2px;
padding: 9px 14px;
font-size: 0.72rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(126, 232, 234, 0.55);
}
body.exec-theme-matrix .exec-tab:hover {
color: #bfffff;
background: rgba(0, 255, 234, 0.08);
}
body.exec-theme-matrix .exec-tab--active {
color: #000;
background: linear-gradient(180deg, #00ffe0 0%, #00c9b0 100%);
border-color: rgba(0, 255, 234, 0.7);
box-shadow: 0 0 22px rgba(0, 255, 234, 0.35);
}
body.exec-theme-matrix .exec-tab:focus-visible {
outline: 2px solid #ff2ea6;
outline-offset: 2px;
}
/* —— 分区标签 —— */
body.exec-theme-matrix .exec-section-label {
letter-spacing: 0.22em;
color: rgba(0, 255, 234, 0.45);
text-shadow: 0 0 8px rgba(0, 255, 234, 0.25);
}
/* —— 卡片 —— */
body.exec-theme-matrix .exec-module {
border-radius: 2px;
border: 1px solid rgba(0, 255, 234, 0.38);
background: linear-gradient(165deg, rgba(0, 18, 20, 0.92) 0%, rgba(0, 0, 0, 0.94) 100%);
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.5),
0 0 28px rgba(0, 255, 234, 0.06);
}
body.exec-theme-matrix .exec-module:hover {
border-color: rgba(0, 255, 234, 0.55);
box-shadow:
0 0 0 1px rgba(255, 46, 166, 0.12),
0 0 36px rgba(0, 255, 234, 0.12);
transform: none;
}
body.exec-theme-matrix .exec-module-head {
padding: 12px 16px;
border-bottom: 1px solid rgba(0, 255, 234, 0.22);
background: linear-gradient(90deg, rgba(0, 255, 234, 0.08) 0%, transparent 55%);
}
body.exec-theme-matrix .exec-module-title {
font-size: 0.78rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #bfffff;
}
body.exec-theme-matrix .exec-module-meta {
font-size: 0.58rem;
letter-spacing: 0.12em;
color: rgba(126, 232, 234, 0.45);
}
body.exec-theme-matrix .exec-module-body {
padding: 14px 16px 16px;
}
/* —— 指标格 —— */
body.exec-theme-matrix .exec-metric {
border-radius: 2px;
border: 1px solid rgba(0, 255, 234, 0.22);
background: rgba(0, 0, 0, 0.45);
box-shadow: inset 0 0 20px rgba(0, 255, 234, 0.03);
}
body.exec-theme-matrix .exec-metric-label {
font-size: 0.58rem;
letter-spacing: 0.14em;
color: rgba(126, 232, 234, 0.5);
}
body.exec-theme-matrix .exec-metric-value {
font-size: 0.84rem;
color: #dff;
}
body.exec-theme-matrix .exec-metric-value--mono {
font-size: 0.78rem;
color: rgba(191, 255, 255, 0.88);
}
/* —— 芯片 / 方向 / 盈亏色(保持语义,套霓虹) —— */
body.exec-theme-matrix .exec-chip--neutral {
border-color: rgba(0, 255, 234, 0.25);
color: rgba(126, 232, 234, 0.75);
background: rgba(0, 0, 0, 0.5);
}
body.exec-theme-matrix .exec-chip--ok {
color: #5fffd0;
border-color: rgba(0, 255, 200, 0.45);
background: rgba(0, 40, 32, 0.5);
text-shadow: 0 0 10px rgba(0, 255, 200, 0.35);
}
body.exec-theme-matrix .exec-chip--warn {
color: #ffe066;
border-color: rgba(255, 200, 80, 0.45);
background: rgba(40, 28, 0, 0.45);
}
body.exec-theme-matrix .exec-chip--live {
color: #ff7ab8;
border-color: rgba(255, 46, 166, 0.5);
background: rgba(36, 0, 24, 0.45);
text-shadow: 0 0 10px rgba(255, 46, 166, 0.35);
}
body.exec-theme-matrix .exec-dir--long {
color: #5fffd0;
border-color: rgba(0, 255, 200, 0.45);
background: rgba(0, 32, 28, 0.55);
text-shadow: 0 0 8px rgba(0, 255, 200, 0.3);
}
body.exec-theme-matrix .exec-dir--short {
color: #ff7ab8;
border-color: rgba(255, 46, 166, 0.45);
background: rgba(36, 0, 22, 0.55);
text-shadow: 0 0 8px rgba(255, 46, 166, 0.3);
}
body.exec-theme-matrix .exec-pnl--profit {
color: #5fffd0;
text-shadow: 0 0 12px rgba(0, 255, 200, 0.35);
}
body.exec-theme-matrix .exec-pnl--loss {
color: #ff6eb0;
text-shadow: 0 0 12px rgba(255, 46, 166, 0.35);
}
body.exec-theme-matrix .exec-pnl--flat {
color: rgba(126, 232, 234, 0.55);
}
/* —— 表格 —— */
body.exec-theme-matrix .exec-table-scroll {
border-radius: 2px;
border: 1px solid rgba(0, 255, 234, 0.28);
background: rgba(0, 0, 0, 0.55);
box-shadow: inset 0 0 32px rgba(0, 255, 234, 0.04);
}
body.exec-theme-matrix .exec-table th {
font-size: 0.58rem;
letter-spacing: 0.14em;
color: rgba(0, 255, 234, 0.55);
border-bottom: 1px solid rgba(0, 255, 234, 0.25);
background: rgba(0, 20, 22, 0.85);
}
body.exec-theme-matrix .exec-table td {
color: rgba(191, 255, 255, 0.78);
border-bottom: 1px solid rgba(0, 255, 234, 0.08);
}
body.exec-theme-matrix .exec-table tbody tr:hover {
background: rgba(0, 255, 234, 0.06);
}
body.exec-theme-matrix .exec-table .exec-mono {
color: #dff;
}
body.exec-theme-matrix .exec-muted {
color: rgba(126, 232, 234, 0.35);
}
body.exec-theme-matrix .exec-cell-status--ok {
color: #5fffd0;
text-shadow: 0 0 8px rgba(0, 255, 200, 0.3);
}
body.exec-theme-matrix .exec-cell-status--skip {
color: rgba(126, 232, 234, 0.45);
}
body.exec-theme-matrix .exec-cell-status--err {
color: #ff6eb0;
text-shadow: 0 0 8px rgba(255, 46, 166, 0.35);
}
/* —— 按钮 —— */
body.exec-theme-matrix .exec-btn {
border-radius: 2px;
border: 1px solid rgba(0, 255, 234, 0.45);
background: rgba(0, 24, 26, 0.85);
color: #bfffff;
box-shadow: 0 0 14px rgba(0, 255, 234, 0.12);
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.68rem;
}
body.exec-theme-matrix .exec-btn:hover {
border-color: rgba(255, 46, 166, 0.55);
color: #ffd0ec;
background: rgba(32, 0, 22, 0.75);
box-shadow: 0 0 20px rgba(255, 46, 166, 0.2);
transform: none;
}
body.exec-theme-matrix .exec-btn--primary {
border-color: rgba(255, 46, 166, 0.55);
background: linear-gradient(180deg, #ff5ec8 0%, #c21e6e 100%);
color: #fff;
text-shadow: 0 0 12px rgba(0, 0, 0, 0.5);
box-shadow: 0 0 24px rgba(255, 46, 166, 0.25);
}
body.exec-theme-matrix .exec-btn--primary:hover {
background: linear-gradient(180deg, #ff7ad4 0%, #d02878 100%);
border-color: rgba(255, 46, 166, 0.75);
}
body.exec-theme-matrix .exec-btn--sm {
border-radius: 2px;
font-size: 0.62rem;
padding: 4px 9px;
}
/* —— 说明文字 —— */
body.exec-theme-matrix .exec-prose {
color: rgba(126, 232, 234, 0.5);
}
body.exec-theme-matrix .exec-prose code {
border-color: rgba(0, 255, 234, 0.25);
background: rgba(0, 0, 0, 0.5);
color: rgba(191, 255, 255, 0.85);
}
body.exec-theme-matrix .exec-table-hint {
color: rgba(126, 232, 234, 0.45);
line-height: 1.55;
}
body.exec-theme-matrix .exec-table-hint code {
font-family: inherit;
color: #7ee8ea;
border: 1px solid rgba(0, 255, 234, 0.3);
padding: 1px 5px;
border-radius: 2px;
background: rgba(0, 0, 0, 0.4);
}
body.exec-theme-matrix .exec-sig-export-label {
color: rgba(126, 232, 234, 0.65);
}
body.exec-theme-matrix .exec-sig-export-label input {
accent-color: #ff2ea6;
}
/* —— 弹窗 —— */
body.exec-theme-matrix .exec-modal__backdrop {
background: rgba(0, 0, 0, 0.82);
}
body.exec-theme-matrix .exec-modal__card {
border-radius: 2px;
border: 1px solid rgba(0, 255, 234, 0.4);
background: linear-gradient(180deg, rgba(0, 22, 24, 0.98) 0%, rgba(0, 0, 0, 0.98) 100%);
box-shadow: 0 0 48px rgba(0, 255, 234, 0.15);
}
body.exec-theme-matrix .exec-modal__title {
color: #bfffff;
letter-spacing: 0.12em;
text-transform: uppercase;
font-size: 0.82rem;
}
/* —— 滚动条 —— */
body.exec-theme-matrix .exec-tab-panel {
scrollbar-color: rgba(0, 255, 234, 0.25) transparent;
}
body.exec-theme-matrix .exec-tab-panel::-webkit-scrollbar-thumb {
background: rgba(0, 255, 234, 0.2);
}
body.exec-theme-matrix .exec-shell ::selection {
background: rgba(255, 46, 166, 0.35);
color: #fff;
}
/* —— 登录页 —— */
body.exec-theme-matrix.exec-login-body .exec-mark {
display: grid !important;
width: 44px;
height: 44px;
place-items: center;
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.1em;
color: #000;
background: linear-gradient(145deg, #00ffe0 0%, #00b8a8 100%);
border: 1px solid rgba(0, 255, 234, 0.8);
border-radius: 2px;
box-shadow: 0 0 20px rgba(0, 255, 234, 0.35);
}
body.exec-theme-matrix.exec-login-body .exec-login-card {
border-radius: 2px;
border: 1px solid rgba(0, 255, 234, 0.4);
background: linear-gradient(165deg, rgba(0, 18, 20, 0.95) 0%, rgba(0, 0, 0, 0.97) 100%);
box-shadow: 0 0 40px rgba(0, 255, 234, 0.12);
}
body.exec-theme-matrix .exec-login-card::before {
height: 3px;
background: linear-gradient(90deg, transparent, #00ffe0, #ff2ea6, #00ffe0, transparent);
}
body.exec-theme-matrix .exec-login-kicker {
color: rgba(0, 255, 234, 0.55);
letter-spacing: 0.24em;
}
body.exec-theme-matrix .exec-login-title {
font-family: "Share Tech Mono", "JetBrains Mono", monospace;
color: #9ff;
text-shadow: 0 0 20px rgba(0, 255, 234, 0.45);
letter-spacing: 0.12em;
text-transform: uppercase;
}
body.exec-theme-matrix .exec-login-desc {
color: rgba(126, 232, 234, 0.55);
}
body.exec-theme-matrix .exec-label {
color: rgba(0, 255, 234, 0.45);
}
body.exec-theme-matrix .exec-input {
border-radius: 2px;
border: 1px solid rgba(0, 255, 234, 0.35);
background: #000;
color: #dff;
font-family: inherit;
}
body.exec-theme-matrix .exec-input:focus {
border-color: rgba(255, 46, 166, 0.55);
box-shadow: 0 0 0 2px rgba(255, 46, 166, 0.15);
}
body.exec-theme-matrix .exec-error {
color: #ff8ec4;
text-shadow: 0 0 10px rgba(255, 46, 166, 0.3);
}
body.exec-theme-matrix .exec-sig-persist {
border-radius: 2px;
border-color: rgba(0, 255, 234, 0.25);
background: rgba(0, 0, 0, 0.45);
color: rgba(191, 255, 255, 0.75);
}
body.exec-theme-matrix .exec-sig-persist--ok {
border-color: rgba(0, 255, 200, 0.45);
background: rgba(0, 32, 28, 0.55);
color: #5fffd0;
text-shadow: 0 0 10px rgba(0, 255, 200, 0.2);
}
body.exec-theme-matrix .exec-sig-persist--warn {
border-color: rgba(255, 46, 166, 0.5);
background: rgba(40, 0, 28, 0.5);
color: #ffb8e0;
}
@media (prefers-reduced-motion: reduce) {
body.exec-theme-matrix .exec-radar__sweep,
body.exec-theme-matrix .exec-tx-dot {
animation: none !important;
}
}
@media (max-width: 720px) {
body.exec-theme-matrix .exec-terminal-head {
flex-direction: column;
align-items: stretch;
}
body.exec-theme-matrix .exec-terminal-actions {
justify-content: flex-end;
}
body.exec-theme-matrix .exec-title-terminal {
font-size: clamp(1.05rem, 6vw, 1.65rem);
}
}
@@ -0,0 +1,463 @@
<!DOCTYPE html>
<!-- 勿用「文件」方式双击打开本模板:请启动服务后访问 http://127.0.0.1:8090/dashboard(端口见 config.yaml -->
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<meta name="color-scheme" content="dark" />
<title>GATE // EXECUTOR</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Share+Tech+Mono&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/static/style.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/theme-matrix-terminal.css?v={{ asset_version }}" />
</head>
<body class="exec-shell exec-dashboard exec-theme-matrix">
<div
id="exec-local-file-overlay"
style="display:none;position:fixed;inset:0;z-index:2147483647;background:#050508;color:#e4e4e7;font-family:system-ui,sans-serif;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:32px;gap:14px;"
>
<p style="margin:0;font-size:1.05rem;font-weight:600;">检测到正在用本地文件(file://)打开</p>
<p style="margin:0;font-size:0.9rem;opacity:0.88;max-width:460px;line-height:1.65;">
此方式不会经过 FastAPI,模板变量不会渲染,<code style="background:#1a1a22;padding:2px 8px;border-radius:6px;">/static/style.css</code>
也无法加载,所以页面是白底「裸 HTML」。
</p>
<p style="margin:0;font-size:0.9rem;line-height:1.65;">
请在项目根目录执行 <code style="background:#1a1a22;padding:2px 8px;border-radius:6px;">python run.py</code>(或 PM2),然后访问:<br />
<strong style="color:#fafafa;font-size:1rem;">http://127.0.0.1:8090/dashboard</strong><br />
<span style="opacity:0.75;font-size:0.82rem;">端口以 config.yaml 里 app.port 为准</span>
</p>
</div>
<script>
(function () {
if (location.protocol === "file:") {
var o = document.getElementById("exec-local-file-overlay");
if (o) o.style.display = "flex";
}
})();
</script>
<div class="exec-ambient" aria-hidden="true"></div>
<div class="exec-noise" aria-hidden="true"></div>
<div class="exec-grid-faint" aria-hidden="true"></div>
<header class="exec-header exec-header--terminal">
<div class="exec-terminal-bar">
<span class="exec-tx-chip exec-tx-chip--live"><span class="exec-tx-dot" aria-hidden="true"></span>LINK ONLINE</span>
<span class="exec-tx-chip">CYCLE <strong>OK</strong></span>
<span class="exec-tx-chip">CHANNEL <strong id="execTxChannel"></strong></span>
<span class="exec-tx-chip exec-tx-chip--time">SYNC <span class="exec-tx-mono" id="execClock">--:--:--</span></span>
</div>
<div class="exec-terminal-head">
<div class="exec-terminal-logo" aria-hidden="true">GE</div>
<div class="exec-terminal-center">
<h1 class="exec-title-terminal">
<span class="exec-tt-part">GATE</span>
<span class="exec-title-slash">//</span>
<span class="exec-radar" aria-hidden="true">
<span class="exec-radar__ring"></span>
<span class="exec-radar__sweep"></span>
<span class="exec-radar__blip"></span>
</span>
<span class="exec-title-slash">//</span>
<span class="exec-tt-part">EXECUTOR</span>
</h1>
<p class="exec-tagline-terminal">Gate USDT PERP · SCHEME-A · MATRIX SCAN ISOLATED</p>
</div>
<div class="exec-terminal-actions">
<span class="exec-tx-chip">OP <strong id="execUser">{{ username }}</strong></span>
<a class="exec-btn exec-btn--sm" href="/logout">LOGOUT</a>
</div>
</div>
</header>
<main class="exec-main exec-main--tabbed">
<nav class="exec-tabs" id="execTabs" role="tablist" aria-label="面板分区">
<button type="button" class="exec-tab exec-tab--active" role="tab" id="tab-overview" data-exec-tab="overview" aria-selected="true">概览</button>
<button type="button" class="exec-tab" role="tab" id="tab-positions" data-exec-tab="positions" aria-selected="false">持仓与计划</button>
<button type="button" class="exec-tab" role="tab" id="tab-history" data-exec-tab="history" aria-selected="false">成交与委托</button>
<button type="button" class="exec-tab" role="tab" id="tab-stats" data-exec-tab="stats" aria-selected="false">统计</button>
<button type="button" class="exec-tab" role="tab" id="tab-signals" data-exec-tab="signals" aria-selected="false">信号流</button>
</nav>
<div class="exec-tab-panels">
<div class="exec-tab-panel exec-tab-panel--active" data-exec-panel="overview" role="tabpanel" aria-labelledby="tab-overview">
<p class="exec-section-label">Overview</p>
<div class="exec-module-row exec-module-row--3">
<!-- 模块:运行态 -->
<section class="exec-module" aria-labelledby="mod-runtime-title">
<div class="exec-module-head">
<h2 class="exec-module-title" id="mod-runtime-title">运行态</h2>
<span class="exec-module-meta">Runtime</span>
</div>
<div class="exec-module-body">
<div class="exec-metric-grid">
<div class="exec-metric">
<span class="exec-metric-label">模式</span>
<div class="exec-metric-value" id="stDryRunWrap"></div>
</div>
<div class="exec-metric">
<span class="exec-metric-label">Gate 密钥</span>
<div class="exec-metric-value" id="stKeysWrap"></div>
</div>
<div class="exec-metric">
<span class="exec-metric-label">占位仓位</span>
<span class="exec-metric-value exec-metric-value--mono" id="stSlots"></span>
</div>
<div class="exec-metric">
<span class="exec-metric-label">单笔风险 / 方案</span>
<span class="exec-metric-value exec-metric-value--mono"><span id="stRisk"></span> · <span id="stScheme"></span></span>
</div>
</div>
</div>
</section>
<!-- 模块:网络 -->
<section class="exec-module" aria-labelledby="mod-net-title">
<div class="exec-module-head">
<h2 class="exec-module-title" id="mod-net-title">网络与代理</h2>
<span class="exec-module-meta">Egress</span>
</div>
<div class="exec-module-body">
<div class="exec-metric-grid">
<div class="exec-metric">
<span class="exec-metric-label">配置启用</span>
<div class="exec-metric-value" id="pxEnabledWrap"></div>
</div>
<div class="exec-metric">
<span class="exec-metric-label">实际走代理</span>
<div class="exec-metric-value" id="pxEffectiveWrap"></div>
</div>
</div>
<div class="exec-metric" style="margin-top: 12px">
<span class="exec-metric-label">代理地址</span>
<span class="exec-metric-value exec-metric-value--mono" id="pxUrl"></span>
</div>
<p class="exec-prose"><code>onchain_scout_gate</code><code>proxy</code> 配置一致;访问 Gate 私有 API 时使用 <code>httpx_client_kwargs</code><code>app/proxy_util.py</code>)。</p>
</div>
</section>
<!-- 模块:风险摘要 -->
<section class="exec-module" aria-labelledby="mod-risk-title">
<div class="exec-module-head">
<h2 class="exec-module-title" id="mod-risk-title">风险参数</h2>
<span class="exec-module-meta">Risk</span>
</div>
<div class="exec-module-body">
<div class="exec-metric">
<span class="exec-metric-label">以损订仓(目标)</span>
<span class="exec-metric-value exec-metric-value--mono" id="stRiskLarge"></span>
</div>
<div class="exec-metric" style="margin-top: 12px">
<span class="exec-metric-label">最大同时标的</span>
<span class="exec-metric-value exec-metric-value--mono" id="stMaxPos"></span>
</div>
<p class="exec-prose"><code>gate.dry_run: false</code> 且配置 API 密钥时,按上述参数与止损距离计算张数并下市价单 + 计划止盈/止损;<code>dry_run: true</code> 时仅校验占位与日志。</p>
</div>
</section>
</div>
<p class="exec-section-label">Account</p>
<section class="exec-module exec-module--wide" aria-labelledby="mod-acct-title">
<div class="exec-module-head">
<h2 class="exec-module-title" id="mod-acct-title">合约账户</h2>
<span class="exec-module-meta">与 Overview 同步刷新(联调请用 curl,见部署/使用说明)</span>
</div>
<div class="exec-module-body">
<div class="exec-metric-grid">
<div class="exec-metric">
<span class="exec-metric-label">权益 total</span>
<span class="exec-metric-value exec-metric-value--mono" id="faTotal"></span>
</div>
<div class="exec-metric">
<span class="exec-metric-label">可用 available</span>
<span class="exec-metric-value exec-metric-value--mono" id="faAvail"></span>
</div>
<div class="exec-metric">
<span class="exec-metric-label">未实现盈亏</span>
<span class="exec-metric-value exec-metric-value--mono" id="faUpnl"></span>
</div>
<div class="exec-metric">
<span class="exec-metric-label">币种</span>
<span class="exec-metric-value" id="faCur"></span>
</div>
</div>
<p class="exec-prose" id="faErr" style="display:none;color:#fca5a5;margin-top:12px"></p>
</div>
</section>
</div>
<div class="exec-tab-panel" data-exec-panel="positions" role="tabpanel" aria-labelledby="tab-positions">
<p class="exec-section-label">Positions</p>
<section class="exec-module exec-module--wide" aria-labelledby="mod-pos-title">
<div class="exec-module-head">
<h2 class="exec-module-title" id="mod-pos-title">当前持仓(Gate</h2>
<span class="exec-module-meta">与计划委托同一轮询 · 便于对账</span>
</div>
<div class="exec-module-body">
<div class="exec-sig-rr-toolbar" style="display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin-bottom:14px">
<label class="exec-sig-export-label"><input type="checkbox" id="beGlobalEnable" checked /> 全局移动保本(达 1R 拉至保本+0.2%)</label>
<button type="button" class="exec-btn exec-btn--sm" id="beGlobalSave">保存全局</button>
<span class="exec-muted" id="beGlobalMsg" role="status" aria-live="polite"></span>
</div>
<p class="exec-prose" id="posErr" style="display:none;color:#fca5a5;margin-bottom:12px"></p>
<p class="exec-prose" id="posPlanHint" style="display:none;margin:0 0 12px;font-size:0.88rem;color:#fbbf24;line-height:1.55"></p>
<div class="exec-table-scroll">
<table class="exec-table" id="posTable">
<thead>
<tr>
<th>合约</th>
<th>张数</th>
<th>开仓价</th>
<th>标记价</th>
<th>未实现盈亏</th>
<th>杠杆</th>
<th>open 计划单</th>
<th>移动保本</th>
<th>保本状态</th>
<th>操作</th>
</tr>
</thead>
<tbody id="posBody">
<tr><td colspan="10" class="exec-muted">加载中…</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<div id="posPlanModal" class="exec-modal" aria-hidden="true">
<div class="exec-modal__backdrop" id="posPlanModalBackdrop"></div>
<div class="exec-modal__card" role="dialog" aria-labelledby="posPlanModalTitle">
<h3 class="exec-modal__title" id="posPlanModalTitle">增加计划委托(市价全平)</h3>
<p class="exec-prose" style="margin:0 0 12px;font-size:0.85rem;opacity:0.88">合约 <span class="exec-mono" id="posPlanModalContract"></span> · 触发后市价 IOC、reduce_only 全平(与信号止盈止损同一套 <code>price_orders</code>)。</p>
<div class="exec-field" style="margin-bottom:12px">
<label class="exec-label" for="posPlanTrigger">触发价</label>
<input class="exec-input" id="posPlanTrigger" type="text" inputmode="decimal" placeholder="如 95000.5" autocomplete="off" />
</div>
<div class="exec-field" style="margin-bottom:16px">
<label class="exec-label" for="posPlanRule">触发规则</label>
<select class="exec-input" id="posPlanRule" style="cursor:pointer;width:100%">
<option value="1">最新价 &gt;= 触发价(rule=1</option>
<option value="2">最新价 &lt;= 触发价(rule=2</option>
</select>
</div>
<p class="exec-prose" id="posPlanModalErr" style="display:none;color:#fca5a5;margin-bottom:12px"></p>
<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap">
<button type="button" class="exec-btn" id="posPlanModalCancel">取消</button>
<button type="button" class="exec-btn exec-btn--primary" id="posPlanModalSubmit">提交</button>
</div>
</div>
</div>
<p class="exec-section-label">Plan orders</p>
<section class="exec-module exec-module--wide" aria-labelledby="mod-plan-title">
<div class="exec-module-head">
<h2 class="exec-module-title" id="mod-plan-title">计划委托(price_orders</h2>
<span class="exec-module-meta">与概览同步轮询 · 可手动撤单</span>
</div>
<div class="exec-module-body">
<p class="exec-prose" id="planHint" style="margin:0 0 12px;font-size:0.88rem;opacity:0.9">
与上方<strong>持仓表按合约对账</strong>:有仓但「open 计划单」为 0 时多为止盈止损已触发/已撤或 OCO 清理延迟;无仓但有计划单时可能为挂单待触发或他端下的条件单。
</p>
<p class="exec-prose" id="planErr" style="display:none;color:#fca5a5;margin-bottom:12px"></p>
<div class="exec-table-scroll">
<table class="exec-table" id="planTable">
<thead>
<tr>
<th>合约</th>
<th>状态</th>
<th>触发价</th>
<th>rule</th>
<th>张数</th>
<th>类型</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="planBody">
<tr><td colspan="8" class="exec-muted">加载中…</td></tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
<div class="exec-tab-panel" data-exec-panel="history" role="tabpanel" aria-labelledby="tab-history">
<p class="exec-section-label">Gate history</p>
<section class="exec-module exec-module--wide" aria-labelledby="mod-gh-title">
<div class="exec-module-head">
<h2 class="exec-module-title" id="mod-gh-title">成交与委托(Gate 官方接口)</h2>
<span class="exec-module-meta">查询 / 下载 CSV · 与信号流无关</span>
</div>
<div class="exec-module-body">
<p class="exec-prose" style="margin:0 0 12px;font-size:0.88rem;opacity:0.9">
数据以 <strong>Gate 合约私有 API</strong> 为准:<code>my_trades_timerange</code>(成交)、<code>orders</code>(委托)。
未填「from / to」时按服务端默认最近 7 天(Unix 秒)。导出最多 5000 行(分页拼接)。
</p>
<div class="exec-test-bar" style="margin-bottom:14px;display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end">
<div class="exec-field" style="margin:0;min-width:140px">
<label class="exec-label" for="ghContract">合约(可选)</label>
<input class="exec-input" id="ghContract" type="text" placeholder="留空=全部" autocomplete="off" />
</div>
<div class="exec-field" style="margin:0;min-width:120px">
<label class="exec-label" for="ghFrom">fromUnix 秒)</label>
<input class="exec-input" id="ghFrom" type="text" inputmode="numeric" placeholder="默认 7 天前" autocomplete="off" />
</div>
<div class="exec-field" style="margin:0;min-width:120px">
<label class="exec-label" for="ghTo">toUnix 秒)</label>
<input class="exec-input" id="ghTo" type="text" inputmode="numeric" placeholder="默认此刻" autocomplete="off" />
</div>
<button type="button" class="exec-btn" id="btnGhTrades">查询成交</button>
<button type="button" class="exec-btn" id="btnGhTradesCsv">下载成交 CSV</button>
<div class="exec-field" style="margin:0;min-width:110px">
<label class="exec-label" for="ghOrdStatus">委托 status</label>
<select class="exec-input" id="ghOrdStatus" style="cursor:pointer">
<option value="finished">finished</option>
<option value="open">open</option>
</select>
</div>
<button type="button" class="exec-btn" id="btnGhOrders">查询委托</button>
<button type="button" class="exec-btn" id="btnGhOrdersCsv">下载委托 CSV</button>
</div>
<p class="exec-prose" id="ghErr" style="display:none;color:#fca5a5;margin-bottom:12px"></p>
<p class="exec-section-label" style="margin-top:4px">成交(最近一页,limit=80</p>
<div class="exec-table-scroll">
<table class="exec-table" id="ghTradesTable">
<thead>
<tr>
<th>时间</th>
<th>合约</th>
<th>张数</th>
<th>价格</th>
<th>手续费</th>
<th>角色</th>
<th>trade_id</th>
</tr>
</thead>
<tbody id="ghTradesBody">
<tr><td colspan="7" class="exec-muted">点击「查询成交」</td></tr>
</tbody>
</table>
</div>
<p class="exec-section-label" style="margin-top:18px">委托(最近一页,limit=80</p>
<div class="exec-table-scroll">
<table class="exec-table" id="ghOrdersTable">
<thead>
<tr>
<th>创建</th>
<th>合约</th>
<th>状态</th>
<th>张数</th>
<th>价 / 成交价</th>
<th>id</th>
</tr>
</thead>
<tbody id="ghOrdersBody">
<tr><td colspan="6" class="exec-muted">点击「查询委托」</td></tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
<div class="exec-tab-panel" data-exec-panel="stats" role="tabpanel" aria-labelledby="tab-stats">
<p class="exec-section-label">Formal stats</p>
<section class="exec-module exec-module--wide" aria-labelledby="mod-st-title">
<div class="exec-module-head">
<h2 class="exec-module-title" id="mod-st-title">正式统计</h2>
<span class="exec-module-meta">GET /api/stats/summary · 手动刷新</span>
</div>
<div class="exec-module-body">
<p class="exec-prose" style="margin:0 0 12px;font-size:0.88rem;opacity:0.9">
口径:<strong>上海时区</strong>、统计日 <strong>[D 08:00, D+1 08:00)</strong>、自然周 <strong>周一至周日</strong>、自然月 <strong>[1日08:00, 次月1日08:00)</strong>;数据来自 Gate <code>GET /futures/&lt;settle&gt;/position_close</code>(与 App「历史仓位」同类),仅统计 <code>stats.official_start</code> 之后<strong>平仓时间</strong>且能解析 <code>pnl</code> 的记录。详见 <code>docs/使用说明.md</code> §3.6。
</p>
<div class="exec-test-bar" style="margin-bottom:14px;display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end">
<div class="exec-field" style="margin:0;min-width:140px">
<label class="exec-label" for="stContract">合约(可选)</label>
<input class="exec-input" id="stContract" type="text" placeholder="留空=全部" autocomplete="off" />
</div>
<button type="button" class="exec-btn exec-btn--primary" id="btnStatsRefresh">刷新统计</button>
</div>
<p class="exec-prose" id="statsErr" style="display:none;color:#fca5a5;margin-bottom:12px"></p>
<p class="exec-prose" id="statsWarn" style="display:none;color:#fbbf24;margin-bottom:12px;font-size:0.88rem"></p>
<p class="exec-prose exec-muted" id="statsIdle" style="margin:0 0 16px;font-size:0.88rem">尚未加载;请点击「刷新统计」(会请求 Gate 分页拉成交,请勿频繁点击)。</p>
<div class="exec-module-row exec-module-row--3" id="statsCards" style="display:none">
<section class="exec-module" aria-labelledby="st-day-title">
<div class="exec-module-head">
<h3 class="exec-module-title" id="st-day-title" style="font-size:1rem">本统计日</h3>
<span class="exec-module-meta" id="stDayMeta"></span>
</div>
<div class="exec-module-body" id="stDayBody"></div>
</section>
<section class="exec-module" aria-labelledby="st-week-title">
<div class="exec-module-head">
<h3 class="exec-module-title" id="st-week-title" style="font-size:1rem">本周</h3>
<span class="exec-module-meta" id="stWeekMeta"></span>
</div>
<div class="exec-module-body" id="stWeekBody"></div>
</section>
<section class="exec-module" aria-labelledby="st-month-title">
<div class="exec-module-head">
<h3 class="exec-module-title" id="st-month-title" style="font-size:1rem">本月</h3>
<span class="exec-module-meta" id="stMonthMeta"></span>
</div>
<div class="exec-module-body" id="stMonthBody"></div>
</section>
</div>
</div>
</section>
</div>
<div class="exec-tab-panel" data-exec-panel="signals" role="tabpanel" aria-labelledby="tab-signals">
<p class="exec-section-label">Signal stream</p>
<section class="exec-module exec-module--wide" aria-labelledby="mod-sig-title">
<div class="exec-module-head">
<h2 class="exec-module-title" id="mod-sig-title">信号流</h2>
<span class="exec-module-meta">POST /v1/signal</span>
</div>
<div class="exec-module-body">
<div class="exec-table-toolbar">
<p class="exec-table-hint">每次 <code>POST /v1/signal</code> 的处理结果<strong>写入本地 SQLite</strong>,本页「信号流」从该库读取最近记录,<strong>进程重启后仍在</strong>(与当前是否持仓无关)。止盈/止损展示价为按合约 tick 对齐;现价优先 <code>reference_price</code>,否则取推送时行情 last。</p>
<div class="exec-sig-export" aria-label="信号流导出">
<label class="exec-sig-export-label"><input type="checkbox" id="sigExportEnable" /> 允许下载 CSV</label>
<button type="button" class="exec-btn exec-btn--sm" id="sigExportBtn" disabled>下载</button>
</div>
<div class="exec-sig-rr-toolbar" style="display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin-top:14px">
<label class="exec-sig-export-label" for="sigMinRrInput">最低盈亏比(无法计算或低于此值则拒单)</label>
<input type="number" class="exec-input" id="sigMinRrInput" min="0.1" max="50" step="0.05" style="width:6.5rem" autocomplete="off" />
<button type="button" class="exec-btn exec-btn--sm" id="sigMinRrSave">保存到服务端</button>
<span class="exec-muted" id="sigMinRrMsg" role="status" aria-live="polite"></span>
</div>
</div>
<p id="sigPersistBanner" class="exec-sig-persist" role="status" aria-live="polite"></p>
<div class="exec-table-scroll">
<table class="exec-table" id="sigTable">
<thead>
<tr>
<th>时间</th>
<th>合约</th>
<th>方向</th>
<th>现价</th>
<th>止盈</th>
<th>止损</th>
<th>盈亏比</th>
<th>结果</th>
</tr>
</thead>
<tbody id="sigBody">
<tr><td colspan="8" class="exec-muted">加载中…</td></tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
</div>
</main>
<script src="/static/exec.js?v={{ asset_version }}"></script>
</body>
</html>
+104
View File
@@ -0,0 +1,104 @@
<!DOCTYPE html>
<!-- 勿双击本地打开:请启动服务后访问 http://127.0.0.1:8090/login -->
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<title>GATE // EXECUTOR · 接入</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Share+Tech+Mono&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/static/style.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/theme-matrix-terminal.css?v={{ asset_version }}" />
</head>
<body class="exec-shell exec-login-body exec-theme-matrix">
<div
id="exec-local-file-overlay"
style="display:none;position:fixed;inset:0;z-index:2147483647;background:#050508;color:#e4e4e7;font-family:system-ui,sans-serif;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:32px;gap:14px;"
>
<p style="margin:0;font-size:1.05rem;font-weight:600;">请通过运行中的服务打开登录页</p>
<p style="margin:0;font-size:0.9rem;opacity:0.88;max-width:440px;line-height:1.65;">
本地 file:// 打开无法加载 <code style="background:#1a1a22;padding:2px 8px;border-radius:6px;">/static/style.css</code>,因此不是设计稿效果。
</p>
<p style="margin:0;font-size:0.9rem;">
启动后访问:<strong style="color:#fafafa;">http://127.0.0.1:8090/login</strong>
</p>
</div>
<script>
(function () {
if (location.protocol === "file:") {
var o = document.getElementById("exec-local-file-overlay");
if (o) o.style.display = "flex";
}
})();
</script>
<div class="exec-ambient" aria-hidden="true"></div>
<div class="exec-noise" aria-hidden="true"></div>
<div class="exec-grid-faint" aria-hidden="true"></div>
<div class="exec-login-stage">
<div class="exec-login-card">
<div class="exec-login-inner">
<div class="exec-header__brand" style="margin-bottom: 24px">
<div class="exec-mark" aria-hidden="true">GE</div>
<div class="exec-header__text">
<p class="exec-login-kicker">GATE // EXECUTOR</p>
<h1 class="exec-login-title">安全接入</h1>
</div>
</div>
<p class="exec-login-desc">独立执行进程 · 与 MATRIX 扫描解耦 · 未授权禁止访问控制台</p>
<form id="execLoginForm" class="exec-login-form" action="#" method="post">
<div class="exec-field">
<label class="exec-label" for="f-user">操作员</label>
<input class="exec-input" id="f-user" type="text" name="username" required autocomplete="username" />
</div>
<div class="exec-field">
<label class="exec-label" for="f-pass">密码</label>
<input class="exec-input" id="f-pass" type="password" name="password" required autocomplete="current-password" />
</div>
<button type="submit" class="exec-btn exec-btn--primary">进入控制台</button>
</form>
<div class="exec-error" id="execLoginError"></div>
</div>
</div>
</div>
<script>
(function () {
var form = document.getElementById("execLoginForm");
var errEl = document.getElementById("execLoginError");
form.addEventListener("submit", function (e) {
e.preventDefault();
errEl.textContent = "";
var fd = new FormData(form);
var username = fd.get("username");
var password = fd.get("password");
fetch("/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ username: username, password: password }),
})
.then(function (r) {
return r.json().then(function (j) {
return { ok: r.ok, status: r.status, body: j };
});
})
.then(function (x) {
if (x.ok && x.body && x.body.redirect) {
window.location.href = x.body.redirect;
return;
}
errEl.textContent = (x.body && x.body.detail) || "登录失败";
})
.catch(function () {
errEl.textContent = "网络错误";
});
});
})();
</script>
</body>
</html>
@@ -0,0 +1,57 @@
"""移动保本逻辑单元测试。"""
from __future__ import annotations
import unittest
from app.breakeven_logic import (
breakeven_sl_price,
find_sl_plan,
is_1r_reached,
risk_distance,
sl_already_at_or_better,
)
class TestBreakevenLogic(unittest.TestCase):
def test_risk_distance_long(self) -> None:
self.assertAlmostEqual(risk_distance("long", 100.0, 95.0), 5.0)
def test_is_1r_long(self) -> None:
self.assertFalse(is_1r_reached("long", 104.0, 100.0, 95.0, trigger_r=1.0))
self.assertTrue(is_1r_reached("long", 105.0, 100.0, 95.0, trigger_r=1.0))
def test_is_1r_short(self) -> None:
self.assertFalse(is_1r_reached("short", 96.0, 100.0, 105.0, trigger_r=1.0))
self.assertTrue(is_1r_reached("short", 95.0, 100.0, 105.0, trigger_r=1.0))
def test_breakeven_sl_price(self) -> None:
self.assertAlmostEqual(breakeven_sl_price("long", 1000.0, 0.002), 1002.0)
self.assertAlmostEqual(breakeven_sl_price("short", 1000.0, 0.002), 998.0)
def test_sl_already_at_or_better(self) -> None:
self.assertTrue(sl_already_at_or_better("long", 1003.0, 1002.0))
self.assertFalse(sl_already_at_or_better("long", 1001.0, 1002.0))
self.assertTrue(sl_already_at_or_better("short", 997.0, 998.0))
def test_find_sl_plan_long(self) -> None:
plans = [
{
"contract": "BTC_USDT",
"order_id": "tp1",
"rule": 1,
"trigger_price": "110000",
},
{
"contract": "BTC_USDT",
"order_id": "sl1",
"rule": 2,
"trigger_price": "95000",
},
]
oid, px = find_sl_plan("long", "BTC_USDT", plans)
self.assertEqual(oid, "sl1")
self.assertAlmostEqual(px or 0, 95000.0)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,58 @@
"""离线测试:TP/SL 触发价按 Gate 合约 tick 对齐(无需 API 密钥)。"""
from __future__ import annotations
import sys
from decimal import Decimal
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.gate_price_rounding import ( # noqa: E402
_format_trigger_price,
_trigger_price_tick,
)
def test_format_xaut_like_float_garbage() -> None:
assert _format_trigger_price(4752.700000000001, Decimal("0.1")) == "4752.7"
assert _format_trigger_price(4691.7976, Decimal("0.1")) == "4691.8"
def test_format_half_tick() -> None:
assert _format_trigger_price(100.25, Decimal("0.5")) == "100.5"
# 100.24 落在 0.5 网格上为 100.0,去尾零后为 "100"
assert _format_trigger_price(100.24, Decimal("0.5")) == "100"
def test_format_small_coin() -> None:
tick = Decimal("0.00001")
assert _format_trigger_price(0.000123456, tick) == "0.00012"
assert _format_trigger_price(1.23456789e-5, tick) == "0.00001"
def test_trigger_price_tick_from_cdata() -> None:
assert _trigger_price_tick({"order_price_round": "0.1"}) == Decimal("0.1")
assert _trigger_price_tick({"order_price_round": "", "mark_price_round": "0.0001"}) == Decimal("0.0001")
assert _trigger_price_tick({"mark_price_round": "0.01"}) == Decimal("0.01")
assert _trigger_price_tick({}) is None
def test_fallback_no_tick_coarse() -> None:
s = _format_trigger_price(4752.700000000001, None)
assert "00000000000" not in s
assert s.replace(".", "").isdigit() or s.replace(".", "", 1).replace("-", "", 1).isdigit()
def main() -> None:
test_format_xaut_like_float_garbage()
test_format_half_tick()
test_format_small_coin()
test_trigger_price_tick_from_cdata()
test_fallback_no_tick_coarse()
print("test_price_rounding: all passed")
if __name__ == "__main__":
main()