Add trend MACD spot strategy
This commit is contained in:
+8
-1
@@ -21,8 +21,11 @@ def make_settings():
|
||||
auto_select_symbols=True,
|
||||
top_symbols_count=6,
|
||||
symbols=(),
|
||||
base_interval="1",
|
||||
strategy_mode="legacy",
|
||||
base_interval="60",
|
||||
kline_limit=240,
|
||||
trend_interval="D",
|
||||
trend_kline_limit=260,
|
||||
loop_interval_seconds=5,
|
||||
fast_trading_enabled=False,
|
||||
fast_loop_interval_seconds=1.0,
|
||||
@@ -62,6 +65,10 @@ def make_settings():
|
||||
kelly_sizing_enabled=True,
|
||||
kelly_fraction=0.25,
|
||||
kelly_max_fraction=0.20,
|
||||
risk_per_trade_percent=0.01,
|
||||
atr_trailing_multiplier=2.2,
|
||||
trend_rsi_min=45.0,
|
||||
trend_rsi_max=65.0,
|
||||
time_series_forecast_enabled=True,
|
||||
time_series_min_candles=120,
|
||||
time_series_forecast_horizon=3,
|
||||
|
||||
+8
-1
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from crypto_spot_bot.bybit import BybitClient, _looks_like_leveraged_token, _looks_like_stablecoin
|
||||
from crypto_spot_bot.bybit import BybitClient, websocket_subscribe_message, _looks_like_leveraged_token, _looks_like_stablecoin
|
||||
|
||||
|
||||
def test_leveraged_token_filter() -> None:
|
||||
@@ -56,3 +56,10 @@ def test_live_spot_order_explicitly_disables_leverage(make_settings, tmp_path) -
|
||||
assert captured["payload"]["category"] == "spot"
|
||||
assert captured["payload"]["isLeverage"] == 0
|
||||
assert captured["payload"]["orderFilter"] == "Order"
|
||||
|
||||
|
||||
def test_websocket_subscribe_uses_configured_kline_interval() -> None:
|
||||
payload = websocket_subscribe_message(["BTCUSDT"], interval="60")
|
||||
|
||||
assert "kline.60.BTCUSDT" in payload
|
||||
assert "kline.1.BTCUSDT" not in payload
|
||||
|
||||
@@ -63,10 +63,11 @@ def test_llm_advisor_is_disabled_by_default(tmp_path, monkeypatch) -> None:
|
||||
assert settings.llm_advisor_enabled is False
|
||||
|
||||
|
||||
def test_default_symbols_are_fixed_six_pairs(tmp_path, monkeypatch) -> None:
|
||||
def test_default_symbols_are_fixed_trend_pairs(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.delenv("AUTO_SELECT_SYMBOLS", raising=False)
|
||||
monkeypatch.delenv("TOP_SYMBOLS_COUNT", raising=False)
|
||||
monkeypatch.delenv("SYMBOLS", raising=False)
|
||||
monkeypatch.delenv("STRATEGY_MODE", raising=False)
|
||||
monkeypatch.setenv("TRADING_MODE", "paper")
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text("TRADING_MODE=paper\nSYMBOLS=\n", encoding="utf-8")
|
||||
@@ -74,5 +75,9 @@ def test_default_symbols_are_fixed_six_pairs(tmp_path, monkeypatch) -> None:
|
||||
settings = load_settings(env_file)
|
||||
|
||||
assert settings.auto_select_symbols is False
|
||||
assert settings.top_symbols_count == 6
|
||||
assert settings.top_symbols_count == 3
|
||||
assert settings.symbols == FIXED_SPOT_SYMBOLS
|
||||
assert settings.strategy_mode == "trend_macd"
|
||||
assert settings.base_interval == "60"
|
||||
assert settings.trend_interval == "D"
|
||||
assert settings.risk_per_trade_percent == 0.01
|
||||
|
||||
@@ -129,3 +129,28 @@ def test_paper_broker_respects_adaptive_exposure_target(make_settings, tmp_path)
|
||||
|
||||
assert position is None
|
||||
assert broker.open_positions() == []
|
||||
|
||||
|
||||
def test_trend_macd_broker_blocks_dca_for_same_symbol(make_settings, tmp_path) -> None:
|
||||
settings = make_settings(
|
||||
tmp_path,
|
||||
strategy_mode="trend_macd",
|
||||
min_position_usdt=1,
|
||||
max_position_usdt=20,
|
||||
max_symbol_exposure_usdt=20,
|
||||
max_total_exposure_usdt=80,
|
||||
max_open_positions=3,
|
||||
max_positions_per_symbol=20,
|
||||
max_entries_per_minute=0,
|
||||
)
|
||||
storage = Storage(settings.database_path)
|
||||
broker = PaperBroker(settings, storage)
|
||||
ticker = Ticker("BTCUSDT", 100, 99.9, 100.1, 10_000_000, 100, 0)
|
||||
instrument = Instrument("BTCUSDT", "BTC", "USDT", "Trading", 0.01, 0.000001, 0.000001, 1)
|
||||
|
||||
first = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "first", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100})
|
||||
second = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "second", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100})
|
||||
|
||||
assert first is not None
|
||||
assert second is None
|
||||
assert len(broker.open_positions()) == 1
|
||||
|
||||
@@ -25,4 +25,7 @@ def test_add_indicators_populates_long_periods() -> None:
|
||||
assert latest.ema_200 is not None
|
||||
assert latest.rsi_14 is not None
|
||||
assert latest.atr_14 is not None
|
||||
assert latest.macd is not None
|
||||
assert latest.macd_signal is not None
|
||||
assert latest.macd_hist is not None
|
||||
assert latest.volume_ma_20 is not None
|
||||
|
||||
@@ -59,6 +59,180 @@ def _rebound_candles() -> list[Candle]:
|
||||
return candles
|
||||
|
||||
|
||||
def _trend_entry_candles(
|
||||
*,
|
||||
macd_cross_up: bool = True,
|
||||
close: float = 105.0,
|
||||
ema50: float = 100.0,
|
||||
rsi: float = 55.0,
|
||||
) -> list[Candle]:
|
||||
candles = []
|
||||
for index in range(80):
|
||||
candles.append(
|
||||
Candle(
|
||||
timestamp=index,
|
||||
open=close - 0.4,
|
||||
high=close + 0.8,
|
||||
low=close - 0.8,
|
||||
close=close,
|
||||
volume=100,
|
||||
ema_20=close - 1.0,
|
||||
ema_50=ema50,
|
||||
ema_200=ema50 - 4.0,
|
||||
rsi_14=rsi,
|
||||
atr_14=1.0,
|
||||
macd=-0.1,
|
||||
macd_signal=0.0,
|
||||
macd_hist=-0.1,
|
||||
volume_ma_20=90,
|
||||
)
|
||||
)
|
||||
candles[-1].macd = 0.15 if macd_cross_up else -0.05
|
||||
candles[-1].macd_signal = 0.0
|
||||
candles[-1].macd_hist = candles[-1].macd - candles[-1].macd_signal
|
||||
return candles
|
||||
|
||||
|
||||
def _daily_trend_candles(*, uptrend: bool = True) -> list[Candle]:
|
||||
close = 110.0 if uptrend else 95.0
|
||||
ema50 = 105.0 if uptrend else 98.0
|
||||
ema200 = 100.0
|
||||
return [
|
||||
Candle(
|
||||
timestamp=index,
|
||||
open=close - 1.0,
|
||||
high=close + 1.0,
|
||||
low=close - 2.0,
|
||||
close=close,
|
||||
volume=1000,
|
||||
ema_20=ema50,
|
||||
ema_50=ema50,
|
||||
ema_200=ema200,
|
||||
rsi_14=55,
|
||||
atr_14=4.0,
|
||||
macd=1.0,
|
||||
macd_signal=0.8,
|
||||
macd_hist=0.2,
|
||||
volume_ma_20=900,
|
||||
)
|
||||
for index in range(205)
|
||||
]
|
||||
|
||||
|
||||
def test_trend_macd_buys_only_when_rules_pass(make_settings, tmp_path) -> None:
|
||||
settings = make_settings(
|
||||
tmp_path,
|
||||
strategy_mode="trend_macd",
|
||||
max_position_usdt=50,
|
||||
stop_loss_percent=0.04,
|
||||
risk_per_trade_percent=0.01,
|
||||
)
|
||||
strategy = SpotStrategy(settings)
|
||||
ticker = Ticker("BTCUSDT", 105, 104.99, 105.01, 10_000_000, 1000, 1.0)
|
||||
|
||||
signal = strategy.entry_signal(
|
||||
"BTCUSDT",
|
||||
_trend_entry_candles(),
|
||||
ticker,
|
||||
open_positions_for_symbol=0,
|
||||
account={"equity": 100.0},
|
||||
trend_candles=_daily_trend_candles(),
|
||||
)
|
||||
|
||||
assert signal.action == "BUY"
|
||||
assert signal.diagnostics["strategy_mode"] == "trend_macd"
|
||||
assert signal.diagnostics["position_notional_usdt"] == 25.0
|
||||
assert signal.diagnostics["position_sizing"]["risk_per_trade_percent"] == 1.0
|
||||
|
||||
|
||||
def test_trend_macd_blocks_when_daily_trend_filter_fails(make_settings, tmp_path) -> None:
|
||||
settings = make_settings(tmp_path, strategy_mode="trend_macd")
|
||||
strategy = SpotStrategy(settings)
|
||||
ticker = Ticker("ETHUSDT", 105, 104.99, 105.01, 10_000_000, 1000, 1.0)
|
||||
|
||||
signal = strategy.entry_signal(
|
||||
"ETHUSDT",
|
||||
_trend_entry_candles(),
|
||||
ticker,
|
||||
open_positions_for_symbol=0,
|
||||
account={"equity": 100.0},
|
||||
trend_candles=_daily_trend_candles(uptrend=False),
|
||||
)
|
||||
|
||||
assert signal.action == "HOLD"
|
||||
assert signal.diagnostics["checks"]["daily_trend_ok"] is False
|
||||
|
||||
|
||||
def test_trend_macd_blocks_second_position_for_symbol(make_settings, tmp_path) -> None:
|
||||
settings = make_settings(tmp_path, strategy_mode="trend_macd")
|
||||
strategy = SpotStrategy(settings)
|
||||
ticker = Ticker("SOLUSDT", 105, 104.99, 105.01, 10_000_000, 1000, 1.0)
|
||||
|
||||
signal = strategy.entry_signal(
|
||||
"SOLUSDT",
|
||||
_trend_entry_candles(),
|
||||
ticker,
|
||||
open_positions_for_symbol=1,
|
||||
account={"equity": 100.0},
|
||||
trend_candles=_daily_trend_candles(),
|
||||
)
|
||||
|
||||
assert signal.action == "HOLD"
|
||||
assert "позиция" in signal.reason
|
||||
|
||||
|
||||
def test_trend_macd_exits_on_macd_cross_down(make_settings, tmp_path) -> None:
|
||||
settings = make_settings(tmp_path, strategy_mode="trend_macd")
|
||||
strategy = SpotStrategy(settings)
|
||||
candles = _trend_entry_candles()
|
||||
candles[-2].macd = 0.2
|
||||
candles[-2].macd_signal = 0.0
|
||||
candles[-1].macd = -0.1
|
||||
candles[-1].macd_signal = 0.0
|
||||
position = Position(1, "BTCUSDT", 1, 100, 100, 0.1, 96, 120, 100)
|
||||
ticker = Ticker("BTCUSDT", 104, 103.99, 104.01, 1_000_000, 100, 0)
|
||||
|
||||
signal = strategy.exit_signal(position, candles, ticker)
|
||||
|
||||
assert signal.action == "SELL"
|
||||
assert "MACD" in signal.reason
|
||||
|
||||
|
||||
def test_trend_macd_exits_on_close_below_ema50(make_settings, tmp_path) -> None:
|
||||
settings = make_settings(tmp_path, strategy_mode="trend_macd")
|
||||
strategy = SpotStrategy(settings)
|
||||
candles = _trend_entry_candles(close=99.0, ema50=100.0, macd_cross_up=False)
|
||||
candles[-2].macd = 0.1
|
||||
candles[-2].macd_signal = 0.0
|
||||
candles[-1].macd = 0.05
|
||||
candles[-1].macd_signal = 0.0
|
||||
position = Position(1, "BTCUSDT", 1, 100, 100, 0.1, 96, 120, 100)
|
||||
ticker = Ticker("BTCUSDT", 99, 98.99, 99.01, 1_000_000, 100, 0)
|
||||
|
||||
signal = strategy.exit_signal(position, candles, ticker)
|
||||
|
||||
assert signal.action == "SELL"
|
||||
assert "EMA50" in signal.reason
|
||||
|
||||
|
||||
def test_trend_macd_exits_on_atr_trailing_stop(make_settings, tmp_path) -> None:
|
||||
settings = make_settings(tmp_path, strategy_mode="trend_macd", atr_trailing_multiplier=2.2)
|
||||
strategy = SpotStrategy(settings)
|
||||
candles = _trend_entry_candles(close=108.0, ema50=100.0, macd_cross_up=False)
|
||||
candles[-2].macd = 0.1
|
||||
candles[-2].macd_signal = 0.0
|
||||
candles[-1].macd = 0.05
|
||||
candles[-1].macd_signal = 0.0
|
||||
candles[-1].atr_14 = 1.0
|
||||
position = Position(1, "BTCUSDT", 1, 100, 100, 0.1, 96, 120, 110)
|
||||
ticker = Ticker("BTCUSDT", 107.7, 107.69, 107.71, 1_000_000, 100, 0)
|
||||
|
||||
signal = strategy.exit_signal(position, candles, ticker)
|
||||
|
||||
assert signal.action == "SELL"
|
||||
assert "ATR trailing" in signal.reason
|
||||
|
||||
|
||||
def test_strategy_emits_buy_when_score_passes_threshold(make_settings, tmp_path) -> None:
|
||||
settings = make_settings(tmp_path)
|
||||
strategy = SpotStrategy(settings)
|
||||
|
||||
Reference in New Issue
Block a user