Record a holdings statement¶
In this tutorial you turn the plugin meridian plugin new makes into a small custody plugin. It
records a holdings statement, one read of one source at one moment, and a holding in it, against
one of your firm's accounts. On the way you set up everything a plugin needs before a deployment
lets it write: a role, an account, and a link from the source's name for that account to yours,
made on the plugin's own Setup page under Manage.
What this tutorial cannot show yet
Read this first. The tutorial runs end to end, with three limits:
- You cannot read the result back here. No dashboard page lists statements or custodial
positions yet, and the SDK's reads of them,
list_statementsandlist_custodial_positions, are anoperationsplugin's, not a custody plugin's. You confirm the recording from the replies your plugin logs. - The data is made up. So that you need no broker account, this plugin calls no broker. It reports two invented accounts and records one invented holding.
- Nothing here trades. Holdings are read-only records of what a custodian says you hold. Trading is a connectivity plugin's: see Route orders through a connectivity plugin.
Allow 30 minutes.
What you need¶
- A deployment installed with
meridian up --development, and you are a deployment admin on it. See Install a deployment. - CLI 0.1.60 and SDK 0.36.0 or later:
meridian --versionsays which CLI you have, andmeridian upgradeupdates it. Itsmeridian plugin newbuilds on SDK 0.35.0, whichmeridian plugin migratemoves to 0.36.0, and writes the Setup page that links accounts, which this tutorial uses. - Docker on this machine.
- To have done Change your plugin's page, live, or be comfortable with
meridian plugin dev.
The examples use https://meridian.localhost, the deployment on this machine. For another, give
its address to meridian connect.
How it fits together¶
A custody plugin follows workflow W2, holdings ingestion, and links its accounts by W6.4. It uses
seven of the SDK's typed operations, all granted by the custody
role. The scaffold already makes the first three; you write the other four:
| Step | SDK call | What it does |
|---|---|---|
| W2.8 | report_external_accounts |
Says which accounts the source reaches, as the source names them |
| W6.4 | read_accounts_for_linking |
Reads the firm's accounts, their identities alone, for the admin viewing the plugin's Setup page under Manage |
| W6.4 | link_external_account |
Links one of the source's accounts to one of the firm's, for that admin |
| W2.1 | report_sync_status |
Says how fresh the source's data is for one external account |
| W3.1 | resolve_identifier |
Asks which instrument an identifier means |
| W2.2 | record_holdings_statement |
Opens one statement for one external account, with its figures, and says how many rows will follow |
| W2.3 | record_holding |
Records one row: one account, one instrument, a side, a quantity and a value |
The deployment's street store closes the statement when as many rows have arrived as it said would follow.
Two rules decide whether a statement or a row is accepted:
- The external account must be linked. The plugin names an account as the source knows it. An admin of the plugin links that to one of the firm's accounts, on a page the plugin serves under Manage, and the plugin sends the link acting for them. A statement or a row for an unlinked account is refused. Link, then record.
- The account must be in the plugin's write scope. The link puts it there: the
custodyrole lets the plugin write to the street store, and the link gives it the one account, for as long as the link stands. No permission is needed for that. A permission is what lets people use a plugin on accounts.
See Accounts and Instruments.
1. Make the plugin¶
2. Ask for the custody role¶
Open pyproject.toml and set the roles:
Roles come from the deployment's fixed list, and each role's grants are fixed with it. The plugin cannot choose its own. A role is approved at launch, so set it before the first one: a save never adds a role to a running instance. See Plugins, roles and grants.
3. Launch it live¶
The plugin must be running, and must have reported its accounts, before an admin can link them. In your first terminal:
The first time, it builds and uploads the plugin, which takes a minute or two. Approve the role when asked:
holdings-demo 0.1.0 asks for
roles: custody
Launch it live as holdings-demo, with these? [y/N] y
Launched holdings-demo: holdings-demo 0.1.0.
Its page, if it serves one: https://meridian.localhost/plugins/holdings-demo
Watching . for holdings-demo. Ctrl-C stops watching; the instance keeps running.
r1 sent (10 files, 0 deleted)
r1 synced (10 sent, 0 deleted)
r1 restarted
r1 ready
Leave it running. In a second terminal, check what it registered as:
… INFO holdings_demo: registered as holdings-demo, roles custody
… INFO holdings_demo: may publish …; may subscribe platform.reference.event.instrument-replaced
… INFO holdings_demo: reported 1 external account(s) to link
… INFO holdings_demo: serving its pages on 127.0.0.1:8000
The may publish list is what the custody role grants, among it
platform.street.command.record-statement and platform.street.command.record-holding. The
may subscribe list is the one event the role hears, platform.reference.event.instrument-replaced:
the deployment saying an instrument ID was replaced, as when two records are merged. With it,
the scaffold reports the one external account it reaches, reference-1: a plugin holding no role
that may report accounts reports none.
Every save from now on is a new revision, r2, r3 and on. To read what one logged, pass
--since the revision before it: for r4, --since 3.
4. Make an account to link to¶
Open the dashboard, sign in, and choose the gear at the top right, Settings. On the
Accounts tab, choose + Add, name it Demo brokerage account, and choose Create.
See Give people access for more on the tab.
The plugin links only to the accounts a deployment admin granted it: on the plugin's Accounts tab in the dashboard's admin view, grant it Demo brokerage account. See
To grant a plugin accounts to link.
That is all the plugin needs from Settings. You do not group the account or grant a person a permission on it: the link you make in step 6 is the plugin's right to write it.
5. Report the source's accounts¶
A custody plugin says which accounts its source reaches before it records anything for them (W2.8).
Only an account it reported can be linked. The scaffold reports REACHES, in
src/holdings_demo/page.py, at start. Replace its one account with the two a broker would name:
REACHES = [
meridian.ExternalAccount(
external_account_id="DEMO-ACCT-1", name="Demo brokerage", venue_account_type="Individual"
),
meridian.ExternalAccount(
external_account_id="DEMO-ACCT-2", name="Demo retirement", venue_account_type="IRA"
),
]
DEMO-ACCT-1 and DEMO-ACCT-2 are the names the source uses for the accounts, as a broker would.
The name and type are the source's own words. The scaffold's Setup page shows each account's
ID and name to the admin who links them, and not its type: the type goes to the deployment with
the report, and is given to an account created from the link (see the note in step 10).
Save, wait for ready, and read what that revision logged. If it was r2:
… INFO holdings_demo: registered as holdings-demo, roles custody
… INFO holdings_demo: may publish …; may subscribe platform.reference.event.instrument-replaced
… INFO holdings_demo: reported 2 external account(s) to link
… INFO holdings_demo: serving its pages on 127.0.0.1:8000
The scaffold's tests, in tests/test_page.py, link its old account, reference-1, named
Reference account. Move them to the first of yours, so they keep passing: change "reference-1"
to "DEMO-ACCT-1" in LINKED, in each external_account_id a test posts or checks, and in
"Linked reference-1.", and change Reference account to Demo brokerage. Leave
meridian.Identity("reference-1", …), and the <code>reference-1</code> the Setup test looks for:
that is the instance the tests' stand-in sidecar says the plugin was launched as. The tests are
never sent to the instance, so this save makes no revision. Run them, in a virtual environment
of the plugin's own:
python3 -m venv .venv && . .venv/bin/activate
pip install -e . pytest
meridian plugin check --run-tests
A bare pip install outside a virtual environment is refused by some Pythons, such as Homebrew's on
macOS (externally-managed-environment). --run-tests runs the tests with .venv/bin/python when
the plugin has a .venv.
tests-pass passes, and every rule holds. role-suite, by which a plugin holding custody runs
the custody suite in its tests, each case mapped to its own exchange with its vendor, is held only
under --verified, and shown skipped otherwise: a plugin not passing its role's suite is not
verified for the role, which breaks no rule. This plugin converts no vendor's data, so it has
nothing to map. A real custody plugin passes it,
checked with meridian plugin check --verified --run-tests; see
Pass the role's suite.
role-pages, by which a plugin holding custody keeps the role's common pages, is held only under
--verified too, and its findings are printed below it as advice that fails nothing: this plugin
declares no Account links page and no Statements page, each instead: pointing to the
role's common part as meridian plugin new --role custody writes it. The output ends:
Advice, 2 place(s), failing nothing: a verified plugin is held to it (--verified).
Every rule it was checked against holds.
This tutorial builds on the plain scaffold, to show each call on its own. A real custody plugin
starts from meridian plugin new --role custody, which writes those pages.
6. Link DEMO-ACCT-1¶
On the dashboard's home, holdings-demo is listed with Manage: as a deployment admin you are an
admin of every plugin, through All plugins (admin). Choose Manage. The plugin's area opens
on its Summary; open the tab after Summary and Settings, its one page at admin,
Setup. See Manage, Open and View.
Under External accounts it lists DEMO-ACCT-1 and DEMO-ACCT-2, each Not linked. On the
DEMO-ACCT-1 row, choose Link…, type Demo in An existing account, choose
Demo brokerage account, then Link. The page says:
and the row shows it Linked to Demo brokerage account. Leave DEMO-ACCT-2 unlinked for now.
What the scaffold's Setup page, setup and link in page.py, does:
- It is declared at
admin:@pages.page("/setup", "Setup", levels="admin"), and its form's route,/link, atadmintoo. The dashboard shows it under Manage alone,Pagesanswers 403 to a session at any other level before the view runs, and it shows no account's data: the accounts' identities and links are configuration. - It reads the firm's accounts, and sends each link, acting for the admin:
acting_foris their header, handed back. The sidecar admits both only in a session opened by Manage, answers the accounts' identities alone, and admits a link only for an account this plugin reported. - It reads its links, never keeps them: each row's link comes from the plugin's account scope,
account_scope(), as the sidecar has it now. - It draws them with the kit's
om-account-map, which searches, filters and pages thousands of accounts, posting plain forms that carry the page's CSRF token. The plugin's page has its own sign-in cookie, so without the token a page elsewhere could make an admin's browser post the form;Pagesrefuses a form without it before the view runs. See Build a plugin's page. - The link is also a tool: the route declares its inputs as one record,
LinkAccount, so an agent you delegated to can link through the deployment's MCP, atadmin, as you would here.
If the page says This form has expired or did not come from this plugin's page, the plugin
restarted since you opened it, with a new secret: reload it and link again. If it says
Refused:, the reason is the sidecar's own.
7. Write the recording code¶
Create a new file:
"""Record one made-up holdings statement, the way a custody plugin would."""
from __future__ import annotations
import logging
import time
import uuid
from decimal import Decimal
import meridian
from meridian.roles import custody
log = logging.getLogger("holdings_demo")
SOURCE = "demo" # the source's name, like "snaptrade"
CUSTODIAN = "Demo Securities" # where the source says the account is held
EXTERNAL_ACCOUNT = "DEMO-ACCT-1" # the account as the source names it
async def record_demo_statement(role: custody.Role) -> None:
now = time.time_ns()
try:
# W2.1: how fresh this source's data is for the account.
await role.report_sync_status(
source=SOURCE,
external_account_id=EXTERNAL_ACCOUNT,
connection_healthy=True,
last_synced_at_ns=now,
observed_at_ns=now,
)
# W3.1: which instrument this identifier means. When nothing matches
# and the source states an identifier, a description and a currency,
# the deployment mints its own record for it, and answers that. Stated
# less, or matching several records, it is a miss.
symbol = meridian.Identifier(scheme="symbol", value="ACME", source=SOURCE)
resolved = await role.resolve_identifier(
identifiers=[symbol],
as_of_ns=now,
stated_description="Acme Corp common stock", # as the source describes it
stated_currency="USD", # the currency the source states it in
)
if resolved.found:
log.info("ACME is %s (minted: %s)", resolved.instrument_id, resolved.minted)
# W2.2: open one statement for the account, saying how many rows will
# follow, with its figures as the source reported them.
opened = await role.record_holdings_statement(
source=SOURCE,
external_statement_id=f"demo-{uuid.uuid4()}",
external_account_id=EXTERNAL_ACCOUNT,
institution=CUSTODIAN,
as_of_date=time.strftime("%Y-%m-%d", time.gmtime()),
read_at_ns=now,
expected_rows=1,
figures=[
meridian.StatementFigures(
segment="", # the account as a whole
buying_power=meridian.Money(Decimal("1000.00"), "USD"),
),
],
)
log.info(
"opened statement %s (already recorded: %s)",
opened.statement_id,
opened.already_recorded,
)
# W2.3: one row. Resolved, it names the instrument. Missed, it
# carries what we held, and is recorded rather than dropped.
row = await role.record_holding(
statement_id=opened.statement_id,
instrument_id=resolved.instrument_id if resolved.found else "",
unresolved_identifiers=[] if resolved.found else [symbol],
side=meridian.HoldingSide.HOLDING_SIDE_LONG,
quantity=Decimal("12.5"),
market_value=meridian.Money(Decimal("2812.50"), "USD"),
external_account_id=EXTERNAL_ACCOUNT,
)
log.info("recorded holding %s (resolved: %s)", row.holding_id, row.resolved)
except meridian.MeridianError as failed:
log.warning("not recorded: %s", failed)
Amounts are Decimal, never float, and a value is a meridian.Money, an amount and its currency.
The SDK refuses a float, or more than 18 decimal places, rather than round it. A holding states
its side, long here.
The statement names the account it was read for, as its row does, and the institution holding it.
Its figures are a set per margin segment, each naming its segment as the source does; the one set
here names none, so it is the account's as a whole. Every figure is as the source reported it: a
source that reports no buying power sends none, which is not zero. See
record_holdings_statement.
Why a fresh statement id each run
Each save restarts the plugin, so this records a new statement each time. A real connector uses
the source's own statement id, so a statement delivered twice comes back with
already_recorded set, instead of being recorded twice.
Money and dates
A Money also names its currency's cash instrument, which core resolves from the code, so
Money(amount, "USD") is enough. A date field takes a datetime.date as well as its ISO 8601
text, as above. See Money and instruments and
Dates and time.
8. Call it on start¶
Open src/holdings_demo/page.py. Its custody class reports the accounts the plugin reaches in
its on_start; record the statement just after, on the same role object. Import the new function
at the top:
and call it at the end of the class's on_start:
class Custody(custody.Role):
async def on_start(self) -> None:
if reports(self.plugin):
await self.report_external_accounts(accounts=REACHES)
log.info("reported %d external account(s) to link", len(REACHES))
await record_demo_statement(self)
Save, and wait for ready on the last revision.
9. Check the result¶
Read what the last revision logged. If it was r4:
Expected output:
… INFO holdings_demo: registered as holdings-demo, roles custody
… INFO holdings_demo: may publish …; may subscribe platform.reference.event.instrument-replaced
… INFO holdings_demo: reported 2 external account(s) to link
… INFO holdings_demo: serving its pages on 127.0.0.1:8000
… INFO holdings_demo: ACME is LCL-… (minted: True)
… INFO holdings_demo: opened statement STMT-… (already recorded: False)
… INFO holdings_demo: recorded holding HLD-… (resolved: True)
What happened:
- The statement was opened against
Demo brokerage account, whichDEMO-ACCT-1is linked to, and given an id by the deployment. - The row was recorded against the same account.
- The deployment knew no instrument for the symbol
ACME. The source stated an identifier, a description and a currency, so the deployment minted its own record for it, an identifier beginningLCL-, and answered that, withminted: True. The record is provisional: the result'sprovisionalisTrue. A later resolve of the same identifiers matches that record, and saysminted: False: from the next save on, and on any later run of this tutorial on the same deployment. AnLCL-record is the deployment's own, a record like any other; a deployment admin completes what it lacks, such as its asset class, on the dashboard's Instruments page. The description and currency the source stated are kept on it as offers, in force once a person accepts them. resolved: Truebecause the row names an instrument, and so it updates a custodial position: the street store now holds a long position of 12.5 underDemo brokerage account, valued at 2812.50 USD.- Had the source stated the symbol alone, with no description or currency, nothing would have been
minted: the resolve would have answered
foundFalse, withMISS_REASON_NOT_FOUND, and logged noACME isline. Had more than one instrument matchedACME, the answer would have been a miss too, not a pick, withMISS_REASON_AMBIGUOUS. Either way the row carries the identifier it held instead, is recorded as unresolved, withresolved: False, and updates no position. Seeresolve_identifier. - One row arrived of one expected, so the street store closed the statement.
10. See the rules work¶
Change the external account in statement.py to the one you left unlinked:
Save, wait for ready, and read the logs after that revision. The last line now reads:
… WARNING holdings_demo: not recorded: RecordHoldingsStatement: refused: external account DEMO-ACCT-2 is not linked to an account; a deployment admin links it on the plugin's admin page (W6.4), and the next statement records it
The statement names DEMO-ACCT-2, so it was refused before its row was sent, and nothing is
recorded for DEMO-ACCT-2. A row naming it would be refused the same way. The sync status before
it was not refused: the dashboard shows it beside the unlinked account, so an admin can tell
whether it is worth linking.
Telling this refusal apart
The words are for you, reading the log, and may change at any release: don't match them. This
refusal raises meridian.NotLinked, chosen by a code the sidecar sends with it, so a plugin
can catch that alone and offer the account for linking. It is a CallFailed, so the
except meridian.MeridianError above still catches it. See
An unlinked external account.
Linking DEMO-ACCT-2 to Demo brokerage account is refused: that account has DEMO-ACCT-1
linked already, and an account has one external account. Try it on Setup, and the page says
so, beginning Refused:. Two external accounts at one custodian are two accounts, each with its
own statements. See Accounts.
So give it its own. On the Accounts tab of Settings, add a second account, Demo retirement
account. Reload the plugin's Setup tab, under Manage, and link DEMO-ACCT-2 to it as you
linked DEMO-ACCT-1.
Creating the account from the plugin's page
link_external_account can also create the account and link it in one step, named with
new_account_name and given the custodian and type the source reported. Only a deployment
admin may name a new account, which caller.deployment_admin says, and the sidecar refuses one
from anybody else. The scaffold's Setup page offers linking to existing accounts alone: its
om-account-map says no-new-account, and its LinkAccount takes no new account's name. See
Build a plugin's page.
The next statement records it. Make any change to statement.py, a blank line will do, save, and
read the logs after that revision: the last line is recorded holding … (resolved: True) again.
The resolve before it answers the LCL- record step 9 minted, matched this time rather than
minted, so its line says minted: False.
A statement or a row for a closed account is refused too, as outside the plugin's write scope,
and that refusal also shows as a refused event from meridian plugin events.
If a save still reports the old state
If a save made straight after you make a link still reports the old state, save again.
11. Clean up¶
Press Ctrl-C in the first terminal, then:
The accounts and the links stay. To remove a link, choose Change… on its row on Setup, then Unlink, before you stop the plugin. Close the accounts on the Accounts tab of Settings if you do not want them.
What you learned¶
- A plugin writes only through its role's typed operations, and only on accounts in its write scope.
- A custody plugin reports the accounts its source reaches, and an admin links them on its own
page at
admin, which the plugin serves under Manage, acting for the admin viewing it. The scaffold's Setup page is that page: it shows no account's data, reads its links from the plugin's account scope, and each form carries the page's CSRF token. - An external account must be linked to one of the firm's accounts before a statement or rows for it are accepted, and the link is the plugin's right to write that account. An account has one external account.
- A statement names its external account, states its figures per margin segment as the source reported them, and says how many rows follow. An instrument nobody knows is answered with a record the deployment mints, when the source states an identifier, a description and a currency; one stated with less, or an ambiguous one, is recorded unresolved. A row is never dropped.
- Refusals come back as
MeridianErrorwith the deployment's own reason.
Next steps¶
- Python SDK and Typed operations: every parameter of these calls.
- Keep what your custody plugin converts: the raw record behind every row, and the custody suite a real custody plugin passes.
- Prove your plugin against a released runtime: link an account through the Setup page and compare the street store with what you expect, in CI.
- Release a plugin version: turn it into a version.