Skip to content

Commit 2ffdc3a

Browse files
Melly-999claude
andauthored
feat(broker): add Alpaca paper order submit sandbox (#311)
Add a backend-only, multi-gated, PAPER-ONLY order submission sandbox. Blocked by default; it submits a single paper order to Alpaca Paper ONLY when every explicit gate is satisfied. Not live trading, not frontend UX, not autotrade. Gates (all required): ALPACA_ENV=paper, ALPACA_PAPER_ORDER_SUBMIT_ENABLED=true, ALPACA_PAPER_ORDER_SUBMIT_SANDBOX_ACK=I_UNDERSTAND_THIS_SUBMITS_A_PAPER_ORDER, paper credentials present, request confirm_paper_order=true and source="manual_sandbox", draft risk validation, and conservative size caps (qty<=100, notional<=5000; market/limit only). Any failure -> no Alpaca call, safe blocked response (HTTP 200), safety flags preserved. - app/schemas/alpaca_paper_order_submit_sandbox.py — request/response (extra="forbid"); response locks paper_only=true, live_trading=false, live_orders_blocked=true, dry_run=true, read_only_posture_preserved=true, execution_enabled=false, requires_human_review=true; only redacted ids. - app/services/alpaca_paper_order_submit_sandbox_service.py — gated service with an isolated paper-only client wrapper. Lazy SDK import only after gates; TradingClient(paper=True) only; single non-retried submit; raw order id redacted. Legacy live adapter (brokers/alpaca_adapter.py) NOT imported. Injected fake client supported for tests. - app/api/routes/alpaca_paper.py — POST /api/alpaca-paper/order-submit-sandbox. - tests/app/test_alpaca_paper_order_submit_sandbox.py — 36 tests (default blocked; every gate; validation reuse + caps; gated fake-client submit called once; redaction/no leak; dry-run-preview; raising client; no network; route POST-only/405; OpenAPI; allowlists do not exempt live paths). - tests/app/test_safety_invariants.py & test_paper_sandbox_guardrails.py — narrow, path-specific allowlist registration with justification (additive). - docs/tasks/alpaca_paper_order_submit_sandbox_001.md (incl. manual-smoke policy). No live endpoint, no live credentials, no frontend trading controls, no secrets. No manual real-paper smoke performed (requires separate explicit approval). Global safety posture unchanged: autotrade=false, dry_run=true, read_only preserved, live_orders_blocked=true, max risk <= 1%. Co-authored-by: Melly <Melly-999@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 60b0a34 commit 2ffdc3a

7 files changed

Lines changed: 1042 additions & 0 deletions

app/api/routes/alpaca_paper.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@
2929
AlpacaPaperOrderDraftResponse,
3030
)
3131
from app.schemas.alpaca_paper_order_preview import AlpacaPaperOrderPreviewResponse
32+
from app.schemas.alpaca_paper_order_submit_sandbox import (
33+
AlpacaPaperOrderSubmitSandboxRequest,
34+
AlpacaPaperOrderSubmitSandboxResponse,
35+
)
3236
from app.schemas.alpaca_paper_readonly import AlpacaPaperPositionsPreview
3337
from app.services.alpaca_paper_demo import AlpacaPaperDemoService
3438
from app.services.alpaca_paper_order_draft_service import (
@@ -37,13 +41,19 @@
3741
from app.services.alpaca_paper_order_preview_service import (
3842
generate_alpaca_paper_order_preview,
3943
)
44+
from app.services.alpaca_paper_order_submit_sandbox_service import (
45+
AlpacaPaperOrderSubmitSandboxService,
46+
)
4047
from app.services.alpaca_paper_readonly_adapter import AlpacaPaperReadOnlyAdapter
4148

4249
router = APIRouter(tags=["alpaca-paper"])
4350
service = AlpacaPaperDemoService()
4451
# Default adapter: no injected client -> degraded_demo unless read-only is
4552
# explicitly enabled for a paper environment with credentials present.
4653
readonly_adapter = AlpacaPaperReadOnlyAdapter()
54+
# Default submit sandbox: no injected client -> blocked unless every explicit
55+
# paper-only gate is satisfied (env flags + ACK + credentials + confirmation).
56+
submit_sandbox_service = AlpacaPaperOrderSubmitSandboxService()
4757

4858

4959
@router.get("/alpaca-paper/status", response_model=AlpacaPaperStatus)
@@ -172,6 +182,45 @@ def post_alpaca_paper_order_draft(
172182
return build_alpaca_paper_order_draft(request)
173183

174184

185+
_SUBMIT_SANDBOX_DESCRIPTION = (
186+
"ALPACA-PAPER-ORDER-SUBMIT-SANDBOX-001 — backend-only, multi-gated, "
187+
"PAPER-ONLY order submission sandbox for local/manual testing. "
188+
"\n\n"
189+
"**Blocked by default.** A real Alpaca **Paper** submission is attempted ONLY "
190+
"when every explicit gate is satisfied: ALPACA_ENV=paper, "
191+
"ALPACA_PAPER_ORDER_SUBMIT_ENABLED=true, the acknowledgement env gate, "
192+
"Alpaca paper credentials present, request confirm_paper_order=true, and "
193+
"source='manual_sandbox' — plus the standard draft risk validation and "
194+
"conservative sandbox size caps. If any gate fails, no Alpaca call is made "
195+
"and a safe blocked response (HTTP 200) is returned. "
196+
"\n\n"
197+
"**This is NOT live trading.** No live endpoint, no autotrade, no frontend "
198+
"control, no cancellation/replacement, no bracket/OCO. live_orders_blocked "
199+
"and dry_run remain true; only a paper order may be submitted, and only a "
200+
"redacted order id is returned (never a raw broker order id, account id, or "
201+
"credential)."
202+
)
203+
204+
205+
@router.post(
206+
"/alpaca-paper/order-submit-sandbox",
207+
response_model=AlpacaPaperOrderSubmitSandboxResponse,
208+
summary="Alpaca paper order submit SANDBOX — gated, paper-only, not live",
209+
description=_SUBMIT_SANDBOX_DESCRIPTION,
210+
operation_id="post_alpaca_paper_order_submit_sandbox",
211+
)
212+
def post_alpaca_paper_order_submit_sandbox(
213+
request: AlpacaPaperOrderSubmitSandboxRequest,
214+
) -> AlpacaPaperOrderSubmitSandboxResponse:
215+
"""Attempt a gated, paper-only order submission for manual sandbox testing.
216+
217+
Blocked by default; submits to Alpaca Paper only when every gate is
218+
satisfied. Never live trading, never frontend-triggered. Returns a safe
219+
blocked response (HTTP 200) on any gate/validation failure.
220+
"""
221+
return submit_sandbox_service.submit(request)
222+
223+
175224
@router.get(
176225
"/alpaca-paper/order-preview",
177226
response_model=AlpacaPaperOrderPreviewResponse,
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""Alpaca Paper order SUBMIT SANDBOX schemas.
2+
3+
ALPACA-PAPER-ORDER-SUBMIT-SANDBOX-001 — request/response models for the
4+
**backend-only, multi-gated, paper-only** order submission sandbox.
5+
6+
This surface can submit a single small order to Alpaca **Paper** only when every
7+
explicit gate is satisfied. It is **not** live trading, **not** frontend UX, and
8+
**not** autotrading. It is blocked by default.
9+
10+
Safety semantics encoded in the response:
11+
- ``paper_only=true``, ``live_trading=false``, ``live_orders_blocked=true``,
12+
``dry_run=true``, ``read_only_posture_preserved=true``,
13+
``execution_enabled=false`` (no *live* execution), ``requires_human_review=true``.
14+
- A submitted **paper** order does not change the global dry-run / live-orders
15+
posture: paper submission is a separate, manually-gated sandbox path.
16+
- No ``account_id`` / raw ``broker_order_id`` / ``api_key`` / ``secret`` /
17+
``token`` is ever represented. Only a redacted order id is returned.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
from typing import Literal, Optional
23+
24+
from pydantic import BaseModel, ConfigDict, Field
25+
26+
27+
class AlpacaPaperOrderSubmitSandboxRequest(BaseModel):
28+
"""Input for a gated paper order submission attempt.
29+
30+
``side`` / ``order_type`` / ``time_in_force`` / ``source`` are plain strings
31+
so invalid values are reported as a blocked response (HTTP 200) rather than a
32+
parse error. Provide exactly one of ``quantity`` / ``notional``.
33+
``entry_price`` / ``stop_loss`` / ``take_profit`` are required (risk geometry
34+
is validated even though only the entry order is submitted — no bracket/OCO).
35+
"""
36+
37+
model_config = ConfigDict(extra="forbid")
38+
39+
symbol: str = Field(min_length=1, max_length=32)
40+
side: str = Field(description="BUY or SELL")
41+
order_type: str = Field(default="market", description="market or limit")
42+
time_in_force: str = Field(default="day", description="day/gtc/ioc/fok/opg/cls")
43+
quantity: Optional[float] = Field(default=None)
44+
notional: Optional[float] = Field(default=None)
45+
limit_price: Optional[float] = Field(
46+
default=None, description="Required only for order_type=limit"
47+
)
48+
entry_price: Optional[float] = Field(default=None, description="Reference price")
49+
stop_loss: Optional[float] = Field(default=None)
50+
take_profit: Optional[float] = Field(default=None)
51+
max_risk_pct: float = Field(description="Per-trade risk; capped at 1.0")
52+
confirm_paper_order: bool = Field(
53+
default=False, description="Must be true to attempt a paper submission"
54+
)
55+
source: str = Field(
56+
default="", description='Must be "manual_sandbox" to attempt submission'
57+
)
58+
client_order_id_prefix: Optional[str] = Field(
59+
default=None, max_length=32, description="Optional safe prefix for our id"
60+
)
61+
dry_run_preview_only: bool = Field(
62+
default=False,
63+
description="If true, exercise all gates but never submit (no Alpaca call)",
64+
)
65+
66+
67+
class AlpacaPaperOrderSubmitSandboxResponse(BaseModel):
68+
"""Result of a gated paper order submission attempt."""
69+
70+
model_config = ConfigDict(extra="forbid")
71+
72+
accepted: bool
73+
submitted_to_alpaca_paper: bool = False
74+
blocked_reason: Optional[str] = None
75+
76+
# Safety posture — locked.
77+
paper_only: Literal[True] = True
78+
live_trading: Literal[False] = False
79+
live_orders_blocked: Literal[True] = True
80+
dry_run: Literal[True] = True
81+
read_only_posture_preserved: Literal[True] = True
82+
execution_enabled: Literal[False] = False
83+
requires_human_review: Literal[True] = True
84+
85+
# Whether submission was actually enabled (all gates satisfied).
86+
order_submission_enabled: bool = False
87+
88+
# Redacted identifiers only — never a raw broker order id / account id.
89+
redacted_order_id: Optional[str] = None
90+
client_order_id: Optional[str] = None
91+
order_status: Optional[str] = None
92+
93+
message: str = (
94+
"Paper sandbox only — blocked by default; submits to Alpaca Paper only "
95+
"when every explicit gate is satisfied. Not live trading."
96+
)

0 commit comments

Comments
 (0)