diff --git a/.env.example b/.env.example
index 41ad5fb..f1ea280 100644
--- a/.env.example
+++ b/.env.example
@@ -8,11 +8,14 @@ BYBIT_API_SECRET=
STARTING_BALANCE_USDT=100
AUTO_SELECT_SYMBOLS=false
-TOP_SYMBOLS_COUNT=6
-SYMBOLS=BTCUSDT,ETHUSDT,HYPEUSDT,SOLUSDT,LTCUSDT,XRPUSDT
+TOP_SYMBOLS_COUNT=3
+SYMBOLS=BTCUSDT,ETHUSDT,SOLUSDT
-BASE_INTERVAL=1
+STRATEGY_MODE=trend_macd
+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
@@ -22,38 +25,42 @@ WEBSOCKET_ENABLED=true
MIN_SIGNAL_CONFIDENCE=0.64
MAX_SPREAD_PERCENT=0.18
MIN_24H_TURNOVER_USDT=1000000
-PATTERN_ANALYSIS_ENABLED=true
+PATTERN_ANALYSIS_ENABLED=false
PATTERN_SCORE_WEIGHT=0.18
-LEARNING_ENABLED=true
+LEARNING_ENABLED=false
LEARNING_LOOKBACK_TRADES=120
LEARNING_MIN_SAMPLES=3
LEARNING_MAX_ADJUSTMENT=0.12
LEARNING_MAX_POSITION_MULTIPLIER=1.6
MIN_POSITION_USDT=1
-MAX_POSITION_USDT=20
-MAX_SYMBOL_EXPOSURE_USDT=20
-MAX_TOTAL_EXPOSURE_USDT=80
-MAX_OPEN_POSITIONS=80
-MAX_POSITIONS_PER_SYMBOL=20
-GRID_TRADING_ENABLED=true
+MAX_POSITION_USDT=25
+MAX_SYMBOL_EXPOSURE_USDT=25
+MAX_TOTAL_EXPOSURE_USDT=75
+MAX_OPEN_POSITIONS=3
+MAX_POSITIONS_PER_SYMBOL=1
+GRID_TRADING_ENABLED=false
GRID_ENTRY_CONFIDENCE=0.58
GRID_BUY_ZONE=0.45
GRID_MAX_POSITION_USDT=8
-REBOUND_TRADING_ENABLED=true
+REBOUND_TRADING_ENABLED=false
REBOUND_ENTRY_CONFIDENCE=0.58
REBOUND_MIN_PROBABILITY=0.58
REBOUND_MAX_POSITION_USDT=6
-KELLY_SIZING_ENABLED=true
+KELLY_SIZING_ENABLED=false
KELLY_FRACTION=0.25
KELLY_MAX_FRACTION=0.20
-TIME_SERIES_FORECAST_ENABLED=true
+RISK_PER_TRADE_PERCENT=0.01
+ATR_TRAILING_MULTIPLIER=2.2
+TREND_RSI_MIN=45
+TREND_RSI_MAX=65
+TIME_SERIES_FORECAST_ENABLED=false
TIME_SERIES_MIN_CANDLES=120
TIME_SERIES_FORECAST_HORIZON=3
TIME_SERIES_MIN_EDGE_PERCENT=0.04
TIME_SERIES_MAX_ADJUSTMENT=0.08
TIME_SERIES_LSTM_ENABLED=true
TIME_SERIES_LSTM_MODEL_PATH=runtime/lstm_forecaster.json
-STOP_LOSS_PERCENT=0.02
+STOP_LOSS_PERCENT=0.04
TAKE_PROFIT_PERCENT=0.035
TRAILING_STOP_PERCENT=0.015
MIN_HOLD_SECONDS=180
diff --git a/README.md b/README.md
index e3228a8..809b980 100644
--- a/README.md
+++ b/README.md
@@ -5,18 +5,16 @@ Spot-бот для демо-торговли криптовалютой на р
## Что реализовано
- Реальные market data Bybit Spot: REST bootstrap и WebSocket-обновления.
-- Фиксированный набор USDT spot-пар: `BTCUSDT`, `ETHUSDT`, `HYPEUSDT`, `SOLUSDT`, `LTCUSDT`, `XRPUSDT`.
+- Фиксированный набор USDT spot-пар для основной стратегии: `BTCUSDT`, `ETHUSDT`, `SOLUSDT`.
- Paper trading с учетом cash, комиссий, проскальзывания, stop-loss, take-profit и trailing stop.
- Spot-only логика: покупка базовой монеты за USDT и продажа обратно, без short и без плеча.
- Live spot-ордеры явно отправляются без плеча: `category=spot`, `isLeverage=0`.
-- Анализ шаблонов рынка: трендовый откат, пробой вверх/вниз, ускоренное падение, боковик, перепроданность с разворотом и объемный всплеск.
-- Обучение на закрытых сделках: статистика PnL и win rate блокирует плохие пары/паттерны, а положительное матожидание масштабирует Kelly-размер входа.
-- LLM Advisor выключен по умолчанию; стратегия, обучение, grid и rebound работают без запросов к Ollama.
-- Динамический размер позиции: стратегия считает вход через fractional Kelly по вероятности прогноза, stop/take и издержкам, затем ограничивает сумму через `MIN_POSITION_USDT`..`MAX_POSITION_USDT` и лимиты экспозиции.
-- Автоматический grid-режим: бот включает grid-входы на боковике, покупает только в нижней части диапазона и выключает grid при падающих/опасных режимах.
-- Вероятностный rebound-вход: после снижения бот отдельно оценивает стабилизацию, отскок от локального low, RSI, объем и рыночные ограничения; такой вход ограничен меньшим размером позиции.
-- Прогнозирование временных рядов: только экспортированная PyTorch `LSTM/GRU`-модель для ожидаемой доходности и оценки неопределенности по validation MAE. Встроенные не-torch fallback-прогнозы удалены; если валидного torch-артефакта нет, прогноз для пары недоступен.
-- Защитные блокировки входа: явно отрицательные LONG-шаблоны и setups с сильной отрицательной статистикой обучения запрещают новые покупки.
+- Основная стратегия `trend_macd`: вход на `1h`, дневной фильтр тренда на `1d`, long только если цена выше дневной EMA200 и дневная EMA50 выше EMA200.
+- Вход `trend_macd`: MACD на `1h` пересекает signal вверх, цена выше EMA50, RSI в диапазоне `45..65`, спред и ликвидность проходят runtime-фильтры.
+- Выход `trend_macd`: MACD пересекает signal вниз, `1h` свеча закрылась ниже EMA50, сработал стоп `4%` или ATR trailing stop `2.2 ATR`.
+- Риск `trend_macd`: размер позиции считается как `equity * RISK_PER_TRADE_PERCENT / STOP_LOSS_PERCENT`, по умолчанию риск не выше `1%` депозита на сделку.
+- DCA/мартингейл отключены: в режиме `trend_macd` брокер не разрешает вторую позицию по той же паре.
+- Grid, rebound, adaptive learning, Kelly sizing и time-series forecast выключены по умолчанию и не участвуют в принятии решений `trend_macd`.
- Быстрый режим торговли: отдельный короткий интервал цикла, короткий cooldown после выхода и лимит новых входов в минуту; выходы по риску этим лимитом не блокируются.
- Веб-dashboard на русском: equity, cash, PnL, позиции, сделки, сигналы, события, свечные графики, переключатель быстрой торговли и индикаторы работы обучения.
- SQLite runtime-хранилище в `runtime/tradebot.sqlite3`.
@@ -74,7 +72,7 @@ Dashboard:
--epochs 60
```
-Файл из `TIME_SERIES_LSTM_MODEL_PATH` читается ботом автоматически. Экспортированные модели появляются в dashboard как `PyTorch LSTM` или `PyTorch GRU`; старый легкий reservoir LSTM-кандидат и все встроенные не-torch прогнозы удалены. Если валидной PyTorch модели для пары нет, бот не подставляет fallback-прогноз.
+Файл из `TIME_SERIES_LSTM_MODEL_PATH` читается ботом автоматически, если `TIME_SERIES_FORECAST_ENABLED=true`. В основной стратегии `trend_macd` прогноз выключен по умолчанию и не влияет на входы/выходы. Экспортированные модели появляются в dashboard как `PyTorch LSTM` или `PyTorch GRU`; старый легкий reservoir LSTM-кандидат и все встроенные не-torch прогнозы удалены.
Автопереобучение на Windows запускает PyTorch trainer, пишет лог в `runtime/torch_retrain.log` и защищается от параллельных запусков:
@@ -83,7 +81,7 @@ powershell -ExecutionPolicy Bypass -File tools\run_torch_retrain.ps1
powershell -ExecutionPolicy Bypass -File tools\install_windows_torch_retrainer.ps1
```
-По умолчанию Windows-расписание переобучает PyTorch `LSTM/GRU` каждые 6 часов с `--limit 1000` на фиксированных парах `BTCUSDT,ETHUSDT,HYPEUSDT,SOLUSDT,LTCUSDT,XRPUSDT`. Параметры можно переопределить через env: `TORCH_RETRAIN_SYMBOLS`, `TORCH_RETRAIN_LIMIT`, `TORCH_RETRAIN_LOOKBACKS`, `TORCH_RETRAIN_ARCHITECTURES`, `TORCH_RETRAIN_HIDDEN_SIZES`, `TORCH_RETRAIN_LAYERS`, `TORCH_RETRAIN_DROPOUTS`, `TORCH_RETRAIN_EPOCHS`, `TORCH_RETRAIN_PATIENCE`, `TORCH_RETRAIN_INTERVAL`, `TORCH_RETRAIN_ENV`.
+По умолчанию Windows-расписание переобучает PyTorch `LSTM/GRU` каждые 6 часов с `--limit 1000` на парах `BTCUSDT,ETHUSDT,SOLUSDT`. Параметры можно переопределить через env: `TORCH_RETRAIN_SYMBOLS`, `TORCH_RETRAIN_LIMIT`, `TORCH_RETRAIN_LOOKBACKS`, `TORCH_RETRAIN_ARCHITECTURES`, `TORCH_RETRAIN_HIDDEN_SIZES`, `TORCH_RETRAIN_LAYERS`, `TORCH_RETRAIN_DROPOUTS`, `TORCH_RETRAIN_EPOCHS`, `TORCH_RETRAIN_PATIENCE`, `TORCH_RETRAIN_INTERVAL`, `TORCH_RETRAIN_ENV`.
## Docker
@@ -103,9 +101,12 @@ Dashboard: `http://:8787/`
TRADING_MODE=paper
STARTING_BALANCE_USDT=100
AUTO_SELECT_SYMBOLS=false
-TOP_SYMBOLS_COUNT=6
-SYMBOLS=BTCUSDT,ETHUSDT,HYPEUSDT,SOLUSDT,LTCUSDT,XRPUSDT
-BASE_INTERVAL=1
+TOP_SYMBOLS_COUNT=3
+SYMBOLS=BTCUSDT,ETHUSDT,SOLUSDT
+STRATEGY_MODE=trend_macd
+BASE_INTERVAL=60
+TREND_INTERVAL=D
+TREND_KLINE_LIMIT=260
LOOP_INTERVAL_SECONDS=5
FAST_TRADING_ENABLED=false
FAST_LOOP_INTERVAL_SECONDS=1
@@ -113,38 +114,42 @@ FAST_ENTRY_COOLDOWN_SECONDS=20
MAX_ENTRIES_PER_MINUTE=12
WEBSOCKET_ENABLED=true
MIN_SIGNAL_CONFIDENCE=0.64
-PATTERN_ANALYSIS_ENABLED=true
+PATTERN_ANALYSIS_ENABLED=false
PATTERN_SCORE_WEIGHT=0.18
-LEARNING_ENABLED=true
+LEARNING_ENABLED=false
LEARNING_LOOKBACK_TRADES=120
LEARNING_MIN_SAMPLES=3
LEARNING_MAX_ADJUSTMENT=0.12
LEARNING_MAX_POSITION_MULTIPLIER=1.6
MIN_POSITION_USDT=1
-MAX_POSITION_USDT=20
-MAX_SYMBOL_EXPOSURE_USDT=20
-MAX_TOTAL_EXPOSURE_USDT=80
-MAX_OPEN_POSITIONS=80
-MAX_POSITIONS_PER_SYMBOL=20
-GRID_TRADING_ENABLED=true
+MAX_POSITION_USDT=25
+MAX_SYMBOL_EXPOSURE_USDT=25
+MAX_TOTAL_EXPOSURE_USDT=75
+MAX_OPEN_POSITIONS=3
+MAX_POSITIONS_PER_SYMBOL=1
+GRID_TRADING_ENABLED=false
GRID_ENTRY_CONFIDENCE=0.58
GRID_BUY_ZONE=0.45
GRID_MAX_POSITION_USDT=8
-REBOUND_TRADING_ENABLED=true
+REBOUND_TRADING_ENABLED=false
REBOUND_ENTRY_CONFIDENCE=0.58
REBOUND_MIN_PROBABILITY=0.58
REBOUND_MAX_POSITION_USDT=6
-KELLY_SIZING_ENABLED=true
+KELLY_SIZING_ENABLED=false
KELLY_FRACTION=0.25
KELLY_MAX_FRACTION=0.20
-TIME_SERIES_FORECAST_ENABLED=true
+RISK_PER_TRADE_PERCENT=0.01
+ATR_TRAILING_MULTIPLIER=2.2
+TREND_RSI_MIN=45
+TREND_RSI_MAX=65
+TIME_SERIES_FORECAST_ENABLED=false
TIME_SERIES_MIN_CANDLES=120
TIME_SERIES_FORECAST_HORIZON=3
TIME_SERIES_MIN_EDGE_PERCENT=0.04
TIME_SERIES_MAX_ADJUSTMENT=0.08
TIME_SERIES_LSTM_ENABLED=true
TIME_SERIES_LSTM_MODEL_PATH=runtime/lstm_forecaster.json
-STOP_LOSS_PERCENT=0.02
+STOP_LOSS_PERCENT=0.04
TAKE_PROFIT_PERCENT=0.035
TRAILING_STOP_PERCENT=0.015
MIN_HOLD_SECONDS=180
diff --git a/crypto_spot_bot/bot.py b/crypto_spot_bot/bot.py
index b1bee00..574acf5 100644
--- a/crypto_spot_bot/bot.py
+++ b/crypto_spot_bot/bot.py
@@ -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] = {}
diff --git a/crypto_spot_bot/bybit.py b/crypto_spot_bot/bybit.py
index b3143de..019e823 100644
--- a/crypto_spot_bot/bybit.py
+++ b/crypto_spot_bot/bybit.py
@@ -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})
diff --git a/crypto_spot_bot/config.py b/crypto_spot_bot/config.py
index 91fe084..c32b333 100644
--- a/crypto_spot_bot/config.py
+++ b/crypto_spot_bot/config.py
@@ -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),
diff --git a/crypto_spot_bot/dashboard.py b/crypto_spot_bot/dashboard.py
index b79f8f6..a9c5009 100644
--- a/crypto_spot_bot/dashboard.py
+++ b/crypto_spot_bot/dashboard.py
@@ -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]) => `
diff --git a/crypto_spot_bot/execution.py b/crypto_spot_bot/execution.py
index 223f0af..8c4fb48 100644
--- a/crypto_spot_bot/execution.py
+++ b/crypto_spot_bot/execution.py
@@ -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)),
diff --git a/crypto_spot_bot/indicators.py b/crypto_spot_bot/indicators.py
index 083592a..36ff69c 100644
--- a/crypto_spot_bot/indicators.py
+++ b/crypto_spot_bot/indicators.py
@@ -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
diff --git a/crypto_spot_bot/market_data.py b/crypto_spot_bot/market_data.py
index 1d1355d..5071caa 100644
--- a/crypto_spot_bot/market_data.py
+++ b/crypto_spot_bot/market_data.py
@@ -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,
diff --git a/crypto_spot_bot/models.py b/crypto_spot_bot/models.py
index f245334..bfcc1a1 100644
--- a/crypto_spot_bot/models.py
+++ b/crypto_spot_bot/models.py
@@ -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]:
diff --git a/crypto_spot_bot/strategy.py b/crypto_spot_bot/strategy.py
index 6f13bb1..0d545c3 100644
--- a/crypto_spot_bot/strategy.py
+++ b/crypto_spot_bot/strategy.py
@@ -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")
diff --git a/tests/conftest.py b/tests/conftest.py
index 85c6221..4100294 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -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,
diff --git a/tests/test_bybit.py b/tests/test_bybit.py
index 00fbe19..7535c99 100644
--- a/tests/test_bybit.py
+++ b/tests/test_bybit.py
@@ -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
diff --git a/tests/test_config.py b/tests/test_config.py
index e0d7948..4dfed02 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -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
diff --git a/tests/test_execution.py b/tests/test_execution.py
index eab6753..c52c00c 100644
--- a/tests/test_execution.py
+++ b/tests/test_execution.py
@@ -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
diff --git a/tests/test_indicators.py b/tests/test_indicators.py
index 8f1e784..7d58996 100644
--- a/tests/test_indicators.py
+++ b/tests/test_indicators.py
@@ -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
diff --git a/tests/test_strategy.py b/tests/test_strategy.py
index 8564428..eb4c84e 100644
--- a/tests/test_strategy.py
+++ b/tests/test_strategy.py
@@ -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)
diff --git a/tools/install_windows_torch_retrainer.ps1 b/tools/install_windows_torch_retrainer.ps1
index 5a0ce37..ed3b4c0 100644
--- a/tools/install_windows_torch_retrainer.ps1
+++ b/tools/install_windows_torch_retrainer.ps1
@@ -2,7 +2,7 @@
param(
[string]$TaskName = "TradeBot PyTorch Forecaster Retrainer",
[int]$EveryHours = 6,
- [string]$Symbols = "BTCUSDT,ETHUSDT,HYPEUSDT,SOLUSDT,LTCUSDT,XRPUSDT",
+ [string]$Symbols = "BTCUSDT,ETHUSDT,SOLUSDT",
[int]$Limit = 1000,
[int]$FirstRunMinutes = 0
)