docs/features/performance-semantics-design.md
origin/main, and current code. Examples below use synthetic account names
and rounded fixture values.This is not one isolated calculation bug. It is a semantics gap between the product model, the stored data model, and the dashboard presentation model.
The current code has two visible tracking modes: transaction-derived holdings and manually entered/imported holdings. That split is reasonable. The problem is that the two modes do not have separate enough economics:
Today, external security transfers and holdings snapshots reuse fields whose meanings overlap:
unit_price can mean trade price, transfer cost basis, or fallback quote.amount can mean cash amount, total market value, broker net amount, or, for
non-buy/sell activities, authoritative lot cost.net_contribution_base is used as a performance fallback, but holdings
snapshots often persist it as zero even when the user supplied
quantity * average cost and cash.external_inflow_base and external_outflow_base exist on valuation rows,
but their source does not encode whether the amount was a true cash flow, a
security fair market value, or a degraded fallback.The result is predictable:
amount is present,
and if amount is present it can corrupt lot cost basis.The target design is to keep the existing modes, but make their economics explicit without adding derived source columns:
cost_basis + cash.This also means external TRANSFER_IN and TRANSFER_OUT should be handled
differently for transaction-mode performance, not as a new transaction type, but
through a richer economics compiler and finalizer.
I compared the released v3.5.2 code path with origin/main. origin/main
contains backend current-valuation work, but I did not find a fix in the
historical performance, holdings snapshot, transfer, or holdings percentage
semantics paths described here. The relevant main changes for the issue symptoms
are mostly dashboard/current-valuation presentation work, not a historical
performance correction.
Implication: fixing issue #1119 requires domain and data-contract changes, not just a rebuild or frontend patch.
The latest user comment on issue #1119 narrows the problem:
+42.49%% was on the main dashboard and later disappeared.Local reproduction data shows the same pattern at larger scale:
Synthetic example from latest valuations:
| Account | Mode | Latest value | Net contribution | Implied P&L |
|---|---|---|---|---|
| Holdings Account A | HOLDINGS | 100,000.00 | 0.00 | 100,000.00 |
| Holdings Account B | HOLDINGS | 85,000.00 | 0.00 | 85,000.00 |
| Holdings Account C | HOLDINGS | 75,000.00 | 0.00 | 75,000.00 |
| Transaction Account D | TRANSACTIONS | 140,000.00 | 95,000.00 | 45,000.00 |
| Transaction Account E | TRANSACTIONS | 60,000.00 | 40,000.00 | 20,000.00 |
Synthetic example from each account's first valuation row:
| Account | Mode | First date | First value | Net contribution | External inflow | Source |
|---|---|---|---|---|---|---|
| Holdings Account A | HOLDINGS | 2025-01-01 | 100,000.00 | 0.00 | 0.00 | ACTIVITY_DERIVED |
| Holdings Account B | HOLDINGS | 2025-03-01 | 85,000.00 | 0.00 | 0.00 | ACTIVITY_DERIVED |
| Holdings Account C | HOLDINGS | 2025-06-01 | 75,000.00 | 0.00 | 0.00 | ACTIVITY_DERIVED |
| Transaction Account D | TRANSACTIONS | 2024-01-01 | 50,000.00 | 50,000.00 | 0.00 | ACTIVITY_DERIVED |
| Transaction Account E | TRANSACTIONS | 2024-06-01 | 25,000.00 | 25,000.00 | 0.00 | ACTIVITY_DERIVED |
Those rows are enough to create misleading all-time returns. The current valuation history treats holdings value as gain versus a zero book basis.
Key current contracts:
crates/core/src/accounts/accounts_model.rs defines only Transactions,
Holdings, and NotSet.crates/core/src/activities/activities_model.rs stores activity economics as
quantity, unit_price, amount, fee, currency, fx_rate, and
metadata. There is no distinct transfer market value or performance flow
value field.crates/core/src/portfolio/valuation/valuation_model.rs has valuation-level
external_inflow_base, external_outflow_base, and external_flow_source,
but no granular flow quality or source amount semantics.Root code paths:
crates/core/src/portfolio/snapshot/manual_snapshot_service.rs creates manual
snapshots with real positions and cost basis, but net_contribution,
net_contribution_base, and cash totals are set to zero.crates/core/src/portfolio/valuation/valuation_service.rs derives activity
flow amount as amount or quantity * unit_price. The first valuation row in
a history slice is forced to zero inflow/outflow.crates/core/src/portfolio/performance/flow_classifier.rs treats transfers as
portfolio-external only when metadata.flow.is_external is true, or as
account-scope external when crossing the selected account boundary. That
classification is directionally right.crates/core/src/portfolio/snapshot/holdings_calculator.rs uses amount as
authoritative for non-buy/sell activities when present. For external security
transfers, this means an imported market value in amount can become lot cost
basis.crates/core/src/portfolio/performance/performance_service.rs uses TWR for
transaction-only scopes, value return for holdings-only scopes, and value
return for mixed scopes. That high-level policy is directionally right, but
polluted holdings basis makes the output wrong.apps/frontend/src/pages/activity/components/forms/transfer-form.tsx asks for
Cost Basis on external security transfer-in. It does not ask for
transfer-date market value separately.apps/frontend/src/pages/activity/import/hooks/use-import-mapping.ts maps
market value aliases to amount, which collides with the backend behavior
above.packages/ui/src/components/financial/gain-percent.tsx can render %% during
the animated fallback because the fallback calls formatPercent(...) and the
parent appends another %.apps/frontend/src/pages/dashboard/accounts-summary.tsx renders account-row
gain amounts with showSign={false}, so a red negative amount can appear
without a minus sign beside a signed percent.crates/core/src/portfolio/holdings/holdings_service.rs and
crates/core/src/portfolio/holdings/holdings_valuation_service.rs fabricate
100% when a percentage numerator is nonzero and the basis is zero or
unavailable. That hides missing basis data instead of exposing it.The market standard is not "one return formula everywhere". Mature portfolio tools split current holdings reporting from transaction/cash-flow performance.
Snowball Analytics documents this split directly. Its Holdings portfolio lets users enter current positions and average cost, then provides current balance, unrealized P&L, allocation, dividends, fundamentals, and analytics. Its Transactions portfolio is the "best and most powerful" mode for performance history, portfolio value history, IRR, realized P&L, fees, and transaction-based benchmarking. See https://help.snowball-analytics.com/holdings-vs-transactions/.
Quicken uses placeholder entries when holdings exist but transaction history is missing. Its docs say placeholders can track holdings-only information, but full performance reporting and tax planning require actual historical transactions. It also supports estimated average cost for limited reporting. See https://info.quicken.com/win/how-do-i-decide-how-to-resolve-placeholder-entries.
Portfolio Performance does not treat unexplained snapshots as full performance history. It models securities entering/leaving without a cash account as explicit Delivery In/Out transactions. At portfolio level, deposit, withdrawal, delivery in, and delivery out are the external flows for TWR. See:
Sharesight supports opening balances with quantity and cost base, then uses its money-weighted performance methodology for portfolios with cash-flow timing. Its historical cost reporting separately shows cost base and market value columns. See:
GIPS guidance remains the benchmark for transaction-mode performance: time-weighted returns adjust for external flows, and external flows can be cash or investments entering/exiting a portfolio. See https://www.gipsstandards.org/wp-content/uploads/2021/03/calculation_methodology_gs_2006.pdf.
The common benchmark is:
Manual and imported holdings snapshots represent current portfolio state. The user supplies quantity and average cost, and may also supply cash balances. That should produce a holdings-mode book basis:
invested capital = sum(quantity * average cost) + cash
unrealized P&L = current market value - invested capital
gain versus cost = unrealized P&L / invested capital
Today, manual snapshots can preserve position cost basis but still persist
net_contribution and net_contribution_base as zero. That makes holdings
accounts look like pure gains versus a zero basis.
The fix is not to infer buys, sells, dividends, or transfers from snapshots, and not to add another persisted source column for book basis. The fix is to derive holdings book basis from existing stored source facts:
holdings_book_basis = stored position cost basis + stored cash
Holdings mode should then be a cost-basis/current-state reporting mode.
For users who do not manage cash, external TRANSFER_IN is the right mechanism
conceptually. It represents an in-kind delivery into the tracked portfolio.
What is missing is a separate performance flow value:
amount; defer preservation until a typed field or
import contract exists.Current code usually uses quantity * unit_price when amount is absent.
Because the transfer form labels unit_price as cost basis, performance
currently uses cost basis as market flow value. If CSV import supplies amount
as market value, holdings_calculator.rs may instead use that amount as lot
cost basis.
valuation_service.rs forces the first row in a valuation slice to zero
external flow. That is acceptable only when the row already represents the
beginning of the selected performance universe. It is wrong when
transaction-mode accounts first appear later because of a real external flow,
and it is misleading when holdings-mode accounts are folded into a portfolio
headline without method labels.
Late-entering transaction accounts need explicit flows. Late-entering holdings accounts need book-basis reporting, not transaction-performance inference.
The mixed dashboard must not silently combine transaction TWR semantics with holdings gain-versus-cost semantics.
The dashboard amount is computed from attribution P&L, while the percent is selected from TWR or value return. For flow-heavy periods, a positive TWR and negative dollar P&L can both be mathematically possible. But when displayed without labels as a pair, users read it as inconsistent.
Account cards also hide the amount sign while preserving red/green color, which makes the inconsistency look worse.
Holdings percentages currently fall back to 100% when the basis is zero and
the numerator is nonzero. That is not a neutral fallback; it creates a false
return.
The correct behavior is:
0% or N/A based on
context;N/A with a
missing-basis reason.The implementation should also test FX consistency. A percentage where average
cost and current price are nearly equal should not become a large gain/loss
solely because cost basis and market value were converted with incompatible FX
dates. This is plausible from the reported price_return symptom, but it needs
a targeted fixture before being stated as confirmed root cause.
%% Screenshot Is A Frontend Formatting BugThe dashboard uses animated GainPercent. While the dynamic number component is
loading, AnimatedNumber returns formatPercent(absValue), which already
includes %; the parent then appends another %.
This explains a transient +42.49%% on the dashboard.
The residual warning is emitted when attribution components do not reconcile with total value delta. That can happen when flows, book basis, unrealized P&L, and snapshot state are not semantically aligned.
The warning should not just say "review Health Center". It should identify the top dates/accounts/activities causing the residual.
Keep two primary modes:
Transaction mode
Holdings mode
Add product language for user intent:
quantity * average cost + cash.This avoids adding a third account mode while making transfer-only transaction accounts legitimate.
Introduce an internal EconomicEventCompiler for transaction-mode activities
and transfers. It compiles persisted activities into typed internal economic
events without adding persisted activity types:
CashFlowExternalSecurityDeliveryInExternalSecurityDeliveryOutInternalSecurityTransferTradeIncomeFeeTaxUnknownBoundaryTransferIn this branch the compiler is computed from existing activity columns, transfer pair resolution, transfer-date quotes, lot-engine feedback, and FX only. There is no source-schema migration and no new metadata contract.
Holdings snapshots should use a separate HoldingsSnapshotEconomics
interpretation. They are not activity history and should not be converted into
guessed buys/sells/transfers.
The first target implementation does not require a new holdings snapshot schema field or a transfer economics table. The database should store source facts; the compiler should produce calculated economics.
Existing source facts are enough for holdings book basis:
snapshot_positions.quantitysnapshot_positions.average_costsnapshot_positions.total_cost_basisholdings_snapshots.cash_balancesholdings_snapshots.cash_total_account_currencyholdings_snapshots.cash_total_base_currencyExisting source facts are enough for normal listed security transfers:
activities.quantityactivities.unit_price as transfer cost basis per unitThe compiler output should be typed even if storage remains unchanged:
CompiledActivityEconomics {
event_kind,
lot_cost_basis_value,
lot_cost_basis_currency,
performance_flow_value,
performance_flow_currency,
performance_flow_source,
basis_status,
diagnostics
}
Derived values can be materialized later in daily_account_valuation or a
debug/audit table if needed, but that is read-model caching, not source schema.
Suggested conceptual fields:
| Concept | Meaning | Source today | Target source |
|---|---|---|---|
quantity | Units moved/held | activities.quantity | unchanged |
cost_basis_per_unit | Lot/tax/book price | unit_price for external transfer-in | explicit form/import field |
cost_basis_total | Book basis for lot | quantity * unit_price or amount today | compiler-derived from cost basis |
market_value_total | Fair market value on transfer date | usually absent | quote-derived by default |
performance_flow_value | External boundary flow used for returns | amount or quantity * unit_price today | market_value_total with source quality |
cash_flow_amount | Cash movement | amount for cash activities | unchanged |
performance_flow_source | Exact producer/degraded fallback source | absent | compiler-owned enum |
Suggested holdings snapshot fields:
| Concept | Meaning | Source today | Target source |
|---|---|---|---|
snapshot_quantity | Units held on snapshot date | snapshot positions | unchanged |
average_cost_per_unit | User-entered average/book cost | import/form average cost | unchanged |
position_cost_basis | quantity * average cost | snapshot position total cost basis | unchanged source fact |
cash_balance | User-entered cash state | snapshot cash balances | unchanged source fact |
invested_capital | sum(position_cost_basis) + cash | currently often zero in net_contribution | compiler-derived book basis |
market_value | quantity * quote + cash | valuation calculation | unchanged |
unrealized_pnl | market_value - invested_capital | derived inconsistently when basis is zero | derived from compiler book basis |
gain_vs_cost | unrealized_pnl / invested_capital | unavailable/misleading when basis is zero | derived headline metric |
Compiler-owned flow value sources:
CASH_AMOUNTQUOTE_DERIVED_MARKET_VALUECOST_BASIS_FALLBACKREMOVED_LOT_BASIS_FALLBACKLEGACY_ACTIVITY_AMOUNT_FALLBACKUNKNOWN_BOUNDARY_TRANSFERUNKNOWNCompatibility values such as ACTIVITY_DERIVED, STORED_GROSS,
NET_CONTRIBUTION_FALLBACK, and aggregate MIXED can remain readable for old
rows or aggregate views, but they are not valid new producer sources for
compiled activity economics.
Use a two-stage pipeline:
The finalizer should produce a period event-effect ledger:
EconomicEventEffect {
activity_id,
account_id,
asset_id,
date,
event_kind,
external_flow,
realized_pnl,
unrealized_movement,
income,
fee,
tax,
fx_effect,
diagnostics
}
Performance attribution must consume this ledger. Legacy *_best_effort
attribution passes can remain only as temporary compatibility code while the
ledger is rolled out.
Required invariant for every performance period:
value_delta = external_flows + event_effects + unreconciled_diagnostic_delta
The unreconciled delta is a diagnostic for bugs or incomplete data. It must not be injected as a normal display P&L component.
For a security TRANSFER_IN marked external:
cost_basis_per_unit or transferred lot details.For a security TRANSFER_OUT marked external:
activity_id. Do
not reconstruct it from daily net_contribution_base deltas.For a paired transfer:
For every holdings-mode snapshot:
sum(quantity * average cost) + cash in
the compiler/read model.10 to
200, do not guess whether that was a buy, transfer, dividend reinvestment,
split, or correction.Manual/custom assets need an explicit policy so they do not silently become zero-value positions:
The backend should return a display-ready headline contract, not force frontend helpers to infer semantics.
Suggested headline fields:
amountpercentmethod: TWR, IRR, VALUE_RETURN, VALUE_CHANGE, GAIN_VS_COST,
P_AND_L, NOT_APPLICABLEbasis: MARKET_VALUE, COST_BASIS, INVESTED_CAPITAL, MIXEDquality: COMPLETE, ESTIMATED, DEGRADED, UNAVAILABLEbasis_status: COMPLETE, PARTIAL_UNKNOWN, UNKNOWN, NOT_APPLICABLEcomponent_coverage: amount/percent completeness plus per-component inclusion
flagsreasons: display-only explanatory stringsFrontend helpers must consume typed fields. They must not parse reasons or
warning text to infer return method, basis, quality, or percent availability.
Display rule:
GAIN_VS_COST:
market value - invested capital, divided by invested capital.N/A.N/A with a useful reason instead of a huge percent.Dashboard and account-summary headlines must branch by scope composition before calculating returns:
| Scope composition | Headline amount | Headline percent | TWR/IRR |
|---|---|---|---|
| Transaction-only | Transaction attribution P&L | TWR by default, or selected transaction return | Available when valid |
| Holdings-only | Bounded: value change; all-time: gain versus book basis | Same numerator divided by starting value/book basis | Not first-class |
| Mixed | Sum of account-level transaction P&L and holdings gain | Only when component coverage is coherent; otherwise N/A | Unavailable for combined scope |
Transaction-only scopes can keep the existing transaction performance path.
Holdings-only scopes can keep the current holdings value-return path. Mixed
scopes need a separate backend aggregator; they must not derive external flows
from aggregate net_contribution_base deltas.
Mixed-scope bounded-period algorithm:
for each account in scope:
if account.tracking_mode == TRANSACTIONS:
amount_i = transaction attribution P&L for the period
denominator_i = starting total value, when positive
if account.tracking_mode == HOLDINGS:
amount_i = ending total value - starting total value
denominator_i = starting total value, when positive
headline_amount = sum(amount_i)
headline_percent = headline_amount / sum(denominator_i)
method = MIXED_VALUE_RETURN
quality = DEGRADED if any account-level component is degraded/unavailable
Mixed-scope all-time algorithm:
for each account in scope:
if account.tracking_mode == TRANSACTIONS:
amount_i = lifetime transaction attribution P&L
denominator_i = earliest positive total value or explicit transaction basis
if account.tracking_mode == HOLDINGS:
amount_i = ending total value - ending book basis
denominator_i = ending book basis, when positive
headline_amount = sum(amount_i)
headline_percent = headline_amount / sum(denominator_i)
method = MIXED_VALUE_RETURN
The denominator contract should be explicit in the backend response. Do not let the frontend infer it from the displayed amount, account mode, or group shape.
The architecture is complete only when these criteria are all true:
EconomicEventCompiler before lots, valuation, performance, diagnostics, or
UI semantics consume it.net_contribution_base deltas.CASH_AMOUNT, QUOTE_DERIVED_MARKET_VALUE, COST_BASIS_FALLBACK,
REMOVED_LOT_BASIS_FALLBACK, LEGACY_ACTIVITY_AMOUNT_FALLBACK,
UNKNOWN_BOUNDARY_TRANSFER, or UNKNOWN.N/A.The plan is split deliberately. Issue #1119 should not wait for a full performance architecture project. Ship the surgical fix first, then continue with the compiler/finalizer transfer and delivery semantics work.
Create minimal test fixtures before changing behavior:
Acceptance:
%%.100% percentage fallback when basis is zero.This is the short-term PR. It should be small, low-risk, and independent of the deeper compiler/finalizer and import-contract work.
GainPercent animated fallback so it does not append a second %.100% percentage fallbacks with
unavailable/missing-basis semantics.Acceptance:
%% can render while @number-flow/react is loading.-0, -0.00, and -0.00% render as 0, 0.00, or 0.00%.100 shares, avg_cost = 50, quote 200, and no
cash reports invested capital 5,000, market value 20,000, P&L 15,000,
gain versus cost 300%.N/A for gain versus cost, not hard
100%.Branch: feature/fix-1119-performance-semantics
Scope: calculation/read-model architecture for #1119 without changing storage schema or activity metadata. Richer dashboard method labels and typed explicit CSV market-value preservation remain follow-up phases.
| Item | Status |
|---|---|
Fix animated percent fallback double % | Implemented |
| Preserve negative sign on dashboard account gain amounts | Implemented |
| Normalize display-level negative zero for gain amount/percent | Implemented |
| Feed non-calculated holdings snapshots from existing cost basis plus cash when contribution is zero | Implemented |
Replace hard 100% zero-basis holdings percentage fallbacks with unavailable percentages | Implemented |
| Use holdings book basis for all-time gain-vs-cost and value change for bounded periods | Implemented |
| Add focused tests for manual holdings basis, zero-basis percentage, and dashboard amount signs | Implemented |
| Add FX-base consistency fixture | Implemented |
| Add quote-derived external security transfer flow compiler | Implemented |
Prefer transfer cost basis over generic amount when quotes are missing | Implemented |
Prevent legacy transfer amount from overriding cost basis when quantity * unit_price is present | Implemented |
Stop auto-mapping CSV market value into generic amount | Implemented |
| Fix mixed-scope dashboard/account-group headline aggregation | Implemented with account-level component aggregation |
| Suppress mixed-scope combined percent when component coverage is incoherent | Implemented with split components and N/A combined percent |
| Enrich mixed-scope transaction component attribution before aggregation | Implemented for service path |
| Use first positive transaction value as all-time mixed denominator | Implemented |
| Avoid showing zero P&L when all-time holdings book basis is unavailable | Implemented with explicit P&L unavailable reason |
| Treat partial missing holdings book basis as all-time holdings headline unavailable | Implemented |
| Skip invalid negative components in mixed scopes instead of failing the whole scope | Implemented with degraded data-quality warnings |
| Build mixed bounded return series from account-level component timelines | Implemented |
| Add explicit frontend return method labels | Deferred until the mixed-scope headline contract is explicit |
| Persist explicit import/provider transfer market value | Deferred; requires a typed field/contract, not metadata or amount |
This architecture is implemented for transfer performance in the current branch. It remains intentionally storage-neutral.
Add a centralized compiler in core, then migrate transaction/transfer callers onto it:
Activity, transfer boundary, transfer-date quote lookup, lot-engine
feedback, FX service, account currency, base currency, valuation date.Critical behavior changes:
quantity + unit_price + amount must not let amount
silently override lot cost basis.amount on a security transfer must not override quantity * unit_price when
cost basis exists. It is only a legacy fallback when both quote and cost basis
are missing.Acceptance:
10, transfer-date quote 12,
current quote 15 reports lot cost basis from 10 and performance inflow
from 12.net_contribution_base delta guess.This phase depends on compiler semantics being explicit enough to separate cost basis from transfer-date market value without burdening the normal form.
Transfer form:
Quantity.Cost basis per share/unit.CSV import:
amount for cash activities and cash transfers.market value into generic amount.unitPrice/cost-basis import behavior for security transfers.amount or metadata.amount still import,
but security-transfer performance ignores it when quantity * unit_price
already supplies cost basis.Bulk holdings / holdings snapshots:
quantity, average_cost, cost_basis = quantity * average_cost,
and cash.Acceptance:
market value column
is left unmapped instead of becoming generic amount.Change snapshot-to-valuation semantics:
quantity * average cost.book_basis source column. Derive holdings
book basis from existing position cost basis plus cash.net_contribution in the
domain model. If a compatibility API needs a value in that slot temporarily,
label it as transitional read-model behavior, not storage semantics.book_basis or invested_capital
for holdings-mode display.Short-term implementation can compute this in the compiler without changing the database. Long-term implementation should still prefer typed read-model fields over mixing holdings book basis with transaction net contribution.
Acceptance:
100 shares, avg_cost = 50, quote 200, and no
cash reports invested capital 5,000, market value 20,000, P&L 15,000,
gain versus cost 300%.10,000 contributes to invested capital and market
value, not to fake P&L.200 shares recomputes invested capital
from the new quantity and average cost; it does not create hidden
buys/transfers.N/A for gain versus cost, not an infinite
return.Fix the dashboard and grouped account summaries before relying on a full
rebuild. The current mixed-scope path aggregates account valuation rows first,
then derives external flows from aggregate net_contribution_base deltas. That
is wrong when holdings-mode book basis appears in the history, because the book
basis is not a period contribution.
Implementation:
TRANSACTION_ONLYHOLDINGS_ONLYMIXEDnet_contribution_base deltas as mixed-scope external
flows.MIXED_VALUE_RETURN in the
display contract.Recommended denominator contract:
| Component type | Bounded-period denominator | All-time denominator |
|---|---|---|
| Transaction account | Starting positive total value | Earliest positive total value or explicit flow basis |
| Holdings account | Starting positive total value | Ending book basis |
| Holdings account no basis | Starting value for bounded only | Unavailable |
Acceptance:
+3,972.94 and
+1.30% for 2026-06-12 to 2026-06-19, not -195,816.22 and -64.07%.+28,536.00 and +2.24% for the
same period, not -516,176.69 and -40.51%.Rebuild historical valuations with the new compiler/finalizer. This is recalculation from source facts, not a source-data migration.
amount only for legacy security-transfer rows that have no
quote and no cost basis.amount on security transfers must be
non-destructive and guarded by diagnostics.Compatibility warnings:
amount as both market value and cost basis
need classification.Acceptance:
Replace generic residual messages with actionable diagnostics:
amount fallback because cost basis is
missing.amount and quantity * unit_price differ
materially.Diagnostics should be structured enough for API/UI consumers and tests:
PerformanceDiagnostic {
reason_code,
severity,
account_id,
activity_id,
asset_id,
date,
event_kind,
flow_source
}
Required reason codes:
UNKNOWN_TRANSFER_BOUNDARYMISSING_TRANSFER_QUOTEREMOVED_LOT_BASIS_FALLBACKLEGACY_ACTIVITY_AMOUNT_FALLBACKMISSING_BASISPARTIAL_BASISMISSING_MARKET_QUOTEMISSING_MANUAL_VALUATIONATTRIBUTION_UNRECONCILEDAcceptance:
Backend tests:
economic_events: every activity class compiles to the expected event kind,
flow source, basis status, and diagnostics.flow_classifier: portfolio versus account boundary behavior, including
invalid groups, conflicting external metadata, and unknown transfers.holdings_calculator: transfer amount no longer corrupts cost basis, and
transfer-out removed-lot basis is available by activity for finalization.valuation_service: external transfer flow value comes from market value, and
no-quote transfer-out uses removed-lot fallback without net-delta inference.performance_service: holdings scopes report gain versus cost and keep
TWR/IRR unavailable.performance_service: mixed scopes aggregate account-level headline amounts
and do not treat holdings book-basis changes as external flows.performance_service: event effects reconcile to value delta; residual is a
diagnostic only.holdings_service and holdings_valuation_service: no hard 100% fallback
when basis is zero/missing.Frontend tests:
market value into generic amount.GainPercent fallback never double-appends %.End-to-end checks:
Recommended direction:
quantity * average cost + cash, not
zero, from stored source facts.This gives transaction-mode users accurate TWR when they provide or can derive market-value flows, and gives holdings-mode users honest value/P&L reporting without pretending incomplete flow history is complete.
book_basis/invested_capital fields
directly, or keep transitional API compatibility through existing
net_contribution fields while clearly labelling the metric?amount on security transfers remain
only a no-quote/no-cost-basis fallback, or should users get an opt-in repair
tool for older imported market values?