Add trend MACD spot strategy
This commit is contained in:
+19
-3
@@ -141,6 +141,7 @@ class CryptoSpotBot:
|
||||
self._entry_cooldown_until.pop(symbol, None)
|
||||
ticker = self.market.tickers.get(symbol)
|
||||
candles = self.market.candles.get(symbol, [])
|
||||
trend_candles = self.market.trend_candles.get(symbol, [])
|
||||
open_count = len(self.broker.positions_for_symbol(symbol))
|
||||
pattern = self.market.patterns.get(symbol, {})
|
||||
forecast = self.market.forecasts.get(symbol, {})
|
||||
@@ -166,7 +167,18 @@ class CryptoSpotBot:
|
||||
account=account,
|
||||
)
|
||||
).as_dict()
|
||||
signal = self.strategy.entry_signal(symbol, candles, ticker, open_count, pattern, learning, llm, forecast, account)
|
||||
signal = self.strategy.entry_signal(
|
||||
symbol,
|
||||
candles,
|
||||
ticker,
|
||||
open_count,
|
||||
pattern,
|
||||
learning,
|
||||
llm,
|
||||
forecast,
|
||||
account,
|
||||
trend_candles,
|
||||
)
|
||||
self.storage.insert_signal(signal)
|
||||
if signal.action == "BUY" and ticker is not None:
|
||||
self.broker.buy(
|
||||
@@ -204,7 +216,7 @@ class CryptoSpotBot:
|
||||
return worst.id
|
||||
|
||||
def _update_patterns(self) -> None:
|
||||
if not self.settings.pattern_analysis_enabled:
|
||||
if self.settings.strategy_mode == "trend_macd" or not self.settings.pattern_analysis_enabled:
|
||||
self.market.patterns = {}
|
||||
return
|
||||
patterns: dict[str, dict] = {}
|
||||
@@ -217,7 +229,11 @@ class CryptoSpotBot:
|
||||
self.market.patterns = patterns
|
||||
|
||||
def _update_forecasts(self) -> None:
|
||||
if self.forecaster is None or not self.settings.time_series_forecast_enabled:
|
||||
if (
|
||||
self.settings.strategy_mode == "trend_macd"
|
||||
or self.forecaster is None
|
||||
or not self.settings.time_series_forecast_enabled
|
||||
):
|
||||
self.market.forecasts = {}
|
||||
return
|
||||
forecasts: dict[str, dict] = {}
|
||||
|
||||
@@ -205,10 +205,10 @@ class BybitClient:
|
||||
return self.private_post("/v5/order/create", payload)
|
||||
|
||||
|
||||
def websocket_subscribe_message(symbols: list[str]) -> str:
|
||||
def websocket_subscribe_message(symbols: list[str], interval: str = "1") -> str:
|
||||
args: list[str] = []
|
||||
for symbol in symbols:
|
||||
args.extend([f"tickers.{symbol}", f"kline.1.{symbol}", f"orderbook.1.{symbol}"])
|
||||
args.extend([f"tickers.{symbol}", f"kline.{interval}.{symbol}", f"orderbook.1.{symbol}"])
|
||||
return json.dumps({"op": "subscribe", "args": args})
|
||||
|
||||
|
||||
|
||||
+31
-13
@@ -5,7 +5,8 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
FIXED_SPOT_SYMBOLS = ("BTCUSDT", "ETHUSDT", "HYPEUSDT", "SOLUSDT", "LTCUSDT", "XRPUSDT")
|
||||
FIXED_SPOT_SYMBOLS = ("BTCUSDT", "ETHUSDT", "SOLUSDT")
|
||||
STRATEGY_MODES = {"legacy", "trend_macd"}
|
||||
|
||||
|
||||
def _load_dotenv(path: Path) -> None:
|
||||
@@ -55,8 +56,11 @@ class Settings:
|
||||
auto_select_symbols: bool
|
||||
top_symbols_count: int
|
||||
symbols: tuple[str, ...]
|
||||
strategy_mode: str
|
||||
base_interval: str
|
||||
kline_limit: int
|
||||
trend_interval: str
|
||||
trend_kline_limit: int
|
||||
loop_interval_seconds: int
|
||||
fast_trading_enabled: bool
|
||||
fast_loop_interval_seconds: float
|
||||
@@ -96,6 +100,10 @@ class Settings:
|
||||
kelly_sizing_enabled: bool
|
||||
kelly_fraction: float
|
||||
kelly_max_fraction: float
|
||||
risk_per_trade_percent: float
|
||||
atr_trailing_multiplier: float
|
||||
trend_rsi_min: float
|
||||
trend_rsi_max: float
|
||||
time_series_forecast_enabled: bool
|
||||
time_series_min_candles: int
|
||||
time_series_forecast_horizon: int
|
||||
@@ -166,6 +174,9 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
|
||||
mode = os.getenv("TRADING_MODE", "paper").strip().lower()
|
||||
if mode not in {"paper", "live"}:
|
||||
raise ValueError("TRADING_MODE must be paper or live")
|
||||
strategy_mode = os.getenv("STRATEGY_MODE", "trend_macd").strip().lower()
|
||||
if strategy_mode not in STRATEGY_MODES:
|
||||
raise ValueError("STRATEGY_MODE must be legacy or trend_macd")
|
||||
settings = Settings(
|
||||
trading_mode=mode,
|
||||
host=os.getenv("HOST", "127.0.0.1"),
|
||||
@@ -177,8 +188,11 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
|
||||
auto_select_symbols=_bool_env("AUTO_SELECT_SYMBOLS", False),
|
||||
top_symbols_count=_int_env("TOP_SYMBOLS_COUNT", len(FIXED_SPOT_SYMBOLS)),
|
||||
symbols=_symbols_env("SYMBOLS") or FIXED_SPOT_SYMBOLS,
|
||||
base_interval=os.getenv("BASE_INTERVAL", "1"),
|
||||
strategy_mode=strategy_mode,
|
||||
base_interval=os.getenv("BASE_INTERVAL", "60"),
|
||||
kline_limit=_int_env("KLINE_LIMIT", 240),
|
||||
trend_interval=os.getenv("TREND_INTERVAL", "D"),
|
||||
trend_kline_limit=_int_env("TREND_KLINE_LIMIT", 260),
|
||||
loop_interval_seconds=_int_env("LOOP_INTERVAL_SECONDS", 5),
|
||||
fast_trading_enabled=_bool_env("FAST_TRADING_ENABLED", False),
|
||||
fast_loop_interval_seconds=_float_env("FAST_LOOP_INTERVAL_SECONDS", 1.0),
|
||||
@@ -188,9 +202,9 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
|
||||
min_signal_confidence=_float_env("MIN_SIGNAL_CONFIDENCE", 0.64),
|
||||
max_spread_percent=_float_env("MAX_SPREAD_PERCENT", 0.18),
|
||||
min_24h_turnover_usdt=_float_env("MIN_24H_TURNOVER_USDT", 1000000.0),
|
||||
pattern_analysis_enabled=_bool_env("PATTERN_ANALYSIS_ENABLED", True),
|
||||
pattern_analysis_enabled=_bool_env("PATTERN_ANALYSIS_ENABLED", False),
|
||||
pattern_score_weight=_float_env("PATTERN_SCORE_WEIGHT", 0.18),
|
||||
learning_enabled=_bool_env("LEARNING_ENABLED", True),
|
||||
learning_enabled=_bool_env("LEARNING_ENABLED", False),
|
||||
learning_lookback_trades=_int_env("LEARNING_LOOKBACK_TRADES", 120),
|
||||
learning_min_samples=_int_env("LEARNING_MIN_SAMPLES", 3),
|
||||
learning_max_adjustment=_float_env("LEARNING_MAX_ADJUSTMENT", 0.12),
|
||||
@@ -202,30 +216,34 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
|
||||
llm_advisor_timeout_seconds=_int_env("LLM_ADVISOR_TIMEOUT_SECONDS", 45),
|
||||
llm_advisor_max_adjustment=_float_env("LLM_ADVISOR_MAX_ADJUSTMENT", 0.06),
|
||||
min_position_usdt=_float_env("MIN_POSITION_USDT", 1.0),
|
||||
max_position_usdt=_float_env("MAX_POSITION_USDT", 20.0),
|
||||
max_symbol_exposure_usdt=_float_env("MAX_SYMBOL_EXPOSURE_USDT", 20.0),
|
||||
max_total_exposure_usdt=_float_env("MAX_TOTAL_EXPOSURE_USDT", 80.0),
|
||||
max_open_positions=_int_env("MAX_OPEN_POSITIONS", 6),
|
||||
max_position_usdt=_float_env("MAX_POSITION_USDT", 25.0),
|
||||
max_symbol_exposure_usdt=_float_env("MAX_SYMBOL_EXPOSURE_USDT", 25.0),
|
||||
max_total_exposure_usdt=_float_env("MAX_TOTAL_EXPOSURE_USDT", 75.0),
|
||||
max_open_positions=_int_env("MAX_OPEN_POSITIONS", len(FIXED_SPOT_SYMBOLS)),
|
||||
max_positions_per_symbol=_int_env("MAX_POSITIONS_PER_SYMBOL", 1),
|
||||
grid_trading_enabled=_bool_env("GRID_TRADING_ENABLED", True),
|
||||
grid_trading_enabled=_bool_env("GRID_TRADING_ENABLED", False),
|
||||
grid_entry_confidence=_float_env("GRID_ENTRY_CONFIDENCE", 0.58),
|
||||
grid_buy_zone=_float_env("GRID_BUY_ZONE", 0.45),
|
||||
grid_max_position_usdt=_float_env("GRID_MAX_POSITION_USDT", 8.0),
|
||||
rebound_trading_enabled=_bool_env("REBOUND_TRADING_ENABLED", True),
|
||||
rebound_trading_enabled=_bool_env("REBOUND_TRADING_ENABLED", False),
|
||||
rebound_entry_confidence=_float_env("REBOUND_ENTRY_CONFIDENCE", 0.58),
|
||||
rebound_min_probability=_float_env("REBOUND_MIN_PROBABILITY", 0.58),
|
||||
rebound_max_position_usdt=_float_env("REBOUND_MAX_POSITION_USDT", 6.0),
|
||||
kelly_sizing_enabled=_bool_env("KELLY_SIZING_ENABLED", True),
|
||||
kelly_sizing_enabled=_bool_env("KELLY_SIZING_ENABLED", False),
|
||||
kelly_fraction=_float_env("KELLY_FRACTION", 0.25),
|
||||
kelly_max_fraction=_float_env("KELLY_MAX_FRACTION", 0.20),
|
||||
time_series_forecast_enabled=_bool_env("TIME_SERIES_FORECAST_ENABLED", True),
|
||||
risk_per_trade_percent=_float_env("RISK_PER_TRADE_PERCENT", 0.01),
|
||||
atr_trailing_multiplier=_float_env("ATR_TRAILING_MULTIPLIER", 2.2),
|
||||
trend_rsi_min=_float_env("TREND_RSI_MIN", 45.0),
|
||||
trend_rsi_max=_float_env("TREND_RSI_MAX", 65.0),
|
||||
time_series_forecast_enabled=_bool_env("TIME_SERIES_FORECAST_ENABLED", False),
|
||||
time_series_min_candles=_int_env("TIME_SERIES_MIN_CANDLES", 120),
|
||||
time_series_forecast_horizon=_int_env("TIME_SERIES_FORECAST_HORIZON", 3),
|
||||
time_series_min_edge_percent=_float_env("TIME_SERIES_MIN_EDGE_PERCENT", 0.04),
|
||||
time_series_max_adjustment=_float_env("TIME_SERIES_MAX_ADJUSTMENT", 0.08),
|
||||
time_series_lstm_enabled=_bool_env("TIME_SERIES_LSTM_ENABLED", True),
|
||||
time_series_lstm_model_path=Path(os.getenv("TIME_SERIES_LSTM_MODEL_PATH", "runtime/lstm_forecaster.json")),
|
||||
stop_loss_percent=_float_env("STOP_LOSS_PERCENT", 0.02),
|
||||
stop_loss_percent=_float_env("STOP_LOSS_PERCENT", 0.04),
|
||||
take_profit_percent=_float_env("TAKE_PROFIT_PERCENT", 0.035),
|
||||
trailing_stop_percent=_float_env("TRAILING_STOP_PERCENT", 0.015),
|
||||
min_hold_seconds=_int_env("MIN_HOLD_SECONDS", 180),
|
||||
|
||||
@@ -176,8 +176,11 @@ def _safe_config(settings: Settings) -> dict[str, Any]:
|
||||
"auto_select_symbols": settings.auto_select_symbols,
|
||||
"top_symbols_count": settings.top_symbols_count,
|
||||
"symbols": settings.symbols,
|
||||
"strategy_mode": settings.strategy_mode,
|
||||
"base_interval": settings.base_interval,
|
||||
"kline_limit": settings.kline_limit,
|
||||
"trend_interval": settings.trend_interval,
|
||||
"trend_kline_limit": settings.trend_kline_limit,
|
||||
"loop_interval_seconds": settings.loop_interval_seconds,
|
||||
"fast_trading_enabled": settings.fast_trading_enabled,
|
||||
"fast_loop_interval_seconds": settings.fast_loop_interval_seconds,
|
||||
@@ -213,6 +216,10 @@ def _safe_config(settings: Settings) -> dict[str, Any]:
|
||||
"kelly_sizing_enabled": settings.kelly_sizing_enabled,
|
||||
"kelly_fraction": settings.kelly_fraction,
|
||||
"kelly_max_fraction": settings.kelly_max_fraction,
|
||||
"risk_per_trade_percent": settings.risk_per_trade_percent,
|
||||
"atr_trailing_multiplier": settings.atr_trailing_multiplier,
|
||||
"trend_rsi_min": settings.trend_rsi_min,
|
||||
"trend_rsi_max": settings.trend_rsi_max,
|
||||
"time_series_forecast_enabled": settings.time_series_forecast_enabled,
|
||||
"time_series_min_candles": settings.time_series_min_candles,
|
||||
"time_series_forecast_horizon": settings.time_series_forecast_horizon,
|
||||
@@ -909,6 +916,7 @@ HTML = r"""
|
||||
function renderConfig(config) {
|
||||
const keys = [
|
||||
['Режим', modeName(config.trading_mode)],
|
||||
['Стратегия', config.strategy_mode || '-'],
|
||||
['Стартовый баланс', money(config.starting_balance_usdt)],
|
||||
['Мин. уверенность', config.min_signal_confidence],
|
||||
['Быстрая торговля', yesNo(config.fast_trading_enabled)],
|
||||
@@ -923,25 +931,15 @@ HTML = r"""
|
||||
['Мин. оборот 24ч', money(config.min_24h_turnover_usdt)],
|
||||
['Размер позиции', `${money(config.min_position_usdt)} - ${money(config.max_position_usdt)}`],
|
||||
['Лимит на пару', money(config.max_symbol_exposure_usdt)],
|
||||
['Grid-режим', yesNo(config.grid_trading_enabled)],
|
||||
['Grid порог / зона', `${num(config.grid_entry_confidence, 2)} / ${num((config.grid_buy_zone || 0) * 100, 0)}%`],
|
||||
['Grid макс. размер', money(config.grid_max_position_usdt)],
|
||||
['Rebound-режим', yesNo(config.rebound_trading_enabled)],
|
||||
['Rebound порог / вероятность', `${num(config.rebound_entry_confidence, 2)} / ${num(config.rebound_min_probability, 2)}`],
|
||||
['Rebound макс. размер', money(config.rebound_max_position_usdt)],
|
||||
['Kelly размер', `${yesNo(config.kelly_sizing_enabled)} · ${num(config.kelly_fraction, 2)}x · max ${num((config.kelly_max_fraction || 0) * 100, 1)}%`],
|
||||
['Прогноз временных рядов', yesNo(config.time_series_forecast_enabled)],
|
||||
['Модельный горизонт', `${config.time_series_forecast_horizon} свечи`],
|
||||
['Мин. edge прогноза', `${num(config.time_series_min_edge_percent, 3)}%`],
|
||||
['Нейропрогноз', modelArtifactSummary(config)],
|
||||
['Файл модели', config.time_series_lstm_model_path || '-'],
|
||||
['Риск на сделку', `${num((config.risk_per_trade_percent || 0) * 100, 2)}% equity`],
|
||||
['RSI входа', `${num(config.trend_rsi_min, 1)} - ${num(config.trend_rsi_max, 1)}`],
|
||||
['Лимит в позициях', money(config.max_total_exposure_usdt)],
|
||||
['Лимит позиций', `${config.max_open_positions} всего / ${config.max_positions_per_symbol} на пару`],
|
||||
['Стоп / цель', `${num(config.stop_loss_percent * 100, 2)}% / ${num(config.take_profit_percent * 100, 2)}%`],
|
||||
['Трейлинг-стоп', `${num(config.trailing_stop_percent * 100, 2)}%`],
|
||||
['Стоп', `${num(config.stop_loss_percent * 100, 2)}%`],
|
||||
['ATR trailing', `${num(config.atr_trailing_multiplier, 2)} ATR`],
|
||||
['Удержание / пауза', `${config.min_hold_seconds}с / ${config.entry_cooldown_seconds}с`],
|
||||
['Комиссия / проскальзывание', `${num(config.taker_fee_rate * 100, 3)}% / ${num(config.slippage_rate * 100, 3)}%`],
|
||||
['Таймфрейм', `${config.base_interval}м`],
|
||||
['Таймфрейм', `${config.base_interval} / тренд ${config.trend_interval}`],
|
||||
['Поток данных', yesNo(config.websocket_enabled)]
|
||||
];
|
||||
document.getElementById('configGrid').innerHTML = keys.map(([k, v]) => `
|
||||
|
||||
@@ -92,6 +92,8 @@ class PaperBroker:
|
||||
return False, "достигнут лимит новых входов в минуту"
|
||||
if len(self.positions) >= self.settings.max_open_positions:
|
||||
return False, "достигнут общий лимит открытых позиций"
|
||||
if self.settings.strategy_mode == "trend_macd" and len(self.positions_for_symbol(symbol)) >= 1:
|
||||
return False, "DCA/усреднение отключено: позиция по паре уже открыта"
|
||||
dynamic_pair_limit = max(
|
||||
self.settings.max_positions_per_symbol,
|
||||
int(self.settings.max_symbol_exposure_usdt // max(self.settings.min_position_usdt, 0.01)),
|
||||
|
||||
@@ -15,6 +15,7 @@ def add_indicators(candles: list[Candle]) -> list[Candle]:
|
||||
ema200 = _ema(closes, 200)
|
||||
rsi14 = _rsi(closes, 14)
|
||||
atr14 = _atr(highs, lows, closes, 14)
|
||||
macd, macd_signal, macd_hist = _macd(closes)
|
||||
volume_ma20 = _sma(volumes, 20)
|
||||
for index, candle in enumerate(candles):
|
||||
candle.ema_20 = ema20[index]
|
||||
@@ -22,6 +23,9 @@ def add_indicators(candles: list[Candle]) -> list[Candle]:
|
||||
candle.ema_200 = ema200[index]
|
||||
candle.rsi_14 = rsi14[index]
|
||||
candle.atr_14 = atr14[index]
|
||||
candle.macd = macd[index]
|
||||
candle.macd_signal = macd_signal[index]
|
||||
candle.macd_hist = macd_hist[index]
|
||||
candle.volume_ma_20 = volume_ma20[index]
|
||||
return candles
|
||||
|
||||
@@ -104,3 +108,33 @@ def _atr(highs: list[float], lows: list[float], closes: list[float], period: int
|
||||
atr = ((atr * (period - 1)) + true_ranges[index]) / period
|
||||
result[index] = atr
|
||||
return result
|
||||
|
||||
|
||||
def _macd(
|
||||
closes: list[float],
|
||||
fast_period: int = 12,
|
||||
slow_period: int = 26,
|
||||
signal_period: int = 9,
|
||||
) -> tuple[list[float | None], list[float | None], list[float | None]]:
|
||||
ema_fast = _ema(closes, fast_period)
|
||||
ema_slow = _ema(closes, slow_period)
|
||||
macd_line: list[float | None] = []
|
||||
compact_macd: list[float] = []
|
||||
compact_indexes: list[int] = []
|
||||
for index, (fast, slow) in enumerate(zip(ema_fast, ema_slow)):
|
||||
value = None if fast is None or slow is None else fast - slow
|
||||
macd_line.append(value)
|
||||
if value is not None:
|
||||
compact_macd.append(value)
|
||||
compact_indexes.append(index)
|
||||
|
||||
signal_line: list[float | None] = [None] * len(closes)
|
||||
hist: list[float | None] = [None] * len(closes)
|
||||
compact_signal = _ema(compact_macd, signal_period)
|
||||
for compact_index, original_index in enumerate(compact_indexes):
|
||||
signal = compact_signal[compact_index]
|
||||
macd_value = macd_line[original_index]
|
||||
signal_line[original_index] = signal
|
||||
if macd_value is not None and signal is not None:
|
||||
hist[original_index] = macd_value - signal
|
||||
return macd_line, signal_line, hist
|
||||
|
||||
@@ -15,7 +15,7 @@ from crypto_spot_bot.models import Candle, Ticker, utc_now
|
||||
from crypto_spot_bot.storage import Storage
|
||||
|
||||
|
||||
POPULAR_FALLBACK = ["BTCUSDT", "ETHUSDT", "SOLUSDT", "XRPUSDT", "DOGEUSDT", "LTCUSDT"]
|
||||
POPULAR_FALLBACK = ["BTCUSDT", "ETHUSDT", "SOLUSDT"]
|
||||
|
||||
|
||||
def _float(value: Any, default: float = 0.0) -> float:
|
||||
@@ -34,6 +34,7 @@ class MarketData:
|
||||
self.instruments: dict[str, Instrument] = {}
|
||||
self.tickers: dict[str, Ticker] = {}
|
||||
self.candles: dict[str, list[Candle]] = {}
|
||||
self.trend_candles: dict[str, list[Candle]] = {}
|
||||
self.orderbook_top: dict[str, tuple[float, float]] = {}
|
||||
self.patterns: dict[str, dict[str, Any]] = {}
|
||||
self.forecasts: dict[str, dict[str, Any]] = {}
|
||||
@@ -77,6 +78,13 @@ class MarketData:
|
||||
)
|
||||
add_indicators(candles)
|
||||
self.candles[symbol] = candles
|
||||
trend_candles = self.client.klines(
|
||||
symbol=symbol,
|
||||
interval=self.settings.trend_interval,
|
||||
limit=self.settings.trend_kline_limit,
|
||||
)
|
||||
add_indicators(trend_candles)
|
||||
self.trend_candles[symbol] = trend_candles
|
||||
bid, ask = self.client.orderbook_top(symbol)
|
||||
self.orderbook_top[symbol] = (bid, ask)
|
||||
if symbol in self.tickers:
|
||||
@@ -101,7 +109,7 @@ class MarketData:
|
||||
try:
|
||||
async with websockets.connect(self.settings.websocket_url, ping_interval=20) as ws:
|
||||
self.ws_connected = True
|
||||
await ws.send(websocket_subscribe_message(self.symbols))
|
||||
await ws.send(websocket_subscribe_message(self.symbols, self.settings.base_interval))
|
||||
self.storage.event("Поток данных Bybit подключен")
|
||||
async for raw in ws:
|
||||
self.last_ws_message_at = utc_now()
|
||||
@@ -215,6 +223,7 @@ class MarketData:
|
||||
{
|
||||
"ticker": self.tickers[symbol].as_dict() if symbol in self.tickers else None,
|
||||
"candles": [candle.as_dict() for candle in self.candles.get(symbol, [])[-120:]],
|
||||
"trend_candles": [candle.as_dict() for candle in self.trend_candles.get(symbol, [])[-5:]],
|
||||
"pattern": self.patterns.get(symbol),
|
||||
"forecast": self.forecasts.get(symbol),
|
||||
"instrument": asdict(self.instruments[symbol]) if symbol in self.instruments else None,
|
||||
|
||||
@@ -23,6 +23,9 @@ class Candle:
|
||||
ema_200: float | None = None
|
||||
rsi_14: float | None = None
|
||||
atr_14: float | None = None
|
||||
macd: float | None = None
|
||||
macd_signal: float | None = None
|
||||
macd_hist: float | None = None
|
||||
volume_ma_20: float | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
|
||||
@@ -22,7 +22,18 @@ class SpotStrategy:
|
||||
llm: dict | None = None,
|
||||
forecast: dict | None = None,
|
||||
account: dict | None = None,
|
||||
trend_candles: list[Candle] | None = None,
|
||||
) -> Signal:
|
||||
if self.settings.strategy_mode == "trend_macd":
|
||||
return _trend_macd_entry_signal(
|
||||
settings=self.settings,
|
||||
symbol=symbol,
|
||||
candles=candles,
|
||||
trend_candles=trend_candles or [],
|
||||
ticker=ticker,
|
||||
open_positions_for_symbol=open_positions_for_symbol,
|
||||
account=account,
|
||||
)
|
||||
if ticker is None:
|
||||
return Signal(symbol, "HOLD", 0.0, "нет ticker-данных")
|
||||
if len(candles) < 200:
|
||||
@@ -343,6 +354,8 @@ class SpotStrategy:
|
||||
learning: dict | None = None,
|
||||
forecast: dict | None = None,
|
||||
) -> Signal:
|
||||
if self.settings.strategy_mode == "trend_macd":
|
||||
return _trend_macd_exit_signal(self.settings, position, candles, ticker)
|
||||
if ticker is None:
|
||||
return Signal(position.symbol, "HOLD", 0.0, "нет ticker-данных для выхода")
|
||||
if not candles:
|
||||
@@ -455,6 +468,192 @@ def _has_entry_indicators(candle: Candle) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _trend_macd_entry_signal(
|
||||
*,
|
||||
settings: Settings,
|
||||
symbol: str,
|
||||
candles: list[Candle],
|
||||
trend_candles: list[Candle],
|
||||
ticker: Ticker | None,
|
||||
open_positions_for_symbol: int,
|
||||
account: dict | None,
|
||||
) -> Signal:
|
||||
if ticker is None:
|
||||
return Signal(symbol, "HOLD", 0.0, "нет ticker-данных")
|
||||
if open_positions_for_symbol > 0:
|
||||
return Signal(symbol, "HOLD", 0.0, "позиция по паре уже открыта")
|
||||
if len(candles) < 60:
|
||||
return Signal(symbol, "HOLD", 0.0, "недостаточно 1h свечей для trend_macd")
|
||||
if len(trend_candles) < 200:
|
||||
return Signal(symbol, "HOLD", 0.0, "недостаточно 1d свечей для EMA200")
|
||||
|
||||
latest = candles[-1]
|
||||
previous = candles[-2]
|
||||
trend_latest = trend_candles[-1]
|
||||
if not _has_trend_entry_indicators(latest, previous, trend_latest):
|
||||
return Signal(symbol, "HOLD", 0.0, "индикаторы trend_macd еще не готовы")
|
||||
|
||||
spread_ok = ticker.spread_percent <= settings.max_spread_percent
|
||||
liquidity_ok = ticker.turnover_24h >= settings.min_24h_turnover_usdt
|
||||
daily_trend_ok = bool(trend_latest.close > trend_latest.ema_200 and trend_latest.ema_50 > trend_latest.ema_200)
|
||||
macd_cross_up = _macd_crossed_up(previous, latest)
|
||||
price_above_ema50 = bool(latest.close > latest.ema_50)
|
||||
rsi_min = min(settings.trend_rsi_min, settings.trend_rsi_max)
|
||||
rsi_max = max(settings.trend_rsi_min, settings.trend_rsi_max)
|
||||
rsi_ok = bool(rsi_min <= latest.rsi_14 <= rsi_max)
|
||||
stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08)
|
||||
sizing = _trend_position_sizing(settings, account, stop_loss_percent)
|
||||
position_notional = float(sizing["notional_usdt"])
|
||||
checks = {
|
||||
"spread_ok": spread_ok,
|
||||
"liquidity_ok": liquidity_ok,
|
||||
"daily_trend_ok": daily_trend_ok,
|
||||
"macd_cross_up": macd_cross_up,
|
||||
"price_above_ema50": price_above_ema50,
|
||||
"rsi_ok": rsi_ok,
|
||||
"risk_size_ok": position_notional >= settings.min_position_usdt,
|
||||
}
|
||||
diagnostics = {
|
||||
"strategy_mode": "trend_macd",
|
||||
"trade_mode": "TREND_MACD",
|
||||
"position_notional_usdt": position_notional,
|
||||
"position_sizing": sizing,
|
||||
"stop_loss_percent": stop_loss_percent,
|
||||
"atr_trailing_multiplier": _clamp(settings.atr_trailing_multiplier, 0.5, 10.0),
|
||||
"entry_timeframe": settings.base_interval,
|
||||
"trend_timeframe": settings.trend_interval,
|
||||
"rsi_14": latest.rsi_14,
|
||||
"rsi_min": rsi_min,
|
||||
"rsi_max": rsi_max,
|
||||
"ema_50": latest.ema_50,
|
||||
"macd": latest.macd,
|
||||
"macd_signal": latest.macd_signal,
|
||||
"trend_close": trend_latest.close,
|
||||
"trend_ema_50": trend_latest.ema_50,
|
||||
"trend_ema_200": trend_latest.ema_200,
|
||||
"spread_percent": round(ticker.spread_percent, 5),
|
||||
"turnover_24h": ticker.turnover_24h,
|
||||
"checks": checks,
|
||||
"grid": {"enabled": False, "active": False},
|
||||
"rebound": {"enabled": False, "active": False},
|
||||
"forecast": {},
|
||||
"learning": {},
|
||||
"llm": {},
|
||||
}
|
||||
if all(checks.values()):
|
||||
return Signal(
|
||||
symbol,
|
||||
"BUY",
|
||||
0.86,
|
||||
f"trend_macd: 1d тренд вверх, MACD пересек signal вверх, RSI {latest.rsi_14:.1f}, размер {position_notional:.2f} USDT",
|
||||
diagnostics,
|
||||
)
|
||||
failed = ", ".join(name for name, ok in checks.items() if not ok)
|
||||
return Signal(symbol, "HOLD", 0.35, f"trend_macd: условия входа не выполнены ({failed})", diagnostics)
|
||||
|
||||
|
||||
def _trend_macd_exit_signal(
|
||||
settings: Settings,
|
||||
position: Position,
|
||||
candles: list[Candle],
|
||||
ticker: Ticker | None,
|
||||
) -> Signal:
|
||||
if ticker is None:
|
||||
return Signal(position.symbol, "HOLD", 0.0, "нет ticker-данных для выхода")
|
||||
if len(candles) < 2:
|
||||
return Signal(position.symbol, "HOLD", 0.0, "недостаточно 1h свечей для выхода")
|
||||
latest = candles[-1]
|
||||
previous = candles[-2]
|
||||
price = ticker.last_price
|
||||
stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08)
|
||||
effective_stop_loss = max(position.stop_loss, position.entry_price * (1 - stop_loss_percent))
|
||||
atr_multiplier = _clamp(settings.atr_trailing_multiplier, 0.5, 10.0)
|
||||
atr_trailing_stop = None
|
||||
if latest.atr_14 is not None and position.highest_price > position.entry_price:
|
||||
atr_trailing_stop = max(effective_stop_loss, position.highest_price - latest.atr_14 * atr_multiplier)
|
||||
macd_cross_down = _macd_crossed_down(previous, latest)
|
||||
close_below_ema50 = latest.ema_50 is not None and latest.close < latest.ema_50
|
||||
diagnostics = {
|
||||
"strategy_mode": "trend_macd",
|
||||
"price": price,
|
||||
"entry_price": position.entry_price,
|
||||
"stop_loss": effective_stop_loss,
|
||||
"atr_trailing_stop": atr_trailing_stop,
|
||||
"atr_trailing_multiplier": atr_multiplier,
|
||||
"highest_price": position.highest_price,
|
||||
"ema_50": latest.ema_50,
|
||||
"rsi_14": latest.rsi_14,
|
||||
"atr_14": latest.atr_14,
|
||||
"macd": latest.macd,
|
||||
"macd_signal": latest.macd_signal,
|
||||
"macd_cross_down": macd_cross_down,
|
||||
"close_below_ema50": close_below_ema50,
|
||||
}
|
||||
if price <= effective_stop_loss:
|
||||
return Signal(position.symbol, "SELL", 1.0, "trend_macd: сработал стоп-лосс", diagnostics)
|
||||
if atr_trailing_stop is not None and price <= atr_trailing_stop:
|
||||
return Signal(position.symbol, "SELL", 0.94, "trend_macd: сработал ATR trailing stop", diagnostics)
|
||||
if macd_cross_down:
|
||||
return Signal(position.symbol, "SELL", 0.84, "trend_macd: MACD пересек signal вниз", diagnostics)
|
||||
if close_below_ema50:
|
||||
return Signal(position.symbol, "SELL", 0.82, "trend_macd: 1h свеча закрылась ниже EMA50", diagnostics)
|
||||
return Signal(position.symbol, "HOLD", 0.35, "trend_macd: условия выхода не выполнены", diagnostics)
|
||||
|
||||
|
||||
def _has_trend_entry_indicators(current: Candle, previous: Candle, trend: Candle) -> bool:
|
||||
return all(
|
||||
value is not None
|
||||
for value in (
|
||||
current.ema_50,
|
||||
current.rsi_14,
|
||||
current.atr_14,
|
||||
current.macd,
|
||||
current.macd_signal,
|
||||
previous.macd,
|
||||
previous.macd_signal,
|
||||
trend.ema_50,
|
||||
trend.ema_200,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _macd_crossed_up(previous: Candle, current: Candle) -> bool:
|
||||
if None in (previous.macd, previous.macd_signal, current.macd, current.macd_signal):
|
||||
return False
|
||||
return bool(previous.macd <= previous.macd_signal and current.macd > current.macd_signal)
|
||||
|
||||
|
||||
def _macd_crossed_down(previous: Candle, current: Candle) -> bool:
|
||||
if None in (previous.macd, previous.macd_signal, current.macd, current.macd_signal):
|
||||
return False
|
||||
return bool(previous.macd >= previous.macd_signal and current.macd < current.macd_signal)
|
||||
|
||||
|
||||
def _trend_position_sizing(
|
||||
settings: Settings,
|
||||
account: dict | None,
|
||||
stop_loss_percent: float,
|
||||
) -> dict[str, float | str]:
|
||||
equity = _safe_float((account or {}).get("equity"), settings.starting_balance_usdt)
|
||||
if equity <= 0:
|
||||
equity = settings.starting_balance_usdt
|
||||
risk_fraction = _clamp(settings.risk_per_trade_percent, 0.0, 0.01)
|
||||
risk_usdt = equity * risk_fraction
|
||||
raw_notional = risk_usdt / max(stop_loss_percent, 0.0001)
|
||||
high = max(0.0, settings.max_position_usdt)
|
||||
low = max(0.0, settings.min_position_usdt)
|
||||
notional = 0.0 if raw_notional < low else min(raw_notional, high)
|
||||
return {
|
||||
"method": "fixed_fractional_risk",
|
||||
"risk_per_trade_percent": round(risk_fraction * 100, 4),
|
||||
"risk_usdt": round(risk_usdt, 4),
|
||||
"stop_loss_percent": round(stop_loss_percent * 100, 4),
|
||||
"raw_notional_usdt": round(raw_notional, 4),
|
||||
"notional_usdt": round(notional, 2),
|
||||
"equity_usdt": round(equity, 2),
|
||||
}
|
||||
|
||||
|
||||
def _decision_suffix(pattern: dict, learning: dict, llm: dict | None = None) -> str:
|
||||
parts: list[str] = []
|
||||
label = pattern.get("label")
|
||||
|
||||
Reference in New Issue
Block a user