Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions rh_open_orders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""Read-only: list open stock orders across ALL accounts (cash + margin) as a table.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated to lot size change — move or remove.

This 134-line script is a standalone Robinhood order viewer that has nothing to do with reducing DEFAULT_LOT_SIZE. It also lives at the repo root instead of scripts/. If it's needed, it should be a separate PR under scripts/ (and drop the sys.path.insert / os.chdir hacks in favor of running as a module).


Generated by Claude Code


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()
118 changes: 118 additions & 0 deletions tests/test_pdt_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a real pytest test — it's a manual script.

This file requires live Robinhood credentials, uses sys.path.insert hacks, prints output instead of asserting, and has a if __name__ == "__main__" block. It will fail in CI without credentials and doesn't test anything deterministically. Either:

  • Convert to proper unit tests with mocked broker calls, or
  • Move to scripts/ as an operational tool (not under tests/)

Also: this exact file already exists in PR #45. The PDT gate changes in this PR are identical to #45 — one of them should be closed to avoid duplicate work.


Generated by Claude Code

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()
2 changes: 1 addition & 1 deletion trading_system/config.py
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one-liner is the only change that matches the PR title. The rest of the diff (PDT gate rewrite, rh_open_orders.py, blob store rename, _handle_order_replacement pricing changes) are unrelated. Consider splitting into focused PRs:

  1. Lot size change (this line only)
  2. PDT gate improvements (already in PR Fix PDT gate to detect same-day filled orders (critical PDT protection) #45)
  3. rh_open_orders.py script (its own PR)
  4. Order replacement pricing changes (its own PR)

Generated by Claude Code

Loading
Loading