Initial TradeBot implementation
This commit is contained in:
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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() == []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"] == "нейтрально"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user