Skip to main content

Own FA configuration and allocation

Financial Advisor (FA) configuration is control-plane state, not ordinary account data. Reading it shares one uncorrelated response slot in pinned ib_async 2.1.0. Replacing it changes advisor-wide allocation configuration. Treat reads as serialized, bounded snapshots and treat replacement as a reviewed migration with an independently observed completion and a read-back comparison.

Complete connection and readiness and account discovery first. Execution reconciliation is a downstream requirement for any allocation order and is linked below. The examples use placeholder paper-account IDs. They read or parse configuration only; they do not call replaceFA or place an order.

Current types, legacy names

The current IBKR requestFA table lists these readable types:

TypeCurrent meaningSafe use
1Allocation groupsRead current group configuration. This is also the only currently supported replacement type.
3Account aliasesRead aliases as a distinct configuration document. Do not parse it as group XML.
2Legacy allocation profilesDo not use. It remains in the pinned library docstring, but is absent from the current read table.

Current IBKR documentation says replaceFA accepts only type 1; other types may produce error 585, “FA Profile is not supported anymore, use FA Group instead.” TWS/IB Gateway 10.20+ enables unified groups and profiles by default, but that migration does not make the obsolete Order.faProfile wire field a current interface. Use Order.faGroup for a group.

Minimal read-only snapshot

IB.requestFA() is the blocking wrapper around IB.requestFAAsync(). In pinned 2.1.0 the asynchronous method has an internal four-second timeout and returns None on timeout. Its wrapper always registers the request under the string key "requestFA", and receiveFA resolves that key while discarding the callback's returned type. Never overlap group and alias reads on one IB instance.

This script makes one group read, caps and parses the returned XML, and recycles the connection if the library cannot prove completion. It never writes advisor configuration.

import asyncio
from xml.etree import ElementTree

from ib_async import IB


MAX_FA_XML_BYTES = 1_000_000


async def main() -> None:
ib = IB()
try:
await ib.connectAsync(
host="127.0.0.1",
port=4002,
clientId=18,
timeout=10,
readonly=True,
raiseSyncErrors=True,
)
xml = await ib.requestFAAsync(1)
if xml is None:
raise TimeoutError("FA group request did not complete")
encoded = xml.encode("utf-8")
if len(encoded) > MAX_FA_XML_BYTES:
raise ValueError("FA configuration exceeds the local size limit")
upper = xml.upper()
if "<!DOCTYPE" in upper or "<!ENTITY" in upper:
raise ValueError("DTD and entity declarations are not accepted")
root = ElementTree.fromstring(xml)
if root.tag != "ListOfGroups":
raise ValueError("expected ListOfGroups")
group_names = [
name.text.strip()
for name in root.findall("./Group/name")
if name.text and name.text.strip()
]
if not group_names:
raise ValueError("FA configuration contains no named groups")
print(f"received {len(group_names)} allocation group(s)")
finally:
ib.disconnect()


asyncio.run(main())

Do not log or publish the XML. It can contain account identifiers, aliases, group names, and allocation parameters. Persist a protected snapshot only if the operating procedure requires it; ordinary telemetry should keep a digest, byte count, group count, connection epoch, and observation time.

Validate before any replacement

The offline seam below rejects oversized XML, DTD/entity declarations, unexpected document shapes, duplicate groups or accounts, unknown accounts, unsupported methods, and invalid amount fields. It also classifies the three current order-routing shapes without placing an order:

  • direct account: account only;
  • allocation group: faGroup only;
  • model: advisor account plus modelCode.

An order that mixes those routes is ambiguous and rejected. faProfile, faMethod, and faPercentage are treated as legacy order fields in this current-group workflow. This fail-closed policy is intentionally narrower than everything the wire format can serialize.

The policy follows IBKR's current allocation XML format and its method-specific examples:

Group methodAccount-level policyOfficial format
AvailableEquityNo <amount>Available Equity
ContractsOrSharesOne finite <amount>Contracts or Shares
EqualNo <amount>Equal Quantity
MonetaryAmountOne finite <amount>Monetary Amount
NetLiqNo <amount>Net Liquidation Value
PercentOne finite <amount>Percentages
RatioOne finite <amount>Ratios
import asyncio
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation
from hashlib import sha256
from typing import Callable, Mapping, NamedTuple, Protocol
from xml.etree import ElementTree

from ib_async import IB, Order


MAX_FA_XML_BYTES = 1_000_000
METHOD_REQUIRES_AMOUNT = {
"AvailableEquity": False,
"ContractsOrShares": True,
"Equal": False,
"MonetaryAmount": True,
"NetLiq": False,
"Percent": True,
"Ratio": True,
}


class AllocationGroup(NamedTuple):
name: str
method: str
accounts: tuple[tuple[str, str | None], ...]


def _require_whitespace_only(
element: ElementTree.Element,
label: str,
) -> None:
if element.text and element.text.strip():
raise ValueError(f"unexpected text in {label}")
if any(child.tail and child.tail.strip() for child in element):
raise ValueError(f"unexpected text in {label}")


def _required_text(parent: ElementTree.Element, tag: str) -> str:
children = parent.findall(tag)
if len(children) != 1:
raise ValueError(f"expected one non-empty {tag}")
child = children[0]
if child.attrib or list(child) or child.text is None:
raise ValueError(f"{tag} must be a plain text element")
value = child.text.strip()
if not value:
raise ValueError(f"expected one non-empty {tag}")
return value


def parse_groups(
xml: str,
authorized_accounts: frozenset[str],
) -> dict[str, AllocationGroup]:
encoded = xml.encode("utf-8")
if len(encoded) > MAX_FA_XML_BYTES:
raise ValueError("FA configuration exceeds the local size limit")
upper = xml.upper()
if "<!DOCTYPE" in upper:
raise ValueError("DTD declarations are not accepted")
if "<!ENTITY" in upper:
raise ValueError("ENTITY declarations are not accepted")
try:
root = ElementTree.fromstring(xml)
except ElementTree.ParseError as exc:
raise ValueError("invalid FA XML") from exc
if root.tag != "ListOfGroups":
raise ValueError("expected ListOfGroups")
if root.attrib:
raise ValueError("ListOfGroups attributes are not accepted")
_require_whitespace_only(root, "ListOfGroups")
if not authorized_accounts:
raise ValueError("authorized account scope is empty")

groups: dict[str, AllocationGroup] = {}
for group_element in root.findall("Group"):
if group_element.attrib:
raise ValueError("Group attributes are not accepted")
_require_whitespace_only(group_element, "Group")
if any(
child.tag not in {"name", "defaultMethod", "ListOfAccts"}
for child in group_element
):
raise ValueError("unexpected element under Group")
name = _required_text(group_element, "name")
if name in groups:
raise ValueError(f"duplicate group: {name}")
method = _required_text(group_element, "defaultMethod")
if method not in METHOD_REQUIRES_AMOUNT:
raise ValueError(f"unsupported allocation method: {method}")
lists = group_element.findall("ListOfAccts")
if len(lists) != 1:
raise ValueError(f"group {name} must contain one ListOfAccts")
list_attributes = lists[0].attrib
if list_attributes not in ({}, {"varName": "list"}):
raise ValueError(f"unexpected ListOfAccts attributes in group {name}")
_require_whitespace_only(lists[0], f"group {name} account list")
if any(child.tag != "Account" for child in lists[0]):
raise ValueError(f"unexpected element in group {name} account list")

accounts: list[tuple[str, str | None]] = []
seen_accounts: set[str] = set()
for account_element in lists[0].findall("Account"):
if account_element.attrib:
raise ValueError(f"Account attributes are not accepted in group {name}")
_require_whitespace_only(account_element, f"account in group {name}")
if any(
child.tag not in {"acct", "amount"}
for child in account_element
):
raise ValueError(f"unexpected account element in group {name}")
account = _required_text(account_element, "acct")
if account not in authorized_accounts:
raise ValueError(f"unknown account in group {name}: {account}")
if account in seen_accounts:
raise ValueError(f"duplicate account in group {name}: {account}")
seen_accounts.add(account)
amount_elements = account_element.findall("amount")
if len(amount_elements) > 1:
raise ValueError(f"duplicate amount for {account}")
amount: str | None = None
if amount_elements:
amount_element = amount_elements[0]
if (
amount_element.attrib
or list(amount_element)
or amount_element.text is None
or not amount_element.text.strip()
):
raise ValueError(f"invalid amount for {account}")
amount = amount_element.text.strip()
if METHOD_REQUIRES_AMOUNT[method] and not amount_elements:
raise ValueError(f"{method} requires an amount for {account}")
if not METHOD_REQUIRES_AMOUNT[method] and amount_elements:
raise ValueError(f"{method} does not accept an amount for {account}")
if amount is not None:
try:
numeric_amount = Decimal(amount)
except InvalidOperation as exc:
raise ValueError(f"invalid amount for {account}") from exc
if not numeric_amount.is_finite():
raise ValueError(f"invalid amount for {account}")
accounts.append((account, amount))
if not accounts:
raise ValueError(f"group {name} contains no accounts")
groups[name] = AllocationGroup(
name,
method,
tuple(accounts),
)

if len(groups) != len(root.findall("Group")) or not groups:
raise ValueError("FA configuration contains no valid groups")
if any(child.tag != "Group" for child in root):
raise ValueError("unexpected element under ListOfGroups")
return groups


def require_order_scope(order: Order) -> str:
if order.faProfile:
raise ValueError("faProfile is obsolete; use a current group")
if order.faMethod or order.faPercentage:
raise ValueError("legacy per-order FA method fields are not accepted")
routes = {
"account": bool(order.account and not order.faGroup and not order.modelCode),
"group": bool(order.faGroup and not order.account and not order.modelCode),
"model": bool(order.account and order.modelCode and not order.faGroup),
}
selected = [name for name, enabled in routes.items() if enabled]
if len(selected) != 1:
raise ValueError("order must have exactly one current order scope")
return selected[0]


def canonical_groups(
groups: Mapping[str, AllocationGroup],
) -> tuple[tuple[str, str, tuple[tuple[str, str | None], ...]], ...]:
return tuple(
(name, group.method, group.accounts)
for name, group in groups.items()
)


class FaReader(Protocol):
async def requestFAAsync(self, faDataType: int) -> str | None: ...
def disconnect(self) -> None: ...


class FaConfigurationOwner:
def __init__(
self,
ib: IB | FaReader,
authorized_accounts: frozenset[str],
connection_epoch: str,
persist: Callable[[dict[str, object]], None],
) -> None:
self.ib = ib
self.authorized_accounts = authorized_accounts
self.connection_epoch = connection_epoch
self.persist = persist
self.poisoned = False
self._read_lock = asyncio.Lock()

def _recycle(self, kind: str) -> None:
self.poisoned = True
disconnect_error: Exception | None = None
try:
self.ib.disconnect()
except Exception as exc:
disconnect_error = exc
self.persist(
{
"kind": kind,
"connectionEpoch": self.connection_epoch,
"epochRecycleAttempted": True,
"epochRecycled": disconnect_error is None,
"disconnectErrorType": (
type(disconnect_error).__name__
if disconnect_error is not None
else None
),
}
)
if disconnect_error is not None:
raise disconnect_error

async def read_groups(self) -> dict[str, AllocationGroup]:
if self.poisoned:
raise RuntimeError("FA configuration owner is poisoned")
async with self._read_lock:
if self.poisoned:
raise RuntimeError("FA configuration owner is poisoned")
try:
xml = await self.ib.requestFAAsync(1)
except asyncio.CancelledError:
self._recycle("fa-read-cancelled")
raise
except Exception:
self._recycle("fa-read-failed")
raise
if xml is None:
self._recycle("fa-read-unknown")
raise TimeoutError("FA group request did not complete")
try:
groups = parse_groups(xml, self.authorized_accounts)
except Exception:
self._recycle("fa-read-invalid")
raise

canonical = canonical_groups(groups)
record = {
"kind": "fa-read",
"connectionEpoch": self.connection_epoch,
"observedAt": datetime.now(timezone.utc).isoformat(),
"faDataType": 1,
"byteCount": len(xml.encode("utf-8")),
"groupCount": len(groups),
"configurationDigest": sha256(
repr(canonical).encode("utf-8")
).hexdigest(),
}
try:
self.persist(record)
except Exception:
self.poisoned = True
self.ib.disconnect()
raise
return groups

parse_groups is an application policy gate, not an IBKR XML schema implementation. Preserve a separately access-controlled baseline before extending it for additional XML fields. Compare canonical_groups(parse_groups(...)) results rather than raw bytes so insignificant whitespace does not look like configuration drift. That canonical tuple deliberately preserves group and account ordering: without explicit evidence that ordering is immaterial, a reordered read-back is a mismatch. Plain mapping equality is not an order-sensitive verification.

Why high-level replacement is dispatch-only

The official protocol gives replaceFA a request ID and reports completion through replaceFAEnd(reqId, text). Pinned ib_async 2.1.0 behaves differently at its high-level seam:

  1. IB.replaceFA(1, xml) allocates a request ID internally.
  2. It calls Client.replaceFA(reqId, 1, xml).
  3. It returns immediately without returning the request ID or creating a future.
  4. Although the decoder recognizes the wire callback, the standard wrapper has no replaceFAEnd method, so the high-level call cannot prove completion.

Therefore, “IB.replaceFA() returned” means dispatched locally, not accepted, saved, or safe to reread. Do not wrap it in asyncio.to_thread() and call that an acknowledgement.

Replacement runbook

Use a dedicated advisor-control connection and an implementation that can correlate the official replaceFAEnd request ID. That may be a deliberately maintained lower-level ib_async adapter or another official-API client; it is not the unmodified high-level IB.replaceFA() method.

  1. Serialize all FA reads and writes for the advisor configuration.
  2. Read type 1, validate it against the exact authorized account set, encrypt and retain a rollback snapshot, and record its semantic digest.
  3. Build the proposed XML from typed configuration—not string concatenation—then parse it again with the same policy gate.
  4. Review a semantic diff: group names, methods, account membership, and amounts. Require an explicit change identifier and operator approval.
  5. Allocate and retain the replacement request ID, dispatch type 1, and mark the outcome unknown immediately after dispatch.
  6. Wait for the matching replaceFAEnd(reqId, text) with a finite deadline. A timeout, disconnect, cancellation, mismatched request ID, or error leaves the outcome unknown and requires a new connection epoch.
  7. After completion, reread type 1. IBKR warns that an early read can produce error 10230 while changes remain unsaved; retry that specific pending state with bounded backoff.
  8. Parse the reread XML and compare its semantic representation with the proposed configuration. Only an exact match may mark the change verified.
  9. If verification fails, block allocation orders, retain both digests and callback/error provenance, and reconcile manually. Do not automatically replay an unknown replacement.

Error 585 means the profile interface is no longer supported; it is not a signal to retry with another undocumented type. Error 10231 identifies groups or profiles containing invalid accounts and should fail the change.

Allocation order and execution ownership

Before placement, validate a single current routing shape and bind it to the configuration version that was read and approved:

RouteRequired fieldsForbidden fieldsReconciliation scope
Direct accountaccountfaGroup, modelCode, faProfile, legacy method fieldsThe same account
Allocation groupfaGroupaccount, modelCode, faProfile, legacy method fieldsEvery account-level execution produced for that group
Modeladvisor account, modelCodefaGroup, faProfile, legacy method fieldsAccount plus model intent, then account-level executions

Model portfolios must already exist in TWS; the TWS API can request model positions/account updates and place an order to a model, but does not create the model. Group configuration and model configuration are different control planes.

An allocation order is not reconciled at the group name alone. Execution callbacks and requested execution history carry the allocated account in Execution.acctNumber. Persist and deduplicate each exact execId, require every account to remain inside the approved group snapshot, and attach late commission reports as described in Record fills and commissions. After a restart, use the history and reconciliation gate before restoring allocation-order readiness.

Failure matrix

FailureProven stateRequired response
requestFAAsync returns XMLOne serialized response completedParse, authorize, digest, and persist the observation
Read returns None, raises, or is cancelledResponse ownership is unresolvedPoison the owner, disconnect, and retry only in a new epoch
XML is malformed, oversized, or outside account policyA response arrived but is unsafe to useBlock FA/order readiness and investigate; do not normalize it silently
High-level IB.replaceFA returnsLocal dispatch onlyKeep outcome unknown; do not claim success
Matching replaceFAEnd arrivesServer-side replacement reception endedReread and compare; callback alone is not semantic verification
Reread reports 10230Save is still pendingBounded backoff under the same change owner
Reread differs from proposalActual configuration is not verifiedBlock allocation orders and reconcile manually
Disconnect after dispatchReplacement outcome may be unknownDo not replay automatically; obtain a fresh read in a new epoch