fix: SL/TP exchange close order & route notifications via discord bot

This commit is contained in:
AI Assistant (Antigravity) 2026-05-26 14:05:21 +00:00
parent 563c20f615
commit 0841a1aa48
8 changed files with 340 additions and 14 deletions

View File

@ -67,7 +67,7 @@ def run_tick(
last = float(snap.ticker.get("last") or snap.ohlcv[settings.timeframes[0]]["close"].iloc[-1]) last = float(snap.ticker.get("last") or snap.ohlcv[settings.timeframes[0]]["close"].iloc[-1])
last_prices[symbol] = last last_prices[symbol] = last
closed_ids = portfolio.check_stop_take_profit(settings, symbol, last) closed_ids = portfolio.check_stop_take_profit(settings, kraken, symbol, last)
if closed_ids: if closed_ids:
log.info("trade.sl_tp_closed", symbol=symbol, trade_ids=closed_ids) log.info("trade.sl_tp_closed", symbol=symbol, trade_ids=closed_ids)

View File

@ -111,16 +111,26 @@ class _AitraderBot(discord.Client):
return deleted return deleted
_bot_instance: _AitraderBot | None = None
def get_bot_instance() -> _AitraderBot | None:
return _bot_instance
def start_bot(settings: Settings) -> None: def start_bot(settings: Settings) -> None:
global _bot_instance
if not settings.discord_bot_token: if not settings.discord_bot_token:
log.info("discord_bot.disabled", reason="kein Token gesetzt") log.info("discord_bot.disabled", reason="kein Token gesetzt")
return return
state.init(settings.db_path) state.init(settings.db_path)
def _run() -> None: def _run() -> None:
bot = _AitraderBot(settings) global _bot_instance
asyncio.run(bot.start(settings.discord_bot_token)) _bot_instance = _AitraderBot(settings)
asyncio.run(_bot_instance.start(settings.discord_bot_token))
t = threading.Thread(target=_run, daemon=True, name="discord-bot") t = threading.Thread(target=_run, daemon=True, name="discord-bot")
t.start() t.start()
log.info("discord_bot.thread_started") log.info("discord_bot.thread_started")

View File

@ -1,9 +1,11 @@
"""Discord-Webhook-Notifier.""" """Discord-Webhook-Notifier (mit Bot-Support)."""
from __future__ import annotations from __future__ import annotations
import asyncio
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
import discord
import requests import requests
from ..config import Settings from ..config import Settings
@ -23,7 +25,61 @@ _error_last_sent: dict[str, float] = {} # key -> timestamp
def _enabled(settings: Settings) -> bool: def _enabled(settings: Settings) -> bool:
return bool(settings.discord.enabled and settings.discord_webhook_url) return bool(
settings.discord.enabled
and (settings.discord_webhook_url or settings.discord_bot_token)
)
def send_via_bot(
settings: Settings,
embed_dict: dict[str, Any],
channel_type: str = "trades",
content: str | None = None,
) -> bool:
from .bot import get_bot_instance
bot = get_bot_instance()
if bot is None or not bot.is_ready():
return False
channel_id_str = (
settings.discord_decisions_channel_id
if channel_type == "decisions"
else settings.discord_channel_id
)
if not channel_id_str:
log.warning("discord_bot.channel_not_configured", channel_type=channel_type)
return False
try:
channel_id = int(channel_id_str)
except ValueError:
log.warning("discord_bot.invalid_channel_id", channel_id=channel_id_str)
return False
embed = discord.Embed.from_dict(embed_dict)
async def _send():
ch = bot.get_channel(channel_id)
if not ch:
try:
ch = await bot.fetch_channel(channel_id)
except Exception as ex:
log.warning("discord_bot.fetch_channel_failed", channel_id=channel_id, error=str(ex))
return
if isinstance(ch, discord.TextChannel):
await ch.send(content=content, embed=embed)
log.info("discord_bot.message_sent", channel_id=channel_id, channel_type=channel_type)
else:
log.warning("discord_bot.channel_not_text_channel", channel_id=channel_id)
future = asyncio.run_coroutine_threadsafe(_send(), bot.loop)
try:
future.result(timeout=10.0)
return True
except Exception as e:
log.error("discord_bot.send_failed", error=str(e))
return False
def _post( def _post(
@ -34,11 +90,25 @@ def _post(
) -> None: ) -> None:
if not _enabled(settings): if not _enabled(settings):
return return
embed.setdefault("timestamp", datetime.now(timezone.utc).isoformat())
# Versuche zuerst über den Discord Bot zu senden
if settings.discord_bot_token:
sent = send_via_bot(settings, embed, channel_type=channel, content=content)
if sent:
return
log.warning("discord.bot_send_failed_falling_back_to_webhook")
# Fallback zu Webhook
if channel == "decisions" and settings.discord_webhook_decisions_url: if channel == "decisions" and settings.discord_webhook_decisions_url:
url = settings.discord_webhook_decisions_url url = settings.discord_webhook_decisions_url
else: else:
url = settings.discord_webhook_url url = settings.discord_webhook_url
embed.setdefault("timestamp", datetime.now(timezone.utc).isoformat())
if not url:
return
payload: dict[str, Any] = {"embeds": [embed]} payload: dict[str, Any] = {"embeds": [embed]}
if content: if content:
payload["content"] = content payload["content"] = content
@ -50,6 +120,7 @@ def _post(
log.warning("discord.post_exception", error=str(e)) log.warning("discord.post_exception", error=str(e))
def _should(settings: Settings, event: str) -> bool: def _should(settings: Settings, event: str) -> bool:
return _enabled(settings) and event in settings.discord.notify_on and state.is_enabled(event) return _enabled(settings) and event in settings.discord.notify_on and state.is_enabled(event)

View File

@ -13,9 +13,10 @@ _engine = None
def get_engine(db_path: str): def get_engine(db_path: str):
global _engine global _engine
if _engine is None: db_url = f"sqlite:///{db_path}"
if _engine is None or str(_engine.url) != db_url:
Path(db_path).parent.mkdir(parents=True, exist_ok=True) Path(db_path).parent.mkdir(parents=True, exist_ok=True)
_engine = create_engine(f"sqlite:///{db_path}", echo=False) _engine = create_engine(db_url, echo=False)
SQLModel.metadata.create_all(_engine) SQLModel.metadata.create_all(_engine)
return _engine return _engine

View File

@ -2,15 +2,22 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Iterable from typing import Iterable, TYPE_CHECKING
from sqlmodel import select from sqlmodel import select
from ..config import Settings from ..config import Settings
from ..logging_setup import get_logger
from ..notify import discord from ..notify import discord
from ..storage import db as dbm from ..storage import db as dbm
from ..storage.models import EquitySnapshot, Trade from ..storage.models import EquitySnapshot, Trade
if TYPE_CHECKING:
from ..exchange.kraken import KrakenClient
log = get_logger(__name__)
def open_trades_for_symbol(settings: Settings, symbol: str) -> list[Trade]: def open_trades_for_symbol(settings: Settings, symbol: str) -> list[Trade]:
with dbm.session(settings.db_path) as s: with dbm.session(settings.db_path) as s:
@ -24,11 +31,22 @@ def all_open_trades(settings: Settings) -> list[Trade]:
return list(s.exec(select(Trade).where(Trade.status == "open")).all()) return list(s.exec(select(Trade).where(Trade.status == "open")).all())
def close_trade(settings: Settings, trade_id: int, exit_price: float) -> None: def close_trade(settings: Settings, kraken: KrakenClient, trade_id: int, exit_price: float) -> None:
with dbm.session(settings.db_path) as s: with dbm.session(settings.db_path) as s:
t = s.get(Trade, trade_id) t = s.get(Trade, trade_id)
if not t or t.status != "open": if not t or t.status != "open":
return return
# Close on exchange first (will simulate if paper_only is true)
ccxt_side = "sell" if t.side == "buy" else "buy"
try:
order = kraken.create_market_order(t.symbol, ccxt_side, t.qty)
log.info("close_trade.exchange_success", symbol=t.symbol, side=ccxt_side, qty=t.qty, order_id=order.get("id"))
except Exception as e:
log.error("close_trade.exchange_failed", error=str(e), symbol=t.symbol, qty=t.qty)
discord.notify_error(settings, f"close_trade.exchange_failed ({t.symbol})", str(e))
return
t.exit_price = exit_price t.exit_price = exit_price
t.exit_ts = datetime.now(timezone.utc) t.exit_ts = datetime.now(timezone.utc)
sign = 1 if t.side == "buy" else -1 sign = 1 if t.side == "buy" else -1
@ -41,7 +59,7 @@ def close_trade(settings: Settings, trade_id: int, exit_price: float) -> None:
def check_stop_take_profit( def check_stop_take_profit(
settings: Settings, symbol: str, current_price: float settings: Settings, kraken: KrakenClient, symbol: str, current_price: float
) -> list[int]: ) -> list[int]:
"""Schließt Trades, wenn SL/TP erreicht. Gibt geschlossene Trade-IDs zurück.""" """Schließt Trades, wenn SL/TP erreicht. Gibt geschlossene Trade-IDs zurück."""
closed: list[int] = [] closed: list[int] = []
@ -58,7 +76,11 @@ def check_stop_take_profit(
elif t.take_profit and current_price <= t.take_profit: elif t.take_profit and current_price <= t.take_profit:
hit = True hit = True
if hit: if hit:
close_trade(settings, t.id, current_price) close_trade(settings, kraken, t.id, current_price)
# Verify if it was successfully closed in the DB before counting it
with dbm.session(settings.db_path) as s:
updated_t = s.get(Trade, t.id)
if updated_t and updated_t.status == "closed":
closed.append(t.id) closed.append(t.id)
return closed return closed

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timezone
from sqlmodel import select from sqlmodel import select

50
tests/test_notify.py Normal file
View File

@ -0,0 +1,50 @@
import asyncio
import pytest
from unittest.mock import MagicMock, patch
from aitrader.config import Settings
from aitrader.notify import discord
def test_discord_enabled_with_webhook():
s = Settings(discord=dict(enabled=True), discord_webhook_url="https://xyz")
assert discord._enabled(s)
def test_discord_enabled_with_bot():
s = Settings(discord=dict(enabled=True), discord_bot_token="xyz")
assert discord._enabled(s)
def test_discord_disabled():
s = Settings(
discord=dict(enabled=False),
discord_webhook_url="https://xyz",
discord_bot_token="xyz",
)
assert not discord._enabled(s)
@patch("aitrader.notify.bot.get_bot_instance")
def test_send_via_bot_success(mock_get_bot):
mock_bot = MagicMock()
mock_bot.is_ready.return_value = True
mock_channel = MagicMock()
mock_bot.get_channel.return_value = mock_channel
mock_get_bot.return_value = mock_bot
s = Settings(discord_channel_id="12345", discord_bot_token="token")
def run_coro_sync(coro, loop):
asyncio.run(coro)
fut = MagicMock()
fut.result.return_value = None
return fut
with patch("asyncio.run_coroutine_threadsafe", side_effect=run_coro_sync) as mock_run:
res = discord.send_via_bot(s, {"title": "Test"}, channel_type="trades")
assert res
mock_run.assert_called_once()
mock_bot.get_channel.assert_called_once_with(12345)

172
tests/test_portfolio.py Normal file
View File

@ -0,0 +1,172 @@
import pytest
from unittest.mock import MagicMock
from aitrader.config import Settings
from aitrader.storage import db as dbm
from aitrader.storage.models import Trade
from aitrader.trader import portfolio
@pytest.fixture
def settings(tmp_path):
s = Settings(starting_equity_eur=10000.0, db_path=str(tmp_path / "t.db"))
# Initialize DB
dbm.get_engine(s.db_path)
return s
@pytest.fixture
def mock_kraken():
kraken = MagicMock()
kraken.create_market_order.return_value = {"id": "mock_order_123"}
return kraken
def test_check_stop_take_profit_buy_sl_hit(settings, mock_kraken):
# Setup open buy trade
with dbm.session(settings.db_path) as s:
trade = Trade(
symbol="BTC/USD:USD",
side="buy",
qty=1.5,
entry_price=60000.0,
stop_loss=59000.0,
take_profit=62000.0,
status="open"
)
s.add(trade)
s.commit()
s.refresh(trade)
trade_id = trade.id
# Under SL (58500 <= 59000)
closed = portfolio.check_stop_take_profit(settings, mock_kraken, "BTC/USD:USD", 58500.0)
assert closed == [trade_id]
mock_kraken.create_market_order.assert_called_once_with("BTC/USD:USD", "sell", 1.5)
# Check DB update
with dbm.session(settings.db_path) as s:
t = s.get(Trade, trade_id)
assert t.status == "closed"
assert t.exit_price == 58500.0
assert t.pnl_eur == (58500.0 - 60000.0) * 1.5
def test_check_stop_take_profit_buy_tp_hit(settings, mock_kraken):
with dbm.session(settings.db_path) as s:
trade = Trade(
symbol="BTC/USD:USD",
side="buy",
qty=1.0,
entry_price=60000.0,
stop_loss=59000.0,
take_profit=62000.0,
status="open"
)
s.add(trade)
s.commit()
s.refresh(trade)
trade_id = trade.id
# Above TP (62500 >= 62000)
closed = portfolio.check_stop_take_profit(settings, mock_kraken, "BTC/USD:USD", 62500.0)
assert closed == [trade_id]
mock_kraken.create_market_order.assert_called_once_with("BTC/USD:USD", "sell", 1.0)
# Check DB update
with dbm.session(settings.db_path) as s:
t = s.get(Trade, trade_id)
assert t.status == "closed"
assert t.exit_price == 62500.0
assert t.pnl_eur == (62500.0 - 60000.0) * 1.0
def test_check_stop_take_profit_no_hit(settings, mock_kraken):
with dbm.session(settings.db_path) as s:
trade = Trade(
symbol="BTC/USD:USD",
side="buy",
qty=1.0,
entry_price=60000.0,
stop_loss=59000.0,
take_profit=62000.0,
status="open"
)
s.add(trade)
s.commit()
s.refresh(trade)
trade_id = trade.id
# Price between SL and TP
closed = portfolio.check_stop_take_profit(settings, mock_kraken, "BTC/USD:USD", 60500.0)
assert closed == []
mock_kraken.create_market_order.assert_not_called()
# Check DB update
with dbm.session(settings.db_path) as s:
t = s.get(Trade, trade_id)
assert t.status == "open"
def test_check_stop_take_profit_sell_sl_hit(settings, mock_kraken):
# Setup open sell trade
with dbm.session(settings.db_path) as s:
trade = Trade(
symbol="ETH/USD:USD",
side="sell",
qty=2.0,
entry_price=3000.0,
stop_loss=3100.0,
take_profit=2800.0,
status="open"
)
s.add(trade)
s.commit()
s.refresh(trade)
trade_id = trade.id
# Above SL for Sell (3150 >= 3100)
closed = portfolio.check_stop_take_profit(settings, mock_kraken, "ETH/USD:USD", 3150.0)
assert closed == [trade_id]
mock_kraken.create_market_order.assert_called_once_with("ETH/USD:USD", "buy", 2.0)
# Check DB update
with dbm.session(settings.db_path) as s:
t = s.get(Trade, trade_id)
assert t.status == "closed"
assert t.exit_price == 3150.0
assert t.pnl_eur == -1 * (3150.0 - 3000.0) * 2.0
def test_check_stop_take_profit_exchange_fails(settings, mock_kraken):
mock_kraken.create_market_order.side_effect = Exception("Kraken API down")
with dbm.session(settings.db_path) as s:
trade = Trade(
symbol="BTC/USD:USD",
side="buy",
qty=1.0,
entry_price=60000.0,
stop_loss=59000.0,
take_profit=62000.0,
status="open"
)
s.add(trade)
s.commit()
s.refresh(trade)
trade_id = trade.id
# Under SL, but exchange order fails
closed = portfolio.check_stop_take_profit(settings, mock_kraken, "BTC/USD:USD", 58500.0)
assert closed == []
mock_kraken.create_market_order.assert_called_once_with("BTC/USD:USD", "sell", 1.0)
# DB record must still be open
with dbm.session(settings.db_path) as s:
t = s.get(Trade, trade_id)
assert t.status == "open"