docs/features/allocations/v1-spec.md
Status: Draft Date: 2026-05-07 Audience: Product, frontend, backend, desktop, web
This document scopes a practical v1 for allocation targets and rebalancing in
Wealthfolio. It is intentionally smaller than the SOTA north-star model in
docs/features/allocations/sota-target-model-spec.md.
V1 should let users:
V1 should not implement tax-lot logic, wash-sale checks, lot disposal strategy, tax reporting, household/account-group optimization, or broker execution. Those belong to separate planned features and should integrate later through explicit extension points.
Use conservative product language.
Recommended user-facing names:
Avoid user-facing names that imply regulated advice:
The feature should help users model their own target and produce transparent manual suggestions. It should not present itself as personalized financial or tax advice.
asset_classes.The full conceptual model remains:
Target
-> Rebalance trigger
-> Funding input
-> Execution constraints
-> Suggested manual trades
V1 does not need separate tables for every concept. Keep the concepts distinct in service code and UI copy, but persist them compactly.
| Concept | V1 Persistence |
|---|---|
| Allocation target | allocation_targets |
| Allocation sleeves | allocation_target_weights |
| Rebalance trigger | Inline fields on allocation_targets |
| Funding input | Request-time input, not persisted |
| Execution constraints | Deferred to planner milestones |
| Suggested trades | Deferred to planner milestones |
| Tax lots | Not in this feature |
Milestone 1 uses two required tables. Rebalance draft persistence is deferred to the planner milestones.
Stores target metadata and trigger settings.
CREATE TABLE allocation_targets (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL CHECK (length(trim(name)) > 0),
scope_type TEXT NOT NULL CHECK (scope_type IN ('all', 'portfolio', 'account')),
scope_id TEXT,
taxonomy_id TEXT NOT NULL DEFAULT 'asset_classes',
trigger_type TEXT NOT NULL DEFAULT 'threshold' CHECK (trigger_type IN ('manual', 'threshold')),
drift_band_bps INTEGER NOT NULL DEFAULT 500 CHECK (drift_band_bps >= 0 AND drift_band_bps <= 10000),
rebalance_goal TEXT NOT NULL DEFAULT 'nearest_band'
CHECK (rebalance_goal IN ('nearest_band', 'exact_target')),
min_trade_amount TEXT NOT NULL DEFAULT '0',
whole_shares_only INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
archived_at TEXT,
CHECK (
(scope_type = 'all' AND scope_id IS NULL) OR
(scope_type IN ('account', 'portfolio') AND scope_id IS NOT NULL)
)
);
Field rules:
scope_type: all, portfolio, or account.scope_id: null for all scope; portfolio/account id for those scopes.taxonomy_id: must reference an existing asset taxonomy.trigger_type: v1 supports manual and threshold.drift_band_bps: absolute tolerance band. 500 means +/-5 percentage points.rebalance_goal: future planner target, nearest_band or exact_target.min_trade_amount: future planner minimum trade amount, stored as decimal
text.whole_shares_only: future planner execution constraint.archived_at: null for normal targets; set when a target is archived.Indexes:
CREATE INDEX idx_allocation_targets_scope
ON allocation_targets(scope_type, scope_id, archived_at);
Stores target percentages for categories in the target taxonomy.
CREATE TABLE allocation_target_weights (
id TEXT PRIMARY KEY NOT NULL,
target_id TEXT NOT NULL,
category_id TEXT NOT NULL,
target_bps INTEGER NOT NULL,
is_locked INTEGER NOT NULL DEFAULT 0,
is_required INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
FOREIGN KEY (target_id) REFERENCES allocation_targets(id) ON DELETE CASCADE,
UNIQUE(target_id, category_id)
);
Rules:
allocation_targets.taxonomy_id.10000.target_bps must be between 0 and 10000.is_required controls whether a zero-current target appears in drift and
breach calculations.Indexes:
CREATE INDEX idx_allocation_target_weights_target
ON allocation_target_weights(target_id);
Deferred. Milestone 1 does not create a draft table.
When the planner ships, saved drafts should be created only when the user explicitly saves a generated plan. Do not persist every calculation.
Recommended core module:
crates/core/src/portfolio/allocation_targets/
mod.rs
model.rs
target_service.rs
drift_service.rs
validation.rs
Recommended storage module:
crates/storage-sqlite/src/portfolio/allocation_targets/
mod.rs
model.rs
repository.rs
Application layers:
apps/tauri/src/commands/allocation_targets.rs
apps/server/src/api/allocation_targets.rs
apps/frontend/src/adapters/shared/allocation-targets.ts
TargetService:
DriftService:
target_id.RebalanceService is deferred to planner milestones.
Use one sign convention everywhere:
target_value = total_value * target_bps / 10000
current_bps = current_value / total_value * 10000
drift_bps = current_bps - target_bps
value_delta = current_value - target_value
For asset_classes, total_value is the full portfolio value including cash.
For other taxonomies, total_value is the selected taxonomy allocation total,
which avoids cash or holdings outside that taxonomy diluting sector, region,
risk, and custom taxonomy percentages.
Interpretation:
drift_bps > 0: overweight.drift_bps < 0: underweight.value_delta > 0: dollars above target.value_delta < 0: dollars needed.Out-of-band check:
abs(drift_bps) > drift_band_bps
Planner settings are not target fields in Milestone 1. Add them with the planner
service instead of backfilling stale columns into allocation_targets.
Input:
interface CalculateRebalancePlanInput {
targetId: string;
availableCash: string;
mode: "cash_flow_only" | "sell_to_rebalance";
}
Rules:
availableCash is parsed as zero.cash_flow_only never sells.Cash-flow-only algorithm:
Sell-to-rebalance algorithm:
Trade output should be honest when no concrete asset can be selected:
Do not force fake ticker suggestions.
Tax lots and disposal rules are planned in another feature. V1 should leave a clean integration point without schema pollution.
Future extension should be able to provide:
trait TaxLotPlanningService {
fn estimate_trade_tax_impact(...);
fn rank_sell_candidates(...);
fn detect_wash_sale_risk(...);
}
V1 RebalanceService should depend only on an optional trait boundary or no-op placeholder, not on persisted tax-lot columns.
Every command must work in desktop and web builds.
Suggested frontend adapter functions:
export async function listAllocationTargets(
scope?: TargetScope,
): Promise<AllocationTargetSummary[]>;
export async function getAllocationTarget(
targetId: string,
): Promise<AllocationTargetDetail>;
export async function saveAllocationTarget(
input: SaveAllocationTargetInput,
): Promise<AllocationTargetDetail>;
export async function archiveAllocationTarget(targetId: string): Promise<void>;
export async function getAllocationTargetDrift(
input: TargetDriftInput,
): Promise<TargetDriftReport>;
export async function calculateRebalancePlan(
input: CalculateRebalancePlanInput,
): Promise<RebalancePlan>;
export async function saveRebalanceDraft(
input: SaveRebalanceDraftInput,
): Promise<RebalanceDraft>;
export async function exportRebalancePlanCsv(
input: ExportRebalancePlanInput,
): Promise<ExportResult>;
Keep handlers thin:
V1 should use one main feature area, not three top-level pages.
Recommended navigation label:
Recommended layout:
Allocation Targets
Header
Scope selector
Allocation target selector
Max drift
Set targets
Plan rebalance
Main panel
Current vs target chart
Drift table
Holdings drilldown
Set targets panel
Target name
Scope
Taxonomy
Target rows
Drift band
Rebalance-to setting
Allow sells toggle
Minimum trade amount
Whole shares toggle
Plan rebalance drawer
Available cash
Scenario selector
Before/after allocation
Suggested manual trades
Warnings
Save draft
Export CSV
Show:
Rows must include:
| Column | Description |
|---|---|
| Sleeve | Category |
| Current | Current percent and value |
| Target | Target percent |
| Drift | Current - target |
| Status | In band, underweight, overweight |
Requirements:
Inputs:
Outputs:
Copy requirements:
interface AllocationTarget {
id: string;
name: string;
scopeType: "all" | "portfolio" | "account";
scopeId: string | null;
taxonomyId: string;
triggerType: "manual" | "threshold";
driftBandBps: number;
rebalanceGoal: "nearest_band" | "exact_target";
minTradeAmount: string;
wholeSharesOnly: boolean;
createdAt: string;
updatedAt: string;
archivedAt?: string | null;
}
interface AllocationTargetWeight {
id: string;
targetId: string;
categoryId: string;
targetBps: number;
isLocked: boolean;
isRequired: boolean;
createdAt: string;
updatedAt: string;
}
interface TargetDriftRow {
categoryId: string;
categoryName: string;
color: string;
currentBps: number;
targetBps: number;
driftBps: number;
currentValue: string;
targetValue: string;
valueDelta: string;
status: "in_band" | "underweight" | "overweight" | "not_targeted";
isRequired: boolean;
isZeroCurrent: boolean;
}
interface RebalancePlan {
targetId: string;
mode: "cash_flow_only" | "sell_to_rebalance";
availableCash: string;
cashUsed: string;
cashRemaining: string;
maxDriftBpsBefore: number;
maxDriftBpsAfter: number;
trades: SuggestedManualTrade[];
warnings: RebalanceWarning[];
}
interface SuggestedManualTrade {
action: "buy" | "sell";
accountId: string | null;
categoryId: string;
categoryName: string;
assetId: string | null;
symbol: string | null;
name: string | null;
quantity: string | null;
estimatedPrice: string | null;
estimatedAmount: string;
reason: string;
}
Target:
scope_id.scope_id.Targets:
Future planning:
current - target consistently.Rust unit tests:
Repository tests:
Frontend tests:
Manual QA:
When tax reporting and lot disposal ship, extend V1 rather than redesigning it.
Expected integration points:
Do not pre-add tax columns in V1 tables.