Your IBKR trades, dividends and cash balance in self-hosted Ghostfolio, on a daily schedule.
| Import | Reconcile | Run |
|---|---|---|
| Trades, dividend payments and withholding tax | Trade IDs, manual-entry matching and a holdings gate | Docker on amd64 / arm64, or plain Python |
| ISIN → Yahoo Finance ticker mapping | Separate Ghostfolio accounts for IBKR sub-accounts | One-off runs, cron scheduling and a preview with DRY_RUN=1 |
| Automatic Ghostfolio login from the security token | Optional failure alerts (ntfy, Telegram, email...) through Apprise |
flowchart LR
IBKR[IBKR Flex Query] --> XML[Trades, dividends and cash]
XML --> Mapping[ISIN to Yahoo ticker]
Ghostfolio[Existing Ghostfolio activities] --> Checks[Deduplication and holdings checks]
Mapping --> Checks
Checks --> Preview[Dry-run preview]
Checks --> Import[Import activities and update cash]
Get started · Configuration · Mapping · Troubleshooting · Limitations · Contribute
- Configure IBKR Flex Web Service and your query, then create your Ghostfolio account and token. Use the same account currency as the IBKR base currency.
- Copy
mapping.yaml.exampletomapping.yamland review your securities' Yahoo tickers. The mapping file must exist unless you explicitly disable it. - Run the Docker preview with
DRY_RUN=1. Review ticker mappings, skipped trades and the proposed cash balance before enabling writes. - After review, set
DRY_RUN=falseto permit writes. Run once, then configure scheduled runs. After the first import, gather historical market data in Ghostfolio.
Before importing: back up Ghostfolio using your deployment's backup procedure. The sync adds activities and updates cash; there is no automatic undo. The first Flex Query covers at most 365 days, so older positions must already be represented in Ghostfolio. Splits and other corporate actions need manual reconciliation. See Limitations.
This tool fetches your IBKR activity statements via Flex Queries, parses trades and dividends, maps ISINs to Yahoo Finance tickers, and pushes everything into Ghostfolio. It handles multiple sub-accounts, deduplicates activities, updates cash balances, syncs dividend payments (including withholding tax), and skips FX conversions and options trades. With GHOST_ACCESS_TOKEN it logs in to Ghostfolio by itself at every run, so there is no session token to renew, and with APPRISE_URLS it sends one alert when a run fails or, in explicit live mode, the legacy session token is about to expire (see Failure notifications).
This fork of obol89/ghostfolio-ibkr-sync parses IBKR XML directly, uses only requests and pyyaml for the sync itself (Apprise is loaded only by a separate, optional notification worker), and keeps symbol mapping under your control. It is intended for investors operating their own Ghostfolio instance who want repeatable daily imports and visible reconciliation warnings.
One run does this, in order:
- Load the configuration and the symbol mapping. A missing or invalid mapping file stops the run before anything is contacted.
- Read all existing Ghostfolio activities once. Deduplication depends on them, so if the list looks incomplete or redacted the run stops and writes nothing.
- Then, for each IBKR account in turn (steps 3 to 6): fetch its Flex Query report (two steps: request, then poll until IBKR has generated the statement), find the Ghostfolio account by name (if
GHOST_ACCOUNT_NAMESis unset, the IBKR account IDs are used as names, with a warning), and parse trades, dividend payments and the cash balance. - Decide what is new: skip FX and options; skip trades already in Ghostfolio (
IBKR#<tradeID>comment) and trades without a trade ID (they cannot be deduplicated); skip dividends already in Ghostfolio (dividend#...comment, or a manual or legacy dividend, one without adividend#comment, of the same symbol in the same account within ±3 days, logged asINFOwhen the gross agrees and as oneWARNINGotherwise); skip trades already entered by hand; and check the net quantity against existing holdings. This is a batch-level guard, not chronological position reconciliation. - Convert what is left to Ghostfolio activities and import them as a batch per account. If Ghostfolio reports an unresolved symbol, remove that symbol from the batch and retry the rest; the run still exits 1 so the omitted activities remain visible.
- Set the account's cash balance. This happens even if the import failed; it is skipped, with a warning, only when the report has no base-currency cash row. The Ghostfolio account currency must equal the IBKR base currency (Account Information
CurrencyPrimaryin the Flex Query): on a mismatch, or when the report has no base currency, the update is refused with anERRORnaming the cause, no balance is sent and the account counts as failed.DRY_RUNstill reads the account and reports the same refusal. After all accounts, the run exits0if every account was clean and1otherwise.
The tool never deletes or edits an existing Ghostfolio activity: it only adds new ones and updates each account's cash balance (and its platform, if GHOST_PLATFORM_ID is set and the account has none). Add DRY_RUN=1 to see the whole decision without any write (see Dry run first).
Activities are read from assetProfile on current Ghostfolio APIs (the legacy
SymbolProfile response field was removed in 3.78.0). Legacy-only responses
remain supported for older versions. If both fields are present, assetProfile
takes precedence. BUY, SELL and DIVIDEND activities must have a profile with a
nonempty string symbol; otherwise the run stops before any import or cash
balance update. ISIN remains optional, as some assets do not have one.
ghcr.io/flowcool/ghostfolio-ibkr-sync:latest
Multi-arch image (linux/amd64 and linux/arm64). The next published stable
GitHub Release starts
the stable channel: vX.Y.Z publishes :X.Y.Z, and only the current stable
release updates :latest. Existing :X.Y and staging aliases remain frozen;
they are not deleted. Main, tag pushes, manual runs and weekly checks publish
nothing. Weekly security checks remain; compatible security fixes require a new
PATCH release selected by the maintainer.
Before upgrading, record the installed image digest, rather than inspecting a
tag that may have moved. For a Compose service named ibkr-sync:
docker inspect --format '{{.Image}}' "$(docker compose ps -q ibkr-sync)"
docker image inspect --format '{{json .RepoDigests}}' <image-id-from-above>For a container identified by name (any service name, for example the container_name of the
Compose example), pass the name instead:
docker inspect --format '{{.Image}}' <container-name>
docker image inspect --format '{{json .RepoDigests}}' <image-id-from-above>Save the matching ghcr.io/flowcool/ghostfolio-ibkr-sync@sha256:... reference
with the existing ENV and mounts. If no repository digest is recorded, stop and
identify the installed artifact before upgrading. Select a full version for
controlled upgrades or pin its digest for exact artifact selection. Container
tags remain mutable outside the workflow's overwrite guards.
A vX.Y.Z-rc.N release candidate may also exist. It is published as :X.Y.Z-rc.N and never
moves :latest, :X.Y.Z or :X.Y. A pinned -rc.N tag receives no automatic update: after the
stable release, change the image back to :latest (or pin :X.Y.Z). To test one on a scheduled
Compose install, see Try a release candidate.
To roll back, select the saved digest and recreate the connector with the same
ENV and mounts. Older images can write with absent DRY_RUN; set
DRY_RUN=true explicitly for a rollback preview. This does not restore
financial records or undo cash updates.
Image publication does not deploy or recreate a container. A failed latest
promotion requires explicit operator recovery using the verified manifest digest
of the current stable release; never rebuild or overwrite its version tag. See
release and recovery steps and
build and release guarantees.
The running version is logged at container start and at the beginning of every sync:
ghostfolio-ibkr-sync version v2.2.0
2026-10-09 06:05:00 [INFO] Starting IBKR to Ghostfolio sync (version v2.2.0)
A release candidate reports its tag, for example ghostfolio-ibkr-sync version v3.0.0-rc.2.
New stable images, including :latest, report vX.Y.Z. Older main-built images
may report vX.Y.Z-N-g<sha> (N commits after the last release, exact commit <sha>).
3.0 contains breaking changes: the Flex query needs a time-zone suffix and more
dividend fields, and TZ must be set. Starting with rc.2, omitted or blank
DRY_RUN simulates; writes require an explicit false value. Read the
3.0 upgrade and rollback guide before pulling a new
image. A v3.0.0-rc.N release candidate may be published first for testing; it
never moves :latest. The guide also shows how to
check that your Flex query is ready
and how to try the candidate.
See the 2.2 upgrade and rollback guide.
- A running self-hosted Ghostfolio instance, version 2.248.0 or newer
- An Interactive Brokers account with Flex Web Service enabled
- Docker (for containerised runs) or Python 3.14+ with the pinned packages from
requirements.txt(CI and the container use Python 3.14)
The tool reads existing activities via GET /api/v1/activities, which landed in Ghostfolio 2.248.0. The /api/v1/order endpoints it replaced were deprecated in that same release and removed in 3.5.0, so on Ghostfolio 3.x this is the only endpoint that works.
Follow the Flex Web Service and query setup guide: enable the service, create one 365-day query per sub-account with the required trade and cash fields, and obtain its Query ID.
Dividend quantities require
the enriched Flex fields
before upgrading. Cash Transactions still establish payments and withholding;
Change in Dividend Accruals supplies quantity evidence only. The connector no
longer infers quantity from descriptions or falls back to one unit. Missing,
conflicting or unsafe evidence rejects the affected payment with a warning.
A required Cash Transactions attribute, or the Change in Dividend Accruals
section, that is missing from the whole report fails the account before any
write (exit 1, one ERROR naming each field to tick). A single empty or
conflicting value rejects only the affected payment: trades and cash continue,
and that rejection does not change the exit code or send failure notifications.
Rejected payments retry on later runs; existing dividends are deduplicated first
and stay untouched and unverified.
Before upgrading, back up Ghostfolio and record the previous image digest. Keep
ENV and mounts available, and set a valid IANA TZ before enabling writes.
For runtime rollback, restore that digest and recreate the connector. Check the
previous runtime's timestamp format requirements before resuming writes.
Rollback does not undo financial writes. See the upgrade procedure
and the 3.0 upgrade guide.
Set exactly one of these two variables (both set, or neither, stops the run with exit 1):
-
GHOST_ACCESS_TOKEN(recommended) — the Ghostfolio security token you sign in with (shown when the user was created; Ghostfolio can generate a new one). At the start of every run, after the configuration and mapping file are validated and before anything is read from Ghostfolio or IBKR, the sync exchanges it once for a fresh session token viaPOST /api/v1/auth/anonymous. There is nothing to renew by hand. A dry run logs in too (logging in writes no financial data). The security token is long-lived: treat it like a password, and rotate it in Ghostfolio if it leaks. -
GHOST_TOKEN(legacy) — a session bearer you obtain yourself; it is used as is and expires, so you have to regenerate it periodically:curl -X POST http://localhost:3333/api/v1/auth/anonymous \ -H 'Content-Type: application/json' \ -d '{"accessToken": "YOUR_GHOSTFOLIO_SECURITY_TOKEN"}'
The response contains an
authTokenfield. Use it asGHOST_TOKEN.
With GHOST_ACCESS_TOKEN, GHOST_HOST must be a plain http:// or https:// URL (an
optional path is allowed): user credentials, a query string, a fragment, whitespace or control
characters are refused before any request. The login request does not follow redirects, so point
GHOST_HOST at the final Ghostfolio URL. A run never logs in again midway: if the session token
is refused later in the run, that run fails with exit 1 and the next run logs in afresh.
Plain http:// is for a trusted network only. The login request carries the long-lived
security token in its body, so over http:// anyone who can observe the path can capture it
(the session token of every later request, in either mode, is exposed the same way). Use
http:// only when the sync reaches Ghostfolio over a private link, such as the internal Docker
network of a shared compose stack (http://ghostfolio:3333). When the traffic crosses any other
network, point GHOST_HOST at an https:// URL.
Switching modes / rollback: remove one variable and set the other, then recreate the
container. Images released before GHOST_ACCESS_TOKEN existed only understand GHOST_TOKEN:
when rolling back to one, set GHOST_TOKEN again first.
- Go to Ghostfolio Admin - Platform
- Click Add Platform
- Enter a name like
Interactive Brokersand a URL likehttps://www.interactivebrokers.com - Save it
To find the Platform ID, query the API with a session token (the authToken returned by the curl command above):
curl http://localhost:3333/api/v1/platform \
-H 'Authorization: Bearer YOUR_AUTH_TOKEN'Look for the id field of the IBKR platform entry. Use this as GHOST_PLATFORM_ID.
Create one Ghostfolio account per IBKR sub-account:
- Go to Accounts and click Add Account
- Set the name to match what you will use in
GHOST_ACCOUNT_NAMES(for exampleIBKR IndividualorIBKR Joint) - Select the IBKR platform you created
- Set the currency to match the sub-account base currency
- Repeat for each sub-account
If your IBKR trades involve currencies that are not yet in Ghostfolio:
- Go to Admin - Market Data
- Search for and add any missing currency pairs (for example
USDEUR,USDGBP,USDCHF) - Ghostfolio needs these to convert values to your base currency
After the first sync, go to Admin - Market Data and click Gather All Data. This fetches historical prices from Yahoo Finance for all newly imported symbols. Without this step, portfolio values and performance charts will be incorrect. Run it again after adding symbols from a new import.
All configuration is done via environment variables:
| Variable | Required | Description | Example |
|---|---|---|---|
IBKR_TOKEN |
Yes | IBKR Flex Web Service token | 1234567890abcdef |
IBKR_ACCOUNT_IDS |
Yes | Comma-separated IBKR account IDs | U1234567 |
IBKR_QUERY_IDS |
Yes | Comma-separated Flex Query IDs (one per account) | 123456 |
GHOST_ACCESS_TOKEN |
One of these two | Ghostfolio security token, exchanged for a session token at the start of each run (recommended; see Ghostfolio Setup) | a1b2c3... |
GHOST_TOKEN |
One of these two | Ghostfolio session bearer token, used as is (legacy; expires) | eyJhbGciOi... |
GHOST_TOKEN_EXPIRY_WARNING_DAYS |
No | Legacy token reminder window, integer 0-365 days (default 14; 0 disables). Invalid values warn and use 14. Ignored with GHOST_ACCESS_TOKEN |
14 |
GHOST_HOST |
Yes | Ghostfolio base URL | http://ghostfolio:3333 |
GHOST_PLATFORM_ID |
No | Platform ID for IBKR in Ghostfolio | abc123-def456 |
GHOST_ACCOUNT_NAMES |
No | Comma-separated Ghostfolio account names (must match account count). Two accounts cannot share one name: the run stops at start-up (exit 1) before any network call. Without names, the IBKR account IDs are the targets and must also be distinct | IBKR Individual |
MAPPING_FILE |
No | Path to symbol mapping YAML (default: mapping.yaml). The run stops (exit 1) if the file is missing or invalid; set it to an empty value (MAPPING_FILE="") to run without mappings on purpose |
/app/mapping.yaml |
CRON |
No | Five numeric cron fields for recurring runs (Docker only); validated before startup | 0 6 * * * |
TZ |
For writes | Valid IANA timezone for historical dividend dates and cron scheduling. Missing or invalid values force the whole run into DRY_RUN, with a WARNING. Explicit false DRY_RUN retains the configured forced-preview notification, even on success |
Europe/Warsaw |
LOG_LEVEL |
No | Logging verbosity: DEBUG, INFO, WARNING, ERROR (default: INFO) |
DEBUG |
APPRISE_URLS |
No | JSON list of Apprise URLs for failures, legacy token expiry reminders and TZ-forced previews (blank or [] = off); see Failure notifications |
["ntfys://ntfy.sh/my-topic"] |
APPRISE_TIMEOUT |
No | Seconds allowed for one notification, integer 1-30 (default 10) |
10 |
DRY_RUN |
No | Since rc.2, absent/blank or 1/true/yes/on simulates; only 0/false/no/off requests writes. Spaces and case are ignored. Other values exit 1 before HTTP; only configured Apprise Failure delivery may follow. Simulation keeps login, reads and preview, without activity, cash or platform writes |
1 |
The mapping file maps ISINs to Yahoo Finance ticker symbols. This is necessary because IBKR identifies securities by ISIN while Ghostfolio uses Yahoo Finance tickers for price data.
Use a symbol_mapping key at the top level (a missing or empty key is treated as an empty mapping with a warning):
symbol_mapping:
IE00B52MJD48: EIMI.L # iShares MSCI EM IMI - London
DE0002635307: EXSA.DE # iShares STOXX Europe 600 - Frankfurt
IE00B4L5Y983: IWDA.AS # iShares Core MSCI World - Amsterdam
CH0110869143: CHSPI.SW # iShares Core SPI - Swiss ExchangeUS-listed securities (VOO, QQQ, NVDA, AAPL, etc.) are recognised by Yahoo Finance using the IBKR symbol directly, so they do not need an explicit mapping.
European ETFs and stocks require mapping because Yahoo Finance uses exchange suffixes that differ from IBKR symbols:
| Exchange | Suffix | Example |
|---|---|---|
| London Stock Exchange | .L |
EIMI.L |
| Frankfurt / Xetra | .DE |
EXSA.DE |
| Amsterdam | .AS |
IWDA.AS |
| SIX Swiss Exchange | .SW |
CHSPI.SW |
- Go to finance.yahoo.com
- Search for the security by name or ISIN
- Use the ticker shown on the Yahoo Finance page, including the exchange suffix
When the script encounters an ISIN not in the mapping file, it falls back to the IBKR symbol and attempts to import the activity. If Ghostfolio cannot resolve that ticker, the tool drops its activities, imports the remaining activities if any remain, and exits 1. At the end of the run it logs one self-contained warning per ISIN:
[WARNING] Unmapped ISIN JP3637000005 (TRINITY INDUSTRIAL CORP) uses IBKR symbol '6382.T' as ticker (fallback) — verify in Ghostfolio or add to mapping symbol_mapping: 'JP3637000005: <yahoo ticker>'
If the fallback ticker is wrong for Yahoo Finance, add the ISIN to your mapping file with the correct ticker. Trades are reported only on the run that imports them; dividends on the fallback are reported on every run.
- FX conversion trades - trades with assetCategory
CASHare currency conversions, not investment positions - Options trades - trades with assetCategory
OPTare skipped (Ghostfolio does not support options) - Sells that would make a position negative - the Flex Query only covers 365 days, so a sell of a position bought earlier arrives without its buy. For each security (per Ghostfolio account, by the ticker it will be imported under), the tool adds the quantity Ghostfolio already holds to the net quantity of IBKR trades not yet imported. If the result is zero or more, the candidates pass the first check. Buys and dividends are submitted first; sells are checked again using only newly created buys confirmed by the server response. If it would go negative, that security is refused by the initial check. If the second check fails, successful buys from the first phase remain and the sells are skipped.
- Sells you already entered by hand - an IBKR sell is treated as already recorded when Ghostfolio has a manual sell (no
IBKR#comment) of the same quantity within ±2 days, under any symbol sharing the ISIN. Several IBKR fills of one day are also matched against a single manual entry by their sum. When a manual sell is nearby but the quantity does not match, the sell is not imported and a warning asks you to check by hand. - Buys you already entered by hand - the same rule applies to buys (manual buy without
IBKR#comment, same quantity, ±2 days, any symbol sharing the ISIN, fills matched by their sum). Unlike sells, a skipped buy is always reported as aWARNINGnaming the trade ID and the manual entry, because skipping it silently would hide a missing purchase. To silence the warning, putIBKR#<tradeID>in that manual entry's comment: the trade is then recognised by ID. If the match is a coincidence (a different purchase with the same quantity within 2 days), or a nearby manual buy has another quantity, the IBKR buy stays skipped (and warned about on every run) until you add it by hand or fix the manual entry. This is deliberate: a visible gap is safer than a silent duplicate. - Positions held under another symbol - if Ghostfolio holds the security under a different symbol with the same ISIN (for example a manual entry on another listing), the sell is not imported and a warning gives the mapping line that fixes it.
- Dividend reversals and orphan tax corrections - dividend and withholding rows of the same security and day are summed; a net zero (reversed payment) is skipped, and a withholding tax row without a dividend that day is not imported and logged as a
WARNINGto check by hand. - Duplicate activities - the tool checks existing Ghostfolio activities before importing and skips anything already present.
Lab acceptance needs LOG_LEVEL=DEBUG for native LAB_EVIDENCE window, preview and cash records.
A clean, fully reconciled run normally emits only INFO lines. WARNING means check this, ERROR means data is not in sync and needs action:
| Level | Meaning |
|---|---|
DEBUG |
FX/options skipped, each sell not imported because already reconciled |
INFO |
Sells imported for long-held positions; one summary line of sells not imported (already entered manually, or no Ghostfolio position) |
WARNING |
Unmapped ISIN on symbol fallback; position held under another symbol; manual sell or buy nearby with another quantity; buy skipped because it matches a manual buy; withholding tax without a dividend the same day; network retry |
ERROR |
Ghostfolio holds some quantity but the IBKR sells exceed it — fix the position in Ghostfolio; IBKR/Ghostfolio request failures; account failed before any write because a Flex prerequisite is missing (time-zone suffix, dividend fields); cash balance refused (currency mismatch or no base currency); trade rejected (unknown buySell); run aborted before any write (see Run stops before importing anything) |
| Source (IBKR) | Ghostfolio activity |
|---|---|
| Trade | BUY when IBKR says BUY, SELL when IBKR says SELL. Any other value (for example a cancellation) rejects the row: an error is logged, the account run is not ok and the other trades import normally. The row is never imported, so every run fails while it stays inside the 365-day report window. Quantity and price are absolute values. The comment is IBKR#<tradeID>, the data source is always YAHOO. |
| Commission | fee = the commission cost. A positive ibCommission (a rebate) is recorded as a fee of 0 and logged as a WARNING, because Ghostfolio fees cannot be negative. |
| Dividend | One DIVIDEND per security and day: dividends and payments in lieu are summed (a reversal nets out), withholding tax becomes the fee. A net refund of tax is recorded as a fee of 0 with a WARNING. The comment is dividend#<ISIN>#<date> (or the IBKR symbol when there is no ISIN) and the date is the date of the cash transaction at 00:00 UTC. A dividend is treated as already present if Ghostfolio has that comment, or a manual or legacy dividend (one without a dividend# comment) of the same symbol in the same account within ±3 days. Rows with a dividend# comment are the connector's own and are matched by exact comment only, so a distinct payment next to an imported one is imported. A payment skipped next to a manual or legacy row is never imported: the log shows INFO "amounts agree" when the gross matches, otherwise one WARNING with both dates, gross values and fees. Check IBKR and add the payment by hand if it is distinct. The exit status does not change. |
| Dividend quantity and price | Each net-positive cash action matches accruals by nonempty accountId, conid, currency, actionID and exDate. It needs exactly one Re with payDate on the cash day and date or reportDate on that day. All associated quantities must agree as positive finite decimals. Dividend/PIL portions and ordinary/bonus actions share one common quantity; quantities are never summed or rounded. Price = cash gross ÷ quantity, regardless of accrual gross, tax or rate. Zero-net actions provide no quantity; negative-net actions, mixed currencies and conflicting evidence reject the group. Quantity and price must remain positive finite numbers, and fees nonnegative finite, after conversion and minor-unit scaling; overflow or positive underflow rejects the group. |
| Date and time | IBKR timestamps are treated as UTC. A manual entry made in local time can differ by a day, which is why the manual-entry matching accepts ±2 days. |
| Currency | The trade or dividend currency, except on markets where Yahoo quotes the minor unit (below). |
Minor-unit markets. Yahoo Finance quotes some exchanges in the minor unit (pence, cents) while IBKR reports the major unit. For these the price and the fee (or withholding tax) are multiplied by 100 and the currency is changed so that amounts stay correct:
| Yahoo suffix | IBKR currency | Sent to Ghostfolio as | Status |
|---|---|---|---|
.L (London) |
GBP | GBp | verified on real trades |
.JO (Johannesburg) |
ZAR | ZAc | inferred, not verified |
.TA (Tel Aviv) |
ILS | ILA | inferred, not verified |
If you trade on .JO or .TA and amounts look 100 times off, check this table first.
docker run --rm --network your-ghostfolio-network \
-e DRY_RUN=true \
-e TZ=Europe/Warsaw \
-e IBKR_TOKEN=your_token \
-e IBKR_ACCOUNT_IDS=U1234567 \
-e IBKR_QUERY_IDS=123456 \
-e GHOST_ACCESS_TOKEN=your_ghost_security_token \
-e GHOST_HOST=http://ghostfolio:3333 \
-e GHOST_ACCOUNT_NAMES="IBKR Main" \
-v "$(pwd)/mapping.yaml:/app/mapping.yaml:ro" \
ghcr.io/flowcool/ghostfolio-ibkr-sync:latestDocker is the only supported way to run the sync. The image installs the
ghostfolio_ibkr_sync package and starts it with python -m ghostfolio_ibkr_sync.
Since v3.0.0 there is no ibkr_to_ghostfolio.py file or launcher: running the
repository from a source checkout is not supported. To test local changes, build the
image yourself. Run it with the command above, but replace
ghcr.io/flowcool/ghostfolio-ibkr-sync:latest with ibkr-sync:local:
docker build -t ibkr-sync:local .Starting with v3.0.0-rc.2, absent, empty or whitespace-only DRY_RUN selects
simulation. Every such invocation logs WARNING
DRY_RUN not set: simulation, nothing written, even if configuration fails.
Explicit 1, true, yes or on also selects simulation. Values are trimmed
and case-insensitive. Only 0, false, no or off requests live writes; all
other values exit 1 before any HTTP call. After rejection, only configured
Apprise Failure delivery may occur. No IBKR/Ghostfolio HTTP call or financial
write occurs. Other safety guards still apply.
Set DRY_RUN=1 explicitly for the preview. The full pipeline runs (fetch, convert, deduplicate, gate) and the activities and cash balance that would be written are logged, but no activities are imported and no cash balance is updated. With GHOST_ACCESS_TOKEN, the authentication POST still exchanges the security token for a session token. IBKR report generation is requested and existing Ghostfolio data is read. This preview does not validate symbols through the Ghostfolio import endpoint, so a successful preview does not guarantee a successful import. Logs include financial activity details; keep them private.
Set a valid IANA TZ, such as Europe/Warsaw, even for one-off Python runs.
Missing or invalid TZ forces every account into DRY_RUN before processing,
with one WARNING explaining the reason. No activities, cash or platform are
written. With explicit false DRY_RUN, the final notification includes the
reason when Apprise is configured, even if all accounts succeed. Successful
simulations selected by default, blank or truthy DRY_RUN send no notification,
including expiry-only reminders and previews. Failed simulations send one
configured Failure notification. After review, set DRY_RUN=false and retain
valid TZ to permit writes.
Every DETAIL dividend, payment-in-lieu and withholding cash row must have
dateTime in YYYYMMDD;HHmmss TimeZone form, for example
20260115;120000 EST or 20260715;120000 EDT. A missing or invalid suffix
fails the affected account before any import, cash or platform write. It does
not enable DRY_RUN; other accounts retain their configured mode. Other cash
types are outside this check. Follow the staged activation and rollback
procedure.
Every dry run also compares historical dividends in the target account before
deduplication. The INFO summary reports coverage and categories; details for
differences or uncertainty include source comments and activity IDs. A coherent
single activity has the canonical source comment, consistent security and local
date in TZ, and exactly equal quantity, gross and fee. The IBKR YYYYMMDD
is the source business day; the destination instant is rendered in TZ for
comparison. Flex suffixes provide provenance; import UTC conversion is unchanged.
Gross comes from broker cash; accruals establish quantity. Component quantities
are never summed. Several
activities with the same group comment do not prove a component partition,
even when their amounts sum correctly; that comparison stays uncertain.
An independently identified component set must cover every source component
exactly once. Each component keeps its own quantity and monetary comparison.
The reader does not invent component identities for historical activities.
Two or more identity-proven complete representations indicate probable repetition,
even if their quantities or amounts differ. Incomplete partitions and contested
ownership remain uncertain. Matching amounts never establish completeness, and
the diagnostic never searches arbitrary subsets of activities.
The diagnostic retains original JSON decimals from the existing activities GET.
It never reconstructs exact evidence from floats. Activity currency determines
the denomination; only an explicit null permits profile currency fallback, with
its provenance retained. Supported denominations are USD, EUR, GBP, CHF, JPY,
CAD, AUD, NZD, HKD, SGD, SEK, NOK, DKK, PLN, CZK, HUF, ILS, ZAR and the existing
GBp, ZAc and ILA minor units. Missing or unsupported context stays uncertain;
the log then shows the observed source and destination currency and provenance
on a currency context: line, whatever the supported list says.
Gross and fee differences use destination minus source, without a tolerance.
Every nonzero exact delta remains visible, including differences smaller than
float precision. Source fee is the positive part of the negative net cash
withholding; signed withholding and refund rows and their total remain separate
evidence. Coherence requires exact quantity, gross and fee agreement; a complete
representation with a discrepancy is not coherent.
The diagnostic adds no reads, writes or files. Findings do not change
import selection, cash updates, exit codes or notifications. Live runs do not
run this comparison. Use the one-off command below without CRON.
absent means no canonical destination comment was found. It does not establish
that a payment is missing: legacy comments, symbol-only comments, proximity and
timezone explanations cannot certify identity. Inactive or unsupported records,
conflicting security/date context and missing/conflicting accruals stay uncertain.
Missing destination profile ISIN is unavailable evidence and permits amount
comparison; a nonempty conflicting ISIN blocks identity. Details name the
active, activity-ID, data-source, symbol, local-date and ISIN checks with
expected and observed values. INFO counts failed checks and shows anomaly
blocks with securities, source and destination UTC/local dates, activity IDs,
quantity, gross, fee and currency. An absent group suppressed by deduplication
shows the skip path that import selection takes: skipped by historical comment
when the account has an old dividend#<symbol>#<date> comment (active or not),
else skipped by proximity (rows with a dividend# comment never count as proximity candidates, and the block also shows source and candidate gross). Each shows candidate values and active status; the
group remains absent and neither label certifies identity. Outside-window records also show their security,
date and values.
The summary counts source payment groups and unmatched destination dividends
separately. Every unmatched target-account dividend remains visible. A UTC date
outside explicit, consistent Flex statement fromDate/toDate bounds is reported
as outside_window; without valid bounds, history stays uncertain. The connector
does not infer a window from the first or last payment. Short reports cannot
verify older history. No category authorizes a repair.
Use a one-off container with no CRON environment variable. The command reads
only the report generated by its configured Flex query; Docker does not extend
the broker's report window. INFO includes anomaly and uncertainty details, IDs
and source references; DEBUG also includes coherent comparisons. Keep Docker
logs private and capture them before removing a container if you need evidence.
The diagnostic creates no persistent report and log retention depends on Docker's
logging configuration. Financial state remains unchanged, but authentication and
IBKR report generation still contact the configured services. Technical read
failures still fail the run; diagnostic findings do not send notifications.
docker run --rm --network your-ghostfolio-network -e DRY_RUN=1 -e LOG_LEVEL=DEBUG \
-e TZ=Europe/Warsaw \
-e IBKR_TOKEN=your_token -e IBKR_ACCOUNT_IDS=U1234567 -e IBKR_QUERY_IDS=123456 \
-e GHOST_ACCESS_TOKEN=your_ghost_security_token -e GHOST_HOST=http://ghostfolio:3333 \
-e GHOST_ACCOUNT_NAMES="IBKR Main" \
-v "$(pwd)/mapping.yaml:/app/mapping.yaml:ro" \
ghcr.io/flowcool/ghostfolio-ibkr-sync:latestThe dividend comparison ends with one line, [DRY RUN] Dividend report: N to check, N informational, N noise, followed by the detail of each item to check. Findings are written in plain English: the reason, the IBKR and Ghostfolio values side by side, one action line and the statement that nothing was written. Coherent and outside-window findings appear at DEBUG only. Differences of less than half a minor unit are noise; a difference of exactly one minor unit with equal quantity is named "minimal difference, cause unknown"; a legacy-comment row on the same security and day is named "probable duplicate". Each item to check that has a candidate carries a "Proposed match": the IBKR dividend and the candidate Ghostfolio rows, each labelled Keep, Duplicate or Differs, with a "strong" or "weak" confidence and the row to act on; with no candidate it reads "no confident match". A proposal never writes, repairs or changes the counts, exit code or notifications.
Look for lines starting with [DRY RUN]; when there is nothing to import the log simply says No new activities to import. Replace your-ghostfolio-network with the Docker network shared with Ghostfolio. Run it again after upgrading to a new version: comparing the log with the previous version's shows exactly what the new version changes.
IBKR sometimes answers the Flex SendRequest step with a transient error. The
connector retries it by ErrorCode, never by message text.
- Retried:
1001,1004,1005,1006,1007,1008,1009,1018,1019,1021. - Fail at once:
1003,1010to1017(including expired or invalid token1012/1015),1020, and any missing or unknown code. - Schedule: at most five SendRequest attempts, with waits of 60, 120, 300 and 600 seconds between them: 18 minutes of scheduled waiting. Network timeouts and connection retries come on top of that.
- Exhaustion: the run fails with the last IBKR message (token redacted), the statement is never requested, and nothing is written for the affected account. Other accounts in a multi-account run are still processed.
- GetStatement window: after SendRequest succeeds, the connector polls up to 60
times, waiting 5 seconds after each pending response: 300 seconds of scheduled
waiting (previously 10 attempts). Pending means status
WarnorErrorCode1019; any other error fails at once. - Polling exhaustion: if all 60 responses stay pending, the run fails with a timeout and nothing is written for the affected account. Other accounts in a multi-account run are still processed.
No ENV change is needed. This is connector policy based on IBKR's Version 3 error table, not a guarantee that IBKR recovers.
Optional. When APPRISE_URLS is set, a run that completes with exit 1 sends one
notification through Apprise, which supports
ntfy, Gotify, Telegram, Discord, Slack, email and many more. A completed successful run
in explicit live mode also sends a reminder when its legacy GHOST_TOKEN is about to expire (see
Token expiry); the reminder keeps exit 0. Give it a JSON list (up to 10
URLs, each up to 2048 characters; put one URL per entry, as an entry holding several
space- or comma-separated URLs counts each one toward the limit of 10):
APPRISE_URLS: '["ntfys://ntfy.sh/my-private-topic", "tgram://BOT_TOKEN/CHAT_ID"]'
APPRISE_TIMEOUT: "10"What is sent: a fixed title, the UTC time, how many accounts failed out of how many, and
one line per failure with a fixed stage and reason code (for example
account: account_failed (account #2), where #2 is the position in IBKR_ACCOUNT_IDS).
Never error messages, account IDs or names, symbols, amounts, URLs or tokens: read the
container log for details. Up to 20 failure lines, then and N more.
ghost_startup: token_rejected means Ghostfolio refused the token (HTTP 401/403): with the
legacy GHOST_TOKEN this is how an expired session token shows up (see
Token expiry).
Expiry reminders use the fixed code ghost_auth: token_expiring and a rounded-up count
of days remaining (0 means expired). If the run also fails, the reminder is included
in the same failure notification. At most one notification is sent per completed run;
there is no daily state, so multiple runs in one day can repeat the reminder.
With explicit false DRY_RUN, missing or invalid IANA TZ also sends a final
notification naming the forced-preview reason, even on success. It shares the single summary with any
failures or expiry reminder. Notification delivery does not change exit status.
Simulation since rc.2: failed runs send one configured Failure notification,
including default, blank or truthy DRY_RUN and invalid-value rejection. Failure
with an expiry reminder or forced preview uses the same summary, never a second
dispatch. Delivery does not change exit status.
When nothing is sent: successful default/blank/truthy simulations, including
expiry-only reminders and previews; successful live runs without an expiry
reminder or forced preview; runs with only other warnings (for example unmapped
ISINs); runs stopped by a signal; and when APPRISE_URLS is blank or []. If
APPRISE_URLS or APPRISE_TIMEOUT is invalid, the run logs
APPRISE_URLS or APPRISE_TIMEOUT is invalid: failure notifications disabled and syncs as usual.
Delivery: Apprise runs in a separate short-lived worker process with only network
settings in its environment (PATH, locale, CA bundle and proxy variables; never the IBKR or
Ghostfolio credentials), the URLs passed on its standard input, and its output discarded, so a
misbehaving plugin cannot print your destination credentials into the log. One deadline,
APPRISE_TIMEOUT, covers the whole worker (Python start, Apprise import, sending, exit); a
worker still running at the deadline is killed. This adds at most APPRISE_TIMEOUT seconds,
plus Apprise's import time inside it, to a notifying run. There is no retry: after a timeout
the notification may or may not have arrived (Failure notification timed out (it may still arrive; not retried)). Delivery problems (timeout, failed, invalid_destination,
unavailable when Apprise cannot be imported, payload_too_large, spawn_failed when the worker
process (python -I -m ghostfolio_ibkr_sync --notify-worker) cannot start) are logged as warnings
and never change the run's exit code. Apprise and its pinned dependencies ship in the image
and are only imported by the worker.
Rollback: remove APPRISE_URLS (or set it to []) and recreate the container; the sync
behaves exactly as without the feature. Older images ignore the variable.
For scheduled runs with multiple sub-accounts, run a separate container per account with staggered cron times. The CRON environment variable uses supercronic internally.
The image runs as the stable non-root UID/GID 10001:10001. Before starting
the container, make the host mapping file readable by that owner:
chmod 0600 mapping.yaml
sudo chown 10001:10001 mapping.yamlThis works with the hardened profile's cap_drop: [ALL] and a read-only bind
mount. chmod o+r mapping.yaml is an alternative only when no matching named user
or group ACL entry denies read access. On POSIX ACL volumes, the ACL mask does
not apply to ACL_OTHER; matching entries with no effective read permission
deny access without falling through to ACL_OTHER. Check getfacl mapping.yaml.
Running as UID 0 does not bypass those permissions after all capabilities are
dropped. Use the same ownership for one-off Docker runs that bind-mount the file.
Use exactly five fields separated by a single space or tab, containing only
digits and *, /, ,, - (for example 0 6 * * * or */15 * * * *).
Named days/months, macros such as @daily, seconds fields and embedded commands
are rejected. The entrypoint checks the grammar, then runs supercronic -test
before starting the scheduler. Invalid schedules exit 1 with a fixed diagnostic;
the supplied value and validator output are not logged. An unset or empty CRON
still runs the sync once immediately.
services:
ibkr-sync-individual:
image: ghcr.io/flowcool/ghostfolio-ibkr-sync:latest
user: "10001:10001"
restart: unless-stopped
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
tmpfs:
- /tmp:rw,noexec,nosuid,size=16m,mode=1777
environment:
TZ: Europe/Warsaw
DRY_RUN: "true"
IBKR_TOKEN: ${IBKR_TOKEN:?Set IBKR_TOKEN}
IBKR_ACCOUNT_IDS: U1234567
IBKR_QUERY_IDS: 123456
GHOST_ACCESS_TOKEN: ${GHOST_ACCESS_TOKEN:?Set GHOST_ACCESS_TOKEN}
GHOST_HOST: http://ghostfolio:3333
GHOST_ACCOUNT_NAMES: "IBKR Individual"
MAPPING_FILE: /app/mapping.yaml
CRON: "0 6 * * *"
# Optional: Failure in any mode; expiry/TZ summaries in explicit live mode.
# APPRISE_URLS: '["ntfys://ntfy.sh/my-private-topic"]'
volumes:
- ./mapping.yaml:/app/mapping.yaml:ro
networks:
- ghostfolio
ibkr-sync-joint:
image: ghcr.io/flowcool/ghostfolio-ibkr-sync:latest
user: "10001:10001"
container_name: ghostfolio-ibkr-sync-joint
restart: unless-stopped
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
tmpfs:
- /tmp:rw,noexec,nosuid,size=16m,mode=1777
environment:
TZ: Europe/Warsaw
DRY_RUN: "true"
IBKR_TOKEN: ${IBKR_TOKEN:?Set IBKR_TOKEN}
IBKR_ACCOUNT_IDS: U7654321
IBKR_QUERY_IDS: 654321
GHOST_ACCESS_TOKEN: ${GHOST_ACCESS_TOKEN:?Set GHOST_ACCESS_TOKEN}
GHOST_HOST: http://ghostfolio:3333
GHOST_ACCOUNT_NAMES: "IBKR Joint"
MAPPING_FILE: /app/mapping.yaml
CRON: "5 6 * * *"
volumes:
- ./mapping.yaml:/app/mapping.yaml:ro
networks:
- ghostfolio
networks:
ghostfolio:
external: trueBoth services select simulation explicitly. Review each preview, then change
DRY_RUN to "false" in each service and recreate the containers to permit
writes. Keep valid TZ; other safety guards still apply.
Use http://ghostfolio:3333 (internal Docker network hostname) rather than an external IP or localhost. Stagger the cron times by a few minutes so the two containers do not run simultaneously.
Supply IBKR_TOKEN and GHOST_ACCESS_TOKEN through the environment used by
Compose, a private .env file beside the Compose file, or Portainer stack
environment variables. Do not put real credentials into the YAML. For a local
.env, restrict permissions to its owner (chmod 600 .env) and keep it out of
version control and shared backups/artifacts. Required-value interpolation
refuses startup configuration when either credential is missing or empty.
docker compose config can print resolved secrets: prefer config --quiet
for validation and never share resolved output. Environment values are not a
secret vault; administrators with Docker access can inspect them.
Both services share these variables when their credentials are the same. If
they differ, use separate required variables per service, for example
IBKR_TOKEN: ${IBKR_TOKEN_JOINT:?Set IBKR_TOKEN_JOINT} and
GHOST_ACCESS_TOKEN: ${GHOST_ACCESS_TOKEN_JOINT:?Set GHOST_ACCESS_TOKEN_JOINT}
for the joint account, and supply those values through the same private sources.
This example uses the recommended access-token mode. For legacy session-token
mode, replace the GHOST_ACCESS_TOKEN line in both services with
GHOST_TOKEN: ${GHOST_TOKEN:?Set GHOST_TOKEN} and do not set both modes.
The read-only root keeps application code immutable. The private runtime
crontab is generated under /tmp; its 16 MiB tmpfs is writable, non-executable
and ephemeral. The sync itself does not write persistent files. Optional
notification plugins may have their own storage requirements; verify your
configured notifier before adopting a read-only root filesystem.
Use a published image containing the /tmp crontab change: older images
writing /app/crontab cannot run this hardened example. Merging the runtime
change is not enough: wait for its image publication workflow to succeed, then
pull and recreate the container. :latest remains the example's choice and
follows the current stable release after the next-stable cutover; operators may
instead select an available full version or a recorded digest for controlled
upgrades. No deployment is changed automatically.
This is a standalone sync stack for an existing Ghostfolio instance. Replace the credentials, account names and network name before deploying. In Portainer, use an absolute host path for mapping.yaml and give it owner 10001:10001 with mode 0600 as described above. The mount is read-only. A scheduled container waits for its first cron tick; remove CRON for an immediate one-off run. A separate stack does not wait for Ghostfolio readiness.
To trigger one run without waiting for the cron tick, execute the cron command in the running
container. It uses the container's environment, so DRY_RUN behaves as configured (with DRY_RUN
true, nothing is written):
docker exec <container> python -m ghostfolio_ibkr_syncThis is the only supported way to run the sync by hand. The old
python /app/ibkr_to_ghostfolio.py command no longer works: v3.0.0 removed that file
without a compatibility launcher, so update any script or note that calls it.
See the troubleshooting guide for exit codes, import failures, missing positions, token expiry and login diagnostics. For interrupted operator maintenance, use the cleanup recovery guide.
- Flex XML size - each SendRequest or GetStatement response is limited to
10 MiB (10,485,760 response-body bytes) before XML parsing. Larger responses
fail with
IBKR Flex response exceeds XML size limit; no financial writes are made for that query. The limit includes the exact boundary and measures the body after HTTP decompression, not a Content-Length header. This is a parsing guard, not a streaming download or total-memory limit:requestsstill buffers the response before the check. Keep the 365-day recovery window; review unnecessary Flex fields/sections rather than shortening it just to fit the limit. - No options support - Ghostfolio does not support options as an asset class; options trades are skipped entirely
- 365-day window - IBKR Flex Query maximum period is 365 days. On the first run, trades older than that are not imported: enter older buys by hand (or keep them from a previous tool). Afterwards the daily sync accumulates history in Ghostfolio, and later sells of those positions are imported as long as Ghostfolio holds the buy.
- Yahoo Finance data quality - price data can have gaps, delays, or missing metadata (sector, country) for non-US ETFs and smaller listings
- Stock splits and other corporate actions are not handled - Ghostfolio keeps your transactions as they were, while Yahoo returns split-adjusted prices, so a split makes the valuation wrong until the old transactions are corrected by hand (multiply the quantity and divide the unit price by the ratio, and fix the stored market data). The Corporate Actions section of Flex reports is not parsed; it does not create split or merger activities.
- Cash balance is a single base-currency figure - the balance written to each Ghostfolio account is IBKR's total ending cash in the account's base currency, with no conversion. The Ghostfolio account must have the same currency as the IBKR base currency (see Create accounts); the sync refuses to write the balance otherwise.
- Daily data only - Activity Statements update once daily after market close; intraday syncing is not possible
- Token management - the IBKR Flex token expires and requires manual renewal: set a recurring calendar reminder (up to 1 year). On the Ghostfolio side,
GHOST_ACCESS_TOKENlogs in on every run; only the legacyGHOST_TOKENneeds manual renewal. - Account-scoped queries - use a separate Flex Query per sub-account. Several account/query pairs can run sequentially in one container; separate containers are recommended for operational isolation.
- Additive imports - later IBKR corrections to already-imported trades or same-date dividends are not reconciled. Dividend proximity matching (manual or legacy rows only, never rows with a
dividend#comment) can suppress a distinct payment within ±3 days; the skip is logged (INFOwhen the gross agrees, oneWARNINGotherwise) and the payment must then be added by hand. A row with adividend#comment is trusted by exact identity; editing such a row is outside the contract. Check unusual payment schedules and tax corrections manually. - Commission currency - commissions are booked in the activity currency; a different IBKR commission currency is not converted.
- Cleanup is not transactional - cleanup requires complete financial evidence, equal fees, a unique one-to-one pair and unchanged fresh copies of both rows. Draft or excluded candidates are refused. PUT can succeed while DELETE fails; stop and inspect both rows and the safety log before any recovery. A new apply writes a private YAML journal with exact preimages and known/unknown outcomes. Explicit journal recovery can finish an unchanged pair; it does not recreate deleted activities or repair changed records.
- Active position context - drafts and excluded activities/accounts retain their import identifiers for deduplication, but cannot contribute holdings, manual matches or dividend proximity evidence. Holdings and manual reconciliation use active YAHOO profiles; other sources cannot back YAHOO sells. Excluded target accounts are refused. Future timestamps are deferred with exit 1 until a later run; this conservative instant check avoids server-local draft assignment even when import responses omit tags. Accepted imports use the actual server profile symbol; a canonicalized symbol can require an explicit mapping before sells under the original ticker are allowed.
- Batch guard limitations - the gate checks net quantity, not chronological balances. Buy and sell phases are separate requests, so successful buys can remain if sells fail. A dry-run previews candidates without verifying server acceptance.
- No overlap protection - avoid concurrent sync runs targeting the same Ghostfolio account; deduplication is based on a snapshot read at startup.
The cleanup and recovery guide explains separately authorized maintenance, private journals, read-only inspection and guarded resume. Cleanup is not part of the additive sync; review its safety requirements first.
See the architecture decision records for the existing sync boundaries, financial trade-offs and reviewed DEGIRO core propagation.
The sync is the Python package ghostfolio_ibkr_sync (one module per concern, strictly typed application code), installed in the image and started with python -m ghostfolio_ibkr_sync; Docker is the only supported way to run it. It has requests and pyyaml as its only runtime dependencies; apprise (with a pinned dependency closure in requirements.txt) is imported only by the notification worker (--notify-worker). Tests are offline (no network, no Ghostfolio, no IBKR): they exercise the pure decision logic with mocked HTTP. scripts/check.sh runs Ruff, strict mypy and pytest.
python3 -m venv .venv
.venv/bin/pip install -r requirements-dev.txt # pytest stays out of the Docker image
.venv/bin/python -m pytest -q # or scripts/check.sh: Ruff, mypy, pytestCI runs tests, an audit of the complete Python CI environment and Docker builds/scans for amd64 and arm64 on every pull request to main, including documentation-only changes. Required checks must pass before merging; PR builds never publish images. CodeQL analyzes Python separately. See .github/BUILD.md for publication rules, weekly maintenance and rollback. A change to the sync logic should come with a test; use synthetic fixtures and mocked HTTP for regression checks. See Contributing for review and reporting guidance.
Every runtime dependency is pinned. Renovate proposes ordinary Python, GitHub Actions and image updates; Dependabot handles security update PRs. The daily dependency release watch opens one dependency-release issue per new stable Supercronic release. Adoption requires updating the version and both architecture checksums together in a reviewed PR. Python packages do not produce release notices. The separate Ghostfolio release watch opens compatibility notices. Both watches deduplicate against open and closed issues. See the upstream inventory for pins and monitoring paths.
Forked from obol89/ghostfolio-ibkr-sync, with thanks to its contributors. See LICENSE for the inherited MIT NON-AI License, including its additional restrictions.