Skip to content

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&sector&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": "..."
}

IdempotencyIdempotency-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.