Add trend MACD spot strategy

This commit is contained in:
Codex
2026-06-21 09:15:38 +03:00
parent f19856ca6e
commit 8a211acf98
18 changed files with 593 additions and 81 deletions
+199
View File
@@ -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")