Initial TradeBot implementation

This commit is contained in:
Курнат Андрей
2026-06-20 19:22:59 +03:00
commit de9de755f5
37 changed files with 6840 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
from pathlib import Path
import pytest
from crypto_spot_bot.config import Settings
@pytest.fixture
def make_settings():
def factory(tmp_path: Path, **overrides) -> Settings:
values = dict(
trading_mode="paper",
host="127.0.0.1",
port=8787,
bybit_testnet=False,
bybit_api_key="",
bybit_api_secret="",
starting_balance_usdt=100.0,
auto_select_symbols=True,
top_symbols_count=6,
symbols=(),
base_interval="1",
kline_limit=240,
loop_interval_seconds=5,
fast_trading_enabled=False,
fast_loop_interval_seconds=1.0,
fast_entry_cooldown_seconds=20,
max_entries_per_minute=12,
websocket_enabled=False,
min_signal_confidence=0.64,
max_spread_percent=0.18,
min_24h_turnover_usdt=1_000_000.0,
pattern_analysis_enabled=True,
pattern_score_weight=0.18,
learning_enabled=True,
learning_lookback_trades=120,
learning_min_samples=3,
learning_max_adjustment=0.12,
llm_advisor_enabled=False,
ollama_base_url="http://192.168.0.210:11434",
ollama_model="gemma4:e4b",
llm_advisor_min_interval_seconds=180,
llm_advisor_timeout_seconds=45,
llm_advisor_max_adjustment=0.06,
min_position_usdt=1.0,
max_position_usdt=20.0,
max_symbol_exposure_usdt=20.0,
max_total_exposure_usdt=80.0,
max_open_positions=6,
max_positions_per_symbol=1,
grid_trading_enabled=True,
grid_entry_confidence=0.58,
grid_buy_zone=0.45,
grid_max_position_usdt=8.0,
rebound_trading_enabled=True,
rebound_entry_confidence=0.58,
rebound_min_probability=0.58,
rebound_max_position_usdt=6.0,
time_series_forecast_enabled=True,
time_series_min_candles=120,
time_series_validation_window=30,
time_series_forecast_horizon=3,
time_series_ewma_lambda=0.94,
time_series_min_edge_percent=0.04,
time_series_max_adjustment=0.08,
time_series_lstm_enabled=True,
time_series_lstm_lookback=32,
time_series_lstm_units=6,
time_series_lstm_ridge=0.0001,
time_series_lstm_model_path=tmp_path / "lstm_forecaster.json",
stop_loss_percent=0.02,
take_profit_percent=0.035,
trailing_stop_percent=0.015,
min_hold_seconds=180,
entry_cooldown_seconds=180,
max_daily_drawdown_usdt=6.0,
min_cash_reserve_usdt=5.0,
taker_fee_rate=0.001,
slippage_rate=0.0003,
enable_live_trading=False,
live_trading_confirm="",
live_order_max_usdt=10.0,
database_path=tmp_path / "tradebot.sqlite3",
log_path=tmp_path / "tradebot.log",
env_file_path=tmp_path / ".env",
)
values.update(overrides)
return Settings(**values)
return factory
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
from crypto_spot_bot.bybit import BybitClient, _looks_like_leveraged_token, _looks_like_stablecoin
def test_leveraged_token_filter() -> None:
assert _looks_like_leveraged_token("BTC3L")
assert _looks_like_leveraged_token("ETHDOWN")
assert not _looks_like_leveraged_token("BTC")
def test_stablecoin_filter() -> None:
assert _looks_like_stablecoin("USDC")
assert _looks_like_stablecoin("FDUSD")
assert not _looks_like_stablecoin("BTC")
def test_spot_instrument_uses_min_order_amt_and_base_precision(make_settings, tmp_path) -> None:
client = BybitClient(make_settings(tmp_path))
client.public_get = lambda *_args, **_kwargs: {
"list": [
{
"symbol": "BTCUSDT",
"baseCoin": "BTC",
"quoteCoin": "USDT",
"status": "Trading",
"priceFilter": {"tickSize": "0.01"},
"lotSizeFilter": {
"basePrecision": "0.000001",
"minOrderQty": "0.000001",
"minOrderAmt": "5",
},
}
]
}
instrument = client.instruments()["BTCUSDT"]
assert instrument.qty_step == 0.000001
assert instrument.min_notional_value == 5.0
def test_live_spot_order_explicitly_disables_leverage(make_settings, tmp_path) -> None:
client = BybitClient(make_settings(tmp_path))
captured = {}
def fake_private_post(path, payload):
captured["path"] = path
captured["payload"] = payload
return {"orderId": "test"}
client.private_post = fake_private_post
client.place_spot_market_order("BTCUSDT", "Buy", 10, "quoteCoin", "order-1")
assert captured["path"] == "/v5/order/create"
assert captured["payload"]["category"] == "spot"
assert captured["payload"]["isLeverage"] == 0
assert captured["payload"]["orderFilter"] == "Order"
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
import pytest
from crypto_spot_bot.config import load_settings
def test_live_mode_requires_explicit_unlock(tmp_path, monkeypatch) -> None:
for key in (
"TRADING_MODE",
"ENABLE_LIVE_TRADING",
"LIVE_TRADING_CONFIRM",
"BYBIT_API_KEY",
"BYBIT_API_SECRET",
):
monkeypatch.delenv(key, raising=False)
env_file = tmp_path / ".env"
env_file.write_text("TRADING_MODE=live\n", encoding="utf-8")
with pytest.raises(ValueError):
load_settings(env_file)
def test_fast_trading_env_sets_effective_intervals(tmp_path, monkeypatch) -> None:
for key in (
"TRADING_MODE",
"FAST_TRADING_ENABLED",
"FAST_LOOP_INTERVAL_SECONDS",
"FAST_ENTRY_COOLDOWN_SECONDS",
"MAX_ENTRIES_PER_MINUTE",
):
monkeypatch.delenv(key, raising=False)
env_file = tmp_path / ".env"
env_file.write_text(
"\n".join(
[
"TRADING_MODE=paper",
"FAST_TRADING_ENABLED=true",
"FAST_LOOP_INTERVAL_SECONDS=0.75",
"FAST_ENTRY_COOLDOWN_SECONDS=12",
"MAX_ENTRIES_PER_MINUTE=4",
]
),
encoding="utf-8",
)
settings = load_settings(env_file)
assert settings.fast_trading_enabled is True
assert settings.effective_loop_interval_seconds == 0.75
assert settings.effective_entry_cooldown_seconds == 12
assert settings.max_entries_per_minute == 4
def test_llm_advisor_is_disabled_by_default(tmp_path, monkeypatch) -> None:
monkeypatch.delenv("LLM_ADVISOR_ENABLED", raising=False)
monkeypatch.setenv("TRADING_MODE", "paper")
env_file = tmp_path / ".env"
env_file.write_text("TRADING_MODE=paper\n", encoding="utf-8")
settings = load_settings(env_file)
assert settings.llm_advisor_enabled is False
+17
View File
@@ -0,0 +1,17 @@
from __future__ import annotations
from crypto_spot_bot.dashboard import _apply_fast_trading
from crypto_spot_bot.storage import Storage
def test_apply_fast_trading_updates_runtime_and_env(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, fast_trading_enabled=False)
settings.env_file_path.write_text("FAST_TRADING_ENABLED=false\n", encoding="utf-8")
storage = Storage(settings.database_path)
env_persisted = _apply_fast_trading(settings, storage, True)
assert env_persisted is True
assert settings.fast_trading_enabled is True
assert storage.get_runtime("fast_trading_enabled") is True
assert "FAST_TRADING_ENABLED=true" in settings.env_file_path.read_text(encoding="utf-8")
+131
View File
@@ -0,0 +1,131 @@
from __future__ import annotations
from crypto_spot_bot.bybit import Instrument
from crypto_spot_bot.execution import PaperBroker
from crypto_spot_bot.models import Signal, Ticker
from crypto_spot_bot.storage import Storage
def test_paper_broker_buy_and_sell_records_trade(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path)
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, 5)
signal = Signal("BTCUSDT", "BUY", 0.8, "test")
position = broker.buy(signal, ticker, instrument, {"BTCUSDT": 100})
assert position is not None
assert broker.cash < settings.starting_balance_usdt
assert len(broker.open_positions()) == 1
trade = broker.sell(position, ticker, "test exit")
assert trade.side == "SELL"
assert len(broker.open_positions()) == 0
assert storage.recent_trades(limit=10)
def test_paper_broker_limits_fast_entries_per_minute(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
max_entries_per_minute=1,
max_open_positions=3,
max_positions_per_symbol=3,
max_total_exposure_usdt=90,
)
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, 5)
first = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "first"), ticker, instrument, {"BTCUSDT": 100})
second = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "second"), ticker, instrument, {"BTCUSDT": 100})
assert first is not None
assert second is None
assert len(broker.open_positions()) == 1
assert "лимит новых входов" in storage.recent_events(limit=1)[0]["message"]
def test_paper_broker_uses_signal_notional_and_pair_exposure(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
min_position_usdt=1,
max_position_usdt=20,
max_symbol_exposure_usdt=6,
max_total_exposure_usdt=50,
max_open_positions=20,
max_positions_per_symbol=1,
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},
)
third = broker.buy(
Signal("BTCUSDT", "BUY", 0.8, "third", {"position_notional_usdt": 2}),
ticker,
instrument,
{"BTCUSDT": 100},
)
fourth = broker.buy(
Signal("BTCUSDT", "BUY", 0.8, "fourth", {"position_notional_usdt": 2}),
ticker,
instrument,
{"BTCUSDT": 100},
)
assert first is not None
assert second is not None
assert third is not None
assert fourth is None
assert len(broker.open_positions()) == 3
assert 5.5 <= broker.symbol_exposure("BTCUSDT") <= 6.0
def test_paper_broker_respects_adaptive_exposure_target(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
min_position_usdt=1,
max_position_usdt=20,
max_symbol_exposure_usdt=20,
max_total_exposure_usdt=80,
max_open_positions=20,
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)
capped_signal = Signal(
"BTCUSDT",
"BUY",
0.8,
"adaptive cap",
{
"position_notional_usdt": 10,
"adaptive_rules": {
"target_total_exposure_usdt": 0,
"target_symbol_exposure_usdt": 0,
},
},
)
position = broker.buy(capped_signal, ticker, instrument, {"BTCUSDT": 100})
assert position is None
assert broker.open_positions() == []
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
from crypto_spot_bot.indicators import add_indicators
from crypto_spot_bot.models import Candle
def test_add_indicators_populates_long_periods() -> None:
candles = [
Candle(
timestamp=index,
open=100 + index * 0.1,
high=101 + index * 0.1,
low=99 + index * 0.1,
close=100 + index * 0.1,
volume=10 + index,
)
for index in range(240)
]
add_indicators(candles)
latest = candles[-1]
assert latest.ema_20 is not None
assert latest.ema_50 is not None
assert latest.ema_200 is not None
assert latest.rsi_14 is not None
assert latest.atr_14 is not None
assert latest.volume_ma_20 is not None
+99
View File
@@ -0,0 +1,99 @@
from __future__ import annotations
from crypto_spot_bot.learning import TradeLearner
from crypto_spot_bot.models import Trade, utc_now
from crypto_spot_bot.storage import Storage
def test_trade_learner_penalizes_losing_symbol_pattern(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, learning_min_samples=2)
storage = Storage(settings.database_path)
for value in (-0.4, -0.2):
storage.insert_trade(
Trade(
id=None,
symbol="BTCUSDT",
side="SELL",
qty=1,
entry_price=100,
exit_price=99,
net_pnl=value,
reason="test",
entry_pattern="пробой вниз",
entry_confidence=0.7,
opened_at=utc_now(),
closed_at=utc_now(),
)
)
learner = TradeLearner(settings, storage)
learner.refresh()
adjustment = learner.adjustment_for("BTCUSDT", "пробой вниз")
assert adjustment.sample_size >= 4
assert adjustment.confidence_adjustment < 0
assert "убыточными" in adjustment.reason
def test_trade_learner_builds_adaptive_rules_for_losing_ema_exit(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, learning_min_samples=3)
storage = Storage(settings.database_path)
for value in (-0.05, -0.04, -0.03):
storage.insert_trade(
Trade(
id=None,
symbol="BTCUSDT",
side="SELL",
qty=1,
entry_price=100,
exit_price=99.9,
net_pnl=value,
reason="краткосрочный тренд ослаб ниже EMA50",
entry_pattern="нейтрально",
entry_confidence=0.7,
opened_at=utc_now(),
closed_at=utc_now(),
)
)
learner = TradeLearner(settings, storage)
state = learner.refresh()
rules = learner.rules_for("BTCUSDT", "нейтрально")
assert state.adaptive_rules["risk_mode"] == "defensive"
assert rules["ema_exit_mode"] == "profit_only"
assert rules["effective_entry_threshold_adjustment"] > 0
assert rules["min_hold_seconds"] > settings.min_hold_seconds
def test_trade_learner_enters_capital_protection_and_validates_rules(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, learning_min_samples=3, starting_balance_usdt=100, max_total_exposure_usdt=80)
storage = Storage(settings.database_path)
for value in (-0.12, -0.10, -0.08):
storage.insert_trade(
Trade(
id=None,
symbol="ETHUSDT",
side="SELL",
qty=1,
entry_price=100,
exit_price=99,
net_pnl=value,
reason="краткосрочный тренд ослаб ниже EMA50",
entry_pattern="нейтрально",
entry_confidence=0.7,
opened_at=utc_now(),
closed_at=utc_now(),
)
)
learner = TradeLearner(settings, storage)
state = learner.refresh()
rules = state.adaptive_rules
assert rules["trade_permission"] == "capital_protection"
assert rules["reduce_exposure"] is True
assert rules["bad_market_entry_block"] is True
assert rules["target_total_exposure_usdt"] == 35.0
assert rules["validation"]["status"] == "accepted"
assert rules["validation"]["avoided_loss_usdt"] > 0
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
from crypto_spot_bot.llm_advisor import LlmAdvisor, _extract_json
from crypto_spot_bot.storage import Storage
def test_extract_json_from_fenced_response() -> None:
data = _extract_json(
"""
```json
{"market_regime":"range","risk_level":"low","confidence_adjustment":0.02}
```
"""
)
assert data["market_regime"] == "range"
assert data["confidence_adjustment"] == 0.02
def test_llm_advisor_parse_clamps_adjustment(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, llm_advisor_max_adjustment=0.05)
advisor = LlmAdvisor(settings, Storage(settings.database_path))
advice = advisor._parse(
"BTCUSDT",
'{"market_regime":"breakout","risk_level":"high","confidence_adjustment":0.5,'
'"block_entry":true,"grid_suitable":false,"reason_ru":"тест"}',
)
assert advice.model == "gemma4:e4b"
assert advice.market_regime == "breakout"
assert advice.risk_level == "high"
assert advice.confidence_adjustment == 0.05
assert advice.block_entry is True
def test_storage_records_llm_advice(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path)
storage = Storage(settings.database_path)
storage.insert_llm_advice(
symbol="BTCUSDT",
model="gemma4:e4b",
prompt_json={"symbol": "BTCUSDT"},
response_text='{"confidence_adjustment":0}',
advice_json={"confidence_adjustment": 0.0, "reason_ru": "нейтрально"},
)
items = storage.recent_llm_advice(limit=1)
assert items[0]["model"] == "gemma4:e4b"
assert items[0]["prompt"]["symbol"] == "BTCUSDT"
assert items[0]["advice"]["reason_ru"] == "нейтрально"
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
from crypto_spot_bot.models import Candle
from crypto_spot_bot.patterns import PatternAnalyzer
def _candles_for_pullback() -> list[Candle]:
candles = []
for index in range(40):
close = 100 + index * 0.2
candles.append(
Candle(
timestamp=index,
open=close - 0.1,
high=close + 0.4,
low=close - 0.4,
close=close,
volume=100,
ema_20=close - 0.2,
ema_50=close - 1.0,
ema_200=close - 2.0,
rsi_14=48,
atr_14=1.0,
volume_ma_20=100,
)
)
latest = candles[-1]
latest.close = latest.ema_20 * 1.005
latest.open = latest.close + 0.1
return candles
def _candles_for_stabilized_drop() -> list[Candle]:
candles = []
closes = [98.0, 97.3, 96.6, 95.9, 95.2, 94.8, 94.55, 94.42, 94.38, 94.40, 94.45, 94.56]
for index in range(40):
close = 101 - index * 0.08
if index >= 28:
close = closes[index - 28]
rsi = 42
if index >= 28:
rsi = 30 + max(0, index - 34)
candles.append(
Candle(
timestamp=index,
open=close - 0.12,
high=close + 0.25,
low=close - 0.26,
close=close,
volume=120,
ema_20=close + 0.35,
ema_50=close + 0.75,
ema_200=close + 1.4,
rsi_14=rsi,
atr_14=0.45,
volume_ma_20=100,
)
)
candles[-1].open = candles[-1].close - 0.20
candles[-1].low = candles[-1].close - 0.26
return candles
def test_pattern_analyzer_detects_trend_pullback() -> None:
result = PatternAnalyzer().analyze(_candles_for_pullback())
assert result.label == "трендовый откат"
assert result.score > 0.7
assert "откат к средней" in result.tags
def test_pattern_analyzer_detects_stabilized_drop() -> None:
result = PatternAnalyzer().analyze(_candles_for_stabilized_drop())
assert result.label == "стабилизация после падения"
assert result.score >= 0.58
assert "стабилизация после падения" in result.tags
+442
View File
@@ -0,0 +1,442 @@
from __future__ import annotations
from datetime import timedelta
from crypto_spot_bot.models import Candle, Position, Ticker, utc_now
from crypto_spot_bot.patterns import PatternAnalyzer
from crypto_spot_bot.strategy import SpotStrategy
def _ready_candles() -> list[Candle]:
candles = []
for index in range(205):
candle = Candle(
timestamp=index,
open=100,
high=103,
low=99,
close=101,
volume=100,
ema_20=100,
ema_50=99,
ema_200=98,
rsi_14=45,
atr_14=1.2,
volume_ma_20=90,
)
candles.append(candle)
return candles
def _rebound_candles() -> list[Candle]:
candles = []
tail = [98.0, 97.3, 96.6, 95.9, 95.2, 94.8, 94.55, 94.42, 94.38, 94.40, 94.45, 94.56]
for index in range(205):
close = 104 - index * 0.025
if index >= 193:
close = tail[index - 193]
rsi = 45
if index >= 193:
rsi = min(42, 29 + max(0, index - 198))
candles.append(
Candle(
timestamp=index,
open=close - 0.12,
high=close + 0.25,
low=close - 0.26,
close=close,
volume=120,
ema_20=close + 0.35,
ema_50=close + 0.75,
ema_200=close + 1.4,
rsi_14=rsi,
atr_14=0.45,
volume_ma_20=100,
)
)
candles[-1].open = candles[-1].close - 0.20
candles[-1].low = candles[-1].close - 0.26
return candles
def test_strategy_emits_buy_when_score_passes_threshold(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path)
strategy = SpotStrategy(settings)
ticker = Ticker(
symbol="BTCUSDT",
last_price=101,
bid=100.99,
ask=101.01,
turnover_24h=10_000_000,
volume_24h=1000,
change_24h=1.0,
)
signal = strategy.entry_signal("BTCUSDT", _ready_candles(), ticker, open_positions_for_symbol=0)
assert signal.action == "BUY"
assert signal.confidence >= settings.min_signal_confidence
def test_strategy_blocks_negative_long_pattern(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path)
strategy = SpotStrategy(settings)
ticker = Ticker(
symbol="BTCUSDT",
last_price=101,
bid=100.99,
ask=101.01,
turnover_24h=10_000_000,
volume_24h=1000,
change_24h=1.0,
)
signal = strategy.entry_signal(
"BTCUSDT",
_ready_candles(),
ticker,
open_positions_for_symbol=0,
pattern={"label": "нисходящий тренд", "score": 0.28},
)
assert signal.action == "HOLD"
assert signal.diagnostics["entry_blocked_by_pattern"] is True
def test_strategy_blocks_strong_negative_learning(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path)
strategy = SpotStrategy(settings)
ticker = Ticker(
symbol="BTCUSDT",
last_price=101,
bid=100.99,
ask=101.01,
turnover_24h=10_000_000,
volume_24h=1000,
change_24h=1.0,
)
signal = strategy.entry_signal(
"BTCUSDT",
_ready_candles(),
ticker,
open_positions_for_symbol=0,
pattern={"label": "нейтрально", "score": 0.5},
learning={
"sample_size": 10,
"net_pnl": -1.0,
"win_rate": 0.1,
"confidence_adjustment": -0.12,
"reason": "test",
},
)
assert signal.action == "HOLD"
assert signal.diagnostics["entry_blocked_by_learning"] is True
def test_strategy_blocks_entry_when_llm_advisor_blocks(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, llm_advisor_enabled=True)
strategy = SpotStrategy(settings)
ticker = Ticker(
symbol="BTCUSDT",
last_price=101,
bid=100.99,
ask=101.01,
turnover_24h=10_000_000,
volume_24h=1000,
change_24h=1.0,
)
signal = strategy.entry_signal(
"BTCUSDT",
_ready_candles(),
ticker,
open_positions_for_symbol=0,
llm={
"confidence_adjustment": -0.03,
"block_entry": True,
"reason_ru": "риск падения",
},
)
assert signal.action == "HOLD"
assert signal.diagnostics["entry_blocked_by_llm"] is True
assert signal.diagnostics["llm_adjustment"] == -0.03
def test_strategy_activates_grid_and_sets_position_size(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path)
strategy = SpotStrategy(settings)
ticker = Ticker(
symbol="BTCUSDT",
last_price=101,
bid=100.99,
ask=101.01,
turnover_24h=10_000_000,
volume_24h=1000,
change_24h=0.1,
)
signal = strategy.entry_signal(
"BTCUSDT",
_ready_candles(),
ticker,
open_positions_for_symbol=2,
pattern={
"label": "боковик",
"score": 0.48,
"tags": ["боковик"],
"metrics": {"high20": 105, "low20": 100, "ema_gap_percent": 0.1, "ret_20_percent": 0.2},
},
llm={"market_regime": "range", "grid_suitable": True, "risk_level": "medium"},
)
assert signal.action == "BUY"
assert signal.diagnostics["trade_mode"] == "GRID"
assert signal.diagnostics["grid"]["active"] is True
assert 1 <= signal.diagnostics["position_notional_usdt"] <= settings.grid_max_position_usdt
def test_strategy_buys_probabilistic_rebound_after_stabilized_drop(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, rebound_entry_confidence=0.58, rebound_min_probability=0.58)
strategy = SpotStrategy(settings)
candles = _rebound_candles()
ticker = Ticker(
symbol="BTCUSDT",
last_price=candles[-1].close,
bid=candles[-1].close * 0.9999,
ask=candles[-1].close * 1.0001,
turnover_24h=10_000_000,
volume_24h=1000,
change_24h=-2.0,
)
pattern = PatternAnalyzer().analyze(candles, ticker).as_dict()
signal = strategy.entry_signal(
"BTCUSDT",
candles,
ticker,
open_positions_for_symbol=0,
pattern={**pattern, "label": "нисходящий тренд", "score": 0.28},
)
assert signal.action == "BUY"
assert signal.diagnostics["trade_mode"] == "REBOUND"
assert signal.diagnostics["rebound"]["active"] is True
assert signal.diagnostics["entry_blocked_by_pattern"] is False
assert signal.diagnostics["position_notional_usdt"] <= settings.rebound_max_position_usdt
def test_strategy_rebound_does_not_override_llm_block(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
llm_advisor_enabled=True,
rebound_entry_confidence=0.58,
rebound_min_probability=0.58,
)
strategy = SpotStrategy(settings)
candles = _rebound_candles()
ticker = Ticker(
symbol="BTCUSDT",
last_price=candles[-1].close,
bid=candles[-1].close * 0.9999,
ask=candles[-1].close * 1.0001,
turnover_24h=10_000_000,
volume_24h=1000,
change_24h=-2.0,
)
signal = strategy.entry_signal(
"BTCUSDT",
candles,
ticker,
open_positions_for_symbol=0,
pattern={"label": "нисходящий тренд", "score": 0.28},
llm={"block_entry": True, "reason_ru": "риск продолжения падения"},
)
assert signal.action == "HOLD"
assert signal.diagnostics["entry_blocked_by_llm"] is True
def test_strategy_trailing_stop_only_exits_after_profit(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path)
strategy = SpotStrategy(settings)
candles = _ready_candles()
from crypto_spot_bot.models import Position
position = Position(
id=1,
symbol="BTCUSDT",
qty=1,
entry_price=100,
notional_usdt=100,
entry_fee_usdt=0.1,
stop_loss=90,
take_profit=120,
highest_price=100.5,
)
ticker = Ticker("BTCUSDT", 99.6, 99.5, 99.7, 1_000_000, 100, 0)
signal = strategy.exit_signal(position, candles, ticker)
assert signal.reason != "сработал trailing stop выше цены входа"
def test_strategy_adaptive_learning_holds_unprofitable_ema_exit(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, min_hold_seconds=60)
strategy = SpotStrategy(settings)
candles = _ready_candles()
candles[-2].close = 100.2
candles[-1].close = 99.9
candles[-1].ema_20 = 98.0
candles[-1].ema_50 = 100.0
position = Position(
id=1,
symbol="BTCUSDT",
qty=1,
entry_price=100,
notional_usdt=100,
entry_fee_usdt=0.1,
stop_loss=90,
take_profit=120,
highest_price=100.5,
opened_at=utc_now() - timedelta(seconds=600),
)
ticker = Ticker("BTCUSDT", 99.9, 99.89, 99.91, 1_000_000, 100, 0)
signal = strategy.exit_signal(
position,
candles,
ticker,
{
"adaptive_rules": {
"ema_exit_mode": "profit_only",
"min_exit_profit_percent": 0.31,
"min_hold_seconds": 60,
}
},
)
assert signal.action == "HOLD"
assert "EMA50" in signal.reason
def test_strategy_blocks_entry_when_learning_exposure_target_exceeded(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path)
strategy = SpotStrategy(settings)
ticker = Ticker("BTCUSDT", 101, 100.99, 101.01, 10_000_000, 1000, 1.0)
signal = strategy.entry_signal(
"BTCUSDT",
_ready_candles(),
ticker,
open_positions_for_symbol=1,
learning={
"adaptive_rules": {
"over_target_exposure": True,
"target_total_exposure_usdt": 35,
"current_total_exposure_usdt": 80,
}
},
)
assert signal.action == "HOLD"
assert signal.diagnostics["entry_blocked_by_adaptive_rules"] is True
assert signal.diagnostics["adaptive_block_reason"] == "экспозиция выше цели обучения"
def test_strategy_learning_reduce_now_sells_after_min_hold(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, min_hold_seconds=60)
strategy = SpotStrategy(settings)
position = Position(
id=7,
symbol="BTCUSDT",
qty=1,
entry_price=100,
notional_usdt=100,
entry_fee_usdt=0.1,
stop_loss=90,
take_profit=120,
highest_price=101,
opened_at=utc_now() - timedelta(seconds=600),
)
ticker = Ticker("BTCUSDT", 99.5, 99.49, 99.51, 1_000_000, 100, 0)
signal = strategy.exit_signal(
position,
_ready_candles(),
ticker,
{"adaptive_rules": {"reduce_exposure": True, "reduce_now": True, "min_hold_seconds": 60}},
)
assert signal.action == "SELL"
assert "экспозицию" in signal.reason
def test_strategy_forecast_sells_to_lock_profit(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, min_hold_seconds=60)
strategy = SpotStrategy(settings)
position = Position(
id=9,
symbol="BTCUSDT",
qty=1,
entry_price=100,
notional_usdt=100,
entry_fee_usdt=0.1,
stop_loss=90,
take_profit=120,
highest_price=101.5,
opened_at=utc_now() - timedelta(seconds=600),
)
ticker = Ticker("BTCUSDT", 101, 100.99, 101.01, 1_000_000, 100, 0)
signal = strategy.exit_signal(
position,
_ready_candles(),
ticker,
forecast={
"usable": True,
"skill": 0.2,
"expected_return_percent": -0.2,
"probability_up": 0.35,
"reason": "тестовый негативный прогноз",
},
)
assert signal.action == "SELL"
assert "прогноз временного ряда" in signal.reason
def test_strategy_forecast_sells_to_limit_loss_before_stop(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, min_hold_seconds=60, stop_loss_percent=0.02)
strategy = SpotStrategy(settings)
position = Position(
id=10,
symbol="BTCUSDT",
qty=1,
entry_price=100,
notional_usdt=100,
entry_fee_usdt=0.1,
stop_loss=98,
take_profit=120,
highest_price=100.4,
opened_at=utc_now() - timedelta(seconds=600),
)
ticker = Ticker("BTCUSDT", 99.2, 99.19, 99.21, 1_000_000, 100, 0)
signal = strategy.exit_signal(
position,
_ready_candles(),
ticker,
forecast={
"usable": True,
"skill": 0.2,
"expected_return_percent": -0.2,
"probability_up": 0.35,
"reason": "тестовый негативный прогноз",
},
)
assert signal.action == "SELL"
assert "ограничиваем убыток" in signal.reason
+124
View File
@@ -0,0 +1,124 @@
from __future__ import annotations
import json
from crypto_spot_bot.models import Candle
from crypto_spot_bot.time_series import TimeSeriesForecaster
def _candles_from_returns(returns: list[float]) -> list[Candle]:
close = 100.0
candles = [
Candle(
timestamp=0,
open=close,
high=close * 1.001,
low=close * 0.999,
close=close,
volume=100,
)
]
for index, ret in enumerate(returns, start=1):
previous = close
close = close * (2.718281828459045 ** ret)
candles.append(
Candle(
timestamp=index,
open=previous,
high=max(previous, close) * 1.001,
low=min(previous, close) * 0.999,
close=close,
volume=100,
)
)
return candles
def test_time_series_forecaster_selects_positive_predictive_model(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
time_series_min_candles=80,
time_series_validation_window=24,
time_series_forecast_horizon=3,
)
returns = []
value = 0.0003
for _ in range(140):
value = 0.00025 + value * 0.55
returns.append(value)
forecast = TimeSeriesForecaster(settings).forecast(_candles_from_returns(returns))
assert forecast.usable is True
assert forecast.model != "naive"
assert forecast.expected_return_percent > 0
assert forecast.probability_up > 0.5
def test_time_series_forecaster_blocks_negative_edge(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
time_series_min_candles=80,
time_series_validation_window=24,
time_series_forecast_horizon=3,
time_series_min_edge_percent=0.03,
)
returns = []
value = -0.0003
for _ in range(140):
value = -0.00025 + value * 0.55
returns.append(value)
forecast = TimeSeriesForecaster(settings).forecast(_candles_from_returns(returns))
assert forecast.usable is True
assert forecast.expected_return_percent < 0
assert forecast.block_entry is True
def test_time_series_forecaster_includes_lstm_candidate(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
time_series_min_candles=80,
time_series_validation_window=20,
time_series_lstm_enabled=True,
time_series_lstm_lookback=12,
time_series_lstm_units=4,
)
returns = []
for index in range(140):
seasonal = 0.00018 if index % 5 in {0, 1, 2} else -0.00011
returns.append(seasonal + 0.00002 * ((index % 7) - 3))
forecast = TimeSeriesForecaster(settings).forecast(_candles_from_returns(returns), symbol="BTCUSDT")
assert forecast.usable is True
assert any(candidate["model"] == "lstm" for candidate in forecast.candidates)
def test_time_series_forecaster_reads_lstm_artifact(make_settings, tmp_path) -> None:
artifact_path = tmp_path / "lstm_forecaster.json"
artifact_path.write_text(
json.dumps(
{
"version": 1,
"symbols": {
"BTCUSDT": {"lookback": 10, "units": 3, "ridge": 0.01},
},
}
),
encoding="utf-8",
)
settings = make_settings(
tmp_path,
time_series_min_candles=80,
time_series_validation_window=20,
time_series_lstm_enabled=True,
time_series_lstm_model_path=artifact_path,
)
returns = [0.00012 if index % 3 else -0.00008 for index in range(140)]
forecast = TimeSeriesForecaster(settings).forecast(_candles_from_returns(returns), symbol="BTCUSDT")
assert forecast.usable is True
assert any(candidate["model"] == "lstm" for candidate in forecast.candidates)