ETRM System Architecture

A single malformed price file from an ISO can silently overwrite a day of nodal positions, and by the time the variance surfaces on a T+30 statement the wrong number has already propagated through pricing, loss, and imbalance. The failure mode this component prevents is exactly that: uncontrolled data crossing the ingestion boundary and corrupting a settlement run that must later be reconciled to the cent. Within the Core Architecture & Market Taxonomy for Energy Settlements framework, the Energy Trading and Risk Management (ETRM) platform is the operational backbone that turns physical delivery schedules, financial derivatives, and grid-operator mandates into auditable, replayable records. Its architecture must be decoupled layer by layer so that a fault in one stage — a stale telemetry feed, a schema drift, a duplicated hour on a fall-back day — is isolated at its boundary rather than allowed to reach the ledger.

The diagram below maps the decoupled layers of a production ETRM pipeline, from schema-agnostic ingestion through temporal alignment and reconciliation to financial posting, including the fallback path that preserves continuity when feeds degrade.

Decoupled ETRM settlement pipeline with failure-isolation branches A left-to-right main pipeline runs from ISO/RTO sources through a schema-validating ingestion engine, a temporal-alignment cycle-mapping engine, reconciliation microservices, and financial posting to the settlement ledger. Malformed records branch down from ingestion to a dead-letter queue, and unreconciled lines branch down from reconciliation to an exception queue, so neither halts the run. Above, a fallback-routing box feeds temporal alignment when the primary source outage engages a secondary SFTP endpoint or cache. ISO / RTO sources SFTP · REST · EDI 867/820 Ingestion engine schema validation Temporal alignment cycle-mapping engine Reconciliation idempotent matching Financial posting settlement ledger Fallback routing secondary SFTP · cache Dead-letter queue isolate, do not halt Exception queue analyst review malformed unreconciled primary fails source outage

Each layer owns exactly one responsibility and exposes exactly one contract to the next. That separation is what makes the pipeline replayable: any stage can be re-run from its input snapshot and must produce a bit-identical output.

Layer Responsibility Input contract Failure isolation
Ingestion & normalization Parse and canonicalize raw feeds Raw SFTP/REST/EDI payload Dead-letter queue
Temporal alignment Map intervals to operating-day windows Canonical record Gap/overlap flag
Reconciliation Idempotent three-way matching Aligned interval Exception queue
Financial posting Decimal-exact ledger write Reconciled line item Append-only, idempotent key

Data Ingestion and Normalization Layer

The ingestion pipeline is the primary control boundary for every downstream financial process. ISOs and RTOs distribute settlement artifacts through disparate delivery mechanisms — SFTP batch drops, RESTful market-data APIs, EDI 867/820 transactions, and real-time telemetry streams — and each jurisdiction enforces its own schema conventions, interval granularities, and versioning protocols. A production-grade ETRM architecture deploys a schema-agnostic ingestion engine that normalizes raw payloads into a canonical internal model before any downstream processing runs. That normalization step enforces strict validation against the ISO/RTO Data Format Standards, guaranteeing that locational marginal price (LMP) components, transmission loss multipliers, congestion rents, and ancillary-service credits all map to standardized dimensional keys.

The canonical model preserves the LMP decomposition the calculation layer depends on. Every nodal price resolves into a system energy component, a congestion component, and a marginal-loss component:

$$LMP_n = \lambda + \mu_n + \nu_n$$

where \(\lambda\) is the system marginal energy price, \(\mu_n\) is the congestion component at node \(n\), and \(\nu_n\) is the marginal-loss component. If ingestion does not carry all three components through as separate typed fields, the Pricing Logic Implementation downstream cannot prove that a nodal price reconstructs from its parts, and a congestion charge silently disappears into the energy line.

Python automation teams typically use polars for high-throughput parsing and pydantic for strict schema validation, rejecting malformed records at the boundary so that contaminated data never reaches the ledger. Because the field contract is the same one enforced by the Schema Validation Frameworks on the trade side, the ingestion model is shared rather than duplicated.

from decimal import Decimal
from datetime import datetime
from pydantic import BaseModel, Field, ValidationError
import polars as pl

class LmpRecord(BaseModel):
    node_id: str
    interval_start: datetime          # tz-aware, UTC-anchored at ingest
    energy_component: Decimal         # lambda
    congestion_component: Decimal     # mu_n
    loss_component: Decimal           # nu_n
    lmp_usd_mwh: Decimal              # published nodal price

    def decomposition_holds(self, tol: Decimal = Decimal("0.005")) -> bool:
        parts = self.energy_component + self.congestion_component + self.loss_component
        return abs(parts - self.lmp_usd_mwh) <= tol

def normalize_feed(raw_df: pl.DataFrame) -> list[LmpRecord]:
    """Reject malformed records at the ingestion boundary; never let them through."""
    records, rejects = [], []
    for row in raw_df.iter_rows(named=True):
        try:
            rec = LmpRecord(**row)
            if not rec.decomposition_holds():
                raise ValueError(f"LMP does not sum from components: {rec.node_id}")
            records.append(rec)
        except (ValidationError, ValueError) as exc:
            rejects.append({"node_id": row.get("node_id"), "reason": str(exc)})
    if rejects:
        route_to_dead_letter(rejects)   # isolate, do not halt the run
    return records

Specification & Standards Reference

Ingestion is not a free design; it is constrained by the same tariff and reliability envelope that governs the rest of the settlement chain. Charge codes, statement cadence, and the components a participant is entitled to receive are defined per market:

  • PJM Manual 28 and the PJM settlement XML schema specify charge-code semantics and the preliminary/final statement cadence.
  • ERCOT Nodal Protocols, Section 9 governs settlement calculation and the fixed-width extract layouts ERCOT publishes.
  • CAISO Business Practice Manual for Settlements & Billing defines the resource-level statement structure and dispute windows.
  • NAESB WEQ standards define the wholesale electric quadrant messaging that EDI 867 (meter usage) and 820 (payment/remittance) transactions ride on.
  • FERC approves the Open Access Transmission Tariff under which each RTO settles, and Order 2222 pulls DER aggregations into the resource taxonomy the model must represent.

Because a charge code can change meaning across a tariff revision, the canonical model must be versioned against a jurisdiction and an effective date — a pipeline that hard-codes today’s semantics will mis-settle tomorrow’s intervals.

Temporal Alignment and Cycle Management

Energy settlements operate across overlapping temporal horizons: day-ahead (D-1), real-time (D), preliminary (D+1), final (D+30), and regulatory true-up (D+90+). Misalignment between trade-execution timestamps, SCADA metered intervals, and market-clearing windows is the leading cause of reconciliation breaks. The architecture routes every canonical record through a deterministic Settlement Cycle Mapping engine that synchronizes UTC, local market time, and daylight-saving transitions without introducing rounding drift. Interval aggregation rules respect 5-minute, 15-minute, or hourly boundaries while preserving fractional MWh precision, apply time-weighted averaging for partial intervals, and explicitly flag any gap exceeding the market’s allowable tolerance.

Cycle window Nominal timing Data source Typical use
Day-ahead (D-1) ~24h pre-delivery DA market clear Scheduling, hedges
Real-time (D) 5 / 15-min intervals RT dispatch Balancing, deviation
Preliminary (D+1 to D+3) provisional Early SCADA Cash-flow forecast
Final (D+30 to D+45) validated MDM meter data Reconciled invoice
True-up (D+90+) corrective Tariff/regulatory Dispute resolution

Step-by-Step Implementation

Building the reconciliation core from the canonical records is a fixed sequence of production steps.

Step 1 — Anchor and align intervals. Convert every reported timestamp to UTC at ingestion and align it to the market operating-day grid so that a metered interval and a market interval share one key.

from zoneinfo import ZoneInfo
from datetime import datetime

def align_interval(local_ts: datetime, market_tz: str) -> datetime:
    """UTC-anchor a reported timestamp; DST tables live in the cycle-mapping engine."""
    return local_ts.replace(tzinfo=ZoneInfo(market_tz)).astimezone(ZoneInfo("UTC"))

Step 2 — Model the settlement line item with Decimal money math. Every monetary and volumetric field is a Decimal; binary floats accumulate drift that eventually flips a rounding boundary and breaks reconciliation.

from decimal import Decimal, ROUND_HALF_EVEN
from datetime import datetime
from pydantic import BaseModel, Field

CENT = Decimal("0.01")

class SettlementLine(BaseModel):
    trade_id: str
    node_id: str
    interval_start: datetime          # UTC-anchored
    iso_zone: str
    scheduled_mwh: Decimal = Field(ge=0)
    settled_mwh: Decimal = Field(ge=0)
    lmp_usd_mwh: Decimal
    loss_factor: Decimal = Decimal("1.0")
    variance_tolerance_pct: Decimal = Field(default=Decimal("0.5"), ge=0, le=Decimal("5.0"))

    def charge(self) -> Decimal:
        gross = self.settled_mwh * self.lmp_usd_mwh * self.loss_factor
        return gross.quantize(CENT, rounding=ROUND_HALF_EVEN)

Step 3 — Reconcile idempotently. Match scheduled against settled volume per (trade_id, node_id, interval_start); the key makes re-delivery of the same file a no-op instead of a double-count.

def is_reconciled(line: SettlementLine) -> bool:
    if line.scheduled_mwh == 0 and line.settled_mwh == 0:
        return True
    denom = max(line.scheduled_mwh, Decimal("1e-9"))
    variance = abs(line.scheduled_mwh - line.settled_mwh) / denom
    return variance <= (line.variance_tolerance_pct / Decimal("100"))

Step 4 — Route by outcome. Reconciled lines proceed to financial posting; breaks go to the exception queue for analyst review. The posting store is append-only and idempotent on the same key, so a replay proves identical.

def route(lines: list[SettlementLine]) -> None:
    for line in lines:
        if is_reconciled(line):
            post_to_ledger(line.trade_id, line.node_id, line.interval_start, line.charge())
        else:
            enqueue_exception(line)   # never blocks cleared transactions

This routing layer feeds the broader Settlement Calculation & Validation Engines, which price and validate the obligations these reconciled lines represent.

Edge Cases and Failure Modes

Production feeds break in predictable, ugly ways. Each must be handled explicitly rather than caught generically.

  • Negative LMPs. Congestion and oversupply routinely push nodal prices below zero; a Field(ge=0) on lmp_usd_mwh would reject valid market data. Prices are unbounded; only volumes are non-negative.
  • DST boundaries. The spring-forward 23-hour day drops an hour-ending and the fall-back 25-hour day duplicates one. Anchoring to UTC at ingestion and carrying an explicit hour-ending index prevents the duplicate from overwriting or the gap from mis-aggregating.
  • Zero-volume intervals. A curtailed resource reports scheduled_mwh == settled_mwh == 0; treat it as reconciled rather than dividing by zero.
  • Stale telemetry. A feed that stops advancing looks healthy but repeats yesterday’s interval. Compare the max reported interval against wall-clock and flag staleness before it reaches reconciliation.
  • Schema drift. A field that silently changes type or unit propagates a wrong number everywhere; the pydantic contract at the boundary rejects it to the dead-letter path.
from decimal import Decimal
from datetime import datetime, timedelta, timezone

def guard_interval(line: "SettlementLine", max_staleness: timedelta = timedelta(hours=2)) -> None:
    # Negative LMP is valid; do not reject it.
    if line.settled_mwh < 0 or line.scheduled_mwh < 0:
        raise ValueError(f"negative volume at {line.node_id} {line.interval_start}")
    if datetime.now(timezone.utc) - line.interval_start > max_staleness + timedelta(hours=1):
        flag_stale(line.node_id, line.interval_start)   # telemetry not advancing

Security & Access Boundaries

Regulatory compliance mandates strict segregation between trading, scheduling, and financial-accounting functions, and the ingestion architecture inherits those Security & Access Boundaries directly. Role-based access control enforces least privilege, isolating position management from the settlement calculation engines. Data residency and encryption-at-rest requirements align with the NERC Critical Infrastructure Protection Standards — CIP-005 electronic security perimeters, CIP-007 system security management, and CIP-011 information protection — so that pricing curves, counterparty credit limits, and settlement statements remain cryptographically secured. Every data mutation carries immutable lineage: inputs, the tariff and price versions in force, a UTC timestamp, and a SHA-256 content hash, enabling forensic reconstruction during a regulatory inquiry or a dispute.

Fallback Routing and Threshold Configuration

Market-data feeds are inherently volatile — network partitions, API rate limits, and delayed batch submissions all interrupt the primary channel. Production ETRM systems implement circuit-breaker patterns, exponential backoff, and dead-letter queues to isolate failed payloads without halting the broader pipeline. When a primary ingestion channel fails, fallback routing switches automatically to a secondary SFTP endpoint or a cached market snapshot, preserving continuity for critical D+1 and D+30 runs. Message brokers such as Apache Kafka or RabbitMQ provide at-least-once delivery; idempotent processing in every reconciliation microservice prevents a duplicated market update from inflating position ledgers or triggering a false break.

Ingestion circuit-breaker state machine Three states control the primary ISO/RTO feed. CLOSED means the primary is healthy and each success resets the failure counter. When consecutive failures reach the threshold of five, the breaker trips to OPEN and diverts traffic to the fallback SFTP endpoint or cached snapshot. After the cooldown elapses it moves to HALF-OPEN and lets a single probe request through: a successful probe returns the breaker to CLOSED and resets the counter, while a failed probe sends it back to OPEN for another cooldown. CLOSED primary healthy OPEN route to fallback HALF-OPEN single probe request failures ≥ 5 cooldown elapsed probe fails probe succeeds → reset counter success → reset

Alerting is tiered so that a transient blip does not page an analyst but a systemic outage does. The parameters below are configurable per market and feed the same escalation model as Threshold Tuning & Alerts.

Tier Trigger Action Escalation
Info Single record to dead-letter Log + dashboard None
Warning Fallback route engaged Notify ops channel On-call ack
Critical Break rate > 2% of interval count Page settlement lead 15-min SLA
Halt Financial-posting hash mismatch Freeze run Manual release
from decimal import Decimal

class CircuitBreaker:
    def __init__(self, fail_threshold: int = 5, reset_after_probes: int = 3):
        self.fail_threshold = fail_threshold
        self.reset_after_probes = reset_after_probes
        self.failures = 0
        self.state = "CLOSED"

    def record_failure(self) -> None:
        self.failures += 1
        if self.failures >= self.fail_threshold:
            self.state = "OPEN"            # divert to fallback SFTP / cache

    def record_success(self) -> None:
        self.failures = 0
        self.state = "CLOSED"

def break_rate_exceeds(breaks: int, interval_count: int, tier: Decimal = Decimal("0.02")) -> bool:
    if interval_count == 0:
        return False
    return (Decimal(breaks) / Decimal(interval_count)) > tier

Multi-ISO Cross-Market Reconciliation

Traders operating across multiple balancing authorities face compounding complexity when reconciling cross-jurisdictional positions. A cross-market reconciliation engine normalizes disparate pricing zones, currency denominations, and transmission loss factors into a unified settlement view, applying the correct multiplier per node using the same discipline as Loss Factor Mapping Strategies. The validator below performs pair-wise matching of executed trades against ISO-issued settlement statements with polars and pydantic, flagging interval mismatches under strict Decimal-exact type safety across heterogeneous feeds.

from decimal import Decimal
from datetime import timezone
from typing import List
import polars as pl

def validate_and_reconcile(lines: List["SettlementLine"]) -> pl.DataFrame:
    """Flag reconciliation breaks across markets; cleared lines proceed to posting."""
    results: List[dict] = []
    for line in lines:
        denom = max(line.scheduled_mwh, Decimal("1e-9"))
        variance_pct = (abs(line.scheduled_mwh - line.settled_mwh) / denom * Decimal("100"))
        results.append({
            "trade_id": line.trade_id,
            "iso_zone": line.iso_zone,
            "interval_start": line.interval_start.astimezone(timezone.utc),
            "is_reconciled": is_reconciled(line),
            "variance_pct": str(variance_pct.quantize(Decimal("0.0001"))),
            "requires_manual_review": not is_reconciled(line),
        })
    return pl.DataFrame(results).sort(["iso_zone", "interval_start"])

This layer integrates directly with automated settlement workflows, routing unreconciled records to exception queues for analyst review while allowing cleared transactions to proceed to financial posting. For comprehensive implementation guidance on schema-validation patterns, refer to the official Python pydantic documentation.

Testing and Reconciliation Verification

The architecture is only trustworthy if it is provably reproducible. Two verification techniques apply on every deployment. First, a shadow calculation: replay a closed settlement run through the current code and assert the ledger output hashes identically to the archived run — any drift means a logic change altered a historical number. Second, edge-case unit tests that pin the exact failure modes above so a refactor cannot silently reintroduce them.

from decimal import Decimal
from datetime import datetime, timezone

def _line(**kw) -> "SettlementLine":
    base = dict(trade_id="T1", node_id="N1", iso_zone="PJM",
                interval_start=datetime(2026, 6, 1, tzinfo=timezone.utc),
                scheduled_mwh=Decimal("10"), settled_mwh=Decimal("10"),
                lmp_usd_mwh=Decimal("42.15"))
    base.update(kw)
    return SettlementLine(**base)

def test_negative_lmp_is_valid():
    line = _line(lmp_usd_mwh=Decimal("-18.40"))
    assert line.charge() == Decimal("-184.00")          # oversupply, not an error

def test_zero_volume_reconciles():
    line = _line(scheduled_mwh=Decimal("0"), settled_mwh=Decimal("0"))
    assert is_reconciled(line) is True                  # no divide-by-zero

def test_charge_is_cent_quantized():
    line = _line(settled_mwh=Decimal("3"), lmp_usd_mwh=Decimal("33.333"))
    assert line.charge() == Decimal("99.99")            # bit-exact, ROUND_HALF_EVEN

Frequently Asked Questions

Why must an ETRM settlement pipeline use Python’s decimal module instead of float?

Charges are quantized to the cent and reconciled against an ISO statement to the cent. Binary floating point cannot represent most decimal fractions exactly, so summing thousands of intervals accumulates drift that eventually flips a rounding boundary and breaks reconciliation. Quantizing every amount with Decimal keeps the ledger bit-exact and reproducible across preliminary and final runs.

Why should the ingestion layer never reject a negative LMP?

Congestion and oversupply routinely drive nodal prices below zero, so a negative LMP is valid market data, not a malformed record. Only volumes are non-negative. Enforcing ge=0 on price fields silently discards real intervals and understates congestion cost; the constraint belongs on scheduled_mwh and settled_mwh, not on lmp_usd_mwh.

What makes an ETRM run replayable and auditable?

Every posted line carries its inputs, the tariff and price versions in force, the code revision, a UTC timestamp, and a SHA-256 content hash, written to an append-only store keyed idempotently on (trade_id, node_id, interval_start). Because re-delivering the same file is a no-op, any historical run can be replayed and proven identical, satisfying FERC traceability and counterparty dispute requirements.

How does the architecture keep a degraded feed from halting settlement?

A circuit breaker diverts a failing primary channel to a secondary SFTP endpoint or a cached snapshot, malformed records fall to a dead-letter queue, and reconciliation breaks go to an exception queue — none of which block cleared transactions. Idempotent processing means the at-least-once redelivery that follows a fallback cannot double-count, so the critical D+1 and D+30 runs complete even while one feed is down.

Explore this topic