首次上传
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
config.yaml
|
||||
runtime/
|
||||
.env
|
||||
*.log
|
||||
@@ -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。
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
# 清除已无持仓的 active(remove_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,
|
||||
}
|
||||
@@ -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):
|
||||
"""
|
||||
出站 HTTP(httpx)代理,与 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)
|
||||
@@ -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
|
||||
@@ -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 字段 dict;price 已为合约 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)}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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收盘价")
|
||||
@@ -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_USDT;micro_market 时必填")
|
||||
side: Literal["long", "short"] = "long"
|
||||
size: int = Field(1, ge=1, le=30, description="张数绝对值,服务端再与 test_max_contracts 取小")
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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_ratio(config 默认)。"""
|
||||
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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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(peak−equity)。",
|
||||
"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,
|
||||
),
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
@@ -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`
|
||||
@@ -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_executor(Gate 下单执行器)
|
||||
*
|
||||
* 在项目根目录执行:
|
||||
* ./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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 删除)"
|
||||
@@ -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
|
||||
@@ -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`**,拼 CSV(UTF-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**。
|
||||
|
||||
**Python(httpx)示例(`/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`(1–500)、`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` / 无仓 / 无密钥 时 400;Gate 错误 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`、本机防火墙、以及是否与扫描端口冲突。
|
||||
@@ -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`** 再测。
|
||||
@@ -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
|
||||
@@ -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
@@ -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">最新价 >= 触发价(rule=1)</option>
|
||||
<option value="2">最新价 <= 触发价(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">from(Unix 秒)</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">to(Unix 秒)</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/<settle>/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>
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user