From 29f46d387cd7584dae2d791f090b003bf4095c68 Mon Sep 17 00:00:00 2001 From: Celes Renata Date: Fri, 17 Apr 2026 00:07:50 +0000 Subject: [PATCH] =?UTF-8?q?fix:=206=20buy/sell=20logic=20bugs=20=E2=80=94?= =?UTF-8?q?=20sells=20check=20trading=20window,=20persist=20audit=20trail,?= =?UTF-8?q?=20dedup=20after=20position=20check,=20no=20duplicate=20buys,?= =?UTF-8?q?=20fix=20stop-level=20insert,=20profit-taking=20respects=20mark?= =?UTF-8?q?et=20hours?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/trading/engine.py | 80 +++++++++++++++++++++++++++++--------- 1 file changed, 62 insertions(+), 18 deletions(-) diff --git a/services/trading/engine.py b/services/trading/engine.py index 9dedeee..9a1e4e6 100644 --- a/services/trading/engine.py +++ b/services/trading/engine.py @@ -686,8 +686,6 @@ class TradingEngine: already = await self.redis.get(dedupe_key) if already: continue - # Set dedupe key with 24h TTL before evaluation - await self.redis.set(dedupe_key, "1", ex=86400) # Ensure portfolio state exists if self.portfolio_state is None: @@ -697,6 +695,11 @@ class TradingEngine: # --- Sell path: skip position sizing, look up existing position --- if action == "sell": + # Check trading window for sells too + now_check = datetime.now(tz=timezone.utc) + if not is_within_trading_window(now_check): + continue + pos_row = None try: pos_row = await self.pool.fetchrow( @@ -708,9 +711,12 @@ class TradingEngine: logger.debug("Could not look up position for sell: %s", ticker) if pos_row is None: - logger.info("Sell recommendation for %s but no open position — skipping", ticker) continue + # Set dedup key only after confirming we have a position to sell + if self.redis is not None: + await self.redis.set(trading_dedupe_key(rec_id), "1", ex=86400) + sell_qty = int(pos_row["quantity"]) sell_price = rec.get("current_price", 0.0) estimated_proceeds = sell_qty * sell_price @@ -719,6 +725,7 @@ class TradingEngine: "trading_decision_id": str(uuid.uuid4()), "ticker": ticker, "action": "sell", + "side": "sell", "quantity": sell_qty, "order_type": "market", "source": "trading_engine", @@ -738,13 +745,54 @@ class TradingEngine: ) self.portfolio_state.active_pool += estimated_proceeds - # Mark as processed + # Persist sell decision for audit trail + sell_decision = TradingDecision( + id=order_job["trading_decision_id"], + recommendation_id=rec_id, + decision="act", + skip_reason=None, + ticker=ticker, + computed_position_size=estimated_proceeds, + computed_share_quantity=sell_qty, + risk_tier_at_decision=self._active_risk_tier.name, + portfolio_heat_at_decision=self.portfolio_state.portfolio_heat if self.portfolio_state else 0, + active_pool_at_decision=self.portfolio_state.active_pool if self.portfolio_state else 0, + reserve_pool_at_decision=self.portfolio_state.reserve_pool if self.portfolio_state else 0, + circuit_breaker_status="active" if self._cb_state.active else "inactive", + decision_trace={"action": "sell", "reasoning": [f"Sell {sell_qty} shares of {ticker}"]}, + ) + await self._persist_decision(sell_decision) + + # Deactivate stop levels for sold position + try: + await self.pool.execute( + "UPDATE position_stop_levels SET active = FALSE, updated_at = NOW() WHERE ticker = $1 AND active = TRUE", + ticker, + ) + except Exception: + pass + if rec_id: self.processed_recommendation_ids.add(rec_id) continue - # --- Buy path: evaluate recommendation through position sizer --- - # Evaluate recommendation + # --- Buy path --- + # Set dedup key for buys + if self.redis is not None: + await self.redis.set(trading_dedupe_key(rec_id), "1", ex=86400) + + # Check if we already hold this ticker — don't double up + try: + existing_pos = await self.pool.fetchrow( + "SELECT quantity FROM positions WHERE ticker = $1 AND quantity > 0", + ticker, + ) + if existing_pos: + continue + except Exception: + pass + + # Evaluate recommendation through position sizer decision = self.evaluate_recommendation( rec=rec, portfolio_state=self.portfolio_state, @@ -803,12 +851,7 @@ class TradingEngine: "(ticker, entry_price, stop_loss_price, take_profit_price, " "trailing_stop_active, atr_value, atr_multiplier, " "reward_risk_ratio, signal_confidence, is_micro_trade, active) " - "VALUES ($1, $2, $3, $4, FALSE, $5, $6, $7, $8, FALSE, TRUE) " - "ON CONFLICT (ticker) WHERE active = TRUE DO UPDATE SET " - "entry_price = EXCLUDED.entry_price, " - "stop_loss_price = EXCLUDED.stop_loss_price, " - "take_profit_price = EXCLUDED.take_profit_price, " - "updated_at = NOW()", + "VALUES ($1, $2, $3, $4, FALSE, $5, $6, $7, $8, FALSE, TRUE)", decision.ticker, price, sl_price, tp_price, atr_est, tier.stop_loss_atr_multiplier, tier.reward_risk_ratio, @@ -1720,16 +1763,17 @@ class TradingEngine: async def _check_profit_taking(self) -> None: """Check positions for profit-taking opportunities. - Sells positions that have gained more than the take-profit threshold - defined by the risk tier's reward_risk_ratio. For moderate tier with - 2.0 ATR stop and 1.5 reward/risk, that's roughly a 3% gain target. - - Also sells positions that have gained > 10% regardless of tier - (absolute profit cap to lock in gains). + Sells positions that have gained more than the take-profit threshold. + Only runs during trading window hours. """ if self.pool is None: return + # Only sell during market hours + now = datetime.now(tz=timezone.utc) + if not is_within_trading_window(now): + return + try: rows = await self.pool.fetch( "SELECT ticker, quantity, avg_entry_price, current_price, unrealized_pnl "