首次上传

This commit is contained in:
dekun
2026-05-16 22:25:48 +08:00
commit 2b8f902548
88 changed files with 16386 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# Gate order executor (separate from onchain_scout_gate scanner).
@@ -0,0 +1,147 @@
"""移动保本运行态:登记 entry/initial_sl/sl_order_id,平仓后清除。"""
from __future__ import annotations
import json
import logging
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
_ROOT = Path(__file__).resolve().parent.parent
_ACTIVE_PATH = _ROOT / "runtime" / "breakeven_active.json"
_lock = threading.Lock()
def _now_iso() -> str:
return datetime.now(timezone.utc).astimezone().isoformat()
def _read_all_unlocked() -> dict[str, Any]:
if not _ACTIVE_PATH.is_file():
return {}
try:
raw = _ACTIVE_PATH.read_text(encoding="utf-8").strip()
if not raw:
return {}
data = json.loads(raw)
if not isinstance(data, dict):
return {}
return {str(k).strip().upper(): v for k, v in data.items() if isinstance(v, dict)}
except (OSError, json.JSONDecodeError) as exc:
logger.warning("breakeven_active_read_failed: %s", exc)
return {}
def _write_all_unlocked(rows: dict[str, Any]) -> None:
_ACTIVE_PATH.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps(rows, indent=2, ensure_ascii=False) + "\n"
tmp = _ACTIVE_PATH.with_suffix(".json.tmp")
tmp.write_text(payload, encoding="utf-8")
tmp.replace(_ACTIVE_PATH)
def read_all_active() -> dict[str, dict[str, Any]]:
with _lock:
return dict(_read_all_unlocked())
def get_active(contract: str) -> dict[str, Any] | None:
ct = contract.strip().upper()
with _lock:
row = _read_all_unlocked().get(ct)
return dict(row) if isinstance(row, dict) else None
def upsert_active(
contract: str,
*,
side: str,
entry: float,
initial_sl: float,
sl_order_id: str,
moved: bool = False,
status: str = "waiting_1r",
) -> dict[str, Any]:
ct = contract.strip().upper()
row: dict[str, Any] = {
"side": str(side).lower(),
"entry": float(entry),
"initial_sl": float(initial_sl),
"sl_order_id": str(sl_order_id).strip(),
"moved": bool(moved),
"status": status,
"registered_at": _now_iso(),
}
with _lock:
all_rows = _read_all_unlocked()
prev = all_rows.get(ct)
if isinstance(prev, dict) and prev.get("registered_at"):
row["registered_at"] = prev["registered_at"]
if isinstance(prev, dict) and prev.get("moved"):
row["moved"] = bool(prev["moved"])
row["status"] = prev.get("status") or row["status"]
all_rows[ct] = row
_write_all_unlocked(all_rows)
return row
def mark_unregistrable(contract: str) -> None:
ct = contract.strip().upper()
with _lock:
all_rows = _read_all_unlocked()
all_rows[ct] = {
"status": "cannot_register",
"moved": False,
"registered_at": _now_iso(),
}
_write_all_unlocked(all_rows)
def mark_moved(contract: str, *, new_sl_order_id: str, breakeven_sl: str) -> None:
ct = contract.strip().upper()
with _lock:
all_rows = _read_all_unlocked()
row = all_rows.get(ct)
if not isinstance(row, dict):
return
row["moved"] = True
row["status"] = "moved"
row["sl_order_id"] = str(new_sl_order_id).strip()
row["breakeven_sl"] = str(breakeven_sl)
row["moved_at"] = _now_iso()
all_rows[ct] = row
_write_all_unlocked(all_rows)
def update_sl_order_id(contract: str, sl_order_id: str) -> None:
ct = contract.strip().upper()
with _lock:
all_rows = _read_all_unlocked()
row = all_rows.get(ct)
if not isinstance(row, dict):
return
row["sl_order_id"] = str(sl_order_id).strip()
all_rows[ct] = row
_write_all_unlocked(all_rows)
def remove_active(contract: str) -> None:
ct = contract.strip().upper()
with _lock:
all_rows = _read_all_unlocked()
if ct in all_rows:
del all_rows[ct]
_write_all_unlocked(all_rows)
def remove_all_except(contracts: set[str]) -> None:
keep = {c.strip().upper() for c in contracts if c}
with _lock:
all_rows = _read_all_unlocked()
filtered = {k: v for k, v in all_rows.items() if k in keep}
if filtered != all_rows:
_write_all_unlocked(filtered)
+287
View File
@@ -0,0 +1,287 @@
"""移动保本:1R 判断、保本价、从计划单/信号登记。"""
from __future__ import annotations
import logging
from typing import Any
from .breakeven_active_store import get_active, mark_unregistrable, upsert_active
from .breakeven_prefs_store import read_effective_enabled
from .config import Settings
from .gate_futures_live import GateFuturesLive, _float, post_stop_loss_price_order
from .gate_price_rounding import _format_trigger_price, _trigger_price_tick
from .models_signal import TradeSignal
logger = logging.getLogger(__name__)
def sl_trigger_rule_for_side(side: str) -> int:
return 2 if side == "long" else 1
def risk_distance(side: str, entry: float, initial_sl: float) -> float | None:
if entry <= 0 or initial_sl <= 0:
return None
if side == "long":
dist = entry - initial_sl
elif side == "short":
dist = initial_sl - entry
else:
return None
return dist if dist > 0 else None
def is_1r_reached(
side: str,
mark: float,
entry: float,
initial_sl: float,
*,
trigger_r: float,
) -> bool:
dist = risk_distance(side, entry, initial_sl)
if dist is None or mark <= 0:
return False
target = trigger_r * dist
if side == "long":
return mark >= entry + target
if side == "short":
return mark <= entry - target
return False
def breakeven_sl_price(side: str, entry: float, buffer_pct: float) -> float | None:
if entry <= 0 or buffer_pct < 0:
return None
if side == "long":
return entry * (1.0 + buffer_pct)
if side == "short":
return entry * (1.0 - buffer_pct)
return None
def sl_already_at_or_better(side: str, current_sl: float, target_sl: float) -> bool:
if current_sl <= 0 or target_sl <= 0:
return False
if side == "long":
return current_sl >= target_sl
if side == "short":
return current_sl <= target_sl
return False
def _order_id_from_plan(plan: dict[str, Any]) -> str | None:
oid = plan.get("order_id")
if oid is None:
return None
s = str(oid).strip()
return s or None
def find_sl_plan(
side: str,
contract: str,
open_plans: list[dict[str, Any]],
) -> tuple[str | None, float | None]:
"""从 open 计划单中识别止损腿,返回 (order_id, trigger_price)。"""
ct = contract.strip().upper()
want_rule = sl_trigger_rule_for_side(side)
candidates: list[tuple[str, float]] = []
for p in open_plans:
if str(p.get("contract") or "").strip().upper() != ct:
continue
try:
rule = int(p.get("rule"))
except (TypeError, ValueError):
continue
if rule != want_rule:
continue
try:
px = float(str(p.get("trigger_price") or "").strip())
except ValueError:
continue
if px <= 0:
continue
oid = _order_id_from_plan(p)
if oid:
candidates.append((oid, px))
if not candidates:
return None, None
# 多仓 SL 在 entry 下方取最高触发价;空仓 SL 在 entry 上方取最低
if side == "long":
oid, px = max(candidates, key=lambda x: x[1])
else:
oid, px = min(candidates, key=lambda x: x[1])
return oid, px
def _gate_order_id(obj: Any) -> str | None:
if not isinstance(obj, dict):
return None
oid = obj.get("id")
if oid is None:
oid = obj.get("id_string")
if oid is None:
return None
s = str(oid).strip()
return s or None
def register_from_execution_result(settings: Settings, sig: TradeSignal, result: dict[str, Any]) -> None:
if result.get("status") != "accepted":
return
contract = sig.contract.strip().upper()
if not read_effective_enabled(settings, contract):
return
entry = _float(result.get("reference_entry"))
sl_sent = result.get("stop_loss_price_sent")
try:
initial_sl = float(sl_sent) if sl_sent is not None else float(sig.stop_loss)
except (TypeError, ValueError):
mark_unregistrable(contract)
return
sl_order = result.get("stop_loss_order")
sl_id = _gate_order_id(sl_order)
if entry <= 0 or initial_sl <= 0 or not sl_id:
mark_unregistrable(contract)
return
upsert_active(
contract,
side=str(sig.side).lower(),
entry=entry,
initial_sl=initial_sl,
sl_order_id=sl_id,
moved=False,
status="waiting_1r",
)
logger.info("breakeven_registered contract=%s entry=%s initial_sl=%s", contract, entry, initial_sl)
def register_from_signal_db_row(row: dict[str, Any], sl_order_id: str) -> dict[str, Any] | None:
res = row.get("result") if isinstance(row.get("result"), dict) else {}
sig = row.get("signal") if isinstance(row.get("signal"), dict) else {}
if res.get("status") != "accepted":
return None
entry = _float(res.get("reference_entry"))
sl_sent = res.get("stop_loss_price_sent")
try:
initial_sl = float(sl_sent) if sl_sent is not None else float(sig.get("stop_loss") or 0)
except (TypeError, ValueError):
return None
side = str(sig.get("side") or res.get("side") or "").lower()
if entry <= 0 or initial_sl <= 0 or side not in ("long", "short") or not sl_order_id:
return None
return {
"side": side,
"entry": entry,
"initial_sl": initial_sl,
"sl_order_id": sl_order_id,
}
async def try_register_existing_position(
settings: Settings,
*,
contract: str,
side: str,
open_plans: list[dict[str, Any]],
signal_repo: Any | None,
) -> bool:
"""有持仓但 active 无记录时尝试登记;失败则 cannot_register。返回是否已登记。"""
ct = contract.strip().upper()
existing = get_active(ct)
if existing:
return existing.get("status") != "cannot_register"
sl_id, sl_px = find_sl_plan(side, ct, open_plans)
if not sl_id or sl_px is None:
mark_unregistrable(ct)
return False
entry: float | None = None
initial_sl: float | None = sl_px
if signal_repo is not None:
try:
db_row = signal_repo.find_latest_accepted_for_contract(ct)
except Exception: # noqa: BLE001
logger.exception("breakeven_signal_db_lookup_failed contract=%s", ct)
db_row = None
if db_row:
reg = register_from_signal_db_row(db_row, sl_id)
if reg:
entry = reg["entry"]
initial_sl = reg["initial_sl"]
side = reg["side"]
if entry is None or entry <= 0:
mark_unregistrable(ct)
return False
upsert_active(
ct,
side=side,
entry=entry,
initial_sl=float(initial_sl),
sl_order_id=sl_id,
moved=False,
status="waiting_1r",
)
logger.info("breakeven_registered_existing contract=%s", ct)
return True
async def move_sl_to_breakeven(
settings: Settings,
*,
contract: str,
side: str,
entry: float,
initial_sl: float,
sl_order_id: str,
mark_price: float,
open_plans: list[dict[str, Any]],
) -> tuple[bool, str | None, str | None]:
"""撤旧 SL 并挂保本+缓冲止损。成功返回 (True, breakeven_sl_str, new_sl_order_id)。"""
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
return False, "missing_api_keys", None
if settings.gate.dry_run:
return False, "dry_run_enabled", None
be = settings.risk.breakeven_stop
target = breakeven_sl_price(side, entry, float(be.buffer_pct))
if target is None:
return False, "invalid_breakeven_price", None
client = GateFuturesLive(settings)
cdata = await client._public_get(f"{client._prefix}/contracts/{contract.strip().upper()}")
if not isinstance(cdata, dict):
return False, "contract_not_found", None
tick = _trigger_price_tick(cdata)
target_s = _format_trigger_price(target, tick)
sl_id_plan, current_sl_px = find_sl_plan(side, contract, open_plans)
use_id = sl_order_id or sl_id_plan or ""
if current_sl_px is not None and sl_already_at_or_better(side, current_sl_px, float(target_s)):
keep_id = use_id or sl_id_plan or ""
return True, target_s, keep_id or None
from .gate_futures_live import cancel_price_triggered_order
from .oco_watcher import update_oco_sl_order_id
if use_id:
try:
await cancel_price_triggered_order(client, use_id)
except Exception as exc: # noqa: BLE001
return False, f"cancel_sl_failed:{exc}", None
try:
resp = await post_stop_loss_price_order(client, contract=contract, side=side, sl_price=target_s)
except Exception as exc: # noqa: BLE001
return False, f"post_sl_failed:{exc}", None
new_id = _gate_order_id(resp)
if not new_id:
return False, "post_sl_no_id", None
await update_oco_sl_order_id(settings, contract=contract, new_sl_id=new_id)
return True, target_s, new_id
@@ -0,0 +1,96 @@
"""移动保本偏好:全局与单合约开关,持久化 runtime/breakeven_prefs.json。"""
from __future__ import annotations
import json
import logging
import threading
from pathlib import Path
from typing import Any
from .config import Settings
logger = logging.getLogger(__name__)
_ROOT = Path(__file__).resolve().parent.parent
_PREFS_PATH = _ROOT / "runtime" / "breakeven_prefs.json"
_lock = threading.Lock()
def _read_file() -> dict[str, Any]:
if not _PREFS_PATH.is_file():
return {}
try:
raw = _PREFS_PATH.read_text(encoding="utf-8").strip()
if not raw:
return {}
data = json.loads(raw)
return data if isinstance(data, dict) else {}
except (OSError, json.JSONDecodeError) as exc:
logger.warning("breakeven_prefs_read_failed: %s", exc)
return {}
def _write_file(data: dict[str, Any]) -> None:
_PREFS_PATH.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
tmp = _PREFS_PATH.with_suffix(".json.tmp")
tmp.write_text(payload, encoding="utf-8")
tmp.replace(_PREFS_PATH)
def read_prefs_snapshot() -> dict[str, Any]:
with _lock:
return dict(_read_file())
def read_effective_global_enabled(settings: Settings) -> bool:
base = bool(settings.risk.breakeven_stop.enabled)
with _lock:
data = _read_file()
if "global_enabled" not in data:
return base
return bool(data.get("global_enabled"))
def read_contract_override(contract: str) -> bool | None:
ct = contract.strip().upper()
with _lock:
data = _read_file()
contracts = data.get("contracts")
if not isinstance(contracts, dict):
return None
row = contracts.get(ct)
if not isinstance(row, dict) or "enabled" not in row:
return None
return bool(row.get("enabled"))
def read_effective_enabled(settings: Settings, contract: str) -> bool:
ov = read_contract_override(contract)
if ov is not None:
return ov
return read_effective_global_enabled(settings)
def write_global_enabled(value: bool) -> bool:
with _lock:
data = _read_file()
data["global_enabled"] = bool(value)
_write_file(data)
return bool(value)
def write_contract_enabled(contract: str, value: bool) -> tuple[str, bool]:
ct = contract.strip().upper()
if not ct:
raise ValueError("empty_contract")
with _lock:
data = _read_file()
contracts = data.get("contracts")
if not isinstance(contracts, dict):
contracts = {}
contracts[ct] = {"enabled": bool(value)}
data["contracts"] = contracts
_write_file(data)
return ct, bool(value)
@@ -0,0 +1,243 @@
"""移动保本后台轮询:达 1R 后撤旧 SL、挂保本+缓冲(仅一次)。"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from .breakeven_active_store import (
get_active,
mark_moved,
mark_unregistrable,
read_all_active,
remove_active,
remove_all_except,
)
from .breakeven_logic import (
is_1r_reached,
move_sl_to_breakeven,
try_register_existing_position,
)
from .breakeven_prefs_store import read_effective_enabled
from .config import Settings
from .gate_futures_live import _float
from .gate_operations import list_futures_positions, list_open_price_orders
from .wecom_notify import notify_breakeven_failed
logger = logging.getLogger(__name__)
_task: asyncio.Task[None] | None = None
_settings: Settings | None = None
_signal_repo: Any | None = None
def _live_ok(settings: Settings) -> bool:
g = settings.gate
return (not g.dry_run) and bool(g.api_key.strip() and g.api_secret.strip())
def start_breakeven_watcher(settings: Settings, signal_repo: Any | None = None) -> None:
global _task, _settings, _signal_repo
_settings = settings
_signal_repo = signal_repo
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
if _task is not None and not _task.done():
return
_task = loop.create_task(_poll_loop(), name="breakeven_stop_watcher")
logger.info(
"breakeven_watcher_started poll=%ss enabled_default=%s",
settings.risk.breakeven_stop.poll_interval_sec,
settings.risk.breakeven_stop.enabled,
)
async def stop_breakeven_watcher() -> None:
global _task
if _task is None:
return
_task.cancel()
try:
await _task
except asyncio.CancelledError:
pass
_task = None
async def _poll_loop() -> None:
assert _settings is not None
interval = float(_settings.risk.breakeven_stop.poll_interval_sec)
while True:
await asyncio.sleep(interval)
try:
await _tick(_settings, _signal_repo)
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001
logger.exception("breakeven_watcher_tick_failed")
def _position_side(size: float) -> str | None:
if size > 1e-12:
return "long"
if size < -1e-12:
return "short"
return None
async def _tick(settings: Settings, signal_repo: Any | None) -> None:
if not _live_ok(settings):
return
positions, pos_err = await list_futures_positions(settings)
if pos_err or not isinstance(positions, list):
return
open_contracts: set[str] = set()
pos_by_contract: dict[str, dict[str, Any]] = {}
for row in positions:
ct = str(row.get("contract") or "").strip().upper()
sz = _float(row.get("size"))
if not ct or abs(sz) <= 1e-12:
continue
open_contracts.add(ct)
pos_by_contract[ct] = row
remove_all_except(open_contracts)
plans, _ = await list_open_price_orders(settings)
plan_list = plans if isinstance(plans, list) else []
be_cfg = settings.risk.breakeven_stop
trigger_r = float(be_cfg.trigger_r)
for ct, prow in pos_by_contract.items():
if not read_effective_enabled(settings, ct):
continue
sz = _float(prow.get("size"))
side = _position_side(sz)
if not side:
continue
mark = _float(prow.get("mark_price"))
active = get_active(ct)
if not active or active.get("status") == "cannot_register":
if not active or active.get("status") != "cannot_register":
await try_register_existing_position(
settings,
contract=ct,
side=side,
open_plans=plan_list,
signal_repo=signal_repo,
)
active = get_active(ct)
if not active or active.get("status") == "cannot_register":
continue
if active.get("moved") or active.get("status") == "moved":
continue
entry = _float(active.get("entry"))
initial_sl = _float(active.get("initial_sl"))
sl_order_id = str(active.get("sl_order_id") or "").strip()
reg_side = str(active.get("side") or side).lower()
if entry <= 0 or initial_sl <= 0 or not sl_order_id:
mark_unregistrable(ct)
continue
if not is_1r_reached(reg_side, mark, entry, initial_sl, trigger_r=trigger_r):
continue
ok, be_px, new_sl_id = await move_sl_to_breakeven(
settings,
contract=ct,
side=reg_side,
entry=entry,
initial_sl=initial_sl,
sl_order_id=sl_order_id,
mark_price=mark,
open_plans=plan_list,
)
if ok:
mark_moved(
ct,
new_sl_order_id=str(new_sl_id or sl_order_id),
breakeven_sl=str(be_px or ""),
)
logger.info("breakeven_moved contract=%s sl=%s", ct, be_px)
else:
logger.warning("breakeven_move_failed contract=%s detail=%s", ct, be_px)
try:
await notify_breakeven_failed(
settings,
contract=ct,
detail=str(be_px or "unknown"),
)
except Exception: # noqa: BLE001
logger.exception("wecom_notify_breakeven_failed")
# 清除已无持仓的 activeremove_all_except 已处理;显式删 cannot_register 残留)
for ct in list(read_all_active().keys()):
if ct not in open_contracts:
remove_active(ct)
async def build_breakeven_state_for_api(
settings: Settings,
*,
exchange_positions: list[dict[str, Any]] | None,
) -> dict[str, Any]:
from .breakeven_prefs_store import read_effective_global_enabled, read_prefs_snapshot
be = settings.risk.breakeven_stop
prefs = read_prefs_snapshot()
active = read_all_active()
per_pos: list[dict[str, Any]] = []
for row in exchange_positions or []:
ct = str(row.get("contract") or "").strip().upper()
if not ct:
continue
sz = _float(row.get("size"))
if abs(sz) <= 1e-12:
continue
enabled = read_effective_enabled(settings, ct)
act = active.get(ct) or {}
st = "disabled"
if not enabled:
st = "disabled"
elif act.get("status") == "cannot_register":
st = "cannot_register"
elif act.get("moved") or act.get("status") == "moved":
st = "moved"
elif act.get("entry"):
st = "waiting_1r"
else:
st = "pending_register"
per_pos.append(
{
"contract": ct,
"effective_enabled": enabled,
"status": st,
"breakeven_sl": act.get("breakeven_sl"),
}
)
return {
"config": {
"enabled_default": bool(be.enabled),
"trigger_r": float(be.trigger_r),
"buffer_pct": float(be.buffer_pct),
"poll_interval_sec": float(be.poll_interval_sec),
},
"global_enabled": read_effective_global_enabled(settings),
"global_enabled_config_default": bool(be.enabled),
"contracts": prefs.get("contracts") if isinstance(prefs.get("contracts"), dict) else {},
"active": active,
"positions": per_pos,
}
+137
View File
@@ -0,0 +1,137 @@
from __future__ import annotations
from pathlib import Path
import yaml
from pydantic import BaseModel, Field, field_validator
class AppConfig(BaseModel):
host: str = "127.0.0.1"
port: int = 8090
log_file: str = "./runtime/executor.log"
session_secret: str = "please-change-session-secret"
class AuthConfig(BaseModel):
"""与扫描端一致:enabled=false 时仅建议局域网使用。"""
enabled: bool = False
username: str = "admin"
password: str = "changeme"
class SecurityConfig(BaseModel):
webhook_secret: str = ""
class GateConfig(BaseModel):
api_base: str = "https://api.gateio.ws/api/v4"
settle: str = "usdt"
api_key: str = ""
api_secret: str = ""
dry_run: bool = True
# 仅人工测试:为 true 时允许 micro_market 真实 IOC 市价(仍受 test_max_contracts 限制);通过 POST /api/test、/v1/test 联调,见 docs/使用说明 §4.1
test_orders_enabled: bool = False
test_max_contracts: int = Field(1, ge=1, le=30)
class BreakevenStopConfig(BaseModel):
"""移动保本:1R 相对初始止损触发后,止损拉至开仓价 ± buffer_pct(仅一次)。"""
enabled: bool = True
trigger_r: float = Field(1.0, ge=0.1, le=10.0, description="相对初始 SL 的 R 倍数")
buffer_pct: float = Field(0.002, ge=0.0, le=0.05, description="保本缓冲,价格的百分比(0.002=0.2%")
poll_interval_sec: float = Field(8.0, ge=3.0, le=120.0)
class RiskConfig(BaseModel):
risk_per_trade_frac: float = Field(0.005, ge=0.0001, le=0.05)
max_open_positions: int = Field(5, ge=1, le=50)
scheme: str = "A"
# Gate 永续 v4 无官方「单笔原生 OCO」双计划互撤时:为 true 则在净持仓为 0 后轮询 DELETE 本次挂出的另一腿计划单
oco_cleanup_enabled: bool = True
# 最低盈亏比(毛利/风险)门槛的 config 默认值;面板保存会写入 runtime/risk_prefs.json 覆盖
min_reward_risk_ratio: float = Field(1.3, ge=0.1, le=50.0)
breakeven_stop: BreakevenStopConfig = Field(default_factory=BreakevenStopConfig)
class StatsConfig(BaseModel):
"""面板「正式统计」:时区与起始时刻(仅统计该时刻之后平仓的 Gate 历史仓位记录)。"""
timezone: str = "Asia/Shanghai"
official_start: str = Field(
default="2026-05-13T02:00:00+08:00",
description="ISO8601,建议带 +08:00;仅统计平仓 time 不早于此的历史平仓记录",
)
max_trade_rows: int = Field(
20000,
ge=500,
le=100000,
description="从 Gate 分页拉 position_close 历史平仓的上限(防爆内存;沿用键名 max_trade_rows",
)
class DatabaseConfig(BaseModel):
"""信号流与执行结果默认写入 SQLite;面板与导出读同一库,进程重启后记录仍在。"""
enabled: bool = Field(
default=True,
description="已废弃,保留兼容旧配置;是否落库由 sqlite_path 决定(空串会自动回退为默认路径)",
)
sqlite_path: str = "./runtime/signals.sqlite"
@field_validator("sqlite_path", mode="before")
@classmethod
def _sqlite_path_default_if_blank(cls, v: object) -> str:
if v is None:
return "./runtime/signals.sqlite"
s = str(v).strip()
return s if s else "./runtime/signals.sqlite"
class ProxyConfig(BaseModel):
"""
出站 HTTPhttpx)代理,与 onchain_scout_gate 的 ``proxy:`` 块写法一致。
访问 Gate 私有 API 时使用此处;企业微信「策略类」仍由扫描端处理,执行结果见 ``wecom`` 配置。
"""
enabled: bool = False
url: str = "socks5h://127.0.0.1:1080"
class WecomNotifyConfig(BaseModel):
"""企业微信群机器人:仅推送执行器侧执行结果(成交/拒单/异常/平仓等);策略发现类仍由扫描端。"""
enabled: bool = False
webhook_url: str = Field(
default="",
description="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...",
)
@field_validator("webhook_url", mode="before")
@classmethod
def _strip_webhook(cls, v: object) -> str:
if v is None:
return ""
return str(v).strip()
class Settings(BaseModel):
app: AppConfig = Field(default_factory=AppConfig)
auth: AuthConfig = Field(default_factory=AuthConfig)
security: SecurityConfig = Field(default_factory=SecurityConfig)
gate: GateConfig = Field(default_factory=GateConfig)
risk: RiskConfig = Field(default_factory=RiskConfig)
stats: StatsConfig = Field(default_factory=StatsConfig)
database: DatabaseConfig = Field(default_factory=DatabaseConfig)
proxy: ProxyConfig = Field(default_factory=ProxyConfig)
wecom: WecomNotifyConfig = Field(default_factory=WecomNotifyConfig)
def load_settings(path: str | Path | None = None) -> Settings:
cfg_path = Path(path or Path(__file__).resolve().parents[1] / "config.yaml")
if not cfg_path.is_file():
return Settings()
raw = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}
return Settings.model_validate(raw)
+89
View File
@@ -0,0 +1,89 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .config import Settings
from .models_signal import TradeSignal
from .positions import PositionBook
logger = logging.getLogger(__name__)
def _live_enabled(settings: "Settings") -> bool:
g = settings.gate
return (not g.dry_run) and bool(g.api_key.strip() and g.api_secret.strip())
async def handle_signal(settings: "Settings", book: "PositionBook", sig: "TradeSignal") -> dict:
"""
校验仓位上限与重复合约。
dry_run:只打日志并释放占位槽。
实盘:同步交易所持仓 → 市价开仓 → 计划委托止盈/止损;成功则保留占位槽。
"""
from .gate_futures_live import GateFuturesLive, execute_signal_live, fetch_open_contracts
contract = sig.contract.strip().upper()
open_on_ex: set[str] = set()
if _live_enabled(settings):
try:
gc = GateFuturesLive(settings)
open_on_ex = await fetch_open_contracts(gc)
book.sync_from_exchange(open_on_ex)
except Exception as exc: # noqa: BLE001
logger.warning("exchange_sync_failed: %s", exc)
if len(open_on_ex) >= settings.risk.max_open_positions:
return {
"status": "skipped",
"reason": "max_positions_exchange",
"max": settings.risk.max_open_positions,
}
if contract in open_on_ex:
return {"status": "skipped", "reason": "already_open_on_exchange", "contract": contract}
if book.has_contract(contract):
return {"status": "skipped", "reason": "already_open_for_contract", "contract": contract}
if not book.try_reserve(contract, sig.signal_id):
return {
"status": "skipped",
"reason": "max_positions_or_race",
"max": settings.risk.max_open_positions,
}
if not _live_enabled(settings):
logger.info(
"dry_run signal accepted contract=%s side=%s tp=%s sl=%s signal_id=%s",
contract,
sig.side,
sig.take_profit,
sig.stop_loss,
sig.signal_id,
)
book.release(contract)
return {
"status": "accepted",
"mode": "dry_run",
"contract": contract,
"side": sig.side,
"take_profit": sig.take_profit,
"stop_loss": sig.stop_loss,
"signal_id": sig.signal_id,
}
try:
out = await execute_signal_live(settings, sig)
except Exception as exc: # noqa: BLE001
logger.exception("live_execute_exception contract=%s", contract)
book.release(contract)
return {"status": "error", "reason": "exception", "detail": str(exc)}
if out.get("status") != "accepted":
book.release(contract)
return out
return out
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
import hashlib
import hmac
import time
from urllib.parse import urlparse
def gate_sign_path(api_base: str, path_rel: str) -> str:
"""签名用路径:/api/v4 + /futures/usdt/...(不含 host)。"""
root = urlparse(api_base).path.rstrip("/") or "/api/v4"
rel = path_rel if path_rel.startswith("/") else "/" + path_rel
return root + rel
def gate_sign_v4_headers(
*,
api_key: str,
api_secret: str,
method: str,
sign_path: str,
query_string: str,
body: str,
) -> dict[str, str]:
ts = str(int(time.time()))
m = hashlib.sha512()
m.update((body or "").encode("utf-8"))
hashed = m.hexdigest()
payload = f"{method.upper()}\n{sign_path}\n{query_string}\n{hashed}\n{ts}"
sign = hmac.new(api_secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha512).hexdigest()
return {
"KEY": api_key,
"Timestamp": ts,
"SIGN": sign,
"Accept": "application/json",
"Content-Type": "application/json",
}
@@ -0,0 +1,439 @@
from __future__ import annotations
import json
import logging
import math
import re
from decimal import ROUND_DOWN, Decimal
from typing import Any
import httpx
from .config import Settings
from .gate_auth import gate_sign_path, gate_sign_v4_headers
from .gate_price_rounding import _format_trigger_price, _trigger_price_tick
from .models_signal import TradeSignal
from .proxy_util import httpx_client_kwargs
logger = logging.getLogger(__name__)
PRICE_ORDER_EXPIRATION_SEC = 604800 # 7 天
def _safe_order_text(signal_id: str) -> str:
s = re.sub(r"[^0-9A-Za-z._-]", "_", (signal_id or "x").strip())[:22]
return "t-e" + s if s else "t-e"
def _json_compact(obj: Any) -> str:
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
class GateFuturesLive:
"""Gate USDT 永续私有 REST(市价开仓 + 计划委托止盈止损)。"""
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._base = settings.gate.api_base.rstrip("/")
self._settle = settings.gate.settle.strip().lower()
self._prefix = f"/futures/{self._settle}"
self._key = settings.gate.api_key.strip()
self._secret = settings.gate.api_secret.strip()
self._kw = httpx_client_kwargs(settings.proxy.enabled, settings.proxy.url)
def _sign_path(self, rel: str) -> str:
return gate_sign_path(self._settings.gate.api_base, rel)
async def _public_get(self, rel: str, *, params: dict[str, str] | None = None) -> Any:
url = f"{self._base}{rel}"
async with httpx.AsyncClient(**self._kw) as client:
r = await client.get(url, params=params)
r.raise_for_status()
return r.json()
async def _signed(
self,
method: str,
rel: str,
*,
query_string: str = "",
body_obj: dict[str, Any] | None = None,
) -> Any:
body_str = _json_compact(body_obj) if body_obj is not None else ""
sp = self._sign_path(rel)
headers = gate_sign_v4_headers(
api_key=self._key,
api_secret=self._secret,
method=method,
sign_path=sp,
query_string=query_string,
body=body_str,
)
url = f"{self._base}{rel}"
async with httpx.AsyncClient(**self._kw) as client:
if method.upper() == "GET":
r = await client.get(url, headers=headers, params=params_from_qs(query_string))
elif method.upper() == "POST":
r = await client.post(url, headers=headers, content=body_str.encode("utf-8"))
elif method.upper() == "DELETE":
r = await client.delete(url, headers=headers)
else:
raise ValueError(f"unsupported {method}")
try:
r.raise_for_status()
except httpx.HTTPStatusError as exc:
try:
detail = exc.response.json()
except Exception:
detail = exc.response.text if exc.response else ""
raise RuntimeError(f"gate_http_{exc.response.status_code}: {detail}") from exc
if not r.content.strip():
return None
return r.json()
async def fetch_net_position_size(client: GateFuturesLive, contract: str) -> float:
"""该合约净持仓张数(单向模式 size 正负表示方向)。"""
ct = contract.strip().upper()
data = await client._signed("GET", f"{client._prefix}/positions")
if not isinstance(data, list):
return 0.0
for p in data:
if str(p.get("contract") or "").strip().upper() != ct:
continue
return _float(p.get("size"))
return 0.0
async def post_stop_loss_price_order(
client: GateFuturesLive,
*,
contract: str,
side: str,
sl_price: str,
) -> dict[str, Any]:
"""POST 一条 reduce_only 全平止损计划单(与信号开仓 SL 同形态)。"""
ct = contract.strip().upper()
sd = str(side).lower()
if sd not in ("long", "short"):
raise ValueError("invalid_side")
cdata = await client._public_get(f"{client._prefix}/contracts/{ct}")
if not isinstance(cdata, dict):
raise ValueError("contract_not_found")
price_tick = _trigger_price_tick(cdata)
sl_s = _format_trigger_price(float(sl_price), price_tick)
_, sl_tr = _tp_sl_triggers(sd, sl_s, sl_s)
import time as _time
text = ("t-besl" + str(int(_time.time())))[-28:]
body: dict[str, Any] = {
"initial": {
"contract": ct,
"size": 0,
"price": "0",
"tif": "ioc",
"text": text,
"reduce_only": True,
"close": True,
},
"trigger": sl_tr,
}
resp = await client._signed("POST", f"{client._prefix}/price_orders", body_obj=body)
if not isinstance(resp, dict):
raise RuntimeError("price_order_response_invalid")
return resp
async def cancel_price_triggered_order(client: GateFuturesLive, order_id: str | int) -> bool:
"""DELETE /price_orders/{id}。成功删除返回 True。
单已不存在(404,或 Gate 400 + 1034 AUTO_ORDER_NOT_FOUND)视为目标已达成,返回 True,不抛错。
"""
oid = str(order_id).strip()
if not oid:
return False
rel = f"{client._prefix}/price_orders/{oid}"
try:
await client._signed("DELETE", rel)
return True
except RuntimeError as exc:
msg = str(exc)
if "gate_http_404" in msg:
return True
# OCO 一腿触发后另一腿常被交易所联动撤掉;再 DELETE 会得到 400+1034 而非 404。
if "gate_http_400" in msg and (
"AUTO_ORDER_NOT_FOUND" in msg or "'1034'" in msg or '"1034"' in msg
):
return True
raise
def params_from_qs(qs: str) -> dict[str, str]:
if not qs.strip():
return {}
out: dict[str, str] = {}
for part in qs.split("&"):
if "=" in part:
k, v = part.split("=", 1)
out[k] = v
return out
def _float(x: Any, default: float = 0.0) -> float:
try:
if x is None:
return default
return float(x)
except (TypeError, ValueError):
return default
async def fetch_open_contracts(client: GateFuturesLive) -> set[str]:
rel = f"{client._prefix}/positions"
data = await client._signed("GET", rel)
if not isinstance(data, list):
return set()
out: set[str] = set()
for p in data:
if not isinstance(p, dict):
continue
c = str(p.get("contract") or "").strip().upper()
if not c:
continue
if abs(_float(p.get("size"))) > 1e-12:
out.add(c)
return out
def _round_contract_size(raw: float, *, enable_decimal: bool, order_size_min: float) -> str | None:
if raw <= 0 or not math.isfinite(raw):
return None
if enable_decimal:
d = Decimal(str(raw)).quantize(Decimal("0.1"), rounding=ROUND_DOWN)
m = Decimal(str(order_size_min))
if d < m:
return None
s = format(d, "f").rstrip("0").rstrip(".")
return s or None
n = int(math.floor(raw))
if n < int(math.ceil(order_size_min)):
return None
return str(n)
def _tp_sl_triggers(side: str, tp_price: str, sl_price: str) -> tuple[dict[str, Any], dict[str, Any]]:
"""返回 (tp_trigger, sl_trigger) 的 trigger 字段 dictprice 已为合约 tick 对齐后的字符串。"""
if side == "long":
tp_tr = {
"strategy_type": 0,
"price_type": 0,
"price": tp_price,
"rule": 1,
"expiration": PRICE_ORDER_EXPIRATION_SEC,
}
sl_tr = {
"strategy_type": 0,
"price_type": 0,
"price": sl_price,
"rule": 2,
"expiration": PRICE_ORDER_EXPIRATION_SEC,
}
else:
tp_tr = {
"strategy_type": 0,
"price_type": 0,
"price": tp_price,
"rule": 2,
"expiration": PRICE_ORDER_EXPIRATION_SEC,
}
sl_tr = {
"strategy_type": 0,
"price_type": 0,
"price": sl_price,
"rule": 1,
"expiration": PRICE_ORDER_EXPIRATION_SEC,
}
return tp_tr, sl_tr
async def execute_signal_live(settings: Settings, sig: TradeSignal) -> dict:
"""
市价开仓 + 计划委托止盈/止损(reduce_only 市价 IOC)。
以损订仓:用 futures 账户 total × risk_per_trade_frac / (|entry-sl|×quanto_multiplier) 估算张数。
"""
client = GateFuturesLive(settings)
contract = sig.contract.strip().upper()
ot = _safe_order_text(sig.signal_id)
try:
ticker = await client._public_get(f"{client._prefix}/tickers", params={"contract": contract})
last = 0.0
if isinstance(ticker, list) and ticker:
last = _float(ticker[0].get("last"))
elif isinstance(ticker, dict):
last = _float(ticker.get("last"))
entry = float(sig.reference_price) if sig.reference_price else last
if entry <= 0:
return {"status": "error", "reason": "no_entry_price", "detail": "缺少 reference_price 且无法从 ticker 取 last"}
cdata = await client._public_get(f"{client._prefix}/contracts/{contract}")
if not isinstance(cdata, dict):
return {"status": "error", "reason": "contract_not_found", "contract": contract}
mult = _float(cdata.get("quanto_multiplier"))
if mult <= 0:
return {"status": "error", "reason": "invalid_quanto_multiplier", "contract": contract}
order_size_min = _float(cdata.get("order_size_min"), 1.0)
enable_decimal = bool(cdata.get("enable_decimal"))
price_tick = _trigger_price_tick(cdata)
if price_tick is None:
logger.warning("contract %s: missing order_price_round/mark_price_round; TP/SL may be rejected", contract)
accounts = await client._signed("GET", f"{client._prefix}/accounts")
if not isinstance(accounts, dict):
return {"status": "error", "reason": "accounts_unexpected", "detail": str(type(accounts))}
equity = _float(accounts.get("total"))
if equity <= 0:
return {"status": "error", "reason": "zero_equity", "detail": "futures accounts total 为 0"}
risk_usdt = equity * float(settings.risk.risk_per_trade_frac)
sl_dist = abs(entry - float(sig.stop_loss))
if sl_dist <= 0:
return {"status": "error", "reason": "invalid_stop_distance"}
raw_contracts = risk_usdt / (sl_dist * mult)
size_s = _round_contract_size(raw_contracts, enable_decimal=enable_decimal, order_size_min=order_size_min)
if not size_s:
return {
"status": "error",
"reason": "size_too_small",
"detail": f"以损订仓张数不足 order_size_min={order_size_min} raw={raw_contracts:.6f}",
}
if sig.side == "long":
open_size = size_s if not size_s.startswith("-") else size_s.lstrip("-")
else:
open_size = "-" + size_s.lstrip("-")
market_body: dict[str, Any] = {
"contract": contract,
"size": open_size,
"price": "0",
"tif": "ioc",
"text": ot,
"reduce_only": False,
}
order = await client._signed("POST", f"{client._prefix}/orders", body_obj=market_body)
if not isinstance(order, dict):
return {"status": "error", "reason": "order_response_invalid"}
st = str(order.get("status") or "")
finish = str(order.get("finish_as") or "")
left_abs = abs(_float(order.get("left")))
if st != "finished" or left_abs > 1e-12:
return {"status": "error", "reason": "market_not_filled", "order": order}
if finish and finish not in {"filled", "ioc"}:
return {"status": "error", "reason": "market_not_filled", "order": order}
tp_s = _format_trigger_price(float(sig.take_profit), price_tick)
sl_s = _format_trigger_price(float(sig.stop_loss), price_tick)
tp_tr, sl_tr = _tp_sl_triggers(sig.side, tp_s, sl_s)
def _price_order(trigger: dict[str, Any], text_val: str) -> dict[str, Any]:
# Gate:单向全平时 close=true 则 initial.size 必须为 0(否则会报 AUTO_INVALID_PARAM_INITIAL_SIZE
return {
"initial": {
"contract": contract,
"size": 0,
"price": "0",
"tif": "ioc",
"text": text_val,
"reduce_only": True,
"close": True,
},
"trigger": trigger,
}
tp_po = _price_order(tp_tr, "api")
sl_po = _price_order(sl_tr, "app")
# 市价已成交后单独捕获计划委托失败,便于返回 market_order(及已挂上的 TP)供落库、对账
partial_base: dict[str, Any] = {
"status": "error",
"mode": "live",
"contract": contract,
"side": sig.side,
"signal_id": sig.signal_id,
"market_order": order,
"sized_contracts": open_size,
"risk_budget_usdt": round(risk_usdt, 6),
"reference_entry": entry,
"trigger_price_tick": str(price_tick) if price_tick is not None else None,
"take_profit_price_sent": tp_s,
"stop_loss_price_sent": sl_s,
}
try:
tp_resp = await client._signed("POST", f"{client._prefix}/price_orders", body_obj=tp_po)
except RuntimeError as exc:
logger.exception("price_orders_take_profit_failed contract=%s", contract)
return {
**partial_base,
"reason": "gate_api",
"detail": str(exc),
"stage": "take_profit",
}
try:
sl_resp = await client._signed("POST", f"{client._prefix}/price_orders", body_obj=sl_po)
except RuntimeError as exc:
logger.exception("price_orders_stop_loss_failed contract=%s", contract)
out_partial: dict[str, Any] = {
**partial_base,
"reason": "gate_api",
"detail": str(exc),
"stage": "stop_loss",
"take_profit_order": tp_resp,
}
return out_partial
from .breakeven_logic import register_from_execution_result
from .oco_watcher import register_tp_sl_oco_cleanup
if isinstance(tp_resp, dict) and isinstance(sl_resp, dict):
await register_tp_sl_oco_cleanup(
settings,
contract=contract,
tp_order=tp_resp,
sl_order=sl_resp,
)
accepted_out = {
"status": "accepted",
"mode": "live",
"contract": contract,
"side": sig.side,
"signal_id": sig.signal_id,
"market_order": order,
"take_profit_order": tp_resp,
"stop_loss_order": sl_resp,
"sized_contracts": open_size,
"risk_budget_usdt": round(risk_usdt, 6),
"reference_entry": entry,
"trigger_price_tick": str(price_tick) if price_tick is not None else None,
"take_profit_price_sent": tp_s,
"stop_loss_price_sent": sl_s,
}
register_from_execution_result(settings, sig, accepted_out)
return accepted_out
except httpx.HTTPStatusError as exc:
body = exc.response.text if exc.response else ""
logger.exception("gate_http_error %s", body[:500])
return {"status": "error", "reason": "http_error", "detail": str(exc)}
except RuntimeError as exc:
return {"status": "error", "reason": "gate_api", "detail": str(exc)}
except Exception as exc: # noqa: BLE001
logger.exception("execute_signal_live failed")
return {"status": "error", "reason": "exception", "detail": str(exc)}
+319
View File
@@ -0,0 +1,319 @@
from __future__ import annotations
import csv
import io
import time
from typing import Any
from urllib.parse import urlencode
from .config import Settings
from .gate_futures_live import GateFuturesLive
def _keys_ok(settings: Settings) -> bool:
return bool(settings.gate.api_key.strip() and settings.gate.api_secret.strip())
def _default_range_ts() -> tuple[int, int]:
now = int(time.time())
return now - 86400 * 7, now
async def fetch_position_close_timerange(
settings: Settings,
*,
contract: str | None,
from_ts: int,
to_ts: int,
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict[str, Any]] | None, str | None]:
"""GET /futures/{{settle}}/position_close — 历史平仓(与 App「历史仓位」同源类数据)。"""
if not _keys_ok(settings):
return None, None
lim = max(1, min(int(limit), 500))
off = max(0, int(offset))
q: dict[str, Any] = {
"from": int(from_ts),
"to": int(to_ts),
"limit": lim,
"offset": off,
}
if contract and str(contract).strip():
q["contract"] = str(contract).strip().upper()
qs = urlencode(q)
try:
c = GateFuturesLive(settings)
data = await c._signed("GET", f"{c._prefix}/position_close", query_string=qs)
if not isinstance(data, list):
return None, f"unexpected_response:{type(data).__name__}"
return [x for x in data if isinstance(x, dict)], None
except Exception as exc: # noqa: BLE001
return None, str(exc)
async def fetch_my_trades_timerange(
settings: Settings,
*,
contract: str | None,
from_ts: int,
to_ts: int,
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict[str, Any]] | None, str | None]:
"""GET /futures/{{settle}}/my_trades_timerange — 成交记录(Gate 为准)。"""
if not _keys_ok(settings):
return None, None
lim = max(1, min(int(limit), 500))
off = max(0, int(offset))
q: dict[str, Any] = {"from": int(from_ts), "to": int(to_ts), "limit": lim, "offset": off}
if contract and str(contract).strip():
q["contract"] = str(contract).strip().upper()
qs = urlencode(q)
try:
c = GateFuturesLive(settings)
data = await c._signed("GET", f"{c._prefix}/my_trades_timerange", query_string=qs)
if not isinstance(data, list):
return None, f"unexpected_response:{type(data).__name__}"
return [x for x in data if isinstance(x, dict)], None
except Exception as exc: # noqa: BLE001
return None, str(exc)
async def fetch_orders_list(
settings: Settings,
*,
status: str,
contract: str | None,
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict[str, Any]] | None, str | None]:
"""GET /futures/{{settle}}/orders?status=… — 委托列表(Gate 为准)。"""
if not _keys_ok(settings):
return None, None
lim = max(1, min(int(limit), 500))
off = max(0, int(offset))
st = (status or "finished").strip().lower()
if st not in ("open", "finished"):
st = "finished"
q: dict[str, Any] = {"status": st, "limit": lim, "offset": off}
if contract and str(contract).strip():
q["contract"] = str(contract).strip().upper()
qs = urlencode(q)
try:
c = GateFuturesLive(settings)
data = await c._signed("GET", f"{c._prefix}/orders", query_string=qs)
if not isinstance(data, list):
return None, f"unexpected_response:{type(data).__name__}"
return [x for x in data if isinstance(x, dict)], None
except Exception as exc: # noqa: BLE001
return None, str(exc)
def trades_rows_to_csv(rows: list[dict[str, Any]]) -> str:
"""成交记录 → CSV 文本(UTF-8 BOM 便于 Excel 打开)。"""
buf = io.StringIO()
buf.write("\ufeff")
w = csv.writer(buf)
w.writerow(
[
"trade_id",
"create_time",
"contract",
"order_id",
"size",
"price",
"fee",
"point_fee",
"role",
"text",
"close_size",
"pnl",
]
)
for r in rows:
w.writerow(
[
r.get("trade_id") or r.get("id") or "",
r.get("create_time"),
r.get("contract") or "",
r.get("order_id") or "",
r.get("size") or "",
r.get("price") or "",
r.get("fee") or "",
r.get("point_fee") or "",
r.get("role") or "",
r.get("text") or "",
r.get("close_size") or "",
r.get("pnl") if r.get("pnl") is not None else "",
]
)
return buf.getvalue()
def orders_rows_to_csv(rows: list[dict[str, Any]]) -> str:
buf = io.StringIO()
buf.write("\ufeff")
w = csv.writer(buf)
w.writerow(
[
"id",
"create_time",
"finish_time",
"contract",
"size",
"left",
"price",
"fill_price",
"status",
"finish_as",
"tif",
"text",
"is_reduce_only",
]
)
for r in rows:
w.writerow(
[
r.get("id") or "",
r.get("create_time"),
r.get("finish_time"),
r.get("contract") or "",
r.get("size") or "",
r.get("left") or "",
r.get("price") or "",
r.get("fill_price") or "",
r.get("status") or "",
r.get("finish_as") or "",
r.get("tif") or "",
r.get("text") or "",
r.get("is_reduce_only"),
]
)
return buf.getvalue()
async def collect_trades_rows(
settings: Settings,
*,
contract: str | None,
from_ts: int,
to_ts: int,
max_rows: int = 2000,
) -> tuple[list[dict[str, Any]] | None, str | None]:
"""分页拉取成交原始行(上限 max_rows,单页最多 500)。"""
if not _keys_ok(settings):
return None, None
cap = max(1, min(int(max_rows), 100_000))
page = 500
all_rows: list[dict[str, Any]] = []
offset = 0
while len(all_rows) < cap:
lim = min(page, cap - len(all_rows))
chunk, err = await fetch_my_trades_timerange(
settings,
contract=contract,
from_ts=from_ts,
to_ts=to_ts,
limit=lim,
offset=offset,
)
if err:
return None, err
if not chunk:
break
all_rows.extend(chunk)
offset += len(chunk)
if len(chunk) < lim:
break
return all_rows[:cap], None
async def collect_position_close_rows(
settings: Settings,
*,
contract: str | None,
from_ts: int,
to_ts: int,
max_rows: int = 2000,
) -> tuple[list[dict[str, Any]] | None, str | None]:
"""分页拉取历史平仓原始行(上限 max_rows,单页最多 500)。"""
if not _keys_ok(settings):
return None, None
cap = max(1, min(int(max_rows), 100_000))
page = 500
all_rows: list[dict[str, Any]] = []
offset = 0
while len(all_rows) < cap:
lim = min(page, cap - len(all_rows))
chunk, err = await fetch_position_close_timerange(
settings,
contract=contract,
from_ts=from_ts,
to_ts=to_ts,
limit=lim,
offset=offset,
)
if err:
return None, err
if not chunk:
break
all_rows.extend(chunk)
offset += len(chunk)
if len(chunk) < lim:
break
return all_rows[:cap], None
async def collect_trades_csv(
settings: Settings,
*,
contract: str | None,
from_ts: int,
to_ts: int,
max_rows: int = 2000,
) -> tuple[str | None, str | None]:
"""分页拉取成交并拼成 CSV(上限 max_rows)。"""
all_rows, err = await collect_trades_rows(
settings,
contract=contract,
from_ts=from_ts,
to_ts=to_ts,
max_rows=max_rows,
)
if err:
return None, err
if all_rows is None:
return None, None
return trades_rows_to_csv(all_rows), None
async def collect_orders_csv(
settings: Settings,
*,
status: str,
contract: str | None,
max_rows: int = 2000,
) -> tuple[str | None, str | None]:
cap = max(1, min(int(max_rows), 5000))
page = 100
all_rows: list[dict[str, Any]] = []
offset = 0
while len(all_rows) < cap:
lim = min(page, cap - len(all_rows))
chunk, err = await fetch_orders_list(
settings,
status=status,
contract=contract,
limit=lim,
offset=offset,
)
if err:
return None, err
if not chunk:
break
all_rows.extend(chunk)
offset += len(chunk)
if len(chunk) < lim:
break
return orders_rows_to_csv(all_rows[:cap]), None
+263
View File
@@ -0,0 +1,263 @@
from __future__ import annotations
import math
import time
from typing import Any
from .config import Settings
from .gate_futures_live import (
PRICE_ORDER_EXPIRATION_SEC,
GateFuturesLive,
cancel_price_triggered_order,
fetch_net_position_size,
)
def _float_field(x: Any, default: float = 0.0) -> float:
try:
if x is None:
return default
return float(x)
except (TypeError, ValueError):
return default
def _slim_futures_position(row: dict[str, Any]) -> dict[str, Any]:
"""GET /positions 单行摘要(面板用)。"""
return {
"contract": str(row.get("contract") or "").strip().upper(),
"size": row.get("size"),
"entry_price": row.get("entry_price") if row.get("entry_price") is not None else row.get("avg_entry_price"),
"mark_price": row.get("mark_price"),
"unrealised_pnl": row.get("unrealised_pnl"),
"leverage": row.get("leverage"),
"value": row.get("value"),
"liq_price": row.get("liq_price"),
"margin": row.get("margin"),
"mode": row.get("mode"),
}
async def list_futures_positions(
settings: Settings, *, limit: int = 80
) -> tuple[list[dict[str, Any]] | None, str | None]:
"""GET /futures/{{settle}}/positions,仅返回 |size|>0 的合约(最多 limit 条)。"""
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
return None, None
cap = max(1, min(int(limit), 200))
try:
c = GateFuturesLive(settings)
data = await c._signed("GET", f"{c._prefix}/positions")
if not isinstance(data, list):
return None, f"unexpected_response:{type(data).__name__}"
out: list[dict[str, Any]] = []
for row in data:
if not isinstance(row, dict):
continue
if abs(_float_field(row.get("size"))) <= 1e-12:
continue
out.append(_slim_futures_position(row))
if len(out) >= cap:
break
return out, None
except Exception as exc: # noqa: BLE001
return None, str(exc)
async def read_futures_balance(settings: Settings) -> tuple[dict[str, Any] | None, str | None]:
"""GET /futures/{{settle}}/accounts,返回 (payload, error_message)。"""
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
return None, None
try:
c = GateFuturesLive(settings)
data = await c._signed("GET", f"{c._prefix}/accounts")
if isinstance(data, dict):
return data, None
return None, f"unexpected_response:{type(data).__name__}"
except Exception as exc: # noqa: BLE001
return None, str(exc)
def _slim_price_order(row: dict[str, Any]) -> dict[str, Any]:
"""面板展示用字段(避免整对象过大)。"""
ini = row.get("initial") if isinstance(row.get("initial"), dict) else {}
tr = row.get("trigger") if isinstance(row.get("trigger"), dict) else {}
oid = row.get("id_string")
if oid is None and row.get("id") is not None:
oid = str(row.get("id"))
return {
"order_id": str(oid or "").strip(),
"contract": str(ini.get("contract") or "").strip().upper(),
"status": str(row.get("status") or ""),
"order_type": str(row.get("order_type") or ""),
"trigger_price": str(tr.get("price") or ""),
"rule": tr.get("rule"),
"size": ini.get("size"),
"reduce_only": ini.get("reduce_only"),
"create_time": row.get("create_time"),
}
async def list_open_price_orders(
settings: Settings, *, limit: int = 50
) -> tuple[list[dict[str, Any]] | None, str | None]:
"""GET /futures/{{settle}}/price_orders?status=open,返回精简列表。"""
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
return None, None
lim = max(1, min(int(limit), 100))
qs = f"status=open&limit={lim}"
try:
c = GateFuturesLive(settings)
data = await c._signed("GET", f"{c._prefix}/price_orders", query_string=qs)
if not isinstance(data, list):
return None, f"unexpected_response:{type(data).__name__}"
out: list[dict[str, Any]] = []
for row in data:
if isinstance(row, dict):
out.append(_slim_price_order(row))
return out, None
except Exception as exc: # noqa: BLE001
return None, str(exc)
async def cancel_plan_price_order(settings: Settings, order_id: str) -> tuple[bool, str | None]:
"""撤销一条计划委托(price_orders)。"""
oid = (order_id or "").strip()
if not oid:
return False, "empty_order_id"
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
return False, "missing_api_keys"
try:
c = GateFuturesLive(settings)
await cancel_price_triggered_order(c, oid)
return True, None
except Exception as exc: # noqa: BLE001
return False, str(exc)
async def post_test_market_order(settings: Settings, *, contract: str, side: str, size_qty: int) -> dict[str, Any]:
"""
极小市价 IOC 测试单。需 config gate.test_orders_enabled=true。
size_qty 会被限制在 [1, test_max_contracts]。
"""
if not settings.gate.test_orders_enabled:
return {"ok": False, "error": "test_orders_disabled", "hint": "请在 config.yaml 设置 gate.test_orders_enabled: true"}
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
return {"ok": False, "error": "missing_api_keys"}
cap = int(settings.gate.test_max_contracts)
n = max(1, min(int(size_qty), cap))
c = GateFuturesLive(settings)
ct = contract.strip().upper()
if "_" not in ct or not ct.endswith("_USDT"):
return {"ok": False, "error": "invalid_contract", "contract": ct}
sz = str(n) if side == "long" else f"-{n}"
text = "t-tst" + str(int(time.time()))[-12:]
body: dict[str, Any] = {
"contract": ct,
"size": sz,
"price": "0",
"tif": "ioc",
"text": text[:28],
"reduce_only": False,
}
order = await c._signed("POST", f"{c._prefix}/orders", body_obj=body)
return {"ok": True, "order": order, "request": body}
def _format_order_size_signed(value: float) -> str | None:
if value == 0 or not math.isfinite(value):
return None
if abs(value - round(value)) < 1e-8:
return str(int(round(value)))
s = f"{value:.10f}".rstrip("0").rstrip(".")
return s if s else None
async def market_close_futures_position(settings: Settings, *, contract: str) -> tuple[dict[str, Any] | None, str | None]:
"""市价 IOC + reduce_only 平掉该合约全部净持仓(单向 size 正负)。"""
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
return None, "missing_api_keys"
if settings.gate.dry_run:
return None, "dry_run_enabled"
c = GateFuturesLive(settings)
ct = contract.strip().upper()
try:
net = await fetch_net_position_size(c, ct)
except Exception as exc: # noqa: BLE001
return None, str(exc)
if abs(net) < 1e-12:
return None, "no_position"
flip = -net
sz_s = _format_order_size_signed(flip)
if not sz_s:
return None, "invalid_close_size"
text = ("t-mcls" + str(int(time.time())))[-28:]
body: dict[str, Any] = {
"contract": ct,
"size": sz_s,
"price": "0",
"tif": "ioc",
"text": text,
"reduce_only": True,
}
try:
order = await c._signed("POST", f"{c._prefix}/orders", body_obj=body)
if not isinstance(order, dict):
return {"response": order}, None
return order, None
except Exception as exc: # noqa: BLE001
return None, str(exc)
async def post_reduce_close_price_order(
settings: Settings,
*,
contract: str,
trigger_price: str,
rule: int,
) -> tuple[dict[str, Any] | None, str | None]:
"""POST price_orders:全平 reduce_only 条件单(与信号挂 TP/SL 同一 initial 形态)。"""
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
return None, "missing_api_keys"
if settings.gate.dry_run:
return None, "dry_run_enabled"
ct = contract.strip().upper()
try:
fp = float(str(trigger_price).strip())
except ValueError:
return None, "invalid_trigger_price"
if fp <= 0 or not math.isfinite(fp):
return None, "invalid_trigger_price"
r = int(rule)
if r not in (1, 2):
return None, "invalid_rule"
trig: dict[str, Any] = {
"strategy_type": 0,
"price_type": 0,
"price": str(fp),
"rule": r,
"expiration": PRICE_ORDER_EXPIRATION_SEC,
}
text = ("t-padd" + str(int(time.time())))[-28:]
body: dict[str, Any] = {
"initial": {
"contract": ct,
"size": 0,
"price": "0",
"tif": "ioc",
"text": text,
"reduce_only": True,
"close": True,
},
"trigger": trig,
}
c = GateFuturesLive(settings)
try:
resp = await c._signed("POST", f"{c._prefix}/price_orders", body_obj=body)
if not isinstance(resp, dict):
return {"response": resp}, None
return resp, None
except Exception as exc: # noqa: BLE001
return None, str(exc)
@@ -0,0 +1,49 @@
"""Gate 计划委托触发价:按合约 tick 对齐(仅标准库,可被离线测试直接导入)。"""
from __future__ import annotations
from decimal import ROUND_HALF_UP, Decimal
from typing import Any
def _parse_positive_decimal(raw: Any) -> Decimal | None:
if raw is None:
return None
s = str(raw).strip()
if not s:
return None
try:
t = Decimal(s)
except Exception:
return None
if t <= 0 or not t.is_finite():
return None
return t
def _trigger_price_tick(cdata: dict[str, Any]) -> Decimal | None:
"""Gate 合约最小价格跳动;优先 order_price_round,其次 mark_price_round(小币种字段齐全)。"""
for key in ("order_price_round", "mark_price_round"):
t = _parse_positive_decimal(cdata.get(key))
if t is not None:
return t
return None
def _decimal_plain_str(d: Decimal) -> str:
s = format(d, "f")
if "." in s:
s = s.rstrip("0").rstrip(".")
return s or "0"
def _format_trigger_price(price: float, tick: Decimal | None) -> str:
"""将信号里的浮点止盈/止损价对齐到合约 tick,避免 4752.700000000001 这类导致 price_orders 400。"""
p = Decimal(str(price))
if not p.is_finite():
raise ValueError("invalid trigger price")
if tick is not None and tick > 0:
q = (p / tick).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
snapped = q * tick
return _decimal_plain_str(snapped)
coarse = p.quantize(Decimal("1e-12"), rounding=ROUND_HALF_UP)
return _decimal_plain_str(coarse)
+708
View File
@@ -0,0 +1,708 @@
from __future__ import annotations
import csv
import hashlib
import io
import logging
import time
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import FastAPI, Header, HTTPException, Query, Request, status
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from starlette.middleware.sessions import SessionMiddleware
from .config import load_settings
from .executor import handle_signal
from .gate_history import (
_default_range_ts,
collect_orders_csv,
collect_trades_csv,
fetch_my_trades_timerange,
fetch_orders_list,
)
from .gate_operations import (
cancel_plan_price_order,
list_futures_positions,
list_open_price_orders,
market_close_futures_position,
post_reduce_close_price_order,
post_test_market_order,
read_futures_balance,
)
from .models_signal import TradeSignal
from .models_test import GateTestRequest
from .positions import PositionBook
from .proxy_util import effective_proxy_url
from .breakeven_active_store import remove_active
from .breakeven_prefs_store import (
read_effective_global_enabled,
read_prefs_snapshot,
write_contract_enabled,
write_global_enabled,
)
from .breakeven_watcher import build_breakeven_state_for_api, start_breakeven_watcher, stop_breakeven_watcher
from .risk_prefs_store import read_effective_min_reward_risk_ratio, write_min_reward_risk_ratio
from .signal_history import SignalHistory
from .signal_metrics import augment_signal_result, compute_signal_stream_metrics
from .signal_repository import SignalRepository
from .stats import build_dashboard_stats
from .wecom_notify import notify_manual_close, notify_signal_db_insert_failed, notify_signal_execution
settings = load_settings()
book = PositionBook(settings.risk.max_open_positions)
signal_history = SignalHistory()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
logger = logging.getLogger(__name__)
root_dir = Path(__file__).resolve().parent.parent
templates = Jinja2Templates(directory=str(root_dir / "templates"))
# 信号流:每条 POST /v1/signal 写入 SQLite;配置里 path 留空时会在 DatabaseConfig 中回退为 ./runtime/signals.sqlite
signal_repo: SignalRepository | None = SignalRepository.from_settings(
settings.database.sqlite_path,
root_dir,
)
if signal_repo:
try:
signal_repo.init_schema()
except Exception: # noqa: BLE001
logger.exception("signal_db_init_failed")
signal_repo = None
def _hash_password(plain: str) -> str:
return hashlib.sha256(plain.encode("utf-8")).hexdigest()
def _asset_version() -> str:
mt = 0
for name in ("exec.js", "style.css", "theme-matrix-terminal.css"):
try:
mt = max(mt, int((root_dir / "static" / name).stat().st_mtime))
except OSError:
continue
return str(mt or 1)
def _password_hash() -> str:
return _hash_password(settings.auth.password)
class LoginBody(BaseModel):
username: str = Field(..., min_length=1)
password: str = Field(..., min_length=1)
class CancelPlanOrderBody(BaseModel):
order_id: str = Field(..., min_length=1, description="price_orders 的 id 或 id_string")
class ClosePositionBody(BaseModel):
contract: str = Field(..., min_length=3, max_length=64, description="如 BTC_USDT")
class ManualPriceOrderBody(BaseModel):
contract: str = Field(..., min_length=3, max_length=64)
trigger_price: str = Field(..., min_length=1, max_length=32)
rule: int = Field(1, ge=1, le=2, description="Gate:1 为价格>=触发价,2 为价格<=触发价")
class RiskPrefsBody(BaseModel):
min_reward_risk_ratio: float = Field(..., ge=0.1, le=50.0, description="面板保存的最低盈亏比")
class BreakevenPrefsBody(BaseModel):
global_enabled: bool | None = Field(None, description="全局移动保本开关")
contract: str | None = Field(None, min_length=3, max_length=64)
enabled: bool | None = Field(None, description="单合约覆盖;需同时传 contract")
@asynccontextmanager
async def _lifespan(_app: FastAPI):
log_path = Path(settings.app.log_file)
log_path.parent.mkdir(parents=True, exist_ok=True)
p = effective_proxy_url(settings.proxy.enabled, settings.proxy.url)
logger.info(
"executor %s:%s dry_run=%s max_positions=%s proxy=%s wecom=%s",
settings.app.host,
settings.app.port,
settings.gate.dry_run,
settings.risk.max_open_positions,
"on" if p else "off",
"on" if (settings.wecom.enabled and (settings.wecom.webhook_url or "").strip()) else "off",
)
start_breakeven_watcher(settings, signal_repo)
try:
yield
finally:
await stop_breakeven_watcher()
app = FastAPI(title="Gate Order Executor", version="0.2.0", lifespan=_lifespan)
app.add_middleware(
SessionMiddleware,
secret_key=settings.app.session_secret,
max_age=60 * 60 * 24 * 7,
same_site="lax",
https_only=False,
)
app.mount("/static", StaticFiles(directory=str(root_dir / "static")), name="static")
def _session_ok(request: Request) -> bool:
if not settings.auth.enabled:
return True
return request.session.get("logged_in") is True
def _require_ui_session(request: Request) -> None:
if not _session_ok(request):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="login required")
def _norm_contract_hist(contract: str | None) -> str | None:
if not contract:
return None
c = str(contract).strip()
return c.upper() if c else None
def _resolve_timerange(from_ts: int | None, to_ts: int | None) -> tuple[int, int]:
"""未传 from/to 时用最近 7 天;只传其一则补齐另一端。"""
now = int(time.time())
f, t = from_ts, to_ts
if f is None and t is None:
return _default_range_ts()
if f is None:
tt = int(t or now)
return tt - 86400 * 7, tt
if t is None:
ff = int(f)
return ff, now
return int(f), int(t)
@app.get("/health")
async def health() -> dict:
p = effective_proxy_url(settings.proxy.enabled, settings.proxy.url)
return {
"ok": True,
"dry_run": settings.gate.dry_run,
"open_slots": book.count(),
"proxy_on": bool(p),
"signal_db": bool(signal_repo),
"signals_persisted": bool(signal_repo),
"signals_sqlite_path": (settings.database.sqlite_path or "").strip(),
}
@app.get("/", response_model=None)
async def root(request: Request) -> RedirectResponse:
if settings.auth.enabled and not _session_ok(request):
return RedirectResponse("/login", status_code=302)
return RedirectResponse("/dashboard", status_code=302)
@app.get("/login", response_model=None)
async def login_page(request: Request) -> HTMLResponse | RedirectResponse:
if not settings.auth.enabled:
return RedirectResponse("/dashboard", status_code=302)
if _session_ok(request):
return RedirectResponse("/dashboard", status_code=302)
return templates.TemplateResponse(
request,
"login.html",
{"asset_version": _asset_version()},
)
@app.post("/login", response_model=None)
async def login_post(request: Request, body: LoginBody) -> JSONResponse | RedirectResponse:
if not settings.auth.enabled:
return JSONResponse({"ok": True, "redirect": "/dashboard"})
if body.username.strip() != settings.auth.username.strip() or _hash_password(body.password) != _password_hash():
return JSONResponse({"ok": False, "detail": "账号或密码错误"}, status_code=401)
request.session["logged_in"] = True
return JSONResponse({"ok": True, "redirect": "/dashboard"})
@app.get("/logout", response_model=None)
async def logout(request: Request) -> RedirectResponse:
request.session.clear()
return RedirectResponse("/login" if settings.auth.enabled else "/", status_code=302)
@app.get("/dashboard", response_model=None)
async def dashboard(request: Request) -> HTMLResponse | RedirectResponse:
if settings.auth.enabled and not _session_ok(request):
return RedirectResponse("/login", status_code=302)
return templates.TemplateResponse(
request,
"dashboard.html",
{
"username": settings.auth.username if settings.auth.enabled else "local",
"asset_version": _asset_version(),
},
)
@app.get("/api/state")
async def api_state(request: Request) -> dict:
_require_ui_session(request)
p = effective_proxy_url(settings.proxy.enabled, settings.proxy.url)
fa: dict | None = None
fa_err: str | None = None
po: list[dict] | None = None
po_err: str | None = None
ex_pos: list[dict] | None = None
ex_pos_err: str | None = None
if settings.gate.api_key.strip() and settings.gate.api_secret.strip():
fa, fa_err = await read_futures_balance(settings)
po, po_err = await list_open_price_orders(settings)
ex_pos, ex_pos_err = await list_futures_positions(settings)
return {
"dry_run": settings.gate.dry_run,
"live_trading_enabled": (not settings.gate.dry_run)
and bool(settings.gate.api_key.strip() and settings.gate.api_secret.strip()),
"gate_api_configured": bool(settings.gate.api_key.strip() and settings.gate.api_secret.strip()),
"test_orders_enabled": bool(settings.gate.test_orders_enabled),
"test_max_contracts": int(settings.gate.test_max_contracts),
"futures_account": fa,
"futures_account_error": fa_err,
"open_price_orders": po,
"open_price_orders_error": po_err,
"proxy": {
"enabled": settings.proxy.enabled,
"effective": bool(p),
"url": settings.proxy.url if settings.proxy.enabled else "",
},
"risk": {
"risk_per_trade_frac": settings.risk.risk_per_trade_frac,
"max_open_positions": settings.risk.max_open_positions,
"scheme": settings.risk.scheme,
"min_reward_risk_ratio": read_effective_min_reward_risk_ratio(settings),
"min_reward_risk_ratio_default": float(settings.risk.min_reward_risk_ratio),
},
"breakeven": await build_breakeven_state_for_api(
settings,
exchange_positions=ex_pos if isinstance(ex_pos, list) else None,
),
"positions": {
"open_slot_count": book.count(),
"exchange": ex_pos,
"exchange_error": ex_pos_err,
},
"recent_signals": _recent_signals_for_state(),
"signals_persisted": bool(signal_repo),
"signals_sqlite_path": (settings.database.sqlite_path or "").strip(),
}
def _recent_signals_for_state() -> list[dict]:
if not signal_repo:
return signal_history.list_recent()
try:
return signal_repo.list_recent(100)
except Exception: # noqa: BLE001
logger.exception("signal_db_list_failed")
return signal_history.list_recent()
def _signals_export_rows(limit: int = 500) -> list[dict]:
if signal_repo:
try:
return signal_repo.list_recent(limit)
except Exception: # noqa: BLE001
logger.exception("signal_export_list_failed")
return signal_history.list_recent()
def _test_http_status(body: GateTestRequest, out: dict) -> int:
if body.action == "balance":
return 502 if out.get("error") else 200
return 400 if out.get("ok") is False else 200
async def _run_gate_test(body: GateTestRequest) -> dict:
if body.action == "balance":
data, err = await read_futures_balance(settings)
return {"action": "balance", "balance": data, "error": err}
if body.action != "micro_market":
return {"ok": False, "error": "unsupported_action"}
if not body.contract.strip():
return {"ok": False, "error": "contract_required"}
return await post_test_market_order(
settings,
contract=body.contract.strip(),
side=body.side,
size_qty=body.size,
)
@app.post("/api/test")
async def api_test(request: Request, body: GateTestRequest) -> JSONResponse:
_require_ui_session(request)
out = await _run_gate_test(body)
return JSONResponse(out, status_code=_test_http_status(body, out))
@app.post("/api/risk-prefs")
async def api_risk_prefs(request: Request, body: RiskPrefsBody) -> JSONResponse:
"""面板保存最低盈亏比到 runtime/risk_prefs.json(需登录会话)。"""
_require_ui_session(request)
try:
v = write_min_reward_risk_ratio(body.min_reward_risk_ratio)
except ValueError as exc:
return JSONResponse({"ok": False, "detail": str(exc)}, status_code=400)
return JSONResponse({"ok": True, "min_reward_risk_ratio": v})
@app.post("/api/breakeven-prefs")
async def api_breakeven_prefs(request: Request, body: BreakevenPrefsBody) -> JSONResponse:
"""保存移动保本全局/单合约开关到 runtime/breakeven_prefs.json。"""
_require_ui_session(request)
out: dict[str, Any] = {"ok": True}
if body.global_enabled is not None:
out["global_enabled"] = write_global_enabled(bool(body.global_enabled))
if body.contract is not None and body.enabled is not None:
try:
ct, en = write_contract_enabled(body.contract, bool(body.enabled))
except ValueError as exc:
return JSONResponse({"ok": False, "detail": str(exc)}, status_code=400)
remove_active(ct)
out["contract"] = ct
out["enabled"] = en
if body.global_enabled is None and (body.contract is None or body.enabled is None):
return JSONResponse({"ok": False, "detail": "nothing_to_save"}, status_code=400)
out["global_enabled"] = read_effective_global_enabled(settings)
out["prefs"] = read_prefs_snapshot()
return JSONResponse(out)
@app.post("/api/positions/market_close")
async def api_positions_market_close(request: Request, body: ClosePositionBody) -> JSONResponse:
_require_ui_session(request)
ct = body.contract.strip().upper()
order, err = await market_close_futures_position(settings, contract=ct)
if err:
bad_req = {
"missing_api_keys",
"dry_run_enabled",
"no_position",
"invalid_close_size",
}
code = 400 if err in bad_req else 502
try:
await notify_manual_close(settings, contract=ct, ok=False, detail=err, order=None)
except Exception: # noqa: BLE001
logger.exception("wecom_notify_manual_close_failed")
return JSONResponse({"ok": False, "detail": err}, status_code=code)
book.release(ct)
remove_active(ct)
try:
await notify_manual_close(settings, contract=ct, ok=True, detail=None, order=order if isinstance(order, dict) else None)
except Exception: # noqa: BLE001
logger.exception("wecom_notify_manual_close_failed")
return JSONResponse({"ok": True, "order": order})
@app.post("/api/price_orders/manual")
async def api_price_orders_manual(request: Request, body: ManualPriceOrderBody) -> JSONResponse:
_require_ui_session(request)
resp, err = await post_reduce_close_price_order(
settings,
contract=body.contract.strip().upper(),
trigger_price=body.trigger_price.strip(),
rule=body.rule,
)
if err:
bad_req = {
"missing_api_keys",
"dry_run_enabled",
"invalid_trigger_price",
"invalid_rule",
}
code = 400 if err in bad_req else 502
return JSONResponse({"ok": False, "detail": err}, status_code=code)
return JSONResponse({"ok": True, "price_order": resp})
@app.post("/api/price_orders/cancel")
async def api_cancel_plan_order(request: Request, body: CancelPlanOrderBody) -> JSONResponse:
_require_ui_session(request)
ok, err = await cancel_plan_price_order(settings, body.order_id)
if ok:
return JSONResponse({"ok": True})
return JSONResponse({"ok": False, "detail": err or "cancel_failed"}, status_code=400)
@app.get("/api/stats/summary")
async def api_stats_summary(
request: Request,
contract: str | None = None,
) -> dict:
"""正式统计:日/周/月(上海 08:00 统计日)基于 Gate 历史平仓 position_close 的 pnl 聚合。"""
_require_ui_session(request)
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "api keys not configured")
c = _norm_contract_hist(contract)
return await build_dashboard_stats(settings, contract=c)
@app.get("/api/gate/trades")
async def api_gate_trades(
request: Request,
contract: str | None = None,
from_ts: int | None = Query(default=None, alias="from"),
to_ts: int | None = Query(default=None, alias="to"),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
) -> dict:
"""成交:Gate `GET /futures/{{settle}}/my_trades_timerange`(与面板「下载」同源)。"""
_require_ui_session(request)
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "api keys not configured")
f, t = _resolve_timerange(from_ts, to_ts)
c = _norm_contract_hist(contract)
rows, err = await fetch_my_trades_timerange(
settings, contract=c, from_ts=f, to_ts=t, limit=limit, offset=offset
)
return {
"source": "gate",
"endpoint": "futures/my_trades_timerange",
"contract": c,
"from": f,
"to": t,
"limit": limit,
"offset": offset,
"rows": rows,
"error": err,
}
@app.get("/api/gate/orders_history")
async def api_gate_orders_history(
request: Request,
status: str = Query("finished"),
contract: str | None = None,
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
) -> dict:
"""委托列表:Gate `GET /futures/{{settle}}/orders`status=open|finished)。"""
_require_ui_session(request)
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "api keys not configured")
st = status.strip().lower()
if st not in ("open", "finished"):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "status must be open or finished")
c = _norm_contract_hist(contract)
rows, err = await fetch_orders_list(
settings, status=st, contract=c, limit=limit, offset=offset
)
return {
"source": "gate",
"endpoint": "futures/orders",
"status": st,
"contract": c,
"limit": limit,
"offset": offset,
"rows": rows,
"error": err,
}
@app.get("/api/gate/trades.csv", response_model=None)
async def api_gate_trades_csv(
request: Request,
contract: str | None = None,
from_ts: int | None = Query(default=None, alias="from"),
to_ts: int | None = Query(default=None, alias="to"),
max_rows: int = Query(2000, ge=1, le=5000, alias="max"),
) -> Response:
_require_ui_session(request)
f, t = _resolve_timerange(from_ts, to_ts)
c = _norm_contract_hist(contract)
csv_text, err = await collect_trades_csv(
settings, contract=c, from_ts=f, to_ts=t, max_rows=max_rows
)
if err:
return JSONResponse({"detail": err}, status_code=502)
if csv_text is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "api keys not configured")
return Response(
content=csv_text.encode("utf-8"),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="gate_futures_trades.csv"'},
)
@app.get("/api/gate/orders_history.csv", response_model=None)
async def api_gate_orders_history_csv(
request: Request,
status: str = Query("finished"),
contract: str | None = None,
max_rows: int = Query(2000, ge=1, le=5000, alias="max"),
) -> Response:
_require_ui_session(request)
if not (settings.gate.api_key.strip() and settings.gate.api_secret.strip()):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "api keys not configured")
st = status.strip().lower()
if st not in ("open", "finished"):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "status must be open or finished")
c = _norm_contract_hist(contract)
csv_text, err = await collect_orders_csv(
settings, status=st, contract=c, max_rows=max_rows
)
if err:
return JSONResponse({"detail": err}, status_code=502)
if csv_text is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "api keys not configured")
fn = "gate_futures_orders_open.csv" if st == "open" else "gate_futures_orders_finished.csv"
return Response(
content=csv_text.encode("utf-8"),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{fn}"'},
)
@app.get("/api/signals/export.csv", response_model=None)
async def api_signals_export_csv(request: Request) -> Response:
"""面板「信号流」导出;需登录会话。"""
_require_ui_session(request)
rows = _signals_export_rows(500)
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(
[
"ts_unix",
"time_utc",
"signal_id",
"contract",
"side",
"reference_price_used",
"take_profit_display",
"stop_loss_display",
"reward_risk_ratio",
"result_status",
"result_reason",
]
)
for row in rows:
s = row.get("signal") or {}
r = row.get("result") or {}
ts = row.get("ts")
try:
ts_f = float(ts) if ts is not None else 0.0
except (TypeError, ValueError):
ts_f = 0.0
tstr = datetime.fromtimestamp(ts_f, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
rp = r.get("reference_price_used")
if rp is None:
rp = r.get("reference_entry")
tp = r.get("take_profit_display")
if tp is None:
tp = r.get("take_profit_price_sent")
if tp is None:
tp = s.get("take_profit")
sl = r.get("stop_loss_display")
if sl is None:
sl = r.get("stop_loss_price_sent")
if sl is None:
sl = s.get("stop_loss")
rr = r.get("reward_risk_ratio")
w.writerow(
[
f"{ts_f:.6f}",
tstr,
s.get("signal_id") or "",
s.get("contract") or "",
s.get("side") or "",
"" if rp is None else rp,
"" if tp is None else tp,
"" if sl is None else sl,
"" if rr is None else rr,
r.get("status") or "",
r.get("reason") or "",
]
)
return Response(
content=buf.getvalue().encode("utf-8-sig"),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="signal_stream.csv"'},
)
@app.post("/v1/test")
async def v1_test(
body: GateTestRequest,
x_webhook_secret: str | None = Header(default=None, alias="X-Webhook-Secret"),
) -> JSONResponse:
expected = (settings.security.webhook_secret or "").strip()
if not expected or (x_webhook_secret or "").strip() != expected:
raise HTTPException(status_code=401, detail="invalid webhook secret")
out = await _run_gate_test(body)
return JSONResponse(out, status_code=_test_http_status(body, out))
@app.post("/v1/signal")
async def post_signal(
body: TradeSignal,
x_webhook_secret: str | None = Header(default=None, alias="X-Webhook-Secret"),
) -> JSONResponse:
expected = (settings.security.webhook_secret or "").strip()
if not expected or (x_webhook_secret or "").strip() != expected:
raise HTTPException(status_code=401, detail="invalid webhook secret")
min_rr = read_effective_min_reward_risk_ratio(settings)
try:
m = await compute_signal_stream_metrics(settings, body, prior=None)
except Exception as exc: # noqa: BLE001
logger.warning("signal_metrics_pre_gate failed signal_id=%s: %s", body.signal_id, exc)
out = {
"status": "skipped",
"reason": "reward_risk_missing",
"min_reward_risk_ratio": min_rr,
"reward_risk_reason": "metrics_failed",
"metrics_error": str(exc),
}
else:
rr = m.get("reward_risk_ratio")
if rr is None:
out = {"status": "skipped", "reason": "reward_risk_missing", "min_reward_risk_ratio": min_rr}
out.update(m)
elif float(rr) < min_rr:
out = {"status": "skipped", "reason": "reward_risk_below_min", "min_reward_risk_ratio": min_rr}
out.update(m)
else:
out = await handle_signal(settings, book, body)
out = await augment_signal_result(settings, body, out)
code = 200 if out.get("status") in {"accepted", "skipped"} else 500
signal_history.push({"signal": body.model_dump(), "result": out})
if signal_repo:
try:
signal_repo.insert_run(body.model_dump(), out, code)
except Exception as exc: # noqa: BLE001
logger.exception("signal_db_insert_failed")
try:
await notify_signal_db_insert_failed(
settings,
signal_id=str(body.signal_id or ""),
detail=str(exc),
)
except Exception: # noqa: BLE001
logger.exception("wecom_notify_signal_db_failed")
try:
await notify_signal_execution(settings, signal=body.model_dump(), result=out, http_status=code)
except Exception: # noqa: BLE001
logger.exception("wecom_notify_signal_failed")
return JSONResponse(out, status_code=code)
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
Side = Literal["long", "short"]
class TradeSignal(BaseModel):
"""
扫描端在 TRIGGER(且你允许自动执行)时 POST 的载荷。
TP/SL 对应推送里「方案 A」已算好的价格;执行器永远按 scheme A 使用本字段。
"""
signal_id: str = Field(..., description="幂等键,建议 uuid 或 交易对+确认K时间戳")
contract: str = Field(..., description="Gate 永续合约名,如 BTC_USDT、XAU_USDT")
side: Side
take_profit: float = Field(..., gt=0, description="方案 A 止盈价")
stop_loss: float = Field(..., gt=0, description="方案 A 止损价")
# 可选:扫描端带的确认收盘价,用于日志与复核;市价单以成交为准
reference_price: float | None = Field(None, gt=0, description="如确认K收盘价")
+14
View File
@@ -0,0 +1,14 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
class GateTestRequest(BaseModel):
"""面板 / Webhook 测试请求体。"""
action: Literal["balance", "micro_market"] = "balance"
contract: str = Field("", description="如 BTC_USDTmicro_market 时必填")
side: Literal["long", "short"] = "long"
size: int = Field(1, ge=1, le=30, description="张数绝对值,服务端再与 test_max_contracts 取小")
+143
View File
@@ -0,0 +1,143 @@
from __future__ import annotations
import asyncio
import logging
import time
from typing import Any
from .config import Settings
from .gate_futures_live import (
GateFuturesLive,
cancel_price_triggered_order,
fetch_net_position_size,
)
from .wecom_notify import notify_oco_cancel_failed
logger = logging.getLogger(__name__)
_POLL_SEC = 18.0
_MAX_AGE_SEC = 604800.0 # 与计划单 expiration 同量级,超时丢弃避免列表泄漏
_pending: list[dict[str, Any]] = []
_lock = asyncio.Lock()
_task: asyncio.Task[None] | None = None
def _live_ok(settings: Settings) -> bool:
g = settings.gate
return (not g.dry_run) and bool(g.api_key.strip() and g.api_secret.strip())
async def update_oco_sl_order_id(settings: Settings, *, contract: str, new_sl_id: str | int) -> None:
"""移动保本改挂新 SL 后,同步 OCO 清理队列中的 sl_id。"""
ct = contract.strip().upper()
nid = new_sl_id
async with _lock:
for row in _pending:
if str(row.get("contract") or "").strip().upper() != ct:
continue
row["sl_id"] = nid
logger.info("oco_sl_id_updated contract=%s new_sl_id=%s", ct, nid)
async def register_tp_sl_oco_cleanup(
settings: Settings,
*,
contract: str,
tp_order: dict[str, Any],
sl_order: dict[str, Any],
) -> None:
"""
登记一笔开仓挂出的止盈/止损计划单。当该合约净持仓为 0 时,尝试撤销两条计划单(另一腿未触发则清掉)。
"""
if not settings.risk.oco_cleanup_enabled:
return
if not _live_ok(settings):
return
tp_id = tp_order.get("id") if tp_order.get("id") is not None else tp_order.get("id_string")
sl_id = sl_order.get("id") if sl_order.get("id") is not None else sl_order.get("id_string")
if tp_id is None or sl_id is None:
return
row = {
"settings": settings,
"contract": contract.strip().upper(),
"tp_id": tp_id,
"sl_id": sl_id,
"t": time.monotonic(),
}
async with _lock:
_pending.append(row)
_ensure_loop()
def _ensure_loop() -> None:
global _task
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
if _task is not None and not _task.done():
return
_task = loop.create_task(_poll_loop(), name="oco_price_order_cleanup")
async def _poll_loop() -> None:
while True:
await asyncio.sleep(_POLL_SEC)
try:
await _tick()
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001
logger.exception("oco_watcher_tick_failed")
async def _tick() -> None:
async with _lock:
rows = list(_pending)
if not rows:
return
now = time.monotonic()
keep: list[dict[str, Any]] = []
for row in rows:
settings: Settings = row["settings"]
if not _live_ok(settings):
continue
if now - float(row["t"]) > _MAX_AGE_SEC:
logger.info("oco_watch_expired contract=%s", row["contract"])
continue
client = GateFuturesLive(settings)
contract = row["contract"]
try:
net = await fetch_net_position_size(client, contract)
except Exception as exc: # noqa: BLE001
logger.warning("oco_fetch_position_failed contract=%s: %s", contract, exc)
keep.append(row)
continue
if abs(net) > 1e-12:
keep.append(row)
continue
tp_id, sl_id = row["tp_id"], row["sl_id"]
cleanup_failed = False
for name, oid in (("tp", tp_id), ("sl", sl_id)):
try:
ok = await cancel_price_triggered_order(client, oid)
if ok:
logger.info("oco_cancelled contract=%s leg=%s order_id=%s", contract, name, oid)
except Exception as exc: # noqa: BLE001
logger.warning("oco_cancel_failed contract=%s leg=%s id=%s: %s", contract, name, oid, exc)
cleanup_failed = True
try:
await notify_oco_cancel_failed(
settings,
contract=contract,
leg=name,
order_id=str(oid),
detail=str(exc),
)
except Exception: # noqa: BLE001
logger.exception("wecom_notify_oco_failed")
if cleanup_failed:
keep.append(row)
async with _lock:
_pending[:] = keep + [r for r in _pending if r not in rows]
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
import threading
import time
from dataclasses import dataclass, field
@dataclass
class OpenSlot:
contract: str
signal_id: str
opened_at: float = field(default_factory=time.time)
class PositionBook:
"""进程内占位:实盘应对接交易所持仓或本地持久化。"""
def __init__(self, max_positions: int) -> None:
self._max = max_positions
self._lock = threading.Lock()
self._slots: dict[str, OpenSlot] = {}
def count(self) -> int:
with self._lock:
return len(self._slots)
def has_contract(self, contract: str) -> bool:
c = contract.strip().upper()
with self._lock:
return c in self._slots
def try_reserve(self, contract: str, signal_id: str) -> bool:
c = contract.strip().upper()
with self._lock:
if c in self._slots:
return False
if len(self._slots) >= self._max:
return False
self._slots[c] = OpenSlot(contract=c, signal_id=signal_id)
return True
def release(self, contract: str) -> None:
c = contract.strip().upper()
with self._lock:
self._slots.pop(c, None)
def sync_from_exchange(self, open_contracts: set[str]) -> None:
"""移除本地有占位但交易所已无持仓的合约(避免槽位永久占满)。"""
with self._lock:
for c in list(self._slots.keys()):
if c not in open_contracts:
self._slots.pop(c, None)
+39
View File
@@ -0,0 +1,39 @@
"""与 onchain_scout_gate 相同的代理 URL 处理,供 httpx 出站(Gate 私有 API 等)。"""
from __future__ import annotations
import httpx
def httpx_proxy_url(proxy_url: str | None) -> str | None:
"""
将配置中的代理地址转为 httpx 可用形式。
``socks5h://`` 在部分环境下会报 Unknown scheme,退化为 ``socks5://``。
"""
if not proxy_url or not str(proxy_url).strip():
return None
u = str(proxy_url).strip()
if u.startswith("socks5h://"):
return "socks5://" + u[len("socks5h://") :]
return u
def effective_proxy_url(proxy_enabled: bool, proxy_url: str | None) -> str | None:
if not proxy_enabled:
return None
return httpx_proxy_url(proxy_url.strip() if proxy_url else None)
def httpx_client_kwargs(
proxy_enabled: bool,
proxy_url: str | None,
*,
timeout_connect: float = 10.0,
timeout_read: float = 16.0,
) -> dict:
"""与扫描端 Gate 客户端一致的出站策略:有代理则 trust_env=False。"""
timeout = httpx.Timeout(timeout_connect, read=timeout_read)
p = effective_proxy_url(proxy_enabled, proxy_url)
if p:
return {"timeout": timeout, "proxy": p, "trust_env": False}
return {"timeout": timeout, "trust_env": True}
@@ -0,0 +1,63 @@
"""面板可写的风险偏好:持久化到 runtime/risk_prefs.json。"""
from __future__ import annotations
import json
import logging
import threading
from pathlib import Path
from typing import Any
from .config import Settings
logger = logging.getLogger(__name__)
_ROOT = Path(__file__).resolve().parent.parent
_PREFS_PATH = _ROOT / "runtime" / "risk_prefs.json"
_lock = threading.Lock()
def _read_json_file(path: Path) -> dict[str, Any] | None:
if not path.is_file():
return None
try:
raw = path.read_text(encoding="utf-8").strip()
if not raw:
return None
data = json.loads(raw)
return data if isinstance(data, dict) else None
except (OSError, json.JSONDecodeError) as exc:
logger.warning("risk_prefs_read_failed: %s", exc)
return None
def read_effective_min_reward_risk_ratio(settings: Settings) -> float:
"""优先 runtime 文件,否则 risk.min_reward_risk_ratioconfig 默认)。"""
base = float(settings.risk.min_reward_risk_ratio)
with _lock:
data = _read_json_file(_PREFS_PATH)
if not data:
return base
try:
v = float(data.get("min_reward_risk_ratio"))
except (TypeError, ValueError):
return base
lo, hi = 0.1, 50.0
if not (lo <= v <= hi):
return base
return v
def write_min_reward_risk_ratio(value: float) -> float:
"""写入并返回规范化后的值(与读侧范围一致)。"""
lo, hi = 0.1, 50.0
v = float(value)
if not (lo <= v <= hi):
raise ValueError(f"min_reward_risk_ratio must be in [{lo}, {hi}]")
payload = json.dumps({"min_reward_risk_ratio": v}, indent=2, ensure_ascii=False) + "\n"
with _lock:
_PREFS_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = _PREFS_PATH.with_suffix(".json.tmp")
tmp.write_text(payload, encoding="utf-8")
tmp.replace(_PREFS_PATH)
return v
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
import threading
import time
from collections import deque
class SignalHistory:
"""最近信号与执行结果(内存,进程重启清空)。"""
def __init__(self, maxlen: int = 100) -> None:
self._q: deque[dict] = deque(maxlen=maxlen)
self._lock = threading.Lock()
def push(self, item: dict) -> None:
with self._lock:
self._q.appendleft({**item, "ts": time.time()})
def list_recent(self) -> list[dict]:
with self._lock:
return list(self._q)
+100
View File
@@ -0,0 +1,100 @@
"""信号流展示:现价、按合约 tick 对齐的 TP/SL 字符串、盈亏比(相对现价)。"""
from __future__ import annotations
import logging
from typing import Any
from .config import Settings
from .gate_futures_live import GateFuturesLive, _float
from .gate_price_rounding import _format_trigger_price, _trigger_price_tick
from .models_signal import TradeSignal
logger = logging.getLogger(__name__)
def _reward_risk_ratio(side: str, p: float, tp: float, sl: float) -> tuple[float | None, str | None]:
if not (p > 0 and tp > 0 and sl > 0):
return None, "invalid_prices"
if side == "long":
reward = tp - p
risk = p - sl
elif side == "short":
reward = p - tp
risk = sl - p
else:
return None, "invalid_side"
if risk <= 0:
return None, "non_positive_risk"
if reward <= 0:
return None, "non_positive_reward"
return reward / risk, None
async def compute_signal_stream_metrics(
settings: Settings, sig: TradeSignal, prior: dict[str, Any] | None = None
) -> dict[str, Any]:
"""
现价:优先信号 reference_price;否则 ticker last;再否则 prior.reference_entry(实盘已算 entry)。
止盈/止损展示:与下单相同的 tick 对齐字符串。
"""
client = GateFuturesLive(settings)
contract = sig.contract.strip().upper()
ticker = await client._public_get(f"{client._prefix}/tickers", params={"contract": contract})
last = 0.0
if isinstance(ticker, list) and ticker:
last = _float(ticker[0].get("last"))
elif isinstance(ticker, dict):
last = _float(ticker.get("last"))
p: float | None = None
if sig.reference_price is not None and float(sig.reference_price) > 0:
p = float(sig.reference_price)
elif last > 0:
p = last
elif prior:
ref_e = prior.get("reference_entry")
if ref_e is not None:
try:
pe = float(ref_e)
except (TypeError, ValueError):
pe = 0.0
if pe > 0:
p = pe
cdata = await client._public_get(f"{client._prefix}/contracts/{contract}")
if not isinstance(cdata, dict):
raise ValueError("contract_not_found")
tick = _trigger_price_tick(cdata)
tp_s = _format_trigger_price(float(sig.take_profit), tick)
sl_s = _format_trigger_price(float(sig.stop_loss), tick)
rr: float | None = None
rr_reason: str | None = None
if p is not None and p > 0:
try:
tp_f = float(tp_s)
sl_f = float(sl_s)
except ValueError:
rr_reason = "invalid_trigger_float"
else:
rr, rr_reason = _reward_risk_ratio(str(sig.side), p, tp_f, sl_f)
return {
"reference_price_used": float(p) if p is not None and p > 0 else None,
"take_profit_display": tp_s,
"stop_loss_display": sl_s,
"reward_risk_ratio": round(rr, 6) if rr is not None else None,
"reward_risk_reason": rr_reason,
}
async def augment_signal_result(settings: Settings, sig: TradeSignal, result: dict[str, Any]) -> dict[str, Any]:
out = dict(result)
try:
m = await compute_signal_stream_metrics(settings, sig, prior=out)
out.update(m)
except Exception as exc: # noqa: BLE001
logger.warning("signal_metrics failed signal_id=%s: %s", sig.signal_id, exc)
return out
@@ -0,0 +1,204 @@
"""信号与执行结果 SQLite 落库(标准库 sqlite3)。"""
from __future__ import annotations
import json
import logging
import sqlite3
import threading
import time
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
def _gate_order_id(obj: Any) -> str | None:
if not isinstance(obj, dict):
return None
oid = obj.get("id")
if oid is None:
oid = obj.get("order_id")
if oid is None:
return None
s = str(oid).strip()
return s or None
def _resolve_sqlite_path(raw: str, root: Path) -> Path:
p = Path(raw.strip())
if not p.is_absolute():
p = (root / p).resolve()
return p
class SignalRepository:
"""线程安全;每条 POST /v1/signal 处理完成后写入一行。"""
def __init__(self, sqlite_path: Path) -> None:
self._path = sqlite_path
self._lock = threading.Lock()
@classmethod
def from_settings(cls, sqlite_path_cfg: str, root: Path) -> SignalRepository | None:
"""配置了非空 ``sqlite_path`` 即落库;空字符串则仅内存环形表(与旧版一致)。"""
raw = (sqlite_path_cfg or "").strip()
if not raw:
return None
return cls(_resolve_sqlite_path(raw, root))
def init_schema(self) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
with self._lock:
con = sqlite3.connect(self._path, check_same_thread=False)
try:
con.execute(
"""
CREATE TABLE IF NOT EXISTS signal_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at REAL NOT NULL,
http_status INTEGER NOT NULL,
signal_id TEXT NOT NULL,
contract TEXT NOT NULL,
side TEXT NOT NULL,
take_profit REAL,
stop_loss REAL,
reference_price REAL,
signal_json TEXT NOT NULL,
result_status TEXT NOT NULL,
result_mode TEXT,
result_reason TEXT,
result_detail TEXT,
stage TEXT,
market_order_id TEXT,
take_profit_order_id TEXT,
stop_loss_order_id TEXT,
result_json TEXT NOT NULL
)
"""
)
con.execute(
"CREATE INDEX IF NOT EXISTS idx_signal_runs_signal_id ON signal_runs(signal_id)"
)
con.execute(
"CREATE INDEX IF NOT EXISTS idx_signal_runs_created_at ON signal_runs(created_at)"
)
con.commit()
finally:
con.close()
def insert_run(self, signal: dict[str, Any], result: dict[str, Any], http_status: int) -> None:
created = time.time()
sig_json = json.dumps(signal, ensure_ascii=False, separators=(",", ":"))
res_json = json.dumps(result, ensure_ascii=False, separators=(",", ":"))
market_obj = result.get("market_order") or result.get("order")
detail_raw = result.get("detail")
if detail_raw is None:
detail_s: str | None = None
elif isinstance(detail_raw, str):
detail_s = detail_raw
else:
try:
detail_s = json.dumps(detail_raw, ensure_ascii=False)
except Exception:
detail_s = str(detail_raw)
row = (
created,
int(http_status),
str(signal.get("signal_id") or ""),
str(signal.get("contract") or "").strip().upper(),
str(signal.get("side") or ""),
signal.get("take_profit"),
signal.get("stop_loss"),
signal.get("reference_price"),
sig_json,
str(result.get("status") or ""),
result.get("mode"),
result.get("reason"),
detail_s,
result.get("stage") if isinstance(result.get("stage"), str) else None,
_gate_order_id(market_obj),
_gate_order_id(result.get("take_profit_order")),
_gate_order_id(result.get("stop_loss_order")),
res_json,
)
with self._lock:
con = sqlite3.connect(self._path, check_same_thread=False)
try:
con.execute(
"""
INSERT INTO signal_runs (
created_at, http_status, signal_id, contract, side,
take_profit, stop_loss, reference_price, signal_json,
result_status, result_mode, result_reason, result_detail, stage,
market_order_id, take_profit_order_id, stop_loss_order_id, result_json
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""",
row,
)
con.commit()
finally:
con.close()
def find_latest_accepted_for_contract(self, contract: str) -> dict[str, Any] | None:
ct = str(contract or "").strip().upper()
if not ct:
return None
with self._lock:
con = sqlite3.connect(self._path, check_same_thread=False)
try:
cur = con.execute(
"""
SELECT created_at, signal_json, result_json
FROM signal_runs
WHERE contract = ? AND result_status = 'accepted'
ORDER BY id DESC
LIMIT 1
""",
(ct,),
)
row = cur.fetchone()
finally:
con.close()
if not row:
return None
created_at, sig_j, res_j = row
try:
sig = json.loads(sig_j)
except Exception:
sig = {}
try:
res = json.loads(res_j)
except Exception:
res = {}
return {"ts": float(created_at), "signal": sig, "result": res}
def list_recent(self, limit: int = 100) -> list[dict[str, Any]]:
lim = max(1, min(int(limit), 500))
with self._lock:
con = sqlite3.connect(self._path, check_same_thread=False)
try:
cur = con.execute(
"""
SELECT created_at, signal_json, result_json
FROM signal_runs
ORDER BY id DESC
LIMIT ?
""",
(lim,),
)
rows = cur.fetchall()
finally:
con.close()
out: list[dict[str, Any]] = []
for created_at, sig_j, res_j in rows:
try:
sig = json.loads(sig_j)
except Exception:
sig = {}
try:
res = json.loads(res_j)
except Exception:
res = {}
out.append({"ts": float(created_at), "signal": sig, "result": res})
return out
+315
View File
@@ -0,0 +1,315 @@
from __future__ import annotations
import time
from datetime import date, datetime, timedelta
from typing import Any
from zoneinfo import ZoneInfo
from .config import Settings
from .gate_history import collect_position_close_rows
def _parse_official_start_iso(s: str) -> float:
raw = (s or "").strip()
if not raw:
raise ValueError("stats.official_start is empty")
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
dt = datetime.fromisoformat(raw)
if dt.tzinfo is None:
raise ValueError("stats.official_start must include a timezone offset (e.g. +08:00)")
return dt.timestamp()
def _position_close_ts(row: dict[str, Any]) -> float | None:
"""Gate `GET .../position_close` 行的平仓时间戳(字段 `time`)。"""
t = row.get("time")
if t is None:
return None
try:
return float(t)
except (TypeError, ValueError):
return None
def _float_field(row: dict[str, Any], *keys: str) -> float | None:
for k in keys:
v = row.get(k)
if v is None or v == "":
continue
try:
return float(v)
except (TypeError, ValueError):
return None
return None
def _row_close_pnl(row: dict[str, Any]) -> float | None:
"""历史平仓记录上的净盈亏(字符串数值)。"""
return _float_field(row, "pnl", "realised_pnl", "realized_pnl")
def stat_day_date(ts: float, tz: ZoneInfo) -> date:
dt = datetime.fromtimestamp(ts, tz=tz)
shifted = dt - timedelta(hours=8)
return shifted.date()
def stat_month_key(ts: float, tz: ZoneInfo) -> tuple[int, int]:
dt = datetime.fromtimestamp(ts, tz=tz)
shifted = dt - timedelta(hours=8)
return shifted.year, shifted.month
def stat_day_window(d: date, tz: ZoneInfo) -> tuple[float, float]:
start = datetime(d.year, d.month, d.day, 8, 0, 0, tzinfo=tz)
end = start + timedelta(days=1)
return start.timestamp(), end.timestamp()
def month_window(y: int, m: int, tz: ZoneInfo) -> tuple[float, float]:
start = datetime(y, m, 1, 8, 0, 0, tzinfo=tz)
if m == 12:
end = datetime(y + 1, 1, 1, 8, 0, 0, tzinfo=tz)
else:
end = datetime(y, m + 1, 1, 8, 0, 0, tzinfo=tz)
return start.timestamp(), end.timestamp()
def monday_of_week(d: date) -> date:
return d - timedelta(days=d.weekday())
def aggregate_pnls(pnls_ordered: list[float]) -> dict[str, Any]:
if not pnls_ordered:
return {
"trade_count": 0,
"wins": 0,
"losses": 0,
"breakeven": 0,
"win_rate": None,
"profit_factor": None,
"gross_profit": 0.0,
"gross_loss": 0.0,
"net_pnl": 0.0,
"max_single_loss": None,
"max_drawdown": 0.0,
"max_consecutive_losses": 0,
}
wins = losses = be = 0
gp = 0.0
gl = 0.0
max_consec = 0
streak = 0
for p in pnls_ordered:
if p > 0:
wins += 1
gp += p
streak = 0
elif p < 0:
losses += 1
gl += p
streak += 1
max_consec = max(max_consec, streak)
else:
be += 1
streak = 0
total = len(pnls_ordered)
win_rate = wins / total if total else None
gross_loss_abs = abs(gl)
if gross_loss_abs > 1e-12:
profit_factor: float | None = gp / gross_loss_abs
else:
profit_factor = None
min_p = min(pnls_ordered)
max_single_loss = min_p if min_p < -1e-12 else None
eq = 0.0
peak = 0.0
mdd = 0.0
for p in pnls_ordered:
eq += p
peak = max(peak, eq)
mdd = max(mdd, peak - eq)
return {
"trade_count": total,
"wins": wins,
"losses": losses,
"breakeven": be,
"win_rate": win_rate,
"profit_factor": profit_factor,
"gross_profit": gp,
"gross_loss": gl,
"net_pnl": sum(pnls_ordered),
"max_single_loss": max_single_loss,
"max_drawdown": mdd,
"max_consecutive_losses": max_consec,
}
def _detect_pnl_field(rows: list[dict[str, Any]]) -> str:
for r in rows:
if _float_field(r, "pnl") is not None:
return "pnl"
if _float_field(r, "realised_pnl", "realized_pnl") is not None:
return "realised_pnl"
return "missing"
def _pnls_for_stat_day(events: list[tuple[float, float]], d: date, tz: ZoneInfo, official_ts: float) -> list[float]:
out: list[tuple[float, float]] = []
for ts, pnl in events:
if ts < official_ts:
continue
if stat_day_date(ts, tz) != d:
continue
out.append((ts, pnl))
out.sort(key=lambda x: x[0])
return [p for _, p in out]
def _pnls_for_week(
events: list[tuple[float, float]], mon: date, sun: date, tz: ZoneInfo, official_ts: float
) -> list[float]:
out: list[tuple[float, float]] = []
for ts, pnl in events:
if ts < official_ts:
continue
dd = stat_day_date(ts, tz)
if dd < mon or dd > sun:
continue
out.append((ts, pnl))
out.sort(key=lambda x: x[0])
return [p for _, p in out]
def _pnls_for_month(
events: list[tuple[float, float]], y: int, m: int, tz: ZoneInfo, official_ts: float
) -> list[float]:
out: list[tuple[float, float]] = []
for ts, pnl in events:
if ts < official_ts:
continue
if stat_month_key(ts, tz) != (y, m):
continue
out.append((ts, pnl))
out.sort(key=lambda x: x[0])
return [p for _, p in out]
async def build_dashboard_stats(
settings: Settings,
*,
contract: str | None,
) -> dict[str, Any]:
try:
tz = ZoneInfo(settings.stats.timezone)
except Exception as exc: # noqa: BLE001
return {"ok": False, "error": f"invalid stats.timezone: {exc}"}
try:
official_ts = _parse_official_start_iso(settings.stats.official_start)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
now = time.time()
cap = int(settings.stats.max_trade_rows)
rows, err = await collect_position_close_rows(
settings,
contract=contract,
from_ts=int(official_ts),
to_ts=int(now),
max_rows=cap,
)
if err:
return {"ok": False, "error": err}
if rows is None:
return {"ok": False, "error": "api keys not configured"}
missing_pnl = 0
events: list[tuple[float, float]] = []
for row in rows:
ts = _position_close_ts(row)
if ts is None or ts < official_ts:
continue
pnl = _row_close_pnl(row)
if pnl is None:
missing_pnl += 1
continue
events.append((ts, pnl))
events.sort(key=lambda x: x[0])
pnl_field = _detect_pnl_field(rows)
truncated = len(rows) >= cap
d_cur = stat_day_date(now, tz)
day_start, day_end = stat_day_window(d_cur, tz)
mon, sun = monday_of_week(d_cur), monday_of_week(d_cur) + timedelta(days=6)
my, mm = stat_month_key(now, tz)
m_start, m_end = month_window(my, mm, tz)
def pack_period(
*,
bucket: str,
label: str,
start_ts: float,
end_ts: float,
pnls: list[float],
) -> dict[str, Any]:
partial = now < end_ts
metrics = aggregate_pnls(pnls)
return {
"bucket": bucket,
"label": label,
"start_ts": start_ts,
"end_ts": end_ts,
"partial": partial,
"metrics": metrics,
}
day_pnls = _pnls_for_stat_day(events, d_cur, tz, official_ts)
week_pnls = _pnls_for_week(events, mon, sun, tz, official_ts)
month_pnls = _pnls_for_month(events, my, mm, tz, official_ts)
return {
"ok": True,
"timezone": settings.stats.timezone,
"official_start": settings.stats.official_start,
"official_start_ts": official_ts,
"now_ts": now,
"contract": contract,
"pnl_field": pnl_field,
"closing_rows_missing_pnl": missing_pnl,
"fetched_position_close_rows": len(rows),
"fetched_trade_rows": len(rows),
"truncated": truncated,
"definitions": {
"unit": "每条 Gate 历史平仓(GET /futures/{settle}/position_close)且能解析到 pnl 的记录,按平仓 time 排序后做序列指标。",
"win_rate": "盈利笔数 / 总笔数(含盈亏为 0)。",
"profit_factor": "毛利和 / |毛亏和|;若无亏损则 null。",
"max_single_loss": "单笔最小 pnl(最负的一笔),无亏损为 null。",
"max_drawdown": "按时间累加 pnl 的权益曲线,相对历史峰值的 max(peakequity)。",
"max_consecutive_losses": "连续 pnl<0 的最大笔数。",
"day": "统计日 [D 08:00, D+1 08:00) 上海;D=(本地时刻−8h) 的日历日。",
"week": "自然周周一至周日(上海日历),聚合落在该周内的平仓记录。",
"month": "自然月 [当月1日08:00, 次月1日08:00) 上海,与统计日对齐。",
},
"day": pack_period(
bucket="day",
label=d_cur.isoformat(),
start_ts=day_start,
end_ts=day_end,
pnls=day_pnls,
),
"week": pack_period(
bucket="week",
label=f"{mon.isoformat()}_{sun.isoformat()}",
start_ts=stat_day_window(mon, tz)[0],
end_ts=stat_day_window(sun, tz)[1],
pnls=week_pnls,
),
"month": pack_period(
bucket="month",
label=f"{my:04d}-{mm:02d}",
start_ts=m_start,
end_ts=m_end,
pnls=month_pnls,
),
}
+181
View File
@@ -0,0 +1,181 @@
"""企业微信群机器人:仅推送执行器侧执行结果(策略/发现类仍由扫描端)。"""
from __future__ import annotations
import logging
import time
from typing import Any
import httpx
from .config import Settings
from .proxy_util import httpx_client_kwargs
logger = logging.getLogger(__name__)
_MAX_MD_LEN = 3500
_oco_last_sent: dict[str, float] = {}
_OCO_COOLDOWN_SEC = 600.0
def _wecom_ready(settings: Settings) -> str | None:
w = settings.wecom
if not w.enabled:
return None
url = (w.webhook_url or "").strip()
return url or None
def _clip(s: str, n: int = 800) -> str:
t = str(s).replace("\r\n", "\n").strip()
if len(t) > n:
return t[: n - 1] + ""
return t
async def _post_markdown(settings: Settings, title: str, body_lines: list[str]) -> None:
url = _wecom_ready(settings)
if not url:
return
text = "\n".join([f"## {_clip(title, 120)}", ""] + body_lines)
if len(text) > _MAX_MD_LEN:
text = text[: _MAX_MD_LEN - 20] + "\n…(truncated)"
payload = {"msgtype": "markdown", "markdown": {"content": text}}
kw = httpx_client_kwargs(settings.proxy.enabled, settings.proxy.url, timeout_connect=6.0, timeout_read=12.0)
try:
async with httpx.AsyncClient(**kw) as client:
r = await client.post(url, json=payload)
r.raise_for_status()
data = r.json()
if isinstance(data, dict) and int(data.get("errcode") or 0) != 0:
logger.warning("wecom_api_err: %s", data)
except Exception: # noqa: BLE001
logger.exception("wecom_post_failed")
async def notify_signal_execution(
settings: Settings,
*,
signal: dict[str, Any],
result: dict[str, Any],
http_status: int,
) -> None:
"""每条 POST /v1/signal 处理结束后推送摘要。"""
if not _wecom_ready(settings):
return
st = str(result.get("status") or "")
title = "执行器 · 信号结果"
if st == "accepted":
title += " · accepted"
elif st == "skipped":
title += " · skipped"
else:
title += " · error"
lines = [
f">signal_id: `{_clip(str(signal.get('signal_id') or ''), 80)}`",
f">contract: **{_clip(str(signal.get('contract') or ''), 32)}** side: `{_clip(str(signal.get('side') or ''), 8)}`",
f">http: **{http_status}** mode: `{_clip(str(result.get('mode') or ''), 20)}`",
f">status: **{st}**",
]
if result.get("reason"):
lines.append(f">reason: `{_clip(str(result.get('reason')), 200)}`")
if result.get("stage"):
lines.append(f">stage: `{_clip(str(result.get('stage')), 40)}`")
if result.get("detail"):
lines.append(f">detail: `{_clip(str(result.get('detail')), 500)}`")
if result.get("sized_contracts") is not None:
lines.append(f">size: `{_clip(str(result.get('sized_contracts')), 40)}`")
if result.get("market_order") and isinstance(result.get("market_order"), dict):
mo = result["market_order"]
oid = mo.get("id") or mo.get("order_id")
if oid is not None:
lines.append(f">market_order_id: `{oid}`")
try:
await _post_markdown(settings, title, lines)
except Exception: # noqa: BLE001
logger.exception("notify_signal_execution_failed")
async def notify_manual_close(
settings: Settings,
*,
contract: str,
ok: bool,
detail: str | None,
order: dict[str, Any] | None,
) -> None:
"""面板一键市价全平结果。"""
if not _wecom_ready(settings):
return
title = "执行器 · 一键平仓 · 成功" if ok else "执行器 · 一键平仓 · 失败"
lines = [f">contract: **{_clip(contract, 32)}**"]
if detail:
lines.append(f">detail: `{_clip(detail, 400)}`")
if ok and isinstance(order, dict):
oid = order.get("id") or order.get("order_id")
if oid is not None:
lines.append(f">order_id: `{oid}`")
try:
await _post_markdown(settings, title, lines)
except Exception: # noqa: BLE001
logger.exception("notify_manual_close_failed")
async def notify_oco_cancel_failed(
settings: Settings,
*,
contract: str,
leg: str,
order_id: str,
detail: str,
) -> None:
"""OCO 清理撤另一腿失败:带冷却,避免 18s 轮询刷屏。"""
if not _wecom_ready(settings):
return
key = f"{contract}:{leg}:{order_id}"
now = time.time()
if now - _oco_last_sent.get(key, 0.0) < _OCO_COOLDOWN_SEC:
return
_oco_last_sent[key] = now
if len(_oco_last_sent) > 500:
_oco_last_sent.clear()
title = "执行器 · OCO 撤单异常"
lines = [
f">contract: **{_clip(contract, 32)}**",
f">leg: `{_clip(leg, 8)}` price_order_id: `{_clip(order_id, 40)}`",
f">detail: `{_clip(detail, 500)}`",
]
try:
await _post_markdown(settings, title, lines)
except Exception: # noqa: BLE001
logger.exception("notify_oco_cancel_failed_post")
async def notify_breakeven_failed(settings: Settings, *, contract: str, detail: str) -> None:
"""移动保本改挂止损失败(仅失败推送)。"""
if not _wecom_ready(settings):
return
title = "执行器 · 移动保本失败"
lines = [
f">contract: **{_clip(contract, 32)}**",
f">detail: `{_clip(detail, 500)}`",
]
try:
await _post_markdown(settings, title, lines)
except Exception: # noqa: BLE001
logger.exception("notify_breakeven_failed_post")
async def notify_signal_db_insert_failed(settings: Settings, *, signal_id: str, detail: str) -> None:
"""SQLite 落库失败(HTTP 仍返回信号结果时单独告警)。"""
if not _wecom_ready(settings):
return
title = "执行器 · 信号落库失败"
lines = [
f">signal_id: `{_clip(signal_id, 80)}`",
f">detail: `{_clip(detail, 600)}`",
]
try:
await _post_markdown(settings, title, lines)
except Exception: # noqa: BLE001
logger.exception("notify_signal_db_insert_failed_post")