Files
DinQuant/backend_api_python/scripts/simulate_trading_executor.py
T

395 lines
13 KiB
Python
Raw Normal View History

2025-12-29 03:06:49 +08:00
"""
Local simulation for TradingExecutor.
Goal:
- Create one indicator strategy using `indicator_python_code/code_test.py`
- Inject deterministic K-lines and a deterministic tick-price sequence
- Run TradingExecutor for a short period
- Verify orders are enqueued into SQLite table `pending_orders`
Notes:
- This is a local-only test helper. It does NOT talk to real exchanges.
- We intentionally shorten tick interval to speed up the simulation.
"""
from __future__ import annotations
import json
import os
import sys
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
def _ensure_backend_on_syspath() -> None:
"""
Ensure `backend_api_python/` is on sys.path so `import app...` works
no matter where the script is executed from.
"""
backend_root = Path(__file__).resolve().parents[1]
p = str(backend_root)
if p not in sys.path:
sys.path.insert(0, p)
_ensure_backend_on_syspath()
from app.services.trading_executor import TradingExecutor # noqa: E402
from app.utils.db import get_db_connection # noqa: E402
def _repo_root() -> Path:
# backend_api_python/scripts/ -> backend_api_python/
return Path(__file__).resolve().parents[1]
def _read_indicator_code() -> str:
# Use the user's current indicator script under repo root.
root = _repo_root().parent # project root (quantdinger/)
p = root / "indicator_python_code" / "code_test.py"
return p.read_text(encoding="utf-8")
def _make_klines_1m(base: float = 3000.0, n: int = 200) -> List[Dict[str, Any]]:
"""
Generate synthetic 1m klines to allow SuperTrend to produce buy/sell signals.
We use a downtrend then an uptrend to force a trend flip.
"""
now = int(time.time())
start = now - n * 60
klines: List[Dict[str, Any]] = []
price = float(base)
for i in range(n):
ts = start + i * 60
# Down for first part, then up for second part.
if i < int(n * 0.45):
price *= 0.996 # -0.4% per bar
else:
price *= 1.008 # +0.8% per bar
o = price * 0.999
c = price
h = max(o, c) * 1.0005
l = min(o, c) * 0.9995
klines.append(
{
"time": int(ts),
"open": float(o),
"high": float(h),
"low": float(l),
"close": float(c),
"volume": 1.0,
}
)
return klines
def _count_signals_for_klines(
ex: TradingExecutor,
indicator_code: str,
klines: List[Dict[str, Any]],
trade_direction: str,
leverage: int,
initial_capital: float,
) -> Dict[str, int]:
df = ex._klines_to_dataframe(klines)
tc = {
"trade_direction": trade_direction,
"leverage": leverage,
"initial_capital": initial_capital,
}
executed_df, _env = ex._execute_indicator_df(indicator_code, df, tc)
if executed_df is None:
return {"buy": 0, "sell": 0}
buy = int(executed_df.get("buy", False).fillna(False).astype(bool).sum()) if "buy" in executed_df.columns else 0
sell = int(executed_df.get("sell", False).fillna(False).astype(bool).sum()) if "sell" in executed_df.columns else 0
return {"buy": buy, "sell": sell}
def _trim_klines_to_last_signal(
ex: TradingExecutor,
indicator_code: str,
klines: List[Dict[str, Any]],
keep_before: int = 220,
keep_after: int = 0,
) -> List[Dict[str, Any]]:
"""
Trim klines so the last buy/sell signal falls within the last 1~2 bars,
which is what TradingExecutor evaluates.
"""
df = ex._klines_to_dataframe(klines)
tc = {"trade_direction": "both", "leverage": 5, "initial_capital": 1000.0}
executed_df, _env = ex._execute_indicator_df(indicator_code, df, tc)
if executed_df is None or "buy" not in executed_df.columns or "sell" not in executed_df.columns:
return klines
buy = executed_df["buy"].fillna(False).astype(bool).values.tolist()
sell = executed_df["sell"].fillna(False).astype(bool).values.tolist()
last_idx = -1
for i in range(len(buy) - 1, -1, -1):
if buy[i] or sell[i]:
last_idx = i
break
if last_idx < 0:
return klines
start = max(0, last_idx - int(keep_before))
end = min(len(klines), last_idx + 1 + int(keep_after))
out = klines[start:end]
if len(out) < 30:
return klines
# Rebase timestamps so the last bar is close to "now".
# Otherwise TradingExecutor will consider signals expired (it compares signal_timestamp vs time.time()).
try:
now = int(time.time())
last_ts = int(out[-1].get("time") or 0)
if last_ts > 0:
shift = (now - 60) - last_ts # keep last candle near current time
for row in out:
row["time"] = int(row.get("time") or 0) + int(shift)
except Exception:
pass
return out
def _find_klines_with_signal(ex: TradingExecutor, indicator_code: str) -> List[Dict[str, Any]]:
"""
Try a few synthetic patterns until SuperTrend produces at least one buy/sell.
This makes the simulation deterministic.
"""
patterns = [
# (down_mult, up_mult, split_ratio)
(0.998, 1.004, 0.45),
(0.996, 1.008, 0.45),
(0.994, 1.012, 0.50),
(0.992, 1.015, 0.55),
(0.990, 1.020, 0.60),
]
for down_mult, up_mult, split in patterns:
kl = _make_klines_1m(base=3000.0, n=260)
# Rewrite using the requested multipliers (keep timestamps).
price = 3000.0
for i, row in enumerate(kl):
if i < int(len(kl) * split):
price *= float(down_mult)
else:
price *= float(up_mult)
o = price * 0.999
c = price
h = max(o, c) * 1.0005
l = min(o, c) * 0.9995
row["open"] = float(o)
row["high"] = float(h)
row["low"] = float(l)
row["close"] = float(c)
cnt = _count_signals_for_klines(ex, indicator_code, kl, "both", 5, 1000.0)
if cnt["buy"] > 0 or cnt["sell"] > 0:
kl2 = _trim_klines_to_last_signal(ex, indicator_code, kl, keep_before=220, keep_after=0)
cnt2 = _count_signals_for_klines(ex, indicator_code, kl2, "both", 5, 1000.0)
print(f"[OK] Found signals with pattern down={down_mult}, up={up_mult}, split={split}: {cnt} -> trimmed={cnt2}, bars={len(kl2)}")
return kl2
print(f"[MISS] Pattern down={down_mult}, up={up_mult}, split={split}: {cnt}")
print("[WARN] No buy/sell signals found in tested patterns; falling back to default klines.")
return _make_klines_1m(base=3000.0, n=260)
def _insert_strategy(
*,
symbol: str,
indicator_code: str,
initial_capital: float,
leverage: int,
trade_direction: str,
timeframe: str,
stop_loss_pct: float,
take_profit_pct: float,
trailing_enabled: bool,
trailing_activation_pct: float,
trailing_stop_pct: float,
) -> int:
"""
Insert one strategy row into qd_strategies_trading and return its id.
"""
now = int(time.time())
trading_config = {
"symbol": symbol,
"initial_capital": float(initial_capital),
"leverage": int(leverage),
"trade_direction": str(trade_direction),
"timeframe": str(timeframe),
"market_type": "swap",
# Make entries deterministic in this simulation.
"entry_trigger_mode": "immediate",
"exit_trigger_mode": "immediate",
# Aggressive = allow current candle signals. This makes simulation deterministic.
"signal_mode": "aggressive",
"exit_signal_mode": "aggressive",
# Risk params (config-driven exits)
"stop_loss_pct": float(stop_loss_pct),
"take_profit_pct": float(take_profit_pct),
"trailing_enabled": bool(trailing_enabled),
"trailing_activation_pct": float(trailing_activation_pct),
"trailing_stop_pct": float(trailing_stop_pct),
# Position sizing
"entry_pct": 1.0,
}
indicator_config = {
"indicator_id": 1,
"indicator_name": "code_test.py",
"indicator_code": indicator_code,
}
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
INSERT INTO qd_strategies_trading
(strategy_name, strategy_type, market_category, execution_mode, notification_config,
status, symbol, timeframe, initial_capital, leverage, market_type,
exchange_config, indicator_config, trading_config, ai_model_config, decide_interval,
created_at, updated_at)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
"SIM_ETH_1m",
"IndicatorStrategy",
"Crypto",
"signal",
json.dumps({"channels": ["webhook"]}, ensure_ascii=False),
"running",
symbol,
timeframe,
float(initial_capital),
int(leverage),
"swap",
json.dumps({}, ensure_ascii=False),
json.dumps(indicator_config, ensure_ascii=False),
json.dumps(trading_config, ensure_ascii=False),
json.dumps({}, ensure_ascii=False),
300,
now,
now,
),
)
sid = int(cur.lastrowid)
db.commit()
cur.close()
return sid
class _SimPriceFeed:
def __init__(self, prices: List[float]):
self._prices = list(prices)
self._idx = 0
def next(self) -> float:
if not self._prices:
return 0.0
if self._idx >= len(self._prices):
return float(self._prices[-1])
p = float(self._prices[self._idx])
self._idx += 1
return p
def main() -> None:
# Speed up: 1s tick in simulation (logic is identical to 10s tick).
os.environ.setdefault("STRATEGY_TICK_INTERVAL_SEC", "1")
# Disable in-memory price cache so each tick uses next simulated price.
os.environ.setdefault("PRICE_CACHE_TTL_SEC", "0")
indicator_code = _read_indicator_code()
ex = TradingExecutor()
klines = _find_klines_with_signal(ex, indicator_code)
# Your requested config (note: risk percentages are margin-based, executor divides by leverage).
strategy_id = _insert_strategy(
symbol="ETH/USDT",
indicator_code=indicator_code,
initial_capital=1000.0,
leverage=5,
trade_direction="both",
timeframe="1m",
stop_loss_pct=0.02, # 2%
take_profit_pct=0.0,
trailing_enabled=True,
trailing_activation_pct=0.04, # 4%
trailing_stop_pct=0.01, # 1%
)
# Build a price path that triggers trailing:
# - start near last close
# - move up enough to activate trailing (activation is divided by leverage in executor)
# - then pull back enough to hit trailing stop
last_close = float(klines[-1]["close"])
up = last_close * 1.02 # +2% (enough to activate when leverage=5)
high = last_close * 1.03
pullback = high * (1 - 0.004) # -0.4% from high (enough to hit trailing when leverage=5)
prices = [
last_close,
last_close * 1.005,
last_close * 1.01,
up,
high,
high * 0.999,
pullback,
pullback * 0.999,
]
feed = _SimPriceFeed(prices)
# Monkeypatch market data methods (no network).
ex._fetch_latest_kline = lambda _symbol, _tf, limit=500: klines # type: ignore[assignment]
ex._fetch_current_price = lambda _exchange, _symbol, market_type=None: feed.next() # type: ignore[assignment]
ok = ex.start_strategy(strategy_id)
if not ok:
raise SystemExit("Failed to start strategy thread")
# Let it run a few ticks.
time.sleep(10)
# Stop strategy by updating DB status.
with get_db_connection() as db:
cur = db.cursor()
cur.execute("UPDATE qd_strategies_trading SET status = 'stopped' WHERE id = ?", (strategy_id,))
db.commit()
cur.close()
# Wait for thread to exit.
time.sleep(2)
# Print pending orders.
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT id, strategy_id, symbol, signal_type, amount, price, status, created_at
FROM pending_orders
WHERE strategy_id = ?
ORDER BY id ASC
""",
(strategy_id,),
)
rows = cur.fetchall() or []
cur.close()
print(f"strategy_id={strategy_id}, pending_orders={len(rows)}")
for r in rows:
print(r)
if __name__ == "__main__":
main()