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