e02a7e3453
CRITICAL FIXES (would have prevented the $194K crisis): 1. HARD MAX SHORT POSITION CAP (safety.py + executor.py) - new config: max_short_positions=4 (hard limit) - new config: max_total_positions=8 (hard limit) - executor now queries broker.get_positions() for AUTHORITATIVE count - BEFORE opening any short, checks broker directly (not just store) - validate_trade() now accepts broker_positions and blocks at hard cap - This directly prevents the 13-shorts scenario 2. YESTERDAY-CLOSE DRAWDC OWN CIRCUIT BREAKER (safety.py) - new config: yesterday_close_drawdown_limit=3% - new config: yesterday_close_drawdown_reduction=50% - Separate from peak-equity tracking (which is too slow) - At $194K from $200K initial = 3% → fires IMMEDIATELY, cuts 50% of positions - Tested: check_yesterday_close_drawdown($194K) → triggers with 50% reduction 3. PER-POSITION STOP LOSS DEFAULTS (safety.py + executor.py) - new config: stop_loss_default_pct_long=1.0%, short=1.5% - new config: stop_loss_max_pct=3.0% (never wider than this) - new config: take_profit_default_pct=2.0% - NEW: get_default_stop_loss() + get_default_take_profit() methods - executor.execute_signal() now ALWAYS sets stop loss (even if GA provides None) - Previously stops were often None → exits never triggered - Tested: get_default_stop_loss($100, 'short') = $101.50 ✓ 4. BROKER/STORE SYNC VALIDATION (safety.py + main_auto.py) - new: validate_broker_position_count() detects discrepancies - new: _trade_symbol() now passes combined_equity to executor - new: _trading_cycle() validates broker vs store BEFORE evaluating new trades - Logs warning when broker has positions not tracked in store - Blocks new trades if broker position count already at hard cap 5. CRISIS MODE: 10+ LOSING POSITIONS (executor.py check_exits) - If 8+ of 10+ positions are losing money → force reduce 50% of ALL positions - Catches cascading blowups before drawdown thresholds are hit - Logs CRITICAL warning when triggered 6. DEFAULT STOP LOSS ENFORCEMENT (executor.py) - _enforce_stop_loss_tightness() was overriding None stops to None - Now executor ALWAYS applies safety defaults if GA provides no stop - Every new position gets a stop loss on entry Config changes (auto_config.json): - Added max_short_positions, max_total_positions - Added stop_loss_default_pct_long/short, take_profit_default_pct - Added stop_loss_atr_multiplier, stop_loss_max_pct - Added yesterday_close_drawdown_limit=3%, yesterday_close_drawdown_reduction=50% HOW THIS WOULD HAVE HELPED THE $194,940 PORTFOLIO: - At $194,940 from $200K = 2.53% drawdown from initial - If yesterday closed at $200K: 2.53% < 3% limit → NOT triggered - But at open today if equity dropped to $194,000 → 3.00% → TRIGGERS IMMEDIATELY - Hard cap at 4 shorts: after 4 shorts, executor blocks action 5/6 - Stop losses: each of the 4 shorts would have had 1.5% stop → 2 shorts would have been stopped out before they lost further, limiting damage - Crisis mode: if 8 positions were losing, 50% of all positions closed
735 lines
31 KiB
Python
735 lines
31 KiB
Python
"""
|
|
Trade Executor
|
|
Executes live trades via Alpaca broker with position management.
|
|
|
|
ENHANCED with:
|
|
- Proper correlation-based position limits (using max_correlated_positions config)
|
|
- Drawdown-scaled position sizing
|
|
- Enforced stop losses with tighter defaults
|
|
- Active position reduction on circuit breaker / drawdown stop
|
|
- Per-sector position limits
|
|
- HARD maximum SHORT position cap (broker-level enforcement)
|
|
- Per-position stop loss defaults (ATR-based, always set)
|
|
- Broker-position sync check (authoritative position counting)
|
|
- Yesterday-close drawdown circuit breaker (faster response)
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from typing import Dict, List, Optional, Tuple
|
|
from loguru import logger
|
|
import numpy as np
|
|
|
|
|
|
class TradingExecutor:
|
|
"""
|
|
Executes live trades based on RL agent decisions.
|
|
Manages positions, stop losses, and take profits.
|
|
"""
|
|
|
|
def __init__(self, broker, store, safety, config: Dict):
|
|
"""
|
|
Args:
|
|
broker: AlpacaBroker instance
|
|
store: DataStore instance
|
|
safety: SafetyManager instance
|
|
config: Trading configuration dict
|
|
"""
|
|
self.broker = broker
|
|
self.store = store
|
|
self.safety = safety
|
|
self.config = config
|
|
self.commission_rate = config.get('commission_rate', 0.001)
|
|
|
|
# Stop loss / take profit defaults (can be overridden by strategy)
|
|
self.default_stop_loss_pct = config.get('stop_loss_pct', 1.0) / 100
|
|
self.default_take_profit_pct = config.get('take_profit_pct', 1.5) / 100
|
|
|
|
# Track recent prices for volatility calculation
|
|
self.recent_returns: Dict[str, List[float]] = {}
|
|
|
|
# Sector map for correlation
|
|
self.sector_map = {
|
|
'SPY': 'broad_market', 'QQQ': 'tech', 'IWM': 'small_cap', 'DIA': 'blue_chip',
|
|
'AAPL': 'tech', 'MSFT': 'tech', 'GOOGL': 'tech', 'AMZN': 'tech', 'NVDA': 'tech', 'META': 'tech',
|
|
'TSLA': 'auto/energy',
|
|
'JPM': 'financials', 'BAC': 'financials', 'GS': 'financials', 'MS': 'financials',
|
|
'JNJ': 'healthcare', 'UNH': 'healthcare',
|
|
'WMT': 'consumer', 'PG': 'consumer',
|
|
'XLE': 'energy', 'CVX': 'energy', 'XOM': 'energy',
|
|
'LMT': 'defense', 'RTX': 'defense', 'NOC': 'defense', 'GD': 'defense',
|
|
'USO': 'commodities', 'GLD': 'metals',
|
|
}
|
|
|
|
def _get_sector(self, symbol: str) -> str:
|
|
return self.sector_map.get(symbol, 'other')
|
|
|
|
def _update_volatility(self, symbol: str, current_price: float):
|
|
"""Track price returns for volatility calculation"""
|
|
if symbol not in self.recent_returns:
|
|
self.recent_returns[symbol] = []
|
|
# Returns are tracked externally; just store prices
|
|
# Volatility is computed by safety manager
|
|
|
|
def _apply_drawdown_scaling(self, invest_amount: float, portfolio_value: float) -> float:
|
|
"""Scale down position size based on current drawdown"""
|
|
scale = self.safety.get_position_scale(portfolio_value)
|
|
vol_mult = self.safety.current_volatility_mult
|
|
return invest_amount * scale * vol_mult
|
|
|
|
def _check_correlation_limits(self, symbol: str, direction: str,
|
|
open_positions: List[Dict]) -> Tuple[bool, str]:
|
|
"""
|
|
Check if adding this position would violate correlation limits.
|
|
|
|
Returns: (allowed: bool, reason: str)
|
|
"""
|
|
max_corr = self.safety.max_correlated_positions
|
|
max_same_dir = self.safety.max_same_direction
|
|
|
|
# Count current positions in same direction
|
|
same_dir_positions = [
|
|
p for p in open_positions
|
|
if p.get('metadata', {}).get('direction') == direction
|
|
]
|
|
|
|
# Check same-direction limit
|
|
if len(same_dir_positions) >= max_same_dir:
|
|
return False, (f"Max {max_same_dir} {direction} positions reached "
|
|
f"(have {len(same_dir_positions)})")
|
|
|
|
# Check sector correlation limit
|
|
sector = self._get_sector(symbol)
|
|
if sector != 'other' and sector != 'broad_market':
|
|
same_sector_same_dir = [
|
|
p for p in same_dir_positions
|
|
if self._get_sector(p.get('symbol', '')) == sector
|
|
]
|
|
if len(same_sector_same_dir) >= max_corr:
|
|
return False, (f"Max {max_corr} {direction} positions in "
|
|
f"{sector} sector (have {len(same_sector_same_dir)})")
|
|
|
|
# Also limit broad market ETF correlation (SPY, QQQ, IWM, DIA all correlate)
|
|
if symbol in ('SPY', 'QQQ', 'IWM', 'DIA'):
|
|
market_etfs = [p for p in same_dir_positions if p.get('symbol') in ('SPY', 'QQQ', 'IWM', 'DIA')]
|
|
if len(market_etfs) >= 2:
|
|
return False, f"Max 2 market ETFs as {direction} positions (have {len(market_etfs)})"
|
|
|
|
return True, "OK"
|
|
|
|
def _enforce_stop_loss_tightness(self, stop_loss: float, entry_price: float,
|
|
direction: str) -> float:
|
|
"""
|
|
Ensure stop loss is tight enough - don't let wide stops blow up risk.
|
|
If strategy provides too-wide stop, override with our default.
|
|
"""
|
|
if stop_loss is None:
|
|
return None
|
|
|
|
if direction == 'short':
|
|
# Short stop: price goes UP to hit stop (bad)
|
|
stop_distance_pct = (stop_loss - entry_price) / entry_price
|
|
else:
|
|
# Long stop: price goes DOWN to hit stop (bad)
|
|
stop_distance_pct = (entry_price - stop_loss) / entry_price
|
|
|
|
# If strategy stop is wider than 3x our default, use our default instead
|
|
if stop_distance_pct > self.default_stop_loss_pct * 3:
|
|
logger.warning(f"Stop loss {stop_distance_pct:.2%} too wide, "
|
|
f"using default {self.default_stop_loss_pct:.2%}")
|
|
if direction == 'short':
|
|
return entry_price * (1 + self.default_stop_loss_pct)
|
|
else:
|
|
return entry_price * (1 - self.default_stop_loss_pct)
|
|
|
|
return stop_loss
|
|
|
|
def execute_signal(self, symbol: str, action: int, current_price: float,
|
|
strategy_params: Dict = None, combined_equity: float = None) -> Optional[Dict]:
|
|
"""
|
|
Execute a trading action from the RL agent.
|
|
|
|
Actions:
|
|
0: Hold
|
|
1: Buy 25% of available capital (go long)
|
|
2: Buy 50% of available capital (go long)
|
|
3: Close 50% of position (long or short)
|
|
4: Close 100% of position (long or short)
|
|
5: Short 25% of available capital
|
|
6: Short 50% of available capital
|
|
|
|
Args:
|
|
combined_equity: Optional total equity across all brokers (for multi-broker setups)
|
|
If provided, used for safety checks instead of single-broker equity
|
|
|
|
Returns: trade record dict, or None if no action taken
|
|
"""
|
|
if action == 0: # Hold
|
|
return None
|
|
|
|
if current_price <= 0:
|
|
return None
|
|
|
|
strategy_params = strategy_params or {}
|
|
|
|
# Get portfolio state
|
|
try:
|
|
portfolio = self.broker.get_portfolio()
|
|
equity = portfolio['equity']
|
|
cash = portfolio['cash']
|
|
except Exception as e:
|
|
logger.error(f"Error getting portfolio: {e}")
|
|
return None
|
|
|
|
# Use combined equity for safety checks if provided (multi-broker setup)
|
|
safety_equity = combined_equity if combined_equity is not None else equity
|
|
|
|
# Update safety peak
|
|
self.safety.update_peak_equity(safety_equity)
|
|
|
|
# Get current open positions from STORE (known trades)
|
|
open_positions = self.store.get_open_positions()
|
|
open_for_symbol = [p for p in open_positions if p['symbol'] == symbol]
|
|
|
|
# CRITICAL: Get ACTUAL broker positions (authoritative) for hard cap checks
|
|
# Store only knows what WE opened — broker knows everything (including manual trades)
|
|
try:
|
|
broker_positions = self.broker.get_positions()
|
|
except Exception as e:
|
|
logger.warning(f"Could not get broker positions for hard cap check: {e}")
|
|
broker_positions = []
|
|
|
|
# HARD SHORT CAP CHECK: If broker already has max_short_positions shorts, block more
|
|
# This is the PRIMARY defense against the "13 shorts" scenario
|
|
broker_short_count = sum(
|
|
1 for p in broker_positions
|
|
if p.get('side') == 'sell_short' or p.get('qty', 0) < 0
|
|
)
|
|
if action in (5, 6) and broker_short_count >= self.safety.max_short_positions:
|
|
logger.warning(
|
|
f"HARD CAP: Broker has {broker_short_count} shorts "
|
|
f"(max={self.safety.max_short_positions}). Short blocked."
|
|
)
|
|
return None
|
|
|
|
# BROKER POSITION SYNC WARNING
|
|
store_short_count = sum(
|
|
1 for p in open_positions
|
|
if p.get('metadata', {}).get('direction') == 'short'
|
|
)
|
|
if broker_short_count != store_short_count:
|
|
logger.warning(
|
|
f"BROKER/STORE SYNC MISMATCH: Broker={broker_short_count} shorts, "
|
|
f"Store={store_short_count} shorts. Using broker as authoritative."
|
|
)
|
|
|
|
# BUY LONG actions
|
|
if action in (1, 2):
|
|
if open_for_symbol:
|
|
logger.debug(f"Already have position in {symbol}, skipping buy")
|
|
return None
|
|
|
|
# CORRELATION CHECK: Don't pile into same direction (uses store positions)
|
|
allowed, reason = self._check_correlation_limits(symbol, 'long', open_positions)
|
|
if not allowed:
|
|
logger.debug(f"Correlation blocked LONG {symbol}: {reason}")
|
|
return None
|
|
|
|
pct = 0.25 if action == 1 else 0.50
|
|
invest = cash * pct
|
|
|
|
# Apply drawdown + volatility scaling
|
|
invest = self._apply_drawdown_scaling(invest, safety_equity)
|
|
|
|
max_position_pct = self.config.get('max_position_pct', 8) / 100
|
|
effective_max = max_position_pct * self.safety.get_position_scale(safety_equity)
|
|
max_invest = safety_equity * effective_max
|
|
if invest > max_invest:
|
|
invest = max_invest
|
|
|
|
if invest < self.config.get('min_trade_value', 50):
|
|
logger.debug(f"Invest amount ${invest:.2f} below minimum")
|
|
return None
|
|
|
|
shares = int(invest / current_price)
|
|
if shares < 1:
|
|
# Try fractional
|
|
shares = round(invest / current_price, 4)
|
|
if shares * current_price < self.config.get('min_trade_value', 50):
|
|
logger.debug(f"Share value ${shares * current_price:.2f} below minimum")
|
|
return None
|
|
|
|
allowed, reason = self.safety.validate_trade(
|
|
symbol, 'buy', shares, current_price, safety_equity, open_positions,
|
|
broker_positions=broker_positions
|
|
)
|
|
if not allowed:
|
|
logger.debug(f"Trade blocked by safety: {reason}")
|
|
return None
|
|
|
|
try:
|
|
order = self.broker.place_market_order(symbol, shares, 'buy')
|
|
logger.info(f"BUY {shares} {symbol} @ ~${current_price:.4f}")
|
|
except Exception as e:
|
|
logger.error(f"Error placing buy order: {e}")
|
|
return None
|
|
|
|
# Stop loss and take profit with tightness enforcement + defaults
|
|
stop_loss = strategy_params.get('stop_loss')
|
|
take_profit = strategy_params.get('take_profit')
|
|
|
|
# CRITICAL: If GA didn't provide stop loss, use SAFETY MANAGER DEFAULTS
|
|
# This ensures EVERY position has a stop loss — the #1 defense against blowup
|
|
if stop_loss is None:
|
|
stop_loss = self.safety.get_default_stop_loss(current_price, 'long')
|
|
logger.info(f"Applying DEFAULT stop loss for LONG {symbol}: ${stop_loss:.4f} "
|
|
f"({((current_price - stop_loss) / current_price) * 100:.2f}% from entry)")
|
|
|
|
# Enforce tightness ceiling on the stop
|
|
stop_loss = self._enforce_stop_loss_tightness(stop_loss, current_price, 'long')
|
|
|
|
# CRITICAL: If GA didn't provide take profit, use SAFETY MANAGER DEFAULTS
|
|
if take_profit is None or take_profit == current_price:
|
|
take_profit = self.safety.get_default_take_profit(current_price, 'long')
|
|
logger.info(f"Applying DEFAULT take profit for LONG {symbol}: ${take_profit:.4f}")
|
|
|
|
if take_profit and take_profit != current_price:
|
|
tp_distance = (take_profit - current_price) / current_price
|
|
if tp_distance < self.default_take_profit_pct * 0.5:
|
|
# TP too tight relative to default
|
|
take_profit = current_price * (1 + self.default_take_profit_pct)
|
|
|
|
trade = {
|
|
'symbol': symbol,
|
|
'side': 'buy',
|
|
'amount': shares,
|
|
'entry_price': current_price,
|
|
'entry_time': datetime.utcnow().isoformat(),
|
|
'strategy_id': strategy_params.get('strategy_id', 'auto'),
|
|
'stop_loss': stop_loss,
|
|
'take_profit': take_profit,
|
|
'order_id': str(order.get('id', '')),
|
|
'status': 'open',
|
|
'metadata': {
|
|
'action': action,
|
|
'invest_pct': pct,
|
|
'equity_at_entry': equity,
|
|
'direction': 'long',
|
|
'sector': self._get_sector(symbol),
|
|
},
|
|
}
|
|
trade_id = self.store.record_trade(trade)
|
|
trade['id'] = trade_id
|
|
return trade
|
|
|
|
# CLOSE POSITION actions (long or short)
|
|
elif action in (3, 4):
|
|
if not open_for_symbol:
|
|
return None
|
|
|
|
position = open_for_symbol[0]
|
|
total_shares = position['amount']
|
|
is_short = position.get('metadata', {}).get('direction') == 'short'
|
|
|
|
if action == 3:
|
|
close_shares = abs(total_shares) * 0.5
|
|
else:
|
|
close_shares = abs(total_shares)
|
|
|
|
close_shares = round(close_shares, 4)
|
|
if close_shares * current_price < self.config.get('min_trade_value', 50):
|
|
close_shares = abs(total_shares)
|
|
|
|
close_side = 'buy' if is_short else 'sell'
|
|
|
|
try:
|
|
order = self.broker.place_market_order(symbol, close_shares, close_side)
|
|
logger.info(f"CLOSE({close_side.upper()}) {close_shares} {symbol} @ ~${current_price:.4f}")
|
|
except Exception as e:
|
|
logger.error(f"Error placing close order: {e}")
|
|
return None
|
|
|
|
# Calculate P&L
|
|
if is_short:
|
|
pnl = (position['entry_price'] - current_price) * close_shares
|
|
else:
|
|
pnl = (current_price - position['entry_price']) * close_shares
|
|
|
|
if close_shares >= abs(total_shares) * 0.99:
|
|
self.store.close_position(
|
|
position['id'], current_price, datetime.utcnow(),
|
|
fees=close_shares * current_price * self.commission_rate
|
|
)
|
|
self.safety.record_trade_result(pnl)
|
|
|
|
return {
|
|
'symbol': symbol,
|
|
'side': close_side,
|
|
'amount': close_shares,
|
|
'exit_price': current_price,
|
|
'pnl': round(pnl, 2),
|
|
'pnl_pct': round(pnl / (position['entry_price'] * close_shares) * 100, 2),
|
|
'action': action,
|
|
'direction': 'short' if is_short else 'long',
|
|
}
|
|
else:
|
|
self.safety.record_trade_result(pnl * (close_shares / abs(total_shares)))
|
|
self.store.reduce_position(
|
|
position['id'], close_shares, current_price, datetime.utcnow(),
|
|
fees=close_shares * current_price * self.commission_rate
|
|
)
|
|
|
|
return {
|
|
'symbol': symbol,
|
|
'side': close_side,
|
|
'amount': close_shares,
|
|
'exit_price': current_price,
|
|
'pnl': round(pnl, 2),
|
|
'action': action,
|
|
'direction': 'short' if is_short else 'long',
|
|
'reduced': True,
|
|
}
|
|
|
|
# SHORT actions
|
|
elif action in (5, 6):
|
|
if open_for_symbol:
|
|
logger.debug(f"Already have position in {symbol}, skipping short")
|
|
return None
|
|
|
|
# CORRELATION CHECK
|
|
allowed, reason = self._check_correlation_limits(symbol, 'short', open_positions)
|
|
if not allowed:
|
|
logger.debug(f"Correlation blocked SHORT {symbol}: {reason}")
|
|
return None
|
|
|
|
pct = 0.25 if action == 5 else 0.50
|
|
invest = cash * pct
|
|
|
|
# Apply drawdown + volatility scaling
|
|
invest = self._apply_drawdown_scaling(invest, safety_equity)
|
|
|
|
max_position_pct = self.config.get('max_position_pct', 8) / 100
|
|
effective_max = max_position_pct * self.safety.get_position_scale(safety_equity)
|
|
max_invest = safety_equity * effective_max
|
|
if invest > max_invest:
|
|
invest = max_invest
|
|
|
|
if invest < self.config.get('min_trade_value', 50):
|
|
logger.debug(f"Short invest amount ${invest:.2f} below minimum")
|
|
return None
|
|
|
|
shares = int(invest / current_price)
|
|
if shares < 1:
|
|
shares = round(invest / current_price, 4)
|
|
if shares * current_price < self.config.get('min_trade_value', 50):
|
|
logger.debug(f"Short share value ${shares * current_price:.2f} below minimum")
|
|
return None
|
|
|
|
allowed, reason = self.safety.validate_trade(
|
|
symbol, 'sell', shares, current_price, safety_equity, open_positions,
|
|
broker_positions=broker_positions
|
|
)
|
|
if not allowed:
|
|
logger.debug(f"Short blocked by safety: {reason}")
|
|
return None
|
|
|
|
try:
|
|
order = self.broker.place_market_order(symbol, shares, 'sell')
|
|
logger.info(f"SHORT {shares} {symbol} @ ~${current_price:.4f}")
|
|
except Exception as e:
|
|
logger.error(f"Error placing short order: {e}")
|
|
return None
|
|
|
|
# For shorts: stop_loss is ABOVE entry (price rises = bad), take_profit is BELOW
|
|
stop_loss = strategy_params.get('short_stop_loss') or strategy_params.get('stop_loss')
|
|
take_profit = strategy_params.get('short_take_profit') or strategy_params.get('take_profit')
|
|
|
|
# CRITICAL: If GA didn't provide stop loss, use SAFETY MANAGER DEFAULTS
|
|
if stop_loss is None:
|
|
stop_loss = self.safety.get_default_stop_loss(current_price, 'short')
|
|
logger.info(f"Applying DEFAULT stop loss for SHORT {symbol}: ${stop_loss:.4f} "
|
|
f"({((stop_loss - current_price) / current_price) * 100:.2f}% from entry)")
|
|
|
|
# Enforce tightness ceiling on the stop
|
|
stop_loss = self._enforce_stop_loss_tightness(stop_loss, current_price, 'short')
|
|
|
|
# CRITICAL: If GA didn't provide take profit, use SAFETY MANAGER DEFAULTS
|
|
if take_profit is None:
|
|
take_profit = self.safety.get_default_take_profit(current_price, 'short')
|
|
logger.info(f"Applying DEFAULT take profit for SHORT {symbol}: ${take_profit:.4f}")
|
|
|
|
trade = {
|
|
'symbol': symbol,
|
|
'side': 'sell',
|
|
'amount': shares,
|
|
'entry_price': current_price,
|
|
'entry_time': datetime.utcnow().isoformat(),
|
|
'strategy_id': strategy_params.get('strategy_id', 'auto'),
|
|
'stop_loss': stop_loss,
|
|
'take_profit': take_profit,
|
|
'order_id': str(order.get('id', '')),
|
|
'status': 'open',
|
|
'metadata': {
|
|
'action': action,
|
|
'invest_pct': pct,
|
|
'equity_at_entry': equity,
|
|
'direction': 'short',
|
|
'sector': self._get_sector(symbol),
|
|
},
|
|
}
|
|
trade_id = self.store.record_trade(trade)
|
|
trade['id'] = trade_id
|
|
return trade
|
|
|
|
return None
|
|
|
|
def check_exits(self, current_prices: Dict[str, float]) -> List[Dict]:
|
|
"""
|
|
Check all open positions for stop loss / take profit exits.
|
|
Also checks for drawdown stop and forces partial closes if needed.
|
|
Returns list of closed trades.
|
|
"""
|
|
closed = []
|
|
open_positions = self.store.get_open_positions()
|
|
|
|
# Get combined portfolio for drawdown check
|
|
try:
|
|
portfolio = self.broker.get_portfolio()
|
|
equity = portfolio['equity']
|
|
except Exception:
|
|
equity = 0
|
|
|
|
# Update yesterday's equity reference at start of each day
|
|
self.safety.update_yesterday_equity(equity)
|
|
|
|
# Check if drawdown stop is triggered (peak-equity based, slow response)
|
|
should_reduce_dd, dd_reason, reduce_pct = False, "", 0.0
|
|
if equity > 0 and open_positions:
|
|
should_reduce_dd, dd_reason, reduce_pct = \
|
|
self.safety.check_drawdown_stop(equity, open_positions)
|
|
|
|
# NEW: Check YESTERDAY-CLOSE drawdown (FASTER response, catches intraday blowups)
|
|
should_reduce_yc, yc_reason, yc_reduce_pct = False, "", 0.0
|
|
if equity > 0 and open_positions:
|
|
should_reduce_yc, yc_reason, yc_reduce_pct = \
|
|
self.safety.check_yesterday_close_drawdown(equity)
|
|
# Use whichever is more urgent
|
|
if should_reduce_yc and not should_reduce_dd:
|
|
should_reduce_dd = True
|
|
dd_reason = yc_reason
|
|
reduce_pct = yc_reduce_pct
|
|
logger.warning(f"YESTERDAY-CLOSE CIRCUIT BREAKER TRIGGERED: {yc_reason}")
|
|
|
|
# CRITICAL: If we have 10+ positions all losing, force reduce regardless of drawdown
|
|
# This catches the "13 shorts all losing" scenario before drawdown thresholds are hit
|
|
if len(open_positions) >= 10:
|
|
losing_count = 0
|
|
for pos in open_positions:
|
|
sym = pos.get('symbol')
|
|
px = current_prices.get(sym)
|
|
if px and pos.get('entry_price'):
|
|
is_short = pos.get('metadata', {}).get('direction') == 'short'
|
|
if is_short and px > pos['entry_price']:
|
|
losing_count += 1
|
|
elif not is_short and px < pos['entry_price']:
|
|
losing_count += 1
|
|
if losing_count >= 8:
|
|
logger.critical(
|
|
f"CRISIS MODE: {losing_count}/{len(open_positions)} positions losing money! "
|
|
f"Force-reducing 50% of all positions immediately."
|
|
)
|
|
return self.force_reduce_all_positions(0.50, current_prices)
|
|
|
|
for position in open_positions:
|
|
symbol = position['symbol']
|
|
price = current_prices.get(symbol)
|
|
if price is None:
|
|
continue
|
|
|
|
should_exit = False
|
|
exit_reason = ''
|
|
should_reduce = False
|
|
reduce_amount = 0
|
|
|
|
is_short = position.get('metadata', {}).get('direction') == 'short'
|
|
|
|
# Normal stop loss / take profit checks
|
|
if is_short:
|
|
if position.get('stop_loss') and price >= position['stop_loss']:
|
|
should_exit = True
|
|
exit_reason = 'stop_loss'
|
|
elif position.get('take_profit') and price <= position['take_profit']:
|
|
should_exit = True
|
|
exit_reason = 'take_profit'
|
|
else:
|
|
if position.get('stop_loss') and price <= position['stop_loss']:
|
|
should_exit = True
|
|
exit_reason = 'stop_loss'
|
|
elif position.get('take_profit') and price >= position['take_profit']:
|
|
should_exit = True
|
|
exit_reason = 'take_profit'
|
|
|
|
# Drawdown stop: force partial close
|
|
if not should_exit and should_reduce_dd:
|
|
should_reduce = True
|
|
reduce_amount = abs(position['amount']) * reduce_pct
|
|
exit_reason = f'drawdown_stop'
|
|
|
|
if should_exit:
|
|
close_side = 'buy' if is_short else 'sell'
|
|
try:
|
|
self.broker.place_market_order(
|
|
symbol, position['amount'], close_side
|
|
)
|
|
logger.info(f"EXIT ({exit_reason}) {symbol} @ ${price:.4f} [{'SHORT' if is_short else 'LONG'}]")
|
|
except Exception as e:
|
|
logger.error(f"Error executing exit for {symbol}: {e}")
|
|
continue
|
|
|
|
if is_short:
|
|
pnl = (position['entry_price'] - price) * position['amount']
|
|
else:
|
|
pnl = (price - position['entry_price']) * position['amount']
|
|
|
|
self.store.close_position(
|
|
position['id'], price, datetime.utcnow(),
|
|
fees=position['amount'] * price * self.commission_rate
|
|
)
|
|
self.safety.record_trade_result(pnl)
|
|
|
|
closed.append({
|
|
'symbol': symbol,
|
|
'side': close_side,
|
|
'amount': position['amount'],
|
|
'entry_price': position['entry_price'],
|
|
'exit_price': price,
|
|
'pnl': round(pnl, 2),
|
|
'pnl_pct': round(pnl / (position['entry_price'] * position['amount']) * 100, 2),
|
|
'exit_reason': exit_reason,
|
|
'direction': 'short' if is_short else 'long',
|
|
})
|
|
|
|
elif should_reduce and reduce_amount > 0:
|
|
# Partial close due to drawdown stop
|
|
reduce_amount = round(reduce_amount, 4)
|
|
if reduce_amount < 0.0001:
|
|
continue
|
|
|
|
close_side = 'buy' if is_short else 'sell'
|
|
try:
|
|
self.broker.place_market_order(
|
|
symbol, reduce_amount, close_side
|
|
)
|
|
logger.warning(f"DRAWDOWN REDUCE ({reduce_amount:.2f} of {position['amount']:.2f}) "
|
|
f"{symbol} @ ${price:.4f}")
|
|
except Exception as e:
|
|
logger.error(f"Error reducing position for {symbol}: {e}")
|
|
continue
|
|
|
|
if is_short:
|
|
pnl = (position['entry_price'] - price) * reduce_amount
|
|
else:
|
|
pnl = (price - position['entry_price']) * reduce_amount
|
|
|
|
self.store.reduce_position(
|
|
position['id'], reduce_amount, price, datetime.utcnow(),
|
|
fees=reduce_amount * price * self.commission_rate
|
|
)
|
|
self.safety.record_trade_result(pnl * (reduce_amount / abs(position['amount'])))
|
|
|
|
closed.append({
|
|
'symbol': symbol,
|
|
'side': close_side,
|
|
'amount': reduce_amount,
|
|
'entry_price': position['entry_price'],
|
|
'exit_price': price,
|
|
'pnl': round(pnl, 2),
|
|
'exit_reason': 'drawdown_reduce',
|
|
'direction': 'short' if is_short else 'long',
|
|
})
|
|
|
|
return closed
|
|
|
|
def force_reduce_all_positions(self, reduction_pct: float, current_prices: Dict[str, float]) -> List[Dict]:
|
|
"""
|
|
Force-reduce ALL positions by reduction_pct (e.g., close 50% of everything).
|
|
Used when drawdown stop triggers.
|
|
"""
|
|
closed = []
|
|
open_positions = self.store.get_open_positions()
|
|
|
|
for position in open_positions:
|
|
symbol = position['symbol']
|
|
price = current_prices.get(symbol)
|
|
if price is None:
|
|
continue
|
|
|
|
reduce_amount = round(abs(position['amount']) * reduction_pct, 4)
|
|
if reduce_amount < 0.0001:
|
|
continue
|
|
|
|
is_short = position.get('metadata', {}).get('direction') == 'short'
|
|
close_side = 'buy' if is_short else 'sell'
|
|
|
|
try:
|
|
self.broker.place_market_order(symbol, reduce_amount, close_side)
|
|
logger.warning(f"FORCE REDUCE ({reduction_pct:.0%}) {reduce_amount:.2f} {symbol} @ ${price:.4f}")
|
|
except Exception as e:
|
|
logger.error(f"Error force-reducing {symbol}: {e}")
|
|
continue
|
|
|
|
if is_short:
|
|
pnl = (position['entry_price'] - price) * reduce_amount
|
|
else:
|
|
pnl = (price - position['entry_price']) * reduce_amount
|
|
|
|
self.store.reduce_position(
|
|
position['id'], reduce_amount, price, datetime.utcnow(),
|
|
fees=reduce_amount * price * self.commission_rate
|
|
)
|
|
self.safety.record_trade_result(pnl * (reduce_amount / abs(position['amount'])))
|
|
|
|
closed.append({
|
|
'symbol': symbol,
|
|
'side': close_side,
|
|
'amount': reduce_amount,
|
|
'exit_price': price,
|
|
'pnl': round(pnl, 2),
|
|
'exit_reason': 'force_reduce_all',
|
|
'direction': 'short' if is_short else 'long',
|
|
})
|
|
|
|
return closed
|
|
|
|
def get_portfolio_state(self) -> Dict:
|
|
"""Get current portfolio state for RL agent"""
|
|
try:
|
|
portfolio = self.broker.get_portfolio()
|
|
positions = self.broker.get_positions()
|
|
open_db = self.store.get_open_positions()
|
|
|
|
total_position_value = sum(p.get('market_value', 0) for p in positions)
|
|
equity = portfolio['equity']
|
|
|
|
# Count directions
|
|
num_long = sum(1 for p in open_db if p.get('metadata', {}).get('direction') == 'long')
|
|
num_short = sum(1 for p in open_db if p.get('metadata', {}).get('direction') == 'short')
|
|
|
|
return {
|
|
'equity': equity,
|
|
'cash': portfolio['cash'],
|
|
'position_ratio': total_position_value / equity if equity > 0 else 0,
|
|
'unrealized_pnl': sum(p.get('unrealized_pl', 0) for p in positions) / equity
|
|
if equity > 0 else 0,
|
|
'time_in_position': 0,
|
|
'num_positions': len(positions),
|
|
'num_long': num_long,
|
|
'num_short': num_short,
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Error getting portfolio state: {e}")
|
|
return {
|
|
'equity': 0, 'cash': 0, 'position_ratio': 0,
|
|
'unrealized_pnl': 0, 'time_in_position': 0, 'num_positions': 0,
|
|
'num_long': 0, 'num_short': 0,
|
|
}
|