Multi-entity finance teams running AI agents have a pair of problems that single-entity teams don’t:

  1. Defensible FX rates. Each transaction in a foreign currency needs a USD-equivalent for materiality checks and consolidation. The rate has to be defensible — your auditor will ask the source.
  2. Intercompany matching. A US-parent expense booked to “intercompany receivable from JP-sub” needs to match the corresponding JP-sub entry booked to “intercompany payable to US-parent.” Across entities, across currencies, often across timezones.

Both problems benefit from the same pattern: Protocol + registry adapters. Define a typing.Protocol; ship reference implementations; let teams register their own.

This article walks both adapters and the runtime mechanics.

The FX adapter Protocol

from typing import Protocol, runtime_checkable
from datetime import date
from decimal import Decimal

@runtime_checkable
class FxRateAdapter(Protocol):
    name: str

    def rate(self, base: str, quote: str, as_of: date) -> FxRate:
        """Return the rate from base to quote on the given date."""
        ...

FxRate carries the rate value plus source attribution:

@dataclass(frozen=True)
class FxRate:
    rate: Decimal
    source: str       # adapter name (e.g. "ecb_daily", "bloomberg-fx")
    as_of: date       # actual date the rate was sourced for

The Protocol is read-only and deterministic. The adapter doesn’t write to closegate’s stores; the audit log records the rate’s source attribution on every cross-currency event.

Reference adapters

Three implementations ship:

FixedRateAdapter (default for demos + tests)

class FixedRateAdapter:
    name = "fixed"
    rates = {
        ("EUR", "USD"): Decimal("1.08"),
        ("GBP", "USD"): Decimal("1.27"),
        ("JPY", "USD"): Decimal("0.0067"),
        # ...
    }
    def rate(self, base, quote, as_of):
        return FxRate(rate=self.rates[(base, quote)], source=self.name, as_of=as_of)

Static table. Use for demos and tests; raises an alarm in production via CLOSEGATE_FX_ADAPTER_PROD_GUARD.

EcbDailyRatesAdapter (free ECB feed)

Pulls the European Central Bank daily reference rates from https://www.ecb.europa.eu/stats/eurofxref/. Free, public, defensible source for non-USD-anchored teams. Caches locally; refreshes daily.

OpenExchangeRatesAdapter (paid)

Paid tier of openexchangerates.org. Required for hourly granularity or for rates against non-major currencies. ~$50/mo.

Registering your own

from closegate_policy.fx import FxRateAdapter, register_fx_adapter

class BloombergFxAdapter:
    name = "bloomberg-fx"

    def __init__(self, api_token: str):
        self._token = api_token

    def rate(self, base, quote, as_of):
        # Bloomberg API call
        ...
        return FxRate(rate=fetched_rate, source=self.name, as_of=as_of)

register_fx_adapter("bloomberg", BloombergFxAdapter(api_token=os.environ["BLOOMBERG_API_TOKEN"]))

Then set CLOSEGATE_FX_ADAPTER=bloomberg in env. The policy gate looks up the configured adapter on every cross-currency calculation.

The intercompany matcher Protocol

@runtime_checkable
class IntercompanyMatcher(Protocol):
    name: str

    def match(self,
              gl_entries: list[Entry],
              sl_entries: list[Entry]
              ) -> list[IntercompanyMatch]:
        """Find candidate intercompany pairs."""
        ...

IntercompanyMatch is a typed pairing:

@dataclass(frozen=True)
class IntercompanyMatch:
    gl_entry_id: str
    sl_entry_id: str
    confidence: float        # 0..1
    rule_applied: str        # which rule fired
    notes: str | None = None

Reference intercompany matchers

Three implementations:

NoOpMatcher

For deployments without intercompany flows. Returns empty list. Default.

AccountCodePairMatcher

The most common pattern. Matches by chart-of-accounts convention:

  • US-parent books to 1500-INTERCO-JP-RECEIVABLE
  • JP-sub books to 2500-INTERCO-US-PAYABLE
  • Both entries have the same amount + opposite signs

The matcher recognizes the 1500-X ↔ 2500-X pattern (configurable via account_pairs in policy.yaml). Returns high-confidence matches when both legs are found.

JsonRulesMatcher

Declarative rules in policy.yaml:

intercompany_rules:
  - name: "us-parent-to-jp-sub"
    gl_account_pattern: "1500-JP-*"
    sl_account_pattern: "2500-US-*"
    tolerance_pct: 0.5         # FX rate slippage tolerance
    require_same_period: true
  - name: "us-parent-to-uk-sub"
    gl_account_pattern: "1500-UK-*"
    sl_account_pattern: "2500-US-*"
    tolerance_pct: 0.5
    require_same_period: true

The JsonRulesMatcher reads these rules and applies them. Useful for teams with non-standard chart-of-accounts conventions.

How it fits into the recon pipeline

The matching pipeline runs in order:

  1. Exact matcher (deterministic, single-entity, amount + reference)
  2. Multi-to-one matcher (deterministic, one GL entry to multiple SL entries)
  3. Intercompany matcher (cross-entity, FX-aware)
  4. Fuzzy matcher (LLM-assisted, near-misses)

The intercompany matcher runs after exact + multi-to-one because intercompany pairs are usually higher-confidence than fuzzy matches but need to be checked after the deterministic single-entity passes have cleared their candidates.

When a pair matches, the FX adapter is called for the cross-currency check. If the JP-sub entry is ¥10,000,000 and the US-parent entry is $66,840, the matcher confirms (¥10M × 0.006684 USD/¥ = $66,840). Tolerance pct in the rule handles small FX slippage.

Audit-log evidence

Every intercompany match writes an audit event with:

  • The two entry IDs
  • The FX rate used + the rate’s source + the as_of date
  • The matcher rule that fired (account_code_pair or json_rules:us-parent-to-jp-sub)
  • The confidence score

Your auditor samples these and asks: “where did this FX rate come from?” The answer is the audit-event row + the FX adapter config in policy.yaml.

Real-world deployment shape

A US-headquartered holdco with US-parent + UK-sub + JP-sub + IN-sub:

fx_adapter: ecb_daily         # or bloomberg in production

intercompany_matcher: account_code_pair
intercompany_account_pairs:
  - { left: "1500-UK-*", right: "2500-US-*", tolerance_pct: 0.5 }
  - { left: "1500-JP-*", right: "2500-US-*", tolerance_pct: 0.5 }
  - { left: "1500-IN-*", right: "2500-US-*", tolerance_pct: 0.5 }

entity_materiality_overrides:
  us-parent: 10000             # USD
  uk-sub: 8000                 # GBP
  jp-sub: 1000000              # JPY
  in-sub: 500000               # INR

The per-entity materiality overrides combine with the intercompany matcher to give: “below-materiality intercompany pairs auto-confirm; above-materiality always route to HITL with full FX rate + source on the audit event.”

What this gives you

  • A defensible FX-rate-source story your auditor accepts
  • Multi-entity intercompany matching that survives multi-currency arithmetic
  • Per-entity materiality overrides that respect local-currency norms
  • Configurable rules without forking closegate; just register your adapter