2026-04-06 23:19:41 +08:00
|
|
|
"""
|
|
|
|
|
Python 策略脚本(on_init / on_bar + ctx.buy/sell/close_position)运行时。
|
|
|
|
|
与回测逻辑对齐,供 TradingExecutor 实盘逐根 K 线调用。
|
|
|
|
|
"""
|
2026-04-09 14:30:51 +07:00
|
|
|
|
2026-04-06 23:19:41 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
|
|
|
|
|
|
|
|
import numpy as np
|
|
|
|
|
import pandas as pd
|
|
|
|
|
|
|
|
|
|
from app.utils.logger import get_logger
|
|
|
|
|
|
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ScriptBar(dict):
|
|
|
|
|
def __getattr__(self, name: str) -> Any:
|
|
|
|
|
try:
|
|
|
|
|
return self[name]
|
|
|
|
|
except KeyError as exc:
|
|
|
|
|
raise AttributeError(name) from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ScriptPosition(dict):
|
|
|
|
|
def __init__(self):
|
|
|
|
|
super().__init__()
|
|
|
|
|
self.clear_position()
|
|
|
|
|
|
|
|
|
|
def __getattr__(self, name: str) -> Any:
|
|
|
|
|
try:
|
|
|
|
|
return self[name]
|
|
|
|
|
except KeyError as exc:
|
|
|
|
|
raise AttributeError(name) from exc
|
|
|
|
|
|
|
|
|
|
def __bool__(self) -> bool:
|
2026-04-09 14:30:51 +07:00
|
|
|
return bool(self.get("side")) and float(self.get("size") or 0) > 0
|
2026-04-06 23:19:41 +08:00
|
|
|
|
|
|
|
|
def __int__(self) -> int:
|
2026-04-09 14:30:51 +07:00
|
|
|
return int(self.get("direction") or 0)
|
2026-04-06 23:19:41 +08:00
|
|
|
|
|
|
|
|
def __float__(self) -> float:
|
2026-04-09 14:30:51 +07:00
|
|
|
return float(self.get("direction") or 0)
|
2026-04-06 23:19:41 +08:00
|
|
|
|
|
|
|
|
def __eq__(self, other: Any) -> bool:
|
|
|
|
|
try:
|
|
|
|
|
return int(self) == int(other)
|
|
|
|
|
except Exception:
|
|
|
|
|
return dict.__eq__(self, other)
|
|
|
|
|
|
|
|
|
|
def __lt__(self, other: Any) -> bool:
|
|
|
|
|
return int(self) < int(other)
|
|
|
|
|
|
|
|
|
|
def __le__(self, other: Any) -> bool:
|
|
|
|
|
return int(self) <= int(other)
|
|
|
|
|
|
|
|
|
|
def __gt__(self, other: Any) -> bool:
|
|
|
|
|
return int(self) > int(other)
|
|
|
|
|
|
|
|
|
|
def __ge__(self, other: Any) -> bool:
|
|
|
|
|
return int(self) >= int(other)
|
|
|
|
|
|
|
|
|
|
def clear_position(self) -> None:
|
|
|
|
|
self.clear()
|
2026-04-09 14:30:51 +07:00
|
|
|
self.update(
|
|
|
|
|
{
|
|
|
|
|
"side": "",
|
|
|
|
|
"size": 0.0,
|
|
|
|
|
"entry_price": 0.0,
|
|
|
|
|
"direction": 0,
|
|
|
|
|
"amount": 0.0,
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-04-06 23:19:41 +08:00
|
|
|
|
|
|
|
|
def open_position(self, side: str, entry_price: float, amount: float) -> None:
|
2026-04-09 14:30:51 +07:00
|
|
|
direction = 1 if side == "long" else (-1 if side == "short" else 0)
|
2026-04-06 23:19:41 +08:00
|
|
|
size = float(amount or 0.0)
|
|
|
|
|
price = float(entry_price or 0.0)
|
|
|
|
|
self.clear()
|
2026-04-09 14:30:51 +07:00
|
|
|
self.update(
|
|
|
|
|
{
|
|
|
|
|
"side": side,
|
|
|
|
|
"size": size,
|
|
|
|
|
"entry_price": price,
|
|
|
|
|
"direction": direction,
|
|
|
|
|
"amount": size,
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-04-06 23:19:41 +08:00
|
|
|
|
|
|
|
|
def add_position(self, entry_price: float, amount: float) -> None:
|
|
|
|
|
extra = float(amount or 0.0)
|
|
|
|
|
if extra <= 0:
|
|
|
|
|
return
|
2026-04-09 14:30:51 +07:00
|
|
|
current_size = float(self.get("size") or 0.0)
|
|
|
|
|
current_price = float(self.get("entry_price") or 0.0)
|
2026-04-06 23:19:41 +08:00
|
|
|
next_size = current_size + extra
|
|
|
|
|
next_price = float(entry_price or current_price or 0.0)
|
|
|
|
|
if current_size > 0 and current_price > 0 and next_size > 0:
|
|
|
|
|
next_price = ((current_price * current_size) + (float(entry_price or current_price) * extra)) / next_size
|
2026-04-09 14:30:51 +07:00
|
|
|
self["size"] = next_size
|
|
|
|
|
self["amount"] = next_size
|
|
|
|
|
self["entry_price"] = next_price
|
2026-04-06 23:19:41 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class StrategyScriptContext:
|
|
|
|
|
"""与回测 ScriptBacktestContext 行为一致,供实盘按根推进。"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, bars_df: pd.DataFrame, initial_balance: float):
|
|
|
|
|
self._bars_df = bars_df
|
|
|
|
|
self._params: Dict[str, Any] = {}
|
|
|
|
|
self._orders: List[Dict[str, Any]] = []
|
|
|
|
|
self._logs: List[str] = []
|
|
|
|
|
self.current_index = -1
|
|
|
|
|
self.position = ScriptPosition()
|
|
|
|
|
self.balance = float(initial_balance)
|
|
|
|
|
self.equity = float(initial_balance)
|
|
|
|
|
|
|
|
|
|
def param(self, name: str, default: Any = None) -> Any:
|
|
|
|
|
if name not in self._params:
|
|
|
|
|
self._params[name] = default
|
|
|
|
|
return self._params[name]
|
|
|
|
|
|
|
|
|
|
def bars(self, n: int = 1):
|
|
|
|
|
start = max(0, self.current_index - int(n) + 1)
|
|
|
|
|
out = []
|
2026-04-09 14:30:51 +07:00
|
|
|
for _, row in self._bars_df.iloc[start : self.current_index + 1].iterrows():
|
|
|
|
|
out.append(
|
|
|
|
|
ScriptBar(
|
|
|
|
|
open=float(row.get("open") or 0),
|
|
|
|
|
high=float(row.get("high") or 0),
|
|
|
|
|
low=float(row.get("low") or 0),
|
|
|
|
|
close=float(row.get("close") or 0),
|
|
|
|
|
volume=float(row.get("volume") or 0),
|
|
|
|
|
timestamp=row.get("time"),
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-04-06 23:19:41 +08:00
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
def log(self, message: Any):
|
|
|
|
|
self._logs.append(str(message))
|
|
|
|
|
|
|
|
|
|
def buy(self, price: Any = None, amount: Any = None):
|
2026-04-09 14:30:51 +07:00
|
|
|
self._orders.append({"action": "buy", "price": price, "amount": amount})
|
2026-04-06 23:19:41 +08:00
|
|
|
|
|
|
|
|
def sell(self, price: Any = None, amount: Any = None):
|
2026-04-09 14:30:51 +07:00
|
|
|
self._orders.append({"action": "sell", "price": price, "amount": amount})
|
2026-04-06 23:19:41 +08:00
|
|
|
|
|
|
|
|
def close_position(self):
|
2026-04-09 14:30:51 +07:00
|
|
|
self._orders.append({"action": "close"})
|
2026-04-06 23:19:41 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def compile_strategy_script_handlers(code: str) -> Tuple[Optional[Callable], Optional[Callable]]:
|
|
|
|
|
"""
|
|
|
|
|
校验并编译策略脚本,返回 (on_init, on_bar)。
|
|
|
|
|
on_bar 不可缺省;on_init 可选。
|
|
|
|
|
"""
|
|
|
|
|
if not code or not str(code).strip():
|
|
|
|
|
raise ValueError("Strategy script is empty")
|
|
|
|
|
|
|
|
|
|
import builtins
|
|
|
|
|
|
|
|
|
|
def safe_import(name, *args, **kwargs):
|
2026-04-09 14:30:51 +07:00
|
|
|
allowed_modules = ["numpy", "pandas", "math", "json", "datetime", "time"]
|
|
|
|
|
if name in allowed_modules or name.split(".")[0] in allowed_modules:
|
2026-04-06 23:19:41 +08:00
|
|
|
return builtins.__import__(name, *args, **kwargs)
|
|
|
|
|
raise ImportError(f"Import not allowed: {name}")
|
|
|
|
|
|
2026-04-09 14:30:51 +07:00
|
|
|
safe_builtins = {
|
|
|
|
|
k: getattr(builtins, k)
|
|
|
|
|
for k in dir(builtins)
|
|
|
|
|
if not k.startswith("_") and k not in ["eval", "exec", "compile", "open", "input", "help", "exit", "quit"]
|
|
|
|
|
}
|
|
|
|
|
safe_builtins["__import__"] = safe_import
|
2026-04-06 23:19:41 +08:00
|
|
|
|
|
|
|
|
exec_env = {
|
2026-04-09 14:30:51 +07:00
|
|
|
"__builtins__": safe_builtins,
|
|
|
|
|
"np": np,
|
|
|
|
|
"pd": pd,
|
2026-04-06 23:19:41 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-09 14:30:51 +07:00
|
|
|
from app.utils.safe_exec import safe_exec_code, validate_code_safety
|
2026-04-06 23:19:41 +08:00
|
|
|
|
|
|
|
|
is_safe, error_msg = validate_code_safety(code)
|
|
|
|
|
if not is_safe:
|
|
|
|
|
raise ValueError(f"Code contains unsafe operations: {error_msg}")
|
|
|
|
|
|
2026-04-09 14:30:51 +07:00
|
|
|
exec_result = safe_exec_code(code=code, exec_globals=exec_env, exec_locals=exec_env, timeout=60)
|
|
|
|
|
if not exec_result["success"]:
|
2026-04-06 23:19:41 +08:00
|
|
|
raise RuntimeError(f"Code execution failed: {exec_result.get('error')}")
|
|
|
|
|
|
2026-04-09 14:30:51 +07:00
|
|
|
on_init = exec_env.get("on_init")
|
|
|
|
|
on_bar = exec_env.get("on_bar")
|
2026-04-06 23:19:41 +08:00
|
|
|
if not callable(on_bar):
|
|
|
|
|
raise ValueError("Strategy script must define on_bar(ctx, bar)")
|
|
|
|
|
if on_init is not None and not callable(on_init):
|
|
|
|
|
on_init = None
|
|
|
|
|
return (on_init if callable(on_init) else None), on_bar
|