c3cf230104
- Removed conditional rendering for the assistant guide bar. - Simplified strategy overview and strategy list item components. - Introduced a new modal for selecting strategy mode. - Enhanced strategy creation modal to support script strategies with a dedicated editor. - Updated form handling for script strategies, including validation and submission logic. - Improved user experience with better messaging and streamlined UI components. - Updated translations for better clarity in Chinese.
3193 lines
148 KiB
Python
3193 lines
148 KiB
Python
"""
|
||
Real-time trade execution services
|
||
"""
|
||
import time
|
||
import threading
|
||
import traceback
|
||
import os
|
||
try:
|
||
import resource # Linux/Unix only
|
||
except Exception:
|
||
resource = None
|
||
from typing import Dict, List, Any, Optional, Tuple
|
||
from datetime import datetime
|
||
import json
|
||
from decimal import Decimal, ROUND_DOWN, ROUND_UP
|
||
import pandas as pd
|
||
import numpy as np
|
||
import ccxt
|
||
|
||
from app.utils.logger import get_logger
|
||
from app.utils.db import get_db_connection
|
||
from app.data_sources import DataSourceFactory
|
||
from app.services.kline import KlineService
|
||
from app.services.indicator_params import IndicatorParamsParser, IndicatorCaller
|
||
|
||
logger = get_logger(__name__)
|
||
|
||
|
||
class TradingExecutor:
|
||
"""Real-time transaction executor (Signal Provider Mode)"""
|
||
|
||
def __init__(self):
|
||
# Instead of using a global connection, obtain it from the connection pool each time it is used.
|
||
self.running_strategies = {} # {strategy_id: thread}
|
||
self.lock = threading.Lock()
|
||
# Local-only lightweight in-memory price cache (symbol -> (price, expiry_ts)).
|
||
# This replaces the old Redis-based PriceCache for local deployments.
|
||
self._price_cache = {}
|
||
self._price_cache_lock = threading.Lock()
|
||
# Default to 10s to match the unified tick cadence.
|
||
self._price_cache_ttl_sec = int(os.getenv("PRICE_CACHE_TTL_SEC", "10"))
|
||
|
||
# In-memory signal de-dup cache to prevent repeated orders on the same candle signal.
|
||
# Keyed by (strategy_id, symbol, signal_type, signal_timestamp).
|
||
self._signal_dedup = {} # type: Dict[int, Dict[str, float]]
|
||
self._signal_dedup_lock = threading.Lock()
|
||
self.kline_service = KlineService() # K-line service (with cache)
|
||
|
||
# The upper limit of single-instance threads to avoid unlimited thread creation causing can't start new thread/OOM
|
||
self.max_threads = int(os.getenv('STRATEGY_MAX_THREADS', '64'))
|
||
|
||
# Make sure the database field exists
|
||
self._ensure_db_columns()
|
||
|
||
def _ensure_db_columns(self):
|
||
"""Ensure necessary database fields exist (PostgreSQL)"""
|
||
try:
|
||
with get_db_connection() as db:
|
||
cursor = db.cursor()
|
||
col_names = set()
|
||
|
||
# PostgreSQL: Query columns using information_schema
|
||
try:
|
||
cursor.execute("""
|
||
SELECT column_name FROM information_schema.columns
|
||
WHERE table_name = 'qd_strategy_positions'
|
||
""")
|
||
cols = cursor.fetchall() or []
|
||
col_names = {c.get('column_name') or c.get('COLUMN_NAME') for c in cols if isinstance(c, dict)}
|
||
except Exception:
|
||
col_names = set()
|
||
|
||
if 'highest_price' not in col_names:
|
||
logger.info("Adding highest_price column to qd_strategy_positions...")
|
||
cursor.execute("ALTER TABLE qd_strategy_positions ADD COLUMN IF NOT EXISTS highest_price DOUBLE PRECISION DEFAULT 0")
|
||
db.commit()
|
||
logger.info("highest_price column added")
|
||
|
||
if 'lowest_price' not in col_names:
|
||
logger.info("Adding lowest_price column to qd_strategy_positions...")
|
||
cursor.execute("ALTER TABLE qd_strategy_positions ADD COLUMN IF NOT EXISTS lowest_price DOUBLE PRECISION DEFAULT 0")
|
||
db.commit()
|
||
logger.info("lowest_price column added")
|
||
|
||
cursor.close()
|
||
except Exception as e:
|
||
logger.error(f"Failed to check/ensure DB columns: {str(e)}")
|
||
|
||
def _normalize_trade_symbol(self, exchange: Any, symbol: str, market_type: str, exchange_id: str) -> str:
|
||
"""
|
||
Normalize symbols from the database/config into the CCXT symbol format accepted by the exchange.
|
||
|
||
Typical case: the normalized OKX perpetual symbol is usually `BNB/USDT:USDT`,
|
||
while the frontend or database may pass `BNB/USDT`.
|
||
"""
|
||
try:
|
||
# New system: only supports swap (perpetual contract) / spot (spot)
|
||
if market_type != 'swap':
|
||
return symbol
|
||
if not symbol or ':' in symbol:
|
||
return symbol
|
||
if not getattr(exchange, 'markets', None):
|
||
return symbol
|
||
|
||
# If symbol itself is a contract market, return it directly
|
||
try:
|
||
m = exchange.market(symbol)
|
||
if m and (m.get('swap') or m.get('future') or m.get('contract')):
|
||
return symbol
|
||
except Exception:
|
||
pass
|
||
|
||
# OKX/some exchanges: Perpetual is usually BASE/QUOTE:QUOTE or BASE/QUOTE:USDT
|
||
if '/' not in symbol:
|
||
return symbol
|
||
base, quote = symbol.split('/', 1)
|
||
candidates = []
|
||
if quote:
|
||
candidates.append(f"{base}/{quote}:{quote}")
|
||
if quote.upper() != 'USDT':
|
||
candidates.append(f"{base}/{quote}:USDT")
|
||
|
||
for cand in candidates:
|
||
if cand in exchange.markets:
|
||
cm = exchange.markets[cand]
|
||
if cm and (cm.get('swap') or cm.get('future') or cm.get('contract')):
|
||
logger.info(f"symbol normalized: {symbol} -> {cand} (exchange={exchange_id}, market_type={market_type})")
|
||
return cand
|
||
|
||
return symbol
|
||
except Exception:
|
||
return symbol
|
||
|
||
def _log_resource_status(self, prefix: str = ""):
|
||
"""Debug helper: record thread and memory usage to diagnose the root cause of 'can't start new thread'."""
|
||
try:
|
||
import psutil # Use more precise metrics if installed
|
||
p = psutil.Process()
|
||
mem = p.memory_info().rss / 1024 / 1024
|
||
th = p.num_threads()
|
||
logger.warning(f"{prefix}resource status: memory={mem:.1f}MB, threads={th}, "
|
||
f"running_strategies={len(self.running_strategies)}")
|
||
except Exception:
|
||
try:
|
||
th = threading.active_count()
|
||
# Read VmRSS from /proc/self/status (for Linux containers)
|
||
vmrss = None
|
||
try:
|
||
with open('/proc/self/status') as f:
|
||
for line in f:
|
||
if line.startswith('VmRSS:'):
|
||
vmrss = line.split()[1:3] # e.g. ['123456', 'kB']
|
||
break
|
||
except Exception:
|
||
pass
|
||
vmrss_str = f"{vmrss[0]}{vmrss[1]}" if vmrss else "N/A"
|
||
logger.warning(f"{prefix}resource status: VmRSS={vmrss_str}, active_threads={th}, "
|
||
f"running_strategies={len(self.running_strategies)}")
|
||
except Exception:
|
||
pass
|
||
|
||
def _console_print(self, msg: str) -> None:
|
||
"""
|
||
Local-only observability: print to stdout so user can see strategy status in console.
|
||
"""
|
||
try:
|
||
print(str(msg or ""), flush=True)
|
||
except Exception:
|
||
pass
|
||
|
||
def _position_state(self, positions: List[Dict[str, Any]]) -> str:
|
||
"""
|
||
Return current position state for a strategy+symbol in local single-position mode.
|
||
|
||
Returns: 'flat' | 'long' | 'short'
|
||
"""
|
||
try:
|
||
if not positions:
|
||
return "flat"
|
||
# Local mode assumes single-direction position per symbol.
|
||
side = (positions[0].get("side") or "").strip().lower()
|
||
if side in ("long", "short"):
|
||
return side
|
||
except Exception:
|
||
pass
|
||
return "flat"
|
||
|
||
def _is_signal_allowed(self, state: str, signal_type: str) -> bool:
|
||
"""
|
||
Enforce strict state machine:
|
||
- flat: only open_long/open_short
|
||
- long: only add_long/close_long
|
||
- short: only add_short/close_short
|
||
"""
|
||
st = (state or "flat").strip().lower()
|
||
sig = (signal_type or "").strip().lower()
|
||
if st == "flat":
|
||
return sig in ("open_long", "open_short")
|
||
if st == "long":
|
||
return sig in ("add_long", "reduce_long", "close_long")
|
||
if st == "short":
|
||
return sig in ("add_short", "reduce_short", "close_short")
|
||
return False
|
||
|
||
def _signal_priority(self, signal_type: str) -> int:
|
||
"""
|
||
Lower value = higher priority. We always close before (re)opening/adding.
|
||
"""
|
||
sig = (signal_type or "").strip().lower()
|
||
if sig.startswith("close_"):
|
||
return 0
|
||
if sig.startswith("reduce_"):
|
||
return 1
|
||
if sig.startswith("open_"):
|
||
return 2
|
||
if sig.startswith("add_"):
|
||
return 3
|
||
return 99
|
||
|
||
def _dedup_key(self, strategy_id: int, symbol: str, signal_type: str, signal_ts: int) -> str:
|
||
sym = (symbol or "").strip().upper()
|
||
if ":" in sym:
|
||
sym = sym.split(":", 1)[0]
|
||
return f"{int(strategy_id)}|{sym}|{(signal_type or '').strip().lower()}|{int(signal_ts or 0)}"
|
||
|
||
def _should_skip_signal_once_per_candle(
|
||
self,
|
||
strategy_id: int,
|
||
symbol: str,
|
||
signal_type: str,
|
||
signal_ts: int,
|
||
timeframe_seconds: int,
|
||
now_ts: Optional[int] = None,
|
||
) -> bool:
|
||
"""
|
||
Prevent repeated orders for the same candle signal across ticks.
|
||
|
||
This is especially important for 'confirmed' signals that point to the previous closed candle:
|
||
the signal timestamp stays constant for the entire next candle, so without de-dup the system
|
||
would re-enqueue the same order every tick.
|
||
"""
|
||
try:
|
||
now = int(now_ts or time.time())
|
||
tf = int(timeframe_seconds or 0)
|
||
if tf <= 0:
|
||
tf = 60
|
||
# Keep keys long enough to cover at least the next candle.
|
||
ttl_sec = max(tf * 2, 120)
|
||
expiry = float(now + ttl_sec)
|
||
key = self._dedup_key(strategy_id, symbol, signal_type, int(signal_ts or 0))
|
||
|
||
with self._signal_dedup_lock:
|
||
bucket = self._signal_dedup.get(int(strategy_id))
|
||
if bucket is None:
|
||
bucket = {}
|
||
self._signal_dedup[int(strategy_id)] = bucket
|
||
|
||
# Opportunistic cleanup
|
||
stale = [k for k, exp in bucket.items() if float(exp) <= now]
|
||
for k in stale[:512]:
|
||
try:
|
||
del bucket[k]
|
||
except Exception:
|
||
pass
|
||
|
||
exp = bucket.get(key)
|
||
if exp is not None and float(exp) > now:
|
||
return True
|
||
|
||
# Reserve the key (best-effort). Caller may still fail to enqueue; that's acceptable
|
||
# because repeated failures should not flood the queue.
|
||
bucket[key] = expiry
|
||
return False
|
||
except Exception:
|
||
return False
|
||
|
||
def _to_ratio(self, v: Any, default: float = 0.0) -> float:
|
||
"""
|
||
Convert a percent-like value into ratio in [0, 1].
|
||
Accepts both 0~1 and 0~100 inputs.
|
||
"""
|
||
try:
|
||
x = float(v if v is not None else default)
|
||
except Exception:
|
||
x = float(default or 0.0)
|
||
if x > 1.0:
|
||
x = x / 100.0
|
||
if x < 0:
|
||
x = 0.0
|
||
if x > 1.0:
|
||
x = 1.0
|
||
return float(x)
|
||
|
||
def _build_cfg_from_trading_config(self, trading_config: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""
|
||
Build a backtest-modal compatible config dict for indicator scripts.
|
||
|
||
Frontend (trading assistant) stores most params as flat keys under `trading_config`.
|
||
Backtest service expects nested structure: cfg.risk/cfg.scale/cfg.position (camelCase).
|
||
|
||
We provide BOTH:
|
||
- `trading_config`: the original flat dict (so existing scripts keep working)
|
||
- `cfg`: a normalized nested dict (so scripts can reuse backtest-style helpers)
|
||
"""
|
||
tc = trading_config or {}
|
||
|
||
# Risk / trailing
|
||
stop_loss_pct = self._to_ratio(tc.get("stop_loss_pct"))
|
||
take_profit_pct = self._to_ratio(tc.get("take_profit_pct"))
|
||
trailing_enabled = bool(tc.get("trailing_enabled"))
|
||
trailing_stop_pct = self._to_ratio(tc.get("trailing_stop_pct"))
|
||
trailing_activation_pct = self._to_ratio(tc.get("trailing_activation_pct"))
|
||
|
||
# Position sizing
|
||
entry_pct = self._to_ratio(tc.get("entry_pct"))
|
||
|
||
# Scale-in
|
||
trend_add_enabled = bool(tc.get("trend_add_enabled"))
|
||
trend_add_step_pct = self._to_ratio(tc.get("trend_add_step_pct"))
|
||
trend_add_size_pct = self._to_ratio(tc.get("trend_add_size_pct"))
|
||
trend_add_max_times = int(tc.get("trend_add_max_times") or 0)
|
||
|
||
dca_add_enabled = bool(tc.get("dca_add_enabled"))
|
||
dca_add_step_pct = self._to_ratio(tc.get("dca_add_step_pct"))
|
||
dca_add_size_pct = self._to_ratio(tc.get("dca_add_size_pct"))
|
||
dca_add_max_times = int(tc.get("dca_add_max_times") or 0)
|
||
|
||
# Scale-out / reduce
|
||
trend_reduce_enabled = bool(tc.get("trend_reduce_enabled"))
|
||
trend_reduce_step_pct = self._to_ratio(tc.get("trend_reduce_step_pct"))
|
||
trend_reduce_size_pct = self._to_ratio(tc.get("trend_reduce_size_pct"))
|
||
trend_reduce_max_times = int(tc.get("trend_reduce_max_times") or 0)
|
||
|
||
adverse_reduce_enabled = bool(tc.get("adverse_reduce_enabled"))
|
||
adverse_reduce_step_pct = self._to_ratio(tc.get("adverse_reduce_step_pct"))
|
||
adverse_reduce_size_pct = self._to_ratio(tc.get("adverse_reduce_size_pct"))
|
||
adverse_reduce_max_times = int(tc.get("adverse_reduce_max_times") or 0)
|
||
|
||
return {
|
||
"risk": {
|
||
"stopLossPct": stop_loss_pct,
|
||
"takeProfitPct": take_profit_pct,
|
||
"trailing": {
|
||
"enabled": trailing_enabled,
|
||
"pct": trailing_stop_pct,
|
||
"activationPct": trailing_activation_pct,
|
||
},
|
||
},
|
||
"position": {
|
||
"entryPct": entry_pct,
|
||
},
|
||
"scale": {
|
||
"trendAdd": {
|
||
"enabled": trend_add_enabled,
|
||
"stepPct": trend_add_step_pct,
|
||
"sizePct": trend_add_size_pct,
|
||
"maxTimes": trend_add_max_times,
|
||
},
|
||
"dcaAdd": {
|
||
"enabled": dca_add_enabled,
|
||
"stepPct": dca_add_step_pct,
|
||
"sizePct": dca_add_size_pct,
|
||
"maxTimes": dca_add_max_times,
|
||
},
|
||
"trendReduce": {
|
||
"enabled": trend_reduce_enabled,
|
||
"stepPct": trend_reduce_step_pct,
|
||
"sizePct": trend_reduce_size_pct,
|
||
"maxTimes": trend_reduce_max_times,
|
||
},
|
||
"adverseReduce": {
|
||
"enabled": adverse_reduce_enabled,
|
||
"stepPct": adverse_reduce_step_pct,
|
||
"sizePct": adverse_reduce_size_pct,
|
||
"maxTimes": adverse_reduce_max_times,
|
||
},
|
||
},
|
||
}
|
||
|
||
def start_strategy(self, strategy_id: int) -> bool:
|
||
"""
|
||
launch strategy
|
||
|
||
Args:
|
||
strategy_id: Strategy ID
|
||
|
||
Returns:
|
||
Is it successful?
|
||
"""
|
||
try:
|
||
with self.lock:
|
||
# Clean up exited threads to prevent count inflation
|
||
stale_ids = [sid for sid, th in self.running_strategies.items() if not th.is_alive()]
|
||
for sid in stale_ids:
|
||
del self.running_strategies[sid]
|
||
|
||
if len(self.running_strategies) >= self.max_threads:
|
||
logger.error(
|
||
f"Thread limit reached ({self.max_threads}); refuse to start strategy {strategy_id}. "
|
||
f"Reduce running strategies or increase STRATEGY_MAX_THREADS."
|
||
)
|
||
self._log_resource_status(prefix="start_denied: ")
|
||
return False
|
||
|
||
if strategy_id in self.running_strategies:
|
||
logger.warning(f"Strategy {strategy_id} is already running")
|
||
return False
|
||
|
||
# Create and start threads
|
||
thread = threading.Thread(
|
||
target=self._run_strategy_loop,
|
||
args=(strategy_id,),
|
||
daemon=True
|
||
)
|
||
try:
|
||
thread.start()
|
||
except Exception as e:
|
||
# Capture exceptions such as can't start new thread and record resource status
|
||
self._log_resource_status(prefix="startup exception")
|
||
raise e
|
||
self.running_strategies[strategy_id] = thread
|
||
|
||
logger.info(f"Strategy {strategy_id} started")
|
||
self._console_print(f"[strategy:{strategy_id}] started")
|
||
return True
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to start strategy {strategy_id}: {str(e)}")
|
||
logger.error(traceback.format_exc())
|
||
return False
|
||
|
||
def stop_strategy(self, strategy_id: int) -> bool:
|
||
"""
|
||
stopping strategy
|
||
|
||
Args:
|
||
strategy_id: Strategy ID
|
||
|
||
Returns:
|
||
Is it successful?
|
||
"""
|
||
try:
|
||
with self.lock:
|
||
if strategy_id not in self.running_strategies:
|
||
logger.warning(f"Strategy {strategy_id} is not running")
|
||
return False
|
||
|
||
# Mark policy as stopped
|
||
with get_db_connection() as db:
|
||
cursor = db.cursor()
|
||
cursor.execute(
|
||
"UPDATE qd_strategies_trading SET status = 'stopped' WHERE id = %s",
|
||
(strategy_id,)
|
||
)
|
||
db.commit()
|
||
cursor.close()
|
||
|
||
# Removed from the run list (the thread will exit the next time the loop checks status)
|
||
del self.running_strategies[strategy_id]
|
||
|
||
logger.info(f"Strategy {strategy_id} stopped")
|
||
self._console_print(f"[strategy:{strategy_id}] stopped (requested)")
|
||
return True
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to stop strategy {strategy_id}: {str(e)}")
|
||
logger.error(traceback.format_exc())
|
||
return False
|
||
|
||
def _run_strategy_loop(self, strategy_id: int):
|
||
"""
|
||
strategy run loop
|
||
|
||
Args:
|
||
strategy_id: Strategy ID
|
||
"""
|
||
logger.info(f"Strategy {strategy_id} loop starting")
|
||
self._console_print(f"[strategy:{strategy_id}] loop initializing")
|
||
|
||
try:
|
||
# Load policy configuration
|
||
strategy = self._load_strategy(strategy_id)
|
||
if not strategy:
|
||
logger.error(f"Strategy {strategy_id} not found")
|
||
return
|
||
|
||
if strategy['strategy_type'] != 'IndicatorStrategy':
|
||
logger.error(f"Strategy {strategy_id} has unsupported strategy_type for realtime execution: {strategy['strategy_type']}")
|
||
return
|
||
|
||
# Initialize policy state
|
||
trading_config = strategy['trading_config']
|
||
indicator_config = strategy['indicator_config']
|
||
ai_model_config = strategy.get('ai_model_config') or {}
|
||
execution_mode = (strategy.get('execution_mode') or 'signal').strip().lower()
|
||
if execution_mode not in ['signal', 'live']:
|
||
execution_mode = 'signal'
|
||
notification_config = strategy.get('notification_config') or {}
|
||
strategy_name = strategy.get('strategy_name') or f"strategy_{int(strategy_id)}"
|
||
symbol = trading_config.get('symbol', '')
|
||
timeframe = trading_config.get('timeframe', '1H')
|
||
|
||
# Secure access to leverage and trade_direction
|
||
try:
|
||
leverage_val = trading_config.get('leverage', 1)
|
||
if isinstance(leverage_val, (list, tuple)):
|
||
leverage_val = leverage_val[0] if leverage_val else 1
|
||
leverage = float(leverage_val)
|
||
except:
|
||
logger.warning(f"Strategy {strategy_id} invalid leverage format, reset to 1: {trading_config.get('leverage')}")
|
||
leverage = 1.0
|
||
|
||
# Get the market type, default is contract
|
||
# Automatic judgment based on leverage: leverage = 1 for spot, leverage > 1 for contract
|
||
market_type = trading_config.get('market_type', 'swap')
|
||
if market_type not in ['swap', 'spot']:
|
||
logger.error(f"Strategy {strategy_id} invalid market_type={market_type} (only swap/spot supported); refusing to start")
|
||
return
|
||
|
||
# Automatically adjust market type based on leverage
|
||
if leverage == 1.0:
|
||
market_type = 'spot' # Spot fixed 1x leverage
|
||
logger.info(f"Strategy {strategy_id} leverage=1; auto-switch market_type to spot")
|
||
else:
|
||
# Contract market: uniformly use swap (perpetual) to avoid futures/delivery confusion that may lead to position/order checking in the wrong market.
|
||
market_type = 'swap'
|
||
logger.info(f"Strategy {strategy_id} derivatives trading; normalize market_type to: {market_type}")
|
||
|
||
# Limit leverage based on market type
|
||
if market_type == 'spot':
|
||
leverage = 1.0 # Spot fixed 1x leverage
|
||
elif leverage < 1:
|
||
leverage = 1.0
|
||
elif leverage > 125:
|
||
leverage = 125.0
|
||
logger.warning(f"Strategy {strategy_id} leverage > 125; capped to 125")
|
||
|
||
# Get the trading direction, spot can only go long
|
||
trade_direction = trading_config.get('trade_direction', 'long')
|
||
if market_type == 'spot':
|
||
trade_direction = 'long' # Spot prices can only be long
|
||
logger.info(f"Strategy {strategy_id} spot trading; force trade_direction=long")
|
||
|
||
# Get market category (Crypto, USStock, Forex, Futures)
|
||
# This determines which data source to use to obtain price and K-line data
|
||
market_category = (strategy.get('market_category') or 'Crypto').strip()
|
||
logger.info(f"Strategy {strategy_id} market_category: {market_category}")
|
||
|
||
# Check if this is a cross-sectional strategy
|
||
cs_strategy_type = trading_config.get('cs_strategy_type', 'single')
|
||
if cs_strategy_type == 'cross_sectional':
|
||
# Run cross-sectional strategy loop
|
||
self._run_cross_sectional_strategy_loop(
|
||
strategy_id, strategy, trading_config, indicator_config,
|
||
ai_model_config, execution_mode, notification_config,
|
||
strategy_name, market_category, market_type, leverage,
|
||
initial_capital, indicator_code, indicator_id
|
||
)
|
||
return
|
||
|
||
# Initialize exchange connection (no real connection required in signal mode)
|
||
exchange = None
|
||
|
||
# Safely obtain initial_capital
|
||
try:
|
||
initial_capital_val = strategy.get('initial_capital', 1000)
|
||
if isinstance(initial_capital_val, (list, tuple)):
|
||
initial_capital_val = initial_capital_val[0] if initial_capital_val else 1000
|
||
initial_capital = float(initial_capital_val)
|
||
except:
|
||
logger.warning(f"Strategy {strategy_id} invalid initial_capital format, reset to 1000: {strategy.get('initial_capital')}")
|
||
initial_capital = 1000.0
|
||
|
||
# Equity is automatically calculated and updated the first time a position is updated
|
||
|
||
# Get indicator code
|
||
indicator_id = indicator_config.get('indicator_id')
|
||
indicator_code = indicator_config.get('indicator_code', '')
|
||
|
||
# If the code is empty, try to get it from the database
|
||
if not indicator_code and indicator_id:
|
||
indicator_code = self._get_indicator_code_from_db(indicator_id)
|
||
|
||
if not indicator_code:
|
||
logger.error(f"Strategy {strategy_id} indicator_code is empty")
|
||
return
|
||
|
||
# Make sure indicator_code is a string (to handle JSON escaping issues)
|
||
if not isinstance(indicator_code, str):
|
||
indicator_code = str(indicator_code)
|
||
|
||
# Handle possible JSON escaping issues
|
||
if '\\n' in indicator_code and '\n' not in indicator_code:
|
||
try:
|
||
import json
|
||
decoded = json.loads(f'"{indicator_code}"')
|
||
if isinstance(decoded, str):
|
||
indicator_code = decoded
|
||
logger.info(f"Strategy {strategy_id} decoded escaped indicator_code")
|
||
except Exception as e:
|
||
logger.warning(f"Strategy {strategy_id} JSON decode failed; falling back to manual unescape: {str(e)}")
|
||
indicator_code = (
|
||
indicator_code
|
||
.replace('\\n', '\n')
|
||
.replace('\\t', '\t')
|
||
.replace('\\r', '\r')
|
||
.replace('\\"', '"')
|
||
.replace("\\'", "'")
|
||
.replace('\\\\', '\\')
|
||
)
|
||
|
||
# ============================================
|
||
# Initialization phase: Obtain historical K-lines and calculate indicators
|
||
# ============================================
|
||
# logger.info(f"Strategy {strategy_id} initialization: Get historical K-line data...")
|
||
history_limit = int(os.getenv('K_LINE_HISTORY_GET_NUMBER', 500))
|
||
klines = self._fetch_latest_kline(symbol, timeframe, limit=history_limit, market_category=market_category)
|
||
if not klines or len(klines) < 2:
|
||
logger.error(f"Strategy {strategy_id} failed to fetch K-lines")
|
||
return
|
||
logger.info(rf'Strategy {strategy_id} history kline number: {len(klines)}')
|
||
|
||
# Convert to DataFrame
|
||
df = self._klines_to_dataframe(klines)
|
||
if len(df) == 0:
|
||
logger.error(f"Strategy {strategy_id} K-lines are empty after normalization")
|
||
return
|
||
|
||
# ============================================
|
||
# At startup: synchronize position status and clean up "ghost positions"
|
||
# ============================================
|
||
# Even in signal mode, it is necessary to check and clean up the situation when the user manually closes the position on the exchange but the database record is still there.
|
||
# This can prevent the strategy from thinking that there are still positions and being unable to execute a new opening signal.
|
||
try:
|
||
logger.info(f"Checking position synchronization during startup for strategy {strategy_id}...")
|
||
# Call position synchronization logic (check even in signal mode)
|
||
from app import get_pending_order_worker
|
||
worker = get_pending_order_worker()
|
||
if worker and hasattr(worker, '_sync_positions_best_effort'):
|
||
worker._sync_positions_best_effort(target_strategy_id=strategy_id)
|
||
logger.info(f"Position synchronization completed during startup for strategy {strategy_id}")
|
||
except Exception as e:
|
||
logger.warning(f"Position synchronization failed during startup for strategy {strategy_id} (startup continues): {e}")
|
||
|
||
# Get the current highest position price (read from local database)
|
||
current_pos_list = self._get_current_positions(strategy_id, symbol)
|
||
initial_highest = 0.0
|
||
initial_position = 0 # 0=No position, 1=Long position, -1=Short position
|
||
initial_avg_entry_price = 0.0
|
||
initial_position_count = 0
|
||
initial_last_add_price = 0.0
|
||
|
||
if current_pos_list:
|
||
pos = current_pos_list[0] # Take the first position (one-way position mode)
|
||
initial_highest = float(pos.get('highest_price', 0) or 0)
|
||
pos_side = pos.get('side', 'long')
|
||
initial_position = 1 if pos_side == 'long' else -1
|
||
initial_avg_entry_price = float(pos.get('entry_price', 0) or 0)
|
||
initial_position_count = 1 # To simplify the process, assume it is a single position
|
||
initial_last_add_price = initial_avg_entry_price
|
||
|
||
# Key diagnostic log: Confirm whether the indicator has obtained the position status
|
||
logger.info(
|
||
f"Injected position state for strategy {strategy_id}: count={len(current_pos_list)}, "
|
||
f"position={initial_position}, entry_price={initial_avg_entry_price}, highest={initial_highest}"
|
||
)
|
||
|
||
# Execute indicator code, get signals and trigger prices
|
||
indicator_result = self._execute_indicator_with_prices(
|
||
indicator_code, df, trading_config,
|
||
initial_highest_price=initial_highest,
|
||
initial_position=initial_position,
|
||
initial_avg_entry_price=initial_avg_entry_price,
|
||
initial_position_count=initial_position_count,
|
||
initial_last_add_price=initial_last_add_price
|
||
)
|
||
if indicator_result is None:
|
||
logger.error(f"Strategy {strategy_id} indicator execution failed")
|
||
return
|
||
|
||
# Extract signals and trigger prices
|
||
pending_signals = indicator_result.get('pending_signals', []) # List of signals to be triggered
|
||
last_kline_time = indicator_result.get('last_kline_time', 0) # The time of the last K-line
|
||
|
||
logger.info(f"Strategy {strategy_id} initialized; pending_signals={len(pending_signals)}")
|
||
if pending_signals:
|
||
logger.info(f"Initial signals: {pending_signals}")
|
||
|
||
# ============================================
|
||
# Main loop: unified tick cadence (default: 10s)
|
||
# ============================================
|
||
# One tick = fetch current price once + evaluate triggers once + (if needed) refresh K-lines / recalc indicator.
|
||
# Note: `pending_orders` scanning stays at 1s (see PendingOrderWorker) to reduce live dispatch latency.
|
||
try:
|
||
# Global-only (no per-strategy override)
|
||
tick_interval_sec = int(os.getenv('STRATEGY_TICK_INTERVAL_SEC', '10'))
|
||
except Exception:
|
||
tick_interval_sec = 10
|
||
if tick_interval_sec < 1:
|
||
tick_interval_sec = 1
|
||
|
||
last_tick_time = 0.0
|
||
last_kline_update_time = time.time()
|
||
|
||
# Calculate K-line period (seconds)
|
||
from app.data_sources.base import TIMEFRAME_SECONDS
|
||
timeframe_seconds = TIMEFRAME_SECONDS.get(timeframe, 3600)
|
||
kline_update_interval = timeframe_seconds # Updated once every K-line cycle
|
||
|
||
while True:
|
||
try:
|
||
# Check policy status
|
||
if not self._is_strategy_running(strategy_id):
|
||
logger.info(f"Strategy {strategy_id} stopped")
|
||
break
|
||
|
||
current_time = time.time()
|
||
|
||
# Sleep until next tick to avoid CPU spin.
|
||
if last_tick_time > 0:
|
||
sleep_sec = (last_tick_time + tick_interval_sec) - current_time
|
||
if sleep_sec > 0:
|
||
time.sleep(min(sleep_sec, 1.0))
|
||
continue
|
||
last_tick_time = current_time
|
||
|
||
# ============================================
|
||
# 0. Virtual position mode, no need to synchronize exchanges
|
||
# ============================================
|
||
# pass
|
||
|
||
# ============================================
|
||
# 1. Fetch current price once per tick
|
||
# ============================================
|
||
current_price = self._fetch_current_price(exchange, symbol, market_type=market_type, market_category=market_category)
|
||
if current_price is None:
|
||
logger.warning(f"Strategy {strategy_id} failed to fetch current price for {market_category}:{symbol}")
|
||
continue
|
||
|
||
# ============================================
|
||
# 2. Check whether the K-line needs to be updated (updated once every K-line cycle, pulled from API)
|
||
# ============================================
|
||
if current_time - last_kline_update_time >= kline_update_interval:
|
||
klines = self._fetch_latest_kline(symbol, timeframe, limit=history_limit, market_category=market_category)
|
||
if klines and len(klines) >= 2:
|
||
df = self._klines_to_dataframe(klines)
|
||
if len(df) > 0:
|
||
current_pos_list = self._get_current_positions(strategy_id, symbol)
|
||
initial_highest = 0.0
|
||
initial_position = 0
|
||
initial_avg_entry_price = 0.0
|
||
initial_position_count = 0
|
||
initial_last_add_price = 0.0
|
||
|
||
if current_pos_list:
|
||
pos = current_pos_list[0]
|
||
initial_highest = float(pos.get('highest_price', 0) or 0)
|
||
pos_side = pos.get('side', 'long')
|
||
initial_position = 1 if pos_side == 'long' else -1
|
||
initial_avg_entry_price = float(pos.get('entry_price', 0) or 0)
|
||
initial_position_count = 1
|
||
initial_last_add_price = initial_avg_entry_price
|
||
|
||
indicator_result = self._execute_indicator_with_prices(
|
||
indicator_code, df, trading_config,
|
||
initial_highest_price=initial_highest,
|
||
initial_position=initial_position,
|
||
initial_avg_entry_price=initial_avg_entry_price,
|
||
initial_position_count=initial_position_count,
|
||
initial_last_add_price=initial_last_add_price
|
||
)
|
||
if indicator_result:
|
||
pending_signals = indicator_result.get('pending_signals', [])
|
||
last_kline_time = indicator_result.get('last_kline_time', 0)
|
||
new_hp = indicator_result.get('new_highest_price', 0)
|
||
|
||
last_kline_update_time = current_time
|
||
|
||
# Update highest_price (using latest close as an approximation of current_price)
|
||
if new_hp > 0 and current_pos_list:
|
||
current_close = float(df['close'].iloc[-1])
|
||
for p in current_pos_list:
|
||
self._update_position(
|
||
strategy_id, p['symbol'], p['side'],
|
||
float(p['size']), float(p['entry_price']),
|
||
current_close,
|
||
highest_price=new_hp
|
||
)
|
||
else:
|
||
# ============================================
|
||
# 3. Non-K-line update tick: update the last K-line with the current price and recalculate the indicator (unify the tick rhythm)
|
||
# ============================================
|
||
if 'df' in locals() and df is not None and len(df) > 0:
|
||
try:
|
||
realtime_df = df.copy()
|
||
realtime_df = self._update_dataframe_with_current_price(realtime_df, current_price, timeframe)
|
||
|
||
current_pos_list = self._get_current_positions(strategy_id, symbol)
|
||
initial_highest = 0.0
|
||
initial_position = 0
|
||
initial_avg_entry_price = 0.0
|
||
initial_position_count = 0
|
||
initial_last_add_price = 0.0
|
||
|
||
if current_pos_list:
|
||
pos = current_pos_list[0]
|
||
initial_highest = float(pos.get('highest_price', 0) or 0)
|
||
pos_side = pos.get('side', 'long')
|
||
initial_position = 1 if pos_side == 'long' else -1
|
||
initial_avg_entry_price = float(pos.get('entry_price', 0) or 0)
|
||
initial_position_count = 1
|
||
initial_last_add_price = initial_avg_entry_price
|
||
|
||
indicator_result = self._execute_indicator_with_prices(
|
||
indicator_code, realtime_df, trading_config,
|
||
initial_highest_price=initial_highest,
|
||
initial_position=initial_position,
|
||
initial_avg_entry_price=initial_avg_entry_price,
|
||
initial_position_count=initial_position_count,
|
||
initial_last_add_price=initial_last_add_price
|
||
)
|
||
if indicator_result:
|
||
pending_signals = indicator_result.get('pending_signals', [])
|
||
new_hp = indicator_result.get('new_highest_price', 0)
|
||
|
||
if new_hp > 0 and current_pos_list:
|
||
for p in current_pos_list:
|
||
self._update_position(
|
||
strategy_id, p['symbol'], p['side'],
|
||
float(p['size']), float(p['entry_price']),
|
||
current_price,
|
||
highest_price=new_hp
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"Strategy {strategy_id} realtime indicator recompute failed: {str(e)}")
|
||
|
||
# ============================================
|
||
# 4. Evaluate triggers once per tick
|
||
# ============================================
|
||
# Optimization point 4: Signal expiration cleanup (Signal Expiration)
|
||
current_ts = int(time.time())
|
||
if pending_signals:
|
||
expiration_threshold = timeframe_seconds * 2
|
||
valid_signals = []
|
||
for s in pending_signals:
|
||
signal_time = s.get('timestamp', 0)
|
||
if signal_time == 0 or (current_ts - signal_time) < expiration_threshold:
|
||
valid_signals.append(s)
|
||
else:
|
||
logger.warning(f"Signal expired and removed: {s}")
|
||
if len(valid_signals) != len(pending_signals):
|
||
pending_signals = valid_signals
|
||
|
||
# Unified cadence log: at most once per tick.
|
||
if pending_signals:
|
||
logger.info(f"[monitoring] strategy={strategy_id} price={current_price}, pending_signals={len(pending_signals)}")
|
||
|
||
# Check if there is a signal to be triggered
|
||
triggered_signals = []
|
||
signals_to_remove = []
|
||
|
||
for signal_info in pending_signals:
|
||
signal_type = signal_info.get('type') # 'open_long', 'close_long', 'open_short', 'close_short'
|
||
trigger_price = signal_info.get('trigger_price', 0)
|
||
|
||
# Check if price triggers
|
||
triggered = False
|
||
|
||
# [Key Fix] Position closing/stop loss and take profit signals default to "trigger immediately"
|
||
exit_trigger_mode = trading_config.get('exit_trigger_mode', 'immediate') # 'immediate' or 'price'
|
||
if signal_type in ['close_long', 'close_short'] and exit_trigger_mode == 'immediate':
|
||
triggered = True
|
||
|
||
# [Optional] Whether the signal to open/increase a position is "triggered immediately"
|
||
entry_trigger_mode = trading_config.get('entry_trigger_mode', 'price') # 'price' or 'immediate'
|
||
if signal_type in ['open_long', 'open_short', 'add_long', 'add_short'] and entry_trigger_mode == 'immediate':
|
||
triggered = True
|
||
|
||
if trigger_price > 0:
|
||
if signal_type in ['open_long', 'close_short', 'add_long']:
|
||
if current_price >= trigger_price:
|
||
triggered = True
|
||
elif signal_type in ['open_short', 'close_long', 'add_short']:
|
||
if current_price <= trigger_price:
|
||
triggered = True
|
||
else:
|
||
triggered = True
|
||
|
||
if triggered:
|
||
triggered_signals.append(signal_info)
|
||
signals_to_remove.append(signal_info)
|
||
|
||
# ============================================
|
||
# 4.1 Server-side exits (config-driven): SL / TP / trailing
|
||
# ============================================
|
||
# Note: stop-loss is only applied when stop_loss_pct > 0. No default fallback.
|
||
risk_tp = self._server_side_take_profit_or_trailing_signal(
|
||
strategy_id=strategy_id,
|
||
symbol=symbol,
|
||
current_price=float(current_price),
|
||
market_type=market_type,
|
||
leverage=float(leverage),
|
||
trading_config=trading_config,
|
||
timeframe_seconds=int(timeframe_seconds or 60),
|
||
)
|
||
if risk_tp:
|
||
triggered_signals.append(risk_tp)
|
||
|
||
risk_sl = self._server_side_stop_loss_signal(
|
||
strategy_id=strategy_id,
|
||
symbol=symbol,
|
||
current_price=float(current_price),
|
||
market_type=market_type,
|
||
leverage=float(leverage),
|
||
trading_config=trading_config,
|
||
timeframe_seconds=int(timeframe_seconds or 60),
|
||
)
|
||
if risk_sl:
|
||
triggered_signals.append(risk_sl)
|
||
|
||
# Remove a triggered signal from the pending trigger list
|
||
for signal_info in signals_to_remove:
|
||
if signal_info in pending_signals:
|
||
pending_signals.remove(signal_info)
|
||
|
||
# Execution trigger signal
|
||
if triggered_signals:
|
||
logger.info(f"Strategy {strategy_id} triggered signals: {triggered_signals}")
|
||
|
||
current_positions = self._get_current_positions(strategy_id, symbol)
|
||
state = self._position_state(current_positions)
|
||
|
||
# Strict state machine + priority:
|
||
# - Only allow signals matching current state (flat/long/short).
|
||
# - Always prefer close_* over open_*/add_*.
|
||
# - Execute at most ONE signal per tick to avoid duplicated/re-entrant orders.
|
||
candidates = [s for s in triggered_signals if self._is_signal_allowed(state, s.get('type'))]
|
||
|
||
# If both directions are present while flat, choose by trade_direction (deterministic).
|
||
if state == "flat" and candidates:
|
||
td = (trade_direction or "both").strip().lower()
|
||
if td == "long":
|
||
candidates = [s for s in candidates if s.get("type") == "open_long"]
|
||
elif td == "short":
|
||
candidates = [s for s in candidates if s.get("type") == "open_short"]
|
||
|
||
candidates = sorted(
|
||
candidates,
|
||
key=lambda s: (
|
||
self._signal_priority(s.get("type")),
|
||
int(s.get("timestamp") or 0),
|
||
str(s.get("type") or ""),
|
||
),
|
||
)
|
||
|
||
selected = None
|
||
now_i = int(time.time())
|
||
for s in candidates:
|
||
stype = s.get("type")
|
||
sts = int(s.get("timestamp") or 0)
|
||
if self._should_skip_signal_once_per_candle(
|
||
strategy_id=strategy_id,
|
||
symbol=symbol,
|
||
signal_type=str(stype or ""),
|
||
signal_ts=sts,
|
||
timeframe_seconds=int(timeframe_seconds or 60),
|
||
now_ts=now_i,
|
||
):
|
||
continue
|
||
selected = s
|
||
break
|
||
|
||
if selected:
|
||
signal_type = selected.get('type')
|
||
position_size = selected.get('position_size', 0)
|
||
trigger_price = selected.get('trigger_price', current_price)
|
||
execute_price = trigger_price if trigger_price > 0 else current_price
|
||
signal_ts = int(selected.get("timestamp") or 0)
|
||
|
||
ok = self._execute_signal(
|
||
strategy_id=strategy_id,
|
||
strategy_name=strategy_name,
|
||
exchange=exchange,
|
||
symbol=symbol,
|
||
current_price=execute_price,
|
||
signal_type=signal_type,
|
||
position_size=position_size,
|
||
signal_ts=signal_ts,
|
||
current_positions=current_positions,
|
||
trade_direction=trade_direction,
|
||
leverage=leverage,
|
||
initial_capital=initial_capital,
|
||
market_type=market_type,
|
||
market_category=market_category,
|
||
execution_mode=execution_mode,
|
||
notification_config=notification_config,
|
||
trading_config=trading_config,
|
||
ai_model_config=ai_model_config,
|
||
)
|
||
if ok:
|
||
logger.info(f"Strategy {strategy_id} signal executed: {signal_type} @ {execute_price}")
|
||
# Notify portfolio positions linked to this symbol
|
||
try:
|
||
from app.services.portfolio_monitor import notify_strategy_signal_for_positions
|
||
notify_strategy_signal_for_positions(
|
||
market=market_type or 'Crypto',
|
||
symbol=symbol,
|
||
signal_type=signal_type,
|
||
signal_detail=f"Strategy: {strategy_name}\nSignal: {signal_type}\nPrice: {execute_price:.4f}"
|
||
)
|
||
except Exception as link_e:
|
||
logger.warning(f"Strategy signal linkage notification failed: {link_e}")
|
||
else:
|
||
logger.warning(f"Strategy {strategy_id} signal rejected/failed: {signal_type}")
|
||
|
||
# Update positions once per tick.
|
||
self._update_positions(strategy_id, symbol, current_price)
|
||
|
||
# Heartbeat for UI observability (once per tick).
|
||
self._console_print(
|
||
f"[strategy:{strategy_id}] tick price={float(current_price or 0.0):.8f} pending_signals={len(pending_signals or [])}"
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Strategy {strategy_id} loop error: {str(e)}")
|
||
logger.error(traceback.format_exc())
|
||
self._console_print(f"[strategy:{strategy_id}] loop error: {e}")
|
||
time.sleep(5)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Strategy {strategy_id} crashed: {str(e)}")
|
||
logger.error(traceback.format_exc())
|
||
self._console_print(f"[strategy:{strategy_id}] fatal error: {e}")
|
||
finally:
|
||
# clean up
|
||
with self.lock:
|
||
if strategy_id in self.running_strategies:
|
||
del self.running_strategies[strategy_id]
|
||
self._console_print(f"[strategy:{strategy_id}] loop exited")
|
||
logger.info(f"Strategy {strategy_id} loop exited")
|
||
|
||
def _sync_positions_with_exchange(self, strategy_id: int, exchange: Any, symbol: str, market_type: str):
|
||
"""
|
||
[Depracated] No need to synchronize exchange positions in signal mode
|
||
"""
|
||
pass
|
||
|
||
def _load_strategy(self, strategy_id: int) -> Optional[Dict[str, Any]]:
|
||
"""Load strategy config (local deployment: no encryption/decryption)."""
|
||
try:
|
||
with get_db_connection() as db:
|
||
cursor = db.cursor()
|
||
query = """
|
||
SELECT
|
||
id, strategy_name, strategy_type, status,
|
||
initial_capital, leverage, decide_interval,
|
||
execution_mode, notification_config,
|
||
indicator_config, exchange_config, trading_config, ai_model_config,
|
||
market_category
|
||
FROM qd_strategies_trading
|
||
WHERE id = %s
|
||
"""
|
||
cursor.execute(query, (strategy_id,))
|
||
strategy = cursor.fetchone()
|
||
cursor.close()
|
||
|
||
if strategy:
|
||
# Parse JSON fields
|
||
for field in ['indicator_config', 'trading_config', 'notification_config', 'ai_model_config']:
|
||
if isinstance(strategy.get(field), str):
|
||
try:
|
||
strategy[field] = json.loads(strategy[field])
|
||
except:
|
||
strategy[field] = {}
|
||
|
||
# exchange_config: local deployment stores plaintext JSON
|
||
exchange_config_str = strategy.get('exchange_config', '{}')
|
||
if isinstance(exchange_config_str, str) and exchange_config_str:
|
||
try:
|
||
strategy['exchange_config'] = json.loads(exchange_config_str)
|
||
except Exception as e:
|
||
logger.error(f"Strategy {strategy_id} failed to parse exchange_config: {str(e)}")
|
||
# Try parsing JSON directly (backwards compatible)
|
||
try:
|
||
strategy['exchange_config'] = json.loads(exchange_config_str)
|
||
except:
|
||
strategy['exchange_config'] = {}
|
||
else:
|
||
strategy['exchange_config'] = {}
|
||
|
||
return strategy
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to load strategy config: {str(e)}")
|
||
return None
|
||
|
||
def _is_strategy_running(self, strategy_id: int) -> bool:
|
||
"""
|
||
Check whether the strategy is running.
|
||
This checks both the database state and the thread state to avoid mismatches after restart.
|
||
"""
|
||
try:
|
||
# 1. Check database status
|
||
with get_db_connection() as db:
|
||
cursor = db.cursor()
|
||
cursor.execute(
|
||
"SELECT status FROM qd_strategies_trading WHERE id = %s",
|
||
(strategy_id,)
|
||
)
|
||
result = cursor.fetchone()
|
||
cursor.close()
|
||
db_status = result and result.get('status') == 'running'
|
||
|
||
# 2. Check if the thread is actually running
|
||
with self.lock:
|
||
thread = self.running_strategies.get(strategy_id)
|
||
thread_running = thread is not None and thread.is_alive()
|
||
|
||
# 3. If the database status is running but the thread is not running, it means the status is inconsistent (may be recovery failure after restart)
|
||
if db_status and not thread_running:
|
||
logger.warning(f"Strategy {strategy_id} status mismatch: DB=running but thread not running. Updating DB status to stopped.")
|
||
# Update the database status to stopped to avoid the policy "zombie" state
|
||
try:
|
||
with get_db_connection() as db:
|
||
cursor = db.cursor()
|
||
cursor.execute(
|
||
"UPDATE qd_strategies_trading SET status = 'stopped' WHERE id = %s",
|
||
(strategy_id,)
|
||
)
|
||
db.commit()
|
||
cursor.close()
|
||
except Exception as e:
|
||
logger.error(f"Failed to update strategy {strategy_id} status to stopped: {e}")
|
||
return False
|
||
|
||
# 4. Return True only if the database status and thread status are consistent
|
||
return db_status and thread_running
|
||
except Exception as e:
|
||
logger.error(f"Error checking strategy {strategy_id} running status: {e}")
|
||
return False
|
||
|
||
def _init_exchange(
|
||
self,
|
||
exchange_config: Dict[str, Any],
|
||
market_type: str = None,
|
||
leverage: float = None,
|
||
strategy_id: int = None
|
||
) -> Optional[ccxt.Exchange]:
|
||
"""(Mock) Signal mode does not require a real exchange connection."""
|
||
return None
|
||
|
||
def _fetch_latest_kline(self, symbol: str, timeframe: str, limit: int = 500, market_category: str = 'Crypto') -> List[Dict[str, Any]]:
|
||
"""Get the latest K-line data (get it from cache first)
|
||
|
||
Args:
|
||
symbol: trading pair/symbol
|
||
timeframe: time period
|
||
limit: number of data items
|
||
market_category: Market type (Crypto, USStock, Forex, Futures)
|
||
"""
|
||
try:
|
||
# Use KlineService to obtain K-line data (automatically handle cache)
|
||
return self.kline_service.get_kline(
|
||
market=market_category,
|
||
symbol=symbol,
|
||
timeframe=timeframe,
|
||
limit=limit,
|
||
before_time=int(time.time())
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Failed to fetch K-lines for {market_category}:{symbol}: {str(e)}")
|
||
return []
|
||
|
||
def _fetch_current_price(self, exchange: Any, symbol: str, market_type: str = None, market_category: str = 'Crypto') -> Optional[float]:
|
||
"""Get the current price (select the correct data source based on market_category)
|
||
|
||
Args:
|
||
exchange: exchange instance (None in signal mode)
|
||
symbol: trading pair/symbol
|
||
market_type: transaction type (swap/spot)
|
||
market_category: Market type (Crypto, USStock, Forex, Futures)
|
||
"""
|
||
# Local in-memory cache first
|
||
cache_key = f"{market_category}:{(symbol or '').strip().upper()}"
|
||
if cache_key and self._price_cache_ttl_sec > 0:
|
||
now = time.time()
|
||
try:
|
||
with self._price_cache_lock:
|
||
item = self._price_cache.get(cache_key)
|
||
if item:
|
||
price, expiry = item
|
||
if expiry > now:
|
||
return float(price)
|
||
# expired
|
||
del self._price_cache[cache_key]
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
# Select the correct data source based on market_category
|
||
# Support: Crypto, USStock, Forex, Futures
|
||
ticker = DataSourceFactory.get_ticker(market_category, symbol)
|
||
if ticker:
|
||
price = float(ticker.get('last') or ticker.get('close') or 0)
|
||
if price > 0:
|
||
if cache_key and self._price_cache_ttl_sec > 0:
|
||
try:
|
||
with self._price_cache_lock:
|
||
self._price_cache[cache_key] = (float(price), time.time() + self._price_cache_ttl_sec)
|
||
except Exception:
|
||
pass
|
||
return price
|
||
except Exception as e:
|
||
logger.warning(f"Failed to fetch price for {market_category}:{symbol}: {e}")
|
||
|
||
return None
|
||
|
||
def _server_side_stop_loss_signal(
|
||
self,
|
||
strategy_id: int,
|
||
symbol: str,
|
||
current_price: float,
|
||
market_type: str,
|
||
leverage: float,
|
||
trading_config: Dict[str, Any],
|
||
timeframe_seconds: int,
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""
|
||
Server-side stop loss: when the price penetrates the stop loss line, close_long/close_short signals are directly generated.
|
||
|
||
Purpose: To prevent non-stop loss when "the indicator replay logic causes the last K-line to have no close_* signal" or "the pin rebound causes the secondary trigger condition to be unsatisfied".
|
||
"""
|
||
try:
|
||
if trading_config is None:
|
||
return None
|
||
|
||
enabled = trading_config.get('enable_server_side_stop_loss', True)
|
||
if str(enabled).lower() in ['0', 'false', 'no', 'off']:
|
||
return None
|
||
|
||
# Get the current position (use local database records as risk control basis)
|
||
current_positions = self._get_current_positions(strategy_id, symbol)
|
||
if not current_positions:
|
||
return None
|
||
|
||
pos = current_positions[0]
|
||
side = pos.get('side')
|
||
if side not in ['long', 'short']:
|
||
return None
|
||
|
||
entry_price = float(pos.get('entry_price', 0) or 0)
|
||
if entry_price <= 0 or current_price <= 0:
|
||
return None
|
||
|
||
# Stop-loss is config-driven: if stop_loss_pct is not set or <= 0, do NOT stop-loss.
|
||
sl_cfg = trading_config.get('stop_loss_pct', 0)
|
||
sl = 0.0
|
||
try:
|
||
sl_cfg = float(sl_cfg or 0)
|
||
if sl_cfg > 1:
|
||
sl = sl_cfg / 100.0
|
||
else:
|
||
sl = sl_cfg
|
||
except Exception:
|
||
sl = 0.0
|
||
|
||
if sl <= 0:
|
||
return None
|
||
|
||
# Align with backtest semantics: risk percentages are defined on margin PnL,
|
||
# so we convert to price move threshold by dividing by leverage.
|
||
lev = max(1.0, float(leverage or 1.0))
|
||
sl = sl / lev
|
||
|
||
# Use candle start timestamp to deduplicate exit attempts within a candle.
|
||
now_ts = int(time.time())
|
||
tf = int(timeframe_seconds or 60)
|
||
candle_ts = int(now_ts // tf) * tf
|
||
|
||
# Bulls: Falling below the stop loss line
|
||
if side == 'long':
|
||
stop_line = entry_price * (1 - sl)
|
||
if current_price <= stop_line:
|
||
return {
|
||
'type': 'close_long',
|
||
'trigger_price': 0, # Trigger immediately (controlled by exit_trigger_mode)
|
||
'position_size': 0,
|
||
'timestamp': candle_ts,
|
||
'reason': 'server_stop_loss',
|
||
'stop_loss_price': stop_line,
|
||
}
|
||
# Short: Stop loss line broken
|
||
elif side == 'short':
|
||
stop_line = entry_price * (1 + sl)
|
||
if current_price >= stop_line:
|
||
return {
|
||
'type': 'close_short',
|
||
'trigger_price': 0,
|
||
'position_size': 0,
|
||
'timestamp': candle_ts,
|
||
'reason': 'server_stop_loss',
|
||
'stop_loss_price': stop_line,
|
||
}
|
||
|
||
return None
|
||
except Exception as e:
|
||
logger.warning(f"Strategy {strategy_id} server-side stop-loss check failed: {str(e)}")
|
||
return None
|
||
|
||
def _server_side_take_profit_or_trailing_signal(
|
||
self,
|
||
strategy_id: int,
|
||
symbol: str,
|
||
current_price: float,
|
||
market_type: str,
|
||
leverage: float,
|
||
trading_config: Dict[str, Any],
|
||
timeframe_seconds: int,
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""
|
||
Server-side exits driven by trading_config (no indicator script required):
|
||
- Fixed take-profit: take_profit_pct
|
||
- Trailing stop: trailing_enabled + trailing_stop_pct + trailing_activation_pct
|
||
|
||
Semantics align with BacktestService:
|
||
- Percentages are defined on margin PnL; effective price threshold = pct / leverage.
|
||
- When trailing is enabled, fixed take-profit is disabled to avoid ambiguity.
|
||
"""
|
||
try:
|
||
if not trading_config:
|
||
return None
|
||
|
||
current_positions = self._get_current_positions(strategy_id, symbol)
|
||
if not current_positions:
|
||
return None
|
||
|
||
pos = current_positions[0]
|
||
side = (pos.get('side') or '').strip().lower()
|
||
if side not in ['long', 'short']:
|
||
return None
|
||
|
||
entry_price = float(pos.get('entry_price', 0) or 0)
|
||
if entry_price <= 0 or current_price <= 0:
|
||
return None
|
||
|
||
lev = max(1.0, float(leverage or 1.0))
|
||
|
||
tp = self._to_ratio(trading_config.get('take_profit_pct'))
|
||
trailing_enabled = bool(trading_config.get('trailing_enabled'))
|
||
trailing_pct = self._to_ratio(trading_config.get('trailing_stop_pct'))
|
||
trailing_act = self._to_ratio(trading_config.get('trailing_activation_pct'))
|
||
|
||
tp_eff = (tp / lev) if tp > 0 else 0.0
|
||
trailing_pct_eff = (trailing_pct / lev) if trailing_pct > 0 else 0.0
|
||
trailing_act_eff = (trailing_act / lev) if trailing_act > 0 else 0.0
|
||
|
||
# Conflict rule: when trailing is enabled, fixed TP is disabled.
|
||
if trailing_enabled and trailing_pct_eff > 0:
|
||
tp_eff = 0.0
|
||
# If activationPct is missing, reuse take_profit_pct as activation threshold.
|
||
if trailing_act_eff <= 0 and tp > 0:
|
||
trailing_act_eff = tp / lev
|
||
|
||
now_ts = int(time.time())
|
||
tf = int(timeframe_seconds or 60)
|
||
candle_ts = int(now_ts // tf) * tf
|
||
|
||
# Highest/lowest tracking (persisted in DB so restart continues trailing correctly)
|
||
try:
|
||
hp = float(pos.get('highest_price') or 0.0)
|
||
except Exception:
|
||
hp = 0.0
|
||
try:
|
||
lp = float(pos.get('lowest_price') or 0.0)
|
||
except Exception:
|
||
lp = 0.0
|
||
|
||
if hp <= 0:
|
||
hp = entry_price
|
||
hp = max(hp, float(current_price))
|
||
|
||
if lp <= 0:
|
||
lp = entry_price
|
||
lp = min(lp, float(current_price))
|
||
|
||
# Persist best-effort
|
||
try:
|
||
self._update_position(
|
||
strategy_id=strategy_id,
|
||
symbol=pos.get('symbol') or symbol,
|
||
side=side,
|
||
size=float(pos.get('size') or 0.0),
|
||
entry_price=entry_price,
|
||
current_price=float(current_price),
|
||
highest_price=hp,
|
||
lowest_price=lp,
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
# 1) Trailing stop
|
||
if trailing_enabled and trailing_pct_eff > 0:
|
||
if side == 'long':
|
||
active = True
|
||
if trailing_act_eff > 0:
|
||
active = hp >= entry_price * (1 + trailing_act_eff)
|
||
if active:
|
||
stop_line = hp * (1 - trailing_pct_eff)
|
||
if current_price <= stop_line:
|
||
return {
|
||
'type': 'close_long',
|
||
'trigger_price': 0,
|
||
'position_size': 0,
|
||
'timestamp': candle_ts,
|
||
'reason': 'server_trailing_stop',
|
||
'trailing_stop_price': stop_line,
|
||
'highest_price': hp,
|
||
}
|
||
else:
|
||
active = True
|
||
if trailing_act_eff > 0:
|
||
active = lp <= entry_price * (1 - trailing_act_eff)
|
||
if active:
|
||
stop_line = lp * (1 + trailing_pct_eff)
|
||
if current_price >= stop_line:
|
||
return {
|
||
'type': 'close_short',
|
||
'trigger_price': 0,
|
||
'position_size': 0,
|
||
'timestamp': candle_ts,
|
||
'reason': 'server_trailing_stop',
|
||
'trailing_stop_price': stop_line,
|
||
'lowest_price': lp,
|
||
}
|
||
|
||
# 2) Fixed take-profit (only when trailing is disabled)
|
||
if tp_eff > 0:
|
||
if side == 'long':
|
||
tp_line = entry_price * (1 + tp_eff)
|
||
if current_price >= tp_line:
|
||
return {
|
||
'type': 'close_long',
|
||
'trigger_price': 0,
|
||
'position_size': 0,
|
||
'timestamp': candle_ts,
|
||
'reason': 'server_take_profit',
|
||
'take_profit_price': tp_line,
|
||
}
|
||
else:
|
||
tp_line = entry_price * (1 - tp_eff)
|
||
if current_price <= tp_line:
|
||
return {
|
||
'type': 'close_short',
|
||
'trigger_price': 0,
|
||
'position_size': 0,
|
||
'timestamp': candle_ts,
|
||
'reason': 'server_take_profit',
|
||
'take_profit_price': tp_line,
|
||
}
|
||
|
||
return None
|
||
except Exception:
|
||
return None
|
||
|
||
def _klines_to_dataframe(self, klines: List[Dict[str, Any]]) -> pd.DataFrame:
|
||
"""Convert K-line data to DataFrame"""
|
||
if not klines:
|
||
# Returns an empty DataFrame with the correct columns
|
||
return pd.DataFrame(columns=['open', 'high', 'low', 'close', 'volume'])
|
||
|
||
# Create DataFrame
|
||
df = pd.DataFrame(klines)
|
||
|
||
# Convert time column.
|
||
# IMPORTANT: use UTC tz-aware index to avoid timezone skew when computing candle boundaries.
|
||
if 'time' in df.columns:
|
||
df['time'] = pd.to_datetime(df['time'], unit='s', utc=True)
|
||
df = df.set_index('time')
|
||
elif 'timestamp' in df.columns:
|
||
df['timestamp'] = pd.to_datetime(df['timestamp'], unit='s', utc=True)
|
||
df = df.set_index('timestamp')
|
||
|
||
# Make sure to include only the columns you need
|
||
required_columns = ['open', 'high', 'low', 'close', 'volume']
|
||
available_columns = [col for col in required_columns if col in df.columns]
|
||
if not available_columns:
|
||
logger.warning("K-lines are missing required columns")
|
||
return pd.DataFrame(columns=required_columns)
|
||
|
||
df = df[available_columns]
|
||
|
||
# Cast all numeric columns to float64 type
|
||
for col in ['open', 'high', 'low', 'close', 'volume']:
|
||
if col in df.columns:
|
||
# Convert to numeric type first, then cast to float64
|
||
df[col] = pd.to_numeric(df[col], errors='coerce').astype('float64')
|
||
|
||
# Delete rows containing NaN
|
||
df = df.dropna()
|
||
|
||
return df
|
||
|
||
def _update_dataframe_with_current_price(self, df: pd.DataFrame, current_price: float, timeframe: str) -> pd.DataFrame:
|
||
"""
|
||
Update the last bar of the DataFrame using the current price (for real-time calculations)
|
||
"""
|
||
if df is None or len(df) == 0:
|
||
return df
|
||
|
||
try:
|
||
# Get the time of the last K-line
|
||
last_time = df.index[-1]
|
||
|
||
# Calculate the K-line starting time corresponding to the current time
|
||
from app.data_sources.base import TIMEFRAME_SECONDS
|
||
timeframe_key = timeframe
|
||
if timeframe_key not in TIMEFRAME_SECONDS:
|
||
timeframe_key = str(timeframe_key).upper()
|
||
if timeframe_key not in TIMEFRAME_SECONDS:
|
||
timeframe_key = str(timeframe_key).lower()
|
||
tf_seconds = TIMEFRAME_SECONDS.get(timeframe_key, 60)
|
||
|
||
# Use epoch seconds directly to avoid naive datetime timezone conversion issues.
|
||
last_ts = float(last_time.timestamp())
|
||
now_ts = float(time.time())
|
||
|
||
# Calculate the starting time of the K-line to which the current price belongs
|
||
current_period_start = int(now_ts // tf_seconds) * tf_seconds
|
||
|
||
# Check whether the last K-line is for the current cycle
|
||
if abs(last_ts - current_period_start) < 2:
|
||
# Update the last one
|
||
df.iloc[-1, df.columns.get_loc('close')] = current_price
|
||
df.iloc[-1, df.columns.get_loc('high')] = max(df.iloc[-1]['high'], current_price)
|
||
df.iloc[-1, df.columns.get_loc('low')] = min(df.iloc[-1]['low'], current_price)
|
||
elif current_period_start > last_ts:
|
||
# Added new bank
|
||
new_row = pd.DataFrame({
|
||
'open': [current_price],
|
||
'high': [current_price],
|
||
'low': [current_price],
|
||
'close': [current_price],
|
||
'volume': [0.0]
|
||
}, index=[pd.to_datetime(current_period_start, unit='s', utc=True)])
|
||
|
||
df = pd.concat([df, new_row])
|
||
|
||
return df
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to update realtime candle: {str(e)}")
|
||
return df
|
||
|
||
def _execute_indicator_with_prices(
|
||
self, indicator_code: str, df: pd.DataFrame, trading_config: Dict[str, Any],
|
||
initial_highest_price: float = 0.0,
|
||
initial_position: int = 0,
|
||
initial_avg_entry_price: float = 0.0,
|
||
initial_position_count: int = 0,
|
||
initial_last_add_price: float = 0.0
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""
|
||
Execute the indicator code and extract the signal and price to be triggered
|
||
"""
|
||
try:
|
||
# Execution indicator code
|
||
executed_df, exec_env = self._execute_indicator_df(
|
||
indicator_code, df, trading_config,
|
||
initial_highest_price=initial_highest_price,
|
||
initial_position=initial_position,
|
||
initial_avg_entry_price=initial_avg_entry_price,
|
||
initial_position_count=initial_position_count,
|
||
initial_last_add_price=initial_last_add_price
|
||
)
|
||
if executed_df is None:
|
||
return None
|
||
|
||
# Extract the latest highest_price
|
||
new_highest_price = exec_env.get('highest_price', 0.0)
|
||
|
||
# Extract the time of the last K-line
|
||
last_kline_time = int(df.index[-1].timestamp()) if hasattr(df.index[-1], 'timestamp') else int(time.time())
|
||
|
||
# Extract the signal to be triggered
|
||
pending_signals = []
|
||
|
||
# Supported indicator signal formats:
|
||
# - Preferred (simple): df['buy'], df['sell'] as boolean
|
||
# - Internal (4-way): df['open_long'], df['close_long'], df['open_short'], df['close_short'] as boolean
|
||
if all(col in executed_df.columns for col in ['buy', 'sell']) and not all(col in executed_df.columns for col in ['open_long', 'close_long', 'open_short', 'close_short']):
|
||
# Normalize buy/sell into 4-way columns for execution.
|
||
td = trading_config.get('trade_direction', trading_config.get('tradeDirection', 'both'))
|
||
td = str(td or 'both').lower()
|
||
if td not in ['long', 'short', 'both']:
|
||
td = 'both'
|
||
|
||
buy = executed_df['buy'].fillna(False).astype(bool)
|
||
sell = executed_df['sell'].fillna(False).astype(bool)
|
||
|
||
executed_df = executed_df.copy()
|
||
if td == 'long':
|
||
executed_df['open_long'] = buy
|
||
executed_df['close_long'] = sell
|
||
executed_df['open_short'] = False
|
||
executed_df['close_short'] = False
|
||
elif td == 'short':
|
||
executed_df['open_long'] = False
|
||
executed_df['close_long'] = False
|
||
executed_df['open_short'] = sell
|
||
executed_df['close_short'] = buy
|
||
else:
|
||
executed_df['open_long'] = buy
|
||
executed_df['close_short'] = buy
|
||
executed_df['open_short'] = sell
|
||
executed_df['close_long'] = sell
|
||
|
||
# Check for 4-way columns after normalization
|
||
if all(col in executed_df.columns for col in ['open_long', 'close_long', 'open_short', 'close_short']):
|
||
# Optimization point 3: Prevent “signal flicker” (Repainting)
|
||
signal_mode = trading_config.get('signal_mode', 'confirmed') # 'confirmed' or 'aggressive'
|
||
exit_signal_mode = trading_config.get('exit_signal_mode', 'aggressive') # 'confirmed' or 'aggressive'
|
||
|
||
entry_check_set = set()
|
||
exit_check_set = set()
|
||
|
||
if len(executed_df) > 1:
|
||
# Always check the last completed K-line
|
||
entry_check_set.add(len(executed_df) - 2)
|
||
exit_check_set.add(len(executed_df) - 2)
|
||
|
||
if signal_mode == 'aggressive' and len(executed_df) > 0:
|
||
entry_check_set.add(len(executed_df) - 1)
|
||
|
||
if exit_signal_mode == 'aggressive' and len(executed_df) > 0:
|
||
exit_check_set.add(len(executed_df) - 1)
|
||
|
||
# Traverse the index uniformly (preserving deterministic ordering)
|
||
check_indices = sorted(entry_check_set.union(exit_check_set), reverse=True)
|
||
|
||
for idx in check_indices:
|
||
# Get the closing price of the K-line (as the default trigger price)
|
||
close_price = float(executed_df['close'].iloc[idx])
|
||
# The timestamp of the signal
|
||
signal_timestamp = int(executed_df.index[idx].timestamp()) if hasattr(executed_df.index[idx], 'timestamp') else last_kline_time
|
||
|
||
# Open long signal (only checked in entry_check_set)
|
||
if idx in entry_check_set and executed_df['open_long'].iloc[idx]:
|
||
trigger_price = close_price
|
||
position_size = 0.08
|
||
if 'position_size' in executed_df.columns:
|
||
pos_size = executed_df['position_size'].iloc[idx]
|
||
if pos_size > 0:
|
||
position_size = float(pos_size)
|
||
|
||
if not any(s['type'] == 'open_long' and s.get('timestamp') == signal_timestamp for s in pending_signals):
|
||
pending_signals.append({
|
||
'type': 'open_long',
|
||
'trigger_price': trigger_price,
|
||
'position_size': position_size,
|
||
'timestamp': signal_timestamp
|
||
})
|
||
|
||
# Hirata signal
|
||
if idx in exit_check_set and executed_df['close_long'].iloc[idx]:
|
||
trigger_price = close_price
|
||
if not any(s['type'] == 'close_long' and s.get('timestamp') == signal_timestamp for s in pending_signals):
|
||
pending_signals.append({
|
||
'type': 'close_long',
|
||
'trigger_price': trigger_price,
|
||
'position_size': 0,
|
||
'timestamp': signal_timestamp
|
||
})
|
||
|
||
# Open short signal
|
||
if idx in entry_check_set and executed_df['open_short'].iloc[idx]:
|
||
trigger_price = close_price
|
||
position_size = 0.08
|
||
if 'position_size' in executed_df.columns:
|
||
pos_size = executed_df['position_size'].iloc[idx]
|
||
if pos_size > 0:
|
||
position_size = float(pos_size)
|
||
|
||
if not any(s['type'] == 'open_short' and s.get('timestamp') == signal_timestamp for s in pending_signals):
|
||
pending_signals.append({
|
||
'type': 'open_short',
|
||
'trigger_price': trigger_price,
|
||
'position_size': position_size,
|
||
'timestamp': signal_timestamp
|
||
})
|
||
|
||
# flat signal
|
||
if idx in exit_check_set and executed_df['close_short'].iloc[idx]:
|
||
trigger_price = close_price
|
||
if not any(s['type'] == 'close_short' and s.get('timestamp') == signal_timestamp for s in pending_signals):
|
||
pending_signals.append({
|
||
'type': 'close_short',
|
||
'trigger_price': trigger_price,
|
||
'position_size': 0,
|
||
'timestamp': signal_timestamp
|
||
})
|
||
|
||
# add bull signal
|
||
if idx in entry_check_set and 'add_long' in executed_df.columns and executed_df['add_long'].iloc[idx]:
|
||
trigger_price = close_price
|
||
position_size = 0.06
|
||
if 'position_size' in executed_df.columns:
|
||
pos_size = executed_df['position_size'].iloc[idx]
|
||
if pos_size > 0:
|
||
position_size = float(pos_size)
|
||
|
||
if not any(s['type'] == 'add_long' and s.get('timestamp') == signal_timestamp for s in pending_signals):
|
||
pending_signals.append({
|
||
'type': 'add_long',
|
||
'trigger_price': trigger_price,
|
||
'position_size': position_size,
|
||
'timestamp': signal_timestamp
|
||
})
|
||
|
||
# Air conditioning signal
|
||
if idx in entry_check_set and 'add_short' in executed_df.columns and executed_df['add_short'].iloc[idx]:
|
||
trigger_price = close_price
|
||
position_size = 0.06
|
||
if 'position_size' in executed_df.columns:
|
||
pos_size = executed_df['position_size'].iloc[idx]
|
||
if pos_size > 0:
|
||
position_size = float(pos_size)
|
||
|
||
if not any(s['type'] == 'add_short' and s.get('timestamp') == signal_timestamp for s in pending_signals):
|
||
pending_signals.append({
|
||
'type': 'add_short',
|
||
'trigger_price': trigger_price,
|
||
'position_size': position_size,
|
||
'timestamp': signal_timestamp
|
||
})
|
||
|
||
# Reduce / scale-out signals (optional)
|
||
# These are used by position management rules (trend/adverse reduce) and should be treated as exits.
|
||
if idx in exit_check_set and 'reduce_long' in executed_df.columns and executed_df['reduce_long'].iloc[idx]:
|
||
trigger_price = close_price
|
||
reduce_pct = 0.1
|
||
if 'reduce_size' in executed_df.columns:
|
||
try:
|
||
reduce_pct = float(executed_df['reduce_size'].iloc[idx] or 0)
|
||
except Exception:
|
||
reduce_pct = 0.1
|
||
elif 'position_size' in executed_df.columns:
|
||
try:
|
||
reduce_pct = float(executed_df['position_size'].iloc[idx] or 0)
|
||
except Exception:
|
||
reduce_pct = 0.1
|
||
if reduce_pct <= 0:
|
||
reduce_pct = 0.1
|
||
if not any(s['type'] == 'reduce_long' and s.get('timestamp') == signal_timestamp for s in pending_signals):
|
||
pending_signals.append({
|
||
'type': 'reduce_long',
|
||
'trigger_price': trigger_price,
|
||
'position_size': reduce_pct,
|
||
'timestamp': signal_timestamp
|
||
})
|
||
|
||
if idx in exit_check_set and 'reduce_short' in executed_df.columns and executed_df['reduce_short'].iloc[idx]:
|
||
trigger_price = close_price
|
||
reduce_pct = 0.1
|
||
if 'reduce_size' in executed_df.columns:
|
||
try:
|
||
reduce_pct = float(executed_df['reduce_size'].iloc[idx] or 0)
|
||
except Exception:
|
||
reduce_pct = 0.1
|
||
elif 'position_size' in executed_df.columns:
|
||
try:
|
||
reduce_pct = float(executed_df['position_size'].iloc[idx] or 0)
|
||
except Exception:
|
||
reduce_pct = 0.1
|
||
if reduce_pct <= 0:
|
||
reduce_pct = 0.1
|
||
if not any(s['type'] == 'reduce_short' and s.get('timestamp') == signal_timestamp for s in pending_signals):
|
||
pending_signals.append({
|
||
'type': 'reduce_short',
|
||
'trigger_price': trigger_price,
|
||
'position_size': reduce_pct,
|
||
'timestamp': signal_timestamp
|
||
})
|
||
|
||
return {
|
||
'pending_signals': pending_signals,
|
||
'last_kline_time': last_kline_time,
|
||
'new_highest_price': new_highest_price
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to execute indicator and extract prices: {str(e)}")
|
||
logger.error(traceback.format_exc())
|
||
return None
|
||
|
||
def _execute_indicator_df(
|
||
self, indicator_code: str, df: pd.DataFrame, trading_config: Dict[str, Any],
|
||
initial_highest_price: float = 0.0,
|
||
initial_position: int = 0,
|
||
initial_avg_entry_price: float = 0.0,
|
||
initial_position_count: int = 0,
|
||
initial_last_add_price: float = 0.0
|
||
) -> tuple[Optional[pd.DataFrame], dict]:
|
||
"""Execute the indicator code and return the executed DataFrame and execution environment."""
|
||
try:
|
||
# Ensure that all numeric columns of the DataFrame are of type float64
|
||
df = df.copy()
|
||
for col in ['open', 'high', 'low', 'close', 'volume']:
|
||
if col in df.columns:
|
||
if not pd.api.types.is_numeric_dtype(df[col]):
|
||
df[col] = pd.to_numeric(df[col], errors='coerce').astype('float64')
|
||
else:
|
||
df[col] = df[col].astype('float64')
|
||
|
||
# Delete rows containing NaN
|
||
df = df.dropna()
|
||
|
||
if len(df) == 0:
|
||
logger.warning("DataFrame is empty; cannot execute indicator script")
|
||
return None, {}
|
||
|
||
# Initialize signal Series
|
||
signals = pd.Series(0, index=df.index, dtype='float64')
|
||
|
||
# Prepare execution environment
|
||
# Expose the full trading config to indicator scripts so frontend parameters
|
||
# (scale-in/out, position sizing, risk params) can be used directly.
|
||
# Also provide a backtest-modal compatible nested config object: cfg.risk/cfg.scale/cfg.position.
|
||
tc = dict(trading_config or {})
|
||
cfg = self._build_cfg_from_trading_config(tc)
|
||
|
||
# === Indicator parameter support ===
|
||
# Get user-set indicator parameters from trading_config
|
||
user_indicator_params = tc.get('indicator_params', {})
|
||
# Parse the parameters declared in the indicator code
|
||
declared_params = IndicatorParamsParser.parse_params(indicator_code)
|
||
# Merge parameters (user values take precedence, otherwise default values are used)
|
||
merged_params = IndicatorParamsParser.merge_params(declared_params, user_indicator_params)
|
||
|
||
# === Indicator caller support ===
|
||
# Get user ID and indicator ID (for call_indicator permission check)
|
||
user_id = tc.get('user_id', 1)
|
||
indicator_id = tc.get('indicator_id')
|
||
indicator_caller = IndicatorCaller(user_id, indicator_id)
|
||
|
||
local_vars = {
|
||
'df': df,
|
||
'open': df['open'].astype('float64'),
|
||
'high': df['high'].astype('float64'),
|
||
'low': df['low'].astype('float64'),
|
||
'close': df['close'].astype('float64'),
|
||
'volume': df['volume'].astype('float64'),
|
||
'signals': signals,
|
||
'np': np,
|
||
'pd': pd,
|
||
'trading_config': tc,
|
||
'config': tc, # alias
|
||
'cfg': cfg, # normalized nested config
|
||
'params': merged_params, # Indicator parameters (new)
|
||
'call_indicator': indicator_caller.call_indicator, # Call other indicators (new)
|
||
'leverage': float(trading_config.get('leverage', 1)),
|
||
'initial_capital': float(trading_config.get('initial_capital', 1000)),
|
||
'commission': 0.001,
|
||
'trade_direction': str(trading_config.get('trade_direction', 'long')),
|
||
'initial_highest_price': float(initial_highest_price),
|
||
'initial_position': int(initial_position),
|
||
'initial_avg_entry_price': float(initial_avg_entry_price),
|
||
'initial_position_count': int(initial_position_count),
|
||
'initial_last_add_price': float(initial_last_add_price)
|
||
}
|
||
|
||
import builtins
|
||
def safe_import(name, *args, **kwargs):
|
||
allowed_modules = ['numpy', 'pandas', 'math', 'json', 'time']
|
||
if name in allowed_modules or name.split('.')[0] in allowed_modules:
|
||
return builtins.__import__(name, *args, **kwargs)
|
||
raise ImportError(f"Importing modules is not allowed: {name}")
|
||
|
||
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', '__import__',
|
||
'copyright', 'credits', 'license'
|
||
]}
|
||
safe_builtins['__import__'] = safe_import
|
||
|
||
exec_env = local_vars.copy()
|
||
exec_env['__builtins__'] = safe_builtins
|
||
|
||
pre_import_code = "import numpy as np\nimport pandas as pd\n"
|
||
exec(pre_import_code, exec_env)
|
||
|
||
# Compatibility fix: Convert the fillna(method=...) syntax of the old version of pandas to the new version of the syntax
|
||
# pandas 2.0+ removes the method parameter of fillna(), you need to use ffill() or bfill()
|
||
# Old syntax: df.fillna(method='ffill') or df.fillna(method="ffill")
|
||
# New syntax: df.ffill()
|
||
import re
|
||
compatibility_fixed_code = indicator_code
|
||
# Replace fillna(method='ffill') or fillna(method="ffill") with ffill()
|
||
compatibility_fixed_code = re.sub(
|
||
r'\.fillna\(\s*method\s*=\s*["\']ffill["\']\s*\)',
|
||
'.ffill()',
|
||
compatibility_fixed_code
|
||
)
|
||
# Replace fillna(method='bfill') or fillna(method="bfill") with bfill()
|
||
compatibility_fixed_code = re.sub(
|
||
r'\.fillna\(\s*method\s*=\s*["\']bfill["\']\s*\)',
|
||
'.bfill()',
|
||
compatibility_fixed_code
|
||
)
|
||
|
||
# safe_exec_code here is assumed to already exist
|
||
exec(compatibility_fixed_code, exec_env)
|
||
|
||
executed_df = exec_env.get('df', df)
|
||
|
||
# Validation: if chart signals are provided, df['buy']/df['sell'] must exist for execution normalization.
|
||
output_obj = exec_env.get('output')
|
||
has_output_signals = isinstance(output_obj, dict) and isinstance(output_obj.get('signals'), list) and len(output_obj.get('signals')) > 0
|
||
if has_output_signals and not all(col in executed_df.columns for col in ['buy', 'sell']):
|
||
raise ValueError(
|
||
"Invalid indicator script: output['signals'] is provided, but df['buy'] and df['sell'] are missing. "
|
||
"Please set df['buy'] and df['sell'] as boolean columns (len == len(df))."
|
||
)
|
||
|
||
return executed_df, exec_env
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to execute indicator script: {str(e)}")
|
||
logger.error(traceback.format_exc())
|
||
return None, {}
|
||
|
||
def _execute_indicator(self, indicator_code: str, df: pd.DataFrame, trading_config: Dict[str, Any]) -> Optional[Any]:
|
||
"""Compatible with older versions"""
|
||
executed_df, _ = self._execute_indicator_df(indicator_code, df, trading_config)
|
||
if executed_df is None:
|
||
return None
|
||
return 0
|
||
|
||
def _get_current_positions(self, strategy_id: int, symbol: str) -> List[Dict[str, Any]]:
|
||
"""Get the current position (supports symbol normalization matching)"""
|
||
try:
|
||
with get_db_connection() as db:
|
||
cursor = db.cursor()
|
||
query = """
|
||
SELECT id, symbol, side, size, entry_price, highest_price, lowest_price
|
||
FROM qd_strategy_positions
|
||
WHERE strategy_id = %s
|
||
"""
|
||
cursor.execute(query, (strategy_id,))
|
||
all_positions = cursor.fetchall()
|
||
|
||
matched_positions = []
|
||
for pos in all_positions:
|
||
# Simplify matching logic: only match prefixes
|
||
if pos['symbol'].split(':')[0] == symbol.split(':')[0]:
|
||
matched_positions.append(pos)
|
||
|
||
cursor.close()
|
||
return matched_positions
|
||
except Exception as e:
|
||
logger.error(f"Failed to fetch positions: {str(e)}")
|
||
return []
|
||
|
||
def _execute_trading_logic(self, *args, **kwargs):
|
||
"""Deprecated."""
|
||
pass
|
||
|
||
def _execute_signal(
|
||
self,
|
||
strategy_id: int,
|
||
strategy_name: str,
|
||
exchange: Any,
|
||
symbol: str,
|
||
current_price: float,
|
||
signal_type: str,
|
||
position_size: float,
|
||
current_positions: List[Dict[str, Any]],
|
||
trade_direction: str,
|
||
leverage: int,
|
||
initial_capital: float,
|
||
market_type: str = 'swap',
|
||
market_category: str = 'Crypto',
|
||
margin_mode: str = 'cross',
|
||
stop_loss_price: float = None,
|
||
take_profit_price: float = None,
|
||
execution_mode: str = 'signal',
|
||
notification_config: Optional[Dict[str, Any]] = None,
|
||
trading_config: Optional[Dict[str, Any]] = None,
|
||
ai_model_config: Optional[Dict[str, Any]] = None,
|
||
signal_ts: int = 0,
|
||
):
|
||
"""Execute specific trading signals"""
|
||
try:
|
||
# Hard state-machine guard (double safety in addition to loop-level filtering).
|
||
state = self._position_state(current_positions)
|
||
if not self._is_signal_allowed(state, signal_type):
|
||
return False
|
||
|
||
# 1. Check trading direction restrictions
|
||
if market_type == 'spot' and 'short' in signal_type:
|
||
return False
|
||
|
||
sig = (signal_type or "").strip().lower()
|
||
|
||
# 1.1 Open position AI filtering (only open_*)
|
||
if sig in ("open_long", "open_short") and self._is_entry_ai_filter_enabled(ai_model_config=ai_model_config, trading_config=trading_config):
|
||
ok_ai, ai_info = self._entry_ai_filter_allows(
|
||
strategy_id=strategy_id,
|
||
symbol=symbol,
|
||
signal_type=sig,
|
||
ai_model_config=ai_model_config,
|
||
trading_config=trading_config,
|
||
)
|
||
if not ok_ai:
|
||
# Best-effort persist a browser notification so UI can show "HOLD due to AI filter".
|
||
reason = (ai_info or {}).get("reason") or "ai_filter_rejected"
|
||
ai_decision = (ai_info or {}).get("ai_decision") or ""
|
||
title = f"AI filter blocked opening a position | {symbol}"
|
||
msg = f"Strategy signal={sig}, AI decision={ai_decision or 'UNKNOWN'}, reason={reason}; action forced to HOLD (no order placed)"
|
||
self._persist_browser_notification(
|
||
strategy_id=strategy_id,
|
||
symbol=symbol,
|
||
signal_type="ai_filter_hold",
|
||
title=title,
|
||
message=msg,
|
||
payload={
|
||
"event": "qd.ai_filter",
|
||
"strategy_id": int(strategy_id),
|
||
"strategy_name": str(strategy_name or ""),
|
||
"symbol": str(symbol or ""),
|
||
"signal_type": str(sig),
|
||
"ai_decision": str(ai_decision),
|
||
"reason": str(reason),
|
||
"signal_ts": int(signal_ts or 0),
|
||
},
|
||
)
|
||
logger.info(
|
||
f"AI entry filter rejected: strategy_id={strategy_id} symbol={symbol} signal={sig} ai={ai_decision} reason={reason}"
|
||
)
|
||
return False
|
||
|
||
# 2. Calculate order quantity
|
||
available_capital = self._get_available_capital(
|
||
strategy_id,
|
||
initial_capital,
|
||
current_positions=current_positions,
|
||
current_price=current_price,
|
||
symbol=symbol,
|
||
)
|
||
|
||
amount = 0.0
|
||
|
||
# Frontend position sizing alignment:
|
||
# - open_* uses entry_pct from trading_config if provided (0~1 or 0~100 are both accepted)
|
||
if sig in ("open_long", "open_short") and isinstance(trading_config, dict):
|
||
ep = trading_config.get("entry_pct")
|
||
if ep is not None:
|
||
position_size = self._to_ratio(ep, default=position_size if position_size is not None else 0.0)
|
||
|
||
# Open / add sizing: position_size is treated as capital ratio in [0,1].
|
||
if ('open' in sig or 'add' in sig):
|
||
if position_size is None or float(position_size) <= 0:
|
||
position_size = 0.05
|
||
position_ratio = self._to_ratio(position_size, default=0.05)
|
||
if market_type == 'spot':
|
||
amount = available_capital * position_ratio / current_price
|
||
else:
|
||
# Futures sizing: treat available_capital as margin budget.
|
||
# Notional = margin * leverage, so base quantity = (margin * leverage) / price.
|
||
amount = (available_capital * position_ratio * leverage) / current_price
|
||
|
||
# Reduce sizing: position_size is treated as a reduce ratio (close X% of current position).
|
||
if sig in ("reduce_long", "reduce_short"):
|
||
pos_side = "long" if "long" in sig else "short"
|
||
pos = next((p for p in current_positions if (p.get('side') or '').strip().lower() == pos_side), None)
|
||
if not pos:
|
||
return False
|
||
cur_size = float(pos.get("size") or 0.0)
|
||
if cur_size <= 0:
|
||
return False
|
||
reduce_ratio = self._to_ratio(position_size, default=0.1)
|
||
reduce_amount = cur_size * reduce_ratio
|
||
# If reduce is effectively full, treat as close_*.
|
||
if reduce_amount >= cur_size * 0.999:
|
||
sig = "close_long" if pos_side == "long" else "close_short"
|
||
signal_type = sig
|
||
amount = cur_size
|
||
else:
|
||
amount = reduce_amount
|
||
|
||
# 3. Check reverse positions (one-way position logic)
|
||
# ... (Simplified processing, assuming no reverse or processing by the user) ...
|
||
|
||
# 4. Execute order enqueue (PendingOrderWorker will dispatch notifications in signal mode)
|
||
if 'close' in sig:
|
||
# Position closing logic: find the corresponding position size
|
||
pos = next((p for p in current_positions if p.get('side') and p['side'] in signal_type), None)
|
||
if not pos:
|
||
return False
|
||
amount = float(pos['size'] or 0.0)
|
||
if amount <= 0:
|
||
return False
|
||
|
||
if amount <= 0 and ('open' in signal_type or 'add' in signal_type):
|
||
return False
|
||
|
||
order_result = self._execute_exchange_order(
|
||
exchange=exchange,
|
||
strategy_id=strategy_id,
|
||
symbol=symbol,
|
||
signal_type=signal_type,
|
||
amount=amount,
|
||
ref_price=float(current_price or 0.0),
|
||
market_type=market_type,
|
||
market_category=market_category,
|
||
leverage=leverage,
|
||
execution_mode=execution_mode,
|
||
notification_config=notification_config,
|
||
signal_ts=int(signal_ts or 0),
|
||
)
|
||
|
||
if order_result and order_result.get('success'):
|
||
# For live execution, the order is only enqueued here.
|
||
# The actual fill/trade/position updates are performed by PendingOrderWorker.
|
||
if str(execution_mode or "").strip().lower() == "live":
|
||
return True
|
||
|
||
# Update database status (signal mode / local simulation)
|
||
if 'open' in sig or 'add' in sig:
|
||
self._record_trade(
|
||
strategy_id=strategy_id, symbol=symbol, type=signal_type,
|
||
price=current_price, amount=amount, value=amount*current_price
|
||
)
|
||
side = 'short' if 'short' in signal_type else 'long'
|
||
|
||
# Find existing positions to calculate average price
|
||
old_pos = next((p for p in current_positions if p['side'] == side), None)
|
||
new_size = amount
|
||
new_entry = current_price
|
||
if old_pos:
|
||
old_size = float(old_pos['size'])
|
||
old_entry = float(old_pos['entry_price'])
|
||
new_size += old_size
|
||
new_entry = ((old_size * old_entry) + (amount * current_price)) / new_size
|
||
|
||
self._update_position(
|
||
strategy_id=strategy_id, symbol=symbol, side=side,
|
||
size=new_size, entry_price=new_entry, current_price=current_price
|
||
)
|
||
elif sig.startswith("reduce_"):
|
||
# Partial scale-out: reduce position size, keep entry price unchanged.
|
||
# Calculate partial closing profit and loss in signal mode
|
||
side = 'short' if 'short' in signal_type else 'long'
|
||
old_pos = next((p for p in current_positions if p.get('side') == side), None)
|
||
if not old_pos:
|
||
return True
|
||
old_size = float(old_pos.get('size') or 0.0)
|
||
old_entry = float(old_pos.get('entry_price') or 0.0)
|
||
|
||
# Calculate the profit and loss of the position reduction part (in signal mode, excluding handling fees)
|
||
reduce_profit = None
|
||
if old_entry > 0 and amount > 0:
|
||
if side == 'long':
|
||
reduce_profit = (current_price - old_entry) * amount
|
||
else:
|
||
reduce_profit = (old_entry - current_price) * amount
|
||
|
||
self._record_trade(
|
||
strategy_id=strategy_id, symbol=symbol, type=signal_type,
|
||
price=current_price, amount=amount, value=amount*current_price,
|
||
profit=reduce_profit
|
||
)
|
||
|
||
new_size = max(0.0, old_size - float(amount or 0.0))
|
||
if new_size <= old_size * 0.001:
|
||
self._close_position(strategy_id, symbol, side)
|
||
else:
|
||
self._update_position(
|
||
strategy_id=strategy_id, symbol=symbol, side=side,
|
||
size=new_size, entry_price=old_entry, current_price=current_price
|
||
)
|
||
elif 'close' in sig:
|
||
# Calculate closing profit and loss in signal mode
|
||
side = 'short' if 'short' in signal_type else 'long'
|
||
old_pos = next((p for p in current_positions if p.get('side') == side), None)
|
||
|
||
# Calculate profit and loss (in signal mode, excluding handling fees)
|
||
close_profit = None
|
||
if old_pos:
|
||
entry_price = float(old_pos.get('entry_price') or 0)
|
||
if entry_price > 0 and amount > 0:
|
||
if side == 'long':
|
||
close_profit = (current_price - entry_price) * amount
|
||
else:
|
||
close_profit = (entry_price - current_price) * amount
|
||
|
||
self._record_trade(
|
||
strategy_id=strategy_id, symbol=symbol, type=signal_type,
|
||
price=current_price, amount=amount, value=amount*current_price,
|
||
profit=close_profit
|
||
)
|
||
self._close_position(strategy_id, symbol, side)
|
||
|
||
return True
|
||
|
||
return False
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to execute signal: {e}")
|
||
return False
|
||
|
||
def _is_entry_ai_filter_enabled(self, *, ai_model_config: Optional[Dict[str, Any]], trading_config: Optional[Dict[str, Any]]) -> bool:
|
||
"""Detect whether the strategy enabled 'AI filter on entry (open positions only)'."""
|
||
amc = ai_model_config if isinstance(ai_model_config, dict) else {}
|
||
tc = trading_config if isinstance(trading_config, dict) else {}
|
||
|
||
# Accept multiple key names for forward/backward compatibility.
|
||
candidates = [
|
||
amc.get("entry_ai_filter_enabled"),
|
||
amc.get("entryAiFilterEnabled"),
|
||
amc.get("ai_filter_enabled"),
|
||
amc.get("aiFilterEnabled"),
|
||
amc.get("enable_ai_filter"),
|
||
amc.get("enableAiFilter"),
|
||
tc.get("entry_ai_filter_enabled"),
|
||
tc.get("ai_filter_enabled"),
|
||
tc.get("enable_ai_filter"),
|
||
tc.get("enableAiFilter"),
|
||
]
|
||
for v in candidates:
|
||
if v is None:
|
||
continue
|
||
if isinstance(v, bool):
|
||
return bool(v)
|
||
s = str(v).strip().lower()
|
||
if s in ("1", "true", "yes", "y", "on", "enabled"):
|
||
return True
|
||
if s in ("0", "false", "no", "n", "off", "disabled"):
|
||
return False
|
||
return False
|
||
|
||
def _entry_ai_filter_allows(
|
||
self,
|
||
*,
|
||
strategy_id: int,
|
||
symbol: str,
|
||
signal_type: str,
|
||
ai_model_config: Optional[Dict[str, Any]],
|
||
trading_config: Optional[Dict[str, Any]],
|
||
) -> Tuple[bool, Dict[str, Any]]:
|
||
"""
|
||
Run internal AI analysis and decide whether an entry signal is allowed.
|
||
|
||
Returns:
|
||
(allowed, info)
|
||
- allowed: True -> proceed; False -> hold (reject open)
|
||
- info: {ai_decision, reason, analysis_error?}
|
||
"""
|
||
amc = ai_model_config if isinstance(ai_model_config, dict) else {}
|
||
tc = trading_config if isinstance(trading_config, dict) else {}
|
||
|
||
# Market for AnalysisService. Live trading executor is Crypto-focused.
|
||
market = str(amc.get("market") or amc.get("analysis_market") or "Crypto").strip() or "Crypto"
|
||
|
||
# Optional model override (OpenRouter model id)
|
||
model = amc.get("model") or amc.get("openrouter_model") or amc.get("openrouterModel") or None
|
||
model = str(model).strip() if model else None
|
||
|
||
# Prefer zh-CN for local UI; can be overridden.
|
||
language = amc.get("language") or amc.get("lang") or tc.get("language") or "zh-CN"
|
||
language = str(language or "zh-CN")
|
||
|
||
# ── Billing: AI filter uses the same cost as ai_analysis ──
|
||
try:
|
||
from app.services.billing_service import get_billing_service
|
||
billing = get_billing_service()
|
||
if billing.is_billing_enabled():
|
||
user_id = 1
|
||
try:
|
||
with get_db_connection() as db:
|
||
cur = db.cursor()
|
||
cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = ?", (strategy_id,))
|
||
row = cur.fetchone()
|
||
cur.close()
|
||
user_id = int((row or {}).get('user_id') or 1)
|
||
except Exception:
|
||
pass
|
||
ok, msg = billing.check_and_consume(
|
||
user_id=user_id,
|
||
feature='ai_analysis',
|
||
reference_id=f"ai_filter_{strategy_id}_{symbol}"
|
||
)
|
||
if not ok:
|
||
logger.warning(f"AI filter billing failed for strategy {strategy_id}: {msg}")
|
||
return False, {"ai_decision": "", "reason": f"billing_failed:{msg}"}
|
||
except Exception as e:
|
||
logger.warning(f"AI filter billing check error: {e}")
|
||
|
||
try:
|
||
from app.services.fast_analysis import get_fast_analysis_service
|
||
|
||
service = get_fast_analysis_service()
|
||
result = service.analyze(market, symbol, language, model=model)
|
||
|
||
if isinstance(result, dict) and result.get("error"):
|
||
return False, {"ai_decision": "", "reason": "analysis_error", "analysis_error": str(result.get("error") or "")}
|
||
|
||
# FastAnalysisService directly returns the decision field
|
||
ai_dec = str(result.get("decision", "")).strip().upper()
|
||
if not ai_dec or ai_dec not in ("BUY", "SELL", "HOLD"):
|
||
return False, {"ai_decision": ai_dec, "reason": "missing_ai_decision"}
|
||
|
||
expected = "BUY" if signal_type == "open_long" else "SELL"
|
||
confidence = result.get("confidence", 50)
|
||
summary = result.get("summary", "")
|
||
|
||
if ai_dec == expected:
|
||
return True, {"ai_decision": ai_dec, "reason": "match", "confidence": confidence, "summary": summary}
|
||
if ai_dec == "HOLD":
|
||
return False, {"ai_decision": ai_dec, "reason": "ai_hold", "confidence": confidence, "summary": summary}
|
||
return False, {"ai_decision": ai_dec, "reason": "direction_mismatch", "confidence": confidence, "summary": summary}
|
||
except Exception as e:
|
||
return False, {"ai_decision": "", "reason": "analysis_exception", "analysis_error": str(e)}
|
||
|
||
def _extract_ai_trade_decision(self, analysis_result: Any) -> str:
|
||
"""
|
||
Normalize AI analysis output into one of: BUY / SELL / HOLD / "".
|
||
We primarily look at final_decision.decision, with fallbacks.
|
||
"""
|
||
if not isinstance(analysis_result, dict):
|
||
return ""
|
||
|
||
def _pick(*paths: str) -> str:
|
||
for p in paths:
|
||
cur: Any = analysis_result
|
||
ok = True
|
||
for k in p.split("."):
|
||
if not isinstance(cur, dict):
|
||
ok = False
|
||
break
|
||
cur = cur.get(k)
|
||
if ok and cur is not None:
|
||
s = str(cur).strip()
|
||
if s:
|
||
return s
|
||
return ""
|
||
|
||
raw = _pick("final_decision.decision", "trader_decision.decision", "decision", "final.decision")
|
||
s = raw.strip().upper()
|
||
if not s:
|
||
return ""
|
||
|
||
# Common variants / synonyms
|
||
if "BUY" in s or s == "LONG" or "LONG" in s:
|
||
return "BUY"
|
||
if "SELL" in s or s == "SHORT" or "SHORT" in s:
|
||
return "SELL"
|
||
if "HOLD" in s or "WAIT" in s or "NEUTRAL" in s:
|
||
return "HOLD"
|
||
return s if s in ("BUY", "SELL", "HOLD") else ""
|
||
|
||
def _persist_browser_notification(
|
||
self,
|
||
*,
|
||
strategy_id: int,
|
||
symbol: str,
|
||
signal_type: str,
|
||
title: str,
|
||
message: str,
|
||
payload: Optional[Dict[str, Any]] = None,
|
||
user_id: int = None,
|
||
) -> None:
|
||
"""Best-effort persistence of a notification row for the frontend notifications panel (browser channel)."""
|
||
try:
|
||
now = int(time.time())
|
||
# Get user_id from strategy if not provided
|
||
if user_id is None:
|
||
try:
|
||
with get_db_connection() as db:
|
||
cur = db.cursor()
|
||
cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = ?", (strategy_id,))
|
||
row = cur.fetchone()
|
||
cur.close()
|
||
user_id = int((row or {}).get('user_id') or 1)
|
||
except Exception:
|
||
user_id = 1
|
||
with get_db_connection() as db:
|
||
cur = db.cursor()
|
||
cur.execute(
|
||
"""
|
||
INSERT INTO qd_strategy_notifications
|
||
(user_id, strategy_id, symbol, signal_type, channels, title, message, payload_json, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||
""",
|
||
(
|
||
int(user_id),
|
||
int(strategy_id),
|
||
str(symbol or ""),
|
||
str(signal_type or ""),
|
||
"browser",
|
||
str(title or ""),
|
||
str(message or ""),
|
||
json.dumps(payload or {}, ensure_ascii=False),
|
||
),
|
||
)
|
||
db.commit()
|
||
cur.close()
|
||
except Exception as e:
|
||
logger.warning(f"persist_browser_notification failed: {e}")
|
||
|
||
def _execute_exchange_order(
|
||
self,
|
||
exchange: Any,
|
||
strategy_id: int,
|
||
symbol: str,
|
||
signal_type: str,
|
||
amount: float,
|
||
ref_price: Optional[float] = None,
|
||
market_type: str = 'swap',
|
||
market_category: str = 'Crypto',
|
||
leverage: float = 1.0,
|
||
margin_mode: str = 'cross',
|
||
stop_loss_price: float = None,
|
||
take_profit_price: float = None,
|
||
# Order execution params (order_mode, maker_wait_sec, maker_offset_bps) are now
|
||
# configured via environment variables: ORDER_MODE, MAKER_WAIT_SEC, MAKER_OFFSET_BPS
|
||
# These parameters are kept for backward compatibility but will be ignored.
|
||
order_mode: str = None,
|
||
maker_wait_sec: float = None,
|
||
maker_retries: int = 3,
|
||
close_fallback_to_market: bool = True,
|
||
open_fallback_to_market: bool = True,
|
||
execution_mode: str = 'signal',
|
||
notification_config: Optional[Dict[str, Any]] = None,
|
||
signal_ts: int = 0,
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""
|
||
Convert a signal into a concrete pending order and enqueue it into DB.
|
||
|
||
A separate worker will poll `pending_orders` and dispatch:
|
||
- execution_mode='signal': dispatch notifications (no real trading).
|
||
- execution_mode='live': reserved for future live trading execution (not implemented).
|
||
|
||
Note: Order execution settings (order_mode, maker_wait_sec, maker_offset_bps) are now
|
||
configured via environment variables and not passed from strategy config.
|
||
"""
|
||
try:
|
||
# Reference price at enqueue time: use current tick price if provided to avoid extra fetch.
|
||
if ref_price is None:
|
||
ref_price = self._fetch_current_price(None, symbol, market_category=market_category) or 0.0
|
||
ref_price = float(ref_price or 0.0)
|
||
|
||
extra_payload = {
|
||
"ref_price": float(ref_price or 0.0),
|
||
"signal_ts": int(signal_ts or 0),
|
||
"stop_loss_price": float(stop_loss_price or 0.0) if stop_loss_price is not None else 0.0,
|
||
"take_profit_price": float(take_profit_price or 0.0) if take_profit_price is not None else 0.0,
|
||
"margin_mode": str(margin_mode or "cross"),
|
||
# Order execution params moved to env config (ORDER_MODE, MAKER_WAIT_SEC, MAKER_OFFSET_BPS)
|
||
"maker_retries": int(maker_retries or 0),
|
||
"close_fallback_to_market": bool(close_fallback_to_market),
|
||
"open_fallback_to_market": bool(open_fallback_to_market),
|
||
}
|
||
pending_id = self._enqueue_pending_order(
|
||
strategy_id=strategy_id,
|
||
symbol=symbol,
|
||
signal_type=signal_type,
|
||
amount=float(amount or 0.0),
|
||
price=float(ref_price or 0.0),
|
||
signal_ts=int(signal_ts or 0),
|
||
market_type=market_type,
|
||
leverage=float(leverage or 1.0),
|
||
execution_mode=execution_mode,
|
||
notification_config=notification_config,
|
||
extra_payload=extra_payload,
|
||
)
|
||
|
||
pending_flag = str(execution_mode or "").strip().lower() == "live"
|
||
|
||
# Local "signal provider mode": we keep the local state machine moving forward.
|
||
return {
|
||
'success': True,
|
||
'pending': bool(pending_flag),
|
||
'order_id': f"pending_{pending_id or int(time.time()*1000)}",
|
||
'filled_amount': 0 if pending_flag else amount,
|
||
'filled_base_amount': 0 if pending_flag else amount,
|
||
'filled_price': 0 if pending_flag else ref_price,
|
||
'total_cost': 0 if pending_flag else (float(amount or 0.0) * float(ref_price or 0.0) if ref_price else 0),
|
||
'fee': 0,
|
||
'message': 'Order enqueued to pending_orders'
|
||
}
|
||
except Exception as e:
|
||
logger.error(f"Signal execution failed: {e}")
|
||
return {'success': False, 'error': str(e)}
|
||
|
||
def _enqueue_pending_order(
|
||
self,
|
||
strategy_id: int,
|
||
symbol: str,
|
||
signal_type: str,
|
||
amount: float,
|
||
price: float,
|
||
signal_ts: int,
|
||
market_type: str,
|
||
leverage: float,
|
||
execution_mode: str,
|
||
notification_config: Optional[Dict[str, Any]] = None,
|
||
extra_payload: Optional[Dict[str, Any]] = None,
|
||
) -> Optional[int]:
|
||
"""Insert a pending order record and return its id."""
|
||
try:
|
||
now = int(time.time())
|
||
# Local deployment supports both "signal" and "live" (live is executed by PendingOrderWorker).
|
||
mode = (execution_mode or "signal").strip().lower()
|
||
if mode not in ("signal", "live"):
|
||
mode = "signal"
|
||
|
||
payload: Dict[str, Any] = {
|
||
"strategy_id": int(strategy_id),
|
||
"symbol": symbol,
|
||
"signal_type": signal_type,
|
||
"market_type": market_type,
|
||
"amount": float(amount or 0.0),
|
||
"price": float(price or 0.0),
|
||
"leverage": float(leverage or 1.0),
|
||
"execution_mode": mode,
|
||
"notification_config": notification_config or {},
|
||
"signal_ts": int(signal_ts or 0),
|
||
}
|
||
if extra_payload and isinstance(extra_payload, dict):
|
||
payload.update(extra_payload)
|
||
|
||
with get_db_connection() as db:
|
||
cur = db.cursor()
|
||
|
||
# Extra dedup/cooldown guard (DB-based, more rigorous than local position state):
|
||
# The indicator recompute runs on a fixed tick cadence, and some strategies may keep emitting the same
|
||
# entry/exit signal across multiple ticks/candles (especially when orders fail).
|
||
# We prevent spamming the queue by skipping if a very recent identical order already exists.
|
||
#
|
||
# Rules:
|
||
# - If signal_ts is provided (>0), treat (strategy_id, symbol, signal_type, signal_ts) as the canonical
|
||
# "same candle" key: if any record already exists, do NOT enqueue again.
|
||
# - Otherwise, fall back to the older (strategy_id, symbol, signal_type) cooldown guard.
|
||
cooldown_sec = 30 # keep small; worker already retries the claimed order via attempts/max_attempts
|
||
try:
|
||
stsig = int(signal_ts or 0)
|
||
# Strict "same candle" de-dup applies to open and close signals.
|
||
# Rationale:
|
||
# - open_* signals should only trigger once per candle (prevents repeated entries)
|
||
# - close_* signals should only trigger once per candle (prevents repeated close attempts)
|
||
# - add_*/reduce_* signals may legitimately trigger multiple times within same candle
|
||
# as price evolves for DCA/scaling strategies
|
||
sig_norm = str(signal_type or "").strip().lower()
|
||
strict_candle_dedup = stsig > 0 and sig_norm in ("open_long", "open_short", "close_long", "close_short")
|
||
|
||
if strict_candle_dedup:
|
||
cur.execute(
|
||
"""
|
||
SELECT id, status, created_at
|
||
FROM pending_orders
|
||
WHERE strategy_id = %s
|
||
AND symbol = %s
|
||
AND signal_type = %s
|
||
AND signal_ts = %s
|
||
ORDER BY id DESC
|
||
LIMIT 1
|
||
""",
|
||
(int(strategy_id), str(symbol), str(signal_type), int(stsig)),
|
||
)
|
||
else:
|
||
cur.execute(
|
||
"""
|
||
SELECT id, status, created_at
|
||
FROM pending_orders
|
||
WHERE strategy_id = %s
|
||
AND symbol = %s
|
||
AND signal_type = %s
|
||
ORDER BY id DESC
|
||
LIMIT 1
|
||
""",
|
||
(int(strategy_id), str(symbol), str(signal_type)),
|
||
)
|
||
last = cur.fetchone() or {}
|
||
last_id = int(last.get("id") or 0)
|
||
last_status = str(last.get("status") or "").strip().lower()
|
||
last_created = int(last.get("created_at") or 0)
|
||
if last_id > 0:
|
||
if strict_candle_dedup:
|
||
logger.info(
|
||
f"enqueue_pending_order skipped (same candle): existing id={last_id} "
|
||
f"strategy_id={strategy_id} symbol={symbol} signal={signal_type} signal_ts={stsig} status={last_status}"
|
||
)
|
||
cur.close()
|
||
return None
|
||
if last_status in ("pending", "processing"):
|
||
logger.info(
|
||
f"enqueue_pending_order skipped: existing_inflight id={last_id} "
|
||
f"strategy_id={strategy_id} symbol={symbol} signal={signal_type} status={last_status}"
|
||
)
|
||
cur.close()
|
||
return None
|
||
if last_created > 0 and (now - last_created) < cooldown_sec:
|
||
logger.info(
|
||
f"enqueue_pending_order cooldown: last_id={last_id} last_status={last_status} "
|
||
f"age_sec={now - last_created} (<{cooldown_sec}) "
|
||
f"strategy_id={strategy_id} symbol={symbol} signal={signal_type}"
|
||
)
|
||
cur.close()
|
||
return None
|
||
except Exception:
|
||
# Best-effort only; do not block enqueue on dedup query errors.
|
||
pass
|
||
|
||
# Get user_id from strategy
|
||
user_id = 1
|
||
try:
|
||
cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = %s", (strategy_id,))
|
||
row = cur.fetchone()
|
||
user_id = int((row or {}).get('user_id') or 1)
|
||
except Exception:
|
||
pass
|
||
|
||
cur.execute(
|
||
"""
|
||
INSERT INTO pending_orders
|
||
(user_id, strategy_id, symbol, signal_type, signal_ts, market_type, order_type, amount, price,
|
||
execution_mode, status, priority, attempts, max_attempts, last_error, payload_json,
|
||
created_at, updated_at, processed_at, sent_at)
|
||
VALUES
|
||
(%s, %s, %s, %s, %s, %s, %s, %s, %s,
|
||
%s, %s, %s, %s, %s, %s, %s,
|
||
NOW(), NOW(), NULL, NULL)
|
||
""",
|
||
(
|
||
int(user_id),
|
||
int(strategy_id),
|
||
symbol,
|
||
signal_type,
|
||
int(signal_ts or 0),
|
||
market_type or 'swap',
|
||
'market',
|
||
float(amount or 0.0),
|
||
float(price or 0.0),
|
||
mode,
|
||
'pending',
|
||
0,
|
||
0,
|
||
10,
|
||
'',
|
||
json.dumps(payload, ensure_ascii=False),
|
||
),
|
||
)
|
||
pending_id = cur.lastrowid
|
||
db.commit()
|
||
cur.close()
|
||
return int(pending_id) if pending_id is not None else None
|
||
except Exception as e:
|
||
logger.error(f"enqueue_pending_order failed: {e}")
|
||
return None
|
||
|
||
def _place_stop_loss_order(self, *args, **kwargs):
|
||
pass
|
||
|
||
def _get_available_capital(
|
||
self,
|
||
strategy_id: int,
|
||
initial_capital: float,
|
||
current_positions: Optional[List[Dict[str, Any]]] = None,
|
||
current_price: Optional[float] = None,
|
||
symbol: str = "",
|
||
) -> float:
|
||
"""Get the equity capital that the current strategy can use for position calculation."""
|
||
return self._calculate_current_equity(
|
||
strategy_id,
|
||
initial_capital,
|
||
current_positions=current_positions,
|
||
current_price=current_price,
|
||
symbol=symbol,
|
||
)
|
||
|
||
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_pnl = 0.0
|
||
unrealized_pnl = 0.0
|
||
try:
|
||
with get_db_connection() as db:
|
||
cursor = db.cursor()
|
||
cursor.execute(
|
||
"""
|
||
SELECT COALESCE(SUM(COALESCE(profit, 0) - COALESCE(commission, 0)), 0) AS realized_pnl
|
||
FROM qd_strategy_trades
|
||
WHERE strategy_id = %s
|
||
""",
|
||
(strategy_id,)
|
||
)
|
||
row = cursor.fetchone() or {}
|
||
realized_pnl = float(row.get('realized_pnl') or 0.0)
|
||
cursor.close()
|
||
except Exception as e:
|
||
logger.warning(f"Failed to calculate realized pnl for strategy {strategy_id}: {e}")
|
||
|
||
positions = list(current_positions or [])
|
||
if not positions:
|
||
try:
|
||
positions = self._get_all_positions(strategy_id) or []
|
||
except Exception:
|
||
positions = []
|
||
|
||
normalized_symbol = (symbol or "").split(':')[0]
|
||
for pos in positions:
|
||
try:
|
||
side = str(pos.get('side') or '').strip().lower()
|
||
size = float(pos.get('size') or 0.0)
|
||
entry_price = float(pos.get('entry_price') or 0.0)
|
||
if size <= 0 or entry_price <= 0 or side not in ('long', 'short'):
|
||
continue
|
||
|
||
mark_price = pos.get('current_price')
|
||
pos_symbol = str(pos.get('symbol') or '')
|
||
if current_price and normalized_symbol and pos_symbol.split(':')[0] == normalized_symbol:
|
||
mark_price = current_price
|
||
mark_price = float(mark_price or 0.0)
|
||
if mark_price <= 0:
|
||
continue
|
||
|
||
if side == 'long':
|
||
unrealized_pnl += (mark_price - entry_price) * size
|
||
else:
|
||
unrealized_pnl += (entry_price - mark_price) * size
|
||
except Exception:
|
||
continue
|
||
|
||
equity = float(initial_capital or 0.0) + realized_pnl + unrealized_pnl
|
||
return max(0.0, equity)
|
||
|
||
def _record_trade(self, strategy_id: int, symbol: str, type: str, price: float, amount: float, value: float, profit: float = None, commission: float = None):
|
||
"""Record transactions to database"""
|
||
try:
|
||
# Get user_id from strategy
|
||
user_id = 1
|
||
with get_db_connection() as db:
|
||
cursor = db.cursor()
|
||
try:
|
||
cursor.execute("SELECT user_id FROM qd_strategies_trading WHERE id = %s", (strategy_id,))
|
||
row = cursor.fetchone()
|
||
user_id = int((row or {}).get('user_id') or 1)
|
||
except Exception:
|
||
pass
|
||
query = """
|
||
INSERT INTO qd_strategy_trades (
|
||
user_id, strategy_id, symbol, type, price, amount, value, commission, profit, created_at
|
||
) VALUES (
|
||
%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()
|
||
)
|
||
"""
|
||
cursor.execute(query, (user_id, strategy_id, symbol, type, price, amount, value, commission or 0, profit))
|
||
db.commit()
|
||
cursor.close()
|
||
except Exception as e:
|
||
logger.error(f"Failed to record trade: {e}")
|
||
|
||
def _update_position(
|
||
self,
|
||
strategy_id: int,
|
||
symbol: str,
|
||
side: str,
|
||
size: float,
|
||
entry_price: float,
|
||
current_price: float,
|
||
highest_price: float = 0.0,
|
||
lowest_price: float = 0.0,
|
||
):
|
||
"""Update position state."""
|
||
try:
|
||
# Get user_id from strategy
|
||
user_id = 1
|
||
with get_db_connection() as db:
|
||
cursor = db.cursor()
|
||
try:
|
||
cursor.execute("SELECT user_id FROM qd_strategies_trading WHERE id = %s", (strategy_id,))
|
||
row = cursor.fetchone()
|
||
user_id = int((row or {}).get('user_id') or 1)
|
||
except Exception:
|
||
pass
|
||
# Simplification: direct Update or Insert
|
||
upsert_query = """
|
||
INSERT INTO qd_strategy_positions (
|
||
user_id, strategy_id, symbol, side, size, entry_price, current_price, highest_price, lowest_price, updated_at
|
||
) VALUES (
|
||
%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()
|
||
) ON CONFLICT(strategy_id, symbol, side) DO UPDATE SET
|
||
size = excluded.size,
|
||
entry_price = excluded.entry_price,
|
||
current_price = excluded.current_price,
|
||
highest_price = CASE WHEN excluded.highest_price > 0 THEN excluded.highest_price ELSE qd_strategy_positions.highest_price END,
|
||
lowest_price = CASE WHEN excluded.lowest_price > 0 THEN excluded.lowest_price ELSE qd_strategy_positions.lowest_price END,
|
||
updated_at = NOW()
|
||
"""
|
||
cursor.execute(upsert_query, (
|
||
user_id, strategy_id, symbol, side, size, entry_price, current_price, highest_price, lowest_price
|
||
))
|
||
db.commit()
|
||
cursor.close()
|
||
except Exception as e:
|
||
logger.error(f"Failed to update position: {e}")
|
||
|
||
def _close_position(self, strategy_id: int, symbol: str, side: str):
|
||
"""Close a position by deleting the position record."""
|
||
try:
|
||
with get_db_connection() as db:
|
||
cursor = db.cursor()
|
||
cursor.execute("DELETE FROM qd_strategy_positions WHERE strategy_id = %s AND symbol = %s AND side = %s", (strategy_id, symbol, side))
|
||
db.commit()
|
||
cursor.close()
|
||
except Exception as e:
|
||
logger.error(f"Failed to close position: {e}")
|
||
|
||
def _delete_position_by_id(self, position_id: int):
|
||
pass
|
||
|
||
def _update_positions(self, strategy_id: int, symbol: str, current_price: float):
|
||
"""Update current prices for all positions"""
|
||
try:
|
||
with get_db_connection() as db:
|
||
cursor = db.cursor()
|
||
cursor.execute("UPDATE qd_strategy_positions SET current_price = %s WHERE strategy_id = %s AND symbol = %s", (current_price, strategy_id, symbol))
|
||
db.commit()
|
||
cursor.close()
|
||
except Exception:
|
||
pass
|
||
|
||
def _get_indicator_code_from_db(self, indicator_id: int) -> Optional[str]:
|
||
try:
|
||
with get_db_connection() as db:
|
||
cursor = db.cursor()
|
||
cursor.execute("SELECT code FROM qd_indicator_codes WHERE id = %s", (indicator_id,))
|
||
result = cursor.fetchone()
|
||
return result['code'] if result else None
|
||
except:
|
||
return None
|
||
|
||
def _get_all_positions(self, strategy_id: int) -> List[Dict[str, Any]]:
|
||
"""Get all positions of the strategy (used by cross-section strategy)"""
|
||
try:
|
||
with get_db_connection() as db:
|
||
cursor = db.cursor()
|
||
cursor.execute("""
|
||
SELECT id, symbol, side, size, entry_price, current_price, highest_price, lowest_price
|
||
FROM qd_strategy_positions
|
||
WHERE strategy_id = %s
|
||
""", (strategy_id,))
|
||
return cursor.fetchall() or []
|
||
except Exception as e:
|
||
logger.error(f"Failed to get all positions: {e}")
|
||
return []
|
||
|
||
def _should_rebalance(self, strategy_id: int, rebalance_frequency: str) -> bool:
|
||
"""Check whether rebalancing should run."""
|
||
try:
|
||
with get_db_connection() as db:
|
||
cursor = db.cursor()
|
||
cursor.execute("""
|
||
SELECT last_rebalance_at FROM qd_strategies_trading WHERE id = %s
|
||
""", (strategy_id,))
|
||
result = cursor.fetchone()
|
||
if not result or not result.get('last_rebalance_at'):
|
||
return True
|
||
|
||
last_rebalance = result['last_rebalance_at']
|
||
if isinstance(last_rebalance, str):
|
||
from datetime import datetime
|
||
last_rebalance = datetime.fromisoformat(last_rebalance.replace('Z', '+00:00'))
|
||
|
||
now = datetime.now()
|
||
delta = now - last_rebalance
|
||
|
||
if rebalance_frequency == 'daily':
|
||
return delta.days >= 1
|
||
elif rebalance_frequency == 'weekly':
|
||
return delta.days >= 7
|
||
elif rebalance_frequency == 'monthly':
|
||
return delta.days >= 30
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"Failed to check rebalance: {e}")
|
||
return True
|
||
|
||
def _update_last_rebalance(self, strategy_id: int):
|
||
"""Update the last rebalance timestamp."""
|
||
try:
|
||
with get_db_connection() as db:
|
||
cursor = db.cursor()
|
||
# Try to update, if column doesn't exist, ignore
|
||
try:
|
||
cursor.execute("""
|
||
UPDATE qd_strategies_trading
|
||
SET last_rebalance_at = NOW()
|
||
WHERE id = %s
|
||
""", (strategy_id,))
|
||
db.commit()
|
||
except Exception:
|
||
# Column may not exist, that's OK
|
||
pass
|
||
cursor.close()
|
||
except Exception as e:
|
||
logger.warning(f"Failed to update last_rebalance_at: {e}")
|
||
|
||
def _execute_cross_sectional_indicator(
|
||
self,
|
||
indicator_code: str,
|
||
symbols: List[str],
|
||
trading_config: Dict[str, Any],
|
||
market_category: str,
|
||
timeframe: str
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""
|
||
Execute cross-sectional strategy indicators and return scores and ranking for all instruments.
|
||
"""
|
||
try:
|
||
# Get K-line data of all targets
|
||
all_data = {}
|
||
for symbol in symbols:
|
||
try:
|
||
klines = self._fetch_latest_kline(symbol, timeframe, limit=200, market_category=market_category)
|
||
if klines and len(klines) >= 2:
|
||
df = self._klines_to_dataframe(klines)
|
||
if len(df) > 0:
|
||
all_data[symbol] = df
|
||
except Exception as e:
|
||
logger.warning(f"Failed to fetch data for {symbol}: {e}")
|
||
continue
|
||
|
||
if not all_data:
|
||
logger.error("No data available for cross-sectional strategy")
|
||
return None
|
||
|
||
# Prepare execution environment
|
||
exec_env = {
|
||
'symbols': list(all_data.keys()),
|
||
'data': all_data, # {symbol: df}
|
||
'scores': {}, # used to store scores
|
||
'rankings': [], # used to store rankings
|
||
'np': np,
|
||
'pd': pd,
|
||
'trading_config': trading_config,
|
||
'config': trading_config,
|
||
}
|
||
|
||
# Execution indicator code
|
||
import builtins
|
||
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', '__import__',
|
||
]}
|
||
exec_env['__builtins__'] = safe_builtins
|
||
|
||
pre_import_code = "import numpy as np\nimport pandas as pd\n"
|
||
exec(pre_import_code, exec_env)
|
||
exec(indicator_code, exec_env)
|
||
|
||
scores = exec_env.get('scores', {})
|
||
rankings = exec_env.get('rankings', [])
|
||
|
||
# If rankings are not provided, sort according to scores
|
||
if not rankings and scores:
|
||
rankings = sorted(scores.keys(), key=lambda x: scores.get(x, 0), reverse=True)
|
||
|
||
return {
|
||
'scores': scores,
|
||
'rankings': rankings
|
||
}
|
||
except Exception as e:
|
||
logger.error(f"Failed to execute cross-sectional indicator: {e}")
|
||
logger.error(traceback.format_exc())
|
||
return None
|
||
|
||
def _generate_cross_sectional_signals(
|
||
self,
|
||
strategy_id: int,
|
||
rankings: List[str],
|
||
scores: Dict[str, float],
|
||
trading_config: Dict[str, Any]
|
||
) -> List[Dict[str, Any]]:
|
||
"""
|
||
Generate cross-sectional strategy signals from the ranking results.
|
||
"""
|
||
portfolio_size = trading_config.get('portfolio_size', 10)
|
||
long_ratio = float(trading_config.get('long_ratio', 0.5))
|
||
|
||
# Select the position target
|
||
long_count = int(portfolio_size * long_ratio)
|
||
short_count = portfolio_size - long_count
|
||
|
||
long_symbols = set(rankings[:long_count]) if long_count > 0 else set()
|
||
short_symbols = set(rankings[-short_count:]) if short_count > 0 and len(rankings) >= short_count else set()
|
||
|
||
# Get the current position
|
||
current_positions = self._get_all_positions(strategy_id)
|
||
current_long = {p['symbol'] for p in current_positions if p.get('side') == 'long'}
|
||
current_short = {p['symbol'] for p in current_positions if p.get('side') == 'short'}
|
||
|
||
signals = []
|
||
|
||
# Generate long signal
|
||
for symbol in long_symbols:
|
||
if symbol not in current_long:
|
||
# If there is currently no long position, open a long position
|
||
if symbol in current_short:
|
||
# If the current position is a short position, close the short position first and then open a long position
|
||
signals.append({
|
||
'symbol': symbol,
|
||
'type': 'close_short',
|
||
'score': scores.get(symbol, 0)
|
||
})
|
||
signals.append({
|
||
'symbol': symbol,
|
||
'type': 'open_long',
|
||
'score': scores.get(symbol, 0)
|
||
})
|
||
|
||
# Close long positions that are not in the long list
|
||
for symbol in current_long:
|
||
if symbol not in long_symbols:
|
||
signals.append({
|
||
'symbol': symbol,
|
||
'type': 'close_long',
|
||
'score': scores.get(symbol, 0)
|
||
})
|
||
|
||
# Generate short signal
|
||
for symbol in short_symbols:
|
||
if symbol not in current_short:
|
||
# If there is currently no short position, open a short position
|
||
if symbol in current_long:
|
||
# If you are currently in a long position, close the long position first and then open a short position.
|
||
signals.append({
|
||
'symbol': symbol,
|
||
'type': 'close_long',
|
||
'score': scores.get(symbol, 0)
|
||
})
|
||
signals.append({
|
||
'symbol': symbol,
|
||
'type': 'open_short',
|
||
'score': scores.get(symbol, 0)
|
||
})
|
||
|
||
# Close short positions that are not in the short list
|
||
for symbol in current_short:
|
||
if symbol not in short_symbols:
|
||
signals.append({
|
||
'symbol': symbol,
|
||
'type': 'close_short',
|
||
'score': scores.get(symbol, 0)
|
||
})
|
||
|
||
return signals
|
||
|
||
def _run_cross_sectional_strategy_loop(
|
||
self,
|
||
strategy_id: int,
|
||
strategy: Dict[str, Any],
|
||
trading_config: Dict[str, Any],
|
||
indicator_config: Dict[str, Any],
|
||
ai_model_config: Dict[str, Any],
|
||
execution_mode: str,
|
||
notification_config: Dict[str, Any],
|
||
strategy_name: str,
|
||
market_category: str,
|
||
market_type: str,
|
||
leverage: float,
|
||
initial_capital: float,
|
||
indicator_code: str,
|
||
indicator_id: Optional[int]
|
||
):
|
||
"""
|
||
Cross-sectional strategy execution loop.
|
||
"""
|
||
logger.info(f"Starting cross-sectional strategy loop for strategy {strategy_id}")
|
||
|
||
symbol_list = trading_config.get('symbol_list', [])
|
||
if not symbol_list:
|
||
logger.error(f"Strategy {strategy_id} has no symbol_list for cross-sectional strategy")
|
||
return
|
||
|
||
timeframe = trading_config.get('timeframe', '1H')
|
||
rebalance_frequency = trading_config.get('rebalance_frequency', 'daily')
|
||
tick_interval_sec = int(trading_config.get('decide_interval', 300))
|
||
|
||
last_tick_time = 0
|
||
last_rebalance_time = 0
|
||
|
||
while True:
|
||
try:
|
||
# Check policy status
|
||
if not self._is_strategy_running(strategy_id):
|
||
logger.info(f"Cross-sectional strategy {strategy_id} stopped")
|
||
break
|
||
|
||
current_time = time.time()
|
||
|
||
# Sleep until next tick
|
||
if last_tick_time > 0:
|
||
sleep_sec = (last_tick_time + tick_interval_sec) - current_time
|
||
if sleep_sec > 0:
|
||
time.sleep(min(sleep_sec, 1.0))
|
||
continue
|
||
last_tick_time = current_time
|
||
|
||
# Check whether position adjustment is needed
|
||
if not self._should_rebalance(strategy_id, rebalance_frequency):
|
||
continue
|
||
|
||
logger.info(f"Cross-sectional strategy {strategy_id} rebalancing...")
|
||
|
||
#Execution cross-section indicators
|
||
result = self._execute_cross_sectional_indicator(
|
||
indicator_code, symbol_list, trading_config, market_category, timeframe
|
||
)
|
||
|
||
if not result:
|
||
logger.warning(f"Cross-sectional indicator returned no result")
|
||
continue
|
||
|
||
# Generate signal
|
||
signals = self._generate_cross_sectional_signals(
|
||
strategy_id, result['rankings'], result['scores'], trading_config
|
||
)
|
||
|
||
if not signals:
|
||
logger.info(f"No rebalancing needed for strategy {strategy_id}")
|
||
self._update_last_rebalance(strategy_id)
|
||
continue
|
||
|
||
logger.info(f"Generated {len(signals)} signals for cross-sectional strategy {strategy_id}")
|
||
|
||
# Execute transactions in batches
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
with ThreadPoolExecutor(max_workers=min(10, len(signals))) as executor:
|
||
futures = {}
|
||
for signal in signals:
|
||
future = executor.submit(
|
||
self._execute_signal,
|
||
strategy_id=strategy_id,
|
||
strategy_name=strategy_name,
|
||
exchange=None, # Signal mode
|
||
symbol=signal['symbol'],
|
||
current_price=0.0, # Will be fetched in _execute_signal
|
||
signal_type=signal['type'],
|
||
position_size=None,
|
||
current_positions=[],
|
||
trade_direction='both',
|
||
leverage=leverage,
|
||
initial_capital=initial_capital,
|
||
market_type=market_type,
|
||
market_category=market_category,
|
||
margin_mode='cross',
|
||
stop_loss_price=None,
|
||
take_profit_price=None,
|
||
execution_mode=execution_mode,
|
||
notification_config=notification_config,
|
||
trading_config=trading_config,
|
||
ai_model_config=ai_model_config,
|
||
signal_ts=int(current_time)
|
||
)
|
||
futures[future] = signal
|
||
|
||
# Wait for all transactions to complete
|
||
for future in as_completed(futures):
|
||
signal = futures[future]
|
||
try:
|
||
result = future.result(timeout=30)
|
||
if result:
|
||
logger.info(f"Successfully executed signal: {signal['symbol']} {signal['type']}")
|
||
except Exception as e:
|
||
logger.error(f"Failed to execute signal {signal['symbol']} {signal['type']}: {e}")
|
||
|
||
# Update position rebalancing time
|
||
self._update_last_rebalance(strategy_id)
|
||
last_rebalance_time = current_time
|
||
|
||
except Exception as e:
|
||
logger.error(f"Cross-sectional strategy loop error: {e}")
|
||
logger.error(traceback.format_exc())
|
||
time.sleep(5) # Wait before retrying
|