Skip to content

Portfolios

A portfolio represents a multi-asset deal — typically a collection of properties grouped under one transaction, sliced by market sector and party. The defining rule of a portfolio is the market-sector breakdown: every portfolio must declare at least one sector, and the shares across all sectors must add up to exactly 100%. The edit form keeps this invariant automatically as you add, remove, or change rows, so you never have to balance the total by hand.

Save is only enabled once you’ve changed something.

FieldRequired whenWhat you see if missing
Portfolio nameAlways”Portfolio name is required.”
At least one market sectorAlways”At least one market sector is required.”

A new portfolio opens with a default first sector pre-populated at 100%.

The form rejects names longer than 255 characters with “Portfolio name must be 255 characters or fewer.”

The Yield (%) field accepts 0 or greater. Negatives are rejected. (Unlike Investment Events, there’s no upper bound on portfolio yield in the validation rules.)

Property count must be a non-negative whole number

Section titled “Property count must be a non-negative whole number”

The Property Count field rejects fractional values and negatives.

In the breakdown table, each sector’s Share column accepts 0–100% inclusive. The form-level rebalance keeps these between 0 and 1 automatically as you edit — the bounds matter mainly for direct API calls.

Where this lives: src/services/web/src/lib/validation/portfolio.ts.

Confidential portfolios redact price and yield in Excel exports

Section titled “Confidential portfolios redact price and yield in Excel exports”

Ticking Is Confidential? marks the portfolio as commercially sensitive. In the app itself, everything still displays as normal — the field exists only to gate the Excel export.

When a confidential portfolio is included in an Excel export of the Portfolio list, the following cells are written as blank instead of their numeric value:

  • Yield (%) — Yield
  • Price — EUR, GBP, USD (PriceEUR, PriceGBP, PriceUSD)

A blank cell (rather than a placeholder like “Confidential”) keeps the column numeric — Excel formulas such as SUM and AVERAGE over the column continue to work, simply skipping the redacted rows. Every other column (name, investors, vendors, building count, sectors, markets, countries, dates, etc.) exports as usual. The row is not removed from the export; only the three price columns and the yield column are redacted.

A Confidential badge appears on the portfolio’s card in the detail view so the flag is visible at a glance. The list page has an optional Is Confidential column (off by default — turn it on from the column picker) so you can filter to find them.

This rule mirrors the equivalent behaviour on Investment Events — both entity types share the same ConfidentialRedactedFields allow-list on the export service.

Where this lives: backend src/services/api/app/app.api/Services/Export/ExcelExportService.cs (the ConfidentialRedactedFields set), badge at src/services/web/src/lib/components/badges/BadgeConfidential.svelte.

Across all sectors on the portfolio, the Share column must add up to exactly 100% (within a tiny floating-point tolerance). The form’s auto-rebalance keeps the sum at 100% on every change; the server re-checks the rule on save with the message “Market sector shares must add up to exactly 100%.”

Where this lives: src/services/web/src/lib/services/portfolio-breakdown.svelte.ts (frontend rebalance) and src/services/api/app/app.api/Handlers/Commands/Portfolios/PortfolioBreakdownValidator.cs (server check).

Sector sizes can’t exceed the portfolio size

Section titled “Sector sizes can’t exceed the portfolio size”

If you’ve set a total Portfolio Size, the sum of the individual sector sizes must not exceed it. Comparison is done in canonical square metres so a sector in one unit and a portfolio in another still compare correctly. If the rule fires you’ll see “Sum of market sector sizes (X sqm) exceeds the portfolio’s total size (Y sqm).”

Where this lives: src/services/api/app/app.api/Handlers/Commands/Portfolios/PortfolioBreakdownValidator.cs.

Market dropdown is scoped to the picked Sector

Section titled “Market dropdown is scoped to the picked Sector”

On a sector-market row, the Market dropdown only lists markets that exist within the picked Sector. Sectors map to MarketTypes by name — pick Industrial and only Industrial markets appear; pick Retail and only retail markets do. Sub-sectors (e.g. Logistics under Industrial) inherit their parent sector’s market list, so picking Logistics shows every market with an Industrial boundary.

Fallback for sectors with no dedicated MarketType. A handful of sectors — currently Hotels, Health Care, Other — have no matching MarketType row, so they would otherwise return an empty Market list. In that case the dropdown falls back to the Common MarketType (the seed’s catch-all bucket) so the user still has something to pick from.

Changing the Sector to one with no overlap clears the previously-picked Market — same shape as the existing Country → Market reset. A Market that’s valid under both Sectors stays selected across the change.

Where this lives: filter helpers at src/services/web/src/lib/services/portfolio-sector-markets.ts; market enum projects Metadata.MarketTypeNames in EnumService.GetMarketsAsync so the filter runs client-side without a per-sector refetch.

Investor shares appear only under Share ownership

Section titled “Investor shares appear only under Share ownership”

The Share % column on the Investor list inside Parties is hidden unless the portfolio’s Ownership type is Share. Under Whole or Joint ownership the company list shows roles only, no per-investor percentages.

(This differs from Investment Events, where Investor shares are visible whenever the role has any entry.)

Where this lives: src/services/web/src/lib/components/panels/PortfolioPartiesEdit.svelte.

Conditional UI — the breakdown auto-rebalance

Section titled “Conditional UI — the breakdown auto-rebalance”

The market-sector breakdown is kept at 100% automatically. Three rules govern what happens when you change the table:

The row you changed gets the new value (clamped to 0–100%). Every other row scales proportionally so the remaining (100 − new share)% is split between them in the same ratio they held before. Any tiny rounding residual is absorbed by the largest non-edited row so the correction is least visible.

Example: rows at 40 / 30 / 30. Change the first to 60. The other two scale proportionally so they together hold 40%: each becomes 20%.

If every other row was at 0 (an edge case that the rule itself prevents from arising), the remainder is split evenly across them.

A newly added sector gets 1 / N where N is the new total row count. Every existing row scales down by (N − 1) / N to make room. The residual goes into the new row so existing rows end up at recognisable scaled figures.

Example: rows at 40 / 30 / 30 (3 rows = 100). Add a fourth row. Scale = 3/4. Existing rows become 30 / 22.5 / 22.5, new row becomes 25. Sum = 100.

The removed row’s share is split between the survivors in the ratio they held before. The residual is absorbed by the largest surviving row.

Example: rows at 50 / 30 / 20. Delete the middle row. The other two split the freed 30 in the ratio 50:20 (= 5:2). The first becomes 50 + 30 × (5/7) ≈ 71.43; the third becomes 20 + 30 × (2/7) ≈ 28.57. Sum = 100.

If every survivor was at 0, the remainder is split evenly.

When only one sector remains, its share is forced to 100%. You can change the sector’s other fields (size, type) but not its share — it’s pinned.

Portfolio name and at least one market sector must be present.

The server re-validates the breakdown rules from scratch:

  • At least one sector present.
  • Every sector share in 0–100%.
  • Shares sum to exactly 100% (±1e-9).
  • If the portfolio has a total size, sector sizes (in sqm) don’t exceed it.

The rules run on Create, Patch, and Replace — even if a caller bypasses the UI auto-rebalance and tries to send malformed shares directly, the server stops the save.

The save carries the RowVersion of the record you loaded. If another user has updated the same portfolio since, you get a concurrency error. See Concurrent edits.

  • A PortfolioCreated or PortfolioUpdated event fires; downstream services pick it up to recalculate the completeness score (eventually, via the nightly job).
  • The portfolio’s row in the PortfolioList query view is upserted.
  • An audit log entry records who saved what.
  • Investment Events — single-event transactions; portfolios are the multi-asset cousin. The Share % rule on investors differs (always visible there, gated on Share ownership here).
  • Schemes — the schemes that make up a portfolio.
  • Companies — the companies recorded as investors, vendors, and other roles on a portfolio.
  • Unknown and optional fields — unknown investors and vendors.
  • Units and display — net vs gross, how sizes are presented.
  • Currency — how Purchase Price displays in your preferred currency.
  • Frontend validation: src/services/web/src/lib/validation/portfolio.ts
  • Breakdown auto-rebalance: src/services/web/src/lib/services/portfolio-breakdown.svelte.ts
  • Edit forms: src/services/web/src/lib/components/panels/PortfolioEdit.svelte, PortfolioMarketSectorsEdit.svelte, PortfolioPartiesEdit.svelte
  • Model: src/common/models/Models/Portfolio.cs and related PortfolioMarketSector.cs, PortfolioCompany.cs, PortfolioScheme.cs
  • Server-side breakdown validator: src/services/api/app/app.api/Handlers/Commands/Portfolios/PortfolioBreakdownValidator.cs
  • Command handlers: src/services/api/app/app.api/Handlers/Commands/Portfolios/
  • List-view mapper: src/common/models/Services/QueryViews/PortfolioListViewMapper.cs