Files
QuantDinger/backend_api_python/app/services/trading_executor.py
T
TIANHE 3a1be9c458 v5.0.2
Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
2026-07-19 12:50:19 +08:00

1416 lines
61 KiB
Python

"""Strategy API V2 live execution supervisor."""
from __future__ import annotations
import json
import math
import os
import re
import threading
import time
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple
import pandas as pd
from app.data_sources import DataSourceFactory
from app.services.fundamental_data import get_fundamental_data_service
from app.services.script_source import get_script_source_service
from app.services.strategy_runtime.health import record_runtime_heartbeat
from app.services.strategy_runtime.identity import ensure_strategy_run, finish_strategy_run
from app.services.strategy_runtime.state import RuntimeStateStore
from app.services.strategy_v2 import (
OrderIntent,
StrategyV2BacktestService,
StrategyV2LiveSession,
compile_strategy_v2,
)
from app.services.strategy_v2.live_execution import LiveOrderRequest, StrategyV2OrderGateway
from app.utils.db import get_db_connection
from app.utils.logger import get_logger
from app.utils.strategy_runtime_logs import append_strategy_log
logger = get_logger(__name__)
MIN_LIVE_ORDER_NOTIONAL = 1.0
def live_history_days(frequency: str, warmup_bars: int) -> int:
"""Return a frequency-aware live lookback with a small startup buffer."""
value = str(frequency or "1d").strip().lower()
match = re.fullmatch(r"(\d+)\s*(m|h|d|w)", value)
if not match:
return 30
count = max(1, int(match.group(1)))
unit_seconds = {"m": 60, "h": 3600, "d": 86400, "w": 604800}[match.group(2)]
bars = max(10, max(1, int(warmup_bars or 0)) * 3)
return max(1, int(math.ceil(count * unit_seconds * bars / 86400)))
class TradingExecutor:
"""Own worker threads and run the single supported strategy runtime."""
def __init__(self) -> None:
self.running_strategies: dict[int, threading.Thread] = {}
self.lock = threading.Lock()
self.max_threads = max(1, int(os.getenv("STRATEGY_MAX_THREADS", "64")))
self.order_gateway = StrategyV2OrderGateway()
self._last_start_failure = ""
self._last_exit_reason: dict[int, str] = {}
def start_strategy(self, strategy_id: int) -> bool:
strategy_id = int(strategy_id)
with self.lock:
self._discard_dead_threads()
self._last_start_failure = ""
if strategy_id in self.running_strategies:
self._last_start_failure = "Strategy is already running."
return False
if len(self.running_strategies) >= self.max_threads:
self._last_start_failure = f"Thread limit reached ({self.max_threads})."
return False
try:
self._preflight_live_strategy(strategy_id)
except Exception as exc:
self._last_start_failure = str(exc or "strategyV2.livePreflightFailed")
logger.warning("Strategy %s live preflight rejected: %s", strategy_id, exc)
return False
thread = threading.Thread(
target=self._run_strategy_loop,
args=(strategy_id,),
name=f"strategy-{strategy_id}",
daemon=True,
)
self.running_strategies[strategy_id] = thread
try:
thread.start()
except Exception as exc:
self.running_strategies.pop(strategy_id, None)
self._last_start_failure = f"Failed to start strategy thread: {exc}"
logger.exception("Failed to start strategy %s", strategy_id)
return False
append_strategy_log(strategy_id, "info", "Strategy execution thread started")
return True
def _preflight_live_strategy(self, strategy_id: int) -> None:
strategy = self._load_strategy(int(strategy_id))
if not strategy:
raise RuntimeError("strategyV2.strategyNotFound")
execution_mode = str(strategy.get("execution_mode") or "signal").strip().lower()
if execution_mode != "live":
return
user_id = int(strategy.get("user_id") or 0)
from app.services.strategy_live_guard import (
find_live_strategy_conflict,
live_conflict_message,
resolve_strategy_direction_mode,
)
trading_config = _json_object(strategy.get("trading_config"))
market_type = str(
strategy.get("market_type")
or trading_config.get("market_type")
or "spot"
).strip().lower()
if market_type in {"future", "futures", "perp", "perpetual"}:
market_type = "swap"
if market_type != "swap":
conflict = find_live_strategy_conflict(strategy, user_id)
if conflict:
raise RuntimeError(live_conflict_message(conflict))
return
direction_mode = resolve_strategy_direction_mode(strategy)
bot_type = str(trading_config.get("bot_type") or "").strip().lower()
bot_params = (
trading_config.get("bot_params")
if isinstance(trading_config.get("bot_params"), dict)
else {}
)
grid_direction = str(
bot_params.get("gridDirection") or bot_params.get("grid_direction") or ""
).strip().lower()
neutral_grid = bot_type == "grid" and grid_direction == "neutral"
if not direction_mode:
raise RuntimeError("strategyV2.directionModeRequired")
from app.services.exchange_execution import resolve_exchange_config
from app.services.grid.exchange_requirements import detect_hedge_position_mode
from app.services.live_trading.factory import create_client
exchange_config = resolve_exchange_config(
_json_object(strategy.get("exchange_config")),
user_id=user_id,
)
client = create_client(exchange_config, market_type=market_type)
is_hedge, label = detect_hedge_position_mode(
client,
symbol=str(strategy.get("symbol") or trading_config.get("symbol") or ""),
market_type=market_type,
exchange_config=exchange_config,
)
owns_both_legs = direction_mode in {"both", "neutral"} or neutral_grid
if owns_both_legs and is_hedge is not True:
raise RuntimeError(f"strategyV2.dualDirectionHedgeModeRequired:{label}")
if is_hedge is not True:
if is_hedge is None:
raise RuntimeError(f"strategyV2.hedgeModeUnknown:{label}")
conflict = find_live_strategy_conflict(
strategy,
user_id,
allow_opposite_leg=is_hedge is True and not owns_both_legs,
)
if conflict:
raise RuntimeError(live_conflict_message(conflict))
def wait_strategy_running(self, strategy_id: int, timeout: float = 3.0) -> Tuple[bool, str]:
strategy_id = int(strategy_id)
deadline = time.monotonic() + max(0.5, float(timeout))
while time.monotonic() < deadline:
with self.lock:
thread = self.running_strategies.get(strategy_id)
alive = bool(thread and thread.is_alive())
if not alive:
return False, self._last_exit_reason.pop(strategy_id, "") or "Strategy runtime exited during startup."
time.sleep(0.1)
return True, ""
def is_running(self, strategy_id: int) -> bool:
with self.lock:
self._discard_dead_threads()
thread = self.running_strategies.get(int(strategy_id))
return bool(thread and thread.is_alive())
def stop_strategy(self, strategy_id: int, *, persist_status: bool = True) -> bool:
strategy_id = int(strategy_id)
try:
if persist_status:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"UPDATE qd_strategies_trading SET status = 'stopped', updated_at = NOW() WHERE id = %s",
(strategy_id,),
)
db.commit()
cur.close()
with self.lock:
self.running_strategies.pop(strategy_id, None)
append_strategy_log(strategy_id, "info", "Strategy stop requested")
return True
except Exception as exc:
logger.exception("Failed to stop strategy %s", strategy_id)
self._last_exit_reason[strategy_id] = str(exc)
return False
def stop_strategy_with_policy(
self,
strategy_id: int,
*,
close_positions: bool = False,
) -> Dict[str, Any]:
"""Pause a strategy and optionally queue reduce-only closes for its owned legs."""
sid = int(strategy_id)
strategy = self._load_strategy(sid) or {}
positions: List[Dict[str, Any]] = []
run_id = 0
if close_positions:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT symbol, side, size, entry_price, current_price, market_type
FROM qd_strategy_positions
WHERE strategy_id = %s AND size > 0
ORDER BY symbol, side
""",
(sid,),
)
positions = [dict(row) for row in (cur.fetchall() or [])]
cur.execute(
"""
SELECT id
FROM strategy_runs
WHERE strategy_id = %s
ORDER BY CASE WHEN runtime_status IN ('running', 'recovering', 'paused') THEN 0 ELSE 1 END,
id DESC
LIMIT 1
""",
(sid,),
)
run_id = int((cur.fetchone() or {}).get("id") or 0)
cur.close()
stopped = self.stop_strategy(sid)
result: Dict[str, Any] = {
"success": bool(stopped),
"status": "stopped" if stopped else "running",
"close_requested": bool(close_positions),
"close_orders_queued": 0,
"close_errors": [],
}
if not stopped or not close_positions or not positions:
return result
if run_id <= 0:
result["success"] = False
result["close_errors"].append("strategyV2.closeRunIdentityMissing")
return result
trading_config = _json_object(strategy.get("trading_config"))
leverage = max(1.0, float(trading_config.get("leverage") or strategy.get("leverage") or 1.0))
notification_config = _json_object(strategy.get("notification_config"))
signal_ts = int(time.time())
for row in positions:
side = str(row.get("side") or "").strip().lower()
if side not in {"long", "short"}:
result["close_errors"].append("strategyV2.closePositionSideInvalid")
continue
price = float(row.get("current_price") or row.get("entry_price") or 0.0)
quantity = max(0.0, float(row.get("size") or 0.0))
if price <= 0 or quantity <= 0:
result["close_errors"].append("strategyV2.closePositionQuoteMissing")
continue
try:
pending_id = self.order_gateway.submit(LiveOrderRequest(
strategy_id=sid,
strategy_run_id=run_id,
user_id=int(strategy.get("user_id") or 0),
symbol=str(row.get("symbol") or ""),
action="close_long" if side == "long" else "close_short",
quantity=quantity,
reference_price=price,
signal_timestamp=signal_ts,
market_type=str(row.get("market_type") or strategy.get("market_type") or "swap"),
execution_mode="live",
leverage=leverage,
reason="user_stop_and_close",
notification_config=notification_config,
execution_algo="market",
order_type="market",
))
if pending_id:
result["close_orders_queued"] += 1
else:
result["close_errors"].append("strategyV2.closeOrderQueueFailed")
except Exception as exc:
logger.exception("Failed to queue stop-and-close for strategy %s", sid)
result["close_errors"].append(str(exc or "strategyV2.closeOrderQueueFailed"))
if result["close_errors"]:
result["success"] = False
return result
def _discard_dead_threads(self) -> None:
for strategy_id, thread in list(self.running_strategies.items()):
if not thread.is_alive():
self.running_strategies.pop(strategy_id, None)
def _run_strategy_loop(self, strategy_id: int) -> None:
current = threading.current_thread()
run_id = 0
exit_reason = "strategy stopped"
try:
strategy = self._load_strategy(strategy_id)
if not strategy:
raise RuntimeError("strategyV2.strategyNotFound")
source_id, code = self._load_source(strategy)
program = compile_strategy_v2(code)
user_id = int(strategy.get("user_id") or 0)
trading_config = _json_object(strategy.get("trading_config"))
exchange_config = _json_object(strategy.get("exchange_config"))
execution_mode = str(strategy.get("execution_mode") or "signal").strip().lower()
if execution_mode not in {"signal", "live"}:
raise RuntimeError("strategyV2.invalidExecutionMode")
if execution_mode == "live":
from app.services.exchange_execution import resolve_exchange_config
exchange_config = resolve_exchange_config(exchange_config, user_id=user_id)
service = StrategyV2BacktestService()
now = datetime.now(timezone.utc)
candidates, universe_id = service.resolve_candidates(
user_id=user_id,
manifest=program.manifest,
start_date=now - timedelta(days=7),
end_date=now,
)
account_exchange = str(
exchange_config.get("exchange_id") or exchange_config.get("exchangeId") or ""
).strip().lower()
if execution_mode == "live" and account_exchange:
for member in candidates:
if member.get("market") == "Crypto":
member["exchange_id"] = account_exchange
member["key"] = _member_key(member)
frequency = program.manifest.primary_frequency
history_days = live_history_days(
frequency,
int(program.manifest.warmup_bars or 0),
)
def fetch_frames() -> dict[str, pd.DataFrame]:
end = datetime.now(timezone.utc)
frames, skipped = service.fetch_frames(
candidates,
frequency,
end - timedelta(days=history_days),
end,
)
if skipped:
append_strategy_log(
strategy_id,
"warning",
f"Skipped {len(skipped)} instrument(s) without usable market data",
)
if program.manifest.fundamental_dependencies:
frames = get_fundamental_data_service().enrich_panel(frames, candidates)
service.validate_fundamental_dependencies(frames, program.manifest)
if not frames:
raise RuntimeError("strategyV2.noMarketData")
return frames
def resolve_universe(reference: str, timestamp: pd.Timestamp) -> list[str]:
del reference
if not universe_id:
return [str(item["key"]) for item in candidates]
members = service.universe_service.resolve_members(
user_id,
universe_id,
as_of=timestamp.date(),
)
return [_member_key(item) for item in members]
frames = fetch_frames()
runtime_price_client: Dict[str, Any] = {}
def runtime_prices() -> dict[str, float]:
if execution_mode != "live":
return self._live_prices(candidates)
return self._execution_account_prices(
candidates,
exchange_config,
runtime_price_client,
)
if execution_mode == "live":
frames = self._align_latest_frame_prices(frames, runtime_prices())
initial_capital = float(
strategy.get("initial_capital") or trading_config.get("initial_capital") or 0
)
if initial_capital <= 0:
raise RuntimeError("strategyV2.invalidInitialCapital")
session = StrategyV2LiveSession(
code=code,
frames=frames,
initial_capital=initial_capital,
params=dict(trading_config.get("params") or {}),
universe_resolver=resolve_universe,
schedule_timezone=self._load_schedule_timezone(user_id),
)
primary = candidates[0]
runtime_run = ensure_strategy_run(
strategy_id=strategy_id,
user_id=user_id,
code=code,
parameter_snapshot=trading_config,
source_version_id=str(source_id),
exchange_id=str(primary.get("exchange_id") or account_exchange),
credential_id=int(
exchange_config.get("credential_id") or 0
),
symbol=str(primary.get("symbol") or ""),
market_type=str(primary.get("market_type") or "spot"),
position_mode=str(trading_config.get("position_mode") or ""),
)
run_id = int(runtime_run.strategy_run_id or 0)
last_prices: dict[str, float] = {}
self._heartbeat(strategy_id, run_id, primary, last_prices, 0)
bot_type = str(
strategy.get("bot_type") or trading_config.get("bot_type") or ""
).strip().lower()
if execution_mode == "live" and bot_type == "grid":
self._run_grid_resting_loop(
strategy_id=strategy_id,
strategy_run_id=run_id,
current_thread=current,
strategy_name=str(strategy.get("strategy_name") or f"strategy_{strategy_id}"),
primary=primary,
candidates=candidates,
frames=frames,
trading_config=trading_config,
exchange_config=exchange_config,
initial_capital=initial_capital,
notification_config=_json_object(strategy.get("notification_config")),
)
exit_reason = "grid strategy stopped"
return
state_store = RuntimeStateStore(
strategy_id=strategy_id,
strategy_run_id=run_id,
state_key="protection",
)
session.restore_protection_snapshot(state_store.load())
signal_poll = max(1.0, min(30.0, float(trading_config.get("data_poll_seconds") or 5)))
risk_tick = max(0.25, min(5.0, float(trading_config.get("risk_tick_seconds") or 1)))
next_signal_poll = 0.0
consecutive_errors = 0
strategy_name = str(strategy.get("strategy_name") or f"strategy_{strategy_id}")
notification_config = _json_object(strategy.get("notification_config"))
leverage = max(1.0, float(trading_config.get("leverage") or strategy.get("leverage") or 1))
append_strategy_log(
strategy_id,
"info",
f"Strategy runtime ready: instruments={len(candidates)}, timeframe={frequency}, mode={execution_mode}",
)
while self._is_strategy_running(strategy_id, current):
cycle_started = time.monotonic()
try:
positions = self._positions_by_symbol(strategy_id, candidates)
session.synchronize_positions(positions)
last_prices.update(runtime_prices())
protection_intents = session.evaluate_protections(
last_prices,
timestamp=pd.Timestamp.now(tz="UTC"),
)
protected = {str(intent.symbol) for intent in protection_intents}
for intent in protection_intents:
self._execute_strategy_v2_intent(
strategy_id=strategy_id,
strategy_name=strategy_name,
intent=intent,
frames=frames,
candidates=candidates,
initial_capital=initial_capital,
leverage=leverage,
execution_mode=execution_mode,
notification_config=notification_config,
trading_config=trading_config,
exchange_config=exchange_config,
signal_ts=int(time.time()),
strategy_run_id=run_id,
current_price_override=last_prices.get(str(intent.symbol)),
)
pending_count = len(protection_intents)
if cycle_started >= next_signal_poll:
frames = fetch_frames()
if execution_mode == "live":
frames = self._align_latest_frame_prices(frames, runtime_prices())
intents, messages, timestamp = session.process(frames)
intents = [intent for intent in intents if str(intent.symbol) not in protected]
pending_count += len(intents)
for message in messages:
append_strategy_log(strategy_id, "info", message)
for intent in intents:
self._execute_strategy_v2_intent(
strategy_id=strategy_id,
strategy_name=strategy_name,
intent=intent,
frames=frames,
candidates=candidates,
initial_capital=initial_capital,
leverage=leverage,
execution_mode=execution_mode,
notification_config=notification_config,
trading_config=trading_config,
exchange_config=exchange_config,
signal_ts=self._intent_signal_timestamp(intent, timestamp),
strategy_run_id=run_id,
)
next_signal_poll = cycle_started + signal_poll
state_store.save(session.protection_snapshot())
self._heartbeat(
strategy_id,
run_id,
primary,
last_prices,
pending_count,
loop_latency_ms=int((time.monotonic() - cycle_started) * 1000),
)
consecutive_errors = 0
except Exception as exc:
consecutive_errors += 1
logger.exception("Strategy %s runtime cycle failed", strategy_id)
append_strategy_log(strategy_id, "error", f"Runtime cycle failed: {exc}")
self._heartbeat(
strategy_id,
run_id,
primary,
last_prices,
0,
loop_latency_ms=int((time.monotonic() - cycle_started) * 1000),
status="degraded",
last_error=str(exc),
)
if consecutive_errors >= 5:
raise RuntimeError(f"strategyV2.repeatedRuntimeFailure:{exc}") from exc
remaining = risk_tick - (time.monotonic() - cycle_started)
if remaining > 0:
time.sleep(remaining)
except Exception as exc:
exit_reason = str(exc)
self._last_exit_reason[strategy_id] = exit_reason
logger.exception("Strategy %s stopped after runtime failure", strategy_id)
append_strategy_log(strategy_id, "error", exit_reason)
self._mark_stopped(strategy_id)
finally:
if run_id > 0:
finish_strategy_run(run_id, reason=exit_reason)
with self.lock:
if self.running_strategies.get(strategy_id) is current:
self.running_strategies.pop(strategy_id, None)
def _execute_strategy_v2_intent(
self,
*,
strategy_id: int,
strategy_name: str,
intent: OrderIntent,
frames: Dict[str, pd.DataFrame],
candidates: List[Dict[str, Any]],
initial_capital: float,
leverage: float,
execution_mode: str,
notification_config: Dict[str, Any],
trading_config: Dict[str, Any],
exchange_config: Dict[str, Any],
signal_ts: int,
strategy_run_id: int = 0,
current_price_override: float | None = None,
) -> bool:
member = next(
(item for item in candidates if str(item.get("key") or "") == str(intent.symbol)),
None,
)
if not member:
raise RuntimeError(f"strategyV2.instrumentNotFound:{intent.symbol}")
frame = frames.get(str(intent.symbol))
price = float(current_price_override or 0)
if price <= 0 and frame is not None and not frame.empty:
price = float(frame["close"].iloc[-1])
if price <= 0:
raise RuntimeError(f"strategyV2.priceUnavailable:{intent.symbol}")
symbol = str(member.get("symbol") or "")
positions = self._get_current_positions(strategy_id, symbol)
intent_position_side = str(intent.position_side or "").strip().lower()
if intent_position_side in {"long", "short"}:
positions = [
item for item in positions
if str(item.get("side") or "").strip().lower() == intent_position_side
]
current_amount = sum(
(-1.0 if str(item.get("side") or "").lower() == "short" else 1.0)
* float(item.get("size") or 0)
for item in positions
)
market_type = str(member.get("market_type") or "spot").lower()
target_amount = self._target_amount(
intent,
current_amount,
initial_capital,
price,
leverage=leverage,
market_type=market_type,
)
if market_type == "spot" and target_amount < -1e-12:
raise RuntimeError("strategyV2.spotShortUnsupported")
closes_position = abs(target_amount) <= 1e-12 and abs(current_amount) > 1e-12
if abs(target_amount - current_amount) * price < MIN_LIVE_ORDER_NOTIONAL and not closes_position:
return False
requests = self._order_plan(current_amount, target_amount)
submitted = False
for action, quantity in requests:
submitted = bool(self._execute_signal(
strategy_id=strategy_id,
strategy_name=strategy_name,
symbol=symbol,
current_price=price,
signal_type=action,
script_base_qty=quantity,
current_positions=positions,
leverage=leverage,
initial_capital=initial_capital,
market_type=market_type,
market_category=str(member.get("market") or ""),
execution_mode=execution_mode,
notification_config=notification_config,
trading_config=trading_config,
exchange_config=exchange_config,
signal_reason=str(intent.reason or "strategy"),
order_type=str(intent.order_type or "market"),
execution_algo=str(intent.execution_algo or "market"),
limit_price=float(intent.limit_price or 0.0),
maker_wait_sec=float(intent.maker_wait_sec or 0.0),
maker_offset_bps=float(intent.maker_offset_bps or 0.0),
protection=intent.protection.metadata() if intent.protection else {},
signal_ts=signal_ts,
strategy_run_id=strategy_run_id,
price_exchange_id=str(member.get("exchange_id") or ""),
)) or submitted
return submitted
def _execute_signal(self, **values: Any) -> bool:
strategy_id = int(values["strategy_id"])
strategy = self._load_strategy(strategy_id) or {}
if str(values.get("execution_mode") or "signal").strip().lower() == "live":
from app.services.strategy_live_guard import validate_strategy_signal_direction
validate_strategy_signal_direction(strategy, values.get("signal_type"))
quantity = float(values.get("script_base_qty") or 0)
reference_price = float(values.get("current_price") or 0)
initial_capital = float(values.get("initial_capital") or 0)
leverage = float(values.get("leverage") or 1)
nominal_capacity = initial_capital * max(1.0, leverage)
entry_pct = ((quantity * reference_price) / nominal_capacity * 100.0) if nominal_capacity > 0 else 0.0
request = LiveOrderRequest(
strategy_id=strategy_id,
strategy_run_id=int(values.get("strategy_run_id") or 0),
user_id=int(strategy.get("user_id") or 0),
symbol=str(values.get("symbol") or ""),
action=str(values.get("signal_type") or ""),
quantity=quantity,
reference_price=reference_price,
signal_timestamp=int(values.get("signal_ts") or time.time()),
market_type=str(values.get("market_type") or "spot"),
execution_mode=str(values.get("execution_mode") or "signal"),
leverage=leverage,
reason=str(values.get("signal_reason") or ""),
notification_config=dict(values.get("notification_config") or {}),
order_type=str(values.get("order_type") or "market"),
execution_algo=str(values.get("execution_algo") or "market"),
limit_price=float(values.get("limit_price") or 0.0),
maker_wait_sec=float(values.get("maker_wait_sec") or 0.0),
maker_offset_bps=float(values.get("maker_offset_bps") or 0.0),
protection=dict(values.get("protection") or {}),
sizing={
"initial_capital": initial_capital,
"entry_pct": entry_pct,
"leverage": leverage,
"source": "strategy_v2",
},
)
pending_id = self.order_gateway.submit(request)
if pending_id:
append_strategy_log(
strategy_id,
"trade",
f"Order queued: {request.action} {request.symbol} quantity={request.quantity:.12f}",
)
return bool(pending_id)
def _run_grid_resting_loop(
self,
*,
strategy_id: int,
strategy_run_id: int,
current_thread: threading.Thread,
strategy_name: str,
primary: Dict[str, Any],
candidates: List[Dict[str, Any]],
frames: Dict[str, pd.DataFrame],
trading_config: Dict[str, Any],
exchange_config: Dict[str, Any],
initial_capital: float,
notification_config: Dict[str, Any],
) -> None:
from app.services.grid.runner import GridRestingRunner
from app.services.live_trading.account_configuration import configure_derivatives_account
from app.services.live_trading.factory import create_client
symbol = str(primary.get("symbol") or "")
market_type = str(primary.get("market_type") or "swap").strip().lower()
market_category = str(primary.get("market") or "Crypto")
exchange_id = str(primary.get("exchange_id") or exchange_config.get("exchange_id") or "")
leverage = max(1.0, float(trading_config.get("leverage") or 1))
margin_mode = str(
trading_config.get("margin_mode") or trading_config.get("marginMode") or "cross"
)
client_holder: Dict[str, Any] = {}
def create_grid_client():
client = client_holder.get("client")
if client is None:
client = create_client(exchange_config, market_type=market_type)
if market_type == "swap":
configure_derivatives_account(
client,
exchange_id=exchange_id,
symbol=symbol,
leverage=leverage,
margin_mode=margin_mode,
)
client_holder["client"] = client
return client
def enqueue_market(signal_type: str, quantity: float, price: float, reason: str) -> bool:
return self._execute_signal(
strategy_id=strategy_id,
strategy_name=strategy_name,
symbol=symbol,
current_price=float(price or 0),
signal_type=str(signal_type or ""),
script_base_qty=max(0.0, float(quantity or 0)),
leverage=leverage,
initial_capital=initial_capital,
market_type=market_type,
market_category=market_category,
execution_mode="live",
notification_config=notification_config,
trading_config=trading_config,
exchange_config=exchange_config,
signal_reason=str(reason or "grid"),
signal_ts=int(time.time()),
strategy_run_id=strategy_run_id,
price_exchange_id=exchange_id,
)
key = str(primary.get("key") or "")
frame = frames.get(key)
initial_prices = self._live_prices(candidates)
initial_price = float(initial_prices.get(key) or 0)
if initial_price <= 0 and frame is not None and not frame.empty:
initial_price = float(frame["close"].iloc[-1])
runtime_grid_config = self._materialize_grid_anchor(trading_config, initial_price)
runner = GridRestingRunner(
strategy_id,
symbol,
runtime_grid_config,
exchange_config,
user_id=int((self._load_strategy(strategy_id) or {}).get("user_id") or 1),
initial_capital=initial_capital,
enqueue_market_fn=enqueue_market,
create_client_fn=create_grid_client,
risk_exit_fn=lambda price: self._grid_bot_risk_exits(
strategy_id=strategy_id,
symbol=symbol,
current_price=float(price),
trading_config=runtime_grid_config,
timeframe_seconds=60,
initial_capital=initial_capital,
),
)
ok, message = runner.startup(initial_price, bars_df=frame)
if not ok:
raise RuntimeError(f"grid.startupFailed:{message}")
tick_seconds = max(0.25, min(5.0, float(trading_config.get("risk_tick_seconds") or 1)))
last_prices: dict[str, float] = {}
try:
while self._is_strategy_running(strategy_id, current_thread):
cycle_started = time.monotonic()
prices = self._live_prices(candidates)
current_price = float(prices.get(key) or 0)
if current_price > 0:
last_prices[key] = current_price
runner.tick(current_price, high=current_price, low=current_price, bars_df=frame)
self._heartbeat(
strategy_id,
strategy_run_id,
primary,
last_prices,
0,
loop_latency_ms=int((time.monotonic() - cycle_started) * 1000),
)
if runner.should_stop:
break
time.sleep(tick_seconds)
finally:
runner.shutdown()
@staticmethod
def _materialize_grid_anchor(
trading_config: Dict[str, Any],
initial_price: float,
) -> Dict[str, Any]:
runtime_config = dict(trading_config or {})
grid_params = (
dict(runtime_config.get("bot_params") or {})
if isinstance(runtime_config.get("bot_params"), dict)
else {}
)
if not bool(grid_params.get("dynamicAnchor")) or initial_price <= 0:
return runtime_config
lower_ratio = float(grid_params.get("lowerPrice") or 0.0)
upper_ratio = float(grid_params.get("upperPrice") or 0.0)
reference = (lower_ratio + upper_ratio) / 2.0
if lower_ratio <= 0 or upper_ratio <= 0 or reference <= 0:
return runtime_config
grid_params["lowerPrice"] = initial_price * lower_ratio / reference
grid_params["upperPrice"] = initial_price * upper_ratio / reference
grid_params["dynamicAnchor"] = False
runtime_config["bot_params"] = grid_params
return runtime_config
@staticmethod
def _to_ratio(value: Any) -> float:
try:
number = float(value)
except (TypeError, ValueError):
return 0.0
return min(1.0, max(0.0, number / 100.0))
@staticmethod
def _code_risk_settings(trading_config: Dict[str, Any]) -> tuple[Dict[str, Any], Dict[str, Any], str]:
config = trading_config if isinstance(trading_config, dict) else {}
code = config.get("_strategy_cfg_from_code")
code_config = code if isinstance(code, dict) else {}
risk = code_config.get("risk")
risk_config = risk if isinstance(risk, dict) else {}
return config, risk_config, str(code_config.get("exitOwner") or "engine").strip().lower()
@classmethod
def _risk_ratio(cls, config: Dict[str, Any], risk: Dict[str, Any], code_key: str, config_key: str) -> float:
if code_key in risk:
try:
return min(1.0, max(0.0, float(risk.get(code_key) or 0.0)))
except (TypeError, ValueError):
return 0.0
return cls._to_ratio(config.get(config_key))
def _server_side_stop_loss_signal(
self,
*,
strategy_id: int,
symbol: str,
current_price: float,
trading_config: Dict[str, Any],
timeframe_seconds: int = 60,
**_: Any,
):
config, risk, exit_owner = self._code_risk_settings(trading_config)
bot_type = str(config.get("bot_type") or "").strip().lower()
if bot_type in {"grid", "dca"}:
return None
if exit_owner != "engine" or config.get("enable_server_side_stop_loss") is False:
return None
stop_ratio = self._risk_ratio(config, risk, "stopLossPct", "stop_loss_pct")
price = float(current_price or 0.0)
if stop_ratio <= 0 or price <= 0:
return None
candle = int(time.time()) // max(1, int(timeframe_seconds or 60)) * max(1, int(timeframe_seconds or 60))
for position in self._get_current_positions(strategy_id, symbol):
side = str(position.get("side") or "").strip().lower()
entry = float(position.get("entry_price") or 0.0)
size = abs(float(position.get("size") or 0.0))
if side not in {"long", "short"} or entry <= 0 or size <= 0:
continue
adverse = (entry - price) / entry if side == "long" else (price - entry) / entry
if adverse + 1e-12 < stop_ratio:
continue
return {
"type": f"close_{side}",
"position_size": size,
"timestamp": candle,
"reason": "server_stop_loss",
"trigger_price": price,
"matched_entry_price": entry,
}
return None
def _server_side_take_profit_or_trailing_signal(
self,
*,
strategy_id: int,
symbol: str,
current_price: float,
trading_config: Dict[str, Any],
timeframe_seconds: int = 60,
**_: Any,
):
config, risk, exit_owner = self._code_risk_settings(trading_config)
if str(config.get("bot_type") or "").strip().lower() in {"grid", "dca"}:
return None
if exit_owner != "engine" or config.get("enable_server_side_take_profit") is False:
return None
price = float(current_price or 0.0)
if price <= 0:
return None
take_ratio = self._risk_ratio(config, risk, "takeProfitPct", "take_profit_pct")
trailing_data = risk.get("trailing") if isinstance(risk.get("trailing"), dict) else {}
trailing_enabled = bool(trailing_data.get("enabled", config.get("trailing_stop_enabled", False)))
trailing_ratio = (
min(1.0, max(0.0, float(trailing_data.get("pct") or 0.0)))
if "pct" in trailing_data
else self._to_ratio(config.get("trailing_stop_pct"))
)
activation_ratio = (
min(1.0, max(0.0, float(trailing_data.get("activationPct") or 0.0)))
if "activationPct" in trailing_data
else self._to_ratio(config.get("trailing_activation_pct"))
)
candle = int(time.time()) // max(1, int(timeframe_seconds or 60)) * max(1, int(timeframe_seconds or 60))
fee_rate = self._to_ratio(config.get("commission"))
from app.utils.risk_guard import trailing_exit_locks_net_profit
for position in self._get_current_positions(strategy_id, symbol):
side = str(position.get("side") or "").strip().lower()
entry = float(position.get("entry_price") or 0.0)
size = abs(float(position.get("size") or 0.0))
if side not in {"long", "short"} or entry <= 0 or size <= 0:
continue
high = max(float(position.get("highest_price") or 0.0), entry, price)
prior_low = float(position.get("lowest_price") or 0.0)
low = min(value for value in (prior_low, entry, price) if value > 0)
self._update_position(
strategy_id=strategy_id,
symbol=symbol,
side=side,
current_price=price,
highest_price=high,
lowest_price=low,
)
favorable = (price - entry) / entry if side == "long" else (entry - price) / entry
if take_ratio > 0 and favorable + 1e-12 >= take_ratio:
return {
"type": f"close_{side}",
"position_size": size,
"timestamp": candle,
"reason": "server_take_profit",
"trigger_price": price,
"matched_entry_price": entry,
}
if not trailing_enabled or trailing_ratio <= 0:
continue
peak_move = (high - entry) / entry if side == "long" else (entry - low) / entry
callback = (high - price) / high if side == "long" else (price - low) / low
if peak_move + 1e-12 < activation_ratio or callback + 1e-12 < trailing_ratio:
continue
if not trailing_exit_locks_net_profit(
side,
entry_price=entry,
exit_price=price,
fee_rate=fee_rate,
):
continue
return {
"type": f"close_{side}",
"position_size": size,
"timestamp": candle,
"reason": "server_trailing_stop",
"trigger_price": price,
"matched_entry_price": entry,
}
return None
@staticmethod
def _update_position(
*,
strategy_id: int,
symbol: str,
side: str,
current_price: float,
highest_price: float = 0.0,
lowest_price: float = 0.0,
) -> bool:
from app.services.live_trading.records import patch_position_markers
return patch_position_markers(
strategy_id=int(strategy_id),
symbol=str(symbol),
side=str(side),
current_price=float(current_price),
highest_price=float(highest_price or 0.0),
lowest_price=float(lowest_price or 0.0),
)
@staticmethod
def _ratio(value: Any, default: float = 0.0) -> float:
try:
number = float(value)
except (TypeError, ValueError):
return float(default)
return number / 100.0 if number > 1 else max(0.0, number)
def _grid_bot_risk_exits(
self,
strategy_id: int,
symbol: str,
current_price: float,
trading_config: Dict[str, Any],
timeframe_seconds: int,
initial_capital: Optional[float] = None,
) -> List[Dict[str, Any]]:
config = trading_config if isinstance(trading_config, dict) else {}
if str(config.get("bot_type") or "").strip().lower() not in {"grid", "dca"}:
return []
positions = self._get_current_positions(strategy_id, symbol)
long_open = any(
str(row.get("side") or "").lower() == "long" and float(row.get("size") or 0) > 0
for row in positions
)
short_open = any(
str(row.get("side") or "").lower() == "short" and float(row.get("size") or 0) > 0
for row in positions
)
if not long_open and not short_open:
return []
candle = int(time.time() // max(1, int(timeframe_seconds or 60))) * max(
1, int(timeframe_seconds or 60)
)
def close_all(reason: str, **extra: Any) -> List[Dict[str, Any]]:
result: List[Dict[str, Any]] = []
if long_open:
result.append({"type": "close_long", "position_size": 0, "timestamp": candle, "reason": reason, **extra})
if short_open:
result.append({"type": "close_short", "position_size": 0, "timestamp": candle, "reason": reason, **extra})
return result
params = config.get("bot_params") if isinstance(config.get("bot_params"), dict) else {}
upper = float(params.get("upperPrice") or params.get("upper_price") or 0)
lower = float(params.get("lowerPrice") or params.get("lower_price") or 0)
buffer_ratio = self._ratio(config.get("grid_oob_buffer_pct"), 0.05)
if upper > lower > 0 and current_price > 0 and buffer_ratio > 0:
if current_price >= upper * (1 + buffer_ratio):
return close_all("grid_out_of_bounds_up", oob_threshold=upper * (1 + buffer_ratio))
if current_price <= lower * (1 - buffer_ratio):
return close_all("grid_out_of_bounds_down", oob_threshold=lower * (1 - buffer_ratio))
capital = float(initial_capital or config.get("initial_capital") or 0)
stop_ratio = self._ratio(config.get("stop_loss_pct"), 0)
take_ratio = self._ratio(config.get("take_profit_pct"), 0)
if capital > 0 and (stop_ratio > 0 or take_ratio > 0):
equity = self._calculate_current_equity(
strategy_id,
capital,
current_positions=positions,
current_price=current_price,
symbol=symbol,
)
change = (equity - capital) / capital
if stop_ratio > 0 and change <= -stop_ratio:
return close_all("grid_equity_stop_loss", equity=equity, equity_pct=change)
if take_ratio > 0 and change >= take_ratio:
return close_all("grid_equity_take_profit", equity=equity, equity_pct=change)
return []
def _calculate_current_equity(
self,
strategy_id: int,
initial_capital: float,
current_positions: Optional[List[Dict[str, Any]]] = None,
current_price: Optional[float] = None,
symbol: str = "",
) -> float:
realized = 0.0
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT COALESCE(SUM(COALESCE(profit, 0) - COALESCE(commission_quote, 0)), 0) AS realized_pnl
FROM qd_strategy_trades WHERE strategy_id = %s
""",
(strategy_id,),
)
realized = float((cur.fetchone() or {}).get("realized_pnl") or 0)
cur.close()
except Exception as exc:
logger.warning("Failed to calculate realized PnL for strategy %s: %s", strategy_id, exc)
unrealized = 0.0
base_symbol = str(symbol or "").split(":", 1)[0]
for row in current_positions or []:
side = str(row.get("side") or "").lower()
size = float(row.get("size") or 0)
entry = float(row.get("entry_price") or 0)
mark = float(row.get("current_price") or 0)
if current_price and str(row.get("symbol") or "").split(":", 1)[0] == base_symbol:
mark = float(current_price)
if size <= 0 or entry <= 0 or mark <= 0:
continue
unrealized += (mark - entry) * size if side == "long" else (entry - mark) * size
return max(0.0, float(initial_capital or 0) + realized + unrealized)
@staticmethod
def _target_amount(
intent: OrderIntent,
current: float,
capital: float,
price: float,
*,
leverage: float = 1.0,
market_type: str = "spot",
) -> float:
notional_multiplier = (
max(1.0, float(leverage or 1.0))
if str(market_type or "").lower() != "spot"
else 1.0
)
if intent.kind == "quantity":
return current + float(intent.value)
if intent.kind == "value":
return current + float(intent.value) * notional_multiplier / price
if intent.kind == "target_quantity":
return float(intent.value)
if intent.kind == "target_value":
return float(intent.value) * notional_multiplier / price
if intent.kind == "target_percent":
return capital * float(intent.value) * notional_multiplier / price
raise RuntimeError(f"strategyV2.orderKindUnsupported:{intent.kind}")
@staticmethod
def _order_plan(current: float, target: float) -> list[tuple[str, float]]:
epsilon = 1e-12
if abs(target - current) <= epsilon:
return []
if current > epsilon and target < -epsilon:
return [("close_long", current), ("open_short", abs(target))]
if current < -epsilon and target > epsilon:
return [("close_short", abs(current)), ("open_long", target)]
if current > epsilon:
if target <= epsilon:
return [("close_long", current)]
delta = target - current
return [("add_long" if delta > 0 else "reduce_long", abs(delta))]
if current < -epsilon:
if target >= -epsilon:
return [("close_short", abs(current))]
delta = target - current
return [("add_short" if delta < 0 else "reduce_short", abs(delta))]
return [("open_long" if target > 0 else "open_short", abs(target))]
def _load_strategy(self, strategy_id: int) -> dict[str, Any] | None:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT * FROM qd_strategies_trading WHERE id = %s", (int(strategy_id),))
row = cur.fetchone()
cur.close()
if not isinstance(row, dict):
return None
for key in ("trading_config", "exchange_config", "notification_config"):
row[key] = _json_object(row.get(key))
return row
@staticmethod
def _load_schedule_timezone(user_id: int) -> str:
fallback = str(os.getenv("TZ") or "UTC").strip() or "UTC"
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"SELECT COALESCE(timezone, '') AS timezone FROM qd_users WHERE id = %s",
(int(user_id),),
)
row = cur.fetchone() or {}
cur.close()
return str(row.get("timezone") or fallback).strip() or fallback
except Exception:
return fallback
@staticmethod
def _intent_signal_timestamp(intent: OrderIntent, fallback: Any) -> int:
value = pd.Timestamp(intent.signal_time if intent.signal_time is not None else fallback)
if value.tzinfo is None:
value = value.tz_localize("UTC")
return int(value.timestamp())
@staticmethod
def _load_source(strategy: dict[str, Any]) -> tuple[int, str]:
trading_config = _json_object(strategy.get("trading_config"))
source_id = int(trading_config.get("script_source_id") or 0)
if source_id <= 0:
raise RuntimeError("strategyV2.sourceRequired")
source = get_script_source_service().get_source(
source_id,
user_id=int(strategy.get("user_id") or 0),
)
code = str((source or {}).get("code") or "").strip()
if not code:
raise RuntimeError("strategyV2.codeRequired")
return source_id, code
def _is_strategy_running(self, strategy_id: int, thread: threading.Thread) -> bool:
with self.lock:
if self.running_strategies.get(strategy_id) is not thread:
return False
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT status FROM qd_strategies_trading WHERE id = %s", (strategy_id,))
row = cur.fetchone() or {}
cur.close()
return str(row.get("status") or "").lower() == "running"
@staticmethod
def _mark_stopped(strategy_id: int) -> None:
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"UPDATE qd_strategies_trading SET status = 'stopped', updated_at = NOW() WHERE id = %s",
(strategy_id,),
)
db.commit()
cur.close()
except Exception:
logger.exception("Failed to persist stopped status for strategy %s", strategy_id)
def _get_current_positions(self, strategy_id: int, symbol: str) -> list[dict[str, Any]]:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT id, symbol, side, size, entry_price, current_price,
highest_price, lowest_price, updated_at
FROM qd_strategy_positions
WHERE strategy_id = %s AND split_part(symbol, ':', 1) = split_part(%s, ':', 1)
""",
(strategy_id, symbol),
)
rows = cur.fetchall() or []
cur.close()
return [dict(row) for row in rows]
def _positions_by_symbol(
self,
strategy_id: int,
candidates: list[dict[str, Any]],
) -> dict[str, dict[str, Any]]:
output: dict[str, dict[str, Any]] = {}
for member in candidates:
key = str(member.get("key") or "")
rows = self._get_current_positions(strategy_id, str(member.get("symbol") or ""))
if rows:
row = rows[0]
output[key] = {
"amount": row.get("size") or 0,
"side": row.get("side") or "long",
"avg_cost": row.get("entry_price") or 0,
"last_price": row.get("current_price") or 0,
}
return output
@staticmethod
def _live_prices(candidates: list[dict[str, Any]]) -> dict[str, float]:
prices: dict[str, float] = {}
for member in candidates:
try:
ticker = DataSourceFactory.get_ticker(
str(member.get("market") or ""),
str(member.get("symbol") or ""),
exchange_id=str(member.get("exchange_id") or "") or None,
market_type=str(member.get("market_type") or "") or None,
)
price = float((ticker or {}).get("last") or (ticker or {}).get("close") or 0)
if price > 0:
prices[str(member.get("key") or "")] = price
except Exception as exc:
logger.warning("Price fetch failed for %s: %s", member.get("key"), exc)
return prices
@classmethod
def _execution_account_prices(
cls,
candidates: list[dict[str, Any]],
exchange_config: dict[str, Any],
client_holder: dict[str, Any],
) -> dict[str, float]:
prices = cls._live_prices(candidates)
try:
from app.services.live_trading.factory import create_client
from app.services.live_trading.symbols import to_okx_spot_inst_id, to_okx_swap_inst_id
market_type = str((candidates[0] if candidates else {}).get("market_type") or "swap")
client = client_holder.get("client")
if client is None:
client = create_client(exchange_config, market_type=market_type)
client_holder["client"] = client
exchange_id = str(exchange_config.get("exchange_id") or "").strip().lower()
for member in candidates:
if str(member.get("market") or "") != "Crypto":
continue
symbol = str(member.get("symbol") or "")
price = 0.0
if hasattr(client, "get_mark_price"):
price = float(client.get_mark_price(symbol=symbol) or 0.0)
elif hasattr(client, "get_ticker"):
if exchange_id == "okx":
is_spot = str(member.get("market_type") or "").lower() == "spot"
inst_id = to_okx_spot_inst_id(symbol) if is_spot else to_okx_swap_inst_id(symbol)
ticker = client.get_ticker(inst_id=inst_id)
else:
ticker = client.get_ticker(symbol=symbol)
if isinstance(ticker, dict):
price = float(
ticker.get("last")
or ticker.get("lastPrice")
or ticker.get("lastPr")
or ticker.get("lastPx")
or ticker.get("markPrice")
or ticker.get("price")
or ticker.get("close")
or 0.0
)
if price > 0:
prices[str(member.get("key") or "")] = price
except Exception as exc:
logger.warning("Execution-account price fetch failed: %s", exc)
return prices
@staticmethod
def _align_latest_frame_prices(
frames: dict[str, pd.DataFrame],
prices: dict[str, float],
) -> dict[str, pd.DataFrame]:
for key, price in prices.items():
frame = frames.get(str(key))
if frame is None or frame.empty or float(price or 0.0) <= 0:
continue
latest = frame.index[-1]
for column in ("open", "high", "low", "close"):
if column in frame.columns:
frame.at[latest, column] = float(price)
return frames
@staticmethod
def _heartbeat(
strategy_id: int,
run_id: int,
primary: dict[str, Any],
prices: dict[str, float],
pending_count: int,
*,
loop_latency_ms: int = 0,
status: str = "healthy",
last_error: str = "",
) -> None:
record_runtime_heartbeat(
strategy_id=strategy_id,
strategy_run_id=run_id,
symbol=str(primary.get("symbol") or ""),
price=float(prices.get(str(primary.get("key") or ""), 0)),
pending_signal_count=pending_count,
loop_latency_ms=loop_latency_ms,
status=status,
last_error=last_error,
)
def _json_object(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return dict(value)
if isinstance(value, str) and value.strip():
try:
parsed = json.loads(value)
return dict(parsed) if isinstance(parsed, dict) else {}
except Exception:
return {}
return {}
def _member_key(member: dict[str, Any]) -> str:
market = str(member.get("market") or "")
symbol = str(member.get("symbol") or "")
exchange_id = str(member.get("exchange_id") or "")
market_type = str(member.get("market_type") or "")
suffix = f"@{exchange_id}" if exchange_id else ""
if suffix and market_type:
suffix += f":{market_type}"
elif market_type:
suffix = f"@{market_type}"
return f"{market}:{symbol}{suffix}"