fix: 6 buy/sell logic bugs — sells check trading window, persist audit trail, dedup after position check, no duplicate buys, fix stop-level insert, profit-taking respects market hours

This commit is contained in:
Celes Renata
2026-04-17 00:07:50 +00:00
parent 1246b3868b
commit 29f46d387c
+62 -18
View File
@@ -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 "