Events mode
Instead of manually maintaining aggregated values, events mode lets you import
your transaction history. SuiviBourse reads a list of events
(BUY, SELL, GRANT, DIVIDEND) and automatically computes:
- Weighted average cost price
- Purchased and owned quantities
- Accumulated dividends
- Transaction fees
Events mode is also what unlocks historical backfill: because SuiviBourse knows when each transaction happened, it can reconstruct the value of your portfolio over time.
Setup
Create a settings.yaml file and an events/ directory:
~/.config/SuiviBourse/
├── settings.yaml
└── events/
├── 2023.csv
├── 2024.csv
└── broker-export.xlsx
mode: events
events:
source: ~/.config/SuiviBourse/events/
watch: true # Optional: reload immediately when files change
| Setting | Required | Description |
|---|---|---|
mode | Yes | Must be events |
events.source | Yes | Path to a single file or a directory of event files |
events.watch | No | When true, a file watcher reloads events immediately on change (default false) |
You can also select events mode with the SB_CONFIG_MODE=events environment
variable — it takes priority over settings.yaml. See
mode selection.
File format
Both CSV and XLSX files are supported. Every file must contain a header
row; only date and event_type are structurally required. Which of the other
columns apply depends on the event type (a share BUY needs symbol/name/
quantity/unit_price; a cash DEPOSIT needs account/amount).
- CSV
- XLSX
date,event_type,symbol,name,quantity,unit_price,fee,amount,notes
2024-01-15,BUY,AAPL,Apple Inc,10,150.00,2.50,,Initial purchase
2024-02-01,BUY,MSFT,Microsoft,5,380.00,2.50,,
2024-03-01,DIVIDEND,AAPL,Apple Inc,,,,8.50,Q1 2024
2024-06-01,GRANT,AAPL,Apple Inc,1,,,,Stock split bonus
2024-09-15,SELL,AAPL,Apple Inc,3,180.00,2.00,,Partial sale
XLSX files use the same columns as CSV. A few specifics:
- All worksheets in the workbook are read.
- The first row of each sheet is the header (column names are matched case-insensitively).
- Empty rows are skipped.
- Date cells may be real Excel dates or
YYYY-MM-DDtext.
Requires the openpyxl library, which is bundled in the Docker image.
Columns
| Column | Required | Description |
|---|---|---|
date | Yes | ISO format YYYY-MM-DD |
event_type | Yes | BUY, SELL, GRANT, DIVIDEND, DEPOSIT or WITHDRAWAL (case-insensitive) |
symbol | For share events | Yahoo! Finance ticker (e.g. AAPL, MSFT); forbidden on cash events |
name | For share events | Display name for the share; forbidden on cash events |
quantity | For BUY / SELL / GRANT | Number of shares |
unit_price | For BUY / SELL | Price per share |
fee | Optional | Transaction fee (must be >= 0) |
amount | For DIVIDEND / DEPOSIT / WITHDRAWAL | Amount received (dividend) or moved (cash) |
notes | Optional | Free-text comment (ignored by the app) |
account | For cash events, and for any event when accounts are declared | Account id this event belongs to |
date and event_type must always be present in the header. Empty cells are
allowed for the columns that don't apply to a given event type, and cash-only
files may omit the share columns entirely.
Event types
| Type | Required fields | Effect on the portfolio |
|---|---|---|
BUY | quantity > 0, unit_price > 0 | +purchased quantity, +owned quantity, recomputes the weighted average cost price, +fee, −cash |
SELL | quantity > 0, unit_price > 0 | −owned quantity, +fee, +cash (proceeds) |
GRANT | quantity > 0 | +owned quantity only (free shares); cash-neutral |
DIVIDEND | amount > 0 | +received dividends, +cash |
DEPOSIT | account, amount > 0 | +cash (external contribution) |
WITHDRAWAL | account, amount > 0 | −cash (external withdrawal) |
If any event fails validation, the whole ingestion is rejected and the previous valid configuration is kept — see Error resilience.
Aggregation rules
BUY — weighted average cost price
When you buy shares at different prices, SuiviBourse computes the weighted average cost price:
new_cost_price = (old_qty × old_price + new_qty × new_price) / total_qty
Example
- Buy 10 shares at $150 →
cost_price = 150 - Buy 5 shares at $175 →
cost_price = (10×150 + 5×175) / 15 =$158.33
A BUY increases both purchase.quantity and estate.quantity, and adds the
fee to the running total.
SELL — reduces owned quantity
- Decreases
estate.quantity(the quantity you currently own). - Does not change
purchase.quantityor the cost price. - The sale fee is added to the total fees.
- The sale
unit_priceis recorded on the event but realized gains are not tracked.
You cannot sell more shares than you currently own at that point in time. Doing so raises an aggregation error and the previous valid configuration is kept.
GRANT — free shares
- Only increases
estate.quantity. - Does not affect
purchase.quantityor the cost price. - Use it for stock splits, employee grants and bonus shares.
DIVIDEND — received dividends
- Increases
estate.received_dividendbyamount. - Also credits the account's cash by
amount − fee.
Cash events & the cash ledger
DEPOSIT and WITHDRAWAL track the money that enters and leaves an account.
Combined with the buy/sell/dividend flows, SuiviBourse keeps a cash balance per
account, so you can see how much you contributed, how much it is worth, and how
much is sitting idle as cash.
date,event_type,amount,fee,account,notes
2024-01-01,DEPOSIT,10000,,PEA,Initial funding
2024-06-01,WITHDRAWAL,500,1.5,PEA,Cash out
Rules
- Required:
date,account,amount(> 0 — the direction is carried by the event type, never by the sign).feeis optional (>= 0). - Forbidden on cash events:
symbol,name,quantity,unit_price— providing any of them is a validation error. - Cash is siloed per account: a transfer between accounts is expressed as a
WITHDRAWAL+DEPOSITpair.
The cash ledger starts at 0.00 per account. The fee always makes the cash
worse:
| Event | Effect on cash |
|---|---|
DEPOSIT | + (amount − fee) |
WITHDRAWAL | − (amount + fee) |
BUY | − (quantity × unit_price + fee) |
SELL | + (quantity × unit_price − fee) |
DIVIDEND | + (amount − fee) |
GRANT | none |
SELL now credits cashPreviously the proceeds of a sale were not tracked. From now on, a SELL
credits the account's cash with quantity × unit_price − fee.
Permissive policy — a negative cash balance is allowed: it only logs a
non-blocking warning. This is what keeps a user who adds accounts without
rewriting their whole DEPOSIT history running (they will see negative balances,
not an app that refuses to start). Selling more shares than you own stays
blocking, as before.
These values feed the Accounts dashboard (cash_balance, holdings_value,
total_value, net_contributed), written to the account_metrics measurement
only for opt-in users (accounts declared in settings.yaml).
Performance: XIRR & TWR
On top of the cash & value series, SuiviBourse computes money-weighted performance — answering "am I making money, given when I put it in?"
External vs internal flows — this is the whole definition of the metric:
- External (they are the contribution, they do not count as
performance):
DEPOSIT,WITHDRAWAL, andGRANT(in-kind, valued at the day's price). - Internal (they do count as performance):
BUY,SELL,DIVIDEND, and everyfee.
XIRR (xirr) — the annualized money-weighted rate of return, solved in
Python by bisection (no SQL, no new dependency). It needs at least one external
flow; an account without any external flow produces no xirr at all
(the field is absent, not 0).
TWR (twr_index) — the time-weighted return as a base-100 index, which
strips out the effect of when you contributed. Its base 100 re-anchors as
backfill fills earlier history.
Absolute gain (gain_absolu) — current value − contributions. It is the
guard for a young XIRR: over a few weeks an annualized rate gives wild
numbers, so the two are always shown side by side.
The global portfolio_totals series is written only when all accounts share
the same currency — pooling different currencies would need FX conversion,
which is out of scope. So the dashboard's global block is empty both when no
account is configured and when accounts use different currencies.
Accounts (opt-in)
By default every position is tracked in a single implicit account named
default. If you hold the same share across several accounts (e.g. a PEA
and a CTO, or several brokers), you can declare them as first-class accounts so
each position is tracked — and priced — separately.
This is an opt-in feature: it is enabled only by adding an accounts: block
to settings.yaml. Without it, nothing changes.
Declaring accounts
mode: events
events:
source: ~/.config/SuiviBourse/events/
accounts:
- id: PEA # referenced by the `account` column in your event files
type: PEA # free-form label (PEA, CTO, 401k, …)
currency: EUR
label: Mon PEA # optional display name (defaults to `id`)
- id: CTO
type: CTO
currency: EUR
| Field | Required | Description |
|---|---|---|
id | Yes | Unique identifier, referenced by the account column of your events |
type | Yes | Free-form account type (PEA, CTO, 401k, …) |
currency | Yes | Account currency (EUR, USD, …) |
label | No | Display name (defaults to id) |
The account column
Once accounts are declared, every event line must carry an account column
whose value matches a declared id:
date,event_type,symbol,name,quantity,unit_price,fee,amount,notes,account
2024-01-15,BUY,AAPL,Apple Inc,10,150.00,2.50,,,PEA
2024-01-16,BUY,AAPL,Apple Inc,5,160.00,1.00,,,CTO
- A line without an
accountvalue is a validation error. - An
accountvalue that does not match any declaredidis a validation error. - With the example above, AAPL is tracked as two independent positions — 10 shares in the PEA and 5 in the CTO — each with its own weighted average cost price.
The account column is simply ignored and every position falls under the
implicit default account. Manual mode is unaffected as well.
How accounts appear downstream
- InfluxDB — every point of
portfolio_metricswritten from v4.1 onwards carries anaccounttag (defaultwhen accounts aren't declared). Points written before the upgrade have no tag — see Upgrading to accounts. - Prometheus — the
sb_*gauges gain anaccountlabel, so the same symbol held in two accounts no longer overwrites itself. - Grafana (dashboard 1) — the "patrimoine total" dashboard aggregates
across all accounts:
Owned quantity,Total Investment, etc. sum the accounts, andCost priceis their quantity-weighted average. It stays a single "total wealth" view and does not add an account selector.
Points written before this feature existed have no account tag; InfluxDB
exposes them as NULL. The dashboard queries read them with
COALESCE(account, 'default'), so your historical data stays visible and is
aggregated correctly. No migration or history rewrite is performed.
Key behaviours
Event ordering
Events are sorted by date before processing, regardless of:
- their order within a file, and
- which file they come from.
You can add past events at any time — they are processed in the correct chronological order.
Multi-file support
All .csv and .xlsx files in the events directory are loaded and merged.
Organize them however you like:
- by year (
2023.csv,2024.csv) - by broker (
degiro.csv,interactive-brokers.xlsx) - by account (
pea.csv,cto.csv)
Caching
Ingestion uses each file's modification time (mtime) to detect changes:
- If no file changed → the cached, already-aggregated data is reused.
- If any file changed → a full reload and re-aggregation happens.
Error resilience
If ingestion fails (invalid data, file error, overselling, …):
- the previous valid configuration is kept,
- price scraping keeps running normally, and
- the error is logged for debugging.
Fix your event files at your own pace — the app won't crash and will pick up
your corrections on the next ingestion cycle (or immediately if watch: true).