feat: implement autonomous Bybit demo trading agent
This commit is contained in:
102
tests/test_agent_tools.py
Normal file
102
tests/test_agent_tools.py
Normal 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
56
tests/test_config.py
Normal 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
37
tests/test_database.py
Normal 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
15
tests/test_market.py
Normal 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
83
tests/test_risk.py
Normal 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
22
tests/test_scheduler.py
Normal 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)
|
||||
Reference in New Issue
Block a user