13 — API & Event Contracts¶
Types are given as Pydantic models because in this system a Pydantic model is simultaneously the API schema, the validation gate, the LLM structured-output schema, and the source of the web client's TypeScript types. One definition, four uses — which is why contract drift becomes a build failure rather than a runtime surprise.
1. Core domain types¶
# ---------- Identity ----------
class InstrumentRef(BaseModel):
id: UUID
ticker: str
exchange_mic: str
name: str
currency: str
# ---------- Provenance: attached to everything the user can see ----------
class FeatureRef(BaseModel):
name: str
value: Decimal | float | None
as_of: datetime
stale: bool = False
class Citation(BaseModel):
source_doc_id: UUID
doc_type: str
title: str | None
excerpt: str # the text actually read, not a summary of it
published_at: datetime
available_at: datetime
trust_tier: Literal["A", "B", "C"]
url: str | None
# ---------- Compliance ----------
class RatioResult(BaseModel):
id: str
label: str
numerator_name: str
numerator_value: Decimal | None
denominator_name: str
denominator_value: Decimal | None
ratio: Decimal | None
threshold: Decimal
operator: Literal["<=", "<", ">=", ">"]
passed: bool | None # None when inputs are missing
inputs: list[FeatureRef]
citations: list[Citation]
class ShariahVerdict(BaseModel):
instrument: InstrumentRef
rule_pack: str
rule_pack_version: str
status: Literal["COMPLIANT", "NON_COMPLIANT", "UNCERTAIN"]
business_activity: ActivityScreenResult
financial_ratios: list[RatioResult]
impure_income: ImpureIncomeResult
failing_criteria: list[str]
uncertainty_causes: list[str]
purification_rate: Decimal | None
explanation: str
as_of: datetime
# ---------- Recommendation ----------
class AgentContribution(BaseModel):
agent: AgentName
direction: Literal["BULLISH", "BEARISH", "NEUTRAL"]
summary: str
shap_contribution: float # signed; drives the contribution bars in-app
abstained: bool
citations: list[Citation]
class RiskPlan(BaseModel):
suggested_quantity: Decimal
suggested_notional: Decimal
pct_of_equity: Decimal
stop_price: Decimal
stop_basis: str # "2.5 × ATR(14)" | "structural: 2026-05-14 low"
target_price: Decimal | None
expected_downside: Decimal # currency, not percent — see [07 §9]
binding_constraint: str | None # which limit determined the size, if any
sizing_method_used: Literal["VOL_TARGET", "STOP_RISK", "KELLY_CAPPED"]
class Recommendation(BaseModel):
id: UUID
instrument: InstrumentRef
created_at: datetime
action: Literal["BUY", "SELL", "HOLD"]
confidence: float = Field(ge=0, le=1) # calibrated, or capped when pre-calibration
calibration_status: Literal["CALIBRATED", "PRE_CALIBRATION"]
risk_score: float = Field(ge=0, le=1)
horizon: Literal["DAYS", "WEEKS", "MONTHS", "QUARTERS"]
expected_return: Decimal
expected_downside: Decimal
thesis: str # three sentences, plain language
falsifiers: list[Falsifier] # required, non-empty
shariah: ShariahVerdict # always COMPLIANT here by construction
contributions: list[AgentContribution]
risk_plan: RiskPlan
citations: list[Citation]
data_quality: DataQualityBadge
manifest_id: UUID # the full audit manifest
class Falsifier(BaseModel):
condition: str # "Q3 order backlog growth below 5%"
monitorable: bool
status: Literal["NOT_TRIGGERED", "TRIGGERED", "UNMONITORABLE"]
class RejectedCandidate(BaseModel):
"""Refusals are first-class output — see [09 §5]."""
instrument: InstrumentRef
stage: Literal["COMPLIANCE", "AGGREGATION", "SUPERVISOR_VETO", "RISK"]
reason_code: str
explanation: str
shariah: ShariahVerdict | None
created_at: datetime
RejectedCandidate being a returned type rather than a log line is a deliberate product decision: the "Why not?" view is where compliance trust is built.
2. Portfolio and orders¶
class Position(BaseModel):
instrument: InstrumentRef
quantity: Decimal
avg_cost: Decimal
market_value: Decimal
weight: Decimal
unrealised_pnl: Decimal
realised_pnl: Decimal
compliance_status: Literal["COMPLIANT", "AT_RISK", "NON_COMPLIANT", "UNCERTAIN"]
purification_accrued: Decimal
correlation_cluster_id: str | None
opened_at: datetime
class Portfolio(BaseModel):
id: UUID
mode: Literal["PAPER"] # LIVE does not exist before Phase 5
currency: str
cash: Decimal
pending_settlement: Decimal
equity: Decimal
positions: list[Position]
purification_liability: Decimal
as_of: datetime
class OrderRequest(BaseModel):
instrument_id: UUID
side: Literal["BUY", "SELL"] # no SHORT — see [06 §9]
quantity: Decimal
order_type: Literal["MARKET", "LIMIT", "STOP", "STOP_LIMIT", "TRAILING_STOP"]
limit_price: Decimal | None = None
stop_price: Decimal | None = None
time_in_force: Literal["DAY", "GTC", "IOC", "FOK"] = "DAY"
recommendation_id: UUID | None = None
override_risk_warning: bool = False # requires an explicit user acknowledgement
class OrderPreview(BaseModel):
"""Returned before confirmation — powers the risk preview in [09 §8]."""
estimated_fill_price: Decimal
estimated_spread_cost: Decimal
estimated_impact_cost: Decimal
estimated_commission: Decimal
estimated_total: Decimal
post_trade_weight: Decimal
post_trade_sector_weight: Decimal
post_trade_cash: Decimal
risk_warnings: list[RiskWarning]
compliance_recheck: ShariahVerdict
Two absences are the contract: there is no SHORT side, and mode has one legal value. These are not validation rules that could be relaxed — they are the type.
3. REST surface¶
Versioned under /v1. All responses carry as_of, and any endpoint that reads point-in-time data accepts an optional as_of query parameter for historical reconstruction.
Recommendations
GET /v1/recommendations ?status&since&limit
GET /v1/recommendations/{id}
GET /v1/recommendations/{id}/manifest full audit manifest
GET /v1/recommendations/{id}/similar case memory
POST /v1/recommendations/{id}/feedback {rating, skip_reason}
GET /v1/rejected ?stage&since the "Why not?" feed
POST /v1/analyze {instrument_id} → SSE stream
Compliance
GET /v1/compliance/{instrument_id} ?rule_pack&as_of
GET /v1/compliance/{instrument_id}/history
GET /v1/compliance/rule-packs
PUT /v1/compliance/rule-packs/active triggers full re-evaluation
POST /v1/compliance/rule-packs/custom
POST /v1/compliance/screen {filters} → compliant universe
Portfolio
GET /v1/portfolio
GET /v1/portfolio/performance ?from&to&basis=twr|mwr
GET /v1/portfolio/risk
GET /v1/portfolio/risk/stress-tests
GET /v1/portfolio/attribution ?by=sector|geography|agent
GET /v1/portfolio/purification
Orders
POST /v1/orders/preview → OrderPreview
POST /v1/orders → Order
GET /v1/orders ?status&since
DELETE /v1/orders/{id}
GET /v1/transactions
Markets
GET /v1/instruments ?query§or&compliance_status
GET /v1/instruments/{id}
GET /v1/instruments/{id}/bars ?interval&from&to&adjusted
GET /v1/instruments/{id}/fundamentals ?as_of
GET /v1/instruments/{id}/news
GET /v1/watchlists
Assistant
POST /v1/assistant/messages → SSE stream with citations
GET /v1/assistant/conversations/{id}
Backtesting
POST /v1/backtests → run id
GET /v1/backtests/{id} status + results
GET /v1/backtests/{id}/manifest reproducibility manifest
GET /v1/backtests/strategies templates
System
GET /v1/system/health
GET /v1/system/data-quality
GET /v1/system/calibration reliability diagram data
GET /v1/system/data-flows what left the device, and why
GET /v1/system/audit hash-chained log
POST /v1/system/emergency-stop
GET /v1/settings PUT /v1/settings
4. Streaming¶
SSE for agent runs and assistant responses. Agent-run events mirror the graph nodes, which is what makes the in-app progress view a genuine trace rather than a decorative animation:
event: stage data: {"stage":"COMPLIANCE","status":"running"}
event: stage data: {"stage":"COMPLIANCE","status":"passed","verdict":{...}}
event: stage data: {"stage":"ANALYSTS","status":"running","agents":[...]}
event: signal data: {"agent":"FUNDAMENTAL","direction":"BULLISH","summary":"..."}
event: signal data: {"agent":"MACRO","direction":"BEARISH","summary":"..."}
event: stage data: {"stage":"AGGREGATION","score":0.68,"contributions":{...}}
event: token data: {"text":"Order backlog grew "} # supervisor, streamed
event: complete data: {"recommendation_id":"..."}
Terminal events include rejected (with stage and reason) and error. A rejection is a normal completion, not an error — the client renders it as the "Why not?" card.
WebSocket at /v1/stream for push: price updates for held and watched instruments, portfolio revaluation, order state transitions, alerts, circuit breaker activations, and compliance status changes.
5. Internal events (NATS JetStream)¶
market.bar.{interval}.{instrument_id}
market.corporate_action.{instrument_id}
data.document.ingested.{doc_type}
data.quality.{severity}
compliance.verdict.changed.{instrument_id}
compliance.at_risk.{instrument_id}
recommendation.created / .rejected / .resolved
order.submitted / .filled / .cancelled
risk.limit.breached.{limit_type}
risk.circuit_breaker.{breaker_type}
portfolio.revalued
learning.outcome.recorded
system.model.promoted / .rolled_back
Subjects are hierarchical so consumers subscribe by wildcard. All events carry event_id, occurred_at, available_at, and a correlation id threading a recommendation through its entire lifecycle.
6. Conventions¶
Errors — RFC 9457 problem details, with a machine-readable code and a user_message written in the same plain language as the rest of the product.
{
"type": "https://halaltrade.local/errors/compliance-blocked",
"title": "Order blocked by compliance screen",
"status": 422,
"code": "COMPLIANCE_BLOCKED",
"detail": "Interest-bearing debt is 34.2% of market cap, above the 30% limit.",
"user_message": "This company no longer passes your Shariah screen, so the order wasn't placed.",
"instance": "/v1/orders",
"verdict_id": "..."
}
Idempotency — Idempotency-Key required on POST /v1/orders. Retrying a submission after a network failure must never create a second order.
Money — every monetary value is a decimal string with an explicit currency, never a float. Serialising Decimal as JSON number is a defect, since it invites float parsing on the client.
Time — RFC 3339 UTC, always. as_of on every read response.
Pagination — cursor-based, since offset pagination over an append-only, time-ordered log skips and duplicates rows.
Staleness — any response containing feature-derived values includes per-field staleness flags. The client renders stale values differently. A number that silently ages is a number that lies.