Files
momentum-system/tests/unit/alpaca_paper/test_execution_and_sync.py
T
momentum devandClaude Fable 5.1 b0fb0582d7 fix: journal round trips that open and close between two broker polls (TNON)
The trade journal learns about exits only by diffing consecutive position
snapshots. On 2026-10-02 TNON was bought at 09:38:37 and its bracket stop filled
at 09:39:26, so no snapshot ever held it and no position_closed was written: the
80-trade scorecard dropped a -1R loser and the re-entry bench fired on a trade the
journal did not have.

AlpacaPaperSync.reconcile_unseen_round_trips runs after the snapshot diff on every
sync. For a symbol open in neither snapshot that has a filled buy and a filled sell
submitted this session (ET), it emits one position_closed per unjournaled exit fill:
entry is the VWAP of the buy fills, exit the sell's fill, stop the session's stop
order, position_id the exit's broker order id. Exit ids already journaled (by the
diff path or an earlier run, seeded from the store) are skipped, so nothing is
double-counted across polls or restarts. Long round trips only; the bot is long-only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXxioW7HA18AY1gqnzA2HN
2026-10-10 10:04:04 -05:00

418 lines
17 KiB
Python

"""Alpaca paper executor + account sync tests (mocked client)."""
import json
import pytest
from alpaca_paper.client import AlpacaApiError
from alpaca_paper.execution import AlpacaPaperExecutor, ExecutionRequest
from alpaca_paper.sync import AlpacaPaperSync
from storage.event_store import EventStore
from storage.projections import (
query_account_orders_snapshot,
query_account_positions_snapshot,
query_account_summary_snapshot,
query_session_pnl,
)
class FakeClient:
def __init__(self):
self.submitted = []
self.fail_with = None
def submit_order(self, **kw):
if self.fail_with:
raise self.fail_with
self.submitted.append(kw)
return {"id": "broker-123", "status": "accepted", "filled_avg_price": None}
def get_account(self):
return {
"account_number": "PA-TEST",
"equity": "100250.50",
"cash": "60000",
"buying_power": "200501",
}
def get_positions(self):
return [
{
"symbol": "GOOD",
"qty": "10",
"avg_entry_price": "13.90",
"current_price": "14.10",
"unrealized_pl": "2.0",
}
]
def get_orders(self, status="all", limit=100, nested=False):
return [{"id": "broker-123", "symbol": "GOOD", "status": "filled"}]
@pytest.fixture
def store():
s = EventStore(":memory:")
yield s
s.close()
def test_executor_submits_and_emits(store):
client = FakeClient()
executor = AlpacaPaperExecutor(store, client=client, session_id="t")
request = ExecutionRequest(
symbol="GOOD", side="buy", quantity=10,
entry_price=14.0, stop_loss_price=13.45,
)
result = executor.execute(request)
assert result.ok
assert result.broker_order_id == "broker-123"
assert client.submitted[0]["symbol"] == "GOOD"
submitted = store.query_events(event_type="order_submitted")
assert len(submitted) == 1
def test_executor_emits_fill_with_side(store):
"""OrderFilledEvent must carry the request's side (schema gap fix) so
consumers don't need the order_submitted backfill workaround."""
class FilledClient(FakeClient):
def submit_order(self, **kw):
self.submitted.append(kw)
return {"id": "broker-123", "status": "filled",
"filled_qty": "10", "filled_avg_price": "14.05"}
client = FilledClient()
executor = AlpacaPaperExecutor(store, client=client, session_id="t")
request = ExecutionRequest(
symbol="GOOD", side="buy", quantity=10,
entry_price=14.0, stop_loss_price=13.45,
)
result = executor.execute(request)
assert result.ok
filled = store.query_events(event_type="order_filled")
assert len(filled) == 1
payload = json.loads(filled[0]["payload_json"])
assert payload["side"] == "buy"
def test_executor_rejects_zero_quantity(store):
executor = AlpacaPaperExecutor(store, client=FakeClient(), session_id="t")
result = executor.execute(ExecutionRequest(symbol="GOOD", side="buy", quantity=0))
assert not result.ok
assert "quantity" in (result.error or "")
def test_executor_api_failure_emits_cancelled(store):
client = FakeClient()
client.fail_with = AlpacaApiError(403, "forbidden")
executor = AlpacaPaperExecutor(store, client=client, session_id="t")
result = executor.execute(ExecutionRequest(symbol="GOOD", side="buy", quantity=5))
assert not result.ok
assert store.query_events(event_type="order_cancelled")
def test_execution_request_payload_round_trip():
request = ExecutionRequest(
symbol="GOOD", side="buy", quantity=7,
entry_price=10.0, stop_loss_price=9.5, take_profit_price=11.0,
)
clone = ExecutionRequest.from_payload(request.to_payload())
assert clone.order_id == request.order_id
assert clone.symbol == "GOOD"
assert clone.quantity == 7
assert clone.stop_loss_price == 9.5
def test_sync_labels_events_by_client_broker(store):
client = FakeClient()
client.broker_name = "tradier"
sync = AlpacaPaperSync(store, client=client, session_id="t")
sync.sync_all()
accounts = query_account_summary_snapshot(store, broker_name="tradier")
assert accounts and accounts[0]["account_desc"] == "Tradier"
# executor filters by client.broker_name — events must match it
assert query_account_positions_snapshot(store, broker_name="tradier")
assert query_account_summary_snapshot(store, broker_name="alpaca_paper") == []
def test_sync_all_populates_snapshots(store):
sync = AlpacaPaperSync(store, client=FakeClient(), session_id="t")
sync.sync_all()
accounts = query_account_summary_snapshot(store, broker_name="alpaca_paper")
assert accounts and accounts[0]["total_equity"] == pytest.approx(100250.50)
positions = query_account_positions_snapshot(store, broker_name="alpaca_paper")
assert positions[-1]["positions"][0]["symbol"] == "GOOD"
orders = query_account_orders_snapshot(store, broker_name="alpaca_paper")
assert orders[-1]["orders"][0]["broker_order_id"] == "broker-123"
def test_sync_survives_client_failure(store):
class Down(FakeClient):
def get_account(self):
raise AlpacaApiError(500, "down")
def get_positions(self):
raise AlpacaApiError(500, "down")
def get_orders(self, status="all", limit=100, nested=False):
raise AlpacaApiError(500, "down")
sync = AlpacaPaperSync(store, client=Down(), session_id="t")
sync.sync_all() # must not raise
assert query_account_summary_snapshot(store) == []
# --- position-close reconciliation (trade journal) ---------------------------
class ProgClient:
"""A client whose positions/orders we change between sync cycles to
simulate a position opening then closing."""
def __init__(self):
self.positions = []
self.orders = []
def get_account(self):
return {"account_number": "PA", "equity": "100000", "cash": "50000", "buying_power": "100000"}
def get_positions(self):
return self.positions
def get_orders(self, status="all", limit=100, nested=False):
return self.orders
def _long(sym, entry, cur):
return {"symbol": sym, "qty": "10", "avg_entry_price": str(entry),
"current_price": str(cur), "unrealized_pl": "0", "side": "long"}
def _order(oid, sym, side, otype, status, px, *, stop=None, at="2026-06-22T14:00:00Z"):
return {"id": oid, "symbol": sym, "side": side, "qty": "10", "filled_qty": "10",
"type": otype, "status": status, "stop_price": (str(stop) if stop else None),
"filled_avg_price": (str(px) if px is not None else None), "submitted_at": at}
def _closed(store):
return [json.loads(e["payload_json"]) for e in store.query_events(event_type="position_closed")]
def test_reconcile_emits_close_on_stop_fill(store):
c = ProgClient()
sync = AlpacaPaperSync(store, client=c, session_id="t")
# cycle 1: GOOD opens, only the entry fill present
c.positions = [_long("GOOD", 13.90, 14.10)]
c.orders = [_order("buy-1", "GOOD", "buy", "market", "filled", 13.90, at="2026-06-22T13:30:00Z")]
sync.sync_all()
assert _closed(store) == []
# cycle 2: GOOD gone, its bracket STOP leg filled at 13.45
c.positions = []
c.orders.append(_order("stop-1", "GOOD", "sell", "stop", "filled", 13.45, stop=13.45))
sync.sync_all()
rows = _closed(store)
assert len(rows) == 1
p = rows[0]
assert (p["symbol"], p["exit_price"], p["exit_reason"]) == ("GOOD", 13.45, "stop_loss")
assert p["realized_pnl"] == -4.5 # (13.45 - 13.90) * 10
assert p["entry_price"] == 13.90 and p["stop_loss_price"] == 13.45
# the trade journal now populates
pnl = query_session_pnl(store)
assert pnl["closed_trades"] == 1 and pnl["losses"] == 1
assert pnl["avg_r_multiple"] == -1.0 # (13.45-13.90)/(13.90-13.45)
def test_reconcile_take_profit_is_a_win(store):
c = ProgClient()
sync = AlpacaPaperSync(store, client=c, session_id="t")
c.positions = [_long("WIN", 10.0, 10.0)]
c.orders = [_order("buy", "WIN", "buy", "market", "filled", 10.0, at="2026-06-22T13:30:00Z")]
sync.sync_all()
c.positions = []
c.orders.append(_order("tp", "WIN", "sell", "limit", "filled", 11.0))
sync.sync_all()
p = _closed(store)[0]
assert p["exit_reason"] == "take_profit" and p["realized_pnl"] == 10.0
pnl = query_session_pnl(store)
assert pnl["wins"] == 1 and pnl["win_rate"] == 1.0
def test_reconcile_no_emit_while_open(store):
c = ProgClient()
sync = AlpacaPaperSync(store, client=c, session_id="t")
c.positions = [_long("HOLD", 5.0, 5.2)]
sync.sync_all(); sync.sync_all() # still open across cycles
assert _closed(store) == []
def test_reconcile_fallback_to_last_mark_without_fill(store):
c = ProgClient()
sync = AlpacaPaperSync(store, client=c, session_id="t")
c.positions = [_long("GAP", 4.0, 4.30)] # last mark 4.30
sync.sync_all()
c.positions = []
c.orders = [] # no exit fill visible yet
sync.sync_all()
p = _closed(store)[0]
assert p["exit_reason"] == "closed" and p["exit_price"] == 4.30
assert p["realized_pnl"] == 3.0 # (4.30 - 4.0) * 10
def test_reconcile_seeds_from_persisted_snapshot_after_restart(store):
# first process records GOOD open, then "crashes"
c = ProgClient()
s1 = AlpacaPaperSync(store, client=c, session_id="t")
c.positions = [_long("REST", 2.0, 2.1)]
s1.sync_all()
# NEW sync instance (fresh in-memory baseline) sees GOOD already gone
c.positions = []
c.orders = [_order("x", "REST", "sell", "market", "filled", 2.5)]
s2 = AlpacaPaperSync(store, client=c, session_id="t")
s2.sync_all()
rows = _closed(store)
assert len(rows) == 1 and rows[0]["symbol"] == "REST"
assert rows[0]["realized_pnl"] == 5.0 # (2.5 - 2.0) * 10
def test_post_not_retried_on_urlerror(monkeypatch):
"""Lost-response URLError on POST /orders must NOT re-send (duplicate live
order, 2026-07-06). GET/DELETE still retry."""
import urllib.error
import urllib.request
from alpaca_paper.client import AlpacaPaperClient
from alpaca_paper.settings import AlpacaPaperSettings
cli = AlpacaPaperClient.__new__(AlpacaPaperClient)
cli.timeout = 1
cli.max_retries = 2
cli._headers = lambda: {}
calls = {"n": 0}
def boom(req, timeout=None):
calls["n"] += 1
raise urllib.error.URLError("connection reset")
monkeypatch.setattr(urllib.request, "urlopen", boom)
import pytest
with pytest.raises(urllib.error.URLError):
cli._request("POST", "https://x/v2/orders", body={"symbol": "AAA"})
assert calls["n"] == 1, "POST must not retry a lost-response URLError"
calls["n"] = 0
with pytest.raises(urllib.error.URLError):
cli._request("GET", "https://x/v2/account")
assert calls["n"] > 1, "idempotent GET should still retry"
# --- lost-response reconcile (plan 003 step 4) -------------------------------
def test_lost_submit_recovered_via_client_order_id(store):
"""A transport error AFTER the POST landed: the order is live at the broker.
Reconcile by client_order_id -> emit submitted (not cancelled), ok=True."""
class LostThenFound(FakeClient):
def submit_order(self, **kw):
raise ConnectionError("connection reset after POST")
def get_order_by_client_order_id(self, coid):
return {"id": "broker-999", "status": "accepted", "client_order_id": coid}
executor = AlpacaPaperExecutor(store, client=LostThenFound(), session_id="t")
result = executor.execute(ExecutionRequest(symbol="GOOD", side="buy", quantity=5,
entry_price=14.0, stop_loss_price=13.45))
assert result.ok and result.broker_order_id == "broker-999"
assert len(store.query_events(event_type="order_submitted")) == 1
assert store.query_events(event_type="order_cancelled") == []
def test_lost_submit_not_found_emits_cancelled(store):
"""Transport error and the order never landed (404 -> lookup raises): emit
cancelled, ok=False (unchanged behavior, now with an honest reason)."""
class LostNotFound(FakeClient):
def submit_order(self, **kw):
raise ConnectionError("connection reset before POST")
def get_order_by_client_order_id(self, coid):
raise AlpacaApiError(404, "order not found")
executor = AlpacaPaperExecutor(store, client=LostNotFound(), session_id="t")
result = executor.execute(ExecutionRequest(symbol="GOOD", side="buy", quantity=5))
assert not result.ok
assert store.query_events(event_type="order_cancelled")
assert store.query_events(event_type="order_submitted") == []
def test_broker_rejection_not_reconciled(store):
"""An AlpacaApiError is a genuine rejection, NOT a lost response — it must not
trigger a reconcile lookup (regression guard on the two-branch split)."""
class Rejected(FakeClient):
def __init__(self):
super().__init__()
self.lookup_calls = 0
def submit_order(self, **kw):
raise AlpacaApiError(422, "insufficient buying power")
def get_order_by_client_order_id(self, coid):
self.lookup_calls += 1
return {"id": "x", "status": "accepted"}
client = Rejected()
executor = AlpacaPaperExecutor(store, client=client, session_id="t")
result = executor.execute(ExecutionRequest(symbol="GOOD", side="buy", quantity=5))
assert not result.ok
assert client.lookup_calls == 0
assert store.query_events(event_type="order_cancelled")
def _today_iso(hhmm):
"""An ISO timestamp on today's ET session date, so the same-session filter admits it."""
from datetime import datetime
from zoneinfo import ZoneInfo
d = datetime.now(ZoneInfo("America/New_York")).date()
return f"{d.isoformat()}T{hhmm}:00-04:00"
def test_round_trip_between_polls_is_journaled_once(store):
# TNON 2026-10-02: bought 7 @4.62 and the bracket stop filled 49 s later — no positions
# snapshot ever held it, so the snapshot diff alone journaled nothing.
c = ProgClient()
sync = AlpacaPaperSync(store, client=c, session_id="t")
c.positions = []
sync.sync_all() # baseline: flat, no orders
c.orders = [
{"id": "p-1", "symbol": "TNON", "side": "buy", "qty": "7", "filled_qty": "0", "type": None,
"status": "filled", "stop_price": None, "filled_avg_price": None, "submitted_at": _today_iso("09:38")},
{"id": "b-1", "symbol": "TNON", "side": "buy", "qty": "7", "filled_qty": "7", "type": "limit",
"status": "filled", "stop_price": None, "filled_avg_price": "4.62", "submitted_at": _today_iso("09:38")},
{"id": "tp-1", "symbol": "TNON", "side": "sell", "qty": "7", "filled_qty": "0", "type": "limit",
"status": "canceled", "stop_price": None, "filled_avg_price": None, "submitted_at": _today_iso("09:38")},
{"id": "s-1", "symbol": "TNON", "side": "sell", "qty": "7", "filled_qty": "7", "type": "stop",
"status": "filled", "stop_price": "4.30", "filled_avg_price": "4.30", "submitted_at": _today_iso("09:38")},
]
sync.sync_all() # still flat: the whole trade happened between polls
rows = _closed(store)
assert len(rows) == 1
p = rows[0]
assert (p["symbol"], p["entry_price"], p["exit_price"], p["exit_reason"]) == ("TNON", 4.62, 4.30, "stop_loss")
assert p["quantity"] == 7 and p["realized_pnl"] == -2.24 and p["stop_loss_price"] == 4.30
assert p["position_id"] == "s-1"
sync.sync_all() # the next poll must not journal it again
assert len(_closed(store)) == 1
# ...and neither must a fresh sync object after a restart (seeded from the store)
AlpacaPaperSync(store, client=c, session_id="t2").sync_all()
assert len(_closed(store)) == 1
def test_round_trip_pass_leaves_snapshot_diff_closes_alone(store):
# the normal path: GOOD opens in one poll and is gone in the next; exactly one close,
# even though the orders list then shows a completed buy+sell for a flat symbol
c = ProgClient()
sync = AlpacaPaperSync(store, client=c, session_id="t")
c.positions = [_long("GOOD", 13.90, 14.10)]
c.orders = [_order("buy-1", "GOOD", "buy", "market", "filled", 13.90, at=_today_iso("09:31"))]
sync.sync_all()
c.positions = []
c.orders.append(_order("stop-1", "GOOD", "sell", "stop", "filled", 13.45, stop=13.45, at=_today_iso("09:40")))
sync.sync_all(); sync.sync_all()
assert [p["position_id"] for p in _closed(store)] == ["stop-1"]
def test_round_trip_pass_ignores_other_sessions_and_open_names(store):
c = ProgClient()
sync = AlpacaPaperSync(store, client=c, session_id="t")
c.positions = [_long("HOLD", 5.0, 5.2)]
c.orders = [
# yesterday's completed trade must not be re-journaled today
_order("ob", "OLD", "buy", "limit", "filled", 2.0, at="2026-06-22T13:31:00Z"),
_order("os", "OLD", "sell", "stop", "filled", 1.9, stop=1.9, at="2026-06-22T13:35:00Z"),
# a name that is still open: its partial sell is the diff path's business
_order("hb", "HOLD", "buy", "limit", "filled", 5.0, at=_today_iso("09:31")),
_order("hs", "HOLD", "sell", "limit", "filled", 5.3, at=_today_iso("09:50")),
]
sync.sync_all(); sync.sync_all()
assert _closed(store) == []