docs/features/short-selling/signed-option-shorts-design.md
Wealthfolio should support short exposure through a generic signed-lot model:
ActivityType remains BUY and SELL;POSITION_OPEN or POSITION_CLOSE; UI labels
and broker imports may use option aliases such as BTO, STO, BTC, STC,
BUY_TO_OPEN, SELL_TO_OPEN, BUY_TO_CLOSE, or SELL_TO_CLOSE, and stock
aliases such as SELL_SHORT, SHORT_SELL, BUY_COVER, or BUY_TO_COVER;The current code has positive-only assumptions across position aggregation, FIFO reduction, realized P&L recording, cost-basis aggregation, valuation, broker sync, import, and UI. This feature must therefore be implemented as a cross-cutting domain invariant change, not as a narrow broker or UI patch.
The target model keeps first-release stock behavior conservative. The signed-lot helpers should be generic, but a shortability policy controls where negative lots are allowed:
A stock oversell must not create a negative stock lot while the stock/ETF shortability gate is disabled.
The implementation should avoid persisting option identity on Position or
snapshot_positions. The current code already has authoritative option
classification on Asset::is_option(), and services that need option behavior
already load asset metadata for multiplier, expiry, pricing, or display. Keeping
option identity derived removes:
snapshot_positions.is_option migration;The remaining required migration is a rebuild migration for generated read models whose rows were calculated under positive-only option semantics.
SnapTrade's current public documentation is the closest public reference for Wealthfolio Connect behavior:
positions/all endpoint for option positions:
https://docs.snaptrade.com/reference/Options/Options_listOptionHoldingspositions/all endpoint documents signed position units, where
long positions are positive and short positions are negative, and documents
option cost_basis as per contract:
https://docs.snaptrade.com/reference/Account%20Information/AccountInformation_getAllAccountPositionsprice as
price per share of the option contract:
https://docs.snaptrade.com/reference/Account%20Information/AccountInformation_getAccountActivitiesImplementation implication:
units as authoritative; no separate
provider side field is part of the current implementation scope;average_purchase_price is per contract and must not be multiplied by the
option multiplier when computing holdings cost basis;price and transaction price are per share and must use the
option multiplier when converting to economic value;positions/all, map its option
cost_basis field to the same per-contract average-cost path.This design covers the option-specific parts of https://github.com/wealthfolio/wealthfolio/issues/339:
Sell to Open and Buy to Close option workflow;This design also prepares the internal model for the issue's broader stock-short proposal by using generic signed lots and generic open/close intent. It does not enable stock shorts in first-release product flows:
SHORT_SELL, SELL_SHORT, or BUY_COVER activity types;SELL_SHORT and BUY_COVER map to generic
SELL + POSITION_OPEN and BUY + POSITION_CLOSE only after stock/ETF
shortability is enabled;The existing high-level activity discriminator:
BUYSELLADJUSTMENTBUY and SELL stay unchanged.
The option contract type is part of the option asset identity:
CALL: contract gives the holder the right to buy the underlying at the
strike price;PUT: contract gives the holder the right to sell the underlying at the
strike price.CALL/PUT is not trade intent. A call can be bought to open, sold to open,
bought to close, or sold to close. The existing option form already captures
this through OptionContractFields.
Optional subtype used for UX, imports, and broker audit:
POSITION_OPEN: open/increase exposure on the side implied by BUY or
SELL;POSITION_CLOSE: close/reduce exposure on the opposite side.Broker-style labels are derived from instrument + activity_type + subtype:
| Instrument | Activity type | Canonical subtype | UI/broker label |
|---|---|---|---|
| option | BUY | POSITION_OPEN | Buy to Open |
| option | SELL | POSITION_OPEN | Sell to Open |
| option | BUY | POSITION_CLOSE | Buy to Close |
| option | SELL | POSITION_CLOSE | Sell to Close |
| stock/ETF | SELL | POSITION_OPEN | Sell Short |
| stock/ETF | BUY | POSITION_CLOSE | Buy to Cover |
Accepted import/broker aliases:
BTO, BUY_TO_OPEN, BUY OPEN -> POSITION_OPEN on BUYSTO, SELL_TO_OPEN, SELL OPEN -> POSITION_OPEN on SELLBTC, BUY_TO_CLOSE, BUY CLOSE -> POSITION_CLOSE on BUYSTC, SELL_TO_CLOSE, SELL CLOSE -> POSITION_CLOSE on SELLSELL_SHORT, SHORT_SELL, SELL SHORT -> POSITION_OPEN on SELLBUY_COVER, BUY_TO_COVER, BUY COVER -> POSITION_CLOSE on BUYSubtypes do not override calculator reality. The calculator still uses current
lots to decide whether a trade opens or closes exposure. If subtype intent
contradicts current position state, keep processing by signed lots and surface a
warning or needs_review flag.
The existing Activity.subtype field is single-valued and already carries
semantic labels such as DRIP, STAKING_REWARD, BONUS, REBATE, REFUND,
and OPTION_EXPIRY. POSITION_OPEN and POSITION_CLOSE are valid only for
BUY and SELL rows. Do not attach position-effect subtypes to income, credit,
transfer, or adjustment rows, and add tests proving existing subtype
canonicalization and activity compilation behavior is unchanged for those
subtypes.
Shortability is a policy decision, not a separate activity type.
Initial policy:
| Instrument | Negative lots | Product behavior |
|---|---|---|
| option | allowed | enabled by this feature |
| stock/ETF | blocked | future feature gate |
| other | blocked | explicit future review |
The signed-lot helper code should be generic, but every path that can open a
negative lot must receive an allows_negative_lots decision derived from asset,
account, import source, and product policy. In the first release this decision
is true for options and false for stocks/ETFs.
Concrete first-release definition:
pub struct ShortabilityPolicy;
impl ShortabilityPolicy {
pub fn allows_negative_lots(asset: &Asset, _account_id: Option<&str>) -> bool {
asset.is_option()
}
}
Ownership:
crates/core/src/portfolio/snapshot/shortability_policy.rs;AssetPositionInfo;All openers must treat this policy as a hard gate. UI/import validation should block or mark unsupported stock short requests before calculation, and the core signed-lot opener must still reject negative lots when the gate is false.
A signed lot:
quantity > 0: long exposurequantity < 0: short exposurecost_basis > 0: long debit basiscost_basis < 0: short credit basis, net of opening feesMultiplier used to convert option premium into economic value.
100101, only when option metadata is unavailableThe current implementation supports long options through contract multipliers, but assumes lots and positions are positive in many places.
Path: crates/core/src/portfolio/snapshot/positions_model.rs
Current issues:
Position has is_alternative and contract_multiplier. Option identity is
already available from the related Asset, but position methods currently do
not receive that context.recalculate_aggregates zeroes negative aggregate quantity and cost basis.add_lot_values skips non-positive quantities.reduce_lots_fifo rejects non-positive reduction input and skips negative
lots.Lot::basis_status requires positive quantity and positive cost basis.Position::basis_status ignores lots with non-positive quantity.Path: crates/core/src/portfolio/snapshot/holdings_calculator.rs
Current issues:
AssetPositionInfo has contract_multiplier and is_bond, but no
is_option.handle_buy always opens a positive lot because Activity::qty() returns an
absolute value.handle_sell only reduces existing positive lots. If there is no position, it
applies cash and records no lot.lot.quantity > 0.record_lot_disposals can support signed P&L only if close proceeds and
removed short lots are passed with intentional signs.Path: crates/core/src/activities/activities_model.rs
Current facts:
Activity::qty(), Activity::price(), Activity::amt(), and
Activity::fee_amt() return absolute values.BTO, STO, BTC, STC, BUY_TO_OPEN, SELL_TO_OPEN,
BUY_TO_CLOSE, SELL_TO_CLOSE, SELL_SHORT, or BUY_COVER.Paths:
crates/core/src/portfolio/snapshot/snapshot_model.rscrates/storage-sqlite/src/portfolio/snapshot/model.rscrates/storage-sqlite/src/schema.rsCurrent issues:
contract_multiplier or
is_alternative.Position values. Any code that needs
option-specific behavior must derive option identity from the related asset at
the service/calculator boundary.Paths:
crates/connect/src/broker/service.rscrates/connect/src/broker/models.rscrates/connect/src/broker/mapping.rsCurrent issues:
units if the provider sends them, but the
created snapshot does not explicitly normalize option short economics.quantity * avg_cost, which is
correct for SnapTrade option holdings because average_purchase_price is per
contract. The implementation needs fixtures so this path is not later changed
to incorrectly multiply holdings cost basis by the option multiplier.optionSymbol, optionPositions,
isMiniOption, and averagePurchasePrice.POSITION_OPEN / POSITION_CLOSE.Paths:
crates/core/src/portfolio/holdings/holdings_valuation_service.rscrates/core/src/portfolio/valuation/current_account_valuation.rsCurrent issues:
None when basis is negative.-100%, which is wrong
for expired short options.Paths:
apps/frontend/src/lib/constants.tsapps/frontend/src/pages/activity/components/forms/buy-form.tsxapps/frontend/src/pages/activity/components/forms/sell-form.tsxapps/frontend/src/pages/activity/components/forms/fields/advanced-options-section.tsxapps/frontend/src/pages/activity/import/utils/validation-utils.tsapps/frontend/src/pages/asset/asset-profile-page.tsxapps/frontend/src/pages/asset/asset-lots-table.tsxCurrent issues:
quantity > 0 holdings.Activity numeric fields remain positive:
quantity >= 0unit_price >= 0fee >= 0amount >= 0Direction is represented by:
activity_type: BUY or SELLNo negative user-entered activity value should be required for short exposure.
Do not add or persist Position.is_option or Position.allows_short. The
authoritative instrument identity comes from Asset, and the permission to open
negative lots comes from runtime shortability policy. This avoids duplicating
asset classification into every snapshot row, avoids a snapshot sync migration,
and prevents drift if asset metadata or shortability policy changes later.
Any service that can apply signed-lot logic must load or receive asset metadata
and an allows_negative_lots decision before opening a negative lot.
Rules:
allows_negative_lots = false must have quantity >= 0 after
recalculation;allows_negative_lots = true may have positive, zero, or
negative quantity;average_cost = total_cost_basis / quantity;average_cost = abs(total_cost_basis) / abs(quantity) for display, or keep
signed average only if all downstream callers are audited;average_cost as positive per-contract cost/credit
for signed positions, and store sign in quantity and total_cost_basis.Rationale:
No Lot.is_option or Lot.allows_short field is required. The lot belongs to
exactly one asset through its position, and instrument identity plus
shortability policy are resolved by the caller.
Rules:
quantity > 0original_quantity > 0cost_basis > 0acquisition_price > 0quantity < 0original_quantity < 0cost_basis < 0acquisition_price > 0Opening fee handling:
long buy-to-open cost_basis = gross_debit + opening_fee
short sell-to-open cost_basis = -gross_credit + opening_fee
The short opening fee reduces the net credit because it makes the negative cost basis less negative.
For signed trade accounting:
gross = quantity_abs * unit_price * instrument_multiplier
instrument_multiplier is the option contract multiplier for options and 1
for stocks/ETFs and ordinary securities.
Cash effects:
Buy to Open cash = -(gross + fee)
Sell to Close cash = +(gross - fee)
Sell to Open cash = +(gross - fee)
Buy to Close cash = -(gross + fee)
Because activity storage uses only BUY and SELL:
BUY activities are cash outflows;SELL activities are cash inflows;For disposal records:
realized_pnl = proceeds - removed_cost_basis
Long close example:
open long cost_basis = +100
sell close proceeds = +120
realized_pnl = +120 - +100 = +20
Short close example:
open short cost_basis = -100
buy close proceeds = -80
realized_pnl = -80 - -100 = +20
Therefore buy-to-close lots must pass negative disposal proceeds into
record_lot_disposals.
Crossing trades allocate fees and proceeds by absolute contract quantity:
close_qty_abs / total_activity_qty_abs
open_qty_abs / total_activity_qty_abs
The allocated close fee is part of close proceeds. The allocated open fee is part of new lot cost basis.
Market value remains signed:
market_value = quantity * quote_price * contract_multiplier
Unrealized gain remains:
unrealized_gain = market_value - cost_basis
Examples:
short option opened for 100 credit:
quantity = -1
cost_basis = -100
market_value at 80 debit = -80
unrealized_gain = -80 - -100 = +20
short option opened for 100 credit:
quantity = -1
cost_basis = -100
market_value at 130 debit = -130
unrealized_gain = -130 - -100 = -30
Percentage denominators:
abs(cost_basis);abs(previous_market_value) when previous value is nonzero;None, omit percent, or
surface a data-quality warning depending on the API contract.Expired options:
-cost_basis;-100%.-cost_basis, which is positive when cost basis is
negative;+100% when measured against abs(cost_basis).File: crates/core/src/portfolio/snapshot/positions_model.rs
Do not add fields to Position.
Instead, add generic signed-lot methods whose callers must have already established asset identity and shortability:
add_lot_values and reduce_lots_fifo positive-only;allows_negative_lots.This keeps the snapshot schema stable and keeps stock behavior unchanged while stock/ETF shortability remains disabled.
File: crates/core/src/portfolio/snapshot/holdings_calculator.rs
Add:
is_option: bool,
allows_negative_lots: bool,
Populate from asset metadata and product policy:
let is_option = asset.is_option();
let allows_negative_lots = shortability_policy.allows_negative_lots(asset, account);
Asset::is_option() already exists and checks
instrument_type == Some(InstrumentType::Option). For the first release, the
policy should return true for options and false for stocks/ETFs.
No snapshot_positions schema change is required for option identity.
The existing asset_id foreign key already points at the authoritative asset
record, and contract_multiplier is already persisted for historical valuation
semantics. Read paths that need option behavior should load asset metadata
beside snapshot positions, as current holdings and current valuation paths
already do for pricing, expiry, and display.
Generated read models must be cleared:
DELETE FROM lot_disposals;
DELETE FROM lots;
DELETE FROM daily_account_valuation;
DELETE FROM holdings_snapshots
WHERE source = 'CALCULATED';
Notes:
holdings_snapshots should cascade to their
snapshot_positions rows if the FK cascade is active;Do not add isOption to snapshot JSON. Old and new JSON snapshots should keep
the same shape for this feature. The runtime code that needs option behavior
must derive it from asset metadata.
File: crates/core/src/portfolio/snapshot/snapshot_model.rs
Update positions_equal to compare:
asset_idquantityaverage_costtotal_cost_basiscurrencyis_alternativecontract_multiplierRationale:
Files to audit:
crates/core/src/sync/app_sync_model.rsRequirements:
instrument_type = OPTION and option
metadata before snapshot positions that reference those assets are rebuilt;Keep the current positive-only FIFO API intact for existing callers. Add generic
signed-lot helpers for the buy/sell calculator and broker/import snapshot paths.
Do not relax reduce_lots_fifo globally.
Recommended helper surface:
impl Position {
pub fn open_lot_signed(
&mut self,
lot_id: String,
signed_quantity: Decimal,
unit_price: Decimal,
fee: Decimal,
acquisition_date: DateTime<Utc>,
fx_rate_used: Option<Decimal>,
source_activity_id: Option<String>,
book_basis: LotBookBasis,
allows_negative_lots: bool,
) -> Result<Decimal>;
pub fn recalculate_aggregates_with_policy(
&mut self,
allows_negative_lots: bool,
);
pub fn reduce_positive_lots_fifo(
&mut self,
quantity_abs: Decimal,
) -> Result<FifoReductionResult>;
pub fn reduce_negative_lots_fifo(
&mut self,
quantity_abs: Decimal,
) -> Result<FifoReductionResult>;
}
Rules:
handle_buy, handle_sell, option expiry, broker holdings sync, and manual
holdings import must call signed helpers only after passing an
allows_negative_lots decision.open_lot_signed must reject negative signed quantities when
allows_negative_lots = false.open_lot_signed should be the only path that can append a negative lot.allows_negative_lots = false and signed aggregation would produce negative
quantity or cost basis, return an error or clamp with an explicit warning. Do
not silently persist a negative stock/ETF position while the gate is disabled.Position.is_option or
Position.allows_short guard.add_lot_values and reduce_lots_fifo keep current positive stock
behavior.FifoReductionResult.quantity_reduced should be absolute quantity for both
positive and negative reducers.quantity and negative cost_basis.For positions with allows_negative_lots = false:
For positions with allows_negative_lots = true:
if quantity_abs_is_significant {
average_cost = total_cost_basis.abs() / quantity.abs();
} else {
average_cost = Decimal::ZERO;
}
File: crates/core/src/portfolio/snapshot/holdings_calculator.rs
Current handle_buy always opens a positive lot. Change behavior:
AssetPositionInfo.asset_info.allows_negative_lots, not asset_info.is_option, to decide
whether the trade may cross through zero.contract_multiplier;record_lot_disposals;Pseudo-code:
fn handle_signed_buy(...) -> Result<()> {
let qty_abs = activity.qty();
let gross = gross_trade_amount(activity, asset_info);
let fee = activity.fee_amt();
let unit_price = effective_unit_price(activity, asset_info);
if !asset_info.allows_negative_lots {
return handle_positive_only_buy(...);
}
book_cash_outflow(gross + fee);
let close_qty = min(qty_abs, abs(position.negative_open_quantity()));
let open_qty = qty_abs - close_qty;
let close_fee = allocate_by_qty(fee, close_qty, qty_abs);
let open_fee = fee - close_fee;
if close_qty > 0 {
let close_gross = unit_price * close_qty;
let close_proceeds = -(close_gross + close_fee);
let reduction = position.reduce_negative_lots_fifo(close_qty)?;
record_lot_disposals(..., close_proceeds, reduction.quantity_reduced, ...);
record_lot_closures(...);
}
if open_qty > 0 {
position.open_lot_signed(
..., +open_qty, unit_price, open_fee, ..., asset_info.allows_negative_lots,
)?;
}
}
Current handle_sell only closes positive lots. Change behavior:
AssetPositionInfo.asset_info.allows_negative_lots, not asset_info.is_option, to decide
whether the trade may cross through zero.contract_multiplier;record_lot_disposals;Pseudo-code:
fn handle_signed_sell(...) -> Result<()> {
let qty_abs = activity.qty();
let gross = gross_trade_amount(activity, asset_info);
let fee = activity.fee_amt();
let unit_price = effective_unit_price(activity, asset_info);
if !asset_info.allows_negative_lots {
return handle_positive_only_sell(...);
}
book_cash_inflow(gross - fee);
let close_qty = min(qty_abs, position.positive_open_quantity());
let open_qty = qty_abs - close_qty;
let close_fee = allocate_by_qty(fee, close_qty, qty_abs);
let open_fee = fee - close_fee;
if close_qty > 0 {
let close_gross = unit_price * close_qty;
let close_proceeds = close_gross - close_fee;
let reduction = position.reduce_positive_lots_fifo(close_qty)?;
record_lot_disposals(..., close_proceeds, reduction.quantity_reduced, ...);
record_lot_closures(...);
}
if open_qty > 0 {
if should_open_short(activity, asset_info, position) {
position.open_lot_signed(
..., -open_qty, unit_price, open_fee, ..., asset_info.allows_negative_lots,
)?;
} else {
handle_oversell_without_short_intent(...)?;
}
}
}
A single activity may both close and open exposure. The open lot ID must not collide with disposal IDs or existing activity-based lot IDs.
Recommended IDs:
{activity.id}:open
{activity.id}:close:{lot.id}:{index}
If existing lot records require open_activity_id to match an activity row for
cascade deletes, keep source_activity_id = Some(activity.id.clone()) and use a
stable synthetic lot id.
record_lot_disposals currently allocates proceeds by:
total_proceeds * effective_quantity / total_quantity_reduced
For short lots, effective_quantity may be negative. That would invert the
allocation. Update disposal allocation to use absolute quantities:
let effective_quantity_abs = lot.effective_quantity().abs();
let proceeds =
total_proceeds * effective_quantity_abs / total_quantity_reduced_abs;
Persist disposal quantity as the absolute closed quantity unless downstream tax reporting explicitly needs the signed quantity. If signed quantity is persisted, all reports must be audited.
Update account snapshot cost basis aggregation:
allows_negative_lots = false: existing positive-lot behavior;allows_negative_lots = true: include signed lots;position.total_cost_basis.Files:
crates/core/src/portfolio/snapshot/holdings_calculator.rscrates/core/src/lots/mod.rsQuote sync should not rely on net quantity alone when long and short positions in different accounts net to zero. Use "any open lot by asset" or sum of absolute quantities where quote planning needs exposure existence.
Current OPTION_EXPIRY reduces positive lots only.
New behavior:
0 - positive_cost_basis = loss;0 - negative_cost_basis = gain.Use activity quantity as absolute contracts to expire.
Frontend expiry creation must submit abs(quantity) for negative holdings.
Subtype should not determine accounting, but it is useful for review.
Validation rules:
BUY and SELL may have POSITION_OPEN or POSITION_CLOSE;SELL_SHORT, SHORT_SELL, BUY_COVER, or
BUY_TO_COVER should canonicalize for audit but remain blocked or
needs_review until stock/ETF shortability is enabled;POSITION_OPEN or POSITION_CLOSE.Warning examples:
POSITION_CLOSE on a BUY but no short option lots exist, so the
trade opened a long lot;POSITION_CLOSE on a SELL but no long option lots exist, so the
trade opened a short lot;POSITION_OPEN short exposure while the stock/ETF
shortability gate is disabled.Warnings can initially be logs or needs_review flags. Do not block broker
imports unless the data is structurally invalid.
Do not treat every oversell as an intentional short.
Rules:
SELL may default to POSITION_OPEN when there is no open long
quantity, matching broker "sell to open" behavior and the first-release option
UX;SELL without an explicit POSITION_OPEN, SELL_SHORT, or
SHORT_SELL label is an ordinary sell. While stock/ETF shortability is
disabled, preserve current positive-only behavior and do not create a negative
lot;SELL with explicit short intent while stock/ETF shortability is
disabled must be blocked in manual UI and marked needs_review or rejected in
imports/broker sync before it reaches the calculator;Enforcement points:
POSITION_OPEN short intent while disabled;apps/frontend/src/pages/activity/import/utils/validation-utils.ts should
flag disabled stock/ETF short intent before import submission;crates/core/src/activities/activities_service.rs should canonicalize the
subtype, preserve the raw alias in metadata when useful, and keep disabled
stock/ETF short rows out of normal calculated activity flow by returning
review/rejection;crates/connect/src/broker/mapping.rs may canonicalize broker
aliases, but broker service preparation must not let disabled stock/ETF short
intent create a calculated negative stock lot;open_lot_signed(..., allows_negative_lots = false) is the final
backstop and must reject negative openings even if validation missed a path.Files:
crates/connect/src/broker/models.rscrates/connect/src/broker/mapping.rsAdd serde aliases for option-related DTO fields where Connect may send camelCase or snake_case.
Examples:
#[serde(alias = "optionSymbol")]
pub option_symbol: Option<HoldingsOptionSymbol>,
#[serde(alias = "optionPositions")]
pub option_positions: Option<Vec<HoldingsOptionPosition>>,
#[serde(alias = "isMiniOption")]
pub is_mini_option: Option<bool>,
#[serde(alias = "averagePurchasePrice")]
pub average_purchase_price: Option<f64>,
Also audit:
expirationDatestrikePriceunderlyingSymbolrawSymbolsymbolTypemicCodeoptionTypeRequirements:
Subtype canonicalization:
BTO, BUY_TO_OPEN, BUY OPEN, OPEN -> POSITION_OPEN when activity_type = BUY
STO, SELL_TO_OPEN, SELL OPEN, OPEN -> POSITION_OPEN when activity_type = SELL
BTC, BUY_TO_CLOSE, BUY CLOSE, CLOSE -> POSITION_CLOSE when activity_type = BUY
STC, SELL_TO_CLOSE, SELL CLOSE, CLOSE -> POSITION_CLOSE when activity_type = SELL
SELL_SHORT, SHORT_SELL, SELL SHORT -> POSITION_OPEN when activity_type = SELL
BUY_COVER, BUY_TO_COVER, BUY COVER -> POSITION_CLOSE when activity_type = BUY
Preserve the raw provider subtype in metadata when broker audit needs the exact
source value. The canonical Activity.subtype should be POSITION_OPEN or
POSITION_CLOSE.
If a stock/ETF broker transaction maps to POSITION_OPEN short exposure while
stock/ETF shortability is disabled, do not silently create a negative stock lot.
Mark the row needs_review or block it according to the import path.
Amount normalization policy:
SnapTrade documents option activity price as premium per share. The existing
calculator path derives multiplier-inclusive economics as:
gross_trade_amount = qty * price * contract_multiplier
For current SnapTrade/Connect option transactions, keep quantity as contracts,
keep price as premium per share, and do not make amount authoritative for
normal option BUY/SELL rows.
Still add provider fixtures that lock down option price, amount, and
currency signs before enabling signed broker option transactions.
Use this policy:
qty * price * multiplier.amount and
metadata that makes should_use_activity_amount treat it as authoritative.amount with the
authoritative metadata above.needs_review and keep source metadata
for inspection.The calculator currently ignores amount for normal buy/sell rows when both
quantity and unit price are present. That is acceptable for SnapTrade/Connect
option transactions because unit price is premium per share and multiplier is
available. If broker amount should override price for options in another
provider, update should_use_activity_amount with an explicit broker metadata
signal instead of making all options amount-authoritative.
Files:
crates/connect/src/broker/service.rscrates/connect/src/broker/models.rsRequirements:
units become signed Position.quantity;contract_multiplier comes from option metadata;average_cost is positive display cost or credit per contract;total_cost_basis is signed:
Cost basis normalization:
if provider_average_purchase_price_is_per_contract:
avg_cost_per_contract = average_purchase_price
else if provider_cost_basis_is_per_contract:
avg_cost_per_contract = cost_basis
else if provider_average_purchase_price_is_per_share:
avg_cost_per_contract = average_purchase_price * contract_multiplier
else if provider_total_cost_basis_is_available:
avg_cost_per_contract = abs(total_cost_basis) / abs(units)
Then:
total_cost_basis =
sign(units) * abs(units) * avg_cost_per_contract
average_cost = avg_cost_per_contract
For current SnapTrade/Connect option holdings:
avg_cost_per_contract = average_purchase_price
total_cost_basis = units * average_purchase_price
market_value = units * price * contract_multiplier
Do not multiply average_purchase_price by contract_multiplier in holdings
mode. The multiplier is already baked into SnapTrade option holdings basis. Only
apply the multiplier to per-share option price when deriving market value or
transaction gross amount.
If Connect migrates to SnapTrade positions/all, map option cost_basis to
avg_cost_per_contract and keep the same no-extra-multiplier basis rule.
Do not compute option holdings basis from ambiguous price fields without a test fixture.
Snapshot diff/content equality must include:
contract_multiplier.Asset option identity is compared through the asset record, not duplicated on the snapshot position.
Current import normalizes numeric values to absolute values. Keep that behavior.
Add row-aware position effect subtype support:
POSITION_OPEN / POSITION_CLOSE only when the row's instrument and
shortability policy support the requested effect;POSITION_OPEN or POSITION_CLOSE;Option amount derivation:
amount empty where possible and let
backend derive with multiplier;Manual holdings snapshot rows must set:
contract_multiplier from option metadata;Do not allow negative quantity for stocks in holdings import while stock/ETF shortability is disabled.
Manual activity forms:
Buy to Open -> POSITION_OPENBuy to Close -> POSITION_CLOSESell to Close -> POSITION_CLOSESell to Open -> POSITION_OPENPOSITION_CLOSE if there is an open short quantity,
otherwise POSITION_OPEN;POSITION_CLOSE if there is open long quantity, otherwise
POSITION_OPEN.Sell Short or Buy to Cover
until stock/ETF shortability is enabled.Important:
Sell warning:
The current buy/sell forms already have the right base structure:
OptionContractFields;Target change: add a position-effect segmented control immediately after the
option contract fields and before trade details. In the first release this
control is visible only for option trades; future stock/ETF shortability can use
the same positionEffect field with stock labels.
Buy option form:
Account
Date
Option Contract
Underlying | Expiration | Call/Put | Strike
Intent
[Buy to Open] [Buy to Close]
Trade Details
Contracts | Premium/Share | Fee
Multiplier: 100x
Total Debit
Advanced Options
Notes
Sell option form:
Account
Date
Option Contract
Underlying | Expiration | Call/Put | Strike
Intent
[Sell to Close] [Sell to Open]
Current Position
Long N contracts or Short N contracts when available
Trade Details
Contracts | Premium/Share | Fee
Multiplier: 100x
Total Credit
Warnings
Advanced Options
Notes
Implementation details:
positionEffect or subtype to buyFormSchema and sellFormSchema;subtype;CALL/PUT inside OptionContractFields;AdvancedOptionsSection for position effect, because intent is a
primary trade field, not an advanced field;activity-form-config.ts so BUY/SELL defaults read activity.subtype
and toPayload writes it for supported activities;Buy to OpenBuy to CloseSell to CloseSell to OpenRequirements:
abs(costBasis) for short option
percentages;-100% for expired short options.File: apps/frontend/src/pages/asset/asset-lots-table.tsx
Fallback math:
market_value = quantity * market_price * contract_multiplier
gain_loss = market_value - cost_basis
gain_loss_percent =
cost_basis != 0 ? gain_loss / abs(cost_basis) : null
When long and short lots are shown together, totals should avoid misleading
average unit cost if signed quantities net near zero. Show N/A or use separate
long and short summaries.
File: apps/frontend/src/pages/asset/asset-profile-page.tsx
Change:
const nonZeroHoldings = accountHoldings.filter((h) => h.quantity !== 0);
quantity: String(Math.abs(h.quantity));
Guard:
Subtype options must be row-aware:
Buy to Open and Buy to CloseSell to Close and Sell to OpenSell Short or
Buy to Cover choicesSell ShortBuy to CoverAvoid adding global BUY/SELL subtype options that appear for all securities.
Market value stays signed:
quantity * quote_price * contract_multiplier
Current account totals may be reduced by short liabilities. This is correct.
Guardrails:
None;None, return zero and include a warning. A
model change is preferable if the affected field is user-facing.Use:
denominator = abs(cost_basis)
for signed positions when cost basis is negative.
Use:
day_change_pct = day_change / abs(previous_market_value)
for signed positions.
This makes a short option liability that becomes more negative show a negative day change percentage.
No performance methodology change is required for the first implementation.
Explicit requirements:
BUY and SELL as internal flows, so add
regression tests rather than a new flow type;No schema column is required for option identity. Add a rebuild migration that clears generated read models affected by old positive-only option semantics:
lot_disposalslotsdaily_account_valuationholdings_snapshotsExisting generated rows were calculated with positive-only lot semantics. They cannot be patched safely because:
This migration is not data-destructive for user source facts:
Because this design does not add a snapshot schema column, rollback risk is limited to generated read models being recalculated by the older positive-only logic. Treat app downgrade after recalculation as unsupported unless verified.
Files:
crates/core/src/portfolio/snapshot/positions_model.rscrates/storage-sqlite/migrationsTasks:
Position, snapshot JSON, or
snapshot_positions;ShortabilityPolicy in core with first-release behavior
asset.is_option();AssetPositionInfo.is_option and AssetPositionInfo.allows_negative_lots
in calculator context;contract_multiplier and
is_alternative.Verification:
Asset::is_option() where signed
logic runs, with stock/ETF shortability disabled.Files:
crates/core/src/portfolio/snapshot/positions_model.rscrates/core/src/portfolio/snapshot/holdings_calculator.rscrates/core/src/lots/mod.rsTasks:
allows_negative_lots;allows_negative_lots;Verification:
allows_negative_lots = false.Files:
crates/core/src/portfolio/snapshot/holdings_calculator.rsTasks:
AssetPositionInfo.allows_negative_lots to decide whether buy/sell may
cross through zero;handle_buy and handle_sell into signed-lot behavior only when
negative lots are allowed;POSITION_OPEN intent for future stock/ETF short opening;Verification:
Files:
crates/core/src/activities/activities_constants.rscrates/core/src/activities/activities_model.rscrates/connect/src/broker/models.rscrates/connect/src/broker/mapping.rscrates/connect/src/broker/service.rsTasks:
POSITION_OPENPOSITION_CLOSENewActivity::canonicalize_subtype;BUY / SELL when activity type is
known, including option aliases and stock-short aliases;POSITION_OPEN / POSITION_CLOSE do not collide with existing
single-valued subtypes such as DRIP, STAKING_REWARD, and OPTION_EXPIRY;Verification:
units * average_purchase_price without an extra
multiplier;units * price * contract_multiplier;Files:
crates/core/src/portfolio/holdings/holdings_valuation_service.rscrates/core/src/portfolio/valuation/current_account_valuation.rsTasks:
Verification:
+100% where applicable;Files:
apps/frontend/src/lib/constants.tsTasks:
Verification:
POSITION_OPEN / POSITION_CLOSE;POSITION_OPEN / POSITION_CLOSE;Sell Short / Buy to Cover while stock/ETF
shortability is disabled;Add tests for:
allows_negative_lots = false;allows_negative_lots = false;Add tests for:
allows_negative_lots in a focused unit test lets the same generic
signed-lot helper represent non-option short exposure without option-specific
code;POSITION_OPEN / SELL_SHORT intent is blocked or marked
review while stock/ETF shortability is disabled;Add tests for:
isOption field;snapshot_positions insert/read round trip remains unchanged;ShortabilityPolicy returns true for options and false for stock/ETF in the
first release;snapshot_positions remains unchanged and still depends
on asset rows being restored before snapshot position rows.Add fixtures for:
BTO / BUY_TO_OPEN;STO / SELL_TO_OPEN;BTC / BUY_TO_CLOSE;STC / SELL_TO_CLOSE;SELL_SHORT / SHORT_SELL canonicalize to
POSITION_OPEN but are blocked or marked needs_review while stock/ETF
shortability is disabled;BUY_COVER / BUY_TO_COVER canonicalize to
POSITION_CLOSE but are blocked or marked needs_review while stock/ETF
shortability is disabled;units * average_purchase_price with no extra
multiplier;units * price * contract_multiplier;Add tests for:
POSITION_OPEN or
POSITION_CLOSE;Sell Short / Buy to Cover while stock/ETF
shortability is disabled;Add tests for:
DRIP, DIVIDEND_IN_KIND, STAKING_REWARD, BONUS, REBATE, REFUND, and
OPTION_EXPIRY canonicalization remains unchanged;POSITION_OPEN and POSITION_CLOSE are accepted only for BUY / SELL;Add tests before enabling stock/ETF shortability:
Add tests for:
The calculator should normally close the opposite side before opening a new side, so one account should not have simultaneous long and short lots for the same option after a single ordered activity stream.
Still audit for:
If simultaneous signs exist, aggregate quantity can net to zero while exposure still exists. Reporting should prefer separate lot display over a misleading net average cost.
Current activity ordering should be preserved. If same timestamp ordering is unstable, add deterministic secondary ordering by activity creation time or ID. This matters for buy-to-open then sell-to-close versus sell-to-open then buy-to-close on the same date.
Generated snapshots and lot read models should rebuild from source activities. Do not try to patch signed lots in place after activity edits.
Out of scope for this feature.
Recommended behavior for now:
needs_review;Covered calls and spreads are naturally represented as separate signed option and stock positions. There is no strategy-level grouping in this design.
Out of scope. A short position may create a liability, but this design does not model margin requirements or buying power.
The first release must keep stock/ETF shortability disabled. Before changing
ShortabilityPolicy to allow stock/ETF negative lots, add a separate
stock-short implementation plan and cover at least these items:
DIVIDEND as a cash
inflow from activity.amount. Short equity holders owe dividends, so dividend
events against short stock exposure must become cash outflows or explicit
payment-in-lieu activities. Do not enable stock shorts until this is modeled
or blocked with review.POSITION_OPEN,
SELL_SHORT, or SHORT_SELL. Plain stock oversells without explicit short
intent remain data-quality or missing-history issues and must not silently
open shorts.Lot cost basis should stay anchored to acquisition-date FX as today. Short close proceeds must preserve sign through FX conversion.
Use existing decimal precision rules. Avoid rounding before splitting close and open legs except where existing storage boundaries require it.
Normalize insignificant signed quantities and cost basis to zero after
recalculation. Avoid displaying -0.
The feature is complete when:
allows_negative_lots;ShortabilityPolicy is implemented in core and returns true only for options
in the first release;SELL_SHORT aliases do not create short stock lots
while stock/ETF shortability is disabled;needs_review before
calculation, not silently handled as a calculated short;-100% loss;DRIP, STAKING_REWARD, and
OPTION_EXPIRY keep their current behavior;average_cost for short signed positions be stored positive for
display or signed for strict accounting? This design recommends positive
average cost with signed total cost basis.needs_review in imports?