From e95eb492356e8e445bb5cee5c7f7fa05068cb9eb Mon Sep 17 00:00:00 2001 From: guigerdts Date: Fri, 26 Jun 2026 01:58:48 +0000 Subject: [PATCH] feat(fase-19): pipeline SHORT completo (Engine, Portfolio, RiskManager, brokers) --- cells/templates/intraday.yaml | 239 ++++++++++++++++----------- cells/templates/scalping.yaml | 56 ++++++- cells/templates/swing.yaml | 295 ++++++++++++++++++++-------------- core/engine.py | 90 +++++++++-- execution/binance_broker.py | 2 +- execution/paper_broker.py | 20 ++- risk/manager.py | 94 ++++++++--- risk/portfolio.py | 35 +++- tests/test_engine.py | 152 +++++++++++++++++- tests/test_portfolio.py | 204 +++++++++++++++++++++++ tests/test_risk_manager.py | 217 +++++++++++++++++++++++++ tests/test_telegram.py | 98 +++++++++++ 12 files changed, 1237 insertions(+), 265 deletions(-) create mode 100644 tests/test_portfolio.py create mode 100644 tests/test_risk_manager.py create mode 100644 tests/test_telegram.py diff --git a/cells/templates/intraday.yaml b/cells/templates/intraday.yaml index ea918a3..f1fc6e5 100644 --- a/cells/templates/intraday.yaml +++ b/cells/templates/intraday.yaml @@ -1,102 +1,149 @@ -- name: intraday_trend - symbols: - - BTCUSDT - - ETHUSDT - - SOLUSDT - symbol: ETHUSDT - timeframe: 5m - entry: - logic: AND - conditions: - - indicator: vwap - params: {} - operator: price > vwap - - indicator: volume_surge - params: - period: 15 - factor: 2.2 - exit: - - type: trailing_stop +name: intraday_trend +symbols: +- BTCUSDT +- ETHUSDT +- SOLUSDT +symbol: ETHUSDT +timeframe: 5m +entry: + logic: AND + conditions: + - indicator: vwap + params: {} + operator: price > vwap + - indicator: volume_surge params: - atr_multiplier: 0.5 - risk: - sizing: 0.1 - max_positions: 4 -- name: intraday_vwap - symbol: BTCUSDT - timeframe: 30m - entry: - logic: AND - conditions: - - indicator: vwap_deviation - params: - factor: 2.7 - exit: - - type: take_profit + period: 15 + factor: 2.2 +short_entry: + logic: AND + conditions: + - indicator: vwap + params: {} + operator: price < vwap + - indicator: volume_surge params: - pct: 6.2 - risk: - sizing: 0.07500000000000001 - max_positions: 4 -- name: intraday_volume_breakout - symbol: SOLUSDT - timeframe: 30m - entry: - logic: AND - conditions: - - indicator: range_breakout - params: - period: 29 - factor: 0.8 - - indicator: volume_surge - params: - period: 27 - factor: 3.3000000000000003 - exit: - - type: stop_loss + period: 15 + factor: 2.2 +exit: +- type: trailing_stop + params: + atr_multiplier: 0.5 +risk: + sizing: 0.005 + max_positions: 4 +--- +name: intraday_vwap +symbol: BTCUSDT +timeframe: 30m +entry: + logic: AND + conditions: + - indicator: vwap_deviation params: - atr_multiplier: 5.6000000000000005 - - type: take_profit + factor: 2.7 +short_entry: + logic: AND + conditions: + - indicator: vwap_deviation params: - atr_multiplier: 1.6 - risk: - sizing: 0.07 - max_positions: 5 -- name: intraday_support_resistance - symbol: LINKUSDT - timeframe: 30m - entry: - logic: AND - conditions: - - indicator: support_resistance - params: - lookback: 93 - touch_count: 4 - exit: - - type: take_profit + factor: 2.7 +exit: +- type: take_profit + params: + pct: 6.2 +risk: + sizing: 0.005 + max_positions: 4 +--- +name: intraday_volume_breakout +symbol: SOLUSDT +timeframe: 30m +entry: + logic: AND + conditions: + - indicator: range_breakout params: - pct: 6.9 - risk: - sizing: 0.005 - max_positions: 4 -- name: intraday_macd_divergence - symbols: - - BTCUSDT - - ETHUSDT - - ADAUSDT - entry: - logic: AND - conditions: - - indicator: macd_divergence - params: - fast: 20 - slow: 16 - signal: 11 - lookback: 42 - exit: - - type: stop_loss + period: 29 + factor: 0.8 + - indicator: volume_surge params: - atr_multiplier: 0.8 - risk: - sizing: 0.095 - max_positions: 5 + period: 27 + factor: 3.3000000000000003 +short_entry: + logic: AND + conditions: + - indicator: range_breakout + params: + period: 29 + factor: 0.8 + - indicator: volume_surge + params: + period: 27 + factor: 3.3000000000000003 +exit: +- type: stop_loss + params: + atr_multiplier: 5.6000000000000005 +- type: take_profit + params: + atr_multiplier: 1.6 +risk: + sizing: 0.005 + max_positions: 5 +--- +name: intraday_support_resistance +symbol: LINKUSDT +timeframe: 30m +entry: + logic: AND + conditions: + - indicator: support_resistance + params: + lookback: 93 + touch_count: 4 +short_entry: + logic: AND + conditions: + - indicator: support_resistance + params: + lookback: 93 + touch_count: 4 +exit: +- type: take_profit + params: + pct: 6.9 +risk: + sizing: 0.005 + max_positions: 4 +--- +name: intraday_macd_divergence +symbols: +- BTCUSDT +- ETHUSDT +- ADAUSDT +entry: + logic: AND + conditions: + - indicator: macd_divergence + params: + fast: 20 + slow: 16 + signal: 11 + lookback: 42 +short_entry: + logic: AND + conditions: + - indicator: macd_divergence + params: + fast: 20 + slow: 16 + signal: 11 + lookback: 42 +exit: +- type: stop_loss + params: + atr_multiplier: 0.8 +risk: + sizing: 0.005 + max_positions: 5 diff --git a/cells/templates/scalping.yaml b/cells/templates/scalping.yaml index 6743f4e..c1c330f 100644 --- a/cells/templates/scalping.yaml +++ b/cells/templates/scalping.yaml @@ -18,6 +18,17 @@ entry: params: period: 23 operator: < 32.0 +short_entry: + logic: AND + conditions: + - indicator: ema + params: + period: 23 + operator: price < ema + - indicator: rsi + params: + period: 23 + operator: '> 68.0' exit: - type: stop_loss params: @@ -26,7 +37,7 @@ exit: params: atr_multiplier: 4.9 risk: - sizing: 0.02 + sizing: 0.005 max_positions: 3 --- name: scalping_breakout @@ -43,6 +54,17 @@ entry: params: period: 34 factor: 5.0 +short_entry: + logic: AND + conditions: + - indicator: range_breakout + params: + period: 17 + factor: 0.8 + - indicator: volume_surge + params: + period: 34 + factor: 5.0 exit: - type: stop_loss params: @@ -51,7 +73,7 @@ exit: params: atr_multiplier: 5.1000000000000005 risk: - sizing: 0.025 + sizing: 0.005 max_positions: 3 --- name: scalping_reversion @@ -64,12 +86,19 @@ entry: params: period: 7 operator: < 25.0 +short_entry: + logic: AND + conditions: + - indicator: rsi + params: + period: 7 + operator: '> 75.0' exit: - type: take_profit params: atr_multiplier: 0.6 risk: - sizing: 0.095 + sizing: 0.005 max_positions: 3 --- name: scalping_orderflow @@ -84,6 +113,13 @@ entry: params: period: 20 factor: 4.8 +short_entry: + logic: AND + conditions: + - indicator: volume_surge + params: + period: 20 + factor: 4.8 exit: - type: stop_loss params: @@ -92,7 +128,7 @@ exit: params: atr_multiplier: 3.8000000000000003 risk: - sizing: 0.02 + sizing: 0.005 max_positions: 3 --- name: scalping_spread @@ -108,10 +144,20 @@ entry: params: period: 18 max_pct: 8.7 +short_entry: + logic: AND + conditions: + - indicator: spread + params: + max_spread_pct: 0.22 + - indicator: atr + params: + period: 18 + max_pct: 8.7 exit: - type: take_profit params: pct: 4.9 risk: - sizing: 0.07500000000000001 + sizing: 0.005 max_positions: 3 diff --git a/cells/templates/swing.yaml b/cells/templates/swing.yaml index 2e13136..37a81d5 100644 --- a/cells/templates/swing.yaml +++ b/cells/templates/swing.yaml @@ -1,117 +1,178 @@ -- name: swing_reversion - symbols: - - BTCUSDT - - ETHUSDT - - LINKUSDT - entry: - logic: AND - conditions: - - indicator: bollinger_lower - params: - period: 20 - std: 2.0 - operator: price < bollinger_lower - - indicator: rsi - params: - period: 48 - operator: < 32.0 - exit: - - type: take_profit - params: - atr_multiplier: 3.8000000000000003 - risk: - sizing: 0.02 - max_positions: 2 -- name: swing_trend_following - symbol: BTCUSDT - timeframe: 1d - entry: - logic: AND - conditions: - - indicator: ichimoku - params: - tenkan: 19 - kijun: 10 - senkou_b: 64 - exit: - - type: trailing_stop - params: - atr_multiplier: 2.7 - risk: - sizing: 0.030000000000000002 - max_positions: 2 -- name: swing_breakout - symbols: - - BTCUSDT - - ETHUSDT - - SOLUSDT - entry: - logic: AND - conditions: - - indicator: range_breakout - params: - period: 4 - factor: 1.0 - - indicator: volume_surge - params: - period: 35 - factor: 3.6 - exit: - - type: stop_loss - params: - atr_multiplier: 0.5 - - type: take_profit - params: - atr_multiplier: 0.5 - risk: - sizing: 0.025 - max_positions: 2 -- name: swing_mean_reversion - symbols: - - BTCUSDT - - ETHUSDT - - SOLUSDT - entry: - logic: AND - conditions: - - indicator: bollinger_lower - params: - period: 21 - std: 2.0 - operator: price < bollinger_lower - - indicator: rsi - params: - period: 35 - operator: < 37.0 - exit: - - type: take_profit - params: - atr_multiplier: 0.5 - risk: - sizing: 0.025 - max_positions: 2 -- name: swing_momentum - symbols: - - BTCUSDT - - ETHUSDT - - ADAUSDT - symbol: ADAUSDT - timeframe: 1d - entry: - logic: AND - conditions: - - indicator: ema - params: - period: 40 - operator: price > ema - - indicator: adx - params: - period: 50 - operator: '> 37.0' - exit: - - type: trailing_stop - params: - atr_multiplier: 0.9 - risk: - sizing: 0.04 - max_positions: 4 +name: swing_reversion +symbols: +- BTCUSDT +- ETHUSDT +- LINKUSDT +entry: + logic: AND + conditions: + - indicator: bollinger_lower + params: + period: 20 + std: 2.0 + operator: price < bollinger_lower + - indicator: rsi + params: + period: 48 + operator: < 32.0 +short_entry: + logic: AND + conditions: + - indicator: bollinger_upper + params: + period: 20 + std: 2.0 + operator: price > bollinger_upper + - indicator: rsi + params: + period: 48 + operator: '> 68.0' +exit: +- type: take_profit + params: + atr_multiplier: 3.8000000000000003 +risk: + sizing: 0.005 + max_positions: 2 +--- +name: swing_trend_following +symbol: BTCUSDT +timeframe: 1d +entry: + logic: AND + conditions: + - indicator: ichimoku + params: + tenkan: 19 + kijun: 10 + senkou_b: 64 +short_entry: + logic: AND + conditions: + - indicator: ema + params: + period: 40 + operator: price < ema + - indicator: adx + params: + period: 50 + operator: '> 25.0' +exit: +- type: trailing_stop + params: + atr_multiplier: 2.7 +risk: + sizing: 0.005 + max_positions: 2 +--- +name: swing_breakout +symbols: +- BTCUSDT +- ETHUSDT +- SOLUSDT +entry: + logic: AND + conditions: + - indicator: range_breakout + params: + period: 4 + factor: 1.0 + - indicator: volume_surge + params: + period: 35 + factor: 3.6 +short_entry: + logic: AND + conditions: + - indicator: range_breakout + params: + period: 4 + factor: 1.0 + - indicator: volume_surge + params: + period: 35 + factor: 3.6 +exit: +- type: stop_loss + params: + atr_multiplier: 0.5 +- type: take_profit + params: + atr_multiplier: 0.5 +risk: + sizing: 0.005 + max_positions: 2 +--- +name: swing_mean_reversion +symbols: +- BTCUSDT +- ETHUSDT +- SOLUSDT +entry: + logic: AND + conditions: + - indicator: bollinger_lower + params: + period: 21 + std: 2.0 + operator: price < bollinger_lower + - indicator: rsi + params: + period: 35 + operator: < 37.0 +short_entry: + logic: AND + conditions: + - indicator: bollinger_upper + params: + period: 21 + std: 2.0 + operator: price > bollinger_upper + - indicator: rsi + params: + period: 35 + operator: '> 63.0' +exit: +- type: take_profit + params: + atr_multiplier: 0.5 +risk: + sizing: 0.005 + max_positions: 2 +--- +name: swing_momentum +symbols: +- BTCUSDT +- ETHUSDT +- ADAUSDT +symbol: ADAUSDT +timeframe: 1d +entry: + logic: AND + conditions: + - indicator: ema + params: + period: 40 + operator: price > ema + - indicator: adx + params: + period: 50 + operator: '> 37.0' +short_entry: + logic: AND + conditions: + - indicator: ema + params: + period: 40 + operator: price < ema + - indicator: adx + params: + period: 50 + operator: '> 37.0' +exit: +- type: trailing_stop + params: + atr_multiplier: 0.9 +risk: + sizing: 0.005 + max_positions: 4 diff --git a/core/engine.py b/core/engine.py index 517c5fa..e92ca96 100644 --- a/core/engine.py +++ b/core/engine.py @@ -174,16 +174,33 @@ async def _process_event(self, event: dict[str, Any]) -> None: # would trap the cell permanently. continue - # Risk check passed — mark the cell as IN_POSITION for BUY signals. - # (SELL signals change the cell state in _exit_signal before - # returning, so we only update for BUY.) + # Risk check passed — update cell state based on action. + # BUY after risk approval: normally a long entry, BUT if the + # signal came from an IN_SHORT exit, it's a BUY-to-close and + # we must NOT call enter_position() (the cell exits instead). + # SHORT signals: mark the cell as IN_SHORT. enter_pos = getattr(cell, "enter_position", None) if signal_action == "BUY" and callable(enter_pos): + # Check if this BUY is a normal long entry or a BUY-to-close + # from an IN_SHORT exit. The cell's state tells us: + # if cell.state == "IN_SHORT", this is buy-to-cover. + cell_state = getattr(cell, "state", "IDLE") + if cell_state == "IN_SHORT": + # BUY-to-close — do NOT enter_position, will exit later + pass + else: + try: + enter_pos(approved.get("price", 0.0)) + except Exception: + logger.exception( + "Error al marcar celula {} como IN_POSITION", cell_name, + ) + elif signal_action == "SHORT" and callable(enter_pos): try: - enter_pos(approved.get("price", 0.0)) + enter_pos(approved.get("price", 0.0), direction="short") except Exception: logger.exception( - "Error al marcar celula {} como IN_POSITION", cell_name, + "Error al marcar celula {} como IN_SHORT", cell_name, ) if self.journal is not None: @@ -249,16 +266,33 @@ async def _process_event(self, event: dict[str, Any]) -> None: _action = approved.get("action", "") _symbol = approved.get("symbol", "") _capital = self.risk_manager.portfolio.capital + _entry_price = approved.get("entry_price", 0.0) + _exit_price = approved.get("price", 0.0) + _qty = approved.get("qty", 0) + if _action == "BUY": + # BUY could be long entry or short close + cell_state = getattr(cell, "state", "IDLE") + if cell_state == "IN_SHORT": + # BUY-to-close → journal position_closed with short PnL + _pnl = (_entry_price - _exit_price) * _qty + await self.journal.position_closed( + _symbol, _pnl, _capital, + direction="short", + trade_id=_trade_id, + ) + else: + await self.journal.position_opened( + _symbol, _capital, trade_id=_trade_id, + ) + elif _action == "SHORT": await self.journal.position_opened( _symbol, _capital, + direction="short", trade_id=_trade_id, ) elif _action == "SELL": - _entry = approved.get("entry_price", 0.0) - _exit_px = approved.get("price", 0.0) - _qty = approved.get("qty", 0) - _pnl = (_exit_px - _entry) * _qty + _pnl = (_exit_price - _entry_price) * _qty await self.journal.position_closed( _symbol, _pnl, _capital, trade_id=_trade_id, @@ -281,19 +315,41 @@ async def _process_event(self, event: dict[str, Any]) -> None: exit_time=str(self.clock.now()), exit_reason="signal", ) + elif self.trade_tracker is not None and signal_action == "BUY": + # Only record if this is a buy-to-close (closing a short) + cell_state = getattr(cell, "state", "IDLE") + if cell_state == "IN_SHORT": + _entry = approved.get("entry_price", 0.0) + _exit_px = approved.get("price", 0.0) + _qty = approved.get("qty", 0) + _pnl = (_entry - _exit_px) * _qty + self.trade_tracker.record_trade( + symbol=approved.get("symbol", ""), + direction="short", + entry_price=_entry, + exit_price=_exit_px, + qty=_qty, + pnl=_pnl, + strategy_name=cell_name, + exit_time=str(self.clock.now()), + exit_reason="signal", + ) - # Reset cell state after successful SELL execution. - # This mirrors enter_position() — state only changes - # AFTER the trade is confirmed, not before. - if signal_action == "SELL": - exit_pos = getattr(cell, "exit_position", None) - if callable(exit_pos): + # Reset cell state after successful exit execution. + exit_pos = getattr(cell, "exit_position", None) + if signal_action in ("SELL", "BUY") and callable(exit_pos): + # For BUY, only call exit_position if this is a buy-to-close + if signal_action == "BUY": + cell_state = getattr(cell, "state", "IDLE") + if cell_state != "IN_SHORT": + exit_pos = None # Don't exit — it's a long entry + if exit_pos is not None: try: exit_pos() except Exception: logger.exception( - "Error al marcar celula {} como IDLE tras SELL", - cell_name, + "Error al marcar celula {} como IDLE tras {}", + cell_name, signal_action, ) logger.info( diff --git a/execution/binance_broker.py b/execution/binance_broker.py index 85f79ff..1fd204e 100644 --- a/execution/binance_broker.py +++ b/execution/binance_broker.py @@ -102,7 +102,7 @@ async def submit_order(self, signal: dict[str, Any]) -> dict[str, Any]: # Map action to Binance enum if action == "BUY": side = Side.BUY - elif action == "SELL": + elif action in ("SELL", "SHORT"): side = Side.SELL else: logger.warning("Unknown action '{}' in signal", action) diff --git a/execution/paper_broker.py b/execution/paper_broker.py index f2e0225..cd9c96f 100644 --- a/execution/paper_broker.py +++ b/execution/paper_broker.py @@ -88,8 +88,24 @@ def update_portfolio(self, trade: dict[str, Any]) -> None: symbol = trade.get("symbol", "") if action == "BUY": - self.capital -= qty * price - self.positions[symbol] = self.positions.get(symbol, 0.0) + qty + # BUY could be long entry or short close (buy-to-cover) + short_positions = getattr(self, '_short_positions', {}) + if symbol in short_positions and short_positions[symbol] > 0: + # Close short — capital decreases + self.capital -= qty * price + short_positions[symbol] -= qty + if short_positions[symbol] <= 0: + short_positions.pop(symbol, None) + else: + # Normal BUY entry + self.capital -= qty * price + self.positions[symbol] = self.positions.get(symbol, 0.0) + qty + elif action == "SHORT": + # Short entry — you receive cash from selling borrowed shares + self.capital += qty * price + if not hasattr(self, '_short_positions'): + self._short_positions = {} + self._short_positions[symbol] = self._short_positions.get(symbol, 0.0) + qty elif action == "SELL": self.capital += qty * price self.positions[symbol] = self.positions.get(symbol, 0.0) - qty diff --git a/risk/manager.py b/risk/manager.py index e9cd72f..e3c9676 100644 --- a/risk/manager.py +++ b/risk/manager.py @@ -50,14 +50,15 @@ def __init__( self._last_config_reload: float = 0.0 self._config_reload_interval: float = 60.0 # seconds - # Track active entries by (symbol, cell_name) instead of relying - # on len(portfolio.positions) which only counts unique symbols. - self._active_entries: set[tuple[str, str]] = set() + # Track active entries by (symbol, cell_name, direction) — 3-tuples. + # Direction is "long" or "short" (lowercase). SHORT and BUY entries + # share the same position limit pool (max_positions). + self._active_entries: set[tuple[str, str, str]] = set() # Track position qty per cell so that each cell sells only its # own portion when multiple cells share the same symbol. - # Key: (symbol, cell_name) -> qty assigned at entry time. - self._entry_qty: dict[tuple[str, str], float] = {} + # Key: (symbol, cell_name, direction) -> qty assigned at entry time. + self._entry_qty: dict[tuple[str, str, str], float] = {} def _reload_config_if_needed(self) -> None: """Re-read ``max_positions`` from ``config.yaml`` if enough time has @@ -113,9 +114,24 @@ def approve(self, signal: dict[str, Any] | None) -> dict[str, Any] | None: price = float(signal.get("price", 0)) sizing = float(signal.get("sizing", 0.01)) + # ── BUY entry (long) or BUY-to-close (short) ──────────────────── if action == "BUY": - # Position limit check — counts ACTIVE ENTRIES, not unique symbols. - # Different cells CAN hold the same symbol simultaneously. + # Check if this is a BUY-to-close for an existing SHORT + short_key = (symbol, cell_name, "short") + if short_key in self._active_entries: + qty = self._entry_qty.pop(short_key, None) + self._active_entries.discard(short_key) + if qty is None or qty <= 0: + return {"approved": False, "reason": "no_position", + "detail": f"No hay posición short para {symbol} al cubrir (cell={cell_name})"} + signal["qty"] = qty + logger.info( + "RISK: {} BUY-TO-COVER aprobada — qty={} cell={}", + symbol, qty, cell_name, + ) + return {"approved": True, **signal} + + # Normal BUY entry — position limit check current_positions = len(self._active_entries) logger.info( "RiskManager: checking signal — positions={}/{} (symbol={}, cell={})", @@ -139,7 +155,7 @@ def approve(self, signal: dict[str, Any] | None) -> dict[str, Any] | None: return {"approved": False, "reason": "drawdown", "detail": f"Drawdown {drawdown:.2%} supera límite {self.max_drawdown:.2%}"} - # Calculate qty from capital * sizing / price (Bug 3) + # Calculate qty from capital * sizing / price capital = self.portfolio.capital if capital <= 0 or price <= 0: logger.warning( @@ -150,25 +166,60 @@ def approve(self, signal: dict[str, Any] | None) -> dict[str, Any] | None: "detail": f"Capital o precio inválido: capital={capital}, price={price}"} raw_qty = (capital * sizing) / price - # Minimum trade: 0.1% of capital worth of asset (prevents dust) min_qty = (capital * 0.001) / price if price > 0 else 0.0 qty = max(min_qty, raw_qty) signal["qty"] = qty - # Register this entry BEFORE returning so max_positions - # is enforced correctly on the next check. - self._active_entries.add((symbol, cell_name)) - # Store per-cell qty so each cell sells only its own portion - self._entry_qty[(symbol, cell_name)] = qty + # Register with direction="long" + self._active_entries.add((symbol, cell_name, "long")) + self._entry_qty[(symbol, cell_name, "long")] = qty logger.info( "RISK: {} {} aprobada — qty={:.4f} (capital=${:.2f}, sizing={:.2%}, price=${:.2f}, cell={})", action, symbol, qty, capital, sizing, price, cell_name, ) + return {"approved": True, **signal} + + # ── SHORT entry ───────────────────────────────────────────────── + elif action == "SHORT": + current_positions = len(self._active_entries) + if current_positions >= self.max_positions: + logger.warning( + "RISK REJECT (SHORT): max_positions reached ({}/{}) — {} cell={}", + current_positions, self.max_positions, symbol, cell_name, + ) + return {"approved": False, "reason": "max_positions", + "detail": f"Límite de {self.max_positions} posiciones alcanzado ({symbol}, cell={cell_name})"} + + drawdown = self.portfolio.get_drawdown() + if drawdown >= self.max_drawdown: + logger.warning( + "RISK REJECT (SHORT): drawdown limit exceeded ({:.2%} >= {:.2%}) — {} cell={}", + drawdown, self.max_drawdown, symbol, cell_name, + ) + return {"approved": False, "reason": "drawdown", + "detail": f"Drawdown {drawdown:.2%} supera límite {self.max_drawdown:.2%}"} + + capital = self.portfolio.capital + if capital <= 0 or price <= 0: + return {"approved": False, "reason": "invalid_params", + "detail": f"Capital o precio inválido: capital={capital}, price={price}"} + + raw_qty = (capital * sizing) / price + min_qty = (capital * 0.001) / price if price > 0 else 0.0 + qty = max(min_qty, raw_qty) + signal["qty"] = qty + + self._active_entries.add((symbol, cell_name, "short")) + self._entry_qty[(symbol, cell_name, "short")] = qty + logger.info( + "RISK: SHORT {} aprobada — qty={:.4f} (capital=${:.2f}, sizing={:.2%}, price=${:.2f}, cell={})", + symbol, qty, capital, sizing, price, cell_name, + ) + return {"approved": True, **signal} + # ── SELL (long exit) ───────────────────────────────────────────── elif action == "SELL": - # Use per-cell qty first; fall back to total symbol qty - # for entries created before this fix was deployed. - qty = self._entry_qty.pop((symbol, cell_name), None) + qty = self._entry_qty.pop((symbol, cell_name, "long"), None) if qty is None: qty = self.portfolio.positions.get(symbol, 0.0) if qty <= 0: @@ -176,19 +227,18 @@ def approve(self, signal: dict[str, Any] | None) -> dict[str, Any] | None: "RISK REJECT: no position to sell (qty={}) — {} cell={}", qty, symbol, cell_name, ) - # Do NOT discard from _active_entries — the cell stays - # IN_POSITION and can retry the exit on the next tick. return {"approved": False, "reason": "no_position", "detail": f"No hay posición abierta para {symbol} al cerrar (cell={cell_name})"} - # Only free the slot AFTER confirming there IS a position. - self._active_entries.discard((symbol, cell_name)) + self._active_entries.discard((symbol, cell_name, "long")) signal["qty"] = qty logger.info( "RISK: {} SELL aprobada — qty={} cell={}", symbol, qty, cell_name, ) + return {"approved": True, **signal} + # ── Unknown action ─────────────────────────────────────────────── else: logger.warning( "RISK REJECT: unknown action '{}' — {} cell={}", @@ -196,5 +246,3 @@ def approve(self, signal: dict[str, Any] | None) -> dict[str, Any] | None: ) return {"approved": False, "reason": "unknown_action", "detail": f"Acción desconocida: {action} (symbol={symbol}, cell={cell_name})"} - - return {"approved": True, **signal} diff --git a/risk/portfolio.py b/risk/portfolio.py index 71260b9..8fb6374 100644 --- a/risk/portfolio.py +++ b/risk/portfolio.py @@ -28,6 +28,8 @@ def __init__(self, initial_capital: float = 100_000.0) -> None: self._position_costs: dict[str, float] = {} # symbol -> entry_price self._peak_value: float = initial_capital # peak tracked for drawdown self._mtm_prices: dict[str, float] = {} # symbol -> last market price + self._short_positions: dict[str, float] = {} # symbol -> qty (negative liability) + self._short_position_costs: dict[str, float] = {} # symbol -> entry_price def get_total_value(self) -> float: """Return the current portfolio value (cash + positions at market). @@ -41,6 +43,12 @@ def get_total_value(self) -> float: for sym, qty in self.positions.items(): price = self._mtm_prices.get(sym, self._position_costs.get(sym, 0.0)) total += qty * price + # Short liability: we owe the shares back if we close the short + short_liability = sum( + self._short_positions.get(sym, 0) * self._mtm_prices.get(sym, 0) + for sym in self._short_positions + ) + total -= short_liability # Update peak-equity tracker (side effect for drawdown calculation) self._peak_value = max(self._peak_value, total) return total @@ -73,6 +81,8 @@ def update_price(self, symbol: str, price: float) -> None: """ if symbol in self.positions: self._mtm_prices[symbol] = price + elif hasattr(self, '_short_positions') and symbol in self._short_positions: + self._mtm_prices[symbol] = price # Update peak after the MTM adjustment self._peak_value = max(self._peak_value, self.get_total_value()) @@ -88,9 +98,28 @@ def update(self, trade: dict[str, Any]) -> None: symbol = trade.get("symbol", "") if action == "BUY": - self.capital -= qty * price - self.positions[symbol] = self.positions.get(symbol, 0.0) + qty - self._position_costs[symbol] = price + # BUY could be long entry or short close (buy-to-cover) + if symbol in self._short_positions and self._short_positions[symbol] > 0: + # Close short position + entry = self._short_position_costs.get(symbol, price) + pnl = (entry - price) * qty + self.capital -= qty * price + self._short_positions[symbol] -= qty + if self._short_positions[symbol] <= 0: + del self._short_positions[symbol] + if symbol in self._short_position_costs: + del self._short_position_costs[symbol] + return pnl + else: + # Normal BUY entry + self.capital -= qty * price + self.positions[symbol] = self.positions.get(symbol, 0.0) + qty + self._position_costs[symbol] = price + elif action == "SHORT": + # Short entry — you receive cash from selling borrowed shares + self.capital += qty * price + self._short_positions[symbol] = self._short_positions.get(symbol, 0.0) + qty + self._short_position_costs[symbol] = price elif action == "SELL": self.capital += qty * price self.positions[symbol] = self.positions.get(symbol, 0.0) - qty diff --git a/tests/test_engine.py b/tests/test_engine.py index 59f2e4d..ad8dd9d 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -150,6 +150,7 @@ async def _run(): self.loop.run_until_complete(_run()) + self.risk_manager.approve.assert_called_once_with(signal) self.broker.submit_order.assert_awaited_once_with(approved_signal) def test_signal_rejected_by_risk(self): @@ -164,7 +165,7 @@ def test_signal_rejected_by_risk(self): cell.handle = AsyncMock(return_value=signal) self.engine.register(cell) - self.risk_manager.approve.return_value = None + self.risk_manager.approve.return_value = None # reject event = {"type": "tick", "symbol": "BTCUSDT", "price": 50000} self.bus.queue.put_nowait(event) @@ -231,3 +232,152 @@ async def _run(): # Both cells should have been called bad_cell.handle.assert_awaited_once_with(event) good_cell.handle.assert_awaited_once_with(event) + + # -- SHORT pipeline ------------------------------------------------------- + + def test_short_pipeline(self): + """SHORT signal → enter_position(direction='short') → broker → journal.""" + cell = MockCell() + signal = { + "action": "SHORT", + "symbol": "BTCUSDT", + "price": 50000, + "qty": 0.01, + "entry_price": 50000, + } + cell.handle = AsyncMock(return_value=signal) + # Add enter_position so engine can call it + cell.enter_position = MagicMock() + cell.exit_position = MagicMock() + cell.state = "IDLE" + self.engine.register(cell) + + approved_signal = {"approved": True, **signal} + self.risk_manager.approve.return_value = approved_signal + + event = {"type": "tick", "symbol": "BTCUSDT", "price": 50000} + self.bus.queue.put_nowait(event) + + async def _run(): + task = asyncio.create_task(self.engine.run()) + await asyncio.sleep(0.05) + self.engine.stop() + await task + + self.loop.run_until_complete(_run()) + + # Should call enter_position with direction="short" + cell.enter_position.assert_called_once_with(50000, direction="short") + self.broker.submit_order.assert_awaited_once() + + def test_short_pipeline_broker_and_portfolio(self): + """SHORT signal should update broker portfolio and risk portfolio.""" + from risk.portfolio import Portfolio + + # Create real portfolio + portfolio = Portfolio(initial_capital=100_000.0) + self.risk_manager.portfolio = portfolio + + cell = MockCell() + signal = { + "action": "SHORT", + "symbol": "BTCUSDT", + "price": 50000, + "qty": 0.02, + "sizing": 0.01, + "entry_price": 50000, + } + cell.handle = AsyncMock(return_value=signal) + cell.enter_position = MagicMock() + cell.exit_position = MagicMock() + cell.state = "IDLE" + self.engine.register(cell) + + approved_signal = {"approved": True, **signal} + self.risk_manager.approve.return_value = approved_signal + + event = {"type": "tick", "symbol": "BTCUSDT", "price": 50000} + self.bus.queue.put_nowait(event) + + async def _run(): + task = asyncio.create_task(self.engine.run()) + await asyncio.sleep(0.05) + self.engine.stop() + await task + + self.loop.run_until_complete(_run()) + + # Risk portfolio should have the short + assert "BTCUSDT" in portfolio._short_positions + assert portfolio._short_positions["BTCUSDT"] == 0.02 + + # -- BUY-to-close pipeline ------------------------------------------------ + + def test_buy_to_close_pipeline(self): + """BUY signal when IN_SHORT → close position → journal short PnL.""" + cell = MockCell() + signal = { + "action": "BUY", + "symbol": "BTCUSDT", + "price": 25000, + "qty": 0.01, + "entry_price": 30000, + } + cell.handle = AsyncMock(return_value=signal) + cell.enter_position = MagicMock() + cell.exit_position = MagicMock() + cell.state = "IN_SHORT" # Cell is in SHORT, so BUY is a close + cell.name = "test_cell" + self.engine.register(cell) + + approved_signal = {"approved": True, **signal} + self.risk_manager.approve.return_value = approved_signal + + event = {"type": "tick", "symbol": "BTCUSDT", "price": 25000} + self.bus.queue.put_nowait(event) + + async def _run(): + task = asyncio.create_task(self.engine.run()) + await asyncio.sleep(0.05) + self.engine.stop() + await task + + self.loop.run_until_complete(_run()) + + # Should NOT call enter_position (it's a close, not entry) + # Instead should call exit_position + cell.exit_position.assert_called_once() + + def test_buy_to_close_does_not_enter_position(self): + """BUY-to-close should NOT call enter_position().""" + cell = MockCell() + signal = { + "action": "BUY", + "symbol": "BTCUSDT", + "price": 25000, + "qty": 0.01, + "entry_price": 30000, + } + cell.handle = AsyncMock(return_value=signal) + cell.enter_position = MagicMock() + cell.exit_position = MagicMock() + cell.state = "IN_SHORT" + cell.name = "test_cell" + self.engine.register(cell) + + approved_signal = {"approved": True, **signal} + self.risk_manager.approve.return_value = approved_signal + + event = {"type": "tick", "symbol": "BTCUSDT", "price": 25000} + self.bus.queue.put_nowait(event) + + async def _run(): + task = asyncio.create_task(self.engine.run()) + await asyncio.sleep(0.05) + self.engine.stop() + await task + + self.loop.run_until_complete(_run()) + + # enter_position should NOT be called for buy-to-close + cell.enter_position.assert_not_called() diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py new file mode 100644 index 0000000..abb3a72 --- /dev/null +++ b/tests/test_portfolio.py @@ -0,0 +1,204 @@ +"""Unit tests for Portfolio: drawdown, update_price, short PnL, and short liability. + +Covers: +- Peak-equity drawdown tracking (peak stays at max, never decreases) +- update_price() mark-to-market updates total value +- Short PnL calculation: (entry - exit) * qty +- get_drawdown() returns ratio (0.0833 not 8.33) +- Short liability in get_total_value() +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def portfolio(): + """Return a fresh Portfolio with 100k initial capital.""" + from risk.portfolio import Portfolio + return Portfolio(initial_capital=100_000.0) + + +# ── Peak-equity drawdown tracking ──────────────────────────────────────── + + +def test_peak_starts_at_capital(portfolio): + """Peak value should start equal to initial capital.""" + assert portfolio._peak_value == 100_000.0 + + +def test_peak_tracks_upward(portfolio): + """Peak should increase when capital grows (e.g. from short credit).""" + # Simulate a short entry that adds capital + short_qty = 1.0 + short_price = 50000.0 + portfolio.capital += short_qty * short_price # credit from short sale + portfolio.get_total_value() # triggers _peak_value update + expected_peak = 100_000.0 + short_qty * short_price + assert portfolio._peak_value == expected_peak + + +def test_peak_does_not_decrease(portfolio): + """Peak should stay at 120000 when value drops to 110000.""" + portfolio._peak_value = 120_000.0 + portfolio.get_total_value() # recalculates but shouldn't lower peak + assert portfolio._peak_value == 120_000.0 + + +# ── update_price() mark-to-market ──────────────────────────────────────── + + +def test_update_price_long_position(portfolio): + """Mark-to-market update should change total value for long positions.""" + portfolio.positions["BTCUSDT"] = 1.0 + portfolio._position_costs["BTCUSDT"] = 50000.0 + portfolio.capital = 50_000.0 + + # Initially total = 50000 + 1*50000 = 100000 + assert portfolio.get_total_value() == 100_000.0 + + # Update price to 48000 → total = 50000 + 1*48000 = 98000 + portfolio.update_price("BTCUSDT", 48000.0) + assert portfolio.get_total_value() == 98_000.0 + + +def test_update_price_short_position(portfolio): + """MTM update should reflect short liability changes.""" + portfolio.capital = 150_000.0 # 100k initial + 50k from short sale + portfolio._short_positions["BTCUSDT"] = 1.0 + portfolio._short_position_costs["BTCUSDT"] = 50000.0 + + # update_price sets mtm for short symbols too + portfolio.update_price("BTCUSDT", 50000.0) + # total = 150000 - (1 * 50000) = 100000 + assert portfolio.get_total_value() == 100_000.0 + + # Price drops → short liability decreases → total increases + portfolio.update_price("BTCUSDT", 45000.0) + # total = 150000 - (1 * 45000) = 105000 + assert portfolio.get_total_value() == 105_000.0 + + +# ── get_drawdown() ratio ───────────────────────────────────────────────── + + +def test_drawdown_ratio_zero(portfolio): + """Drawdown should be 0 when total == peak.""" + assert portfolio.get_drawdown() == 0.0 + + +def test_drawdown_ratio_value(portfolio): + """Drawdown should return correct ratio (0.0833, not 8.33).""" + portfolio._peak_value = 120_000.0 + portfolio.capital = 100_000.0 + portfolio.positions["BTCUSDT"] = 1.0 + portfolio._position_costs["BTCUSDT"] = 10_000.0 + # total = 100000 + 1*10000 = 110000 + # drawdown = (120000 - 110000) / 120000 = 10000/120000 = 0.08333 + assert portfolio.get_drawdown() == pytest.approx(0.08333, rel=1e-3) + + +def test_drawdown_ratio_zero_peak(portfolio): + """Drawdown should be 0 when peak is 0.""" + portfolio._peak_value = 0.0 + assert portfolio.get_drawdown() == 0.0 + + +# ── Short PnL ──────────────────────────────────────────────────────────── + + +def test_short_entry_updates_capital(portfolio): + """SHORT entry should increase capital (credit from selling borrowed shares).""" + initial = portfolio.capital + portfolio.update({ + "action": "SHORT", + "symbol": "BTCUSDT", + "qty": 0.1, + "price": 30000.0, + }) + # Capital increases by qty * price = 3000 + assert portfolio.capital == initial + 3000.0 + assert portfolio._short_positions.get("BTCUSDT") == 0.1 + assert portfolio._short_position_costs.get("BTCUSDT") == 30000.0 + + +def test_short_close_pnl(portfolio): + """Close short with BUY: PnL = (entry - exit) * qty = (30000-25000)*0.1 = 500.0.""" + # Enter short + portfolio.update({ + "action": "SHORT", + "symbol": "BTCUSDT", + "qty": 0.1, + "price": 30000.0, + }) + # Close short (buy-to-cover) + pnl = portfolio.update({ + "action": "BUY", + "symbol": "BTCUSDT", + "qty": 0.1, + "price": 25000.0, + }) + assert pnl == 500.0 + # Position should be removed + assert "BTCUSDT" not in portfolio._short_positions + assert "BTCUSDT" not in portfolio._short_position_costs + + +def test_short_close_negative_pnl(portfolio): + """Close short at higher price: PnL = (30000 - 32000) * 0.1 = -200.0 (loss).""" + portfolio.update({ + "action": "SHORT", + "symbol": "BTCUSDT", + "qty": 0.1, + "price": 30000.0, + }) + pnl = portfolio.update({ + "action": "BUY", + "symbol": "BTCUSDT", + "qty": 0.1, + "price": 32000.0, + }) + assert pnl == -200.0 + + +# ── Short liability in get_total_value ─────────────────────────────────── + + +def test_total_value_with_short(portfolio): + """get_total_value should subtract short liability from capital.""" + portfolio.capital = 130_000.0 # 100k initial + 30k short credit + portfolio._short_positions["BTCUSDT"] = 1.0 + portfolio._short_position_costs["BTCUSDT"] = 30000.0 + portfolio._mtm_prices["BTCUSDT"] = 30000.0 + + # total = 130000 - (1 * 30000) = 100000 + assert portfolio.get_total_value() == 100_000.0 + + # If price drops to 25000, short liability decreases + portfolio._mtm_prices["BTCUSDT"] = 25000.0 + assert portfolio.get_total_value() == 105_000.0 + + +def test_total_value_with_both_long_and_short(portfolio): + """get_total_value should correctly account for both long and short positions.""" + portfolio.capital = 130_000.0 + portfolio.positions["ETHUSDT"] = 10.0 + portfolio._position_costs["ETHUSDT"] = 2000.0 + portfolio._short_positions["BTCUSDT"] = 1.0 + portfolio._short_position_costs["BTCUSDT"] = 30000.0 + portfolio._mtm_prices["ETHUSDT"] = 2000.0 + portfolio._mtm_prices["BTCUSDT"] = 30000.0 + + # total = 130000 + (10*2000) - (1*30000) = 130000 + 20000 - 30000 = 120000 + assert portfolio.get_total_value() == 120_000.0 + + +# ── update_price tracks short symbols ──────────────────────────────────── + + +def test_update_price_short_symbol_mtm(portfolio): + """update_price should store MTM price for short position symbols.""" + portfolio._short_positions["BTCUSDT"] = 1.0 + portfolio.update_price("BTCUSDT", 48000.0) + assert portfolio._mtm_prices["BTCUSDT"] == 48000.0 diff --git a/tests/test_risk_manager.py b/tests/test_risk_manager.py new file mode 100644 index 0000000..9020ad1 --- /dev/null +++ b/tests/test_risk_manager.py @@ -0,0 +1,217 @@ +"""Unit tests for RiskManager: structured rejection paths, SHORT approve, and BUY-to-close. + +Covers: +- All 6 structured rejection paths return correct reason strings +- SHORT signal approval when capacity available +- SHORT at max_positions rejected +- BUY-to-close recognition (closes short, returns approved) +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + + +@pytest.fixture +def portfolio(): + """Return a mock Portfolio with adequate capital and no drawdown.""" + p = MagicMock() + p.capital = 100_000.0 + p.get_drawdown.return_value = 0.0 + p.positions = {} + return p + + +@pytest.fixture +def rm(portfolio): + """Return a RiskManager with max 5 positions, 3% max drawdown.""" + from risk.manager import RiskManager + return RiskManager(portfolio, max_positions=5, max_drawdown=0.03) + + +def _buy_signal(symbol="BTCUSDT", cell_name="test_cell", price=50000.0, sizing=0.01): + return { + "action": "BUY", + "symbol": symbol, + "price": price, + "sizing": sizing, + "cell_name": cell_name, + } + + +def _short_signal(symbol="BTCUSDT", cell_name="test_cell", price=50000.0, sizing=0.01): + return { + "action": "SHORT", + "symbol": symbol, + "price": price, + "sizing": sizing, + "cell_name": cell_name, + } + + +def _sell_signal(symbol="BTCUSDT", cell_name="test_cell", price=51000.0): + return { + "action": "SELL", + "symbol": symbol, + "price": price, + "cell_name": cell_name, + } + + +# ── Structured rejection paths ─────────────────────────────────────────── + + +def test_rejection_null_signal(rm): + """null_signal: signal is None → reason='null_signal'.""" + result = rm.approve(None) + assert result is not None + assert not result["approved"] + assert result["reason"] == "null_signal" + assert isinstance(result["detail"], str) + + +def test_rejection_max_positions(rm): + """max_positions: pool full → reason='max_positions'.""" + # Fill all 5 slots with BUY entries + for i in range(5): + sig = _buy_signal(symbol="BTCUSDT", cell_name=f"cell_{i}", price=50000.0) + result = rm.approve(sig) + assert result["approved"], f"Entry {i} should be approved" + # Must also update portfolio positions so SELL tracking works + rm.portfolio.positions["BTCUSDT"] = rm.portfolio.positions.get("BTCUSDT", 0.0) + result["qty"] + + # 6th BUY should be rejected + result = rm.approve(_buy_signal(cell_name="cell_6")) + assert not result["approved"] + assert result["reason"] == "max_positions" + + +def test_rejection_drawdown(rm): + """drawdown: exceeded threshold → reason='drawdown'.""" + rm.portfolio.get_drawdown.return_value = 0.05 # 5% > 3% max + result = rm.approve(_buy_signal()) + assert not result["approved"] + assert result["reason"] == "drawdown" + + +def test_rejection_invalid_params(rm): + """invalid_params: capital <= 0 or price <= 0.""" + rm.portfolio.capital = 0.0 + result = rm.approve(_buy_signal(price=50000.0)) + assert not result["approved"] + assert result["reason"] == "invalid_params" + + +def test_rejection_no_position(rm): + """no_position: SELL when no position exists.""" + result = rm.approve(_sell_signal()) + assert not result["approved"] + assert result["reason"] == "no_position" + + +def test_rejection_unknown_action(rm): + """unknown_action: e.g., HOLD is not recognized.""" + result = rm.approve({"action": "HOLD", "symbol": "BTCUSDT", "cell_name": "test"}) + assert not result["approved"] + assert result["reason"] == "unknown_action" + + +# ── SHORT approval ─────────────────────────────────────────────────────── + + +def test_short_approval(rm): + """SHORT signal with capacity → approved with qty.""" + result = rm.approve(_short_signal()) + assert result["approved"] + assert result["qty"] > 0 + assert ("BTCUSDT", "test_cell", "short") in rm._active_entries + assert ("BTCUSDT", "test_cell", "short") in rm._entry_qty + + +def test_short_at_max_positions(rm): + """SHORT at max_positions → rejected with 'max_positions'.""" + # Fill all 5 slots with BUY entries (mix of long and short) + for i in range(5): + sig = _buy_signal(symbol="BTCUSDT", cell_name=f"cell_{i}", price=50000.0) + result = rm.approve(sig) + assert result["approved"] + rm.portfolio.positions["BTCUSDT"] = rm.portfolio.positions.get("BTCUSDT", 0.0) + result["qty"] + + # SHORT should be rejected + result = rm.approve(_short_signal(cell_name="cell_short")) + assert not result["approved"] + assert result["reason"] == "max_positions" + + +def test_short_and_long_share_pool(rm): + """SHORT and BUY entries share the same max_positions pool.""" + # 3 BUY entries + for i in range(3): + sig = _buy_signal(cell_name=f"buy_cell_{i}", price=50000.0) + result = rm.approve(sig) + assert result["approved"] + rm.portfolio.positions["BTCUSDT"] = rm.portfolio.positions.get("BTCUSDT", 0.0) + result["qty"] + + # 2 SHORT entries + for i in range(2): + sig = _short_signal(cell_name=f"short_cell_{i}", price=50000.0) + result = rm.approve(sig) + assert result["approved"] + + # Next entry (any direction) should fail + result = rm.approve(_buy_signal(cell_name="extra")) + assert not result["approved"] + assert result["reason"] == "max_positions" + + +# ── BUY-to-close recognition ───────────────────────────────────────────── + + +def test_buy_to_close_recognized(rm): + """BUY signal with matching short entry → approved (close-short).""" + # Enter short + short_result = rm.approve(_short_signal(cell_name="test_cell")) + assert short_result["approved"] + short_qty = short_result["qty"] + + # BUY should match and close the short + buy_result = rm.approve(_buy_signal(cell_name="test_cell")) + assert buy_result["approved"] + assert buy_result["qty"] == short_qty + # Short entry should be removed + assert ("BTCUSDT", "test_cell", "short") not in rm._active_entries + + +def test_buy_to_close_rejected_no_short(rm): + """BUY without matching short entry → treated as normal entry.""" + result = rm.approve(_buy_signal(cell_name="unknown_cell")) + assert result["approved"] # Normal entry, not a close + + +def test_sell_frees_long_slot(rm): + """SELL should free the (symbol, cell, 'long') slot.""" + # Enter long + buy_result = rm.approve(_buy_signal(cell_name="test_cell")) + assert buy_result["approved"] + + # SELL frees the slot + sell_result = rm.approve(_sell_signal(cell_name="test_cell")) + assert sell_result["approved"] + assert ("BTCUSDT", "test_cell", "long") not in rm._active_entries + + +def test_buy_to_close_short_with_direction_key(rm): + """BUY-to-close should match on (symbol, cell_name, 'short').""" + # Enter SHORT via cell_1 + rm.approve(_short_signal(cell_name="cell_1")) + # Enter LONG via cell_2 (same symbol, different cell) + rm.approve(_buy_signal(cell_name="cell_2")) + + # BUY from cell_1 should close SHORT, not affect LONG + result = rm.approve(_buy_signal(cell_name="cell_1")) + assert result["approved"] + assert ("BTCUSDT", "cell_1", "short") not in rm._active_entries + # cell_2's LONG should still be active + assert ("BTCUSDT", "cell_2", "long") in rm._active_entries diff --git a/tests/test_telegram.py b/tests/test_telegram.py new file mode 100644 index 0000000..722660b --- /dev/null +++ b/tests/test_telegram.py @@ -0,0 +1,98 @@ +"""Unit tests for TelegramAlerts rejection formatting. + +Covers: +- _fmt_rejected() includes the detail string verbatim +- Missing detail uses default "risk_rejected" +- _fmt_single() with rejection events +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def telegram(): + """Return a TelegramAlerts instance with minimal setup.""" + from monitoring.telegram_alerts import TelegramAlerts + + bus = type("FakeBus", (), {"subscribe": lambda self: __import__("asyncio").Queue()})() + return TelegramAlerts(bus, bot_token="test", chat_id="test") + + +# ── _fmt_rejected ──────────────────────────────────────────────────────── + + +def test_fmt_rejected_includes_detail(telegram): + """_fmt_rejected should include the detail string verbatim.""" + event = {"type": "rejected", "reason": "Límite de 5 posiciones alcanzado"} + pending = {"symbol": "BTCUSDT", "action": "BUY", "strategy": "swing_1"} + result = telegram._fmt_rejected(event, pending) + + assert "Límite de 5 posiciones alcanzado" in result + assert "BTCUSDT" in result + assert "swing_1" in result + + +def test_fmt_rejected_detail_with_special_chars(telegram): + """_fmt_rejected should handle detail with special characters.""" + event = {"type": "rejected", "reason": "Drawdown 15.2% supera límite 10%"} + pending = {"symbol": "BTCUSDT", "action": "BUY"} + result = telegram._fmt_rejected(event, pending) + + assert "Drawdown 15.2%" in result + assert "BTCUSDT" in result + + +def test_fmt_rejected_missing_detail(telegram): + """_fmt_rejected with missing detail should default to 'risk_rejected'.""" + event = {"type": "rejected"} + pending = {"symbol": "BTCUSDT", "action": "SELL"} + result = telegram._fmt_rejected(event, pending) + + assert "risk_rejected" in result + assert "BTCUSDT" in result + + +def test_fmt_rejected_no_pending(telegram): + """_fmt_rejected should fall back to event fields when pending is empty.""" + event = {"type": "rejected", "symbol": "ETHUSDT", "action": "BUY", + "reason": "max_positions"} + pending = {} + result = telegram._fmt_rejected(event, pending) + + assert "max_positions" in result + assert "ETHUSDT" in result + + +# ── _fmt_single ────────────────────────────────────────────────────────── + + +def test_fmt_single_rejection(telegram): + """_fmt_single with rejected event should include detail.""" + event = {"type": "rejected", "symbol": "BTCUSDT", "action": "BUY", + "reason": "Drawdown 5% supera límite 3%"} + result = telegram._fmt_single(event) + + assert result is not None + assert "Drawdown 5%" in result + assert "BTCUSDT" in result + + +def test_fmt_single_rejection_default_reason(telegram): + """_fmt_single with rejected event but missing reason → 'risk_rejected'.""" + event = {"type": "rejected", "symbol": "BTCUSDT", "action": "BUY"} + result = telegram._fmt_single(event) + + assert result is not None + assert "risk_rejected" in result + + +def test_fmt_single_other_types_work(telegram): + """_fmt_single should still format non-rejected events.""" + event = {"type": "signal", "symbol": "BTCUSDT", "action": "BUY", + "price": 50000.0, "strategy": "swing_1"} + result = telegram._fmt_single(event) + + assert result is not None + assert "BTCUSDT" in result