diff --git a/rh_open_orders.py b/rh_open_orders.py new file mode 100644 index 0000000..5153787 --- /dev/null +++ b/rh_open_orders.py @@ -0,0 +1,134 @@ +"""Read-only: list open stock orders across ALL accounts (cash + margin) as a table. + +Places nothing. Account-aware: logs in via RobinhoodAuth, discovers each account on +the login, queries open stock orders per account, and labels every row with the +account it lives on (so margin orders are included, not hidden behind a cash filter). + +Self-locating: chdir/​sys.path to its own dir so it runs from any cwd +(the Telegram bot runs Claude with cwd=/). +""" +import os +import sys +from datetime import datetime + +HERE = os.path.dirname(os.path.abspath(__file__)) +os.chdir(HERE) # so load_dotenv() finds .env +sys.path.insert(0, HERE) # so `from utils...` resolves + +import robin_stocks.robinhood as r # noqa: E402 +from utils.rh_auth import RobinhoodAuth # noqa: E402 + + +def _fmt_price(v): + return f"${float(v):,.2f}" if v not in (None, "", "N/A") else "—" + + +def _fmt_ts(ts): + try: + return datetime.fromisoformat(str(ts).replace("Z", "+00:00")).strftime("%Y-%m-%d %H:%M") + except Exception: + return str(ts)[:16] + + +def discover_accounts(): + """Return {account_number: type} for every account on this login. + + Seeds with the configured trade account (RH_AUTOMATED_ACCOUNT_NUMBER, which the + /accounts/ endpoint may omit), then merges anything the endpoint reports. + """ + accts = {} + cfg = os.getenv("RH_AUTOMATED_ACCOUNT_NUMBER") + if cfg: + accts[cfg] = None + try: + data = r.helper.request_get("https://api.robinhood.com/accounts/", "regular") + results = (data or {}).get("results", []) if isinstance(data, dict) else (data or []) + for a in results: + num = a.get("account_number") + if num: + accts[num] = a.get("type") + except Exception: + pass + for num in list(accts): + if accts[num] is None: + try: + prof = r.profiles.load_account_profile(account_number=num) + accts[num] = (prof or {}).get("type", "?") + except Exception: + accts[num] = "?" + return accts + + +def fetch_open_orders(account_number, acct_label): + """Return parsed open stock orders for one account, each tagged with acct_label.""" + raw = r.orders.get_all_open_stock_orders(account_number=account_number) or [] + out = [] + for o in raw: + symbol = o.get("symbol", "N/A") + if symbol == "N/A" and o.get("instrument_id"): + try: + inst = r.stocks.get_instrument_by_url( + f"https://api.robinhood.com/instruments/{o['instrument_id']}/" + ) + symbol = (inst or {}).get("symbol", "N/A") + except Exception: + pass + order_type = o.get("type", "N/A") + trigger = o.get("trigger", "immediate") + if trigger == "stop" and order_type == "limit": + desc = "Stop Limit" + elif trigger == "stop": + desc = "Stop Loss" + elif order_type == "limit": + desc = "Limit" + else: + desc = "Market" + side = o.get("side", "N/A") + out.append({ + "account": acct_label, + "symbol": symbol, + "side": side.upper() if side != "N/A" else "N/A", + "order_type": desc, + "quantity": float(o.get("quantity", 0)), + "limit_price": o.get("price"), + "stop_price": o.get("stop_price"), + "created_at": _fmt_ts(o.get("created_at", "N/A")), + }) + return out + + +def main(): + RobinhoodAuth().login() # cached session + accounts = discover_accounts() + + orders = [] + for num, acct_type in accounts.items(): + label = f"{num}/{acct_type}" + orders.extend(fetch_open_orders(num, label)) + + scanned = ", ".join(f"{n}/{t}" for n, t in accounts.items()) + print(f"\nOpen orders — accounts: {scanned} — DRY RUN (read-only)\n") + if not orders: + print(" (no open orders)") + return + + rows = [( + o["account"], o["symbol"], o["side"], o["order_type"], f"{o['quantity']:g}", + _fmt_price(o["limit_price"]), _fmt_price(o["stop_price"]), o["created_at"], + ) for o in orders] + + headers = ("ACCOUNT", "SYMBOL", "SIDE", "TYPE", "QTY", "LIMIT", "STOP", "PLACED") + widths = [max(len(h), *(len(r[i]) for r in rows)) for i, h in enumerate(headers)] + + def line(cells): + return " ".join(c.ljust(widths[i]) for i, c in enumerate(cells)) + + print(line(headers)) + print(line(["-" * w for w in widths])) + for row in rows: + print(line(row)) + print(f"\n{len(orders)} open order(s) across {len(accounts)} account(s).") + + +if __name__ == "__main__": + main() diff --git a/tests/test_pdt_guard.py b/tests/test_pdt_guard.py new file mode 100644 index 0000000..9833d57 --- /dev/null +++ b/tests/test_pdt_guard.py @@ -0,0 +1,118 @@ +""" +Test PDT Gate with current live positions and fills +""" + +import sys +import os +from datetime import date + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from utils.safe_cash_bot import SafeCashBot +from trading_system.execution.pdt_gate import PDTGate + + +def test_pdt_guard(): + """Test PDT guard against current live positions and fills.""" + print("\n" + "="*70) + print("PDT GATE TEST - LIVE DATA") + print("="*70 + "\n") + + # Initialize bot and PDT gate + bot = SafeCashBot() + pdt_gate = PDTGate(trading_bot=bot) + + # Get recent fills (stock and options) + print("Fetching recent fills (last 1 day)...\n") + recent_stock_orders = bot.get_recent_orders(days=1) + recent_option_orders = bot.get_recent_option_orders(days=1) + + stock_fills = [o for o in recent_stock_orders if o['state'] in ('filled', 'confirmed')] + option_fills = [o for o in recent_option_orders if o['state'] == 'filled'] + + print(f"Stock fills today: {len(stock_fills)}") + print(f"Option fills today: {len(option_fills)}\n") + + # Display stock fills + if stock_fills: + print("STOCK FILLS TODAY:") + for fill in stock_fills: + print(f" {fill['side']} {fill['symbol']} x{fill['quantity']:.0f} @ ${fill.get('average_price', 0):.2f}") + print(f" Filled: {fill.get('last_transaction_at', 'N/A')}") + print() + + # Display option fills + if option_fills: + print("OPTION FILLS TODAY:") + for fill in option_fills: + for leg in fill.get('legs', []): + print(f" {leg['side']} {leg['position_effect']} {leg['chain_symbol']} " + f"${leg['strike']:.2f} {leg['option_type'].upper()} exp {leg['expiration']}") + print(f" Premium: ${fill['processed_premium']:.2f}") + print(f" Updated: {fill['updated_at']}") + print() + + # Test PDT checks for various scenarios + print("="*70) + print("PDT CHECK SCENARIOS") + print("="*70 + "\n") + + # Collect unique symbols from fills + test_symbols = set() + for fill in stock_fills: + test_symbols.add(fill['symbol']) + for fill in option_fills: + for leg in fill.get('legs', []): + test_symbols.add(leg['chain_symbol']) + + if not test_symbols: + print("No fills today - testing with common symbols: BTC, SPY, QQQ\n") + test_symbols = {'BTC', 'SPY', 'QQQ'} + + # Test each symbol for both buy and sell + for symbol in sorted(test_symbols): + print(f"Testing {symbol}:") + + # Test BUY + can_buy, buy_reason = pdt_gate.can_place_order(symbol, 'buy') + status = "✅ ALLOWED" if can_buy else "🚫 BLOCKED" + print(f" BUY: {status}") + print(f" {buy_reason}") + + # Test SELL + can_sell, sell_reason = pdt_gate.can_place_order(symbol, 'sell') + status = "✅ ALLOWED" if can_sell else "🚫 BLOCKED" + print(f" SELL: {status}") + print(f" {sell_reason}") + print() + + # Summary + print("="*70) + print("PDT GUARD STATUS") + print("="*70 + "\n") + + pdt_info = bot.get_pdt_status() + if pdt_info: + count = pdt_info.get('day_trade_count', 0) + flagged = pdt_info.get('flagged', False) + + print(f"Day Trades Used: {count}/3") + print(f"PDT Flagged: {'YES ⚠️' if flagged else 'NO ✅'}") + print(f"Remaining Day Trades: {3 - count}") + + if flagged: + print("\n⚠️ ACCOUNT IS PDT FLAGGED") + print(" Only position-closing sells are allowed") + elif count >= 2: + print("\n⚠️ WARNING: Only 1 day trade remaining") + else: + print(f"\n✅ Safe to trade ({3 - count} day trades remaining)") + else: + print("❌ Could not fetch PDT status") + + print("\n" + "="*70 + "\n") + + +if __name__ == "__main__": + test_pdt_guard() diff --git a/trading_system/config.py b/trading_system/config.py index f06056e..d438eb6 100644 --- a/trading_system/config.py +++ b/trading_system/config.py @@ -1,4 +1,4 @@ """Central configuration constants for the trading system.""" # Maximum shares per paired order (used by live strategy, backtests, and simulations) -DEFAULT_LOT_SIZE = 250 +DEFAULT_LOT_SIZE = 50 diff --git a/trading_system/execution/pdt_gate.py b/trading_system/execution/pdt_gate.py index fab1f7c..fbb22c9 100644 --- a/trading_system/execution/pdt_gate.py +++ b/trading_system/execution/pdt_gate.py @@ -14,6 +14,9 @@ class PDTGate: def __init__(self, trading_bot=None): self._trading_bot = trading_bot + # Cache of same-day transactions to prevent round trips + # Format: {symbol: {'buy': date_str, 'sell': date_str}} + self._same_day_fills = {} def can_place_order(self, symbol: str, side: str) -> tuple: """Check if an order is safe to place from a PDT perspective. @@ -59,7 +62,13 @@ def can_place_order(self, symbol: str, side: str) -> tuple: f"PDT warning: {day_trade_count}/3 day trades used, " f"this {side} on {symbol} may create another (2/3)") - # Step 4: Safe to proceed + # Step 4: Would create day trade but we have capacity + if would_dt and day_trade_count == 0: + return (True, + f"⚠️ WILL CREATE DAY TRADE on {symbol} (opposite side filled today) — " + f"allowed ({day_trade_count}/3 used)") + + # Step 5: Safe to proceed return (True, f"PDT OK: {day_trade_count}/3 day trades") except Exception as e: @@ -69,29 +78,117 @@ def _would_create_day_trade(self, symbol: str, side: str) -> bool: """Check if placing this order would create a day trade (round trip). Returns True if there's a same-day opposite fill for this symbol. + Checks both: + 1. Recent filled orders from Robinhood API + 2. Local cache of same-day fills (updated on successful order placement) + Defensive: returns True on API failure (assume worst case). """ try: - open_orders = self._trading_bot.get_open_orders() - if not open_orders: - return False + # Clear old cached fills from previous days + self._clear_old_fills() today = date.today().isoformat() - opposite_side = 'SELL' if side.lower() == 'buy' else 'BUY' + opposite_side = 'sell' if side.lower() == 'buy' else 'buy' - for order in open_orders: + # Check 1: Local cache (updated when orders fill) + if symbol in self._same_day_fills: + if opposite_side in self._same_day_fills[symbol]: + cached_date = self._same_day_fills[symbol][opposite_side] + if cached_date == today: + print(f" [pdt_gate] CACHE HIT: {symbol} {opposite_side.upper()} filled today") + return True + + # Check 2: Recent filled stock orders from API (last 1 day) + recent_orders = self._trading_bot.get_recent_orders(days=1) + + for order in recent_orders: if order.get('symbol') != symbol: continue - order_side = order.get('side', '').upper() + + # Only check filled/confirmed orders + order_state = order.get('state', '').lower() + if order_state not in ('filled', 'confirmed'): + continue + + # Check if it's the opposite side + order_side = order.get('side', '').lower() if order_side != opposite_side: continue - # Check if created today - created = order.get('created_at', '') - if isinstance(created, str) and created.startswith(today): + + # Check if it FILLED today (not just created) + # Use last_transaction_at (when filled) instead of created_at + filled_at = order.get('last_transaction_at') or order.get('updated_at') + if isinstance(filled_at, str) and filled_at.startswith(today): + print(f" [pdt_gate] API HIT: {symbol} {order_side.upper()} filled today at {filled_at}") + # Update cache for future checks + self._record_fill(symbol, order_side) return True + # Check 3: Recent filled option orders (options also count for PDT) + try: + recent_option_orders = self._trading_bot.get_recent_option_orders(days=1) + + for order in recent_option_orders: + if order.get('state', '').lower() != 'filled': + continue + + # Check each leg for same-day open+close on the same underlying + for leg in order.get('legs', []): + if leg.get('chain_symbol') != symbol: + continue + + position_effect = leg.get('position_effect', '').lower() + leg_side = leg.get('side', '').lower() + + # Map option position_effect to stock buy/sell equivalent + # BUY to open = debit (like buying stock) + # SELL to close = credit (like selling stock) + option_equiv_side = None + if position_effect == 'open': + option_equiv_side = 'buy' if leg_side == 'buy' else 'sell' + elif position_effect == 'close': + option_equiv_side = 'sell' if leg_side == 'sell' else 'buy' + + if option_equiv_side == opposite_side: + filled_at = order.get('updated_at', '') + if isinstance(filled_at, str) and filled_at.startswith(today): + print(f" [pdt_gate] OPTION HIT: {symbol} option {position_effect} filled today") + self._record_fill(symbol, option_equiv_side) + return True + except Exception as e: + print(f" [pdt_gate] Option order check failed (continuing): {e}") + return False except Exception as e: print(f" [pdt_gate] _would_create_day_trade error: {e}") return True # Assume worst case on failure + + def _record_fill(self, symbol: str, side: str): + """Record that we filled a buy/sell for this symbol today.""" + today = date.today().isoformat() + if symbol not in self._same_day_fills: + self._same_day_fills[symbol] = {} + self._same_day_fills[symbol][side.lower()] = today + print(f" [pdt_gate] Recorded {symbol} {side.upper()} fill for today") + + def _clear_old_fills(self): + """Remove fills from previous days to keep cache fresh.""" + today = date.today().isoformat() + symbols_to_remove = [] + + for symbol in self._same_day_fills: + sides_to_remove = [] + for side, fill_date in self._same_day_fills[symbol].items(): + if fill_date != today: + sides_to_remove.append(side) + + for side in sides_to_remove: + del self._same_day_fills[symbol][side] + + if not self._same_day_fills[symbol]: + symbols_to_remove.append(symbol) + + for symbol in symbols_to_remove: + del self._same_day_fills[symbol] diff --git a/trading_system/main.py b/trading_system/main.py index 631d5e5..165650b 100644 --- a/trading_system/main.py +++ b/trading_system/main.py @@ -236,6 +236,24 @@ def process_signal(self, symbol: str, signal: Dict, open_orders: list = None): if target_qty: paired_buy['quantity'] = target_qty + # PDT safety check for paired orders + # Don't place opposite-side paired order if we already filled that side today + pdt_gate = getattr(self.trading_bot, '_pdt_gate', None) + if pdt_gate: + # Check if placing the paired buy would violate PDT (sell filled today) + can_place_buy, buy_reason = pdt_gate.can_place_order(symbol, 'buy') + # Check if placing the stop-limit sell would violate PDT (buy filled today) + can_place_sell, sell_reason = pdt_gate.can_place_order(symbol, 'sell') + + if not can_place_buy or not can_place_sell: + print(f" 🚫 PDT BLOCKED paired orders for {symbol}:") + if not can_place_buy: + print(f" BUY: {buy_reason}") + if not can_place_sell: + print(f" SELL: {sell_reason}") + print(f" Skipping paired order placement to prevent PDT violation") + return + # Default: place buy first, then sell. # If first existing order is a buy, place sell first instead. sell_first = False @@ -297,13 +315,12 @@ def _handle_order_replacement(self, symbol: str, signal: Dict, symbol_orders: li # Build replacement orders using momentum pricing from the signal current_price = signal['order'].get('current_price', 0) - stop_offset_pct = getattr(self.strategy, 'stop_offset_pct', 0.01) - buy_offset = getattr(self.strategy, 'buy_offset', 0.20) + buy_offset_pct = getattr(self.strategy, 'buy_offset_pct', 0.0125) hedge_symbol_map = getattr(self.strategy, 'hedge_symbol_map', {}) - stop_price = round(current_price * (1 - stop_offset_pct), 2) + stop_price = round(current_price + 1.00, 2) limit_price = stop_price - buy_price = round(stop_price - buy_offset, 2) + buy_price = round(current_price * (1 - buy_offset_pct), 2) order_symbol = hedge_symbol_map.get(symbol, symbol) qty = lot_size diff --git a/trading_system/state/blob_logger.py b/trading_system/state/blob_logger.py index 64baae9..b2efffb 100644 --- a/trading_system/state/blob_logger.py +++ b/trading_system/state/blob_logger.py @@ -15,7 +15,7 @@ NETLIFY_BLOBS_URL = "https://api.netlify.com/api/v1/blobs" -STORE_NAME = "order-book" +STORE_NAME = "state-logs" # Using state-logs while UI manages cut-overs LOCAL_LOG_DIR = Path(__file__).resolve().parent.parent.parent / "state_logs"