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:
+62
-18
@@ -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 "
|
||||
|
||||
Reference in New Issue
Block a user