TradingAgents/tradingagents/agents/execution_gatekeeper.py

330 lines
15 KiB
Python

import json
import logging
from typing import Dict, Any, Optional
from datetime import datetime, timezone, timedelta
# V2 Spec Imports
from tradingagents.agents.utils.agent_states import (
AgentState,
ExecutionResult,
FinalDecision,
TraderDecision
)
from tradingagents.utils.logger import app_logger as logger
class ExecutionGatekeeper:
"""
PHASE 2: The Omnipotent Gatekeeper (HARDENED V2.5).
Separates 'Decision Generation' (LLM) from 'Decision Authorization' (Python).
Responsibilities:
1. Compliance (Restricted Lists, Insider Data).
2. Divergence Checks (Epistemic Uncertainty). - FIXED MATH
3. Trend Override ("Don't Fight the Tape").
4. Direction Consensus (Trader vs Analysts). - ADDED
5. Data Freshness Re-Verification. - ADDED
"""
def __init__(self):
self.RESTRICTED_LIST = ["GME", "AMC"]
self.DIVERGENCE_THRESHOLD = 0.5
self.MAX_DATA_AGE_MINUTES = 15
# Rule Parameters
self.INSIDER_SELL_LIMIT = -50_000_000 # -$50M
self.STOP_LOSS_THRESHOLD = -0.10 # -10%
self.HYPER_GROWTH_THRESHOLD = 0.30 # 30% YoY
def _check_compliance(self, ticker: str, ledger: Dict) -> Optional[ExecutionResult]:
"""Returns ABORT_COMPLIANCE if validation fails."""
if ticker.upper() in self.RESTRICTED_LIST:
logger.warning(f"⛔ GATEKEEPER: {ticker} is on Restricted List.")
return ExecutionResult.ABORT_COMPLIANCE
return None
def _validate_freshness(self, ledger: Dict) -> Optional[ExecutionResult]:
"""
CRITICAL: Re-verify data age at execution time.
Prevents executing on old data if the graph took too long.
"""
if not ledger: return ExecutionResult.ABORT_DATA_GAP
try:
created_at_str = ledger.get("created_at")
if not created_at_str: return ExecutionResult.ABORT_DATA_GAP
# Parse ISO8601
created_at = datetime.fromisoformat(created_at_str)
now = datetime.now(timezone.utc)
age = (now - created_at).total_seconds() / 60
if age > self.MAX_DATA_AGE_MINUTES:
logger.error(f"Gatekeeper: Data Expired! Age: {age:.1f}m > Limit: {self.MAX_DATA_AGE_MINUTES}m")
return ExecutionResult.ABORT_DATA_GAP
except Exception as e:
logger.warning(f"Gatekeeper Freshness Check Error: {e}")
return ExecutionResult.ABORT_DATA_GAP
return None
def _calculate_divergence(self, bull_score: float, bear_score: float, mean_conf: float) -> float:
"""
FIXED FORMULA: abs(Bull - Bear) * Mean_Analyst_Confidence
"If analysts strongly disagree AND are confident, it's a Blind Spot."
"""
raw_diff = abs(bull_score - bear_score)
return raw_diff * mean_conf
def _check_direction_consensus(self, action: str, bull_conf: float, bear_conf: float) -> Optional[ExecutionResult]:
"""
RULE: If Trader opposes the Strong Consensus, ABORT.
"""
consensus_direction = "NEUTRAL"
consensus_strength = abs(bull_conf - bear_conf)
if bull_conf > (bear_conf + 0.2):
consensus_direction = "BUY"
elif bear_conf > (bull_conf + 0.2):
consensus_direction = "SELL"
# Check Mismatch
if action == "BUY" and consensus_direction == "SELL":
logger.warning(f"🛑 GATEKEEPER: DIRECTION MISMATCH. Trader=BUY, Consensus=SELL (Conf Gap {consensus_strength:.2f})")
return ExecutionResult.ABORT_DIVERGENCE # Or define ABORT_DIRECTION_MISMATCH if in Enum
if action == "SELL" and consensus_direction == "BUY":
logger.warning(f"🛑 GATEKEEPER: DIRECTION MISMATCH. Trader=SELL, Consensus=BUY (Conf Gap {consensus_strength:.2f})")
return ExecutionResult.ABORT_DIVERGENCE
return None
def _check_trend_override(self, action: str, regime: str, technicals: Dict, bull_c: float, bear_c: float) -> Optional[ExecutionResult]:
"""
Deterministic Trend Override ("Don't Fight the Tape").
INTEGRATED RULE: Protect Hyper-Growth stocks in Uptrends.
REVERSAL EXCEPTION: If consensus strength > 0.8, allow fighting the tape.
"""
regime_upper = regime.upper()
action_upper = action.upper()
# 1. Detect Conflict
is_conflict = (action_upper == "SELL" and "TRENDING_UP" in regime_upper) or \
(action_upper == "BUY" and "TRENDING_DOWN" in regime_upper)
if not is_conflict:
return None
# 2. Reversal Exception (High Consensus)
consensus_strength = abs(bull_c - bear_c)
if consensus_strength > 0.8:
logger.info(f"⚖️ GATEKEEPER: REVERSAL EXCEPTION. Fighting {regime_upper} due to Ultra-High Consensus ({consensus_strength:.2f}).")
return None # Allow it
# 3. Institutional Rule (Hyper-Growth Protection)
# IF (Regime == BULL) AND (Price > 200SMA) AND (Growth > 30%): BLOCK_SELL
sma_200 = technicals.get("sma_200", 0)
price = technicals.get("current_price", 0) # DataRegistrar provides price in technicals or we pull from raw
growth = technicals.get("revenue_growth", 0)
# Note: In DataRegistrar we added sma_200, sma_50, rsi_14, revenue_growth.
# We also need the 'current_price' which is the last close.
if action_upper == "SELL" and regime_upper in ["TRENDING_UP", "BULL"]:
if sma_200 > 0 and growth > self.HYPER_GROWTH_THRESHOLD:
# We assume prices_series[-1] was used for sma calc, so it fits the lock.
# If we don't have current_price in technicals, we'll assume it met the SMA check in Registrar.
logger.warning(f"🛑 GATEKEEPER: Blocked SELL into Hyper-Growth Uptrend ({growth:.1%}).")
return ExecutionResult.BLOCKED_TREND
# Otherwise, standard block
logger.warning(f"🛑 GATEKEEPER: Blocked {action_upper} into {regime_upper}. Consensus too weak to call reversal.")
return ExecutionResult.BLOCKED_TREND
def _fetch_pulse_price(self, ticker: str) -> Optional[float]:
"""[SENIOR] Fetch 'Instant' price with strict timeout to prevent hangs."""
try:
import yfinance as yf
import requests
# Use a faster, lighter approach if possible or strict timeout
t = yf.Ticker(ticker)
# Fetch with a very short window
hist = t.history(period="1d", interval="1m", timeout=2) # 2s timeout
if not hist.empty:
return float(hist["Close"].iloc[-1])
# Fast fallback to info (often cached)
info = t.info
return float(info.get("regularMarketPrice") or info.get("previousClose") or 0.0)
except Exception as e:
logger.warning(f"⚠️ GATEKEEPER Pulse Check Restricted: {e}")
return None
def _is_market_open(self) -> bool:
"""[SENIOR] Abort if trading outside of market hours."""
now = datetime.now(timezone.utc)
# Simple NYSE hours check (14:30 - 21:00 UTC)
# Weekends
if now.weekday() >= 5: return False
# Hours (9:30 AM - 4:00 PM EST)
# EST is typically UTC-5
hour = now.hour
minute = now.minute
utc_total_minutes = hour * 60 + minute
# 14:30 UTC to 21:00 UTC
return 870 <= utc_total_minutes <= 1260
def _check_temporal_drift(self, ticker: str, ledger_price: float) -> Optional[ExecutionResult]:
"""Abort if live price has drifted > 3% from frozen ledger reality."""
instant_price = self._fetch_pulse_price(ticker)
if not instant_price or ledger_price <= 0:
return None # Fail-safe: If we can't pulse, we trust the ledger
drift = abs(instant_price - ledger_price) / ledger_price
# Split Check: Abort on massive drift (potential corporate action)
if drift > 0.5:
logger.error(f"🔥 GATEKEEPER CRITICAL: Massive Drift ({drift:.1%}). Possible Split/Black Swan. ABORTING.")
return "MASSIVE_DRIFT" # Return string for unique handling
if drift > 0.03:
logger.warning(f"🛑 GATEKEEPER: Temporal Drift Alert ({drift:.1%}). Reality @ ${ledger_price:.2f}, Market @ ${instant_price:.2f}.")
return ExecutionResult.ABORT_STALE_DATA
return None
def _check_insider_veto(self, technicals: Dict, ledger: Dict) -> Optional[ExecutionResult]:
"""Rule B: Insider Selling > $50M into Downtrend (< 50SMA)."""
# [SENIOR] Use deterministic float math from Registrar
flow = ledger.get("net_insider_flow_usd")
if flow is None:
return ExecutionResult.ABORT_DATA_GAP
if flow < self.INSIDER_SELL_LIMIT:
price = technicals.get("current_price", 0)
sma_50 = technicals.get("sma_50", 0)
if price < sma_50:
logger.warning(f"🛑 GATEKEEPER: Insider Veto. Net Flow {flow/1e6:.1f}M into Downtrend.")
return ExecutionResult.ABORT_COMPLIANCE
return None
def _check_stop_loss(self, ticker: str, portfolio: Dict, technicals: Dict) -> Optional[ExecutionResult]:
"""Rule 72: Hard Stop Loss at -10%."""
if ticker not in portfolio: return None
pos = portfolio[ticker]
cost = pos.get("average_cost", 0)
if cost <= 0: return None
# Use the 'Frozen' price from technicals
price = technicals.get("current_price", 0)
if price <= 0: return None
pnl = (price - cost) / cost
if pnl < self.STOP_LOSS_THRESHOLD:
logger.warning(f"🚨 GATEKEEPER: RULE 72 Stop Loss ({pnl:.1%}). Proposing EXIT.")
# Forced Liquidation
return ExecutionResult.APPROVED # We approve the trade if it's a SELL, or force state change.
# Wait, if the Trader proposes SELL anyway, we just approve.
# If they propose BUY/HOLD, we might need a more complex override.
# For now, let's just flag it in logs.
return None
def run(self, state: AgentState) -> Dict[str, Any]:
"""
Main execution node.
"""
logger.info("🛡️ EXECUTION GATEKEEPER: Authorizing Trade... [V2.5]")
# 1. Extract Inputs
trader_decision: TraderDecision = state.get("trader_decision")
if not trader_decision:
return self._finalize(ExecutionResult.ABORT_DATA_GAP, "NO_OP", 0.0, "Missing Input")
ledger: Dict = state.get("fact_ledger")
if not ledger:
return self._finalize(ExecutionResult.ABORT_DATA_GAP, "NO_OP", 0.0, "Missing Ledger")
action = trader_decision.get("action", "HOLD")
confidence = trader_decision.get("confidence", 0.0)
ticker = state.get("company_of_interest", "UNKNOWN")
regime = ledger.get("regime", "UNKNOWN") # EXTRACT FROM LEDGER (Frozen)
technicals = ledger.get("technicals", {}) # EXTRACT FROM LEDGER
portfolio = state.get("portfolio", {})
bull_c = state.get("bull_confidence", 0.5)
bear_c = state.get("bear_confidence", 0.5)
# 2. Compliance & Market Hours
if not self._is_market_open():
logger.warning("🕒 GATEKEEPER: Market Closed. Aborting.")
return self._finalize(ExecutionResult.ABORT_COMPLIANCE, "NO_OP", 0.0, "Market Closed")
if self._check_compliance(ticker, ledger) == ExecutionResult.ABORT_COMPLIANCE:
return self._finalize(ExecutionResult.ABORT_COMPLIANCE, "NO_OP", 0.0, "Compliance Block")
# Stop Loss Logic
sl_res = self._check_stop_loss(ticker, portfolio, technicals)
if sl_res and action != "SELL":
# Force a SELL if not already selling
logger.warning("🚨 GATEKEEPER: Overriding Trade for Stop Loss Liquidation.")
return self._finalize(ExecutionResult.APPROVED, "SELL", 1.0, "Rule 72 Stop Loss")
# 3. Data Freshness & Data Gaps (Phase 2.6)
freshness_res = self._validate_freshness(ledger)
if freshness_res:
return self._finalize(freshness_res, "NO_OP", 0.0, "Data Expired/Missing")
# Rule B: Insider Veto & Data Gaps
insider_res = self._check_insider_veto(technicals, ledger)
if insider_res:
reason = "Critical Insider Data Gap" if insider_res == ExecutionResult.ABORT_DATA_GAP else "Insider Veto: High Selling into Downtrend"
return self._finalize(insider_res, "NO_OP", 0.0, reason)
# Pulse Check for Temporal Drift
pulse_res = self._check_temporal_drift(ticker, technicals.get("current_price", 0))
if pulse_res:
reason = "Massive Drift (Corporate Action?)" if pulse_res == "MASSIVE_DRIFT" else "Pulse Check: Temporal Drift > 3%"
final_status = ExecutionResult.ABORT_STALE_DATA
return self._finalize(final_status, "NO_OP", 0.0, reason)
# 4. Consensus Divergence (Hardened Math)
mean_analyst_conf = (bull_c + bear_c) / 2.0
divergence = self._calculate_divergence(bull_c, bear_c, mean_analyst_conf)
if divergence > self.DIVERGENCE_THRESHOLD:
logger.warning(f"Gatekeeper: High Divergence ({divergence:.2f}). Aborting.")
return self._finalize(ExecutionResult.ABORT_DIVERGENCE, "NO_OP", 0.0, f"Divergence {divergence:.2f}")
# 5. Direction Mismatch
dir_res = self._check_direction_consensus(action, bull_c, bear_c)
if dir_res:
return self._finalize(dir_res, "NO_OP", 0.0, "Direction Mismatch")
if self._check_trend_override(action, regime, technicals, bull_c, bear_c) == ExecutionResult.BLOCKED_TREND:
return self._finalize(ExecutionResult.BLOCKED_TREND, "HOLD", 0.0, "Trend Protection")
# 7. Low Confidence Abort
if confidence < 0.6:
return self._finalize(ExecutionResult.ABORT_LOW_CONFIDENCE, "NO_OP", 0.0, "Confidence < 0.6")
# 8. APPROVED
logger.info(f"✅ GATEKEEPER: Trade APPROVED -> {action} ({confidence})")
return self._finalize(ExecutionResult.APPROVED, action, confidence, trader_decision.get("rationale"))
def _finalize(self, status: ExecutionResult, action: str, conf: float, details: Any) -> Dict:
return {
"final_trade_decision": {
"status": status,
"action": action,
"confidence": conf,
"details": {"reason": str(details)}
}
}
def create_execution_gatekeeper():
gatekeeper = ExecutionGatekeeper()
return gatekeeper.run