feat: implement autonomous Bybit demo trading agent

This commit is contained in:
Azimkin
2026-09-19 16:10:11 +02:00
commit 110b9fee0b
26 changed files with 3391 additions and 0 deletions

102
tests/test_agent_tools.py Normal file
View File

@@ -0,0 +1,102 @@
from datetime import time
from decimal import Decimal
from pathlib import Path
from zoneinfo import ZoneInfo
import pytest
from llm_trade_bot.agent import AgentTools
from llm_trade_bot.config import (
AppConfig,
BraveConfig,
BybitConfig,
DeepSeekConfig,
RiskConfig,
ScannerConfig,
ScheduleConfig,
StorageConfig,
TelegramConfig,
)
from llm_trade_bot.database import Database
from llm_trade_bot.models import AccountSnapshot, InstrumentRules
class FakeBybit:
def __init__(self):
self.orders = []
async def account_state(self):
return {"wallet": [{"coin": []}], "positions": [], "open_orders": []}
async def market_context(self, category, symbol):
return {"ticker": {"lastPrice": "100"}}
async def account_snapshot(self):
return AccountSnapshot(Decimal(10000), Decimal(10000), Decimal(0), 0)
async def instrument_rules(self, category, symbol):
return InstrumentRules(Decimal("0.1"), Decimal("0.001"), Decimal("0.001"), Decimal(5), Decimal(100))
async def set_leverage(self, symbol, leverage):
return {"retCode": 0}
async def place_order(self, **parameters):
self.orders.append(parameters)
return {"retCode": 0, "result": {"orderId": f"order-{len(self.orders)}"}}
def make_config(database_path: Path) -> AppConfig:
return AppConfig(
DeepSeekConfig("key"),
BybitConfig("key", "secret"),
TelegramConfig(1, "hash", "token", frozenset({1})),
BraveConfig("key"),
ScheduleConfig(ZoneInfo("Europe/Warsaw"), (time(9), time(21))),
ScannerConfig(),
RiskConfig(),
StorageConfig(database_path),
)
async def test_open_trade_is_risk_sized_and_deduplicated_within_run(tmp_path):
database = Database(tmp_path / "state.db")
await database.open()
bybit = FakeBybit()
tools = AgentTools(make_config(database.path), database, bybit) # type: ignore[arg-type]
run_id = await database.start_run("test")
context = {"run_id": run_id, "candidate_keys": {"linear:BTCUSDT"}}
try:
first = await tools.open_trade(
"linear", "BTCUSDT", "Buy", "Market", 90, 2, "test", take_profit=120, leverage=3, context=context
)
second = await tools.open_trade(
"linear", "BTCUSDT", "Buy", "Market", 90, 2, "test", take_profit=120, leverage=3, context=context
)
assert first["quantity"] == "20"
assert second["deduplicated"] is True
assert len(bybit.orders) == 1
assert bybit.orders[0]["stopLoss"] == "90"
finally:
await database.close()
async def test_pause_blocks_entry_from_running_session(tmp_path):
database = Database(tmp_path / "state.db")
await database.open()
tools = AgentTools(make_config(database.path), database, FakeBybit()) # type: ignore[arg-type]
await database.set_state("paused", "true")
try:
with pytest.raises(ValueError, match="paused"):
await tools.open_trade(
"linear",
"BTCUSDT",
"Buy",
"Market",
90,
2,
"test",
take_profit=120,
context={"run_id": 1, "candidate_keys": {"linear:BTCUSDT"}},
)
finally:
await database.close()

56
tests/test_config.py Normal file
View File

@@ -0,0 +1,56 @@
from pathlib import Path
import pytest
from llm_trade_bot.config import ConfigError, load_config
CONFIG = """
[deepseek]
api_key = "deepseek-key"
[bybit]
api_key = "bybit-key"
api_secret = "bybit-secret"
[telegram]
api_id = 123
api_hash = "api-hash"
bot_token = "bot-token"
admin_user_ids = [42]
[brave]
api_key = "brave-key"
[schedule]
timezone = "Europe/Warsaw"
times = ["09:00", "21:00"]
[scanner]
categories = ["spot", "linear"]
[risk]
max_risk_per_trade_pct = 2
max_total_exposure_pct = 40
max_positions = 5
max_leverage = 3
[storage]
database_path = "state.db"
"""
def test_loads_and_resolves_database_path(tmp_path: Path):
path = tmp_path / "config.toml"
path.write_text(CONFIG, encoding="utf-8")
config = load_config(path)
assert config.deepseek.model == "deepseek-flash"
assert config.telegram.admin_user_ids == frozenset({42})
assert config.storage.database_path == tmp_path / "state.db"
def test_rejects_risk_above_hard_limit(tmp_path: Path):
path = tmp_path / "config.toml"
path.write_text(CONFIG.replace("max_leverage = 3", "max_leverage = 4"), encoding="utf-8")
with pytest.raises(ConfigError, match="max_leverage"):
load_config(path)
def test_rejects_non_official_deepseek_endpoint(tmp_path: Path):
path = tmp_path / "config.toml"
changed = CONFIG.replace("api_key = \"deepseek-key\"", 'api_key = "deepseek-key"\nbase_url = "https://example.com"', 1)
path.write_text(changed, encoding="utf-8")
with pytest.raises(ConfigError, match="base_url"):
load_config(path)

37
tests/test_database.py Normal file
View File

@@ -0,0 +1,37 @@
from pathlib import Path
from llm_trade_bot.database import Database
async def test_notes_and_admin_deduplication(tmp_path: Path):
database = Database(tmp_path / "state.db")
await database.open()
try:
await database.add_note("first")
await database.add_note("second")
assert await database.recent_notes(10, 100) == ["first", "second"]
first_id, first_queued = await database.add_admin_request("Need a new data feed")
second_id, second_queued = await database.add_admin_request("Need a new data feed")
assert first_queued is True
assert second_queued is False
assert second_id == first_id
outbox = await database.pending_outbox()
assert len(outbox) == 1
assert outbox[0].kind == "ask_admin"
finally:
await database.close()
async def test_run_lifecycle(tmp_path: Path):
database = Database(tmp_path / "state.db")
await database.open()
try:
run_id = await database.start_run("test")
await database.add_event("tool_started", {"name": "note"}, run_id)
await database.finish_run(run_id, status="completed", summary="idle")
last = await database.last_run()
assert last is not None
assert last["status"] == "completed"
assert last["summary"] == "idle"
finally:
await database.close()

15
tests/test_market.py Normal file
View File

@@ -0,0 +1,15 @@
from decimal import Decimal
from llm_trade_bot.market import technical_indicators
def test_indicators_are_computed_for_sufficient_history():
closes = [Decimal(index) for index in range(1, 61)]
highs = [value + 1 for value in closes]
lows = [value - 1 for value in closes]
volumes = [Decimal(100) for _ in closes]
result = technical_indicators(closes, highs, lows, volumes)
assert result["ema_20"] is not None
assert result["ema_50"] is not None
assert result["rsi_14"] == 100
assert result["atr_14"] is not None

83
tests/test_risk.py Normal file
View File

@@ -0,0 +1,83 @@
from decimal import Decimal
import pytest
from llm_trade_bot.config import RiskConfig
from llm_trade_bot.models import (
AccountSnapshot,
Category,
InstrumentRules,
OpenTradeRequest,
OrderType,
Side,
)
from llm_trade_bot.risk import RiskEngine, RiskRejected
@pytest.fixture
def engine() -> RiskEngine:
return RiskEngine(RiskConfig())
@pytest.fixture
def account() -> AccountSnapshot:
return AccountSnapshot(Decimal(10000), Decimal(10000), Decimal(0), 0)
@pytest.fixture
def rules() -> InstrumentRules:
return InstrumentRules(Decimal("0.1"), Decimal("0.001"), Decimal("0.001"), Decimal(5), Decimal(100))
def request(**overrides) -> OpenTradeRequest:
values = {
"category": Category.LINEAR,
"symbol": "BTCUSDT",
"side": Side.BUY,
"order_type": OrderType.MARKET,
"reference_price": Decimal(100),
"limit_price": None,
"stop_loss": Decimal(90),
"take_profit": Decimal(120),
"trailing_distance": None,
"risk_pct": Decimal(2),
"leverage": Decimal(3),
"rationale": "test",
}
values.update(overrides)
return OpenTradeRequest(**values)
def test_sizes_by_risk_and_exposure(engine, account, rules):
result = engine.validate_open(request(), account, rules)
assert result.quantity == Decimal(20)
assert result.notional == Decimal(2000)
def test_remaining_exposure_caps_position(engine, rules):
account = AccountSnapshot(Decimal(10000), Decimal(10000), Decimal(3900), 2)
result = engine.validate_open(request(), account, rules)
assert result.quantity == Decimal(1)
assert result.notional == Decimal(100)
@pytest.mark.parametrize(
"overrides, message",
[
({"risk_pct": Decimal("2.1")}, "risk_pct"),
({"leverage": Decimal(4)}, "Leverage"),
({"stop_loss": Decimal(101)}, "stop-loss"),
({"take_profit": None, "trailing_distance": None}, "take-profit or trailing"),
({"category": Category.SPOT, "order_type": OrderType.MARKET}, "Spot entries"),
({"category": Category.SPOT, "order_type": OrderType.LIMIT, "limit_price": Decimal(100), "take_profit": None}, "Spot entry requires"),
],
)
def test_rejects_unsafe_requests(engine, account, rules, overrides, message):
with pytest.raises(RiskRejected, match=message):
engine.validate_open(request(**overrides), account, rules)
def test_rejects_max_positions(engine, rules):
account = AccountSnapshot(Decimal(10000), Decimal(10000), Decimal(0), 5)
with pytest.raises(RiskRejected, match="Maximum"):
engine.validate_open(request(), account, rules)

22
tests/test_scheduler.py Normal file
View File

@@ -0,0 +1,22 @@
from datetime import datetime, time
from zoneinfo import ZoneInfo
from llm_trade_bot.config import ScheduleConfig
from llm_trade_bot.scheduler import next_scheduled_time
def _config() -> ScheduleConfig:
zone = ZoneInfo("Europe/Warsaw")
return ScheduleConfig(zone, (time(9), time(21)))
def test_next_time_same_day():
config = _config()
now = datetime(2026, 9, 19, 10, 0, tzinfo=config.timezone)
assert next_scheduled_time(config, now) == datetime(2026, 9, 19, 21, 0, tzinfo=config.timezone)
def test_next_time_rolls_to_tomorrow():
config = _config()
now = datetime(2026, 9, 19, 22, 0, tzinfo=config.timezone)
assert next_scheduled_time(config, now) == datetime(2026, 9, 20, 9, 0, tzinfo=config.timezone)