Repository navigation
Expand file tree
/
Copy path04_alpaca_paper_trading_demo.py
More file actions
827 lines (711 loc) · 31 KB
/
Copy path04_alpaca_paper_trading_demo.py
File metadata and controls
827 lines (711 loc) · 31 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
# ---
# jupyter:
# jupytext:
# cell_metadata_filter: tags,-all
# text_representation:
# extension: .py
# format_name: percent
# format_version: '1.3'
# jupytext_version: 1.19.3
# kernelspec:
# display_name: Python 3 (ipykernel)
# language: python
# name: python3
# ---
# %% [markdown]
# # Alpaca Paper Trading Demo
#
# **Docker image**: `ml4t`
#
# **Book Reference**: Chapter 25, Section 25.3 (Integrating with Alpaca)
#
# [`03_ib_paper_trading_demo`](03_ib_paper_trading_demo.ipynb) connected the same strategy to
# Interactive Brokers. This notebook connects it to Alpaca, and the interesting part is how little
# changes: a different broker class, a different feed class, the same `Strategy`, the same shadow
# mode, the same order log. The two brokers differ in what they cost to reach, not in what the
# strategy has to know about them.
#
# It also runs, by default, without an Alpaca account at all. The offline path uses a simulated
# broker over deterministic bars, which is what makes this notebook executable in continuous
# integration and readable by anyone who has not signed up for anything. Everything that path
# demonstrates about the strategy interface is real; everything it would demonstrate about Alpaca
# is not, and the notebook is explicit about which is which at each step.
#
# **Learning Objectives**
# - Check the environment, the SDK and the account state before a strategy is allowed near a
# broker, and treat each as a separate gate
# - Wrap a strategy in shadow mode, so orders are logged and never sent
# - Tell what a run established from what it merely exercised, when the broker was simulated
#
# **Prerequisites**: [`01_unified_framework_demo`](01_unified_framework_demo.ipynb) for the shared
# `Strategy` interface. An Alpaca paper account and `ALPACA_API_KEY` / `ALPACA_SECRET_KEY` are
# needed only for the live path, which is off by default.
# %%
"""Connect ml4t strategies to Alpaca with shadow-mode risk controls."""
import asyncio
import logging
import os
import warnings
from datetime import UTC, datetime, timedelta
import numpy as np
import polars as pl
from async_utils import run_async
from ml4t.backtest import OrderSide, Strategy
from utils.paths import display_path, get_output_dir
from utils.reproducibility import set_global_seeds
# The broker adapters pull in websockets' legacy module, which deprecates itself on import, so
# the filter has to be in force before the import rather than after it. It is the library's
# business rather than this notebook's and nothing in the result depends on it.
warnings.filterwarnings("ignore", category=DeprecationWarning, module=r"websockets\.legacy")
# alpaca-py is an optional broker SDK; the simulated path runs without it. The
# try/except is the only such optional import in the notebook.
HAS_ALPACA_SDK = False
try:
import alpaca # noqa: F401
from alpaca.trading.client import TradingClient
from ml4t.live import AlpacaBroker, AlpacaDataFeed, LiveEngine, LiveRiskConfig
from ml4t.live.safety import SafeBroker
HAS_ALPACA_SDK = True
except ImportError:
pass
# Give this notebook its own logger rather than configuring the root one: basicConfig is a no-op
# once an imported library has attached a handler, and reconfiguring the root would either do
# nothing or print every line twice.
logger = logging.getLogger("alpaca_paper_demo")
logger.setLevel(logging.INFO)
logger.propagate = False
if not logger.handlers:
_handler = logging.StreamHandler()
_handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
logger.addHandler(_handler)
logging.getLogger("alpaca").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
if HAS_ALPACA_SDK:
print("[OK] ml4t.live Alpaca components imported")
else:
print("Alpaca SDK not installed (uv add alpaca-py); running simulation only")
# %% tags=["parameters"]
DEMO_DURATION_SECONDS = 60
MAX_SYMBOLS = 0
SIMULATION_STEPS = 10
LIVE_FEED = 0 # explicit opt-in; default execution is offline and paper-safe
SEED = 42
# %% [markdown]
# One environment override before anything else. Alpaca's WebSocket loop and the nested event loop
# a headless notebook runner installs do not cooperate: `asyncio.wait_for` cannot reliably cancel
# the inner streaming task, so an unattended run would sit past its own duration limit instead of
# finishing. When the runner announces itself, the live feed is turned off and the simulated path
# runs. An interactive Jupyter session is unaffected.
#
# Forcing the safe path in the environment that cannot supervise itself is the right default, and
# the run says so in its output rather than silently taking a different branch.
# %%
if os.environ.get("ML4T_HEADLESS_PAPERMILL") == "1":
LIVE_FEED = 0
print("Headless papermill detected: LIVE_FEED disabled, simulated path will run")
set_global_seeds(SEED)
ALPACA_API_KEY = os.environ.get("ALPACA_API_KEY", "")
ALPACA_SECRET_KEY = os.environ.get("ALPACA_SECRET_KEY", "")
PAPER_TRADING = True
ALL_SYMBOLS = ["SPY", "QQQ", "IWM"]
SYMBOLS = ALL_SYMBOLS[:MAX_SYMBOLS] if MAX_SYMBOLS > 0 else ALL_SYMBOLS.copy()
# %% [markdown]
# ## 1. Credential Verification
#
# Alpaca requires API keys for authentication. For paper trading:
# - Sign up at https://alpaca.markets
# - Generate API keys in the dashboard
# - Set environment variables (never hardcode!)
# %%
def verify_credentials():
"""Check that API credentials and SDK are available."""
if not HAS_ALPACA_SDK:
print("\n" + "=" * 60)
print("ALPACA SDK NOT INSTALLED")
print("=" * 60)
print("\nTo connect to Alpaca, install the SDK:")
print(" uv add alpaca-py")
print("\nRunning in DEMO MODE with simulated broker...")
return False
if not ALPACA_API_KEY or not ALPACA_SECRET_KEY:
print("\n" + "=" * 60)
print("ALPACA CREDENTIALS NOT FOUND")
print("=" * 60)
print("\nTo run this notebook with real Alpaca connection:")
print("1. Create an Alpaca account at https://alpaca.markets")
print("2. Generate API keys in the dashboard")
print("3. Set environment variables:")
print(" export ALPACA_API_KEY='PKXXXXXXXX'")
print(" export ALPACA_SECRET_KEY='xxxxxxxxxx'")
print("\nRunning in DEMO MODE with simulated broker...")
return False
print("\n" + "=" * 60)
print("ALPACA CREDENTIALS VERIFIED")
print("=" * 60)
print(f"Paper Trading: {'YES' if PAPER_TRADING else 'NO (LIVE!)'}")
return True
HAS_CREDENTIALS = verify_credentials()
# %% [markdown]
# **Finding**: The credential gate explicitly distinguishes missing SDKs from missing secrets, which makes
# the execution mode observable before the notebook touches a broker connection.
#
# **Trading implication**: Live notebooks should never hide whether they are authenticated, shadowing, or
# fully simulated because that status determines the operational risk of every downstream action.
#
# %% [markdown]
# ## 2. Connect to Alpaca
#
# The AlpacaBroker class handles:
# - REST API for orders and account info
# - WebSocket for real-time order updates
# - Automatic position/order synchronization
# %%
def get_alpaca_account_snapshot():
"""Fetch an account snapshot without starting the streaming broker session."""
if not HAS_CREDENTIALS:
return None, None, None, None
print("\n" + "=" * 60)
print("CONNECTING TO ALPACA")
print("=" * 60)
trading_client = TradingClient(
api_key=ALPACA_API_KEY,
secret_key=ALPACA_SECRET_KEY,
paper=PAPER_TRADING,
)
account = trading_client.get_account()
raw_positions = trading_client.get_all_positions()
broker = AlpacaBroker(
api_key=ALPACA_API_KEY,
secret_key=ALPACA_SECRET_KEY,
paper=PAPER_TRADING,
)
print("\nConnected to Alpaca")
print(f" Paper Trading: {'YES' if PAPER_TRADING else 'NO'}")
nlv = float(account.equity)
cash = float(account.cash)
print("\nACCOUNT READINESS")
print(" Account values received: [OK]")
# Get positions
positions = {
position.symbol: {
"quantity": float(position.qty),
"entry_price": float(position.avg_entry_price),
"current_price": float(position.current_price or position.avg_entry_price),
}
for position in raw_positions
}
print(f" Open positions received: {len(positions)}")
return broker, nlv, cash, positions
# Run connection
if LIVE_FEED and HAS_CREDENTIALS and HAS_ALPACA_SDK:
try:
broker, nlv, cash, positions = get_alpaca_account_snapshot()
except Exception as e:
print(f"\nConnection failed: {e}")
print("\nTroubleshooting:")
print("1. Are your API keys correct?")
print("2. Is your account enabled for paper trading?")
print("3. Check https://status.alpaca.markets for outages")
raise RuntimeError("Alpaca paper session unavailable") from e
else:
broker = None
print("Offline mode selected; no Alpaca account request was made.")
# %% [markdown]
# **Finding**: The account summary confirms that the broker adapter exposes the same cash, equity, and
# position state the strategy will rely on later in the session.
#
# **Trading implication**: A live strategy should always prove that its broker snapshot is sane before it
# starts listening to market data; otherwise even correct signals can be routed with stale inventory.
#
# %% [markdown]
# ## 3. ETF Momentum Strategy
#
# This strategy is **identical** to what we use in backtesting.
# Uses the ETF case study with SPY, QQQ, IWM.
# %%
class ETFMomentumStrategy(Strategy):
"""ETF momentum strategy for live trading.
Tracks 5-day momentum across ETFs and generates signals
when momentum crosses thresholds.
This is the SAME code used in backtest - zero changes for live!
"""
def __init__(self, lookback: int = 5, threshold: float = 0.02, position_size: int = 10):
self.lookback = lookback
self.threshold = threshold
self.position_size = position_size
self.prices: dict[str, list[float]] = {}
self.signals: list[dict] = []
def on_start(self, broker):
"""Called when engine starts."""
logger.info(f"Strategy started: ETFMomentum(lookback={self.lookback})")
for symbol in SYMBOLS:
self.prices[symbol] = []
def on_data(self, timestamp: datetime, data: dict, context: dict, broker):
"""Called for each bar.
Args:
timestamp: Bar timestamp
data: {symbol: {'open', 'high', 'low', 'close', 'volume'}}
context: Additional metadata (vwap, trade_count, etc.)
broker: Broker instance (sync interface)
"""
for symbol, bar in data.items():
if symbol not in self.prices:
self.prices[symbol] = []
close = bar["close"]
self.prices[symbol].append(close)
# Calculate momentum
if len(self.prices[symbol]) > self.lookback:
old_price = self.prices[symbol][-self.lookback - 1]
momentum = (close - old_price) / old_price
# Get current position
position = broker.get_position(symbol)
has_position = position is not None and position.quantity > 0
# Generate signals
if momentum > self.threshold and not has_position:
signal = {
"timestamp": timestamp,
"symbol": symbol,
"action": "BUY",
"momentum": momentum,
"price": close,
}
self.signals.append(signal)
logger.info(f"BUY {symbol}: Momentum {momentum:.2%} > {self.threshold:.2%}")
broker.submit_order(symbol, self.position_size, side=OrderSide.BUY)
elif momentum < -self.threshold and has_position:
signal = {
"timestamp": timestamp,
"symbol": symbol,
"action": "SELL",
"momentum": momentum,
"price": close,
}
self.signals.append(signal)
logger.info(f"SELL {symbol}: Momentum {momentum:.2%} < -{self.threshold:.2%}")
broker.submit_order(symbol, self.position_size, side=OrderSide.SELL)
def on_end(self, broker):
"""Called when engine stops."""
logger.info(f"Strategy ended. Signals generated: {len(self.signals)}")
# %% [markdown]
# ### Structured Signal and Order Log
#
# Both the live and simulated paths accumulate signals and order intentions into a Polars frame so reviewers
# can inspect what the strategy decided at every step, in the same shape, regardless of whether the broker is
# Alpaca or the in-notebook mock.
# %%
def signals_to_frame(signals: list[dict]) -> pl.DataFrame:
"""Render the strategy's signal accumulator as a Polars frame for display."""
if not signals:
return pl.DataFrame(
schema={
"timestamp": pl.Datetime,
"symbol": pl.String,
"action": pl.String,
"momentum": pl.Float64,
"price": pl.Float64,
}
)
return pl.DataFrame(signals)
# %% [markdown]
# ## 4. Safe Broker Configuration
#
# Before going live, we wrap the broker with `SafeBroker` which provides:
# - Shadow mode (virtual orders only)
# - Position limits
# - Order rate limiting
# - Kill switch
# %%
def create_safe_broker(underlying_broker):
"""Create SafeBroker with risk configuration."""
risk_state_path = get_output_dir(25, "alpaca_paper_demo") / "risk_state.json"
risk_config = LiveRiskConfig(
shadow_mode=True, # CRITICAL: Virtual orders only!
max_position_value=50_000.0,
max_order_value=10_000.0,
max_orders_per_minute=10,
dedup_window_seconds=0.0, # Disable for demo
state_file=str(risk_state_path),
)
safe_broker = SafeBroker(underlying_broker, risk_config)
print("\n" + "=" * 60)
print("RISK CONFIGURATION (SHADOW MODE)")
print("=" * 60)
print(" Shadow Mode: ENABLED (no real orders)")
print(f" Max Position Value: ${risk_config.max_position_value:,.0f}")
print(f" Max Order Value: ${risk_config.max_order_value:,.0f}")
print(f" Rate Limit: {risk_config.max_orders_per_minute}/minute")
print(f" Risk State: {display_path(risk_state_path)}")
return safe_broker, risk_config
# %% [markdown]
# **Finding**: The risk-configuration printout makes shadow mode and exposure limits visible before the live
# feed starts emitting data.
#
# **Trading implication**: Broker wrappers should surface their active limits explicitly because a live
# rollout is only as safe as the controls that are actually enabled at runtime.
#
# %% [markdown]
# ## 5. Real-Time Data Feed
#
# AlpacaDataFeed subscribes to real-time market data:
# - **bars**: OHLCV aggregates (1-minute default)
# - **quotes**: Bid/ask with sizes
# - **trades**: Individual trades
#
# Data sources:
# - **IEX**: Free, 15-min delayed for some symbols
# - **SIP**: Premium, real-time from all exchanges
# %% [markdown]
# ### Simulated Path: Flat Dict Portfolio
#
# When credentials or the SDK are missing, the demo runs against a tiny in-notebook broker. The portfolio is a
# flat dict (`{"cash": float, "positions": {symbol: {qty, entry_price}}}`) rather than nested dataclasses so
# the simulated state can be read directly into a Polars frame for display alongside the live path.
# %%
class MockBroker:
"""Minimal sync broker for the no-credential simulation path.
The portfolio is a flat dict to keep the simulation state inspectable; it is
intentionally not a SafeBroker wrapper because shadow-mode requires fresh
quotes that the simulation does not produce.
"""
REF_PRICES = {"SPY": 600.0, "QQQ": 520.0, "IWM": 225.0}
def __init__(self, initial_cash: float = 100_000.0):
self.portfolio = {"cash": initial_cash, "positions": {}}
self.order_log: list[dict] = []
self.current_prices = dict(self.REF_PRICES)
self.current_timestamp = datetime(2025, 1, 2, tzinfo=UTC)
def update_market(self, timestamp: datetime, prices: dict[str, float]) -> None:
"""Update the simulated market used for subsequent fills."""
self.current_timestamp = timestamp
self.current_prices.update(prices)
def get_position(self, symbol: str):
pos = self.portfolio["positions"].get(symbol)
if pos is None:
return None
class _PosView:
quantity = pos["quantity"]
return _PosView()
def submit_order(self, asset: str, quantity: int, side=None, **kwargs) -> dict:
price = self.current_prices[asset]
if isinstance(side, OrderSide):
side_name = side.value.upper()
elif side is None:
side_name = "BUY"
else:
side_name = str(getattr(side, "value", side)).upper()
status = "rejected"
if side is None or side == OrderSide.BUY:
cost = quantity * price
if cost <= self.portfolio["cash"]:
self.portfolio["cash"] -= cost
pos = self.portfolio["positions"].get(asset)
if pos is None:
self.portfolio["positions"][asset] = {
"quantity": quantity,
"entry_price": price,
}
else:
total_qty = pos["quantity"] + quantity
avg = (pos["quantity"] * pos["entry_price"] + quantity * price) / total_qty
self.portfolio["positions"][asset] = {
"quantity": total_qty,
"entry_price": avg,
}
status = "filled"
elif side == OrderSide.SELL:
pos = self.portfolio["positions"].get(asset)
if pos is not None and pos["quantity"] >= quantity:
self.portfolio["cash"] += quantity * price
remaining = pos["quantity"] - quantity
if remaining == 0:
del self.portfolio["positions"][asset]
else:
self.portfolio["positions"][asset] = {
"quantity": remaining,
"entry_price": pos["entry_price"],
}
status = "filled"
else:
status = "unsupported"
order = {
"order_id": f"SIM-{len(self.order_log) + 1}",
"timestamp": self.current_timestamp,
"symbol": asset,
"side": side_name,
"quantity": quantity,
"price": price,
"status": status,
}
self.order_log.append(order)
return order
# %% [markdown]
# ### Live Path: Engine Wiring
#
# The live path constructs `AlpacaDataFeed > SafeBroker > LiveEngine`. Each helper is small enough to inspect
# at a glance; `run_engine_for_duration` is the only async piece, and `display_engine_results` is purely
# read-only post-processing.
# %%
def create_alpaca_engine(strategy):
"""Build the AlpacaDataFeed, SafeBroker, and LiveEngine wiring."""
safe_broker, _ = create_safe_broker(broker)
feed = AlpacaDataFeed(
api_key=ALPACA_API_KEY,
secret_key=ALPACA_SECRET_KEY,
symbols=SYMBOLS,
data_type="bars",
feed="iex",
)
engine = LiveEngine(strategy=strategy, broker=safe_broker, feed=feed)
# Alpaca SDK retries aggressively under nest_asyncio; quiet the retry logs.
for name in [
"alpaca",
"alpaca.data",
"alpaca.data.live",
"alpaca.data.live.websocket",
"alpaca.trading.stream",
"websockets",
]:
logging.getLogger(name).setLevel(logging.CRITICAL)
return engine, safe_broker, feed
# %%
async def run_engine_for_duration(engine, duration_s: int):
"""Connect the engine and let it stream for at most `duration_s` seconds."""
await asyncio.wait_for(engine.connect(), timeout=10)
try:
await asyncio.wait_for(engine.run(), timeout=duration_s)
except TimeoutError:
print(f"Demo duration ({duration_s}s) reached")
# %%
def display_engine_results(strategy, safe_broker, feed, engine):
"""Print engine stats and render the strategy's signal log as a Polars frame."""
print("Engine stats:", {k: engine.stats[k] for k in list(engine.stats)[:6]})
print("Feed stats: ", {k: feed.stats[k] for k in list(feed.stats)[:6]})
vp = safe_broker._virtual_portfolio
print(f"Virtual Portfolio cash: ${vp.cash:,.2f}")
for symbol, pos in vp.positions.items():
value = pos.quantity * (pos.current_price or pos.entry_price)
print(f" {symbol}: {pos.quantity} shares @ ${pos.entry_price:.2f} = ${value:,.2f}")
print(f"\nSignals: {len(strategy.signals)}")
return signals_to_frame(strategy.signals)
# %%
async def run_live_demo_with_feed():
"""Run the explicitly selected Alpaca or offline simulation path."""
if not LIVE_FEED:
print(f"LIVE_FEED={LIVE_FEED}: running the offline simulation")
return await run_simulated_demo()
if not HAS_CREDENTIALS or broker is None:
raise RuntimeError("LIVE_FEED requires the Alpaca SDK and paper credentials")
print("LIVE TRADING DEMO (Shadow Mode)")
strategy = ETFMomentumStrategy(lookback=5, threshold=0.02, position_size=10)
engine, safe_broker, feed = create_alpaca_engine(strategy)
print(f"Starting live engine for {DEMO_DURATION_SECONDS}s; watching {', '.join(SYMBOLS)}")
try:
await run_engine_for_duration(engine, DEMO_DURATION_SECONDS)
finally:
feed.stop()
return display_engine_results(strategy, safe_broker, feed, engine), pl.DataFrame()
# %%
async def run_simulated_demo() -> tuple[pl.DataFrame, pl.DataFrame]:
"""Run the strategy against the flat-dict MockBroker; return the signal log."""
print("SIMULATED DEMO (no live Alpaca feed)")
strategy = ETFMomentumStrategy(lookback=3, threshold=0.01, position_size=10)
mock_broker = MockBroker()
strategy.on_start(mock_broker)
set_global_seeds(SEED)
base_prices = dict(MockBroker.REF_PRICES)
start = datetime(2025, 1, 2, 14, 30, tzinfo=UTC)
for step in range(SIMULATION_STEPS):
timestamp = start + timedelta(minutes=step)
data = {}
for symbol in SYMBOLS:
base_prices[symbol] *= 1 + np.random.normal(0.001, 0.01)
data[symbol] = {
"open": base_prices[symbol] * 0.999,
"high": base_prices[symbol] * 1.002,
"low": base_prices[symbol] * 0.998,
"close": base_prices[symbol],
"volume": int(np.random.randint(100000, 1000000)),
}
mock_broker.update_market(timestamp, {symbol: bar["close"] for symbol, bar in data.items()})
strategy.on_data(timestamp, data, {}, mock_broker)
strategy.on_end(mock_broker)
print(f"Simulated cash: ${mock_broker.portfolio['cash']:,.2f}")
for symbol, pos in mock_broker.portfolio["positions"].items():
value = pos["quantity"] * pos["entry_price"]
print(f" {symbol}: {pos['quantity']} shares @ ${pos['entry_price']:.2f} = ${value:,.2f}")
print(f"Signals: {len(strategy.signals)}; Orders: {len(mock_broker.order_log)}")
signal_frame = signals_to_frame(strategy.signals)
order_frame = (
pl.DataFrame(mock_broker.order_log)
if mock_broker.order_log
else pl.DataFrame(
schema={
"order_id": pl.String,
"timestamp": pl.Datetime,
"symbol": pl.String,
"side": pl.String,
"quantity": pl.Int64,
"price": pl.Float64,
"status": pl.String,
}
)
)
return signal_frame, order_frame
# %%
# Run the demo
demo_signal_log, demo_order_log = run_async(run_live_demo_with_feed())
demo_signal_log
# %%
demo_order_log
# %% [markdown]
# **Finding**: The selected execution mode is explicit. The offline path exercises the same strategy interface
# with an inspectable broker, while the opt-in live path adds `SafeBroker` and Alpaca transport controls.
#
# **Trading implication**: Keeping the live and simulated paths structurally aligned makes it easier to
# detect true broker-side issues instead of debugging differences introduced by the demo environment.
#
# %% [markdown]
# ## 6. Order Type Demonstrations
#
# Alpaca supports various order types:
# - **MARKET**: Execute immediately at best available price
# - **LIMIT**: Execute at specified price or better
# - **STOP**: Trigger market order when price reaches stop
# - **STOP_LIMIT**: Trigger limit order when price reaches stop
# %%
async def demonstrate_order_types():
"""Demonstrate different order types (shadow mode)."""
if not HAS_CREDENTIALS or broker is None:
print("\nSkipping order demo - no credentials")
return
if not LIVE_FEED:
# SafeBroker requires fresh market data for staleness checks; without the
# live WebSocket feed there are no recent quotes, so submit_order_async
# raises RiskLimitError. Skip the demo under headless papermill.
print(f"\nLIVE_FEED={LIVE_FEED}: skipping order-type demo (needs live market data)")
return
print("\n" + "=" * 60)
print("ORDER TYPE DEMONSTRATIONS (Shadow Mode)")
print("=" * 60)
safe_broker, _ = create_safe_broker(broker)
from ml4t.backtest.types import OrderType
# Market order
print("\n1. MARKET ORDER")
order = await safe_broker.submit_order_async("SPY", 10, side=OrderSide.BUY)
print(f" Order ID: {order.order_id}")
print(" Type: MARKET")
print(f" Status: {order.status.value}")
# Limit order
print("\n2. LIMIT ORDER")
order = await safe_broker.submit_order_async(
"QQQ", 5, side=OrderSide.BUY, order_type=OrderType.LIMIT, limit_price=500.00
)
print(f" Order ID: {order.order_id}")
print(" Type: LIMIT @ $500.00")
print(f" Status: {order.status.value}")
# Stop order
print("\n3. STOP ORDER")
order = await safe_broker.submit_order_async(
"IWM", 10, side=OrderSide.SELL, order_type=OrderType.STOP, stop_price=220.00
)
print(f" Order ID: {order.order_id}")
print(" Type: STOP @ $220.00")
print(f" Status: {order.status.value}")
# Show virtual portfolio
vp = safe_broker._virtual_portfolio
print("\nVirtual Portfolio After Orders:")
print(f" Cash: ${vp.cash:,.2f}")
for symbol, pos in vp.positions.items():
print(f" {symbol}: {pos.quantity} shares")
run_async(demonstrate_order_types())
# %% [markdown]
# **Finding**: The optional live path defines shadow-mode examples for each order type. The default offline
# run skips those submissions explicitly because it has no Alpaca client, live feed, or `SafeBroker`.
#
# **Trading implication**: After credentials and a live paper feed are available, shadow-mode submission is
# the intermediate stage that tests routing, validation, and guardrails without creating exposure.
#
# %% [markdown]
# ## 7. Clean Shutdown
#
# The session should end with an explicit disconnect so the next run starts from a known broker state instead
# of inheriting stale subscriptions or session assumptions.
# %%
# Disconnect from Alpaca
if broker is not None:
run_async(broker.disconnect())
print("\nDisconnected from Alpaca")
# %% [markdown]
# ## Summary
#
# This notebook defines the complete optional Alpaca integration workflow and executes the deterministic
# offline strategy/broker path by default. With `LIVE_FEED=True`, credentials, and the SDK available, it can
# additionally exercise:
#
# 1. **Authentication**: API key/secret via environment variables
# 2. **Connection**: Paper trading account access
# 3. **Account Info**: Query equity, cash, positions
# 4. **Real-Time Feed**: Subscribe to bars/quotes/trades
# 5. **Safe Trading**: Use SafeBroker in shadow mode
# 6. **Order Types**: Market, limit, stop orders
#
# ### Alpaca vs IB Comparison
#
# | Feature | Alpaca | Interactive Brokers |
# |---------|--------|---------------------|
# | Minimum Balance | None | None (but higher margin reqs) |
# | Commissions | Free | $0-1 per trade |
# | Real-time Data | Free (IEX) | Paid subscription |
# | API Complexity | Simple REST | Complex socket protocol |
# | Crypto | Yes (24/7) | Limited |
# | Paper Trading | Yes | Yes |
#
# ### Next Steps
#
# 1. Run in shadow mode for 1-2 weeks
# 2. Verify signals match backtest expectations
# 3. Enable paper trading (`shadow_mode=False`)
# 4. Monitor for 2-4 weeks on paper
# 5. Gradually transition to live with small positions
#
# ### Crypto Trading
#
# See `05_alpaca_crypto_live_demo.py` for 24/7 crypto trading demonstration.
# %%
print("\n" + "=" * 60)
print("ALPACA PAPER TRADING DEMO COMPLETE")
print("=" * 60)
print(f"Symbols: {', '.join(SYMBOLS)}")
print(f"Paper Trading: {'YES' if PAPER_TRADING else 'NO'}")
shadow_mode_state = "ENABLED" if LIVE_FEED else "NOT ACTIVE (offline simulation)"
print(f"Shadow Mode: {shadow_mode_state}")
execution_mode = "Alpaca shadow feed" if LIVE_FEED else "offline simulation"
print(f"Execution Mode: {execution_mode}")
print("The same ETFMomentumStrategy interface drives the selected execution path.")
# %% [markdown]
# ## Key Takeaways
#
# 1. **The broker is the smallest part of the change.** Moving from Interactive Brokers to Alpaca
# swaps a broker class and a feed class. The strategy, the risk wrapper, the order log and the
# engine are the same objects. That is what a broker abstraction is for, and it is the reason
# the choice of venue is an operational decision rather than a research one.
# 2. **Check the environment, the SDK and the account separately.** They fail differently and are
# fixed differently: a missing key is a setup problem, a missing package is an install problem,
# and a rejected account is an entitlement problem. One combined "connection failed" hides all
# three.
# 3. **Shadow mode is where a live deployment should start.** Real prices, real account state,
# real strategy, and orders that are logged instead of sent. Everything except the one step
# that cannot be undone.
# 4. **Say which parts of a run were real.** This one ran offline, so it established that the
# strategy interface and the order log work, and established nothing whatever about Alpaca's
# fills, latency or rejections. A demonstration that does not distinguish the two teaches a
# reader to trust the wrong half.
#
# **Known limitations of what is built here.** The default path never contacts Alpaca, so none of
# the failure modes that matter live - a rejected order, a partial fill, a disconnected socket, a
# stale quote - appears anywhere in it. The simulated bars are drawn from a fixed seed rather than
# from a market. And shadow mode proves that orders were not sent, not that they would have been
# filled at the prices assumed.
#
# **Next**: [`05_alpaca_crypto_live_demo`](05_alpaca_crypto_live_demo.ipynb) takes the same
# deployment pattern to a market that never closes, which removes the session boundaries this
# notebook could rely on.