← Back to Dashboard

Paper Trading Test Plan

FROZEN AS HISTORY (Aug 21, 2026, Finding #386 per R4): pre-live test plan superseded by live operations; retained only as a regression checklist of WHAT to verify — every parameter below is pre-live and stale (see the STALE-WARNING). Indexed in docs/archive/INDEX.md.

Version: 1.4 | Last Updated: February 7, 2026 | Status: 📋 READY FOR EXECUTION (superseded by live operations — bot LIVE since Jan 2026; retained as a regression checklist)

⚠️ STALE-WARNING (Apr 26, 2026 — Phase 1 of Strategy Analysis Revamp). This doc was written pre-live and references plain "STOP" order type. The current operative behavior since Finding #179 (Feb 10, 2026) is STP-LMT with a 2% buffer on every stop, with outsideRth=True. The pre-live test categories below remain a useful checklist of what to verify, but the order-type column should be read as STP-LMT, not STOP. Other current parameters (sizing 30/22/15%, stops 3/4/5%, $1,000 volume gate per Finding #311) supersede any older numbers in this doc.


Objective

Validate all order types and execution flows used in the Earnings Trading Strategy. (Historical note: this was a pre-live checklist; the bot has been LIVE since Jan 2026, so these categories now serve as a regression/verification reference rather than a gate before first deployment.)

Paper Account: DU…91 (Port 4002) Starting Balance: ~$1,000,000


Test Categories

Category 1: Basic Order Types

Test ID Test Name Order Type Expected Result Status
1.1 Market Buy MARKET Immediate fill at market price ⬜
1.2 Market Sell MARKET Immediate fill at market price ⬜
1.3 Limit Buy (at ask) LIMIT Fill within seconds ⬜
1.4 Limit Buy (below bid) LIMIT No fill (expected) ⬜
1.5 Limit Sell (at bid) LIMIT Fill within seconds ⬜
1.6 Stop Loss (sell) STP-LMT Triggers when price hits stop (#179) ⬜
1.7 Stop Loss (buy to cover) STP-LMT Triggers when price hits stop (#179) ⬜

Category 2: Strategy Order Flows

Test ID Test Name Flow Expected Result Status
2.1 Guaranteed Fill Entry LIMIT → MARKET fallback Full position filled ⬜
2.2 Entry with Stop Attachment Entry + STP-LMT Both orders placed (#179) ⬜
2.3 Scheduled Exit LIMIT → MARKET fallback Position closed ⬜
2.4 Instant Exit (cancel stop + market) CANCEL + MARKET Stop cancelled, position closed ⬜

Category 3: Position Types

Test ID Test Name Direction Expected Result Status
3.1 Long Entry BUY Position opened ⬜
3.2 Long Exit SELL Position closed ⬜
3.3 Short Entry SELL SHORT Short position opened ⬜
3.4 Short Exit BUY TO COVER Short position closed ⬜

Category 4: Extended Hours

Test ID Test Name Time Expected Result Status
4.1 Pre-market order (if tested during pre-market) 4-9:30 AM outsideRth=True works ⬜
4.2 After-hours order (if tested during after-hours) 4-8 PM outsideRth=True works ⬜

Category 5: Error Handling

Test ID Test Name Scenario Expected Result Status
5.1 Invalid Symbol Order for "ZZZZZ" Graceful error, no crash ⬜
5.2 Insufficient Funds Order > buying power Rejected, logged ⬜
5.3 Cancel Non-existent Order Cancel invalid ID Graceful error ⬜

Category 6: Signal Generation (Added Jan 13, 2026)

Test ID Test Name Scenario Expected Result Status
6.1 Future Entry Time Conviction Query conviction for BMO tomorrow Returns latest available data ✅
6.2 Market Cap Lookup (Major Banks) Get market cap for BAC, C, WFC Returns correct value, not None ✅
6.3 Market Cap Lookup (Unknown Stock) Get market cap for rare ticker Falls back to yfinance ⬜
6.4 Schema Query (Conviction) Query conviction from correct table Uses price_snapshots.yes_price ✅
6.5 Schema Query (Volume) Query volume from correct table Uses markets.volume_total ✅
6.6 SHORT Signal Detection Generate signal for ≤30% conviction Creates SHORT signal correctly ⬜
6.7 Signal Generation Dry Run Run signal generation T-12h before market Returns valid signals, not 0 ✅

Note: Tests 6.1, 6.2, 6.4, 6.5, 6.7 were added and verified after Finding #32 (pre-trade simulation) on Jan 13, 2026.


Test Execution Script

#!/usr/bin/env python3
"""
Paper Trading Test Suite
Run all order type tests against IBKR Paper Trading account.
"""

import sys
import time
import logging
from datetime import datetime
from typing import Dict, List, Any

# Setup path
sys.path.insert(0, '[hidden]')

# NOTE: this standalone test harness uses StopOrder (plain STOP) only to exercise
# the paper gateway. PRODUCTION uses StopLimitOrder (STP-LMT) with a 2% buffer and
# outsideRth=True on every stop (Finding #179) — not plain STOP/GTC.
from ib_insync import IB, Stock, MarketOrder, LimitOrder, StopOrder, util

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

# Test configuration
PAPER_PORT = 4002
TEST_SYMBOL = "AAPL"  # Highly liquid stock
TEST_SHARES = 1  # Minimal size for testing

class PaperTradingTester:
    def __init__(self):
        self.ib = IB()
        self.results: List[Dict[str, Any]] = []

    def connect(self) -> bool:
        """Connect to paper trading gateway."""
        try:
            self.ib.connect('127.0.0.1', PAPER_PORT, clientId=50, readonly=False)
            accounts = self.ib.managedAccounts()
            logger.info(f"Connected to paper account: {accounts[0]}")

            if not accounts[0].startswith('DU'):
                logger.error("NOT A PAPER ACCOUNT! Aborting.")
                return False
            return True
        except Exception as e:
            logger.error(f"Connection failed: {e}")
            return False

    def disconnect(self):
        """Disconnect from gateway."""
        self.ib.disconnect()

    def get_contract(self, symbol: str) -> Stock:
        """Get qualified stock contract."""
        contract = Stock(symbol, 'SMART', 'USD')
        self.ib.qualifyContracts(contract)
        return contract

    def get_quote(self, contract: Stock) -> Dict:
        """Get current bid/ask."""
        self.ib.reqMktData(contract, '', False, False)
        self.ib.sleep(2)
        ticker = self.ib.ticker(contract)
        return {
            'bid': ticker.bid,
            'ask': ticker.ask,
            'last': ticker.last,
        }

    def run_test(self, test_id: str, test_name: str, test_func) -> Dict:
        """Run a single test and record result."""
        logger.info(f"\n{'='*60}")
        logger.info(f"TEST {test_id}: {test_name}")
        logger.info('='*60)

        result = {
            'test_id': test_id,
            'test_name': test_name,
            'status': 'PENDING',
            'details': '',
            'timestamp': datetime.now().isoformat(),
        }

        try:
            success, details = test_func()
            result['status'] = 'PASS' if success else 'FAIL'
            result['details'] = details
        except Exception as e:
            result['status'] = 'ERROR'
            result['details'] = str(e)
            logger.error(f"Test error: {e}")

        self.results.append(result)
        logger.info(f"Result: {result['status']} - {result['details']}")
        return result

    # === TEST IMPLEMENTATIONS ===

    def test_1_1_market_buy(self):
        """Test 1.1: Market Buy"""
        contract = self.get_contract(TEST_SYMBOL)
        order = MarketOrder('BUY', TEST_SHARES)
        order.outsideRth = True

        trade = self.ib.placeOrder(contract, order)
        self.ib.sleep(5)

        if trade.orderStatus.status == 'Filled':
            return True, f"Filled {trade.orderStatus.filled} @ ${trade.orderStatus.avgFillPrice:.2f}"
        return False, f"Status: {trade.orderStatus.status}"

    def test_1_2_market_sell(self):
        """Test 1.2: Market Sell (close position from 1.1)"""
        contract = self.get_contract(TEST_SYMBOL)
        order = MarketOrder('SELL', TEST_SHARES)
        order.outsideRth = True

        trade = self.ib.placeOrder(contract, order)
        self.ib.sleep(5)

        if trade.orderStatus.status == 'Filled':
            return True, f"Filled {trade.orderStatus.filled} @ ${trade.orderStatus.avgFillPrice:.2f}"
        return False, f"Status: {trade.orderStatus.status}"

    def test_1_3_limit_buy_at_ask(self):
        """Test 1.3: Limit Buy at Ask (should fill)"""
        contract = self.get_contract(TEST_SYMBOL)
        quote = self.get_quote(contract)
        limit_price = round(quote['ask'] * 1.001, 2)  # Slightly above ask

        order = LimitOrder('BUY', TEST_SHARES, limit_price)
        order.outsideRth = True

        trade = self.ib.placeOrder(contract, order)
        self.ib.sleep(10)

        if trade.orderStatus.status == 'Filled':
            return True, f"Filled @ ${trade.orderStatus.avgFillPrice:.2f} (limit was ${limit_price})"

        # Cancel if not filled
        self.ib.cancelOrder(order)
        return False, f"Status: {trade.orderStatus.status}"

    def test_1_4_limit_buy_below_bid(self):
        """Test 1.4: Limit Buy below Bid (should NOT fill)"""
        contract = self.get_contract(TEST_SYMBOL)
        quote = self.get_quote(contract)
        limit_price = round(quote['bid'] * 0.95, 2)  # 5% below bid

        order = LimitOrder('BUY', TEST_SHARES, limit_price)
        order.outsideRth = True
        order.tif = 'IOC'  # Immediate or Cancel

        trade = self.ib.placeOrder(contract, order)
        self.ib.sleep(3)

        # Should be cancelled (not filled)
        if trade.orderStatus.status in ['Cancelled', 'Inactive']:
            return True, f"Correctly not filled at ${limit_price} (bid was ${quote['bid']})"
        elif trade.orderStatus.status == 'Filled':
            return False, f"Unexpectedly filled (should not have)"
        return True, f"Status: {trade.orderStatus.status} (expected no fill)"

    def test_1_5_limit_sell_at_bid(self):
        """Test 1.5: Limit Sell at Bid (close position from 1.3)"""
        contract = self.get_contract(TEST_SYMBOL)
        quote = self.get_quote(contract)
        limit_price = round(quote['bid'] * 0.999, 2)  # Slightly below bid

        order = LimitOrder('SELL', TEST_SHARES, limit_price)
        order.outsideRth = True

        trade = self.ib.placeOrder(contract, order)
        self.ib.sleep(10)

        if trade.orderStatus.status == 'Filled':
            return True, f"Filled @ ${trade.orderStatus.avgFillPrice:.2f}"

        # Cancel and use market if not filled
        self.ib.cancelOrder(order)
        self.ib.sleep(1)
        market_order = MarketOrder('SELL', TEST_SHARES)
        trade2 = self.ib.placeOrder(contract, market_order)
        self.ib.sleep(3)
        return True, f"Limit not filled, used market @ ${trade2.orderStatus.avgFillPrice:.2f}"

    def test_1_6_stop_loss_sell(self):
        """Test 1.6: Stop Loss (Sell) - Place and verify, then cancel"""
        contract = self.get_contract(TEST_SYMBOL)
        quote = self.get_quote(contract)
        stop_price = round(quote['last'] * 0.95, 2)  # 5% below current

        # First buy a share
        buy_order = MarketOrder('BUY', TEST_SHARES)
        buy_trade = self.ib.placeOrder(contract, buy_order)
        self.ib.sleep(3)

        # Place stop loss
        # NOTE: harness uses plain StopOrder; PRODUCTION uses StopLimitOrder (STP-LMT)
        # with a 2% buffer and outsideRth=True per Finding #179.
        stop_order = StopOrder('SELL', TEST_SHARES, stop_price)
        stop_order.outsideRth = True
        stop_order.tif = 'GTC'

        stop_trade = self.ib.placeOrder(contract, stop_order)
        self.ib.sleep(2)

        # Verify stop is placed
        if stop_trade.orderStatus.status in ['PreSubmitted', 'Submitted']:
            # Cancel stop and close position
            self.ib.cancelOrder(stop_order)
            self.ib.sleep(1)
            sell_order = MarketOrder('SELL', TEST_SHARES)
            self.ib.placeOrder(contract, sell_order)
            self.ib.sleep(3)
            return True, f"Stop placed @ ${stop_price}, then cancelled and position closed"

        return False, f"Stop status: {stop_trade.orderStatus.status}"

    def test_2_1_guaranteed_fill(self):
        """Test 2.1: Guaranteed Fill (LIMIT with MARKET fallback)"""
        contract = self.get_contract(TEST_SYMBOL)
        quote = self.get_quote(contract)

        # Aggressive limit price
        limit_price = round(quote['ask'] * 1.003, 2)  # 0.3% above ask

        order = LimitOrder('BUY', TEST_SHARES, limit_price)
        order.outsideRth = True

        trade = self.ib.placeOrder(contract, order)

        # Wait up to 10 seconds for fill
        for _ in range(20):
            self.ib.sleep(0.5)
            if trade.orderStatus.status == 'Filled':
                # Close position
                sell_order = MarketOrder('SELL', TEST_SHARES)
                self.ib.placeOrder(contract, sell_order)
                self.ib.sleep(3)
                return True, f"Limit filled @ ${trade.orderStatus.avgFillPrice:.2f}"

        # Fallback to market
        self.ib.cancelOrder(order)
        self.ib.sleep(1)

        market_order = MarketOrder('BUY', TEST_SHARES)
        market_trade = self.ib.placeOrder(contract, market_order)
        self.ib.sleep(3)

        if market_trade.orderStatus.status == 'Filled':
            # Close position
            sell_order = MarketOrder('SELL', TEST_SHARES)
            self.ib.placeOrder(contract, sell_order)
            self.ib.sleep(3)
            return True, f"Market fallback filled @ ${market_trade.orderStatus.avgFillPrice:.2f}"

        return False, "Neither limit nor market filled"

    def test_3_3_short_entry(self):
        """Test 3.3: Short Entry"""
        contract = self.get_contract(TEST_SYMBOL)

        order = MarketOrder('SELL', TEST_SHARES)  # Short sell
        order.outsideRth = True

        trade = self.ib.placeOrder(contract, order)
        self.ib.sleep(5)

        if trade.orderStatus.status == 'Filled':
            return True, f"Short filled @ ${trade.orderStatus.avgFillPrice:.2f}"
        return False, f"Status: {trade.orderStatus.status}"

    def test_3_4_short_exit(self):
        """Test 3.4: Short Exit (cover position from 3.3)"""
        contract = self.get_contract(TEST_SYMBOL)

        order = MarketOrder('BUY', TEST_SHARES)  # Buy to cover
        order.outsideRth = True

        trade = self.ib.placeOrder(contract, order)
        self.ib.sleep(5)

        if trade.orderStatus.status == 'Filled':
            return True, f"Cover filled @ ${trade.orderStatus.avgFillPrice:.2f}"
        return False, f"Status: {trade.orderStatus.status}"

    def test_5_1_invalid_symbol(self):
        """Test 5.1: Invalid Symbol - should error gracefully"""
        try:
            contract = Stock("ZZZZZ123", 'SMART', 'USD')
            self.ib.qualifyContracts(contract)
            return False, "Should have failed for invalid symbol"
        except Exception as e:
            return True, f"Gracefully handled: {str(e)[:50]}"

    def run_all_tests(self):
        """Run all tests in sequence."""
        if not self.connect():
            return

        try:
            # Basic order types
            self.run_test("1.1", "Market Buy", self.test_1_1_market_buy)
            self.run_test("1.2", "Market Sell", self.test_1_2_market_sell)
            self.run_test("1.3", "Limit Buy (at ask)", self.test_1_3_limit_buy_at_ask)
            self.run_test("1.4", "Limit Buy (below bid)", self.test_1_4_limit_buy_below_bid)
            self.run_test("1.5", "Limit Sell (at bid)", self.test_1_5_limit_sell_at_bid)
            self.run_test("1.6", "Stop Loss (sell)", self.test_1_6_stop_loss_sell)

            # Strategy flows
            self.run_test("2.1", "Guaranteed Fill Entry", self.test_2_1_guaranteed_fill)

            # Short selling
            self.run_test("3.3", "Short Entry", self.test_3_3_short_entry)
            self.run_test("3.4", "Short Exit", self.test_3_4_short_exit)

            # Error handling
            self.run_test("5.1", "Invalid Symbol", self.test_5_1_invalid_symbol)

        finally:
            self.disconnect()

        # Print summary
        self.print_summary()

    def print_summary(self):
        """Print test summary."""
        print("\n" + "="*60)
        print("TEST SUMMARY")
        print("="*60)

        passed = sum(1 for r in self.results if r['status'] == 'PASS')
        failed = sum(1 for r in self.results if r['status'] == 'FAIL')
        errors = sum(1 for r in self.results if r['status'] == 'ERROR')

        for r in self.results:
            icon = "✅" if r['status'] == 'PASS' else "❌" if r['status'] == 'FAIL' else "⚠️"
            print(f"{icon} {r['test_id']}: {r['test_name']} - {r['status']}")
            if r['details']:
                print(f"   → {r['details']}")

        print(f"\nTotal: {len(self.results)} tests")
        print(f"✅ Passed: {passed}")
        print(f"❌ Failed: {failed}")
        print(f"⚠️  Errors: {errors}")


if __name__ == "__main__":
    tester = PaperTradingTester()
    tester.run_all_tests()

Execution Instructions

Prerequisites

  1. Paper Gateway running on port 4002
  2. VNC connected and Gateway logged in
  3. Market hours (or extended hours enabled)

Run Tests

# SSH to server
ssh root@[hidden]

# Run test suite
cd [hidden]
source venv/bin/activate
python3 docs/paper_trading_tests.py

Expected Results

All tests should PASS. If any fail: 1. Check Gateway connection 2. Verify API settings (Read-Only = OFF) 3. Check market hours 4. Review error details


Post-Test Checklist


Fractional Shares Test Cases (Added Jan 11, 2026)

Test F.1: Fractional Eligibility Check

# Check if AAPL (NASDAQ) is fractional-eligible
is_eligible = client.is_fractional_eligible('AAPL')
assert is_eligible == True  # NYSE/NASDAQ stocks should be eligible

# Check if OTC stock is NOT eligible (if you have one to test)
# is_eligible = client.is_fractional_eligible('OTCBB_STOCK')
# assert is_eligible == False

Test F.2: Fractional Share Calculation

# Calculate fractional shares for $1,000 at $185.50
shares, is_fractional = client.calculate_fractional_shares('AAPL', 1000.0, 185.50)
assert is_fractional == True
assert abs(shares - 5.3908) < 0.0001  # Should be ~5.3908 shares

Test F.3: Fractional Order Placement

# Place a fractional limit order (paper trading)
result = client.place_limit_order(
    symbol='AAPL',
    quantity=1.5,  # Fractional quantity
    side='BUY',
    limit_price=185.50,
    extended_hours=True
)
assert result.is_fractional == True

Exit Verification Test Cases (Added Jan 11, 2026)

Test EV.1: Exit Verification Success

# After an exit order fills, verify IBKR position is 0
result = executor.place_instant_exit('AAPL', direction='LONG', shares=1)
assert result.success == True
assert result.exit_verified == True
assert result.ibkr_position_at_exit == 0

Test EV.2: Exit Verification Failure (Simulated)

# Simulate partial fill scenario (manual test)
# 1. Open position for 2 shares
# 2. Manually sell 1 share via different clientId
# 3. Bot attempts exit - should detect remaining shares
# Expected: exit_verified = False, alert triggered

Test EV.3: Position Reconciliation

# On orchestrator startup, check reconciliation log
# SELECT * FROM position_reconciliation_log ORDER BY run_time DESC LIMIT 1
# Expected: db_positions_checked >= 0, no mismatches for clean state

Test EV.4: External Trade Detection

# Place order with clientId != 1 (e.g., via manual VNC/TWS)
# Start earnings-trader
# Check external_trades table
# Expected: Entry logged with detected_at timestamp

Phase 4D: Event-Driven Architecture Tests (Added Feb 7, 2026)

Test Description Expected Status
EV.1 Real-time stop fill detection When stop order fills in IBKR, _on_ibkr_fill() fires and publishes STOP_FILLED event. Position cleanup queued on main thread. Complete (Phase 3+4)
EV.2 EventBus handler isolation If one event handler throws exception, other handlers still execute Verified (unit test)
EV.3 Fill callback during disconnection Callback not called when IBKR disconnected (no crash) Complete (Phase 3+4)
EV.4 IBKR position cache invalidation Cache invalidated immediately after any order fill Verified (code review)

Last updated: February 7, 2026