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)
|
already = await self.redis.get(dedupe_key)
|
||||||
if already:
|
if already:
|
||||||
continue
|
continue
|
||||||
# Set dedupe key with 24h TTL before evaluation
|
|
||||||
await self.redis.set(dedupe_key, "1", ex=86400)
|
|
||||||
|
|
||||||
# Ensure portfolio state exists
|
# Ensure portfolio state exists
|
||||||
if self.portfolio_state is None:
|
if self.portfolio_state is None:
|
||||||
@@ -697,6 +695,11 @@ class TradingEngine:
|
|||||||
|
|
||||||
# --- Sell path: skip position sizing, look up existing position ---
|
# --- Sell path: skip position sizing, look up existing position ---
|
||||||
if action == "sell":
|
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
|
pos_row = None
|
||||||
try:
|
try:
|
||||||
pos_row = await self.pool.fetchrow(
|
pos_row = await self.pool.fetchrow(
|
||||||
@@ -708,9 +711,12 @@ class TradingEngine:
|
|||||||
logger.debug("Could not look up position for sell: %s", ticker)
|
logger.debug("Could not look up position for sell: %s", ticker)
|
||||||
|
|
||||||
if pos_row is None:
|
if pos_row is None:
|
||||||
logger.info("Sell recommendation for %s but no open position — skipping", ticker)
|
|
||||||
continue
|
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_qty = int(pos_row["quantity"])
|
||||||
sell_price = rec.get("current_price", 0.0)
|
sell_price = rec.get("current_price", 0.0)
|
||||||
estimated_proceeds = sell_qty * sell_price
|
estimated_proceeds = sell_qty * sell_price
|
||||||
@@ -719,6 +725,7 @@ class TradingEngine:
|
|||||||
"trading_decision_id": str(uuid.uuid4()),
|
"trading_decision_id": str(uuid.uuid4()),
|
||||||
"ticker": ticker,
|
"ticker": ticker,
|
||||||
"action": "sell",
|
"action": "sell",
|
||||||
|
"side": "sell",
|
||||||
"quantity": sell_qty,
|
"quantity": sell_qty,
|
||||||
"order_type": "market",
|
"order_type": "market",
|
||||||
"source": "trading_engine",
|
"source": "trading_engine",
|
||||||
@@ -738,13 +745,54 @@ class TradingEngine:
|
|||||||
)
|
)
|
||||||
self.portfolio_state.active_pool += estimated_proceeds
|
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:
|
if rec_id:
|
||||||
self.processed_recommendation_ids.add(rec_id)
|
self.processed_recommendation_ids.add(rec_id)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# --- Buy path: evaluate recommendation through position sizer ---
|
# --- Buy path ---
|
||||||
# Evaluate recommendation
|
# 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(
|
decision = self.evaluate_recommendation(
|
||||||
rec=rec,
|
rec=rec,
|
||||||
portfolio_state=self.portfolio_state,
|
portfolio_state=self.portfolio_state,
|
||||||
@@ -803,12 +851,7 @@ class TradingEngine:
|
|||||||
"(ticker, entry_price, stop_loss_price, take_profit_price, "
|
"(ticker, entry_price, stop_loss_price, take_profit_price, "
|
||||||
"trailing_stop_active, atr_value, atr_multiplier, "
|
"trailing_stop_active, atr_value, atr_multiplier, "
|
||||||
"reward_risk_ratio, signal_confidence, is_micro_trade, active) "
|
"reward_risk_ratio, signal_confidence, is_micro_trade, active) "
|
||||||
"VALUES ($1, $2, $3, $4, FALSE, $5, $6, $7, $8, FALSE, TRUE) "
|
"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()",
|
|
||||||
decision.ticker, price, sl_price, tp_price,
|
decision.ticker, price, sl_price, tp_price,
|
||||||
atr_est, tier.stop_loss_atr_multiplier,
|
atr_est, tier.stop_loss_atr_multiplier,
|
||||||
tier.reward_risk_ratio,
|
tier.reward_risk_ratio,
|
||||||
@@ -1720,16 +1763,17 @@ class TradingEngine:
|
|||||||
async def _check_profit_taking(self) -> None:
|
async def _check_profit_taking(self) -> None:
|
||||||
"""Check positions for profit-taking opportunities.
|
"""Check positions for profit-taking opportunities.
|
||||||
|
|
||||||
Sells positions that have gained more than the take-profit threshold
|
Sells positions that have gained more than the take-profit threshold.
|
||||||
defined by the risk tier's reward_risk_ratio. For moderate tier with
|
Only runs during trading window hours.
|
||||||
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).
|
|
||||||
"""
|
"""
|
||||||
if self.pool is None:
|
if self.pool is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Only sell during market hours
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
if not is_within_trading_window(now):
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
rows = await self.pool.fetch(
|
rows = await self.pool.fetch(
|
||||||
"SELECT ticker, quantity, avg_entry_price, current_price, unrealized_pnl "
|
"SELECT ticker, quantity, avg_entry_price, current_price, unrealized_pnl "
|
||||||
|
|||||||
Reference in New Issue
Block a user